..

Linux Kernel: The Virtual Filesystem

通过所谓的虚拟文件系统的概念,Linux 系统能够支持多种文件系统类型。虚拟文件系统所隐含的思想是把表示多种不同类型的文件系统的共同信息存放在内核中,对文件系统的读写调用,内核都能将其替换成支持本地 Linux 文件系统或者其他文件系统的实际函数。

1-虚拟文件系统的作用

虚拟文件系统(Virtual FileSystem)也被称为虚拟文件系统转换(Virtual FileSystem Switch),是一个内核软件层用来处理与 Unix 标准文件系统相关的所有系统调用;能够为各个文件系统提供一个通用的接口。

VFS 是用户程序与文件系统实现之间的抽象层。例如用户输入以下 shell 命令

# /floppy 是 ms-dos 文件系统,而 /tmp 是 ext2 文件系统
cp /floppy/test /tmp/test

cp 程序并不需要操作目录的具体文件系统类型,相反,cp 程序与 VFS 交互。

a 描述了 VFS 的层次,b 表示 cp 应用程序的实现

VFS 支持的文件系统可以划分为三种类型:

  1. 磁盘文件系统

    用于管理本地磁盘分区。比较知名的基于磁盘的文件系统有:Linux 使用的文件系统,Unix 家族文件系统,微软公司文件系统等

  2. 网络文件系统

    这些文件系统允许访问属于其他网络计算机的文件系统所包含的文件,常见的有 NFS,Coda,AFS 等

  3. 特殊文件系统

    这些文件系统不管理本地或者远程磁盘空间。如 /proc 文件系统就是一种典型的特殊文件系统

Unix 目录简易了一棵根目录为 ’/’ 的树,根目录包含在根文件系统(root filesystem)中。在 Linux 中,根文件系统通常为 Ext2 or Ext3 类型,其他所有的文件系统都可以被“安装”在跟文件系统的子目录中

当一个文件系统被安装在某个目录上时,在父文件系统中的目录内容不再是可访问的

1-1 通用文件模型

VFS 的主要思想在于引入了一个通用的文件模型(common file model),该模型能够表示所有支持的文件系统。

对于每个具体的文件系统,需要将其物理组织结构转换为虚拟文件系统的通用文件模型。Linux 内核对于每个文件操作必须使用一个指针,指向要访问的具体文件系统的适当函数。

通用文件模型由以下对象类型组成:

  1. 超级块对象(superblock object)

    用于存放已安装文件系统的有关信息。对于基于磁盘的文件系统,该类对象通常对应于存放在磁盘上的文件系统控制块(filesystem control block)

  2. 索引节点对象(inode object)

    用于存放具体文件的一般信息。对于基于磁盘的文件系统,该类对象通常对应于存放在磁盘上的文件控制块(file control block)。每个索引节点对象都有一个索引节点号,该索引节点号唯一标识文件系统中的文件

  3. 文件对象(file object)

    用于存放打开的文件与进程之间进行交互的有关信息。这类信息仅当进程访问文件期间存在于内核内存中。

  4. 目录项对象(dentry object)

    用于存放目录项(也就是文件的特地名称)与对应文件进行链接的有关信息。每个磁盘文件系统都以自己的方式将该类信息存放在磁盘上。

下图简单说明进程怎样与文件进行交互:

  • 三个不同的进程打开同一个文件,其中两个进程使用硬链接
  • 每个进程都有自己的文件对象,但是只需要两个目录项对象(每个硬链接对应一个目录项对象)
  • 这两个目录项对象指向同一个索引节点对象
  • 该索引节点对象标识超级块对象以及普通的磁盘文件

为了提高系统性能,最近最常用的目录项对象被存放在目录项高速缓存(dentry cache)的磁盘高速缓存(disk cache)中,以提高从文件路径名到最后一个路径分量的索引节点的转换效率。

一般来说,磁盘高速缓存属于软机制,允许内核将原本存放在磁盘上的某些信息保存在 RAM 中,以加速对这些数据的访问

1-2 VFS 所处理的系统调用

下图列出了 VFS 处理的系统调用,涉及文件系统,普通文件,目录文件,符号链接文件等。

VFS 属于内核层,处理应用程序的系统调用

  • select() poll():等待一组文件描述符上发生的事情
  • read() write() readv() writev() sendfile():进行文件 IO 操作
  • mmap() mmap2():处理文件内存映射

VFS 是应用程序与具体文件系统之间的一层。不过,在某些情况下一个文件操作可能由 VFS 本身执行,不需要调用底层函数。例如,当进程关闭打开的文件时,并不需要涉及磁盘上相应文件,因此 VFS 只需要释放对应的文件对象即可。

从某种意义上来说,VFS 可以被看作普通的文件系统,在必要时依赖某种具体的文件系统。

2-VFS 的数据结构

每个 VFS 对象都存放在一个适当的数据结构中,包括对象的属性和指向对象方法表的指针。

2-1 超级块对象

超级块对象由 super_block 结构组成,部分字段如下:

  • 所有的超级块对象都以双向循环链表的形式链接在一起。链表的第一个元素用 super_blocks 变量表示,超级块对象的 s_list 字段存放指向链表相邻元素的指针。sb_lock 自旋锁用于避免链表被多个处理器系统同时访问。
  • s_fs_info 字段指向属于具体文件系统的超级块信息。任何基于磁盘的文件系统都需要访问和更新自己的磁盘分配位图,以便分配或释放磁盘块。为了提高效率,由 s_fs_info 字段所指向的数据被复制到内存,VFS 允许这些文件系统直接对内存超级块的 s_fs_info 字段进行操作,无需访问磁盘。
  • 不过这种方法可能会导致 VFS 超级块与磁盘上的超级块不同步,因此通过引入 s_dirt 标志来表示该超级块是否是脏的,以便及时更新磁盘上的数据。

    Linux 通过周期性地将所有脏的超级块回写磁盘来减少不一致的风险

  • 超级块的操作方法由数据结构 super_operations 结构表示,该结构的起始地址存放在超级块的 s_op 字段中

    每一个具体的文件系统可以定义自己的超级块操作。当 VFS 需要调用其中一个操作时,如 read_inode(),执行操作如下:

      sb->s_op->read_inode(inode); 
      // sb 存放涉及的超级块对象地址;super_operation 表的 read_inode 字段存放对应函数的地址
    

2-1-1 超级块操作方法

超级块操作表 super_operation 中一些比较常见的方法:

  • alloc_inode(sb):为索引节点对象分配空间,包括具体文件系统的数据所需空间
  • destroy_inode(inode): 撤销索引节点对象,包括具体文件系统的数据
  • read_inode(inode): 用磁盘上的数据填充以参数传递过来的索引节点对象的字段

    索引节点对象的 i_ino 字段标识从磁盘上要读取的具体文件系统的索引节点

  • dirty_inode(inode): 当索引节点被修改时调用
  • write_inode(inode): 通过传递参数指定的索引节点对象内容更新一个文件系统的索引节点

    索引节点对象的 i_ino 字段标识所涉及的磁盘上文件系统的索引节点

  • put_inode(inode): 释放索引节点时调用,同时减少该节点引用计数器值
  • drop_inode(inode): 当最后一个用户释放该索引节点时调用;该索引节点将会被撤消
  • delete_inode(inode): 撤消索引节点时调用;删除内存中的 VFS 索引节点和磁盘上的文件数据及元数据

2-2 索引节点对象

文件系统处理文件所需要的所有信息都存放在一个名为索引节点的数据结构中。内存中的索引节点对象由一个 inode 数据结构组成。

文件名可以随意更改,但是对文件来说,索引节点时唯一的,并且随着文件的存在而存在

  • i_hash: 用于散列表的指针
  • i_list: 用于描述索引节点当前状态的链表的指针
  • i_sb_list: 用于超级块的索引节点链表的指针
  • i_dentry: 引用索引节点的目录项对象链表的头
  • i_ino: 索引节点号
  • i_count: 引用计数器
  • i_nlink: 硬链接数目
  • i_state: 索引节点的状态标志
    • 如果 i_state 的值为 I_DIRTY_SYNC, I_DIRTY_DATASYNC, I_DIRTY_PAGES ,则表明该索引节点是脏的,对应的磁盘索引节点必须更新
    • 如果 i_state 字段的值为 I_LOCK,表明对应的索引节点对象处于 IO 传输中;I_FREEING 表示索引节点对象正在被释放;I_CLEAR 表示索引节点对象的内容不再有意义;I_NEW 表示索引节点对象已经分配,但是还没用磁盘索引节点读取来的数据填充
  • i_size: 文件的字节数
  • i_blocks: 文件的块数
  • i_op: 索引节点的操作
  • i_sb: 指向超级块对象的指针

每个索引节点对象都会复制磁盘索引节点包含的一些数据,如分配给文件的磁盘块数。

每个索引节点对象总是处于下列双向循环链表中的某个链表中(链表相邻元素的指针存放在 i_list 字段中):

  1. 有效未使用的索引节点链表:这些索引节点当前未被任何进程使用,且不为脏,i_count 字段为 0;链表首尾元素由变量 inode_unused 引用。该链表用作磁盘高速缓存。
  2. 正在使用的索引节点链表:这些索引节点当前正在被某些进程使用,且不为脏,i_count 字段为正数;链表的首尾元素由变量 inode_in_use 引用。
  3. 脏索引节点的链表:链表的首尾元素由相应的超级块对象的 s_dirty 字段引用。

这些链表都是通过适当索引节点对象的 i_list 字段链接在一起

每个索引节点对象也包含在文件系统(per-filesystem)的双向循环链表中,链表的头存放在超级块对象的 s_inodes 字段中;索引节点对象的 i_sb_list 字段存放了指向链表相邻元素的指针。

索引节点对象也存放在一个被称为 inode_hashtable 的散列表中。散链表加快了对索引节点的搜索。

2-2-1 索引节点操作方法

与索引节点对象关联的方法也被称为索引节点操作,由 inode_operation 结构来表示,存放在索引节点对象的 i_op 字段中。

  • create(dir, dentry, mode, nameidata): 在某一目录下,为目录项对象相关的普通文件创建一个新的磁盘索引节点
  • lookup(dir, dentry, nameidata): 为包含在一个目录项对象中的文件名对应的索引节点查找目录
  • link(old_dentry, dir, new_dentry): 创建一个名为 new_dentry 的新的硬链接,指向 dir 目录下名为 old_dentry 的文件
  • unlink(dir, dentry): 从一个目录中删除目录项对象所指定文件的硬链接
  • symlink(dir, dentry, mode): 在某个目录下,为与目录项对象相关的目录创建一个新的索引节点
  • mkdir(dir, dentry, mode): 在某个目录下,为与目录项对象相关的目录创建一个新的索引节点
  • rmdir(dir, dentry): 从一个目录删除子目录,子目录的名称包含在目录项对象中

2-3 文件对象

文件对象描述进程如何与一个打开的文件进行交互。文件对象是在文件被打开时创建的,由一个 file 结构表示。

文件对象在磁盘上没有对应的映像,因此 file 结构中没有设置 dirty 字段来表示文件对象是否被修改

存放在文件对象中的主要信息是文件指针,即文件当前的位置,下个操作将在该位置执行。

由于几个进程可能同时访问同一文件,因此文件指针必须存放在文件对象而不是索引节点对象中

文件对象通过一个名为 filp 的 slab 高速缓存分配,可分配的文件对象数目存在上限,因此系统可同时访问的文件数目也存在上限

  • 正在使用中的文件对象包含在具体文件系统的超级块的链表中,每个超级块对象把文件对象链表的头存放在 s_files 字段中。因此,属于不同文件系统的文件对象包含在不同的链表中。文件对象的 f_list 字段用于指向链表的前后元素。
  • f_count 字段为引用计数器,用于记录使用文件对象的进程数。

    当内核本身使用该文件对象时,也需要增加计数器的值

当 VFS 代表进程必须打开一个文件时,调用 get_empty_filp() 函数分配一个新的文件对象。该函数调用 kmem_cache_alloc() 从 filp 高速缓存中获得一个空闲的文件对象,之后初始化该对象的字段。

memset(f, 0, sizeof(*f));
INIT_LIST_HEAD(&f->f_ep_links);
spin_lock_init(&f->f_ep_lock);
atomic_set(&f->f_count, 1);
f->f_uid = current->fsuid;
f->f_gid = current->fsgid;
f->f_owner.lock = RW_LOCK_UNLOCKED;
INIT_LIST_HEAD(&f->f_list);
f->f_maxcount = INT_MAX;

之前说过,每个文件系统都有其自己的文件操作集合,用于执行文件读写等操作。

  • 当内核将一个索引节点从磁盘装入内存时,就会把指向这些文件操作的指针存放在 file_operatrions 结构中,而该结构的地址存放在索引节点对象的 i_fop 字段中。
  • 当进程打开文件时,VFS 就用存放在索引节点中的这个地址初始化新文件对象的 f_op 字段,使得后续对该文件的操作能够调用这些函数。
  • 如果有需要,VFS 随后可以通过在 f_op 字段存放一个新值来修改文件操作的集合。

2-3-1 文件对象操作方法

  • llseek(file, offset, origin): 更新文件指针
  • read(file, fuf, count, offset): 从文件的 offset 处开始读取 count 个字节,然后增加 offset 的值
  • aio_read(req, buf, len, pos): 异步 IO 读取操作
  • write(file, buf, count, offset): 从文件的 offset 处开始写入 count 个字节,然后增加 offset 的值
  • aio_write(req, buf, len, pos): 异步 IO 写入操作
  • mmap(file, vma): 执行文件的内存映射,并将映射放入进程的地址空间
  • open(inode, file): 通过创建一个新的文件对象来打开一个文件,并将其连接到对应的索引节点对象
  • flush(file): 当打开的文件引用被关闭时调用
  • fsync(file, dentry, flag): 将文件所缓存的全部数据写入磁盘
  • aio_fsync(req, flag): 异步 IO 刷新操作
  • lock(file, cmd, file_lock): 对 file 文件申请一个锁

2-4 目录项对象

VFS 把每个目录看作由若干子目录和文件组成的一个普通文件。当一个目录项被读入内存,VFS 就会将其转换成基于 dentry 结构的一个目录项对象。

对于进程查找的路径名中的每个分量,内核都会为其创建一个目录项对象。目录项对象将每个路径分量与其对应的索引节点联系起来。如在查找路径名 /tmp/test 时,内核为根目录 / 创建一个目录项对象,为根目录下的 tmp 项创建一个第二级目录项对象,为 /tmp 目录下的 test 项创建一个第三级目录项对象。

与文件对象一样,目录项对象在磁盘上也没有对应的映像,因此 dentry 结构不包含指出该对象已被修改的字段

目录项对象存放名为 dentry_cache 的 slab 高速缓存中;目录项对象的创建和删除是通过调用 kmem_cache_alloc()kmem_cache_free() 实现的。

  • d_count: 目录项对象的引用计数器
  • d_flag: 目录项高速缓存标志
  • d_inode: 与文件名关联的索引节点
  • d_parent: 父目录的目录项对象
  • d_child: 对目录而言,用于同一父目录中的目录项链表的指针
  • d_subdirs: 对目录而言,子目录项链表的头
  • d_name: 文件名

每个目录项对西那个可以处于以下四种状态之一:

  1. 空闲状态(free)

    该状态的目录项对象不包含有效信息,且还没被 VFS 使用。对应的内存区由 slab 分配器进行分配

  2. 未使用状态(unused)

    该状态的目录项对象还没被内核使用。引用计数器 d_count 值为 0,但是其 d_inode 字段指向关联的索引节点。目录项对象包含有效的信息,但是为了在必要时回收内存,对应内容可能会被丢失。

  3. 正在使用状态(in use)

    该状态的目录项对象正在被内核使用。引用计数器 d_count 值为正数,其 d_inode 字段指向关联的索引节点对象。目录项对象包含有效的信息,且不能被丢弃。

  4. 负状态(negative)

    与目录项关联的索引节点不存在,可能是因为相应的磁盘节点已经被删除,或者是因为目录项对象是通过解析一个不存在文件的路径名创建的。目录项对象的 d_inode 字段为 NULL,但是该对象仍被保存在目录项高速缓存中,以便后续对同一个文件目录名的查找操作能快速完成。

2-4-1 目录项对象操作方法

与目录项对象关联的方法称为目录项操作,由 dentry_operation 结构表示,该结构的地址存放在目录项对象的 d_op 字段中。

  • d_revalidate(dentry, nameidata): 把目录项对象转换为一个文件路径名之前,判断该目录项对象是否仍然有效。
  • d_delete(dentry): 当对目录项对象的最后一个引用被删除时,调用该方法。
  • d_release(dentry): 当要释放一个目录项对象时(放入 slab 分配器),调用该方法。
  • d_iput(dentry, ino): 当一个目录项对象变为负状态使调用(即丢弃对应的索引节点)

2-5 目录项高速缓存

由于从磁盘读入一个目录项并构造相应的目录项对象需要花费一定的时间,因此在完成对目录项对象的操作之后,并不会立即丢弃,而是将其保留在内存中。为了最大限度地提高处理这些目录项对象的效率,Linux 使用目录项高速缓存,该缓存由两种类型的数据结构组成:

  1. 一个处于正在使用,未使用或负状态的目录项对象的集合
    • 所有未使用的目录项对象存放在一个“最近最少使用(LRU)”的双向链表中
    • 正在使用的目录项对象被保存在一个双向链表中,该链表由索引对象的 i_dentry 字段引用;目录项对象的 d_alias 字段存放链表中相邻元素的地址

      每个索引节点对象可能与多个硬链接关联,因此需要一个链表

    • 当指向文件的最后一个硬链接被删除之后,一个正在被使用的目录项对象可能变成负状态。此时,该目录项对象被移动到未使用目录项对象组成的 LRU 链表中
  2. 一个能够快速获取与给定的文件名和目录名对应的目录项对象的散列表

    如果目标目录项对象不存在缓存中,那么散列函数范围空值

    • 散列表是由数组实现,数组中的每个元素是一个指向链表的指针。目录项对象的 d_hash 字段包含指向具有相同散列值的链表中的相邻元素

目录项高速缓存还相当于索引节点高速缓存(inode cache)的控制器。

  • 在内核内存中,不会丢弃与未使用目录项相关的索引节点,因此这些索引节点对象仍保存在 RAM 中,并且能够通过相应的目录项快速引用

dcache_lock 自旋锁用于保护目录项高速缓存数据结构避免被多个处理器同时访问。

2-6 与进程相关的文件

之前提到,每个进程都有自己当前的工作目录与根目录,这仅仅是进程与文件系统交互所必须维护的数据中的两个例子。类型 fs_struct 数据结构维护了进程与文件系统交互所需要的数据每个进程描述符的 fs 字段指向该结构

  • count: 共享这个表的进程个数
  • lock: 用于表中字段的读写自旋锁
  • root: 根目录的目录项
  • pwd: 当前工作目录的目录项

除了 fs_struct,还有另一个结构 files_struct,用于表示进程当前打开的文件。进程描述符的 files 字段存放了该表的地址

  • fd: 指向文件对象的指针数组;max_fds: 指向该数组的长度
  • 通常,fd 字段指向 files_struct 结构的 fd_array 字段,该字段包含 32 个文件对象指针。如果进程打开的文件数大于 32,内核就分配一个新的,更大的文件指针数组,并将地址存放在 fd 字段中,内核同时更新 max_fds 字段的值
  • 对于 fd 数组中有元素的每个文件来说,数组的索引就是文件描述符(file descriptor)

    fd 数组的第一个元素(index = 0)通常是进程的标准输入文件第二个元素(index = 1)是进程的标准输出文件,第三个元素(index = 2)是进程的标准错误文件。如下图所示:

  • Unix 进程将文件描述符作为主文件标识符。两个文件描述符可以指向同一个打开的文件,即数组中的两个元素可能指向同一个文件对象。
  • 进程不能使用多于 NR_OPEN(一般为 1048576)个文件描述符。
  • open_fds: 指向打开文件描述符的指针。open_fds_init: 文件描述符的初始集合。

使用文件对象

  • 当内核开始使用一个文件对象时,调用 fget() 函数:这个函数接收文件描述符 fd 作为参数,返回 current→files→fd[fd] 中的地址,即对应文件对象的地址;同时使文件对象引用计数器 f_count 值 +1。如果没有文件与 fd 对应,则 fget() 返回 NULL。

释放文件对象

  • 当内核完成对文件对象的使用后,调用 fput() 函数:该函数将文件对象的地址作为参数,并减少文件对象引用计数器 f_count 的值。如果 f_count 的值变成 0,则该该函数调用文件操作的 release 方法,减少索引节点对象的 i_write count 字段的值,并将文件对象从超级块链表中移除,释放文件对象给 slab 分配器,最后减少相关的文件系统描述符的目录项对象的引用计数器的值。

3-文件系统类型

Linux 内核支持多种不同的文件系统。有一些特殊的文件系统在 Linux 内核设计中具有重要作用。

3-1 特殊文件系统

当网络和磁盘文件系统能够使用户处理存放在内核之外的信息时,特殊文件系统可以为系统管理员和程序员提供一种容易的方式来操作内核的数据结构并实现操作系统的特殊特征

特殊文件系统提供给系统管理员使用,网络和磁盘文件系统提供给普通用户使用

常用的特殊文件系统如下:

注意,有几个文件系统没有固定的安装点

  • bdedv 块设备;pipefs 管道;shm IPC 共享线性区;sockfs 套接字;usbfs USB 设备;tmpfs 临时文件(如果不被交换出去就保持在 RAM 中)

特殊文件系统不局限于物理块设备。不过,内核给每个安装的特殊文件系统分配一个虚拟的块设备,其主设备号为 0,次设备号具有任意值(不同特殊文件系统的值不同)。

3-2 文件系统类型注册

VFS 需要对代码已经在内核中的所有文件系统的类型进行跟踪,需要进行文件系统类型注册来实现。

每个注册的文件系统使用类型为 file_system_type 对象来表示,该对象结构如下:

所有文件系统类型的对象都插入到一个单向链表中,该链表由自旋锁保护避免同时访问。

在系统初始化期间,调用 register_filesystem() 函数来注册编译时指定的每个文件系统;该函数把相应的 file_system_type 对象插入到文件系统类型的链表中。

4-文件系统处理

与其他 Unix 系统一样,Linux 也使用系统的根文件系统(system’s root filesystem)由内核在引导阶段直接安装,并拥有系统初始化脚本及最基本的系统程序

其他文件系统要么由初始化脚本安装,要么由用户直接安装在已经安装的文件系统的目录上。

作为一个目录树,每个文件系统都拥有自己的根目录(root directory)。安装文件系统的目录称之为安装点(mount point)对于已经安装的文件系统属于安装点目录的一个子文件系统,如 /proc 虚拟文件系统是系统的根文件系统的子文件系统。

4-1 命名空间

在 Linux 中,每个进程可以拥有自己的已安装文件系统树,被称为进程的命名空间(namespace)。通常大多数进程共享同一个命名空间,即位于系统的根文件系统且被 init 进程使用的已安装的文件系统树。

5-路径名查找

当进程需要识别一个文件时,就将文件路径名传递给某个 VFS 系统调用(如 open(), mkdir(), rename() 等),VFS 会将路径名导出对应的索引节点。

路径名查找的标准流程是分析路径名并把它拆分成一个文件名序列,除了最后一个文件名以外,其余所有文件名肯定都是目录

  • 如果路径名的第一个字符是 “/”,则该路径名是绝对路径,需要从 current→fs→root(进程根目录)所标识的目录开始搜索
  • 否则,该路径名是相对路径,需要从 current→fs→pwd(进程当前目录)所标识的目录开始搜索

在对初始目录的索引节点进行处理时,需要检查与第一个名字匹配的目录项,获得相应的索引节点;之后,从磁盘读取包含那个索引节点的目录文件,并检查与第二个名字匹配的目录项,以获得相应的索引节点。对包含在路径中的每个名字,该过程反复执行。

目录项高速缓存能够加速上述解析流程,因为最近最常使用的目录项对象保被缓存在内存中,每个目录项对象使得特定目录中的一个文件名与它相应的索引节点联系起来。因此,可以避免在路径分析过程中从磁盘读取中间目录。

路径名查找由 path_look_up() 函数执行,接收三个参数:

  • name: 指向要解析的文件路径名的指针
  • flags: 标志,表示将会如何访问查找到的文件
  • nd: nameidata 数据结构的地址,存放了查找操作的结果

    path_lookup() 返回时,nd 指向的 nameidata 结构用与路径名查找操作有关的数据填充

nameidata 结构如下:

  • dentrymnt 分别指向所解析的最后一个路径分量的目录项对象和已经安装文件系统对象

    这两个字段表示给定的路径文件

flags 字段的取值有: