← 返回文章列表

C 语言(十二)|文件操作:从基础读写到随机访问与缓冲区原理

本文系统讲解C语言文件操作全流程,从数据持久化的必要性出发,详解二进制与文本文件的区别,以及fopen/fclose的正确用法与12种打开模式。重点介绍字符、字符串、格式化、数据块四类顺序读写函数,对比易混淆的scanf/fscanf/sscanf系列。同时深入解析随机读写、文件缓冲区原理,纠正feof误用等常见误区,最后总结文件更新模式的差异与最佳实践,帮你避开坑点,掌握数据持久化核心技能。

在C语言程序开发中,数据的持久化存储是不可或缺的核心能力。程序运行时产生的所有数据都存储在内存中,一旦程序退出或系统断电,这些数据就会永久丢失。而文件操作正是解决这一问题的关键,它允许我们将数据写入磁盘文件,在需要时再读取回来,实现数据的长期保存和跨程序共享。本文将系统讲解C语言文件操作的全部核心知识,从文件的基本概念到高级的随机读写,再到容易被忽略的文件缓冲区原理,帮你全面掌握这一基础且重要的技能。

一、为什么需要文件操作?

我们之前编写的所有程序,数据都是存储在内存中的:

  • 变量、数组、结构体等数据结构都在栈或堆上分配空间
  • 程序运行时可以读写这些数据,但程序结束后,操作系统会回收所有内存资源
  • 再次运行程序时,无法获取上一次运行产生的任何数据

这种临时性的存储方式无法满足很多实际需求,比如:

  • 保存用户的配置信息和程序状态
  • 存储大量的业务数据(如学生信息、商品库存)
  • 实现程序之间的数据交换
  • 记录程序运行日志以便排查问题

文件操作的出现完美解决了这些问题,它将数据存储在磁盘等持久化存储介质上,即使程序退出或计算机重启,数据依然存在。

二、文件的基本概念

2.1 文件的分类

从程序设计的角度,我们通常将文件分为两大类:

  1. 程序文件:存储程序代码的文件,包括源程序文件(.c)、目标文件(Windows下.obj)、可执行程序(Windows下.exe)等
  2. 数据文件:存储程序运行时读写的数据的文件,这也是本章讨论的重点

2.2 文件名

每个文件都有一个唯一的文件标识,也就是我们常说的文件名,它由三部分组成:

文件路径 + 文件名主干 + 文件后缀

例如:C:\Luminous\code\student.txt

  • 文件路径:C:\Luminous\code\,指定文件在磁盘上的位置
  • 文件名主干:student,用于区分不同的文件
  • 文件后缀:.txt,标识文件的类型

三、二进制文件与文本文件

根据数据在磁盘上的存储方式,数据文件可以分为文本文件和二进制文件两种。

3.1 核心区别

  • 文本文件:数据以ASCII码的形式存储,每个字符占用一个字节,文件内容可以直接用文本编辑器打开查看
  • 二进制文件:数据直接以内存中的二进制形式存储,不进行任何转换,文件内容无法直接用文本编辑器查看

3.2 存储示例

以整数10000为例:

  • 文本文件存储:占用5个字节,分别存储字符'1'、'0'、'0'、'0'、'0'的ASCII码
  • 二进制文件存储:占用4个字节,直接存储整数 10000 的二进制表示00000000 00000000 00100111 00010000

二进制文件写入示例:

#include <stdio.h>

int main()
{
    int a = 10000;
    FILE* Luminous = fopen("test.bin", "wb");
    fwrite(&a, 4, 1, Luminous); // 以二进制形式写入文件
    fclose(Luminous);
    Luminous = NULL;

    return 0;
}

运行后生成的test.bin文件用文本编辑器打开会显示乱码,但用二进制编辑器可以看到正确的十六进制值10 27 00 00(小端存储)。

四、文件的打开与关闭

C 语言中所有的文件操作都遵循"先打开,后操作,最后关闭"的流程。

4.1 流与标准流

为了统一对不同外部设备(键盘、显示器、磁盘、打印机等)的操作,C语言抽象出了流的概念。我们可以把流想象成一个数据通道,数据通过流在程序和外部设备之间传输。

C语言程序在启动时会自动打开三个标准流:

  • stdin:标准输入流,默认关联键盘,scanf函数从该流读取数据
  • stdout:标准输出流,默认关联显示器,printf函数向该流写入数据
  • stderr:标准错误流,默认关联显示器,用于输出错误信息

这三个流的类型都是FILE*,也就是文件指针。

4.2 文件指针

每个被打开的文件,系统都会在内存中创建一个对应的文件信息区,用于存储文件的名称、状态、当前读写位置等信息。这个文件信息区是一个结构体,类型名为FILE。

我们通过一个FILE*类型的指针来维护这个文件信息区,进而操作对应的文件:

FILE* pf; // 定义一个文件指针变量

4.3 fopen:打开文件

函数原型:

FILE* fopen(const char* filename, const char* mode);
  • filename:要打开的文件名,可以是相对路径或绝对路径
  • mode:文件的打开方式,决定了我们可以对文件进行哪些操作
  • 返回值:打开成功返回指向文件信息区的指针,失败返回NULL

必须检查返回值:文件打开可能会因为各种原因失败(如文件不存在、权限不足、磁盘已满等),如果直接对NULL指针进行操作,会导致程序崩溃。

4.4 文件打开模式大全

模式含义文件不存在时文件存在时读写位置
"r"只读文本文件出错保留原有内容文件开头
"w"只写文本文件创建新文件清空原有内容文件开头
"a"追加文本文件创建新文件保留原有内容文件末尾
"rb"只读二进制文件出错保留原有内容文件开头
"wb"只写二进制文件创建新文件清空原有内容文件开头
"ab"追加二进制文件创建新文件保留原有内容文件末尾
"r+"读写文本文件出错保留原有内容文件开头
"w+"读写文本文件创建新文件清空原有内容文件开头
"a+"读写文本文件创建新文件保留原有内容文件末尾
"rb+"读写二进制文件出错保留原有内容文件开头
"wb+"读写二进制文件创建新文件清空原有内容文件开头
"ab+"读写二进制文件创建新文件保留原有内容文件末尾

4.5 fclose:关闭文件

函数原型:

int fclose(FILE* stream);
  • stream:指向要关闭的文件的指针
  • 返回值:关闭成功返回0,失败返回EOF

重要注意事项:

  1. 文件使用完毕后必须关闭,否则会导致资源泄漏,同时可能造成数据丢失
  2. 关闭文件后,原文件指针会变成野指针,必须手动置为NULL

完整的文件打开与关闭示例:

#include <stdio.h>
#include <stdlib.h>

int main()
{
    FILE* fp = fopen("test.txt", "r");
    if (fp == NULL)
    {
        perror("fopen failed"); // 打印详细的错误信息
        return 1;
    }

    printf("文件打开成功\n");

    // 这里进行文件读写操作

    fclose(fp); // 关闭文件
    fp = NULL; // 置空指针

    return 0;
}

五、文件的顺序读写

顺序读写是指按照文件内容的先后顺序依次进行读写操作,这是最常用的文件操作方式。C 语言提供了多组函数用于不同类型数据的顺序读写。

5.1 字符读写:fgetc和fputc

fputc:写入一个字符

函数原型:

int fputc(int character, FILE* stream);
  • 功能:将一个字符写入指定的输出流
  • 返回值:成功返回写入的字符,失败返回EOF

示例:写入26个英文字母到文件

#include <stdio.h>

int main()
{
    FILE* fp = fopen("char.txt", "w");
    if (fp == NULL)
    {
        perror("fopen");
        return 1;
    }

    for (char ch = 'a'; ch <= 'z'; ch++)
    {
        fputc(ch, fp);
    }

    fclose(fp);
    fp = NULL;
    return 0;
}
fgetc:读取一个字符

函数原型:

int fgetc(FILE* stream);
  • 功能:从指定的输入流读取一个字符
  • 返回值:成功返回读取的字符,到达文件末尾或发生错误返回EOF

示例:读取文件中的所有字符并打印

#include <stdio.h>

int main()
{
    FILE* fp = fopen("char.txt", "r");
    if (fp == NULL)
    {
        perror("fopen");
        return 1;
    }

    int ch;
    while ((ch = fgetc(fp)) != EOF)
    {
        putchar(ch);
    }

    fclose(fp);
    fp = NULL;
    return 0;
}

5.2 字符串读写:fgets和fputs

fputs:写入一个字符串

函数原型:

int fputs(const char* str, FILE* stream);
  • 功能:将一个字符串写入指定的输出流(不包含结尾的'\0')
  • 返回值:成功返回非负整数,失败返回EOF

示例:

fputs("Hello, World!\n", fp);
fputs("C语言文件操作\n", fp);
fgets:读取一个字符串

函数原型:

char* fgets(char* str, int num, FILE* stream);
  • 功能:从指定的输入流读取字符串,最多读取num-1个字符,遇到换行符或文件末尾停止
  • 特点:会将读取到的换行符'\n'也存入字符串中,并在末尾自动添加'\0'
  • 返回值:成功返回str,到达文件末尾或发生错误返回NULL

示例:

char buf[1024];
while (fgets(buf, sizeof(buf), fp) != NULL)
{
    printf("%s", buf);
}

5.3 格式化读写:fscanf和fprintf

这两个函数与我们熟悉的scanf和printf用法几乎完全相同,只是多了一个文件指针参数,可以对任意文件流进行格式化读写。

fprintf:格式化写入

函数原型:

int fprintf(FILE* stream, const char* format, ...);

示例:将结构体数据写入文件

#include <stdio.h>

struct Student
{
    char name[20];
    int age;
    float score;
};

int main()
{
    struct Student s = {"张三", 20, 95.5f};
    FILE* fp = fopen("student.txt", "w");
    if (fp == NULL)
    {
        perror("fopen");
        return 1;
    }

    fprintf(fp, "%s %d %.1f", s.name, s.age, s.score);

    fclose(fp);
    fp = NULL;
    return 0;
}
fscanf:格式化读取

函数原型:

int fscanf(FILE* stream, const char* format, ...);

示例:从文件中读取结构体数据

#include <stdio.h>

struct Student
{
    char name[20];
    int age;
    float score;
};

int main()
{
    struct Student s = {0};
    FILE* fp = fopen("student.txt", "r");
    if (fp == NULL)
    {
        perror("fopen");
        return 1;
    }

    fscanf(fp, "%s %d %f", s.name, &s.age, &s.score);
    printf("姓名:%s,年龄:%d,成绩:%.1f\n", s.name, s.age, s.score);

    fclose(fp);
    fp = NULL;
    return 0;
}

5.4 数据块读写:fread和fwrite

这两个函数用于以二进制形式读写整块数据,适合读写数组、结构体等复杂数据类型,效率比格式化读写高。

fwrite:二进制写入

函数原型:

size_t fwrite(const void* ptr, size_t size, size_t count, FILE* stream);
  • ptr:指向要写入的数据的指针
  • size:每个数据项的大小(字节)
  • count:要写入的数据项的数量
  • 返回值:实际成功写入的数据项数量

示例:写入一个整型数组到二进制文件

#include <stdio.h>

int main()
{
    int arr[] = {1, 2, 3, 4, 5};
    FILE* fp = fopen("array.bin", "wb");
    if (fp == NULL)
    {
        perror("fopen");
        return 1;
    }

    fwrite(arr, sizeof(int), 5, fp);

    fclose(fp);
    fp = NULL;
    return 0;
}
fread:二进制读取

函数原型:

size_t fread(void* ptr, size_t size, size_t count, FILE* stream);
  • 返回值:实际成功读取的数据项数量

示例:从二进制文件读取整型数组

#include <stdio.h>

int main()
{
    int arr[5] = {0};
    FILE* fp = fopen("array.bin", "rb");
    if (fp == NULL)
    {
        perror("fopen");
        return 1;
    }

    size_t n = fread(arr, sizeof(int), 5, fp);
    printf("成功读取%d个整数\n", n);
    for (int i = 0; i < n; i++)
    {
        printf("%d ", arr[i]);
    }

    fclose(fp);
    fp = NULL;
    return 0;
}

5.5 重要函数对比

C语言中有三组非常相似的格式化输入输出函数,很多初学者容易混淆,这里做一个清晰的对比:

函数输入 / 输出源用途
scanf标准输入流 (stdin)从键盘读取格式化数据
printf标准输出流 (stdout)向屏幕输出格式化数据
fscanf任意输入流从文件或键盘读取格式化数据
fprintf任意输出流向文件或屏幕输出格式化数据
sscanf字符串从内存中的字符串解析格式化数据
sprintf字符串将格式化数据写入内存中的字符串

sprintf和sscanf示例:

#include <stdio.h>

struct Student
{
    char name[20];
    int age;
    float score;
};

int main()
{
    struct Student s1 = {"李四", 21, 88.0f};
    char buf[100] = {0};

    // 将结构体数据格式化为字符串
    sprintf(buf, "%s %d %.1f", s1.name, s1.age, s1.score);
    printf("格式化后的字符串:%s\n", buf);

    // 从字符串中解析出结构体数据
    struct Student s2 = {0};
    sscanf(buf, "%s %d %f", s2.name, &s2.age, &s2.score);
    printf("解析结果:%s %d %.1f\n", s2.name, s2.age, s2.score);

    return 0;
}

六、文件的随机读写

顺序读写只能从文件开头或末尾开始依次操作,而随机读写允许我们在文件的任意位置进行读写,大大提高了文件操作的灵活性。

6.1 fseek:定位文件指针

函数原型:

int fseek(FILE* stream, long int offset, int origin);
  • offset:偏移量,正数表示向后偏移,负数表示向前偏移
  • origin:起始位置,有三个可选值:SEEK_SET:文件开头SEEK_CUR:文件指针当前位置SEEK_END:文件末尾

示例:修改文件中的指定内容

#include <stdio.h>

int main()
{
    FILE* fp = fopen("example.txt", "w+");
    if (fp == NULL)
    {
        perror("fopen");
        return 1;
    }

    fputs("This is an apple.", fp);
    fseek(fp, 9, SEEK_SET); // 从文件开头向后偏移9个字节
    fputs(" sam", fp); // 写入内容覆盖原有数据

    fclose(fp);
    fp = NULL;
    return 0;
}

运行后文件内容变为:This is a sample.

6.2 ftell:获取当前偏移量

函数原型:

long int ftell(FILE* stream);
  • 功能:返回文件指针相对于文件开头的当前偏移量
  • 常用用途:获取文件的大小

示例:获取文件大小

#include <stdio.h>

int main()
{
    FILE* fp = fopen("example.txt", "rb");
    if (fp == NULL)
    {
        perror("fopen");
        return 1;
    }

    fseek(fp, 0, SEEK_END); // 将文件指针移到文件末尾
    long size = ftell(fp); // 获取当前偏移量,即文件大小
    printf("文件大小:%ld字节\n", size);

    fclose(fp);
    fp = NULL;
    return 0;
}

6.3 rewind:回到文件开头

函数原型:

void rewind(FILE* stream);
  • 功能:将文件指针重新定位到文件的起始位置
  • 等价于:fseek(stream,0,SEEK_SET);

示例:先写后读同一个文件

#include <stdio.h>

int main()
{
    FILE* fp = fopen("test.txt", "w+");
    if (fp == NULL)
    {
        perror("fopen");
        return 1;
    }

    // 写入数据
    for (char ch = 'A'; ch <= 'Z'; ch++)
    {
        fputc(ch, fp);
    }

    rewind(fp); // 回到文件开头

    // 读取数据
    char buf[27] = {0};
    fread(buf, 1, 26, fp);
    printf("%s\n", buf);

    fclose(fp);
    fp = NULL;
    return 0;
}

七、文件读取结束的正确判定

很多初学者会错误地使用feof函数来判断文件是否读取结束,这是一个非常常见的误区。

7.1 feof和ferror的正确用法

  • feof(FILE* stream):检测流是否到达文件末尾,到达返回非0值,否则返回0
  • ferror(FILE* stream):检测流是否发生读写错误,发生错误返回非0值,否则返回0

核心原则:feof函数是在读取函数返回 EOF 之后,用来判断是因为到达文件末尾而结束,还是因为发生错误而结束。不能用feof直接作为循环条件来读取文件。

错误写法:

// 错误!feof会在文件最后一个字符读取后才返回真,导致多读取一次
while (!feof(fp))
{
    int ch = fgetc(fp);
    putchar(ch);
}

正确写法:

int ch;
while ((ch = fgetc(fp)) != EOF)
{
    putchar(ch);
}

// 读取结束后,判断是正常结束还是错误结束
if (feof(fp))
{
    printf("文件读取正常结束\n");
}
else if (ferror(fp))
{
    printf("文件读取发生错误\n");
}

八、文件缓冲区原理

很多人可能会遇到这样的情况:向文件写入数据后,立即打开文件却发现没有内容,这是因为 C 语言采用了缓冲文件系统。

8.1 什么是文件缓冲区

ANSI C标准规定,系统会自动在内存中为每个正在使用的文件开辟一块缓冲区:

  • 输出缓冲区:程序向文件写入数据时,先将数据写入输出缓冲区,缓冲区满了之后才会一次性写入磁盘
  • 输入缓冲区:程序从文件读取数据时,先将磁盘上的数据读入输入缓冲区,然后再从缓冲区逐个读取数据到程序变量

缓冲区的存在大大提高了文件操作的效率,因为磁盘的读写速度比内存慢得多,频繁的磁盘读写会严重影响程序性能。

8.2 fflush:强制刷新缓冲区

函数原型:

int fflush(FILE* stream);
  • 功能:强制将输出缓冲区中的数据立即写入磁盘
  • 参数为NULL时,会刷新所有打开的输出流

缓冲区演示示例:

#include <stdio.h>
#include <unistd.h> // 用于sleep函数

int main()
{
    FILE* fp = fopen("buffer.txt", "w");
    if (fp == NULL)
    {
        perror("fopen");
        return 1;
    }

    fputs("Hello, Buffer!", fp); // 数据先写入缓冲区
    printf("数据已写入缓冲区,现在打开文件看不到内容\n");
    sleep(5); // 睡眠5秒

    fflush(fp); // 强制刷新缓冲区,数据写入磁盘
    printf("缓冲区已刷新,现在打开文件可以看到内容了\n");
    sleep(5);

    fclose(fp); // 关闭文件时也会自动刷新缓冲区
    fp = NULL;
    return 0;
}

8.3 重要结论

因为缓冲区的存在,我们在操作文件时必须注意:

  1. 文件操作结束后一定要调用fclose关闭文件,它会自动刷新缓冲区
  2. 如果需要立即将数据写入磁盘,可以调用fflush强制刷新
  3. 程序崩溃时,缓冲区中的数据会丢失,无法写入磁盘

九、文件更新模式详解

在实际开发中,我们经常需要同时读写同一个文件,这时候就需要使用r+、w+、a+这三种更新模式。它们的行为有很大区别,使用时一定要注意:

模式文件不存在文件存在初始指针位置写入特点典型用途
"r+"打开失败保留内容文件开头可覆盖原有数据修改文件部分内容
"w+"创建新文件清空内容文件开头从头写入创建新文件或完全重写
"a+"创建新文件保留内容文件末尾只能在末尾追加日志记录

使用更新模式的注意事项:

  1. 写完文件后要继续读文件,必须先调用fflush刷新缓冲区,或者用fseek/rewind重新定位文件指针
  2. 读完文件后要继续写文件,必须用fseek/rewind重新定位文件指针

十、总结

文件操作是C语言程序开发中不可或缺的技能,它让我们能够实现数据的持久化存储。本文我们系统学习了:

  1. 文件操作的必要性:解决内存数据临时性的问题
  2. 文件的基本概念:分类、文件名、二进制文件与文本文件的区别
  3. 文件的打开与关闭:fopen和fclose的用法,各种打开模式的区别
  4. 文件的顺序读写:字符、字符串、格式化、数据块四种读写方式
  5. 文件的随机读写:fseek、ftell、rewind三个核心函数
  6. 文件读取结束的正确判定:feof和ferror的正确用法
  7. 文件缓冲区原理:理解缓冲区的作用和刷新时机
  8. 文件更新模式:r+、w+、a+的区别和使用注意事项

文件操作的核心原则:

  • 打开文件必须检查返回值
  • 读写操作要符合文件的打开模式
  • 文件使用完毕必须及时关闭
  • 关闭文件后立即将指针置为NULL
  • 不要用feof直接作为循环条件读取文件

掌握了这些知识,你就能够熟练地进行各种文件操作,写出稳定、高效的C语言程序了。