| 本篇位置 | 第 25 篇讲接口,这一篇讲实现 |
| 运行环境 | 容器里的 Linux,Python 3 |
一、盘上只有编号的块
去掉所有抽象,一块磁盘就是一长串编号的块,每块 4096 字节:
块 0 块 1 块 2 块 3 ... 块 N
没有文件,没有目录,没有名字。文件系统的全部工作,就是在这串块上摆出一套结构,让"目录树"这个东西能被表达出来。
最小的一套需要四种东西:
┌────────┬──────────┬──────────┬────────────────────────────┐
│ 超级块 │ inode位图 │ 数据位图 │ inode 表 │ 数据块 │
└────────┴──────────┴──────────┴────────────────────────────┘
块0 块1 块2 块3–7 块8 以后
- 超级块(superblock):整个文件系统的说明书——总共多少块、多少 inode、块多大、根目录的 inode 号是几。挂载时第一个读的就是它。
- inode 位图:每个 inode 一个比特,标记用没用。
- 数据位图:每个数据块一个比特,标记用没用。
- inode 表:所有 inode 排排坐(第 25 篇讲过里面有什么)。
- 数据块:剩下的全是。文件内容和目录内容都放这儿。
打个比方
接着第 25 篇那个图书馆:
- 超级块 = 门口那块牌子:“本馆共 12 个书架,卡片柜在东侧,编目规则见此。”
- 位图 = 一张"哪些架位空着"的登记表。要放新书先查它。
- inode 表 = 卡片柜(每张卡片记着一本书的信息和它在哪个架位)。
- 数据块 = 书架本身。
⭐ 注意位图这一层:它不存任何内容,只存"占没占"。没有它,你每次要放新书都得把整个馆走一遍看哪儿空着。
二、打开一个文件,到底读了多少次盘
open("/home/lss/a.txt") 这一个调用,背后是一串查找:
读根目录的 inode(它的号写在超级块里)
→ 读根目录的数据块,在里面找 "home" → 得到 home 的 inode 号
→ 读 home 的 inode
→ 读 home 的数据块,找 "lss" → 得到 lss 的 inode 号
→ 读 lss 的 inode
→ 读 lss 的数据块,找 "a.txt" → 得到 a.txt 的 inode 号
→ 读 a.txt 的 inode
⭐ 路径每深一层,就多两次读盘(一次 inode,一次它的数据块)。
第 24 篇算过:机械盘上一次随机读约 8.7 毫秒。一个五层深的路径,光是打开就要几十毫秒。
所以文件系统必须缓存这些东西——Linux 里叫 dentry cache(目录项缓存)和 inode cache。它们和第 15 篇的页缓存一起,共同占着你机器上那些"看起来被用掉了"的内存。
⚠️ 这也解释了一个实际现象:第一次 ls 一个大目录很慢,第二次瞬间完成。 不是磁盘热身了,是目录项进了缓存。
三、跑一遍看看:分配以块为单位
文件的数据不是"按需要多少给多少",是按块给。让内核自己报:
def show(path, tag):
st = os.stat(path)
print(f" {tag:<28} 大小 {st.st_size:>12,} 字节 实际占用 {st.st_blocks*512:>10,} 字节")
空文件 大小 0 字节 实际占用 0 字节
只有 1 个字节 大小 1 字节 实际占用 4,096 字节
4097 个字节 大小 4,097 字节 实际占用 8,192 字节
1 个字节的文件占掉 4096 字节。4097 个字节占掉 8192。
⭐ 这就是内部碎片在文件系统里的样子——和第 12 篇分页的内部碎片是同一回事,同一个原因(固定大小的块换来分配的简单),同一个代价。
⚠️ 有个很实际的后果:一百万个小文件,即使每个只有几十字节,也要占掉 4 GB。“文件总大小"和"占用磁盘"在小文件多的场景下能差一个数量级。
四、⭐ 文件不是一段地,是一张表
现在看更关键的一个实验:
with open("/tmp/fs/sparse", "wb") as f:
f.seek(1 << 30) # 跳到 1 GB 处
f.write(b"x") # 只写一个字节
show("/tmp/fs/sparse", "跳到 1 GB 处写 1 字节")
跳到 1 GB 处写 1 字节 大小 1,073,741,825 字节 实际占用 4,096 字节
“大小"是 1 GB,“实际占用"是 4 KB。
如果一个文件是磁盘上一段连续的地,这不可能发生——你要么占 1 GB,要么没有这个文件。
⭐ 所以 inode 里存的不是"起点和长度”,是一张"文件的第几块 → 磁盘的第几块"的映射表。 中间没写过的部分,表里干脆没有条目;读到那里,内核直接给你返回零。
这叫稀疏文件(sparse file)。虚拟机的磁盘镜像、数据库的预分配文件,全靠它。
回到图书馆:卡片上写的不是"这套书从 3 排 2 架一直排到 3 排 40 架”,而是一张逐册的清单——第 1 册在 3 排 2 架,第 2 册在 7 排 11 架……清单上没写的册数,就是馆里根本没有那一册。 所以一套编号到 1000 的丛书,馆里可以只有第 1000 册那一本。
这张表怎么存? 经典 UNIX 文件系统的做法是多级索引:
inode 里有 15 个指针:
12 个直接指针 → 直接指向数据块 (覆盖 12 × 4 KB = 48 KB)
1 个一级间接指针 → 指向一个装满指针的块 (+ 1024 × 4 KB = 4 MB)
1 个二级间接指针 → 指向一个装满一级指针的块(+ 4 GB)
1 个三级间接指针 (+ 4 TB)
⭐ 这个结构你应该觉得眼熟——它和第 14 篇的多级页表是同一个东西,连动机都一样:
- 小文件极常见,所以前 12 块直接指,一次间接都不用。
- 大文件也得支持,所以往上加级。
- 稀疏的部分整棵子树都不用存。
现代文件系统(ext4、XFS、btrfs)改用了 extent——不记"第几块到第几块”,而是记"从这块开始,连续 N 块"。顺序写出来的大文件,一条记录就够了。 但思想没变:inode 里存的是一张地图,不是一段地。
五、目录是一个装着表的文件
第 25 篇说"目录是文件"。这句话可以直接量——给目录里加文件,看目录自己的大小:
目录里文件数 目录自身大小
0 0
100 580
1,000 7,780
10,000 97,780
目录自己的大小,随着里面的文件数线性增长。 因为它的内容就是那张"名字 → inode 号"的表,多一个文件多一条记录。
⚠️ 而"在表里找一个名字"这件事,朴素实现是线性扫描。所以一个有一百万文件的目录,每次 open 都要扫一遍——这是"别把几十万个文件放在一个目录里"这条经验的来源。
现代文件系统给目录加了索引(ext4 的 HTree、XFS 的 B+ 树),把它变成对数级。但目录仍然是一个文件,只是内容从一张平表变成了一棵树。
六、把数据摆在哪:局部性
第 24 篇算出的那个 320 倍,在这里变成具体的布局决策。
最早的 UNIX 文件系统把 inode 全堆在盘的开头,数据块放后面。结果:读一个文件要先跑到盘头读 inode,再跑到盘中间读数据——每次都是一次全盘寻道。
FFS(快速文件系统,1984)的改进是块组(cylinder group):把盘切成很多组,每组里都有自己的 inode 表和数据块,然后:
- 一个文件的 inode 和它的数据块,尽量放在同一组。
- 同一个目录下的文件,尽量放在同一组(因为你多半会一起访问它们)。
- 大文件故意打散到多个组——否则一个大文件会把一个组填满,把它"应该在的邻居"挤走。
⭐ 最后那条特别值得玩味:为了整体的局部性,故意牺牲单个大文件的局部性。 这是一个典型的"局部最优不等于全局最优"——这门课第三次遇到它了(第 5 篇的 SJF、第 11 篇的最佳适配)。
⚠️ 而这一整套推理在 SSD 上失效了一半:第 24 篇说过,FTL 早把你的逻辑地址打散了,“放在同一个块组"在物理上未必真的相邻。但它依然有价值——因为读取仍然是按块进行的,把相关数据放进同一块能减少 I/O 次数。理由变了,结论还在。
七、代价与取舍
这套结构换来了什么: 在一串编号的块上,表达出了任意深度的目录树、任意大小的文件、共享和稀疏,而且查找是可缓存的。
它花了什么:
- 内部碎片:1 字节的文件占 4096 字节。
- 路径查找的多次读盘:每深一层多两次,靠缓存救。
- 元数据的空间:inode 表、位图都要占地方,而且 inode 数量在格式化时定死(第 25 篇说的"有空间但建不了文件”)。
它放弃了什么: ⭐ 原子性。 上面每一个操作——创建文件、加一条目录记录、分配一个数据块——都要改好几个地方:位图、inode、目录的数据块。
如果在中间断电呢?
那就是下一篇。
八、小结
- 盘上只有编号的块。最小的文件系统需要四样:超级块、位图、inode 表、数据块。⭐ 位图不存内容,只存"占没占"。
- 路径每深一层多两次读盘(inode + 数据块)。所以必须有
dentry cache和inode cache——⚠️ 这就是"第一次ls慢、第二次瞬间"的原因。 - ⭐ 实测:1 字节的文件占 4096 字节,4097 字节占 8192。内部碎片,和第 12 篇分页同一回事同一原因。⚠️ 一百万个小文件能占掉 4 GB。
- ⭐ 实测:“大小 1 GB"的稀疏文件只占 4096 字节。所以 inode 里存的是一张"文件第几块 → 磁盘第几块"的地图,不是一段连续的地。
- 经典做法是多级索引(12 直接 + 一级/二级/三级间接),⭐ 和第 14 篇的多级页表是同一个结构、同一个动机。现代用 extent(起点 + 长度),思想不变。
- ⭐ 实测:目录自身的大小随文件数线性增长(0 → 580 → 7780 → 97780 字节)——目录就是一个装着表的文件。⚠️ 朴素查找是线性扫描,这是"别把几十万文件放一个目录"的来源。
- FFS 的块组把 inode 和它的数据放近、同目录的文件放近,但故意打散大文件——⭐ 又一次"局部最优不等于全局最优”。⚠️ 在 SSD 上理由变了(FTL 打散了物理位置),但减少 I/O 次数这条结论还在。
- ⭐ 它放弃的是原子性:每个操作都要改好几个地方,中间断电就出事。这是下一篇。
思考题
- 第三节里 4097 字节的文件占了 8192 字节。如果块大小改成 1 KB,小文件的浪费会小很多。为什么现代文件系统还是普遍用 4 KB?
- 多级索引里,前 12 个是直接指针。为什么是 12 而不是 5 或 50?(提示:想想 inode 结构体的大小,以及它要放进一个块里。)
- 稀疏文件"读没写过的地方返回零"。那么
cp一个稀疏文件会发生什么?怎么才能保持它的稀疏性? - 图书馆那个比方里,“块组"对应什么?为什么"大部头故意拆到多个书架"反而对整个图书馆更好?