C 文件 I/O

使用 C23 的 stdio 流可靠地打开、读取、写入和定位文件,并正确处理 EOF、短计数、缓冲与关闭错误。

难度 进阶 时长 标准深度约 14分钟
版本 C23 (GCC 13.3.0)
what

C 的 <stdio.h>FILE * 表示带缓冲和状态的流;程序打开流,按字符、行、格式或字节块传输数据,再关闭流。

trap

EOF 不是循环条件的预告,fread()fwrite() 也不保证完成请求;忽略返回值或 fclose() 错误会让截断文件看似成功。

fix

让每次 I/O 的返回值驱动控制流,用 feof()ferror() 解释短读,并明确处理打开、写入和关闭失败。

是什么,为什么存在

C 文件 I/O 是 <stdio.h> 提供的一组标准接口,用于在程序与文件、终端等外部对象之间传输数据。打开对象后,库会返回一个指向 文件流(file stream) FILE *。这个值不是文件内容,也不是可由程序检查成员的公开结构;它是后续读写、定位和状态查询所需的句柄。

流把不同外部对象统一成同一种接口。fgetc()fgets()fprintf()fread() 等函数都接收 FILE *,因此相同的处理逻辑可以作用于普通文件或标准流 stdinstdoutstderr。标准库还可以在程序与宿主环境之间缓冲数据,减少每个字符都触发底层操作的需要。

你会在读取配置、逐行处理日志、导入文本数据、复制任意字节和保存自定义格式时使用这些接口。文件名、权限、目录和并发修改由宿主环境决定;标准 C 只规定流接口及其可观察行为。若程序需要遍历目录、读取元数据或进行异步 I/O,应使用平台 API 或更高层库,而不是把这些能力假定为 stdio 的一部分。

文件 I/O 的核心契约不是“调用函数”,而是完整管理生命周期和状态:选择不会破坏数据的打开模式,检查每次传输的结果,区分文件结束与错误,并检查最终关闭。任何一步失败,都可能使已读取的数据不完整,或使写出的文件只包含前缀。

需求接口返回值要表达的事实
打开或创建fopen()FILE *NULL
读取字符或行fgetc()fgets()数据、EOFNULL
传输对象块fread()fwrite()完成的元素数量
格式化文本fprintf()fscanf()写入字符数或成功转换数,失败时返回特定值
定位fseek()ftell()成功状态或可供恢复的位置
结束生命周期fclose()0EOF

工作原理

流的生命周期与状态

fopen(path, mode) 把外部文件与新流关联。成功时,流具有访问方式、当前位置、缓冲状态、文件结束指示器和错误指示器;失败时返回 NULL,并可能设置 errnoperror() 可以把当前 errno 解释成面向人的诊断,但程序逻辑仍应通过自己的状态码把失败传给调用者。

流只在成功打开后、关闭前有效。fclose() 会处理待写缓冲并解除关联,之后再把旧指针传给任何 I/O 函数都没有合法语义。关闭也可能失败,例如先前缓冲的输出在最终提交时出错,所以写入路径不能把“最后一次 fwrite() 成功”当成完整成功条件。

stdinstdoutstderr 在宿主环境为程序提供时已经打开。它们分别用于标准输入、标准输出和诊断输出,但不保证连接到键盘或屏幕;重定向后,它们可以连接文件或管道。不要根据流的名称推断设备类型或交互行为。

打开模式是数据契约

模式的首字符决定基本访问方式。r 要求文件已存在,w 会创建文件或立即截断已有文件,a 会创建文件或把每次输出写到当时的文件末尾。添加 + 会得到更新流(update stream),允许输入与输出;添加 b 会请求二进制模式。

模式输入输出已有内容初始位置
r保留开头
w截断开头
a保留,输出追加末尾
r+保留开头
w+截断开头
a+保留,输出追加输入从开头开始

二进制数据应使用 rbwbr+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() 查询的是不同状态。读取返回 EOFNULL 或短计数后,前者说明输入因文件结束而停止,后者说明发生读错误。两个指示器都会保持已设置状态,直到 clearerr()rewind() 或相应规则明确改变它们;不要把 errno 当成区分普通 EOF 与流错误的唯一依据。

定位与更新流

fseek() 可以相对 SEEK_SETSEEK_CURSEEK_END 改变位置,ftell() 返回可用于之后定位的信息。fgetpos()fsetpos() 使用 fpos_t 保存和恢复位置。不是每个流都支持随机定位,管道等对象上的调用可以失败,所以返回值仍需检查。

更新流允许读写,但方向切换有额外顺序要求。输出后开始输入之前,必须先成功调用 fflush() 或文件定位函数;输入后开始输出之前,通常必须调用文件定位函数,除非输入操作遇到了文件结束。最容易审查的做法是在每次切换方向时显式 fseek(),并检查结果。

示例

写入并逐行读回文本

第一个示例创建两行文本,检查写入与关闭,再逐行读回。fgets() 保留每行的换行符,所以 printf() 不再额外添加换行。

line_reader.c
#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 位无符号整数编码成高字节在前的固定六字节记录,而不是直接写入编译器的内存布局。

binary_values.c
#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() 创建临时输入流,再把内容复制到标准输出,因此不依赖预先存在的输入文件。

checked_copy.c
#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() 建立明确的文件位置,同时满足更新流的顺序要求。

record_header.c
#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: 5

fseek() 的第一次调用把位置从记录末尾移到头部,随后两字节长度覆盖占位值。第二次调用既把位置移回开头,也隔开输出与输入。真实格式还应检查长度是否在允许上限内,并说明长度是否包含头部。

陷阱

修复: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 道找错题

复制为 Markdown 面试题库 在 GitHub 上编辑 报告错误 讲清楚了吗?