第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 启动的第一站,它完成以下工作:
- 设置 CPU 模式:切换到 SVC(超级用户)模式,关闭中断。
- 初始化栈指针:为 C 语言运行环境准备栈空间。
- 清零 BSS 段:调用
__bss_start和__bss_end之间的内存。 - 调用 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 通过一条简单的跳转指令把控制权交给 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 段的起止符号是否被正确导出,以及清零代码是否执行。