路径不是字符串查表:mount namespace 和 pathname walk
上一篇建立了静态对象图:
struct path = mount + dentry
dentry -> inode -> superblock
现在追动态过程:openat(dirfd, "a/b", ...) 怎样得到最后的 struct path?
最容易产生误解的模型是:
"/a/b/c" -> 全局哈希表 -> inode
更准确的第一层模型是:pathname 是一串要在当前进程上下文中执行的路径分量,不是对象的全局名字。
pathname
+ 解析起点
+ 进程可见根
+ mount namespace
+ 凭据与解析规则
│
▼
逐分量执行
│
├─ 成功:得到 struct path
└─ 失败:ENOENT / ENOTDIR / EACCES / ELOOP / EXDEV ...
本文先完整手推一条具体路径,再从这次查找中提炼状态和转换规则;逻辑流程讲完后,才把每一步映射到 Linux v6.12.65 的 nameidata、link_path_walk() 和 step_into()。
一、路径解析器的输入不只是一段字符串
一次 pathname walk 至少依赖六类输入:
| 输入 | 回答的问题 |
|---|---|
| pathname | 接下来要执行哪些名字、.、.. 和 /? |
| 起点 path | 第一个相对分量从哪里解释? |
| 可见根 path | 绝对路径从哪里开始,普通 .. 最多退到哪里? |
| mount namespace | 哪些挂载覆盖关系对本次解析可见? |
| 当前凭据 | 是否有权搜索沿途目录? |
| lookup/open 规则 | 是否跟随 symlink、允许跨 mount、允许创建最后分量? |
前三项很容易混在一起。它们实际上是三个不同问题:
- 起点决定相对路径的第一步;
- 可见根决定绝对路径和普通
..的边界; - mount namespace决定走到某个挂载点后会切换到哪个 mount。
因此,相同 pathname 不保证得到相同对象:
进程 A:cwd=/srv/A,open("config") -> /srv/A/config
进程 B:cwd=/srv/B,open("config") -> /srv/B/config
进程 C:/proc -> C 的 procfs mount
进程 D:/proc -> D 的另一个 procfs mount,甚至不可见
pathname 只有放进一次具体解析中才有意义。
1.1 起点怎样选择
普通规则可以压缩成三行:
pathname 以 / 开头 -> 从进程可见 root 开始
相对 pathname,dirfd == AT_FDCWD -> 从 cwd 开始
相对 pathname,dirfd 是目录 fd -> 从该 fd 的 path 开始
cwd 是 current working directory,即当前工作目录。程序启动时通常继承父进程的 cwd,之后可以用 chdir() 或 fchdir() 修改。shell 提示符或 pwd 可能把它显示成 /home/alice,但内核保存的 cwd 不是这段字符串,而是进程 fs_struct 中的完整 path:
current->fs->pwd
├─ mnt cwd 所在的 mount
└─ dentry cwd 对应的目录 dentry
例如 cwd 显示为 /home/alice 时:
open("report.txt", ...)
初始 current = cwd 的 (mount, dentry)
第一个分量 = report.txt
实际查找 = 在 cwd dentry 下查 "report.txt"
相对 pathname 自身不记录 /home/alice;更换 cwd 后,同一个 open("report.txt") 就可能得到另一个文件。显式传入目录 fd 时,起点改为该 fd 的 path,cwd 不再参与起点选择。
“dirfd 是目录 fd”指 openat()/openat2() 这类调用。程序先打开一个目录,得到指向该目录的 fd,再传入一条不以 / 开头的 pathname:
int dirfd = open("/srv/app", O_RDONLY | O_DIRECTORY);
int fd = openat(dirfd, "config/settings.json", O_RDONLY);
第二次调用的 pathname 是相对路径 config/settings.json,但它不是相对 cwd 解释,而是相对 dirfd 指向的 /srv/app 目录解释:
起点 current = dirfd 对应的 path,也就是 /srv/app
处理 config = 在 /srv/app 的 dentry 下查 "config"
处理最终名字 = 在 /srv/app/config 下查 "settings.json"
逻辑结果 = /srv/app/config/settings.json
内核不需要先把两段字符串拼成 /srv/app/config/settings.json。目录 fd 对应的 struct file 已保存 f_path = mount + dentry,解析器直接用这个 path 作为 current 的初值。
如果传入的是绝对 pathname,普通 openat() 会忽略 dirfd:
openat(dirfd, "/etc/hosts", O_RDONLY); // 仍从进程 root 开始
因此这条规则专指“显式目录 fd + 相对 pathname”。它常用于让一组文件操作稳定地锚定在某个已打开目录,而不依赖随后可能变化的 cwd;但和 cwd 一样,普通 dirfd 只提供起点,.. 和 symlink 仍可能离开它。
root 与 cwd 都来自进程的 fs_struct,并且都是完整的 mount + dentry。这里的 root 不一定是宿主机所见的 /;chroot、mount namespace 和容器都可能改变它。
dirfd 只选择相对解析的起点,默认不会自动成为边界。下面两种写法都可能离开 dirfd 指向的目录:
openat(dirfd, "../outside.txt", ...)
openat(dirfd, "link-out", ...) // link-out -> ../outside.txt
如果 dirfd 还要充当安全边界,需要在同一次内核路径解析中加入 openat2() 的约束,后文再展开。
二、完整手推 /mnt/vfs-lab/home/alice/report.txt
先固定一次具体调用:
open("/mnt/vfs-lab/home/alice/report.txt", O_RDONLY);
假设:
- 进程可见 root 是父文件系统的 root path;
- ext4 已挂载到父文件系统的
/mnt/vfs-lab; - ext4 根目录中有
home/alice/report.txt; - 沿途目录权限允许搜索,没有 symlink 和嵌套 mount;
report.txt是已存在的普通文件,inode number 为14。
查找开始前已经存在两组关系:
父文件系统
root dentry
└─ mnt dentry
└─ vfs-lab dentry 挂载点,仍属于父文件系统
mount namespace
(parent mount, vfs-lab dentry)
-> ext4 mount, ext4 root dentry / inode #2
ext4 文件系统
root dentry / inode #2
└─ home dentry / inode #12
└─ alice dentry / inode #13
└─ report.txt dentry / inode #14
这不是一棵单独的目录树:父文件系统和 ext4 各自提供目录关系,mount namespace 再把前者的 vfs-lab 接到后者的 root。
2.1 先拆分输入,但不拼“规范化绝对路径”
解析器识别出五个名字分量:
mnt | vfs-lab | home | alice | report.txt
因为 pathname 以 / 开头,初始位置是进程可见 root。report.txt 是最后分量,要结合 O_RDONLY 单独处理;其余四个是中间分量。
current = (parent mount, parent root dentry)
remaining = [mnt, vfs-lab, home, alice]
last = report.txt
intent = 打开一个已存在对象用于只读
这里的 remaining 数组只是讲解模型,内核不必真的先分配一个字符串数组。关键是:每次只在 current 表示的目录中解释下一个分量。
2.2 处理 mnt:在父文件系统 root 中查名字
处理前:
current = (parent mount, parent root dentry)
next = mnt
解析器依次回答:
current.dentry对应的 inode 是不是目录?- 当前凭据能不能搜索这个目录?
- 这个目录下的名字
mnt对应哪个 child dentry?
第三问先查 dcache:
(parent root dentry, "mnt") -> mnt dentry
若没有可用缓存,才让父文件系统用 root inode 的目录实现查找 mnt。两条路径最终都得到同一个逻辑结果:父文件系统中的 mnt dentry。
先把它与原 mount 组成候选位置:
candidate = (parent mount, mnt dentry)
当前 namespace 没有在这里覆盖 child mount,也不需要展开 symlink,于是提交候选位置:
current = (parent mount, mnt dentry)
remaining = [vfs-lab, home, alice]
下一次查找因此发生在 /mnt 目录中,而不是再次从 root 搜完整字符串。
2.3 处理 vfs-lab:先在父文件系统查名,再跨 mount
处理前:
current = (parent mount, mnt dentry)
next = vfs-lab
解析器仍先在当前目录中查普通名字:
(mnt dentry, "vfs-lab") -> vfs-lab dentry
此时只完成了目录命名查找,候选位置仍属于父文件系统:
candidate = (parent mount, vfs-lab dentry)
然后解析器拿完整候选 path 查询当前 mount namespace。已登记的覆盖关系命中:
(parent mount, vfs-lab dentry)
-> (ext4 mount, ext4 root dentry)
所以真正提交的新位置不是父文件系统的 vfs-lab dentry,而是 ext4 root:
current = (ext4 mount, ext4 root dentry / inode #2)
remaining = [home, alice]
这一刻最容易误解:
- 字符串分量
vfs-lab已经消耗; - 新
current.dentry在 ext4 内部却是它的/,名字不是vfs-lab; - 下一分量
home必须去 ext4 root 下面查; - ext4 的目录数据中不需要存在名为
vfs-lab的目录项。
路径解析器推进的是 mount + dentry,不是把已处理字符串当作后续查找的数据库 key。
2.4 处理 home:查找已经发生在 ext4 中
处理前:
current = (ext4 mount, ext4 root dentry / inode #2)
next = home
现在 dcache key 是:
(ext4 root dentry, "home")
若缓存未命中,VFS 调用的是 ext4 root inode 的目录 lookup。ext4 解析自己的目录数据,得到:
"home" -> home dentry -> inode #12
组成候选 path 并检查当前 namespace 后,没有发生新的 mount crossing:
candidate = (ext4 mount, home dentry)
current = candidate
remaining = [alice]
无论复用 dcache,还是进入 ext4 的目录 lookup,下一状态都相同;区别只在“这条命名边从缓存获得,还是向文件系统询问”。
2.5 处理 alice:上一步结果成为这一步的父目录
处理前:
current = (ext4 mount, home dentry / inode #12)
next = alice
本次查找的 key 已经变成:
(home dentry, "alice") -> alice dentry / inode #13
检查 mount 和 symlink 后提交:
current = (ext4 mount, alice dentry / inode #13)
remaining = []
last = report.txt
至此,中间分量处理完毕。current 不是最终文件,而是最终名字所在的父目录 /home/alice。
2.6 处理最后分量 report.txt
现在最终阶段拿到:
parent = (ext4 mount, alice dentry / inode #13)
last = report.txt
intent = O_RDONLY,不创建
它先检查 alice 目录可搜索,再查:
(alice dentry, "report.txt") -> report.txt dentry / inode #14
因为调用没有 O_CREAT:
- 若得到有效的 positive dentry,继续处理 mount、最终 symlink 和打开权限;
- 若得到 negative dentry 或文件系统确认名字不存在,返回
ENOENT,不能顺手创建文件。
本例得到普通文件 dentry,且没有新的 mount 或 symlink,于是 pathname lookup 的结果是:
result path
├─ mnt = ext4 mount
└─ dentry = report.txt dentry
└─ inode #14
之后 open 流程才按 O_RDONLY 完成最终检查,并补全此前已经分配的 struct file 外壳;再下一篇才会把它最终发布为 fd。
2.7 把五次查找并排看
| 分量 | 查找发生在哪个目录 | 名字查找先得到 | mount 处理后的 current |
|---|---|---|---|
| 初始化 | — | — | parent mount + parent root dentry |
mnt | 父文件系统 / | parent mnt dentry | parent mount + mnt dentry |
vfs-lab | 父文件系统 /mnt | parent vfs-lab dentry | ext4 mount + ext4 root dentry |
home | ext4 / | ext4 home dentry | ext4 mount + home dentry |
alice | ext4 /home | ext4 alice dentry | ext4 mount + alice dentry |
report.txt | ext4 /home/alice | ext4 report.txt dentry | 最终 result path |
整条路径没有哪一步查询
/mnt/vfs-lab/home/alice/report.txt 这个完整 key。忽略本例没有触发的特殊分支,中间分量始终重复同一个动作:
for component in [mnt, vfs-lab, home, alice]:
确认 current 是目录且允许搜索
child = 在 current.dentry 下查 component
若 child 不存在:失败
candidate = (current.mount, child)
candidate = 按当前 namespace 穿过覆盖它的 mount
若 candidate 是需要跟随的 symlink:改写待解析分量
否则:current = candidate
用 current 作为父目录,结合 O_RDONLY 处理 report.txt
三、从具体过程抽象出“当前位置 + 剩余工作”
路径解析过程中,内核不是不断拼出一个越来越长的规范化字符串。更稳定的抽象状态是:
解析状态
├─ current 当前 struct path = mount + dentry
├─ remaining 尚未处理的路径分量
├─ root 本次解析使用的根/边界
├─ rules follow、beneath、in-root、no-xdev 等规则
└─ links 展开 symlink 后要返回的剩余路径
其中最重要的不变量是:
current表示已经解析完成部分的落点;下一个分量必须以current为上下文解释。
每一步只有三种结果:
- 把
current推进到下一个可见位置; - 改写
remaining,例如展开 symlink; - 结束并返回结果或错误。
以第 03 篇的挂载为例,解析:
/mnt/vfs-lab/home/alice/latest
假设 latest -> "report.txt",状态变化可以先不看任何内核函数:
| 已处理动作 | current | remaining |
|---|---|---|
| 初始化绝对路径 | 进程可见 root path | mnt/vfs-lab/home/alice/latest |
处理 mnt | 父文件系统的 /mnt | vfs-lab/home/alice/latest |
处理 vfs-lab | ext4 mount 的 root | home/alice/latest |
处理 home、alice | ext4 的 /home/alice | latest |
读出 latest 的相对 symlink | 仍以 /home/alice 为解释基准 | report.txt |
| 处理最终名字 | ext4 的 report.txt path | 空 |
vfs-lab 这一步同时发生了“在父文件系统中找到 dentry”和“按 namespace 跨入 ext4 root”;latest 这一步没有直接跳到某个 inode,而是产生了新的待解析名字。两种情况都能由同一个状态模型表达。
四、解析空间由两层拓扑叠加而成
单个文件系统提供的是目录命名边:
(parent dentry, component name) -> child dentry
mount namespace 额外提供的是挂载覆盖边:
(parent mount, mountpoint dentry) -> child mount 的 root dentry
pathname walk 看到的是两者叠加后的有效拓扑:
因此普通名字分量的完整转换不是一次查表,而是:
current path
│
├─ 在 current.dentry 下查名字
▼
候选 path = current.mount + child dentry
│
├─ 按当前 mount namespace 处理零个或多个覆盖边
▼
下一个可见 path
这解释了两个关键事实:
- dentry 不足以表示当前位置。相同 dentry 放在不同 mount 中,向上和向下能走到的路径可能不同;
- mount namespace 不必复制每个文件系统的 dentry 树。它只需要组织“哪些 mount 接到哪些
mount + dentry上”。
符号链接又是另一类关系:它保存 pathname 文本,作用是改写待执行的分量,而不是在对象图里增加一条“直接指向目标 inode”的边。
五、每个路径分量怎样驱动状态机
先看不带具体函数名的主循环:
这张图表达的是稳定语义,不承诺每个检查在源码中恰好对应一个函数,也暂时忽略 RCU、锁和引用计数。
5.1 普通名字:先确认“能经过目录”,再找命名边
假设 current 是目录 /home/alice,下一个分量是 report.txt。逻辑顺序是:
- 当前对象必须是可查找的目录,否则返回
ENOTDIR; - 当前凭据必须有该目录的 search/execute 权限,否则返回
EACCES; - 查
(alice dentry, "report.txt")对应的 child dentry; - 把当前 mount 与 child dentry 组成候选 path;
- 处理候选位置上的 mount、automount 和 symlink 语义;
- 若后面还有分量,新位置还必须能作为目录继续查找。
目录的 read 权限与 search 权限不是一回事。列出目录项通常需要 read;仅沿已知名字穿过目录需要 execute/search。dcache 命中也不会绕过这类权限检查。
5.2 .:不推进位置
. 消耗一个分量,但 current 不变。它仍处在完整解析规则中;例如路径尾部的 / 会要求结果可作为目录,并不能一概先把所有 . 和斜杠当普通字符串优化掉就结束。
5.3 ..:不是简单读取 d_parent
普通目录中的 .. 可以理解为移到父 dentry,但有两个边界会改变这个动作:
- 已到本次解析 root 时,普通 lookup 停在 root,不会越过进程可见根;受
RESOLVE_BENEATH约束的 lookup 则会把逃逸尝试作为错误; - 若
current.dentry是当前 mount 的 root,必须先沿 mount 关系回到父 mount 的挂载点,再取挂载点的父目录。
例如 ext4 挂在 /mnt/vfs-lab:
current = ext4 mount root
执行 ..
先回到父 mount 的 vfs-lab 挂载点
再取该挂载点的父 dentry
result = 父文件系统中的 /mnt
它不会钻进被 child mount 覆盖的旧 vfs-lab 目录。路径向下时跨 mount,向上时也必须理解同一套 mount 拓扑;这正是 current 不能只保存 dentry 的另一个原因。
5.4 symlink:改写程序,不是直接换 inode
假设当前解析:
/sandbox/dir/link-out/tail
而 link-out 保存 ../outside。到达 symlink 时的逻辑状态是:
解释基准: /sandbox/dir
symlink 目标: ../outside
原路径剩余: tail
新的工作:从 /sandbox/dir 继续解释 ../outside/tail
相对 symlink 以软链接所在目录为基准,不以线程 cwd 重新开始;绝对 symlink 让解析器跳到本次 lookup 的 root,再解释去掉开头 / 后的目标。目标走完后还要返回原路径剩余部分,因此内核需要保存 symlink continuation。
Linux v6.12.65 对一次 pathname lookup 最多允许跟随 40 个 symlink,超过后返回 ELOOP。最后一个分量是否跟随 symlink 还取决于具体操作和 O_NOFOLLOW、RESOLVE_NO_SYMLINKS 等规则。
这也解释了第 03 篇的 latest -> "report.txt":目录项先让 lookup 到达独立的 symlink inode;读出字符串后,才从 latest 所在目录继续解析 report.txt。删除原文件名字不会改写 symlink 保存的字符串。
5.5 最后一个分量为什么要单独处理
中间分量的目标很单一:必须找到一个能继续向下搜索的位置。最后分量却携带调用意图:
| 意图 | 最后分量要回答的问题 |
|---|---|
| 普通 lookup/open | 名字是否已存在,最终对象是什么? |
O_CREAT | 不存在时是否能在父目录创建? |
O_EXCL + O_CREAT | 名字已经存在时是否必须失败? |
O_DIRECTORY 或尾随 / | 结果是否必须是目录? |
O_NOFOLLOW | 最终 symlink 是否应拒绝跟随? |
O_TRUNC | 找到现有普通文件后是否执行截断? |
所以解析 a/b/c 时,可以把状态理解成:
中间 walk 先得到:current = a/b 的目录 path
最终阶段再处理: last = c,intent = lookup/create/open/truncate...
“解析名字”和“按 open flags 操作最终对象”在这里会合。把 c 和中间分量完全同样处理,会丢失原子创建、排他创建和最终 symlink 等语义。
六、dcache 缓存的是命名边,不是完整路径答案
对普通分量 report.txt,dcache 查找的核心 key 可以抽象为:
(parent dentry, "report.txt")
它缓存的是某个父目录下的一条命名关系:
positive dentry: "report.txt" -> inode #14
negative dentry: "missing.txt" -> NULL
负 dentry 让 VFS 可以复用“当前不存在”的结果;目录修改、重验证和缓存回收都可能使它失效,所以它不是永久证明。
完整查找分叉是:
dcache 查 (parent dentry, component)
│
├─ 命中且仍有效 -> 复用 dentry
└─ 未命中或必须慢查
│
▼
询问 parent inode 的文件系统 lookup 实现
│
├─ ext4:解析自己的目录数据结构
├─ procfs:根据内核对象和注册项生成结果
└─ 其他文件系统:按自己的目录语义回答
因此 dcache 既不是 /a/b/c -> inode 的全局表,也不是权限、mount、symlink 和重验证规则的替代品。即使每个分量都命中,解析器仍要逐级维护上下文并验证这条路径在当前时刻是否可用。
七、mount crossing 是“普通查名后再替换可见位置”
仍以:
/mnt/vfs-lab/home/alice/report.txt
为例。
7.1 查路径之前,mount 关系已经建立
mount(2) 成功返回前,mount namespace 已经登记:父文件系统中的 /mnt/vfs-lab 被一个 ext4 child mount 覆盖。
挂载关系的 key
(parent vfsmount, parent dentry("vfs-lab"))
│
▼
child vfsmount
└─ mnt_root -> ext4 root dentry / inode #2
pathname walk 到这里时不会重新扫描分区表、识别 ext4 或挂载设备;它只查询已经存在的 namespace 关系。
key 必须同时包含 mount 和 dentry。同一底层 dentry 可能被多个 namespace 共享,而各 namespace 展示的挂载树可以不同。dentry 上的“这里可能挂载了东西”标志最多只是快速提示,不能单独回答当前 namespace 是否真的有 child mount。
7.2 两个问题必须按顺序回答
进入 vfs-lab 可以拆成两问:
- 当前父目录下,名字
vfs-lab对应哪个 dentry? - 到达这个 dentry 后,当前 mount namespace 是否把它导向另一个 mount?
第一问由 dcache/具体文件系统回答,第二问由 mount traversal 回答。四个关键时刻如下:
| 时刻 | mount | dentry | 含义 |
|---|---|---|---|
查 vfs-lab 前 | parent mount | /mnt | 当前位置仍在父文件系统 |
| 普通 lookup 后 | parent mount | vfs-lab | 得到挂载点候选,尚未跨越 |
| namespace traversal 后 | ext4 mount | ext4 mnt_root | 候选位置已切到 child root |
| 提交状态后 | ext4 mount | ext4 mnt_root | 新 current 正式位于 inode #2 |
挂载没有删除父文件系统中的 vfs-lab 目录,只是在这个 namespace 的路径视图中覆盖它。卸载 child mount 后,原目录及其内容会重新可见。
7.3 同一点可以连续跨越多层 mount
如果同一位置叠放了多层 mount,向下 traversal 会继续处理覆盖关系,直到到达当前 namespace 最上层可见的 mount。抽象算法仍是同一个循环:
candidate = current.mount + child dentry
while candidate 是当前 namespace 中的挂载点:
candidate = child mount + child mount root
current = candidate
这是逻辑伪代码,不表达 automount、RCU、引用计数和并发校验。
7.4 跨越后从 ext4 root 继续普通查名
vfs-lab 分量已经消耗完。此时:
current.mount = ext4_vfsmount
current.dentry = ext4 root dentry
current.inode = ext4 root inode #2
next component = home
VFS 接着查 (ext4 root dentry, "home");没有可用 dcache 结果时才调用 ext4 的 lookup。第 03 篇的样本随后走过:
root inode #2 的目录 block 10
-> "home" -> inode #12
-> "alice" -> inode #13
-> "report.txt" -> inode #14
vfs-lab 不会出现在 /dev/loop0 的 ext4 根目录项中。它属于父文件系统,只是 mount namespace 把两棵树接起来的位置。
八、解析约束是在状态转换时执行的
仅在调用前用字符串检查 ..,或先 realpath() 再 open(),都无法完整约束 pathname walk:symlink 和 rename 可以改变结果,两次系统调用之间还有竞态窗口。
openat2() 把边界规则交给执行同一次 walk 的内核:
| resolve 规则 | 对状态转换的限制 |
|---|---|
RESOLVE_BENEATH | 要求整个解析留在 dirfd 起点之下;拒绝绝对 pathname、会跳到根的绝对 symlink 和向上的逃逸 |
RESOLVE_IN_ROOT | 临时把 dirfd 当作本次 root;绝对路径/绝对 symlink 从这里解释,.. 也不能越过它 |
RESOLVE_NO_SYMLINKS | 任一分量需要跟随 symlink 时失败 |
RESOLVE_NO_XDEV | 不允许跨 mount,包括 bind mount |
RESOLVE_CACHED | 只接受无需进入可能阻塞慢路径的缓存解析,否则以 EAGAIN 交回调用者 |
这些选项不是事后检查最终字符串,而是直接删掉状态机中不被允许的边。例如 RESOLVE_NO_XDEV 禁止 mount crossing,RESOLVE_NO_SYMLINKS 禁止 symlink 改写,RESOLVE_BENEATH 把向上逃逸变成错误。
九、RCU-walk 与 REF-walk 只是两种执行策略
到这里为止的 current + remaining、普通名字、mount、.. 和 symlink 规则,在 RCU-walk 与 REF-walk 中都相同。两者不同的是怎样证明读取期间对象关系没有被并发修改。
RCU-walk
├─ 在 RCU 保护下乐观读取 dentry/mount 关系
├─ 尽量不改引用计数、不获取会阻塞的锁
├─ 用 dentry、rename、mount 等序列状态检测变化
└─ 无法安全继续 -> 要求退回 REF-walk
REF-walk
├─ 显式取得 path/dentry/mount 引用
├─ 必要时获取锁或进入可能阻塞的文件系统操作
└─ 执行相同的路径语义
RCU-walk 不是“只查缓存,所以不用检查”,REF-walk 也不是另一套解析规则。前者是乐观执行;只要某一步需要阻塞、对象无法稳定验证或发生并发变化,就切到更保守的保护方式。
在 namei 内部,-ECHILD 常被用作“请用 REF-walk 从头重试”的控制结果。正常的 open() 调用会在内核内部处理这次重试,用户并不会因为它的名字而看到某个“子进程错误”。
十、把抽象映射到 Linux v6.12.65 源码
现在再看函数名,它们只是把前文的状态机分工实现出来。
10.1 struct nameidata 保存解析状态
省略并发和辅助字段后,关键成员与抽象模型一一对应:
struct nameidata {
struct path path; /* current */
struct qstr last; /* 最后一个分量 */
struct path root; /* root / 解析边界 */
struct inode *inode; /* path.dentry 对应 inode */
unsigned int flags; /* rules */
unsigned int depth;
int total_link_count;
struct saved *stack; /* symlink continuation */
struct filename *name;
int dfd;
};
这不是完整定义。真实结构还保存 dentry、mount、rename 的序列状态,用来支撑 RCU-walk 和受约束解析的并发校验。
10.2 顶层调用链先走中间分量,再处理最后分量
open/openat/openat2
│
▼
do_sys_openat2
│
▼
do_filp_open
│
├─ path_openat(..., LOOKUP_RCU)
│ ├─ path_init
│ ├─ link_path_walk
│ ├─ open_last_lookups
│ └─ do_open
│
├─ -ECHILD -> 不带 LOOKUP_RCU 重试
└─ -ESTALE -> 带 LOOKUP_REVAL 重试
path_openat() 的主干可以删减成下面这样:
file = alloc_empty_file(...);
next = path_init(nd, flags);
while (!(error = link_path_walk(next, nd)) &&
(next = open_last_lookups(nd, file, op)) != NULL)
;
if (!error)
error = do_open(nd, file, op);
循环存在是因为最后分量也可能是 symlink:open_last_lookups() 读出新目标后,解析器还要回到 link_path_walk() 继续执行目标和原路径 continuation。
10.3 每个函数在抽象流程中的位置
| 函数 | 对应的抽象职责 |
|---|---|
path_init() | 根据绝对/相对 pathname、cwd、dirfd 和 scoped rules 设置起点与 root |
link_path_walk() | 切分并循环处理中间分量,维护 symlink continuation |
walk_component() | 分派 ./..,或对普通名字执行 fast/slow lookup |
lookup_fast() | 尝试复用并验证 dcache 中的命名边 |
lookup_slow() | 必要时调用目录 inode 的 i_op->lookup() |
step_into() | 把 child dentry 变成候选 path,处理 mount 与 symlink,再提交状态 |
handle_mounts() | 按当前 namespace 穿过 mount/automount 关系 |
open_last_lookups() | 按 open intent 查找、创建或展开最终分量 |
do_open() | 对最终对象做 open 权限、类型、截断等处理 |
普通分量的源码骨架与前面的状态机完全对应:
if (component_is_dot_or_dotdot)
return handle_dots(...);
dentry = lookup_fast(...);
if (!dentry)
dentry = lookup_slow(...);
return step_into(nd, ..., dentry);
lookup_slow() 最终才会进入具体文件系统:
result = parent_inode->i_op->lookup(parent_inode, child_dentry, flags);
这就是 VFS 与 ext4、procfs 等目录实现的交接点。
10.4 step_into() 为什么先处理 mount,再判断 symlink
walk_component() 得到的 dentry 还只是在当前文件系统中的候选名字。step_into() 先交给 handle_mounts() 组成并规范化候选 path,再检查实际可见位置是否为 symlink:
child dentry
│
▼
handle_mounts(current.mount + child dentry)
│
├─ 可能仍是原 mount + child dentry
└─ 也可能变成 child mount + mnt_root
│
▼
若可见对象是 symlink -> pick_link() 改写剩余路径
否则 -> 提交为 nd->path
在 REF-walk 中,handle_mounts() 通过 traverse_mounts() 处理 mount 与 automount;RCU-walk 则先尝试 __follow_mount_rcu(),无法无阻塞地完成时再退出乐观模式。DCACHE_MOUNTED 只是“这里可能有 mount”的提示,真正选择 child mount 仍必须结合候选 path 和当前 namespace。
十一、实验:观察起点、逃逸与 mount 身份
- 创建一个目录并打开为 dirfd;
- 验证相对 pathname 从 dirfd 开始;
- 验证普通
openat()可通过..和 symlink 离开; - 尝试用
openat2()限制状态转换; - 用
statx(STATX_MNT_ID)比较/tmp和/proc的 mount 身份。
完整源码如下:
#define _GNU_SOURCE
#include <errno.h>
#include <fcntl.h>
#include <linux/openat2.h>
#include <linux/stat.h>
#include <limits.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <sys/syscall.h>
#include <sys/types.h>
#include <unistd.h>
static void die(const char *what)
{
perror(what);
exit(EXIT_FAILURE);
}
static void create_file(const char *path, const char *content)
{
int fd = open(path, O_WRONLY | O_CREAT | O_TRUNC, 0644);
size_t length = strlen(content);
ssize_t n;
if (fd < 0)
die("open create_file");
n = write(fd, content, length);
if (n < 0 || (size_t)n != length)
die("write create_file");
if (close(fd) < 0)
die("close create_file");
}
static int openat2_call(int dirfd, const char *path, unsigned long long resolve)
{
struct open_how how = {
.flags = O_RDONLY | O_CLOEXEC,
.mode = 0,
.resolve = resolve,
};
return (int)syscall(SYS_openat2, dirfd, path, &how, sizeof(how));
}
static void report_open(const char *label, int fd)
{
if (fd >= 0) {
char byte;
if (read(fd, &byte, 1) != 1)
die("read opened file");
printf("%-34s success, first byte=%c\n", label, byte);
if (close(fd) < 0)
die("close opened file");
} else {
printf("%-34s failed: errno=%d (%s)\n",
label, errno, strerror(errno));
}
}
static unsigned long long mount_id(const char *path)
{
struct statx stx;
memset(&stx, 0, sizeof(stx));
if (syscall(SYS_statx, AT_FDCWD, path, AT_STATX_SYNC_AS_STAT,
STATX_MNT_ID, &stx) < 0)
die("statx");
return stx.stx_mnt_id;
}
int main(void)
{
char base[] = "/tmp/path-openat2-XXXXXX";
char root[128];
char inside[160];
char outside[160];
char link_out[160];
int dirfd;
int fd;
if (mkdtemp(base) == NULL)
die("mkdtemp");
snprintf(root, sizeof(root), "%s/root", base);
snprintf(inside, sizeof(inside), "%s/inside.txt", root);
snprintf(outside, sizeof(outside), "%s/outside.txt", base);
snprintf(link_out, sizeof(link_out), "%s/link-out", root);
if (mkdir(root, 0755) < 0)
die("mkdir root");
create_file(inside, "inside\n");
create_file(outside, "outside\n");
if (symlink("../outside.txt", link_out) < 0)
die("symlink");
dirfd = open(root, O_RDONLY | O_DIRECTORY | O_CLOEXEC);
if (dirfd < 0)
die("open root directory");
printf("== dirfd chooses the starting directory ==\n");
fd = openat(dirfd, "inside.txt", O_RDONLY | O_CLOEXEC);
report_open("openat(dirfd, inside.txt)", fd);
fd = openat(dirfd, "../outside.txt", O_RDONLY | O_CLOEXEC);
report_open("openat(dirfd, ../outside.txt)", fd);
fd = openat(dirfd, "link-out", O_RDONLY | O_CLOEXEC);
report_open("openat(dirfd, link-out)", fd);
printf("\n== openat2 resolution constraints ==\n");
errno = 0;
fd = openat2_call(dirfd, "inside.txt", RESOLVE_BENEATH);
report_open("RESOLVE_BENEATH inside.txt", fd);
errno = 0;
fd = openat2_call(dirfd, "../outside.txt", RESOLVE_BENEATH);
report_open("RESOLVE_BENEATH ../outside.txt", fd);
errno = 0;
fd = openat2_call(dirfd, "link-out", RESOLVE_BENEATH);
report_open("RESOLVE_BENEATH link-out", fd);
errno = 0;
fd = openat2_call(dirfd, "link-out", RESOLVE_NO_SYMLINKS);
report_open("RESOLVE_NO_SYMLINKS link-out", fd);
printf("\n== the same pathname tree can cross mounts ==\n");
printf("mount id of %-8s = %llu\n", "/tmp", mount_id("/tmp"));
printf("mount id of %-8s = %llu\n", "/proc", mount_id("/proc"));
if (close(dirfd) < 0)
die("close dirfd");
if (unlink(link_out) < 0 ||
unlink(inside) < 0 ||
unlink(outside) < 0 ||
rmdir(root) < 0 ||
rmdir(base) < 0)
die("cleanup");
return EXIT_SUCCESS;
}
编译运行:
docker run --rm --platform linux/amd64 \
-v "$PWD/os/filesystem/assets:/src:ro" \
gcc:13 \
sh -c 'gcc -O2 -Wall -Wextra -Werror -std=c11 \
/src/path-openat2-demo.c -o /tmp/path-demo &&
/tmp/path-demo'
普通 openat() 的真实输出:
openat(dirfd, inside.txt) success, first byte=i
openat(dirfd, ../outside.txt) success, first byte=o
openat(dirfd, link-out) success, first byte=o
这三行分别验证了 dirfd 起点、.. 向上转换和 symlink 改写。
11.1 必须如实记录的环境边界
本机 Docker server 是 arm64,linux/amd64 容器通过 amd64 模拟层执行。即使容器报告:
uname -m = x86_64
kernel = 6.12.65-linuxkit
openat2 在默认和 seccomp=unconfined 两种运行中都返回:
errno=38 (Function not implemented)
因此本文不把 RESOLVE_* 的成功/失败码伪装成已运行结果。openat2 的实现和 do_sys_openat2() -> do_filp_open() 路径已由 Linux v6.12.65 源码确认;要验证 RESOLVE_BENEATH 等运行语义,需要在原生暴露 openat2 的内核/虚拟机中复跑同一程序。
这个限制本身也说明:系统调用是否可用不只取决于源码版本,还会受容器 syscall 过滤、模拟层和宿主执行环境影响。
十二、把完整 pathname walk 收成一张图
输入 pathname + dirfd/cwd/root + namespace + credentials + rules
│
▼
选择起点 current path
│
▼
逐个处理中间分量
├─ 检查当前目录与 search 权限
├─ . 保持 current
├─ .. 按 root 与 mount 拓扑向上
└─ 普通名字
├─ dcache / filesystem lookup 得到 child dentry
├─ 按 namespace 向下跨 mount
└─ symlink 则改写 remaining,否则提交 current
│
▼
按 lookup/create/open intent 处理最后分量
│
├─ 成功:得到 struct path,并继续 open
└─ 失败:返回具体路径解析错误
最值得保留的不是函数名,而是三个判断:
- pathname 是在进程上下文中执行的分量序列;
- 每个普通名字先产生 dentry 候选,再由 mount namespace 决定可见 path;
- dcache、RCU-walk 和 REF-walk 只优化或保护执行过程,不改变
.、..、mount、symlink 和最终分量的语义。
得到 path 还不是得到 fd。下一篇继续走 do_sys_openat2() 的另一半:预留 fd、构造 struct file、安装 file->f_op,最后用 fd_install() 发布到 fdtable。
参考源码
- Linux
v6.12.65fs/namei.c:struct nameidata、path_init()、link_path_walk()、walk_component()、step_into()、handle_mounts()、open_last_lookups()、path_openat()与do_filp_open()。 - Linux
v6.12.65fs/namespace.c与fs/mount.h:mount lookup、traversal 与 namespace 关系。 - Linux 内核文档:Pathname lookup。
- Linux UAPI:
include/uapi/linux/openat2.h。