关注

Linux系统篇(二十)——文件(四):吃透 Linux 磁盘与文件系统:一张原理图讲清块、inode、分区底层逻辑

前言

刚接触 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个操作:

  1. 存储属性
    内核先找到一个空闲的i节点(假如是263466)。内核把文件信息记录到其中。
  2. 存储数据
    该文件需要存储在某些磁盘块,假如内核找到了三个空闲块:300,500,800。将内核缓冲区的第一块数据复制到300,下一块复制到500,以此类推。
  3. 记录分配情况
    文件内容按顺序300,500,800存放。内核在inode上的磁盘分布区记录了上述块列表。
  4. 添加文件名到目录
    新的文件名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时,流程是:

  1. 读取当前目录的 inode,找到目录数据块;
  2. 在目录映射表匹配字符串code.c,拿到对应 inode 编号;
  3. 读取该 inode,获取文件内容所在数据块;
  4. 读取数据块输出文件内容。
  • 所以,访问文件必须打开当前目录,根据文件名,获得对应的inode号,然后进行文件访问
  • 所以,访问文件必须要知道当前工作目录,本质是必须能打开当前工作目录文件,查看目录文件的内容!

1.路径解析

而上级目录也有当前的目录那就会一直追溯到根目录  而这个过程就叫做路径解析

2.路径缓存

  1. 文件访问依赖路径解析:绝对路径默认从根目录/逐层读取各级目录文件,匹配文件名拿到目标 inode 才能访问文件。
  2. 路径访问性能优化:全程从根目录逐层解析速度较慢,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,自身是单独文件,数据块仅存储目标文件路径字符串;访问时读取路径再跳转原文件;原文件删除后软链接直接失效,无计数机制,支持跨分区、链接目录

明明没有手动进行硬链接,可为什么硬链接数还会发生变化呢?

其实每一个目录创建后都会有当前目录上级目录,自动进行了硬链接

七、格式化、分区实操底层本质(总结梳理)

看完整套架构,就能理解日常磁盘操作底层做了什么:

  1. 分区(fdisk):修改磁盘 MBR 分区表,切割磁盘空间边界,不修改分区内部数据;
  2. 格式化(mkfs.ext4):给分区写入 EXT 文件系统,划分块组、初始化超级块、位图、空白 inode 表,清空原有数据;
  3. 挂载(mount):读取分区超级块,加载块组信息,将文件系统关联到系统目录入口;
  4. rm 删除文件:仅把 inode 位图、块位图对应 bit 置为空闲,不擦除磁盘数据,这是删除文件仍能数据恢复的底层原理。

八.文件系统总结

结尾总结

Linux 文件系统是一套分层、解耦的精密设计:硬件扇区封装为块,磁盘切割分区隔离空间,分区拆分为块组均衡管理,再通过 inode 分离「文件属性」和「文件名」。

这张思维导图把从磁盘硬件到文件访问的完整链路全部打通,弄懂块、块组、inode、目录映射这套底层逻辑,再去学习磁盘扩容、数据恢复、软硬链接、磁盘 IO 调优都会事半功倍,彻底告别只会敲命令不懂底层原理的阶段。

解耦 = 把两件强绑定、改一个就必须动另一个的东西,拆分开,让两者互相独立、互不依赖。

耦合:两件事捆死在一起; 解耦:拆开,各司其职,改动其中一边不影响另一边。

带来巨大好处:

  1. 给文件重命名:只改目录里的名字映射,不动 inode、不动文件真实数据,几乎零开销;
  2. 硬链接:同一个 inode,可以对应 N 个不同文件名,新增名字完全不用复制文件内容;
  3. 移动同分区文件:只是修改父目录映射,文件本体 inode 和数据块一点不变;
  4. 删除文件名:只是删掉一条映射,inode 和数据还在,只要还有别的硬链接,文件依然可用。

核心:把「文件标识名」和「文件真实本体」解耦,两者独立变化,互不牵制。

转载自 CSDN-专业IT技术社区

原文链接:https://blog.csdn.net/2502_94387000/article/details/162880780

文章来源crawl

评论

赞0

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--