第25章 序列化与反序列化模块:二进制序列化、JSON序列化、实战:设计一个通用的序列化接口

序列化与反序列化,说白了就是把内存里的数据结构「打包」成字节流,再「拆包」还原回来。我在嵌入式项目里遇到过太多次这样的场景:设备配置要存到Flash里、传感器数据要发到云端、两个MCU之间要通信……没有一套好用的序列化模块,代码就会写得又臭又硬。

这一章,我们就来聊聊怎么设计一个通用的序列化接口。我会从二进制序列化讲起,再对比JSON序列化,最后给出一个实战方案。嗯,这里要注意:我们追求的不是「万能」,而是「够用且可扩展」。

25.1 为什么需要序列化?

先问一个问题:你写了一个结构体,里面存了温度、湿度、时间戳。你想把这个结构体存到EEPROM里,或者通过UART发出去。直接memcpy行不行?

行,但很脆弱。为什么?

  • 字节序问题:大端小端不一样,换个平台就翻车。
  • 对齐问题:结构体里有填充字节,存进去和读出来可能对不上。
  • 版本兼容问题:结构体加了一个字段,旧数据就读不出来了。
  • 跨语言问题:C写的结构体,Python那边怎么解析?

序列化就是来解决这些问题的。它定义了一套规则,把数据结构变成「通用语言」——要么是紧凑的二进制流,要么是人可读的文本(比如JSON)。

核心原则:序列化模块应该做到「写一次,到处读」。不管是大端小端、32位还是64位、C还是Python,只要遵循同一套协议,数据就能互通。

25.2 二进制序列化:紧凑、高效、但需要自己操心

二进制序列化,我个人习惯叫它「裸序列化」。它直接把数据按字节排好,没有多余的标记符号。优点是体积小、速度快,非常适合资源受限的嵌入式环境。

我在项目中遇到过这样一个需求:一个传感器节点每100ms上报一次数据,包含温度(float)、湿度(float)、电池电压(uint16_t)、状态标志(uint8_t)。如果用JSON,一条报文可能50字节以上;用二进制,只需要4+4+2+1=11字节。省下来的带宽,在LoRa这种低速链路上就是命。

25.2.1 二进制序列化的基本套路

写二进制序列化函数,核心就是「按顺序、按大小、按字节序」把数据塞进缓冲区。我一般会这样设计:

// 序列化:把结构体写入缓冲区
uint8_t* serialize_sensor_data(sensor_data_t* data, uint8_t* buf) {
    uint8_t* p = buf;
    // 写入温度(float,4字节,小端)
    *((uint32_t*)p) = float_to_uint32(data->temperature, LITTLE_ENDIAN);
    p += 4;
    // 写入湿度(float,4字节,小端)
    *((uint32_t*)p) = float_to_uint32(data->humidity, LITTLE_ENDIAN);
    p += 4;
    // 写入电压(uint16_t,2字节,小端)
    *((uint16_t*)p) = data->voltage;
    p += 2;
    // 写入状态(uint8_t,1字节)
    *p = data->status;
    p += 1;
    return p;  // 返回结束位置,方便计算长度
}

反序列化就是反过来:从缓冲区里按顺序读出每个字段,注意字节序转换。嗯,这里有个坑:float不能直接memcpy到uint32_t再传,因为不同平台的float表示可能不同。我建议用联合体或者自定义的float_to_uint32函数来保证一致性。

我的小技巧:写二进制序列化时,我会在数据流开头加一个「魔数」(Magic Number)和「版本号」。魔数用来快速判断数据是否损坏,版本号用来兼容不同格式。比如:0xAA55 + 版本号 + 数据体。这样即使以后改了结构体,也能通过版本号做兼容处理。

25.2.2 二进制序列化的优缺点

优点 缺点
体积小,带宽占用低 人不可读,调试困难
解析速度快,CPU开销小 需要自己处理字节序、对齐
适合资源受限的MCU 版本兼容需要额外设计
没有冗余标记,数据紧凑 跨语言解析需要严格对齐协议

25.3 JSON序列化:可读、灵活、但开销大

JSON序列化,说白了就是把数据变成文本。比如上面的传感器数据,JSON格式长这样:

{"temp":25.3,"hum":60.1,"volt":3300,"status":1}

你想想看,这个格式多友好——用串口助手就能直接看,用Python的json库就能直接解析。我在做产品原型阶段,经常先用JSON做通信协议,等稳定了再换成二进制。因为调试阶段,能直接看到数据内容,省了多少抓包分析的时间。

但是,JSON在嵌入式里有个硬伤:解析开销大。你需要一个JSON解析库(比如cJSON),它要动态分配内存、要递归解析、要处理字符串匹配。在只有几十KB RAM的MCU上,跑JSON解析器可能就把堆栈撑爆了。

我曾经踩过的坑:在一个STM32F103项目里,我用cJSON序列化一个包含20个字段的结构体,结果每次序列化都要malloc好几KB内存。跑了几小时后,堆碎片化严重,直接死机。后来我换成了静态分配的JSON构建方式,才解决了问题。所以,如果你用JSON,一定要关注内存分配策略,最好用静态缓冲区+池化分配。

25.3.1 轻量级JSON序列化思路

如果你不想引入完整的cJSON库,可以自己写一个极简的JSON序列化函数。核心思路就是用sprintf拼接字符串:

void json_serialize_sensor(char* buf, size_t buf_size, sensor_data_t* data) {
    snprintf(buf, buf_size,
        "{\"temp\":%.1f,\"hum\":%.1f,\"volt\":%u,\"status\":%u}",
        data->temperature, data->humidity,
        data->voltage, data->status);
}

反序列化呢?可以用sscanf或者自己写一个简单的键值对解析器。当然,这只适合字段少、结构固定的场景。字段多了,还是老老实实用cJSON吧。

25.4 实战:设计一个通用的序列化接口

好了,前面铺垫了这么多,现在进入正题。怎么设计一个通用的序列化接口?我的思路是:抽象出「序列化器」和「反序列化器」两个概念,用函数指针表来实现多态

说白了,就是定义一套接口,二进制和JSON各自实现。上层代码只调用接口,不关心底层是二进制还是JSON。

25.4.1 接口定义

// 序列化器接口
typedef struct {
    // 序列化:将数据写入缓冲区,返回写入字节数
    int (*serialize)(void* data, uint8_t* buf, size_t buf_size);
    // 反序列化:从缓冲区读取数据,返回读取字节数
    int (*deserialize)(void* data, const uint8_t* buf, size_t buf_size);
    // 获取序列化后的最大长度(用于预分配缓冲区)
    size_t (*max_serialized_size)(void);
} serializer_t;

这个接口的好处是:你只需要实现三个函数,就可以接入任何序列化格式。比如二进制版本:

int bin_serialize(void* data, uint8_t* buf, size_t buf_size) {
    sensor_data_t* d = (sensor_data_t*)data;
    // 检查缓冲区大小
    if (buf_size < 11) return -1;
    // 按二进制格式写入...
    return 11;
}

serializer_t bin_serializer = {
    .serialize = bin_serialize,
    .deserialize = bin_deserialize,
    .max_serialized_size = bin_max_size
};

JSON版本类似:

int json_serialize(void* data, uint8_t* buf, size_t buf_size) {
    sensor_data_t* d = (sensor_data_t*)data;
    // 用snprintf构建JSON字符串...
    return strlen((char*)buf);
}

serializer_t json_serializer = {
    .serialize = json_serialize,
    .deserialize = json_deserialize,
    .max_serialized_size = json_max_size
};

25.4.2 使用示例

// 上层代码:不关心用哪种序列化
void save_config(serializer_t* ser, void* config) {
    uint8_t buffer[256];
    int len = ser->serialize(config, buffer, sizeof(buffer));
    if (len > 0) {
        flash_write(0x08010000, buffer, len);
    }
}

// 调用时传入不同的序列化器
save_config(&bin_serializer, &my_config);   // 用二进制
save_config(&json_serializer, &my_config);  // 用JSON

你想想看,这样设计之后,换序列化格式只需要换一个指针。我在一个产品里同时用了两种:调试阶段用JSON方便看数据,正式发布时换成二进制省空间。代码一行都不用改。

25.5 知识体系图:序列化模块设计全景

下面这张图展示了序列化模块的核心设计思路。你可以看到,从数据源到序列化器,再到存储/传输,最后反序列化还原,整个链路是清晰的。而我们的通用接口,就是中间那个「适配层」。

序列化模块设计全景 数据源(结构体/变量) 通用序列化接口(serializer_t) serialize / deserialize / max_serialized_size 二进制序列化 JSON序列化 其他格式(CBOR等) Flash / EEPROM / 文件 UART / SPI / 网络 云端 / 数据库 反序列化还原数据

25.6 避坑指南与经验总结

最后,分享几个我在实际项目中积累的经验:

  • 永远不要假设字节序:就算你只在STM32上跑,也建议显式处理字节序。因为说不定哪天就要跟x86的PC通信。
  • 缓冲区大小要留余量:JSON序列化后的长度很难精确预估,我一般会按结构体大小的3~5倍预留缓冲区。
  • 版本号是救命稻草:我曾经在产品发布后改了结构体,结果旧设备升级后数据全乱。后来我在序列化头里加了版本号,旧版本数据自动走兼容路径,问题解决。
  • 测试要覆盖边界:空数据、最大长度、浮点数边界(NaN、Inf)、字符串含特殊字符……这些都要测。我吃过一次亏:JSON里有个字段值是NaN,cJSON序列化出来是"NaN",但Python那边解析直接报错。

总结一句话:序列化模块的设计,本质上是「在通用性和效率之间找平衡」。二进制适合资源受限、追求性能的场景;JSON适合调试、跨语言、人可读的场景。而通用接口,就是让你在两者之间自由切换的「开关」。

好了,这一章的内容就到这里。记住:好的序列化设计,能让你的代码在「换平台、换协议、换格式」时,依然稳如磐石。


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