笔记:现代操作系统:原理与实现(8)
第九章 文件系统
文件是操作系统在进行存储时使用最多的抽象之一。每个文件实质上是一个有名字的字符序列。序列的内容称为文件数据(file data)。而序列长度、序列的修改时间等描述文件数据的属性、支撑文件功能的其他信息称为文件元数据(file metadata)。
操作系统将这些存储设备抽象为块设备(block device),以方便文件系统使用统一的接口访问。块设备上的存储空间在逻辑上被划分成固定大小的存储块(block)。块,是块设备读写的最小单元,大小一般为 512 字节或 4 KB。每个存储块均有一个地址,称为块号。文件系统在请求中指定块号,操作系统负责对块设备中的指定块进行数据写入和读取。
Linux 内核会调用其**虚拟文件系统(Virtual File System, VFS)处理文件请求。虚拟文件系统如同一个大管家,负责管理具体的文件系统,并提供一系列服务,如页缓存(page cache)、inode 缓存(icache)和目录项缓存(dcache)**等。
存储栈:

访问文件系统的过程:
- Linux 虚拟文件系统在解析用户请求后,调用具体的文件系统的接口进一步处理请求
- 具体的文件系统(比如 Ext4 文件系统)会根据请求对数据进行读取或修改
- 当需要访问存储设备上的数据时,文件系统会创建对存储块的访问请求,并发送给 I/O 调度器
- 由于可能同时存在多个对存储设备的访问请求,I/O 调度器根据预定策略对这些请求进行调度,以一定的顺序将请求发送给设备驱动
- 最终,设备驱动与存储设备交互并完成请求。在存储设备完成写入请求后,下载的文件便被持久保存于存储设备之中
基于inode的文件系统
本节将介绍基于 inode 的文件系统设计。为了叙述的简洁性,我们在本节中介绍的所有存储结构均直接保存在块存储设备之上(即暂时不考虑内存缓存等情况)。
inode与文件
inode 就是用来记录磁盘块的一种结构。inode 是 “index node” 的简写,即 “索引节点”,记录了一个文件所对应的所有存储块(即存储的索引)。
inode 的结构该如何设计呢?一种简单的方法是将文件所有的存储块号都按顺序保存在 inode 中。然而,这种方法会导致 inode 大小随文件大小的变化而变化。因此,在 inode 中采用分级的方式组织存储块。inode 中保存了三种存储指针(即存储设备的块号):
- 第一种指针为直接指针,其直接指向数据块,数据块中保存了文件数据
- 第二种指针为间接指针,指向一个一级索引块,一级索引块中存放着数据块的指针
- 第三种指针为二级间接指针,指向一个二级索引块,二级索引块中的每个指针均指向一个一级索引块,进而指向多个数据块
为了方便后面的讨论,我们之后均假设块大小为 4 KB,每个指针占据 8 个字节,每个 inode 中直接指针有 12 个,间接指针有 3 个,二级间接指针有 1 个。
常规文件数据索引:

在我们的假设场景下,一个 inode 所能管理的最大文件大小为 48 KB(12个直接指针) + 6 MB(3个间接指针存储着512×3个直接指针) + 1 GB(1个二级间接指针存储着512×512个直接指针)。
从上述组织方式中可以看出,一个文件系统所支持的最大文件大小受 inode 数据组织方式的限制。因此,我们可以通过调整 inode 的设计来改变其所能管理的最大文件大小。例如,为了管理更大的文件,inode 中的索引可以使用更多的二级间接指针,甚至启用三级或者四级间接指针。
inode 中除了记录存储的索引外,还记录了该文件相关的其他元数据,包括文件模式、文件链接数、文件拥有者和用户组、文件大小,以及文件访问时间等。
| 文件元数据 | 说明 |
|---|---|
| mode | 文件模式,其中包括文件类型和文件权限 |
| nlink | 指向此文件的链接个数 |
| uid | 文件所属用户的 ID |
| gid | 文件所属用户组的 ID |
| size | 文件的大小 |
| atime | 文件数据最近访问时间 |
| ctime | 文件元数据最近修改时间 |
| mtime | 文件数据最近修改时间 |
一个文件系统一般会支持多种文件类型,例如常规文件(regular file)、目录文件(directory file)和符号链接文件(symbolic link file)。不同类型的文件保存了不同种类的数据,在数据存储格式和使用方法上也会有所区别。
| 文件类型 | 文件用途 |
|---|---|
| 常规文件 | 保存数据 |
| 目录文件 | 表示和组织一组文件 |
| 符号链接文件 | 保存符号链接(指向目标文件的路径) |
| FIFO 文件 | 以队列形式传递数据,又称命名管道 |
| 套接字文件 | 用于传输数据,比 FIFO 文件更加灵活 |
| 字符设备文件 | 表示和访问字符设备 |
| 块设备文件 | 表示和访问块设备 |
文件名与目录
对于计算机程序来说,通过 inode 号(即 inode 结构的编号)就可以找到对应的文件。但是缺点如下
- 对于用户来说,使用 inode 号作为文件名进行记忆比较困难。
- 使用 inode 号直接表示文件还会造成 inode 号与文件存储位置的强耦合
- 如果 inode 号与文件 inode 结构的存储位置一一对应,文件系统就无法在不改变 inode 号的情况下更改文件 inode 的存储位置,也无法用一个新的 inode 号指代一个已有的 inode 结构
为了对用户更加友好,文件系统加入了我们熟悉的字符串形式的文件名,从而增加了一层从文件名字串到 inode 号之间的映射,即目录。文件名不存放在inode元数据中。
目录是一种特殊类型的文件,记录了从文件名到 inode 号的映射。由于目录本身也是文件,因此可以通过递归,来结构化地组织文件系统中的文件。与常规文件中保存的用户数据不同,目录中保存的是一种特殊的结构——目录项。每个目录项代表一条文件信息,记录了文件的文件名及其对应的 inode 号。
目录文件中的内容:目录项

目录项内的结构,其包括:
- 文件名
- 文件名长度
- 记录着保存的文件名的有效长度6
- 文件名对应的 inode 号
- 用于找到文件名对应的 inode 结构,从而访问文件数据和元数据
- 目录项长度
- 用于记录整个目录项的长度,主要是为了目录项的删除和重用而设计的
以此格式,目录项一个接一个地存放在文件的数据块中,组成一个连续的字符序列,从而可以直接复用常规文件的数据组织方式。
对目录的操作主要包括查找、遍历、增加和删除目录项。
- 查找:在目录中查找某个文件时,文件系统从目录文件中存放的第一个目录项开始,依次比较目录项中存放的文件名。当目录项中的文件名与要查找的文件名相同时,文件系统根据该目录项保存的 inode 号找到文件的 inode,从而在 inode 上进行各种操作。
- 遍历:目录的遍历操作与查找操作类似,文件系统依次检查目录文件中保存的所有有效(即还未被删除的)目录项,并通过回调函数等方式返回结果。
- 增加:当一个新的文件被创建时,文件系统会在其父目录中增加一个新的目录项,记录所创建文件的文件名和 inode 号。为了节省存储空间,如果目录文件中有无效(即已被删除的)目录项,且该目录项空间足够,则文件系统可以重用此目录项的位置存放新的目录项。如果没有合适的无效目录项,则新的目录项会以追加的方式记录在目录文件末尾。
- 删除:当一个文件被删除时,文件系统会从其父目录中删除对应的目录项。删除操作通过将目标目录项中的 inode 号变为 0 来标记这个目录项是无效的。这种方法无须为目录有效性信息预留额外的存储空间,可以更加高效地利用空间。同时,在进行文件删除时,可以将相邻的无效目录项进行合并,以允许更长的新目录项重新利用这些空间。
硬链接与符号链接
硬链接
由于文件名并不是文件的元数据,因此一个文件可以对应多个文件名,即多个目录项可以指向同一个文件。在 Linux 中,可通过 “ln file link” 为文件 file 创建另一个名字 link。这里的 link 就是 file 的硬链接(hard link)。当用户创建一个新的硬链接时,文件系统并不会创建一个新的 inode,而是先找到目标文件的 inode,随后在目标路径的父目录中增加一个指向此 inode 的新目录项.
一个硬链接一旦创建成功,其与原来的文件是同等地位的。对其中任意一个硬链接的读写操作或者元数据修改,都会影响到指向同一 inode 的其他硬链接。对于删除操作,只有当所有指向这个 inode 的全部硬链接都被删除时,这个 inode 及其数据才会被删除。
考虑到硬链接的存在,一个 inode 可能会被多个目录项所指向。为了准确地记录何时能够将 inode 及其索引的数据从文件系统中删除,文件系统在 inode 中记录了一个链接数(元数据中的 nlink),表示有多少个目录项指向了该 inode。
小知识
在实现文件系统时,目录文件的链接数需要格外注意。由于目录文件中保存了“.”和“…”两个目录项,当一个新的目录文件被创建时,其本身的链接数为 2,同时其父目录的链接数增加 1。
符号链接
符号链接(symbolic link)又称为软链接(soft link),是一种特殊的文件类型。在 Linux 中,可以通过 “ln -s file slink” 为 file 创建名为 slink 的软链接。与常规文件保存数据、目录文件保存目录项不同,符号链接文件中保存的是一个字符串,表示一个文件路径。在后面的介绍中,我们称这个文件路径所对应的文件为目标文件。由于路径的长度一般不会过长,符号链接文件的一个简单实现是将路径字符串直接保存在 inode 中,占据原本用于保存数据块指针的空间。
除了创建、删除和更新其元数据之外,符号链接文件本身只支持读取操作。
- 读取操作的实现也很简单:只需要找到符号链接文件的 inode,并返回其中保存的路径即可
- 修改一个符号链接文件中的内容,一般需要先删除原文件,再使用新的路径创建一个名字相同的符号链接文件
符号链接文件本身的操作和实现并不复杂,但是其对路径解析的过程会产生较大影响。
- 在不考虑符号链接的情况下,从一个路径得到其所代表的文件非常简单与直接:只需在每一层目录中,查找路径里的下一个文件,直到整个路径都被解析完毕,最终得到的文件便是目标文件
- 然而在支持符号链接的情况下,每解析完路径中的一部分,文件系统都需要判断当前得到的文件是否为符号链接。如果是符号链接,要先跟随符号链接中的路径找到目标文件,再继续解析原路径的剩余部分
符号链接与硬链接的比较
符号链接与硬链接均允许应用程序使用一个新的路径访问已有文件(目标文件),但两者的原理有所不同。
- 访问方式
- 当应用程序访问一个以目标文件路径为内容的符号链接时,文件系统读取符号链接中保存的路径,并继续进行解析,最终找到目标文件
- 而当应用程序访问一个指向目标文件的硬链接时,其直接通过硬链接的目录项访问到目标文件的 inode
- 对链接的操作是否会对文件本身产生影线
- 由于符号链接有自己独立的 inode 结构,其有自己的权限、时间等元数据,且删除符号链接本身,不会影响其目标文件。
- 硬链接与目标文件共享同一个 inode 结构,两者是等价的,并没有主次之分;删除其中的任意一个,应用程序依然可以通过未删除的另一个路径对文件数据进行访问
- 对目标文件的要求
- 在一个符号链接中,用户可以随意存放目标路径,即使这个目标路径不存在。只有在跟随符号链接进行路径解析时,符号链接中的路径才会真正被使用
- 而对于硬链接,其目标路径在创建时即被用于查找 inode,因此用户无法成功地创建一个指向不存在的文件的硬链接;硬链接还要求目标文件不能为目录
- 是否受到文件系统格式的制约
- 号链接不受文件系统边界的限制,即在一个文件系统中,可以创建一个指向其他文件系统的符号链接
- 硬链接的目标文件只能与被链接的目标文件处于同一个文件系统中
存储布局
下图展示了一个简单的文件系统存储布局,其中一个存储设备被划分成超级块、块分配信息、inode 分配信息、inode 表和文件数据块等区域。
当在一个存储设备上创建一个新的文件系统(即格式化操作)时,文件系统格式化工具会根据文件系统的存储布局和存储设备的容量,计算每个区域的大小,并初始化区域中的元数据。
文件系统的存储布局:

超级块:
超级块中记录了整个文件系统的全局元数据。魔法数字(magic number)是保存在超级块中的比较重要的元数据之一。不同的文件系统通常会使用不同的魔法数字。通过读取魔法数字,操作系统可以得知存储设备上文件系统的类型和存储布局。
除了魔法数字之外,超级块中还保存了文件系统的版本、文件系统所管理空间的大小、最后一次挂载时间和一些统计信息等。统计信息中包括文件系统能支持的最大 inode 数量、当前空闲可用的 inode 数量、能支持的最大的块数量、当前空闲可用的块数量等。
块分配信息与 inode 分配信息:
块分配信息使用位图(bitmap)的格式标记文件数据块区域中各个块的使用情况。块分配信息区域中的每个比特位,对应文件数据块区域中的一个块。若此比特位为 1,则表示对应的数据块已经被分配和使用;若为 0,则表示对应的数据块空闲。inode 分配信息与块分配信息类似,区别在于其对应的是 inode 表中每个 inode 的使用情况。
inode 表:
inode 表以数组的形式保存了整个文件系统中所有的 inode 结构。文件系统通常使用 inode 在此表中的索引(称为 inode 号)对不同 inode 进行区分。因此,文件系统中对 inode 的引用只需要使用 inode 号即可,无须保存 inode 结构在存储设备中的偏移量。此外,由于 inode 表的大小在文件系统创建时已经固定,因此文件系统所能保存的最大文件数量也受此限制。
文件数据块区域:
用于保存文件的数据。理论上来说,只有文件数据区域被用来存放应用程序的数据。因此在一个存储设备上创建一个新的文件系统后,文件系统显示的可用大小往往比存储设备的总容量小。
虚拟文件系统
在操作系统中,虚拟文件系统(Virtual File System, VFS)对多种文件系统进行管理和协调,允许它们在一个操作系统上共同工作。不同文件系统在存储设备上使用不同的数据结构和方法保存文件。
为了让这些文件系统工作在同一个操作系统之上,VFS 定义了一系列内存数据结构,并要求底层的不同文件系统提供指定的方法,然后利用这些方法将(存储设备上的)不同文件系统的元数据统一转换为 VFS 的内存数据结构;VFS 通过这些内存数据结构,向上为应用程序提供统一的文件系统服务。在本节中,我们将以 Linux 中的 VFS 为例,介绍 VFS 如何管理和调用多种文件系统,以及 VFS 如何为应用程序提供一个统一的文件系统抽象。
面向文件系统的接口
VFS定义的内存数据结构
Linux 的 VFS 基于 inode 制定了一系列的内存数据结构,包括超级块、inode、目录项等。这些统一的内存数据结构掩盖了多种文件系统在设计和实现上的不同,使以不同格式保存在存储设备中的数据以统一的格式出现在内存中。
- VFS 中的超级块
- VFS 定义了自己的内存超级块结构,其中保存了文件系统的通用元数据信息,如文件系统的类型、版本、挂载点信息等。每个挂载的文件系统实例均在内存中维护了一个 VFS 超级块结构。VFS 通过这些超级块结构中的通用元数据信息对多个文件系统实例进行管理。除了这些通用元数据信息之外,VFS 的超级块结构中还预留了一个指针。文件系统可以将该指针指向其特有的超级块信息。这种设计既达到了统一数据结构的目的,又保留了不同文件系统的特有信息,增加了 VFS 下文件系统的灵活性。
- VFS 中的 inode
- Linux 在 VFS 中同样定义了内存 inode 结构,用于保存基本的文件元数据。若文件系统想要在内存 inode 结构中记录额外信息,其需要在为 VFS 的 inode 结构分配空间时多分配一些空间,之后通过计算偏移量的方式将额外信息保存在 VFS 的 inode 结构之外。为了快速定位和使用 inode,VFS 维护了一个 inode 缓存(icache)。VFS 的 inode 缓存使用哈希表保存了操作系统中所有的 inode 结构。当应用程序或者文件系统需要使用某个 inode,且此 inode 保存在 inode 缓存中时,可以直接从缓存中访问该 inode,从而避免了一次耗时的存储设备访问。
- VFS 中的文件数据管理
- 每个 VFS 的 inode 会使用基数树(radix tree)表示一个文件的数据。基数树中的每个叶子节点为一个内存页,保存了文件数据的一部分。这个基数树为这个文件的页缓存建立了索引。
- VFS 中的目录项
- VFS 中的目录项是在内存中保存文件名和目标 inode 号的结构。正如 inode 缓存一样,VFS 在内存中为目录项维护了一个缓存,称为目录项缓存(dcache)。
VFS 定义的文件系统方法
下图展示了一个应用程序通过系统调用访问文件系统的例子:
- 应用程序向操作系统内核发出创建文件的系统调用请求,操作系统内核调用 VFS(虚拟文件系统)处理该请求
- VFS 根据系统调用的内容和上下文,发现对应的文件系统为 Ext4 文件系统
- 因此调用 Ext4 文件系统的“读取文件”方法,并最终将请求的结果返回给应用程序
虚拟文件系统将应用程序与文件系统的操作进行连接:

每个 Linux 中的文件系统均需要将其方法以函数指针的方式提供给 VFS。
这些信息保存在如代码片段 9-1 所示的结构中,由 VFS 进行组织和管理。在处理文件系统的挂载请求时,此结构中的 mount 函数便会被调用,文件系统可以进行相应的挂载操作。
// 代码片段 9-1 Linux VFS 中的文件系统数据结构
struct file_system_type {const char *name;...struct dentry *(*mount) (struct file_system_type *,int, const char *, void *);void (*kill_sb) (struct super_block *);struct module *owner;struct file_system_type *next;struct hlist_head fs_supers;...
};
Linux 的 VFS 为其内存数据结构分别定义了不同的文件系统方法。以文件结构为例,代码片段 9-2 中列出了 Linux 的 VFS 为文件结构定义的部分方法,主要包括文件的打开(open)、读(read 和 read_iter)、写(write 和 write_iter)、定位(llseek)、内存映射(mmap)、同步写回(fsync)等操作。
// 代码片段 9-2 Linux VFS 为文件结构定义的方法
struct file_operations {struct module *owner;...loff_t (*llseek) (struct file *, loff_t, int);ssize_t (*read) (struct file *, char __user *,size_t, loff_t *);ssize_t (*write) (struct file *,const char __user *,size_t, loff_t *);ssize_t (*read_iter) (struct kiocb *,struct iov_iter *);ssize_t (*write_iter) (struct kiocb *,struct iov_iter *);long (*unlocked_ioctl) (struct file *, unsigned int,unsigned long);int (*mmap) (struct file *,struct vm_area_struct *);int (*open) (struct inode *, struct file *);int (*fsync) (struct file *, loff_t, loff_t,int datasync);...
};
小知识
代码片段 9-2 中的 unlocked_ioctl 对应于 ioctl 接口,原本用于控制字符设备、网络设备和 IPC。由于 POSIX 未定义 ioctl 在其他类型文件上的行为,许多文件系统通过此接口拓展文件上的操作,为应用程序提供更多的功能。例如,一个文件系统可以通过实现 ioctl 接口来允许应用程序设置文件的冷热程度(hotness),以便文件系统根据数据的冷热程度使用不同的存储方法,提高存储效率。又如,为 Linux 提供硬件虚拟化支持的 KVM 模块,也是通过实现 ioctl 接口来提供虚拟化支持的。
面向应用程序的接口
一个简单的文件访问程序
代码片段 9-3 展示了一个简单的、使用文件的程序。其使用的接口原型在代码片段 9-4 和代码片段 9-5 中给出。我们首先介绍这个程序所进行的操作,之后再对这些操作背后的机制进行更加详细的介绍。
//代码片段 9-3 使用文件接口读写文件
#include <fcntl.h>
#include <stdio.h>
#include <unistd.h>#define DATA_SIZE 20int main()
{int fd;char data[DATA_SIZE + 1];// 打开文件// fd为打开文件后返回的文件描述符号fd = open("/home/chcore/filesystem.tex",O_RDWR | O_CREAT, S_IRUSR | S_IWUSR);// 读取文件中的前 20 个字节read(fd, data, DATA_SIZE);// 打印文件中的前 20 个字符data[DATA_SIZE] = '\0';printf("file data: %s\n", data);// 向文件中写入 6 个字节的数据write(fd, "hello\n", 6);// 关闭文件close(fd);return 0;
}
//代码片段 9-4 POSIX 中文件操作的相关接口// 打开文件
int open(const char *path, int oflag, ...);
int openat(int fd, const char *path, int cflag, ...);// 获取文件属性
int fstat(int filedes, struct stat *buf);
int fstatat(int fd, const char *restrict path,struct stat *restrict buf, int flag);
int lstat(const char *restrict path,struct stat *restrict buf);
int stat(const char *restrict path,struct stat *restrict buf);// 关闭文件
int close(int filedes);
//代码片段 9-5 POSIX 中常规文件相关的部分接口// 读写文件
ssize_t pread(int filedes, void *buf,size_t nbyte, off_t offset);
ssize_t read(int filedes, void *buf, size_t nbyte);
ssize_t pwrite(int filedes, const void *buf,size_t nbyte, off_t offset);
ssize_t write(int filedes, const void *buf,size_t nbyte);// 调整读写位置
off_t lseek(int filedes, off_t offset, int whence);
路径解析
在程序执行的最开始,其首先使用 open 将文件路径转换成了一个文件描述符,此后使用该文件描述符进行文件上的其他操作。
路径解析过程:
- 在进行解析时,应用程序提供的路径被拆分成多个部分。每部分为一个文件名。路径 “/home/chcore/filesystem.tex” 被拆分成 “home”“chcore” 和 “filesystem.tex”
- 由于路径是以 “/” 开头的绝对路径,路径查找从整个操作系统的根目录开始
- VFS 首先在根目录中查找名为 “home” 的文件。在此过程中,VFS 首先在其维护的各种缓存中进行查找
- 如果缓存中不存在此文件,则调用具体的文件系统接口进行查找
- 若文件系统中也无法找到该文件,则打开失败,返回路径不存在的错误消息给应用程序
- 当 “home” 文件被找到之后,VFS 还会进行一系列检查。如检查当前应用程序是否有访问该文件的权限、该文件是否为一个目录文件等
- 若该文件是一个符号链接,VFS 还需要先解析该符号链接,再继续后面的路径查找工作
- 若该文件是一个挂载点,则还需更新当前路径的文件系统信息等
- chore过程与此类似
由于应用程序在 open 中指定了 O_CREAT 标记,如果路径的最后一部分,即 “filesystem.tex” 文件不存在,则 VFS 会首先创建一个空文件,再继续路径查找操作。
在路径解析完毕后,VFS 得到了 “filesystem.tex” 文件所对应的 inode。然而 VFS 并不直接将 inode 返回给应用程序,而是在其中增加了一层抽象,即文件描述符。
文件描述符
VFS 为操作系统中的每个进程维护一个文件描述符表,该表以文件描述符为索引,保存了一组文件描述结构。每个文件描述结构中记录了一个被打开文件的各种信息,如目标 inode(即被打开文件的 inode)、文件当前的读写位置和文件打开的模式等。
当 VFS 通过路径解析找到要打开的文件的 inode 后,VFS 会分配一个文件描述符和对应的文件描述结构并进行初始化。VFS 将 inode 信息记录在文件描述结构中,将文件读写位置设置为 0,并将文件打开的模式也记录在文件描述结构中。最后,VFS 将文件描述符返回给应用程序,表示文件已成功打开。
当应用程序使用文件描述符调用文件操作(如 read 或 write)时,VFS 使用文件描述符在该进程的文件描述符表中找到对应的文件描述结构。通过文件描述结构中保存的信息,VFS 可以继续执行应用程序所请求的操作。
文件描述符是应用程序和操作系统之间建立起的一个认证关系。应用程序首先使用文件路径换取文件描述符,此后使用文件描述符对该文件进行其他操作。文件描述符使得 VFS 无需每次都进行路径解析和各种检查操作,在一定程度上提高了效率。同时,通过维护文件描述符表,操作系统可以监视和控制每个进程正打开的文件,对资源使用情况进行控制。
相对路径与当前工作目录
在没有指定工作目录时,VFS 会默认使用每个进程的当前工作目录。VFS 为每个进程维护了一个当前工作目录。应用程序可以通过 getcwd 和 chdir 获取和修改当前工作目录。
- 对于那些不以 “at” 结尾的函数来说,若使用相对路径进行操作,其会从当前工作目录开始进行路径解析
- 而对于以 “at” 结尾的函数来说,用户可以指定一个打开的目录的文件描述符 fd,让文件系统从这个 fd 指定的目录开始进行路径解析
文件的打开、统计和关闭接口
使用文件描述符通过 “打开—访问—关闭” 的过程进行文件访问是一种很标准的使用方法。但是如果只是想读取某个文件的大小,却要通过 open、fstat、close 这三个调用,会显得有些烦琐。因此,有一些函数对此进行了简化,允许应用程序直接使用路径读取文件的元数据信息,如代码片段 9-4 中的 lstat 和 stat 函数。这两个函数在目标文件为符号链接时会有所区别,lstat 会返回符号链接的信息,而 stat 则会继续跟随符号链接,返回其所保存的目标文件的信息。
文件读写接口
代码片段 9-5 中列出了常规文件读写相关的接口函数。其中我们能看到两类读写接口
- 一类是直接的 read 和 write
- 一类是以 “p” 开头的 pread 和 pwrite
使用这两类接口均需要提供文件描述符 fd、用于读写的缓冲区 buf,以及需要读写的字节数 nbyte。
不同点在于,使用以 “p” 开头的两个接口还需要额外提供一个读写偏移量 offset 以表示开始读取或写入的位置。
对于文件读请求(read)来说,文件系统将从其保存的文件数据中读取相应的数据,并拷贝到用户提供的缓冲区中。
而对于写请求(write)来说,用户需先将要写入文件的数据填充到缓冲区中,之后再调用写接口。文件系统会从用户提供的缓冲区中读出数据,并将其保存到对应的文件中。
对于同一个文件描述符来说,读写的偏移是共享的。比如,第一次调用read(10,buf,20)是将文件描述符前20个字符读取到buf缓冲区中,第二次调用read(10,buf,15)则会读取21-35个字符,第三次调用write(10,buf,24)则会将buf中的24个字节写入到以偏移量36开头的位置中。
如果只使用 read 和 write 接口,应用程序只能单向地向后访问数据,无法再次访问之前的数据。应用程序可以调用 lseek 接口来调整文件描述符结构中保存的文件读写位置。lseek 中的 whence 参数可以指定至少三种调整模式,分别为
- SEEK_SET
- 接设置读写位置为 offset 参数指定的位置
- SEEK_CUR
- offset 为相对于当前文件位置的偏移量,若 offset 为正数,则文件位置向文件尾部移动 offset 个字节,若为负数,则向文件头部移动 offset 个字节
- SEEK_END
- 从文件尾部开始继续向后移动 offset 个字节
在频繁地随机访问文件时,每次都 lseek 后再进行读写操作是比较耗时的。因此,POSIX 中也提供了以 “p” 开头的读写接口,允许应用程序在进行读写时直接指定开始位置。
对于写入操作来说,若写入的位置超出了文件当前的末尾,则文件的大小会增大。如果要写入的数据超出了文件的最后一个数据块,则文件系统会首先从存储设备中分配新的数据块,并将数据块记录在 inode 的文件索引结构中。此后文件系统便可以进行正常的数据拷贝操作。
目录操作接口
代码片段 9-6 中给出了目录的访问接口。其中 mkdir 和 rmdir 用于创建和删除指定的目录。文件系统在处理这两个请求时,与此前创建和删除常规文件类似。
// 代码片段 9-6 POSIX 中目录文件相关的部分接口
// 创建目录
int mkdir(const char *path, mode_t mode);
int mkdirat(int fd, const char *path, mode_t mode);// 删除目录
int rmdir(const char *path);// 打开目录
DIR *fdopendir(int fd);
DIR *opendir(const char *dirname);// 读取目录项
struct dirent *readdir(DIR *dirp);
int readdir_r(DIR *dirp,struct dirent *restrict entry,struct dirent **restrict result);// 重置目录文件读取位置
void rewinddir(DIR *dirp);// 关闭目录
int closedir(DIR *dirp);
在进行目录访问时,有两个新的数据类型。其中 DIR 表示一个目录流,用于表示一个打开的目录及其状态。另外一个 dirent 结构则对应于文件系统中的目录项。应用程序可以通过文件描述符或者路径打开一个目录流。
- readdir 或 readdir_r 接口从目录流中读取目录项的信息
- rewinddir 用于重置目录流为刚打开时的状态
- closedir 用于关闭目录流。
这些接口并非完全需要由操作系统内核提供。在基于 Linux 内核的操作系统中,Linux 内核提供了基于文件描述符的目录接口,并提供了 getdents 系统调用将目录项返回给 libc。基于 DIR 目录流的接口实现以及 DIR 的状态维护均在用户态的 libc 中完成。
链接相关接口
代码片段 9-7 中给出了与链接相关的接口函数。
- 其中两个 symlink 函数用于创建符号链接
- 两个 readlink 函数用于从符号链接文件中读取目标路径
- 两个 link 函数用于创建硬链接
- 两个 unlink 函数用于删除链接或符号链接
// 代码片段 9-7 POSIX 中符号链接和硬链接相关的部分接口
// 创建符号链接
int symlink(const char *path1, const char *path2);
int symlinkat(const char *path1, int fd,const char *path2);// 读取符号链接中的目标路径
ssize_t readlink(const char *restrict path,char *restrict buf, size_t bufsize);
ssize_t readlinkat(int fd, const char *restrict path,char *restrict buf,size_t bufsize);// 创建 (硬) 链接
int link(const char *path1, const char *path2);
int linkat(int fd1, const char *path1, int fd2,const char *path2, int flag);// 删除链接或符号链接
int unlink(const char *path);
int unlinkat(int fd, const char *path, int flag);
页缓存、直接 I/O 与内存映射
在存储设备上直接访问数据有两个问题
- 首先,目前大多数存储设备都是块接口,读写粒度为一个块,大小通常为 512 个字节或者 4096 个字节。然而文件系统所进行的更改往往并非对齐到块的边界,其读写的字节数也并非恰好为块大小的整数倍
- 其次,存储设备的访问速度慢,与内存相比要慢几个数量级。大量频繁的存储设备访问操作会成为应用程序的性能瓶颈
访问粒度不一致问题和一些优化
文件系统使用内存作为中转来解决访问粒度不一致的问题。将需要访问的内容所在的文件块写入到内存中,待修改完之后再将整块内容写回文件件系统。
在此之上的优化:
- 如果需要修改的内容正好等于整块的大小,则不必读取,直接将整块写入对应的文件块中即可
- 如果对同一个文件存储块有多次修改操作,可以进行“读取—修改 1—修改 2—修改 3—…—修改 n—写回”。减少读取和写入文件块造成的性能损耗
读缓存
文件的访问具有时间局部性:当文件的一部分被访问后,有较高的概率其会再次被使用。因此,当文件系统从设备中读取了某个文件的数据之后,可以让这些数据继续保留在内存中一段时间。这样,当应用程序需要再次读取这些数据时,就可以从此前保留在内存中的数据中读取,从而避免了存储设备的访问。这便是文件系统中的读缓存。
读缓存是需要占用内存空间的。为了防止读缓存占用过多的内存,操作系统会对读缓存的大小进行限制。当读缓存占用过多内存时,使用 LRU 等策略回收读缓存占用的内存。
写缓冲区与写合并
在文件系统的设计中有一个权衡:在一个文件写请求返回到应用程序之后,允许其修改的数据暂时不持久化到存储设备中。这个权衡允许文件系统暂时将修改的数据保存在内存中,并在后台慢慢地持久化到存储设备上,然而这样却牺牲了一定的可靠性,即如果修改的数据未被持久化到存储设备时发生了断电,修改的操作可能丢失。
为了确保数据被持久化到设备中,POSIX 中规定了 fsync 接口(见代码片段 9-8),用于保证某个已打开文件的所有修改全部被持久化到存储设备中。
// 代码片段 9-8 POSIX 中的 fsync 接口
int fsync(int fd);
页缓存
在 Linux 内核中,读缓存与写缓冲区的功能被合并起来管理,称为页缓存。页缓存以内存页为单位,将存储设备中的存储位置映射到内存中。文件系统通过调用 VFS 提供的相应接口对页缓存进行操作。
当文件被读取时,文件系统会先检查其内容是否已经保存在页缓存中。
- 如果文件数据已保存在页缓存中,则文件系统直接从页缓存中读取数据返回给应用程序
- 否则,文件系统会在页缓存中创建新的内存页,并从存储设备中读取相关的数据,然后将其保存在创建的内存页中。之后,文件系统从内存页中读取相应的数据,返回给应用程序
在进行文件修改时,文件系统同样会首先检查页缓存。
- 如果要修改的数据已经在页缓存中,文件系统可以直接修改页缓存中的数据,并将该页标记为脏页
- 若不在页缓存中,文件系统同样先创建页缓存并从存储设备中读取数据,然后在页缓存中进行修改并标记该页为脏页
标记为脏页的缓存会由文件系统定期写回到存储设备中。当操作系统内存不足或者应用程序调用 fsync 时,文件系统也会将脏页中的数据写回到存储设备中。
页缓存中页的状态:

直接I/O和缓存I/O
页缓存是持久化和性能之间权衡的产物。在大多数情况下,页缓存能够显著地提升文件系统性能。然而并非在所有情况下页缓存都能起到正面作用。
- 对于对数据持久化有较强的要求的应用,这些应用需要在每次修改文件后立即执行 fsync 操作进行同步,这会影响应用程序的性能
- 一些应用程序(如数据库等)会自己实现缓存机制对数据进行缓存和管理。由于应用程序更加了解自己对数据的需求,在这种情况下,操作系统提供的页缓存机制是冗余的,且一般会带来额外的性能开销
因此,文件系统将是否使用页缓存的判断和选择权交给了应用程序。应用程序可以在打开文件时通过附带 O_DIRECT 标志,提示文件系统不要使用页缓存。这种文件访问方式就是直接 I/O。而相对应地,使用页缓存的文件请求称为缓存 I/O。
内存映射
除了文件的 read 和 write 接口外,应用程序还可以通过内存映射机制,以访问内存的形式访问文件内容。Linux 在其页缓存的基础上实现了文件的内存映射机制。
代码片段 9-9 给出了 POSIX 中内存映射相关的接口。在建立内存映射前,应用程序先打开目标文件,获得其文件描述符。随后,应用程序调用 mmap 接口建立文件内存映射,调用时需提供映射的目标虚拟地址、长度、属性、标志位、文件描述符和起始位置在文件中的偏移量。
// 代码片段 9-9 POSIX 中内存映射相关的接口
void *mmap(void *addr, size_t len, int prot,int flags, int fd, off_t off);
int msync(void *addr, size_t len, int flags);
int munmap(void *addr, size_t len);
在处理内存映射请求时,VFS 会分配对应的 VMA 结构,并通过在 VMA 结构中记录目标文件的 inode 和映射时的属性,将 VMA 对应的虚拟地址空间与文件 inode 进行关联,最后返回起始虚拟地址给应用程序。由于此时并未更新页表,当应用程序首次访问映射后的虚拟地址时,会触发缺页中断。
需要注意的是,在进行内存映射之后,应用程序在进行访问时,访问的是页缓存对应的内存页。其修改的数据保存在页缓存中,可能并未写回到存储设备上。因此,为了保证修改的数据被写回到存储设备上,应用程序需要调用 msync 接口,请求 VFS 对指定的内存映射区域进行同步写回操作。
当所有操作完成之后,应用程序可以调用 munmap,移除指定的虚拟内存地址区域上的内存映射。
多种文件系统的组织和管理
Linux 操作系统使用简单的链表结构,将其支持的所有文件系统保存起来,包括内嵌的文件系统和在运行时作为模块加载的文件系统。在代码片段 9-1 中展示的 file_system_type 结构中,name 作为文件系统类型的标识符,next 作为链表指针,指向支持的下一种文件系统。
在进行存储设备的挂载时,VFS 通过指定的文件系统标识符,找到对应的 file_system_type 结构,并开始进行挂载操作。
挂载点
一般来说,每个文件系统在设计时都会有其本身的内部结构。这些结构均会有一个固定的入口保存在超级块中。通常这个入口为文件系统的根目录。
在根文件系统的基础上,其他的文件系统可以被自由地挂载到 VFS 文件系统树上的任何一个目录之上(如图 9-8 中的 FS1 和 FS2)。这些作为挂载目标的目录,便被称为挂载点。一旦挂载成功,当应用程序访问到挂载点及其子节点时,会访问到被挂载文件系统中的数据。这些挂载点就像一个个虫洞,一旦文件访问操作到达挂载点,便会跳到被挂载文件系统的根目录处继续进行访问。
文件系统的挂载:

小知识
并不要求挂载点在挂载时一定是空目录。挂载点原有的内容会被临时“覆盖”,但并不会丢失。当所挂载的文件系统被卸载时,这些原有内容又可以再次被访问到。
代码片段 9-10 中展示了 Linux 的 VFS 中用于保存挂载信息的部分结构。每个挂载的文件系统都会有一个 mount 结构,其中保存了挂载点信息(mnt_mp)和挂载的文件系统信息(mnt)。挂载点信息(mountpoint 结构)记录了挂载点对应的目录项等信息。挂载的文件系统信息(vfsmount 结构)则包含挂载文件系统的超级块、挂载文件系统的根目录和挂载的选项。
// 代码片段 9-10 Linux VFS 中的挂载结构
struct vfsmount {struct dentry *mnt_root; // 挂载树的根目录struct super_block *mnt_sb; // 指向超级块int mnt_flags;
};struct mountpoint {struct hlist_node m_hash;
};struct mount {struct hlist_node mnt_hash;struct mount *mnt_parent;struct dentry *mnt_mountpoint;struct vfsmount mnt;...struct mountpoint *mnt_mp; // 挂载点union {// 相同挂载点上挂载的其他文件系统struct hlist_node mnt_mp_list;struct hlist_node mnt_umount;};...
};
通过这种对挂载点的维护,Linux 的 VFS 可以灵活地挂载多种文件系统,让各种不同的文件系统在相同的操作系统下共同工作。
伪文件系统
文件接口是一种非常灵活的数据访问方式。其不仅可以用来访问用户数据,还可以用于其他功能。Linux 实现了一些不用于保存文件数据的文件系统,称为伪文件系统(pseudo file system)。表 9-3 中给出了一些常见的 Linux 伪文件系统。
表 9-3 Linux 中的一些伪文件系统
| 常用挂载点 | 描述 | |
|---|---|---|
| procfs | /proc | 查看和操作进程相关的信息和配置 |
| sysfs | /sys | 查看和操作与进程无关的系统配置 |
| debugfs | 用于内核状态的调试 | |
| cgroupfs | 用于管理系统中的 cgroup | |
| configfs | /sys/kernel/config | 创建、管理和删除内核对象 |
| hugetlbfs | 查看和管理系统中的大页信息 |
通过使用文件的抽象,伪文件系统能够直接获得内核中 VFS 所提供的命名、权限检查等功能。同时,由于文件接口的简单易用性,用户可以使用诸如文件管理、查看、监控等工具,与伪文件系统进行交互。
伪文件系统的一个常见用途是:允许用户态应用程序通过读取文件的方式读取内核提供的信息,并通过写入文件的方式对操作系统内核进行配置与调整。
伪文件系统同样使用 VFS 所定义的内存数据结构和方法。当伪文件系统中的文件被读取时:
- VFS 在进行路径解析后,会发现目标文件被该伪文件系统管理
- VFS 会通过记录在内存数据结构中的文件系统方法,将文件请求交由该伪文件系统进行处理。伪文件系统便可以根据文件名等特征进行特定的内核操作。
- 如在上述 /proc/filesystems 文件被读取时,/proc 伪文件系统会遍历保存在内存中的所有文件系统类型(file_system_type 结构),并将其逐一写入用户态传递的缓冲区中。
同理,当伪文件系统中的文件被写入时,伪文件系统会根据请求类型对内核状态进行改变。
其他文件系统
FAT 文件系统
文件分配表(File Allocation Table, FAT)是一种组织文件系统的架构。1977 年,Bill Gates 和 Marc McDonald 创建了第一个基于 FAT 的文件系统,用于管理 Microsoft Disk BASIC 系统中的存储设备 [7]。自被搭载在 Windows 操作系统中至今,基于 FAT 的文件系统从早期的 FAT12、FAT16 逐步发展到现在被广泛使用的 FAT32 和 exFAT 等文件系统。这些文件系统均使用 FAT 作为主要索引格式,后文中我们统一使用 FAT 文件系统来表示这一类文件系统。在本小节中,我们将以 FAT32 为基础介绍 FAT 文件系统的基本设计。
存储布局
图 9-9 展示了 FAT 文件系统的基本存储布局。
FAT文件系统的存储布局

- 位于整个文件系统最前端的是引导记录。引导记录的作用和超级块类似,其中记录了文件系统的元数据以及后续各个区域的位置。若此分区是可启动分区,则引导记录中还会包括引导整个操作系统启动所需要的代码
- FAT,即文件分配表。FAT 通常有两份,在图 9-9 中分别标记为 FAT1 和 FAT2。两份 FAT 的内容一致,当 FAT1 由于各种原因损坏后,文件系统依然可以使用 FAT2 来访问和修复整个文件系统
- FAT 之后通常保存根文件夹。根文件夹之后为数据区,用于保存其他文件夹和文件数据。FAT 文件系统以簇(cluster)作为逻辑存储单元。每个簇对应于物理存储上的一个或多个连续的存储扇区(sector),具体大小可以在格式化时进行指定
目录项格式
FAT 文件系统的目录中同样保存着目录项。与我们此前介绍的基于 inode 的文件系统不同,FAT 中每个目录项的大小固定为 32 字节,其中内容包括:
- 8.3 格式的文件名(11 个字节)
- 属性(1 个字节)
- 创建时间(3 个字节)
- 创建日期(2 个字节)
- 最后访问日期(2 个字节)
- 最后修改时间(2 个字节)
- 最后修改日期(2 个字节)
- 数据的起始簇号(2 个字节)
- 文件大小(4 个字节)
- 保留字节(3 个字节)
属性
属性用来表示该目录项的种类和状态。属性分别用一个比特位表示只读(read-only)、隐藏(hidden)、系统文件(system)、卷标(volume label)、子目录(subdirectory)和存档(archive)。
- 其中,卷标和子目录两个比特位用于表示该目录项的种类,由 FAT 文件系统进行维护
- 其余四个比特位可以由用户设置
为了支持长文件名,FAT 文件系统使用特殊值 0x0F 作为属性值,表示该目录项是一个长文件名的一部分,本小节中称为长文件名构件。关于长文件名和长文件名构件,我们将在稍后进行介绍。
短文件名
短文件名(8.3 格式)是一种占用 11 个字节的文件名格式,其中前 8 个字节为文件名,后 3 个字节为扩展名。因此,当一个名为 datafile.dat 的文件使用 8.3 格式时,保存的文件名为 DATAFILEDAT,共 11 个字节。如果文件名超过 8 个字节,则会将其截断并添加序号以做区别。如 mydatafile.dat 会变为 MYDATA~1DAT,其中 MYDATA 为原文件名的前 6 个字符,~1 表示此文件名是被截断的,而且它是此类文件名的第一个。
如果 MYDATA~1DAT 文件已经存在,则 mydatafile.dat 会变为 MYDATA~2DAT,以此类推。此外,8.3 格式中并不支持小写字母,所有文件名中的小写字母会被自动转换成大写字母进行存储。
长文件名
如何让文件支持更长的文件名呢?FAT32 文件系统的解决方法是:在保留短文件名的同时,用多个额外的目录项来保存长文件名,从而弥补短文件名表达力的不足。因此如果一个文件拥有长文件名,那么它就同时拥有了两个文件名;应用程序可以使用其中任意一个来找到这个文件。
图 9-10 展示了一个 FAT 文件系统中的一个名为 “OSBook File System.tex” 的文件的长文件名存储内容。首先,文件系统会为这个文件自动生成一个短文件名 “OSBOOK~1.TEX”,如图 9-10 的最下方。该文件的长文件名则使用连续的两个长文件名构件类型的目录项进行存储。这两个长文件名构件目录项保存在短文件名目录项之前(即图 9-10 的上方)。
FAT文件系统的长文件名:

用于保存长文件名的目录项,其类型是 0x0F(如图 9-10 中粗线方框所示),用于与正常的目录项相区分。若需要多个目录项才能保存下文件名,那么第一个目录项的编号是 0x1,第二个是 0x2,以此类推(如图 9-10 中深色方框);注意最后一个目录项的序号需要与 0x40 进行异或操作,因此图 9-10 中的最后一个目录项的序号是 0x42。
每个目录项除去开头的序号以及类型、预留的 0x00 和校验字节外,均可用于保存文件名。在本例中,每个字母占据了 2 个字节大小,这是因为 Windows 采用 Unicode 的编码方式;这样,每个目录项可以最多保存 13 个 Unicode 字符。FAT32 限制文件名的长度不能超过 255(不超过 20 个目录项)。
FAT和文件格式
FAT 文件系统以簇作为逻辑存储单元。每个簇有一个地址,称为簇号。FAT 实际上是一个由簇号组成的数组——每个数组中的值为逻辑上的下一个簇的簇号。
举例来说,簇 3 在其 FAT 表项中记录了 5,说明簇 3 之后的下一个簇是簇 5。十六进制的全 F 表示当前簇已经是最后一个数据簇,不存在下一个簇。
使用FAT格式对文件进行保存:

另一个使用FAT的例子:

FAT文件系统小结
FAT 是 FAT 文件系统的核心。相对于我们此前介绍的使用 inode 索引文件数据的方法
- FAT 的方法更加简单直接
- FAT 所使用的链式结构在查找时并不高效
在演进过程中,FAT 文件系统的簇号上限和簇大小被不断提高,以便能够提升管理的总存储空间和单个文件大小,从而满足存储设备容量的增长和用户对大文件的使用需求。
由于 FAT 文件系统设计和实现的简单性,FAT 文件系统依然被广泛用于各种设备和场景中。例如,UEFI 中 EFI 分区上的文件系统便是基于 FAT 文件系统进行设计的,且与 FAT 文件系统兼容。
NTFS
NTFS(New Technology File System)是另外一个在 Windows 操作系统中广泛使用的文件系统。相对于此前的 FAT 文件系统,NTFS 在功能、性能、可靠性等方面进行了诸多提升,并解除了原有 FAT 文件系统中的诸多限制。
存储布局与 MFT
在 NTFS 中,文件系统的核心结构从 FAT 变成主文件表(Master File Table, MFT)。MFT 中的每一行对应着一个文件,每一列为这个文件的某个元数据。NTFS 中所有的文件均在 MFT 中有记录。NTFS 中所有被分配的空间均被某个文件所使用。即使是那些用于存放文件系统元数据的空间,也会属于某个保留的元数据文件。
FAT 的存放与 MFT 的存放却有着本质区别。
FAT 是一个固定的结构,其大小和位置在创建文件系统时就已经固定下来并记录在引导区域内。这样虽然简单高效,但同时也限制了文件系统的可扩展性。
在 NTFS 中,并非所有 MFT 记录的位置在创建文件系统时就已经固定。MFT 的内容(即 MFT 记录)保存在一个名为 $Mft 的元数据文件 [9] 中。此文件的元数据被保存在 MFT 的第一条记录中。通过 $Mft 文件本身的记录,可以找到 $Mft 文件所有数据保存的位置,进而可以找到 MFT 中剩余的所有记录。
NTFS的存储布局和MFT

FUSE 与用户态文件系统
对于宏内核来说,其文件系统一般实现在内核态,作为内核的一部分或者内核模块,为用户态应用程序提供服务。
内核态实现文件系统的问题:
- 内核态的文件系统与内核其他部分是强相关的。它们具有相同的运行环境和地址空间,内核文件系统中的漏洞或缺陷会影响到内核的其他部分,从而会影响到整个操作系统的功能、性能和安全
- 为了保证内核代码的安全和可靠性,在内核中实现的文件系统中不宜加入大量的第三方代码。同时,由于构建方法等的限制,将各种不同的代码混合编译到内核中也是不现实的。因此,内核态文件系统无法使用大量的第三方用户态代码,能复用的代码很有限
- 内核中实现的文件系统缺少像用户态工具那样方便易用的调试工具。因而在内核态进行文件系统的调试难度较大,开发效率较低
鉴于这些问题,在用户态实现文件系统成为一个很好的选择。为了让其他应用程序能够使用用户态文件系统提供的服务,宏内核中需要加入支持相应功能的模块。其中比较流行和常用的是 FUSE 模块和框架。
下图中展示了 Linux 宏内核中的 FUSE 架构。从图中我们可以看出,应用程序依然通过系统调用与内核中的 VFS 模块进行交互。
- 若 VFS 发现应用程序请求的路径由 FUSE 模块管理时,其会将请求交给 FUSE 模块。
- FUSE 模块会根据其记录的信息将请求加入对应的共享队列中。
- 在用户态运行的 FUSE 文件系统服务器是一个用户态应用程序。在 FUSE 文件系统服务器开始运行时,内核中的 FUSE 模块会对其进行注册。
- 随后,其会不断地从共享队列中获取文件请求并进行处理,再通过队列将处理的结果返回给 FUSE 模块。
- FUSE 模块拿到处理结果后,再返回给发出文件请求的应用程序。
宏内核中的FUSE架构:

用户态的 FUSE 文件系统服务器可以通过各种方式处理应用程序的文件请求,如
- 在内存中维护一个内存文件系统来保存数据
- 通过系统调用使用内核中其他文件系统和存储设备上的数据
- 访问网络中其他设备上的存储资源
在用户态实现文件系统,还便于将不同资源整合成文件抽象。如,可以将电子邮件变成 FUSE 文件系统服务器所管理的资源。应用程序或者用户可以通过访问文件,对电子邮件进行接收、查看、编辑和发送。
与普通文件系统相比,FUSE文件系统需要在用户态和内核态之间反复转发、这种频繁的进程切换、请求转发和数据拷贝,会极大地影响用户态文件系统的性能。因此用户态文件系统一般会用在一些性能要求不高的场景下,或者用来研究和试验新的文件系统设计。
思考题
在介绍文件的符号链接和硬链接时,我们提到硬链接的目标文件不能为目录。为什么硬链接的目标文件不能是目录?如果允许硬链接到一个目录文件,会产生什么问题?如果一定要设计一个支持目录硬链接的文件系统,该如何设计和实现?
- 根本原因:防止破坏目录树结构,避免环和歧义
- 主要问题:遍历死循环、父目录不确定、链接计数失效
- 可行方案:采用图模型设计目录结构
在云环境下,有许多存储方法只提供单层文件管理,即不支持目录。这种设计有哪些优点?在这种情况下,如果用户或者应用程序需要使用“目录”功能来更好地管理自己的文件,那么该如何操作才能达到模拟“目录”的效果?
- 优点:简化了模型,不需要对link count、分子关系等进行维护;扁平结构避免了目录树的深度遍历或元数据锁竞争;访问文件的速度为O(1);不存储目录元数据,节省存储和计算资源;扁平键空间易于进行分片(sharding),不同前缀可分布到不同存储节点,适合分布式结构
- 操作:使用前缀(Prefix)模拟路径;创建“目录占位符”对象(photos/2024/ 以斜杠结尾)
文件描述符本质上也是一种 Capability。在微内核的情况下,能否直接使用 Capability 替代文件描述符?如果可以,那么会有哪些挑战?如果不行,为什么?
- Capability 是一个不可伪造的令牌(token),持有者可凭此直接访问特定资源,并隐含了访问权限。
- 因此,fd 可被视为一种“受限的、非显式的 Capability”——但它依赖全局内核状态(如进程 fd 表、vnode 表),且无法安全传递给其他进程(除非通过 SCM_RIGHTS 等复杂机制)。
- 挑战:
- POSIX 兼容性破坏:大量现有应用依赖 int fd、open()、dup()、select() 等 POSIX 接口,而Capability不支持
- Capability 的传递与组合复杂性:fd 可通过 fork() 继承、sendmsg(SCM_RIGHTS) 传递;Capability 需要显式传递
- 性能开销:Capability 验证可能涉及能力表查找或消息认证
当存在“/home/chcore”目录,但其中不存在“filesystem.tex”文件时,运行代码片段 9-3 中的程序会打印什么信息?如果多次运行呢?为什么会有这种现象?
- 第一次执行filesystem.tex,并输出空值或垃圾值;之后每次执行输出hello+(空值或垃圾值)
操作系统使用块设备将不同存储设备进行统一抽象。这种抽象是否为必需的?有何利弊?
- 统一抽象是工程上高度实用的折中
- 优点:简化了文件系统设计、提升可移植性与虚拟化支持
- 缺点:可能造成性能损失、无法表达不同设备的特异性语义
文件系统存储布局会影响文件系统的性能和可靠性。与我们在本章中介绍的基于 inode 的文件系统不同,在 Ext2 文件系统中,整个存储设备首先被分为块组(block group)。每个块组由地址(即块号)连续的存储块组成。在每个块组中,都保存了整个文件系统的超级块、本块组中的分配信息、块组中的 inode 表,块组中剩余的空间用于保存数据块。这种设计对文件系统的性能和可靠性会有何影响?
- 对性能的影响:
- 优点:提升局部性(Locality),减少磁盘寻道
- 缺点:块组内碎片问题,若某个块组频繁创建/删除小文件,可能导致其数据块碎片化,而其他块组仍有大量连续空闲空间
- 对可靠性的影响:
- 优点:超级块冗余提升容错能力
- 缺点:备份不完整,仍有单点风险,并非所有块组都存超级块:为节省空间,只有部分块组有备份(如每 8192 个块组一个),若坏道恰好覆盖所有超级块副本,仍可能无法恢复
使用 FUSE 实现用户态文件系统还能提供哪些其他的新功能?请至少举出两个例子
- 基于内容的文件去重与透明压缩
- 按需加载的远程/归档文件系统
- 加密文件系统
- 版本控制文件系统
