大家好,我是程序员小青蛙,今天介绍文件系统的浅显理解。

前言
很多 C 语言初学者接触文件操作时,最先学会的是fopen/fread/fwrite这套标准库接口。但当我们深入 Linux 系统编程时,会发现还有另一套open/read/write系统调用接口。为什么会有两套接口?它们之间是什么关系?文件描述符到底是什么?重定向的底层原理是什么?"Linux 一切皆文件" 这句耳熟能详的话背后,又隐藏着怎样的设计哲学?
本文将从 C 标准库 I/O 出发,一步步深入 Linux 内核,揭开文件 I/O 的神秘面纱,带你理解这些问题的本质。
一、C 标准库 I/O 与 Linux 系统调用 I/O:封装与本质
1.1 两套接口的代码对比
我们先来看两段功能完全相同的代码,分别用 C 标准库和 Linux 系统调用实现文件写入:
C 标准库版本
cpp
#include <stdio.h>
#include <string.h>
int main()
{
FILE *fp = fopen("myfile", "w");
if(!fp){
printf("fopen error!\n");
}
const char *msg = "hello bit!\n";
int count = 5;
while(count--){
fwrite(msg, strlen(msg), 1, fp);
}
fclose(fp);
return 0;
}
Linux 系统调用版本
cpp
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
int main()
{
umask(0);
int fd = open("myfile", O_WRONLY|O_CREAT, 0644);
if(fd < 0){
perror("open");
return 1;
}
const char *msg = "hello bit!\n";
int count = 5;
int len = strlen(msg);
while(count--){
write(fd, msg, len);
}
close(fd);
return 0;
}
可以看到,两套接口非常相似:都有打开、写入、关闭操作,参数也有对应关系。这并非巧合 ------C 标准库的文件 I/O 函数,本质上就是对 Linux 系统调用的封装。
cpp
#include <stdio.h>
#include <string.h>
int main()
{
const char *msg = "hello fwrite\n";
fwrite(msg, strlen(msg), 1, stdout);
printf("hello printf\n");
fprintf(stdout, "hello fprintf\n");
return 0;
}
直接输入到显示屏上
stdin & stdout & stderrC默认会打开三个输入输出流,分别是stdin, stdout, stderr
仔细观察发现,这三个流的类型都是FILE*, fopen返回值类型,文件指针
打开文件的方式有r,r+,w,w+,a,a+,还有fseekftellrewind的函数
1.2 库函数与系统调用的本质区别
要理解两者的关系,我们需要先明确两个概念:
- 库函数 :运行在用户态 ,是编程语言提供的对系统调用的封装,方便开发者使用。例如
fopen/fclose/fread/fwrite都属于 C 标准库 (libc) 函数。 - 系统调用 :运行在内核态 ,是操作系统内核提供给用户程序的接口,是用户态访问内核资源的唯一方式。例如
open/close/read/write都属于 Linux 系统调用。
当我们调用fwrite时,它并不会直接访问磁盘,而是先将数据写入 C 标准库提供的用户级缓冲区 ,当缓冲区满足一定条件(如缓冲区满、遇到换行符、调用fflush或进程退出)时,再调用系统调用write将数据一次性写入内核缓冲区,最终由内核将数据写入磁盘。
这种分层设计的好处是:
- 提高 I/O 效率:减少系统调用的次数(系统调用需要从用户态切换到内核态,开销较大)
- 提高代码可移植性:C 标准库屏蔽了不同操作系统系统调用的差异,使得同样的代码可以在 Windows、Linux、macOS 等不同系统上编译运行
1.3 接口函数open
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
int open(const char *pathname, int flags);
int open(const char *pathname, int flags, mode_t mode);
pathname:要打开或创建的目标文件
flags:打开文件时,可以传入多个参数选项,用下面的一个或者多个常量进行"或"运算,构成flags。
参数:
O_RDONLY:只读打开
O_WRONLY:只写打开
O_RDWR:读,写打开
这三个常量,必须指定一个且只能指定一个
O_CREAT :若文件不存在,则创建它。需要使用mode选项,来指明新文件的访问权限
O_APPEND:追加写
返回值:
成功:新打开的文件描述符
失败:-1
返回值:
在认识返回值之前,先来认识一下两个概念:系统调用和库函数
上面的fopen fclose fread fwrite 都是C标准库当中的函数,我们称之为库函数(libc)。
而,open close read write seek都属于系统提供的接口,称之为系统调用接口
回忆一下我们讲操作系统概念时,画的一张图
所以,可以认为,f#系列的函数,都是对系统调用的封装,方便二次开发。
二、文件描述符 (fd):内核管理文件的核心
2.1 文件描述符的本质
open系统调用成功时会返回一个非负整数,这个整数就是文件描述符 (File Descriptor, fd)。很多人只知道它是一个用来标识文件的整数,但它的本质是什么呢?
答案是:文件描述符本质上是进程文件描述符表的数组下标。
让我们深入内核来看这个过程:
- 当进程调用
open打开一个文件时,内核会在内存中创建一个struct file结构体,用来描述这个打开的文件(包含文件的属性、操作方法、当前读写位置等信息) - 每个进程在内核中都有一个
struct files_struct结构体,称为文件描述符表 - 这个结构体内部包含一个指针数组
struct file *fd_array[],数组的每个元素都指向一个struct file结构体 open函数会在这个数组中找到一个最小的未被使用的下标,将新创建的struct file结构体的地址存入该下标对应的位置,然后将这个下标作为返回值返回给用户
这就是为什么文件描述符总是从 0 开始的小整数 ------ 它只是数组的下标而已。
2.2 三个默认的文件描述符
Linux 进程在创建时,会默认打开三个文件描述符:
0:标准输入 (stdin),默认对应键盘设备1:标准输出 (stdout),默认对应显示器设备2:标准错误 (stderr),默认对应显示器设备
这就是为什么我们可以直接使用printf输出到显示器,使用scanf从键盘输入 ------ 它们本质上是向文件描述符 1 写入数据,从文件描述符 0 读取数据。
我们可以用代码验证这一点:
cpp
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
int main()
{
int fd = open("myfile", O_RDONLY);
if(fd < 0){
perror("open");
return 1;
}
printf("fd: %d\n", fd); // 输出:fd: 3
close(fd);
return 0;
}
输出结果是 3,因为 0、1、2 已经被默认打开的三个标准流占用了,所以第一个新打开的文件的文件描述符是 3。
输入输出也可以利用这样的规则:
cpp#include <stdio.h> #include <sys/types.h> #include <sys/stat.h> #include <fcntl.h> #include <string.h> int main() { char buf[1024]; ssize_t s = read(0, buf, sizeof(buf)); if(s > 0){ buf[s] = 0; write(1, buf, strlen(buf)); write(2, buf, strlen(buf)); } return 0; }而现在知道,文件描述符就是从0开始的小整数。当我们打开文件时,操作系统在内存中要创建相应的数据结构来描述目标文件。于是就有了file结构体。表示一个已经打开的文件对象。而进程执行open系统调用,所以必须让进程和文件关联起来。每个进程都有一个指针*files,指向一张表files_struct,该表最重要的部分就是包涵一个指针数组,每个元素都是一个指向打开文件的指针!所以,本质上,文件描述符就是该数组的下标。所以,只要拿着文件描述符,就可以找到对应的文件
2.3 文件描述符的分配规则
文件描述符的分配遵循最小可用原则:内核会在文件描述符表中,从小到大寻找第一个未被使用的下标,分配给新打开的文件。
我们可以用下面的代码验证这个规则:
cpp
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
int main()
{
close(1); // 关闭标准输出,文件描述符1被释放
int fd = open("myfile", O_WRONLY|O_CREAT, 0644);
if(fd < 0){
perror("open");
return 1;
}
printf("fd: %d\n", fd); // 输出:fd: 1
fflush(stdout);
close(fd);
return 0;
}
cpp#include <stdio.h> #include <sys/types.h> #include <sys/stat.h> #include <fcntl.h> int main() { close(0); //close(2); int fd = open("myfile", O_RDONLY); if(fd < 0){ perror("open"); return 1; } printf("fd: %d\n", fd); close(fd); return 0; }
在这个例子中,我们先关闭了文件描述符 1,然后打开一个新文件,内核会将最小的可用下标 1 分配给这个新文件,所以输出结果是 1。
三、重定向的底层原理:修改内核的指针
3.1 什么是重定向
在 Linux 命令行中,我们经常使用重定向符号:
>:输出重定向,将命令的输出写入文件而不是显示器>>:追加重定向,将命令的输出追加到文件末尾<:输入重定向,从文件读取输入而不是键盘
例如,执行ls > file.txt会将ls命令的输出写入file.txt文件中,而不是显示在屏幕上。
那么重定向的本质是什么呢?我们来看刚才的代码:
cpp
close(1);
int fd = open("myfile", O_WRONLY|O_CREAT, 0644);
printf("fd: %d\n", fd);
这段代码执行后,我们会发现屏幕上没有任何输出,而myfile文件中却出现了fd: 1这行内容。
这就是最原始的输出重定向!printf函数本质上是向标准输出 (stdout) 写入数据,而 stdout 对应的文件描述符是 1。当我们关闭文件描述符 1,然后打开一个新文件时,新文件的文件描述符被分配为 1。此时,所有向文件描述符 1 写入的数据,都会被写入到这个新文件中,而不是显示器。
3.2 重定向的本质
从上面的例子可以看出,重定向的本质是:在内核层面,修改文件描述符表中对应下标指向的struct file结构体的地址。
上层的printf/fwrite等函数并不知道底层发生了变化,它们仍然按照原来的方式向文件描述符 1 写入数据,但此时文件描述符 1 已经不再指向显示器设备,而是指向了我们打开的文件。
3.3 使用 dup2 系统调用实现重定向
上面的方法虽然可以实现重定向,但不够优雅,而且在多线程环境下可能会出现问题。Linux 提供了专门的系统调用dup2来实现文件描述符的复制,从而实现重定向。
dup2函数的原型如下:
cpp
#include <unistd.h>
int dup2(int oldfd, int newfd);
dup2函数的作用是:将newfd指向oldfd指向的文件。如果newfd已经打开,则先关闭newfd。
使用dup2实现输出重定向的代码如下:
cpp
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
int main() {
int fd = open("./log", O_CREAT | O_RDWR, 0644);
if (fd < 0) {
perror("open");
return 1;
}
dup2(fd, 1); // 将标准输出重定向到log文件
printf("hello world\n"); // 这句话会输出到log文件中
fflush(stdout);
close(fd);
return 0;
}
3.4 在 minishell 中实现重定向
理解了重定向的原理后,我们就可以在自己实现的简易 shell 中添加重定向功能了。核心思路是:
- 解析用户输入的命令,识别出重定向符号和目标文件名
- 在子进程中执行命令前,使用
dup2将标准输入 / 输出重定向到目标文件 - 执行命令,命令的输入 / 输出就会自动重定向到目标文件
cpp# include <stdio.h> # include <stdlib.h> # include <unistd.h> # include <string.h> # include <fcntl.h> # define MAX_CMD 1024 char command[MAX_CMD]; int do_face() { memset(command, 0x00, MAX_CMD); printf("minishell$ "); fflush(stdout); if (scanf("%[^\n]%*c", command) == 0) { getchar(); return -1; } return 0; } char **do_parse(char *buff) { int argc = 0; static char *argv[32]; char *ptr = buff; while(*ptr != '\0') { if (!isspace(*ptr)) { argv[argc++] = ptr; while((!isspace(*ptr)) && (*ptr) != '\0') { ptr++; } }else { while(isspace(*ptr)) { *ptr = '\0'; ptr++; } } } argv[argc] = NULL; return argv; } int do_redirect(char *buff) { char *ptr = buff, *file = NULL; int type = 0, fd, redirect_type = -1; while(*ptr != '\0') { if (*ptr == '>') { *ptr++ = '\0'; redirect_type++; if (*ptr == '>') { *ptr++ = '\0'; redirect_type++; } while(isspace(*ptr)) { ptr++; } file = ptr; while((!isspace(*ptr)) && *ptr != '\0') { ptr++; } *ptr = '\0'; if (redirect_type == 0) { fd = open(file, O_CREAT|O_TRUNC|O_WRONLY, 0664); }else { fd = open(file, O_CREAT|O_APPEND|O_WRONLY, 0664); } dup2(fd, 1); } ptr++; } return 0; } int do_exec(char *buff) { char **argv = {NULL}; int pid = fork(); if (pid == 0) { do_redirect(buff); argv = do_parse(buff); if (argv[0] == NULL) { exit(-1); } execvp(argv[0], argv); }else { waitpid(pid, NULL, 0); } return 0; } int main(int argc, char *argv[]) { while(1) { if (do_face() < 0) continue; do_exec(command); } return 0; }
四、缓冲区的 "坑" 与本质:为什么 fork 后输出次数不同?
4.1 一个令人困惑的现象
我们来看一段代码:
cpp
#include <stdio.h>
#include <string.h>
#include <unistd.h>
int main()
{
const char *msg0="hello printf\n";
const char *msg1="hello fwrite\n";
const char *msg2="hello write\n";
printf("%s", msg0);
fwrite(msg1, strlen(msg1), 1, stdout);
write(1, msg2, strlen(msg2));
fork();
return 0;
}
当我们直接运行这个程序时,输出结果是:
hello printf
hello fwrite
hello write
但当我们将输出重定向到文件中时(./a.out > file.txt),查看文件内容会发现:
hello write
hello printf
hello fwrite
hello printf
hello fwrite
同样的代码,只是输出目标不同,结果却大相径庭:printf和fwrite输出了两次,而write只输出了一次。这是为什么呢?
4.2 缓冲区的本质与分类
要解释这个现象,我们需要理解缓冲区的概念。
缓冲区本质上是一段内存空间,用来暂存 I/O 数据。缓冲区的存在是为了提高 I/O 效率 ------ 磁盘访问是毫秒级的,而内存访问是纳秒级的,差了 6 个数量级。如果每次写一个字节都直接访问磁盘,程序的效率会极其低下。
一般C库函数写入文件时是全缓冲的,而写入显示器是行缓冲。
printffwrite库函数会自带缓冲区,当发生重定向到普通文件时,数据的缓冲方式由行缓冲变成了全缓冲。
而我们放在缓冲区中的数据,就不会被立即刷新,甚至fork之后但是进程退出之后,会统一刷新,写入文件当中。
但是fork的时候,父子数据会发生写时拷贝,所以当你父进程准备刷新的时候,子进程也就有了同样的一份数据,随即产生两份数据。
write没有变化,说明没有所谓的缓冲。
综上:printffwrite库函数会自带缓冲区,而write系统调用没有带缓冲区。另外,我们这里所说的缓冲区,都是用户级缓冲区。其实为了提升整机性能,OS也会提供相关内核级缓冲区,不过不再我们讨论范围之内。
那这个缓冲区谁提供呢?printffwrite是库函数,write是系统调用,库函数在系统调用的"上层",是对系统调用的"封装",但是write没有缓冲区,而printffwrite有,足以说明,该缓冲区是二次加上的,又因为是C,所以由C标准库提供。
在 Linux 中,缓冲区主要分为两类:
- 用户级缓冲区 :由 C 标准库提供,位于用户态内存中。
FILE结构体内部就包含了用户级缓冲区的相关信息(缓冲区指针、大小、当前位置等)。 - 内核级缓冲区:由操作系统内核提供,位于内核态内存中。所有的磁盘 I/O 都会经过内核级缓冲区。
我们通常所说的缓冲区,指的是用户级缓冲区。
4.3 缓冲区的刷新策略
C 标准库为不同的设备设置了不同的缓冲区刷新策略:
- 行缓冲 :当缓冲区中遇到换行符
\n时,刷新缓冲区。标准输出 (stdout) 默认采用行缓冲。 - 全缓冲:当缓冲区被写满时,刷新缓冲区。普通文件默认采用全缓冲。
- 无缓冲:不使用缓冲区,数据直接写入内核。标准错误 (stderr) 默认采用无缓冲。
缓冲区刷新的几种情况:
- 调用
fflush函数强制刷新 - 进程正常退出时
- 缓冲区被写满时
- 行缓冲遇到换行符时
4.4 现象解释
现在我们可以解释刚才的现象了:
直接运行程序(输出到显示器):
- 标准输出 (stdout) 采用行缓冲
printf和fwrite输出的内容都包含换行符\n,会立即刷新缓冲区,数据被写入内核write是系统调用,没有用户级缓冲区,数据直接写入内核- 此时 fork () 执行时,用户级缓冲区已经是空的,所以子进程不会复制任何数据
- 最终每个函数都只输出一次
输出重定向到文件:
- 标准输出 (stdout) 不再指向显示器,而是指向普通文件,刷新策略变为全缓冲
printf和fwrite输出的内容虽然包含换行符\n,但不会立即刷新缓冲区,数据会暂存在用户级缓冲区中write是系统调用,没有用户级缓冲区,数据直接写入内核- 此时 fork () 执行时,会复制父进程的地址空间,包括用户级缓冲区中的内容
- 当父进程和子进程退出时,都会刷新各自的用户级缓冲区,将数据写入文件
- 所以
printf和fwrite各输出了两次,而write只输出了一次
这个例子深刻地揭示了 C 标准库 I/O 和系统调用 I/O 的本质区别:C 标准库 I/O 有用户级缓冲区,而系统调用 I/O 没有。
五、"Linux 一切皆文件":操作系统层面的多态
先看文件:
模式 硬链接数 文件所有者 组 大小 最后修改时间 文件名
stat命令可以看到更多
Block Group:ext2文件系统会根据分区的大小划分为数个Block Group。而每个Block Group都有着相同的结构组成。政府管理各区的例子
超级块(Super Block):存放文件系统本身的结构信息。记录的信息主要有:bolck和inode的总量,未使用的block和inode的数量,一个block和inode的大小,最近一次挂载的时间,最近一次写入数据的时间,最近一次检验磁盘的时间等其他文件系统的相关信息。Super Block的信息被破坏,可以说整个文件系统结构就被破坏了
GDT,Group Descriptor Table:块组描述符,描述块组属性信息,可以在了解一下块位图(Block Bitmap):Block Bitmap中记录着Data Block中哪个数据块已经被占用,哪个数据块没有被占用
inode位图(inode Bitmap):每个bit表示一个inode是否空闲可用。
i节点表:存放文件属性如文件大小,所有者,最近修改时间等
数据区:存放文件内容
将属性和数据分开存放的想法看起来很简单,但实际上是如何工作的呢?我们通过touch一个新文件来看看如何工作。
创建一个新文件主要有一下4个操作:
1.存储属性
内核先找到一个空闲的i节点(这里是263466)。内核把文件信息记录到其中。
2.存储数据
该文件需要存储在三个磁盘块,内核找到了三个空闲块:300,500,800。将内核缓冲区的第一块数据复制到300,下一块复制到500,以此类推。
3.记录分配情况
文件内容按顺序300,500,800存放。内核在inode上的磁盘分布区记录了上述块列表。
4.添加文件名到目录
新的文件名abc。linux如何在当前的目录中记录这个文件?内核将入口(263466,abc)添加到目录文件。文件名和inode之间的对应关系将文件名和文件的内容及属性连接起来。
下面解释一下文件的三个时间:
Access最后访问时间
Modify文件内容最后修改时间
Change属性最后修改时间
理解硬链接我们看到,真正找到磁盘上文件的并不是文件名,而是inode。其实在linux中可以让多个文件名对应于同一个inode。
abc和def的链接状态完全相同,他们被称为指向文件的硬链接。内核记录了这个连接数,inode263466的硬连接数为2。
我们在删除文件时干了两件事情:1.在目录中将对应的记录删除,2.将硬连接数-1,如果为0,则将对应的磁盘释放。
软链接
硬链接是通过inode引用另外一个文件,软链接是通过名字引用另外一个文件。
5.1 不仅仅是口号
"Linux 一切皆文件" 是 Linux 最著名的设计哲学之一。但很多人只是把它当作一个口号,并没有真正理解它的含义。
在 Linux 中,不仅仅是普通的磁盘文件被当作文件,几乎所有的系统资源都被抽象成了文件:
- 硬件设备:键盘、鼠标、显示器、磁盘、网卡、打印机等
- 进程间通信:管道、消息队列、共享内存等
- 系统信息:/proc 目录下的各种文件
- 网络连接:socket
所有这些完全不同的资源,都可以使用同一套文件操作接口(open/read/write/close)来访问。这是如何实现的呢?
5.2 VFS:虚拟文件系统层
Linux 内核中实现了一个虚拟文件系统 (Virtual File System, VFS) 层。VFS 定义了一套所有文件系统都必须实现的统一接口。
每个打开的文件在内核中都对应一个struct file结构体,这个结构体中有一个重要的成员:struct file_operations *f_op。struct file_operations是一个函数指针表,包含了对文件进行各种操作的函数指针,如read、write、open、release等。
不同的文件系统或设备驱动,会实现自己的struct file_operations函数表。例如:
- 磁盘文件系统(如 ext4)会实现针对磁盘的
read和write函数 - 键盘驱动会实现针对键盘的
read函数 - 显示器驱动会实现针对显示器的
write函数 - 网卡驱动会实现针对网络的
read和write函数
当用户调用read系统调用时,内核会根据文件描述符找到对应的struct file结构体,然后调用f_op指向的函数表中的read函数。
这就是 "一切皆文件" 的本质:通过 VFS 层和函数指针,实现了操作系统层面的多态。上层应用不需要关心底层是什么设备,只需要使用统一的文件操作接口即可,具体的操作细节由底层的驱动程序实现。
这种设计思想极其优雅,它让 Linux 系统的接口变得非常统一和简洁,极大地提高了系统的可扩展性和可维护性。
六、磁盘底层原理:文件系统的物理基础
6.1 磁盘的物理结构
磁盘是计算机中唯一的主要机械存储设备,它的物理结构决定了它的访问特性。
一个典型的机械硬盘由以下几个部分组成:
- 盘片:硬盘通常有多个盘片,每个盘片的两面都可以存储数据
- 磁头:每个盘面都有一个磁头,负责读写数据
- 主轴:带动盘片高速旋转
- 磁头臂:带动磁头在盘面上移动
盘片上的数据是按照同心圆来组织的,这些同心圆称为磁道 。每个磁道又被划分成多个扇区,每个扇区的大小通常是 512 字节。扇区是磁盘读写的基本单位。
所有盘面上相同编号的磁道,共同组成了一个柱面。
6.2 CHS 寻址方式
要在磁盘上定位一个扇区,需要三个参数:
- C(Cylinder):柱面号,确定磁头要移动到哪个磁道
- H(Head):磁头号,确定使用哪个盘面的磁头
- S(Sector):扇区号,确定磁道上的哪个扇区
这种寻址方式称为CHS 寻址。
磁盘访问一个扇区的时间主要由三部分组成:
- 寻道时间:磁头移动到目标磁道所需的时间,通常是几毫秒到十几毫秒
- 旋转延迟:盘片旋转到目标扇区经过磁头下方所需的时间,对于 7200 转 / 分钟的硬盘,平均旋转延迟约为 4.17 毫秒
- 传输时间:将数据从扇区读取到内存或将数据写入扇区所需的时间,通常非常短
可以看出,磁盘访问的主要开销是寻道时间和旋转延迟,这也是为什么磁盘访问比内存访问慢得多的原因。
6.3 文件系统的作用
磁盘的物理结构是圆形的,数据是按照 CHS 方式组织的。但对于用户来说,我们希望文件是线性的,可以按照文件名和偏移量来访问。
文件系统的作用就是将磁盘的圆形物理结构抽象成线性的逻辑结构,为用户提供一个简单、统一的文件访问接口。文件系统负责管理磁盘上的空闲空间,维护文件的元数据(文件名、大小、创建时间、存储位置等),并将用户的逻辑读写请求转换为物理磁盘的 CHS 读写请求。
七、总结
本文从 C 标准库 I/O 出发,一步步深入 Linux 内核,全面解析了文件 I/O 的本质:
- C 标准库 I/O 是对 Linux 系统调用 I/O 的封装,提供了用户级缓冲区,提高了 I/O 效率和代码可移植性。
- 文件描述符本质上是进程文件描述符表的数组下标,内核通过文件描述符表来管理进程打开的所有文件。
- 重定向的本质是在内核层面修改文件描述符表中对应下标指向的
struct file结构体的地址。 - 缓冲区是为了提高 I/O 效率而存在的,C 标准库提供的用户级缓冲区是导致 fork 后输出次数不同的根本原因。
- "Linux 一切皆文件" 是通过 VFS 虚拟文件系统层和函数指针实现的操作系统层面的多态,它让 Linux 系统的接口变得极其统一和简洁。
- 文件系统将磁盘的圆形物理结构抽象成线性的逻辑结构,为用户提供了简单、统一的文件访问接口。
学习系统级文件 I/O,不仅仅是学会使用几个函数,更重要的是理解操作系统如何管理文件和设备,理解用户态和内核态的交互方式。这些知识是学习网络编程、进程间通信、操作系统内核等高级主题的基础。














