ext4、XFS、Btrfs、tmpfs、procfs——Linux 上几十种文件系统形态各异,但应用只需要一套 open/read/write 接口。这背后是 VFS(Virtual File System Switch)的抽象层设计。本文用一个 130 行的 Python 迷你 VFS 完整复现四层对象模型、路径解析与 dentry 缓存、硬链接与引用计数语义,并用真实运行输出验证每一个机制。
一、为什么需要 VFS
没有 VFS 的世界:每个文件系统各自实现系统调用,应用要感知底层差异,NFS 挂载点上的 ls 和本地磁盘上的 ls 行为可能完全不同。VFS 用经典的面向对象内核设计(结构体 + 函数指针表)解决这一问题:定义统一的抽象接口,具体文件系统填充自己的实现,上层应用只面对抽象。这与 C++ 的虚函数表机制同源,只是用纯 C 手工实现。
二、四层对象模型
- super_block:一个已挂载文件系统的实例信息(块大小、挂载点、操作表)。每挂载一次产生一个。
- inode:一个文件的唯一身份(ino 号、权限、大小、数据块指针)。inode 里没有文件名——这是理解硬链接的关键。
- dentry:目录项,把一个名字绑定到一个 inode,并记录父子关系。dentry 构成路径的树形骨架,内核把热 dentry 放进 dcache 加速路径解析。
- file:一次 open 的上下文(读写的当前偏移 offset、打开模式、指向 dentry)。同一 inode 被 open 十次就有十个 file 对象,各自的 pos 互不干扰。
四者的关系一句话概括:路径 → dentry → inode → 数据;open 在 dentry 之上再包一层 file 携带会话状态。
三、路径解析:dcache 如何把 O(路径深度) 摊到 O(1)
每次 open('/a/b/c') 朴素实现要从根目录逐级读目录文件、查名字对应的 ino、再读 inode——磁盘 I/O 次数与路径深度成正比。VFS 的解法是 dentry cache(dcache):解析过的每一级 dentry 都缓存在哈希表中,以 (父 dentry, 名字) 为键。热点路径的重复 open 完全命中内存,inode 表和磁盘都不再触碰。下面模拟器的统计数据会直观展示这一点。
四、完整可运行模拟器(Python 3,约 130 行)
#!/usr/bin/env python3
"""迷你 VFS:superblock / inode / dentry cache / file 四层对象全演示"""
class Inode:
_next = 1
def __init__(self, is_dir=False):
self.ino = Inode._next; Inode._next += 1
self.is_dir = is_dir
self.data = bytearray()
self.nlink = 1 # 硬链接计数
self.children = {} # 目录内容: name -> Inode
class Dentry:
def __init__(self, name, inode, parent):
self.name, self.inode, self.parent = name, inode, parent
class File: # 打开文件对象(每次 open 一个)
def __init__(self, dentry):
self.dentry = dentry
self.pos = 0
class MiniVFS:
def __init__(self):
self.root = Inode(is_dir=True)
self.dcache = {} # 路径 -> Dentry(dentry 缓存)
self.hits = 0
self.misses = 0
self.inode_lookups = 0 # 真实访问 inode 表的次数
def _lookup(self, path):
"""路径解析:逐级走 dentry,未命中才查 inode 表"""
if path in self.dcache:
self.hits += 1
return self.dcache[path]
self.misses += 1
cur, cur_path = self.root, ''
for comp in [c for c in path.split('/') if c]:
cur_path += '/' + comp
if cur_path in self.dcache:
self.hits += 1
cur = self.dcache[cur_path].inode
continue
self.inode_lookups += 1
if comp not in cur.children:
raise FileNotFoundError(path)
child = cur.children[comp]
self.dcache[cur_path] = Dentry(
comp, child, cur_path.rsplit('/', 1)[0] or '/')
cur = child
return self.dcache[path]
def create(self, path, data=b''):
parent_path, name = path.rsplit('/', 1)
parent = (self._lookup(parent_path).inode
if parent_path else self.root)
ino = Inode()
ino.data = bytearray(data)
parent.children[name] = ino
self.dcache[path] = Dentry(name, ino, parent_path or '/')
return ino
def link(self, old, new):
"""硬链接:新 dentry 指向同一 inode,nlink+1"""
d = self._lookup(old)
parent_path, name = new.rsplit('/', 1)
parent = (self._lookup(parent_path).inode
if parent_path else self.root)
parent.children[name] = d.inode
d.inode.nlink += 1
self.dcache[new] = Dentry(name, d.inode, parent_path or '/')
def open(self, path):
return File(self._lookup(path))
def read(self, f, n=-1):
d = f.dentry.inode.data
out = bytes(d[f.pos:] if n < 0 else d[f.pos:f.pos + n])
f.pos += len(out)
return out
def write(self, f, buf):
d = f.dentry.inode.data
end = f.pos + len(buf)
if end > len(d):
d.extend(b'\x00' * (end - len(d)))
d[f.pos:end] = buf
f.pos = end
def unlink(self, path):
d = self._lookup(path)
parent_path, name = path.rsplit('/', 1)
parent = (self._lookup(parent_path).inode
if parent_path else self.root)
del parent.children[name]
del self.dcache[path]
d.inode.nlink -= 1
return d.inode.nlink
print('=== 迷你 VFS 演示 ===')
vfs = MiniVFS()
vfs.create('/etc', b'')
vfs.root.children['etc'].is_dir = True
vfs.create('/etc/passwd', b'root:x:0:0\n')
vfs.create('/home', b'')
vfs.root.children['home'].is_dir = True
f = vfs.open('/etc/passwd')
print('第一次 open /etc/passwd:', vfs.read(f).decode().strip(),
' [inode=%d]' % f.dentry.inode.ino)
print('dcache: hits=%d misses=%d, inode表查询=%d 次'
% (vfs.hits, vfs.misses, vfs.inode_lookups))
f2 = vfs.open('/etc/passwd')
print('第二次 open(全命中 dcache): [inode=%d]'
% f2.dentry.inode.ino)
print('dcache: hits=%d misses=%d, inode表查询=%d 次(未增加)'
% (vfs.hits, vfs.misses, vfs.inode_lookups))
vfs.link('/etc/passwd', '/home/pw-backup')
d = vfs._lookup('/etc/passwd')
print('硬链接 /home/pw-backup -> inode=%d, nlink=%d'
% (d.inode.ino, d.inode.nlink))
f3 = vfs.open('/home/pw-backup')
vfs.write(f3, b'daemon:x:1:1\n')
f.pos = 0
print('经硬链接写入后, 从原路径读到:',
repr(vfs.read(f).decode()))
print('两路径共享同一 inode(数据互通): %s'
% (vfs._lookup('/etc/passwd').inode
is vfs._lookup('/home/pw-backup').inode))
nl = vfs.unlink('/etc/passwd')
print('unlink /etc/passwd 后 nlink=%d, inode 保留(仍有引用)' % nl)
f3.pos = 0
print('从 /home/pw-backup 仍可读:', repr(vfs.read(f3).decode()[:12]))
print('最终 dcache 统计: hits=%d, misses=%d, 命中率=%.0f%%'
% (vfs.hits, vfs.misses,
100 * vfs.hits / (vfs.hits + vfs.misses)))
五、真实运行输出与解读
以上代码在 Python 3 下原样运行,输出如下:
=== 迷你 VFS 演示 ===
第一次 open /etc/passwd: root:x:0:0 [inode=3]
dcache: hits=2 misses=0, inode表查询=0 次
第二次 open(全命中 dcache): [inode=3]
dcache: hits=3 misses=0, inode表查询=0 次(未增加)
硬链接 /home/pw-backup -> inode=3, nlink=2
经硬链接写入后, 从原路径读到: 'daemon:x:1:1\n'
两路径共享同一 inode(数据互通): True
unlink /etc/passwd 后 nlink=1, inode 保留(仍有引用)
从 /home/pw-backup 仍可读: 'daemon:x:1:1'
最终 dcache 统计: hits=11, misses=0, 命中率=100%
这组输出精确对应真实内核行为:
- dcache 的累积效应:第一次 open 时 /etc 和 /etc/passwd 已在 create 阶段进入 dcache,故路径解析直接命中(hits=2,未触碰 inode 表);第二次 open 整个路径整体命中,查询次数完全不再增加。真实系统上 dcache 命中率常年在 95% 以上,这就是频繁 open 同一文件几乎零成本的原因。
- 硬链接的本质:link() 只是新建一个 dentry 指向同一个 inode=3,nlink 从 1 涨到 2。经备份路径写入的数据从原路径立刻可见——因为两条路径解到的是同一份数据。这也解释了为什么硬链接不能跨文件系统:inode 号只在单个文件系统内有意义。
- unlink 的延迟销毁语义:删除 /etc/passwd 只摘掉一个 dentry,nlink 减到 1,inode 存活,从 /home/pw-backup 照常读。真实内核再叠加一层:只要还有进程持有打开的 fd(file 对象引用 inode),即使 nlink=0,inode 也不会销毁——这就是『删除正在写入的日志文件,磁盘空间不释放』经典现象的根源,也是临时文件技巧(open 后立即 unlink)的实现基础。
六、理论分析
6.1 操作表:一切皆虚函数
VFS 的四个对象各自携带操作表:super_operations(alloc_inode、sync_fs…)、inode_operations(lookup、link、mkdir…)、dentry_operations(d_hash、d_compare…)、file_operations(read、write、mmap…)。ext4 在挂载时填入 ext4_xxx 系列函数,NFS 填入 nfs_xxx,VFS 层只做转发。新写一个文件系统 = 实现这几张表并 register_filesystem(),框架分文不动——开闭原则在内核级的教科书案例。
6.2 dcache 的并发设计
dcache 是内核里最热的全局数据结构之一,其演进浓缩了 Linux 并发优化的历史:从全局自旋锁到 per-bucket 锁,再到 2.6.38 的 RCU-walk——路径解析全程不取锁、只靠 RCU 读侧临界区,只有遇到需要睡眠的操作才退化到 ref-walk。今天一颗 64 核机器每秒可做数亿次路径查找,靠的就是这个无锁读路径。
6.3 page cache 与统一缓冲
file 对象的读写并不直达磁盘,而是落在 page cache(以 inode+页偏移索引的基数树,现为 XArray)。read 命中缓存则零 I/O;write 先改缓存页置脏,由回写线程周期落盘。mmap 和 read/write 共享同一份 page cache,避免了双份数据的一致性问题——这是『统一缓冲缓存』设计,也是 mmap 能与 read 混用的根本原因。
七、工程应用
- 观测 dcache/inode 压力:
cat /proc/sys/fs/dentry-state与inode-state给出缓存规模;slabtop 里的 dentry、inode_cache 行异常膨胀通常意味着海量小文件遍历(如 find、备份扫描)冲刷了缓存。 - 文件句柄观测:
lsof -p <pid>、/proc/<pid>/fd/列出每个打开的 file 对象;/proc/sys/fs/file-nr是全系统句柄用量。句柄泄漏(fd 只增不减)在这里一眼可见。 - deleted-but-open 排障:磁盘满了但 du 找不到大文件?
lsof +L1列出所有已删除但仍被占用的文件——正是 unlink 延迟销毁语义的现实案例,重启持有进程或 truncate 对应 fd 即可释放。 - 自研文件系统路径:学习 VFS 后写内核文件系统门槛仍高,工程上首选 FUSE(把 file_operations 的回调导出到用户态),JuiceFS、s3fs、sshfs 皆为此路线的成熟代表。
八、常见误区
- 误区 1:『文件名存在 inode 里』。错。文件名只存在于父目录的数据(dentry 关系)中,inode 只有元数据和数据块指针。这正是硬链接多名字共享一文件的原理,也是 mv 同分区改名为什么是 O(1) 的原因。
- 误区 2:『两次 open 同一文件共享读写位置』。不共享。每次 open 新建 file 对象、pos 各自独立(模拟器中 f 与 f2 即如此);要共享偏移应使用 dup/dup2 复制 fd,或 fork 继承。
- 误区 3:『unlink 后文件立刻消失、空间立刻释放』。unlink 只删 dentry;nlink>0 或有打开 fd 时 inode 与数据都保留。运维中『rm 了大文件空间没回来』几乎都是这个原因。
- 误区 4:『write 返回了数据就安全落盘了』。write 只保证进入 page cache;掉电前需要 fsync/fdatasync 或 O_SYNC。数据库的 WAL 刷盘、配置工具的『写入+fsync 目录』原子替换模式,都是在跟这一语义打交道。
- 误区 5:『VFS 是性能瓶颈,绕过它直连裸设备一定更快』。绝大多数负载的热点数据由 page cache 与 dcache 供应,VFS 层开销占比极低;绕过它等于放弃缓存、权限、挂载命名空间等基础设施,只在极少数专用数据库场景值得权衡。
九、参考文献
- D. Bovet, M. Cesati (2005). Understanding the Linux Kernel (3rd ed.), Chapter 12: The Virtual Filesystem. O'Reilly.
- Linux kernel documentation: Documentation/filesystems/vfs.rst 与 path-lookup.rst(RCU-walk 设计细节)。
- N. Brown (2014). RCU-walk: faster pathname lookup in Linux. LWN.net 系列文章。
- W. Mauerer 著,郭旭译. 专业 Linux 内核架构 第 8 章:虚拟文件系统。
- S. Kleiman (1986). Vnodes: An Architecture for Multiple File System Types in Sun UNIX. USENIX Summer Conference.(VFS/vnode 架构的原始论文)