第4章 SPL详解:作用、链接脚本、入口与初始化

SPL,全称 Secondary Program Loader,是 U-Boot 启动流程中一个非常关键的中间层。说白了,它就是一段精简版的 U-Boot,负责把完整的 U-Boot 从存储介质(比如 NAND Flash、SD 卡、eMMC)加载到内存里,然后跳过去执行。

为什么需要这么个东西?我刚开始接触嵌入式时也觉得多此一举。后来在项目里踩过坑才明白——很多 SoC 内部 SRAM 太小了,装不下完整的 U-Boot。比如某些芯片内部 SRAM 只有 64KB,而完整 U-Boot 编译出来动辄几百 KB。这时候 SPL 就派上用场了:它体积小,能在 SRAM 里跑,先把 DDR 初始化好,再把大块头的 U-Boot 搬进去。

4.1 SPL 的核心作用

SPL 在整个启动链路中扮演着「桥梁」的角色。它的职责可以归纳为以下几点:

  • 硬件最小初始化:设置时钟、初始化串口、配置引脚复用。这些操作让 SoC 从「裸片」状态进入可编程状态。
  • DDR 初始化:这是 SPL 最核心的任务。没有 DDR,完整 U-Boot 根本无处安放。我遇到过一块板子,DDR 时序参数配错了,SPL 加载 U-Boot 后一跑就挂,查了两天才发现是某个寄存器少配了一位。
  • 加载完整 U-Boot:从 Boot Device 读取 U-Boot 镜像到 DDR 指定地址。支持 NAND、SD/MMC、SPI Flash、USB 等多种介质。
  • 跳转执行:设置好栈指针和跳转地址,把控制权交给完整 U-Boot。

关键点:SPL 本身也是一个独立的可执行程序,有自己的链接脚本、入口函数和初始化流程。它和完整 U-Boot 共享大部分代码,但通过 CONFIG_SPL_BUILD 宏来区分编译路径。

4.2 SPL 的链接脚本

链接脚本决定了 SPL 的代码段、数据段、BSS 段在内存中的布局。SPL 的链接脚本通常位于 arch/arm/cpu/armv7/u-boot-spl.lds(ARMv7 架构为例),或者通过 CONFIG_SPL_LDSCRIPT 指定自定义路径。

我习惯先看链接脚本的入口点声明:

OUTPUT_FORMAT("elf32-littlearm", "elf32-littlearm", "elf32-littlearm")
OUTPUT_ARCH(arm)
ENTRY(_start)
SECTIONS
{
    . = 0x00000000;
    .text : {
        *(.vectors)
        *(.text*)
    }
    . = ALIGN(4);
    .rodata : { *(.rodata*) }
    . = ALIGN(4);
    .data : { *(.data*) }
    . = ALIGN(4);
    .bss : {
        __bss_start = .;
        *(.bss*)
        __bss_end = .;
    }
}

这里有几个要点:

  • 入口地址:. = 0x00000000 表示 SPL 的起始链接地址。实际运行时,这个地址对应 SoC 内部 SRAM 的基地址。不同芯片可能不同,比如有些芯片 SRAM 基址在 0x20000000。
  • 向量表优先:*(.vectors) 确保异常向量表放在最前面。这是 ARM 架构的要求——复位后 CPU 从 0x0 地址取第一条指令。
  • BSS 段清零:__bss_start 和 __bss_end 标记了 BSS 段的起止地址,SPL 初始化代码会用它来清零全局变量。

避坑指南:我曾经在移植 SPL 时,链接脚本里忘了给 BSS 段留足够的对齐空间,结果全局变量被错误地放在了代码段里,导致运行时数据被覆盖。排查时用 objdump -h u-boot-spl 查看各段地址,一眼就发现了问题。

4.3 SPL 的入口与初始化流程

SPL 的入口函数是 _start,定义在 arch/arm/cpu/armv7/start.S 中。这个汇编文件是 SPL 启动的第一站,它完成以下工作:

  1. 设置 CPU 模式:切换到 SVC(超级用户)模式,关闭中断。
  2. 初始化栈指针:为 C 语言运行环境准备栈空间。
  3. 清零 BSS 段:调用 __bss_start 和 __bss_end 之间的内存。
  4. 调用 board_init_f:进入 C 语言阶段的早期初始化。

我们来看一段典型的入口代码:

_start:
    /* 设置 CPU 为 SVC 模式,关闭 IRQ/FIQ */
    mrs r0, cpsr
    bic r0, r0, #0x1f
    orr r0, r0, #0xd3
    msr cpsr, r0

    /* 设置栈指针,指向 SRAM 顶部 */
    ldr sp, =(CONFIG_SPL_STACK)

    /* 清零 BSS 段 */
    ldr r0, =__bss_start
    ldr r1, =__bss_end
    mov r2, #0
    bl  memset

    /* 跳转到 board_init_f */
    bl  board_init_f

嗯,这里要注意 CONFIG_SPL_STACK 的值。它必须指向 SRAM 范围内,并且要留出足够的空间给函数调用栈。我见过有人把栈设在 DDR 里——但 DDR 还没初始化呢,一压栈就崩了。

board_init_f 是 SPL 的 C 语言入口,它完成:

  • 初始化串口(方便调试输出)
  • 初始化定时器
  • 初始化 DDR 控制器
  • 调用 spl_load_image 从存储介质加载完整 U-Boot
  • 跳转到 U-Boot 入口

重要提醒:SPL 的代码体积必须严格控制。如果 SPL 编译后超过 SRAM 大小,链接阶段就会报错。我一般用 size u-boot-spl 查看各段大小,确保 text+data+bss 总和不超过 SRAM 容量。必要时可以裁剪驱动、去掉不必要的功能。

4.4 SPL 的加载与跳转逻辑

SPL 加载完整 U-Boot 的过程,可以用下面这张流程图来理解:

SPL 加载与跳转流程图 SoC 复位 SPL 入口 _start board_init_f 初始化 DDR 加载 U-Boot 到 DDR 跳转 U-Boot 入口 关键说明 • SPL 在 SRAM 中运行 • DDR 初始化后才能加载 • 跳转前需设置栈指针 • 跳转地址由 CONFIG 指定

加载完成后,SPL 通过一条简单的跳转指令把控制权交给 U-Boot:

/* 跳转到 U-Boot 入口 */
ldr pc, =CONFIG_SYS_TEXT_BASE

CONFIG_SYS_TEXT_BASE 就是完整 U-Boot 在 DDR 中的加载地址。跳转前,SPL 会确保栈指针已经切换到 DDR 空间,否则 U-Boot 一运行就栈溢出。

4.5 SPL 的调试技巧

SPL 调试比完整 U-Boot 更麻烦,因为串口可能还没初始化好。我总结了几条实用经验:

调试手段 适用场景 注意事项
GPIO 点灯 确认代码执行到哪一步 需要提前配置 GPIO 复用
串口打印 查看变量值和流程状态 串口初始化必须在打印之前
JTAG 调试 单步跟踪、查看寄存器 需要调试器支持 SRAM 访问
内存 dump 检查加载的 U-Boot 镜像 用 md 命令或 JTAG 读取

个人经验:我习惯在 SPL 的 board_init_f 开头加一个 GPIO 翻转操作。如果灯亮了,说明 SPL 至少跑到了 C 语言阶段。如果没亮,问题出在汇编入口或链接脚本上。这个方法简单粗暴,但非常有效。

4.6 常见问题与解决方案

SPL 移植过程中,我遇到过不少坑。这里列几个典型的:

  • SPL 体积超限:SRAM 装不下。解决方案是裁剪驱动、去掉不必要的功能模块,或者使用更小的编译器优化等级(-Os)。
  • DDR 初始化失败:SPL 加载 U-Boot 后系统无响应。检查 DDR 时序参数、电压配置、以及 PHY 的校准流程。我建议先用厂商提供的 DDR 测试程序验证硬件。
  • 跳转地址错误:SPL 跳转到错误地址导致崩溃。用 objdump -d u-boot 查看 U-Boot 的实际入口地址,确保 CONFIG_SYS_TEXT_BASE 与之匹配。
  • BSS 段未清零:全局变量初始值随机。检查链接脚本中 BSS 段的起止符号是否被正确导出,以及清零代码是否执行。