在 C 语言程序开发中,数据的持久化存储是不可或缺的核心能力。程序运行时产生的所有数据都存储在内存中,一旦程序退出或系统断电,这些数据就会永久丢失。而文件操作正是解决这一问题的关键,它允许我们将数据写入磁盘文件,在需要时再读取回来,实现数据的长期保存和跨程序共享。本文将系统讲解 C 语言文件操作的全部核心知识,从文件的基本概念到高级的随机读写,再到容易被忽略的文件缓冲区原理,帮你全面掌握这一基础且重要的技能。
一、为什么需要文件操作?
我们之前编写的所有程序,数据都是存储在内存中的:
- 变量、数组、结构体等数据结构都在栈或堆上分配空间
- 程序运行时可以读写这些数据,但程序结束后,操作系统会回收所有内存资源
- 再次运行程序时,无法获取上一次运行产生的任何数据
这种临时性的存储方式无法满足很多实际需求,比如:
- 保存用户的配置信息和程序状态
- 存储大量的业务数据(如学生信息、商品库存)
- 实现程序之间的数据交换
- 记录程序运行日志以便排查问题
文件操作的出现完美解决了这些问题,它将数据存储在磁盘等持久化存储介质上,即使程序退出或计算机重启,数据依然存在。
二、文件的基本概念
2.1 文件的分类
从程序设计的角度,我们通常将文件分为两大类:
- 程序文件 :存储程序代码的文件,包括源程序文件(
.c)、目标文件(Windows 下.obj)、可执行程序(Windows 下.exe)等 - 数据文件:存储程序运行时读写的数据的文件,这也是本章讨论的重点
2.2 文件名
每个文件都有一个唯一的文件标识,也就是我们常说的文件名,它由三部分组成:
cpp
文件路径 + 文件名主干 + 文件后缀
例如: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
二进制文件写入示例:
cpp
#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*类型的指针来维护这个文件信息区,进而操作对应的文件:
cpp
FILE* pf; // 定义一个文件指针变量
4.3 fopen:打开文件
函数原型:
cpp
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:关闭文件
函数原型:
cpp
int fclose(FILE* stream);
stream:指向要关闭的文件的指针- 返回值:关闭成功返回 0,失败返回
EOF
重要注意事项:
- 文件使用完毕后必须关闭,否则会导致资源泄漏,同时可能造成数据丢失
- 关闭文件后,原文件指针会变成野指针,必须手动置为
NULL
完整的文件打开与关闭示例:
cpp
#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:写入一个字符
函数原型:
cpp
int fputc(int character, FILE* stream);
- 功能:将一个字符写入指定的输出流
- 返回值:成功返回写入的字符,失败返回
EOF
示例:写入 26 个英文字母到文件
cpp
#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:读取一个字符
函数原型:
cpp
int fgetc(FILE* stream);
- 功能:从指定的输入流读取一个字符
- 返回值:成功返回读取的字符,到达文件末尾或发生错误返回
EOF
示例:读取文件中的所有字符并打印
cpp
#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:写入一个字符串
函数原型:
cpp
int fputs(const char* str, FILE* stream);
- 功能:将一个字符串写入指定的输出流(不包含结尾的 '\0')
- 返回值:成功返回非负整数,失败返回
EOF
示例:
cpp
fputs("Hello, World!\n", fp);
fputs("C语言文件操作\n", fp);
fgets:读取一个字符串
函数原型:
cpp
char* fgets(char* str, int num, FILE* stream);
- 功能:从指定的输入流读取字符串,最多读取
num-1个字符,遇到换行符或文件末尾停止 - 特点:会将读取到的换行符 '\n' 也存入字符串中,并在末尾自动添加 '\0'
- 返回值:成功返回
str,到达文件末尾或发生错误返回NULL
示例:
cpp
char buf[1024];
while (fgets(buf, sizeof(buf), fp) != NULL)
{
printf("%s", buf);
}
5.3 格式化读写:fscanf 和 fprintf
这两个函数与我们熟悉的scanf和printf用法几乎完全相同,只是多了一个文件指针参数,可以对任意文件流进行格式化读写。
fprintf:格式化写入
函数原型:
cpp
int fprintf(FILE* stream, const char* format, ...);
示例:将结构体数据写入文件
cpp
#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:格式化读取
函数原型:
cpp
int fscanf(FILE* stream, const char* format, ...);
示例:从文件中读取结构体数据
cpp
#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:二进制写入
函数原型:
cpp
size_t fwrite(const void* ptr, size_t size, size_t count, FILE* stream);
ptr:指向要写入的数据的指针size:每个数据项的大小(字节)count:要写入的数据项的数量- 返回值:实际成功写入的数据项数量
示例:写入一个整型数组到二进制文件
cpp
#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:二进制读取
函数原型:
cpp
size_t fread(void* ptr, size_t size, size_t count, FILE* stream);
- 返回值:实际成功读取的数据项数量
示例:从二进制文件读取整型数组
cpp
#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 示例:
cpp
#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:定位文件指针
函数原型:
cpp
int fseek(FILE* stream, long int offset, int origin);
offset:偏移量,正数表示向后偏移,负数表示向前偏移origin:起始位置,有三个可选值:SEEK_SET:文件开头SEEK_CUR:文件指针当前位置SEEK_END:文件末尾
示例:修改文件中的指定内容
cpp
#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:获取当前偏移量
函数原型:
cpp
long int ftell(FILE* stream);
- 功能:返回文件指针相对于文件开头的当前偏移量
- 常用用途:获取文件的大小
示例:获取文件大小
cpp
#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:回到文件开头
函数原型:
cpp
void rewind(FILE* stream);
- 功能:将文件指针重新定位到文件的起始位置
- 等价于:
fseek(stream, 0, SEEK_SET);
示例:先写后读同一个文件
cpp
#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 值,否则返回 0ferror(FILE* stream):检测流是否发生读写错误,发生错误返回非 0 值,否则返回 0
核心原则 :feof函数是在读取函数返回 EOF 之后 ,用来判断是因为到达文件末尾而结束,还是因为发生错误而结束。不能用feof直接作为循环条件来读取文件。
错误写法:
cpp
// 错误!feof会在文件最后一个字符读取后才返回真,导致多读取一次
while (!feof(fp))
{
int ch = fgetc(fp);
putchar(ch);
}
正确写法:
cpp
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:强制刷新缓冲区
函数原型:
cpp
int fflush(FILE* stream);
- 功能:强制将输出缓冲区中的数据立即写入磁盘
- 参数为
NULL时,会刷新所有打开的输出流
缓冲区演示示例:
cpp
#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 重要结论
因为缓冲区的存在,我们在操作文件时必须注意:
- 文件操作结束后一定要调用
fclose关闭文件,它会自动刷新缓冲区 - 如果需要立即将数据写入磁盘,可以调用
fflush强制刷新 - 程序崩溃时,缓冲区中的数据会丢失,无法写入磁盘
九、文件更新模式详解
在实际开发中,我们经常需要同时读写同一个文件,这时候就需要使用r+、w+、a+这三种更新模式。它们的行为有很大区别,使用时一定要注意:
| 模式 | 文件不存在 | 文件存在 | 初始指针位置 | 写入特点 | 典型用途 |
|---|---|---|---|---|---|
| "r+" | 打开失败 | 保留内容 | 文件开头 | 可覆盖原有数据 | 修改文件部分内容 |
| "w+" | 创建新文件 | 清空内容 | 文件开头 | 从头写入 | 创建新文件或完全重写 |
| "a+" | 创建新文件 | 保留内容 | 文件末尾 | 只能在末尾追加 | 日志记录 |
使用更新模式的注意事项:
- 写完文件后要继续读文件,必须先调用
fflush刷新缓冲区,或者用fseek/rewind重新定位文件指针 - 读完文件后要继续写文件,必须用
fseek/rewind重新定位文件指针
十、总结
文件操作是 C 语言程序开发中不可或缺的技能,它让我们能够实现数据的持久化存储。本文我们系统学习了:
- 文件操作的必要性:解决内存数据临时性的问题
- 文件的基本概念:分类、文件名、二进制文件与文本文件的区别
- 文件的打开与关闭:
fopen和fclose的用法,各种打开模式的区别 - 文件的顺序读写:字符、字符串、格式化、数据块四种读写方式
- 文件的随机读写:
fseek、ftell、rewind三个核心函数 - 文件读取结束的正确判定:
feof和ferror的正确用法 - 文件缓冲区原理:理解缓冲区的作用和刷新时机
- 文件更新模式:
r+、w+、a+的区别和使用注意事项
文件操作的核心原则:
- 打开文件必须检查返回值
- 读写操作要符合文件的打开模式
- 文件使用完毕必须及时关闭
- 关闭文件后立即将指针置为 NULL
- 不要用
feof直接作为循环条件读取文件
掌握了这些知识,你就能够熟练地进行各种文件操作,写出稳定、高效的 C 语言程序了。