Linux 文件系统原理 / 虚拟文件系统VFS

虚拟文件系统 VFS

VFS 定义

VFS是一个抽象层,其向上提供了统一的文件访问接口,而向下则兼容了各种不不同类型的文件系统。不仅仅是诸如Ext2、Ext3、Ext4、XFS、windows家族的NTFS和Btrfs等常规意义上的文件系统,还可以是比如上图的proc等伪文件系统和设备,也可以是诸如NFS、CIFS等网络文件系统。

VFS 采用标准的Linux系统调用读写位于不同物理介质上的不同文件系统,即为各类文件系统提供了一个统一的操作界面和应用编程接口, VFS是一个内核软件层VFS是一个可以让open()、read()、write()等系统调用不用关心底层的存储介质和文件系统类型就可以工作的 抽象层 ,如下图所示:

img

VFS 的对象演绎

虚拟文件系统在磁盘中并没有对应的存储的信息。尽管 Linux 支持多达几十种文件系统,但这些真实的文件系统并不是一下子都挂在系统中的,它们实际上是按需挂载的。另外,这些实的文件系统只有安装到系统中,VFS 才予以认可,也就是说, VFS 只管理挂载到系统中的实际文件系统

VFS 有 4 个主要对象:

  • 超级块(Superblock) :存放系统中已安装文件系统的有关信息。

  • 文件索引节点(inode) :存放关于具体文件的一般信息。

  • 目录项对象(dentry) :存放目录项与对应文件进行链接的信息。

    路径中的每一个部分被称作目录项,例如 /home/clj/myfile 中,根目录是 / ,而 home,clj 和文件 myfile 都是目录项。

  • 文件对象(file) :存放打开文件与进程之间进行交互的有关信息。

超级块是对一个 文件系统 的描述索引节点是对一个 文件物理属性 的描述而目录项是对一个 文件逻辑属性 的描述

超级块 super_block

超级块用来描述整个文件系统的信息,包括文件系统的大小、有多少是空的和已经填满的占多少,以及他们各自的总数和其他诸如此类的信息。超级块占用1号物理块,就是文件系统的控制块 ,要 使用一个分区来进行数据访问,那么第一个要访问的就是超级块 。所以,超级块坏了,那磁盘也就基本没救了。

当内核在对一个文件系统进行初始化和注册时 在内存为其 分配一个超级块 。此时的超级块为 VFS 超级块 。也就是说,VFS 超级块是各种具体文件系统在安装时建立的,并在这些文件系统卸载时被自动删除。 VFS 超级块只存放在内存中

对于每个具体的文件系统来说,都有各自的超级块,如 Ext2 超级块和 Ext3 超级块,它存放在磁盘上,内容包括:文件系统的大小、空闲块数目、空闲块索引表、空闲i节点数目、空闲i节点索引表、封锁标记等。超级块是系统为文件分配存储空间、回收存储空间的依据。这一部分的拓扑结构如下图:

7ed1504eefb36427.png

其中 Block Group 存储的各部分含义如下:

  • indoe bitmap (indoe对照表): 用来记录当前文件系统的indoe哪些是已经使用的,哪些又是未使用的。
  • block bitmap (块对照表): 用来记录当前文件系统哪些block已经使用,哪些又是未使用的。
  • inode table (inode 表格):inode是用来记录文件的属性以及该文件实际数据所在的block的号码。
  • GDT(Global Descriptor Table):用来描述每个block group开始和结束的block号码以及每个区段位于哪一个block号码之间。相当于文件系统描述的是每个block group的信息。
  • data blocks:数据块,用于存放数据

超级块的数据结构定义如下:

struct super_block
{
    dev_t s_dev;    // 
    unsigned long s_blocksize;    // 以字节为单位数据块的大小
    unsigned char s_blocksize_bits;    // 块大小的值所占用的位数,
 
    ...
    
    struct list_head s_list;    // 指向超级块链表的指针
 
    struct file_system_type *s_type;    // 指向文件系统的 file_system_type 的指针
 
    struct super_operation *s_op;    // 指向具体文件系统的用于超级块操作的函数集合
 
    struct mutex s_lock;
 
    struct list_head s_dirty;
 
    ...
 
    void *s_fs_info;    // 指向具体文件系统的超级块
};

从上面定义的数据结构可知:所有的超级块对象都以双向循环链表的形式链接在一起。链表中第一个元素用 super_blocks 变量来表示。

与超级块关联的方法就是所谓的超级块操作表,其数据结构是 super_operations,定义如下:

struct super_operations
{
    void (*write_super) (struct super_block *);    // 将超级块的信息写回磁盘
 
    void (*put_super) (struct super_block *);    // 释放超级块对象
 
    void (*read_inode) (struct inode *);    // 读取某个文件系统的索引节点
 
    void (*write_inode) (struct inode *, int);    // 把索引节点写回磁盘
 
    void (*put_inode) (struct inode *);    // 逻辑上释放索引节点
 
    void (*delete_inode) (struct inode *);    // 从磁盘上删除索引节点
 
};

索引节点 inode

文件系统处理文件所需要的所有信息都存放在索引节点中。 在同一个文件系统中,每个索引节点号都是唯一的 。具体文件系统的索引节点是存放在磁盘上,是一种静态结构,要使用它,必须调入内存,填写 VFS 的索引节点,因此,也称 VFS 索引节点是 动态节点

我们的磁盘在进行分区、格式化的时候会分为两个区域, 一个是 数据区 ,用于存储文件中的数据另一个是 inode区 ,用于存放 inode table (inode表)inode table 中存放的是一个一个的 inode (也称为inode节点),不同的 inode 就可以表示不同的文件,每一个文件都必须对应一个 inodeinode 实质上是一个结构体,这个结构体中有很多的元素,不同的元素记录了文件了不同信息,譬如:

  • 文件字节大小
  • 文件所有者
  • 文件对应的读/写/执行权限
  • 文件时间戳(创建时间、更新时间等)
  • 文件类型
  • 文件数据存储的block(块)位置
  • ………

img

inode 结构体 定义在<linux/fs.h>中,主要包含:存放的内容如下:

struct inode
{
    struct list_head    i_hash;    // 指向哈希表的指针
    struct list_head    i_list;    // 指向索引节点链表的指针
    struct list_head    i_dentry;  // 指向目录项链表的指针
    ...
    
    unsigned long i_ino;    // 索引节点号
    umode_t    i_mode;      // 文件的类型与访问权限
    kdev_t     i_rdev;      // 实际设备标识号
    uid_t    i_uid;         // 文件拥有者标识号
    gid_t    i_gid;         // 文件拥有者所在组的标识号
    ...
    struct inode_operations *i_op;    // 指向对该节点进行操作的一组函数
 
    struct super_block     *i_sb;     // 指向该文件系统超级块的指针
 
    atomic_t    i_count;    // 当前使用该节点的进程数,计数为0时,表明该节点可丢弃或重新使用
 
    struct file_operations *i_fop;    // 指向文件操作的指针
    ...
    struct vm_area_struct *i_op;      // 指向对文件进行映射所使用的虚存区指针
 
    unsigned long    i_state;    // 索引节点的状态标志
    unsigned int     i_flags;    // 文件系统的安装标志
    
    union    // 联合结构体,其成员指向具体文件系统的 inode 结构
    {
        struct minix_inode_info     minix_i;
        struct Ext2_inode_info      Ext2_i;
    }
};

inode 的索引流程如图所示(注意, 文件名并不是记录在 inode 中,而是目录项 dentry ):

image-20230508160028492

所以由此可知, inode table 本身也需要占用磁盘的存储空间。在同一个文件系统中,每一个文件都有唯一的一个 inode ,每一个 inode 都有一个与之相对应的数字编号 ,内核可以根据索引节点号的哈希值查找其 inode 结构,前提是内核要知道索引节点号和对应文件所在文件系统的超级块对象的地址。 在Linux系统下,我们可以通过"ls -i"命令查看文件的 inode编号 ,如下所示:

image-20230508160918965

上图中 ls 打印出来的信息中,每一行前面的一个数字就表示了对应文件的 inode编号 。除此之外,还可以使用 stat 命令查看,用法如下:

image-20230508161003790

与索引节点关联的方法叫索引节点操作表,由 inode_operations 结构来描述:

struct inode_operations
{
    // 创建一个新的磁盘索引节点
    int (*create) (struct inode *, struct dentry *, int);
    
    // 查找一个索引节点所在的目录
    struct dentry * (*lookup) (struct inode *, struct dentry *);
    
    // 创建一个新的硬链接
    int (*link) (struct dentry  *, struct inode *, struct dentry *);
 
    // 删除一个硬链接
    int (*unlink) (struct inode *, struct dentry *);
    
    // 为符号链接创建一个新的索引节点
    int (*symlink) (struct inode *, struct dentry *, const char *);
    
    // 为目录项创建一个新的索引节点
    int (*mkdir) (struct inode *, struct dentry *, int);
 
    // 删除一个目录项的索引节点
    int (*rmdir) (struct inode *, struct dentry *);
};

目录项 dentry

每个文件除了有一个索引节点inode数据结构外,还有一个目录项dentry数据结构。 目录项反应了文件系统的树状结构,目前主流的操作系统基本都是用树状结构来组织文件的。linux也不例外。dentry表示一个目录项,目录项下面又有子目录。

文件系统树形结构

img

目录在文件系统中的存储方式与常规文件类似,常规文件包括了 inode节点 以及 文件内容数据存储块(block) ;但对于目录来说,其存储形式则是由 inode节点目录块 所构成, 目录块当中记录了有哪些文件组织在这个目录下,记录它们的 文件名 以及对应的 inode编号 。所以对此总结如下:

  • 普通文件由 inode节点数据块 构成
  • 目录由 inode节点目录块 构成

其存储形式如下图所示:

image-20230509164629213

对于 dentryinode 的区别可以如此总结:

  • dentry 结构代表的是逻辑意义上的文件,描述的是文件逻辑上的属性, 目录项对象在磁盘上并没有对应的映像
  • inode 结构代表的是物理意义上的文件,记录的是物理上的属性,对于一个具体的文件系统, 其inode结构在磁盘上就有对应的映像

当打开一个文件时,按照目录树搜索的过程如下:

根目录 inode 编号
查找 inode 表
是否是目录?
打开对应本级目录块
查找下一级目录对应 inode 编号
打开对应文件数据块

这一流程大概如下图所示:

img

**一个索引节点对象可能对应多个目录项对象(因为路径的每一部分称作目录项,而文件的路径很长)。**目录项由dentry结构体标识,定义在<linux/dcache.h>中,主要包含:

struct dentry
{
    atomic_t d_count;            // 目录项引用器
    unsigned int d_flags;        // 目录项标志
    struct inode *d_inode;       // 与文件名关联的索引节点
    struct dentry *d_parent;     // 父目录的目录项 
 
    struct list_head d_hash;     // 目录项形成的哈希表
    struct list_head d_lru;      // 未使用的 LRU 链表
    struct list_head d_child;    // 父目录的子目录项所形成的链表
    struct list_head d_subdirs;  // 该目录项的子目录所形成的的链表
    struct list_head d_alias;    // 索引节点别名的链表
    
    int d_mounted;               // 目录项的安装点
    struct qstr d_name;          // 目录项名(可快速查找)
    struct dentry_operations *d_op;    // 操作目录项的函数
    struct super_block *d_sb;    // 目录项树的根
    unsigned long d_vfs_flags;
    void *d_fsdata;              // 具体文件系统的数据
    unsigned char d_iname[DNAME_INLINE_LEN];    // 短文件名
    ...
 
};

目录项有三种状态:

  • 被使用:该目录项指向一个有效的索引节点,并有一个或多个使用者,不能被丢弃。

  • 未被使用:也对应一个有效的索引节点,但VFS还未使用,被保留在缓存中。如果要回收内存的话,可以撤销未使用的目录项。

  • 负状态:没有对应有效的索引节点,因为索引节点被删除了,或者路径不正确,但是目录项仍被保留了。

将整个文件系统的目录结构解析成目录项,是一件费力的工作,为了节省VFS操作目录项的成本,内核会将目录项缓存起来

文件 file

文件对象是进程打开的文件在内存中的实例。Linux用户程序可以通过open()系统调用来打开一个文件,通过close()系统调用来关闭一个文件。由于多个进程可以同时打开和操作同一个文件, 所以同一个文件,在内存中也存在多个对应的文件对象,但对应的索引节点和目录项是唯一的

一个进程所处的位置是由 fs_strcut 来描述的而一个进程(或者用户)打开的文件是由 files_struct / fdtable 来描述的而整个系统所打开的文件是由 file 结构来描述的

img

文件对象由file结构体表示,file 结构形成了一个双链表,称为 系统打开文件表 。其定义在<linux/fs.h>中,主要包含:

struct file
{
    struct list_head        f_list;      // 所有打开的文件形成一个链表
    struct dentry           *f_dentry;   // 与文件相关的目录项对象
    struct vfsmount         *f_mount;    // 该文件所在的已安装文件系统
    struct file_operations  *f_op;       // 指向文件操作表的指针
    mode_t     f_mode;                   // 文件的打开模式
    loff_t     f_pos;                    // 文件的当前位置
    unsigned short f_flags;              // 打开文件时所指定的标志
    unsigned short f_count;              // 使用该结构的进程数
    
    ...
};

对文件进行操作的一组函数叫 文件操作表 ,由 file_operations 结构描述,如下:

struct file_operations
{
    // 修改文件指针
    loff_t (*llseek) (struct file *, loff_t, int);
 
    // 从文件中读取若干个字节
    ssize_t (*read)  (struct file *, char *, size_t, loff_t *);
 
    // 给文件中写若干个字节
    ssize_t (*write) (struct file *, const char *, size_t, loff_t *);
    
    // 文件到内存的映射
    int (*mmap) (struct file *, struct vm_area_struct *);
 
    // 打开文件
    int (*open) (struct inode *, struct file *);
 
    // 关闭文件时减少 f_count 计数
    int (*flush) (struct file *);
    
    // 释放 file 对象
    int (*release) (struct inode *, struct file *);
 
    // 文件在缓冲区的数据写回磁盘
    int (*fsync) (struct file *, struct dentry *, int datasync);
 
    ...
};

文件描述符是用来描述打开的文件的。每一个进程用一个 files_struct 结构来记录文件描述符的使用情况,即一个进程可以有多个文件描述符,因为一个进程可以打开多个文件。而通过 dup()、dup2() 和 fcntl() 两个文件描述符可以指向同一个打开的文件,数组的两个元素可能指向同一个文件对象。

每个进程都有 自己的根目录和当前工作目录 ,内核使用 struct fs_struct 来记录这些信息,其定义为:

struct fs_struct
{
    atomic_t count;    // 表示共享同一 fs_struct 表进程数目
    rwlock_t lock;
    int umask;    // 为新创建的文件设置初始文件许可权
    struct dentry *root, *pwd, *altroot;    // 对目录项的描述
    struct vfsmount *rootmnt, *pwdmnt, *altrootmnt;    // 目录安装点的描述
};

除了根目录和当前工作目录,进程还需要记录自己打开的文件。进程已经打开的所有文件使用 struct files_struct 来记录,进程描述符的 files 字段便指向该进程的files_struct结构。它是 进程的私有数据 ,其定义如下:

struct files_struct 
{
    atomic_t count;               // 共享该表的进程数
    rwlock_t file_lock;           // 保护以下的所有域
    int max_fds;                  // 当前文件对象的最大数
    int max_fdset;                // 当前文件描述符的最大数
    int next_fd;                  // 已分配的文件描述符加 1
    
    struct  file ** fd;           // 指向文件对象指针数据的指针
    fd_set *close_on_exec;        // 指向指向 exec() 时需要关闭的文件描述符
    fd_set *open_fds;             // 指向打开的文件描述符的指针
    
    fd_set close_on_exec_init;    // 执行 exec() 时需要关闭的文件描述符的初值集合
    fd_set open_fds_init;         // 文件描述符的初值集合
    struct file *fd_array[32];    // 文件对象指针的初始化数组
};

旧版本的内核中, struct files_struct 中有一个 fd字段 ,指向文件对象的指针数组。通常fd指向fd_array,如果进程打开的文件数目多于32个,内核就分配一个新的更大的文件对象的指针数组,并将其地址存放在fd字段中,这个数组所包含的元素数目存放在 max_fds字段

新版本的内核将 fdmax_fds 以及其他几个相关字段组织在一起,增加一个新的独立数据结构 struct fdtable ,称为 文件描述符表 ,定义于 include/linux/fdtable.h ,其主要数据结构定义如下所示:

struct fdtable {
    unsigned int max_fds;
    struct file __rcu **fd;      /* current fd array */
    unsigned long *close_on_exec;
    unsigned long *open_fds;
    struct rcu_head rcu;
};

文件共享

所谓文件共享指的是同一个文件(譬如磁盘上的同一个文件,对应同一个inode)被 多个独立的读写体同时进行IO操作 。同时进行IO操作指的是 一个读写体操作文件尚未调用 close 关闭的情况下,另一个读写体去操作文件

文件共享的意义有很多,多用于多进程或多线程编程环境中,譬如我们可以通过文件共享的方式来实现多个线程同时操作同一个大文件,以减少文件读写时间、提升效率。

常见的三种文件共享的实现方式有:

  1. 同一个进程中 通过 dup(dup2) 函数 对文件描述符进行 复制 ,其数据结构关系如下图所示:

    image-20230509105627824

  2. 同一个进程中 多次调用 open 函数 打开同一个文件 ,各数据结构之间的关系如下图所示:

    image-20230509105523562

  3. 不同进程中 分别使用 open 函数 打开同一个文件 ,其数据结构关系图如下所示:

    image-20230509105552970

因为 文件描述符表是相对每个进程而言 ;而 文件表(打开文件表)是相对于整个系统而言 。所以单个进程内不论是否多次打开相同文件还是打开多个文件,导致的都只是 文件描述符fd 的增加。

opendup 的区别在于, open 是打开文件操作,会 获得新的fd并在系统打开文件表内创造新条目 ;而 dup 只是复制文件描述符fd,并不会增加打开文件表

打开文件流程

文件在没有被打开的情况下一般都是存放在磁盘中的,譬如电脑硬盘、移动硬盘、U盘等外部存储设备, 文件存放在磁盘文件系统中,并且以一种固定的形式进行存放,我们把他们称为 静态文件

当我们调用 open 函数去打开文件的时候,内核会申请一段内存(一段缓冲区),并且将静态文件的数据内容从磁盘这些存储设备中读取到内存中进行管理、缓存 (也 把内存中的这份文件数据叫做 动态文件 、内核缓冲区 )。 打开文件后,以后对这个文件的读写操作,都是针对内存中这一份动态文件进行相关的操作,而并不是针对磁盘中存放的静态文件

当我们对动态文件进行读写操作后,此时内存中的动态文件和磁盘设备中的静态文件就不同步了, 数据的同步工作由内核完成,内核会在之后将内存这份动态文件更新(同步)到磁盘设备中

因为磁盘、硬盘、U盘等存储设备基本都是Flash块设备,因为块设备硬件本身有读写限制等特征,块设备是以一块一块为单位进行读写的(一个块包含多个扇区,而一个扇区包含多个字节), 一个字节的改动也需要将该字节所在的block全部读取出来进行修改,修改完成之后再写入块设备中,所以导致对块设备的读写操作非常不灵活 ;而 内存可以按字节为单位来操作,而且可以随机操作任意地址数据,非常地很灵活 ,所以对于操作系统来说,会先将磁盘中的静态文件读取到内存中进行缓存,读写操作都是针对这份动态文件,而不是直接去操作磁盘中的静态文件,不但操作不灵活,效率也会下降很多,因为内存的读写速率远比磁盘读写快得多。

在Linux系统中,内核会为每个进程设置一个专门的数据结构用于管理该进程,譬如记录进程的状态信息、运行特征等,我们把这个称为 进程控制块(Process control block,PCB)

PCB数据结构体中有一个指针指向了 文件描述符表(File descriptors) ,文件描述符表中的每一个元素索引到对应的 文件表(File table) ,文件表也是一个数据结构体,其中记录了很多文件相关的信息,譬如 文件状态标志、引用计数、当前文件的读写偏移量以及i-node指针(指向该文件对应的inode)等 ,进程打开的所有文件对应的文件描述符都记录在文件描述符表中,每一个文件描述符都会指向一个对应的文件表,其示意图如下所示:

image-20230508164212232

我们打开文件的过程也就是对 文件表file 的初始化的过程。在打开文件的过程中会将 inode 部分关键信息填充到 file 中,特别是文件操作的函数指针 。在 task_struct 中保存着一个 file类型 的数组,而用户态的文件描述符其实就是数组的下标。这样通过文件描述符就可以很容易到找到 file ,然后通过其中的函数指针访问数据。

通过以上介绍可知,打开一个文件,系统内部会将这个过程大概分为下面三步:

  1. 传入文件路径,系统根据 dentry 找到这个文件名所对应的 inode编号
  2. 通过 inode编号inode table 中找到对应的 inode struct ,包含 inode 详细信息
  3. 根据 inode结构体 中记录的信息,确定文件数据所在的 block ,并读出数据到内存,建立动态文件;
    同时将 inode关键信息 填充到 打开文件表file 中,并返回该表的下标(其实就是 文件描述符fd
解析 dentry cache
解析 inode table
解析 block pos
解析 inode 关键信息
调用 open 传入文件路径
获得 inode index
获得 inode struct
读取 block,建立动态文件
填充到打开文件表 file 新条目
返回新的下标:文件描述符 fd




image-20230508204058007

我们以Ext2文件系统的写数据为例来看看文件处理流程和各个层级之间的关系,如下图:

img

在调用用户态的写数据接口的时候,需要传入文件描述符。内核根据文件描述符找到file,然后调用函数接口(file->f_op->write)文件磁盘数据。其中file结构体的f_op指针就是在打开文件的时候通过inode初始化的。

参考文献

1:文件系统理论详解,Linux操作系统原理与应用_Great Macro的博客-CSDN博客

2:Linux内核的5个子系统 - schips - 博客园

3:理解linux文件系统(VFS主要数据结构及之间的关系) - 周围静地出奇 - 博客园

4:文件系统入门知识_Linux教程_Linux公社-Linux系统门户网站

5:Linux 文件系统详解_hguisu的博客-CSDN博客

6:谈谈linux内核学习:虚拟文件系统(VFS) - 知乎

7:【正点原子】STM32MP1嵌入式Linux C应用编程指南V1.4 - 第三章



如有疑问或错误,欢迎和我私信交流指正。
版权所有,未经授权,请勿转载!
Copyright © 2023.05 by Mr.Idleman. All rights reserved.


猜你喜欢

转载自blog.csdn.net/qq_42059060/article/details/130567010