4. U-Boot启动流程第一阶段:从reset向量到lowlevel_init,SPL/TPL的引入与作用
好,我们正式开始啃U-Boot启动的第一段代码。很多初学者一上来就被汇编和链接脚本吓住了,其实没那么玄乎。你想想看,CPU上电后第一件事是什么?它得去一个固定的地址取指令——这就是reset向量。U-Boot的整个第一阶段,说白了就是从这个向量开始,一路跑到C语言环境就绪。
4.1 reset向量:一切从这里开始
CPU复位后,PC指针会指向一个硬件决定的地址。对于ARM Cortex-A系列,这个地址通常是0x00000000或0xFFFF0000。U-Boot在链接脚本u-boot.lds里,把入口点强行定在了_start。
/* arch/arm/cpu/armv7/start.S */
.globl _start
_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
这段代码就是异常向量表。第一条指令b reset直接跳转到reset标签。我刚开始看的时候有个疑问:为什么不用ldr pc, =reset?后来发现,b是相对跳转,不依赖绝对地址,在SPL和U-Boot重定位前更安全。
关键点:异常向量表必须放在链接脚本指定的起始地址。如果地址错了,CPU复位后直接跑飞,连打印都看不到。
4.2 reset函数:关中断、设栈、切模式
跳进reset后,U-Boot干了三件大事:
- 切换到SVC模式——关掉所有中断,防止在初始化过程中被乱七八糟的中断打断。
- 设置栈指针——给后续的C函数调用准备栈空间。这时候还没初始化DDR,栈只能放在芯片内部的SRAM里。
- 调用
cpu_init_crit——这里面会做更底层的CPU初始化,比如禁用MMU和Cache。
reset:
/* 设置CPU为SVC32模式,关中断 */
mrs r0, cpsr
bic r0, r0, #0x1f
orr r0, r0, #0xd3
msr cpsr, r0
/* 设置栈指针,指向内部SRAM顶部 */
ldr sp, =(CONFIG_SYS_INIT_SP_ADDR)
/* 调用底层CPU初始化 */
bl cpu_init_crit
这里有个坑——CONFIG_SYS_INIT_SP_ADDR必须指向可写的内存区域。我遇到过一块芯片,内部SRAM只有4KB,栈稍微用猛了就踩到关键数据,导致后面lowlevel_init莫名其妙崩溃。后来我习惯在调试阶段把栈地址往下挪一点,留出冗余。
注意:此时DDR还没初始化,所有代码和数据都必须放在芯片内部SRAM或ROM里。栈大小通常只有几KB,别在汇编阶段搞大数组。
4.3 cpu_init_crit:关MMU、清TLB
这个函数很短,但很重要。它做了两件事:
- 关闭MMU和Cache——因为此时页表还没建立,开着MMU会导致地址翻译错误。
- 清除TLB和Branch Predictor——防止之前残留的预测信息干扰。
ENTRY(cpu_init_crit)
/* 关闭MMU和Cache */
mrc p15, 0, r0, c1, c0, 0
bic r0, r0, #0x0005 @ 清除M位和C位
mcr p15, 0, r0, c1, c0, 0
/* 清除TLB */
mov r0, #0
mcr p15, 0, r0, c8, c7, 0
/* 清除Branch Predictor */
mcr p15, 0, r0, c7, c5, 6
/* 返回 */
mov pc, lr
ENDPROC(cpu_init_crit)
我个人习惯在关MMU之后加一小段延时,确保写操作完全生效。有些芯片的流水线比较深,不等等就直接往下跑,可能会读到脏数据。
4.4 lowlevel_init:板级最底层的初始化
从cpu_init_crit返回后,U-Boot调用lowlevel_init。这个函数是板级相关的,每个板子都不一样。它通常负责:
| 任务 | 说明 |
|---|---|
| 时钟初始化 | 配置PLL,让CPU跑到目标频率 |
| 看门狗关闭 | 防止看门狗在初始化过程中复位 |
| GPIO早期配置 | 比如点亮一个LED,表示“我还活着” |
| 电源管理 | 使能某些外设的电源域 |
| 调试串口初始化 | 有些板子在这里就开串口,方便打印 |
ENTRY(lowlevel_init)
/* 关看门狗 */
ldr r0, =WDT_BASE
mov r1, #0
str r1, [r0]
/* 初始化时钟 */
bl clock_init
/* 初始化串口(可选) */
bl debug_uart_init
/* 返回 */
mov pc, lr
ENDPROC(lowlevel_init)
我曾经在一个项目里,lowlevel_init里忘了关看门狗,结果每次跑到DDR初始化一半就被复位了。查了两天才发现,从此以后我写任何板级代码,第一件事就是关看门狗。
小技巧:在lowlevel_init里加一个GPIO翻转,用示波器看波形,可以精确测量各阶段的耗时。比用串口打印靠谱多了。
4.5 SPL/TPL的引入:为什么需要它们?
早期的U-Boot是单体的,一个二进制文件搞定所有事。但随着芯片越来越复杂,问题来了:
- DDR初始化需要代码,但DDR初始化代码本身需要内存才能跑——鸡生蛋蛋生鸡。
- 芯片内部SRAM太小,装不下完整的U-Boot。
- NAND/NOR Flash读取需要驱动,但驱动代码又占空间。
解决方案就是分阶段加载。U-Boot引入了两个概念:
- SPL(Secondary Program Loader):第二级引导程序,负责初始化DDR和存储控制器,然后把完整的U-Boot从Flash加载到DDR。
- TPL(Tertiary Program Loader):第三级引导程序,比SPL更小,用于某些需要三级引导的芯片(比如某些TI的AM335x系列)。
说白了,SPL就是一个小号的U-Boot,它只做最必要的事:初始化硬件、加载下一级镜像。我习惯把SPL叫做“最小启动集合”。
4.6 SPL的执行流程
SPL的启动流程和完整U-Boot非常像,只是更精简:
- 同样从
_start开始,走reset→cpu_init_crit→lowlevel_init。 - 然后进入C环境,调用
board_init_f,初始化DDR和存储控制器。 - 从Flash读取完整U-Boot镜像到DDR。
- 跳转到U-Boot的入口点。
核心区别:SPL的代码量通常控制在4KB-32KB以内,所以它不能包含完整的文件系统驱动,只能用最简单的原始读Flash命令。
下面这张图展示了从reset向量到SPL再到U-Boot的完整流程:
4.7 TPL:什么时候需要第三级?
TPL比SPL更小,通常只有1KB-4KB。它存在的场景是:
- 芯片内部SRAM极小(比如只有2KB),连SPL都放不下。
- 需要多级安全校验,TPL只做最基本的认证,然后加载SPL。
- 某些TI和Freescale的芯片,ROM代码只加载TPL,TPL再加载SPL。
TPL的代码和SPL几乎一样,只是裁剪得更狠。我见过一个项目,TPL里连串口初始化都砍了,全靠GPIO电平来指示状态。
经验之谈:如果你的芯片内部SRAM大于32KB,基本不需要TPL。SPL直接搞定。只有那些内部SRAM小于8KB的芯片,才需要考虑三级引导。
4.8 第一阶段总结
从reset向量到lowlevel_init,U-Boot完成了最底层的硬件初始化。这段代码全部是汇编,因为此时C环境还没准备好。SPL/TPL的引入,解决了“鸡生蛋蛋生鸡”的问题——先用极小代码初始化DDR,再把完整U-Boot加载进来。
嗯,这里要注意:不同架构的启动流程略有差异,但核心思想是一样的。ARM、RISC-V、MIPS,都是先走汇编初始化,再跳C语言。你只要把今天讲的这个框架吃透了,看其他架构的启动代码也会很快上手。