一、EXT2 文件系统的整体磁盘布局
1.1 从物理磁盘到逻辑分区的演进
1. 什么是分区
在深入了解文件系统之前,我们需要知道数据在硬件层面的宏观组织方式。物理磁盘首先会被划分为多个分区,如 Partition 1 到 Partition n。这种划分由磁盘的主引导记录(MBR)等分区表进行管理。(分区本质上就是记录所有的起始地址和结束地址)
2. 分区的基本单位
柱面是分区的基本单位。柱面大小一致、扇区个数一致,那么其实只要知道每个分区的起始和结束柱面号、知道每一个柱面多少个扇区,那么该分区多大、LBA 是多少也就清楚了。
3. 分区的构成
然而,仅仅完成了分区,操作系统依然无法直接利用它。我们必须对分区进行格式化,格式化的本质就是向该分区内写入特定文件系统的管理信息。在 Linux 中,EXT2 便是经典的基于块管理的文件系统。
在一个 EXT2 分区内部,其开头的第一个数据块被称为启动扇区(Boot Sector),用于存放引导加载程序,剩余的空间则被划分为文件系统的核心管理区。
EXT2 文件系统将整个分区划分成若干个同样大小的块组(Block Group),如下图所示。只要能管理一个分区就能管理所有分区,也就能管理所有磁盘文件。
我们有那么多分区,但是不是所有的分区都用的是同一套文件系统,比如 Ext,也有的是所有的分区用的是同一套文件系统。
1.2 块组的划分
其实磁盘是典型的"块"设备,操作系统读取磁盘数据的时候,其实不会一个个扇区地读取,这样效率太低,而是一次性连续读取 8 个扇区(4KB),即一次性读取一个"块"。
硬盘的每个分区是被划分为一个个的"块"。一个"块"的大小是由格式化的时候确定的,并且不可更改。
"块"是文件存取的最小单位。好处:在同一时间附近创建的文件往往存在一些关联,一次拷贝 4KB,这些文件会被同时拷贝到内存,下次需要的时候就不需要再从磁盘中读取。
结论:文件系统的基本存储和交互单位通常是 4KB 的数据块。
- 补充:
- 与块设备相对的是字符设备,它们根本的区别是是否支持随机读取;
- 在默认 4KB 块大小的经典 EXT2 文件系统规范中,一个块组的最大容量被硬性固定为 128MB。
1.3 文件系统对文件数据的管理是对块的管理
每个扇区都有 LBA,那么 8 个扇区一个块,每一个块的地址我们也能算出来。
- 知道 LBA:块号 = LBA / 8
- 知道块号:LBA = 块号 × 8 + n(n 是块内第几个扇区)
文件系统对文件数据的管理就变成了对块的管理:把块管理好了,块组就管理好了;块组管理好了,分区就管理好了;整个磁盘就管理好了。
二、块组内部的空间管理
// 磁盘级 block group 的数据结构/* * Structure of a blocks group descriptor */structext2_group_desc{__le32 bg_block_bitmap;/* Blocks bitmap block */__le32 bg_inode_bitmap;/* Inodes bitmap */__le32 bg_inode_table;/* Inodes table block */__le16 bg_free_blocks_count;/* Free blocks count */__le16 bg_free_inodes_count;/* Free inodes count */__le16 bg_used_dirs_count;/* Directories count */__le16 bg_pad;__le32 bg_reserved[3];};每个块组内部都包含了控制信息区与实际的数据存储区。一个标准的块组结构通常由以下几个部分组成:
文件 = 内容 + 属性。内容存放在数据块,属性存放在 inode。
2.1 数据块(Data Blocks)
实际存放文件具体内容的区域,被切分成了多个 4KB 大小的物理数据块,在一个块组中占据绝对空间。
2.2 inode 表(inode Table)
用于集中存储文件的属性信息集合。它由许多个固定大小的 inode 结构体串联而成,同样被切分成了多个 4KB 大小的块。
2.3 块位图(Block Bitmap)
以位(bit)为单位记录 Data Blocks 中哪个数据块已经被占用,哪个数据块没有被占用。例如,0 表示空闲,1 表示已分配。
在经典的 EXT2 文件系统设计规范中,不论你的磁盘总容量有多大,每一个块组内部都固定只有 1 个4KB大小的块来专门充当 Block Bitmap(块位图)。
4KB 可以表示 4 × 1024 × 8 = 32,768 个位(bit)。
2.4 inode 位图(inode Bitmap)
与块位图类似,它其中的每个 bit 表示一个 inode 编号是否空闲可用。
比如我有 1 万个数据块,这之中一共有 7484 个文件。于是就会有 7484 个 inode 位图的位被置为 1,有 1 万个块位图的位被置为 1。
2.5 这两个位图的用处举例
- 生活场景:
下载资源非常慢,但是删除资源非常快;
删除的资源被恢复非常快。
- 解释:
当我们删除资源的时候,我们是直接把表示这个资源占有状态的位图 block bitmap 和 inode bitmap 加载到内存,然后从 1 置为 0,这个时候这个资源在 inode table 和 data blocks 中的数据就会被视为乱码,下次加载其他资源的时候会直接覆盖这块内存。
如果你误删了某一块资源,那么什么都不要做,立即恢复(位图恢复 0 → 1),否则资源可能被覆盖破坏。
2.6 块组描述符表(GDT, Group Descriptor Table)
块组描述符表,描述块组属性信息,整个分区分成多少个块组就对应有多少个块组描述符(包含的是整个分区的每一个,不只是当前)。每个块组描述符存储一个块组的描述信息,如在这个块组中从哪里开始是 inode Table、从哪里开始是 Data Blocks、空闲的 inode 和数据块还有多少个等等。块组描述符在每个块组的开头都有一份拷贝(防止由于磁盘坏道等原因导致 GDT 损坏、整个分区崩溃)。
2.7 超级块(Super Block)
存放整个文件系统(注意是整个文件系统)的核心元数据。包括文件系统的总块数、空闲块数、总 inode 数、空闲 inode 数、单个块的大小(如 4KB)以及最近一次挂载时间等。
高可用备份:为了防止磁盘坏道导致整个文件系统崩溃,Super Block 的信息通常会在某些特定的块组开头保留一份拷贝(备份),但每个备份都会浪费空间。
一个文件的存储,它需要申请:
- 申请数据块;
- 申请 inode 块;
- 修改 block bitmap;
- 修改 inode bitmap。
我们得到一个结论:格式化的本质就是将 Super Block、GDT、Bitmap 和 inode Table 等管理信息进行写入。
2.8 对齐问题
- 如果内存页(4KB)== 文件系统块(4KB)== 磁盘物理扇区(4KB):
操作系统只需要下达一个简单的指令:"把内存的这一页,复制到磁盘的这个块上。"数据的传输是一对一、完美对齐的(Aligned),效率最高。
- 如果不对齐(例如磁盘是 512 字节,内存是 4KB):
操作系统为了写满一个内存页,需要连续读写 8 个磁盘扇区。如果起始位置没对齐,甚至可能引发"读-改-写"的额外开销,导致磁盘性能严重下降。
2.9 inode 向 data block 映射的逻辑结构
在 Linux Ext 系列文件系统中,文件 = 属性 + 内容。其中,文件的属性存储在 inode 中,而文件的内容则分布在磁盘的 Data Block(数据块)中。
然而,磁盘的块大小通常是固定的(例如 4KB),而文件的大小却是不可预估的。为了能够高效地管理大大小小的各种文件,并在有限的 inode 空间内建立起从 inode 到无数个 Data Block 的映射关系,Linux 采用了多级间接索引表的逻辑结构。
1. 多级索引
在 Ext 传统的文件系统设计中,一个 inode 结构体内部会包含一个大小固定的数据块指针数组(通常为 15 个元素)。这 15 个指针并不是平铺直叙地指向普通数据块,而是分为了四个等级来协同工作:
- 直接块索引指针(前 12 个指针)
12×4KB=48KB12 \times 4\text{KB} = 48\text{KB}12×4KB=48KB
这组指针是为了保证小文件的高效访问。在处理绝大多数几 KB 到几十 KB 的小文件时,操作系统甚至不需要进行二次寻址,就能直接直达数据目的地。
- inode 的间接索引指针
inode 一共 15 个块指针:前 12 个是直接块指针,第 13 一级间接、14 二级间接、15 三级间接。
- 一级间接块索引指针(第 13 个指针)
第 13 个指针不再指向普通的数据块,而是指向一个数据块索引表。这个被指向的块里存放的全部都是数据块指针。
数量计算:假设一个指针占用 4 个字节(32 位系统),那么一个 4KB 的索引块内部可以容纳:
4KB4B=1024 个指针\frac{4\text{KB}}{4\text{B}} = 1024\text{ 个指针}4B4KB=1024个指针
这意味着,第 13 个指针通过引入这一级间接关系,可以额外拓展指向 1024 个普通数据块,可存储文件的最大容量拓展:
1024×4KB=4MB1024 \times 4\text{KB} = 4\text{MB}1024×4KB=4MB
- 二级间接块索引指针(第 14 个指针)
第 14 个指针指向一个二级间接块索引表。该索引表里的 1024 个指针每一个又分别指向一个一级间接块索引表。
数量计算:通过二次层级的展开,第 14 个指针能够索引的普通数据块达到了:
1024×1024=1 048 576 个块1024 \times 1024 = 1\,048\,576 \text{ 个块}1024×1024=1048576个块
在 4KB 块大小的加持下,二级间接寻址能够为单个文件拓展出接近 4GB 空间:
1 048 576×4KB=4GB1\,048\,576 \times 4\text{KB} = 4\text{GB}1048576×4KB=4GB
- 三级间接块索引指针(第 15 个指针)
第 15 个指针则是终极扩容手段。它指向一个三级间接块索引表,展开后拥有:
1024×1024×1024=1 073 741 824 个块1024 \times 1024 \times 1024 = 1\,073\,741\,824 \text{ 个块}1024×1024×1024=1073741824个块
理论上三级间接能够支持的单文件大小可达 4TB:
1 073 741 824×4KB=4TB1\,073\,741\,824 \times 4\text{KB} = 4\text{TB}1073741824×4KB=4TB
为什么要设计得这么复杂,而不是直接使用一个超级大的普通数组?
核心目的同样是为了平衡检索效率与空间开销:文件很小的时候,只用前 12 个直接块,寻址极快;文件很大的时候,才逐级用到间接块,不会一开始就占用巨大的指针数组空间。
三、属性与内容的分离存储
3.1 认识 struct inode
1. 文件的属性信息
ls -l会读取存储在磁盘上的文件信息并显示出来。除了这种方式,使用stat命令能够看到更丰富的属性信息:
[ljm@iZ0jlgycwuno4vikja7kneZ Mylibc]$statmain.c File:'main.c'Size:168Blocks:8IO Block:4096regularfileDevice: fd01h/64769d Inode:1574570Links:1Access:(0664/-rw-rw-r--)Uid:(1000/ ljm)Gid:(1000/ ljm)Access:2026-11-0217:13:00.025832295 +0800 Modify:2026-11-0217:12:56.438814271 +0800 Change:2026-11-0217:12:56.438814271 +0800 Birth: -- 分区之后的格式化操作,就是对分区进行分组,在每个分组中写入 SB、GDT、Block Bitmap、Inode Bitmap 等管理信息,这些管理信息统称:文件系统;
- 只要知道文件的 inode 号,就能在指定分区中确定是哪一个分组,进而在哪一个分组确定是哪一个 inode;
- 拿到 inode,文件属性和内容就全部都有了。
2. struct inode 是用来存储文件属性的
在 Linux 操作系统中,核心的底层设计原则之一就是:文件 = 内容 + 属性,且内容和属性是分开存储的!
在 Linux 中,任何正常的文件都必须拥有属于自己的属性集合。这个集合在内核中被定义为名为struct inode的结构体。
structinode{/* 文件的各类属性 */mode_ttype;/* 文件类型 */size_tsize;/* 文件大小 */uid_tuid;/* 所有者 UID */gid_tgid;/* 所属组 GID */intlink_count;/* 硬链接数 */intinode_number;/* 该 inode 在当前分区内的唯一编号 */intdatablocks[N];/* 数组:映射该文件所对应的 Data Block 块号 */// 这个 N 表示该文件所对应的数据块编号!!!};3. struct inode 的特征
- 大小固定:所有文件的属性大小都是一样的,通常在 EXT2 中一个 inode 占用 128 字节;
- 块级打包:由于一个物理数据块是 4KB,因此一个 4KB 的数据块内部可以刚好保存 4096 ÷ 128 = 32 个 inode 结构;
- 唯一标识:文件名不作为属性保存在文件的 inode 中!在同一个分区内部,每个文件都对应着一个在当前分区内唯一的 inode 编号(inode Number)。
文件的 inode 号可以通过 ls 的 -i 选项查看:
[ljm@iZ0jlgycwuno4vikja7kneZ day13]$ ll-itotal81574215drwxrwxr-x2ljm ljm4096Nov217:13 Mylibc1574213drwxrwxr-x2ljm ljm4096Nov211:37 test13.2 inode 查找文件内容的完整流程
往后,我们拿着一个文件的 inode 就可以获取该文件的全部内容。
3.3 跨组但不能跨分区的边界
文件的 inode 结构体与它实际存储内容的 Data Blocks 是跨组编号的。这意味着一个文件的 inode 属性可能在 Block Group 0 中,而它的内容数据块可能分布在 Block Group 1。又或者说:如果我的文件超级无敌大,一个组里面的 block 放不下,怎么办?这个文件内容是可以跨组保存的。
我们的每一个组都是固定的,所以我们可以直接用这个 inode 的编号,去除以这个组的大小,再对这个组的大小取模,就可以知道我的这个 inode 在哪个组的哪一个位置。
但是,inode 和数据块,绝对不能跨分区!因此,在同一个分区内部,inode 编号和块号都是唯一的;而不同分区之间的 inode 编号则是各自独立的。
操作系统管理文件系统,本质是将文件系统的每一个分区的管理信息加载到内存中。
通过先描述再组织的方式,将对文件系统每一个分区的管理变成对数据结构的增删查改。
比如:要对每一个组进行管理,可以把 GDT 加载到内存中,一样的操作。
管理的时候只需要将管理信息加载到内存。
3.4 struct ext2_inode 与 struct inode
在 Linux 系统中,我们常说的 inode 实际上有两个完全不同的存在形态:一个是磁盘级别的 inode(struct ext2_inode),另一个是内存级别的 inode(struct inode)。
结论:内存中的 struct inode 包含的信息比磁盘上的 struct ext2_inode 要多得多!内存级别的 inode 才是父集,而磁盘级别的 inode 是它的子集。
1. 为什么内存 struct inode 反而是父集?
我们可以思考一个核心问题:当一个文件被加载到内存中并被进程操作时,操作系统除了需要知道它的物理属性(大小、权限、创建时间)之外,还需要管理什么?
内核需要管理大量的内存运行时状态。而这些状态在磁盘上是根本没有、也不需要记录的。
内存中的struct inode(定义在<linux/fs.h>中)除了包含磁盘 inode 的核心属性(如 uid、gid、size 等)之外,还塞满了各种管理用的红黑树、链表头和锁。
四、目录的本质与文件查找机制
4.1 文件名不在 inode 中,那它保存在哪里?
当我们通过命令行查看文件时,使用的是文件名(如 code.c),而磁盘文件系统只认 inode 编号。这两者是如何关联起来的呢?
答案在于目录(Directory)。在 Linux 中,目录也是一种文件!它同样遵循"内容 + 属性"的分开存储规则:
- 目录的属性:保存在属于该目录文件的 inode 中;
- 目录的内容:保存在该目录所分配的 Data Blocks 中。而目录的数据内容,本质上就是一组组"文件名 : inode 编号"的映射关系!
在磁盘中,我们不再对文件和目录做区分:都是 inode + 数据内容存储。具体是文件还是目录,根据 inode 的 type 获取。
1. ACM 时间
修改了文件名称,文件的 ACM 时间不变,目录的 ACM 时间改变。
4.2 完整的路径解析与文件定位流程
由于文件名存在于其所属的父目录的数据块中,因此我们在访问任何一个文件时,操作系统都必须获得该文件的路径。
例如,当我们访问/home/ljm/code.c时,系统的底层查找逻辑如下:
- 找到根目录
/的 inode(系统唯一默认已知); - 读取
/的 Data Block; - 找到
home目录的 inode 编号; - 读取
home的 Data Block; - 找到
code.c的 inode 编号; - 读取
code.c的 inode 属性; - 进而读取内容。
文件名和 inode 互为键值。
每次访问文件都要从根目录开始向下逐层解析路径、触发多级磁盘 I/O,效率不是太低了吗?
是的。所以 Linux 内核(OS)在进行路径解析时,会将历史访问过的目录路径和映射关系缓存到内存中,形成一棵多叉树结构进行保存(每次读取一个目录就构建一个结点)。这个内存中的核心结构体就是
struct dentry(目录项缓存),它能够极大地减少对物理磁盘的 I/O 次数(它本质上就是 Linux 树状目录结构在内存中的映射)。
——从根目录开始查找,第一次有点慢,第二次开始就快了,也是这个道理。
4.3 深入理解 dentry 树与 LRU 算法
1. 什么是 LRU 算法?
LRU 是 Least Recently Used 的缩写,意为最近最少使用。它是一种极其经典的缓存淘汰策略(Cache Replacement Policy)。
其核心思想基于计算机的"时间局部性"原理:如果数据最近被访问过,那么它在未来被访问的概率也会更高;反之,如果某些数据长期没有被访问,那么在内存不足时,应该优先将其清理掉。
2. 为什么 dentry 需要 LRU?
在 Linux 文件系统中,每一层路径(例如/usr/bin/python中的/、usr、bin、python)都对应一个 dentry(Directory Entry,目录项)结构体。这些目录项层层嵌套,在内核中构成了一棵dentry 树。
为了避免每次查找路径都去读取慢速的磁盘,内核会将这些 dentry 缓存在内存中,构建出dentry cache(简称 dcache)。
然而,内存空间是有限的。当大量的目录项占用了内存,且面临以下情况时,内核便会引入 LRU 链表进行管理:
- 引用计数归零(d_count == 0):当某个目录项当前没有被任何进程使用(即没有进程正在打开、访问或驻留在此路径下),它的引用计数会变为 0;
- 延迟销毁:内核此时不会立刻销毁它。因为如果立刻销毁,万一进程下一秒又要访问这个路径,就不得不重新从磁盘加载;
- 挂载到 LRU 链表:内核选择将这些引用计数为 0 的孤立 dentry 挂载到一个全局的 LRU 链表中。
3. dentry LRU 的工作流
dcache 内部的 LRU 链表主要承担着"缓冲"与"淘汰"的作用,其具体运行逻辑如下:
- 重新激活:如果一个位于 LRU 链表中的 dentry 突然又被某个进程访问了,它的 d_count 会重新大于 0。此时内核会将其移出 LRU 链表,重新回到活跃的 dcache 中;
- 尾部淘汰(内存回收):当系统内存吃紧(如触发了内核的
shrink_dcache_sb内存回收机制)时,内核会顺着 LRU 链表的尾部(即最久未被使用的节点)开始,批量将这些引用计数为 0 的 dentry 真正从内存中销毁、释放,以此来还给系统宝贵的物理内存空间。
通过这种设计,Linux 既保证了整个 dentry 树高频路径的高效缓存复用,又在系统内存不足时提供了一种安全、合理的自我裁员机制。
整个 dentry 树的每一个结点又隶属于哈希,方便快速查找。
4.4 文件创建的全过程
当执行touch abc(或写入数据)时,Linux 内核在底层主要完成了以下 4 个步骤:
1. 存储属性
- 操作:内核先去inode bitmap找空闲 bit,得到空闲 inode 编号,再用这个编号去 inode table 定位对应的结构体位置;
- 具体:系统找到了编号为 263466 的 inode,并将文件 abc 的文件类型、所有者、权限、大小以及时间戳等元数据(属性)记录到该 inode 结构中。
2. 存储数据
- 操作:先去block bitmap(块位图)找空闲 bit,得到空闲块号,再拿着块号去 Data Block 区域读写数据;
- 具体:内核找到了三个空闲块,编号分别为 300、500 和 800。接着,内核将内存缓冲区中的文件数据分别复制到这三个磁盘块中。
3. 记录分配情况(建立 inode 与数据块的映射)
- 操作:为了后续能找到文件内容,内核必须在文件的 inode 结构体内部的磁盘分布区(Block Pointers)中,记录下刚刚分配的磁盘块列表;
- 具体:在编号为 263466 的 inode 中,写入数据块列表 [300, 500, 800]。此后读取该文件时,内核通过 inode 即可直接寻址到对应的磁盘数据。
4. 添加文件名到目录(建立文件名与 inode 的映射)
- 操作:在 Linux 中,目录也是一个文件,其数据块中保存的是一条条"文件名与 inode 号"的映射记录(Directory Entry);
- 具体:内核将新文件的映射入口 (263466, abc) 写入当前所属目录的数据块中。
五、磁盘分区挂载
5.1 什么是挂载(Mount)?
正如前文所述,磁盘经过分区、格式化写入 EXT2 文件系统后,仍然无法直接在系统中使用。
Linux 的规定是:我们的分区必须和一个特定的空目录进行关联。通过进入这个目录,就能向对应的分区中写入和读取数据。这种将分区与目录建立关联的操作就叫做挂载!
5.2 深入理解回环设备(Loop Device)
1. 什么是回环设备?
在 Linux 系统中,/dev/loop0代表第一个循环设备(loop device)。循环设备,也被称为回环设备或者 loopback 设备,是一种伪设备(pseudo-device)。
它允许将文件作为块设备(block device)来使用。这种机制使得可以将文件(比如 ISO 镜像文件)挂载(mount)为文件系统,就像它们是物理硬盘分区或者外部存储设备一样。
通过下面的命令可以查看系统中所有的循环设备及其详细属性:
[ljm@iZ0jlgycwuno4vikja7kneZ dev]$ ll /dev/loop* crw-rw----1root disk10,237Sep1013:41 /dev/loop-control输出信息开头的
b代表这是一个块设备文件(Block Device),其主设备号为 10;如果开头为c,代表这是一个字符设备文件(Character Device)。上面这个loop-control是字符设备,具体挂载生成的/dev/loop0才是块设备。
2. 回环设备挂载实验
为了更直观地理解回环设备的作用,我们可以通过制作一个虚拟磁盘文件并将其挂载到系统中来进行验证。
- 创建并格式化虚拟磁盘文件
首先,利用dd命令生成一个全零的、大小为 5MB 的文件,将其作为我们的虚拟磁盘块。随后,利用mkfs.ext4命令在此文件上格式化并写入 EXT4 文件系统。
ddif=/dev/zeroof=./disk.imgbs=1Mcount=5# 制作一个大的磁盘块,就当做一个分区mkfs.ext4 disk.img# 格式化写入文件系统
dd:Linux 下的一个核心工具(Data Duplicator),用于在底层复制和转换文件;if=/dev/zero:Input File(输入文件)。/dev/zero是 Linux 的一个特殊字符设备文件,它会源源不断地提供全为内核零(空字符\0)的字节流;of=./disk.img:Output File(输出文件)。指定将数据写入到当前目录下的disk.img文件中。如果该文件不存在,系统会自动创建它;bs=1M:Block Size(块大小)。指定单次读取和写入的数据块大小为 1 Megabyte(1MB);count=5:数量。指定总共只复制 5 个数据块。
- 创建挂载点并执行挂载
在系统内创建一个空目录作为挂载点,并在挂载前后通过df -h命令观察系统的磁盘挂载状态:
sudomkdir/mnt/mydisk# 建立空目录df-h# 查看可以使用的分区此时系统输出的挂载列表中,尚未出现任何循环设备。接下来,我们使用mount命令将刚刚创建的disk.img镜像文件挂载到该空目录下:
sudomount-text4 ./disk.img /mnt/mydisk/# 将分区挂载到指定的目录df-h再次查看系统分区状态,会发现在输出列表的最后,自动多出了一条记录:
这表明,Linux 操作系统自动将
disk.img虚拟磁盘文件与本地的回环设备/dev/loop0进行了绑定,并将其视作一个真实的硬件分区挂载到了/mnt/mydisk目录。此时进入该目录,即可像读写普通硬盘一样向该文件中写入数据。
- 卸载分区
当实验结束后,可以通过umount命令解除挂载关联。卸载后,通过df -h可以看到挂载记录已消失,回环设备被安全释放:
sudoumount/mnt/mydisk# 卸载分区我们的文件系统默认就在
/目录底下挂载着,于是我们登录机器后就默认在我们的分区里面。
5.3 重新深入理解操作系统执行 fopen 函数的过程
1. 路径解析与 CWD 补全
用户调用fopen("file.txt", "r")。因为传入的是相对路径,操作系统会去当前进程的task_struct中读取 CWD(当前工作目录),将其拼接建立到文件名之前,组合成绝对路径。
2. 内存缓存查找(Dentry Cache)
VFS(虚拟文件系统)顺着绝对路径的每一级目录,在内存的Dentry 树中查找。
- 如果命中:直接获取该 dentry 结构体中的
d_inode指针,直达该文件的 inode; - 如果未命中:操作系统才会去读磁盘上的目录数据块,建立对应的 dentry 并在内存中创建一个
struct inode,把磁盘上的文件属性和扇区映射表读入这个内存 inode 中。
3. 创建进程级的 struct file
内核在堆区申请并创建一个struct file结构体(代表一次"打开文件"的上下文实例,里面记录了文件的打开模式、当前读写位置f_pos等),让这个struct file的指针指向刚才找到的struct inode。
4. 初始化文件缓冲区(Page Cache)
内核会为这个文件关联或创建文件系统级的缓冲区(Page Cache)。后续的fread/fwrite数据都会先在这里缓存。
5. 分配文件描述符(fd)并返回
内核扫描当前进程task_struct中的文件描述符表(struct file_struct),找到当前最小且未被使用的数字(例如 3)作为文件描述符(fd),把struct file的地址填入该下标的槽位中。
6. C 标准库封装(返回 FILE*)
fopen属于 C 标准库函数。它在拿到内核返回的底层 fd 后,会在用户空间封装一个FILE结构体(里面包含标准库级别的缓冲区),最后把FILE*指针返回给用户。
六、软链接 vs 硬链接
利用命令行工具
ls -li可以清晰地观察到软硬链接在底层设计上的本质区别。
6.1 软链接(Soft Link / Symbolic Link)
可执行程序文件的路径太深不好找,用一个软链接,直接./软链接名称就可以运行。
- 命令:
ln -s code.c code-soft - 本质:软链接是一个独立的文件!因为它拥有自己独立且全新的 inode 编号;
- 数据内容:软链接文件的 Data Blocks 中不保存目标文件的具体内容,而是保存着目标文件的绝对路径或相对路径。类似于 Windows 系统下的"快捷方式"。如果删除了源文件,软链接将会失效(报"死链接"错误)。
6.2 硬链接
1. 硬链接的物理机制与本质
通过ln code.c code-ln来创建:
硬链接(Hard Link)本质上不是一个独立的新文件!它没有自己独立的 inode 编号,而是和源文件共享同一个 inode 编号。
硬链接的建立,实际上只是在当前所属目录的 Data Blocks 中,新增加了一组全新的"别名 : 目标 inode 编号"的映射关系。同时,这会让对应 inode 结构体中的i_links_count(硬链接引用计数)自增 1。
2. 理解硬链接数
成员i_links_count是专门用来记录该 inode 被多少个文件名关联的计数器。只有当一个文件的硬链接计数归零(即删除了所有指向该 inode 的文件名的链接)时,该文件的 inode 空间和数据块才会被系统真正回收。
structext2_inode{__le16 i_mode;/* File mode */__le16 i_uid;/* Low 16 bits of Owner Uid */__le32 i_size;/* Size in bytes */__le32 i_atime;/* Access time */__le32 i_ctime;/* Creation time */__le32 i_mtime;/* Modification time */__le32 i_dtime;/* Deletion time */__le16 i_gid;/* Low 16 bits of Group Uid */__le16 i_links_count;/* Links count —— 硬链接引用计数 */__le32 i_blocks;/* Blocks count */__le32 i_flags;/* File flags *//* ... 后续省略 union 及底层保留字段 ... */};3. 目录的特殊硬链接机制
在 Linux 中,硬链接不仅可以通过ln命令手动创建,系统在创建目录时也会默认利用硬链接机制进行架构支撑。当我们通过mkdir新建一个空目录时,它的默认硬链接数默认是 2。我们来看一下:
drwxrwxr-x2ljm ljm4096Nov313:41dir[ljm@iZ0jlgycwuno4vikja7kneZ test]$ treedirdir0directories,0files可以看到,普通文件 file.txt 的引用计数为 1,而刚刚创建的空目录 dir 的引用计数默认就是 2。我们进入 dir 目录内部查看:
第一个硬链接是父目录下的当前目录名 dir,它指向 inode 1574577;
第二个硬链接是该目录内部默认生成的隐藏目录
.(当前目录),它同样指向 inode 1574577。因此,任何一个目录内部的.本质上都是对当前目录的硬链接。
如果我们继续在 dir 目录下再建一个子目录 d_dir:
我们发现 dir 的硬链接数增加变成了 3。这是因为进入新创建的子目录 d_dir 内部后,默认生成的隐藏目录
..(上一级目录)会自动指向父目录 dir 的 inode(即 1574577)。
总结目录硬链接的递增规律:
- 一个目录的硬链接数 = 2 + 该目录下的子目录个数;
..本质上就是对上级目录的硬链接。
那我们来计算一下 home 目录下的目录数:
6.3 为什么 Linux 不允许普通用户给目录建立硬链接?
当尝试使用ln命令为目录创建硬链接时,系统会直接报错拦截:
[ljm@iZ0jlgycwuno4vikja7kneZ test]$ ll total4-rw-rw-r--2ljm ljm0Nov319:25 code.c -rw-rw-r--2ljm ljm0Nov319:25 code-ln drwxrwxr-x3ljm ljm4096Nov319:29dir[ljm@iZ0jlgycwuno4vikja7kneZ test]$lndirdir-ln ln:'dir':hardlinknot allowedfordirectory[ljm@iZ0jlgycwuno4vikja7kneZ test]$既然 Linux 系统自己会在底层使用.和..对目录进行硬链接映射,为什么不允许普通用户这么干呢?
1. 致命的路径环路问题(环形图缺陷)
Linux 的文件系统组织结构是一棵多叉树。如果允许普通用户随意给目录建立硬链接,会在目录结构中形成环路(Loop)。
Linux 系统内部在执行路径查找、计算目录大小或者备份文件系统时,底层大多依赖的是 **DFS(深度优先搜索)**算法。假设放开限制,构造环路:
- 目录 A inode=100,里面目录项:
B → 200; - 目录 B inode=200,里面目录项:
A → 100。
遍历逻辑(DFS):
进入 A → 找到 B → 进入 B → 找到 A → 进入 A → 找到 B …… 无限递归。
栈不断上涨,崩溃。
而.和..虽然也是硬链接,但它们是系统定死的、行为受控的特殊机制,底层算法在遍历时遇到它们会自动跳过,因此不会引发环路问题。
6.4 软链接为什么可以指向目录?
与之相反,软链接(Symbolic Link)是被允许应用在目录上的。
因为软链接是一个真实存在的独立文件。它拥有自己独立的 inode 编号(如上文中的 1574577),它的 Data Blocks 中存储的是目标路径的字符串。当系统算法在进行 DFS 目录遍历时,不会自动深入去遍历软链接所指向的目标目录。它只会把软链接当成一个普通的文本文件处理,从而天然地规避了路径环路死循环的风险。
七、其他问题
7.1 文件系统存在的浪费问题
为什么在源代码中,我们的 inode 和 data block 的大小是固定的数字(格式化时预先划定的 Inode Table 和 data block 的总大小)?事实上这是科学家决定的。那么会有浪费吗?比如 inode 用完了但是 data block 没有用完等等问题。
会有!所以这是很多文件系统不可避免的浪费问题。