第二章 启动流程基础:嵌入式系统上电启动流程、ROM Code与BootROM、一级启动与二级启动
好,咱们正式开始聊启动流程。说实话,很多工程师写了几年嵌入式代码,对启动这块还是一知半解。我当年刚入行时也是这样,总觉得上电后程序就自动跑起来了,至于中间发生了什么,完全是个黑盒。直到有一次调试一个死活起不来的板子,才逼着我把这玩意儿彻底啃了一遍。
这一章,咱们就把嵌入式系统上电后那点事儿掰扯清楚。说白了,就是回答一个问题:芯片上电后,第一条指令从哪里来?
2.1 嵌入式系统上电启动流程
先看宏观流程。一个典型的嵌入式系统,从上电到用户程序运行,大致要经历这么几个阶段:
- 上电复位:电源稳定,复位信号释放
- 硬件初始化:CPU取复位向量,配置基本时钟、内存控制器
- BootROM执行:芯片内部固化的代码开始运行
- 一级启动:加载并验证下一级引导程序
- 二级启动:初始化外设,加载操作系统或应用程序
嗯,这里要注意:不同芯片的启动流程细节差异很大。比如有些MCU只有一级启动,直接就从Flash里跑用户代码了。而像i.MX、STM32MP1这类应用处理器,启动流程就复杂得多。
核心要点:启动流程的本质,是一个「信任链」的建立过程。每一级都负责验证下一级的完整性和合法性。
我个人习惯把启动流程比作「接力赛」——每一棒都有自己的任务,交接棒时必须确保安全。你想想看,如果第一棒就出了问题,后面跑得再快也没用。
2.2 ROM Code与BootROM
ROM Code,也叫BootROM,是芯片出厂时固化在内部ROM里的一段代码。这段代码不可修改,是芯片上电后执行的第一段程序。
BootROM到底干了些什么?我拆解一下:
- 最基本的硬件初始化:设置堆栈指针、配置PLL和时钟、初始化内存控制器
- 检测启动介质:根据Boot Pin的电平状态,判断从哪个设备启动(比如NAND Flash、SD卡、UART等)
- 加载一级启动程序:从选定的启动介质中读取固定大小的代码到SRAM中
- 跳转执行:将CPU控制权交给加载到SRAM中的代码
小提示:BootROM的大小通常只有几十KB到几百KB。我见过有些芯片的BootROM只有32KB,却要支持从6种不同的介质启动。代码密度极高,基本是手写汇编优化的。
这里有个关键点:BootROM是芯片厂商写的,用户改不了。所以它的功能必须足够通用,能适配各种启动场景。我曾经在一个项目里遇到过,某款芯片的BootROM在读取NAND Flash时,默认的ECC校验算法和我们的Flash不匹配,导致启动失败。折腾了两天才找到原因——最后只能换Flash型号。
避坑指南:BootROM的启动介质检测顺序是固定的。如果你的板子上同时接了SD卡和NAND Flash,一定要确认Boot Pin的电平配置正确。我曾经见过一个同事,因为Boot Pin的上下拉电阻焊错了,导致芯片每次都从空的SD卡启动,板子死活点不亮。
2.3 一级启动与二级启动
好,BootROM跑完了,接下来就是咱们开发者能控制的部分了。
一级启动(SPL / Preloader)
一级启动程序,通常叫SPL(Secondary Program Loader)或Preloader。它由BootROM加载到芯片内部的SRAM中执行。
一级启动的任务很明确:
- 初始化DDR内存:SRAM太小,跑不了复杂程序,必须先把DDR初始化好
- 加载二级启动程序:从启动介质中读取完整的Bootloader(如U-Boot)到DDR中
- 可选的安全校验:验证二级启动程序的签名或哈希值
一级启动的代码量非常有限。我记得在某个项目里,SPL被限制在64KB以内,还得包含DDR初始化代码和Flash驱动。写起来真是捉襟见肘,每一行汇编都得精打细算。
// 伪代码:一级启动的典型流程
void SPL_main(void) {
// 1. 初始化时钟和DDR
ddr_init();
// 2. 从Flash读取二级启动程序
// 通常读取到DDR的固定地址
read_flash(CONFIG_BOOT_DEVICE,
CONFIG_SPL_LOAD_ADDR,
CONFIG_SPL_SIZE);
// 3. 可选:验证签名
if (CONFIG_SECURE_BOOT) {
verify_signature(CONFIG_SPL_LOAD_ADDR);
}
// 4. 跳转到二级启动
jump_to(CONFIG_SPL_LOAD_ADDR);
}
二级启动(U-Boot / Bootloader)
二级启动程序,就是我们常说的Bootloader。在嵌入式Linux领域,最典型的就是U-Boot。
二级启动的功能就丰富多了:
- 完整的硬件初始化:网卡、USB、存储控制器等
- 文件系统支持:能从FAT、ext4等文件系统中读取内核镜像
- 网络启动:通过TFTP、NFS等方式加载内核
- 交互式命令行:方便调试和配置
- 加载并启动操作系统:将内核和设备树文件加载到内存,然后跳转
关键区别:一级启动在SRAM中运行,代码量小,功能单一;二级启动在DDR中运行,功能完整,可交互。
为什么要把启动分成两级?说白了,就是成本与灵活性的权衡。芯片内部的SRAM很贵,不可能做得太大。但Bootloader又需要足够的功能来加载复杂的操作系统。所以折中的方案就是:先用一个小巧的一级启动把DDR初始化好,再把完整的Bootloader加载到DDR里运行。
我做过一个项目,因为DDR初始化参数配置错了,导致二级启动加载到DDR后运行就崩溃。调试这种问题特别痛苦——因为一级启动阶段还没有串口输出,你根本不知道DDR初始化是否成功。后来我学乖了,在一级启动里加了一个LED闪烁的调试手段,至少能确认DDR初始化那一步有没有跑过。
| 特性 | 一级启动(SPL) | 二级启动(U-Boot) |
|---|---|---|
| 运行位置 | 芯片内部SRAM | 外部DDR内存 |
| 代码大小 | 通常 < 128KB | 通常 256KB ~ 2MB |
| 功能复杂度 | 简单,仅初始化DDR和加载 | 完整,支持文件系统、网络等 |
| 可修改性 | 可修改,但受限于SRAM大小 | 可修改,功能丰富 |
| 安全校验 | 通常校验二级启动 | 校验内核和文件系统 |
嗯,这里再补充一点。有些芯片支持三级启动,比如在SPL和U-Boot之间再加一个阶段。但大多数嵌入式系统,两级启动就足够了。我个人建议,除非有特殊需求(比如需要极小的安全校验代码),否则不要轻易增加启动级数——每多一级,就多一个故障点,调试起来也更麻烦。
经验之谈:调试启动问题时,我建议先从最简单的配置开始。比如先用串口下载模式(如果芯片支持的话),确认DDR和串口能正常工作。然后再逐步增加Flash读取、文件系统支持等功能。这样出了问题,你能快速定位到是哪一步引入的。
最后总结一下:启动流程就是一场接力赛。BootROM是第一棒,负责把SPL请进SRAM;SPL是第二棒,负责把DDR初始化好,再把U-Boot请进DDR;U-Boot是第三棒,负责把内核请进内存并启动。每一棒都只做自己该做的事,然后把控制权交给下一棒。理解了这一点,你就掌握了嵌入式系统启动的精髓。