4、SPL/第一阶段引导加载程序优化:SPL代码精简、SPL初始化流程优化、SPL与DDR初始化加速

SPL,也就是第一阶段引导加载程序。说白了,它是芯片上电后执行的第一个用户可控的代码段。它的任务很明确:把硬件从“沉睡”状态唤醒,初始化必要的外设,然后加载并跳转到下一级引导程序(比如U-Boot)。

但这里有个矛盾——SPL通常被限制在很小的片上SRAM里运行,可能只有几十KB甚至更少。空间小,任务重,还得快。我见过不少项目,SPL阶段就耗掉了上百毫秒,这还没算DDR初始化的时间。你想想看,如果整个安全启动要求500ms以内完成,SPL就占了一半,后面还怎么玩?

所以,SPL的优化,核心就三个方向:代码精简、初始化流程优化、DDR初始化加速。下面我一个个说。

4.1 SPL代码精简:能砍就砍,能省则省

SPL的代码空间是硬约束。SRAM就那么点大,你塞进去的东西越多,编译出来的镜像就越大,加载和校验的时间自然就长。我个人习惯,在写SPL代码时,脑子里始终绷着一根弦:这段代码真的需要吗?

4.1.1 裁剪不必要的功能模块

U-Boot SPL默认会开启很多功能,比如文件系统支持、网络协议栈、命令行解析器。这些在SPL阶段基本用不上。我的做法是,在配置阶段就把它们统统关掉。

核心原则:SPL只做三件事——初始化时钟、初始化DDR、加载下一级镜像。其他一切功能都是冗余。

具体来说,你可以检查以下几个配置项:

  • CONFIG_SPL_FS_EXT4:除非你从EXT4分区加载镜像,否则关掉。
  • CONFIG_SPL_NET_SUPPORT:网络启动?SPL阶段基本用不上。
  • CONFIG_SPL_LIBCOMMON_SUPPORT:这个会引入printf等标准库函数,能省则省。
  • CONFIG_SPL_SERIAL_SUPPORT:调试阶段可以开,量产阶段建议关掉。串口打印本身就很慢。

我的经验:我在一个ARM Cortex-A7的项目上,通过关闭不必要的SPL功能,把镜像从120KB压缩到了48KB。加载时间直接从15ms降到了6ms。效果立竿见影。

4.1.2 使用轻量级库和自定义实现

标准C库的函数虽然方便,但体积大。比如memset、memcpy这些,如果能自己写一个精简版本,效果会好很多。我经常这么干:

// 精简版memcpy,只处理4字节对齐的情况
void __attribute__((optimize("Os"))) fast_memcpy(void *dst, const void *src, size_t len) {
    uint32_t *d = dst;
    const uint32_t *s = src;
    len >>= 2;
    while (len--) {
        *d++ = *s++;
    }
}

嗯,这里要注意,__attribute__((optimize("Os")))是告诉编译器,这段代码以优化尺寸为首要目标。你想想看,编译器默认的优化等级是-O2,它可能会为了速度而展开循环,导致代码膨胀。我们手动指定Os,就能避免这个问题。

4.2 SPL初始化流程优化:先做要紧的,再做次要的

SPL的初始化流程,说白了就是一堆硬件模块的“唤醒”顺序。这个顺序很有讲究。我见过一些工程师,把所有的初始化都堆在一起,不管三七二十一,全部跑一遍。结果呢?很多外设根本用不上,白白浪费时间。

4.2.1 延迟初始化非关键外设

SPL的核心目标是加载下一级镜像。所以,只有那些直接影响镜像加载的外设才需要立即初始化。其他的,统统可以推迟到U-Boot或内核阶段。

举个例子,SPL需要从eMMC加载镜像,那么eMMC控制器和相关的GPIO必须初始化。但如果你还顺便把USB、I2C、SPI都初始化了,那就是在浪费生命。

我曾经踩过的坑:有一次,我在SPL里初始化了所有外设,包括一个用于调试的UART。结果发现,仅仅因为UART的波特率设置不当,导致整个SPL启动卡住了。后来我改成按需初始化,问题迎刃而解。所以,不要初始化你不需要的东西。

4.2.2 并行化初始化步骤

如果硬件支持,某些初始化步骤可以并行执行。比如,时钟PLL锁定和DDR控制器复位,这两个操作通常可以同时进行。我习惯在代码里这样安排:

void spl_init_sequence(void) {
    // 第一步:启动PLL(非阻塞)
    pll_start(PLL_CPU);
    pll_start(PLL_DDR);

    // 第二步:在等待PLL锁定的同时,初始化其他低延迟外设
    gpio_init();
    uart_init();  // 如果调试需要

    // 第三步:等待PLL锁定
    while (!pll_is_locked(PLL_CPU));
    while (!pll_is_locked(PLL_DDR));

    // 第四步:初始化DDR控制器
    ddr_controller_init();
}

你看,PLL启动后,我们并没有傻等,而是利用这段时间去初始化GPIO和UART。虽然省下的时间可能只有几微秒,但在SPL这种“寸秒寸金”的阶段,积少成多。

4.3 SPL与DDR初始化加速:内存是性能的基石

DDR初始化,是SPL阶段最耗时的操作之一。一个典型的DDR初始化流程,包括PLL配置、时序训练、ZQ校准等步骤,动辄几十毫秒。如果DDR容量大、频率高,这个时间还会更长。

为什么会这样?因为DDR的时序参数需要精确匹配硬件布局和温度环境。训练过程就是通过反复读写,找到最优的时序窗口。这个过程没法跳过,但我们可以优化它。

4.3.1 使用预置参数替代全自动训练

很多SoC的DDR控制器支持“全自动训练”模式。这种模式虽然方便,但会执行大量的读写测试,耗时很长。如果你的硬件设计是固定的,PCB走线长度也是确定的,那么完全可以使用预置参数来跳过训练过程。

我建议的做法是:在开发阶段,先用全自动训练跑一遍,把训练出来的参数记录下来。然后把这些参数硬编码到SPL代码里。量产时,直接加载这些参数,跳过训练。

方法 初始化时间 适用场景
全自动训练 30-50ms 开发调试、硬件未定型
预置参数 1-3ms 量产、硬件设计固定
半自动训练(快速模式) 5-10ms 需要兼顾兼容性和速度

我的经验:在一个量产项目中,我把DDR初始化从全自动训练改成了预置参数,时间从45ms降到了2ms。但要注意,预置参数对温度敏感。如果产品工作温度范围很宽(比如-40°C到85°C),建议保留一个简化的训练步骤,或者使用半自动训练模式。

4.3.2 优化DDR控制器的配置顺序

DDR控制器的初始化,通常有固定的寄存器配置顺序。但不同的SoC,这个顺序可能略有不同。我建议仔细阅读芯片手册,找到最精简的配置序列。

举个例子,有些控制器允许你在配置完基本参数后,立即开始访问内存,而不需要等待所有校准完成。这种情况下,你可以把耗时较长的ZQ校准放到后台执行,或者干脆延迟到U-Boot阶段再做。

void ddr_init_fast(void) {
    // 1. 配置PLL和时钟
    ddr_pll_config(DDR_FREQ_1600);
    
    // 2. 配置控制器基本参数(行列地址、突发长度等)
    ddr_ctrl_config();
    
    // 3. 执行快速时序训练(只做必要的读写校准)
    ddr_training_quick();
    
    // 4. 立即返回,让SPL继续执行
    // ZQ校准放到后台或延迟执行
    ddr_zq_calibration_start();  // 非阻塞启动
}

你看,这里的关键是ddr_zq_calibration_start()。它只是发起一个校准命令,然后立即返回。SPL可以继续加载镜像,而DDR控制器会在后台完成校准。等U-Boot启动时,校准早就完成了。

4.4 本章小结:一张图看懂SPL优化

说了这么多,我画了一张图,帮你梳理一下SPL优化的核心逻辑。你可以把它当作一个检查清单,每次做SPL优化时,对照着看一遍。

SPL优化核心逻辑 SPL优化目标 代码精简 初始化流程优化 DDR初始化加速 裁剪功能模块 使用轻量级库 延迟初始化非关键外设 并行化初始化步骤 使用预置参数 优化配置顺序 核心原则:SPL只做三件事 初始化时钟 → 初始化DDR → 加载下一级镜像

这张图把SPL优化的三个方向串起来了。你从“SPL优化目标”出发,沿着箭头往下走,每个分支都有具体的落地方法。我个人建议,每次做SPL优化时,都拿这张图对照一下,看看自己有没有遗漏。

好了,SPL优化就聊到这里。记住,SPL是安全启动的第一道关卡,它的性能直接影响整个启动链路的体验。代码精简、流程优化、DDR加速,这三板斧用好了,你的SPL就能跑得又快又稳。