第13章:文件输入 / 输出
文件让数据在程序结束后继续存在。标准 I/O 把文件、终端等输入输出来源组织成流,用统一接口完成打开、读写、定位和关闭。
本章的核心是:选择合适的打开模式,让读取结果驱动处理流程,并检查写入与关闭是否真正成功。 文件存在、流已打开、数据已经读到、缓冲区已经提交,是不同状态。
1. 文件、流与 FILE *
1.1 文件名用于打开,流指针用于后续操作
#include <stdio.h>
FILE *input = fopen("notes.txt", "r");
fopen 接收文件名和模式字符串,成功时返回 FILE *,失败时返回空指针。
FILE * 指向标准库管理的流对象,不是“整个文件内容在内存中的首地址”。库可以通过它维护缓冲、当前位置、读写错误和文件结束状态。
文件名 → fopen → 流对象 → 读取、写入、定位 → fclose
不要自己访问或修改 FILE 的内部成员,也不要通过指针算术移动 FILE * 来移动文件位置。
1.2 三个标准流
| 流 | 通常用途 | 常用接口 |
|---|---|---|
stdin |
输入数据 | getchar、scanf、fgets |
stdout |
输出正常结果 | putchar、printf、puts |
stderr |
输出错误诊断 | fprintf(stderr, ...)、fputs(..., stderr) |
这些流通常在程序启动时已经准备好,也可以作为普通标准 I/O 函数的流参数。
它们不一定连接键盘和屏幕。例如重定向或管道可以让标准输入来自文件,让标准输出进入另一个程序。
把错误信息送到 stderr,可以与正常结果分开;具体是否显示在屏幕上,仍取决于运行环境如何连接或重定向这些流。
2. 文本模式与二进制模式
2.1 文件模式与数据表示分开理解
| 问题 | 可选方式 | 决定什么 |
|---|---|---|
| 以什么模式打开 | 文本模式、二进制模式 | 是否进行实现规定的文本映射 |
| 用什么表示数据 | 字符文本、对象的二进制表示等 | 文件字节如何被解释 |
| 用什么函数处理 | fprintf、fgets、fread 等 |
是否转换数值、扫描格式或按块传输 |
所有文件最终都由位构成。所谓文本数据,通常是把内容编码成字符;所谓数值的二进制数据,则可能直接使用某种数值编码或对象表示。
fprintf(file, "%d", 123); // 写入数字的文本表示。
fwrite(&value, sizeof value, 1, file); // 写入对象表示。
把文件用 "wb" 打开,并不会使 fprintf 自动变成数值的原始二进制输出。fread 也可以读取文本文件的字节,不只用于数值文件。
2.2 文本模式可能转换行结束表示
文本流在程序内部使用 \n 表示换行,实现可能把外部的 \r\n 等表示转换为 \n,写出时再进行相应映射。
二进制模式用于按该实现的二进制流规则处理字节,避免文本模式的换行等转换,适合复制任意文件、保存特定文件格式。
一些常见环境中文本模式和二进制模式行为相同,但可移植代码仍应根据用途选择模式。
3. 打开模式:能否读取、是否创建、是否清空
| 模式 | 可读 | 可写 | 文件不存在 | 已有内容 |
|---|---|---|---|---|
"r" |
是 | 否 | 打开失败 | 保留 |
"w" |
否 | 是 | 创建 | 打开成功即截断为零长度 |
"a" |
否 | 是 | 创建 | 保留,写入追加到末尾 |
"r+" |
是 | 是 | 打开失败 | 保留 |
"w+" |
是 | 是 | 创建 | 打开成功即截断为零长度 |
"a+" |
是 | 是 | 创建 | 保留,写入始终追加到末尾 |
加入 b 表示二进制模式,例如 "rb"、"wb"、"rb+" 或 "r+b"。
C11 的 x 可与适用的写模式组合,例如 "wx"、"wbx":只创建新文件,若目标已经存在则打开失败。它可以避免误覆盖已有目标,但不应被简单理解成所有环境下通用的文件锁。
"w" 的截断发生在打开成功时,不需要等第一次写入。若目标就是输入文件,可能在读取之前就已经毁掉原内容。
追加模式下,即使把读取位置移到文件开头,后续写入仍追加到末尾。要原地修改已有内容,通常考虑 "r+" 或其二进制形式。
相对路径一般相对于进程当前工作目录解释,不一定相对于源文件目录或可执行文件目录。
4. 先检查打开结果,最后分别关闭每个流
FILE *file = fopen("notes.txt", "r");
if (file == NULL) {
fputs("cannot open notes.txt\n", stderr);
return EXIT_FAILURE;
}
/* 使用 file。 */
if (fclose(file) != 0) {
fputs("cannot close notes.txt\n", stderr);
return EXIT_FAILURE;
}
EXIT_SUCCESS、EXIT_FAILURE 由 <stdlib.h> 提供。
fclose 会完成关闭过程,必要时提交尚未写出的输出缓冲。成功返回 0,失败返回 EOF,所以最后一次写入看起来成功,不代表关闭时也不会报告错误。
调用关闭后,就不能继续使用那个流,包括再次关闭同一流。关闭失败也不意味着可以把原 FILE * 当作仍然正常打开的流重试操作。
同时打开两个流时,不要用短路表达式代替两次必要的关闭:
int close_input = fclose(input);
int close_output = fclose(output);
if (close_input != 0 || close_output != 0) {
/* 至少有一次关闭失败,但两次关闭都已尝试。 */
}
直接写 fclose(input) != 0 || fclose(output) != 0,会在左侧为真时跳过关闭右侧流。
在普通辅助函数中,return 只结束这次函数调用,exit 则结束整个程序。最初调用的 main 返回退出状态,与正常程序终止机制相联系;仍应主动完成需要检查结果的文件清理。
5. 正确的读取循环由读取结果驱动
5.1 字符输入使用 int
int ch;
while ((ch = getc(input)) != EOF) {
if (putc(ch, output) == EOF) {
/* 写入失败。 */
break;
}
}
getc 与 fgetc 读取下一个字符,以 unsigned char 的值转换成 int 返回;遇到文件结束或错误时返回 EOF。
不能先缩窄到 char,再尝试区分字符与 EOF。例如二进制字节 0xFF 不能因为某个平台上 char 有符号,就被误认为输入结束。
getc、putc 在某些实现中可能是宏。流参数应使用稳定的指针表达式,不要把带递增等副作用的复杂表达式放进去。
5.2 feof 不会提前预知输入结束
// 不要这样设计:
// while (!feof(input)) {
// ch = getc(input);
// process(ch);
// }
文件结束指示器通常在一次读取尝试发现结束时才被设置。刚处理完最后一个有效字符,并不意味着循环条件已经能提前知道后面没有数据。
应先读,再判断这次是否成功,成功才处理。
5.3 结束与错误要分开检查
if (ferror(input)) {
fputs("read error\n", stderr);
} else if (feof(input)) {
/* 正常读到末尾。 */
}
输入函数返回结束信号,可能是正常结束,也可能是读取失败。feof 和 ferror 检查的是流上记录的状态,不是文件中的某个特殊字节。
clearerr 清除错误指示器与文件结束指示器,但不会自动修复底层问题、丢弃坏数据或把文件位置移回开头。
6. 按字符、按行、按格式、按块选择接口
| 需求 | 输入 | 输出 | 成功或失败的判断 |
|---|---|---|---|
| 单个字符 | getc、fgetc |
putc、fputc |
字符结果或 EOF |
| 字符串、文本片段 | fgets |
fputs |
输入返回地址或 NULL;输出返回非负数或 EOF |
| 格式化数据 | fscanf |
fprintf |
输入返回成功赋值项数;输出失败时返回负数 |
| 原始数据块 | fread |
fwrite |
返回完整读写的项数 |
6.1 按行读取要考虑长行
char line[256];
while (fgets(line, sizeof line, input) != NULL) {
if (fputs(line, output) == EOF) {
break;
}
}
fgets 最多保存“容量减 1”个输入字符,追加 \0;能读到换行时保留换行。fputs 不额外补换行,两者适合配合输出文本。
数组装不下整行时,一次成功调用只得到片段。若程序只是原样输出片段,可以继续读取;若需要“逐行搜索某个子串”,就不能把每个片段直接当成完整一行,否则可能漏掉跨片段的匹配。
fgets 可以从流中读到嵌入空字符,但后续 fputs 会在第一个空字符处停止。因此,任意二进制数据的复制更适合块 I/O。
6.2 fscanf 检查成功赋值的数量
char name[32];
double price;
int matched = fscanf(input, "%31s %lf", name, &price);
if (matched == 2) {
printf("%s: %.2f\n", name, price);
}
它与 scanf 使用类似格式,只是显式提供输入流。字段宽度仍需为字符串末尾的 \0 留空间。
返回 1 表示只成功赋值了一项,返回 0 表示匹配失败且没有完成赋值;在第一次转换前发生输入失败时,可能返回 EOF。
格式匹配失败可能不消费那个不合适的字符。若直接重复同一调用,程序可能一直卡在原位置;必须设计跳过、报告或终止策略。
空白格式并不把数据限定在同一行。需要严格验证每行格式时,先读取完整行,再解析内容通常更清楚。
6.3 输出格式仍由程序控制
fprintf(output, "%s\n", text);
fprintf(stderr, "cannot read %s\n", filename);
流参数在最前面;putc(ch, output) 则把流放在第二个参数位置,不能混淆。
外部文本通过 %s 等格式传入,不要把文件内容本身直接当作格式字符串。
7. fread 与 fwrite:返回的是完整项数
size_t fwrite(const void *ptr, size_t size, size_t count, FILE *stream);
size_t fread(void *ptr, size_t size, size_t count, FILE *stream);
这里展示核心参数形状;标准原型还可以包含 restrict 等限定。
ptr:内存中数据的起始位置。size:每项字节数。count:希望传输的项数。- 返回值:完整传输的项数,不一定等于字节数。
double values[4] = {1.0, 2.0, 3.0, 4.0};
size_t written = fwrite(values, sizeof values[0], 4, output);
期望返回 4,不是 sizeof values。如果把每项大小设成 1,返回的项数才等于字节数:
unsigned char buffer[4096];
size_t bytes = fread(buffer, 1, sizeof buffer, input);
短读可能来自正常文件结束,也可能来自错误;结合 ferror 判断。短写需要按失败处理,不能当作已成功保存完整数据。
若按多字节对象读取时得到不完整的一项,那一项不能当作成功读取的对象使用。解析文件时还要防止信任未经验证的外部对象表示。
8. 完整示例:复制任意文件并保留已有目标
下面的程序使用 C11 的 "wbx" 创建新目标;目标已经存在时拒绝覆盖。源文件和目标可以包含任意字节,包括空字符。
#include <stdio.h>
#include <stdlib.h>
int copy_stream(FILE *input, FILE *output);
int main(int argc, char *argv[])
{
if (argc != 3) {
fputs("usage: copy source destination\n", stderr);
return EXIT_FAILURE;
}
FILE *input = fopen(argv[1], "rb");
if (input == NULL) {
fputs("cannot open source\n", stderr);
return EXIT_FAILURE;
}
FILE *output = fopen(argv[2], "wbx");
if (output == NULL) {
fputs("cannot create destination\n", stderr);
fclose(input);
return EXIT_FAILURE;
}
int status = copy_stream(input, output)
? EXIT_SUCCESS : EXIT_FAILURE;
if (fclose(input) != 0) {
status = EXIT_FAILURE;
}
if (fclose(output) != 0) {
status = EXIT_FAILURE;
}
if (status != EXIT_SUCCESS) {
fputs("copy failed\n", stderr);
}
return status;
}
int copy_stream(FILE *input, FILE *output)
{
unsigned char buffer[4096];
size_t bytes;
while ((bytes = fread(buffer, 1, sizeof buffer, input)) > 0) {
if (fwrite(buffer, 1, bytes, output) != bytes) {
return 0;
}
}
return !ferror(input);
}
调用示意:
./copy source.bin target.bin
程序每次只写入本轮实际读到的字节数,最后不足一整块的数据不会丢失。空文件也能正常处理。
若中途失败,目标可能只包含部分数据;这个示例没有实现失败回滚。若改用 "wb",则必须另行处理覆盖策略与源目标实际是同一文件的问题。
比较两个路径字符串是否相同,不能完全判断它们是否指向同一文件。不同路径、链接等都可能引用同一对象。
9. 缓冲与刷新:写入库缓冲不等于已经落盘
9.1 标准 I/O 在后台组织数据块
输入:文件或设备 → 标准库缓冲 → 程序取得字符或数据块
输出:程序 → 标准库缓冲 → 文件或设备
使用逐字符函数,也不代表底层每次只发起一个字节的系统调用。具体缓冲结构与大小由实现决定,不必假定每个流一定有固定数量的缓冲区。
9.2 fflush 提交待写出的输出
if (fflush(output) == EOF) {
/* 输出提交失败。 */
}
fflush(NULL) 用于刷新符合条件的所有输出流。成功返回 0,失败返回 EOF。
常见用途是输出不带换行的提示后,主动刷新标准输出:
fputs("name: ", stdout);
fflush(stdout);
标准 C 不把 fflush(stdin) 定义成“清空键盘输入”的通用办法。需要丢弃输入时,应使用适当的读取逻辑。
刷新标准库输出缓冲,不等于获得了断电后数据仍已持久保存的保证;更强的持久化要求属于具体系统接口与存储语义。
9.3 setvbuf 在其他流操作之前设置缓冲
if (setvbuf(file, NULL, _IOFBF, 4096) != 0) {
/* 缓冲设置失败。 */
}
| 模式 | 含义 |
|---|---|
_IOFBF |
全缓冲 |
_IOLBF |
行缓冲 |
_IONBF |
无缓冲 |
应在打开流后、进行其他普通流操作之前调用。返回 0 表示成功,非零表示失败。
若传入自己提供的缓冲区,该缓冲区必须在流使用它期间一直有效。不能把即将退出的辅助函数局部数组交给仍会继续使用的流。
10. 更新流:读取与写入之间需要正确切换
"r+"、"w+"、"a+" 都是更新模式,但不是读写操作可以随意交错。
| 切换方向 | 标准规则要点 |
|---|---|
| 写入后改为读取 | 中间需要 fflush 或文件定位操作 |
| 读取后改为写入 | 中间需要文件定位操作;前一次读取遇到文件结束时有例外 |
定位操作包括适用的 fseek、fsetpos、rewind。只调用 fflush 不能一般性地替代“读取后改为写入”所需的定位。
追加模式可读全文件,但每次写入仍追加到末尾。读取前主动定位,可以避免依赖初始读取位置的实现差异。
11. 随机访问:移动文件位置指示器
11.1 fseek 指定起点与偏移
int fseek(FILE *stream, long offset, int origin);
| 起点常量 | 含义 |
|---|---|
SEEK_SET |
文件开始 |
SEEK_CUR |
当前位置 |
SEEK_END |
文件末尾 |
fseek(file, 0L, SEEK_SET);
fseek(file, 10L, SEEK_SET);
fseek(file, -2L, SEEK_CUR);
成功返回 0,失败返回非零值,不要只检查是否恰好等于 -1。不是所有流都可定位,二进制流也不必在所有实现上支持有意义的末尾相对定位。
定位成功会清除文件结束指示器,并撤销尚未读出的推回字符。不要把它当作自动清除所有错误状态的办法。
11.2 ftell 取得当前位置
long position = ftell(file);
if (position == -1L) {
/* 取得位置失败。 */
}
对于二进制流,通常可以按从开始位置计算的字符单位理解;文本流中的结果应当视为恢复位置所需的标记,不应随意对它加减来计算外部字节偏移。
文本流的可移植定位方式主要是:使用零偏移,或把同一流先前成功取得的 ftell 值配合 SEEK_SET 恢复。
不要把 fseek(..., SEEK_END) 加 ftell 当作适用于所有流和所有文件的通用大小查询方案。
11.3 rewind 回到开头
rewind(file);
它将位置移到开始,并清除文件结束与错误指示器,但没有直接返回值。需要检查定位成功与否时,考虑适当的可报告结果的接口。
11.4 fgetpos 与 fsetpos 保存和恢复位置
fpos_t saved;
if (fgetpos(file, &saved) == 0) {
/* 读取或进行其他处理。 */
if (fsetpos(file, &saved) != 0) {
/* 恢复失败。 */
}
}
fpos_t 是位置类型,不保证就是整数;实现可以让它保存位置与必要的文本转换状态。不要对它做普通字节偏移运算。
两个函数都以返回 0 表示成功,非零表示失败。位置标记应来自相应流的成功保存操作。
12. 完整示例:按下标读取二进制数值
下面使用临时二进制流演示写入、切换方向、按固定项大小定位,再读取单个值:
#include <stdio.h>
#include <stdlib.h>
int main(void)
{
double values[] = {1.25, 2.5, 3.75, 5.0};
FILE *file = tmpfile();
if (file == NULL) {
return EXIT_FAILURE;
}
int status = EXIT_FAILURE;
if (fwrite(values, sizeof values[0], 4, file) != 4) {
goto cleanup;
}
/* 固定下标 2 与这里的项大小都能安全表示为 long。 */
long offset = 2L * (long)sizeof values[0];
if (fseek(file, offset, SEEK_SET) != 0) {
goto cleanup;
}
double value;
if (fread(&value, sizeof value, 1, file) != 1) {
goto cleanup;
}
printf("index 2: %.2f\n", value);
status = EXIT_SUCCESS;
cleanup:
if (fclose(file) != 0) {
status = EXIT_FAILURE;
}
return status;
}
输出:
index 2: 3.75
tmpfile 创建临时二进制更新流,关闭时删除临时文件。这里用定位操作完成从输出到输入的切换。
对来自输入的任意下标,要先检查下标范围、乘法是否溢出,以及计算结果能否由 long 表示。简单强制转换不会自动消除这些问题。
13. ungetc:把一个字符推回输入流
int ch = getc(input);
if (ch != EOF && ch != ':') {
/* 处理普通字符。 */
} else if (ch == ':') {
if (ungetc(ch, input) == EOF) {
/* 推回失败。 */
}
}
下一次读取会先取得推回的字符。标准保证至少支持一个字符的推回能力,不应假定可以无限推回整行。
它不等于修改磁盘文件,也不支持推回 EOF。成功推回会清除文件结束指示器,适合解析时“多读了一个分隔符,再留给下一步”。
14. 数据持久化还需要定义文件格式
直接用 fwrite 写出 int 或 double,能在相容实现中保留对象表示,省去数值到文本的转换。但不能由此保证跨机器、跨编译器、跨版本可读。
需要考虑:
- 类型大小和整数的字节顺序。
- 浮点数表示。
- 结构的填充、对齐和成员布局。
- 指针只表示当前进程中的地址,不能原样保存后当作下一次运行的有效链接。
文本数值也不必然损失精度,关键在于选择足够的有效数字和合适格式;默认 %f 的有限小数位不能代表所有文本编码方法。
需要长期保存或交换数据时,应显式定义字段、长度、编码、版本和错误处理规则,而不只依赖内存中的偶然布局。
15. 文件处理的基本检查顺序
| 阶段 | 要确认的事情 |
|---|---|
| 打开之前 | 路径、模式、是否允许覆盖、源目标关系 |
| 打开之后 | FILE * 是否为空 |
| 每次读取 | 这次究竟成功取得了多少项 |
| 每次写入 | 需要的内容是否完整交给流 |
| 循环结束 | 正常结束、格式不匹配还是 I/O 错误 |
| 定位时 | 流是否支持,返回值是否成功,偏移是否可表示 |
| 收尾时 | 已打开流是否都被关闭,关闭是否报告错误 |
把数据处理与流管理分开,可以让同一个处理函数用于磁盘文件、标准输入或临时流。函数只需要知道如何读取和报告结果,调用者负责决定来源、目标与生存期。

