中断向量表:游戏机CPU的“紧急通讯录”

各位同学,咱们接着聊中断系统。上一章我讲了中断的触发机制,说白了就是外设怎么“敲门”告诉CPU有事。那CPU听到敲门声后,怎么知道该去哪个房间处理?这就得靠我们今天的主角——中断向量表

我个人习惯把向量表比作一本“紧急通讯录”。CPU收到中断信号,就像你手机响了,你得翻通讯录看看是谁打来的,然后决定接不接、怎么接。向量表就是干这个的——它告诉CPU:每个中断源对应的处理函数在内存的哪个位置。

向量表的结构:一张地址映射表

先看最基础的结构。中断向量表本质上是一张地址表,每个表项存放着一个函数指针。这张表通常放在内存的起始位置,比如0x00000000。

我当年做第一台掌机原型时,就踩过这个坑。当时以为向量表随便放哪都行,结果中断一触发,CPU直接跑飞。后来查手册才发现——ARM Cortex-M系列强制要求向量表从0x00000000开始,这是硬件写死的。

来看一个典型的向量表布局(以STM32F4为例):

// 向量表定义(简化版)
typedef void (*pFunction)(void);

// 向量表结构体
typedef struct {
    pFunction    StackTop;      // 0x00: 栈顶指针
    pFunction    Reset_Handler; // 0x04: 复位中断
    pFunction    NMI_Handler;   // 0x08: 不可屏蔽中断
    pFunction    HardFault_Handler; // 0x0C: 硬错误
    pFunction    MemManage_Handler; // 0x10: 内存管理错误
    pFunction    BusFault_Handler;  // 0x14: 总线错误
    pFunction    UsageFault_Handler;// 0x18: 用法错误
    // ... 后面跟着外设中断
    pFunction    TIM2_IRQHandler;   // 定时器2中断
    pFunction    TIM3_IRQHandler;   // 定时器3中断
    // ...
} VectorTable;

注意看,第一个表项不是中断处理函数,而是栈顶指针。这是硬件设计上的一个巧妙之处——CPU复位后第一件事就是初始化栈指针,然后才去执行复位中断。你想想看,如果没有栈,中断处理函数连局部变量都存不了。

关键点:每个表项占4字节(32位系统),表项编号就是中断号。比如TIM2中断号是28,那它的处理函数地址就放在 (0x00000000 + 28*4) 这个位置。

默认向量表与重定向:为什么需要搬个家?

默认情况下,向量表固定在0x00000000。但实际项目中,我们经常需要重定向向量表。为什么?

举个例子。我做的那台游戏机,Bootloader放在Flash起始地址,应用程序从0x08010000开始。如果应用程序还用默认的向量表,那中断一来,CPU直接跳到Bootloader的向量表里去了——这不就乱套了吗?

所以,我们需要在应用程序启动时,把向量表“搬”到应用程序自己的地址空间。这个过程叫向量表重定向

具体怎么做?看代码:

// 向量表重定向函数
void VectorTable_Redirect(uint32_t baseAddress)
{
    // 检查地址是否对齐(4字节对齐)
    if ((baseAddress & 0x3) != 0) {
        // 我曾经在这里吃过亏,地址没对齐导致中断异常
        Error_Handler();
    }
    
    // 对于Cortex-M3/M4,通过SCB寄存器设置
    // SCB->VTOR 是向量表偏移寄存器
    SCB->VTOR = baseAddress;
    
    // 注意:设置后需要同步内存屏障
    __DSB();
    __ISB();
}

警告:重定向向量表时,一定要确保新地址处的向量表已经正确初始化。我曾经见过一个同事,先重定向再拷贝向量表,结果中断一触发,CPU跳到一个未初始化的地址——直接HardFault。

这里有个细节:重定向后,原来的向量表还在吗? 嗯,还在。只是CPU不再用它了。你可以把重定向理解成“换了一本通讯录”,旧的那本还留着,但不再翻它了。

在游戏机启动代码中的配置:从零开始搭积木

好,理论讲完了,咱们来点实战。游戏机启动代码(也叫Bootloader或启动文件)里,向量表的配置是第一步。

我习惯把启动代码分成三个阶段:

  1. 第一阶段:硬件初始化(关中断、设栈、清BSS)
  2. 第二阶段:向量表配置(设置VTOR、拷贝向量表)
  3. 第三阶段:跳转到main

来看一个完整的启动代码片段:

// 启动代码核心部分
void Reset_Handler(void)
{
    // 1. 关闭所有中断(防止配置过程中被中断打断)
    __disable_irq();
    
    // 2. 初始化栈指针(虽然硬件已经做了,但再设一次更保险)
    __set_MSP((uint32_t)&_estack);
    
    // 3. 拷贝向量表到RAM(如果需要动态修改中断)
    // 为什么要拷到RAM?因为Flash不能随便写,RAM可以运行时修改
    // 我做的游戏机支持玩家自定义按键映射,就需要动态改中断处理函数
    #ifdef VECTOR_TABLE_IN_RAM
    CopyVectorTableToRAM();
    #endif
    
    // 4. 重定向向量表到应用程序地址
    // 假设应用程序从0x08010000开始
    VectorTable_Redirect(0x08010000);
    
    // 5. 初始化C运行时环境(BSS清零、数据段拷贝)
    __main();
    
    // 6. 开中断,跳转到main
    __enable_irq();
    main();
}

小技巧:我建议在向量表重定向后,加一个自检函数。读一下VTOR寄存器,确认设置生效。我曾经在一个项目里,因为编译器优化把VTOR赋值语句优化掉了,导致中断一直不工作。加了自检后,这种问题一眼就能看出来。

这里有个容易忽略的点:中断优先级分组。向量表重定向后,别忘了配置NVIC的中断优先级分组。我见过有人重定向完向量表,高高兴兴去写中断处理函数,结果中断优先级没设,所有中断都按默认优先级跑——高优先级中断抢不到CPU,系统响应一塌糊涂。

优先级分组配置代码:

// 设置优先级分组:4位抢占优先级,0位子优先级
// 这是游戏机常用的配置,因为我们需要精细控制中断嵌套
NVIC_SetPriorityGrouping(3);  // 3表示4位抢占 + 0位子优先级

// 设置具体中断的优先级
NVIC_SetPriority(TIM2_IRQn, 1);  // 定时器中断,高优先级
NVIC_SetPriority(UART_IRQn, 5);  // 串口中断,低优先级

避坑指南:我踩过的那些坑

做游戏机中断系统这么多年,向量表这块我栽过不少跟头。挑几个典型的说说:

  • 向量表对齐问题:有一次我图省事,把向量表放在一个非对齐地址。结果中断一来,CPU读到的地址是错的,直接跳转到随机位置。从那以后,我每次重定向前都加对齐检查。
  • 中断嵌套导致向量表被修改:在一个双核游戏机项目里,两个核共享同一个向量表。一个核在修改向量表时,另一个核触发了中断——数据竞争,系统崩溃。解决方案是加互斥锁,或者每个核用独立的向量表。
  • Bootloader和应用程序的向量表冲突:我见过有人把Bootloader的向量表和应用程序的向量表放在同一个地址,结果升级固件后,旧的中断处理函数还在运行。正确的做法是:Bootloader用Flash起始地址的向量表,应用程序用偏移后的向量表,两者互不干扰。

最后说一句,向量表看似简单,但它是整个中断系统的基石。你想想看,如果通讯录都找错了,后面的电话怎么可能打对?所以,每次配置向量表时,多花一分钟检查,能省下后面调试的三小时。

下一章,咱们聊聊中断优先级和嵌套——这才是真正体现游戏机实时响应能力的地方。