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会自动做几件事:

  1. 保存返回地址到ELR_ELn
  2. 保存当前PSTATE到SPSR_ELn
  3. 根据异常类型,切换到对应的异常等级
  4. 从向量表取地址,跳转执行

这里有个细节:从低EL往高EL切换时,栈指针会自动切换到目标EL的SP。比如从EL0触发系统调用进入EL1,SP会从SP_EL0变成SP_EL1。这意味着你的EL1栈必须提前准备好。

在联发科的BSP里,我通常会在启动时给每个异常等级分配独立的栈空间。EL3的栈最小,因为ATF做的事情不多。EL1的栈最大,因为Linux内核要用。

总结一下:向量表是中断系统的地基。地基没打好,上面盖多高的楼都白搭。EL0到EL3的划分,让系统有了层次化的安全模型。同步和异步的区分,决定了你怎么去排查问题。这些概念看着简单,但真到项目里,每一个细节都可能让你加班到深夜——嗯,我深有体会。