清风日常

Stay curious. Stay gentle.

听一会儿

4 首收藏
LOVE 2000 封面

LOVE 2000

遠野ひかる

0:004:23
首页/C Primer Plus/正文

C Primer Plus(第6版)· 第13章:文件输入 / 输出

文件让数据在程序结束后继续存在。标准 I/O 把文件、终端等输入输出来源组织成流,用统一接口完成打开、读写、定位和关闭。

文章目录 32 个章节

第13章:文件输入 / 输出

文件让数据在程序结束后继续存在。标准 I/O 把文件、终端等输入输出来源组织成流,用统一接口完成打开、读写、定位和关闭。

本章的核心是:选择合适的打开模式,让读取结果驱动处理流程,并检查写入与关闭是否真正成功。 文件存在、流已打开、数据已经读到、缓冲区已经提交,是不同状态。

1. 文件、流与 FILE *

1.1 文件名用于打开,流指针用于后续操作

c
#include <stdio.h>

FILE *input = fopen("notes.txt", "r");

fopen 接收文件名和模式字符串,成功时返回 FILE *,失败时返回空指针。

FILE * 指向标准库管理的流对象,不是“整个文件内容在内存中的首地址”。库可以通过它维护缓冲、当前位置、读写错误和文件结束状态。

text
文件名 → 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 等 是否转换数值、扫描格式或按块传输

所有文件最终都由位构成。所谓文本数据,通常是把内容编码成字符;所谓数值的二进制数据,则可能直接使用某种数值编码或对象表示。

c
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. 先检查打开结果,最后分别关闭每个流

c
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 * 当作仍然正常打开的流重试操作。

同时打开两个流时,不要用短路表达式代替两次必要的关闭:

c
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

c
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 不会提前预知输入结束

c
// 不要这样设计:
// while (!feof(input)) {
//     ch = getc(input);
//     process(ch);
// }

文件结束指示器通常在一次读取尝试发现结束时才被设置。刚处理完最后一个有效字符,并不意味着循环条件已经能提前知道后面没有数据。

应先读,再判断这次是否成功,成功才处理。

5.3 结束与错误要分开检查

c
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 按行读取要考虑长行

c
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 检查成功赋值的数量

c
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 输出格式仍由程序控制

c
fprintf(output, "%s\n", text);
fprintf(stderr, "cannot read %s\n", filename);

流参数在最前面;putc(ch, output) 则把流放在第二个参数位置,不能混淆。

外部文本通过 %s 等格式传入,不要把文件内容本身直接当作格式字符串。

7. fread 与 fwrite:返回的是完整项数

c
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:希望传输的项数。
  • 返回值:完整传输的项数,不一定等于字节数。
c
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,返回的项数才等于字节数:

c
unsigned char buffer[4096];
size_t bytes = fread(buffer, 1, sizeof buffer, input);

短读可能来自正常文件结束,也可能来自错误;结合 ferror 判断。短写需要按失败处理,不能当作已成功保存完整数据。

若按多字节对象读取时得到不完整的一项,那一项不能当作成功读取的对象使用。解析文件时还要防止信任未经验证的外部对象表示。

8. 完整示例:复制任意文件并保留已有目标

下面的程序使用 C11 的 "wbx" 创建新目标;目标已经存在时拒绝覆盖。源文件和目标可以包含任意字节,包括空字符。

c
#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);
}

调用示意:

sh
./copy source.bin target.bin

程序每次只写入本轮实际读到的字节数,最后不足一整块的数据不会丢失。空文件也能正常处理。

若中途失败,目标可能只包含部分数据;这个示例没有实现失败回滚。若改用 "wb",则必须另行处理覆盖策略与源目标实际是同一文件的问题。

比较两个路径字符串是否相同,不能完全判断它们是否指向同一文件。不同路径、链接等都可能引用同一对象。

9. 缓冲与刷新:写入库缓冲不等于已经落盘

9.1 标准 I/O 在后台组织数据块

text
输入:文件或设备 → 标准库缓冲 → 程序取得字符或数据块
输出:程序 → 标准库缓冲 → 文件或设备

使用逐字符函数,也不代表底层每次只发起一个字节的系统调用。具体缓冲结构与大小由实现决定,不必假定每个流一定有固定数量的缓冲区。

9.2 fflush 提交待写出的输出

c
if (fflush(output) == EOF) {
    /* 输出提交失败。 */
}

fflush(NULL) 用于刷新符合条件的所有输出流。成功返回 0,失败返回 EOF。

常见用途是输出不带换行的提示后,主动刷新标准输出:

c
fputs("name: ", stdout);
fflush(stdout);

标准 C 不把 fflush(stdin) 定义成“清空键盘输入”的通用办法。需要丢弃输入时,应使用适当的读取逻辑。

刷新标准库输出缓冲,不等于获得了断电后数据仍已持久保存的保证;更强的持久化要求属于具体系统接口与存储语义。

9.3 setvbuf 在其他流操作之前设置缓冲

c
if (setvbuf(file, NULL, _IOFBF, 4096) != 0) {
    /* 缓冲设置失败。 */
}
模式 含义
_IOFBF 全缓冲
_IOLBF 行缓冲
_IONBF 无缓冲

应在打开流后、进行其他普通流操作之前调用。返回 0 表示成功,非零表示失败。

若传入自己提供的缓冲区,该缓冲区必须在流使用它期间一直有效。不能把即将退出的辅助函数局部数组交给仍会继续使用的流。

10. 更新流:读取与写入之间需要正确切换

"r+"、"w+"、"a+" 都是更新模式,但不是读写操作可以随意交错。

切换方向 标准规则要点
写入后改为读取 中间需要 fflush 或文件定位操作
读取后改为写入 中间需要文件定位操作;前一次读取遇到文件结束时有例外

定位操作包括适用的 fseek、fsetpos、rewind。只调用 fflush 不能一般性地替代“读取后改为写入”所需的定位。

追加模式可读全文件,但每次写入仍追加到末尾。读取前主动定位,可以避免依赖初始读取位置的实现差异。

11. 随机访问:移动文件位置指示器

11.1 fseek 指定起点与偏移

c
int fseek(FILE *stream, long offset, int origin);
起点常量 含义
SEEK_SET 文件开始
SEEK_CUR 当前位置
SEEK_END 文件末尾
c
fseek(file, 0L, SEEK_SET);
fseek(file, 10L, SEEK_SET);
fseek(file, -2L, SEEK_CUR);

成功返回 0,失败返回非零值,不要只检查是否恰好等于 -1。不是所有流都可定位,二进制流也不必在所有实现上支持有意义的末尾相对定位。

定位成功会清除文件结束指示器,并撤销尚未读出的推回字符。不要把它当作自动清除所有错误状态的办法。

11.2 ftell 取得当前位置

c
long position = ftell(file);
if (position == -1L) {
    /* 取得位置失败。 */
}

对于二进制流,通常可以按从开始位置计算的字符单位理解;文本流中的结果应当视为恢复位置所需的标记,不应随意对它加减来计算外部字节偏移。

文本流的可移植定位方式主要是:使用零偏移,或把同一流先前成功取得的 ftell 值配合 SEEK_SET 恢复。

不要把 fseek(..., SEEK_END) 加 ftell 当作适用于所有流和所有文件的通用大小查询方案。

11.3 rewind 回到开头

c
rewind(file);

它将位置移到开始,并清除文件结束与错误指示器,但没有直接返回值。需要检查定位成功与否时,考虑适当的可报告结果的接口。

11.4 fgetpos 与 fsetpos 保存和恢复位置

c
fpos_t saved;

if (fgetpos(file, &saved) == 0) {
    /* 读取或进行其他处理。 */
    if (fsetpos(file, &saved) != 0) {
        /* 恢复失败。 */
    }
}

fpos_t 是位置类型,不保证就是整数;实现可以让它保存位置与必要的文本转换状态。不要对它做普通字节偏移运算。

两个函数都以返回 0 表示成功,非零表示失败。位置标记应来自相应流的成功保存操作。

12. 完整示例:按下标读取二进制数值

下面使用临时二进制流演示写入、切换方向、按固定项大小定位,再读取单个值:

c
#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;
}

输出:

text
index 2: 3.75

tmpfile 创建临时二进制更新流,关闭时删除临时文件。这里用定位操作完成从输出到输入的切换。

对来自输入的任意下标,要先检查下标范围、乘法是否溢出,以及计算结果能否由 long 表示。简单强制转换不会自动消除这些问题。

13. ungetc:把一个字符推回输入流

c
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 错误
定位时 流是否支持,返回值是否成功,偏移是否可表示
收尾时 已打开流是否都被关闭,关闭是否报告错误

把数据处理与流管理分开,可以让同一个处理函数用于磁盘文件、标准输入或临时流。函数只需要知道如何读取和报告结果,调用者负责决定来源、目标与生存期。

Aron 的清风日常

作者 Aron · 发布于 2026.08.12

愿这份记录能为你提供一点参考。发现问题,欢迎通过 GitHub 或 邮件交流。

如果这篇记录对你有用,留下一份喜欢。

← 所有文章继续阅读C Primer Plus(第6版)· 附录整理:语法与标准库速查 →
SEARCH MY NOTES

在记录里,找一点答案

搜索标题、标签与全文 · 按 Esc 关闭