从轮询到中断
上篇博客,我们用串口的轮询模式实现了电脑控制 LED。当时的代码长这样:
1 | while (1) |
这里存在一个问题:在 HAL_UART_Receive 等数据的时候,CPU 在干什么?
答案是——在空转查询,一直在问”数据到了吗?到了吗?到了吗?”
这就好比你等外卖:
- 轮询模式:每5秒跑去门口看一眼,啥也干不了,饭来了才回去吃
- 中断模式:装个门铃,该干嘛干嘛,门铃响了再去拿
显然,中断模式高效得多。这篇就来把上一版的轮询代码改成中断模式。
轮询模式的底层机制(为什么它”堵”)
先看看轮询模式底层是怎么工作的,理解清楚了才知道中断模式解决了什么问题。
每个 STM32 串口内部都有这么几个核心部件:
1 | ┌──────────────────┐ |
轮询发送的过程
调用 HAL_UART_Transmit 后:
1 | 1. CPU 把第一个字节写入"发送数据寄存器" |
轮询接收的过程
调用 HAL_UART_Receive 后:
1 | 1. RX 脚收到电平信号,存入"接收移位寄存器" |
关键问题:不管是发送还是接收,CPU 全程都在主动查询,一圈一圈地轮询寄存器状态。这段时间里 CPU 被完全占用了,干不了别的事。这就是阻塞(Blocking)。
中断模式:让”门铃”代替”傻等”
中断模式的思路完全不同:
1 | 轮询模式: |
区别在哪?CPU 不再主动查询,而是由硬件在数据就绪时通知 CPU。 这个”通知”就是中断。
具体来说,中断模式下,每发送完一个字节或每接收到一个字节,串口硬件都会触发一次中断,把 CPU “叫回来”处理下一个字节。HAL 库在中断处理函数里把这些事都做好了,我们只需要:
- 在 CubeMX 中开启串口中断
- 把
HAL_UART_Receive换成HAL_UART_Receive_IT - 把
HAL_UART_Transmit换成HAL_UART_Transmit_IT - 把处理数据的逻辑写到回调函数
HAL_UART_RxCpltCallback里
CubeMX 配置变化
相比上一篇的 CubeMX 配置,只多了一步:
开启 USART2 中断
在 NVIC Settings 标签页中,勾选 USART2 global interrupt 的 Enabled 复选框:
其他所有配置(波特率、引脚、LED 等)和上一篇完全一样,不需要改动。
这一步告诉 NVIC(嵌套向量中断控制器):”当 USART2 触发中断时,CPU 要去响应它。”
保存并重新生成代码。你会发现 CubeMX 自动做了几件事:
- 在
stm32f1xx_it.c中生成USART2_IRQHandler()函数 - 在
stm32f1xx_hal_msp.c中添加HAL_NVIC_SetPriority和HAL_NVIC_EnableIRQ - 这些不需要手动写,CubeMX 全包了
VS Code 中修改核心代码
和上一篇的代码相比,改动集中在四个方面。
改动一:接收数组变成全局变量
原来的 receiveData 定义在 main() 内部(局部变量),现在要提到外面变成全局变量,因为回调函数里也要用它。
1 | /* USER CODE BEGIN PV */ |
为什么? 局部变量只在
main()函数内有效,而回调函数HAL_UART_RxCpltCallback在main()外部,访问不到main()里的局部变量。提升为全局变量后,整个文件都能访问它。
改动二:main() 中只需启动一次中断接收
main() 的初始化部分(USER CODE BEGIN 2)中,原来的局部变量定义被替换为启动中断接收:
1 | /* USER CODE BEGIN 2 */ |
注意这里和轮询模式的区别:
| 对比 | 轮询模式 | 中断模式 |
|---|---|---|
| 函数 | HAL_UART_Receive |
HAL_UART_Receive_IT |
| 放在哪 | while(1) 循环里 |
放在循环前面,只执行一次 |
| 超时参数 | 有(第4个参数) | 不需要(中断模式不阻塞) |
改动三:while(1) 循环变空了
原来的 while 循环里塞满了接收、发送、控制 LED 的代码,现在全部清空:
1 | /* USER CODE BEGIN WHILE */ |
轮询模式:while 循环是”主动干活”的地方,不停地接收→处理→发送。
中断模式:while 循环变成了”后台”,可以放一些不紧急的任务。串口数据到了,中断自然会打断 while 循环,处理完再回来。
改动四:核心逻辑移到回调函数
真正的变化在这里——所有串口数据的处理逻辑,从 while 循环搬到了回调函数中:
1 | /* USER CODE BEGIN 0 */ |
第4步非常关键——为什么要在回调函数末尾再次调用 HAL_UART_Receive_IT?
中断接收是”一锤子买卖”:调用一次 HAL_UART_Receive_IT,它接收完指定长度的数据后,就会停下来。如果不再次调用,下一次串口发数据来,就没人在乎了。
这就好比你按了一次门铃——门铃是一次性的。如果下次来人,找不到门铃可以通知你,你就不知道。
所以在回调函数的最后,必须**重新”装好门铃”**:调用 HAL_UART_Receive_IT 启动下一次中断接收。
函数对比总结
| 功能 | 轮询模式 | 中断模式 |
|---|---|---|
| 发送 | HAL_UART_Transmit(&huart2, buf, len, timeout) |
HAL_UART_Transmit_IT(&huart2, buf, len) |
| 接收 | HAL_UART_Receive(&huart2, buf, len, timeout) |
HAL_UART_Receive_IT(&huart2, buf, len) |
| 超时参数 | ✅ 有 | ❌ 不需要 |
| CPU 占用 | 一直占用,直到完成 | 仅中断时短暂占用 |
| 数据在哪处理 | while 循环中 | 回调函数中 |
补充:什么是回调函数?
“回调函数”这个名字听起来挺唬人,但本质上就是个普通的函数。它特殊的地方在于——不是你在代码里主动调用的,而是 HAL 库在特定事件发生时自动帮你调用的。
一个生活中的例子
你去4S店修车:
1 | 你:师傅,车修好了打电话给我。(留下电话号码) |
这里的”打电话”就是回调——你把电话号码留给4S店,4S店在车修好时主动打给你。
对应到代码里:
1 | 你:HAL_UART_Receive_IT(&huart2, ...) ← "数据收到了叫我" |
关键点
| 概念 | 解释 |
|---|---|
| 你把函数地址告诉 HAL 库 | 你定义了 HAL_UART_RxCpltCallback 这个函数(相当于留了电话号码) |
| HAL 库在适当时机调用它 | 收到指定字节数后,HAL 库自动调你这个函数(相当于4S店打电话给你) |
| 你不需要知道它何时被调用 | 你只需要把”收到数据后要做什么”写在回调函数里就行 |
和轮询模式的对比
1 | // 轮询模式:你主动去查 |
轮询模式是你主动去问,中断模式是你留下联系方式等着被通知。回调函数就是那个”联系方式”。
解读新增的初始化代码 —— NVIC 与中断向量
CubeMX 开启 USART2 中断后,生成了三段和中断相关的代码。
1. stm32f1xx_it.c —— 中断服务函数
1 | void USART2_IRQHandler(void) |
这个函数是中断向量表中 USART2 对应的中断服务函数。当串口触发中断时,CPU 暂停当前任务,跳到这里执行。
函数体只有一行:调用了 HAL_UART_IRQHandler(&huart2)。这是 HAL 库提供的中断分发函数,它的内部做了判断逻辑,然后把不同的情况分发给对应的回调函数。
关键问题是:它怎么知道我收到几个字节了?又是什么时候调用的回调函数?
要回答这个问题,得翻开 HAL 库源码看看。在vscode中,把鼠标停在 HAL_UART_IRQHandler 上按右键,找到定义栏,点进去,就能看到它的实现(下面放了简化版):
1 | void HAL_UART_IRQHandler(UART_HandleTypeDef *huart) |
这里的关键是它检查了两个条件:
| 条件 | 含义 | 谁设置的 |
|---|---|---|
USART_SR_RXNE |
状态寄存器中的”接收数据寄存器非空”标志——串口硬件收到一个字节后自动置1 | ✅ 硬件自动 |
USART_CR1_RXNEIE |
控制寄存器中的”RXNE中断使能”位——我们调用 HAL_UART_Receive_IT 时由 HAL 库开启 |
✅ HAL_UART_Receive_IT 设置的 |
两个条件都满足时,调用 UART_Receive_IT(huart),把收到的这个字节读走。
那什么时候才调用回调函数呢? 这就进到 UART_Receive_IT 里面看了:
1 | static HAL_StatusTypeDef UART_Receive_IT(UART_HandleTypeDef *huart) |
重点全在这个 RxXferCount 上。
回顾我们调用的 HAL_UART_Receive_IT(&huart2, receiveData, 2)——第三个参数填的是 2。HAL 库会把 2 存到 huart->RxXferCount 里:
1 | RxXferCount = 2 → 收到第1个字节,减为1 → 收到第2个字节,减为0 → 回调! |
所以整个过程的时间线是这样的:
1 | main() 中: |
这就是”中断模式下,收到指定字节数后才触发回调”的完整链路:
1 | USART2_IRQHandler() ← 中断向量,硬件触发 |
和之前学的外部中断做个对比,路径非常相似:
| 外部中断 | 串口中断 |
|---|---|
EXTI15_10_IRQHandler() |
USART2_IRQHandler() |
HAL_GPIO_EXTI_IRQHandler() |
HAL_UART_IRQHandler() |
HAL_GPIO_EXTI_Callback() |
HAL_UART_RxCpltCallback() ← 你的代码 |
2. stm32f1xx_hal_msp.c —— NVIC 配置
CubeMX 在 HAL_UART_MspInit 中添加了两行新代码:
1 | /* USART2 interrupt Init */ |
这两行的作用:
| 函数 | 作用 |
|---|---|
HAL_NVIC_SetPriority(中断号, 抢占优先级, 子优先级) |
设置这个中断的优先级。数字越小优先级越高。0, 0 就是最高优先级 |
HAL_NVIC_EnableIRQ(中断号) |
使能中断——告诉 NVIC,这个外设的中断请求可以被响应了 |
如果不调用 HAL_NVIC_EnableIRQ,即使外设触发了中断,NVIC 也不会把它发给 CPU——中断就被”屏蔽”了。
3. 回调函数的 __weak 弱定义
如果你在 HAL 库源码里搜索 HAL_UART_RxCpltCallback,会看到这样的定义:
1 | __weak void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) |
__weak 是弱定义——它像一个”占位符”。编译器看到弱定义后,如果在其他地方找到了同名但非弱定义的函数,就会用那个新函数覆盖掉弱定义。
所以,只需要在 main.c 中定义一个同名的 HAL_UART_RxCpltCallback,编译器就会自动使用我们的版本,HAL 库的中断分发逻辑就会在接收到数据时调用我们的函数。
如果你不在 main.c 里定义这个函数,HAL 库的弱定义版本就会被使用——它啥也不干,所以数据收到了也没反应。
编译 & 烧录 & 测试
- VS Code 底部工具栏点击齿轮图标 → 编译
- 连接 ST-Link,点击运行和调试 → 选择 STM32Cube:STM32 Launch STLink GDB Server
- 点击继续按钮
用串口助手测试,功能完全一样:
| 发送 | 效果 |
|---|---|
R1 |
🔴 红色灯亮,收到回显 R1 |
R0 |
🔴 红色灯灭 |
G1 |
🟢 绿色灯亮 |
B1 |
🔵 蓝色灯亮 |
但背后的机制完全不同了——发 R1 后,CPU 不再是傻等着下一条指令,而是可以去做其他事了。下次数据到了,中断会把它叫回来。
由于此处效果和上一篇完全一样,此处就放上之前的那个视频链接
https://www.bilibili.com/video/BV1NV3y6YEYv/?vd_source=5a111e8d49f70e532d36ddb73be3d0aa
轮询模式与中断模式对比
| 对比维度 | 轮询模式 | 中断模式 |
|---|---|---|
| CPU 利用率 | ❌ 低——一直在查询,浪费 CPU | ✅ 高——只在数据到达时短暂处理 |
| 代码位置 | while 循环中,顺序执行 | 回调函数中,事件驱动 |
| 多任务能力 | ❌ 差——被串口阻塞时啥也干不了 | ✅ 好——while 里可以做其他事 |
| 代码复杂度 | ✅ 简单直观 | ❌ 稍复杂(需理解中断和回调) |
| 适用场景 | 简单 demo、只有串口任务的项目 | 多任务、需要高效利用 CPU 的项目 |
完整代码结构
1 | 全局变量区: |
总结
这篇博客的核心变化是:
把代码从 while 循环搬到回调函数,把 HAL_UART_Receive 换成 HAL_UART_Receive_IT。
学了这篇,STM32 就具备了”事件驱动”的能力——外设不再需要 CPU 不停地轮询,而是在需要的时候主动通知 CPU。这是从简单裸机编程走向复杂嵌入式开发的必经之路。
下一篇将学习如何接收不定长度的数据,以及如何使用 DMA 进一步解放 CPU——让数据搬运也不占用 CPU 时间。
参考资源
- keysking STM32 教程(B站)
- 波特律动文档站
- 上一篇:STM32串口通信——用电脑控制三色LED
- STM32外部中断深入理解(回调函数机制与中断优先级的概念相通)
