4、一级引导加载程序:SPL/MLO原理、片内SRAM初始化、DDR初始化时序、跳转到uboot
好,咱们进入正题。上一章我们聊了ROM Code,它把SPL从SD卡或eMMC里搬到了片内SRAM。那SPL拿到控制权之后,到底干了些什么?说白了,SPL就是个「小号的bootloader」,它的使命只有一个:把DDR初始化好,然后把完整的U-Boot从存储设备里加载到DDR里,最后跳过去。
我个人习惯把SPL叫做「二级引导程序」,但TI的文档里叫它MLO。名字不重要,重要的是它怎么干活。
4.1 SPL/MLO到底是什么?
SPL,全称是Secondary Program Loader。它被ROM Code加载到片内SRAM里执行。为什么要有SPL?因为片内SRAM太小了,装不下完整的U-Boot。你想想看,一个完整的U-Boot动不动就几百KB,而片内SRAM通常只有64KB到256KB。所以我们需要一个「瘦身版」的U-Boot,先把DDR初始化好,再把完整版U-Boot搬进去。
MLO是TI平台对SPL的称呼。其实它们是一回事。MLO这个名字来源于TI的文档,全称是Memory Loader Object。嗯,这里要注意:MLO文件通常放在SD卡或eMMC的特定分区里,ROM Code会去固定的偏移位置找它。
关键点:SPL/MLO的大小必须小于片内SRAM的大小。比如AM335x的片内SRAM是128KB,那SPL就不能超过这个数。我曾经在项目里遇到过SPL编译出来刚好128KB多一点,结果死活跑不起来——后来发现是链接脚本没配好,多塞了几个调试符号进去。
4.2 片内SRAM初始化
SPL被加载到片内SRAM后,第一件事是什么?不是初始化DDR,而是先把自己安顿好。具体来说,SPL需要做以下几件事:
- 设置栈指针:SPL需要栈来调用函数。栈通常放在片内SRAM的高地址区域。
- 清零BSS段:全局变量和静态变量需要初始化为0。
- 初始化时钟:CPU主频、外设时钟都要配好。没有时钟,DDR根本没法工作。
- 初始化引脚复用:DDR控制器的引脚需要配置成正确的功能。
这里我提一个坑:片内SRAM的地址范围,不同芯片不一样。比如i.MX6的片内SRAM在0x00900000,而AM335x的在0x402F0000。你写链接脚本的时候一定要查对数据手册。我曾经见过一个同事把链接地址写错了,SPL加载进去后直接跑飞,查了两天才找到原因。
小技巧:调试SPL的时候,可以在片内SRAM里放一个LED闪烁的代码。如果LED亮了,说明SPL已经成功加载并执行了。这是最原始的「Hello World」调试法。
4.3 DDR初始化时序
DDR初始化是SPL里最核心、也最容易出问题的地方。DDR不像片内SRAM,上电就能用。它需要一套严格的时序来初始化。我把它拆成几个步骤:
| 步骤 | 操作 | 说明 |
|---|---|---|
| 1 | DDR控制器复位 | 将DDR控制器置于已知状态 |
| 2 | 配置DDR时序参数 | 设置CAS延迟、突发长度、刷新周期等 |
| 3 | DDR PHY初始化 | 训练DQS、DQ信号,校准阻抗 |
| 4 | 发送ZQ校准命令 | DDR颗粒内部阻抗校准 |
| 5 | 发送MRS命令 | 设置DDR模式寄存器 |
| 6 | 写DDR验证 | 写入并回读数据,确认DDR工作正常 |
嗯,这里要注意:DDR时序参数必须和你的DDR颗粒型号匹配。不同厂家的DDR颗粒,时序参数可能不一样。比如三星的DDR3和镁光的DDR3,tRCD、tRP这些参数就有差异。我建议你在项目初期就把DDR颗粒的datasheet拿到手,对照着配参数。
DDR初始化代码通常长这样:
/* DDR控制器配置示例(伪代码) */
void ddr_init(void)
{
/* 1. 复位DDR控制器 */
ddr_ctrl_reset();
/* 2. 配置时序参数 */
ddr_ctrl_set_timing(DDR_TIMING_T_RCD, 13); /* tRCD = 13 cycles */
ddr_ctrl_set_timing(DDR_TIMING_T_RP, 13); /* tRP = 13 cycles */
ddr_ctrl_set_timing(DDR_TIMING_T_RAS, 36); /* tRAS = 36 cycles */
/* 3. 初始化DDR PHY */
ddr_phy_init();
/* 4. 发送ZQ校准 */
ddr_send_zq_cal();
/* 5. 设置模式寄存器 */
ddr_send_mrs(0, 0x00000432); /* CAS=6, BL=8 */
/* 6. 验证DDR */
if (ddr_verify() != 0) {
/* 初始化失败,进入错误处理 */
error_handler();
}
}
这段代码看起来简单,但实际调试起来很痛苦。我曾经在一个项目里,DDR初始化后读写总是出错,后来发现是PCB走线长度不匹配导致的信号延迟问题。嗯,硬件问题有时候比软件问题更难排查。
警告:DDR初始化失败后,千万不要直接跳转到U-Boot。否则U-Boot在DDR里跑起来,读到的全是乱码,系统会直接崩溃。我建议在DDR验证失败时,让SPL进入一个死循环,或者打印错误信息到串口。
4.4 跳转到U-Boot
DDR初始化成功后,SPL就可以从存储设备(SD卡、eMMC、NAND Flash等)里把完整的U-Boot加载到DDR里了。加载完成后,SPL会做最后一件事:跳转到U-Boot的入口地址。
跳转的方式很简单,就是用一个函数指针:
/* 跳转到U-Boot */
void jump_to_uboot(void)
{
void (*uboot_entry)(void) = (void (*)(void))CONFIG_SYS_TEXT_BASE;
/* 关闭中断,清理缓存 */
disable_interrupts();
flush_cache_all();
/* 跳转 */
uboot_entry();
}
这里有个细节:跳转之前,SPL需要把CPU的状态清理干净。比如关闭中断、刷新缓存、设置好栈指针。否则U-Boot启动时可能会被残留的中断信号干扰。
我个人习惯在跳转前打印一行日志,比如「Jumping to U-Boot at 0x87800000...」。这样如果U-Boot没起来,我至少知道SPL已经成功完成了它的工作。
4.5 避坑指南
最后,我总结几个SPL开发中常见的坑:
- SPL太大:超过片内SRAM大小,加载后直接溢出。解决办法是优化代码,去掉不必要的功能。
- DDR时序配错:DDR初始化后读写不稳定。解决办法是严格按照datasheet配参数,必要时用示波器量信号。
- 跳转地址不对:U-Boot的链接地址和加载地址不一致。解决办法是检查U-Boot的链接脚本,确保CONFIG_SYS_TEXT_BASE正确。
- 缓存没清理:跳转前D-cache里的数据没写回DDR,导致U-Boot读到旧数据。解决办法是在跳转前调用flush_cache_all()。
我曾经在一个项目里,SPL加载U-Boot后总是跑飞,查了两天发现是跳转前没关中断。U-Boot启动时,一个定时器中断突然进来,把程序计数器打乱了。从那以后,我每次跳转前都会检查中断状态。
好了,这一章就到这里。下一章我们聊聊U-Boot的启动流程,看看它从DDR里起来后,又做了哪些事情。