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 - 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

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

  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_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 那个下拉框里。

Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐