从中断到 DMA——CPU 还是太忙了
上篇博客,我们用串口中断模式解决了 CPU “傻等”的问题。回顾一下当时的代码结构:
1 | HAL_UART_Receive_IT(&huart2, receiveData, 2); // 启动中断接收 |
CPU 确实不用空等了,但每次收发一个字节,CPU 都要进一次中断:
1 | 发送时: |
虽然每次中断处理时间很短,但数据量大的时候,CPU 频繁地在”正常任务”和”中断搬运”之间切换——时间都花在来回奔波上了。
这就好比:
1 | 轮询模式:CPU 站在门口等外卖,啥也干不了 |
DMA(Direct Memory Access,直接内存访问) 就是这个”助理”——它是一个独立于 CPU 的硬件模块,可以在不经过 CPU 的情况下,在外设寄存器和内存之间自动搬运数据。
DMA 原理
传统的数据搬运路径
先看看没有 DMA 时,串口接收的完整链路:
1 | ┌─────────────────────────────────────┐ |
每收到一个字节,CPU 都要亲自从 USART2->DR 读到内存。发的时候反过来,也是要亲历亲为。
有了 DMA 之后
1 | ┌─────────────────────────────────────┐ |
CPU 只需要做一件事:**告诉 DMA “把数据从 USART2->DR 搬到 receiveData,搬 N 次”**。DMA 就会在每次串口收到数据时自动搬走,全部搬完后再发一个中断通知 CPU。
DMA 的内部结构(简化版)
STM32F103 有 两个 DMA 控制器(DMA1 和 DMA2),每个控制器有 7 个通道。每个通道可以服务一个外设的请求:
1 | DMA1 |
DMA 通道需要的配置信息:
| 配置项 | 含义 | 类比 |
|---|---|---|
| 源地址 | 从哪里搬数据 | 仓库的位置 |
| 目标地址 | 搬到哪去 | 货架的位置 |
| 传输方向 | 外设→内存 / 内存→外设 / 内存→内存 | 从仓库到货架,还是从货架到仓库 |
| 传输数量 | 搬多少个数据 | 要搬多少箱货 |
| 地址自增 | 搬完一个后地址要不要+1 | 搬完一箱后,是在原地拿下一箱,还是往前走一步 |
在串口场景中
发送(内存 → 外设):
- 源地址:
receiveData数组(每次自增,因为要依次取数据) - 目标地址:
USART2->DR寄存器(不自增,因为每次都是写到同一个寄存器) - 方向:内存 → 外设
接收(外设 → 内存):
- 源地址:
USART2->DR寄存器(不自增,因为每次都是从这个寄存器读) - 目标地址:
receiveData数组(每次自增,因为要依次存到数组里) - 方向:外设 → 内存
CubeMX 配置
基于上一篇中断模式的工程(Serial),只新增 DMA 配置,其他不变。
添加 DMA 通道
在 Connectivity → USART2 中,切到 DMA Settings 标签页:

点击 Add 按钮,添加两个 DMA 通道:
第一个:USART2_TX(发送)
| 参数 | 值 |
|---|---|
| Channel | DMA1_Channel7(CubeMX 自动分配) |
| Direction | Memory To Peripheral |
| Priority | Low |
| Address Increment | 勾选 Memory(内存地址自增) |
| Data Width | Byte(每次搬 1 字节) |
| Mode | Normal |
第二个:USART2_RX(接收)
| 参数 | 值 |
|---|---|
| Channel | DMA1_Channel6(CubeMX 自动分配) |
| Direction | Peripheral To Memory |
| Priority | Low |
| Address Increment | 勾选 Memory(内存地址自增) |
| Data Width | Byte(每次搬 1 字节) |
| Mode | Normal |
关于 Mode:Normal vs Circular
这里 Mode 选了 Normal,意思是 DMA 搬运完指定字节数后就停止。
还有一种 Circular(循环模式),DMA 会在搬完后自动从头开始继续搬——适合连续、不间断的数据流场景(比如 ADC 连续采样)。这里我们只是收一条指令,Normal 就够了。
关于地址自增
为什么只有 Memory 勾选了地址自增,Peripheral 不勾选?
1 | 发送时(Memory → Peripheral): |
别忘了 USART2 的中断
切到 NVIC Settings 标签页,确保 USART2 global interrupt 仍然是勾选状态——因为 DMA 传输完成后,还是要通过中断来通知 CPU 的。
这里的中断是 DMA 传输完成中断,不是串口的中断。但 CubeMX 会自动把 NVIC 也配好,我们只需要确保 USART2 的中断是开启的就行。
右上角点击GENERATE CODE,CubeMX 会重新生成代码。
VS Code 中修改核心代码
打开 main.c,和上篇中断模式的代码对比,改动集中在以下几个方面。
main.c 完整代码
1 | /* USER CODE BEGIN PV */ |
改动一:接收数组变大
1 | // 之前(中断模式):uint8_t receiveData[2]; // 固定2字节 |
因为不定长数据不知道会收多少字节,先把缓冲区设大。sizeof(receiveData) 就是 50,作为安全上限。
改动二:发送/接收函数从 _IT 变为 _DMA
之前的函数:
1 | HAL_UART_Transmit_IT(&huart2, data, len); // 中断发送 |
现在的函数:
1 | HAL_UART_Transmit_DMA(&huart2, data, len); // DMA发送 |
参数完全一样,只是把后缀 _IT 换成 _DMA。对使用者来说,API 基本不变,底层的搬运机制却完全不同了。
改动三:接收不定长数据——两个新函数
这是这篇最关键的新知识。
之前的串口接收(不管是轮询、中断还是 DMA 模式),都需要预先指定接收的长度:
1 | HAL_UART_Receive_DMA(&huart2, receiveData, 2); // 必须收够2字节 |
那如果不知道对方会发多少字节呢?比如电脑发一条任意长度的字符串?那就需要串口空闲中断(Idle Interrupt) 来帮忙。
串口空闲中断的原理
串口有一个特殊的中断——空闲中断(IDLE Interrupt)。它的触发条件非常巧妙:
1 | RX 引脚电平变化: |
空闲中断不是在收到固定字节数时触发,而是在 RX 引脚从”忙碌”变为”空闲”时触发。 换句话说:
当串口收到一帧数据,然后对方停止发送了——RX 线变成空闲状态——硬件就触发空闲中断。
利用这个特性,我们可以实现:数据来了就收,对方停发了就知道收完了。
HAL 库封装好的函数
HAL 库提供了一个扩展函数组,把”接收不定长数据”这件事包装好了:
1 | // DMA模式的空闲中断接收(本篇使用的) |
函数名解析:
| 部分 | 含义 |
|---|---|
| HAL_UARTEx_ | Ex 代表 Extension(扩展),说明这不是标准 HAL_UART 系列,而是额外补充的 |
| ReceiveToIdle | 接收直到空闲——一直收数据,直到 RX 线空闲 |
| _DMA | 使用 DMA 方式搬运数据 |
第三个参数和普通接收不同:
1 | // 普通接收:第三个参数是"要收多少字节" |
这里填的是 sizeof(receiveData) = 50,意思是”最多收 50 字节,超过就不收了,防止数组越界”。
对应的回调函数变了——RxEventCallback
之前中断模式下,数据接收完毕后的回调函数是:
1 | void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) |
现在使用 ReceiveToIdle 系列函数后,对应的回调函数变成:
1 | void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size) |
两个重要区别:
| 对比 | RxCpltCallback |
RxEventCallback |
|---|---|---|
| 触发时机 | 收到指定字节数后 | RX线空闲时(不定长) |
| 参数 | 只有 huart |
多了一个 **Size**——本次实际接收了多少字节 |
| 来源 | HAL 库标准函数 |
HAL_UARTEx 扩展函数 |
为什么多了一个 Size 参数?
因为定长接收(RxCpltCallback)你已经知道是 2 字节,不需要额外说明。但不定长接收你事先不知道会收多少字节,所以硬件在触发回调时,把”实际收到了多少字节”通过 Size 参数告诉你。
1 | void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size) |
改动四:重启接收时要在回调里再次调用
和中断模式的 Receive_IT 一样,ReceiveToIdle_DMA 也是”一次性”的——触发回调后就停了。所以必须在回调函数末尾重新启动它:
1 | void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size) |
⚠️ 踩坑记录:DMA 传输过半中断
如果你只写上面这些代码,测试时可能会发现一个奇怪的现象——
发送短数据(比如 R1):一切正常,完美回显。
发送长数据(比如超过 25 字节):你会看到回显了两次!第一次回显了一半数据,第二次才回显完整数据。
这背后的”元凶”是 DMA 传输过半中断(Half-Transfer Interrupt,HT)。
为什么会触发两次?
回顾一下 HAL_UARTEx_ReceiveToIdle_DMA 的第二个参数:
1 | HAL_UARTEx_ReceiveToIdle_DMA(&huart2, receiveData, sizeof(receiveData)); |
DMA 的内部计数器设为 50(最大上限)。DMA 默认开启了一个叫传输过半中断的选项——当搬运的数量到达总数的一半(25 个) 时,也会触发一次中断。
所以如果你发送的数据超过了 25 字节:
1 | DMA 接收计数:0 → 1 → 2 → … → 25(触发 HT 中断!)→ 26 → … → 实际结束(空闲中断) |
RxEventCallback 被触发了两次:
- 第一次:DMA 收到 25 字节(过半中断触发)
- 第二次:RX 线空闲(真正的空闲中断触发)
这就导致了数据被重复处理。
解决方案:关闭 DMA 传输过半中断
在 HAL_UARTEx_ReceiveToIdle_DMA 之后,立刻关闭 DMA 的过半中断:
1 | __HAL_DMA_DISABLE_IT(&hdma_usart2_rx, DMA_IT_HT); |
这行代码的作用:
| 部分 | 含义 |
|---|---|
__HAL_DMA_DISABLE_IT |
关闭 DMA 的某个中断 |
&hdma_usart2_rx |
指定要操作的 DMA 通道——这里是 USART2 的 RX DMA 通道 |
DMA_IT_HT |
HT = Half-Transfer,传输过半中断 |
需要在两个地方都加上这行代码:
1 | // 1. main() 初始化时 |
为什么每次重启后都要关一次?
因为
HAL_UARTEx_ReceiveToIdle_DMA内部重新初始化 DMA 时,又会默认打开 HT 中断。所以每次调用后都要跟着关一次。
两个回调函数可以共存吗?
注意代码中有两个回调函数:
1 | void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) // 定长接收完成 |
它们可以共存。HAL 库内部根据调用的是哪个接收函数来决定触发哪一个:
| 你调用的接收函数 | 触发哪个回调 |
|---|---|
HAL_UART_Receive_DMA(&huart2, data, 2) |
HAL_UART_RxCpltCallback |
HAL_UARTEx_ReceiveToIdle_DMA(&huart2, data, 50) |
HAL_UARTEx_RxEventCallback |
在同一份代码中,只有一个会被用到。因为 main() 里只调用了一次 ReceiveToIdle_DMA,所以实际上只会触发 RxEventCallback。
这里保留了 RxCpltCallback 的完整代码,是为了方便以后切换到定长模式时直接启用。
代码逻辑总览
现在整个程序的控制流程:
1 | 程序启动(main): |
解读新增的初始化代码
1. MX_DMA_Init() —— DMA 初始化
CubeMX 新生成一个专门的 MX_DMA_Init 函数:
1 | static void MX_DMA_Init(void) |
| 行 | 代码 | 说明 |
|---|---|---|
| (1) | __HAL_RCC_DMA1_CLK_ENABLE() |
开启 DMA1 外设时钟。和 GPIO 一样,DMA 也需要时钟才能工作 |
| (2)(3) | DMA1_Channel6_IRQn |
通道6 的中断设置。前面 CubeMX 配置中,通道6分配给了 USART2_RX。所以这里使能的是接收完成后的 DMA 中断 |
| (4)(5) | DMA1_Channel7_IRQn |
通道7 的中断设置。分配给了 USART2_TX。这里使能的是发送完成后的 DMA 中断 |
这些中断在什么时候触发?当 DMA 完成了所有传输任务(搬运完指定数量的数据)后,会触发一个”传输完成中断”来通知 CPU。这个中断和我们之前学的串口中断不同:
| 中断 | 触发条件 | 谁触发 |
|---|---|---|
| USART2_IRQn | 串口收到/发送了一个字节 | 串口外设 |
| DMA1_Channel6/7_IRQn | DMA 搬运完了所有数据 | DMA 控制器 |
所以现在的完整中断路径是:
1 | 串口收到数据 → DMA自动搬走 → DMA搬完了 → DMA中断 → 回调函数 |
2. stm32f1xx_it.c 中的 DMA 中断服务函数
CubeMX 还会在 stm32f1xx_it.c 中生成两个中断服务函数:
1 | void DMA1_Channel6_IRQHandler(void) |
和之前串口中断类似,这里也是调用 HAL 库的中断分发函数 HAL_DMA_IRQHandler,它内部会判断中断类型(传输完成/传输过半/传输错误),然后调用对应的回调。
3. USART2 的 DMA 相关寄存器配置
DMA 通道的具体配置(源地址、目标地址、传输数量等),是在 HAL_UART_Init 内部调用 HAL_UART_MspInit 时,由 HAL 库自动写入 DMA 的寄存器。所以我们看不到显式的配置代码——CubeMX 帮我们藏在了 HAL 库底层。
但我们可以用 HAL_DMA_GetState() 等函数在调试时查看配置是否生效。
函数对比表
串口收发函数的三种模式对比
| 功能 | 轮询模式 | 中断模式 | DMA模式 |
|---|---|---|---|
| 发送 | HAL_UART_Transmit |
HAL_UART_Transmit_IT |
HAL_UART_Transmit_DMA |
| 接收(定长) | HAL_UART_Receive |
HAL_UART_Receive_IT |
HAL_UART_Receive_DMA |
| 接收(不定长) | HAL_UARTEx_ReceiveToIdle |
HAL_UARTEx_ReceiveToIdle_IT |
HAL_UARTEx_ReceiveToIdle_DMA |
| CPU参与度 | ❌ 全程占用 | 🔶 每字节中断一次 | ✅ 仅传输完成时中断 |
| 回调函数 | ❌ 无回调 | RxCpltCallback |
RxCpltCallback / RxEventCallback |
HAL_UARTEx_ReceiveToIdle_DMA 参数详解
| 参数 | 含义 | 本项目中填的值 |
|---|---|---|
| 串口句柄指针 | 操作哪个串口 | &huart2 |
| 数据缓冲区指针 | 收到的数据存到哪里 | receiveData |
| 最大接收长度 | 一次最多收多少字节(防止越界) | sizeof(receiveData) = 50 |
HAL_UARTEx_RxEventCallback 参数详解
| 参数 | 含义 |
|---|---|
| huart | 哪个串口触发了事件(多个串口共用一个回调时,用它区分) |
| Size | 本次实际接收了多少个字节——通过计算”DMA 还剩多少没搬”得出 |
__HAL_DMA_DISABLE_IT 宏详解
| 参数 | 含义 | 本项目中填的值 |
|---|---|---|
| DMA 通道句柄指针 | 操作哪个 DMA 通道 | &hdma_usart2_rx(USART2_RX 对应的 DMA 通道) |
| 中断标志位 | 要关闭哪个中断 | DMA_IT_HT(Half-Transfer,传输过半中断) |
编译 & 烧录 & 测试
- VS Code 底部工具栏点击齿轮图标 → 编译
- 连接 ST-Link,点击运行和调试 → 选择 STM32Cube:STM32 Launch STLink GDB Server
- 点击继续按钮
测试不定长接收
打开串口助手,在发送区输入任意长度的字符串:
| 测试 | 操作 | 预期效果 |
|---|---|---|
| 短数据发送 | 发送 R1 |
收到回显 R1,红色灯亮 |
| 长数据发送 | 发送 Hello STM32! This is a test. |
收到完整回显 Hello STM32! This is a test. |
| 连续发送 | 多次发送不同长度的数据 | 每次都能完整回显,不会丢数据 |
| HEX 发送 | 切换 Hex 模式,发送 52 31 |
收到 52 31,红色灯亮(52=R, 31=1) |
注意观察,无论发多少字节,STM32 都会原样回复——这是空闲中断在背后工作:检测到 RX 线空闲了就回调,把收到的数据回显。
效果展示视频:
https://www.bilibili.com/video/BV1zh3x6TEqR/?vd_source=5a111e8d49f70e532d36ddb73be3d0aa
验证 DMA 传输过半中断的影响(可选)
如果想亲眼看到那个 bug,可以注释掉 __HAL_DMA_DISABLE_IT 的代码,然后发送长度超过 25 字节的数据:
1 | 发送:ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890 |
你会看到串口助手收到:
- 第一次回显:前面一半数据(前 25 字节)——这是过半中断触发的
- 第二次回显:完整数据——这是空闲中断触发的
加上 __HAL_DMA_DISABLE_IT 后再试,就只有一次完整回显了。
三种模式的完整对比总结
| 对比维度 | 轮询模式 | 中断模式 | DMA + 空闲中断 |
|---|---|---|---|
| CPU 参与搬运 | ✅ 全程搬运 | 🔶 每字节中断一次搬运 | ❌ 硬件自动搬运 |
| CPU 参与处理 | ✅ 主循环中 | 🔶 中断服务中 | 🔶 回调函数中 |
| 接收长度 | 固定 | 固定 | ✅ 不定长 |
| 代码复杂度 | ✅ 简单 | 🔶 中等 | 🔶 中等 |
| CPU 利用率 | ❌ 极低 | 🔶 中等 | ✅ 极高 |
| 适用场景 | 最简单的 demo | 多任务、高效率通信 | 大量数据收发、高实时性要求 |
完整代码结构
1 | 全局变量区: |
总结
到这里,串口通信这条学习路线就走完了三个台阶:
1 | 第1步:轮询模式 —— 能通信了,但CPU干等 |
| 新知识点 | 说明 |
|---|---|
| DMA(直接内存访问) | 独立于 CPU 的硬件模块,自动在外设寄存器和内存之间搬运数据 |
| DMA 通道 | 每个外设的 DMA 请求绑定到特定的 DMA 通道(USART2_TX → 通道7,USART2_RX → 通道6) |
HAL_UART_Transmit_DMA / HAL_UART_Receive_DMA |
DMA 模式的串口收发函数,和 _IT 版本参数完全一致 |
| 空闲中断(Idle Interrupt) | RX 线从忙碌变为空闲时触发,用于判断一帧数据接收完毕 |
HAL_UARTEx_ReceiveToIdle_DMA |
基于 DMA + 空闲中断的接收函数,第三个参数是”最大接收长度” |
HAL_UARTEx_RxEventCallback |
不定长接收的回调函数,多了一个 Size 参数表示实际接收字节数 |
| DMA 传输过半中断(HT) | 默认开启的问题——搬运到一半也会触发回调,需要用 __HAL_DMA_DISABLE_IT 关闭 |
参考资源
- keysking STM32 教程第10集(B站)
- 波特律动文档站
- 上一篇:STM32串口中断模式通信
- 上一篇:STM32串口通信——用电脑控制三色LED
- STM32外部中断深入理解(中断优先级和 NVIC 的概念相通)