10. 指针与文件操作:文件指针 FILE*、文件的打开与关闭、文件的读写操作(fread/fwrite)、文件位置指针的定位
文件操作,说白了就是让程序跟磁盘上的数据打交道。很多初学者觉得文件操作跟指针关系不大,其实恰恰相反——文件操作的核心就是指针。FILE* 这个指针,贯穿了整个文件读写流程。我做了这么多年嵌入式开发,可以负责任地说:搞不懂文件指针,你就搞不懂文件操作。
10.1 文件指针 FILE*:一切操作的起点
在 C 语言里,所有文件操作都围绕 FILE 结构体展开。这个结构体定义在 stdio.h 中,里面包含了文件的各种状态信息:当前读写位置、缓冲区状态、错误标志等。但我们一般不直接操作它的成员,而是通过 FILE* 指针来间接控制。
核心理解:FILE* 就像是一个遥控器。你不需要知道电视机内部怎么工作的,只要按遥控器上的按钮就行。FILE* 就是那个遥控器,fopen、fread、fwrite 这些函数就是按钮。
我个人习惯把 FILE* 叫做「文件句柄」。虽然严格来说句柄和指针有区别,但在 C 语言里,它们就是一回事。你声明一个 FILE* 变量,然后用 fopen 给它赋值,后续所有操作都通过这个指针来完成。
FILE *fp; // 声明一个文件指针
fp = fopen("data.bin", "rb"); // 打开文件,让 fp 指向它
if (fp == NULL) {
// 处理错误
}
这里要注意:fp 本身只是一个指针,它指向的是系统内部维护的一个 FILE 结构体。你不需要关心这个结构体在哪里、有多大,只要知道怎么用 fp 就行。
10.2 文件的打开与关闭:有借有还,再借不难
打开文件用 fopen,关闭文件用 fclose。这俩函数必须成对出现。我在项目中遇到过好几次内存泄漏,追查到最后都是 fopen 了忘记 fclose。
fopen 的原型是:
FILE *fopen(const char *filename, const char *mode);
mode 参数决定了你怎么操作这个文件。常用的模式有:
| 模式 | 含义 | 文件存在时 | 文件不存在时 |
|---|---|---|---|
| "r" | 只读 | 正常打开 | 返回 NULL |
| "w" | 只写 | 清空内容 | 创建新文件 |
| "a" | 追加 | 从末尾写入 | 创建新文件 |
| "rb" | 二进制只读 | 正常打开 | 返回 NULL |
| "wb" | 二进制只写 | 清空内容 | 创建新文件 |
我曾经踩过的坑:用 "w" 模式打开一个重要的配置文件,结果文件被清空了。从那以后,我写文件前一定会先确认:这个文件能不能覆盖?要不要先备份?
关闭文件很简单:
fclose(fp);
fp = NULL; // 我习惯把指针置空,防止野指针
为什么要置空?因为 fclose 之后,fp 指向的 FILE 结构体已经被释放了,但 fp 本身的值还在。如果你不小心又用了它,那就是典型的野指针问题。嗯,这里要注意。
10.3 文件的读写操作:fread 和 fwrite
fread 和 fwrite 是二进制文件读写的核心函数。它们的原型是:
size_t fread(void *ptr, size_t size, size_t nmemb, FILE *stream);
size_t fwrite(const void *ptr, size_t size, size_t nmemb, FILE *stream);
参数看着多,其实很好记:
- ptr:数据缓冲区的指针
- size:每个数据单元的字节数
- nmemb:要读写的数据单元个数
- stream:文件指针
返回值是实际读写的数据单元个数。如果跟 nmemb 不一样,说明出错了或者读到文件末尾了。
举个例子,读写一个结构体数组:
typedef struct {
int id;
char name[32];
float score;
} Student;
Student students[100];
// 写入文件
size_t written = fwrite(students, sizeof(Student), 100, fp);
if (written != 100) {
// 处理写入错误
}
// 从文件读取
size_t read = fread(students, sizeof(Student), 100, fp);
if (read != 100) {
if (feof(fp)) {
// 读到文件末尾了
} else {
// 读取出错
}
}
我的经验:用 fread/fwrite 读写结构体时,一定要注意结构体内存对齐问题。不同编译器、不同平台的对齐方式可能不一样。如果你要把数据在不同平台间交换,最好用固定大小的字段,或者自己实现序列化。
你想想看,如果在一个 32 位系统上写了一个结构体,拿到 64 位系统上去读,很可能因为对齐方式不同而读出一堆乱码。我曾经在项目里吃过这个亏,后来学乖了,要么用 #pragma pack,要么手动处理每个字段。
10.4 文件位置指针的定位:想读哪里读哪里
每个打开的文件都有一个「文件位置指针」,它指向当前要读写的位置。你每次调用 fread 或 fwrite,这个指针就会自动往后移动。但有时候你需要跳到文件的某个特定位置去读写,这时候就要用到 fseek 和 ftell。
fseek 的原型:
int fseek(FILE *stream, long offset, int whence);
whence 参数有三个选项:
- SEEK_SET:从文件开头算起
- SEEK_CUR:从当前位置算起
- SEEK_END:从文件末尾算起
offset 是偏移量,单位是字节。正数向后移,负数向前移。
// 跳到文件开头
fseek(fp, 0, SEEK_SET);
// 跳到文件末尾
fseek(fp, 0, SEEK_END);
// 获取文件大小
long file_size = ftell(fp);
// 跳到第 100 个字节
fseek(fp, 100, SEEK_SET);
// 从当前位置后退 50 个字节
fseek(fp, -50, SEEK_CUR);
注意:fseek 对文本文件的支持不太好。因为文本文件在 Windows 系统下会有换行符转换(\n 变成 \r\n),导致字节偏移量跟实际文件位置对不上。所以,如果你要用 fseek 做精确定位,最好用二进制模式打开文件。
ftell 返回当前文件位置指针的值,也就是从文件开头到当前位置的字节数。它常跟 fseek 配合使用,比如先 ftell 记住位置,操作完后再 fseek 回去。
还有一个 rewind 函数,等价于 fseek(fp, 0, SEEK_SET),但更简洁:
rewind(fp); // 回到文件开头
10.5 知识体系总览
下面这张图把本章的核心知识点串起来了。你看一遍,应该能对文件指针的操作有个整体认识。
10.6 综合示例:读写一个二进制文件
说了这么多,来一个完整的例子。这个程序把 10 个整数写入文件,然后读出来验证:
#include <stdio.h>
#include <stdlib.h>
int main() {
FILE *fp;
int data[10] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
int buffer[10];
size_t ret;
// 写入文件
fp = fopen("numbers.bin", "wb");
if (fp == NULL) {
perror("打开文件失败");
return 1;
}
ret = fwrite(data, sizeof(int), 10, fp);
if (ret != 10) {
fprintf(stderr, "写入数据不完整\n");
fclose(fp);
return 1;
}
fclose(fp);
// 读取文件
fp = fopen("numbers.bin", "rb");
if (fp == NULL) {
perror("打开文件失败");
return 1;
}
ret = fread(buffer, sizeof(int), 10, fp);
if (ret != 10) {
fprintf(stderr, "读取数据不完整\n");
fclose(fp);
return 1;
}
// 验证数据
for (int i = 0; i < 10; i++) {
printf("buffer[%d] = %d\n", i, buffer[i]);
}
fclose(fp);
return 0;
}
这个例子看起来简单,但包含了文件操作的所有关键步骤:打开、检查、读写、关闭。每一步都不能省。我见过太多人只检查 fopen 的返回值,却不检查 fread/fwrite 的返回值,结果数据写了一半都不知道。
我的习惯:每次调用 fread/fwrite 后,我都会检查返回值。如果跟预期不符,再用 feof 和 ferror 判断是到了文件末尾还是真的出错了。这样排查问题会快很多。
文件操作说白了就是三个步骤:打开、操作、关闭。但每个步骤里都有坑。你只要记住:指针要检查、返回值要检查、关闭后要置空。做到这三点,文件操作这块基本就不会出大问题。
公众号:蓝海资料掘金营,微信deep3321