从硬件开始:认识磁盘
磁盘是计算机中唯一的机械设备
在我们每天都在使用的计算机中,绝大多数组件都是电子设备------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 时:
-
内核根据路径前缀
/mnt/usb/判断 → 属于分区1 -
在分区1中执行inode查找
-
分区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 (同上)
│ └── ...
└─────────────────────────────────────────────────────────────────────┘
查找文件的流程:
-
根据文件路径,在目录树中找到对应的目录
-
在目录的数据块中查找文件名,得到inode号
-
根据inode号找到对应的分组和inode表位置
-
读取inode,获得文件属性和数据块指针
-
通过数据块指针读取文件内容
软链接和硬链接
硬链接(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号是以分区为单位的)
-
不能给目录创建硬链接(防止循环引用)
-
..和.就是硬链接
软链接(Symbolic Link / Soft Link)
软链接的本质:创建一个新文件,它的内容指向另一个文件的路径。
$ 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 | 文件最后被读取的时间 | cat、vi查看等 |
| 修改时间 | 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转换 → 控制磁头移动 → 读取数据
│ └──────────────────────────────────────────────────────────────────┘
└─────────────────────────────────────────────────────────────────────────┘