C 的 <stdio.h> 用 FILE * 表示带缓冲和状态的流;程序打开流,按字符、行、格式或字节块传输数据,再关闭流。
EOF 不是循环条件的预告,fread() 和 fwrite() 也不保证完成请求;忽略返回值或 fclose() 错误会让截断文件看似成功。
让每次 I/O 的返回值驱动控制流,用 feof() 与 ferror() 解释短读,并明确处理打开、写入和关闭失败。
是什么,为什么存在
C 文件 I/O 是 <stdio.h> 提供的一组标准接口,用于在程序与文件、终端等外部对象之间传输数据。打开对象后,库会返回一个指向 文件流(file stream) 的 FILE *。这个值不是文件内容,也不是可由程序检查成员的公开结构;它是后续读写、定位和状态查询所需的句柄。
流把不同外部对象统一成同一种接口。fgetc()、fgets()、fprintf()、fread() 等函数都接收 FILE *,因此相同的处理逻辑可以作用于普通文件或标准流 stdin、stdout、stderr。标准库还可以在程序与宿主环境之间缓冲数据,减少每个字符都触发底层操作的需要。
你会在读取配置、逐行处理日志、导入文本数据、复制任意字节和保存自定义格式时使用这些接口。文件名、权限、目录和并发修改由宿主环境决定;标准 C 只规定流接口及其可观察行为。若程序需要遍历目录、读取元数据或进行异步 I/O,应使用平台 API 或更高层库,而不是把这些能力假定为 stdio 的一部分。
文件 I/O 的核心契约不是“调用函数”,而是完整管理生命周期和状态:选择不会破坏数据的打开模式,检查每次传输的结果,区分文件结束与错误,并检查最终关闭。任何一步失败,都可能使已读取的数据不完整,或使写出的文件只包含前缀。
| 需求 | 接口 | 返回值要表达的事实 |
|---|---|---|
| 打开或创建 | fopen() | FILE * 或 NULL |
| 读取字符或行 | fgetc()、fgets() | 数据、EOF 或 NULL |
| 传输对象块 | fread()、fwrite() | 完成的元素数量 |
| 格式化文本 | fprintf()、fscanf() | 写入字符数或成功转换数,失败时返回特定值 |
| 定位 | fseek()、ftell() | 成功状态或可供恢复的位置 |
| 结束生命周期 | fclose() | 0 或 EOF |
工作原理
流的生命周期与状态
fopen(path, mode) 把外部文件与新流关联。成功时,流具有访问方式、当前位置、缓冲状态、文件结束指示器和错误指示器;失败时返回 NULL,并可能设置 errno。perror() 可以把当前 errno 解释成面向人的诊断,但程序逻辑仍应通过自己的状态码把失败传给调用者。
流只在成功打开后、关闭前有效。fclose() 会处理待写缓冲并解除关联,之后再把旧指针传给任何 I/O 函数都没有合法语义。关闭也可能失败,例如先前缓冲的输出在最终提交时出错,所以写入路径不能把“最后一次 fwrite() 成功”当成完整成功条件。
stdin、stdout 和 stderr 在宿主环境为程序提供时已经打开。它们分别用于标准输入、标准输出和诊断输出,但不保证连接到键盘或屏幕;重定向后,它们可以连接文件或管道。不要根据流的名称推断设备类型或交互行为。
打开模式是数据契约
模式的首字符决定基本访问方式。r 要求文件已存在,w 会创建文件或立即截断已有文件,a 会创建文件或把每次输出写到当时的文件末尾。添加 + 会得到更新流(update stream),允许输入与输出;添加 b 会请求二进制模式。
| 模式 | 输入 | 输出 | 已有内容 | 初始位置 |
|---|---|---|---|---|
r | 是 | 否 | 保留 | 开头 |
w | 否 | 是 | 截断 | 开头 |
a | 否 | 是 | 保留,输出追加 | 末尾 |
r+ | 是 | 是 | 保留 | 开头 |
w+ | 是 | 是 | 截断 | 开头 |
a+ | 是 | 是 | 保留,输出追加 | 输入从开头开始 |
二进制数据应使用 rb、wb、r+b 等带 b 的模式。在某些系统上,文本模式会转换换行或把部分字节当作特殊标记;二进制模式则用于保留文件中的字节序列。POSIX 系统通常不区分这两种模式,但可移植代码仍应写出真实意图。
C23 还支持在 w 模式中加入 x,例如 wbx,要求独占创建;若文件已经存在,打开失败。这适合“不得覆盖已有目标”的契约。它不替代路径授权、符号链接策略或目录边界检查,这些仍属于应用和宿主环境。
按字符、行和格式读取
fgetc() 返回读取的字节转换成的 unsigned char 值,再提升为 int;无法再提供字符时返回 EOF。因此接收变量必须是 int,否则某个有效字节可能与 EOF 混淆。fputc() 也返回写出的字符或 EOF,调用者应检查失败。
fgets(buffer, size, stream) 最多存入 size - 1 个字符,并追加 \0。如果读到换行符且空间足够,换行符会留在缓冲区中。缓冲区已满却尚未读到换行时,只得到一行的片段;需要完整记录的程序必须继续拼接或把超长行作为错误处理。
fprintf() 适合产生有明确格式的文本。fscanf() 会跳过或保留空白,取决于格式说明符,而且成功转换数可能小于请求数。对于需要验证整行结构的输入,通常先用 fgets() 取得有界文本,再用解析函数检查字段、范围和尾随字符,错误边界更清楚。
块传输与短计数
fread(pointer, size, count, stream) 尝试读取 count 个元素,每个元素占 size 字节,并返回完整读到的元素数。fwrite() 使用相同的计数约定。返回值是元素数,不是字节数;把 size 设为 1 时,返回值才直接等于传输的字节数。
小于 count 的返回值叫作 短计数(short count) 。读取时,它可能表示到达文件末尾,也可能表示发生错误;调用者要在短读后查询 feof() 与 ferror()。写入时,短计数表示输出没有完整完成,程序不能继续把目标报告为有效文件。
按块复制时,一次 fwrite() 理论上也可能只接受输入块的前缀。可靠的写入辅助函数会推进指针并重试剩余字节,直到全部完成或出现零进展与错误。是否允许重试取决于目标和应用契约,但忽略短写永远不是正确的处理。
文件结束与错误指示器
到达文件末尾不是读操作开始前可预测的状态。只有一次读取尝试无法取得下一个字符时,流的 文件结束指示器(end-of-file indicator) 才会被设置。因此,应先调用读取函数,再根据它的返回值退出循环;while (!feof(stream)) 会多执行一次已经失败的循环体。
feof() 与 ferror() 查询的是不同状态。读取返回 EOF、NULL 或短计数后,前者说明输入因文件结束而停止,后者说明发生读错误。两个指示器都会保持已设置状态,直到 clearerr()、rewind() 或相应规则明确改变它们;不要把 errno 当成区分普通 EOF 与流错误的唯一依据。
定位与更新流
fseek() 可以相对 SEEK_SET、SEEK_CUR 或 SEEK_END 改变位置,ftell() 返回可用于之后定位的信息。fgetpos() 与 fsetpos() 使用 fpos_t 保存和恢复位置。不是每个流都支持随机定位,管道等对象上的调用可以失败,所以返回值仍需检查。
更新流允许读写,但方向切换有额外顺序要求。输出后开始输入之前,必须先成功调用 fflush() 或文件定位函数;输入后开始输出之前,通常必须调用文件定位函数,除非输入操作遇到了文件结束。最容易审查的做法是在每次切换方向时显式 fseek(),并检查结果。
示例
写入并逐行读回文本
第一个示例创建两行文本,检查写入与关闭,再逐行读回。fgets() 保留每行的换行符,所以 printf() 不再额外添加换行。
#include <stdio.h>
int main(void) {
const char *path = "scores.txt";
FILE *stream = fopen(path, "w");
if (stream == NULL) {
perror("open for writing");
return 1;
}
if (fputs("Ada 91\nLin 88\n", stream) == EOF) {
perror("write scores");
fclose(stream);
return 1;
}
if (fclose(stream) == EOF) {
perror("close after writing");
return 1;
}
stream = fopen(path, "r");
if (stream == NULL) {
perror("open for reading");
return 1;
}
char line[32];
size_t line_number = 0;
while (fgets(line, sizeof line, stream) != NULL) {
printf("%zu: %s", ++line_number, line);
}
int failed = ferror(stream);
if (fclose(stream) == EOF) failed = 1;
printf("lines: %zu\n", line_number);
remove(path);
return failed ? 1 : 0;
}1: Ada 91
2: Lin 88
lines: 2这里的循环由 fgets() 返回值驱动。若最后一行没有换行符,它仍会作为一行返回;若一行超过 31 个字符,则会分多次返回。固定缓冲区只限制单次读取量,并不自动定义“完整行”。
用明确字节序保存整数
二进制文件需要自己的格式契约。下面把三个 16 位无符号整数编码成高字节在前的固定六字节记录,而不是直接写入编译器的内存布局。
#include <limits.h>
#include <stdint.h>
#include <stdio.h>
static_assert(CHAR_BIT == 8, "example needs 8-bit bytes");
int main(void) {
const uint16_t values[] = {300, 1024, 65535};
unsigned char encoded[6] = {0};
for (size_t index = 0; index < 3; ++index) {
encoded[index * 2] = (unsigned char)(values[index] >> 8);
encoded[index * 2 + 1] = (unsigned char)values[index];
}
FILE *stream = fopen("values.bin", "w+b");
if (stream == NULL) {
perror("open binary output");
return 1;
}
const size_t written = fwrite(encoded, 1, sizeof encoded, stream);
if (written != sizeof encoded || fseek(stream, 0, SEEK_SET) != 0) {
fputs("binary write failed\n", stderr);
fclose(stream);
return 1;
}
unsigned char loaded[sizeof encoded] = {0};
const size_t read = fread(loaded, 1, sizeof loaded, stream);
const int read_close = fclose(stream);
if (read != sizeof loaded || read_close == EOF) {
fputs("binary read failed\n", stderr);
return 1;
}
for (size_t index = 0; index < 3; ++index) {
const unsigned value =
((unsigned)loaded[index * 2] << 8) | loaded[index * 2 + 1];
printf("%u%c", value, index == 2 ? '\n' : ' ');
}
remove("values.bin");
return 0;
}300 1024 65535文件格式明确规定每个值占两个字节及字节顺序,因此不会依赖结构体填充或宿主端序。示例也分别保存传输计数与关闭结果,避免逻辑短路使 fclose() 根本没有执行。
处理短写的分块复制
复制任意数据时,把 fread() 的实际返回值交给写入循环。示例使用 tmpfile() 创建临时输入流,再把内容复制到标准输出,因此不依赖预先存在的输入文件。
#include <stdio.h>
static int write_all(FILE *stream, const unsigned char *data, size_t length) {
while (length > 0) {
const size_t written = fwrite(data, 1, length, stream);
if (written == 0 || ferror(stream)) {
return -1;
}
data += written;
length -= written;
}
return 0;
}
int main(void) {
FILE *input = tmpfile();
if (input == NULL) {
return 1;
}
if (fputs("alpha\nbeta\ngamma\n", input) == EOF ||
fseek(input, 0, SEEK_SET) != 0) {
fclose(input);
return 1;
}
unsigned char buffer[8];
size_t total = 0;
size_t count;
while ((count = fread(buffer, 1, sizeof buffer, input)) > 0) {
if (write_all(stdout, buffer, count) != 0) {
fclose(input);
return 1;
}
total += count;
}
int failed = ferror(input);
printf("copied: %zu bytes\n", total);
if (fclose(input) == EOF) failed = 1;
if (fflush(stdout) == EOF) failed = 1;
return failed ? 1 : 0;
}alpha
beta
gamma
copied: 17 bytes缓冲区大小是 8,但最后一次 fread() 只返回剩余的 1 字节。write_all() 只写这次实际读到的范围,并在短写后推进指针。输入循环结束后再查 ferror(),并用 fflush(stdout) 确认输出已交给宿主环境。
回填二进制记录头
更新流适合先写内容,再回到开头补充长度等元数据。每次方向切换前,示例都用 fseek() 建立明确的文件位置,同时满足更新流的顺序要求。
#include <stdio.h>
int main(void) {
const char *path = "record.bin";
const unsigned char payload[] = {'h', 'e', 'l', 'l', 'o'};
const unsigned char empty_header[2] = {0, 0};
FILE *stream = fopen(path, "w+b");
if (stream == NULL) {
perror("open record");
return 1;
}
if (fwrite(empty_header, 1, 2, stream) != 2 ||
fwrite(payload, 1, sizeof payload, stream) != sizeof payload) {
fclose(stream);
return 1;
}
const unsigned char header[2] = {0, sizeof payload};
if (fseek(stream, 0, SEEK_SET) != 0 ||
fwrite(header, 1, 2, stream) != 2 ||
fseek(stream, 0, SEEK_SET) != 0) {
fclose(stream);
return 1;
}
unsigned char loaded_header[2];
if (fread(loaded_header, 1, 2, stream) != 2) {
fclose(stream);
return 1;
}
const unsigned length =
((unsigned)loaded_header[0] << 8) | loaded_header[1];
printf("payload bytes: %u\n", length);
const int close_result = fclose(stream);
remove(path);
return close_result == EOF ? 1 : 0;
}payload bytes: 5fseek() 的第一次调用把位置从记录末尾移到头部,随后两字节长度覆盖占位值。第二次调用既把位置移回开头,也隔开输出与输入。真实格式还应检查长度是否在允许上限内,并说明长度是否包含头部。
陷阱
修复: 让 fgetc()、fgets() 或 fread() 的返回值控制循环,并把 fgetc() 存入 int。循环结束后,再用 feof() 和 ferror() 解释停止原因。不要在读取之前查询 EOF 来决定缓冲区是否有效。
修复: 检查每个可能失败的输出操作,并把关闭成功纳入事务结果。若失败后留下半成品会误导下一次启动,应写入受控目录中的临时文件,确认写入与关闭成功后再使用宿主环境提供的原子替换机制。
修复: 在打开前明确“必须存在”“可以替换”或“只允许新建”的契约。只允许新建时,在 C23 中使用带 x 的写模式,并把路径限制在调用者被授权的目录;路径安全需要宿主平台能力,不能仅靠文件扩展名检查。
修复: 把方向切换写成显式状态转换。输出后先检查 fflush() 或定位调用;输入后用成功的定位调用再开始输出。若算法只是分阶段处理,关闭并用明确模式重新打开往往更容易证明正确。
修复: 定义版本、字段宽度、字节顺序、长度上限和完整性规则,逐字段编码。读取不可信文件时,先验证头部与所有长度,再分配或索引。只有文件明确限定在同一 ABI 与受控生命周期内时,才能把原始布局作为局部约定,而且仍要检查短读。
流状态、位置与持久性边界
指示器记录过去,不预测未来
文件结束指示器说明此前的输入操作已经尝试越过可用输入。向普通文件读到恰好最后一个字节时,那次读取仍然成功,EOF 指示器不必立即设置;下一次无法取得数据的读取才会设置它。因此,“当前位置等于文件长度”和“feof() 为真”不是同一个状态。
错误指示器同样具有粘性。一次后续成功读取不会自动证明先前错误已被处理。只有调用者理解失败原因并决定重试时,才应使用 clearerr() 清除两个指示器;盲目清除只会丢失诊断状态,并可能让没有进展的循环继续运行。
成功的 fseek() 会清除 EOF 指示器,并撤销 ungetc() 产生的回退效果。rewind() 把位置移到开头并清除错误与 EOF 指示器,但它没有返回值,无法直接报告定位失败。需要处理定位错误时,使用 fseek(stream, 0, SEEK_SET) 更容易表达契约。
文本流的位置不是通用字节偏移
在二进制流中,位置适合按文件格式定义的字节范围进行定位,但仍要检查 fseek() 与 ftell()。在文本流中,换行转换等实现规则可能让 ftell() 的返回值不等于用户看到的字符数。可移植代码把该值视为之后交给 fseek() 的位置令牌,而不是拿它做文本长度算术。
对于文本流,最可移植的定位方式是偏移 0,或者把此前由 ftell() 返回的值配合 SEEK_SET 使用。相对文件末尾做任意文本偏移并不具有普遍保证。若业务需要第 N 条记录,顺序解析并建立由合法位置值组成的索引,比猜测换行占几个字节可靠。
fgetpos() 与 fsetpos() 使用 fpos_t,可以保存实现定位文本流所需的额外解析状态。fpos_t 不承诺是整数,也不应序列化进文件或参与算术。它适合在同一运行中的同一流上保存和恢复位置。
缓冲完成不等于介质持久化
全缓冲流通常积累更多输出后再交给宿主环境;行缓冲流可以在遇到换行时提交;无缓冲流尽量直接传递每次操作。具体默认值会受流连接对象影响,程序不应依赖某个普通文件恰好使用多大的缓冲区。setvbuf() 如需使用,必须在该流进行其他操作之前调用,并确保调用者提供的缓冲区在流关闭前一直有效。
fflush() 对输出流的保证是把 C 库尚未交付的数据写给宿主环境。它不等同于电源故障后数据一定仍在存储介质上;这类持久性通常需要操作系统特定的同步、目录处理和原子替换协议。标准 C 不能单独提供完整的崩溃安全文件提交。
对输入流调用 fflush() 不是可移植的“清空键盘输入”方法。输入可能来自文件、管道或终端,而且标准 C 没有一个通用调用可以丢弃“一行”。应按协议持续读取到记录边界,或者让平台终端 API 处理设备特有行为。
文件格式必须先于内存布局
结构体对象可能在字段之间和末尾包含填充字节;未显式写入这些字节还可能泄露进程内存中的旧数据。整数与浮点类型的宽度和字节顺序也由实现与平台决定。即使读写程序使用相同源码,编译器选项或 ABI 变化也可能让原始结构体文件失效。
稳定格式应从字节层定义:魔数识别格式,版本决定字段解释,固定宽度字段配合指定字节序,长度和计数有明确上限。解码器先验证总长度与字段关系,再申请资源。需要兼容升级时,新版本应能拒绝未知必需字段或跳过有长度的可选字段,而不是把文件强制转换成当前结构体。
格式化文本也需要契约。区域设置可能改变数字格式,fprintf() 的精度决定信息是否可逆,分隔符可能出现在字段内容中。若数据格式已经由 CSV、JSON 或其他规范定义,应使用符合该规范的解析器;几行 strtok() 或 fscanf("%s") 通常没有覆盖转义、空字段和范围验证。
延伸阅读
4个问题 · 1 道输出预测题 · 1 道找错题