STM32 HAL库DMA+空闲中断接收串口不定长数据的丢包排查与环形缓冲区改造
一、为什么“DMA+空闲中断”会丢包
DMA+空闲中断是STM32串口接收不定长数据的经典组合:DMA负责搬运,CPU只在总线空闲时处理一帧。但在高吞吐或突发场景下,丢包几乎必然发生,根因有三类:
- 空闲中断与DMA搬运的竞争:空闲中断触发时,DMA可能仍在搬运最后几个字节,若此时直接读取NDTR并重启DMA,会截断或覆盖数据。
- 单缓冲覆盖:HAL库默认使用单缓冲,CPU处理上一帧期间,新帧到达会直接覆盖缓冲区。
- 中断优先级与处理时长:空闲中断中做解析、打印等耗时操作,会阻塞后续DMA完成中断,导致NDTR计算错位。
二、丢包复现与定位方法
2.1 复现路径
- 上位机以1Mbps连续发送长度不等的帧,帧间隔小于1ms。
- 在
HAL_UARTEx_RxEventCallback中打印Size,对比发送计数。 - 观察是否出现
Size为0、帧被截断或整帧丢失。
2.2 定位要点
- 检查
huart->RxState是否在回调中被意外置为READY。 - 用逻辑分析仪抓TX/RX,确认是硬件层丢字节还是软件层丢帧。
- 在空闲回调中只做“记录+重启”,把解析放到主循环,验证是否仍丢包。
三、环形缓冲区改造方案
核心思想:DMA只负责把数据写入一个足够大的环形缓冲区,空闲中断只更新写指针并唤醒处理任务,读写分离,彻底消除覆盖。
3.1 数据结构
#define RING_BUF_SIZE 2048
typedef struct {
uint8_t buf[RING_BUF_SIZE];
volatile uint16_t head; // 写指针,仅DMA/空闲中断修改
volatile uint16_t tail; // 读指针,仅主循环修改
volatile uint16_t last_pos;// 上次DMA位置
} ring_t;
static ring_t g_ring;
3.2 初始化与启动
void ring_uart_init(UART_HandleTypeDef *huart)
{
g_ring.head = 0;
g_ring.tail = 0;
g_ring.last_pos = 0;
// 使用ReceiveToIdle_DMA,HAL会自动开启空闲中断
HAL_UARTEx_ReceiveToIdle_DMA(huart, g_ring.buf, RING_BUF_SIZE);
// 关闭半满中断,避免频繁打断
__HAL_DMA_DISABLE_IT(huart->hdmarx, DMA_IT_HT);
}
3.3 空闲回调:只更新写指针
void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size)
{
if (huart->Instance != USART1) return;
uint16_t pos = RING_BUF_SIZE - __HAL_DMA_GET_COUNTER(huart->hdmarx);
// 计算本次新增数据长度(处理回绕)
uint16_t len = (pos >= g_ring.last_pos)
? (pos - g_ring.last_pos)
: (RING_BUF_SIZE - g_ring.last_pos + pos);
g_ring.head = (g_ring.head + len) % RING_BUF_SIZE;
g_ring.last_pos = pos;
// 重启DMA,保持接收不中断
HAL_UARTEx_ReceiveToIdle_DMA(huart, g_ring.buf, RING_BUF_SIZE);
__HAL_DMA_DISABLE_IT(huart->hdmarx, DMA_IT_HT);
}
3.4 主循环读取
uint16_t ring_read(uint8_t *dst, uint16_t max_len)
{
uint16_t head = g_ring.head;
uint16_t tail = g_ring.tail;
uint16_t avail = (head >= tail) ? (head - tail)
: (RING_BUF_SIZE - tail + head);
if (avail == 0) return 0;
if (avail > max_len) avail = max_len;
for (uint16_t i = 0; i < avail; i++) {
dst[i] = g_ring.buf[(tail + i) % RING_BUF_SIZE];
}
g_ring.tail = (tail + avail) % RING_BUF_SIZE;
return avail;
}
四、配置步骤
- CubeMX中使能USART1的DMA RX(Circular模式关闭,Normal模式即可,因为每次回调重启)。
- 空闲中断由
HAL_UARTEx_ReceiveToIdle_DMA自动开启,无需手动使能。 - 中断优先级:DMA RX中断优先级高于串口空闲中断,保证NDTR读取准确。
- 主循环中调用
ring_read,解析逻辑与接收解耦。
五、注意事项
- 不要在半满中断中重启DMA,半满中断只适合流式处理,重启会破坏NDTR连续性。
-
head与tail必须用volatile,且读指针只由主循环修改,避免竞态。 - 环形缓冲区容量应大于“一帧最大长度×2”,防止处理延迟导致回绕覆盖。
- 回调中禁止耗时操作,解析、打印一律放到主循环。
- 若使用
ReceiveToIdle_DMA,HAL版本需≥1.8.0,旧版本需手动开启UART_IT_IDLE。 - 调试时可用GPIO翻转测量回调执行时间,确认未超过帧间隔。
六、小结
丢包的本质是“单缓冲+回调内耗时”造成的读写竞争。通过DMA直写环形缓冲区、空闲中断只更新写指针、主循环异步读取,可将接收链路的可靠性提升到工业级水平。该方案同样适用于CAN、SPI等DMA接收场景。