fs_struct —— 进程的文件系统上下文
这是 task-struct 里
task->fs那一项的展开。fs_struct是进程看到文件系统视图的"立足点"——当前目录在哪、根目录在哪、创建文件时的默认权限掩码。chdir/chroot/umask 改的都是它。
零、一句话认知:fs_struct = "我在文件系统里的位置"
每次进程用相对路径(open("data/config.json"))访问文件,内核必须知道"从哪个目录出发解析"。fs_struct 就是这个出发点。

fs_struct 核心数据结构(各字段深入见下文 §一~§三):
struct fs_struct {
int users; // 引用计数:多少个 task 共用此 fs_struct
spinlock_t lock; // 自旋锁:保护并发修改(如多线程 chdir/chroot)
umode_t umask; // 文件创建权限掩码:实际权限 = mode & ~umask,详见 §三
struct path pwd; // 当前工作目录({vfsmount*, dentry*}),相对路径解析起点,详见 §一
struct path root; // 进程根目录,chroot 把 "/" 重新锚定到此子树,详见 §二
};其中 struct path 内部结构为 {vfsmount *mnt; dentry *dentry;}(见 §一 1.1)。五个字段分工:users 控制生命周期、lock 保证并发安全、umask 管新建权限、pwd 和 root 分别锚定进程在文件系统中的"当前位置"和"根边界"。
一、pwd —— 当前工作目录
1.1 pwd 的类型:struct path
pwd 不是字符串路径,而是 struct path —— 一对指针精确锁定文件系统中的某个目录:
struct path {
struct vfsmount *mnt; // 指向哪个挂载实例(同一文件系统可 mount 到多个位置)
struct dentry *dentry; // 指向哪个目录项(已缓存的目录结点,含 inode、名称、父子关系)
};path → dentry → inode:VFS 的三层命名体系
在分析 struct path 的两个成员之前,需要先搞清三个概念的层次关系——它们是整个 VFS 路径解析的基石:
| 层 | 存什么 | 生命周期 | 类比 |
|---|---|---|---|
| path 字符串 | 用户态路径("/home/alice/work") | 栈上临时变量,open() 返回后立即释放 | 问路的问题 |
| dentry | 路径分量到 inode 的映射缓存 | 内存缓存(dcache),压力下可回收,下次访问时重建 | 记住的路 |
| inode | 文件元数据(权限、大小、磁盘块位置) | 持久存在(磁盘有对应记录),inode cache 中常驻 | 到达的目的地 |
// ── dentry:namespace 层,一个路径分量 → 一个 dentry 对象 ──
struct dentry {
struct qstr d_name; // 分量名称("home"),含预计算的哈希值
struct inode *d_inode; // → 指向对应的 inode(文件本体)
struct dentry *d_parent; // → 父目录 dentry(向上回溯)
struct hlist_bl_node d_hash; // 挂在父 dentry 的 d_subdirs 哈希链中
struct list_head d_child; // 挂在父 dentry 子目录链表中的兄弟节点
struct list_head d_subdirs; // 本 dentry 下所有子目录的哈希表头(查找子项的入口)
};
// ── inode:数据层,一个磁盘文件/目录 → 一个 inode 对象 ──
struct inode {
umode_t i_mode; // 文件类型(S_IFREG/S_IFDIR/...)+ 权限位(rwx)
kuid_t i_uid; // 所有者 UID
kgid_t i_gid; // 所有者 GID
loff_t i_size; // 文件大小(字节)
unsigned long i_ino; // inode 编号(ls -i 看到的值,文件系统内唯一)
struct super_block *i_sb; // 所属超级块(标识 ext4 / xfs / tmpfs ……)
};两者的树状组织——以 "/home/alice" 为例:

三层分工的关键差异:
- path → dentry 转换只做一次:
chdir("/home/alice")触发path_lookup(),把字符串 "home" → "alice" 逐分量转成 dentry 链并缓存;后续open("data.txt")直接从 pwd dentry 出发,不再碰路径字符串。 - dentry ≠ inode,且非一对一:一个 inode 可以有多个 dentry 指向它——最典型的是硬链接:
/home/a/file和/backup/file是两个 dentry,但d_inode指向同一个 inode(i_ino相同)。反过来,一个 dentry 也可能没有 inode(d_inode == NULL),即 negative dentry——用于缓存"这个文件不存在"的查询结果,避免反复穿透磁盘。 - dentry 可回收,inode 是常驻的:dcache 是一个内存缓存层,
shrink_dcache()在内存紧张时回收长期未用的 dentry。但 inode 只要文件未被删除 + 无进程持有引用,就保留在 inode cache 中。dentry 回收后再次访问时,由父 dentry +d_name重新通过文件系统的lookup()创建。
一句话:path 是问路,dentry 是记路,inode 是到站。 VFS 路径解析 = 把 path 拆成分量 → 沿 dentry 的
d_subdirs哈希表逐级查找 → 最终落到 inode。
两个成员缺一不可,各自回答不同的问题:
vfsmount *mnt:对用户来说,文件系统确实只有从 "/" 出发的一棵树。但在内核内部,这棵树是通过多次 mount 操作拼装出来的——每次 mount 把一个独立的文件系统实例(ext4 分区、tmpfs、procfs……)"嫁接"到树上的某个节点。vfsmount记录了这个嫁接关系:谁被接到了哪个位置。以系统启动时挂载
/home分区为例:bashmount /dev/sda2 /home # 把 sda2 分区挂到根文件系统的 /home 目录上根文件系统上
/home原来只是一个空 dentry,执行mount /dev/sda2 /home后,内核的工作是把两棵原本独立的 dentry 树"焊接"成一棵。mount 前的状态——两棵独立的 dentry 树:
┌──── 根文件系统 (sda1) ────┐ ┌─── sda2 文件系统 ───┐
│ "/" dentry │ │ "/" dentry │
│ / | \ │ │ / \ │
│ "bin" "home" "etc" … │ │ "alice" "bob" … │
│ | │ │ | │
│ (空目录, 无子dentry) │ │ "work" "docs" … │
└────────────────────────────┘ └───────────────────────┘两棵树各自独立——sda1 的 home dentry 下面没东西,sda2 有自己的 dentry 整树。mount 的任务是让 sda2 的根取代 sda1 的 home 在命名空间中的"下半身"。
关键数据结构——struct mount 如何记录嫁接关系:
// struct mount 是内核内部表示(struct vfsmount 是其内嵌成员,对外暴露的公共接口)
struct mount {
struct mount *mnt_parent; // 父 mount:我被挂在了谁的树上
struct dentry *mnt_mountpoint; // 接合点:父文件系统中哪个 dentry 上接了我
struct list_head mnt_child; // 挂在父 mount 的子挂载链表中(兄弟 mount 的链表节点)
struct list_head mnt_mounts; // 我上面的子挂载链表头(别人接在我身上)
struct vfsmount mnt; // 对外可见的 vfsmount(内含 mnt_root、mnt_sb 等字段)
};四组字段的分工:
| 字段 | 回答的问题 | 本例中的值 |
|---|---|---|
mnt_mountpoint | 接在父树的哪个 dentry 上? | sda1 的 home dentry |
mnt.mnt_root | 我的树根在哪? | sda2 的 "/" dentry |
mnt_parent | 我挂在谁身上? | sda1 对应的 struct mount |
mnt_child / mnt_mounts | 父子 mount 链表 | 把 sda2_mount 串入 sda1_mount 的子链表 |
此外,dentry 上还有一个关键标志位 DCACHE_MOUNTED:dentry 被用作 mountpoint 时置位。路径遍历时内核看到这个标志,就知道这里不是普通子目录,而是 mount 边界——后面跟着另一棵 dentry 树。
mount 过程——时序图:

mount 后的结果——组件图(两棵树通过 struct mount 焊接):

路径遍历如何穿越 mount 边界——时序图:

三个要点总结:
- mount 不修改 dentry 树:
homedentry 的d_subdirs始终只有 sda1 原有的子目录。sda1 和 sda2 的 dentry 树各自独立,连接靠额外的struct mount链表,不靠 dentry 父子指针。 - 穿越由
DCACHE_MOUNTED+lookup_mnt()完成:路径解析走到 dentry 时先检查标志位,有挂载就沿mnt_mounts链表找到struct mount,跳到其mnt.mnt_root继续。跳转由 mount 层控制,dentry 无感知。 - 这就是
struct path必须包含mnt的根因:路径解析结果用{mnt, dentry}表示。data.txt 的 dentry 属于 sda2 的树,但它被解析到的前提是穿过了 sda1→sda2 的 mount 边界。后续任何操作(如..返回父目录)都需要知道"当前在哪个 mnt 下",否则无法正确穿越回 sda1 的home。
dentry *dentry:我在哪个目录结点?dentry 是 VFS 的目录项缓存对象,包含指向inode的指针、目录名(如"work")、父目录 dentry 和子目录链表。它把路径查找的结果缓存下来——知道 dentry 就知道这个目录对应的文件实体是什么、父目录是谁、下面有哪些子目录。
一句话:
struct path = {vfsmount, dentry}等价于"文件系统命名空间中的唯一坐标"——dentry 回答"哪个文件",mnt回答"从哪条路过来的"。
内核如何从 "/" 逐级查找文件——dentry 的链式遍历
有了上面对 dentry/inode 结构的理解,现在看内核如何解析一个绝对路径。以 open("/home/alice/work/data.txt") 为例:路径以 "/" 开头,内核从 fs->root 出发,每遇到一个路径分量就做一次 dentry 子树哈希查找,一级一级往下走:

这个过程的三个关键事实:
- "/" 就是
fs->root.dentry:绝对路径的第一个 "/" 告诉 VFS "从进程根目录 dentry 出发"。正常进程的fs->root指向系统 "/" 的 dentry;chroot后指向 jail 目录的 dentry——根节点换了,后续所有绝对路径解析的基准跟着换,这就是 chroot 隔离文件系统的本质。 - dentry 是路径查找的最小粒度:每个路径分量("home"、"alice"……)都在其父 dentry 的哈希表
d_subdirs中查找对应子 dentry。命中 dcache → 直接拿到(纯内存);未命中 → 调具体文件系统的lookup()从磁盘读出并新建 dentry 入缓存。各 dentry 通过d_parent/d_child指针串联成树状结构。 - 绝对路径的查找成本 = 分量数 × 单次 dentry 哈希查找:即使全部命中缓存,
open("/home/alice/work/data.txt")也要做 5 次哈希查找。如果进程反复 open 同一深路径下的不同文件,前 N-1 级的查找完全是重复劳动。
为什么 pwd 不存字符串? 如果 pwd 存的是字符串 "/home/alice/work",那每次 open("data.txt") 先要拼接成 "/home/alice/work/data.txt",然后从 fs->root 出发重复上述 5 级 dentry 遍历——每次 I/O 都白花一遍路径解析的开销。而 struct path 的 pwd = {mnt, dentry_of_work} 直接跳到第 4 级,open("data.txt") 只需做 1 次查找:5 级 → 1 级,省掉 80% 的无用功。
存 struct path 的好处是 一次性解析,永久复用:chdir 时做一次 path_lookup,把结果(dentry + vfsmount)缓存到 fs->pwd;此后所有相对路径操作直接从已缓存的 dentry 出发向下查找,无需反复解析前段路径。下面的时序图展示了这个过程:

1.2 相对路径解析全过程
用户代码调用 open("data/config.json", O_RDONLY),glibc 在进入内核前做一层转换:相对路径 → openat(AT_FDCWD, ...)。这是 Linux 2.6.16 引入的 *at 系统调用族(openat / fstatat / unlinkat / renameat……),统一用 dirfd 参数指定"从哪个目录出发解析",彻底取代了老式 open() 内核里硬编码 current->fs->pwd 的做法。时序如下:

流程拆解:
- 用户态 glibc 转换:
open("data/config.json")在 glibc 内部被重写为openat(AT_FDCWD, "data/config.json")。glibc 不碰路径字符串本身,只是把"用哪个起点"的信息从隐式(当前目录)变成显式(AT_FDCWD参数)。 - 内核获取 pwd:
openat系统调用进入 VFS 层,遇到dirfd == AT_FDCWD(即-100),内核读current->fs->pwd,拿到{mnt, dentry}。这个 dentry 就是 §1.1 中chdir时缓存下来的 work 目录项——省掉了从 "/" 到 work 的 4 级 dentry 遍历(回顾上文绝对路径解析的 5 级成本)。 - 逐级解析残余分量:以 pwd.dentry 为起点,对 "data" / "config.json" 各做一次 dentry 哈希查找(2 级),找到目标文件的 inode,权限检查后返回 file 对象和 fd。
AT_FDCWD = -100(定义在<fcntl.h>):是发给内核的信号——"用当前工作目录作为起点"。如果传入真实 fd(如先open("备份目录", O_RDONLY)拿到dirfd=3,再openat(3, "file", ...)),路径解析就从那个具体的目录 fd 出发。*at系统调用族的真正价值在于:用 dirfd 替代隐式 pwd,避免多线程 chdir 竞态(一个线程拿着 dirfd 永远不受其他线程 chdir 影响),这正是 §四要讲的多线程共享陷阱的解法。
1.3 如何观测 pwd
ls -l /proc/<pid>/cwd # 符号链接, 指向当前目录
readlink /proc/<pid>/cwd # 人类可读的路径
# 启动时改 cwd
cd /tmp && ./myapp & # 进程的 cwd = /tmp
# 进程内读取
getcwd(buf, size); # glibc → getcwd 系统调用**pwd 不一定能"读出来" **:如果 pwd 所在的目录已被删除(
rm -rf),getcwd可能返回(unreachable)或被删除的路径。但struct path里的dentry仍然有效——因为还有引用计数,dentry 不会被真正释放。
二、root —— 进程的根目录
2.1 正常情况
对于普通进程,fs->root 和系统的 "/" 一样。open("/etc/hosts") 就是从 fs->root 出发解析 /etc/hosts。
2.2 chroot —— 把进程关在一个子目录里

# chroot 操作
mkdir -p /home/jail/{bin,lib,etc}
cp /bin/bash /home/jail/bin/
# 把依赖库也拷进去...
chroot /home/jail /bin/bash # 现在 / = /home/jail2.3 chroot 的安全局限
| 可以隔离 | 不能隔离 |
|---|---|
| 文件系统视图 | 网络(同一个 IP 端口) |
/proc 的部分内容 | 进程列表(同一个 pid namespace) |
| 用户看到的 "/" | 真正的 root 用户仍能逃逸(有 CAP_SYS_CHROOT 就能 chroot 出去) |
这也是为什么容器不用 chroot 而用 pivot_root + mount namespace:前者有逃逸风险,后者结合 user namespace 即使容器内是 root 也逃不出去。
2.4 观测 root
ls -l /proc/<pid>/root # 符号链接, 指向进程的根目录
readlink /proc/<pid>/root # 系统上真实路径三、umask —— 新建文件的默认权限掩码
3.1 权限计算公式
实际权限 = 请求权限 & ~umask
3.2 umask 的进程继承
- shell 的 umask 命令 → 调
umask()系统调用 → 改当前 shell 的fs->umask - fork 出的子进程继承父的 umask
- exec 后保持 umask(不变)
- 线程共享同一个 fs_struct → 同进程的线程 umask 一致
Dockerfile 的坑:
RUN umask 0027只影响当前 RUN 层;建议用ENV或在 ENTRYPOINT 脚本里设。
3.3 程序内设置 umask
#include <sys/stat.h>
mode_t old = umask(0027); // 设新值, 返回旧值
// 此后 open/create 的文件权限 = mode & ~0027
umask(old); // 恢复四、共享语义 —— 什么时候共 fs_struct

| 场景 | fs_struct | 影响 |
|---|---|---|
| 同进程线程 | 共享(CLONE_FS) | 一个线程 chdir → 所有线程 cwd 变化(多线程里别 chdir) |
| fork 子进程 | 复制新 fs_struct | 各自独立,改不干扰 |
| clone(无 CLONE_FS) | 复制新 fs_struct | 同 fork |
| execve | 保留 fs_struct | cwd/root 不变 |
| 内核线程 | fs = NULL(无 cwd/root) | 访问文件需绝对路径,或借用其他 task 的 fs(set_fs(KERNEL_DS) 旧接口已废弃) |
多线程里 chdir 是大忌:线程 A 正在读
./data/file,线程 B 做chdir("/tmp")→ 线程 A 的./data/file解析失败或误读/tmp/data/file。如果需要:用openat(dirfd, ...)锁定目录 fd,或保证只有一个线程改 cwd。
五、和 chroot/pivot_root/容器的关系
| 操作 | 改什么 | 谁受影响 | 安全强度 |
|---|---|---|---|
chdir | fs->pwd | 同线程组共享 fs 的所有线程 | 无安全含义 |
chroot | fs->root | 单进程 | 弱——root 可逃逸 |
pivot_root | 整个 mount namespace 的 "/" | 同 mnt namespace 的所有进程 | 强——需要 mount namespace 配合 |
| 容器(Docker) | pivot_root + mount ns + user ns | 容器内所有进程 | 强——多隔离层组合 |
六、观测
# 当前工作目录
ls -l /proc/<pid>/cwd
readlink /proc/<pid>/cwd
# 根目录
ls -l /proc/<pid>/root
readlink /proc/<pid>/root
# umask — 无法直接观测当前值(/proc 不导出)
# 间接方法:看进程新建文件的权限, 反推 umask
cat /proc/<pid>/status | grep Umask # 不!这个不存在
# 近似办法:看子进程创建的文件
strace -f -e openat,creat -p <pid> 2>&1 | grep O_CREAT
/proc/<pid>/cwd是链接到真实目录的符号链接;/proc/<pid>/root也是——如果进程被 chroot 了,/proc/<pid>/root指向 jail 目录,你可以用ls -l /proc/<pid>/root/查看"进程眼里的 /"。
七、一句话总结
fs_struct是进程在文件系统里的"立足点"——存着当前目录 pwd(相对路径解析的起点,存 dentry 而非字符串,即使目录被删也能工作)、根目录 root(chroot 把它设成子目录、容器用 pivot_root 换掉它)、umask(新建文件默认去掉的权限位)、引用计数。同进程线程共享 fs_struct(一个 chdir 全员变,多线程大忌),fork 复制。/proc/<pid>/cwd/root可观测。