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判断失败的原因。别把顺序搞反了。
五、知识体系总览
下面这张图帮你理清本章的核心脉络:
六、避坑指南与实战经验
最后分享几个我亲身踩过的坑:
- 结构体对齐问题:不同编译器、不同平台的结构体内存布局可能不同。用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