在打开手里的内核源码去定位某个文件“真实身份”的时候,大概率会翻到这么一个结构体:struct path。它看起来只有两个字段,短得不能再短,但整个虚拟文件系统(VFS)的路径解析、挂载点跨越、文件引用计数,全都绕着它转。我最早看内核源码时,对struct path的理解停留在“一个dentry的壳”,后来在写内核模块并通过filp_path去反查打开文件路径时,才意识到这个结构体里的mnt和dentry各管一摊,少了谁都会翻车。这篇东西想做的,就是把我自己从“知道它”到“用得上它”的过程梳理一遍,重点放在struct path的数据来源、生命周期管理、路径遍历中的行为,以及在内核模块里实操时的那些坑。
这篇文章适合谁看?想搞懂文件描述符与VFS之间关系的内核学习者,正在写文件系统、安全模块、审计模块或者和路径解析打交道的驱动开发者,以及面试前想把这个点彻底理清的人。我不打算写得像内核文档那样冷冰冰,更多是站在“我在调试时真的遇到过”的角度去讲。
1. struct path 前传:从 dentry/vfsmount 到 path 的结构化演进
1.1 这个结构体到底装了什么
先看定义,这个在include/linux/path.h里,定义非常干净:
struct path { struct vfsmount *mnt; struct dentry *dentry; };就这么两个指针,没了。很多人第一反应是:你为什么不能直接用struct dentry代表路径,非要包一层?原因很简单,一个dentry只能代表某个目录项在单台设备上的“名字到inode”的映射,但它表达不了“这个文件属于哪个挂载点”。同一个路径字符串,比如/data/log/app.log,在不同挂载命名空间里,完全可能落在不同的文件系统上。以dentry为单位的路径信息,缺少“从哪棵设备树出发”这个维度的概念。
mnt成员指向的struct vfsmount,本质上是某个struct mount实例内部的已导出数据。这一层用来表达“这个路径所指代的文件,位于哪个挂载树分支下面”。而dentry则负责表达“目录项在具体某个文件系统内叫什么名字、对应哪个inode”。两者合在一起,才能唯一确定内核当前对“某个文件系统实例内的某个对象”的描述。你可以把struct path理解成一份“坐标”:mnt是楼栋号,dentry是房号,光有房号不知道哪栋楼,照样找不到人。
1.2 没有 path 之前的内核是怎么活的
早期内核里,不少接口在处理路径时,直接传递struct dentry *,同时某些地方还需要自己把vfsmount挂在参数里。例如老的follow_link处理流程,涉及跨文件系统的符号链接跳转时,就得手动去查挂载点,甚至临时拼一些缓存结构。整套逻辑分散、可读性差,而且稍不注意就会出现对dentry引用计数管理混乱的情况。
后来把mnt和dentry组合成struct path,并让大量VFS接口统一使用这个结构体作为参数与返回值。最典型的就是struct file里的f_path成员:
struct file { ... struct path f_path; ... };f_path的意义在于,每当你持有一个struct file的时候,就可以直接通过file->f_path.mnt和file->f_path.dentry获取当前进程正在操作的文件真实挂载点与目录项。这比传两个参数进去要容易维护得多。很多审计功能要打印某个进程打开的“原始路径”,本质上取的就是file->f_path,然后调用更底层的路径序列化函数。
这一演进解决了一个特别现实的麻烦:路径解析不是只发生在“用户用open()打开文件”那一瞬间,后续的read()、write()、stat(),包括fcntl(),都需要回头再找文件对应的mnt/dentry。如果没有统一的结构类型,你能想象的接口至少是这样:int foo(struct vfsmount *mnt, struct dentry *dentry),然后再加一个不发疯的参数位置。每次函数签名变长一点,整个内核的修改成本都在上升。struct path把两件事打包成一个参数之后,接口简洁度完全不一样了。
1.3 看到 struct path 时最容易产生的误解
很多初学内核源码的朋友看到一个path变量,第一反应是“这个结构体里应该保存了完整的文件路径字符串”。不是的。struct path里永远保存的是指针,不是字符串,这些指针指向的对象存在于内存中,并且受引用计数保护。字符串只是这些内存对象“序列化”之后得到的产物,比如你在/proc/self/fd/里看到的软链,那是内核在读取时临时格式化出来的结果。
第二个常见误解是,觉得path->mnt和path->dentry指向的对象一定是永远有效的。大错特错。dentry可以被回收,vfsmount可以随着文件系统卸载而被销毁,前提是引用计数归零。为什么很多内核函数开头都会调用path_get()或者path_get()的配套操作?就是因为struct path本身只是两个指针,它不会自动帮你续命。如果你从一个file里拷贝了f_path,然后在fput()之后再去使用这份拷贝,那一瞬间你手上拿的很可能就是指向已释放内存的“悬空指针”。这个坑,我后面单独讲path_get()/path_put()的部分会说得更透。
2. struct path 的生命周期:谁在管理引用计数,谁在给 mnt 和 dentry 续命
2.1 引用计数落在哪里
struct path本身没有引用计数成员,这是它和struct file、struct inode很不一样的地方。它依赖的是两个底层对象各自独立的计数:mnt对应的挂载对象,以及dentry对应的目录项对象。你需要同时保证这两者在持有的周期内不会被释放,才能安全使用一个struct path。
内核为此专门提供了两个按键式操作:
static inline void path_get(const struct path *path) { mntget(path->mnt); dget(path->dentry); } static inline void path_put(const struct path *path) { dput(path->dentry); mntput(path->mnt); }看调用顺序就知道:获取时先mntget再dget,释放时是反过来的,先dput再mntput。这个顺序是有讲究的——如果先释放mnt再释放dentry,而dentry对应的文件系统实例还在等着挂载数据,整个对象间的关系就会乱掉。按“谁包含谁”的层级来释放,即先释放内层依赖,再释放外层依赖,就是在对树形结构的引用关系做清晰收尾。
在我自己写测试模块时,一度习惯用path->dentry做大量字符串拼接操作,特别是在调试遍历目录时,完全没想过对dentry本身就是个“借来用”的指针。直到有一次在遍历过程中频繁打开和关闭文件,导致模块直接触发了内核空指针,踩完坑之后才老老实实遵循path_get()/path_put()的使用规则。
2.2 从用户层 open() 到内核 path 的一次完整旅程
当进程调用open("/etc/hostname", O_RDONLY)时,内核不一定立刻创建struct file,而是要先把路径解析成struct path,再做后续的dentry_open()操作。do_open_execat()里那段逻辑不必细看,关键是路径解析过程会生成一个struct nameidata,其中包含struct path path;成员。解析期间,namei.c的path_walk()函数大概会经历查找目录项、解析挂载点、跨越绑定挂载等一连串动作,最终得到完整路径对应的struct path。
创建struct file时,内核做的操作是把这份struct path当作初始参数传进去:
struct file *alloc_file(const struct path *path, fmode_t mode, const struct file_operations *fop) { struct file *file = alloc_empty_file(mode, 0); ... file->f_path = *path; ... }绝大多数情况下,file->f_path里的指针值和传给open()系统调用的那份struct path是同一个对象或等价对象。这时候打开的文件已经“带上”了自己对应的挂载点和目录项。之后所有对文件的操作,包括read()、fstat(),都能从file->f_path出发拿到文件对象信息。
2.3 什么时候必须自行管理 path 的生命周期
如果你写的是普通的文件系统逻辑,那大概率跟着struct file走就好,因为fput()会对file本身做释放,它会顺带把f_path的引用清掉。但是如果你的代码把struct path存进了自己的队列、链表或者哈希表,准备后续异步任务再处理,那就必须在存储时抢先获取引用,并且在自己最后用完的地方把引用还回去。
一种典型场景是内核模块的监控逻辑:系统调用路径里拿到file->f_path之后,把它放进一个workqueue,然后立即返回。如果workqueue的延迟稍长,恰好期间老文件被关闭、目录项被回收、文件系统被卸载,那你的异步任务手里就只剩两个失效指针。解决办法很简单,入队前:
path_get(&file->f_path);出队处理后:
path_put(&saved_path);这里还要注意一个细节:path_put()不保证能够把原来的对象完好无损地还原,因为外部可能已经修改了dentry或者挂载状态,但你要确保的是在你自己持有期间这两个对象没有被系统回收。这也是“借用”与“持有”的本质区别。
2.4 引用计数和挂载卸载的“对账”
挂载卸载是一个极其考验path生命周期逻辑的场景。卸载某个挂载点时,内核需要能安全地遍历并销毁属于该挂载点的vfsmount和dentry对象。这个动作的前提是所有对该路径的引用都必须被及时释放。如果一个模块里偷偷保存着struct path而没有加引用,那么挂载卸载时,它可能把已经被销毁的dentry地址当作有效对象来用,随后内核崩溃极难排查。
调试这种问题,最痛苦的点在于崩溃现场往往跟真正的持有者毫无关系,因为指针已经悬空,读到的是别的对象。我的经验是,在模块里凡是跨越“当前进程上下文”使用struct path的地方,一律使用path_get()显式保护,并且配合毒化内存手段验证谁在非法访问这些对象。内核里有个类似思路的调试开关是CONFIG_DEBUG_OBJECTS,它检查对象是否被错误复用,虽然不是专门为path设计的,但对于发现生命周期管理失误很有参考价值。
3. 路径遍历中的 struct path:跨挂载点、符号链接与查找的瞬间
3.1 为什么必须把 mnt 和 dentry 放在同一个结构里
路径遍历过程中,dentry负责逐级查找,mnt负责判断是否越过了挂载边界。以前者为主导的路径查找很容易出现一个问题:/mnt/disk1/a.txt和/mnt/disk2/a.txt里,如果只看dentry的路径字符串,可能都是a.txt,但实际挂载在不同设备上。路径解析需要知道名字在不同挂载点上的割裂关系,这就需要mnt。
每次跨越挂载点,比如从/mnt走到/mnt/disk1,实际上就需要在逻辑上切换到另一个vfsmount的根dentry。路径查找代码里,有一个常驻的follow_mount()和follow_managed()函数,它们会根据当前path里的mnt去查找子挂载点。如果把两个信息分开传,跨越挂载点时还要搞一套临时参数来记录旧挂载点、新挂载点、旧目录项、新目录项,代码复杂度会变得非常可观。
struct path作为参数跟随着路径遍历的每一步,相当于在一条长长的字符串中不断更新“当前已解析到的坐标”。每走完一层目录,这个结构体都会更新其mnt、dentry字段,以匹配当前解析到的那一层。你在理解lookup_slow()、walk_component()等函数时,脑子里的模型应该是:一个会滚动的坐标,滚到终点,就是这个路径对应的最终对象。
3.2 符号链接跳转时 path 的重新计算
符号链接的解析并不简单。假设路径里包含一个指向/elsewhere/go的软链接,内核在解析到该链接时,会自动把剩下的路径和软链接的目标路径拼接起来,然后重新进入路径解析。此时原来的struct path很可能被丢弃,需要重新走一轮“从根目录或当前目录出发”的解析流程。
这里有个关键点:符号链接的跳转可能带着“当前工作目录的上下文”,也可能跨挂载点。struct path的mnt在跳转过程中可能被彻底替换,比如从proc文件系统的一个符号链接跳转到ext4的某个路径下。如果解析过程没有正确持有旧路径的引用,就可能在切换瞬间释放掉正要被继续解析的目录项。内核设计者在struct nameidata里专门维护path成员,并定义了set_nameidata()等上下文管理函数,就是为了应对这种跳转时的路径栈切换。
3.3 openat2 与 RESOLVE_BENEATH 里的 path 语义
如果读过openat2()的代码,你会发现它引入了struct open_how,里面有一些路径解析控制标志,比如RESOLVE_BENEATH要求路径解析不能越过某个基准目录。底层实现里依然离不开struct path,因为它需要时刻保存“当前解析到的位置”,用于和基准路径做比较。基准路径本身也是一个struct path。越界检查很简单:如果当前解析到的路径已经超出了基准path的边界,就返回错误。但实现比这个复杂在挂载边界的检查必须同时看mnt_id和dentry的深度关系。
这种场景再次印证了struct path的实用性:它天然地保存了“所在文件系统实例”和“文件系统内部节点”两个层次,让越界判断可以在不同层上进行。如果你只传一个dentry,根本没法判断它是否跑到了另一个挂载点上。
3.4 我怎么读 nameidata 里的 path
读fs/namei.c的时候,我的建议是先不要碰那些复杂的优先级的RCU模式,先锁定struct nameidata这个核心上下文。它里面有一个struct path path;和一个struct qstr last;。每解析完一个路径分量,path就会更新。阅读时把这些更新动作串成一条线,基本就能把握路径遍历的主干。RCU模式下,路径查找可以在不增加引用计数的情况下快速读取dentry,但一旦需要实际引用时,会先验证对象是否还活着,再切换成常规模式。struct path在其中仍是那个被反复更新的坐标载体。
我在阅读时特别关注path->mnt的切换点。每一个挂载点的跨越切换,都是path->mnt被更新的地方。在内核里暴力搜索path.mnt =以及path->mnt =这些赋值语句,你就能把路径跨越挂载点的所有活跃场景摸出来,这比直接读整篇VFS文档要直观得多。
4. 内核编程实战:获取路径、打印路径与常见坑
4.1 从 struct file 反向拿完整路径
我知道很多读者手里都有那种“需要知道某个fd对应文件路径”的模块需求。最简单粗暴的方式就是从current进程的files_struct取出fdtable,拿到struct file *,然后读取其f_path成员。此后真正需要做的是把这个struct path转成一段可用于日志输出的字符串。
内核里可以干这件事的是dentry_path()以及seq_path()这类函数,但它们的使用方式都比较繁琐。更常见也更实用的方法是直接用d_path():
#include <linux/fs.h> #include <linux/path.h> char *get_path_from_file(struct file *file) { struct path path; char *buf, *res; if (!file) return NULL; path = file->f_path; path_get(&path); buf = kmalloc(PATH_MAX, GFP_KERNEL); if (!buf) goto out; res = d_path(&path, buf, PATH_MAX); if (IS_ERR(res)) { kfree(buf); goto out; } /* res 指向缓冲区中路径字符串的起始位置,而 buf 只是内存块起点 */ ... out: path_put(&path); return buf; }这段代码里,d_path()返回的指针可能跟buf不是同一个位置,因为内核可能会在前面跳过一些共同前缀。使用结果时,要按返回值来,而不是直接用buf。d_path()在解析时并不要求你传的buf是路径的“起点”,它会在内部做偏移协商。
这里最容易被忽略的点是path_get()和path_put()之间的匹配。很多模块代码喜欢把path = file->f_path直接拷贝之后就用,却忘了fput()之后dentry可能已经失活。要避免在日志输出过程中内核崩溃,提前获取引用是最稳妥的。
4.2 不要用 filp->f_dentry,它已经变了
如果你是从老内核代码或者其他博客里看到的filp->f_dentry,这个字段在很久以前的内核里就没了,现在struct file中保留的是f_path。所以当你拿到一个struct file *时,应当优先写file->f_path.dentry,而不是幻想直接访问f_dentry来获取目录项。兼容层代码可能还有一些宏定义,但是新代码没必要再依赖这些历史遗留字段。
这也带出另一个习惯:在代码里,如果你发现自己在频繁访问path->dentry且完全不碰path->mnt,要警觉起来。对一个文件的访问路径判断,通常同时需要挂载点信息,特别是在做安全过滤时。如果只根据dentry判断路径是否匹配某个前缀,极有可能因为挂载命名空间不同而漏判。举个例子,一个容器内应用访问/etc/app/config,如果单独查看dentry路径,你只会看到容器rootfs里的路径,但mnt揭示的是它实际落在哪个挂载点上。审计和隔离场景里,mnt信息才是判断“文件是不是某个被绑定挂载进来的敏感文件”的关键。
4.3 文件系统过滤钩子里怎么用 path
很多安全模块会在inode_permission或者security_file_permission等钩子里做路径判断。此时钩子函数往往只能拿到struct inode *和struct dentry *,不见得直接提供struct path *。如果想要拿到挂载点信息,一种做法是通过dentry的d_sb获取超级块,进一步推断文件系统,但拿不到具体的vfsmount实例。
为了让钩子充分感知struct path的语义,建议把逻辑迁移到那些直接传入path的钩子上,比如security_path_*系列钩子,或者fanotify所依赖的路径事件点。在这些钩子里你可以直接获得struct path *,不必自己通过复杂的dentry关系反推vfsmount。钩子签名里带struct path *path的接口,实际上就是在刻意推动开发者在路径内核对象级别做检查,避免反复解析字符串。
4.4 解析相对路径与当前工作目录
内核模块通过current->fs->pwd可以拿到进程当前工作目录对应的struct path。它的形态是:
struct fs_struct { ... struct path pwd; struct path root; ... };如果你要解析一个用户空间的相对路径,首先得先拿到current->fs->pwd里的path,然后结合相对路径字符串逐级解析。这个过程不会像用户态那样有shell帮你做规范化,你必须自己处理.、..、符号链接和挂载点。好在内核提供了kern_path()函数,直接接受绝对路径字符串并返回struct path:
struct path path; int error = kern_path("/etc/hostname", LOOKUP_FOLLOW, &path); if (!error) { ... path_put(&path); }kern_path()对我们写内核模块来说是基本工具,但它默认会使用当前进程的fs上下文。对于模块自身发起的路径解析,这个行为可能跟预期一致,也可能不一致,取决于模块是否在目标进程的上下文里运行。如果你要在中断上下文或者独立的kernel thread里解析路径,要特别小心退避到睡眠路径的调用方式,GFP_KERNEL内存分配和path resolution都可能引起睡眠。
4.5 打印路径的几个实用选择
平时调试打印路径,我常用的有这么几种方式:
| 方式 | 所需参数 | 注意事项 |
|---|---|---|
d_path(&path, buf, buflen) | struct path * | 路径可能带“(unreachable)”前缀,输出与挂载命名空间相关 |
seq_dentry(seq, dentry, escaped) | struct seq_file *+dentry | 适合在seq_file输出时用,灵活度高 |
dentry_path_raw(dentry, buf, buflen) | struct dentry * | 只输出dentry层面的路径,不带挂载信息 |
kern_path()+d_path() | 字符串 + 输出buf | 适合拿到绝对路径后快速打印 |
d_path()的输出结果受当前进程挂载命名空间的影响很大。同一个struct path,在主机上打印出来是/host/data/file,在容器进程上下文里打印可能就变成/data/file,因为d_path()会根据命名空间将路径序列化到当前进程可见的根上。调试时如果怀疑路径不对,不要先怀疑指针损坏,先想想进程是不是跑在一个隔离的mnt命名空间里。这种“路径变了”的现象不是bug,是挂载命名空间对路径展示的必然作用。
4.6 遗留的坑:不要拿着 path 去做字符串前缀匹配
我看到很多内核模块作者喜欢把d_path()拿到的字符串,按用户态路径的模式去前缀匹配,然后做策略放行或拦截。这种方案在功能上看着没问题,但性能上很糟糕,因为d_path()需要遍历整个目录链、分配字符串,还可能被页表锁等竞争拖慢。更本质的问题是,路径是可以通过挂载和符号链接绕过的,用字符串匹配做安全决策有天然的绕过面。
更优的做法是保存一个“锚点路径”的struct path,然后在运行时比较当前路径对象的mnt和dentry是否与锚点一致,或者通过is_subpath()这类辅助函数判断父子关系。这种比较是纯指针和树关系的判断,不涉及字符串格式化,性能好得多。即便路径被改写、被重命名,只要dentry对象还是同一个,就能识别出来。字符串只应该留给日志输出和用户态展示,不应该成为权限判断的唯一依据。
5. 面向调试的内核观测:从 struct path 里看出问题的源头
5.1 怎样从崩溃堆栈定位 path 相关的问题
如果一个崩溃的调用栈里出现了__d_lookup、dput、path_put、__fput这类函数,很大程度上是某个对象已经处于释放过程,而你的代码还在尝试使用里面的指针。此时不要急着怀疑CPU或者内存损坏,先检查你是否有未配对的path_get()。比如,你的调用路径中每次path_get()都会多一次引用,那当外部把最后一个引用释放时,对象还会活着,但不会被重新初始化。如果某些流程没有正确调用path_put(),最后的结果就是dentry和mnt引用泄漏,对应内存无法被回收。
调试时最有效的办法是打开内核的引用计数调试选项,CONFIG_DEBUG_VM和CONFIG_DEBUG_MUTEXES虽然不直接调试path,但能帮助你发现许多因为引用计数错误引发的不一致状态。另一个实用技巧是使用tracepoint,内核里有syscalls相关的跟踪点,但针对dput/mntput的跟踪点比较少。实际项目中,我往往直接在模块的hook里包一层引用计数器,每次path_get()后记录调用方符号,再用dump_stack()拿到调用栈,后续用栈指纹判断哪些路径做了一次不对称的引用操作。
5.2 用 eBPF 或内核日志追踪 path 的变化
现代内核里用eBPF追踪路径解析非常方便。kprobe可以挂在path_get、path_put、follow_managed、__link_path_walk这些关键函数上,通过读取struct path参数并序列化出路径字符串,能够清晰看到某个路径在整个系统调用生命周期中经历了哪些拼装和替换。
下面是一个挂在path_get上的极简eBPF思路(伪代码):
SEC("kprobe/path_get") int BPF_PROG(trace_path_get, struct path *path) { char buf[256]; bpf_probe_read_kernel(&buf, sizeof(buf), (void *)path->dentry); bpf_trace_printk("path_get dentry=%s\n", buf); return 0; }实际使用中,你还需要读取path->dentry->d_name.name来获取名字字符串,并注意dentry结构里的锁问题。用eBPF读取路径时,尽量在入口处做一次bpf_probe_read_kernel,不要在读取过程中停留太久,避免影响跟踪数据的准确性。
有一点很关键:跟踪path_get()只能看到谁在增加引用,看不到谁在“借用”但不计数。所以项目里如果怀疑有人拿着struct path乱用但不加引用,挂kprobe在path_get上是抓不到的,需要配合抓d_path()的调用点,或者干脆用内存毒化手段直接验证。
5.3 一个我真实遇到过的 path 引用泄漏过程
有次我写一个文件系统审计模块,逻辑是拦截每次打开文件的操作,记录文件名和打开模式,然后异步写入审计日志。最初实现很简单:在security_file_open钩子里拿到struct file *和struct path *,随便拷贝一份path就丢进workqueue,完全没做path_get()。测试时只做普通打开文件、写日志,系统稳定运行。但当我同时运行一个压力脚本,反复创建和删除文件时,很快系统挂起。
排查时发现,workqueue在处理某些文件时,dentry已经被回收,字符串拼接直接崩溃。后来我在入队前调用path_get(),出队后调用path_put(),问题消失。这个案例让我真正意识到,在钩子里看到的struct path *只是“寄居蟹借壳”,不用时随时可能被退租。只要你的使用跨出了当前系统调用的生命周期,就一定要自己持有引用。
5.4 看代码时的顺藤摸瓜路线
最后给想系统掌握struct path的朋友一份阅读路线。不要一上来就读VFS最繁杂的通用层,而是先读这几处的调用关系:
fs/open.c里的do_sys_open和file_open_root相关路径,看看struct path是怎么初始化、怎么被传递到struct file的。fs/namei.c里的path_openat、link_path_walk,重点看mnt和dentry字段在目录切换时如何同步更新。fs/d_path.c里的d_path()函数,看一个struct path如何被转成字符串,理解命名空间对路径输出的影响。fs/mount.h和fs/namespace.c里的vfsmount、mount结构,搞明白mnt指针背后到底指向哪一层的对象。
按照这条线读下来,你对struct path的理解会从“这是个装了两个指针的东西”升级成“这是一个贯穿路径解析和对象生命周期管理的核心坐标系统”。
6. 进阶:围绕 struct path 做安全与追踪时,我总结出的几个铁律
6.1 铁律一:借用必须立刻使用,持有必须立刻计数
struct path不像struct file那样,天然带一个“生命周期句柄”。你在某个系统调用里拿到指针,如果在同一调用上下文内完成所有操作,那么直接使用没什么问题。但只要你准备把这份数据跨出当前上下文去存储、缓存、排队、异步处理,第一件事就是调用path_get()。
同理,持有之后,在使用完的每一个分支上都要确保有对应的path_put()。写代码时我习惯在一个结构体里保存struct path saved_path;,并且专门写一个free_item()函数集中释放,减少在错误分支遗漏。别把释放逻辑散落在多处,否则后期加一个提前返回分支,就会漏掉一次释放,导致引用泄漏。
6.2 铁律二:字符串路径只是展示,对象关系才是本质
做安全决策时,一定要尽量避免基于d_path()字符串的前缀匹配。struct path提供的mnt和dentry可以完成更可靠的关系判断。这里不是说字符串完全不能用,而是要在“给你看日志”和“帮你做判断”这两个目标之间做清晰划分。日志输出可以用字符串,权限判断尽量用对象关系。
举个例子,判断一个文件是否位于/data/app目录下,如果使用字符串前缀匹配,攻击者绑定挂载一个路径就能绕过。但如果在内核里先解析/data/app的锚点struct path,然后逐个对比当前文件路径的path是否位于锚点path之下,这种判断很难被路径混淆蒙混过去。is_subpath()这类工具函数就是为此设计的。
6.3 铁律三:尊重命名空间对 path 序列化结果的影响
d_path()输出的路径字符串不是全局唯一的。同一个文件在不同进程上下文里,完全可以展示成两个不同的字符串路径。反过来说,字符串看起来相同,实际对象也可能完全不同。理解这一点,有助于减少许多排查路径问题时的困惑。
有一次我在一个容器运行时环境中调试,模块日志里显示的路径是/data/local/log.txt,但用宿主机视角看同一个文件却对应/host_data/.../log.txt。如果我不清楚挂载命名空间这个机制,会以为内核模块拿到的路径是错的。事实是,模块在容器进程的上下文里执行,内核采用该进程可见的root和cwd来序列化路径。要避免这种混淆,可以在打印时把mnt的挂载ID也带出来,比如打印path->mnt->mnt_id,这样即便两个命名空间里字符串路径不同,也能确认是同一个挂载实例。注意访问vfsmount的某些字段时要加锁或使用合适的读取方式,旧内核中甚至没有mnt_id也能通过mnt->mnt_sb->s_id这类字段辅助判断。
6.4 铁律四:使用 path 时要意识到睡眠与原子上下文限制
有些朋友看完前面的代码,会在自己的并发队列里直接调用d_path()。这里必须提醒:d_path()可能需要获取rename_lock,还有可能触发内存分配,这个过程并非在所有上下文里都安全。如果你的代码运行在原子上下文、CPU热插拔路径、中断处理程序等地方,不能盲目调用路径序列化函数。临时退避方案是先用path_get()将引用持有,再推迟到后续进程上下文完成字符串生成和日志输出。
调度延时和锁竞争也是一个考虑点。路径解析在RCU模式下可以快速进行,但一旦需要实际引用目录项或跨越模块边界,成本会提高。在性能敏感的高频路径上,比如网络收发或块设备层,最好不要频繁构造和解析struct path。把路径相关计算尽量限制在文件系统自身的调用链里。
6.5 铁律五:读源码时要把 struct path 看作一个持续变化的状态机
我见过很多人把struct path当成“不可变配置”,认为只要从某个file拷出来一次,就永远不变了。真实情况是,struct path在很多内核路径上是被就地更新的:mnt和dentry会在路径解析过程中不断变化。尤其是follow_managed()处理自动挂载时,它可能会释放原来的vfsmount并重新挂载一个,此时path成员会在同一段逻辑里被改写。
所以,阅读代码时要区分两种情况:一是“拷贝型使用”,把某个地方的struct path复制到局部变量,后续局部变量不再被系统更新;二是“游标型使用”,把struct path作为遍历过程的工作变量,每一步都被修改。把这两种用法分清楚,你才不会被某段代码里path值的前后变化搞晕。
6.6 后续还能怎么用:从路径追踪到更上层的文件治理
基于struct path写出来的工具,扩展空间很大。比如你可以做一个内核模块,统计每个进程打开的文件路径分布;你可以做一个安全策略模块,基于mount实例做可执行文件白名单;你也可以在容器运行时里,通过比较主机视角和容器视角的path->mnt,厘清某些路径是否跨越了容器边界。
我在实际项目里做得比较多的是审计日志增强:不记录纯粹字符串,而是把字符串和挂载ID、文件系统魔数一起记录下来,这样事后定位问题时,能准确区分“同名同路径但不同挂载点”的两个文件。这种做法只增加了一点点日志量,却把排查难度降低了一个量级。
如果再配合动态跟踪,你可以把struct path转换成输出时,把父目录的dentry链慢慢遍历出来。虽然性能开销大一些,但在诊断棘手问题时,这种“看到完整路径来源”的能力能救命。
内核里任何结构体,只要牵扯到引用计数、生命周期、命名空间这几件事,都会变得比表面看上去复杂。struct path这个两字段的结构体更是如此。它连接着挂载子系统、目录项缓存、文件描述符体系和安全模块,搞懂它,你读VFS源码时的脑内地图就会清晰很多。我自己也是在踩过一次挂载卸载引发的悬空指针问题后,才算真正对它有了敬畏之心。如果你正在写和文件路径打交道的内核代码,不妨把上面几条铁律抄进你的代码评审清单里,省下的调试时间绝对值得。