
写这篇东西的动机很简单前阵子公司内部做文件系统相关的分享我发现不少同事天天在用df、ls -i、stat但被问到底层 inode 和数据块怎么映射的、目录项长什么样基本都说不太清楚。这其实很正常——现代 Linux 上 ext4、xfs 这些文件系统已经把很多细节藏得很深用户态拿到的是抽象好的结果。但如果你想搞嵌入式、做内核开发、排查磁盘空间诡异膨胀、甚至恢复误删文件不懂底层机制会很吃亏。与其一上来就啃 ext4 的复杂日志逻辑不如先把 Ext2 吃透。Ext2 发布于 1993 年没有日志、没有延迟分配、没有 extents结构纯粹得像教科书。但正因为纯粹它把块组、inode、数据块、目录项这套核心机制暴露得一清二楚。理解了 Ext2ext3/ext4 的大部分概念都能无缝迁移过去差异点反而更容易理解。这篇文章我准备用一次完整的实操视角来讲从磁盘布局到块组内部结构从 inode 解剖到目录项底层长相最后亲手做一个 ext2 镜像用工具一步步围观一个文件从创建到落盘的完整过程。建议你把这篇文章当成一个可跟做的实验手册别光看把命令敲一遍。1. 为何要啃 Ext2 块组磁盘不是一块大饼1.1 把磁盘切成小组而不是摊大饼很多人的第一反应是文件系统不就是一个大数组嘛文件数据往里存不就完了理论上确实可以这么做但实际工程里没人这么干。原因很简单如果一整块磁盘只有一个统一管理区那么“哪个数据块空闲、哪个数据块被占用”这张表会非常庞大修改任何一个小文件都可能要读写这张全局表并发操作还能打起来。Ext2 的做法是把磁盘划分成若干个等大的块组Block Group每个块组独立管理自己内部的数据块和 inode。这就好比一个大仓库被划分成很多个独立货架区域每个区域有自己的登记本找东西和记录东西都只在对应区域里完成。多个区域还能并行维护性能上更友好。块组划分带来的另一个好处是局部性。一个目录下的文件尽可能被分配到同一个块组所以遍历目录、批量读取文件时磁盘寻道距离更短。这个设计思想后来被 ext4 的 flex_bg、mballoc 等机制进一步强化但根源在 Ext2 就已经定下来了。1.2 块组内部各玩各的但整体有一张总图每个块组内部大致有六样东西超级块的副本、块组描述符表、块位图、inode 位图、inode 表、数据块区。你可能会问超级块不是全盘一份吗是的但 Ext2 为了避免超级块损坏导致整个文件系统报废会在部分块组里存超级块副本。块组描述符表也一样它记录着每个块组的元数据位置信息。这里有一个常见误区不是每个块组都有超级块副本。默认情况下只有块组 0、1 和编号为 3、5、7 的幂次方块组才会存放超级块副本。所以你在看到dumpe2fs输出里某些块组“没有超级块备份”时不用惊讶这是设计使然。一句话总结块组的意义把全局管理拆成局部管理元数据读写不抢一把锁磁盘空间分配尽量靠近元数据损坏范围尽可能隔离在单个块组内。2. 块组内外的核心结构超级块、描述符表、位图与 inode 表2.1 超级块整个文件系统的“户口本”超级块存在块组 0 的最开头偏移 1024 字节处也就是第 1 块之后的位置。它记录了文件系统的全局参数块大小、块总数、每组块数、每组 inode 数、inode 大小、第一个可用的 inode 号、卷名、挂载次数、保留块比例等。用dumpe2fs可以直观看到这些信息。我创建了一个 16MB 的 ext2 镜像执行dumpe2fs ext2.img之后输出里最关键的几项如下Block size: 1024 Blocks per group: 8192 Inodes per group: 2032 Inode size: 128 First inode: 11这些参数决定了块组的布局。比如块大小 1024、每块组 8192 块那么这个文件系统的块组大小就是 8MB16MB 镜像只有 2 个块组。这里想提醒一句mkfs 的默认参数会根据文件系统大小自动调整比如块大小可能是 1024、2048 或 4096每组 inode 数也会变所以你在自己机器上实操时看到的值和我这里不一样是正常的。超级块里的另一个重点是最小挂载次数和最大挂载次数。Ext2 不是日志文件系统启动时检查磁盘一致性主要靠这个计数如果挂载次数超过设定值系统就会强制 e2fsck。这也是为什么老式 Linux 用 ext2 时开机会偶尔卡在 fsck 上。2.2 块组描述符表每个块组的“门牌号码”块组描述符表紧跟在超级块后面每项 32 字节对应一个块组。里面记录的是块位图起始块号、inode 位图起始块号、inode 表起始块号、当前组空闲块数、当前组空闲 inode 数、当前组目录数。这块是定位一切元数据的入口。想找一个块组里 inode 表在哪不看描述符表是找不到的。因为 mkfs 会根据块组数量、块大小动态计算这些位置没有任何“第 N 块固定是 inode 表”的硬编码规则。实操中如果你想读原始盘上的描述符表可以用dumpe2fs直接看它已经把二进制解析好了。原始读取用dd从块组描述符表区域抓取再解析也行但除了写工具练手日常完全没有必要。2.3 位图一张图盯住空闲与占用每个块组内有两张位图块位图block bitmap和 inode 位图inode bitmap。它们各占一个数据块的空间。以 1024 字节块为例一个块能表示 8192 个数据块/ inode 的分配状态正好覆盖一个块组的容量。位图里的一位对应一个数据块或 inode 表项1 表示已占用0 表示空闲。分配空间时文件系统就在位图里找第一个为 0 的位把它置为 1然后把对应的块分配给文件释放时反向操作。这个设计意味着创建文件时 inode 从哪里来、数据块从哪里来完全由位图的扫描顺序决定。实际观察中你会发现新文件经常拿到“当前还能找到的第一个空闲 inode”而不是逻辑上离得最近的。这个细节在排查“为什么明明删了很多文件df 显示空间还是不减”时会用到——如果你删的是硬链接只有最后一个链接删除时 inode 才会真正释放。2.4 inode 表inode 的“落户区”每个块组里都有一片连续的块专门存放 inode 表表项数量由超级块里的 Inodes per group 决定。每个 inode 在表里的偏移就是它的编号减一。比如编号 12 的 inode就在 inode 表起始块偏移 (12-1) × inode_size 的位置。这里的编号规则很重要inode 编号从 1 开始但 inode 0 不用。编号 1 是坏块列表2 是根目录11 通常是 lostfound。这也是为什么你在 ext2/3/4 上ls -i /看到根目录 inode 一定是 2。一块空白盘上第一次创建文件拿到的大概率是 12 号 inode因为 1-10 有保留用途11 是 lostfound。我很早以前以为新文件会从 1 开始分配后来用 debugfs 看才发现系统直接把前 10 个保留掉了这也是 Ext 系的一个历史包袱——保留 inode 给未来功能扩展用。3. inode 解剖128 字节如何撑起一个文件3.1 inode 结构体文件的“身份证”兼“索引卡”inode 存的是文件的元数据和数据块指针不存文件名。文件名在目录项里这个必须分清。一个 Ext2 inode 占 128 字节核心字段包括mode文件类型与权限普通文件/目录/符号链接等共 16 位uid / gid属主和属组size文件大小字节timestampsatime、mtime、ctimeblocks文件占用的扇区数512 字节为单位i_block15 个 32 位指针重点说 i_block 这 15 个指针。前 12 个是直接块指针直接指向数据块。第 13 个是单间址指针指向一个装满块指针的块第 14 个是双间址第 15 个是三间址。这种索引机制决定了文件大小上限和访问路径长度。以 1024 字节块大小、指针占 4 字节为例单个指针块能装 256 个指针。所以直接寻址最多 12KB单间址 256 块 256KB双间址 256×256 65536 块 64MB三间址 256×256×256 16777216 块 16GB。加起来一个文件理论最大约 16GB。如果块大小换成 4096三间址上限能到 64TB。这也是为什么 ext4 后来引入 extents 的重要原因间接寻址对超大文件很不友好读一个文件尾部需要一级一级去“翻抽屉”磁盘 I/O 次数显著增加。老 ext2 时代大家觉得 16GB 够用现在一个文件几十 GB 很常见间接寻址的性能劣势就非常明显了。3.2 小文件与符号链接的优化Ext2 有个很有意思的优化如果文件很小比如只有 50 字节12 个直接块指针里有几个指向真实数据块其余的为 0。但如果文件是一个短符号链接且目标路径不超过 60 字节内核会直接把目标路径字符串存在 i_block 数组里不分配数据块。这就是为什么符号链接不开辟数据块也能读取目标。同理空文件不一定不占块。一个空普通文件的 inode 里直接块指针全部为 0size 为 0blocks 为 0确实不占数据块空间。但要注意目录哪怕空也不是 0 块——目录至少要有一个目录项自引用所以会占用数据块。这个在“为什么目录的 du 大小不是 0”里经常被问到。3.3 inode 与磁盘空间的换算关系很多人第一次看stat file输出会蒙Blocks: 8那一项为什么和Size: 14对不上因为 stat 里的 Blocks 以 512 字节扇区为单位而文件系统实际分配块大小是 1024/4096。一个 14 字节的文件分配了 1 个 1024 字节块换算成 512 字节扇区就是 2 块但 inode 里的 blocks 字段统计的是实际扇区数加元数据块开销。如果你用du -h看小文件看到的也是实际分配块大小比如 4K不是文件逻辑大小。这个坑我见人踩过无数次du结果比ls -l大是正常的因为分配粒度和块大小不同。反过来稀疏文件du比ls -l小也正常因为没写入的空洞块没有真正分配。4. 目录的底层长相目录项不是你以为的那个结构4.1 目录也是文件内容是目录项数组在 Ext2 里目录本身就是一个普通文件只不过它的数据块内容是你不太敢想象的“乱糟糟的一串记录”。每条记录是一个目录项struct ext2_dir_entry_2结构如下inode该项对应文件的 inode 号rec_len目录项记录长度name_len文件名长度file_type文件类型1普通文件2目录7符号链接name文件名不定长目录不是一张规整的哈希表而是一条线性链表式的记录列表。查找一个文件名时内核从目录数据块开头逐条扫描目录项比对名字直到找到匹配项。目录项越多平均查找时间越长。这也是为什么大目录性能差线性扫描的复杂度是 O(n)不像数据库索引那样 O(log n)。4.2 目录项的“对齐填充”玄机目录项有一个关键细节每条记录按 4 字节对齐所以 rec_len 会把文件名补齐到 4 的倍数。比如文件名 a 长度为 1目录项头 8 字节加上文件名字节 1 字节理论是 9 字节但为了对齐rec_len 会是 12。这个对齐机制对理解目录大小变化特别重要。删除一个文件时系统会把前一个目录项的 rec_len 扩展到覆盖被删除目录项的长度而不是真的从物理上抹掉那 4 字节。所以你会发现删了很多文件后目录文件大小不缩小但ls -l看到目录是空的。目录项占用的空间会一直留在那里等下一次创建文件时内核判断“现有 rec_len 够不够放下新目录项”够就复用不够才追加新块。4.3 目录查找流程从文件名到 inode 到数据当你要打开/home/user/test.txt时内核在内存里其实做了一次“逐级目录项查找”从根目录 inode编号 2开始读取根目录的数据块在目录项里找到home对应的 inode 号读取home目录的 inode再读取它的数据块在home目录项里找到user继续下钻在user目录项里找到test.txt的 inode 号读取test.txt的 inode然后按 inode 里的数据块指针读取文件内容所以你每一次打开文件的系统调用背后都是一次目录项线性扫描加 inode 定位的组合操作。路径越长扫描层数越多。这也是为什么把文件分散到多级目录、而不是全堆在一个目录里对性能是有实际帮助的。5. 实操亲手做一个 Ext2 镜像并围观一个文件的诞生下面这部分我建议你照着敲一遍。不需要 root不需要真盘全程用一个普通文件模拟块设备工具只要mkfs.ext2、dumpe2fs、debugfs。5.1 创建并格式化一个 ext2 镜像首先创建一个 16MB 的空白文件并格式化为 ext2dd if/dev/zero ofext2.img bs1024 count16384 mkfs.ext2 -b 1024 ext2.img-b 1024手动指定块大小为 1024 字节这样块组数量更多、每个块组内的结构更容易用 dd 定位。格式化完成后看一眼文件系统布局dumpe2fs ext2.img输出里关注 Blocks per group、Inodes per group、First inode、Block size 这几项。我这边得到的块组数量是 2 个即块组 0 和块组 1。块组 0 有超级块和块组描述符表块组 1 没有超级块备份只有后面四项。5.2 挂载、写入、把文件“打回原形”mkdir -p /mnt/ext2test sudo mount -o loop ext2.img /mnt/ext2test sudo mkdir /mnt/ext2test/data sudo sh -c echo hello ext2 internal /mnt/ext2test/data/hello.txt sudo umount /mnt/ext2test挂载后创建的hello.txt很小但也足够观察 inode 和数据块分配了。如果你不想借 sudo 和 mount也可以直接用 debugfs 往镜像里写文件debugfs -w -R mkdir /data ext2.img debugfs -w -R write /tmp/hello.txt /data/hello.txt ext2.img我两种方式都试过结论完全一致。用 debugfs 的好处是不依赖 root纯粹的镜像操作更安全。5.3 从 debugfs 里挖出 inode 和数据块拿到镜像文件后用 debugfs 打开查看文件 inodedebugfs ext2.img stat /data/hello.txt输出会告诉你 inode 号、文件大小、块数、直接块指针指向的块号。比如我这边文件拿到了块号 538inode 号是 12。注意这个块号是文件系统块号不是磁盘偏移的物理块号它已经包含了文件系统起始偏移在内。接着查一下 12 号 inode 在盘上的原始位置。先看块组描述符用 debugfs 的stats或者dumpe2fs -g。假设块组 0 的 inode 表起始块是 243块大小 1024那么 12 号 inode 在镜像文件中的字节偏移大约是偏移 1024 (243 - 1) * 1024 (12 - 1) * 128这里的 1024 是超级块起点偏移(243-1)*1024是因为文件系统块号从 0或从 1计数的换算(12-1)*128是 inode 表内偏移。公式里的具体块号要以你本机的 dumpe2fs 输出为准不同镜像不一样。我用 dd 把这块区域抠出来再用 xxd 看十六进制能看到文件大小 21对应hello ext2 internal\n的长度和第一个直接块指针 538。5.4 用 xxd 和 dd 直接看目录项和数据块再用 debugfs 看目录项ls -l /data这条命令显示的是目录内容。但真正底层的内容要看目录文件的数据块。目录/data本身也有 inode用stat 2找到它的数据块号然后dd ifext2.img ofdirblock bs1024 count1 skip目录数据块号 xxd dirblock你会看到一个比较直观的二进制先是.和..两个目录项然后是hello.txt的目录项里面带有 inode 号 12、文件名长度、文件类型。这就是目录项的原始模样。再回看数据块dd ifext2.img bs1024 count1 skip538 | xxd应该能看到hello ext2 internal的 ASCII 内容。到这一步你实际上已经完成了一个完整闭环从文件名出发通过目录项找到 inode从 inode 找到数据块最终在数据块里读出文件内容。6. 常见问题与避坑速查表6.1 为什么 df 显示已用空间比目录 du 加起来大原因很多最常见的是文件系统元数据本身占空间、保留块占空间默认 5%、已删除但仍被进程占用的文件、日志文件ext3/4 有 journal等。在 ext2 上保留块的比例可以看超级块里的 Reserved block count。想验证直接对比dumpe2fs里的 Free blocks 和df输出就明白了。6.2 误删文件还有救吗inode 层面发生了什么删除文件时系统做了三件事把目录项里的 inode 置 0、把 inode 位图对应位置 0、把数据块位图对应位置 0。文件数据本身不一定被立即覆盖所以理论上能恢复。但数据块被标记为空闲后后续任何文件写入都可能立刻占用同一个块。这也是为什么误删后立刻停用磁盘是铁律——尽量减少覆盖概率。至于 inode 恢复情况更麻烦。即使从目录项残余信息里还原了 inode 号inode 表项里那些数据块指针也需要在磁盘上找回来。老手会优先去翻块组里的数据块位图找找有没有“看起来像是文件内容但不在任何 inode 引用下”的块再手工把 inode 指针修复回去。这个过程极其耗时成功率取决于碎片程度。6.3 为什么删除百万个小文件后磁盘空间“没回来”这个问题十有八九是目录项复用机制造成的。目录数据块不会因为删除就被释放回位图——目录文件本身分配的块只有在目录数据块空到一定程度、触发目录收缩逻辑时才会释放而 ext2 压根没有完善的收缩机制。所以你在一个目录里创建又删除大量小文件最终那个目录文件可能膨胀到几百 MB 却只有几个实际文件空间全被目录项历史残留占据。解决办法就是定期重建目录把文件迁走再删掉旧目录。6.4 常用问题速查表问题场景底层原因排查建议小文件 du 比 ls 大分配粒度为块大小用stat -c %b查看实际块数df 与 du 统计不一致元数据/保留块/已删未释放对比dumpe2fs的 Free inodes/Blocks目录删文件后不缩小目录项 rec_len 合并复用重建目录inode 用尽但空间充足inode 表耗尽查每组 Inode count重建或扩容访问大文件性能差间接寻址多级跳转换 ext4 或用更大的块大小6.5 一个关于工具的心得很多人一看到 debugfs 交互界面就发怵但实际最常用的命令就几个stat看 inode、ls看目录项、blocks看数据块、dump导出文件。想调试块组布局dumpe2fs -g比交互界面更直观。我建议你把这几个工具组合成“体检套装”格式化一个新盘后用dumpe2fs看布局创建一个文件后用debugfs看细节再用dd抠原始字节核对。跑一遍这套流程ext2 在你心里就不是图而是具体到块的物理现场了。最后再分享一个小技巧如果你在做嵌入式开发、需要定制文件系统镜像genext2fs这类用户态工具比 mkfs 更可控它可以完全不依赖 root 权限直接把一个目录打包成 ext2 镜像。遇到在系统启动阶段挂载根文件系统的场景时这个工具能救大命。配合本文说的 debugfs 验证手段你能清清楚楚看到打包进去的每个文件到底落在哪个块组的哪个块上排查启动失败类的文件系统问题会顺手很多。