跳到主要内容
Linux Ext 系列文件系统原理:从磁盘结构到文件存储 | 极客日志
Shell / Bash
Linux Ext 系列文件系统原理:从磁盘结构到文件存储 深入剖析 Linux Ext 系列文件系统的底层原理。首先解析磁盘物理、存储及逻辑结构,阐述 CHS 与 LBA 寻址方式。接着介绍文件系统核心概念,包括块(Block)、分区、超级块(Super Block)及组描述符表(GDT)。重点讲解 inode 节点结构、数据块映射关系及路径解析机制。最后对比软硬链接的原理与应用场景,阐明文件在磁盘上的存储与管理方式。
DebugKing 发布于 2026/3/30 更新于 2026/5/23 30 浏览前言
本文探讨文件打开前的存储机制,围绕以下问题展开:
为什么打开文件要带文件路径?
打开文件时操作系统做了什么?
没被打开的文件在哪里?如何存放的?
目前可以回答第三个问题,没被打开的文件一定在磁盘这样的存储设备上。Ext 系列文件系统是专为磁盘等持久化块存储设备设计的文件系统。
一、理解磁盘
机械磁盘是计算机中唯一的机械设备,属于外设,特点是慢、容量大、价格便宜。大型互联网公司常使用磁盘来存数据。
磁盘的物理结构
主轴马达会一直高速旋转,磁头会高速左右摆动。
磁盘的存储结构
扇区是磁盘存储数据的基本单位,也是操作系统访问数据的基本单位,通常为 512 字节,它是块设备。
一般认为一个磁盘中各个半径不同的磁道所含的扇区数目是一样的,原因是不同磁道扇区的疏密程度不同,一般需要高频访问的数据在内侧,不太高频访问的数据就在外侧。
磁盘结构说明:
盘面和磁头是一对一对应的。
并且一个磁盘的所有磁头是共进退的,同一时间六个磁头都是访问的是对应盘面同一半径的磁道,我们把这六个磁头所在的六个相同半径的磁道合为一体称为柱面。
让磁盘动起来:
磁头左右摆动的本质是在定位哪个磁道。
盘面旋转的本质是在确定某个磁道后,定位该磁道(柱面)上的某一个扇区。
如何定位一个扇区呢?
可以先定位磁头(header)。
确定磁头要访问哪一个柱面 (磁道)(cylinder)。
定位一个扇区 (sector)。
这就叫做 CHS 地址定位,但是现代磁盘一般是用 LBA 地址定位。
一般磁头、磁道编号从 0 开始,扇区编号从 1 开始。
总结:
扇区是从磁盘读出和写入信息的最小单位,通常大小为 512 字节。
磁头(head)数:每个盘片一般有上下两面,分别对应 1 个磁头,共 2 个磁头。
磁道(track)数:磁道是从盘片外圈往内圈编号 0 磁道,1 磁道…,靠近主轴的同⼼圆用于停靠磁头,不存储数据。
柱面(cylinder)数:磁道构成柱面,数量上等同于磁道个数。
扇区(sector)数:每个磁道都被切分成很多扇形区域,每道的扇区数量相同。
圆盘(platter)数:就是盘片的数量。
磁盘容量=磁头数 × 磁道 (柱面) 数 × 每道扇区数 × 每扇区字节数。
细节:传动臂上的磁头是共进退的 (这点比较重要,后面会说明)。
柱面(cylinder),磁头(head),扇区(sector),显然可以定位数据了,这就是数据定位 (寻址) 方式之一,CHS 寻址方式。
磁盘的逻辑结构
理解过程
磁盘本质上虽然是硬质的,但是逻辑上我们可以把磁盘想象成为卷在一起的磁带,那么磁盘的逻辑存储结构我们也可以类似于:
这样每一个扇区,就有了一个线性地址 (其实就是数组下标),sector array[N],这种地址叫做 LBA(Logic Block Address)
真实过程 我们在前面理解磁盘展开的时候是以盘面为单位展开的,但磁盘的真实展开,是以柱面为单位展开的。
某一个盘面的其中一个磁道是一维数组,磁盘中该磁道所处的柱面是二维数组,那么整个磁盘本质就是一个三维数组。
所有,寻址一个扇区:先找到哪一个柱面 (Cylinder),在确定柱面内哪一个磁道 (其实就是磁头位置,Head),在确定扇区(Sector),所以就有了 CHS,而 CHS 这正好对应磁盘三维数组的下标:sector array[C][H][S]。
不管是二维数组还是三维数组,本质还是一维数组,因为数组元素在内存中都是连续存放的,下面是三维数组降维后的演示图:
所以从今天开始我们可以把磁盘当作一个以 sector 为单位的线性一维数组(操作系统内部并没有定义这个一维数组,所以一维数组只是一个抽象概念),数组的每个扇区元素的下标,就是 LBA 地址。
所以未来操作系统只用使用 LBA 地址就可以访问磁盘了,磁盘自己会做 LBA 和 CHS 之间的转化。
CHS/LBA 地址转换
CHS 转成 LBA:
磁头数*每个磁道扇区数 = 单个柱面的扇区总数。
LBA = 柱面号 C单个柱面的扇区总数 + 磁头号 H 每磁道扇区数 + 扇区号 S - 1。
即:LBA = 柱面号 C*(磁头数每磁道扇区数) + 磁头号 H 每磁道扇区数 + 扇区号 S - 1。
扇区号通常是从 1 开始的,而在 LBA 中,地址是从 0 开始的。
柱面和磁道都是从 0 开始编号的。
总柱面,磁道个数,扇区总数等信息,在磁盘内部会自动维护,上层开机的时候,会获取到这些参数。
LBA 转成 CHS:
柱面号 C = LBA // (磁头数*每磁道扇区数)【就是单个柱面的扇区总数】。
磁头号 H = (LBA % (磁头数*每磁道扇区数)) // 每磁道扇区数。
扇区号 S = (LBA % 每磁道扇区数) + 1。
'//': 表示除取整。
所以:从此往后,在磁盘使用者看来,根本就不关心 CHS 地址,而是直接使用 LBA 地址,磁盘内部自己转换。所以:从现在开始,磁盘就是一个 元素为扇区 的一维数组,数组的下标就是每一个扇区的 LBA 地址。OS 使用磁盘,就可以用一个数字访问磁盘扇区了。
二、引入文件系统
引入"块"概念 其实硬盘是典型的'块'设备,操作系统读取硬盘数据的时候,其实是不会一个个扇区地读取,这样效率太低,而是一次性连续读取多个扇区,即一次性读取一个'块'(block)。
硬盘的每个分区是被划分为一个个的'块'。一个'块'的大小是由格式化的时候确定的,并且不可以更改,最常见的是 4KB,即连续八个扇区组成一个 '块'。'块'是操作系统访问文件的最小单位。
磁盘就是一个三维数组,我们把它看待成为一个"一维数组",数组下标就是 LBA,每个元素都是扇区。
每个扇区都有 LBA,那么 8 个扇区一个块,每一个块的地址我们也能算出来。
知道 LBA:块号 = LBA/8。
知道块号:LBA=块号*8 + n. (n 是块内第几个扇区)。
所以现在我们站在操作系统的角度看待磁盘,就认为磁盘是一个块设备,每个块都有下标,从文件系统的角度,对磁盘的文件进行访问都是以块为单位的。
至此我们已经完成了对磁盘的完整建模过程,磁盘本质就是: block array[N] 数组,是块设备。
补充 OS 访问外设的实现细节 其实不止 CPU 有寄存器,各种硬件外设也有寄存器,只不过数量较少。
我们以磁盘为例,磁盘中会有 dir 寄存器,指示操作系统要对硬盘做什么操作,例如是读还是写。addr 寄存器,指示操作系统要对磁盘的哪一个扇区进行访问,一般会对 addr 寄存器内写入 LBA 地址。data 寄存器,用于存储操作系统的读写数据,所以操作系统对外设的控制本质就是对外设的特定寄存器写入对应数据,然后外设内部的硬件电路会自动进行相应的访问。
引入"分区"概念 其实磁盘是可以被分成多个分区(partition)的,以 Windows 观点来看,你电脑会有一块磁盘并且将它分区成 C,D,E 盘。如果没有给电脑添加硬盘那么 C,D,E 就是电脑自带的一块机械硬盘(磁盘)的分区。分区从实质上说就是对硬盘的一种格式化。但是 Linux 的设备都是以文件形式存在,那是怎么分区的呢?
分区可以类似治理国家,国家很大,所以需要甚至省政府,市政府…而一块磁盘空间也很大,也需要分区治理,一块磁盘会被分为多个区,而一个区还会被分为多个组,所以我们只用研究如何把一个组管理好,就可以把整个磁盘管理好。下面我们就需要来研究一个磁盘的一个组内部有哪些要素。
文件 = 内容 + 属性,磁盘中文件的内容和属性都需要存储。这里先输出两个结论:
Linux 中,文件的内容和属性是分开存储的。
OS 文件系统中,OS 和磁盘进行 IO 的基本单位是 4kb。
文件内容 Data Blocks Data Blocks 是用来存储文件内容的,以 4kb 数据块为单位,它占据了绝大部分磁盘的分组空间。
Block Bitmap
用来表示数据块的整体使用情况,它记录了 Data Block 中哪个数据块已经被占用,哪个数据块没有被占用,每一个比特位的 0、1 用来表示其中一个数据块是否被占用。
文件属性 inode 在 linux 中,我们一般用结构体 struct inode 表示文件属性,下面是 ext2 文件系统的 inode 完整代码:
struct ext2_inode {
__le16 i_mode;
__le16 i_uid;
__le32 i_size;
__le32 i_atime;
__le32 i_ctime;
__le32 i_mtime;
__le32 i_dtime;
__le16 i_gid;
__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;
__le32 i_block[EXT2_N_BLOCKS];
__le32 i_generation;
__le32 i_file_acl;
__le32 i_dir_acl;
__le32 i_faddr;
union {
struct {
__u8 l_i_frag;
__u8 l_i_fsize;
__u16 l_i_pad;
__le16 l_i_uid_high;
__le16 l_i_gid_high;
__le32 l_i_reserved2;
} 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;
__le32 m_i_reserved2;
} masix2;
} osd2;
};
既然 inode 是结构体,说明 inode 的大小是固定的,所以每个 inode 所包含的成员变量类型和数目是一样的,但不同文件之间 inode 的成员变量的具体内容一般不同。文件的 inode 根据文件系统的不同,一般是 128 字节或者 256 字节。OS 若读取 inode,因为一次读取 4kb,所以 OS 一次可以最多可以读取 32 个 inode。
一般一个文件对应一个 inode,一个文件可能对应 0 个或多个 Data Block。这里会引出两个结论:
inode 会有一个 int 类型变量 inode,用来表示文件的唯一性。
linux 中,文件名不能也不在 inode 中存储。
下面介绍文件属性在磁盘中的存储情况,磁盘的分组中会有一个 inode Table 用来存放文件属性内容,结构如下图所示,inode Bitmap 类似 Block Bitmap,表示 inode Table 的整体使用情况。
下面我们上手实践一下,看我们创建的文件的 inode number 是多少,可以通过 -i 选项查看文件的 inode number:
现在我们拿到了文件的 inode number,就可以拿到文件的属性:struct inode,那如何通过文件属性找到文件的内容呢?实际上文件的 struct inode 中会有一个 inode 到 block 的映射关系表:
__le32 i_block[EXT2_N_BLOCKS];
总结:我们可以通过 inode 编号访问 struct inode,然后根据 struct inode 内部包含的 inode 和数据块的映射关系,进一步找到文件的内容。
inode 和 datablock 映射 inode 中不仅有文件的属性信息,内部还维护了一张表 i_block 存储了 inode 和文件数据 data block 的映射关系。
一个 inode 只能映射 15 组 data block 数据,前 12 组是映射到的数据块中的数据就是文件内容,而 13-15 是映射的数据块中的数据是其他数据块的块号,也就是索引,详情见下表,13、14、15 依次的一级索引、二级索引、三级索引。
一个组内的 inode 是可能映射到同一分区其他组的 data block 的,因为 data block 是全区整体编号的。
GDT(Group Descriptor Table) 块组描述符表,描述块组属性信息(包含块组的区域划分情况),整个分区分成多个块组就对应有多少个块组描述符。每个块组描述符存储一个块组的描述信息,如在这个块组中从哪里开始是 inode Table,从哪里开始是 Data Blocks,空闲的 inode 和数据块还有多少个等等。块组描述符在每个块组的开头都有一份拷贝。
struct ext2_group_desc {
__le32 bg_block_bitmap;
__le32 bg_inode_bitmap;
__le32 bg_inode_table;
__le16 bg_free_blocks_count;
__le16 bg_free_inodes_count;
__le16 bg_used_dirs_count;
__le16 bg_pad;
__le32 bg_reserved[3 ];
};
超级块(Super Block) 存放文件系统本身的结构信息,描述整个分区文件系统信息。等于说 GDT 管理它所在的一个组,Super Block 管理它所在分区的所有组。(一般来说磁盘的每个分区都有各自独立的文件系统)
这里会有一个问题,既然 Super Block 是管理一整个分区,为什么它不在分区的开头,而是在一个分组的开头?
这其实是一种数据容灾的备份处理:Super Block 不仅仅只在一个组里,它可能同时在多个组里存在,但是不一定整个分区中的所有组都有,那么当其中一个分组的 Super Block 出于某种原因损坏后,不会导致整个分区直接崩掉,并且损坏的数据可以通过其他分组中的 Super Block 恢复,分组的数据量不是很大,所以分组的 GDT 只有一份。记录的信息主要有:bolck 和 inode 的总量,未使用的 block 和 inode 的数量,一个 block 和 inode 的大小,最近一次挂载的时间,最近一次写入数据的时间,最近一次检验磁盘的时间等其他文件系统的相关信息。Super Block 的信息被破坏,可以说整个文件系统结构就被破坏了。
(补充:还能通过超级块中的成员数据间接找到文件所在扇区的 LBA 地址)
几个子问题(格式化、删除文件、文件名存储)
格式化分区是指给特定分区写入管理信息,即写入文件系统和分组分区的相关管理数据,文件数据可以暂时不写入。所以当我们新建一个分区时会就会将该区格式化。
删除文件不用释放掉文件 inode Table 和 Data Block 中的数据,只用把它在 inode Bitmap 和 Block Bitmap 中的对应位置置为 0 就行了。这其实可以解释我们平时在下载文件需要很长时间,但是删除文件只需要 1、2 秒的原因。
这里可以引申出一个结论:计算机删除文件,只用设置数据无效就行了。
要访问一个文件,只能通过该文件的 inode,所以我们要拿到它的 inode 编号,因为只有 inode 编号可以在一个分区内表示文件的唯一性。
inode 编号和 data block 编号是全区统一分配的,不是只在一个组内有效。所以 super block 中会维护该区中每个组的中有多少个 block 和 inode。不同分区的 inode 编号和 data block 编号就互不影响,可以重复了。
我们之前谈过,文件名不在文件的 inode struct 中,那文件名应该在哪里?其实普通文件的文件名在它所在的目录的文件内容中,因为目录也是文件,所以目录也有文件内容和文件属性。但普通文件的文件名并不是单独的存在目录内容中,而是以文件名和它对应的 inode 的映射关系形式存储的。所以我们平时用的文件名本质是指定目录下的映射关系。这也解释了为什么同一目录下文件名不能重复。
从今天开始,在文件系统的角度,普通文件和目录文件的存储方式没有任何区别,无非就是存储 inode 和数据块,只不过内容不同。区分普通文件和目录文件是用文件的 struct inode 中的权限成员变量 i_mode 的第一个比特位:'-'表示普通文件,'d"表示目录文件。
证明目录内容是文件和 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 ;
}
解决文件权限相关困惑
在指定目录下新建文件的本质是将文件的文件名和 inode 的映射关系写到目录的内容 data block 里,相当于新建文件本质要对目录做写入操作,所以这里可以解释之前讲的为什么在目录下新建文件,需要目录有 w 权限。
路径解析 我们已经知道要访问一个文件首先要拿到它的 inode,拿着文件名要找 inode 就需要访问它的上级目录内 data block 中的文件名-inode 映射关系,而这个上级目录也是一个文件,要访问它有需要拿到它的 inode,而它的 inode 又在更上级的目录中…直到打开根目录为止,这个过程类似递归。所以我们访问要 linux 下任意一个文件,都需要从根目录开始,对该文件上的所有目录进行解析,也就是依次打开路径上的所有目录(如 /home/fdb:打开根目录,拿着 home 文件名就能在根目录中找到 home 的 inode,就能打开 home 了…依此类推),这个过程就叫做路径解析。
这就是为什么打开一个文件、访问文件必须要有该文件路径的原因,也是为什么 PCB 中会维护 cwd(当前进程的工作路径)的原因。
路径缓存 我们学习了路径解析后,我相信大家和我都有一个疑问,每访问一个文件都要做路径解析,这样是不是太慢了啊?所以 linux 还会支持一个名叫路径缓存的功能。
在 linux 系统中,当用户访问指定路径下的文件时(包括路上目录,最终的目标文件在内),linux 会在进路径解析的过程中,在内核里形成目录树和路径缓存,在整个树形结构中,普通文件和空目录就是叶子结点。
Linux 中,在内核中维护树状路径结构的内核结构体叫做: struct dentry。
每个文件其实都要有对应的 dentry 结构,包括普通文件。这样所有被打开的文件,就可以在内存中形成整个树形结构。
整个树形节点也会隶属于 LRU(Least Recently Used,最近最少使用) 结构中,进行节点淘汰。
整个树形节点也同时会隶属于 Hash,方便快速查找。
更重要的是,这个树形结构,整体构成了 Linux 的路径缓存结构,打开访问任何文件,都在先在这棵树下根据路径进行查找,找到就返回属性 inode 和内容,没找到就从磁盘加载路径,添加 dentry 结构,缓存新路径。
文件描述符 fd 会关联 struct file,Linux 内核内存中的 struct file 会间接关联 struct dentry 结构体,struct dentry 会间接关联 inode,所以我们之前介绍 struct file 内的三大内容之一的文件属性就在 inode 中。内存中的 inode 是由磁盘的 inode 在将文件加载到内存时初始化的,我们前面介绍的 ext2_inode 就是磁盘级 inode。
总而言之,小编只想让大家理解一点,打开一个文件首先会对该文件进行路径解析、对每个路径结点检查 / 创建 dentry,dentry 中还会关联 inode,并将 dentry 结构挂进目录树中,之后会为其创建 struct file,并将 struct file 与刚创建的 dentry 相关联,所以我们就可以通过 struct file 访问该文件的内容和属性了(访问文件内容也需要通过 inode),文件描述符封装了 file,所以最上层就能通过文件描述符访问文件的内容和属性了。
总结
当我们要对磁盘中的文件进行增删改查时需要先将文件加载到内存中,在内存中修改后再刷新回磁盘。
文件在内存中用文件描述符来唯一标识文件,文件在磁盘中用 inode 来唯一标识文件。
fd 最终绑定的是唯一的 inode,因此进程拿到一个 fd 看到的是一个唯一文件(例外场景如硬链接看似不唯一,本质仍是 inode 唯一)。
挂载分区
一个磁盘,必须分区格式化,才有使用的前提。
一个分区,必须挂载到指定的目录才可以被真正的使用。
所以,可以根据访问目标文件的"路径前缀"准确判断我在哪一个分区。
三、软硬链接
操作 在 linux 中,还有两种链接文件:软连接和硬链接。在讲解原理之前,我们先认识一下操作,如何用指令创建这两种链接文件:
原理和区别 我们看上图会发现软链接是一个独立的文件,而硬链接不是一个独立的文件。
软链接的文件内容是该链接文件指向文件的路径字符串,例如 test-soft 的文件内容就是 test.c 文件的路径字符串。
硬链接本质是在指定目录下,建立新的文件名和 inode 的映射关系,并没有在系统层面创建新的文件。
文件的硬链接数本质是有几个文件名指向特定的 inode,相当于是一种引用计数,删除一个文件名时若该文件名映射的 inode 的引用计数减 1,若引用计数为 0 了才会真的释放掉文件的属性和内容所占用的磁盘空间。
软链接必须使用绝对路径,硬链接推荐使用绝对路径。
应用场景
快捷方式。
让用户无感知进行软件升级,用户拿到的是->前面的文件,升级的是->后面的文件。
对文件进行备份,并且备份后该文件只会占用一份文件的属性和内容空间,因为创建硬链接本质只是创建了一组文件名和 inode 的映射关系。
一些问题
为什么新建的普通文件硬链接数默认为 1?因为只有一组文件名和 inode 的映射关系。
为什么新建的目录文件(空目录)硬链接数默认为 2?因为目录不仅有目录名和 inode 的映射关系,目录里还有一个隐藏目录文件 . 表示当前目录,所以还有一组 . 和 inode 的映射关系。当我们在该目录下再创建一个目录文件时,目录的硬链接数会变为 3,因为新建的目录文件里会有一个隐藏目录文件 .. 指向当前文件。
一个目录的子目录数是它的硬链接数减 2,因为它本身要占两个(目录名和 . )。
在用户层面,我们可以给目录、普通文件设置硬链接,但是不允许对目录设置硬链接,因为对目录设置硬链接会发生路径环路问题,如下图。当我们想对该路径进行路径解析时,打开最后一个目录 home-hard 就相当于打开了 home,所以会继续从 home 开始进行解析,形成环路问题。
那为什么系统却允许的 . 和 .. 的存在呢,它们不就是对目录的硬链接吗?这其实是系统的一种特殊处理,系统通过底层逻辑避免了环路风险。
相关免费在线工具 Base64 字符串编码/解码 将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
Base64 文件转换器 将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online
Markdown转HTML 将 Markdown(GFM)转为 HTML 片段,浏览器内 marked 解析;与 HTML转Markdown 互为补充。 在线工具,Markdown转HTML在线工具,online
HTML转Markdown 将 HTML 片段转为 GitHub Flavored Markdown,支持标题、列表、链接、代码块与表格等;浏览器内处理,可链接预填。 在线工具,HTML转Markdown在线工具,online
JSON 压缩 通过删除不必要的空白来缩小和压缩JSON。 在线工具,JSON 压缩在线工具,online
JSON美化和格式化 将JSON字符串修饰为友好的可读格式。 在线工具,JSON美化和格式化在线工具,online