4、U-Boot启动流程第一阶段(SPL):从ROM到SPL的接力
好,咱们进入正题。上一章讲了ROM Code怎么把SPL搬进SRAM,这一章就轮到SPL登场了。SPL全称是Secondary Program Loader,说白了就是「二级加载器」。它是个迷你版的U-Boot,小到什么程度?我见过最小的SPL只有十几KB,放在现在的芯片上,连张自拍都存不下。
但你别看它小,它干的事可不少。初始化时钟、配置DDR、初始化串口,然后从存储介质里把完整的U-Boot加载到DDR里。嗯,这就是它的全部使命。
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空间有限,栈向下生长,得留够空间。
4.2 SPL初始化流程:时钟、DDR、串口
从board_init_f开始,SPL进入C语言世界。这个函数在common/spl/spl.c里,它会调用一系列初始化函数。我画了个流程图,你看一眼就明白了。
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 */
}
4.4 DDR初始化:最关键的步骤
DDR初始化是SPL里最复杂、最容易出问题的部分。为什么?因为DDR的时序参数太多了——CAS延迟、刷新周期、ODT配置、DQS训练……随便哪个配错了,板子就起不来。
SPL里的DDR初始化通常分三步:
- 控制器配置: 设置DDR控制器的寄存器,包括地址映射、突发长度、时序参数等。
- PHY初始化: 配置DDR PHY,进行DQS门控训练、读写平衡训练。这一步是为了补偿PCB走线的延迟差异。
- 校准测试: 写一个已知数据到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 */
}
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)
;
}