# C 文件 I/O

Source: https://codewiki.com/zh/cpp/c-file-io/

> - **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()` 不再额外添加换行。

<!-- quick -->

```c
// file: 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;
}
```

```text
1: Ada 91
2: Lin 88
lines: 2
```

<!-- /quick -->

这里的循环由 `fgets()` 返回值驱动。若最后一行没有换行符，它仍会作为一行返回；若一行超过 31 个字符，则会分多次返回。固定缓冲区只限制单次读取量，并不自动定义“完整行”。

### 用明确字节序保存整数

二进制文件需要自己的格式契约。下面把三个 16 位无符号整数编码成高字节在前的固定六字节记录，而不是直接写入编译器的内存布局。

```c
// file: 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;
}
```

```text
300 1024 65535
```

文件格式明确规定每个值占两个字节及字节顺序，因此不会依赖结构体填充或宿主端序。示例也分别保存传输计数与关闭结果，避免逻辑短路使 `fclose()` 根本没有执行。

### 处理短写的分块复制

复制任意数据时，把 `fread()` 的实际返回值交给写入循环。示例使用 `tmpfile()` 创建临时输入流，再把内容复制到标准输出，因此不依赖预先存在的输入文件。

```c
// file: 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;
}
```

```text
alpha
beta
gamma
copied: 17 bytes
```

缓冲区大小是 `8`，但最后一次 `fread()` 只返回剩余的 `1` 字节。`write_all()` 只写这次实际读到的范围，并在短写后推进指针。输入循环结束后再查 `ferror()`，并用 `fflush(stdout)` 确认输出已交给宿主环境。

### 回填二进制记录头

更新流适合先写内容，再回到开头补充长度等元数据。每次方向切换前，示例都用 `fseek()` 建立明确的文件位置，同时满足更新流的顺序要求。

```c
// file: 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;
}
```

```text
payload bytes: 5
```

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

## 陷阱

> **陷阱:** 用 `while (!feof(stream))` 预测文件结束，会在最后一次读取失败后仍处理旧缓冲区；用 `char` 保存 `fgetc()` 结果，还可能把有效字节误认成 `EOF`。

**修复：** 让 `fgetc()`、`fgets()` 或 `fread()` 的返回值控制循环，并把 `fgetc()` 存入 `int`。循环结束后，再用 `feof()` 和 `ferror()` 解释停止原因。不要在读取之前查询 EOF 来决定缓冲区是否有效。

> **陷阱:** 生成代码常只检查 `fopen()`，却忽略 `fprintf()`、`fwrite()`、`fflush()` 与 `fclose()`。磁盘空间不足或底层写入延迟失败时，函数仍会报告保存成功。

**修复：** 检查每个可能失败的输出操作，并把关闭成功纳入事务结果。若失败后留下半成品会误导下一次启动，应写入受控目录中的临时文件，确认写入与关闭成功后再使用宿主环境提供的原子替换机制。

> **陷阱:** `w` 和 `w+` 在成功打开时立即截断已有文件。把未经授权或未规范化的路径直接传给生成的保存函数，还可能覆盖应用边界外的数据。

**修复：** 在打开前明确“必须存在”“可以替换”或“只允许新建”的契约。只允许新建时，在 C23 中使用带 `x` 的写模式，并把路径限制在调用者被授权的目录；路径安全需要宿主平台能力，不能仅靠文件扩展名检查。

> **陷阱:** 在 `r+`、`w+` 或 `a+` 流上直接从写切换到读，或从未到 EOF 的读切换到写，会违反更新流的顺序规则。小文件测试可能因缓冲布局而看似正常。

**修复：** 把方向切换写成显式状态转换。输出后先检查 `fflush()` 或定位调用；输入后用成功的定位调用再开始输出。若算法只是分阶段处理，关闭并用明确模式重新打开往往更容易证明正确。

> **陷阱:** `fwrite(&record, sizeof record, 1, stream)` 会写出当前实现的对象表示（object representation），其中可能包含填充、宿主端序和实现相关类型宽度。它不是自动得到的可移植文件格式，也不应直接信任后再分配内存。

**修复：** 定义版本、字段宽度、字节顺序、长度上限和完整性规则，逐字段编码。读取不可信文件时，先验证头部与所有长度，再分配或索引。只有文件明确限定在同一 ABI 与受控生命周期内时，才能把原始布局作为局部约定，而且仍要检查短读。

<!-- deep -->

## 流状态、位置与持久性边界

### 指示器记录过去，不预测未来

文件结束指示器说明此前的输入操作已经尝试越过可用输入。向普通文件读到恰好最后一个字节时，那次读取仍然成功，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")` 通常没有覆盖转义、空字段和范围验证。

<!-- /deep -->

[检查点: cpp/c-file-io](https://codewiki.com/zh/cpp/c-file-io/#checkpoint)

## 延伸阅读

- [GNU C Library：流式输入输出](https://sourceware.org/glibc/manual/latest/html_node/I_002fO-on-Streams.html)
- [GNU C Library：打开流](https://sourceware.org/glibc/manual/latest/html_node/Opening-Streams.html)
- [GNU C Library：块输入输出](https://sourceware.org/glibc/manual/latest/html_node/Block-Input_002fOutput.html)
- [GNU C Library：文件结束与错误](https://sourceware.org/glibc/manual/latest/html_node/EOF-and-Errors.html)
- [cppreference：C 输入输出库](https://en.cppreference.com/w/c/io)
