开头
如果你拿一块刚格式化完的硬盘,用dumpe2fs或者stat去翻它的底层信息,第一眼看到的东西基本都是这两样:一串一串的“块号”,以及按固定数量切分出来的“块组”。很多学Linux系统编程的人卡在文件系统这一关,就是因为这两个概念之间的跳跃太大了——知道文件是存在块上的,但不知道块是怎么组织成组的;知道mkfs.ext2能格式化,但不知道它到底在磁盘上写下了什么。
这篇我打算把Ext2的底层架构摊开来讲,从扇区到块,从块到块组,尽量说清楚每个设计决策背后的原因。适合正在看《Linux系统编程》系列、卡在文件系统章节的人,也适合想真正理解inode、位图、超级块这些名词到底长什么样、放在哪、怎么协作的读者。看完之后你至少能回答这几个问题:块组是什么、里面有什么、文件是怎么被放进一个块组的、以及为什么格式化完容量会变小。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
1. 从512字节到4K块:块这层抽象是怎么来的
1.1 硬件只认扇区,文件系统不认
磁盘硬件的最小读写单位是扇区,老式机械盘是512字节,现在很多新盘是4K扇区。操作系统读写磁盘的时候,驱动层最终都是按扇区地址去发命令的。但文件系统不可能按扇区来管理文件——512字节太小了,一个几十GB的分区如果按扇区编号管理元数据,光记录空闲扇区的表就能把内存吃垮。
所以文件系统引入了一层自己的抽象,叫块(block)。Ext2默认块大小是4096字节,也就是把连续的8个512字节扇区打包成一个逻辑单位。块这个概念只存在于文件系统内部,到了硬盘驱动层,它最终还是要换算成扇区地址。换算公式很简单:
code复制扇区地址 = 块号 × (块大小 / 扇区大小)
块大小4K,扇区大小512字节时,块号就是扇区地址右移3位。这事看起来就是个比例换算,但意义很大:文件系统所有空间管理、地址映射,都建立在这层抽象上,不需要关心底下的物理扇区怎么排布。
1.2 为什么偏偏是4K
Ext2在mkfs的时候允许指定块大小,从1024字节到4096字节都有。默认值选4096,是拿“空间利用率”和“寻址效率”做的折中。
块太小,比如1K,一个100KB的文件就要100个块来装,inode里的间接块索引很快就得往上叠加,而且文件碎片化的概率会明显增大。块太大,比如8K甚至64K,大文件是爽了,但小文件会浪费大量空间——一个100字节的文件也占一个8K块,分配率低的吓人。
4K是当年那个环境下平衡点最好的选择:一个块能装下足够多的数据,又不至于让小文件浪费太多空间。这个设定一直延续到了Ext4,哪怕Ext4的块大小上限已经支持到64K,默认依然是4K。
1.3 块号是文件系统的根本寻址方式
一旦块大小定下来,整个文件系统就变成一个从0开始的巨大“块数组”。文件系统里的任何一个位置,都可以用一个整数块号唯一表示。块号0、块号1、块号2……一直到最后一个块号,连续排列。超级块、块组描述符、位图、inode表、数据块,全部住在这些块号上。
这个设计带来的直接好处是:文件系统地址可以转化为非常简单的偏移量计算。比如要读块号为100的文件数据块,只需要让块设备驱动去读偏移100 × 4096字节处的那块数据。这种线性结构也为后面块组的划分打好了底子——既然整个文件系统是一长串块,那就把它们按固定数量切成一节一节,每一节独立管理,就是块组。
2. 单块索引撑不住大磁盘:块组设计是怎么“逼”出来的
2.1 位图管理的必然性
文件系统要回答的最基本问题是:哪些块空闲、哪些块被占用了。早期简单文件系统用链表串空闲块,每次分配要顺着链表找,效率很低。Ext2采用的是位图(bitmap)方式:每一个块对应一个bit,bit为1表示已分配,bit为0表示空闲。
位图的管理效率很高,但有个问题——如果整个磁盘只用一张位图,那么一个4KB的块位图最多能管理4K × 8 = 32768个块。块大小4K时,也就是128MB。磁盘稍大一点,位图就得占很多个块,查找和更新都会变慢,而且一张位图串起整个磁盘,并发访问时锁竞争会很严重。
所以Ext2的设计者选择了一个更自然的分治方案:把块数组切成等长的段,每一段配一套自己的元数据——自己的位图、自己的inode表、自己的数据区。这段被切出来的区间就是块组(block group)。
2.2 为什么要聚集元数据
块组的另一个核心动机是“相近的数据放一起”。机械硬盘时代,磁头寻道是最大开销。如果文件的数据块、inode、位图分散在磁盘各个角落,每次创建一个文件都要磁头来回跑好几趟。
块组的做法是把一组的元数据全部放在这一组的最前面:块位图、inode位图、inode表紧挨着,后面连续跟一大堆数据块。这样分配一个文件时,位图、inode、数据块都可能在很近的位置,磁头移动距离大大缩短。这个“把元数据集中放”的思路在整个文件系统设计史上都非常关键,后来的Ext4、XFS、Btrfs都延续了类似的局部性原则。
2.3 一个块组的容量怎么算
块组大小有硬性上限,因为它必须保证“一个块组的所有块能被一张块位图管理”。公式是:
code复制一个块组最多容纳的块数 = 块大小 × 8
块大小4K时,这一个块组最多能装32768个块,也就是128MB的数据区(不算元数据占用)。Ext2对单个块组还另有限制,比如inode数量不能超过65536个,但实际中往往是位图先到上限。
所以一个1GB的Ext2分区,用4K块格式化,大概会有8~9个块组;一个100GB的分区,块组数量就是800个左右。每多一个块组,就多一套位图和inode表,管理粒度变细,但元数据开销也略微上升。
3. mkfs.ext2 的一趟流程:把块组装出来的每步操作
3.1 格式化前你需要理解它在干什么
很多人跑完一句mkfs.ext2 /dev/sdb1就以为完事了,从来不想这条命令到底在磁盘上画了什么。实际上mkfs.ext2做的事情,可以粗略拆成下面几步:
- 计算分区大小,确定块大小(默认4K)和每块组块数。
- 根据分区大小算出需要的块组数量。
- 在每个块组内部规划好元数据区域的位置:块位图、inode位图、inode表、数据区。
- 在块组0开头写入超级块(superblock),建立块组描述符表(GDT),并按规定在部分块组写入冗余备份。
- 创建根目录(inode 2),创建
lost+found目录(inode 11),把对应inode号和数据块标记为已分配。 - 重置所有位图、inode表,初始化空闲块计数、空闲inode计数。
这一步一步说清楚很关键。很多人在学习时容易犯一个错误:以为mkfs主要是在写“文件”,实际上它是在写“管理结构”——位图和inode表才是大头,根目录和lost+found只是顺手建的两个普通文件。
3.2 超级块和块组描述符表的位置
Ext2中,块0是引导扇区预留区,不归文件系统管。真正的文件系统元数据从块1开始。
块1刚好是偏移1024字节处,超级块就放在这里,长度1024字节,占了块1的前半部分。超级块里记录的是全局信息:
- 文件系统魔数(0xEF53)
- 总块数、总inode数
- 块大小、每块组块数
- 每块组inode数
- 空闲块数、空闲inode数
- 挂载次数、挂载时间、上次fsck时间等
紧跟着超级块的,是块组描述符表(GDT, Group Descriptor Table)。这张表是一个数组,每个块组对应一个ext2_group_desc结构,里面记录了该块组的核心元数据位置:
- 块位图在哪个块
- inode位图在哪个块
- inode表起始块号
- 本组空闲块计数、空闲inode计数、已分配目录数
如果超级块是“文件系统的大脑”,GDT就是“所有块组的通讯录”。没有它,你连“块组1在哪、inode表在哪”都不知道。
3.3 超级块为什么要备份多处
超级块这么重要,万一坏了整个文件系统就废了。所以Ext2规定:块组0、1、3、5、7……这些编号为奇数的块组开头,都放一份超级块和GDT的备份。这叫“稀疏超级块(sparse superblock)”特性。
之所以只选部分块组而不是全部,是为了省空间。早期Ext2在每个块组都备份超级块,元数据开销太大,后来才改成稀疏策略。现在默认格式化时,只有块组1、3、5、7等奇数块组有备份。
提示:一旦超级块损坏,可以用
e2fsck -b 8193 <设备>手动指定备份超级块的位置来恢复。-b后面跟的是块号,不是偏移量。常见的备份位置是块32768、8193等,具体得按块大小算。
3.4 格式化后容量缩水的数学解释
格式化完用df -h看,总会发现可用空间比标称的小一截。很多人以为是厂商容量换算的锅(1G=1000M vs 1G=1024M),其实文件系统自身的元数据开销也是重要原因,尤其是inode表。
以4K块、每块组inode数为8192为例,每个inode结构是128字节(Ext2/3经典大小),一个块组里inode表就要占8192 × 128 = 1MB,也就是256个块。再加上块位图1个块、inode位图1个块、超级块和GDT开销,一个128MB的块组光元数据就吃掉约258个块。一个100GB分区,块组约819个,元数据总开销就是800MB左右。
这还没算根用户保留块(默认5%)。所以小分区格式化后容量看起来少很多,不是错觉,是真的有一部分空间被管理系统吃了。
4. 块组内部六件套:每个区域到底是干什么的
4.1 一张表看全块组布局
用4K块、每块组32768个块的典型布局是这样:
| 起始块 | 长度 | 内容 |
|---|---|---|
| 组起始块 | 1块 | 块位图(Block Bitmap) |
| +1 | 1块 | inode位图(Inode Bitmap) |
| +2 | 可变 | inode表(Inode Table) |
| 之后 | 剩余 | 数据块区(Data Blocks) |
如果这个块组还承担超级块备份的任务(奇数块组),那么组的最前面会先空出超级块和GDT备份的位置,然后才是位图。这一点在dumpe2fs输出里看得很清楚。
4.2 块位图和inode位图
块位图记录本组数据块区的分配情况,一个bit对应一个数据块。inode位图记录本组inode表中哪个inode被占用,一个bit对应一个inode。
这两个位图结构极其简单,但承担了整个文件系统最频繁的操作:分配和释放。每次创建一个文件,就要在inode位图里找一个空闲bit并置1,再在块位图里找一个空闲数据块bit并置1。删除文件则反过来,把对应的bit清零。
realize这一点,很多问题就能理解了:为什么删除100GB的大文件比复制它快?因为删除只是把位图里的几个bit归零,理论上是O(1)的操作。这也是为什么SSD的TRIM和文件系统删除操作是两回事——文件系统先把bit清掉,TRIM是后续再让SSD真正擦除物理页。
4.3 inode表:文件身份证的仓库
inode并不是“文件本体”,它是一份128字节的元数据结构体。里面记录:
- 文件类型、权限位(i_mode)
- 属主UID、属主GID
- 文件大小(i_size)
- 时间戳(访问、修改、状态变更、删除等)
- 链接数(i_links_count)
- 数据块指针数组:i_block[15]
inode表就是所有inode的数组,每个块组一个。inode编号是全局的,但物理存储位置是“块组号 + 组内序号”共同决定的。换算公式:
code复制块组号 = (inode号 - 1) / 每块组inode数
组内序号 = (inode号 - 1) % 每块组inode数 + 1
4.4 i_block[15]:文件数据从哪找
这个15个指针的数组是整个Ext2地址映射的核心。它的分配策略非常精妙:
- 前12个指针直接指向数据块(直接寻址)
- 第13个指针指向一个“间接块”,这个块里存放的是指向数据块的指针数组
- 第14个指针指向“二级间接块”,先找到一级间接块,再找到数据块指针
- 第15个指针指向“三级间接块”
为什么是12个直接指针?因为小文件才是文件系统的绝大多数。对于小于12 × 4K = 48KB的文件,只需要直接指针就能找到所有数据块,不需要任何间接寻址,效率最高。超过48KB才渐进式地上间接块。
一个4K块的间接块能存4096 / 4 = 1024个块指针,所以单间接寻址能覆盖4MB,二级间接能覆盖4GB,三级间接能覆盖4TB。这也是Ext2老版本单文件大小上限的来源。
4.5 数据块区:真正装内容的地方
数据块区就是普通的块数组,逻辑上和大楼里的房间一样。文件数据按块大小切分,写入连续或不连续的块。为什么文件会被切碎?因为数据块分配是动态的,磁盘空间用完时,新分配的块很可能不在原来的inode直接指针范围附近,碎片就产生了。
Ext2还提供了reserved blocks,也就是根用户保留空间,默认占5%。这部分空间普通用户用不了,只有root能写。这么做的目的是当磁盘写满时,系统至少还有空间可以运行关键进程、保存日志、做紧急维修。
5. 一个文件的“落地”全程:从路径解析到 debugfs 验证
5.1 目录本质是一个特殊文件
在Ext2里,目录(directory)不是什么特殊结构,它就是一个数据块内容被规定好格式的普通文件。这个文件的每个“条目”叫ext2_dir_entry_2,包含:
- inode号
- 文件名长度
- 文件名(最多255字节)
- 目录项类型
- 目录项大小(用于对齐)
每个目录项就是“文件名 → inode号”的映射。目录文件的数据块里,整整齐齐排着这些条目。
所以“查找一个文件”这件事,本质就是:从根目录的inode 2开始,一层层读出目录文件的数据块,在目录项里匹配名字,找到下一级目录或文件的inode号,然后读写那个inode对应的数据块。路径/home/user/test.txt的解析过程就是三次目录查找,每次都是“读目录数据块 → 比对名字 → 拿inode号”。
5.2 创建一个文件的完整链路
在Ext2中创建一个空文件,系统要做的事如下:
- 在父目录的数据块中找到一个空闲目录项位置,准备填入新文件名。
- 调用inode分配逻辑:从父目录所在块组开始,就近找一个空闲inode,把inode位图对应bit置1,初始化inode元数据(权限、时间戳、链接数=1)。
- 如果写入数据,则在块位图中找空闲数据块,将块号填入inode的i_block数组。
- 更新父目录的目录项,填入新文件的inode号和文件名。
- 更新相关块组的空闲inode数、空闲块数,更新超级块中的全局计数。
注意第2步的“就近分配”——分配inode时并不是全局范围内随机挑一个,而是优先从父目录所在的块组里找。这个策略叫Orlov块分配器,目的是增加目录局部性:把属于同一目录的文件尽量放在同一个块组里,减少跨组访问。
5.3 用 debugfs 直接看到底层结构
debugfs是排查文件系统问题时非常好用的工具。比如查看ext2超级块信息:
code复制debugfs -R "stats" /dev/sdb1
它输出的就是超级块和每个块组的统计信息,和dumpe2fs的输出差不多。想精确查看某个文件的inode内容:
code复制debugfs -R "stat /tmp/test.txt" /dev/sdb1
这条命令能看到文件对应的inode号、文件模式、大小、链接数、以及i_block数组里的块号列表。配合:
code复制debugfs -R "blocks /tmp/test.txt" /dev/sdb1
能直接列出文件占用的所有块号。想看某个块里存的是什么,可以用:
code复制debugfs -R "cat /tmp/test.txt" /dev/sdb1
注意:
debugfs适合在卸载状态下检查,或者在只读模式下挂载后再查看。在读写挂载状态下用debugfs修改内容,容易和内核Cache冲突,造成数据不一致。
5.4 为什么小文件不“浪费”额外空间
时常见有人问:一个100字节的文件,是不是还要额外分配一个inode块放inode,再加一个数据块?答案是:占用空间是两部分,inode放在inode表里,固定128字节(其实是占inode表预分配好的空间),而数据块区要分配至少一个块,哪怕文件只有100字节。
所以,1百万个100字节的小文件,数据块区的“浪费”是每个文件约4K减去实际内容。这也是为什么存储海量小文件时,建议用小块大小重新格式化(比如1K块)。块变小,直接寻址范围内的文件就能少占空间,但间接寻址深度可能会大一些。这是薛定谔式的取舍:没有绝对最优,只有针对场景的匹配。
6. 块组设计埋下的坑与它给出的答案
6.1 碎片化:文件被打散到多个块组
跨组分配并不是刻意设计的,但在磁盘空间紧张时会发生。比如A目录的块组空间不足,但B目录的块组还有空闲,那新的数据块就不得不放在B块组。这块数据离inode的距离变远,机械盘读起来要多花寻道时间。
Ext2对碎片的管理比较初级——没有内置碎片整理功能,只能靠e2fsck -f检查后再用e4defrag这类工具尝试整理。这里的建议是:保持分区剩余空间在15%以上,碎片率会显著下降。
6.2 保留块:为什么root永远有救
默认5%的保留块,在4K块的文件系统上就是约占总容量的5%的块。这些块在普通用户眼中是“已使用”,不管空闲多少,非root用户都无法再写入。很多人刚用Linux时发现磁盘明明有空间却提示No space left,就是被这个设置坑了。
可以用tune2fs -m调节这个比例。比如把保留比例改成2%:
code复制tune2fs -m 2 /dev/sdb1
对大容量数据分区,5%的保留空间可能太奢侈了;但对系统盘,保留空间就是保命用的。调低保留比例后,一旦磁盘写满,连root都可能无法登录或记录日志,这个教训我踩过一次之后就老老实实恢复系统盘的默认值了。
6.3 inode数量固定:文件数量上限
Superblock里记录的inodes_count是格式化时就定死的。Ext2不会动态生成新的inode,inode表在mkfs阶段就固定了大小。所以一个分区的“文件总数上限”在格式化那一刻就锁死了。
如果inode耗尽了,哪怕磁盘还有几十GB空余,你也创建不了新文件。检查方法:
code复制df -i
看到IUsed%接近100%,就是inode耗尽。解决办法有两个:一是重新格式化,用-N参数调高inode数量;二是把一部分小文件转移到别的分区。在批量缓存文件的场景(比如node_modules密集项目),inode配额要提前算清楚。
6.4 fsck 能救回来的原因:冗余设计
fsck之所以能恢复一部分损坏数据,依赖的就是Ext2的冗余设计。超级块和GDT在稀疏块组有备份,fsck发现主超级块异常时,可以读取备份超级块重构结构。
断电、强制重启后,如果出现文件系统不一致,fsck会遍历所有块组的位图和inode表,重新核对每个块、每个inode的引用关系,把不一致的地方修正并写回。
但注意,fsck不是万能的。它的恢复能力建立在“位图和inode表还能读出来”的基础上。如果连inode表都被覆盖了,那么文件的数据块虽然还在,却已经“找不到归属”了。这也是为什么文件恢复软件能找回“已删除文件”——删除只清了位图,inode和数据块的内容还没被覆盖,恢复工具就是在inode表里找残留的inode记录。
6.5 为什么Ext2意外断电后要那么久才能开机
Ext2没有日志(journal),每次异常掉电后重启,fsck必须全盘扫描,把每个位图、每个inode都检查一遍。分区越大,扫描越久,这就是很多人对旧时代Linux“断电后开机要等半天”的记忆来源。
这也是Ext3/Ext4引入日志的最直接原因:先写日志,再改元数据。如果中途断电,重启时只要重放日志,就能快速恢复到一致状态,不需要全盘扫描。这个问题的根源依然是块组结构——元数据分散在每个组里,不加日志就没法保证一致性。
7. 站在块组肩膀上:从 Ext2 到 Ext4 的演进逻辑
7.1 Ext3:只给Ext2加了日志
Ext3和Ext2的磁盘布局几乎一模一样,唯一的重大区别是它增加了日志区(journal),可以先记操作意图再落到元数据上。所以Ext3可以直接mount已格式化的Ext2分区,反过来却不行。块组的位图结构、inode表结构、目录项结构都没变。本质上,Ext3就是“带日志的Ext2”。
7.2 Ext4:块组还在,但管理方式换了
Ext4并没有推翻块组设计,而是把块组做成了“虚拟聚合”的形态:
- flex_bg特性:把多个块组聚合成一个flex block group,把它们的位图和inode表集中放置,减少元数据区域分散。
- extent树:用
extent(ext4_extent结构)替代ext2的间接块索引,一次可以描述一段连续块区间,大文件的地址映射效率大幅提升。 - 64位块号支持:块号从32位扩展到64位,文件系统容量上限突破16TB(Ext2/3时代的上限)。
- 多块分配(multiblock allocator):一次分配多个连续块,削弱碎片化问题。
即便如此,它的底层还是“超级块 + 块组描述符 + 位图 + inode表 + 数据块”这一套Ext2骨架。你理解了Ext2,看Ext4的每个新特性都是有明确动机的增量修改。
7.3 这套架构为什么能活这么多年
块组架构从1993年的Ext2到现在的Ext4,核心思想没变过:分而治之、元数据局部化、位图管理空闲资源。它的生命力来自一个被实践反复验证的道理——文件系统的瓶颈往往不在算法复杂度,而在磁盘的物理寻址特性和元数据的一致性保障。块组把“元数据贴近数据”这件事做到了极致,后来的XFS的AG(Allocation Group)设计、Btrfs的块组树结构,本质上也都在用类似的分区治理思路。
我个人在做存储相关项目时的体会是,理解块组结构最大的收益不是能背出结构体字段,而是在排查“为什么inode耗尽”“为什么df和du不一致”“为什么删了大文件空间没释放”这类问题时,能一眼定位到问题发生在哪一层:是位图没更新,是inode表写坏了,还是超级块的计数没有同步。搞清楚这一层,后面再去看VFS、page cache、IO调度,都会顺很多。
最后再分享一个我自己常用的验证小技巧:格式化完一个新分区后,先用dumpe2fs把输出存一份;等系统跑了一段时间、删过一堆文件、写过一堆数据之后,再对比看块组描述符里的空闲块数和空闲inode数变化。你会发现所有操作都能通过位图和计数的变化“对上账”,这种感觉比看十遍书上的结构体定义都来得直观。
