STM32 串口发一个 7,却回了一屏 7:我绕进 HAL 源码后,才发现先该看 DMA 模式
STM32 串口发一个 7,却回了一屏 7:我绕进 HAL 源码后,才发现先该看 DMA 模式

最近调一个 STM32F103 的串口程序,功能本来很简单:
电脑串口助手发一个字符 7,MCU 收到后回显,同时模拟一次按键按下,再打印 rx_buffer[0] 的内容。
听起来没什么难度。
结果一跑,串口助手直接刷屏。
我只发了一个 7,它却回了一屏 7,像串口自己突然学会了复读。
一开始我以为是回调循环、串扰、中断没清干净,甚至顺着 HAL 源码看了一大圈。最后才发现,真正的问题不是 HAL 有多复杂,而是我一开始就忽略了一个更基础的问题:
DMA 到底把数据写到了哪里?
这篇文章就把这次调试过程复盘一下。重点不是证明某个写法一定错,而是讲清楚:调 STM32 串口 DMA 时,为什么要先看 DMA 模式,再看 HAL 源码。
现场现象:发一个 7,回显出一堆 7
工程环境大概是这样:
-
芯片:STM32F103C8T6
-
串口:USART1
-
引脚:PA9 做 TX,PA10 做 RX
-
接收方式:
HAL_UARTEx_ReceiveToIdle_DMA -
开发环境:CubeMX + VSCode
-
目标功能:串口收到字符后回显,并根据字符模拟按键事件
核心接收逻辑大概是这样:
uint8_t rx_buffer[128];
HAL_UARTEx_ReceiveToIdle_DMA(&huart1, rx_buffer, sizeof(rx_buffer));
收到不定长数据后,在回调里处理:
void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size)
{
if (huart->Instance == USART1)
{
HAL_UART_Transmit(&huart1, rx_buffer, Size, 100);
if (rx_buffer[0] == '7')
{
s_key1.event = PRESSED_EVENT;
}
HAL_UARTEx_ReceiveToIdle_DMA(&huart1, rx_buffer, sizeof(rx_buffer));
}
}
主循环里再根据按键事件打印:
if (s_key1.event == PRESSED_EVENT)
{
printf("%c\r\n", rx_buffer[0]);
s_key1.event = KEY_EVENT_NONE;
}
问题就出在这里。
串口助手只发一次 7,程序却像进入了某种循环:回显、打印、再收到、再回显,最后屏幕上刷出一堆 7。
绕了一圈:我先怀疑了回调循环
第一反应很自然:
是不是 HAL_UARTEx_RxEventCallback() 进了很多次?
因为用的是 ReceiveToIdle_DMA,收到一个字符后触发 IDLE 中断,进入回调。在回调里又调用:
HAL_UART_Transmit(&huart1, rx_buffer, Size, 100);
如果板子上 TX 和 RX 靠得很近,比如 PA9 和 PA10 挨着,或者接线、杜邦线、USB 转串口模块、面包板环境不太干净,确实可能出现一点串扰。
发送出去的字节,被自己的 RX 端又“听”到了一点。
于是思路就变成了:
是不是发送回显时,TX 串扰到了 RX,然后 RX 又触发了一次 DMA + IDLE 回调?
这条怀疑不是瞎猜。串口线没接好、TX/RX 靠太近、地线不好、波特率边沿干扰,这些都可能让接收端看到奇怪的东西。

为了打断这个可能的循环,我先后加过一堆保护逻辑。
比如发送前先关 IDLE 中断:
__HAL_UART_DISABLE_IT(&huart1, UART_IT_IDLE);
暂停 DMA 接收:
HAL_UART_DMAPause(&huart1);
等 IDLE 状态、清数据寄存器、清 IDLE 标志:
while (!__HAL_UART_GET_FLAG(&huart1, UART_FLAG_IDLE))
{
}
__HAL_UART_FLUSH_DRREGISTER(&huart1);
__HAL_UART_CLEAR_IDLEFLAG(&huart1);
最后再恢复 DMA:
HAL_UART_DMAResume(&huart1);
__HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE);
这一套下来,确实有用。
打断点看,回调只进了一次。
这说明什么?
说明“回调反复进”这个问题被压住了,或者至少不是当前继续刷屏的直接原因。
但串口还是会输出很多字符。
那就说明,还有别的地方也在发。
第二个坑:main 循环里还有一次 printf
继续打断点,发现一个容易忽略的地方:
回调里已经回显了一次:
HAL_UART_Transmit(&huart1, rx_buffer, Size, 100);
主循环里又打印了一次:
if (s_key1.event == PRESSED_EVENT)
{
printf("%c\r\n", rx_buffer[0]);
}
而 printf() 最终也走同一个 USART1。
也就是说,整个系统里并不是只有回调在发串口,主循环也在发串口。
如果 DMA 接收已经重新启动,主循环的 printf() 输出又被 RX 端接收到,那么它也可能继续触发接收流程。
这时候你会发现,问题不再是单纯的“回调里能不能发串口”。
它变成了一个链路问题:
串口助手发 7
↓
DMA 收到数据,进入 RxEventCallback
↓
回调里回显
↓
设置按键事件
↓
main 循环 printf
↓
TX 侧输出又可能被 RX 侧收到
↓
新的接收事件出现
所以,先前那些保护逻辑并不是完全没价值。
它们帮我确认了一件事:回调本身没有无限进。
但它们还没有触到根因。
真正的问题藏在更底层:
我读 rx_buffer[0] 的假设,和 DMA 的工作模式不匹配。
真正的问题:DMA 模式和读数据方式不匹配
CubeMX 里 DMA 有一个非常关键的配置:Normal 还是 Circular。
我当时用的是 Circular。
Circular 模式下,DMA 的写指针会在缓冲区里循环移动。
比如缓冲区是 128 字节,它不是每次都从 rx_buffer[0] 开始写,而是类似这样:
rx_buffer[0]
rx_buffer[1]
rx_buffer[2]
...
rx_buffer[127]
rx_buffer[0]
rx_buffer[1]
...
这对连续数据流很有用。
比如你要一直收串口数据,自己维护读指针、写指针、环形缓冲区解析协议,那 Circular 很合适。
但我的代码不是这么写的。
我的代码假设数据永远从 rx_buffer[0] 开始:
HAL_UART_Transmit(&huart1, rx_buffer, Size, 100);
以及:
if (rx_buffer[0] == '7')
{
s_key1.event = PRESSED_EVENT;
}
这就矛盾了。
Circular 模式可能把刚收到的字节写到 rx_buffer[37]、rx_buffer[85],甚至任何一个当前位置。
但我永远只看 rx_buffer[0]。
那结果就可能非常怪:
-
明明发了
7,rx_buffer[0]不一定是这次收到的7 -
回显的数据可能不是最新一帧的真实位置
-
有时看起来能触发,有时又像隔一次才有反应
-
串扰、回显、printf 叠在一起后,现象会更乱

最后的解决办法很简单:
把 DMA 模式从 Circular 改成 Normal。
Normal 模式下,每次重新调用:
HAL_UARTEx_ReceiveToIdle_DMA(&huart1, rx_buffer, sizeof(rx_buffer));
DMA 都从 rx_buffer[0] 重新开始写。
收到 1 个字节,它就在:
rx_buffer[0]
收到 5 个字节,它就在:
rx_buffer[0] ~ rx_buffer[4]
这样代码里的读取假设就成立了:
if (rx_buffer[0] == '7')
{
s_key1.event = PRESSED_EVENT;
}
改成 Normal 后,那些诡异现象基本都消失了:
-
发一个字符回一堆字符的问题消失
-
数据错乱消失
-
隔一次才有反应的问题消失
-
回调和主循环的行为也更容易分析
不是 HAL 突然变简单了。
是 DMA 写数据的位置,终于和代码读数据的位置对上了。
Normal 和 Circular 到底该怎么选?
这块很容易被新手配错。
不是说 Normal 一定好,也不是说 Circular 一定坑。
关键看你的代码怎么读数据。
适合 Normal 的场景
如果你的处理方式是“一帧一帧地收”,每次收到 IDLE 后处理当前这帧,然后重新开启接收:
HAL_UARTEx_ReceiveToIdle_DMA(&huart1, rx_buffer, sizeof(rx_buffer));
并且你希望每一帧都从 rx_buffer[0] 开始,那么 Normal 更适合。
典型写法是:
void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size)
{
if (huart->Instance != USART1)
{
return;
}
// 当前这一帧:rx_buffer[0] 到 rx_buffer[Size - 1]
ProcessFrame(rx_buffer, Size);
// 重新从 rx_buffer[0] 开始接下一帧
HAL_UARTEx_ReceiveToIdle_DMA(&huart1, rx_buffer, sizeof(rx_buffer));
}
这类写法对初学者也更友好,因为数据位置清楚。
适合 Circular 的场景
如果你要做持续接收,不想每一帧都停下来重新启动 DMA,而是让 DMA 一直收,再通过读写指针去解析数据,那么 Circular 就很合适。
但这时候你不能再假设数据从 rx_buffer[0] 开始。
你需要自己维护“上次读到哪里”和“当前 DMA 写到哪里”。
示意逻辑大概是:
static uint16_t old_pos = 0;
uint16_t new_pos;
new_pos = UART_RX_BUFFER_SIZE - __HAL_DMA_GET_COUNTER(huart1.hdmarx);
if (new_pos != old_pos)
{
if (new_pos > old_pos)
{
// 新数据在 old_pos 到 new_pos - 1
ProcessData(&rx_buffer[old_pos], new_pos - old_pos);
}
else
{
// 发生回绕,分两段处理
ProcessData(&rx_buffer[old_pos], UART_RX_BUFFER_SIZE - old_pos);
ProcessData(&rx_buffer[0], new_pos);
}
old_pos = new_pos;
}
这才是 Circular 模式的正确打开方式。
如果只是把 DMA 配成 Circular,但代码仍然写:
if (rx_buffer[0] == '7')
{
}
那就很容易进入玄学调试。
HAL 源码该不该看?
该看。
但顺序要对。
这次调试里,看 HAL 源码并不是没有收获。比如你能理解:
-
HAL_UART_IRQHandler()里 IDLE 标志是怎么处理的 -
HAL_UARTEx_RxEventCallback()为什么会被调用 -
UART_EndRxTransfer()会如何处理接收状态 -
huart->RxState什么时候回到 READY -
HAL_UART_DMAResume()为什么可能看起来没有效果
这些都值得学。
但如果一开始没有确认 DMA 模式、缓冲区位置、读数据方式,直接扎进 HAL 源码,很容易把简单问题看复杂。
就像这次:
我花了不少时间看 IDLE、DMA Pause、Resume、清 DR、清 IDLE 标志。
这些都不是错的。
但最终决定 Bug 是否消失的,是 CubeMX 里那个 DMA Mode:
Circular -> Normal
所以更合理的调试顺序应该是:
-
先确认物理层:TX/RX/GND、波特率、串口助手配置
-
再确认 DMA 模式:Normal 还是 Circular
-
再确认数据位置:新数据到底在
rx_buffer的哪个下标 -
再确认业务逻辑:回调和 main 循环是否都在发串口
-
最后再看 HAL 源码:状态机、中断标志、DMA 状态是否符合预期
顺序很重要。
基础假设错了,看再多源码也容易绕。
以后遇到类似问题,按这个顺序查

如果你以后也遇到 STM32 UART + DMA + IDLE 接收异常,可以按这个清单排:
1. 先问:我到底想怎么收?
如果你想一帧一帧接收:
收到一帧 -> 处理 -> 重新开启接收
优先考虑 Normal。
如果你想连续接收流式数据:
DMA 一直接收 -> 自己维护读写指针 -> 环形解析
再考虑 Circular。
2. 再问:我的代码假设数据在哪?
如果代码里大量出现:
rx_buffer[0]
那你就要非常警惕。
因为这意味着你的代码默认“最新数据从 0 开始”。
Normal 可以满足这个假设。
Circular 不一定满足。
3. 检查回调里做了什么
回调里可以做轻量处理,但不要随便堆太多逻辑。
尤其是:
HAL_UART_Transmit()
printf()
重新开启 DMA
修改全局事件
解析协议
这些混在一起时,问题会很难看。
建议回调里先做三件事:
保存 Size
拷贝或标记数据
置一个事件标志
复杂业务放到主循环或任务里。
4. 确认系统里到底有几个地方在发串口
这次就踩在这里。
回调里发一次,main 循环里又 printf() 一次。
如果你正在排查“为什么串口一直输出”,不要只看回调,也要搜:
HAL_UART_Transmit
printf
puts
日志宏
调试打印函数
很多时候,不是一个地方在发。
5. 最后再看 HAL 源码
HAL 源码不是不能看。
但建议在下面这些问题都确认之后再看:
DMA 模式对不对?
数据位置对不对?
Size 是否符合预期?
回调是否真的反复进入?
main 循环是否也在发送?
串扰是否存在?
这样看源码才有方向。
一个更稳的最小写法
如果你的目标只是“串口收到一帧,处理一帧”,可以先用 Normal 模式,把逻辑写简单一点。
初始化时开启接收:
#define UART_RX_BUF_SIZE 128
uint8_t rx_buffer[UART_RX_BUF_SIZE];
volatile uint8_t uart_rx_event = 0;
volatile uint16_t uart_rx_size = 0;
void App_UART_StartReceive(void)
{
HAL_UARTEx_ReceiveToIdle_DMA(&huart1, rx_buffer, UART_RX_BUF_SIZE);
}
回调里只记录事件:
void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size)
{
if (huart->Instance != USART1)
{
return;
}
uart_rx_size = Size;
uart_rx_event = 1;
}
主循环里处理:
if (uart_rx_event)
{
uart_rx_event = 0;
if (uart_rx_size > 0)
{
HAL_UART_Transmit(&huart1, rx_buffer, uart_rx_size, 100);
if (rx_buffer[0] == '7')
{
s_key1.event = PRESSED_EVENT;
}
}
HAL_UARTEx_ReceiveToIdle_DMA(&huart1, rx_buffer, UART_RX_BUF_SIZE);
}
这个写法不是最高级,但对初学阶段很清楚:
-
DMA Normal 模式
-
每次从
rx_buffer[0]开始收 -
回调只做标记
-
主循环统一处理业务
-
处理完再重新开启接收
等这个跑稳定了,再考虑 Circular、环形缓冲区、协议解析、RTOS 消息队列这些升级版。
小站总结
这次 Bug 表面看是串口疯狂回显。
中间看起来像串扰、IDLE 中断、DMA Pause/Resume、HAL 状态机的问题。
但真正的根因,是代码和 DMA 模式没有对齐:
代码假设:新数据在 rx_buffer[0]
DMA Circular:新数据可能在任意位置
两边一错位,后面所有现象都会变得像玄学。
所以调 STM32 串口 DMA,我建议先记住一句话:
先确认数据在哪,再追中断怎么进。
HAL 源码值得看,但它不是第一步。
第一步永远是你的工程假设:
-
我配的是 Normal 还是 Circular?
-
我代码读的是哪个下标?
-
这两个东西是否匹配?
这一步想清楚,很多串口 Bug 会少绕一大圈。
你们调 HAL_UARTEx_ReceiveToIdle_DMA 时,有没有遇到过“发一个字符,回一堆字符”或者“隔一次才收到”的情况?如果有,建议先回去看一眼 DMA 模式,说不定答案就在 CubeMX 那个下拉框里。
更多推荐

所有评论(0)