目录

Linux 虚拟文件系统

文件系统是对一个存储设备上的数据和元数据进行组织的机制,由于定义如此宽泛,各个文件系统的实现也大不相同,其中常见的文件系统有 ext4、NFS、/proc 等。Linux 采用为分层的体系结构,将用户接口层、文件系统实现和存储设备的驱动程序分隔开,进而兼容不同的文件系统。

虚拟文件系统(Virtual File System, VFS)是 Linux 内核中的软件层,它在内核中提供了一组标准的、抽象的文件操作,允许不同的文件系统实现共存,并向用户空间程序提供统一的文件系统接口。下面这张图展示了 Linux 虚拟文件系统的整体结构:

/posts/linux/vfs/vfs-architecture@2x.png

上图修改自:《Linux 文件系统剖析》图 1. Linux 文件系统组件的体系结构

从上图可以看出,用户空间的应用程序直接、或是通过编程语言提供的库函数间接调用内核提供的 System Call 接口(如open()write()等)执行文件操作。System Call 接口再将应用程序的参数传递给虚拟文件系统进行处理。

每个文件系统都为 VFS 实现了一组通用接口,具体的文件系统根据自己对磁盘上数据的组织方式操作相应的数据。当应用程序操作某个文件时,VFS 会根据文件路径找到相应的挂载点,得到具体的文件系统信息,然后调用该文件系统的对应操作函数。

VFS 提供了两个针对文件系统对象的缓存 INode Cache 和 DEntry Cache,它们缓存最近使用过的文件系统对象,用来加快对 INode 和 DEntry 的访问。Linux 内核还提供了 Buffer Cache 缓冲区,用来缓存文件系统和相关块设备之间的请求,减少访问物理设备的次数,加快访问速度。Buffer Cache 以 LRU 列表的形式管理缓冲区。

VFS 的好处是实现了应用程序的文件操作与具体的文件系统的解耦,使得编程更加容易:

  • 应用层程序只要使用 VFS 对外提供的read()write()等接口就可以执行文件操作,不需要关心底层文件系统的实现细节;
  • 文件系统只需要实现 VFS 接口就可以兼容 Linux,方便移植与维护;
  • 无需关注具体的实现细节,就实现跨文件系统的文件操作。

了解 Linux 文件系统的整体结构后,下面主要分析 Linux VFS 的技术原理。由于文件系统与设备驱动的实现非常复杂,笔者也未接触过这方面的内容,因此文中不会涉及具体文件系统的实现。

VFS 结构

Linux 以一组通用对象的角度看待所有文件系统,每一级对象之间的关系如下图所示:

/posts/linux/vfs/vfs-object@2x.png

fd 与 file

每个进程都持有一个fd[]数组,数组里面存放的是指向file结构体的指针,同一进程的不同fd可以指向同一个file对象;

file是内核中的数据结构,表示一个被进程打开的文件,和进程相关联。当应用程序调用open()函数的时候,VFS 就会创建相应的file对象。它会保存打开文件的状态,例如文件权限、路径、偏移量等等。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// https://elixir.bootlin.com/linux/v5.4.93/source/include/linux/fs.h#L936 结构体已删减
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/v5.4.93/source/include/linux/path.h#L8
struct path {
    struct vfsmount  *mnt;
    struct dentry    *dentry;
}

从上面的代码可以看出,文件的路径实际上是一个指向 DEntry 结构体的指针,VFS 通过 DEntry 索引到文件的位置。

除了文件偏移量f_pos是进程私有的数据外,其他的数据都来自于 INode 和 DEntry,和所有进程共享。不同进程的file对象可以指向同一个 DEntry 和 Inode,从而实现文件的共享。

DEntry 与 INode

Linux文件系统会为每个文件都分配两个数据结构,目录项(DEntry, Directory Entry)和索引节点(INode, Index Node)。

DEntry 用来保存文件路径和 INode 之间的映射,从而支持在文件系统中移动。DEntry 由 VFS 维护,所有文件系统共享,不和具体的进程关联。dentry对象从根目录“/”开始,每个dentry对象都会持有自己的子目录和文件,这样就形成了文件树。举例来说,如果要访问"/home/beihai/a.txt"文件并对他操作,系统会解析文件路径,首先从“/”根目录的dentry对象开始访问,然后找到"home/“目录,其次是“beihai/”,最后找到“a.txt”的dentry结构体,该结构体里面d_inode字段就对应着该文件。

1
2
3
4
5
6
7
8
// https://elixir.bootlin.com/linux/v5.4.93/source/include/linux/dcache.h#L89 结构体已删减
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 包含管理文件系统中的对象所需的所有元数据,以及可以在该文件对象上执行的操作。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// https://elixir.bootlin.com/linux/v5.4.93/source/include/linux/fs.h#L628 结构体已删减
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 所关联,即相当于为某一文件创建了多个文件路径(通常是为文件建立硬链接)。

SuperBlock

SuperBlock 表示特定加载的文件系统,用于描述和维护文件系统的状态,由 VFS 定义,但里面的数据根据具体的文件系统填充。每个 SuperBlock 代表了一个具体的磁盘分区,里面包含了当前磁盘分区的信息,如文件系统类型、剩余空间等。SuperBlock 的一个重要成员是链表s_list,包含所有修改过的 INode,使用该链表很容易区分出来哪个文件被修改过,并配合内核线程将数据写回磁盘。SuperBlock 的另一个重要成员是s_op,定义了针对其 INode 的所有操作方法,例如标记、释放索引节点等一系列操作。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// https://elixir.bootlin.com/linux/v5.4.93/source/include/linux/fs.h#L1425 结构体已删减
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;             // 根目录
}

SuperBlock 是一个非常复杂的结构,通过 SuperBlock 我们可以将一个实体文件系统挂载到 Linux 上,或者对 INode 进行增删改查操作。所以一般文件系统都会在磁盘上存储多份 SuperBlock,防止数据意外损坏导致整个分区无法读取。

应用

procfs

/proc 目录是 Linux 提供的一个虚拟文件系统,存储的是当前内核运行状态的一系列特殊文件。用户可以通过这些文件查关系统硬件及当前正在运行进程的信息,甚至可以通过更改其中某些文件来改变内核的运行状态。

/proc 不是一个真正的文件系统,它并不占用存储空间,只是占用有限的内存。但 /proc 实现了虚拟文件系统的接口,使得我们可以像操作一个普通的文件那样操作/proc 目录下的内容:

1
2
3
# more /proc/{pid}/status

# ll /proc/{pid}/fd

有关/proc 更多的用法可以参考文档*proc(5) — Linux manual page*,在 Linux 系统中类似于 procfs 的伪文件系统还有 sysfs、tmpfs 等。

Linux 的一个重要概念就是「一切皆是文件」,从这里可以看出,不论是普通的文件,还是特殊的目录、设备等,只要实现了相关的接口,VFS 就可以将它们同等看待成文件,通过同一套文件操作方式来对它们进行操作。当我们打开文件时,VFS 会获取该文件对应的文件系统格式,当 VFS 把控制权传给实际的文件系统时,实际的文件系统再做出具体区分,对不同的文件类型执行不同的操作。

总结

虚拟文件系统是操作系统中非常重要的一层抽象,其主要作用在于让上层的软件,能够用统一的方式,与底层不同的文件系统沟通。在操作系统与底层的各类文件系统之间,虚拟文件系统提供了标准的操作接口,让操作系统能够很快地支持新的文件系统。也因为 VFS 的支持,众多不同的实际文件系统才能在 Linux 中共存,跨文件系统的操作才能实现。

References