news 2026/6/18 20:39:19

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
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]

那结果就可能非常怪:

  • 明明发了7rx_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 -1ProcessData(&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

所以更合理的调试顺序应该是:

  1. 先确认物理层:TX/RX/GND、波特率、串口助手配置

  2. 再确认 DMA 模式:Normal 还是 Circular

  3. 再确认数据位置:新数据到底在rx_buffer的哪个下标

  4. 再确认业务逻辑:回调和 main 循环是否都在发串口

  5. 最后再看 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_Transmitprintfputs 日志宏 调试打印函数

很多时候,不是一个地方在发。

5. 最后再看 HAL 源码

HAL 源码不是不能看。

但建议在下面这些问题都确认之后再看:

DMA 模式对不对? 数据位置对不对? Size 是否符合预期? 回调是否真的反复进入? main 循环是否也在发送? 串扰是否存在?

这样看源码才有方向。


一个更稳的最小写法

如果你的目标只是“串口收到一帧,处理一帧”,可以先用 Normal 模式,把逻辑写简单一点。

初始化时开启接收:

#define UART_RX_BUF_SIZE 128uint8_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 那个下拉框里。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/6/18 20:37:59

中小企业 AI 招聘落地实操:世纪云猎公域人才向量检索完整操作流程

避开传统ATS无寻源短板,手把手演示从岗位发布到候选人跟进全链路数字化方案 在前一篇《企业招聘数字化避坑:传统ATS不是万能解,中小企业AI招聘系统选型思路》中,我们拆解了以北森、Moka为代表的传统ATS底层架构,点明成…

作者头像 李华
网站建设 2026/6/18 20:35:52

PolarQuant-KV:面向消费级GPU的KV Cache双压缩方案

1. 这不是“又一个量化方案”,而是一次对 KV Cache 本质的重新丈量你有没有在 RTX 5060 Ti 上跑过 32K 上下文的 Qwen2.5?我试过——显存直接爆掉,报错信息还没刷完,风扇已经叫得像要起飞。这不是模型太重,是 KV Cache…

作者头像 李华
网站建设 2026/6/18 20:31:24

2026免费图片去水印工具推荐:无广告免费图片去水印网站、手机免费去水印APP无付费限制、在线电脑手机工具全整理

日常浏览网页、刷短视频时,常会遇到带有水印的图片素材,很多人仅出于个人收藏、学习参考的需求,想要干净无水印的原图。2026 年市面上有大量完全免费、无强制付费门槛的去水印工具,覆盖手机 APP、网页在线端、微信小程序、电脑本地…

作者头像 李华
网站建设 2026/6/18 20:24:09

Pandas多维聚合实战:银行级滚动计算与业务逻辑内嵌

1. 项目概述:为什么多维聚合不是“加个GROUP BY”那么简单我在银行数据平台组干了八年,从最早用SQL写几十行嵌套子查询做客户分层,到后来带团队设计实时风控指标引擎,踩过的坑比写的代码还多。今天聊的这个主题——“多维聚合”&a…

作者头像 李华
网站建设 2026/6/18 20:20:39

老吴申论范文100篇|模板|高分

老吴申论范文100篇|模板|高分 申论是公务员考试中拉开分差的关键科目,大作文写作更是重中之重。本资料精选老吴老师整理的申论范文100篇,涵盖乡村振兴、基层治理、生态文明、科技创新、民生保障等高频主题,每篇范文均附结构拆解与写作思路分…

作者头像 李华
网站建设 2026/6/18 20:19:44

Notebook到生产:MLOps实战中的模型可观测性与熔断机制

1. 项目概述:这不是“部署”,是让模型真正活在业务流水线里“From Notebook to Production: Running ML in the Real World (Part 4)”——这个标题里藏着一个被太多人轻描淡写、却足以让90%的机器学习项目半途夭折的核心真相:Notebook不是终…

作者头像 李华