Linux --Ext系列文件系统

从硬件开始:认识磁盘

磁盘是计算机中唯一的机械设备

在我们每天都在使用的计算机中,绝大多数组件都是电子设备------CPU、内存、主板芯片组......它们没有活动部件,工作速度极快。但磁盘(尤其是机械硬盘)是个例外,它是计算机中唯一一个含有机械运动部件的核心设备

机械硬盘内部有旋转的盘片、移动的磁头臂,这些物理运动决定了它的速度远低于内存和CPU。但它的优势也很明显:容量大、价格便宜。这也是为什么我们至今仍在使用磁盘来存储海量数据。

磁盘的物理结构

复制代码
主轴(马达)  →  带动盘片高速旋转
磁头臂       →  负责移动磁头
磁头         →  读取/写入数据
永磁铁       →  控制磁头臂的运动
盘片         →  实际存储数据的介质

一个关键细节:所有磁头是"共进退"的------它们通过同一个传动臂同时移动,这意味着所有磁头在同一时刻位于相同半径的磁道位置。

磁盘的存储结构

磁盘的存储结构可以分为几个层次:

  • 盘片(Platter):磁盘中实际存储数据的圆形盘片,通常由铝合金或玻璃制成,表面涂有磁性材料

  • 磁道(Track):盘片上的一个个同心圆

  • 扇区(Sector) :磁道被划分成的一段段圆弧,是磁盘存储数据的基本单位,通常为512字节

  • 柱面(Cylinder):所有盘片上相同半径的磁道构成一个逻辑上的柱面

地址定位:CHS和LBA

如何定位一个扇区?

磁盘要想读取数据,首先要知道数据在哪个位置。早期的磁盘使用CHS地址来定位扇区:

  • C(Cylinder):柱面号

  • H(Head):磁头号(可以理解为盘面号)

  • S(Sector):扇区号

CHS地址就像三维坐标:先找到在第几柱面,再确定用哪个磁头,最后定位到该磁道上的第几个扇区。

从三维到一维:LBA地址

但是,CHS地址用起来太麻烦。于是聪明的工程师们想了一个办法:把磁盘看作一个一维数组

想象一下磁带------它是卷起来的,但我们可以把它"拉直",变成一个线性结构。磁盘虽然是一个三维的物理结构(多个盘片 × 多个磁道 × 多个扇区),但我们可以逻辑上把它想象成一个一维的扇区数组。

这个数组的下标就是LBA(Logical Block Address,逻辑块地址)

关键理解:磁盘从物理上是三维的(盘片 × 磁道 × 扇区),但从逻辑上看,就是一个一维数组,LBA就是数组下标。OS只需要使用LBA地址即可,磁盘内部的固件会自动完成LBA到CHS的转换。

CHS与LBA的转换公式

CHS → LBA:

LBA = C × (HPC × SPT) + H × SPT + (S - 1)

其中:

  • C = 柱面号(Cylinder),从0开始编号

  • H = 磁头号(Head),从0开始编号(可以理解为盘面号)

  • S = 扇区号(Sector),从1开始编号

  • HPC = 每柱面的磁头数(Heads Per Cylinder)

  • SPT = 每磁道的扇区数(Sectors Per Track)

部分 含义
C × (HPC × SPT) 前面所有柱面包含的扇区总数
H × SPT 当前柱面中,前面所有磁道包含的扇区数
(S - 1) 当前磁道中,前面扇区的数量(因为扇区号从1开始)

HPC(每柱面磁头数)和SPT(每磁道扇区数)这些参数磁盘内部自动维护

LBA → CHS:

C = LBA / (HPC × SPT) // 整除
H = (LBA % (HPC × SPT)) / SPT // 整除
S = (LBA % (HPC × SPT)) % SPT + 1

注意:扇区号通常从1开始编号,而LBA从0开始编号,所以公式中要减1或加1。

文件的本质:数据 + 属性

在深入文件系统之前,我们必须先理解一个核心概念:

文件 = 内容 + 属性

  • 内容:文件里实际存储的数据

  • 属性:文件的大小、所有者、创建时间、修改时间、权限等信息

在Linux中,这两者是分开存储的。这也是为什么即使文件内容为空(0字节),它仍然占用磁盘空间------因为它的属性信息需要存储。

块(Block)的引入

为什么需要"块"?

如果操作系统每次读写磁盘都以"扇区"为单位(512字节),效率会非常低。就像我们不会每次只搬一块砖,而是用箱子一次性搬很多块砖一样。

操作系统的解决方案是:一次性读取连续多个扇区 ,这个组合叫做一个块(Block)

块的大小在格式化时确定,通常是4KB(即连续8个扇区),这是文件存取的最小单位。

关系:块号 = LBA / 8(假设块大小为4KB,每个扇区512字节)

块的意义

有了块的概念之后,可以把问题简化:

  • 磁盘是一个扇区的一维数组(LBA地址)

  • 多个连续扇区组成块

  • 文件系统操作的基本单位是块,而不是扇区

这大大提升了磁盘I/O的效率。

分区(Partition)

什么是分区?

分区就是把一块物理磁盘划分成多个逻辑上独立的区域。在Windows中,你看到的C盘、D盘、E盘就是不同的分区。在Linux中,分区表现为设备文件,如/dev/sda1/dev/sda2等。

分区的最小单位

分区的最小单位是柱面 。分区的本质就是:设置每个分区的起始柱面和结束柱面号

知道了起始和结束柱面号,又知道每个柱面有多少个扇区,自然就能算出这个分区有多大,以及它的LBA范围是多少。

inode:文件的"身份证"

什么是inode?

"文件 = 内容 + 属性",属性存储在哪里呢?答案就是inode(索引节点)

每个文件都有一个对应的inode,其中包含了该文件的全部属性信息:

复制代码
struct ext2_inode {
    __le16 i_mode;      // 文件模式(类型和权限)
    __le16 i_uid;       // 所有者ID
    __le32 i_size;      // 文件大小(字节)
    __le32 i_atime;     // 最后访问时间
    __le32 i_ctime;     // 创建时间
    __le32 i_mtime;     // 最后修改时间
    __le16 i_links_count; // 硬链接数
    __le32 i_blocks;    // 占用的块数
    // ... 更多字段
    __le32 i_block[15]; // 指向数据块的指针(重要!)
};

关键点

  • inode的大小通常是128字节或256字节

  • 所有文件的inode大小相同(因为存储的是属性,属性结构是固定的)

  • 文件名不在inode中存储

  • inode编号以分区为单位,跨分区inode号可能重复

通过stat命令查看inode信息

复制代码
$ stat test.c
  File: "test.c"
  Size: 654         Blocks: 8
  Device: 802h/2050d Inode: 263715
  Access: (0644/-rw-r--r--) Uid: (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

ls -li可以同时看到inode号和文件名:

复制代码
$ ls -li
10526669 -rw-rw-r-- 1 whb whb 225 Oct 17 19:09 test.c

ext2文件系统:完整解剖

宏观结构

ext2文件系统将整个分区划分成若干个块组(Block Group),每个块组的大小相同。只要能管理好一个块组,就能管理好整个分区。

复制代码
┌──────────────────────────────────────────────────────────────┐
│ 启动块 │ 块组0 │ 块组1 │ 块组2 │ ... │ 块组N │
└──────────────────────────────────────────────────────────────┘
  • 启动块(Boot Block):大小为1KB,存储分区信息和启动信息,任何文件系统都不能修改它

  • 块组:ext2文件系统真正的管理单元

块组内部结构

每个块组包含以下组成部分:

复制代码
┌─────────────┬─────────────┬─────────────┬─────────────┬─────────────┐
│ 超级块       │ 块组描述符    │ 块位图       │ inode位图   │ inode表      │ 数据块     │
│             │             │             │             │             │           │
└─────────────┴─────────────┴─────────────┴─────────────┴─────────────┘
超级块(Super Block)

超级块存储的是整个文件系统的元信息,包括:

  • block和inode的总量

  • 未使用的block和inode数量

  • block和inode的大小

  • 最近一次挂载时间

  • 最近一次写入时间

  • 最近一次检验磁盘的时间

重要:超级块在每个块组的开头都有一份拷贝(第一个块组必须要有)。这样做是为了容错------如果某个块组的超级块损坏,可以从其他块组恢复。

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

GDT描述了每个块组的属性信息,包括:

  • 本组的数据块位图位置

  • 本组的inode位图位置

  • 本组的inode表位置

  • 本组空闲块数

  • 本组空闲inode数

  • 本组目录数

    struct ext2_group_desc {
    __le32 bg_block_bitmap; // 块位图位置
    __le32 bg_inode_bitmap; // inode位图位置
    __le32 bg_inode_table; // inode表位置
    __le16 bg_free_blocks_count; // 空闲块数
    __le16 bg_free_inodes_count; // 空闲inode数
    __le16 bg_used_dirs_count; // 目录数
    };

块位图(Block Bitmap)

块位图用一个bit来表示一个数据块是否被占用:

  • 1 = 已被占用

  • 0 = 空闲可用

inode位图(Inode Bitmap)

和块位图类似,用bit来表示inode是否空闲可用。

inode表(Inode Table)

存放当前分组中所有文件的inode属性信息。这是一个数组,每个元素就是一个inode结构。

数据块(Data Blocks)

存放文件的实际内容

  • 普通文件:文件数据存储在这里

  • 目录文件:目录下所有文件名和inode号的映射关系存储在这里

inode和数据块的映射

inode结构中有一个关键字段:i_block[15],这是一个包含15个指针的数组,用来指向存储文件内容的数据块。

复制代码
┌──────────────────────────────────────────────────────────────┐
│ inode结构                                                    
│ ┌────────────────────────────────────────────────────────────┐
│ │ 直接块指针 0  → 指向数据块                                  
│ │ 直接块指针 1  → 指向数据块                                  
│ │ ...                                                        
│ │ 直接块指针 11 → 指向数据块   (共12个直接块)                   
│ │ 一级间接指针  → 指向一个索引块,该块再指向数据块                
│ │ 二级间接指针  → 指向一个索引块,...再指向数据块                
│ │ 三级间接指针  → 指向一个索引块,...再指向数据块                
│ └────────────────────────────────────────────────────────────┘
└──────────────────────────────────────────────────────────────┘

这种设计可以支持不同大小的文件:小文件直接用直接块,大文件通过间接块扩展。

创建文件的完整流程

当我们在Linux中创建一个新文件(比如touch abc)时,背后发生了这些事情:

1. 存储属性

内核在inode位图中找到一个空闲的inode(比如编号263466),把文件属性信息写入这个inode。

复制代码
1. 在当前目录所在的分区中,查找空闲的inode
2. 扫描inode位图,找到第一个0 → 假设是263466
3. 将263466置为1(已占用)
4. 在inode表中,初始化inode 263466:
   - 记录文件大小、时间戳、权限等
   - 硬链接数设为1
   - i_block数组清空

2. 存储数据

内核在块位图中找到三个空闲的数据块(比如300、500、800),把文件内容写入这些块。

复制代码
(本例中文件内容为空,跳过此步)
如果文件有内容,则需要:
1. 扫描块位图,找到空闲的数据块(如300, 500, 800)
2. 将数据写入这些块
3. 在inode的i_block中记录:[300, 500, 800]

3. 记录分配情况

内核更新inode中的i_block数组,记录数据块列表:[300, 500, 800]

复制代码
更新块位图:块300, 500, 800 置为1
更新inode位图:inode 263466 置为1

4. 添加文件名到目录

内核在当前目录的数据块中添加一条记录:(inode号=263466, 文件名="abc")

复制代码
在当前目录的数据块中,添加一条记录:
  文件名: "abc" → inode号: 263466

核心理解 :文件名到inode号的映射关系存储在目录文件的数据块中,而不是存储在inode中!

删除文件发生了什么

执行 rm abc

复制代码
1. 在当前目录的数据块中,删除记录 "abc → 263466"
2. inode 263466 的硬链接数减1
3. 如果硬链接数变为0:
   - 释放该inode占用的所有数据块(更新块位图为0)
   - 释放该inode(更新inode位图为0)

目录与文件名的秘密

目录也是文件

在Linux中,目录也是一种文件,它有自己的inode和数据块。

  • 目录的属性和其他文件一样(所有者、权限、大小等)

  • 目录的内容 是一个特殊的结构:文件名 ↔ inode号 的映射表

验证:读取目录内容

我们可以用C程序读取一个目录的内容:

复制代码
#include <stdio.h>
#include <dirent.h>

int main(int argc, char *argv[]) {
    DIR *dir = opendir(argv[1]);
    struct dirent *entry;
    
    while ((entry = readdir(dir)) != NULL) {
        printf("Filename: %s, Inode: %lu\n", 
               entry->d_name, entry->d_ino);
    }
    
    closedir(dir);
    return 0;
}

运行结果会显示每个文件名对应的inode号,和ls -li看到的一致。

关键结论:访问文件时,必须打开当前目录文件,根据文件名找到对应的inode号,然后才能访问文件内容。

路径解析从根目录开始

既然访问文件需要先打开目录文件,而目录文件本身也是文件,也需要先打开它所在的目录......这就形成了一个"先有鸡还是先有蛋"的问题。

解决方法是:从根目录(/)开始

  • 根目录的inode号是固定的(通常为2),系统开机后就知道

  • 访问/home/joy/test.c的流程:

    步骤1:访问根目录 "/"

    根目录inode号固定(通常是2)

    在根目录的数据块中查找 "home" → inode号 786433

    步骤2:访问目录 "/home"

    打开inode 786433

    在home目录的数据块中查找 "joy" → inode号 655362

    步骤3:访问目录 "/home/joy"

    打开inode 655362

    在joy目录的数据块中查找 "test.c" → inode号 263466

    步骤4:访问文件 "/home/joy/test.c"

    打开inode 263466

    读取i_block指针 → 数据块300, 500, 800

    组装成完整文件内容

这个过程叫做Linux路径解析

任何文件都有路径,而路径解析永远从根目录开始。

路径缓存

每次访问文件都要从根目录开始解析,效率太低了。Linux内核维护了一个路径缓存结构 ------struct dentry

复制代码
┌─────────────────────────────────────────────────────────────┐
│ 内存中的目录项缓存(dentry cache)                              │
│                                                             │
│    "/"                                                      │
│     ├── "home"  → inode 786433                              │
│     │    └── "whb" → inode 655362                           │
│     │         └── "code" → inode ...                        │
│     │              └── "test" → inode ...                   │
│     ├── "etc"   → inode 262145                              │
│     ├── "usr"   → inode 1179651                             │
│     └── ...                                                 │
└─────────────────────────────────────────────────────────────┘
  • 所有被打开过的文件路径都会缓存在这棵树中

  • 查找路径时先在这棵树中查找,找到了就直接返回

  • 没找到再从磁盘加载,然后缓存起来

挂载(Mount)

什么是挂载?

inode不能跨分区,但Linux可以有多个分区。那么问题来了:当我们访问一个文件时,怎么知道它在哪个分区?

答案就是挂载。挂载将分区关联到目录树的某个位置。

挂载的本质 :把某个分区的根目录,关联到目录树中某个空目录上。此后访问该目录,实际访问的是该分区的文件系统。

路径 → 分区的判断

访问 /mnt/usb/test.txt 时:

  1. 内核根据路径前缀 /mnt/usb/ 判断 → 属于分区1

  2. 在分区1中执行inode查找

  3. 分区1的根目录inode号是什么?由文件系统决定(ext2/3/4的根目录inode号通常是2)

挂载实验

复制代码
# 创建一个5MB的文件作为虚拟分区
$ dd if=/dev/zero of=./disk.img bs=1M count=5

# 格式化,写入ext4文件系统
$ mkfs.ext4 disk.img

# 创建挂载点(一个空目录)
$ mkdir /mnt/mydisk

# 挂载分区到目录
$ sudo mount -t ext4 ./disk.img /mnt/mydisk/

# 查看挂载情况
$ df -h
Filesystem      Size Used Avail Use% Mounted on
/dev/vda1        50G  20G   28G  42% /
/dev/loop0      4.9M  24K  4.5M   1% /mnt/mydisk

# 卸载分区
$ sudo umount /mnt/mydisk

结论:分区写入文件系统后不能直接使用,必须挂载到某个目录才能访问。访问文件时,根据路径前缀判断它在哪个分区。

关于loop设备

/dev/loop0是一种循环设备(loop device) ,它允许将文件 当作块设备来使用。这种机制让我们可以把一个普通文件(如ISO镜像)当作磁盘分区来挂载。

文件系统的组织结构

复制代码
┌─────────────────────────────────────────────────────────────────────┐
│ 磁盘                                                                 
│  └── 分区 (如 /dev/sda1)                                            
│       └── 文件系统 (格式化后)                                      
│            ├── 块组0                                                
│            │    ├── 超级块 (文件系统元信息)                          
│            │    ├── 块组描述符 (各组属性)                            
│            │    ├── 块位图 (哪些块被占用)                           
│            │    ├── inode位图 (哪些inode被占用)                      
│            │    ├── inode表 (所有文件的属性)                         
│            │    └── 数据块 (所有文件的内容)                          
│            ├── 块组1 (同上)                                         
│            └── ...                                                  
└─────────────────────────────────────────────────────────────────────┘

查找文件的流程:

  1. 根据文件路径,在目录树中找到对应的目录

  2. 在目录的数据块中查找文件名,得到inode号

  3. 根据inode号找到对应的分组和inode表位置

  4. 读取inode,获得文件属性和数据块指针

  5. 通过数据块指针读取文件内容

软链接和硬链接

硬链接(Hard Link)

硬链接的本质 :多个文件名指向同一个inode

复制代码
$ touch abc
$ ln abc def
$ ls -li abc def
263466 abc
263466 def
  • abc和def的inode号相同(都是263466)

  • 硬链接数变为2(i_links_count

  • 删除文件时:删除目录记录,硬链接数减1;当硬链接数变为0时,释放inode和数据块

硬链接的特点

  • 不能跨分区(因为inode号是以分区为单位的)

  • 不能给目录创建硬链接(防止循环引用)

  • ...就是硬链接

软链接的本质:创建一个新文件,它的内容指向另一个文件的路径。

复制代码
$ ln -s abc abc.s
$ ls -li abc abc.s
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
  • abc.s的inode号(261678)和abc的inode号(263563)不同

  • abc.s是一个独立文件,它的数据块存储的是目标文件名"abc"

  • 类似Windows中的"快捷方式"

软链接的特点

  • 可以跨分区

  • 可以链接目录

  • 如果目标文件被删除,软链接会"断裂"(无法访问)

  • 软链接文件有自己的inode,是一个独立的文件

软硬链接对比

特性 硬链接 软链接
inode号 相同 不同
跨分区 不可以 可以
链接目录 不可以 可以
目标删除后 仍然有效(引用计数减1) 断裂失效
本质 同一文件的多个名字 指向目标文件路径的文件

用途

硬链接的用途:

  • 目录中的...

  • 文件备份(节省空间,修改同步)

软链接的用途:

  • 快捷方式

  • 版本管理(如/usr/bin/python -> python3

  • 跨分区访问

文件的三个时间戳

每个文件在inode中记录了三个时间:

时间戳 英文 含义 何时更新
访问时间 Access 文件最后被读取的时间 catvi查看等
修改时间 Modify 文件内容最后被修改的时间 写入数据时
变更时间 Change 文件属性最后被修改的时间 权限、所有者等变化时

touch命令可以更新这些时间戳。

完整知识体系图

复制代码
┌─────────────────────────────────────────────────────────────────────────┐
│                          用户层                                         
│  ┌─────────┐  ┌─────────┐  ┌─────────┐  ┌─────────┐                    
│  │ ls -li  │  │  touch  │  │   rm    │  │   ln    │                    
│  └────┬────┘  └────┬────┘  └────┬────┘  └────┬────┘                   
│       │            │            │            │                        
│       ▼            ▼            ▼            ▼                        
│  ┌──────────────────────────────────────────────────────────────────┐  
│  │                       系统调用层 (VFS)                            
│  │   open(), read(), write(), close(), stat(), link(), unlink()     
│  └──────────────────────────────────────────────────────────────────┘  
│                                    │                                   
│                                    ▼                                   
│  ┌──────────────────────────────────────────────────────────────────┐  
│  │                    路径解析 + 路径缓存 (dentry)                    
│  │  从根目录开始,逐级查找,直到找到目标文件的inode号                   
│  └──────────────────────────────────────────────────────────────────┘  
│                                    │                                   
│                                    ▼                                   
│  ┌──────────────────────────────────────────────────────────────────┐  
│  │                    根据路径前缀判断所在分区                        
│  │   /mnt/usb/ → 分区1    /home/ → 分区0    /data/ → 分区2           
│  └──────────────────────────────────────────────────────────────────┘  
│                                    │                                   
│                                    ▼                                   
│  ┌──────────────────────────────────────────────────────────────────┐  
│  │                    文件系统层 (ext2/3/4)                          
│  │  根据inode号 → 找到对应块组 → 读取inode表 → 获取数据块指针           
│  └──────────────────────────────────────────────────────────────────┘  
│                                    │                                   
│                                    ▼                                   
│  ┌──────────────────────────────────────────────────────────────────┐  
│  │                        块设备层                                   
│  │  通过数据块指针 → 计算LBA地址 → 发送I/O请求                         
│  └──────────────────────────────────────────────────────────────────┘  
│                                    │                                  
│                                    ▼                                   
│  ┌──────────────────────────────────────────────────────────────────┐ 
│  │                        磁盘固件层                                 
│  │  LBA → CHS转换 → 控制磁头移动 → 读取数据                            
│  └──────────────────────────────────────────────────────────────────┘  
└─────────────────────────────────────────────────────────────────────────┘
相关推荐
Yang961117 分钟前
一台顶三台:鼎讯DLJ-1在不同故障类型中的模式切换数据复盘
服务器·网络·数据库
潘正翔23 分钟前
Memcached构建缓存服务器
运维·服务器·数据库·缓存·云原生·memcached
醉颜凉1 小时前
Elasticsearch服务器部署:从零到一完整启动+配置教程
大数据·服务器·elasticsearch
攻城有术1 小时前
专项攻克——研发排查Linux各类问题命令+实战案例
linux·运维·服务器
自动化和Linux2 小时前
【win11+virtual Box简单安装centos7,不使用GPT分区】
linux·gpt·虚拟现实
Acrellea2 小时前
当机柜功率冲向 50kW,配电运维如何跟上算力的进化速度?
大数据·运维
M158227690552 小时前
三格串口服务器:RS232/485 设备联网解决方案
运维·服务器·单片机
云上工程笔记2 小时前
ELI5:如何把复杂概念自动画成图,方便写文档、做分享、带新人
运维·服务器·网络·云计算
饼饼学习空间智能2 小时前
具身智能实训平台有哪些供应商?2026年机器人教学与仿真平台选型指南
运维·人工智能