4. 中断系统与NVIC:STM32的中断灵魂
中断,说白了就是CPU正在干一件事,突然被更重要的事打断,处理完再回来继续。没有中断,你的单片机就只能轮询——那效率,想想都着急。
我刚开始用STM32时,对中断的理解就是“按个按钮,进个函数”。后来做项目多了才发现,中断优先级配不好,系统直接死给你看。嗯,这一章咱们就把中断体系彻底捋清楚。
4.1 STM32中断体系概览
STM32的中断系统,核心是NVIC——嵌套向量中断控制器。它负责管理所有中断的使能、挂起、优先级和响应顺序。
整个中断体系分两层:
- 内核中断:比如SysTick、HardFault、PendSV,这些是Cortex-M内核自带的,优先级固定。
- 外设中断:比如TIM、USART、ADC、EXTI,这些由芯片厂商定义,通过NVIC统一管理。
我个人习惯把中断响应流程画成一条线:
中断事件 → 中断标志位置位 → NVIC判断优先级 → 响应中断 → 执行ISR → 清标志位 → 返回主程序
这里有个关键点:中断服务函数(ISR)的名字不能写错。HAL库在启动文件里已经给每个中断向量分配了弱定义函数名,你只需要重写同名函数就行。我见过有人把USART1_IRQHandler写成USART1_IRQ_Handler,结果中断死活不进——查了半天,就是名字差一个下划线。
4.2 NVIC中断控制器
NVIC是Cortex-M3/M4/M7内核标配,支持最多240个中断源(具体看芯片型号)。它干三件事:
- 使能/禁止中断:通过寄存器控制每个中断的开关。
- 设置优先级:决定哪个中断能抢断哪个。
- 处理挂起/活跃状态:中断来了但没来得及响应,就挂起;正在执行,就标记活跃。
NVIC的优先级分组是个容易踩坑的地方。STM32把优先级分成抢占优先级和子优先级:
| 分组编号 | 抢占优先级位数 | 子优先级位数 | 典型场景 |
|---|---|---|---|
| NVIC_PriorityGroup_0 | 0 bit | 4 bit | 所有中断同级,无抢占 |
| NVIC_PriorityGroup_1 | 1 bit | 3 bit | 2级抢占,8级子优先级 |
| NVIC_PriorityGroup_2 | 2 bit | 2 bit | 4级抢占,4级子优先级 |
| NVIC_PriorityGroup_3 | 3 bit | 1 bit | 8级抢占,2级子优先级 |
| NVIC_PriorityGroup_4 | 4 bit | 0 bit | 16级抢占,无子优先级 |
重要提醒:优先级分组只能设置一次,一般在main函数开头调用HAL_NVIC_SetPriorityGrouping()。中途改分组,中断行为会乱套。我曾经在一个项目里,因为不同模块初始化时各设了一次分组,结果高优先级中断抢不过低优先级的——排查了两天才发现是分组冲突。
4.3 HAL_NVIC_SetPriority 与 HAL_NVIC_EnableIRQ
这两个函数是NVIC配置的入口。咱们直接看代码:
// 设置优先级分组(全局只调用一次)
HAL_NVIC_SetPriorityGrouping(NVIC_PRIORITYGROUP_2);
// 使能USART1中断,抢占优先级1,子优先级0
HAL_NVIC_SetPriority(USART1_IRQn, 1, 0);
HAL_NVIC_EnableIRQ(USART1_IRQn);
// 使能TIM2中断,抢占优先级0,子优先级1
HAL_NVIC_SetPriority(TIM2_IRQn, 0, 1);
HAL_NVIC_EnableIRQ(TIM2_IRQn);
这里有几个细节:
- 抢占优先级数值越小,优先级越高。0是最高的。
- 子优先级只在抢占优先级相同时才起作用。如果抢占优先级不同,子优先级不参与比较。
- HAL_NVIC_EnableIRQ只是使能NVIC层面的中断,外设本身的中断使能还要单独配(比如USART_CR1里的RXNEIE位)。
我建议你在配置中断时,先想清楚哪些中断是实时性要求高的(比如电机控制、通信超时),给它们高抢占优先级;其他中断(比如按键、LED闪烁)给低优先级就行。别把所有中断都设成0,否则高优先级中断嵌套会把堆栈撑爆。
4.4 外部中断EXTI配置
EXTI(External Interrupt/Event Controller)是STM32处理外部引脚中断的模块。它支持最多16条外部中断线(EXTI0~EXTI15),每条线对应一组GPIO引脚。
配置EXTI的步骤,我总结成五步:
- 使能GPIO时钟:你要用的引脚,时钟必须打开。
- 配置GPIO为中断模式:设置引脚为输入,选择上下拉,使能中断。
- 配置EXTI线:选择触发边沿(上升沿、下降沿、双边沿)。
- 配置NVIC:设置优先级,使能中断。
- 编写ISR:在中断服务函数里处理业务逻辑。
用HAL库实现,代码很简洁:
// 以PA0引脚为例,配置为外部中断,下降沿触发
GPIO_InitTypeDef GPIO_InitStruct = {0};
__HAL_RCC_GPIOA_CLK_ENABLE(); // 1. 使能GPIO时钟
GPIO_InitStruct.Pin = GPIO_PIN_0;
GPIO_InitStruct.Mode = GPIO_MODE_IT_FALLING; // 下降沿触发中断
GPIO_InitStruct.Pull = GPIO_PULLUP; // 内部上拉
HAL_GPIO_Init(GPIOA, &GPIO_InitStruct); // 2. 初始化GPIO
HAL_NVIC_SetPriority(EXTI0_IRQn, 2, 0); // 3. 设置NVIC优先级
HAL_NVIC_EnableIRQ(EXTI0_IRQn); // 4. 使能NVIC中断
中断服务函数怎么写?HAL库提供了两个层次:
- HAL_GPIO_EXTI_IRQHandler(uint16_t GPIO_Pin):这是底层中断处理函数,它会清除中断标志位,然后调用回调函数。
- HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin):这是用户回调函数,你在这里写业务逻辑。
// 中断服务函数(在stm32f4xx_it.c中)
void EXTI0_IRQHandler(void)
{
HAL_GPIO_EXTI_IRQHandler(GPIO_PIN_0);
}
// 用户回调函数(写在你的应用代码里)
void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin)
{
if(GPIO_Pin == GPIO_PIN_0)
{
// 按键按下,执行动作
// 注意:这里不要做耗时操作,建议只设标志位
key_pressed = 1;
}
}
个人经验:回调函数里不要做延时、打印、复杂计算。中断服务讲究快进快出。我一般只在回调里设一个全局标志位,主循环里轮询这个标志位再处理具体业务。这样既保证了中断响应速度,又避免了中断嵌套导致的堆栈溢出。
4.5 中断系统知识结构图
下面这张图把中断体系的脉络画清楚了,从外设事件到CPU响应,每一步都对应一个配置环节:
4.6 避坑指南与实战建议
做中断开发,有几个坑我反复踩过,分享给你:
- 中断标志位不清除:HAL库的EXTI回调函数会自动清标志,但如果你直接操作寄存器,别忘了在ISR末尾调用
__HAL_GPIO_EXTI_CLEAR_IT()。不清除的话,中断会反复触发,CPU卡死在ISR里。 - 优先级分组冲突:前面说过,分组只能设一次。如果你用了CubeMX,它默认会在HAL_Init()里设分组,你自己代码里就别再调了。
- 中断嵌套导致堆栈溢出:每个中断嵌套一层,堆栈就多占一份。如果你的中断嵌套超过3层,建议检查堆栈大小(在启动文件里修改Stack_Size)。
- EXTI线冲突:同一组EXTI线(比如EXTI0)只能对应一个引脚。如果你把PA0和PB0都配成EXTI0,只有一个能生效。我建议用CubeMX的Pinout视图检查冲突。
曾经有个项目:客户反馈设备偶尔死机,我远程调试发现是外部中断里调用了HAL_Delay()。HAL_Delay依赖SysTick中断,而SysTick中断优先级比外部中断低,结果外部中断里等不到SysTick,直接卡死。从那以后,我定了个规矩:中断里绝对不用延时函数。
好了,中断系统这块内容就这些。配置NVIC和EXTI其实不难,难的是理解优先级背后的逻辑,以及养成好的中断编程习惯。记住:中断是给紧急任务用的,不是给你写业务逻辑的地方。