4. Bootloader启动流程:上电复位、CPU初始化、内存初始化、设备树加载、内核启动

说实话,Bootloader 的启动流程,说白了就是给硬件「梳妆打扮」,最后把内核请进家门的过程。我做了这么多年嵌入式,见过太多板子卡在某个环节不动弹,最后发现都是这几个步骤里出了岔子。今天咱们就把这五个阶段掰开揉碎了讲清楚。

核心要点:Bootloader 启动流程分为五个不可跳过的阶段——上电复位 → CPU 初始化 → 内存初始化 → 设备树加载 → 内核启动。每个阶段都有坑,跳过任何一个,系统都跑不起来。

Bootloader 启动流程五阶段 Bootloader 启动流程五阶段 上电复位 CPU 初始化 内存初始化 设备树加载 内核启动 关键说明: • 上电复位:CPU 从复位向量取第一条指令,通常位于 ROM 或 Flash 起始地址。 • CPU 初始化:设置异常向量、栈指针、关闭 MMU 和 Cache,为 C 环境做准备。 • 内存初始化:配置 DDR 控制器时序,完成内存训练,使能内存空间。

4.1 上电复位:一切从这里开始

按下电源键那一刻,CPU 的复位引脚被拉高,内部逻辑强制将程序计数器(PC)指向一个固定地址——这就是复位向量。对于 ARM Cortex-A 系列,通常是 0x00000000 或 0xFFFF0000;对于 RISC-V,则是 0x80000000 附近。

嗯,这里有个细节很多人会忽略:复位向量指向的必须是非易失性存储器,比如 ROM 或 NOR Flash。我遇到过一块板子,设计时把复位向量指向了尚未初始化的 DDR,结果上电就死,查了三天才发现是硬件设计问题。

我的习惯:在复位向量处放一条无条件跳转指令,而不是直接放代码。这样后续如果要调整启动地址,只需要改跳转目标,不用动整个镜像布局。

4.2 CPU 初始化:给处理器「立规矩」

CPU 刚上电时,内部寄存器处于随机状态,Cache 和 MMU 默认是开启的(有些架构是关闭的)。这时候必须做几件事:

  • 设置异常向量表——告诉 CPU 发生中断、异常时跳到哪里。
  • 初始化栈指针——没有栈,C 语言函数调用就是空谈。
  • 关闭 MMU 和 Cache——在内存初始化完成前,虚拟地址和缓存只会添乱。
  • 配置时钟和 PLL——让 CPU 跑在目标频率上。

我曾经在一个项目中,因为忘记关闭指令 Cache,导致从 Flash 读取的指令被缓存污染,程序跑飞得莫名其妙。从那以后,我写 CPU 初始化代码时,第一件事就是 flush 并 disable Cache。

/* ARM Cortex-A7 典型 CPU 初始化片段 */
reset_handler:
    /* 设置栈指针 */
    ldr sp, =_stack_top

    /* 关闭 MMU */
    mrc p15, 0, r0, c1, c0, 0
    bic r0, r0, #0x1
    mcr p15, 0, r0, c1, c0, 0

    /* 关闭 I-Cache 和 D-Cache */
    mrc p15, 0, r0, c1, c0, 0
    bic r0, r0, #(1 << 12)  /* I-Cache */
    bic r0, r0, #(1 << 2)   /* D-Cache */
    mcr p15, 0, r0, c1, c0, 0

    /* 跳转到 C 语言入口 */
    bl main

注意:关闭 MMU 前,确保当前代码运行在物理地址空间。如果 MMU 开启时你正在访问虚拟地址,关闭后 CPU 会直接访问物理地址,很可能触发异常。

4.3 内存初始化:让 DDR 能干活

这一步是 Bootloader 里最「硬」的部分。DDR 控制器需要精确的时序参数,包括 CAS 延迟、刷新周期、ODT 配置等。说白了,你得告诉 DDR 颗粒怎么工作。

我习惯把内存初始化分成三步:

  1. 配置控制器寄存器——设置时钟频率、数据宽度、突发长度。
  2. 执行内存训练——校准读写时序,补偿 PCB 走线延迟。
  3. 验证内存可用——写一个模式再读回来,确认无误。

你想想看,如果内存初始化失败,后面所有代码都无法运行。我见过一个案例,某款 DDR4 颗粒需要特殊的 ZQ 校准序列,开发人员漏掉了这一步,结果 10 块板子里有 3 块启动不稳定。

参数 典型值 说明
CAS 延迟 (CL) 11 列地址选通延迟,影响读写速度
刷新周期 (tREFI) 7.8 μs DDR 需要定期刷新保持数据
ODT 阻抗 60 Ω 片上端接,减少信号反射
数据线训练 自动校准 补偿各数据线之间的延迟差异

避坑指南:我曾经在调试一块 8 层 PCB 的板子时,发现内存训练总是失败。后来用示波器量了 DQS 信号,发现走线等长没做好。硬件改版后问题解决。所以,内存初始化失败不一定是软件问题,先检查硬件设计。

4.4 设备树加载:给内核一张「硬件地图」

设备树(Device Tree)说白了就是一份描述硬件拓扑的配置文件。内核不再硬编码硬件信息,而是通过 Bootloader 传递的设备树来了解:CPU 有几个核、外设挂在哪里、中断号是多少。

加载设备树的流程大致如下:

  • 从存储介质(Flash、SD 卡、网络)读取 .dtb 文件。
  • 将其复制到内存中的特定地址(通常是 0x40000000 附近)。
  • 如果需要,可以修改设备树中的某些节点(比如内存大小、MAC 地址)。
  • 将设备树地址通过寄存器或约定位置传递给内核。

我建议在加载设备树后做一次 CRC 校验。有一次我在项目中用了损坏的 DTB 文件,内核启动到一半就 panic 了,提示 OF: fdt: not found。加上校验后,这类问题一眼就能发现。

/* 设备树加载伪代码 */
void load_device_tree(void) {
    uint32_t dtb_addr = 0x48000000;

    /* 从 Flash 偏移 0x100000 处读取 DTB */
    flash_read(0x100000, dtb_addr, DTB_MAX_SIZE);

    /* 校验 FDT 魔术字 */
    if (*(uint32_t *)dtb_addr != 0xD00DFEED) {
        panic("Invalid DTB magic!");
    }

    /* 可选:修改内存节点 */
    fdt_setprop_u32(dtb_addr, "/memory", "reg", 0x80000000, 0x20000000);

    /* 将地址存入寄存器 r2(ARM 约定) */
    __asm__ volatile ("mov r2, %0" : : "r"(dtb_addr));
}

小技巧:如果你在调试阶段频繁修改设备树,可以把 DTB 放在内存的固定位置,用 Bootloader 命令手动加载,不用每次都烧写 Flash。我习惯留一个 0x50000000 的缓冲区专门干这事。

4.5 内核启动:最后的交接

所有准备工作就绪后,Bootloader 要做最后一件事——跳转到内核入口。对于 ARM Linux,这通常意味着:

  1. 设置寄存器 r0 = 0(表示是 ATAG 或设备树启动方式)。
  2. 设置寄存器 r1 = 机器类型 ID(老内核用,新内核已废弃)。
  3. 设置寄存器 r2 = 设备树地址。
  4. 关闭中断,确保 CPU 处于 SVC 模式。
  5. 跳转到内核入口地址(通常是 0x80008000)。

跳转之后,Bootloader 就「功成身退」了。内核会接管所有硬件资源,包括之前 Bootloader 初始化的内存、时钟、串口等。

重要提醒:跳转到内核前,必须关闭 Bootloader 自身使用的所有中断。否则内核启动过程中,一个未处理的中断可能让系统直接挂掉。我见过有人忘了关定时器中断,内核刚解压完就被打断,反复重启。

/* 跳转到内核入口 */
void jump_to_kernel(uint32_t kernel_addr, uint32_t dtb_addr) {
    /* 关闭所有中断 */
    __asm__ volatile ("cpsid if");

    /* 设置内核启动参数 */
    __asm__ volatile (
        "mov r0, #0\n\t"
        "mov r1, #0xFFFFFFFF\n\t"  /* 机器类型 ID,新内核忽略 */
        "mov r2, %0\n\t"
        "bx  %1\n\t"
        :
        : "r"(dtb_addr), "r"(kernel_addr)
        : "r0", "r1", "r2"
    );

    /* 不会返回 */
    while (1);
}

嗯,整个流程走下来,你会发现 Bootloader 其实就是一个「硬件初始化 + 参数传递」的桥梁。每个阶段都有它的坑,但只要按部就班,把每一步的细节抠到位,系统就能稳稳地跑起来。