一. ext2 文件系统

1.1 宏观认识

所有的准备工作都已经做完,是时候认识下文件系统了。我们想要在硬盘上存文件,必须先把硬盘格式化为某种格式的文件系统,才能存储文件。(硬盘出厂时是 “空白的原始存储空间”,无法直接理解 “文件” 的概念,必须通过 “格式化” 给它套上一套 “文件管理规则(文件系统)”,才能让操作系统和用户像用 “文件夹存文件” 一样管理数据。)

格式化的 “核心规则(文件系统逻辑)” 由操作系统定义,格式化操作本身则是操作系统按照这套规则,对硬件存储介质进行 “初始化配置” 的过程 —— 简单说就是 “系统定规则,按规则改硬件” --- 格式化的规则是操作系统设定的,然后再按照规则对硬件进行操作

文件系统的目的就是组织和管理硬盘中的文件。Linux系统中,最常见的是ext2系列的文件系统。其早期版本为ext2,后来又发展出ext3ext4ext3ext4虽然对ext2进行了增强,但是其核心设计并没有发生变化,我们仍是以较老的ext2作为演示对象。

ext2 文件系统将整个分区划分成若干个同样大小的块组 (Block Group),如下图所示。只要能管理一个分区就能管理所有分区,也就能管理所有磁盘文件。

上图中 启动块(Boot Block/Sector)的大小是确定的,为1KB,由PC标准规定,用来存储磁盘分区信息和启动信息,任何文件系统都不能修改启动块。启动块之后才是ext2文件系统的开始。

要理解 “启动块之后才是 ext2 文件系统的开始”,需要先明确两个核心概念:启动块(Boot Block)的作用,以及ext2文件系统的 “空间边界” —— 简单说就是:硬盘的 “最开头一小块空间” 被用来存 “启动电脑的关键数据(启动块)”,这部分空间不属于ext2管理;而ext2文件系统的所有结构(比如管理文件的索引、实际文件数据),都从 “启动块结束的位置” 才正式开始。

1.2 Block Group

ext2 文件系统会根据分区的大小划分为数个 Block Group。而每个Block Group都有着相同的结构组成。类比政府管理各区的例子。我们接下来只需要理解,并管理好一个组,就可以实现方法的复用。

1.3 块组内部构成

接下来,我将会对块组内部构成部分进行一一讲解。

在我们所有的分组当中,基本单位是块哦,是4KB!!!

详细解释:

不是 “一组只有 4KB 的数据块”,而是 “一个数据块的大小是固定的(比如 4KB),一个块组(Block Group)里包含若干个这样的‘固定大小数据块’,以及其他管理结构(超级块、索引节点表等)”。下面分层解释清楚:

ext2/ext3/ext4 文件系统会把整个分区逻辑划分为多个 “块组(Block Group)”,目的是分散管理压力、提升效率 —— 每个块组 “自治”,都包含一套 “管理结构 + 数据存储结构”避免所有管理信息集中在一处(比如硬盘开头),导致访问热点和性能瓶颈。

每个块组的结构是:

  • 管理类结构Super Block(超级块,存文件系统整体信息)、GDT描述符表)、Block Bitmap(块位图,标记 “该块组内哪些数据块空闲”)、Inode Bitmap(索引节点位图,标记 “该块组内哪些索引节点空闲”)、Inode Table(索引节点表,存文件的元数据,比如大小、权限、所属数据块编号)。

  • 数据存储类结构Data Blocks(数据块区域) —— 这里存放 “文件的实际内容”“文件夹的目录项” 等数据。

这两者是非数据块和数据块!


数据块的大小(比如4KB8KB)是格式化时全局指定的,整个文件系统的所有数据块都用这个大小,和 “块组” 的划分没有直接关系。

举个例子:

假设格式化时指定 “数据块大小 = 4KB”,那么整个文件系统里,每一个数据块都是4KB

一个块组里可能包含1000个这样的4KB数据块(具体数量由块组大小和数据块大小决定)。

“块组” 是文件系统的 “管理分区”,把大分区拆成多个小组,每个小组自己管自己的 “数据块、索引节点”

“数据块” 是文件系统存储数据的 “最小单元”,大小是全局固定的(比如4KB),一个块组里会包含N个这样的固定大小数据块(N由块组总大小和数据块大小决定)。所以不是 “一组只有 4KB 数据块”,而是 “一个数据块 4KB,一个组里有很多个 4KB 数据块 + 管理结构”


1.3.1 Data Blocks

数据区:存放文件内容,也就是一个一个的Block。根据不同的文件类型有以下几种情况:

  • 对于普通文件,文件的数据存储在数据块中。

  • 对于目录,该目录下的所有文件名和目录名存储在所在目录的数据块中,除了文件名外,ls -l命令看到的其它信息保存在该文件的inode中。

Block按照分区划分,不可跨分区 --- 块号在分区【不是分组】内是统一编号且唯一,但在块组之间的分配是动态灵活的,在不同分区之间则完全独立编号,互不干扰。

  • 数据区(Data Blocks):数据区是文件系统中用于存储文件实际内容的区域。数据区被划分为多个固定大小的数据块(通常为4KB8KB,具体取决于文件系统类型和配置)。

  • 数据块(Data Block):数据块是数据区的基本单位,用于存储文件的实际数据。每个数据块的大小是固定的,文件系统通过块号来标识和管理这些数据块。

注意:除了Data Blocks区域内的存储单元,其他区域的块(比如管理块)不被称为数据块——数据块有明确的专属范围,仅指Data Blocks区域内用于存储文件实际内容的最小单元。

普通文件的数据存储在数据块中。

文件的元数据(如文件类型、权限、所有者、大小、时间戳等)存储在inode中。(只是inode table中的一个子集,不是数据块)

inode中包含指向数据块的指针,通过这些指针,文件系统可以找到存储文件内容的数据块。

目录文件是用于组织文件和子目录的特殊文件。目录文件的内容存储在数据块中,这些内容包括目录下的所有文件名和子目录名,以及每个文件名或子目录名对应的inode号。

目录文件的元数据(如目录的权限、所有者、大小等)存储在目录文件的inode中。

当你使用ls -l命令时,看到的文件名以外的其他信息(如权限、所有者、大小等)实际上是存储在文件的inode中的。

块号是文件系统分配给每个数据块的唯一标识符。块号是按照分区划分的,不可跨分区。每个分区的块号是独立的,不同分区的数据块不会共享块号。

假设你有一个文件系统,数据块大小为4KB,分区大小为1GB。文件系统会将这1GB的空间划分为256000个数据块。每个数据块都有一个唯一的块号,从0开始编号。

  • 如果你创建一个大小为8KB的普通文件,文件系统会分配两个数据块(块号可能是123124),并将文件内容存储在这两个数据块中。文件的inode会包含指向这两个数据块的指针。

  • 如果你创建一个目录,目录文件的内容(文件名和对应的inode号)会存储在数据块中。目录文件的inode会包含指向这些数据块的指针。

1.3.2 inode 节点表(Inode Table)

inode table(inode 节点表)是文件系统为存储所有inode而分配的存储空间。它本身并不直接存储文件属性,而是存储了所有inode的集合。由于在文件系统的块组中,所有可管理的存储单元均以固定大小(如4KB)的数据块为单位,因此inode tableData Blocks的最小组成单元相同 —— 都是4KB的数据块(inode table 由多个4KB数据块组合而成,专门用于容纳inode;Data Blocks 则是多个4KB数据块的集合,用于存储文件实际内容)。

所以我们实际上,在我们磁盘上保存的对应inode,本质就和保存数据来说,是没有区别的,所以创建一个内容为0的文件,是要占磁盘空间的,因为在inode当中还要保存属性,inode存在inode Table当中。

可是单位是4KB的数据块,我们的inode只有128字节 --- 4096÷128=32

也就是说,一个数据块大小,会保存32inode,即32个文件,但是我们文件系统(filesystem)和磁盘(IO)交互的时候,不是以4KB为单位吗?如果以4KB为单位,那么今天我想打开或者访问一个文件,获得属性的时候,那么我们一定是首先是要一次就把32inode所对应的数据块(4kb)全部都读取到内存里了吗?

答案是:是的,这也是一种局部性原理的体现,从概率上讲,两个文件的属性在同一数据块,那么对应的两个文件是在短时间内同时创建的,在同数据块的两文件就不需要再加载一次了,这跟我们的1.5倍扩容是同样的作用的。

那我们怎么区分文件呢?所以每一个文件的inode都要有自己的inode 编号

我们可以使用:

ls -li

来查看文件对应inode的编号:

我们根目录的inode编号是2,明显是创建得比较早的。 

我们inode Table横向的数据是inode结构体当中的内容,是文件内的所有属性集。

现在我们知道:文件内容保存在Data Blocks;文件属性保存在inode Table。可是这样是完全不够的,这个道理就好比一个高校里只有学生的话是不够的,还要有老师,辅导员,校长等等,因为学生还需要管理,所以同样的,除了将文件的内容和属性保存到对应的inode TableData Blocks当中,还需要有一些能够管理数据的,将保存的文件进行管理。

接下来我们来看看inode BitmapBlock Bitmap

1.3.3 块位图(Block Bitmap)

我们所有的文件内容全都保存在Data Blocks里,比如说该Data Blocks里面有10万个4KB块,将来如果我要新建一个文件的时候,我会在这Data Blocks里面选择没有被占用的若干个数据块分配使用,可是这么多数据块里,我怎么知道哪些被用了,哪些没有被用呢?所以我们就有了Block Bitmap

Block Bitmap中记录着Data Block中哪个数据块已经被占用,哪个数据块没有被占用。

假设文件系统将数据区划分为10万个4KB的数据块。这意味着整个数据区的大小为:

100000×4KB=400000KB=390.625MB

每个数据块对应一个位(bit),因此块位图总位数为:100000 bits

由于1字节(Byte) = 8位(bit),因此块位图占用字节大小为:100000 ÷ 8 = 12500 Byte

\frac{100,000 \text{ bits}}{8 \text{ bits/Byte}} = 12,500 \text{ Bytes}

12,500 Bytes=12.5 KB

所以,块位图的大小约为12.21KB,以4KB数据块为单位存储,一共占用4 个数据块。

块位图是一个由10万个位组成的数组,每个位对应一个数据块。

  • 如果块位图的第12345位是0,表示第12345个数据块是空闲的。

  • 如果块位图的第12345位是1,表示第12345个数据块已被占用。

通过Block Bitmap,文件系统可以快速找到空闲数据块并进行分配,同时也能高效地管理数据块的释放。

所以我们申请一个数据块来保存对应的数据的话,所谓的申请,本质就是对Block Bitmap对应的比特位进行置 1!释放一个数据块,我们此时只需要对Block Bitmap对应的比特位进行清 0

1.3.4 inode位图(Inode Bitmap) 

同理,Data Blocks 里有十万个数据块,系统中可能存在一万个文件,对应就有一万个inodeinode Table里并非所有inode都已生效使用,存在大量空闲节点,想要快速区分哪些inode已占用、哪些空闲,就需要用到inode Bitmap

inode Bitmap中每一个bit位,都用来标识一个inode是否空闲可用。

因此新建文件时,既要申请inode节点,又要分配数据块,本质就是修改inode BitmapBlock Bitmap这两张位图。

日常使用中能明显发现,拷贝3~4GB高清电影往往需要两三分钟,可删除仅需一秒,背后原理就在这里:拷贝文件过程中:需要生成文件属性并写入对应inode,还要把完整文件内容真实写入Data Blocks,是实打实写入数GB数据,耗时自然久;写入完成后,仅需把inode Bitmap对应比特位置1标记节点占用,同时把Block Bitmap里多个对应比特位置1标记数据块占用。

而删除文件时,系统不会清空inode属性,也不会抹除Data Blocks里的文件内容,仅仅只需将两张位图里对应的比特位统一置0即可。

对磁盘而言,只要inode和数据块被标记为空闲,原有残留数据是什么都无关紧要,无需做任何数据擦除操作,只改动管理标记,所以删除速度极快。这也是文件误删后能够恢复的核心原因:数据本体没有消失,只需把原本置0的占用比特位重新置1,就能重新识别找回文件。

文件误删后能否成功恢复,和操作时机息息相关,越早恢复,原有数据被新数据覆盖的概率越低。一旦发现误删文件,立刻停止向该磁盘分区写入任何数据,最大程度避免删除文件所在的数据块被覆盖。

1.3.5 GDT(Group Descriptor Table)

我们现在就存在一个问题:

在我们的整个分组里面,一个组的位置,从哪个位置到哪个位置是哪个对应的模块?也就是还有一部分信息通过以上 4 个模块是无法直接体现出来的,我们就需要一个比较关键的东西:GDT

块组描述符表描述块组属性信息,整个分区分成多个块组就对应有多少个块组描述符。每个块组描述符存储一个块组的描述信息,如在这个块组中从哪里开始是inode Table,从哪里开始是Data Blocks,空闲的inode和数据块还有多少个等等。块组描述符在每个块组的开头都有一份拷贝【备份】。

GDT(块组描述符表)本质上是文件系统级别的 全局数据结构—— 它整合了整个文件系统中所有块组的关键管理信息,是文件系统实现 全局块组管理 的核心依据,不存在 “分块组的局部GDT”。

GDT 中的 “块组描述符” 序号 对应块组 描述符记录的核心信息(示例)
1 块组 1 块位图在块号 100,inode 表在块号 200,空闲块 50 个
2 块组 2 块位图在块号 300,inode 表在块号 400,空闲块 30 个
3 块组 3 块位图在块号 500,inode 表在块号 600,空闲块 40 个

GDT(Group Descriptor Table,块组描述符表)并不是单个块组内部的内容,而是用来统一管理整个文件系统内所有块组元数据的集合。GDT本质是一个数组,数组里每一个元素都是块组描述符(ext2_group_desc),专门记录对应块组的详细信息:包含块位图inode 位图inode 表的存储位置,以及当前块组内空闲数据块数量、空闲inode节点数量等核心信息。

// 磁盘级块组(Block Group)的数据结构
/*
* 块组描述符结构
*/
struct ext2_group_desc
{
    __le32 bg_block_bitmap;       // 块位图(Block Bitmap)所在的块号
                                  // 该位图记录了本块组内所有数据块的使用情况(0表示空闲,1表示已占用)

    __le32 bg_inode_bitmap;       // inode位图(Inode Bitmap)所在的块号
                                  // 该位图记录了本块组内所有inode的使用情况(0表示空闲,1表示已占用)

    __le32 bg_inode_table;        // inode表(Inode Table)所在的块号
                                  // 该表存储了本块组内所有inode的详细信息

    __le16 bg_free_blocks_count;  // 本块组内空闲的数据块数量
                                  // 用于快速统计和管理空闲块

    __le16 bg_free_inodes_count;  // 本块组内空闲的inode数量
                                  // 用于快速统计和管理空闲inode

    __le16 bg_used_dirs_count;    // 本块组内已使用的目录数量
                                  // 用于统计目录数量,有助于文件系统的管理和优化

    __le16 bg_pad;                // 填充字段,用于对齐
                                  // 确保结构体大小为32字节的倍数,便于访问和存储

    __le32 bg_reserved[3];        // 保留字段,供未来扩展使用
                                  // 目前未使用,但为未来可能的功能扩展预留空间
};
  • 管理块组的元数据:GDT 提供了每个块组的详细信息,使得文件系统可以快速定位和管理块组

  • 支持文件系统的扩展和可靠性:GDT 的设计使得文件系统可以方便地扩展,同时通过保留的 GDT 块(Reserved GDT Blocks)增强了文件系统的可靠性。

1.3.6 超级块(Super Block)

我们有了上面五类结构,看似已经能勉强完成块组管理,那Super Block超级块又起到什么作用?

超级块是文件系统最核心的元数据结构,专门存放整个文件系统的全局信息,包含文件系统总大小、数据块大小、总inode数量、总数据块数量等全局核心参数

/*
* Structure of the super block
*/
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;     // 第一个数据块的块号(通常是1)
    __le32 s_log_block_size;       // 数据块大小的对数(2的幂,如10表示1024字节)
    __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;                // 文件系统的魔数(用于标识文件系统类型)
    __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

    /*
    * 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 doesn't
    * know about, it should refuse to mount the filesystem.
    *
    * e2fsck's requirements are more strict; if it doesn't know
    * about a feature in either the compatible or incompatible
    * feature set, it must abort and not try to meddle with
    * things it doesn't understand...
    */
    __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];               // 文件系统的128位UUID
    char s_volume_name[16];        // 文件系统的卷标名称
    char s_last_mounted[64];       // 文件系统最后一次挂载的目录路径
    __le32 s_algorithm_usage_bitmap; // 压缩算法的使用情况

    /*
    * Performance hints. Directory preallocation should only
    * happen if the EXT2_COMPAT_PREALLOC flag is on.
    */
    __u8 s_prealloc_blocks;        // 尝试预分配的块数量
    __u8 s_prealloc_dir_blocks;    // 尝试为目录预分配的块数量
    __u16 s_padding1;              // 填充字段

    /*
    * Journaling support valid if EXT3_FEATURE_COMPAT_HAS_JOURNAL set.
    */
    __u8 s_journal_uuid[16];       // 日志文件的UUID
    __u32 s_journal_inum;          // 日志文件的inode编号
    __u32 s_journal_dev;           // 日志文件的设备编号
    __u32 s_last_orphan;           // 待删除inode链表的起始位置
    __u32 s_hash_seed[4];          // HTREE哈希种子
    __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];         // 保留字段,用于未来的扩展
};

文件系统格式化的过程,就是把存储设备划分成逻辑结构、初始化各类元数据的过程。

首先,格式化工具会在存储设备指定位置创建超级块,存放文件系统全局信息,例如总容量、块大小等。

接着生成块组描述符表(GDT),记录每一个块组的详细参数,包含块位图、inode位图、inode表的具体存放位置。

随后把整个存储设备切分为多个块组,每个块组配备固定数量的数据块与inode节点。格式化程序会初始化各组内的块位图inode 位图,全部比特位统一置0,代表初始状态下所有资源均为空闲;同时为每个块组搭建inode 表,用来存放各类文件属性信息。

最后自动创建根目录,配置默认参数,将文件系统标记为正常可用状态,完成后即可被操作系统挂载使用。

所以格式化的本质,就是往磁盘中写入整套文件系统管理信息!!!


超级块(Superblock)记录整个文件系统的全局数据,那为何不单独划出一片固定区域只存放一份超级块?并非所有块组都需要存放超级块,比如十个块组里通常只在少数几个块组留存副本,且所有副本内容完全一致。

日常使用Windows开机时,若出现异常关机、文件系统损坏等情况,系统会弹出磁盘检测与自动修复流程,本质就是系统在校验、修复损坏的文件系统管理结构。

单纯inode损坏只会造成个别文件、目录无法访问,GDT出错仅影响单个块组的资源调度;可一旦超级块损坏,后果最为严重,它承载着整个文件系统的全局核心参数,一旦失效,整个分区文件系统直接瘫痪无法使用。

正因如此,系统会给超级块设置多份备份,分散存放在磁盘不同位置,极大提升文件系统容错性与稳定性。

  • 超级块:统筹全局,属于文件系统顶层管理结构,统一存放全分区整体参数,避免重复查询全局信息。

  • GDT:分管局部,属于文件系统中层管理结构,以数组形式存放所有块组描述信息,能够快速定位并调度任意块组资源,无需全盘遍历。

  • 块位图 & inode 位图:精准标记资源状态,属于文件系统底层管理结构,实时记录单个块组内数据块与inode节点的占用情况。

同一分区内,inode编号与数据块编号全局唯一,且采用跨块组连续编排:例:组 1 编号范围0~1000,组 2 编号范围1000~2000,组 3 编号范围2000~3000不同分区编号相互独立互不干扰,分区 1、分区 2 均可从零开始重新编号,一个分区就对应一套完整独立的文件系统

依靠统一编号规则,通过编号就能快速判定归属块组:例如编号1001,组内基数为1000,整除得出归属第 1 块组,取余确定组内对应位图比特位。

全局连续编号也带来灵活存储特性:文件的inode归属某一个块组,但超大文件所需的数据块,可以跨多个块组存放,不受单一块组空间大小限制。

操作系统开机管控磁盘,并非和硬件磁盘相互脱离,必须提前把磁盘上所有核心管理结构加载至内存:在内存中生成对应Super Block管理对象,读取磁盘分区超级块内容完成映射;若存在多个磁盘分区,依次加载全部超级块完成关联,同时把GDT块组描述信息一并载入内存完成统筹梳理。

日常创建文件、删除文件等所有操作,都优先在内存中的位图完成比特位修改,等到系统空闲时机,再统一把变更数据刷新同步至物理磁盘。

系统启动加载完整流程:

  1. 加载超级块(Super Block)操作系统逐个读取所有分区的超级块并载入内存,读取文件系统总块数、inode总数、数据块大小等全局参数,整合形成完整的全局文件系统管理视图。

  2. 加载块组描述符表(GDT)超级块加载完成后,同步读取各分区GDT存入内存,依靠表内信息快速定位任意块组里位图、inode表的存放位置,实现高效组级管理。

  3. 加载位图(Bitmap)将每个块组对应的块位图inode 位图全部调入内存,全程在内存中完成空闲资源查询、占用标记、释放标记等操作,减少频繁磁盘 IO。

文件创建流程:先在内存位图中检索空闲inode节点与空闲数据块,完成资源分配后即时更新内存位图,再把文件属性写入对应inode表,后续择机将所有修改持久化写入磁盘。

文件删除流程:仅在内存位图中将对应inode与数据块标记为空闲状态即可,无需擦除磁盘内原有文件内容,延后统一同步磁盘数据。

总结:所有文件系统管理操作,核心运算与状态修改全都在内存中完成;就连文件内容读写,也需要先把磁盘数据读取到内存,编辑处理完毕后再写回磁盘,最大程度提升整机运行效率。

1.4 inode 和 datablock 映射 [ 弱化 ]

/*
 * Structure of an inode on the disk
 */
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 */
};

文件系统中的每个文件都有一个唯一的inodeinode存储了文件的属性信息,如文件类型、权限、大小等。inode内部存在一个__le32 i_block[EXT2_N_BLOCKS];数组,其中EXT2_N_BLOCKS = 15,这个数组用来进行inode和数据块的映射,数组内容是该文件所对应的数据块编号。(我们将i_block[]看成Blocks[]:下图)

具体来说,i_block数组的前 12 个元素直接指向文件的前 12 个数据块,如果文件较小,这 12 个数据块足以存储文件内容。(前面12 个直接块指针,后面的就是一级 / 二级 / 三级间接块索引表指针)如果文件较大,超出这 12 个直接块的大小,i_block数组的第 13 个元素会指向一个间接块,这个间接块中包含了更多数据块的指针。如果文件更大,第 14 个元素会指向一个双重间接块,双重间接块中包含了指向间接块的指针,这些间接块又指向实际的数据块。第 15 个元素则指向一个三重间接块,三重间接块中包含了指向双重间接块的指针,以此类推,形成了一个多级间接映射结构

一级间接块索引表指针:指向一个索引表,该表中的每个条目指向文件的一个数据块。这种方式可以管理更多的数据块,因为一个索引表可以包含多个指针。

二级间接块索引表指针:指向一个索引表,该表中的每个条目指向一个一级间接块索引表。这种方式可以管理更多的数据块,因为每个一级间接块索引表可以包含多个一级间接块指针。

三级间接块索引表指针:指向一个索引表,该表中的每个条目指向一个二级间接块索引表。这种方式可以管理极其庞大的文件,因为每个二级间接块索引表可以包含多个一级间接块索引表,从而可以管理大量的数据块。

通过这种映射机制,我们拿着一个文件的 inode,就可以通过 i_block 数组找到对应文件的所有内容。这样,文件的内容和属性就都能找到了,文件系统就能够高效地管理和访问文件数据。

注意:

i_block 是 inode 里的 “数据块映射数组”,它既可以直接存储数据块的块号(小文件),也能通过多级间接块扩展映射范围(大文件),最终实现 “inode → 数据块” 的精准定位 —— 这和图中 Pointers 要表达的 “inode 通过指针找数据块” 的逻辑完全一致,只是 i_block 是更底层的 “实现细节”。

1.5 目录与文件名

但是我们的inode 编号是跨块组连续编排的,仅凭编号能在单分区内定位所属块组,可inode仅在当前分区内生效,不同分区编号互不通用,那系统究竟如何判定一个文件归属哪个分区?

理清这个问题前,先搞懂核心前置知识点:Linux 如何定义与看待目录

前面已经明确:文件名不会存入文件自身的 inode 属性中,可我们日常操作全靠文件名访问文件,而非inode编号,那文件名到底存在哪里?

答案很简单:目录本身也是一种特殊文件,完全遵循普通文件存储规则,目录同样拥有专属自己的inode

我们平时用的是文件名,但是我们在默认显示一个文件的时候,有一个潜台词:当前一定处在了某个路径下

所以我们文件的查询本质是通过路径 + 文件名既然目录有inode,那么它指向的对应内容是什么呢?

目录的inode指向的内容是目录项(Directory Entries),这些目录项记录了目录中每个文件和子目录的名称及其对应的inode 编号。具体来说:

每个目录项包含两个主要信息:

  • 文件名或子目录名:这是目录中一个文件或子目录的名称。
  • inode 编号:这是该文件或子目录的inode编号,通过这个编号可以找到文件或子目录的详细元数据。

所以文件名不作为属性保存在inode当中,而是保存在所属的目录的数据内容当中

自此,在磁盘中进行文件保存的时候就没有了目录的概念了,在磁盘当中保存的无非就是inode和数据。

下面这段代码是一个简单的 C 程序,用于列出指定目录中的文件和目录,同时打印出每个条目的文件名inode 编号

// 包含标准输入输出头文件,用于printf和fprintf函数
#include <stdio.h>
// 包含字符串操作头文件,用于strcmp函数
#include <string.h>
// 包含标准库函数头文件,用于exit函数
#include <stdlib.h>
// 包含目录项操作头文件,用于读取目录内容
#include <dirent.h>
// 包含系统数据类型头文件,用于定义文件类型相关的数据类型
#include <sys/types.h>
// 包含Unix标准函数头文件,用于访问目录和文件
#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);
    }

    // 定义一个指向dirent结构的指针,用于存储读取的目录项
    struct dirent *entry;
    // 循环读取目录中的每个条目
    while ((entry = readdir(dir)) != NULL) {
        // 跳过当前目录(".")和父目录("..")的条目
        if (strcmp(entry->d_name, ".") == 0 || strcmp(entry->d_name, "..") == 0) {
            continue;
        }
        // 打印当前条目的文件名和inode编号
        printf("Filename: %s, Inode: %lu\n", entry->d_name, (unsigned long)entry->d_ino);
    }

    // 关闭目录流,释放系统资源
    closedir(dir);

    // 程序正常退出,返回0
    return 0;
}

1.6 路径解析

问题:打开当前工作目录文件,查看当前工作目录文件的内容?当前工作目录不也是文件吗?我们访问当前工作目录不也是只知道当前工作目录的文件名吗?要访问它,不也得知道当前工作目录的inode吗?

答案 1:所以也要打开:当前工作目录的上级目录,额....,上级目录不也是目录吗??不还是上面的问题吗?

答案 2:所以类似 “递归”,需要把路径中所有的目录全部解析,出口是“/” 根目录。

最终答案 3:而实际上,任何文件,都有路径,访问目标文件,比如:/home/lfz/code/test/test/test.c,都要从根目录开始,依次打开每一个目录,根据目录名,依次访问每个目录下指定的目录,直到访问到test.c。这个过程叫做 Linux 路径解析

所以,找到任何Linux文件,都必须从 / 目录开始,进行路径分析,直到找到对应的文件。

💡 注意:

所以,我们知道了:访问文件必须要有 目录 + 文件名 = 路径 的原因。

根目录固定文件名,inode号,无需查找,系统开机之后就必须知道。

可是路径谁提供?

你访问文件,都是指令 / 工具访问,本质是进程访问,进程有CWD!进程提供路径。

open文件,提供了路径。

可是最开始的路径从哪里来?

所以 Linux 为什么要有根目录,根目录下为什么要有那么多缺省目录?

你为什么要有家目录,你自己可以新建目录?

上面所有行为:本质就是在磁盘文件系统中,新建目录文件。而你新建的任何文件,都在你或者系统指定的目录下新建,这不就是天然就有路径了嘛!

系统 + 用户 共同构建 Linux 路径结构。

1.7 路径缓存

访问目标文件,比如:/home/lfz/code/test/test/test.c,都要从根目录开始,依次打开每一级目录,根据目录名匹配目录项,逐级往下查找直到定位到test.c整个过程全程产生磁盘 IO,不断把目录文件内容加载进内存,这套流程就叫做Linux 路径解析

如果频繁访问code目录下的文件,每次都从头完整走一遍磁盘 IO 解析路径,效率会极低。那Linux 是如何优化提速的?

Linux 在路径解析过程中,会把所有已经访问过的目录节点,维护成一棵常驻内核的多叉树结构,这就是我们熟知的Linux 树状目录结构

磁盘里本身没有目录概念,目录在磁盘中完全以 inode+ 数据块 的形式存储,和普通文件存储规则一致。

但频繁访问/home/lfz/code这类路径下的文件时,Linux 依靠内存路径缓存dentry cache做极致优化,避免每次都走完整磁盘IO解析路径。

该缓存会在内存中构建路径多叉树,每个节点对应一级目录路径。路径解析时优先检索内存缓存树,命中则直接取用节点信息,无需再次从磁盘加载目录内容。

系统运行期间,所有访问过的路径都会被自动缓存留存,在内存中形成常驻目录树,大幅提升重复访问的效率。

可使用命令查看相关缓存与目录项信息:

# 查看系统目录项缓存状态
cat /proc/sys/fs/dentry-state

我们可以进行如下操作:

find ~ -name myshell.cc//这是我的路径下的文件

来找 myshell.cc 所在的一个或多个路径,第一次可能会慢一点,但是接着就很快了。

问题 1Linux磁盘中,存在真正的目录吗?答案:不存在,只有文件。只保存文件属性+文件内容

问题 2:访问任何文件,都要从/目录开始进行路径解析答案:原则上是,但是这样太慢,所以Linux缓存历史路径结构

问题 3Linux目录的概念,怎么产生的?答案:打开的文件是目录的话,由OS自己在内存中进行路径维护

Linux中,在内核中维护树状路径结构的内核结构体(和task_struct一样的内存级结构体)叫做: struct dentry

struct dentry {
    atomic_t d_count;         /* 引用计数,原子操作,用于跟踪引用该dentry的数量 */
    unsigned int d_flags;     /* 保护标志,由d_lock保护 */
    spinlock_t d_lock;        /* 每个dentry的锁,用于同步访问 */

    struct inode *d_inode;    /* 指向inode结构,如果文件存在则非NULL,否则为NULL(负dentry) */

    /* 以下字段由__d_lookup操作,放置在一起以适应缓存行 */
    struct hlist_node d_hash; /* 哈希表节点,用于快速查找 */
    struct dentry *d_parent; /* 父目录的dentry指针 */
    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;    /* RCU头,用于读-复制-更新机制 */
    } d_u;

    struct list_head d_subdirs; /* 子目录列表 */
    struct list_head d_alias;   /* inode别名列表,多个dentry可能指向同一个inode */
    unsigned long d_time;       /* 时间戳,用于d_revalidate函数 */
    struct dentry_operations *d_op; /* 指向dentry操作的函数指针表 */

    struct super_block *d_sb;   /* 指向超级块,表示文件系统的根 */
    void *d_fsdata;             /* 文件系统特定数据 */

#ifdef CONFIG_PROFILING
    struct dcookie_struct *d_cookie; /* 用于性能分析的cookie */
#endif

    int d_mounted;             /* 是否有文件系统挂载到此dentry */
    unsigned char d_iname[DNAME_INLINE_LEN_MIN]; /* 用于存储短名称的缓冲区 */
};

在 Linux 内核中,dentry(目录项)结构体所形成的结构既包含链表(list)的元素,也具有多叉树(multi-way tree)的特征。这种设计是为了优化路径的查找和缓存,提高文件系统的性能。

  1. 使用多叉树结构存储 dentry 可以高效地进行路径查找,因为它允许快速定位文件系统中的目录项,通过哈希表减少冲突,并迅速访问目录层次结构。

  2. 当内存中缓存的 dentry 数量过多时,LRU(最近最少使用)列表确保了最少使用的 dentry 可以被优先淘汰,从而优化内存使用并保持缓存效率。

dentry 结构与进程控制块(PCB)在某种程度上具有相似性。它们都可以同时作为不同数据结构的一部分,以满足操作系统中不同管理需求:

dentry 结构

  • 作为树状结构的一部分:dentry 通过 d_parentd_subdirs 指针形成树状结构,表示文件系统中的目录层次。

  • 作为链表的一部分:dentry 通过 d_lru 列表头形成链表,用于实现最近最少使用(LRU)缓存策略,以优化内存使用。

进程控制块(PCB)

  • 作为链表的一部分:PCB 通常包含一个链表指针,用于将所有PCB链接起来,形成进程控制块链表,便于操作系统管理所有进程。

  • 作为其他数据结构的一部分:例如,PCB 可能被组织成数组或哈希表的一部分,以支持快速查找特定进程。

这种设计使得 dentry 和 PCB 都可以灵活地参与到不同的数据结构中,以满足文件系统和进程管理的不同需求。通过这种方式,操作系统可以更高效地组织和访问关键数据,提高系统的整体性能和可靠性。

dentry 和 PCB(进程控制块)都是操作系统中重要的数据结构,但它们负责不同的功能和数据管理。dentry 主要用于文件系统中,将文件名映射到文件的具体位置(通常是 inode),并缓存文件路径以加快文件路径的解析速度。而 PCB 用于管理和控制进程,记录进程的所有相关信息。尽管 dentry 和 PCB 看起来相似,因为它们都可以同时存在于不同的数据结构中,但它们之间没有直接的联系。一个 struct dentry 可以属于多叉树,又可以属于某个管理的链表,正如一个进程PCB可以属于全局的链表,也可以属于某个队列中。 

注意以下几点关于Linux文件系统的路径缓存机制:

  • 每个文件,包括普通文件,都对应一个 dentry 结构。这使得所有被打开的文件可以在内存中构建成一个完整的树形结构。

  • 这个树形结构中的每个节点同时也属于LRU(Least Recently Used,最近最少使用)结构,该结构负责管理节点的淘汰,以优化内存使用。

  • 树形结构中的节点同样会加入到哈希表中,这样做可以加快查找速度,使得路径解析更为高效。

  • 关键的是,这个树形结构整体上构成了Linux的路径缓存。当访问任何文件时,系统首先会在这个路径缓存树中根据路径进行查找。如果找到了对应的 dentry,则直接返回文件的属性(通过inode)和内容;如果没有找到,则从磁盘加载相应的路径信息,创建新的 dentry 结构,并将其添加到路径缓存中。

这个机制显著提高了文件访问的速度,因为它减少了需要从磁盘读取数据的次数,并且快速地在内存中定位文件路径。

我们是如何确定我们一个文件是在哪一个分区的?我们最重要的还没解决呢?

1.8 挂载分区

我们已经能够根据inode号在指定分区查找文件,也能通过目录文件内容匹配找到对应inode在单个分区内已经可以完成所有文件操作。但这里出现一个关键疑问:

问题inode编号无法跨分区通用,Linux系统又支持挂载多个磁盘分区,系统究竟该如何判定当前文件隶属于哪一个分区???

我们的分区,一定是要很一个特定的目录进行关联,往后我们通过进入这个目录就相当于进入这个分区,我们称为挂载:

在计算机系统中,挂载是指将一个文件系统(例如硬盘分区、外部存储设备、网络文件系统等)与一个目录(称为挂载点)关联起来,从而使得用户可以通过访问这个目录来访问存储在该文件系统中的数据。简单来说,挂载就是把一个存储设备的内容“映射”到一个目录上。

在计算机系统中,分区是磁盘上划分的独立存储区域,用于组织和管理数据。然而,这些分区本身是作为块设备存在的【前面是" b "开头,而非" d "】,例如 /dev/sda1/dev/sda2 等,它们本身并不是可以直接访问的目录路径。我们无法直接像访问普通文件夹那样进入这些分区,因为它们是磁盘的物理划分,而不是文件系统中的目录结构。

为了能够方便地访问这些分区中的数据,我们需要将它们与一个特定的目录路径关联起来,这个过程就被称为挂载(Mounting)。通过挂载,我们可以将一个分区映射到一个目录(称为挂载点),这样我们就可以通过访问这个目录来间接访问分区中的内容。例如,我们可以将分区 /dev/sda1 挂载到 /mnt/mydisk,之后通过 cd /mnt/mydisk 命令进入该目录,就可以像操作普通文件夹一样访问分区中的文件和数据了。

如果系统中有多个分区(比如10个分区),我们可以通过挂载操作将每个分区分别关联到不同的目录路径上。这样,每个分区都有一个对应的挂载点目录,用户可以通过这些目录方便地访问各个分区中的数据,而无需记住复杂的设备文件路径。挂载操作不仅使得分区的访问更加直观和方便,还能够通过挂载点目录来统一管理不同分区的文件系统,提高数据访问的灵活性和安全性。

由于有点抽象,我们来通过一个实验理解:

这段操作展示了如何在Linux系统中创建一个虚拟磁盘分区、格式化它、挂载到一个目录,并进行访问,最后卸载。以下是对每个步骤的详细解释:

1. 创建虚拟磁盘文件

dd if=/dev/zero of=./disk.img bs=1M count=5
  • dd:这是一个用于数据复制的工具,可以将数据从一个文件复制到另一个文件。

  • if=/dev/zero:指定输入文件为/dev/zero,这是一个特殊的设备文件,会输出无限的零(0)。

  • of=./disk.img:指定输出文件为当前目录下的disk.img,这个文件将被创建并写入数据。

  • bs=1M:指定每次读写的数据块大小为1MB。

  • count=5:指定总共写入5个数据块。

结果:创建了一个大小为5MB的文件disk.img,内容全部是零。这个文件可以被当作一个虚拟的磁盘分区来使用。

2. 格式化虚拟磁盘

mkfs.ext4 disk.img
  • mkfs.ext4:这是一个用于创建ext4文件系统的工具。

  • disk.img:指定要格式化的文件(虚拟磁盘)。

结果:将disk.img格式化为一个ext4文件系统。现在,这个文件可以被挂载并作为文件系统使用。

3. 创建挂载点目录

mkdir dir
  • mkdir:创建一个新目录。

  • dir:指定要创建的目录路径。

结果:在当前目录下创建了一个名为dir的空目录,这个目录将用作挂载点。(我们已经创建)

4. 查看当前挂载的文件系统

df -h
  • df -h:显示文件系统的磁盘使用情况,-h选项表示以易读的格式显示。

结果:列出当前系统中所有挂载的文件系统及其使用情况。此时,disk.img还没有挂载,所以不会出现在列表中。

5. 挂载虚拟磁盘

sudo mount -t ext4 ./disk.img /mnt/mydisk/
  • sudo:以超级用户权限执行命令。

  • mount:挂载文件系统。

  • -t ext4:指定文件系统的类型为ext4。

  • ./disk.img:指定要挂载的文件(虚拟磁盘)。

  • ./dir:指定挂载点目录。

结果:将disk.img挂载到./dir目录。现在,可以通过访问./dir来访问disk.img中的内容。

6. 再次查看挂载的文件系统

df -h

结果:此时,disk.img已经挂载到./dir,会在df -h的输出中显示为/dev/loop4(Linux使用循环设备来挂载文件作为块设备)。可以看到它的大小、已用空间、可用空间等信息。

7. 卸载虚拟磁盘

sudo umount ./dir
  • sudo:以超级用户权限执行命令。

  • umount:卸载文件系统。

  • ./dir:指定要卸载的挂载点目录。

结果:将disk.img./dir卸载。卸载后,./dir目录仍然存在,但不再与disk.img关联。

8. 再次查看挂载的文件系统

df -h

结果disk.img已经从挂载列表中移除,/mnt/mydisk目录不再显示为挂载点。

通过上述步骤,我们完成了一个虚拟磁盘分区的创建、格式化、挂载和卸载。这个过程模拟了真实磁盘分区的管理操作,展示了如何在Linux系统中灵活地使用文件系统。

我们现在就可以解决上面遗留的问题:

但是我们的inode编号是跨组的,所以我们在一个分区中可以确定该文件所对应的组号,但是inode只能在一个分区内有效,那我们是如何确定一个文件是在哪一个分区的?

答:Linux系统中,虽然inode编号在不同分区之间相互独立,但文件系统依靠挂载点的目录结构判定文件所属分区。访问文件时,系统顺着文件路径从根目录逐级检索,定位到对应的挂载点,以此确定该文件归属哪一个分区。

假设有以下挂载情况:

  • /dev/sda1挂载到/(根目录)

  • /dev/sdb1挂载到/mnt/data

  • /dev/sdc1挂载到/home/user

当访问文件/home/user/documents/file.txt时:

  • 系统从根目录/开始。

  • 找到/home,再找到/home/user,这里/home/user是一个挂载点,关联到分区/dev/sdc1

  • 在分区/dev/sdc1中查找documents/file.txt

所以我们调用fopen()函数打开文件时,若未填写完整绝对路径,操作系统会自动拼接当前进程的CWD当前工作目录。执行fopen()过程中,内核会依据传入路径完成文件定位,优先检索内存里的struct dentry目录项结构完成路径检索,逐级匹配目录节点,梳理出文件名与inode的映射关系,最终定位目标文件。

1.9 文件系统总结

这些图描述了在Linux操作系统中,文件从进程打开到挂载在文件系统上的整个流程。首先,每个进程(如进程A和进程B)都有一个task_struct结构,其中包含一个files_struct结构,用于跟踪该进程打开的所有文件。每个打开的文件由file结构表示,该结构包含文件描述符、文件状态信息以及指向inodedentry的指针。

inode结构代表硬盘文件系统上的索引节点,包含文件的元数据,如权限、所有者、大小和时间戳等。dentry(目录项)结构代表文件系统中的文件名和它对应的inode的关联。dentry通过其d_inode字段与inode关联。

文件路径由path结构表示,它包含指向dentryvfsmount(虚拟文件系统挂载)的指针。vfsmount结构表示一个挂载的文件系统,包含指向其super_block的指针。super_block结构代表文件系统的超级块,包含文件系统类型和状态等信息。

当一个文件被打开时,系统会根据文件路径找到对应的dentryinode,然后创建或使用现有的file结构来表示该文件的打开状态。file结构中的f_op字段指向一组文件操作函数,这些函数定义了如何读写文件等。

文件系统类型由file_system_type结构定义,它包含文件系统的名称和各种操作函数,如挂载(mount)、卸载(kill_sb)等。super_operations结构定义了超级块的操作,如分配和销毁inode、同步文件系统等。

总的来说,这些图展示了Linux内核如何通过一系列的数据结构和操作函数来管理文件、目录项、索引节点和文件系统,从而实现文件的打开、读写和挂载等操作。

所以说:

找到一个文件的具体过程大致是进行路径解析,再依据解析得到的 inode 以及 inode 中指向的数据块信息来获取文件内容!

不过,在实际过程中,为了提高效率,系统还会使用缓存机制,比如目录项缓存、inode 缓存等,避免每次都从磁盘中读取相关信息。

二. 软硬链接

2.1 软链接

软链接(Symbolic Link),也称作符号链接,是Unix与类Unix系统(LinuxmacOS)里的特殊文件类型,作用是引用指向另一个普通文件或目录,作用等同于Windows系统里的快捷方式

假设在Windows中存在如下文件结构:

C:\Projects\
├── ProjectA\
│   └── source\
│       └── main.cpp
└── ProjectB\
    └── docs\
        └── readme.md

现在,你想在ProjectB/docs目录下创建一个指向ProjectA/source/main.cpp的快捷方式,以便快速访问这个文件。在 Windows 中,你可以这样做:

  1. 右键点击ProjectA/source/main.cpp文件。

  2. 选择“创建快捷方式”。

  3. 将快捷方式拖动到ProjectB/docs目录下。

现在,在ProjectB/docs目录下会有一个指向ProjectA/source/main.cpp的快捷方式。你可以双击这个快捷方式来打开main.cpp文件。

对于Linux,我们先来实验一下:

Linux中,可以使用ln -s命令创建软链接。命令格式如下:

ln -s <目标文件或目录> <软链接名称>

例如,创建一个指向code.c文件的软链接code-soft

ln -s code.c code-soft
lfz@HUAWEI:~/lesson/lesson21$ ll
total 12
drwxrwxr-x  2 lfz lfz 4096 Feb 10 16:43 ./
drwxrwxr-x 19 lfz lfz 4096 Feb  9 23:15 ../
-rw-rw-r--  1 lfz lfz    0 Feb  9 23:15 code.c
-rw-rw-r--  1 lfz lfz 1781 Feb 10 16:42 readdir.c
lfz@HUAWEI:~/lesson/lesson21$ ln -s code.c code-soft
lfz@HUAWEI:~/lesson/lesson21$ ls -l
total 4
-rw-rw-r-- 1 lfz lfz    0 Feb  9 23:15 code.c
lrwxrwxrwx 1 lfz lfz    6 Feb 10 16:44 code-soft -> code.c
-rw-rw-r-- 1 lfz lfz 1781 Feb 10 16:42 readdir.c
lfz@HUAWEI:~/lesson/lesson21$ echo "hello code.c" >> code.c
lfz@HUAWEI:~/lesson/lesson21$ cat code-soft
hello code.c

我们看到:

cat code-soft命令读取并显示软链接code-soft指向的文件内容。由于code-soft是指向code.c的软链接,所以显示的是code.c的内容,包括刚刚追加的"hello code.c"字符串。

我们能看到软链接拥有专属inode,拥有独立的inode编号,足以证明它本身就是一个独立文件,并且文件权限标识以l开头,代表链接文件类型。

既然属于文件,自然也具备自身文件属性与数据内容,那软链接实际作用是什么?

原理和Windows快捷方式完全一致,核心目的就是简化路径、方便调用使用。

所以软链接的文件内容,仅仅只存放目标源文件的路径!!!

可以使用unlink或者rm命令删除软链接文件。

重点注意:绝对不能用软链接充当文件备份

软链接虽然拥有独立inode,但它的inode与数据块中,不会存储源文件真实数据,只保存源文件路径地址,和备份需要完整留存独立数据的需求完全不符,无法起到备份作用。

2.2 硬链接

硬链接(Hard Link),是 Unix 及类 Unix 系统文件系统里的特殊链接形式,可实现多个文件名指向同一份磁盘数据

Linux中使用ln命令创建硬链接,标准格式如下:

ln <目标文件> <硬链接名称>

例如,创建名为code-hard的硬链接,指向code.c

ln code.c code-hard

我们发现code-hard对应的inode编号和code.c完全相同,所以硬链接本质上并不是一个独立的文件,因为它没有自己独立的inode

那它到底是什么?

我们来看看软硬链接创建后的变化:

所以它本质就是新增一组文件名与目标文件inode编号的映射关系!【核心核心核心!!!】

这里的数字代表硬链接计数,新增硬链接就多一个文件名指向同一个inodeinode结构体内部自带引用计数器,专门记录当前有多少个文件名指向该inode

那这玩意儿有什么用?

第一,假设你有一个重要的配置文件,你希望在不占用额外磁盘空间的情况下创建一个 “防误删” 的访问入口。你可以创建一个硬链接到该文件:

ln /etc/config.txt /etc/config_backup.txt

这样config_backup.txt就是config.txt的硬链接,二者指向同一个 inode 与数据块,不会额外占用磁盘存储空间。

若删除config.txt,只要config_backup.txt还存在并指向原inode与数据块,依旧能通过它读取原始数据,有效规避单文件名误删丢数据的问题;

但修改任意一个文件内容,另一个文件内容也会同步变动,二者共享同一份数据,无法留存原始版本。

【这其实和shared_ptr引用计数的设计目的完全一致:二者都是为了实现多标识访问同一份底层资源,任意一端修改,本质都是操作同一份资源本体】

因此硬链接作用:不占额外空间,给文件增设冗余访问入口,防误删丢数据,无法隔绝内容修改

另外新建空目录时,默认硬链接数为2,进入目录能看到.当前目录文件。

所以.本身就是硬链接.代表当前目录!!!

第三,在dir目录内新建hello子目录后,dir的硬链接数自动加 1,原因是hello目录中自带..硬链接,专门用来指向上级目录

所以..本身就是硬链接..代表上级目录!!!

可用公式:目录硬链接数 - 2,就能算出该目录下含有的子目录数量!!!

2.3 软硬链接对比

硬链接(Hard Link)和软链接(Symbolic Link)是Linux系统中用于创建文件或目录引用的两种不同机制。下面是一个详细的对比:

创建方式:

硬链接

ln 源文件 硬链接文件

使用ln命令创建,不带-s选项。

软链接:

ln -s 源文件 软链接文件

使用ln命令创建,带-s选项。


存储结构:

硬链接:硬链接实际上是文件系统中的另一个名字,指向相同的inode。不占用额外的磁盘空间,因为它们只是文件系统中的另一个名字。

软链接:软链接是一个独立的文件,包含对目标文件路径的引用。占用少量的磁盘空间来存储目标文件的路径。


文件删除:

硬链接:删除硬链接不会影响原始文件,只有当所有指向同一inode的硬链接都被删除后,文件数据才会被删除。

软链接:删除软链接不会影响它所指向的原始文件,因为软链接本身是一个独立的文件。


文件系统限制:

硬链接:不能跨文件系统创建。不能指向目录(因为可能导致目录结构的循环引用)。

软链接:可以跨文件系统创建。可以指向目录。

下面详细解释为什么硬链接不能跨文件系统创建以及为什么不能指向目录:

1. 不能跨文件系统创建

硬链接直接指向文件系统中的inode(索引节点),inode包含了文件的元数据和数据块的位置信息。不同文件系统有不同的inode结构和inode号空间:

  • 不同的inode号空间:每个文件系统维护自己的inode号空间,这意味着一个文件系统中的inode号在另一个文件系统中可能是无效的或指向不同的inode。

  • 文件系统独立性:文件系统设计为独立的数据管理单元,跨文件系统创建硬链接会破坏这种独立性,可能导致数据不一致和管理上的复杂性。

2. 不能指向目录(避免目录结构的循环引用

目录在文件系统中是特殊的文件,它们包含文件和子目录的名称和inode号的映射。如果允许硬链接指向目录,可能会引发以下问题:

  • 循环引用:假设目录A有一个硬链接B,B又有一个硬链接指向A,这样就形成了一个循环引用。在遍历目录时,系统会不断在A和B之间跳转,导致无限循环,无法正常访问文件系统。

  • 目录树结构破坏:文件系统的目录结构是一个树形结构,硬链接目录可能导致目录树结构的逻辑混乱,使得文件系统的遍历和管理变得复杂。

  • 一致性问题:目录的硬链接可能导致目录内容的一致性问题。例如,如果通过一个硬链接删除目录,可能会导致其他硬链接指向的目录内容丢失或不一致。

假设有以下目录结构:

/a
└── /b

如果允许硬链接目录,可能会创建如下结构:

/a -> /b
/b -> /a

现在,如果尝试遍历目录/a,系统会进入无限循环:/a -> /b -> /a -> /b -> ...,这会导致系统无法正常工作。

硬链接的限制主要是为了维护文件系统的稳定性和一致性,避免产生复杂的文件系统结构问题。相比之下,软链接通过存储目标文件的路径来避免这些问题,因此可以跨文件系统创建,也可以指向目录。

那我们上面的.和..的本质是对目录的硬链接,这不就是“只许州官放火,不许百姓点灯”吗?!

你别说,就是这么双标!!!


更新和修改:

硬链接:对任何一个硬链接的修改都会反映到所有其他硬链接上,因为它们指向的是同一个inode。

软链接:指向的文件被修改时,软链接本身不受影响,它仍然指向原始文件。


访问权限:

硬链接:不能改变,因为它们是同一个文件的不同名字。

软链接:可以有自己的访问权限,这可能会影响用户能否通过软链接访问目标文件。


系统资源:

硬链接:增加硬链接会增加文件系统中的inode使用,但不会增加磁盘空间的使用。

软链接:虽然占用的磁盘空间很小,但每个软链接都是文件系统中的一个独立实体。 

硬链接和软链接各有优势和适用场景。硬链接适用于需要多个名称指向同一数据的场景,而软链接提供了一种灵活的方式来引用文件,特别是在需要跨文件系统链接或创建目录链接时。然而,由于硬链接的限制(如不能跨文件系统创建、不能链接目录等),在某些场景下可能需要使用软链接作为替代方案。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐