一、为什么空闲中断会“骗”你?
STM32的串口空闲中断(IDLE)在总线空闲一个字节时间后触发,常被用来判断一帧数据接收完成。配合DMA,可以实现“无中断逐字节搬运,空闲时一次性处理”的高效收包。
但现实场景中,数据流并不总是“一帧一空闲”:
- 半包:发送方因调度延迟或分包发送,一帧数据被拆成多段,每段之间出现短暂空闲,导致空闲中断提前触发,收到不完整帧。
- 粘包:发送方连续发送多帧,帧间无空闲或空闲时间极短,DMA连续搬运,空闲中断只在最后一帧结束后触发,导致多帧数据粘在一起。
若直接以空闲中断作为帧边界,必然出现半包误判和粘包无法拆分的问题。
二、边界处理的核心思路
要可靠地区分帧边界,必须引入协议层的辅助信息。常用策略有:
- 固定帧头+长度字段:每帧包含帧头(如0xAA 0x55)和长度域,接收方根据长度域精确切分。
- 超时判定:为每段数据打时间戳,若两次空闲中断间隔小于阈值,则视为粘包,需缓存拼接;若大于阈值,则视为新帧。
- 双缓冲+环形队列:DMA使用双缓冲模式,空闲中断中切换缓冲区,避免数据覆盖,同时将数据推入环形队列供应用层解析。
本文采用帧头+长度+超时辅助的综合方案,兼顾效率与可靠性。
三、硬件与软件配置
以STM32F4系列、HAL库为例,串口USART1配置为:
- 波特率115200,8N1
- DMA接收:循环模式(Circular),缓冲区大小256字节
- 开启串口空闲中断
关键代码(CubeMX生成后补充):
#define RX_BUF_SIZE 256
uint8_t rx_dma_buf[RX_BUF_SIZE];
volatile uint16_t rx_len = 0; // 本次空闲中断收到的长度
volatile uint32_t last_idle_tick = 0; // 上次空闲中断时间戳
void USART1_IRQHandler(void) {
if (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_IDLE)) {
__HAL_UART_CLEAR_IDLEFLAG(&huart1);
// 停止DMA以获取已接收长度
HAL_UART_DMAStop(&huart1);
uint16_t recv_len = RX_BUF_SIZE - __HAL_DMA_GET_COUNTER(huart1.hdmarx);
uint32_t now = HAL_GetTick();
// 处理半包/粘包逻辑(见下文)
process_data(recv_len, now);
// 重启DMA接收
HAL_UART_Receive_DMA(&huart1, rx_dma_buf, RX_BUF_SIZE);
}
}
四、半包与粘包的实战处理
4.1 数据结构设计
定义一个帧缓存结构,用于拼接半包和拆分粘包:
typedef struct {
uint8_t buf[512]; // 拼接缓存
uint16_t len; // 当前缓存长度
uint32_t last_tick; // 最后更新时间
} frame_assembler_t;
frame_assembler_t assembler = {0};
4.2 处理函数实现
// 帧格式:0xAA 0x55 + 长度(1字节) + 数据 + 校验(1字节)
#define FRAME_HEAD1 0xAA
#define FRAME_HEAD2 0x55
#define MAX_FRAME_LEN 256
void process_data(uint16_t recv_len, uint32_t now) {
// 1. 将新数据追加到拼接缓存
if (assembler.len + recv_len > sizeof(assembler.buf)) {
// 溢出保护:丢弃旧数据
assembler.len = 0;
}
memcpy(assembler.buf + assembler.len, rx_dma_buf, recv_len);
assembler.len += recv_len;
assembler.last_tick = now;
// 2. 尝试解析完整帧
uint16_t parsed = 0;
while (assembler.len - parsed >= 4) { // 最小帧长:头2+长度1+校验1
uint8_t *p = assembler.buf + parsed;
if (p[0] != FRAME_HEAD1 || p[1] != FRAME_HEAD2) {
parsed++; // 滑动查找帧头
continue;
}
uint8_t data_len = p[2];
uint16_t frame_len = 4 + data_len; // 头2+长度1+数据+校验1
if (frame_len > MAX_FRAME_LEN) {
parsed++;
continue;
}
if (assembler.len - parsed < frame_len) {
break; // 数据不足,等待后续包(半包)
}
// 校验(简单异或)
uint8_t checksum = 0;
for (uint16_t i = 0; i < frame_len - 1; i++) checksum ^= p[i];
if (checksum != p[frame_len - 1]) {
parsed++; // 校验失败,跳过
continue;
}
// 完整帧处理
handle_frame(p + 3, data_len);
parsed += frame_len;
}
// 3. 移除已解析数据
if (parsed > 0) {
memmove(assembler.buf, assembler.buf + parsed, assembler.len - parsed);
assembler.len -= parsed;
}
// 4. 超时清理:若超过500ms无新数据且缓存非空,视为半包超时
if (assembler.len > 0 && (now - assembler.last_tick) > 500) {
// 可记录错误或丢弃
assembler.len = 0;
}
}
4.3 粘包拆分示例
假设收到连续两帧:AA 55 02 01 02 FC 和 AA 55 01 03 FD,DMA一次搬运全部10字节。process_data会先解析第一帧(长度6),再解析第二帧(长度5),剩余1字节(不足帧头)留在缓存,等待下次数据。
4.4 半包拼接示例
第一段收到 AA 55 03 01 02(5字节),解析时发现长度域为3,完整帧需7字节,数据不足,break等待。第二段收到 03 FC(2字节),拼接后缓存为7字节,成功解析出完整帧。
五、注意事项与优化
- DMA缓冲区大小:应大于最大帧长,避免循环模式覆盖未处理数据。建议256~512字节。
- 中断优先级:串口空闲中断优先级不宜过高,避免阻塞其他关键中断;处理函数中避免耗时操作。
-
时间戳精度:使用
HAL_GetTick()毫秒级足够;若帧间隔极短(<1ms),需改用定时器微秒计数。 -
内存拷贝开销:
memcpy和memmove在数据量大时可能影响实时性,可改用环形缓冲区+零拷贝解析。 - 错误恢复:当缓存溢出或校验连续失败时,应清空缓存并重新同步帧头,防止死锁。
- 多串口场景:每个串口需独立维护assembler实例,避免交叉污染。
六、总结
DMA+空闲中断是STM32串口收包的高效方案,但半包与粘包是必须跨越的坑。通过“帧头+长度”协议配合缓存拼接与滑动解析,可以稳定处理任意边界情况。本文代码可直接移植到HAL库工程,开发者只需根据实际协议调整帧格式与校验方式,即可获得工业级的串口通信可靠性。
暂无评论,来抢沙发~