Linux 进程如何与 VFS 绑定 —— 从 task_struct 到文件树的两条全链路
本篇聚焦进程和 VFS 之间的绑定关系——进程描述符通过哪几条指针链接入全局 VFS 挂载树、路径名怎么一步步解析成 inode、fork/exec/exit 各个阶段绑定关系如何变化。 前置知识:task-struct(
task_struct字段全景)、fs-struct(fs_struct各字段深入)、files-struct(files_struct三层模型)。VFS 本身的架构见 vfs-overview(四大对象与多态分发),进程视角的 VFS 交互见 vfs-from-process(五跳指针链)。 本篇不重复讲fs_struct/files_struct/ VFS 对象各自的字段细节——那些是上述三篇的职责。本篇只讲它们之间的连接关系:进程凭什么能open("/a/b.txt")、内核怎么把路径字符串变成 dentry / inode、fork 后父子怎么共享这些绑定。
零、一句话先给结论
每个 task_struct 通过两条指针链绑到全局 VFS 文件树:task->fs(fs_struct)锚定进程在文件系统中的"视角"——根目录在哪、当前目录在哪;task->files(files_struct)管理进程打开的所有文件——每个 fd 是一个数组下标,指向的 struct file 再通过 f_path = {vfsmount, dentry} 锁定 VFS 树上的具体节点。两条链路共同构成"进程能访问文件"的完整基础设施。
一、数据结构全景:两条绑定链路

图析:两个链路分工明确。**链路一(
fs->root/fs->pwd)**是进程在 VFS 树上的"出发点"——决定open("/a")从哪开始解析、open("b")从哪个目录出发。这条链路通过 chdir / chroot / pivot_root 修改,与具体的打开文件无关。**链路二(files->fdtable→ file →f_path)**是进程与具体文件的"连接线"——每次open()创建一条,close()断开。两条链路互不干扰:你可以 chdir 到/tmp,但之前 open 的 fd 对应的文件仍然可读——因为 file 里存的是{mnt, dentry},不依赖 pwd。
两条链路速查
| 链路 | 起 | 经 | 终 | 回答的问题 | 修改接口 |
|---|---|---|---|---|---|
| 链路一 | task->fs | fs_struct.root / fs_struct.pwd | VFS 树上的 dentry 节点 | "路径解析从哪出发?" | chdir / chroot / pivot_root |
| 链路二 | task->files | fdtable[fd] → struct file → f_path | VFS 树上的 dentry + inode | "fd 对应哪个文件?" | open / close / dup |
二、链路一:fs_struct —— 进程在 VFS 挂载树的"立足点"
fs_struct各字段(pwd / root / umask / lock / users)的深入拆解见 fs-struct。这里聚焦它如何把进程锚定到 VFS 挂载树上。
2.1 struct path 的双坐标体系
fs_struct 里最关键的设计决策:pwd 和 root 不是存字符串路径,而是存 struct path = {vfsmount *mnt, dentry *dentry}。
struct path {
struct vfsmount *mnt; // 从哪条 mount 路线过来的
struct dentry *dentry; // 在哪个目录节点上
};这两个成员缺一不可:
dentry:回答"在哪个目录"。dentry 是 VFS 的目录项缓存,内含d_inode(指向 inode)和d_subdirs(子目录哈希表)。知道 dentry,后续路径查找就能直接从它出发。vfsmount:回答"从哪条路过来的"。Linux 的文件树不是一棵天然的树——是通过多次 mount 操作把不同文件系统"嫁接"到一起的。两棵原本独立的 dentry 树通过struct mount链表连接,vfsmount记录了这条嫁接路径。如果只存 dentry 不存 mnt,遇到..返回父目录时就不知道是否该穿越 mount 边界回到上一级文件系统。
为什么不能只存字符串路径:存
"/home/alice/work"意味着每次open("data.txt")都要拼接成"/home/alice/work/data.txt",然后从根开始逐级查找 dentry(5 级哈希查找)。而struct path的 dentry 已经直接指向 work 目录的 dentry 节点——open("data.txt")只需做 1 级查找,省掉 80% 的重复劳动。详见 fs-struct §1.1。
2.2 绝对路径解析:从 root 出发的一级级 dentry 穿越
进程调用 open("/home/alice/work/data.txt"),路径以 "/" 开头,内核从 fs->root.dentry 出发:

图析:绝对路径解析 = 路径分量数 × 单次 dentry 哈希查找。每遇到一个 "/" 分隔的分量("home"、"alice"、"work"、"data.txt"),就在当前 dentry 的
d_subdirs哈希表中查一次。命中 dcache → 纯内存操作(极快);未命中 → 调具体文件系统的lookup()读磁盘。全程不涉及字符串拼接。
穿越 mount 边界:路径解析走到某个 dentry 时,如果 d_flags & DCACHE_MOUNTED 置位(说明这里有子挂载),内核调用 lookup_mnt() 在父 mount 的 mnt_mounts 链表中找到对应的子 mount,切换到子 mount 的 mnt.mnt_root 继续。这就是为什么 /home 可以是独立挂载的分区——路径解析穿越了 sda1 → sda2 的 mount 边界,但用户感知到的仍然是一棵无缝的树。
2.3 相对路径解析:pwd 免去前段重复遍历

图析:
open("data/config.json")在 glibc 层转换成openat(AT_FDCWD, ...)。内核遇到AT_FDCWD(值为 -100),直接读取current->fs->pwd拿到 {mnt, dentry}。这个 dentry 是之前chdir时path_lookup缓存下来的——已经指向 work 目录的 dentry 节点,后续只需从该节点出发向下查两级("data" → "config.json"),完全跳过 "/" → "home" → "alice" → "work" 这 4 级。
2.4 root:绝对路径的解析起点与 chroot 隔离

图析:
chroot("/home/jail")做的事情极其简单——把当前进程fs->root的 dentry 从系统 "/" 换成"/home/jail"的 dentry。此后该进程的所有绝对路径解析都从 jail 的 dentry 出发,看不到 jail 之外的任何文件。全局 VFS 挂载树完全不受影响——其他进程的fs->root仍指向系统 "/",它们眼中的文件树没有变化。chroot 只是进程级的"视觉障碍",不是文件系统的物理切割。
2.5 链路一的观测命令
# 进程当前工作目录
ls -l /proc/<pid>/cwd # 符号链接 → 当前目录
readlink /proc/<pid>/cwd # 解析出字符串路径
# 进程根目录
ls -l /proc/<pid>/root # 符号链接 → 根目录
readlink /proc/<pid>/root # 若 chroot 过,这里指向 jail 目录
# 进程内读取
#include <unistd.h>
char *getcwd(buf, size); # 返回当前工作目录字符串**pwd 可能"不可读" **:如果 pwd 所在的目录已被
rm -rf删除,/proc/<pid>/cwd符号链接的目标变成(deleted),getcwd()可能返回错误。但fs->pwd里的 dentry 仍然有效——因为还有引用计数,dentry 不会被真正释放,进程仍可正常使用相对路径和..。
三、链路二:files_struct —— 进程打开文件与 VFS 节点的绑定
files_struct的三层模型(fd 表 →struct file→ inode)完整拆解见 files-struct。这里聚焦这条链路如何与 VFS 文件树节点建立绑定。
3.1 open() 全过程:从空 fd 到 VFS 节点的完整绑定

图析:
open()做四件事建立链路二的绑定——(1) 路径查找:从fs->root出发,逐级 dentry 哈希查找,最终定位到目标文件的 dentry + inode,并获得对应的 vfsmount(用于处理 mount 边界穿越);(2) 分配struct file:初始化偏移为 0、引用计数为 1,把f_path设为{vfsmount, dentry}——这条路径一旦设好就固化,后续read/write不再依赖fs->pwd或fs->root;(3) 分配 fd:在fdtable中找空闲位,得到一个整数;(4) 绑定:fdtable.fd[fd] = file——此时fd=3这个整数和 VFS 文件树上的a.txt节点通过struct file牢牢绑定。
3.2 read() 如何沿链路二到达 VFS 节点
用户调 read(fd, buf, len) 时内核的路径:
| 步骤 | 操作 | 涉及结构 |
|---|---|---|
| ① | fdget(fd) → 从 current->files->fdt->fd[fd] 取出 struct file * | files_struct → fdtable |
| ② | 检查 file->f_mode & FMODE_READ,不通过返回 -EBADF | struct file |
| ③ | file->f_op->read(file, buf, len, &file->f_pos) | file_operations vtable 分发 |
| ④ | 具体实现读取数据(ext4 走 page cache、pipe 走环形缓冲区、socket 走 tcp_recvmsg) | 具体文件系统 |
| ⑤ | 返回读取字节数 | — |
关键点:
read()不需要路径查找——struct file.f_path里已经锁定了 {vfsmount, dentry},内核直接拿着 inode 读数据。读完后f_pos自动前移,下一次read从新位置接着读。不管之后进程 chdir 去了哪里、甚至源文件所在的目录被删了,只要 file 没 close,read照用不误。
3.3 close():解绑与引用计数

图析:
close(fd)分两层。fd 层:fdtable.fd[fd] = NULL,进程立即失去通过该 fd 访问文件的能力。VFS 层:fput(file)递减f_count,只有归零才触发真正的 release。fork 后父子共享struct file(f_count=2),一方 close 后另一方仍可通过自己的 fd 正常读写——这是"引用计数"给进程的假象保护。
3.4 链路二的观测命令
# 查看进程当前开了多少个 fd
ls /proc/<pid>/fd | wc -l
# 查看每个 fd 指向什么文件
ls -l /proc/<pid>/fd/
# 输出示例: 3 -> /tmp/a.txt
# 4 -> socket:[12345]
# 5 -> pipe:[67890]
# 用 lsof 看详情(含偏移量、文件大小)
lsof -p <pid>
# strace 抓 open/close
strace -e open,openat,close -p <pid>四、进程生命周期中的 VFS 绑定变化
4.1 fork / clone:继承与引用计数

图析:fork 对两条链路的行为不同。链路一(fs_struct):父子初始时共享同一
fs_struct(users=2),触发写时复制——子进程第一次执行chdir/chroot时才拷贝一份独立的。链路二(files_struct):files_struct立即复制出新实例,但fdtable里存的file *指针指向的还是父进程那批struct file——所有共享的 file 的f_count递增。这意味着父子共享读写偏移,一个进程read()移动了f_pos,另一个的read()接着往下读。
4.2 共享语义对照表
| 场景 | fs_struct | files_struct | struct file(f_pos) | close 行为 |
|---|---|---|---|---|
| fork | 先共享(CoW),子首次 chdir 时复制 | 复制 fd 表,指针指向同一批 file | 共享 file,共享偏移 | 各自 close 只减 f_count,全关才 release |
线程(CLONE_FILES | CLONE_FS) | 共享同一份 | 共享同一份 | 同一份 | 一个线程 close → 全线程丢失该 fd |
| clone 无 CLONE_FS | 复制(同 fork) | — | — | — |
| clone 无 CLONE_FILES | — | 复制(同 fork) | — | — |
| execve | 保留 | 保留(除了 FD_CLOEXEC 标记的 fd) | 保留 | — |
| 进程 exit | put_fs_struct() 减引用,归零释放 | 遍历 fdt,逐个 fput | f_count 归零 → release → 解绑 inode | 自动 close 全部 |
多线程 chdir 是大忌:线程共享
fs_struct,一个线程 chdir → 所有线程的 pwd 同时变化。线程 A 正在读./data/file,线程 B 做了chdir("/tmp")→ 线程 A 的相对路径解析立即出错。解法:用openat(dirfd, ...)锁定目录 fd,不依赖隐式 pwd。
4.3 进程退出时的解绑全过程
进程调用 exit() 或收到致命信号后,内核执行以下与 VFS 解绑相关的步骤:
- 关闭所有打开文件:遍历
files_struct->fdt,对每个非空 fd 调filp_close()→fput()。每个struct file的f_count递减。 - 触发 release:
f_count归零的 file 调file->f_op->release(inode, file)。对普通文件:inode 引用计数减 1;对 socket:触发四次挥手;对 pipe:唤醒阻塞的 reader/writer。 - 释放 files_struct:所有 fd 关闭完毕,释放
files_struct和fdtable。 - 释放 fs_struct:
put_fs_struct()递减引用计数,归零后释放 pwd/root 的 dentry 引用,最后释放fs_struct本身。
全局 VFS 挂载树不受单个进程退出影响——dentry 和 inode 有独立的引用计数系统,只在该节点不再被任何进程引用时才会被回收。
五、容器隔离:chroot / pivot_root / mount namespace
两条链路也是容器文件系统隔离的基础:
| 操作 | 改什么 | 影响范围 | 安全强度 | 典型场景 |
|---|---|---|---|---|
chdir | fs->pwd | 共享 fs_struct 的所有线程 | 无 | 日常 cd |
chroot | fs->root | 当前进程 | 弱——root 可逃逸 | 传统 FTP 服务器 jail |
pivot_root | 整个 mount namespace 的 "/" | 同 namespace 所有进程 | 强——但需 mnt ns 配合 | 容器 init 进程 |
| Docker/K8s 容器 | pivot_root + mount ns + user ns + cgroup | 容器内所有进程 | 最强——多层隔离 | 生产容器 |

图析:容器隔离的本质是给两条链路换上不同的值——
pivot_root把fs->root指向容器专属的文件树根(overlayfs),mount namespace让容器内的 mount 操作不影响宿主机。但注意链路二(files_struct)不受 namespace 约束——如果容器启动前宿主机 open 了一个 fd 再传给容器,容器内的进程仍然可以通过这个 fd 访问宿主机文件。
六、延伸阅读
| 文档 | 关联点 | 说明 |
|---|---|---|
| task-struct | task_struct 全体字段 | fs / files 在进程描述符中的位置和初始化 |
| fs-struct | fs_struct 各字段深入 | pwd path 类型、dentry/inode 关系、mount 嫁接机制 |
| files-struct | files_struct 三层模型 | fd → file → inode,dup/fork 共享语义 |
| mm-struct | 地址空间 | 和 file 的 mmap 绑定(mm_struct → vm_area_struct → file) |
| vfs-overview | VFS 四大对象 | super_block / inode / dentry / file 的全景 |
| vfs-from-process | 进程视角的 VFS | 五跳指针链:task_struct → files_struct → fdtable → file → f_op |
| fork-and-threads | 多线程 fork 陷坑 | 子进程只剩一个线程,但锁、fd 等共享状态全部继承 |
| process-creation | fork/clone 全流程 | CoW、fd 表复制、mm_struct 共享 |
| overview | 容器隔离全景 | namespace / cgroup / pivot_root / overlayfs |
七、一句话总结
task_struct通过两条指针链绑定到全局 VFS 文件树——链路一(task->fs)通过fs_struct.root和fs_struct.pwd锚定进程在文件系统中的"视角",决定绝对路径和相对路径从哪个 dentry 出发解析(存struct path而非字符串,免去重复遍历);链路二(task->files)通过fdtable[fd]→struct file→f_path = {mnt, dentry}把每个 fd 锁定到 VFS 树上的具体文件节点,open()时建立绑定、close()靠f_count引用计数推迟释放直到所有持有者松手。fork 时 fs_struct 写时复制、files_struct fd 表复制但共享底层struct file(偏移共享);chroot 只改fs->root不改全局 VFS 树;容器通过 pivot_root + mount namespace 给这两个链路换上隔离的值。