aarch64启动代码实战:向量表与异常处理从零搭建
你有没有遇到过这样的场景?板子一上电,程序还没跑进main()就死机了,串口输出一片空白,JTAG也连不上——这种“卡在黑暗中的bug”,往往就藏在那几十行不起眼的汇编代码里。
对于aarch64架构来说,这段神秘代码的核心任务之一,就是建立异常向量表。它就像系统的“急救中心”:当CPU遭遇非法指令、内存访问错误或外部中断时,能否正确跳转并响应,全靠这张表是否设置得当。
本文不讲空泛理论,而是带你亲手写一段可运行的aarch64启动代码,深入剖析向量表如何布局、异常如何捕获,并解释每一条汇编背后的工程考量。无论你是开发Bootloader、RTOS内核,还是参与芯片bring-up,这些内容都极具实战价值。
为什么你的aarch64程序还没进main就崩溃?
我们先来看一个真实问题:
“我用GCC编译了一段裸机程序,烧录到开发板后发现根本没执行
main()函数。用逻辑分析仪抓取总线信号,发现CPU复位后确实开始取指,但几条指令之后就停了。”
这种情况很常见。原因通常是:处理器触发了一个异常(比如未定义指令或访存失败),但由于没有配置异常向量表,直接跳到了不可预测的位置,导致系统挂死。
而在aarch64中,这个“默认跳转位置”是哪里?答案是:由VBAR_ELx寄存器决定的基地址 + 异常偏移。如果VBAR_ELx没初始化,默认值为0。也就是说,一旦发生异常,CPU就会尝试跳转到地址0去执行代码。
如果你的Flash不是从0开始映射,或者该区域没有合法的向量表,结果只能是死循环或总线错误。
所以,在高级语言环境启用前,必须完成的第一件事就是——建立一张有效的异常向量表,并将其地址写入VBAR寄存器。
aarch64异常模型到底怎么工作?
要搞懂向量表,得先理解aarch64的异常机制是如何设计的。别被手册里的术语吓住,其实它的逻辑非常清晰。
四级特权:EL0 到 EL3,谁管谁?
aarch64引入了四个异常级别(Exception Level, 简称EL),数字越大权限越高:
| EL | 名称 | 典型用途 |
|---|---|---|
| EL0 | 用户态 | 应用程序运行 |
| EL1 | 内核态 | Linux内核、RTOS |
| EL2 | 虚拟机监控器 | KVM、Hypervisor |
| EL3 | 安全监控器 | TrustZone、Secure Monitor |
你可以把它想象成一座四层楼的大厦:
- 普通住户住在一楼(EL0);
- 物业管理员在二楼(EL1)处理日常事务;
- 地下室有个安保中心(EL3),专门应对紧急情况;
- 中间还有一层给租户中介用(EL2),负责虚拟房间分配。
每一层都有自己的“门禁规则”。例如,用户程序想调用系统服务(如打印字符串),就得通过SVC指令发起请求,由EL1接管处理。
异常来了,CPU自动做了什么?
当异常发生时(比如执行了未定义指令),硬件会自动完成以下几步:
保存现场:
- 当前状态(PSTATE) → 存入SPSR_ELx
- 下一条指令地址(返回地址) → 存入ELR_ELx切换等级:
根据异常类型和配置,跳转到目标EL(如EL1)查找入口:
使用当前EL的VBAR_ELx作为基址,加上对应异常类型的偏移量,跳转到向量表中的指定位置
整个过程无需软件干预,但前提是:VBAR必须指向一张结构正确的向量表。
向量表长什么样?512字节里藏着什么秘密?
ARMv8规定,每张异常向量表占512字节(0x200),包含16个向量条目,每个条目128字节(0x80)。这128字节不是随便浪费空间的——它是有意为之的设计,允许你在里面放一小段完整的处理逻辑。
向量表结构拆解
以VBAR_EL1为例,其组织如下:
Offset Description ─────────────────────────────────────── 0x000 同步异常,使用SP_EL0(用户栈) 0x080 IRQ中断,使用SP_EL0 0x100 FIQ快速中断,使用SP_EL0 0x180 SError系统错误,使用SP_EL0 0x200 同步异常,使用SP_ELx(内核栈) 0x280 IRQ中断,使用SP_ELx 0x300 FIQ快速中断,使用SP_ELx 0x380 SError系统错误,使用SP_ELx ...(其余保留用于高EL)每组分为两种栈选择模式:
-SP0:来自低一级EL且使用SP_EL0(通常表示用户态上下文)
-SPx:来自当前或更高EL,使用本级栈指针
举个例子:
- 如果你在EL0执行SVC #0,属于“同步异常”,并且使用的是用户栈(SP_EL0),那么会跳转到VBAR_EL1 + 0x000
- 如果你在EL1自己触发了一个未定义指令,则跳转到VBAR_EL1 + 0x200
这意味着你可以为不同上下文提供不同的处理路径,灵活性极高。
手把手写一个可用的向量表
现在我们来动手实现一个最简版本的向量表。目标是在发生异常时能停下来,打印出错信息,而不是悄无声息地死机。
第一步:定义向量表结构(汇编)
.section ".vectors", "ax" .align 9 // 必须512字节对齐 (2^9) .global g_vector_table g_vector_table: // ================ 同步异常,来自Lower EL (SP_EL0) ================ b handle_sync_sp0 // offset 0x000 nop nop .space 128 - 4*4 // 填充至128字节 // ================ IRQ,来自Lower EL ============================= b handle_irq_sp0 // offset 0x080 nop nop .space 128 - 4*4 // ================ FIQ,来自Lower EL ============================= b handle_fiq_sp0 nop nop .space 128 - 4*4 // ================ SError,来自Lower EL ========================== b handle_serror_sp0 nop nop .space 128 - 4*4 // ================ 同步异常,来自Current/Lower EL (SP_ELx) ====== b handle_sync_spx // offset 0x200 nop nop .space 128 - 4*4 // ================ IRQ,来自Current/Lower EL ===================== b handle_irq_spx nop nop .space 128 - 4*4 // ================ FIQ,来自Current/Lower EL ===================== b handle_fiq_spx nop nop .space 128 - 4*4 // ================ SError,来自Current/Lower EL ================== b handle_serror_spx nop nop .space 128 - 4*4注意几点:
-.align 9是强制要求,否则VBAR_ELx写入时会因对齐检查失败而导致异常。
- 每个向量块留足128字节,方便后续插入调试代码或跳转逻辑。
- 使用相对跳转b而非绝对跳转,保证位置无关性(适合固化在ROM中)。
第二步:编写异常处理桩函数
接下来我们实现其中一个处理函数,比如handle_sync_spx(最常见的同步异常):
.extern exception_handler_c // C语言中的统一处理函数 handle_sync_spx: stp x0, x1, [sp, #-16]! // 保存x0-x1 stp x2, x3, [sp, #-16]! mrs x0, esr_el1 // 获取异常原因(ESR: Exception Syndrome Register) mrs x1, elr_el1 // 获取出错指令地址(ELR: Exception Link Register) mrs x2, spsr_el1 // 获取异常前状态 bl exception_handler_c // 转发给C函数做日志输出 b . // 死循环等待调试这里的ESR_EL1尤其重要。它记录了异常的具体类型,比如:
-0x25:未定义指令(Undefined instruction)
-0x3c:SVC调用
-0x96:数据访问异常(Data Abort)
通过解析ESR,你能精确知道是哪条指令出了问题,极大提升调试效率。
启动代码:从复位到C世界的桥梁
有了向量表,还需要一段启动代码将它“激活”。这是系统真正意义上的第一段软件逻辑。
典型_start流程
.section ".text.startup", "ax" .global _start _start: // 设置堆栈指针(必须最先做!) ldr x0, =stack_top mov sp, x0 // 清零部分寄存器(可选,防干扰) mov x1, #0 mov x2, #0 // 读取当前EL,确认运行环境 mrs x0, CurrentEL ubfx x0, x0, 2, 2 // 提取bits[3:2] => 0x4=EL1, 0x8=EL2, 0xC=EL3 cmp x0, #4 // 是否已在EL1? b.eq 1f // 若不在EL1,需降级(简化处理,实际应构造上下文后eret) // 这里假设我们希望最终运行在EL1 // 实际项目中可能需要通过eret链式返回,此处略过 1: // 加载向量表地址并写入 VBAR_EL1 ldr x0, =g_vector_table msr vbar_el1, x0 // 可选:启用缓存(需配合sctlr_el1设置) // mrs x1, sctlr_el1 // orr x1, x1, #(1 << 2) // 设置I bit,开启指令缓存 // msr sctlr_el1, x1 // 跳转到C环境主函数 bl c_main_entry // 不应到达这里 b .关键点说明:
- 堆栈必须优先设置:任何函数调用(包括
bl)都会修改SP,若未初始化会导致不可预知行为。 - CurrentEL必须检查:有些SoC出厂默认从EL2或EL3启动(如某些Rockchip芯片),盲目设置
VBAR_EL1无效。 - VBAR写入时机:越早越好。建议在进入C之前完成,确保后续代码哪怕出错也能被捕获。
工程实践中的坑点与秘籍
别以为写了上面代码就能一帆风顺。以下是我在多个项目中踩过的坑,值得你警惕:
❌ 坑点1:向量表放在了会被覆盖的DRAM区域
现象:系统启动初期还能处理异常,加载第二阶段镜像后突然无法响应中断。
原因:向量表被链接到了SDRAM低端地址,而后续加载的Bootloader恰好覆盖了这块内存。
✅ 解法:
- 将.vectors段放入SRAM或已知安全的静态区域;
- 或者在加载下一阶段前重新设置VBAR_ELx指向新的位置。
❌ 坑点2:多核系统只给一个核心设置了VBAR
现象:CPU0能正常处理中断,CPU1一开中断就死机。
原因:每个物理核心都有独立的VBAR_ELx寄存器!启动时必须逐个初始化。
✅ 解法:
for_each_cpu(cpu) { write_vbar_per_core(cpu, vector_base); }通常在PSCI(Power State Coordination Interface)唤醒次核后立即执行。
✅ 秘籍1:用LED编码异常类型,无串口也能调试
在资源受限环境下,可以这样做:
void exception_handler_c(uint64_t esr, uint64_t elr, uint64_t spsr) { uint32_t reason = esr & 0xFF; for (;;) { flash_led(reason); // 用闪烁次数表示异常码 delay_ms(500); } }这样即使没有串口输出,也能大致判断故障类别。
✅ 秘籍2:向量表区域设为只读,防止篡改
在启用MMU后,务必通过页表将向量表所在页标记为只读:
map_page((uint64_t)&g_vector_table, (uint64_t)&g_vector_table, PAGE_ATTRIB_RO_EXEC); // 只读可执行避免恶意代码或野指针破坏向量表,造成系统失控。
链接脚本怎么配?别让向量表“丢”了
很多开发者忘了这一点:即使写了.vectors段,如果不告诉链接器怎么安排它,最终也不会出现在正确位置。
示例linker.ld片段:
ENTRY(_start) MEMORY { ROM : ORIGIN = 0x00000000, LENGTH = 64K RAM : ORIGIN = 0x80000000, LENGTH = 128M } SECTIONS { . = ORIGIN(ROM); .text : { KEEP(*(.vectors)) /* 必须放在最前面 */ *(.text.startup) *(.text*) } > ROM .rodata : { *(.rodata*) } > ROM .data : { *(.data*) } > RAM AT > ROM __data_load_addr = LOADADDR(.data); __data_start = ADDR(.data); __data_size = SIZEOF(.data); .bss : { __bss_start = .; *(.bss*) __bss_end = .; } > RAM }重点:
-KEEP(*(.vectors))防止被优化掉;
- 放在.text起始位置,确保复位后第一条指令就是向量表;
- 使用AT > ROM支持重定位(data段从Flash拷贝到RAM);
结语:掌握底层,才能掌控全局
当你下次面对一块全新的aarch64开发板时,不妨问自己几个问题:
- 复位后CPU处于哪个EL?
- VBAR现在指向哪儿?
- 如果我现在故意写一条udf指令,会发生什么?
这些问题的答案,决定了你是在“控制硬件”,还是“被硬件控制”。
向量表虽小,却是连接硬件与软件的枢纽。它不仅是异常处理的起点,更是构建可信执行环境的第一块基石。无论是写一个简单的裸机程序,还是移植ARM Trusted Firmware,理解并正确实现这一机制,都是不可或缺的基本功。
如果你正在开发U-Boot SPL、RT-Thread Nano、FreeRTOS porting,或是参与国产芯片的底层适配,这套方法论可以直接套用。欢迎在评论区分享你的实战经验,我们一起把“黑盒”变成“透明盒子”。