前言
本文探讨文件打开前的存储机制,围绕以下问题展开:
本文深入剖析 Linux Ext 系列文件系统的底层原理。首先解析磁盘物理、存储及逻辑结构,阐述 CHS 与 LBA 寻址方式。接着介绍文件系统核心概念,包括块(Block)、分区、超级块(Super Block)及组描述符表(GDT)。重点讲解 inode 节点结构、数据块映射关系及路径解析机制。最后对比软硬链接的原理与应用场景,阐明文件在磁盘上的存储与管理方式。

本文探讨文件打开前的存储机制,围绕以下问题展开:
目前可以回答第三个问题,没被打开的文件一定在磁盘这样的存储设备上。Ext 系列文件系统是专为磁盘等持久化块存储设备设计的文件系统。
机械磁盘是计算机中唯一的机械设备,属于外设,特点是慢、容量大、价格便宜。大型互联网公司常使用磁盘来存数据。
主轴马达会一直高速旋转,磁头会高速左右摆动。
扇区是磁盘存储数据的基本单位,也是操作系统访问数据的基本单位,通常为 512 字节,它是块设备。
一般认为一个磁盘中各个半径不同的磁道所含的扇区数目是一样的,原因是不同磁道扇区的疏密程度不同,一般需要高频访问的数据在内侧,不太高频访问的数据就在外侧。

磁盘结构说明:
让磁盘动起来:
如何定位一个扇区呢?
总结:
磁盘本质上虽然是硬质的,但是逻辑上我们可以把磁盘想象成为卷在一起的磁带,那么磁盘的逻辑存储结构我们也可以类似于:

这样每一个扇区,就有了一个线性地址 (其实就是数组下标),sector array[N],这种地址叫做 LBA(Logic Block Address)
我们在前面理解磁盘展开的时候是以盘面为单位展开的,但磁盘的真实展开,是以柱面为单位展开的。
某一个盘面的其中一个磁道是一维数组,磁盘中该磁道所处的柱面是二维数组,那么整个磁盘本质就是一个三维数组。 所有,寻址一个扇区:先找到哪一个柱面 (Cylinder),在确定柱面内哪一个磁道 (其实就是磁头位置,Head),在确定扇区(Sector),所以就有了 CHS,而 CHS 这正好对应磁盘三维数组的下标:sector array[C][H][S]。
不管是二维数组还是三维数组,本质还是一维数组,因为数组元素在内存中都是连续存放的,下面是三维数组降维后的演示图:
所以从今天开始我们可以把磁盘当作一个以 sector 为单位的线性一维数组(操作系统内部并没有定义这个一维数组,所以一维数组只是一个抽象概念),数组的每个扇区元素的下标,就是 LBA 地址。 所以未来操作系统只用使用 LBA 地址就可以访问磁盘了,磁盘自己会做 LBA 和 CHS 之间的转化。
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,即连续八个扇区组成一个 '块'。'块'是操作系统访问文件的最小单位。
注意:
所以现在我们站在操作系统的角度看待磁盘,就认为磁盘是一个块设备,每个块都有下标,从文件系统的角度,对磁盘的文件进行访问都是以块为单位的。
至此我们已经完成了对磁盘的完整建模过程,磁盘本质就是: block array[N] 数组,是块设备。
其实不止 CPU 有寄存器,各种硬件外设也有寄存器,只不过数量较少。 我们以磁盘为例,磁盘中会有 dir 寄存器,指示操作系统要对硬盘做什么操作,例如是读还是写。addr 寄存器,指示操作系统要对磁盘的哪一个扇区进行访问,一般会对 addr 寄存器内写入 LBA 地址。data 寄存器,用于存储操作系统的读写数据,所以操作系统对外设的控制本质就是对外设的特定寄存器写入对应数据,然后外设内部的硬件电路会自动进行相应的访问。
其实磁盘是可以被分成多个分区(partition)的,以 Windows 观点来看,你电脑会有一块磁盘并且将它分区成 C,D,E 盘。如果没有给电脑添加硬盘那么 C,D,E 就是电脑自带的一块机械硬盘(磁盘)的分区。分区从实质上说就是对硬盘的一种格式化。但是 Linux 的设备都是以文件形式存在,那是怎么分区的呢? 分区可以类似治理国家,国家很大,所以需要甚至省政府,市政府…而一块磁盘空间也很大,也需要分区治理,一块磁盘会被分为多个区,而一个区还会被分为多个组,所以我们只用研究如何把一个组管理好,就可以把整个磁盘管理好。下面我们就需要来研究一个磁盘的一个组内部有哪些要素。

文件 = 内容 + 属性,磁盘中文件的内容和属性都需要存储。这里先输出两个结论:
Data Blocks 是用来存储文件内容的,以 4kb 数据块为单位,它占据了绝大部分磁盘的分组空间。 Block Bitmap 用来表示数据块的整体使用情况,它记录了 Data Block 中哪个数据块已经被占用,哪个数据块没有被占用,每一个比特位的 0、1 用来表示其中一个数据块是否被占用。
在 linux 中,我们一般用结构体 struct inode 表示文件属性,下面是 ext2 文件系统的 inode 完整代码:
/*
* Structure of an inode on the disk
*/
struct ext2_inode {
__le16 i_mode; /* 文件类型 + 权限(如截图中 `-rw-rw-r--`) */
__le16 i_uid; /* 所有者 UID 的低 16 位 */
__le32 i_size; /* 文件大小(字节),截图中 touch 创建的空文件为 0 */
__le32 i_atime; /* 最后访问时间(时间戳) */
__le32 i_ctime; /* inode 元数据最后修改时间(时间戳) */
__le32 i_mtime; /* 文件内容最后修改时间(时间戳,对应截图中 `10:55` 等) */
__le32 i_dtime; /* 文件删除时间(时间戳,未删除时无效) */
__le16 i_gid; /* 所属组 GID 的低 16 位 */
__le16 i_links_count; /* 硬链接数(截图中每个文件为 1) */
__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; /* 操作系统相关字段(兼容 Linux、Hurd、Masix) */
__le32 i_block[EXT2_N_BLOCKS]; /* 数据块指针数组(直接/间接块,EXT2_N_BLOCKS 通常为 15) */
__le32 i_generation; /* 文件版本号(用于 NFS 等网络文件系统) */
__le32 i_file_acl; /* 文件 ACL(访问控制列表)的块指针 */
__le32 i_dir_acl; /* 目录 ACL 的块指针(若为目录时有效) */
__le32 i_faddr; /* 碎片地址(若文件启用碎片存储时使用) */
union {
struct {
__u8 l_i_frag; /* 碎片编号 */
__u8 l_i_fsize; /* 碎片大小 */
__u16 l_i_pad; /* 填充(字节对齐) */
__le16 l_i_uid_high; /* 所有者 UID 的高 16 位(扩展 UID 范围) */
__le16 l_i_gid_high; /* 所属组 GID 的高 16 位(扩展 UID 范围) */
__le32 l_i_reserved2; /* 保留字段 */
} linux2;
struct {
__u8 h_i_frag; /* 碎片编号(Hurd 系统用) */
__u8 h_i_fsize; /* 碎片大小(Hurd 系统用) */
__le16 h_i_mode_high; /* 高 16 位模式(Hurd 系统用) */
__le16 h_i_uid_high; /* 高 16 位 UID(Hurd 系统用) */
__le16 h_i_gid_high; /* 高 16 位 GID(Hurd 系统用) */
__le32 h_i_author; /* 作者标识(Hurd 系统用) */
} hurd2;
struct {
__u8 m_i_frag; /* 碎片编号(Masix 系统用) */
__u8 m_i_fsize; /* 碎片大小(Masix 系统用) */
__u16 m_pad1; /* 填充(字节对齐) */
__le32 m_i_reserved2; /* 保留字段(Masix 系统用) */
} masix2;
} osd2; /* 操作系统相关字段(第二部分,兼容多系统) */
};
既然 inode 是结构体,说明 inode 的大小是固定的,所以每个 inode 所包含的成员变量类型和数目是一样的,但不同文件之间 inode 的成员变量的具体内容一般不同。文件的 inode 根据文件系统的不同,一般是 128 字节或者 256 字节。OS 若读取 inode,因为一次读取 4kb,所以 OS 一次可以最多可以读取 32 个 inode。
一般一个文件对应一个 inode,一个文件可能对应 0 个或多个 Data Block。这里会引出两个结论:
下面介绍文件属性在磁盘中的存储情况,磁盘的分组中会有一个 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 中不仅有文件的属性信息,内部还维护了一张表 i_block 存储了 inode 和文件数据 data block 的映射关系。 一个 inode 只能映射 15 组 data block 数据,前 12 组是映射到的数据块中的数据就是文件内容,而 13-15 是映射的数据块中的数据是其他数据块的块号,也就是索引,详情见下表,13、14、15 依次的一级索引、二级索引、三级索引。
一个组内的 inode 是可能映射到同一分区其他组的 data block 的,因为 data block 是全区整体编号的。
块组描述符表,描述块组属性信息(包含块组的区域划分情况),整个分区分成多个块组就对应有多少个块组描述符。每个块组描述符存储一个块组的描述信息,如在这个块组中从哪里开始是 inode Table,从哪里开始是 Data Blocks,空闲的 inode 和数据块还有多少个等等。块组描述符在每个块组的开头都有一份拷贝。
// 磁盘级 blockgroup 的数据结构
/*
* Structure of a blocks group descriptor
*/
struct ext2_group_desc {
__le32 bg_block_bitmap; /* Blocks bitmap block */
__le32 bg_inode_bitmap; /* Inodes bitmap */
__le32 bg_inode_table; /* Inodes table block*/
__le16 bg_free_blocks_count;/* Free blocks count */
__le16 bg_free_inodes_count;/* Free inodes count */
__le16 bg_used_dirs_count; /* Directories count */
__le16 bg_pad;
__le32 bg_reserved[3];
};
存放文件系统本身的结构信息,描述整个分区文件系统信息。等于说 GDT 管理它所在的一个组,Super Block 管理它所在分区的所有组。(一般来说磁盘的每个分区都有各自独立的文件系统) 这里会有一个问题,既然 Super Block 是管理一整个分区,为什么它不在分区的开头,而是在一个分组的开头? 这其实是一种数据容灾的备份处理:Super Block 不仅仅只在一个组里,它可能同时在多个组里存在,但是不一定整个分区中的所有组都有,那么当其中一个分组的 Super Block 出于某种原因损坏后,不会导致整个分区直接崩掉,并且损坏的数据可以通过其他分组中的 Super Block 恢复,分组的数据量不是很大,所以分组的 GDT 只有一份。记录的信息主要有:bolck 和 inode 的总量,未使用的 block 和 inode 的数量,一个 block 和 inode 的大小,最近一次挂载的时间,最近一次写入数据的时间,最近一次检验磁盘的时间等其他文件系统的相关信息。Super Block 的信息被破坏,可以说整个文件系统结构就被破坏了。 (补充:还能通过超级块中的成员数据间接找到文件所在扇区的 LBA 地址)

// readdir.c
#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) { // 系统调用,自行查阅
// Skip the "." and ".." directory entries
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,拿着文件名要找 inode 就需要访问它的上级目录内 data block 中的文件名-inode 映射关系,而这个上级目录也是一个文件,要访问它有需要拿到它的 inode,而它的 inode 又在更上级的目录中…直到打开根目录为止,这个过程类似递归。所以我们访问要 linux 下任意一个文件,都需要从根目录开始,对该文件上的所有目录进行解析,也就是依次打开路径上的所有目录(如 /home/fdb:打开根目录,拿着 home 文件名就能在根目录中找到 home 的 inode,就能打开 home 了…依此类推),这个过程就叫做路径解析。 这就是为什么打开一个文件、访问文件必须要有该文件路径的原因,也是为什么 PCB 中会维护 cwd(当前进程的工作路径)的原因。
我们学习了路径解析后,我相信大家和我都有一个疑问,每访问一个文件都要做路径解析,这样是不是太慢了啊?所以 linux 还会支持一个名叫路径缓存的功能。 在 linux 系统中,当用户访问指定路径下的文件时(包括路上目录,最终的目标文件在内),linux 会在进路径解析的过程中,在内核里形成目录树和路径缓存,在整个树形结构中,普通文件和空目录就是叶子结点。

文件描述符 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,所以最上层就能通过文件描述符访问文件的内容和属性了。
在 linux 中,还有两种链接文件:软连接和硬链接。在讲解原理之前,我们先认识一下操作,如何用指令创建这两种链接文件:


我们看上图会发现软链接是一个独立的文件,而硬链接不是一个独立的文件。
软链接的文件内容是该链接文件指向文件的路径字符串,例如 test-soft 的文件内容就是 test.c 文件的路径字符串。
软链接:
硬链接:
为什么新建的普通文件硬链接数默认为 1?因为只有一组文件名和 inode 的映射关系。
为什么新建的目录文件(空目录)硬链接数默认为 2?因为目录不仅有目录名和 inode 的映射关系,目录里还有一个隐藏目录文件 . 表示当前目录,所以还有一组 . 和 inode 的映射关系。当我们在该目录下再创建一个目录文件时,目录的硬链接数会变为 3,因为新建的目录文件里会有一个隐藏目录文件 .. 指向当前文件。

那为什么系统却允许的 . 和 .. 的存在呢,它们不就是对目录的硬链接吗?这其实是系统的一种特殊处理,系统通过底层逻辑避免了环路风险。

微信公众号「极客日志」,在微信中扫描左侧二维码关注。展示文案:极客日志 zeeklog
将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online
将 Markdown(GFM)转为 HTML 片段,浏览器内 marked 解析;与 HTML 转 Markdown 互为补充。 在线工具,Markdown 转 HTML在线工具,online
将 HTML 片段转为 GitHub Flavored Markdown,支持标题、列表、链接、代码块与表格等;浏览器内处理,可链接预填。 在线工具,HTML 转 Markdown在线工具,online
通过删除不必要的空白来缩小和压缩JSON。 在线工具,JSON 压缩在线工具,online
将JSON字符串修饰为友好的可读格式。 在线工具,JSON美化和格式化在线工具,online