做 Linux 系统编程这几年,我有个很深的体会:只要把 Ext2 吃透,后面看 Ext3、Ext4、XFS 都不会发虚。它没有日志、没有校验和、没有压缩快照这些附加层,整个盘面上就是超级块、组描述符、位图、inode 表和一堆数据块,干干净净。这篇文章从块组这个最宏观的单元讲起,一路深入到 inode 内部、目录项结构,以及一次路径解析的完整账目,最后用调试工具直接对着磁盘镜像验证。适合刚学操作系统、想知道文件系统到底在盘上放了什么的同学,也适合被 Ext4 各种兼容机制搞晕、想回头找地基的开发者。
1. 块组是个棋盘:格式化那一刻布局就固定了
mkfs.ext2 格式化一块分区时,不会把整个文件系统当成一个巨大平面来处理,而是切成大小一致的块组(Block Group)。为什么要切?最直接的原因是局部性:同一组内的 inode 和它对应的数据块离得近,分配时优先落在同组,减少磁头寻道。另一个原因是可管理性——位图、inode 表都是固定尺寸的台账,一组一组管,错误也容易隔离。格式化完成后,每一组的边界、每张表的位置就全部固定下来,后面的运行过程只是在棋盘上落子。
1.1 超级块与组描述符:文件系统的自述文件
超级块固定写在偏移 1024 字节处,里面是文件系统的身份证:魔数 0xEF53、块大小、总块数、inode 总数、每组的块数、每组的 inode 数、空闲块和空闲 inode 计数、最后的挂载时间等等。内核挂载文件系统第一件事就是读它,并校验魔数和各项参数是否自洽。如果超级块损坏,文件系统基本等于报废,所以 ext2 会在部分组里写备份超级块和备份组描述符,fsck 时可以用备份把主超级块救回来。
组描述符表紧跟在超级块附近,每一条描述一个块组:块位图所在块、inode 位图所在块、inode 表起始块、该组的空闲块数、空闲 inode 数和目录数。这个表完全可以看作块组的“索引目录”,后面用 debugfs 定位 inode 表时,本质上就是先读它,找到目标组那一行的字段。
1.2 位图和 inode 表:一块磁盘的台账系统
每个组有两个专属位图。块位图用 1 bit 表示一个块的状态,1 表示已被占用,0 表示空闲;inode 位图同理。分配数据块时,内核在块位图里线性找一个 0 位,置 1,然后返回对应块号;释放时反向操作。你平时遇到的“磁盘空间被吃了”这类现象,往根上追就是位图状态和实际元数据不一致,e2fsck 重建的正是这个一致性。
inode 表是一组连续块,每个 inode 默认 128 字节(新版工具可以设成 256),按 inode 号顺序排列。inode 号减 1,乘以 inode 大小,就是它在这个表内的偏移,这也是文件系统能“按号取 inode”的硬件逻辑。数据块区放着所有文件和目录的内容,真正的用户数据都在这片区域里。
1.3 1GB 磁盘的块组参数推演
拿一个 1GB 镜像算一算:块大小 4KB 时,一张块位图能表达 4096×8=32768 个块的状态,所以每个块组就是 32768 块。总块数 262144,分 8 组正好。按每 16KB 一个 inode 的默认密度来算,每组 inode 数 = 32768×4096/16384 = 8192,inode 表占 8192×128/4096 = 256 块。于是每组里 2 个位图块、256 个 inode 表块,剩下的约 3.2 万个块才是数据区。
| 参数 | 数值 |
|---|---|
| 块大小 | 4096 字节 |
| 每组块数 | 8×4096/1 = 32768 |
| 组数 | 262144 / 32768 = 8 |
| inode 密度 | 每 16KB 一个 |
| 每组 inode 数 | 8192 |
| inode 表块数 | 8192×128/4096 = 256 |
这些参数不需要死记,关键是理解换算关系:位图大小决定组内块数,inode 密度决定 inode 表大小,组描述符的布局决定元数据在盘上的绝对位置。搞懂这张表,后面所有偏移计算都能自己推出来。
2. inode 的 128 字节:权限之外还藏着大文件的账本
inode 是文件系统的“户口本”,一个文件对应一个 inode,但 inode 里没有文件名。这个细节很多人第一次听说都会懵,事实上文件名都放在目录文件的数据块里,inode 只负责记录除名字以外的一切。打开任何一个文件的 inode,最先面对的就是那 128 字节。
2.1 关键字段逐项拆解
下面这张表是 ext2 标准 inode 的主要布局,偏移单位是字节:
| 偏移 | 字段 | 说明 |
|---|---|---|
| 0 | i_mode | 文件类型与权限,2 字节 |
| 2 | i_uid | 属主 uid,老格式是 16 位 |
| 4 | i_size | 文件字节数 |
| 8 | i_atime | 访问时间 |
| 12 | i_ctime | 状态变更时间 |
| 16 | i_mtime | 修改时间 |
| 20 | i_dtime | 删除时间 |
| 24 | i_gid | 属组 gid |
| 26 | i_links_count | 硬链接数 |
| 28 | i_blocks | 以 512 字节为单位的块数 |
| 32 | i_flags | 文件特性标志 |
| 40 | i_block[15] | 15 个块指针 |
| 100 | i_generation | 世代号,NFS 等场景用 |
| 104 | i_file_acl | 文件扩展属性块 |
| 108 | i_dir_acl | 目录扩展属性块 |
这里有个特别容易踩的坑:i_blocks 的单位是 512 字节扇区,不是块。一个文件 i_blocks=16,表示占 16 个扇区,也就是 8KB。稀疏文件里 i_size 很大、i_blocks 却很小,就是因为文件逻辑上看着大,实际并没占用那么多物理块。看 du 和 ls 显示不一致时,往这两个字段想就对了。
2.2 15 个块指针如何撑起一个大文件
i_block 数组有 15 个 32 位指针。前 12 个是直接块指针,指向存放文件内容的块;第 13 个指向一级间接块,这个块里存下一批数据块号;第 14 个指向二级间接块,里面存的是“指向间接块的指针”;第 15 个是三级间接块,再往上一层。
指针里存的是块号,块号直接换算磁盘偏移:偏移 = 块号 × 块大小。块大小为 4KB、指针大小为 4 字节时,一个间接块能塞 1024 个指针。算下来:直接块 48KB,一级间接 4MB,二级间接 4GB,三级间接 4TB,总和约 4.004TB。
| 块大小 | 每间接块指针数 | 理论最大文件大小 |
|---|---|---|
| 1KB | 256 | 约 16GB |
| 2KB | 512 | 约 256GB |
| 4KB | 1024 | 约 4TB |
有人会问,块号是 32 位,为什么 4KB 块的理论最大文件不是 16TB?因为 i_block 只有 15 个指针,够不着 2^32×4KB 的全部空间。文件大小上限和文件系统大小上限是两回事,前者受 inode 指针结构限制,后者受块号位宽限制。
2.3 间接层级对读写性能的连锁影响
访问文件尾部数据时,内核要先读对应的间接块,才能拿到数据块号。直接块只需 1 次盘操作;一级间接要读间接块加数据块,2 次;二级要读两个间接块加数据块,3 次;三级要 4 次。这里说的是冷缓存缺页的代价,块缓存命中时另说。
所以一个大文件追加写,如果落点正好在三级间接区,每次定位都要走三块间接块。尤其当文件刚超过 4GB、正在跨越双重/三级边界时,能明显感觉到 I/O 有顿挫感。这也是后来 Ext4 用 extent 树替代间接块的核心动机之一——间接层数太多,逻辑地址到物理块的翻译代价随文件增大而变贵。
3. 目录项:文件名其实是目录文件里的“小纸条”
标题里“目录的底层工作机制”,用一句话概括就是:目录是一种特殊文件,它的数据块里存放一条条定长的记录,记录里没有子文件的内容,只有子文件的 inode 号和文件名。所谓“目录占用的空间”和“目录里文件的内容”完全是两回事。
3.1 目录文件的内容格式
每条目录记录(ext2_dir_entry_2)的字段:inode 号(4 字节)、rec_len(2 字节,本条记录长度,含头部和对齐)、name_len(2 字节,文件名长度)、可选的 file_type(1 字节,文件类型提示)、name 本体。较新的 ext2 在启用目录文件类型特性时会在 name_len 后面加 file_type,没有该特性的老格式头部就是 8 字节。
rec_len 是一条链式标记,它指向下一条记录的位置;目录文件尾部由 inode 的 i_size 界定,不像普通数据那样有结束符。因为 name_len 可变,内核要快速跳转就必须知道每条记录的真实长度,所以每条记录都会按 4 字节边界对齐,多出来的字节作为 padding 写进 rec_len。目录项删除、复用、切分都建立在 rec_len 这套机制上。
3.2 rec_len 的复用魔法
删除一个文件时,内核不会物理清除那条记录,只把 inode 字段置 0,然后把它的长度合并进上一条记录的 rec_len,相当于在链表上把它绕过去。新建文件时,内核从头扫描目录链,如果某条记录的剩余长度超过新记录实际需要,就把多出来的部分切成新条目,并把剩余长度写回原条目。这样反复删建,目录内会攒下很多 inode=0 的空洞。
这带来一个非常直观的现象:你从一个几千文件的大目录里删掉一半文件,目录文件本身的大小几乎不变,ls -ld 看到的大小还是老样子。这是 ext2 目录回收机制决定的,不是“空间被偷了”。如果你因此怀疑磁盘丢东西,先想想这条机制,再用目录空洞的实际占用去解释。
3.3 线性扫描的代价与“大目录慢”的根源
查找文件时,内核把目录的块从头到尾读一遍,逐条比较 inode、name_len、文件名。目录越大,平均比较次数越多;如果记录分散在不同数据块,冷缓存下还等于多次随机读。Ext3 后期引入 HTree 索引目录,Ext4 全面使用有索引的目录,才把这种 O(n) 查找变成近似 O(log n)。所以在 ext2 上把一个目录塞进 10 万个文件,性能会非常难看。
我自己做过一个简单测试:在 ext2 镜像里放了 5 万个文件的目录,冷缓存下随便找一个文件名,光目录扫描就要读掉几百个块。相比之下,ext4 用 HTree 只要几个块定位。这也是为什么老工程师都习惯把文件按日期或前缀分目录,而不是一锅端。
4. 从路径到数据块的完整翻译过程
从用户进程调用 open 到真正定位到数据块,中间隔了 VFS、块缓存、驱动很多层,但文件系统层面做核心的事件就一件:一次一次地“查目录、拿 inode 号、读 inode”。
4.1 路径解析:逐级查表
ext2 的根 inode 号固定是 2。解析/home/user/a.txt时,内核从 inode 2 开始,读取根目录的数据块,在记录里找到 home 的 inode 号,读对应 inode 并确认它是目录且权限允许;然后读 home 目录,找到 user;再读 user 目录,找到 a.txt。每一级都要做权限检查和类型检查,路径多一级,冷缓存时的磁盘读就可能多一轮。
VFS 的 dcache 缓存了最近用过的目录项,所以反复访问同一路径不会每次都走完整流程。但第一次访问、缓存被回收之后,这套“逐级翻译”就会真实发生。弧线中每一级都返回一个 inode 号,最终拿到的 inode 号才是文件内容的户口,而不是路径本身。
4.2 新建文件时内核要做的事
新建文件时,内核要完成的典型操作链大致是:先在 inode 位图里找第一个空闲位,置 1,初始化 inode 并写入 inode 表;再在目标目录链上找空位或追加一条目录记录;最后更新父目录的 i_size、链接数、时间戳。如果第一次写数据,还要在块位图里占块。
这些步骤之间没有事务保护。任意一步中断,都会留下不一致状态,比如“inode 已经分配了但目录项没写”,或者反过来“目录项指向一个空 inode”。这就是 ext2 必须依赖 fsck 的根源。ext3、ext4 的日志本质上是给这一串动作记账,崩溃后回放日志就能补完或撤销,而不是全盘扫描。
4.3 删除文件时究竟释放了什么
删除分两层。unlink 先删除目录项,并把 inode 的 i_links_count 减 1;只有链接数减到 0,且没有进程还开着这个文件时,内核才真正回收 inode 和数据块。如果你删一个正在被 tail 跟踪的文件,它不会立刻从盘上消失,inode 会等最后一个 fd 关闭后才被回收。硬链接每多一个,链接数就加 1,所以删硬链接文件时少一次计数都不会触发回收。
真正回收时,内核要依照 i_block 的指示,把文件所有直接块、间接块、数据块逐个在块位图里清零。文件越大、间接层级越多,要解析和清除的块越多,删除延迟越明显。所以“删大文件卡一下”不只是数据量的问题,还有文件系统簿记工作的开销。
5. 把镜像翻出来:用 dumpe2fs 和 debugfs 亲手验证
理论讲再多,不如把盘翻出来看。下面用 e2fsprogs 自带的工具,自己造一个 ext2 镜像逐层解剖。
5.1 制作一个测试镜像
dd if=/dev/zero of=test-ext2.img bs=1M count=64 mkfs.ext2 -F -b 1024 test-ext2.img注意-F是必须的,镜像文件不是块设备,mkfs 默认会拒绝格式化,必须强制造镜像。小镜像我习惯把块大小降到 1KB,这样块组数量更多,组描述符和位图的变化更容易观察。挂载到临时目录:
mkdir -p /mnt/ext2test && sudo mount -o loop test-ext2.img /mnt/ext2test在镜像里创建几个文件后,先sync再umount,否则后面 hexdump 看到的是页缓存里的状态,不是磁盘上的真实字节。
5.2 从位图到 inode 表的一步步操作
dumpe2fs -h test-ext2.img可以查看超级块信息,dumpe2fs test-ext2.img会列出每个块组的完整描述,包括块位图所在块、inode 位图所在块、inode 表起始块、空闲块数等。先看它,能拿到所有关键偏移。
debugfs test-ext2.img进入交互模式后,常用命令有:
ls -l / stat /某个文件 stat <inode号>debugfs 可以绕过权限检查直接读写 inode,非常强大。我一般只用只读查询,避免误操作裸盘。
假设 dumpe2fs 告诉你第 0 组的 inode 表起始块是 19(不同 mkfs 参数会有变化,以实际输出为准),块大小 1024 字节,要看根目录 inode 2 的原始字节,先算字节偏移:19*1024 + (2-1)*128 = 19584,然后用 hexdump 看:
hexdump -C -s 19584 -n 128 test-ext2.img你会看到前两个字节是文件类型和权限位,i_size、时间戳都能对着字段表一个个挑出来。我第一次 hexdump 出 inode 的时候,才对“一切皆字节”这句话有了实感。
5.3 动手排错前必须记住的几条经验
排查结构问题时,第一件事永远是先看超级块和组描述符,确认块大小、inode 表位置,再算偏移。不看这些直接 hexdump,等于盲人摸象。
第二,操作镜像前记得 sync 和 umount。页缓存里存着最近改过的 inode 和目录块,热状态下你读到的可能是内存副本,不是磁盘上的持久状态,拿它做分析会得出错误结论。
第三,生产环境的真正磁盘千万别用 debugfs 的写命令。只读查询没问题,但任何写操作都可能把不一致的位图写坏。想练手就在自己造的镜像上随便折腾,没有心理负担。
6. 从 Ext2 到 Ext4:这套地基还能用多久
把这套结构吃透,再看现代文件系统会轻松很多。Ext2 看起来“过时”,但它的设计语言几乎原封不动地长在后代身上。
6.1 保留至今的“老骨架”
Ext4 的超级块魔数依然是 0xEF53,老 ext2 文件系统可以直接挂载而不需要迁移;inode 概念一脉相承,只是把 inode 大小做成动态扩展,并引入 extent 树替代传统间接块映射;目录项结构保留了 rec_len 这套链式机制,HTree 只是在它上面增加了索引层;块组概念也保留着,只是通过 flex_bg 变成松散大组。XFS、Btrfs 虽然复杂许多,但你一旦有了“文件系统就是把位图、inode、目录、块映射组织起来”的心智模型,读它们的设计文档就不会被各种术语淹死。
6.2 学习顺序上的建议
我的建议是先造小镜像,用 dumpe2fs、debugfs、hexdump 把每个结构亲眼确认一遍,再去读内核源码里 fs/ext2 目录。直接一头扎进源码很容易被缓存、锁和并发路径绕晕,因为内核里看到的函数调用和磁盘布局之间隔了好几层抽象。先建立“磁盘上有什么”的物理图景,再回来看代码,很多函数名就会豁然开朗。
我自己现在排查磁盘占用异常或 I/O 慢的问题时,还会经常用 debugfs 先确认元数据位置,再配合 stat、iostat 看行为。这套方法在 ext3、ext4 下同样适用。花一晚上把一个 ext2 镜像从头解剖到尾,是很值得的功课。