结构体与序列化:JSON序列化、二进制序列化、跨平台兼容性
序列化这个话题,说白了就是把内存里的结构体数据,变成一串可以存文件、可以发网络的字节流。反过来叫反序列化。我在嵌入式项目里跟序列化打了十几年交道,踩过的坑比吃过的盐还多。今天咱们就聊聊结构体序列化的几种主流方式,以及跨平台时那些让人头疼的问题。
为什么需要序列化?
你想想看,结构体在内存里是怎么存的?成员变量紧挨着,中间可能还有填充字节。这种布局是跟编译器、CPU架构、对齐方式强相关的。换一台机器,同样的结构体可能就解析不出来了。
我遇到过最典型的一个场景:设备A采集数据,通过串口发给设备B。两边结构体定义一模一样,但A用32位ARM,B用8位51单片机。结果呢?数据全乱套了。原因就是对齐方式不同,结构体大小都不一样。
所以我们需要一种标准化的数据表示方式,让不同平台、不同语言之间能无障碍地交换数据。这就是序列化的价值所在。
JSON序列化:人类可读,但代价不小
JSON是现在最流行的文本序列化格式。它的优点是直观、调试方便,几乎每种语言都有现成的库。
但我要泼盆冷水——在嵌入式领域,JSON不是银弹。我见过不少团队在资源受限的MCU上硬跑JSON解析器,结果RAM爆了、CPU跑满了,得不偿失。
核心要点:JSON适合配置管理、调试接口、低频数据交换。不适合实时控制、高频数据流。
来看一个简单的例子。假设我们有这样一个结构体:
typedef struct {
uint8_t id;
float temperature;
char name[16];
uint32_t timestamp;
} SensorData;
JSON序列化后的样子:
{
"id": 5,
"temperature": 23.5,
"name": "sensor_a",
"timestamp": 1699000000
}
用cJSON库来序列化:
#include "cJSON.h"
char* serialize_sensor(SensorData* data) {
cJSON* root = cJSON_CreateObject();
cJSON_AddNumberToObject(root, "id", data->id);
cJSON_AddNumberToObject(root, "temperature", data->temperature);
cJSON_AddStringToObject(root, "name", data->name);
cJSON_AddNumberToObject(root, "timestamp", data->timestamp);
char* json_str = cJSON_Print(root);
cJSON_Delete(root);
return json_str;
}
这段代码看着简单,但你要注意:cJSON_Print会动态分配内存,用完必须free。我曾经在一个长期运行的设备上忘记释放,结果内存泄漏,三天后系统挂了。嗯,血的教训。
注意:JSON序列化后的字符串体积通常比原始二进制大3-5倍。浮点数转成字符串还会损失精度。如果你的数据量很大,或者对实时性有要求,JSON可能不是好选择。
二进制序列化:高效,但需要自己操心
二进制序列化,说白了就是把结构体直接按字节打包。没有冗余的括号、逗号、引号,体积最小,速度最快。
但问题来了——直接memcpy结构体到缓冲区,然后发出去?别这么干。我见过太多人这么写:
// 错误示范!千万别学
SensorData data;
send_buffer((uint8_t*)&data, sizeof(SensorData));
为什么不行?原因有三:
- 对齐填充:不同平台结构体大小可能不同
- 字节序:大端小端问题
- 基本类型大小:int在不同平台可能是16位、32位或64位
正确的做法是逐字段手动打包,使用固定宽度的整数类型:
#include <stdint.h>
void serialize_sensor_binary(SensorData* data, uint8_t* buf) {
uint32_t temp_int;
// id: 1字节
buf[0] = data->id;
// temperature: 转成定点数,保留2位小数
temp_int = (uint32_t)(data->temperature * 100);
buf[1] = (temp_int >> 24) & 0xFF;
buf[2] = (temp_int >> 16) & 0xFF;
buf[3] = (temp_int >> 8) & 0xFF;
buf[4] = temp_int & 0xFF;
// name: 固定16字节,不足补0
memcpy(&buf[5], data->name, 16);
// timestamp: 4字节,大端序
buf[21] = (data->timestamp >> 24) & 0xFF;
buf[22] = (data->timestamp >> 16) & 0xFF;
buf[23] = (data->timestamp >> 8) & 0xFF;
buf[24] = data->timestamp & 0xFF;
}
你看,每个字段的位置、大小、字节序都是明确定义的。这样不管在什么平台上解析,结果都一样。
我的习惯:二进制序列化时,统一使用大端序(网络字节序)。这样数据在网络上传输时不需要额外转换。小端机器在打包时做转换,大端机器直接拷贝,性能影响不大。
跨平台兼容性的核心问题
跨平台序列化,说白了就是解决三个问题:
| 问题 | 原因 | 解决方案 |
|---|---|---|
| 字节序 | 大端/小端不同 | 统一使用网络字节序(大端) |
| 对齐填充 | 编译器、架构不同 | 使用#pragma pack(1)或手动逐字段打包 |
| 基本类型大小 | int/float等在不同平台位数不同 | 使用stdint.h中的固定宽度类型 |
我给你们画个图,看看序列化在整个数据流中的位置:
实战中的避坑指南
我曾经接手过一个项目,两个设备之间用结构体直接通信。A设备是ARM Cortex-M4,B设备是x86工控机。两边结构体定义一模一样,但数据就是不对。查了两天,最后发现是ARM默认是4字节对齐,而x86那边用了1字节对齐。结构体大小差了8个字节。
从那以后,我给自己定了几条规矩:
- 永远不要直接memcpy结构体——除非你确定两端完全一样
- 序列化时明确每个字段的字节序——我习惯用大端
- 使用固定宽度类型——uint8_t、int32_t这些,别用int、long
- 版本号要带上——结构体可能会变,版本号能帮你做兼容
核心原则:序列化协议一旦确定,就不要轻易改。如果非要改,一定要保留版本兼容。我见过太多因为协议升级导致老设备变砖的案例。
如何选择序列化方式?
这个问题没有标准答案,但我可以分享我的经验:
- 调试接口、配置管理:用JSON。方便人看,方便改。
- 高频数据采集、实时控制:用二进制。体积小,速度快。
- 跨语言、跨平台通信:考虑Protocol Buffers或FlatBuffers。它们帮你处理了字节序、版本兼容等问题。
- 资源极度受限的MCU:手写二进制序列化。轻量、可控、无依赖。
嗯,说到Protocol Buffers,我最近在一个项目里用了nanopb——它是PB的嵌入式版本,内存占用极小。如果你感兴趣,可以自己去查查,这里就不展开了。
最后说一句:序列化看似简单,但细节决定成败。字节序、对齐、类型大小、版本兼容,每一个点都可能成为你调试到崩溃的根源。我的建议是——先想清楚你的应用场景,再选择合适的方式。别盲目追求高大上的方案,也别图省事直接memcpy。
个人习惯:我会在项目初期就定义好序列化协议文档,明确每个字段的偏移、大小、字节序、取值范围。这样后期不管是自己维护还是交接给别人,都不会出大问题。
公众号:蓝海资料掘金营,微信deep3321