本篇位置 第 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 cacheinode 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 次数这条结论还在。
  • ⭐ 它放弃的是原子性:每个操作都要改好几个地方,中间断电就出事。这是下一篇。

思考题

  1. 第三节里 4097 字节的文件占了 8192 字节。如果块大小改成 1 KB,小文件的浪费会小很多。为什么现代文件系统还是普遍用 4 KB?
  2. 多级索引里,前 12 个是直接指针。为什么是 12 而不是 5 或 50?(提示:想想 inode 结构体的大小,以及它要放进一个块里。)
  3. 稀疏文件"读没写过的地方返回零"。那么 cp 一个稀疏文件会发生什么?怎么才能保持它的稀疏性?
  4. 图书馆那个比方里,“块组"对应什么?为什么"大部头故意拆到多个书架"反而对整个图书馆更好?

延伸