19、二进制文件读写:fread/fwrite。结构体直接读写、大端小端问题、数据对齐
说到文件IO,很多新手朋友一开始接触的都是文本读写——fprintf、fscanf这些。但说实话,真正到了工业级开发,二进制文件才是主力。为什么?因为快、准、狠。文本文件要转来转去,二进制直接就是内存的镜像,读写效率高出一个量级。
今天我们就来聊聊C语言里最核心的二进制读写函数:fread和fwrite。顺便把结构体直接读写、大端小端、数据对齐这几个坑一并填了。
19.1 fread/fwrite 的基本用法
这两个函数的原型长这样:
size_t fread(void *ptr, size_t size, size_t count, FILE *stream);
size_t fwrite(const void *ptr, size_t size, size_t count, FILE *stream);
参数看着多,其实很好记:
- ptr:数据缓冲区的指针
- size:每个元素的大小(字节数)
- count:要读写的元素个数
- stream:文件指针
返回值是实际成功读写的元素个数。注意,不是字节数,是元素个数。如果返回值小于count,说明出错了或者读到文件尾了。
核心要点:fread/fwrite 是面向数据块的,不是面向字符的。它们不关心数据内容,只关心字节流。
19.2 结构体直接读写
二进制文件最大的优势之一,就是可以直接把结构体整个写进去、整个读出来。省去了逐个字段序列化的麻烦。
看个例子:
#include <stdio.h>
#include <string.h>
typedef struct {
int id;
char name[32];
float score;
} Student;
int main() {
Student stu = {1001, "张三", 95.5f};
FILE *fp = fopen("student.dat", "wb");
if (!fp) return -1;
// 直接写入整个结构体
fwrite(&stu, sizeof(Student), 1, fp);
fclose(fp);
// 读回来
Student stu2;
fp = fopen("student.dat", "rb");
fread(&stu2, sizeof(Student), 1, fp);
fclose(fp);
printf("id=%d, name=%s, score=%.1f\n", stu2.id, stu2.name, stu2.score);
return 0;
}
你看,代码非常简洁。一个fwrite就把整个结构体怼进文件了,一个fread又原样怼回来。我当年第一次用这个功能时,感觉打开了新世界的大门——再也不用一行一行地解析文本了。
个人习惯:我一般会在结构体定义后面加个注释,标明这个结构体是“可序列化”的,提醒自己和同事注意字节对齐问题。
19.3 大端小端问题
嗯,这里要敲黑板了。结构体直接读写虽然方便,但有个大坑——字节序。
什么叫字节序?说白了就是一个多字节数据在内存里是怎么排列的:
- 小端(Little-Endian):低位字节在低地址。x86、ARM默认都是小端。
- 大端(Big-Endian):高位字节在低地址。网络协议、某些嵌入式芯片用大端。
举个例子,整数0x12345678:
| 地址 | 小端存储 | 大端存储 |
|---|---|---|
| 低地址 | 0x78 | 0x12 |
| 高地址 | 0x12 | 0x78 |
如果你在小端机器上写了一个二进制文件,拿到大端机器上读,那数据就全乱了。id=1001可能变成id=26738688这种离谱的值。
我曾经在做一个跨平台项目时,PC端写的配置文件拿到嵌入式Linux上读,结果所有int字段都反了。排查了半天才发现是字节序问题。从那以后,我养成了一个习惯:二进制文件头里写一个魔数(magic number),顺便标记字节序。
解决大端小端问题,常用的方法有:
- 使用
htonl/ntohl等网络字节序转换函数 - 自己写一个字节序检测函数,然后做条件转换
- 或者干脆用文本格式(但牺牲性能)
一个简单的字节序检测:
int is_little_endian() {
unsigned int x = 1;
return *(unsigned char*)&x;
}
如果返回1,说明是小端;返回0,是大端。原理很简单:取整数1的最低地址字节,小端的话就是0x01,大端的话是0x00。
19.4 数据对齐问题
另一个坑是结构体对齐。C语言编译器会在结构体成员之间插入填充字节,以保证每个成员都对齐到它的自然边界上。
比如这个结构体:
struct Example {
char a; // 1字节
int b; // 4字节
short c; // 2字节
};
你以为它的大小是1+4+2=7字节?不对。实际上在默认对齐下,它占12字节。为什么?因为int b要对齐到4字节边界,所以a后面会填充3个字节;short c要对齐到2字节边界,但结构体整体大小要对齐到最大成员(int,4字节)的整数倍,所以末尾还会填充2个字节。
这就导致一个问题:你在一个编译器上写的二进制文件,拿到另一个编译器上读,可能因为对齐方式不同而读错。
我建议:如果结构体要跨平台使用,要么手动指定对齐方式(比如#pragma pack(1)),要么在结构体里显式填充字节,保证没有隐式填充。
使用#pragma pack(1)的例子:
#pragma pack(push, 1)
typedef struct {
char a;
int b;
short c;
} PackedStruct;
#pragma pack(pop)
这样结构体大小就是7字节了,没有填充。但代价是访问效率会降低——因为CPU访问未对齐的数据可能需要多次内存访问。
19.5 知识体系总览
下面这张图把本章的核心逻辑串起来了:
19.6 避坑指南
最后,我把自己踩过的坑总结一下:
- 不要用fwrite写指针:结构体里如果有指针,写进去的是地址值,不是数据。读回来时那个地址早就失效了。
- 字符串要固定长度:用char数组,不要用char*。数组长度固定,读写才可控。
- 文件打开模式要正确:二进制文件一定要用"wb"、"rb"模式。用"w"、"r"的话,Windows下会把0x0A转成0x0D 0x0A,数据就坏了。
- 检查返回值:fread/fwrite的返回值一定要检查。别以为写文件不会失败——磁盘满了怎么办?
- 版本兼容:结构体一旦写入文件,就别轻易改字段顺序或类型。实在要改,加个版本号字段。
一句话总结:二进制文件读写效率高、代码简洁,但字节序和对齐这两个坑必须提前想清楚。跨平台场景下,要么统一字节序,要么用#pragma pack控制对齐,要么在文件头里做标记。