跳到正文

文件与流式 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

三个必须:

  1. 长度参数传 sizeof buf,fgets 最多读 n-1 个字符并补 \0,所以它不会溢出(这是它取代 gets 的理由,gets 已从 C11 删除)。
  2. fgets 会保留行尾的 \n,比较前通常要去掉:buf[strcspn(buf, "\r\n")] = '\0';(\r 也要去,否则 Windows 上写的文件在 Linux 读会带尾 \r)。
  3. 必须判断”这一行是不是被截断了”——上面第 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 在真实库里的用法。