一、为什么DMA+空闲中断仍会丢包?
串口接收不定长数据时,传统逐字节中断方式CPU开销大。DMA+空闲中断(IDLE)是经典组合:DMA自动搬运数据,总线空闲时触发中断通知CPU取数。但实际项目中常出现两类问题:
- 半包:一帧数据被拆成两次空闲中断上报,协议解析器收到不完整帧。
- 粘包:两帧数据间隔极短,DMA连续搬运,一次空闲中断上报了多帧拼接数据。
根因在于:空闲中断只表示“当前总线空闲”,并不保证DMA已搬运完所有数据,也不保证两次空闲中断之间恰好是一帧。理解这一点是解决问题的前提。
二、原理剖析:IDLE标志与DMA的时序关系
STM32的USART在检测到接收线空闲(一个字符时间无新数据)时置位IDLE标志。关键时序如下:
- 数据字节到达,DMA请求搬运至缓冲区,
NDTR递减。 - 总线空闲,硬件置位IDLE。
- 若使能IDLE中断,CPU进入ISR。
-
此时DMA可能仍在搬运最后一个字节(尤其高波特率下),直接读取
NDTR计算长度可能少1字节。
因此,在ISR中必须:
- 先清除IDLE标志(读SR再读DR);
- 暂停DMA(或等待传输完成);
- 再读取剩余计数
NDTR,用缓冲区大小 - NDTR得到本次接收长度; - 重启DMA前重置
NDTR。
三、边界处理核心技巧
3.1 长度快照 + 双缓冲
单缓冲在ISR处理期间若新数据到达会覆盖未处理数据。推荐双缓冲:DMA在Buffer A接收时,ISR处理Buffer B;交换时用原子操作或关中断保护。
3.2 半包处理:超时拼接
若协议帧有固定头尾,可在应用层维护“半包缓存”。当解析器发现帧不完整时,将数据暂存,等待下一次空闲中断数据拼接后再解析。注意设置超时清理,避免死等。
3.3 粘包处理:循环解析
一次上报的数据可能包含多帧。解析器应采用while循环:只要缓冲区剩余长度≥最小帧长且校验通过,就持续解析并前移读指针,直到剩余数据不足以构成完整帧,剩余部分作为半包缓存。
3.4 DMA传输完成中断兜底
当一帧长度恰好等于缓冲区大小时,IDLE可能不触发(DMA已满)。务必使能DMA传输完成中断(TC),在TC中断中同样执行“取长度-处理-重启”流程,与IDLE中断共用处理函数。
四、完整代码示例(STM32 HAL库,以USART1为例)
#define RX_BUF_SIZE 256
static uint8_t rx_dma_buf[RX_BUF_SIZE];
static volatile uint16_t rx_len = 0;
static volatile uint8_t rx_flag = 0;
/* 启动接收 */
void UART_DMA_Start(void)
{
HAL_UARTEx_ReceiveToIdle_DMA(&huart1, rx_dma_buf, RX_BUF_SIZE);
__HAL_DMA_DISABLE_IT(&hdma_usart1_rx, DMA_IT_HT); // 关闭半传输中断,减少干扰
}
/* 空闲/传输完成回调(HAL库已封装IDLE与TC) */
void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size)
{
if (huart->Instance == USART1) {
rx_len = Size; // 本次接收长度快照
rx_flag = 1; // 通知应用层
// 注意:HAL库内部已重启DMA,无需手动调用
}
}
/* 应用层解析:处理粘包与半包 */
void UART_ParseTask(void)
{
static uint8_t frame_cache[512];
static uint16_t cache_len = 0;
if (!rx_flag) return;
rx_flag = 0;
// 1. 将新数据追加到半包缓存(防粘包拆分)
if (cache_len + rx_len > sizeof(frame_cache)) {
cache_len = 0; // 溢出保护,丢弃旧数据
}
memcpy(frame_cache + cache_len, rx_dma_buf, rx_len);
cache_len += rx_len;
// 2. 循环解析完整帧(假设帧头0xAA,帧尾0x55,长度字段为第2字节)
uint16_t pos = 0;
while (cache_len - pos >= 4) {
if (frame_cache[pos] != 0xAA) { pos++; continue; } // 找帧头
uint8_t payload_len = frame_cache[pos + 1];
uint16_t frame_len = payload_len + 3; // 头+长度+载荷+尾
if (cache_len - pos < frame_len) break; // 半包,等待下次
if (frame_cache[pos + frame_len - 1] != 0x55) { pos++; continue; }
// 校验通过,处理一帧
ProcessFrame(&frame_cache[pos + 2], payload_len);
pos += frame_len;
}
// 3. 保留剩余半包数据
if (pos > 0) {
memmove(frame_cache, frame_cache + pos, cache_len - pos);
cache_len -= pos;
}
// 超时清理可在此处用tick判断,超过500ms无新数据则清空cache_len
}
五、注意事项与避坑指南
- 清除IDLE标志的顺序:必须“读SR再读DR”,否则标志无法清除,导致中断反复触发。HAL库已处理,但寄存器操作时需注意。
-
DMA重启时机:在ISR中处理完数据后尽快重启DMA,避免后续数据丢失。HAL的
ReceiveToIdle_DMA会在回调前自动重启,但自定义寄存器版本需手动重置NDTR并使能通道。 - 缓冲区大小与帧长:DMA缓冲区应大于最大帧长,否则TC中断与IDLE中断可能同时触发,需在回调中做去重判断。
- 半包超时:协议层必须设置半包超时(如500ms),超时后丢弃不完整帧,防止内存泄漏或解析卡死。
- 中断优先级:DMA与USART中断优先级需合理配置,避免高优先级中断打断取数流程导致长度计算错误。
- 内存对齐:DMA缓冲区建议32字节对齐,提升搬运效率。
掌握以上边界处理技巧,你的串口通信框架即可稳定应对各种不定长收包场景。
暂无评论,来抢沙发~