4. U-Boot 源码结构与启动流程:SPL、TPL、U-Boot proper 与启动三阶段
U-Boot 的源码结构,说实话,第一次接触的人很容易懵。目录多,文件杂,光顶层目录就有几十个。但你别怕,它的设计思路其实很清晰——一切都是为了「在硬件上电后,把系统引导起来」这个终极目标服务的。
我个人习惯把 U-Boot 的源码结构分成三大块:平台相关、通用框架、工具与脚本。搞清楚这三块,你就能在源码里快速定位到想要的东西。
4.1 顶层目录结构速览
我们先看几个最关键的目录:
| 目录/文件 | 作用 | 我的经验 |
|---|---|---|
arch/ |
CPU 架构相关代码(arm、riscv、x86 等) | 移植新板子,90% 的时间都在这里 |
board/ |
具体板级初始化代码 | 每个厂商一个子目录,比如 board/freescale/ |
common/ |
通用命令和框架代码 | 你敲的 printenv、bootm 都在这里 |
drivers/ |
各种外设驱动(串口、网卡、MMC、NAND 等) | 我踩过最多的坑就在这——驱动时序不对 |
include/ |
头文件,尤其是 configs/ 下的板级配置 |
改配置先找这里,别瞎翻 |
dts/ |
设备树源文件 | 现在新板子都靠它描述硬件 |
tools/ |
镜像打包、签名等工具 | 比如 mkimage,生成 U-Boot 镜像必备 |
嗯,这里要注意:arch/arm/ 下面还有 cpu/、dts/、include/ 等子目录。你想想看,ARM 架构下有那么多 SoC 厂商(NXP、TI、Rockchip、Allwinner 等),每个厂商的启动流程都不一样,所以 arch/arm/mach-xxx/ 就是存放这些差异的地方。
4.2 启动三阶段:SPL、TPL、U-Boot proper
为什么 U-Boot 要分成三个阶段?说白了,就是「空间不够」。
早期的嵌入式芯片,内部 SRAM 可能只有 4KB 或 8KB。你想想看,完整的 U-Boot 编译出来动辄几百 KB,根本放不下。所以就有了 SPL(Secondary Program Loader)——一个迷你版的 U-Boot,负责初始化 DDR,然后把完整的 U-Boot proper 从存储设备加载到内存里。
那 TPL(Tertiary Program Loader)呢?这是后来某些更复杂的 SoC 才引入的。比如有些芯片,内部 SRAM 只有 2KB,连 SPL 都塞不下。那就再加一层:TPL 负责最基础的初始化(比如时钟、电源),然后加载 SPL,SPL 再加载 U-Boot proper。
三阶段关系总结:
- TPL(可选):最精简的初始化,加载 SPL
- SPL(必须):初始化 DDR,加载 U-Boot proper
- U-Boot proper:完整的引导加载程序,加载内核
我曾经在一个 i.MX6 的项目上,因为 SPL 的 DDR 初始化参数配错了,导致 U-Boot proper 加载到内存后跑飞。查了两天才发现是时序参数里一个 CAS latency 写错了。嗯,从那以后我每次调 DDR 参数都格外小心。
4.3 启动流程详解
我们以典型的 ARM 芯片为例,看看上电后到底发生了什么:
- ROM code:芯片内部固化的代码,上电后最先执行。它会根据启动模式(eFuse、GPIO 电平)选择启动设备(SD 卡、NAND、SPI NOR 等),然后把 TPL 或 SPL 加载到内部 SRAM 中。
- TPL 阶段(如果有):初始化最基本的硬件(比如 PLL、时钟),然后从启动设备加载 SPL 到 SRAM。
- SPL 阶段:初始化 DDR 控制器、串口(方便调试)、存储设备驱动。然后从存储设备读取 U-Boot proper 到 DDR 中,跳转执行。
- U-Boot proper 阶段:初始化所有外设(网卡、USB、MMC 等),解析环境变量,执行 bootcmd 命令,最终加载并启动内核。
调试小技巧:SPL 阶段可以通过串口输出调试信息。在配置中开启 CONFIG_SPL_SERIAL_SUPPORT 和 CONFIG_SPL_DEBUG,就能看到 SPL 的执行过程。我每次移植新板子,第一件事就是确保 SPL 的串口能打印东西——否则就像瞎子摸象。
下面这张图可以帮你更直观地理解整个流程:
4.4 SPL 与 U-Boot proper 的代码差异
SPL 和 U-Boot proper 虽然共享同一套源码,但编译出来的镜像大小天差地别。原因在于 SPL 通过配置选项裁剪掉了大量功能。
我列个对比表,你一看就明白:
| 特性 | SPL | U-Boot proper |
|---|---|---|
| 典型大小 | 16KB ~ 128KB | 200KB ~ 1MB+ |
| 运行位置 | 内部 SRAM | 外部 DDR |
| 命令行 | 无(或极简) | 完整命令行 |
| 文件系统支持 | 通常不支持 | 支持 ext4、FAT、btrfs 等 |
| 网络协议栈 | 无 | 完整 TCP/IP |
| 设备树 | 可选(精简版) | 完整设备树 |
| 主要任务 | 初始化 DDR,加载 proper | 加载内核、设备树、ramdisk |
你可能会问:SPL 怎么知道自己该加载哪个 U-Boot proper 镜像?答案在 CONFIG_SPL_FS_LOAD_PAYLOAD_NAME 或 CONFIG_SPL_NAND_LOAD 这类配置里。SPL 根据这些配置,从存储设备的固定偏移位置读取 U-Boot proper。
常见坑点:
- SPL 的 DDR 初始化参数必须和硬件完全匹配,否则 U-Boot proper 加载后跑飞
- U-Boot proper 在存储设备上的位置必须和 SPL 中配置的加载地址一致
- 如果启用了校验(如 FIT 镜像),SPL 还需要集成对应的哈希算法
4.5 从源码角度看启动入口
我们来看看代码层面是怎么串联起来的。以 ARM64 为例:
// 1. 上电后第一个入口(ROM code 跳转过来)
// arch/arm/cpu/armv8/start.S
_start:
// 设置异常向量表
// 初始化栈指针
// 跳转到 main 函数
// 2. SPL 的 main 函数
// common/spl/spl.c
void board_init_f(ulong dummy)
{
// 初始化时钟、串口、DDR
// 调用 spl_load_image() 加载 U-Boot proper
}
// 3. U-Boot proper 的入口
// common/board_f.c
void board_init_f(ulong boot_flags)
{
// 初始化各种外设
// 调用 board_init_r() 进入命令行循环
}
嗯,这里有个细节:board_init_f 和 board_init_r 这两个函数,在 SPL 和 U-Boot proper 里都有,但做的事情完全不同。SPL 版本的 board_init_f 只做最基本的初始化,而 U-Boot proper 版本会初始化所有外设。
我个人习惯在 board_init_f 里加一些调试打印,比如打印当前运行的阶段和关键寄存器值。这样一旦启动卡住,我能立刻知道是哪个阶段出了问题。
4.6 配置系统:如何控制三阶段的编译
U-Boot 使用 Kconfig 来管理配置。你运行 make menuconfig 时看到的界面,就是 Kconfig 生成的。
控制三阶段的关键配置项:
CONFIG_SPL:启用 SPL 构建CONFIG_TPL:启用 TPL 构建CONFIG_SPL_BUILD:编译时自动定义,用于区分当前编译的是 SPL 还是 U-Boot proper
在源码里,你经常能看到这样的条件编译:
#ifdef CONFIG_SPL_BUILD
// 这段代码只在 SPL 中编译
ddr_init();
serial_init();
#else
// 这段代码只在 U-Boot proper 中编译
net_initialize();
usb_start();
#endif
我曾经接手过一个项目,前任工程师在 SPL 里调用了 usb_start(),结果 SPL 镜像直接超了 SRAM 大小。嗯,这就是没搞清楚 SPL 和 U-Boot proper 的编译边界。
我的建议:在移植新板子时,先确保 SPL 能正常打印信息并初始化 DDR。这一步通了,后面的事情就好办了。你可以先在 SPL 里只保留串口和 DDR 初始化,其他驱动统统关掉——越小越容易调试。
4.7 小结
U-Boot 的三阶段设计,本质上是嵌入式系统资源受限的产物。但随着芯片越来越强,有些 SoC 已经开始尝试把 SPL 和 U-Boot proper 合并了。不过目前来看,SPL + U-Boot proper 仍然是主流方案。