4. Bootloader启动流程:上电复位、CPU初始化、内存初始化、设备树加载、内核启动
说实话,Bootloader 的启动流程,说白了就是给硬件「梳妆打扮」,最后把内核请进家门的过程。我做了这么多年嵌入式,见过太多板子卡在某个环节不动弹,最后发现都是这几个步骤里出了岔子。今天咱们就把这五个阶段掰开揉碎了讲清楚。
核心要点:Bootloader 启动流程分为五个不可跳过的阶段——上电复位 → CPU 初始化 → 内存初始化 → 设备树加载 → 内核启动。每个阶段都有坑,跳过任何一个,系统都跑不起来。
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 颗粒怎么工作。
我习惯把内存初始化分成三步:
- 配置控制器寄存器——设置时钟频率、数据宽度、突发长度。
- 执行内存训练——校准读写时序,补偿 PCB 走线延迟。
- 验证内存可用——写一个模式再读回来,确认无误。
你想想看,如果内存初始化失败,后面所有代码都无法运行。我见过一个案例,某款 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,这通常意味着:
- 设置寄存器
r0 = 0(表示是 ATAG 或设备树启动方式)。 - 设置寄存器
r1 = 机器类型 ID(老内核用,新内核已废弃)。 - 设置寄存器
r2 = 设备树地址。 - 关闭中断,确保 CPU 处于 SVC 模式。
- 跳转到内核入口地址(通常是
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 其实就是一个「硬件初始化 + 参数传递」的桥梁。每个阶段都有它的坑,但只要按部就班,把每一步的细节抠到位,系统就能稳稳地跑起来。