第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 初始化的典型步骤

  1. 配置 DDR 控制器——设置地址映射、突发长度、CAS 延迟等
  2. 设置 PHY 层——调整 DQ/DQS 的延迟,保证数据眼图张开
  3. 执行 ZQ 校准——校准输出驱动强度,匹配板级阻抗
  4. 运行 DDR 训练——读写训练、VREF 训练、写均衡
  5. 验证内存可用——简单读写测试,确认无误

我曾经踩过的坑:某次项目用了新批次 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 典型执行流程 Boot ROM 跳转到 SPL 最小化初始化(栈、异常向量) 时钟配置(PLL + 分频) DDR 初始化 + 训练 从存储介质加载 U-Boot/Linux 跳转到下一级入口 关键说明 SPL 必须在 4KB-64KB 内 完成所有初始化工作 代码体积是硬约束

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 开发最考验的是对硬件的理解深度。你没法依赖操作系统帮你抽象,必须直接面对寄存器、时序、信号完整性。嗯,这也是嵌入式系统最有魅力的地方。