8. 结构体与文件操作:结构体的序列化与反序列化
好,咱们今天聊点实战的。结构体定义好了,内存里跑得欢,但程序一关,数据全没了。怎么办?写到文件里去。
把结构体数据变成字节流存到文件,这叫序列化。反过来,从文件读回内存重建结构体,叫反序列化。说白了,就是给数据拍个快照,下次开机还能恢复现场。
8.1 为什么需要序列化?
我早年做嵌入式设备时,有个设备每次断电都要重新校准参数。用户烦,我也烦。后来我加了个结构体,把校准参数打包,关机前写进EEPROM,开机再读出来。嗯,问题就解决了。
序列化的典型场景:
- 配置保存:设备参数、用户设置
- 数据持久化:传感器日志、状态记录
- 网络传输:把结构体打包成字节流发出去
- 进程间通信:共享内存或管道里传结构体
核心思想:结构体在内存中是连续排列的(不考虑对齐的话),所以我们可以直接把内存块整个搬进文件。搬进去是序列化,搬出来是反序列化。
8.2 fwrite / fread 读写结构体
C标准库给了我们两个好用的函数:fwrite 和 fread。它们直接操作内存块,非常适合结构体的批量读写。
函数原型:
size_t fwrite(const void *ptr, size_t size, size_t nmemb, FILE *stream);
size_t fread(void *ptr, size_t size, size_t nmemb, FILE *stream);
ptr:结构体的起始地址size:每个元素的大小(用sizeof)nmemb:要写/读的元素个数(通常是1)stream:文件指针
来看个例子。假设我们有个学生信息结构体:
#include <stdio.h>
#include <string.h>
typedef struct {
int id;
char name[32];
float score;
} Student;
// 序列化:写一个学生到文件
int save_student(const char *filename, const Student *s) {
FILE *fp = fopen(filename, "wb");
if (!fp) return -1;
// 直接写整个结构体
size_t written = fwrite(s, sizeof(Student), 1, fp);
fclose(fp);
return (written == 1) ? 0 : -1;
}
// 反序列化:从文件读一个学生
int load_student(const char *filename, Student *s) {
FILE *fp = fopen(filename, "rb");
if (!fp) return -1;
size_t read = fread(s, sizeof(Student), 1, fp);
fclose(fp);
return (read == 1) ? 0 : -1;
}
int main() {
Student stu1 = {1001, "张三", 88.5f};
save_student("student.dat", &stu1);
Student stu2;
load_student("student.dat", &stu2);
printf("ID: %d, Name: %s, Score: %.1f\n", stu2.id, stu2.name, stu2.score);
return 0;
}
你看,代码很简洁。但这里有个坑——结构体对齐。我当年就吃过这个亏。
我曾经在A平台用fwrite写了一个结构体,拿到B平台用fread读,结果数据全乱了。为什么?两个平台的编译器对结构体的对齐方式不一样,内存布局不同,直接读写就出问题。
8.3 结构体对齐带来的问题
结构体不是简单地把成员挨个排好。编译器会在成员之间插入填充字节,让每个成员对齐到它的自然边界。比如:
typedef struct {
char a; // 1字节
int b; // 4字节
short c; // 2字节
} Misaligned;
你以为大小是 1+4+2=7 字节?实际上可能是 12 字节。因为 int b 要从4字节对齐的地址开始,char a 后面会填充3个字节。
所以,直接 fwrite(&stu, sizeof(stu), 1, fp) 写进去的,包含了这些填充字节。换到另一个平台,填充规则变了,读出来就是垃圾。
8.4 如何安全地序列化?
我有两个建议:
- 使用 packed 结构体:告诉编译器不要填充,成员紧挨着排。
- 手动逐字段序列化:每个字段单独写,自己控制格式。
方法一:packed 结构体
GCC 和 MSVC 都支持:
// GCC
typedef struct __attribute__((packed)) {
char a;
int b;
short c;
} PackedStruct;
// MSVC
#pragma pack(push, 1)
typedef struct {
char a;
int b;
short c;
} PackedStruct;
#pragma pack(pop)
这样结构体大小就是 7 字节,没有填充。但代价是访问成员时可能变慢(因为CPU不喜欢非对齐访问)。
我个人习惯:只在序列化时用 packed 结构体,平时用普通结构体。或者干脆写一个转换函数,把普通结构体转成 packed 的再写文件。
方法二:逐字段序列化
这是最稳妥的方式。每个字段按固定顺序、固定大小读写:
int save_student_manual(const char *filename, const Student *s) {
FILE *fp = fopen(filename, "wb");
if (!fp) return -1;
// 按字段逐个写,自己控制格式
fwrite(&s->id, sizeof(s->id), 1, fp);
fwrite(s->name, sizeof(s->name), 1, fp);
fwrite(&s->score, sizeof(s->score), 1, fp);
fclose(fp);
return 0;
}
int load_student_manual(const char *filename, Student *s) {
FILE *fp = fopen(filename, "rb");
if (!fp) return -1;
fread(&s->id, sizeof(s->id), 1, fp);
fread(s->name, sizeof(s->name), 1, fp);
fread(&s->score, sizeof(s->score), 1, fp);
fclose(fp);
return 0;
}
这样做的好处是:跨平台兼容。你甚至可以加个版本号,以后结构体变了也能兼容旧数据。
8.5 批量读写结构体数组
如果有一堆结构体要存,fwrite/fread 也支持一次搞定:
Student class[30];
// ... 填充数据 ...
// 一次写30个
fwrite(class, sizeof(Student), 30, fp);
// 一次读30个
fread(class, sizeof(Student), 30, fp);
这里 nmemb 参数就是元素个数。底层会连续读写 size * nmemb 字节。
但要注意:如果结构体里有指针,千万别直接这么干!指针存的是地址,换个进程地址就无效了。这种情况需要深度序列化,把指针指向的内容也写进去。
8.6 知识体系总览
下面这张图帮你理清本章的核心逻辑:
8.7 避坑指南
最后,把我这些年踩过的坑总结一下:
- 不要直接写带指针的结构体:地址是运行时分配的,换个进程就无效。要写指针指向的内容。
- 注意字节序:大端小端问题。如果数据要在不同架构的机器间交换,建议用网络字节序(htons/htonl)。
- 文件打开模式用 "wb" 和 "rb":文本模式会在 Windows 下把 \n 转成 \r\n,破坏二进制数据。
- 检查返回值:fwrite/fread 返回实际写入/读取的元素个数,一定要检查是否等于期望值。
- 考虑版本兼容:在文件头加个版本号字段,以后结构体变了也能识别旧格式。
我建议:对于嵌入式系统,如果存储空间和性能允许,优先用逐字段序列化。虽然代码多几行,但换来的是跨平台无忧。如果追求极致性能,用 packed 结构体,但一定要在项目文档里注明对齐方式。
好了,结构体与文件操作就聊到这儿。记住:序列化不是简单的内存拷贝,要考虑对齐、字节序、指针和版本兼容。把这些想清楚,你的数据就能在文件里安家落户了。
公众号:蓝海资料掘金营,微信deep3321