第十关回答:“一切皆文件”在内核里到底长什么样。答案是一条查找流水线:路径字符串 → dentry(路径分量的内存表示)→ inode(文件本身)。而这条流水线 99% 的请求根本不走完——dcache(dentry 缓存)让热点路径在第一步就命中,零 I/O、零锁竞争。
这一关的机制支点分两半:“找”——getname(用户路径如何进内核)、link_path_walk(逐分量 walk 的驱动)、lookup_fast(无锁 dentry 查找),全在 fs/namei.c;“缓存”——address_space + filemap 读路径与脏页回写(mm/filemap.c)。M11(块层与 io_uring)讲缓存 miss 之后 I/O 如何提交下去,M12(ext4 与 journal)讲脏页如何落盘、崩溃如何一致。M01 的 CLONE_FILES 在这里接上——VFS 的 struct file 活在进程的打开文件表里;M03 的 slab 藏在 filename、dentry 背后;M09 的 RCU 思想在 lookup_fast 里现身。
open("/a/b/c") 的字符串活在用户内存里——内核不能直接用它走查找:用户随时可以改它(TOCTOU),它还可能缺页。所以 fs/namei.c 的第一步是 do_getname:strncpy_from_user 一次性把整条路径拷贝进内核,装进 struct filename。
这里有两个工程细节。第一,内嵌优化:注释写着 EMBEDDED_NAME_MAX——“try to embed the struct filename inside the names_cache allocation”,短路径直接塞进 filename 对象自身的尾部空间,零额外分配;超长才单独分配。第二,长度截断:PATH_MAX 上限,超了返回 ENAMETOOLONG——既是正确性(内核栈/堆预算),也是 DoS 防护(不让用户传 10MB 路径拖死内核)。filename 对象来自专用 slab names_cache(注释原话:“SLAB cache for struct filename instances”),用完 putname 归还——M03 的回声。
路径查找的驱动是 static int link_path_walk(const char *name, struct nameidata *nd)(fs/namei.c:3633)。struct nameidata *nd 是 walk 上下文:从哪个目录开始、当前走到哪个 dentry、挂载点跳跃状态,全在这里面流转。
它的工作模式是循环切分量:从路径字符串里切出下一个分量 → 对当前目录做一次 lookup 得到子 dentry → 推进。遇到符号链接,按 10.5 的规则决定跟随还是停下;遇到挂载点,切换到被挂载文件系统的根。整条路径走完,nd 里就停着目标的 dentry。论断:link_path_walk 是 VFS 查找唯一的“主循环”,所有路径解析(open/stat/exec)都收敛到它。
每个分量的查找先走 lookup_fast(fs/namei.c:2539),注释是整关最诚实的一句:“do fast lockless (but racy) lookup of a dentry”——对 dentry 做快速、无锁(但有竞争)的查找。
dcache 就是 dentry 的内存哈希缓存:键是“父 dentry + 分量字符串”,值是子 dentry。命中意味着这个分量上次查过,答案还在内存里——不需要读盘、不需要进具体文件系统、不需要拿锁。lockless 是 M09 的回声:读端不拿锁,并发 open 在 dcache 上零竞争;but racy 是代价:无锁读可能撞上并发的改名/删除,miss 或存疑就回落到慢路径,由慢路径做权威判定。快路径赌“世界没变”,慢路径负责“世界变了时不错”。
dcache miss 时,内核为这个分量新建 dentry:d_alloc(base, name)(fs/namei.c:2491)从父 dentry 分配子项。但有个竞态:两个 CPU 同时 miss 同一个名字,各建一个就分裂了。所以并发查找用 d_alloc_parallel(dir, name)(:2627 / :6399)——并行版带去重:建之前先原子地“认领”这个名字,输的那个用赢家建好的 dentry。
记住 dentry 的准确含义:路径分量的内存表示,不是文件本身。文件本身是 inode(dentry 背后挂的那个)。dcache 缓存的是“分量→dentry”这层映射——这就是 deck 里“三层缓存”的第一层。
每一步 lookup 都嵌着权限检查。check_acl(fs/namei.c:490 起的文档注释)按 @mask 检查:MAY_READ / MAY_WRITE / MAY_EXEC——目录的“执行”权限就是“穿越”权限,/a/b/c 的每一步父目录都要可穿越,否则 lookup 在半路就 EACCES。
符号链接的规则写在 fs/namei.c 文件头注释(:129-142),三条铁律:(1) 路径中间的 symlink 永远跟随——不跟随就走不下去;(2) 末尾分量看 flag:LOOKUP_FOLLOW 才跟随;(3) 递归跟随:symlink 指向的路径里还可以有 symlink。这就是为什么 O_NOFOLLOW / O_CREAT|O_EXCL 的语义全系于“末尾分量跟不跟随”这一念之间——安全语义(不被链接劫持)就藏在这条规则里。
VFS 的抽象是四层,每层一个结构体,各管一段:
super_block:一个已挂载的文件系统实例一个。它是整棵树的“根容器”——inode->i_sb 指回它,连 filemap_read 都要读 inode->i_sb->s_maxbytes 做截断。struct inode(include/linux/fs.h):文件的元数据本体。i_ino(编号)、i_mode(类型+权限)、i_op(inode 操作)、i_sb(所属 superblock),以及最关键的 i_mapping——指向这个文件 page cache 的 address_space。struct dentry(include/linux/dcache.h:93):“路径分量→inode”的缓存项。d_name(qstr 名字)、d_inode(指向的 inode,可为 NULL 即负 dentry)、d_parent、d_sb、d_lockref(锁+引用计数合一,注意 7.x 里它叫 d_lockref 而不是 d_lock)。10.1–10.5 玩的全是它。struct file(include/linux/fs.h):一次 open 的“会话”。f_path(这次打开走的路径)、f_inode、f_op(read/write 等)、f_pos(偏移),以及 f_mapping——直指 page cache,读路径(10.7)的第一跳。记住这条链:file --f_mapping--> address_space <--i_mapping-- inode,dentry --d_inode--> inode。查找(10.1–10.5)解决的是“dentry→inode”,缓存(10.7–10.8)解决的是“inode/file→页”。
struct address_space(include/linux/fs.h:471)就是一个文件的 page cache 本体:host(所属 inode)、i_pages(xarray,按文件偏移索引 folio)、a_ops(address_space_operations:read_folio / writepages / dirty_folio / readahead / write_begin|end / direct_IO)、nrpages、writeback_index。
读的入口是 filemap_read(mm/filemap.c:2787):第一行就是 mapping = filp->f_mapping——从 open 会话直达 page cache,不经过路径。然后循环调 filemap_get_pages(:2686):按文件偏移算页号,在 i_pages 里用 find_get_entry 找 folio。命中:直接拿 folio 拷贝给用户;miss:分配新 folio,经 a_ops->read_folio 从盘读入(顺序读还会触发 a_ops->readahead 预读)。注意 7.x 的缓存单位叫 folio(可含多页),不是裸 struct page——这是 5.16 起的内存管理术语迁移。
一次 read 的 page cache 路径(mm/filemap.c,已核实):
read(fd, buf, n)
└─ filemap_read(iocb, iter)
├─ mapping = filp->f_mapping # file 直达 address_space
└─ 循环 filemap_get_pages()
├─ 页号 = 文件偏移 >> 页移位
├─ find_get_entry(i_pages xarray, 页号)
│ ├─ HIT → folio 直接拷贝到用户 iter
│ └─ MISS → 分配 folio → a_ops->read_folio 读盘
│ (顺序读: a_ops->readahead 预读后续页)
└─ 拷贝 folio 内容 → iter,推进 ki_pos
写操作只改 page cache 并标脏(经 a_ops->dirty_folio),落盘是另一件事。启动回写的通用入口是 filemap_fdatawrite(mapping)(mm/filemap.c:416):filemap_fdatawrite_range(:409)→ filemap_writeback → do_writepages(mapping, &wbc)(:387),最终调 a_ops->writepages 让具体文件系统把脏页写下去。
filemap_writeback 开头有两道门:mapping_can_writeback(mapping)——没有 a_ops->writepages 的 mapping(比如某些伪文件系统)直接返回 0,不是所有 address_space 都能回写;mapping_tagged(mapping, PAGECACHE_TAG_DIRTY)——没有脏页也直接返回。M12 的 ext4_sync_file 第一步 file_write_and_wait_range 最终就落到这条链上——fsync 的“数据已写出”承诺,是这条链兑现的。
为什么路径查找先 getname 拷贝到内核,而不是直接读用户内存走?
三重理由:(1) TOCTOU——边走边读用户内存,用户线程可以同时改字符串,lookup 看到的路径是“薛定谔的路径”;(2) 缺页——用户页可能被换出,查找中途缺页的错误处理是灾难;(3) 长度攻击——PATH_MAX + ENAMETOOLONG 一次性截断,不让 10MB 的路径字符串拖死内核。一次性拷贝把“不可信输入”变成“内核私有的不可变快照”,后面的 walk 全是纯内核操作。这是所有系统调用处理用户字符串的通用范式。
为什么 dcache 要独立存在,而不是每次都进文件系统查目录?
因为查目录贵:冷路径要读盘、走具体文件系统的目录解析(ext4 的 htree 等,M12 的地盘)。dcache 把“分量字符串→dentry”做成纯内存哈希查找,热点 open(比如动态链接器反复打开同一批 .so)的每一步分量都命中,整条路径零 I/O。更狠的是 lookup_fast 无锁——多核并发 open 在 dcache 上不抢锁,这是 M09 RCU 思想的直接应用:读多(open 远多于改名/删除)→ 读端无锁化。
为什么 symlink 中间永远跟随、末尾却看 flag?
中间不跟随就走不下去——/link/to/file 里 link 若停在链接本身,后面的 to/file 无处可查,所以中间跟随是“能走完”的必要条件。末尾跟随与否则是语义选择:open 默认跟随(你要的是目标文件),但 O_NOFOLLOW / lstat / O_CREAT|O_EXCL 要的是链接本身——跟随了就会被“链接劫持”(攻击者把路径换成指向 /etc/passwd 的链接)。一条规则同时承载“可达性”和“安全性”,这就是它看起来绕的原因。
为什么读文件要先过 page cache,而不是直读磁盘?
三重理由:(1) 局部性——读过一次的页大概率还会被读(循环读、mmap 缺页、多个进程读同一文件),内存命中是纳秒级,读盘是毫秒级;(2) 统一——mmap 的缺页、read、sendfile 走的是同一份页,后台改了文件内容,前台读到的是一致的(都是 i_pages 里的 folio);(3) 合并写——写只标脏不落盘,do_writepages 可以把相邻脏页攒成一次顺序 I/O 交给块层(M11 的地盘)。代价是内存占用和一致性复杂度(脏页、回写、失效),但“磁盘太慢”这个物理现实让这笔交易永远划算。
CLONE_FILES 共享打开文件表——VFS 的 struct file 就活在那张表里。fork 复制文件表项时,表项指向的 dentry/inode 引用计数 +1:进程看到的“文件”是三层结构(fd → file → dentry → inode)的最外层。filename(names_cache)、dentry 都是高频小对象——查找路径上每一步都可能分配,slab 让这条热路径的分配成本可控。M03 的“小对象批量”在这里是刚需。mmap 读触发的缺页(M04 的 #PF 链条)最终走 VFS 的 page cache 找页(10.7):命中直接用,miss 才发起读盘——读盘提交是 M11 的地盘。lookup_fast 的 “lockless (but racy)” 就是 RCU 式读端无锁——读端不拿锁、写端(改名/删除)负责一致性。M09 的理论在这里第一次落地成真实代码。出题原则:10 题中记忆性题目不超过 3 道,其余为应用/推理/权衡类——场景预测、设计 trade-off 比较、从代码路径找问题、解释“为什么这样设计而不是那样”。难度递进,最后两道需要想一会儿。
两件事:(1) strncpy_from_user 把整条路径一次性拷贝进内核,装进 struct filename(来自 names_cache slab);(2) 长度截断,超 PATH_MAX 返回 ENAMETOOLONG。前者防 TOCTOU 和缺页(把不可信输入变成内核私有的不可变快照),后者防超长路径的 DoS。短路径走 EMBEDDED_NAME_MAX 内嵌优化,零额外分配。
省掉了路径字符串本身的第二次分配:短路径直接塞进 struct filename 对象尾部的内嵌空间,一次 slab 分配搞定对象+字符串。只有路径长度超过内嵌上限时,才为字符串单独分配。原理:热路径上每一次额外分配都是延迟——open 是全系统最高频的系统调用之一,省一次分配,全系统受益。这是 10.1 “零额外分配”的量化含义。
签名 static int link_path_walk(const char *name, struct nameidata *nd)(fs/namei.c:3633)。一次调用走完一整条路径——内部是循环:切出一个分量 → lookup → 推进,逐个分量处理。所以 open 一次调一次 link_path_walk,但它内部做了 N 次分量 lookup(N = 路径深度)。nd 是 walk 上下文,循环的状态(当前 dentry、挂载跳跃)全在里面流转。
防止同一个名字建出两个 dentry(dentry 分裂)——那之后两个 dentry 各自缓存、各自指向不同状态,dcache 的一致性就崩了。d_alloc_parallel 让两个 CPU 原子地“认领”这个名字:赢的建,输的用赢家建好的那个(通过并发查找的去重逻辑)。这是“先占锁再建”思想在 dentry 分配上的实例——和 qspinlock 的 pending 位异曲同工:高频竞态值得一个专用轻量路径。
lockless 是性能:读端不拿锁,多核并发 open 在 dcache 上零竞争(M09 RCU 思想的落地)。but racy 是代价:无锁读可能撞上并发的改名/删除,读到中间状态。代价由慢路径来付:miss 或结果存疑就回落到慢路径做权威判定。设计哲学:快路径赌“世界没变”(高频正确),慢路径负责“世界变了时不错”(低频兜底)——和 mutex 乐观自旋“先赌后睡”是同一套赌法。
b 在中间:永远跟随——link_path_walk 读出 b 的目标路径,从那里继续走 to/file(即 10.5 规则 1,不跟随就走不下去)。c 在末尾:默认跟随(LOOKUP_FOLLOW 置位时,规则 2)。O_NOFOLLOW 清掉跟随语义:open 打开的是链接本身而不是目标——防“链接劫持”(攻击者把路径换成指向敏感文件的链接)。记住判据:中间跟随是“可达性”,末尾跟随是“语义选择”。
用 filp->f_mapping——open 会话直达 page cache,不经过路径。命中:filemap_get_pages 在 i_pages(xarray)里 find_get_entry 找到 folio,直接拷贝给用户,零 I/O。miss:分配新 folio,经 a_ops->read_folio 让具体文件系统从盘读入(顺序读还会触发 a_ops->readahead 预读后续页)。所以“读文件内容”的代价分两档:内存拷贝 vs 读盘 I/O——page cache 的存在就是为了让绝大多数读落在第一档。
因为权限是“路径相关”的,不是“终点相关”的:/a/b/c 要求 a、b 可穿越(目录的 MAY_EXEC 即穿越权)且 c 符合 open 的读写要求。统一查一次只能查到终点 c,查不到“b 不可穿越但 c 可读”的中间违规。更深一层:每一步的 dentry 都可能来自不同的挂载/文件系统(挂载点跳跃),权限域也随之切换——只有边走边查才能保证“你走过的每一步都是合法的”。
在处理末尾分量的那一步动手:发现末尾分量是 symlink 且 flag 要求不跟随时,直接返回该 symlink 自身的 dentry(lstat 语义)或报错(O_NOFOLLOW|O_CREAT 的 ELOOP)。依据规则 2(末尾分量看 flag)。中间分量不动——规则 1(中间永远跟随)是“能走完”的必要条件,动了它整条路径就断了。本题考点:flag 只影响“末尾这一步的语义选择”,不影响“中间的可达性”——两条规则管的是不同的事。
同:都是“慢速权威结构 + 快速缓存”——dcache 缓存“分量→dentry”,TLB 缓存“虚拟页号→物理页号”;都是读多写少(open/访存远多于改名/改页表)→ 读路径无锁化/硬件化。异:(1) TLB miss 由硬件自动重填页表(或走 #PF),dcache miss 要走软件慢路径(建 dentry + 查文件系统);(2) TLB 的一致性由硬件 + 页表锁/ shootdown 保证,dcache 的一致性由“lockless 读 + 慢路径权威判定”保证;(3) dentry 是可分配的新对象(miss 时新建),TLB 条目只是页表项的拷贝(不新建映射)。一句话:TLB 是“映射的缓存”,dcache 是“查找结果的缓存 + 对象的缓存”——后者多了一层对象生命周期管理(这正是 d_alloc_parallel 存在的原因)。
getname、link_path_walk(约 3633 行)、lookup_fast(约 2539 行)、d_alloc(已核实存在,9374 行)struct dentry 定义在 include/linux/dcache.h:93(d_name/d_inode/d_parent/d_sb/d_lockref)struct inode(i_mapping 等)、struct file(f_mapping 等)、struct address_space(约 471 行)、address_space_operationsfilemap_read(约 2787 行)、filemap_get_pages、filemap_fdatawrite → do_writepages(约 387 行)一次 open 的路径查找全链条(fs/namei.c,已核实):
getname("/a/b/c") # 用户路径 → 内核 struct filename
└─ strncpy_from_user 一次性拷贝 # 防 TOCTOU + 缺页
└─ EMBEDDED_NAME_MAX 内嵌 / PATH_MAX 截断
link_path_walk(name, nd) # 主循环:一次分量,一次查找
├─ 切分量 "a" → lookup_fast() ─┐
├─ 切分量 "b" → lookup_fast() ─┤ dcache 命中:无锁返回 dentry
├─ 切分量 "c" → lookup_fast() ─┘ miss:d_alloc / d_alloc_parallel
├─ 遇 symlink → 中间永远跟随 / 末尾看 LOOKUP_FOLLOW
└─ 每步 check_acl(MAY_EXEC 穿越 / MAY_READ|WRITE)
dcache 命中 vs miss(10.3–10.4):
lookup_fast(nd): "fast lockless (but racy) lookup of a dentry"
├─ HIT → 直接返回 dentry(纳秒级,零 I/O,零锁)
└─ MISS → 慢路径
├─ d_alloc(base, name):新建 dentry
├─ d_alloc_parallel(dir, name):并发去重,输家用赢家的
└─ 进文件系统查目录(冷路径可能读盘,毫秒级)
在页面里每题写下你的答案(关键词即可),卡住的题点一下“卡住了”标记,
点“生成作答汇总”后复制、贴到聊天里——对错由我来判定,我逐题批改并据此
调整下一关的深浅;准备好了说“下一关”。
点下面按钮生成汇总文本,复制后贴到「内核二十关」侧聊里,对错由我判定。