一、为什么半包与粘包难以避免?
串口通信中,一帧数据往往由多个字节组成,且长度不固定。使用DMA接收时,DMA会在后台将数据搬运到缓冲区,而CPU通过空闲中断(IDLE)感知“一帧结束”。但问题在于:
- 半包:一帧数据尚未接收完整,总线出现短暂空闲(如发送方延时),IDLE中断提前触发,导致只收到部分数据。
- 粘包:两帧数据连续到达,中间没有空闲间隔,IDLE中断只触发一次,缓冲区中包含了多帧数据。
传统做法是在IDLE中断中直接读取DMA剩余计数并拷贝数据,但无法区分上述情况。要解决边界问题,必须理解IDLE中断与DMA计数的时序关系,并引入辅助判定逻辑。
二、IDLE中断与DMA计数的时序关系
STM32的USART空闲中断在检测到总线空闲(一个字节时间无新数据)后置位。此时DMA可能仍在搬运最后一个字节,也可能已经停止。关键点:
- IDLE中断标志置位时,DMA的
NDTR寄存器值反映了已接收但尚未被CPU取走的字节数(假设DMA循环模式或正常模式)。 - 若在IDLE中断中立即读取
NDTR,可能因DMA未完成最后一次传输而得到错误长度。 - 正确做法:先清除IDLE标志,再短暂延时(或等待DMA传输完成标志),然后读取
NDTR。
但仅靠一次读取仍无法区分半包与粘包。我们需要引入时间窗和长度差两个维度。
三、边界判定策略:时间窗+长度差
3.1 核心思想
- 维护一个“上次接收完成时间戳”和“上次帧长度”。
- 每次IDLE中断触发时,计算当前时间与上次时间差
Δt,以及当前长度与上次长度差ΔL。 - 若
Δt小于阈值(如2个字节传输时间),且ΔL为负或异常,则判定为粘包,需拆分;若Δt较大且ΔL为正,则可能是半包后的续传,需合并。
实际工程中更简单有效的做法是:在IDLE中断中不立即处理数据,而是启动一个定时器(如100μs),在定时器回调中再读取DMA计数并处理。这样可过滤掉总线上的短暂抖动,同时给DMA留出完成时间。对于粘包,则通过协议层(如帧头帧尾)二次校验。
3.2 具体步骤
- 配置DMA为循环模式,串口接收缓冲区大小设为最大帧长×2。
- 使能串口IDLE中断,在中断中清除标志并启动单次定时器(如TIM6,100μs)。
- 定时器中断中:读取DMA当前写指针(通过
__HAL_DMA_GET_COUNTER),计算已接收长度,与上次处理位置比较,得到新数据长度。 - 若新数据长度大于0,则拷贝数据到应用缓冲区,并更新上次位置。
- 应用层根据协议(如帧头0xAA、帧尾0x55)校验完整性,若校验失败则丢弃或请求重传。
四、完整代码示例(基于HAL库)
#define RX_BUF_SIZE 256
uint8_t rx_dma_buf[RX_BUF_SIZE];
volatile uint16_t last_pos = 0;
volatile uint8_t frame_ready = 0;
uint8_t app_buf[RX_BUF_SIZE];
uint16_t app_len = 0;
// 串口初始化后启动DMA接收
void uart_dma_init(void) {
HAL_UART_Receive_DMA(&huart1, rx_dma_buf, RX_BUF_SIZE);
__HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE);
}
// IDLE中断处理(在USART1_IRQHandler中调用)
void uart_idle_isr(void) {
if (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_IDLE)) {
__HAL_UART_CLEAR_IDLEFLAG(&huart1);
// 启动单次定时器,延时100us后再处理
HAL_TIM_Base_Start_IT(&htim6);
}
}
// 定时器中断回调(100us后)
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) {
if (htim == &htim6) {
HAL_TIM_Base_Stop_IT(&htim6);
uint16_t curr_pos = RX_BUF_SIZE - __HAL_DMA_GET_COUNTER(huart1.hdmarx);
if (curr_pos != last_pos) {
uint16_t len;
if (curr_pos > last_pos) {
len = curr_pos - last_pos;
memcpy(app_buf, &rx_dma_buf[last_pos], len);
} else {
// 环形缓冲区回绕
len = RX_BUF_SIZE - last_pos + curr_pos;
memcpy(app_buf, &rx_dma_buf[last_pos], RX_BUF_SIZE - last_pos);
memcpy(&app_buf[RX_BUF_SIZE - last_pos], rx_dma_buf, curr_pos);
}
app_len = len;
frame_ready = 1;
last_pos = curr_pos;
}
}
}
// 应用层处理(主循环中)
void process_frame(void) {
if (frame_ready) {
frame_ready = 0;
// 协议校验:帧头0xAA,帧尾0x55
if (app_len >= 3 && app_buf[0] == 0xAA && app_buf[app_len-1] == 0x55) {
// 有效帧,处理数据
handle_valid_frame(app_buf, app_len);
} else {
// 半包或粘包,丢弃或缓存等待后续
// 可在此处实现粘包拆分逻辑
}
}
}
五、注意事项与避坑指南
- DMA模式选择:推荐循环模式,避免每次接收后重新启动DMA。但需注意缓冲区回绕处理。
-
IDLE标志清除顺序:必须先读SR再读DR,或使用HAL宏
__HAL_UART_CLEAR_IDLEFLAG,否则可能无法清除。 - 定时器延时时间:应大于1个字节传输时间(如115200bps下约87μs),建议100~200μs。
- 半包处理:若协议允许,可在应用层设置超时机制,若超过一定时间未收到完整帧则丢弃。
- 粘包拆分:若协议有明确帧头帧尾,可在应用层循环查找帧头并截取,但需注意跨缓冲区情况。
- 中断优先级:IDLE中断优先级应高于定时器中断,确保及时响应。
-
内存对齐:DMA缓冲区建议使用
__attribute__((aligned(4)))对齐,提高访问效率。
通过上述策略,可显著降低半包与粘包导致的解析错误率,适用于Modbus、自定义二进制协议等场景。
暂无评论,来抢沙发~