🌌 文件系统真正难的,从来不是记住几个 open/read/write****接口。
真正困难的是把下面这一整条链路串起来:
进程 → fd → struct file → dentry / inode → address_space → Page Cache → 磁盘文件系统
如果这些东西在脑子里还是一个个孤立名词,那么学到最后一定会乱。
这一篇,我们不把知识点拆碎背,而是沿着一次真实的文件访问过程,一路走到底。
📖 本文关键词
open·read·write·close·fd·dup2· 重定向 · 用户级缓冲区 ·fork· 软硬链接 · inode · dentry · VFS · Page Cache ·task_struct
@TOC
1. 文件到底是什么?先把最基础的认知对齐
在开始研究接口之前,先统一几个非常重要的认知。
1.1 文件不仅仅是"磁盘上的东西"
我们平时提到文件,第一反应一般是:
-
.txt -
.cpp -
.jpg -
.mp4
这些确实是文件,但这是站在普通用户角度看到的磁盘文件。
从 Linux 系统角度看,文件的范围远比这个大。
文件
├── 磁盘文件
│ ├── 普通文本
│ ├── 程序
│ ├── 图片
│ └── ...
│
└── 内存中的文件对象
└── 进程打开文件后,内核为其建立相应的数据结构
所以学习 Linux 文件系统时,我们其实同时在研究两个世界:
磁盘上的文件系统
↓
内核中的文件对象
↓
进程如何操作这些文件对象
后面所有内容,基本都围绕这三层展开。
1.2 文件 = 内容 + 属性
一个文件不是只有里面保存的数据。
比如:
ls -l test.txt
除了文件内容,我们还能看到:
-
文件大小
-
权限
-
所有者
-
所属组
-
修改时间
-
链接数
所以可以先建立一个最基本的认识:
文件 = 文件内容 + 文件属性
在 Ext 系列文件系统中,可以粗略理解为:
文件属性
↓
inode
文件内容
↓
Data Blocks
这也是为什么:
一个文件即使大小为 0,也不意味着它完全不消耗文件系统资源。
它可能没有真正的数据块,但仍然需要 inode、目录项等元数据来描述它。
1.3 Linux 为什么总说"一切皆文件"?
Linux 最经典的一句话:
Everything is a file ------ 一切皆文件。
它并不是说键盘、显示器、网卡真的都变成了磁盘上的 .txt。
真正的意思是:
Linux 尽量把不同设备、不同资源抽象成统一的文件接口。
比如:
普通磁盘文件
键盘
显示器
管道
设备
Socket
...
上层都可以通过类似:
read();
write();
这样的统一接口去操作。
于是应用程序不需要针对每一种硬件重新设计一套完全不同的操作方式。
这背后依赖的一个重要机制就是:
struct file
↓
f_op
↓
file_operations
不同类型的文件提供不同的具体实现,但上层看到的仍然可以是统一的:
read(fd, ...);
write(fd, ...);
这其实就是 C 语言环境下一种非常经典的"多态"思想。
1.4 文件操作为什么一定和进程有关?
一个磁盘文件可以独立存在,但文件操作一定由进程发起。
比如:
open();
read();
write();
close();
这些函数最终都是当前进程去调用的。
因此,进程必须保存一个重要的信息:
我现在到底打开了哪些文件?
Linux 在进程对应的数据结构 task_struct 中,会关联一个:
files_struct
用于管理当前进程已经打开的文件。
先简单建立这个关系:
task_struct
│
└── files
↓
files_struct
↓
fdtable
↓
struct file*
后面讲到文件描述符时,这条链会正式串起来。
2. 文件操作:open、read、write、close 到底发生了什么?
Linux 下最基础的一组文件操作接口就是:
open
read
write
close
看起来只有四个函数,但真正值得学的并不是"函数怎么调用",而是:
调用这些函数之后,操作系统到底做了什么?
2.1 open:从路径找到文件,再给进程一个 fd
常见函数原型:
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
int open(const char *pathname, int flags, ...);
当需要创建文件时,第三个参数通常是:
mode_t mode
例如:
int fd = open("test.txt", O_WRONLY | O_CREAT, 0666);
返回值:
成功 → 文件描述符 fd
失败 → -1
2.1.1 fd 到底是什么?
很多人在刚学这里时会产生一个错误理解:
fd 是不是文件的编号?
不准确。
更加准确的说法是:
fd 是当前进程文件描述符表中的一个下标。
可以先把结构想象成:
task_struct
│
└── files
↓
files_struct
↓
fd_array / fdtable
│
├── [0] ──→ struct file
├── [1] ──→ struct file
├── [2] ──→ struct file
├── [3] ──→ struct file
└── ...
所以:
fd = 数组下标
系统默认通常已经打开:
0 → stdin → 标准输入
1 → stdout → 标准输出
2 → stderr → 标准错误
因此我们第一次主动打开普通文件时,经常得到:
fd = 3
但要注意:
fd 并不是永远从 3 开始递增。
它遵循的是:
当前进程中最小的、尚未被使用的文件描述符
例如:
close(1);
int fd = open("log.txt", O_WRONLY | O_CREAT, 0666);
此时 1 已经空出来了,因此 open 完全可能返回:
fd = 1
这个机制后面正是重定向能够实现的基础。
2.1.2 一个 fd 最终指向什么?
fd 本身只是数组下标。
真正描述"当前这一次打开行为"的,是内核里的:
struct file
因此关系是:
fd
↓
文件描述符表
↓
struct file
struct file 中有几个非常重要的成员。
f_pos:当前文件读写位置
例如:
文件内容:
ABCDEFG
↑
f_pos
连续调用:
read(fd, buf, 3);
第一次读:
ABC
f_pos 向后移动。
第二次再读时,会从新的位置继续读取。
f_flags:文件打开状态
例如:
O_RDONLY
O_WRONLY
O_RDWR
O_APPEND
O_NONBLOCK
...
这些打开文件时指定的状态,需要被保存下来。
f_op:这个文件应该怎么操作?
也就是:
file_operations
普通文件、设备文件、管道等对象的底层实现可以完全不同。
但对于应用层来说:
read();
write();
接口仍然保持统一。
这就是前面"一切皆文件"能够成立的重要原因之一。
f_inode:这个打开文件属于哪个 inode?
struct file
│
└── f_inode
↓
struct inode
struct file 描述的是:
一次打开行为。
而 inode 描述的是:
文件本身。
因此:
open("a.txt", ...);
open("a.txt", ...);
完全可能产生:
struct file A
struct file B
但是:
它们最终对应同一个 inode
也就是说:
同一个文件
↓
可以被 open 多次
↓
产生多个 struct file
↓
但最终指向同一个 inode
这个关系非常重要。
2.2 open 不仅是"打开文件":它还必须完成路径解析
我们调用:
open("/home/user/test.txt", O_RDONLY);
传进去的是一个:
字符串路径
但是磁盘和内核显然不能直接靠字符串完成文件操作。
操作系统必须先回答:
/home/user/test.txt到底对应哪个文件?
这个过程就是:
路径解析。
2.2.1 相对路径从哪里开始找?
例如:
open("./test.txt", O_RDONLY);
这是相对路径。
Linux 会从当前进程的:
fs_struct->pwd
开始寻找。
也就是:
task_struct
│
└── fs
↓
fs_struct
↓
pwd
pwd 保存当前工作目录。
2.2.2 绝对路径从哪里开始?
例如:
open("/home/user/test.txt", O_RDONLY);
路径以 / 开头。
此时从:
fs_struct->root
开始解析。
所以可以记住:
相对路径 → pwd
绝对路径 → root
2.2.3 pwd 和 root 保存的并不只是一个字符串
它们最终对应:
struct path
其中非常重要的两个部分是:
struct path
├── mnt
└── dentry
其中:
mnt
表示:
当前路径位于哪个挂载实例中。
Linux 并不像 Windows 那样简单使用:
C:
D:
E:
而是把不同文件系统挂载到统一目录树的不同位置。
所以解析一个路径时,不仅要知道:
当前目录是谁
还要知道:
它属于哪个挂载文件系统
dentry
dentry 可以理解为:
目录项在内存中的表示。
里面有:
d_name
d_parent
d_inode
于是可以形成:
文件名
↓
dentry
↓
inode
这里开始出现 Linux 文件系统一个非常重要的设计:
名字和文件本体并不是一回事。
文件名字主要通过目录项组织。
真正描述文件属性的是:
inode
2.2.4 路径解析为什么需要 dcache?
假设我们每次访问:
/home/user/project/src/main.cpp
都从磁盘上的根目录开始一层一层找:
/
↓
home
↓
user
↓
project
↓
src
↓
main.cpp
那性能会非常差。
所以 Linux 会缓存已经解析过的目录项。
这就是:
dcache
也就是:
dentry cache。
路径解析时,可以先尝试利用已经存在的 dentry 缓存。
如果对应目录项没有缓存,则需要进入具体文件系统,通过 inode 的相关操作,例如:
i_op->lookup()
继续完成查找。
于是整体思路可以理解成:
open(path)
↓
确定路径起点
↓
绝对路径 → root
相对路径 → pwd
↓
逐级解析路径
↓
优先利用 dcache
↓
缓存不存在
↓
进入具体文件系统查找
↓
得到 dentry / inode
2.2.5 chdir 为什么会影响相对路径?
例如:
chdir("/home/user");
之后:
open("./test.txt", O_RDONLY);
路径解析的起点已经发生变化。
本质上:
chdir
↓
修改当前进程 fs_struct 中的 pwd
目录树本身并没有被修改。
变的是:
当前进程站在目录树的哪个位置。
可以把它想象成:
目录树没动
人换地方了
所以如果当前工作目录发生变化,再继续使用相对路径,就可能访问完全不同的位置。
2.3 flags:文件到底准备怎么打开?
open 第二个参数用于描述打开方式。
最常见的几个:
O_RDONLY
只读。
O_WRONLY
只写。
O_RDWR
可读可写。
O_CREAT
文件不存在时创建。
open("test.txt", O_WRONLY | O_CREAT, 0666);
O_TRUNC
如果文件已经存在,将原内容截断为 0。
所以:
O_WRONLY | O_CREAT | O_TRUNC
经常用于覆盖写。
O_APPEND
追加写。
每次写数据时都追加到文件尾部。
O_NONBLOCK
非阻塞方式。
这个标志在:
管道
Socket
设备
等场景中非常常见。
O_CLOEXEC
表示:
在成功执行
exec后自动关闭这个 fd。
它可以避免文件描述符意外泄漏给新程序。
2.4 创建文件时 mode 和 umask 是怎么配合的?
例如:
open("test.txt", O_WRONLY | O_CREAT, 0666);
我们传入:
0666
但最终创建出来的文件权限不一定真的是:
0666
还需要经过:
umask
过滤。
可以粗略理解成:
最终权限 = mode & ~umask
例如:
mode = 0666
umask = 0002
则:
0666 & ~0002
=
0664
目录为什么一定要注意 x 权限?
对于普通文件:
r → 读
w → 写
x → 执行
而对于目录:
x
还有一个非常重要的含义:
允许穿过、进入这个目录进行路径解析。
因此即使你知道某个文件完整路径,如果中间目录缺少相应的执行权限,路径解析仍然可能失败。
这也是:
目录 x 权限
经常被忽略的地方。
2.5 creat:专门用来创建文件
函数:
#include <fcntl.h>
int creat(const char *pathname, mode_t mode);
它本质上相当于:
open(pathname,
O_WRONLY | O_CREAT | O_TRUNC,
mode);
所以现代代码中很多时候直接使用:
open()
就足够了。
2.6 write:把用户数据写向文件
函数原型:
#include <unistd.h>
ssize_t write(int fd, const void *buf, size_t count);
参数:
fd
→ 写哪个文件
buf
→ 数据从哪里来
count
→ 最多写多少字节
返回值:
> 0 → 实际写入的字节数
-1 → 发生错误
要注意:
返回值不一定永远等于
count。
系统调用存在"短写"的可能,可靠程序应根据返回值判断实际写入了多少。
2.6.1 为什么写字符串时经常用 strlen?
例如:
const char *msg = "hello Linux\n";
write(fd, msg, strlen(msg));
而不是:
write(fd, msg, 100);
原因很简单:
write根本不知道buf里面装的是不是 C 字符串。
它只知道:
从 buf 开始
向后取 count 个字节
所以:
strlen(msg)
用于告诉系统:
我真正需要写的是这个字符串实际有效的字节数。
而字符串末尾的:
'\0'
通常只是 C 字符串在用户态的结束标志,本身没有必要写入普通文本文件。
2.7 write 成功 ≠ 数据已经真正落盘
这是文件 IO 非常重要的一点。
很多人会下意识认为:
write(fd, buf, size);
只要返回成功:
数据就已经进入硬盘
实际上并不是。
Linux 为了解决:
CPU / 内存非常快
磁盘非常慢
这个巨大速度差距,引入了:
Page Cache
普通 buffered I/O 的典型写入过程更接近:
用户空间 buf
↓
write
↓
内核 Page Cache
↓
页面标记 dirty
↓
write 返回
↓
后台 writeback
↓
磁盘
所以:
write 返回成功,通常只代表数据已经成功进入内核管理的文件缓存体系,并不等于磁盘介质已经完成持久化。
后面 Page Cache 部分会完整解释这条链路。
2.8 用户级缓冲区:FILE* 和 fd 不要混为一谈
C 标准库中还有:
FILE *
比如:
FILE *fp = fopen("test.txt", "w");
这里要纠正一个非常容易出现的说法:
FILE*并不"等于" fd。
更加准确的是:
FILE*指向用户态标准 IO 库维护的流对象,它内部会封装文件描述符,并维护自己的缓冲区、错误状态等信息。
可以理解成:
FILE*
│
├── fd
├── 用户态缓冲区
├── 当前状态
└── ...
所以:
stdio
↓
FILE*
↓
用户级缓冲区
↓
write 系统调用
↓
内核
为什么还要多加一层缓冲区?
因为如果:
printf("a");
printf("b");
printf("c");
每次都立刻执行一次系统调用:
write()
系统调用次数会非常多。
于是 libc 可以先把数据积攒在用户级缓冲区里:
a
ab
abc
...
等满足刷新条件以后,再一次性调用:
write()
减少用户态与内核态之间频繁切换。
2.8.1 三种缓冲模式
标准 IO 常见三种缓冲方式。
全缓冲
缓冲区达到一定程度时再刷新。
普通文件经常采用这种方式。
行缓冲
遇到:
'\n'
时可以触发刷新。
终端上的 stdout 通常表现为行缓冲。
无缓冲
数据尽量直接提交给底层 IO。
例如 stderr 通常采用无缓冲策略,以便错误信息及时输出。
2.8.2 fflush 到底刷新到哪?
fflush(FILE *stream);
它做的是:
用户态 FILE 缓冲区
↓
fflush
↓
内核空间
注意:
fflush 并不等于"强制磁盘落盘"。
它解决的是:
用户级缓冲区
↓
内核
而真正涉及内核缓存向存储设备同步,则是另外一层问题。
这个区分非常重要:
fflush
→ libc 用户缓冲区 → 内核
write
→ 用户内存 → 内核文件缓存体系
fsync / O_SYNC
→ 进一步要求同步到持久化存储语义
2.8.3 fclose 和 exit 为什么会刷新缓冲区?
正常执行:
fclose(fp);
标准库会负责处理尚未刷新的用户态数据。
正常:
exit();
退出时同样会执行用户态清理过程,其中包括刷新标准 IO 缓冲区。
这件事一旦遇到:
fork()
就非常有意思了。
2.9 fork 为什么会把 printf 打印两遍?
看下面的程序:
#include <stdio.h>
#include <unistd.h>
int main()
{
printf("hello");
fork();
return 0;
}
如果:
hello
在调用 fork() 之前还停留在用户态缓冲区里,那么 fork 创建子进程时,整个用户空间状态会按写时拷贝语义被继承。
于是:
fork 前:
父进程缓冲区
[hello]
fork 后:
父进程缓冲区
[hello]
子进程缓冲区
[hello]
最终两个进程都:
return 0;
正常退出时都会刷新自己的缓冲区。
于是可能看到:
hellohello
加入:
printf("hello\n");
如果标准输出连接终端,通常是行缓冲。
\n 导致在 fork() 之前就完成刷新:
printf("hello\n")
↓
终端行缓冲刷新
↓
fork 时缓冲区已经为空
因此不会重复。
但是如果:
./a.out > test.txt
事情又变了。
stdout 不再连接终端,而是连接普通文件,往往转为:
全缓冲
因此即使存在:
'\n'
也不一定在 fork() 前刷新。
于是缓冲区仍然可能被复制,最终文件中出现两份输出。
exec 失败后为什么经常推荐 _exit?
典型场景:
pid_t id = fork();
if (id == 0)
{
execl(...);
// exec执行到这里说明失败
_exit(1);
}
为什么不是:
exit(1);
原因之一就是:
exit会执行用户态标准库清理,其中可能再次刷新从父进程继承而来的缓冲区。
而:
_exit()
直接交给内核结束当前进程,不执行 stdio 用户态清理。
因此在:
fork → exec
模型中,子进程 exec 失败后使用 _exit(),可以避免一些重复刷新导致的诡异问题。
2.10 read:从文件中读取数据
函数:
#include <unistd.h>
ssize_t read(int fd, void *buf, size_t count);
返回值非常重要:
n > 0
→ 成功读取 n 个字节
n == 0
→ EOF,读取结束
n == -1
→ 读取失败
同时:
read也不保证一次一定能读满count个字节。
因此应该始终以返回值 n 为准。
读取字符串时为什么要给 '\0' 留位置?
例如:
char buf[1024];
ssize_t n = read(fd, buf, sizeof(buf) - 1);
if (n > 0)
{
buf[n] = '\0';
}
为什么是:
sizeof(buf) - 1
因为:
read()
读取的是裸字节。
它不会自动帮我们补:
'\0'
如果后面准备:
printf("%s", buf);
那么就需要人为保证它是合法 C 字符串。
所以最后留下一个位置:
有效数据 | \0
需要注意:
如果读取的是二进制文件,就没有必要把数据强行当 C 字符串处理。
2.11 close:关闭的到底是什么?
函数:
#include <unistd.h>
int close(int fd);
返回值:
成功 → 0
失败 → -1
close(fd) 的核心作用是:
关闭当前进程文件描述符表中的这个 fd。
可以理解成:
fdtable[fd]
│
X
并使对应打开文件对象的引用计数减少。
如果已经没有任何引用,那么相关打开文件对象才会被真正释放。
close 和"删除文件"不是一回事
文件真正什么时候被删除,是 Linux 中非常经典的问题。
假设:
rm test.txt
本质上首先删除的是:
目录项 / 文件名到 inode 的链接
使 inode 的硬链接计数减少。
真正能够回收文件存储空间,通常需要满足:
硬链接数 == 0
&&
没有进程继续持有这个打开文件
所以会出现一种非常经典的现象:
进程已经 open 一个文件
↓
另一个进程 rm 文件
↓
文件名消失
↓
原来的进程仍然可以继续 read/write
因为:
目录项虽然没有了
但打开文件仍然引用 inode
直到最后的文件引用也被释放,文件数据才真正具备被回收的条件。
2.12 最小系统 IO 完整示例
把最基本的:
open → write → close → open → read → close
串起来:
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
int main()
{
const char *filename = "test.txt";
const char *msg = "hello Linux file system\n";
// 1. 打开/创建文件
int fd = open(filename,
O_WRONLY | O_CREAT | O_TRUNC,
0666);
if (fd == -1)
{
perror("open");
return 1;
}
// 2. 写文件
ssize_t ret = write(fd, msg, strlen(msg));
if (ret == -1)
{
perror("write");
close(fd);
return 1;
}
// 3. 关闭
close(fd);
// 4. 重新以只读方式打开
fd = open(filename, O_RDONLY);
if (fd == -1)
{
perror("open");
return 1;
}
char buf[1024];
// 给 '\0' 留一个位置
ssize_t n = read(fd, buf, sizeof(buf) - 1);
if (n == -1)
{
perror("read");
close(fd);
return 1;
}
if (n > 0)
{
buf[n] = '\0';
printf("%s", buf);
}
close(fd);
return 0;
}
到这里为止,我们已经拥有:
路径
↓
open
↓
fd
↓
struct file
↓
read / write
↓
close
但 fd 的能力远不止"标识文件"。
接下来就是它非常经典的应用:
重定向。
3. 重定向:改变 fd 与文件之间的指向关系
平时执行:
printf("hello\n");
为什么内容会打印到显示器?
因为:
printf
↓
stdout
↓
fd = 1
↓
终端设备
所以真正决定数据去哪里的,并不是:
printf
而是:
fd = 1 最终指向谁。
只要把 1 的指向修改掉:
1 ──→ 显示器
变成:
1 ──→ log.txt
程序完全不需要修改:
printf("hello\n");
输出就会自动进入文件。
这就是重定向的本质。
3.1 dup2
函数:
#include <unistd.h>
int dup2(int oldfd, int newfd);
可以理解为:
让
newfd指向oldfd当前指向的那个打开文件对象。
例如:
dup2(fd, 1);
原来:
1 ──→ 终端
fd ──→ log.txt
调用后:
1 ──┐
├──→ log.txt
fd ─┘
于是:
printf("hello\n");
最终就写进了:
log.txt
3.2 输出重定向:覆盖写
完整示例:
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
int main()
{
int fd = open("log.txt",
O_WRONLY | O_CREAT | O_TRUNC,
0666);
if (fd == -1)
{
perror("open");
return 1;
}
dup2(fd, STDOUT_FILENO);
printf("hello Linux\n");
printf("stdout has been redirected\n");
close(fd);
return 0;
}
这里:
STDOUT_FILENO
就是:
1
3.3 输出重定向:追加写
只需要把:
O_TRUNC
换成:
O_APPEND
例如:
int fd = open("log.txt",
O_WRONLY | O_CREAT | O_APPEND,
0666);
dup2(fd, STDOUT_FILENO);
于是新的输出不会清空旧文件,而是不断追加。
3.4 输入重定向
输入同理。
int fd = open("input.txt", O_RDONLY);
dup2(fd, STDIN_FILENO);
之后:
scanf();
getchar();
等原本从键盘读取数据的函数,就会转而从:
input.txt
读取。
本质仍然没有变化:
修改的不是 scanf
而是 fd = 0 指向谁
3.5 2>&1 到底是什么意思?
Shell 中经常看到:
./server > server.log 2>&1
先看:
> server.log
它相当于把:
1 → server.log
然后:
2>&1
表示:
让 fd = 2 做和 fd = 1 一样的指向。
于是最后:
1 ──┐
├──→ server.log
2 ──┘
因此:
stdout
stderr
都会进入同一个文件。
顺序非常重要
下面两个命令不是一回事:
./server > server.log 2>&1
和:
./server 2>&1 > server.log
Shell 会从左向右处理。
第一种:
1 → 文件
2 → 当前的 1 → 文件
所以二者都进入文件。
而第二种首先让:
2 → 当前的 1
此时 1 还指向终端。
然后才:
1 → 文件
所以最终:
1 → 文件
2 → 终端
这就是重定向顺序为什么不能乱。
4. 文件名和文件本体为什么能够分开?软硬链接与磁盘文件系统
前面路径解析时已经遇到了:
dentry
inode
这里终于可以回答一个非常关键的问题:
Linux 中,文件名到底是不是文件本身的一部分?
答案是:
不是。
目录中维护的核心关系更接近:
文件名 → inode number
inode 再去描述真正的文件属性以及文件数据的位置。
理解这一点以后,软链接和硬链接就会非常自然。
4.1 硬链接:多个文件名指向同一个 inode
创建硬链接:
ln a.txt b.txt
此时:
a.txt ──┐
├──→ inode
b.txt ──┘
也就是说:
a.txt和b.txt是两个目录项名称,但最终指向同一个 inode。
可以通过:
ls -li
观察 inode number。
例如:
12345 a.txt
12345 b.txt
两个名字的 inode 编号相同。
删除一个硬链接为什么文件还在?
例如:
rm a.txt
删除的是:
a.txt → inode
这一条名字链接。
但是:
b.txt → inode
仍然存在。
所以文件本体依然可以通过:
b.txt
访问。
因此:
删除文件名
≠
立刻删除文件数据
只有当:
inode 硬链接计数 == 0
并且:
没有进程继续持有打开引用
文件才真正具备释放条件。
4.2 为什么硬链接不能跨文件系统?
硬链接最终依赖:
目录项 → inode number
而 inode number 是:
某个具体文件系统内部管理 inode 时使用的编号。
两个不同文件系统:
文件系统 A
文件系统 B
各自管理自己的 inode。
所以不能简单让 A 文件系统中的目录项直接指向 B 文件系统内部的 inode。
因此:
硬链接不能跨文件系统。
4.3 为什么普通用户不能随便给目录创建硬链接?
如果目录可以随便互相建立硬链接:
A → B
B → A
就可能产生:
环
而目录本身还涉及:
.
..
等特殊关系。
这会破坏目录树应有的结构,让:
遍历
父子关系
引用管理
全部变得异常复杂。
因此 Linux 对目录硬链接有严格限制。
4.4 软链接:保存的是"路径"
创建:
ln -s a.txt b.txt
注意:
后面的 b.txt
才是新创建的软链接文件。
它的关系不是:
b.txt → a.txt 的 inode
而更像:
b.txt
↓
自己的 inode
↓
文件内容中保存 "a.txt" 这个路径
访问:
b.txt
时,系统读出:
a.txt
然后:
再做一次路径解析。
所以软链接可以理解成:
文件系统里的"快捷方式"。
4.5 为什么删掉目标文件后软链接会悬空?
因为软链接保存的是:
路径
例如:
a.txt
当:
rm a.txt
之后,再通过软链接访问时:
读取软链接
↓
得到路径 a.txt
↓
重新做路径解析
↓
找不到
于是形成:
悬空链接 / dangling symbolic link
4.6 为什么软链接可以跨文件系统?
因为它保存的是:
路径字符串
而不是:
直接引用另一个文件系统的 inode
只要这个路径能够经过正常路径解析找到目标,就可以跨文件系统。
4.7 为什么软链接不能无限套娃?
例如:
A → B
B → A
如果内核毫无限制地继续解析:
A
↓
B
↓
A
↓
B
↓
...
就会陷入死循环。
因此 Linux 对符号链接解析深度存在限制。
这也是为什么循环软链接最终会报:
Too many levels of symbolic links
4.8 磁盘到底怎么保存文件?
上面的 inode、目录、文件内容,最终都必须落在真实磁盘上。
传统机械硬盘曾经可以通过:
Cylinder
Head
Sector
进行物理位置描述,也就是经典的:
CHS
但现代软件层面更倾向使用:
LBA
将底层存储抽象成线性逻辑块:
0
1
2
3
4
...
文件系统再建立在这些逻辑块之上。
因此可以把层次理解成:
物理存储设备
↓
LBA 逻辑块
↓
文件系统
↓
inode / block / directory
↓
文件
4.9 Ext 文件系统为什么采用 Block Group?
如果整个磁盘只用一套全局结构来管理:
几百万个 inode
几千万个 block
管理成本会非常高。
Ext 系列文件系统采用:
Block Group
分组管理磁盘区域。
一个块组中,可以看到几个非常经典的部分:
Block Group
│
├── Super Block
├── Group Descriptor Table
├── Block Bitmap
├── Inode Bitmap
├── Inode Table
└── Data Blocks
不用死记名字,可以从"文件系统到底需要管理什么"出发理解。
Super Block
可以把它理解成:
整个文件系统的说明书。
记录文件系统整体的重要元数据,例如:
文件系统规模
block 大小
inode 情况
整体状态
...
GDT:Group Descriptor Table
用于描述各个:
Block Group
的管理信息。
Block Bitmap
文件系统必须知道:
哪个数据块已经使用?
哪个数据块还是空闲?
因此使用:
Block Bitmap
进行管理。
可以粗略想象:
0 → 空闲
1 → 已使用
Inode Bitmap
同理:
哪个 inode 可以分配?
哪个 inode 已经使用?
由:
Inode Bitmap
管理。
Inode Table
真正存放 inode。
前面说过:
文件属性 → inode
因此 inode table 就是大量 inode 的集中存放区域。
Data Blocks
真正保存文件内容。
所以磁盘上一个文件最重要的两部分最终就是:
属性
↓
inode
内容
↓
Data Blocks
4.10 目录本身到底保存什么?
目录当然也是文件。
但是目录数据的意义非常特殊。
其核心之一就是保存类似:
文件名 → inode number
这样的目录项信息。
例如一个目录中有:
a.txt
b.txt
hello
可以粗略理解成内部存在:
a.txt → inode 100
b.txt → inode 204
hello → inode 810
所以:
inode 本身并不依赖"文件名"来确定文件身份。
文件名属于目录组织体系的一部分。
这也是硬链接能够成立的根本原因:
多个名字
↓
同一个 inode
5. Page Cache:为什么 read/write 不会每次都直接碰磁盘?
磁盘非常慢。
如果每调用一次:
read()
都必须真正访问磁盘:
CPU
↓
内存
↓
磁盘
整个系统性能会非常糟糕。
所以 Linux 会把已经读取过的文件数据缓存在内存中。
这就是:
Page Cache。
5.1 Page Cache 为什么不能挂在 struct file 上?
先回忆:
struct file
表示:
一次打开行为。
比如:
int fd1 = open("a.txt", O_RDONLY);
int fd2 = open("a.txt", O_RDONLY);
完全可能产生:
fd1 → struct file A
fd2 → struct file B
但是它们打开的明明是:
同一个文件
如果 Page Cache 挂在 struct file 上:
struct file A → 一份缓存
struct file B → 又一份缓存
那么同一个磁盘文件就会被反复缓存,非常浪费,而且一致性管理也会变得复杂。
所以 Page Cache 更合理的归属应该是:
文件本身。
也就是:
inode
5.2 inode、address_space 与 Page Cache
Linux 中可以建立这样一条链:
struct inode
↓
struct address_space
↓
Page Cache
address_space 可以理解为:
管理某个文件内容在内存中缓存映射关系的重要结构。
Page Cache 中的数据按照:
文件偏移 / 文件页索引
进行组织。
历史内核实现中经常提到:
radix tree
现代 Linux 内核则使用:
XArray
并逐渐大量使用:
folio
来管理页缓存中的内存数据。
我们这里不继续深挖内核版本实现,只需要抓住核心关系:
inode
↓
address_space
↓
某个文件在内存中的 Page Cache
5.3 struct file 怎么找到 Page Cache?
前面:
fd
↓
struct file
而 struct file 中又可以通过:
f_mapping
访问对应的:
address_space
对于普通文件:
struct file
│
├── f_pos
├── f_flags
├── f_op
├── f_inode
└── f_mapping
↓
address_space
↓
Page Cache
于是整个文件读取过程逐渐能够串起来了。
5.4 read 的完整路径
调用:
read(fd, buf, size);
首先:
fd
↓
当前进程 files_struct
↓
struct file
从 struct file 中得到:
f_pos
确定当前从文件哪个位置读取。
然后找到:
f_mapping
↓
address_space
↓
Page Cache
接下来出现两个情况。
情况一:Page Cache 命中
需要的数据已经在内存。
read
↓
fd
↓
struct file
↓
address_space
↓
Page Cache
↓
命中
↓
拷贝给用户
完全不需要再次读取磁盘。
速度非常快。
情况二:Page Cache 未命中
如果所需文件页不在缓存中:
Page Cache miss
↓
访问磁盘文件系统
↓
读取对应数据块
↓
装入 Page Cache
↓
再把数据返回用户
于是:
第一次读取
→ 可能较慢
后续再次读取
→ 很可能直接命中内存
这就是文件缓存存在的重要意义。
5.5 write 的完整路径
普通 buffered write 的逻辑同样可以串起来。
write(fd, buf, size);
先通过:
fd
↓
struct file
↓
f_pos
确定写入位置。
然后:
f_mapping
↓
address_space
↓
Page Cache
把用户缓冲区中的数据复制进相应的缓存页。
于是:
用户 buf
↓
Page Cache
被修改的页面会被标记:
dirty
也就是:
内存中的数据已经修改,但磁盘版本还没有同步更新。
之后:
write()
便可能先返回。
后台再通过:
writeback
机制把脏页写回磁盘。
完整关系:
用户 buf
↓
write
↓
Page Cache
↓
dirty page
↓
write 返回
↓
后台 writeback
↓
文件系统
↓
磁盘
现在就能真正理解前面的那句话:
write 成功,不代表数据此刻已经物理落盘。
5.6 为什么要延迟写?
因为:
内存速度 >>> 磁盘速度
如果每次:
write()
都必须等待磁盘真正完成:
4KB
4KB
4KB
4KB
大量碎片化 IO 会严重影响性能。
有了 Page Cache 后,可以:
先写内存
↓
继续运行程序
↓
积累脏页
↓
系统统一 writeback
这样可以:
-
减少频繁磁盘访问
-
合并多个写操作
-
提高整体吞吐量
这就是典型的:
用内存换 IO 性能。
5.7 O_SYNC 是干什么的?
如果打开文件时使用:
O_SYNC
则要求写操作具有更强的同步语义。
也就是:
write不再只是简单把数据扔进缓存就立即结束,而是需要等待相应同步要求完成后才能返回。
代价很明显:
安全性 / 持久化保证增强
↑
性能下降
因此:
普通高吞吐写入
和:
强持久化要求
之间始终存在取舍。
数据库、日志系统等场景之所以经常研究:
fsync
fdatasync
O_SYNC
本质上就是在研究:
数据什么时候才真正算"可靠保存"。
6. 最后回到 task_struct:把整个文件系统真正串成一张图
学到这里,再看:
task_struct
就不再是一个孤零零的 PCB 结构体了。
它实际上把:
进程
文件描述符
当前目录
根目录
umask
打开文件
VFS
inode
Page Cache
全部连接了起来。
6.1 files_struct:进程已经打开了哪些文件?
第一条链:
task_struct
↓
files
↓
files_struct
↓
fd_array / fdtable
↓
struct file*
所以:
read(fd, ...);
第一步并不是"去磁盘找 fd"。
而是:
当前进程
↓
files_struct
↓
根据 fd 下标
↓
找到 struct file
fd 的作用,到这里就非常清晰了:
它是当前进程访问打开文件对象的索引入口。
6.2 struct file:描述一次打开行为
struct file
│
├── f_pos
│ └── 当前读写偏移
│
├── f_flags
│ └── O_RDONLY / O_APPEND / ...
│
├── f_op
│ └── 这个文件支持哪些具体操作
│
├── f_inode
│ └── 文件本身对应的 inode
│
└── f_mapping
└── address_space
↓
Page Cache
这里必须牢牢记住:
同一个文件多次 open,可以有多个不同的 struct file。
因此:
struct file
更偏向:
打开状态。
而:
inode
更偏向:
文件本体。
6.3 fs_struct:当前进程站在文件系统的哪里?
另一条重要链:
task_struct
↓
fs
↓
fs_struct
其中包括:
pwd
root
umask
pwd
表示:
当前工作目录
用于相对路径解析。
root
表示:
当前进程看到的根路径
用于绝对路径解析。
umask
创建文件时参与最终权限计算。
6.4 pwd / root 为什么最终都是 struct path?
因为路径不能只知道:
某个目录是谁
还需要知道:
它属于哪个挂载实例
所以:
pwd
↓
struct path
│
├── mnt
└── dentry
同理:
root
↓
struct path
│
├── mnt
└── dentry
6.5 dentry 最核心的几个关系
可以抓住:
dentry
│
├── d_name
│ └── 当前名字
│
├── d_parent
│ └── 父目录
│
└── d_inode
└── 文件 inode
于是:
名字
↓
dentry
↓
inode
如果对应 dentry 已经存在于:
dcache
路径查找可以直接利用缓存。
否则需要通过具体文件系统的:
inode_operations
继续寻找。
例如目录查找时常会涉及:
lookup()
创建目录项或文件时,则可能涉及:
create()
等操作。
注意:
dcache是整个 VFS 对 dentry 的缓存机制,并不是简单理解成dentry中某一个叫 dcache 的字段。
6.6 从 open 开始,把整条链完整走一遍
现在假设程序执行:
int fd = open("/home/user/a.txt", O_RDWR);
到底发生什么?
可以沿着这条主线理解。
第一步:确定路径解析起点
因为:
/home/user/a.txt
是绝对路径。
所以从:
task_struct
↓
fs_struct
↓
root
开始。
如果是:
./a.txt
则从:
pwd
开始。
第二步:逐级解析路径
/
↓
home
↓
user
↓
a.txt
解析过程中会使用:
dentry
dcache
inode
mount
等 VFS / 文件系统对象。
最终找到目标文件对应的:
inode
第三步:建立打开文件对象
内核为这一次打开行为建立:
struct file
其中记录:
f_pos
f_flags
f_op
f_inode
f_mapping
...
第四步:分配 fd
找到当前进程:
fdtable
中最小的空闲位置。
例如:
3
然后:
fdtable[3]
↓
struct file
最后:
open()
把:
3
返回给应用程序。
6.7 再走一次 read
程序:
read(fd, buf, size);
完整路径:
fd
↓
files_struct
↓
fdtable
↓
struct file
↓
f_pos
↓
f_mapping
↓
address_space
↓
Page Cache
如果命中:
Page Cache
↓
直接拷贝到用户 buf
如果未命中:
Page Cache
↓
磁盘文件系统
↓
Data Blocks
↓
加载进 Page Cache
↓
返回用户
6.8 再走一次 write
write(fd, buf, size);
典型路径:
fd
↓
struct file
↓
f_pos
↓
f_mapping
↓
address_space
↓
Page Cache
↓
修改缓存页
↓
dirty
↓
write 返回
↓
writeback
↓
磁盘
至此:
open
read
write
fd
struct file
inode
Page Cache
文件系统
已经不是七八个独立知识点,而是一条完整链路。
6.9 一张图收掉整篇文章
最后把整篇最重要的关系压成一张图。
task_struct
/ \
/ \
↓ ↓
files_struct fs_struct
│ / | \
│ / | \
fdtable pwd root umask
│ │ │
│ └──┬───┘
│ ↓
│ struct path
│ / \
│ ↓ ↓
│ mnt dentry
│ │
│ ┌───────┼────────┐
│ ↓ ↓ ↓
│ d_name d_parent d_inode
│ │
│ ↓
fd ──────────────────┘ struct inode
↓ │
struct file │
│ │
├── f_pos │
├── f_flags │
├── f_op │
├── f_inode ───────────────────────────────────┘
└── f_mapping
↓
address_space
↓
Page Cache
↓
文件系统
↓
inode table / data blocks
↓
磁盘
如果还要再压缩,可以记成:
进程如何找到已打开文件?
task_struct
→ files_struct
→ fdtable
→ fd
→ struct file
路径如何找到文件?
task_struct
→ fs_struct
→ root / pwd
→ struct path
→ mnt + dentry
→ inode
文件内容如何进入内存?
inode
→ address_space
→ Page Cache
最终如何落到磁盘?
Page Cache
→ 文件系统
→ inode / Data Blocks
→ 磁盘
这四条链如果真正能够在脑子里连起来,Linux 文件系统最重要的骨架基本就建立起来了。
7. 几个非常值得记住的面试结论
最后不重新复读整篇,只把真正容易被问、也容易答错的点压一下。
① fd 的本质是什么?
当前进程文件描述符表中的下标,通过它能够找到对应的
struct file。
② 为什么第一次 open 经常返回 3?
因为:
0 stdin
1 stdout
2 stderr
通常已经被占用。
而 fd 按:
当前最小未使用描述符
分配。
③ struct file 和 inode 有什么区别?
struct file
→ 描述一次打开行为
inode
→ 描述文件本身
因此同一个文件多次 open:
可以产生多个 struct file
但它们:
可以共同指向同一个 inode
④ 重定向的本质是什么?
改变文件描述符与打开文件对象之间的对应关系。
dup2 的核心不是修改 printf,而是修改:
stdout(fd=1)
到底指向谁。
⑤ write 返回成功是否意味着已经写入磁盘?
不一定。
普通 buffered I/O 通常先写入:
Page Cache
之后再通过:
writeback
真正落盘。
⑥ fflush 和 fsync 是一回事吗?
不是。
fflush
→ 刷 libc 用户级缓冲区
fsync
→ 请求同步内核中的文件数据到持久化存储
这是非常典型的混淆点。
⑦ Page Cache 为什么不挂在 struct file 上?
因为:
同一个文件
可能:
被 open 多次
产生多个:
struct file
但文件缓存应该属于:
文件本身
因此通过:
inode
→ address_space
→ Page Cache
统一组织。
⑧ 硬链接和软链接最本质的区别?
硬链接:
不同文件名
↓
同一个 inode
软链接:
独立 inode
↓
文件内容保存目标路径
↓
访问时再次路径解析
⑨ 文件什么时候才真正被删除?
典型情况下需要:
硬链接计数 == 0
&&
没有进程继续持有打开引用
所以:
rm
不一定意味着某个正在使用该文件的进程立即失去访问能力。
⑩ 相对路径和绝对路径分别从哪开始?
相对路径
→ fs_struct->pwd
绝对路径
→ fs_struct->root
🌙 写在最后
文件系统这一块最容易掉进一个坑:
open 背一个
read 背一个
write 背一个
inode 背一个
dentry 背一个
Page Cache 再背一个
背到最后,每一个词都见过,但真正让你解释:
执行一次 read(fd, buf, size)****,数据到底是怎么从文件来到进程里的?
脑子里还是一片散的。
真正有价值的理解应该是:
fd
↓
struct file
↓
inode / address_space
↓
Page Cache
↓
文件系统
↓
磁盘
以及另一条:
pathname
↓
root / pwd
↓
dentry
↓
inode
↓
struct file
↓
fd
当这两条链真正接起来以后:
open不再只是一个函数,
fd不再只是一个整数,
inode不再只是一个八股名词,Page Cache 也不再是一句"为了提高效率"。
它们终于变成了同一个 Linux 文件系统故事里的不同角色。
🧭 Linux 的魅力,就在这种"看起来只是一个接口,背后却连着整个操作系统"的地方。
今天这把文件系统的剑,就练到这里。
👀 关注 :后续继续沿着 Linux 系统与网络这棵大树向下挖
❤️ 点赞 :如果这篇文章真的帮你把文件系统串起来了
⭐ 收藏 :面试前重新顺一遍
fd → file → inode → Page Cache💬 评论:有哪一段仍然感觉断,我也很想知道大家最容易卡在哪
代码不是背出来的,系统也不是八股拼出来的。把关系真正串起来,很多东西自然就记住了。
🐾 今日份 Linux 练级完成
文件基础认知 ✅
open/read/write ✅
fd / struct file ✅
用户级缓冲区 ✅
fork 缓冲问题 ✅
重定向 ✅
软硬链接 ✅
Ext 文件系统 ✅
inode / dentry ✅
Page Cache ✅
task_struct 总串联 ✅
下一次再看到"Linux 一切皆文件",脑子里应该已经不只剩这一句话了。