19、二进制文件读写:fread/fwrite。结构体直接读写、大端小端问题、数据对齐

说到文件IO,很多新手朋友一开始接触的都是文本读写——fprintf、fscanf这些。但说实话,真正到了工业级开发,二进制文件才是主力。为什么?因为快、准、狠。文本文件要转来转去,二进制直接就是内存的镜像,读写效率高出一个量级。

今天我们就来聊聊C语言里最核心的二进制读写函数:freadfwrite。顺便把结构体直接读写、大端小端、数据对齐这几个坑一并填了。

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 知识体系总览

下面这张图把本章的核心逻辑串起来了:

二进制文件读写 fread / fwrite 结构体直接读写 跨平台问题 数据块读写 返回值检查 序列化/反序列化 代码简洁 大端小端 数据对齐 htonl/ntohl 转换 #pragma pack(1) 跨平台二进制读写 = 字节序处理 + 对齐控制

19.6 避坑指南

最后,我把自己踩过的坑总结一下:

  1. 不要用fwrite写指针:结构体里如果有指针,写进去的是地址值,不是数据。读回来时那个地址早就失效了。
  2. 字符串要固定长度:用char数组,不要用char*。数组长度固定,读写才可控。
  3. 文件打开模式要正确:二进制文件一定要用"wb"、"rb"模式。用"w"、"r"的话,Windows下会把0x0A转成0x0D 0x0A,数据就坏了。
  4. 检查返回值:fread/fwrite的返回值一定要检查。别以为写文件不会失败——磁盘满了怎么办?
  5. 版本兼容:结构体一旦写入文件,就别轻易改字段顺序或类型。实在要改,加个版本号字段。

一句话总结:二进制文件读写效率高、代码简洁,但字节序和对齐这两个坑必须提前想清楚。跨平台场景下,要么统一字节序,要么用#pragma pack控制对齐,要么在文件头里做标记。


专注资料整理