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传递硬件参数,需注意版本兼容性
  • 跳转前务必重新设置栈指针,避免内存冲突
SPL启动流程核心逻辑图 阶段1:SPL加载 ROM Code → SRAM CPU跳转到SPL入口 阶段2:基础初始化 设置栈指针、异常向量 初始化时钟、串口 阶段3:DDR初始化 时序配置 读写训练 阶段4:代码搬迁 SPL自身或U-Boot → DDR 阶段5:设备树传递 扁平结构体 → 寄存器/内存 阶段6:跳转执行 跳转到DDR中的U-Boot U-Boot在DDR中运行,读取设备树参数,继续加载内核 图例: 加载阶段 初始化阶段 DDR阶段 搬迁阶段 传递阶段 跳转阶段

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