跳到主要内容

格式化输入输出、流与文件

本节目标

查询 C23 格式化输入输出、流状态、文件定位和文件操作。

处理输入输出时,先回答三个问题:数据是按字段、字符、整行还是固定大小的块组织;流以什么模式打开;每一步由哪个返回值和流标志报告结果。scanf 家族适合结构明确的字段输入,任意整行文本则交给 fgets。本章以 ISO C23 的窄字符 I/O 为主线,把格式合同、更新流读写切换、EOF 和错误判断放进同一条检查路径。

FILE、标准流与流方向

FILE * 是库管理的流对象指针,不能复制其表示来复制流。程序只保存和传递这个指针,并通过 <stdio.h> 接口操作流;不要解引用、按字节复制或自行释放 FILE 对象。

程序开始时可使用三个预定义文本流:stdin 负责标准输入,stdout 负责标准输出,stderr 负责诊断输出。它们连接到什么设备、是否交互以及宿主如何呈现内容,不是 C 程序能够一概假设的。需要稳定测试时,应断言程序自己写出的文本和返回状态,而不是终端提示或系统错误文案。

当前任务选择的流或模式检查重点
读取标准输入stdin输入接口返回值、未消费字符、EOF 与错误指示
写普通结果stdout输出接口返回值、缓冲与必要的刷新
写诊断stderr输出仍可能失败,不把显示时机当作持久化保证
只读或只写文件输入模式或输出模式不在错误方向调用 I/O 接口
在同一文件读写更新模式每次方向切换满足刷新或定位要求

输入流、输出流和更新流描述允许的操作方向;字节定向与宽定向则是另一层属性。窄字符 I/O 和宽字符 I/O 不能在已经以相反方式定向的流上随意混用。本章代表程序只使用窄字符接口,不用复制 FILE 表示或混合宽字符 I/O。

文件打开模式与流生命周期

fopen(path, mode) 建立文件流,先按任务选择主模式,再决定是否需要更新模式 + 和二进制标志 b。打开失败返回空指针;成功后,每条离开路径都要让对应的 fclose 只执行一次,并检查关闭是否成功。

模式既有文件不存在的文件初始位置允许操作
r保留内容打开失败开头
w截断为空创建开头
a保留内容创建每次写入都在末尾
r+保留内容打开失败开头读、写
w+截断为空创建开头读、写
a+保留内容创建读位置按实现规则,写入总在末尾读、写

b 加入模式可请求二进制流,例如 rbw+b。文本流与二进制流的表示和定位保证不同,不把本机换行或字节布局写成跨平台规则。二进制模式也不会自动解决对象填充、整数表示、字节序或版本兼容问题。

更新流在读写方向切换时必须满足刷新或定位要求:输出后转输入,先调用 fflush 或文件定位函数;输入后转输出,先调用文件定位函数,除非刚才的输入操作遇到文件末尾。代表程序以 w+ 打开,写出后依次 fflushfseek,再读取核对。

printf 家族与格式化输出

格式化输出由格式字符串同时描述文本和后续参数类型。printf/scanf 的格式必须与实际参数类型匹配;默认实参提升不消除格式契约。即使两个类型在当前平台大小相同,错误的转换说明仍可能产生未定义行为。

任务格式化输出接口目标成功结果
写标准输出printfstdout写出的字符数
写指定流fprintfFILE *写出的字符数
写定长数组snprintf字符数组及容量本来需要写出的字符数,不含结尾空字符

返回负值表示输出或编码错误。使用 snprintf 时,返回值非负也不表示内容一定完整;把它转换成合适的无符号类型之前,先排除负值,再与目标容量比较。容量大于零时它会在规则允许的范围内写入结尾空字符;返回值大于等于容量表示发生截断。

常用判断包括:%d 对应提升后的 int%u 对应 unsigned int%zu 对应 size_t%fprintf 中接收提升后的 double%s 需要指向有效的空字符结尾字符串,%p 需要 void *。用户输入不能直接充当格式字符串;固定格式,把数据作为后续实参传入。

scanf 家族与格式化输入

scanf 返回成功赋值的项目数。只有返回值精确等于预期项目数,程序才能使用全部目标对象;匹配失败、输入失败和 EOF 需要结合返回值与后续状态判断。若在第一次转换前发生输入失败,返回 EOF;格式不匹配则可能返回零;完成部分转换后发生问题会返回已经成功赋值的数量。

* 紧跟在 % 后表示赋值抑制:转换仍按规则尝试读取并匹配输入,但不对应也不消耗接收指针,而且不增加返回的赋值项目数。除用 %n 间接记录已读取字符外,返回值不能直接证明被抑制转换是否成功;若后续逻辑必须知道它是否匹配,应让格式或解析流程产生可观察的检查结果。

任务格式化输入接口数据来源主要检查
从标准输入读取字段scanfstdin返回值是否等于目标项目数
从指定流读取字段fscanfFILE *转换数量,再检查流状态
从字符串解析字段sscanf字符串转换数量和未消费尾部

%s 不读取空白,并且必须设置与目标数组容量匹配的字段宽度。字段宽度限制最多消费的输入字符数,不包含库补写的结尾空字符,所以 char name[16] 对应 %15s。任意整行文本仍应使用 fgets,然后再按项目规则解析。

输入函数会留下未消费字符,正文必须说明空白指令、换行和下一次读取之间的关系。格式中的普通空白会跳过任意数量的输入空白,直到遇到下一个非空白字符;大多数数值和 %s 转换也先跳过输入空白,但 %c%[%n 不会自动这样做。格式末尾的空白可能继续等待后续非空白输入,不应靠它“吃掉一行”。需要丢弃当前记录的剩余内容时,应按项目定义读取并判断到换行或 EOF,不能使用 fflush(stdin) 清空输入。

字符、字符串与行输入输出

根据记录边界选择接口,不要先用字段读取破坏边界,再猜测剩余内容。

数据单位输入输出关键合同
单个字节字符fgetcfputc输入结果保存在 int,先与 EOF 比较
空字符结尾字符串fputs不自动追加换行;失败返回 EOF
有容量上限的一段行文本fgets最多读容量减一字符并补 \0

fgets 在遇到换行且容量允许时保留换行;若数组已装满,得到的可能只是当前行的一部分。区分完整行、分段行和最后一行时,要检查数组中是否出现换行,并结合后续读取结果与流标志判断。fputs 不追加换行,需要时显式输出 \n。已从 C 标准删除的 gets 没有容量参数,不能使用。

fgetc 返回的是转换为 unsigned char 后再表示为 int 的字符值,或特殊值 EOF。若先缩窄存入 char,可能失去区分普通字符与 EOF 的能力。

fread、fwrite 与二进制块读写

块 I/O 按“元素大小 × 元素数量”传递数据,返回值按完整元素数量解释。

任务块 I/O 接口返回值使用判断
从流读入对象数组fread成功读入的完整元素数少于请求数量后检查 feofferror
把对象数组写入流fwrite成功写出的完整元素数少于请求数量就是未完整写入

fread/fwrite 返回成功处理的元素数量,不把部分元素自动解释为完整成功。返回值小于请求数量时,先保留实际完成数量,再分别判断文件结束和错误;不能把“短读”一律写成错误,也不能忽略短写。

直接写出结构体的对象表示通常不是可移植文件格式:填充字节、整数表示、字节序、浮点表示和版本布局都可能变化。跨程序或跨平台存储时,应定义字段编码、范围、顺序和版本,再逐字段序列化。对象表示与字符类型访问的语言边界见限定符、对象访问与程序行为边界

缓冲、刷新与交互式输出

缓冲决定数据何时从库缓冲区交给宿主环境,不改变输出接口的格式合同,也不保证数据已经持久化到存储设备。

任务接口或方式约束
在流建立后选择缓冲方式setvbuf在该流执行其他操作前调用,并保持用户缓冲区有效
把输出缓冲交给宿主环境fflush检查返回值;失败返回 EOF
交互提示后等待输入fflush(stdout)提示无换行或时机重要时显式刷新

fflush 的可移植用途是处理输出流或满足标准条件的更新流,不能使用 fflush(stdin) 清空输入。对输入流调用它不构成可移植的残留输入处理方法。从 main 的初始调用返回或调用 exit 时,标准规定刷新并关闭所有打开的流、移除 tmpfile 创建的文件。quick_exit 转入 _Exit;这两条路径是否刷新或关闭流、移除临时文件由实现定义。错误分支仍应在拥有资源的位置显式检查写入、刷新和关闭结果。

不要把“终端上已经看到文字”当作所有输出都已成功,也不要依赖某种本机默认缓冲模式决定协议时序。需要严格交互顺序时,程序明确输出、检查并刷新。

文件位置与重新定位

定位接口适合重读、跳过或恢复一个已记录的位置,但文本流和二进制流拥有不同保证。

任务定位接口结果与边界
相对开头、当前位置或末尾移动fseek成功返回零;文本流偏移选择受限
取得可供 fseek 使用的位置ftell失败返回 -1L;值不必是字节数
回到开头并清除流错误指示rewind无返回值,不能直接报告定位失败
保存不透明位置fgetpos成功返回零,写入 fpos_t 对象
恢复不透明位置fsetpos使用同一流先前保存的有效位置

对文本流,fseek 的非零偏移只应使用先前由 ftell 为同一流取得的值并配合 SEEK_SET;偏移零可配合标准起点。不要把 ftell 在文本流上的值当作通用字节索引。二进制流更适合按已定义记录布局定位,但仍要检查算术范围和接口失败。

查询位置的 ftellfgetpos 不清除 EOF 指示。成功的 fseekfsetpos 会清除 EOF 指示;rewind 还会清除错误指示。这些重新定位操作可满足更新流的方向切换要求。若必须获知失败,应选择有返回值的重新定位接口并显式检查。

EOF、错误标志与状态清理

EOF 指示和错误指示是流状态,不是“最后一次读取结果”的同义词。读取接口先通过返回值报告当前操作没有取得所需数据;随后才用 feofferror 区分到达文件末尾、发生读取错误或两者相关的状态。

要判断的状态查询接口何时使用如何清理
已设置文件结束指示feof读取返回 EOF 或发生短读之后clearerr 或成功定位
已设置错误指示ferrorI/O 返回失败或短结果之后clearerr;同时处理根因
同时清除 EOF 与错误指示clearerr已经处理原因且确实要继续操作时接口本身无返回值

EOF 是负值整型常量宏,不是文件中存储的一个字符。循环应先调用读取函数,再处理成功取得的数据;读取失败后检查流标志。while (!feof(stream)) 会在真正读取之前猜测未来状态,容易多处理一次旧数据。

清除标志不修复文件、设备或参数问题。只有程序已经识别并处理根因,而且流合同允许继续时,才调用 clearerr 后重试。

文件重命名、删除与临时文件

文件名操作作用于宿主环境中的名字,不等同于对已打开 FILE * 的读写。路径语法、权限模型以及目标已存在时的细节可能依赖实现或宿主,程序必须检查接口结果。

文件管理任务接口成功结果主要边界
改变文件名rename返回零失败保持非零结果;目标处理规则需查实现
删除文件名remove返回零打开文件被删除时的宿主行为不可一概假设
建立自动管理的临时二进制更新流tmpfile返回 FILE *失败为空指针;关闭或 exit 路径(含初始 main 返回)按标准移除,quick_exit/_Exit 不作同等保证
生成临时名字tmpnam按接口规则返回名字存在名字竞争风险,不作为安全创建流程

优先让安全创建机制以一次操作建立并打开临时资源;仅生成名字再打开会留下竞争窗口。tmpnam 只作为风险接口说明,tmpnam 不进入推荐方案或代表程序。代表程序接收测试提供的路径,用 fopen 建立文件,成功和已打开后的错误路径都会尝试 remove

流与文件操作陷阱

排查顺序是:调用前提与模式 → 当前读写方向 → 接口返回值 → EOF/错误指示 → 已获取资源 → 关闭与文件名清理。

症状常见根因修正方式
%s 写越界未设置字段宽度,或宽度等于数组容量char array[N] 使用至多 N - 1 的宽度
后续读取立即得到换行前一转换留下未消费字符明确记录边界,用字符或行读取处理剩余内容
更新流读不到刚写的数据写后直接转读先刷新或定位,再开始读取
fflush(stdin) 后“偶尔有效”依赖实现扩展按输入协议读取到边界并检查 EOF/错误
把短读视为完整记录只判断返回值非零与请求元素数比较,再检查流状态
文本位置在另一平台失效ftell 当通用字节偏移只在同一流按文本流定位规则复用
文件内容已写但最终仍损坏忽略刷新或关闭失败检查写、刷新、关闭的每个返回值

下面的代表程序从标准输入按 %15s %d 读取两个字段,把它们写入测试提供的 w+ 文件流,刷新并重新定位后读回核对。随后它用 fgetc 观察记录换行和文件末尾,并用 feofferror 区分流标志;所有正常路径都关闭并删除文件。输入 pears 7 时得到固定输出:

stream_file_report.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

static int fail_after_open(FILE *stream, const char *path) {
if (stream != NULL) {
(void)fclose(stream);
}
(void)remove(path);
fputs("stream-error\n", stderr);
return EXIT_FAILURE;
}

int main(int argc, char *argv[]) {
if (argc != 2) {
fputs("usage-error\n", stderr);
return EXIT_FAILURE;
}

char input_name[16];
int input_quantity = 0;
if (scanf("%15s %d", input_name, &input_quantity) != 2) {
fputs("input-error\n", stderr);
return EXIT_FAILURE;
}

FILE *stream = fopen(argv[1], "w+");
if (stream == NULL) {
fputs("file-open-error\n", stderr);
return EXIT_FAILURE;
}

if (fprintf(stream, "%s %d\n", input_name, input_quantity) < 0 ||
fflush(stream) == EOF || fseek(stream, 0L, SEEK_SET) != 0) {
return fail_after_open(stream, argv[1]);
}

char file_name[16];
int file_quantity = 0;
if (fscanf(stream, "%15s %d", file_name, &file_quantity) != 2) {
return fail_after_open(stream, argv[1]);
}

int character = fgetc(stream);
if (character != '\n' || fgetc(stream) != EOF || !feof(stream) || ferror(stream)) {
return fail_after_open(stream, argv[1]);
}

if (strcmp(input_name, file_name) != 0 || input_quantity != file_quantity) {
return fail_after_open(stream, argv[1]);
}

if (fclose(stream) == EOF) {
(void)remove(argv[1]);
fputs("file-close-error\n", stderr);
return EXIT_FAILURE;
}
if (remove(argv[1]) != 0) {
fputs("file-remove-error\n", stderr);
return EXIT_FAILURE;
}

printf("input=%s,%d\n", input_name, input_quantity);
printf("file=%s,%d\n", file_name, file_quantity);
puts("records=1");
puts("status=ok");
return EXIT_SUCCESS;
}

输出:

input=pears,7
file=pears,7
records=1
status=ok