3. 异常入口与返回:SPSR_ELx与ELR_ELx寄存器详解

异常处理,说白了就是CPU被“打断”后怎么记住自己刚才在干嘛,处理完异常后又怎么“无缝”切回去。很多刚接触ARMv8的朋友,总觉得异常处理很神秘。其实核心就三个问题:现场保存在哪?怎么恢复?谁负责跳转?

我个人习惯把SPSR_ELx和ELR_ELx比作“异常现场的拍立得”——异常来的一瞬间,CPU自动拍下两张快照,一张记录状态,一张记录位置。今天我们就来拆开看看这两张“照片”到底拍了什么。

3.1 异常发生时,CPU自动保存了什么?

当异常触发,CPU硬件会自动完成以下操作(不需要你写任何代码):

  1. 保存返回地址:将当前PC值存入ELR_ELx(Exception Link Register)
  2. 保存处理器状态:将当前PSTATE存入SPSR_ELx(Saved Program Status Register)
  3. 切换异常等级:根据异常类型,提升到EL1/EL2/EL3
  4. 屏蔽嵌套中断:自动设置PSTATE的DAIF掩码位
  5. 跳转到异常向量表:根据异常类型和当前EL,查表跳转

核心要点:这5步是硬件原子完成的,中间不可打断。你想想看,如果保存一半又来一个异常,那现场就乱套了。ARMv8的设计保证了这5步的完整性。

我在项目中遇到过一个问题:某次调试发现异常返回后程序跑飞了。查了半天,发现是ELR_ELx里保存的地址被意外修改了。从那以后,我每次在异常处理函数入口都会先检查ELR_ELx的值是否合理。

3.2 SPSR_ELx:异常发生时的“状态快照”

SPSR_ELx保存的是异常发生那一刻的PSTATE。PSTATE不是寄存器,而是一组状态位的集合,包括:

位域 名称 说明
[31:28] N, Z, C, V 条件标志位(算术运算结果标志)
[27:25] D, A, I, F 调试掩码、SError掩码、IRQ掩码、FIQ掩码
[23:22] M[3:2] 执行状态(AArch64/AArch32)
[21:20] M[1:0] 异常等级(EL0/EL1/EL2/EL3)
[9] SS 软件单步标志(调试用)
[8] PAN 特权访问禁止(Privileged Access Never)
[7] UAO 用户访问覆盖(User Access Override)
[6] IL 非法执行状态
[5:0] M[5:0] 完整的执行状态编码

调试小技巧:在异常处理入口,我习惯先打印SPSR_ELx的M位域。如果M[3:2]是0b00,说明异常发生在AArch64状态;如果是0b01,说明是AArch32状态。这在混合架构调试时特别有用。

我曾经踩过一个坑:在EL3的异常处理中,想通过修改SPSR_EL3的M位来强制切换执行状态。结果系统直接挂掉。后来查手册才发现,SPSR_ELx的M位在异常返回时会被硬件用来恢复PSTATE,但你不能随意修改它——除非你清楚知道自己在做什么。

3.3 ELR_ELx:异常返回的“导航坐标”

ELR_ELx保存的是异常发生时,下一条本该执行的指令地址。注意,不是当前指令地址,而是当前指令的下一条。

为什么是下一条?因为异常通常发生在指令执行完成后(同步异常除外)。对于同步异常(比如未定义指令),ELR_ELx保存的是触发异常的指令地址。

异常类型 ELR_ELx保存的地址 典型场景
IRQ/FIQ 被中断指令的下一条 外设中断
SError 被中断指令的下一条 总线错误
同步异常(SVC) SVC指令的下一条 系统调用
同步异常(未定义指令) 未定义指令本身 指令解码失败
同步异常(页 fault) 触发fault的指令 缺页异常

注意:对于同步异常,ELR_ELx指向的是触发异常的指令。如果你在异常处理中想重新执行这条指令,直接ERET即可。但如果是未定义指令,重新执行还是会触发异常——死循环!我见过有人在这个问题上卡了一整天。

3.4 ERET指令:异常返回的“一键恢复”

ERET是ARMv8中专门用于异常返回的指令。它的行为可以概括为:

  1. 从ELR_ELx恢复PC
  2. 从SPSR_ELx恢复PSTATE
  3. 异常等级降回原来的级别

说白了,ERET就是异常进入的逆操作。但有几个细节需要注意:

  • ERET必须在目标异常等级执行:比如你在EL3处理异常,必须用ERET从EL3返回,不能用ERET从EL1返回EL0
  • ERET不会自动清除中断掩码:如果你在异常处理中手动清除了DAIF掩码,返回后中断仍然使能
  • ERET会检查SPSR_ELx的合法性:如果SPSR_ELx的M位指向一个不合法的异常等级,ERET会触发非法异常

实战经验:我建议在ERET之前,显式检查SPSR_ELx和ELR_ELx的值。可以用一个简单的断言:

// 伪代码:ERET前的安全检查
if (SPSR_ELx.M[3:2] != 0b00) {
    // 目标状态不是AArch64,需要特殊处理
    handle_aarch32_return();
}
if (ELR_ELx & 0x3) {
    // 地址未对齐,触发对齐错误
    panic("ELR misaligned!");
}
// 执行ERET
ERET;

3.5 异常进入与返回的完整流程

下面这张图展示了异常从触发到返回的完整数据流:

异常进入与返回数据流 正常执行(EL0/EL1) 异常触发 硬件自动保存 保存到: ELR_ELx ← PC SPSR_ELx ← PSTATE DAIF ← 屏蔽位 异常向量表查表 异常处理函数 ERET指令 ERET恢复: PC ← ELR_ELx PSTATE ← SPSR_ELx 异常等级降级 恢复执行 正常执行(恢复后)

3.6 实战中的常见陷阱

讲几个我在项目中实际踩过的坑:

  • 陷阱一:ELR_ELx被错误修改。有一次我在异常处理中调用了某个函数,这个函数内部修改了ELR_ELx。ERET后程序直接跳到未知地址。从那以后,我规定团队在异常处理中禁止直接读写ELR_ELx,除非有明确文档说明。
  • 陷阱二:SPSR_ELx的M位被破坏。在混合架构(AArch64 + AArch32)系统中,如果异常处理代码不小心修改了SPSR_ELx的M位,ERET后CPU可能进入错误的执行状态。我建议在ERET前用MRS读取SPSR_ELx做一次完整性检查。
  • 陷阱三:嵌套异常中的寄存器保存。如果异常处理中又触发了更高优先级的异常,硬件会再次保存ELR_ELx和SPSR_ELx,覆盖之前的值。所以高等级异常的处理函数必须自己保存这些寄存器。我习惯在EL3异常入口处立即将ELR_EL3和SPSR_EL3压栈。

调试建议:在异常处理入口和出口分别设置断点,观察ELR_ELx和SPSR_ELx的值。如果发现异常返回后程序行为异常,优先检查这两个寄存器是否被意外修改。我曾经用这个方法半小时定位了一个困扰团队两天的bug。

3.7 小结

异常入口与返回的核心,就是ELR_ELx和SPSR_ELx这两个寄存器。异常来时,硬件自动保存;ERET时,硬件自动恢复。你只需要保证在异常处理期间不破坏这两个寄存器的值,以及ERET前它们的内容是合法的。

嗯,记住一句话:ELR_ELx告诉CPU“从哪回来”,SPSR_ELx告诉CPU“以什么状态回来”。这两个搞清楚了,异常处理就掌握了八成。