一、问题现象与背景
在STM32开发中,使用HAL库的HAL_UARTEx_ReceiveToIdle_DMA()配合空闲中断(IDLE)接收不定长串口数据是一种常见方案。但在高波特率(如921600bps)或数据帧密集场景下,开发者常遇到偶发丢包:接收到的数据长度比实际发送少几个字节,或帧尾数据丢失。
典型现象:
- 发送端连续发送多帧数据,接收端偶尔丢失最后一帧的部分字节。
- 丢包概率随波特率升高而增加,低波特率下几乎不出现。
- 使用
HAL_UARTEx_ReceiveToIdle_DMA()时,回调HAL_UARTEx_RxEventCallback中Size参数偶尔小于实际帧长。
二、原理剖析:DMA+空闲中断接收机制
2.1 HAL库的接收流程
HAL_UARTEx_ReceiveToIdle_DMA()启动DMA接收,并开启空闲中断。当总线空闲(超过1个字符时间无数据)时触发IDLE中断,HAL库在中断中计算已接收数据长度,并调用HAL_UARTEx_RxEventCallback。
关键点:
- DMA配置为循环模式(Circular)时,DMA计数器持续递减,空闲中断触发时通过
__HAL_DMA_GET_COUNTER()计算已接收字节数。 - 若DMA配置为正常模式(Normal),则每次接收完成后需重新启动,否则后续数据丢失。
2.2 丢包根因分析
根因1:DMA半传输中断干扰
HAL库默认开启DMA半传输中断(HT),当接收数据达到缓冲区一半时触发HAL_UARTEx_RxEventCallback(事件类型为HAL_UART_RXEVENT_HT)。若用户未区分事件类型,误将半传输事件当作完整帧处理,会导致数据被截断。
根因2:空闲中断响应延迟
高波特率下,一帧数据结束到下一帧开始间隔极短(可能仅几个us)。若CPU正在处理其他中断(如SysTick、DMA HT中断),IDLE中断可能被延迟响应,期间DMA继续写入新数据,导致上一帧尾部被覆盖。
根因3:缓冲区覆盖
若使用固定长度缓冲区且未及时处理数据,DMA循环写入会覆盖未读数据。尤其当HAL_UARTEx_RxEventCallback中处理耗时较长时,下一帧数据已开始写入。
根因4:HAL库内部状态机冲突
HAL_UARTEx_ReceiveToIdle_DMA()在回调中若未正确重启接收,或多次调用导致DMA状态混乱,也会引发丢包。
三、排查方法与实战定位
3.1 逻辑分析仪抓包
使用逻辑分析仪同时抓取TX和RX引脚波形,对比发送与接收数据。若RX波形完整但MCU接收数据缺失,则问题在软件侧。
3.2 代码级调试
在HAL_UARTEx_RxEventCallback中打印事件类型和Size:
void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size)
{
if (huart->Instance == USART1) {
printf("Event: %d, Size: %d\n", huart->RxEventType, Size);
}
}
观察是否出现HAL_UART_RXEVENT_HT事件被误处理,或Size小于预期。
3.3 检查DMA配置
确认DMA模式为DMA_CIRCULAR,且缓冲区大小足够容纳最大帧长。若使用DMA_NORMAL,需在每次回调中重新启动接收。
四、环形缓冲区改造方案
4.1 设计思路
- 使用双缓冲+环形队列:DMA直接写入环形缓冲区,空闲中断仅更新写指针,不拷贝数据。
- 关闭DMA半传输中断,仅保留空闲中断和传输完成中断。
- 在空闲中断中计算本次接收长度,更新环形缓冲区写索引,并通知应用层读取。
4.2 环形缓冲区结构
typedef struct {
uint8_t *buffer; // 数据区
uint16_t size; // 总大小
volatile uint16_t head; // 写索引(DMA写入位置)
volatile uint16_t tail; // 读索引(应用读取位置)
} ring_buffer_t;
4.3 关键代码实现
初始化与启动
#define RX_BUF_SIZE 512
static uint8_t dma_buf[RX_BUF_SIZE];
static ring_buffer_t ring;
void UART_DMA_Init(void)
{
ring.buffer = dma_buf;
ring.size = RX_BUF_SIZE;
ring.head = 0;
ring.tail = 0;
// 启动DMA接收(循环模式)
HAL_UARTEx_ReceiveToIdle_DMA(&huart1, dma_buf, RX_BUF_SIZE);
// 关闭半传输中断
__HAL_DMA_DISABLE_IT(&hdma_usart1_rx, DMA_IT_HT);
}
空闲中断回调
void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size)
{
if (huart->Instance == USART1) {
// 仅处理空闲事件和传输完成事件
if (huart->RxEventType == HAL_UART_RXEVENT_IDLE ||
huart->RxEventType == HAL_UART_RXEVENT_TC) {
// 更新写索引(环形回绕)
ring.head = (ring.head + Size) % ring.size;
// 通知应用层(如置标志位或发送信号量)
// ...
}
}
}
应用层读取
uint16_t RingBuffer_Read(ring_buffer_t *rb, uint8_t *data, uint16_t len)
{
uint16_t available = (rb->head - rb->tail + rb->size) % rb->size;
if (len > available) len = available;
for (uint16_t i = 0; i < len; i++) {
data[i] = rb->buffer[rb->tail];
rb->tail = (rb->tail + 1) % rb->size;
}
return len;
}
4.4 注意事项
- 关闭半传输中断:避免HT事件干扰,仅依赖IDLE和TC事件。
- 缓冲区大小:应大于最大帧长的2倍,防止DMA回绕覆盖未读数据。
-
临界区保护:
head和tail的更新需考虑中断与主循环的并发,必要时使用__disable_irq()短暂保护。 - 空闲中断标志清除:HAL库已自动处理,但若使用LL库需手动清除IDLE标志。
-
DMA循环模式:确保DMA配置为
DMA_CIRCULAR,否则需在每次回调中重启接收。
五、总结
通过关闭DMA半传输中断、采用环形缓冲区解耦DMA写入与应用读取,并合理设置缓冲区大小,可有效解决HAL库DMA+空闲中断接收不定长数据时的丢包问题。该方案已在实际项目中验证,在921600bps下连续接收无丢包。开发者应根据具体场景调整缓冲区大小与中断优先级,确保系统实时性。