想象你在一家跨国贸易公司工作,需要给不同国家的合作伙伴寄文件。你从不关心对方用的是哪家本地快递、地址格式如何,只需要按公司的统一快递单填写,把包裹交给前台,前台会自动把包裹转给对应的当地快递公司。操作系统里的文件访问正是这个逻辑:应用程序只认识一套通用的文件操作指令,而这个“跨国快递中转站”就是 VFS(Virtual File System,虚拟文件系统)。它让用户可以像读取本地文件一样,访问 U 盘上的 FAT32、硬盘里的 ext4、甚至远端服务器的 NFS,而完全感受不到底层“方言”的差异。这篇文章会跟着一个“快递包裹”从寄出到送达的全过程,拆解 VFS 如何管理超级块、索引节点、目录项、文件对象这四位“关键员工”,看看它如何接入新“快递公司”、如何用缓存加速,又如何在出问题时追根溯源。

岗位关联

理解 VFS 是看懂操作系统文件子系统的基石,也是你排查“磁盘明明没满却写不进”“删除文件后空间没释放”这类工程问题时的指路明灯。

目录
  1. 可视化演示
  2. VFS 是什么
  3. 四大对象
  4. 挂载注册
  5. 路径解析
  6. 读写流程
  7. 操作接口
  8. 性能排查
  9. AI 应用场景
  10. Python 演示
  11. 常见误区
  12. 面试题

可视化演示

演示虚拟文件系统核心机制:四大对象关系、系统调用路径、页缓存IO优化,三种模式交互切换。

本节配有交互动效(进入视口后自动加载,约 3KB)。

VFS四大核心对象 superblock:文件系统全局元信息 inode:文件元数据与数据块指针 dentry:目录项缓存加速路径解析 4对象通过5条关联链路构成VFS统一抽象层

VFS 是什么

假如你在一家跨国贸易公司上班,要给全世界合作伙伴寄文件。你只需要使用统一快递单,前台会根据地址自动转给当地快递公司,从不关心对方那家用的是马车还是无人机。Linux 里的 VFS 就是这个“前台快递中转站”。

用户调用 read() 时,根本不关心底层是 SSD 上的 ext4、U 盘里的 FAT32,还是远端服务器的 NFS。VFS 拦截了所有文件系统调用,把它们翻译成具体文件系统能听懂的命令。也正因如此,应用层代码面对的是一个统一的目录树,目录树下可以同时挂着磁盘文件系统、内存文件系统和网络文件系统。

VFS 是内核中的一个软件抽象层。它定义了 open、read、write、close 等标准操作接口,并维护一棵全局目录树。任何具体文件系统只要实现这些接口,就能被 VFS 收纳。从上层看,所有文件系统拼成同一棵树,每个挂载点就是树上的一个分叉;从下层看,VFS 本身不产生磁盘 I/O,只负责分发请求。

放在快递网络里看,这个流程就是你把快递单交给前台,前台转给对应合作快递,对方送完后回执再回到你手里。全程你只需要填一张单。

App→read(fd,buf,count)VFS→dispatchFSext4\displaystyle \text{App} \xrightarrow{\text{read}(fd, buf, count)} \text{VFS} \xrightarrow{\text{dispatch}} \text{FS}_{\text{ext4}}
这个公式展示一次 read 调用如何从应用穿过 VFS 到达具体文件系统。
  1. 应用发起调用用户程序调用 read(fd, buf, count),其中 fd 是打开文件时拿到的句柄。
    App→read(fd,buf,count)VFS\displaystyle \text{App} \xrightarrow{\text{read}(fd, buf, count)} \text{VFS}
  2. VFS 负责分诊VFS 根据文件对象和路径信息,决定把请求转给哪个已注册的文件系统。
    VFS→dispatchFSext4\displaystyle \text{VFS} \xrightarrow{\text{dispatch}} \text{FS}_{\text{ext4}}
  3. 具体文件系统执行ext4 完成实际磁盘读写,把数据返回给 VFS,再由 VFS 回传给应用。
    FSext4→dataApp\displaystyle \text{FS}_{\text{ext4}} \xrightarrow{\text{data}} \text{App}

四大对象

在快递中转站里不只有一张快递单,还有四类关键文件:总调度表、包裹清单、门牌地址、投递回执。VFS 中的四大核心对象正好和它们一一对应:superblock、inode、dentry、file。

superblock 是每个已挂载文件系统的“总调度表”,记录块大小、总块数、挂载状态、根 inode 等信息。inode 是一个文件或目录的“身份证”,保存权限、大小、时间戳和数据块位置。dentry 把路径分量(比如 home)与 inode 关联起来,它主要在内存中工作,用于加速路径解析。file 是进程打开文件后得到的“回执”,记录当前读写位置和访问模式;多个进程可以共享同一个 inode,但各自持有独立的 file 对象。

四个对象协作关系可以用一个极简公式描述:

门牌、包裹清单、总调度表各司其职,回执则记录你这次投递的进度。四件套齐了,中转站才能高效运转。

dentry↦inode,inode∈superblock\displaystyle \text{dentry} \mapsto \text{inode},\quad \text{inode} \in \text{superblock}
这条式子表示 dentry 完成“路径分量 → inode”的映射,而 inode 由 superblock 统一管理。
  1. dentry 映射到 inode每个路径分量查找之后会得到一个 dentry,dentry 内部保存指向 inode 的指针。
    dentry↦inode\displaystyle \text{dentry} \mapsto \text{inode}
  2. inode 归属 superblock一个文件系统中的所有 inode 都受该文件系统 superblock 管理。
    inode∈superblock\displaystyle \text{inode} \in \text{superblock}
  3. file 指向 inode进程打开文件后,file 对象指向对应 inode,并独立保留自己的偏移量。
    file→inode\displaystyle \text{file} \to \text{inode}

挂载注册

一家新的快递公司想加入中转站,前台要先和它“签约”,再在地图上标记“从这个街区开始由它负责”。这套动作对应文件系统的注册与挂载。

内核启动或加载模块时,具体文件系统会调用 register_filesystem 向 VFS 注册自己,把自己写入 file_system_type 链表。mount 时,VFS 调用该文件系统的 mount 方法,读取存储介质上的全局信息,创建 superblock 和根 dentry,并把根 dentry 挂到目标目录(即挂载点)上。此后,挂载点之下的路径全部由新文件系统接管。umount 卸载时则需要先保证脏数据写回,再断开 dentry 连接。

签约之后,地图上多了“由该公司负责的片区”,你寄件时依然只填一张单,完全不需要知道包裹后来换了几趟车。

mount(type,target)→superblock+root_dentry\displaystyle \text{mount}(\text{type}, \text{target}) \to \text{superblock} + \text{root\_dentry}
挂载操作接收文件系统类型和挂载点路径,返回新建的超级块和根目录项。
  1. 注册类型register_filesystem 把 fs_type 加入链表,让 VFS 知道这种文件系统的存在。
    register_filesystem(fs_type)\displaystyle \text{register\_filesystem}(\text{fs\_type})
  2. 指定挂载参数mount 系统调用需要拿到文件系统类型名和目标挂载路径。
    mount(type,target)\displaystyle \text{mount}(\text{type}, \text{target})
  3. 创建根信息具体文件系统读取磁盘上的超级块,在内存中建立 superblock 和 root_dentry。
    →superblock+root_dentry\displaystyle \to \text{superblock} + \text{root\_dentry}

路径解析

快递员拿到一个跨国地址时,要一级一级翻译:国家 → 城市 → 街道 → 门牌。VFS 收到路径 /home/user/a.txt 时,做的事情也一样:从根目录开始,逐级找到 home,再找 user,最后找到 a.txt。

路径解析从根 dentry 出发,按 / 分隔的每个分量逐级查找。如果目标目录恰好是挂载点,还要切换到新文件系统的根 dentry。每个分量先在 dentry cache(dcache)中查找,命中就直接得到 dentry 和对应 inode;未命中则调用具体文件系统的 lookup 函数从磁盘创建 dentry。常用的目录项会留在 dcache 中,inode 也会在 inode cache 中复用,避免反复读盘。

老快递员会凭记忆中的门牌快速投递,dcache 就是 VFS 的地图记忆。记住的越多,找路越快。

Lookup(/a/b/c)=droot→da→db→dc\displaystyle \text{Lookup}(/a/b/c) = d_{root} \to d_a \to d_b \to d_c
一次完整查找等于从根目录项出发,沿着路径分量逐个匹配目录项。
  1. 从根开始路径解析以 VFS 的根目录 dentry 作为起点。
    droot\displaystyle d_{root}
  2. 逐级匹配每级目录通过 dentry 查找下一级子目录或文件,最终得到目标 dentry。
    da→db→dc\displaystyle d_a \to d_b \to d_c
  3. 缓存介入每一级查找都可能命中 dcache,从而跳过磁盘操作,大幅缩短时间。
    droot→dcachedc\displaystyle d_{root} \xrightarrow{\text{dcache}} d_c

读写流程

包裹发出后会在中转站分拣。如果货架上恰好有你要的东西,就不必去仓库翻找。文件读写里的 page cache 就是这排“货架”。

open 系统调用通过路径解析找到 inode,创建 file 对象。read 时,VFS 根据 inode 的 mapping 定位到 address_space,先查 page cache:命中则直接把数据拷贝给用户,未命中则从磁盘读入页缓存再返回。write 流程类似,数据先写入页缓存并标记为脏页,由后台线程在合适时机刷盘。因此,write 系统调用成功返回,并不代表数据已经落在磁盘上。

货架命中率越高,用户等待时间越短。这也是数据库、云存储等系统喜欢“预热缓存”的原因。

平均访问时间=h⋅tcache+(1−h)⋅tdisk\displaystyle \text{平均访问时间} = h \cdot t_{cache} + (1-h) \cdot t_{disk}
读请求的平均耗时由缓存命中率 h 决定:命中走内存,未命中走磁盘。
  1. 命中率 hh 表示请求在 page cache 中命中的比例,取值范围在 0 到 1 之间。
    h\displaystyle h
  2. 命中时的开销命中时不需要访问磁盘,只付出内存访问时间 t_cache。
    h⋅tcache\displaystyle h \cdot t_{cache}
  3. 未命中时的开销未命中概率为 1-h,必须访问磁盘,付出 t_disk。
    (1−h)⋅tdisk\displaystyle (1-h) \cdot t_{disk}

操作接口

中转站和各国快递公司签的合同中规定:必须提供“接单、派送、签收”三种服务。具体文件系统向 VFS 上交的“合同”就是一组操作函数表。

VFS 不关心具体文件系统内部实现,只依赖函数指针。file_operations 描述已打开文件的读写行为,成员包括 read、write、ioctl 等;inode_operations 描述对 inode 的操作,如 lookup、create、unlink;super_operations 负责超级块和 inode 的生命周期管理,如 sync_fs、put_inode;address_space_operations 则负责页缓存与磁盘之间的数据搬运。每种文件系统填充这些表,VFS 调用时只需查表跳转。

合同内容一样,但各家快递公司的内部组织完全不同——这正是抽象带来的弹性。

file_operations={open,read,write,release,… }\displaystyle \text{file\_operations} = \{ \text{open}, \text{read}, \text{write}, \text{release}, \dots \}
file_operations 是一个函数指针结构体,具体文件系统按约定填入实现。
  1. 函数表定义file_operations 结构体保存 open、read、write 等函数指针。
    open,read,write,release\displaystyle \text{open}, \text{read}, \text{write}, \text{release}
  2. 具体实现填充ext4 提供一组函数,tmpfs 提供另一组,但接口签名完全一致。
    fs_impl↦file_operations\displaystyle \text{fs\_impl} \mapsto \text{file\_operations}
  3. VFS 查表调用调用 read 时,VFS 通过 file 对象中的 f_op 跳转到具体实现。
    file→f_op→read\displaystyle \text{file} \to f\_op \to \text{read}

性能排查

中转站运营久了,货架可能不够用,某些片区投递慢,甚至出现包裹积压。排查问题的第一步是看报表,VFS 性能调优也是如此。

VFS 相关调优集中在缓存与回写策略。sysctl 参数中,vm.dirty_ratio 与 vm.dirty_background_ratio 控制脏页回写水位;vm.vfs_cache_pressure 影响 dentry 与 inode 缓存回收的激进程度。排查工具方面,iostat 可看设备利用率,strace 可观察系统调用耗时,slabtop 可看目录项缓存占用,perf 能定位 VFS 层热点。常见故障包括:删除文件后空间不释放(文件仍被进程持有)、NFS 挂载卡顿(网络重传与 RPC 锁)、页缓存膨胀(回收不及时)。

IOPS 相当于快递站每秒能处理多少包裹。数字越大,中转站的“吞货能力”越强,排查和优化也就越有方向。

IOPS=NrequestsTtotal\displaystyle \text{IOPS} = \frac{N_{\text{requests}}}{T_{\text{total}}}
IOPS 表示每秒完成的 I/O 请求次数,是衡量存储系统吞吐能力的基础指标。
  1. 请求数统计窗口内完成的读写请求总次数,记为 N_requests。
    Nrequests\displaystyle N_{\text{requests}}
  2. 总时间统计窗口的时长记为 T_total,通常以秒为单位。
    Ttotal\displaystyle T_{\text{total}}
  3. 求商用总请求数除以总时间,得到每秒处理的请求数,也就是 IOPS。
    NrequestsTtotal\displaystyle \frac{N_{\text{requests}}}{T_{\text{total}}}

AI 应用场景

海量小文件训练集的缓存调优

阶段:数据准备与预处理

解决的问题:深度学习训练常读取数以万计的小文件,dentry cache 命中率低导致路径解析频繁访问磁盘,训练数据加载成为瓶颈。调大缓存相关参数、用 tar 打包或预读目录项,可以把命中率从不足 60% 提升到 95% 以上。

具体案例:某 CV 团队把 50 万张图片放在 NFS 上直接训练,发现每个 epoch 数据加载耗时 2 小时。用 strace 定位到 VFS 路径解析耗时占比极高后,他们改用本地 NVMe 并将目录项预热进 dcache,加载时间降到 20 分钟。

分布式存储网关的接口抽象

阶段:模型推理服务的多后端存储接入

解决的问题:AI 平台需要同时对接本地盘、对象存储和 HDFS。直接写多套读写逻辑会让业务代码充满 if-else。借鉴 VFS 的“统一接口 + 后端适配器”思想,可以设计存储接入层,让模型服务以同一套 API 读写任意后端。

具体案例:某推理平台定义了一个类似 file_operations 的 StorageAdapter 接口,本地盘、S3、HDFS 各实现一套 read/write/list。上线后新增存储后端只需注册新适配器,业务代码零改动。

训练集群 I/O 故障排查

阶段:训练运行时问题定位

解决的问题:训练任务突然卡顿或报“磁盘写满”,但 df 显示空间充足。这种矛盾通常来自 VFS 层的缓存与文件占用:文件被进程打开未关闭、page cache 脏页未回写、dent cache 持有 inode。按照路径解析和文件对象图逐层排查,能快速找到持有文件句柄的进程。

具体案例:某模型训练中日志文件被删除但空间持续下降,工程师用 lsof 找到仍有 CUDArt 进程持有旧日志 inode,重启进程后空间立即释放。

Python 演示

python41 行
class DentryCache:    def __init__(self, capacity=3):        self.capacity = capacity        self.order = []        self.table = {}     def lookup(self, name):        if name in self.table:            self.order.remove(name)            self.order.append(name)            return True        if len(self.order) >= self.capacity:            evict = self.order.pop(0)            del self.table[evict]        self.table[name] = {"name": name, "inode": object()}        self.order.append(name)        return False  def resolve_path(path, cache):    hits = misses = 0    for part in path.strip('/').split('/'):        if not part:            continue        if cache.lookup(part):            hits += 1        else:            misses += 1    return hits, misses  if __name__ == '__main__':    cache = DentryCache(capacity=3)    paths = ['/home/user/data.txt', '/home/user/log.txt', '/var/log/syslog']    total_hits = total_misses = 0    for p in paths:        h, m = resolve_path(p, cache)        total_hits += h        total_misses += m    hit_rate = total_hits / (total_hits + total_misses)    print('dentry cache hit rate: {:.2%}'.format(hit_rate))
目的:用 Python 模拟 VFS 的 dentry 缓存与路径解析流程,计算路径查找的命中率,直观体会 dcache 对性能的影响。
  1. if name in self.table:检查路径分量是否已经缓存在 dentry cache 中。
  2. self.order.remove(name)命中后将该项移到队尾,实现 LRU 的“最近使用”更新。
  3. evict = self.order.pop(0)缓存满时淘汰最久未使用的目录项,模拟 dcache 回收。
  4. self.table[name] = {"name": name, "inode": object()}未命中时创建新目录项并放入缓存。
  5. for part in path.strip('/').split('/'):把绝对路径拆成多个分量,逐级解析。
  6. if cache.lookup(part): hits += 1命中一次计数加一,对应公式中的 h。
  7. hit_rate = total_hits / (total_hits + total_misses)计算总命中率,命中率越高说明缓存越有效。

常见误区

误区我一直以为 VFS 是一种像 ext4、NTFS 一样写在磁盘上的文件系统格式,所以想知道它占据多少扇区。

正解VFS 并不存在于磁盘上,它是内核内存中的抽象层。它像快递中转站的“前台”,而 ext4、FAT32 才是真正负责派送包裹的“快递公司”。VFS 只定义接口和调度逻辑,不负责存储布局,所以不会有自己的分区格式。

为何常见:名字里带“文件系统”,又是操作系统的核心部分,初学者容易把抽象层和具体实现混为一谈。

误区dentry 既然叫目录项,那它应该就是磁盘上那个目录文件吧?

正解磁盘上的目录内容由具体文件系统管理,而 dentry 是内存中的缓存对象,只负责把路径分量与 inode 关联起来。快递员脑中记的“门牌”不等于写在墙上的真实门牌,前者可以擦掉重记,后者才是实体。

为何常见:“目录项”三个字本身具有迷惑性,加上路径解析中 dentry 出现频繁,容易误以为它对应磁盘持久化数据。

误区程序里调用 write 成功后,我马上断电重启,文件数据应该还在吧?

正解不一定。write 默认先把数据写入 page cache,标记为脏页后由后台线程稍后刷盘。如果断电发生在刷盘前,数据就会丢失。可以调用 fsync/fdatasync 强制刷盘,就像快递虽然已揽收,但还没装车,要求对方立刻发车才更稳妥。

为何常见:用户态编程中写文件“感觉”是同步操作,而内核为了性能引入了延迟回写,这个“延迟”和直觉冲突。

误区我删掉一个大日志文件,df 显示可用空间居然没变多,是不是系统坏了?

正解如果文件仍被某个进程打开,它的 inode 还活着,占用的数据块也不会释放,直到句柄关闭。另外,page cache 中残留的脏页也可能让空间“晚点”释放。这就像快递仓库里那个包裹虽然收货人已拒收,但运单没注销,货物就一直占着货架。

为何常见:用户只知道“删除”这个动作,不知道还有文件句柄、缓存引用等隐藏依赖,于是产生空间未释放的错觉。

误区性能差一定是磁盘慢,VFS 只是转发一下,肯定不是瓶颈。

正解高并发场景下,VFS 层的 dentry 查找、inode 引用计数、路径锁等都会成为瓶颈;尤其是大量小文件随机访问时,dcache 未命中会把压力传导到磁盘。快递中转站的调度台如果效率低,就算每个快递员都跑得飞快,整体吞吐照样上不去。

为何常见:VFS 对上层透明,让人误以为它没有开销,实际上它内部的锁和缓存策略直接影响每一次 I/O。

面试题

Q1. VFS 的主要作用是什么?

单选题 · easy

  • A.管理磁盘分区
  • B.为应用提供统一文件操作接口,屏蔽底层文件系统差异← 正确答案
  • C.负责磁盘坏道检测
  • D.将文件压缩后存储

答案:B。VFS 是内核中的抽象层,就像快递中转站为不同快递公司提供统一收件窗口。它不负责分区管理、坏道检测或压缩,只负责统一接口和请求分发,因此选 B。

Q2. dentry 的主要作用是什么?

单选题 · easy

  • A.保存文件的全部数据
  • B.描述路径分量到 inode 的映射← 正确答案
  • C.存储文件系统总块数
  • D.管理磁盘空闲空间

答案:B。dentry 是内存中的目录项缓存,相当于快递员脑中的门牌号,负责把“/home”这类路径分量映射到对应的 inode。文件数据由 inode 和数据块管理,总块数属于 superblock,因此选 B。

Q3. 路径 /home/user/a.txt 的解析起点是什么?

单选题 · easy

  • A.home 的 dentry
  • B.user 的 dentry
  • C.根目录的 dentry← 正确答案
  • D.文件系统的超级块

答案:C。路径解析像快递员从国家门牌开始找起,必须先从根 dentry 出发,逐级匹配 home、user、a.txt。所以起点是根目录的 dentry,选 C。

Q4. 哪个对象描述一个文件系统的全局信息(如总块数、挂载状态)?

单选题 · mid

  • A.file
  • B.inode
  • C.superblock← 正确答案
  • D.dentry

答案:C。superblock 是文件系统的“总调度表”,记录块大小、总块数、挂载状态等全局信息。inode 描述单个文件,dentry 描述路径映射,file 描述打开实例,因此选 C。

Q5. mount 一个文件系统后,VFS 会得到什么?

单选题 · mid

  • A.superblock 和 root_dentry← 正确答案
  • B.一个新的 file 对象
  • C.一个 page cache 指针
  • D.一个固定字节的 IOPS 值

答案:A。挂载的本质是把新快递公司接入中转站,VFS 调用具体文件系统的 mount 方法后,获得 superblock 和根 dentry,并把根 dentry 挂到挂载点上,因此选 A。

Q6. write 系统调用成功返回后,数据一定已经写入磁盘了吗?

单选题 · mid

  • A.一定在
  • B.不一定,可能还滞留在 page cache 中← 正确答案
  • C.取决于 dentry cache 是否命�中
  • D.取决于 superblock 的 inode 数

答案:B。write 默认先写入 page cache 并标记脏页,由后台线程延迟回写。就像快递已揽收但还没装车,所以成功返回不代表落盘。需要 fsync 才能保证数据持久化,选 B。

Q7. 下列哪种情况最容易导致 page cache 命中率下降?

单选题 · hard

  • A.使用 LRU 回收策略
  • B.顺序读大文件
  • C.数据总大小远超内存且随机读← 正确答案
  • D.设置 vm.vfs_cache_pressure = 0

答案:C。随机读且数据集远超内存时,每次访问大概率要换新页,缓存刚装入就被淘汰,命中率自然低。顺序读可以借助预读提高命中;LRU 只是回收策略;vfs_cache_pressure 主要影响 dentry/inode 缓存,不直接决定 page cache 命中率。

Q8. 下面哪些属于 VFS 的四大核心对象?

多选题 · mid

  • A.superblock← 正确答案
  • B.inode← 正确答案
  • C.dentry← 正确答案
  • D.block group descriptor

答案:ABC。四大核心对象是超级块、索引节点、目录项和文件对象。block group descriptor 是 ext4 等具体文件系统内部的块组描述符,不属于 VFS 抽象层,因此 D 不选。

Q9. 关于 VFS 与具体文件系统的关系,下列说法正确的是?

多选题 · hard

  • A.VFS 通过 file_operations 等函数指针调用具体文件系统实现← 正确答案
  • B.每种具体文件系统需要实现同一套 VFS 接口← 正确答案
  • C.VFS 会直接读取磁盘扇区
  • D.具体文件系统需要提供 super_operations、inode_operations 等函数表← 正确答案

答案:ABD。VFS 像中转站的统一合同,各快递公司必须按合同提供接单、派送等接口,所以 A、B、D 正确。VFS 本身不碰磁盘扇区,真正读盘的是具体文件系统,因此 C 错误。

Q10. 生产环境中删除大量文件后磁盘空间未释放,可能的原因有哪些?

多选题 · extreme

  • A.文件仍被进程打开,inode 被引用← 正确答案
  • B.page cache 中的脏页尚未回写← 正确答案
  • C.dentry cache 持有 inode 引用,阻止回收← 正确答案
  • D.磁盘分区发生物理损坏

答案:ABC。空间未释放常见于“货架和运单没清理干净”。文件被进程打开时 inode 仍被持有;脏页未回写也会让数据块保持占用;dentry cache 中的目录项如果仍引用 inode,同样会阻止 inode 释放。物理损坏会导致读写错误,但不是删除空间不释放的典型原因,因此 D 不选。