4. SPL阶段(上):SPL的入口函数、堆栈设置、时钟与PLL的早期初始化

好,我们正式开始拆解SPL。很多做嵌入式开发的朋友,可能对SPL又爱又恨——爱它小巧灵活,恨它调试困难。其实说白了,SPL就是一段“最小启动代码”,它的任务只有一个:把U-Boot完整版从存储介质里请出来。

我个人习惯把SPL看作一个“微缩版”的Bootloader。它麻雀虽小,五脏俱全。今天我们先聊它的前半段:入口、堆栈、时钟。这三件事没做好,后面全是白搭。

4.1 SPL的入口函数:从Reset向量到board_init_f

SPL的入口在哪?你打开任何一块ARM SoC的U-Boot源码,第一眼看到的肯定是 arch/arm/cpu/armv7/start.S(以Cortex-A7为例)。这个文件里的 _start 标签,就是SPL的“出生地”。

核心流程:

  • CPU上电后,从0x00000000或ROM映射地址取第一条指令
  • 执行 _start 处的异常向量表
  • 跳转到 reset 标签,进入SPL初始化流程

嗯,这里要注意。很多SoC的ROM Code会先检查启动介质(比如SD卡、NAND Flash),然后把SPL的前4KB或8KB加载到内部SRAM里。所以SPL的入口地址,通常就是SRAM的基地址。我在项目中遇到过,某款芯片的SRAM只有64KB,SPL必须精打细算,连一个字节都不敢浪费。

我们来看一段典型的入口代码:

_start:
    b   reset
    ldr pc, _undefined_instruction
    ldr pc, _software_interrupt
    ldr pc, _prefetch_abort
    ldr pc, _data_abort
    ldr pc, _not_used
    ldr pc, _irq
    ldr pc, _fiq

reset:
    /* 设置CPU为SVC32模式 */
    mrs r0, cpsr
    bic r0, r0, #0x1f
    orr r0, r0, #0xd3
    msr cpsr, r0
    /* 关闭中断 */
    /* ... 后续初始化 ... */

这段代码看起来简单,但每个细节都有讲究。比如 #0xd3 这个值,它把CPU切到SVC模式,同时关掉了IRQ和FIQ。为什么?因为SPL阶段我们不想被任何中断打扰,必须一口气把关键初始化做完。

4.2 堆栈设置:给C语言搭好舞台

汇编跑完几个基本设置后,下一步就是设置堆栈。没有堆栈,C语言函数调用就是空中楼阁。你想想看,局部变量、函数参数、返回地址,全得靠栈来存。

SPL的堆栈设置通常在 _main 函数里完成。这个函数是C环境的“总开关”。

ENTRY(_main)

    /* 设置初始栈指针 */
    ldr sp, =(CONFIG_SPL_STACK)
    /* 设置GD(全局数据)指针 */
    ldr r9, =(CONFIG_SPL_GD_ADDR)
    /* 清空BSS段 */
    ldr r0, =__bss_start
    ldr r1, =__bss_end
    mov r2, #0
    bl  memset

    /* 跳转到board_init_f */
    bl  board_init_f

这里有个关键点:CONFIG_SPL_STACK 和 CONFIG_SPL_GD_ADDR 这两个宏,必须在板级配置里定义好。我曾经踩过一个坑——某次移植SPL时,忘记给堆栈留够空间,结果 board_init_f 里一调用函数就死机。查了两天才发现,是栈顶地址设得太高,和代码段重叠了。

避坑指南:

我曾经在调试SPL时,发现堆栈指针指向了未初始化的SRAM区域。结果第一次压栈就触发异常。后来我养成了一个习惯:在设置完SP后,立刻往栈里写一串0xDEADBEEF,然后在board_init_f末尾检查这些值有没有被覆盖。这样能快速判断栈大小是否够用。

堆栈设置完成后,SPL就有了“呼吸”的能力。接下来就可以调用C函数了。但别高兴太早——时钟还没配呢,CPU可能还跑在几十兆赫兹的慢速时钟下。

4.3 时钟与PLL的早期初始化:让CPU跑起来

时钟初始化,是SPL里最“硬核”的部分。为什么这么说?因为这时候你连串口都还没配,调试全靠点LED或者JTAG。一旦时钟配错,整个系统直接“哑火”。

SPL的时钟初始化,通常放在 board_init_f 里,或者更早的汇编阶段。我见过一些SoC,甚至要求在 _main 之前就把PLL配好,否则CPU根本跑不到C代码入口。

典型的时钟初始化流程如下:

  1. 等待晶振稳定——上电后晶振需要几百微秒才能起振
  2. 配置PLL倍频——把低频晶振(比如24MHz)倍频到CPU目标频率(比如1GHz)
  3. 切换系统时钟源——从低速时钟切换到PLL输出
  4. 配置外设时钟——比如DDR控制器、串口、NAND控制器的时钟分频

我们来看一个实际的PLL配置代码(以某款Cortex-A7 SoC为例):

void spl_early_clk_init(void)
{
    /* 1. 等待晶振稳定 */
    while (!(osc_stable_status & OSC_READY_MASK));

    /* 2. 配置PLL:24MHz * 41 = 984MHz */
    writel(0x00290001, &pll_ctrl_reg);
    while (!(pll_status & PLL_LOCK_MASK));

    /* 3. 切换CPU时钟到PLL输出 */
    clk_sel_reg = (clk_sel_reg & ~CPU_CLK_MASK) | CPU_CLK_PLL;
    writel(clk_sel_reg, &clk_sel_reg);

    /* 4. 配置AHB/APB分频 */
    writel(0x00000441, &div_reg);
}

这段代码里,0x00290001 这个值怎么来的?它包含了PLL的倍频系数、分频系数和使能位。每个SoC的PLL寄存器布局都不一样,必须对照芯片手册来配。我建议你第一次配PLL时,先用示波器量一下PLL输出引脚,确认频率对了再往下走。

个人经验:

我习惯在PLL锁定后,立刻读一个GPIO来输出方波,然后用示波器看频率。这样能快速验证时钟配置是否正确。别问我为什么不用串口打印——这时候串口时钟可能还没配呢!

4.4 整体流程可视化

说了这么多,我们画一张图来梳理SPL早期的完整流程。这张图我建议你保存下来,以后调试SPL时对照着看。

SPL早期初始化流程 SPL早期初始化流程(入口 → 堆栈 → 时钟) CPU上电 / ROM Code _start → reset (SVC模式) _main (C环境入口) 设置SP / GD / BSS PLL配置 / 时钟切换 board_init_f() DDR / 串口 / 存储介质 ⚠ 关键说明 • 堆栈地址不能与代码段重叠 • PLL锁定后务必验证频率 • 时钟切换前确保外设已停用

这张图里,我特意把堆栈设置和时钟初始化画成了并行分支。为什么?因为有些SoC允许在汇编阶段就配PLL,而有些则必须等到C环境就绪。你实际移植时,要根据芯片手册来调整顺序。

4.5 几个容易踩的坑

最后,我总结几个SPL早期初始化的常见问题。这些都是我在项目里真金白银换来的教训。

问题 现象 根因 解决方案
堆栈溢出 board_init_f 中函数调用异常 栈顶地址与代码段重叠 检查链接脚本,确保栈在SRAM安全区域
PLL无法锁定 CPU跑在低速时钟,系统极慢 晶振未稳定或PLL参数错误 增加晶振等待延时,核对倍频系数
时钟切换后死机 切换时钟源后CPU无响应 外设时钟未关闭或分频配置错误 切换前停用所有外设时钟
BSS段未清零 全局变量初始值随机 _main 中遗漏了BSS清零 确认 __bss_start/__bss_end 符号正确

嗯,这张表我建议你打印出来贴在工位上。尤其是“PLL无法锁定”那条,我当年调试一块新板子时,花了整整两天才发现是晶振负载电容没焊对,导致起振时间过长。从那以后,我每次画板子都会在晶振附近预留测试点。