异常模型基础:异常类型、异常等级与向量表基址

异常处理,说白了就是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的完整路由路径。我画这张图时特意把每个等级的向量表和路由决策点标出来了,方便你理解异常是怎么“往上冒”的。

异常路由与等级切换流程 EL0 用户态 应用程序 触发 SVC / 缺页 / IRQ 异常陷入 EL1 内核态 Linux / RTOS 内核 VBAR_EL1 向量表 是否需要升到更高等级? (安全/虚拟化相关异常) 需要虚拟化 EL2 Hypervisor KVM / Xen VBAR_EL2 向量表 需要安全服务 EL3 Secure Monitor ATF / TrustZone VBAR_EL3 向量表 ERET 返回 说明: 1. 异常从当前等级向上路由,每个等级检查自己的VBAR_ELx向量表。 2. 如果当前等级能处理,就不继续往上路由;否则升到更高等级。

从这张图你能看到,异常处理路径其实是一条“向上冒泡”的链。每个等级先看自己能不能处理,不能就往上抛。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被污染了。

小结