一、问题背景:为什么会出现半包与粘包?
在STM32开发中,使用HAL库的HAL_UARTEx_ReceiveToIdle_DMA(或自定义空闲中断)接收不定长数据是常见做法。但实际项目中,常遇到以下现象:
-
半包:一帧数据被拆成两次空闲中断上报,例如发送
{0x01,0x02,0x03},第一次收到{0x01},第二次收到{0x02,0x03}。 -
粘包:两帧数据被合并到一次空闲中断中,例如连续发送
A和B,却一次性收到AB。
这两种情况会导致协议解析错位,例如将半包误判为完整帧,或将粘包中的第二帧数据当作第一帧的延续。
根本原因:
- 空闲中断的触发时机依赖于总线空闲时间(通常为1个字符时间),若发送方帧间隔过短或接收方处理不及时,就会粘包。
- DMA传输完成中断与空闲中断的竞争,或DMA缓冲区未及时重置,可能导致半包。
- 直接使用
HAL_UART_Receive_DMA配合__HAL_UART_GET_FLAG(&huart, UART_FLAG_IDLE)的旧方法,若未正确清除标志或重启DMA,易丢失数据。
二、解决方案:环形缓冲区 + 帧边界检测
核心思想:不依赖单次空闲中断的完整性,而是将每次DMA接收到的数据追加到环形缓冲区,由应用层根据协议特征(如帧头、帧尾、长度字段)进行帧提取。这样即使半包或粘包,也能正确重组或拆分。
2.1 硬件与CubeMX配置
- 启用USART1,模式为Asynchronous。
- 开启USART1全局中断(NVIC中使能USART1 interrupt)。
- 配置DMA:USART1_RX,模式为Circular(循环模式),数据宽度Byte,优先级High。
- 生成代码。
注意:使用循环DMA可避免每次重启DMA,减少半包风险。
2.2 关键代码实现
2.2.1 环形缓冲区定义
#define RING_BUF_SIZE 512
typedef struct {
uint8_t buffer[RING_BUF_SIZE];
volatile uint16_t head; // 写入位置(DMA/中断写)
volatile uint16_t tail; // 读取位置(应用读)
} RingBuffer_t;
RingBuffer_t uart_ring;
2.2.2 空闲中断回调(HAL_UARTEx_RxEventCallback)
HAL库在HAL_UARTEx_ReceiveToIdle_DMA模式下,空闲中断会调用HAL_UARTEx_RxEventCallback,参数Size为本次接收到的字节数。
void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size)
{
if (huart->Instance == USART1) {
// 将DMA缓冲区中的数据拷贝到环形缓冲区
// 注意:此处DMA缓冲区为循环模式,需根据当前DMA写指针计算起始地址
// 简化处理:假设使用线性缓冲区,每次重新启动DMA(不推荐,但便于理解)
// 实际推荐使用循环DMA + 位置计算,见下文说明
for (uint16_t i = 0; i < Size; i++) {
uint16_t next = (uart_ring.head + 1) % RING_BUF_SIZE;
if (next != uart_ring.tail) { // 防止溢出
uart_ring.buffer[uart_ring.head] = huart->pRxBuffPtr[i];
uart_ring.head = next;
}
}
// 重新启动接收(若使用非循环DMA)
// HAL_UARTEx_ReceiveToIdle_DMA(&huart1, huart1.pRxBuffPtr, BUFFER_SIZE);
}
}
更优做法:使用循环DMA,在回调中通过
__HAL_DMA_GET_COUNTER计算本次接收的数据在DMA缓冲区中的位置,避免拷贝整个缓冲区。
2.2.3 应用层帧提取函数
假设协议格式:帧头0xAA 0x55,长度字段len(1字节),数据len字节,校验和1字节。
// 从环形缓冲区读取一个字节,成功返回1,失败返回0
uint8_t RingBuffer_Read(RingBuffer_t *rb, uint8_t *data)
{
if (rb->head == rb->tail) return 0;
*data = rb->buffer[rb->tail];
rb->tail = (rb->tail + 1) % RING_BUF_SIZE;
return 1;
}
// 尝试解析一帧,成功返回帧长度,失败返回0
uint16_t Protocol_ParseFrame(uint8_t *frame, uint16_t max_len)
{
static uint8_t state = 0;
static uint8_t len = 0;
static uint16_t idx = 0;
uint8_t byte;
while (RingBuffer_Read(&uart_ring, &byte)) {
switch (state) {
case 0: // 等待帧头1
if (byte == 0xAA) state = 1;
break;
case 1: // 等待帧头2
if (byte == 0x55) state = 2;
else state = 0;
break;
case 2: // 长度字段
len = byte;
if (len > max_len - 4) { state = 0; break; } // 长度非法
frame[0] = 0xAA; frame[1] = 0x55; frame[2] = len;
idx = 3;
state = 3;
break;
case 3: // 接收数据
frame[idx++] = byte;
if (idx >= 3 + len) state = 4;
break;
case 4: // 校验和(简单累加和)
frame[idx++] = byte;
uint8_t sum = 0;
for (uint16_t i = 0; i < idx - 1; i++) sum += frame[i];
if (sum == byte) {
state = 0;
return idx; // 返回完整帧长度
} else {
state = 0; // 校验失败,丢弃
}
break;
}
}
return 0;
}
2.2.4 主循环调用
uint8_t frame[64];
while (1) {
uint16_t len = Protocol_ParseFrame(frame, sizeof(frame));
if (len > 0) {
// 处理完整帧
ProcessFrame(frame, len);
}
HAL_Delay(1);
}
三、注意事项与优化建议
-
使用循环DMA:避免每次空闲中断后重启DMA,减少半包概率。通过
__HAL_DMA_GET_COUNTER获取当前DMA剩余计数,计算新数据起始位置。 -
空闲中断标志清除:在
HAL_UART_IRQHandler中HAL库已自动处理,但若使用自定义中断,需手动清除UART_FLAG_IDLE并读取USART->DR。 -
缓冲区溢出保护:环形缓冲区写入时检查
head+1 != tail,防止覆盖未读数据。 - 协议设计:尽量包含帧头、长度、校验,便于应用层重组与纠错。
- 中断优先级:DMA中断优先级应高于串口空闲中断,避免数据竞争。
- 调试技巧:使用逻辑分析仪或串口打印环形缓冲区状态,定位半包/粘包发生点。
四、总结
通过环形缓冲区解耦DMA接收与协议解析,结合帧边界检测,可彻底解决半包与粘包导致的解析错位。该方法不依赖空闲中断的完整性,适用于高吞吐、不定长数据的工业场景。代码可直接移植到STM32F1/F4/H7等系列,只需调整HAL库版本对应的回调函数名。
暂无评论,来抢沙发~