从一根磁道到一棵目录树: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 步操作)
- [5.1 inode 里的 `i_block15` 是什么?](#5.1 inode 里的
- [六、📂 目录究竟是什么?磁盘里真的没有"目录"!](#六、📂 目录究竟是什么?磁盘里真的没有"目录"!)
-
- [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 目录的删除)
- [10.1 `unlink` 系统调用](#10.1
- [十一、📊 文件系统知识图谱:从一句话到全貌](#十一、📊 文件系统知识图谱:从一句话到全貌)
- [⭐ 看图复习](#⭐ 看图复习)
-
- [十二、🤔 几个思考题](#十二、🤔 几个思考题)
-
- [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 个分区 |
💡 为什么要分区?
- 不同文件系统可以混用(系统盘用 ext4,备份盘用 xfs);
- 隔离数据(一个分区崩了不影响其他);
- 支持不同挂载点 (
/一个分区,/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 个块"------这个数组就是后面要重点讲的多级指针映射。
到这里,文件系统需要管理的事情就很清楚了:
- 块怎么排布(一个分区里那么多块,怎么知道哪些是空的、哪些被占了?)
- 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 树有几个关键特性:
- 树形结构 :
d_parent把所有 dentry 串成一棵多叉树(根节点是根目录的 dentry) - Hash 加速 :每个 dentry 都在一个全局哈希表里,O(1) 时间按"父目录+名字"找
- LRU 淘汰:内存有限,不可能把所有路径都缓存,所以用 LRU(最近最少使用)算法淘汰冷门 dentry
- 负向缓存 :如果查找一个不存在的文件 (比如
cat /nope),内核也会记住"没有",避免反复查磁盘
📌 工作流程:
- 用户输入路径
/home/user/test.c- 内核从 dcache 哈希表里查
/home/user/test.c- 命中:直接返回 inode,秒开!
- 没命中:从根开始解析,每解析一级就把对应的 dentry 插入 dcache
- 冷门 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一个文件时,文件系统干了两件事:
- 从目录里删除该目录项(不再让这个文件名指向 inode)
- 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 干了两件事",但真实场景下"删除"涉及的内核操作远不止两步。下面我们把删除一个文件的全过程彻底掰开。
10.1 unlink 系统调用
在 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/
内核会:
- 打开
mydir这个目录文件 - 遍历里面的所有目录项
- 对每个子项(文件/子目录)递归
unlink - 把自己从父目录里
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 | 🌟 原创不易,如果对你有帮助,记得 👍 点赞 + ⭐ 收藏 哦!