输入关键词开始搜索

自定义二进制协议:状态机、CRC 和粘包拆包

为什么不用 JSON?因为一条传感器帧只有 9 个字节,JSON 光是字段名就比数据大了。本文介绍如何从零设计一个适合嵌入式通信的二进制协议,以及如何用有限状态机实现可靠解码。


为什么不用 JSON / Protobuf?

工业传感器通常通过串口或 TCP 推送数据。一条典型的三通道 uint16 数据帧:

方案字节数说明
JSON95{"ts":123456,"ch":[320,480,550]}
Protobuf142B tag + 12B values + varint
自定义二进制92B 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 连续采集无丢帧、无错帧。