【Linux】磁盘中的文件与 Ext 文件系统

发布时间:2026/7/16 5:10:22
【Linux】磁盘中的文件与 Ext 文件系统 本篇文章主要讲解在 Linux EXT2 文件系统包括磁盘的逻辑结构、LBA 地址同时我们会理解路径的重要性。看完相信你会对 Linux 文件系统有更加深刻的理解。 如果想要查看 Linux 2.6.18 版本的内核源代码请去作者主页资源中获取。 如果想要查看内存中的文件请查阅这篇文章【Linux】内存中的“文件”-CSDN博客1 认识磁盘磁盘属于永久性数据存储介质凭借其容量大、性价比高的优势长期作为大数据场景下的主流存储设备。但磁盘依靠机械结构运作I/O 读写速度偏低。如今个人电脑普遍选用固态硬盘 SSD其读写速率远超传统磁盘唯一缺点就是单位存储成本更高因此成为小体量数据存储的首选。而现在的互联网企业需要承载海量数据存储需求对存储容量与成本控制要求更高故而服务器依旧以传统磁盘作为主流存储介质。Linux 系统多用于服务器场景因此我们讲解 Linux 文件系统便以磁盘作为核心存储载载体展开。我们对于磁盘的讲解会先从磁盘的物理结构讲起了解什么是磁盘的CHS定址法之后一步步将磁盘抽象为逻辑结构进而了解什么是磁盘的LBA地址后面所讲解的文件系统也是基于磁盘的逻辑结构展开的。1.1 磁盘的物理结构磁盘是一种机械设备其内部组成如图所示图一 磁盘的物理结构磁盘会由 4 大块组成分别是存储数据的盘片以及读取数据的磁头控制磁头移动的永磁体以及控制盘片旋转的主轴马达。磁盘在 I/O 的过程中主轴马达会带着盘片高速旋转可以达到 120 转/秒然后永磁铁会带着磁头左右摆动注意磁头与盘面不直接接触磁盘 I/O 过程如图所示图二 磁盘的 I/O 过程存储数据的盘片又分为上下两个盘面即如果有 4 个盘片那就是 8 个盘面然后每一个盘面都会配有一个磁头在 I/O 过程中磁头是共进退的。其中对于每个盘面来说又由许多的同心圆构成每个同心圆称为一个磁道然后每个磁道又由许多扇区构成如图一所示。其中扇区是磁盘 I/O 的基本单位大小一般为 512 B。需要注意的是虽然内圈与外圈的磁道长度是不同的但是扇区的个数一般都是相同的。由于 I/O 过程中磁头是共进退的所以在同一时间内所有磁头都只能定位到不同盘面的同一个磁道而如果我们将所有竖着看磁盘那么上下所有盘面的磁道叠起来就很像一个圆柱体所以我们又将所有盘面的同一磁道成为一个柱面如图一所示。根据磁盘的物理结构我们可以算出磁盘的容量 磁头数 * 每面的磁道柱面数 * 每个磁道的扇区数 * 每个扇区的容量。1.2 CHS 定址法扇区是磁盘 I/O 的基本单位磁盘上有很多扇区所有磁盘 I/O 时必然要先定位到一个具体的扇区才可以。那么磁盘是如何定位到具体的一个扇区的呢其实一共就分为三步选择一个柱面cylinder由于所有磁头是共进退的所以选择柱面其实就是定位磁道的过程选择一个磁头header选择磁头就是选择一个具体的盘面的过程选择一个扇区sector通过上面三步我们就可以定位到一个具体的扇区了由于定位的顺序为 cylinder - header - sector所以该定位方法又称为 CHS 定址法之所以叫做定址是因为在磁盘中会对柱面、磁头和扇区进行编号柱面和磁头一般从 0 开始扇区一般从 1 开始定位过程中其实定位的编号即柱面、磁头、扇区的地址。早期的 CHS 定址法是十分有效的因为知道 CHS 地址就可以直接定位到一个具体的扇区。但是CHS 定址法可以表示的磁盘容量是有限的一般使用 10 bit 来表示一个柱面的地址用 8 bit 来表示一个磁头的位置用 6 bit 来表示一个扇区的地址那么可以定位的扇区个数 1024 * 256 * 63这里为 63 是因为扇区从 1 开始编号所以 6 位可以表示的扇区地址为 1 ~ 63 16,515,072假设一个扇区容量为 512B那么 CHS 地址可以表示的磁盘最大容量就是 7.875 GB后面随着磁盘容量的不断扩大CHS 定址法就不够用了所以后面人们又将磁盘抽象储了其逻辑结构进而提出了 LBA 定址法。1.3 磁盘的逻辑结构磁盘是以扇区为基本寻址单位来进行 I/O 的所以在对磁盘进行逻辑结构抽象时我们也以扇区为一个基本寻址单位来进行抽象。首先一个一个的扇区构成了一条磁道而一个磁道是一个圆如果我们将这个圆从中间切开然后将其拉直那么一个磁道就变成了一个线性结构其中我们可以将扇区的编号看作是数组下标所以整个磁道其实就是一个一维数组。而如果我们将磁盘看作是由很多柱面组成一个柱面由有上下很多个磁道组成我们将这些磁道都展开成一维数组那么整个柱面其实就变成了二维数组而磁盘又是由很多柱面组成的如果我们将每个柱面都变为这种结构其实整个磁盘就是一个三维数组所以说磁盘其实就是一个三维数组 sector array[i][j][k]其中 i 下标用来定位柱面j 下标用来定位磁道其实就是定位磁头k 下标用来定位一个具体的扇区我们可以惊人的发现这三个下标不就是 CHS 定址法中三个地址而且前后顺序都是一致的。而我们在 C/C 中知道不管是二维、三维还是多维数组其在内存中存储时其实都是一维数组只不过是每个位置的下标跳过的字节数不一样罢了。所以我们可以对磁盘的三维数组逻辑结构进行进一步的抽象将其变为一维数组经过进一步的抽象之后我们就将磁盘转化为了一个一维数组。经过上述对于磁盘的抽象过程我们得到了磁盘最终的一个逻辑结构一维数组也是一个一维线性结构后面我们就可以基于这个逻辑结构来提出磁盘的 LBA 定址法了。1.4 LBA 定址法LBA 的全称为 logical block address中文全称为逻辑块地址其是根据磁盘的逻辑结构提出的。在上一小节中我们可以得到磁盘的逻辑结构其实就是一个一维数组所以 LBA 地址其实就是磁盘这个一维数组的下标。那么 LBA 地址相比 CHS 地址具有很多优势。1LBA 地址其实就是用一个整形数字来进行寻址这样上层各种软件在进行磁盘 I/O 时只需要给一个整形数字下层负责进行转换这样就避免了上层使用复杂的 CHS 地址2上层在进行 I/O 时不管底层是磁盘还是 SSD上层软件给操作系统的都是 LBA 地址不用管底层硬件具体是什么底层硬件根据 LBA 地址自己来转化为具体的物理地址如果是使用 CHS 地址还需要根据底层硬件的差异来进行区分。所以使用 LBA 地址可以使得上层软件与底层硬件解耦3对于 CHS 寻址法其可以表示的磁盘容量是有上限的但是 LBA 地址仅仅是一个线性地址从 0 开始编号其可以表示的容量上限大大多于 CHS 地址。比如 LBA 地址为 28 位那么其可以表示的容量上限就是 2^28 * 512B ≈ 137GB。所以使用 LBA 地址就可以突破 CHS 地址的容量上限适配超大容量磁盘或者 SSD。1.5 LBA 地址与 CHS 地址的转化LBA 地址只是一个逻辑地址在上层软件之间相互通用但是到了具体的底层硬件磁盘这里他是不认识 LBA 地址的其只认识 CHS 地址所以要想使用 LBA 地址还必须将其转化为具体的 CHS 地址。那么如何将 LBA 转化为具体的 CHS 地址呢其实很简单LBA 地址是由关于扇区的一维数组下标嘛而这个一维数组是由很多个磁道和柱面拼起来的之前我们也说过如果将磁盘看作是一个三维数组其三个下标就是磁盘的 CHS 地址。所以LBA 转化为 CHS 地址的核心就是将一维数组下标转化为三维数组下标。具体的转化过程如下CLBA / (磁头数 * 每个磁道的扇区数) [后面计算的其实就是一个柱面的扇区数量]H(LBA % (磁头数 * 每个磁道的扇区数)) / 每个磁道的扇区数S(LBA % 每个磁道的扇区数) 1 [最后 1 是因为磁道编号从 1 开始]这样 LBA 就可以转化为 CHS 地址了但是磁盘通过 CHS 地址找到扇区之后取出里面的数据还需要交给操作系统啊但是操作系统里面用的是 LBA 地址所以 CHS 地址还需要转化为 LBA 地址。转化过程如下LBA C * (每个柱面的扇区数) H * (每个磁道的扇区数) S - 1所以 LBA 地址是可以和 CHS 地址相互转化的那么在操作系统内部也就不需要 CHS 地址了只要拿着 LBA 地址就可以访问磁盘的任意一个扇区了也就是拿着一个整形数字就可以访问磁盘了。2 EXT2 文件系统在 Linux 中文件系统有很多比如 ext2、ext3、ext4这里我们主要以比较简单的 ext2 为例来讲解 Linux 系统中具体的文件系统。2.1 操作系统中的 块经过上面对于磁盘的讲解我们知道磁盘是以扇区为 I/O 基本单位的一个扇区一般为 512B但是操作系统认为一个扇区太小了以扇区为基本 I/O 单位需要进行多次 I/O效率很低。所以在 OS 中又将多个连续的扇区合并为一个块将这个块作为 OS I/O 的基本单位即一个块的大小是由操作系统格式化磁盘格式化就是给磁盘写入具体的文件系统时确定的一旦确定无法更改。一般一个块是由8 个扇区组成的也就是4KB我们也可以通过stat命令来查看一个文件 I/O 时的块为多大stat [文件名]可以看到 IO Block 为 4096B正好为 4KB。这里需要强调的一点是块是 OS I/O 的基本单位是 OS 层面的概念并不是磁盘所具有的概念磁盘还是以扇区为 I/O 基本单位只不过 OS 一次加载时会将 8 个扇区也就是一个块加载进入内存。正是因为 OS 访问磁盘以块为单位所以磁盘在 OS 上被叫做块设备。那么上面我们说 OS 都是使用 LBA 地址来访问磁盘的那么 LBA 地址和块号之间是否可以相互转化呢当然是可以的因为一个块就是 8 个扇区嘛。具体的转化如下块号 LBA / 8。比如 LBA 6 的扇区所在块号为 6/8 0LBA 块号 * 8 [0 - 7] 偏移量。比如一个在块 6 内偏移量为 2 的扇区其 LBA 6 * 8 2 50所以块号、LBA、CHS 都可以进行相互转化在 OS 系统内不仅是使用块号还是 LBA 地址都可以进行磁盘的访问了。2.2 操作系统中的 分区一个磁盘所能存储的数据很多可能是 800GB也有可能是 1TB甚至是 10TB、12TB 都有可能。一块磁盘那么大操作系统如果对整个磁盘进行管理不仅管理起来麻烦而且很容易出错所以操作系统就将整个磁盘划分为了一个一个的分区只要操作系统对一个分区管理好了那么将该分区的管理方式复制到另一个分区上就可以管理好所有的分区了管理好所有的分区也就将整个磁盘管理好了。比如在 Windows 系统中你可能会看到资源管理器中有 C盘、D盘、E盘等等这些其实就是分区。C、D、E 盘并不是 3 块磁盘或者 SSD而是对一块磁盘或者 SSD 进行了分区C、D、E 各是一个独立分区每一个分区内部都有一个独立的文件系统而对 C、D、E 盘进行格式化的过程其实就是对对应分区重新写入文件系统所以格式化后数据就会全部消失。那么在操作系统中是如何对磁盘进行分区划分的呢其实就是确定一个分区中起始块号与结束块号即确定 [start, end] 区间就可以了。因为一个块内部包含的扇区个数是固定的而一个扇区的大小也是固定的所以只要确定了分区的起始与结束块号就可以确定一个分区的大小了。但是对于操作系统来说一个分区还是太大了就比如将一个 1TB 的磁盘划分为 3 个分区可能是 400GB、400GB、224GB。所以为了更加方便管理磁盘OS 又将分区划分为一个一个的块组以下简称为组一般一个组内部的块数量是相同的。所以在 OS 内部磁盘的逻辑结构其实是将磁盘划分为了很多块然后很多块组合成为一个组很多组又组合成为一个分区很多分区最终构成了整个磁盘其中OS 只要将一个组管理好就可以管理好整个分区只要将一个分区管理好就可以管理好整个磁盘那么管理好一个组不就是管理整个磁盘的子问题吗只要将子问题解决那么整个问题也就随之解决了所以 OS 的文件系统其实体现了分治的算法思想。2.3 文件属性 inode文件 文件属性 文件内容但是不管是文件属性还是文件内容都属于数据都是存储在磁盘上面的。通过 2.1我们知道了 OS 与磁盘 I/O 的基本单位是块一个块等于 8 个扇区所以在不管是文件属性还是文件内容都是存储在块中的那么我们如何区分哪些是块存储文件内容哪些块存储文件属性呢首先Linux 文件系统中对于文件属性和文件内容是分开存储的即存储文件属性和文件内容的块是分离的有些块是专门存储文件属性的有些块是专门存储文件内容的并且我们可以通过文件属性内容来找到存储文件内容的块这样 OS 只需要找到对应的文件属性块就可以了那么文件属性块又如何找到呢对于这个问题我们需要先了解一下 Linux 内核中存储文件属性的结构体是什么。在 Linux 系统中描述一个文件属性的结构体为strcut inode这里以 ext2_inode 为例#define EXT2_NDIR_BLOCKS 12 #define EXT2_IND_BLOCK EXT2_NDIR_BLOCKS #define EXT2_DIND_BLOCK (EXT2_IND_BLOCK 1) #define EXT2_TIND_BLOCK (EXT2_DIND_BLOCK 1) #define EXT2_N_BLOCKS (EXT2_TIND_BLOCK 1) struct ext2_inode { __le16 i_mode; /* File mode */ __le16 i_uid; /* Low 16 bits of Owner Uid */ __le32 i_size; /* Size in bytes */ __le32 i_atime; /* Access time */ __le32 i_ctime; /* Creation time */ __le32 i_mtime; /* Modification time */ __le32 i_dtime; /* Deletion Time */ __le16 i_gid; /* Low 16 bits of Group Id */ __le16 i_links_count; /* Links count */ __le32 i_blocks; /* Blocks count */ __le32 i_flags; /* File flags */ union { struct { __le32 l_i_reserved1; } linux1; struct { __le32 h_i_translator; } hurd1; struct { __le32 m_i_reserved1; } masix1; } osd1; /* OS dependent 1 */ __le32 i_block[EXT2_N_BLOCKS];/* Pointers to blocks */ __le32 i_generation; /* File version (for NFS) */ __le32 i_file_acl; /* File ACL */ __le32 i_dir_acl; /* Directory ACL */ __le32 i_faddr; /* Fragment address */ union { struct { __u8 l_i_frag; /* Fragment number */ __u8 l_i_fsize; /* Fragment size */ __u16 i_pad1; __le16 l_i_uid_high; /* these 2 fields */ __le16 l_i_gid_high; /* were reserved2[0] */ __u32 l_i_reserved2; } linux2; struct { __u8 h_i_frag; /* Fragment number */ __u8 h_i_fsize; /* Fragment size */ __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; /* Fragment number */ __u8 m_i_fsize; /* Fragment size */ __u16 m_pad1; __u32 m_i_reserved2[2]; } masix2; } osd2; /* OS dependent 2 */ };inode 全称为 index node中文名称就叫做索引节点在 struct inode 中有很多属性字段比如 i_mode 是文件属性i_size 是文件大小i_atime、i_ctime、i_mtime 就是 ACM 时间而在 inode 中最重要的一个字段是i_block[EXT2_N_BLOCKS]其中 EXT2_N_BLOCKS 是一个宏其值为 15。这个 i_block 中就存储着该文件存储内容的块地址即 i_block 就是 inode 和其内容块的映射表所以 OS 就可以根据 inode 结构体找到对应的内容块这也是属性结构体被称为 inode 的原因。inode 包含了一个文件的所有属性所以一个文件只会有一个 inode 结构体一个 inode 结构体的大小为 128B 或者 256B即一个块可以存储 32 或者 16 个 inode 结构体操作系统以块为基本 I/O 单位所以 OS 在加载文件属性时一次会加载 32 或者 16 个 inode并不会一个一个加载。但是在 inode 中存在一个问题那就是 i_block 只有 15 个元素也就是一个 inode 只能映射 15 个块啊一个块为 4KB那么说明文件内容最多为 60KB那么 GB 以及 TB 级别的文件怎么办呢其实在 i_block 中前 12 个元素是正常映射但是后三个分别是一级、二级、三级映射表那么一个索引表最多可以映射多少个块呢我们这里以一个块 4KB也就是 4096B一个块号是 4B 来算前 12 个就是映射 12 个块1 级索引表可以找到一个间接索引块一个间接索引块可以找到 4096/4 1024 个块所以 1 级索引表可以找到 1024 个块2 级索引表可以找到 1024*1024 1,048,576 个块3 级索引表可以找到 1024 * 1024 * 1024 1,073,741,824 个块最终加起来就是1,074,791,436 个块一个块是 4KB那么最终可以存储的文件内容大小算下来就是 4PB 左右肯定是足够了。回到上面的问题那么 OS 如何找到文件属性即文件 inode 的块呢其实是根据每个文件 inode 编号inode number找到的这个 inode 编号是为了标识每一个文件 inode 唯一性而设置的我们可以在 ls 中携带 -i 属性来查看到文件的 inode 编号前面的一串编号就是文件的 inode 编号OS 就可以根据该编号找到文件的 inode 块将对应的块加载进内存然后再根据 i_block 字段找到内容块就可以将整个文件加载进入内存了。那么这个 inode 编号又如何找到呢而且我们发现 inode 中并没有 char* 字段也就是文件名并不会存在 inode 中那么文件名存在哪呢这些问题都是一个问题那就是目录文件的内容是什么2.4 路径解析对于上一小节的问题我们需要先解决一个问题目录文件的文件内容是什么我们都知道在 Linux 下一切皆文件所以目录也是一个文件即目录文件 文件属性 文件内容文件属性与普通文件是一样的都存储在 inode 中而目录文件的文件内容保存的正是文件名和 inode 编号的映射关系。我们可以通过下面这段代码验证一下#include iostream #include cstring #include stdlib.h #include sys/types.h #include dirent.h #include unistd.h int main(int argc, char* argv[]) { if (argc ! 2) { std::cerr Usage: argv[0] dirname std::endl; return 1; } //1. 利用 opendir 打开一个目录 DIR* dir opendir(argv[1]); if (dir nullptr) { std::cerr lt;lt; direrror! lt;lt; std::endl; return 2; } //2. 利用 readdir 读取目录下的内容 struct dirent* entry; while ((entry readdir(dir)) ! nullptr) { if (strcmp(entry-gt;d_name, .) 0 || strcmp(entry-gt;d_name, ..) 0) continue; std::cout lt;lt; filename: lt;lt; entry-gt;d_name lt;lt; -gt;inode: lt;lt; entry-gt;d_ino lt;lt; std::endl; } //3. 使用 closedir 关闭目录 closedir(dir); return 0; }其中用到了三个库函数opendir、readdir、closedir分别用来打开、读取、关闭一个目录这三个函数都以 DIR 指针作为句柄运行结果可以看到打开一个文件里面存储的内容确实是文件名和 inode 的映射关系。所以文件的文件名没有存在 inode 中而是存在文件所在目录文件的文件内容中OS 也并不是以文件名作为键值来打开文件的而是通过文件名找到对应的 inode 编号进而使用 inode 编号来将所有文件加载到内存之中。所以为什么打开一个文件需要路径呢就是因为打开一个文件需要使用文件的 inode 编号而 inode 编号存储在其所在目录的文件内容中要想打开该文件就必须先将其所在的目录文件打开但是目录文件也是一个文件啊打开这个目录文件就必须先打开其所在的目录文件如此循环直到打开根目录而根目录的 inode 号在编写系统时就确定了一般是 2打开时不用去查找 inode 号就可以直接打开打开了根目录之后按照上面的流程就可以打开文件了该过程就称为路径解析。例如打开如下这个文件打开 mystdio就必须先打开 buffer找到 mystdio 文件名与 inode 号的映射关系而要打开 buffer就必须先打开 linux要打开 linux就必须先打开 code要打开 code就必须先打开 ltl要打开 ltl就必须先打开 home要打开 home就必须先打开 /而 / 可以直接打开。所以文件路径存在的主要意义不是为了定位一个文件而是根据该路径一路解析知道找到文件的所在目录打开目录找到文件的 inode 号从而打开文件。那么这样看来打开每一个文件都必须从根目录开始解析啊原则上是这样的但是 OS 防止每一次打开文件都从根目录开始解析拖慢运行效率设计了路径缓存。2.5 路径缓存为了防止每次打开文件都从根目录开始解析Linux 系统设计了路径缓存的机制即会将已经打开过的文件属性缓存在内存中下次打开文件直接查找内存就不用每次都从磁盘中进行加载了。实现路径缓存机制的核心结构体为 struct dentrystruct dentry { atomic_t d_count; unsigned int d_flags; /* protected by d_lock */ spinlock_t d_lock; /* per dentry lock */ struct inode *d_inode; /* Where the name belongs to - NULL is * negative */ /* * The next three fields are touched by __d_lookup. Place them here * so they all fit in a cache line. */ struct hlist_node d_hash; /* lookup hash list */ struct dentry *d_parent; /* parent directory */ struct qstr d_name; struct list_head d_lru; /* LRU list */ /* * d_child and d_rcu can share memory */ union { struct list_head d_child; /* child of parent list */ struct rcu_head d_rcu; } d_u; struct list_head d_subdirs; /* our children */ struct list_head d_alias; /* inode alias list */ unsigned long d_time; /* used by d_revalidate */ struct dentry_operations *d_op; struct super_block *d_sb; /* The root of the dentry tree */ void *d_fsdata; /* fs-specific data */ #ifdef CONFIG_PROFILING struct dcookie_struct d_cookie; / cookie, if any / #endif int d_mounted; unsigned char d_iname[DNAME_INLINE_LEN_MIN]; / small names */ };可以看到在 struct dentry 中 struct inode*d_inode 属性指向该文件对应的 struct inode 结构体还有 struct qstr d_name 字段可以找到对应的文件名。在 dentry 结构体中还存在 struct dentry *d_parent 和 struct list_head d_child 选项所以一个 dentry 结构还有其父节点与孩子节点那么所有的 dentry 结构就构成了一棵多叉树整个 dentry 多叉树结构就是 Linux 中的路径缓存。那么整棵树的叶子节点就是普通文件或者空目录了。有了路径缓存机制下次内核再进行路径解析时会先从路径缓存中查找对应的 struct dentry 结构当每次打开一个新文件内核就会在内存中为其创建一个 struct dentry 结构体然后将其挂接在路径缓存多叉树的对应位置中。另外在 struct dentry 结构中还有两个字段分别是 d_hash 和 d_lru说明 dentry 结构不仅属于多叉树还属于哈希表这一数据结构方便快速查找而且当哈希表和多叉树中如果如果 dentry 结构太多就会采用 LRU即最近最少使用算法优先保留最近使用的淘汰掉最久未被使用的 dentry 结构。2.6 具体的文件系统经过上面 5 个小节的讲解我们已经了解了 OS I/O 的基本单位块以及组和分区的概念了也了解文件属性的结构体 struct inode 以及对目录文件的理解也知道了为什么查找或者打开文件必须携带路径以及 Linux 中的路径缓存机制接下来我们就来了解一下真正的文件系统。如果将一个磁盘直接插入你的电脑是没法直接使用的必须在格式化之后才可以使用而格式化的过程就是写入文件系统的过程只有格式化之后OS 才能借助文件系统来阻止和管理磁盘中的文件。具体的 ext2 文件系统如图所示其中在分区前有一个 MBR其全称为 Master Boot Record中文名称为主引导记录其是 LBA 地址中的 0 号扇区即磁盘中的第一个扇区共 512B采用 446 64 2 的布局结构前 446B 主要是主引导代码中间得到 64B 是分区表用来记录分区等系统信息最后两个字节是记录 MBR 是否有效的标志位。值得注意的是每个分区表项固定占 16B所以一个 MBR 最多记录 4 个分区的信息。其中文件系统是存在于一个具体分区之下的即一个分区拥有一个文件系统在每一个分区中具体的文件系统信息之前还会有一个 VBR其全称为 Volume Boot Record中文全称为卷引导记录也可以简称为 Boot Sector其主要功能与 MBR 类似就是保存分区引导代码与文一些文件系统基本参数大小是固定的为 1KB。然后在每一个分区之下文件系统又将整个分区划分为了不同的块组这也就是我们在 2.2 中讲解过的分治思想的体现只需要将一个组管好那么整个磁盘就管好了所以我们就只要了解一个组是如何被具体的管理就可以了。接下来我们就来了解一下块组内部的各个组成部分及其作用是什么。2.6.1 超级块Super BlockSuper Block 主要是用来存放整个文件系统本身的信息比如 block 与 inode 的总量未使用的 block 与 inode 的数量以及整个文件系统有多大以下是 ext2 文件系统 super block 的内核源码struct ext2_super_block { __le32 s_inodes_count; /* Inodes count */ __le32 s_blocks_count; /* Blocks count */ __le32 s_r_blocks_count; /* Reserved blocks count */ __le32 s_free_blocks_count; /* Free blocks count */ __le32 s_free_inodes_count; /* Free inodes count */ __le32 s_first_data_block; /* First Data Block */ __le32 s_log_block_size; /* Block size */ __le32 s_log_frag_size; /* Fragment size */ __le32 s_blocks_per_group; /* # Blocks per group */ __le32 s_frags_per_group; /* # Fragments per group */ __le32 s_inodes_per_group; /* # Inodes per group */ __le32 s_mtime; /* Mount time */ __le32 s_wtime; /* Write time */ __le16 s_mnt_count; /* Mount count */ __le16 s_max_mnt_count; /* Maximal mount count */ __le16 s_magic; /* Magic signature */ __le16 s_state; /* File system state */ __le16 s_errors; /* Behaviour when detecting errors */ __le16 s_minor_rev_level; /* minor revision level */ __le32 s_lastcheck; /* time of last check */ __le32 s_checkinterval; /* max. time between checks */ __le32 s_creator_os; /* OS */ __le32 s_rev_level; /* Revision level */ __le16 s_def_resuid; /* Default uid for reserved blocks */ __le16 s_def_resgid; /* Default gid for reserved blocks */ /* * These fields are for EXT2_DYNAMIC_REV superblocks only. * * Note: the difference between the compatible feature set and * the incompatible feature set is that if there is a bit set * in the incompatible feature set that the kernel doesnt * know about, it should refuse to mount the filesystem. * * e2fscks requirements are more strict; if it doesnt know * about a feature in either the compatible or incompatible * feature set, it must abort and not try to meddle with * things it doesnt understand... */ __le32 s_first_ino; /* First non-reserved inode */ __le16 s_inode_size; /* size of inode structure */ __le16 s_block_group_nr; /* block group # of this superblock */ __le32 s_feature_compat; /* compatible feature set */ __le32 s_feature_incompat; /* incompatible feature set */ __le32 s_feature_ro_compat; /* readonly-compatible feature set */ __u8 s_uuid[16]; /* 128-bit uuid for volume */ char s_volume_name[16]; /* volume name */ char s_last_mounted[64]; /* directory where last mounted */ __le32 s_algorithm_usage_bitmap; /* For compression */ /* * Performance hints. Directory preallocation should only * happen if the EXT2_COMPAT_PREALLOC flag is on. */ __u8 s_prealloc_blocks; /* Nr of blocks to try to preallocate*/ __u8 s_prealloc_dir_blocks; /* Nr to preallocate for dirs */ __u16 s_padding1; /* * Journaling support valid if EXT3_FEATURE_COMPAT_HAS_JOURNAL set. */ __u8 s_journal_uuid[16]; /* uuid of journal superblock */ __u32 s_journal_inum; /* inode number of journal file */ __u32 s_journal_dev; /* device number of journal file */ __u32 s_last_orphan; /* start of list of inodes to delete */ __u32 s_hash_seed[4]; /* HTREE hash seed */ __u8 s_def_hash_version; /* Default hash version to use */ __u8 s_reserved_char_pad; __u16 s_reserved_word_pad; __le32 s_default_mount_opts; __le32 s_first_meta_bg; /* First metablock block group */ __u32 s_reserved[190]; /* Padding to the end of the block */ };其中的 s_inodes_count 与 s_blocks_count 就是当前文件系统内有多少个 inode 与 block而 s_free_inodes_count 与 s_free_blocks_count 就是还剩多少 inode 和 block 没用。其中有两个比较关键的字段s_inodes_per_group 和 s_blocks_per_group这两个字段表示每个块组内部有多少个 inode 和 data block这两个字段是固定的说明每个块组内部的 inode 和 data block 是相同的这样 inode 号和 data block 块号就可以在全分区内有效了因为在一个块组的 GDT 中会记录当前块组存储 inode 与 data block 的起始块号。使用全局 inode 转为一个块组内的 inode 序号以及查找具体 inode 结构体的过程为首先计算全局 inode 号所在的块组即 group_number (inode_number - 1) / s_inodes_per_group inode - 1 是因为 inode 从 1 开始而块号和块内序号是从 0 开始计算块组内偏移量即块组 inode 序号index (inode_numver - 1) % s_inodes_per_group在 GDT 中有一个关键字段为 bg_inode_table 字段其记录了当前块组的 inode table 从哪个块号开始而一个块可以承载 32 个 inode所以算出了 index 之后就可以算出具体所在的 inode 块即 floor(index / 32)floor 表示向下取整计算块内偏移bias index % 32然后使用 bias * 128 即可算出 inode_number 所在块的具体字节地址处从块组内 inode 序号转变为全局 inode 号就更简单了全局 inode 号 inode 序号 块组号 * s_inodes_per_group。数据块号也是全局有效的而且其转变更为简单只要用 (data_block_number - s_first_data_block) / s_blocks_per_group s_first_data_block 在 super block 中含义为第一个有效的数据块号就可以算出数据块所在的块组用 (data_block_number - s_first_data_block) % s_blocks_per_group 就可以算出块组内数据块序号了。那么这里就会产生一个疑问那就是 super block 明明是描述整个文件系统信息的数据为什么存在块组中而不是存在分区中的原因就是为了防止存储 super block 的部分磁盘损坏后整个分区无法使用的原因。super block 不仅仅会在第一个块组中出现而且会在很多块组中存有备份一旦一个 super block 数据丢失那么还可以利用其他的 super block 来恢复整个文件系统的信息。2.6.2 块组描述符表GDTGDT 全称为 Group Discriptor Table中文名称为块组描述符表以下是 ext2 文件系统 GDT 的内核源码struct ext2_group_desc { __le32 bg_block_bitmap; /* Blocks bitmap block */ __le32 bg_inode_bitmap; /* Inodes bitmap block */ __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 主要就是用来描述一个块组相关信息的bg_block_bitmap、bg_inode_bitmap、bg_inode_table 分别表示 block bitmap、inode bitmap、inode table 的起始块号至于为什么没有 bg_data_block是因为在文件系统中其数据是连续存放的data block 就在 inode table 的后面有了 bg_inode_table 和 s_inodes_per_group 就可以算出具体的 inode 块数量那么当前块组后面的块就都是 data block 了。2.6.3 inode bitmap 与 inode table这两个是跟存储文件属性相关的字段inode bitmap 是一个位图用来快速查找哪个 inode 序号已经被使用空闲的 inode 序号有哪些inode table 就是真正存储 struct inode 的块了。之前已经讲解过很多了这里就不再赘述。2.6.4 block bitmap 与 data blocks这两个是跟存储文件内容相关的字段block bitmap 是一个位图用来快速查找哪些块已经被使用data blocks 中有很多块用来真正存储文件内容。2.7 分区挂载通过上面的了解我们已经可以通过一个分区内部的 inode 号来查找当前文件的 inode 以及 data block 了但是 inode 是全分区有效的并不是全磁盘有效而且 Linux 可能具有多个分区那么每个分区内部的 inode 号不同但是分区之间的 inode 号可以相同啊我使用一个 inode 号如何知道我是在哪个分区下呢所以就有了分区挂载的概念。如果你自己新创建了一个分区那么该分区是无法使用的必须将其挂载到一个指定目录下才可以使用我们可以使用如下命令来查看分区挂载的具体目录df -h其中 /dev/vda2 就是云服务器的虚拟磁盘的第二个分区我们这里就将其看作磁盘第二分区。其中 Mounted on 就是这个分区挂载的具体目录可以看到其是挂载到根目录下的所以后面如果在根目录后面创建的文件都会使用这个分区。我们也可以看到别的分区 tmpfs这个分区是 OS 在内存创建的一个虚拟文件系统用来存放一些临时数据其不像 /dev/vda2 一样会永久保存数据断电就会丢失数据。如果你后来创建了一个具体的分区可以使用 mount 和 umount 命令将其挂载或者取消挂载到一个指定的目录那么以后在你指定的目录下创建的文件就都会使用你新创建的分区了。比如你新创建了一个分区 mydisk挂载到了 /home/ltl 目录下那么以后在 /home/ltl/ 目录下创建的文件就会使用 mydisk 这个分区如 /home/ltl/code在 / 别的目录下创建的文件会使用 /dev/vda2 这个分区。所以如何确定 inode 号位于哪个分区呢就是根据文件路径来的每个分区都会挂载到一个指定目录下OS 会采用类似于前缀匹配的方式确定文件的具体分区只要是在 / 下创建的文件就都是使用 /dev/vda2 这个分区。2.8 文件系统总结了解了上述文件系统相关信息之后可以说出文件的增删查改本质上是在干什么吗首先在增删查改之前都必须先跟据这个文件的路径进行路径解析查看文件路径的所有文件是否已经在路径缓存即 dentry 目录树中如果没有为该文件创建一个 struct dentry 结构并且挂接到 dentry 树的对应位置。进行路径解析之后便找到了文件或者其所在目录文件的 inode 号。如果是创建即增加文件首先根据路径解析找到其父目录的 inode 号将其父目录的 struct inode 加载进内存查看是否有读写权限没有就创建失败之后根据 inode bitmap查找一个未被使用的 inode 号分配给新创建的文件并且 inode bitmap 对应位置由 0 置 1然后创建 struct inode 结构体填写相关信息同时修改所在目录文件的文件内容新增新建文件名和 inode 号的映射关系和 inode为新创建的文件创建 struct dentry 结构体最后将所有修改的内容写回磁盘。如果是删除文件那就更简单了只需要进行路径解析找到文件的 inode 号然后根据 inode 号查询对应块组的 inode bitmap 和 block bitmap 对应位置内容是否是1如果是那就由 1 置 0就完成了文件的删除。剩下的修改和查找文件打开可以自己试一下。至此结合内存中的文件我们已经完全了解了一个文件从创建到打开的全部流程。将内存中的文件与磁盘中的文件结合起来如图所示可以看到 struct dentry 是链接内存与磁盘文件的核心struct file 中也是通过 struct dentry 进而找到文件属性的。3 软硬链接3.1 软硬链接的概念及创建方式软硬链接是 Linux 中的两种特殊文件软链接的文件类型为 l而硬链接的文件类型为 -我们可以使用如下命令创建软硬链接ln [-s] targetfilename linkname如果携带 -s(soft) 选项那就是创建软链接否则就是创建硬链接。3.2 软硬链接的区别为了搞清楚他们的区别先做一个小实验。首先在 Test 目录下新建了一个 code 目录里面有 Makefile 与 math.cc 两个文件//Makefile math:math.cc g -o $ $^ .PHONY:clean rm -f math //math.cc #include iostream using namespace std; int main() { auto add [](int x, int y){ return x y; }; int x 0,y 0; cin gt;gt; x gt;gt; y; cout lt;lt; add(x, y) lt;lt; endl; return 0; }然后在 Test 目录下新建 math 的软硬链接然后查看他们的 inode 号与 math 源文件的 inode 号可以发现硬链接的 inode 号与源文件相同但是软链接不同。所以我们可以得出他们最大的区别就是软链接是一个独立文件但是硬链接不是独立文件。软链接是一种特殊文件其文件内容保存的就是链接文件的路径字符串运行软链接其实就是运行源文件类似于 Windows 的快捷方式而硬链接不是独立文件就只是新的文件名和 inode 的映射关系其用途主要是用来做备份。因为 OS 会根据 inode 与文件名的映射关系数来确实是否删除文件当你添加一个新的硬链接之后原 inode 与文件名的映射关系多了一个所以删除源文件对硬链接没什么影响但是软链接却不能使用了因为软链接里面保存的文件路径失效了。OS 会记录 inode 与文件名的映射关系数即引用计数可以通过 ls -l 查看可以看到 hmath 的引用计数为 2那么为什么 code 目录也是 2 呢没有为 code 目录建立硬链接啊原因就是 . 和 .. 就是两个硬链接所以一个目录的引用计数天生就是 2因为有 . 的存在而如果在目录下再新建一个目录那么引用计数就是变成 3因为 .. 的存在所以我们就可以通过一个目录的引用计数来算出该目录下有多少个目录只要用引用计数减 2 就可以了还有最重要的一点就是无法对目录创建软硬链接因为会造成路径环路问题。系统中对 . 和 .. 做了特殊处理使得各种命令遇到他们的时候会自动跳过他们不会造成环路问题。总结本文系统地探讨了 Linux 文件系统的底层原理从磁盘的物理结构到操作系统对文件的管理机制构建了一个完整的知识体系。核心要点回顾磁盘基础磁盘作为主流存储介质其物理结构由盘片、磁头、柱面和扇区构成。CHS柱面-磁头-扇区定址法是早期定位扇区的方法但存在容量限制。通过将磁盘抽象为三维数组并进一步线性化为一维数组我们得到了更通用的 LBA逻辑块地址定址法实现了上层软件与底层硬件的解耦。操作系统抽象操作系统以块通常为4KB为基本I/O单位管理磁盘并将大容量磁盘划分为分区分区内再细分为块组体现了分治的管理思想。文件系统核心以EXT2为例文件系统的核心是inode索引节点它存储了文件的全部属性如权限、大小、时间戳以及指向文件数据块的指针。文件内容与属性分开存储通过inode编号唯一标识。文件查找与路径文件名与inode编号的映射关系存储在目录文件的文件内容中。因此打开文件需要进行路径解析从根目录开始逐级查找。Linux内核通过dentry目录项缓存来加速频繁访问的路径避免每次从磁盘读取。文件系统布局一个格式化的分区文件系统包含超级块 (Super Block)记录整个文件系统的元信息如inode/block总量。块组描述符表 (GDT)描述每个块组的信息。inode位图与inode表分别记录inode的使用情况和存储具体的inode结构。数据块位图与数据块分别记录数据块的使用情况和存储文件的实际内容。文件操作的本质文件的增、删、查、改本质上是操作inode位图、block位图、目录文件内容以及对应的inode和数据块。例如创建文件就是在inode位图中找一个空闲编号初始化一个inode并在其父目录的文件内容中增加一条文件名到该inode编号的记录。软硬链接硬链接多个文件名指向同一个inode不是独立文件仅增加inode的引用计数。不能对目录创建。软链接独立的文件类型其内容是对目标文件路径的引用类似于快捷方式。贯穿始终的思想整个文件系统的设计体现了分层抽象从物理CHS到逻辑LBA、分而治之分区、块组、索引与缓存inode、dentry以及通过元数据管理数据的核心思想。理解这些底层机制有助于我们更深刻地把握操作系统的运作原理并在进行系统编程、性能调优或故障排查时做到心中有数。