1. 中断到底是个什么东西,为什么每个STM32项目都绕不开它
刚接触STM32那会儿,我最怕的就是中断。主循环里跑得好好的代码,一进中断就各种莫名其妙的问题——变量值不对、标志位丢了、程序跑飞。后来项目做多了才明白,中断不是“可选的高级功能”,而是嵌入式系统里最基础的并发模型。你不用它,就只能靠轮询死等,CPU利用率低得可怜,响应还慢。
打个比方:主循环就像你在工位上写代码,中断就像同事突然拍你肩膀说“线上出故障了”。你可以选择每五分钟抬头问一圈“有事吗”(轮询),也可以让同事有事直接喊你(中断)。显然后者效率高得多,但前提是你得处理好“被打断后怎么恢复现场”这件事。
STM32的中断体系围绕三个核心组件展开:NVIC(嵌套向量中断控制器)、EXTI(外部中断/事件控制器)、以及各个外设自己的中断标志位。NVIC负责管理所有中断的优先级和使能,EXTI负责把GPIO引脚上的电平变化翻译成中断信号,外设中断则是定时器、串口、ADC这些模块自己产生的。HAL库把这些底层寄存器操作封装成了HAL_NVIC_SetPriority()、HAL_NVIC_EnableIRQ()、HAL_GPIO_EXTI_Callback()之类的函数,用起来确实方便,但如果不理解背后的机制,出了问题根本无从下手。
这篇内容适合谁看?如果你正在用STM32做项目,不管是按键检测、串口接收、定时器计时还是编码器测速,只要涉及到“某件事发生了要立刻处理”,中断就躲不掉。我会从NVIC的优先级分组讲起,到EXTI的配置细节,再到HAL库的中断处理流程,最后给出几个实际项目中踩过的坑和排查方法。代码基于STM32F4系列和HAL库,但原理通用,F1、F7、H7系列逻辑一致。
2. NVIC优先级分组:中断嵌套的规则制定者
2.1 优先级分组到底在分什么
STM32的中断优先级用4个bit表示,数值越小优先级越高。但这4个bit不是全部用来排队的,而是被拆成两部分:抢占优先级和子优先级。抢占优先级决定能不能打断正在执行的中断,子优先级决定同时挂起时谁先执行。
分组方式由NVIC_PriorityGroupConfig()或HAL库的HAL_NVIC_SetPriorityGrouping()设置,STM32F4支持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 |
我一般推荐用GROUP_2,也就是2位抢占、2位子优先级。为什么?因为大多数项目里需要嵌套的中断不超过4层,子优先级用来处理同一抢占级下的排序足够了。GROUP_4虽然给了16级抢占,但完全没有子优先级,两个中断如果抢占级相同就只能靠硬件编号排队,不够灵活。
设置分组的代码通常放在main()开头,HAL库初始化之后:
HAL_Init(); // 里面默认设置了GROUP_4 HAL_NVIC_SetPriorityGrouping(NVIC_PRIORITYGROUP_2);注意:整个系统只能设置一次优先级分组,运行中改分组会导致已配置的中断优先级错乱。我见过有人在每个外设初始化里都调一次,结果串口中断死活进不去,查了半天才发现是分组被反复重置了。
2.2 抢占优先级和子优先级的实际影响
假设你设置了GROUP_2,那么抢占优先级范围是0~3,子优先级也是0~3。配置两个中断:
- 中断A:抢占0,子0
- 中断B:抢占1,子0
中断A可以打断中断B的执行,因为A的抢占优先级更高。反过来B不能打断A。
如果改成:
- 中断A:抢占1,子0
- 中断B:抢占1,子1
两者抢占级相同,A和B不能互相打断。如果A正在执行,B触发了只能等A执行完;如果A和B同时触发,子优先级小的A先执行。
这里有个容易混淆的点:子优先级只在多个中断同时挂起时起作用,不影响嵌套。很多人以为子优先级高的能打断子优先级低的,这是错的。
2.3 HAL库中配置NVIC的标准姿势
以EXTI0中断为例,HAL库的标准配置流程:
// 1. 设置优先级分组(全局一次) HAL_NVIC_SetPriorityGrouping(NVIC_PRIORITYGROUP_2); // 2. 设置具体中断的抢占和子优先级 HAL_NVIC_SetPriority(EXTI0_IRQn, 2, 0); // 抢占2,子0 // 3. 使能中断通道 HAL_NVIC_EnableIRQ(EXTI0_IRQn);这三步通常在MX_GPIO_Init()或者外设初始化函数里完成。CubeMX生成的代码会自动帮你做好,但如果你手动移植代码,这三步一个都不能少。
实操心得:
HAL_NVIC_SetPriority()的第一个参数是IRQn_Type枚举值,不是中断号。比如EXTI0是EXTI0_IRQn,TIM2是TIM2_IRQn。写错了编译器不一定报错,但中断永远进不去。我习惯在配置完后用调试器看一下NVIC的ISER寄存器,确认对应位被置1了。
3. EXTI外部中断:从GPIO电平到中断服务函数
3.1 EXTI的触发链路
EXTI的输入源可以是GPIO引脚、PVD输出、RTC闹钟、USB唤醒等。以GPIO为例,信号路径是这样的:
GPIO引脚 -> 输入滤波 -> 边沿检测 -> 挂起寄存器 -> NVIC -> 中断服务函数
关键点在于EXTI线号和GPIO引脚号的对应关系。STM32的EXTI0~EXTI15分别对应GPIO的Pin0~Pin15,但同一个EXTI线不能同时映射到多个端口。比如PA0和PB0不能同时用作EXTI0,只能选一个。
配置EXTI的GPIO模式时,HAL库提供了三种触发方式:
- 上升沿触发:
GPIO_MODE_IT_RISING - 下降沿触发:
GPIO_MODE_IT_FALLING - 双边沿触发:
GPIO_MODE_IT_RISING_FALLING
按键检测通常用下降沿或双边沿,因为按键按下时引脚从高变低。如果按键电路有硬件消抖,用下降沿就够了;如果没有,双边沿加软件消抖更稳妥。
3.2 HAL库EXTI初始化代码拆解
一个典型的按键中断初始化:
GPIO_InitTypeDef GPIO_InitStruct = {0}; __HAL_RCC_GPIOA_CLK_ENABLE(); // 使能GPIOA时钟 GPIO_InitStruct.Pin = GPIO_PIN_0; GPIO_InitStruct.Mode = GPIO_MODE_IT_FALLING; // 下降沿触发中断 GPIO_InitStruct.Pull = GPIO_PULLUP; // 上拉,按键接GND GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(GPIOA, &GPIO_InitStruct); // NVIC配置 HAL_NVIC_SetPriority(EXTI0_IRQn, 2, 0); HAL_NVIC_EnableIRQ(EXTI0_IRQn);这段代码里,GPIO_MODE_IT_FALLING是关键。它告诉硬件:这个引脚不仅要作为输入,还要把下降沿信号送到EXTI控制器。如果你只写GPIO_MODE_INPUT,中断永远不会触发。
3.3 中断服务函数和回调函数的关系
HAL库把中断处理分成了两层:IRQHandler和Callback。以EXTI0为例:
// stm32f4xx_it.c中 void EXTI0_IRQHandler(void) { HAL_GPIO_EXTI_IRQHandler(GPIO_PIN_0); }HAL_GPIO_EXTI_IRQHandler()会做两件事:清除中断挂起标志位,然后调用HAL_GPIO_EXTI_Callback()。你只需要重写回调函数:
void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin) { if (GPIO_Pin == GPIO_PIN_0) { // 你的处理逻辑 } }这种分层设计的好处是:中断标志位的清除由HAL库统一处理,你不用担心忘记清标志导致中断反复触发。但坏处是回调函数里不能做太耗时的操作,否则会阻塞其他中断。
常见坑:
HAL_GPIO_EXTI_Callback()是弱定义函数,如果你在多个文件里都定义了这个函数,链接时会报重复定义。正确做法是只在一个文件里实现,用if判断引脚号来区分不同来源。
3.4 按键中断的软件消抖
机械按键按下时会有5~20ms的抖动,如果直接用下降沿触发,一次按下可能进好几次中断。硬件消抖靠RC电路,软件消抖有两种思路:
思路一:中断里延时再读
void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin) { if (GPIO_Pin == GPIO_PIN_0) { HAL_Delay(20); // 延时20ms避开抖动 if (HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0) == GPIO_PIN_RESET) { // 确认按下 } } }但HAL_Delay()依赖SysTick中断,如果在高优先级中断里调用,而SysTick优先级更低,就会死锁。所以这种方法只适合低优先级中断。
思路二:定时器辅助消抖
中断里只置一个标志,主循环检测标志后启动定时器,定时器溢出再读引脚。这样中断执行时间极短,不影响其他中断。
volatile uint8_t key_flag = 0; void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin) { if (GPIO_Pin == GPIO_PIN_0) { key_flag = 1; } } // 主循环中 if (key_flag) { HAL_Delay(20); if (HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0) == GPIO_PIN_RESET) { // 按键处理 } key_flag = 0; }我一般用思路二,中断里只做最轻量的操作,把耗时逻辑放到主循环。这样中断响应快,也不会影响系统实时性。
4. 定时器中断:精准定时的核心手段
4.1 定时器中断的配置流程
定时器中断比EXTI复杂一些,因为涉及到时钟源、预分频器、自动重装载值的计算。以TIM2为例,假设系统时钟84MHz,要产生1ms中断:
TIM_HandleTypeDef htim2; htim2.Instance = TIM2; htim2.Init.Prescaler = 8400 - 1; // 84MHz / 8400 = 10kHz htim2.Init.CounterMode = TIM_COUNTERMODE_UP; htim2.Init.Period = 10 - 1; // 10kHz / 10 = 1kHz,即1ms htim2.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1; HAL_TIM_Base_Init(&htim2); HAL_TIM_Base_Start_IT(&htim2); // 启动定时器并开启中断 HAL_NVIC_SetPriority(TIM2_IRQn, 1, 0); HAL_NVIC_EnableIRQ(TIM2_IRQn);计算逻辑:预分频器把84MHz分成10kHz,自动重装载值设为10,计数器每数到10就溢出一次,频率1kHz,周期1ms。
4.2 定时器中断回调函数
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM2) { // 每1ms执行一次 } }这个回调函数是所有定时器共用的,所以必须判断htim->Instance来区分。我见过有人不判断,结果TIM2和TIM3的中断互相干扰,一个定时器溢出触发了另一个的处理逻辑。
4.3 定时器中断的实际应用场景
定时器中断最典型的用途是系统时基。比如做一个数据采集系统,每10ms采集一次传感器数据,用定时器中断触发ADC转换,转换完成后DMA搬运,完全不占用CPU。
另一个场景是软件PWM。如果硬件PWM通道不够用,可以用定时器中断手动翻转GPIO。比如要产生50Hz的舵机信号,定时器每20ms中断一次,中断里先拉高GPIO,再启动一个单次定时器,0.5~2.5ms后拉低。这样就能用普通GPIO模拟舵机控制。
注意事项:定时器中断频率不要设太高。我试过用1MHz的定时器中断做软件计数,结果CPU 80%的时间都在进中断出中断,主循环几乎跑不动。一般来说,中断频率控制在10kHz以内比较合理,超过这个值就要考虑用硬件外设或者DMA来分担。
5. 串口中断与DMA空闲中断:高效接收不定长数据
5.1 串口接收中断的基本用法
串口中断接收是最基础的方式,每收到一个字节进一次中断:
uint8_t rx_byte; void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART1) { // 处理rx_byte HAL_UART_Receive_IT(&huart1, &rx_byte, 1); // 重新开启接收 } } // 初始化时 HAL_UART_Receive_IT(&huart1, &rx_byte, 1);这种方式适合低速、短数据的场景。如果波特率115200,每字节约87us进一次中断,接收100字节就要进100次中断,CPU开销不小。
5.2 DMA加空闲中断的方案
更高效的做法是DMA搬运加空闲中断检测帧结束。原理是:DMA负责把串口数据搬到缓冲区,不占用CPU;当总线空闲时触发空闲中断,此时DMA已经搬完一帧数据,你在中断里处理缓冲区即可。
#define RX_BUFFER_SIZE 256 uint8_t rx_buffer[RX_BUFFER_SIZE]; volatile uint16_t rx_len = 0; // 初始化 HAL_UART_Receive_DMA(&huart1, rx_buffer, RX_BUFFER_SIZE); __HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE); // 使能空闲中断 // 空闲中断处理 void USART1_IRQHandler(void) { if (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_IDLE)) { __HAL_UART_CLEAR_IDLEFLAG(&huart1); HAL_UART_DMAStop(&huart1); rx_len = RX_BUFFER_SIZE - __HAL_DMA_GET_COUNTER(&hdma_usart1_rx); // 处理rx_buffer中的rx_len个字节 HAL_UART_Receive_DMA(&huart1, rx_buffer, RX_BUFFER_SIZE); // 重启DMA } HAL_UART_IRQHandler(&huart1); }这个方案的精髓在于:DMA搬运数据不需要CPU干预,空闲中断只在帧结束时触发一次。接收100字节和接收1字节,中断次数都是1次。
踩坑记录:
__HAL_UART_CLEAR_IDLEFLAG()必须放在HAL_UART_DMAStop()之前,否则空闲标志可能清不掉。另外,重启DMA之前要确保上一帧数据已经处理完,否则新数据会覆盖旧数据。我一般用一个环形缓冲区来解耦。
5.3 串口中断的优先级设置
串口中断优先级不宜过高。如果串口中断打断了定时器中断,可能导致定时器计数丢失。我一般把串口中断设为抢占2或3,定时器中断设为抢占1,这样定时器优先,串口数据稍微延迟一点处理没关系。
6. 中断优先级冲突与常见问题排查
6.1 中断进不去的排查思路
中断配置了但死活不进,按以下顺序排查:
- NVIC使能了吗?检查
HAL_NVIC_EnableIRQ()是否调用,对应ISER寄存器位是否置1。 - 优先级分组设了吗?没设分组的话,
HAL_NVIC_SetPriority()的参数解释可能不对。 - 外设中断使能了吗?比如串口要调
__HAL_UART_ENABLE_IT(),定时器要调HAL_TIM_Base_Start_IT()。 - 中断标志清了吗?如果标志没清,中断会反复触发或者被硬件忽略。
- IRQHandler名字写对了吗?启动文件里的中断向量名必须和你的函数名完全一致,大小写都不能错。
我遇到过一次,EXTI0_IRQHandler写成了EXTI0_Handler,编译链接都没报错,但中断就是不进。后来用调试器看向量表才发现问题。
6.2 中断嵌套导致的死锁
高优先级中断里调用了依赖低优先级中断的函数,就会死锁。最典型的是在中断里调HAL_Delay():
void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin) { HAL_Delay(10); // 如果这个中断优先级高于SysTick,死锁 }HAL_Delay()依赖SysTick中断更新计数,如果当前中断优先级比SysTick高,SysTick中断进不来,计数不更新,HAL_Delay()永远等不到。解决办法:要么不在中断里延时,要么把SysTick优先级设为最高(0)。
6.3 中断服务函数执行时间过长
中断里做浮点运算、打印日志、大量内存拷贝,都会导致其他中断被延迟。我见过有人在串口中断里用printf,结果每接收一个字节就阻塞几毫秒,整个系统卡死。
原则:中断里只做标志置位、数据搬运、简单判断。复杂逻辑放到主循环,用状态机处理。
6.4 常见问题速查表
| 现象 | 可能原因 | 解决方法 |
|---|---|---|
| 中断完全不进 | NVIC未使能 | 检查HAL_NVIC_EnableIRQ() |
| 中断反复触发 | 标志位未清除 | 在IRQHandler中清标志 |
| 中断只进一次 | 未重新使能中断 | 在回调中重新调用接收/启动函数 |
| 高优先级中断卡死 | 调用了依赖低优先级中断的函数 | 移除中断中的阻塞调用 |
| 数据错乱 | 中断中变量未加volatile | 共享变量加volatile修饰 |
| 中断响应慢 | 中断执行时间过长 | 精简中断逻辑,耗时操作放主循环 |
独家技巧:用调试器查看NVIC的IABR寄存器可以知道哪些中断正在挂起,查看ISER可以知道哪些中断已使能。这两个寄存器配合看,能快速定位中断配置问题。
7. HAL库中断处理的底层逻辑与移植要点
7.1 HAL库的中断处理流程
HAL库的中断处理遵循一个固定模式:IRQHandler -> 判断标志 -> 清除标志 -> 调用回调。以定时器为例:
void TIM2_IRQHandler(void) { HAL_TIM_IRQHandler(&htim2); } // HAL_TIM_IRQHandler内部 if (__HAL_TIM_GET_FLAG(htim, TIM_FLAG_UPDATE) != RESET) { if (__HAL_TIM_GET_IT_SOURCE(htim, TIM_IT_UPDATE) != RESET) { __HAL_TIM_CLEAR_IT(htim, TIM_IT_UPDATE); HAL_TIM_PeriodElapsedCallback(htim); } }这种设计的好处是标志判断和清除由HAL库统一处理,你只需要关注回调函数。但缺点是每次中断都要经过一层函数调用,有一定开销。如果对性能要求极高,可以直接操作寄存器。
7.2 从标准库移植到HAL库的注意事项
很多老项目用标准库,移植到HAL库时中断部分需要改:
- 标准库的
NVIC_Init()换成HAL_NVIC_SetPriority()加HAL_NVIC_EnableIRQ() - 标准库的
EXTI_Init()换成HAL_GPIO_Init()配置GPIO_MODE_IT_xxx - 中断服务函数里的逻辑移到回调函数
- 标志清除用
__HAL_xxx_CLEAR_IT()宏
移植时最容易漏的是时钟使能。标准库用RCC_APB2PeriphClockCmd(),HAL库用__HAL_RCC_GPIOA_CLK_ENABLE()。漏了时钟使能,GPIO配置不生效,中断自然进不去。
7.3 中断向量表的理解
STM32的启动文件里定义了中断向量表,每个中断入口占4字节,存放中断服务函数的地址。复位时,CPU从向量表取出栈顶地址和复位处理函数地址。中断触发时,CPU根据中断号查表跳转。
HAL库通过HAL_NVIC_SetVector()可以在运行时修改向量表,但一般用不到。理解向量表的意义在于:中断服务函数的名字必须和启动文件里定义的一致,否则链接器找不到入口,中断触发时跳转到默认的死循环。
8. 几个实际项目中的中断使用经验
8.1 编码器测速中的定时器中断
用定时器编码器模式读取电机转速时,我一般用另一个定时器每100ms中断一次,读取编码器计数并清零。这样既能测速,又不会因为频繁读取影响编码器计数。
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM3) // 100ms定时器 { int16_t encoder_count = (int16_t)__HAL_TIM_GET_COUNTER(&htim2); __HAL_TIM_SET_COUNTER(&htim2, 0); // 计算转速 } }注意编码器定时器不要开中断,只开100ms定时器的中断。编码器计数由硬件自动完成,不需要CPU干预。
8.2 中断中的原子操作
主循环和中断共享的变量,读写时要注意原子性。比如主循环在读取一个32位变量,中断里修改了这个变量,可能读到一半新一半旧的值。解决办法:
- 简单变量加
volatile,确保每次从内存读取 - 复杂结构体用临界区保护:
__disable_irq()和__enable_irq() - 或者用双缓冲区,中断写一个,主循环读另一个
我一般用volatile加临界区,简单可靠。
8.3 中断优先级的实际分配建议
根据项目经验,给一个优先级分配参考:
| 中断源 | 抢占优先级 | 理由 |
|---|---|---|
| SysTick | 0 | 系统时基,必须最高 |
| 电机控制PWM | 1 | 实时性要求高 |
| 定时器采样 | 2 | 周期性任务 |
| 串口接收 | 3 | 数据可缓冲,延迟容忍度高 |
| 按键EXTI | 3 | 人机交互,延迟无所谓 |
这个分配不是绝对的,根据项目实时性要求调整。核心原则:越不能延迟的,优先级越高。
8.4 调试中断的实用技巧
- 在中断入口翻转一个GPIO,用示波器看波形,能直观看到中断频率和执行时间
- 用调试器的断点功能,在中断服务函数里设断点,看是否触发
- 如果中断触发太频繁导致无法调试,先用定时器或计数器统计中断次数,再分析
volatile变量在调试器里看值可能不准,因为编译器优化了读取,可以在watch窗口强制刷新
中断这东西,理论看再多不如实际调一次。我刚开始学的时候,一个按键中断调了一下午,最后发现是GPIO时钟没使能。后来养成习惯,配置外设前先检查时钟,配置中断后先用调试器看NVIC寄存器,确认使能了再往下写逻辑。这些经验文档里不会写,但实际项目中能省很多时间。