Lazy loaded image
操作系统
🫑文件和目录
Words 4329Read Time 11 min
2025-4-6
2025-4-6
type
status
date
slug
summary
tags
category
icon
password
类型
标签
状态
notion image
我们已知:进程是虚拟化调度CPU;地址空间是虚拟化的内存。在这两种抽象的共同作用下,程序运行时就好像它在自己的私有空间中,它有自己的处理器和内存。这种假象使得对系统编程变得更容易,因此现在不仅在台式机和服务器上盛行,而且在所有可编程平台上越来越普遍。
现在我们加上虚拟化拼图中更关键的一块:持久存储(persistent storage)。永久存储设备永久地存储信息,如传统硬盘驱动器(hard disk drive)或固态存储设备(solid-state storage device)。持久存储于内存不同。内存在断电时,其内容会丢失,而持久存储设备会保持这些数据不变。
如何管理持久性设备? 操作系统应该如何管理持久存储设备?都需要哪些 API?实现有哪些重要方面?

文件和目录

存储虚拟化形成了两个关键的抽象:
  1. 文件(file)
    1. 文件是一个线性字节数组,每个字节可用读取或者写入。每个文件都有某种低级名称(low-level name),通常是某种数字。用户通常不知道这个名字。文件的低级名称通常称为indode号(inode number)。
      在大多操作系统中,OS不太了解文件的结构。相反文件系统的责任仅仅是将这些数据永久地存储在磁盘上,并确保当再次请求数据时,得到原来放在那里的内容。
  1. 目录(directory) 一个目录,和文件一样,,也有一个低级名字(inode 号),但是它的内容非常具体:它包含一个(用户可读名字,低级名字)对的列表。例如,假设存在一个低级别名称为“10”的文件,它的用户可读的名称为“foo”。“foo”所在的目录因此会有条目(“foo”,“10”),将用户可读名称映射到低级名称。
    1. 目录中的每个条目都指向文件或其他目录。通过将目录放入其他目录中,用户可以构建任意的目录树(directory tree,或目录层次结构,directory hierarchy),在该目录树下存储所有文件和目录。
      目录层次结构从根目录(root directory)开始(在基于 UNIX 的系统中,根目录就记为“/”),并使用某种分隔符(separator)来命名后续子目录(sub-directories),直到命名所需的文件或目录。
文件名通常包含两部分:bar 和 txt, 以英文句号分割。第一部分是任意名称,而文件名的第二部分通常用于指示文件的类型(type)。
综上,文件系统提供了一种方便的方式来命名我们感兴趣的所有文件。名称在系统中很重要,因为访问任何资源的第一步是能够命名它。

创建文件

创建一个文件。这可以通过 open 系统调用完成。通过调用 open()并传入 O_CREAT 标志,程序可以创建一个新文件。
函数 open()接受一些不同的标志。在上例中,程序创建文件(O_CREAT),只能写入该文件,因为以(O_WRONLY)这种方式打开,并且如果该文件已经存在,则首先将其截断为零字节大小,删除所有现有内容(O_TRUNC)
💡
creat()系统调用
创建文件的旧方法是调用create():
creat()是 open()加上以下标志:O_CREAT | O_WRONLY | O_TRUNC。
open()的一个重要方面是它的返回值:文件描述符(file description)。文件描述符只是一个整数,是每个进程私有的,在Unix系统中用于访问文件。

读写文件

在命令行下,我们可以用cat程序将文件的内容显示到屏幕。然后我们可以使用strace工具来弄清楚cat程序是如何访问文件foo的。
💡
strace 工具
strace 工具提供了一种非常棒的方式,来查看程序在做什么。通过运行它,你可以跟踪程序生成的系统调用,查看参数和返回代码,通常可以很好地了解正在发生的事情。 该工具还接受一些非常有用的参数。例如,-f 跟踪所有 fork 的子进程,-t 报告每次调用时间,-etrace=open,close,read,write 只跟踪对这些系统调用的调用,并忽略所有其他调用。

不按顺序读取和写入

上面我们以及了解了如何顺序访问文件,但是有时候能够读取或写入文件中的特定偏移量是有用的。例如,如果你在文本文件上构建了索引并利用它来查找特定单词,最终可能会从文件中的某些随机(random)偏移量中读取数据。为此我们使用 lseek() 系统调用:
第一个参数是一个文件描述符;第二个参数是偏移量,它将文件偏移量定位到文件中的特定位置;第三个参数明确地指定了搜索的方式。对于每个进程打开的文件,操作系统都会跟踪一个”当前“偏移量,这将决定在文件中读取或者写入时,下一次读取或写入开始的位置。
lseek()调用只是在 OS 内存中更改一个变量,该变量跟踪特定进程的下一个读取或写入开始的偏移量。如果发送到磁盘的读取或写入与最后一次读取或写入不在同一磁道上,就会发生磁盘寻道, 因此需要磁头移动。调用 lseek()肯定会导致在即将进行的读取或写入中进行搜索,但绝对不会导致任何磁盘 I/O 自动发生。

fsync()直接写入

大多数情况下,当程序调用write()时,它只告诉文件系统:在将来的某个时刻,将此数据写入持久存储。出于性能的原因,文件系统会将这些写入在内存中缓冲(buffer)一段时间。在稍后的时间点,写入将实际发送到存储设备。
在 UNIX中,提供给应用程序的接口被称为 fsync(int fd)。当进程针对特定文件描述符调用 fsync()时,文件系统通过强制将所有脏(dirty)数据(即尚未写入的)写入磁盘来响应,针对指定文件描述符引用的文件。一旦所有这些写入完成,fsync()例程就会返回。
以下代码打开文件foo,写入一个数据块,然后调用fsync()确保立即强制写入磁盘。

文件重命名

有了一个文件后,有时需要给一个文件一个不同的名字。在命令行键入时,这是通过mv 命令完成的。在下面的例子中,文件 foo 被重命名为 bar。
mv 使用了系统调用rename(char * old, char *new),他需要两个参数:文件原来的名称(old)和新名称(new)。rename()调用提供了一个有趣的保证:它(通常)是一个原子(atomic)调用,不论系 统是否崩溃。如果系统在重命名期间崩溃,文件将被命名为旧名称或新名称,不会出现奇怪的中间状态。

获取文件信息

文件系统能够保存关于它正在存储的每个文件的大量信息。我们通常将这些数据称为文件元数据(metadata)。要查看特定文件的元数据,我们可以使用 stat()或 fstat()系统调用。这些调用将一个路径名(或文件描述符)添加到一个文件中,并填充一个 stat 结构,如下所示:
每个文件系统通常将这种类型的信息保持在一个名为inode 的结构中,我们可将inode看作是由文件系统保存的持久数据结构。

删除文件

只需要运行程序rm,但是rm使用什么系统调用来删除文件?
unlink()只需要待删除文件的名称,并在成功时返回零。但这引出了一个很大的疑问:为什么这个系统调用名为“unlink”?为什么不就是“remove”或“delete”?要理解这个问题的答案,我们不仅要先了解文件,还有目录。

创建目录

除了文件外,还可以使用一组与目录相关的系统调用来创建、读取和删除目录。目录是不可能被直接写入的。因为目录的格式被视为文件系统元数据,所有只能通过间接更新目录,例如,通过在其中创建文件、目录或其他对象类型。通过这种方式,文件系统可以确保目录的内容始终符合预期
要创建目录,可以用系统调用mkdir()。同时的吗,mkdir 程序可以用来创建一个目录。
💡
一些 rm 相关命令
prompt > rm *
其中*将匹配当前目录中的是所有文件。当但有时你也想删除目录,实际上包括它们的所有内容。你可以告诉 rm 以递归方式进入每个目录并删除其内容,从而完成此操作:
prompt> rm - rf *
这样的目录创建时,它被认为是“空的”,尽管它实际上包含最少的内容。具体来说,空目录有两个条目:一个引用自身的条目,一个引用其父目录的条目。前者称为“.”(点)目录,后者称为“..”(点-点)目录。

读取目录

通过 ls 来读取目录,它不是像打开文件一样打开一个目录,而是使用一组新的调用。由于目录只有少量的信息(基本上,只是将名称映射到 inode 号,以及少量其他细节),程序可能需要在每个文件上调用 stat()以获取每个文件的更多信息,例如其长度或其他详细信息。实际上,这正是 ls 带有-l 标志时所做的事情。

删除目录

通过调用 rmdir()来删除目录(它由相同名称的程序 rmdir 使用)。然而,与删除文件不同,删除目录更加危险,因为你可以使用单个命令删除大量数据。因此,rmdir()要求该目录在被删除之前是空的(只有“.”和“..”条目)。如果你试图删除一个非空目录,那么对 rmdir()的调用就会失败。

硬链接

在文件系统数中创建条目的新方法,即通过所谓的link()系统调用。link() 系统调用有两个参数:一个旧路径名和一个新路径名。当将一个新的文件名“链接”到一个旧的文件名时,实际上创建了另一种引用同一个文件的方法。命令行程序ln用于执行此操作:
通过带-i 标志的 ls,它会打印出每个文件的 inode 编号(以及文件名)。
创建一个文件时,首先构建了一个结构(inode),它将跟踪几乎所有关于文件的信息,包括其大小、文件块在磁盘上的位置等等。其次,将人类可读的名称链接到该文件,并将该链接放入目录中。创建文件的硬链接之后,在文件系统中,原有文件名(file)和新创建的文件名(file2)之间没有区别。实际上,它们都只是指向文件底层元数据的链接。
调用 unlink()时,会删除人类可读的名称(正在删除的文件)与给定inode 号之间的“链接”,并减少引用计数。只有当引用计数达到零时,文件系统才会释放inode 和相关数据块,从而真正“删除”该文件。

符号链接(软链接)

为了处理硬链接的局限性:不能创建目录的硬链接(防止在目录树中创建一个环)。不能硬链接到其他磁盘分区中的文件(因为 inode 号在特定文件系统中是唯一的,而不是跨文件系统)因此,人们创建了一种称为符号链接的新型链接。同样使用 ln 创建这样的链接,但是使用 -s 标志。
符号链接与硬链接的区别:
  1. 符号链接本身实际上是一个不同类型的文件。它是文件系统知道的第三种类型。
  1. 由于创建符号链接的方式,可能造成悬空引用(dangling reference)。
符号链接与硬链接完全不同,删除名为 file 的原始文件会导致符号链接指向不再存在的路径名。

创建并挂载文件系统

为了解决如何从许多底层文件系统组建完整的目录树的问题,我们先制作文件系统,然后挂载它们,使其内容可以访问。
为了创建一个文件系统,大多数文件系统提供了一个工具,通常名为 mkfs(发音为“makefs”),它就是完成这个任务的。思路如下:作为输入,为该工具提供一个设备(例如磁盘分区,例如/dev/sda1),一种文件系统类型(例如 ext3),它就在该磁盘分区上写入一个空文件系统,从根目录开始。
一旦创建了这样的文件系统,就需要在统一的文件系统树中进行访问。这个任务是通过 mount 程序实现的:以现有的目录作为目标挂载点,本质上是将新的文件系统粘贴到目录树的这个点上。想象一下,我们有一个未挂载的 ext3 文件系统,存储在设备分区/dev/sda1 中,它的内容包括:一个根目录,其中包含两个子目录 a 和 b,每个子目录依次包含一个名为 foo 的文件。假设希望在挂载点/home/users 上挂载此文件系统。我们会输入以下命令:
如果成功,mount 就让这个新的文件系统可用了。但是,请注意现在如何访问新的文件系统。要查看那个根目录的内容,我们将这样使用 ls:
路径名/home/users/现在指的是新挂载目录的根。同样,我们可以使用路径名/home/users/a 和/home/users/b 访问文件 a 和 b。最后,可以通过/home/users/a/foo 和/home/users/b/foo 访问名为 foo 的两个文件。要查看系统上挂载的内容,以及在哪些位置挂载,只要运行 mount 程序。
 
上一篇
文件系统实现
下一篇
虚拟内存