☰
从_start汇编到C世界:ARM64 Uboot启动流程源码深度解析
2026/10/6 5:58:18 网站建设 项目流程

1. 从_start说起:为什么值得啃 Uboot 的汇编入口

很多人第一次接触 Uboot,都是被一句“上电后第一行代码在哪”给问住的。芯片一上电,PC 指针跳到某个固定地址,那里躺着的就是 Uboot 的入口,而入口的第一段代码,几乎清一色是汇编。标题里说的“从源码_start汇编开始一步步踏进 C 世界”,讲的正是这条路径:汇编负责把 CPU 从裸机状态摆弄到能跑 C 的环境,C 负责把板子带到能加载内核的状态。

我见过太多人卡在这一步:能背出“Uboot 分两阶段,stage1 汇编、stage2 C”,但真让他打开arch/arm/cpu/armv8/start.S或者arch/arm/lib/vectors.S,就完全不知道每一行在干嘛。更麻烦的是,网上讲 Uboot 启动流程的文章,要么停留在“SPL→Uboot→Kernel”这种框图级别,要么直接贴一大段汇编不加解释,中间那段“为什么这么写”的空白,只能自己填。

这篇东西就是来填这个空白的。我会以 ARM64 为主线(因为现在新板子基本都是 AArch64),从_start这个符号开始,一行行拆汇编在做什么、为什么这么做、做完之后 C 世界是怎么被“请”出来的。适合两类人看:一类是刚学嵌入式、想搞懂“上电第一行代码”的学生或者转行者;另一类是做了几年驱动、但一直没系统梳理过启动链的工程师。看完你至少能做到:拿到一块新板子的 Uboot 源码,知道从哪个文件开始读,知道_start到board_init_r之间发生了什么,遇到“卡在汇编起不来”的问题时知道往哪查。

需要提前说明的是,不同 SoC 厂商(比如海思、瑞芯微、全志)会在通用流程上做裁剪和魔改,具体寄存器名、地址可能对不上,但主干逻辑是共通的。我下面讲的是主线思路,你对照自己手上的源码看,八九不离十。

2. 上电那一刻:ARM64 的复位向量与_start的落点

2.1 复位后 CPU 到底在哪取指

ARM64 架构里,复位属于一种异常。CPU 上电或者复位拉低之后,会从复位向量取第一条指令。AArch64 的异常向量表基址由VBAR_EL3(或对应异常级别的 VBAR)决定,但复位这一下,硬件有个默认行为:从架构定义的复位地址开始执行。对多数 SoC 来说,这个地址被映射到片内 ROM(BootROM),BootROM 里跑的是厂商固化的一小段代码,它负责最基础的初始化,然后根据启动介质(SPI Flash、eMMC、SD 卡)去加载下一级。

这里就出现了第一个容易混淆的点:BootROM 不是 Uboot。BootROM 是芯片出厂就烧死的,你改不了;它加载的下一级,可能是 SPL(Secondary Program Loader),也可能是 Uboot 的完整镜像,取决于 SoC 设计。像很多 ARM64 芯片会先加载 SPL 到片内 SRAM,因为此时 DDR 还没初始化,外部大内存用不了。SPL 很小,任务就一个:把 DDR 控制器配好,然后把完整的 Uboot 搬到 DDR 里,跳过去。

所以当你打开arch/arm/cpu/armv8/start.S,看到的_start,严格说是Uboot 镜像自己的入口,不是芯片上电的第一条指令。但它是你能改、能读、能调试的起点,也是“踏进 C 世界”这条路的真正开端。

2.2_start符号在链接脚本里的位置

汇编代码能跑,前提是它被链接到了正确的地址。Uboot 的链接脚本(通常是arch/arm/cpu/armv8/u-boot.lds或者u-boot-spl.lds)里,会明确把_start放在镜像的最前面。典型写法是:

ENTRY(_start) SECTIONS { . = 0x00000000; .text : { *(.__image_copy_start) *(.vectors) *(.text*) } ... }

ENTRY(_start)告诉链接器入口符号是_start,.vectors段放的是异常向量表,紧跟着才是普通代码。这个顺序不是随便排的:异常向量表必须放在一个对齐的、固定的位置,因为硬件在发生异常时会按固定偏移去查表。ARM64 要求向量表 2KB 对齐,每个表项 128 字节,一共 16 个表项。Uboot 里这段通常在arch/arm/lib/vectors.S或者直接内联在start.S开头。

我实测过,如果你把向量表挪到别的位置,或者对齐没做对,板子一上电就飞,连串口都出不来。所以读源码时,先确认向量表在哪、对齐对不对,这是排查“完全无输出”问题的第一站。

2.3 向量表里为什么有一堆branch

打开向量表,你会看到类似这样的结构(简化):

.align 11 .globl vectors vectors: b reset .align 7 b undefined_instruction .align 7 b software_interrupt .align 7 b prefetch_abort ...

.align 11是 2 的 11 次方,也就是 2048 字节对齐,符合 ARM64 要求。每个表项之间.align 7,即 128 字节间隔。每个表项里就一条b指令,跳到真正的处理函数。为什么这么设计?因为异常发生时,CPU 只给你 128 字节的空间,放不下完整处理逻辑,所以只能放一条跳转,把真正的活交给后面的代码。

复位这一项跳到的reset,就是我们要跟的主线。注意,有些版本里_start和reset是同一个位置,有些是_start先做一些最基础的设置再跳到reset。读的时候以你手上的源码为准,但逻辑上,复位后的第一件正事,是设置 CPU 的运行环境。

3. 汇编阶段的核心任务:把 CPU 摆到能跑 C 的状态

3.1 关中断、设异常级别、选栈指针

复位刚进来的时候,CPU 处于一个“什么都不能信”的状态:中断可能开着,异常级别可能是 EL3 或 EL2,栈指针没设,MMU 没开,缓存状态未知。汇编阶段要做的,就是把这些一个个摆平。

第一件事通常是屏蔽中断。ARM64 里通过写DAIF寄存器来关掉调试、SError、IRQ、FIQ:

msr daifset, #0xf

这行执行完,所有中断都被屏蔽。为什么必须先关?因为此时异常向量表可能还没完全就位,栈也没设,一旦来中断,CPU 按向量表跳过去,结果发现处理函数依赖的东西都没有,直接死给你看。

第二件事是确定异常级别并做相应设置。ARM64 有 EL0 到 EL3 四个级别,Uboot 通常跑在 EL2 或 EL1(取决于是否用虚拟化)。如果当前在 EL3,而 Uboot 想跑在 EL2,就需要配置SCR_EL3、SPSR_EL3、ELR_EL3然后eret降级。这段代码在start.S里通常叫switch_el或者类似的名字。我踩过的坑是:有些板子的 BootROM 把 CPU 留在 EL3,而 Uboot 的某些驱动假设自己在 EL1,结果访问系统寄存器时触发异常。所以读源码时一定要确认当前 EL 级别和目标 EL 级别。

第三件事是设置栈指针。C 语言函数调用依赖栈,没有栈就没法跑 C。汇编阶段会在片内 SRAM 或者已经初始化好的 DDR 里划一块区域当栈,然后把sp指过去。典型写法:

ldr x0, =CONFIG_SYS_INIT_SP_OFFSET mov sp, x0

CONFIG_SYS_INIT_SP_OFFSET是在板级配置里定义的,通常指向片内 SRAM 的高地址端,因为栈是向下增长的。这里有个细节:栈必须 16 字节对齐,ARM64 的 ABI 要求。如果对齐没做对,跑 C 的时候可能出现莫名其妙的崩溃。

3.2 重定位:把代码搬到该在的地方

Uboot 有个概念叫重定位(relocation)。简单说,链接的时候代码被假定放在地址 A,但实际运行时可能被加载到地址 B,这时候所有绝对地址引用都要调整。汇编阶段会判断当前 PC 和链接地址是否一致,如果不一致,就执行重定位。

ARM64 里判断当前地址常用adr指令:

adr x0, _start ldr x1, =_TEXT_BASE cmp x0, x1 b.eq relocate_done

如果不等,就调用relocate_code,把代码段、数据段整体搬到_TEXT_BASE指向的位置,然后跳到新地址继续执行。这段逻辑在arch/arm/lib/relocate.S里。为什么要重定位?因为 Uboot 最终要跑在 DDR 的高端地址,把低端内存留给内核和文件系统。SPL 阶段可能把 Uboot 加载到 DDR 低端,重定位就是把它挪到高端。

我遇到过一个问题:重定位后串口没输出。查了半天发现是重定位时把串口寄存器的基地址也当成需要调整的引用给改了,但那个地址是物理地址,不该动。后来在链接脚本里把那段排除掉才解决。所以重定位的粒度要控制好,不是所有地址都需要调整。

3.3 从汇编跳进 C:board_init_f的登场

汇编把该设的都设完,最后一步是跳到 C 函数。ARM64 里跳转用bl或br:

bl board_init_f

board_init_f是 Uboot 第一阶段 C 代码的入口,名字里的f是first的意思。到这里,CPU 已经有关中断、有栈、有正确的异常级别、代码在正确的位置,C 世界正式开启。

但注意,board_init_f运行时,DDR 可能还没完全初始化(如果是 SPL 阶段),或者已经初始化但还没做内存布局。这个函数的核心任务是建立内存分配框架,也就是gd(global data)结构体和bd(board data)结构体,然后决定 Uboot 自己、栈、堆、内核各自该放在内存的什么位置。这个决策过程叫dram_init和init_sequence_f,里面是一长串函数指针,按顺序执行。

4. C 阶段怎么接棒:从board_init_f到board_init_r

4.1gd和bd:Uboot 的全局账本

gd是global_data的缩写,一个结构体,存的是 Uboot 运行期间到处都要用的东西:波特率、CPU 频率、内存大小、重定位偏移、环境变量地址等等。它通常被放在一个固定的寄存器里(ARM64 里是x18),这样任何函数都能快速拿到。

typedef struct global_data { bd_t *bd; unsigned long flags; unsigned int baudrate; unsigned long cpu_clk; unsigned long mem_clk; ... } gd_t;

bd是板级数据,存的是内存起始地址、大小、IP 地址之类的板子相关信息。board_init_f的一大任务就是把这两个结构体填好,并且把gd的地址存到x18。

为什么用寄存器存gd而不是全局变量?因为 Uboot 在重定位前后,全局变量的地址会变,但寄存器不变。用x18存gd,重定位后只要更新gd里的内容,访问方式不用改。这是 Uboot 设计里很巧妙的一手。

4.2init_sequence_f:一长串初始化函数

board_init_f的主体是一个循环,遍历init_sequence_f数组:

static init_fnc_t init_sequence_f[] = { setup_mon_len, arch_cpu_init, mach_cpu_init, initf_malloc, ... dram_init, ... NULL, };

每个函数负责一件事:setup_mon_len算 monitor 长度,arch_cpu_init做架构相关初始化,dram_init探测内存大小。这些函数按顺序执行,任何一个返回非零就停住。

我读这段源码的经验是:不要试图一次记住所有函数,先抓几个关键的。dram_init决定内存布局,initf_malloc建立早期 malloc,reserve_uboot和reserve_malloc划出 Uboot 自己的地盘。这几个搞懂了,内存布局就清楚了。

4.3 重定位后的第二次初始化:board_init_r

board_init_f跑完,会调用relocate_code做重定位,然后跳到board_init_r。r是second的意思。到这里,Uboot 已经在最终位置,内存布局也定了,接下来是外设初始化:串口、网口、存储、USB、环境变量、命令解析器。

board_init_r里也有一个init_sequence_r数组,比f版本更长。最后会进入main_loop,也就是你熟悉的 Uboot 命令行。从_start到这里,整个启动流程才算走完。

5. 实操:用 QEMU 跑一遍 ARM64 Uboot 看启动流程

5.1 环境准备与编译

光看源码不够,得跑起来才有感觉。我用 QEMU 模拟 ARM64 来演示,因为不需要真实硬件,出问题也好调。

先装工具链:

sudo apt install gcc-aarch64-linux-gnu qemu-system-arm

然后拿一份 Uboot 源码,配置qemu_arm64_defconfig:

make ARCH=arm CROSS_COMPILE=aarch64-linux-gnu- qemu_arm64_defconfig make ARCH=arm CROSS_COMPILE=aarch64-linux-gnu- -j8

编译完会生成u-boot.bin。QEMU 可以直接加载它:

qemu-system-aarch64 -machine virt -cpu cortex-a57 -nographic -bios u-boot.bin

如果一切正常,你会看到串口输出 Uboot 的版本信息和命令行。这一步能跑通,说明你对启动流程的理解有了一个可验证的基线。

5.2 用 GDB 单步跟_start

想真正看清_start在干嘛,得用 GDB。QEMU 支持-s -S参数,-s开 GDB server 在 1234 端口,-S让 CPU 启动时暂停:

qemu-system-aarch64 -machine virt -cpu cortex-a57 -nographic -bios u-boot.bin -s -S

另开一个终端:

aarch64-linux-gnu-gdb u-boot (gdb) target remote :1234 (gdb) break _start (gdb) continue

命中断点后,用si单步执行汇编,info registers看寄存器变化。我建议重点观察几个点:daifset执行后DAIF寄存器的值、sp被设置成什么、x18什么时候被赋值。这几个点看明白,汇编阶段就通了。

5.3 关键寄存器速查表

调试时经常要查寄存器,我整理了一个常用表:

寄存器作用典型值/操作
DAIF中断屏蔽位msr daifset, #0xf全关
SP栈指针指向 SRAM 或 DDR 高端
X18存 gd 指针board_init_f里赋值
VBAR_ELx异常向量基址指向 vectors 段
SCTLR_ELx系统控制控制 MMU、缓存
CurrentEL当前异常级别读出来判断在 EL几

这张表我在调试时贴在显示器边上,省得每次翻手册。

6. 常见问题与排查技巧实录

6.1 串口完全无输出怎么查

这是最让人抓狂的问题。我的排查顺序是:

  1. 确认向量表对齐。用objdump -h u-boot看.vectors段的地址,必须是 2KB 对齐。
  2. 确认栈指针有效。在 GDB 里看sp指向的地址是否在有效内存范围内。
  3. 确认串口时钟和引脚复用。有些板子串口没输出是因为 pinctrl 没配,或者波特率算错。
  4. 确认重定位没跑飞。在relocate_code前后打断点,看 PC 是否跳到合理地址。

我遇到过一次,串口没输出是因为CONFIG_SYS_INIT_SP_OFFSET配错了,栈指到了未映射的区域,一压栈就异常。改对之后立刻有输出。

6.2 卡在board_init_f不往下走

如果汇编阶段过了,但 C 阶段卡住,通常是某个init_sequence_f里的函数死循环或者等硬件超时。用 GDB 在board_init_f里打断点,单步跟,看卡在哪个函数。常见的是dram_init等 DDR 训练完成,如果 DDR 参数不对,会一直等。

6.3 重定位后崩溃

重定位后崩溃,多半是地址引用没调整对。检查链接脚本里哪些段参与了重定位,哪些没参与。relocate.S里的relocate_code会遍历重定位表,表里记录的是需要调整的地址。如果某个绝对地址被错误地调整了,就会崩。

6.4 常见问题速查表

现象可能原因排查方法
无串口输出向量表对齐错、栈无效、串口未初始化objdump 看段地址、GDB 看 sp
卡在汇编异常级别切换失败、中断未关看 DAIF、CurrentEL
C 阶段卡住DDR 初始化超时、时钟未配单步跟 init_sequence_f
重定位后崩地址引用调整错误检查 relocate 表
进不了命令行环境变量损坏、命令解析器未初始化看 board_init_r 末尾

7. 我个人读 Uboot 源码的几点体会

读 Uboot 源码,最忌讳一上来就从头读到尾。我的做法是先跑通,再打断点,再读代码。跑通给你一个可工作的基线,打断点让你看到实际执行路径,读代码才是带着问题去读,效率高得多。

另外,不同版本的 Uboot 差异不小。2018 版和 2023 版的start.S可能差出几百行,init_sequence_f里的函数也可能增删。所以看网上文章时,一定要对照自己手上的版本,别硬套。我一般会先git log看这个文件的修改历史,了解哪些地方被改过、为什么改,这比直接读当前版本更有收获。

最后分享一个小技巧:在board_init_f和board_init_r里加printf,把关键变量打出来,比纯靠 GDB 看寄存器直观。尤其是内存布局那块,打出来一目了然。当然,早期汇编阶段没法printf,那就只能靠 GDB 和串口寄存器的直接写入了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询