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.h 和 sensor.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 消息版本兼容:别让新旧设备打架
系统一旦上线,设备不可能同时升级。新版本的消息格式变了,旧设备怎么处理?
我总结了几条原则:
- 永远不要删除字段:旧设备可能还在用。实在要删,标记为 reserved。
- 新增字段加默认值:旧设备解析时忽略新字段,新设备发现字段缺失就用默认值。
- 消息头里带版本号:接收方根据版本号选择解析逻辑。
举个例子,在二进制协议的消息头里加一个版本字段:
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,高频数据用二进制,跨团队协作用 Protobuf。版本兼容则是贯穿始终的设计红线,别等到设备打架了才想起来补。
公众号:蓝海资料掘金营,微信deep3321