4、U-Boot启动流程详解:SPL、TPL与主U-Boot的三阶段
说实话,很多做嵌入式开发的朋友,一上来就盯着U-Boot的源码看,结果越看越晕。为什么?因为现代U-Boot已经不是当年那个单阶段的小家伙了。它被拆成了SPL、TPL和主U-Boot三个阶段。你想想看,一个Bootloader为什么要搞得这么复杂?
我刚开始接触i.MX6平台时,也被这个三阶段搞得一头雾水。后来在调试一块DDR3的板子时,SPL阶段死活跑不起来,我才真正理解了这三兄弟各自的分工。今天我就把这段经验掰开揉碎了讲给你听。
4.1 为什么需要三阶段?
先回答一个最根本的问题:为什么不能一个U-Boot搞定所有事?
原因很简单——资源限制。很多SoC内部的SRAM只有几十KB到几百KB,比如全志的某些芯片,SRAM只有32KB。这点空间连一个完整的U-Boot镜像都放不下,更别提初始化DDR了。
所以,工程师们想出了一个办法:分阶段加载。就像你盖房子,先搭脚手架,再砌墙,最后装修。每个阶段只做自己该做的事。
核心思想:用最小的代码量初始化最基本的硬件(时钟、DDR),然后把更大的加载任务交给下一阶段。
4.2 三阶段全景图
先给你一张图,把三阶段的整体流程看清楚。我习惯把这张图贴在工位上,每次调试都对照着看。
4.3 SPL:第一阶段的硬仗
SPL(Secondary Program Loader)是整个启动链中第一个由用户控制的代码。它从BootROM手里接过接力棒,运行在SoC内部的SRAM中。
SPL的主要任务就三个:
- 初始化最基本的硬件——时钟、串口、DDR控制器
- 初始化存储设备——从SD卡、eMMC、NAND Flash或SPI Flash中读取下一阶段镜像
- 加载并跳转到下一阶段——要么是TPL,要么直接是主U-Boot
我的经验:调试SPL时,串口是最重要的工具。我习惯在SPL的每个关键步骤后加一个串口打印,哪怕只是一个字符。这样当板子卡住时,我能立刻知道是卡在时钟初始化、DDR训练还是存储读取上。
SPL的代码量通常控制在16KB到64KB之间。为什么?因为SRAM就那么大。我记得有一次在Amlogic平台上,SRAM只有32KB,SPL镜像压缩后刚好塞进去,解压后还要在SRAM里自解压,那叫一个惊险。
4.4 TPL:中间人的存在感
TPL(Tertiary Program Loader)是个可选角色。它存在的唯一理由就是——DDR初始化太复杂了。
有些SoC的DDR控制器初始化需要大量代码,比如DDR3的时序训练、ZQ校准、ODT配置等。SPL那点空间根本放不下。怎么办?再加一个阶段。
TPL的工作流程很简单:
- SPL加载TPL到SRAM(通常覆盖SPL自身的位置)
- TPL执行更复杂的DDR初始化
- TPL加载主U-Boot到DDR
- TPL跳转到主U-Boot
注意:TPL和SPL共享SRAM空间。这意味着TPL启动时,SPL的代码已经被覆盖了。所以调试TPL时,你没法回头查SPL的日志。我建议在SPL阶段就把关键信息通过串口打印出来,或者存到SoC的保留寄存器里。
4.5 主U-Boot:真正的Bootloader
到了主U-Boot阶段,才算是进入了我们熟悉的U-Boot世界。它运行在DDR中,拥有充足的内存空间,可以加载完整的驱动、文件系统和各种命令。
主U-Boot做的事情包括但不限于:
| 功能模块 | 具体内容 | 依赖条件 |
|---|---|---|
| 板级初始化 | GPIO、I2C、PMIC、网络PHY | DDR已就绪,时钟已稳定 |
| 存储驱动 | MMC、NAND、SPI Flash、USB | 板级初始化完成 |
| 网络协议栈 | TFTP、NFS、DHCP | 网络PHY初始化完成 |
| 环境变量 | 从存储设备加载bootargs、bootcmd | 存储驱动就绪 |
| 加载内核 | 读取zImage/Image、设备树(.dtb) | 环境变量解析完成 |
主U-Boot的代码量通常在200KB到1MB之间。它不再受SRAM的限制,可以尽情地使用DDR。但这也带来了一个问题——启动时间变长了。所以很多产品会在主U-Boot里做优化,比如只加载必要的驱动,或者使用Fastboot模式。
4.6 三阶段的代码组织
在U-Boot源码中,三阶段的代码是通过不同的编译配置来区分的。我常用的配置方式是这样的:
# 配置SPL
make <board>_defconfig
make spl/u-boot-spl.bin
# 配置TPL(如果支持)
make <board>_defconfig
make tpl/u-boot-tpl.bin
# 配置主U-Boot
make <board>_defconfig
make u-boot.bin
在代码层面,SPL和TPL的入口函数分别是spl_main()和tpl_main()。它们都定义在common/spl/目录下。主U-Boot的入口则是board_init_f()和board_init_r()。
关键文件路径:
common/spl/spl.c— SPL主逻辑common/spl/spl_tpl.c— TPL主逻辑(如果存在)common/board_f.c— 主U-Boot早期初始化common/board_r.c— 主U-Boot后期初始化
4.7 实际调试中的避坑指南
调试三阶段启动,最怕的就是不知道卡在哪一步。我总结了几条经验:
- 串口是命根子——SPL阶段就要把串口初始化好,哪怕只打印一个字符。我曾经遇到过SPL串口没初始化,结果板子死在那里,我对着示波器看了半天才发现是串口配置错了。
- DDR训练要谨慎——DDR初始化失败是SPL阶段最常见的死因。我建议先用SoC厂商提供的DDR训练工具生成参数,再移植到SPL中。手动调参?那是最后的手段。
- 存储设备时序——从SD卡或eMMC读取下一阶段镜像时,如果时序不对,读出来的数据就是错的。我习惯在SPL里加一个CRC校验,确保镜像完整性。
- TPL的覆盖问题——TPL会覆盖SPL的代码,所以SPL阶段的日志一定要在跳转前打印完。我见过有人把日志缓冲到SRAM的末尾,结果被TPL覆盖了,查了半天查不到。
一个小技巧:在SPL和TPL的入口处,把当前阶段的名称和一个唯一ID写入SoC的保留寄存器。这样当系统崩溃时,可以通过JTAG或硬件调试器读出这个寄存器,立刻知道是哪个阶段出了问题。
4.8 三阶段的内存布局
最后,我们来看看三阶段在内存中的布局。这张表我建议你保存下来,调试时对照着看:
| 阶段 | 运行位置 | 大小限制 | 加载目标 |
|---|---|---|---|
| BootROM | SoC内部ROM | 通常16-32KB | 加载SPL到SRAM |
| SPL | SRAM(0x00000000或0x10000000附近) | 16-64KB | 加载TPL或主U-Boot |
| TPL | SRAM(覆盖SPL位置) | 16-32KB | 加载主U-Boot到DDR |
| 主U-Boot | DDR(通常0x20000000或0x40000000) | 200KB-1MB | 加载内核和设备树 |
嗯,到这里,U-Boot的三阶段启动流程就讲完了。说白了,这就是一个层层递进、逐步扩大资源的过程。每个阶段都只做自己能力范围内的事,然后把接力棒交给下一棒。理解了这一点,你再去看U-Boot的源码,就不会觉得乱了。