一、为什么会有半包与粘包?
串口是字节流设备,没有天然帧边界。用DMA接收时,常见做法是:DMA循环模式搬运数据到缓冲区,同时开启串口空闲中断(IDLE)。当总线空闲一个字节时间后触发IDLE中断,此时读取DMA剩余计数(NDTR)就能算出本帧长度。
但实际工程中会遇到两类边界问题:
- 半包:一帧数据尚未接收完整,IDLE就触发了。原因可能是发送方分包发送、线路干扰、或高波特率下中断响应延迟导致DMA已搬运新数据但IDLE标志未及时清除。
- 粘包:两帧数据之间间隔小于一个字节时间,IDLE未触发,两帧被合并成一包。
根本矛盾在于:IDLE中断标志是“电平/事件”语义,而DMA计数器是“实时”语义。两者读取时刻不一致,就会产生边界错位。
二、核心原理:IDLE与DMA计数器的时序关系
STM32的USART_IDLE标志在检测到总线空闲(接收线持续高电平一个字节时间)后置位。清除顺序必须是:先读SR,再读DR(F1/F4系列)。而DMA的NDTR寄存器在DMA搬运时实时递减。
关键时序陷阱:
- IDLE触发时,DMA可能已经搬运了N个字节,但NDTR读取时又搬了M个字节(中断延迟期间)。
- 若使用循环模式,NDTR从缓冲区大小递减到0后回绕,计算长度时需处理回绕。
- 清除IDLE标志后,若总线上又有新数据,可能立即再次触发IDLE,造成“假半包”。
因此,不能简单用“缓冲区大小 - NDTR”作为帧长,必须结合上一次记录的NDTR做差值,并处理回绕。
三、配置步骤(以STM32F4 HAL库为例)
3.1 初始化DMA为循环模式
// 串口DMA接收配置(CubeMX生成后手动调整)
hdma_usart1_rx.Instance = DMA2_Stream2;
hdma_usart1_rx.Init.Channel = DMA_CHANNEL_4;
hdma_usart1_rx.Init.Direction = DMA_PERIPH_TO_MEMORY;
hdma_usart1_rx.Init.PeriphInc = DMA_PINC_DISABLE;
hdma_usart1_rx.Init.MemInc = DMA_MINC_ENABLE;
hdma_usart1_rx.Init.PeriphDataAlignment = DMA_PDATAALIGN_BYTE;
hdma_usart1_rx.Init.MemDataAlignment = DMA_MDATAALIGN_BYTE;
hdma_usart1_rx.Init.Mode = DMA_CIRCULAR; // 循环模式
hdma_usart1_rx.Init.Priority = DMA_PRIORITY_HIGH;
3.2 开启IDLE中断
__HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE);
HAL_UART_Receive_DMA(&huart1, rx_buf, RX_BUF_SIZE);
3.3 中断服务函数中处理边界
#define RX_BUF_SIZE 256
uint8_t rx_buf[RX_BUF_SIZE];
volatile uint16_t last_pos = 0; // 上一次处理到的位置
void USART1_IRQHandler(void)
{
if (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_IDLE)) {
__HAL_UART_CLEAR_IDLEFLAG(&huart1); // 先清标志
// 读取当前DMA写位置(已搬运字节数)
uint16_t curr_pos = RX_BUF_SIZE - __HAL_DMA_GET_COUNTER(&hdma_usart1_rx);
// 计算本帧长度(处理回绕)
uint16_t len;
if (curr_pos >= last_pos) {
len = curr_pos - last_pos;
} else {
len = RX_BUF_SIZE - last_pos + curr_pos; // 回绕
}
if (len > 0) {
// 将数据从rx_buf[last_pos]开始,长度len,拷贝到应用层队列
// 注意:此处应尽快拷贝,避免DMA覆盖
process_frame(&rx_buf[last_pos], len);
last_pos = curr_pos;
}
}
}
四、半包与粘包的边界处理细节
4.1 半包:如何识别“不完整帧”
半包的本质是IDLE触发时,应用层期望的帧长未达到。处理策略:
- 超时拼接:记录每次IDLE的时间戳,若相邻两次IDLE间隔小于阈值(如5ms),则将数据追加到上一帧末尾,直到满足协议长度或超时。
- 协议层校验:在应用层根据帧头、长度字段、CRC判断完整性,不完整则丢弃或请求重发。
代码示例(超时拼接):
uint32_t last_idle_tick = 0;
#define FRAME_GAP_MS 5
void process_frame(uint8_t *data, uint16_t len)
{
uint32_t now = HAL_GetTick();
if (now - last_idle_tick < FRAME_GAP_MS) {
// 与上一包间隔太短,视为粘包/半包,追加
append_to_pending(data, len);
} else {
// 新帧开始
start_new_frame(data, len);
}
last_idle_tick = now;
}
4.2 粘包:如何拆分
粘包发生在两帧间隔小于1字节时间。IDLE不会触发,DMA连续搬运。此时只能靠协议层帧头+长度字段拆分。建议在应用层实现状态机:
typedef enum { WAIT_HEAD, WAIT_LEN, WAIT_DATA } parse_state_t;
void parse_stream(uint8_t *buf, uint16_t len)
{
static parse_state_t state = WAIT_HEAD;
static uint8_t frame_len = 0;
static uint8_t frame_buf[256];
static uint16_t idx = 0;
for (uint16_t i = 0; i < len; i++) {
switch (state) {
case WAIT_HEAD:
if (buf[i] == 0xAA) { frame_buf[0] = buf[i]; idx = 1; state = WAIT_LEN; }
break;
case WAIT_LEN:
frame_len = buf[i];
frame_buf[idx++] = buf[i];
state = WAIT_DATA;
break;
case WAIT_DATA:
frame_buf[idx++] = buf[i];
if (idx >= frame_len + 2) { // 头+长度+数据
handle_complete_frame(frame_buf, idx);
state = WAIT_HEAD; idx = 0;
}
break;
}
}
}
4.3 缓冲区设计:双缓冲避免覆盖
单缓冲区在中断中拷贝数据时,若拷贝耗时较长,DMA可能已回绕覆盖未处理数据。推荐双缓冲或环形队列+临界区:
// 双缓冲方案:DMA目标区与处理区分离
uint8_t dma_buf[2][RX_BUF_SIZE];
volatile uint8_t active_buf = 0;
// 在IDLE中断中切换缓冲区(需重新配置DMA目标地址)
// 或使用DMA半传输/传输完成中断配合IDLE
更简单的做法是:IDLE中断中只记录位置和长度,将数据快速拷贝到软件环形队列(memcpy),队列长度足够大(如2KB),应用层从队列取数据解析。
五、注意事项与避坑指南
-
清除IDLE标志的顺序:F1/F4必须“先读SR再读DR”,HAL库的
__HAL_UART_CLEAR_IDLEFLAG已封装,但某些系列需手动操作。 -
NDTR读取的原子性:读取NDTR时DMA可能正在递减,建议在中断中关DMA或使用
__HAL_DMA_GET_COUNTER后立即计算,误差通常可接受。 - 高波特率下的中断延迟:115200bps下1字节约87us,若中断延迟超过此值,可能丢失IDLE。建议提高中断优先级,或改用DMA传输完成中断+定时器超时检测。
-
循环模式回绕:务必用
curr_pos >= last_pos判断,否则长度计算错误。 - 半包超时阈值:根据协议最小帧间隔设定,太小会误合并,太大会增加延迟。
- 调试技巧:用GPIO翻转+示波器观察IDLE中断响应时间,验证边界处理逻辑。
六、总结
DMA+IDLE是STM32串口收包的高效方案,但半包与粘包的边界处理不能只依赖硬件标志。核心思路是:中断中只做“位置快照+快速拷贝”,应用层用协议状态机做帧同步。配合双缓冲或环形队列,即可在大多数工业场景下稳定运行。
暂无评论,来抢沙发~