路径不是字符串查表:mount namespace 和 pathname walk

0 阅读26分钟

路径不是字符串查表: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.65nameidatalink_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

解析器依次回答:

  1. current.dentry 对应的 inode 是不是目录?
  2. 当前凭据能不能搜索这个目录?
  3. 这个目录下的名字 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 dentryparent mount + mnt dentry
vfs-lab父文件系统 /mntparent vfs-lab dentryext4 mount + ext4 root dentry
homeext4 /ext4 home dentryext4 mount + home dentry
aliceext4 /homeext4 alice dentryext4 mount + alice dentry
report.txtext4 /home/aliceext4 report.txt dentry最终 result path

image.png 整条路径没有哪一步查询 /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 为上下文解释。

每一步只有三种结果:

  1. current 推进到下一个可见位置;
  2. 改写 remaining,例如展开 symlink;
  3. 结束并返回结果或错误。

以第 03 篇的挂载为例,解析:

/mnt/vfs-lab/home/alice/latest

假设 latest -> "report.txt",状态变化可以先不看任何内核函数:

已处理动作currentremaining
初始化绝对路径进程可见 root pathmnt/vfs-lab/home/alice/latest
处理 mnt父文件系统的 /mntvfs-lab/home/alice/latest
处理 vfs-labext4 mount 的 roothome/alice/latest
处理 homealiceext4 的 /home/alicelatest
读出 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 看到的是两者叠加后的有效拓扑:

image.png 因此普通名字分量的完整转换不是一次查表,而是:

current path
    │
    ├─ 在 current.dentry 下查名字
    ▼
候选 path = current.mount + child dentry
    │
    ├─ 按当前 mount namespace 处理零个或多个覆盖边
    ▼
下一个可见 path

这解释了两个关键事实:

  • dentry 不足以表示当前位置。相同 dentry 放在不同 mount 中,向上和向下能走到的路径可能不同;
  • mount namespace 不必复制每个文件系统的 dentry 树。它只需要组织“哪些 mount 接到哪些 mount + dentry 上”。

符号链接又是另一类关系:它保存 pathname 文本,作用是改写待执行的分量,而不是在对象图里增加一条“直接指向目标 inode”的边。

五、每个路径分量怎样驱动状态机

先看不带具体函数名的主循环:

image.png

这张图表达的是稳定语义,不承诺每个检查在源码中恰好对应一个函数,也暂时忽略 RCU、锁和引用计数。

5.1 普通名字:先确认“能经过目录”,再找命名边

假设 current 是目录 /home/alice,下一个分量是 report.txt。逻辑顺序是:

  1. 当前对象必须是可查找的目录,否则返回 ENOTDIR
  2. 当前凭据必须有该目录的 search/execute 权限,否则返回 EACCES
  3. (alice dentry, "report.txt") 对应的 child dentry;
  4. 把当前 mount 与 child dentry 组成候选 path;
  5. 处理候选位置上的 mount、automount 和 symlink 语义;
  6. 若后面还有分量,新位置还必须能作为目录继续查找。

目录的 read 权限与 search 权限不是一回事。列出目录项通常需要 read;仅沿已知名字穿过目录需要 execute/search。dcache 命中也不会绕过这类权限检查。

5.2 .:不推进位置

. 消耗一个分量,但 current 不变。它仍处在完整解析规则中;例如路径尾部的 / 会要求结果可作为目录,并不能一概先把所有 . 和斜杠当普通字符串优化掉就结束。

5.3 ..:不是简单读取 d_parent

普通目录中的 .. 可以理解为移到父 dentry,但有两个边界会改变这个动作:

  1. 已到本次解析 root 时,普通 lookup 停在 root,不会越过进程可见根;受 RESOLVE_BENEATH 约束的 lookup 则会把逃逸尝试作为错误;
  2. 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_NOFOLLOWRESOLVE_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 可以拆成两问:

  1. 当前父目录下,名字 vfs-lab 对应哪个 dentry?
  2. 到达这个 dentry 后,当前 mount namespace 是否把它导向另一个 mount?

第一问由 dcache/具体文件系统回答,第二问由 mount traversal 回答。四个关键时刻如下:

时刻mountdentry含义
vfs-labparent mount/mnt当前位置仍在父文件系统
普通 lookup 后parent mountvfs-lab得到挂载点候选,尚未跨越
namespace traversal 后ext4 mountext4 mnt_root候选位置已切到 child root
提交状态后ext4 mountext4 mnt_rootcurrent 正式位于 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 身份

  1. 创建一个目录并打开为 dirfd;
  2. 验证相对 pathname 从 dirfd 开始;
  3. 验证普通 openat() 可通过 .. 和 symlink 离开;
  4. 尝试用 openat2() 限制状态转换;
  5. 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
    └─ 失败:返回具体路径解析错误

最值得保留的不是函数名,而是三个判断:

  1. pathname 是在进程上下文中执行的分量序列;
  2. 每个普通名字先产生 dentry 候选,再由 mount namespace 决定可见 path;
  3. dcache、RCU-walk 和 REF-walk 只优化或保护执行过程,不改变 ...、mount、symlink 和最终分量的语义。

得到 path 还不是得到 fd。下一篇继续走 do_sys_openat2() 的另一半:预留 fd、构造 struct file、安装 file->f_op,最后用 fd_install() 发布到 fdtable。

参考源码