
前言
刚接触 Linux 存储时,我一直有一堆百思不解的疑问: 为什么磁盘要先分区、再格式化才能存文件?ls -li输出的 inode 号到底代表什么?4KB 块、块组、超级块、位图之间是什么从属关系?Windows 靠文件名找文件,Linux 为什么非要单独搞一套 inode 机制?
网上零散教程只讲命令,很少把磁盘硬件→分区→文件系统→块组→inode→数据块这条完整链路串起来。直到整理了这张完整底层思维导图,从磁盘最底层扇区,一路拆解到文件属性、文件名存储逻辑,才打通 Linux 文件存储的全部脉络。本文结合原理图,从零拆解 Linux EXT 系列文件系统完整底层架构,看懂这篇,磁盘、inode、软硬链接、格式化原理全都一通百通。

一、磁盘最底层:硬件最小单位扇区,OS 统一封装为「块」
1. 硬件层面:扇区
物理硬盘出厂最小读写单元是扇区,传统大小 512 字节,现在新型磁盘 4K 扇区,操作系统不能直接以扇区管理数据,效率太低。
2. OS 抽象:块(Block)
Linux 文件系统格式化时,会把连续多个扇区封装成块,标准默认 4KB,可手动调整,是文件系统读写的最小单位:
- 所有文件、目录、日志最终都按 4KB 块分配存储空间;
- 哪怕文件只有 1 字节,也会占用一整个 4KB 块,这就是磁盘空间会有微小浪费的根本原因。

注意:
- 磁盘就是一个三维数组,我们把它看待成为一个"一维数组",数组下标就是LBA,每个元素都是扇区
- 每个扇区都有LBA,那么8个扇区一个块,每一个块的地址我们也能算出来。
- 知道LBA:块号 = LBA/8
- 知道块号:LBA=块号*8 + n.(n是块内第几个扇区)

二、磁盘分层管理:磁盘→分区→文件系统三层架构
一块裸盘不能直接存文件,必须经历分区 → 格式化(创建文件系统) 两步操作,原理图清晰画出层级:
1.Disk(整块磁盘)
磁盘头部存放 MBR 主引导记录,同时记录分区表,把整块大磁盘切割成多个独立分区(Partition1/2/3/4),分区隔离不同业务数据,损坏互不影响。
2.Partition(分区)
单个分区内包含引导扇区(Boot Sector),分区只是空间划分,没有存储文件的规则,裸分区无法存放文件,必须格式化。
柱面是分区的最小单位,我们可以利用参考柱面号码的方式来进行分区,其本质就是设置每个区的起始柱面和结束柱面号码。此时我们可以将硬盘上的柱面(分区)进行平铺,将其想象成一个大的平面:

3.File System(EXT 文件系统)
格式化就是给分区写入 EXT2/3/4 文件系统规则,把分区空间切割成多个块组(Block Group),这是 Linux 文件系统的核心管理单元。
三、EXT2文件系统核心:块组 Block Group 内部完整结构
每个分区会平均拆分成若干块组,所有块组结构完全一致,均衡分散数据避免磁盘局部损耗,单个块组内部由 6 部分组成:

位图思想:
传统的标记方式,比如用一个字节/布尔值记录一个磁盘块的占用状态,管理大容量磁盘时会产生极大的内存开销。而位图利用一个二进制位对应一个元素的状态,比如管理1000万个磁盘块,仅需要约1.2MB的内存空间,相比传统方案空间压缩了近8倍,在早期内存资源极其紧张的计算机环境下,大幅降低了内存占用成本。(本质上是为了节省空间和时间)
1.Super Block 超级块
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) */
__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; /* Ext2文件系统魔数,用于校验文件系统合法性 */
__le16 s_state; /* 文件系统状态 */
__le16 s_errors; /* 遇到错误时的处理行为 */
__le16 s_minor_rev_level; /* 次版本号 */
__le32 s_lastcheck; /* 上次执行磁盘检查的时间 */
__le32 s_checkinterval; /* 两次磁盘检查的最大允许间隔时间 */
__le32 s_creator_os; /* 创建该文件系统的操作系统 */
__le32 s_rev_level; /* 主版本号 */
__le16 s_def_resuid; /* 可以使用预留块的默认用户ID */
__le16 s_def_resgid; /* 可以使用预留块的默认组ID */
/*
* 以下字段仅在EXT2_DYNAMIC_REV版本的超级块中生效
* 旧的兼容版本文件系统不会识别这些扩展字段
* 如果你想使用这些扩展特性,必须将s_rev_level设置为1
* 同时必须将s_minor_rev_level设置为非0值
* 否则内核会直接忽略这些扩展字段
*/
__le32 s_first_ino; /* 第一个非预留的inode编号 */
__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]; /* 文件系统的唯一UUID标识 */
char s_volume_name[16]; /* 卷名 */
char s_last_mounted[64]; /* 上次挂载的目录路径 */
__le32 s_algorithm_usage_bitmap; /* 压缩算法使用位图 */
/* 性能提示,仅在COMPAT_PREALLOC特性开启时生效 */
__u8 s_prealloc_blocks; /* 为文件预分配的块数量 */
__u8 s_prealloc_dir_blocks; /* 为目录预分配的块数量 */
__u16 s_padding1;
/* 日志相关字段,仅在EXT3_JOURNAL特性开启时生效 */
__u8 s_journal_uuid[16]; /* 日志文件系统的UUID */
__le32 s_journal_inum; /* 日志文件的inode编号 */
__le32 s_journal_dev; /* 日志文件所在的设备号 */
__le32 s_last_orphan; /* 待删除孤立inode链表的起始位置 */
__le32 s_hash_seed[4]; /* 目录哈希算法的种子值 */
__u8 s_def_hash_version; /* 默认使用的目录哈希算法版本 */
__u8 s_reserved_char_pad;
__u16 s_reserved_word_pad;
__le32 s_default_mount_opts; /* 默认挂载选项 */
__le32 s_first_meta_bg; /* 第一个元数据块组的编号 */
__u32 s_reserved[190]; /* 预留的填充字段,用于扩展到块末尾 */
};
2.GDT 块组描述符表
GDT 块组描述符表 记录每个块组的位图、inode 表、数据块起始位置,操作系统通过 GDT 快速定位任意块组资源。
// 磁盘级blockgroup的数据结构
/*
* 块组描述符的结构体定义
*/
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]; /* 预留扩展字段 */
};
3.Data Blocks 数据块
Data Blocks 数据块 真正存放文件实际内容的区域:普通文件存文本 / 二进制数据,目录文件存放「文件名 - inode 号」映射表,软链接存放目标文件路径。
4.Block Bitmap 块位图
Block Bitmap 块位图 二进制位图,每 1bit 标记一个数据块:0 = 空闲,1 = 已占用。新建文件时系统读取位图分配空闲块,删除文件对应 bit 置 0 回收空间。
为了实现高速的分配和回收操作,文件系统会把完整的块位图加载到内存中。删除文件时,只需要在内存里把对应数据块的标识位直接置0,不需要擦除磁盘上的实际数据,操作速度极快。这也就是为什么我们在下载应用时相对较慢,而在删除应用时却很快的原因。
5.inode Table inode 表(Linux 灵魂)
inode Table inode 表是整块思维导图的核心红框,存储全部文件的属性集合:单个 inode 固定 128 字节,存储文件元数据:文件类型、权限、属主、大小、时间戳、数据块指针;
关键:inode 不存储文件名!只存文件内容对应的磁盘块编号;磁盘上每一个独立文件 / 目录 / 软链接都独占一个唯一 inode 号,ls -li 命令输出的第一列数字就是 inode 编号。
ls -li

磁盘上存储的inode(索引节点)核心结构体的C语言源码定义
/*
* 磁盘上存储的inode结构
*/
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 {
struct {
__le32 l_i_reserved1;
} linux1;
struct {
__le32 h_i_translator;
} hurd1;
struct {
__le32 m_i_reserved1;
} masix1;
} osd1; // 操作系统相关保留字段1
__le32 i_block[EXT2_N_BLOCKS]; // 指向数据块的指针数组
__le32 i_generation; // 文件版本号(用于NFS网络文件系统)
__le32 i_file_acl; // 文件访问控制列表
__le32 i_dir_acl; // 目录访问控制列表
__le32 i_faddr; // 文件碎片地址
union {
struct {
__u8 l_i_frag; // 碎片编号
__u8 l_i_fsize; // 碎片大小
__u16 i_pad1;
__le32 l_i_uid_high; // 用户ID高16位
__le32 l_i_gid_high; // 用户组ID高16位
__le32 l_i_reserved2[2];
} linux2;
struct {
__u8 h_i_frag; // 碎片编号
__u8 h_i_fsize; // 碎片大小
__le16 h_i_mode_high;
__le16 h_i_uid_high;
__le16 h_i_gid_high;
__le32 h_i_author;
} hurd2;
struct {
__u8 m_i_frag; // 碎片编号
__u8 m_i_fsize; // 碎片大小
__u16 m_pad1;
__u32 m_i_reserved2[2];
} masix2;
} 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 // 索引块总数量
备注:EXT2_N_BLOCKS = 15
6.inode Bitmap inode 位图
inode Bitmap inode 位图 和块位图逻辑一致,每 1bit 标记一个 inode 是否空闲,负责分配回收文件属性节点。
📌 注意:
- 文件名属性并未纳入到inode数据结构内部
- inode的大小一般是128字节或者256
- 任何文件的内容大小可以不同,但是属性大小一定是相同的
因此,创建一个文件的流程:
主要有以下4个操作:
- 存储属性
内核先找到一个空闲的i节点(假如是263466)。内核把文件信息记录到其中。 - 存储数据
该文件需要存储在某些磁盘块,假如内核找到了三个空闲块:300,500,800。将内核缓冲区的第一块数据复制到300,下一块复制到500,以此类推。 - 记录分配情况
文件内容按顺序300,500,800存放。内核在inode上的磁盘分布区记录了上述块列表。 - 添加文件名到目录
新的文件名abc。linux如何在当前的目录中记录这个文件?内核将入口(263466,abc)添加到目录文件。文件名和inode之间的对应关系将文件名和文件的内容及属性连接起来。

7.跨组但不跨区
文件的 inode 结构体与它实际存储内容的 Data Blocks 是跨组编号的。这意味着一个文件的 inode 属性可能在 Block Group 0 中,而它的内容数据块可能分布在 Block Group 1。如果我的文件比较大,一个group里面的block放不下,这个文件内容是可以跨组保存的。
我们的每一个组都是固定的,所以我们可以直接用这个inode的编号,去除以这个组的大小,并且模上这个组的大小,就可以知道我的这个inode在哪个组的哪一个位置。
但是,inode 和数据块,绝对不能跨分区!因此,在同一个分区内部,inode 编号和块号都是唯一的;而不同分区之间的 inode 编号则是各自独立的。
四、Linux 核心设计:文件名与文件数据彻底分离(inode 机制)
这是和 Windows 文件存储最大的区别,也是很多人搞不懂软硬链接的根源,原理图专门拆解了这套逻辑:
1. inode 只保管文件本体属性
文件大小、权限、创建时间、磁盘数据块地址全部存在 inode 中,和文件名完全解绑。系统定位文件,本质是先找到 inode,再通过 inode 读取磁盘数据块拿到文件内容。
2. 文件名只存放在父目录的数据块里
目录本身也是特殊文件,有自己的 inode 和数据块;目录的数据块内存储一张映射表:文件名 -> inode编号。 也就是说:文件名只是 inode 的别名,真正唯一标识文件的是 inode 号。 举个场景:我们执行cat code.c时,流程是:
- 读取当前目录的 inode,找到目录数据块;
- 在目录映射表匹配字符串
code.c,拿到对应 inode 编号; - 读取该 inode,获取文件内容所在数据块;
- 读取数据块输出文件内容。
- 所以,访问文件,必须打开当前目录,根据文件名,获得对应的inode号,然后进行文件访问
- 所以,访问文件必须要知道当前工作目录,本质是必须能打开当前工作目录文件,查看目录文件的内容!
1.路径解析
而上级目录也有当前的目录那就会一直追溯到根目录 而这个过程就叫做路径解析
2.路径缓存
- 文件访问依赖路径解析:绝对路径默认从根目录
/逐层读取各级目录文件,匹配文件名拿到目标 inode 才能访问文件。 - 路径访问性能优化:全程从根目录逐层解析速度较慢,Linux 通过缓存历史路径结构提升查找效率。
于是就有了路径缓存,操作系统会把历史访问过的文件路径构建成一棵树,以便于后面访问快速查找。
Linux中,在内核中维护树状路径结构的内核结构体叫做:struct dentry
struct dentry {
atomic_t d_count;
unsigned int d_flags; /* 受 d_lock 保护 */
spinlock_t d_lock; /* 每个 dentry 的锁 */
struct inode *d_inode; /* 名称所属的索引节点 - NULL 表示负向(不存在) */
/*
* 接下来的三个字段会被 __d_lookup 访问。将它们放在这里
* 以便它们都能放入同一个缓存行中。
*/
struct hlist_node d_hash; /* 查找哈希链表 */
struct dentry *d_parent; /* 父目录 */
struct qstr d_name;
struct list_head d_lru; /* LRU 链表 */
/*
* d_child 和 d_rcu 可以共享内存
*/
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; /* 被 d_revalidate 使用 */
struct dentry_operations *d_op;
struct super_block *d_sb; /* dentry 树的根(超级块) */
void *d_fsdata; /* 文件系统特定数据 */
#ifdef CONFIG_PROFILING
struct dcookie_struct *d_cookie; /* cookie,如果有的话 */
#endif
int d_mounted;
unsigned char d_iname[DNAME_INLINE_LEN_MIN]; /* 小名称(内联存储) */
};
注意:
- 每个文件都有对应的dentry结构,包括普通文件。这样所有被打开的文件,就可以在内存中形成整个树形结构
- 整个树形节点也同时会隶属于LRU(Least Recently Used,最近最少使用)结构中,进行节点淘汰
- 整个树形节点也同时会隶属于Hash,方便快速查找
- 更重要的是,这个树形结构,整体构成了Linux的路径缓存结构,打开访问任何文件,都先在这棵树下根据路径进行查找,找到就返回属性inode和内容,没找到就从磁盘加载路径,添加dentry结构,缓存新路径

3.inode和datablock映射
- inode内部存在 __le32 i_block[EXT2_N_BLOCKS];
- /* Pointers to blocks */,EXT2_N_BLOCKS =15,就是用来进行inode和block映射的
- 这样文件=内容+属性,就都能找到了。

1. 直接块指针(inode 里 12 个直接指针)
指针 直接指向普通数据块,这个数据块里存的就是文件真实内容(文字、图片、程序数据)。 → 这种指针,最终指向文件内容。
如果文件内容太多了,该如何存储呢?
inode 只给了 12 个直接指针,假设一块 4KB: 12 × 4KB = 49152 字节(48KB)
如果文件超过 48KB,12 个直接指针根本不够存所有数据块的地址,存不下大文件。
2. 一级 / 二级 / 三级间接块指针(图里带横线的索引表块)
第一步:inode 的间接指针 → 索引表块(数据块索引表)
这个索引表里只存一堆子指针(块号),完全没有文件内容,只是中转目录。
第二步:索引表里的子指针 → 普通数据块
走到这一层,才是存放文件内容的地方。
3.举二级间接流程:
inode 二级指针 → 一级索引表(全是指针,无内容) → 二级索引表(全是指针,无内容) → 普通数据块(真正文件内容)
这些一级、二级、三级间接指针本质上就是为了解决文件内容太多的问题的
4.文件查找的过程

五.挂载分区
我们已经能够根据inode号在指定分区找文件了,也已经能根据目录文件内容,找指定的inode了。
问题:inode不是不能跨分区吗?Linux不是可以有多个分区吗?我怎么知道我在哪一个分区???
# 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设备,是一种伪设备(pseudo-device),它允许将文件作为块设备(block device)来使用。这种机制使得可以将文件(比如ISO镜像文件)挂载(mount)为文件系统,就像它们是物理硬盘分区或者外部存储设备一样。
ls /dev/loop* -l
六、软硬链接底层差异,从 inode 一眼看懂
结合原理图 inode 机制,软硬链接的区别一目了然:
1.硬链接
多个不同文件名,指向同一个 inode 编号;目录映射表里多条记录绑定同一个 inode,inode 内链接计数 + 1;删除其中一个文件名仅计数 - 1,计数归 0 才释放 inode 与数据块。 限制:不能跨分区、不支持目录创建硬链接。

2.软链接(符号链接)
拥有独立全新 inode,自身是单独文件,数据块仅存储目标文件路径字符串;访问时读取路径再跳转原文件;原文件删除后软链接直接失效,无计数机制,支持跨分区、链接目录。


明明没有手动进行硬链接,可为什么硬链接数还会发生变化呢?
其实每一个目录创建后都会有当前目录和上级目录,自动进行了硬链接

七、格式化、分区实操底层本质(总结梳理)
看完整套架构,就能理解日常磁盘操作底层做了什么:
- 分区(fdisk):修改磁盘 MBR 分区表,切割磁盘空间边界,不修改分区内部数据;
- 格式化(mkfs.ext4):给分区写入 EXT 文件系统,划分块组、初始化超级块、位图、空白 inode 表,清空原有数据;
- 挂载(mount):读取分区超级块,加载块组信息,将文件系统关联到系统目录入口;
- rm 删除文件:仅把 inode 位图、块位图对应 bit 置为空闲,不擦除磁盘数据,这是删除文件仍能数据恢复的底层原理。
八.文件系统总结





结尾总结
Linux 文件系统是一套分层、解耦的精密设计:硬件扇区封装为块,磁盘切割分区隔离空间,分区拆分为块组均衡管理,再通过 inode 分离「文件属性」和「文件名」。
这张思维导图把从磁盘硬件到文件访问的完整链路全部打通,弄懂块、块组、inode、目录映射这套底层逻辑,再去学习磁盘扩容、数据恢复、软硬链接、磁盘 IO 调优都会事半功倍,彻底告别只会敲命令不懂底层原理的阶段。
解耦 = 把两件强绑定、改一个就必须动另一个的东西,拆分开,让两者互相独立、互不依赖。
耦合:两件事捆死在一起; 解耦:拆开,各司其职,改动其中一边不影响另一边。
带来巨大好处:
- 给文件重命名:只改目录里的名字映射,不动 inode、不动文件真实数据,几乎零开销;
- 硬链接:同一个 inode,可以对应 N 个不同文件名,新增名字完全不用复制文件内容;
- 移动同分区文件:只是修改父目录映射,文件本体 inode 和数据块一点不变;
- 删除文件名:只是删掉一条映射,inode 和数据还在,只要还有别的硬链接,文件依然可用。
核心:把「文件标识名」和「文件真实本体」解耦,两者独立变化,互不牵制。
转载自 CSDN-专业IT技术社区
原文链接:https://blog.csdn.net/2502_94387000/article/details/162880780




