type
status
date
slug
summary
tags
category
icon
password
Property
Apr 15, 2023 03:22 PM
到目前为止,我们看到了两项关键操作系统技术的发展:
- 进程,它是虚拟化的 CPU;
- 地址空间,它是虚拟化的内存。
在这两种抽象共同作用下,程序运行时就好像它在自己的私有独立世界中一样,好像它有自己的处理器(或多处理器),好像它有自己的内存。这种假象使得对系统编程变得更容易,因此现在不仅在台式机和服务器上盛行,而且在所有可编程平台上越来越普遍,包括手机等在内。
在这一部分,我们加上虚拟化拼图中更关键的一块:
持久存储
(persistent storage)。永久存储设备永久地(或至少长时间地)存储信息,如传统硬盘驱动器(hard disk drive)或更现代的固态存储设备(solid-state storage device)。持久存储设备与内存不同。内存在断电时,其内容会丢失,而持久存储设备会保持这些数据不变。
关键问题:如何管理持久存储设备
操作系统应该如何管理持久存储设备?都需要哪些 API?实现有哪些重要方面?
先从总体上看看 API:你在与 UNIX 文件系统交互时会看到的接口。
文件和目录
随着时间的推移,存储虚拟化形成了两个关键的抽象:
文件(file)
文件就是一个
线性字节数组
,每个字节都可以读取或写入。每个文件都有某种低级名称
(low-level name),通常是某种数字。用户通常不知道这个名字(我们稍后会看到)。文件的低级名称通常称为 inode 号
(inode number)现在,只要假设每个文件都有一个与其关联的 inode 号。在大多数系统中,操作系统不太了解文件的结构(例如,它是图片、文本文件还是 C代码)。文件系统的责任仅仅是将这些数据永久存储在磁盘上,并确保当你再次请求数据时,得到你原来放在那里的内容。做到这一点并不像看起来那么简单!
目录(directory)
一个目录也有一个低级名字(即inode 号),但是它的内容非常具体:它包含一个
(用户可读名字,低级名字)对的列表
。例如,假设存在一个低级别名称为“10”的文件,它的用户可读的名称为“foo”。“foo”所在的目录因此会有条目(“foo”,“10”),将用户可读名称映射到低级名称。目录中的每个条目都指向文件或其他目录
。通过将目录放入其他目录中,用户可以构建任意的目录树
(directory tree,或目录层次结构,directory hierarchy),在该目录树下存储所有文件和目录。目录层次结构从
根目录
(root directory)开始(在基于 UNIX 的系统中,根目录就记为“/”),并使用某种分隔符
(separator)来命名后续子目录(sub-directories),直到命名所需的文件或目录。一个惯例(convention):文件名通常包含两部分以句点分隔,第一部分是任意名称,而文件名的第二部分通常用于指示文件的类型(type)
文件系统接口
创建文件
通过调用 open()并传入
O_CREAT
标志,程序可以创建一个新文件。函数 open()接受一些不同的标志。在本例中,程序创建文件(O_CREAT),只能写入该文件,因为以(O_WRONLY)这种方式打开,并且如果该文件已经存在,则首先将其截断为零字节大小,删除所有现有内容(O_TRUNC)。
补充:creat()系统调用 创建文件的旧方法是调用 creat(),如下所示:int fd = creat("foo");
你可以认为 creat()是 open()加上以下标志:O_CREAT | O_WRONLY | O_TRUNC。 因为 open()可以创建一个文件,所以 creat()的用法有些失宠(实际上,它可能就是实现为对 open()的一个库调用)。然而,它确实在 UNIX 知识中占有一席之地。特别是,有人曾问 Ken Thompson,如果他重新设计 UNIX,做法会有什么不同,他回答说:“我拼写 creat 时会加上 e”。
open()的一个重要方面是它的返回值:
文件描述符
(file descriptor)。文件描述符只是一个整数,是每个进程
私有
的,在 UNIX 系统中用于访问文件
。因此,一旦文件被打开,你就可以使用文件描述符来读取或写入文件,假定你有权这样做。一个文件描述符就是一种
权限
(capability),即一个不透明的句柄,它可以让你执行某些操作。另一种看待文件描述符的方法,是将它作为指向文件类型对象的指针。一旦你有这样的对象,就可以调用其他“方法”来访问文件。读写文件
strace 的作用就是跟踪程序在运行时所做的每个系统调用,然后将跟踪结果显示在屏幕上供你查看。
提示:使用 strace(和类似工具)
strace 工具提供了一种非常棒的方式,来查看程序在做什么。通过运行它,你可以跟踪程序生成的系统调用,查看参数和返回代码,通常可以很好地了解正在发生的事情。
该工具还接受一些非常有用的参数。例如,-f 跟踪所有 fork 的子进程,-t 报告每次调用的时间,-etrace=open,close,read,write 只跟踪对这些系统调用的调用,并忽略所有其他调用。还有许多更强大的标志,请阅读手册页,弄清楚如何利用这个奇妙的工具。
下面是一个例子,使用 strace 来找出 cat 在做什么(为了可读性删除了一些调用)。
- open 只读方式打开 foo 文件,使用 64 位偏移量(O_LARGEFILE,这个参数一般不由用户使用),返回文件描述符 3(每个正在运行的进程已经打开了 3 个文件:
标准输入 0
(进程可以读取以接收输入),标准输出 1
(进程可以写入以便将信息显示到屏幕),以及标准错误 2
(进程可以写入错误消息)。当你第一次打开另一个文件时(如上例所示),它几乎肯定是文件描述符 3)
- read 读取文件内容,第一参数为文件描述符,第二参数为用于存放 read()结果的缓冲区地址,这里 strace 直接用读取后的缓冲区内容字符串填充了,第三参数为缓冲区大小
- write 将缓冲区内容写入标准输出。对 write()系统调用的一次调用,针对文件描述符 1,此描述符被称为标准输出,因此用于将单词写到屏幕上。
- 再次尝试读取是否有剩余内容,没有的话read()返回 0,程序知道这意味着它已经读取了整个文件。因此,程序调用
close()
,传入相应的文件描述符,表明它已用完文件。该文件因此被关闭,对它的读取完成了。
读取和写入,但不按顺序
参数:
文件描述符
偏移量
:它将文件偏移量定位到文件中的特定位置。
whence
:明确地指定了搜索的执行方式,也就是偏移量参数 offset 的使用方式- If whence is
SEEK_SET
, the offset is set to offset bytes.(从起始位置偏移) - If whence is
SEEK_CUR
, the offset is set to its current location plus offset bytes.(从当前位置偏移) - If whence is
SEEK_END
, the offset is set to the size of the file plus offset bytes.(从结束位置开始的偏移,比如3
表示倒数第三字节)
用 fsync()立即写入
大多数情况下,当程序调用
write()
时,它只是告诉文件系统:请在将来的某个时刻,将此数据写入持久存储。出于性能的原因,文件系统会将这些写入在内存中缓冲
(buffer)一段时间。在稍后的时间点,写入将实际发送到存储设备。操作系统对于
write
系统掉用会先写入缓存
,等待一段时间再写入磁盘,如果需要立即写入,需要调用 fsync
系统调用强制写入所有脏数据
,该调用会阻塞直到操作完成可以理解为 write 是个
异步过程
,需要 fsync 转换为同步过程
以下是如何使用 fsync()的简单示例。代码打开文件 foo,向它写入一个数据块,然后调用 fsync()以确保立即强制写入磁盘。一旦 fsync()返回,应用程序就可以安全地继续前进,知道数据已被保存(如果 fsync()实现正确,那就是了)。
以上代码存在问题,调用 fsync() 不一定确保包含文件的
目录
中的条目也已到达磁盘
。为此,还需要在目录的文件描述符上使用显式的 fsync()
。文件重命名
在命令行键入时,这是通过mv 命令完成的。在下面的例子中,文件 foo 被重命名为 bar。
mv 使用了系统调用
rename(char * old, char * new)
它只需要两个参数:文件的
原来名称
(old)和新名称
(new)。rename()调用提供了一个有趣的保证:它(通常)是一个
原子
(atomic)调用,不论系统是否崩溃。如果系统在重命名期间崩溃,文件将被命名为旧名称或新名称,不会出现奇怪的中间状态。因此,对于支持某些需要对文件状态进行原子更新的应用程序,rename()非常重要。假如你正在使用文件编辑器(例如 emacs),并将一行插入到文件的中间。例如,该文件的名称是 foo.txt。编辑器更新文件并确保新文件包含原有内容和插入行的方式如下:
在这个例子中,编辑器做的事很简单:将文件的新版本写入
临时名称(foot.txt.tmp)
,使用 fsync()将其强制
写入磁盘。然后,当应用程序确定新文件的元数据和内容在磁盘上,就将临时文件重命名为原有文件的名称。最后一步自动将新文件交换到位,同时删除旧版本的文件,从而实现原子文件更新。获取文件信息
除了文件访问之外,我们还希望文件系统能够保存关于它正在存储的每个文件的大量信息。我们通常将这些数据称为
文件元数据
(metadata)。要查看特定文件的元数据,我们可以使用
stat()
或 fstat()
系统调用。这些调用将一个路径名(或文件描述符)添加到一个文件中,并填充一个 stat 结构,如下所示:你可以看到有关于每个文件的大量信息,包括其大小(以字节为单位),其低级名称(即inode 号),一些
所有权信息
以及有关何时
文件被访问或修改的一些信息,等等。要查看此信息,可以使用命令行工具 stat:
每个文件系统通常将这种类型的信息保存在一个名为
inode
(一些文件系统中,这些结构名称相似但略有不同,如 dnodes。但基本的想法是相似的)的结构中。删除文件
UNIX,只需运行程序 rm
unlink()
只需要待删除文件的名称
,并在成功时返回零。创建目录
要创建目录,可以用系统调用
mkdir()
。同名的 mkdir
程序可以用来创建这样一个目录空目录
有两个条目:一个引用自身
的条目(.
),一个引用其父目录
的条目(..
)读取目录
读取目录是
ls 程序
做的事不是像打开文件一样打开一个目录,而是使用一组新的调用。该程序使用了
opendir()
、readdir()
和 closedir()
这 3 个调用来完成工作,你可以看到接口有多简单。我们只需使用一个简单的循环就可以一次读取一个目录条目,并打印目录中每个文件的名称和 inode 编号。下面的声明在
struct dirent
数据结构中,展示了每个目录条目中可用的信息:由于目录只有少量的信息(基本上,只是将名称映射到 inode 号,以及少量其他细节),程序可能需要在每个文件上调用 stat()以获取每个文件的更多信息,例如其长度或其他详细信息。
删除目录
你可以通过调用
rmdir()
来删除目录(它由相同名称的程序 rmdir
使用)。rmdir()要求该目录在被删除之前是
空
的(只有“.”和“..”条目)。如果你试图删除一个非空目录,那么对 rmdir()的调用就会失败。硬链接
link()
系统调用有两个参数:一个旧路径名
和一个新路径名
。当你将一个新的文件名“链接”到一个旧的文件名时,你实际上创建了另一种引用
同一个文件的方法。命令行程序 ln
用于执行此操作link 只是在要创建链接的目录中创建了
另一个名称
,并将其指向原有文件的相同 inode号
(即低级别名称)。该文件不以任何方式复制。相反,你现在就有了两个人类可读的名称(file 和 file2),都指向同一个文件
。通过打印每个文件的 inode 号,我们甚至可以在目录中看到这一点。通过带-i 标志的 ls,它会打印出每个文件的 inode 编号(以及文件名)。因此,你可以看到实际上已完成的链接:只是对同一个 inode 号(本例中为 67158084)创建了新的引用。
现在,你可能已经开始明白 unlink()名称的由来。创建一个文件时,实际上做了两件事:
- 首先,要构建一个结构(inode),它将跟踪几乎所有关于文件的信息,包括其大小、文件块 在磁盘上的位置等等。
- 将人类可读的名称链接到该文件,并将该链接放入目录中。
在创建文件的硬链接之后,在文件系统中,原有文件名(file)和新创建的文件名(file2)之间没有区别。实际上,它们都只是指向文件底层元数据的链接。因此,为了从文件系统中删除一个文件,我们调用
unlink()
。在上面的例子中,我们可以删除文件名 file,并且仍然毫无困难地访问该文件:
这样的结果是因为当文件系统取消链接文件时,它检查 inode 号中的
引用计数
(reference count)。该引用计数(有时称为链接计数,link count)允许文件系统跟踪有多少不同的文件名已链接到这个 inode。调用 unlink()时,会删除人类可读的名称(正在删除的文件)与给定inode 号之间的“链接”,并减少引用计数。只有
当引用计数达到零
时,文件系统才会释放inode 和相关数据块,从而真正“删除”该文件。可以使用
stat()
来查看文件的引用计数:事实表明,硬链接有点局限:
- 你不能创建目录的硬链接(因为担心会在目录树中创建一个环)
- 你不能硬链接到其他磁盘分区中的文件(因为 inode 号在特定文件系统中是唯一的,而不是跨文件系统)
符号链接
符号链接
(symbolic link),有时称为软链接
(soft link)。
要创建这样的链接,可以使用相同的程序 ln
,但使用-s
标志符号链接
本身实际上是一个不同类型的文件
。符号链接是文件系统知道的除常规文件和目录外第三种类型
。使用
ls -al
打印详细信息,行开头的 d
表示目录,-
表示普通文件,l
表示符号链接文件日期前的数字表示文件长度,符号链接文件的
大小
就是链接到的目标文件的文件名长度
,可能会包含路径alongerfilename 文件名长度为 15,那符号链接文件的
大小
就是 15 个字节目标文件不存在或被删除时就会造成
悬空引用
创建并挂载文件系统
如何从许多底层文件系统组建完整的目录树:先制作文件系统,然后挂载它们,使其内容可以访问。
用
mkfs
在磁盘分区
上创建特定类型
的空文件系统:作为输入,为该工具提供一个设备(例如磁盘分区,例如/dev/sda1),一种文件系统类型(例如 ext3),它就在该磁盘分区上写入一个空文件系统,从根目录开始。再用
mount
挂载:mount 程序实现的(它使底层系统调用 mount()
完成实际工作)。mount 的作用很简单:以现有目录作为目标挂载点(mount point),本质上是将新的文件系统粘贴到目录树的这个点上,相当于把该文件系统挂载到系统目录树的某个位置。mount
将所有文件系统统一到一棵树中,而不是拥有多个独立的文件系统,这让命名统一而且方便ext3(标准的基于磁盘的文件系统)、proc 文件系统(用于访问当前进程信息的文件系统)、tmpfs(仅用于临时文件的文件系统) 和 AFS(分布式文件系统)
小结
多读书,多实践
参考
上一篇
《Operating System:Three Easy Pieces》第四十章 文件系统实现
下一篇
《Operating System:Three Easy Pieces》第三十八章 廉价冗余磁盘阵列(RAID)
- 作者:GJJ
- 链接:https://blog.gaojj.cn/article/blog-71
- 声明:本文采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处。