跟内核、文件系统这类问题打交道久了,你会发现一个很有意思的现象:很多看起来"高级"的性能问题、死锁问题、内存问题,追到最后都落在几个基础结构体身上。struct dentry就是其中之一。它管的是路径解析和目录项缓存,也就是 dcache 的核心,整个 VFS 层的"翻译官"。不管你做的是嵌入式、存储、容器还是内核安全,只要你跟文件路径、open、stat、mkdir 这类操作沾边,就绕不开它。
这篇文章我不打算给你抄一遍源码注释,而是想从"这玩意儿到底解决什么问题"讲起,把它的字段、状态机、生命周期、并发控制,以及我在实际调试和写文件系统驱动时踩过的坑,一条条拆开讲清楚。
1. dentry 到底是什么:一次 open() 背后的完整链路
1.1 VFS 为什么需要 dentry,而不是直接用 inode
你先想一个问题:用户态执行open("/etc/nginx/nginx.conf", O_RDONLY)的时候,内核是怎么找到这个文件的?
最朴素的做法是逐级查找:从根目录/开始,查etc目录,再查nginx目录,最后查到nginx.conf这个文件。每一次目录查询,最终都会对应到一个 inode 上。但这里有个性能问题:如果每次都从磁盘读取目录数据来解析路径,一次 open 可能要触发好几次磁盘 I/O,那系统根本跑不动。
于是内核在内存里加了一层缓存,专门缓存"路径组件到 inode"的映射关系。这个缓存里的每一个节点,就是struct dentry。你可以把 dentry 理解成一个"路径名组件"的代言人:它记住了自己叫什么名字、父目录是谁、对应哪个 inode,以及自己在 hash 表里的位置。整个路径树在内存里的形态,就是由这些 dentry 互相链接而成的。
这里要特别澄清一个容易混淆的概念:dentry 不等于目录。平常说的"目录"是一个 inode 类型,S_ISDIR。而 dentry 是路径解析过程中的一个"目录项"(directory entry),它既可以代表一个目录,也可以代表一个普通文件、软链接、设备文件。/etc/nginx这个路径里,etc和nginx各有一个 dentry,它们指向的 inode 是目录类型的;nginx.conf也有一个 dentry,指向的 inode 是普通文件类型。所以 dentry 是"路径名组件",inode 才是"文件或目录本身"。
1.2 dentry 与 inode 的对应关系:多对一
dentry 和 inode 是多对一的关系。硬链接就是最好的例子:同一个 inode 可以有多个名字,比如/data/a.log和/data/b.log都指向同一个 inode,那么在 dcache 里就有两个 dentry,分别指向同一个 inode。inode 内部通过i_dentry链表维护所有指向它的 dentry,这个链表在别的地方都以d_alias的名义出现。
这个多对一的关系在删除文件时有个坑:你想删除某个名字,但 dentry 还在被别的进程引用,或者它被硬链接到了别处,这时候 inode 的释放会被延迟,典型的现象就是"文件删了但磁盘空间没释放",或者"进程还持有 fd,文件却已经 unlink 了"。这些行为,根子都在 dentry 和 inode 的生命周期纠缠上。
2. struct dentry 核心字段逐项拆解
我在调试代码时喜欢把结构体打印出来逐字段看,因为很多问题就藏在字段语义里。struct dentry在include/linux/dcache.h中定义,我挑几个关键字段讲。
2.1 d_name、d_parent、d_inode:最核心的三件套
struct dentry { ... const struct qstr d_name; // 当前组件名称,如 "nginx.conf" struct dentry *d_parent; // 父目录的 dentry struct inode *d_inode; // 指向该 dentry 对应的 inode ... };d_name是struct qstr,里面除了name和len,还带一个hash字段。这个hash是路径查找时快速比较的凭据,不用每次都逐个字符比较字符串,先比 hash,比不过就直接跳过。d_parent指向父目录的 dentry,所以通过dentry->d_parent可以一路向上回溯到根。d_inode可能为 NULL,这个情况代表 negative dentry,下面会专门讲。
我在实际调试时经常用/proc的一些接口反推 dentry 树关系,但要小心 d_parent 的引用计数。如果你在内核模块里遍历 d_parent,必须持有d_lock或者在 RCU 读锁保护下访问,否则你看到的父节点可能已经被人释放了。
2.2 d_lockref、d_seq、d_flags:状态与并发控制
struct lockref d_lockref; // 引用计数 + 自旋锁的组合 seqcount_spinlock_t d_seq; // 用于 RCU pathwalk 的序列计数 unsigned int d_flags; // DCACHE_* 状态标志d_lockref是 Linux 3.12 之后引入的一个优化:把引用计数(refcount)和一把自旋锁塞进同一个 64-bit 字里,一次原子操作就能完成"加锁 + 检查计数 + 修改计数"。这样频繁的dget()/dput()路径不需要每次都开锁关锁,缓存行竞争小很多。你用lockref结构体时,无法把锁和计数分开操作,必须用它的专用 API,比如lockref_get_not_zero()。
d_seq是 seqlock 的序列号,典型用在 RCU pathwalk 里做乐观并发控制。你可以在不持锁的情况下读 dentry,然后检查序列号有没有变化;如果变了就重新来一遍。这是 VFS 路径解析最快路径的核心机制。写者(比如修改 d_name、d_inode 指向)会持有锁并递增序列号,读者靠read_seqcount_retry()判断期间有没有被修改。
d_flags是一堆DCACHE_*标志位,比如DCACHE_DISCONNECTED(从目录树中断开了)、DCACHE_UNUSED(不再被引用,在 LRU 上)等。调试时用d_flags判断 dentry 处于什么状态非常有用,后面状态机部分我会展开。
2.3 d_hash、d_lru、d_subdirs:如何组织这棵大树
struct hlist_bl_node d_hash; // 全局 dcache hash 表节点 struct list_head d_lru; // 未使用 dentry 的 LRU 链表节点 struct list_head d_child; // 挂到父 dentry 的 d_subdirs 上 struct list_head d_subdirs; // 本目录包含的子 dentry 链表头d_hash把 dentry 挂到全局哈希表里。哈希函数是full_name_hash(),用dentry->d_name.hash作为键的一部分。查找路径时先在哈希表里找,找到再比对字符串和父节点。哈希桶是hlist_bl类型,bl表示 bucket list,目的是为每个桶的锁和链表头做优化,减少内存占用。
d_lru则解决缓存淘汰问题:当 dentry 不再被使用(引用计数归零),它会挂到全局 LRU 链表上。内存压力大时,内核会从 LRU 尾部回收 dentry,走到shrink_dcache_parent()或shrink_dcache_for_umount()这类路径。d_subdirs就是父 dentry 保有的子项链表,遍历一个目录所有子项时用的就是它。
2.4 d_op、d_sb、d_fsdata:dentry 与具体文件系统的接口
const struct dentry_operations *d_op; // dentry 操作函数集 struct super_block *d_sb; // 所在文件系统的 super_block void *d_fsdata; // 文件系统私有数据这是 dentry 能适配几十种文件系统的关键。d_op里常见的有d_revalidate(路径解析时让文件系统确认缓存是否有效)、d_compare(自定义的名字比较逻辑)、d_delete(最后一次引用释放时的钩子)、d_release(dentry 完全释放时的钩子)。NFS、FUSE、overlayfs 都重度依赖这些钩子。
d_fsdata是给文件系统存私有数据的,比如有的文件系统会把目录的偏移量或者自有结构指针存在这里。注意它不是d_inode->i_private,那个是 inode 的私有数据,别混淆。
3. dentry 的状态机:从分配、命中到回收
3.1 从 d_alloc 到 d_add:dentry 是怎么诞生的
内核解析路径时,如果某个路径组件在 dcache 里找不到,就需要分配一个新的 dentry。这个过程大致是:
struct dentry *d_alloc(struct dentry * parent, const struct qstr *name);d_alloc()负责初始化一个 dentry:拷贝名字、设置父指针、把 d_subdirs 链表接好、初始化锁和引用计数。刚分配出来的 dentry 只是"幽灵节点",还没有和 inode 绑定。然后用d_add()把它加进 dcache:
void d_add(struct dentry *dentry, struct inode *inode);d_add()的内部逻辑是:把 dentry 插入全局哈希表,并设置d_inode = inode。此时 dentry 真正进入"已连接且可用"的状态。如果 inode 传入 NULL,这个 dentry 就是 negative dentry,表示"路径名有效,但对应的 inode 不存在或已被删除"。
我在写文件系统驱动时经常犯错的一个点是:调d_add()之前必须确认文件名和 inode 已经对应好,否则一旦把这个 dentry 插进去,后续路径解析命中了错误映射,问题极难排查,因为 dcache 的缓存命中日志都很隐蔽。
3.2 路径查找的核心路径:fast path 与 __d_lookup
每次路径解析(pathwalk)都从__d_lookup()或<b>path_lookupat` 出发,核心逻辑是:用 qstr 的 hash 去全局 dcache 哈希表里找,找到候选后比较名字是否真的相等,相等且父节点匹配就算命中。Linux 3.x 之后的 RCU 优化让这个过程可以完全不持锁,靠 seqlock 和 RCU 机制完成。
fast path 的整个流程基本是:
- 读取
d_seq,得到 sequence 值 s。 - 在不持锁的情况下读取
dentry->d_name、d_parent、d_inode。 - 用
read_seqcount_retry(&dentry->d_seq, s)验证数据没有变化。 - 如果验证失败,退回到持
d_lock的慢路径重试。
这个设计显著提升了所有文件的 open/stat 性能。我自己做负载测试时,开perf stat看 open 的系统调用开销,前后对比能差出好几倍,全靠这个机制撑着。
3.3 dentry 的三种状态:used、unused 与 negative
这是面试常问的题目,但很多人只会背名字。我结合代码捋一下:
used:引用计数大于 0,dentry 被某个进程或某段内核代码"持有"。此时它不在 LRU 上,也不会被回收。unused:引用计数归零,没有任何代码在使用它,但还在 dcache 哈希表里。它会被挂到 LRU 上,内存压力大时被回收。dput()是进入 unused 状态的关键函数,它会先减引用计数,归零后根据文件系统是否禁止 negative caching 等情况决定是放进 LRU 还是直接释放。negative:d_inode == NULL。表示这个路径名在当前文件系统上不存在文件(或者文件已删除,但 dentry 被负缓存住了)。negative dentry 的存在能有效避免对不存在的文件路径反复发起磁盘 I/O。
negative dentry 是双刃剑:如果文件系统实现不当,d_delete没有及时触发,或者目录被 remove 之后 dentry 没被清理,就会导致"文件明明创建了,但 open 却一直报 ENOENT"。这就是经典的 negative dentry 失效问题。
3.4 dentry 的回收与 LRU:内存压力下的行为
当系统内存吃紧,内核要回收 dcache 的时候,颗粒度是 dentry 对象本身。shrink_dentry_list()会从全局dentry_lru链表尾部摘一些 dentry 出来:
- 先摘掉哈希表节点(
d_hash删除)。 - 从父节点的
d_subdirs链表中摘除。 - 调用
d_op->d_release()钩子让文件系统做清理。 - 最终释放 dentry 对象和名字占用的内存。
回收和引用计数是竞争的关系:如果回收过程中发现有人正持有引用,就会跳过。所以你经常能看到 dcache 占的内存看着很大但无法回收,原因多半是长生命周期进程把一堆 dentry 引用计数养肥了。定位这种问题时,/proc/slabinfo里:dentry的 active_objs 和 num_objs 能给出很多线索。
4. dcache 并发与锁:别在并发模型上翻车
4.1 d_lock、seqlock 与 RCU:三种机制各管一摊事
很多人把 dcache 的并发模型想象成一把大锁,其实精确一点说是分成几层:
d_lock(即 d_lockref 中的 lock):保护单个 dentry 的字段一致性,比如 d_inode 的切换、d_name 的修改、d_child/d_subdirs 链表的改动。d_seq/seqlock:保护 RCU pathwalk 的读路径,只允许一个写者,多个读者廉价且乐观。- RCU:负责 dentry 对象本身的安全释放。路径解析时能在无锁情况下获取到 dentry 指针,读完字段再验证序列号;释放时用
call_rcu()延迟释放,确保所有读者都退出 RCU 临界区后才真正销毁对象。 - 全局哈希锁:分布在每个 hash bucket 上(
hlist_bl_lock(b)),不是一把全局锁,所以不同目录的查找并发度很高。
我在排查死锁时有个心得:只要看到 dentry 的代码里有人试图"先持 d_lock 再申请别的锁",就要高度警惕。d_lock是自旋锁,持有期间不能睡眠,一旦嵌套持锁顺序错了,很容易出现 ABBA 死锁。标准做法是:能用lockrefAPI 就用lockref_get_not_zero,不要自己尝试在内核里维护引用计数。
4.2 RCU 遍历 dentry 树的经典注意事项
遍历一个目录的d_subdirs在并发环境里特别容易踩雷。list_for_each_entry_rcu不是万能药,你必须记住:
- 用
hlist_bl_for_each_entry_rcu遍历哈希表时,只保证对象本身不释放,不保证字段不被并发修改。因此遍历取到的 d_name 可能是旧值。 - 要安全读取 d_name,需要持有 d_lock 或者在 seqlock 保护下读取并验证。
- 遍历不是"快照",它看到的是一个动态变化的链表。即使你在遍历时目录里新增了一个文件,链表里也可能没出现,这是正常的。
我在 fuzz 测试中就碰到过一次并发遍历读取 d_name 导致内核崩溃的问题,根因就是没持锁读取字符串,瞬间字符串被并发 rename 修改了。排查手段是用 KASAN 开起来跑压力测试,崩一次就能抓到 UAF 点。
4.3 d_delete 和 d_revalidate:文件系统最容易写错的两个钩子
d_op->d_delete()在 dentry 引用计数最后一次归零时被调用。它返回值很关键:
- 返回 0,表示"可以负缓存",dentry 会以 negative 或 unused 状态挂在 LRU 上,之后还能复用。
- 返回 1,表示"不要缓存了,直接释放掉"。
如果实现一个日志型文件系统,文件删除后 dentry 还缓存着 inode,而磁盘上的 name 已经没了,那 open 同一路径就会命中旧 dentry,行为完全错误。NFS 那类文件系统则依赖d_revalidate来做强一致校验。用简单类比来说:d_delete决定"死了要不要留坟",d_revalidate决定"每次上坟要不要验 DNA",两者不能混。
5. 实操:怎么观察和调试 dentry/dcache
5.1 用 /proc 接口查看 dcache 状态
内核提供了一些接口直接暴露 dcache 健康状况:
/proc/sys/fs/dentry-state:输出四列数字,分别是最小未用数、未用 dentry 数、总 dentry 数、以及曾经创建过的 dentry 总数(可以用来观察缓存增长趋势)。/proc/slabinfo:找到dentry行,看active_objs(实存对象数)和num_objs(总对象槽数)。如果两者差值巨大且一直不降,说明 dcache 回收受阻。slabtop:交互式工具里能看到 dentry slab cache 占用的内存大小,排在前几名的通常就是它。
在排查内存泄漏或 dcache 无限膨胀时,这几个文件是我必看的第一现场。如果/proc/sys/fs/dentry-state的未用 dentry 数持续上涨,说明某个路径上在不停创建和释放临时文件,而且文件系统没有正确抑制 negative dentry。
5.2 用 ftrace 追踪 d_lookup 和 dput 调用
要精准知道哪些进程在做什么路径解析、dentry 的分配释放是不是异常频繁,可以用 ftrace 的 function tracer:
# 先看可用的函数列表 grep d_lookup /sys/kernel/tracing/available_filter_functions # 设置过滤函数并开启 tracing echo '__d_lookup dput d_alloc d_add' > /sys/kernel/tracing/set_ftrace_filter echo function > /sys/kernel/tracing/current_tracer echo 1 > /sys/kernel/tracing/tracing_on # 结束抓取后查看结果 cat /sys/kernel/tracing/trace注意函数名字在不同内核版本有区别,旧内核用的可能是d_lookup,新内核是__d_lookup。追踪结果里每个记录都有时间戳和进程 PID,可以配合kallsyms和addr2line定位到具体内核模块的路径。我有一个小技巧:追踪前先把/sys/kernel/debug/tracing/buffer_size_kb加大到几十 MB,否则高并发下 trace 文件会被冲掉,什么都查不到。
5.3 开 KASAN 和 slub_debug 定位 dentry UAF
dentry 的 UAF(use-after-free)问题很难肉眼看出,全靠工具。如果你重新编内核做调试,建议开这几个配置:
CONFIG_KASAN=y或CONFIG_KASAN_GENERIC=yCONFIG_SLUB_DEBUG=y,并在启动参数里加slub_debug=P(对普通对象检查 poisoning)CONFIG_DEBUG_OBJECTS_DENTRY=y,它会用 debug objects 对 dentry 做额外的生命周期检查,能在 dentry 释放后再被引用时打印出道栈。
我遇到过一次真实案例:某个文件系统驱动在d_release里没有清空 filp 的 private_data 指针,之后 open 被杀后,另一个进程复用该 filp 导致 final_put 时重复调用dput,直接 double-free 崩溃。打开CONFIG_DEBUG_OBJECTS_DENTRY之后,内核很明确地报出了第一个释放点和第二次引用操作的栈,几分钟就定位根治了。
5.4 内核模块里安全遍历 dentry 树
这里给一个极简的内核模块示例,展示安全遍历目录下所有子目录项的方法。注意:这里演示的只是结构,真实场景请考虑用vfs_*或 readdir 代替手写遍历。
#include <linux/dcache.h> #include <linux/spinlock.h> #include <linux/rcupdate.h> /* 在给定父 dentry 的目录下,安全统计未使用子项数量 */ static int count_unused_children(struct dentry *parent) { struct dentry *child; int count = 0; /* * 先 RCU 读锁定,再遍历 d_subdirs。 * 注意 d_subdirs 链表的每个节点都是 dentry,释放受 RCU 保护。 */ rcu_read_lock(); list_for_each_entry_rcu(child, &parent->d_subdirs, d_child) { /* 读取字段前拿 d_lock,保证字符串和指针稳定 */ spin_lock(&child->d_lockref.lock); if (d_unhashed(child) && d_is_negative(child)) count++; spin_unlock(&child->d_lockref.lock); } rcu_read_unlock(); return count; }只要记住铁律"RCU 保证对象不释放,d_lock 保证字段不变",遍历逻辑基本不会错。如果你想彻底做"目录快照",更省心的方案是走iterate_dir/readdir接口,而不是自己去啃 dcache 链表,因为后者的所有细节都需要你自己处理。
6. 常见问题排查与避坑心得
6.1 典型问题速查表
| 现象 | 根因方向 | 建议排查手段 |
|---|---|---|
| 文件删除后磁盘空间未释放 | dentry 或 inode 仍被引用,dput未执行到位 | 查进程 fd、lsof;用 slabtop 看 inode/dentry 对象数量 |
| open 一个新创建文件返回 ENOENT | negative dentry 缓存未失效 | 确认文件系统是否实现d_delete返回 0/1;检查 dcache 负缓存条目 |
| 目录重命名后旧路径仍可访问 | dcache 未刷新,或文件系统d_revalidate未实现 | 检查是否有d_drop/d_invalidate被真正调用 |
| 路径解析耗时异常增大 | dcache 命中率下降,或 hash 被恶意冲突攻击 | 用 ftrace 统计__d_lookup调用次数;观察 dentry-state 数值 |
| 模块加载后 panic,栈指向 dentry 释放 | UAF / double-free | 开启 KASAN + slub_debug + debug objects dentry |
内核死锁,栈里多个 CPU 都锁在d_lockref.lock | 锁顺序错误,或持锁睡眠 | 检查代码中持锁是否嵌套,使用 lockdep 复现 |
6.2 印象最深的两个 dentry 问题
第一个是 overlayfs 场景。有人报"容器启动后文件一直显示旧内容",查到最后是 overlayfs 的 upper/lower 层切换时,dentry 的 d_op 没切换,旧层缓存一直生效。解决方法是底层切换时主动d_drop相关 dentry,让路径解析重新走lookup。这个理解起来很简单,但要在成百上千个容器并发启动时做好,涉及的工程细节不少。
第二个是网络文件系统场景。NFS 的 dentry 默认要求d_revalidate检查 attr timeout,但有些第三方客户端实现得比较粗糙,把 result 写死为 1(始终失效),导致每一台客户机上的每个路径访问都会穿透到服务端。这时的表现是:网络文件系统访问慢到不可用。排查方式就是在客户端开 ftrace 抓__d_lookup的 miss 率,一抓就看出d_lookup把流量压在了 lookup 慢路径上。
6.3 给内核模块开发者的建议
写文件系统或相关 module 时,我对 dentry 的代码有几点长期遵守的规则,算是拿崩溃换来的:
- 无论什么时候,不要随手直接赋值
dentry->d_inode,必须走d_add()、d_splice_alias()或d_instantiate()这类接口。 - 使用
dget()之前一定确认 dentry 是 "hashed" 且在路径树上,否则引用计数会保护到一个隔离节点上,白保护。 dput()是可以睡眠的(可能触发 LRU shrink),所以不要在原子上下文或持有自旋锁时调用它。代码里如果看到spin_lock(); dput(); spin_unlock();这种写法,几乎一定有隐患。- 遍历 dcache 不持锁时读字符串,早晚会吃到错觉。想取 d_name 必须先拿 d_lock 或走 seqlock 校验。
坦白说,struct dentry的坑不是看一两天源码就能全部摸清的,但把这几个核心链路和字段真正理解透,后面再碰 pathwalk、dcache shrink、NFS/FUSE/overlayfs 都会轻松很多。这篇文章也只是把骨架撑起来,真上了生产环境,还是要带着 KASAN 和 lockdep 去现实中磨一轮。