3、二进制序列化:使用memcpy进行基础序列化、结构体直接写入文件

好,咱们今天聊点实在的。

上一章我们讲了文本序列化,JSON、XML 这些格式虽然可读性好,但有个硬伤——。而且体积大。你想想看,一个 int 本来 4 个字节,转成字符串 "123456" 要占 6 个字节,传输和存储都吃亏。

所以,在嵌入式系统里,二进制序列化才是主流。我做了十几年嵌入式,坦白讲,90% 以上的通信协议都是二进制的。为什么?因为快、省、直接。

3.1 什么是二进制序列化?

说白了,就是把内存里的数据,原封不动地搬出去

你在结构体里定义的 int、float、char 数组,它们在内存里怎么排列的,序列化后就怎么写到文件或发到网络里。反序列化时,再原样读回来。

这就像搬家——你不需要把家具拆成零件再画图纸,直接把整个柜子搬过去就行。

核心思想:内存布局 = 传输格式

没有解析开销,没有格式转换,性能极高。

3.2 用 memcpy 做序列化

最基础的做法,就是 memcpy。把结构体变量的内存内容,直接拷贝到一个字节缓冲区里。

我给你们看个例子:

#include <stdio.h>
#include <string.h>

typedef struct {
    int id;
    float value;
    char name[16];
} SensorData;

int main() {
    SensorData data = {1, 23.5f, "temp_sensor"};
    unsigned char buffer[sizeof(SensorData)];

    // 序列化:把结构体拷贝到缓冲区
    memcpy(buffer, &data, sizeof(SensorData));

    // 反序列化:从缓冲区拷贝回结构体
    SensorData restored;
    memcpy(&restored, buffer, sizeof(SensorData));

    printf("id=%d, value=%.1f, name=%s\n",
           restored.id, restored.value, restored.name);
    return 0;
}

你看,就这么简单。两行 memcpy,序列化和反序列化都搞定了。

我的习惯:序列化时用 memcpy 把数据拷到 unsigned char 数组里,而不是直接写结构体到文件。这样更灵活,方便加协议头、校验等。

3.3 结构体直接写入文件

如果你只是想存盘,更粗暴的方式是直接用 fwrite 把整个结构体写进去。

FILE *fp = fopen("sensor.bin", "wb");
if (fp) {
    fwrite(&data, sizeof(SensorData), 1, fp);
    fclose(fp);
}

// 读回来
fp = fopen("sensor.bin", "rb");
if (fp) {
    fread(&restored, sizeof(SensorData), 1, fp);
    fclose(fp);
}

嗯,这里要注意:fwritefread 的第二个参数是单个元素大小,第三个参数是元素个数。我见过有人把这两个参数写反了,结果数据错位,排查了半天。

3.4 避坑指南:结构体对齐

好,现在我要说重点了——结构体对齐

你想想看,C 语言的结构体在内存里并不是紧密排列的。编译器会在成员之间插入填充字节,让每个成员对齐到它的自然边界。比如 int 要 4 字节对齐,double 要 8 字节对齐。

我曾经在一个项目里吃过这个亏。当时写了一个通信协议,结构体里混了 char、int、short,在 x86 上测试一切正常。结果部署到 ARM 平台后,数据全乱了。为什么?因为两个平台的对齐规则不一样,结构体大小不同。

重要:不同编译器、不同平台,结构体的内存布局可能不同。直接 memcpy 序列化后的数据,在跨平台场景下会出问题。

怎么解决?两个思路:

  1. 手动填充:在结构体里显式添加填充字节,保证布局可控。
  2. 使用 #pragma pack:强制编译器取消对齐优化。

看代码:

#pragma pack(push, 1)  // 按 1 字节对齐
typedef struct {
    char type;
    int value;
    short flag;
} PackedData;
#pragma pack(pop)

printf("sizeof(PackedData) = %zu\n", sizeof(PackedData));
// 输出:7(1 + 4 + 2),没有填充字节

用了 #pragma pack(1) 之后,结构体就是紧密排列的,跨平台一致性大大提高。但代价是访问效率会降低,因为 CPU 访问未对齐的数据可能需要多次内存访问。

我的建议:在嵌入式系统里,如果数据要跨平台传输,一律用 #pragma pack(1)。性能损失通常可以接受,但数据一致性是底线。

3.5 二进制序列化的优缺点

优点 缺点
速度快,几乎没有解析开销 数据不可读,调试困难
体积小,节省存储和带宽 跨平台需要处理字节序和对齐
实现简单,代码量少 结构体变化后,旧数据无法兼容
适合实时性要求高的场景 指针类型无法直接序列化

3.6 什么时候用二进制序列化?

我个人经验是:

  • 嵌入式通信协议:比如 I2C、SPI、CAN 总线,数据包很小,必须用二进制。
  • 高性能日志:传感器数据采集,每秒几千个点,用文本写会卡死。
  • 固件升级包:二进制镜像,直接烧录,不需要解析。
  • 游戏存档:快速读写,玩家体验好。

但如果你需要人类可读、需要版本兼容、需要跨语言交互,那还是老老实实用文本序列化或者 protobuf 吧。

3.7 本章知识体系

下面这张图,帮你理清二进制序列化的核心脉络:

二进制序列化核心知识体系 二进制序列化 memcpy 拷贝到缓冲区 fwrite 直接写入文件 结构体对齐问题 字节序(大小端) 嵌入式通信协议 高性能数据采集 #pragma pack(1) 手动字节序转换

3.8 小结

二进制序列化,说白了就是用内存的视角去看待数据。它简单、高效,但需要你对底层的内存布局有清晰的认识。

我个人的经验是:在同一个平台内部,放心用 memcpy;跨平台时,一定要处理对齐和字节序。别偷懒,否则线上出问题的时候,你连数据都解析不出来。

下一章,我们会深入聊字节序(大小端)的问题——这可是跨平台通信的必修课。


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