3、中断向量表与异常向量:向量表结构、异常等级(EL0-EL3)、同步异常与异步异常
好,咱们接着聊中断系统里最基础、也最容易被忽视的一块——向量表。
说实话,我早年刚接触ARMv8架构时,被这个异常等级和向量表绕得有点晕。后来在MTK一个项目里,因为向量表放错了位置,导致系统一进中断就跑飞,查了两天才找到原因。从那以后,我对这块就格外上心。
3.1 向量表长什么样?
先看最直观的东西。中断向量表,说白了就是一个地址表。每个表项对应一个异常或中断,CPU拿到异常后,直接跳转到对应表项指向的地址去执行。
在ARMv8-A架构里,向量表有固定的格式。每个异常等级都有自己的向量表,基地址由VBAR_ELn寄存器控制。
关键点:向量表不是随便放的。它必须对齐到2KB边界。我见过有人忘了对齐,结果系统启动就挂——嗯,这个坑我踩过。
每个异常等级下,向量表有16个入口。为什么是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来的同步异常,目标为AArch64 |
| 0x480 | IRQ(低EL,AArch64) | 从低EL来的IRQ,目标为AArch64 |
| 0x500 | FIQ(低EL,AArch64) | 从低EL来的FIQ,目标为AArch64 |
| 0x580 | SError(低EL,AArch64) | 从低EL来的SError,目标为AArch64 |
| 0x600 | 同步异常(低EL,AArch32) | 从低EL来的同步异常,目标为AArch32 |
| 0x680 | IRQ(低EL,AArch32) | 从低EL来的IRQ,目标为AArch32 |
| 0x700 | FIQ(低EL,AArch32) | 从低EL来的FIQ,目标为AArch32 |
| 0x780 | SError(低EL,AArch32) | 从低EL来的SError,目标为AArch32 |
每个入口占0x80字节,也就是128字节。够放32条指令了。我个人习惯是放一个跳转指令,直接跳到C函数去处理。
3.2 异常等级EL0到EL3
ARMv8引入了异常等级的概念,从EL0到EL3,数字越大权限越高。
- EL0:用户态。跑应用程序的地方。不能直接操作硬件。
- EL1:操作系统内核。Linux内核就跑在这。
- EL2:虚拟化层。跑Hypervisor用的。
- EL3:安全监控层。最底层,负责安全世界和普通世界的切换。
在联发科的芯片上,EL3通常跑的是ATF(ARM Trusted Firmware)。我参与过一个项目,需要在EL3处理一些安全相关的FIQ,那会儿调试起来真是痛苦——因为EL3的打印输出都得自己实现。
小技巧:在MTK平台上,如果你要确认当前CPU在哪个异常等级,可以读CurrentEL寄存器。返回值的bit[3:2]就是EL编号。我经常在启动代码里加这么一段来验证。
3.3 同步异常 vs 异步异常
这个区分很重要。你想想看,CPU执行指令时,异常来源无非两种:
- 同步异常:跟当前指令直接相关。比如访问了非法地址、执行了未定义指令、触发了系统调用。这类异常是“可预测”的——你执行这条指令,它就会发生。
- 异步异常:跟当前指令无关。比如外设发来的中断、硬件产生的SError。这类异常是“随机”的——你不知道它什么时候来。
为什么要在意这个?因为处理方式不同。同步异常发生时,CPU能精确告诉你出错的指令地址(ELR_ELn寄存器)。异步异常就不行——它可能发生在任意两条指令之间。
注意:在联发科平台上,SError(异步异常的一种)经常被忽略。我曾经遇到一个case,DDR的ECC错误触发了SError,但中断处理函数里没做任何处理,导致系统间歇性死机。排查了整整一周才发现是SError没被正确响应。
3.4 实际代码示例
咱们看一段在MTK平台上初始化向量表的代码。这是EL1的向量表设置:
// 汇编代码示例:设置EL1的向量表
.globl el1_vector_table
.align 11 // 2KB对齐,必须的!
el1_vector_table:
// 当前EL,SP0
b sync_el1_sp0 // 0x000
.align 7
b irq_el1_sp0 // 0x080
.align 7
b fiq_el1_sp0 // 0x100
.align 7
b serror_el1_sp0 // 0x180
.align 7
// 当前EL,SPx
b sync_el1_spx // 0x200
.align 7
b irq_el1_spx // 0x280
.align 7
b fiq_el1_spx // 0x300
.align 7
b serror_el1_spx // 0x380
.align 7
// 低EL,AArch64
b sync_lower_64 // 0x400
.align 7
b irq_lower_64 // 0x480
.align 7
b fiq_lower_64 // 0x500
.align 7
b serror_lower_64 // 0x580
.align 7
// 低EL,AArch32
b sync_lower_32 // 0x600
.align 7
b irq_lower_32 // 0x680
.align 7
b fiq_lower_32 // 0x700
.align 7
b serror_lower_32 // 0x780
然后在C代码里设置VBAR:
// C代码:设置EL1的向量表基地址
void init_vector_table(void)
{
uint64_t vbar;
// 获取向量表地址
vbar = (uint64_t)&el1_vector_table;
// 写入VBAR_EL1寄存器
__asm__ volatile("msr vbar_el1, %0" : : "r" (vbar));
// 验证是否写成功
__asm__ volatile("mrs %0, vbar_el1" : "=r" (vbar));
// 我习惯在这里加个断言,确保对齐
if (vbar & 0x7FF) {
// 对齐错误!赶紧处理
panic("VBAR not aligned to 2KB!");
}
}
避坑指南:我曾经在MT8167平台上犯过一个错——在设置VBAR之前就开了中断。结果中断一来,CPU跳到一个随机地址,系统直接崩溃。正确的顺序是:先设好向量表,再开中断。这个顺序千万别搞反。
3.5 异常等级切换的细节
异常发生时,CPU会自动做几件事:
- 保存返回地址到
ELR_ELn - 保存当前PSTATE到
SPSR_ELn - 根据异常类型,切换到对应的异常等级
- 从向量表取地址,跳转执行
这里有个细节:从低EL往高EL切换时,栈指针会自动切换到目标EL的SP。比如从EL0触发系统调用进入EL1,SP会从SP_EL0变成SP_EL1。这意味着你的EL1栈必须提前准备好。
在联发科的BSP里,我通常会在启动时给每个异常等级分配独立的栈空间。EL3的栈最小,因为ATF做的事情不多。EL1的栈最大,因为Linux内核要用。
总结一下:向量表是中断系统的地基。地基没打好,上面盖多高的楼都白搭。EL0到EL3的划分,让系统有了层次化的安全模型。同步和异步的区分,决定了你怎么去排查问题。这些概念看着简单,但真到项目里,每一个细节都可能让你加班到深夜——嗯,我深有体会。