上一篇文章我们学会了用 fprintf 和 fscanf 读写文本文件。文本文件最大的好处是可读------拿记事本打开就能看懂。但它也有明显的短板:存一个 double 要写成 "3.14159265358979" 这十几字节,读回来还要解析,精度可能损失,速度也慢。
如果你需要高效地存储大量数值,或者想快速跳到文件中间读写某条记录,那就得换另一种思路------把数据在内存中的样子原封不动地写入磁盘 。这就是二进制文件。配合 C 语言提供的随机访问函数,你就能像操作数组一样操作文件中的任意位置。
一、二进制文件 vs 文本文件:底层都一样,解读方式不同
不管文本文件还是二进制文件,对于计算机来说都是 0 和 1 的字节流。区别只在于:你如何解读这些字节。
比如一个整数 123456789(十六进制 0x075BCD15),在内存中占 4 字节:
内存内容(小端): 15 CD 5B 07
- 文本方式写入 :
fprintf(fp, "%d", 123456789);会把它转换为字符串"123456789",写出 9 个 ASCII 字符:31 32 33 34 35 36 37 38 39。 - 二进制方式写入 :
fwrite(&n, sizeof(int), 1, fp);直接把那 4 字节15 CD 5B 07写入文件。
优缺点对比:
| 文本文件 | 二进制文件 | |
|---|---|---|
| 可读性 | 人可以直接看懂 | 乱码(需要程序解读) |
| 文件体积 | 较大(数字越长越大) | 紧凑(固定大小) |
| 读写速度 | 慢(需要格式转换) | 快(直接搬移内存) |
| 精度 | 可能损失(浮点数) | 完全不损失 |
| 跨平台 | 安全 | 需注意字节序和类型大小 |
什么时候用二进制? 游戏存档、数据库文件、图像/音频/视频数据、大量传感器数据记录------只要数据量大、结构固定、不靠人眼阅读,就优选二进制。
二、fread 与 fwrite:读写"裸"数据块
这两个函数是二进制 I/O 的核心。
c
size_t fwrite(const void *ptr, size_t size, size_t count, FILE *stream);
size_t fread (void *ptr, size_t size, size_t count, FILE *stream);
ptr:内存中数据的地址。size:每个数据块的字节数(通常用sizeof)。count:要读写的数据块个数。stream:文件指针。- 返回值:成功读/写的数据块个数(不是字节数!)。
写入二进制数据
把几个不同类型的数据一次写入文件:
c
#include <stdio.h>
int main(void) {
FILE *fp = fopen("data.bin", "wb"); // 注意 "wb":二进制写模式
if (fp == NULL) {
perror("打开文件失败");
return 1;
}
int n = 42;
double pi = 3.14159265;
char msg[] = "Hello";
fwrite(&n, sizeof(int), 1, fp); // 写入一个 int
fwrite(&pi, sizeof(double), 1, fp); // 写入一个 double
fwrite(msg, sizeof(char), 5, fp); // 写入 5 个 char(不含 '\0')
fclose(fp);
return 0;
}
"wb" 中的 b 明确告诉操作系统"我是二进制模式"。在 Linux/macOS 上 "w" 和 "wb" 没有实际区别,但 Windows 上文本模式会自动把 \n 转换为 \r\n,二进制模式则不会。为了跨平台,写二进制一定要加 b。
读取二进制数据
c
#include <stdio.h>
int main(void) {
FILE *fp = fopen("data.bin", "rb");
if (fp == NULL) {
perror("打开文件失败");
return 1;
}
int n;
double pi;
char msg[6] = {0}; // 多留一位给 '\0'
fread(&n, sizeof(int), 1, fp);
fread(&pi, sizeof(double), 1, fp);
fread(msg, sizeof(char), 5, fp);
printf("n=%d, pi=%.8f, msg=%s\n", n, pi, msg);
fclose(fp);
return 0;
}
读取的顺序和类型必须与写入时严格一致。如果读的类型对不上,结果将是垃圾值。
检查 fread 的返回值
fread 可能读不到你期望的数量(文件损坏、意外结束)。应该检查返回值:
c
size_t read_count = fread(&n, sizeof(int), 1, fp);
if (read_count != 1) {
printf("读取失败或文件结束\n");
}
三、结构体 + 二进制 = 天然绝配
结构体的内存布局是连续的(考虑对齐),因此可以一次性把整个结构体写入或读出,极其方便。
c
#include <stdio.h>
typedef struct {
char name[20];
int id;
float score;
} Student;
int main(void) {
// 写入
FILE *fp = fopen("students.bin", "wb");
Student s1 = {"Alice", 1001, 92.5};
Student s2 = {"Bob", 1002, 85.0};
fwrite(&s1, sizeof(Student), 1, fp);
fwrite(&s2, sizeof(Student), 1, fp);
fclose(fp);
// 读取
fp = fopen("students.bin", "rb");
Student students[10];
int count = 0;
while (fread(&students[count], sizeof(Student), 1, fp) == 1) {
count++;
}
fclose(fp);
for (int i = 0; i < count; i++) {
printf("%s %d %.1f\n", students[i].name, students[i].id, students[i].score);
}
return 0;
}
注意:如果结构体中有指针成员(比如 char *name),不能直接 fwrite。因为写入的是指针的值(一个地址),而不是指针指向的内容,读回来时那个地址早已无效。包含指针的结构体需要手动序列化(逐个成员处理)。
四、随机访问:在文件中"跳来跳去"
到目前为止,我们读写文件都是顺序的------从头往后,不能回头,不能直接定位。但很多时候我们想直接跳到第 100 条记录、或者回到开头重读。这就需要随机访问。
每个文件流内部维护一个当前位置指示器,记录下一次读写将在哪个字节偏移处进行。C 语言提供了三个关键函数来操作它。
1. fseek:定位到指定位置
c
int fseek(FILE *stream, long offset, int whence);
stream:文件指针。offset:偏移量(字节),正数向后移,负数向前移。whence:参照点,可选:SEEK_SET:文件开头SEEK_CUR:当前位置SEEK_END:文件末尾
示例:
c
fseek(fp, 0, SEEK_SET); // 回到文件开头
fseek(fp, sizeof(Student) * 5, SEEK_SET); // 跳到第 6 个学生(索引 5)
fseek(fp, 0, SEEK_END); // 跳到文件末尾
fseek(fp, -100, SEEK_END); // 从文件末尾往前退 100 字节
fseek(fp, 10, SEEK_CUR); // 从当前位置往后跳 10 字节
返回值:成功返回 0,失败返回非 0。
2. ftell:获取当前位置
c
long ftell(FILE *stream);
返回当前字节偏移(从文件开头算起),出错返回 -1L。
c
fseek(fp, 0, SEEK_END);
long file_size = ftell(fp); // 文件大小(字节数)
3. rewind:快捷回到开头
c
rewind(fp);
等价于 fseek(fp, 0, SEEK_SET);,但同时会清除文件流的错误标志。
五、实战:小型学生信息管理系统(二进制存储 + 随机读写)
把结构体、动态内存、随机访问结合,做一个完整的小型管理系统。数据以二进制存储,支持添加、列表、修改、查找、删除功能。
student_system.c
c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#define FILENAME "students.dat"
#define MAX_NAME 20
typedef struct {
char name[MAX_NAME];
int id;
float score;
} Student;
// 追加一个学生记录
void add_student(void) {
FILE *fp = fopen(FILENAME, "ab");
if (fp == NULL) {
perror("打开文件失败");
return;
}
Student s;
printf("姓名 学号 成绩: ");
scanf("%s %d %f", s.name, &s.id, &s.score);
fwrite(&s, sizeof(Student), 1, fp);
fclose(fp);
printf("已添加。\n");
}
// 列表所有学生
void list_students(void) {
FILE *fp = fopen(FILENAME, "rb");
if (fp == NULL) {
printf("暂无记录。\n");
return;
}
Student s;
int count = 0;
printf("---- 学生列表 ----\n");
while (fread(&s, sizeof(Student), 1, fp) == 1) {
printf("#%d: %s, 学号=%d, 成绩=%.1f\n", count, s.name, s.id, s.score);
count++;
}
if (count == 0) printf("(无记录)\n");
fclose(fp);
}
// 根据学号查找
int find_student_by_id(int target_id, Student *out) {
FILE *fp = fopen(FILENAME, "rb");
if (fp == NULL) return -1;
int index = 0;
while (fread(out, sizeof(Student), 1, fp) == 1) {
if (out->id == target_id) {
fclose(fp);
return index; // 返回记录索引
}
index++;
}
fclose(fp);
return -1;
}
// 修改学生成绩
void update_score(void) {
int target_id;
printf("输入要修改的学号: ");
scanf("%d", &target_id);
FILE *fp = fopen(FILENAME, "r+b"); // 二进制读写模式
if (fp == NULL) {
printf("文件不存在。\n");
return;
}
Student s;
long pos;
int found = 0;
while ((pos = ftell(fp)) >= 0 && fread(&s, sizeof(Student), 1, fp) == 1) {
if (s.id == target_id) {
printf("当前成绩: %.1f,新成绩: ", s.score);
scanf("%f", &s.score);
fseek(fp, pos, SEEK_SET); // 回到这条记录的开头
fwrite(&s, sizeof(Student), 1, fp); // 覆盖写入
found = 1;
break;
}
}
fclose(fp);
printf(found ? "修改成功。\n" : "未找到该学号。\n");
}
// 删除学生(通过创建新文件并跳过删除项)
void delete_student(void) {
int target_id;
printf("输入要删除的学号: ");
scanf("%d", &target_id);
FILE *fp = fopen(FILENAME, "rb");
if (fp == NULL) {
printf("文件不存在。\n");
return;
}
FILE *temp = fopen("temp.dat", "wb");
if (temp == NULL) {
perror("临时文件创建失败");
fclose(fp);
return;
}
Student s;
int found = 0;
while (fread(&s, sizeof(Student), 1, fp) == 1) {
if (s.id == target_id) {
found = 1; // 跳过这条记录
} else {
fwrite(&s, sizeof(Student), 1, temp);
}
}
fclose(fp);
fclose(temp);
remove(FILENAME);
rename("temp.dat", FILENAME);
printf(found ? "删除成功。\n" : "未找到该学号。\n");
}
int main(void) {
int choice;
while (1) {
printf("\n1.添加 2.列表 3.查找 4.修改 5.删除 0.退出\n");
printf("选择: ");
scanf("%d", &choice);
switch (choice) {
case 1: add_student(); break;
case 2: list_students(); break;
case 3: {
int id;
printf("输入学号: ");
scanf("%d", &id);
Student s;
int idx = find_student_by_id(id, &s);
if (idx >= 0) printf("找到: %s 成绩 %.1f\n", s.name, s.score);
else printf("未找到。\n");
break;
}
case 4: update_score(); break;
case 5: delete_student(); break;
case 0: return 0;
default: printf("无效选项。\n");
}
}
}
重点分析:
- 追加 :用
"ab"模式,新记录自动加在末尾。 - 修改 :用
"r+b"模式,先用ftell记录读取位置,读到目标后,用fseek回退到该位置,再fwrite覆盖。这就用到了随机定位。 - 删除:因为从文件中"挖掉"一块很难,常用策略是创建临时文件,把要保留的记录复制过去,跳过目标,再替换原文件。这也是随机读写的一种变通应用。
六、常见错误与陷阱
1. 忘记 b 模式(Windows 下最要命)
c
fopen("data.bin", "w"); // Windows 下会把 0x0A 变成 0x0D 0x0A
在 Windows 上,文本模式会转换换行符,破坏二进制数据。写二进制文件永远加 b。
2. 读写的结构体包含指针
c
typedef struct {
char *name; // 指针!
int id;
} Bad;
fwrite(&bad, sizeof(Bad), 1, fp); // 写入的是指针值,不是字符串
包含指针的结构体不能直接二进制读写。只对不含指针的"纯数据"结构体使用。
3. 对 fread 返回值检查不足
c
fread(&s, sizeof(Student), 5, fp); // 期望读 5 个,实际可能只读 3 个
永远用返回值判断实际读到了多少块。
4. fseek 偏移量计算错误
c
fseek(fp, 5, SEEK_SET); // 跳到第 5 字节,不是第 5 个记录!
若想跳第 n 条记录,偏移量应为 n * sizeof(Student)。
5. 跨平台字节序和大小不一致
一台机器上写入的二进制文件,另一台可能读出来是错的(比如大端 vs 小端,int 4 字节 vs 2 字节)。如果要在不同平台间交换二进制数据,需要设计固定的字节序和类型大小(如使用 int32_t 进行序列化)。
七、小结
今天你打开了文件操作的另一半世界:
- 二进制 I/O :
fwrite和fread直接搬移内存,高效紧凑,与结构体配合尤其方便。 - 随机访问 :
fseek让你在文件中任意定位,ftell告诉你当前位置,rewind一键回开头。 - 实战组合:用二进制 + 随机读写实现了学生信息的增、查、改、删,体验了"文件即数据库"的雏形。
现在你对文件操作已经有了相当全面的掌握。但 C 语言还有一个非常强大却容易被滥用的部分------预处理器 。从下一篇开始,我们将进入预处理指令的世界:宏定义的技巧与陷阱,条件编译如何让一套代码适配多平台,以及 #include 背后更深层的管理艺术。准备好了吗?
课后小练习
- 写一个程序,用二进制方式把 100 以内的所有偶数写入
evens.bin,然后再读取回来验证数据是否正确。 - 用
fseek和ftell实现一个函数long file_size(const char *filename),返回文件的大小(字节数)。如果文件不存在,返回 -1。 - 在上面的学生管理系统中增加一个"交换第 i 和 第 j 条记录"的功能:通过
fseek定位两条记录,读取到内存,然后交换并写回。 - (小挑战)设计一个简单的"键值数据库"文件格式:每条记录是
int key+int value。实现put(key, value)和get(key)操作。提示:可以将整个文件读入数组,在内存中查找/修改,再写回;或者用fseek随机遍历。哪种方式更高效?什么时候用哪一种?
我们下期见!
💡获取本系列示例代码请访问 GitCode 仓库。