
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_004116NVIC_PRIORITYGROUP_11328NVIC_PRIORITYGROUP_22244NVIC_PRIORITYGROUP_33182NVIC_PRIORITYGROUP_440161我一般推荐用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_IRQnTIM2是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 中断优先级的实际分配建议根据项目经验给一个优先级分配参考中断源抢占优先级理由SysTick0系统时基必须最高电机控制PWM1实时性要求高定时器采样2周期性任务串口接收3数据可缓冲延迟容忍度高按键EXTI3人机交互延迟无所谓这个分配不是绝对的根据项目实时性要求调整。核心原则越不能延迟的优先级越高。8.4 调试中断的实用技巧在中断入口翻转一个GPIO用示波器看波形能直观看到中断频率和执行时间用调试器的断点功能在中断服务函数里设断点看是否触发如果中断触发太频繁导致无法调试先用定时器或计数器统计中断次数再分析volatile变量在调试器里看值可能不准因为编译器优化了读取可以在watch窗口强制刷新中断这东西理论看再多不如实际调一次。我刚开始学的时候一个按键中断调了一下午最后发现是GPIO时钟没使能。后来养成习惯配置外设前先检查时钟配置中断后先用调试器看NVIC寄存器确认使能了再往下写逻辑。这些经验文档里不会写但实际项目中能省很多时间。