第3章 SPL (Secondary Program Loader) 详解
SPL,全称 Secondary Program Loader。说白了,它就是 Boot ROM 之后、真正加载系统之前的那个“二传手”。
很多人觉得 SPL 就是个过渡代码,随便写写就行。我刚开始做嵌入式时也这么想,直到有一次在某个 ARM 平台上,SPL 没配好 DDR 时序,板子死活起不来。折腾了两天,最后发现是 SPL 里一个时钟分频参数写错了。嗯,从那以后我再也不敢小看 SPL 了。
SPL 的使命:承上启下
SPL 到底要干什么?我总结了三件事:
- 初始化关键硬件——内存、时钟、串口,这三样是必须的
- 加载下一级程序——通常是 U-Boot 完整版,也可能是 Linux 内核
- 提供最小化运行环境——让后续代码能正常跑起来
你想想看,Boot ROM 通常只有几 KB 到几十 KB,它没能力做复杂初始化。SPL 就是来补这个缺的。
核心原则:SPL 必须足够小、足够快、足够稳。它不需要花哨功能,只需要把硬件“唤醒”到可用状态。
内存初始化:DDR 的“唤醒仪式”
内存初始化是 SPL 里最关键的环节。没有内存,你连栈都搭不起来,更别提加载程序了。
DDR 初始化为什么复杂?因为 DDR 控制器和颗粒之间有一套严格的时序协议。我见过不少新手直接抄参考代码,结果板子跑起来随机死机。为什么?因为不同 PCB 走线长度会导致时序偏差,必须做 DDR 训练。
DDR 初始化的典型步骤
- 配置 DDR 控制器——设置地址映射、突发长度、CAS 延迟等
- 设置 PHY 层——调整 DQ/DQS 的延迟,保证数据眼图张开
- 执行 ZQ 校准——校准输出驱动强度,匹配板级阻抗
- 运行 DDR 训练——读写训练、VREF 训练、写均衡
- 验证内存可用——简单读写测试,确认无误
我曾经踩过的坑:某次项目用了新批次 DDR 颗粒,SPL 里沿用旧训练参数。结果低温环境下内存读写偶发错误。后来加了温度补偿训练才解决。所以,DDR 参数别写死,最好留出校准接口。
时钟配置:让芯片“心跳”起来
时钟是芯片的脉搏。SPL 阶段必须把 PLL 配好,让 CPU、总线、外设都能工作在正确频率上。
时钟配置的难点在于:依赖关系复杂。比如你要先配好晶振输入,再配 PLL 倍频,然后分频给各个模块。顺序错了,芯片直接罢工。
| 时钟域 | 典型频率 | SPL 阶段作用 |
|---|---|---|
| CPU 核心时钟 | 400MHz - 1.2GHz | 运行 SPL 代码 |
| DDR 时钟 | 200MHz - 800MHz | 驱动内存控制器 |
| AXI/AHB 总线时钟 | 100MHz - 400MHz | 外设通信 |
| UART 时钟 | 1.8432MHz / 24MHz | 串口输出调试信息 |
我个人习惯在 SPL 里先配一个安全频率,比如 CPU 跑 400MHz,DDR 跑 200MHz。等 U-Boot 完整版加载后,再切换到高性能模式。这样即使参数有误,也不至于烧芯片。
最小化硬件抽象层:HAL 的“瘦身版”
SPL 不需要完整的 HAL。它只需要一个最小化硬件抽象层,能操作寄存器、读写内存、控制基本外设就够了。
这个最小化 HAL 通常包含:
- 寄存器读写宏——readl() / writel(),直接操作物理地址
- 延时函数——简单的循环延时,或者用定时器
- 串口驱动——只实现输出,不实现中断接收
- 时钟操作接口——PLL 配置、分频设置
- 内存控制器接口——DDR 初始化、训练
/* 一个典型的 SPL 最小化 HAL 示例 */
#define writel(v, a) (*(volatile unsigned int *)(a) = (v))
#define readl(a) (*(volatile unsigned int *)(a))
void spl_uart_putc(char c)
{
/* 等待发送 FIFO 空 */
while (!(readl(UART_BASE + UART_LSR) & LSR_THRE));
writel(c, UART_BASE + UART_THR);
}
void spl_delay(unsigned long loops)
{
while (loops--);
}
我的建议:SPL 的 HAL 不要用函数指针、不要用回调、不要用动态分配。这些特性在 SPL 阶段要么不支持,要么增加代码体积。老老实实写宏和静态函数,最可靠。
SPL 的典型执行流程
下面这张图展示了 SPL 从入口到跳转的完整流程。我画这张图时参考了好几个主流 SoC 的启动流程,包括 TI 的 AM335x 和 NXP 的 i.MX 系列。
SPL 的存储与加载
SPL 通常存放在 NAND Flash、SD 卡、eMMC 或 SPI NOR Flash 中。Boot ROM 会根据启动引脚配置,从指定介质读取 SPL 到内部 SRAM 并执行。
这里有个关键限制:内部 SRAM 通常只有 64KB 到 256KB。所以 SPL 必须严格控制代码体积。我见过有些团队把 SPL 做到 30KB 以内,还包含了完整的 DDR 训练算法,确实厉害。
| 存储介质 | 典型读取速度 | SPL 体积限制 | 注意事项 |
|---|---|---|---|
| SPI NOR Flash | ~10 MB/s | 64KB - 128KB | 支持 XIP,可直接执行 |
| NAND Flash | ~20 MB/s | 64KB - 256KB | 需要 ECC,坏块管理 |
| SD/eMMC | ~25 MB/s | 128KB - 512KB | 需要 MMC 协议栈 |
个人经验:如果使用 NAND Flash,SPL 里一定要包含最小化 ECC 引擎。否则读出来的代码可能是错的,你根本不知道问题出在哪。我曾经被 NAND 的位翻转坑过,后来加了硬件 ECC 校验才稳定。
SPL 的调试技巧
SPL 调试很痛苦,因为这时候没有操作系统,没有调试器,甚至串口都可能没配好。我总结了几条实用技巧:
- GPIO 点灯法——在关键步骤前后翻转 GPIO,用示波器看时序
- 串口“裸奔”输出——先配好 UART,用最原始的轮询方式打印信息
- 内存写模式——往 DDR 特定地址写固定模式,复位后用 JTAG 读出来验证
- 看门狗喂狗——如果初始化时间较长,记得喂狗,防止复位
我曾经犯过的错:某次调试 SPL 时,DDR 训练失败,但代码没有做错误处理,直接跳到了下一阶段。结果 U-Boot 加载到错误的内存区域,整个系统行为诡异。后来我在 DDR 初始化后加了校验,失败就打印错误码并死循环。这样问题定位快多了。
总结一下
SPL 虽然代码量不大,但它是整个启动链中最关键的一环。内存初始化、时钟配置、最小化 HAL,这三块做好了,后续的 U-Boot 和 Linux 启动就稳了。
我个人觉得,SPL 开发最考验的是对硬件的理解深度。你没法依赖操作系统帮你抽象,必须直接面对寄存器、时序、信号完整性。嗯,这也是嵌入式系统最有魅力的地方。