29、文件操作(三):二进制文件读写、文件定位与结束判断

聊到二进制文件读写,我得先坦白一件事——刚学C语言那会儿,我总觉得文本文件就够用了,二进制文件?没必要吧?直到有一次我在项目中需要读写一个结构体数组,用fprintf/fscanf搞了半天,数据总是对不齐,还莫名其妙多出换行符……嗯,从那以后,我才真正理解了二进制读写的价值。

一、为什么需要二进制读写?

说白了,文本文件存的是“人能看懂的字符”,二进制文件存的是“机器直接用的字节”。你想想看,如果你要存一个int类型的数值123456,文本方式会存成6个字符'1','2','3','4','5','6',占6个字节;而二进制方式直接存这个整数的4个字节内存映像,又快又省空间。

我个人习惯:需要跨平台交换数据时用文本,追求性能和精确性时用二进制。比如保存游戏存档、图像数据、音频采样,这些场景二进制文件是首选。

二、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:文件指针

返回值是实际成功读写的数据项个数,不是字节数。这一点我踩过坑——曾经以为返回的是字节数,结果判断条件写错了,调试了半天才发现。

核心要点:fread/fwrite操作的是“内存块”,不关心数据的具体类型。所以读写结构体、数组这类复合数据特别方便。

示例:写入并读取一个结构体数组

#include <stdio.h>
#include <stdlib.h>

typedef struct {
    int id;
    char name[20];
    float score;
} Student;

int main() {
    Student students[3] = {
        {1, "张三", 89.5},
        {2, "李四", 92.0},
        {3, "王五", 78.5}
    };
    Student read_back[3];

    // 写入二进制文件
    FILE *fp = fopen("students.dat", "wb");
    if (fp == NULL) {
        perror("打开文件失败");
        return 1;
    }
    size_t written = fwrite(students, sizeof(Student), 3, fp);
    printf("成功写入 %zu 个学生数据\n", written);
    fclose(fp);

    // 读取二进制文件
    fp = fopen("students.dat", "rb");
    if (fp == NULL) {
        perror("打开文件失败");
        return 1;
    }
    size_t read = fread(read_back, sizeof(Student), 3, fp);
    printf("成功读取 %zu 个学生数据\n", read);
    for (int i = 0; i < 3; i++) {
        printf("ID: %d, 姓名: %s, 成绩: %.1f\n",
               read_back[i].id, read_back[i].name, read_back[i].score);
    }
    fclose(fp);

    return 0;
}

小提示:二进制文件打开模式必须用"b"标志——"wb"写入、"rb"读取、"ab"追加。忘了加"b"在某些系统上(比如Windows)会导致数据被意外转换(比如0x0A变成0x0D 0x0A)。

三、文件定位:fseek / ftell / rewind

文件操作就像在磁带机上找歌——你得知道当前在哪儿,想去哪儿,怎么跳过去。这三个函数就是干这个的。

fseek:跳到指定位置

int fseek(FILE *stream, long offset, int whence);

whence有三种取值:

含义
SEEK_SET 0 从文件开头算起
SEEK_CUR 1 从当前位置算起
SEEK_END 2 从文件末尾算起

举个例子:

fseek(fp, 0, SEEK_SET);   // 跳到文件开头
fseek(fp, 10, SEEK_CUR);  // 向前跳10个字节
fseek(fp, -5, SEEK_END);  // 跳到文件末尾前5个字节

注意:fseek的offset参数是long类型,对于超大文件(超过2GB)可能不够用。这时候可以考虑fseeko(使用off_t类型)或平台相关的API。

ftell:告诉我现在在哪儿

long ftell(FILE *stream);

返回当前文件位置相对于开头的字节偏移量。出错时返回-1L。

我在项目中常用ftell来获取文件大小:

fseek(fp, 0, SEEK_END);
long file_size = ftell(fp);
rewind(fp);  // 或者 fseek(fp, 0, SEEK_SET);
printf("文件大小: %ld 字节\n", file_size);

rewind:一键回到起点

void rewind(FILE *stream);

等价于 fseek(stream, 0L, SEEK_SET),同时会清除文件结束标志和错误标志。嗯,这里要注意:rewind没有返回值,如果出错你没法直接知道。我个人更习惯用fseek,至少能检查返回值。

四、文件结束判断:feof

int feof(FILE *stream);

当文件内部位置指针到达文件末尾时,feof返回非零值。但有个坑——feof不是提前预判,而是事后确认

我曾经犯过一个经典错误:

// ❌ 错误用法
while (!feof(fp)) {
    fread(&data, sizeof(data), 1, fp);
    // 处理data...
}

为什么会这样?因为feof只有在尝试读取越过文件末尾之后才会被设置。上面的循环会多读一次,处理到无效数据。

正确的做法是:

// ✅ 正确用法
while (fread(&data, sizeof(data), 1, fp) == 1) {
    // 处理data...
}
// 循环结束后,可以用feof判断是否正常结束
if (feof(fp)) {
    printf("正常读取到文件末尾\n");
} else {
    printf("读取过程中发生错误\n");
}

记住这个原则:用读写函数的返回值判断是否成功,用feof/ferror判断失败的原因。别把顺序搞反了。

五、知识体系总览

下面这张图帮你理清本章的核心脉络:

二进制文件操作 核心函数:fread / fwrite 参数:ptr, size, count, stream 返回值:实际读写项数 模式:"wb"/"rb"/"ab" 文件定位三剑客 fseek ftell rewind 结束判断:feof 事后判断机制 配合返回值使用 区分EOF与错误 核心原则:读写靠返回值,定位靠三剑客,结束靠feof

六、避坑指南与实战经验

最后分享几个我亲身踩过的坑:

  • 结构体对齐问题:不同编译器、不同平台的结构体内存布局可能不同。用fwrite写入的结构体,换台机器用fread读回来可能就乱套了。我曾经在x86上写的数据,放到ARM上读就出问题了。解决方案:要么用#pragma pack(1)强制1字节对齐,要么序列化成固定格式。
  • 文本模式与二进制模式的混淆:在Windows上,如果不加"b"标志,fwrite写入0x0A时会被自动扩展成0x0D 0x0A。读回来时0x0D 0x0A又被压缩成0x0A。这会导致二进制数据损坏。我有个同事因为这个bug排查了一整天。
  • feof的误用:前面已经说过了,千万别用while(!feof(fp))作为循环条件。用fread/fscanf等函数的返回值来判断是否读到数据,这才是正道。

我的个人习惯:每次打开二进制文件后,第一件事就是检查fopen的返回值。写入完成后,用fflush确保数据落盘。读取前,用fseek+ftell获取文件大小,预分配好缓冲区。这些习惯帮我避免了很多低级错误。

二进制文件操作是C语言文件处理的核心能力。掌握了fread/fwrite、fseek/ftell/rewind、feof这三组工具,你就能应对绝大多数文件读写场景。记住:读写靠返回值,定位靠三剑客,结束判断靠feof配合返回值。多写多练,这些函数会成为你得心应手的工具。


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