第4章:启动流程第一阶段(SPL)——从ROM到DDR的惊险一跳

好,咱们进入正题。上一章聊了ROM Code怎么把SPL从Flash里捞出来,扔到SRAM里跑起来。那SPL拿到接力棒之后,到底要干些什么?说白了,SPL就是个「过渡人」——它要把一个光溜溜的SoC,变成能跑完整U-Boot的环境。

我刚开始接触SPL时,总觉得它就是个「小号的U-Boot」。后来被坑了几次才明白,SPL的使命非常单一:初始化最小系统,加载并跳转到下一级Bootloader。它不需要文件系统,不需要网络协议栈,甚至不需要完整的命令行。你想想看,SRAM通常只有几十KB到几百KB,哪塞得下那么多东西?

4.1 SPL的核心职责

SPL到底要干哪些活?我把它拆成四个关键步骤:

  1. 设置CPU为SVC模式——关中断、清MMU、设栈指针
  2. 初始化时钟系统——让CPU和总线跑在目标频率
  3. 初始化DDR控制器——把外部DRAM训练好、能用
  4. 加载U-Boot主体——从Flash/NAND/SD卡读到DDR里,然后跳过去

这里面,第2步和第3步是最容易翻车的。我曾在某个项目里,DDR时序参数配错了一个寄存器,结果SPL跑完跳转时直接死机,连个日志都没留下。嗯,那感觉就像你开了门却发现地板是透明的——直接掉下去了。

SPL vs U-Boot 主体对比

维度 SPL U-Boot主体
运行位置 内部SRAM(通常) 外部DDR
代码大小 16KB~128KB 256KB~2MB+
功能范围 最小系统初始化 + 加载 命令行、网络、文件系统、启动OS
调试难度 极高(无串口时只能点灯) 中等(有串口有网络)
典型入口 _start / reset_vector board_init_f / board_init_r

4.2 SPL入口点分析——从哪开始跑?

SPL的入口点,通常就是芯片复位后的第一个有效指令地址。但这里有个细节:SPL的入口和U-Boot主体的入口是同一个符号,都叫 _start 或 reset_vector。那怎么区分谁是谁?

答案在链接脚本里。SPL有自己独立的链接脚本,通常叫 u-boot-spl.lds。它会告诉链接器:代码段放SRAM的哪个地址,数据段放哪,栈顶在哪。

我习惯先看 arch/arm/cpu/armv7/start.S(以ARMv7为例),SPL和U-Boot主体都从这里开始。区别在于编译宏:

#ifdef CONFIG_SPL_BUILD
    /* SPL 特有的初始化 */
    bl  lowlevel_init
    bl  s_init
#else
    /* U-Boot 主体特有的初始化 */
    bl  board_init_f
#endif

你看,同一个文件,同一个入口,靠宏来分流。这种做法很常见,但也容易让人混淆。我建议你在调试时,先在 _start 处加个点灯代码,确认SPL确实跑起来了。

我的调试小技巧: 在 _start 后立即写一个GPIO翻转循环。如果LED闪烁,说明CPU已经执行到SPL入口。如果没闪,问题出在ROM Code或启动设备选择上。

4.3 时钟初始化——让SoC跑起来

SPL进来时,芯片通常跑在内部低速振荡器上,比如24MHz或32kHz。这个频率跑代码没问题,但要初始化DDR就太慢了——DDR控制器需要几百MHz的时钟才能正常工作。

时钟初始化的典型流程:

  1. 锁定PLL:配置主PLL、DDR PLL、外设PLL
  2. 切换时钟源:从低速振荡器切到PLL输出
  3. 配置分频器:给CPU、总线、外设分配合适频率
  4. 等待稳定:轮询PLL锁定标志位

以i.MX6为例,时钟初始化代码通常在 board/freescale/mx6ul_14x14_evk/spl.c 里:

void board_init_f(ulong dummy)
{
    /* 设置系统时钟 */
    clock_init();

    /* 初始化串口(可选,用于调试) */
    preloader_console_init();

    /* 初始化DDR */
    ddr_init();

    /* 加载U-Boot */
    board_boot_order(&spl_boot_list);
}

这里有个坑:时钟顺序不能乱。我遇到过一位同事,先初始化了DDR PLL,再切系统时钟,结果DDR控制器因为时钟域不同步直接挂掉。正确的做法是:先让系统时钟稳定,再初始化DDR时钟域。

警告: 某些SoC的DDR控制器和CPU共享同一个PLL。如果你改了CPU频率,DDR频率也会跟着变。这时必须重新训练DDR,否则数据读写会出错。我曾在TI AM335x上踩过这个坑,排查了整整两天。

4.4 DDR初始化——最考验耐心的环节

DDR初始化,说白了就是让外部DRAM芯片和SoC的DDR控制器「对上话」。这个过程涉及大量时序参数,任何一个不对,系统就起不来。

典型的DDR初始化步骤:

  • 配置DDR控制器寄存器:设置行列地址宽度、Burst长度、CAS延迟等
  • 设置PHY参数:调整DQS/DQ的延迟、ODT阻抗、驱动强度
  • 执行DDR训练:写读校验、眼图扫描、Write Leveling
  • 验证可用性:写一个模式再读回来,确认数据通路正常

我见过最头疼的情况是:DDR初始化成功,但跑一段时间后随机死机。后来发现是ODT(片上端接)配置不对,导致信号反射干扰。嗯,这种问题用示波器都很难抓,只能靠反复调整参数。

下面是一个简化的DDR初始化流程SVG图,帮你理清逻辑:

SPL DDR初始化流程 SPL DDR初始化入口 配置DDR PLL与时钟域 配置DDR控制器寄存器 配置DDR PHY与ODT 执行DDR训练与校验 读写验证通过? 失败重试 成功 → 加载U-Boot

4.5 避坑指南——我踩过的那些坑

做SPL移植这么多年,我总结了几条血泪教训:

  • DDR训练失败不要慌:先检查时钟频率是否正确,再检查PHY的延迟参数。我遇到过因为PCB走线长度差异导致DQS延迟需要微调的情况。
  • SPL代码不要超过SRAM大小:链接时一定要看map文件。我曾经因为多加了几个调试打印,SPL体积超了2KB,结果链接器静默地把代码放到了DDR地址上——而DDR还没初始化呢!
  • 串口输出不一定可靠:在DDR初始化完成前,串口可能还没配好。这时可以用GPIO点灯或JTAG来调试。
  • 注意Cache和MMU的状态:SPL阶段通常关MMU、关Cache。如果你不小心开了,访问DDR时可能会读到脏数据。

我的习惯: 每次改DDR参数后,先在SPL里做一次完整的读写校验——写0xAA、0x55、0x00000000、0xFFFFFFFF到每个地址,再读回来比对。这个测试虽然慢,但能发现90%的DDR问题。

4.6 小结

SPL阶段是整个Bootloader流程里最脆弱的一环。它没有完整的调试环境,没有文件系统,甚至没有可靠的串口输出。但正是这个「小不点」,决定了你的系统能不能从ROM顺利过渡到DDR,从裸机过渡到完整的U-Boot环境。

我个人觉得,SPL移植最考验的是对芯片数据手册的理解深度。时钟树怎么走?DDR时序参数怎么算?这些在手册里都有,但需要你一行一行地啃。别怕麻烦,我当年为了调通一个DDR训练,把500页的DDR控制器手册翻了三遍。