news 2026/2/26 22:14:28

深入揭秘 Linux 虚拟文件系统 VFS(上)

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
深入揭秘 Linux 虚拟文件系统 VFS(上)

1. 前言

在学习**虚拟文件系统(VFS)**之前,我们应该先了解一下它的出现是为了解决什么问题。

不论是在以前还是现在,Linux 都支持着好几十种文件系统类型,我们在 Shell 里输入ls -l看到的都是一行描述文件信息的字,但它们背后的世界却完全不同,下面简单介绍几个文件系统:

  1. EXT4文件:Linux 的标准文件系统。它是硬盘盘片上磁性颗粒的翻转,或者是 SSD 里的电荷状态。我们读取这类文件时需要驱动程序去操作SATA/NVMe 控制器,向传统机械硬盘或者现代高性能固态硬盘发送指令,把数据从磁盘扇区读到内存。这种文件的特点是断电后数据还在。
  2. FAT32文件:一种比较古老的标准,但至今仍然广泛用于嵌入式系统和 U 盘,它的设计原理是链表结构。相比 Linux 原生文件系统最大的区别是FAT32 没有 Inode,元数据直接塞在了目录项中。此外,由于它自身链表结构的局限性,读取文件尾部数据的效率极低,因为必须从头遍历链表。
  3. PROCFS文件:相比前面介绍的两个文件系统,procfs文件(通常挂载在/proc)完全打破了我们对文件的固有认知,它是无实体的。举个例子,我们在/proc/meminfo文件中看到的每一个字节都不存在于磁盘上面。可能会有人好奇文件里面的数据怎么来的,这里简单介绍一下:procfs是一种接口映射,当对/proc下的文件发起read()请求时,内核并不会去读硬件,而是触发了一系列回调函数,函数中会统计需要的信息并转换为字符串,拷贝给用户程序。打个比方吧:对于普通的文件,可以把他当做一个仓库,你打开仓库门,东西就在里面放着。而对于这类文件,它只是一个类似于传送门的东西,门后面什么都没有,你打开门的这个操作会触发一系列连锁反应从而将你需要的数据传送过来。

这里只介绍这三种比较经典的文件系统。

现在,试着想象一下你在编写内核的sys_read系统调用,我们需要对上面的三种情况分别进行处理:

  1. 对于 EXT4 文件,需要读磁盘,解析 B-Tree 结构,操作 Inode。
  2. 对于 FAT32 文件,需要读磁盘,解析 FAT 链表,而且由于没有 Inode,Linux 为了统一管理,需要强行给 FAT32 创建一个 Inode。
  3. 对于 PROCFS 文件,不能读磁盘,需要去调用内核内部的某个统计函数来获取信息。

这不仅意味着每增加一种新的文件系统,都要重写内核核心代码,还意味着应用程序的开发者需要了解底层的每一个细节,这对应用程序的开发也会带来巨大的不便。

这就是 VFS 诞生的原因:它不是为了创造一种新的文件存储格式,而是为了平息这场各种文件系统百家争鸣的混乱局面,强行制定一种通用的标准的文件系统层,也就是 VFS 虚拟文件系统。

显然,VFS 作为 Linux 内核提供的软件抽象层,必然是位于系统调用之下,各种不同文件系统之上的。

对上,VFS 向用户程序提供统一的视角,抹平底层文件系统的差异。

对下,要求 EXT4、FAT32、PROCFS 必须适配这套接口。哪怕 FAT32 没有 Inode,驱动程序也必须在内存里伪造一个给 VFS 看。

下面,我们将结合内核源码,看看 VFS 是如何实现这一设计的。

2. VFS 核心架构

第一章的内容中提到过 VFS 是一个抽象层,这往往意味着我们需要定义一套通用的接口,让底层去设计具体的实现方式。

熟悉 C++ 和 java 的可能已经知道了这其实就是多态,定义一个基类再写几个虚函数不就行了?

逻辑上说得过去,但是 Linux 内核是用 C 语言写的,而 C 语言本身并不支持类和继承。那么,编写 Linux 内核的那些大佬们是如何用 C 语言实现一套面向对象架构的呢?

答案就是极其朴素的结构体加上函数指针

2.1 四个核心对象

为了管理所有的文件系统,VFS 抽象出了四个核心对象,无论底层是 EXT4、FAT32 还是 NFS,在 VFS 这一层看来,都必须被转换成这四个对象。这四个对象实际上构成了 VFS 的骨架,他们分别是:

  1. Superblock 超级块: 代表整个文件系统。
  2. Inode 索引节点:代表一个具体的文件。
  3. Dentry 目录项:代表路径和文件名。
  4. File 文件对象:代表进程打开的一个文件。

这四个概念尤其对于新手来说,非常容易混淆,在理解上也会存在不少误区,这一小节我们结合内核源码详细拆解一下。

此外,还需要提一点,我使用的内核源码是 Linux 5.10 版本,由于这四个对象涉及到的结构体通常都比较庞大,我会对相关结构体的成员进行缩减,只保留我们需要了解的核心的成员,并以代码块的形式放在文中。

2.1.1 超级块 struct super_block

这是一个文件系统的对象,表示一个已经挂载的文件系统,记录了块大小,文件系统类型,根目录等内容。

当我们在 Shell 中执行下面命令:

mount-t ext4 /dev/sda1 /mnt

我们首先来拆解一下这个命令到底在干什么:这条命令的作用是将/dev目录下的sda1这个文件挂载到/mnt目录下,-t选项是type的缩写,用来指定文件系统的类型。总结一下,就是把类型为EXT4的文件系统sda1挂载到/mnt目录下。

拆解完了这个命令我们继续往下。

在挂载的这条命令执行的同时,内核会在内存中创建一个struct super_block结构体来代表这个挂载的具体文件系统实例,而这个结构体存储的就是我们已经挂载的文件系统sda1的相关信息。

该结构体在内核源码中的位置为:include/linux/fs.h

核心成员参考下面代码块,同时我添加了一些注释方便大家理解:

structsuper_block{structlist_heads_list;//系统中所有superblock的链表dev_ts_dev;//存储设备的设备号unsignedchars_blocksize_bits;//块大小的位数,如4k就是12位,2^12=4Kunsignedlongs_blocksize;//块大小,以字节为单位loff_ts_maxbytes;//该文件系统支持的最大文件大小structfile_system_type*s_type;//指向该文件系统类型驱动conststructsuper_operations*s_op;//超级块的操作函数集structdentry*s_root;//指向该文件系统根目录的dentrystructlist_heads_inodes;//该super_block下所有inode的链表void*s_fs_info;//指向具体文件系统的私有数据/*其余成员省略*/};

由于 Linux 系统需要统一集中管理超级块,所以将所有struct super_block结构体都用链表串起来。要注意这里使用的是侵入式链表,在Linux 内核中经常使用这种链表,我前面还写过一篇这个相关的内容,想了解侵入式链表具体实现的可以去看看。

还包含一些内容就是我们前面提到的文件系统的块大小,文件系统类型等信息。

一个重要的成员就是s_op,它是一个函数指针表。它的作用是:当 VFS 需要写回脏的superblock或者分配inode时,它就会调用sb->s_op->write_super()sb->s_op->alloc_inode()。这种函数指针表在 Linux 内核中很多结构体中都会出现,对应的成员通常都是以_op结尾的,后面我们还会看到几个。这种操作函数集本质上就是将某个操作与相应的函数对应起来

还有一个重要的成员是s_fs_info,它是 VFS 实现抽象的关键。打个比方说明一下它的作用:VFS 其实并不知道 EXT4 的磁盘布局,但 EXT4 需要在内存里存一些只有它自己懂的全局信息,比如inode table在磁盘的哪一块。EXT4 会分配一个struct ext4_sb_info用来存储那些只有它懂的信息,并将s_fs_info指针指向它,这就是 C 语言实现继承的方式。

2.1.2 索引节点 struct inode

struct inode是 VFS 中最核心的概念,它代表了文件在磁盘上的实体

该结构体存储的内容包含文件的元数据:大小,权限,所有者,时间戳,以及数据块在磁盘上的位置等。

但是要特别强调一点:它并不存储文件名称。这个其实很好理解,就拿我们每个人自己来举例:struct inode代表的是这个人的实体,也就是这个人本身的一些属性,比如身高体重多少,肺活量等等,而名字只是一个称呼而已,它不论叫张三还是李四,这些固有属性是不会变的。

记得第一章说的吗?FAT32 磁盘上没有 Inode,但 VFS 又必须要 Inode。所以当 Linux 挂载 FAT32 时,内核会在内存中现场捏造一个struct inode结构体,填入必要的信息,这就是 VFS 标准化的威力——不管你底层有没有这个东西,上层需要你就必须有

该结构体在源码目录的位置如下:include/linux/fs.h

structinode{umode_ti_mode;//访问权限和文件类型kuid_ti_uid;//所有者IDkgid_ti_gid;//组IDconststructinode_operations*i_op;//inode的操作集structsuper_block*i_sb;//反向指针,指向所属的super_blockstructaddress_space*i_mapping;//指向页缓存的核心结构unsignedlongi_ino;//inode号loff_ti_size;//文件大小structtimespec64i_atime;//访问时间structtimespec64i_mtime;//修改时间structtimespec64i_ctime;//状态改变时间conststructfile_operations*i_fop;//默认的文件操作集, open, read, writestructaddress_spacei_data;union{structhlist_headi_dentry;//指向引用该inode的dentry链表structrcu_headi_rcu;};void*i_private;//具体文件系统的私有数据/*其他成员*/};

在同一个super_block中,i_ino唯一标识一个struct inode,内核会在内存中缓存struct inode

这里有一个容易混淆的点:i_opi_fopi_op涉及inode 本身目录项创建的操作,比如mkdirunlinklookup等等。i_fop涉及文件内容的操作,当文件被open后,这个指针会被复制到struct file中,struct file是我们后面要讲的,i_fop的相关操作都有readwriteioctlmmap等。

i_mapping指向struct address_space结构体,管理着文件的Page Cache(页缓存),对于普通文件,它通常指向struct inode自身的i_data,对于块设备文件,它可能指向块设备的struct address_space

2.1.3 目录项 struct dentry

上面我们已经讲过,文件名不在struct inode里面,那么它在哪呢?答案是struct dentry目录项中。

Linux 为了加速文件查找,不仅仅将目录看作一种特殊的文件,还专门引入了struct dentry结构体在内存中缓存路径与struct inode的映射关系。

这里要先声明一下:

  1. 虽然我们把dentry叫做目录项,但并不是只有目录才有dentry,每一个文件都有自己的dentry
  2. Dcache(Dentry Cache)是 VFS 性能的关键,内核不会每次都去读取磁盘上的目录文件来解析路径,而是将解析过的路径缓存在内存中的dentry树中,以便下次访问。

strcut denrty这个结构体的定义位于内核源码目录:include/linux/dcache.h

主要成员如下:

structdentry{unsignedintd_flags;seqcount_spinlock_td_seq;//锁机制,用于无锁查找structhlist_bl_noded_hash;//用于在dcache哈希表中查找structdentry*d_parent;//指向父目录的dentrystructqstrd_name;//文件名,这里存储了字符串structinode*d_inode;//指向该dentry对应的inodeunsignedchard_iname[DNAME_INLINE_LEN];//短文件名直接存在这里,不用分配内存structlockrefd_lockref;conststructdentry_operations*d_op;//dentry操作集structsuper_block*d_sb;//指向所属的superblockstructlist_headd_child;//挂入父目录的子节点链表structlist_headd_subdirs;//本目录下的子节点链表/*其他成员*/};

d_name是一个快速字符串结构,包含指向字符串的指针字符串的哈希值,VFS 在查找文件时,先算哈希值,再去d_hash哈希表中找,速度极快。

对于d_inode,有下面两种情况:

  1. Positive Dentry:d_inode指向一个有效的inode
  2. Negative Dentry:d_inodeNULL,这非常重要。当你ls /tmp/bu_cun_zai_de_wen_jian时,内核解析后发现文件不存在,它依然会创建一个dentry,但把d_inode设为NULL。下次你再访问这个不存在的文件,内核直接看缓存就知道不存在,不用去读盘,这也是一个性能优化的方式。

d_parentd_childd_subdirs这些指针共同构成了内存中的目录树结构。

2.1.4 文件对象 struct file

这是用户态程序接触最多的对象,当你调用open()系统调用成功后,内核就会创建一个struct file结构体。

它代表一个打开的文件实例,也就是进程与文件的一次交互对话。

该结构体在内核源码中的位置如下:include/linux/fs.h

structfile{union{structllist_nodefu_llist;structrcu_headfu_rcuhead;}f_u;structpathf_path;//包含{mnt, dentry}structinode*f_inode;//指向对应的inode,也就是f_path.dentry->d_inodeconststructfile_operations*f_op;//操作函数集read, writespinlock_tf_lock;//锁atomic_long_tf_count;//引用计数unsignedintf_flags;//open时的标志(O_RDONLY, O_NONBLOCK等)fmode_tf_mode;//读写模式(FMODE_READ, FMODE_WRITE)loff_tf_pos;//当前文件读写位置structfown_structf_owner;void*private_data;//具体驱动或文件系统的私有数据structaddress_space*f_mapping;//指向 inode->i_mapping}__randomize_layout;

在 Linux 5.10 内核版本,dentryvfsmount指针被封装在struct path中:

structpath{structvfsmount*mnt;//属于哪个挂载点structdentry*dentry;//指向哪个目录项};

这充分说明了:唯一确定一个打开的文件,不仅需要知道它是哪个文件(dentry),还需要知道它是从哪个挂载点(mnt)访问的

f_posstruct file存在的最大意义之一,两个进程打开同一个文件,它们共享同一个struct inode,但各自拥有独立的struct file和独立的f_pos。所以进程 A 读前 100 字节,不会影响进程 B 从头读取。

对于f_op, 当open发生时,VFS 会从inode->i_fop拷贝指针到file->f_op。之后该文件的read/write操作都直接用file->f_op,这允许驱动程序在open时动态替换操作集

2.2 四个对象之间的协作关系

上一节我们已经分别了解了四个核心结构体的功能,这一节我们把他们串起来,看看他们是怎样动态协作的。

最经典的场景莫过于两个进程打开同一个文件了。

为了搞懂这个问题,我们需要理清从用户态的文件描述符fd到磁盘inode的完整索引链。请看下图:

大家可以结合上图的箭头指向关系来理解下面的内容:

  1. 用户空间使用open打开一个文件,得到这个打开的文件实例对应的文件描述符fd,该fd存放在进程控制块(task_struct)中的文件描述符表中。
  2. 当用户空间进行系统调用read(fd, buf, len)时,CPU 陷入内核态。内核通过进程控制块(task_struct)中的文件描述符表(files_struct),用fd作为索引,找到了一个指向struct file的指针。
  3. 找到了struct file,就等于找到了这个打开的文件实例。上面已经提到过struct file中存放着f_pos,也就是当前读写位置。这就是为什么进程 A 读到了第 100 字节,而进程 B 刚打开文件还是从 0 开始读,因为它们各自拥有独立的struct file结构体,他们的读写位置f_pos也是独立的。这也恰恰说明了为什么我们会把struct file对应一个打开的文件实例,大家可以仔细体会一下**“打开的文件实例”**这个词。
  4. 我们知道struct file中并没有文件名,内核是通过f_path.dentry指针找到struct dentry的。在struct dentry中,内核确认了文件名,并确认了它在目录树中的位置。如果这是一个硬链接文件,它有着自己独立的struct dentry,这就意味着它也有着自己独立的文件名,但这个硬链接的struct dentry和原文件的struct dentry最终都会指向同一个struct inode
  5. 到现在,我们终于找到了文件的真身struct inode。不管有多少个进程打开它,也不管有多少个硬链接指向它,在内存中,针对该物理文件,struct inode 永远只有一个。这里存储着文件的物理大小、权限,以及操作磁盘的函数集合i_op
  6. 如果需要读取磁盘数据,它会通过struct inode中的i_sb指针找到所属的super_block,从而获取块大小、文件系统类型等全局信息,最终驱动硬件完成数据拷贝。

2.3 以VFS 的视角看Linux 的文件特性

理解了上面那张图,就基本掌握了 Linux 文件系统。很多看似玄学的 Linux 文件特性,如果从 VFS 的数据结构出发,一切都变得合理甚至有趣起来。

我们从 VFS 的角度,重新审视几个经典的场景:

2.3.1 进程间共享文件

进程 A 和进程 B 同时打开了同一个日志文件a.log,进程 A 正在写入第 100 行,进程 B 正在读取第 1 行。为什么它们不会干扰到对方的工作?

站在 VFS 的角度来分析这个场景,这个文件对于两个进程来说既是隔离的,又是共享的

  1. 隔离:由于两个进程 A 和 B 都打开了a.log文件,因此他们各自都拥有一个独立的struct file,每个struct file内部都有一个f_pos成员来记录当前的读写偏移量。A 的f_pos指向文件尾,B 的f_pos指向文件头,互不影响。
  2. 共享:两个进程独立的struct file最终都指向同一个struct inode,这意味着它们操作的是同一块物理磁盘空间,如果 A 修改了文件内容,B 读到的也会是修改后的内容,这就涉及到页缓存 Page Cache 的同步机制,不是我们本篇文章的重点。

2.3.2 硬链接本质

我们在 Shell 中执行命令ln a.txt b.txt,会发现a.txtb.txt拥有相同的Inode号,修改其中一个,另一个也变了,删除其中一个,文件数据却还在。

这是因为硬链接的本质是:N 个 Dentry 指向 1 个 Inode

当你创建硬链接时,内核并没有复制文件数据,仅仅是新建了一个struct dentry(名为b.txt),并将它的d_inode指针指向了原有的那个struct inode,同时,该Inode内部的引用计数i_nlink加 1。

当你执行rm a.txt时,内核只是删除了a.txt这个dentry,并将inode->i_nlink减 1。只有当i_nlink变为 0 时,内核才会真正释放Inode和磁盘上的数据块。

理解了引用计数的原理,就理解了为什么删除原文件a.txt之后,硬链接a.txt依然能够访问文件数据。

大家再来思考一下另一个问题:为什么 Linux 不允许给目录做硬链接?为什么硬链接不能跨分区?

其实原理前面也讲过了,每个 Superblock 超级块都管理着自己的 Inode 编号,也就是说对于两个不同的 Superblock,即使 Inode 相同,那也完全是两码事。而 dentry 只能指向同一个 Superblock 下的 Inode,没法指到别人的地盘去。

至于为什么目录不能做硬链接,原理就更简单了,想象一下你现在已经给一个目录做了硬链接,并且你正处于该目录的一个子目录中,这时你执行cd ..命令,会切换到那个文件?答案是不确定。

要想追究其深层原理,我们需要知道文件系统需要保证从根目录/到任何文件只有唯一一条路径。如果可以给目录做硬链接,那么显然违背了这条定理。

2.3.3 软链接本质

我们执行命令ln -s a.txt link_a,就创建了a.txt文件的软链接link_a,这类似于 Windows 的快捷方式。

软链接和硬链接完全不同,它是一个独立的文件

软链接有自己独立的struct dentry,也有自己独立的struct inode。普通文件的Data Block存的是文件内容,而软链接文件的 Data Block 存的是目标文件的路径字符串

当 VFS 访问软链接时,发现其 Inode 类型是S_IFLNK,于是内核会读取它存储的路径,触发一次重定向,重新去解析那个新的路径。

2.3.4 mv 移动文件原理

假如你有一个 100GB 的大文件,在同一个磁盘分区内,把它从/download目录移动到/movie目录,这通常是瞬间完成的。

为什么能瞬间完成呢?这是因为根本没有发生数据搬运。

mv在 VFS 层面,只是修改了目录树的指针关系,内核把代表电影文件的dentry/download的子节点链表摘下来,挂到了/movie的子节点链表上,文件背后的 Inode 和磁盘上的 100GB 数据纹丝不动。

注意:如果是跨分区移动,比如mv /dev/sda1/file /dev/sdb1/file,那就必须先拷贝数据,创建新的 Inode,再删除旧文件,这时候就很慢了。原理上面也讲过,dentry不能指向别的分区。

3. 总结

到现在,本篇文章的内容也已经不少了,但要说讲了什么,其实重点并不多,仅仅是四个核心结构体,大部分的文字都是对这些结构体的解释,以及一些例子帮助大家能够理解这四个结构体的协作关系。还讲了在 VFS 视角下我们常见的一些场景,相信大家读完这篇文章,那些概念已经不再是一些冰冷的文字组合,而是实实在在的刻在脑海中的对 Linux 虚拟文件系统的理解。

以后当别人问起你对硬链接的看法时,你不再是机械式的背诵书本上的概念,而是从创建一个硬链接说起,一直到删除这个硬链接时的引用计数减一,详细的描述内核到底干了什么。这就是我们学习底层原理要达到的效果。

本篇文章是我们深入学习 VFS 的上篇,在这篇文章中虽然我们已经对 VFS 有了一个大致的了解,但还只是停留在比较浅的层次。稍后发的下篇内容将会更加深入内核源码,从具体的系统调用入手,看看系统是怎样查找一个具体文件的路径的。

本篇完。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/2/25 16:06:56

软件测试中的白盒测试,这些技巧你知道吗?

对于很多刚开始学习软件测试的小伙伴来说,如果能尽早将黑盒、白盒测试弄明白,掌握两种测试的结论和基本原理,将对自己后期的学习有较好的帮助。今天,我们就来聊聊黑盒、白盒测试的相关话题。 1、黑盒测试的方法和小结 最常见黑盒…

作者头像 李华
网站建设 2026/2/18 6:07:03

中专学历转行本地电商数据分析的可行性分析

行业背景与需求 本地电商行业近年来快速发展,数据驱动决策成为核心竞争力。企业对数据分析人才的需求持续增长,尤其是能够结合本地市场特点进行精准分析的专业人员。 本地电商数据分析岗位需求技能要求薪资范围(初级)销售数据分…

作者头像 李华
网站建设 2026/2/23 19:31:35

大专学历出纳转型财务BP的路径规划

财务BP(Business Partner)是企业财务与业务深度融合的岗位,需具备数据分析、业务洞察和战略支持能力。以下从技能提升、证书考取、实战经验等维度,为出纳转型财务BP提供具体方案。 核心能力对比分析 出纳岗位能力财务BP岗位能力提…

作者头像 李华
网站建设 2026/2/18 13:16:42

pytest实战技巧之参数化应用

pytest是Python中最流行的测试框架之一。它提供了丰富的功能,可以帮助我们编写高效、可靠的测试用例。其中一个重要的功能就是参数化,它可以让我们用不同的数据组合来运行同一个测试用例,从而 提高测试覆盖率和效率。本文将介绍pytest参数化的…

作者头像 李华
网站建设 2026/2/26 0:06:11

基于单片机的数显照度计的设计

基于单片机的数显照度计的设计 一、设计背景与意义 在工业生产、农业种植、建筑照明、科研实验等领域,光照强度是影响生产效率、产品质量与实验精度的关键环境参数。传统照度计多采用模拟电路设计,存在测量精度低、读数误差大、操作繁琐等问题&#xff0…

作者头像 李华
网站建设 2026/2/10 9:31:02

一款带空间音效的蓝牙耳机如何定义沉浸听感与音质体验?

2025年,倍思与音频巨头Bose携手推出Inspire系列耳机,正式进军高端市场。该系列作为其“专业音频大众化”理念的落地实践,以旗舰级配置,迅速成为广大用户关注的焦点。尤其在消费者重点关注的空间音效维度,其表现卓越。正如系列代表型号之一Inspire XH1,便是一款能够带来深度沉浸…

作者头像 李华