8、自定义序列化协议:设计自己的序列化格式、实现序列化与反序列化函数

说实话,做嵌入式这些年,我越来越觉得「协议设计」是区分普通工程师和资深工程师的一道分水岭。你想想看,项目里用 JSON 确实方便,但到了资源受限的 MCU 上,一个 JSON 解析库就能吃掉你大半个 Flash。更别提有些场景需要极致的数据压缩和实时性——这时候,自己动手设计一套序列化协议,反而是最优雅的解法。

这一章,我就带你走一遍完整的流程:从协议设计、到编码实现、再到踩坑复盘。嗯,咱们不搞花架子,直接上干货。

8.1 为什么需要自定义协议?

我在一个工业传感器项目里遇到过这么个事:传感器每 10ms 上报一次数据,包含温度、湿度、气压、加速度四个值。如果用 JSON 传输,一条报文大概 120 字节;换成自定义二进制协议,压缩到 16 字节。你算算,同样一个 115200 波特率的串口,前者每秒最多传 96 条,后者能传 720 条。差距就是这么赤裸裸。

自定义协议的核心优势就三点:

  • 体积小:没有冗余的字段名、分隔符,纯二进制打包
  • 解析快:直接内存拷贝或位运算,不需要字符串解析
  • 可控性强:端序、对齐、校验方式全由你说了算

适用场景判断:如果你的数据帧长度超过 64 字节、或者每秒传输超过 100 帧,我建议你认真考虑自定义二进制协议。反之,如果只是偶尔传几个配置参数,JSON 或 XML 反而更省事。

8.2 协议格式设计——从一张草图开始

设计协议之前,我习惯先在纸上画个帧结构。说白了,就是定义好「这一串字节里,每个比特代表什么」。下面是我常用的一个通用帧格式:

偏移(字节) 长度(字节) 字段名 说明
0 2 帧头 固定值 0xAA55,用于帧同步
2 1 版本号 协议版本,方便后续升级
3 1 数据类型 0x01=传感器数据,0x02=配置命令
4 2 数据长度 Payload 的字节数(小端)
6 N Payload 实际数据区
6+N 2 CRC16 从帧头到 Payload 的校验值

这个结构看起来简单,但我在实际项目中踩过不少坑。比如帧头用 0xAA55 还是 0x55AA?这取决于你的总线是 MSB 还是 LSB 优先。我曾经因为端序搞反,调试了整整一个下午才发现是帧头匹配失败。

我的习惯:帧头用两个字节的魔数,并且让两个字节互不相同(比如 0xAA 和 0x55)。这样在二进制数据流里更容易做帧同步——你只需要连续匹配两个特定字节,就能大概率找到帧起始位置。

8.3 实现序列化函数

序列化,说白了就是把结构体里的数据,按照协议格式塞进一个字节数组里。我习惯用一个「缓冲区 + 游标指针」的方式,这样代码可读性高,也容易扩展。

#include <stdint.h>
#include <string.h>

// 定义传感器数据结构体
typedef struct {
    float temperature;   // 温度,单位℃
    float humidity;      // 湿度,单位%
    uint16_t pressure;   // 气压,单位hPa,放大10倍存储
    int16_t  accel_x;    // 加速度X轴,单位mg
    int16_t  accel_y;
    int16_t  accel_z;
} sensor_data_t;

// 序列化:将结构体打包为二进制帧
// 参数:data - 输入结构体指针
//       buf  - 输出缓冲区
// 返回:实际写入的字节数
uint16_t serialize_sensor(const sensor_data_t *data, uint8_t *buf) {
    uint16_t idx = 0;

    // 1. 帧头
    buf[idx++] = 0xAA;
    buf[idx++] = 0x55;

    // 2. 版本号
    buf[idx++] = 0x01;

    // 3. 数据类型
    buf[idx++] = 0x01;  // 传感器数据

    // 4. 数据长度占位(先填0,后面再更新)
    uint16_t len_pos = idx;
    buf[idx++] = 0x00;
    buf[idx++] = 0x00;

    // 5. Payload:按小端序写入各个字段
    // 注意:float 直接内存拷贝,但需确保两端浮点格式一致
    memcpy(&buf[idx], &data->temperature, 4); idx += 4;
    memcpy(&buf[idx], &data->humidity, 4);    idx += 4;

    buf[idx++] = data->pressure & 0xFF;
    buf[idx++] = (data->pressure >> 8) & 0xFF;

    buf[idx++] = data->accel_x & 0xFF;
    buf[idx++] = (data->accel_x >> 8) & 0xFF;

    buf[idx++] = data->accel_y & 0xFF;
    buf[idx++] = (data->accel_y >> 8) & 0xFF;

    buf[idx++] = data->accel_z & 0xFF;
    buf[idx++] = (data->accel_z >> 8) & 0xFF;

    // 6. 回填数据长度(Payload 长度 = idx - 6 - 2)
    uint16_t payload_len = idx - 6 - 2;  // 减去帧头(2)+版本(1)+类型(1)+长度(2)
    buf[len_pos]     = payload_len & 0xFF;
    buf[len_pos + 1] = (payload_len >> 8) & 0xFF;

    // 7. CRC16 校验(这里用简单的异或和演示,实际项目请用标准CRC16)
    uint16_t crc = 0;
    for (uint16_t i = 0; i < idx; i++) {
        crc ^= buf[i];
    }
    buf[idx++] = crc & 0xFF;
    buf[idx++] = (crc >> 8) & 0xFF;

    return idx;
}

注意:上面代码里对 float 用了 memcpy 直接拷贝。这在发送端和接收端都是同一架构时没问题。但如果两端一个是 x86(小端),一个是 PowerPC(大端),float 的字节序就会出问题。我建议在协议设计阶段就明确约定:所有多字节数据统一使用小端序,并在序列化/反序列化时做显式转换。

8.4 实现反序列化函数

反序列化就是序列化的逆过程。但这里有个关键点:一定要做边界检查。我曾经在一个项目中,接收端没有校验数据长度就直接 memcpy,结果收到一个被干扰的帧,长度字段变成了 0xFFFF,直接导致缓冲区溢出,系统当场死机。

// 反序列化:从二进制帧还原为结构体
// 参数:buf  - 输入缓冲区
//       len  - 缓冲区长度
//       data - 输出结构体指针
// 返回:0=成功,-1=失败
int deserialize_sensor(const uint8_t *buf, uint16_t len, sensor_data_t *data) {
    // 1. 最小长度检查(帧头2+版本1+类型1+长度2+CRC2 = 8)
    if (len < 8) return -1;

    uint16_t idx = 0;

    // 2. 帧头匹配
    if (buf[idx++] != 0xAA || buf[idx++] != 0x55) return -1;

    // 3. 版本检查(可选)
    uint8_t version = buf[idx++];
    if (version != 0x01) return -1;  // 不支持的版本

    // 4. 数据类型(可选)
    uint8_t type = buf[idx++];
    if (type != 0x01) return -1;

    // 5. 读取数据长度
    uint16_t payload_len = buf[idx] | ((uint16_t)buf[idx + 1] << 8);
    idx += 2;

    // 6. 检查缓冲区是否足够(payload + CRC2)
    if (idx + payload_len + 2 > len) return -1;

    // 7. 解析 Payload
    memcpy(&data->temperature, &buf[idx], 4); idx += 4;
    memcpy(&data->humidity, &buf[idx], 4);    idx += 4;

    data->pressure = buf[idx] | ((uint16_t)buf[idx + 1] << 8); idx += 2;
    data->accel_x  = (int16_t)(buf[idx] | ((uint16_t)buf[idx + 1] << 8)); idx += 2;
    data->accel_y  = (int16_t)(buf[idx] | ((uint16_t)buf[idx + 1] << 8)); idx += 2;
    data->accel_z  = (int16_t)(buf[idx] | ((uint16_t)buf[idx + 1] << 8)); idx += 2;

    // 8. CRC 校验
    uint16_t crc_recv = buf[idx] | ((uint16_t)buf[idx + 1] << 8);
    uint16_t crc_calc = 0;
    for (uint16_t i = 0; i < idx; i++) {
        crc_calc ^= buf[i];
    }
    if (crc_recv != crc_calc) return -1;

    return 0;
}

核心原则:反序列化时,永远不要相信输入数据。每一个字段都要做范围检查,尤其是长度字段和数组索引。这是我在嵌入式安全编程中学到的最重要的一课。

8.5 协议设计的避坑指南

这些年我设计过不下十套自定义协议,踩过的坑能写满一页纸。挑几个最典型的说说:

  • 端序问题:我曾经在两个板子之间通信,一个用 STM32(小端),一个用某些 DSP(大端)。没做端序转换,结果解析出来的温度值直接飙到 4000℃。从那以后,我所有协议都强制约定小端序,并在序列化时手动转换。
  • 对齐问题:结构体里有 uint32_t 和 uint8_t 混排时,编译器可能会插入填充字节。如果你直接用 sizeof(struct) 来序列化,发送端和接收端的结构体定义稍有不同,数据就全乱了。我的做法是:永远手动逐字段打包,不用 memcpy 整个结构体。
  • 版本兼容:协议一定会升级。我习惯在帧头后面加一个版本号字段,这样旧版接收端遇到新版数据时,可以优雅地丢弃或打印警告,而不是直接崩溃。

8.6 知识体系总览

下面这张图,是我对自定义序列化协议设计思路的总结。你可以把它当作一个检查清单:

自定义序列化协议设计流程 1. 需求分析 数据量、实时性、资源 2. 帧结构设计 帧头、长度、校验、Payload 3. 序列化实现 逐字段打包、端序转换 4. 反序列化实现 边界检查、CRC校验 5. 测试验证 边界值、异常帧、压力测试 6. 迭代优化 版本升级、性能调优 三个关键原则 🔒 永远校验输入数据 | 🔄 显式处理端序 | 📐 手动逐字段打包 —— 这三点做到,你的协议就稳了

这张图展示了一个完整的闭环流程。你会发现,从需求分析到迭代优化,每一步都环环相扣。我个人觉得,最容易被忽视的是第一步和最后一步——很多人上来就写代码,结果写到一半发现帧结构不合理,又得重来。嗯,先想清楚再动手,反而更快。

8.7 写在最后

自定义序列化协议,说白了就是「约定大于配置」。你和你通信的对端约定好每一个字节的含义,剩下的就是机械的打包和解包工作。但正是这些「机械工作」里藏着无数细节——端序、对齐、校验、版本兼容……每一个细节没处理好,都可能让你的设备在关键时刻掉链子。

我建议你从今天开始,在自己的项目里试着设计一套简单的协议。哪怕只是两个 LED 灯之间的通信,也能让你把这一章的知识真正用起来。毕竟,看十遍不如写一遍,对吧?


公众号:蓝海资料掘金营,微信 deep3321