STM32中断服务函数编写实战:在MDK中避开99%的坑
你有没有遇到过这种情况——明明配置好了串口,也开启了中断,可数据就是收不到?或者定时器中断一进来,系统就卡死不动?更离谱的是,改了一个函数名,整个项目编译通过却完全不进中断……
别怀疑人生。这些问题,90%都出在中断服务函数(ISR)的细节处理上。
今天我们就以Keil MDK 为平台,从工程实践出发,彻底讲清楚 STM32 中断到底该怎么写、怎么配、怎么调。不是照搬手册,而是告诉你哪些地方最容易“翻车”,以及老手是怎么绕过去的。
为什么你的中断没响应?先看这三步对不对
很多初学者写完USART1_IRQHandler后满怀期待地下载程序,结果发现根本进不去。这时候别急着查代码逻辑,先确认下面三个基础环节是否全部到位:
- 外设时钟开了吗?
- NVIC中断使能了吗?
- 函数名字拼对了吗?
尤其是第三个——看似简单,却是新手最常踩的雷。
比如把TIM2_IRQHandler错写成Timer2_IRQHandler或tim2_IRQHandler,编译器不会报错,链接器也不会警告,但中断永远不会触发。因为 Cortex-M 的中断跳转是靠精确匹配函数名与向量表条目实现的。
而这个函数名,来源于 ST 官方启动文件中的弱符号声明。我们来看一眼 MDK 里的startup_stm32f103xb.s是怎么定义的:
WEAK USART1_IRQHandler HANDLER USART1_HandlerProc USART1_IRQHandler PROC EXPORT USART1_IRQHandler [WEAK] B . ENDP注意这里的EXPORT USART1_IRQHandler [WEAK]—— 它表示这是一个“弱符号”,你可以用同名的 C 函数去覆盖它。只要你在.c文件里定义了void USART1_IRQHandler(void),链接器就会自动把你写的版本填进中断向量表。
但如果拼错了?那链接器就还是用默认的那个空循环B .,于是你就看着串口数据来了,就是进不了处理函数。
✅秘籍:打开对应型号的启动文件,复制你要使用的中断名称,原封不动粘贴到你的 C 文件中。
中断向量表不是摆设,它是系统的“电话总机”
你可以把中断向量表理解为一个公司的总机号码簿。当某个部门(比如 UART1)有事要汇报时,CPU 就去查这个号码簿,找到对应的分机号(即函数地址),然后拨过去。
STM32 的中断向量表通常位于 Flash 起始地址(如0x08000000),由启动代码初始化。它的结构长这样:
| 地址偏移 | 内容 |
|---|---|
| 0x0000 | MSP 初始值(栈顶) |
| 0x0004 | Reset_Handler 地址 |
| 0x0008 | NMI_Handler 地址 |
| … | … |
| 0x005C | USART1_IRQHandler 地址 |
每一条都是一个函数指针。这些地址最终会被链接器填充为你实际定义的函数位置。
所以,只要你写了正确命名的 ISR,并且该中断被 NVIC 使能,硬件就能精准跳转过来执行。
但这里有个隐藏知识点:如果你启用了中断重映射(例如将向量表拷贝到 SRAM 并修改 VTOR 寄存器),那你必须确保新的向量表也被正确初始化,否则即使函数写对了,也可能跳不过去。
⚠️坑点提醒:使用动态内存或 Bootloader 时务必检查 VTOR 设置!
NVIC 不只是开关,它是中断世界的“交通指挥官”
很多人以为HAL_NVIC_EnableIRQ()就是给中断按了个电源键,其实远不止如此。
嵌套向量中断控制器(NVIC)真正厉害的地方在于它的优先级调度机制。它可以做到:
- 高优先级中断能打断低优先级中断(抢占)
- 相同优先级的中断排队执行(不可抢占)
- 每个中断可独立设置抢占优先级和子优先级
这就像是高速公路收费站:有的车走 VIP 通道(高抢占优先级),哪怕前面有人缴费也能强行插队;普通车辆则按顺序来。
优先级分组决定“话语权”大小
ARM Cortex-M 支持多种优先级分组方式,通过SCB->AIRCR[PRIGROUP]设置。常见的有:
| 分组模式 | 抢占位数 | 子优先级位数 | 可设等级 |
|---|---|---|---|
| NVIC_PRIORITYGROUP_2 | 2 | 2 | 4 级抢占 |
| NVIC_PRIORITYGROUP_4 | 4 | 0 | 16 级抢占 |
举个例子:
HAL_NVIC_SetPriorityGrouping(NVIC_PRIORITYGROUP_4); // 定时器用于关键控制,给最高优先级 HAL_NVIC_SetPriority(TIM2_IRQn, 0, 0); // 串口中断次要一些 HAL_NVIC_SetPriority(USART1_IRQn, 5, 0); HAL_NVIC_EnableIRQ(TIM2_IRQn); HAL_NVIC_EnableIRQ(USART1_IRQn);这样配置后,即便正在处理串口接收,一旦 TIM2 溢出,CPU 会立刻暂停当前 ISR,转而去执行 TIM2 的任务。等它完成后,再回到原来的串口中断继续执行。
📌经验法则:时间敏感型任务(如电机控制、ADC同步采样)应赋予较高抢占优先级;通信类中断可适当降低。
写 ISR 的黄金法则:短、快、稳
我见过太多人直接在中断里做字符串解析、调printf打印日志,甚至延时几毫秒……结果就是系统频繁重启、响应迟钝、数据丢失。
记住一句话:ISR 只负责“通知”和“搬运”,不负责“思考”和“决策”。
正确做法:置标志 + 主循环处理
推荐采用“中断只打标记,主循环干活”的模式:
volatile uint8_t uart_data_ready = 0; uint8_t rx_buffer[64]; uint32_t head = 0; void USART1_IRQHandler(void) { if (LL_USART_IsActiveFlag_RXNE(USART1)) { rx_buffer[head++] = LL_USART_ReceiveData8(USART1); // 只设置标志,不做复杂操作 if (is_frame_complete(rx_buffer)) { uart_data_ready = 1; } } } int main(void) { HAL_Init(); SystemClock_Config(); MX_USART1_UART_Init(); while (1) { if (uart_data_ready) { parse_command_and_execute(rx_buffer); uart_data_ready = 0; // 清除标志 } } }这样做有几个好处:
- ISR 执行时间极短,不影响其他中断响应
- 主循环可以安全调用库函数、进行复杂运算
- 避免在中断上下文中访问非原子变量导致数据紊乱
必须清除中断标志!否则无限循环
这是另一个高频致命错误。
比如 EXTI 外部中断:
void EXTI0_IRQHandler(void) { if (__HAL_GPIO_EXTI_GET_FLAG(GPIO_PIN_0)) { handle_button(); // 忘记清标志 → 下次中断立刻再次触发! } }由于 GPIO 引脚电平未变或机械抖动持续存在,中断条件一直满足,导致 CPU 陷入“刚退出中断又进来”的死循环。
正确写法永远是:先读状态,再处理,最后清标志。
void EXTI0_IRQHandler(void) { if (__HAL_GPIO_EXTI_GET_IT(GPIO_PIN_0)) { handle_button_press(); __HAL_GPIO_EXTI_CLEAR_IT(GPIO_PIN_0); // 必须清除挂起位 } }🔍调试技巧:如果发现某中断频繁进入,打开寄存器视图查看
EXTI_PR是否始终为 1。
常见陷阱与避坑指南
❌ 陷阱1:在 ISR 中调用阻塞函数
void ADC_IRQHandler(void) { float v = HAL_ADC_GetValue(&hadc1) * 3.3 / 4095; printf("Voltage: %.2fV\n", v); // 危险!printf 可能阻塞 }printf默认输出到半主机模式,在中断中会导致 HardFault。即使重定向到串口,也可能因缓冲区满而阻塞。
✅ 解决方案:使用环形缓冲区 + DMA + 主循环发送,或者仅在中断中记录原始数据。
❌ 陷阱2:共享资源未加保护
多个 ISR 共享全局变量时,若无保护措施,极易引发数据冲突。
volatile int sensor_value; void TIM2_IRQHandler() { sensor_value = read_temp(); } void USART1_IRQHandler() { send_data(sensor_value); } // 读取可能被中断打断✅ 推荐做法:
- 使用__disable_irq()/__enable_irq()临时关闭中断
- 或改用原子变量(Cortex-M 提供 LDREX/STREX 指令)
❌ 陷阱3:堆栈不够用
深度中断嵌套会消耗大量栈空间。假设每个 ISR 保存上下文占用 0.5KB,5 层嵌套就需要至少 2.5KB 栈空间。
检查startup_stm32f103xb.s中的定义:
Stack_Size EQU 0x00000400 ; 默认 1KB 可能不够!根据项目复杂度调整至0x00000800(2KB)以上,并在调试时观察栈使用情况。
工程实战:构建可靠的串口命令控制系统
设想一个典型应用场景:STM32 接收上位机发来的指令,控制设备动作并回传状态。
架构设计
[PC] → USART RX → 触发 RXNE 中断 → 缓存数据 → 收到帧尾 → 置 flag ↓ 主循环检测 flag → 解析命令 → 执行动作 ↓ 组包回复 → 启动 TXE 中断 → 分段发送关键实现
#define CMD_BUF_SIZE 64 volatile uint8_t cmd_received = 0; uint8_t cmd_buf[CMD_BUF_SIZE]; uint32_t cmd_len = 0; void USART1_IRQHandler(void) { if (LL_USART_IsActiveFlag_RXNE(USART1)) { uint8_t data = LL_USART_ReceiveData8(USART1); if (cmd_len < CMD_BUF_SIZE - 1) cmd_buf[cmd_len++] = data; // 帧结束符 '\n' if (data == '\n') { cmd_buf[cmd_len] = '\0'; cmd_received = 1; } } // 发送空中断(用于连续发送) if (LL_USART_IsActiveFlag_TXE(USART1)) { static uint32_t tx_index; if (tx_index < response_len) { LL_USART_TransmitData8(USART1, response_buf[tx_index++]); } else { LL_USART_DisableIT_TXE(USART1); // 关闭中断 tx_index = 0; } } }主循环只需轮询cmd_received标志即可,完全解耦。
最后的忠告:别让中断毁了你的实时性
中断本是为了提升实时性,但如果滥用或设计不当,反而会让系统变得更脆弱。
给开发者的五条建议:
- 永远不要在 ISR 中做耗时操作—— 数据搬运交给 DMA,处理留给主循环。
- 优先级不是越高越好—— 过多高优先级中断会导致低优先级任务“饿死”。
- 命名一定要严格对照启动文件—— 建议建立自己的中断清单表。
- 善用 volatile 关键字—— 所有 ISR 修改的变量都要加
volatile。 - 定期测量中断响应时间—— 用示波器抓 GPIO 翻转,验证是否满足需求。
如果你现在正卡在一个“明明配置了却进不了中断”的问题上,不妨停下来问自己:
“我的函数名真的和启动文件里的一模一样吗?”
“我在处理完之后,真的把中断标志清掉了吗?”
“这个操作放在主循环里会不会更好?”
往往答案就在其中。
欢迎在评论区分享你遇到过的最诡异的中断 bug,我们一起排雷拆弹。