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 手工实现。

二、四层对象模型

四者的关系一句话概括:路径 → 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%

这组输出精确对应真实内核行为:

六、理论分析

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 混用的根本原因。

七、工程应用

八、常见误区

九、参考文献

  1. D. Bovet, M. Cesati (2005). Understanding the Linux Kernel (3rd ed.), Chapter 12: The Virtual Filesystem. O'Reilly.
  2. Linux kernel documentation: Documentation/filesystems/vfs.rst 与 path-lookup.rst(RCU-walk 设计细节)。
  3. N. Brown (2014). RCU-walk: faster pathname lookup in Linux. LWN.net 系列文章。
  4. W. Mauerer 著,郭旭译. 专业 Linux 内核架构 第 8 章:虚拟文件系统。
  5. S. Kleiman (1986). Vnodes: An Architecture for Multiple File System Types in Sun UNIX. USENIX Summer Conference.(VFS/vnode 架构的原始论文)
← 返回文章列表