第35问:序列化与反序列化:结构体序列化、版本兼容、数据校验
序列化与反序列化,说白了就是把内存里的结构体数据,变成一串字节流存到文件里,或者反过来从字节流恢复出结构体。听起来简单?嗯,我刚开始做嵌入式开发时也这么想。直到有一次,我把一个结构体直接fwrite到文件里,换了个编译器版本就全读不出来了……那场面,真是欲哭无泪。
今天我们就来聊聊,怎么在C语言里做好序列化这件事。我会结合自己踩过的坑,把结构体序列化、版本兼容、数据校验这三个核心问题讲透。
一、为什么不能直接fwrite结构体?
很多新手会这么写:
struct Student {
char name[32];
int age;
float score;
};
struct Student stu = {"张三", 20, 95.5f};
fwrite(&stu, sizeof(stu), 1, fp);
看起来没问题?其实隐患很大。我遇到过三个典型问题:
- 内存对齐:不同编译器、不同平台,结构体内部填充的字节数可能不同。你在x86上写的文件,拿到ARM上可能读不对。
- 字节序:大端小端的问题。我当年在PowerPC和x86之间传数据,就栽在这上面。
- 版本演进:结构体加了字段,旧文件就读不出来了。
警告:千万不要把结构体当二进制块直接写入文件。除非你100%确定程序永远不会换平台、换编译器、改结构体——但说实话,这种假设在真实项目中几乎不成立。
二、手工序列化:最可控的方式
我个人习惯的做法是:写一对函数,serialize_xxx和deserialize_xxx,逐个字段处理。这样虽然代码多了点,但每个字节都在掌控之中。
来看一个例子:
// 序列化:将结构体写入缓冲区
int serialize_student(const struct Student *stu, uint8_t *buf, size_t buf_size) {
size_t offset = 0;
// 1. 写入魔数(用于校验)
if (offset + 4 > buf_size) return -1;
uint32_t magic = 0xABCD1234;
memcpy(buf + offset, &magic, 4);
offset += 4;
// 2. 写入版本号
if (offset + 2 > buf_size) return -1;
uint16_t version = 1;
memcpy(buf + offset, &version, 2);
offset += 2;
// 3. 写入name(固定长度,直接拷贝)
if (offset + 32 > buf_size) return -1;
memcpy(buf + offset, stu->name, 32);
offset += 32;
// 4. 写入age(转为网络字节序)
if (offset + 4 > buf_size) return -1;
uint32_t age_be = htonl((uint32_t)stu->age);
memcpy(buf + offset, &age_be, 4);
offset += 4;
// 5. 写入score(转为网络字节序的float)
if (offset + 4 > buf_size) return -1;
uint32_t score_be = htonl(*(uint32_t*)&stu->score);
memcpy(buf + offset, &score_be, 4);
offset += 4;
return (int)offset;
}
反序列化就是反过来做。注意每个字段都要做校验,不能直接memcpy就完事。
小技巧:我习惯在序列化数据开头放一个魔数(Magic Number)和版本号。魔数用来快速判断文件格式对不对,版本号用来做向后兼容。这个习惯帮我省了不少排查时间。
三、版本兼容:让旧数据也能读
项目做久了,结构体肯定会变。加字段、删字段、改字段类型——这些都是家常便饭。我曾经维护过一个通信协议,三年迭代了7个版本。
怎么做版本兼容?我的经验是:
- 永远不要删除字段:可以标记为废弃,但保留位置。否则旧数据读进来就错位了。
- 新字段加在末尾:这样旧版本程序读新数据时,读到末尾不认识的就跳过。
- 用TLV格式:Type-Length-Value,每个字段自带类型和长度标识。这是最灵活的方案。
TLV格式的简单实现:
// TLV写入示例
int write_tlv_field(uint8_t *buf, size_t *offset,
uint8_t type, uint16_t len, const void *val) {
// 写入Type
buf[(*offset)++] = type;
// 写入Length
*(uint16_t*)(buf + *offset) = htons(len);
*offset += 2;
// 写入Value
memcpy(buf + *offset, val, len);
*offset += len;
return 0;
}
TLV的好处是:解析时遇到不认识type,直接根据length跳过就行。新老版本各取所需,互不干扰。
四、数据校验:别让脏数据进来
序列化不只是把数据写出去,还得保证读回来的数据是完整的、没被篡改的。我见过最惨的案例:某嵌入式设备升级固件时,因为没做校验,刷了个半截文件进去,直接变砖。
常用的校验手段:
| 校验方式 | 适用场景 | 开销 |
|---|---|---|
| CRC32 | 文件完整性校验 | 中等 |
| MD5/SHA256 | 安全要求高的场景 | 较高 |
| 简单累加和 | 嵌入式、资源受限 | 极低 |
| 魔数+长度校验 | 快速格式识别 | 几乎无 |
我个人在项目中常用的组合是:魔数 + 长度 + CRC32。魔数判断文件格式,长度判断数据是否完整,CRC32判断内容是否损坏。三层下来,基本能挡住99%的问题。
// 带校验的序列化框架
typedef struct {
uint32_t magic; // 魔数
uint32_t total_len; // 总长度(含头部)
uint16_t version; // 版本号
uint32_t crc32; // CRC校验
uint8_t payload[]; // 实际数据
} SerialHeader;
// 写入时:先写头部,再写数据,最后回填CRC
// 读取时:先校验魔数,再校验长度,最后校验CRC
注意:CRC32计算时,记得把头部中CRC字段本身的位置填0再算。否则你算出来的CRC和存进去的CRC混在一起,永远对不上。
五、知识体系总览
下面这张图,是我对序列化与反序列化核心知识点的总结。你可以把它当作一个检查清单:
六、避坑指南
最后,分享几个我亲身踩过的坑:
- 我曾经在序列化浮点数时直接memcpy,结果在不同平台间传输后精度丢失。后来统一用IEEE 754的二进制表示,配合字节序转换才解决。
- 我曾经在结构体里用了指针,序列化时只序列化了指针值本身,没序列化指针指向的数据。读回来时指针指向了随机地址,程序直接崩。记住:指针必须解引用后序列化实际数据。
- 我曾经在版本兼容时偷懒,没加版本号。结果半年后没人记得当初的格式是什么样了。从那以后,我所有序列化数据的第一件事就是写版本号。
总结一下:序列化这件事,看似简单,实则细节很多。我的建议是——别图省事,老老实实写序列化函数,加版本号,做校验。这些工作前期多花一小时,后期能省你一天排查时间。