从进程级文件描述符到Linux内核VFS(虚拟文件系统)数据结构源码剖析

       这学期学习《嵌入式系统》这门课,主要涉及到Linux相关的各种系统调用的学习,也算是给学习Linux内核知识建立了一个更高层的认识基础。这是Linux操作系统内核源码系列博客的第一篇,主要记录和整理了一些VFS相关的内容。

第一部分 Linux系统中的文件描述符

(一)Linux下一切皆文件

       对于Linux系统来说,一切内容都是被看作文件的,包括普通文件(-)、目录文件(d)、链接文件(l)、字符设备文件(c)、块设备文件(b)、管道(p)、堆栈(f)、套接字(s)。下图是一些Linux默认目录的存放类型。
Linux的默认目录及文件内容

(二)Linux中的文件描述符

       Linux是通过文件描述符来访问文件的。对于每一个进程来说,它们的PCB(进程控制块/task_struct)中都维护着一个文件描述符链表,文件描述符通常是非负整数(其中0、1、2已经由标准输入(stdin)、标准输出(stdout)、标准错误(stderr)占用,Linux内核规定生成的索引值应该是当前可用的最小索引值,所以一般一个进程新建的文件描述符是从索引3开始的)。
       每一个文件描述符会指向一个当前进程打开文件的索引,系统会维护一个打开文件表,其中每一个表项会维护该文件的偏移量(即读写该文件内容的文件指针信息)、状态标志(如访问模式等)、引用计数、包含的目录项文件操作表(read()、write()等。其中每一个打开的文件会通过目录项(文件路径)来对应一个inode(索引节点)值,inode中存放了文件的元信息(如修改时间、文件类型、文件锁等)。
       下面这张图表述了三者之间的关系。
在这里插入图片描述
从上面的关系图中我们也可以看出:

  • 同一个进程的不同文件描述符可以指向同一个文件(通过调用 dup()、dup2()、fcntl() 或者对同一个文件多次调用了 open() 函数而形成)
  • 不同进程可以拥有相同的文件描述符(fork())
  • 不同进程的同一个文件描述符可以指向不同的文件(一般也是这样,除了 0、1、2 这三个特殊的文件)
  • 不同进程的不同文件描述符也可以指向同一个文件。

顺便一提——Linux中文件硬链接和软链接的区别

  • 硬连接指通过索引节点来进行连接。在Linux的文件系统中,保存在磁盘分区中的文件不管是什么类型都给它分配一个编号,称为索引节点号(Inode Index)。在Linux中,多个文件名指向同一索引节点是存在的。比如:A是B的硬链接(A和B都是文件名),则A的目录项中的inode节点号与B的目录项中的inode节点号相同,即一个inode节点对应两个不同的文件名,两个文件名指向同一个文件,A和B对文件系统来说是完全平等的。删除其中任何一个都不会影响另外一个的访问。(注意,一个inode确定唯一的一个具体文件,但是可以有多个不同的文件名。如果删除掉其中一个文件名(即硬链接),只要inode引用计数不为0则实际文件不会被删除。)
  • 另外一种连接称之为符号连接(Symbolic Link),也叫软连接。软链接文件有类似于Windows的快捷方式。它实际上是一个特殊的文件。在符号连接中,文件实际上是一个文本文件,其中包含的有另一文件的位置信息。比如:A是B的软链接(A和B都是文件名),A的目录项中的inode节点号与B的目录项中的inode节点号不相同,A和B指向的是两个不同的inode,继而指向两块不同的数据块。但是A的数据块中存放的只是B的路径名(可以根据这个找到B的目录项)。A和B之间是“主从”关系,如果B被删除了,A仍然存在(因为两个是不同的文件),但指向的是一个无效的链接。

第二部分 Linux内核VFS(虚拟文件系统)数据结构源码剖析

(一)概述

       上文提到的关于文件描述符的内容主要还是从进程级去理解的,下面从Linux内核底层的VFS实现来具体分析。一个操作系统可以支持多种底层不同的文件系统(比如NTFS, FAT, ext3, ext4),为了给内核和用户进程提供统一的文件系统视图,Linux在用户进程和底层文件系统之间加入了一个抽象层,即虚拟文件系统(Virtual File System, VFS),进程所有的文件操作都通过VFS,由VFS来适配各种底层不同的文件系统,完成实际的文件操作。
       通俗的说,VFS就是定义了一个通用文件系统的接口层和适配层,一方面为用户进程提供了一组统一的访问文件,目录和其他对象的统一方法,另一方面又要和不同的底层文件系统进行适配。如图所示:
在这里插入图片描述
VFS中有4个主要的对象类型

  • 超级块对象(super block)——用于保存一个文件系统的所有元数据,相当于这个文件系统的信息库,为其他的模块提供信息。因此一个超级块可代表一个文件系统。文件系统的任意元数据修改都要修改超级块。超级块对象是常驻内存并被缓存的。
  • 索引节点对象(index node)——对应于上文所述的inode对象,它代表一个唯一的具体文件。
  • 目录项对象(dentry)——它代表一个目录项,是路径的一个组成部分。
  • 文件对象(file)——它代表由进程打开的文件,对应于上文所述的打开文件表中的表项,此出要注意文件对象与索引节点对象的区别,文件对象可以理解为由进程打开的具体文件的某个临时状态(比如对于同一个文件,不同的文件状态下的指针偏移量并不同),一个inode可以对应于多个文件对象。
    下面的两张图清晰地表达出了VFS各模块之间的关系:
    在这里插入图片描述

由上图可以看出:

  • 每个模块都维护了一个X_op指针指向它所对应的操作对象X_operations。
  • 超级块维护了一个s_files指针指向了“已打开文件列表模块”,即内核所有的打开文件的链表,这个链表信息是所有进程共享的。
  • 目录操作模块和inode模块都维护了一个X_sb指针指向超级块,从而可以获得整个文件系统的元数据信息。
  • 目录项对象和inode对象各自维护了指向对方的指针,可以找到对方的数据。
  • 已打开文件列表上每一个file结构体实例维护了一个f_dentry指针,指向了它对应的目录项,从而可以根据目录项找到它对应的inode信息。
  • 已打开文件列表上每一个file结构体实例维护了一个f_op指针,指向可以对这个文件进行操作的所有函数集合file_operations。
  • inode中不仅有和其他模块关联的指针,重要的是它可以指向address_space模块,从而获得自身文件在内存中的缓存信息。
  • address_space内部维护了一个树结构来指向所有的物理页结构page,同时维护了一个host指针指向inode来获得文件的元数据。

在这里插入图片描述
(注:上图中的dentry cache指目录项缓存,下文会提及)

关于Page Cache的说明如下

(在struct inode数据结构源码中好像没有体现出这个结构……没找到指向address_space的结构)
       页缓存是面向文件,面向内存的。通俗来说,它位于内存和文件之间缓冲区,文件IO操作实际上只和page cache交互,不直接和内存交互。page cache可以用在所有以文件为单元的场景下,比如网络文件系统等等。page cache通过一系列的数据结构,比如inode, address_space, struct page,实现将一个文件映射到页的级别:

  1. struct page结构标志一个物理内存页,通过page + offset就可以将此页帧定位到一个文件中的具体位置。同时struct page还有以下重要参数:
    (1)标志位flags来记录该页是否是脏页,是否正在被写回等等;
    (2)mapping指向了地址空间address_space,表示这个页是一个页缓存中页,和一个文件的地址空间对应;
    (3)index记录这个页在文件中的页偏移量;
  2. 文件系统的inode实际维护了这个文件所有的块block的块号,通过对文件偏移量offset取模可以很快定位到这个偏移量所在的文件系统的块号,磁盘的扇区号。同样,通过对文件偏移量offset进行取模可以计算出偏移量所在的页的偏移量。
  3. page cache缓存组件抽象了地址空间address_space这个概念来作为文件系统和页缓存的中间桥梁。地址空间address_space通过指针可以方便的获取文件inode和struct page的信息,所以可以很方便地定位到一个文件的offset在各个组件中的位置(即通过:文件字节偏移量 --> 页偏移量 --> 文件系统块号 block --> 磁盘扇区号)
  4. 页缓存实际上就是采用了一个基数树结构将一个文件的内容组织起来存放在物理内存struct page中。一个文件inode对应一个地址空间address_space。而一个address_space对应一个页缓存基数树。它们之间的关系如下:
    在这里插入图片描述

(二)VFS数据结构源码剖析

这里涉及到了一些内核底层定义的数据结构,下面按照从进程级开始逐步向下的顺序进行说明:

  • 进程控制块(PCB)——这里进程控制块对应着结构体task_struct,其中定义着files_strcut指针,即文件描述符表。
  • struct file(文件对象,定义在<linux/fs.h>中)——上面的files_struct会指向一个结构体file,即文件对象,这里列举一部分struct file中的数据成员说明:
    (1)f_pos(文件偏移量)
    (2)f_mode(访问模式):对应于前文第二张图中打开文件表里的访问标志。
    (3)f_count(引用计数):像pipe、fork,dup等操作可能会使得文件描述符指向同一个file,比如现在有fd1和fd2,他们都指向了同一个文件,那么这个文件的计数就是2,要想关闭这个文件,close(fd1)是不能关掉的,因为这个时候计数为1,只有在计数为0的时候才算完全关闭。
    (4)file_operations(文件操作表):file_operations里面包含了对文件操作的内核函数指针,他指向内核操作函数,比如说read/write/release/open,当然,对于不同的文件类型,file_opertions有不同的操作,像读取字符设备的文件操作肯定不会和读取正常文件的一样,他们不是读取磁盘,而是读取硬件设备。
    (5)f_dentry(或f_path,目录项):一个指向带有文件路径的dentry结构体指针,我们在操作文件时,一定要知道他的路径,才能进行操作。为了减少读盘次数,内核缓存了目录的树状结构,称为dentry cache(或page_tree),其中每个节点是一 个dentry结构体,只要沿着路径各部分的dentry搜索即可。
    struct file详细结构体定义如下图所示:
    在这里插入图片描述

在这里插入图片描述

  • struct dentry(目录项对象,定义在<linux/dcache.h>中)——VFS把目录也当作文件对待。这里列举一部分struct file中的数据成员说明:
    (1)*d_inode(索引节点):目录项指向了其所关联的索引节点,以便根据路径查找对应的inode。
    (2)d_conut(引用计数):实际上目录项有3种有效状态,被使用、未被使用和负状态,它们分别对应于:
           引用计数>0,正在被使用。
           引用计数=0,此时d_inode依然对应于一个有效的索引节点,只是当前VFS并未使用它,该目录项对象被保留在缓存中,以便需要它时不必重新创建,这样路径查找更加迅速。但如果要内存回收的话,可以撤销未使用的目录项。
           引用计数<0,此时d_inode为NULL,该索引节点已被删除或路径不再正确了,但是该目录项仍然被保留,以便快速解析以后的路径查询。虽然负状态的目录项有些用处,但如果有需要可以撤销它,因为实际上很少用到它。
    struct dentry的详细结构体定义如下图所示:
    在这里插入图片描述

  • struct inode(索引节点对象,定义在<linux/fs.h>中)——索引节点对象包含了内核在操作文件或目录时需要的全部信息,索引节点仅在访问时才在内存中创建,以便于文件系统使用。这里列举一部分struct inode的数据成员及成员函数说明:
    (1)i_uid, i_gid, i_atime, i_mtime, i_ctime:使用者id, 使用者组id, 最后访问时间、最后修改时间、最后改变时间等一系列文件的元数据。
    (2)l_sb_list:指向超级块链表,拿到更多元数据信息。
    (3)struct inode_operations(索引节点操作):此结构体中指向了一些文件操作的内核函数,如mkdir()(创建目录), create()(VFS通过系统调用create()和open()来调用该函数,创建一个新的索引节点), link()(硬连接), symlink()(符号连接)……等文件操作。
    (注:目录项缓存在第二张VFS模式图中已标出(dentry cache))
    struct inode的详细结构体定义如下图所示:
    在这里插入图片描述
    在这里插入图片描述
    索引节点操作定义如下:
    在这里插入图片描述

  • struct super_block(超级块对象)——该对象用于存储特定文件系统的信息,通常对应于存放在磁盘特定扇区中的文件系统超级块或文件系统控制块。对于并非基于磁盘的文件系统,它们会在使用现场创建超级块并将其保存到内存中。超级块对象保存着从磁盘分区的超级块读上来的信息,像文件系统类型(比如说是ext2,ext3等),块大小,不同的文件类型,底层的实现是不同的。当然,super_block还有s_root个成员指向了dentry,因为他需要知道文件的根目录被mount 到哪里。struct super_block详细结构体定义如下图所示:
    在这里插入图片描述
    在这里插入图片描述

(三)从VFS的角度看文件的读写操作

读文件
  1. 进程调用库函数向内核发起读文件请求;
  2. 内核通过检查进程的文件描述符定位到虚拟文件系统的已打开文件列表表项;
  3. 调用该文件可用的系统调用函数read(),read()函数通过文件表项链接到目录项模块,根据传入的文件路径,在目录项模块中检索,找到该文件的inode;
  4. 在inode中,通过文件内容偏移量计算出要读取的页;
  5. 通过inode找到文件对应的address_space;
  6. 在address_space中访问该文件的页缓存树,查找对应的页缓存结点:
           (1)如果页缓存命中,那么直接返回文件内容;
           (2)如果页缓存缺失,那么产生一个页缺失异常,创建一个页缓存页,同时通过inode找到文件该页的磁盘地址,读取相应的页填充该缓存页;重新进行第6步查找页缓存;
  7. 文件内容读取成功。
写文件

前5步和读文件一致,在address_space中查询对应页的页缓存是否存在:

  1. 如果页缓存命中,直接把文件内容修改更新在页缓存的页中。写文件就结束了。这时候文件修改位于页缓存,并没有写回到磁盘文件中去。
  2. 如果页缓存缺失,那么产生一个页缺失异常,创建一个页缓存页,同时通过inode找到文件该页的磁盘地址,读取相应的页填充该缓存页。此时缓存页命中,进行第6步。
  3. 一个页缓存中的页如果被修改,那么会被标记成脏页。脏页需要写回到磁盘中的文件块。有两种方式可以把脏页写回磁盘:
    (1)手动调用sync()或者fsync()系统调用把脏页写回
    (2)pdflush进程会定时把脏页写回到磁盘
    同时注意,脏页不能被置换出内存,如果脏页正在被写回,那么会被设置写回标记,这时候该页就被上锁,其他写请求被阻塞直到锁释放。

参考文献

       https://www.cnblogs.com/huxiao-tee/p/4657851.html
       https://www.jianshu.com/p/504a53c30c17
       http://c.biancheng.net/view/3066.html
       《Linux内核设计与实现(第三版)》(Robert Love著)

猜你喜欢

转载自blog.csdn.net/qq_42968686/article/details/105848984