4. U-Boot启动流程分析:SPL、TPL、U-Boot proper的三阶段启动

很多刚接触U-Boot的朋友,一上来就被SPL、TPL、U-Boot proper这几个概念搞晕了。说实话,我当年第一次看启动代码时也是一脸懵——怎么一个bootloader还要分三段?直接跑不行吗?

后来在项目里吃过亏才明白:不是所有硬件都像PC那样有BIOS帮你初始化。嵌入式设备上电时,CPU内部RAM可能只有几十KB,根本装不下完整的U-Boot。怎么办?那就搞个"小马拉大车"的策略——分阶段加载。

核心认知:SPL → TPL → U-Boot proper 的本质,是从"极小引导"到"完整引导"的逐级接力。每一级都比上一级功能更强,但体积也更大。

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功能,加载内核
我的经验:并不是所有平台都需要TPL。如果你的芯片内部SRAM ≥ 64KB,通常SPL + U-Boot proper两阶段就够了。TPL主要用在那些SRAM极小(< 32KB)的Cortex-M或低端ARM9平台上。

4.2 三阶段启动的完整流程

下面这张图是我根据多个项目经验总结的典型启动流程。你可以把它当作一个"路线图",后面分析代码时对照着看。

U-Boot 三阶段启动流程图 ① 上电复位 ② ROM BootROM 执行 ③ TPL 加载并执行 ④ SPL 加载并执行 ⑤ U-Boot proper 加载并执行 ⑥ 加载 Linux 内核 说明 如果芯片SRAM足够大 可跳过TPL,直接从SPL启动 即:② → ④ → ⑤ → ⑥ 关键节点 ③ TPL:初始化DDR ④ SPL:从存储介质加载proper

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*) }
}
我踩过的坑:有一次移植TPL,链接脚本里忘了把 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加载proper后跳转失败。查了两天才发现是 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
我的建议:刚开始移植时,先不要开TPL。直接从SPL + proper两阶段入手。等SPL跑通了,再考虑是否要拆出TPL。少一个阶段就少一个调试环节,能省不少时间。

4.7 调试技巧

三阶段启动的调试,最头疼的是不知道当前跑到了哪一步。我常用的方法:

  • GPIO点灯法:在每个阶段的入口和出口处,点亮不同的LED。比如TPL入口亮红灯,SPL入口亮绿灯,proper入口亮蓝灯。一眼就能看出卡在哪。
  • 串口打印:在 board_init_f 早期加入 putc() 输出。注意SPL阶段串口可能还没初始化,需要提前配置。
  • JTAG单步:如果条件允许,用JTAG调试器直接看PC指针。这是最直接的方法。
曾经踩过的坑:有一次SPL加载proper后,串口没有任何输出。我以为是proper没跑起来,折腾了半天。后来用JTAG一看,proper其实已经跑起来了,但串口驱动在proper阶段被重新初始化了,而初始化代码里有个bug导致配置错误。所以没输出不等于没执行,要结合多种手段定位。

4.8 小结

三阶段启动的核心思想就一句话:用最小的代码初始化硬件,然后逐级加载更大的代码。TPL负责DDR初始化,SPL负责加载proper,proper负责加载内核。每一级都只做自己该做的事,不多做也不少做。