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 的来龙去脉,以后遇到类似问题就能迅速定位并解决。如果你还有疑问,欢迎继续提问!

相关推荐
为搬砖记录1 天前
杰理AC695N soundbox 3.1.2打开ble宏的编译bug
c语言·开发语言·单片机·bug
席万里1 天前
关于Go1.26.1无法在vscode上运行调试,这是BUG吗
bug
icy、泡芙1 天前
全志 GPIO BUG
linux·bug
青主创享阁3 天前
玄晶引擎2.7.8更新解析:全新UI+Sora接入,功能优化与Bug修复全汇总
人工智能·bug
在坚持一下我可没意见3 天前
软件测试入门复习笔记:BUG篇
笔记·bug·测试
Zwj-c3 天前
【测试报告】个人博客系统测试报告(功能测试、自动化测试、Bug描述)
功能测试·selenium·测试用例·bug
单车少年ing3 天前
一个编码BUG
算法·bug
Zwj-c3 天前
【测试报告】学评一体化平台测试报告(功能测试、自动化测试、Bug描述)
python·功能测试·selenium·测试用例·bug
构建的乐趣3 天前
visual studio监视的有效方法【bug调试】
bug
维齐洛波奇特利(male)3 天前
IDEA 实例类多开bug:勾选后还是只能运行一个类
java·bug·intellij-idea