15、二进制文件读写实战:结构体数据的存储与读取

说实话,很多初学者一听到“二进制文件”就觉得头大。觉得那是底层高手才玩的东西。其实不然。我个人习惯把二进制文件看作“内存的快照”。你想想看,结构体在内存里怎么排列的?二进制文件里就怎么存。简单直接,没有花里胡哨的格式转换。

这一节,我们就来实战一把。把结构体数据写进二进制文件,再读出来。我会带着你一步步走,顺便聊聊我踩过的坑。

为什么不用文本文件?

文本文件存结构体,你得自己处理格式化。比如用 fprintf 写进去,再用 fscanf 读出来。麻烦不说,还容易出错。浮点数精度会丢,字符串里万一有逗号、换行符,解析就崩了。

二进制文件呢?直接按内存字节拷贝。写进去是什么,读出来就是什么。效率也高,尤其适合存大量数据。我在项目中遇到过需要存几十万个传感器数据点的场景。用文本文件?慢得让人抓狂。换成二进制,秒级搞定。

核心要点: 二进制文件读写,本质就是内存和磁盘之间的直接字节拷贝。

两个关键函数: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:文件指针。

返回值是成功读写的元素个数。如果没达到预期,说明出错了。

我的小习惯: 每次调用完 fwritefread,我都会检查返回值。哪怕只是写一个结构体。因为磁盘满了、权限不够,都可能让写入失败。别偷懒。

实战:存一个学生信息结构体

假设我们要存一个学生的信息:学号、姓名、成绩。结构体定义如下:

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

typedef struct {
    int id;             // 学号
    char name[20];      // 姓名
    float score;        // 成绩
} Student;

嗯,这里要注意。字符串用定长数组,别用指针。为什么?因为指针存的是地址。你写进文件的是地址值,不是字符串本身。下次读出来,那个地址早就失效了。我曾经在这个问题上栽过跟头,调试了半天才发现。

写入二进制文件

int main() {
    Student stu = {1001, "张三", 92.5f};
    
    FILE *fp = fopen("student.dat", "wb");
    if (fp == NULL) {
        perror("打开文件失败");
        return 1;
    }
    
    size_t written = fwrite(&stu, sizeof(Student), 1, fp);
    if (written != 1) {
        fprintf(stderr, "写入失败\n");
        fclose(fp);
        return 1;
    }
    
    fclose(fp);
    printf("写入成功\n");
    return 0;
}

看到没?fwrite 一行就把整个结构体怼进去了。文件打开模式是 "wb",w 表示写,b 表示二进制。在 Windows 下,这个 b 很重要。不加的话,系统可能会偷偷把换行符 \n 转成 \r\n,把数据搞乱。Linux 下没区别,但为了可移植性,我建议你永远加上 b。

读取二进制文件

int main() {
    Student stu = {0};  // 先清零
    
    FILE *fp = fopen("student.dat", "rb");
    if (fp == NULL) {
        perror("打开文件失败");
        return 1;
    }
    
    size_t read = fread(&stu, sizeof(Student), 1, fp);
    if (read != 1) {
        fprintf(stderr, "读取失败\n");
        fclose(fp);
        return 1;
    }
    
    fclose(fp);
    
    printf("学号: %d\n", stu.id);
    printf("姓名: %s\n", stu.name);
    printf("成绩: %.2f\n", stu.score);
    return 0;
}

读出来直接就能用。不用解析字符串,不用处理格式。舒服吧?

注意: 二进制文件没有可读性。你用记事本打开 student.dat,看到的是一堆乱码。这是正常的。二进制文件是给程序读的,不是给人看的。

结构体对齐问题

这里有个坑,我必须提一下。结构体在内存中不是紧密排列的。编译器可能会在成员之间插入填充字节,以保证对齐。比如:

typedef struct {
    char a;    // 1字节
    int b;     // 4字节
} Test;

在 32 位系统上,sizeof(Test) 很可能是 8,而不是 5。因为 int b 要从 4 的倍数地址开始,所以 char a 后面会填充 3 个字节。

这意味着,你用 fwrite 写进去的文件,在不同平台或不同编译器下,可能无法正确读取。因为填充规则可能不一样。

怎么解决?我一般用两种方法:

  1. 手动对齐: 把成员按大小从大到小排列。大的放前面,小的放后面。这样可以减少填充。
  2. 使用 #pragma pack 强制编译器按 1 字节对齐。但要注意,这可能会影响性能。

举个例子:

#pragma pack(1)  // 按1字节对齐
typedef struct {
    char a;
    int b;
} Test;
#pragma pack()   // 恢复默认对齐

这样 sizeof(Test) 就是 5 了。写出来的文件也更紧凑。跨平台读写时,两边都用同样的 pack 设置,就没问题。

我的建议: 如果你确定文件只在同一台机器、同一个编译器下使用,可以不管对齐。否则,最好用 #pragma pack(1) 或者自己写序列化函数。

读写结构体数组

单个结构体太简单了。实际项目中,我们经常要存一堆数据。比如一个班级的学生。这时候,fwritefread 的第三个参数就派上用场了。

Student class[3] = {
    {1001, "张三", 92.5f},
    {1002, "李四", 88.0f},
    {1003, "王五", 76.5f}
};

// 写入整个数组
fwrite(class, sizeof(Student), 3, fp);

// 读取整个数组
fread(class, sizeof(Student), 3, fp);

一次调用,搞定全部。效率极高。我记得有一次需要存 10 万个日志记录,用 fwrite 批量写入,耗时不到 0.1 秒。换成文本方式,至少 2 秒。

知识体系总览

下面这张图,帮你理清今天的内容:

二进制文件读写 fwrite 写入 模式 "wb" 检查返回值 fread 读取 模式 "rb" 结构体对齐问题 结构体数组批量读写 第三个参数 = 元素个数 核心:内存字节 ↔ 磁盘字节 直接拷贝

避坑指南

最后,分享几个我实战中遇到的教训:

  • 文件打开失败不检查: 很多人直接 fwrite,结果程序崩溃。记住,每次 fopen 后都要判断 fp == NULL
  • 忘记用 fclose 写完后不关闭文件,数据可能还在缓冲区里,没真正写到磁盘。尤其是程序突然崩溃时,数据就丢了。
  • 结构体里有指针: 前面说过,指针存的是地址。写文件时,应该写指针指向的数据,而不是指针本身。要么用定长数组,要么自己实现序列化。
  • 跨平台读写: 不同系统的字节序(大端/小端)可能不同。如果文件要在不同架构的机器间交换,需要处理字节序。不过,大部分情况下,x86 和 ARM 都是小端,问题不大。

好了,二进制文件读写的基本功就这些。说白了,就是 fwritefread 的灵活运用。多写几遍,自然就熟了。


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