3. 自定义协议设计:帧头/帧尾/长度/校验/数据域设计、小端与大端字节序、协议版本管理

好,咱们进入实战课最核心的部分——自己动手设计一个通信协议。

说实话,我见过太多工程师在协议上栽跟头。要么是帧头帧尾没设计好,一包数据错位就全乱了;要么是校验太弱,数据被干扰了都不知道。我自己早期做理疗仪时,就吃过这个亏。今天咱们把这块彻底讲透。

3.1 协议帧结构:骨架要搭稳

一个靠谱的自定义协议,就像盖房子。框架必须结实。我习惯把一帧数据分成五个部分:

字段 长度(字节) 说明
帧头 2 固定值 0xAA 0x55,用于同步
长度 2 从本字段之后到校验之前的总字节数
协议版本 1 当前版本号,如 0x01
命令字 1 操作类型,如 0x10 表示设置参数
数据域 N 实际载荷,长度可变
校验 2 CRC16,覆盖从长度到数据域
帧尾 2 固定值 0x0D 0x0A

嗯,这里要注意:帧头我用了两个字节 0xAA 0x55。为什么选这个?因为二进制是 10101010 01010101,交替的比特模式在示波器上一眼就能认出来。调试时特别方便。

核心原则:帧头帧尾必须选择在数据域中极少出现的组合。如果数据域里可能包含 0xAA 0x55,那就得做转义处理,或者用更长的帧头。

3.2 帧头与帧尾:别小看这两个标记

帧头是接收方的「起跑线」。接收端一看到帧头,就知道:好,新的一包来了。

帧尾呢?它是「终点线」。收到帧尾,意味着这包数据完整了。

我曾经遇到过一个坑:某款理疗仪在强电磁干扰下,数据域里偶然出现了帧头相同的字节。结果接收端误判,把一包数据拆成了两包,后面的数据全乱了。

避坑指南:如果数据域中可能包含帧头字节,一定要做字节填充(Byte Stuffing)。比如在 0xAA 后面插入一个 0x00,接收端再把它还原。我当年没做这个,被现场工程师骂惨了。

3.3 长度字段:别让它成为瓶颈

长度字段告诉接收方:这包数据到底有多长。我一般用 2 个字节,最大支持 65535 字节的数据域。对于理疗仪来说,绰绰有余。

但有个细节:长度字段到底从哪算起?

  • 方案A:包含整个帧(包括帧头、帧尾)。
  • 方案B:只包含数据域。
  • 方案C(我推荐的):从长度字段之后开始算,到校验之前。

为什么推荐方案C?因为接收端可以先收帧头,再收长度,然后根据长度值决定还要收多少字节。这样内存分配就简单了。你想想看,如果长度包含了帧头,那接收端还得先减去帧头的长度,多了一步计算。

3.4 校验设计:CRC 是你的好朋友

校验是协议的「守门员」。数据在传输过程中被干扰了,校验能第一时间发现。

我见过有人用简单的累加和做校验。说实话,在实验室里够用。但理疗仪是医疗设备,人命关天。我强烈建议用 CRC16。

// CRC16 计算示例(Modbus 标准)
uint16_t crc16(uint8_t *data, uint16_t len) {
    uint16_t crc = 0xFFFF;
    for (uint16_t i = 0; i < len; i++) {
        crc ^= data[i];
        for (uint8_t j = 0; j < 8; j++) {
            if (crc & 0x0001) {
                crc = (crc >> 1) ^ 0xA001;
            } else {
                crc >>= 1;
            }
        }
    }
    return crc;
}

这段代码我用了快十年了。注意多项式 0xA001 是 0x8005 的反转,这是 Modbus 标准。如果你和上位机通信,用这个最通用。

小技巧:CRC 计算范围一定要覆盖「长度 + 协议版本 + 命令字 + 数据域」。帧头和帧尾不参与校验,因为它们是固定的。我曾经见过有人把帧头也算进去,结果帧头被干扰了,CRC 校验通过但数据全错——因为帧头变了,接收端根本没认出来这是一包数据。

3.5 小端与大端字节序:一个字节的顺序能搞死你

这个问题,说白了就是:多字节数据在内存里怎么排?

  • 小端(Little-Endian):低字节在低地址。比如 0x1234,存成 0x34 0x12。
  • 大端(Big-Endian):高字节在低地址。比如 0x1234,存成 0x12 0x34。

为什么重要?因为理疗仪里通常用 ARM Cortex-M 系列单片机,默认是小端。但上位机可能是 x86 架构,也是小端。看起来一致对吧?

但别忘了,有些传感器模块、蓝牙芯片可能是大端。我遇到过最离谱的一次:设备端发的是小端,上位机按大端解析,结果温度值从 25°C 变成了 6400°C。还好只是测试,不然真把病人烫伤了。

我的建议:在协议文档里明确写明字节序。我习惯统一用小端,然后在协议版本字段里加一个标志位,表示当前使用的是小端还是大端。这样即使以后换芯片,也能兼容。

// 小端与大端转换示例
// 假设 uint16_t value = 0x1234;
// 小端存储:buf[0] = 0x34, buf[1] = 0x12
// 大端存储:buf[0] = 0x12, buf[1] = 0x34

// 从大端读取
uint16_t read_big_endian(uint8_t *buf) {
    return (buf[0] << 8) | buf[1];
}

// 从小端读取
uint16_t read_little_endian(uint8_t *buf) {
    return (buf[1] << 8) | buf[0];
}

3.6 协议版本管理:给协议一个「身份证」

协议版本号,我把它放在帧结构里,紧跟在长度字段后面。为什么放这里?因为接收端收到长度后,马上就能知道协议版本,然后决定怎么解析后面的数据。

版本号怎么设计?我一般用 1 个字节,高 4 位表示主版本,低 4 位表示次版本。比如 0x13 表示 V1.3。

版本号 含义 兼容性
0x10 V1.0 初始版本 -
0x11 V1.1 新增了心跳包 向前兼容
0x20 V2.0 帧结构大改 不兼容 V1.x

主版本号变了,说明协议不兼容。次版本号变了,说明新增了功能但老设备还能用。

血的教训:我曾经在一个项目里没加版本号。后来升级协议,老设备和新上位机通信,解析出来的数据全是错的。现场几十台设备,一台台刷固件,刷了整整两天。从那以后,版本号成了我协议的标配。

3.7 完整的数据帧示例

咱们把上面讲的串起来,看一个完整的例子。假设我们要发送一条指令:设置理疗仪的输出功率为 50W。

// 数据域内容
// 命令字:0x10(设置参数)
// 参数ID:0x01(功率)
// 参数值:50(0x0032,小端存储)

// 完整帧(十六进制):
AA 55  // 帧头
00 07  // 长度:从版本到数据域共7字节
01     // 协议版本 V1.0
10     // 命令字:设置参数
01     // 参数ID:功率
32 00  // 参数值:50(小端)
XX XX  // CRC16 校验
0D 0A  // 帧尾

接收端收到后,先检查帧头 AA 55,然后读长度 0x0007,接着收 7 个字节,计算 CRC 比对,最后检查帧尾 0D 0A。全部通过,才认为这是一包有效数据。

个人习惯:我会在协议里加一个「应答帧」。设备收到指令后,必须回复一包确认。如果上位机没收到确认,就重发。这个机制虽然简单,但能解决 90% 的通信问题。

好了,自定义协议的核心要素就这些。帧头帧尾定边界,长度字段管解析,CRC 校验保安全,字节序统一标准,版本号留后路。把这五点做好,你的协议就稳了。

下一节咱们讲具体的通信流程——怎么发包、怎么收包、怎么处理粘包和断包。到时候我会拿出当年调试理疗仪时踩过的坑,一个一个说给你听。