AArch64 异常模型 - 异常处理
本文介绍AArch64异常模型的异常处理。
1. 处理异常
1.1 前言概述
发生异常时的硬件自动行为
当异常发生并进入以 AArch64 运行的目标异常级别(ELx)时,处理器(PE)会自动完成以下操作:
- 把异常发生前一刻的
PSTATE内容保存到对应的SPSR_ELx(Saved Program Status Register); - 把首选异常返回地址保存到
ELR_ELx(Exception Link Register); - 记录异常原因:对同步异常与 SError 中断,把具体原因(Exception Syndrome)写入
ESR_ELx;对与内存地址相关的同步异常(如 MMU Fault),再把触发异常的虚拟地址写入FAR_ELx(Fault Address Register)。
AArch64 异常向量表(Vector Table)的特点
与很多传统架构不同,AArch64 的向量表存放的不是跳转地址,而是可执行代码/指令。每个向量条目最多包含 32 条指令(128 字节 / 32 字),这个空间刚好够软件完成基础的寄存器压栈保存(Stacking),并根据异常类型跳转到更深的处理函数(Handler)。
权限级别(EL)变迁规则
权限的升降各有且仅有一条途径:提升权限只能通过触发异常(Taking an Exception),降低权限只能通过异常返回(Exception Return,如 ERET)。方向上,发生异常时 EL 可以升高或保持不变,异常返回时 EL 可以降低或保持不变。异常的目标 EL 可以由异常类型隐式定义,也可以通过系统寄存器配置位指定。
EL0 尤为特殊:异常只能从 EL0 提升到更高的 EL(EL1~EL3),异常永远不会路由给 EL0,也不存在 EL0 向量表。
执行状态(AArch32 ↔ AArch64)切换与映射(Interprocessing)
处理器只能在系统复位(Reset)、发生异常或异常返回时更改执行状态。
架构层面的级别约束
- 如果某个 EL 使用 AArch32,则其所有较低的 EL 必须全部使用 AArch32;
- 如果某个 EL 使用 AArch64,则其所有较高的 EL 必须全部使用 AArch64。
Armv9-A 与部分 Armv8-A 的限制
在 Armv9-A 及部分 Armv8-A 实现中,AArch32 仅在 EL0 受支持。要更改 EL0 的执行状态,必须先触发异常提升到更高级别的 EL,修改配置后再返回 EL0。
寄存器映射规则(AArch32 到 AArch64)
从 AArch32 陷入 AArch64 时,为方便处理程序读取数据,通用寄存器做了物理映射:
- R0–R12 → X0–X12;
- Banked SP / LR → X13–X23;
- Banked FIQ → X24–X30。
对于高位与不可访问的寄存器:AArch32 状态下无法访问的寄存器,保留先前 AArch64 执行时的原值;双状态共享的 64 位寄存器,低 32 位是映射后的 AArch32 寄存器值,高 32 位为 UNKNOWN 或保留旧值/全 0。
1.2 保存当前处理器状态
PSTATE 概念与保存机制
AArch64 用 PSTATE(Processor State,处理器状态)记录当前的运行环境与状态,在 Armv7 及更早架构中被称为 CPSR。PSTATE 主要包含:条件标志位(N、Z、C、V)、执行状态控制位、访问控制位、时序与投机执行控制位,以及异常屏蔽标志位 DAIF——对应位为 1 时该异常被屏蔽(Masked),暂时不触发:
- D(Debug):调试异常屏蔽位;
- A(SError):异步 SError 屏蔽位;
- I(IRQ):常规中断屏蔽位;
- F(FIQ):快速中断屏蔽位。
状态保存与恢复的全流程
异常发生时,硬件与软件按下述流程协同工作:
- 自动快照保存:处理器把当前
PSTATE快照写入目标异常级别的 SPSR(如异常跳转到 EL1 处理,则更新SPSR_EL1); - 硬件更新 PSTATE:处理器随后把当前
PSTATE更新为该异常类型定义的全新状态(包括更新目标 EL 与安全状态); - 跳转至处理函数:状态更新完成后,跳转到向量表中对应的异常入口,开始执行 handler 代码;
- 恢复与返回(ERET):异常处理完毕后执行
ERET,硬件自动把SPSR_ELx的内容还原回PSTATE,并跳转到ELR_ELx记录的返回地址,恢复被中断程序的运行。
1.3 路由和中断控制
中断路由的基本概念(Target Exception Level)
每种异常触发后跳转到哪个 EL,要么由架构隐式固定,要么通过系统寄存器的配置位由软件动态配置。但有一条铁律:异常绝对不能被路由给 EL0——EL0 没有异常向量表,也不允许接收异常。
异常类型的路由规则
- 同步异常(Synchronous):按照发起指令(如
SVC、HVC、SMC)自带的固定架构规则路由; - 异步异常(IRQ / FIQ / SError):可以独立配置路由到 EL2(Hypervisor)或 EL3(Secure Monitor)。操作系统常见的配置是:把所有 IRQ 路由到 EL1 由 OS 内核处理,把系统硬件级别的致命错误 SError 路由到 EL3 由固件统一兜底处理。
路由控制寄存器与优先级(SCR_EL3 & HCR_EL2)
控制异常路由的寄存器有两个:SCR_EL3 控制哪些异常被截获路由到 EL3,HCR_EL2 控制哪些异常被截获路由到 EL2。两者的优先级有明确高低:若配置冲突,SCR_EL3 会覆盖(Override)HCR_EL2。这些路由控制位在 CPU 复位(Reset)时处于未定义状态(UNKNOWN),因此软件初始化时必须显式配置。
路由对中断屏蔽(Masking)的影响
异常能否被屏蔽,取决于当前 CPU 所处的 EL 与目标路由 EL 的关系:
| 异常目标 EL 与当前 EL 的关系 | 中断屏蔽表现(Masking Rules) | 说明 / 逻辑 |
|---|---|---|
| 目标 EL > 当前 EL | 不可被当前 EL 屏蔽 | 高权限级别的中断不能被低权限级别关中断(如 EL1 关中断影响不到路由给 EL2 的中断)。 |
| 目标 EL < 当前 EL | 隐式屏蔽(Implicitly Masked) | 中断保持挂起状态,直到 CPU 降级回不高于目标 EL 的级别才响应(遵循”异常绝不降低权限”原则)。 |
| 目标 EL == 当前 EL | 由 PSTATE 的屏蔽位控制 |
由当前级别的 PSTATE(DAIF 标志位)决定是否响应。 |
硬件中断控制器(GIC)的作用
在真实的 Arm 系统中,通常搭配 Arm Generic Interrupt Controller (GIC) 硬件统一进行中断的收集、优先级排序和路由分发,这极大降低了虚拟化场景下的软件开销。
目标异常级别的执行状态的决定
发生异常时,CPU 会跳转到更高的异常级别(Target EL)去处理。那么 Target EL 到底运行在 AArch64 还是 AArch32?架构规定:低级别的执行状态由比它更高级别的系统寄存器的 RW 位(Register Width)决定。
| 异常跳转到的 Target EL | 执行状态由谁决定? | 详细解释 |
|---|---|---|
| EL1 (Guest OS / Host Kernel) | HCR_EL2.RW |
当异常跳转到 EL1 时,EL1 是 64 位还是 32 位,由 EL2(Hypervisor)的 HCR_EL2 寄存器中的 RW(Register Width)位决定:RW = 1 时 EL1 以 AArch64 运行,RW = 0 时以 AArch32 运行。 |
| EL2 (Hypervisor / 虚拟化层) | SCR_EL3.RW |
当异常跳转到 EL2 时,EL2 的执行状态由 EL3(Secure Monitor / 安全固件)的 SCR_EL3 寄存器中的 RW 位决定:RW = 1 时 EL2 以 AArch64 运行,RW = 0 时以 AArch32 运行。 |
| EL3 (Root / Secure Monitor) | Reset state of EL3(硬件复位状态) | EL3 是全系统最高权限级别,上面已经没有更高的 EL 来控制它了。因此 EL3 是 AArch64 还是 AArch32,由芯片复位(Reset)时的硬件物理引脚配置或固件复位状态直接决定。 |
1.4 AArch64 向量表
向量表的基本定义与存储方式
当 CPU 响应并进入异常时,执行流程会跳转到内存中固定的位置执行处理代码,这个内存区域称为异常向量(Exception Vector),保存这些向量的区域就是异常向量表(Vector Table)。每个异常级别(EL1、EL2、EL3)都有各自独立的向量表。
与传统架构的关键区别在于:AArch64 向量表直接存放可执行代码/指令(很多传统处理器如 Cortex-M 存放的是跳转地址),每个入口固定最多 32 条指令(128 字节 / 32 字),足够完成基础的寄存器压栈保存并根据异常原因跳转到更深的处理代码。EL0 没有专属向量表,因为异常永远不会路由给 EL0。
向量表基地址寄存器(VBAR_ELx)
每个 EL 的向量表基地址由对应的 VBAR_EL<x>(Vector Base Address Register,即 VBAR_EL1、VBAR_EL2、VBAR_EL3)定义。注意,VBAR_ELx 在复位后的初始值未定义(UNDEFINED),软件在使能/开启中断之前必须显式初始化。
入口确定依据(Entry Selection Factors)
发生异常时,CPU 硬件根据以下 4 个因素计算偏移量,决定跳转到向量表中的哪个入口:
- 异常类型:SError、FIQ、IRQ 还是同步异常;
- 异常发生源与目标 EL:异常从当前 EL 触发,还是从更低权限的 EL 陷入(Lower EL);
- 支持的 Execution State:Lower EL 运行在 AArch64 还是 AArch32;
- 使用的栈指针:当前使用的是
SP_EL0还是SP_ELx。
向量表偏移量结构
全表划分为 4 大场景,每种场景包含 4 种异常类型,共 16 个入口(每个入口步长固定为 0x80 / 128 字节):
- 场景 A:当前 EL 触发,用
SP_EL0; - 场景 B:当前 EL 触发,用
SP_ELx; - 场景 C:来自低 EL,低 EL 是 AArch64;
- 场景 D:来自低 EL,低 EL 是 AArch32。
| 向量地址偏移 (Offset) | 异常类型 (Exception Type) | 触发场景描述 |
|---|---|---|
VBAR_ELx + 0x000 |
Synchronous | 当前 EL 触发,且使用的是 SP_EL0 栈指针 |
VBAR_ELx + 0x080 |
IRQ / vIRQ | 当前 EL 触发,且使用的是 SP_EL0 栈指针 |
VBAR_ELx + 0x100 |
FIQ / vFIQ | 当前 EL 触发,且使用的是 SP_EL0 栈指针 |
VBAR_ELx + 0x180 |
SError / vSError | 当前 EL 触发,且使用的是 SP_EL0 栈指针 |
VBAR_ELx + 0x200 |
Synchronous | 当前 EL 触发,且使用的是专属 SP_ELx 栈指针 |
VBAR_ELx + 0x280 |
IRQ / vIRQ | 当前 EL 触发,且使用的是专属 SP_ELx 栈指针 |
VBAR_ELx + 0x300 |
FIQ / vFIQ | 当前 EL 触发,且使用的是专属 SP_ELx 栈指针 |
VBAR_ELx + 0x380 |
SError / vSError | 当前 EL 触发,且使用的是专属 SP_ELx 栈指针 |
VBAR_ELx + 0x400 |
Synchronous | 来自较低 EL,且 lower EL 至少有一个运行在 AArch64 |
VBAR_ELx + 0x480 |
IRQ / vIRQ | 来自较低 EL,且 lower EL 至少有一个运行在 AArch64 |
VBAR_ELx + 0x500 |
FIQ / vFIQ | 来自较低 EL,且 lower EL 至少有一个运行在 AArch64 |
VBAR_ELx + 0x580 |
SError / vSError | 来自较低 EL,且 lower EL 至少有一个运行在 AArch64 |
VBAR_ELx + 0x600 |
Synchronous | 来自较低 EL,且 lower EL 全部运行在 AArch32 |
VBAR_ELx + 0x680 |
IRQ / vIRQ | 来自较低 EL,且 lower EL 全部运行在 AArch32 |
VBAR_ELx + 0x700 |
FIQ / vFIQ | 来自较低 EL,且 lower EL 全部运行在 AArch32 |
VBAR_ELx + 0x780 |
SError / vSError | 来自较低 EL,且 lower EL 全部运行在 AArch32 |
1.5 栈指针的选择
栈指针的硬件选择机制
在 AArch64 中,运行环境有两种栈指针可选:SP_EL0 或当前异常级别专属的 SP_EL<x>(如 SP_EL1、SP_EL2、SP_EL3)。硬件根据当前权限级别和 PSTATE.SP 位自动选择:
- 运行在 EL0:强制固定使用
SP_EL0; - 运行在 EL1 / EL2 / EL3:
PSTATE.SP == 0时使用SP_EL0,PSTATE.SP == 1时使用当前级别的专属栈SP_ELx。
另外,当异常发生进入新的 ELx 时,CPU 硬件默认自动设置 PSTATE.SP = 1,从而自动选中专属的 SP_ELx。
异常入口与处理函数的”分栈处理”策略
真实的操作系统/架构设计中,推荐采用两阶段切栈(Split Stack)策略:
- 刚进入向量表顶层处理程序(Top-level Handler)时,CPU 默认使用
SP_ELx,用这个安全的专属栈压栈保存现场通用寄存器(Context Saving); - 现场保存完毕后,软件再显式切换回
SP_EL0,去调用深层的异常处理逻辑(如复杂的 C 语言函数)。
为什么设计”栈指针隔离”
栈指针隔离有两个重要的工程价值。
一是安全防范与栈溢出保护:如果系统因内核主栈溢出(Stack Overflow)触发 Fault,此时主栈已损坏,若异常入口继续使用主栈,系统会直接崩溃;而使用独立的 SP_ELx 能保证异常现场一定有干净、可用的栈空间来保存现场和处理错误。
二是隔离不同线程/任务的栈开销:把”异常响应入口”与”高深度栈消耗的业务逻辑(如 C 代码)”隔离开,可以给 SP_EL0 分配更大的栈空间跑主任务,而 SP_ELx 只保留很小一块空间专门做中断压栈,大幅提升系统稳定性与内存利用率。
向量表入口对系统状态的诊断暗示
向量表专门分出了”当前 EL 且使用 SP_ELx 触发异常”的入口(如 VBAR_EL1 + 0x280)。由于异常响应的入口和出口期间系统通常是屏蔽中断的,如果在 SP_ELx 场景下收到异步中断/错误,往往意味着发生了极其严重的系统级致命错误(如 Kernel Panic),软件可以借此入口精准捕获并记录 Panic 日志。
2. 从异常返回
2.1 从异常返回的基本步骤
异常处理程序(Handler)处理完成后,需要返回到触发异常前正在运行的代码,主要分两步:
- 出栈并还原此前保存的所有易失寄存器(Corruptible Registers);
- 执行异常返回指令
ERET,触发硬件自动还原处理器状态并跳转。
2.2 ERET 指令的硬件底层行为
CPU 执行 ERET 时,硬件自动完成三个动作:
- 把当前异常级别对应的
SPSR_ELx内容还原回PSTATE(包含目标异常级别与执行状态); - 把 PC 更新为
ELR_ELx中记录的异常返回地址; - 对 PSTATE 和 PC 的更新是不可分割的原子操作,确保 CPU 绝不会处于未定义状态。
另外还有一个合法性检查:SPSR_ELx 中指定的执行状态(AArch64 或 AArch32)必须与 SCR_EL3.RW 或 HCR_EL2.RW 的寄存器宽度配置一致,否则会触发非法的异常返回(Illegal Exception Return)。
2.3 不同异常类型的首选返回地址(ELR_ELx)
ELR_ELx 保存的首选返回地址(Preferred Exception Return Address)取决于异常类型:
| 异常类型 | 首选返回地址记录位置 | 典型说明 / 逻辑 |
|---|---|---|
同步服务调用(如 SVC) |
异常指令的下一条指令(Next Instruction) | 系统调用处理完后,需要继续往下执行下一条用户指令。 |
| 其他同步异常(如 Page Fault) | 生成异常的该条指令本身(Current Instruction) | 修复完错误(如补上缺页)后,需要重新执行触发 Fault 的那条指令。 |
| 异步异常(如 IRQ / FIQ 中断) | 中断发生时尚未完全执行的第一条指令 | 中断处理完后,回到当时被打断、未完整执行的指令处继续执行。 |
3. 异常处理示例
3.1 同步异常处理
同步异常由指令直接触发,返回地址指示了引发异常的指令。处理时用到三个核心寄存器:ESR_ELx 提供同步异常的原因和类型信息,FAR_ELx 保存与地址相关的同步异常(如 MMU fault)的触发虚拟地址,ELR_ELx 保存触发指令的地址、提供异常返回地址。
示例 1:跨执行状态系统调用(AArch32 EL0 → AArch64 EL1)
AArch32 应用程序运行在 EL0,需要向运行在 AArch64 的 OS/kernel(EL1)请求堆内存分配,于是执行 SVC 指令触发系统调用。硬件和软件的完整流程如下:
- 把当前
PSTATE作为快照保存到SPSR_EL1; - 把首选异常返回地址(下一条指令)写入
ELR_EL1; - 把异常综合信息(异常原因,即
SVC)写入ESR_EL1; - 读取 Hypervisor 控制寄存器
HCR_EL2.RW位,确定目标执行状态; - 更新
PSTATE:异常级别切换为 EL1,执行状态切换为 AArch64; - 跳转到
VBAR_EL1向量表,偏移量0x600(VBAR_EL1 + 0x600)——来自较低 EL、且所有较低 EL 均为 AArch32 的同步异常; - 把相关寄存器压入栈中,维护寄存器上下文;
- 从
ESR_EL1识别同步异常的具体类型,本例为SVC; - 执行特定的
SVC处理程序代码; - 处理完成后,控制流返回顶层 handler;
- 恢复所有压栈保存的寄存器并执行
ERET; - PSTATE 从
SPSR_EL1恢复(包括返回 EL0 与 AArch32 状态),PC 更新为ELR_EL1中的值。
示例 2:跨安全状态与权限级别调用(NS.EL0 → S.EL1)
运行在非安全状态(Non-secure,NS)EL0 的 AArch64 应用,需要调用运行在 EL1 的安全可信 OS(Secure,Trusted OS):
- EL0 无法直接发起
SMC调用到 EL3,所以先从 NS.EL0 发起SVC到 NS.EL1; - NS.EL1 接收到请求后,再通过
SMC调用到 EL3 处理安全状态切换; - 最终由 EL3 完成状态转换并过渡到 S.EL1。
3.2 异步异常处理
异步异常(中断)来自处理器(PE)外部以及当前正在执行的指令外部。Arm 架构未定义何时响应异步异常,因此异步异常相对于其他异常(包括同步和异步)的优先级由实现决定(IMPLEMENTATION DEFINED)。
示例 3:常规外部中断响应(AArch32 EL0 → AArch64 EL1)
处理器在 AArch32 EL0 执行应用代码时发生 IRQ 中断,此时 HCR_EL2 和 SCR_EL3 已配置为把 IRQ 路由到 AArch64 EL1。完整流程如下:
- 把当前
PSTATE作为快照保存到SPSR_EL1; - 把首选异常返回地址(尚未完成的第一条指令)写入
ELR_EL1; - 读取 Hypervisor 控制寄存器
HCR_EL2.RW位,确定目标执行状态; - 更新
PSTATE:异常级别切换为 EL1,执行状态切换为 AArch64; - 跳转到
VBAR_EL1向量表,偏移量0x680(VBAR_EL1 + 0x680)——来自较低 EL、且所有较低 EL 均为 AArch32 的 IRQ 异常; - 把相关寄存器压入栈中,维护寄存器上下文;
- 执行特定的 IRQ 处理程序代码;
- 处理完成后,控制流返回顶层 handler;
- 恢复所有寄存器并执行
ERET; - PSTATE 从
SPSR_EL1恢复(包括返回 EL0 与 AArch32 状态),PC 更新为ELR_EL1中的值。
关于 GIC 中断源识别:
IRQ 异常本身不区分具体中断原因(如定时器或 UART)。假设使用了 GIC,可以通过读取 GIC 的中断确认寄存器
IAR来识别中断:读取会返回中断 ID,并在 GIC 中把该中断标记为 Active。处理完成后,再向 GIC 的中断结束寄存器EOIR写入该 ID,把状态清除回 Inactive。与同步异常不同,IRQ/FIQ 发生时不需要更新
ESR_ELx,因为它们在硬件向量表中已按类型分流(有专门的IRQ、FIQ入口)。不过要注意,SError 异常依然会更新ESR_ELx,用于记录具体的系统错误原因。
3.3 异常屏蔽与不可屏蔽中断(NMI)
中断屏蔽规则
- 自动屏蔽:处理器响应异常进入 AArch64 执行状态时,PSTATE 的中断屏蔽位(
DAIF,即 Debug、Abort/SError、IRQ、FIQ)会被自动置位。写 1 相当于屏蔽/忽略该异常类型,让它保持 Pending 状态直到显式解除屏蔽; - 同步异常不可屏蔽:同步异常由指令执行直接引发,若被忽略会导致执行阻塞;
- 跨级别屏蔽限制:在较低 EL 设置 PSTATE 屏蔽位,无法阻止路由到更高 EL 的异常触发(如设置了
SCR_EL3.FIQ=1,在 EL1 或 EL2 设PSTATE.F=1也挡不住 FIQ);反过来,处理器处于更高 EL 时,路由到较低 EL 的异常会被隐式屏蔽(如 IRQ 路由到 EL2 或 EL1 时,EL3 运行期间 IRQ 始终被隐式屏蔽); - 嵌套异常(Nested Exceptions):若要支持高优先级异常打断低优先级异常,软件必须在压栈保存易失寄存器后显式重新开启中断。
不可屏蔽中断(NMI)
引入背景
在 Armv8.8-A 之前,所有异步中断都受制于 PSTATE.DAIF 掩码。一旦软件为了保护临界区(如内核锁操作、调度的关键逻辑)屏蔽中断(写 PSTATE.I = 1),所有中断都会被一刀切地挡在外面。可如果此时系统遇到致命紧急事件(如硬件过热、内存发生无法纠正的 ECC 错误、watchdog 超时、或需要高精度的看门狗采样 profiling),软件即使发出最紧急的中断,处理器也只能等 PSTATE.I 解除后才能响应。这对高可靠性(High Availability)、汽车安全(Safety)和实时系统是不可接受的。
为此,Arm 在 Armv8.8-A / Armv9.3-A 引入 NMI(不可屏蔽中断),赋予某些特殊中断超级优先级(Superpriority):即使 PSTATE 的常规中断掩码生效,也能强制穿透屏蔽、立即打断 CPU 进行紧急处理。
衍生问题
“强制打断”解决了紧急响应问题,却带来了一个危险的新问题:
NMI 打断 CPU 的瞬间,CPU 必须把现场通用寄存器压入栈保存。如果这几条压栈指令还没执行完又来了一个 NMI,现场数据就会被覆写破坏,导致系统直接崩溃(Panic)!
所以绝对不能允许 NMI 无限制地随时打断,硬件必须提供一种”让软件在保现场时拉下闸门”的机制。
ARM 的解决方案
为此 ARM 引入了 PSTATE.ALLINT 屏蔽位和 SCTLR_ELx 配置项。
PSTATE.ALLINT 掩码
当 CPU 第一次响应 NMI 跳进 Handler 时,硬件会自动把 NMI 也暂时屏蔽。软件通过控制 ALLINT 位可以在 3 种状态间切换:
- 完全解禁:常规 IRQ 和 NMI 都能进来;
- 只放行 NMI:常规 IRQ 被挡住,但紧急的 NMI 可以进来(NMI 的正常工作状态);
- 全部封锁:连 NMI 也挡住(专用于刚进中断 Handler、正在压栈保现场的那几条指令)。
SCTLR_ELx.NMI 与 SPINTMASK(硬件级自动防护)
为了防止程序员忘记手动设置 ALLINT 导致现场被打断破坏,SCTLR_ELx 寄存器 提供了自动化保护配置:
SCTLR_ELx.NMI = 0:关闭 NMI,所有中断退回传统模式(用PSTATE.I/PSTATE.F控制);SCTLR_ELx.NMI = 1:开启 NMI,并通过SPINTMASK控制防踩坑的方式:SPINTMASK = 0(手动模式):完全由软件显式修改PSTATE.ALLINT,决定何时封锁/解封 NMI;SPINTMASK = 1(硬件自动安全模式,推荐):只要 CPU 切换到异常专属栈指针SP_ELx(说明刚触发异常、正处于入口压栈阶段),硬件就自动隐式屏蔽 NMI,无需程序员手动加锁,彻底杜绝现场压栈时被二次打断的风险。
1 | SPINTMASK = 1 的情况: |