2、嵌入式系统启动流程:从复位向量到操作系统加载的完整链路、ROM Code与SPL/TPL的概念

大家好,我是你们的老朋友。今天咱们来聊聊嵌入式系统上电后,到底发生了什么。很多初学者觉得启动就是“上电→跑系统”,其实中间藏着好几道关卡。我当年第一次调板子,灯不亮、串口没输出,折腾了两天才发现是ROM Code阶段就把时钟配错了。嗯,从那以后,我再也不敢小看这个“看不见的启动链”了。

2.1 复位向量:系统苏醒的第一行代码

CPU上电或复位后,PC指针会跳到一个固定地址——这就是复位向量。对于ARM Cortex-A系列,通常是0x00000000或0xFFFF0000;对于RISC-V,一般是0x80000000附近。说白了,这就是系统醒来后读的第一条指令。

核心要点:复位向量里通常放一条跳转指令,直接跳到初始化代码入口。不要在这里放复杂逻辑,只做一件事:跳转。

你想想看,此时RAM还没初始化,堆栈也没设,能用的只有内部SRAM或寄存器。所以复位向量代码必须极其精简。我个人习惯在汇编阶段就把异常向量表也一并建好,省得后面再补。

2.2 ROM Code:芯片出厂就写好的“第一级引导”

现代SoC内部都固化了一段ROM Code,也叫BootROM。这是芯片出厂时写死的,用户改不了。它的任务很简单:检测启动介质,把下一级代码加载到SRAM里。

ROM Code会依次检查哪些启动源?我整理了一个表格,大家看看:

启动源 典型存储介质 ROM Code读取方式
eMMC / NAND Flash 板载闪存 通过SD/MMC控制器或NAND控制器读取
SPI NOR Flash 串行Flash 通过SPI接口读取
UART / USB 调试串口或USB线 下载模式,用于烧录或调试
SD卡 外部存储卡 通过SDIO接口读取

ROM Code会按预设优先级轮询这些介质。找到第一个合法的启动头后,就把数据拷贝到内部SRAM,然后跳转过去。我在项目中遇到过一个问题:某款芯片的ROM Code只支持从eMMC的特定偏移地址读取,结果我们分区表没对齐,死活起不来。后来查手册才发现,偏移必须是0x200的倍数。

避坑指南:ROM Code对启动介质有严格的时序要求。我曾经因为eMMC的初始化延迟太长,导致ROM Code超时退出,直接进了下载模式。解决办法是在硬件设计时确保eMMC上电时序满足SoC要求。

2.3 SPL与TPL:把引导拆成两段,为什么?

早期U-Boot是单阶段引导:ROM Code直接加载整个U-Boot到RAM。但后来芯片内部SRAM越来越小(通常只有几十KB到几百KB),而U-Boot越来越大(动辄几百KB甚至几MB)。怎么办?

答案就是分阶段引导。U-Boot引入了SPL(Secondary Program Loader)和TPL(Tertiary Program Loader)的概念。说白了,就是把一个大任务拆成两三个小步骤,每步只做必要的事。

2.3.1 SPL:第二级引导,初始化DDR

SPL是ROM Code加载的第一个用户程序。它体积很小,通常只有几十KB,放在内部SRAM里运行。SPL的核心任务就两个:

  • 初始化DDR控制器——把外部DRAM配好,让它能用
  • 加载完整的U-Boot——从Flash或SD卡把U-Boot主体读到DDR里

SPL代码必须精简,不能带太多驱动。我习惯在SPL阶段只配时钟、DDR和必要的存储控制器,其他外设一律不管。你想想看,SRAM就那么点空间,塞不下完整U-Boot的驱动框架。

2.3.2 TPL:第三级引导,可选的中转站

TPL是更极端的情况。有些SoC的SRAM实在太小(比如只有16KB),连SPL都放不下。那就再加一级:TPL先初始化一个小容量的SRAM或缓存,然后加载SPL,SPL再初始化DDR,最后加载U-Boot。

我整理了一个对比表,方便大家理解:

阶段 运行位置 典型大小 主要职责
ROM Code 芯片内部ROM 几十KB 检测启动介质,加载SPL/TPL到SRAM
TPL 内部SRAM 8~16KB 初始化SRAM或缓存,加载SPL
SPL 内部SRAM 32~128KB 初始化DDR,加载U-Boot到DDR
U-Boot 外部DDR 几百KB~几MB 完整引导,加载操作系统

个人经验:如果你的SoC内部SRAM大于128KB,通常不需要TPL。直接ROM Code→SPL→U-Boot就够了。TPL只在极端资源受限的场景下才用,比如某些低功耗MCU级别的SoC。

2.4 完整启动链路:一张图看懂

下面这张SVG图把整个启动流程串起来了。从复位向量到操作系统加载,每一步的载体和位置都标清楚了。

嵌入式系统完整启动链路 复位向量 ROM Code (BootROM) TPL(可选) SPL U-Boot 操作系统 (Linux/RTOS) 运行位置:芯片内部ROM 用户不可修改 运行位置:内部SRAM 初始化DDR 运行位置:外部DDR 加载操作系统

从这张图可以清楚看到:复位向量→ROM Code→(可选TPL)→SPL→U-Boot→操作系统。每一步的载体和运行位置都不同。ROM Code在芯片内部ROM里跑,SPL在内部SRAM里跑,U-Boot和操作系统在外部DDR里跑。说白了,这就是一个“从小内存到大内存”的接力过程。

2.5 启动介质的选择与优先级

ROM Code怎么知道从哪个设备启动?通常由启动引脚或eFuse决定。芯片复位时,ROM Code会读取这些硬件配置,然后按优先级列表尝试。

我遇到过最坑的情况是:某款芯片的启动引脚默认电平是“从SD卡启动”,但我们板子上没焊SD卡座。结果ROM Code一直尝试读SD卡,超时后才切到eMMC,导致启动慢了将近2秒。后来我们在硬件设计时加了下拉电阻,把默认启动源改成了eMMC。

关键点:启动介质的选择不是软件能完全控制的。硬件设计时就要确认启动引脚的默认电平,否则后面改起来很麻烦。

2.6 从U-Boot到操作系统:最后的交接

U-Boot加载到DDR后,会完成更复杂的初始化:文件系统、网络、显示等。然后根据bootcmd环境变量,从指定介质读取操作系统镜像(如Linux的zImage或Image),跳转到内核入口。

这里有个细节:U-Boot跳转前要准备好启动参数(ATAG或设备树),告诉内核内存布局、控制台、根文件系统位置等信息。我刚开始做移植时,经常因为设备树地址没对齐,导致内核启动到一半就挂掉。后来养成习惯:每次跳转前都打印一下设备树地址,确认它在DDR范围内且4字节对齐。

个人习惯:在U-Boot的bootm命令执行前,我会加一句printenv和fdt addr,确认环境变量和设备树都正确。这个习惯帮我省了不少调试时间。