3. U-Boot启动流程第一阶段:从reset向量到lowlevel_init的汇编代码分析
好,咱们今天来啃一块硬骨头——U-Boot的第一阶段启动。说白了,就是从CPU上电复位开始,到执行lowlevel_init之前的那段汇编代码。
我刚开始接触U-Boot时,总觉得这部分很神秘。CPU一上电,它怎么就知道该干嘛?代码在哪?谁把它加载到内存里的?其实说白了,第一阶段就是帮C语言运行环境“铺路”的。你想想看,C语言需要栈、需要BSS段清零、需要全局变量能访问……这些在汇编阶段都得搞定。
核心认知:U-Boot第一阶段(汇编阶段)的目标只有一个——为C语言执行准备好最基本的硬件环境。具体包括:设置CPU为SVC32模式、关闭中断、初始化时钟和串口、设置栈指针、搬移代码、清零BSS段。
3.1 入口点:reset向量
CPU复位后,会从0x00000000或0xFFFF0000(取决于具体架构)取第一条指令。这个地址对应的就是reset向量。在U-Boot源码中,入口定义通常在arch/arm/cpu/armv7/start.S(以ARMv7为例)。
/*
* 异常向量表 —— 硬件强制要求放在起始位置
*/
_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,这是一个相对跳转。为什么不用ldr pc, =reset?因为此时CPU刚上电,内存控制器可能还没初始化,绝对地址访问可能会出问题。相对跳转只依赖当前PC值,安全得多。
我的经验:有一次我在移植U-Boot到一块新板子时,发现复位后总是跑飞。查了半天,原来是链接脚本里把入口地址设错了,导致b reset跳到了一个未初始化的内存区域。嗯,从那以后我每次都会先确认链接脚本的.text段起始地址是否正确。
3.2 设置CPU模式与关闭中断
进入reset标号后,第一件事就是设置CPU工作模式。ARM处理器有7种工作模式,U-Boot需要运行在SVC32模式(超级管理员模式),因为它拥有最高的权限,可以访问所有系统资源。
reset:
/* 设置SVC32模式,关闭IRQ和FIQ */
mrs r0, cpsr
bic r0, r0, #0x1f @ 清除模式位
orr r0, r0, #0xd3 @ 设置SVC32模式 + 关IRQ/FIQ
msr cpsr, r0
这段代码做了两件事:
- 清除模式位:把CPSR的低5位清零
- 设置新模式:写入0xD3,其中bit[4:0]=10011表示SVC模式,bit[7]=1关IRQ,bit[6]=1关FIQ
为什么要关中断?因为此时中断向量表可能还没准备好,栈也没设置,一旦来了中断,CPU根本不知道去哪处理,系统直接崩溃。我见过一个新手在调试时忘了关中断,结果每次单步执行到某条指令就莫名其妙跳走,查了一下午才发现是中断没关。
3.3 CPU初始化与SoC早期配置
接下来是cpu_init_cp15,这部分负责初始化CP15协处理器。CP15是ARM架构中用来控制MMU、Cache、TLB等核心功能的协处理器。
cpu_init_cp15:
/* 关闭MMU和Cache */
mrc p15, 0, r0, c1, c0, 0
bic r0, r0, #0x00000005 @ 清除M位(MMU)和C位(DCache)
bic r0, r0, #0x00001000 @ 清除I位(ICache)
mcr p15, 0, r0, c1, c0, 0
/* 使能分支预测 */
mrc p15, 0, r0, c1, c0, 0
orr r0, r0, #0x00000800 @ 设置Z位
mcr p15, 0, r0, c1, c0, 0
/* 设置域访问控制为Manager模式 */
mov r0, #0xffffffff
mcr p15, 0, r0, c3, c0, 0
这里有个关键点:刚上电时MMU和Cache都是关闭的,但U-Boot还是显式地再关一次,确保万无一失。为什么?因为有些BootROM可能会提前开启Cache,或者某些仿真器会修改CP15的状态。我习惯在初始化时先“重置”所有关键寄存器到已知状态,再按需开启。
| CP15控制位 | 功能 | U-Boot初始值 |
|---|---|---|
| M (bit0) | MMU使能 | 0(关闭) |
| C (bit2) | 数据Cache使能 | 0(关闭) |
| I (bit12) | 指令Cache使能 | 0(关闭) |
| Z (bit11) | 分支预测使能 | 1(开启) |
注意:分支预测可以提前打开,因为它不依赖内存映射。但MMU和Cache必须在确定物理内存可用后才能开启,否则CPU会尝试访问不存在的页表,导致数据中止异常。
3.4 lowlevel_init之前的最后准备
在跳转到lowlevel_init之前,还有几件小事要处理:
- 设置栈指针:为C函数调用准备栈空间。通常指向IRQ/FIQ栈之后的安全区域。
- 清零BSS段:全局变量和静态变量的初始值必须为0,否则C代码无法正常工作。
- 搬移代码:如果U-Boot当前运行在Flash或ROM中,需要把自己搬到RAM里执行(这一步有时在第二阶段做)。
/* 设置栈指针 —— 指向内部SRAM的顶部 */
ldr sp, =CONFIG_SYS_INIT_SP_ADDR
/* 清零BSS段 */
ldr r0, =__bss_start
ldr r1, =__bss_end
mov r2, #0
clear_bss:
cmp r0, r1
strlo r2, [r0], #4
blo clear_bss
/* 跳转到lowlevel_init */
bl lowlevel_init
这里有个细节:CONFIG_SYS_INIT_SP_ADDR通常指向内部SRAM的顶部。为什么不用外部DDR?因为此时DDR控制器可能还没初始化,外部内存根本不可用。内部SRAM是CPU自带的,上电就能用,虽然容量小(通常只有4KB~64KB),但足够放栈了。
避坑指南:我曾经在一个项目中,把栈指针设到了外部DDR的地址,结果bl lowlevel_init一执行就死机。因为bl指令会把返回地址压栈,而栈在不可用的内存上……嗯,那次调试花了我整整一天。记住:在DDR初始化完成之前,只能用内部SRAM。
3.5 第一阶段流程图
下面这张图把整个流程串起来了,你可以对照着看:
3.6 关键数据结构与配置项
第一阶段涉及的配置项通常在include/configs/目录下的头文件中定义。我整理了一个表格,方便你对照:
| 配置宏 | 含义 | 典型值 |
|---|---|---|
CONFIG_SYS_INIT_SP_ADDR |
初始栈指针地址 | 内部SRAM顶部地址,如0x1000FFFF |
CONFIG_SYS_TEXT_BASE |
U-Boot代码链接基址 | 0x87800000(DDR地址) |
CONFIG_SYS_SDRAM_BASE |
DDR控制器基地址 | 0x80000000 |
CONFIG_SYS_INIT_RAM_SIZE |
内部SRAM大小 | 0x8000(32KB) |
重要提醒:CONFIG_SYS_INIT_SP_ADDR必须指向可读写的内存区域,且不能与代码段重叠。我见过有人把栈指针设到了代码段中间,结果压栈时把指令覆盖了,程序直接跑飞。调试这种问题,用JTAG单步跟踪是最快的。
3.7 第一阶段总结
好了,咱们把第一阶段的核心逻辑捋一遍:
- 入口:CPU复位后从
reset向量开始执行 - 模式设置:切换到SVC32模式,关闭所有中断
- CP15初始化:关闭MMU和Cache,开启分支预测
- 栈与BSS:在内部SRAM上设置栈指针,清零BSS段
- 跳转:调用
lowlevel_init,进入硬件初始化阶段
我个人觉得,第一阶段最难理解的地方在于“为什么要在汇编里做这些事”。其实答案很简单:C语言需要栈才能调用函数,需要BSS清零才能使用全局变量,需要关中断才能保证执行流不被干扰。这些基础条件不满足,C代码根本跑不起来。
下一阶段,lowlevel_init会初始化时钟、串口和DDR,然后U-Boot才能把自身搬到DDR里继续执行。不过那是后话了,咱们先把第一阶段吃透再说。