2. 中断向量表与入口地址:中断服务函数的注册与实现

中断向量表,说白了就是一张“紧急事件处理人员名单”。

CPU 遇到中断信号后,会暂停当前工作,然后去这张表里查——这个中断号对应哪个处理函数?地址在哪?然后直接跳过去执行。

我刚开始做单片机项目时,总觉得向量表是编译器自动搞定的,不用管。直到有一次,我手写了一个 bootloader,结果中断死活不响应……查了两天,发现是向量表偏移没设对。嗯,从那以后我再也不敢轻视这张表了。

2.1 中断向量表的结构

中断向量表,本质上是一个函数指针数组。每个表项存放着对应中断服务函数的入口地址。

以 Cortex-M3/M4 内核为例,向量表从地址 0x00000000 开始,前 16 个是系统异常,后面才是外部中断。典型布局如下:

偏移地址 异常/中断号 功能描述
0x0000_0000 - 栈顶指针(MSP 初始值)
0x0000_0004 Reset 复位向量,程序入口
0x0000_0008 NMI 不可屏蔽中断
0x0000_000C HardFault 硬错误异常
0x0000_0010 ~ 0x0000_003C 4~15 系统异常(MemManage, BusFault, UsageFault, SVCall, PendSV, SysTick 等)
0x0000_0040 起 16~ 外部中断(IRQ0, IRQ1, ...)

每个表项占 4 字节(32 位地址)。所以 IRQn 对应的向量地址 = 基址 + (16 + IRQn) * 4。

关键点:向量表必须 256 字节对齐(Cortex-M 要求)。如果做 IAP 升级或 bootloader,需要把向量表重定位到 RAM 或 Flash 的其他位置,这时要设置 SCB->VTOR 寄存器。

2.2 中断服务函数的注册机制

注册 ISR,说白了就是把你的 C 函数地址填到向量表的对应位置。

不同编译器有不同的做法。我以最常用的 ARM GCC 和 IAR 为例:

2.2.1 使用弱符号(Weak Symbol)—— GCC/ARMCC 方式

启动文件里会预先声明一堆 __weak 的默认中断处理函数,比如:

// 启动文件中的弱定义(汇编或C)
void __weak Default_Handler(void) {
    while(1);  // 死循环,方便调试
}

void __weak USART1_IRQHandler(void) __attribute__((weak, alias("Default_Handler")));
void __weak TIM2_IRQHandler(void)    __attribute__((weak, alias("Default_Handler")));

你在用户代码里写一个同名函数,就会覆盖弱符号:

// 用户代码
void USART1_IRQHandler(void) {
    // 真正的串口中断处理
    if(USART1->SR & USART_SR_RXNE) {
        uint8_t data = USART1->DR;
        // 处理接收数据
    }
}

链接器会自动把强符号(你的函数)替换掉弱符号(默认的 Handler)。

我的习惯:在工程里单独建一个 interrupts.c 文件,把所有 ISR 集中管理。不要散落在各个模块里,否则查中断冲突时你会想骂人。

2.2.2 使用中断向量表数组——直接赋值

有些场景下,你需要动态注册中断。比如在 RTOS 中,或者做软件模拟中断时:

// 定义向量表类型
typedef void (*ISR_FuncPtr)(void);

// 在链接脚本中定义向量表地址
extern uint32_t __VECTOR_TABLE[];

// 动态注册
void RegisterIRQ(uint32_t irqNum, ISR_FuncPtr handler) {
    uint32_t vectorIndex = 16 + irqNum;  // 前16个是系统异常
    __VECTOR_TABLE[vectorIndex] = (uint32_t)handler;
    
    // 如果是运行中修改,需要重新使能中断
    __DSB();
    __ISB();
}

我在一个需要热插拔外设的项目里用过这种方式。不同外设插入时,动态注册对应的 ISR,省去了写一堆条件编译的麻烦。

2.3 中断服务函数的实现要点

写 ISR 和写普通函数不一样。有几个坑,我一个个说。

2.3.1 保持短小精悍

ISR 里不要做复杂运算,不要调用 printf,不要等待。原则是:能多快就多快。

我见过一个同事在 ISR 里做浮点运算,结果中断嵌套把栈撑爆了。从那以后,我定了个规矩:ISR 里只做标志位设置和数据搬运,耗时操作丢到主循环或任务里。

// 好的做法
void TIM2_IRQHandler(void) {
    if(TIM2->SR & TIM_SR_UIF) {
        TIM2->SR = ~TIM_SR_UIF;   // 清标志
        g_tick++;                  // 只做计数
    }
}

// 坏的做法
void TIM2_IRQHandler(void) {
    if(TIM2->SR & TIM_SR_UIF) {
        TIM2->SR = ~TIM_SR_UIF;
        for(int i=0; i<1000; i++) {  // 不要在ISR里循环
            process_data(i);
        }
    }
}

2.3.2 正确清除中断标志

这是新手最容易犯的错。不清标志,ISR 会反复触发,系统直接卡死。

不同外设的清标志方式不同:

  • 读状态寄存器清标志:如 USART 的 RXNE,读 DR 寄存器自动清
  • 写 1 清标志:如 TIM 的 UIF,向 SR 寄存器的对应位写 1
  • 写 0 清标志:如某些 GPIO 中断,向 PR 寄存器写 1 清(注意是写 1 清)

我曾经踩过的坑:用 STM32F1 的 EXTI 中断时,以为清标志是写 0,结果中断一直触发。后来看参考手册才发现,EXTI->PR 寄存器是写 1 清标志。所以,一定要看参考手册,不要凭经验猜。

2.3.3 中断优先级与嵌套

Cortex-M 内核支持中断优先级分组。你可以配置抢占优先级和子优先级。

我一般这样配置:

// 设置优先级分组:4位抢占优先级,0位子优先级
NVIC_SetPriorityGrouping(3);  // 注意:STM32 HAL 库的宏定义

// 设置单个中断优先级
NVIC_SetPriority(TIM2_IRQn, 1);    // 高优先级
NVIC_SetPriority(USART1_IRQn, 2);  // 低优先级

// 使能中断
NVIC_EnableIRQ(TIM2_IRQn);
NVIC_EnableIRQ(USART1_IRQn);

优先级数值越小,优先级越高。TIM2 中断可以打断 USART1 中断。

建议:把时间关键的中断(如定时器、通信超时)设高优先级。把数据量大的中断(如 DMA 完成)设低优先级。避免高优先级 ISR 里做耗时操作,否则低优先级中断永远得不到响应。

2.4 中断向量表重定位

做 bootloader 或 IAP 升级时,应用程序的向量表不在 0x00000000 了。你需要告诉 CPU:向量表搬家了。

// 在应用程序启动代码中(一般在 main 函数最前面)
#define APP_VECTOR_TABLE_ADDR  0x08010000  // 应用程序的向量表地址

void SystemInit(void) {
    // 设置向量表偏移
    SCB->VTOR = APP_VECTOR_TABLE_ADDR;
    
    // 确保设置生效
    __DSB();
    __ISB();
}

注意:VTOR 寄存器要求地址512 字节对齐(Cortex-M3/M4)。如果你的向量表大小超过 512 字节,对齐值要相应增大。

2.5 本章知识结构图

下面这张图梳理了中断向量表、ISR 注册和实现的核心关系:

中断向量表与ISR实现知识结构图 中断向量表与ISR实现知识结构 中断向量表 向量表结构 ISR注册机制 ISR实现要点 系统异常(前16项) 外部中断(IRQ0~IRQn) 256字节对齐要求 弱符号覆盖(GCC/IAR) 向量表数组直接赋值 动态注册(RTOS场景) 保持短小,只做标志位 正确清除中断标志 优先级配置与嵌套 核心:向量表是CPU的中断“通讯录” ISR注册就是往通讯录里填电话号码

2.6 常见问题与避坑指南

  • 中断不响应? 先查向量表地址对不对,再查中断是否使能,最后查优先级配置是否冲突。
  • 中断反复触发? 99% 是没清中断标志。用调试器看外设状态寄存器。
  • 程序跑飞? 检查 ISR 是否用到了被中断破坏的寄存器。Cortex-M 会自动压栈,但如果你在 ISR 里调用了非可重入函数,就可能出问题。
  • 中断嵌套导致栈溢出? 给 ISR 分配独立的栈空间,或者降低嵌套深度。我一般限制最多 2 层嵌套。

调试小技巧:在 ISR 入口和出口各放一个 GPIO 翻转。用逻辑分析仪看波形,就能直观看到中断响应时间和嵌套情况。这比 printf 靠谱多了。