你好!这节课我们来解决一个在开发中非常容易遇到的 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_pos到Size-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 批量传输导致的误超时。
五、总结
通过这节课,我们学到了:
-
UART DMA 接收的回调不仅会在完成/空闲时触发,也会在半传输时触发,不能一进回调就重启 DMA。
-
正确做法:在半传输时只处理数据,不重启;在完成/空闲时才重启 DMA,并用一个变量记录已处理位置。
-
使用 DMA 接收会影响 libmodbus 的字节超时判断,因为数据是批量通知的,需要适当增大字节超时时间,比如 20ms。
-
修改后的回调代码和超时设置,可以保证数据不丢失,通信稳定。
希望这份讲解能帮你彻底理解这个 Bug 的来龙去脉,以后遇到类似问题就能迅速定位并解决。如果你还有疑问,欢迎继续提问!