3、SPL(Secondary Program Loader)详解:初始化DDR、时钟、串口

好,咱们接着聊。上一章我们把 BootROM 讲透了,它干完活就把接力棒交给了 SPL。SPL 是什么?说白了,它是 U-Boot 的“精简版”,或者叫“前传”。

为什么需要 SPL?你想想看,BootROM 通常只有几十 KB,它没能力去初始化大容量的 DDR。而 U-Boot 本体又太大,放不进片内 SRAM。所以,中间加一个 SPL,它体积小,刚好能塞进 SRAM,先把 DDR 搞起来,再把 U-Boot 从 Flash 里搬到 DDR 里运行。

我个人习惯把 SPL 比作“先遣队”。它不负责打仗,只负责把大部队(U-Boot)的营地(DDR)给搭好。

3.1 SPL 的使命:三件大事

SPL 的核心任务,归纳起来就三件:初始化时钟、初始化 DDR、初始化串口。这三件事有严格的先后顺序。

  • 先搞时钟:没有时钟,CPU 和总线都动不了。
  • 再搞串口:串口是调试的“眼睛”,搞定了才能看打印信息。
  • 最后搞 DDR:DDR 是重头戏,也是最容易翻车的地方。

嗯,这里要注意:串口初始化其实可以放在 DDR 之前,因为串口不需要大内存,只需要几个寄存器。早点打开串口,方便我们看 SPL 的执行日志。

核心逻辑图:SPL 初始化流程

SPL 入口 ① 初始化时钟 ② 初始化串口 ③ 初始化 DDR

3.2 时钟初始化:让心脏跳起来

时钟就是 SoC 的心脏。SPL 刚启动时,系统通常跑在一个很慢的内部振荡器上,比如 24MHz 或者 32kHz。这个速度连点亮一个 LED 都嫌慢,更别说初始化 DDR 了。

我见过不少新手,上来就调 DDR 参数,结果怎么调都不对。其实问题出在时钟上——PLL 没锁住,或者分频系数错了。

时钟初始化的典型步骤:

  1. 配置 PLL:把低频时钟倍频到高频,比如从 24MHz 倍到 1.2GHz。
  2. 设置分频:为 CPU、总线、外设分配不同的时钟频率。
  3. 切换时钟源:从低速振荡器切换到 PLL 输出。
  4. 等待稳定:检查 PLL 的锁定标志位。

来看一段实际的代码(以某 ARM SoC 为例):

void spl_clock_init(void)
{
    // 1. 解锁 PLL 控制寄存器
    writel(0xDEADBEEF, &pll->lock);
    
    // 2. 设置倍频系数:24MHz * 50 = 1.2GHz
    writel((50 << 16) | (1 << 0), &pll->cfg);
    
    // 3. 等待 PLL 锁定
    while (!(readl(&pll->stat) & (1 << 3)))
        ;
    
    // 4. 切换 CPU 时钟源到 PLL
    writel(0x2, &clk->cpu_sel);
    
    // 5. 设置串口时钟分频
    writel(0x1, &clk->uart_div);
    
    printf("SPL: Clock init done, CPU @ 1.2GHz\n");
}

避坑指南:我曾经在一个项目里,PLL 配置好了但忘记等待锁定标志,结果 DDR 初始化时总线时钟还没稳定,导致系统随机死机。排查了整整两天才发现是少了个 while 循环。记住:时钟切换后,一定要等 PLL 锁定

3.3 串口初始化:打开调试的窗户

串口初始化相对简单,但极其重要。没有串口,你就像蒙着眼睛在调试。

串口初始化的核心参数:

参数 典型值 说明
波特率 115200 常用标准速率,太慢影响调试效率
数据位 8 标准配置
停止位 1 标准配置
校验位 嵌入式调试通常不校验
流控 SPL 阶段不需要硬件流控

代码实现上,其实就是配置几个寄存器:

void spl_uart_init(void)
{
    // 设置波特率分频器
    // 假设时钟 24MHz,目标 115200
    // 分频值 = 24MHz / (16 * 115200) ≈ 13
    writel(13, &uart->divisor);
    
    // 设置数据格式:8N1
    writel(0x3, &uart->lcr);  // 8位数据,1位停止,无校验
    
    // 使能 UART
    writel(0x1, &uart->cr);
    
    // 发送一个字符验证
    uart_putc('U');
}

你想想看,如果串口没配好,后面 DDR 初始化失败了,你连个错误信息都看不到,那得多憋屈?所以我的习惯是:SPL 一上来,先把串口点亮,哪怕只打印一个字符,也证明系统活着。

3.4 DDR 初始化:最硬核的部分

DDR 初始化是 SPL 里技术含量最高的部分。它涉及时序、电压、阻抗匹配、刷新率等一堆参数。任何一个参数不对,系统要么直接挂掉,要么跑一段时间后随机崩溃。

DDR 初始化的标准流程:

  1. 配置 DDR 控制器:设置地址映射、突发长度、CAS 延迟等。
  2. 配置 PHY:设置 DQ 延时、DQS 延时、ODT 等。
  3. 发送 ZQ 校准命令:让 DDR 芯片自动校准阻抗。
  4. 发送 MRS 命令:配置模式寄存器,如读写延迟。
  5. 执行训练:读写训练,找到最佳采样点。
  6. 验证:写入并读取测试数据,确认 DDR 工作正常。

警告:DDR 初始化参数与 PCB 布线长度、DDR 芯片型号、工作频率强相关。不要直接复制其他板子的参数!我曾经在一个项目里,直接用了参考设计的 DDR 参数,结果板子跑 800MHz 没问题,一上 1066MHz 就随机死机。最后发现是 PCB 走线等长差了 2mm,导致 DQS 与时钟的相位偏移过大。

来看一段 DDR 初始化的核心代码片段:

void spl_ddr_init(void)
{
    // 1. 复位 DDR 控制器
    writel(0x1, &ddrc->reset);
    udelay(100);
    writel(0x0, &ddrc->reset);
    
    // 2. 配置时序参数
    // CAS 延迟 = 11,tRCD = 11,tRP = 11
    writel((11 << 16) | (11 << 8) | (11 << 0), &ddrc->timing);
    
    // 3. 配置 DDR 大小:512MB
    writel(0x20000000, &ddrc->size);
    
    // 4. 使能 DDR 控制器
    writel(0x1, &ddrc->ctrl);
    
    // 5. 等待初始化完成
    while (!(readl(&ddrc->stat) & (1 << 0)))
        ;
    
    // 6. 简单读写测试
    volatile uint32_t *test_addr = (uint32_t *)0x80000000;
    *test_addr = 0xA5A5A5A5;
    if (*test_addr == 0xA5A5A5A5) {
        printf("SPL: DDR init OK @ 0x80000000\n");
    } else {
        printf("SPL: DDR init FAILED!\n");
        while(1);  // 死在这里,方便调试
    }
}

为什么我要在初始化后做读写测试?因为 DDR 初始化失败是“静默”的——它不会报错,但数据读写会出错。如果不测试,后面 U-Boot 加载到 DDR 里,跑着跑着就崩了,你根本不知道是 DDR 的问题还是 U-Boot 的问题。

3.5 避坑总结

做 SPL 开发这几年,我踩过的坑不少。给你列几个最常见的:

  • 时钟源切换顺序:先切外设时钟,再切 CPU 时钟。反过来可能导致总线崩溃。
  • DDR 电压:DDR3 是 1.5V,DDR4 是 1.2V,搞错了会烧芯片。我见过有人把 DDR4 当 DDR3 用,板子冒烟了。
  • ODT 配置:片上端接电阻的值要匹配 PCB 阻抗。太大信号反射,太小信号过冲。
  • 刷新率:DDR 需要定期刷新,否则数据会丢。温度越高,刷新间隔要越短。

个人经验:调试 DDR 时,我习惯先用低速(比如 400MHz)跑通,确认基本功能没问题后,再逐步提高频率。这样如果高频出问题,可以快速定位是时序问题还是硬件问题。

好了,SPL 的三件大事就讲到这里。记住:时钟是基础,串口是工具,DDR 是目标。每一步都要稳扎稳打,别急着跳步。


公众号:蓝海资料掘金营,微信deep3321