中断系统深入:NVIC配置、中断优先级、嵌套与临界区保护
一、一个让我熬夜到凌晨三点的中断问题
去年做一款工业数据采集器,主控是STM32F407,外挂三个SPI传感器、一个UART通信模块、一个I2C EEPROM。功能验证跑得挺好,一上产线就出幺蛾子------设备运行几小时后随机死机,看门狗都救不回来。
用串口打印调试信息,发现死机前最后一次打印总是卡在某个外设的中断服务函数里。更诡异的是,同样的代码在开发板上跑三天都没事,一装进金属机箱就频繁触发。最后用示波器抓IRQ引脚,发现是多个中断源同时触发时,优先级配置不当导致的中断嵌套死锁。
这个问题让我意识到:中断系统不是"配个优先级就能跑"那么简单。NVIC的配置、优先级分组、临界区保护,每一个细节都可能成为嵌入式系统的"定时炸弹"。
二、NVIC配置:别被寄存器数量吓到
NVIC(Nested Vectored Interrupt Controller)是ARM Cortex-M系列的中断控制核心。很多人一看到NVIC的寄存器手册就头大------几十个寄存器,每个还分8位、16位、32位。其实真正需要手配的没几个。
2.1 中断使能与除能
c
// 使能USART1中断(IRQn = 37)
NVIC_EnableIRQ(USART1_IRQn);
// 除能某个中断
NVIC_DisableIRQ(USART1_IRQn);
这里有个坑:不要在中断服务函数里频繁调用NVIC_EnableIRQ和NVIC_DisableIRQ。这两个函数内部有内存屏障指令,频繁调用会拖慢系统响应。我见过有人每接收一个字节就重新使能一次中断,结果中断延迟从1μs飙升到50μs。
2.2 挂起与清除挂起
c
// 软件触发中断(调试用)
NVIC_SetPendingIRQ(USART1_IRQn);
// 清除挂起位(别乱用!)
NVIC_ClearPendingIRQ(USART1_IRQn);
ClearPendingIRQ这个函数要小心。正常情况下,硬件自动清除挂起位。如果你在中断服务函数里手动清除,可能会把硬件刚置位的挂起位清掉,导致丢失中断。只有一种情况需要手动清除:在临界区里屏蔽中断期间,有中断被挂起,退出临界区前需要清除这些"过期"的挂起位。
2.3 优先级配置
c
// 设置USART1中断优先级为2(组2:2位抢占优先级,2位子优先级)
NVIC_SetPriority(USART1_IRQn, 2);
优先级数值越小,优先级越高。这个和直觉相反,很多人第一次写都会搞反。我习惯在代码开头加个宏定义:
c
#define HIGH_PRIORITY 0
#define MEDIUM_PRIORITY 1
#define LOW_PRIORITY 2
三、中断优先级分组:一个决定系统生死的配置
优先级分组是NVIC最容易被忽视的配置。它决定了优先级寄存器中多少位用于抢占优先级,多少位用于子优先级。
c
// 设置优先级分组为组2
NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2);
分组选项:
- 组0:0位抢占,4位子优先级(所有中断同级,不能嵌套)
- 组1:1位抢占,3位子优先级
- 组2:2位抢占,2位子优先级
- 组3:3位抢占,1位子优先级
- 组4:4位抢占,0位子优先级(完全嵌套)
我的经验法则:工业控制用组2,消费电子用组3。 为什么?工业场景下,你需要区分"紧急中断"(如过流保护)和"普通中断"(如按键扫描),2位抢占优先级够用。消费电子追求响应速度,3位抢占优先级可以更精细地控制嵌套层次。
千万别在系统运行后修改优先级分组!这个配置必须在系统初始化时一次性设置,而且只能设置一次。我见过有人在不同模块的初始化函数里分别调用NVIC_PriorityGroupConfig,结果后调用的覆盖了前面的,整个优先级体系乱套。
四、中断嵌套:看似强大,实则危险
中断嵌套是NVIC的卖点,但也是bug的温床。高优先级中断可以打断低优先级中断的服务函数,这听起来很美好,实际调试时能让你崩溃。
4.1 嵌套深度限制
Cortex-M3/M4支持最多256级中断优先级,但实际嵌套深度受限于堆栈空间。每个中断嵌套需要额外的堆栈空间(约72字节用于保存上下文)。如果嵌套深度达到5层,堆栈消耗就接近400字节。很多人的堆栈只配了512字节,嵌套几次就栈溢出。
检查堆栈使用量的方法:
c
// 在调试器里查看当前堆栈指针
// 或者用这个函数(需要实现)
uint32_t GetStackUsage(void) {
extern uint32_t _estack;
uint32_t sp = __get_MSP();
return (uint32_t)&_estack - sp;
}
4.2 嵌套导致的资源竞争
这是最隐蔽的问题。假设有两个中断:
- 中断A(高优先级):读取传感器数据,写入全局变量sensor_val
- 中断B(低优先级):通过UART发送sensor_val
如果中断A在中断B执行到一半时触发,中断B读取的sensor_val可能是不完整的。解决方案有两个:
方案一:关中断保护(简单粗暴)
c
void UART_SendSensorValue(void) {
__disable_irq(); // 关全局中断
uint32_t val = sensor_val;
__enable_irq(); // 开全局中断
// 发送val
}
方案二:使用原子操作(推荐)
c
// 使用LDREX/STREX指令实现原子读取
uint32_t AtomicReadSensor(void) {
uint32_t val;
do {
val = __LDREXW(&sensor_val);
} while(__STREXW(val, &sensor_val) != 0);
return val;
}
五、临界区保护:别让中断破坏你的数据
临界区是指那些不能被中断打断的代码段。最常见的场景是操作共享数据结构。
5.1 临界区的三种实现方式
方式一:全局关中断(最粗暴)
c
void CriticalSection_Enter(void) {
__disable_irq();
}
void CriticalSection_Exit(void) {
__enable_irq();
}
缺点:关中断时间过长会导致系统响应延迟。关中断时间不要超过10μs,否则高优先级中断可能丢失。
方式二:保存并恢复中断状态(推荐)
c
void CriticalSection_Enter(uint32_t *saved_state) {
*saved_state = __get_PRIMASK();
__disable_irq();
}
void CriticalSection_Exit(uint32_t saved_state) {
__set_PRIMASK(saved_state);
}
这种方式可以嵌套使用,不会因为退出临界区而意外开启之前被关闭的中断。
方式三:使用互斥量(RTOS场景)
c
// FreeRTOS示例
taskENTER_CRITICAL();
// 操作共享数据
taskEXIT_CRITICAL();
5.2 临界区的常见陷阱
陷阱1:在临界区里调用可能触发中断的函数
c
void ProcessData(void) {
__disable_irq();
// 操作数据
UART_SendByte(data); // 危险!UART发送可能触发中断
__enable_irq();
}
陷阱2:临界区嵌套导致死锁
c
void FuncA(void) {
__disable_irq();
FuncB(); // FuncB里又关了一次中断
__enable_irq(); // 这里只开了一次,中断还是关着的
}
void FuncB(void) {
__disable_irq();
// 操作
__enable_irq();
}
使用保存状态的方式可以避免这个问题。
六、中断延迟:一个被忽视的性能指标
中断延迟是指从中断触发到执行第一条中断服务指令的时间。影响中断延迟的因素:
- 关中断时间:临界区越长,延迟越大
- 中断优先级:低优先级中断要等高优先级中断处理完
- 总线仲裁:如果CPU正在访问慢速外设(如Flash),中断响应会延迟
测量中断延迟的方法:
c
// 在中断服务函数入口记录时间
void TIM2_IRQHandler(void) {
uint32_t entry_time = TIM2->CNT; // 记录当前定时器值
// 与触发时刻的定时器值比较
}
经验数据:Cortex-M4在72MHz下,典型中断延迟约12个时钟周期(约167ns)。如果关中断时间达到100μs,中断延迟就会飙升到100μs以上。
七、实战:一个中断系统的完整配置
以STM32F407为例,配置一个完整的中断系统:
c
void System_Interrupt_Init(void) {
// 1. 设置优先级分组(只调用一次!)
NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2);
// 2. 配置各个中断
// UART接收中断:高优先级,需要快速响应
NVIC_SetPriority(USART1_IRQn, 0); // 抢占0,子优先级0
NVIC_EnableIRQ(USART1_IRQn);
// SPI传输完成中断:中等优先级
NVIC_SetPriority(SPI1_IRQn, 1); // 抢占1,子优先级0
NVIC_EnableIRQ(SPI1_IRQn);
// 定时器中断:低优先级,用于周期性任务
NVIC_SetPriority(TIM3_IRQn, 2); // 抢占2,子优先级0
NVIC_EnableIRQ(TIM3_IRQn);
// 3. 配置中断向量表偏移(如果使用Bootloader)
SCB->VTOR = FLASH_BASE | 0x10000; // 偏移到0x08010000
}
八、个人经验总结
-
优先级分组选组2:除非你有特殊需求,组2(2位抢占+2位子优先级)是最平衡的选择。组0太死板,组4太复杂。
-
中断服务函数要短:我给自己定的规矩:中断服务函数不超过20行代码,只做最必要的操作(读数据、清标志、设置事件标志),具体处理放到主循环或任务里。
-
关中断时间用示波器量:别靠猜。在临界区入口拉高一个GPIO,出口拉低,用示波器看脉冲宽度。超过10μs就要优化。
-
中断嵌套最多3层:超过3层,堆栈风险急剧增加。如果发现需要4层以上嵌套,说明你的中断优先级设计有问题。
-
调试中断问题用逻辑分析仪:串口打印会改变时序,可能掩盖问题。用逻辑分析仪抓IRQ引脚和GPIO调试引脚,比任何调试手段都直观。
-
最后一条,也是最重要的一条 :永远不要在中断服务函数里调用printf。我见过太多人因为这个导致系统卡死。printf内部会关中断,如果中断里调用printf,相当于在关中断的状态下又试图关中断,直接死锁。
中断系统是嵌入式开发的"内功",配置对了系统稳如泰山,配错了就是定时炸弹。希望这篇笔记能帮你少踩几个坑。