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 三阶段全景图

先给你一张图,把三阶段的整体流程看清楚。我习惯把这张图贴在工位上,每次调试都对照着看。

U-Boot 三阶段启动流程 阶段0:ROM BootROM 固化在SoC内部ROM 加载SPL到SRAM 阶段1:SPL 初始化时钟、DDR 加载TPL或主U-Boot 阶段2:TPL(可选) 更精细的DDR初始化 加载主U-Boot 阶段3:主U-Boot 完整的Bootloader功能 加载内核、设备树、根文件系统 关键说明: • TPL不是必须的,只有部分SoC(如某些TI、Rockchip平台)才需要 • SPL和TPL都运行在SRAM中,主U-Boot运行在DDR中 • 每个阶段的大小受限于上一阶段初始化的存储空间

4.3 SPL:第一阶段的硬仗

SPL(Secondary Program Loader)是整个启动链中第一个由用户控制的代码。它从BootROM手里接过接力棒,运行在SoC内部的SRAM中。

SPL的主要任务就三个:

  1. 初始化最基本的硬件——时钟、串口、DDR控制器
  2. 初始化存储设备——从SD卡、eMMC、NAND Flash或SPI Flash中读取下一阶段镜像
  3. 加载并跳转到下一阶段——要么是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的源码,就不会觉得乱了。