4. U-Boot启动流程分析:SPL、TPL、U-Boot proper的三阶段启动
很多刚接触U-Boot的朋友,一上来就被SPL、TPL、U-Boot proper这几个概念搞晕了。说实话,我当年第一次看启动代码时也是一脸懵——怎么一个bootloader还要分三段?直接跑不行吗?
后来在项目里吃过亏才明白:不是所有硬件都像PC那样有BIOS帮你初始化。嵌入式设备上电时,CPU内部RAM可能只有几十KB,根本装不下完整的U-Boot。怎么办?那就搞个"小马拉大车"的策略——分阶段加载。
4.1 为什么需要三阶段?
先看一个现实问题。我做过一个i.MX6ULL的项目,芯片内部SRAM只有128KB。而U-Boot proper编译出来通常300KB~500KB。显然,128KB装不下500KB的代码。
解决方案就是:先跑一个极小的loader,初始化DDR,然后把真正的U-Boot从EMMC/NAND搬到DDR里执行。这个"极小的loader"就是SPL(Secondary Program Loader)。
那TPL(Tertiary Program Loader)又是干嘛的?说白了,有些芯片更极端——内部SRAM只有16KB甚至4KB。SPL都塞不进去。那就再拆一层:TPL只做最基础的时钟和DDR初始化,然后加载SPL,SPL再加载U-Boot proper。
| 阶段 | 典型体积 | 运行位置 | 主要职责 |
|---|---|---|---|
| TPL | 4KB ~ 16KB | 内部SRAM | 时钟、DDR初始化,加载SPL |
| SPL | 16KB ~ 64KB | 内部SRAM / DDR | DDR完整初始化,加载U-Boot proper |
| U-Boot proper | 200KB ~ 1MB+ | DDR | 完整bootloader功能,加载内核 |
4.2 三阶段启动的完整流程
下面这张图是我根据多个项目经验总结的典型启动流程。你可以把它当作一个"路线图",后面分析代码时对照着看。
4.3 各阶段代码入口分析
搞清楚了流程,接下来看看代码层面是怎么组织的。我习惯从链接脚本入手,因为链接脚本决定了代码的加载地址和入口点。
4.3.1 TPL 入口
TPL的链接脚本通常在 arch/arm/cpu/u-boot-tpl.lds。入口函数是 _start,定义在 arch/arm/lib/vectors.S 中。
/* arch/arm/cpu/u-boot-tpl.lds (简化) */
OUTPUT_FORMAT("elf32-littlearm")
OUTPUT_ARCH(arm)
ENTRY(_start)
SECTIONS
{
. = 0x00000000; /* 根据芯片内部SRAM基地址修改 */
.text : {
*(.__image_copy_start)
arch/arm/cpu/armv7/start.o (.text*)
*(.text*)
}
.rodata : { *(.rodata*) }
.data : { *(.data*) }
.bss : { *(.bss*) }
}
start.o 放在最前面,结果CPU上电后直接跑飞。调试了整整一天才发现——入口代码必须位于镜像的最开头,因为BootROM固定从偏移0处加载。
4.3.2 SPL 入口
SPL的链接脚本是 arch/arm/cpu/u-boot-spl.lds。入口同样是 _start,但SPL比TPL多了更多初始化逻辑。
/* SPL 启动流程(伪代码) */
_start:
/* 1. 设置CPU为SVC模式,关中断 */
mrs r0, cpsr
bic r0, r0, #0x1f
orr r0, r0, #0xd3
msr cpsr, r0
/* 2. 初始化异常向量表 */
/* 3. 初始化堆栈指针 */
ldr sp, =CONFIG_SPL_STACK
/* 4. 清零BSS段 */
/* 5. 调用 board_init_f() */
bl board_init_f
/* 6. 初始化DDR */
/* 7. 从EMMC/NAND加载U-Boot proper */
bl spl_load_image
/* 8. 跳转到U-Boot proper入口 */
ldr pc, =CONFIG_SYS_TEXT_BASE
4.3.3 U-Boot proper 入口
U-Boot proper的链接脚本是 arch/arm/cpu/u-boot.lds。入口地址通常配置为 CONFIG_SYS_TEXT_BASE,比如0x80800000(DDR地址)。
proper阶段的启动流程比SPL复杂得多,包括完整的设备驱动初始化、环境变量加载、命令行解析等。但它的入口逻辑和SPL类似,都是从 _start 开始,经过 board_init_f → board_init_r 的经典流程。
4.4 关键数据结构:spl_image_info
SPL和TPL之间、SPL和proper之间,通过一个全局结构体传递信息。这个结构体定义在 include/spl.h 中:
struct spl_image_info {
const char *name; /* 镜像名称 */
u8 os; /* 操作系统类型 */
u32 load_addr; /* 加载地址 */
u32 entry_point; /* 入口地址 */
u32 size; /* 镜像大小 */
u32 flags; /* 标志位 */
u32 boot_device; /* 启动设备 */
void *priv; /* 私有数据 */
};
举个例子,SPL从EMMC加载U-Boot proper时,会填充这个结构体:
/* common/spl/spl_mmc.c (简化) */
int spl_mmc_load_image(struct spl_image_info *spl_image,
struct blk_desc *block_dev, int partition)
{
/* 从EMMC的指定分区读取U-Boot proper */
spl_image->name = "U-Boot";
spl_image->os = IH_OS_U_BOOT;
spl_image->load_addr = CONFIG_SYS_TEXT_BASE;
spl_image->entry_point = CONFIG_SYS_TEXT_BASE;
spl_image->size = CONFIG_SYS_MONITOR_LEN;
spl_image->boot_device = BOOT_DEVICE_MMC1;
/* 执行实际的读取操作 */
return mmc_load_image_raw(spl_image, block_dev, CONFIG_SYS_MMC_U_BOOT_OFFS);
}
spl_image->entry_point 和 spl_image->load_addr 不一致。U-Boot proper的入口地址必须等于加载地址(除非你做了位置无关代码处理),否则跳转后PC直接飞了。
4.5 三阶段的内存布局
不同阶段运行在不同的内存区域,这是理解启动流程的关键。以我常用的i.MX6ULL为例:
| 阶段 | 运行地址 | 内存区域 | 大小 |
|---|---|---|---|
| BootROM | 0x00000000 | 内部ROM | 96KB |
| TPL | 0x00900000 | 内部SRAM | 16KB |
| SPL | 0x00904000 | 内部SRAM | 48KB |
| U-Boot proper | 0x80800000 | DDR | 512KB |
看到这个地址分配,你应该能理解为什么需要分阶段了。TPL和SPL在内部SRAM里运行,初始化DDR后,proper才能搬到DDR里。没有DDR,proper根本跑不起来。
4.6 实际项目中的配置方法
在U-Boot源码中,通过Kconfig配置是否启用TPL:
# 启用TPL
CONFIG_TPL=y
CONFIG_TPL_TEXT_BASE=0x00900000
CONFIG_TPL_MAX_SIZE=0x4000 # 16KB
# 启用SPL
CONFIG_SPL=y
CONFIG_SPL_TEXT_BASE=0x00904000
CONFIG_SPL_MAX_SIZE=0xC000 # 48KB
# U-Boot proper
CONFIG_SYS_TEXT_BASE=0x80800000
CONFIG_SYS_MONITOR_LEN=0x80000 # 512KB
4.7 调试技巧
三阶段启动的调试,最头疼的是不知道当前跑到了哪一步。我常用的方法:
- GPIO点灯法:在每个阶段的入口和出口处,点亮不同的LED。比如TPL入口亮红灯,SPL入口亮绿灯,proper入口亮蓝灯。一眼就能看出卡在哪。
- 串口打印:在
board_init_f早期加入putc()输出。注意SPL阶段串口可能还没初始化,需要提前配置。 - JTAG单步:如果条件允许,用JTAG调试器直接看PC指针。这是最直接的方法。
4.8 小结
三阶段启动的核心思想就一句话:用最小的代码初始化硬件,然后逐级加载更大的代码。TPL负责DDR初始化,SPL负责加载proper,proper负责加载内核。每一级都只做自己该做的事,不多做也不少做。