文件
文件系统(file system, FS)是操作系统的必要组成部分,它的作用是实现文件这一概念。文件的作用是储存信息,我们可以将其视为在二级储存中的一批数据。所谓二级储存是指那些除寄存器和内存外的存储设备,我们所熟知的机械硬盘,USB, SSD都是二级储存。操作系统通过封装将这一批批数据抽象为文件。不同的类型的文件,结构不一样。
所有文件都有它自己的属性,比如说文件的名字、大小、还有创建日期等等。我们把这些文件的信息称之为元数据(metadata)。一个文件的文件头(file header),也有人称之为信头(header),存放的是那些只有文件系统才会用到的信息,它们只对文件系统可见。比如说:每个文件都有自己的一个魔术数字(magic number),这和编程语言里面的魔术数字不一样。在编程语言中,它指的是那些难以理解,凭空出现的数值;而在文件中,它指的是一串用于表示文件类型的字符。^1 另一个例子是文件的拓展名。在很多操作系统中,它都是可以显示出来被用户看到的,如test.txt中,txt就是文件的后缀名。
文件路径
文件路径(file direcotry),或者路径,更广泛的叫法是文件夹(folder)。它是一种比较特殊的文件, 因为它的作用是记录其它文件,它包含了其它文件信息给用户。
树状结构目录(Tree structured directory)
树状结构目录是一种文件管理的方式。从一个根路径开始,它的每个节点要么是0,要么是其它文件。除了根路径外的其它所有路径都有且仅有一个父文件路径。文件系统会给每个路径在创建的时候分配一个唯一的ID。这个ID被文件系统用来管理文件,并且对用户是不可见的。
文件的绝对路径名(absolute path name)指的是文件从根路径开始一直到文件的唯一名称。如上图的文件 file.txt。 其文件路径为/user/foo/file.txt。除了文件的绝对文件名外,文件的相对路径名(relative path name)是一个以目前路径为基础的路径名。比如说如果当前路径为/user/foo/, 那么文件file.txt的相对路径名就是file.txt。为了使路径索引方便,文件系统按照惯例都会提供一个为”..”文件来代表上一级路径,用”.”文件来代表当前路径。如果在终端里使用命令 ls -a,你会看到当前路径下的文件,其中就包括了“.” 和“..”这两个文件。

定向非循环路径结构
树状结构有个缺点,用户要层层往下走才能找到自己需要的文件。定向非循环路径结构(directed acyclic directory hierarchy)是一种比树状结构稍微复杂的文件结构。在这样的结构中,文件或者路径可以向下连接多个其他文件。每个文件都有一个引用数(reference count),表示该文件被多少个上层路径指向。
上图中f2的引用数是3,因为同时有3个上级路径可以指向它。如果允许一个文件向上连接,循环可能会出现, 这样一来,在搜索文件的时候就会进入死循环。
上图就是一个死循环的例子。为了避免这样的死循环,我们可以规定一个节点只有一个父节点,同时允许一种连接符号(symbolic links)来创建捷径。进行类似搜索文件这样的操作的时候我们走正常的继承路线,而连接符号则允许我们更加方便的来回穿梭于文件之中。
上图绿色的剪头是捷径。其实我们熟知的windows操作系统就有链接的例子:桌面上的快捷方式。
路径的实现
除了文件名外,操作系统还需要掌握文件的其他属性。大小、类型、所在的内存地址、权限信息、创建日期、更改日期等,都是文件系统需要保持更新的。
像进程控制块一样,文件也有一个文件控制块(File Control Blocks, FCB)。这是一种记录文件内容的数据结构。不同的操作系统有不同得存法,有的操作系统会将上面的所有信息存在文件头, 仅保留部分信息在FCB;另一些系统为了追求极高得读写速度,将所有信息都保留在了FCB中。
路径的内部结构
文件系统要能够很快的搜索文件名,很快的回收垃圾(关闭不需要的文件),还要能快速的索引到能够创建文件的最佳内存位置。
最简单的方法就是在每个路径下用一个数组来装住该路径的条目。每个元素都有一个位来表示文件的使用情况,一个指针指向对应的FCB,以及一个文件名。对文件的搜索、更新、以及回收和一般对数组的操作是一样的。
上面的方法有一个缺点:如果文件的名字超出了数组元素规定的大小,文件系统就无法将其放置到数组中。所以人们又引入了动态元素大小的概念。每个元素都有一个记录文元素大小的位,在遍历数组的时候,可以通过这个元素来确定下一个元素的位置。
文件的操作
对文件的操作可以总结为创建、读、写、定位、删除和截断^2。前面的死种操作都是人们常见的基础操作,不论你是学习算法还是数据库都会学习到这方面的知识。截断对大家来说比较陌生。
创建和删除文件
创建文件的过程:创建一个新的文件,文件系统首先会新建一个文件控制块,然后找到一个空路径,将文件名填上,并将文件的指针指向这个文件控制块。
删除文件的过程:删除一个文件,文件系统的步骤和创建文件相反,先根据FBC提供的信息将文件占用的磁盘空间清空,然后将FBC删除,最后将该文件从路径中释放。
打开文件
为了提高读写速度,操作系统会记录一份打开文件表(poen file table, OFT)。当需要对某个文件进行读写的时候,系统可以通过这个表格索引到指定的文件。通常来说,操作系统维护两个不同的文件表格,分别是单个进程的表和整个系统的表^2。单个进程的表记录着一个进程所打开的所有文件,而系统的表则记录所有进程打开了的文件。在操作系统中,为了方便调用,在对文件进行操作的时候需要用一个文件描述符(file descriptor)来引用对应的文件。默认的情况下,打开文件表的前三个文件0,1,2分别为标准输入,输出和错误。一个用户或者进程在访问文件的时候,文件系统会首先查看它们的权限,如果可行便将文件添加到表中。这样一来,每次在需要使用文件的时候直接在这个表格里面找到文件的地址并读写就好了。
下面是操作系统打开一个文件的步骤
- 系统从当前路径下的条目中搜索对应的文件
- 顺着文件找到它对应的FCB
- 从FCB控制块中匹配权限信息,如果权限不足则失败
- 如果成功,从打开文件表中找到一个空的目录
- 文件系统将这个文件的必要信息(内容块的地址,文件的大小,文件的权限)加载到打开文件表中。
- 文件系统还将该FCB的指针复制了一份放到打开文件表中,以备搜索时用
例1:
Linux操作系统提供了一个open()系统调用来打开文件,这个方法会将上面的流程走一遍然后返回该文件的编号。这个调用接受两个参数,文件名和读取权限。打开该目录下一个叫list.txt的文件。
1 | #include <stdio.h> |
读写文件
读写文件都需要用到打开文件表和文件控制块。假设我们有一个文件,下面是它的控制块。其中,文件数据块里面包含了两个块。
文件在一开始创建的时候,打开文件表就拥有一部分缓存好了的数据了。缓存的大小不一定和block一致,这也是打开文件表里面有一个当前位置的建的原因。操作系统提供了读取文件的系统调用read() 它需要三个参数,分别是文件描述符, 缓冲的地址,读取的大小。我们假设上面的文件控制块所在地址为0xF8, 其对应的文件描述符为3。它在打开文件表中长这样:
下面代码展示了读取文件以及写入文件(标准输出)的操作。首先打开了路径下的test.txt()文件,然后一个一个字符串地输出。
1 | #include <unistd.h> |
磁盘块的定位
一个磁盘块(Disk block)是磁盘中的一个连续字节块。它只能被整取整存,不可分割。文件系统将磁盘看作是许许多多这样的块的一个集合。
邻近分配
在邻近分配法(contiguous block allocation scheme)中,所有的文件都被映射在邻近的磁盘块中,文件控制块的指针指向文件的第一个块,这个文件长度(文件所占磁盘块的数量)也被同时记录在了文件控制块中(见上面的文件控制块图)。这么做的好处是: 对文件的访问十分高速,因为同一文件的磁盘块都在相邻的位置,无需重新定位。这么做的坏处是:随着文件的打开和关闭,时间长了以后很难有适合新文件的连续磁盘块;文件的大小一旦固定,所在的磁盘块段前后如果被其他文件占用将无法再增加大小;而且没有一个好的方案确定到底给文件规划多少磁盘块。
链式分配
和邻近分配不一样,链式分配法(linked block allocation scheme)。FCB只会指向第一个磁盘块,后面的磁盘块会一个指向下一个直到最后一个磁盘块。这么做的好处是:动态地分配了磁盘空间。而坏处也很明显:访问速度比邻近分配慢,不能直接访问某个块;因为不是连续的;磁盘块的大小增加,因为需要储存下一个磁盘块的地址。
簇式分配
簇式分配法(clustered allocation)将邻近分配和链式分配两种方法结合起来用的方法。在读写的过程中,它会连续的占用磁盘块,如果接下来的磁盘块被占用了,它会找到新的空白块,并在最后一个磁盘块用一个指针指向这个空白块的地址,然后继续在新的空白块中读写。
优点
- 比邻近分配更高效地使用磁盘空间
- 比链式分配更少的占用磁盘空间
缺点:
- 访问速度慢
- 和链式分配法一样,无法直接定位磁盘块。
文件配置表
链式分配法的一个变种是文件配置表。这个表的每个键都对应磁盘块的位置,并且每个键的值都要么指向另一个键,要么为空。通过这个方法,每个磁盘块就不需要增加额外的信息来实现指针了。如下图,文件的数据块指针指向文件配置表对应的文件开头的位置,然后再由文件开头的位置用指针指向后面的块,依次将整个文件记录。