Linux文件系统(二):ext2 结构与路径解析
文件名 → dentry → inode → data block用户通过路径访问文件,系统则通过目录逐级解析,借助 dentry 加速查找,最终通过 inode 定位到具体的数据块但这些结构是如何在不同文件系统之间被统一管理的?操作系统又如何对外提供一致的文件接口?在下一篇中,我们将引入 VFS(虚拟文件系统),进一步理解 Linux 文件系统的整体架构。
目录
一、文件系统基础
在上一篇文章中,我们已经了解了磁盘物理如何被抽象为连续线性地址空间(LBA)。然而,面对这一段由 0 至 N 统一编号的大容量存储空间,仍存在诸多管理问题:数据应存放于哪个地址单元,又如何为一段连续的数据进行标识与命名?
本文将对 ext2 文件系统展开详细解析,以明确一个核心问题:文件是如何在磁盘上组织,并通过路径被找到的?
1. 为什么需要文件系统
当你买回一块新磁盘,在 Linux 中通过 fdisk -l 能够看到它,但你却无法在上面 touch 任何文件。此时的磁盘对操作系统来说只是原始块设备
-
没有文件系统 = 只有原始块:这就好比你有一个巨大的仓库,里面没有任何货架、没有任何账本。你只能告诉搬运工:把这吨货存在第 4096 号地板上

-
格式化的本质:就是写入规则。当你对分区执行 mkfs.ext2 时,系统会往磁盘里写入管理元数据。这就像为原始磁盘空间建立了管理制度,将无序的存储块转变为结构化的文件系统
2. ext 文件系统家族
在 Linux 系统的发展历程中,ext 系列文件系统始终占据着主流地位
| 版本 | 核心特点 |
| ext2 | 经典布局,奠定了 Linux 文件系统的基石。虽然没有日志功能,但结构最纯粹 |
| ext3 | 在 ext2 基础上增加了日志功能,解决了意外断电导致的文件系统崩溃问题 |
| ext4 | 现代主流。引入了 Extents(区段) 管理,极大地优化了大文件的存储效率 |
核心设计的一致性:
尽管版本在迭代,但它们基于索引节点(inode)和数据块(Data Block)的核心架构从未改变。掌握 ext2 的设计原理,即可为理解各类现代文件系统奠定坚实基础
3. 分区与分治思想
面对动辄几个 TB 的磁盘,文件系统如果尝试一次性管理整个物理空间,效率将极为低下。为了解决这个问题,操作系统采用了经典的分治思想
磁盘的分级治理流程
数据的组织遵循以下层级:
-
物理磁盘(Disk):最底层的物理载体
-
分区(Partition):通过分区表,将大磁盘切割成一个个独立的逻辑区域。管理一个分区,就等同于管理了整个磁盘
-
文件系统(File System):在一个分区内,我们按照特定的规则(如 ext2)进行初始化
-
块组(Block Group):文件系统依然觉得分区太大,于是将分区进一步切分为若干个等大的块组

文件系统的设计本质上是在物理机械性能与逻辑组织需求之间寻找平衡。通过 "分区 -> 分组" 这套拆解,复杂的磁盘管理变成了一个局部的、可复制的小规模管理问题
二、ext2 整体布局
文件系统采用分治策略作为最高效的运行法则。ext2 并没有把整个分区看作一块铁板,而是将其切成了一个个互不干涉又逻辑统一的块组(Block Group)
1. block group
划分块组本质上是为了降低管理的复杂度
一个分区由成百上千个块组组成。每个块组的内部结构高度一致。对于内核来说,管理好一个块组,就能管理好所有块组,进而掌控整个分区乃至物理磁盘
-
缩短寻道距离:划分块组后,文件系统会尽量让一个文件的 inode(属性)和它对应的 Data Block(内容)处在同一个块组里
-
性能优化:磁头在读完属性后,能以极短的物理位移迅速读到内容,减少了机械臂在大范围内摆动的时间成本
2. block group结构

Superblock(超级块):文件系统的大脑
-
作用:存储整个文件系统的全局信息,包括:块大小(1KB/2KB/4KB)、总块数、空闲块数、总 Inode 数、挂载时间等
-
超级块非常重要,如果被损坏整个文件系统就瘫痪了。因此,ext2 会在多个块组中存放超级块的备份,以防不测

GDT(Group Descriptor Table 块组描述符表):块组管理
-
作用:记录当前块组的档案信息,比如:这个块组中从哪里开始是 inode Table,从哪里开始是数据块,空闲的块还有多少个等等。块组描述符在每个块组的开头都有一份拷贝

Block Bitmap(块位图):数据空位标识
-
作用:用一个位(bit)来代表一个 Data Block 的状态
-
1 表示该块已被占用;
-
0 表示该块是空闲的,可以被写入
-
-
当你要存一个 4KB 的文件时,OS 只需要检查位图,就能立即定位到可用的空闲块
Inode Bitmap(Inode 位图):inode 空位标识
-
作用:与块位图类似,只不过它管理的是 Inode。它记录了 inode table 中哪些位置已经存了文件的属性,哪些还是空的
Inode Table(Inode 表):inode 存储
-
作用:这是块组中最核心的区域之一,存储了该块组内所有文件的 Inode 结构体。在格式化时,Inode 表的大小和数量就已经固定好了
Data Block(数据块):数据块存储
-
作用:占据了块组 90% 以上的空间。这里不存属性,只存文件真正的内容数据
Bitmap 解决了 “去哪存” 的问题;Inode Table 解决了 “怎么管” 的问题;Data Block 解决了 “存什么” 的问题。Superblock 作为全局协调者,确保整个系统协调运行
三、inode 与数据块
块组可视为文件系统的基本管理单元,而索引节点 inode 与数据块则分别承担文件元数据记录与实际数据存储的功能。在 ext2 文件系统中,通过一套高效的多级索引结构,单个 inode 即可实现对大小从数字节至数 TB 文件的统一管理
1. inode结构
在 Linux 中,文件的属性被严密地封装在 inode 结构体中
核心字段
一个典型的 ext2 inode(通常大小为 128 字节)包含以下关键信息:

2. 通过编号定位 inode
在 ext2/ext3 文件系统中,一个 inode 的标准大小是 128 字节。 在更现代的 ext4 中,为了存更多的纳米级时间戳和扩展属性,默认大小通常是 256 字节
这个大小是在磁盘格式化时就定死的,记录在超级块里。这意味着在一个特定的分区里,每个 inode 的体型是一模一样的,为查找操作提供了物理基础
寻址算法
假设我们现在要找编号为 262145 的 inode。操作系统会经历三步运算:
第一步:定位所在的块组
文件系统在格式化时,每个块组能存放多少个 inode(inodes_per_group)是固定的
公式:
通过这个商,OS 立刻知道该去第几个块组查找
第二步:计算在 Inode Table 中的索引
我们要知道这个编号是该块组里的第几个 inode
公式:
第三步:计算绝对物理偏移量
OS 会去查 GDT,找到该块组的 inode_table 起始块号
最终物理位置:
当 OS 读入这个 128 字节的结构体后,就可以通过 i_block 数组找到映射的
OS 一次能读取多少个 inode?
操作系统从来不会只读 128 字节。因为磁盘 IO 的基本单位是块,在 Linux 下通常是 4 KB
-
批量搬运:inode 是 128 字节,一个 4 KB 的块里正好塞下了 4096 / 128 = 32 个 inode
-
访问 inode 1 时,内核会顺便把同一个块里的 inode 2 到 inode 32 全部读进内存的中
文件系统具有局部性原理。通常在同一个目录下的文件,它们的 inode 编号往往是连续或临近的。OS 会预判:当你读取文件 A的属性后,很可能就会去读旁边的文件 B
3. 数据块组织方式
你可能会疑惑:i_block 数组其实只有 15 个元素,如果每个块大小是 4KB},那一个 inode 难道只能管 15 * 4KB = 60KB 的文件吗?
为了解决大文件存储问题,ext2 设计了一套多级索引系统。这 15 个指针被划分为四个等级:
(1) 直接块:0 - 11 号指针
前 12 个指针(i_block[0] 到 i_block[11])是直接块指针,它们直接存储数据块的编号
-
容量:12 * 4KB = 48KB
-
特点:访问最快,适合处理系统中的海量小文件
(2) 一级间接块:12 号指针
-
它不指向数据,而是指向一级索引块。这个索引块里整齐地排满了其他数据块的编号
-
计算:假设一个指针占 4 字节,一个 4KB 的索引块可以存 1024 个指针
-
容量:1024 * 4KB = 4MB
(3) 二级间接块:13 号指针
-
开始套娃。它指向二级索引块,该块里的每个指针又指向一级索引块,最后才到数据块
-
容量:1024 * 1024 * 4KB = 4GB
(4) 三级间接块:14 号指针
-
终极套娃
-
容量:1024 *1024 *1024 * 4KB = 4TB

对于小文件,通过直接块瞬间就能定位到数据;对于大文件,虽然增加了几次磁盘寻址(多级索引读取),但保证了 inode 结构体的长度固定(永远是 128 字节)。这种以时间换空间的弹性架构,支撑起了 Linux 处理海量数据的能力
4. 通过 inode 查找 data block
假设文件系统块大小是 4KB。100KB 的文件需要 25 个数据块
-
读取前 48KB: OS 查看 i_block[0] 到 i_block[11],拿到 12 个 Block ID,直接去磁盘读
-
寻找剩下的 52KB(13 个块):
-
前 12 个坑位满了,OS 转向 i_block[12]
-
i_block[12] 存储的是一个索引块的编号(假设是 5000 号块)
-
-
读取索引块: OS 把第 5000 号块读进内存。这个块有 4096 字节,每个指针 4 字节,所以这里面存了 1024 个 块编号
-
定位: OS 从这 1024 个编号里取出前 13 个,就知道剩下的数据在哪了
为什么不全部用直接索引?
为什么不直接把 i_block 数组开大点,比如开到 1000 个?
-
绝大多数文件其实很小。如果每个 inode 都存 1000 个指针,那每个 inode 都要占 4KB。磁盘上会有几百万个 inode,这会浪费巨大的元数据空间
-
固定长度:inode 必须是固定大小(128 字节)才方便我们用公式计算位置
结论: inode 里存的是 Block 的 ID 列表
OS 读出 inode 结构体
解析 i_block[ ] 数组
如果是大文件,就顺着间接指针多跑一趟磁盘读索引块
拿着拿到的 Block ID,再次向磁盘驱动发起 IO 请求,读取最终的数据块
四、已知 inode 号如何操作文件
在文件系统中,一旦获取了文件的 inode 编号,操作系统便掌握了该文件的所有元数据及其物理分布。所有的文件操作(增、删、查、改)最终都会转化为对磁盘特定 块(Block) 的读写请求
增加与创建
创建文件的本质是在元数据区申请条目并建立逻辑映射
-
分配 Inode 编号:
内核遍历 GDT,寻找 bg_free_inodes_count(空闲数据块) 大于 0 的块组。随后读取该块组的 Inode Bitmap,检索第一个标记为 0 的位,将其置为 1
-
初始化 Inode 条目:
根据计算出的偏移量,定位到 Inode Table 中对应的 128 字节空间。内核将填入文件的初始属性,建立与 data block 的映射,此时 i_size 和 i_blocks 通常初始化为 0
-
挂接目录项(核心步骤):
创建文件最关键的一步是修改父目录的内容。内核必须找到父目录的 Data Block,并在其中写入一个新的目录项,包含:新文件名与刚分配的 Inode 编号
-
更新全局统计信息:
同步修改块组描述符中的 bg_free_inodes_count 以及超级块中的空闲 Inode 总数,确保系统状态的一致性
查询与读取
读取文件的本质是根据 inode 提供的索引图进行数据寻址
-
定位 Inode:根据前文所述的偏移量计算公式,将 inode 结构体从磁盘的 Inode Table 区域读入内存
-
解析索引指针:通过访问 i_block[15] 数组,获取存储文件内容的数据块编号,并发起对这些 Block 编号对应的逻辑块地址(LBA)的读取请求
-
数据流组合:由于物理块可能不连续,文件系统负责按顺序将各个 Data Block 的内容拼凑成连续的字节流,返回给用户态
修改与写入(Write/Update)
写入操作的本质是数据块的分配与元数据的同步
-
检索空闲空间:如果写入导致文件增大,内核需要查询块 GDT 中的 Block Bitmap,寻找标记为 0 的位,并将其置为 1
-
写入数据块:将新数据写入刚分配的 Data Block 物理位置
-
更新元数据:修改 inode 结构体中的 i_size(文件大小)、i_blocks(占用块数)以及 mtime(修改时间)
-
多级索引维护:若文件大小超过直接块限制,还需额外分配并写入索引块(Index Block)
物理删除(Delete)
在文件系统层面,删除操作通常并不涉及对 Data Block 内容的物理擦除,而是引用计数的递减与标记位的重置
-
减少链接数:将 inode 中的 links_count 减 1
-
释放资源标志:
-
若 links_count 归零,则在 Inode Bitmap 中将该编号对应的位置为 0
-
遍历 i_block 数组,在 Block Bitmap 中将对应的所有 Data Block 标志位重置为 0
-
-
断开目录关联:在父目录的数据块中移除该文件名与 inode 编号的映射条目
核心:数据内容依然残留在磁盘上,但由于位图标记已释放,系统认为这些块是空闲的,后续写入操作会直接覆盖它们
五、目录与文件名
在深入探讨路径解析之前,必须理清一个文件系统设计中最关键的逻辑解耦:文件名与文件属性(inode)的分离
1. 目录的本质
在 Linux 的哲学中,"一切皆文件",目录也不例外
-
目录也是文件:目录在磁盘上同样拥有自己的 inode 和 Data Block
-
目录的内容:普通文件的 Data Block 存储的是文本、二进制等数据,而目录的 Data Block 存储的是一张映射表
-
映射表项:这张表记录了该目录下所有文件的 "文件名" 与对应的 "inode 编号"
当我们 vim / 时可以看到根目录下的所有文件名


2. 为什么文件名不在 inode 中存储
这是一个极其高明的设计决策。在 ext2 中,inode 存储了文件的大小、权限、位置等所有属性,唯独没有存储文件名。这种设计基于以下书面考量:
(1) 实现硬链接
如果文件名存在 inode 里,一个文件在全局就只能有一个名字。由于文件名存储在目录的 Data Block 中,我们可以让不同路径下的多个文件名同时指向同一个 inode 编号。这便是硬链接的物理基础——一份数据,多个外号
(2) 提高目录遍历效率
inode 是固定长度的元数据,如果其中包含长度不一的文件名,会导致 Inode Table 的索引计算变得异常复杂。将文件名剥离到目录文件中,可以保持 inode 结构的规整
(3) 符合分层管理逻辑
文件名是用户层面的标识,而 inode 是内核层面的标识。将文件名存在目录中,意味着重命名操作只需修改目录的内容,而不需要变动文件本身的属性
核心原则:用户用名字,系统用 inode
在文件系统的交互链条中,存在一个明确的角色分工:
-
用户视角:人类难以记忆数字,因此使用具有语义化的文件名和路径
-
系统视角:内核需要高效寻址和统一管理,因此使用固定长度的 inode 编号 进行底层操作
关键流程: 当用户执行 open("test.txt") 时,操作系统并不会直接去磁盘上找名为 test.txt 的块,而是先去当前目录的 Data Block 里查表,找到 test.txt 对应的编号是 262145,然后再通过这个编号去 Inode Table 里定位物理地址
3. 重谈目录权限
在 Linux 中,目录的权限并不直接作用于目录下的文件内容,而是作用于目录本身这一“映射表文件”。
(1) r:读取权限
-
本质:是否允许读取该目录的 Data Block
-
表现:如果用户拥有 r 权限,就可以执行 ls 命令。系统会读取目录的数据块,将存储在其中的文件名列表提取并展示给用户
仅有 r 权限时,用户只能看到文件名。由于无法访问这些文件对应的 inode,在 ls -l 中会显示问号或报错
(2) w:写入权限
-
本质:是否允许修改该目录的 Data Block
-
表现:
-
增:创建新文件(在目录数据块中增加条目)
-
删:删除文件(在目录数据块中抹除条目)
-
改:重命名文件(修改目录数据块中的字符串)
-
重要结论:
删除一个文件的权限,并不取决于该文件自身的权限,而取决于其父目录的 w 权限。 即使文件本身是只读或属于他人,只要对父目录有 w 权限,就能通过操作目录的数据块来抹除该文件的映射条目,从而实现删除
(3) x:执行权限
-
本质:是否允许通过该目录进行 Inode 寻址
-
表现:
-
w 权限决定了用户能否去访问其下的 inode
-
它是执行 cd 命令的前提
-
如果你没有 x 权限,即使你知道某个文件的路径(如 /data/test.txt),你也无法获取该文件的 inode,从而导致操作受限
六、路径解析
在理解 inode 的寻址逻辑与目录的映射关系本质后,便可完整梳理文件访问的完整流程。查找某个目录下的文件时,内核并非直接定位目标文件,而是通过逐级解析路径完成寻址
1. 路径解析过程
路径解析的核心逻辑是将复杂的长路径拆解为逐级的目录项查找
为什么需要递归(或迭代)查找?
因为文件系统是一个分层树状结构。除了根目录外,没有任何一个目录的位置是预先固定的。要找到 /a/b/c.txt,你必须先找到 a 才能知道 b 在哪,找到 b 才能知道 c.txt 的 Inode 编号

实例演示:解析 /var/log/messages
假设我们要读取这个文件,内核的动作如下:
-
锁定起点:从超级块中获取根目录
/的 Inode 编号(在 ext2 中通常固定为 2) -
查找 var:读取 Inode 2 指向的数据块,在其中搜索字符串 "var",获取其对应的 Inode 编号(假设为 100)
-
查找 log:读取 Inode 100 指向的数据块,搜索字符串 "log",获取其 Inode 编号(假设为 500)
-
锁定目标:读取 Inode 500 指向的数据块,搜索字符串 "messages",最终拿到目标文件的 Inode 编号(假设为 999)
-
读取内容:根据 Inode 999 里的 i_block 指针,去磁盘寻找真正的 Data Block
本质: 路径解析的过程就是不断地 "读取目录数据块 -> 匹配文件名 -> 获得下一级 Inode" 的循环,直到找到目标文件
2. 根目录与家目录
在 Linux 系统中,/ 和 ~ 不仅仅是两个路径,更是代表了文件系统设计的顶层设计哲学
(1) 为什么必须有根目录(/)
-
物理入口:如果没有一个预定义的、编号固定的根目录 Inode,内核在挂载文件系统后将处于致盲状态——它拥有整块磁盘的位图和表,却找不到进入目录树的第一扇门
-
统一视图:根目录是所有物理分区、虚拟文件系统的总挂载点,确保了 Linux 文件树的逻辑一致性
(2) 为什么根目录下有那么多缺省目录?
如果查看 /,会发现 bin, etc, usr, var 等目录。这遵循了 FHS 标准:
-
隔离性:将只读的可执行文件(/bin)与经常变动的数据(/var)分开,便于备份和挂载
-
可预测性:无论你用的是 Ubuntu 还是 CentOS,内核总是在 /etc 找配置,在 /lib 找库,这降低了软件开发的复杂度
(3) 为什么要有家目录
在多用户环境下,家目录是权限管理最直观的体现。每个用户在自己的家目录下拥有完整的 rwx 权限,而对其他用户的家目录则可能被完全封锁。家目录隔离了系统配置与个人偏好。即便系统重装(只要保留 /home 分区),用户的个人数据和配置文件也能完美保留
七、路径缓存
解析一个路径需要经历多次磁盘 I/O——读取目录 Inode、读取目录数据块、匹配字符串、再读取下一级 Inode。如果在高并发场景下,磁头为了找一个文件就要在磁盘表面反复移动,导致系统响应时间从毫秒级骤降至明显的延迟状态
为了解决这个瓶颈,Linux 引入了文件系统中最关键的性能优化组件:路径缓存(Path Cache)
1. 为什么需要路径缓存
磁盘 I/O 与内存访问的速度差异通常在 10,000 倍以上
-
如果每次执行 ls 或 open 都要重新遍历磁盘上的目录文件,将严重拖累文件系统性能
-
在实际应用中,用户访问的路径往往具有极强的局部性。比如 Web 服务器会反复访问 /var/www/html/index.html。如果能把 "路径字符串 -> Inode" 的映射结果缓存起来,就能瞬间跳过繁琐的磁盘搜索
dentry 概念
为了实现高效缓存,Linux VFS(虚拟文件系统)层引入了一个核心对象:dentry
-
核心定义:dentry 是文件名与 Inode 在内存中的缓存快照
-
名实分离:Inode 代表文件属性,而 dentry 代表文件路径关系
-
重要区别:磁盘上并没有 dentry 结构。它是内核在读取目录数据块后,在内存中动态生成的对象,将费时的文件名匹配操作转化为极速的内存比对

3. dentry 结构特点
为了同时满足 "快速查找"、"层级结构" 和 "内存自动释放",内核为 dentry 设计了三套组织结构:
(1) 树状结构:反映文件系统拓扑
每个 dentry 都有指向父节点和子节点的指针
-
作用:它在内存中复刻了磁盘上的目录树。通过这种树形连接,内核可以轻松实现 cd .. 这种向上回溯的操作,也能清晰地维护文件的层级关系
(2) 哈希表: O(1) 级查找
如果目录树很大,逐层遍历指针依然不够快。内核将所有 dentry 挂载到一个全局的 dentry_hashtable 中
-
作用:当你提供路径 /home/user 时,内核通过计算字符串的哈希值,直接定位到该路径对应的 dentry 对象。这种查找几乎是瞬时完成的,完全避开了物理磁盘的检索
(3) LRU 链表:智能内存释放
内存是有限的,不能无限地缓存所有路径
-
作用:内核维护着一个 LRU(最近最少使用) 链表
-
机制:当内存紧张时,那些长时间未被访问的 dentry 会被移出缓存。而被频繁访问的热点路径则会始终驻留在内存中,确保核心业务的响应速度

八、总结
通过以上内容可以看出,文件在系统中的访问本质是一条清晰的链路:
文件名 → dentry → inode → data block
用户通过路径访问文件,系统则通过目录逐级解析,借助 dentry 加速查找,最终通过 inode 定位到具体的数据块
但这些结构是如何在不同文件系统之间被统一管理的?操作系统又如何对外提供一致的文件接口?
在下一篇中,我们将引入 VFS(虚拟文件系统),进一步理解 Linux 文件系统的整体架构

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐

所有评论(0)