STM32 零基础可移植教程 17:USART + DMA + IDLE,串口不定长接收怎么做
前面我们已经写过三篇串口:
bash
第 07 篇:USART 串口打印,从 CubeMX 配置到 printf 输出
第 08 篇:串口接收一个字节,先把 RX 中断跑通
第 09 篇:串口收一行命令,用 led on 控制 LED
那为什么还要再写一篇 USART + DMA + IDLE?
因为真实项目里,串口数据经常不是固定长度。
比如上位机可能发:
bash
led on
也可能发:
bash
set pwm 50
也可能发一段二进制协议:
bash
AA 55 03 01 02 03 5A
如果每来一个字节就进一次中断,数据量小的时候还好;数据量一大,CPU 就会频繁被打断。
DMA 可以帮我们把字节搬进内存。
但 DMA 又有一个问题:
bash
它知道缓冲区什么时候满
但它不知道对方这一帧什么时候发完
这时候就轮到 IDLE 出场。
IDLE 可以理解成:
bash
串口线上安静了一小段时间,说明对方可能发完了一帧
所以这篇只做一个明确目标:
bash
用 USART + DMA + IDLE 接收不定长数据,并通过 printf 打印长度、文本和 HEX
先不解析复杂协议,不做环形队列,不做 RTOS。
先把"不定长接收"这条链路跑通。
本篇目标
最终现象:
电脑串口助手发送任意内容,比如:
bash
hello stm32
STM32 串口打印:
bash
RX len=11, lost=0, text=hello stm32, hex=68 65 6C 6C 6F 20 73 74 6D 33 32
发送:
bash
led on
STM32 串口打印:
bash
RX len=6, lost=0, text=led on, hex=6C 65 64 20 6F 6E
本篇用到的外设:
bash
USART
DMA
USART IDLE
本篇跑通标准:
-
串口能正常 printf 输出;
-
串口助手发送不同长度文本,STM32 都能收到;
-
打印出来的
len和实际发送长度大致一致; -
能说清楚 DMA 负责搬数据,IDLE 负责判断一帧可能结束;
-
知道
MX_DMA_Init()和 UART 中断配置为什么重要。
准备工作
你需要准备:
|
项目
|
说明
|
| --- | --- |
|
STM32 开发板
|
任意带 USART 和 DMA 的 STM32 都可以
|
|
下载器
|
ST-LINK/V2 或板载 ST-LINK
|
|
USB 转 TTL 模块
|
如果开发板没有板载串口转 USB
|
|
串口助手
|
用来发送不定长数据
|
|
杜邦线
|
外接串口模块时使用
|
接线和第 07 篇一样:
|
USB 转 TTL
|
STM32
|
| --- | --- |
|
TXD
|
STM32 RX
|
|
RXD
|
STM32 TX
|
|
GND
|
GND
|
注意:
bash
TX 和 RX 要交叉
GND 必须共地
不要把 5V TTL 直接接到不耐 5V 的 STM32 RX
如果你用的是开发板板载 USB 转串口,那一般不需要额外接线。

先把 DMA 和 IDLE 分清楚
DMA 解决的是"搬运问题"。
不用 DMA 时:
bash
来 1 个字节 -> 进 1 次中断 -> CPU 读 1 次 RDR/DR
用了 DMA 后:
bash
来 1 个字节 -> DMA 自动搬到数组
来 1 个字节 -> DMA 自动搬到数组下一个位置
CPU 不需要每个字节都进中断处理。
但 DMA 只知道数组长度。
比如你给它一个 128 字节缓冲区:
bash
uint8_t rx_buffer[128];
如果对方只发了 7 个字节:
bash
led on\n
DMA 不会天然知道"这一帧就是 7 个字节"。
如果你只等 DMA 收满 128 字节,那读者会等到怀疑人生。
IDLE 解决的是"什么时候算一帧结束"的问题。
当串口收到一段数据后,线路空闲超过 1 帧时间,硬件就会产生 IDLE 事件。
可以简单理解为:
bash
刚才有数据
现在安静了一小会儿
那这一段数据大概率发完了
所以组合起来就是:
bash
DMA 负责把收到的字节搬进数组
IDLE 负责通知程序:现在可以把这一段拿出来处理了

本篇为什么用 HAL_UARTEx_ReceiveToIdle_DMA
STM32 HAL 里提供了一个很适合入门的函数:
bash
HAL_UARTEx_ReceiveToIdle_DMA()
它的意思是:
bash
用 DMA 接收串口数据
遇到 IDLE 或缓冲区满时,触发回调告诉用户收到了多少字节
回调函数是:
bash
void
HAL_UARTEx_RxEventCallback
(UART_HandleTypeDef *huart, uint16_t Size)
其中 Size 就是本次收到的数据长度。
本篇代码做的事情很简单:
-
启动
HAL_UARTEx_ReceiveToIdle_DMA(); -
串口收到一段数据;
-
进入
HAL_UARTEx_RxEventCallback(); -
把 DMA 缓冲区里的数据复制到一块应用层缓冲区;
-
设置
s_frame_ready = 1; -
主循环读取这一帧并打印;
-
回调里重新启动下一次 DMA + IDLE 接收。
这里有一个小细节:
bash
__HAL_DMA_DISABLE_IT(APP_UART_IDLE_HANDLE.hdmarx, DMA_IT_HT);
它是把 DMA 半传输中断关掉。
因为本篇只想在:
bash
IDLE
或者缓冲区满
时处理数据。
如果半传输中断也打开,新手可能会看到一帧数据被拆成奇怪的两段,反而更迷糊。
CubeMX 配置步骤
1. 复制前面的串口工程
建议从第 09 篇串口命令工程复制一份,改名为:
bash
17_usart_dma_idle
如果你重新建工程,也可以按第一篇流程:
-
选择芯片型号;
-
SYS -> Debug设置为Serial Wire; -
配置时钟;
-
配置 USART;
-
配置 DMA;
-
生成 Keil 工程。

2. 配置 USART
选择你要用的 USART,比如:
bash
USART1
模式选择:
bash
Asynchronous
参数先用最常见的:
|
配置项
|
推荐值
|
| --- | --- |
|
Baud Rate
|
115200
|
|
Word Length
|
8 Bits
|
|
Parity
|
None
|
|
Stop Bits
|
1
|
|
Hardware Flow Control
|
None
|

3. 给 USART RX 添加 DMA
进入 USART 的 DMA Settings。
添加:
bash
USARTx_RX
DMA 参数推荐:
|
配置项
|
推荐值
|
说明
|
| --- | --- | --- |
|
Direction
|
Peripheral to Memory
|
串口数据寄存器搬到内存
|
|
Mode
|
Normal
|
本篇回调后手动重新开启接收
|
|
Peripheral Increment
|
Disable
|
串口数据寄存器地址不变
|
|
Memory Increment
|
Enable
|
接收数组地址要往后走
|
|
Peripheral Data Width
|
Byte
|
串口 1 字节 1 字节收
|
|
Memory Data Width
|
Byte
|
数组也是 uint8_t
|
|
Priority
|
Low / Medium
|
入门先默认即可
|
为什么这里用 Normal,不用 Circular?
因为本篇目标是先理解:
bash
一段数据 -> IDLE -> 回调 -> 主循环处理
Normal 模式更好理解。
后面如果要做高吞吐协议、不断流接收、环形缓冲区,再单独升级 Circular。

4. 打开 USART 全局中断
这一步非常重要。
IDLE 是 USART 外设产生的事件,HAL 要通过 USART 中断去处理。
所以在 NVIC 里打开:
bash
USARTx global interrupt
生成代码后,stm32f1xx_it.c 里应该有类似:
bash
void USART1_IRQHandler(void)
{
HAL_UART_IRQHandler(&huart1);
}
如果这个中断没开,DMA 可能在搬数据,但 IDLE 事件进不了 HAL 回调。

5. 确认 DMA 初始化顺序
CubeMX 生成的 main.c 里,通常会有:
bash
MX_GPIO_Init();
MX_DMA_Init();
MX_USART1_UART_Init();
建议确认:
bash
MX_DMA_Init() 在 MX_USART1_UART_Init() 前面
因为 USART 初始化时会把 UART 句柄和 DMA 句柄关联起来。
如果 DMA 初始化顺序不对,HAL_UARTEx_ReceiveToIdle_DMA() 可能启动失败,或者回调不进。

Keil 工程生成和编译
生成 Keil 工程后,先编译 CubeMX 原始工程:
bash
Build / F7
确认没有错误:
bash
0 Error(s)
然后新建两个文件:
bash
Core/Inc/app_uart_dma_idle.h
Core/Src/app_uart_dma_idle.c
如果你是手动新建 .c 文件,记得在 Keil 工程树里添加:
bash
Core/Src/app_uart_dma_idle.c

完整代码
1. 新建 Core/Inc/app_uart_dma_idle.h
bash
#ifndef APP_UART_DMA_IDLE_H
#define APP_UART_DMA_IDLE_H
#include "main.h"
#include <stdint.h>
#ifndef APP_UART_IDLE_RX_BUFFER_SIZE
#define APP_UART_IDLE_RX_BUFFER_SIZE 128u
#endif
typedef
struct{
uint8_t
data[APP_UART_IDLE_RX_BUFFER_SIZE];
uint16_t
length;
uint32_t
lost_count;
} App_UARTIdle_Frame;
void App_UARTIdle_Init(void)
;
HAL_StatusTypeDef App_UARTIdle_Start(void)
;
void App_UARTIdle_Stop(void)
;
uint8_t App_UARTIdle_GetFrame(App_UARTIdle_Frame *frame)
;
#endif
这里的 APP_UART_IDLE_RX_BUFFER_SIZE 是单帧最大接收长度。
本篇先设成:
bash
128 字节
如果你要收更长的数据,可以适当调大。
但注意,缓冲区越大,占用 RAM 越多。
2. 新建 Core/Src/app_uart_dma_idle.c
bash
#include "app_uart_dma_idle.h"
#ifndef APP_UART_IDLE_HANDLE
#define APP_UART_IDLE_HANDLE huart1
#endif
extern
UART_HandleTypeDef APP_UART_IDLE_HANDLE;
static
uint8_t
s_dma_rx_buffer[APP_UART_IDLE_RX_BUFFER_SIZE];
static
uint8_t
s_frame_buffer[APP_UART_IDLE_RX_BUFFER_SIZE];
static
volatile
uint16_t
s_frame_length =
0u
;
static
volatile
uint8_t
s_frame_ready =
0u
;
static
volatile
uint32_t
s_lost_count =
0u
;
static void App_UARTIdle_ClearDmaBuffer(void)
{
uint16_t
i;
for
(i =
0u
; i < APP_UART_IDLE_RX_BUFFER_SIZE; i++)
{
s_dma_rx_buffer[i] =
0u
;
}
}
void App_UARTIdle_Init(void)
{
s_frame_length =
0u
;
s_frame_ready =
0u
;
s_lost_count =
0u
;
App_UARTIdle_ClearDmaBuffer();
}
HAL_StatusTypeDef App_UARTIdle_Start(void)
{
HAL_StatusTypeDef status;
status = HAL_UARTEx_ReceiveToIdle_DMA(&APP_UART_IDLE_HANDLE,
s_dma_rx_buffer,
APP_UART_IDLE_RX_BUFFER_SIZE);
/* * We only care about IDLE or full-buffer events in this beginner example. * Half-transfer interrupts can make readers think one frame arrived twice. */
if
((status == HAL_OK) && (APP_UART_IDLE_HANDLE.hdmarx !=
0
))
{
__HAL_DMA_DISABLE_IT(APP_UART_IDLE_HANDLE.hdmarx, DMA_IT_HT);
}
return
status;
}
void App_UARTIdle_Stop(void)
{
(
void
)HAL_UART_DMAStop(&APP_UART_IDLE_HANDLE);
}
uint8_t App_UARTIdle_GetFrame(App_UARTIdle_Frame *frame)
{
uint16_t
i;
if
(frame ==
0
)
{
return
0u
;
}
__disable_irq();
if
(s_frame_ready ==
0u
)
{
__enable_irq();
return
0u
;
}
frame->length = s_frame_length;
frame->lost_count = s_lost_count;
for
(i =
0u
; i < frame->length; i++)
{
frame->data[i] = s_frame_buffer[i];
}
s_frame_ready =
0u
;
s_frame_length =
0u
;
__enable_irq();
return
1u
;
}
void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t size)
{
uint16_t
i;
uint16_t
copy_size;
if
(huart != &APP_UART_IDLE_HANDLE)
{
return
;
}
if
(size > APP_UART_IDLE_RX_BUFFER_SIZE)
{
copy_size = APP_UART_IDLE_RX_BUFFER_SIZE;
}
else
{
copy_size = size;
}
if
(copy_size >
0u
)
{
if
(s_frame_ready ==
0u
)
{
for
(i =
0u
; i < copy_size; i++)
{
s_frame_buffer[i] = s_dma_rx_buffer[i];
}
s_frame_length = copy_size;
s_frame_ready =
1u
;
}
else
{
s_lost_count++;
}
}
(
void
)App_UARTIdle_Start();
}
这段代码有几个点值得单独说。
第一,s_dma_rx_buffer 是 DMA 正在写的缓冲区:
bash
static
uint8_t
s_dma_rx_buffer[APP_UART_IDLE_RX_BUFFER_SIZE];
第二,s_frame_buffer 是应用层准备给主循环读取的一帧:
bash
static
uint8_t
s_frame_buffer[APP_UART_IDLE_RX_BUFFER_SIZE];
为什么不让主循环直接读 DMA buffer?
因为回调结束后我们会重新启动下一次 DMA 接收,DMA buffer 后面还会继续被写。
所以收到一帧后,先复制出来,主循环读复制后的这一份,更稳。
第三,s_frame_ready 是中断回调和主循环之间的标志:
bash
static
volatile
uint8_t
s_frame_ready =
0u
;
回调里置 1:
bash
s_frame_ready =
1u
;
主循环读走后清 0:
bash
s_frame_ready =
0u
;
第四,App_UARTIdle_GetFrame() 里用了临界区:
bash
__disable_irq();
...
__enable_irq();
原因和前面讲过的临界区一样:这些标志和长度变量会被中断回调修改,也会被主循环读取。
这段临界区很短,只复制最多 128 字节,入门阶段可以接受。
第五,如果上一帧还没被主循环取走,新的一帧又来了,代码会增加:
bash
s_lost_count++;
这表示有帧被丢掉了。
真实项目里可以改成环形队列。本篇先不加,避免一下子把代码写得太重。
main.c 调用方式
1. 添加头文件
在 main.c 顶部添加:
bash
/* USER CODE BEGIN Includes */
#include "app_uart_dma_idle.h"
#include <stdio.h>
/* USER CODE END Includes */
2. 初始化后启动接收
确认 CubeMX 已生成:
bash
MX_GPIO_Init();
MX_DMA_Init();
MX_USART1_UART_Init();
然后在 USER CODE BEGIN 2 中添加:
bash
/* USER CODE BEGIN 2 */
App_UARTIdle_Init();
if
(App_UARTIdle_Start() != HAL_OK)
{
printf
(
"UART DMA IDLE start failed\r\n"
);
}
printf
(
"\r\nUSART DMA IDLE receive test\r\n"
);
printf
(
"Send any text from serial assistant.\r\n"
);
/* USER CODE END 2 */
3. while 循环里处理收到的一帧
bash
/* USER CODE BEGIN WHILE */
while
(
1
)
{
/* USER CODE END WHILE */
/* USER CODE BEGIN 3 */
App_UARTIdle_Frame frame;
uint16_t
i;
if
(App_UARTIdle_GetFrame(&frame) !=
0u
)
{
printf
(
"RX len=%u, lost=%lu, text="
, frame.length, frame.lost_count);
for
(i =
0u
; i < frame.length; i++)
{
putchar
(frame.data[i]);
}
printf
(
", hex="
);
for
(i =
0u
; i < frame.length; i++)
{
printf
(
"%02X "
, frame.data[i]);
}
printf
(
"\r\n"
);
}
HAL_Delay(
10
);
/* USER CODE END 3 */
}
这里不需要在 while 里反复启动 DMA 接收。
第一次启动在:
bash
App_UARTIdle_Start();
后面每次收到一帧,回调里会重新启动下一次接收。
编译、下载和验证
代码加完后:
-
Keil 编译;
-
下载程序;
-
打开串口助手;
-
设置 115200 8N1;
-
发送不同长度的文本。
比如发送:
bash
hello
正常输出:
bash
RX len=5, lost=0, text=hello, hex=68 65 6C 6C 6F
发送:
bash
stm32 dma idle
正常输出:
bash
RX len=14, lost=0, text=stm32 dma idle, hex=73 74 6D 33 32 20 64 6D 61 20 69 64 6C 65
如果串口助手勾选了"发送新行",那实际会多出:
bash
\r
\n
HEX 里可能看到:
bash
0D 0A
这不是错误,而是串口助手帮你加了回车换行。

移植到其他板子的修改点
|
要改的地方
|
为什么要改
|
在哪里改
|
| --- | --- | --- |
|
USART 实例
|
可能不是 USART1
|
CubeMX USART,代码里的 APP_UART_IDLE_HANDLE
|
|
TX/RX 引脚
|
不同板子串口引脚不同
|
CubeMX Pinout
|
|
波特率
|
要和串口助手一致
|
CubeMX USART 参数
|
|
USART RX DMA 请求
|
不同芯片 DMA 映射不同
|
CubeMX DMA Settings
|
|
DMA 模式
|
本篇用 Normal
|
CubeMX DMA Mode
|
|
USART 全局中断
|
IDLE 需要 UART IRQ 进入 HAL
|
CubeMX NVIC
|
|
DMA 初始化顺序
|
DMA 要先于 USART 初始化
| main.c
中 MX_DMA_Init() 位置
|
|
接收缓冲区大小
|
单帧长度不同
| APP_UART_IDLE_RX_BUFFER_SIZE |
|
HAL 版本
|
老版本可能没有 HAL_UARTEx_ReceiveToIdle_DMA()
|
升级 Cube HAL 或改手动 IDLE 中断方案
|
如果你用的是 USART2,只需要把代码中的默认句柄改成:
bash
#define APP_UART_IDLE_HANDLE huart2
也可以在 app_uart_dma_idle.c 顶部改。
如果你使用的是 USART3,就改成:
bash
#define APP_UART_IDLE_HANDLE huart3
常见问题排查
1. 编译报 HAL_UARTEx_ReceiveToIdle_DMA 未定义
这通常说明你的 STM32 HAL 库版本比较旧,或者当前系列 HAL 没有这个扩展函数。
解决思路:
-
优先升级对应芯片的 Cube Firmware Package;
-
检查
stm32xx_hal_uart_ex.c是否参与编译; -
如果确实没有这个 API,可以后续单独写一篇"手动 IDLE 中断 + DMA 接收"的版本。
本篇先走 HAL 已经封装好的路线。
2. App_UARTIdle_Start() 返回错误
优先检查:
|
检查项
|
说明
|
| --- | --- |
|
USART 是否初始化
| MX_USARTx_UART_Init()
是否调用
|
|
DMA 是否初始化
| MX_DMA_Init()
是否调用
|
|
初始化顺序
| MX_DMA_Init()
是否在 MX_USARTx_UART_Init() 前
|
|
RX DMA 是否添加
|
CubeMX USART DMA Settings 是否有 RX
|
|
句柄是否正确
| APP_UART_IDLE_HANDLE
是否和实际 USART 一致
|
3. 串口能 printf,但收不到数据
按这个顺序查:
-
TX/RX 是否交叉;
-
GND 是否共地;
-
串口助手波特率是否一致;
-
USART RX DMA 是否配置;
-
USART global interrupt 是否开启;
-
USARTx_IRQHandler()里是否调用HAL_UART_IRQHandler(&huartx); -
APP_UART_IDLE_HANDLE是否写错。
4. DMA 好像收了,但进不了 RxEventCallback
重点查 IDLE 相关链路:
-
USART 全局中断是否打开;
-
NVIC 里是否启用了
USARTx global interrupt; -
stm32xx_it.c里是否有USARTx_IRQHandler(); -
IRQHandler 里是否调用 HAL;
-
是否调用的是
HAL_UARTEx_ReceiveToIdle_DMA(),而不是普通HAL_UART_Receive_DMA()。
普通 DMA 接收不会自动进 HAL_UARTEx_RxEventCallback()。
5. 收到的数据被拆成两段
常见原因:
-
串口助手发送时中间有停顿;
-
波特率太低,发送间隔被 IDLE 识别成一帧结束;
-
半传输中断没有关,导致你误以为一帧被拆开;
-
上位机不是一次性发送,而是一段一段写串口。
本篇代码里已经关闭了 DMA 半传输中断:
bash
__HAL_DMA_DISABLE_IT(APP_UART_IDLE_HANDLE.hdmarx, DMA_IT_HT);
如果仍然被拆分,优先看上位机发送方式。
6. lost 数值增加
说明上一帧还没被主循环取走,下一帧又到了。
解决方向:
-
主循环不要长时间
HAL_Delay(); -
不要在主循环里做太慢的操作;
-
增大处理速度;
-
后续升级成帧队列或环形缓冲区;
-
如果数据很密集,考虑 Circular DMA + ring buffer。
本篇为了新手好理解,只保留一个应用层帧缓冲。
7. 编译报回调函数重复定义
如果你的工程里已经有:
bash
HAL_UARTEx_RxEventCallback()
再加入本篇代码就会重复定义。
解决方法是合并回调。
一个工程里同名 HAL 回调只能定义一次。
你可以在已有回调里加:
bash
if
(huart == &huart1)
{
/* 调用本篇的处理逻辑 */
}
或者把其他串口的处理也合到本篇回调里。
8. 打印文本后面有乱码
本篇接收的是一段字节,不自动在末尾加 \0。
所以不要直接这样写:
bash
printf
(
"%s"
, frame.data);
因为 printf("%s") 需要 C 字符串以 \0 结束。
本篇用的是逐字节输出:
bash
for
(i =
0u
; i < frame.length; i++)
{
putchar
(frame.data[i]);
}
这样更适合不定长数据,也能处理二进制内容。
本篇小结
这一篇我们完成了 USART + DMA + IDLE 不定长接收。
你现在应该知道:
-
DMA 负责把串口数据自动搬到内存;
-
IDLE 负责判断"这一段数据可能发完了";
-
HAL_UARTEx_ReceiveToIdle_DMA()很适合入门不定长接收; -
USART global interrupt 必须打开,否则 IDLE 回调可能进不来;
-
MX_DMA_Init()一般要在MX_USARTx_UART_Init()前面; -
回调里不要做复杂业务,复制数据、置标志、重启接收就够了;
-
主循环拿到一帧后再打印、解析或执行命令;
-
如果数据很密集,一个帧缓冲不够,后面要升级成队列或环形缓冲。
下一篇我们进入 I2C:
STM32 I2C 入门:先用扫描器找一找总线上有没有设备。
I2C 这部分会先从地址扫描开始,不急着读传感器寄存器。先确认总线上真的有设备,再谈通信。