一、引言:
在 Linux 系统编程的学习路上,文件系统是绕不开的核心基石。而 ext2 作为 Linux 早期最经典的索引式文件系统,不仅是 ext3/ext4 的前身,更是理解文件如何在磁盘上存储的最佳入门范本。它简洁高效的块组架构、inode 与数据分离的索引模型,至今仍是操作系统课程的经典案例。本文将带你走进 ext2 的世界,拆解它的核心结构,读懂文件在磁盘上的存储逻辑,为后续深入 Linux 存储与 I/O 编程打下坚实基础。
二、宏观认识
我们想要在硬盘上存储文件,必须先把硬盘格式化为某种格式的文件系统,才能正常存放和管理文件。文件系统的核心目的,就是高效组织和管理硬盘上的文件数据。
在 Linux 系统中,最常见的是 ext2 系列文件系统。其早期经典版本为ext2,后续又逐步发展出 ext3 和 ext4。尽管 ext3 和 ext4 在 ext2 基础上做了诸多功能增强,但底层核心设计思想并未发生根本改变。因此,我们仍然以结构更简洁的 ext2 作为学习和演示对象。
ext2 文件系统会将整个磁盘分区划分成若干个大小相同的块组(Block Group),如下图所示。
上图中的启动块(Boot Block/Sector)大小是固定的,为1KB,由 PC 标准规定,主要用于存储磁盘分区信息和启动相关信息,任何文件系统都不能修改启动块。启动块之后,才真正开始 ext2 文件系统的区域。
三、Block Group
Block Group 是ext2 文件系统的核心管理单元,它把整个磁盘分区切分成多个大小相同的小块组,每个块组都独立管理自己的 inode、数据块和元数据,就像把一个大仓库分成了多个小仓库,各自管理自己的货物。
3.1块组的内部组成
3.1.1超级块(Super Block)
超级块是 Ext2 文件系统的总控中心,它记录了整个文件系统的全局核心信息,是文件系统挂载和运行的基础。可以把它理解为整个文件系统的身份证 + 配置清单。
它记录了:
块大小(Block Size):通常为 1KB/2KB/4KB
总块数、总 inode 数
空闲块数、空闲 inode 数
块组数量、每个块组的大小
挂载时间、上次检查时间等状态信息
注:为了避免超级块损坏导致整个文件系统不可用,ext2 会在每个块组(Block Group)都备份一份超级块!
3.1.2GDT
全程Group Descriptor Table,是块组描述符表,它记录了每个块组(Block Group)的核心元数据位置,是连接超级块和具体块组的关键桥梁,整个分区分成多个块组就对应有多少个块组描述符。它记录了:
当前块组的数据块位图起始位置
当前块组的 inode 位图起始位置
当前块组的 inode 表起始位置
当前块组的空闲块数、空闲 inode 数、目录数等统计信息
注:和超级块一样,GDT 也会在每个块组中备份一份!
3.1.3块位图(Block Bitmap)
Block Bitmap记录着Data Block哪个数据块已经被占用,哪个数据块没被占用!
3.1.4inode位图(Inode Bitmap)
每个bit表示一个inode是否空闲可用!
3.1.5i节点表(Inode Table)
inode 表(也叫索引节点表)是 ext2 文件系统中存储所有文件 / 目录元数据的核心区域,可以把它理解为文件的档案库!
inode 表的核心作用:存储文件的元数据属性,包括文件大小、所有者 UID、所属组 GID、权限模式、最近访问 / 修改 / 状态变更时间戳等。
局部管理特性:每个块组(Block Group)都维护独立的 inode 表,仅管理当前块组内所有文件 / 目录的 inode 信息。
inode 编号范围:inode 编号以磁盘分区为单位进行全局分配,不可跨分区使用。
3.1.6Data Block
数据区用于存放文件的实际内容,由一个个数据块(Block)组成。根据文件类型不同,数据区的存储方式也有所区别:
对于普通文件,文件的数据内容直接保存在对应的数据块中。
对于目录,其下属的所有文件名与子目录名均存储在该目录对应的数据块中;而 ls -l 显示的除文件名以外的信息,则保存在对应文件的inode中。
数据块编号以单个分区为单位进行管理,不能跨分区使用。
四、inode和datablock的映射
inode 和 data block 的映射,就是“文件属性”和“文件内容”之间的对应关系。
inode 里专门有一段指针,用来记录:这个文件的数据,存在哪几个 data block 里 。(因为inode不存内容,只存属性+数据块地址,而datablock里只存文件真实内容。)
我们来思考一个问题:知道inode号的情况下,在指定分区,那么对⽂件进⾏增、删、查、改是在做什么呢?我们给出结论:
查:读 inode + 读数据块
增:写 inode + 分配 / 写数据块 + 更新位图
改:覆写 inode / 数据块 + 更新位图
删:减链接计数 → 若为 0 则释放 inode 和数据块
五、目录与文件名
我们现在有几个问题:我们访问文件时都是用的文件名啊,没用过inode号呀,这其中是怎样的对应关系?另外目录是文件吗?该怎么理解呢?
答:目录也是文件,它是一种特殊的文件,它和普通文件共用一套 inode 管理机制,只是内容和用途不同。只不过数据块里存的内容不一样:目录文件的数据块里存的是目录项列表(文件名 → inode 号 的映射表),而普通文件的数据块里存的是业务内容(代码、文本、图片等)。
所以,访问⽂件,必须打开当前⽬录,根据⽂件名,获得对应的inode号,然后进⾏⽂件访问!
六、路径缓存
我们先引入三个问题:
问题 1:Linux 磁盘中,存在真正的目录吗?
答案:不存在真正意义上的目录实体,只有文件。磁盘上只持久保存文件的属性与文件内容。
问题 2:访问任何文件,都要从根目录 / 开始进行路径解析吗?
答案:原则上是的,但完整从头解析效率较低,因此 Linux会对历史路径结构进行缓存以加快访问。
问题 3:Linux 中的目录概念是如何产生的?
答案:当打开的文件类型为目录时,由操作系统在内存中维护并呈现出层级路径结构。
Linux中在内核中维护树状路径结构的内核结构体是:struct dentry
每个 dentry 都绑定一个 inode,但文件名本身只存在于 dentry 的 d_name 中,inode 不记录文件名。
目录在内存中就是由 dentry 构成的树形结构,磁盘上只存 inode 和目录项数据,内存中的 dentry 是为了加速路径查找而缓存的中间层。
树形节点同时隶属于LRU(最近最少使用)结构,用于实现节点的淘汰管理。
树形节点同时隶属于哈希表,以便实现快速查找。
该树形结构整体构成了 Linux 的路径缓存。访问文件时,会先在这棵缓存树中按路径查找,若找到则直接返回对应的 inode 和文件内容;若未找到,则从磁盘加载路径并创建新的 dentry 节点。
七、挂载分区
挂载分区,就是把一个磁盘分区“接入” 到 Linux 文件系统的某个目录下,让这个分区里的文件能被系统访问和使用。
问题:inode不是不能跨分区吗?Linux不是可以有很多个分区吗?我怎么知道我在哪一个分区?
答:
inode 确实不能跨分区,每个分区都有自己独立的 inode 编号空间,同一个 inode 号在不同分区里代表完全不同的文件。那我是怎么知道我在哪个分区的呢?
其实是用挂载点 + 路径前缀匹配来判断路径所属分区:
它的原理就是我们上面提过的,挂载点是分区的入口!
每个分区都挂载到目录树的某个节点(挂载点),比如 /、/home。
系统会维护一张挂载表,记录了挂载点 → 分区的对应关系。
当你访问一个路径时,内核会从根目录开始,逐层向上匹配最长的挂载点前缀,从而确定该路径属于哪个分区。
八、文件系统总结
在 Linux 中,我们日常操作的 “文件” 和 “目录”,本质上都是内核通过一套统一的数据结构管理起来的抽象对象。整个文件系统可以理解为三层结构:磁盘上的真实数据 → 内存中的路径缓存 → 进程视角的文件句柄。
8.1磁盘层面:文件的本质是 inode + 数据块
磁盘上不存在真正的 “目录”,只有两种核心元素:
inode(索引节点):记录文件的元数据(权限、大小、属主、时间戳),以及指向数据块的指针。每个文件都有唯一的 inode 号,但 inode 不能跨分区,每个分区都有自己独立的 inode 表。
数据块(Data Block):真正存储文件内容的地方,普通文件存数据,目录文件存目录项列表。
也就是说,“目录” 只是一种特殊的文件,它的内容就是一张文件名与 inode 的映射表。
8.2内存层面:路径缓存与目录项(dentry)
为了避免每次访问文件都从磁盘解析路径,内核会在内存中维护一棵目录项缓存树(dentry cache):
struct dentry(目录项):每个节点对应一个文件名,指向它的父目录、子目录列表,以及文件对应的 inode。
这棵树会同时加入 LRU 淘汰链表和哈希表,既可以快速按路径查找,也能在内存不足时清理不常用节点。
我们访问文件时,内核会先在这棵缓存树中查找,找到则直接返回 inode,找不到再从磁盘加载并创建新的 dentry。
8.3挂载:多分区如何形成统一目录树
Linux 支持多个磁盘分区,但用户看到的是一棵统一的目录树,关键就在于挂载:
每个分区都有自己的文件系统和独立的 inode 空间,挂载就是将分区的根目录 “接入” 到主目录树的某个节点(挂载点)下。
内核通过挂载表记录 “挂载点 → 分区设备” 的映射,访问文件时,会根据路径的最长前缀匹配,自动确定它属于哪个分区,再去该分区查找对应的 inode。
Linux 把多个独立的磁盘分区,像接插头一样 “接” 到同一棵目录树上,让你感觉所有文件都在一个大目录里,而不是分散在不同盘符里。
8.4进程视角:从路径到文件句柄的完整链路
进程要读写文件,需要通过一系列内核结构完成定位:
1.先知道自己在哪:进程通过 task_struct 里的 fs_struct,知道自己当前在哪个目录、用哪个分区(根目录、当前工作目录、挂载信息)。
2.拿着文件描述符找句柄:再通过 files_struct 里的文件描述符(fd),找到对应的 struct file。
3.通过句柄定位文件:struct file 里的 f_path 会指向对应的 dentry(目录项缓存节点),最终定位到文件的 inode。
4.读写数据:拿到 inode 后,就能找到文件的元数据和数据块,开始读写。
8.5总结梳理
这里我们用一个例子来梳理文件系统!
我们的程序怎么进行库函数的调用呢?
1.内核先通过路径查找,找到对应的struct file
2.顺着file->path->dentry->inode,拿到inode
3.读出inode里的i_block数组,找到库文件在硬盘的哪个扇区
4.先把库从硬盘搬到内核手里,这时候还没给进程用
5.内核要给进程分一块虚拟地址空间来放库,在共享区里创建一个新的vma(包括虚拟地址范围、权限、关联的struct file),然后内核更新页表,映射虚拟地址到物理地址的转化
6.内核吧这个新vma的起始地址返回给进程,进程就知道:哦!库的代码和数据从这个虚拟地址开始!
九、结语
本文完整梳理ext2磁盘结构、inode映射、路径缓存与分区挂载原理,并结合库加载流程,串联起磁盘文件到进程虚拟内存的完整链路。我们能看清VFS、dentry缓存、虚拟内存协同工作的底层逻辑,理清文件访问的完整内核流程,为后续IO、内存等高阶知识打下扎实底层基础。