从双击到内核:一次文件打开背后的操作系统原理
文件系统的基本全貌(宏观视角)
1.1 文件与文件系统
文件也有一些分类。按逻辑结构分类如下所示。
图1 文件逻辑结构分类标题
文件系统,首先包括的当然是当前存储容器中的所有文件。如果仅仅是一摞文件丢在存储容器中,就像我的房间一样,是非常乱的,作为用户的我无法很好的利用这个文件。所以文件系统里头也得包括管理文件的功能,这样当用户需要找寻文件的时候,直接调用文件系统提供的接口即可获取文件。就像我在我的房间安排一个管家管理我的文件一样,我只需要吩咐管家我要打开文件名为xxx的文件,管家就可以把该文件递到我手中了。
图2 文件系统组成
值得注意的是,在我们的windows个人电脑中,一个物理硬盘可以分为几个逻辑硬盘ABCD。一个D盘对应一个文件系统,一个C盘对应另一个文件系统,也就是说,D盘和C盘对应了两个独立的文件系统,就好像在家里,我和弟弟的房间是独立的两间,管家也是独立的两个人。而这两个管家,可能出自同一公司,也可能出自不同公司,这就是具体而言文件系统的不同了。当然,如果属于同一个物理硬盘,那他们就是同一种文件系统,不过相互之间也是独立的,就像两个来自同一公司的相互独立的管家。
图3 逻辑硬盘与文件系统的关系
1.2 文件系统的层次结构
文件系统并不是独立于操作系统、位于操作系统之下的东西,而是操作系统内核中的一个重要子系统,负责文件的组织、存储、访问和管理。具体的文件系统有FAT文件系统(file allocation table),ext2文件系统(extension:linux常用的文件系统),NTFS文件系统(New Technology File System,windows的默认文件系统),这些具体的文件系统,都是操作系统内核中的实现(或可加载模块),它们向上通过系统调用接口为用户程序服务,向下通过设备驱动访问磁盘。
因此,每个文件系统具体的管理方式是不同的,不过其管理哲学还是相似的。由于实现者不同,其提供的接口当然也是不一样的,那么操作系统为了屏蔽文件系统之间的不同,就提供给了这些文件系统一套接口格式。(这就是虚拟文件系统)“你们这些文件系统如果不靠我要求的接口格式来实现接口【也就是函数名(实参)】,那你就别想接入我的操作系统了。”学到这我就想到,难怪一个应用不能同时上架到ios和安卓,原来是操作系统搁这设置了不同的门槛呢,也就是设置了不同的接口规范。作为文件系统的开发者,我必须写两份逻辑相同格式不同的代码,才能分别接入 Windows 和 Linux。
图4 虚拟文件系统
文件系统的层次,笔者认为非常抽象,而且这些层次虽然看起来相互独立,但有时不免重叠。层次结构从上至下分别是:
`用户接口(用户调用)-文件目录系统(就是一个目录以及其管理方案)-存取控制模块(权限控制)-逻辑文件系统与文件信息缓冲区(管理inode与逻辑块)-物理文件系统(将逻辑地址转换为物理地址)-设备管理模块(cpu与设备交流的中介)-设备。具体内容暂且按下不表。
图5 文件系统的层次结构
1.3 逻辑磁盘
前面也提到了,在我们的笔记本电脑上,windows操作系统中,一个物理硬盘会被分割为多个逻辑磁盘,也叫卷。所以你一打开"我的电脑",就会有好几个目录,他们分别对应物理硬盘上连续的一段区域,相互之间是独立的文件系统。而linux中,一个操作系统就只对应一个根目录。私以为,我们可以把一个windows系统下的CDE盘对应到linux中的根目录/,就相当于,windows中有好几个根目录,而linux中只有一个根目录。
图6 windows与linux目录定义的差异
小补充:对用户而言,打开根目录看到的bin、home、usr等文件夹可能来自物理上完全不同的磁盘,但操作系统通过挂载机制将它们“拼”成了一棵统一的目录树,用户根本感知不到底层有几块硬盘。挂载就是将某个硬盘放到linux中的某个目录之下而不独立出来,保证整个操作系统只有一个根目录。
目录结构
2.1 树形结构
我们熟悉的目录结构是树形目录结构,这是最最最常用的目录结构,目前笔者还没见过其他的目录结构呢。而在文件系统的发展中,还包括其他的目录结构,分别是单目录结构,双目录结构,以及在树形结构的基础上发展的有向无环图目录结构。
单目录和双目录可太好理解了,就是一个文件系统中只有一个目录或者两个目录,而这个目录之下就全是文件,没有目录了。这两种层次结构最大和最明显的弊端就是同一个文件系统下允许重名的文件可太少了。
而树形结构,就允许目录之下有目录,也有文件,就像我们现在使用的这样,可以无限套娃存储文件。
有向无环图目录结构,是在树形结构的基础上,为了实现“共享”而发展的,不过由于它实现的共享有点瑕疵(bug),所以就没有被广泛使用,这里等我们介绍了inode和文件打开表后在文件共享中详说。
接下来我们谈论的内容都建立在树形目录之下。
2.2 目录项
我个人认为教科书上对文件目录的描写太晦涩了,其实是很简单的东西,但是为了严谨,就不得不用一些专业词汇套来套去,给套复杂了。
书中首先强调了目录也是一种文件。这是显然的,但是有点弯绕的原因就是,我们理解文件这个词的时候,其实是有两层含义的,宏观的文件就是文件系统管理的单位,既包括微观的文件也包括目录。微观的文件就是那些带有文件后缀的文件,(当然有一些不带文件后缀咱们可以理解成它把文件后缀省略掉了)。我寻思就应该起个别名,比如宏观文件叫文件单位,微观文件叫后缀文件,anyway,这只是我个人的牢骚。
目录中的目录项,其实就对应着我们打开一个目录后显示的每一行文件,只是目录项中每一列的数据,大部分都被系统省略了,作为用户乍一看只能看到文件名。要是想看到其他部分列的数据,咱们可以右键点击属性,能够查到文件的详细信息,不过文件的物理地址,就被系统隐藏了,没必要,咱也看不懂。
对于目录项,我们只需要把目录项中的列从宏观上分为三列:文件名,文件详细信息,文件的物理地址。
图7 目录与目录项
2.3 补充
绝对路径与相对路径
相对路径就是相对当前路径的文件位置,绝对路径就是相对根目录的文件位置。
比如绝对位置C:\Users\yufeng\language.txt,其相对yufeng这个目录的文件位置就是language.txt。
目录查询的方式
线性搜索:当我们在搜索框输入我们想要查询的文件的路径时,系统会在咱们这个文件所属的目录下,根据文件名逐项比对每一个目录项的文件名,匹配成功,则查询完成,进行下一步操作。查询的时间复杂度是O(n)。(文件数量为n)
哈希表:将文件名根据哈希计算放入一个确定的位置,或者这个确定的位置的周围(如果这个计算出的确定位置被占据的话),那我们查询文件的时候,就会定位到这个文件实际存储位置的周围,然后线性搜索,匹配文件名,查询成功,缩短查询消耗的时间,查询的时间复杂度接近O(1)。
inode
inode的出现是专门为了解决一个问题的!
一个目录项中的列从宏观上分为三列:文件名,文件详细信息,文件的物理地址。
图8 传统目录项
查询文件的时候,咱们需要先绝对路径中涉及的每一个目录都从外存加载到内存,再在当前目录查询下一级的文件。而将数据从外存导入内存是非常耗时的。
图9 传统目录项下,匹配文件名流程
而文件的查询只需要文件名的匹配。
因此,传统目录项的问题就是,你把很多个很长的目录项导入内存,结果你要使用的只有短短的文件名,其它的列导进来与否根本不影响。而由于一个磁盘块的空间是有限的,在传统目录项的长度下,假设一个磁盘块只能存五个目录项,一个目录有十个目录项。那咱们匹配一个目录还得导两次磁盘块。如果咱们优化一下,把目录项的列变成两个短短的列:文件名,文件剩余信息存储的物理地址。这样一个目录项的长度大大缩短,那么一个磁盘块也能从只能存五个目录项变成能存十个目录项了。那咱们IO的次数就会大大缩短,从查询的流程上,就能减少很多时间,从而优化查询时间。
而inode,就是存储文件剩余信息的那一整个位置的总称。实际上,有了inode,传统目录项就会被优化为只有两个列的目录项:文件名,inode指针。
图10 inode目录项下,匹配文件名流程
inode本质就是保存文件的信息的,当你点击某个文件的属性,看到了很多文件信息,这个时候inode就被加载到了内存,才能被你看到inode中存储的信息。当inode被加载到内存后,它会在外存inode的基础上多添加几列数据,比如打开计数器,记录有多少进程打开了这个文件,等等,不过这些数据只在内存中存有,当咱们关闭文件,把内存inode存入外存并销毁后,外存inode是不会有这几列数据的。这很好理解,毕竟有多少进程打开了这个文件就是当前文件的一个信息,不记录到inode中,记录到哪呢?这就是内存inode与外存inode的区别。
文件打开表
文件打开表分为系统文件打开表和进程打开文件表。一个系统只有一个系统文件打开表。
在路径解析全过程中,我会稍微解释一下咱们为啥需要一份文件打开表。在这里就简单讲述一下使用过程。
进程打开文件表中的表项有三个列:索引号,读写指针和访问权限。在这里我们先只关心索引号这一列。一个进程对应一个进程打开文件表。
上文提到,当用户提出要查询某个文件,咱们会根据路径进行匹配,匹配成功后进入下一步操作。这个下一步操作中,不可绕过的就是接下来咱们要说的这一步,打开文件。
用户决定对文件进行操作,是指对文件数据进行操作。所以它需要定位文件的物理位置,取出文件数据。于是系统会先打开文件:将该文件对应的目录项导入系统文件打开表,并返回给用户一个索引号。一个进程可能会打开很多文件,当它需要对某个文件进行操作的时候,就根据表项中的索引号去系统打开文件表中匹配,这样就可以拿到系统打开文件表中的inode,进而定位物理地址,能够对该文件里头的具体数据进行操作了。
图11 打开文件表
因此,有了打开文件表,进程在查询文件的时候,不是靠文件名匹配,而是靠索引号匹配。这很好理解,毕竟一个系统中有很多重名文件,如果咱们在整个系统唯一的系统文件表中用文件名标记文件,又用文件名搜索文件,就会造成搜索一个文件跳出多个文件的情况了。
路径解析全过程
常用的文件系统是树形结构,因此,当我们想要打开一个文件的时候,需要以路径的形式打开。
用户输入路径
C:\Users\yufeng\language.txt目录被设计出来的作用,就是为了根据文件名找到文件的信息,尤其是这个文件在外存的存放位置。因此,这一步,OS在目录中是这样操作的。
C卷是一个逻辑磁盘,在最初的定义中,一个物理磁盘会被分为多个卷,我们姑且分为C、D、E三个卷。
图12 逻辑磁盘
2. 在C目录中找到`Users` 文件对应的目录项,拿到`Users` 的 `inode` 指针。比如这个`inode` 指针为2,意思就是他是`inodes`这个数组的第二个元素,接着我们去`inodes`这个数组所在的磁盘中拿到`inode` ,再从这个`inode`中读取`users`目录的物理地址,就读出了`Users`目录。
接着,在
Users目录中以同样的方法,拿到yufeng目录,再从yufeng目录中,拿到language.txt文件。
图13 获取文件物理地址全过程
由于
language.txt是第一次被打开,所以它的文件属性会被存储到文件打开表中,文件打开表会返回索引号给用户。为什么要设置一个文件打开表?注意,此处需要区分两个概念:“打开文件”(系统调用
open) 和 “读写文件”(系统调用read/write)。open的职责:简单说,就是定位文件,找到用户所需文件在磁盘中的位置。read/write的职责:将磁盘中的文件内容读到内存中进行操作,比如显示在屏幕中,或者修改文件内容然后写回磁盘。
假设同一个文件
language.txt被进程A和进程B先后打开,且进程A尚未关闭该文件。如果没有系统文件打开表(或表项不共享):
进程A对文件进行读操作时,为了获得文件的物理地址,又需要重新走一趟图14的流程。假设进程A要执行100次读操作,那系统就要执行图14的步骤100次,也就是通过文件名匹配目录项,从磁盘中将目录项和文件信息(inode)导入内存,才能获得物理地址。
进程B同上。
图14 没有文件打开表,获取文件物理地址过程
2. 有了系统文件打开表:
1. 进程A对系统文件进行读操作时,就不需要重复图14的方式通过磁盘IO得到物理地址,而可以直接在内存中通过索引号得到物理地址。
2. 进程B在想要打开该文件时,系统会发现,诶,进程B想要获得的inode之前已经被打开过了诶,就在系统文件表里!于是进程B在打开文件时,也不用通过磁盘IO获取文件物理地址,而可以直接在系统文件表中获取索引号,并将该文件的inode中的“打开计数器”+1,表示我这个进程也要打开你这个文件。如图15。
图15 有了文件打开表,获取文件物理地址过程
有了这张表,图14中昂贵的磁盘查找和I/O操作只需要执行1次(第一次打开时)。后续的99次重复“打开”操作,都不再需要完整路径解析,而是直接走图15中的极速内存查找通道——只需要输入索引号,在系统文件打开表的内存数组里一查,物理地址就拿到了。这就完美节省了99次目录查找时间和99次读取目录项/`inode`的磁盘I/O时间。
用户对
language.txt进行读写操作。用户进行读操作。首先,通过进程文件打开表的读写指针,它本质就是该文件起始地址之后的偏移量。我们就可以定位确切的物理地址。接着,将我们要读的部分从磁盘中读入内存,用户就可以看到要读的内容啦。
用户进行写操作。首先,通过进程文件打开表的读写指针,定位了咱们用户写入的具体位置,接着,往磁盘的这个具体位置将用户写入的数据加上后面的原数据全部直接覆盖写到磁盘里面。(实际经过缓存,最终持久化)
用户真正的关闭文件。这个时候,系统文件打开表中的打开计数器会减1。当打开计数器=0时,该文件对应的系统文件打开表的表项会被删除。