4、U-Boot启动流程第一阶段(SPL):从ROM到SPL的接力

好,咱们进入正题。上一章讲了ROM Code怎么把SPL搬进SRAM,这一章就轮到SPL登场了。SPL全称是Secondary Program Loader,说白了就是「二级加载器」。它是个迷你版的U-Boot,小到什么程度?我见过最小的SPL只有十几KB,放在现在的芯片上,连张自拍都存不下。

但你别看它小,它干的事可不少。初始化时钟、配置DDR、初始化串口,然后从存储介质里把完整的U-Boot加载到DDR里。嗯,这就是它的全部使命。

一句话总结SPL: 它是ROM Code和完整U-Boot之间的桥梁,负责把硬件从「刚上电」状态带到「能跑大程序」状态。

4.1 SPL的入口:start.S

SPL的入口在哪?在arch/arm/lib/vectors.S或者arch/arm/cpu/armv7/start.S里,具体看芯片架构。ARM上电后从0x00000000开始执行,那里放的是异常向量表。第一条指令就是b reset,直接跳转到reset标号。

我刚开始看这段代码时,觉得不就是个跳转嘛,有啥好看的?后来有一次调试,板子死活起不来,JTAG连上去一看,PC指针停在0x00000004——中断向量表没配好,直接跑飞了。从那以后,我再也不敢小看这几行汇编了。

/* arch/arm/cpu/armv7/start.S 简化片段 */
.globl reset
reset:
    /* 设置CPU为SVC模式,关中断 */
    mrs     r0, cpsr
    bic     r0, r0, #0x1f
    orr     r0, r0, #0xd3
    msr     cpsr, r0

    /* 关闭MMU和Cache */
    mrc     p15, 0, r0, c1, c0, 0
    bic     r0, r0, #0x0005
    mcr     p15, 0, r0, c1, c0, 0

    /* 设置栈指针 */
    ldr     sp, =CONFIG_SPL_STACK

    /* 跳转到C语言入口 */
    bl      board_init_f

这段代码干了三件事:

  • 设置CPU模式: 切到SVC模式,关掉所有中断。为什么?因为SPL阶段太脆弱,一个中断进来可能连栈都还没准备好,直接崩了。
  • 关MMU和Cache: 刚上电时MMU默认是关的,但有些芯片会从ROM里带出一些奇怪的状态,干脆强制关掉。Cache也一样,SPL代码量小,不需要Cache,反而可能因为Cache没初始化导致数据不一致。
  • 设栈指针: 这个很关键。没有栈,C语言函数就没法调用。SPL的栈通常放在SRAM的高地址,因为SRAM空间有限,栈向下生长,得留够空间。
我的习惯: 调试SPL时,我总会在start.S里加几行GPIO点灯代码。比如在跳转到board_init_f之前,把某个GPIO拉高。这样不用串口,光看灯就能知道SPL有没有跑到这里。这招在串口还没初始化时特别好用。

4.2 SPL初始化流程:时钟、DDR、串口

从board_init_f开始,SPL进入C语言世界。这个函数在common/spl/spl.c里,它会调用一系列初始化函数。我画了个流程图,你看一眼就明白了。

SPL初始化流程 start.S 汇编入口 board_init_f() 时钟初始化 串口初始化 DDR初始化 spl_load_image() 加载U-Boot 跳转到U-Boot入口 注意:时钟初始化必须先于DDR和串口,因为DDR和串口都需要时钟才能工作 串口初始化通常最早,方便输出调试信息

4.3 时钟初始化:一切的基础

时钟是芯片的心脏。没有时钟,CPU不跑,DDR不刷新,串口不传数据。SPL里的时钟初始化,说白了就是配置PLL,把外部晶振的低频信号倍频到芯片需要的工作频率。

我遇到过最坑的一次:某款芯片的DDR控制器需要先给PLL加锁,才能配置DDR时序。但参考代码里PLL的锁定等待时间写死了,换了个慢一点的晶振后,PLL还没锁住就开始配DDR,结果板子死活起不来。后来我在PLL锁定后面加了个超时循环,问题解决。

/* 时钟初始化伪代码 */
void clock_init(void)
{
    /* 1. 配置主PLL,比如从24MHz倍频到800MHz */
    pll_config(PLL_MAIN, 24 * 1000000, 800 * 1000000);

    /* 2. 等待PLL锁定 */
    while (!(readl(&pll_regs->lock_status) & PLL_LOCK_BIT))
        ;

    /* 3. 配置DDR时钟分频 */
    clk_set_divider(CLK_DDR, 2);  /* DDR时钟 = 800MHz / 2 = 400MHz */

    /* 4. 配置外设时钟,比如串口时钟 */
    clk_set_divider(CLK_UART, 50); /* 串口时钟 = 800MHz / 50 = 16MHz */
}
注意: 时钟配置的顺序很重要。先配PLL,再配分频器,最后才能开外设时钟。如果顺序搞反了,外设可能拿到一个不稳定的时钟,导致初始化失败。我曾经见过有人先开了串口时钟再配PLL,结果串口输出的全是乱码。

4.4 DDR初始化:最关键的步骤

DDR初始化是SPL里最复杂、最容易出问题的部分。为什么?因为DDR的时序参数太多了——CAS延迟、刷新周期、ODT配置、DQS训练……随便哪个配错了,板子就起不来。

SPL里的DDR初始化通常分三步:

  1. 控制器配置: 设置DDR控制器的寄存器,包括地址映射、突发长度、时序参数等。
  2. PHY初始化: 配置DDR PHY,进行DQS门控训练、读写平衡训练。这一步是为了补偿PCB走线的延迟差异。
  3. 校准测试: 写一个已知数据到DDR,再读出来比对。如果对不上,说明初始化失败。

我个人习惯在DDR初始化完成后,立刻写一个简单的内存测试:往整个DDR空间写0xAA、0x55、0xFFFFFFFF、0x00000000,再读回来验证。这步虽然慢一点,但能提前发现硬件问题,省得后面U-Boot加载到DDR里跑飞了,你还不知道是DDR的问题。

/* DDR初始化后自检 */
int ddr_self_test(void)
{
    u32 *addr = (u32 *)CONFIG_SYS_SDRAM_BASE;
    int i;

    /* 写0xAA55AA55 */
    for (i = 0; i < 1024; i++)
        addr[i] = 0xAA55AA55;

    /* 读回来验证 */
    for (i = 0; i < 1024; i++) {
        if (addr[i] != 0xAA55AA55) {
            printf("DDR test failed at 0x%08x\n", &addr[i]);
            return -1;
        }
    }

    printf("DDR self test passed\n");
    return 0;
}

4.5 串口初始化:调试的窗口

串口初始化相对简单,但它的意义重大——没有串口,你根本不知道SPL跑到哪了。串口初始化通常就是配置波特率、数据位、停止位、校验位,然后打开发送接收。

串口初始化在SPL里通常放在时钟之后、DDR之前。为什么?因为DDR初始化可能需要很长时间,如果串口没开,你只能干等。开了串口,至少能看到U-Boot SPL 2024.01这样的打印信息,心里踏实。

/* 串口初始化 */
void uart_init(void)
{
    /* 设置波特率,假设时钟16MHz,目标115200 */
    u32 baud_div = 16000000 / (16 * 115200);  /* 约等于8.68,取整 */

    /* 配置UART寄存器 */
    writeb(0x00, &uart_regs->lcr);    /* 线控寄存器清零 */
    writeb(0x80, &uart_regs->lcr);    /* 使能波特率设置 */
    writeb(baud_div & 0xFF, &uart_regs->dll);  /* 低8位 */
    writeb((baud_div >> 8) & 0xFF, &uart_regs->dlm); /* 高8位 */
    writeb(0x03, &uart_regs->lcr);    /* 8位数据,1位停止,无校验 */
    writeb(0x01, &uart_regs->fcr);    /* 使能FIFO */
}
避坑指南: 串口波特率算出来如果不是整数,尽量往低取整。比如算出来是8.68,取8而不是9。因为取9会导致实际波特率偏高,接收端可能丢数据。我曾经因为这个坑,调试信息总是断断续续,折腾了两天才发现是波特率误差太大。

4.6 SPL的最终使命:加载U-Boot

时钟、DDR、串口都初始化好了,SPL的最后一步就是从存储介质里把完整的U-Boot加载到DDR里。这个加载函数叫spl_load_image(),它支持多种存储介质:

存储介质 加载函数 典型场景
NAND Flash spl_nand_load_image() 工业控制、低成本设备
MMC/SD卡 spl_mmc_load_image() 开发板、树莓派类设备
SPI Flash spl_spi_load_image() 路由器、IoT设备
USB spl_usb_load_image() 调试模式、工厂烧录

加载完成后,SPL会跳转到U-Boot的入口地址。怎么跳?很简单,把U-Boot镜像头里的入口地址取出来,然后bx r0就过去了。这时候,SPL的使命就完成了,它把自己占用的SRAM也交了出去——嗯,有点像接力赛,SPL跑完第一棒,把棒子交给U-Boot。

/* SPL跳转到U-Boot */
void jump_to_uboot(void *uboot_entry)
{
    /* 关闭SPL用过的外设 */
    timer_stop();
    uart_deinit();

    /* 清理Cache,确保数据写回DDR */
    flush_dcache_all();

    /* 跳转 */
    ((void (*)(void))uboot_entry)();

    /* 不会回来了 */
    while (1)
        ;
}
关键点: 跳转前一定要关掉SPL用过的外设中断、清掉Cache、把栈指针切到DDR空间。否则U-Boot启动时可能会被SPL留下的状态干扰。我见过有人忘了关定时器中断,U-Boot刚启动就被中断打飞了。