☰
U-Boot启动流程深度解析:从_start汇编到C语言世界
2026/10/6 1:32:04 网站建设 项目流程

1. 从一条让人抓狂的启动日志说起

如果你曾经在调试一块 ARM64 开发板时,盯着串口终端里那几行冷冰冰的启动信息发呆,那你一定懂我在说什么。板子上电,串口打印出几行字符,然后要么卡死,要么跳进一个你完全不知道从哪来的地址,要么干脆连打印都没有。你手里有源码,有编译工具链,有调试器,但就是不知道 CPU 执行的第一条指令到底在哪、它怎么从汇编一步步走到 C 语言的board_init_f、board_init_r,最后把内核拉起来。

这个问题的核心,就藏在 U-Boot 的启动流程里。而启动流程的起点,就是那个几乎所有移植文档都会提到、但很少有人真正讲透的符号——_start。它通常位于arch/arm/lib/vectors.S或者arch/arm/cpu/armv8/start.S这类汇编文件里,是链接脚本u-boot.lds中ENTRY(_start)指定的入口。CPU 复位后,程序计数器被硬件设置为某个复位向量地址,而 U-Boot 的镜像恰好被烧写或加载到那个位置,于是_start就成了整个软件世界的第一行代码。

我写这篇东西的目的很直接:把 U-Boot 从_start汇编到进入 C 世界这段路,掰开揉碎讲清楚。不是泛泛地讲“U-Boot 分两个阶段”,而是从第一条指令开始,逐段分析它在干什么、为什么这么干、异常向量表怎么摆、栈怎么设、重定位怎么发生、_main是怎么被调到的。适合谁看?适合那些已经能编译 U-Boot、能烧写、能看串口输出,但一遇到启动卡死就无从下手的嵌入式工程师;也适合想真正理解“裸机程序如何从汇编过渡到 C”的初学者。关键词里的 uboot、_start、汇编、C、ARM64,就是这篇文章要串起来的主线。

2. 复位之后的第一现场:异常向量表与 _start 的真实位置

2.1 为什么入口不是 main,而是 _start

很多人第一次看 U-Boot 源码时会下意识去找main函数,结果发现根本找不到传统意义上的main。原因很简单:CPU 复位后没有任何操作系统帮你准备运行环境,栈指针是未知的,全局变量所在的内存可能还没初始化,甚至连内存控制器都没配好。这种状态下,C 代码根本无法可靠运行,因为 C 代码依赖栈、依赖已初始化的数据段、依赖可预测的内存布局。

所以链接器必须把入口点指定为一个汇编符号,这个符号就是_start。在 U-Boot 的链接脚本arch/arm/cpu/armv8/u-boot.lds里,你能看到类似这样的段落:

ENTRY(_start) SECTIONS { . = 0x00000000; . = ALIGN(8); .text : { *(.__image_copy_start) *(.vectors) CPUDIR/start.o (.text*) *(.text*) } ... }

注意*(.vectors)被放在很靠前的位置,而CPUDIR/start.o (.text*)紧随其后。这意味着最终镜像里,异常向量表在最前面,然后是start.S编译出来的代码。_start这个符号就定义在start.S里,但它和向量表的关系需要仔细看。

2.2 ARM64 的向量表布局与 0x0 地址的玄机

ARM64 架构下,异常向量表有固定的格式要求。它必须放在一个 2KB 对齐的地址上,表内包含 16 个条目,每个条目 128 字节,总共 2048 字节。这 16 个条目对应四类异常(同步、IRQ、FIQ、SError)在四种状态下的入口。U-Boot 在arch/arm/lib/vectors.S里定义了这张表:

.globl _start _start: b reset .align 7 b undefined_instruction .align 7 b software_interrupt ...

在 ARMv8 的 U-Boot 中,向量表的基地址由VBAR_EL3或VBAR_EL2等系统寄存器决定。复位时,如果运行在 EL3,硬件会从VBAR_EL3指向的地址取第一条指令。但很多 SoC 的 BootROM 会把 U-Boot 的镜像加载到某个地址并跳转过去,这时候_start的实际运行地址可能和链接地址不一致,这就引出了后面要讲的重定位问题。

这里有个容易踩的坑:如果你在调试时发现程序跑飞,先确认向量表是否真的在预期地址上。我遇到过一块板子,BootROM 把镜像加载到 0x40000000,但链接脚本里_start链接在 0x0,结果所有绝对地址跳转全部错乱。解决办法是在链接脚本里把起始地址改成实际加载地址,或者确保重定位代码在位置无关的前提下尽早执行。

2.3 reset 标签里到底做了什么

向量表第一条是b reset,这个reset标签才是真正干活的起点。在 ARMv8 的start.S里,reset处的代码大致做这几件事:

第一,保存启动参数。BootROM 或上一级引导程序可能会在寄存器里留下一些信息,比如设备树地址、启动介质标识。U-Boot 会把这些值保存到通用寄存器或临时内存里,后续 C 代码会用到。

第二,设置 CPU 状态。包括关闭 MMU、关闭 D-Cache、设置异常级别、配置栈指针。关闭 MMU 和 D-Cache 是必须的,因为此时页表还没建立,缓存一致性也无法保证。栈指针通常先指向一个临时区域,比如CONFIG_SYS_INIT_SP_ADDR定义的地址,这个地址必须在 SRAM 或已初始化的内存范围内。

第三,调用lowlevel_init。这个函数因平台而异,通常负责初始化时钟、DDR 控制器、串口等最基础的硬件。没有它,后面连打印都出不来。

第四,设置好 C 运行环境后,跳转到_main。注意不是main,而是_main,这个符号在arch/arm/lib/crt0_64.S或类似文件里定义,是汇编到 C 的桥梁。

3. 从汇编跳进 C 之前,必须搭好的三个台子

3.1 栈指针:C 语言的命根子

C 函数调用依赖栈来保存返回地址、局部变量和寄存器现场。在汇编阶段如果不把栈指针设好,第一个 C 函数调用就会直接跑飞。U-Boot 在reset里通常会这样设置:

ldr x0, =CONFIG_SYS_INIT_SP_ADDR mov sp, x0

但这里有个细节:ARM64 的栈是向下增长的,而且要求 16 字节对齐。CONFIG_SYS_INIT_SP_ADDR一般指向一块 SRAM 的高地址端,这样栈向下增长时不会覆盖到代码段。如果你在移植时发现 C 函数一进去就异常,先检查这个地址是否落在有效的 RAM 范围内,以及是否对齐。

另外,在进入 C 之前,U-Boot 还会预留一段空间给全局数据(gd)。gd是一个指向global_data结构体的指针,C 代码通过它访问各种全局状态。在 ARM64 上,gd通常保存在x18寄存器里,这是平台约定的。所以你在_main里会看到先把x18设置好,再调用board_init_f。

3.2 BSS 段清零:为什么不能省

C 语言规定未初始化的全局变量和静态变量初值为 0。但裸机环境下,这些变量所在的 BSS 段在内存里可能是随机值。所以必须在进入 C 之前把 BSS 段清零。U-Boot 在crt0_64.S里有一段循环:

ldr x0, =__bss_start ldr x1, =__bss_end mov x2, #0 clear_bss: str x2, [x0] add x0, x0, #8 cmp x0, x1 b.lo clear_bss

这段代码看起来简单,但有两个坑。第一,__bss_start和__bss_end必须由链接脚本正确导出,如果链接脚本写错,可能把代码段也清零了,那就直接死机。第二,如果 BSS 段很大,清零会消耗时间,在某些对启动速度敏感的场合,可以考虑只清零必要的部分,但标准做法还是全清。

3.3 重定位:U-Boot 最容易被误解的环节

重定位是 U-Boot 启动流程里最绕的部分。简单说,U-Boot 编译时链接在一个地址(比如 0x0 或 0x40000000),但实际运行时可能被加载到另一个地址(比如 DDR 里的 0x7FF00000)。如果代码里有绝对地址访问,就会出错。解决办法是在启动早期把整个 U-Boot 镜像从加载地址复制到链接地址,或者反过来,让代码运行在正确的位置。

在 ARM64 的 U-Boot 中,重定位通常发生在board_init_f之后、board_init_r之前。board_init_f会计算需要多少内存、最终运行地址在哪,然后调用relocate_code完成复制。复制完成后,程序会跳转到新的地址继续执行。这个过程涉及__image_copy_start、__image_copy_end、__rel_dyn_start等链接符号,以及动态重定位表的处理。

我踩过的一个坑是:在重定位之前使用了全局变量,结果变量地址还是旧的,导致判断逻辑出错。所以 U-Boot 在重定位前尽量只用栈上和寄存器里的数据,gd也是通过寄存器传递的。如果你在board_init_f里看到某个全局变量值不对,先想想是不是重定位还没发生。

4. _main 到 board_init_f:C 世界的第一段路

4.1 _main 的职责清单

_main是汇编和 C 的分界线,它本身也是汇编写的,但它的主要工作是为 C 函数铺路。在 ARM64 的crt0_64.S里,_main大致做这些事:

  • 设置gd指针,把它放到x18寄存器。
  • 调用board_init_f_alloc_reserve分配早期内存。
  • 调用board_init_f_init_reserve初始化gd结构体。
  • 清零 BSS。
  • 调用board_init_f。

注意board_init_f是第一个真正意义上的 C 函数。它接收一个参数,就是gd指针。在 ARM64 调用约定里,第一个参数放在x0寄存器,所以你会看到mov x0, x18然后bl board_init_f。

4.2 board_init_f 里到底在规划什么

board_init_f的名字容易让人误解,以为它是“初始化硬件”。其实它的核心任务是“规划”——规划内存布局、规划外设初始化顺序、规划后续流程。它运行在重定位之前,所以不能依赖全局变量,所有状态都通过gd传递。

它主要做这几件事:

第一,初始化串口。这是你能看到打印的前提。board_init_f会调用serial_init或类似的函数,把串口配好,然后通过printf输出一些调试信息。如果你在串口里看到第一行 U-Boot 版本信息,说明这一步成功了。

第二,计算内存分配。它会根据CONFIG_SYS_MALLOC_LEN、CONFIG_SYS_STACK_SIZE等配置,计算 U-Boot 自身需要多少内存,以及最终运行地址应该在哪。这些信息会存到gd->relocaddr、gd->new_gd等字段里。

第三,初始化定时器、环境变量存储等基础组件。这些组件在后续阶段会用到,但此时还不能依赖完整的内存管理。

第四,调用relocate_code完成重定位。重定位之后,程序会跳转到新的地址,继续执行board_init_r。

4.3 串口打印为什么是调试启动流程的第一抓手

在启动流程调试中,串口打印的价值无可替代。因为此时没有操作系统、没有文件系统、没有网络,你能依赖的输出通道只有串口。U-Boot 在board_init_f里初始化串口后,会通过debug或printf输出大量信息。如果你在某个阶段卡死,串口最后一行输出就是定位问题的关键线索。

我通常会在关键节点手动加打印,比如在_start里加一句汇编级别的串口输出(需要先配好串口寄存器),或者在board_init_f的每个子步骤前后加printf。这样即使没有调试器,也能大致判断卡在哪。注意,在重定位之前加打印要小心,因为串口寄存器地址可能是绝对地址,重定位后可能失效。所以重定位前后的打印要分开处理。

5. 重定位之后:board_init_r 与启动内核的最后一公里

5.1 board_init_r 的初始化顺序

重定位完成后,程序跳转到board_init_r。这个函数运行在新的地址上,可以正常使用全局变量和完整的内存管理。它的初始化顺序大致是:

  • 初始化 malloc 堆,让malloc可用。
  • 初始化环境变量,从存储介质里读取env。
  • 初始化设备模型(DM),扫描设备树,绑定驱动。
  • 初始化各种外设:MMC、NAND、网络、USB 等。
  • 进入主循环,等待用户输入或执行启动命令。

这个顺序不能乱。比如网络初始化依赖 malloc,环境变量读取依赖存储驱动,设备模型初始化依赖设备树。如果你在移植时发现某个外设不工作,先检查它的初始化是否在正确的阶段被调用。

5.2 启动内核时 U-Boot 做了什么

当用户输入bootm或booti命令时,U-Boot 会执行启动内核的流程。以 ARM64 的booti为例:

第一,解析内核镜像。ARM64 的 Image 格式有一个 64 字节的头部,里面包含魔数、加载地址、入口地址等信息。U-Boot 会校验魔数,如果不对就报错。你看到的“内核段既不是 arm64 image”这类错误,通常就是魔数校验失败。

第二,准备启动参数。包括设备树地址、initrd 地址、命令行参数等。这些信息会通过寄存器或内存传递给内核。

第三,关闭中断、关闭缓存、设置好 CPU 状态,然后跳转到内核入口地址。从这一刻起,U-Boot 的生命周期结束,内核开始接管。

5.3 常见启动卡死场景与排查思路

启动卡死是嵌入式调试的家常便饭。我整理了几种典型场景和排查方法:

现象可能原因排查手段
串口无任何输出串口未初始化、时钟未配置、镜像未正确加载检查 BootROM 跳转地址、测量串口引脚波形
输出几行后卡死DDR 初始化失败、栈指针错误、BSS 清零越界在卡死前加打印、用调试器查看 PC 值
重定位后跑飞链接地址与运行地址不一致、重定位表损坏检查链接脚本、确认__image_copy_start等符号
内核启动失败镜像格式错误、设备树地址错误、命令行参数错误校验镜像魔数、打印设备树地址、检查bootargs

这张表不是万能的,但能覆盖大部分常见问题。关键是要养成“从第一条指令开始排查”的习惯,而不是一上来就怀疑内核。

6. 几个让我印象深刻的移植翻车现场

6.1 栈指针指向了未初始化的 DDR

有一次移植一块新板子,串口能输出第一行,但紧接着就卡死。用调试器连上去看,发现 PC 停在一个莫名其妙的地方,栈指针指向的地址读出来全是 0xFF。后来查出来是CONFIG_SYS_INIT_SP_ADDR配到了 DDR 区域,但 DDR 控制器还没初始化,读写 DDR 直接返回错误。把栈指针改到片内 SRAM 后问题解决。

这个坑的教训是:早期栈必须放在无需初始化就能访问的内存里,通常是片内 SRAM 或 TCM。等 DDR 初始化完成后,再切换到 DDR 里的栈。

6.2 重定位后全局变量值不对

另一次是在board_init_f里设置了一个全局变量,然后在board_init_r里读它,发现值变了。原因就是重定位:board_init_f运行时变量在旧地址,重定位后变量被复制到新地址,但board_init_f里写的还是旧地址的值。解决办法是改用gd结构体传递,因为gd指针在重定位时会被更新。

6.3 向量表没对齐导致异常处理失效

ARM64 要求向量表 2KB 对齐。有一次链接脚本里忘了加.align 11,结果向量表没对齐,发生异常时 CPU 跳到了错误的地方,直接死机。加上对齐后,异常处理正常,能看到具体的异常类型和地址,排查效率大幅提升。

7. 给正在啃 U-Boot 启动流程的你几点实在建议

第一,不要一上来就通读所有汇编。先找到_start,然后顺着reset、_main、board_init_f、relocate_code、board_init_r这条主线走,每个阶段搞清楚输入、输出和关键操作。支线代码用到再看。

第二,善用链接脚本和符号表。u-boot.map和u-boot.lds能告诉你每个符号的地址和段布局。当你怀疑某个地址不对时,先查 map 文件。

第三,串口打印和调试器结合使用。串口告诉你“卡在哪”,调试器告诉你“为什么卡”。两者缺一不可。

第四,重定位是分水岭。重定位之前的代码要尽量位置无关,重定位之后才能放心用全局变量和完整内存管理。理解这一点,很多奇怪现象就解释得通了。

第五,多动手改配置、加打印、单步调试。看十遍源码不如自己改一次配置、加一行打印、用调试器单步走一遍。启动流程的细节只有在实际操作中才会真正暴露出来。

最后分享一个我常用的技巧:在_start的最开头加一段汇编,直接把某个 GPIO 拉高,用示波器或逻辑分析仪看波形。这样即使串口没配好,也能知道 CPU 是否执行到了这里。等串口配好后再去掉这段代码。这个方法在调试最早期启动问题时特别管用。

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

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

立即咨询