自定义二进制协议:状态机、CRC 和粘包拆包
为什么不用 JSON?因为一条传感器帧只有 9 个字节,JSON 光是字段名就比数据大了。本文介绍如何从零设计一个适合嵌入式通信的二进制协议,以及如何用有限状态机实现可靠解码。
为什么不用 JSON / Protobuf?
工业传感器通常通过串口或 TCP 推送数据。一条典型的三通道 uint16 数据帧:
| 方案 | 字节数 | 说明 |
|---|---|---|
| JSON | 95 | {"ts":123456,"ch":[320,480,550]} |
| Protobuf | 14 | 2B tag + 12B values + varint |
| 自定义二进制 | 9 | 2B header + 1B len + 1B type + 6B data + 2B CRC |
STM32 的 UART 缓冲区通常只有 256 字节。JSON 一帧占 37% 的缓冲区,自定义协议只占 3.5%。对 100Hz 的推送频率来说,这意味着串口拥塞风险降低了一个数量级。
Protobuf 是不错的选择,但它需要双方都依赖 libprotobuf(嵌入式端 ≈ 40KB ROM)。自定义协议只需要一个 CRC 查表 + 状态机,200 行 C 代码搞定。
帧结构
┌──────────┬────────┬──────┬──────────┬──────────┐
│ Header │ Length │ Type │ Payload │ CRC16 │
│ 0xA55A │ 1B │ 1B │ 0~255B │ 2B LE │
└──────────┴────────┴──────┴──────────┴──────────┘
CRC 范围 ──────────────────────────┘
为什么帧头是 0xA55A?
0xA55A = 1010 0101 0101 1010,交替的比特模式。随机数据中出现这种模式匹配的概率极低(≈ 1/65536),减少了假帧头触发的机会。工业协议中常用的 0x55 / 0xAA 也是同样的原理——交替比特帮助 UART 做时钟恢复。
为什么 CRC 放在末尾而非开头?
STM32 的 DMA 可以一边接收字节一边流水线计算 CRC。如果 CRC 在帧头,接收端必须缓冲完整帧后才能反推 CRC(因为 CRC 覆盖了后面的数据)。放在末尾,DMA 接收完成时 CRC 也刚好算完——零延迟校验。
校验范围为何不含 CRC 自身?
CRC 放在校验范围内 = 自己校验自己。接收端重新计算 CRC 时,如果把接收到的 CRC 字节也纳入计算,永远对不上。这是初学者最容易犯的错误。
CRC16-CCITT:查表 vs 逐位
| 方法 | 每字节耗时 | 256 字节帧 | 适用场景 |
|---|---|---|---|
| 逐位计算 | ~8 次循环 | ~2048 次 | 无 ROM 空间时 |
| 查表 | 1 次查表 | 256 次 | 有 512B ROM |
查表法预先把 0x00~0xFF 对应的 CRC 余数算好存进 uint16_t table[256],每次计算只需:
crc = (crc << 8) ^ table[((crc >> 8) ^ byte) & 0xFF];
多项式 0x1021(CRC-CCITT)的 256 条目表占 512 字节 ROM。STM32F103 有 64KB Flash,完全可以接受。
标准测试向量:crc16_ccitt("123456789", 9) == 0x29B1。每次改协议都跑一遍这个测试,确保 CRC 实现没有被意外修改。
7 状态机:粘包拆包的核心
TCP 是流式传输,不保证边界。一次 recv() 可能收到半帧,也可能收到一帧半。状态机逐字节推进,无论边界在哪里都能正确解码:
0xA5 到达
WAIT_HEADER_H ─────────────────→ WAIT_HEADER_L
│
0x5A 到达 │ 非 0x5A 到达
┌──────────────┘ │
↓ ↓
WAIT_LENGTH WAIT_HEADER_H
│ (假帧头,回退)
↓
WAIT_TYPE
│ │
length=0 │ │ length>0
↓ ↓
WAIT_CRC_L WAIT_PAYLOAD
↑ │
│ │ 收齐 N 字节
│ ↓
└── WAIT_CRC_L
│
↓
WAIT_CRC_H
│
CRC 对 ─────┼───── CRC 错
│ │
emit frameDecoded emit crcError
│ │
└──── 回到 WAIT_HEADER_H ────┘
关键设计点
1. 假帧头回退
数据流中可能出现 0xA5 后跟的不是 0x5A(噪声、帧错位)。此时必须回到 WAIT_HEADER_H 重新查找同步头,不能丢弃这个 0xA5 后面的字节——它们可能是下一帧的真实帧头。
2. 空 payload 帧
心跳帧(0xE2)、握手应答(0xE5)的 payload 长度为 0。状态机在 WAIT_TYPE 检查 length==0 时跳过 WAIT_PAYLOAD,直接进入 CRC 阶段。必须在此处清空 m_payload,否则会残留上一帧的数据。
这是一个实际遇到过并修复的 bug:发送心跳帧后,紧随的数据帧 payload 中混入了心跳帧的旧 payload。
3. CRC CRC_L→CRC_H 用 |= 拼合
case WAIT_CRC_L:
m_crcReceived = byte; // 存低字节
m_state = WAIT_CRC_H;
break;
case WAIT_CRC_H:
m_crcReceived |= (byte << 8); // 拼高字节 ← 是 |= 不是 =
如果写成 m_crcReceived = byte << 8,低字节被覆盖,CRC 校验永远失败。
4. 错误限流
CRC 不匹配时不是每次都打日志——Fuzzing 工具可能每秒生成几百个错误帧,日志洪流会拖垮 UI。用 static int crcErrCount 限定打印前 3 次。
对比 Modbus RTU 解码
自定义协议用帧头同步,Modbus RTU 没有帧头——它用时间间隙(3.5 字符间隔)分割帧。但在 TCP 隧道中这个间隔不可靠,所以 Modbus 解码器采用滑动窗口 CRC 扫描:
for (int frameLen = 4; frameLen <= maxFrame; ++frameLen) {
计算 CRC → 匹配?→ 提取帧
}
对每个可能的帧长度(4~256 字节)都计算一次 CRC,找到最小匹配窗口。这个方案更消耗 CPU(O(N²)),但不需要帧头——典型的鲁棒性换效率。
总结
自定义二进制协议的设计哲学:
- 帧头找边界:用 0xA55A 替代 Modbus 的时间间隙,适合 TCP 隧道
- CRC 收尾:配合 DMA 流水线,收到即验完
- 状态机解码:逐字节推进,天然支持粘包拆包
- 少即是多:9 字节完成 JSON 需要 95 字节才能做的事
这套方案已经在 PulseQt 中稳定运行,100Hz 连续采集无丢帧、无错帧。