3.1.2 关键处理流程举例
为了更加清楚地理解VFS与具体文件系统的关系,本节以Ext2文件系统的挂载、打开文件与写入数据为例介绍一下用户态接口、VFS和Ext2接口之间的调用关系。通过上述流程的分析,我们会对VFS的架构及关键数据结构和流程有比较清晰的认识。基于这个基础,在学习其他流程时也就相对轻车熟路了。
3.1.2.1 文件系统的注册
在Linux中,具体文件系统通常是一个内核模块。在内核模块被加载(初始化)时完成文件系统的注册。以Ext2文件系统为例,其初始化代码如代码3-1所示,调用register_filesystem()函数将Ext2文件系统注册到系统中。这个函数其实就是Linux中模块初始化的代码,任何内核模块都有一个类似的初始化函数。
代码3-1 Ext2文件系统初始化
register_filesystem()函数调用了两个主要的函数,一个是初始化inode缓存(第1641行);另一个是调用文件系统注册函数(第1645行)。文件系统的注册很简单,该流程就是将表示某种类型的文件系统的结构体(file_system_type)实例添加到一个全局的链表中。这个结构体实例主要实现的函数是mount()。以Ext2文件系统为例,该结构体如代码3-2所示。
代码3-2 Ext2文件系统的结构体
由于文件系统实例被添加到一个全局链表中,当用户态执行挂载命令时就可以调用这里的mount()函数指针(对于Ext2文件系统,其具体实现为ext2_mount)。mount()函数的主要作用是从存储介质读取超级块信息,并创建该文件系统根目录的dentry实例。这个dentry实例在后面挂载流程中将被用到。
大家在这里只需要知道文件系统注册了一个mount()函数即可,关于挂载的更多细节会在后面章节再详细介绍。
3.1.2.2 打开文件的流程
按理说应该先介绍一下文件系统的挂载流程,毕竟文件系统的挂载才是一个从无到有的过程。但是直接介绍挂载流程,大家理解上有点困难,因此先介绍打开文件的流程。
当打开一个文件时,调用的是open()函数,其语法格式如下:
open()函数传入一个字符串的路径参数(如/mnt/data/dir1/file.log),然后返回一个文件描述符。返回的文件描述符就是一个整数,后续用该整数表示一个文件。这样就可以通过这个文件描述符进行访问,如读/写数据相关接口。
本节将介绍打开文件的流程,重点解释清楚如下几个问题。
(1)如何通过一个字符串路径来打开一个文件?
(2)为什么通过一个文件描述符就可以实现文件的访问?
要回答上述问题,需要更加深入地分析内核打开文件的流程。在内核中,打开的文件是通过file结构体表示的,而且该结构体与进程关联。因此,使用进程打开的所有文件都保存在表示进程结构体(task_struct)的files成员中。进程结构体(task_struct)与file的关系比较复杂,如图3-5所示。其中,fdtable是一个文件描述符表,fd成员以数组的形式存储file结构体中的指针,而上文所述的文件描述符其实就是该数组的索引。
在3.1.1节提到file结构体中最重要的是函数指针。正是通过这些函数指针,当读/写该文件时就可以访问具体文件系统的函数。接下来介绍一下这些函数指针是如何被初始化的。
首先,从整体上看一下打开文件的流程,如图3-6所示。该流程忽略了缓存情况,只展示了核心流程。该流程有两个主要分支,分支1用来对文件路径进行解析,并逐级构建每级目录名/文件名对应的inode和dentry;分支2则进行文件打开必要的设置工作,具体内容根据不同的文件系统而定。
图3-5 进程结构体(task_struct)与files的关系
图3-6 打开文件的流程
图3-6中的核心函数是do_sys_openat2(),如代码3-3所示。在do_sys_openat2()函数中,首先调用get_unused_fd_flags()函数(第1177行)分配一个可用的文件描述符;然后调用do_flip_open函数(第1179行)打开文件,返回file指针;最后调用fd_install()函数(第1185行)将file指针关联到进程结构体(task_struct)中文件描述符所在的数据项。
完成上述关联操作后,后续对文件进行读/写等操作,就可以通过文件描述符找到对应的file结构体指针。
代码3-3 do_sys_openat2()函数
更进一步,我们看一下do_filp_open()函数是如何分配并初始化file结构体指针的。在图3-6中,分支1通过link_path_walk()函数实现字符串路径的解析,该函数的语法格式如下:
其中,name参数是字符串表示的路径;nd参数类似一个迭代器,用于存储中间结果和最终结果。
路径(Path)字符串被“/”拆分为若干部分,每一部分称为一个组件(Component),如图3-7所示。在打开文件时,link_path_walk()函数正是通过逐个组件遍历的方式最终打开文件的。
组件的遍历就是逐渐实例化该组件对应的inode和dentry的过程。在没有任何缓存的情况下,dentry会先被初始化,在dentry中包含文件/目录名字符串。在具体某一级目录中,会调用该目录inode的lookup()函数查找该目录中的对应子项(子目录或子文件),然后完成子项dentry和inode的初始化。
图3-7 路径与组件
以Ext4文件系统中的lookup()函数为例,通过其关键代码(见代码3-4)可以看出共有3个关键步骤。
(1)从目录中查找对应子项:根据dentry存储的名称字符串从目录中查找是否有对应的项目。如果有该名称对应的文件/目录,则返回目录项数据结构de。同时,dentry会被插入到哈希表中。
(2)创建并初始化inode:根据de中保存的inode ID信息从磁盘查找inode数据,并初始化内存数据结构inode。该inode与具体文件系统相关。
(3)关联inode与dentry:在dentry中有一个成员用于存储inode信息,这一步骤会建立两者之间的关系。
代码3-4 ext4_lookup()函数
在分支1完成路径解析,获得inode和dentry之后,分支2负责file指针的设置。主要代码在do_dentry_open()函数中,将该函数中无关代码删除,只保留核心代码,如代码3-5所示。
代码3-5 do_dentry_open()函数
通过上述代码可以看出,这里完成了file指针的主要初始化工作,特别是函数指针的初始化(第809行)。通过上文介绍的打开文件的流程,我们对如何从一个路径字符串打开一个具体的文件,最终生成file指针和文件描述符的过程有了一定的了解。
上面介绍的打开文件的流程是缓存中没有期望内容的情况。如果在缓存中已经有dentry和inode,那么就不用调用lookup()函数,而是可以直接从缓存中获得dentry和inode,因此打开文件的流程会简单一些。
接下来再看一看用户态的文件描述符为什么可以表示一个文件。其实前面已经提及,在Linux中,打开文件必须要与进程(线程)关联。也就是说,一个打开的文件必须隶属于某个进程。在Linux内核中一个进程通过task_struct结构体描述,而打开的文件则用file结构体描述。
通过图3-5可知,file指针其实就是task_struct结构体中的一个数组项。而用户态的文件描述符其实就是数组的下标。这样通过文件描述符就可以很容易到找到file结构体指针,然后通过其中的函数指针访问数据。
接下来看一下具体的代码。以写入数据流程为例,在内核中是ksys_write()函数。如代码3-6所示,其中,第622行中的fdget_pos()函数根据fd返回fd类型的结构体,而该结构体中包含file结构体指针。后续的操作则是以该指针来表示这个文件的。
代码3-6 ksys_write()函数
完成本节阅读后,大家应该对字符串路径与dentry和inode结构体的关系,以及file结构体中指针的内容有所了解。基于这个基础,我们再学习挂载的过程就要简单一些。
3.1.2.3 挂载文件系统
挂载是用户态发起的命令,也就是大家都知道的mount命令。当执行该命令时需要指定文件系统的类型(本文假设为XFS)、文件系统数据的位置(也就是设备)及希望挂载到的位置(挂载点)。通过这些关键信息,VFS就可以完成具体文件系统的初始化,并将其关联到当前已经存在的文件系统中。假设操作系统使用的是Ext4文件系统,有一个磁盘(sdc)并格式化为XFS文件系统。我们期望将磁盘sdc挂载到/mnt/xfs_test目录下,命令如下:
执行上述命令后,XFS文件系统就被挂载到xfs_test目录了。这样,当访问xfs_test目录时就是访问的XFS根目录,而不是原Ext4文件系统的目录了。
为了更加清楚地说明该问题,给出一个实例,如图3-8所示。假设有一个磁盘并格式化为XFS文件系统,在根目录有xfs_file1和xfs_file2两个文件。此时,期望以xfs_test作为挂载点对XFS进行挂载。在挂载之前该目录中有file1和file2两个文件,上述文件是Ext4中的数据(见图3-8上半部分)。如果执行上面挂载命令后,则该目录的内容就变成XFS根目录中的内容,也就是xfs_file1和xfs_file2(见图3-8下半部分)。
结合前文,我们知道文件/目录名是与dentry相关联的,而dentry又和inode相关联。因此,无论是访问文件还是目录中的内容,关键是找到对应的元数据并初始化为inode。其中,比较重要的是对inode中操作函数的初始化。
由此我们可以猜测,对于挂载操作,应该是将dentry中的d_inode成员由Ext4的inode替换为XFS的inode。这样在打开文件流程中遍历路径时,获取的就是已挂载文件系统的inode,访问的数据自然就是已挂载文件系统的数据。是否如猜想的那样?我们接下来具体分析一下文件系统挂载的代码。
mount命令本质上调用的是mount API,其函数原型如下:
从参数可以看出,主要包括设备路径、挂载点和文件系统类型等参数。
以文件系统API为入口,挂载操作的核心流程如图3-9所示。由于Linux的挂载命令支持的特性比较多,所以代码的各种分支流程很多。限于篇幅,本节以基本挂载流程为例进行介绍,其他流程大同小异,大家可以自行阅读内核相关代码。
图3-8 文件系统挂载示意图
图3-9 文件系统挂载操作的核心流程
在上述核心流程中,涉及挂载的关键信息的初始化是在do_new_mount()函数中完成的,包括获取待挂载的文件系统类型数据结构、创建文件系统上下文数据结构体和获取待挂载文件系统的根目录等,如代码3-7所示。
代码3-7 do_new_mount()函数
其中,文件系统上下文数据结构体包含了挂载文件系统必需的信息,最主要的是在调用vfs_get_tree()函数时会调用具体文件系统中的mount()函数,然后将该函数返回的根目录对应的dentry填充到文件系统上下文数据结构体(以下简称为fc)中。
有了上述信息的准备后,接下来就调用do_new_mount_fc()函数来完成后续的挂载动作。在该函数中会根据fc中的信息创建一个vfsmount的实例,vfsmount结构体定义如代码3-8所示。
代码3-8 vfsmount结构体定义
在vfsmount结构体中有两个非常重要的成员:一个是mnt_root,它是文件系统根目录的dentry;另一个是mnt_sb,它是文件系统的超级块数据。
另一个与vfsmount关联的结构体是mount,前者是后者的一个成员,两者关系如图3-10所示。mount结构体有很多成员,我们这里不再逐一介绍,比较重要的成员包括mnt_mountpoint(挂载点目录项)、mnt(挂载文件系统的信息)和mnt_mp(挂载点)。
图3-10 挂载相关数据结构
除了上面成员,mount结构体还有mnt_parent和mnt_child成员,通过上述成员将mount构成一个树形结构。另外,在mount结构体中还有一个用于哈希表的成员,用于将mount结构体添加到哈希表中。
了解了上述数据结构,接下来看一下挂载流程中几个比较重要的函数。其中,一个是d_set_mounted()函数,该函数的实现如代码3-9所示。d_set_mounted()函数最主要的语句是第1459行,用于为dentry增加DCACHE_MOUNTED旗标。通过该旗标标识该子目录是一个特殊的子目录,也就是挂载了文件系统的子目录,这个旗标在打开文件时会用到。
代码3-9 d_set_mounted()函数
另外两个比较重要的函数是mnt_set_mountpoint()和commit_tree()(这两个函数通过do_new_mount_fc->do_add_mount->graft_tree->attach_recursive_mnt路径被先后调用),通过这两个函数建立了父子mount之间的关联,并且将待挂载的mount添加到哈希表中。当完成上述函数的处理流程后,文件系统也就挂载成功了。
由于dentry在mount中,因此父子mount关联之后,在文件系统层面也就建立了挂载点dentry和待挂载设备根目录dentry之间的关联。也就是说,通过挂载点中的dentry,我们就能找到挂载设备中的dentry。
返回打开文件遍历路径的流程,看一看对挂载点有什么特殊的处理。当每次遍历路径中的一个组件时,最后都会调用step_into()函数,该函数最终会调用__traverse_mounts()函数进行挂载点的处理。__traverse_mounts()函数与挂载点相关的代码如代码3-10所示。
代码3-10__traverse_mounts()函数
通过代码3-10可以看出,第1224行代码判断该组件是否为挂载点。如果是挂载点,则通过调用lookup_mnt()函数来找到对应的vfsmount实例。由于该实例保存着已挂载文件系统的根目录dentry,因此,可以使用该dentry更新path中的dentry,而忽略原始的dentry。
有了dentry之后,也就相当于找到了该文件系统根目录对应的inode。从而使用该inode的函数指针就可以访问已挂载文件系统的数据。
通过上述分析,我们对挂载流程如何实现将一个具体文件系统挂载到当前目录树的一个子目录有了比较清晰的认识。可以看出上述流程主要是在VFS中完成的,而具体文件系统方面主要是调用了其实现的mount()函数来创建一个根目录的dentry。
3.1.2.4 读/写数据流程
打开文件的知识,理解文件系统操作VFS与具体文件系统的关系就简单多了。由于文件的绝大部分操作都是通过在inode注册的函数指针完成的,而在打开文件时,函数指针会被赋值给file结构体中的成员f_op。因此,对于文件的读/写等访问,经过VFS后都可以找到对应的具体文件系统的函数指针进行具体文件系统的操作。