3、一级Bootloader(SPL):SPL的加载与执行、SPL的内存初始化、SPL的设备树传递
好,我们接着往下走。上一章聊了ROM Code怎么把SPL从启动介质里捞出来,扔到SRAM里。那现在SPL已经躺在SRAM里了,CPU也跳过去执行了。接下来会发生什么?
说白了,SPL的任务就三个:把自己喂饱、把内存点亮、把接力棒传好。我习惯把SPL叫做“最小硬件初始化器”,它不负责加载完整系统,只负责把最关键的外设——尤其是DDR——搞定。
3.1 SPL的加载与执行:从SRAM到DDR的惊险一跳
SPL被ROM Code加载到SRAM后,CPU从复位向量跳过来。这时候SPL的代码还在SRAM里跑。SRAM虽然快,但容量小得可怜——很多芯片只有几十KB到几百KB。你想想看,这点空间连个完整的内核都塞不下。
所以SPL的第一件事,就是把自己从SRAM里“搬”到DDR里去。嗯,这里要注意:搬的是SPL自己,不是后面的U-Boot。为什么?因为SPL后续要执行更复杂的初始化逻辑,代码量会膨胀,SRAM装不下。
我在项目中遇到过一种情况:某款芯片的SRAM只有64KB,SPL编译出来刚好62KB。结果加了个调试打印,直接爆了。后来我不得不把SPL的代码分段——一部分在SRAM里跑,初始化DDR后立刻跳转到DDR继续执行。这种“分段加载”的技巧,在资源紧张的嵌入式系统里很常见。
SPL执行流程的关键点:
- CPU从ROM Code跳转到SPL入口(通常是SRAM基地址+偏移)
- SPL设置异常向量表、栈指针(SP),确保C语言环境可用
- 初始化时钟、PLL、电源管理单元(PMU)
- 初始化串口(方便调试,我个人习惯一上来就开串口)
- 初始化DDR控制器,完成内存训练
- 将自身或下一级Bootloader(如U-Boot)拷贝到DDR
- 跳转到DDR中的代码继续执行
这里有个容易踩的坑:跳转前的栈指针必须重新设置。我曾经在某个项目里,SPL在SRAM里用的栈地址是0x1000,跳转到DDR后忘了改栈指针,结果栈操作直接写到了SRAM的未初始化区域,系统跑飞了。排查了整整两天才发现。
3.2 SPL的内存初始化:DDR训练的那些事儿
内存初始化是SPL最核心的工作,没有之一。DDR不像SRAM那样上电就能用,它需要一套复杂的初始化序列——供电、时钟、复位、CKE、模式寄存器配置、ZQ校准、读写训练……每一步都不能错。
我刚开始做RISC-V芯片时,觉得DDR初始化不就是写几个寄存器吗?后来发现完全不是这么回事。DDR的时序参数跟PCB布线长度、颗粒型号、温度电压都有关。同一套代码,换一批内存颗粒可能就点不亮。
SPL里通常包含两种DDR初始化方式:
| 方式 | 特点 | 适用场景 |
|---|---|---|
| 硬编码参数 | 时序参数写死在代码里,编译时确定 | 量产板卡,内存颗粒固定 |
| 动态训练 | 上电后自动读写训练,自适应时序 | 开发板、多颗粒兼容场景 |
我个人更推荐动态训练,虽然启动时间会多个几十毫秒,但兼容性好得多。记得有一次客户反馈说他们的板子偶尔启动失败,我远程一看,就是硬编码参数里tRCD设得太紧,温度一高就出错。换成动态训练后问题消失。
避坑指南:我曾经在DDR训练时遇到过“训练通过但跑着跑着就挂”的情况。后来发现是DDR的ODT(片上端接)配置不对。SPL里训练时用的是默认ODT,但实际运行时负载变化,ODT需要动态调整。建议在SPL里把ODT配置成“自动校准”模式,别用固定值。
DDR初始化的典型代码流程(伪代码):
// SPL内存初始化伪代码
void spl_ddr_init(void) {
// 1. 使能DDR时钟和电源
ddr_clock_enable();
ddr_power_enable();
// 2. 复位DDR控制器和PHY
ddr_controller_reset();
ddr_phy_reset();
// 3. 配置DDR时序参数(从设备树或硬编码读取)
ddr_timing_config(tCL, tRCD, tRP, tRAS);
// 4. 执行ZQ校准
ddr_zq_calibration();
// 5. 执行读写训练(动态训练时)
if (dynamic_training_enabled) {
ddr_write_leveling();
ddr_read_leveling();
ddr_vref_training();
}
// 6. 验证内存可用性(写一个模式再读回来)
uint32_t test_pattern = 0xA5A5A5A5;
*(volatile uint32_t *)DDR_BASE = test_pattern;
if (*(volatile uint32_t *)DDR_BASE != test_pattern) {
// 内存初始化失败,进入错误处理
spl_error_handler("DDR init failed!");
}
}
你可能会问:为什么要在SPL里做这么复杂的内存训练?直接让U-Boot做不行吗?答案是:不行。因为U-Boot本身需要运行在DDR里,如果DDR没初始化好,U-Boot连加载都做不到。所以SPL必须先把DDR点亮,才能把U-Boot从Flash里搬过来。
3.3 SPL的设备树传递:扁平结构体的接力赛
设备树(Device Tree)是现代嵌入式系统的标配。SPL需要把硬件信息传递给下一级Bootloader。但SPL本身可能没有完整的设备树解析能力——它太小了,装不下libfdt。
所以SPL通常采用一种“轻量级”的设备树传递方式:扁平结构体。说白了,就是把关键硬件参数(内存大小、时钟频率、外设基地址)打包成一个固定格式的结构体,放在内存的约定位置。下一级Bootloader启动时,直接从这个地址读取。
我在项目中见过三种常见的传递方式:
- 方式一:固定地址传递。SPL把参数写到DDR的某个固定地址(比如0x1000),U-Boot启动时直接读。简单粗暴,但地址冲突风险大。
- 方式二:寄存器传递。SPL把参数塞进某个通用寄存器(比如a0),跳转时带过去。RISC-V的ABI里a0-a7就是干这个用的。
- 方式三:设备树叠加。SPL生成一个极简的设备树片段(只包含修改的节点),附加到主设备树上。这种方式最灵活,但SPL需要支持fdt操作。
注意:如果你用的是方式一,一定要确保那个固定地址不会被其他模块覆盖。我曾经在某个项目里,SPL把参数写到了0x1000,结果U-Boot的堆初始化也用了这个地址,直接把参数冲掉了。后来我改成用寄存器传递,再也没出过问题。
SPL传递设备树的核心数据结构(以RISC-V为例):
// SPL传递给U-Boot的扁平结构体
struct spl_platform_info {
uint32_t magic; // 魔数,用于校验
uint32_t version; // 结构体版本
uint64_t dram_size; // DDR总大小(字节)
uint32_t dram_base; // DDR基地址
uint32_t clock_rate; // CPU主频(Hz)
uint32_t periph_base; // 外设基地址
uint32_t boot_device; // 启动介质类型(0=SPI, 1=SD, 2=NAND)
uint8_t mac_addr[6]; // MAC地址(从OTP读取)
// ... 其他平台特定参数
};
SPL在跳转前,会把这个结构体填充好,然后通过寄存器a0传递结构体地址。U-Boot启动时,第一件事就是读a0,解析这个结构体。如果魔数不对,说明SPL和U-Boot版本不匹配,直接报错。
嗯,这里有个小技巧:魔数一定要选得有个性。我习惯用0xDEADBEEF或者0xCAFEBABE,好记又不容易冲突。曾经有个同事用了0x00000000,结果未初始化的内存也是0,排查了半天才发现是魔数校验通过了但实际上参数全是错的。
最后说一句:SPL的设备树传递,本质上是一种“契约”。SPL和U-Boot必须约定好数据格式、存放位置、版本号。一旦改了其中一方,另一方必须同步更新。我在团队里推行过一个做法:把spl_platform_info的定义放在一个独立的头文件里,SPL和U-Boot都包含它。这样改结构体时,两边都会重新编译,不会出现版本不一致的问题。
本章核心要点:
- SPL在SRAM中启动,完成DDR初始化后跳转到DDR执行
- DDR初始化包括时钟、时序、训练、验证四个阶段,动态训练优于硬编码
- SPL通过扁平结构体或寄存器向U-Boot传递硬件参数,需注意版本兼容性
- 跳转前务必重新设置栈指针,避免内存冲突
公众号:蓝海资料掘金营,微信 deep3321