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

108 阅读21分钟

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

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

struct super_block {
    struct list_head    s_list;     //系统中所有superblock的链表
    dev_t               s_dev;      //存储设备的设备号
    unsigned char       s_blocksize_bits; //块大小的位数,如4k就是12位,2^12=4K
    unsigned long       s_blocksize;      //块大小,以字节为单位
    loff_t              s_maxbytes;       //该文件系统支持的最大文件大小
    
    struct file_system_type *s_type;  //指向该文件系统类型驱动
    const struct super_operations *s_op; //超级块的操作函数集
    
    struct dentry       *s_root;      //指向该文件系统根目录的dentry
    
    struct list_head    s_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

struct inode {
    umode_t             i_mode;     //访问权限和文件类型
    kuid_t              i_uid;      //所有者ID
    kgid_t              i_gid;      //组ID
    
    const struct inode_operations   *i_op;  //inode的操作集
    struct super_block              *i_sb;  //反向指针,指向所属的super_block
    struct address_space            *i_mapping; //指向页缓存的核心结构
    
    unsigned long       i_ino;      //inode号
    loff_t              i_size;     //文件大小
    struct timespec64   i_atime;    //访问时间
    struct timespec64   i_mtime;    //修改时间
    struct timespec64   i_ctime;    //状态改变时间
    
    const struct file_operations    *i_fop; //默认的文件操作集, open, read, write
    
    struct address_space    i_data;
    
    union {
        struct hlist_head   i_dentry;   //指向引用该inode的dentry链表
        struct rcu_head     i_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

主要成员如下:

struct dentry {
    unsigned int d_flags;       
    seqcount_spinlock_t d_seq;  //锁机制,用于无锁查找
    
    struct hlist_bl_node d_hash;    //用于在dcache哈希表中查找
    
    struct dentry *d_parent;        //指向父目录的dentry
    struct qstr d_name;             //文件名,这里存储了字符串
    
    struct inode *d_inode;          //指向该dentry对应的inode
    
    unsigned char d_iname[DNAME_INLINE_LEN]; //短文件名直接存在这里,不用分配内存
​
    struct lockref d_lockref;   
    
    const struct dentry_operations *d_op; //dentry操作集
    
    struct super_block *d_sb;       //指向所属的superblock
    
    struct list_head d_child;       //挂入父目录的子节点链表
    struct list_head d_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

struct file {
    union {
        struct llist_node   fu_llist;
        struct rcu_head     fu_rcuhead;
    } f_u;
    
    struct path         f_path;     //包含{mnt, dentry}
    struct inode        *f_inode;   //指向对应的inode,也就是f_path.dentry->d_inode
    
    const struct file_operations    *f_op; //操作函数集read, write
    
    spinlock_t          f_lock;     //锁
    atomic_long_t       f_count;    //引用计数
    
    unsigned int        f_flags;    //open时的标志(O_RDONLY, O_NONBLOCK等)
    fmode_t             f_mode;     //读写模式(FMODE_READ, FMODE_WRITE)
    
    loff_t              f_pos;      //当前文件读写位置
    
    struct fown_struct  f_owner;
    
    void                *private_data; //具体驱动或文件系统的私有数据
    
    struct address_space *f_mapping;   //指向 inode->i_mapping
} __randomize_layout;

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

struct path {
    struct vfsmount *mnt;   //属于哪个挂载点
    struct dentry *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. 四个对象协作.png

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

  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 有了一个大致的了解,但还只是停留在比较浅的层次。稍后发的下篇内容将会更加深入内核源码,从具体的系统调用入手,看看系统是怎样查找一个具体文件的路径的。

本篇完。