C 语言文件操作:让数据从内存走向持久化
程序运行时,变量和数组通常保存在内存中。内存访问速度快,但它的内容会随着进程结束而消失。一个通讯录、配置文件或日志程序,如果只把数据放在内存里,关闭程序后所有修改都会丢失。要让数据在下次运行时仍然存在,就需要把它保存到磁盘文件,或者保存到数据库。
文件操作正是 C 语言连接程序和外部存储的基础。程序可以打开文件、读取和写入数据、移动读写位置,并在结束时关闭文件。掌握这些操作后,就能把一次性的内存数据变成可以长期保存、重复读取和持续更新的文件数据。
一、程序文件与数据文件
从用途看,文件大致可以分为两类:
- 程序文件 :保存程序本身,例如
.c源文件、编译产生的目标文件,以及可执行文件。 - 数据文件:保存程序运行过程中需要读取或产生的数据,例如用户信息、成绩记录、配置项和日志。
文件名通常由三部分组成:路径、主文件名和后缀。例如:
text
C:\data\contacts.txt
路径决定文件所在位置,主文件名用于区分内容,后缀则常常提示文件的组织形式或用途。实际开发中,建议明确使用绝对路径还是相对路径。相对路径是相对于程序启动时的当前工作目录而言的,并不一定是源代码所在目录。
二、文件为什么需要打开和关闭
C 语言通过 FILE 类型描述一个正在使用的文件。FILE 是标准库内部定义的结构类型,保存文件状态、缓冲区信息以及当前读写位置等细节。程序一般不直接操作这个结构,而是使用 FILE * 指针作为文件句柄:
c
#include <stdio.h>
FILE *fp;
打开文件使用 fopen:
c
FILE *fopen(const char *filename, const char *mode);
关闭文件使用 fclose:
c
int fclose(FILE *stream);
打开可能失败,因此必须检查返回值。失败时 fopen 返回 NULL,可以使用 perror 输出更具体的系统错误:
c
FILE *fp = fopen("data.txt", "r");
if (fp == NULL) {
perror("data.txt");
return 1;
}
/* 在这里读写 fp */
if (fclose(fp) != 0) {
perror("fclose");
}
fp = NULL;
关闭文件不仅是释放句柄,也通常会刷新输出缓冲区。只要文件已经成功打开,就应该保证每一条执行路径最终都会调用 fclose。
三、fopen 的模式选择
第二个参数决定文件的访问方式。模式中的 b 表示二进制方式,没有 b 时通常按文本方式处理。
| 模式 | 用途 | 文件不存在时 |
|---|---|---|
r |
只读打开文本文件 | 打开失败 |
w |
只写打开文本文件 | 创建新文件;原内容会被清空 |
a |
只写并追加到文本文件末尾 | 创建新文件 |
rb |
只读打开二进制文件 | 打开失败 |
wb |
只写打开二进制文件 | 创建新文件;原内容会被清空 |
ab |
只写并追加到二进制文件末尾 | 创建新文件 |
r+ |
读写已有文本文件 | 打开失败 |
w+ |
读写文本文件 | 创建新文件;原内容会被清空 |
a+ |
读写文本文件,写入位置在末尾 | 创建新文件 |
rb+ |
读写已有二进制文件 | 打开失败 |
wb+ |
读写二进制文件 | 创建新文件;原内容会被清空 |
ab+ |
读写二进制文件,写入位置在末尾 | 创建新文件 |
选择模式时要特别留意 w 和 w+:它们会截断原文件。如果只是想在日志末尾增加内容,应使用 a 或 ab,不要误用 w。
一个最小的写文件例子如下:
c
#include <stdio.h>
int main(void)
{
FILE *fp = fopen("message.txt", "w");
if (fp == NULL) {
perror("message.txt");
return 1;
}
fputs("file I/O starts here\n", fp);
if (fclose(fp) != 0) {
perror("fclose");
return 1;
}
return 0;
}
四、顺序读写:从当前位置向前处理
顺序读写按照文件指针当前所在位置,一次处理一个字符、一行文本、一个格式化字段或一组二进制对象。常用函数可以分成以下几组:
| 操作 | 文本或流 | 常用函数 |
|---|---|---|
| 字符输入 | 输入流 | fgetc |
| 字符输出 | 输出流 | fputc |
| 行输入 | 输入流 | fgets |
| 行输出 | 输出流 | fputs |
| 格式化输入 | 输入流 | fscanf |
| 格式化输出 | 输出流 | fprintf |
| 二进制输入 | 文件流 | fread |
| 二进制输出 | 文件流 | fwrite |
1. 字符和字符串
fputc 写入一个字符,fgetc 读取一个字符。fgets 读取一行或指定数量的字符,fputs 写入一个字符串:
c
#include <stdio.h>
int main(void)
{
FILE *fp = fopen("note.txt", "w");
if (fp == NULL) {
return 1;
}
fputc('A', fp);
fputs("\nC language file I/O\n", fp);
fclose(fp);
fp = fopen("note.txt", "r");
if (fp == NULL) {
return 1;
}
char line[128];
while (fgets(line, sizeof line, fp) != NULL) {
fputs(line, stdout);
}
fclose(fp);
return 0;
}
使用 fgets 时,缓冲区大小必须传给函数,函数会在空间允许时保留换行符,并自动在末尾补上字符串结束符。读取长度不确定的行时,固定数组仍可能被截断,需要根据业务设计更大的缓冲区或采用动态拼接策略。
2. 格式化读写
fprintf 和 fscanf 的用法分别类似 printf 和 scanf,但数据源或目标变成了文件:
c
fprintf(fp, "%s %d\n", "Alice", 95);
fscanf(fp, "%31s %d", name, &score);
格式化输入必须检查返回值,因为输入内容可能缺少字段或格式不符合预期。字符串转换还必须限制最大长度,避免写出数组边界。
可以把三组函数这样理解:
scanf、printf面向标准输入和标准输出。fscanf、fprintf面向FILE *文件流。sscanf、sprintf面向内存中的字符串。
3. 二进制块读写
fwrite 按字节写入一组对象,fread 按字节读出一组对象:
c
size_t written = fwrite(values, sizeof values[0], count, fp);
if (written != count) {
/* 写入失败或只完成了部分写入 */
}
size_t received = fread(values, sizeof values[0], count, fp);
if (received < count) {
/* 可能遇到文件尾,也可能发生读取错误 */
}
函数返回实际完成的元素个数,而不是字节数。判断结果时必须使用正确的单位,不能把返回值简单当作布尔值。
五、随机读写:移动文件指针
顺序读取适合从头到尾处理日志或文本;当程序需要直接访问某个位置时,就要移动文件指针。
fseek:按偏移量定位
c
int fseek(FILE *stream, long offset, int origin);
origin 常用的基准有:
SEEK_SET:从文件开头计算。SEEK_CUR:从当前位置计算。SEEK_END:从文件末尾计算。
下面的例子先写入一段文本,再从开头偏移 9 个字节的位置覆盖内容:
c
#include <stdio.h>
int main(void)
{
FILE *fp = fopen("example.txt", "wb");
if (fp == NULL) {
return 1;
}
fputs("This is an apple.", fp);
if (fseek(fp, 9, SEEK_SET) != 0) {
fclose(fp);
return 1;
}
fputs("sam", fp);
fclose(fp);
return 0;
}
二进制文件中,偏移量通常以字节计算。文本模式在不同平台上可能存在换行符转换,因此不要轻易假设任意文本偏移都能跨平台复用。
ftell:获取当前位置
c
long ftell(FILE *stream);
它返回当前位置相对于文件起始位置的偏移量。常见做法是先移动到末尾,再调用 ftell 估算文件大小:
c
FILE *fp = fopen("data.bin", "rb");
if (fp == NULL) {
return 1;
}
if (fseek(fp, 0, SEEK_END) != 0) {
fclose(fp);
return 1;
}
long size = ftell(fp);
if (size < 0) {
perror("ftell");
}
fclose(fp);
对于需要严格跨平台的程序,应结合具体文件类型和平台接口选择更合适的文件大小获取方式,不要把 long 能表示的范围当成所有文件都适用。
rewind:回到文件开头
c
void rewind(FILE *stream);
rewind 会把文件指针移动到起始位置,并清除相关错误和文件尾状态。它适合在同一个文件上先写入、再从头读取:
c
FILE *fp = fopen("letters.txt", "w+");
if (fp == NULL) {
return 1;
}
for (int ch = 'A'; ch <= 'Z'; ++ch) {
fputc(ch, fp);
}
rewind(fp);
char buffer[27];
size_t n = fread(buffer, 1, 26, fp);
buffer[n] = '\0';
puts(buffer);
fclose(fp);
六、文本文件与二进制文件
内存中的数据本质上以二进制形式存储。写入磁盘时,如果先把数值转换成可读字符,就是文本文件;如果直接保存内存中的字节,就是二进制文件。
例如整数 10000:
- 以文本形式保存时,需要保存字符 1、0、0、0、0,通常占 5 个字节。
- 以二进制形式保存时,常见的
int可能只占 4 个字节。
文本文件便于人工查看、调试和跨工具交换,例如配置文件和日志。二进制文件体积更紧凑,读写速度通常更高,适合图片、音视频、索引和结构化数据。但二进制数据会受到类型大小、字节序、对齐方式和浮点表示等因素影响,不能简单假定不同平台上的内存布局完全相同。
以二进制方式保存一个整数:
c
#include <stdio.h>
int main(void)
{
int value = 10000;
FILE *fp = fopen("number.bin", "wb");
if (fp == NULL) {
return 1;
}
if (fwrite(&value, sizeof value, 1, fp) != 1) {
perror("fwrite");
}
fclose(fp);
return 0;
}
如果要设计长期保存、跨平台交换的格式,更稳妥的做法是明确字段宽度、字节序和编码,而不是直接把包含指针的结构体整体写入文件。指针保存的是进程地址,在下一次运行或另一台机器上没有意义。
七、正确判断文件是否读取结束
feof 经常被误用。它不是"下一次读取会不会结束"的预判函数,而是用于在读取操作失败或停止后,判断是否确实到达了文件尾。因此,不能写成:
c
while (!feof(fp)) {
/* 读取数据 */
}
这种写法通常会多处理一次无效数据。正确方式是直接检查具体读取函数的返回值。
文本字符:检查 EOF
fgetc 返回 int,而不是 char。这样它才能同时表示所有有效字符和特殊的 EOF 值:
c
int ch;
while ((ch = fgetc(fp)) != EOF) {
putchar(ch);
}
读取停止后,再区分是正常到达文件尾,还是发生了 I/O 错误:
c
if (ferror(fp)) {
puts("I/O error while reading");
} else if (feof(fp)) {
puts("end of file reached");
}
文本行:检查 NULL
fgets 成功返回传入的缓冲区地址,失败或到达文件尾时返回 NULL:
c
while (fgets(line, sizeof line, fp) != NULL) {
process_line(line);
}
if (ferror(fp)) {
perror("fgets");
}
二进制块:检查元素个数
fread 返回实际读到的元素数量。请求读取 10 个元素时,返回值小于 10 就表示没有完成整批读取;此时再用 feof 和 ferror 判断具体原因:
c
size_t got = fread(items, sizeof items[0], 10, fp);
if (got != 10) {
if (feof(fp)) {
puts("file ended before all items were read");
} else if (ferror(fp)) {
perror("fread");
}
}
八、文件缓冲区与 fflush
标准 C I/O 通常使用缓冲区。程序向文件写入时,数据可能先进入内存中的输出缓冲区,达到一定条件后才真正写到磁盘。读取时,系统也可能先把一批数据装入输入缓冲区,再交给程序逐步处理。
缓冲区减少了频繁访问磁盘的次数,但也意味着"调用写函数返回"不一定等于"数据已经落盘"。需要提前让输出可见时,可以调用:
c
if (fflush(fp) != 0) {
perror("fflush");
}
关闭文件时,fclose 通常会自动刷新输出缓冲区,所以正常结束文件操作必须调用 fclose。如果程序异常终止,缓冲区中的内容可能来不及写入;对于真正重要的数据,还需要考虑操作系统和存储设备层面的持久化保证,不能只依赖 C 标准库的刷新。
标准输入输出流也可能有缓冲。交互程序中常见的现象是提示文字没有立即显示,可以在适当位置刷新 stdout:
c
printf("Input: ");
fflush(stdout);
不要频繁无目的地刷新缓冲区,否则会削弱缓冲带来的性能优势。应在需要与用户交互、需要观察文件最新内容或即将关闭文件时刷新。
九、一个更稳健的文件复制示例
下面的程序使用块读写复制二进制文件。它把打开、读取、写入和关闭的错误都纳入处理,并且不使用 feof 作为循环条件:
c
#include <stdio.h>
int copy_file(const char *source, const char *target)
{
FILE *in = fopen(source, "rb");
if (in == NULL) {
perror(source);
return 0;
}
FILE *out = fopen(target, "wb");
if (out == NULL) {
perror(target);
fclose(in);
return 0;
}
unsigned char buffer[4096];
int ok = 1;
for (;;) {
size_t n = fread(buffer, 1, sizeof buffer, in);
if (n > 0 && fwrite(buffer, 1, n, out) != n) {
perror("write");
ok = 0;
break;
}
if (n < sizeof buffer) {
if (ferror(in)) {
perror("read");
ok = 0;
}
break;
}
}
if (fclose(in) != 0) {
ok = 0;
}
if (fclose(out) != 0) {
ok = 0;
}
return ok;
}
这个例子使用 rb 和 wb,因此不会因为文本模式的换行转换而改变二进制内容。循环先处理实际读到的字节,再通过 ferror 判断短读的原因,适合复制任意类型的文件。
十、文件操作中的常见问题
打开文件后没有检查 NULL
文件路径错误、权限不足、文件不存在或资源耗尽,都可能导致打开失败。直接使用空的 FILE * 会造成未定义行为。
用 w 更新已有文件
w 会清空原内容。如果只想增加内容,使用 a;如果要修改某个位置,使用适当的读写模式和 fseek。
把 char 用来接收 fgetc
EOF 需要通过 int 表示。使用 char 接收可能导致某些合法字节和 EOF 混淆。
用 feof 控制读取循环
文件尾状态通常是在一次读取尝试之后才被设置。直接以 !feof(fp) 作为循环条件,容易多处理一次数据。
忘记关闭文件
不关闭文件会造成句柄泄漏,输出缓冲区也可能没有及时写入。每次 fopen 都应对应一次 fclose。
把结构体内存直接当成通用文件格式
结构体可能包含填充字节、指针和平台相关类型。短期同平台程序可以使用二进制写入,但需要长期保存或跨平台交换时,应逐字段序列化。
十一、实用检查清单
写文件代码时,可以按照下面的顺序自检:
- 文件路径是否正确,当前工作目录是否符合预期?
fopen的模式是否会意外清空原文件?fopen的返回值是否检查过?- 每个成功打开的文件是否都能走到
fclose? - 文本读取是否检查
EOF或NULL? - 二进制读取是否比较了
fread返回的元素数量? - 是否在读取停止后用
ferror和feof区分原因? fwrite是否检查了实际写入的数量?- 是否在需要的时机刷新输出缓冲区?
- 二进制格式是否考虑类型大小、字节序和指针不可持久化的问题?
总结
文件操作解决的是数据持久化问题:程序把内存中的信息写到磁盘,下一次运行时再把它读回内存。fopen 和 fclose 管理文件生命周期,fgetc、fgets、fprintf、fread 等函数完成不同粒度的顺序读写,fseek、ftell 和 rewind 支持随机定位。
写出可靠的文件程序,关键不只是记住函数名,还要始终检查返回值,正确判断文件尾和 I/O 错误,理解文本与二进制的差异,并在合适的时候刷新和关闭文件。把这些原则落实到每一次打开、读取、写入和关闭中,文件就能成为稳定可靠的数据存储接口。