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 知识体系总览

下面这张图把本章的核心知识点串起来了。你看一遍,应该能对文件指针的操作有个整体认识。

文件指针 FILE* 操作体系 FILE* 文件指针 fopen / fclose fread / fwrite fseek / ftell 模式选择:r/w/a/rb/wb 错误处理:NULL 检查 缓冲区指针:void* ptr 返回值检查:实际读写个数 SEEK_SET/CUR/END ftell 获取当前位置 核心原则:先打开 → 再读写/定位 → 最后关闭 每一步都要做错误检查,关闭后指针置空

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