【Linux系统编程】Ext 文件系统:磁盘与存储、文件与目录、现代计算机究竟如何寻找文件

从一根磁道到一棵目录树:Linux Ext 文件系统深度剖析


🌈 say-fall:个人主页 🚀 专栏:《手把手教你学会C++》 | 《系统深入Linux操作系统》 | 《数据结构与算法》 💪 格言:做好你自己,才能吸引更多人,与他们共赢,这才是最好的成长方式。


📝 前言

在前面的文章中,我们已经聊过进程、内存、文件描述符这些"上半身"的话题。但你有没有想过:你敲下 cat test.c,屏幕上就出现了一行行代码------这块硬盘里到底发生了什么?

更具体地说:

现象 我们以为的答案 真实的答案
找一个文件 用文件名搜 实际上在底层搜的是 inode 编号
看一个目录 目录就是文件夹 目录其实是 一种特殊文件,内容是文件名→inode 的映射表
存一堆数据 顺序写到硬盘 数据被切成 块(block),散落在磁盘各处
定位一个扇区 用文件名/路径就行 物理上是 CHS,现代硬盘统一用 LBA

🔍 问题来了: 从一块"啥也没有"的物理硬盘,到你能 ls、cd、cat,中间到底经历了哪些抽象?文件、目录、inode、块组、超级块、软硬链接......这些概念是怎么一层层搭起来的?

答案就是------Ext 系列文件系统(以 ext2 为核心)!它用一套精妙的数据结构,把一维的 LBA 扇区数组,组织成了我们熟悉的"目录树"。

通过本文,你将掌握:

技能 应用场景
磁盘物理结构 理解 CHS/LBA 寻址,分清盘面、磁道、柱面、扇区
CHS 与 LBA 互相转换 明白 OS 为什么只跟 LBA 打交道
块 / 分区 / 块组 理解 ext2 把硬盘切成"小区"的方式
inode 与多级指针映射 算出一个 inode 最多能管多大的文件
目录、路径解析、dentry 缓存 解释为什么 cd / 之后一切变快
挂载实验(dd + mkfs + mount) 亲手造一块"假硬盘",理解分区挂载
软链接与硬链接 看穿 .、..、快捷方式的本质

📌 前置知识: 了解 Linux 基本命令行(ls、cd、cat),了解"文件 = 内容 + 属性"的基本概念即可。

文章目录

  • [从一根磁道到一棵目录树:Linux Ext 文件系统深度剖析](#从一根磁道到一棵目录树:Linux Ext 文件系统深度剖析)
    • [📝 前言](#📝 前言)
    • [一、💽 磁盘的物理结构:盘片、磁道、扇区、磁头、柱面](#一、💽 磁盘的物理结构:盘片、磁道、扇区、磁头、柱面)
      • [1.1 机械磁盘是计算机里唯一"会转"的东西](#1.1 机械磁盘是计算机里唯一"会转"的东西)
      • [1.2 拆开硬盘:盘片、磁道、扇区](#1.2 拆开硬盘:盘片、磁道、扇区)
      • [1.3 几个容易混淆的细节](#1.3 几个容易混淆的细节)
      • [1.4 CHS 寻址:物理世界的"三维坐标"](#1.4 CHS 寻址:物理世界的"三维坐标")
    • [二、🔢 从三维到一维:LBA 寻址与 CHS/LBA 互相转换](#二、🔢 从三维到一维:LBA 寻址与 CHS/LBA 互相转换)
      • [2.1 LBA 的核心思想:把磁盘拉直成磁带](#2.1 LBA 的核心思想:把磁盘拉直成磁带)
      • [2.2 谁来做 CHS↔LBA 转换?](#2.2 谁来做 CHS↔LBA 转换?)
      • [2.3 CHS 与 LBA 的互转公式](#2.3 CHS 与 LBA 的互转公式)
      • [2.4 一个例子](#2.4 一个例子)
    • [三、📦 块、分区与 ext2 文件系统的雏形](#三、📦 块、分区与 ext2 文件系统的雏形)
      • [3.1 引入"块(Block)":8 个扇区打包读](#3.1 引入"块(Block)":8 个扇区打包读)
      • [3.2 引入"分区(Partition)":把一块大硬盘切成几段](#3.2 引入"分区(Partition)":把一块大硬盘切成几段)
      • [3.3 引入"inode":文件 = 内容 + 属性](#3.3 引入"inode":文件 = 内容 + 属性)
    • [四、🗂️ ext2 文件系统宏观认识:把分区切成 N 个"小区"](#四、🗂️ ext2 文件系统宏观认识:把分区切成 N 个"小区")
      • [4.1 把分区切成块组(Block Group)](#4.1 把分区切成块组(Block Group))
      • [4.2 块组(Block Group)的内部结构](#4.2 块组(Block Group)的内部结构)
      • [4.3 超级块(Super Block)详解](#4.3 超级块(Super Block)详解)
      • [4.4 块组描述符表(GDT)](#4.4 块组描述符表(GDT))
      • [4.5 位图(Bitmap):"占座"管理](#4.5 位图(Bitmap):"占座"管理)
      • [4.6 inode 表(Inode Table)](#4.6 inode 表(Inode Table))
      • [4.7 数据块(Data Block)](#4.7 数据块(Data Block))
    • [五、🧭 inode 与数据块的多级指针映射:12 + 1 + 1 + 1 = 4GB+](#五、🧭 inode 与数据块的多级指针映射:12 + 1 + 1 + 1 = 4GB+)
      • [5.1 inode 里的 `i_block15` 是什么?](#5.1 inode 里的 i_block[15] 是什么?)
      • [5.2 一图看懂多级指针](#5.2 一图看懂多级指针)
      • [5.3 一个 inode 最多能管多大的文件?](#5.3 一个 inode 最多能管多大的文件?)
      • [5.4 创建新文件的 4 步操作](#5.4 创建新文件的 4 步操作)
    • [六、📂 目录究竟是什么?磁盘里真的没有"目录"!](#六、📂 目录究竟是什么?磁盘里真的没有"目录"!)
      • [6.1 一个反直觉的事实](#6.1 一个反直觉的事实)
      • [6.2 内存里的"目录树" vs 磁盘里的"目录"](#6.2 内存里的"目录树" vs 磁盘里的"目录")
      • [6.3 一个文件被访问的全过程](#6.3 一个文件被访问的全过程)
      • [6.4 一个递归式的问题](#6.4 一个递归式的问题)
    • [七、⚡ 路径缓存(dentry cache):让"打开过的路径"飞起来](#七、⚡ 路径缓存(dentry cache):让"打开过的路径"飞起来)
      • [7.1 每次都从根解析?太慢了!](#7.1 每次都从根解析?太慢了!)
      • [7.2 `struct dentry` 源码剖析](#7.2 struct dentry 源码剖析)
      • [7.3 dentry 树的工作机制](#7.3 dentry 树的工作机制)
      • [7.4 总结:磁盘 vs 内存的对应关系](#7.4 总结:磁盘 vs 内存的对应关系)
    • [八、🛠️ 挂载分区:把"裸盘"和"目录树"连起来](#八、🛠️ 挂载分区:把"裸盘"和"目录树"连起来)
      • [8.1 为什么要挂载?](#8.1 为什么要挂载?)
      • [8.2 一个实验:手工制造一块"假硬盘"](#8.2 一个实验:手工制造一块"假硬盘")
      • [8.3 卸载](#8.3 卸载)
      • [8.4 一个结论](#8.4 一个结论)
    • [九、🪢 软链接与硬链接:同一个文件的两种"分身"](#九、🪢 软链接与硬链接:同一个文件的两种"分身")
      • [9.1 硬链接(Hard Link):多个文件名指向同一个 inode](#9.1 硬链接(Hard Link):多个文件名指向同一个 inode)
      • [9.2 软链接(Symbolic Link,符号链接):独立的"快捷方式"](#9.2 软链接(Symbolic Link,符号链接):独立的"快捷方式")
      • [9.3 软硬链接对比](#9.3 软硬链接对比)
      • [9.4 软硬链接的典型用途](#9.4 软硬链接的典型用途)
      • [9.5 文件的三个时间戳](#9.5 文件的三个时间戳)
    • [十、🗑️ 深入理解:文件删除与 inode 回收的真实流程](#十、🗑️ 深入理解:文件删除与 inode 回收的真实流程)
      • [10.1 `unlink` 系统调用](#10.1 unlink 系统调用)
      • [10.2 完整的删除流程](#10.2 完整的删除流程)
      • [10.3 一个"被删但还活着"的有趣现象](#10.3 一个"被删但还活着"的有趣现象)
      • [10.4 目录的删除](#10.4 目录的删除)
    • [十一、📊 文件系统知识图谱:从一句话到全貌](#十一、📊 文件系统知识图谱:从一句话到全貌)
  • [⭐ 看图复习](#⭐ 看图复习)
    • [十二、🤔 几个思考题](#十二、🤔 几个思考题)
      • [1️⃣ 为什么机械硬盘的 CHS 寻址最大只能寻到 8.4GB 左右?怎么算出来的?](#1️⃣ 为什么机械硬盘的 CHS 寻址最大只能寻到 8.4GB 左右?怎么算出来的?)
      • [2️⃣ inode 是分区内唯一的还是磁盘内唯一的?为什么不同分区的 inode 编号可能相同?](#2️⃣ inode 是分区内唯一的还是磁盘内唯一的?为什么不同分区的 inode 编号可能相同?)
      • [3️⃣ 一个 inode 最多能管多大的文件?请用 4KB 块大小和 4 字节指针算一下。](#3️⃣ 一个 inode 最多能管多大的文件?请用 4KB 块大小和 4 字节指针算一下。)
      • [4️⃣ 既然目录是文件,那目录项里到底存了什么?`.` 和 `..` 是什么?](#4️⃣ 既然目录是文件,那目录项里到底存了什么?. 和 .. 是什么?)
      • [5️⃣ 磁盘上真的没有"目录"吗?那我们看到的目录树是哪来的?](#5️⃣ 磁盘上真的没有"目录"吗?那我们看到的目录树是哪来的?)
      • [6️⃣ `rm` 一个正在被进程打开的文件,到底发生了什么?](#6️⃣ rm 一个正在被进程打开的文件,到底发生了什么?)
      • [7️⃣ 软链接可以跨文件系统,硬链接为什么不行?](#7️⃣ 软链接可以跨文件系统,硬链接为什么不行?)
      • [8️⃣ dentry 缓存和 inode 缓存是一回事吗?](#8️⃣ dentry 缓存和 inode 缓存是一回事吗?)
    • 本节完

一、💽 磁盘的物理结构:盘片、磁道、扇区、磁头、柱面

1.1 机械磁盘是计算机里唯一"会转"的东西

我们今天的故事,先从一块最"原始"的硬件------ **机械硬盘(HDD)**开始讲起。

机械硬盘有几个很鲜明的特点:

  • 它是外设------CPU 不能直接读写,得通过控制器(南桥/磁盘控制器)下达指令
  • 它很慢------相对内存来说,读一次磁盘大约是 1ms 级别,而内存是 100ns 级别,差了一万倍
  • 它容量大、价格便宜------几 TB 也就几百块,所以"慢点但便宜"也能接受

💡 小贴士: 固态硬盘(SSD)没有机械结构,但绝大多数 OS 文件系统(ext4、NTFS 等)最初都是为机械盘设计的,所以搞懂机械盘 = 搞懂文件系统的设计初衷。

1.2 拆开硬盘:盘片、磁道、扇区

把一块机械盘的外壳拆掉,里面是这样:

复制代码
            ┌─────────────────────────┐
            │  盘片 1(Platter)       │  ← 一张圆形的"光碟"
            │   ┌───────────────┐     │
            │   │   磁道 0      │     │  ← 离主轴最远的同心圆
            │   │   磁道 1      │     │
            │   │   磁道 2      │     │
            │   │   ...         │     │
            │   │   磁道 N      │     │  ← 离主轴最近的同心圆
            │   └───────────────┘     │
            │  盘片 2 ...              │
            │  传动臂 + 磁头           │  ← 多个磁头,共进退
            └─────────────────────────┘

磁盘并不是只有一个面,而是由好几个面构成的,像是千层面结构:

几个核心名词:

名词 英文 含义 重要细节
盘片 Platter 一张圆形的薄片,双面都可存数据 一般一块硬盘有 1~4 张
磁道 Track 盘片表面一个同心圆 从外到内编号 0、1、2...
扇区 Sector 磁道被切成的一段段弧 基本读写单位,512 字节
磁头 Head 负责读/写的小电磁铁 每个盘面上下各一个
柱面 Cylinder 所有盘面同一半径的磁道"摞起来" 关键点:磁头是共进退的
停靠区 Landing Zone 盘片最内圈的一块不存数据 磁头闲置时停这里,避免划伤盘面

1.3 几个容易混淆的细节

📌 关键细节 1:每磁道扇区数相同

早期磁盘的每个磁道扇区数是一样的(虽然物理上外圈磁道能装更多数据,但为了简化控制都按"最小磁道"来算)。这就导致一个直接推论:

磁盘容量 = 磁头数 × 磁道数 × 每道扇区数 × 512B
📌 关键细节 2:磁头共进退

多个磁头焊在同一根传动臂上,物理上没法"独立行动"。于是就有了柱面这个逻辑概念:

  • 选"磁道 5 的 0 面"和"磁道 5 的 1 面"是同时定位的
  • 切换磁道是"机械移动"传动臂,比切换磁头慢得多
  • 所以先选柱面、再选磁头、最后选扇区就是最优的寻址顺序

1.4 CHS 寻址:物理世界的"三维坐标"

因为有了"柱面-磁头-扇区"这三个维度,我们就可以用一个三元组 (C, H, S) 来精确定位磁盘上任意一个扇区,这就是著名的:

CHS 寻址:Cylinder(柱面号)+ Head(磁头号)+ Sector(扇区号)

但 CHS 寻址有一个致命的硬伤:地址空间有限。早期 BIOS 用 8 bit 存磁头、10 bit 存柱面、6 bit 存扇区,算下来一块硬盘最大只能寻址:

复制代码
2^8 × 2^10 × 2^6 × 512B = 256 × 1024 × 63 × 512B ≈ 8.4 GB

8.4 GB!放到现在连一张照片都装不下。这显然不够用,于是就有了 LBA。

💡 历史冷知识: 早期装 Windows XP 的同学可能记得"老硬盘超过 8GB 装不上系统"的梗,那就是 CHS 寻址的 24 bit 限制在作怪。


二、🔢 从三维到一维:LBA 寻址与 CHS/LBA 互相转换

2.1 LBA 的核心思想:把磁盘拉直成磁带

还记得小时候听歌的磁带吗?磁带本身是"卷起来"的一长条,但我们可以把它拉直成一条线。

机械盘的物理结构虽然是"卷起来"的盘片,但逻辑上 ,我们可以假装它也是一根长条:

复制代码
   磁道 0  →  [s0][s1][s2][s3] ... [sN]  ┐
   磁道 1  →  [s0][s1][s2][s3] ... [sN]  ├─  拉直成一条
   磁道 2  →  [s0][s1][s2][s3] ... [sN]  │
   ...                                    │
   磁道 M  →  [s0][s1][s2][s3] ... [sN]  ┘
   
   拉直后:s0(0道0面), s1(0道0面), ..., sN(0道0面),
           s0(1道0面), s1(1道0面), ..., 
           ...
           s0(M道末面), s1(M道末面), ..., sN(M道末面)

把这根"长条"上的每一个扇区,从 0 开始编号------这个编号就是:

LBA(Logical Block Address):逻辑块地址,本质就是扇区在"一维数组"里的下标。

这样,对操作系统来说,磁盘就是一个"元素为扇区的一维数组" ,LBA 0 是第 1 个扇区,LBA 1 是第 2 个......以此类推。

2.2 谁来做 CHS↔LBA 转换?

OS 只跟 LBA 打交道,CHS 地址根本不用关心。那 LBA 转 CHS 到底谁做?

答案:磁盘自己做!

磁盘内部有固件(硬件电路、伺服系统),开机时 OS 通过 ATA/SATA 协议问它:"你有多少柱面?多少磁头?每道多少扇区?",然后固件就负责 LBA → CHS 的实时转换。

对 OS 来说,它就当磁盘是个一维数组,永远不碰 CHS。

2.3 CHS 与 LBA 的互转公式

虽然 OS 不直接用 CHS,但理解转换公式对搞懂磁盘结构很有帮助。设:

  • C = 柱面号(0 开始)
  • H = 磁头号(0 开始)
  • S = 扇区号(1 开始,与 LBA 的 0 开始不同!)
  • N = 每磁道扇区数
  • Heads = 磁头数

语法:CHS → LBA

复制代码
LBA = C × (Heads × N) + H × N + (S - 1)

语法:LBA → CHS

复制代码
C = LBA ÷ (Heads × N)         // 整除
H = (LBA % (Heads × N)) ÷ N   // 取余后再整除
S = (LBA % N) + 1             // 注意 +1,因为扇区号从 1 开始

💡 速记口诀: 柱面优先、磁头其次、扇区最后;扇区号从 1 开始,LBA 从 0 开始,这就是 (S-1) 和 +1 的来源。

2.4 一个例子

假设某硬盘参数:Heads=4, N=63(即每道 63 扇区),已知 LBA = 1000,求 CHS?

复制代码
C = 1000 ÷ (4 × 63) = 1000 ÷ 252 = 3    (商)
H = (1000 % 252) ÷ 63 = 244 ÷ 63 = 3     (余数 244,再整除 63)
S = (1000 % 63) + 1 = 55 + 1 = 56

所以 LBA 1000 对应 C=3, H=3, S=56。验证一下:

复制代码
LBA = 3 × 252 + 3 × 63 + 56 - 1 = 756 + 189 + 55 = 1000  ✅

📌 关键结论: 从此往后,磁盘就是一个元素为扇区的一维数组,下标就是 LBA。OS 只需要一个数字就能访问任意扇区,再也不关心物理结构了。


三、📦 块、分区与 ext2 文件系统的雏形

OS 现在能"按扇区"访问磁盘了。但每次只读 512 字节?太慢、太碎!于是就有了"块"的概念。

3.1 引入"块(Block)":8 个扇区打包读

块(Block):操作系统读/写硬盘的最小单位 ,由若干个连续扇区组成。ext 系列默认 1 块 = 8 个扇区 = 4KB。

为什么是 4KB?这是工程上的折衷:

  • 比 512B 大:减少"读一次不够"造成的浪费(局部性原理)
  • 比 1MB 小:不会因为一个 1 字节的文件就占满 1MB 空间
  • 4KB 正好是 x86 CPU 的一页大小,OS 在虚拟内存和磁盘之间交换数据时最方便

有了块,LBA 和块号就有了简单对应:

复制代码
块号 = LBA ÷ 8
LBA  = 块号 × 8 + n        (n 是块内的扇区偏移,0~7)

3.2 引入"分区(Partition)":把一块大硬盘切成几段

一块 1TB 硬盘如果只装一个"大文件系统",管理起来太累。实际使用中我们会分区------比如 C/D/E 盘(Windows 视角)。

分区(Partition):对硬盘的一种"格式化",本质是给一段连续的柱面设置一个起点柱面号 + 结束柱面号。

Linux 里"一切皆文件",所以硬盘、分区都被映射成 /dev/ 下的设备文件:

设备文件 类型 含义
/dev/sda 整块硬盘 第一块 SCSI/SATA 硬盘
/dev/sda1 分区 1 /dev/sda 的第 1 个分区
/dev/sda2 分区 2 /dev/sda 的第 2 个分区
/dev/nvme0n1 NVMe 整盘 第一块 NVMe 固态
/dev/nvme0n1p1 NVMe 分区 NVMe 盘的第 1 个分区

💡 为什么要分区?

  1. 不同文件系统可以混用(系统盘用 ext4,备份盘用 xfs);
  2. 隔离数据(一个分区崩了不影响其他);
  3. 支持不同挂载点 (/ 一个分区,/home 另一个)。

3.3 引入"inode":文件 = 内容 + 属性

光有"块"还不够------OS 还要知道哪些块属于哪个文件 、文件多大、谁创建、什么时候改的。

我们敲 ls -l,看到的是这样的 7 列信息:

复制代码
-rwxr-xr-x. 1 root root 7438 Sep 13 14:56 a.out
-rw-r--r--. 1 root root  654 Sep 13 14:56 test.c
列 含义
模式 -rw-r--r-- 表示权限位
硬链接数(引用计数) 1 表示只有 1 个文件名指向它
文件所有者 root
组 root
大小 654 字节
最后修改时间 Sep 13 14:56
文件名 test.c

这些"除了文件名以外的信息",就是文件的元数据(metadata) ,也叫属性 。问题是:这些属性存在哪里?

答案:存在一个叫 inode 的结构里!

inode(index node,索引节点) 是 Linux 文件系统的灵魂。它的几个核心要点:

特性 说明
一个文件一个 inode(不考虑硬链接) inode 是文件的"身份证"
inode 有唯一编号 在同一个分区内唯一(不同分区可以编号相同)
inode 大小固定 一般 128 字节或 256 字节(ext 系列默认 256 字节)
文件名不在 inode 里 文件名存在它所在目录的数据块中(这个点很关键,后面会细讲)

我们用 stat 命令可以看一个文件的 inode 详细信息:

bash 复制代码
[user@localhost ~]$ 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

📌 关键要点: Linux 下文件存储是属性(inode)和内容(数据块)分离存储 的。一个 inode 里有一个 i_block[15] 数组,专门用来"指向文件内容所在的 15 个块"------这个数组就是后面要重点讲的多级指针映射。

到这里,文件系统需要管理的事情就很清楚了:

  1. 块怎么排布(一个分区里那么多块,怎么知道哪些是空的、哪些被占了?)
  2. inode 怎么放(一个分区里那么多 inode,怎么知道哪些是空的、哪些被占了?)

文件系统就是来解决这些问题的!


四、🗂️ ext2 文件系统宏观认识:把分区切成 N 个"小区"

4.1 把分区切成块组(Block Group)

核心思想: ext2 把一个分区切成若干个同样大小的块组(Block Group) ,每个块组内部结构都一样。只要能管理好一个块组,就能管理好整个分区------这就是"分而治之"。

一个 ext2 分区的整体布局大致长这样(简化版):

复制代码
┌──────────┬──────────┬──────────┬──────────┬──────────┬─ ─ ─ ─ ─
│ 启动块    │ Block    │ Block    │ Block    │ Block    │
│ Boot     │ Group 0  │ Group 1  │ Group 2  │ Group 3  │   ...
│ Block    │          │          │          │          │
│ (1KB)    │          │          │          │          │
└──────────┴──────────┴──────────┴──────────┴──────────┴─ ─ ─ ─ ─
   ↑固定 1KB
   由 PC 标准规定
   存磁盘分区表
   和启动信息
   任何文件系统
   都不能动它

启动块是固定 1KB,任何文件系统都不能动它(因为它存的是磁盘分区表 和启动信息)。从 1KB 之后开始,才是 ext2 的"地盘"。

4.2 块组(Block Group)的内部结构

每个块组内部都有6 个固定区域,按顺序排列:

复制代码
┌────────────────────────────────────────────────────────┐
│  Block Group N                                          │
│                                                          │
│  ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐       │
│  │ 超级块   │ │ GDT     │ │ Block   │ │ Inode   │  Inode │ Data
│  │Super    │ │ 块组描述│ │ Bitmap   │ │ Bitmap  │  Table │ Blocks
│  │Block    │ │ 符表    │ │ 块位图   │  │ 位图     │  i节点 │ 数据块
│  │(1块)    │  │(若干块) │ │(1块)    │  │(1块)    │  表    │ 一堆块
│  └─────────┘ └─────────┘ └─────────┘ └─────────┘       │
│                                                          │
└────────────────────────────────────────────────────────┘
区域 作用 大小
超级块(Super Block) 整个分区的"户籍本":block/inode 总数、剩余数、块大小、上次挂载时间...... 1 块
GDT(Group Descriptor Table) 块组描述符表,每个块组对应一个描述符,描述"本组的 inode 表在哪、空闲块数等" 若干块
块位图(Block Bitmap) 一组 bit,每个 bit 对应本组一个数据块,1=已用 0=空闲 1 块
inode 位图(Inode Bitmap) 一组 bit,每个 bit 对应本组一个 inode,1=已用 0=空闲 1 块
inode 表(Inode Table) 一组 inode,存放本组所有文件的属性 若干块
数据块(Data Block) 真正存文件内容的"仓库" 剩余所有块

📌 关键问题:为什么"知道 inode 就能找到文件"?

答:inode 编号是按分区统一编号的(比如编号 263466),这个编号的"组内偏移"和"组号"可以算出来:

复制代码
块组号 = (inode 号 - 1) ÷ 每组 inode 数
组内偏移 = (inode 号 - 1) % 每组 inode 数

再通过 GDT 找到该块组的 inode 表起始块号 → 加上偏移 → 就定位到了磁盘上具体的 inode 结构 。拿到 inode,里面有 i_block[15] 数组,就能找到文件内容了。

所以整个流程是:inode 号 → 算块组号 → GDT 查 inode 表位置 → 算组内偏移 → 拿到 inode → i_block\[\] 找数据块 。根本不用遍历,O(1) 定位!

4.3 超级块(Super Block)详解

超级块 = 整个文件系统的"户口本"。它被破坏 = 整个文件系统结构被破坏。所以 ext2 在多个块组都备份了超级块(第一个块组必须有,后面的可选)。

我们来看一下 ext2 超级块的源码结构(include/linux/ext2_fs.h):

c 复制代码
struct ext2_super_block {
    __le32  s_inodes_count;       // inode 总数
    __le32  s_blocks_count;       // 块总数
    __le32  s_r_blocks_count;     // 保留块数
    __le32  s_free_blocks_count;  // 空闲块数
    __le32  s_free_inodes_count;  // 空闲 inode 数
    __le32  s_first_data_block;   // 第一个数据块号
    __le32  s_log_block_size;     // 块大小(1024 << 这个值)
    __le32  s_log_frag_size;      // 片大小
    __le32  s_blocks_per_group;   // 每组块数
    __le32  s_frags_per_group;    // 每组片数
    __le32  s_inodes_per_group;   // 每组 inode 数
    __le32  s_mtime;              // 上次挂载时间
    __le32  s_wtime;              // 上次写入时间
    __le16  s_mnt_count;          // 挂载次数
    __le16  s_max_mnt_count;      // 最大挂载次数(达到后强制检查)
    __le16  s_magic;              // 魔数(0xEF53,ext2 标志)
    __le16  s_state;              // 文件系统状态
    __le16  s_errors;             // 出错时的行为
    __le16  s_minor_rev_level;    // 次版本号
    __le32  s_lastcheck;          // 上次检查时间
    __le32  s_checkinterval;      // 检查间隔
    __le32  s_creator_os;         // 创建者 OS
    __le32  s_rev_level;          // 主版本号
    __le16  s_def_resuid;         // 默认保留块 uid
    __le16  s_def_resgid;         // 默认保留块 gid
    __le16  s_inode_size;         // inode 大小
    __le16  s_block_group_nr;     // 本超级块所在块组号
    __le32  s_feature_compat;     // 兼容特性
    __le32  s_feature_incompat;   // 不兼容特性
    __le32  s_feature_ro_compat;  // 只读兼容特性
    __u8    s_uuid[16];           // 128 位 uuid
    char    s_volume_name[16];    // 卷名
    char    s_last_mounted[64];   // 上次挂载点
    // ... 还有 190 个保留字
    __u32   s_reserved[190];
};

4.4 块组描述符表(GDT)

GDT:块组描述符表。整个分区有多少个块组,GDT 就有多少个描述符,每个描述符对应一个块组。

c 复制代码
struct ext2_group_desc {
    __le32  bg_block_bitmap;       // 本组 Block Bitmap 所在块号
    __le32  bg_inode_bitmap;       // 本组 Inode Bitmap 所在块号
    __le32  bg_inode_table;        // 本组 Inode Table 起始块号
    __le16  bg_free_blocks_count;  // 本组空闲块数
    __le16  bg_free_inodes_count;  // 本组空闲 inode 数
    __le16  bg_used_dirs_count;    // 本组目录数
    __le16  bg_pad;                // 对齐填充
    __le32  bg_reserved[3];
};

4.5 位图(Bitmap):"占座"管理

块位图:一组 bit,每个 bit 对应本组的一个数据块,1 = 已占用,0 = 空闲。

比如本组有 100 个数据块,块位图就是一个 100 位的"开关矩阵":

复制代码
块位图:1 1 0 0 1 0 0 0 1 1 0 ...
        ↓ ↓        ↓         ↓
      已用 已用    已用     已用
       ↓   ↓       ↓        ↓
       0   1   2   3   4   5  6  7  8  9 ...
      已用 空闲 空闲 已用 空闲 ...

inode 位图:同理,每个 bit 对应本组一个 inode。

为什么要用位图?查找空闲块 / 空闲 inode 时只需要扫一遍位图(O(n) bit),比遍历所有块 / inode 快得多。

4.6 inode 表(Inode Table)

inode 表:本组所有 inode 的"集合",是一个连续数组,每个元素就是一个 inode(默认 128 字节,ext4 默认 256 字节)。

struct ext2_inode,就是这玩意儿的源码定义:

c 复制代码
struct ext2_inode {
    __le16  i_mode;          // 文件模式(权限 + 类型)
    __le16  i_uid;           // 所有者 uid(低 16 位)
    __le32  i_size;          // 文件大小(字节)
    __le32  i_atime;         // 最后访问时间
    __le32  i_ctime;         // 创建时间
    __le32  i_mtime;         // 最后修改时间(内容)
    __le32  i_dtime;         // 删除时间
    __le16  i_gid;           // 所属组 gid(低 16 位)
    __le16  i_links_count;   // 硬链接数
    __le32  i_blocks;        // 占用的块数(512 字节为单位)
    __le32  i_flags;         // 文件标志
    union { ... } osd1;      // OS 相关字段
    __le32  i_block[EXT2_N_BLOCKS];  // ⭐ 15 个块指针!
    __le32  i_generation;    // 文件版本号(NFS 用)
    __le32  i_file_acl;      // 文件 ACL
    __le32  i_dir_acl;       // 目录 ACL
    __le32  i_faddr;         // 片地址
    union { ... } osd2;      // OS 相关字段
};

#define EXT2_NDIR_BLOCKS  12   // 12 个直接块指针
#define EXT2_IND_BLOCK    EXT2_NDIR_BLOCKS  // 13:1 级间接块
#define EXT2_DIND_BLOCK   (EXT2_IND_BLOCK + 1)  // 14:2 级间接块
#define EXT2_TIND_BLOCK   (EXT2_DIND_BLOCK + 1) // 15:3 级间接块
#define EXT2_N_BLOCKS     (EXT2_TIND_BLOCK + 1) // 15

📌 注意: 文件名不在 inode 里 !文件名存在它所在目录的数据块 中("目录项")。这就是为什么 mv 一个文件很快------只改目录项,不动 inode。

4.7 数据块(Data Block)

数据块:文件的"内容仓库"。

根据文件类型不同,数据块里存的东西不同:

文件类型 数据块里存什么
普通文件 文件的实际数据(cat 出来的那些内容)
目录文件 一组"目录项"------文件名 + inode 号的映射
软链接(小) 链接目标的路径字符串(直接存在 inode 里)
块设备/字符设备 不占数据块,内容由设备驱动提供

💡 隐藏的小知识: 小软链接的目标路径直接存在 inode 内的 i_block 里(4 个指针 = 60 字节够存短路径),所以访问小软链接根本不读磁盘!只有路径长超过 60 字节,才会用数据块存。


五、🧭 inode 与数据块的多级指针映射:12 + 1 + 1 + 1 = 4GB+

5.1 inode 里的 i_block[15] 是什么?

上一节我们看到,inode 内部有一个长度为 15 的 __le32 i_block[15] 数组,每个元素都是一个块号 。这 15 个块号不是"平铺直叙"地指向 15 个数据块,而是分成了4 类:

编号 名称 数量 指向
0~11 直接块指针 12 12 个数据块(每块 4KB)
12 一级间接块指针 1 指向一个"间接块",该间接块内是若干数据块号
13 二级间接块指针 1 指向"二级间接块",里面是若干"一级间接块号"
14 三级间接块指针 1 指向"三级间接块",里面是若干"二级间接块号"

5.2 一图看懂多级指针

复制代码
inode.i_block[0]  ──────────────►  Data Block #100   ┐
inode.i_block[1]  ──────────────►  Data Block #200   │ 12 个直接块
        ...                                         │ = 12 × 4KB
inode.i_block[11] ──────────────►  Data Block #800   ┘  = 48KB

inode.i_block[12] ──────► [ Indirection Block ] ──► [ptr → Data Block]
                              [ptr → Data Block]   一级间接块
                              [ptr → Data Block]   块号是 __le32 = 4B
                              [ ... ]               每块 4KB / 4B = 1024 个指针
                                                     即 1024 × 4KB = 4MB

inode.i_block[13] ──────► [ 2-level Indirection ] ──► [Indirection Block] ──► [Data Block]
                              2 级间接块            1 级间接块
                              每块 1024 个指针       每块 1024 个数据块
                              = 1024 × 1024 数据块   = 1024 × 1024 × 4KB
                              = 4GB

inode.i_block[14] ──────► [ 3-level Indirection ] ──► ... ──► ... ──► [Data Block]
                              3 级间接块
                              最多 1024³ = 1G 个数据块
                              = 1024³ × 4KB = 4TB

5.3 一个 inode 最多能管多大的文件?

按 1 块 = 4KB 算(指针 4 字节):

段 块数计算 容量
12 个直接块 12 × 1 48 KB
1 级间接 (4KB ÷ 4B) × 1 = 1024 × 1 4 MB
2 级间接 1024 × 1024 4 GB
3 级间接 1024 × 1024 × 1024 4 TB
总计 ≈ 1024³ ≈ 4 TB

📌 工程意义: 用 15 个 4 字节的指针(60 字节),就能管理最高 4TB 的单个文件。这就是"以小博大"的经典设计。ext4 引入了 extent 范围描述符进一步压缩,但 ext2 时代这套"多级指针"已经够用很久了。

5.4 创建新文件的 4 步操作

当我们敲 touch abc 时,文件系统在背后做了 4 件事:

bash 复制代码
[user@localhost linux]$ touch abc
[user@localhost linux]$ ls -i abc
263466 abc
步骤 动作 涉及的数据结构
1️⃣ 存储属性 在 inode 位图找一个空闲 bit,假设是 inode 263466;把文件属性写进对应的 inode Inode Bitmap + Inode Table
2️⃣ 存储数据 在块位图找 3 个空闲块(假设 300/500/800),把数据依次写入 Block Bitmap + Data Block
3️⃣ 记录分配情况 在 inode 的 i_block[15] 里记下 300, 500, 800 这 3 个块号 Inode Table
4️⃣ 添加文件名到目录 在当前目录的数据块里增加一条 "abc → 263466" 的目录项 父目录的 Data Block

至此,文件 abc 就"在磁盘上存在"了。我们 ls -i 看到的 inode 号就是 263466。

📌 关键结论: 对文件的增、删、查、改,本质都是在操作 inode、bitmap、数据块这三种结构。理解了这个,你就理解了"文件系统"90% 的内容。


六、📂 目录究竟是什么?磁盘里真的没有"目录"!

6.1 一个反直觉的事实

Linux 磁盘上根本没有"目录"这个概念!

磁盘上只有两种东西:文件属性(inode) 和 文件内容(数据块)。你以为的"目录",其实是一种特殊"文件"。

我们用 readdir 系统调用遍历一个目录,亲自验证一下:

c 复制代码
// readdir.c ------ 遍历目录,把 "文件名" 和 "inode 号" 打印出来
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <dirent.h>
#include <sys/types.h>
#include <unistd.h>

int main(int argc, char *argv[]) {
    if (argc != 2) {
        fprintf(stderr, "Usage: %s <directory>\n", argv[0]);
        exit(EXIT_FAILURE);
    }

    DIR *dir = opendir(argv[1]);      // 打开目录(也是文件!)
    if (!dir) {
        perror("opendir");
        exit(EXIT_FAILURE);
    }

    struct dirent *entry;
    while ((entry = readdir(dir)) != NULL) {
        // 跳过 . 和 ..
        if (strcmp(entry->d_name, ".") == 0 ||
            strcmp(entry->d_name, "..") == 0)
            continue;

        printf("Filename: %s, Inode: %lu\n",
               entry->d_name, (unsigned long)entry->d_ino);
    }

    closedir(dir);
    return 0;
}
bash 复制代码
[user@localhost test]$ ./readdir /
Filename: mnt,       Inode: 1048577
Filename: tmp,       Inode: 1179650
Filename: sys,       Inode: 917506
Filename: srv,       Inode: 786434
Filename: dev,       Inode: 131073
Filename: etc,       Inode: 262145
Filename: home,      Inode: 786433
Filename: usr,       Inode: 1179651
Filename: root,      Inode: 655362
# ... 等等

看到没?每个文件条目,本质就是 (文件名, inode 号) 这个二元组!目录的"内容"就是一张这样的映射表。

6.2 内存里的"目录树" vs 磁盘里的"目录"

维度 磁盘上的目录 内存里的目录树
本质 一个文件 一组由 OS 维护的内核结构体
存储 持久化(关机还在) 临时(关机就没了)
内容 目录项列表(文件名→inode) 一棵由 struct dentry 组成的树
谁维护 由 mkfs 创建,由文件系统管理 由内核 VFS 层维护

💡 划重点:

  • 磁盘上的目录 = 文件,存的是映射表,没有"树"的概念
  • 内存里的目录树 = dentry 树,是 OS 为了"快速定位路径"在内存里搭的

6.3 一个文件被访问的全过程

假设我们要 cat /home/user/test.c,背后发生了什么?

复制代码
路径: /home/user/test.c
   ↓
1. 内核从 / 目录的 inode 开始(/ 的 inode 启动时就固定)
   ↓
2. 打开 / 的数据块,找到 "home" 目录项,得到 home 的 inode 号
   ↓
3. 算 home 的块组号 → 拿到 home 的 inode → 找到 home 的数据块
   ↓
4. 在 home 的数据块里找 "user" 目录项,得到 user 的 inode 号
   ↓
5. 算 user 的块组号 → 拿到 user 的 inode → 找到 user 的数据块
   ↓
6. 在 user 的数据块里找 "test.c" 目录项,得到 test.c 的 inode 号
   ↓
7. 算 test.c 的块组号 → 拿到 test.c 的 inode → i_block[] 找到数据块
   ↓
8. 把数据块里的内容读出来 → 显示在屏幕上

📌 关键结论:访问任何文件,都必须先打开"当前目录文件"!

这就是为什么 cd 进不去 /mnt 你啥也干不了------因为你没法打开这个目录,自然没法定位里面的文件。

6.4 一个递归式的问题

那"当前目录"本身也是一个文件啊,要访问它也得知道它的 inode;要知道它的 inode,又得打开它的"父目录"......

答案:从根目录 / 开始,递归打开每一级!

复制代码
/  ← 系统启动时就固定(root inode,编号固定)
├── home
│   ├── user
│   │   ├── test.c
│   │   └── note.md
│   └── admin
└── var
    └── log
        └── syslog

访问 /home/user/test.c,内核就会沿着 / → home → user → test.c 一路打开每一级目录文件,最终拿到 test.c 的 inode。这个过程叫:

路径解析(Path Resolution):从根目录出发,逐级打开目录文件,根据"目录名"找到下一级 inode,直到定位到目标文件。
💡 小贴士: 进程有"当前工作目录"(CWD),是为了避免每次都从 / 开始解析。比如你在 /home/user 下敲 cat test.c,就只需要从 user 目录开始解析,少走 3 级。


七、⚡ 路径缓存(dentry cache):让"打开过的路径"飞起来

7.1 每次都从根解析?太慢了!

问题:磁盘中其实根本没有"目录"的概念,所有目录都是文件。每次访问都从根解析 → 性能太差!

想象一下:每次 ls 都要从 / 走一遍,那 make 编译一个项目(涉及几千个文件)不得慢到爆炸?

答案:Linux 内核自己维护了一棵"路径缓存树",这棵树由 struct dentry 组成。

7.2 struct dentry 源码剖析

c 复制代码
struct dentry {
    atomic_t          d_count;          // 引用计数
    unsigned int      d_flags;
    spinlock_t        d_lock;           // 锁
    struct inode     *d_inode;          // ⭐ 关联的 inode(NULL = 负向缓存)
    struct hlist_node d_hash;           // 哈希链表节点(用于快速查找)
    struct dentry    *d_parent;         // ⭐ 父目录的 dentry
    struct qstr       d_name;           // ⭐ 目录项名字
    struct list_head  d_lru;            // ⭐ LRU 链表(淘汰用)
    struct list_head  d_subdirs;        // 子目录链表
    struct list_head  d_alias;          // inode 别名链表
    unsigned long     d_time;
    struct dentry_operations *d_op;
    struct super_block *d_sb;           // 所属超级块
    void             *d_fsdata;
    int               d_mounted;
    unsigned char     d_iname[DNAME_INLINE_LEN_MIN];
};

关键字段:

字段 作用
d_inode 反向指针:指向本 dentry 对应的 inode(拿到 inode 就能找到文件内容)
d_parent 父指针:形成树形结构
d_name 名字
d_subdirs 子目录链表
d_hash 哈希节点(O(1) 查找)
d_lru 链表节点(LRU 淘汰)

7.3 dentry 树的工作机制

所有被打开过 的文件(不仅是目录,普通文件也算)都会在内存中形成一个 dentry 节点。这棵 dentry 树有几个关键特性:

  1. 树形结构 :d_parent 把所有 dentry 串成一棵多叉树(根节点是根目录的 dentry)
  2. Hash 加速 :每个 dentry 都在一个全局哈希表里,O(1) 时间按"父目录+名字"找
  3. LRU 淘汰:内存有限,不可能把所有路径都缓存,所以用 LRU(最近最少使用)算法淘汰冷门 dentry
  4. 负向缓存 :如果查找一个不存在的文件 (比如 cat /nope),内核也会记住"没有",避免反复查磁盘

📌 工作流程:

  1. 用户输入路径 /home/user/test.c
  2. 内核从 dcache 哈希表里查 /home/user/test.c
  3. 命中:直接返回 inode,秒开!
  4. 没命中:从根开始解析,每解析一级就把对应的 dentry 插入 dcache
  5. 冷门 dentry 通过 LRU 自动回收,腾出内存
    💡 性能小知识: 启动过后的 Linux 系统,常用路径基本都在 dcache 里,访问起来就是"内存速度"(ns 级),所以你感觉 ls 飞快。这就是为什么"重启后第一次访问慢,第二次就快了"。

7.4 总结:磁盘 vs 内存的对应关系

对象 磁盘上的形态 内存里的形态
文件 inode + 数据块 inode 结构体 + 页缓存
目录 一种文件(内容是目录项) dentry 树
路径 一串字符串,藏在各个目录的数据块里 dentry 哈希表 + 树

八、🛠️ 挂载分区:把"裸盘"和"目录树"连起来

8.1 为什么要挂载?

问题: 我们已经能通过 inode 在指定分区找文件了。但 Linux 有多个分区,inode 不能跨分区,那我怎么知道我现在访问的文件在哪个分区?

答案就是挂载(mount) 。挂载的本质是把一个分区的根 inode 接到目录树(dentry 树)的某个节点上。

8.2 一个实验:手工制造一块"假硬盘"

我们用 dd 造一个 5MB 的"假盘",格式化成 ext4,挂载到 /mnt/mydisk:

bash 复制代码
# 第 1 步:制造一个 5MB 的"假盘"
[user@localhost fs]$ dd if=/dev/zero of=./disk.img bs=1M count=5
5+0 records in
5+0 records out
5242880 bytes (5.2 MB) copied, 0.0160072 s, 328 MB/s

# 第 2 步:用 ext4 格式化
[user@localhost fs]$ mkfs.ext4 disk.img
mke2fs 1.42.9 (28-Dec-2013)
disk.img is not a block special device.    # 提示这不是真正的块设备
Proceed anyway? (y,n) y
Discarding device blocks: done
Filesystem label=
OS type: Linux
Block size=1024 (log=0)                   # 块大小 1024 字节
Fragment size=1024 (log=0)
Stride=0 blocks, Stripe width=0 blocks
1280 inodes, 5120 blocks                  # inode 数 1280,块数 5120
256 blocks (5.00%) reserved for the super user
First data block=1
Maximum filesystem blocks=5242880
1 block group                            # 1 个块组
8192 blocks per group
1280 inodes per group                     # 每组 1280 个 inode
Allocating group tables: done
Writing inode tables: done
Creating journal (1024 blocks): done      # ext4 的日志
Writing superblocks and filesystem accounting information: done

# 第 3 步:建立挂载点(必须是个空目录)
[user@localhost fs]$ mkdir /mnt/mydisk

# 第 4 步:查看当前可用的分区
[user@localhost fs]$ df -h
Filesystem      Size  Used Avail Use% Mounted on
devtmpfs        869M     0  869M   0% /dev
/dev/sda1        40G  7.7G   30G  21% /
tmpfs           176M     0  176M   0% /run/user/0
tmpfs           176M     0  176M   0% /run/user/1000
# (看不到我们的 disk.img,因为它还没挂载)

# 第 5 步:挂载!
[user@localhost fs]$ sudo mount -t ext4 ./disk.img /mnt/mydisk/

# 第 6 步:再次 df,发现 disk.img 出现啦
[user@localhost fs]$ df -h
Filesystem      Size  Used Avail Use% Mounted on
/dev/sda1        40G  7.7G   30G  21% /
/dev/loop0      3.9M   53K  3.5M   2% /mnt/mydisk
#                ↑
#        loop 设备:把文件"伪装"成块设备

💡 loop 设备是什么? /dev/loop0 是第一个回环设备(loop device)。它把"普通文件"伪装成"块设备",这样我们就可以给一个文件装文件系统、挂载它。ISO 镜像也是用这个机制挂载的。

8.3 卸载

bash 复制代码
# 卸载分区
[user@localhost fs]$ sudo umount /mnt/mydisk

# 再 df,就看不到 /dev/loop0 了
[user@localhost fs]$ df -h
Filesystem      Size  Used Avail Use% Mounted on
/dev/sda1        40G  7.7G   30G  21% /
# /dev/loop0 消失

8.4 一个结论

分区写入文件系统后,无法直接使用,必须和某个目录"挂载"才能用。

由此推出:

📌 可以根据访问路径的"前缀"准确判断当前在哪个分区。

比如:

  • 访问 /home/user/test.c → 在 /dev/sda1 上
  • 访问 /mnt/mydisk/data.txt → 在 /dev/sdb1 上

这就是"挂载点"这个概念的由来:目录树上的某个节点(dentry)"嫁接"了某个分区的根 inode。


九、🪢 软链接与硬链接:同一个文件的两种"分身"

9.1 硬链接(Hard Link):多个文件名指向同一个 inode

硬链接的本质: 多个不同的文件名 ,在目录项中指向同一个 inode。

我们看个例子:

bash 复制代码
[user@localhost linux]$ touch abc
[user@localhost linux]$ ln abc def              # 创建硬链接 def
[user@localhost linux]$ ls -li abc def
263466 abc
263466 def                                    # inode 号完全相同!

观察 ls -li 输出:

文件名 inode 号 链接数
abc 263466 2
def 263466 2

abc 和 def 的 inode 号一模一样,链接数都是 2------它们是同一个文件的两个"名字"。

📌 删除的本质:

rm 一个文件时,文件系统干了两件事:

  1. 从目录里删除该目录项(不再让这个文件名指向 inode)
  2. inode 的 i_links_count 减 1(如果减到 0,就真正释放磁盘块)

也就是说:只有当所有硬链接都被删除(链接数 = 0)时,文件数据才真正消失。

9.2 软链接(Symbolic Link,符号链接):独立的"快捷方式"

软链接的本质: 一个独立的文件 ,内容是目标文件的路径字符串("快捷方式")。

bash 复制代码
[user@localhost linux]$ ln -s abc abc.s
[user@localhost linux]$ ls -li
263563 -rw-r--r--. 2 root root 0 9月 15 17:45 abc
261678 lrwxrwxrwx. 1 root root 3 9月 15 17:53 abc.s -> abc
263563 -rw-r--r--. 2 root root 0 9月 15 17:45 def

观察对比:

文件名 inode 号 类型 链接数
abc 263563 普通文件 - 2
abc.s 261678 软链接 l 1
def 263563 普通文件 - 2

注意 abc.s 的 inode 是 261678,跟 abc 不一样! 而且文件类型列是 l(link),链接数是 1。

💡 小知识: 软链接文件的内容是"目标路径"(这里就是字符串 "abc",3 字节)。小软链接的目标路径直接存在 inode 内(前面说过 60 字节够用),所以访问很快;路径太长就会用数据块存。

9.3 软硬链接对比

维度 硬链接 软链接
inode 和目标共享同一个 inode 有自己的独立 inode
跨分区 ❌ 不能(inode 不跨分区) ✅ 可以(路径字符串是文本)
链接目录 ❌ 默认不允许(避免循环) ✅ 可以(甚至可以链接到不存在的目标)
删除源文件 不影响(其他硬链接还在) 软链接变**"死链"**(指向空)
本质 多个名字 → 同一个 inode 一个新文件 → 内容是路径字符串
创建命令 ln 源 目标 ln -s 源 目标
ls -l 表现 普通文件 - 软链接 l + -> 目标

9.4 软硬链接的典型用途

📌 硬链接的两个经典用途:

用途 1:. 和 .. 都是硬链接

bash 复制代码
[user@localhost test]$ ls -la
drwxr-xr-x . 2 user user 4096 .
drwxr-xr-x .. 5 user user 4096 ..

每个目录里都有 .(指向自己)和 ..(指向父目录),它们就是硬链接。这种"目录项"的设计天然构成了树形结构。

为什么. 和..不用考虑循环?因为 系统会自动排查 他们!

💡 妙处: .. 的 inode 指向父目录的 inode,所以我们才能从任意子目录"往上爬"回根目录。这比单独维护一个"父子关系表"省事多了。

用途 2:备份文件

bash 复制代码
# 不复制内容,只创建一个"别名"指向源文件
[user@localhost data]$ cp -l important.txt important.bak
# 之后改 important.txt,important.bak 也会跟着变(共享 inode)

📌 软链接的经典用途: 相当于 Windows 的"快捷方式":

  • 给深路径文件一个"短名字"(/usr/local/bin/python3.11 → ~/py)
  • 跨文件系统引用(链接到 U 盘里的文件)
  • 版本切换(多个 Python 版本共存,通过 python 软链接切换)

9.5 文件的三个时间戳

我们 stat 一个文件,会看到 3 个时间:

时间 字段 何时更新
Access atime 最后访问时间(读文件就更新)
Modify mtime 最后修改时间(写内容就更新)
Change ctime 属性最后修改时间(改权限、所有者等就更新)

💡 性能小坑: 每次读文件都更新 atime,写一次磁盘会慢很多。现代 Linux 提供了 relatime/noatime 挂载选项来减少 atime 更新频率。


十、🗑️ 深入理解:文件删除与 inode 回收的真实流程

上一节提到"rm 干了两件事",但真实场景下"删除"涉及的内核操作远不止两步。下面我们把删除一个文件的全过程彻底掰开。

在 Linux 里,删除文件用的系统调用是 unlink(2),不是 delete------这名字就暗示了"取消一个硬链接"的本质。

c 复制代码
#include <unistd.h>
int unlink(const char *pathname);

rm 命令内部就是调 unlink。

10.2 完整的删除流程

当你在 shell 里敲 rm test.c 时,内核按顺序做了这些事:

复制代码
用户: $ rm test.c
        ↓
shell 调 unlink("test.c")
        ↓
┌──────────────────────────────────────────────┐
│ 阶段 1:路径解析                              │
│  从 CWD 出发解析 test.c → 得到 inode 号       │
│  (走 dcache,没命中才读磁盘)                │
└──────────────────────────────────────────────┘
        ↓
┌──────────────────────────────────────────────┐
│ 阶段 2:权限检查                              │
│  当前用户对 test.c 所在目录是否有 w 权限?     │
│  (删除文件是改"目录"的内容,不动文件本身)   │
└──────────────────────────────────────────────┘
        ↓
┌──────────────────────────────────────────────┐
│ 阶段 3:从目录中移除目录项                     │
│  • 在父目录的数据块里找到 "test.c → inode"    │
│  • 删除这条记录(合并相邻空闲空间)            │
│  • 父目录的 mtime/ctime 更新                  │
└──────────────────────────────────────────────┘
        ↓
┌──────────────────────────────────────────────┐
│ 阶段 4:inode 引用计数减 1                    │
│  • inode.i_links_count--                      │
│  • 如果减后 > 0 → 还有其他硬链接,保留文件!  │
│  • 如果减后 = 0 → 走阶段 5,真正释放          │
└──────────────────────────────────────────────┘
        ↓ (i_links_count 归零)
┌──────────────────────────────────────────────┐
│ 阶段 5:释放 inode                            │
│  • inode.dtime = 当前时间("删除时间")       │
│  • inode.i_blocks 清零                        │
│  • 在 inode 位图里把对应 bit 置 0(标记空闲)  │
└──────────────────────────────────────────────┘
        ↓
┌──────────────────────────────────────────────┐
│ 阶段 6:释放数据块                            │
│  • 遍历 inode.i_block[15] 找到所有数据块号    │
│  • 对每个块号,在块位图里把对应 bit 置 0       │
│  • 把这些块加入"空闲块池"                     │
└──────────────────────────────────────────────┘
        ↓
┌──────────────────────────────────────────────┐
│ 阶段 7:dentry 处理                           │
│  • 如果该文件有打开的 fd,对应 inode 的引用计数+1 │
│  • 只有当引用计数和链接数都为 0 时,           │
│    inode 才会被彻底销毁                       │
└──────────────────────────────────────────────┘

10.3 一个"被删但还活着"的有趣现象

关键:进程打开的文件,不会因为 rm 而真正消失!

bash 复制代码
# 终端 1
[user@localhost ~]$ tail -f /var/log/big.log
# 进程正在以"读"方式打开 big.log

# 终端 2
[user@localhost ~]$ rm /var/log/big.log
# 看着像删除了

# 终端 1
# 进程依然能继续读,没报错!
# 因为 inode 还被进程持有(i_count > 0)
# 磁盘空间也没有真正释放

为什么?因为 inode 实际上有两个引用计数:

计数器 字段 谁加 1 谁减 1
硬链接计数 i_links_count 创建硬链接 unlink
内核引用计数 i_count open 一个 fd close 一个 fd

只有当两个计数都为 0,inode 才被彻底销毁、数据块才被真正释放。这就是为什么:

📌 一个被 rm 但还有进程在用的文件,在 df 里依然占着空间(经典的"删了文件磁盘却没释放"问题)。
💡 排查方法: lsof | grep deleted 可以列出所有"被删除但还被进程持有"的文件,定位到具体进程后 kill 掉,空间就释放了。

10.4 目录的删除

删除目录比删除文件复杂,因为要递归:

bash 复制代码
[user@localhost ~]$ rm -rf mydir/

内核会:

  1. 打开 mydir 这个目录文件
  2. 遍历里面的所有目录项
  3. 对每个子项(文件/子目录)递归 unlink
  4. 把自己从父目录里 unlink 掉

📌 危险警告: rm -rf 不走回收站!没有"撤销"机制!文件被 unlink 后,磁盘块被标记为"空闲"------但实际数据可能还在,新数据写入就会覆盖。所以一旦误删,靠 rm -rf 是救不回来的 (理论上可以用 extundelete 之类的工具抢救,但成功率不高)。


十一、📊 文件系统知识图谱:从一句话到全貌

最后用一张表把全文的核心知识点串起来,方便复习:

层级 关键概念 核心要点 关键数据结构
硬件层 盘片/磁道/扇区/磁头/柱面 物理三维结构,CHS 寻址有 8.4GB 上限 无
抽象层 1 LBA 磁盘 = 一维数组,下标 = LBA 无
抽象层 2 块(Block) 8 扇区 = 4KB,OS 读写最小单位 块号
抽象层 3 分区(Partition) 把硬盘切成几段,独立的"文件系统容器" 分区表
抽象层 4 块组(Block Group) 分区再切片,每个块组有 6 个固定区域 SB / GDT / Bitmap / Inode Table / Data Block
文件层 inode + 数据块 一个文件 = 一个 inode + N 个数据块 struct ext2_inode + i_block[15] 多级指针
目录层 目录 = 文件 内容是 (文件名, inode 号) 映射表 目录项
路径层 路径解析 + dcache 根目录开始逐级解析,dentry 树缓存加速 struct dentry
挂载层 mount 把分区的根 inode 接到目录树某个 dentry 上 vfsmount
链接层 硬链接 / 软链接 多对一 / 独立的"快捷方式" 目录项 / 独立 inode

💡 一句话总结: 磁盘是三维的,OS 看成一维的,文件系统切成块的,文件用 inode 索引,目录是映射表,路径是 dentry 树,挂载是 dentry 嫁接,链接是 inode 的别名------这就是 Linux 文件系统的全貌。

⭐ 看图复习


十二、🤔 几个思考题

学完本文,来试试回答这些问题:

1️⃣ 为什么机械硬盘的 CHS 寻址最大只能寻到 8.4GB 左右?怎么算出来的?

答: 因为 BIOS 用 24 bit 存 CHS:8 bit 存磁头(0~255)、10 bit 存柱面(0~1023)、6 bit 存扇区(0~63)。所以最大可寻址:

复制代码
2^8 × 2^10 × 2^6 × 512B = 256 × 1024 × 64 × 512B ≈ 8.4 GB

💡 早期装 Windows XP 经常出现"硬盘超过 8GB 装不上",就是这个限制。解决方法是改用 LBA 寻址。

2️⃣ inode 是分区内唯一的还是磁盘内唯一的?为什么不同分区的 inode 编号可能相同?

答: inode 编号是分区内唯一的,不是磁盘内唯一。原因:

  • 块组结构按分区划分(每个分区有自己的 SB / GDT / inode 表)
  • 块组号 = (inode 号 - 1) ÷ 每组 inode 数,这个公式只在单个分区内有效
  • 跨分区后,每组 inode 数可能不同,"块组号"的算式就不成立了

所以 /dev/sda1 上可能有 inode 100,/dev/sdb1 上也可能有 inode 100,但它们指向完全不同的文件。

💡 思考延伸:如果用 ls -i /mnt/disk1/fileA /mnt/disk2/fileB 看到两个 inode 号相同的文件,能直接 cp 一个到另一个当硬链接吗?答:不能 。硬链接不能跨分区,会报错 Invalid cross-device link。

3️⃣ 一个 inode 最多能管多大的文件?请用 4KB 块大小和 4 字节指针算一下。

答: 见下表:

段 公式 容量
12 个直接块 12 × 4KB 48 KB
1 级间接 (4KB / 4B) × 4KB = 1024 × 4KB 4 MB
2 级间接 1024² × 4KB 4 GB
3 级间接 1024³ × 4KB 4 TB
总容量 约 1024³ × 4KB ≈ 4 TB

💡 实际能管的文件大小略小于 4TB,因为文件系统本身也要占一些块。但这个上限对绝大多数场景够用了。

4️⃣ 既然目录是文件,那目录项里到底存了什么?. 和 .. 是什么?

答: 目录项是 (文件名, inode 号) 的二元组。. 和 .. 是硬链接:

  • .:当前目录的硬链接,指向当前目录自己
  • ..:上一级目录的硬链接,指向父目录

💡 隐藏细节:每个目录至少有 2 个硬链接(自己 + 父目录的 ..),所以新建一个子目录会让父目录的 i_links_count 加 1。这也是为什么 ls -l 看父目录的"链接数"能反映子目录数。

5️⃣ 磁盘上真的没有"目录"吗?那我们看到的目录树是哪来的?

答: 磁盘上只有文件 (属性 + 内容),没有独立的"目录"对象。我们看到的"目录树"是 OS 在内存 里用 struct dentry 维护的。

  • 磁盘上的目录文件 = 内容是 (文件名, inode 号) 映射表的特殊文件
  • 内存里的目录树 = 由 VFS 层用 dentry 节点搭出的多叉树

💡 这两个概念别混了。重启之后,dentry 树就消失了(要重新解析),但磁盘上的目录文件还在。

6️⃣ rm 一个正在被进程打开的文件,到底发生了什么?

答: 分两种情况:

引用计数 结果
i_links_count > 0(还有其他硬链接) 文件还"活"着,磁盘不释放
i_links_count = 0 但 i_count > 0(有进程持有 fd) 磁盘块不释放(df 看还是占着),但目录项没了。文件处于"幽灵状态"
两个计数都为 0 inode 销毁,磁盘块标记为空闲,文件彻底消失

💡 排查"删了文件空间没释放":lsof | grep deleted。

7️⃣ 软链接可以跨文件系统,硬链接为什么不行?

答: 因为硬链接是共享 inode 号:

  • 不同文件系统的 inode 编号体系是独立的
  • 跨分区的"硬链接"实际指向的 inode 在另一个分区里没有意义
  • 系统调用 link() 会返回 EXDEV(跨设备)错误

软链接是存路径字符串的独立文件,不依赖 inode 号,所以可以跨分区、跨文件系统、指向不存在的目标。

💡 这也是为什么 ln -s /mnt/usb/file ~/file_link 能成功,但 ln /mnt/usb/file ~/file_link 会失败。

8️⃣ dentry 缓存和 inode 缓存是一回事吗?

答: 不是。它们是两个独立的缓存:

缓存 缓存内容 加速什么
dentry cache struct dentry(目录项) 路径解析:把路径 → inode 映射
inode cache struct inode(文件元数据) 属性访问:把 inode 号 → 元数据(权限/大小/时间等)
page cache 文件内容页 读写文件:避免每次都读磁盘

💡 三者配合:路径解析命中 dcache → 拿到 inode 后命中 icache → 读文件内容命中 page cache。这就是 Linux "缓存一切"的精髓。


本节完

📝 作者:say-fall | 编辑:say-fall | 🌟 原创不易,如果对你有帮助,记得 👍 点赞 + ⭐ 收藏 哦!

相关推荐
雪落漂泊1 小时前
C++算法 滑动窗口篇(下)
开发语言·c++
wacsii2 小时前
【Linux进程(一)】Linux 进程初探:操作系统如何管理进程?fork() 为什么返回两次?
linux·运维·服务器
共享家95272 小时前
408 操作系统第一章
服务器·数据库·windows
AI+程序员在路上2 小时前
RV1126B 上支持的模型及全流程应用--从CNN及语音到 LLMVLM 与跨平台选型
linux·c语言·人工智能
默|笙2 小时前
【Linux网络】传输层协议TCP(1)---TCP报文介绍
linux·网络·tcp/ip
相思难忘成疾2 小时前
从零搭建 Kubernetes 1.30 集群:kubeadm 部署一主多从实战(RHEL 9.3 + Docker + flannel)
linux·运维·kubernetes
这个DBA有点耶2 小时前
连拿六年第一后宣布出海:国产数据库凭什么敢撕掉“平替”的标签?
数据库·人工智能·架构
PellyKoo2 小时前
【docker】cifs卷改smb密码后容器起不来的解决方法
运维·docker·容器
用户4107192013252 小时前
用 systemd 从零做一个系统健康巡检服务:脚本、沙箱与磁盘满排查实战
linux·ubuntu