[toc]

fs/namei.c 路径名查找(Pathname Lookup) VFS将路径字符串解析为内核对象的核心

历史与背景

这项技术是为了解决什么特定问题而诞生的?

fs/namei.c 是Linux虚拟文件系统(VFS)中最核心、最基础的组件之一。它的名字来源于“name to inode”(名称到索引节点),其诞生的目的就是为了解决一个操作系统最基本的问题:如何将一个人类可读的文件路径字符串(如 /home/user/document.txt)转换成内核能够理解和操作的内部对象(即一个 struct inode

这个过程被称为路径解析(Path Resolution)路径查找(Pathname Lookup),它需要解决以下几个关键问题:

  1. 分层遍历:如何从一个起点(根目录或当前工作目录)开始,逐级地、安全地遍历目录树?
  2. 权限检查:在遍历路径的每一步,如何确保当前进程有权限进入下一级目录(需要执行x权限)?
  3. 抽象与统一:如何让这个遍历过程对所有不同类型的文件系统(ext4, XFS, NFS等)都有效,而无需关心它们的具体实现?
  4. 处理复杂情况:如何正确地处理符号链接(Symbolic Links)、挂载点(Mount Points)、..(父目录)等特殊情况?

fs/namei.c 提供了这个统一的、健壮的路径遍历引擎,它是所有基于路径的系统调用(如open, stat)的基石。

它的发展经历了哪些重要的里程碑或版本迭代?

路径查找的逻辑自Unix诞生之初就已存在,Linux继承了其基本思想并进行了大量的性能优化。

  • dcache的引入和完善:这是Linux VFS路径查找性能的决定性里程碑。每次都从磁盘读取目录内容来查找下一个路径组件是非常缓慢的。目录项缓存(dcache)将“目录+文件名 -> inode”的映射关系缓存在高速的RAM中。namei.c的逻辑被设计为首先查询dcache,只有在缓存未命中(cache miss)时,才需要调用底层文件系统的代码去访问磁盘。这使得绝大多数重复的文件访问都变成了极快的内存操作。
  • RCU(Read-Copy-Update)的集成:路径查找是一个典型的“读多写少”的场景。为了在多核CPU上获得极高的并发性能,namei.c的路径遍历逻辑(link_path_walk())被深度优化,广泛使用RCU锁。这允许多个CPU核心同时进行路径查找,而几乎不需要昂贵的互斥锁,极大地提升了系统的可伸缩性。
  • *at()系列系统调用的支持:为了解决传统系统调用(如open)在处理相对路径时的竞争条件问题,内核引入了openat, statat等一系列以文件描述符为起点的系统调用。namei.c中的逻辑被扩展,以支持从一个给定的目录文件描述符开始进行路径查找,而不是仅仅从进程的CWD或root开始。

目前该技术的社区活跃度和主流应用情况如何?

fs/namei.c是VFS中最繁忙、最关键的代码路径之一。

  • 主流应用:系统上的每一个接收文件路径作为参数的系统调用,其内核实现的第一步几乎都是调用namei.c中提供的函数。它无处不在,是整个操作系统的基石。

核心原理与设计

它的核心工作原理是什么?

fs/namei.c的核心是一个被称为**“路径遍历”(Path Walk)**的循环算法。这个算法以一个起始点和一个路径字符串作为输入,输出一个dentryvfsmount的组合(封装在struct path中),最终可以得到inode

遍历的核心步骤如下:

  1. 确定起点
    • 如果路径以/开头(绝对路径),遍历从当前进程的根目录(current->fs->root)开始。
    • 如果路径不以/开头(相对路径),遍历从当前进程的当前工作目录(current->fs->pwd)开始。
    • 如果是*at()系统调用,则从指定的目录文件描述符对应的path开始。
  2. 循环遍历路径组件:内核在一个循环中逐个处理由/分隔的路径组件(component)。
  3. 在循环的每一步(处理一个组件,例如 “user”)
    • 权限检查:首先,内核检查当前进程是否对当前的目录(例如/home)拥有执行(x)权限。如果没有,查找立即失败并返回-EACCES
    • 查询dcache(快速路径):内核以当前目录的dentry和要查找的组件名"user"为键,在dcache中进行快速查找。
      • 命中(Hit):如果找到了,内核就获得了"user"对应的dentry,并立即进入循环的下一步,开始查找下一个组件(如 “document.txt”)。
    • 调用文件系统(慢速路径)
      • 未命中(Miss):如果在dcache中没找到,内核必须调用当前目录inode的操作函数集(inode->i_op)中的.lookup()方法。
      • 这个.lookup()函数是由具体的文件系统(如ext4)实现的。ext4的.lookup函数会读取/home目录在磁盘上的数据块,在其中搜索名为"user"的条目,并找到它对应的inode号。
      • 如果找到,ext4会创建一个新的inode对象(如果它尚未在内存中),并返回一个新的dentry给VFS。
      • VFS(namei.c)接收到这个新的dentry后,会将其添加/更新到dcache中,以便下一次查找能够命中缓存。
  4. 处理特殊情况
    • 符号链接(Symlink):如果查找到的dentry是一个符号链接,路径遍历会暂停。内核会读取链接的目标路径,然后递归地对新路径重新开始一次路径遍历(同时会有一个最大递归深度限制,以防止无限循环)。
    • 挂载点(Mount Point):如果查找到的dentry是一个挂载点(例如,从/查找到/mnt,而/mnt上挂载了另一个设备),遍历会**“跨越”**到被挂载文件系统的根dentry,然后继续在新的文件系统中进行后续组件的查找。
  5. 结束:当所有路径组件都被成功处理后,循环结束,namei.c返回最终找到的path对象。

它的主要优势体现在哪些方面?

  • 性能:dcache和RCU的使用使得路径查找在绝大多数情况下都非常快。
  • 抽象:将通用的遍历逻辑和安全检查放在VFS层,而将具体的目录搜索任务委托给底层文件系统,实现了完美的抽象。
  • 安全性:在遍历的每一步都强制进行权限检查,确保了文件系统的访问安全。

它存在哪些已知的劣势、局限性或在特定场景下的不适用性?

  • 复杂性namei.c中的代码是内核中最复杂的之一,因为它必须处理大量的边界情况和竞争条件。
  • 性能瓶颈:尽管经过了大量优化,路径查找仍然是许多工作负载下的性能热点。例如,创建大量小文件时,会给dcache和namei逻辑带来巨大压力。

使用场景

在哪些具体的业务或技术场景下,它是首选解决方案?

这不是一个可选的方案,而是所有基于路径名的文件访问的唯一内核路径

  • open("/etc/passwd", O_RDONLY): 内核调用namei逻辑,从根目录开始,查找etc,再在etc中查找passwd,最终返回passwdinode,然后open的后续逻辑会创建一个struct file对象。
  • mkdir("/tmp/newdir", 0755): 内核调用namei逻辑,但会传入一个LOOKUP_PARENT标志。遍历会停在/tmp,并返回/tmpinode和最后的目标组件名"newdir"。然后mkdir的后续逻辑会调用/tmp目录inode.mkdir操作。
  • cd ../backup: Shell调用chdir。内核调用namei,从当前目录开始,处理..(向上返回一级),然后再在父目录中查找backup

是否有不推荐使用该技术的场景?为什么?

  • 当你已经有文件描述符时:一旦你通过opensocket等调用获得了一个文件描述符(FD),后续的I/O操作(如read, write, fsync)就直接作用于这个FD。这些操作不会再进行路径查找,因为FD已经是一个指向内核中struct file对象的直接句柄。在性能敏感的应用中,应尽可能早地打开文件获取FD,并复用这个FD,以避免重复的路径查找开销。

对比分析

请将其 与 其他相似技术 进行详细对比。

在VFS中,namei是动作的发起者和协调者,最好的对比是理解它与其他核心组件的关系。

组件 namei.c (路径查找) dcache.c (目录项缓存) 具体文件系统的.lookup
角色 引擎/协调者 (Engine/Orchestrator) 缓存/数据库 (Cache/Database) 后端/实现者 (Backend/Implementer)
核心功能 执行从头到尾的路径遍历算法,处理所有复杂情况。 提供一个极快的、基于内存的“路径组件 -> inode”映射的查找服务。 在磁盘上查找一个文件名对应的元数据。
交互关系 nameidcache的使用者。它总是先问dcache。 dcachenamei提供快速路径 namei在dcache未命中时,会调用具体文件系统的.lookup
数据来源 无直接数据来源,它是一个纯粹的逻辑引擎。 数据来源于底层文件系统的.lookup调用的结果。 数据的最终来源是物理存储设备
总结 路径查找的总指挥 路径查找的“速记员/备忘录” 路径查找的“实地勘探员”

文件系统链接保护:为 protected_* 策略创建 Sysctl 开关

本代码片段的功能是为Linux内核的VFS(虚拟文件系统)路径解析层(namei)创建一组重要的安全相关的sysctl接口。它在/proc/sys/fs/目录下生成了protected_symlinksprotected_hardlinksprotected_fifosprotected_regular四个文件。这些接口允许系统管理员在运行时启用或配置一系列保护措施,旨在缓解常见的、利用文件链接和特殊文件创建的本地权限提升攻击(例如TOCTOU - Time-of-check to time-of-use 竞态条件攻击)。

实现原理分析

此功能的实现是sysctl框架的一个直接应用,用于将内核内部的安全策略开关暴露给用户空间。

  1. 策略变量定义:

    • 代码首先定义了四个static int类型的全局变量,并使用了__read_mostly属性进行修饰。
    • sysctl_protected_symlinks: 控制是否限制在特定条件下(如在粘滞位(sticky bit)的全局可写目录下)跟随符号链接。
    • sysctl_protected_hardlinks: 控制是否禁止普通用户为他们不拥有的文件创建硬链接。
    • sysctl_protected_fifos/sysctl_protected_regular: 控制是否限制在全局可写目录下创建FIFO(命名管道)或常规文件。
    • __read_mostly: 这是一个对编译器的性能优化提示。它表明这些变量的值很少被写入(通常只在启动时或由管理员通过sysctl修改),但会被非常频繁地读取(在每次相关的文件系统操作路径上)。编译器可以据此将这些变量放置在更优化的内存区域(如只读数据段),并可能在代码生成时做出更积极的优化。
  2. Sysctl表 (namei_sysctls):

    • namei_sysctls数组精确地定义了每个sysctl接口的属性。
    • 每个条目都将一个.procname(文件名)与一个.data(对应的策略变量)绑定。
    • .proc_handler = proc_dointvec_minmax: 所有条目都使用了这个标准的、带范围检查的整数处理函数。
    • .extra1 = SYSCTL_ZERO, .extra2 = SYSCTL_ONE or SYSCTL_TWO: 这些字段为处理函数提供了参数,将用户可以写入的值严格限制在一个小范围内(通常是0表示关闭,1表示开启/基本保护,2表示更强的保护)。这防止了用户写入无效的配置值,增强了健壮性。
  3. 注册与初始化:

    • init_fs_namei_sysctls函数通过register_sysctl_init("fs", namei_sysctls)将整个配置表注册到”fs”命名空间下,从而在/proc/sys/fs/目录中创建出对应的文件。
    • fs_initcall确保了这个初始化过程在内核启动期间、在VFS子系统准备就绪之后被执行。

代码分析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
// 定义用于控制符号链接保护策略的变量。0=关闭, 1=开启。
static int sysctl_protected_symlinks __read_mostly;
// 定义用于控制硬链接保护策略的变量。0=关闭, 1=开启。
static int sysctl_protected_hardlinks __read_mostly;
// 定义用于控制FIFO(命名管道)保护策略的变量。0=关闭, 1=基本保护, 2=增强保护。
static int sysctl_protected_fifos __read_mostly;
// 定义用于控制常规文件保护策略的变量。0=关闭, 1=基本保护, 2=增强保护。
static int sysctl_protected_regular __read_mostly;

#ifdef CONFIG_SYSCTL
// 定义在 /proc/sys/fs/ 目录下的sysctl条目表。
static const struct ctl_table namei_sysctls[] = {
{
.procname = "protected_symlinks", // 文件名
.data = &sysctl_protected_symlinks, // 关联到对应的策略变量
.maxlen = sizeof(int),
.mode = 0644, // 权限: 可读写
.proc_handler = proc_dointvec_minmax, // 使用带范围检查的整数handler
.extra1 = SYSCTL_ZERO, // 允许的最小值为0
.extra2 = SYSCTL_ONE, // 允许的最大值为1
},
{
.procname = "protected_hardlinks",
.data = &sysctl_protected_hardlinks,
.maxlen = sizeof(int),
.mode = 0644,
.proc_handler = proc_dointvec_minmax,
.extra1 = SYSCTL_ZERO,
.extra2 = SYSCTL_ONE,
},
{
.procname = "protected_fifos",
.data = &sysctl_protected_fifos,
.maxlen = sizeof(int),
.mode = 0644,
.proc_handler = proc_dointvec_minmax,
.extra1 = SYSCTL_ZERO,
.extra2 = SYSCTL_TWO, // 允许的值范围是 0, 1, 2
},
{
.procname = "protected_regular",
.data = &sysctl_protected_regular,
.maxlen = sizeof(int),
.mode = 0644,
.proc_handler = proc_dointvec_minmax,
.extra1 = SYSCTL_ZERO,
.extra2 = SYSCTL_TWO,
},
};

// init_fs_namei_sysctls: 初始化函数,用于注册上述sysctl表。
static int __init init_fs_namei_sysctls(void)
{
register_sysctl_init("fs", namei_sysctls);
return 0;
}
// 将初始化函数注册为fs_initcall。
fs_initcall(init_fs_namei_sysctls);

#endif /* CONFIG_SYSCTL */

VFS路径查找状态机:nameidata结构体

本代码片段定义了Linux虚拟文件系统(VFS)中用于路径名查找(Pathname Traversal/Lookup)的核心数据结构struct nameidata,以及其生命周期管理的辅助函数。nameidata可以被视为一个状态机,它包含了在将一个字符串路径(如”/home/user/file”)解析为最终的dentryinode对象过程中所需的所有上下文信息。内核中几乎所有的文件相关系统调用(open, stat, chmod等)的第一步都是设置并使用一个nameidata实例来执行路径查找。

实现原理分析

VFS的路径查找是一个复杂的过程,需要处理目录遍历、符号链接(symlinks)、挂载点(mount points)、权限检查以及..等特殊组件。nameidata结构体的设计正是为了封装并管理这种复杂性。

  1. 核心状态: path字段是查找过程中的当前位置,它包含了当前的vfsmountdentry。随着查找的进行,path会从根目录(’/‘)逐级深入到路径的下一个组件。
  2. 符号链接处理: 这是nameidata中最复杂的部分。当查找到一个符号链接时,内核必须暂停当前的查找,转而解析符号链接自身指向的路径。
    • stackinternal字段为此提供了支持。stack是一个后进先出(LIFO)的堆栈,用于保存暂停的查找状态。
    • internal数组是一个重要的性能优化:对于路径中前两个(EMBEDDED_LEVELS)符号链接,内核直接使用这个预分配在nameidata结构体内的空间来保存状态,避免了成本较高的kmalloc动态内存分配。只有当符号链接的嵌套深度超过这个值时,才需要动态分配更大的stack
    • depthtotal_link_count字段用于防止符号链接循环(A->B, B->A)和滥用(过长的链接链条)导致的拒绝服务攻击。
  3. 嵌套查找与生命周期:
    • 一个系统调用内部可能需要执行多次独立的路径查找。例如,rename()需要查找源路径和目标路径。saved指针和current->nameidata形成了每个任务(task)的nameidata实例栈。
    • set_nameidata函数是查找的入口:它从当前任务中获取旧的nameidata,将其保存在新实例的saved字段中,然后将current->nameidata指向新实例,从而“压栈”。
    • restore_nameidata是查找的出口:它将current->nameidata恢复为saved中保存的旧实例,并释放当前实例可能动态分配的资源(如stack),实现“出栈”。
  4. 起始点与根目录: dfd字段支持了openat()*at系列系统调用,允许路径查找相对于一个由文件描述符指定的目录开始,而不仅仅是当前工作目录。root字段则支持了chroot环境,可以将查找的根限定在文件系统的某个子树中。

代码分析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
#define EMBEDDED_LEVELS 2
struct nameidata {
struct path path; // 查找过程中的当前路径(dentry 和 vfsmount)。
struct qstr last; // 路径的最后一个分量。
struct path root; // 本次查找的根目录(用于chroot)。
struct inode *inode; // path.dentry.d_inode 的缓存指针。
unsigned int flags, state; // 查找标志(如LOOKUP_FOLLOW)和内部状态(如ND_ROOT_PRESET)。
unsigned seq, next_seq, m_seq, r_seq; // 用于乐观锁(seqlock)的序列号。
int last_type; // last分量的类型(普通、.、..等)。
unsigned depth; // 当前符号链接的递归深度。
int total_link_count; // 本次系统调用已遍历的链接总数。
struct saved {
struct path link;
struct delayed_call done;
const char *name;
unsigned seq;
} *stack, internal[EMBEDDED_LEVELS]; // 用于处理符号链接的状态保存栈。internal是内嵌的、避免动态分配的优化。
struct filename *name; // 指向用户传入的完整文件名结构体。
const char *pathname; // 指向用户传入的路径字符串。
struct nameidata *saved; // 指向当前任务中上一个(嵌套的)nameidata实例。
unsigned root_seq; // 根目录的序列号。
int dfd; // 目录文件描述符,用于 *at() 系统调用。
vfsuid_t dir_vfsuid; // 缓存的父目录用户ID。
umode_t dir_mode; // 缓存的父目录模式。
} __randomize_layout; // 结构体成员布局随机化,一种安全加固措施。

#define ND_ROOT_PRESET 1 // 状态:使用了自定义的根目录。
#define ND_ROOT_GRABBED 2 // 状态:根目录已被引用计数。
#define ND_JUMPED 4 // 状态:查找过程中跨越了挂载点。

// __set_nameidata: 初始化nameidata的核心部分,不处理root。
static void __set_nameidata(struct nameidata *p, int dfd, struct filename *name)
{
// 获取当前任务正在使用的nameidata,以便进行嵌套。
struct nameidata *old = current->nameidata;
// 初始化符号链接栈,首先使用内嵌的数组以优化性能。
p->stack = p->internal;
p->depth = 0;
p->dfd = dfd;
p->name = name;
p->pathname = likely(name) ? name->name : "";
p->path.mnt = NULL;
p->path.dentry = NULL;
// 继承上一个nameidata的链接总数,以确保限制在整个系统调用中有效。
p->total_link_count = old ? old->total_link_count : 0;
// 将新的nameidata链接到旧的上面,形成一个栈。
p->saved = old;
// 激活新的nameidata,使其成为当前任务的上下文。
current->nameidata = p;
}

// set_nameidata: nameidata的完整初始化函数。
static inline void set_nameidata(struct nameidata *p, int dfd, struct filename *name,
const struct path *root)
{
// 调用核心初始化函数。
__set_nameidata(p, dfd, name);
p->state = 0;
// 如果提供了自定义根目录(例如chroot环境),则进行设置。
if (unlikely(root)) {
p->state = ND_ROOT_PRESET;
p->root = *root;
}
}

// restore_nameidata: 恢复上一个nameidata,清理当前实例。
static void restore_nameidata(void)
{
struct nameidata *now = current->nameidata, *old = now->saved;

// 将当前任务的nameidata指针恢复到上一个实例。
current->nameidata = old;
if (old)
// 将本次查找中累计的链接遍历总数传递回给父查找上下文。
old->total_link_count = now->total_link_count;
// 如果符号链接栈使用了动态分配的内存(而非内嵌数组),则释放它。
if (now->stack != now->internal)
kfree(now->stack);
}

文件名获取: 将路径字符串安全地复制到内核中

本代码片段定义了getname_kernel函数,它是VFS层一个至关重要的安全辅助函数。其核心功能是接收一个指向内核空间中路径字符串的const char *指针,并返回一个struct filename *。这个过程的关键在于它创建了一个稳定、内核拥有的路径字符串副本,并将其封装在一个受管理的struct filename结构中。这是防止在复杂、可睡眠的VFS路径查找过程中,原始路径指针失效或其内容被修改所导致的安全漏洞(如use-after-free)的基础。

实现原理分析

getname_kernel的实现兼顾了安全性、性能和资源管理

  1. 安全第一 (Copying): 函数的首要任务是复制filename字符串。直接使用传入的指针是危险的,因为调用getname_kernel的函数后续可能会睡眠(例如,等待I/O)。在此期间,原始的filename指针指向的内存可能会被释放或重用。通过制作一个内核自己管理的副本,VFS确保了在整个路径查找期间,路径字符串的生命周期是可控和安全的。
  2. 性能优化 (Two-Tier Allocation): 为了避免对每个路径名都执行昂贵的kmalloc操作,函数采用了一种两级分配策略,针对短路径名进行了优化:
    • 快速路径 (Embedded Name): 首先,它通过__getname()从一个专用的缓存(通常是slab/slub分配器)中获取一个struct filename对象。这个结构体内部包含了一个名为iname的小字符数组。如果路径长度(len)小于等于这个嵌入数组的大小(EMBEDDED_NAME_MAX),函数就直接将路径字符串复制到iname中。这避免了一次额外的kmalloc调用,极大地提升了处理常见短路径(如/dev/null)的性能。
    • 慢速路径 (External Buffer): 如果路径长度超过了嵌入数组的大小,但仍在系统最大路径长度PATH_MAX之内,函数会采取不同的策略(这段代码中的逻辑较为复杂,但其效果是确保有一个足够大的、独立于struct filename元数据的缓冲区来存储路径字符串)。
    • 错误路径: 如果路径长度超过PATH_MAX,则直接返回-ENAMETOOLONG错误。
  3. 资源管理: getname_kernel的实现与putname(未在此处显示)紧密配对。__getname()获取资源,__putname()释放资源。这种get/put模式是内核中管理对象生命周期的标准做法。

代码分析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49

#define __getname() kmem_cache_alloc(names_cachep, GFP_KERNEL)
#define __putname(name) kmem_cache_free(names_cachep, (void *)(name))


// getname_kernel: 从内核空间获取一个路径名,并将其封装在struct filename中。
// @filename: 指向内核空间中以NUL结尾的路径字符串的指针。
struct filename *getname_kernel(const char * filename)
{
struct filename *result;
// 计算字符串长度,包括结尾的NUL字符。
int len = strlen(filename) + 1;

// 从专用缓存中分配一个struct filename对象。
result = __getname();
if (unlikely(!result))
return ERR_PTR(-ENOMEM); // 如果分配失败,返回内存不足错误。

// 快速路径:如果路径长度足够短,可以放入结构体内部的嵌入式缓冲区。
if (len <= EMBEDDED_NAME_MAX) {
// 直接将name指针指向嵌入式缓冲区iname。
result->name = (char *)result->iname;
} else if (len <= PATH_MAX) { // 慢速路径:路径较长,但仍在合法范围内。
const size_t size = offsetof(struct filename, iname[1]);
struct filename *tmp;

// 这段逻辑较为复杂,其效果是为长字符串分配一个单独的缓冲区,
// 同时可能复用或重新分配元数据结构,以优化内存使用。
tmp = kmalloc(size, GFP_KERNEL);
if (unlikely(!tmp)) {
__putname(result);
return ERR_PTR(-ENOMEM);
}
tmp->name = (char *)result;
result = tmp;
} else { // 错误路径:路径太长。
__putname(result); // 释放之前分配的结构体。
return ERR_PTR(-ENAMETOOLONG);
}
// 将传入的路径字符串拷贝到新分配或嵌入的缓冲区中。
memcpy((char *)result->name, filename, len);
// 初始化struct filename中的其他字段(如引用计数)。
initname(result, NULL);
// 通知审计子系统,一个路径名正在被内核使用。
audit_getname(result);
return result;
}
// 导出符号,使内核模块可以调用此函数。
EXPORT_SYMBOL(getname_kernel);

VFS路径名遍历:从字符串到dentry的核心解析过程

本代码片段是Linux虚拟文件系统(VFS)中最为核心和复杂的部分之一,它负责将一个给定的字符串路径名(例如”/home/user/file”)解析成内核中的dentry(目录条目)对象。这个过程被称为“路径遍历”或“路径行走”(Path Walking)。path_lookupat是这个过程的高层入口,而link_path_walk是执行逐级目录下降和符号链接处理的主力循环。这是所有基于路径的文件操作(open, stat等)的基础。

实现原理分析

您可以将路径遍历想象成一个侦探根据一张写着地址的纸条,在大城市里找一个具体的房间。nameidata结构体就是侦探的笔记本,记录着所有线索和当前位置。

  1. 准备出发 (path_init): 这是侦探出发前的准备工作。

    • 找到起点: 首先要确定从哪里开始。
      • 如果地址以/开头(绝对路径),侦探会从城市的中心广场(进程的根目录 current->fs->root)出发。
      • 如果地址不是以/开头,并且没有指定特别的出发点(dfdAT_FDCWD相对路径),侦探会从自己当前所在的位置(进程的当前工作目录 current->fs->pwd)出发。
      • 如果给了一个特定的门牌号(dfd是一个有效的文件描述符,用于*at()系统调用),侦探会先去这个门牌号代表的建筑物,从那里开始按纸条上的地址寻找。
    • 检查天气和交通: 在出发前,侦探会看一下“交通状况公告牌”。这就是__read_seqcount_begin等函数的作用。它们记录下当前文件系统的一些关键“版本号”(序列号)。如果在寻路途中,发现这些版本号变了(比如有道路被rename或有区域被umount),侦探就知道自己的地图可能过时了,需要重新确认。
    • 选择交通工具: 根据LOOKUP_RCU标志,侦探会选择不同的寻路模式。RCU模式就像是“快速扫视”,速度快,但看的不是最精确的实时画面;非RCU模式则像是“仔细勘察”(使用引用计数path_get),确保每一步都稳固可靠。
  2. 按图索骥 (link_path_walk): 这是侦探在城市街道中穿梭的主要过程。

    • 一站一站地走: 循环会以/为标志,把地址拆分成一站一站(例如”home”, “user”, “file”)。
    • 出示证件: 每到一个新的建筑物(目录),侦探都需要出示证件(may_lookup函数),检查自己是否有权限进入。
    • 寻找下一站: walk_component函数是侦探的核心技能,它在当前建筑物(目录)里,根据下一站的名字(如”user”),找到通往下一个建筑物的门。
    • 处理意外情况——绕路指示牌 (符号链接): 这是最复杂的部分。如果侦探发现门上贴着一张“绕路指示牌”(符号链接),上面写着另一个地址。
      • 他会拿出笔记本(nd->stack),记下当前的位置和还剩下没走的路线。
      • 然后,他会把绕路指示牌上的新地址当作当前的目标,重新开始“按图索骥”的过程。
      • 当他走完绕路指示牌上的路线后,他会翻开笔记本,回到之前记录的地方,继续走完原来剩下的路。depth变量就是记录他进入了多少层绕路。
  3. 到达终点 (lookup_last, complete_walk):

    • link_path_walk走完纸条上所有的路段后,它就到达了目标建筑物的门口。
    • lookup_last函数负责推开最后一扇门,进入目标房间(最终的dentry)。
    • complete_walk函数进行最后的确认和整理工作。
    • 最终,path_lookupat这个总指挥,将侦探找到的最终房间信息(path)报告给调用者。

代码分析

这是路径遍历的核心引擎,一个处理路径各组成部分的循环。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
static int link_path_walk(const char *name, struct nameidata *nd)
{
int depth = 0; // 用来追踪符号链接的嵌套深度,防止无限循环。
int err;

nd->last_type = LAST_ROOT; // 初始时,我们认为“上一个”分量是根目录。
nd->flags |= LOOKUP_PARENT; // 设置一个标志,表示我们正在查找目标的父目录。
if (IS_ERR(name)) // 检查传入的路径字符串是否是一个错误指针。
return PTR_ERR(name);
if (*name == '/') { // 如果路径以'/'开始(绝对路径)。
do {
name++; // 跳过这个'/'以及所有连续的'/'。
} while (unlikely(*name == '/'));
}
if (unlikely(!*name)) { // 如果路径在跳过'/'后为空(例如路径就是"/"或"//")。
nd->dir_mode = 0; // 一个小处理,避免不必要的检查。
return 0; // 查找成功,结果就是起点。
}

/* 进入主循环,此时我们确定路径至少有一个有效的分量。 */
for(;;) {
struct mnt_idmap *idmap;
const char *link;
unsigned long lastword;

idmap = mnt_idmap(nd->path.mnt);
// 在深入查找前,检查当前进程是否有权限“执行”或“搜索”当前目录。
err = may_lookup(idmap, nd);
if (unlikely(err))
return err;

nd->last.name = name; // 记录当前分量的起始地址。
// 从name中解析出下一个分量(到'/'或'\0'为止),计算其哈希值。
// name指针会被更新,指向路径的剩余部分。
// lastword是一个优化,通过分量的最后几个字符快速判断是否为"."或".."。
name = hash_name(nd, name, &lastword);

// 根据分量类型("."、".."或普通文件名)进行处理。
switch(lastword) {
case LAST_WORD_IS_DOTDOT: // 如果是 ".."
nd->last_type = LAST_DOTDOT;
nd->state |= ND_JUMPED; // 标记我们进行了一次向上或跨越挂载点的跳转。
break;

case LAST_WORD_IS_DOT: // 如果是 "."
nd->last_type = LAST_DOT;
break;

default: // 普通文件名
nd->last_type = LAST_NORM;
nd->state &= ~ND_JUMPED; // 清除跳转标记。

// 这是一个特殊情况,用于支持某些需要自定义哈希和比较操作的文件系统。
struct dentry *parent = nd->path.dentry;
if (unlikely(parent->d_flags & DCACHE_OP_HASH)) {
err = parent->d_op->d_hash(parent, &nd->last);
if (err < 0)
return err;
}
}

// 如果name指向字符串末尾'\0',说明已处理完最后一个分量。
if (!*name)
goto OK;

// 跳过分量后的'/'分隔符以及所有连续的'/'。
do {
name++;
} while (unlikely(*name == '/'));
// 如果斜杠之后再无字符(例如 "/home/user/"),也视为到达路径末尾。
if (unlikely(!*name)) {
OK:
/* 到达路径末尾。 */
if (!depth) { // 如果当前不在解析符号链接的过程中。
nd->dir_vfsuid = i_uid_into_vfsuid(idmap, nd->inode);
nd->dir_mode = nd->inode->i_mode;
nd->flags &= ~LOOKUP_PARENT; // 目标已找到,清除“查找父目录”标志。
return 0; // 遍历成功,返回。
}
/* 如果在解析符号链接,说明这个符号链接的路径已走完。 */
// 从栈中弹出原始路径的剩余部分,准备继续解析。
name = nd->stack[--depth].name;
// 再次调用walk_component,这次是在符号链接解析后的新位置上。
link = walk_component(nd, 0);
} else {
/* 还不是最后一个分量,调用walk_component继续下降到下一级目录。 */
link = walk_component(nd, WALK_MORE);
}
// 检查walk_component的返回值,判断是否遇到了符号链接。
if (unlikely(link)) {
if (IS_ERR(link))
return PTR_ERR(link);
/* 发现一个符号链接需要跟随。 */
// 将当前路径的剩余部分压入栈中保存。
nd->stack[depth++].name = name;
// 将name指针指向符号链接的目标路径字符串。
name = link;
// 继续主循环,开始解析这个新的(符号链接的)路径。
continue;
}
// 如果walk_component失败了(例如,当前nd->path.dentry不是一个目录)。
if (unlikely(!d_can_lookup(nd->path.dentry))) {
if (nd->flags & LOOKUP_RCU) {
if (!try_to_unlazy(nd)) // 在RCU模式下,尝试修复一个可能“懒加载”的dentry。
return -ECHILD;
}
return -ENOTDIR; // 返回“不是一个目录”的错误。
}
}
}

path_init 函数

路径查找的准备和初始化函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
static const char *path_init(struct nameidata *nd, unsigned flags)
{
int error;
const char *s = nd->pathname; // 获取要解析的完整路径字符串。

/* 如果只设置了LOOKUP_CACHED而没有设置LOOKUP_RCU,这是无效组合,要求调用者重试。 */
if ((flags & (LOOKUP_RCU | LOOKUP_CACHED)) == LOOKUP_CACHED)
return ERR_PTR(-EAGAIN);

if (!*s) // 如果路径是空字符串,则不能使用RCU模式。
flags &= ~LOOKUP_RCU;
if (flags & LOOKUP_RCU)
rcu_read_lock(); // 进入RCU读临界区。
else
nd->seq = nd->next_seq = 0; // 非RCU模式,不使用序列号。

nd->flags = flags;
nd->state |= ND_JUMPED; // 初始化状态。

// 读取mount和rename操作的全局序列号,用于后续检查一致性。
nd->m_seq = __read_seqcount_begin(&mount_lock.seqcount);
nd->r_seq = __read_seqcount_begin(&rename_lock.seqcount);
smp_rmb(); // 内存屏障,确保序列号读取发生在后续操作之前。

// CASE 1: 已经预设了根目录 (例如chroot环境)。
if (nd->state & ND_ROOT_PRESET) {
struct dentry *root = nd->root.dentry;
struct inode *inode = root->d_inode;
if (*s && unlikely(!d_can_lookup(root))) // 如果根不是目录,则出错。
return ERR_PTR(-ENOTDIR);
nd->path = nd->root; // 起点就是预设的根。
nd->inode = inode;
if (flags & LOOKUP_RCU) {
nd->seq = read_seqcount_begin(&nd->path.dentry->d_seq);
nd->root_seq = nd->seq;
} else {
path_get(&nd->path); // 增加起点路径的引用计数。
}
return s;
}

nd->root.mnt = NULL;

// CASE 2: 绝对路径 (以'/'开头)。
if (*s == '/' && !(flags & LOOKUP_IN_ROOT)) {
error = nd_jump_root(nd); // 获取当前进程的根目录作为起点。
if (unlikely(error))
return ERR_PTR(error);
return s;
}

// CASE 3: 相对路径 (不以'/'开头)。
if (nd->dfd == AT_FDCWD) { // 相对于当前工作目录。
if (flags & LOOKUP_RCU) {
// 在RCU模式下,需要在一个循环里读取,以防止current->fs->pwd在读取时被修改。
// ...
} else {
get_fs_pwd(current->fs, &nd->path); // 获取当前工作目录作为起点。
nd->inode = nd->path.dentry->d_inode;
}
} else { // 相对于一个文件描述符(dfd)代表的目录。
// ... [代码通过fd找到对应的file结构,再找到其path]
nd->path = fd_file(f)->f_path;
if (flags & LOOKUP_RCU) {
// ...
} else {
path_get(&nd->path); // 增加起点路径的引用计数。
nd->inode = nd->path.dentry->d_inode;
}
}

// ... [处理 LOOKUP_IS_SCOPED 的逻辑]
return s; // 返回路径字符串,准备给link_path_walk使用。
}

lookup_last, handle_lookup_down, path_lookupat

这些是上层控制和特殊情况处理函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
// lookup_last: 处理路径的最后一个分量。
static inline const char *lookup_last(struct nameidata *nd)
{
// 如果最后一个分量是普通文件名,且路径后面没有'/'
// 那么我们可能需要跟随符号链接(如果设置了LOOKUP_FOLLOW)
// 或者确认它是一个目录(如果设置了LOOKUP_DIRECTORY)。
if (nd->last_type == LAST_NORM && nd->last.name[nd->last.len])
nd->flags |= LOOKUP_FOLLOW | LOOKUP_DIRECTORY;

// 调用walk_component来处理这最后一个分量。
return walk_component(nd, WALK_TRAILING);
}

// handle_lookup_down: 处理LOOKUP_DOWN标志,通常用于查找挂载点。
static int handle_lookup_down(struct nameidata *nd)
{
if (!(nd->flags & LOOKUP_RCU))
dget(nd->path.dentry); // 增加dentry引用计数。
nd->next_seq = nd->seq;
// step_into会尝试“下沉”到当前dentry所挂载的文件系统的根。
return PTR_ERR(step_into(nd, WALK_NOFOLLOW, nd->path.dentry));
}

// path_lookupat: 路径查找的顶层入口函数,负责指挥整个流程。
static int path_lookupat(struct nameidata *nd, unsigned flags, struct path *path)
{
// 1. 调用path_init进行准备,获取起点和路径字符串。
const char *s = path_init(nd, flags);
int err;

// 特殊情况:处理LOOKUP_DOWN标志。
if (unlikely(flags & LOOKUP_DOWN) && !IS_ERR(s)) {
err = handle_lookup_down(nd);
if (unlikely(err < 0))
s = ERR_PTR(err);
}

// 2. 循环调用link_path_walk(核心引擎)和lookup_last(处理末尾)。
// 只要link_path_walk成功,并且lookup_last返回一个需要继续处理的符号链接路径,循环就继续。
while (!(err = link_path_walk(s, nd)) &&
(s = lookup_last(nd)) != NULL)
;

// 3. 循环结束后,进行一些收尾工作。
if (!err && unlikely(nd->flags & LOOKUP_MOUNTPOINT)) {
// ... 处理挂载点
err = handle_lookup_down(nd);
nd->state &= ~ND_JUMPED; // no d_weak_revalidate(), please...
}
if (!err)
err = complete_walk(nd); // 最终完成和验证。

// 4. 检查最终结果是否满足要求(例如,如果要求是目录)。
if (!err && nd->flags & LOOKUP_DIRECTORY)
if (!d_can_lookup(nd->path.dentry))
err = -ENOTDIR;

// 5. 如果一切成功,将最终找到的路径(nd->path)复制到输出参数path中。
if (!err) {
*path = nd->path;
// 清空nd->path,防止其资源被错误地释放两次。
nd->path.mnt = NULL;
nd->path.dentry = NULL;
}

// 6. 调用terminate_walk,释放所有在查找过程中获取的资源(如引用计数、RCU锁等)。
terminate_walk(nd);
return err;
}

VFS匿名文件创建:在文件系统中创建无链接的临时文件

本代码片段实现了vfs_tmpfile函数,它是Linux内核openat(..., O_TMPFILE)系统调用的核心实现。它的功能是在指定的目录中创建一个匿名的无链接的临时文件。

您可以将它想象成这样一种操作:你在一个文件夹里变魔术,凭空创造出了一个文件。这个文件真实存在,你可以往里面写东西,也可以从里面读东西,但是你在文件夹里却看不到它的名字。因为它从被创造出来的那一刻起,就没有名字链接指向它。这个文件会一直存在,直到你关闭它(最后一个指向它的文件描述符被关闭),届时它就会彻底消失。

这个功能非常有用,它可以原子地(一步完成)替代传统的“先open一个带名字的文件,再立即unlink删除它的名字”的操作模式,从而避免了在创建和删除的短暂瞬间,文件在文件系统中可见,可能导致安全或竞争问题。

实现原理分析

vfs_tmpfile的实现流程是一个精心设计的、VFS层与具体文件系统层协作的过程:

  1. 权限检查 (守卫): 在做任何事情之前,内核必须确保你有权在目标目录(parentpath)里进行创造。inode_permission(..., MAY_WRITE | MAY_EXEC)函数扮演了这个守卫的角色,它检查当前进程是否对该目录同时拥有“写权限”(创建文件所需)和“执行权限”(进入目录所需)。
  2. 能力查询 (询问): 并不是所有的文件系统都支持“凭空造物”这个魔法。所以,VFS会礼貌地询问目录所属的文件系统:“您好,请问您是否支持tmpfile操作?”。这就是if (!dir->i_op->tmpfile)这行代码的作用。如果文件系统(例如ext4, xfs)的inode_operations表里没有提供tmpfile这个函数指针,VFS就会礼貌地拒绝请求,返回-EOPNOTSUPP(操作不支持)。
  3. 创建临时占位符 (dentry): 即使文件是匿名的,在VFS内部,它仍然需要一个临时的“身份牌”来完成创建过程。d_alloc(parentpath->dentry, &slash_name)函数就是创建这个临时的身份牌——一个dentry对象。这个dentry是匿名的(它的名字是一个特殊的slash_name),并且是父目录的一个“假”子节点。
  4. 施展魔法 (委托给文件系统): 这是最关键的一步。VFS将所有准备好的材料(空的file结构、父目录inode、文件模式mode等)打包好,然后调用具体文件系统的->tmpfile()方法,说:“好了,请您施法,创造一个inode吧!”。具体的文件系统(如ext4)会在此时真正在磁盘上分配一个inode和相关的数据块,并将这个新创建的inode与我们传入的file结构关联起来。
  5. 销毁临时占位符: 魔法施展完毕,inode已经成功创建并与file结构绑定。那个临时的dentry占位符已经完成了它的历史使命。dput(child)函数会释放掉这个临时的dentry。此时,新创建的inode就没有任何dentry从文件系统目录树中指向它了,它成为了一个真正的“孤儿”inode,只能通过我们手里的file结构来访问。
  6. 后续处理和状态设置:
    • 通知系统: fsnotify_open(file)会告诉内核的事件通知系统(inotify等),“嘿,有一个新文件被打开了!”。
    • 二次权限检查: may_open进行一次额外的安全检查。虽然文件是我们刚创建的,但某些高级安全模块(LSM,如SELinux)可能有更复杂的规则,比如“不允许在这个目录下创建并打开可执行文件”。
    • 设置可链接状态: 这是一个非常重要的细节。默认情况下,O_TMPFILE创建的文件是无法被链接的。但是,如果用户在打开时没有指定O_EXCL标志,就意味着他可能希望稍后能通过linkat()系统调用给这个匿名文件起一个真正的名字,让它“转正”。inode->i_state |= I_LINKABLE;这行代码就是为此设置一个状态,允许后续的链接操作。

代码分析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
/**
* vfs_tmpfile - 创建一个临时文件
* @idmap: ... (用于处理ID映射挂载)
* @parentpath: 指向父目录的路径结构体。
* @file: 一个空的file结构体,函数成功后会填充它。
* @mode: 新文件的模式(权限)。
*/
int vfs_tmpfile(struct mnt_idmap *idmap,
const struct path *parentpath,
struct file *file, umode_t mode)
{
struct dentry *child;
struct inode *dir = d_inode(parentpath->dentry); // 获取父目录的inode。
struct inode *inode;
int error;
int open_flag = file->f_flags; // 备份一下原始的打开标志。

/* 步骤1: 我们需要确保对父目录有写入和进入的权限。 */
error = inode_permission(idmap, dir, MAY_WRITE | MAY_EXEC);
if (error)
return error; // 如果没权限,直接返回错误。

/* 步骤2: 检查底层文件系统是否支持tmpfile操作。 */
if (!dir->i_op->tmpfile)
return -EOPNOTSUPP; // 如果不支持,返回“操作不支持”错误。

/* 步骤3: 创建一个临时的、匿名的dentry作为占位符。 */
child = d_alloc(parentpath->dentry, &slash_name);
if (unlikely(!child))
return -ENOMEM; // 如果内存不足,返回错误。

// 将空的file结构与父目录的挂载点和这个临时dentry关联起来。
file->f_path.mnt = parentpath->mnt;
file->f_path.dentry = child;

// 准备最终的文件模式,这会考虑umask等因素。
mode = vfs_prepare_mode(idmap, dir, mode, mode, mode);

/* 步骤4: 调用具体文件系统的 ->tmpfile 方法,这是实际创建inode的地方。 */
// 这个函数会创建inode,并将其与 file->f_path.dentry 关联。
error = dir->i_op->tmpfile(idmap, dir, file, mode);

/* 步骤5: 临时dentry的使命完成,释放它。 */
dput(child);

// 如果文件系统的tmpfile方法设置了FMODE_OPENED标志,就发送一个打开通知。
if (file->f_mode & FMODE_OPENED)
fsnotify_open(file);

if (error)
return error; // 如果文件系统创建失败,返回错误。

/*
* 步骤6.1: 进行额外的安全检查。
* 因为inode是刚刚创建的,我们不需要检查常规的读写权限,
* 但需要通过LSM(如SELinux)的钩子函数进行检查。
*/
error = may_open(idmap, &file->f_path, 0, file->f_flags);
if (error)
return error;

inode = file_inode(file); // 获取新创建的inode。

/*
* 步骤6.2: 设置inode的可链接状态。
* 如果用户打开时没有指定 O_EXCL,意味着他们保留了稍后给这个文件
* “命名转正”的权利。
*/
if (!(open_flag & O_EXCL)) {
spin_lock(&inode->i_lock);
inode->i_state |= I_LINKABLE; // 设置I_LINKABLE标志。
spin_unlock(&inode->i_lock);
}

// 调用安全模块的最后一个钩子函数。
security_inode_post_create_tmpfile(idmap, inode);

return 0; // 一切顺利,返回成功。
}

VFS 文件打开的核心:原子性地查找、创建与打开

本代码片段是open()系统调用在路径查找(path walk)到达父目录之后,处理最后一个路径组件(文件名)的完整逻辑。它的核心目标是原子性地完成“查找,如果不存在就创建,然后打开”这一系列操作。原子性在这里至关重要,它能防止多个进程同时尝试创建同一个文件时引发的竞争条件(race condition)。

这个过程可以比喻为一个在高档餐厅预订座位的流程:

  1. 快速查询 (lookup_fast_for_open): 您告诉前台经理(open_last_lookups)您想要一个叫“张三”的预留座位。经理会先快速地瞥一眼预订系统(dentry缓存),看看“张三”这个名字是否已经存在。如果存在,就直接告诉您座位在哪,流程很快。
  2. 详细处理 (lookup_open): 如果快速查询找不到,或者情况比较复杂(例如您说“如果没有就给我创建一个”),经理就需要进入一个更详细的处理流程。
    • 上锁: 经理会先把整个预订本锁起来(inode_lock),防止在他处理您的请求时,其他同事也来修改预订信息。
    • 再次查询: 在锁定的情况下,他会再次仔细查找“张三”(d_lookup)。
    • 创建新预订: 如果还是找不到,并且您要求创建,他就会在预订本上写下“张三”这个新条目(dir_inode->i_op->create)。
    • 特殊 VIP 通道 (atomic_open): 如果这家餐厅(文件系统)非常高级,它会提供一个atomic_open的VIP服务。经理会把您的所有需求(“查找张三,没有就创建,然后直接带我入座”)一次性告诉后台总管。后台总管(文件系统的->atomic_open方法)会原子地完成所有事情,效率极高。
  3. 最终确认和入座 (do_open): 无论是通过哪个流程找到了或创建了座位,最后一步是带您入座。
    • 检查权限: 服务员(do_open)会最后确认一下您的会员等级是否足够坐这个位置(may_open)。
    • 清空桌子: 如果您的预订要求是“清空桌子”(O_TRUNC),服务员会先把桌上的东西全部清掉(handle_truncate)。
    • 正式入座: 最后,服务员会帮您拉开椅子,完成最后的招待服务(vfs_open)。

实现原理分析

这个过程的实现是VFS层、dentry缓存层和具体文件系统层之间高度协作的结果。

  1. Dentry缓存与并行查找:

    • lookup_open首先调用d_lookup在dentry缓存中查找。如果找到,流程就变得简单。
    • 如果没找到,d_alloc_parallel会尝试创建一个“查找中”(d_in_lookup)状态的dentry。这是一个非常精妙的无锁/少锁优化。它允许其他同样在查找这个dentry的进程在一个等待队列(wq)上等待,而由第一个进程去执行真正的查找(dir_inode->i_op->lookup)。这避免了多个进程为同一个不存在的文件反复查询磁盘,极大地提升了性能。
  2. 原子打开 (atomic_open):

    • 这是现代文件系统(如ext4, xfs)提供的一个高级特性。VFS层会优先使用它。
    • VFS将查找、创建、打开的所有意图(open_flag, mode)一次性传递给文件系统的->atomic_open方法。
    • 文件系统层自己负责加锁,并在锁保护下完成“检查文件是否存在 -> 如果不存在则创建 -> 打开文件”的所有步骤,然后返回一个已经完全打开的file对象。这从根本上消除了VFS层和文件系统层之间多步操作可能产生的竞争窗口。
  3. 传统路径 (非 atomic_open):

    • 如果文件系统不支持atomic_open,VFS就必须自己协调这个过程。
    • 加锁: VFS必须在父目录的inode上加锁(inode_lock),这是保证原子性的关键。
    • 查找: 在锁保护下,再次调用->lookup查找dentry。
    • 创建: 如果lookup返回的是一个负dentry(negative dentry,表示文件不存在),并且用户指定了O_CREAT,VFS就会调用文件系统的->create方法来创建文件(inode)。
    • 解锁: 完成查找/创建后,释放inode锁。
    • 打开: 创建完成后,流程会汇合到do_open,在这里再执行打开操作。
  4. 写权限与错误处理的复杂性:

    • lookup_open中有一段非常复杂的逻辑来处理写权限(got_write)和错误码。
    • 问题: VFS不知道文件是否真的需要被创建。如果文件已经存在,那么open操作可能不需要写权限。但如果文件不存在,create操作就需要写权限。VFS不能过早地因为没有写权限而失败,否则对于一个已存在的文件的只读打开也会失败。
    • 解决方案: VFS会先尝试获取写权限。如果失败,它并不会立即返回错误,而是记录下这个失败(create_error = -EROFS),然后继续尝试查找。只有当文件确实不存在并且需要创建时,这个预先记录的错误才会被作为最终的返回值。这确保了返回给用户的错误码是最准确的。

代码分析 (节选关键函数)

atomic_open 函数

委托给支持此功能的文件系统来原子性地完成所有事情。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
static struct dentry *atomic_open(struct nameidata *nd, struct dentry *dentry,
struct file *file,
int open_flag, umode_t mode)
{
// ...
struct inode *dir = nd->path.dentry->d_inode;
int error;

// ...

// 核心调用:将所有意图一次性传递给文件系统的 ->atomic_open 方法。
error = dir->i_op->atomic_open(dir, dentry, file,
open_to_namei_flags(open_flag), mode);
d_lookup_done(dentry); // 标记并行查找已完成。
if (!error) {
// 根据文件系统返回的结果(是否已完全打开),处理dentry的引用。
if (file->f_mode & FMODE_OPENED) {
// 如果文件系统完全打开了文件,它可能会返回一个新的dentry。
// 我们需要确保我们最终使用的是正确的dentry。
if (unlikely(dentry != file->f_path.dentry)) {
dput(dentry);
dentry = dget(file->f_path.dentry);
}
} // ... [处理其他情况]
}
// ... [错误处理]
return dentry;
}

lookup_open 函数

处理最后一个组件的查找、创建的核心协调逻辑。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
static struct dentry *lookup_open(struct nameidata *nd, struct file *file,
const struct open_flags *op,
bool got_write)
{
// ... [初始化]

// 步骤1: 在dentry缓存中查找(快速路径)。
dentry = d_lookup(dir, &nd->last);

// 步骤2: 处理并行查找和dentry验证。
for (;;) {
if (!dentry) { // 缓存中没有
// 分配一个“查找中”的dentry,其他查找者可以等待。
dentry = d_alloc_parallel(dir, &nd->last, &wq);
// ...
}
if (d_in_lookup(dentry)) // 如果我们是第一个创建它的,就开始查找。
break;
// 如果dentry已存在,验证它是否仍然有效。
error = d_revalidate(dir_inode, &nd->last, dentry, nd->flags);
if (likely(error > 0))
break; // 有效,跳出循环。
// ... [无效则释放并重试]
}

// 如果dentry是阳性的(positive,即文件已存在),直接返回,上层会处理打开。
if (dentry->d_inode) {
return dentry;
}

// ... [复杂的创建权限检查和错误处理逻辑] ...

// 步骤3: 优先使用atomic_open
if (dir_inode->i_op->atomic_open) {
return atomic_open(nd, dentry, file, open_flag, mode);
}

// 步骤4: 传统路径
if (d_in_lookup(dentry)) {
// 调用文件系统的 ->lookup 方法,进行真正的磁盘查找。
struct dentry *res = dir_inode->i_op->lookup(dir_inode, dentry,
nd->flags);
d_lookup_done(dentry); // 查找完成,唤醒等待者。
// ... [处理lookup结果]
}

// 如果dentry是阴性的(negative,文件不存在)并且需要创建。
if (!dentry->d_inode && (open_flag & O_CREAT)) {
file->f_mode |= FMODE_CREATED; // 标记文件是新创建的。
// ...
// 调用文件系统的 ->create 方法来创建inode。
error = dir_inode->i_op->create(idmap, dir_inode, dentry,
mode, open_flag & O_EXCL);
// ...
}
// ... [错误处理]
return dentry;
}

do_open 函数

在所有查找和创建都完成后,执行最终的打开和状态设置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
static int do_open(struct nameidata *nd,
struct file *file, const struct open_flags *op)
{
// ... [初始化和前期检查] ...

// 关键检查:如果O_EXCL和O_CREAT同时指定,但文件不是新创建的,
// 说明文件已存在,返回 EEXIST 错误。这是原子创建的核心保证。
if ((open_flag & O_EXCL) && !(file->f_mode & FMODE_CREATED))
return -EEXIST;

// ... [其他检查,如不能打开目录用于写入等] ...

// 处理O_TRUNC标志
if (d_is_reg(nd->path.dentry) && open_flag & O_TRUNC) {
error = mnt_want_write(nd->path.mnt); // 截断需要写权限。
if (error)
return error;
do_truncate = true;
}

// 最终的权限检查。
error = may_open(idmap, &nd->path, acc_mode, open_flag);

// 如果文件不是由atomic_open或create打开的,现在就调用vfs_open来完成打开。
if (!error && !(file->f_mode & FMODE_OPENED))
error = vfs_open(&nd->path, file);

// ... [安全模块钩子] ...

// 如果需要,执行截断操作。
if (!error && do_truncate)
error = handle_truncate(idmap, file);

// ... [释放写权限] ...
return error;
}

VFS路径查找与文件打开核心:内核open的执行引擎

本代码片段是Linux VFS层最核心的部分之一,它实现了内核空间open操作的完整执行流程。do_filp_open作为filp_open的下一层实现,是整个过程的入口。它负责初始化路径查找,并通过path_openat驱动整个过程。path_openat则是一个分发器,它处理了三种主要的打开场景:常规文件打开(最复杂的路径)、O_PATH模式下的“仅查找”打开、以及__O_TMPFILE模式下的匿名文件创建。这是将一个路径字符串和一组标志,最终转化为一个可用的struct file指针的底层引擎。

实现原理分析

该功能的实现是一个精心设计的、健壮的、且为性能高度优化的流程,它处理了文件系统操作中的大量复杂性和边缘情况。

  1. Orchestrator (do_filp_open):
    • RCU-based Optimistic Lookup: do_filp_open首先尝试使用LOOKUP_RCU标志进行路径查找。这是一种乐观的、无锁的查找模式,它利用RCU机制来遍历dentry缓存。在绝大多数情况下(当路径中的组件没有被重命名或删除时),这种方式非常快,因为它避免了获取任何锁。
    • Fallbacks: 如果RCU查找失败(返回-ECHILD,表示在查找过程中检测到了路径变更),它会回退到一次常规的、基于锁的查找。如果查找因为缓存陈旧而失败(返回-ESTALE,常见于网络文件系统),它会再次回退,并使用LOOKUP_REVAL标志强制重新验证路径中的每一个组件。这种“乐观-回退”模式是内核中一个经典的性能优化策略。
  2. Main Worker (path_openat):
    • 文件对象分配: 它首先通过alloc_empty_file分配一个struct file对象。这是最终要返回的对象。
    • 分发: 接下来,它根据打开标志分发到三个不同的处理路径:
      • do_tmpfile: 处理__O_TMPFILE。这是一个专门的路径,用于在指定的目录中创建一个没有硬链接的、匿名的文件。它首先查找父目录,然后调用vfs_tmpfile在文件系统层面创建这个特殊的inode。
      • do_o_path: 处理O_PATH。这是一个轻量级的路径,它只执行路径查找,并将找到的path对象关联到file结构,但不执行完整的打开操作(如调用文件系统的->open方法或进行权限检查)。
      • 常规路径: 这是最复杂的路径。它通过一个while循环调用link_path_walk来逐分量地“行走”在文件系统路径上。link_path_walk负责处理目录查找、权限检查、符号链接解析等所有复杂逻辑。当路径走到最后一个分量时,do_open(未显示)被调用,它负责处理文件的创建(O_CREAT)、截断(O_TRUNC)等最终操作,并最终调用底层文件系统的->open()方法。
  3. Error Handling: path_openat在出错时会负责清理已分配的file对象,并转换特定的内部错误码(如-EOPENSTALE)为用户空间可见的错误码。

代码分析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
// do_tmpfile: 处理O_TMPFILE标志的专门函数。
static int do_tmpfile(struct nameidata *nd, unsigned flags,
const struct open_flags *op,
struct file *file)
{
struct path path;
// 首先,查找要创建临时文件的父目录。
int error = path_lookupat(nd, flags | LOOKUP_DIRECTORY, &path);

if (unlikely(error))
return error;
// 获取对挂载点的写权限,因为要创建inode。
error = mnt_want_write(path.mnt);
if (unlikely(error))
goto out;
// 调用VFS核心函数,在父目录(path)中创建一个匿名的临时文件。
error = vfs_tmpfile(mnt_idmap(path.mnt), &path, file, op->mode);
if (error)
goto out2;
audit_inode(nd->name, file->f_path.dentry, 0); // 审计日志
out2:
mnt_drop_write(path.mnt); // 释放挂载点写权限。
out:
path_put(&path); // 释放对父目录的路径引用。
return error;
}

// do_o_path: 处理O_PATH标志的专门函数。
static int do_o_path(struct nameidata *nd, unsigned flags, struct file *file)
{
struct path path;
// 执行完整的路径查找,但不进行权限检查或打开操作。
int error = path_lookupat(nd, flags, &path);
if (!error) {
audit_inode(nd->name, path.dentry, 0);
// 调用一个轻量级的VFS open,仅将路径和文件对象关联起来。
error = vfs_open(&path, file);
path_put(&path);
}
return error;
}

// path_openat: 路径查找和文件打开的核心工作函数。
static struct file *path_openat(struct nameidata *nd,
const struct open_flags *op, unsigned flags)
{
struct file *file;
int error;

// 1. 分配一个空的file结构体。
file = alloc_empty_file(op->open_flag, current_cred());
if (IS_ERR(file))
return file;

// 2. 根据标志分发到不同的处理路径。
if (unlikely(file->f_flags & __O_TMPFILE)) {
error = do_tmpfile(nd, flags, op, file);
} else if (unlikely(file->f_flags & O_PATH)) {
error = do_o_path(nd, flags, file);
} else {
// 3. 常规文件打开路径。
const char *s = path_init(nd, flags);
// 核心的路径行走循环。
while (!(error = link_path_walk(s, nd)) &&
(s = open_last_lookups(nd, file, op)) != NULL)
;
if (!error)
// 路径行走成功后,执行最后的打开/创建操作。
error = do_open(nd, file, op);
terminate_walk(nd); // 清理路径行走状态。
}

// 4. 错误处理和返回。
if (likely(!error)) {
if (likely(file->f_mode & FMODE_OPENED))
return file; // 成功,返回文件指针。
WARN_ON(1);
error = -EINVAL;
}
fput_close(file); // 出错则释放已分配的file结构。
// ... (错误码转换) ...
return ERR_PTR(error);
}

// do_filp_open: filp_open的实现,负责协调路径查找策略。
struct file *do_filp_open(int dfd, struct filename *pathname,
const struct open_flags *op)
{
struct nameidata nd;
int flags = op->lookup_flags;
struct file *filp;

// 初始化nameidata结构,它包含了路径查找所需的所有上下文。
set_nameidata(&nd, dfd, pathname, NULL);
// 第一次尝试:使用RCU模式进行乐观的、无锁的路径查找。
filp = path_openat(&nd, op, flags | LOOKUP_RCU);
// 如果RCU查找失败(因为路径在查找时被修改),则回退。
if (unlikely(filp == ERR_PTR(-ECHILD)))
// 第二次尝试:使用常规的、基于锁的路径查找。
filp = path_openat(&nd, op, flags);
// 如果查找因为缓存陈旧而失败(常见于NFS),则回退。
if (unlikely(filp == ERR_PTR(-ESTALE)))
// 第三次尝试:强制重新验证路径中的所有组件。
filp = path_openat(&nd, op, flags | LOOKUP_REVAL);

restore_nameidata(); // 清理nameidata上下文。
return filp;
}