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));

是不是很简洁?但有个前提——你得保证发送方和接收方的结构体布局完全一致。包括对齐方式、字节序、成员顺序。否则就是灾难。

我曾经踩过的坑: 有一次用联合体序列化,本地测试一切正常。发到远端设备上,数据全乱套。查了两天才发现,远端编译器默认用了1字节对齐,而我这边是4字节对齐。从那以后,我再用联合体做序列化,一定会在代码里显式加上 __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 知识体系总览

下面这张图,把序列化的核心逻辑串起来了:

结构体与联合体序列化知识体系 序列化/反序列化 二进制序列化 文本序列化 (JSON/XML) 逐成员序列化(手动控制字节序) 联合体直接映射(需对齐一致) 变长数据:先长度后数据 可读性好,跨平台强 解析开销大,适合配置/日志 关键:魔数 + 版本号 + 字节序约定 + 元信息

序列化这事儿,说难不难,说简单也不简单。核心就三点:格式约定、字节序统一、元信息完备。把这三点做好了,你的数据就能在各种平台间自由穿梭。

好了,今天的内容就到这里。记住,写序列化代码时多想想——如果换一台机器、换一个编译器,我的数据还能不能正确解析?想明白了,代码就稳了。


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