异常模型基础:异常类型、异常等级与向量表基址
异常处理,说白了就是CPU在运行过程中遇到“意外情况”时,该怎么停下来、怎么处理、怎么回去。我在做ARMv8/v9的底层固件时,发现很多性能问题其实都出在异常处理路径上。所以这一章,咱们先把异常模型的基础打牢。
异常类型:Synchronous vs Asynchronous
ARMv9把异常分成两大类:同步异常和异步异常。这个分类标准很简单——异常的产生是否和当前执行的指令直接相关。
| 类型 | 触发条件 | 典型例子 | 能否精确返回 |
|---|---|---|---|
| Synchronous | 由当前指令直接引发 | 缺页异常、未定义指令、SVC调用 | 能,返回地址指向触发指令 |
| Asynchronous | 与当前指令无关的外部事件 | IRQ、FIQ、SError | 不一定,需软件保存上下文 |
同步异常,我习惯叫它“指令级异常”。你执行了一条指令,这条指令本身出了问题,CPU立刻停下来,跳转到异常向量。比如你访问了一个不存在的内存地址,MMU直接给你报一个同步异常。嗯,这里要注意:同步异常的返回地址一定指向触发异常的指令(或者下一条,取决于具体异常类型),所以恢复执行时很明确。
异步异常,说白了就是“外部打断”。中断控制器告诉你“网卡有数据到了”,这时候CPU可能正在执行一条加法指令。这条加法指令本身没问题,但CPU必须停下来去处理网卡中断。异步异常的返回地址指向下一条还没执行的指令。我在项目中遇到过一个问题:中断处理函数里不小心修改了被中断线程的寄存器,结果恢复执行后数据全乱了。所以异步异常处理一定要保存和恢复现场。
核心区别一句话:同步异常是“指令自己惹的祸”,异步异常是“外部有人敲门”。
异常等级:EL0 ~ EL3
ARMv9定义了4个异常等级(Exception Level),从EL0到EL3。等级越高,权限越大,能访问的硬件资源越多。
- EL0:用户态。跑应用程序的地方。不能直接操作硬件,不能配置MMU,不能关中断。
- EL1:操作系统内核态。Linux内核、RTOS都跑在这里。可以配置MMU、管理虚拟内存。
- EL2:虚拟化层。Hypervisor(如KVM、Xen)待的地方。负责虚拟机之间的隔离。
- EL3:安全监控层。最高权限,负责安全世界和普通世界的切换(TrustZone)。
你想想看,一个异常发生时,CPU会从当前等级“陷入”到更高等级去处理。比如EL0的应用程序触发了一个系统调用(SVC),CPU会陷入到EL1,让内核来处理。处理完再返回EL0。
我曾经在调试一个虚拟化场景的性能问题时,发现中断响应延迟特别高。查了半天,原来是中断被配置成从EL1路由到EL2再路由到EL3,绕了一大圈。后来改成直接路由到EL1,延迟降了30%。所以异常等级的路由配置,直接影响实时性。
我的建议:在设计系统时,尽量让异常在最低的等级处理完。能不升EL2就不升,能不升EL3就不升。每升一级,上下文切换的开销就多一层。
异常向量表基址:VBAR_ELx
异常向量表,就是一张“异常处理函数跳转表”。CPU发生异常时,硬件会根据异常类型和当前等级,从这张表里找到对应的处理入口地址。
每个异常等级都有自己的向量表基址寄存器:VBAR_EL1、VBAR_EL2、VBAR_EL3。EL0没有自己的VBAR,因为它不能处理异常,异常必须升到EL1及以上去处理。
向量表的布局是固定的,一共16个条目,每个条目占128字节(32条指令)。布局如下:
| 偏移 | 异常类型 | 说明 |
|---|---|---|
| 0x000 | Current EL with SP0, Synchronous | 当前等级,使用SP_EL0,同步异常 |
| 0x080 | Current EL with SP0, IRQ | 当前等级,使用SP_EL0,IRQ |
| 0x100 | Current EL with SP0, FIQ | 当前等级,使用SP_EL0,FIQ |
| 0x180 | Current EL with SP0, SError | 当前等级,使用SP_EL0,SError |
| 0x200 | Current EL with SPx, Synchronous | 当前等级,使用SP_ELx,同步异常 |
| 0x280 | Current EL with SPx, IRQ | 当前等级,使用SP_ELx,IRQ |
| 0x300 | Current EL with SPx, FIQ | 当前等级,使用SP_ELx,FIQ |
| 0x380 | Current EL with SPx, SError | 当前等级,使用SP_ELx,SError |
| 0x400 | Lower EL using AArch64, Synchronous | 从低等级(AArch64)陷入,同步异常 |
| 0x480 | Lower EL using AArch64, IRQ | 从低等级(AArch64)陷入,IRQ |
| 0x500 | Lower EL using AArch64, FIQ | 从低等级(AArch64)陷入,FIQ |
| 0x580 | Lower EL using AArch64, SError | 从低等级(AArch64)陷入,SError |
| 0x600 | Lower EL using AArch32, Synchronous | 从低等级(AArch32)陷入,同步异常 |
| 0x680 | Lower EL using AArch32, IRQ | 从低等级(AArch32)陷入,IRQ |
| 0x700 | Lower EL using AArch32, FIQ | 从低等级(AArch32)陷入,FIQ |
| 0x780 | Lower EL using AArch32, SError | 从低等级(AArch32)陷入,SError |
为什么每个条目要留128字节?因为异常处理的前几步——保存上下文、切换栈指针、识别异常原因——这些代码可以直接放在向量表里,不用再跳转一次。这样可以省掉一次分支预测失败的开销。我在做性能调优时,会把最频繁的异常处理逻辑直接内联在向量表里,实测能省5~10个cycle。
注意:VBAR_ELx必须按2KB对齐。因为向量表大小是2KB(16个条目 × 128字节),硬件只取VBAR的高位地址,低位强制为0。如果你没对齐,CPU会直接挂掉。我曾经见过一个同事把VBAR配错了,系统一进中断就跳飞,查了两天才找到原因。
异常路由与等级切换流程
下面这张图展示了异常从EL0触发,经过EL1处理,再到EL3的完整路由路径。我画这张图时特意把每个等级的向量表和路由决策点标出来了,方便你理解异常是怎么“往上冒”的。
从这张图你能看到,异常处理路径其实是一条“向上冒泡”的链。每个等级先看自己能不能处理,不能就往上抛。ERET指令负责从高等级返回低等级,恢复现场。
异常向量表配置示例
下面是一个配置VBAR_EL1的代码片段,我习惯在系统启动早期就把向量表地址写进去:
// 汇编示例:配置VBAR_EL1
.section .vectors, "ax"
.align 11 // 2KB对齐
.global vector_table_el1
vector_table_el1:
// 0x000: Current EL with SP0, Sync
b sync_curr_sp0
.align 7
// 0x080: Current EL with SP0, IRQ
b irq_curr_sp0
.align 7
// 0x100: Current EL with SP0, FIQ
b fiq_curr_sp0
.align 7
// 0x180: Current EL with SP0, SError
b serror_curr_sp0
.align 7
// 0x200: Current EL with SPx, Sync
b sync_curr_spx
.align 7
// ... 后续条目类似
// 启动时配置VBAR_EL1
.global init_vbar_el1
init_vbar_el1:
ldr x0, =vector_table_el1
msr vbar_el1, x0
isb
ret
注意那个.align 11,就是2KB对齐。每个条目之间用.align 7(128字节对齐)隔开。我刚开始写的时候忘了加对齐,结果向量表错位,中断一进来就跑到错误的地方去了。嗯,这种坑踩过一次就记住了。
性能小技巧:把最频繁的异常处理(比如timer中断)放在向量表的前几个条目里,并且尽量让处理代码在128字节内完成。这样可以避免额外的跳转,减少icache miss。我在一个网络设备驱动里这么做过,中断延迟从120ns降到了95ns。
异常处理的核心寄存器
除了VBAR_ELx,还有几个寄存器在异常处理中至关重要:
| 寄存器 | 全称 | 作用 |
|---|---|---|
SPSR_ELx |
Saved Program Status Register | 保存异常发生前的PSTATE(包括条件标志、中断掩码、异常等级) |
ELR_ELx |
Exception Link Register | 保存异常返回地址 |
ESR_ELx |
Exception Syndrome Register | 记录异常原因和详细信息(比如缺页、未定义指令、SVC编号) |
FAR_ELx |
Fault Address Register | 记录触发异常的虚拟地址(仅地址相关异常有效) |
异常处理的第一步,就是读取ESR_ELx,判断异常原因。比如ESR的值是0x20,说明是“指令异常”(Instruction Abort)。如果是0x25,就是“数据异常”(Data Abort)。每个异常原因都有对应的处理逻辑。
我曾经在调试一个SError(系统错误)时,发现ESR的值是0x3F,查手册才知道是“内存访问的ECC错误”。这种错误通常意味着硬件内存有问题,或者缓存一致性出了故障。嗯,那次排查花了我整整两天。
避坑指南:异常处理函数里,一定要先保存SPSR_ELx和ELR_ELx,再去做其他操作。因为一旦你调用了其他函数,或者触发了新的异常,这两个寄存器的值就会被覆盖。我曾经在调试时忘了保存SPSR,结果异常返回后程序跑飞,查了半天才发现是SPSR被污染了。