一级Bootloader(SPL):从零开始的第一棒
大家好,我是你们的嵌入式系统讲师。今天我们来聊聊启动流程中的第一棒——SPL(Secondary Program Loader,其实叫一级Bootloader更准确)。很多同学刚接触时容易把名字搞混,记住:SPL就是芯片上电后执行的第一个用户程序。
我个人习惯把SPL比作「接力赛的第一棒」。它不需要跑完全程,但必须把接力棒稳稳交给下一棒。这个比喻很贴切,因为SPL的任务确实很有限——初始化必要硬件,加载真正的Bootloader,然后跳转。
SPL到底在干什么?
说白了,SPL就是一段「最小化」的启动代码。芯片上电后,内部ROM会执行一段固化代码,然后跳转到SPL。SPL要做的事情其实不多:
- 初始化时钟——让CPU跑起来,别用默认的慢速时钟
- 初始化DDR——这是最关键的,没有内存啥也干不了
- 初始化存储控制器——比如NAND、eMMC、SD卡
- 加载下一级Bootloader——从存储介质读到DDR里
- 跳转执行——把控制权交给下一棒
嗯,这里要注意:SPL不负责加载操作系统内核,那是后面UBoot或类似Bootloader的事。SPL只负责把「真正的Bootloader」请进内存。
代码规模限制——为什么SPL必须「瘦」?
这个问题我在项目中遇到过好几次。很多新手写SPL时,习惯性地加了很多功能,结果编译出来超了。为什么会这样?
因为SPL的存储空间极其有限。芯片内部SRAM通常只有几十KB到几百KB。比如我常用的某款Cortex-A7芯片,内部SRAM只有128KB。你想想看,SPL代码、堆栈、数据段全挤在这128KB里,能写多少东西?
| 芯片类型 | 内部SRAM大小 | SPL最大限制 |
|---|---|---|
| 低端MCU | 16KB - 64KB | 8KB - 32KB |
| 中端应用处理器 | 64KB - 256KB | 32KB - 128KB |
| 高端SoC | 256KB - 1MB | 128KB - 512KB |
所以SPL的代码必须精简。我见过最极端的案例,有人把SPL压缩到4KB以内,只做DDR初始化和跳转。当然,现在大部分芯片的SRAM都大了不少,但「精简」依然是SPL的第一原则。
核心原则:SPL只做「不得不做」的事,其他功能一律交给下一级Bootloader。
DDR初始化——最头疼的一步
DDR初始化是SPL里最复杂、最容易出问题的部分。为什么?因为DDR的时序参数极其敏感,稍微配错一个寄存器,系统就起不来。
我记得有一次调试,板子死活跑不到DDR3的标称频率。查了两天,最后发现是ODT(片上端接)配置不对。这种坑,踩过一次就记住了。
DDR初始化通常包含以下几个步骤:
- 配置DDR控制器——设置地址映射、突发长度、CAS延迟等
- 配置PHY层——调整DQS选通、读写平衡
- 校准时序——通过训练算法找到最佳采样点
- 初始化DDR颗粒——发送MRS命令,设置模式寄存器
- 验证读写——简单写读测试,确认DDR工作正常
这里我给大家一个简单的DDR初始化代码框架,以某款Cortex-A7平台为例:
/* DDR初始化伪代码 */
void ddr_init(void)
{
/* 1. 使能DDR时钟 */
clock_enable(DDR_CLK);
/* 2. 配置DDR控制器 */
ddr_ctrl_regs->CONFIG = 0x12345678;
ddr_ctrl_regs->TIMING = 0xABCDEF01;
/* 3. 配置PHY */
ddr_phy_regs->DQ_IMPS = 0x1F;
ddr_phy_regs->DQS_IMPS = 0x1F;
/* 4. 等待PHY初始化完成 */
while (!(ddr_phy_regs->STATUS & PHY_READY));
/* 5. 发送ZQ校准命令 */
ddr_ctrl_regs->CMD = ZQ_CALIB;
/* 6. 简单读写测试 */
*((volatile uint32_t *)DDR_BASE) = 0xDEADBEEF;
if (*((volatile uint32_t *)DDR_BASE) != 0xDEADBEEF) {
/* 初始化失败,进入错误处理 */
error_handler();
}
}
警告:DDR初始化代码必须放在SRAM中执行,不能放在DDR里。因为DDR还没初始化好,代码放进去也读不出来。我曾经见过有人犯这个低级错误,debug了一整天。
跳转逻辑——接力棒怎么交?
SPL的最后一步是跳转到下一级Bootloader。这个跳转不是简单的函数调用,而是「彻底移交控制权」。具体来说:
- 关闭中断——跳转前必须关掉所有中断,防止跳转过程中被打断
- 清理缓存——把D-Cache里的数据刷回DDR,否则下一级读到的可能是脏数据
- 设置栈指针——为下一级准备好栈环境
- 传递参数——比如DDR大小、启动介质类型等信息
- 跳转到入口地址——用绝对跳转指令,不能用相对跳转
跳转代码通常用汇编写,因为C语言不好控制寄存器的最终状态。我给大家看一段典型的跳转代码:
/* 跳转到下一级Bootloader */
.section .text.jump
.globl jump_to_uboot
jump_to_uboot:
/* 关闭所有中断 */
CPSID IF
/* 清理D-Cache */
MOV r0, #0
MCR p15, 0, r0, c7, c14, 0 /* 清空D-Cache */
DSB
/* 设置栈指针 */
LDR sp, =UBOOT_STACK_TOP
/* 传递参数:r0 = DDR大小, r1 = 启动介质类型 */
LDR r0, =DDR_SIZE
LDR r1, =BOOT_MEDIA
/* 绝对跳转到UBoot入口 */
LDR pc, =UBOOT_ENTRY
小技巧:跳转前最好把SPL用过的全局变量清零,避免下一级误读。我习惯在跳转前把BSS段全部清零,虽然UBoot自己也会做,但多一步总没坏处。
避坑指南——我踩过的那些坑
做SPL开发这么多年,我总结了几条血泪教训:
- DDR时序参数别乱抄——不同PCB布局、不同批次的内存颗粒,时序参数都可能不同。我曾经直接抄参考设计,结果板子跑在高温下就挂。
- 注意SPL的链接地址——SPL必须链接到SRAM的起始地址,不能链接到DDR地址。这个错误很隐蔽,编译不报错,但运行就死。
- 跳转前关MMU——如果SPL开了MMU,跳转前必须关掉。否则下一级Bootloader访问的虚拟地址和物理地址对不上。
- 预留足够的栈空间——SPL的栈通常只有几KB,别在SPL里做递归调用或者大数组分配。
嗯,今天就讲到这里。SPL虽然代码量不大,但它是整个启动流程的基石。DDR初始化搞不定,后面全是白搭。下一章我们会讲二级Bootloader(UBoot)的加载过程,到时候你会看到SPL和UBoot是怎么配合的。
记住:SPL的核心就是「快、小、稳」。别想着在SPL里加花哨功能,那不是它的职责。