ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

从_start汇编到C世界:ARM64 Uboot启动流程源码深度解析

从_start汇编到C世界:ARM64 Uboot启动流程源码深度解析 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 来说这个地址被映射到片内 ROMBootROMBootROM 里跑的是厂商固化的一小段代码它负责最基础的初始化然后根据启动介质SPI Flash、eMMC、SD 卡去加载下一级。这里就出现了第一个容易混淆的点BootROM 不是 Uboot。BootROM 是芯片出厂就烧死的你改不了它加载的下一级可能是 SPLSecondary 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、FIQmsr 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, x0CONFIG_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 从汇编跳进 Cboard_init_f的登场汇编把该设的都设完最后一步是跳到 C 函数。ARM64 里跳转用bl或brbl board_init_fboard_init_f是 Uboot 第一阶段 C 代码的入口名字里的f是first的意思。到这里CPU 已经有关中断、有栈、有正确的异常级别、代码在正确的位置C 世界正式开启。但注意board_init_f运行时DDR 可能还没完全初始化如果是 SPL 阶段或者已经初始化但还没做内存布局。这个函数的核心任务是建立内存分配框架也就是gdglobal data结构体和bdboard data结构体然后决定 Uboot 自己、栈、堆、内核各自该放在内存的什么位置。这个决策过程叫dram_init和init_sequence_f里面是一长串函数指针按顺序执行。4. C 阶段怎么接棒从board_init_f到board_init_r4.1gd和bdUboot 的全局账本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建立早期 mallocreserve_uboot和reserve_malloc划出 Uboot 自己的地盘。这几个搞懂了内存布局就清楚了。4.3 重定位后的第二次初始化board_init_rboard_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_defconfigmake ARCHarm CROSS_COMPILEaarch64-linux-gnu- qemu_arm64_defconfig make ARCHarm CROSS_COMPILEaarch64-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 串口完全无输出怎么查这是最让人抓狂的问题。我的排查顺序是确认向量表对齐。用objdump -h u-boot看.vectors段的地址必须是 2KB 对齐。确认栈指针有效。在 GDB 里看sp指向的地址是否在有效内存范围内。确认串口时钟和引脚复用。有些板子串口没输出是因为 pinctrl 没配或者波特率算错。确认重定位没跑飞。在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、CurrentELC 阶段卡住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 和串口寄存器的直接写入了。
返回列表