Re:Linux系统篇(三十三)文件篇·六:一文讲透 Linux 文件系统:从 EXT2 物理布局到 VFS 源码级全景解析


◆ 博主名称: 小此方-CSDN博客 大家好,欢迎来到小此方的博客。
⭐️Linux系列个人专栏: 【主题曲】Linux
⭐️此方的GitHub: github_此方
⭐️ Re系列专栏:我们思考 (Rethink) · 我们重建 (Rebuild) · 我们记录 (Record)


文章目录

  • 概要&序论
  • [一、 EXT2 文件系统的整体磁盘布局](#一、 EXT2 文件系统的整体磁盘布局)
  • [二、 深入块组内部的空间管理](#二、 深入块组内部的空间管理)
      • [1. 数据块(Data Blocks)](#1. 数据块(Data Blocks))
      • [2. inode 表(inode Table)](#2. inode 表(inode Table))
      • [3. 块位图(Block Bitmap)](#3. 块位图(Block Bitmap))
      • [4. inode 位图(inode Bitmap)](#4. inode 位图(inode Bitmap))
      • Tips:这两个位图的用处举例
      • [5. 块组描述符表(GDT, Group Descriptor Table)](#5. 块组描述符表(GDT, Group Descriptor Table))
      • [6. 超级块(Super Block)](#6. 超级块(Super Block))
    • 2.1关于对齐
    • [2.2 inode向datablock映射的逻辑结构](#2.2 inode向datablock映射的逻辑结构)
      • [2.2.1 经典多级索引结构剖析](#2.2.1 经典多级索引结构剖析)
  • [三、 属性与内容的分离存储](#三、 属性与内容的分离存储)
    • [3.1 认识 struct inode](#3.1 认识 struct inode)
      • 3.1.1文件的属性信息
      • [3.1.2struct inode是用来存储文件属性](#3.1.2struct inode是用来存储文件属性)
      • [3.1.3struct iode的特征](#3.1.3struct iode的特征)
    • 3.2inode查找文件内容的完整流程
    • [3.3 跨组但不能跨分区的边界](#3.3 跨组但不能跨分区的边界)
    • [3.4 struct ext2_inode与struct inode](#3.4 struct ext2_inode与struct inode)
      • [3.4.1 为什么内存 struct inode 反而是"超集"?](#3.4.1 为什么内存 struct inode 反而是“超集”?)
  • [四、 目录的本质与文件查找机制](#四、 目录的本质与文件查找机制)
    • [4.1 既然文件名不在 inode 中,那它保存在哪里?](#4.1 既然文件名不在 inode 中,那它保存在哪里?)
    • [4.2 完整的路径解析与文件定位流程](#4.2 完整的路径解析与文件定位流程)
    • [4.3 深入理解dentry树与LRU算法](#4.3 深入理解dentry树与LRU算法)
      • [4.3.1 什么是 LRU 算法?](#4.3.1 什么是 LRU 算法?)
      • [4.3.2 为什么 dentry 需要 LRU?](#4.3.2 为什么 dentry 需要 LRU?)
      • [4.3.3 dentry LRU 的工作流](#4.3.3 dentry LRU 的工作流)
    • 4.4文件创建的全过程
  • [五、 磁盘分区挂载与软硬链接](#五、 磁盘分区挂载与软硬链接)
    • [5.1 什么是挂载(Mount)?](#5.1 什么是挂载(Mount)?)
    • [5.2 深入理解回环设备(Loop Device)](#5.2 深入理解回环设备(Loop Device))
      • [5.2.1 什么是回环设备?](#5.2.1 什么是回环设备?)
      • [5.2.2 回环设备挂载实验](#5.2.2 回环设备挂载实验)
        • [1. 创建并格式化虚拟磁盘文件](#1. 创建并格式化虚拟磁盘文件)
        • [2. 创建挂载点并执行挂载](#2. 创建挂载点并执行挂载)
        • [3. 卸载分区](#3. 卸载分区)
    • 5.3重新深入理解操作系统执行fopen函数的过程
        • [1 路径解析与 CWD 补全](#1 路径解析与 CWD 补全)
        • [2 内存缓存查找(Dentry Cache)](#2 内存缓存查找(Dentry Cache))
        • [3 创建进程级的 struct file](#3 创建进程级的 struct file)
        • [4 初始化文件缓冲区(Page Cache)](#4 初始化文件缓冲区(Page Cache))
        • [5 分配文件描述符(fd)并返回](#5 分配文件描述符(fd)并返回)
        • [6 C标准库封装(返回 FILE*)](#6 C标准库封装(返回 FILE*))
    • [5.3 软链接 vs 硬链接](#5.3 软链接 vs 硬链接)
    • 5.4深入理解硬链接
  • 六、其他问题解答

本文和上文关系紧密,可优先观看《Linux系统篇(三十二)文件篇·五》再阅读本文

概要&序论

  我们想要在硬盘上储文件,必须先把硬盘格式化为某种格式的文件系统,才能存储文件。文件系统的目的就是组织和管理硬盘中的文件。 在 Linux 系统中,最常见的是 ext2 系列的文件系统。其早期版本为 ext2,后来又发展出 ext3 和 ext4。ext3 和 ext4 虽然对 ext2 进行了增强,但是其核心设计并没有发生变化,本文主要讲解EXT2文件系统发相关内容。

一、 EXT2 文件系统的整体磁盘布局

1.1 从物理磁盘到逻辑分区的演进

1.1.1什么是分区

  在深入了解文件系统之前,我们需要知道数据在硬件层面的宏观组织方式。物理磁盘首先会被划分为多个分区 ,如 Partition 1 到 Partition n。这种划分由磁盘的主引导记录(MBR)等分区表进行管理。 (分区本质上就是记录所有的起始地址和结束地址)

1.1.2分区的基本单位

  柱面是分区的基本单位。柱面大小一致,扇区个位一致,那么其实只要知道每个分区的起始和结束柱面号,知道每一个柱面多少个扇区,那么该分区多大,LBA是多少也就清楚了。

1.1.3分区的构成

  然而,仅仅完成了分区,操作系统依然无法直接利用它。我们必须对分区进行格式化 ,格式化的本质就是向该分区内写入特定文件系统的管理信息。在 Linux 中,EXT2 便是经典的基于块管理的文件系统。

  在一个 EXT2 分区内部,其开头的第一个数据块被称为启动扇区(Boot Sector) ,用于存放引导加载程序,剩余的空间则被划分为文件系统的核心管理区。

  ext2文件系统将整个分区划分成若干个同样大小的块组 (Block Group), 如下图所示。 只要能管理一个分区就能管理所有分区, 也就能管理所有磁盘文件。

  我们有那么多分区,但是不是所有的分区都用的是同一套文件系统,比如Ext,但是也有的是所有的分区用的是同一套文件系统。

1.2 块组的划分

1.2.1快组与块设备

  其实磁盘是典型的"块"设备,操作系统读取磁盘数据的时候,其实是不会一个个扇区地读取,这样效率太低,而是一次性连续读取8个扇区(4KB),即一次性读取一个"块"。

  硬盘的每个分区是被划分为一个个的"块"。一个"块"的大小是由格式化的时候确定的,并且不可更改。

  "块"是文件存取的最小单位。 好处:在同一时间附近创建的文件往往存在一些关联,一次拷贝4KB这些文件会被同时拷贝到内存,下次需要的时候就不需要再从磁盘种读取。

结论 :文件系统的基本存储和交互单位数通常是 4KB 的数据块。

  科普

  1. 与块设备相对的是字符设备,他们根本的区别是是否支持随机读取。
  2. 在默认 4KB 块大小的经典 EXT2 文件系统规范中,一个块组的最大容量被硬性固定为 128MB。

1.2.3文件系统对文件数据的管理是对块的管理

  每个扇区都有LBA,那么8个扇区一个块,每一个块的地址我们也能算出来。

  • 知道LBA:块号 = LBA/8
  • 知道块号:LAB=块号*8 + n. (n是块内第几个扇区)

文件系统对文件数据的管理就变成了对块的管理

二、 深入块组内部的空间管理

cpp 复制代码
// 磁盘级blockgroup的数据结构
/*
 * Structure of a blocks group descriptor
 */
struct ext2_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

1. 数据块(Data Blocks)

  • 职责 :实际存放文件具体内容的区域,被切分成了多个 4KB 大小的物理数据块。 在一个块中占据绝对空间。

2. inode 表(inode Table)

  • 职责 :用于集中存储文件的属性信息集合。它由许多个固定大小的 inode 结构体串联而成。同样被切分成了多个 4KB 大小的块。

3. 块位图(Block Bitmap)

  • 职责 :以 位(bit) 为单位记录 Data Blocks 中哪个数据块已经被占用,哪个数据块没有被占用。例如,0 表示空闲,1 表示已分配。

  在经典的 EXT2 文件系统设计规范中,不论你的磁盘总容量有多大,每一个块组内部都固定只有 1 个 4KB 大小的块来专门充当 Block Bitmap(块位图)。

4. inode 位图(inode Bitmap)

  • 职责与块位图类似 ,它其中的每个 bit 表示一个 inode 编号是否空闲可用。

  比如我有10万个数据块,这之中一共有7484个文件。于是就会有7484个inode 位图的位被置为1,有10万个块位图的位被置为1。

Tips:这两个位图的用处举例

  生活场景:

  1. 下载资源非常慢,但是删除资源非常快.
  2. 删除的资源被恢复非常快.

  解释:

  当我们删除资源的时候,我们是直接把表示这个资源占有状态的位图blockbitmap和inodebitmap加载到内存,然后从1置为0 ,这个时候这个资源在inodetable和datablocks中的数据就会被视为乱码 ,下次加载其他资源的时候会直接覆盖这块内存。

  如果你误删了某一块资源,那么什么都不要做,立即恢复(位图恢复0->1),否则资源可能被覆盖破坏。

5. 块组描述符表(GDT, Group Descriptor Table)

  • 职责 :块组描述符表,描述块组属性信息,整个分区分成多少个块组就对应有多少个块组描述符 (包含的是整个分区的每一个,不只是当前)。每个块组描述符存储一个块组的描述信息,如在这个块组中从哪里开始是 inode Table,从哪里开始是 Data Blocks,空闲的 inode 和数据块还有多少个等等。块组描述符在每个块组的开头都有一份拷贝。(防止由于磁盘坏道等原因导致 GDT 损坏、整个分区崩溃)

6. 超级块(Super Block)

  • 职责 :存放整个文件系统(注意是整个文件系统)的核心元数据。包括文件系统的总块数、空闲块数、总 inode 数、空闲 inode 数、单个块的大小(如 4KB)以及最近一次挂载时间等。
  • 高可用备份 :为了防止磁盘坏道导致整个文件系统崩溃,Super Block 的信息通常会在某些特定的块组开头保留一份拷贝(备份)

一个文件的存储,它需要申请:

  • 申请数据块
  • 请inode块
  • 修改blockbitmap
  • 修改inodebitmap
    我们得到一个结论:格式化的本质就是将Super Block、GDT、Bitmap 和 inode Table等管理信息进行写入

2.1关于对齐

  • 如果内存页(4KB) == 文件系统块(4KB ) == 磁盘物理扇区(4KB):
    操作系统只需要下达一个简单的指令:"把内存的这一页,复制到磁盘的这个块上。"数据的传输是一对一、完美对齐的(Aligned),效率最高。
  • 如果不对齐(例如磁盘是 512 字节,内存是 4KB):
    操作系统为了写满一个内存页,需要连续读写 8 个磁盘扇区。如果起始位置没对齐,甚至可能引发**"读-改-写"**的额外开销,导致磁盘性能严重下降。

2.2 inode向datablock映射的逻辑结构

  在Linux Ext系列文件系统中,文件 = 属性 + 内容 。其中,文件的属性存储在 inode 中,而文件的内容则分布在磁盘的 Data Block(数据块) 中。

  然而,磁盘的块大小通常是固定的(例如 4KB),而文件的大小却是不可预估的。为了能够高效地管理大大小小的各种文件,并在有限的 inode 空间内建立起从 inode 到无数个 Data Block 的映射关系,Linux 采用了多级间接索引表的逻辑结构。

2.2.1 经典多级索引结构剖析

  在 Ext 传统的文件系统设计中,一个 inode 结构体内部会包含一个大小固定的数据块指针数组(通常为 15 个元素)。这 15 个指针并不是平铺直叙地指向普通数据块,而是分为了四个等级 来协同工作:

  • 直接块指针(前 12 个指针)

      第 1 到第 12 个指针为直接块指针 。它们每一个都直接指向一个普通的 Data Block。如果一个 Data Block 的大小为 4KB,那么前 12 个直接块可以存储的单个文件最大容量为:

    12 × 4 KB = 48 KB 12 \times 4\text{KB} = 48\text{KB} 12×4KB=48KB

      这组指针是为了保证小文件的高效访问。在处理绝大多数几 KB 到几十 KB 的小文件时,操作系统甚至不需要进行二次寻址,就能直接直达数据目的地。

  • 一级间接块索引指针(第 13 个指针)

      第 13 个指针不再指向普通的数据块,而是指向一个数据块索引表 。这个被指向的块里存放的全部都是数据块指针

      数量计算 :假设一个指针占用 4 个字节(32位系统),那么一个 4KB 的索引块内部可以容纳:

    4 KB 4 B = 1024 个指针 \frac{4\text{KB}}{4\text{B}} = 1024\text{个指针} 4B4KB=1024个指针

      这意味着,第 13 个指针通过引入这一级间接关系,可以额外拓展指向 1024 个普通数据块 ,可存储文件的最大容量瞬间暴涨:

    1024 × 4 KB = 4 MB 1024 \times 4\text{KB} = 4\text{MB} 1024×4KB=4MB

  • 二级间接块索引指针(第 14 个指针)

      第 14 个指针指向一个二级间接块索引表。该索引表里的 1024 个指针每一个又分别指向一个一级间接块索引表。

      数量计算 :通过二次幂级的展开,第 14 个指针能够索引的普通数据块达到了:

    1024 × 1024 = 1 , 048 , 576 个块(约 100 万个块) 1024 \times 1024 = 1,048,576\text{个块(约 100 万个块)} 1024×1024=1,048,576个块(约 100 万个块)

      在 4KB 块大小的加持下,二级间接寻址能够为单个文件拓展出接近 4GB 空间( 1 , 048 , 576 × 4 KB = 4 GB 1,048,576 \times 4\text{KB} = 4\text{GB} 1,048,576×4KB=4GB)。

  • 三级间接块索引指针(第 15 个指针)

      第 15 个指针则是终极扩容手段。它指向一个三级间接块索引表,展开后拥有:

    1024 × 1024 × 1024 = 1 , 073 , 741 , 824 个块(约 10 亿个块) 1024 \times 1024 \times 1024 = 1,073,741,824\text{个块(约 10 亿个块)} 1024×1024×1024=1,073,741,824个块(约 10 亿个块)

      理论上三级间接能够支持的单文件大小可达 4TB ( 1 , 073 , 741 , 824 × 4 KB = 4 TB 1,073,741,824 \times 4\text{KB} = 4\text{TB} 1,073,741,824×4KB=4TB)。

为什么要设计得这么复杂,而不是直接使用一个超级大的普通数组?

核心目的同样是为了平衡"检索效率"与"空间开销"。

三、 属性与内容的分离存储

3.1 认识 struct inode

3.1.1文件的属性信息

  ls -l 会读取存储在磁盘上的文件信息并显示出来。除了这种方式,使用 stat 命令能够看到更丰富的属性信息:

bash 复制代码
[root@localhost linux]# stat test.c
  File: "test.c"
  Size: 654             Blocks: 8          IO Block: 4096   普通文件
Device: 802h/2050d      Inode: 263715      Links: 1
Access: (0644/-rw-r--r--)  Uid: (    0/    root)   Gid: (    0/    root)
Access: 2017-09-13 14:56:57.059012947 +0800
Modify: 2017-09-13 14:56:40.067012944 +0800
Change: 2017-09-13 14:56:40.069012948 +0800
  • 分区之后的格式化操作,就是对分区进行分组,在每个分组中写入SB、GDT、Block Bitmap、Inode Bitmap等管理信息,这些管理信息统称:文件系统
  • 只要知道文件的inode号,就能在指定分区中确定是哪一个分组,进而在哪一个分组确定是哪一个inode
  • 拿到inode文件属性和内容就全部都有了

3.1.2struct inode是用来存储文件属性

  在 Linux 操作系统中,核心的底层设计原则之一就是:文件 = 内容 + 属性,且内容和属性是分开存储的!

  在 Linux 中,任何正常的文件都必须拥有属于自己的属性集合。这个集合在内核中被定义为名为 struct inode 的结构体。

c 复制代码
struct inode {
    /* 文件的各类属性 */
    mode_t type;               /* 文件类型 */
    size_t size;               /* 文件大小 */
    uid_t uid;                 /* 所有者 UID */
    gid_t gid;                 /* 所属组 GID */
    int link_count;            /* 硬链接数 */
    
    int inode_number;          /* 该 inode 在当前分区内的唯一编号 */
    int datablocks[N];         /* 数组:映射该文件所对应的 Data Block 块号 */ 
//这个N表示该文件所对应的数据块编号!!!
};

3.1.3struct iode的特征

  1. 大小固定 :所有文件的属性大小都是一样的,通常在 EXT2 中一个 inode 占用 128字节
  2. 块级打包 :由于一个物理数据块是 4KB,因此一个 4KB 的数据块内部可以刚好保存 4096 ÷ 128 = 32 4096 \div 128 = 32 4096÷128=32 个 inode 结构。
  3. 唯一标识文件名不作为属性保存在文件的 inode 中! 在同一个分区内部,每个文件都对应着一个在当前分区内唯一的 inode 编号(inode Number)

  文件的inode号可以通过ls 的-i选项查看:

  为什么文件名称不存在inode中?

  • 原因其之一 :inode本身是固定的但是文件名称是不固定的,放在inode中会导致每一个inode都不一样。
  • 原因其之二:见下4.1

3.2inode查找文件内容的完整流程

  往后,我们拿着一个文件的inode就可以获取文件是所有内容。

3.3 跨组但不能跨分区的边界

  文件的 inode 结构体与它实际存储内容的 Data Blocks跨组编号 的。这意味着一个文件的 inode 属性可能在 Block Group 0 中,而它的内容数据块可能分布在 Block Group 1。又或者说:如果我的文件超级无敌大一个group里面的block放不下。怎么办?这个文件内容是可以跨组保存的。

  我们的每一个组都是固定的,所以我们可以直接用这个inode的编号,去除以这个组的大小,并且是模上这个组的大小,就可以知道我的这个inode在哪个组的哪一个。

  但是,inode 和数据块,绝对不能跨分区! 因此,在同一个分区内部,inode 编号和块号都是唯一的;而不同分区之间的 inode 编号则是各自独立的。

操作系统管理文件系统。

  本质是将文件系统的每一个分区的管理信息加载到内存中

  通过先描述在组织的方式,将对文件系统每一个分区的管理变成对数据结构的增删查改。

  比如:要对每一个组进行管理,可以把GDT加载到内存中,一样的操作。

一句话:管理的时候只需要将管理信息加载到内存

3.4 struct ext2_inode与struct inode

  在Linux系统中,我们常说的 inode 实际上有两个完全不同的存在形态:一个是磁盘级别的 inodestruct ext2_inode),另一个是内存级别的 inodestruct inode)。

  结论 :内存中的 struct inode 包含的信息比磁盘上的 struct ext2_inode 要多得多!内存级别的 inode 才是父集(超集),而磁盘级别的 inode 是它的子集。

3.4.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获取

bash 复制代码
#目录的 Data Block 内容示例:
+-------------+--------------+
|   文件名     |  inode 编号   |
+-------------+--------------+
|   code.c    |   1321114    |
|    dir      |   1321115    |
+-------------+--------------+

反直觉的小结论: 修改了文件名称,文件的ACM时间不变,目录的ACM时间改变

4.2 完整的路径解析与文件定位流程

  由于文件名存在于其所属的父目录的数据块中,因此我们在访问任何一个文件时,操作系统都必须获得该文件的路径。

  例如,当我们访问 /home/whb/code.c 时,系统的底层查找逻辑如下:

cpp 复制代码
1. 找到根目录 `/` 的 inode(系统唯一默认已知) -> 2. 读取 `/` 的 Data Block -> 3. 找到 `home` 目录的 inode 编号
                                                                                  |
2. 读取 `code.c` 的 inode 属性  <- 5. 找到 `code.c` 的 inode 编号 <- 4. 读取 `whb` 的 Data Block

//文件名和inode互为键值

  每次访问文件都要从根目录开始向下逐层解析路径、触发多级磁盘 I/O,效率不是太低了吗?

  是的。所以 Linux 内核(OS)在进行路径解析时,会将历史访问过的目录路径和映射关系缓存到内存中,形成一颗多叉树结构进行保存(每次读取一个目录就构建一个结点)。这个内存中的核心结构体就是 struct dentry(目录项缓存),它能够极大地减少对物理磁盘的 I/O 次数。

(它本质上就是 Linux 树状目录结构在内存中的映射)

  ------------从根目录开始查找,第一次有点慢,第二次开始就快了,也是这个道理。

4.3 深入理解dentry树与LRU算法

4.3.1 什么是 LRU 算法?

  LRULeast Recently Used 的缩写,意为最近最少使用 。它是一种极其经典的缓存淘汰策略(Cache Replacement Policy)

  其核心思想基于计算机的"时间局部性"原理:如果数据最近被访问过,那么它在未来被访问的概率也会更高;反之,如果某些数据长期没有被访问,那么在内存不足时,应该优先将其清理掉。

4.3.2 为什么 dentry 需要 LRU?

  在 Linux 文件系统中,每一层路径(例如 /usr/bin/python 中的 /usrbinpython)都对应一个 dentry(Directory Entry,目录项) 结构体。这些目录项层层嵌套,在内核中构成了一棵 dentry 树

  为了避免每次查找路径都去读取慢速的磁盘,内核会将这些 dentry 缓存在内存中,构建出 dentry cache(简称 dcache)

  然而,内存空间是有限的。当大量的目录项占用了内存,且面临以下情况时,内核便会引入 LRU 链表进行管理:

  • 引用计数归零(d_count == 0:当某个目录项当前没有被任何进程使用(即没有进程正在打开、访问或驻留在此路径下),它的引用计数会变为 0。
  • 延迟销毁 :内核此时不会立刻销毁它。因为如果立刻销毁,万一进程下一秒又要访问这个路径,就不得不重新从磁盘加载。
  • 挂载到 LRU 链表 :内核选择将这些引用计数为 0 的孤立 dentry 挂载到一个全局的 LRU 链表中。

4.3.3 dentry LRU 的工作流

  dcache 内部的 LRU 链表主要承担着"缓冲"与"兜底清理"的作用,其具体运行逻辑如下:

  1. 重新激活 :如果一个位于 LRU 链表中的 dentry 突然又被某个进程访问了,它的 d_count 会重新大于 0。此时内核会将其移出 LRU 链表,重新回到活跃的 dcache 中。
  2. 尾部淘汰(内存回收) :当系统内存吃紧(如触发了内核的 shrink_dcache_sb 内存回收机制)时,内核会顺着 LRU 链表的尾部(即最久未被使用的节点)开始,批量将这些引用计数为 0 的 dentry 真正从内存中销毁、释放,以此来还给系统宝贵的物理内存空间。
cpp 复制代码
  [ 进程访问中 (d_count > 0) ] 
            │
            │ 访问结束 (d_count == 0)
            ▼
  [ LRU 链表头部 (最近释放) ] ───► 重新被访问 ───► [ 回归活跃状态 ]
            │
            │ 随着时间推移,逐渐向后移动
            ▼
  [ LRU 链表尾部 (最久未用) ] ───► 遇到内存紧张 ───► [ 释放销毁,回收内存 ]

  通过这种设计,Linux 既保证了整个 dentry 树高频路径的高效缓存复用,又在系统内存不足时提供了一种安全、合理的自我裁员机制。

  整个dentry树的每一个结点同样隶属于哈希,方便快速查找。

4.4文件创建的全过程

  当执行 touch abc(或写入数据)时,Linux 内核在底层主要完成了以下 4 个步骤:

1. 存储属性

  • 底层操作 :内核首先在磁盘的 i 节点表 (inode table) 中寻找一个空闲的 inode
  • 实例图解 :系统找到了编号为 263466 的 inode,并将文件 abc 的文件类型、所有者、权限、大小以及时间戳等元数据(属性)记录到该 inode 结构中。

2. 存储数据

  • 底层操作 :内核在磁盘的 数据区 (Data Block) 寻找空闲的数据块,用以存放文件的实际内容。
  • 实例图解 :内核找到了三个空闲块,编号分别为 300500800。接着,内核将内存缓冲区中的文件数据分别复制到这三个磁盘块中。

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)

5.2.1 什么是回环设备?

  在 Linux 系统中,/dev/loop0 代表第一个循环设备(loop device) 。循环设备,也被称为回环设备或者 loopback 设备 ,是一种伪设备(pseudo-device)

  它允许将文件作为块设备(block device)来使用。这种机制使得可以将文件(比如 ISO 镜像文件)挂载(mount)为文件系统,就像它们是物理硬盘分区或者外部存储设备一样。

  通过下面的命令可以查看系统中所有的循环设备及其详细属性:

bash 复制代码
[whb@bite:/mnt]$ ls /dev/loop* -l

  执行后,系统会列出类似如下的块设备信息:

text 复制代码
brw-rw---- 1 root disk  7,   0 Oct 17 18:24 /dev/loop0
brw-rw---- 1 root disk  7,   1 Jul 17 10:26 /dev/loop1
brw-rw---- 1 root disk  7,   2 Jul 17 10:26 /dev/loop2
brw-rw---- 1 root disk  7,   3 Jul 17 10:26 /dev/loop3
brw-rw---- 1 root disk  7,   4 Jul 17 10:26 /dev/loop4
brw-rw---- 1 root disk  7,   5 Jul 17 10:26 /dev/loop5
brw-rw---- 1 root disk  7,   6 Jul 17 10:26 /dev/loop6
brw-rw---- 1 root disk  7,   7 Jul 17 10:26 /dev/loop7
crw-rw---- 1 root disk 10, 237 Jul 17 10:26 /dev/loop-control

   注意 :输出信息开头的 b 代表这是一个块设备文件 (Block Device),其主设备号为 7 。最后的 /dev/loop-control 开头为 c ,代表这是一个字符设备文件(Character Device)。

5.2.2 回环设备挂载实验

  为了更直观地理解回环设备的作用,我们可以通过制作一个虚拟磁盘文件并将其挂载到系统中来进行验证。

1. 创建并格式化虚拟磁盘文件

  首先,利用 dd 命令生成一个全零的、大小为 5MB 的文件,将其作为我们的虚拟磁盘块。随后,利用 mkfs.ext4 命令在此文件上格式化并写入 EXT4 文件系统。

bash 复制代码
$dd if=/dev/zero of=./disk.img bs=1M count=5     # 制作一个大的磁盘块,就当做一个分区
$ mkfs.ext4 disk.img  # 格式化写入文件系统
  • dd:Linux 下的一个核心工具(Data Duplicator),用于在底层复制和转换文件。
  • if=/dev/zeroInput File (输入文件)。/dev/zero 是 Linux 的一个特殊字符设备文件,它会源源不断地提供全为内核零(空字符 \0)的字节流。
  • of=./disk.imgOutput File (输出文件)。指定将数据写入到当前目录下的 disk.img 文件中。如果该文件不存在,系统会自动创建它。
  • bs=1MBlock Size(块大小)。指定单次读取和写入的数据块大小为 1 Megabyte(1MB)。
  • count=5:数量。指定总共只复制 5 个数据块。
2. 创建挂载点并执行挂载

  在系统内创建一个空目录作为挂载点,并在挂载前后通过 df -h 命令观察系统的磁盘挂载状态:

bash 复制代码
$mkdir /mnt/mydisk       # 建立空目录
$ df -h                   # 查看可以使用的分区

  此时系统输出的挂载列表中,尚未出现任何循环设备。接下来,我们使用 mount 命令将刚刚创建的 disk.img 镜像文件挂载到该空目录下:

bash 复制代码
$sudo mount -t ext4 ./disk.img /mnt/mydisk/     # 将分区挂载到指定的目录$ df -h

  再次查看系统分区状态,会发现在输出列表的最后,自动多出了一条记录:

text 复制代码
Filesystem      Size  Used Avail Use% Mounted on
...
/dev/loop0      4.9M   24K  4.5M   1% /mnt/mydisk

  这表明,Linux 操作系统自动将 disk.img 虚拟磁盘文件与本地的回环设备 /dev/loop0 进行了绑定 ,并将其视作一个真实的硬件分区挂载到了 /mnt/mydisk 目录。此时进入该目录,即可像读写普通硬盘一样向该文件中写入数据。

3. 卸载分区

  当实验结束后,可以通过 umount 命令解除挂载关联。卸载后,通过 df -h 可以看到挂载记录已消失,回环设备被安全释放:

bash 复制代码
$ sudo umount /mnt/mydisk    # 卸载分区
whb@bite:/mnt$ df -h

我们的文件系统默认就在/目录底下挂载着,于是我们登录机器后就默认在我们的分区里面。

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* 指针返回给用户。

5.3 软链接 vs 硬链接

  利用命令行工具 ls -li 可以清晰地观察到软硬链接在底层设计上的本质区别。

可执行程序文件的路径太深不好找,用一个软链接,直接./软链接名称可以直接运行

  • 命令ln -s code.c code-soft
  • 本质软链接是一个独立的文件! 因为它拥有自己独立且全新inode 编号。
  • 数据内容 :软链接文件的 Data Blocks 中不保存目标文件的具体内容,而是保存着目标文件的绝对路径或相对路径。类似于 Windows 系统下的"快捷方式"。如果删除了源文件,软链接将会失效(报"死链接"错误)。

5.3.2硬链接

5.3.2.1硬链接的物理机制与本质

  硬链接(Hard Link)本质上不是一个独立的新文件! 它没有自己独立的 inode 编号,而是和源文件共享同一个 inode 编号。

  硬链接的建立,实际上只是在当前所属目录的 Data Blocks 中,新增加了一组全新的"别名 : 目标 inode 编号 "的映射关系。同时,这会让对应 inode 结构体中的 i_links_count(硬链接引用计数)自增 1。

5.4深入理解硬链接

  成员 i_links_count 是专门用来记录该 inode 被多少个文件名关联的计数器。只有当一个文件的硬链接计数归零(即删除了所有指向该 inode 的文件名和链接)时,该文件的 inode 空间和数据块才会被系统真正回收。

c 复制代码
struct ext2_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 及底层保留字段 ... */
};

5.4.1目录的特殊硬链接机制

  在 Linux 中,硬链接不仅可以通过 ln 命令手动创建,系统在创建目录时也会默认利用硬链接机制进行架构支撑。当我们通过 mkdir 新建一个空目录时,它的默认硬链接数默认是 2。我们来看一个实际的命令行演练:

bash 复制代码
[whb@bite-alicloud bin]$ touch file.txt
[whb@bite-alicloud bin]$ ll -li
total 4
1321114 drwxrwxr-x 2 whb whb 4096 Jan 15 18:03 dir
1321115 -rw-rw-r-- 1 whb whb    0 Jan 15 18:03 file.txt

  可以看到,普通文件 file.txt 的引用计数为 1 ,而刚刚创建的空目录 dir 的引用计数默认就是 2 。我们进入 dir 目录内部查看:

bash 复制代码
[whb@bite-alicloud bin]$ cd dir
[whb@bite-alicloud dir]$ ls -ali
total 8
1321114 drwxrwxr-x 2 whb whb 4096 Jan 15 18:03 .
1321118 drwxrwxr-x 3 whb whb 4096 Jan 15 18:03 ..

  原因揭晓

  1. 第一个硬链接是父目录下的当前目录名 dir,它指向 inode 1321114
  2. 第二个硬链接是该目录内部默认生成的隐藏目录 . (当前目录),它同样指向 inode 1321114。因此,任何一个目录内部的 . 本质上都是对当前目录的硬链接。

  如果我们继续在 dir 目录下再建一个子目录 hello

bash 复制代码
[whb@bite-alicloud dir]$ mkdir hello
[whb@bite-alicloud dir]$ ls -li
total 4
1321117 drwxrwxr-x 2 whb whb 4096 Jan 15 18:05 hello

  此时我们退回到上一级目录,再次查看 dir 的属性:

bash 复制代码
[whb@bite-alicloud bin]$ ls -li
total 4
1321114 drwxrwxr-x 3 whb whb 4096 Jan 15 18:05 dir

  我们发现 dir 的硬链接数增加变成了 3 。这是因为进入新创建的子目录 hello 内部后,默认生成的隐藏目录 .. (上一级目录)会自动指向父目录 dir 的 inode(即 1321114)。

bash 复制代码
[whb@bite-alicloud hello]$ ls -ali
total 8
1321117 drwxrwxr-x 2 whb whb 4096 Jan 15 18:05 .
1321114 drwxrwxr-x 3 whb whb 4096 Jan 15 18:05 ..

  总结目录硬链接的递增规律

  • 一个目录的硬链接数 = 2 + 该目录下的子目录个数
  • .. 本质上就是对上级目录的硬链接。

5.4.2为什么 Linux 不允许普通用户给目录建立硬链接?

  当尝试使用 ln 命令为目录创建硬链接时,系统会直接报错拦截:

bash 复制代码
[whb@bite-alicloud lesson22]$ ln dir hard
ln: 'dir': hard link not allowed for directory

  既然 Linux 系统自己会在底层使用 ... 对目录进行硬链接映射,为什么不允许普通用户这么干呢?

  1. 致命的路径环路问题(环形图缺陷)

  Linux 的文件系统组织结构是一棵多叉树 。如果允许普通用户随意给目录建立硬链接,会在目录结构中形成环路(Loop)

  Linux 系统内部在执行路径查找、计算目录大小、或者备份文件系统时,底层依赖的是 BFS(广度优先搜索)DFS(深度优先搜索) 算法。如果目录中存在用户自定义的硬链接环路,搜索算法就会陷入死循环(死锁/无限递归 ),导致系统崩溃。

  而 ... 虽然也是硬链接,但它们是系统定死的、行为受控的特殊机制,底层算法在遍历时遇到它们会自动跳过,因此不会引发环路问题。

5.4.3软链接为什么可以指向目录?**

  与之相反,软链接(Symbolic Link)是被允许应用在目录上的:

bash 复制代码
[whb@bite-alicloud lesson22]$ ln -s dir hard
[whb@bite-alicloud lesson22]$ ll -li
total 8
1321121 drwxrwxr-x 2 whb whb 4096 Jan 17 14:42 dir
1321122 lrwxrwxrwx 1 whb whb    3 Jan 17 14:44 hard -> dir

  因为软链接是一个真实存在的独立文件 。它拥有自己独立的 inode 编号(如上文中的 1321122),它的 Data Blocks 中存储的是目标路径的字符串。当系统算法在进行 DFS 目录遍历时,不会自动深入去遍历软链接所指向的目标目录。它只会把软链接当成一个普通的"快捷方式"文本文件处理,从而天然地规避了路径环路死循环的风险。

六、其他问题解答

6.1文件系统存在的浪费问题

  为什么在源代码中,我们的inode和datablock的大小是固定的数字? 事实上这是科学家决定的,。那么会有浪费吗?比如在inode用完了但是datablock没有用完。等等问题。会有!所以这是很多文件系统不可避免浪费问题。

6.2文件系统总结图


好的本期内容就到这里,如果对你有帮助,还不要忘记点赞三联支持。我是此方,我们下期再见。bye!