Linux系统篇(二十)——文件(四):吃透 Linux 磁盘与文件系统:一张原理图讲清块、inode、分区底层逻辑
从扇区封装为块,到分区、块组、超级块、位图、inode 与目录映射,讲清 Linux EXT 文件系统的完整底层逻辑。
项目背景
刚接触 Linux 存储时,很多人都会有一堆疑问:为什么磁盘要先分区、再格式化才能存文件?ls -li 输出的 inode 号到底代表什么?4KB 块、块组、超级块、位图之间是什么从属关系?Windows 靠文件名找文件,Linux 为什么非要单独搞一套 inode 机制?
网上零散教程只讲命令,很少把"磁盘硬件 → 分区 → 文件系统 → 块组 → inode → 数据块"这条完整链路串起来。本文从磁盘最底层的扇区开始,一路拆解到文件属性、文件名存储逻辑,讲清 Linux EXT 系列文件系统的完整底层架构。看懂这篇,磁盘、inode、软硬链接、格式化原理都能一通百通。
技术方案
磁盘最底层:硬件最小单位扇区,OS 统一封装为"块"
- 硬件层面:扇区。物理硬盘出厂的最小读写单元是扇区,传统大小 512 字节,现在新型磁盘为 4K 扇区。操作系统不能直接以扇区为单位管理数据,效率太低;
- OS 抽象:块(Block)。Linux 文件系统格式化时,会把连续多个扇区封装成块,标准默认 4KB,可手动调整,是文件系统读写的最小单位。所有文件、目录、日志最终都按 4KB 块分配存储空间;哪怕文件只有 1 字节,也会占用一整个 4KB 块,这就是磁盘空间会有微小浪费的根本原因。
注意:磁盘是一个三维数组,但我们把它看待成一维数组,数组下标就是 LBA,每个元素都是扇区。每个扇区都有 LBA,那么 8 个扇区组成一个块,每一个块的地址也能算出来:
知道 LBA:块号 = LBA / 8
知道块号:LBA = 块号 × 8 + n(n 是块内第几个扇区)
磁盘分层管理:磁盘 → 分区 → 文件系统三层架构
一块裸盘不能直接存文件,必须经历"分区 → 格式化(创建文件系统)"两步操作:
- Disk(整块磁盘):磁盘头部存放 MBR 主引导记录,同时记录分区表,把整块大磁盘切割成多个独立分区(Partition 1/2/3/4)。分区隔离不同业务数据,损坏互不影响;
- Partition(分区):单个分区内包含引导扇区(Boot Sector)。分区只是空间划分,没有存储文件的规则,裸分区无法存放文件,必须格式化。柱面是分区的最小单位,我们可以利用参考柱面号码的方式来进行分区,本质就是设置每个区的起始柱面和结束柱面号码,此时可以把硬盘上的柱面(分区)平铺,想象成一个大的平面;
- File System(EXT 文件系统):格式化就是给分区写入 EXT2/3/4 文件系统规则,把分区空间切割成多个块组(Block Group),这是 Linux 文件系统的核心管理单元。
EXT2 文件系统核心:块组 Block Group 内部完整结构
每个分区会平均拆分成若干块组,所有块组结构完全一致,均衡分散数据以避免磁盘局部损耗。单个块组内部由 6 部分组成。
先说明位图思想:传统的标记方式用一个字节/布尔值记录一个磁盘块的占用状态,管理大容量磁盘时会产生极大的内存开销。而位图利用一个二进制位对应一个元素的状态,比如管理 1000 万个磁盘块仅需约 1.2MB 的内存空间,相比传统方案空间压缩了近 8 倍,在早期内存资源极其紧张的计算机环境下大幅降低了内存占用成本(本质是为了节省空间和时间)。
- Super Block 超级块:文件系统全局信息仓库,记录总块数、总 inode 数量、块大小、空闲块/空闲 inode 统计、挂载记录。为防止损坏,超级块会在每个块组做冗余备份,防止超级块损坏导致分区无法挂载。
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 << s_log_block_size */
};
- GDT 块组描述符表:记录每个块组的位图、inode 表、数据块起始位置,操作系统通过 GDT 快速定位任意块组的资源。
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; /* 当前块组内已使用的目录类 inode 总数 */
__le16 bg_pad; /* 内存对齐填充字段 */
__le32 bg_reserved[3]; /* 预留扩展字段 */
};
- Data Blocks 数据块:真正存放文件实际内容的区域。普通文件存文本/二进制数据,目录文件存放"文件名 - inode 号"映射表,软链接存放目标文件路径;
- Block Bitmap 块位图:二进制位图,每 1bit 标记一个数据块:0 = 空闲,1 = 已占用。新建文件时系统读取位图分配空闲块,删除文件时对应 bit 置 0 回收空间。为了实现高速的分配和回收,文件系统会把完整的块位图加载到内存中。删除文件时,只需要在内存里把对应数据块的标识位直接置 0,不需要擦除磁盘上的实际数据,操作速度极快——这就是为什么下载应用时相对较慢,而删除应用时却很快的原因;
- inode Table inode 表(Linux 灵魂):存储全部文件的属性集合。单个 inode 固定 128 字节(可配置为 256),存储文件元数据:文件类型、权限、属主、大小、时间戳、数据块指针。关键点:inode 不存储文件名!只存文件内容对应的磁盘块编号。磁盘上每一个独立文件/目录/软链接都独占一个唯一 inode 号,ls -li 命令输出的第一列数字就是 inode 编号:
ls -li
磁盘上存储的 inode(索引节点)核心结构体(EXT2):
struct ext2_inode {
__le16 i_mode; // 文件模式
__le16 i_uid; // 文件所有者的用户ID(低16位)
__le32 i_size; // 文件总字节大小
__le32 i_atime; // 文件访问时间
__le32 i_ctime; // 文件创建/属性修改时间
__le32 i_mtime; // 文件内容修改时间
__le32 i_dtime; // 文件删除时间
__le16 i_gid; // 文件所属用户组ID(低16位)
__le16 i_links_count; // 文件硬链接计数
__le32 i_blocks; // 文件占用的磁盘块总数
__le32 i_flags; // 文件标志位
union { ... } osd1; // 操作系统相关保留字段1
__le32 i_block[EXT2_N_BLOCKS]; /* 指向数据块的指针数组 */
__le32 i_generation; // 文件版本号(用于 NFS)
__le32 i_file_acl; // 文件访问控制列表
__le32 i_dir_acl; // 目录访问控制列表
__le32 i_faddr; // 文件碎片地址
union { ... } osd2; // 操作系统相关保留字段2
} __attribute__((packed));
#define EXT2_NDIR_BLOCKS 12 // 直接索引块数量
#define EXT2_IND_BLOCK 13 // 一级间接索引块
#define EXT2_DIND_BLOCK 14 // 二级间接索引块
#define EXT2_TIND_BLOCK 15 // 三级间接索引块
#define EXT2_N_BLOCKS 16 // 索引块总数量
注意:文件名属性并未纳入 inode 数据结构内部;inode 的大小一般是 128 字节或 256 字节;任何文件的内容大小可以不同,但属性大小一定是相同的。
- inode Bitmap inode 位图:与块位图逻辑一致,每 1bit 标记一个 inode 是否空闲,负责分配回收文件属性节点。
创建一个文件的流程主要有 4 个操作:
- 存储属性:内核先找到一个空闲的 inode(假如是 263466),把文件信息记录到其中;
- 存储数据:该文件需要存储在若干磁盘块,假如内核找到三个空闲块:300、500、800,将内核缓冲区的第一块数据复制到 300,下一块复制到 500,以此类推;
- 记录分配情况:文件内容按顺序 300、500、800 存放,内核在 inode 上的磁盘分布区记录了上述块列表;
- 添加文件名到目录:新的文件名 abc,内核将入口(263466,abc)添加到目录文件。文件名和 inode 之间的对应关系,把文件名和文件的内容及属性连接起来。
跨组但不跨区:文件的 inode 结构体与它实际存储内容的 Data Blocks 是跨组编号的。一个文件的 inode 属性可能在 Block Group 0 中,而它的内容数据块可能分布在 Block Group 1;如果文件比较大,一个 group 里面的 block 放不下,文件内容可以跨组保存。每一个组的大小都是固定的,所以可以直接用 inode 的编号除以组的大小再取模,就可以知道这个 inode 在哪个组的哪个位置。但是,inode 和数据块绝对不能跨分区!在同一个分区内部,inode 编号和块号都是唯一的;不同分区之间的 inode 编号则是各自独立的。
Linux 核心设计:文件名与文件数据彻底分离(inode 机制)
这是与 Windows 文件存储最大的区别,也是很多人搞不懂软硬链接的根源:
- inode 只保管文件本体属性:文件大小、权限、创建时间、磁盘数据块地址全部存在 inode 中,与文件名完全解绑。系统定位文件,本质是先找到 inode,再通过 inode 读取磁盘数据块拿到文件内容;
- 文件名只存放在父目录的数据块里:目录本身也是特殊文件,有自己的 inode 和数据块;目录的数据块内存储一张映射表:文件名 → inode 编号。也就是说,文件名只是 inode 的别名,真正唯一标识文件的是 inode 号。
举例:执行 cat code.c 时的流程:
- 读取当前目录的 inode,找到目录数据块;
- 在目录映射表匹配字符串 code.c,拿到对应 inode 编号;
- 读取该 inode,获取文件内容所在数据块;
- 读取数据块输出文件内容。
所以,访问文件必须打开当前目录,根据文件名获得对应的 inode 号,然后进行文件访问。访问文件必须要知道当前工作目录,本质是必须能打开当前工作目录文件,查看目录文件的内容。而上级目录也有当前目录,一直追溯到根目录,这个过程就叫做路径解析。
路径缓存:文件访问依赖路径解析,绝对路径默认从根目录 / 逐层读取各级目录文件,匹配文件名拿到目标 inode 才能访问文件。全程从根目录逐层解析速度较慢,Linux 通过缓存历史路径结构提升查找效率:操作系统会把历史访问过的文件路径构建成一棵树,以便后面快速查找。内核中维护树状路径结构的结构体叫做 struct dentry。
struct dentry {
atomic_t d_count;
unsigned int d_flags; /* 受 d_lock 保护 */
spinlock_t d_lock;
struct inode *d_inode; /* 名称所属的索引节点,NULL 表示负向(不存在) */
struct hlist_node d_hash; /* 查找哈希链表 */
struct dentry *d_parent; /* 父目录 */
struct qstr d_name;
struct list_head d_lru; /* LRU 链表 */
union {
struct list_head d_child;
struct rcu_head d_rcu;
} d_u;
struct list_head d_subdirs; /* 子目录 */
struct list_head d_alias; /* 索引节点别名链表 */
unsigned long d_time;
struct dentry_operations *d_op;
struct super_block *d_sb; /* dentry 树的根(超级块) */
void *d_fsdata;
int d_mounted;
unsigned char d_iname[DNAME_INLINE_LEN_MIN]; /* 小名称内联存储 */
};
每个文件都有对应的 dentry 结构,包括普通文件,这样所有被打开的文件就可以在内存中形成整棵树的形状。树形节点同时隶属于 LRU(Least Recently Used,最近最少使用)结构进行节点淘汰,也隶属于 Hash 方便快速查找。更重要的是,这棵树整体构成了 Linux 的路径缓存结构:打开访问任何文件,都先在这棵树下根据路径查找,找到就返回属性 inode 和内容;没找到就从磁盘加载路径,添加 dentry 结构,缓存新路径。
inode 和 datablock 映射:inode 内部存在 __le32 i_block[EXT2_N_BLOCKS],EXT2_N_BLOCKS = 15(12 个直接指针 + 一级/二级/三级间接指针各 1 个),就是用来进行 inode 和 block 映射的,这样"文件 = 内容 + 属性"就都能找到了。
- 直接块指针(inode 里 12 个直接指针):指针直接指向普通数据块,数据块里存的就是文件真实内容(文字、图片、程序数据)。假设一块 4KB:12 × 4KB = 49152 字节(48KB)。如果文件超过 48KB,12 个直接指针根本不够存所有数据块的地址,存不下大文件;
- 一级/二级/三级间接块指针:第一步,inode 的间接指针指向索引表块(数据块索引表),这个索引表里只存一堆子指针(块号),完全没有文件内容,只是中转目录;第二步,索引表里的子指针指向普通数据块,走到这一层才是存放文件内容的地方;
- 举二级间接流程:inode 二级指针 → 一级索引表(全是指针,无内容)→ 二级索引表(全是指针,无内容)→ 普通数据块(真正文件内容)。这些一级、二级、三级间接指针本质上就是为了解决文件内容太多的问题。
挂载分区
我们已经能根据 inode 号在指定分区找文件了,也已经能根据目录文件内容找指定的 inode 了。问题来了:inode 不能跨分区,Linux 又可以有多个分区,我怎么知道文件在哪个分区?答案就是挂载(mount)。
# 1. 制作一个大的磁盘文件,当作一个分区
$ dd if=/dev/zero of=./disk.img bs=1M count=5
# 2. 格式化写入文件系统
$ mkfs.ext4 disk.img
# 3. 建立空目录
$ sudo mkdir /mnt/mydisk
# 4. 查看当前可用分区
$ df -h
# 5. 将分区挂载到指定目录
$ sudo mount -t ext4 ./disk.img /mnt/mydisk/
# 6. 再次查看(注意新增了 loop0)
$ df -h
# 7. 卸载分区
$ sudo umount /mnt/mydisk
注意:/dev/loop0 在 Linux 系统中代表第一个循环设备(loop device)。循环设备也被称为回环设备或 loopback 设备,是一种伪设备,允许将文件作为块设备来使用。这种机制使得可以将文件(比如 ISO 镜像文件)挂载(mount)为文件系统,就像它们是物理硬盘分区或外部存储设备一样。
ls /dev/loop* -l
软硬链接底层差异,从 inode 一眼看懂
- 硬链接:多个不同文件名指向同一个 inode 编号;目录映射表里多条记录绑定同一个 inode,inode 内链接计数 +1;删除其中一个文件名仅计数 -1,计数归 0 才释放 inode 与数据块。限制:不能跨分区、不支持对目录创建硬链接;
- 软链接(符号链接):拥有独立的全新 inode,自身是单独文件,数据块仅存储目标文件路径字符串;访问时读取路径再跳转原文件;原文件删除后软链接直接失效,无计数机制,支持跨分区、可以链接目录。
为什么明明没有手动做硬链接,硬链接数还会变化?因为每一个目录创建后都会有当前目录和上级目录,自动进行了硬链接。
格式化、分区实操底层本质(总结梳理)
看完整套架构,就能理解日常磁盘操作底层到底做了什么:
- 分区(fdisk):修改磁盘 MBR 分区表,切割磁盘空间边界,不修改分区内部数据;
- 格式化(mkfs.ext4):给分区写入 EXT 文件系统,划分块组、初始化超级块、位图、空白 inode 表,清空原有数据;
- 挂载(mount):读取分区超级块,加载块组信息,将文件系统关联到系统目录入口;
- rm 删除文件:仅把 inode 位图、块位图对应 bit 置为空闲,不擦除磁盘数据,这就是删除文件后仍能数据恢复的底层原理。
系统架构
Linux 文件系统是一套分层、解耦的精密设计:
- 硬件层:扇区(512B/4K)→ 操作系统封装为块(默认 4KB);
- 空间层:磁盘(MBR 分区表)→ 分区 → 块组(Block Group);
- 管理层:超级块(全局信息)→ GDT(块组描述符)→ 位图(块位图/inode 位图)→ inode 表 → 数据块;
- 逻辑层:目录映射表(文件名 → inode)→ dentry 路径缓存树 → inode 数据块指针(直接/间接索引)。
核心设计思想是解耦:把两件强绑定、改一个就必须动另一个的东西拆分开,让两者互相独立、互不依赖。带来巨大好处:
- 给文件重命名:只改目录里的名字映射,不动 inode、不动文件真实数据,几乎零开销;
- 硬链接:同一个 inode 可以对应 N 个不同文件名,新增名字完全不用复制文件内容;
- 移动同分区文件:只是修改父目录映射,文件本体 inode 和数据块一点不变;
- 删除文件名:只是删掉一条映射,inode 和数据还在,只要还有别的硬链接,文件依然可用。
核心就是:把"文件标识名"和"文件真实本体"解耦,两者独立变化,互不牵制。
实施过程
- 用 dd 制作磁盘镜像文件,mkfs.ext4 格式化,体验"分区 → 格式化"完整流程;
- 用 ls -li 观察文件 inode 号,用 stat 命令查看 inode 中的属性信息;
- 创建硬链接与软链接,对比 ls -li 输出与链接计数的变化,验证 inode 机制;
- 用 df -h 观察挂载点,mount/umount 回环设备,理解挂载的含义;
- 结合超级块、位图概念,理解 rm 删除文件后数据可恢复的原理。
应用价值
- 彻底理解磁盘扩容、分区、格式化、挂载每一步操作的底层含义;
- 掌握 inode 机制后,软硬链接、数据恢复、磁盘 IO 调优都事半功倍;
- 明白"文件名与文件本体解耦"的设计后,能解释重命名、移动为何极快;
- 告别只会敲命令不懂底层原理的阶段,为存储方向深入打下坚实基础。
SEO关键词
Linux文件系统, inode, 块组, 超级块, 位图, EXT4, 软硬链接, 磁盘分区
