Linux-20 VFS:一切皆文件的实现代价,file/dentry/inode 三层对象
18 篇讲了「一切皆文件」这个抽象的取舍,这一篇讲内核到底怎么实现它 —— 以及为什么这个实现需要三层对象而不是一层。
先看五个问题:
read(fd, buf, n)从 fd 到实际的磁盘操作,中间经过哪些层?struct file、struct dentry、struct inode三者各自解决什么问题?为什么不能合并成一个?- 为什么
fork()之后父子进程共享文件偏移量,而两次open()同一个文件却各自独立? - 为什么内核要专门做 dentry cache?
- 挂载遮盖(11 篇)、bind mount、容器的不同文件系统视图,在 VFS 里分别是怎么表示的?
1. VFS 要解决的问题
1.1 问题:几十种文件系统,一套接口
# 一台普通机器上同时挂载着多少种"文件系统"
findmnt -o TARGET,FSTYPE | awk 'NR>1{print $2}' | sort -u
# autofs bpf cgroup2 configfs debugfs devpts devtmpfs
# efivarfs ext4 fusectl hugetlbfs mqueue overlay proc
# pstore securityfs sysfs tmpfs tracefs xfs
cat /proc/filesystems | wc -l # 内核支持的类型数
这些东西的差异极大:
ext4 真实的磁盘布局,有 inode 表、日志、extent 树
xfs 完全不同的磁盘布局,B+ 树、动态 inode 分配
tmpfs 根本没有磁盘,数据只在 page cache 里
procfs 文件内容是【读取时由内核函数生成的】,磁盘上不存在
sysfs 每个文件对应一个内核对象的属性
overlay 由多个下层目录「叠加」出来的视图(容器镜像的基础)
nfs 数据在另一台机器上,通过网络访问
fuse 实现代码在【用户态】进程里
而上层完全不应该关心这些差异:
// 应用代码、甚至内核里的大部分代码,都只用这一组接口
int fd = open(path, flags);
read(fd, buf, n);
write(fd, buf, n);
close(fd);
VFS(Virtual File System,虚拟文件系统)就是这个「统一层」。它的职责是:定义一套抽象接口,让每种文件系统各自实现,上层只面向抽象编程。
1.2 C 语言里的多态:函数指针表
内核是 C 写的,没有 class 和虚函数。它用结构体里的函数指针表实现同样的效果 —— 这本质上就是手写的虚函数表(vtable):
// include/linux/fs.h(简化)
struct file_operations {
struct module *owner;
loff_t (*llseek)(struct file *, loff_t, int);
ssize_t (*read_iter)(struct kiocb *, struct iov_iter *);
ssize_t (*write_iter)(struct kiocb *, struct iov_iter *);
int (*open)(struct inode *, struct file *);
int (*release)(struct inode *, struct file *); // 对应 close
int (*fsync)(struct file *, loff_t, loff_t, int datasync);
long (*unlocked_ioctl)(struct file *, unsigned int, unsigned long);
int (*mmap)(struct file *, struct vm_area_struct *);
__poll_t (*poll)(struct file *, struct poll_table_struct *); // epoll 用它
ssize_t (*splice_read)(...); // 零拷贝
...
};
每种文件系统提供一份自己的实现:
// ext4 的实现(fs/ext4/file.c)
const struct file_operations ext4_file_operations = {
.llseek = ext4_llseek,
.read_iter = ext4_file_read_iter,
.write_iter = ext4_file_write_iter,
.unlocked_ioctl = ext4_ioctl,
.mmap = ext4_file_mmap,
.fsync = ext4_sync_file,
...
};
// 管道的实现(fs/pipe.c)—— 同一组接口,完全不同的语义
const struct file_operations pipefifo_fops = {
.llseek = no_llseek, // ✅ 管道不支持 seek —— 直接返回错误
.read_iter = pipe_read,
.write_iter = pipe_write,
.poll = pipe_poll,
...
};
// socket 的实现(net/socket.c)
static const struct file_operations socket_file_ops = {
.llseek = no_llseek,
.read_iter = sock_read_iter,
.write_iter = sock_write_iter,
.poll = sock_poll,
...
};
于是 read() 系统调用的核心只有一行分派:
// fs/read_write.c(大幅简化)
ssize_t vfs_read(struct file *file, char __user *buf, size_t count, loff_t *pos)
{
if (!(file->f_mode & FMODE_READ)) return -EBADF;
if (!file->f_op->read_iter) return -EINVAL;
return call_read_iter(file, &kiocb, &iter); // ← 通过函数指针分派到具体实现
// ^^^^^^^^^^^^^^ 本质上是 file->f_op->read_iter(...)
}
这就是 18 篇讲的「统一接口」在内核里的样子,也是它两个代价的来源:
代价一:间接调用开销
每次 read/write 都要一次函数指针跳转(无法内联、影响 CPU 分支预测)
在百万 IOPS 场景下可测量 —— 这是 io_uring 想绕开的开销之一(32 篇)
代价二:接口必须"够宽"以容纳所有实现
file_operations 有几十个成员,因为要覆盖普通文件、管道、socket、
设备、epoll 实例的全部需求 —— 而任何单一实现只用到其中一小部分
(管道的 llseek 只能返回错误,这就是 18 篇讲的「语义泄漏」)
1.3 四组操作表
VFS 定义了四组主要的操作表,分别挂在四个对象上:
| 操作表 | 挂在哪 | 管什么 | 典型成员 |
|---|---|---|---|
file_operations |
struct file |
已打开文件的 I/O | read_iter write_iter mmap poll ioctl fsync |
inode_operations |
struct inode |
命名空间操作(增删改查目录项) | lookup create link unlink symlink mkdir rename setattr |
super_operations |
struct super_block |
文件系统整体 | alloc_inode write_inode sync_fs statfs remount_fs |
dentry_operations |
struct dentry |
目录项的缓存行为 | d_revalidate d_hash d_compare d_delete |
这个划分本身就有信息量:open/read 属于 file_operations(作用于「打开的实例」),而 unlink/rename/mkdir 属于 inode_operations(作用于「目录这个 inode 里的条目」)。
这直接印证了 04 篇讲的「删除文件看目录权限」:
// unlink 是【父目录 inode】的操作,不是文件自己的操作
// fs/namei.c
int vfs_unlink(struct inode *dir, struct dentry *dentry, ...)
// ^^^^^^^^^^^^^^^^ 传进来的是【父目录】的 inode
{
error = may_delete(dir, dentry, false); // ← 检查【父目录】的写权限
...
error = dir->i_op->unlink(dir, dentry); // ← 调用父目录 inode 的 unlink
}
2. 四个核心对象
2.1 开篇第二问:为什么必须分三层
先给出结论,再用三个场景证明「合并会出问题」:
| 对象 | 代表什么 | 生命周期 | 有几个 |
|---|---|---|---|
struct file |
一次 open() 的结果(打开实例) |
从 open 到 close |
每次 open 一个 |
struct dentry |
路径中的一个名字(目录项) | 内存缓存对象,可被回收 | 每个「名字」一个 |
struct inode |
文件本身(元数据 + 数据位置) | 与磁盘上的 inode 对应 | 每个文件唯一 |
struct super_block |
一个已挂载的文件系统 | 从 mount 到 umount | 每个挂载一个 |
进程 A 进程 B
fd 3 ──┐ fd 5 ──┐
v v
+--------------+ +--------------+
| struct file | | struct file | ← 各有独立的 f_pos(偏移量)
| f_pos = 100 | | f_pos = 0 | 和 f_flags(打开模式)
| f_flags=O_RD | | f_flags=O_WR |
+--------------+ +--------------+
| |
+---------------+---------------+
v
+------------------+
| struct dentry | "app.log" ← 名字与 inode 的绑定
+------------------+
|
+-----+-----+ ← 硬链接:另一个 dentry 指向同一个 inode
v v
+------------------+ +------------------+
| dentry "app.log" | | dentry "app.bak" |
+------------------+ +------------------+
| |
+-----------+-----------+
v
+------------------+
| struct inode | ← 唯一:权限、大小、时间戳、
| i_mode i_uid | 链接计数、数据块位置
| i_size i_nlink | (04 篇 1.1 讲的那些字段)
| i_op i_fop |
+------------------+
|
v
+------------------+
| super_block | ← 这个文件系统实例
| (ext4 on /dev/sda2)|
+------------------+
三个场景证明必须分层:
场景一:同一文件被打开两次 → 需要两个 file,一个 inode
# 两次 open 各有独立的读写偏移量
exec 3< /etc/passwd
exec 4< /etc/passwd
head -c 10 <&3 >/dev/null # fd 3 的偏移量前进 10
# fd 4 的偏移量还是 0 —— 两个 struct file 各自独立
ls -l /proc/$$/fd/3 /proc/$$/fd/4 # 指向同一个文件
cat /proc/$$/fdinfo/3 # ✅ 能直接看到偏移量
# pos: 10
# flags: 02000000
cat /proc/$$/fdinfo/4
# pos: 0 <- 独立的偏移量
exec 3<&- 4<&-
如果把偏移量放进 inode,两次打开就会互相干扰 —— 这是必须有 struct file 这一层的直接原因。
场景二:硬链接 → 需要两个 dentry,一个 inode
cd /tmp && echo data > a.txt && ln a.txt b.txt
stat -c '%i %h' a.txt b.txt
# 2621441 2
# 2621441 2 <- 同一个 inode,两个名字(04 篇 1.1)
元数据(权限、大小、时间戳)只有一份,必须放在 inode 里;而「名字」和「在哪个目录下」是 dentry 的职责。如果合并,改一个名字的权限会影响另一个 —— 但那正是硬链接的正确语义,所以元数据必须在共享的那一层。
场景三:fork() → 父子共享同一个 file(共享偏移量!)
# 这是很多人不知道的行为
cat > /tmp/fork_test.sh <<'EOF'
exec 3> /tmp/out.txt
( echo "child-line" >&3 ) & # 子进程(fork 出来的)写
wait
echo "parent-line" >&3 # 父进程写
exec 3>&-
EOF
bash /tmp/fork_test.sh && cat /tmp/out.txt
# child-line
# parent-line <- ✅ 没有互相覆盖!
因为 fork() 复制的是 fd(指向同一个 struct file),不是复制 struct file 本身 —— 所以父子共享同一个偏移量,一个写完偏移量前进,另一个接着写。
fork() 前: fork() 后:
父 fd 3 ──┐ 父 fd 3 ──┐
v v
struct file struct file ← 只有一个!f_count 变成 2
f_pos = 0 f_pos = 0
^
子 fd 3 ──────┘
这解释了 shell 重定向的一个重要行为:
# ✅ 多个子进程写同一个重定向目标不会互相覆盖(共享偏移量)
{ echo a; echo b; } > /tmp/f # 两个 echo 是同一个 shell 的
( echo x; echo y ) > /tmp/f2 # 子 shell 里的多条命令
for i in 1 2 3; do echo $i & done > /tmp/f3; wait # 并发写,各自追加不覆盖
cat /tmp/f3 # 三行都在(顺序可能乱,但不丢)
# ❌ 但每次重新打开就不共享了
echo a > /tmp/f4
echo b > /tmp/f4 # 第二次 open 带 O_TRUNC,截断了文件
cat /tmp/f4 # 只有 b
对比:dup() 与重新 open() 的区别
int fd1 = open("f", O_WRONLY);
int fd2 = dup(fd1); // ✅ 两个 fd,【同一个 struct file】→ 共享偏移量
int fd3 = open("f", O_WRONLY); // 两个 struct file → 偏移量独立
// 这就是 09 篇 2.2 讲的 2>&1 的本质:
// dup2(1, 2) 让 fd 2 指向 fd 1【当时指向的那个 struct file】
// 所以之后 fd 1 改指向别处,fd 2 不受影响
2.2 struct file 里有什么
// include/linux/fs.h(简化并注释)
struct file {
struct path f_path; // 包含 dentry 和 vfsmount(见第 5 章)
struct inode *f_inode; // 缓存的 inode 指针(快速访问)
const struct file_operations *f_op; // ← 操作表:决定 read/write 的实际行为
atomic_long_t f_count; // 引用计数(dup/fork 会增加)
unsigned int f_flags; // O_RDONLY / O_APPEND / O_NONBLOCK …
fmode_t f_mode; // FMODE_READ / FMODE_WRITE
loff_t f_pos; // ✅ 当前读写偏移量(每个 open 独立)
struct fown_struct f_owner; // 异步 I/O 的信号接收者
void *private_data;// 具体实现的私有数据(如 socket、epoll 实例)
struct address_space *f_mapping; // page cache(指向 inode 的 i_mapping)
...
};
# 从用户态观察这些字段
PID=$$
cat /proc/$PID/fdinfo/1
# pos: 0 <- f_pos
# flags: 02100002 <- f_flags(八进制)
# mnt_id: 26 <- 属于哪个挂载点(vfsmount)
# ino: 6 <- inode 号
# epoll 实例的 fdinfo 更有信息量(31 篇会用到)
# tfd: 5 events: 19 data: ... <- 它监视的每个 fd
2.3 fd 到 file 的映射
// fd 只是一个数组下标(04 篇 1.1 说过)
struct task_struct { // 进程描述符
struct files_struct *files; // 打开文件表
...
};
struct files_struct {
atomic_t count; // ✅ 引用计数:线程共享同一份(vfork/clone 时)
struct fdtable __rcu *fdt;
...
};
struct fdtable {
unsigned int max_fds;
struct file __rcu **fd; // ✅ 就是一个数组:fd[3] 就是 fd 3 对应的 struct file
unsigned long *close_on_exec; // FD_CLOEXEC 位图
unsigned long *open_fds; // 哪些 fd 已被占用
...
};
由此可以解释几件事:
# ① 为什么 fd 号总是「最小可用的整数」
# 因为内核在 open_fds 位图里找第一个空位
exec 3<&- ; exec 5< /etc/hosts # 关掉 3,再开一个会拿到 3 而不是 6
ls /proc/$$/fd
# ② 为什么 ulimit -n 限制的是「每进程」的 fd 数(13 篇 8.1)
# 因为 fdtable 是 per-process 的
cat /proc/self/limits | grep 'open files'
# ③ 为什么线程之间共享 fd
# clone(CLONE_FILES) 让新线程复用同一个 files_struct
# → 一个线程 open 的 fd,另一个线程能直接用
# ④ FD_CLOEXEC 是怎么实现的
# execve() 时内核遍历 close_on_exec 位图,关掉标记过的 fd
# 这就是为什么现代 API 都提供 O_CLOEXEC(避免 fd 泄漏给子进程)
// Go 里所有 fd 默认带 O_CLOEXEC,这是有意的设计
// 否则 exec.Command 起的子进程会继承一堆无关 fd(安全风险 + fd 泄漏)
f, _ := os.Open("x") // 内部用 openat(..., O_CLOEXEC)
// 需要故意传给子进程时,要用 cmd.ExtraFiles
cmd := exec.Command("child")
cmd.ExtraFiles = []*os.File{f} // 会成为子进程的 fd 3
3. 从 fd 到磁盘:完整调用链
用户态:read(3, buf, 4096)
|
| syscall 指令(陷入内核,切换到内核栈)
v
① sys_read() / __x64_sys_read()
| fdget_pos(fd) —— 查 current->files->fdt->fd[3],拿到 struct file
| 并对 f_pos 加锁(防止多线程并发读写偏移量错乱)
v
② vfs_read(file, buf, count, pos)
| 权限检查(f_mode & FMODE_READ)
| rw_verify_area()(检查偏移量合法性、强制锁)
v
③ call_read_iter() -> file->f_op->read_iter() ← 【VFS 分派点】
|
+---- ext4_file_read_iter (普通文件)
+---- pipe_read (管道)
+---- sock_read_iter (socket)
+---- proc_reg_read_iter (/proc)
|
v (以 ext4 为例)
④ generic_file_read_iter()
| 查 page cache:filemap_read()
| ├─ 命中 → 直接从 page 拷到用户缓冲区,【结束】
| └─ 未命中 → 继续
v
⑤ page_cache_sync_readahead() / filemap_get_pages()
| 分配 page,发起读取,并触发【预读】(readahead)
v
⑥ ext4_readpage / ext4_readahead
| 通过 extent 树把「文件内偏移」翻译成「磁盘块号」
v
⑦ submit_bio() ← 提交给块层
| bio → 请求队列 → IO 调度器(none/mq-deadline/bfq,11 篇 11.3)
v
⑧ 块设备驱动(nvme/virtio_blk/scsi)
| 写入硬件队列,触发 DMA
v
⑨ 硬件完成 → 中断 → 软中断处理 → 唤醒等待的进程
| (进程在这期间处于 D 状态 —— 13 篇 2.5 讲的不可中断睡眠!)
v
⑩ 数据从 page cache 拷到用户缓冲区,返回字节数
用工具实际观察这条链:
# ① strace 看系统调用层
strace -e trace=read,openat cat /etc/hostname
# openat(AT_FDCWD, "/etc/hostname", O_RDONLY) = 3
# read(3, "myhost\n", 131072) = 7
# read(3, "", 131072) = 0 <- 返回 0 = EOF
# ② ftrace 看内核函数调用链(需要 root)
sudo trace-cmd record -p function_graph -g vfs_read -- cat /etc/hostname
sudo trace-cmd report | head -40
# 能看到 vfs_read -> ext4_file_read_iter -> generic_file_read_iter -> filemap_read
# ③ bpftrace 统计各文件系统的 read 分布
sudo bpftrace -e 'kprobe:vfs_read { @[comm] = count(); }'
sudo bpftrace -e 'kprobe:ext4_file_read_iter { @bytes = hist(arg2); }'
# ④ 看是否命中 page cache(未命中才会真的读磁盘)
sudo bpftrace -e '
kprobe:filemap_read { @total = count(); }
kprobe:submit_bio { @disk_io = count(); }
'
# @total 远大于 @disk_io 说明 page cache 命中率高
# ⑤ 看整条路径的耗时分布
sudo bpftrace -e 'kprobe:vfs_read { @start[tid] = nsecs; }
kretprobe:vfs_read /@start[tid]/ { @us = hist((nsecs - @start[tid])/1000); delete(@start[tid]); }'
这条链解释了前面篇目的几个现象:
# ① 为什么 page cache 命中时 read 极快(05 篇的缓冲、11 篇的 df/du)
# 命中时在第 ④ 步就返回了,根本不碰磁盘
# ② 为什么进程会卡在 D 状态(13 篇 2.5)
# 第 ⑨ 步等硬件完成时进程睡在不可中断状态
# ③ 为什么 O_DIRECT 能绕过 page cache
# 它跳过 ④⑤,直接走 ⑦(代价是失去缓存与预读)
# ④ 为什么 sendfile/splice 能零拷贝(32 篇细讲)
# 它们在内核内部把 page cache 的页直接交给 socket,省掉「拷到用户态再拷回来」
4. 路径解析与 dentry cache
4.1 开篇第四问:为什么需要 dentry cache
03 篇讲过路径是逐段解析的。把这个过程放到 VFS 的对象模型里看:
open("/var/log/app.log")
① 从当前进程的 root dentry 开始("/")
② 在 "/" 的 inode 里查找名字 "var" -> 需要 inode_operations->lookup()
lookup 要【读磁盘】:读目录的数据块,解析目录项,找到 "var" 的 inode 号
③ 读入 var 的 inode,检查执行权限(03 篇 5.1 的 x 权限)
④ 在 var 的 inode 里查找 "log" -> 又一次 lookup,又要读磁盘
⑤ 在 log 的 inode 里查找 "app.log" -> 又一次
⑥ 创建 struct file,返回 fd
如果每次都这样走,代价是灾难性的:
一个 4 级路径 = 4 次目录查找 = 最坏情况 4~8 次磁盘 I/O
而 open() 在真实负载里极其频繁:
- 每个 HTTP 请求可能 open 几个文件(模板、静态资源、日志)
- 编译一次大项目要 open 几十万次
- ls -l 一个目录要对每个文件 stat(也要走路径解析)
→ 没有缓存,系统会被路径解析压垮
dentry cache(dcache)就是「路径名 → dentry」的缓存,它是纯内存对象,不对应磁盘上的任何结构:
# 看 dcache 的规模
grep -E 'dentry|inode_cache' /proc/slabinfo | awk '{print $1, $2, $3}'
# dentry 1234567 1234567 <- 活跃对象数 / 总对象数
# ext4_inode_cache 456789 456789
sudo slabtop -o | head -8 # 更直观
# dcache 属于「可回收的内核内存」,算在 SReclaimable 里
grep -E 'SReclaimable|SUnreclaim' /proc/meminfo
# 手动回收(⚠️ 生产上慎用,会导致后续路径解析全部 miss)
sync; echo 2 | sudo tee /proc/sys/vm/drop_caches # 2 = 只清 dentry 和 inode 缓存
4.2 负 dentry:缓存「不存在」
一个反直觉但很重要的机制:dcache 也缓存「这个名字不存在」这个事实。
// 负 dentry(negative dentry):d_inode == NULL 的 dentry
// 它表示「这个名字确定不存在」,避免重复查磁盘
# 为什么需要它?因为「查找失败」也很频繁
strace -f -e trace=openat python3 -c 'import json' 2>&1 | grep -c ENOENT
# 几十到上百次 ENOENT —— 解释器在多个目录里搜索模块,大部分是失败的
# 同类场景:动态链接器搜索 .so、shell 搜索 PATH(01 篇 3.1)、
# Java 找 class、编译器找头文件
# 没有负 dentry 的话,每次失败的查找都要读一次磁盘目录
它的副作用:负 dentry 可能大量堆积占用内存。典型场景是频繁创建删除临时文件、或程序不断探测不存在的路径:
# 观察负 dentry 数量(需要 root)
sudo bpftrace -e 'kprobe:d_alloc { @ = count(); }' 2>/dev/null
# 或者从 slabinfo 看 dentry 总数持续增长但文件数没变
# 内核会在内存压力下回收它们,但如果 dentry 占用异常大:
echo 2 | sudo tee /proc/sys/vm/drop_caches
sysctl vm.vfs_cache_pressure # 默认 100,调大会让内核更激进地回收 dcache
4.3 快慢两条路径:RCU-walk 与 ref-walk
路径解析是内核里最热的代码路径之一,所以内核为它做了两套实现:
| RCU-walk(快路径) | ref-walk(慢路径) | |
|---|---|---|
| 加锁 | 完全无锁(依赖 RCU + 序号校验) | 对每个 dentry 加引用计数和自旋锁 |
| 适用 | dcache 全部命中、路径中无符号链接、无挂载点跨越 | 需要读磁盘、遇到软链接、跨挂载点、权限需要复杂检查 |
| 性能 | 极快(多核可完全并行,无 cache line 争抢) | 慢(原子操作 + 可能睡眠) |
| 失败处理 | 一旦条件不满足就回退到 ref-walk 重走一遍 | — |
为什么值得做两套?
因为绝大多数 open() 的路径都在 dcache 里,
而「给每个 dentry 加引用计数」在多核上会造成严重的 cache line 争抢
(几十个核同时解析 /usr/lib/... 时,都要原子地改同一个 dentry 的计数)
RCU-walk 完全避免了写操作 —— 这是它能线性扩展的关键。
# 观察两条路径的比例
sudo bpftrace -e '
kprobe:path_lookupat { @lookup = count(); }
kprobe:unlazy_walk { @fallback_to_refwalk = count(); }
'
# @fallback 占比高说明有大量软链接或跨挂载点访问
4.4 符号链接与 ELOOP
// 软链接在 VFS 里的处理:inode_operations 里有 get_link()
struct inode_operations {
const char *(*get_link)(struct dentry *, struct inode *, struct delayed_call *);
...
};
// 路径解析遇到软链接时:
// ① 调 get_link() 拿到目标路径字符串
// ② 【递归】解析这个新路径
// ③ 用 nd->depth 计数,超过 MAXSYMLINKS(40)就返回 ELOOP
# 验证 40 层限制(03 篇 6.3 提过)
cd /tmp && ln -s a b && ln -s b a
cat a
# cat: a: Too many levels of symbolic links <- ELOOP
# 内核里这个限制是编译期常量(不可调)
grep -rn 'MAXSYMLINKS' /usr/include/linux/limits.h
# #define MAXSYMLINKS 40
# 这也解释了 03 篇的另一件事:为什么硬链接不能指向目录
# 软链接成环有【可数的跟随层数】作为兜底
# 硬链接成环没有任何计数器能发现 -> 内核直接禁止
5. 挂载在 VFS 里怎么表示
5.1 挂载点是 dentry 上的一个「跳板」
struct vfsmount {
struct dentry *mnt_root; // 这个文件系统的根 dentry
struct super_block *mnt_sb; // 对应的超级块
int mnt_flags; // MNT_NOSUID / MNT_NODEV / MNT_NOEXEC / MNT_READONLY
};
struct mount { // 内部结构(fs/mount.h)
struct mount *mnt_parent; // ✅ 父挂载
struct dentry *mnt_mountpoint; // ✅ 挂在【哪个 dentry】上
struct vfsmount mnt;
struct list_head mnt_mounts; // 子挂载列表
int mnt_id;
struct mnt_namespace *mnt_ns; // ✅ 属于哪个 mount namespace(容器基础)
...
};
路径解析走到一个挂载点时会「跳转」:
解析 /data/file,其中 /data 是一个挂载点
① 正常解析到 "/" 下的 dentry "data"
② 发现这个 dentry 被标记了 DCACHE_MOUNTED
③ 查 mount hash 表,找到挂在它上面的 vfsmount
④ 【切换】到那个 vfsmount 的 mnt_root(新文件系统的根 dentry)
⑤ 从新的 dentry 继续解析 "file"
→ 所以 struct path 需要【两个】成员:
struct path { struct vfsmount *mnt; struct dentry *dentry; };
光有 dentry 不够 —— 同一个 dentry 可能属于不同的挂载实例(bind mount)
5.2 挂载遮盖的原理
这就是 11 篇 5.3 那个现象的实现原因:
# 现象回顾
sudo mkdir -p /data && echo "重要数据" | sudo tee /data/important.txt
sudo mount /dev/sdb1 /data
ls /data # 空的!important.txt 不见了
sudo umount /data
ls /data # 又回来了
原理:挂载没有动原来的任何东西。它只是在 /data 这个 dentry 上放了一个「跳板」标记 —— 之后所有路径解析走到这里都会被转向新文件系统的根,于是原来的目录内容再也不会被访问到(但它仍然占着原文件系统的空间)。
# 所以「用 bind mount 从另一个位置访问被遮盖的内容」是完全合理的
sudo mount --bind / /mnt/rootfs
# ^^^^^^ 把根文件系统在另一个 dentry 上再挂一次
sudo ls /mnt/rootfs/data # ✅ 能看到被遮盖的原始内容
# ^^^^^^^^^^^^ 这条路径解析时不会经过 /data 那个跳板
sudo du -sh /mnt/rootfs/data
sudo umount /mnt/rootfs
5.3 bind mount 的实现
sudo mount --bind /data/www /var/www
# 内核做的事:创建一个新的 struct mount,
# mnt_mountpoint = /var/www 的 dentry
# mnt_root = /data/www 的 dentry ← 关键:指向【已有的】dentry
# mnt_sb = 与源相同的 super_block
# 所以它不是拷贝,而是「同一份 inode 树的第二个入口」
# 验证:bind mount 的两边是同一个 inode
sudo mount --bind /data/www /var/www
stat -c '%i %d' /data/www /var/www
# 2621500 2049
# 2621500 2049 <- inode 和设备号都相同
findmnt /var/www
# TARGET SOURCE FSTYPE OPTIONS
# /var/www /dev/sdb1[/www] ext4 rw,relatime
# ^^^^^^ 方括号里显示「源是该文件系统里的哪个子目录」
# 只读 bind mount 需要两步(11 篇 5.4 提过)
sudo mount --bind /data/config /app/config
sudo mount -o remount,bind,ro /app/config
# 原因:bind 时的 ro 在老内核上不生效 —— 因为第一步只是建立跳板,
# 挂载标志(MNT_READONLY)要在第二步才能设到新的 struct mount 上
5.4 挂载传播:容器共享目录的基础
一个 dentry 上的挂载操作,是否应该「传播」到其他挂载了同一目录的地方?内核提供了四种策略:
findmnt -o TARGET,PROPAGATION | head
# TARGET PROPAGATION
# / shared
# /proc shared
# 四种传播类型
sudo mount --make-shared /mnt/a # shared:双向传播(A 下面挂新东西,B 也能看到)
sudo mount --make-private /mnt/a # private:不传播(默认)
sudo mount --make-slave /mnt/a # slave:单向接收(上游的变化会传下来,反之不会)
sudo mount --make-unbindable /mnt/a # unbindable:不能被 bind
# 为什么需要这个机制?
# 场景:容器里挂载了宿主机的 /media,宿主机插了 U 盘自动挂到 /media/usb
# shared -> 容器里也能看到 /media/usb(数据共享场景需要)
# slave -> 容器能看到宿主的挂载,但容器内的挂载不影响宿主(K8s 的默认行为之一)
# private -> 完全隔离(安全,但共享存储场景不方便)
# K8s 的 volume mountPropagation 就是这三种:
# None(private)/ HostToContainer(slave)/ Bidirectional(shared)
5.5 mount namespace:容器的文件系统视图
开篇第五问的答案:每个 mount namespace 有自己的一份挂载树(struct mnt_namespace),所以不同容器能看到完全不同的文件系统视图。
# 每个进程属于某个 mount namespace
ls -l /proc/self/ns/mnt
# lrwxrwxrwx ... /proc/self/ns/mnt -> 'mnt:[4026531840]'
# ^^^^^^^^^^ namespace 的 inode 号
# 对比宿主与容器的挂载树
sudo readlink /proc/1/ns/mnt
sudo readlink /proc/$(docker inspect -f '{{.State.Pid}}' mycontainer)/ns/mnt
# 数字不同 = 不同的 namespace = 不同的挂载树
# 手动创建一个(不需要容器运行时)
sudo unshare --mount --propagation private bash
# 现在在新的 mount namespace 里
mount -t tmpfs tmpfs /mnt # 这个挂载【只在这个 namespace 里可见】
findmnt /mnt
exit
findmnt /mnt # 宿主机上看不到 ✅
# 进入某个容器的挂载视图(15 篇用过同样的技巧看网络)
sudo nsenter -t <PID> -m findmnt
sudo nsenter -t <PID> -m --wd=/ ls /
这是容器「文件系统隔离」的全部秘密:不是什么特殊的沙箱技术,就是每个 namespace 一份挂载树 + chroot 到镜像目录。36 篇会完整展开。
6. 特殊文件系统:抽象的边界
VFS 的抽象力度可以从这些「根本不是文件的东西也能当文件用」来体现:
6.1 procfs:内容是函数生成的
// procfs 的每个文件注册一个 show 函数
// fs/proc/meminfo.c
static int meminfo_proc_show(struct seq_file *m, void *v)
{
struct sysinfo i;
si_meminfo(&i);
seq_printf(m, "MemTotal: %8lu kB\n", ...); // ← 读的瞬间才生成文本
...
}
这解释了 03 篇 3.5 的现象:
ls -l /proc/meminfo
# -r--r--r-- 1 root root 0 Aug 12 14:00 /proc/meminfo
# ^ 大小是 0,因为内容还不存在!
wc -c /proc/meminfo # 但 cat 出来有上千字节
df /proc # 大小 0 —— 不占任何磁盘
# 更有意思的是:有些 proc 文件不支持部分读
dd if=/proc/meminfo bs=10 count=1 2>/dev/null | wc -c
# 结果可能不是 10 —— seq_file 的语义与普通文件不同(18 篇讲的语义泄漏)
6.2 sysfs:内核对象的映射
# sysfs 的每个文件对应一个 kobject 的属性,有 show/store 两个回调
cat /sys/class/net/eth0/mtu # 调用 show 回调
echo 1400 | sudo tee /sys/class/net/eth0/mtu # 调用 store 回调
# ^^ 这就是 15 篇 ip link set mtu 的另一条路径
# sysfs 的组织是「一个属性一个文件」,这是有意的设计(对比 ioctl)
ls /sys/block/sda/queue/
# scheduler rotational nr_requests read_ahead_kb max_sectors_kb ...
# ^^^^^^^^^^ 11 篇用它判断 SSD/机械盘
# 好处:可发现(ls 就知道有哪些参数)、可脚本化、有独立的权限控制
# —— 正是 18 篇 2.3 说的「ioctl 的结构化替代方案」
6.3 tmpfs:page cache 就是全部
# tmpfs 的实现极简:文件数据【直接】就是 page cache 里的页,没有后备存储
findmnt /dev/shm
# /dev/shm tmpfs tmpfs rw,nosuid,nodev
# 所以:
# ✓ 极快(没有磁盘 I/O)
# ✓ 可以被 swap 出去(与 ramdisk 的关键区别)
# ✗ 占的是内存(11 篇 3.4 讲的「往 /tmp 写大文件会吃内存」)
# 常见的 tmpfs
findmnt -t tmpfs -o TARGET,SIZE,USED
# /run 1.6G 1.2M <- 03 篇讲的 pid 文件与 socket
# /dev/shm 7.8G 0 <- POSIX 共享内存
# /tmp 7.8G 12M <- 部分发行版
# /sys/fs/cgroup ...
6.4 overlayfs:容器镜像的分层
# 容器的根文件系统就是一个 overlay 挂载
findmnt -t overlay
# TARGET SOURCE FSTYPE OPTIONS
# / overlay overlay rw,lowerdir=/l1:/l2:/l3,upperdir=/u,workdir=/w
# 手动做一个(理解镜像分层)
mkdir -p /tmp/ov/{lower1,lower2,upper,work,merged}
echo "from lower1" > /tmp/ov/lower1/a.txt
echo "from lower2" > /tmp/ov/lower2/a.txt # 同名文件
echo "only in lower2" > /tmp/ov/lower2/b.txt
sudo mount -t overlay overlay \
-o lowerdir=/tmp/ov/lower1:/tmp/ov/lower2,upperdir=/tmp/ov/upper,workdir=/tmp/ov/work \
/tmp/ov/merged
cat /tmp/ov/merged/a.txt # from lower1 <- 靠前的 lower 优先(镜像层的覆盖语义)
cat /tmp/ov/merged/b.txt # only in lower2
echo "modified" > /tmp/ov/merged/a.txt # 写入 -> 触发 copy-up
ls /tmp/ov/upper/ # a.txt <- ✅ 被复制到 upper 层再修改
cat /tmp/ov/lower1/a.txt # from lower1 <- 下层【未被修改】
rm /tmp/ov/merged/b.txt
ls -l /tmp/ov/upper/b.txt # c--------- ... <- ✅ whiteout:用字符设备标记「已删除」
sudo umount /tmp/ov/merged
这解释了容器的三个特性:镜像层只读且可共享(多个容器共用同一份 lower)、容器内的修改只影响自己的 upper 层(copy-up)、容器删除后 upper 层丢弃即可。
6.5 FUSE:把文件系统实现放到用户态
# FUSE 是 18 篇讲的「宏内核里的微内核思想」
ls -l /dev/fuse
# 工作方式:
# ① 内核的 fuse 模块注册成一个文件系统类型
# ② 用户态守护进程通过 /dev/fuse 与内核通信
# ③ 应用的 read() -> VFS -> fuse 模块 -> 【转发给用户态进程】-> 结果返回
# 常见的 FUSE 文件系统
sshfs user@host:/remote /mnt/remote # 通过 SSH 访问远程目录(16 篇)
rclone mount remote:bucket /mnt/cloud # 对象存储当文件系统
gocryptfs /enc /mnt/plain # 加密目录
s3fs mybucket /mnt/s3
# 代价:每次 I/O 要多两次用户态↔内核态切换 + 数据拷贝
# → 性能远低于内核态文件系统(这正是 18 篇讲的微内核 IPC 开销)
findmnt -t fuse.sshfs
7. 这套设计的代价
回收 18 篇的论点,现在有了具体依据:
| 代价 | 具体表现 | 缓解手段 |
|---|---|---|
| 间接调用开销 | 每次 I/O 至少一次函数指针跳转 + 系统调用陷入 | io_uring 批量提交(32 篇)、DPDK/SPDK 完全旁路 |
| 接口必须够宽 | file_operations 有几十个成员,任何实现只用一小部分 |
未实现的成员填 NULL 或返回 -EINVAL(如管道的 llseek) |
| 语义泄漏 | read() 在文件/管道/socket 上的行为不同(能否 seek、部分读) |
只能靠文档和应用侧适配 |
| 缓存一致性复杂 | page cache、dcache、inode cache 三层缓存要保持一致;mmap 与 read/write 混用要小心 | 大量锁与 RCU;这是内核里最容易出 bug 的地方之一 |
| 元数据操作难以并行 | 同一目录下并发创建文件要竞争父目录的 inode 锁 | 每个文件系统各自优化(xfs 在这方面强于 ext4) |
# 观察 VFS 层的锁竞争(元数据密集负载下)
sudo perf top -e 'lock:*' 2>/dev/null | head
sudo perf record -g -- bash -c 'for i in {1..20000}; do touch /tmp/many/f$i; done'
sudo perf report | head -20
# 能看到 inode_lock、d_lock 这类符号占比
# 对比:同目录并发创建 vs 分散到多目录
mkdir -p /tmp/bench1 /tmp/bench2/{a,b,c,d}
time (for i in $(seq 1 5000); do touch /tmp/bench1/f$i; done)
time (for i in $(seq 1 5000); do touch /tmp/bench2/$((i%4==0?"a":i%4==1?"b":i%4==2?"c":"d"))/f$i 2>/dev/null; done)
# 大量小文件的场景下,「分散到多个目录」通常更快
8. 面试题
Q:VFS 是什么?内核用 C 语言怎么实现「多态」?
VFS(Virtual File System)是内核里的统一抽象层:它定义一套接口,让几十种差异极大的文件系统(ext4 的磁盘布局、tmpfs 的纯内存、procfs 的函数生成内容、overlay 的分层叠加、nfs 的网络访问、fuse 的用户态实现)各自实现,而上层只面向抽象编程。
C 语言没有虚函数,内核用结构体里的函数指针表实现同样效果 —— 本质是手写的 vtable。有四组主要操作表:file_operations(已打开文件的 I/O:read_iter/write_iter/mmap/poll)、inode_operations(命名空间操作:lookup/create/unlink/rename)、super_operations(文件系统整体)、dentry_operations(缓存行为)。
于是 vfs_read() 的核心只是一行分派:file->f_op->read_iter(...) —— 具体跳到 ext4_file_read_iter 还是 pipe_read 还是 sock_read_iter,取决于 open 时挂上的是哪张表。
这个划分本身有信息量:unlink 属于 inode_operations 且参数是父目录的 inode(vfs_unlink(struct inode *dir, ...) 里检查的是 dir 的写权限)—— 这就是 04 篇「删除文件看目录权限」在内核里的直接依据。
Q:struct file、dentry、inode 为什么必须分成三层?
三个场景各自证明一层的必要性:
- 同一文件被打开两次 → 需要两个
file、一个inode。因为读写偏移量(f_pos)必须是 per-open 的,放进 inode 会让两次打开互相干扰。可以用cat /proc/$$/fdinfo/N直接看到各自独立的pos - 硬链接 → 需要两个
dentry、一个inode。因为元数据(权限、大小、时间戳、链接计数)只有一份,必须在共享的那一层;而「名字」和「在哪个目录下」属于 dentry fork()→ 父子共享同一个file。所以它们共享偏移量 —— 这正是{ echo a; echo b; } > f和多个后台进程写同一个重定向目标不会互相覆盖的原因
同理 dup(fd) 得到的两个 fd 指向同一个 struct file(共享偏移量),而重新 open() 得到两个独立的 file。这也是 09 篇 2>&1 的本质:dup2 让 fd 2 指向 fd 1 当时指向的那个 struct file,所以之后 fd 1 改指向别处不影响 fd 2。
Q:fd 到底是什么?为什么 fd 号总是最小可用的整数?
fd 就是进程打开文件表里的数组下标:task_struct → files_struct → fdtable → fd[],fd[3] 存的是指向 struct file 的指针。
由此可以解释四件事:fd 号是最小可用整数(内核在 open_fds 位图里找第一个空位);ulimit -n 限制的是每进程的 fd 数(因为 fdtable 是 per-process 的);线程之间共享 fd(clone(CLONE_FILES) 复用同一个 files_struct);FD_CLOEXEC 的实现是 execve() 时内核遍历 close_on_exec 位图关掉标记过的 fd。
最后这点有实际意义:Go 里所有 fd 默认带 O_CLOEXEC,避免 exec.Command 起的子进程继承一堆无关 fd(既是安全风险也是 fd 泄漏源);需要故意传递时用 cmd.ExtraFiles。
Q:read() 从 fd 到磁盘经过哪些层?
十步:sys_read → fdget_pos(查 fdtable 拿 struct file 并锁住偏移量)→ vfs_read(权限检查)→ file->f_op->read_iter(VFS 分派点) → generic_file_read_iter → 查 page cache(命中就直接返回,不碰磁盘) → 未命中则分配页 + 触发预读 → 文件系统把「文件内偏移」翻译成「磁盘块号」(ext4 走 extent 树)→ submit_bio 提交给块层 → IO 调度器 → 驱动 → DMA → 硬件完成中断 → 唤醒进程 → 拷到用户缓冲区。
这条链解释了前面遇到的几个现象:page cache 命中时极快(在第 6 步就返回);进程等硬件时处于 D 状态(13 篇的不可中断睡眠);O_DIRECT 跳过 page cache 直接走块层(代价是失去缓存与预读);sendfile/splice 在内核内部把 page cache 的页直接交给 socket 实现零拷贝。
可以用 trace-cmd record -p function_graph -g vfs_read 实际看到这条调用链。
Q:dentry cache 解决什么问题?什么是负 dentry?
路径解析是逐段的:open("/var/log/app.log") 要在 / 里查 var、在 var 里查 log、在 log 里查 app.log,每一次查找都要调 inode_operations->lookup() 读磁盘目录。一个 4 级路径最坏是 4~8 次磁盘 I/O,而 open() 极其频繁(编译一次大项目要几十万次)—— 没有缓存系统会被压垮。
dcache 缓存「路径名 → dentry」的映射,是纯内存对象(不对应磁盘上任何结构),算在 SReclaimable 里。
负 dentry 是 d_inode == NULL 的 dentry,缓存「这个名字确定不存在」。为什么需要?因为查找失败也极其频繁 —— strace python3 -c 'import json' 会看到几十上百个 ENOENT(解释器在多个目录里搜模块),同类场景还有动态链接器找 .so、shell 搜 PATH、编译器找头文件。没有它,每次失败的查找都要读一次磁盘。
内核还为路径解析做了两条路径:RCU-walk(完全无锁,靠 RCU + 序号校验,适用于全部命中且无软链接的情况)和 ref-walk(加引用计数与锁,需要读磁盘或遇软链接时回退到它)。做两套的理由是:给每个 dentry 加引用计数会在多核上造成严重的 cache line 争抢,RCU-walk 完全避免了写操作,这是它能线性扩展的关键。
Q:挂载遮盖是怎么实现的?为什么 mount --bind / 能看到被遮盖的内容?
挂载没有动原来的任何东西。它只是在挂载点那个 dentry 上放了一个标记(DCACHE_MOUNTED),路径解析走到这里时会查 mount hash 表,切换到新文件系统的 mnt_root 继续解析。所以原目录的内容再也不会被访问到,但仍然占着原文件系统的空间 —— 这就是 11 篇「df 满了但 du 找不到」的原因之一。
mount --bind / /mnt/rootfs 创建一个新的 struct mount,它的 mnt_root 指向根文件系统的根 dentry。访问 /mnt/rootfs/data 这条路径不会经过 /data 那个「跳板」,所以看到的是原始内容。
顺带说 bind mount 本身的实现:新 struct mount 的 mnt_root 指向已有的 dentry、mnt_sb 与源相同 —— 所以它不是拷贝,而是「同一份 inode 树的第二个入口」(stat -c '%i %d' 两边完全相同)。这也解释了为什么只读 bind mount 需要两步:第一步只建立跳板,MNT_READONLY 标志要在 remount 时才能设到新的 struct mount 上。
Q:容器的文件系统隔离是怎么做到的?
每个 mount namespace 有自己的一份挂载树(struct mnt_namespace)。struct mount 里的 mnt_ns 字段决定它属于哪个 namespace,所以不同容器能看到完全不同的文件系统视图。
这不是什么特殊的沙箱技术,就是「每个 namespace 一份挂载树 + 切换根目录到镜像的 overlay 挂载点」。可以手动验证:unshare --mount bash 后 mount -t tmpfs tmpfs /mnt,这个挂载只在新 namespace 里可见。
配套机制是挂载传播(shared/private/slave/unbindable):宿主机插了 U 盘自动挂到 /media/usb,容器里是否能看到?shared 双向传播、slave 只接收上游变化、private 完全隔离 —— K8s 的 mountPropagation(None/HostToContainer/Bidirectional)就是这三种。
Q:procfs 的文件为什么大小是 0,但能 cat 出内容?
因为 procfs 的文件内容不存在于任何存储上,而是读取的瞬间由内核函数生成的。每个 proc 文件注册一个 show 回调(如 meminfo_proc_show),cat 触发它时才调 si_meminfo() 取当前数据并 seq_printf 成文本。
所以 ls -l /proc/meminfo 显示大小 0(内容还不存在),df /proc 也是 0(不占磁盘)。副作用是有些 proc 文件不支持部分读或 seek,dd bs=10 count=1 的结果可能不是 10 字节 —— 又一个 18 篇讲的「语义泄漏」实例。
同族的 sysfs 是一个属性一个文件(对应 kobject 的 show/store 回调),这个设计是有意针对 ioctl 的缺陷:可发现(ls 就知道有哪些参数)、可脚本化、有独立权限控制。tmpfs 则是「page cache 就是全部」—— 极快、可被 swap 出去,但占的是内存(这就是「往 /tmp 写大文件会吃内存」)。
Q:overlayfs 怎么实现容器镜像的分层?
mount -t overlay -o lowerdir=L1:L2,upperdir=U,workdir=W merged:
- 读:按 lowerdir 顺序查找,靠前的层优先(镜像层的覆盖语义)
- 写:触发 copy-up —— 把文件从 lower 复制到 upper 再修改,下层永不改变
- 删:在 upper 层创建一个字符设备作为 whiteout 标记(
ls -l能看到c---------)
这解释了容器的三个特性:镜像层只读且可被多个容器共享(共用同一份 lower)、容器内的修改只影响自己的 upper、容器删除只需丢弃 upper。
相关的 FUSE 则是 18 篇讲的「宏内核里的微内核思想」:文件系统实现跑在用户态进程里,内核的 fuse 模块把 VFS 请求转发给它(sshfs、rclone mount、gocryptfs)。代价是每次 I/O 多两次用户态↔内核态切换 + 数据拷贝,性能远低于内核态文件系统 —— 正是微内核 IPC 开销的具体体现。
小结
- VFS 用函数指针表在 C 里实现多态:
file_operations/inode_operations/super_operations三张主表,vfs_read的核心只是一行分派 - 三层对象各有不可替代的职责:
file持有 per-open 的偏移量、dentry承载「名字」、inode承载唯一的元数据与数据位置 fork共享struct file(共享偏移量),重新open则独立 —— 这解释了 shell 重定向的行为和2>&1的本质- fd 就是数组下标,由此推出「最小可用整数」、
ulimit -n的作用域、线程共享 fd、FD_CLOEXEC的实现 read()的十步链条解释了 page cache 命中为何极快、D 状态从哪来、O_DIRECT与零拷贝绕过了哪一步- dcache 是路径解析的性能命门,还要缓存「不存在」(负 dentry);RCU-walk 无锁快路径是多核扩展的关键
- 挂载只是在 dentry 上放跳板 → 挂载遮盖、bind mount、mount namespace 全部由此推导
- procfs 内容是函数生成的(所以大小为 0)、overlayfs 靠 copy-up 与 whiteout 实现镜像分层、FUSE 是宏内核里的微内核
- 这套抽象的代价:间接调用开销、接口必须够宽、语义泄漏、三层缓存的一致性复杂度
下一篇讲 进程的诞生:fork 为什么是 fork —— 对比 Windows 的 CreateProcess 和 posix_spawn,COW 是原始设计还是后来的补丁,为什么必须有僵尸进程,以及为什么 Go 程序禁止在 fork 后的子进程里跑 runtime。
xingliuhua