1.前言 Linux 采用 Virtual Filesystem(VFS) 的概念,通过内核在物理存储介质上的文件系统和用户之间建立起一个虚拟文件系统的软件抽象层,使得 Linux 能够支持目前绝大多数的文件系统,不论它是 windows 、unix 还是其他一些系统的文件系统,都可以挂载在 Linux 上供用户使用。 VFS, Virtual File System虚拟文件系统 ,也称为虚拟文件系统开关 (Virtual Filesystem Switch ),就是采用标准的Linux系统调用读写位于不同物理介质上的不同文件系统,即为各类文件系统提供了一个统一的操作界面和应用编程接口,VFS是一个内核软件层 。 VFS是一个 可以让open()、read()、write() 等系统调用不用关心底层的存储介质和文件系统类型就可以工作的抽象层 : 2.VFS结构 这里以Ext4文件系统示例 VFS中包含着向物理文件系统转换的一系列数据结构,如 VFS超级块(Super Block) 、VFS的Inode 、各种操作函数的转换入口等。Linux中VFS依靠四个主要的数据结构来描述其结构信息,分别为超级块、索引结点、目录项和文件对象 ,这些数据结构大都会与磁盘上的对应上。 VFS对每种类型的对象都定义了一组必须实现的操作。这些类型的每一个对象都包含了一个指向函数表的指针。函数表列出了实际上实现特定对象的操作函数。 超级块(Super Block) :超级块对象表示一个文件系统。它存储一个已安装的文件系统的控制信息,包括文件系统名称(比如Ext2)、文件系统的大小和状态、块设备的引用和元数据信息(比如空闲列表等等)。超级块与磁盘上文件系统的超级块对应。 所有超级块对象都以 双向循环链表 的形式链接在一起,对象的 自旋锁 ( sb_lock )保护链表免受多处理器系统上的同时访问。 索引结点(Inode) :索引结点对象存储文件的相关元数据信息,例如:文件大小、设备标识符、用户标识符、用户组标识符等等。Inode 分为两种:一种是VFS 的Inode ,一种是具体文件系统的Inode 。前者在内存中,后者在磁盘中。所以每次其实是将磁盘中的Inode 调进填充内存中的Inode ,这样才是算使用了磁盘文件Inode 。当创建一个文件的时候,就给文件分配了一个Inode 。一个Inode只对应一个实际文件,一个文件也会只有一个Inode (Unix/Linux系统 中目录也是一种文件,打开目录实际上就是打开目录文件。目录文件的结构非常简单,就是一系列目录项(dirent )的列表。每个目录项,由两部分组成:所包含文件的文件名,以及该文件名对应的inode号码)。 从文件的角度来看,目录就是一个特殊的文件 目录项(Dentry) :引入目录项对象的概念主要是出于方便查找文件的目的。不同于前面的两个对象,目录项对象只存在于内存中,实际对应的是磁盘的目录innode 对象。VFS 在查找的时候,根据一层一层的目录项找到对应的每个目录项的Inode ,那么沿着目录项进行操作就可以找到最终的文件。文件对象(File) :文件对象描述的是进程已经打开的文件。因为一个文件可以被多个进程打开,所以一个文件可以存在多个文件对象,但多个文件对象其对应的索引节点和目录项对象肯定是惟一的2.1 SuperBlock SuperBlock 表示特定加载的文件系统,用于描述和维护文件系统的状态,由 VFS 定义,但里面的数据根据具体的文件系统填充。每个 SuperBlock 代表了一个具体的磁盘分区,里面包含了当前磁盘分区的信息,如文件系统类型、剩余空间等。SuperBlock 的一个重要成员是链表s_list ,包含所有修改过的 INode ,使用该链表很容易区分出来哪个文件被修改过,并配合内核线程将数据写回磁盘。SuperBlock 的另一个重要成员是s_op ,定义了针对其 INode 的所有操作方法,例如标记、释放索引节点等一系列操作。// https://elixir.bootlin.com/linux/v6.0/source/include/linux/fs.h#L1451 结构体已删减 struct super_block { struct list_head s_list; // 指向链表的指针 dev_t s_dev; // 设备标识符 unsigned long s_blocksize; // 以字节为单位的块大小 loff_t s_maxbytes; // 文件大小上限 struct file_system_type *s_type; // 文件系统类型 const struct super_operations *s_op; // SuperBlock 操作函数,write_inode、put_inode 等 const struct dquot_operations *dq_op; // 磁盘限额函数 struct dentry *s_root; // 根目录 } 2.2 DEntry 和 INode Linux 文件系统会为每个文件都分配两个数据结构,目录项(DEntry, Directory Entry)和索引节点(INode, Index Node) 。 DEntry 用来保存文件路径和 INode 之间的映射,从而支持在文件系统中移动。DEntry 由 VFS 维护,所有文件系统共享,不和具体的进程关联。dentry 对象从根目录"/" 开始,每个dentry 对象都会持有自己的子目录和文件,这样就形成了文件树。举例来说,如果要访问**"/home/ccs/a.txt"文件并对他操作,系统会解析文件路径,首先从"/"根目录的 dentry 对象开始访问,然后找到"home/"目录,其次是 "ccs/",最后找到 "a.txt"**的dentry 结构体,该结构体里面d_inode 字段就对应着该文件。// https://elixir.bootlin.com/linux/v6.0/source/include/linux/dcache.h#L81 结构体已删减 struct dentry { struct dentry *d_parent; // 父目录 struct qstr d_name; // 文件名称 struct inode *d_inode; // 关联的 inode struct list_head d_child; // 父目录中的子目录和文件 struct list_head d_subdirs; // 当前目录中的子目录和文件 } 每一个 dentry 对象都持有一个对应的inode 对象,表示 Linux 中一个具体的目录项或文件。INode 包含管理文件系统中的对象所需的所有元数据,以及可以在该文件对象上执行的操作。// https://elixir.bootlin.com/linux/v6.0/source/include/linux/fs.h#L593 结构体已删减 struct inode { umode_t i_mode; // 文件权限及类型 kuid_t i_uid; // user id kgid_t i_gid; // group id const struct inode_operations *i_op; // inode 操作函数,如 create,mkdir,lookup,rename 等 struct super_block *i_sb; // 所属的 SuperBlock loff_t i_size; // 文件大小 struct timespec i_atime; // 文件最后访问时间 struct timespec i_mtime; // 文件最后修改时间 struct timespec i_ctime; // 文件元数据最后修改时间(包括文件名称) const struct file_operations *i_fop; // 文件操作函数,open、write 等 void *i_private; // 文件系统的私有数据 } 虚拟文件系统维护了一个 DEntry Cache 缓存,用来保存最近使用的 DEntry ,加速查询操作。当调用open() 函数打开一个文件时,内核会第一时间根据文件路径到 DEntry Cache 里面寻找相应的 DEntry ,找到了就直接构造一个file 对象并返回。如果该文件不在缓存中,那么 VFS 会根据找到的最近目录一级一级地向下加载,直到找到相应的文件。期间 VFS 会缓存所有被加载生成的dentry 。 INode 存储的数据存放在磁盘上,由具体的文件系统进行组织,当需要访问一个 INode 时,会由文件系统从磁盘上加载相应的数据并构造 INode 。一个 INode 可能被多个 DEntry 所关联,即相当于为某一文件创建了多个文件路径(通常是为文件建立硬链接)。对于inode结构而言,可能有三种主要情况: 存在内存中,未关联到任何文件,也不处于活动使用状态; 存在内存中,正在由一个或多个进程使用,正在由一个或多个进程使用,通常表示一个文件。两个计数器(i_count和i_nlink)的值都必须大于0。文件内容和inode元数据都与底层块设备上的信息相同。也就是表示从上一次与介质同步依赖,该inode没有改变过; 处于活动使用状态。其数据内容已经改变,与存储介质上的内容不同。这种状态的inode被称作脏的。 2.3 fd 与 file 每个进程都持有一个 fd[] 数组,数组里面存放的是指向file 结构体的指针,同一进程的不同fd 可以指向同一个file 对象; file 是内核中的数据结构,表示一个被进程打开的文件,和进程相关联。当应用程序调用open() 函数的时候,VFS 就会创建相应的file 对象。它会保存打开文件的状态,例如文件权限、路径、偏移量等等。// https://elixir.bootlin.com/linux/v6.0/source/include/linux/fs.h#L940 结构体已删减 struct file { struct path f_path; struct inode *f_inode; const struct file_operations *f_op; unsigned int f_flags; fmode_t f_mode; loff_t f_pos; struct fown_struct f_owner; } // https://elixir.bootlin.com/linux/v6.0/source/include/linux/path.h#L8 struct path { struct vfsmount *mnt; struct dentry *dentry; } 3. 抽象层VFS到实现层文件系统3.1挂载 VFS 可以管理各种文件系统,那么VFS 和文件系统怎么关联的呢?给用户如何展示的呢?通过挂载。 如下图所示,该系统根文件系统是 Ext3 文件系统,而在其/mnt 目录下面又分别挂载了Ext4 文件系统和XFS 文件系统。最后形成了一个由多个文件系统组成的文件系统树。 挂载是在用户态发起的命令,也就是我们使用的 mount命令 ,该命令执行的时候需要指定文件系统的类型 (这里假设是Ext2 )和文件系统数据的位置 (也就是device )。通过这些关键信息,VFS 就可以完成Ext2文件系统的初始化,并将其关联到当前已经存在的文件系统当中,也就是建立起上面所示的文件系统树 。 挂载的过程中,最重要的数据结构就是 vfsmount ,vfsmount 代表的是一个挂载点。其次再是dentry 和inode ,这两个都是对文件的表示,且都会缓存在哈希表中以提高查找的效率。 其中inode是对磁盘上文件的唯一表示,其中包含文件的元数据(管理数据)和文件数据等内容,但不含文件名称。而 dentry 则是为了Linux 内核中查找文件方便虚拟出来的一个数据结构,其中包含文件名称、子目录(如果存在的话)和关联的inode 等信息。 dentry 结构体最为关键,其维护了内核中的文件目录树。其中里面比较重要的几个结构体分别是d_name 、d_hash 和d_subdirs 。其中d_name 代表一个路径节点的名称(文件夹名称)、d_hash 则用于构建哈希表,d_subdirs 则是下级目录(或文件)的列表。这样,通过dentry 就可以形成一个非常复杂的目录树。3.2文件处理流程 文件处理流程包括两步:我们在访问一个文件之前首先要打开它( open )文件访问,然后进行文件的读写操作(read 或者write )。 我们知道,在用户态打开一个文件是返回的是一个文件描述符,其实也就是一个整数值;同时,访问文件也是通过这个文件描述符进行的。那么操作系统是怎么通过这个整数值实现不同类型文件系统的访问呢? 不同文件系统的差异 其实就是inode中初始化的函数指针的差异 。 在 Linux 操作系统中,文件的打开必须要与进程(或者线程)关联,也就是说一个打开的文件必须隶属于某个进程。 在 linux 内核当中一个进程通过task_struct 结构体描述,而打开的文件则用file 结构体描述,打开文件 的过程也就是对file结构体的初始化的过程 。在打开文件的过程中会将inode 部分关键信息填充到file 中,特别是文件操作的函数指针。在task_struct 中保存着一个file类型的数组,而用户态的文件描述符其实就是数组的下标。这样通过文件描述符就可以很容易到找到file ,然后通过其中的函数指针访问数据。 我们以 Ext2 文件系统的写数据为例 来看看文件处理流程和各个层级之间的关系,如下图。 在调用用户态的写数据接口的时候,需要传入文件描述符。内核根据文件描述符找到file,然后调用函数接口( file->f_op->write )文件磁盘数据。其中file结构体的f_op 指针就是在打开文件的时候通过inode 初始化的。4.总结 虚拟文件系统是操作系统中非常重要的一层抽象, 其主要作用在于让上层的软件,能够用统一的方式,与底层不同的文件系统沟通 。在操作系统与底层的各类文件系统之间,虚拟文件系统提供了标准的操作接口,让操作系统能够很快地支持新的文件系统。也因为 VFS 的支持,众多不同的实际文件系统才能在 Linux 中共存,跨文件系统的操作才能实现。 附上一张各组件交互图吧 参考资料: 《深入理解Linux内核》第三版 《Linux 虚拟文件系统》