3. U-Boot启动流程(上):从ROM Code到SPL
好,咱们今天聊聊U-Boot的启动流程。说实话,很多刚接触嵌入式Linux的工程师,一上来就被各种启动阶段搞懵了——ROM Code、SPL、U-Boot proper,到底谁先谁后?各自干了什么?
我当年第一次调一块i.MX6的板子,看着串口只打印出一个'U'字就卡住了,折腾了两天才发现是SPL阶段DDR初始化没搞好。从那以后,我就养成了一个习惯:先把启动流程吃透,再动手调板子。
3.1 为什么需要多阶段启动?
你可能会问:为什么不能像PC的BIOS那样,一个固件搞定所有事情?
原因其实很简单——资源限制。嵌入式SoC内部的SRAM通常只有几十KB到几百KB。以我常用的AM335x为例,内部SRAM才64KB。这点空间,连个完整的U-Boot都放不下,更别说内核了。
所以,业界普遍采用多阶段启动方案:
- 第一阶段(ROM Code):固化在SoC内部,不可修改。负责最基本的硬件初始化和加载下一阶段。
- 第二阶段(SPL):即Secondary Program Loader。由ROM Code加载到SRAM中执行,负责初始化DDR、时钟等关键外设,然后加载U-Boot proper。
- 第三阶段(U-Boot proper):完整的U-Boot,运行在DDR中。提供命令行、网络、文件系统支持,最终加载内核。
核心观点:SPL的本质是一个"瘦身版"的U-Boot。它只做两件事——初始化硬件、加载下一阶段镜像。
3.2 ROM Code:芯片出厂就有的"第一口饭"
ROM Code是芯片制造商固化在SoC内部的一段只读代码。你改不了它,也删不掉它。它的任务很明确:
- 最基本的CPU初始化:设置栈指针、关闭看门狗、配置系统时钟(通常用内部振荡器)。
- 启动介质检测:按照预设的优先级顺序,轮询各个启动设备——比如先从SD卡读,读不到再试eMMC,再试NAND Flash,最后试UART或USB下载。
- 加载SPL到SRAM:从检测到的启动介质中读取固定偏移位置的SPL镜像,加载到SRAM中,然后跳转执行。
这里有个关键点:ROM Code对硬件的认知非常有限。它不知道DDR怎么配置,不知道复杂的时钟树怎么设。它只敢用内部SRAM——因为SRAM不需要初始化,上电就能用。
经验之谈:我在调试一块新板子时,如果串口完全没有输出,我会先检查启动介质选择引脚的电平是否正确。很多时候,板子压根没走到SPL阶段,就是因为ROM Code没找到合法的启动设备。
3.3 SPL:承上启下的"小管家"
SPL的全称是Secondary Program Loader,也叫MLO(在TI平台上)或boot1(在Rockchip平台上)。它由ROM Code加载到SRAM中执行,大小通常被限制在几十KB以内。
SPL的主要职责包括:
| 职责 | 说明 | 常见坑点 |
|---|---|---|
| 时钟初始化 | 配置PLL,将CPU和总线频率提升到目标值 | 频率设太高可能导致DDR不稳定 |
| DDR初始化 | 配置DDR控制器和PHY,完成内存训练 | 时序参数不对,板子直接死掉 |
| 存储设备驱动 | 初始化MMC、NAND、SPI Flash等控制器 | 驱动时序与ROM Code不一致 |
| 加载U-Boot proper | 从存储设备读取U-Boot镜像到DDR | 偏移地址或大小写错 |
| 跳转执行 | 清理自身痕迹,跳转到U-Boot入口 | 栈指针未恢复导致崩溃 |
说白了,SPL就是给U-Boot proper铺路的。它把最难搞的DDR初始化搞定,把U-Boot镜像从慢速存储搬到快速DDR里,然后功成身退。
3.4 SPL的初始化流程(逐层拆解)
咱们以U-Boot 2023.04为例,看看SPL的初始化流程到底长什么样。代码入口在 arch/arm/lib/spl.c 里的 board_init_f() 函数。
// 简化后的SPL初始化流程
void board_init_f(ulong boot_flags)
{
// 1. 架构级初始化
arch_cpu_init(); // CPU架构相关,如设置异常向量表
timer_init(); // 定时器初始化,用于延时
// 2. 板级初始化
board_early_init_f(); // 板级早期初始化,如GPIO、LED
// 3. 时钟与DDR
dram_init(); // 获取DDR大小信息
sdram_init(); // 实际初始化DDR控制器
// 4. 控制台初始化(此时DDR已可用)
console_init_f(); // 串口输出初始化
// 5. 存储设备初始化
spl_init(); // SPL框架初始化
board_init_r(NULL, 0); // 跳转到第二阶段初始化
}
注意看,console_init_f() 被放在了DDR初始化之后。这意味着——如果DDR初始化失败,你连任何调试信息都看不到。这就是为什么我前面说,串口只打印一个'U'字就卡住,大概率是DDR的问题。
避坑指南:我曾经在一块新板子上,DDR初始化总是失败。查了两天,最后发现是DDR的VREF电压没配好。建议大家在调试SPL时,先确保串口能在DDR初始化之前输出信息——有些SoC支持用SRAM里的串口驱动做早期输出,这个功能一定要打开。
3.5 SPL镜像的生成与布局
SPL镜像怎么来的?其实它就是从U-Boot源码编译出来的一个独立二进制文件。编译时通过配置 CONFIG_SPL=y 来开启。
生成SPL的关键配置项:
| 配置项 | 作用 | 典型值 |
|---|---|---|
| CONFIG_SPL_TEXT_BASE | SPL在SRAM中的加载地址 | 0x402F0400 (AM335x) |
| CONFIG_SPL_MAX_SIZE | SPL镜像的最大尺寸 | 0x8000 (32KB) 或 0x10000 (64KB) |
| CONFIG_SPL_BSS_START_ADDR | BSS段起始地址 | SRAM末尾区域 |
| CONFIG_SPL_BSS_MAX_SIZE | BSS段最大尺寸 | 0x2000 (8KB) |
编译完成后,你会得到 spl/u-boot-spl.bin。这个文件需要加上头部信息(比如TI的GP header),才能被ROM Code识别。加头部的工具通常是 mkimage。
3.6 一张图看懂启动流程
下面这张图,是我自己画的一个启动流程概览。它把ROM Code、SPL、U-Boot proper的关系和关键节点都标出来了。
从这张图可以看得很清楚:ROM Code是起点,SPL是桥梁,U-Boot proper是终点。每一阶段都依赖前一阶段完成特定的初始化工作。
3.7 调试SPL的实用技巧
最后,分享几个我调试SPL时常用的技巧:
- 开启早期串口输出:在
board_early_init_f()里初始化UART,这样DDR初始化之前就能看到打印信息。配置CONFIG_SPL_SERIAL_SUPPORT=y和CONFIG_SPL_EARLY_PRINTK=y。 - 使用LED作为调试信号:如果串口还没通,可以用GPIO控制LED闪烁,判断代码执行到了哪一步。
- 检查SPL镜像大小:编译完成后,用
ls -l spl/u-boot-spl.bin查看大小。如果超过了SRAM容量,ROM Code根本不会加载它。 - 验证加载地址:用
objdump -d spl/u-boot-spl查看反汇编,确认第一条指令的地址是否与CONFIG_SPL_TEXT_BASE一致。
个人习惯:我每次拿到一块新板子,第一件事就是先让SPL在串口输出一个"Hello from SPL"。这一步通了,后面的DDR和U-Boot proper调试才有意义。