一、问题背景:为什么半包与粘包难以避免
在STM32等MCU上,使用DMA+串口空闲中断(IDLE)接收变长帧是常见做法:DMA自动搬运数据,空闲中断在总线静默一个字节时间后触发,通知CPU取走数据。但实际场景中:
- 半包:一帧数据尚未接收完整,总线因干扰或发送方分包出现短暂空闲,IDLE提前触发,CPU误认为帧结束。
- 粘包:两帧数据背靠背到达,中间无空闲间隔,IDLE只在第二帧结束后触发一次,缓冲区包含多帧数据。
若解析器简单地将“一次IDLE中断=一帧”,必然错位。根本原因在于空闲中断只反映总线状态,不携带帧边界信息。
二、核心原理:DMA计数与空闲中断的配合
STM32的DMA通道在循环模式或普通模式下,可通过__HAL_DMA_GET_COUNTER()获取剩余传输字节数。设缓冲区总长BUF_SIZE,则已接收字节数:
recv_len = BUF_SIZE - __HAL_DMA_GET_COUNTER(hdma);
空闲中断触发时,recv_len即为本次“疑似帧”的长度。但该长度可能小于真实帧长(半包)或大于单帧长(粘包)。因此,不能直接以recv_len作为帧长,而应将其视为“本次新增数据块”,交由上层协议解析器处理。
三、健壮接收策略:三级防护
3.1 第一级:环形缓冲区解耦DMA与解析
DMA直接写入环形缓冲区(Ring Buffer),空闲中断仅负责更新写指针并通知解析任务。这样即使半包/粘包,数据也不会丢失,解析器可按自身节奏消费。
3.2 第二级:帧头帧尾+长度字段校验
协议设计上增加帧头(如0xAA 0x55)、长度字段和帧尾(如0x0D 0x0A)或CRC。解析器在环形缓冲区中搜索帧头,读取长度字段,校验帧尾/CRC,只有完整且校验通过的帧才被提交。
3.3 第三级:超时重同步机制
若解析器在缓冲区中长时间找不到有效帧头,或帧长度字段异常(超过最大帧长),则丢弃当前字节并滑动窗口重新搜索帧头,避免死锁。
四、完整代码实现(基于STM32 HAL)
4.1 环形缓冲区定义
#define RING_BUF_SIZE 1024
#define MAX_FRAME_LEN 256
typedef struct {
uint8_t buf[RING_BUF_SIZE];
volatile uint16_t head; // 写指针(DMA/中断更新)
volatile uint16_t tail; // 读指针(解析任务更新)
} RingBuffer_t;
RingBuffer_t g_ring;
4.2 DMA+空闲中断初始化
uint8_t g_dma_buf[64]; // DMA临时缓冲区
void UART_DMA_Init(UART_HandleTypeDef *huart) {
__HAL_UART_ENABLE_IT(huart, UART_IT_IDLE);
HAL_UART_Receive_DMA(huart, g_dma_buf, sizeof(g_dma_buf));
}
4.3 空闲中断处理:搬运数据到环形缓冲区
void USART1_IRQHandler(void) {
if (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_IDLE)) {
__HAL_UART_CLEAR_IDLEFLAG(&huart1);
uint16_t remain = __HAL_DMA_GET_COUNTER(huart1.hdmarx);
uint16_t len = sizeof(g_dma_buf) - remain;
if (len > 0) {
RingBuffer_Write(&g_ring, g_dma_buf, len);
}
// 重启DMA接收(普通模式需重新启动)
HAL_UART_AbortReceive(&huart1);
HAL_UART_Receive_DMA(&huart1, g_dma_buf, sizeof(g_dma_buf));
}
HAL_UART_IRQHandler(&huart1);
}
4.4 环形缓冲区写入(考虑溢出)
void RingBuffer_Write(RingBuffer_t *rb, uint8_t *data, uint16_t len) {
for (uint16_t i = 0; i < len; i++) {
uint16_t next = (rb->head + 1) % RING_BUF_SIZE;
if (next != rb->tail) { // 未满
rb->buf[rb->head] = data[i];
rb->head = next;
} else {
// 缓冲区满,丢弃新数据(或覆盖最旧数据,视需求)
break;
}
}
}
4.5 帧解析状态机(在main循环或任务中调用)
// 协议:0xAA 0x55 | len(1B) | payload(len B) | crc16(2B)
#define FRAME_HEAD1 0xAA
#define FRAME_HEAD2 0x55
#define FRAME_MIN_LEN 5 // 头2+长度1+CRC2
typedef enum { WAIT_HEAD1, WAIT_HEAD2, WAIT_LEN, WAIT_PAYLOAD, WAIT_CRC } ParseState_t;
void Frame_Parser(void) {
static ParseState_t state = WAIT_HEAD1;
static uint8_t payload_len = 0, payload_idx = 0;
static uint8_t frame_buf[MAX_FRAME_LEN];
static uint16_t crc_calc = 0;
uint8_t byte;
while (RingBuffer_Read(&g_ring, &byte, 1) == 1) {
switch (state) {
case WAIT_HEAD1:
if (byte == FRAME_HEAD1) state = WAIT_HEAD2;
break;
case WAIT_HEAD2:
state = (byte == FRAME_HEAD2) ? WAIT_LEN : WAIT_HEAD1;
break;
case WAIT_LEN:
payload_len = byte;
if (payload_len > MAX_FRAME_LEN - FRAME_MIN_LEN) {
state = WAIT_HEAD1; // 长度非法,重同步
} else {
frame_buf[0] = FRAME_HEAD1;
frame_buf[1] = FRAME_HEAD2;
frame_buf[2] = payload_len;
payload_idx = 0;
crc_calc = 0xFFFF; // 初始化CRC
state = WAIT_PAYLOAD;
}
break;
case WAIT_PAYLOAD:
frame_buf[3 + payload_idx++] = byte;
crc_calc = CRC16_Update(crc_calc, byte);
if (payload_idx >= payload_len) state = WAIT_CRC;
break;
case WAIT_CRC:
// 接收CRC低字节和高字节,比较
static uint8_t crc_bytes[2], crc_cnt = 0;
crc_bytes[crc_cnt++] = byte;
if (crc_cnt == 2) {
uint16_t crc_recv = crc_bytes[0] | (crc_bytes[1] << 8);
if (crc_recv == crc_calc) {
// 完整帧,提交处理
Frame_Handle(frame_buf, 3 + payload_len + 2);
}
crc_cnt = 0;
state = WAIT_HEAD1;
}
break;
}
}
}
五、注意事项与调试建议
- DMA缓冲区大小:不宜过小(建议≥64字节),否则频繁中断增加CPU负载;也不宜过大,避免单次搬运延迟。
- 空闲中断清除顺序:先清IDLE标志,再读DMA计数,最后重启DMA,防止漏数据。
-
环形缓冲区临界区:
head和tail的更新需考虑中断与主循环并发,建议写指针仅在中断中修改,读指针仅在主循环中修改,天然无锁。 - 超时重同步:若长时间(如500ms)未解析出完整帧,可清空环形缓冲区或滑动丢弃一个字节,防止错误累积。
- CRC校验:强烈建议启用,可有效过滤半包导致的残缺帧。
- 调试技巧:用逻辑分析仪抓取串口波形,对比IDLE中断时刻与帧边界,验证半包/粘包场景。
通过上述“环形缓冲+状态机+校验+重同步”的组合,可彻底解决DMA+空闲中断接收变长帧时的解析错位问题,适用于工业控制、传感器网络等对可靠性要求高的嵌入式场景。
暂无评论,来抢沙发~