Skip to main content

第 2 章 - 什么是文件系统(What Is a File System?)

2.1 基础知识(The Fundamentals)

本章是对文件系统的概念、其管理内容以及它向操作系统其余部分提供的抽象的介绍。阅读本章将使你在术语、概念以及文件系统实现所用的标准技术方面打下扎实基础。

大多数计算机用户对文件系统的作用、文件和目录的概念等都有一定的了解,这些知识来自于他们与计算机的直接接触。然而,由于每个人的经验不同,我们不以已有经验为基础来讨论这些问题,而是从头开始,思考如何在计算机上存储信息,然后再从这个角度逐步深入。

计算机的主要用途是创建、操作、存储和检索数据。文件系统提供了支持这些任务所需的机制。从最高层次来看,文件系统是一种用于在永久性存储介质(如磁盘)上组织、存储、检索和管理信息的方式。文件系统负责管理永久存储,并构成所有操作系统不可或缺的一部分。

管理永久性存储的方法有很多种。在一个极端,有一些非常简单的文件系统,它们施加了很多限制,使得用户使用起来十分不便。而在另一个极端,有持久化对象存储和面向对象的数据库,它们将“永久性存储”的概念抽象化,以至于用户和程序员甚至不需要意识到它的存在。由于在计算机中存储、检索和操作信息是一个非常通用的问题,因此对于这个问题存在很多解决方案。

并不存在“正确”的文件系统写法。在决定某个操作系统应使用哪种文件系统时,我们必须在需求与项目的其他约束之间做出权衡。例如,一些游戏主机使用的 flash-ROM 卡几乎不需要高级的查询接口或属性支持,但对数据写入的可靠性要求非常高,因此可能需要支持日志记录(journaling)的文件系统。又如,大型主机所需的文件系统必须具有极高的数据吞吐率,但对用户友好功能的要求较低,因此优化每秒事务处理数(TPS)的技术会比方便用户查找文件的特性更受青睐。

我们应始终牢记文件系统的抽象目标:存储、检索、定位和操作信息。用这种通用方式来表述目标,可以让我们自由地思考不同的实现方法和可能性,而不局限于将文件系统视为典型的、严格层级化的、基于磁盘的结构。

2.2 术语(The Terminology)

在讨论文件系统时,会使用很多专门术语来指代某些概念,因此有必要先定义我们将如何使用这些术语。以下从底层开始定义,每个术语的定义都建立在前一个之上:

  • 磁盘(Disk):一种具有特定容量的永久性存储介质。磁盘有固定的扇区(sector)或块(block)大小,即它读取或写入数据的最小单位。大多数现代硬盘的块大小为 512 字节。
  • 块(Block):磁盘或文件系统可写入的最小单位。文件系统的所有操作都是基于块进行的。文件系统中的块大小通常等于或大于(整数倍)磁盘块的大小。
  • 分区(Partition):磁盘上的一部分块。一个磁盘可以有多个分区。
  • 卷(Volume):我们用来指代某个存储介质(例如磁盘)上若干块的集合的名称。一个卷可以是磁盘上的所有块、部分块,甚至跨多个磁盘而组成一个整体。通常,"volume" 指的是已经初始化了文件系统的磁盘或分区。
  • 超级块(Superblock):文件系统在卷上用于存储关键信息的区域。超级块通常包含诸如卷的大小、卷的名称等信息。
  • 元数据(Metadata):指关于某项内容的信息,但并不属于该内容本身。例如,文件的大小是很重要的信息,但它不属于文件的实际数据。
  • 日志记录(Journaling):一种即使在断电或系统意外重启时也能确保文件系统元数据正确性的方法。
  • i-node(索引节点):文件系统用于存储某个文件所有必要元数据的位置。i-node 也负责连接到文件内容以及与该文件关联的其他数据。i-node 一词源自 Unix,也被称为文件控制块(FCB)或文件记录。
  • 区段(Extent):表示磁盘上从某个起始块号开始的一系列连续块。例如,一个区段可能从第 1000 块开始,连续占用 150 个块。区段总是连续的,也被称为“块区间(block run)”。
  • 属性(Attribute):一个名称(文本字符串)和与之关联的值。该值可以是定义好的类型(如字符串、整数等),也可以是任意数据。

2.3 抽象概念(The Abstractions)

文件系统最基本的两个概念是 文件目录

文件(Files)

所有文件系统都必须提供的基本功能,是能够存储一段带名称的数据,并通过名称来检索该数据。我们通常将这类带名称的数据称为“文件”。文件仅提供文件系统中最基础的功能。

文件是程序永久存储数据的地方。最简单的形式下,文件可以只存储一段信息。这段信息可能是文本(例如一封信、程序源代码等)、图像、数据库,或者用户希望永久保存的任何字节集合。文件中数据的大小可能从几字节到占满整个卷的容量不等。一个文件系统应能够容纳大量文件,其中的“大量”可能意味着数万个甚至上百万个。

文件的结构 (The Structure of a File)

有了文件这一概念后,文件系统可以选择不对文件施加任何结构,也可以对文件内容施加严格的结构约束。一个无结构的“原始”文件,通常被称为“字节流(stream of bytes)”,实际上是完全没有结构的。文件系统只记录文件的大小,并允许程序按任意顺序和方式读取这些字节。

无结构文件可以每次读取 1 个字节、17 个字节,或其他任何程序所需的数量。此外,不同程序可以以不同方式读取同一个文件;文件系统并不关心 I/O 请求的对齐或大小。将文件视为无结构字节流,是当前文件系统中最常见的方法。

如果一个文件系统选择对文件内容施加结构,通常会采用 记录(record) 的形式。在记录的概念下,程序员需要指定记录的大小和格式,而所有对该文件的输入输出都必须在记录边界上进行,并且大小为记录长度的整数倍。有些系统允许程序创建 VSAM(虚拟顺序访问方式)和 ISAM(索引顺序访问方式)文件,基本上等同于存储在文件中的数据库。这些结构化文件的概念通常不会出现在通用桌面操作系统中。我们在讨论文件系统时不会涉及结构化文件。如果你对此感兴趣,可以查阅关于主机操作系统(如 MVS、CICS、CMS、VMS)的相关文献。

文件系统还必须允许用户以有意义的方式为文件命名。文件(即信息)的检索,是文件系统成功与否的关键。文件系统允许用户命名文件的方式,是影响文件是否易于查找的一个因素。对于面向普通用户的系统,文件名长度至少要支持 32 个字符。对于嵌入式系统或几乎不需要用户界面的系统,限制文件名长度可能更具经济性和效率。

文件元数据(File Metadata)

文件的名称是一种元数据,因为它是关于文件的信息,但不属于文件的字节内容本身。还有其他类型的元数据,例如文件所有者、安全访问控制、最后修改时间、创建时间、大小等。文件系统需要在保存文件内容之外,另外存储这些元数据。

通常,文件系统将文件元数据保存在 i-node(索引节点) 中。 图 2-1 展示了 i-node 与其包含信息以及文件数据之间的关系(此图文中未提供)。

图 2-1

文件系统在 i-node 中存储的信息种类因文件系统而异。常见的信息包括:文件最后访问时间、文件类型、创建者、版本号,以及指向包含该文件的目录的引用。选择在 i-node 中存储哪些元数据信息,取决于整个系统的需求。

文件的数据(The Data of a File)

i-node 中最重要的信息是文件数据在磁盘上的位置。i-node 通过维护文件在磁盘上所占块(block)的列表,来指向文件的内容。

从高层次来看,文件表现为连续的字节流,但实际上存储文件数据的磁盘块可能在物理位置上并不连续。i-node 包含的信息使得文件系统可以将文件中的逻辑位置(例如字节偏移量 11,239)映射到磁盘上的物理位置。

图 2-2 展示了这一过程(此图文中未提供)。 假设文件系统块大小为 1024 字节: 如果要读取文件位置 4096 处的数据,文件系统需要找到该文件的第 4 个块(因为 4096 ÷ 1024 = 4)。i-node 中保存了构成文件的所有块的列表,因此可以得知第 4 个块在磁盘上的地址。文件系统随后请求磁盘读取该块,并将数据返回给用户。

图 2-2

虽然这个例子简化了过程,但基本思想始终相同: 面对读取某一逻辑位置的数据请求,文件系统需将其转换为物理磁盘位置,请求读取对应块,然后将数据返回用户。

当请求读取(或写入)的位置不在块边界时,文件系统必须将位置向下对齐到该块的起始处。随后在块内读取或写入数据时,还要加上起始块与原始位置之间的偏移。例如,如果请求读取文件偏移 4732 处的数据,而不是 4096,那么仍需读取第 4 个块。但读取块后,只使用块中偏移 636(即 4732 - 4096)处的数据。

当 I/O 请求跨越多个块(例如一次读取 8192 字节)时,文件系统必须定位多个块的位置。如果文件系统设计良好,这些块会在磁盘上是连续的。 请求连续块是提高磁盘 I/O 效率的关键,因为磁盘驱动器最擅长的操作就是读取或写入大片连续区域。因此,文件系统总是努力将文件数据尽可能连续地安排在磁盘上。

块映射(Block Map)

i-node 有多种方式来存储指向文件数据的引用。最简单的方法是一个块列表,每个文件块对应一个条目。例如,一个长度为 4096 字节的文件需要四个磁盘块。用虚构的磁盘块编号举例,i-node 可能如表 2-1 所示。

table-2-1

表 2-1 磁盘块与文件的映射示例

通常,一个 i-node 会直接在其结构中存储 4 到 16 个块引用。直接在 i-node 中存储少量块地址可以简化数据定位过程,因为大多数文件都小于 8K。若 i-node 有足够空间来映射大多数文件的数据,就能简化文件系统的设计。文件系统设计者需要在 i-node 的大小与其可映射数据量之间做权衡。i-node 的大小通常最好是块大小的整数倍,这意味着其大小通常是 2 的幂。

i-node 只能存储有限数量的块地址,因此也限制了文件的最大大小。即使是中等大小的文件,也不实际地将所有数据块地址都直接存储在 i-node 中。为了突破 i-node 中块地址存储空间的限制,可以使用间接块(indirect block)。当使用间接块时,i-node 存储的是一个间接块的地址,而不是实际数据块的地址。这个间接块则包含指向文件数据块的指针。间接块本身不包含用户数据,只存储指向数据块的地址。通过一个磁盘块地址,i-node 就能访问更多的数据块。

图 2-3 展示了 i-node 与间接块之间的关系。间接块中包含的数据块地址指向实际存储文件数据的磁盘块。间接块扩大了文件可以访问的数据范围。一个间接块可引用的数据块数量等于文件系统块大小除以磁盘块地址大小。在 32 位文件系统中,块地址为 4 字节;在 64 位文件系统中,则为 8 字节。举例来说,若文件系统块大小为 1024 字节,块地址大小为 64 位(即 8 字节),则一个间接块最多可以引用 128 个块。

图 2-3

虽然间接块扩展了文件的最大容量,但通常不足以支持几百 KB 以上的文件。因此,文件系统会进一步采用**双重间接块(double-indirect blocks)**技术。

双重间接块的原理与间接块相同。i-node 包含一个双重间接块的地址,该块中保存的是间接块的地址,而每个间接块又保存数据块的地址。计算双重间接块所能映射的数据量稍复杂一些。双重间接块与间接块的关系类似于间接块与数据块的关系。双重间接块可引用的间接块数量等于文件系统块大小除以块地址大小。延续之前的例子,一个 1024 字节块大小、8 字节地址大小的文件系统中,双重间接块可引用 128 个间接块,而每个间接块又可引用 128 个数据块,因此:

128 个间接块 × 每个间接块 128 个数据块 = 16384 个数据块, 即,在 1K 块大小的情况下,16MB 数据量。

这已经是一个相对合理的文件大小了,但仍可能不足。这种情况下可以使用三重间接块(triple-indirect blocks),不过这种情况比较少见。事实上,许多系统的块大小较大,块地址较小,因此可以映射更大的数据空间。例如,若文件系统使用 4096 字节块、4 字节块地址,则每个块可存储 1024 个地址,一个双重间接块可映射 1024 个间接块,每个间接块映射 1024 个 4KB 数据块,总共可映射 4GB 的磁盘空间。

在这种列表式块映射方法中,从文件位置映射到磁盘块地址是非常直接的。文件位置可以作为文件块列表的索引。由于直接块、间接块、双重间接块和三重间接块所能映射的空间是固定的,文件系统总是能准确定位数据块的地址。

伪代码如列表 2-1 所示,演示了如何从一个位于双重间接范围内的文件位置,映射到实际数据块地址。使用 dbl_indirect_indexindirect_index,文件系统能加载双重间接块和间接块,找到数据块地址。加载数据块后,block_offset 可用于定位到目标字节。若该位置仅在直接块或间接块范围内,算法会更简单。

blksize = size of the file system block 
size dsize = amount of file data mapped by direct blocks
indsize = amount of file data mapped by an indirect block
if (filepos >= (dsize + indsize)) { /* double-indirect blocks */
filepos -= (dsize + indsize);
dbl_indirect_index = filepos / indsize;
if (filepos >= indsize) { /* indirect blocks */
filepos -= (dbl_indirect_index * indsize);
indirect_index = filepos / blksize;
}
filepos -= (indirect_index * blksize); /* offset in data block */
block_offset = filepos;
}

代码清单 2-1 使用双重间接块将文件位置映射到数据块

举个例子,假设某文件系统有 8 个直接块,块大小为 1KB,块地址为 4 字节,因此一个间接块或双重间接块可映射 256 个块。若要定位文件位置 786769,对应的伪代码如列表 2-2 所示。执行完这些计算后,文件系统会按以下顺序读取块:

blksize = 1024;dsize = 8192;
indsize = 256 * 1024;
filepos = 786769;
if (filepos >= (dsize+indsize)) { /* 786769 >= (8192+262144) */
filepos -= (dsize+indsize); /* 516433 */
dbl_indirect_index = filepos / indsize; /* 1 */
/* at this point filepos == 516433 */
if (filepos >= indsize) { /* 516433 > 262144 */
filepos -= (dbl_indirect_index * indsize); /* 254289 */
indirect_index = filepos / blksize; /* 248 */
}
/* at this point filepos == 254289 */
filepos -= (indirect_index * blksize); /* 337 */
block_offset = filepos; /* 337 */
}

代码清单 2-2 将特定的文件位置映射到对应的磁盘块

  1. 读取双重间接块,使用 dbl_indirect_index 找到对应间接块地址;
  2. 读取该间接块,使用 indirect_index 获取最终数据块地址;
  3. 加载该数据块,使用 block_offset 定位精确字节偏移。

区段(Extents)

另一种从逻辑字节流位置映射到磁盘数据块的方法是使用区段列表(extent lists)。区段列表类似于前述的简单块列表,但每个地址指向的是一段连续块,而非单个块。每个区段地址由起始块编号和长度(后续连续块数)组成。区段结构通常比单块地址略大,但可映射的磁盘区域远大得多。

例如,在一个使用 8 字节块地址的文件系统中,若使用 2 字节长度字段,一个区段就可以映射多达 65,536 个连续块。不过,这种 10 字节的区段大小并不理想,因为它无法整除任何 2 的幂大小的文件系统块。为了在一个块中容纳更多区段,可以使用压缩方式。一种简单方法是截断块地址以挤出长度字段。例如,若使用 64 位块地址,可以将其压缩为 48 位,留下 16 位表示长度。这样做的缺点是降低了最大寻址能力。但若考虑块大小通常为 1024 字节或更大,这样的压缩仍能支持寻址 2^58 字节(或更多,视块大小而定),对多数应用已足够。

尽管区段列表在空间上更紧凑,但在碎片较多时仍需依赖间接或双重间接块。如果每个区段只能映射少量块,间接块机制就变得不可或缺。

使用区段列表的一个缺点是:定位特定文件位置可能需要扫描多个区段。因为区段长度是可变的,文件系统必须从第一个区段开始扫描,直到找到覆盖目标位置的区段为止。对于使用双重间接块的大文件来说,这种查找方式效率极低。一个优化方法是对双重间接区域的区段固定大小,以减少搜索开销。

文件总结(File Summary)

在本节中,我们讨论了“文件”作为用户数据存储单元的基本概念。我们提到了文件系统需要为每个文件维护的元数据(即 i-node)、结构化文件与非结构化文件的区别,以及存储用户数据的不同方式(如简单块列表和区段

extents)。“文件”这一基本抽象是任何文件系统的核心。

目录(Directories)

除了将单个文件作为字节流进行存储之外,文件系统还必须提供一种方式来命名和组织多个文件。文件系统使用术语“目录”(directory)或“文件夹”(folder)来描述一种容器,它通过名称来组织文件。目录的主要作用是管理一个文件列表,并将目录中的名称与其关联的文件(即 i-node)建立连接。

如我们将看到的,目录可以通过多种方式实现,但其基本概念在所有实现中都是一致的:目录包含一个名称列表。每个名称都关联着一个句柄,该句柄指向该名称所表示的内容(可能是一个文件,也可能是另一个目录)。尽管各个文件系统对文件名的具体定义有所不同,但一个目录必须存储文件的名称和对应的 i-node 编号。

文件名是目录在查找文件时使用的关键字,而 i-node 编号则是一个引用,使文件系统能够访问该文件的内容以及其它元数据。例如,如果一个目录包含三个条目:foo(i-node 525)、bar(i-node 237)和 blah(i-node 346),那么从概念上看,该目录的内容可如图 2-4 所示。

图 2-4

当用户希望打开某个特定文件时,文件系统必须在目录中搜索所请求的名称。如果该名称不存在,文件系统将返回一个错误,例如“找不到名称”(Name not found)。如果文件存在,文件系统则使用该 i-node 编号来定位与该文件相关的元数据,加载这些信息,并允许访问该文件的内容。

存储目录条目(Storing Directory Entries)

目录可以使用多种技术来维护其条目列表。最简单的方法是将每个名称按顺序存储在一个数组中,如图 2-4 所示。尽管有明显的缺点,将目录存储为一个无序的线性列表仍然是存储目录信息的流行方法。无序的目录条目列表在目录中包含大量名称时查找效率低下,因为搜索必须扫描整个目录。当一个目录开始包含成千上万个文件时,进行查找所需的时间可能会变得相当长。

另一种组织目录条目的方法是使用适合磁盘存储的排序数据结构。一个这样的数据结构是 B-tree(或其变种 B+tree 和 B*tree)。B-tree 按名称排序存储键,并且在查找某个键是否存在于目录中时非常高效。B-tree 还具有良好的扩展性,能够高效处理包含数万个文件的目录。

目录还可以使用其他数据结构,如哈希表或基数排序方法。存储目录条目的数据结构的主要要求是能够高效地进行查找,并且插入/删除操作的成本合理。这是一个常见的问题,已经有许多现成的适用解决方案。在实践中,如果文件系统除了简单的线性列表之外有其他实现,它几乎总是使用按文件名排序的 B-tree。

如前所述,每个文件系统对文件名都有自己的限制。文件名的最大长度、允许的字符集以及字符集的编码方式,都是文件系统设计者需要做出的策略决策。对于面向交互式使用的系统,文件名的最小长度通常为 32 个字符。许多系统允许文件名的最大长度为 255 个字符,这提供了足够的空间。经验数据显示,长度超过 150 个字符的文件名非常罕见。

文件名中允许的字符集也是一个重要的考虑因素。一些文件系统,如 CD-ROM 文件系统 ISO-9660,允许的字符集非常有限(基本上只有字母数字字符和下划线)。更常见的限制是必须选择某个字符作为路径层次结构的分隔符。在 Unix 中,这是正斜杠(/);在 MS-DOS 中,是反斜杠(\);在 Macintosh 操作系统中,是冒号(:)。目录分隔符不能出现在文件名中,因为如果出现在文件名中,操作系统将无法解析文件名:无法区分文件名的哪部分是目录组件,哪部分是实际的文件名。

最后,文件系统选择的字符集编码方式会影响系统如何处理多字节字符语言(如日语、韩语和中文)所带来的国际化问题。大多数 Unix 系统不做任何政策决策,仅将文件名存储为一系列非空字节。其他系统,如 Windows NT 文件系统,则明确将所有文件名存储为 2 字节的 Unicode 字符。Macintosh 上的 HFS 只存储单字节字符,并假设使用 Macintosh 字符集编码。BeOS 使用 UTF-8 字符编码来处理多字节字符;因此,BFS 不需要担心多字节字符问题,因为 UTF-8 会将多字节字符编码为非空字节的字符串。

层级结构(Hierarchies)

将所有文件存储在单一目录中,仅适用于最小型嵌入式或独立系统。一个文件系统必须允许用户根据自己的习惯组织文件。传统方法是分层结构(hierarchical organization),这是人们熟悉且易于在计算机中实现的模型。

最简单的实现方式是允许一个目录条目指向另一个目录。通过允许目录中包含指向其他目录的名称,就可以构建层次结构。

图 2-5

图 2-5 展示了一个目录层级结构的例子。顶层包含三个目录(work、school、funstuff)和一个文件(readme)。各个目录又包含了更多的文件和目录。例如:

  • work 含一个文件 file1;
  • school 含一个文件 file2 和一个空目录 dir2;
  • funstuff 含两个文件 file3、file4 以及一个目录 dir3,dir3 又包含 file5、file6。

由于目录可以包含其他目录,因此可以构建任意复杂的层级结构。虽然实现细节可能对层级深度有所限制,但理论上层级结构的深度和大小是无限的。 层级结构是组织信息的有效抽象形式。用户创建目录结构之后,通常不会频繁修改结构。因此层级结构一般是固定不变的。不过,这是一个研究领域,也存在将层级结构视为文件关系视图的一种方法,甚至可以允许程序修改这种视图。

其他方法(Other Approaches)

一种更灵活的架构允许用户根据当前的需求而不是先前的组织方式来查看一组信息。这种架构使用户可以根据需要以不同的方式查看数据。例如,程序员可能有多个项目,每个项目按项目名组织在子目录中。在每个项目下,通常还有进一步的子目录来组织源代码、文档、测试用例等。对于组织多个项目来说,这是非常有用的方式。

然而,如果用户需要查看所有文档文件或所有源代码文件,现有目录结构的刚性就会造成一定困难。我们可以设想一个系统,允许用户请求“所有文档文件”或“所有源代码”,而不管它们在层级结构中的具体位置。这不仅仅是一个“查找文件”工具,它不会仅仅返回一个静态的结果列表,而是由文件系统直接支持此类操作,使其成为真正的一等文件系统操作。

目录小结(Directory Summary)

本节讨论了目录这一概念,它不仅是存储多个文件的机制,也是将信息组织成层级结构的一种方式。目录的内容可以通过简单的线性列表、B 树,甚至哈希表等其他数据结构来存储。我们还探讨了除固定层级结构之外,更灵活的数据组织方式的可能性。

2.4 基本文件系统操作(Basic File System Operations)

文件和目录这两种基本抽象构成了文件系统可以操作的对象。文件系统可以对文件和目录执行多种操作。所有文件系统都必须至少提供一些基本支持。在最基本的文件系统原语之上,还可以有其他特性、扩展和更复杂的操作。

在本节对文件系统操作的讨论中,我们侧重于文件系统必须实现的功能,而不一定与对应的用户级操作的表现完全相同。例如,在文件系统内部,打开一个文件需要给出一个目录引用和一个名称,但在用户层面,只需一个表示文件名的字符串。用户级 API 与文件系统内部实现之间有紧密关联,但它们并非一一对应。

初始化(Initialization)

很显然,文件系统首先要能在指定卷上创建一个空的文件系统。文件系统会根据待初始化卷的大小和用户指定的选项,确定其内部数据结构的大小及布局。对初始数据结构的布局进行仔细设计,会显著影响性能,因此尝试不同的放置位置非常有价值。

通常,宿主操作系统能报告卷的大小(以设备块为单位)。文件系统利用此信息计算各种结构的大小,例如空闲/已用块映射(通常是位图)、i-node 数量(若预先分配)以及日志区域的大小(若有)。计算出这些数据结构的尺寸后,再决定把它们放在哪些块上。结构的位置、卷的大小、卷的状态(干净或脏)、以及其他全局信息会记录在超级块(superblock)中。通常,超级块会写到卷上的一个固定位置。此外,初始化时还必须创建一个空的顶层目录(即根目录),否则挂载后无处创建文件或子目录。如果根目录不总是位于卷上固定的位置,则根目录的 i-node 编号(或地址)也要存储在超级块中。

初始化操作既可以由独立的用户程序完成,也可以集成在核心文件系统代码中。无论如何,初始化的目的就是把一个卷准备成一个空容器,供后续创建文件和目录。初始化完成后,文件系统就可以“挂载”了。

挂载(Mounting)

挂载文件系统的过程,是打开原始设备、读取超级块及其他元数据,并为访问该卷做准备。挂载时必须小心,因为被挂载卷的状态未知,可能已损坏。超级块通常包含文件系统的状态标志:若上次正确卸载,标记为“干净”,无需一致性检查;若上次异常断电或崩溃,则标记为“脏”,必须检查。对“脏”卷的验证至关重要:若直接挂载一个损坏文件系统,可能导致进一步的数据破坏,甚至因非法操作而崩溃。对文件系统有效性进行验证和(如有必要)修复,通常是一项复杂且耗时的任务;因而大多由独立的“文件系统检查”程序来完成。日志文件系统虽可通过重放日志保证一致性,但依然要验证其他数据结构。只有当超级块标记为“脏”时,才需进行完整检查;否则,快速挂载即可。

确认卷有效后,文件系统须基于磁盘上的数据结构,构建内存中的对应结构,以便后续快速访问。通常至少要在内存中保存超级块的镜像,根目录引用,以及空闲/已用块映射;日志文件系统还需加载日志信息。此时,文件系统就“挂入”操作系统,以供用户和程序通过操作系统调用访问卷上的文件和目录。

卸载(Unmounting)

与挂载相对的是卸载操作。卸载时,要将所有内存中与该卷相关的状态刷新到磁盘,使卷恢复到“干净”状态。卸载的最后一步是更新超级块,标记为正常关闭。这样下次挂载时就可跳过一致性检查。卸载后,除非再次挂载,否则不应再访问该卷。

创建文件(Creating Files)

对于一个新挂载的空卷,首个必需操作就是创建文件。创建一个文件需要两个基本信息:要创建到哪个目录,以及文件名。文件系统据此分配一个 i-node,用以记录文件元数据;并在所在目录中添加一个文件名–i-node 条目。可选参数还可能指定访问权限、文件模式或文件系统特定的标志。

分配好 i-node 后,文件系统需填充相关字段:如记录创建时间、将文件大小初始化为零,并在 i-node 中记录所有权和安全信息(如适用)。创建文件并不会预先为其内容分配数据块;只有当写入数据时,才会分配存储空间。因此,文件创建本身是一个相对简单的操作。

创建目录(Creating Directories)

目录的创建与文件类似,只是略复杂一些。文件系统同样要分配 i-node 并在父目录中添加目录名–i-node 条目。此外,还必须初始化目录内容:若目录以简单列表存储,则初始化开销小;若以 B 树等复杂结构存储,则需要额外构造这些结构。每个目录还要包含一个指向其父目录的引用——通常在 POSIX 风格文件系统中,将父目录的 i-node 编号以名称 “..”(点点)保存在目录项中;“.”(点)则始终指向目录自身。这两条约定使得程序能在目录层次中自由上下遍历,而无需在用户空间中重复维护路径状态。

目录的创建是构建层次结构的基础操作,使用户能够以树状结构组织信息。维护对父目录的引用,是实现这种层次导航的一项关键设计。'

以下是更贴近原文、较为直译的中文翻译:

打开文件(Opening Files)

打开现有文件可能是文件系统中使用最频繁的操作。打开文件的任务相对复杂,包含两个操作。第一个操作——查找(lookup)——接受一个目录引用和一个名称,在该目录的数据结构中查找该名称:遍历目录结构,查看名称是否存在,若存在则返回关联的 i-node。查找操作的效率非常重要。许多目录中的文件很少,数据结构的选择影响不大;但在大型服务器上,目录常有数千个条目,此时目录数据结构的选择尤为关键。

得到 i-node 编号后,打开操作的第二步是验证用户是否有权限访问该文件。在不进行权限检查的系统中,此步可跳过;在重视安全的系统中,需要检查权限以确认请求访问的程序是否获准访问。如果安全检查通过,文件系统便分配一个内存中的状态结构,用于记录对该文件的访问状态(如只读打开、追加模式等)。

打开操作的结果是返回一个句柄,调用程序可以使用该句柄对文件发起 I/O 请求。此句柄被操作系统的上层部分使用;操作系统有额外的数据结构,将用户级文件描述符映射到内部的文件系统句柄。

写入文件(Writing to Files)

写操作允许程序将数据存储到文件中。写操作的参数包括:文件引用、写入的起始位置、内存缓冲区和要写入的数据长度。写入文件相当于请求文件系统将一段数据复制到文件中的某个永久位置。

写操作将缓冲区中的数据写入指定位置。如果给定位置已在文件末尾,文件必须先增长:分配足够的磁盘块以容纳数据,并将这些块添加到文件“拥有”的块列表中。增长文件会更新空闲/已用块列表、文件 i-node,以及任何相关的间接或双重间接块;在某些情况下,还可能修改超级块。

一旦有足够空间,文件系统需将文件中的逻辑位置映射到物理块地址,并将数据写入底层设备,实现数据持久化。写入完成后,内核维护的文件偏移量会增加已写入的字节数。

读取文件(Reading Files)

读操作允许程序访问文件内容。读操作的参数与写操作相同:文件引用、读取起始位置、内存缓冲区和长度。

读操作比写操作更简单,因为它不修改磁盘。读操作只需将逻辑位置映射到物理块地址,然后从底层设备读取数据到用户缓冲区,并将文件偏移量增加读取的字节数。

删除文件(Deleting Files)

删除文件是文件系统需要支持的下一个逻辑操作。最常见的删除方式是传入文件名。若文件名存在,删除分两阶段进行。第一阶段,从所在目录中移除文件名,防止其他程序再打开该文件,然后将文件标记为“待删除”。第二阶段仅在没有程序持有该文件的打开句柄时进行:当引用计数归零后,文件系统才释放文件资源,将数据块归还空闲池,并将 i-node 放回空闲列表。

将删除分两阶段是必要的,因为在请求删除时,文件可能仍被打开。如果立即释放资源,则后续的 I/O 请求会因块不再属于该文件而无效。通过等待引用计数为零再真正删除,系统能保证打开文件的程序在关闭句柄前,依然可以正常读写。

两阶段方法的另一个好处是:程序可打开一个临时文件进行 I/O,然后立即删除它,并继续正常 I/O。当程序退出并关闭所有资源后,文件便被彻底删除,无需在出错时额外清理。

重命名文件(Renaming Files)

重命名操作是文件系统需要支持的最复杂操作。重命名的参数包括:源目录引用、源名称、目标目录引用和目标名称。

在执行重命名前,需要进行大量参数验证。在多线程系统中,通常需要锁住整个文件系统,以防其他操作影响此过程。验证包括:若源目录与目标目录相同,则源名称和目标名称必须不同;若目录不同,则源名称和目标名称可相同。接着检查源名称是否指向目录,若是,则目标目录不能是源目录的子目录。此检查需从目标目录一路向上遍历到根目录,确保源目录不是目标的祖先。

只有在上述验证通过后,才可开始重命名。首先,如果目标名称已指向某文件或空目录,必须先删除它;然后从源目录中删除源名称,并在目标目录中插入目标名称;如果重命名的是目录,还需更新该目录中指向父目录的引用,否则会破坏层次结构。

读取元数据(Reading Metadata)

读取元数据是一个维护操作,允许程序访问文件的信息。此操作的参数仅为文件引用,返回值通常为 i-node 中部分字段的副本(如最后修改时间、所有者、安全信息等)。在 POSIX 系统中,此操作称为 stat()。

写入元数据(Writing Metadata)

如果支持读取元数据,往往也需要支持修改元数据。写入元数据操作允许程序修改文件 i-node 的字段。在用户层面,这些操作可能以 chown(), chmod(), utimes() 等多种函数出现;但文件系统内部只需一个通用函数来更新可写字段。不应允许修改所有字段,仅限特定可写项。

打开目录(Opening Directories)

与通过 open() 访问文件内容类似,目录也有对应操作,通常称为 opendir()。所谓“打开”目录,其含义很简单:目录需要提供一种机制,以便访问其中存储的文件列表,而 opendir 操作即用来授予对目录的访问权限。opendir 的参数只是一个目录引用。系统会检查调用程序的权限;如果没有阻止条件,就返回一个句柄,调用程序之后可以使用该句柄调用 readdir 操作。

在内部,opendir 可能需要分配一个状态结构,以便后续多次调用 readdir 时,能够在目录中维护当前遍历的位置。

读取目录(Reading Directories)

readdir 操作用于枚举目录内容。严格来说,并没有对应的 “WriteDir” 操作(创建文件 create 和创建目录 makedir 都属于向目录写入条目)。readdir 必须遍历目录结构,依次返回存储在目录中的各个名称/i-node 对,以及可能的其他附加信息。条目的返回顺序由底层数据结构决定。

如果文件系统用较复杂的数据结构(如 B 树)存储目录条目,那么在 opendir 时分配的状态就非常重要:文件系统会在多次 readdir 调用之间保留并更新该状态,以便每次都能继续读取下一个条目。没有 readdir,程序就无法遍历文件系统的层次结构。

基本文件系统操作小结(Basic File System Operation Summary)

本节所述的文件系统操作构成了任何文件系统的功能基础:

  1. 初始化 —— 在卷上创建一个空文件系统;
  2. 挂载/卸载 —— 将文件系统卷挂载到操作系统、并在卸载时保证卷的清洁和一致性;
  3. 创建文件和目录 —— 构建层次化的文件和目录结构;
  4. 读写数据 —— 在永久存储中存取文件内容;
  5. 删除/重命名 —— 管理文件和目录的生命周期;
  6. 读取/写入元数据 —— 查询和修改文件属性;
  7. opendir/readdir —— 打开目录并遍历其内容。

这组基本操作提供了文件系统所需的最低限度功能。

2.5 扩展文件系统操作(Extended File System Operations)

仅仅提供最基本的普通文件和目录特性的文件系统几乎谈不上有什么价值。有许多特性可以增强文件系统的能力。本节讨论一些对基本文件系统的扩展,以及现代文件系统所支持的一些更高级功能。我们在此仅作简要介绍,深入讨论留到后续章节。

许多文件系统实现了一种特性,称为符号链接。符号链接是在文件系统中创建一个命名实体,该实体仅仅引用另一个文件;也就是说,符号链接在目录中是一个命名实体,但其关联的 i-node 并不指向实际数据,而是保存了应当打开的另一个文件的名称。例如,如果某目录中包含一个名为 Feeder 的符号链接,且该链接指向名为 Breeder 的文件,那么每当程序打开 Feeder 时,文件系统会透明地将其转为打开 Breeder。由于这两文件之间的连接只是被引用文件名的简单文本字符串,因此这种连接很脆弱:也就是说,如果 Breeder 被重命名为 Breeder.old,则符号链接 Feeder 将变为悬挂(仍指向原名 Breeder),因而不再起作用。尽管存在此问题,符号链接仍极为方便。

另一种链接称为硬链接,也称别名。硬链接是一种与文件的更强关联。使用硬链接时,目录中的一个命名实体仅仅包含另一个文件的 i-node 编号,而非自己的 i-node(事实上,硬链接本身并不拥有 i-node)。这种连接之所以牢固,有几个原因:即使原文件被移动或重命名,其 i-node 地址保持不变,因此指向该文件的连接永远不会被破坏;即使原始文件被删除,文件系统也会维护引用计数,仅当计数为零(无人再引用该文件)时才真正删除文件。硬链接适用于必须确保与文件之间连接绝不断开的场合。

第三种链接——动态链接——其实只是具有特殊属性的符号链接。如前所述,符号链接包含对另一个文件的引用,并将该引用存储为文本字符串。动态链接通过解释该文本字符串增加了一层间接性。文件系统可用多种方式解释链接文本:一种方法是将字符串视为环境变量名,并用相应环境变量的内容替换链接文本;也可以采用其他更复杂的解释方式。动态链接使得同一符号链接可根据查看者的不同指向多个不同的文件。虽然功能强大,但动态链接也可能引发混淆,因为链接解析结果可能在没有任何显式用户操作的情况下改变。

文件内存映射(Memory Mapping of Files)

另一个一些操作系统支持的特性是将文件进行内存映射。文件内存映射会在程序的虚拟地址空间中创建一段区域,该区域内的每个字节都对应文件中的一个字节。如果程序将文件映射到地址 0x10000 开始处,则虚拟地址 0x10000 相当于文件中的偏移 0;同理,地址 0x10400 相当于文件中的偏移 0x400 (1024)。Unix 风格的 mmap() 调用可选择将内存中的文件副本同步到磁盘,以便在内存中写入的数据被刷新到磁盘。该调用还提供跨多个进程共享映射文件的标志(对共享信息非常有用)。

文件内存映射的实现需要操作系统的虚拟内存系统与文件系统紧密配合。主要要求是虚拟内存系统必须能够将文件偏移映射到相应的磁盘块;文件系统在代表虚拟内存(VM)系统执行操作时也可能面临其他限制。例如,在与内存映射文件相关的操作中,VM 系统可能无法容忍文件系统触发页面错误或内存分配请求(因为虚拟内存系统已被锁定)。此类约束和要求使得实现文件内存映射变得颇为棘手。

属性(Attributes)

若干现代文件系统(如 OS/2 的 HPFS、Windows NT 的 NTFS、SGI 的 XFS 以及 BeOS 的 BFS)都支持扩展文件属性。属性仅仅是一个名称(类似于文件名)和一些值(任意大小的数据块)。经常需要将附加信息与文件一起存储,但却无法(或不现实)修改文件内容。例如,当浏览器下载一幅图像时,可以将该图像的来源 URL 作为属性存储;几个月后若想回到最初获取该图像的网站,这将十分有用。属性提供了一种将额外信息与文件关联的方式。理想情况下,文件系统应允许任意数量的扩展属性,且属性大小不受限制。至于将属性信息存储在哪里,则取决于具体的文件系统:例如,HPFS 会为每个文件保留固定的 64 KB 区域来存放属性,而 BFS 与 NTFS 则更灵活,可将属性存放在磁盘的任意位置。

索引(Indexing)

文件属性让用户能够将额外信息关联到文件上,但若文件系统进一步为属性建立索引,就可以帮助用户更高效地管理和定位信息。若将属性纳入索引,就能对属性内容发起查询。例如,如果为一组文件添加了名为 “Keyword” 的属性,并为其建立了索引,用户就可以执行查询,找出所有包含特定关键字的文件,而无需关心它们在目录层次结构中的具体位置。配合良好的查询语言,索引为文件系统提供了一种强大的替代接口:用户无需局限于固定目录树的导航,而是可以发起查询来定位自己关心的工作文件集,无论它们存放于何处。

日志/记录(Journaling/Logging)

避免文件系统损坏是一项艰难的任务。一些文件系统费尽心力来防止损坏:它们可能尝试对磁盘写入操作进行顺序安排,以便在故障后能够恢复;或者将可能导致损坏的操作强制为同步执行,从而保证文件系统始终处于已知状态;还有些系统则回避此问题,依赖高度复杂的文件系统检查程序在崩溃后进行恢复。所有这些做法都需要在启动时对磁盘进行检查,这可能耗时甚长(尤其是当磁盘容量日益增大时)。此外,若在不合时宜的时刻发生崩溃,文件系统仍可能处于损坏状态。

一种更为现代的防损坏方法是使用日志(journaling)。日志技术源自数据库领域,它通过将一组更改打包并一次性提交到事务日志来避免损坏。批量操作保证了多项更改的原子性:要么全部成功,要么全部不生效。此外,若发生崩溃,只需重放事务日志即可将系统恢复到已知状态。重放日志通常只需几秒钟,远比非日志文件系统在启动时所需的一致性检查要快得多。

保证带宽/带宽预留(Guaranteed Bandwidth/Bandwidth Reservation)

多媒体应用对高带宽 I/O 的需求促使一些文件系统设计者提供特殊接口,使应用程序能够预留一定的 I/O 带宽(在硬件能力范围内)。要实现这一点,文件系统需要深入了解底层硬件的性能,以及对 I/O 请求进行调度。这一问题并不简单,仍是一个研究课题。

访问控制列表

访问控制列表(ACL)提供了一种扩展机制,用于指定谁可以以何种方式访问文件。传统的 POSIX 模型将权限分为三组——文件所有者、所有者所在组和其他人——在某些场景下并不够用。访问控制列表可以为任意用户或组精确地指定访问级别,使得相较于 POSIX 的粗粒度分类,文件访问控制更为细致。

2.6 小结(Summary)

本章介绍并解释了文件系统的基本概念、所执行的操作,以及文件系统可选择实现的额外功能。最基础的层面上,文件系统提供了一种以层次结构组织方式存储和检索数据的手段。文件系统的两个基本概念是文件和目录。

除基础功能外,文件系统还可以选择实现多种扩展特性,以便让用户更轻松地管理、导航和操作其信息。属性与索引是两种能显著增强功能的特性;日志技术用于保持文件系统一致性;带宽保证则是支持实时多媒体应用的可选方案。文件系统设计者在实现时必须做出诸多抉择:并非所有系统都需要或适合所有特性,系统限制、时间和资源等因素都会影响最终方案。