从复位向量到main函数:深入 aarch64 裸机启动全流程
你有没有想过,当你按下电源键的那一刻,CPU 是如何“活”起来的?它第一条指令从哪里来?又是怎样一步步建立起运行环境,最终调用我们熟悉的 C 语言main函数?
在 x86 平台,这个问题可能还带着实模式、保护模式切换的历史包袱。但在现代aarch64(ARMv8-A 64 位执行状态)架构中,这一切变得更加清晰而结构化——只要你知道该看哪里。
本文不讲理论堆砌,而是带你亲手实现一个最小可运行的 aarch64 裸机程序,从上电开始,穿越异常级别、栈初始化、向量表配置,直到c_main()被成功调用。整个过程无需操作系统,完全依赖汇编与链接脚本控制,适合嵌入式开发者、Bootloader 编写者和对底层机制好奇的技术爱好者。
启动起点:CPU 上电后第一件事
当 aarch64 处理器上电或复位时,硬件会将程序计数器(PC)指向一个预定义的物理地址,通常是:
0x0000_0000 或 0xFFFF_0000这个地址就是所谓的复位向量(Reset Vector)。此时 CPU 已经处于 AArch64 执行状态,并且绝大多数 SoC 默认以最高特权级EL3启动。
✅ 为什么是 EL3?
因为它是安全监控器(Secure Monitor)的运行层级,也是 TrustZone 安全启动链的信任根。即使你不做安全功能,很多平台仍默认从此级开始。
在这个地址处,我们必须放置一段极简的汇编代码,作为整个系统的入口点_start。这段代码不能依赖任何运行时环境——没有栈、没有.data初始化、甚至不能直接调用 C 函数。
第一步:设置栈指针(SP),否则一切皆空
C 语言函数调用依赖栈来保存局部变量、返回地址和参数传递。但刚上电时,sp寄存器是未定义的。如果此时直接bl main,编译器生成的代码可能会尝试访问非法内存区域,触发Data Abort异常,系统瞬间死机。
所以,第一步必须手动设置栈指针。
假设我们的板子有片上 SRAM 或 DRAM 映射在0x80000000开始的位置,我们可以这样写:
.section ".text.startup" .global _start .equ STACK_TOP, 0x80001000 // 栈顶地址(高地址) _start: mov sp, #0x80001000 // 设置栈指针 mov x29, #0 // 清除帧指针 FP b c_main // 直接跳转(不用 bl,避免 LR 写入无效地址)⚠️ 注意事项:
- aarch64 的栈向下增长,因此sp应指向分配内存的高地址。
- AAPCS64 要求栈保持16 字节对齐,所以我们选择0x80001000这样的边界。
- 不使用bl是为了防止无意中修改 X30(LR),虽然在这里影响不大,但属于良好习惯。
现在,栈准备好了,但我们还没完——万一发生异常怎么办?比如访问了错误地址,或者执行了非法指令?系统会直接飞掉。
第二步:建立异常向量表,让崩溃也能说话
aarch64 使用异常向量表(Exception Vector Table, EVT)来处理各种异常事件,包括:
- 复位
- 指令/数据中止
- IRQ/FIQ 中断
- 系统调用(SVC)
每个异常类型对应一个 128 字节的槽位,共 16 个槽,总计 2KB。这张表的位置可以由寄存器VBAR_EL3动态指定。
我们先定义一个最简版本的向量表:
.align 11 // 对齐到 2048 字节(2^11),确保表起始位置正确 .vector_table: // 当前 EL 发生同步异常(如非法指令、访存失败) .org 0x000 b handle_sync_el3 // 当前 EL 的 IRQ(外部中断) .org 0x080 b handle_irq_el3 // 当前 EL 的 FIQ .org 0x100 b handle_fiq_el3 // SError(系统错误) .org 0x180 b handle_serror_el3 // 其他情况省略...接着,在_start中将其地址写入VBAR_EL3:
_start: // 设置栈 mov sp, #STACK_TOP // 加载向量表地址到 x0 adrp x0, .vector_table // 获取页基址 add x0, x0, #:lo12:.vector_table // 加上低 12 位偏移 msr vbar_el3, x0 // 写入 VBAR_EL3 b c_main再补充一个简单的同步异常处理函数:
handle_sync_el3: stp x0, x1, [sp, #-16]! // 临时压栈 mrs x0, esr_el3 // 读取异常原因 mrs x1, far_el3 // 读取出错地址 // 此处可通过 UART 输出调试信息(后续扩展) b . // 死循环挂起🔍 ESR_EL3 包含异常类型码(ISS),FAR_EL3 是故障虚拟地址。这两个寄存器是定位低层 crash 的关键线索。
有了这个机制,哪怕你的代码出了问题,也不会静默重启,而是能留下“遗言”。
第三步:理解异常级别(EL)与权限模型
aarch64 的核心安全机制之一就是异常级别(Exception Level, EL),共四级:
| EL | 名称 | 典型用途 |
|---|---|---|
| 3 | Secure Monitor | 安全世界切换、可信执行环境 |
| 2 | Hypervisor | 虚拟机管理 |
| 1 | OS Kernel | Linux 内核等 |
| 0 | User Application | 普通应用程序 |
大多数裸机程序从 EL3 启动,但主逻辑通常降级到 EL1 运行,以模拟真实操作系统环境。
你可以通过以下方式检查当前 EL:
mrs x0, CurrentEL lsr x0, x0, #2 // 提取 bits[3:2] and x0, x0, #0b11 // 得到 EL 数值(3=EL3, 2=EL2...)如果你想从 EL3 切换到 EL1 并运行非安全世界代码,还需要配置:
-SCR_EL3:启用非安全状态
-SCTLR_EL1:开启 MMU、对齐检查等
-SPSR_EL3:设置目标 EL 的处理器状态
不过对于初学者,先专注于 EL3 下运行 C 代码即可。
第四步:链接脚本设计——掌控内存布局的生命线
在裸机系统中,没有操作系统的加载器帮你搬数据。.data段需要从 Flash 复制到 RAM,.bss需要清零,这些都靠你自己安排。
这就引出了链接脚本(Linker Script)的重要性。
下面是一个典型的.lds文件:
ENTRY(_start) MEMORY { RAM : ORIGIN = 0x80000000, LENGTH = 64M } SECTIONS { . = ORIGIN(RAM); /* 代码段 */ .text : { KEEP(*(.text.startup)) /* 必须放在最前面 */ *(.text) } > RAM /* 只读数据 */ .rodata : { *(.rodata) } > RAM /* 可读写数据段(需复制) */ .data : { _data_lma = LOADADDR(); /* 记录加载地址 */ *(.data) } > RAM /* BSS 段(清零) */ .bss ALIGN(16) : { _bss_start = .; *(.bss) _bss_end = .; } > RAM /* 丢弃调试符号 */ /DISCARD/ : { *(.comment) *(.debug_info) *(.note.gnu.build-id) } }关键点说明:
-ENTRY(_start)确保_start是程序入口。
-KEEP(*(.text.startup))防止被优化掉,保证_start在.text最前端。
-_bss_start和_bss_end提供给 C 代码用于清零。
-.data的LOADADDR()可用于判断是否需要复制(例如从 ROM 到 RAM)。
第五步:进入 C 语言世界前的最后准备
虽然我们现在可以直接跳转c_main,但更完整的流程应该包含:
- 复制
.data段(如果代码存储在只读介质) - 清零
.bss段 - 可选:初始化 C++ 构造函数表(本文不涉及)
为此,我们在 C 层面提供一个启动函数:
// crt0.c - C runtime initialization extern char _data_lma, _data_vma, _edata; extern char _bss_start, _bss_end; void __attribute__((noinline)) copy_data_and_bzero_bss(void) { // 复制 .data char *src = &_data_lma; char *dst = &_data_vma; while (dst < &_edata) { *dst++ = *src++; } // 清零 .bss dst = &_bss_start; while (dst < &_bss_end) { *dst++ = 0; } } void c_main(void) { copy_data_and_bzero_bss(); // 现在可以安全使用全局变量了! while (1) { // 用户逻辑 } }当然,如果你只是跑个 demo,也可以跳过这一步,前提是所有变量都不在.data/.bss中。
常见陷阱与调试秘籍
❌ 陷阱 1:忘记设置栈就调用 C 函数
现象:CPU 上电后立即死机,无任何输出。
原因:C 函数试图使用 SP 存储局部变量或保存寄存器。
✅ 解法:务必在调用任何 C 函数前设置sp。
❌ 陷阱 2:链接脚本未保留.text.startup
现象:程序入口不是_start,而是某个随机函数。
原因:链接器按字母顺序排列.text段,导致_start不在开头。
✅ 解法:使用KEEP(*(.text.startup))并确保其优先链接。
❌ 陷阱 3:异常向量表未对齐或地址错误
现象:发生异常时系统行为不可预测。
原因:VBAR_EL3必须指向 2KB 对齐的地址,且表内偏移必须正确。
✅ 解法:使用.align 11并验证.vector_table地址确实是 2048 字节对齐。
✅ 调试建议
- 使用 QEMU 模拟器快速测试:
bash qemu-system-aarch64 -machine virt -cpu cortex-a57 -nographic \ -kernel your_image.bin -semihosting - 添加 LED 闪烁或 UART 输出作为“心跳”信号。
- 利用 GDB 单步跟踪
_start执行流程:gdb (gdb) target remote :1234 (gdb) break _start (gdb) stepi
结语:掌握裸机,才能真正驾驭硬件
从_start到c_main,看似只有几步,实则贯穿了现代处理器的核心机制:异常模型、栈规范、内存布局、链接控制。
这套流程不仅是编写 U-Boot SPL、Barebox 或 TrustZone TA 的基础,更是你在遇到 kernel panic、early boot crash 时能够独立分析的根本能力。
下一步你可以尝试:
- 添加串口驱动,打印 “Hello from bare-metal!”
- 启用 MMU 实现虚拟内存
- 实现多核启动(PSCI 调用)
- 构建支持加载 ELF 的小型 Bootloader
技术之路,始于脚下。现在,你已经迈出了最关键的第一步。
如果你正在实践这一流程,欢迎在评论区分享你的开发板型号和遇到的问题,我们一起 debug。