文件与流式 IO
环境同前:Windows 11 x64,MinGW-w64 GCC 13.1.0(运行时是 msvcrt)。本节两个”意外发现”都是实测出来的,不是照抄文档。
一、文本模式与二进制模式:Windows 上会咬人
FILE *f = fopen("io_test.bin", "w"); /* 文本模式 */
fputs("hello\nworld\n", f);
fclose(f);
/* 再用 "rb" 把原始字节读出来 */
文本模式写 "hello\nworld\n",按二进制读出 14 字节:
68 65 6c 6c 6f 0d 0a 77 6f 72 6c 64 0d 0a
(0d 0a = CRLF ⇒ Windows 文本模式把 LF 换成了 CRLF)
12 个字符写进去,磁盘上变 14 字节——每个 \n 被替换成 \r\n。读取时反向:0d 0a 变成单个 0a。Linux/macOS 上文本与二进制没有区别,所以这类 bug 只在跨平台时暴露。
规矩:
- 协议文件、图片、序列化数据、需要校验和或长度的东西,一律用
rb/wb。 用文本模式写二进制,数据会被悄悄改写。 - 只有”给人看的行式文本”才用文本模式。
- 需要精确控制行尾,就用二进制模式自己写
\r\n或\n。
顺带两个坑:fseek 的偏移在文本模式下不保证等于字节数(标准允许实现做转换记账),只有 rewind 和”以 SEEK_SET 定位到此前由 ftell 得到的值”是可靠的;'b' 在 POSIX 平台上被忽略,写了也无害。
二、打开模式:r+/w+/a+ 与读写交错
| 模式 | 文件不存在 | 文件存在 | 初始位置 | 能否截断 |
|---|---|---|---|---|
r / rb | 打开失败 | 只读 | 开头 | 否 |
w / wb | 创建 | 立刻清空 | 开头 | 是 |
a / ab | 创建 | 追加 | 结尾(每次写前都强制移到尾) | 否 |
r+ | 打开失败 | 读写 | 开头 | 否 |
w+ | 创建 | 清空 | 开头 | 是 |
a+ | 创建 | 读写追加 | 结尾(读位置仍是开头,实现相关) | 否 |
wx | 创建(独占) | 打开失败 | 开头 | 是 |
w 会立刻清空文件——哪怕你后面什么都没写
fopen(path, "w") 成功的那一刻文件就被截成 0 字节。用它”打开来读一下再写回”是数据丢失事故的常见来源。要读要写就用 r+,或者先读到内存再决定怎么写。
a 的”每次写前强制到末尾”是防丢写的机制
追加模式下即使你 fseek 到中间,下一次写仍会先移到末尾。这是给多进程写同一个日志文件用的(虽然不保证原子性,需要 O_APPEND 级别才能保证)。
C11 的 x 模式:本机不可用
wx 的语义是”文件已存在就失败”,用来避免”两个进程互相覆盖”的竞态。听起来完美,实测:
"wx" 第一次 失败,第二次 失败(x 模式:已存在就失败,防覆盖竞态)
第一次也失败——因为本机运行时是 msvcrt,它不支持 C11 新增的 x 标志,一律返回失败。结论:跨平台代码里不要用 x 模式;要做独占创建,Windows 上走 CreateFile 的 CREATE_NEW,POSIX 上走 open(O_CREAT|O_EXCL)。
三、读写交错:不 fseek 就写,数据会静默消失
C 标准对更新流(r+/w+/a+)有一条硬性规定:输入之后紧接着输出、或输出之后紧接着输入,中间必须插入一次 fflush 或文件定位操作(fseek/rewind/fsetpos),否则行为未定义。
f = fopen(PATH_T, "w+"); fputs("ABCDEF", f); fclose(f);
f = fopen(PATH_T, "r+");
int first = fgetc(f); /* 读了一个字符 */
long after_read = ftell(f); /* 位置 = 1 */
fputs("xy", f); /* 紧接着写:没有 fseek! */
fclose(f);
/* 再用 rb 读回内容 */
r+ 读一个字符后 ftell=1,紧接着写 "xy" → 文件内容 [ABCDEF]
写完全丢了:文件仍是 ABCDEF,没有报错、没有崩溃、返回的 fputs 也没说失败。这就是”未定义行为”最恶劣的形态之一。正确写法:
fputs("xy", f); /* 输出 */
fflush(f); /* 或 fseek(f, 0, SEEK_CUR) */
fseek(f, 0, SEEK_SET); /* 再切到输入 */
fscanf(f, "%s", buf);
顺便看清”覆盖写不会截断”:
w+ 写完 ftell=6,rewind 后覆盖写 → [xyCDEF](长度不变就是残留)
rewind 只是把位置拨回开头,不会清空。想截断要显式 ftruncate(POSIX)或 rewind + fclose,Windows 上用 _chsize_s。
四、缓冲:什么时候数据真的落盘
写 3 字节但还没 fflush:文件大小 = 0
fflush 之后:文件大小 = 3
setvbuf(_IONBF) 写 3 字节、不 fflush:文件大小 = 3
FILE 默认是全缓冲(_IOFBF),数据先在用户态缓冲区,攒满一块才 write() 一次。所以:
fclose会fflush,正常关闭不丢数据;exit/正常结束也会刷,但_exit、abort、段错误不会。- 崩溃时缓冲区里的东西全丢。这就是”日志明明打了却没写进文件”的原因。对策:关键日志
fflush或用stderr(它默认无缓冲,第三节实测的 stderr/stdout 顺序错位就是这个原因)。 - 行缓冲(
_IOLBF)只对”遇\n就刷”生效,适合终端;重定向到文件时 stdout 通常退化成全缓冲。 setvbuf必须在第一次读写之前调用,之后调用行为未定义。- 关缓冲(
_IONBF)会让每次fputc都进系统调用,性能可能差几十倍——只在确实需要即时性时用。
setvbuf 的三种模式:_IOFBF 全缓冲、_IOLBF 行缓冲、_IONBF 无缓冲。传 buf=NULL 让实现自己分配(大小被忽略)。
五、行读取:fgets 的三个必须
char small[16];
while (fgets(small, sizeof small, f)) { /* ... */ }
对一个 56 字节的文件、16 字节的缓冲区:
读到 [short\n] 长度 6 以换行结尾=1
读到 [this line is de] 长度 15 以换行结尾=0
读到 [finitely longer] 长度 15 以换行结尾=0
读到 [ than sixteen b] 长度 15 以换行结尾=0
读到 [ytes\n] 长度 5 以换行结尾=1
循环结束:feof=16 ferror=0
三个必须:
- 长度参数传
sizeof buf,fgets最多读n-1个字符并补\0,所以它不会溢出(这是它取代gets的理由,gets已从 C11 删除)。 fgets会保留行尾的\n,比较前通常要去掉:buf[strcspn(buf, "\r\n")] = '\0';(\r也要去,否则 Windows 上写的文件在 Linux 读会带尾\r)。- 必须判断”这一行是不是被截断了”——上面第 2~4 段末尾都没有
\n,说明一行被切成了三块。不检查就会把一条长记录当成三条。检测办法:读到的最后一个字符不是\n且不是错误,就说明后面还有内容,要么继续读,要么丢弃到行尾。
六、feof 必须在读失败之后才看
while (!feof(f)) { ch = fgetc(f); count++; } /* 错 */
while ((ch = fgetc(f)) != EOF) count++; /* 对 */
错误写法 while (!feof(f)) 把 2 个字符数成了 3
正确写法 while ((ch = fgetc(f)) != EOF) 得到 2
feof 不会预测下一次读会不会到末尾,它只在一次读操作真的撞上 EOF 之后才置位。所以 while (!feof(f)) 必然多执行一次循环体,里面拿到的是 EOF(值 -1)。如果循环体是 fgetc 后立刻当字符用,你就多处理了一个 -1;如果是 fscanf,字段还是旧值——这类 bug 的表现为”最后一行被处理了两遍”。
同理,fread/fwrite 的返回值是实际完成的项数,短读不等于出错:
请求 64 字节实际读到 2,feof=16 ferror=0(短读不等于出错)
判断到底是”到文件尾”还是”真出错”,必须分开看 feof() 和 ferror()。注意 feof 返回的是非 0 的标志位(本机是 16),不要拿它跟 1 比较,只用作真假。
七、这一节的自查清单
- 二进制数据一律
rb/wb,别用文本模式 - 不确定的场景不要用
"w"打开(会立刻清空),优先r+或先读进内存 - 不用 C11 的
x模式(msvcrt 不支持),独占创建走平台 API - 更新流里读↔写切换必须插
fflush/fseek,否则数据静默丢失 - 覆盖写之后要截断,
rewind不清空内容 - 关键日志
fflush,或写stderr;崩溃路径别指望缓冲区 -
setvbuf只在首次读写前调 -
fgets传sizeof buf、去掉\r\n、并检测长行被截断 - 循环条件写成”读操作本身返回失败”,不要
while (!feof(f)) -
fread/fwrite检查返回值,再用feof/ferror区分短读与错误 - 用完
fclose,remove/rename检查返回值
下一节讲可变参数与复杂声明:stdarg 手工实现 printf 类函数的正确姿势、sizeof/声明的读法口诀,以及柔性数组和 _Generic 在真实库里的用法。