回忆一下你之前写的所有 C 程序——数据从键盘输入,结果往屏幕输出。程序一退出,内存收回,一切归零。这种模式下,你的程序和外部世界之间只有"人"这个媒介。如果你想处理几百个学生的成绩、读取一个配置文件、或者记录程序的运行日志,总不能每次都靠人手打进去吧?
文件操作就是来解决这个问题的。它让你的程序能够和外存(硬盘、SSD 等)打交道——从磁盘读入数据、往磁盘写入结果。更重要的是,文件上的数据是持久化的:程序关了,甚至电脑重启了,数据都还在。这是任何一个实用程序的必修课。
在开始之前,先给你一个整体的心智地图。这一讲我们要回答六个问题:
- 文件在计算机里到底以什么形态存在?(二进制 vs 文本)
- 程序怎么"打开"一个文件、怎么"关闭"它?(fopen / fclose)
- 打开之后,怎么把数据读进来、写出去?(四对读写函数)
- 怎么在文件里"跳来跳去"地随机访问?(fseek / ftell / rewind)
- 怎么知道文件读完了?怎么区分"正常结束"和"出错"?(EOF / feof / ferror)
- 数据到底是怎么从内存到达磁盘的?(文件缓冲区)
把这六个问题串起来,你对文件操作的理解就不是"背了几个函数",而是形成一套完整的体系。我们一个一个来。
什么是文件?
日常口语里"文件"指的就是硬盘上的一个文档。但在 C 程序设计中,我们从功能角度把文件分成两类:
程序文件——源代码文件(.c)、目标文件(Windows 上是 .obj,Linux 上是 .o)、可执行文件(.exe)。这些是构建程序本身的文件。
数据文件——程序运行时读写的文件。比如配置文件(.ini、.json)、日志文件(.log)、数据库文件等。本章讨论的"文件操作"指的就是对数据文件的操作。
一个完整的文件名包含三部分:文件路径 + 文件名主干 + 文件后缀,比如 c:\code\test.txt。
这里展开说一下路径(path),因为它和后面 fopen 能不能打开文件直接相关。路径分两种:
- 绝对路径:从盘符(Windows)或根目录(Linux/macOS)写起的完整路径。比如
c:\code\test.txt、/home/user/data.txt。 - 相对路径:相对于程序当前工作目录的路径。比如程序运行在
c:\code\目录下,那么写"test.txt"就等价于"c:\code\test.txt"。
写代码时有个非常容易踩的坑:在 C 字符串里,反斜杠 \ 是转义字符。所以写 Windows 路径 c:\code\test.txt 时,必须写成 "c:\\code\\test.txt"(两个反斜杠),否则 \c、\t 会被解释成转义序列。当然,更省心的做法是用相对路径,或者统一用正斜杠 /——Windows 的 API 和标准库都接受 "c:/code/test.txt" 这种写法。
另外提醒一句:如果你在 VS 里写 fopen("test.txt", "r") 却老是打开失败,先检查文件是不是放在"当前工作目录"里——VS 默认的工作目录通常是工程文件(.vcxproj)所在的目录,而不是源文件所在的目录。这是一个让无数新手困惑的问题,后面我们还会碰到。
二进制文件和文本文件
这是初学者容易混淆的一对概念。它们的区别不在内容上,而在存储格式上:
- 文本文件:数据以 ASCII 码(或 UTF-8 等编码)的形式存储。比如整数
10000,在文本文件中是五个字符'1''0''0''0''0',占用 5 个字节。 - 二进制文件:数据直接以内存中的二进制形式存储。同样的整数
10000,如果是int类型,只占 4 个字节(在 32 位系统上)。
换句话说,文本文件是"人类可读"的——你用记事本打开能看懂。二进制文件是"机器原生"的——程序读写更快、占用空间更小,但用记事本打开是乱码。
一个关键的事实:字符一律以 ASCII 形式存储,数值型数据既可以 ASCII 存储也可以二进制存储。选择哪种取决于你的需求——如果需要人工查看和编辑,用文本格式;如果追求效率和紧凑,用二进制格式。
来看一个具体的对比,把整数 12345 分别以两种方式存进文件:
#include <stdio.h>
int main()
{
int num = 12345;
FILE *fp;
// === 文本方式存储 ===
fp = fopen("num_text.txt", "w");
fprintf(fp, "%d", num); // 按 %d 格式输出:存的是 '1''2''3''4''5' 五个字符
fclose(fp);
// === 二进制方式存储 ===
fp = fopen("num_bin.dat", "wb");
fwrite(&num, sizeof(int), 1, fp); // 把 num 在内存中的 4 个字节原样写入
fclose(fp);
return 0;
}写完后用记事本打开两个文件:
| 文件 | 打开内容 | 占用字节 |
|---|---|---|
num_text.txt | 12345 | 5 字节(每个数字一个字节) |
num_bin.dat | 乱码(可能看到 9'00 之类) | 4 字节 |
为什么二进制文件里是"乱码"?因为 12345 的十六进制是 0x3039,在内存中按小端序存放是 39 30 00 00 四个字节——其中 0x39 恰好是字符 '9' 的 ASCII 码,0x30 是 '0' 的 ASCII 码,所以你会看到"90"后面跟着两个不可见的 \0。这就是"机器原生"的含义:文件内容就是内存的字节拷贝,不做任何转换。
什么时候用文本,什么时候用二进制? 简单粗暴的判断标准:
- 文件要给人看、要跨程序交换(比如配置文件、CSV 导出、日志)→ 文本;
- 文件只是给程序自己读(比如存档、缓存、数据库原始数据)→ 二进制(体积小、读写快、不丢精度)。
还有一点必须强调:文本模式下,Windows 系统会自动做行尾转换。在 Windows 上,文本文件的一行以 \r\n(回车换行,CRLF)结束,而 C 语言内部统一用 \n(换行,LF)表示换行。当你以文本模式(不带 b)写入 \n 时,系统会悄悄把它变成 \r\n 写进磁盘;读取时反向转换。而二进制模式(带 b)不做任何转换,写入什么就是什么。
这个差异在 Linux/macOS 上不存在(它们统一用 \n),所以很多人写跨平台代码时发现 Windows 上读文本文件"多了个 \r"——其实是文本模式的转换在起作用。记住:读写二进制数据(数字、结构体)必须用带 b 的模式,否则行尾转换会篡改数据,导致字节数和内容对不上。
文件的打开和关闭
在深入函数细节之前,先理解 C 语言是如何抽象 I/O 操作的。
流:C 语言的 I/O 抽象
外部设备五花八门——键盘、显示器、磁盘文件、打印机、网络端口……每种设备的 I/O 方式都不一样。如果程序员每次都要针对不同设备写不同的代码,开发效率就太低了。于是 C 语言的设计者引入了"流"(stream)这个概念。你可以把流想象成一条流淌着字节的河——你想从键盘读数据?从"标准输入流"里取。你想把结果打印到屏幕上?往"标准输出流"里写。你想访问文件?打开一个文件流就行了。
"流"这个抽象的伟大之处在于:无论数据来自键盘还是文件,对程序员来说操作方式完全一样——都是"从一个流里取字节"或"往一个流里放字节"。这也是为什么下面要讲的 fgetc、fgets、fscanf 既可以从文件读、也可以从键盘读。统一抽象,是软件工程中降低复杂度的重要手段。
C 程序在启动时,会自动打开三个标准流:
| 流名 | 含义 | 默认设备 |
|---|---|---|
stdin | 标准输入流 | 键盘 |
stdout | 标准输出流 | 显示器 |
stderr | 标准错误流 | 显示器 |
这三个流的类型都是 FILE*(文件指针),这也是为什么你从来不需要手动"打开键盘"或"打开显示器"——操作系统在你程序启动时就帮你做好了。
为什么要有独立的 stderr?因为它和 stdout 有本质区别:stdout 通常有缓冲区(攒一批再显示),而 stderr 默认不缓冲(立即输出)。这意味着你用 fprintf(stderr, "...") 打印错误信息时,即使程序马上崩溃,错误信息也已经显示出来了——用 printf 则可能因为缓冲区没刷新而丢失。在程序发生致命错误时,stderr 是你的救命稻草。
文件指针:FILE 结构体
每个被打开的文件在内存中都有一个对应的文件信息区——一个 FILE 类型的结构体,里面存储着文件名、当前读写位置、文件状态、缓冲区等元信息。这个结构体由 fopen 自动创建并填充,你只需要用一个 FILE* 指针来引用它。
不同编译器的 FILE 结构体长得不太一样,但核心信息大同小异。比如 VS2013 的 stdio.h 中定义的大概是:
struct _iobuf {
char *_ptr; // 文件位置指针(缓冲区内的当前位置)
int _cnt; // 剩余未读的字符数
char *_base; // 缓冲区基址
int _flag; // 文件状态标志(读写模式、是否出错等)
int _file; // 文件描述符
int _charbuf; // 单字符缓冲区
int _bufsiz; // 缓冲区大小
char *_tmpfname; // 临时文件名
};
typedef struct _iobuf FILE;你不需要操作这个结构体的内部成员——所有操作都通过标准库函数间接完成。你只需要知道:每个 FILE* 指向一个打开的文件,所有的读写操作都基于这个指针。
顺便理解一个细节:FILE 结构体里记录的"文件位置指针"(也就是我们常说的"文件位置指示器",file position indicator)是什么?它记录的是下一次读写操作将从文件的哪个位置开始。每读一个字节,它自动 +1;fseek 可以手动移动它。整个"顺序读写"和"随机读写"的机制,底层就是围绕这个位置指示器展开的。
fopen 和 fclose
打开文件用的是 fopen,关闭文件用的是 fclose:
FILE* fopen(const char* filename, const char* mode);
int fclose(FILE* stream);filename 是文件路径。mode 是一个字符串,决定你以什么方式打开文件。C 语言提供了丰富的打开模式:
| 模式 | 含义 | 文件不存在时 | 文件已存在时 |
|---|---|---|---|
"r" | 只读,打开已有文本文件 | 出错(返回 NULL) | 从开头读 |
"w" | 只写,打开文本文件 | 创建新文件 | 清空内容后从开头写 |
"a" | 追加,在文本文件末尾写 | 创建新文件 | 从末尾追加 |
"rb" | 只读,打开已有二进制文件 | 出错 | 从开头读 |
"wb" | 只写,打开二进制文件 | 创建新文件 | 清空内容后从开头写 |
"ab" | 追加,在二进制文件末尾写 | 创建新文件 | 从末尾追加 |
"r+" | 读写,打开已有文本文件 | 出错 | 从开头读写 |
"w+" | 读写,创建新文本文件 | 创建新文件 | 清空内容后读写 |
"a+" | 读写追加,在末尾读写文本文件 | 创建新文件 | 从末尾追加 |
"rb+" | 读写,打开已有二进制文件 | 出错 | 从开头读写 |
"wb+" | 读写,创建新二进制文件 | 创建新文件 | 清空内容后读写 |
"ab+" | 读写追加,在末尾读写二进制文件 | 创建新文件 | 从末尾追加 |
这个表是整个文件操作的基石,建议你把它背下来,或者至少熟记以下几个规律:
r系模式(r、r+、rb、rb+):文件必须存在,否则fopen返回NULL。w系模式(w、w+、wb、wb+):文件不存在就创建;文件存在就清空——原有数据全部丢失,没有任何恢复手段。这一点最危险:不小心把"r"写成"w",或者把应该追加的文件用"w"打开,数据瞬间蒸发。a系模式(a、a+、ab、ab+):文件不存在就创建;所有写入总是追加到文件末尾,即使你调用了fseek把位置指针移走了也没用——追加模式在写之前会自动把位置指针移到末尾。这是追加模式的特性,也是很多人困惑的地方。- 带
+的模式表示"可读可写",但从读切换到写(或反过来)时,中间必须有一次fseek/rewind/fflush之类的定位操作,否则行为未定义。这是 C 标准的规定,用来让底层缓冲区和文件位置同步。 - 带
b的模式表示二进制模式,不做行尾转换;不带b的文本模式在 Windows 上会把\n和\r\n互相转换。Linux/macOS 上b有没有都一样(它们不做转换),但为了可移植性,读写二进制数据时请务必写b。
还要注意一个 VS 特有的小坑:在 VS 里,fopen 会提示"此函数可能不安全,请考虑使用 fopen_s"并给出 C4996 警告(其实程序能正常编译运行)。如果想消除警告,可以在文件开头加 #define _CRT_SECURE_NO_WARNINGS,或者用 fopen_s(但 fopen_s 不是 C 标准函数,是微软扩展)。本教程统一使用标准 fopen。
先看一个最简单的例子——创建一个文件并写入内容:
#include <stdio.h>
int main()
{
// 以只写(w)模式打开文件
// 如果文件不存在,会创建新文件
// 如果文件已存在,会清空原有内容
FILE *fp = fopen("hello.txt", "w");
// 永远检查 fopen 返回值——文件可能无法创建(权限问题、磁盘满等)
if (fp == NULL) {
perror("文件打开失败"); // perror 会打印出具体的系统错误原因
return 1;
}
// 向文件写入字符串
fputs("Hello, File I/O!\n", fp);
fputs("这是 C 语言文件操作的第一个示例。\n", fp);
// 关闭文件——这一步不能省略!
// fclose 会自动刷新缓冲区并释放 FILE 结构体
fclose(fp);
printf("文件写入成功,请查看 hello.txt\n");
return 0;
}perror 是一个很方便的函数。它会根据全局变量 errno 的当前值,打印出人类可读的错误描述,比如 "Permission denied" 或 "No such file or directory"。在调试文件操作问题时,perror 是你的好朋友。
再补一个细节:fclose 的返回值。它成功时返回 0,失败时返回 EOF(通常是 -1)。失败的典型场景是缓冲区刷新时磁盘写入出错(比如磁盘满了)。严谨的程序会在关闭后检查返回值:
if (fclose(fp) != 0) {
fprintf(stderr, "关闭文件时出错,可能有数据没写成功\n");
}虽然大多数情况下你不会遇到,但"检查返回值"是好习惯——尤其是在写重要数据(银行流水、成绩单)的时候。
文件的顺序读写
掌握了打开和关闭,接下来看看 C 语言提供的八种读写函数。它们可以分成四对:
| 函数 | 方向 | 操作单元 | 适用场景 |
|---|---|---|---|
fgetc / fputc | 输入 / 输出 | 单个字符 | 逐字节处理、文件拷贝 |
fgets / fputs | 输入 / 输出 | 一行文本 | 读配置文件、逐行处理日志 |
fscanf / fprintf | 输入 / 输出 | 格式化数据 | 结构化文本数据(成绩表、通讯录) |
fread / fwrite | 输入 / 输出 | 二进制块 | 二进制数据、结构体数组整体存取 |
"顺序读写"的意思是:文件内部有一个位置指针,你每读一个字符(或写一个字符),这个指针就自动前移一个位置。你不用手动控制它,数据按从头到尾的顺序被处理。
还有一个容易忽略的通用性:表格里的函数适用于所有输入流/输出流——除了 fread/fwrite 主要用于文件流之外,fgetc/fgets/fscanf 这些输入函数既可以从文件读,也可以从标准输入流 stdin 读(比如 fgets(buf, sizeof(buf), stdin) 可以安全地读取键盘输入的一行,比 gets 安全得多);输出函数同理。明白了这一点,你会发现这套函数远比你想象的通用。
选择哪个函数,核心看你的数据形态:
- 数据是一个个独立字符 →
fgetc/fputc; - 数据是"一行一行"的 →
fgets/fputs; - 数据有固定结构、想按字段解析 →
fscanf/fprintf; - 数据是二进制(数字、结构体) →
fread/fwrite。
下面逐个深入。
字符读写:fgetc / fputc
int fgetc(FILE* stream); // 从流中读取一个字符,返回其 ASCII 码;失败或结束返回 EOF
int fputc(int c, FILE* stream); // 把一个字符写入流;成功返回该字符,失败返回 EOF先从最基础的字符读写开始,用它们写一个文件拷贝程序:
#include <stdio.h>
int main()
{
FILE *src, *dst;
int ch; // 必须是 int 而不是 char——既要存放字符,也要能表示 EOF
// 打开源文件(只读)
src = fopen("source.txt", "r");
if (src == NULL) {
perror("无法打开源文件");
return 1;
}
// 打开目标文件(只写)
dst = fopen("copy.txt", "w");
if (dst == NULL) {
perror("无法创建目标文件");
fclose(src); // 关闭已打开的源文件
return 1;
}
// 逐字符读取并写入——最基础的文件拷贝方式
// fgetc 每次从文件中读取一个字符,返回该字符的 ASCII 码(int 类型)
// 读到文件末尾或出错时返回 EOF(通常是 -1)
while ((ch = fgetc(src)) != EOF) {
fputc(ch, dst); // fputc 将一个字符写入文件
}
// 判断是正常结束还是出错
if (ferror(src)) {
printf("读取过程中发生错误\n");
} else {
printf("文件拷贝完成!\n");
}
fclose(src);
fclose(dst);
return 0;
}这里有个重要细节:fgetc 的返回类型是 int 而不是 char。原因是 EOF 通常定义为 -1,如果返回 char 类型,有符号的 char 值为 -1 时会和 EOF 混淆。所以接收 fgetc 返回值的变量必须是 int。
为什么 EOF 必须是 -1 而不用其他值?因为 ASCII 字符的取值范围是 0127(扩展 ASCII 是 0255),-1 不可能是一个合法字符的编码,所以它可以安全地作为"文件结束"的哨兵值。C 标准保证 EOF 是一个负的整数常量,几乎所有实现里都是 -1。
对比记忆:fgetc(fp) 从文件读一个字符;getchar() 等价于 fgetc(stdin),从键盘读。fputc(c, fp) 往文件写一个字符;putchar(c) 等价于 fputc(c, stdout)。这一组函数是全家族的地基。
行读写:fgets / fputs
逐行读写更常用,尤其是处理文本配置文件或日志时:
#include <stdio.h>
#define MAX_LINE 256 // 每行最大长度
int main()
{
FILE *fp;
char line[MAX_LINE];
int line_count = 0;
// 以读模式打开文件
fp = fopen("input.txt", "r");
if (fp == NULL) {
perror("打开文件失败");
return 1;
}
// fgets 每次读取一行(直到遇到换行符或缓冲区满或文件结束)
// 参数:缓冲区地址、缓冲区大小、文件指针
// 返回值:成功时返回缓冲区地址,失败或文件结束返回 NULL
printf("文件内容(带行号):\n");
while (fgets(line, sizeof(line), fp) != NULL) {
line_count++;
printf("%3d: %s", line_count, line);
// line 本身包含换行符,所以 printf 不需要额外加 \n
}
// 文件结束才退出循环,检查是否因错误退出
if (ferror(fp)) {
printf("读取文件时发生错误\n");
} else {
printf("\n共读取 %d 行\n", line_count);
}
fclose(fp);
return 0;
}fgets 有几个细节值得注意:
- 它会读取换行符
\n本身,所以读到的字符串末尾可能带换行符。如果你要拼接或比较,可能需要手动去掉行尾的\n(比如line[strcspn(line, "\n")] = '\0';或判断line[strlen(line)-1] == '\n')。 - 如果一行太长超过了缓冲区大小,它只会读满缓冲区(留出位置放
\0),剩下的留在文件中等下次读。也就是说,fgets不等于"读一行",严格说是"读一小块",只是大多数情况下"一行刚好能放下"。所以不要假设一次fgets一定读到了一整行——要检查末尾是不是\n。 - 它会在字符串末尾自动添加
\0,保证你拿到的始终是一个合法 C 字符串。 - 第二个参数是缓冲区大小(包括
\0)。写fgets(line, 256, fp)最多读入 255 个字符,留 1 个位置给\0。传入sizeof(line)是最稳妥的写法。 gets因为不检查缓冲区大小、必然导致缓冲区溢出,已经从 C11 标准中移除——永远不要用gets,用fgets代替。
fputs(s, fp) 往文件写一个字符串,不会自动加换行符——它忠实地把 s 的内容(直到 \0)写入文件。对比 puts(s) 是往标准输出写字符串并自动加一个 \n。所以"fputs 读什么写什么"的特点,让它可以和 fgets 完美配对做逐行拷贝:
#include <stdio.h>
int main()
{
FILE *src = fopen("in.txt", "r");
FILE *dst = fopen("out.txt", "w");
char line[512];
if (src == NULL || dst == NULL) {
perror("打开文件失败");
return 1;
}
// fgets 读一行(含换行符),fputs 原样写出——行结构被完整保留
while (fgets(line, sizeof(line), src) != NULL) {
fputs(line, dst);
}
fclose(src);
fclose(dst);
return 0;
}注意这里有个细节:如果某行超过了 512 字节,fgets 会分几次读完(每次带一小段,只有最后一次带 \n),fputs 分几次写出,最终内容拼接起来仍然正确——因为 fputs 忠实于缓冲区内容。这就是"fgets+fputs 是逐行处理的最稳健组合"的原因。
格式化读写:fscanf / fprintf
当你处理结构化数据时,格式化读写就派上用场了:
#include <stdio.h>
// 定义一个学生结构体
typedef struct {
char name[32];
int age;
float score;
} Student;
int main()
{
Student students[3] = {
{"张三", 20, 88.5},
{"李四", 21, 92.0},
{"王五", 19, 76.5}
};
// === 写入文件(格式化输出)===
FILE *fp = fopen("students.txt", "w");
if (fp == NULL) { perror("写入失败"); return 1; }
// fprintf 的用法和 printf 几乎一模一样,只是多了一个 FILE* 参数
for (int i = 0; i < 3; i++) {
fprintf(fp, "%s %d %.1f\n",
students[i].name,
students[i].age,
students[i].score);
}
fclose(fp);
printf("数据已写入 students.txt\n");
// === 从文件读取(格式化输入)===
fp = fopen("students.txt", "r");
if (fp == NULL) { perror("读取失败"); return 1; }
Student read_data[3];
int count = 0;
// fscanf 从文件中按格式读取数据
// 返回值:成功匹配并赋值的参数个数
while (fscanf(fp, "%s %d %f",
read_data[count].name,
&read_data[count].age,
&read_data[count].score) == 3) {
count++;
if (count >= 3) break; // 安全边界
}
fclose(fp);
// 验证读取结果
printf("\n从文件读回的数据:\n");
for (int i = 0; i < count; i++) {
printf("%s, %d岁, 成绩: %.1f\n",
read_data[i].name,
read_data[i].age,
read_data[i].score);
}
return 0;
}fprintf 和 fscanf 是 printf/scanf 的文件版本。使用它们时,格式字符串的匹配规则和你在终端 I/O 中用的一样。但要注意,fscanf 对空格和换行比较敏感——如果你写入的格式和读取的格式不完全匹配,解析结果可能出错。
fscanf 的返回值是成功匹配并赋值的参数个数,这是它的灵魂。几个关键点:
- 如果三个格式都匹配成功,返回 3;
- 如果匹配到一半失败(比如该读整数时遇到字母),返回已成功的个数(可能是 0、1、2);
- 如果一开始就遇到文件结束或格式不匹配,返回
EOF(负数)。
所以正确的循环写法是比较返回值和格式个数:
// 正确:严格要求每次都读到 3 个字段
while (fscanf(fp, "%s %d %f", name, &age, &score) == 3) {
// 处理这条记录
}而不是用 while (!feof(fp))——因为 feof 是事后标志,容易导致最后一次读失败还继续处理脏数据。还有几个 %s 的坑必须提醒:
%s遇到空白字符(空格、换行、制表符)就停,所以读名字时如果名字里有空格(比如 "张 三")就会出问题——这是fscanf读文本数据的天然限制,想读含空格的整行请用fgets。%s不检查缓冲区大小,读到超长的词照样往下写,会缓冲区溢出。安全写法是限制宽度:%31s(最多读 31 个字符,留 1 个给\0)。- 读取时必须给变量传地址(
&age、&score),而数组名本身已经是地址(name不用加&)。
fprintf 则是完全把 printf 的格式控制(%d、%f、%.2f、%s、%-10s 左对齐、%05d 补零等)搬运到文件上。你可以用它生成漂亮的报表:
fprintf(fp, "%-10s %5d %8.2f\n", "张三", 20, 88.50);
// 输出: 张三 20 88.50(左对齐10格、数字右对齐5格、保留两位小数)二进制读写:fread / fwrite
如果需要高效存储,用二进制读写:
#include <stdio.h>
int main()
{
int numbers[] = {10, 20, 30, 40, 50};
int count = sizeof(numbers) / sizeof(numbers[0]);
// === 以二进制模式写入 ===
FILE *fp = fopen("data.bin", "wb"); // "wb" = write binary
if (fp == NULL) { perror("写入失败"); return 1; }
// fwrite: 将内存中的数据原样写入文件
// 参数:数据地址、每个元素大小、元素个数、文件指针
// 返回值:实际写入的元素个数
size_t written = fwrite(numbers, sizeof(int), count, fp);
printf("成功写入 %zu 个整数到 data.bin\n", written);
fclose(fp);
// === 以二进制模式读取 ===
int read_back[5] = {0};
fp = fopen("data.bin", "rb"); // "rb" = read binary
if (fp == NULL) { perror("读取失败"); return 1; }
// fread: 从文件中读取二进制数据到内存
// 参数和 fwrite 完全相同
size_t items_read = fread(read_back, sizeof(int), count, fp);
printf("成功读取 %zu 个整数:\n", items_read);
for (int i = 0; i < (int)items_read; i++) {
printf("read_back[%d] = %d\n", i, read_back[i]);
}
fclose(fp);
return 0;
}输出:
成功写入 5 个整数到 data.bin
成功读取 5 个整数:
read_back[0] = 10
read_back[1] = 20
read_back[2] = 30
read_back[3] = 40
read_back[4] = 50
fwrite 和 fread 做的是"原样搬运"——内存里的字节长什么样,文件里就存成什么样。这个过程不涉及任何编码转换,所以读写速度和数据一致性都比文本模式好。但代价是:用记事本打开 data.bin 看到的是一堆乱码。读写二进制数据时务必用 "b" 系列模式——否则 Windows 系统上的行尾转换(0x0A → 0x0D 0x0A)会悄悄篡改你的数据。
这两个函数的签名是:
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:要读写的元素个数;- 返回值:实际成功读写的完整元素个数。
注意返回值是"元素个数"而不是"字节数"!而且只要出错或到达文件末尾,返回值就可能小于 nmemb。所以判断读失败的惯用写法是:
// 读 100 个 int,但文件可能没有 100 个
int buf[100];
size_t got = fread(buf, sizeof(int), 100, fp);
if (got != 100) {
if (feof(fp))
printf("文件不够长,实际读到 %zu 个元素\n", got);
else if (ferror(fp))
printf("读取出错\n");
}强烈建议把 fread/fwrite 的第三个参数写成 1,让第二个参数等于"整个对象的大小":
// 推荐写法:一次读写一个完整对象
fwrite(&stu, sizeof(Student), 1, fp);
fread(&stu, sizeof(Student), 1, fp);这样返回值只有 0 或 1,语义极其清晰:0 = 没读/写成功,1 = 成功。反过来,fwrite(&stu, 1, sizeof(Student), fp) 虽然字节数一样,但"写完了 76 个元素中的 40 个"这种中间状态会让你很困惑。一次一个完整对象,还能避免"写入到一半程序崩溃导致文件里留下半个结构体"的脏数据更难发现的问题。
二进制读写结构体数组是实际项目里最常见的用法,比如存档系统:
#include <stdio.h>
typedef struct {
char name[32];
int hp;
int level;
float exp;
} Player;
int main()
{
// 三个玩家存档
Player party[3] = {
{"勇者", 100, 1, 0.0f},
{"法师", 80, 2, 120.5f},
{"弓手", 90, 1, 45.0f}
};
// 整个数组一次性写入
FILE *fp = fopen("save.dat", "wb");
if (fp == NULL) { perror("保存失败"); return 1; }
// 一次写 3 个完整对象
size_t n = fwrite(party, sizeof(Player), 3, fp);
printf("已保存 %zu 个角色\n", n);
fclose(fp);
// 读取时按同样格式读回
Player loaded[3] = {0};
fp = fopen("save.dat", "rb");
if (fp == NULL) { perror("读取存档失败"); return 1; }
n = fread(loaded, sizeof(Player), 3, fp);
fclose(fp);
for (size_t i = 0; i < n; i++) {
printf("%s 等级%d HP%d EXP%.1f\n",
loaded[i].name, loaded[i].level, loaded[i].hp, loaded[i].exp);
}
return 0;
}这个程序的优雅之处在于:不管结构体多大、字段多复杂,fread/fwrite 都是"一次性搬运",代码量不会随字段增加而变多。
但二进制读写有两个跨平台/跨编译器的隐患,必须知道:
- 字节序(endianness):x86 是小端(低字节在前),而有些嵌入式平台是大端。同一个
int在不同平台写出的文件字节序不同,换平台读就错了。对策:跨平台交换数据时,要么用文本格式,要么在读写时统一处理字节序。 - 结构体对齐(padding):结构体在内存中会插入填充字节来对齐(比如 char[32]+int+... 实际占的字节可能比字段之和多),而不同编译器、不同对齐设置下填充不同。所以用
fwrite写出的结构体文件,通常只能由同一编译器、同一平台读回。如果想跨平台,用#pragma pack(1)关闭填充,或者干脆用文本格式逐字段读写。
三种 I/O 体系:printf/fprintf/sprintf
实际上,C 语言的格式化 I/O 函数不是只有文件版本。它们有完整的三组对应关系:
#include <stdio.h>
int main()
{
// 第一组:从标准输入/输出
int a;
printf("请输入一个整数: ");
scanf("%d", &a);
printf("你输入的是: %d\n\n", a);
// 第二组:从文件输入/输出
FILE *fp = fopen("num.txt", "w+");
if (fp == NULL) return 1;
fprintf(fp, "%d", 42); // 写入文件
fseek(fp, 0, SEEK_SET); // 回到文件开头准备读取
int b;
fscanf(fp, "%d", &b); // 从文件读取
printf("从文件读取的值: %d\n\n", b);
fclose(fp);
// 第三组:从字符串输入/输出(在内存中操作)
char buffer[32];
sprintf(buffer, "score=%d", 95); // 将格式化数据写入字符串
printf("sprintf 生成的字符串: %s\n", buffer);
int score;
sscanf(buffer, "score=%d", &score); // 从字符串中解析格式化数据
printf("sscanf 解析出来的分数: %d\n", score);
return 0;
}三组函数的对应关系非常规律:
| 目标 | 输出函数 | 输入函数 |
|---|---|---|
| 标准 I/O(键盘/屏幕) | printf | scanf |
| 文件 | fprintf | fscanf |
| 字符串(内存) | sprintf | sscanf |
记住了这个规律,你就能在"控制台 I/O"、"文件 I/O"和"字符串解析"之间无缝切换。它们的格式字符串语法是完全通用的。
这里提醒 sprintf 的一个坑:它不检查目标缓冲区大小,超长就会缓冲区溢出。安全版本是 snprintf(buffer, sizeof(buffer), "score=%d", 95)——指定缓冲区大小,超出部分自动截断。现在写代码应该一律用 snprintf。
文件的随机读写
到目前为止,我们看到的都是"顺序读写"——文件位置指针从开头走到结尾,一路向前不回头。但很多时候你需要随机访问:跳到文件中间修改某个记录、定位到文件末尾追加内容、或者反复横跳处理不同区域的数据。C 语言提供了三个函数来实现这种能力:fseek、ftell、rewind。
#include <stdio.h>
int main()
{
// 创建一个文件,写入 A 到 Z
FILE *fp = fopen("alphabet.txt", "w+");
if (fp == NULL) { perror("打开失败"); return 1; }
for (char ch = 'A'; ch <= 'Z'; ch++) {
fputc(ch, fp);
}
// === fseek:移动文件位置指针 ===
// SEEK_SET:从文件开头算起
fseek(fp, 0, SEEK_SET);
printf("当前位置(开头): %ld\n", ftell(fp));
// SEEK_CUR:从当前位置算起
fseek(fp, 5, SEEK_CUR);
printf("当前位置(+5): %ld\n", ftell(fp));
// SEEK_END:从文件末尾算起
fseek(fp, -3, SEEK_END);
printf("当前位置(末尾-3): %ld\n", ftell(fp));
// 读取当前位置的字符
char ch = fgetc(fp);
printf("当前位置字符: %c\n", ch);
// === ftell:获取当前位置 ===
// 移动到文件末尾,ftell 就返回文件大小
fseek(fp, 0, SEEK_END);
long size = ftell(fp);
printf("文件大小: %ld 字节\n", size);
// === rewind:回到文件开头 ===
// rewind(fp) 等价于 fseek(fp, 0, SEEK_SET),同时清除错误标志
rewind(fp);
printf("rewind 后位置: %ld\n", ftell(fp));
fclose(fp);
return 0;
}输出:
当前位置(开头): 0
当前位置(+5): 5
当前位置(末尾-3): 23
当前位置字符: X
文件大小: 26 字节
rewind 后位置: 0
fseek 的三个基准点——SEEK_SET(文件开头)、SEEK_CUR(当前位置)、SEEK_END(文件末尾)——让你可以灵活地在文件中任意跳转。结合 ftell 获取当前位置,你可以实现数据库般的随机访问。
三个函数逐个说明:
fseek(FILE *stream, long offset, int origin):把位置指针移动到origin + offset处。origin只能取SEEK_SET/SEEK_CUR/SEEK_END。成功返回 0,失败返回非 0。注意offset是long类型。ftell(FILE *stream):返回当前位置距离文件开头的偏移量。失败返回-1L。对二进制文件,这个偏移就是字节数。rewind(FILE *stream):把位置指针拨回文件开头,并清除错误标志和 EOF 标志。它等价于fseek(fp, 0, SEEK_SET); clearerr(fp);。而fseek本身不会清除错误标志。
两个容易踩的坑:
- 文本模式下
ftell/fseek的偏移不是字节数。因为文本模式下有\r\n和\n的转换,位置指针的值不等于真实字节偏移(在 Windows 上,ftell返回的偏移和字符数不一致)。所以对文本文件做随机跳转是不安全的,C 标准只保证在二进制模式下ftell返回的值可以直接用于fseek恢复位置。想随机访问,用二进制模式。 SEEK_END搭配负偏移:fseek(fp, 0, SEEK_END)跳到末尾,fseek(fp, -3, SEEK_END)跳到离末尾 3 字节处。正偏移从SEEK_END出发是未定义行为(在文件末尾之后),别这么写。
一个实用的用法是用 fseek + ftell 获取文件大小:
#include <stdio.h>
long get_file_size(const char *filename)
{
FILE *fp = fopen(filename, "rb"); // 二进制模式,避免文本模式的行尾转换干扰
if (fp == NULL) {
return -1; // -1 表示错误
}
// 跳到文件末尾
fseek(fp, 0, SEEK_END);
// 获取偏移量(即文件大小)
long size = ftell(fp);
fclose(fp);
return size;
}
int main()
{
// 先创建一个测试文件
FILE *fp = fopen("test_size.txt", "w");
if (fp == NULL) return 1;
fprintf(fp, "Hello, this is a test file for size measurement.");
fclose(fp);
// 获取并打印文件大小
long size = get_file_size("test_size.txt");
if (size >= 0) {
printf("文件大小: %ld 字节\n", size);
} else {
printf("无法获取文件大小\n");
}
return 0;
}注意:C 标准并不保证 fseek(fp, 0, SEEK_END) 返回的偏移就是文件大小,但在所有主流平台上这都是可行的。另外,这个函数用 "rb" 模式打开文件是故意的——文本模式下 Windows 的行尾转换会让 ftell 返回的偏移量和实际字符数不一致。
再演示一个"随机修改文件中间某个位置"的完整例子——把二进制文件里第 3 个整数改成 999:
#include <stdio.h>
int main()
{
// 先写 10 个整数
FILE *fp = fopen("nums.dat", "wb+"); // wb+:可读可写的二进制模式
if (fp == NULL) { perror("打开失败"); return 1; }
for (int i = 0; i < 10; i++) {
fwrite(&i, sizeof(int), 1, fp);
}
// 把第 3 个元素(下标 2)改成 999
// 第 3 个整数在文件中的字节偏移 = 2 * sizeof(int)
long offset = 2L * sizeof(int);
if (fseek(fp, offset, SEEK_SET) != 0) {
perror("定位失败");
fclose(fp);
return 1;
}
int new_value = 999;
fwrite(&new_value, sizeof(int), 1, fp); // 写覆盖了原来的值
fclose(fp);
// 验证:重新读出来看
fp = fopen("nums.dat", "rb");
if (fp == NULL) { perror("打开失败"); return 1; }
int val;
while (fread(&val, sizeof(int), 1, fp) == 1) {
printf("%d ", val);
}
printf("\n");
fclose(fp);
return 0;
}输出:0 1 999 3 4 5 6 7 8 9
这个例子展示了"随机写"的完整套路:先定位(fseek)再写(fwrite)。配合 "w+"/"r+"/"a+" 这类可写模式,就能做到只改文件中的一小块数据,而不是把整个文件重写一遍——在文件很大(比如几十 GB 的数据库)时,这是性能的关键。
文件读取结束的判定
这是 C 语言文件操作中最常见的错误。很多人这样写:
while (!feof(fp)) {
fgets(buffer, sizeof(buffer), fp);
printf("%s", buffer);
}看起来对,实际上最后一行会被打印两次。为什么?
因为 feof 不是"预测"文件是否到达末尾——它是事后判断。它只有在已经尝试过读操作并且确实到达了文件末尾之后,才会返回真。流程是这样的:
- 读掉倒数第二行,文件位置指针停在最后一行之前 →
feof返回 0(还没到末尾),进入循环体 fgets读取最后一行成功 →printf打印最后一行- 回到
while条件,feof还是 0(因为还没有"尝试读但失败"的经历) - 进入循环体,
fgets尝试再读——这次读失败了(已经到了文件末尾),但fgets返回 NULL 之前不会修改 buffer 的内容 printf又把 buffer 里上次的内容打印了一遍
正确的写法是:直接判断读取函数的返回值。
// 正确:fgets 返回 NULL 表示读取结束或出错
while (fgets(buffer, sizeof(buffer), fp) != NULL) {
printf("%s", buffer);
}类似地,fgetc 判断返回值是否为 EOF,fread 判断返回值是否小于请求读取的元素个数:
// fgetc 正确用法
while ((c = fgetc(fp)) != EOF) {
putchar(c);
}
if (ferror(fp)) {
printf("I/O 错误\n");
} else if (feof(fp)) {
printf("已到达文件末尾\n");
}总结成一张表:
| 函数 | 判断循环结束的条件 |
|---|---|
fgetc | 返回值 == EOF |
fgets | 返回值 == NULL |
fscanf | 返回值 < 预期参数个数(遇到 EOF 时返回 EOF) |
fread | 返回值 < 请求读取的元素个数 |
然后在循环结束后用 feof(fp) 或 ferror(fp) 来区分是正常结束还是发生了错误。
EOF 和 feof 这两个概念要彻底分清:
EOF是一个值(通常是 -1),由读取函数返回,表示"这次读操作没读到数据"。它既可能是"文件真的读完了",也可能是"出了错误"。feof(fp)是一个函数,查询文件的"结束标志位"。只有发生过一次失败的读操作,这个标志位才会被置位。
打个比方:EOF 像"交警拦下你说前面没路了",而 feof 像"你车上的里程表记录了你曾经开到过没路的地方"。你要判断"能不能继续走",应该看交警(返回值),而不是看里程表(feof)。
什么时候才用 feof? 只有在循环结束之后,用来区分"正常读完"和"读取出错":
// 典型正确用法:读循环用返回值判断,结束后用 feof/ferror 区分原因
while (fread(buf, sizeof(int), 100, fp) == 100) {
// 处理这一批 100 个整数
}
if (feof(fp)) {
// 文件恰好读完(或者剩余不足 100 个)
// 这时还要把最后不足一批的数据读出来
} else if (ferror(fp)) {
// 发生了真正的 I/O 错误
}还有一个配套函数 clearerr(fp):清除文件的 EOF 标志和错误标志。比如你用 fgets 读到文件末尾后,又想从头再读一遍——如果只调用 rewind(fp) 还不够(rewind 会清标志,OK 那够了),但如果用 fseek(fp, 0, SEEK_SET) 移回去,EOF 标志还在,后续读操作可能行为异常。所以移动位置指针后想重新读,最好调用 clearerr(fp)。
文件缓冲区
讲到文件操作,有一个幕后机制必须了解——文件缓冲区。
ANSI C 采用"缓冲文件系统"。也就是说,系统自动为每个打开的文件在内存中分配一块"文件缓冲区"。当你调用 fprintf 向文件写数据时,数据不是立刻写到磁盘上——而是先进入这个内存缓冲区。等缓冲区满了(或者在 fclose/fflush 时),数据才会被真正地写入磁盘。
这样做的好处是性能。磁盘 I/O 是计算机系统中最慢的操作之一——和内存读写差了几个数量级。如果在缓冲区中攒够一批数据再一次性写入磁盘,I/O 次数就大大减少。
我们来算一笔账:假如你要写 100 万个字符。如果没有缓冲区,每次 fputc 都是一次磁盘写操作——100 万次磁盘 I/O,假设每次 0.1ms,那就是 100 秒。有了缓冲区(假设 4KB),数据先在内存里攒着,攒满 4KB 才写一次磁盘,大约只需要 250 次磁盘 I/O,耗时可以忽略不计。这就是缓冲区的意义——用内存的容量和速度,换磁盘的读写次数。
C 语言的文件缓冲有三种策略:
| 缓冲类型 | 何时真正读写磁盘 | 典型对象 |
|---|---|---|
| 全缓冲 | 缓冲区满了才写 | 磁盘文件(默认) |
| 行缓冲 | 遇到 \n 就写 | 交互式终端(stdout 连到终端时) |
| 无缓冲 | 立即写 | stderr(保证错误信息即时可见) |
stdin 和 stdout 在连接到终端时是行缓冲——这也是为什么 printf("请输入:") 后面必须带 \n 或调用 fflush(stdout) 才会立刻显示提示语,否则要等用户输入(输入操作会触发刷新)才看到提示。而 stderr 永远无缓冲。重定向到文件时,stdout 又会变成全缓冲——这也是为什么 a.out > log.txt 时,程序崩溃会导致日志"消失":数据还憋在缓冲区里没写出去。
缓冲区的副作用也很明显:如果程序崩溃或异常退出,缓冲区中尚未刷新的数据就丢了。这也是为什么 fclose 和 fflush 如此重要。
fflush 强制将输出缓冲区的内容写入文件:
int fflush(FILE* stream);有时候你需要在文件仍然打开的状态下确保数据已经落盘——比如记录日志时,你希望哪怕程序下一秒崩溃,日志也已经写入了。注意:fflush 只对输出流有效,对输入流的行为是未定义的。另外,高版本的 Visual Studio 中 fflush 对 stdin 的行为可能有变化,不建议依赖它来清空输入缓冲区(在 VS 里 fflush(stdin) 是"清空输入缓冲"这个非标准用法,C 标准说这是未定义行为,跨平台代码不要这么写)。
如果你需要精细控制缓冲行为,可以用 setvbuf:
char mybuf[4096];
setvbuf(fp, mybuf, _IOFBF, sizeof(mybuf)); // _IOFBF 全缓冲 / _IOLBF 行缓冲 / _IONBF 无缓冲setvbuf 必须在 fopen 之后、任何读写操作之前调用,而且要保证 mybuf 的生命周期覆盖文件打开期间(通常用全局数组或 static 数组)。
文件缓冲区解释了为什么我们要强调"一定要 fclose"——即使操作系统在程序退出时会自动关闭文件,但退出过程中的异常可能导致缓冲区没来得及刷新。在你自己的代码里显式调用 fclose,就是确保数据完整落盘的最后一道防线。
错误处理:ferror、perror 与 errno
文件操作很容易出错,一个健壮的程序必须能处理这些错误。C 语言提供的错误处理机制有:
- 返回值检查:
fopen返回NULL、fgetc返回EOF、fread返回不足、fclose返回EOF——这些都是"出错了"的信号,必须检查。 ferror(fp):检查某个流是否发生了 I/O 错误。错误一旦发生,标志会一直保持,直到调用clearerr(fp)或rewind。perror(const char *s):把errno对应的错误描述打印到stderr,前面加上你传入的字符串。perror("打开失败")会输出类似打开失败: No such file or directory的内容。errno:一个全局整数变量,记录了最近一次系统调用/库函数错误码。需要#include <errno.h>。注意它不会在成功时清零,所以正确的用法是"先失败,再查 errno",而不是"先查 errno 再判断成败"。
一个综合示范:
#include <stdio.h>
#include <errno.h>
int main()
{
FILE *fp = fopen("no_such_file.txt", "r");
if (fp == NULL) {
// errno 在 fopen 失败时被设置
printf("errno = %d\n", errno); // 比如 2(ENOENT:文件不存在)
perror("fopen 失败"); // fopen 失败: No such file or directory
return 1;
}
fclose(fp);
return 0;
}设计程序时的原则:每个可能失败的步骤都要处理失败。fopen 失败、fread 读不完整、fclose 失败……把这些检查写全,程序才能在被问"为什么运行到一半崩了"时给你一个明确答案,而不是留下一堆无法解释的垃圾数据。
综合实战:学生成绩管理系统(文本版)
把这一讲的知识串起来,写一个完整的小项目:从文件读入学生成绩、按总分排序、把结果写回文件。
#include <stdio.h>
#include <string.h>
#define MAX_STUDENTS 100
typedef struct {
char name[32];
int chinese;
int math;
int english;
int total;
} Student;
// 从文件读取学生信息,返回读取的人数
int load_students(Student stu[], int max_num, const char *filename)
{
FILE *fp = fopen(filename, "r");
if (fp == NULL) {
perror("打开成绩文件失败");
return 0;
}
int n = 0;
// fscanf 返回 4 表示 name/chinese/math/english 四个字段都读到了
while (n < max_num &&
fscanf(fp, "%s %d %d %d",
stu[n].name, &stu[n].chinese,
&stu[n].math, &stu[n].english) == 4) {
stu[n].total = stu[n].chinese + stu[n].math + stu[n].english;
n++;
}
if (ferror(fp)) {
printf("读取过程中发生错误\n");
} else if (feof(fp)) {
printf("文件读取完毕,共 %d 名学生\n", n);
}
fclose(fp);
return n;
}
// 按总分从高到低排序(简单选择排序)
void sort_by_total(Student stu[], int n)
{
for (int i = 0; i < n - 1; i++) {
int max_idx = i;
for (int j = i + 1; j < n; j++) {
if (stu[j].total > stu[max_idx].total) {
max_idx = j;
}
}
if (max_idx != i) {
Student tmp = stu[i];
stu[i] = stu[max_idx];
stu[max_idx] = tmp;
}
}
}
// 把结果写入新文件
void save_ranked(Student stu[], int n, const char *filename)
{
FILE *fp = fopen(filename, "w");
if (fp == NULL) {
perror("创建结果文件失败");
return;
}
fprintf(fp, "%-10s %5s %5s %5s %5s\n", "姓名", "语文", "数学", "英语", "总分");
for (int i = 0; i < n; i++) {
fprintf(fp, "%-10s %5d %5d %5d %5d\n",
stu[i].name, stu[i].chinese, stu[i].math,
stu[i].english, stu[i].total);
}
fclose(fp);
printf("排名结果已写入 %s\n", filename);
}
int main()
{
Student stu[MAX_STUDENTS];
int n = load_students(stu, MAX_STUDENTS, "scores.txt");
if (n == 0) {
printf("没有读到任何学生数据,程序结束\n");
return 1;
}
sort_by_total(stu, n);
printf("\n===== 按总分排名 =====\n");
printf("%-10s %5s %5s %5s %5s\n", "姓名", "语文", "数学", "英语", "总分");
for (int i = 0; i < n; i++) {
printf("%-10s %5d %5d %5d %5d\n",
stu[i].name, stu[i].chinese, stu[i].math,
stu[i].english, stu[i].total);
}
save_ranked(stu, n, "ranked.txt");
return 0;
}配套的 scores.txt 格式(每行四个字段):
张三 90 85 92
李四 78 95 88
王五 82 76 91
赵六 95 90 89
这个例子融合了:fopen 模式选择、fscanf 返回值驱动的循环读取、ferror/feof 的收尾判断、fprintf 的格式化为报表。读懂它,你就掌握了文本文件读写的完整套路。
注意一个容易遗漏的点:读文件时 fscanf 的 %s 遇到空白就停,所以姓名不能含空格;如果姓名可能含空格(比如 "张 三"),就要改成 %[^\n] 这种格式,或者改用 fgets 逐行读再 sscanf 解析——这正好把前面讲的 fgets + sscanf 组合用上了:
char line[256];
char name[32];
int c, m, e; // 三个成绩字段
FILE *fp = fopen("scores.txt", "r"); // 打开成绩文件
if (fp == NULL) return 1;
while (fgets(line, sizeof(line), fp) != NULL) {
// 从字符串中解析出各字段——sscanf 和 fscanf 格式语法完全一样
if (sscanf(line, "%s %d %d %d", name, &c, &m, &e) == 4) {
// 处理这条记录
}
}
fclose(fp);文本文件 vs 二进制文件:终极对比
把这一讲反复出现的对比集中整理一下:
| 维度 | 文本文件 | 二进制文件 |
|---|---|---|
| 存储形式 | 字符编码(ASCII/UTF-8) | 内存字节原样拷贝 |
| 可读性 | 记事本能看 | 记事本乱码 |
| 数值精度 | 浮点数转文本可能丢精度 | 原样保存,不丢精度 |
| 空间 | 通常更大("10000"占5字节) | 紧凑(int 占4字节) |
| 速度 | 需编码/解码转换 | 直接搬运,更快 |
| 跨平台 | 好(文本格式通用) | 差(字节序、结构体对齐问题) |
| 随机访问 | 偏移量不等于字符数 | 偏移量就是字节数 |
| 打开模式 | 不带 b | 必须带 b(如 "rb"/"wb") |
| 典型场景 | 配置、日志、报表、CSV | 存档、缓存、数据库文件 |
选择建议:数据要给人看、要跨程序交换 → 文本;数据只给自己的程序读写、追求速度和体积 → 二进制。
到这里,文件操作的核心内容就讲完了。从 fopen 选择合适模式打开文件,到八种读写函数的场景选择,再到用 fseek 在文件中自由跳转、用正确的循环条件安全读出所有数据、理解缓冲区保护数据完整性——你手里的 C 程序现在真的能和外部世界对话了。掌握了这些,写一个简单的文本编辑器或成绩管理系统就水到渠成了。
思考题
- 用
"w"模式打开一个已存在的文件会发生什么?如果只想"追加内容"应该用哪个模式?如果既想读又想改原有内容而不清空,应该用哪个模式? - 为什么
fgetc的返回值要用int而不是char?如果把返回值赋给char变量再比较EOF,在什么情况下会出错? while (!feof(fp))为什么是错的?请写出fgets、fgetc、fscanf、fread四种循环的正确写法。fgets读到"超长行"时会发生什么?缓冲区里有没有\0?剩下的内容下次还能读到吗?- 为什么
fread/fwrite的第二个参数和第三个参数建议写成sizeof(结构体), 1而不是1, sizeof(结构体)? - 在 Windows 上以文本模式读写包含
\n的二进制数据会出什么问题?怎么避免? rewind(fp)和fseek(fp, 0, SEEK_SET)有什么区别?哪个会清除错误标志?- 为什么日志程序要用
fflush或直接写stderr?如果你写了一个记账程序,客户说"断电后账目丢了",你会从哪里开始排查?
参考答案与详解
1. 用 "w" 模式打开一个已存在的文件会发生什么?如果只想"追加内容"应该用哪个模式?如果既想读又想改原有内容而不清空,应该用哪个模式?
"w":文件已存在时会把原有内容全部清空(truncate),然后从头开始写——这是最危险的打开模式之一,一旦用错数据瞬间蒸发且无恢复手段。- 只想在末尾追加内容(不破坏已有数据):用
"a"(追加模式),文件指针总在末尾。 - 既想读又想改原有内容且不清空:用
"r+"(读写、打开已有文件,从开头读写,不清空)。注意它要求文件必须已存在,否则返回 NULL。"w+"会清空,不符合"不清空"的要求。
FILE* fp;
fp = fopen("data.txt", "w"); // 清空原内容,从头写
fp = fopen("data.txt", "a"); // 追加,不清空
fp = fopen("data.txt", "r+"); // 文件必须存在,可读写,不清空2. 为什么 fgetc 的返回值要用 int 而不是 char?如果把返回值赋给 char 变量再比较 EOF,在什么情况下会出错?
fgetc 返回的是"读到的那个字符",也可能是"文件结束哨兵" EOF(通常是 -1)。char 取值只有 256 种可能(而且无符号 char 是 0~255,根本表示不了负数 -1),无法同时容纳"字符值"和"EOF"这两个不同的东西。
出错场景:用无符号 char 存 fgetc 的结果时,读到合法字符 0xFF(扩展 ASCII)会变成 255,而真正的 EOF(-1)在比较时会被隐式提升——(char)255 和 EOF 的 int 值对不上或混淆,导致要么"把没读完误判为读完",要么"读到文件尾却没识别出来"。所以标准写法是:
int c; // 必须是 int,既能存字符值(0~255),也能存 EOF(-1)
while ((c = fgetc(fp)) != EOF) {
putchar(c);
}3. while (!feof(fp)) 为什么是错的?请写出 fgets、fgetc、fscanf、fread 四种循环的正确写法。
feof 是事后标志——它只有在"某次读操作真的失败了(到文件末尾)"之后才被置位,不能提前判断是否还有数据。用 while (!feof(fp)) 会在最后一次"读失败"前多进一次循环,把上一次读到的(或缓冲区残留的)数据重复处理一遍(比如最后一行重复打印)。
正确写法一律看读函数的返回值:
// fgetc:读到 EOF 停止
int c;
while ((c = fgetc(fp)) != EOF) { putchar(c); }
// fgets:读到 NULL(文件结束或出错)停止
char buf[256];
while (fgets(buf, sizeof buf, fp) != NULL) { fputs(buf, stdout); }
// fscanf:成功匹配到预期个数才继续
int v;
while (fscanf(fp, "%d", &v) == 1) { printf("%d\n", v); }
// fread:实际读到的个数不足请求数时停止
int buf[100];
size_t n;
while ((n = fread(buf, sizeof(int), 100, fp)) > 0) {
for (size_t i = 0; i < n; i++) printf("%d ", buf[i]);
}读完后再用 feof(fp)/ferror(fp) 区分"正常读完"还是"出错"。
4. fgets 读到"超长行"时会发生什么?缓冲区里有没有 \0?剩下的内容下次还能读到吗?
fgets(buf, n, fp) 最多读 n-1 个字符,然后自动在末尾补 \0(所以缓冲区里一定以 \0 结尾,读到的内容就是合法的 C 字符串)。当一行比 n-1 还长时:
- 它只读走前
n-1个字符(不读换行符,因为没到行尾);缓冲区末尾有\0; - 剩下那半行留在文件里,文件位置指针停在断点处;下次再调用
fgets会从断点继续读,接上后半截。
所以 fgets 严格说不是"读一整行",而是"读一小块",只是通常一行放得下。想确认"这次是否读完一整行",要检查读到的内容末尾是不是 \n:if (buf[strcspn(buf, "\n")] == '\0') /* 没读到换行 = 行还没读完 */。用 fgets+fputs 逐块拷贝时,即使断行,拼接结果依然正确。
5. 为什么 fread/fwrite 的第二个参数和第三个参数建议写成 sizeof(结构体), 1 而不是 1, sizeof(结构体)?
这两个参数的顺序是 (ptr, size, nmemb),返回值是"完整元素个数"。建议写 fwrite(&stu, sizeof(Student), 1, fp) 是为了让返回值语义最清晰:
- 写成
sizeof(Student), 1:返回值为 0 或 1,含义是"成功写了 0/1 个完整对象",一眼看懂,且天然避免"写到一半文件里留下半个结构体"的脏数据更易被发现; - 反过来写
fwrite(&stu, 1, sizeof(Student), fp):返回值是"一次性搬了多少个 1-字节元素",即实际字节数。文件被截断时会出现"写了 40 个字节"这种模糊中间态,很难判断是不是完整对象。
// 推荐:一次一个完整对象,返回值只有 0 或 1
size_t n = fwrite(&stu, sizeof(Student), 1, fp);
if (n != 1) { /* 没写完整 */ }6. 在 Windows 上以文本模式读写包含 \n 的二进制数据会出什么问题?怎么避免?
Windows 文本模式会把写入的 \n(0x0A)自动转换成 \r\n(0x0D 0x0A)写进文件,读取时再反向转换。而二进制数据里字节 0x0A 只是普通数据(比如 int 的某个字节恰好是 0x0A)——文本模式会把它的内容悄悄改掉:
- 写侧:文件里本应是
0x0A的地方变成0x0D 0x0A,多了一个字节,文件字节数对不上,数据被篡改; - 读侧:反向转换让读回来的字节又变了。
避免方法:读写二进制数据(数字、结构体、非文本内容)时一律用带 b 的模式,如 "rb"/"wb"/"ab"。b 表示二进制模式,不做任何行尾转换,写入什么就是什么。
FILE *fp = fopen("data.dat", "wb"); // 写二进制必须用 "wb"
int value = 10000;
fwrite(&value, sizeof(int), 1, fp);
fclose(fp);7. rewind(fp) 和 fseek(fp, 0, SEEK_SET) 有什么区别?哪个会清除错误标志?
fseek(fp, 0, SEEK_SET):只把文件位置指针移到开头,不会清除错误标志/EOF 标志;rewind(fp):等价于"fseek(fp, 0, SEEK_SET)+clearerr(fp)",即除了回到开头,还会清除错误标志和 EOF 标志。它返回void(无返回值)。
所以当你想"回到开头重新读"且希望错误/EOF 状态一并清零时,rewind 更省事、更干净。若只用 fseek 移回开头,之前的 EOF 标志可能还挂着,继续读会行为异常。
// 读完全部内容后想从头再读一遍:
rewind(fp); // 既回到开头,又清掉 EOF/错误标志
// 等价于:
// fseek(fp, 0, SEEK_SET);
// clearerr(fp);8. 为什么日志程序要用 fflush 或直接写 stderr?如果你写了一个记账程序,客户说"断电后账目丢了",你会从哪里开始排查?
-
为什么用
fflush/ 写stderr:磁盘文件默认是全缓冲,数据先攒在内存缓冲区里,满了才真正写盘。日志/记账这类"必须即时落盘"的写入,如果不清缓冲区就崩溃或断电,内存里那部分数据就丢了。fflush(fp)强制把缓冲区内容刷到磁盘;而stderr默认无缓冲,写进去立即生效,所以fprintf(stderr, ...)适合输出即时的错误和日志。 -
记账程序"断电丢账"排查路线(按可能性从高到低):
- 缓冲区没刷:写入是否只是调用了
fprintf/fwrite,但从没fflush/fclose?断电时缓冲区数据全丢。→ 记账写入后立即fflush,或改用无缓冲策略; fclose返回值没检查:fclose失败(如磁盘满)时数据可能没写全,但没被发现;- 写盘方式:即使
fflush到了 C 库,数据可能只到操作系统页缓存,断电依旧有丢失窗口——关键账目应考虑配合"写后校验/顺序写/事务日志/双写"等更强保证; - 先复现,确认到底丢在哪一层(应用缓冲?库缓冲?OS 缓存?)。
- 缓冲区没刷:写入是否只是调用了
// 记账关键写入的稳健做法:写后立即刷新,并检查返回值
FILE *fp = fopen("ledger.txt", "a");
if (fp == NULL) { /* 报错 */ }
fprintf(fp, "入账: %d\n", amount);
fflush(fp); // 关键数据写完立刻刷盘
int r = fclose(fp);
if (r != 0) { /* 关闭也出错,要处理 */ }
还没有评论 — 第一条由你来留。