第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_xxxdeserialize_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混在一起,永远对不上。

五、知识体系总览

下面这张图,是我对序列化与反序列化核心知识点的总结。你可以把它当作一个检查清单:

序列化与反序列化 结构体序列化 手工逐字段序列化 TLV格式(Type-Length-Value) 处理字节序(htonl/ntohl) 处理内存对齐 版本兼容 版本号管理 向后兼容策略 字段废弃标记 新字段追加到末尾 数据校验 魔数校验(格式识别) 长度校验(完整性) CRC32校验(内容正确性) MD5/SHA256(安全场景) 核心原则:可移植 + 可兼容 + 可校验 不要直接fwrite结构体 | 版本号放头部 | 校验不能省

六、避坑指南

最后,分享几个我亲身踩过的坑:

  • 我曾经在序列化浮点数时直接memcpy,结果在不同平台间传输后精度丢失。后来统一用IEEE 754的二进制表示,配合字节序转换才解决。
  • 我曾经在结构体里用了指针,序列化时只序列化了指针值本身,没序列化指针指向的数据。读回来时指针指向了随机地址,程序直接崩。记住:指针必须解引用后序列化实际数据。
  • 我曾经在版本兼容时偷懒,没加版本号。结果半年后没人记得当初的格式是什么样了。从那以后,我所有序列化数据的第一件事就是写版本号。
总结一下:序列化这件事,看似简单,实则细节很多。我的建议是——别图省事,老老实实写序列化函数,加版本号,做校验。这些工作前期多花一小时,后期能省你一天排查时间。

专注资料整理