找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

1459

积分

0

好友

187

主题
发表于 2026-2-12 06:45:25 | 查看: 27| 回复: 0

在嵌入式Linux开发的面试中,一个常见的问题是:“按下电源键到内核跑起来,中间发生了什么?” 很多人的回答停留在“先运行Bootloader,再加载内核”的层面。但如果继续追问:内核加载到哪儿了?是加载到RAM里吗?为什么不直接从Flash里运行?MMU什么时候打开的?设备树是什么时候加载的?不少工程师的答案就开始变得模糊。

这些看似基础的知识点,实则隐藏着系统启动的诸多关键细节。本文将以瑞芯微RK3588这款高性能SoC为例,为你深入剖析从芯片上电到Linux内核运行的完整技术流程。

芯片上电与第一级引导

当你按下电源键时,芯片内部会经历一系列精密的初始化过程。首先,电源管理单元启动,系统时钟开始工作。紧接着,芯片会执行一段出厂时就被固化在硅片内部的代码,我们称之为一级BootloaderROM Bootloader,它不可修改。

以RK3588为例,其上电后的ROM代码会执行以下关键操作:

  1. 检测启动介质:芯片支持从eMMC、SD卡、SPI NAND/NOR Flash等多种存储介质启动。ROM代码会按照预设的优先级顺序(例如先SD卡,后eMMC)逐一尝试检测。
  2. 读取引导程序:从检测到的存储介质的特定位置读取第二级引导程序。例如,对于eMMC,通常从第64个扇区开始读取;对于SPI Flash,则从0x8000偏移处读取。
  3. 校验与跳转:对读取到的引导程序镜像进行CRC32等校验,验证通过后,将其加载到芯片内部的SRAM(静态随机存储器)中,然后跳转到SRAM中的入口地址开始执行。

RK3588的启动流程(第一阶段)可以概括为:

上电 → ROM代码执行
       ↓
检测启动介质(eMMC/SD卡/SPI Flash)
       ↓
从对应位置读取Bootloader(如Rockchip Miniloader)
       ↓
校验CRC32
       ↓
跳转到SRAM中执行第二级Bootloader

由于RK3588的SRAM容量有限(通常只有几百KB),不足以容纳完整的U-Boot,因此芯片厂商设计了一个精简的引导程序(如Rockchip的Miniloader)。这个Miniloader的主要任务是初始化至关重要的外部DDR内存,为加载完整、功能丰富的U-Boot做好准备。

一个典型的嵌入式系统启动架构包含了SoC(片上系统)、外部Flash和外部RAM三部分。

  • 外部Flash(如eMMC/NAND/QSPI Flash/SD卡)中存储了SPL(Secondary Program Loader,在某些架构中相当于Miniloader)、完整的U-Boot以及Linux内核镜像。
  • SoC内部则包含了不可更改的ROM(内含BootROM)和容量较小的SRAM。
  • 外部RAM(如SDRAM/DDR)是系统运行时的主内存。

其启动流程遵循以下五个步骤:

  1. SoC内部的BootROM从外部Flash中加载SPL(Miniloader)到SRAM。
  2. SPL初始化外部RAM,并将完整的U-Boot从外部Flash加载到外部RAM中。
  3. U-Boot开始执行,它可以从外部Flash或RAM中加载Linux内核。
  4. 内核被加载并开始运行。
  5. 值得一提的是,U-Boot在运行时具备重新从外部存储加载内核的能力。

第二级Bootloader:U-Boot的使命

U-Boot是嵌入式Linux领域事实标准的Bootloader。当Miniloader将其加载到DDR并跳转执行后,U-Boot便开始接管系统。此时,CPU处于特权模式(如ARM的SVC模式),MMU尚未开启,所有寻址操作均基于物理地址。

U-Boot的启动可以分为几个主要阶段:

1. 汇编初始化阶段 (board_init_f)
此阶段由汇编代码完成,为后续C语言运行环境铺路:

  • 关闭中断和缓存。
  • 初始化关键CPU寄存器,如栈指针(SP)。
  • 检测CPU类型和版本。
  • 初始化系统时钟,对于RK3588,需要配置复杂的PLL锁相环以获得核心运行频率。
  • 初始化DRAM控制器:这是最关键的一步,RK3588支持LPDDR4/LPDDR5,初始化过程涉及复杂的时序参数校准。
  • 清空BSS段(未初始化的全局变量区)。

2. 重定位阶段
DRAM初始化完成后,U-Boot会将自身从较慢的Flash中重定位到DDR的高地址区域(例如RK3588上的0x4FFE0000附近)。这样,后续的代码执行速度将得到质的飞跃。

3. C语言主循环阶段 (board_init_r)
进入C语言环境后,U-Boot会依次初始化各个子系统:

  • 初始化串口控制台,此时我们才能在终端看到U-Boot的启动信息。
  • 初始化存储设备驱动(如eMMC、SD卡),以便读取内核等镜像。
  • 初始化USB子系统,可能用于USB烧录或网络启动。
  • 初始化网络设备(以太网、Wi-Fi),支持tftp网络加载内核。
  • 最后,加载Linux内核镜像与设备树文件。

board_init_r函数中,初始化过程是模块化的、顺序执行的,其核心流程如下:

  1. 执行board_init_r,这是板级初始化的入口。
  2. 调用initr_dm,初始化驱动模型,扫描设备树节点并将其与对应的驱动程序绑定。
  3. 执行一系列其他初始化函数(用...表示)。
  4. 调用initr_net,进行网络接口的初始化。
  5. 继续执行其他初始化。
  6. 最终进入run_main_loop,执行U-Boot的主循环,等待用户命令或执行自动启动脚本。

在RK3588平台上,成功的DRAM初始化会在串口输出类似信息:

DDR Version 1.08 20221121
LPDDR4X, 2112MHz
BW=32 Col=10 Bk=8 CS0 Row=16 CS=1 Die BW=16 Size=4096M
...
U-Boot 2022.11-rk (Jan 20 2026 - 12:00:00)
Model: Rockchip RK3588 Evaluation Board
DRAM:  4 GiB

设备树(DTS)的加载时机与传递

设备树(.dtb文件)并非内核的一部分,它是由Bootloader负责加载到内存中,并通过约定好的协议将地址传递给内核的。

在RK3588的典型系统中,eMMC的存储布局可能如下:

0x00000 ─────────────────────
         Bootloader (miniloader)
0x20000 ─────────────────────
         U-Boot镜像
0x80000 ─────────────────────
         环境变量区
0x100000 ────────────────────
         内核镜像(Image)
0x400000 ────────────────────
         设备树文件 (rk3588-evb.dtb)
0x500000 ────────────────────
         根文件系统

U-Boot使用以下命令(或配置在bootcmd环境变量中自动执行)来加载并启动内核:

load mmc 0 0x50000000 Image
load mmc 0 0x5f000000 rk3588-evb.dtb
bootm 0x50000000 - 0x5f000000

关键点在于bootm命令最后一个参数0x5f000000,它就是设备树在内存中的地址。U-Boot在跳转到内核入口前,会按照ARM的启动协议设置好寄存器:

  • R0:通常为0。
  • R1:机器类型ID(旧式ATAGS方式使用,现较少用)。
  • R2设备树二进制文件(DTB)在物理内存中的起始地址

内核启动后,最先运行的汇编代码会从R2寄存器中获取设备树地址,并开始解析。U-Boot中负责准备并传递这些启动参数的函数是do_bootm_linux。以下是其关键部分的代码片段,展示了如何处理机器ID和准备跳转:

int do_bootm_linux(int flag, int argc, char *argv[], bootm_headers_t *images)
{
    bd_t *bd = gd->bd;
    char *s;
    int machid = bd->bi_arch_number; // 默认机器ID
    void (*theKernel)(int zero, int arch, uint params);

    // ... 省略部分检查和预处理代码 ...

    theKernel = (void (*)(int, int, uint))images->ep; // 内核入口地址

    // 检查环境变量中是否覆盖了机器ID
    s = env_get("machid");
    if (s) {
        machid = simple_strtoul(s, NULL, 16);
        printf("Using machid 0x%lx from environment\n", machid);
    }

    debug("## Transferring control to Linux (at address %08lx) ...\n", (ulong)theKernel);

    // 处理设备树(FDT)
#ifdef CONFIG_OF_LIBFDT
    if (IMAGE_ENABLE_OF_LIBFDT && images->ft_len) {
        debug("using FDT\n");
        if (image_setup_linux(images)) {
            printf("FDT creation failed! hanging...");
            hang();
        }
    }
#endif

    // 设置启动参数标签(ATAGS,传统方式,与FDT互斥)
    if (!defined(CONFIG_SETUP_MEMORY_TAGS) ||
        defined(CONFIG_CMDLINE_TAG) ||
        defined(CONFIG_INITRD_TAG) ||
        defined(CONFIG_SERIAL_TAG) ||
        defined(CONFIG_REVISION_TAG)) {
        setup_start_tag(bd);
#ifdef CONFIG_SERIAL_TAG
        setup_serial_tag(¶ms);
#endif
        // ... 其他标签设置 ...
#ifdef CONFIG_SETUP_MEMORY_TAGS
        setup_memory_tags(bd); // 设置内存信息标签
#endif
    }
    // ... 后续跳转代码 ...
}

在完成所有启动参数准备后,最终跳转到内核的代码如下:

    /* we assume that the kernel is in place */
    printf("\nStarting kernel ...\n\n");

#ifdef CONFIG_USB_DEVICE
    {
        extern void udc_disconnect(void);
        udc_disconnect();
    }
#endif

    cleanup_before_linux(); // 跳转前的最后清理

    // 跳转到内核,传递参数:R0=0, R1=machid, R2=设备树地址(如果使用FDT)
    if (IMAGE_ENABLE_OF_LIBFDT && images->ft_len)
        theKernel(0, machid, (unsigned long)images->ft_addr);
    else
        theKernel(0, machid, bd->bi_boot_params);

    /* does not return */
    return 1;

设备树文件本身包含了对硬件的描述。例如,以下是定义启动顺序和串口输出的配置片段:

/ {
    aliases {
        mmc0 = &sdmmc;
        mmc1 = &sdhci;
    };
    chosen {
        stdout-path = &uart2; // 指定标准输出为uart2
        u-boot,spl-boot-order = &sdmmc, &sdhci, &spi_nand, &spi_nor; // SPL启动顺序
    };
    // ... 其他节点 ...
};

uart2的具体硬件描述如下:

uart2: serial@feb50000 {
    compatible = "rockchip,rk3588-uart", "snps,dw-apb-uart";
    reg = <0x0 0xfeb50000 0x100>; // 寄存器基地址和长度
    interrupts = <GIC_SPI 333 IRQ_TYPE_LEVEL_HIGH>;
    clocks = <&cru SCLK_UART2>, <&cru PCLK_UART2>;
    clock-names = "baudclk", "apb_pclk";
    reg-shift = <2>;
    reg-io-width = <4>;
    dmas = <&dmac0 10>, <&dmac0 11>;
    pinctrl-names = "default";
    pinctrl-0 = <&uart2m_xfer>;
    status = "disabled";
};

MMU的开启时机

一个关键的问题是:MMU(内存管理单元)在什么时候被打开?
答案是:在Linux内核自身启动的早期,由内核代码打开的,而不是在U-Boot中。

具体流程如下:

  1. 内核入口(汇编代码):CPU从U-Boot跳转至此,MMU仍处于关闭状态。
  2. 解析启动参数:读取ATAGS或设备树,获取内存布局等信息。
  3. 初始化页表:在内存中建立最初的虚拟地址到物理地址的映射关系。这里有一个精妙的设计:为了在打开MMU的瞬间不会导致程序“跑飞”,内核会建立一个恒等映射,即让一段物理地址和虚拟地址相同。这样,即使打开MMU,正在执行的代码地址(物理地址)也能被正确映射,保证指令流不中断。
  4. 开启MMU和缓存:执行完上述准备后,正式开启MMU。从此,CPU访问的都是虚拟地址。
  5. 跳转到虚拟地址空间的高端:内核通常会将自己映射到虚拟地址空间的高端(如0xC0000000)。在恒等映射的“保护”下,内核代码可以安全地切换到高地址运行。
  6. 进入C语言主函数:最终调用start_kernel(),进入内核初始化主流程。

Initrd与Initramfs的作用

有时我们会看到内核启动信息中包含“Initrd”或“Initramfs”,这是一个解决“鸡生蛋蛋生鸡”问题的巧妙设计。内核需要挂载根文件系统(例如在eMMC的某个分区),但访问存储设备需要驱动,而驱动模块又存放在根文件系统中。为了解决这个依赖,Bootloader会将一个初始的、压缩的内存文件系统镜像加载到RAM中。

内核启动后,首先将这个内存文件系统解压并挂载为一个临时的根文件系统。在这个临时系统中,包含了必要的驱动程序(如eMMC驱动、文件系统驱动)和工具。内核利用这些驱动,再去访问真正的物理存储设备,挂载最终的根文件系统,并切换到其上运行。

常见启动问题与排查思路

在实际开发中,启动过程常会遇到各种问题,以下是一些典型场景及排查方法:

问题1:U-Boot卡在DRAM初始化阶段

  • 现象:串口只打印出DDR版本和频率信息,随后没有任何输出,系统卡住。
  • 分析:这通常是DRAM初始化失败。RK3588的LPDDR4X/LPDDR5对时序、电源完整性非常敏感。
  • 排查
    1. 检查硬件:内存芯片焊接、电源纹波。
    2. 核对U-Boot中对应板级的DDR初始化参数(时序表)是否正确。
    3. 尝试降低DRAM运行频率测试。

问题2:内核启动后无串口输出

  • 现象:U-Boot阶段串口正常,但内核启动后无任何printk信息。
  • 分析:内核使用的串口配置与U-Boot或硬件实际连接不符。问题出在设备树或内核命令行参数。
  • 排查
    1. 确认设备树chosen节点下的stdout-path属性指向正确的UART节点(如前文的&uart2)。
    2. 确认该UART节点(如uart2)的reg(寄存器地址)、pinctrl(引脚复用)配置与原理图一致。
    3. 检查内核命令行参数console=是否与stdout-path指定的串口设备名匹配。

问题3:内核Panic

  • 现象:内核启动过程中发生致命错误,打印出调用栈后停止。
  • 分析:原因多样,可能涉及驱动初始化失败、内存访问越界、设备树节点解析错误等。
  • 排查
    1. 仔细阅读Panic信息:找到出错的具体函数和行号(如果内核包含调试符号)。
    2. 检查设备树:特别是内存节点、中断控制器(GIC)节点、出问题外设的节点描述是否准确。
    3. 对于RK3588,需注意其集成的复杂IP(如GPU、NPU、PCIe)驱动初始化是否正常。

RK3588完整启动时间线示例

以下是一个典型的RK3588系统从冷启动到进入用户空间的简化时间线:

  • T=0-5ms:电源稳定,芯片内部ROM代码运行,从eMMC加载Miniloader到SRAM。
  • T=5-50ms:Miniloader执行,初始化LPDDR4X内存,并将完整U-Boot加载至DDR。
  • T=50-150ms:U-Boot执行,初始化各子系统,从eMMC加载内核(Image)和设备树(.dtb)到DDR指定地址。
  • T=150-160ms:U-Boot跳转至内核入口地址。
  • T=160-220ms:内核汇编阶段:验证设备树,创建初始页表,开启MMU。
  • T=220-400ms:内核C语言初始化:打印版本信息,解析命令行,初始化中断、时钟、内存管理等核心子系统。
  • T=400-800ms:内核驱动初始化:依次初始化平台设备、PCIe、GPU、NPU、各种总线上的设备驱动。
  • T=800-1200ms:挂载根文件系统,启动第一个用户空间进程init,系统启动完成。

整个过程通常在一到两秒内完成,具体时间受存储速度、内核配置、驱动初始化复杂度影响。

理解从Bootloader到内核的完整启动链,是进行嵌入式Linux深度开发、性能优化和故障排查的基石。它不仅仅是一系列步骤的集合,更是对计算机基础中硬件与软件如何协同工作的生动诠释。希望本文的解析能帮助你构建起更清晰、更深入的系统启动认知框架。

参考资料

[1] Bootloader从启动到内核记录, 微信公众号:mp.weixin.qq.com/s/epKZhsIj7pEwJ74ClIXluA

版权声明:本文由 云栈社区 整理发布,版权归原作者所有。




上一篇:Ubuntu Server 22.04 安装时锁定内核:桥接IP+NAT网络的虚拟机配置法
下一篇:JS Bin挂了:1GB内存的老服务器遭遇千万级爬虫攻击与三天救援实录
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-2-23 14:18 , Processed in 0.505334 second(s), 40 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表