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 注册和实现的核心关系:
2.6 常见问题与避坑指南
- 中断不响应? 先查向量表地址对不对,再查中断是否使能,最后查优先级配置是否冲突。
- 中断反复触发? 99% 是没清中断标志。用调试器看外设状态寄存器。
- 程序跑飞? 检查 ISR 是否用到了被中断破坏的寄存器。Cortex-M 会自动压栈,但如果你在 ISR 里调用了非可重入函数,就可能出问题。
- 中断嵌套导致栈溢出? 给 ISR 分配独立的栈空间,或者降低嵌套深度。我一般限制最多 2 层嵌套。
调试小技巧:在 ISR 入口和出口各放一个 GPIO 翻转。用逻辑分析仪看波形,就能直观看到中断响应时间和嵌套情况。这比 printf 靠谱多了。