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 *,因此相同的处理逻辑可以作用于普通文件或标准流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()不再额外添加换行。

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上编辑 报告错误 讲清楚了吗?