ZeroOne AI
← 返回文章列表

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

👁 0
分类:工业互联网

从扇区封装为块,到分区、块组、超级块、位图、inode 与目录映射,讲清 Linux EXT 文件系统的完整底层逻辑。

项目背景

刚接触 Linux 存储时,很多人都会有一堆疑问:为什么磁盘要先分区、再格式化才能存文件?ls -li 输出的 inode 号到底代表什么?4KB 块、块组、超级块、位图之间是什么从属关系?Windows 靠文件名找文件,Linux 为什么非要单独搞一套 inode 机制?

网上零散教程只讲命令,很少把"磁盘硬件 → 分区 → 文件系统 → 块组 → inode → 数据块"这条完整链路串起来。本文从磁盘最底层的扇区开始,一路拆解到文件属性、文件名存储逻辑,讲清 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 主引导记录,同时记录分区表,把整块大磁盘切割成多个独立分区(Partition 1/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 超级块:文件系统全局信息仓库,记录总块数、总 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 */
};
  1. GDT 块组描述符表:记录每个块组的位图、inode 表、数据块起始位置,操作系统通过 GDT 快速定位任意块组的资源。
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];       /* 预留扩展字段 */
};
  1. Data Blocks 数据块:真正存放文件实际内容的区域。普通文件存文本/二进制数据,目录文件存放"文件名 - inode 号"映射表,软链接存放目标文件路径;
  2. Block Bitmap 块位图:二进制位图,每 1bit 标记一个数据块:0 = 空闲,1 = 已占用。新建文件时系统读取位图分配空闲块,删除文件时对应 bit 置 0 回收空间。为了实现高速的分配和回收,文件系统会把完整的块位图加载到内存中。删除文件时,只需要在内存里把对应数据块的标识位直接置 0,不需要擦除磁盘上的实际数据,操作速度极快——这就是为什么下载应用时相对较慢,而删除应用时却很快的原因;
  3. inode Table inode 表(Linux 灵魂):存储全部文件的属性集合。单个 inode 固定 128 字节(可配置为 256),存储文件元数据:文件类型、权限、属主、大小、时间戳、数据块指针。关键点:inode 不存储文件名!只存文件内容对应的磁盘块编号。磁盘上每一个独立文件/目录/软链接都独占一个唯一 inode 号,ls -li 命令输出的第一列数字就是 inode 编号:
ls -li

磁盘上存储的 inode(索引节点)核心结构体(EXT2):

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 { ... } osd1;     // 操作系统相关保留字段1
    __le32 i_block[EXT2_N_BLOCKS]; /* 指向数据块的指针数组 */
    __le32 i_generation;    // 文件版本号(用于 NFS)
    __le32 i_file_acl;      // 文件访问控制列表
    __le32 i_dir_acl;       // 目录访问控制列表
    __le32 i_faddr;         // 文件碎片地址
    union { ... } 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  // 索引块总数量

注意:文件名属性并未纳入 inode 数据结构内部;inode 的大小一般是 128 字节或 256 字节;任何文件的内容大小可以不同,但属性大小一定是相同的。

  1. inode Bitmap inode 位图:与块位图逻辑一致,每 1bit 标记一个 inode 是否空闲,负责分配回收文件属性节点。

创建一个文件的流程主要有 4 个操作:

  1. 存储属性:内核先找到一个空闲的 inode(假如是 263466),把文件信息记录到其中;
  2. 存储数据:该文件需要存储在若干磁盘块,假如内核找到三个空闲块:300、500、800,将内核缓冲区的第一块数据复制到 300,下一块复制到 500,以此类推;
  3. 记录分配情况:文件内容按顺序 300、500、800 存放,内核在 inode 上的磁盘分布区记录了上述块列表;
  4. 添加文件名到目录:新的文件名 abc,内核将入口(263466,abc)添加到目录文件。文件名和 inode 之间的对应关系,把文件名和文件的内容及属性连接起来。

跨组但不跨区:文件的 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 号,然后进行文件访问。访问文件必须要知道当前工作目录,本质是必须能打开当前工作目录文件,查看目录文件的内容。而上级目录也有当前目录,一直追溯到根目录,这个过程就叫做路径解析

路径缓存:文件访问依赖路径解析,绝对路径默认从根目录 / 逐层读取各级目录文件,匹配文件名拿到目标 inode 才能访问文件。全程从根目录逐层解析速度较慢,Linux 通过缓存历史路径结构提升查找效率:操作系统会把历史访问过的文件路径构建成一棵树,以便后面快速查找。内核中维护树状路径结构的结构体叫做 struct dentry。

struct dentry {
    atomic_t d_count;
    unsigned int d_flags;   /* 受 d_lock 保护 */
    spinlock_t d_lock;
    struct inode *d_inode;  /* 名称所属的索引节点,NULL 表示负向(不存在) */
    struct hlist_node d_hash;   /* 查找哈希链表 */
    struct dentry *d_parent;    /* 父目录 */
    struct qstr d_name;
    struct list_head d_lru;     /* LRU 链表 */
    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;
    struct dentry_operations *d_op;
    struct super_block *d_sb;   /* dentry 树的根(超级块) */
    void *d_fsdata;
    int d_mounted;
    unsigned char d_iname[DNAME_INLINE_LEN_MIN]; /* 小名称内联存储 */
};

每个文件都有对应的 dentry 结构,包括普通文件,这样所有被打开的文件就可以在内存中形成整棵树的形状。树形节点同时隶属于 LRU(Least Recently Used,最近最少使用)结构进行节点淘汰,也隶属于 Hash 方便快速查找。更重要的是,这棵树整体构成了 Linux 的路径缓存结构:打开访问任何文件,都先在这棵树下根据路径查找,找到就返回属性 inode 和内容;没找到就从磁盘加载路径,添加 dentry 结构,缓存新路径。

inode 和 datablock 映射:inode 内部存在 __le32 i_block[EXT2_N_BLOCKS],EXT2_N_BLOCKS = 15(12 个直接指针 + 一级/二级/三级间接指针各 1 个),就是用来进行 inode 和 block 映射的,这样"文件 = 内容 + 属性"就都能找到了。

  1. 直接块指针(inode 里 12 个直接指针):指针直接指向普通数据块,数据块里存的就是文件真实内容(文字、图片、程序数据)。假设一块 4KB:12 × 4KB = 49152 字节(48KB)。如果文件超过 48KB,12 个直接指针根本不够存所有数据块的地址,存不下大文件;
  2. 一级/二级/三级间接块指针:第一步,inode 的间接指针指向索引表块(数据块索引表),这个索引表里只存一堆子指针(块号),完全没有文件内容,只是中转目录;第二步,索引表里的子指针指向普通数据块,走到这一层才是存放文件内容的地方;
  3. 举二级间接流程:inode 二级指针 → 一级索引表(全是指针,无内容)→ 二级索引表(全是指针,无内容)→ 普通数据块(真正文件内容)。这些一级、二级、三级间接指针本质上就是为了解决文件内容太多的问题。

挂载分区

我们已经能根据 inode 号在指定分区找文件了,也已经能根据目录文件内容找指定的 inode 了。问题来了:inode 不能跨分区,Linux 又可以有多个分区,我怎么知道文件在哪个分区?答案就是挂载(mount)。

# 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 设备,是一种伪设备,允许将文件作为块设备来使用。这种机制使得可以将文件(比如 ISO 镜像文件)挂载(mount)为文件系统,就像它们是物理硬盘分区或外部存储设备一样。

ls /dev/loop* -l

软硬链接底层差异,从 inode 一眼看懂

  1. 硬链接:多个不同文件名指向同一个 inode 编号;目录映射表里多条记录绑定同一个 inode,inode 内链接计数 +1;删除其中一个文件名仅计数 -1,计数归 0 才释放 inode 与数据块。限制:不能跨分区、不支持对目录创建硬链接;
  2. 软链接(符号链接):拥有独立的全新 inode,自身是单独文件,数据块仅存储目标文件路径字符串;访问时读取路径再跳转原文件;原文件删除后软链接直接失效,无计数机制,支持跨分区、可以链接目录。

为什么明明没有手动做硬链接,硬链接数还会变化?因为每一个目录创建后都会有当前目录和上级目录,自动进行了硬链接。

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

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

系统架构

Linux 文件系统是一套分层、解耦的精密设计:

核心设计思想是解耦:把两件强绑定、改一个就必须动另一个的东西拆分开,让两者互相独立、互不依赖。带来巨大好处:

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

实施过程

  1. 用 dd 制作磁盘镜像文件,mkfs.ext4 格式化,体验"分区 → 格式化"完整流程;
  2. 用 ls -li 观察文件 inode 号,用 stat 命令查看 inode 中的属性信息;
  3. 创建硬链接与软链接,对比 ls -li 输出与链接计数的变化,验证 inode 机制;
  4. 用 df -h 观察挂载点,mount/umount 回环设备,理解挂载的含义;
  5. 结合超级块、位图概念,理解 rm 删除文件后数据可恢复的原理。

应用价值

SEO关键词

Linux文件系统, inode, 块组, 超级块, 位图, EXT4, 软硬链接, 磁盘分区

评论(0