本章要解决什么
如何实现一个简单的文件系统?磁盘上需要哪些结构?它们要记录什么?系统调用又是怎样访问它们的?
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 次数与路径长度成正比。创建文件还要改位图和目录。
缓存与写缓冲
页缓存让重复的读几乎不碰磁盘;写缓冲把多次写合并、推迟,换来性能,代价是崩溃时可能丢数据。
思考文件系统的方式
原书建议从两个方面理解任何一个文件系统:
- 数据结构(data structures):磁盘上用什么结构组织数据和元数据?vsfs 用最简单的"块数组";更复杂的文件系统(如 SGI XFS)用 B 树之类的树形结构。
- 访问方法(access methods):进程调用
open()、read()、write()时,文件系统要读哪些结构、写哪些结构?效率如何?
学文件系统,真正要建立的是心智模型(mental model):磁盘上有哪些结构保存数据与元数据?进程打开一个文件时发生了什么?一次读、一次写各访问了哪些结构?能回答这些,你就理解了它;只会背某个文件系统的代码细节反而是次要的。本章后面的两个模拟器正是分别针对"数据结构"和"访问方法"设计的。
整体组织:64 个块的 vsfs
先把磁盘分区切成大小相同的块(block),vsfs 选常用的 4KB。假设分区非常小,只有 64 个块(编号 0–63)。接下来一步步决定这些块里放什么——点下面的按钮,跟着原书的推理顺序把布局"搭"出来;点任意一个块可以查看它的用途。
推理顺序是这样的:
- 数据区(data region):文件系统里绝大部分空间应该留给用户数据。先预留最后 56 块(8–63)。
- inode 表(inode table):每个文件的元数据放在一个 inode 里。inode 通常只有 128 或 256 字节;按 256 字节算,一个 4KB 块能放 16 个 inode。拿 5 块(3–7)做 inode 表,就有 5 × 16 = 80 个 inode——这也就是这个文件系统最多能容纳的文件数(在更大的磁盘上可以分配更大的 inode 表)。
- 分配结构(allocation structures):要知道哪些 inode、哪些数据块是空闲的。vsfs 用两个位图:inode 位图(块 1)和数据位图(块 2)。一整块 4KB 的位图可以追踪 32K 个对象,对 80 个 inode 和 56 个数据块来说大材小用,但图简单。
- 超级块(superblock):剩下的块 0 放超级块,记录"有 80 个 inode、56 个数据块、inode 表从块 3 开始"之类的全局信息,外加一个标识文件系统类型的魔数。挂载(mount)时操作系统先读超级块,初始化各种参数,再把这个卷接到目录树上。
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 里存着关于文件的几乎一切:类型(普通文件、目录……)、大小、分配了多少块、权限与属主、各种时间,以及数据块在哪里。这些统称为元数据(metadata)——文件系统里凡不是纯用户数据的信息都叫元数据。下面是简化的 ext2 inode(原书 Figure 40.1):
| 字节 | 字段 | 用途 |
|---|---|---|
| 2 | mode | 能否读 / 写 / 执行 |
| 2 | uid | 属主是谁 |
| 4 | size | 文件有多少字节 |
| 4 | time | 最后访问时间 |
| 4 | ctime | inode 最后被修改的时间 |
| 4 | mtime | 文件内容最后修改时间 |
| 4 | dtime | inode 被删除的时间 |
| 2 | gid | 属组 |
| 2 | links_count | 有多少个硬链接指向它(上一章的引用计数) |
| 4 | blocks | 分配了多少块 |
| 4 | flags | ext2 如何使用这个 inode |
| 4 | osd1 | 与操作系统相关的字段 |
| 60 | block | 15 个磁盘指针(下一节的重点) |
| 4 | generation | 文件版本(NFS 用) |
| 4 | file_acl | 超越 mode 位的新权限模型 |
| 4 | dir_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 块走哪条路
为什么要用这么不平衡的树?因为几十年的测量一再发现同样的"事实"(原书 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 上无法创建硬链接。
目录的存储
在 vsfs 里(很多文件系统也一样),目录的组织非常简单:它的数据块里就是一串 (名字, inode 号)。名字可变长,所以每项还带长度。原书的例子:目录 dir(inode 5)里有 foo、bar、foobar_is_a_pretty_longname 三个文件,inode 号分别为 12、13、24。
- inum:inode 号;reclen:这条记录占的总字节数(名字加上可能剩余的空间);strlen:名字的实际长度(含结尾的
\0,所以foo是 4);name:名字本身。 - 每个目录都有两项特殊记录:
.指向自己,..指向父目录(这里是根,inode 2)。 - 删除文件(
unlink())会在目录中间留下空洞,可以用保留的 inode 号(如 0)标记为空闲。这也是需要 reclen 的原因:新建的名字可以复用一条更大的旧记录,记录里就留有多余空间。
目录数据块:删除留下空洞,新名字复用旧记录
目录存在哪里?文件系统通常把目录当作一种特殊的文件:它也有一个 inode(类型字段标为"目录"),inode 指向若干数据块(也可以有间接块),这些数据块就放在普通的数据区里。所以磁盘布局不需要任何改变。线性列表也不是唯一选择:XFS 把目录存成 B 树,创建文件时检查"名字是否已存在"就不必扫描整个列表。
空闲空间管理
文件系统必须知道哪些 inode 和数据块是空闲的,分配新文件 / 新目录时才有地方放。vsfs 用两个位图:
- 创建文件:在 inode 位图里找一个空闲 inode 分配给它,把那一位标成 1,并(最终)把位图写回磁盘。
- 分配数据块:同理,在数据位图里找空位。
- 预分配(pre-allocation)策略:ext2/ext3 给新文件分配数据块时,会找一串连续的空闲块(比如 8 块)一起分配,保证文件至少有一部分在磁盘上是连续的,提高后续顺序读写的性能。
位图只是一种办法。一些早期文件系统用空闲链表(free list):超级块里一个指针指向第一个空闲块,那个块里存着下一个空闲块的指针,依此类推;要用块时取表头并更新链表。现代文件系统用更复杂的结构,例如 XFS 用某种 B 树紧凑地表示哪些区段空闲。和所有数据结构一样,这是时间与空间的取舍。
模拟器:仿 vsfs.py 观察磁盘状态的变化
下面的模拟器复现了原书作业工具 vsfs.py 的模型:一个只有 8 个 inode、8 个数据块的小文件系统,开始时只有根目录(inode 0,数据块 0)。每执行一个操作,就能看到 inode 位图、inode 表、数据位图、数据块发生了什么变化(黄框 = 本步改动,红色虚线 = 本步释放)。显示格式与 vsfs.py 相同:
- inode:
[d a:0 r:2]表示目录(d)、数据块地址 0、引用计数 2;[f a:-1 r:1]是还没有数据块的空文件;[]是空闲 inode。 - 数据块:目录块显示目录项
[(.,0) (..,0) (g,1)];文件块显示一个字母,代表它的内容;[]是空闲块。 - 和 vsfs.py 一样,每个文件最多只有 1 个数据块;分配策略是"找编号最小的空闲项"。
vsfs:mkdir / creat / write / link / unlink 如何改变磁盘
语法: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):
- 一切遍历从根目录开始。根的 inode 号必须是"众所周知"的(根没有父目录可查),大多数 UNIX 文件系统里是 2。读入包含 inode 2 的块(第一个 inode 块)。
- 从根 inode 里的指针找到根目录的数据块,读进来,在里面找
foo这一项,得到 foo 的 inode 号(比如 44)。 - 递归:读 foo 的 inode,再读 foo 的目录数据,找到
bar的 inode 号。 - 最后把 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 各读写了哪些块
上图是 64 块的 vsfs 布局(紫 = 超级块,黄 = 位图,蓝 = inode 表)。本模拟器假设:根 = inode 2(块 3),foo = inode 44(块 5),x = 20(块 4),y = 52(块 6),bar = 67(块 7);目录数据依次在块 8–11,bar 的数据从块 12 开始。
两个结论值得记住:
- open 产生的 I/O 与路径长度成正比:路径上每多一层目录,就要多读它的 inode 和(至少一块)数据。目录很大时,可能要读很多数据块才找到目标项。
- 写比读更糟,创建更糟:读文件,每块 3 次 I/O;写新文件,每块 5 次 I/O;创建 10 次 I/O——而且这些 I/O 散布在磁盘各处(位图、inode 表、数据区),全是寻道。
原书脚注里的"小谜题":创建 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),谁需要更多内存就多给谁。
分配资源给多个使用者时,静态划分保证每个人都有一份,性能更可预测、实现更简单;动态划分利用率更高(空闲资源可以被需要的人用),但更复杂,而且当你闲置的资源被别人占了、而你突然需要时,收回可能要花很久。没有绝对的最好,要看具体问题。
有了缓存,第一次打开文件可能产生大量 I/O 来读目录的 inode 和数据,但之后再打开同一个文件(或同一目录下的文件),基本都会命中缓存,不需要 I/O。在上面的时间线模拟器里把缓存切到"有页缓存"就能看到:读 /foo/bar 并读 3 块,从 14 次 I/O 降到 9 次(冷)甚至 1 次(热)。
写缓冲
写就不一样了:读 I/O 可以被足够大的缓存完全消除,但写最终必须到达磁盘才能持久。不过写缓冲(write buffering)仍有三大好处:
- 批处理(batch):inode 位图在创建一个文件时被改了,片刻后又因为创建另一个文件被改——推迟写入,两次修改只需一次 I/O。
- 调度:内存里攒了一批写,就可以对它们排序、调度,提高磁盘效率。
- 避免写:应用创建一个文件又马上删掉,推迟写就能完全省掉这些 I/O。此时"懒"是一种美德。
因此大多数现代文件系统会把写在内存里缓冲 5 到 30 秒。这又是一个权衡:系统在刷盘前崩溃,这些更新就丢了;缓冲得越久,性能越好(批处理、调度、避免写)。
存储系统常常让用户在持久性(durability)与性能之间选择。想要写完立即持久,就必须真的等数据落盘,写很慢但安全;能容忍丢一点数据,系统就可以先缓冲、稍后在后台写,写"看起来"很快。浏览器丢了最后几张下载的图片也许无所谓,数据库事务丢了一笔进账可就不行了。
数据库这类应用不接受这种权衡,于是它们用 fsync() 强制落盘、用绕过缓存的直接 I/O(direct I/O)接口,或干脆使用裸磁盘接口(raw disk)完全绕开文件系统。大多数应用接受文件系统的默认选择,但需要时总有办法让系统按你的要求做。
先预测,再验证
看答案
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 越可能在同一块里(一次读就能拿到多个)。
看答案
每块 P = 1024 / 4 = 256 个指针。最大块数 = 12 + 256 + 256² = 65804 块 × 1KB = 65804KB ≈ 64.3MB。块小了,不仅每块数据少,每个间接块能装的指针也少,最大文件大小按"平方"缩水。
第 300 块:300 − 12 = 288 ≥ 256,所以不在一级间接范围;288 − 256 = 32,落在双重间接里:双重间接块[0] → 间接块[32],额外读 2 个索引块。
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 里的 ..)。
看答案
总是分配编号最小的空闲项(从位图开头扫描第一个 0)。所以刚被释放的低编号 inode / 数据块会立刻被下一次分配复用。真实文件系统(如下一章的 FFS)会用更讲究的策略,考虑局部性:把同一目录的文件放在一起。
看答案
只有 2 个数据块(根目录已占 1 块):只能再有一次 mkdir 或一次 write 成功,之后 mkdir 和 write 都失败;creat、link、unlink 不需要数据块,只要还有 inode / 目录空间就能成功。所以文件系统里最后多是空文件和链接。
只有 3 个 inode:只能再创建 2 个对象,之后 creat 和 mkdir 都失败;link、write、unlink 不需要新 inode 仍能成功。unlink 释放 inode 后又能创建新的。最终状态多是少数几个文件带着多个硬链接。
看答案
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
sparse:inode 里的size(文件有多长)和blocks(真正分配了多少块)是两回事——跳着写只分配写到的块,中间是"洞";目录也是文件,条目多了要分配更多数据块,删掉条目后也不收缩。fiemap:用FS_IOC_FIEMAPioctl 读出文件的"逻辑块 → 物理块"映射,亲眼看到 ext4 的 extent(起始块 + 长度)。cache:写 128MB 带不带fsync、同一文件连续读两次、用O_DIRECT绕过页缓存读,体会写缓冲和读缓存。
示例输出(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)
怎么解读:
- 空文件
blocks=0:creat 只分配 inode,不分配数据块(和 vsfs 模拟器一致)。写 1 字节就分配一整块 4KB(blocks以 512B 为单位,8 × 512 = 4KB)。跳到 1GB 处写 1 字节,size变成 1GB 多,实际只占 12KB——中间的洞不占空间,读洞会得到 0。 - 目录从 4KB 长到 36KB:目录项多了,目录文件需要更多数据块;删光文件后大小不变(空出来的目录项只是被标记为空闲,正如前面目录模拟器里的"空洞")。ext4 在目录超过一块时还会启用 htree 索引,所以增长不完全线性。
- fiemap:1MB 连续写入被记成一个长度 256 块的 extent,而不是 256 个指针;洞(逻辑块 256–1023)根本不出现在映射里。
- cache:不 fsync 的写只写进页缓存就返回;fsync 要等设备确认。第二次读命中页缓存更快。WSL2 的虚拟磁盘背后还有 Windows 主机的缓存,差距可能没有物理硬盘那么夸张,多跑几次看看波动。
自测题
open("/foo/bar", O_RDONLY) 然后 read() 一个块(无缓存)。下面哪个结构在整个过程中完全不会被访问?link("/q", "/u"),哪些结构会被修改?open("/a/b/c/d.txt") 为了找到 d.txt 的 inode,至少要读多少次磁盘(假设每个目录只有一个数据块)?reclen(记录长度)为什么可以大于名字实际需要的空间?常见误区与小结
- "inode 里存着文件名"——没有。名字在父目录的数据块里,inode 只有元数据和块指针;所以一个 inode 可以有多个名字(硬链接)。
- "读文件也要查位图"——不需要。位图只在分配 / 释放时访问。
- "close() 会把数据写回磁盘"——在 vsfs 模型里 close 不产生任何磁盘 I/O;要保证落盘请用 fsync()。
- "文件大小 = 占用的磁盘空间"——稀疏文件的洞不分配块;反过来 1 字节的文件也占一整块。
- "unlink 就是删除"——unlink 只是删掉一个名字、引用计数减 1;计数为 0(且没有进程打开它)时才释放 inode 和数据块。
- "inode 数量无限"——inode 表大小在 mkfs 时就定了;inode 用光后,即使还有数据块,也无法创建新文件。
本章要点
- 磁盘被划分成块:超级块 + inode 位图 + 数据位图 + inode 表 + 数据区。
- inode 保存文件的元数据;i-number 是它在 inode 表里的下标,
sector = ((inum × size / B) × B + start) / 512可直接算出位置。 - 多级索引(直接 + 间接 + 双重间接……)为"多数文件很小"优化,又能支持很大的文件;extent 是更紧凑的替代方案。
- 目录是特殊的文件,数据是一串 (inum, reclen, strlen, name) 记录;位图管理空闲空间。
- 访问路径:open 逐级读 inode 和目录数据;每次读 3 次 I/O、每次分配型写 5 次、创建 10 次。
- 缓存消除重复读;写缓冲通过批处理、调度、避免写来提升性能,代价是持久性,需要时用 fsync。
和后续章节的联系:vsfs 完全不考虑磁盘的物理特性,数据块可能被放得很散——下一章的 FFS(快速文件系统)就是要让文件系统"懂磁盘",改进局部性。上面看到的"一次创建 10 次写、分散在各处"如果中途断电怎么办?这就是第 42 章崩溃一致性(fsck 与日志)要解决的问题。而"写都要到处寻道"的问题,第 43 章的 LFS 给出了激进的答案:把所有写都变成顺序写。