在上一篇文章中,我们从磁盘硬件出发,介绍了扇区、inode等基础概念。然而,关于inode的许多关键细节尚未展开。本文将补全此前遗漏的知识点,并引入新的内容,帮助大家更全面地掌握文件系统的工作原理。
当我们需要判断某个inode是否已被使用时,系统依赖的是一个称为“位图”的数据结构——inode bitmap。该位图中的每一个比特位对应一个inode编号,0表示未使用,1表示已占用。同理,block bitmap用于记录数据块的使用状态。
以创建并写入一个文件为例,整个过程如下:首先,在inode bitmap中查找第一个值为0的位,假设找到的是第8号inode,则将其置为1,表示该inode已被分配。接着,若要存储"hello world"这样的内容,需申请至少一个数据块的空间。此时系统会在block bitmap中搜索空闲块,假如5号块为空,则将其标记为已使用,并将数据写入该块。最后,更新inode table中对应inode的信息,在其指向数据块的数组中填入5号块的编号。
这样一来,文件的属性(由inode管理)和实际内容(由数据块保存)就完成了持久化存储。在Linux系统中,查找文件本质上就是定位其inode编号的过程。
值得注意的是,删除文件并不意味着立即清除其数据内容。实际上,系统仅需将该文件所使用的inode和block在各自bitmap中的对应位重新设为0,即可完成逻辑删除。这也解释了为何误删文件后若未进行大量写操作,仍有可能通过恢复工具找回原始数据——只要对应的数据块未被新数据覆盖,信息依然存在。
GDT(Group Descriptor Table),即块组描述符表,是每个块组头部都会包含的一份元数据结构。它本质上是一个结构体,存储着当前块组的关键信息,例如:
每个块组都保留有一份GDT的拷贝,使得文件系统无需遍历整个磁盘即可快速定位到特定块组的元数据区域。这种设计显著提升了文件操作的效率,尤其是在大容量存储设备上表现尤为明显。
Super Block(超级块)堪称文件系统的“大脑”,负责保存整个分区的全局元数据,是文件系统能够正常挂载和运行的基础。它不针对某个块组,而是描述整个分区级别的文件系统信息。
其中包含的关键信息有:
一旦Super Block遭到破坏,整个文件系统的结构信息将面临丢失风险,可能导致分区无法识别或数据无法访问。
通常情况下,一个分区会划分为多个块组。虽然不需要每个块组都独立拥有一个Super Block,但也不会只设置一个副本。一般会在分区中设置三到四个完全相同的Super Block备份,以防主Super Block损坏时可用备用副本进行修复。
设想一下,如果整个分区仅依赖单一Super Block,一旦其损坏,就意味着所有关键配置信息消失,相当于整个分区失效。因此,多副本机制极大地增强了文件系统的容错能力与可靠性。
当一个分区被格式化之后,其可容纳的inode总数和数据块总数就已经固定下来。这意味着这两个资源的数量是静态确定的,不会随后续使用而改变。
那么是否存在inode耗尽但block仍有剩余的情况?答案是否定的。因为每个文件至少需要一个inode来存储元信息,而block的分配始终依附于inode的存在。反过来说,出现block用完但inode仍有富余的情形则是完全可能的。
这种情况常见于存储大量大型文件的场景。由于一个文件可能跨越多个数据块,导致block资源被迅速消耗,而inode尚未达到上限。这说明inode与block之间并非严格按1:1比例消耗。
对于超大文件的存储,单个块组往往不足以容纳全部数据。此时系统允许文件的数据跨多个块组分布。为了实现这一点,文件系统会记录每个块组的起始block编号。基于此起始编号加上偏移量,即可精确定位任意一个物理块的位置。
举例而言,假设块组A的起始block编号为1,每个块组包含10000个block,则块组B的起始编号为10001,块组C为20001,依此类推。若某inode中记录的block编号为10042,我们可通过计算得知该块位于块组B中,且偏移量为42。随后便可依据超级块中记载的块组布局信息,结合block bitmap获取该块的具体使用状态。
由此可见,尽管数据分布在不同的块组中,但由于block编号在整个分区内连续编号,系统依然能高效地完成寻址与管理。
在讨论 inode 编号与 block 编号时,需要明确的是:inode 编号是按分区进行划分的。当我们为 inode 分配编号时,只需确定起始的 inode 号,之后的编号便可依次递增,无需复杂的管理机制。
由于 inode 具有固定大小且连续存储的特点,inode 表本质上就是一个由 inode 结构组成的数组。每个 inode 占据固定的字节数(例如 ext4 中为 128 或 256 字节)。因此,只要知道 inode 表所在的起始 block 以及单个 inode 的大小,就可以通过计算直接得出第 n 个 inode 的偏移地址。
那么操作系统是如何实现对文件系统的管理的呢?
答案依然是:先描述,再组织!
超级块(Super Block)和组描述符表(GDT)都有其对应的结构体,这些结构体可以进一步构成链表形式。这样一来,对整个文件系统的管理就转化为内存中的数据结构操作,极大提升了效率。
前文提到的 block[NUM] 数组,在 ext2 文件系统中实际上仅有 15 个指针项(若更多则不合理)。这 15 个块指针中,前 12 个为直接指针,各自指向一个数据块;后三个则用于实现多级间接寻址。
其中,一级间接指针指向一个块,该块中存储的全是直接块地址指针,每个指针再分别指向实际的数据块。二级间接指针所指向的块,则存放的是一级间接指针,依此类推。
这种设计虽然结构简单,但在性能上存在局限性。后续的文件系统(如 ext4)对此进行了显著优化。
基于以上原理,我们可以总结出以下几点结论:
我们如何能够获得 inode 号?在日常使用 Linux 时,用户通常都是通过文件名来操作文件。但执行 ls -l -i 命令时却能查看到 inode 号,这是如何实现的?
请回想一下之前强调的一个关键点:文件名并不存在于 inode 结构体中!
文件分为两种类型:普通文件和目录文件。值得注意的是,目录本身也是一种特殊类型的文件,它同样拥有自己的属性和内容,也需要独立的 inode 和数据块(block)来存储信息。
目录文件的数据块中,保存的是该目录下所有子文件或子目录的名称与其对应 inode 号之间的映射关系。
我们知道目录具有 rwx 三种权限。如果目录确实是以这种方式存储映射信息的,那么就可以解释以下现象:
之所以使用 inode 号而非文件名作为核心标识,是因为数字比较比字符串匹配更高效,这正是为了提升系统整体性能。
当我们执行 ll 命令时,系统实际做了什么?
—— 遍历当前目录中的文件名与 inode 的映射关系,然后根据查得的 inode 号,进一步获取对应文件的全部属性信息。
那么,目录本身的 inode 号又是从何而来呢?
答案是:路径解析!
每一个目录都可以追溯到其父目录,而父目录的文件内容中正包含了该子目录的文件名与 inode 号的映射。通过逐层向上回溯,最终可到达根目录。而根目录的 inode 号是系统预设的固定值。
这也解释了为何每个进程都维护着当前工作目录(cwd)—— 若无路径信息,就无法完成路径解析,也就无法定位到根目录下的任何文件。
因此,访问任意文件的前提是必须能够打开其所在目录,依据文件名查找对应的 inode 号,进而完成文件访问。
换句话说,访问文件必须知晓当前操作目录,其本质在于必须能成功打开该目录文件,并读取其内容。
那么,这个路径信息是由谁提供的呢?
答案是:进程!
所有的文件访问行为,无论是命令还是工具触发,归根结底都是由进程发起的。而每个进程都维护有自己的当前工作目录(CWD),从而提供路径上下文。当你调用 open() 打开文件时,传入的路径也正是由进程所提供的。
问题1: Linux 磁盘中是否存在真正的“目录”?
答: 不存在。磁盘上只存储文件的属性和内容。所谓的“目录”,只是具有特定结构的文件。
问题2: 是否每次访问文件都需要从根目录开始进行路径解析?
答: 理论上如此,但这会导致效率低下。为此,Linux 会缓存历史访问过的路径结构,以加快查找速度。
问题3: Linux 中“目录”的概念是如何形成的?
答: 当一个文件被识别为目录并被打开时,操作系统会在内存中维护其层级结构。
Linux 内核中用于维护树状路径结构的核心数据结构是 struct dentry。该结构体主要用于缓存路径信息,属于内存级别的数据结构。借助 dentry 缓存,系统能够显著提升文件路径解析的速度。
struct dentry {
atomic_t d_count; // 引用计数
unsigned int d_flags; // 状态标志(如 DCACHE_UNUSED)
struct inode *d_inode; // 关联的 inode(文件/目录的实际数据)
struct dentry *d_parent; // 父目录的 dentry
struct qstr d_name; // 文件名(如 "file.txt")
struct list_head d_child; // 兄弟节点链表(同一目录下的其他 dentry)
struct list_head d_subdirs; // 子目录链表(如果是目录)
const struct dentry_operations *d_op; // 操作函数表
struct super_block *d_sb; // 所属的超级块
void *d_fsdata; // 文件系统私有数据
// ...
};(简化版)
dentry 主要负责维护 文件名到 inode 的映射关系,同时缓存目录的层级结构,以便加速文件路径的查找过程。
可以说,dentry 是实现 Linux 文件系统“按名称访问文件”这一功能的核心机制之一。
至此,我们已经了解了文件系统的底层结构。那么,之前学习的文件描述符又是如何与这些文件系统机制关联起来的呢?
每个文件描述符都可以用来定位一个文件,而该文件的属性中包含了一个名为 struct dentry 的数据结构。这个结构体记录了当前文件(无论是目录还是普通文件)与其对应的 inode 编号之间的映射关系。由于 d_child 和 d_subdirs 字段的存在,我们可以构建出子目录与父目录之间的层级连接,从而形成一个多叉树的组织形式。
操作系统中的文件系统本质上就是以这种多叉树结构进行组织的。我们可以通过执行 tree 命令来查看某个目录下的树形结构,直观地理解这种层次关系。
一块物理硬盘可以被划分为多个分区,例如 /dev/sda1、/dev/sda2 等,每一个分区都可以独立格式化为不同的文件系统类型。当我们访问类似 /home/user/file.txt 这样的路径时,系统如何判断这个文件究竟位于哪一个分区呢?
Linux 采用“挂载点”(Mount Point)的方式来解决这个问题。所谓挂载,实际上是将某个分区的文件系统“接入”到整个目录树的某一个具体目录位置上。比如,把设备 /dev/sda1 挂载到 /home 目录下之后,所有对 /home/user 的访问实际上都会指向该分区中的实际数据。此时,原 /home 目录下的内容会被暂时覆盖,直到该分区被卸载才会重新显现。
我们可以通过 df 命令来查看当前各个文件系统的挂载位置。
在输出结果中,临时分区(如 tmpfs 类型)可忽略不计。可以看到,我们的 vda2 分区正是挂载在根目录 / 上的。通过这种方式,系统只需检查路径的前缀是否匹配某个已挂载的目录路径(例如最近匹配的是根目录),就能确定该路径所属的具体分区。
需要注意的是,一个分区即使已经写入了文件系统,也不能直接使用——必须将其与某个目录建立关联,完成挂载操作后才能访问其中的数据。具体的挂载方法本文不再展开,有兴趣的读者可自行查阅相关资料。
接下来的三幅图有助于帮助大家理清上述概念之间的逻辑关系:


回顾一下:文件描述符能够关联到 struct file,而该结构又通过 dentry 找到对应的 inode;inode 则指向实际存储数据的 block。这一整套机制贯穿了从进程打开文件、解析路径、遍历目录,到最终访问磁盘硬件的全过程。
至此,关于文件系统的核心知识体系已基本梳理完毕。希望大家能真正理解 inode 与 block 的映射原理,并将文件描述符、struct file、目录项、路径解析、分区挂载以及底层存储设备等知识点有机串联起来,形成一个完整且清晰的认知框架。

扫码加好友,拉您进群



收藏
