20. 结构体与联合体的序列化:把数据装进文件、送上网络
各位好,今天我们来聊一个实战性极强的话题——序列化。
说白了,就是把内存里的结构体、联合体,变成一串字节流。然后你可以存到文件里,或者通过网线发出去。反过来,从文件或网络里再把那串字节流还原成结构体,这叫反序列化。
我刚开始做嵌入式开发那会儿,觉得这事儿特简单——直接把结构体指针强转成 char*,然后 fwrite 写出去不就完了?后来被坑了几次,才明白这里面门道不少。
20.1 为什么需要序列化?
你想想看,结构体在内存里是什么样子?是一块连续的内存,各个成员挨着排。但问题是,不同机器、不同编译器、甚至同一个编译器的不同版本,对结构体的内存布局都可能不一样。
举个例子:
struct SensorData {
uint8_t id; // 1字节
uint32_t value; // 4字节
uint16_t crc; // 2字节
};
你觉得这个结构体占多少字节?1+4+2=7?不对。因为有内存对齐,实际可能是8字节甚至12字节。我在一个ARM项目上遇到过,同样的代码,GCC和IAR编译出来的结构体大小差了4个字节。
所以,直接把内存二进制扔出去,接收方不一定能正确解析。这就是序列化要解决的问题——定义一套与平台无关的数据格式。
20.2 两种序列化思路
我一般把序列化方法分成两类:
| 方法 | 特点 | 适用场景 |
|---|---|---|
| 二进制序列化 | 紧凑、高效、解析快 | 嵌入式通信、高性能场景 |
| 文本序列化(如JSON、XML) | 可读性好、跨平台强 | 配置、日志、与PC端交互 |
嵌入式系统里,二进制序列化用得更多。毕竟带宽和内存都金贵。但如果你要和云端通信,JSON反而更常见。嗯,看场景选。
20.3 二进制序列化实战
先看一个最简单的做法——逐成员序列化。说白了,就是手动把每个成员按固定顺序、固定大小写进缓冲区。
// 序列化:结构体 → 字节流
void serialize_sensor(const struct SensorData *s, uint8_t *buf) {
buf[0] = s->id; // 1字节
buf[1] = (s->value >> 24) & 0xFF; // 大端序
buf[2] = (s->value >> 16) & 0xFF;
buf[3] = (s->value >> 8) & 0xFF;
buf[4] = s->value & 0xFF;
buf[5] = (s->crc >> 8) & 0xFF; // 大端序
buf[6] = s->crc & 0xFF;
}
// 反序列化:字节流 → 结构体
void deserialize_sensor(const uint8_t *buf, struct SensorData *s) {
s->id = buf[0];
s->value = ((uint32_t)buf[1] << 24) |
((uint32_t)buf[2] << 16) |
((uint32_t)buf[3] << 8) |
(uint32_t)buf[4];
s->crc = ((uint16_t)buf[5] << 8) | buf[6];
}
注意这里我用了大端序(网络字节序)。为什么?因为不同CPU的字节序不一样。x86是小端,ARM可以配置,PowerPC是大端。统一用大端,大家都能认。
20.4 联合体在序列化中的妙用
联合体有个天然优势——它允许你用不同视角看同一块内存。这在序列化时特别有用。
union SerializeHelper {
struct SensorData data;
uint8_t bytes[sizeof(struct SensorData)];
};
你看,这样写:
union SerializeHelper helper;
helper.data.id = 0x01;
helper.data.value = 0x12345678;
helper.data.crc = 0xABCD;
// 直接拿 bytes 数组发出去
send(helper.bytes, sizeof(helper.bytes));
是不是很简洁?但有个前提——你得保证发送方和接收方的结构体布局完全一致。包括对齐方式、字节序、成员顺序。否则就是灾难。
__attribute__((packed)) 或 #pragma pack(1)。
20.5 处理变长数据
结构体里如果有指针、变长数组怎么办?直接序列化指针地址是没用的,因为那是虚拟地址,换个进程就不认了。
我的做法是:先序列化长度,再序列化数据。
struct Message {
uint8_t type;
uint16_t payload_len;
uint8_t *payload; // 变长数据
};
// 序列化变长消息
uint8_t* serialize_message(const struct Message *msg, uint16_t *out_len) {
// 总长度 = type(1) + payload_len(2) + payload数据
*out_len = 1 + 2 + msg->payload_len;
uint8_t *buf = malloc(*out_len);
buf[0] = msg->type;
buf[1] = (msg->payload_len >> 8) & 0xFF;
buf[2] = msg->payload_len & 0xFF;
memcpy(buf + 3, msg->payload, msg->payload_len);
return buf;
}
反序列化时,先读前3个字节拿到长度,再根据长度读取后续数据。这样不管数据多长,都能正确还原。
20.6 联合体序列化的特殊场景
联合体里不同成员大小不同,序列化时要注意。我一般会额外用一个字段标记当前是哪个成员有效。
struct Packet {
uint8_t type; // 0:温度, 1:湿度, 2:风速
union {
int16_t temperature;
uint8_t humidity;
uint16_t wind_speed;
} data;
};
序列化时,先写type,再根据type决定写多少字节。反序列化时也一样,先读type,再决定怎么解析后面的数据。
20.7 实战建议与避坑指南
- 别用
sizeof直接写文件——不同平台结构体大小可能不同。我建议固定每个字段的字节数。 - 注意字节序——多字节数据统一用大端序,或者双方约定好。
- 加个魔数(Magic Number)——在数据流开头放一个固定值,比如
0xAA55,用来校验数据格式是否正确。 - 考虑版本号——协议会变,加个版本字段,方便以后扩展。
- 别序列化指针——指针是地址,换个进程就无效。要序列化指针指向的数据本身。
我曾经维护过一个老项目,里面直接用 fwrite(&struct, sizeof(struct), 1, fp) 写配置。后来换了编译器,结构体对齐变了,所有配置文件全部报废。嗯,从那以后,我写的每个序列化函数都带版本号和魔数。
20.8 知识体系总览
下面这张图,把序列化的核心逻辑串起来了:
序列化这事儿,说难不难,说简单也不简单。核心就三点:格式约定、字节序统一、元信息完备。把这三点做好了,你的数据就能在各种平台间自由穿梭。
好了,今天的内容就到这里。记住,写序列化代码时多想想——如果换一台机器、换一个编译器,我的数据还能不能正确解析?想明白了,代码就稳了。
公众号:蓝海资料掘金营,微信deep3321