4. 中断系统与外部中断:从理论到实战
中断,说白了就是让CPU停下手里的事,先去处理一个紧急任务。我刚开始学STM32时,觉得中断就是个“高级点的轮询”。后来做项目才发现,没有中断,很多实时性要求根本没法满足。
这一章,我们就把中断系统拆开揉碎。从NVIC控制器,到外部中断配置,再到实际写中断服务函数。嗯,每一步我都会结合项目经验来讲。
4.1 NVIC中断控制器:中断的“调度中心”
NVIC(Nested Vectored Interrupt Controller)是Cortex-M内核的中断管家。它负责管理所有中断的使能、挂起、优先级和响应。
我个人习惯把NVIC理解成一个“优先级仲裁器”。当多个中断同时发生时,谁先响应,谁后响应,都由它说了算。
NVIC核心能力:
- 支持最多240个中断(STM32实际使用约16~80个)
- 每个中断可独立使能/失能
- 支持中断优先级分组(抢占优先级 + 子优先级)
- 硬件自动压栈/出栈,无需软件干预
这里有个关键点:NVIC的优先级是数字越小,优先级越高。很多新手会搞反,我刚开始也犯过这个错。
4.2 中断优先级分组:抢占与响应
STM32把优先级分成两组:抢占优先级和子优先级。抢占优先级决定能否打断正在执行的中断,子优先级决定同抢占级中断的响应顺序。
优先级分组通过 NVIC_PriorityGroupConfig() 函数配置。STM32支持5种分组方式:
| 分组 | 抢占优先级位数 | 子优先级位数 | 抢占级数量 | 子优先级数量 |
|---|---|---|---|---|
| NVIC_PriorityGroup_0 | 0 | 4 | 1 | 16 |
| NVIC_PriorityGroup_1 | 1 | 3 | 2 | 8 |
| NVIC_PriorityGroup_2 | 2 | 2 | 4 | 4 |
| NVIC_PriorityGroup_3 | 3 | 1 | 8 | 2 |
| NVIC_PriorityGroup_4 | 4 | 0 | 16 | 1 |
我的建议:大多数项目用 NVIC_PriorityGroup_2 就够了。4级抢占 + 4级子优先级,基本覆盖所有场景。我在一个多传感器采集项目中用过Group_2,效果很好。
配置示例:
// 设置优先级分组:4位抢占优先级,4位子优先级
NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2);
// 配置USART1中断
NVIC_InitTypeDef NVIC_InitStructure;
NVIC_InitStructure.NVIC_IRQChannel = USART1_IRQn;
NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 1; // 抢占优先级
NVIC_InitStructure.NVIC_IRQChannelSubPriority = 0; // 子优先级
NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE;
NVIC_Init(&NVIC_InitStructure);
4.3 EXTI外部中断配置:从引脚到中断的链路
EXTI(External Interrupt/Event Controller)负责把GPIO引脚上的电平变化,转换成中断请求。说白了,就是“引脚信号 → 中断信号”的桥梁。
配置EXTI的步骤,我总结为4步:
- 使能GPIO时钟 —— 引脚要工作,时钟先到位
- 配置GPIO为输入模式 —— 上拉/下拉根据电路决定
- 配置EXTI线 —— 选择触发边沿(上升沿/下降沿/双边沿)
- 配置NVIC —— 使能中断通道,设置优先级
来看一个按键中断的完整配置:
// 按键中断配置:PA0作为外部中断输入
void EXTI_Key_Config(void)
{
GPIO_InitTypeDef GPIO_InitStructure;
EXTI_InitTypeDef EXTI_InitStructure;
NVIC_InitTypeDef NVIC_InitStructure;
// 1. 使能GPIOA和AFIO时钟
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_AFIO, ENABLE);
// 2. 配置PA0为浮空输入
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN_FLOATING;
GPIO_Init(GPIOA, &GPIO_InitStructure);
// 3. 将PA0连接到EXTI0线
GPIO_EXTILineConfig(GPIO_PortSourceGPIOA, GPIO_PinSource0);
// 4. 配置EXTI0:下降沿触发
EXTI_InitStructure.EXTI_Line = EXTI_Line0;
EXTI_InitStructure.EXTI_Mode = EXTI_Mode_Interrupt;
EXTI_InitStructure.EXTI_Trigger = EXTI_Trigger_Falling; // 按键按下时产生下降沿
EXTI_InitStructure.EXTI_LineCmd = ENABLE;
EXTI_Init(&EXTI_InitStructure);
// 5. 配置NVIC
NVIC_InitStructure.NVIC_IRQChannel = EXTI0_IRQn;
NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 2;
NVIC_InitStructure.NVIC_IRQChannelSubPriority = 0;
NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE;
NVIC_Init(&NVIC_InitStructure);
}
注意:EXTI线0~15分别对应GPIO的Pin0~Pin15。同一编号的EXTI线只能映射到一个GPIO引脚。比如PA0和PB0不能同时使用EXTI0线。我曾经在一个项目里踩过这个坑,两个按键用了同一个EXTI线,结果只有一个能触发中断。
4.4 编写中断服务函数:ISR的规范写法
中断服务函数(ISR)是中断触发后实际执行的代码。STM32的ISR函数名是固定的,在启动文件里已经定义好。你只需要在C文件中实现它。
ISR的命名规则:XXX_IRQHandler。比如EXTI0的中断服务函数叫 EXTI0_IRQHandler。
// EXTI0中断服务函数
void EXTI0_IRQHandler(void)
{
// 检查中断标志位
if(EXTI_GetITStatus(EXTI_Line0) != RESET)
{
// 执行中断处理逻辑
// 比如:翻转LED、记录按键次数、唤醒系统等
// 清除中断标志位 —— 这一步绝对不能忘!
EXTI_ClearITPendingBit(EXTI_Line0);
}
}
ISR里应该写什么?我的原则是:快进快出。中断服务函数里只做最紧急的事,比如设置一个标志位、计数加1、或者把数据存到缓冲区。复杂的处理逻辑放到主循环里做。
ISR编写黄金法则:
- 代码尽量短小精悍
- 避免调用延时函数
- 避免使用printf等阻塞型函数
- 避免进行复杂运算
- 必须清除中断标志位
4.5 中断应用:按键中断实战
按键中断是最常见的EXTI应用场景。相比轮询方式,中断方式响应更快,而且不占用CPU时间。
来看一个完整的按键中断示例:
// 全局变量:按键按下标志
volatile uint8_t key_pressed = 0;
// 主函数
int main(void)
{
// 初始化
LED_Init(); // LED初始化
EXTI_Key_Config(); // 按键中断配置
while(1)
{
if(key_pressed)
{
key_pressed = 0; // 清除标志
LED_Toggle(); // 翻转LED
}
// 其他任务...
}
}
// 中断服务函数
void EXTI0_IRQHandler(void)
{
if(EXTI_GetITStatus(EXTI_Line0) != RESET)
{
// 消抖处理:延时20ms再检测
delay_ms(20);
if(GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_0) == 0)
{
key_pressed = 1; // 设置标志位
}
EXTI_ClearITPendingBit(EXTI_Line0);
}
}
这里有个细节:我在ISR里做了软件消抖。虽然中断里加延时不太优雅,但20ms的延时对系统影响很小。如果你对实时性要求极高,可以用定时器来做消抖。
4.6 中断注意事项:标志位清除与重入问题
中断用不好,系统会出各种奇怪问题。我总结几个最常见的坑:
标志位清除
这是新手最容易犯的错误。中断触发后,硬件会置位中断标志位。如果你不清除它,中断会反复触发,导致系统卡死在ISR里。
清除标志位的正确姿势:
// 正确:清除指定中断线标志
EXTI_ClearITPendingBit(EXTI_Line0);
// 或者使用更安全的版本
EXTI_ClearFlag(EXTI_Line0);
我曾经在一个项目中,因为忘记清除标志位,导致系统一上电就卡死在中断里。排查了整整半天才发现问题。从那以后,我写ISR的第一件事就是先写清除标志位的代码。
中断重入问题
中断重入是指一个中断正在执行时,又被同优先级或更高优先级的中断打断。如果处理不当,会导致数据混乱或系统崩溃。
避免重入问题的几个方法:
- 合理设置优先级:关键中断用高优先级,普通中断用低优先级
- ISR中关中断:在访问共享资源时,临时关闭中断
- 使用volatile关键字:ISR和主循环共享的变量,必须用volatile修饰
- 避免在ISR中调用可重入函数:比如malloc、printf等
// 使用volatile防止编译器优化
volatile uint32_t interrupt_count = 0;
void EXTI0_IRQHandler(void)
{
if(EXTI_GetITStatus(EXTI_Line0) != RESET)
{
// 临时关闭中断,保护共享变量
__disable_irq();
interrupt_count++;
__enable_irq();
EXTI_ClearITPendingBit(EXTI_Line0);
}
}
4.7 中断系统知识图谱
下面这张图展示了中断系统的完整链路和核心要素:
4.8 避坑指南:我的血泪教训
做中断开发这些年,我踩过不少坑。分享几个典型的:
坑1:中断优先级设置不当导致系统卡死
我曾经在一个电机控制项目中,把定时器中断优先级设得比系统滴答中断还高。结果定时器频繁打断系统调度,导致系统卡死。后来把定时器优先级降到3(Group_2分组下),问题解决。
坑2:ISR里调用printf导致堆栈溢出
printf函数内部会申请大量栈空间,而且可能触发其他中断。我在调试时习惯在ISR里加printf,结果程序跑着跑着就飞了。后来改用串口DMA + 环形缓冲区,完美解决。
坑3:多个中断共享同一个变量
主循环和ISR同时操作一个变量时,如果不加保护,数据会错乱。我遇到过按键计数不准的问题,就是因为主循环读取变量时,ISR刚好在修改它。加个临界区保护就搞定了。