3. 中断系统与NVIC:中断优先级管理、HAL库中断回调机制、外部中断(EXTI)驱动、中断服务函数编写规范

中断,说白了就是CPU正在干一件事,突然被一个更紧急的事件打断,跑去处理完再回来接着干。我刚开始学STM32时,觉得中断就是个“跳转+返回”,后来踩了不少坑才明白——中断优先级配不好,系统直接死给你看。

这一章,咱们把中断系统彻底捋清楚。从NVIC的优先级管理,到HAL库的回调机制,再到外部中断的驱动编写,最后给出中断服务函数的规范写法。嗯,都是实战中必须拿捏的东西。

3.1 NVIC中断优先级管理

NVIC(Nested Vectored Interrupt Controller)是Cortex-M内核的中断控制器。它负责管理所有中断的使能、挂起、优先级和嵌套。说白了,它就是中断系统的“调度中心”。

STM32的中断优先级分为抢占优先级和子优先级。抢占优先级决定能否打断正在执行的中断,子优先级决定同时到达的中断谁先执行。

优先级分组 抢占优先级位数 子优先级位数 典型应用场景
NVIC_PriorityGroup_0 0位 4位 所有中断同级,无嵌套
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级抢占,无子优先级
我的习惯:项目里我一般用NVIC_PriorityGroup_2,4级抢占+4级子优先级,够用且灵活。如果中断源特别多,可以考虑Group_3。

配置优先级分组,在HAL库中调用:

HAL_NVIC_SetPriorityGrouping(NVIC_PriorityGroup_2);

这个函数必须在系统初始化时调用一次,且只能调用一次。为什么?因为优先级分组一旦设定,整个系统的中断嵌套规则就固定了。中途改分组,中断行为会乱套。

3.2 HAL库中断回调机制

HAL库的中断处理,走的是“中断服务函数 + 回调函数”的套路。中断服务函数里做最必要的事(清标志、读数据),然后调用一个弱定义的回调函数,真正的业务逻辑写在回调里。

举个例子,串口接收中断的流程:

  1. 硬件触发USARTx_IRQHandler
  2. HAL库的HAL_UART_IRQHandler被调用
  3. 内部判断中断类型,调用HAL_UART_RxCpltCallback
  4. 你在回调里写接收完成后的处理逻辑

回调函数是弱符号(__weak),你可以在自己的代码里重新定义它。HAL库的默认实现是空函数,你不重写,它什么都不做。

关键点:回调函数运行在中断上下文中。不要在回调里做耗时操作,比如printf、延时、复杂计算。我曾经在回调里加了个HAL_Delay(10),结果系统响应直接崩了。

常用的HAL库中断回调函数:

外设 回调函数名 触发条件
UART HAL_UART_RxCpltCallback 接收完成
UART HAL_UART_TxCpltCallback 发送完成
TIM HAL_TIM_PeriodElapsedCallback 定时器溢出
ADC HAL_ADC_ConvCpltCallback 转换完成
EXTI HAL_GPIO_EXTI_Callback 外部中断触发

3.3 外部中断(EXTI)驱动

外部中断EXTI,就是GPIO引脚电平变化触发的中断。按键检测、传感器信号跳变、编码器脉冲捕获,都离不开它。

配置EXTI的步骤,我总结为四步走:

  1. 使能GPIO时钟:RCC相关,不用多说
  2. 配置GPIO为中断模式:设置触发边沿(上升沿、下降沿、双边沿)
  3. 配置NVIC:使能中断通道,设置优先级
  4. 编写中断服务函数和回调

用HAL库配置一个按键外部中断(PA0,下降沿触发):

GPIO_InitTypeDef GPIO_InitStruct = {0};

__HAL_RCC_GPIOA_CLK_ENABLE();

GPIO_InitStruct.Pin = GPIO_PIN_0;
GPIO_InitStruct.Mode = GPIO_MODE_IT_FALLING;  // 下降沿触发
GPIO_InitStruct.Pull = GPIO_PULLUP;           // 内部上拉
HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);

// 配置NVIC
HAL_NVIC_SetPriority(EXTI0_IRQn, 2, 0);
HAL_NVIC_EnableIRQ(EXTI0_IRQn);
我曾经踩过的坑:配置外部中断时,忘了设置内部上拉/下拉。结果引脚悬空,电平不稳定,中断乱触发。后来养成习惯,外部中断引脚一定要明确上拉或下拉。

中断服务函数和回调的写法:

// 中断服务函数(在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)
    {
        // 按键按下,做你想做的事
        // 注意:这里不要做耗时操作!
    }
}

3.4 中断服务函数编写规范

中断服务函数(ISR)的编写,直接决定系统的稳定性和实时性。我见过太多人把中断当成普通函数来写,结果系统卡死、数据丢失、响应延迟。下面是我总结的几条硬规矩。

规范 说明 反面案例
短小精悍 ISR里只做最必要的事,清标志、读数据、设状态 在ISR里做浮点运算、malloc
避免阻塞 不要用延时、轮询、等待信号量 HAL_Delay(10)放在ISR里
保护共享数据 ISR和主循环共享的变量,加volatile 全局变量不加volatile,优化后读不到最新值
不要调用不可重入函数 printf、sprintf等标准库函数大多不可重入 ISR里直接printf调试
中断嵌套要可控 高优先级中断不要做太多事,避免低优先级被饿死 所有中断优先级设成一样,没有嵌套
我的做法:ISR里只设一个标志位,主循环里轮询标志位再做处理。这样ISR执行时间控制在微秒级,系统响应又快又稳。

一个规范的ISR示例:

volatile uint8_t button_pressed = 0;

void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin)
{
    if(GPIO_Pin == GPIO_PIN_0)
    {
        button_pressed = 1;  // 只设标志,不做其他
    }
}

// 主循环中
while(1)
{
    if(button_pressed)
    {
        button_pressed = 0;
        // 处理按键逻辑,这里可以放心延时、计算
    }
}

3.5 中断系统知识结构图

下面这张图,把中断系统的核心脉络画出来了。从NVIC配置到中断触发,再到回调执行,一条线走通。

STM32中断系统知识结构图 STM32中断系统核心流程 中断源(EXTI/TIM/UART...) NVIC中断控制器(优先级管理) 中断服务函数(ISR) 清标志 + 读数据 调用HAL库中断处理函数 回调函数(用户逻辑) 核心原则:ISR只做最必要的事,耗时操作放到主循环或任务中处理 优先级分组决定嵌套规则,回调函数运行在中断上下文中,注意保护共享数据

3.6 避坑指南与经验总结

中断系统这块,我踩过的坑能写满一页纸。挑几个最典型的说说。

  • 优先级分组只设一次:HAL_NVIC_SetPriorityGrouping在整个系统生命周期里只能调用一次。我见过有人在外设初始化里反复调用,结果中断行为完全不可控。
  • 外部中断引脚去抖:机械按键按下时会有抖动,一次按下可能触发多次中断。硬件上加RC滤波,或者软件里做延时去抖。我习惯在回调里设标志,主循环里做20ms延时去抖。
  • 中断服务函数不要太长:ISR执行时间过长,会阻塞其他中断。高优先级中断尤其要注意。我曾经在TIM中断里做了个浮点运算,结果串口中断响应延迟,数据丢包。
  • 共享变量加volatile:ISR和主循环共享的变量,一定要加volatile关键字。否则编译器优化后,主循环可能永远读不到ISR更新的值。
  • HAL_Delay不能在中断里用:HAL_Delay依赖SysTick中断,如果在中断里调用,SysTick中断优先级低于当前中断,就会死锁。我刚开始不知道,调试了整整一天。
一句话总结:中断系统是嵌入式开发的基石。优先级配好、ISR写短、回调里只设标志、共享数据加volatile。做到这四点,中断系统基本稳了。