0%

STM32——串口DMA模式收发,与不定长数据接收

从中断到 DMA——CPU 还是太忙了

上篇博客,我们用串口中断模式解决了 CPU “傻等”的问题。回顾一下当时的代码结构:

1
2
3
4
5
6
7
8
HAL_UART_Receive_IT(&huart2, receiveData, 2);   // 启动中断接收

// —— 数据到了,中断处理 ——
void HAL_UART_RxCpltCallback(...)
{
// 处理数据
HAL_UART_Receive_IT(&huart2, receiveData, 2); // 重新开启接收
}

CPU 确实不用空等了,但每次收发一个字节,CPU 都要进一次中断:

1
2
3
4
5
6
7
8
9
发送时:
CPU 把第1字节写入发送数据寄存器 → 进中断 →
CPU 把第2字节写入发送数据寄存器 → 进中断 →
CPU 把第3字节写入发送数据寄存器 → 进中断 → ……

接收时:
┌─ 收到第1字节 → 中断!CPU 来读走 ─┐
┌─ 收到第2字节 → 中断!CPU 来读走 ─┐
┌─ 收到第3字节 → 中断!CPU 来读走 ─┐

虽然每次中断处理时间很短,但数据量大的时候,CPU 频繁地在”正常任务”和”中断搬运”之间切换——时间都花在来回奔波上了。

这就好比:

1
2
3
轮询模式:CPU 站在门口等外卖,啥也干不了
中断模式:CPU 装了个门铃,外卖到了去拿,但每来一单都要跑一趟
DMA 模式 :CPU 雇了个助理(DMA),告诉它"去门口等外卖,都拿到了叫我"

DMA(Direct Memory Access,直接内存访问) 就是这个”助理”——它是一个独立于 CPU 的硬件模块,可以在不经过 CPU 的情况下,在外设寄存器和内存之间自动搬运数据。


DMA 原理

传统的数据搬运路径

先看看没有 DMA 时,串口接收的完整链路:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
┌─────────────────────────────────────┐
│ 没有 DMA 时 │
│ │
│ ┌──────────┐ │
│ │ CPU │ ← 每收 1 字节就 │
│ │ │ 进一次中断来搬运 │
│ │ 从 DR │ │
│ │ 读到 │ │
│ │ 内存 │ │
│ └──┬────┬──┘ │
│ │ ▲ │
│ │ │ │
│ ▼ │ │
│ ┌────────┐ ┌────────────┐ │
│ │USART2 │ │receiveData │ │
│ │ ->DR │ │ (内存变量) │ │
│ └────────┘ └────────────┘ │
└─────────────────────────────────────┘

每收到一个字节,CPU 都要亲自从 USART2->DR 读到内存。发的时候反过来,也是要亲历亲为。

有了 DMA 之后

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
┌─────────────────────────────────────┐
│ 有了 DMA 时 │
│ │
│ ┌──────────┐ │
│ │ DMA │ ← CPU 只需配置一次 │
│ │ │ 剩下全部由 DMA 自动 │
│ │ 自动搬运 │ 完成,CPU 0 干预 │
│ │ │ │
│ │ 不需要 │ │
│ │ CPU插手 │ │
│ └──┬────┬──┘ │
│ │ ▲ │
│ │ │ │
│ ▼ │ │
│ ┌────────┐ ┌────────────┐ │
│ │USART2 │ │receiveData │ │
│ │ ->DR │ │ (内存变量) │ │
│ └────────┘ └────────────┘ │
└─────────────────────────────────────┘

CPU 只需要做一件事:**告诉 DMA “把数据从 USART2->DR 搬到 receiveData,搬 N 次”**。DMA 就会在每次串口收到数据时自动搬走,全部搬完后再发一个中断通知 CPU。

DMA 的内部结构(简化版)

STM32F103 有 两个 DMA 控制器(DMA1 和 DMA2),每个控制器有 7 个通道。每个通道可以服务一个外设的请求:

1
2
3
4
5
6
7
8
9
10
                DMA1
┌─────────────────────────────────┐
│ 通道1 ← ADC1 │
│ 通道2 ← SPI1_RX │
│ 通道3 ← SPI1_TX │
│ 通道4 ← USART1_TX │
│ 通道5 ← USART1_RX │
│ 通道6 ← USART2_RX ← 我们用的 │
│ 通道7 ← USART2_TX ← 我们用的 │
└─────────────────────────────────┘

DMA 通道需要的配置信息:

配置项 含义 类比
源地址 从哪里搬数据 仓库的位置
目标地址 搬到哪去 货架的位置
传输方向 外设→内存 / 内存→外设 / 内存→内存 从仓库到货架,还是从货架到仓库
传输数量 搬多少个数据 要搬多少箱货
地址自增 搬完一个后地址要不要+1 搬完一箱后,是在原地拿下一箱,还是往前走一步

在串口场景中

发送(内存 → 外设):

  • 源地址:receiveData 数组(每次自增,因为要依次取数据)
  • 目标地址:USART2->DR 寄存器(不自增,因为每次都是写到同一个寄存器)
  • 方向:内存 → 外设

接收(外设 → 内存):

  • 源地址:USART2->DR 寄存器(不自增,因为每次都是从这个寄存器读)
  • 目标地址:receiveData 数组(每次自增,因为要依次存到数组里)
  • 方向:外设 → 内存

CubeMX 配置

基于上一篇中断模式的工程(Serial),只新增 DMA 配置,其他不变。

添加 DMA 通道

在 Connectivity → USART2 中,切到 DMA Settings 标签页:

image-20260730121003458

点击 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
2
3
4
5
6
7
8
9
10
11
发送时(Memory → Peripheral):
源(Memory)是 receiveData 数组:receiveData[0] → receiveData[1] → receiveData[2] …
地址自增 ↑
目标(Peripheral)是 USART2->DR:每次都是同一个寄存器
地址不变 ↑

接收时(Peripheral → Memory):
源(Peripheral)是 USART2->DR:每次都是从这个寄存器读
地址不变 ↑
目标(Memory)是 receiveData 数组:receiveData[0] → receiveData[1] → receiveData[2] …
地址自增 ↑

别忘了 USART2 的中断

切到 NVIC Settings 标签页,确保 USART2 global interrupt 仍然是勾选状态——因为 DMA 传输完成后,还是要通过中断来通知 CPU 的。

这里的中断是 DMA 传输完成中断,不是串口的中断。但 CubeMX 会自动把 NVIC 也配好,我们只需要确保 USART2 的中断是开启的就行。

右上角点击GENERATE CODE,CubeMX 会重新生成代码。


VS Code 中修改核心代码

打开 main.c,和上篇中断模式的代码对比,改动集中在以下几个方面。

main.c 完整代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
/* USER CODE BEGIN PV */
// 存放STM32接收内容的数组——这次设大一些,用于接收不定长数据
uint8_t receiveData[50];
/* USER CODE END PV */

/* USER CODE BEGIN 0 */
// 回调函数1:接收定长数据(2字节)——使用中断模式,兼容旧协议
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
// 用DMA模式将收到的数据发回电脑
HAL_UART_Transmit_DMA(&huart2, receiveData, 2);

// 设置小灯亮灭状态值
GPIO_PinState state = GPIO_PIN_SET;
if (receiveData[1] == '0')
{
state = GPIO_PIN_RESET;
}

// 根据第一字节控制对应小灯
switch (receiveData[0])
{
case 'R':
HAL_GPIO_WritePin(LED_RED_GPIO_Port, LED_RED_Pin, state);
break;
case 'G':
HAL_GPIO_WritePin(LED_GREEN_GPIO_Port, LED_GREEN_Pin, state);
break;
case 'B':
HAL_GPIO_WritePin(LED_BLUE_GPIO_Port, LED_BLUE_Pin, state);
break;
}

// 重新启动DMA接收(定长2字节)
HAL_UART_Receive_DMA(&huart2, receiveData, 2);
}

// 回调函数2:接收不定长数据——多了一个 Size 参数
void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size)
{
if (huart == &huart2)
{
// 用DMA将收到的数据原样发回,发多少就回显多少
HAL_UART_Transmit_DMA(&huart2, receiveData, Size);

// 再次启动不定长DMA接收
HAL_UARTEx_ReceiveToIdle_DMA(&huart2, receiveData, sizeof(receiveData));

// 关闭DMA传输过半中断,防止重复触发回调
__HAL_DMA_DISABLE_IT(&hdma_usart2_rx, DMA_IT_HT);
}
}
/* USER CODE END 0 */

int main(void)
{
// …… 初始化代码不变 ……

/* USER CODE BEGIN 2 */
// 启动不定长DMA接收——一次设置,之后在回调里重新启动
HAL_UARTEx_ReceiveToIdle_DMA(&huart2, receiveData, sizeof(receiveData));
__HAL_DMA_DISABLE_IT(&hdma_usart2_rx, DMA_IT_HT);
/* USER CODE END 2 */

while (1)
{
// 空的——所有串口逻辑由中断和DMA驱动
}
}

改动一:接收数组变大

1
2
3
// 之前(中断模式):uint8_t receiveData[2];    // 固定2字节
// 现在:
uint8_t receiveData[50]; // 50字节,给不定长数据留足空间

因为不定长数据不知道会收多少字节,先把缓冲区设大。sizeof(receiveData) 就是 50,作为安全上限。

改动二:发送/接收函数从 _IT 变为 _DMA

之前的函数:

1
2
HAL_UART_Transmit_IT(&huart2, data, len);   // 中断发送
HAL_UART_Receive_IT(&huart2, data, len); // 中断接收

现在的函数:

1
2
HAL_UART_Transmit_DMA(&huart2, data, len);   // DMA发送
HAL_UART_Receive_DMA(&huart2, data, len); // DMA接收

参数完全一样,只是把后缀 _IT 换成 _DMA。对使用者来说,API 基本不变,底层的搬运机制却完全不同了。

改动三:接收不定长数据——两个新函数

这是这篇最关键的新知识。

之前的串口接收(不管是轮询、中断还是 DMA 模式),都需要预先指定接收的长度:

1
HAL_UART_Receive_DMA(&huart2, receiveData, 2);   // 必须收够2字节

那如果不知道对方会发多少字节呢?比如电脑发一条任意长度的字符串?那就需要串口空闲中断(Idle Interrupt) 来帮忙。

串口空闲中断的原理

串口有一个特殊的中断——空闲中断(IDLE Interrupt)。它的触发条件非常巧妙:

1
2
3
4
5
6
7
8
9
RX 引脚电平变化:
┌──┐ ┌──┐ ┌──┐ ┌──┐ ┌──┐ ┌──┐ ┌─────
│ │ │ │ │ │ │ │ │ │ │ │ 空闲 │
│ │ │ │ │ │ │ │ │ │ │ ├─────── │
└──┘ └──┘ └──┘ └──┘ └──┘ └──┘ └─────
字节1 字节2 字节3 字节4 字节5 字节6
↑
这里触发空闲中断!
——"RX线上没数据了"

空闲中断不是在收到固定字节数时触发,而是在 RX 引脚从”忙碌”变为”空闲”时触发。 换句话说:

当串口收到一帧数据,然后对方停止发送了——RX 线变成空闲状态——硬件就触发空闲中断。

利用这个特性,我们可以实现:数据来了就收,对方停发了就知道收完了。

HAL 库封装好的函数

HAL 库提供了一个扩展函数组,把”接收不定长数据”这件事包装好了:

1
2
3
4
5
6
7
8
// DMA模式的空闲中断接收(本篇使用的)
HAL_UARTEx_ReceiveToIdle_DMA(&huart2, receiveData, sizeof(receiveData));

// 中断模式的版本
HAL_UARTEx_ReceiveToIdle_IT(&huart2, receiveData, sizeof(receiveData));

// 轮询模式的版本
HAL_UARTEx_ReceiveToIdle(&huart2, receiveData, sizeof(receiveData), HAL_MAX_DELAY);

函数名解析:

部分 含义
HAL_UARTEx_ Ex 代表 Extension(扩展),说明这不是标准 HAL_UART 系列,而是额外补充的
ReceiveToIdle 接收直到空闲——一直收数据,直到 RX 线空闲
_DMA 使用 DMA 方式搬运数据

第三个参数和普通接收不同:

1
2
3
4
5
// 普通接收:第三个参数是"要收多少字节"
HAL_UART_Receive_DMA(&huart2, receiveData, 2); // 收 2 字节

// 空闲中断接收:第三个参数是"最多收多少字节"(安全上限)
HAL_UARTEx_ReceiveToIdle_DMA(&huart2, receiveData, sizeof(receiveData)); // 最多 50 字节

这里填的是 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
2
3
4
5
6
7
8
9
void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size)
{
if (huart == &huart2)
{
// Size 就是本次实际接收的字节数!
// 发回来的字节数也要和收到的相同
HAL_UART_Transmit_DMA(&huart2, receiveData, Size);
}
}

改动四:重启接收时要在回调里再次调用

和中断模式的 Receive_IT 一样,ReceiveToIdle_DMA 也是”一次性”的——触发回调后就停了。所以必须在回调函数末尾重新启动它:

1
2
3
4
5
6
7
8
9
10
11
12
void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size)
{
if (huart == &huart2)
{
HAL_UART_Transmit_DMA(&huart2, receiveData, Size);

// 重新启动不定长DMA接收!
HAL_UARTEx_ReceiveToIdle_DMA(&huart2, receiveData, sizeof(receiveData));

// ……(还有一行关键代码,见下文)
}
}

⚠️ 踩坑记录:DMA 传输过半中断

如果你只写上面这些代码,测试时可能会发现一个奇怪的现象——

发送短数据(比如 R1):一切正常,完美回显。

发送长数据(比如超过 25 字节):你会看到回显了两次!第一次回显了一半数据,第二次才回显完整数据。

这背后的”元凶”是 DMA 传输过半中断(Half-Transfer Interrupt,HT)。

为什么会触发两次?

回顾一下 HAL_UARTEx_ReceiveToIdle_DMA 的第二个参数:

1
2
HAL_UARTEx_ReceiveToIdle_DMA(&huart2, receiveData, sizeof(receiveData));
// ↑ 最大 50 字节

DMA 的内部计数器设为 50(最大上限)。DMA 默认开启了一个叫传输过半中断的选项——当搬运的数量到达总数的一半(25 个) 时,也会触发一次中断。

所以如果你发送的数据超过了 25 字节:

1
2
3
DMA 接收计数:0 → 1 → 2 → … → 25(触发 HT 中断!)→ 26 → … → 实际结束(空闲中断)
↑
又触发一次 RxEventCallback

RxEventCallback 被触发了两次:

  1. 第一次:DMA 收到 25 字节(过半中断触发)
  2. 第二次: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
2
3
4
5
6
7
8
9
10
// 1. main() 初始化时
HAL_UARTEx_ReceiveToIdle_DMA(&huart2, receiveData, sizeof(receiveData));
__HAL_DMA_DISABLE_IT(&hdma_usart2_rx, DMA_IT_HT); // ← 关闭过半中断

// 2. 回调函数中(重启接收后)
void HAL_UARTEx_RxEventCallback(...)
{
HAL_UARTEx_ReceiveToIdle_DMA(&huart2, receiveData, sizeof(receiveData));
__HAL_DMA_DISABLE_IT(&hdma_usart2_rx, DMA_IT_HT); // ← 再次关闭
}

为什么每次重启后都要关一次?

因为 HAL_UARTEx_ReceiveToIdle_DMA 内部重新初始化 DMA 时,又会默认打开 HT 中断。所以每次调用后都要跟着关一次。


两个回调函数可以共存吗?

注意代码中有两个回调函数:

1
2
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)          // 定长接收完成
void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size) // 空闲中断接收

它们可以共存。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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
程序启动(main):
HAL_UARTEx_ReceiveToIdle_DMA() ← 启动不定长DMA接收
__HAL_DMA_DISABLE_IT(HT) ← 关闭过半中断
while(1) { 空 }
│
▼
电脑发数据到串口:
DMA 自动把数据从 USART2->DR 搬到 receiveData[]
─── CPU 完全不用管 ───
│
▼
电脑停止发送,RX线空闲:
空闲中断触发 → DMA传输完成
│
▼
HAL_UARTEx_RxEventCallback(huart, Size) ← Size = 实际收到的字节数
├─ HAL_UART_Transmit_DMA() ← 用DMA发回显
├─ HAL_UARTEx_ReceiveToIdle_DMA() ← 重新启动接收
└─ __HAL_DMA_DISABLE_IT(HT) ← 关闭过半中断
│
▼
等待下一帧数据……

解读新增的初始化代码

1. MX_DMA_Init() —— DMA 初始化

CubeMX 新生成一个专门的 MX_DMA_Init 函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
static void MX_DMA_Init(void)
{
/* DMA controller clock enable */
__HAL_RCC_DMA1_CLK_ENABLE(); // (1) 开启 DMA1 的时钟

/* DMA interrupt init */
/* DMA1_Channel6_IRQn interrupt configuration */
HAL_NVIC_SetPriority(DMA1_Channel6_IRQn, 0, 0); // (2) RX通道中断优先级
HAL_NVIC_EnableIRQ(DMA1_Channel6_IRQn); // (3) 使能RX通道中断

/* DMA1_Channel7_IRQn interrupt configuration */
HAL_NVIC_SetPriority(DMA1_Channel7_IRQn, 0, 0); // (4) TX通道中断优先级
HAL_NVIC_EnableIRQ(DMA1_Channel7_IRQn); // (5) 使能TX通道中断
}
行 代码 说明
(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
2
串口收到数据 → DMA自动搬走 → DMA搬完了 → DMA中断 → 回调函数
CPU 只在这里介入!

2. stm32f1xx_it.c 中的 DMA 中断服务函数

CubeMX 还会在 stm32f1xx_it.c 中生成两个中断服务函数:

1
2
3
4
5
6
7
8
9
void DMA1_Channel6_IRQHandler(void)
{
HAL_DMA_IRQHandler(&hdma_usart2_rx); // USART2 RX 的 DMA 中断
}

void DMA1_Channel7_IRQHandler(void)
{
HAL_DMA_IRQHandler(&hdma_usart2_tx); // USART2 TX 的 DMA 中断
}

和之前串口中断类似,这里也是调用 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,传输过半中断)

编译 & 烧录 & 测试

  1. VS Code 底部工具栏点击齿轮图标 → 编译
  2. 连接 ST-Link,点击运行和调试 → 选择 STM32Cube:STM32 Launch STLink GDB Server
  3. 点击继续按钮

测试不定长接收

打开串口助手,在发送区输入任意长度的字符串:

测试 操作 预期效果
短数据发送 发送 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

你会看到串口助手收到:

  1. 第一次回显:前面一半数据(前 25 字节)——这是过半中断触发的
  2. 第二次回显:完整数据——这是空闲中断触发的

加上 __HAL_DMA_DISABLE_IT 后再试,就只有一次完整回显了。


三种模式的完整对比总结

对比维度 轮询模式 中断模式 DMA + 空闲中断
CPU 参与搬运 ✅ 全程搬运 🔶 每字节中断一次搬运 ❌ 硬件自动搬运
CPU 参与处理 ✅ 主循环中 🔶 中断服务中 🔶 回调函数中
接收长度 固定 固定 ✅ 不定长
代码复杂度 ✅ 简单 🔶 中等 🔶 中等
CPU 利用率 ❌ 极低 🔶 中等 ✅ 极高
适用场景 最简单的 demo 多任务、高效率通信 大量数据收发、高实时性要求

完整代码结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
全局变量区:
receiveData[50] ← 接收缓冲区(扩大为50字节,兼容不定长)
huart2 ← 串口句柄
hdma_usart2_rx ← RX DMA 通道句柄(CubeMX 自动声明)
hdma_usart2_tx ← TX DMA 通道句柄(CubeMX 自动声明)

初始化顺序:
HAL_Init()
SystemClock_Config()
MX_GPIO_Init()
MX_DMA_Init() ← ★ 新增!先初始化 DMA
MX_USART2_UART_Init() ← 注意:DMA 要在 USART 之前初始化
HAL_UARTEx_ReceiveToIdle_DMA() ← 启动不定长DMA接收
__HAL_DMA_DISABLE_IT(HT) ← 关闭过半中断

回调函数 1(定长接收完成):
HAL_UART_RxCpltCallback:
HAL_UART_Transmit_DMA() ↕ 改为 DMA 发送
控制 LED
HAL_UART_Receive_DMA()

回调函数 2(不定长接收完成):
HAL_UARTEx_RxEventCallback:
判断是哪个串口
HAL_UART_Transmit_DMA(Size) ↕ 用 Size 回显
HAL_UARTEx_ReceiveToIdle_DMA() ↕ 重启接收
__HAL_DMA_DISABLE_IT(HT) ↕ 关过半中断

总结

到这里,串口通信这条学习路线就走完了三个台阶:

1
2
3
第1步:轮询模式  —— 能通信了,但CPU干等
第2步:中断模式 —— CPU不干等了,收发靠中断
第3步:DMA模式 —— CPU彻底解放,收发靠DMA + 空闲中断 → 不定长接收
新知识点 说明
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 关闭

参考资源