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的步骤,我总结成五步:

  1. 使能GPIO时钟:你要用的引脚,时钟必须打开。
  2. 配置GPIO为中断模式:设置引脚为输入,选择上下拉,使能中断。
  3. 配置EXTI线:选择触发边沿(上升沿、下降沿、双边沿)。
  4. 配置NVIC:设置优先级,使能中断。
  5. 编写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响应,每一步都对应一个配置环节:

STM32中断系统知识结构图 STM32中断系统知识结构图 外设中断源 外部中断EXTI 内核中断 NVIC 嵌套向量中断控制器 使能/禁止 | 优先级分组 | 挂起/活跃管理 优先级分组:抢占优先级 + 子优先级 HAL_NVIC_SetPriority HAL_NVIC_EnableIRQ 中断服务函数 ISR → 回调函数 配置顺序:外设时钟 → GPIO模式 → EXTI配置 → NVIC优先级 → 使能中断 → 编写ISR

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其实不难,难的是理解优先级背后的逻辑,以及养成好的中断编程习惯。记住:中断是给紧急任务用的,不是给你写业务逻辑的地方。