Linux文件系统与缓冲区深度解析

你先告诉我,printf 打出来的东西到底在哪?

先看一段代码。别急着往下翻,盯着它,猜猜运行结果。

c 复制代码
#include <stdio.h>
#include <unistd.h>
#include <string.h>

int main() {
    printf("hello printf\n");
    fprintf(stdout, "hello fprintf\n");

    const char *msg1 = "hello fputs\n";
    fputs(msg1, stdout);

    const char *msg2 = "hello write\n";
    write(1, msg2, strlen(msg2));

    fork();
    return 0;
}

直接跑:./a.out

四条消息,整整齐齐,每人一行。没问题。

现在换个跑法:./a.out > log.txt

打开 log.txt,见鬼了。

printf 那行出现两次。fprintf 那行出现两次。fputs 那行也出现两次。只有 write 那行老老实实只出现一次。

把 fork 删掉再试------一切正常。所以这事跟 fork 有关。但凭什么 C 库函数被 fork 搞了两次,write 就没事?

要回答这个问题,你得先搞清楚另一件事:printf 写完数据之后,数据到底待在哪儿? 不在显示器上,不在内核里------它待在 C 语言自己的缓冲区里,根本没交出去。

这就是这篇文章要讲的东西。但它没法三句话说清楚,因为要真正理解这个现象,你得把几件事串起来:文件描述符是什么,重定向怎么玩的,什么叫"一切皆文件",缓冲区到底有几层,以及凭什么 C 语言要在中间插一脚。

走着。


重定向的一个小坑

先不说 fork,说说我们之前写 shell 时撞上的一个 bug。

我们在自己的 shell 里加了重定向功能。解析的思路是:用一个 start 指针遍历命令行字符串。碰到 > 或者 <,说明存在重定向。把当前位置设为 \0,字符串就被切成了两半------左边是命令,右边是文件名。

具体来说,如果是 ls -a -l > log.txt

  1. start 从头往后走,碰到 > 停下来
  2. 判断是不是两个 > (追加模式):检查 start+1,如果下一个字符也是 >,那就是追加(>>),redir_type 设成 APPENDstart 跳两格(start += 2);如果只是一个 >,那就是覆盖输出,redir_type 设成 OUTPUTstart 跳一格(start++
  3. 当前位置设为 \0,左边就只剩命令部分了
  4. start 跳完之后可能指向空格,用一个 clear_left_space 宏把空格全部跳过
  5. 跳过空格后,start 指向的就是文件名

输入重定向(<)同理。解析出 redir_type 和文件名之后,在子进程里用 dup2 把对应的 fd 指向目标文件,然后 exec 替换程序。

顺带一提,这段解析代码的鲁棒性并不强。比如你只写了 ls > 没给文件名,start++ 之后可能就越界了。真要写得健壮,得先从 start 往后 strlen 一下确认还有多少字符,再决定怎么跳。但这个不是今天的重点------有兴趣自己改成 C++ 用 stringsplit 也行。

clear_left_space 这个宏用了一个 do-while 循环的技巧写成的:

c 复制代码
#define CLEAR_LEFT_SPACE(p) do { while (*(p) == ' ') (p)++; } while (0)

这里用 do-while(0) 包裹是为了让宏在任何上下文中都能安全展开------一种常见的 C 语言宏编码技巧。

写完一跑,ls -a -l > log.txt,第一次没问题。再跑个 ls,不执行了。

顺带一提,我们的 shell 目前只支持"创建子进程执行命令 + 在子进程里重定向"这种方式。也就是说,只有通过 fork + exec 运行的外部命令(如 ls、cat、pwd)才能被重定向,shell 的内建命令(如 cd)暂时不行。因为内建命令在当前进程里执行,重定向会影响整个 shell 的状态。

原因是全局变量没清零。

你第一次做了输出重定向,redir_type 被设成了 OUTPUTfilename 指向了上次的文件名。第二次你只打了个 ls,没带重定向符号,但 redir_type 还是 OUTPUT。程序一看,"哦,你要输出重定向",就去找文件名------文件名是空的或者残留的,打开文件失败,整个命令就废了。

修法很简单:每次循环开始前,把重定向相关的全局变量全部重置为默认值。

c 复制代码
redir_type = NONE;
filename = NULL;

修完就好了。ls > log.txt 正常,cat log.txt 看到内容,pwd >> log.txt 追加也正常。

这就是重定向在我们的 shell 里的实现------底层用的是 dup2,把文件描述符表里的内容互相拷贝,让某个 fd 指向另一个文件对象。

但修完这个 bug,我想问你另一个问题。

凭什么要有 0、1、2?

每个进程一启动,默认就打开了三个文件描述符:0(stdin)、1(stdout)、2(stderr)。键盘、显示器、显示器。

0 和 1 好理解。程序是加工数据的,你得有办法把数据喂给它(键盘),它处理完了得把结果告诉你(显示器)。没有这两个,程序跟外界就是断的。

但凭什么还要有个 2? stdout 和 stderr 不都是往显示器上打的吗?一个不就够了?

做个实验。

c 复制代码
#include <stdio.h>
#include <unistd.h>
#include <string.h>

int main() {
    printf("hello printf\n");           // 往 stdout
    fprintf(stdout, "hello fprintf\n"); // 往 stdout
    fprintf(stderr, "hello stderr\n");  // 往 stderr

    const char *msg1 = "hello write to 1\n";
    write(1, msg1, strlen(msg1));

    const char *msg2 = "hello write to 2\n";
    write(2, msg2, strlen(msg2));

    return 0;
}

直接跑,四条消息全打出来。OK,都是往显示器上打的。

现在 ./a.out > log.txt,重定向输出。

log.txt 里只有往 1 里写的那几条。往 2 里写的那条------hello stderrhello write to 2------照样打在屏幕上。

这就是为什么要有 2。

stdout 和 stderr 虽然都指向同一个显示器文件,但它们是两个不同的文件描述符。重定向只改了 1 的指向,2 纹丝不动。

所以你可以这样做:

bash 复制代码
./a.out > normal.log 2> error.log

正常输出进 normal.log,错误信息进 error.log,彻底分开。

以前你写 ./a.out > log.txt,其实是 ./a.out 1> log.txt 的简写------默认省略的那个数字就是 1。

所以你以前学的那些规则------正常输出用 printf/cout,错误输出用 perror/cerr------不是随便定的。这么设计就是为了让你在 debug 的时候,只重定向一下 2,就能单独看错误信息。

012 不是什么神秘数字。0 和 1 是为了让程序能获取数据和给出结果,2 是为了 debug。就这么回事。

还有一个细节值得提------文件描述符的分配规则。 操作系统总是分配当前最小的、没有被使用的 fd。如果你把 0 关掉,下次 open 分配的就是 0。把 2 关掉,分配的就是 2。这就是 dup2 实现重定向的底层原理:想办法让某个 fd(比如 1)指向你想要的文件对象,然后新写入的数据就自动流到那个文件里去了。fd 的数字没变------1 还是 1------但 1 里面存的内容(指向哪个文件对象)变了。上层 struct FILE 里包装的 fd 数字没动,但底下的指向已经偷偷换了。

你从来没打开过 stdin

那下一个问题:0、1、2 是谁打开的?

你可能以为是你的进程自己打开的。不是。

你的进程是 bash 的子进程。bash 在启动的时候,操作系统就已经帮它把键盘文件和显示器文件打开了,分配到 0、1、2。然后 bash 创建子进程时,子进程的文件描述符表是直接从父进程拷贝过去的。

拷贝的是文件描述符表,不是文件本身。 文件描述符表是你进程的一部分------它描述的是"我这个进程怎么看文件"。就像虚拟地址空间描述的是"我这个进程怎么看内存",创建子进程时也得拷贝。而文件对象本身(那个 struct file)不用拷贝------你创建的是子进程,又不是子文件。

所以你在命令行上跑的每一个程序,所谓的"默认打开 0、1、2",其实是 bash 打开后你继承下来的。

换个问法,不用"继承"这个词,还能说清楚吗?

能。bash 有一个数组,数组里存了三张"门禁卡"------0 号卡刷键盘,1 号卡刷显示器,2 号卡也刷显示器。bash 生了个孩子,给孩子也复印了三张一模一样的卡。孩子拿着卡就能刷卡进同样的门。孩子自己从来没申请过卡。

Windows 下同理。VS 启动你的程序时,图形化界面那一层早就把 stdin/stdout/stderr 打开了,你的进程照样继承。

那 bash 自己又是怎么打开的呢?你登录的时候,操作系统给你启动了 login shell 进程。这个进程在初始化的时候,在 /dev 下找到了键盘文件和显示器文件,打开它们,分配到 0、1、2。从此以后,所有从它 fork 出去的进程,都自动带着这三张卡。

行了,这件事到此为止。

谁告诉你键盘和磁盘是"一样的东西"?

"一切皆文件"------这句话你听过无数遍了。但你仔细想想:键盘就是键盘,磁盘就是磁盘,网卡就是网卡。读键盘的方式和读磁盘的方式客观上就是不一样,凭什么把它们都叫"文件"?

要回答这个问题,你得先搞清楚另一件事:人是怎么认识世界的。

属性:你除了属性什么都看不到

你有个朋友。我问你这朋友是什么人,你会说什么?

"他个子挺高,长得还行,就是晚上爱打游戏不睡觉,第二天起不来。喜欢打篮球。"

你说的每一个词,都是你朋友的属性。你不可能说出你朋友头上每根头发的数量,不可能知道他一生中经历的每一件事。你只能用有限的、有代表性的属性来认识他。

人类认识任何事物的方式,就是通过属性。 知道的多叫"了解",知道的少叫"不了解",区别只是你知道多少属性。

这件事在编程里的体现是什么?看看你写的类------里面的成员叫什么?成员属性。所有的面向对象语言让你设计类的时候,写的都是属性。这不是巧合。

第二件事:同类事物的属性类别相同,但属性值不同。

你和你朋友,都有身高、体重、籍贯、爱好。属性类别一样。但你身高 185,他 190,她 160------属性值不一样。你们的差别不在"有没有身高这个属性",而在这个属性的值是多少。

设备也是这么回事

键盘、磁盘、网卡、显示器------这些东西统称"外设"或"设备"。在操作系统眼里,它们都是设备。

既然是设备,操作系统就要管理它们。管理的套路永远是:先描述,再组织。

怎么描述?用一个结构体,里面放属性:设备名、厂商、唯一标识、当前状态、驱动编号......

键盘有这些属性,磁盘也有,网卡也有。有些设备缺某个属性------比如显示器不需要"读速度"------你把那个属性置空就行了。就像一个人少了一只眼睛,他"眼睛数量"这个属性就是 1 而不是 2,不是他没有这个属性类别。

所以从属性层面看,所有设备可以被统一抽象成一个结构体。属性类别相同,属性值不同------就跟你和你朋友一样。

读写方法:客观上就是不一样

但属性只能说清楚"设备长什么样"。真正头疼的是------你怎么用这些设备?

读键盘跟读磁盘的操作,在硬件层面是两套完全不同的流程。读键盘要等中断,读磁盘要发 SCSI 指令,读网卡要处理 DMA。客观上,它们的读写方法就是不一样的。

键盘只需要读,不需要写------键盘哪来的"写"?显示器只需要写,不需要读------计算机不会从显示器上读数据。磁盘读写都要,网卡读写都要。有些设备有写方法没读方法,有些反过来。

没关系,没有的方法你实现成空函数就行了。

但问题是:如果每种设备的读写方法名字都不一样------read_keyboard()read_disk()read_nic()------那上层的程序员就惨了。想访问键盘,得记住键盘的结构体;想访问网卡,得记住网卡的结构体。每种设备的读写方法全得背下来。

Linux 设计者干了一件事

把所有设备都包装成"文件"。

具体怎么做的?在 struct file 里放一个东西:函数指针表。

c 复制代码
struct file_operations {
    int (*read)(...);
    int (*write)(...);
    int (*open)(...);
    int (*flush)(...);
    // ...
};

键盘被当成一个文件,它的 read 函数指针指向键盘的底层读方法,它的 write 函数指针指向一个空函数(因为键盘不需要写)。

磁盘被当成一个文件,它的 read 指向磁盘的读方法,write 指向磁盘的写方法。

网卡同理。

然后在你的进程视角里,你不再直接跟硬件打交道了。你想读键盘?通过文件描述符 0 找到对应的 struct file,调文件对象里的 read 函数指针。你想写显示器?通过 fd 1 找到文件对象,调 write 函数指针。

函数名从此统一。 所有设备对你来说都只剩一个接口:文件对象的 readwrite

这一层软件抽象,叫 VFS(Virtual File System,虚拟文件系统)

这不就是多态吗?

停下来想一想。

一个结构体(struct file),里面有一组函数指针(file_operations),函数指针指向不同底层硬件的具体实现。上层用统一的接口,下层各有各的玩法。

这不就是多态吗? struct file 是虚基类,各个具体设备是子类。

但这件事发生在内核里,用 C 语言写的。C 语言没有 class,没有 virtual,没有继承------怎么办?用函数指针。用结构体嵌套。用大量工程实践磨出来的设计模式。

先有 C 语言还是先有 C++?先有操作系统还是先有编译器?先有 C 语言,先有操作系统。 内核工程师那时候根本没有"面向对象"这个概念。但他们写出来的代码,天然就是多态的------因为分层抽象、屏蔽底层差异是大型软件的刚需,不是什么语言特性。

后来的人把这种实践总结出来,给它起了个名字叫"多态",然后直接做进了语言里。C++、Java 都支持多态,不是因为委员会拍脑门决定的,是因为这本来就是世界运转的方式。

如果一个语言这么干,叫语言特性。如果所有语言都这么干,那它就是世界的真相。

所以"一切皆文件"的底层机制,就是用 C 语言的函数指针,实现了一套多态机制。让你站在进程的角度,看到的所有 I/O 资源都是统一的 struct file,读写方法全都叫 readwrite

重定向也因此变得可爱起来:以前写显示器,用的是显示器文件对象的 write 函数指针。重定向之后,fd 1 指向了一个磁盘文件对象,write 函数指针自动变成了磁盘的写方法。上层代码一行不用改------这就是多态的威力。

知道这些之后再去翻内核源码。找到 struct file,找到里面的 f_op(就是 file_operations),打开它------里面全是一个一个的函数指针:readwriteopenflush......这就是一切皆文件的骨架。


缓冲区:你在菜鸟驿站寄快递

好,现在我们知道文件被打开之后,内核里会创建一个 struct file 对象。但你有没有想过一个问题:

write(fd, buf, len) 这个调用,到底干了什么?

答案可能让你意外:它只是把数据从你的 buf 拷到了内核缓冲区里。它根本没碰硬件。

c 复制代码
const char *msg = "hello world";
write(3, msg, strlen(msg));

执行过程:通过 fd 3 找到对应的 struct file,把 msg 指向的 11 个字节,原样拷到 struct file 内部维护的内核文件缓冲区 里。拷完就返回。 数据此时还在内存里,根本没写到磁盘上。

所以第一个结论:write 本质是拷贝函数。

那数据什么时候才真正写到磁盘上?由操作系统自主决定。它会在合适的时机,把内核缓冲区里的数据刷新到外设。刷新策略我们一会儿说。

read 同理。你调用 read(fd, buf, len),它先检查:你要读的那个位置的数据,内核缓冲区里有没有?如果有,直接拷给你。如果没有,操作系统去外设上加载,加载完了再拷给你。

read 本质也是拷贝函数。

那操作系统怎么知道你要读的数据在不在缓冲区里?很简单。struct file 里有一个字段叫 f_pos------当前文件的读写位置。你说"我要读第 100 个字节",它就看看缓冲区里已经加载了从开头到第几个字节。如果已经加载到第 200 个字节了,那就直接拷给你。如果只加载到第 50 个字节,那就得先去外设上把 51 到 100 的数据拉进来。

这里有个细节没完全展开:内核缓冲区不是一个线性数组。它是一棵 radix tree(基数树),用文件偏移量做索引,每一"页"数据(4KB)是一个节点。这棵树的根地址存在 struct fileaddress_space 里。但数据结构的部分以后讲内存管理时再细说------你现在只需要知道,它用偏移量就能判定数据在不在内存里。

菜鸟驿站的故事:为什么要有缓冲区

假如没有缓冲区------就是没有菜鸟驿站。

张三在云南上学,李四在北京上学。张三想送李四一个键盘当生日礼物。没有快递,没有邮政,张三只能自己千里迢迢走过去,花几个月把键盘送到李四手上。在这几个月里,张三什么都干不了。

现在有了菜鸟驿站。张三只需要下楼,把键盘交给驿站的工作人员,然后上楼。上楼之后,室友问他"你那好键盘呢?"张三大手一挥------"已经送给我朋友了。"

站在张三的视角,他已经把事办完了。 实际上李四还没收到。但张三不 care------他的效率提高了。

这就是缓冲区存在的第一个意义:提高使用缓冲区的人(张三/用户进程)的 I/O 响应速度。 以前调一次 write 可能要等 500 毫秒(等硬件写完才返回),现在拷到内存就返回,可能只需要 100 毫秒。差了五倍。

注意,缓冲区提高的是使用者 的效率,不是提供者 的效率。菜鸟驿站里摆满了快递架,快递架本身没有提高菜鸟驿站的效率------它提高的是张三的效率。缓冲区给谁用,就提高谁的效率。

现在把视角切换到菜鸟驿站身上。

菜鸟驿站会不会来一个快递就立刻发一架飞机送出去?不会。这么干亏死------单次发送量越少,成本越高,效率越低。

菜鸟驿站的做法是:把你的快递先放架子上,放一天、两天。在这两天里,学校里有其他同学也要寄东西到北京------所有人的快递都被缓存起来。等架子放满了,把所有快递打包,一架飞机一次性发到北京,到北京再分拣。

这就是缓冲区的第二个意义:把分散的 I/O 请求聚合起来,减少 I/O 次数,提高整机效率。 以前 10 个 write 各刷一次磁盘,现在攒满一页(4KB)统一刷一次------本来要 10 次 I/O,现在 1 次就够了。

所以缓冲区的两大作用:

  1. 提高用户的 I/O 响应速度(交完就返回,不用等硬件)
  2. 提高操作系统的真实 I/O 效率(聚合请求,减少外设访问次数)

刷新策略:什么时候才真写?

数据拷到缓冲区了,什么时候才刷到外设上?

常见的刷新策略有这么几种:

  • 立即刷新:不缓存,写一次刷一次。效率最低,但数据最安全。
  • 行刷新(行缓冲) :遇到换行符 \n 就把这一行刷出去。显示器默认用这种------因为人是按行看东西的,一行一行显示符合人的习惯。
  • 全缓冲:缓冲区写满了才刷。普通磁盘文件默认用这种------效率最高。
  • 强制刷新 :用户调用 fsync(fd),"你现在、立刻、马上给我刷"。Word 的保存按钮本质上就是这个。

这里有一个容易搞混的地方。C 语言有一个 fflush(FILE *),操作系统有一个 fsync(int fd)。它们干的活根本不一样:fflush 是把 C 语言缓冲区的数据刷到内核缓冲区,fsync 是把内核缓冲区的数据刷到外设。 一个管"用户→内核"这一段,一个管"内核→硬件"这一段。很多人以为 fflush 就是"写盘",不是------它只是把接力棒交给了下一棒。

  • 进程退出时刷新:进程要挂了,操作系统会说"你先别死,把你缓冲区里的数据刷完再死"。所以正常退出的程序数据不会丢------但如果进程被 kill -9 强行干掉,或者突然断电,缓冲区里的数据就没了。

不同的缓冲区可能采用不同的策略。内核缓冲区的刷新策略跟操作系统整体状态有关------内存不够了可能提前刷,系统空闲了可能推迟刷。而 C 语言缓冲区的策略相对简单:显示器用行缓冲,普通文件用全缓冲。这个一会儿会变得极其重要。


C 语言的私货:语言级缓冲区

好,现在我们已经知道内核里有一层缓冲区了。但故事还没完。

我问你一个问题:调用系统调用有没有成本?

有。而且成本不小。

你调用 write(fd, buf, len),表面上就是把数据拷到内核缓冲区。但在操作系统内部,这个调用要走一大圈:找到进程→找到文件描述符表→找到文件对象→确认设备类型→调对应的底层方法→访问外设 I/O------每一步都要花时间。

更别说有些系统调用在底层做的工作远超你的想象。你调用 malloc 申请内存,操作系统可能在背后挂起了好几个进程、把不用的数据交换到了 swap 分区、重新整理了内存碎片------然后才把空间返回给你。你完全不知道。

所以 C 语言做了一个决定:我自己也搞一层缓冲区。

语言缓冲区在哪?

你一直用的 printffprintffputsfwrite------这些 C 库函数,它们并没有直接调 write

它们做的事是:把你要输出的数据,先写到 C 语言自己维护的语言级缓冲区 里。等你写了很多次 printf,攒够了数据,C 语言才调一次 write,把整个缓冲区的数据一次性拷到内核缓冲区。

所以 printf 本质上也是拷贝函数------只不过它从"你的字符串"拷到"C 语言的缓冲区",而不是直接拷到内核。从用户到用户,不是从用户到内核。

那这个缓冲区到底在哪?

struct FILE 里。就是那个你每次都传来传去的 FILE *

c 复制代码
struct FILE {
    int fd;                    // 文件描述符
    unsigned char *write_buf;  // 写缓冲区指针
    unsigned char *read_buf;   // 读缓冲区指针
    // ... 各种读写位置、缓冲区大小、标志位
};

你去系统头文件里翻,能找到 typedef struct _IO_FILE FILE;。在 Linux 上它在 /usr/include/stdio.h/usr/include/libio.h 里。里面有读缓冲区指针、写缓冲区指针、缓冲区大小、当前读写位置......你打开一个文件,C 语言就帮你创建这个结构体,把缓冲区也一并准备好。

所以为什么 C 语言做文件操作,每次都要传一个 FILE *因为文件描述符和缓冲区全都在这个结构体里装着。 它就是你操作文件时的"句柄"------就跟你在饭店吃饭时的桌号牌一样,拿着它你才能找到自己的菜。

行缓冲 vs 全缓冲:一切谜团的答案

现在回到语言缓冲区的刷新策略。

C 语言的缓冲区会根据目标文件的类型自动选择策略:

  • 显示器文件(终端设备)行缓冲 :遇到 \n 就刷新
  • 普通磁盘文件全缓冲:缓冲区写满了才刷新

为什么显示器用行缓冲?因为人是按行看东西的。你把一行凑齐了显示出来,人看着舒服。一次显示一大坨,人就懵了。

为什么普通文件用全缓冲?因为它没有"立即显示"的需求。效率第一,攒满了再刷。

这个区别,就是文章开头那个 fork 实验的全部秘密。

拆解 fork 实验

重新看这段代码:

c 复制代码
int main() {
    printf("hello printf\n");          // C 库函数,往 stdout
    fprintf(stdout, "hello fprintf\n");// C 库函数,往 stdout
    fputs("hello fputs\n", stdout);    // C 库函数,往 stdout
    const char *msg = "hello write\n";
    write(1, msg, strlen(msg));        // 系统调用,往 fd 1
    fork();
    return 0;
}

情况一:直接跑(输出到显示器)。

  1. printf/fprintf/fputs 把数据写到 C 语言缓冲区(stdout 的 FILE 结构体里)
  2. 因为目标是显示器,用的是行缓冲 。每条消息都有 \n,所以每次 printf 结束,C 语言就立刻把缓冲区的数据刷到内核
  3. write 直接把数据拷到内核
  4. fork 创建子进程------内核缓冲区是文件层面共享的,父子看到的数据一样
  5. 两个进程退出,各自刷新内核缓冲区到显示器

四条消息,每人一行。干净利落。

情况二:重定向到文件(> log.txt)。

  1. printf/fprintf/fputs 把数据写到 C 语言缓冲区
  2. 但因为目标是普通文件,用的是全缓冲 !三条消息写完了,缓冲区可能才几十个字节,远没满------C 语言不刷!
  3. 此时这三条消息还待在 C 语言缓冲区里,根本没到内核
  4. write 不管你 C 语言的事,直接把数据拷到内核
  5. fork 创建子进程。子进程复制了父进程的整个地址空间------包括 C 语言缓冲区的数据。stdout 是一个全局变量,父子共享了这份缓冲区的副本
  6. 两个进程退出。每个进程退出时,C 运行时(Runtime)会调用清理逻辑,把 stdout 缓冲区里的数据刷到内核。父进程刷了一次,子进程也刷了一次。
  7. log.txt 里出现两份 printf、两份 fprintf、两份 fputs

write 为什么只有一份? 因为 write 不经过 C 语言缓冲区,它的数据早就直接进内核了。fork 只复制了文件描述符表,没复制内核缓冲区的数据------父子看到的是同一份内核缓冲区,退出时只刷了一次。

说白了:重定向改变了缓冲策略,从行缓冲变成全缓冲,printf 的数据在 fork 之前被"暂扣"在了 C 语言缓冲区里。fork 之后父子各有一份,退出时各刷一次------所以数据出现了两次。

干净利落的验证

如果你还是不信,来看两个实验的对比。

第一个实验------带 \n

c 复制代码
#include <stdio.h>
#include <unistd.h>

int main() {
    printf("hello world\n");
    sleep(14);
    return 0;
}

编译运行。hello world 立刻出现,然后程序等 14 秒才退出。

第二个实验------不带 \n

c 复制代码
#include <stdio.h>
#include <unistd.h>

int main() {
    printf("hello world");  // 注意:没有 \n
    sleep(4);
    return 0;
}

编译运行。前 4 秒屏幕上一片空白,直到进程退出前的那一瞬间,hello world 才终于出现。

printf 和 sleep 谁先运行?printf 先运行。那在 sleep 期间,hello world 这个字符串待在哪?

在 C 语言缓冲区里。 不在内核,不在显示器,就在用户态的内存里,在一个叫 struct FILE 的结构体里面。

\n 时,目标是显示器,用的是行缓冲------\n 触发 C 语言立刻把数据刷到内核,你马上就看到了。不带 \n 时,行缓冲没被触发,数据一直蹲在 C 语言缓冲区里,直到进程退出时 C 运行时做清理才刷出去。所以你等了 4 秒才看到。

第三个实验------暴力杀进程:

c 复制代码
#include <stdio.h>
#include <unistd.h>

int main() {
    printf("hello world\n");
    sleep(40);
    return 0;
}

编译运行。hello world 立刻出现(因为 \n + 显示器 = 行缓冲刷新)。然后用 ps 找到 PID,kill -9 干掉它。

此时 hello world 已经刷到内核了------进程虽然被杀,但内核缓冲区会在进程退出时(或被内核感知到进程消亡时)刷到显示器上。数据没丢。

但如果换一种跑法------重定向到文件:

c 复制代码
int main() {
    printf("hello world\n");
    sleep(40);
    return 0;
}
// 编译后:./a.out > log.txt & 然后 kill -9

重定向到文件之后,缓冲策略变成了全缓冲。\n 不再触发刷新。此时 printf 的数据被"扣留"在 C 语言缓冲区里,根本没到内核。你在 40 秒内 kill -9,数据跟着进程一起死了------log.txt 里什么都没有。

所以结论很明确:printf 写完的数据,在到达内核之前,是待在 C 语言自己的缓冲区里的。 行缓冲触发、全缓冲写满、手动 fflush、进程正常退出------这四件事能把数据从 C 语言缓冲区推到内核。换个问法------kill -9、突然断电、缓冲区还没满------这三件事能让数据死在你眼皮底下。


三层缓冲区:一场接力赛

现在把整个故事串起来。

你写代码时自己定义的 char buf[1024]------这是用户缓冲区(你说了算)。

你调 printffwrite 时,数据进的是C 语言缓冲区 (C 运行时说了算,存在 struct FILE 里)。

C 语言缓冲区满了(或者遇到 \n 且目标是显示器),调一次 write,数据进内核文件缓冲区 (操作系统说了算,存在 struct file 的 radix tree 里)。

内核在合适的时机(缓冲区满了、用户调了 fsync、进程退出、定时刷新),把数据写进外设(硬盘、显示器)。

复制代码
你的数据 → C语言缓冲区 → 内核缓冲区 → 外设

你在每一层交出数据之后,就认为"事情办完了"------跟张三把键盘交给菜鸟驿站之后的心态一模一样。每一层都是一个菜鸟驿站。

这就是"文件流"这个词的由来。数据在缓冲区里像流水一样从尾部进来,从头部出去。先写的在前面,后写在后面------就是个队列。所以叫"流"。

知道了这些,你再看 printf 的格式化输出机制就特别清楚。printf("%d", 123) ------它把数字 123 转成字符 '1'、'2'、'3' 三个字节,写进 C 语言缓冲区,然后再由 C 语言统一刷出去。格式化的结果就是缓冲区里的一串字节。

C++ 的 coutcerr 呢?一回事。std::ostream 这个类里面必然封装了文件描述符和缓冲区。跟 C 的 FILE 是一一对应的关系。


为什么工程师们这么在意"拷贝次数"?

你在学 C/C++ 的时候一定听过"传引用比传值快""传指针可以减少拷贝"。为什么大家都在意这件事?

因为在这个接力赛里,数据被拷贝了很多很多次。 键盘到用户缓冲区一次,用户到语言缓冲区一次,语言到内核一次,内核到外设可能还有 DMA 的拷贝。网络编程里还要加上协议栈的处理。

如果你的每一步操作都增加不必要的拷贝,整个链路下来性能损耗是叠加的。

所以怎么减少拷贝次数,是整个 I/O 系统设计的核心问题之一。缓冲区存在的意义,一半是为了提高响应速度,一半就是为了聚合请求,用更少的系统调用完成更多的数据搬运------以此减少拷贝次数


我们要自己封装一个 my_stdio

说了这么多,还有一件事没搞清楚:C 语言到底什么时候把数据刷到内核的? 你写的代码里又没调 fflush,C 运行时是怎么插手的?

要回答这个问题,光看不行。我们得自己写一个简易版的 stdio ------就叫 my_stdio

工程结构很简单------跟之前写 shell 一样,几个文件搞定:

复制代码
my_stdio/
├── my_stdio.h    // 头文件:结构体定义 + 接口声明
├── my_stdio.c    // 实现文件:my_fopen, my_fwrite, my_fflush, my_fclose
├── main.c        // 测试入口:作为"用户"来调我们的接口
└── Makefile      // 编译 my_stdio.c + main.c → 可执行文件

只做输出缓冲区,不搞输入。核心就几个接口:

  • MY_FILE *my_fopen(const char *name, const char *mode) --- 打开文件,创建结构体,分配缓冲区,返回指针
  • my_fwrite(...) --- 写数据到语言缓冲区,满了就自动刷
  • my_fflush(...) --- 手动刷新
  • my_fclose(...) --- 关文件,刷数据,释放资源

数据结构长这样:

c 复制代码
typedef struct {
    int fd;                    // 封装文件描述符
    int flags;                 // 打开方式(读/写)
    int flush_mode;            // 刷新模式(行缓冲/全缓冲)
    char out_buf[1024];        // 输出缓冲区
    int buf_pos;               // 当前缓冲区写入位置
} MY_FILE;

思路跟内核的 struct file 一样:把 fd 和缓冲区打包在一个结构体里。用户拿到的就是这个结构体的指针,之后所有操作都传它。

具体实现留着下节课写。今天先把思路理清楚:

  1. my_fwrite 先检查缓冲区还能不能装下要写的数据
  2. 能装下------直接拷到 out_buf 里,更新 buf_pos,返回
  3. 装不下------先调 my_fflush 把缓冲区的数据刷到内核,清空缓冲区,再把新数据拷进去
  4. my_fflush 做的事情就是调 write(fd, out_buf, buf_pos),把缓冲区里的数据一次性拷到内核,然后 buf_pos 清零

等你实现了这一套,你就能直观地看到:哦,原来 printf 的调用链是 printf → 格式化 → fwrite → 语言缓冲区 → (满了或换行) → write → 内核缓冲区 → (合适时机) → 外设。每一步的延迟、每一次的拷贝,全在代码里。


今天到底学了什么

四件事。

第一,文件描述符 0、1、2 不是凭空来的。 0 和 1 是为了让程序获取数据和输出结果,2 是为了把正常输出和错误输出分开------方便你 debug。而且你从来没自己打开过它们------你是从 bash 那里继承下来的。

第二,"一切皆文件"是用 C 实现的"多态"。 内核把键盘、磁盘、网卡、显示器都用 struct file 统一包装,用函数指针表(file_operations)指向底层不同的硬件操作。在进程的视角里,所有的 I/O 只用 readwrite 两个名字------不管底层是什么设备。这不是巧合,这是 C 语言在面向对象诞生之前就写出来的多态。

第三,缓冲区是分层接力赛。 内核缓冲区让你调 write 时不用等硬件(提高响应速度),也帮操作系统把分散的 I/O 聚合起来一次刷(提高整机效率)。readwrite 本质都是拷贝函数------从一块内存拷到另一块内存。

第四,C 语言有自己的缓冲区。 它存在 struct FILE 里,目的是减少系统调用次数。显示器用行缓冲(遇 \n 就刷),普通文件用全缓冲(写满了才刷)。重定向改变了缓冲策略,这就是 fork 实验中 printf 数据出现两次的全部原因------数据在 fork 之前被"扣留"在了 C 语言缓冲区里,父子进程退出时各刷了一次。

最后一个小总结。

你口中所说的"缓冲区",在不同的语境里指的是不同的东西。你定义的 char buf[1024] 是用户缓冲区。C 语言帮你维护的那个是语言缓冲区。struct file 里那个是内核文件缓冲区。它们之间是一个接力关系------每一层都把数据交给下一层,然后就认为"我干完了"。这份"自欺欺人"正是计算机能跑这么快的关键。

搞清楚了这些,你再回头看那个进度条------为什么 printf 不带 \n 就不显示,不调 fflush 就不刷新------你的脑子里应该有画面了。数据在语言缓冲区里蹲着,等一个换行,等一次满,等一句 fflush,等进程体面地死去。

相关推荐
HLC++11 小时前
Linux的进程间通信
android·linux·服务器
华清远见IT开放实验室13 小时前
实验室建设案例 | 石家庄科技信息职业学院嵌入式实验室——从底层硬件到系统应用,一所应用型高校的嵌入式人才培养这样落地
linux·arm开发·stm32·嵌入式硬件·高校·实验室建设
groundhappy15 小时前
idalib安装和codex ida-mcp配置
linux·开发语言·python
通信小小昕16 小时前
Ubuntu 26.04 中文输入法安装
linux·运维·ubuntu
张小姐的猫17 小时前
【Linux】网络编程 —— HTTP协议(上)
linux·运维·服务器·网络·http·单例模式·策略模式
栩栩云生17 小时前
AI 写代码犯的错,早被写进了错题集
linux·安全·ai编程
imc.1119 小时前
linux基础IO
linux·运维·服务器
BelongPanda20 小时前
Linux Nginx 纯手动 Let‘s Encrypt 泛域名证书配置教程
linux·nginx
酷可达拉斯21 小时前
Linux操作系统-shell编程(0)
linux·运维·服务器·python·云计算
2301_7779983421 小时前
Linux中断机制:操作系统如何高效运行
linux·运维·服务器