17. 消息序列化与协议:JSON序列化、二进制协议设计、协议缓冲区、消息版本兼容

说到消息队列,有个绕不开的话题——序列化。说白了,就是把内存里的结构体变成一串字节,扔到网络或者消息队列里。另一端收到后,再反序列化回来。

我见过不少项目,前期图省事,直接用结构体指针强转就发出去。嗯,在同一个芯片上跑两个进程,这样确实快。但一旦跨网络、跨平台、跨语言,立马崩给你看。字节序、对齐、字段类型长度……全是坑。

所以这一章,我们来聊聊正经的序列化方案。

17.1 JSON序列化:简单但别滥用

JSON 的好处不用我多说——人类可读、调试方便、几乎所有语言都有现成库。我在嵌入式项目里也常用,尤其是配置下发、状态上报这类低频场景。

但你要注意,JSON 在嵌入式环境里是有代价的:

  • 解析开销大:cJSON 这样的库,解析一个深层嵌套的 JSON 对象,内存分配次数可能上百次。
  • 体积膨胀:同样的数据,JSON 比二进制大 3~5 倍很正常。
  • 无类型约束:字段名写错了,解析时不会报错,只是返回 NULL。调试起来很头疼。

我的建议:JSON 适合做配置接口调试接口,不适合做高频数据通道。如果你每秒要发几千条消息,别用 JSON。

举个简单的例子,用 cJSON 序列化一个传感器数据:

// 传感器数据结构
typedef struct {
    uint32_t timestamp;
    float    temperature;
    float    humidity;
    uint8_t  sensor_id;
} sensor_data_t;

// 序列化为 JSON 字符串
char* sensor_to_json(const sensor_data_t* data) {
    cJSON* root = cJSON_CreateObject();
    cJSON_AddNumberToObject(root, "ts", data->timestamp);
    cJSON_AddNumberToObject(root, "temp", data->temperature);
    cJSON_AddNumberToObject(root, "hum", data->humidity);
    cJSON_AddNumberToObject(root, "id", data->sensor_id);
    
    char* json_str = cJSON_PrintUnformatted(root);
    cJSON_Delete(root);
    return json_str;  // 调用者需要 free()
}

你看,代码量不大。但每次调用都要 malloc 好几次。在 RAM 只有几十 KB 的 MCU 上,频繁用 JSON 很容易导致堆碎片。

避坑指南:我曾经在一个项目里用 cJSON 做日志上报,跑了三天后系统随机死机。查了两天才发现是堆碎片导致 malloc 失败。后来换成固定长度的二进制协议,问题消失。

17.2 二进制协议设计:自己动手,丰衣足食

如果你对性能有要求,或者带宽有限,那就得自己设计二进制协议了。说白了,就是定义好每个字节的含义。

我个人习惯用这种结构:

// 二进制协议帧格式
// [起始标志(2B)] [长度(2B)] [类型(1B)] [序列号(2B)] [负载(NB)] [校验(2B)]

#define FRAME_HEADER_SIZE  7   // 2+2+1+2
#define FRAME_TRAILER_SIZE 2   // 校验
#define FRAME_MIN_SIZE     (FRAME_HEADER_SIZE + FRAME_TRAILER_SIZE)

typedef struct {
    uint16_t magic;        // 起始标志,固定 0xAA55
    uint16_t length;       // 负载长度
    uint8_t  msg_type;     // 消息类型
    uint16_t seq;          // 序列号
    uint8_t  payload[];    // 柔性数组,负载数据
} __attribute__((packed)) frame_header_t;

这里有个关键点——__attribute__((packed))。不加这个,编译器会在结构体里插入填充字节,导致跨平台解析失败。我踩过这个坑,真的。

序列化和反序列化的代码也很直白:

// 序列化:填充帧头 + 拷贝负载
int serialize_message(uint8_t* buf, size_t buf_size,
                      uint8_t msg_type, const uint8_t* payload, uint16_t payload_len) {
    if (buf_size < FRAME_HEADER_SIZE + payload_len + FRAME_TRAILER_SIZE)
        return -1;
    
    frame_header_t* hdr = (frame_header_t*)buf;
    hdr->magic   = 0xAA55;
    hdr->length  = payload_len;
    hdr->msg_type = msg_type;
    hdr->seq     = get_next_seq();
    
    memcpy(hdr->payload, payload, payload_len);
    
    // 计算校验和(简单累加,实际项目建议用 CRC)
    uint16_t checksum = 0;
    for (int i = 0; i < FRAME_HEADER_SIZE + payload_len; i++) {
        checksum += buf[i];
    }
    buf[FRAME_HEADER_SIZE + payload_len] = (checksum >> 8) & 0xFF;
    buf[FRAME_HEADER_SIZE + payload_len + 1] = checksum & 0xFF;
    
    return FRAME_HEADER_SIZE + payload_len + FRAME_TRAILER_SIZE;
}

注意:二进制协议虽然高效,但可读性差,调试时必须用十六进制查看器。另外,版本兼容全靠设计者自己维护,稍有不慎就会导致新旧设备对不上。

17.3 协议缓冲区:Google 的 Protobuf

如果你既想要二进制的高效,又不想自己维护序列化代码,那就用 Protocol Buffers(Protobuf)。

Protobuf 的核心思路是:你写一个 .proto 文件定义数据结构,然后 protoc 编译器自动生成 C 语言的序列化/反序列化代码。

举个例子:

// sensor.proto
syntax = "proto3";

message SensorData {
    uint32 timestamp = 1;
    float  temperature = 2;
    float  humidity = 3;
    uint32 sensor_id = 4;
}

然后执行 protoc --c_out=. sensor.proto,会生成 sensor.pb-c.hsensor.pb-c.c。使用起来很简单:

#include "sensor.pb-c.h"

SensorData data = SENSOR_DATA__INIT;
data.timestamp   = 123456;
data.temperature = 25.6;
data.humidity    = 60.2;
data.sensor_id   = 1;

// 计算序列化后的大小
size_t len = sensor_data__get_packed_size(&data);
uint8_t* buf = malloc(len);

// 序列化
sensor_data__pack(&data, buf);

// 发送 buf,长度为 len
// ...

free(buf);

Protobuf 的好处很明显:

  • 自动处理字节序和对齐,跨平台无压力。
  • 向后兼容:新增字段不影响旧版本解析。
  • 体积小:比 JSON 小得多,接近手写二进制。

但缺点也有:

  • 代码体积大:生成的 .c 文件可能有好几 KB,对 ROM 紧张的 MCU 不友好。
  • 依赖 protoc 工具链:每次改 .proto 都要重新生成代码。

我的经验:在资源充足的 ARM Cortex-M4 以上平台,我倾向于用 Protobuf。在 Cortex-M0 或者只有几十 KB Flash 的芯片上,我选择手写二进制协议。

17.4 消息版本兼容:别让新旧设备打架

系统一旦上线,设备不可能同时升级。新版本的消息格式变了,旧设备怎么处理?

我总结了几条原则:

  1. 永远不要删除字段:旧设备可能还在用。实在要删,标记为 reserved。
  2. 新增字段加默认值:旧设备解析时忽略新字段,新设备发现字段缺失就用默认值。
  3. 消息头里带版本号:接收方根据版本号选择解析逻辑。

举个例子,在二进制协议的消息头里加一个版本字段:

typedef struct {
    uint16_t magic;
    uint8_t  version;      // 协议版本号,从 1 开始
    uint16_t length;
    uint8_t  msg_type;
    uint16_t seq;
    uint8_t  payload[];
} __attribute__((packed)) frame_header_v2_t;

接收方解析时:

void handle_message(const uint8_t* buf, size_t len) {
    const frame_header_v2_t* hdr = (const frame_header_v2_t*)buf;
    
    switch (hdr->version) {
        case 1:
            // 旧版本解析逻辑
            break;
        case 2:
            // 新版本解析逻辑
            break;
        default:
            // 未知版本,记录日志并丢弃
            break;
    }
}

核心思想:消息版本兼容不是技术问题,是设计习惯问题。每次改协议时,多想想「旧设备收到这个会怎样?」

17.5 本章知识体系

下面这张图总结了消息序列化与协议设计的核心脉络:

消息序列化与协议设计 JSON 序列化 • 人类可读,调试方便 • 解析开销大,内存碎片 • 体积膨胀 3~5 倍 • 适合低频配置/调试接口 二进制协议 • 高效,体积小 • 需手动处理字节序/对齐 • 可读性差,调试困难 • 适合资源受限 MCU Protocol Buffers • 自动序列化/反序列化 • 跨平台,向后兼容 • 代码体积较大 • 适合资源较丰富的平台 消息版本兼容 • 不删除字段,标记为 reserved • 新增字段加默认值 • 消息头带版本号,按版本解析

三种方案各有适用场景。我个人习惯是:配置类用 JSON,高频数据用二进制,跨团队协作用 Protobuf。版本兼容则是贯穿始终的设计红线,别等到设备打架了才想起来补。


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