4. 同步异常处理:数据中止、指令中止、未定义指令、SVC/HVC/SMC 调用
同步异常,说白了就是 CPU 执行指令时自己“撞上”的问题。跟外部中断不一样,这类异常是程序主动或被动触发的,异常返回地址能精确告诉你哪条指令出了问题。我在调试中遇到的大部分“死机”现象,追根溯源都是同步异常没处理好。
ARMv8 的同步异常种类不少,但最常用的就这五类:数据中止、指令中止、未定义指令、SVC、HVC、SMC。咱们一个一个拆开讲。
4.1 数据中止(Data Abort)
数据中止发生在 CPU 访问内存的时候。读数据、写数据,只要地址有问题,MMU 就会拉异常。常见的场景:访问了未映射的地址、权限不够、或者地址对齐有问题。
核心要点:数据中止的 ESR(Exception Syndrome Register)会告诉你具体原因。ESR_ELx 的位 24:0 是异常综合征信息,其中 DFSC(Data Fault Status Code)字段指明了故障类型。
我个人习惯,一进数据中止处理函数,第一件事就是读 ESR。你看这个代码片段:
// 数据中止处理函数入口
void data_abort_handler(void) {
uint64_t esr, far, elr;
// 读取异常综合征寄存器
asm volatile("mrs %0, esr_el1" : "=r"(esr));
// 读取错误地址寄存器
asm volatile("mrs %0, far_el1" : "=r"(far));
// 读取返回地址
asm volatile("mrs %0, elr_el1" : "=r"(elr));
uint32_t dfsc = esr & 0x3F; // DFSC 在低 6 位
uint32_t write_not_read = (esr >> 6) & 1; // 第 6 位:0=读,1=写
// 根据 DFSC 判断具体原因
switch(dfsc) {
case 0b000001: // 地址尺寸错误
case 0b000100: // 未映射的页
case 0b001101: // 权限错误(一级页表)
case 0b001111: // 权限错误(三级页表)
case 0b010000: // 同步外部异常
// ... 其他情况
}
}
避坑指南:我曾经遇到一个坑——数据中止后,FAR_EL1 不一定有效。如果 ESR 里的 FnV 位(位 11)为 1,说明 FAR 的值不可靠。这时候你只能靠 ELR 回溯指令,反汇编看是哪条 load/store 出的问题。
4.2 指令中止(Prefetch Abort)
指令中止跟数据中止很像,区别在于它发生在 CPU 取指令的时候。说白了,CPU 去内存里拿指令,结果地址不对或者权限不够,那就指令中止了。
为什么会单独区分指令中止?你想想看,数据访问失败可能只是某个变量读错了,但指令取不到,CPU 根本不知道下一步该干啥。所以指令中止的优先级比数据中止高,处理起来也更紧急。
| 对比项 | 数据中止 | 指令中止 |
|---|---|---|
| 触发时机 | load/store 指令执行时 | 取指令阶段 |
| ESR 编码 | DFSC(位 24:0) | IFSC(位 24:0) |
| FAR 有效性 | 通常有效(看 FnV 位) | 无效(FAR 不更新) |
| 典型场景 | 空指针解引用、数组越界 | 跳转到非法地址、代码段被覆盖 |
指令中止的 ESR 里,IFSC 字段跟 DFSC 的编码规则一样。但有个关键区别——FAR 在指令中止时不会更新。我记得有次调试一个跳转表错误,一直读 FAR 发现是 0,后来才反应过来指令中止得看 ELR。
4.3 未定义指令
未定义指令,就是 CPU 不认识你写的这条指令。ARMv8 的指令集是固定长度的(AArch64 下都是 32 位),但并不是所有 32 位组合都是有效指令。遇到无效编码,CPU 就抛未定义指令异常。
这个异常在实际项目中挺常见的。我遇到过两种情况:
- 协处理器指令没实现:比如你写了 MRS/MSR 访问某个系统寄存器,但当前异常等级没权限,或者这个寄存器根本不存在。
- SIMD/FP 指令没使能:CPACR_EL1 没配好,FPEN 位为 0,一执行浮点运算就崩。
注意:未定义指令异常的处理函数里,ESR 的位 24:0 是 ISS(Instruction Specific Syndrome),不是 DFSC。你得根据 ESR 的位 25(IL 位)判断指令长度,再结合 ISS 反推是哪条指令出了问题。
处理未定义指令时,我建议先判断是不是合法的“未定义”。什么意思?有些操作系统会用未定义指令异常来实现软件断点或功能扩展。比如你在 EL2 的 hypervisor 里,可以捕获 EL1 的未定义指令,然后模拟执行——这就是虚拟化里常见的“trap-and-emulate”手法。
4.4 SVC、HVC、SMC 调用
这三类异常是主动触发的。程序执行 SVC、HVC、SMC 指令,CPU 就会同步地陷入更高的异常等级。说白了,这是用户程序“请愿”的方式——我想让内核帮我干点特权事。
SVC(Supervisor Call)
SVC 是 EL0 调用 EL1 的接口。你在用户态程序里写:
// 触发 SVC #0x12,参数通过 x0-x5 传递
asm volatile("svc #0x12" : : : "x0", "x1", "x2", "x3", "x4", "x5");
CPU 就会陷入 EL1,SVC 处理函数通过 ESR 的 imm16 字段(位 20:5)拿到立即数 0x12,然后根据这个号分发到不同的系统调用服务。
我见过不少新手在 SVC 处理函数里忘了保存 x0-x5 的值。你想想看,用户程序把参数放在寄存器里,结果你一进异常处理就把寄存器覆盖了,那参数不就丢了吗?所以 SVC 入口第一件事就是保存现场。
HVC(Hypervisor Call)
HVC 是 EL1 调用 EL2 的接口。在虚拟化场景下,Guest OS(EL1)想操作硬件或切换虚拟 CPU,就得通过 HVC 陷入 Hypervisor(EL2)。
HVC 的用法跟 SVC 几乎一样,只是异常等级不同。ESR 里同样有 imm16 字段,Hypervisor 根据这个号判断 Guest 想要什么服务。
关键区别:SVC 的返回地址是 ELR_EL1,HVC 的返回地址是 ELR_EL2。处理完 HVC 后,你要用 ERET 指令返回 EL1,而不是直接跳转。
SMC(Secure Monitor Call)
SMC 是切换到安全世界的入口。ARM 的 TrustZone 架构里,普通世界(Non-secure)通过 SMC 指令陷入 Monitor 模式(EL3),然后切换到安全世界执行敏感操作。
SMC 的调用约定跟 SVC/HVC 不太一样。它使用 SMCCC(SMC Calling Convention),参数通过 x0-x17 传递,返回值也在 x0 里。x0 的低 8 位是 Function ID,告诉 Monitor 你要干啥。
| 指令 | 触发等级 | 目标等级 | 典型用途 |
|---|---|---|---|
| SVC | EL0 | EL1 | 系统调用 |
| HVC | EL1 | EL2 | Hypervisor 调用 |
| SMC | EL0/EL1/EL2 | EL3 | 安全世界调用 |
4.5 同步异常处理的核心流程
不管哪种同步异常,处理流程都差不多。我画了个流程图,你看一眼就明白了:
这个流程里有个细节容易被忽略——异常返回地址的修正。数据中止和指令中止的返回地址指向出问题的那条指令,如果你不修正就直接 ERET,CPU 会反复执行同一条指令,形成死循环。正确的做法是:如果能修复问题(比如缺页异常补上页表了),就原地址返回重试;如果修复不了,就跳过这条指令或者杀掉当前任务。
SVC/HVC/SMC 的情况正好相反。返回地址指向 SVC 指令的下一条指令,因为 SVC 本身已经执行完了。你不需要修正 ELR,直接 ERET 就行。
调试小技巧:我在项目里经常用 SVC 来测试异常处理链路。写个简单的 SVC #0,然后在 EL1 的处理函数里打印 ESR 和 ELR,就能验证整个异常入口、分发、返回的流程是否正常。这比直接等程序崩溃再调试要高效得多。
4.6 实战中的常见陷阱
最后总结几个我踩过的坑,你遇到了可以少走弯路:
- ESR 没保存就被覆盖:一进异常处理函数就调用了其他函数,结果 ESR 被新函数里的 MRS 覆盖了。我建议在异常入口的汇编代码里就把 ESR 保存到栈上。
- SP 切换导致栈回溯失败:ARMv8 有 SP_EL0 和 SP_ELx 两个栈指针。异常处理时如果切换了 SP,调试器可能找不到调用栈。我习惯在异常入口统一使用 SP_ELx。
- 忽略 SError:同步异常处理得好好的,结果系统被异步的 SError 搞挂了。SError 虽然不属于同步异常,但它的处理流程跟数据中止很像,别忘了在异常向量表里也给它留个位置。
嗯,同步异常这部分内容比较多,但核心就一句话:读 ESR,看类型,修现场,再返回。把这四个步骤练熟了,大部分异常问题都能搞定。