本章要解决什么

如何实现一个简单的文件系统?磁盘上需要哪些结构?它们要记录什么?系统调用又是怎样访问它们的?

How can we build a simple file system? What structures are needed on the disk? What do they need to track? How are they accessed?

上一章我们站在用户这一边,学会了 open/read/write/link/unlink 这些接口。这一章翻到接口的另一面:用一个极简的文件系统 vsfs(Very Simple File System),看看"文件""目录""inode 号"在磁盘上到底是什么样子,一次 open("/foo/bar") 会引发多少次磁盘读写。

文件系统是纯软件:和 CPU、内存虚拟化不同,这里不需要新的硬件支持。正因为自由度大,才有了从 AFS 到 ZFS 的各种设计。vsfs 是一个简化版的 UNIX 文件系统,后面几章(FFS、日志、LFS)都是在它的基础上改进"性能"或"崩溃一致性"。所以这一章是整个持久化部分的地基——请把本页的两个模拟器都亲手玩一遍。

核心概念速览

两个角度:数据结构 + 访问方法

磁盘上放了什么结构?每个系统调用读写了哪些结构?两个问题都答得上来,就有了文件系统的心智模型。

超级块(superblock)

记录整个文件系统的参数:多少 inode、多少数据块、inode 表从哪开始、魔数。挂载时第一个读它。

位图(bitmap)

inode 位图 + 数据位图,每一位表示对应的 inode / 数据块是空闲(0)还是已用(1)。

inode 与 i-number

inode 存文件的元数据和数据块位置;i-number 就是它在 inode 表里的下标,可以直接算出它在哪个扇区。

多级索引(multi-level index)

少量直接指针照顾小文件,间接 / 双重间接 / 三重间接指针让文件可以长到 GB、TB 级。

目录 = 特殊的文件

目录的数据块里存一串 (名字, inode 号) 记录:inum | reclen | strlen | name。

访问路径(access path)

打开文件要从根目录逐级读 inode 和目录数据;I/O 次数与路径长度成正比。创建文件还要改位图和目录。

缓存与写缓冲

页缓存让重复的读几乎不碰磁盘;写缓冲把多次写合并、推迟,换来性能,代价是崩溃时可能丢数据。

思考文件系统的方式

原书建议从两个方面理解任何一个文件系统:

  1. 数据结构(data structures):磁盘上用什么结构组织数据和元数据?vsfs 用最简单的"块数组";更复杂的文件系统(如 SGI XFS)用 B 树之类的树形结构。
  2. 访问方法(access methods):进程调用 open()、read()、write() 时,文件系统要读哪些结构、写哪些结构?效率如何?

学文件系统,真正要建立的是心智模型(mental model):磁盘上有哪些结构保存数据与元数据?进程打开一个文件时发生了什么?一次读、一次写各访问了哪些结构?能回答这些,你就理解了它;只会背某个文件系统的代码细节反而是次要的。本章后面的两个模拟器正是分别针对"数据结构"和"访问方法"设计的。

整体组织:64 个块的 vsfs

先把磁盘分区切成大小相同的块(block),vsfs 选常用的 4KB。假设分区非常小,只有 64 个块(编号 0–63)。接下来一步步决定这些块里放什么——点下面的按钮,跟着原书的推理顺序把布局"搭"出来;点任意一个块可以查看它的用途。

S 超级块i inode 位图d 数据位图I inode 表D 数据区
点击一个块查看说明。
vsfs 的磁盘布局(对应原书 40.2 节的一系列图):块 0 超级块,块 1 inode 位图,块 2 数据位图,块 3–7 inode 表,块 8–63 数据区

推理顺序是这样的:

inode 位图(块 1)的前 16 位 ↔ inode 表中的前 16 个 inode 位图 inode 1 = 已分配(inode 里有内容),0 = 空闲。创建文件时扫描位图找第一个 0,把它置 1 再写回磁盘。
位图:第 k 位描述第 k 个对象。inode 位图对应 inode 表,数据位图对应数据区

inode:文件的"档案卡"

inode 是 index node(索引节点)的缩写:最早的 UNIX 把这些节点存成数组,用编号去索引它。每个 inode 由一个数字隐式标识,叫 i-number,也就是上一章说的文件的低级名字(low-level name)。在 vsfs 这种简单的文件系统里,给定 i-number,就能直接算出它在磁盘上的位置:

blk    = (inumber * sizeof(inode_t)) / blockSize;              // 第几个 inode 块
sector = ((blk * blockSize) + inodeStartAddr) / sectorSize;   // 该块的起始扇区

原书的例子:inode 表从 12KB 开始(超级块 0KB、inode 位图 4KB、数据位图 8KB),每个 inode 256 字节。要读 inode 32:表内偏移 32 × 256 = 8192,加上起始地址 12KB 得到 20KB;磁盘不能按字节寻址,只能按 512 字节的扇区读,所以发出读扇区 20 × 1024 / 512 = 40 的请求,把包含 inode 32 的整块(iblock 2,也就是块 5)读进来。自己换几个数试试:

计算器

inode 定位:给定 i-number,算出块号与扇区号

改 inode 号、inode 大小或 inode 表起始地址,观察它落在哪个 inode 块、哪个扇区。红框是目标 inode,蓝色是需要整块读入的那个块。

inode 里存着关于文件的几乎一切:类型(普通文件、目录……)、大小、分配了多少块、权限与属主、各种时间,以及数据块在哪里。这些统称为元数据(metadata)——文件系统里凡不是纯用户数据的信息都叫元数据。下面是简化的 ext2 inode(原书 Figure 40.1):

字节字段用途
2mode能否读 / 写 / 执行
2uid属主是谁
4size文件有多少字节
4time最后访问时间
4ctimeinode 最后被修改的时间
4mtime文件内容最后修改时间
4dtimeinode 被删除的时间
2gid属组
2links_count有多少个硬链接指向它(上一章的引用计数)
4blocks分配了多少块
4flagsext2 如何使用这个 inode
4osd1与操作系统相关的字段
60block15 个磁盘指针(下一节的重点)
4generation文件版本(NFS 用)
4file_acl超越 mode 位的新权限模型
4dir_acl即访问控制列表(ACL)

注意:inode 里没有文件名。名字存在目录里(见后文);ext2 甚至把文件类型也记在目录项里。这正是硬链接能成立的原因——多个名字可以指向同一个 inode。

多级索引:inode 怎样指向数据块

inode 设计里最重要的决定之一:怎样记录数据块的位置。最简单的办法是放若干个直接指针(direct pointer),每个指针是一个磁盘块地址。但这样文件最大只能是"块大小 × 直接指针数"——12 个直接指针 × 4KB = 48KB,显然不够。

于是引入间接指针(indirect pointer):它不指向用户数据,而是指向一个装满指针的块。4KB 的块、4 字节的地址,一个间接块能装 1024 个指针,文件可以长到 (12 + 1024) × 4KB = 4144KB。还不够?再加双重间接指针(double indirect pointer):它指向一个"装满间接块指针"的块,又多出 1024 × 1024 ≈ 100 万个块,文件可以超过 4GB:(12 + 1024 + 1024²) × 4KB。再往上是三重间接指针。这棵"一边倒"的不平衡树就叫多级索引(multi-level index)。

计算器

多级索引:最大文件大小 + 第 k 块走哪条路

改块大小、指针大小、直接指针个数和间接层数,看最大文件大小怎样变化;输入文件的第 k 个块,看文件系统要沿哪条指针链找到它、额外要读几个索引块。

为什么要用这么不平衡的树?因为几十年的测量一再发现同样的"事实"(原书 Figure 40.2,来自 Agrawal 等人的研究):

规律具体数字
大多数文件很小最常见的大小约 2KB
平均文件大小在增长平均接近 200KB
大部分字节存在大文件里少数大文件占了大部分空间
文件系统里文件很多平均约 10 万个
文件系统大约半满即使磁盘变大,也保持 ~50% 使用率
目录通常很小多数目录项很少,大多 ≤ 20 项

既然大多数文件很小,就为小文件优化:12 个直接指针让 inode 直接覆盖 48KB,读小文件不需要任何额外的索引块;只有大文件才付出读间接块的代价。ext2、ext3、NetApp WAFL 和原始 UNIX 文件系统都采用多级索引。

另一种做法是用 extent(区段):一个磁盘指针 + 一个长度(以块计)。不需要为每个块存一个指针,一个 extent 就能描述一大段连续的块。只允许一个 extent 太受限(很难总找到足够大的连续空闲空间),所以基于 extent 的文件系统通常允许多个 extent。

比较:指针方式最灵活,但每个文件的元数据很多(尤其是大文件);extent 方式不那么灵活但更紧凑,在空闲空间充足、文件能连续存放时效果很好。SGI XFS 和 Linux ext4 用 extent——本章实验里的 fiemap 程序会让你亲眼看到 ext4 文件的 extent。extent 之于指针,就像虚拟内存里的分段之于分页。

更简单的设计:inode 里只放一个指针指向文件第一块,每个数据块末尾再放指向下一块的指针——链表。问题是读文件最后一块、或者随机访问,都要把前面的块挨个读一遍。

改进:把"下一块"指针从数据块里拿出来,集中成一张内存中的表,按数据块地址 D 索引,表项是 D 的下一块(或文件结束 / 空闲标记)。这样随机访问只需先在内存里沿表走,再直接去磁盘读目标块。这张表就是 文件分配表(FAT),也就是经典 Windows FAT 文件系统的核心。FAT 没有 inode,目录项里直接存元数据和第一块的地址——所以 FAT 上无法创建硬链接。

目录项:foo → 首块 4 FAT(内存中): 2:EOF 3:free 4:7 5:free 6:2 7:9 8:free 9:EOF foo 的块链:4 → 7 → 9 → EOF。 要读 foo 的第 3 块:先在内存里查 FAT[4]=7、FAT[7]=9,再直接读磁盘块 9。

目录的存储

在 vsfs 里(很多文件系统也一样),目录的组织非常简单:它的数据块里就是一串 (名字, inode 号)。名字可变长,所以每项还带长度。原书的例子:目录 dir(inode 5)里有 foo、bar、foobar_is_a_pretty_longname 三个文件,inode 号分别为 12、13、24。

模拟器

目录数据块:删除留下空洞,新名字复用旧记录

先点某行的"删除",再创建一个名字:新项会复用第一个"足够大"的空闲记录(reclen 不变);找不到才追加到末尾。观察偏移量与 reclen。本模拟器用 reclen = 8 字节头 + 名字按 4 字节对齐,与原书示例的数字一致。

目录存在哪里?文件系统通常把目录当作一种特殊的文件:它也有一个 inode(类型字段标为"目录"),inode 指向若干数据块(也可以有间接块),这些数据块就放在普通的数据区里。所以磁盘布局不需要任何改变。线性列表也不是唯一选择:XFS 把目录存成 B 树,创建文件时检查"名字是否已存在"就不必扫描整个列表。

空闲空间管理

文件系统必须知道哪些 inode 和数据块是空闲的,分配新文件 / 新目录时才有地方放。vsfs 用两个位图:

位图只是一种办法。一些早期文件系统用空闲链表(free list):超级块里一个指针指向第一个空闲块,那个块里存着下一个空闲块的指针,依此类推;要用块时取表头并更新链表。现代文件系统用更复杂的结构,例如 XFS 用某种 B 树紧凑地表示哪些区段空闲。和所有数据结构一样,这是时间与空间的取舍。

模拟器:仿 vsfs.py 观察磁盘状态的变化

下面的模拟器复现了原书作业工具 vsfs.py 的模型:一个只有 8 个 inode、8 个数据块的小文件系统,开始时只有根目录(inode 0,数据块 0)。每执行一个操作,就能看到 inode 位图、inode 表、数据位图、数据块发生了什么变化(黄框 = 本步改动,红色虚线 = 本步释放)。显示格式与 vsfs.py 相同:

模拟器

vsfs:mkdir / creat / write / link / unlink 如何改变磁盘

在文本框里写操作序列(每行一个),或随机生成;单步执行,看每一步改了哪几个位图位、哪几个 inode、哪几个数据块。勾选"隐藏操作"就变成 vsfs.py 的默认玩法:只看状态变化,猜是什么操作。

语法:mkdir /g、creat /g/h、write /g/h(追加一个块)、link /g/h /u、unlink /u;也接受 vsfs.py 的输出格式,如 mkdir("/g");。

vsfs.py 格式的文本输出 & 目录树

玩的时候盯住三件事:① creat 只动 inode 位图、inode 和父目录,不分配数据块(空文件没有数据);② link 两个位图都不动,只在目录里多一项、引用计数 +1;③ unlink 只有在引用计数降到 0 时才真正释放 inode 和数据块。再观察一下 mkdir:父目录的引用计数为什么也会 +1?

访问路径:读和写一个文件时发生了什么

知道了磁盘上有什么,接下来追踪系统调用的访问路径(access path)。假设文件系统已经挂载,超级块已在内存中,其他一切(inode、目录)都还在磁盘上。

读文件 /foo/bar

执行 open("/foo/bar", O_RDONLY) 时,文件系统先要找到 bar 的 inode,但它手里只有路径名,所以必须遍历路径(traverse the pathname):

  1. 一切遍历从根目录开始。根的 inode 号必须是"众所周知"的(根没有父目录可查),大多数 UNIX 文件系统里是 2。读入包含 inode 2 的块(第一个 inode 块)。
  2. 从根 inode 里的指针找到根目录的数据块,读进来,在里面找 foo 这一项,得到 foo 的 inode 号(比如 44)。
  3. 递归:读 foo 的 inode,再读 foo 的目录数据,找到 bar 的 inode 号。
  4. 最后把 bar 的 inode 读进内存,做权限检查,在进程的打开文件表里分配一个文件描述符返回给用户。

之后每次 read():查 inode 找到下一块的地址 → 读数据块 → 更新 inode 的最后访问时间(一次写),并更新内存中打开文件表里的偏移。close() 只需释放文件描述符,不产生磁盘 I/O。

很多同学以为读文件时也要查位图。不会!位图这类分配结构只在需要分配时才访问。inode、目录、间接块已经包含了完成读请求所需的全部信息——inode 已经指向这个块了,没必要再去确认它"是否已分配"。

创建并写文件 /foo/bar

写文件可能需要分配新块(除非是覆盖写),于是每次写不仅要写数据,还要决定用哪个块,并更新相应结构。一次分配型写入逻辑上产生 5 次 I/O:读数据位图、写数据位图、读 inode、写 inode(记录新块地址)、写数据块本身。

创建文件更贵:要分配 inode,还要在目录里分配空间。读 inode 位图、写 inode 位图、写新 inode(初始化)、写目录数据(名字 → inode 号)、读写目录 inode(更新大小和时间)……再加上路径遍历,原书例子里一次 create 就是 10 次 I/O。如果目录需要变大,还得再分配数据块(数据位图 + 新目录块)。

下面的模拟器逐行复现原书 Figure 40.3(读)和 Figure 40.4(创建 + 三次写)的时间线表格,时间向下增长。你还可以改变路径深度、调用次数,或者打开"页缓存"看看 I/O 数量怎样骤减。

模拟器

I/O 时间线:open / read / create / write 各读写了哪些块

每一行是一次磁盘 I/O(或缓存命中 / 被缓冲的写)。单步执行,看当前这一次访问的是哪个结构、在磁盘布局里的哪个块、为什么需要它。底部统计总 I/O 数。

上图是 64 块的 vsfs 布局(紫 = 超级块,黄 = 位图,蓝 = inode 表)。本模拟器假设:根 = inode 2(块 3),foo = inode 44(块 5),x = 20(块 4),y = 52(块 6),bar = 67(块 7);目录数据依次在块 8–11,bar 的数据从块 12 开始。

两个结论值得记住:

原书脚注里的"小谜题":创建 bar 时为什么要先读它还没初始化的 inode?因为 inode 很小(256B),而磁盘按块(4KB)读写,同一块里还有别的 inode。要更新其中一个,只能先读整块、改其中一部分、再写回——读-改-写(read-modify-write)。

如何降低文件系统的 I/O 成本?即便是打开、读、写文件这样最简单的操作,也会引发大量分散在磁盘上的 I/O。文件系统能做些什么?

How to reduce file system I/O costs? Even the simplest of operations like opening, reading, or writing a file incurs a huge number of I/O operations, scattered over the disk.

缓存与缓冲

答案是利用内存(DRAM)缓存重要的块。没有缓存,打开 /1/2/3/.../100/file.txt 这样的长路径要做好几百次读。

从静态划分到统一页缓存

早期文件系统在启动时分配一块固定大小的缓存(大约占内存的 10%),用 LRU 之类的策略决定保留哪些块。问题是这种静态划分(static partitioning)很浪费:文件系统暂时用不着 10% 的内存时,这些页也不能挪作他用。现代操作系统采用动态划分(dynamic partitioning):把虚拟内存页和文件系统页合并成统一页缓存(unified page cache),谁需要更多内存就多给谁。

静态划分:启动时切死 虚拟内存页(90%)——内存紧张时也不能用右边 FS 10% 空闲也浪费 动态划分:统一页缓存 进程的匿名页 文件页(页缓存) 边界随需求移动
静态划分 vs 动态划分(统一页缓存)

分配资源给多个使用者时,静态划分保证每个人都有一份,性能更可预测、实现更简单;动态划分利用率更高(空闲资源可以被需要的人用),但更复杂,而且当你闲置的资源被别人占了、而你突然需要时,收回可能要花很久。没有绝对的最好,要看具体问题。

有了缓存,第一次打开文件可能产生大量 I/O 来读目录的 inode 和数据,但之后再打开同一个文件(或同一目录下的文件),基本都会命中缓存,不需要 I/O。在上面的时间线模拟器里把缓存切到"有页缓存"就能看到:读 /foo/bar 并读 3 块,从 14 次 I/O 降到 9 次(冷)甚至 1 次(热)。

写缓冲

写就不一样了:读 I/O 可以被足够大的缓存完全消除,但写最终必须到达磁盘才能持久。不过写缓冲(write buffering)仍有三大好处:

  1. 批处理(batch):inode 位图在创建一个文件时被改了,片刻后又因为创建另一个文件被改——推迟写入,两次修改只需一次 I/O。
  2. 调度:内存里攒了一批写,就可以对它们排序、调度,提高磁盘效率。
  3. 避免写:应用创建一个文件又马上删掉,推迟写就能完全省掉这些 I/O。此时"懒"是一种美德。
时间 应用 磁盘 creat a(改位图) creat b(改位图) creat tmp unlink tmp 全部只改内存中的缓存页(很快返回) 一次刷盘:位图只写 1 次 tmp 的创建从未落盘 若此刻崩溃:a、b 都丢失
写缓冲的收益(合并、避免写)与代价(崩溃时丢失尚未刷盘的更新)

因此大多数现代文件系统会把写在内存里缓冲 5 到 30 秒。这又是一个权衡:系统在刷盘前崩溃,这些更新就丢了;缓冲得越久,性能越好(批处理、调度、避免写)。

存储系统常常让用户在持久性(durability)与性能之间选择。想要写完立即持久,就必须真的等数据落盘,写很慢但安全;能容忍丢一点数据,系统就可以先缓冲、稍后在后台写,写"看起来"很快。浏览器丢了最后几张下载的图片也许无所谓,数据库事务丢了一笔进账可就不行了。

数据库这类应用不接受这种权衡,于是它们用 fsync() 强制落盘、用绕过缓存的直接 I/O(direct I/O)接口,或干脆使用裸磁盘接口(raw disk)完全绕开文件系统。大多数应用接受文件系统的默认选择,但需要时总有办法让系统按你的要求做。

先预测,再验证

用 inode 定位计算器:inode 表从 12KB 开始、inode 256B、块 4KB、扇区 512B。inode 10 和 inode 79 各要读哪个扇区(所在块的起始扇区)?如果 inode 改成 128B,inode 32 又在哪?先手算再验证。
看答案

inode 10:偏移 2560 → blk 0 → 块起始 12KB → 扇区 24。inode 79:偏移 20224 → blk 4 → 块起始 28KB → 扇区 56(inode 本身在该块内偏移 3840 处,也就是扇区 63)。

128B 时每块放 32 个 inode:inode 32 偏移 4096 → blk 1 → 块起始 16KB → 扇区 32。inode 越小,同一块里装得越多,编号相近的 inode 越可能在同一块里(一次读就能拿到多个)。

块大小改成 1KB、指针 4B,12 个直接指针 + 1 个间接 + 1 个双重间接。最大文件多大?文件的第 300 块要额外读几个索引块?
看答案

每块 P = 1024 / 4 = 256 个指针。最大块数 = 12 + 256 + 256² = 65804 块 × 1KB = 65804KB ≈ 64.3MB。块小了,不仅每块数据少,每个间接块能装的指针也少,最大文件大小按"平方"缩水。

第 300 块:300 − 12 = 288 ≥ 256,所以不在一级间接范围;288 − 256 = 32,落在双重间接里:双重间接块[0] → 间接块[32],额外读 2 个索引块。

在 vsfs 模拟器里输入下面的序列(8 个 inode、8 个数据块),先写出最终状态再运行验证:
mkdir /a、creat /a/b、write /a/b、link /a/b /c、unlink /a/b
看答案
inode bitmap  11100000
inodes        [d a:0 r:3] [d a:1 r:2] [f a:2 r:1] [] [] [] [] []
data bitmap   11100000
data          [(.,0) (..,0) (a,1) (c,2)] [(.,1) (..,0)] [a] [] [] [] [] []

要点:/a/b 被 unlink 后,inode 2 仍被 /c 引用(r:1),所以 inode 和数据块都没被释放;目录 /a 里只剩 . 和 ..。根的引用计数是 3(自己的 .、自己的 ..、以及 /a 里的 ..)。

(原书作业 2)用不同的随机种子多生成几组操作,只看 inode 和数据块的编号:vsfs 的分配算法偏好哪些块?删除一个文件后再创建,新文件会用到哪个 inode?
看答案

总是分配编号最小的空闲项(从位图开头扫描第一个 0)。所以刚被释放的低编号 inode / 数据块会立刻被下一次分配复用。真实文件系统(如下一章的 FFS)会用更讲究的策略,考虑局部性:把同一目录的文件放在一起。

(原书作业 3、4)把数据块数设成 2:哪些操作会失败?哪些永远能成功?把 inode 数设成 3 呢?用两个预设按钮验证。
看答案

只有 2 个数据块(根目录已占 1 块):只能再有一次 mkdir 或一次 write 成功,之后 mkdir 和 write 都失败;creat、link、unlink 不需要数据块,只要还有 inode / 目录空间就能成功。所以文件系统里最后多是空文件和链接。

只有 3 个 inode:只能再创建 2 个对象,之后 creat 和 mkdir 都失败;link、write、unlink 不需要新 inode 仍能成功。unlink 释放 inode 后又能创建新的。最终状态多是少数几个文件带着多个硬链接。

在 I/O 时间线模拟器里选 /foo/x/y/bar、打开并读 2 块、无缓存。总共多少次 I/O?再预测:把缓存换成"冷启动",原书 Figure 40.4(创建 + 3 次写)从 25 次 I/O 降到多少?
看答案

open:4 个目录 × 2(inode + 数据)+ bar 的 inode = 9 次读;每次 read() 3 次 I/O,共 6 次;合计 15 次。

冷缓存创建 + 3 次写:真正的读只剩 7 次(根 inode、根数据、foo inode、foo 数据、inode 位图、bar inode、数据位图,每个只读一次),写被缓冲后合并为 8 个不同的块各刷一次,合计 15 次(原来 25 次)。

动手实验

三个小程序,把本章的概念落到真实的 Linux ext4 上。代码在 labs/40-fs-impl/:

cd labs/40-fs-impl && make && ./sparse && ./fiemap && ./cache

示例输出(WSL2 · ext4,你的机器上数字会不同):

$ ./sparse
== 1. 文件大小 vs 分配的数据块 ==
inode=1034668   size=0           blocks=0   (=  0 KB) links=1  <- 刚创建(空文件)
inode=1034668   size=1           blocks=8   (=  4 KB) links=1  <- 写入 1 字节后
inode=1034668   size=49153       blocks=16  (=  8 KB) links=1  <- 在偏移 48KB 处再写 1 字节
inode=1034668   size=1073741825  blocks=24  (= 12 KB) links=1  <- 在偏移 1GB 处再写 1 字节

== 2. 目录也是文件:条目多了,目录要分配更多数据块 ==
inode=1030428   size=4096        blocks=8   (=  4 KB) links=2  <- 空目录
inode=1030428   size=4096        blocks=8   (=  4 KB) links=2  <- 目录里有 51 个文件后
inode=1030428   size=12288       blocks=24  (= 12 KB) links=2  <- 目录里有 101 个文件后
inode=1030428   size=16384       blocks=32  (= 16 KB) links=2  <- 目录里有 201 个文件后
inode=1030428   size=36864       blocks=80  (= 40 KB) links=2  <- 目录里有 401 个文件后
inode=1030428   size=36864       blocks=80  (= 40 KB) links=2  <- 删掉 400 个文件后(目录不收缩)

$ ./fiemap
fiemap-test.dat: size=4259840 字节, inode=1030428, extent 数=2
  #   逻辑偏移(4K块) 物理位置(4K块) 长度(块) 标志
  0   0                  127213568          256        
  1   1024               127218688          16         LAST 

$ ./cache
== 写缓冲:write() 返回 ≠ 数据已落盘 ==
200 次 4KB 追加写,不 fsync   :     1.02 ms(平均每次    5.1 µs)
200 次 4KB 追加写,每次 fsync :   352.24 ms(平均每次 1761.2 µs)
写 64 MB:write() 循环用时     89.2 ms  ← 数据只是进了页缓存(脏页)
          随后 fsync() 用时     27.0 ms  ← 这时才等设备把脏页真正写下去

== 读缓存:第二次读几乎不碰磁盘 ==
第 1 次读(已尽量清缓存)  :     31.6 ms  ( 2023 MB/s)
第 2 次读(命中页缓存)    :     19.2 ms  ( 3340 MB/s)
O_DIRECT 读(绕过页缓存)  :     30.4 ms  ( 2105 MB/s)

怎么解读:

自测题

1. vsfs 中,inode 表从 12KB 开始,每个 inode 256B,块 4KB。读 inode 32 需要读哪个扇区(512B)?
32 × 256 = 8192 = 8KB,加上起始 12KB = 20KB;20 × 1024 / 512 = 40。C 是 KB 数不是扇区号;B 把 inode 号直接当扇区号。
2. 进程执行 open("/foo/bar", O_RDONLY) 然后 read() 一个块(无缓存)。下面哪个结构在整个过程中完全不会被访问?
读文件不需要分配任何东西,所以不访问位图。inode 已经指向数据块,没必要去确认"是否已分配"。
3. 4KB 块、4 字节指针、12 个直接指针 + 1 个间接指针,最大文件是多少?
间接块能装 4096/4 = 1024 个指针,(12 + 1024) × 4KB = 4144KB。48KB 是只有直接指针的情况;约 4GB 要再加双重间接指针。
4. 为什么 inode 的指针树设计成"少量直接指针 + 逐级间接"这种不平衡的样子?
测量研究一再发现"大多数文件很小"。为常见情况优化:小文件只用直接指针,零额外 I/O;只有大文件才需要走间接块。
5. 在 vsfs 里执行 link("/q", "/u"),哪些结构会被修改?
硬链接不分配任何新东西:只在目录里加一项 (u, q 的 inode 号),再把该 inode 的引用计数加 1。两个位图都不变。B 漏掉了引用计数。
6. 原书 Figure 40.4 中,创建 /foo/bar 需要 10 次 I/O,其中有一次是读 bar 的 inode——明明它还没初始化。原因是?
磁盘按块读写,一个 4KB 块里有 16 个 inode。只改其中一个,必须先读整块,改好后再写回整块,否则会覆盖邻居 inode。
7. 关于缓存与缓冲,下列说法正确的是:
写缓冲通过合并、避免写来减少 I/O;现代系统用统一页缓存动态划分;write() 返回时数据可能还在内存里,要持久化得用 fsync()。
8. 不考虑缓存,open("/a/b/c/d.txt") 为了找到 d.txt 的 inode,至少要读多少次磁盘(假设每个目录只有一个数据块)?
要遍历 4 个目录(/、a、b、c),每个读 inode + 数据块 = 8 次,再读 d.txt 的 inode 1 次,共 9 次。I/O 数量与路径长度成正比。
9. 目录项中的 reclen(记录长度)为什么可以大于名字实际需要的空间?
reclen 告诉遍历者"下一条记录从哪开始"。复用旧记录时 reclen 保持不变,于是可能比 strlen 大很多——在目录模拟器里试试删掉长名字再创建短名字。

常见误区与小结

本章要点

和后续章节的联系:vsfs 完全不考虑磁盘的物理特性,数据块可能被放得很散——下一章的 FFS(快速文件系统)就是要让文件系统"懂磁盘",改进局部性。上面看到的"一次创建 10 次写、分散在各处"如果中途断电怎么办?这就是第 42 章崩溃一致性(fsck 与日志)要解决的问题。而"写都要到处寻道"的问题,第 43 章的 LFS 给出了激进的答案:把所有写都变成顺序写。