异常模型与路由:EL0-EL3、同步异步异常、路由机制
异常模型,说白了就是ARMv8/v9架构的“中断与特权级切换”规则。你想想看,一个系统里同时跑着Linux、TrustZone、Hypervisor,谁该响应中断?谁该处理缺页?谁该接管安全监控?答案全在异常等级和路由机制里。
我个人习惯把异常模型看作“电梯系统”——EL0是地下室,EL3是顶层。异常就是按电梯按钮,路由机制决定哪一层停。嗯,咱们一层层拆开看。
异常等级(Exception Levels)
ARMv9定义了4个异常等级,从EL0到EL3。数字越大,特权越高。
| 等级 | 典型用途 | 运行环境 |
|---|---|---|
| EL0 | 用户态应用 | 非特权,无系统寄存器访问 |
| EL1 | 操作系统内核 | 特权,管理MMU、中断、调度 |
| EL2 | Hypervisor(虚拟机监控器) | 虚拟化扩展,管理VM切换 |
| EL3 | Secure Monitor(安全监控器) | 最高特权,控制安全状态切换 |
我在项目中遇到过一个问题:某款SoC的EL2代码里直接访问了EL3的寄存器,结果系统直接挂死。后来查手册才发现,EL2根本看不到EL3的寄存器空间——这是硬件强制隔离的。
关键点:异常等级是硬件强制隔离的。EL3的代码可以访问所有低等级的内存和寄存器,但EL2看不到EL3,EL1看不到EL2,EL0什么都看不到。这种“单向可见性”是安全的基础。
同步异常 vs 异步异常
异常分两类:同步异常和异步异常。区别很简单——同步异常是“自找的”,异步异常是“被找的”。
同步异常(Synchronous Exceptions)
同步异常由当前指令直接触发。比如你执行了一条未定义指令,或者访问了一个不存在的内存地址。这类异常的特点是:异常返回地址指向触发异常的指令(或者紧挨着它的下一条)。
- 未定义指令异常:执行了CPU不认识的指令
- 缺页异常:MMU找不到页表映射
- 对齐异常:访问未对齐的地址
- SVC/HVC/SMC指令:软件主动触发异常(系统调用)
我曾经调试过一个bug:某驱动在EL1执行了SMC指令,结果异常路由到了EL2而不是EL3。查了半天才发现,SCR_EL3的NS位没配好——EL2拦截了本该去EL3的SMC。嗯,这种坑踩过一次就记住了。
异步异常(Asynchronous Exceptions)
异步异常来自外部,跟当前执行的指令无关。典型的就是中断和SError。
- IRQ:普通中断,比如网卡、UART
- FIQ:快速中断,优先级更高
- SError:系统错误,比如ECC校验失败、总线错误
异步异常的返回地址不固定——它可能发生在任意指令之间。所以处理异步异常时,你不能假设返回地址指向哪条指令。
个人经验:调试SError是最头疼的。因为它不告诉你哪条指令出错了,只告诉你“总线出问题了”。我一般会在SError处理函数里打印所有系统错误状态寄存器(比如RAS寄存器),然后反推是哪个外设挂了。
异常路由机制
异常路由,就是决定“这个异常该由哪个EL处理”。ARMv9的路由规则由一组系统寄存器控制,核心是SCR_EL3、HCR_EL2和VBAR_ELx。
路由决策流程
当一个异常发生时,硬件按以下顺序判断路由目标:
- 检查异常类型:同步还是异步?来自哪个EL?
- 检查路由控制寄存器:SCR_EL3决定是否路由到EL3,HCR_EL2决定是否路由到EL2
- 查找异常向量表:根据目标EL的VBAR_ELx找到对应的异常处理入口
注意:异常路由是硬件行为,软件无法干预。你只能通过配置SCR_EL3和HCR_EL2来“建议”硬件怎么路由,但最终决定权在硬件。
异常向量表(Vector Table)
每个EL都有自己的异常向量表,基地址由VBAR_ELx寄存器指定。向量表有16个入口,每个入口对应一种异常场景:
| 偏移 | 异常类型 | 说明 |
|---|---|---|
| 0x000 | 同步异常(当前EL,SP0) | 使用SP_EL0时的同步异常 |
| 0x080 | IRQ(当前EL,SP0) | 使用SP_EL0时的IRQ |
| 0x100 | FIQ(当前EL,SP0) | 使用SP_EL0时的FIQ |
| 0x180 | SError(当前EL,SP0) | 使用SP_EL0时的SError |
| 0x200 | 同步异常(当前EL,SPx) | 使用SP_ELx时的同步异常 |
| 0x280 | IRQ(当前EL,SPx) | 使用SP_ELx时的IRQ |
| 0x300 | FIQ(当前EL,SPx) | 使用SP_ELx时的FIQ |
| 0x380 | SError(当前EL,SPx) | 使用SP_ELx时的SError |
| 0x400 | 同步异常(低EL,AArch64) | 从低EL来的同步异常 |
| 0x480 | IRQ(低EL,AArch64) | 从低EL来的IRQ |
| 0x500 | FIQ(低EL,AArch64) | 从低EL来的FIQ |
| 0x580 | SError(低EL,AArch64) | 从低EL来的SError |
| 0x600 | 同步异常(低EL,AArch32) | 从32位低EL来的同步异常 |
| 0x680 | IRQ(低EL,AArch32) | 从32位低EL来的IRQ |
| 0x700 | FIQ(低EL,AArch32) | 从32位低EL来的FIQ |
| 0x780 | SError(低EL,AArch32) | 从32位低EL来的SError |
每个入口占0x80字节(128字节)。你想想看,16个入口总共2KB。我建议你在写启动代码时,把VBAR_ELx对齐到2KB边界——这是硬件要求,不对齐的话异常直接挂死。
路由控制寄存器
两个核心寄存器控制路由:
- SCR_EL3:Secure Configuration Register。控制哪些异常路由到EL3。比如SCR_EL3的IRQ位为1时,所有IRQ都路由到EL3。
- HCR_EL2:Hypervisor Configuration Register。控制哪些异常路由到EL2。比如HCR_EL2的FMO位为1时,FIQ路由到EL2。
我曾经在调试一个虚拟化场景时,发现虚拟机里的中断总是被EL2拦截了。后来发现是HCR_EL2的IMO位被误设为1——本该路由到EL1的IRQ全跑到了EL2。嗯,这种配置错误在开发初期特别容易踩。
路由优先级:EL3的配置优先级最高。如果SCR_EL3把某个异常路由到EL3,那么HCR_EL2的配置就无效了。EL2只能路由那些EL3“放行”的异常。
异常路由流程图
下面这张图展示了异常从发生到处理的完整路由路径:
实际开发中的避坑指南
异常模型看着简单,实际开发中坑不少。我列几个常见的:
- VBAR对齐问题:VBAR_ELx必须2KB对齐。不对齐的话,异常直接触发“三重故障”——系统死锁。我见过有人把VBAR放在1KB边界上,调试了一周才发现。
- SP选择问题:异常处理时,硬件自动切换SP。如果你在EL1用SP_EL1,但异常向量表里配了SP_EL0的入口,那栈指针就乱了。嗯,这种bug特别隐蔽。
- 路由配置冲突:SCR_EL3和HCR_EL2的配置不能冲突。比如SCR_EL3把IRQ路由到EL3,但HCR_EL2也试图拦截IRQ——结果是EL3优先,HCR_EL2的配置被忽略。
- SError的“幽灵”特性:SError是异步的,可能延迟到异常处理完成后才触发。我建议在每次异常返回前,主动检查SError状态寄存器,避免“幽灵错误”累积。
我的调试习惯:在异常处理函数入口处,先保存所有通用寄存器和系统寄存器(ESR_ELx、FAR_ELx、SPSR_ELx等),然后打印到串口。这样即使系统崩溃,也能从日志里反推异常原因。
总结
异常模型是ARMv9架构的“交通规则”。EL0到EL3定义了4个特权等级,同步异常和异步异常决定了异常来源,路由机制决定了异常去向。你只要记住三点:
- 异常等级:EL0最低,EL3最高,硬件强制隔离
- 异常类型:同步异常是“自找的”,异步异常是“被找的”
- 路由规则:SCR_EL3优先,HCR_EL2次之,VBAR_ELx决定入口