文件 IO 与 fd:open/read/write/dup 与缓冲那点事
引言:从 stdio 走到系统调用
第 12 章我们用 fopen/fread/fwrite/fclose 跟文件打过照面,那会儿它们来自 <stdio.h>,是 C 标准库给的一套封装。可那套东西底下真正在干活的是谁?是操作系统提供的系统调用(system call,syscall)——open、read、write、close 这些。fopen 帮你把 fd 包成 FILE*、加上一层用户态缓冲、替你处理短读短写;好处是省心,代价是你离「机器真正在干什么」隔了一层棉花。这一章我们要把棉花掀掉,直接用最原始的系统调用跟内核说话。
掀掉之前,有一件尴尬事必须先摊开说:open/read/write/close 不是 ISO C 标准函数,它们是 POSIX(IEEE Std 1003.1,Unix 系操作系统的可移植性标准)规定的。ISO C 只认 <stdio.h> 那一套(fopen/fread/fwrite),它压根不知道什么叫「文件描述符」。所以从这一章起,我们正式走出 ISO C 的地界、迈进 POSIX——这是系统编程绕不过去的一步,也是为什么这章的代码顶上都多了一行 #define _POSIX_C_SOURCE 200809L(先记住这个悬念,马上真跑给你看它到底管什么用)。
换句话说,这一章的代码不再保证在「任何一个号称实现了 C 的环境」里都能跑——比如 Windows 的 MSVC 就没有 open/read(它有自己一套 _open/_read)。本章的所有真跑都在 Linux(WSL2)+ glibc 上完成,gcc 16.1.1、clang 22.1.6。这是系统编程的题中之意:一旦你开始跟内核打交道,「平台相关」就成了常态,诚实交代比假装可移植重要得多。
文件描述符:一个不起眼的小整数
掀开 stdio 之后,第一个撞进眼里的概念就是文件描述符(file descriptor,简称 fd)。它是什么?说白了就是一个小非负整数——不是什么神秘指针、不是句柄对象,就是 3、4、7 这种数字。内核给每个进程都维护一张「已打开文件」的小表,fd 就是这张表里的下标:你 open 一个文件,内核在某格里登个记,把格子的编号(下标)还给你;之后你 read/write/close 都只拿这个编号说话,内核凭编号反查回那张表。
这张表的前三格是预先占好的,每个进程一启动就有:0 号是 stdin、1 号是 stdout、2 号是 stderr——正好对上第 12 章那三个标准流。所以你新打开的文件,fd 不会是 0/1/2,从 3 开始;并且内核的分配规则是「挑最小的空闲号给你」。这两条加起来,跑一遍就全明白了:
#define _POSIX_C_SOURCE 200809L
#include <fcntl.h>
#include <stdio.h>
#include <unistd.h>
int main(void) {
int a = open("/tmp/cj/p5ch1/fa.txt", O_CREAT | O_WRONLY, 0644);
int b = open("/tmp/cj/p5ch1/fb.txt", O_CREAT | O_WRONLY, 0644);
printf("第一个文件 fd = %d\n", a); /* 0/1/2 被 stdin/out/err 占,新 fd 从 3 起 */
printf("第二个文件 fd = %d\n", b);
close(a); /* 释放 3 号 */
int c = open("/tmp/cj/p5ch1/fc.txt", O_CREAT | O_WRONLY, 0644);
printf("关掉第一个后再开,fd = %d\n", c); /* 最小空闲号回收 → 3 */
close(b);
close(c);
return 0;
}$ gcc -std=c11 -Wall fd_intro.c -o fd_intro && ./fd_intro
第一个文件 fd = 3
第二个文件 fd = 4
关掉第一个后再开,fd = 3头两个文件依次拿到 3、4,毫不意外;好戏在第三步——我把 3 号 close 掉之后再开一个新文件,它拿到的又是 3,因为 3 号格子刚空出来、是当前最小空闲号,内核就把它回收复用了。这个「最小空闲号」规则不是花架子,它正是 shell 做 2>&1、> file 这类重定向的底层原理——把某个已占用的编号 close 掉、再 dup 一个别的 fd 占到那个号上,后面对那个编号的读写就改道了(后面 dup2 那节亲手玩一次)。
顺带一提,fd 的数量不是无限的。每个进程有一个 fd 上限,可以用 getrlimit(RLIMIT_NOFILE, ...) 查、setrlimit 调,shell 里对应的命令是 ulimit -n。我这台机器默认上限是 1048576(一百万出头,够壕);等下面讲 close 的坑时,我会把这个上限故意压到 8,让你亲眼看看「忘了 close」会怎样撑爆这张表。
open(2):开门拿 fd
open 的活儿就一件:把一个路径打开(或新建),换成 fd 交给你。原型长这样(POSIX 版,头 <fcntl.h>):
int open(const char* path, int flags, ...);
int open(const char* path, int flags, mode_t mode); /* 当 flags 含 O_CREAT 时用这版 */path 是路径;flags 是一组标志位,决定「怎么开」;mode 是权限(只在你给了 O_CREAT、要新建文件时才用得上)。返回值是 fd(一个 >= 0 的小整数),失败返回 -1、并且把 errno 设上(errno 马上专讲)。
flags 分两类,拼的时候用按位或 | 叠起来。第一类是访问模式,三者互斥、必选其一:O_RDONLY(只读)、O_WRONLY(只写)、O_RDWR(读写)。第二类是修饰位,按需叠加,常用的有 O_CREAT(不存在就建)、O_TRUNC(如果文件已存在、且是写打开,就先把它截成长度 0)、O_APPEND(每次写都追加到文件末尾,后面多进程写日志就靠它)、O_EXCL(跟 O_CREAT 配合:文件已存在就反而失败,专门用来「保证只有我来建它」)。
mode 是权限位,写成八进制比如 0644( owner 读写、其他人只读)。这里有个真正的坑就地提醒一句:mode 只在 flags 里有 O_CREAT 的时候才被读——可 open 是变参的,你不写第三个参数它不会报错,于是「O_CREAT 加了、mode 忘写了」就会拿一个未指定的随机权限去建文件,轻则权限乱七八糟,重则在某些实现上建出谁都改不了的怪文件。所以纪律是:只要写了 O_CREAT,第三个参数 mode 就老老实实跟在后面,一眼都不能省。另外实际落地的权限还会被进程的 umask 削掉一层(你写 0666、umask 是 022,最后文件是 0644),这是 shell 的事,这里知道有这么个减法就够了。
把上面这些凑起来,真跑三个典型场景——只读打开一个不存在的文件、用 O_EXCL 首次创建、再 O_EXCL 同一个文件第二次:
展开代码 (共 28 行)收起代码
#define _POSIX_C_SOURCE 200809L
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>
int main(void) {
/* (a) 只读打开一个不存在的文件,又不给 O_CREAT —— 必败 */
int fd1 = open("/tmp/cj/p5ch1/no_such_file", O_RDONLY);
if (fd1 < 0) {
printf("只读打开不存在: 失败, errno=%d (%s)\n", errno, strerror(errno));
}
/* (b) O_CREAT|O_WRONLY|O_EXCL:不存在就建、存在就拒绝 */
int fd2 = open("/tmp/cj/p5ch1/excl.txt", O_CREAT | O_WRONLY | O_EXCL, 0644);
if (fd2 >= 0) {
printf("首次 O_EXCL 创建: 成功, fd=%d\n", fd2);
close(fd2);
}
/* (c) 文件已经在,再 O_EXCL 一次 —— 这次该栽在 EEXIST 上 */
int fd3 = open("/tmp/cj/p5ch1/excl.txt", O_CREAT | O_WRONLY | O_EXCL, 0644);
if (fd3 < 0) {
printf("再次 O_EXCL: 失败, errno=%d (%s)\n", errno, strerror(errno));
}
return 0;
}$ gcc -std=c11 -Wall open_flags.c -o of && ./of
只读打开不存在: 失败, errno=2 (No such file or directory)
首次 O_EXCL 创建: 成功, fd=3
再次 O_EXCL: 失败, errno=17 (File exists)三行输出把 open 的脾气展得明明白白:只读开一个不存在的文件,败在 errno=2(ENOENT,No such file or directory);第一次 O_EXCL 创建成功,拿到 fd=3;第二次对同一个文件再 O_EXCL,文件已经在那儿了,O_EXCL 的职责就是反手拒绝,败在 errno=17(EEXIST,File exists)。O_EXCL 这一手在写「单实例守护进程」「锁文件」「临时文件」时特别有用——它能保证「这个文件是我亲手建的、不是别人先建好的」,避免你一脚踏进别人留下的文件里。
顺便回应开头那个 _POSIX_C_SOURCE 的悬念。本章所有程序顶上都写着 #define _POSIX_C_SOURCE 200809L,它的意思是「请把 POSIX.1-2008 及其要求的符号都暴露给我」。为什么教程都劝你加它?因为严格 -std=c11 模式下编译器会定义 __STRICT_ANSI__,glibc 看到它就把一堆 POSIX 符号藏起来。可别凭印象觉得「藏起来了那 open 肯定就编译不过」——我自己也是这么以为的,真跑一遍才发现事情没这么干脆。下面是两个都不带 _POSIX_C_SOURCE 的小程序,唯一区别是一个用了 strdup、一个用了 open:
#include <string.h>
int main(void) {
char* s = strdup("hi");
return s != 0;
}#include <fcntl.h>
#include <unistd.h>
int main(void) {
int fd = open("/tmp/cj/p5ch1/x.txt", O_RDONLY);
if (fd >= 0)
close(fd);
return 0;
}同样 -std=c11 -Wall -Wextra、同样没加 macro,命运却截然相反:
$ gcc -std=c11 -Wall -Wextra no_macro_strdup.c -o nms # 用 strdup
no_macro_strdup.c: In function 'main':
no_macro_strdup.c:3:15: error: implicit declaration of function 'strdup'; did you mean 'strcmp'? [-Wimplicit-function-declaration]
3 | char* s = strdup("hi");
| ^~~~~~
| strcmp
no_macro_strdup.c:3:15: error: initialization of 'char *' from 'int' makes pointer from integer without a cast [-Wint-conversion]
$ gcc -std=c11 -Wall -Wextra no_macro_open.c -o nmo # 用 open/close
$ echo $?
0 # 居然编过了、零警告strdup 被门控得死死的——没有原型、gcc 14 起隐式声明从警告升级成硬 error(呼应阶段 0 第 10 章),编译直接失败、退出码 1。可 open/close 这两个同样属于 POSIX、同样不在 ISO C 里的调用,却恰好漏网、编过了还没半句警告(gcc、clang 都一样,退出码 0)。我把 glibc 在 -std=c11 下实际暴露的宏打出来看了一眼:_POSIX_C_SOURCE、_DEFAULT_SOURCE 都没定义、__USE_POSIX 也没开,门确实是关着的——但这几个基础调用就是从门缝里溜出来了(glibc 2.43 对 <fcntl.h>/<unistd.h> 里这几个历史悠久的核心入口放得格外松)。
这种「有的漏、有的卡」是 libc 实现的细节、不是标准承诺的:换 musl、换老版 glibc、换 BSD,漏的不一定还漏。所以正确的姿势依然是老老实实写 #define _POSIX_C_SOURCE 200809L(或者干脆 -std=gnu11)——这样不管哪个 libc、不管用哪个 POSIX 调用,都能稳稳拿到声明。这里压一句教训:断言 C/POSIX 行为之前一定自己真跑一遍,我这次就靠记忆差点写错——以为「没 macro 一定编不过」,实际 open 偏偏能过。
errno:只在出错时才有意义
刚才每行失败都配了一个 errno,现在把它说清楚。errno 是个 int,头 <errno.h>。它不是一个真全局变量那么简单——C11 起(ISO/IEC 9899:2011 §7.5)规定它有线程存储期(thread-local),也就是说每个线程各有一份自己的 errno,你在线程 A 里调用失败设上的 errno,不会串到线程 B 那儿去(这一点对后面阶段讲多线程至关重要,先记下)。它也不是「函数返回值」,而是「副作用」:库函数/系统调用在出错时,会顺手把一个能说明「错在哪」的正整数塞进 errno,比如 ENOENT=2、EEXIST=17、EMFILE=24。
把 errno 翻译成人话有两个常用手段:strerror(errno)(头 <string.h>)返回错误码对应的字符串;perror("前缀")(头 <stdio.h>)直接把「前缀: 错误字符串」打到 stderr。但 errno 最容易让人栽跟头的地方在于——它只在「刚才那个调用明确返回了错误」时才有意义。一个成功的调用没有义务把 errno 清零。这件事光说不踏实,真跑给你看:
#define _POSIX_C_SOURCE 200809L
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>
int main(void) {
int bad = open("/tmp/cj/p5ch1/definitely_not_here", O_RDONLY); /* 失败,errno=ENOENT */
printf("失败后 errno = %d (%s)\n", errno, strerror(errno));
int good = open("/tmp/cj/p5ch1/ok.txt", O_CREAT | O_WRONLY, 0644); /* 成功 */
printf("成功后 errno = %d (%s)\n", errno, strerror(errno)); /* 没被清零! */
if (bad >= 0)
close(bad);
if (good >= 0)
close(good);
return 0;
}$ gcc -std=c11 -Wall errno_resid.c -o er && ./er
失败后 errno = 2 (No such file or directory)
成功后 errno = 2 (No such file or directory)第一行 bad 那次 open 失败,errno=2 合情合理;可第二行 good 那次明明成功了,errno 居然还是 2——成功路径根本没帮我们把上一次的残留抹掉。这件事直接决定了一条写系统编程的铁律:判断一个调用成不成功,永远只看它的返回值(fd >= 0 还是 fd == -1),绝不能写 if (errno != 0) 来判成败——因为 errno 可能正揣着上一次失败留下的旧债。规矩就一句:errno 只在返回值告诉你「出错了」之后再去读,读一次就够,别在成功路径上搭理它。
write 与 read:字节进、字节出
有了 fd 和 errno,就可以真正读写文件了。read 和 write 的原型在 <unistd.h>:
ssize_t read(int fd, void* buf, size_t count);
ssize_t write(int fd, const void* buf, size_t count);两者的核心约定值得逐字记住。write:把 buf 里前 count 个字节写进 fd,返回实际写入的字节数(>= 0),失败返回 -1。read:从 fd 读最多 count 个字节进 buf,返回实际读到的字节数;返回 0 表示「读到文件末尾」(EOF,不是错误!);失败返回 -1。
类型上有个细节:count 是 size_t(无符号,ISO C §7.19,第 2 章见过),返回值却是 ssize_t(带符号的 size,POSIX,<sys/types.h>)——为什么带符号?因为返回值得既能表示「读了 N 字节」又能表示「出错了 -1」,无符号可装不下 -1。所以打印这俩类型时,size_t 用 %zu、ssize_t 用 %zd,别混(第 12 章说过格式说明符不匹配是 UB)。
来一个最朴素的写完再读:
展开代码 (共 25 行)收起代码
#define _POSIX_C_SOURCE 200809L
#include <fcntl.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>
int main(void) {
/* --- 写 --- */
int fd = open("/tmp/cj/p5ch1/hello.txt", O_CREAT | O_WRONLY | O_TRUNC, 0644);
const char* msg = "hello, file IO\n";
ssize_t n = write(fd, msg, strlen(msg));
printf("write 写了 %zd 字节\n", n);
close(fd);
/* --- 读 --- */
fd = open("/tmp/cj/p5ch1/hello.txt", O_RDONLY);
char buf[64];
n = read(fd, buf, sizeof(buf) - 1);
if (n >= 0) {
buf[n] = '\0'; /* read 不管字符串结尾,自己补 \0 */
printf("read 读到 %zd 字节: %s", n, buf);
}
close(fd);
return 0;
}$ gcc -std=c11 -Wall write_read.c -o wr && ./wr
write 写了 15 字节
read 读到 15 字节: hello, file IOwrite 写了 15 字节("hello, file IO\n" 正好 15 个字节),read 又把这 15 字节原样读回来。这段代码把字节搬进栈上的 buf,我另外用 -fsanitize=address,undefined 复核过一遍,退出码 0、没有内存错误——本章凡是涉及往缓冲区里读写的例子都照此过 ASan。代码里有个坑就地提醒:read 只负责把字节搬进 buf,它不会帮你补字符串结尾的 '\0'——buf 是你开的字符数组,里面装的是裸字节,不补 '\0' 就直接当字符串 printf("%s") 用,会一路读越过本次读到的数据、踩到 buf 里原来的垃圾值直到撞见偶然的 \0。所以上面我在 buf[n] 处手动补了一个 '\0',这才敢当字符串用。记住:read 给你的是一段字节流,不是 C 字符串;要当字符串用,自己负责结尾。
短读短写:别假设一次到底
刚才那个例子 write 一次写了 15 字节、read 一次读了 15 字节,正好都「一次到底」。但你千万别据此总结出「调一次 write 就一定把我要写的都写完了」——这正是 read/write 最坑人的地方:它们都允许「短读短写」,也就是返回的字节数可以小于你请求的 count,剩下的你得自己接着读/写。
read 的短读最好复现——对一个只有 6 字节的文件,我张口要 4096 字节,它只会把实际存在的 6 字节给我:
#define _POSIX_C_SOURCE 200809L
#include <fcntl.h>
#include <stdio.h>
#include <unistd.h>
int main(void) {
/* 先建一个只有 6 字节的小文件 */
int w = open("/tmp/cj/p5ch1/small.txt", O_CREAT | O_WRONLY | O_TRUNC, 0644);
write(w, "hello\n", 6);
close(w);
/* 然后张口要 4096 字节,看实际给多少 */
int fd = open("/tmp/cj/p5ch1/small.txt", O_RDONLY);
char buf[4096];
ssize_t n = read(fd, buf, sizeof(buf));
printf("要了 %zu 字节, 实际读到 %zd 字节\n", sizeof(buf), n);
close(fd);
return 0;
}$ gcc -std=c11 -Wall short_read.c -o sr && ./sr
要了 4096 字节, 实际读到 6 字节要了 4096,实得 6——这就是一次短读。这个例子里的短读其实是因为很快撞上 EOF(文件就那么大),读完了自然停;但「短读」真正让人头秃的场景是还没到 EOF 也只给你一部分——读管道、读 socket 时,对端先写了一小段、或内核缓冲区暂时只有那么多,read 就会先把你能拿到的那点给你返回,让你决定要不要继续等。短写同理:往管道、socket 写一大批数据,对端缓冲区快满了,write 可能只写进去前一半就返回了,告诉你「这次先写这么多」。
诚实地补一句:对普通磁盘文件,Linux 上 write/read 一般会尽量一次到底(像刚才那两次 15 字节),但这只是「通常」、不是标准给你的承诺。POSIX read(2)/write(2) 的语义白纸黑字写着「返回值可能小于 nbyte」。所以只要你想保证写满 N 字节,就必须套个循环,一次没写完就接着写剩下的。下面这个 write_all 是工程里几乎人手一份的标配:
展开代码 (共 31 行)收起代码
#define _POSIX_C_SOURCE 200809L
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>
/* 把 count 字节完整写下去:处理「短写」和被信号打断的 EINTR。失败返回 -1。*/
ssize_t write_all(int fd, const void* buf, size_t count) {
const char* p = buf;
size_t done = 0;
while (done < count) {
ssize_t n = write(fd, p + done, count - done);
if (n < 0) {
if (errno == EINTR)
continue; /* 被信号打断:不算错,重试 */
return -1; /* 别的真错误 */
}
done += (size_t) n; /* n 可能 < 剩余量,继续凑 */
}
return (ssize_t) done;
}
int main(void) {
int fd = open("/tmp/cj/p5ch1/wall.txt", O_CREAT | O_WRONLY | O_TRUNC, 0644);
const char* msg = "这条字符串故意写长一点,演示即使被拆成几段也能凑完整。\n";
ssize_t n = write_all(fd, msg, strlen(msg));
printf("write_all 共写入 %zd 字节\n", n);
close(fd);
return 0;
}$ gcc -std=c11 -Wall write_all.c -o wa && ./wa
write_all 共写入 80 字节这个循环干两件事:一是凑够字节数——done 记着已经写下去多少,每次只写「剩下的」count - done,只要 done < count 就接着写,直到全写完;二是处理 EINTR——write 有可能被到达的信号打断,这时它返回 -1 且 errno == EINTR,这不是真错误,重试就行(信号是阶段后面专门一章的大题目,这里先认下 EINTR 这个名字、知道「被信号打断要重试而不是报错退出」就够了)。read 的对应版本叫 read_all 或 readn,思路完全对称,留给你当练习。
close 与 fd 泄漏:开门别忘了关
close(fd)(<unistd.h>)干的事和 open 相反:告诉内核「这个 fd 我用完了,把那张表里的格子释放掉」。返回 0 表示成功、-1 表示失败。看起来最没戏份的一步,却是长跑进程最容易栽的地方——忘了 close,fd 就一直占着格子不还。普通短命程序无所谓(进程一退出,内核把它打开的所有 fd 统统回收),可一个守护进程、一个服务器,跑着跑着 fd 表就被漏光了。
要把这个后果亲眼复现,不用真去漏一百万个——用 setrlimit(RLIMIT_NOFILE, ...) 把本进程的 fd 上限压到 8,再循环 open 不 close,几下就撑爆:
展开代码 (共 25 行)收起代码
#define _POSIX_C_SOURCE 200809L
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <string.h>
#include <sys/resource.h>
#include <unistd.h>
int main(void) {
/* 把本进程的 fd 上限压到 8,快速复现「不 close 撑满 fd 表」的后果 */
struct rlimit rl = {8, 8};
setrlimit(RLIMIT_NOFILE, &rl);
int count = 0;
for (;;) {
int fd = open("/tmp/cj/p5ch1/leak.txt", O_CREAT | O_WRONLY, 0644);
if (fd < 0) {
printf("第 %d 次 open 失败: errno=%d (%s)\n", count + 1, errno, strerror(errno));
break;
}
count++; /* 故意不 close,fd 越漏越多 */
}
printf("一共泄漏 %d 个 fd 就撑爆了表\n", count);
return 0;
}$ gcc -std=c11 -Wall close_leak.c -o cl && ./cl
第 6 次 open 失败: errno=24 (Too many open files)
一共泄漏 5 个 fd 就撑爆了表上限压到 8,意味着本进程只能用 fd 0..7;其中 0/1/2 被 stdin/stdout/stderr 占着,所以我每次 open 拿到的是 3、4、5、6、7——连漏 5 个之后,8 个格子全满,第 6 次 open 直接被内核拒绝,errno=24(EMFILE,Too many open files)。一个 ulimit -n 8 的微缩模型,把「服务器跑半年突然再也开不动文件、连个 socket 都建不起来」的惨剧浓缩成了 5 次 open。纪律也就这一句:开一个 fd,用完立刻 close,越早越好;尤其在错误处理路径上,别一遇到错就 return、把已经打开的 fd 丢在身后(C 没有 RAII,没人替你兜底)。
文件偏移与 lseek:在文件里跳着读写
每个打开的 fd,内核都给它记着一个「当前偏移量」(offset)——一个表示「下一次 read/write 从文件的第几号字节开始」的位置。read/write 每次成功后会自动把这个偏移往后推进实际读写的字节数,所以你连续 read 就能依次把文件读完,不用自己记位置。
想主动跳到某个位置呢?用 lseek(<unistd.h>,注意是 l 开头不是 fseek——fseek 是 stdio 那套给 FILE* 用的):
off_t lseek(int fd, off_t offset, int whence);whence 决定 offset 怎么解释:SEEK_SET(从文件开头算,绝对位置)、SEEK_CUR(从当前位置算,相对)、SEEK_END(从文件末尾算)。返回新的偏移量(类型 off_t,POSIX 的文件偏移类型,<sys/types.h>)。lseek 的一个好玩用法是 lseek(fd, 0, SEEK_END)——跳到末尾、返回值就是文件大小,不用调别的接口。来一次完整的「写完→跳回开头读→跳到末尾外再写→读洞」:
展开代码 (共 34 行)收起代码
#define _POSIX_C_SOURCE 200809L
#include <fcntl.h>
#include <stdio.h>
#include <unistd.h>
int main(void) {
int fd = open("/tmp/cj/p5ch1/seek.txt", O_CREAT | O_RDWR | O_TRUNC, 0644);
write(fd, "ABCDEF", 6);
/* 把偏移拉回开头,再读 */
lseek(fd, 0, SEEK_SET);
char buf[6];
read(fd, buf, 6);
printf("回到开头读: %.6s\n", buf);
/* 跳到第 15 号位置写一个 X —— 中间 6..14 成了「洞」*/
lseek(fd, 15, SEEK_SET);
write(fd, "X", 1);
/* 用 SEEK_END 量出文件多大 */
off_t sz = lseek(fd, 0, SEEK_END);
printf("文件大小 = %lld 字节\n", (long long) sz);
/* 读洞里那 9 个字节,稀疏文件的洞读出来全是 0 */
lseek(fd, 6, SEEK_SET);
char hole[9];
read(fd, hole, 9);
printf("洞里的 9 字节:");
for (int i = 0; i < 9; i++)
printf(" %d", hole[i]);
printf("\n");
close(fd);
return 0;
}$ gcc -std=c11 -Wall lseek_demo.c -o ls && ./ls
回到开头读: ABCDEF
文件大小 = 16 字节
洞里的 9 字节: 0 0 0 0 0 0 0 0 0这里发生了一件有意思的事:我先写了 6 个字节 ABCDEF,然后直接 lseek 跳到第 15 号位置、再写一个 X。中间第 6..14 号那 9 个字节我根本没写过——可文件照样有 16 字节长(SEEK_END 给出 16),而那 9 个「没写过」的字节读出来全是 0。这就是所谓的稀疏文件(sparse file):跨过去没写的部分不占真实磁盘块、只是一个「洞」,读的时候内核用 \0 填给你。有些场景(比如数据库、虚拟机磁盘镜像)里头大量连续的零,稀疏文件能省一大笔磁盘。这里把它当 lseek 的副产品见识一下,重点是记住偏移量可以随便跳、不一定连续。
dup 与 dup2:复制 fd 就是复制一条路
最后一个 fd 操作——dup 和 dup2(<unistd.h>)。它俩干的事是「复制一个 fd」:让一个新的 fd 编号指向同一个打开文件(同一个偏移、同一份权限)。dup(oldfd) 返回最小空闲号当新 fd;dup2(oldfd, newfd) 更强——它强制把 oldfd 复制到你指定的 newfd 编号上(如果 newfd 已经开着,先默默 close 它)。
听起来抽象,可它的杀手级应用就一个词:重定向。回想 fd 那一节——0/1/2 是 stdin/stdout/stderr,而 printf 默认就是往 fd 1 写。如果我用 dup2 把一个普通文件的 fd 复制到编号 1 上,那此后所有 printf(往 fd 1 写)就全改道进了那个文件,这正是 shell 里 > file 的底层原理。亲手玩一次:
展开代码 (共 23 行)收起代码
#define _POSIX_C_SOURCE 200809L
#include <fcntl.h>
#include <stdio.h>
#include <unistd.h>
int main(void) {
/* 先偷偷记下真正的 stdout,等会儿好还原 */
int saved = dup(1);
int fd = open("/tmp/cj/p5ch1/redirected.txt", O_CREAT | O_WRONLY | O_TRUNC, 0644);
dup2(fd, 1); /* 把 fd 1(stdout)改指向我们的文件 */
close(fd); /* 有了 fd 1 这条路,原 fd 多余了 */
/* 这句 printf 不上屏幕,改道进了文件 */
printf("这行被重定向,写进了文件\n");
fflush(stdout); /* stdio 是有缓冲的,不刷不一定落(下一节细讲) */
/* 把真正的 stdout 还原回去 */
dup2(saved, 1);
close(saved);
printf("还原 stdout,这行才上屏幕\n");
return 0;
}$ gcc -std=c11 -Wall dup2_redirect.c -o dr && ./dr
还原 stdout,这行才上屏幕
$ cat /tmp/cj/p5ch1/redirected.txt
这行被重定向,写进了文件屏幕上只看到「还原 stdout,这行才上屏幕」一句,而「这行被重定向」那句没上屏幕——它进了 redirected.txt,cat 一下文件就能看到。整个戏法的关键就是 dup2(fd, 1):它把 fd 1(stdout)原来指向的终端顶掉、换成指向我们开的文件,从此 printf 写 fd 1 就是写文件。完事再用 dup2(saved, 1) 把一开始用 dup(1) 备份的原 stdout 还原回去。注意我中间写了一句 fflush(stdout)——这其实是给下一节埋的伏笔:printf 是 stdio、它在用户态有缓冲,不刷的话数据可能还卡在缓冲里没进内核,重定向的效果就看不见。这个坑马上讲透。
顺带埋一句(不展开,留给后面进程章):dup 出来的新 fd,默认没有 FD_CLOEXEC 标志——意思是如果你 fork 出子进程再 exec 换成另一个程序,这个 fd 会漏给被 exec 的程序,造成意料之外的 fd 泄漏。要用 fcntl 的 F_DUPFD_CLOEXEC 或显式设 FD_CLOEXEC 来堵这个口子。这里只认下这个名词,等讲 fork+exec 时再亲手踩。
两套缓冲:stdio 用户态 vs 内核页缓存
这一节是本章的另一个核心,也是从 stdio 走到 syscall 以后必须重新理一遍的事——缓冲分两层,别再混为一谈。
第一层是 stdio 自己的用户态缓冲。第 12 章提过,FILE*(fopen 给你的那个)在用户态内存里维护一个小缓冲区(默认几 KB),你 fprintf/fread 的数据先进这儿,攒够一批、或遇到换行(行缓冲)、或缓冲满了,才一次性通过 write 系统调用送进内核。这一层的存在是为了减少 syscall 次数——每进一次内核都是开销,攒一批再送划算。普通磁盘文件默认是全缓冲(攒满才送),终端是行缓冲(遇 \n 就送),stderr 是无缓冲(立刻送)。
第二层是 内核里的页缓存(page cache)。即便你用裸 write 系统调用绕开 stdio,数据进了内核也不是立刻就写到磁盘硬件——内核先把它收进页缓存这一层,择机(一定时间后、或内存紧张、或你主动要求)才真正落盘。这一层是为了减少磁盘 IO 次数、提升读写性能。
理解了这两层,下面这几个常被搅在一起的名字就能分清了。fflush(FILE*) 只刷第一层:它把 stdio 用户态缓冲里还没送的数据,推过 write 这一关、送进内核的页缓存——但到此为止,它没有保证数据到了磁盘硬件。fsync(fd) 才刷第二层:它命令内核把这个 fd 对应的、还赖在页缓存里的脏页真正写到磁盘设备上、等设备确认落盘了才返回。两者不可互相替代:只 fflush 不 fsync,断电还是丢数据(数据在内核页缓存里、没到磁盘);只 fsync 不 fflush,那 stdio 缓冲里还没 fflush 的数据连内核都没进、fsync 自然也管不着它。真正要保证数据落盘,得先 fflush 再 fsync,缺一不可。
要把这套缓冲「看得见」,最能说明问题的就是 _exit 和 stdio 缓冲的交锋。_exit(<unistd.h>,POSIX)是裸 syscall 退出——它直接让内核终结进程,不走 C 库的退出收尾流程,不会帮你 flush 任何 stdio 缓冲。而 exit(<stdlib.h>,ISO C §7.22.4.4)是 C 库的退出——它会先调用所有用 atexit 注册的清理函数、把所有 stdio 流都 flush 一遍,再调 _exit。两者的差别,恰好能把「stdio 缓冲里还没送内核的数据」暴露出来:
#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main(int argc, char** argv) {
int mode = (argc > 1) ? atoi(argv[1]) : 0;
FILE* fp = fopen("/tmp/cj/p5ch1/buf.txt", "w"); /* 普通文件 = 全缓冲 */
fprintf(fp, "还在 stdio 用户态缓冲里的数据\n");
if (mode == 0) {
_exit(0); /* 裸 syscall 退出:不刷 stdio,数据原地蒸发 */
}
fflush(fp); /* mode==1:先把 stdio 缓冲推进内核,再 _exit */
_exit(0);
}$ gcc -std=c11 -Wall buffer_exit.c -obe && ./be 0 # mode 0:只 _exit,不 flush
$ wc -c < /tmp/cj/p5ch1/buf.txt
0
$ ./be 1 # mode 1:先 fflush 再 _exit
$ wc -c < /tmp/cj/p5ch1/buf.txt
41两组对照干净利落。mode 0 那次,fprintf 把数据写进了 stdio 的用户态缓冲、但还没送内核,紧接着 _exit 裸退出、根本不 flush——结果文件 0 字节,那句「还在 stdio 用户态缓冲里的数据」原地蒸发了。mode 1 那次,_exit 之前先 fflush(fp),把 stdio 缓冲推进了内核(进了页缓存),再 _exit 就没事了——文件 41 字节,数据保住了。
这个对照把三件事一次讲透:其一,stdio 缓冲确实存在,没 flush 的数据就飘在用户态、没进内核;其二,_exit 不刷 stdio,而 exit 会(你把 _exit 换成 exit 跑 mode 0,数据照样保得住,因为 exit 帮你 flush 了所有流);其三,前面 dup2 那节我特意写的 fflush(stdout) 不是多余的——printf 是 stdio、有用户态缓冲,重定向到文件后是全缓冲、不会自动按 \n 送,不 fflush 那句很可能就卡在缓冲里看不见。系统编程里一个频繁踩的坑就是 fork 之后子进程用 _exit 退出、管道里的输出却莫名其妙少了半截——根子就在这儿,等下一章讲 fork 时还会再撞见它。
诚实地收一句关于 fsync 的话:这一节我用 _exit 把「stdio 缓冲 vs 内核」的边界演示得很清楚了,但「内核页缓存 vs 磁盘硬件」这层(也就是 fsync 的主战场)在普通教学环境里很难肉眼区分——fsync 真正的价值在断电、在数据库的持久性保证,这些光在我们这种 WSL2 + 普通文件系统上很难「演」出来。所以这里我只把 fflush/fsync/_exit 三者的边界讲准(fflush 刷 stdio→内核、fsync 刷内核→磁盘、_exit 啥都不刷),至于 fsync 的真落盘演示,等你写数据库类项目时再去亲手体会——那种「fsync 慢得能感觉到」的量级,比我在这里造个假演示有说服力得多。
顺手管一下 stdio 这一层:setvbuf 与 fread/fwrite 的脾气
上面讲的是「stdio 这层存在、它怎么被 _exit 整吞」;可既然 stdio 这层躲不开,我们也该顺手把它能调的那些旋钮认识一下——setvbuf 改缓冲策略、fread/fwrite 的返回值到底算什么、feof 这个状态位到底在什么时候才置位。这些是上一章 stdio 留的尾巴,正好趁讲完两层缓冲一起补齐,免得后面写真正的工程代码时一个一个地栽进去。
setvbuf:自己改 stdio 的缓冲脾气
stdio 默认给每个流配的缓冲模式不一定合你心意——比如想让一个小日志流完全不缓冲(每个字符立刻打 syscall,方便实时观察)、或者想给它换一块更大的自定义缓冲。setvbuf(<stdio.h>,ISO C §7.21.5.6)就是干这个的,原型是 int setvbuf(FILE* restrict stream, char* restrict buf, int mode, size_t size):mode 三选一,_IOFBF(全缓冲,攒满才刷)、_IOLBF(行缓冲,遇 \n 就刷)、_IONBF(不缓冲,立刻刷);buf 和 size 让你塞一块自己的缓冲进去(buf 传 NULL、size 传 0,就让 stdio 自己分配)。
光说三档模式不够直观,我们直接把切换前后的缓冲区大小量出来给你看。怎么量?用 glibc 的私有扩展 __fbufsize(FILE*)——它不在 ISO C 里、连 POSIX 都不算,纯粹是 glibc 给的偷窥接口,所以 extern size_t __fbufsize(FILE*); 自己声明一下就能用(头文件里没有它的原型)。下面这段三档连切、每切一次就用 __fbufsize 量一遍:
展开代码 (共 30 行)收起代码
#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
/* glibc 私有扩展:偷看 FILE 内部缓冲区。仅 Linux/glibc 有,演示用,
头文件没原型,自己 extern 声明一下即可 */
extern size_t __fbufsize(FILE *stream);
extern int __flbf(FILE *stream); /* 是否行缓冲 */
int main(void) {
FILE *f = fopen("/tmp/cj/p5ch1/setvbuf.txt", "w");
if (!f) {
perror("fopen");
return 1;
}
fputs("seed\n", f); /* seed 一下,让默认缓冲区物化(避免读到 lazy alloc 前的 0) */
printf("默认磁盘流: 缓冲=%zu 行缓冲=%d\n", __fbufsize(f), __flbf(f));
setvbuf(f, NULL, _IONBF, 0); /* 改成无缓冲:之后每个字符立刻打 syscall */
fputs("seed2\n", f);
printf("setvbuf(_IONBF) 后: 缓冲=%zu 行缓冲=%d\n", __fbufsize(f), __flbf(f));
char mybuf[16];
setvbuf(f, mybuf, _IOFBF, sizeof mybuf); /* 改成自定义 16 字节全缓冲 */
fputs("seed3\n", f);
printf("setvbuf(_IOFBF,16): 缓冲=%zu 行缓冲=%d\n", __fbufsize(f), __flbf(f));
fclose(f);
return 0;
}$ gcc -std=c11 -Wall -Wextra setvbuf_demo.c -o svb && ./svb
默认磁盘流: 缓冲=4096 行缓冲=0
setvbuf(_IONBF) 后: 缓冲=1 行缓冲=0
setvbuf(_IOFBF,16): 缓冲=16 行缓冲=0三行输出把 setvbuf 的脾气全摆出来了:默认磁盘流缓冲是 4096 字节、行缓冲=0(也就是全缓冲,符合上一章 file_internals.c 实测的那 4KB);切成 _IONBF 之后 __fbufsize 报 1——注意这「1」不是说它真给你开了 1 字节缓冲,而是 glibc 内部用一个 1 字节的占位表示「这流不缓冲」,每次写入就直接打 write;再切成 _IOFBF 配 16 字节自定义缓冲,量出来就是 16——你给什么它就用什么。这台机器默认是 4096(不是某些老教材想当然的 512 或 1024,别凭记忆写,真跑一下最稳)。
用 setvbuf 有两条铁律必须刻进去。第一条:它必须在流做任何 I/O 之前调用——fopen 之后、fread/fwrite/fputs/fgetc 任何一次之前就得调好。一旦流上有过哪怕一次 I/O,stdio 内部的缓冲区已经按默认模式物化、可能还攒进了数据,这时再 setvbuf 等于在它脚下抽地毯,ISO C 原话是「behavior is undefined」——表现可能是缓冲被静默丢弃、可能是数据错位、可能看着没事但换个 libc 就炸。上面这段代码我每个 setvbuf 后都 fputs 一次 seed、但第一次 setvbuf 是在第一次 fputs("seed\n") 之后——这其实是利用了「seed 让默认缓冲物化」这个副作用来量出 4096,真要严格按铁律写工程代码,应该 fopen 完立刻 setvbuf、一次到位、之后再开始读写。第二条:你传进去的自定义 buf,在 fclose 之前绝对不能失效——这意味着 buf 不能是函数里那个 char mybuf[16] 出了函数就没了的栈数组,除非你保证 fclose 也在同一作用域内(像上面 demo 那样全塞在 main 里没问题);更不能是 setvbuf 之后被 free 掉的堆内存。stdio 不会复制你给的 buf,它只是记下指针、之后所有 I/O 都直接往这块内存里读写——buf 一旦失效,每一次读写都在踩已释放内存,那种崩溃是出了名地难查。
fread/fwrite 返回的是「元素个数」不是字节
写惯了 read/write(返回字节、ssize_t)的人,碰 fread/fwrite 特别容易栽在返回值的语义上。看签名:
size_t fread(void *ptr, size_t size, size_t nmemb, FILE *stream);
size_t fwrite(const void *ptr, size_t size, size_t nmemb, FILE *stream);size 是单个元素的字节数,nmemb 是元素个数。返回值类型是 size_t(无符号),它给的不是写入/读出的字节总数,而是「成功完成的完整元素个数」——也就是 nmemb 那一维。fwrite(arr, sizeof(int), 5, f) 全部写成功就返回 5,不是 5 * sizeof(int) = 20。这一处差异看着小,写错了要么日志数字对不上、要么把「写了 5 个」误判成「写了 5 字节、还差 15 字节」从而进死循环补写。
要分清「返回 < nmemb 时到底是出错还是到尾」,得请出 ferror/feof。下面这段把「写 3 个 Student 元素→fseek 随机跳到第 3 个读→读到文件末尾再读」一次性演示,重点看返回值和 feof/ferror 的配合:
展开代码 (共 59 行)收起代码
#include <stdio.h>
#include <stddef.h>
typedef struct {
int id;
char name[16];
float score;
} Student;
int main(void) {
Student roster[3] = {
{1, "alice", 90.5f},
{2, "bob", 77.0f},
{3, "carol", 88.25f},
};
FILE *f = fopen("/tmp/cj/p5ch1/students.dat", "wb");
if (!f) {
perror("fopen wb");
return 1;
}
size_t n = fwrite(roster, sizeof(Student), 3, f); /* 返回完整元素个数,不是字节 */
printf("sizeof(Student) = %zu\n", sizeof(Student));
printf("请求写 3 个元素, fwrite 返回: %zu\n", n);
if (n != 3) {
if (ferror(f))
perror("写出错");
fclose(f);
return 1;
}
fclose(f);
f = fopen("/tmp/cj/p5ch1/students.dat", "rb");
if (!f) {
perror("fopen rb");
return 1;
}
Student one;
size_t got = fread(&one, sizeof(Student), 1, f); /* 故意只读 1 个 */
printf("读第 1 个: got=%zu id=%d name=%s score=%.2f\n", got, one.id,
one.name, one.score);
fseek(f, 2 * (long)sizeof(Student), SEEK_SET); /* 随机跳到第 3 个 */
Student third;
got = fread(&third, sizeof(Student), 1, f);
printf("读第 3 个: got=%zu id=%d name=%s score=%.2f\n", got, third.id,
third.name, third.score);
fseek(f, 0, SEEK_END); /* 跳到末尾再读:fread 返回 0、feof 为真、ferror 为 0 */
Student over;
got = fread(&over, sizeof(Student), 1, f);
printf("文件末尾再读: fread 返回 %zu, feof=%d, ferror=%d\n", got, feof(f),
ferror(f));
fclose(f);
return 0;
}$ gcc -std=c11 -Wall -Wextra fread_nmemb.c -o frn && ./frn
sizeof(Student) = 24
请求写 3 个元素, fwrite 返回: 3
读第 1 个: got=1 id=1 name=alice score=90.50
读第 3 个: got=1 id=3 name=carol score=88.25
文件末尾再读: fread 返回 0, feof=1, ferror=0
$ wc -c /tmp/cj/p5ch1/students.dat
72 /tmp/cj/p5ch1/students.dat对着输出逐条核:fwrite 请求写 3 个元素、返回 3(不是 72);sizeof(Student)=24(int 4 + char[16] 16 + float 4,glibc 这套布局正好 24,没有填充),3 个元素落盘就是 72 字节,wc -c 印证了。读那两次 fread 都请求 1 个元素、各返回 1,fseek 跳到第 3 个读出来正是 carol——fseek 在 FILE* 上干的活,对应裸 syscall 那边的 lseek,只是参数从 fd 换成了 FILE*。最值得看的是末尾那行:跳到 SEEK_END 再读,fread 返回 0、feof=1、ferror=0——这次「少于请求量」的原因是到了文件尾(feof 被置位),不是出错(ferror 保持 0)。纪律也就这一条:fread/fwrite 返回值不等于 nmemb 时,用 ferror/feof 二选一判断是出错还是到尾,别瞎猜。这跟裸 syscall 那边「read 返回 -1 看 errno、返回 0 是 EOF」是同一类思路,只是 stdio 把状态拆成了两个独立的标志位。
feof 是事后状态,不是事前预判
上一段末尾那个 feof,其实藏着 stdio 最经典的一个坑。先把它的语义咬死:feof(f) 只有在「已经发生过一次读到 EOF 的读操作之后」才为真,它不会预判「下一次读会不会到尾」。换句话说,feof 是事后的状态读,不是事前的预判——它读的是「上一次 I/O 是不是撞了 EOF 标志」,而不是「这个流接下来还有没有数据」。
这个语义直接杀掉了一种特别诱人的写法:while (!feof(f)) { fread(...); 处理(...); }。看着像「只要还没到尾就继续读」,可实际上 feof 在「最后一次成功读」之后还没被置位(置位要等下一次读到 EOF),于是循环会多进一次——这次 fread 返回 0、什么都没读到,可循环体里的「处理」照样跑了一遍,把上一轮残留在变量里的旧数据又当新数据处理一次。空口说太抽象,真跑给你看,下面这段往文件里只放 3 个 int,分别用「错法」和「正法」去读,循环进了几次一目了然:
展开代码 (共 45 行)收起代码
#include <stdio.h>
#define FILENAME "/tmp/cj/p5ch1/three.dat"
#define COUNT 3
int main(void) {
FILE *w = fopen(FILENAME, "wb");
if (!w) {
perror("fopen wb");
return 1;
}
int src[COUNT] = {10, 20, 30};
fwrite(src, sizeof(int), COUNT, w);
fclose(w);
/* ===== 错法: while(!feof) 当条件, 循环体里 fread ===== */
FILE *f = fopen(FILENAME, "rb");
int v = -999;
int iters_bad = 0, last_bad = -999;
while (!feof(f)) {
size_t got = fread(&v, sizeof(int), 1, f);
iters_bad++;
last_bad = v; /* 不检查 got: 拿 v 就处理 */
printf("[错法] 第 %d 次进循环: fread 返回 %zu, v=%d\n", iters_bad, got,
v);
}
fclose(f);
printf("[错法] 循环共进了 %d 次, 最后一轮 v=%d (注意这个 %d 没在文件里)\n\n",
iters_bad, last_bad, last_bad);
/* ===== 正法: 用 fread 返回值当条件 ===== */
f = fopen(FILENAME, "rb");
int iters_good = 0;
size_t got;
int v2;
while ((got = fread(&v2, sizeof(int), 1, f)) == 1) {
iters_good++;
printf("[正法] 第 %d 次进循环: fread 返回 %zu, v=%d\n", iters_good, got,
v2);
}
fclose(f);
printf("[正法] 循环共进了 %d 次, 退出时 fread 返回 %zu\n", iters_good, got);
return 0;
}$ gcc -std=c11 -Wall -Wextra feof_loop.c -o fpl && ./fpl
[错法] 第 1 次进循环: fread 返回 1, v=10
[错法] 第 2 次进循环: fread 返回 1, v=20
[错法] 第 3 次进循环: fread 返回 1, v=30
[错法] 第 4 次进循环: fread 返回 0, v=30
[错法] 循环共进了 4 次, 最后一轮 v=30 (注意这个 30 没在文件里)
[正法] 第 1 次进循环: fread 返回 1, v=10
[正法] 第 2 次进循环: fread 返回 1, v=20
[正法] 第 3 次进循环: fread 返回 1, v=30
[正法] 循环共进了 3 次, 退出时 fread 返回 0看清楚了——文件里明明只有 3 个 int(10、20、30),错法那个循环进了 4 次。前 3 次都正常:fread 返回 1、v 分别是 10/20/30。可第 4 次 fread 返回 0(没读到东西),v 里残留着上一轮的 30,循环体照样把这次「空读」当一次有效数据处理——这就是 while (!feof) 多跑一次的实物证据。如果这个循环体是「往另一个文件写一份摘要」「累加一个计数」「往链表里塞一个节点」,那后果就是日志里多一条重复记录、统计数字大了一格、链表尾多一个重复节点——这种 bug 不是崩溃,是数据悄悄错一格,特别难查。正法那次循环进了 3 次、干净利落,退出时 fread 返回 0,没有任何多余的处理。
正确姿势只有一句话:循环条件用读函数自己的返回值,while ((got = fread(...)) == 期望个数)、while ((c = fgetc(f)) != EOF)、while (fgets(line, sizeof line, f) != NULL)——读函数返回「成功读了多少」就是它替你做的「事前预判」,比 feof 准、比 feof 早。feof 的正确用途是读完之后用来分辨「刚才那次 fread 返回少于请求量,到底是因为到尾了、还是因为出错了」——配合 ferror 一起用,而不是拿来当循环条件。
小结
走到这儿,我们把 C 标准库那层棉花真正掀开了一角。文件描述符就是内核给你的一张表里的小整数下标,0/1/2 是 stdin/stdout/stderr、新文件从 3 开始按最小空闲号分配;open 拿 fd、close 还 fd、read/write 在 fd 上搬字节、lseek 在文件里跳位置、dup2 把 fd 复制成另一条路(重定向的底子)。围绕这套接口有三件必须刻进肌肉记忆的事:第一,errno 只在出错返回之后才有意义,成功路径它不清零、别拿它判成败;第二,read/write 允许短读短写,要保证写满 N 字节就得循环凑、外加处理 EINTR,别假设一次到底;第三,缓冲分两层,stdio 用户态一层(fflush 刷它)、内核页缓存一层(fsync 刷它),_exit 连 stdio 那层都不刷、数据和它一起蒸发。
更重要的是这一章定下的世界观基调:从现在开始,你打交道的是一个**「每个调用都可能只完成一半、每个错误都得自己查、每个资源都得自己记得还」**的世界——read 可能只给你几字节,write 可能只吃下去半截,open 可能因为你漏写 mode 就埋下权限地雷,close 漏一次就往 fd 表里漏一个格子。stdio 帮你兜的这些底,syscall 这层统统不兜,全甩给你自己。听着累,但这就是系统编程的真相,也是后面所有章节的共同底色——进程、信号、管道、socket,没一个能绕开这套「自己查错、自己凑齐、自己善后」的纪律。把这一章的 fd 和缓冲吃透,下一章我们就能放心地 fork 出一个新进程,去看父子进程各自的 fd 表和 stdio 缓冲又会怎么互相折腾。
参考资源
- APUE:《Advanced Programming in the UNIX Environment》(W. Richard Stevens / Stephen A. Rago),第 3、4 章讲文件 IO 与 fd,是这一章的内容骨架。
- TLPI:《The Linux Programming Interface》(Michael Kerrisk),第 4、5 章对
open/read/write/lseek/dup的返回值与短读写讲得最细,fflush与fsync的分层也清楚。 - man 页:
open(2)、read(2)、write(2)、close(2)、lseek(2)、dup(2)、errno(3)、feature_test_macros(7)——本章每条 syscall 行为都对齐 man 页原文。 - ISO C:§7.5(
errno,C11 起线程存储期)、§7.21(stdio:fopen/fread/fwrite/fflush/fclose)、§7.22.4.4(exit)。注意open/read/write/close/_exit/ssize_t/off_t都不在 ISO C 里,它们是 POSIX。 - POSIX:IEEE Std 1003.1-2008(即 POSIX.1-2008),
open/read/write/close/lseek/dup/dup2的权威定义;_POSIX_C_SOURCE是其 feature test macro 体系。