第二章 启动流程基础:嵌入式系统上电启动流程、ROM Code与BootROM、一级启动与二级启动

好,咱们正式开始聊启动流程。说实话,很多工程师写了几年嵌入式代码,对启动这块还是一知半解。我当年刚入行时也是这样,总觉得上电后程序就自动跑起来了,至于中间发生了什么,完全是个黑盒。直到有一次调试一个死活起不来的板子,才逼着我把这玩意儿彻底啃了一遍。

这一章,咱们就把嵌入式系统上电后那点事儿掰扯清楚。说白了,就是回答一个问题:芯片上电后,第一条指令从哪里来?

2.1 嵌入式系统上电启动流程

先看宏观流程。一个典型的嵌入式系统,从上电到用户程序运行,大致要经历这么几个阶段:

  1. 上电复位:电源稳定,复位信号释放
  2. 硬件初始化:CPU取复位向量,配置基本时钟、内存控制器
  3. BootROM执行:芯片内部固化的代码开始运行
  4. 一级启动:加载并验证下一级引导程序
  5. 二级启动:初始化外设,加载操作系统或应用程序

嗯,这里要注意:不同芯片的启动流程细节差异很大。比如有些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是第三棒,负责把内核请进内存并启动。每一棒都只做自己该做的事,然后把控制权交给下一棒。理解了这一点,你就掌握了嵌入式系统启动的精髓。