8. 二进制文件读写:fread、fwrite 与结构体序列化

文本文件读写,说白了就是存字符串。但真实项目里,我们更多是在跟结构体、数组、传感器数据打交道。这时候,二进制读写才是主角。

我个人习惯,只要不是给人直接看的配置文件,一律用二进制。为什么?效率高、精度高、结构完整。你想想看,一个 float 用文本存成 "3.1415926",再读回来还得解析,多折腾。

8.1 fwrite:把内存原样扔进文件

fwrite 的签名很简单:

size_t fwrite(const void *ptr, size_t size, size_t count, FILE *stream);
  • ptr:要写入的数据起始地址
  • size:每个元素的大小(字节)
  • count:元素个数
  • stream:文件指针

返回值是实际写入的元素个数。如果跟 count 不一样,说明出错了。

核心理解:fwrite 就是把内存中的二进制数据,原封不动地拷贝到磁盘上。不转换、不格式化、不增加任何额外字符。

举个例子,写一个 int 数组:

#include <stdio.h>

int main() {
    int data[] = {100, 200, 300, 400, 500};
    FILE *fp = fopen("data.bin", "wb");
    if (!fp) { perror("fopen"); return 1; }

    size_t written = fwrite(data, sizeof(int), 5, fp);
    if (written != 5) {
        fprintf(stderr, "写入不完整\n");
    }

    fclose(fp);
    return 0;
}

这段代码写进去的就是 5 个 int 的二进制表示。每个 int 4 字节,总共 20 字节。你用文本编辑器打开 data.bin 会看到乱码——嗯,那是正常的。

8.2 fread:从文件还原到内存

fread 是 fwrite 的逆操作:

size_t fread(void *ptr, size_t size, size_t count, FILE *stream);

参数含义完全对称。返回值是实际读取的元素个数。如果小于 count,可能是读到文件尾了,也可能是出错了。

我建议你每次 fread 后都检查返回值,别偷懒。我在项目中遇到过,SD 卡突然拔出,fread 返回 0,但 feof 和 ferror 都没触发——嗯,嵌入式设备上这种事不罕见。

int read_data[5];
FILE *fp = fopen("data.bin", "rb");
if (!fp) { perror("fopen"); return 1; }

size_t n = fread(read_data, sizeof(int), 5, fp);
if (n != 5) {
    if (feof(fp)) {
        printf("文件提前结束,只读到 %zu 个元素\n", n);
    } else if (ferror(fp)) {
        perror("fread 出错");
    }
}

fclose(fp);

8.3 结构体的二进制读写

这才是二进制读写的真正用武之地。一个结构体,可能包含 int、float、char 数组,甚至嵌套结构体。用 fwrite 一次写入,fread 一次读出,干净利落。

typedef struct {
    uint32_t id;
    char     name[32];
    float    temperature;
    uint16_t checksum;
} SensorData;

SensorData sensor = {
    .id = 0xA5A5,
    .name = "Temp_Sensor_01",
    .temperature = 25.6f,
    .checksum = 0
};

// 写入
FILE *fp = fopen("sensor.bin", "wb");
fwrite(&sensor, sizeof(SensorData), 1, fp);
fclose(fp);

// 读出
SensorData read_back;
fp = fopen("sensor.bin", "rb");
fread(&read_back, sizeof(SensorData), 1, fp);
fclose(fp);

printf("ID: 0x%X, Name: %s, Temp: %.2f\n",
       read_back.id, read_back.name, read_back.temperature);

经验之谈:结构体里如果有指针,千万别直接 fwrite。指针存的是地址,换一台机器、甚至同一个程序下次运行,地址都变了。正确的做法是:把指针指向的数据单独序列化。

8.4 字节序问题——跨平台的大坑

字节序,也叫端序(Endianness)。说白了就是:多字节数据在内存里是怎么排的。

  • 大端(Big-Endian):高位字节在低地址。比如 0x12345678,内存里是 12 34 56 78。
  • 小端(Little-Endian):低位字节在低地址。比如 0x12345678,内存里是 78 56 34 12。

x86 和大多数 ARM 都是小端。但网络协议、某些 DSP、PowerPC 是大端。你想想看,在 x86 上用 fwrite 写了一个 int,拿到大端机器上用 fread 读回来——数值就变了。

我曾经踩过的坑:在 STM32 上采集了一批传感器数据,用 fwrite 写到 SD 卡。拿到 PC 上解析,温度值全不对。查了一下午才发现,STM32 是小端,PC 也是小端,但我的解析代码里手动做了字节序转换——画蛇添足。

解决字节序问题,业界通用做法是:约定一个网络字节序(大端),写入前转换,读出后转换回来。

// 判断本机字节序
int is_little_endian() {
    uint16_t test = 0x0001;
    return *(uint8_t*)&test == 0x01;
}

// 简单的字节序转换(32位)
uint32_t swap32(uint32_t val) {
    return ((val >> 24) & 0x000000FF) |
           ((val >> 8)  & 0x0000FF00) |
           ((val << 8)  & 0x00FF0000) |
           ((val << 24) & 0xFF000000);
}

// 写入时统一转成大端
uint32_t val = 0x12345678;
uint32_t be_val = is_little_endian() ? swap32(val) : val;
fwrite(&be_val, sizeof(uint32_t), 1, fp);

实用建议:如果你的数据只在同一平台内使用,字节序不用管。但如果要跨平台、跨网络、或者存到 SD 卡拿到 PC 上解析——统一用大端,省心。

8.5 知识体系总览

下面这张图,把二进制文件读写的核心脉络梳理清楚了:

二进制文件读写知识体系 fread / fwrite 基本数据类型读写 int / float / char 数组 结构体序列化 注意指针成员处理 字节序问题 大端 vs 小端 核心原则 跨平台统一字节序 · 检查返回值 · 指针单独处理

8.6 避坑指南

  1. 结构体对齐:不同编译器、不同平台的结构体填充字节可能不同。跨平台传输时,建议用 #pragma pack(1) 取消对齐,或者手动序列化每个字段。
  2. 文件打开模式:二进制读写必须用 "wb""rb"。在 Windows 上,如果用了 "w""r",系统会把 0x0A 自动转成 0x0D 0x0A——数据就坏了。
  3. fwrite 不保证立即写入磁盘:嵌入式设备突然断电时,fwrite 返回成功但数据可能还在缓冲区。需要时可以用 fflush()fsync()
  4. 不要用 sizeof 直接算结构体大小来传输:不同机器上 sizeof 结果可能不同。我建议你定义一个固定的协议头,里面写明每个字段的长度。

一句话总结:二进制读写就是内存的「快照」操作。快、准、省空间。但跨平台时,字节序和对齐这两个坑,你得提前填好。


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