9.2.3 UART 驱动严重 Bug(保姆级讲解)

你好!这节课我们来解决一个在开发中非常容易遇到的 UART DMA 接收的 Bug。这个 Bug 会导致数据丢失,而且很难排查。我们会结合代码和原理,一步步分析问题根源,并给出正确的解决方案。同时,还会讨论在使用 libmodbus 时,如何正确设置超时时间,以避免因 DMA 特性引发的通信问题。


目录

[一、问题背景:我们想用 DMA 高效接收串口数据](#一、问题背景:我们想用 DMA 高效接收串口数据)

[二、Bug 是怎么产生的?](#二、Bug 是怎么产生的?)

[2.1 DMA 的"半传输"中断](#2.1 DMA 的“半传输”中断)

[2.2 错误的重启导致数据覆盖](#2.2 错误的重启导致数据覆盖)

三、正确的处理方式

[四、对 libmodbus 超时时间的影响](#四、对 libmodbus 超时时间的影响)

[4.1 这两个超时分别起什么作用?](#4.1 这两个超时分别起什么作用?)

[4.2 使用 DMA 接收时,为什么需要调整 _BYTE_TIMEOUT?](#4.2 使用 DMA 接收时,为什么需要调整 _BYTE_TIMEOUT?)

[4.3 计算举例](#4.3 计算举例)

[4.4 修改后的超时值](#4.4 修改后的超时值)

五、总结


一、问题背景:我们想用 DMA 高效接收串口数据

在嵌入式系统中,使用 DMA 接收串口数据可以大大减轻 CPU 负担。通常我们调用 HAL_UARTEx_ReceiveToIdle_DMA() 函数启动一次 DMA 接收,并指定一个缓冲区(比如 100 字节)。DMA 会自动把串口收到的数据存入这个缓冲区,当发生以下情况时会触发回调函数 HAL_UARTEx_RxEventCallback

  • 数据接收完成(即接收到了指定的字节数,比如 100 字节)

  • 或者串口总线空闲(Idle 中断)时,即使没收满指定长度,也会触发回调

这样,我们就可以在回调里处理接收到的数据,然后重新启动下一次接收。


二、Bug 是怎么产生的?

我们之前的代码错误地认为:只要进入 HAL_UARTEx_RxEventCallback 回调,就表示一次接收已经结束 ,于是立即调用 HAL_UARTEx_ReceiveToIdle_DMA 重新启动下一次接收。然而,这个回调不仅仅在接收完成或空闲时触发,还会在"一半数据"被接收时触发

2.1 DMA 的"半传输"中断

DMA 传输有一个特性:当传输长度较大时,可以设置"半传输中断"(Half Transfer Interrupt)。对于 STM32 的 HAL 库,当你调用 HAL_UARTEx_ReceiveToIdle_DMA 时,它内部会启用 DMA 的半传输中断和完全传输中断。当 DMA 传输到一半(比如接收 100 字节,当接收了 50 字节时),就会触发半传输中断,然后 HAL 库会调用 HAL_UARTEx_RxEventCallback,并传入一个参数 Size,这个 Size 表示当前已经接收到的字节数(即 50)。

2.2 错误的重启导致数据覆盖

假设我们设置了缓冲区 100 字节,DMA 正在接收数据。当接收到 50 字节时,触发了半传输中断,进入回调。在回调中,我们错误地认为接收已经完成,于是立即再次调用 HAL_UARTEx_ReceiveToIdle_DMA 重新启动一次新的 DMA 接收。这时会发生什么?

  • 新的 DMA 接收会重新使用同一个缓冲区(或者另一个缓冲区)从头开始存放数据。

  • 但是原来的 DMA 传输还在继续(因为半传输后还有另一半数据要接收),两个 DMA 可能会同时操作同一个缓冲区,导致数据混乱,或者后半部分数据被覆盖。

  • 最终,我们只能得到一部分数据,甚至可能完全错乱。

这就是 Bug 的根源:错误地在半传输回调中重启了 DMA,导致数据丢失。


三、正确的处理方式

我们需要区分回调被触发的 原因 :是"半传输"还是"完成/空闲"。HAL 库提供了一个变量 huart->RxEventType 来告诉我们触发回调的事件类型:

  • HAL_UART_RXEVENT_HT:半传输事件

  • HAL_UART_RXEVENT_TC:传输完成事件

  • HAL_UART_RXEVENT_IDLE:空闲事件

正确的逻辑是

  • 无论是半传输、完成还是空闲,我们都应该将本次收到的数据(从上次记录的位置到当前 Size 位置)送入队列或进行处理。

  • 但只有不是半传输事件时,才意味着这一次 DMA 接收已经结束(要么是收满了,要么是空闲了),这时才能重新启动下一次 DMA 接收。

特别注意:半传输事件发生时,我们不应该重启 DMA,因为 DMA 还在继续接收剩余数据。

下面给出正确的回调函数代码):

cs 复制代码
void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size)
{
    PUART_Data pdata;
    static uint16_t old_pos;   // 注意:这里 static 有问题,如果多个 UART 共用会有冲突,应放在 pdata 结构体中

    if (huart == &huart2)
    {
        pdata = &g_uart2_data;
    }
    if (huart == &huart4)
    {
        pdata = &g_uart4_data;
    }

    /* 将新收到的数据(从 old_pos 到 Size-1)送入队列 */
    for (int i = old_pos; i < Size; i++)
    {
        xQueueSendFromISR(pdata->rxQueue, (const void *)&pdata->rx_buf[i], NULL);
    }

    old_pos = Size;  // 记录当前已处理到的位置

    // 如果不是半传输事件,说明本次 DMA 接收已结束(完成或空闲),可以重新启动
    if (huart->RxEventType != HAL_UART_RXEVENT_HT)
    {
        old_pos = 0;  // 重置位置
        /* 重新启动 DMA+IDLE 接收 */
        HAL_UARTEx_ReceiveToIdle_DMA(pdata->huart, pdata->rx_buf, UART_RX_BUF_LEN);
    }
}

代码解释

  • old_pos 记录上次已经处理到哪个位置。由于半传输、完成、空闲可能会多次进入回调,我们需要知道哪些数据是新的。

  • 每次进入,我们将从 old_posSize-1 的数据送入队列(比如给协议栈处理)。

  • 然后更新 old_pos = Size

  • 关键判断:如果事件类型不是 HAL_UART_RXEVENT_HT(即不是半传输),说明这次回调代表一个传输阶段的结束(可能是收满了,或者线路空闲了),这时我们可以安全地重启 DMA 接收,同时把 old_pos 清零,准备下一轮。

  • 如果事件是半传输,我们只把数据入队,不重启 DMA,DMA 会继续接收后半部分,后续还会再触发完成或空闲回调。

这样,半传输来的数据被及时处理,但 DMA 不会被中断,后半部分数据也能正常接收。


四、对 libmodbus 超时时间的影响

当我们使用 libmodbus 库进行 Modbus 通信时,它内部使用串口接收,并且有两个超时时间需要设置:

cs 复制代码
// modbus-private.h
#define _RESPONSE_TIMEOUT 500000  // 等待响应的总超时,单位微秒 (0.5秒)
#define _BYTE_TIMEOUT     10000    // 字节间超时,单位微秒 (10毫秒)
4.1 这两个超时分别起什么作用?
  • _RESPONSE_TIMEOUT:客户端发出请求后,等待服务器响应的最大时间。服务器可能需要处理数据然后回复,这个时间要足够大,比如 500ms。

  • _BYTE_TIMEOUT:当客户端开始收到响应后,在接收后续字节时,如果两个字节之间的间隔超过这个时间,就认为接收结束(或出错)。通常 Modbus 协议建议设为 3.5 个字符时间,对于 115200 波特率,3.5 个字符 ≈ 3.5 * 10 / 115200 ≈ 0.3ms,但为了可靠,一般设大一些。

4.2 使用 DMA 接收时,为什么需要调整 _BYTE_TIMEOUT?

在 DMA 接收方式下,数据从串口硬件到达后,由 DMA 自动存入内存,CPU 并不会在每个字节到达时立即得到通知。只有当以下情况发生时,应用程序才会知道有新数据:

  • DMA 半传输中断

  • DMA 完成传输中断

  • 空闲中断

因此,应用程序接收到数据是"批量的",不是逐字节的。例如,我们启动了一次 256 字节的 DMA 接收,服务器回复了 200 字节。这 200 字节可能分两次触发回调:先触发半传输(128 字节),再触发完成传输(72 字节,或因为空闲而触发)。但在这两次回调之间,CPU 并不知道中间还有字节在陆续到达。

现在考虑 libmodbus 的字节超时:当 libmodbus 收到第一个字节后,它开始计时,如果后续字节间隔超过 _BYTE_TIMEOUT,就认为一帧结束。但在 DMA 方式下,后续字节虽然已经到达并被 DMA 存入内存,但 CPU 可能还没有被通知到(因为还没触发中断)。如果 _BYTE_TIMEOUT 设置得太短(比如 10ms),那么很可能在第一次半传输回调后,libmodbus 收到了 128 字节,然后它等待下一个字节,但由于下一次中断(完成)可能还在 11ms 之后(取决于波特率和剩余字节数),这就会导致 libmodbus 认为超时,从而丢弃数据。

4.3 计算举例

假设波特率 115200,10 位/字节(1 起始 + 8 数据 + 1 停止),则传输 1 字节需约 86.8μs。传输 128 字节需约 11.1ms。也就是说,从半传输中断到完成中断,大约间隔 11.1ms。如果 _BYTE_TIMEOUT 设为 10ms,那么在半传输后,libmodbus 等待后续字节时,就会因为超过 10ms 而超时,导致接收失败。

因此,我们需要将 _BYTE_TIMEOUT 设置得比可能的最大字节间隔稍大。考虑到最坏情况(例如完成中断前的最后一个字节到完成中断),可以将 _BYTE_TIMEOUT 设为 20ms 或更大,以确保安全。

4.4 修改后的超时值

在 libmodbus 的配置头文件中,我们修改:

cs 复制代码
#define _RESPONSE_TIMEOUT 500000   // 0.5秒,不变
#define _BYTE_TIMEOUT     20000     // 20毫秒,原先10毫秒不够

这样就能避免因 DMA 批量传输导致的误超时。


五、总结

通过这节课,我们学到了:

  1. UART DMA 接收的回调不仅会在完成/空闲时触发,也会在半传输时触发,不能一进回调就重启 DMA。

  2. 正确做法:在半传输时只处理数据,不重启;在完成/空闲时才重启 DMA,并用一个变量记录已处理位置。

  3. 使用 DMA 接收会影响 libmodbus 的字节超时判断,因为数据是批量通知的,需要适当增大字节超时时间,比如 20ms。

  4. 修改后的回调代码和超时设置,可以保证数据不丢失,通信稳定。

希望这份讲解能帮你彻底理解这个 Bug 的来龙去脉,以后遇到类似问题就能迅速定位并解决。如果你还有疑问,欢迎继续提问!

相关推荐
qq_24218863322 天前
代码诊疗室——疑难Bug破解战
bug
Moshow郑锴4 天前
Java SpringBoot 疑难 Bug 排查思路解析:从“语法正确”到“行为相符”
java·spring boot·bug
人间花海4 天前
BUG终结者:挑战你的调试极限
bug
2401_858286114 天前
OS54.【Linux】System V 共享内存(3) “共享内存+管道“修bug记录
linux·运维·服务器·算法·bug
Kurbaneli5 天前
代码诊疗室——疑难Bug破解战
bug
Mr -老鬼7 天前
从 0 到 1 落地:Rust + Salvo 实现用户系统与 Bug 管理系统
开发语言·rust·bug
剑亦未配妥7 天前
CSS 折叠引发的 scrollHeight 异常 —— 一次 Blink 引擎的诡异 Bug
前端·css·bug
gfdgd xi7 天前
GXDE OS 25.3.1 更新了!修复更多 bug 了!
linux·c++·操作系统·bug·deepin
Groundwork Explorer9 天前
wiznet5k.py硬件驱动w5500芯片网卡bug
bug