16、指针与文件操作:文件指针FILE*、fopen/fclose、fread/fwrite、文件读写中的指针偏移
文件操作,说白了就是让程序跟磁盘上的数据打交道。很多C语言初学者觉得文件操作跟指针没啥关系,其实恰恰相反——文件操作的核心就是一个指针,叫FILE*。我当年刚入行时,也以为文件操作就是调几个函数完事,直到有一次项目里读写二进制文件出了大问题,才真正把文件指针这玩意儿搞明白。
16.1 文件指针FILE*——文件操作的“遥控器”
你想想看,你要操作一个文件,总得有个东西代表这个文件吧?FILE*就是干这个的。它是一个结构体指针,里面装着文件的各种状态信息:当前读写位置、缓冲区状态、错误标志等等。
我个人习惯把FILE*想象成一个遥控器——你拿着它,就能控制文件的读写。但注意,它不直接指向文件内容,而是指向一个管理结构。
核心理解:
FILE*是一个指针,指向FILE结构体- 这个结构体由系统维护,你不需要关心它的内部细节
- 你只需要通过这个指针调用各种文件操作函数
// 声明一个文件指针
FILE *fp;
// 打开文件,fp指向这个文件的管理结构
fp = fopen("data.bin", "rb");
if (fp == NULL) {
// 打开失败,处理错误
perror("文件打开失败");
return -1;
}
嗯,这里要注意:fopen返回的FILE*绝对不能随便修改。我见过有人试图对fp做fp++操作,结果程序直接崩溃。这个指针是系统给你的“凭证”,不是让你随便玩的。
16.2 fopen/fclose——开门和关门
fopen和fclose就像一对搭档,一个负责开门,一个负责关门。开门时要告诉系统你要怎么用这个文件——读?写?追加?二进制还是文本?
| 模式 | 含义 | 文件不存在时 |
|---|---|---|
| "r" | 只读(文本) | 返回NULL |
| "w" | 只写(文本) | 创建新文件 |
| "a" | 追加(文本) | 创建新文件 |
| "rb" | 只读(二进制) | 返回NULL |
| "wb" | 只写(二进制) | 创建新文件 |
| "ab" | 追加(二进制) | 创建新文件 |
我曾经踩过的坑:忘记调用fclose。在嵌入式系统里,文件描述符是有限资源。你打开一个文件不关,系统不会自动帮你回收。有一次我在一个循环里反复打开文件却不关闭,结果跑了几个小时程序就报“Too many open files”。从那以后,我写fopen的同时就会把fclose写好,形成肌肉记忆。
FILE *fp = fopen("config.bin", "rb");
if (fp == NULL) {
// 错误处理
return;
}
// ... 读写操作 ...
fclose(fp); // 别忘了!
16.3 fread/fwrite——二进制数据的搬运工
说到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);
注意第一个参数——void*。这意味着你可以传任何类型的指针进去。我个人觉得这是C语言最优雅的设计之一:用一个通用指针,就能读写任意类型的数据。
实用技巧:用fread读取结构体时,一定要确保结构体没有对齐问题。我在项目中遇到过结构体里有#pragma pack的情况,读出来的数据全是乱的。建议读写二进制文件时,明确指定结构体的对齐方式。
// 定义一个结构体
typedef struct {
int id;
char name[32];
float score;
} Student;
// 写入一个学生数据
Student stu = {1001, "张三", 95.5f};
fwrite(&stu, sizeof(Student), 1, fp);
// 读取一个学生数据
Student read_stu;
fread(&read_stu, sizeof(Student), 1, fp);
这里有个细节:fread返回的是实际读取的元素个数,不是字节数。如果返回值小于count,说明可能读到文件末尾了,或者发生了错误。我建议每次都检查返回值:
size_t ret = fread(buffer, sizeof(int), 10, fp);
if (ret != 10) {
if (feof(fp)) {
printf("读到文件末尾了\n");
} else if (ferror(fp)) {
printf("读取出错\n");
}
}
16.4 文件读写中的指针偏移——fseek/ftell
文件内部其实也有一个“指针”,叫文件位置指针。它指向当前要读写的位置。每次fread或fwrite之后,这个指针会自动向后移动。
但有时候,你想跳到文件的某个特定位置去读写。这时候就需要fseek和ftell了。
// fseek 原型
int fseek(FILE *stream, long offset, int whence);
// whence 取值:
// SEEK_SET - 从文件开头算起
// SEEK_CUR - 从当前位置算起
// SEEK_END - 从文件末尾算起
实际应用场景:我在做嵌入式日志系统时,需要快速定位到日志文件的最后一条记录。如果用fread从头读到尾,效率太低了。我的做法是:
- 用
fseek(fp, -sizeof(LogEntry), SEEK_END)跳到倒数第一条记录的位置 - 用
fread读取这条记录 - 用
ftell获取当前位置,方便后续操作
// 获取文件大小
fseek(fp, 0, SEEK_END);
long file_size = ftell(fp);
// 跳回文件开头
fseek(fp, 0, SEEK_SET);
// 跳过前10个int
fseek(fp, 10 * sizeof(int), SEEK_SET);
// 从当前位置后退5个字节
fseek(fp, -5, SEEK_CUR);
我曾经犯过的错:在文本模式下使用fseek。Windows系统下,文本模式会把\n转换成\r\n,导致文件位置指针的计算出现偏差。二进制模式下就没有这个问题。所以,如果你要用fseek做精确定位,一定要用二进制模式打开文件。
16.5 知识体系总览
下面这张图把文件指针操作的核心脉络梳理清楚了。你看一遍,应该就能建立起整体认知。
16.6 综合示例:读取配置文件
最后,我写一个实际项目中常用的例子——读取一个二进制配置文件。这个文件里存着多个结构体,我们需要按索引读取指定的那个。
#include <stdio.h>
#include <stdlib.h>
typedef struct {
int key;
int value;
} ConfigItem;
ConfigItem* read_config(const char* filename, int index) {
FILE *fp = fopen(filename, "rb");
if (fp == NULL) {
perror("打开文件失败");
return NULL;
}
// 计算偏移量:跳到第index个元素的位置
long offset = index * sizeof(ConfigItem);
if (fseek(fp, offset, SEEK_SET) != 0) {
perror("定位失败");
fclose(fp);
return NULL;
}
// 分配内存并读取
ConfigItem *item = (ConfigItem*)malloc(sizeof(ConfigItem));
if (item == NULL) {
fclose(fp);
return NULL;
}
size_t ret = fread(item, sizeof(ConfigItem), 1, fp);
if (ret != 1) {
free(item);
fclose(fp);
return NULL;
}
fclose(fp);
return item;
}
int main() {
ConfigItem *item = read_config("config.bin", 5);
if (item) {
printf("key=%d, value=%d\n", item->key, item->value);
free(item);
}
return 0;
}
这个例子把fopen、fseek、fread、fclose串在了一起。你想想看,如果没有指针偏移,你要读取第5个元素,就得把前面4个都读一遍,多浪费啊。
我的个人习惯:每次写完文件操作代码,我都会检查三件事:
- 文件打开成功了吗?(检查返回值)
- 文件关闭了吗?(配对fclose)
- 读写的数据量对吗?(检查返回值)
这三条检查,帮我避免过无数次线上事故。
文件指针操作,说白了就是管理好三个东西:文件指针的生命周期、数据的搬运、位置的偏移。把这三点吃透了,文件操作这块你就拿下了。
公众号:蓝海资料掘金营,微信deep3321