VFS 对象地图:从 ext4 superblock 到 dentry、inode 和 address_space

107 阅读1小时+

VFS 对象地图:从 ext4 superblock 到 dentry、inode 和 address_space

上一篇struct file 定义为“一次打开实例”。但要回答“文件到底在哪里”,必须同时看到两套结构:

  • ext4 在块设备上持久化的 superblock、block group、group descriptor、inode table、目录项和 extent tree;
  • 挂载后 Linux 在内存中建立的 VFS struct super_block、mount、dentry、inode 和 address_space

如果先从一个具体 inode 号开始推导,读者会在尚未理解文件系统几何布局时,就被迫使用 s_blocks_per_groupbg_inode_table 等字段。本文因此改用从顶层向下的阅读顺序:

先抓住一个总原则:Linux 里的“文件”不是某一个结构体,而是同一对象在不同问题下的多种表示。

要回答的问题关键对象/关系
这段设备字节应按什么格式解释?on-disk superblock → block group → group descriptor
这个名字指向谁?目录项(磁盘)/ dentry(内存)→ inode number / VFS inode
文件内容落在哪里?on-disk inode → extent → ext4 block
文件在当前进程的路径视图中出现在哪里?struct path = mount + dentry
已读写的内容缓存在哪里?inode → address_space → folio

全文只做两次“跨层”:先从 ext4 的磁盘坐标走到一个具体 inode 和数据块,再从 mount namespace 的名字走到同一个 VFS inode。JBD2 事务细节、分配器策略,以及 RCU-walk、符号链接和最终分量的完整规则留给各自后文;这里仅在它们影响对象关系时点到为止。

分区边界
  → ext4 on-disk superblock
  → block group
  → group descriptor / bitmap / inode table
  → on-disk inode
  → 目录项与 extent tree
  → 完整定位一个具体文件
  → 挂载后的 VFS 内存对象
  → 用一次简单路径查找把两侧接起来
  → 观察 link/create/unlink/rename 如何改变关系

首次阅读可以先沿标题、关系图和每节结论走主线,把字段偏移表与第 8.2~8.4 节的逐字节计算当作复现时再查的“参考层”。这是一条概念依赖链,不是说每次运行都必须逐层读取磁盘。缓存命中时,许多 I/O 会被省掉,但对象之间的逻辑关系不变。所有磁盘格式结构讲完之前,本文也不使用 report.txt 的 inode #14 倒推格式;它会在后面作为一次完整验算。

一、先分清三层坐标:分区、ext4 块号、内存对象

同一个“位置”在本文中有三种坐标,必须先把它们分开:

层次起点典型坐标本文例子
整块磁盘/虚拟磁盘设备 LBA 0分区起始 LBA、长度环回设备后面的镜像文件
ext4 文件系统该分区或块设备范围的字节 0ext4 block number、块内偏移block 2067
VFS 内存对象内核挂载后创建的对象super_block *inode *dentry *&ext4_sb&inode#14

image.png

本文说“ext4 物理块 2067”时,指的是 ext4 自己块号空间中的 block,不是宿主 SSD 的最终 LBA。若 ext4 位于分区中,还要再加分区起始偏移;若块设备后面是 loop、device-mapper 或虚拟磁盘,则下面还有其他映射层。

二、从分区参数到 ext4 on-disk superblock

2.1 分区只给出可用边界,不会自动产生 ext4

分区表中最重要的是“起始 LBA + 长度”。内核为该分区创建块设备后,向 mkfs.ext4 暴露一个从字节 0 开始、长度为 partition_bytes 的设备视图。ext4 块号全部相对于这个视图;struct ext4_super_block 不需要保存分区在整盘上的起始 LBA。

mkfs.ext4 在构造文件系统时综合三类输入:

输入例子影响
设备几何可用字节数、逻辑/物理扇区、I/O 对齐提示总 block 数、元数据对齐
命令行参数-b 4096-I 256-i-g-Oblock size、inode size/密度、group 容量、feature flags
mke2fs.conf 与文件系统 profilesmallbighugenews 等用途未显式指定参数的默认值

因此“分区大小确定”并不能唯一推出所有 superblock 字段;block size、inode 密度、journal、64bitflex_bgmetadata_csum 等都会受 mkfs 选项和 e2fsprogs 配置影响。

2.2 主 superblock 固定在文件系统内字节偏移 1024

ext4 的主 on-disk superblock 总长度是 1024 bytes,固定从文件系统起点后的字节偏移 1024 开始:

4 KiB block size:

filesystem byte 0                                      4095
│ block 0                                             │
├─ 0..1023:保留/引导空间
├─ 1024..2047:primary ext4 superblock(1024 bytes)
└─ superblock 仍在 ext4 block 0 内

1 KiB block size:

block 0              block 1                 block 2...
0..1023              1024..2047
保留/引导空间       primary superblock       group descriptor table...

这也解释了 s_first_data_block:1 KiB block 文件系统通常为 1,2 KiB 及以上通常为 0。它表示纳入 block-group 编号空间的第一个文件系统块,不是“第一个能存放普通文件内容的块”。

2.3 struct ext4_super_block 的 1024-byte 具体结构

这是磁盘格式结构,所有整数字段均按 little-endian 存储。它不含内核指针;其职责是保存足以解码整个文件系统的全局几何、能力与状态。

完整代码:Linux v6.12.65 struct ext4_super_block。下表只按本文主线重排字段职责,不代替完整定义。

superblock 内偏移宽度字段作用
0x0004s_inodes_count全文件系统 inode 总数
0x0044s_blocks_count_loblock 总数低 32 位;启用 64bit 时与 s_blocks_count_hi 组合
0x0084s_r_blocks_count_lo为特权用户保留的 block 数低位
0x00C4s_free_blocks_count_lo全局空闲 block 摘要计数低位
0x0104s_free_inodes_count全局空闲 inode 摘要计数
0x0144s_first_data_blockblock-group 块号空间的起点
0x0184s_log_block_sizeblock_size = 2^(10 + value) bytes
0x01C4s_log_cluster_sizebigalloccluster_size = 2^(10 + value) bytes;未启用时与 block size 一致
0x0204s_blocks_per_group每个 block group 最多管理多少 block
0x0244s_clusters_per_group每 group 的 cluster 数;未启用 bigalloc 时等于 blocks/group
0x0284s_inodes_per_group每 group 分配多少 inode 槽位
0x02C4s_mtime最后挂载时间低位
0x0304s_wtime最后写 superblock 的时间低位
0x0342s_mnt_count自上次 fsck 以来的挂载次数
0x0362s_max_mnt_count触发检查建议的挂载次数阈值
0x0382s_magicext2/3/4 签名,必须为 0xEF53
0x03A2s_statecleanly unmounted、errors detected 等状态位
0x03C2s_errors检测到错误时 continue / remount-ro / panic 策略
0x0404s_lastcheck上次文件系统检查时间
0x0444s_checkinterval检查时间间隔
0x0484s_creator_os创建者 OS 编码
0x04C4s_rev_level磁盘格式 revision
0x0544s_first_ino第一个非保留 inode 号,现代 ext4 通常为 11
0x0582s_inode_size每条 on-disk inode 记录的字节数
0x05A2s_block_group_nr当前这份 superblock 副本所在的 group 号
0x05C4s_feature_compat不识别某位时仍可读写挂载的兼容特性
0x0604s_feature_incompat不识别某位必须拒绝挂载的不兼容特性
0x0644s_feature_ro_compat不识别某位时最多只能读挂载的特性
0x06816s_uuid文件系统 UUID,也参与多种 metadata checksum
0x07816s_volume_namevolume label
0x08864s_last_mounted最近一次挂载目录字符串
0x0CE2s_reserved_gdt_blocks为未来扩容保留的 GDT block 数
0x0D016s_journal_uuid外部 journal 的 UUID,如果使用
0x0E04s_journal_inum内部 journal 文件的 inode 号
0x0E44s_journal_dev外部 journal 设备号
0x0E84s_last_orphan旧式 orphan inode 链的起点
0x0EC16s_hash_seedhtree 目录哈希种子
0x0FC1s_def_hash_version默认目录哈希算法
0x0FE2s_desc_size启用 64bit 时的 group descriptor 字节数
0x1004s_default_mount_opts文件系统内记录的默认挂载选项
0x1044s_first_meta_bgmeta_bg 布局的起始 meta block group
0x1084s_mkfs_time文件系统创建时间低位
0x1504s_blocks_count_hiblock 总数高 32 位
0x1544s_r_blocks_count_hi保留 block 数高 32 位
0x1584s_free_blocks_count_hi空闲 block 数高 32 位
0x15C2s_min_extra_isize已有 inode 的最小扩展字节数
0x15E2s_want_extra_isize新 inode 期望保留的扩展字节数
0x1642s_raid_strideRAID chunk 大小提示,单位为文件系统 block
0x1688s_mmp_blockmultiple-mount protection 元数据块号
0x1704s_raid_stripe_widthRAID stripe width 提示
0x1741s_log_groups_per_flex每个 flex group 包含 2^value 个 block group
0x1751s_checksum_typemetadata checksum 算法类型,当前有效值为 CRC32C
0x194..0x1FF多字段first/last error 记录记录错误时间、inode、block、内核函数和行号
0x240..0x280多字段quota、backup groups、checksum seed、encoding、orphan file新特性的全局入口
0x284..0x3FB376s_reserved保留到 1024-byte 末尾
0x3FC4s_checksum超级块 CRC32C checksum

把偏移细节收起来,superblock 对下游结构的控制关系如下:

image.png

这张表最重要的不是记住偏移,而是看出五类信息:

  1. 几何:block/inode 总数、block size、每 group 容量、inode/descriptor size。
  2. 摘要状态:全局 free block/free inode 计数、保留量。
  3. 格式能力compat/incompat/ro_compat 决定后续字节应如何解码。
  4. 身份与恢复:UUID、label、state、error policy、journal/orphan 入口。
  5. 完整性:checksum 以及对 group descriptor/bitmap checksum 所需的 UUID/seed。

抽象地说,on-disk superblock 是这个 ext4 实例的“解码说明书 + 全局总账”,不是一张逐文件索引表。它告诉内核后续结构怎样分组、字段怎样解释以及当前总体状态;具体文件仍要经过目录项、inode table 和 extent 才能找到。

例如本文 4 KiB 样本的 s_log_block_size = 2,因为:

block_size = 2^(10 + 2) = 4096 bytes

s_feature_incompat 中的 EXTENTS 表示文件系统能够出现 extent 格式 inode;64BIT 决定高位块号和扩展 descriptor 的解码;FLEX_BG 允许多个 group 的 bitmap/inode table 集中放置。不检查 feature flags 就直接套某一种结构,是磁盘解码中最危险的错误之一。

2.4 mkfs.ext4 如何从参数构造这些字段

下面是逻辑依赖顺序,旨在说清哪个结果依赖哪个参数,不是对 e2fsprogs 函数调用顺序的逐行转录:

image.png

对本文的 64 MiB 镜像,命令明确指定 -b 4096 -I 256

device_bytes       = 64 × 1024 × 1024 = 67,108,864
block_size         = 4096
blocks_count       = 67,108,864 / 4096 = 16,384
first_data_block   = 0
blocks_per_group   = 32,768
group_count        = ceil((16,384 - 0) / 32,768) = 1
inode_size         = 256
inodes_per_group   = 16,384
inode_table_blocks = ceil(16,384 × 256 / 4096) = 1,024
descriptor_size    = 64
GDT_blocks         = ceil(1 × 64 / 4096) = 1

-i bytes-per-inode 在这条命令中没有显式指定,因此 inode 密度来自当时 mke2fs.conf 为该容量选中的 profile。这次输出的有效密度是:

bytes_per_inode = 67,108,864 / 16,384 = 4096

然后 mke2fs 还要让 inode 总数、inodes_per_group 和 inode-table block 数满足 group 边界、bitmap 容量和对齐约束。所以不能在脱离 profile 与舍入/对齐规则时,只凭分区大小声称唯一推出 s_inodes_count

blocks_per_group = 32768 并不意味着这个小镜像真的有 32768 个 block。它是一个 group 的容量上限;本文唯一的 group 0 是被文件系统边界截短的最后一组,实际只覆盖 block 0..16383

确定几何后,mkfs.ext4 还必须完成下列构造,文件系统才不只是一份 superblock:

  • 为每个 group 建立 descriptor,填入 block bitmap、inode bitmap 和 inode table 的实际块号;
  • 在 bitmap 中先标记 superblock/GDT、bitmap、inode table、journal 等元数据已占用;
  • 初始化保留 inode,其中 root directory 固定使用 inode #2,内部 journal 通常使用 inode #8
  • 创建根目录数据,常规 mke2fs 还会建立 lost+found
  • 根据已占用的元数据和初始文件,回填 superblock 全局 free counters 与每组 descriptor counters;
  • 计算 superblock、group descriptor 和 bitmap 等元数据 checksum。

本文命令使用 lazy_itable_init=0,lazy_journal_init=0,因此在 mkfs 阶段完整初始化 inode tables 和 journal,而不是把部分清零工作延迟到挂载后。

2.5 主 superblock、备份与 group descriptor table 是什么关系

主 superblock 是挂载时的默认入口,但 ext4 可在后续 block group 中保留 superblock 和 group descriptor table 副本。启用常见的 sparse_super 时,副本位于 group 01,以及组号为 3、5、7 的正整数次幂的 group 中;sparse_super2 则用 s_backup_bgs[] 明确指定备份 group。

这里经常出现一个容易误解的说法:“主 GDT 紧随 primary superblock”中的“紧随”,以文件系统 block 为单位,而不是以字节为单位。 在传统的非 meta_bg 布局中,若包含 primary superblock 的文件系统块号为 sb_block,则:

primary_gdt_first_block = sb_block + 1

因此,4 KiB block 下的 superblock 虽然在 block 0 的字节 1024..2047,GDT 也不会从字节 2048 开始,而是从下一个完整的文件系统块——block 1——开始。1 KiB block 下,superblock 恰好位于 block 1,所以 GDT 从 block 2 开始。

图中的 block 2..8 是本文样本启用在线扩容能力后预留的 GDT blocks;它们不是 7 份当前 GDT,而是给未来增加 block group descriptor 使用的空间。其数量来自 superblock 的 s_reserved_gdt_blocks,实际边界可由 dumpe2fsReserved GDT blocks at 2-8 交叉验证。

更大文件系统中,只有 sparse_supersparse_super2 选中的 block group 才在组首放置 backup superblock,并从下一块开始放置 backup GDT;普通 group 没有这两个副本。它们用于主元数据损坏后的恢复,并不是日常查找文件时轮询的多个“主人”。此外,上图描述的是传统 GDT 布局:启用 meta_bg 后,完整的 descriptor 集合可以分布到多个 meta block group,不能再把“整张 GDT 永远连续放在主 superblock 后面”当作无条件规则。

对本文只有一个 group 的 64 MiB 镜像,不存在后续 group 可供存放额外备份。更大文件系统才能直观看到 sparse backup 布局。

2.6 本文样本的 superblock 值从哪里看

dumpe2fs -h image 是用户态解码 on-disk superblock 的观察工具,它不参与内核运行时定位。本文 mkfs 参数下的结构性输出为:

Filesystem magic number:  0xEF53
Filesystem revision #:    1 (dynamic)
Filesystem features:      has_journal ... extent 64bit flex_bg ... metadata_csum
Inode count:              16384
Block count:              16384
First block:              0
Block size:               4096
Group descriptor size:    64
Reserved GDT blocks:      7
Blocks per group:         32768
Inodes per group:         16384
Inode blocks per group:   1024
Flex block group size:    16
First inode:              11
Inode size:               256
Journal inode:            8
Checksum type:            crc32c

UUID、checksum、创建时间和 free counters 会随每次创建与后续写入变化;文中不把它们当作固定常量。

三、block group:superblock 如何把连续块号分组

superblock 已经给出 s_first_data_blocks_blocks_per_group 和总 block 数。ext4 从 s_first_data_block 开始,每 s_blocks_per_group 个连续文件系统块划成一组。这是 mkfs 时确定的静态几何,不是运行中根据文件聚类临时分组。

对编号为 g 的 group:

group_first_block = first_data_block + g × blocks_per_group

group_last_block  = min(group_first_block + blocks_per_group - 1,
                        blocks_count - 1)

group_count       = ceil((blocks_count - first_data_block)
                         / blocks_per_group)

例如一个 4 KiB block、共 76800 个 block 的 ext4,若 first_data_block = 0blocks_per_group = 32768

group 0: block     0 .. 32767    (32768 个)
group 1: block 32768 .. 65535    (32768 个)
group 2: block 65536 .. 76799    (11264 个,最后一组)

未启用 bigalloc 时,一个 block bitmap 的每一 bit 对应一个文件系统 block。一张 bitmap 通常自己占一个 block,因此最多管理:

block_size × 8 bits

4 KiB block 时就是 4096 × 8 = 32768 个 block,也就是常见的 s_blocks_per_group = 32768。每个完整 group 因而覆盖 32768 × 4096 = 128 MiB

从逻辑管理角度看,一个 group 同时拥有四类资源:

image.png

这里的“拥有”表示逻辑归属。启用 flex_bg 后,多个 group 可组成 flex group,它们的 bitmap 和 inode table 可集中放到 flex group 前部,不一定物理落在各自所属的 group 范围内。因此 group 边界可以用公式计算,但 bitmap 和 inode table 的真实位置必须查 group descriptor。

四、group descriptor:每个 group 的资源地址簿

4.1 group descriptor table 是一组按 group 号索引的固定大小记录

每个 block group 都对应一条 struct ext4_group_desc。所有 descriptor 按 group 号排成 group descriptor table(GDT):

group_desc[0]  → group 0 的 bitmap、inode table 与摘要计数
group_desc[1]  → group 1 的 bitmap、inode table 与摘要计数
...
group_desc[g]  → group g

descriptor size 来自 superblock 的 s_desc_size。未启用 64bit 的老格式通常为 32 bytes;本文样本启用 64bit,每条为 64 bytes。GDT 需要的 block 数为:

gdt_blocks = ceil(group_count × descriptor_size / block_size)

常规非 meta_bg 布局中,主 GDT 紧跟主 superblock 所在 block 之后。存在 superblock 备份的 group 通常也保留 GDT 备份;sparse_supersparse_super2meta_bg 和 resize 预留会改变备份布局,因此不应把“每个 group 都完整复制 GDT”当作无条件规则。

4.2 struct ext4_group_desc 的关键字段

完整代码:Linux v6.12.65 struct ext4_group_desc

descriptor 内偏移字段作用
0x00bg_block_bitmap_loblock bitmap 物理块号低 32 位
0x04bg_inode_bitmap_loinode bitmap 物理块号低 32 位
0x08bg_inode_table_loinode table 起始物理块号低 32 位
0x0Cbg_free_blocks_count_lo本组空闲 block 数低 16 位
0x0Ebg_free_inodes_count_lo本组空闲 inode 数低 16 位
0x10bg_used_dirs_count_lo本组目录 inode 数低 16 位
0x12bg_flagsinode/block bitmap 是否延迟初始化、inode table 是否已清零
0x18bg_block_bitmap_csum_loblock bitmap checksum 低位
0x1Abg_inode_bitmap_csum_loinode bitmap checksum 低位
0x1Cbg_itable_unused_loinode table 末尾尚未使用的记录数低位
0x1Ebg_checksumdescriptor 自身 checksum 低 16 位
0x20bg_block_bitmap_hiblock bitmap 块号高 32 位,仅扩展 descriptor
0x24bg_inode_bitmap_hiinode bitmap 块号高 32 位
0x28bg_inode_table_hiinode table 块号高 32 位
0x2C..0x32count/unused 高位free blocks、free inodes、used dirs、unused inode 计数高位
0x38bg_block_bitmap_csum_hiblock bitmap checksum 高位
0x3Abg_inode_bitmap_csum_hiinode bitmap checksum 高位

高位与低位字段组合后才是完整块号,例如:

inode_table_start = (bg_inode_table_hi << 32) | bg_inode_table_lo

descriptor 存的是块号,不是 C 指针。它们全部位于同一 ext4 文件系统块号空间中。

image.png

4.3 descriptor 指出的三个并列结构

image.png

三者不是“bitmap 指向 inode table”的三层指针链,而是使用同一 group-local index 的并列结构。

image.png

block bitmap

未启用 bigalloc 时,本组 block bitmap 的 bit n 表示“本组第 n 个文件系统 block 是否已占用”:

是否启用 bigalloc 由 superblock 的 s_feature_ro_compatBIGALLOC feature bit 决定,dumpe2fs -hFilesystem features 也会列出它。启用后 bitmap 的一个 bit 对应一个 cluster 而不是一个 block,下面这套逐 block 公式不能原样使用。

group = (physical_block - first_data_block) / blocks_per_group
group_first_block = first_data_block + group × blocks_per_group
local_block_index = physical_block - group_first_block

bitmap_byte = local_block_index / 8
bit_in_byte = local_block_index % 8

公式中的变量来源与含义如下。这里的 / 是向下取整的整数除法,% 是取余;group 号、组内 block index、byte index 和 byte 内 bit index 都从 0 开始。

变量从哪里取得含义
physical_block不是从 bitmap 读取,而是本次准备查询的物理文件系统块号。验证已有文件时通常来自 inode extent;分配时则是分配器正在检查的候选块要判断“是否已占用”的目标 block
first_data_blockon-disk superblock 的 s_first_data_blockdumpe2fs -h 显示为 First blockblock group 编号空间的起点;4 KiB 文件系统通常为 0,1 KiB 文件系统通常为 1
blocks_per_groupon-disk superblock 的 s_blocks_per_groupdumpe2fs -h 显示为 Blocks per group每个完整 block group 包含多少个文件系统 block
group由第一条公式计算,不是磁盘中的独立字段physical_block 属于哪个 block group
group_first_blockfirst_data_blockgroupblocks_per_group 计算该 group 的第一个物理文件系统块号
local_block_index由目标块号减去组首块号得到目标块在本 group 内的零基 block index,也就是 block bitmap 中的 bit index n
bitmap_bytelocal_block_index / 8 计算目标 bit 位于 block bitmap 内容的第几个 byte;这是相对 bitmap 起点的 byte offset
bit_in_bytelocal_block_index % 8 计算目标 bit 位于该 byte 内的第几位;ext4 的 bit 0 对应该 byte 的最低有效位

block bitmap 本身位于哪里,要另外读取当前 group descriptor 的地址字段:

block_bitmap_block = (group_desc[group].bg_block_bitmap_hi << 32)
                     | group_desc[group].bg_block_bitmap_lo

bitmap_fs_offset = block_bitmap_block × block_size + bitmap_byte

其中 block_size = 1024 << s_log_block_sizes_log_block_size 来自 superblock;block_bitmap_block 是 bitmap 所在的物理文件系统块号,bitmap_fs_offset 是相对文件系统起点的实际字节偏移。若 ext4 位于分区内,设备绝对偏移还要再加分区起始偏移。本文样本的 group_desc[0] 给出 block_bitmap_block = 9

bit 为 1 表示已分配,为 0 表示可分配。它用于分配和回收空闲空间,不保存“某文件逻辑块对应哪个物理块”的映射。文件块映射保存在 inode 的 extent tree 中。

inode bitmap

inode 号从 1 开始,group 与组内 index 从 0 开始:

inode_group = (ino - 1) / inodes_per_group
local_index = (ino - 1) % inodes_per_group

bitmap_byte = local_index / 8
bit_in_byte = local_index % 8

这里同样使用向下取整的整数除法和取余,所有 index 都从 0 开始。

变量从哪里取得含义
ino查找已有文件时来自父目录的 directory entry,进入内存后也保存在 VFS inode.i_ino;它不是从 inode bitmap 扫描出来的要查询的 ext4 inode number;有效编号从 1 开始
inodes_per_groupon-disk superblock 的 s_inodes_per_groupdumpe2fs -h 显示为 Inodes per group每个 block group 管理多少个 inode-table slot
inode_group(ino - 1) / inodes_per_group 计算inode 属于哪个 block group;公式先减 1,是因为 inode number 从 1 开始而 group 从 0 开始
local_index(ino - 1) % inodes_per_group 计算inode 在本 group 内的零基 slot index,同时也是 inode bitmap 中对应的 bit index
bitmap_bytelocal_index / 8 计算对应 bit 位于 inode bitmap 内容的第几个 byte,是相对 bitmap 起点的 offset
bit_in_bytelocal_index % 8 计算对应 bit 位于该 byte 内的第几位

inode bitmap 的物理位置来自 group_desc[inode_group],而不是由 inode 号直接推出:

inode_bitmap_block = (group_desc[inode_group].bg_inode_bitmap_hi << 32)
                     | group_desc[inode_group].bg_inode_bitmap_lo

bitmap_fs_offset = inode_bitmap_block × block_size + bitmap_byte

本文样本中,group_desc[0] 给出 inode_bitmap_block = 25。因此 inode bitmap 从文件系统字节偏移 25 × 4096 = 102400 开始,公式得到的 bitmap_byte 再在此基础上定位。

inode bitmap 的 bit local_index 只表示同编号 inode-table slot 是否已占用,不保存 mode、size、links 或 extent。创建 inode 时,分配器先根据 descriptor 的 free counters 选 group,再在 inode bitmap 找 0 bit,将它置 1,并初始化 inode table 的同 index 记录。

inode table

inode table 是 mkfs 时预先分配的固定大小记录数组:

inode_table_bytes  = inodes_per_group × inode_size
inode_table_blocks = ceil(inode_table_bytes / block_size)
变量从哪里取得含义
inodes_per_groupsuperblock 的 s_inodes_per_group本 group 的 inode-table slot 总数
inode_sizesuperblock 的 s_inode_sizedumpe2fs -h 显示为 Inode size每条 on-disk inode 记录占用的字节数
block_size由 superblock 的 s_log_block_size 计算:1024 << s_log_block_sizedumpe2fs -h 显示为 Block size一个 ext4 文件系统 block 的字节数
inode_table_bytesinodes_per_group × inode_size 计算一个 group 的全部 inode 记录合计需要多少 bytes
inode_table_blocksceil(inode_table_bytes / block_size) 计算inode table 需要占用多少个完整文件系统 block;ceil 表示不足一块也要向上补成一整块
bg_inode_table_lo/hi当前 group 的 on-disk struct ext4_group_desc,GDT 中按 group 号取得inode table 起始物理块号的低 32 位与高 32 位

本文样本的值代入后为:

inode_table_bytes  = 16384 × 256 = 4194304
inode_table_blocks = ceil(4194304 / 4096) = 1024
inode_table_start  = (0 << 32) | 41 = block 41
inode_table_range  = block 41 .. block 1064

它的起始块不能由 group 号直接套固定偏移推出,必须读取 bg_inode_table_hi/loflex_bg 正是利用“descriptor 记录真实位置”这一点,让多个 group 的 inode tables 物理集中。

以 inode #14 为例,把 inode number → group → group descriptor → inode table slot 这条定位链展开如下:

image.png

4.4 读已有文件与分配新文件使用的不是同一条路

读已有文件:
directory entry → inode number → group_desc[g].bg_inode_table
                → inode table slot → extent → data block

创建/分配:
group free counters → inode bitmap 找 0 bit → 初始化 inode slot
                    → block bitmap 找空闲范围
                    → 把新块号写入 extent

所以读已有文件时不会扫描 block bitmap 去“找文件内容”,也不会扫描 inode bitmap 去“找 inode”。目录项和 extent 已经给出了精确索引。

五、on-disk inode:一条固定大小的文件元数据记录

5.1 inode 号是 table slot 的全局编号

ext4 inode number 不是物理块号。它先选中一个 block group,再选中该 group inode table 中的一条记录:

group       = (ino - 1) / inodes_per_group
index       = (ino - 1) % inodes_per_group
table_start = group_desc[group].bg_inode_table

byte_offset_in_table = index × inode_size
inode_block          = table_start + byte_offset_in_table / block_size
offset_in_block      = byte_offset_in_table % block_size

公式中的变量来源与含义如下。这里的 / 是向下取整的整数除法,% 是取余;groupindex 和块内偏移都从 0 开始。

变量/字段从哪里取得含义
ino查找文件时来自父目录的 directory entry;inode 进入内存后对应 VFS inode.i_ino要定位的 ext4 inode number,从 1 开始编号
inodes_per_groupon-disk superblock 的 s_inodes_per_groupdumpe2fs -h 显示为 Inodes per group每个 block group 管理的 inode-table slot 数量
group(ino - 1) / inodes_per_group 计算,不是 inode 中存储的字段目标 inode 所属的 block group 编号
index(ino - 1) % inodes_per_group 计算目标 inode 在该 group inode table 中的零基 slot index
group_desc[group]从 GDT 中按 group 号选择的 struct ext4_group_desc 记录保存该 group 的 bitmap、inode table 地址及空闲计数等信息
bg_inode_table公式中的简写;实际由 descriptor 的 bg_inode_table_hi/lo 组合,即 hi × 2^32 + lo该 group inode table 的起始物理文件系统块号
table_start读取并组合 group_desc[group].bg_inode_table_hi/lo 得到bg_inode_table 含义相同,是后续地址计算使用的 inode table 起始块号
inode_sizesuperblock 的 s_inode_sizedumpe2fs -h 显示为 Inode size一条 on-disk inode 记录占用的字节数
byte_offset_in_tableindex × inode_size 计算目标 inode 记录相对 inode table 起点的字节偏移
block_size由 superblock 的 s_log_block_size 计算:1024 << s_log_block_sizedumpe2fs -h 显示为 Block size一个 ext4 文件系统 block 的字节数
inode_blocktable_start + byte_offset_in_table / block_size 计算实际包含目标 inode 记录的物理文件系统块号
offset_in_blockbyte_offset_in_table % block_size 计算目标 inode 记录在 inode_block 内的起始字节偏移

最终记录相对文件系统起点的字节偏移为:

inode_record_fs_offset = inode_block × block_size + offset_in_block

如果 ext4 位于磁盘分区中,换算为整块设备的绝对字节偏移时,还要加上该分区的起始字节偏移。

因为 inode 号从 1 开始,group 和数组 index 从 0 开始,公式中必须先减 1。对边界值做一个纯几何验算:若 inodes_per_group = 16384,inode #16384 是 group 0 的 index 16383,inode #16385 才是 group 1 的 index 0

inode table 自身只是定长记录数组,里面没有每条记录的单独目录。只有先通过 superblock 得到 inode_size,才能正确跨过一条记录寻找下一条。

5.2 本文样本中 256-byte on-disk inode 记录的字段布局

内存 VFS struct inode 与磁盘 inode 记录不是同一个结构的直接镜像。记录前部由 on-disk struct ext4_inode 定义并按 little-endian 编码,基础部分沿用 ext2/3 布局;更大的 inode record 则在 128-byte 基础部分后容纳 ext4 扩展字段与 inode body 内联 xattr 空间。

完整代码:Linux v6.12.65 struct ext4_inode

本文 mkfs 指定 inode_size = 256,与普通文件定位最相关的布局如下:

记录内偏移大小磁盘字段含义
0x002i_mode文件类型和权限
0x022i_uid_loUID 低 16 位
0x044i_size_lo文件长度低 32 位
0x084i_atime访问时间低位
0x0C4i_ctimeinode 状态变更时间低位
0x104i_mtime文件内容修改时间低位
0x144i_dtime删除时间,现代 ext4 的部分语义受 orphan 机制影响
0x182i_gid_loGID 低 16 位
0x1A2i_links_count硬链接计数
0x1C4i_blocks_lo占用空间计数低位;通常以 512-byte sector 计,huge_file inode flag 可改变组合后计数的单位
0x204i_flagsext4 inode flags,包括 EXT4_EXTENTS_FL
0x244i_osd1OS 相关字段
0x28..0x6360i_block[15]extent tree 根、旧式 block map、fast symlink 或其他内联内容
0x644i_generationinode generation,可用于 NFS 文件句柄等
0x684i_file_acl_lo扩展属性 block 号低 32 位
0x6C4i_size_high普通文件 size 高 32 位
0x704i_obso_faddr已废弃的 fragment address
0x74..0x7F12i_osd2blocks/UID/GID 高位和 inode checksum 低位等 OS 相关字段
0x802i_extra_isize128-byte 基础记录之后已使用的 ext4 扩展字节数
0x822i_checksum_hiinode checksum 高位
0x84..0x9316i_ctime_extra/i_mtime_extra/i_atime_extra/i_crtime高精度时间与创建时间低位
0x94..0x9F12i_crtime_extra/i_version_hi/i_projid创建时间扩展、version 高位、project ID
0xA0..0xFF剩余空间inode record 的剩余区域可存放内联 extended attributes,实际起点受 i_extra_isize 影响

两个根本性事实:

  1. on-disk inode 中没有 i_ino 字段。 inode 号就是这条记录在全文件系统 inode tables 中的编号。
  2. on-disk inode 中不保存文件名。 文件名位于父目录的目录数据中,目录项只把名字映射到 inode number。

还要区分“inode 记录大小”和“当前结构字段已使用的大小”:s_inode_size = 256 表示 inode table 中每个 slot 占 256 bytes,不表示 struct ext4_inode 的命名字段恰好填满 256 bytes。i_extra_isize 说明 128-byte 基础部分之后有多少字节属于已定义的扩展 inode 字段,余下空间可用于 inode body 内联 xattr。

5.3 i_block[60] 是需要根据类型和 flags 解码的联合区域

i_block 不是无条件的“15 个数据块指针”。它的含义取决于 inode 类型、i_flags 和文件系统特性:

普通文件 + EXT4_EXTENTS_FL  → extent tree 的内联根
未使用 extent 的旧 inode       → direct/indirect block map
较短符号链接                 → 目标路径字符串(fast symlink)
inline-data inode               → 内联文件/目录内容的一部分

因此只有确认普通文件的 EXT4_EXTENTS_FL 后,才能将这 60 bytes 解码成下文的 extent tree。

六、目录数据:文件名如何变成 inode number

目录本身也是 inode。它的 extent 指向的不是普通用户字节流,而是由 ext4 目录项记录填充的目录数据 block。启用 filetype 特性时,常用记录是 struct ext4_dir_entry_2

完整代码:Linux v6.12.65 struct ext4_dir_entry_2

记录内偏移大小字段含义
0x004inode该名字对应的 inode number;0 表示未使用记录
0x042rec_len本条目录项占用的总字节数,也用于跳到下一条
0x061name_len文件名字节数
0x071file_typeregular/dir/symlink 等类型提示
0x08name_lenname[]不自带 NUL 结尾的文件名字节

rec_len 通常是 4-byte 对齐后的长度,并可包含记录后的空闲空间。删除目录项时,ext4 可合并相邻 rec_len,而不需把后面所有字节整体前移。file_type 是快速提示;inode 的 i_mode 才是文件类型的权威元数据。

查找一个路径分量的磁盘逻辑是:

当前目录 inode
  → extent tree
  → 目录数据 block
  → 查找 name 匹配的 ext4_dir_entry_2
  → 得到下一个 inode number

小目录可线性扫描目录项;启用 dir_index 且目录增大后,ext4 可使用 htree 快速选择目标叶子 block,最终仍要在目录项中验证文件名并取出 inode number。路径不保存在 superblock、group descriptor 或目标 inode 中;它是由多次“目录名字 → inode number”逐级连接出来的。

因此可以把磁盘侧主线压缩成三次换坐标:目录项把“名字坐标”换成 inode number,inode table 把 inode number 换成元数据记录位置,extent 再把“文件逻辑偏移”换成物理块位置。三个结构各做一次转换,谁也不能替代谁。

七、extent tree:文件逻辑块如何映射到 ext4 物理块

7.1 extent 是一段连续区间映射

extent 不是“一个逻辑块存一个物理块指针”,而是一条连续范围映射:

文件逻辑块 [ee_block, ee_block + length)
        →
ext4 物理块 [physical_start, physical_start + length)

例如:

ee_block       = 100
length         = 8
physical_start = 9000

logical 100..107 → physical 9000..9007

要查逻辑块 L,先确认 ee_block <= L < ee_block + length,然后:

physical = physical_start + (L - ee_block)

一条 12-byte extent 可以描述整段连续数据。两条 extent 之间没有映射的逻辑块是 hole,读取 hole 时文件系统返回零,不需要为它保存物理数据块。

7.2 extent tree 的根内联在 inode i_block[60]

ext4 on-disk extent tree 是一棵按文件逻辑块号有序、高度平衡的多路索引树。内部节点保存“逻辑起点 + 子节点物理块号”,真正的逻辑块到数据块映射只在叶子节点。

image.png

不论树多高,根始终位于 on-disk inode 的 60-byte i_block。根放不下所有叶子记录时,才分配额外 extent metadata block,并把 inode 内记录变成 index。

每个节点都以 12-byte struct ext4_extent_header 开头:

字段宽度意义
eh_magic16 bits固定为 0xF30A,用于验证节点格式
eh_entries16 bits当前有效 extent/index 记录数
eh_max16 bits当前节点最大记录容量
eh_depth16 bits从当前节点到叶子还有多少层;0 表示当前就是叶子
eh_generation32 bitsextent tree generation 字段

inode 根共 60 bytes,扣掉 12-byte header 后正好容纳 4 条 12-byte extent 或 index。4 KiB 外部节点在启用 metadata checksum 时,扣掉 header 和 4-byte tail 后可容纳 floor((4096 - 12 - 4) / 12) = 340 条记录。ext4 磁盘格式将最大 extent depth 限制为 5。

7.3 内部 index 和叶子 extent 的结构

完整代码:Linux v6.12.65 struct ext4_extentstruct ext4_extent_idxstruct ext4_extent_header

内部节点的每条 struct ext4_extent_idx 占 12 bytes:

字段宽度含义
ei_block32 bits该子树覆盖的第一个文件逻辑块
ei_leaf_lo32 bits子节点物理块号低 32 位
ei_leaf_hi16 bits子节点物理块号高 16 位
ei_unused16 bits保留

index 只保存子树的逻辑起点,不保存单独的逻辑终点。某条 index 覆盖到哪里,由下一条 index 的 ei_block 边界和子树中实际 extent 共同决定。

叶子节点的每条 struct ext4_extent 也占 12 bytes:

字段宽度含义
ee_block32 bitsextent 覆盖的第一个文件逻辑块
ee_len16 bitsextent 长度,同时编码 initialized/unwritten 状态
ee_start_hi16 bits数据物理起始块号高 16 位
ee_start_lo32 bits数据物理起始块号低 32 位

initialized extent 表示物理块已包含可读的文件数据。unwritten extent 表示空间已预分配,但未写入范围在文件语义上仍必须读为零,以防暴露介质中的旧数据。ee_len > 32768 时表示 unwritten,实际长度为 ee_len - 32768ee_len = 32768 是 initialized extent 可表示的特殊最大长度。

7.4 如何拼出 48-bit 物理块号,并加上 extent 内偏移

ext4 把物理起始块号拆成高 16 位与低 32 位:

extent_start = (ee_start_hi << 32) | ee_start_lo

ee_start_hi << 32 把高 16 位放到结果 bit 32..47,按位或 | 再填入 bit 0..31 的低位。因为两部分不重叠,这也等价于将它们相加。例如:

ee_start_hi = 1
ee_start_lo = 2067

extent_start = (1 << 32) | 2067
             = 4,294,967,296 + 2,067
             = 4,294,969,363

这只得到整条 extent 的物理起点。目标逻辑块的物理块还要加上它在 extent 中的相对位置:

physical_block = extent_start + (logical_block - ee_block)

例如 extent 记录“逻辑块 5..8 映射到物理块 1000..1003”,查逻辑块 7

extent_start   = 1000
ee_block       = 5
logical_block  = 7
relative_index = 7 - 5 = 2
physical_block = 1000 + 2 = 1002

7.5 给定文件字节偏移时怎样查树

先把字节偏移换成文件逻辑块号和块内偏移:

logical_block   = file_offset / block_size
offset_in_block = file_offset % block_size

完整代码:Linux v6.12.65 ext4_find_extent()ext4_ext_binsearch_idx()ext4_ext_binsearch()。其核心步骤是:

  1. 从 inode i_block 内的根 header 读取 eh_depth
  2. eh_depth > 0,在按 ei_block 升序排列的 index 中二分查找,选择最大的 ei_block <= logical_block
  3. (ei_leaf_hi << 32) | ei_leaf_lo 组成子节点物理块号,读入该 extent metadata block,在下一层重复。
  4. 到达 eh_depth = 0 的叶子后,在按 ee_block 升序排列的 extent 中选择最大的 ee_block <= logical_block
  5. 检查 logical_block < ee_block + actual_len。成立则命中;不成立则目标位于 extent 之间,是 hole。
  6. 命中后计算 physical_block,再用 physical_block × block_size + offset_in_block 得到文件系统设备内字节偏移。

每层查的是有序数组,而不是从左到右扫描整棵树。内部节点和叶子分别进入上面链接的两个完整二分查找函数。

7.6 树在写入、分裂、truncate 时怎样变化

  • 新映射若与相邻 extent 在逻辑块、物理块和 written/unwritten 状态上都兼容,ext4 会尽量合并。
  • inode 内 depth-0 根最多放 4 条 extent;继续插入时需分配外部叶子块,将 inode 根提升为 index。
  • 外部叶子满时分裂,并向父节点插入新 index;父节点也满时分裂向上传播。
  • truncate 和 hole punching 可删除、缩短或分裂 extent;外部节点不再需要时可释放并降低树高。
  • extent、inode 与 block bitmap 的相关更新是文件系统元数据变更,ext4 通过 JBD2 journal 保护所需的一致性。

不要将 on-disk extent tree 与 struct ext4_inode_info 中的内存 extent-status red-black tree 混淆。前者持久化保存物理块映射;后者是可回收的运行时 written、unwritten、delayed 和 hole 范围缓存。

八、现在完整定位 /home/alice/report.txt

到这里,superblock、block group、descriptor、inode table、目录项和 extent tree 都已经独立讲完。下面不再引入新的磁盘概念,只把它们组合成一次真实定位。

本节的 /home/alice/report.txt以 ext4 自身根目录为 / 的文件系统内部路径。挂载到进程的 mount namespace 后,同一对象在本文样本中显示为 /mnt/vfs-lab/home/alice/report.txt;前者便于只讲磁盘格式,后者要到第十一节加入 mount 关系后才能成立。

样本是一个 64 MiB ext4 镜像,经 /dev/loop0 挂载到 /mnt/vfs-lab,内部文件 /home/alice/report.txt 内容为 hello VFS\n。结构性几何与实测坐标是:

参数/坐标来源
block size4096superblock s_log_block_sizedumpe2fs -h
block count16384superblock s_blocks_count_hi/lo
first data block0superblock s_first_data_block
blocks per group32768superblock s_blocks_per_group
inodes per group16384superblock s_inodes_per_group
inode size256superblock s_inode_size
group 0 block bitmapblock 9group_desc[0].bg_block_bitmap
group 0 inode bitmapblock 25group_desc[0].bg_inode_bitmap
group 0 inode tableblock 41..1064group_desc[0].bg_inode_table = 41
ext4 根目录inode #2ext4 格式约定
home / alice / report.txtinode #12 / #13 / #14目录项查找与 debugfs ls
report.txt 数据block 2067inode #14 的 extent

8.1 从根目录逐级得到 inode #14

ext4 根目录 inode number 固定为 2。每一步都是“定位当前目录 inode → 通过 extent 读目录数据 block → 在目录项中找名字”:

image.png

正在查的分量当前目录 inode目录数据 block命中的磁盘目录项
home#210home → inode #12
alice#122065alice → inode #13
report.txt#132066report.txt → inode #14

这一阶段定位的是名字与 inode number,还没有读取 inode #14 的持久化记录。

8.2 由 inode #14 定位磁盘上的 on-disk inode 记录

本节的目标不是查找文件数据块,而是找到:inode #14 的持久化元数据究竟保存在哪个物理文件系统块、块内哪个字节区间。 磁盘上实际保存的是一段固定长度的序列化记录;读取这段记录后,ext4 再按 on-disk struct ext4_inode 的字段布局解码它。

第一步:收集定位所需的输入

开始计算前已经从目录项和 superblock 取得四个输入:

数值来源在本次计算中的含义
14解析父目录数据时,目录项 report.txt → inode #14要定位的 inode number
16384superblock 的 s_inodes_per_group每个 block group 管理 16384 个 inode slot
4096由 superblock 的 s_log_block_size 得到的 block size每个文件系统 block 占 4096 bytes
256superblock 的 s_inode_size每条 on-disk inode 记录占 256 bytes

这一步只收集输入,还没有读取 inode #14 的磁盘记录。

第二步:确定 inode 属于哪个 group、是组内第几个 slot

将 inode number 拆成 block group 编号和 group-local index:

group = (14 - 1) / 16384 = 0
index = (14 - 1) % 16384 = 13

这里先减 1,是因为 inode number 从 1 开始,而 group 和 table index 从 0 开始。整数除法得到 group = 0,表示应查询 GDT 的 group_desc[0];取余得到 index = 13,表示 inode #14 是该组 inode table 中从零计数的 slot 13,也就是第 14 条记录。

这一步的输出是 (group, index) = (0, 13)group 用于选择哪条 group descriptor,index 用于在该组 inode table 内选择哪条记录。

第三步:通过 GDT 找到该组 inode table 的起点

group = 0 查询 GDT 的 group_desc[0],读取 bg_inode_table_hi/lo

table_start = (bg_inode_table_hi << 32) | bg_inode_table_lo
            = (0 << 32) | 41
            = physical filesystem block 41

这一步解决的是“group 0 的 inode table 在磁盘哪里开始”。table_start = 41 只定位到了整张表的起点,还没有定位到表内的 inode #14

第四步:把 group-local slot 换算成物理块和块内偏移

先用一个 block 的大小除以一条 inode 记录的大小:

records_per_block = 4096 / 256 = 16 条 inode 记录

records_per_block = 16 表示一个 inode-table block 能完整容纳 16 条 inode 记录。因此 slot 0..15 都位于 inode table 的第一个 block,slot 16..31 才位于第二个 block。这一步可以先直观判断 index 13 不会越过 table 的第一个 block。

再把 group-local index = 13 精确换算成 inode table 内部的字节位置、相对 table 起点跨过的 block 数,以及目标 block 内的偏移:

byte_offset_in_table = 13 × 256 = 3328
block_delta          = 3328 / 4096 = 0
offset_in_block      = 3328 % 4096 = 3328
inode_block          = 41 + 0 = 41

每一行分别回答不同的问题:

计算结果含义
byte_offset_in_table = 3328从 inode table 起点跳过前面的 13 条记录后,inode #14 记录从表内第 3328 byte 开始
block_delta = 0从 inode table 起始 block 向后跨过的完整 block 数;结果为 0,说明目标仍在 table 的第一个 block
offset_in_block = 3328去掉完整 block 后的余数,即目标记录在当前 block 内从 byte 3328 开始
inode_block = 41inode table 起始块 41 加上 block_delta = 0,得到实际包含目标记录的物理文件系统块

到这里,ext4 正常读取所需的位置已经确定:读取 physical filesystem block 41,取其中 byte 3328..3583,这 256 bytes 就是 inode #14 的 on-disk inode 记录。

第五步:需要裸盘验证时,再换算成连续字节地址

块号加块内偏移适合文件系统代码使用;为了用 dd 或十六进制工具直接读取,可以再换算成相对文件系统起点的连续字节地址:

inode_record_fs_offset = inode_block × block_size + offset_in_block
                       = 41 × 4096 + 3328
                       = 171,264 bytes

record byte range      = [171264, 171264 + 256)
                       = [171264, 171520)

因此 inode #14 对应 /dev/loop0 中字节区间 [171264, 171520)。这一步不是 ext4 内核完成 inode 查找的必要步骤,主要用于本文的裸盘交叉验证。因为本例 loop device 从 ext4 文件系统 byte 0 开始,所以文件系统偏移和设备偏移相同;若 ext4 位于磁盘分区中,还要加上分区起始字节偏移。

image.png

inode bitmap 的 bit 13 与这条 inode-table slot 使用同一个 local index,但读既有 inode 时不需要先扫描 bitmap。目录项已经精确给出 inode number 14

8.3 从 8.2 找到的 inode 记录解码 depth-0 extent 根

8.2 已经完成“定位”:inode #14 的 256-byte 磁盘记录位于 filesystem block 41 的块内 byte 3328..3583,等价于文件系统字节区间 [171264, 171520)。8.3 从这个位置读取 256 bytes,并完成“解码”:先按 on-disk inode 字段布局解释记录前部,再根据 flags 决定如何解释其中的 i_block[60]

第一步:读取 8.2 定位出的 256-byte inode 记录
读取 filesystem block 41
取块内 byte [3328, 3584)
得到 inode #14 的 256-byte on-disk inode 记录

这里读到的仍是 little-endian 原始字节。struct ext4_inode 描述记录前部已定义字段的布局,不表示磁盘上保存了一个可直接解引用的内存 C 指针。

第二步:先解码外层 inode 字段

按照第 5.2 节给出的 struct ext4_inode 偏移读取关键字段,得到:

记录内 0x00: i_mode        = 0100644       → 这是普通文件
记录内 0x04/0x6C: i_size_lo/high = 10/0     → 组合后文件长度为 10 bytes
记录内 0x1A: i_links_count = 1             → 当前有 1 个硬链接
记录内 0x20: i_flags       包含 EXT4_EXTENTS_FL
记录内 0x28: i_block[60]   共 60 bytes

这一步的关键输出是 EXT4_EXTENTS_FL:它说明 i_block[60] 不是旧式的 15 个 direct/indirect block pointers,而应按 extent tree 节点解码。

第三步:读取 i_block[60] 开头的 extent header

i_block[60] 从整条 inode 记录的偏移 0x28 开始。先解码它开头的 12-byte struct ext4_extent_header

eh_magic   = 0xF30A  → 确认这是合法 extent 节点
eh_entries = 1       → header 后有 1 条有效记录
eh_max     = 4       → 这个 inode 内联根最多容纳 4 条记录
eh_depth   = 0       → 当前根节点同时就是叶子节点

eh_depth = 0 决定下一步的记录类型:header 后跟的是 struct ext4_extent 叶子记录,而不是 struct ext4_extent_idx 索引记录;因此不需要再读取外部 extent metadata block。

第四步:把唯一的叶子 extent 解码成逻辑块到物理块的映射

下表同时给出字段相对 i_block 的偏移,以及它在整条 256-byte inode 记录中的偏移:

i_block 内偏移inode 记录内偏移字段这一步解码出的含义
0x000x28eh_magic0xF30A合法 extent 节点
0x020x2Aeh_entries1当前只有 1 条有效 extent
0x040x2Ceh_max4inode 内联根最多容纳 4 条记录
0x060x2Eeh_depth0根同时是叶子,后续记录按 ext4_extent 解码
0x0C0x34ee_block0这条 extent 从文件逻辑块 0 开始
0x100x38ee_len1连续覆盖 1 个文件逻辑块
0x120x3Aee_start_hi0物理起始块号的高 16 位
0x140x3Cee_start_lo2067物理起始块号的低 32 位

组合物理块号并结合逻辑范围:

extent_start = (ee_start_hi << 32) | ee_start_lo
             = (0 << 32) | 2067
             = physical filesystem block 2067

文件逻辑块 0..0 → ext4 物理块 2067..2067

所以 8.3 的最终输出不是文件内容,而是一条块映射:inode #14 的文件逻辑块 0 存放在 ext4 物理块 2067。下一节再使用这条 extent 映射,把具体文件 offset 换算到 block 2067 内的字节位置。

8.4 读文件 offset 7 的完整换算

这一步的输入来自两处:文件读取请求给出 file_offset = 7;8.3 解码 inode #14 后给出文件大小与 extent。所有变量的来源和含义如下:

变量/字段从哪里取得含义
file_offset本次 read 请求要访问的文件字节位置,本例为 7相对文件开头的零基 byte offset;它不是磁盘偏移
i_size8.3 从 on-disk inode 的 i_size_lo/high 解码,本例为 10文件的有效字节长度;首先要确认 0 ≤ file_offset < i_size
block_sizesuperblock 的 s_log_block_size,本例为 4096一个 ext4 文件系统 block 的字节数
logical_blockfile_offset / block_size 计算目标字节位于文件的第几个逻辑块;文件逻辑块从 0 开始,与磁盘物理块号不是同一编号空间
offset_in_blockfile_offset % block_size 计算目标字节在该文件逻辑块内的 byte offset;这里不是 8.2 中 inode-table block 内的偏移
ee_block8.3 从叶子 struct ext4_extent 解码,本例为 0这条 extent 覆盖范围的起始文件逻辑块号
ee_len8.3 从叶子 extent 解码,本例为 1这条 extent 连续覆盖的文件逻辑块数量
ee_start_hi/lo8.3 从叶子 extent 解码,本例为 0/2067extent 物理起始块号的高 16 位和低 32 位
extent_startee_start_hi/lo 组合这条 extent 对应的第一个 ext4 物理文件系统块号
relative_indexlogical_block - ee_block 计算目标逻辑块距离本 extent 起点有几个 block
physical_blockextent_start + relative_index 计算实际保存目标文件逻辑块的 ext4 物理文件系统块号
device_offsetphysical_block × block_size + offset_in_block 计算目标字节在本例 /dev/loop0 中的连续 byte offset

第一步,把文件 byte offset 拆成“文件逻辑块号 + 逻辑块内偏移”。这里的 / 是向下取整的整数除法,% 是取余:

logical_block   = file_offset / block_size
                = 7 / 4096
                = 0

offset_in_block = file_offset % block_size
                = 7 % 4096
                = 7

结果表示文件 offset 7 位于文件逻辑块 0 内的 byte 7。接着检查 8.3 解码出的 extent 是否覆盖这个逻辑块:

extent logical range = [ee_block, ee_block + ee_len)
                     = [0, 0 + 1)
                     = [0, 1)

ee_block ≤ logical_block < ee_block + ee_len
0        ≤ 0             < 1                  → 命中这条 extent

如果不满足这个范围条件,就不能使用下面的 relative_index;应继续查下一条 extent,找不到则该逻辑块是 hole。

第二步,组合 extent 的物理起始块号:

extent_start    = (ee_start_hi << 32) | ee_start_lo
                = (0 << 32) | 2067
                = 2067

这里的 extent_start = 2067 表示 extent 的第一个文件逻辑块映射到 ext4 物理块 2067

第三步,计算目标逻辑块在 extent 内的相对位置,再加到物理起始块号上:

relative_index  = logical_block - ee_block
                = 0 - 0
                = 0

physical_block  = extent_start + relative_index
                = 2067 + 0
                = 2067

relative_index = 0 表示目标是 extent 覆盖的第一个逻辑块,因此不需要在 extent_start 基础上继续向后移动,最终得到 physical_block = 2067

第四步,把“物理块号 + 块内 byte offset”换算成 loop device 上的连续字节地址:

device_offset   = physical_block × block_size + offset_in_block
                = 2067 × 4096 + 7
                = 8,466,439

所以文件 offset 7 对应 /dev/loop0 的字节偏移 8,466,439。本例 loop device 从 ext4 文件系统 byte 0 开始,因此文件系统偏移和设备偏移相同;如果 ext4 位于磁盘分区中,还要加上分区起始字节偏移。block 2067 开头 10 bytes 是 hello VFS\ni_size = 10 将有效文件内容限定在这 10 bytes,块中其余字节不属于文件语义范围。

8.5 block bitmap 可以验证 block 2067 已分配,但不参与主定位链

本镜像只有 group 0:

group_first_block = 0 + 0 × 32768 = 0
local_block_index = 2067 - 0 = 2067
bitmap_byte       = 2067 / 8 = 258
bit_in_byte       = 2067 % 8 = 3

因此 block bitmap block 9 中 byte 258 的 bit 3 应为 1。这与 debugfs -R 'testb 2067' 相互验证,但“block 2067 属于 report.txt”的证据仍然来自 inode extent,而不是 bitmap。bitmap 只能证明该 block 已被某种用途占用。

8.6 depth-1 实例:为什么 inode 内记录会变成 index

report.txt 只有一条 extent,无法展示外部树节点。另一个受控实验只写文件逻辑块 0、2、4、6、8、10,中间的奇数块都是 hole,因此生成 6 条不能合并的 extent。一次真实 debugfs dump_extents 显示:

inode #12 的 i_block(Level 0/1,eh_depth=1)
└─ index: logical 0..10 → extent metadata block 7193
                                  │
                                  ▼
block 7193(Level 1/1,eh_depth=0)
├─ extent logical 0  len 1 → physical 6169
├─ extent logical 2  len 1 → physical 6681
├─ extent logical 4  len 1 → physical 6682
├─ extent logical 6  len 1 → physical 6683
├─ extent logical 8  len 1 → physical 6684
└─ extent logical 10 len 1 → physical 6685

inode 根只能放 4 条记录,第 5 条 extent 出现后需要将叶子搬到外部元数据 block,并把 inode 内根提升为 index。查 logical block 8 时:

根:选中 ei_block=0 的 index → 读 block 7193
叶:选中 ee_block=8, ee_len=1
结果:physical = 6684 + (8 - 8) = 6684

查 logical block 9 时,叶子二分查找仍先选中 ee_block=8,但 9 < 8 + 1 不成立,所以结果是 hole。物理块号受分配历史影响,重跑实验可能变化;树层级、查找与 hole 判定规则不变。

九、挂载后:on-disk superblock 如何变成 VFS struct super_block

9.1 挂载建立的三层 superblock 关系

到目前为止讲的都是 ext4 磁盘格式。Linux 挂载时读取主 on-disk superblock,验证 magic、feature flags、几何与 checksum,再建立 VFS 与 ext4 运行时对象:

image.png

三个名字相似但不是同一对象:

对象位置/生命期作用
on-disk struct ext4_super_block持久化在文件系统字节 1024..2047ext4 全局几何、features、持久化状态和 checksum
struct ext4_sb_info内存,ext4 挂载实例的私有状态缓存几何、group descriptor block 引用、分配/journal/选项/锁等运行时信息
VFS struct super_block内存,文件系统被装载时存在Linux 对“一个已装载文件系统实例”的统一表示

9.2 VFS struct super_block 的具体结构分工

VFS superblock 比 on-disk superblock 大得多,且具体布局受内核版本与配置影响。它不是 ABI,不应依赖字段字节偏移;应按功能组理解:

完整代码:Linux v6.12.65 struct super_block

功能组字段含义本例
实例身份s_dev用于搜索/区分块设备文件系统实例的 dev_tloop 设备的 major/minor
实例身份s_type文件系统类型与挂载实现ext4
实例身份s_id / s_uuid日志用人类可读标识与 UUID 的内存表示由 ext4 挂载初始化
几何/限制s_blocksize / s_blocksize_bits已验证的文件系统 block size 及其 log24096 / 12
几何/限制s_maxbytes该实例支持的最大文件字节偏移由 ext4 根据特性设定
几何/限制s_time_gran / s_time_min / s_time_maxinode 时间粒度与可表示范围由 ext4 格式能力设定
格式身份s_magic文件系统 magic 的内存表示0xEF53
对象根s_root该文件系统实例的根 dentryext4 root inode #2 的 dentry
操作表s_opsuper_operationsinode 回写/释放、statfs、sync/freeze 等实例操作
操作表s_d_op该实例 dentry 的默认 dentry_operationsext4 需要时设定
操作表s_export_op / s_xattrexportfs/NFS 和 xattr 入口由 ext4 挂载初始化
具体文件系统s_fs_info具体文件系统私有指针struct ext4_sb_info *
后端 I/Os_bdev / s_bdev_file块设备后端及内核打开引用;内存/伪文件系统可不使用/dev/loop0
后端 I/Os_bdiwriteback 后端信息与 loop 块设备后端关联
对象集合s_inodes / s_inode_lru该实例已实例化 inode 和可回收 inode 管理运行时状态
对象集合s_dentry_lru该实例可回收 dentry 的 LRU运行时状态
mount 关系s_mounts引用该 superblock 的 mounts至少包含 /mnt/vfs-lab 处 mount
生命周期s_count / s_activesuperblock 内存引用与活跃引用计数挂载/卸载流程管理
生命周期s_umountmount/unmount/freeze 等实例级状态变更的读写信号量挂载期间存在
冻结s_writers分级跟踪写入、写缺页和文件系统内部修改,用于 freeze/thaw运行时同步状态
错误报告s_wb_errsyncfs() 等记录该实例的 writeback error sequence运行时错误状态
状态位s_flags / s_iflagssuperblock 级运行语义与内部状态不等于 on-disk feature flags

运行时 flags 还要再分一层:super_block.s_flags 保存 SB_RDONLY 等文件系统实例级状态;vfsmount.mnt_flags 保存 MNT_NOEXECMNT_NOSUIDMNT_NODEV 等每个 mount 的策略。同一 superblock 可以被多个 mount 引用,这些 mount 的策略不必完全相同。二者都不等于 on-disk struct ext4_super_block.s_feature_*;后者描述的是磁盘格式能力。

9.3 struct ext4_sb_info 在两个 superblock 之间做什么

VFS superblock 只能放文件系统无关的公共字段。ext4 把具体实现所需的数据放在 struct ext4_sb_info,并由 s_fs_info 连过去。其关键角色包括:

完整代码:Linux v6.12.65 struct ext4_sb_info

VFS struct super_block
└─ s_fs_info → struct ext4_sb_info
                  ├─ s_es          → 缓存的 on-disk ext4_super_block
                  ├─ s_sbh         → 包含主 superblock 的 buffer_head
                  ├─ s_group_desc  → GDT 各元数据 block 的缓存引用
                  ├─ group/inode/block 几何的 CPU-endian 运行时值
                  ├─ mount options、feature-derived 状态与锁
                  ├─ allocator / flex-group / multiblock-allocation 状态
                  └─ JBD2 journal、orphan、quota、error 等 ext4 私有入口

比如 s_blocks_per_group 在磁盘 superblock 中是 little-endian __le32,挂载校验后会得到方便运行时使用的 CPU-endian 几何值。内核执行完整的 ext4_group_first_block_no() 时使用这套已建立的挂载状态,不会为每次计算调用 dumpe2fs 或重新解码整个设备。

9.4 从读 superblock 到可用 root dentry 的动态流程

1. VFS 根据文件系统类型与块设备获取/创建 struct super_block。
2. ext4 读取文件系统内偏移 1024 的 on-disk superblock。
3. 验证 0xEF53、block size、counts、feature flags 与 metadata checksum。
4. 分配 ext4_sb_info,建立 s_es、几何、GDT 缓存、journal 与 allocator 状态。
5. 查 inode #2 的 descriptor 和 inode-table slot,实例化 root inode。
6. 为 root inode 建立 root dentry,写入 super_block.s_root。
7. 建立 mount,将这个 superblock/root dentry 接入指定 mount namespace。

mount(2) 返回后,后续 pathname walk 才能从该 mount 的 root dentry 开始逐级查目录。如果 superblock 的不兼容 feature 无法理解、几何不合法或 checksum 校验失败,流程会在 root dentry 可用之前失败。

VFS superblock 并不只属于 ext4。tmpfs、procfs、devpts 也都会建立 struct super_block,即使它们没有块设备上的 struct ext4_super_block。所以“superblock”一词在上下文不明时很容易混淆:本文从此使用 on-disk ext4 superblockVFS struct super_block 明确区分。

十、从文件系统实例向下展开 VFS 内存对象

10.1 mount 回答“这个实例接在命名空间哪里”

VFS struct super_block 表示“被装载的文件系统实例是谁”,mount 则表示“它在某个 mount namespace 中接在哪里”。路径位置因此不能只用 dentry 表示:

Linux 源码同时出现 struct mountstruct vfsmount。前者是 mount 子系统的完整内部对象,后者内嵌在前者中,是 struct path 等 VFS 公共接口持有的视图:

完整代码:Linux v6.12.65 struct mountstruct vfsmount

struct mount
├─ mnt_parent / mnt_mountpoint   在挂载树中向父级连接
├─ namespace、传播与生命周期状态
└─ struct vfsmount mnt
      ├─ mnt_root                这个 mount 对外暴露的根 dentry
      ├─ mnt_sb                  所属 VFS superblock
      └─ mnt_flags               该 mount 的运行策略

本文泛称“mount”时指这一个逻辑对象;写具体字段时则保留源码中的 struct mount / struct vfsmount 层次。

struct path
├─ mnt       → 当前 mount
└─ dentry   → 该 mount 中的当前名字节点

同一 superblock 可通过多个 mount 出现在不同位置,bind mount 也可让同一 dentry 子树出现在多个路径下。路径遇到挂载点时,pathname walk 从覆盖目录的 dentry/mount 切换到被挂载文件系统的 root dentry/mount。

superblock:文件系统实例是谁
mount:     该实例在这个命名空间接在哪里
dentry:    在该 mount 中当前是哪个名字节点

10.2 dentry 回答“这个父目录下的名字解析到什么”

VFS pathname walk 逐个处理路径分量,并用 struct dentry 表示“某个父 dentry 下的某个名字”:

完整代码:Linux v6.12.65 struct dentry

/
└─ home       → dentry
   └─ alice   → dentry
      └─ report.txt → dentry

dentry 的关键状态包括名字、父 dentry、哈希/缓存状态、解析成功时的 inode 引用,以及文件系统的 dentry_operations。它是 dcache 中的内存对象,不是 on-disk ext4_dir_entry_2 的同构副本。

一个有用的抽象是:inode 更像“对象节点”,dentry 更像“父目录到这个对象的一条命名边”。 同一 inode 可因硬链接拥有多条命名边;同一个字符串也可在不同父目录下形成不同 dentry。这个模型不是结构体布局,但能直接解释“inode 不保存文件名”和“一个 inode 可以有多个名字”。

positive dentry:  "report.txt" → struct inode #14
negative dentry:  "missing.txt" → NULL

负 dentry 缓存“当前这个父目录下没有这个名字”,避免短时间内反复进入文件系统查磁盘目录数据。目录修改、重验证与缓存回收都会影响它的有效性,所以它不是永久的“不存在证明”。

10.3 VFS struct inode 回答“这个已实例化的文件对象是什么”

VFS inode 不保存路径名字,它统一表示文件对象的身份、类型、权限、大小与操作入口:

完整代码:Linux v6.12.65 struct inode 与 ext4 私有的 struct ext4_inode_info

struct inode
├─ i_ino        文件系统实例内的 inode number
├─ i_sb         所属 VFS superblock
├─ i_mode       类型与权限
├─ i_uid/i_gid  所有者
├─ i_nlink      硬链接名字数
├─ i_size       有效文件大小
├─ i_op         inode_operations
├─ i_fop        默认 file_operations
├─ i_mapping    内容缓存的 address_space
└─ i_data       该 inode 内嵌的 address_space

inode number 只在所属文件系统实例中有意义,所以运行时身份至少需要 (i_sb, i_ino) 这一组合。不同 superblock 中完全可以同时存在 inode #14

ext4 使用更大的 struct ext4_inode_info 容纳 VFS inode 和 ext4 运行时私有状态:

struct ext4_inode_info
├─ ext4 extent-status / allocation / journal 等私有状态
└─ struct inode vfs_inode

内核在 inode-cache miss 时,使用前文的 i_ino → group → descriptor → inode-table slot 链读取 on-disk inode,再将需要的持久化字段解码到 ext4_inode_info + vfs_inode。这不是对 256-byte 磁盘记录做 memcpy 就得到内存 struct inode;内存对象还有锁、引用、操作表、cache 和文件系统私有状态。

10.4 address_space 回答“这个 inode 的文件内容缓存在哪里”

完整代码:Linux v6.12.65 struct address_space

inode.i_mapping
  → struct address_space
       ├─ host       所属 inode
       ├─ i_pages    XArray:文件偏移索引到 folio
       ├─ nrpages    已缓存 page/folio 数量摘要
       ├─ a_ops      address_space_operations
       └─ writeback / error / mmap 相关状态

这里的 address space 不是进程 mm_struct 虚拟地址空间。文件 mmap() 会连接两者:VMA 记录文件映射,缺页时通过文件 address_space 获取 page-cache folio,再建立进程页表映射。

从坐标角度看,extent 回答“文件逻辑块怎样落到磁盘块”,address_space 回答“文件偏移对应的内容当前是否已在内存 folio 中”。前者是持久化映射,后者是可回收缓存;缓存未命中时,文件系统才借助前者补入内容。

十一、把 VFS 内存对象与 ext4 磁盘位置合起来

现在内存和磁盘两侧的每个概念都已经定义,可以用一次受控的 pathname lookup 把它们接起来。下面假设进程执行:

open("/mnt/vfs-lab/home/alice/report.txt", O_RDONLY);

路径中没有符号链接和嵌套 mount,各级目录搜索权限允许,且暂不展开 RCU/REF-walk、最终分量规则和 rename 并发保护。本节的目的只是连接对象图,不代替下一篇对 namei 的完整分析。

pathname lookup 的语义终点是代表 report.txtstruct path,也就是“所在 mount + 最终 dentry”。在实际 open() 实现中,path_openat() 会先分配一个尚未完成绑定的 struct file 外壳,再进行路径查找;找到或创建最终对象后才把 path、inode、操作表等补齐,成功时最后安装到 fdtable。读取文件内容是之后的另一阶段。

11.1 先把绝对路径拆成分量

VFS 将路径拆成:

/ | mnt | vfs-lab | home | alice | report.txt

因为路径以 / 开头,查找从当前进程在其 mount namespace 中看到的根 struct path 开始,而不是从 ext4 的 inode #2 直接开始。查找过程中始终维护:

current_path = (current mount, current dentry)

每处理一个普通分量,VFS 都会确认当前 inode 是目录、执行目录搜索权限检查,并尝试在当前父 dentry 下找到这个名字对应的子 dentry。

11.2 先在父文件系统中走到 /mnt/vfs-lab,再跨越 mount

mnt 和作为挂载点的 vfs-lab 属于挂载 ext4 之前的父文件系统,不是 /dev/loop0 中的 ext4 目录项。因此这段路径必须分成两段:

父文件系统:/ → mnt → vfs-lab(挂载点 dentry)
                              │
                              │ 跨越 mount
                              ▼
ext4 文件系统: /(root dentry / inode #2)→ home → alice → report.txt

这里说的“进入挂载点所在的分区”是用户视角的说法。内核并不是在查路径时重新扫描分区表、重新识别 ext4,而是从父文件系统的 mount + dentry 切换到一个已经建好的子 mount。本文样本的 /dev/loop0 严格说是 loop block device;如果 ext4 位于真实分区,这个后端也可以是 /dev/sda1 之类的分区块设备,挂载后的路径切换机制相同。

只想掌握对象地图时,先记住一次替换即可:(父 mount, 挂载点 dentry) → (子 mount, 子 mount 的 mnt_root)。本节只解释替换前后各是什么对象;nameidatastep_into()handle_mounts()lookup_mnt() 以及 RCU/REF-walk 的动态过程放到待发布的第 04 篇《路径不是字符串查表:mount namespace 和 pathname walk》展开。

11.2.1 查路径之前:mount(2) 已经把两个文件系统接好

第 9.4 节已经给出挂载的内部准备过程。对本例而言,mount(2) 成功返回前,内核已经完成:

  1. 解析挂载目标 /mnt/vfs-lab,保留父文件系统中的挂载点 struct path
  2. 打开并识别源块设备 /dev/loop0,读取 ext4 on-disk superblock。
  3. 建立 ext4 的 VFS struct super_block,使 s_bdev 指向 /dev/loop0
  4. 读取 ext4 root inode #2,建立 root dentry,使 s_root 指向它。
  5. 建立子 mount,使它的 mnt_sbmnt_root 分别指向这个 superblock 和 root dentry。
  6. 在 mount namespace 中登记覆盖关系:“父 mount 中的 vfs-lab dentry 被这个子 mount 覆盖”。

可以把挂载完成后的关系压缩为:

挂载点 key
(parent vfsmount, dentry("vfs-lab"))
                │
                │ mount namespace 中的覆盖关系
                ▼
child vfsmount
├─ mnt_root ─────→ ext4 root dentry
│                     ├─ d_inode ───→ root inode #2
│                     └─ d_sb ─────→ ext4 VFS super_block
└─ mnt_sb ───────→ ext4 VFS super_block
                          ├─ s_root ────→ 同一个 ext4 root dentry
                          └─ s_bdev ────→ /dev/loop0

这里的 mnt_root 是从哪里来的?它不是 ext4 磁盘中的字段,也不是由 /mnt/vfs-lab 这个挂载点 dentry 生成的。mnt_rootstruct vfsmount 中的内存 dentry 指针,来自“被挂载文件系统要暴露出来的根”。本例的建立链是:

完整代码入口:ext4 建立 s_rootget_tree_bdev_flags()s_root 交给 fc->root,以及 vfs_create_mount() 建立 mnt_root

ext4 on-disk inode #2
        │ ext4_iget(sb, EXT4_ROOT_INO, ...)
        ▼
内存 root struct inode
        │ d_make_root(root_inode)
        ▼
ext4 root dentry
        │
        ├─ sb->s_root = root dentry
        │
        └─ get_tree_bdev_flags(): fc->root = dget(sb->s_root)
                                      │
                                      │ vfs_create_mount()
                                      ▼
                    child_vfsmount->mnt_root = dget(fc->root)

这条链上有三个不能混成一个概念的“根”:磁盘格式用 inode number #2 标识 ext4 根对象,VFS superblock 用 s_root 持有该对象的 root dentry,mount 再用 mnt_root 表示“这个 mount 向上层暴露的树根”。普通整树挂载中,后两者恰好是同一个 dentry 指针;语义仍然不同。

这段关系的完整实现见 Linux v6.12.65 vfs_create_mount()。它不仅设置 mnt_sbmnt_root,还处理空 root、mount 分配、内核内部 mount flags、superblock 活跃引用、初始父子关系,以及把 mount 挂入 super_block.s_mounts;本文不再用两行摘录代替完整函数。

字段类型直接指向本例关系
mnt_rootstruct dentry *mount 对外暴露的根 dentrymnt_root->d_inode->i_ino = 2
mnt_sbstruct super_block *该 mount 所属的 VFS superblockmnt_root->d_sb == mnt_sb
mnt_sb->s_rootstruct dentry *整个文件系统实例的根 dentry普通整树挂载中 mnt_root == mnt_sb->s_root

dget(fc->root) 不会复制一个 root dentry,只会取得对同一对象的引用并延长其生命期。因此本例中 fc->rootmnt_rootmnt_sb->s_root 是同一个 dentry 指针;mnt_sb 自身则是另一种类型,直接指向 superblock。

不过,mnt_root 的通用含义是“这个 mount 向上层暴露的树根”,它不保证永远等于 mnt_sb->s_root。例如 bind mount 某个子目录时,新 mount 的 mnt_root 可以指向该子目录 dentry,而 mnt_sb->s_root 仍指向整个文件系统的根 dentry。

mnt_root 与挂载点也是两个不同的方向:

child struct mount 往上看:mnt_mountpoint → 父文件系统的 vfs-lab dentry
child struct vfsmount 往下看:mnt_root     → 进入子 mount 后的 ext4 root dentry

路径跨越挂载点时,正是从前者所在的父文件系统位置,切换到后者表示的子 mount 入口。

所以 open() 处理这条路径时,只需查这个现成的覆盖关系;它不会再执行一次挂载。

11.2.2 跨越 mount 后换了哪两个对象

image.png

跨越前后的 dentry 不是同一个对象:前者是父文件系统中的挂载点,后者是 ext4 mount 暴露的根。vfs-lab 已被消耗,ext4 根目录里第一个要查的是 home。这里的对象替换不会重新扫描分区表,也不会重新挂载设备;它只使用 mount namespace 中已经存在的连接关系。

11.2.3 为什么 ext4 root inode 固定为 #2

为什么固定为 #2?这是 ext2 创建磁盘格式时确定、随后由 ext3/ext4 继承的 on-disk ABI 约定,不是挂载时根据目录内容或空闲 inode 动态计算的结果。内核源码直接定义 EXT4_ROOT_INO = 2mkfs.ext4 创建文件系统时也明确使用这个编号建立根目录。低编号 inode 的约定为:

inode number约定用途
0不存在,不是有效 inode number
1bad-blocks inode,用于记录坏块
2root directory inode
3..10quota、boot loader、resize、journal 等特殊或保留用途
11传统格式中第一个非保留 inode;动态版本以 superblock 的 s_first_ino 为准

所以根目录不能像普通新文件一样从 inode bitmap 中任意选择一个空闲编号。格式化程序必须创建 inode #2;挂载时 ext4 也知道应读取 inode #2,再以对应的内存 struct inode 建立 root dentry。这里固定的只是 inode number,不是磁盘块号或字节位置。inode #2 仍然要通过普通 inode-table 定位公式找到其 on-disk inode 记录。本文样本中:

group       = (2 - 1) / 16384 = 0
index       = (2 - 1) % 16384 = 1
table_start = group_desc[0].bg_inode_table = block 41

inode #2 → group 0 inode table 的 slot 1
         → filesystem block 41 内 byte offset 1 × 256 = 256

根目录 inode 的数据 block 也不是固定常量;本文样本的 inode #2 extent 恰好指向目录数据 block 10。另外,#2 是 ext2/3/4 的格式约定,不是 VFS 对所有文件系统提出的统一要求。

因此,vfs-lab 这个名字负责找到挂载点;它不会出现在 ext4 根目录的磁盘目录项中。跨越 mount 后,路径的文本位置仍表现为 /mnt/vfs-lab,但 (mount, dentry) 已经换成另一个文件系统实例。

11.3 ext4 如何把一个名字分量变成下一级 inode

对象关系只需记住两层缓存和一条磁盘链:VFS 先以 (父 dentry, 分量名) 查询 dcache;没有可用答案时,ext4 才从当前目录 inode 的 extent 找到目录数据,在目录项中取得下一级 inode number。若该 inode 尚未实例化,再通过 inode number → group descriptor → inode table slot 读取磁盘记录,建立 dentry 与 VFS inode 的关联。

positive/negative dentry、RCU/REF-walk、慢查找和并发重试属于 pathname walk 的执行机制,待发布的第 04 篇再逐步展开;这里仅保留它们最终连接出的对象。

在本文受控镜像中,三轮 ext4 查找的实际坐标为:

当前 ext4 目录要查的名字cache miss 时读取的目录数据命中的目录项下一轮得到的对象
ext4 root inode #2home由 inode #2 extent 定位到 block 10home → inode #12home dentry + inode #12
inode #12alice由 inode #12 extent 定位到 block 2065alice → inode #13alice dentry + inode #13
inode #13report.txt由 inode #13 extent 定位到 block 2066report.txt → inode #14report.txt dentry + inode #14

这些 block 号描述缓存为空时磁盘侧如何提供答案;如果相应 dentry、inode 或目录 folio 已在缓存中,本次查找可以少做部分磁盘 I/O,但名字到 inode 的逻辑关系不变。

11.4 本例的完整对象链

image.png

把流程代入本文具体路径,可以压缩成下面这条对象与磁盘坐标链:

进程的 namespace root struct path
  → 父文件系统 dentry("mnt")
  → 父文件系统 mountpoint dentry("vfs-lab")
  → 跨越 mount:切换到 /dev/loop0 的 ext4 root dentry / inode #2
  → root 目录 block 10:dirent "home"      → inode #12
  → home 目录 block 2065:dirent "alice"   → inode #13
  → alice 目录 block 2066:dirent "report.txt" → inode #14
  → 最终 struct path = (ext4 mount, report.txt dentry)

这里最容易混淆的是两个边界:目录项查找只负责把名字变成 inode number;inode table 查找才把 inode number 变成 on-disk inode 记录。找到 inode #14 也只表示找到了文件元数据,文件内容还要继续解码 inode extent 才能定位。open() 如何围绕这条查找链预留 fd、构造 struct file 并最终发布,则由待发布的第 05 篇《open() 怎么造出 fd:从路径结果到 struct file》负责。

11.5 交互总览与最终对象关系

下面的交互图仍以 /mnt/vfs-lab/home/alice/report.txt 为路径轴,左侧显示命名空间和 VFS 对象,右侧显示 /dev/loop0 中的目录项、inode 记录和数据块。

从 pathname 到 ext4 物理块的真实文件定位器

打开 HTML 交互版:逐级查看 pathname 如何定位到 ext4 物理块

/ → mnt → vfs-lab → home → alice → report.txt → offset 0 逐级查看。图中符号地址如 &inode#14 只表示对象引用,不伪造一次运行中的内核虚拟地址。

在最后的 buffered read 状态,下列关系同时成立:

super_block.s_bdev       → /dev/loop0
block 2066 的磁盘目录项  → "report.txt" 映射到 inode number 14
dentry("report.txt")     → 缓存这次名字解析结果并关联内存 inode #14
inode.i_ino = 14         → inode table block 41 的 slot 13
inode extent logical 0   → data block 2067
address_space.i_pages[0] ← block 2067 读入后的 folio

读 offset 0 时,内存路径是:

fd
 → struct file
 → f_mapping / inode.i_mapping
 → address_space.i_pages[0]
      ├─ cache hit  → 直接从 folio 复制数据
      └─ cache miss → ext4 extent 查到 block 2067
                    → s_bdev=/dev/loop0 发起块 I/O
                    → 读入 folio 并放入 i_pages
                    → 只返回 i_size 范围内的字节

因此内存 inode 不是用一根指针直接指向“磁盘文件”。冷启动时的完整逻辑链是:

(superblock, inode number)
  → on-disk inode
  → extent 映射
  → ext4 物理块
  → s_bdev 块 I/O
  → address_space / folio

这里刻意画的是 buffered I/O;direct I/O 可以绕过 page cache,但仍要从 inode 的文件块映射走到后端设备。后文再讨论两条数据路径的边界。

十二、三类操作表各管什么

完整代码:Linux v6.12.65 struct inode_operationsstruct file_operationsstruct address_space_operations

操作表挂在哪里解决什么问题
inode_operationsinode->i_oplookup/create/unlink/rename/getattr 等名字和元数据动作
file_operationsinode->i_fop、打开后 file->f_opread_iter/write_iter/poll/ioctl/fsync 等打开实例动作
address_space_operationsmapping->a_opsread_folio/readahead/write_begin/write_end/writepages 等缓存与后端交换

这里的表格只标出对象上的操作入口,不展开调用顺序。为什么字节读写不放在 inode_operations、普通文件为何会连续经过 file_operationsaddress_space_operations、而 pipe/socket 又为何不会,统一放到待发布的第 06 篇《VFS 统一了什么:三组操作表和文件类型分派》比较。

前十一节描述的是一张对象快照;下面改用 link、create、unlink 和 rename 观察这张图随时间怎样变化。阅读时只追“名字、inode、打开引用、缓存、磁盘块”中哪一条关系被改动即可。

十三、软链接与硬链接:到底多了哪个对象

在总图的样本上执行:

cd /mnt/vfs-lab/home/alice
ln report.txt report.hard
ln -s report.txt latest

硬链接和软链接都表现为“多了一个名字”,但对象关系完全不同:

image.png

因此:

  • 硬链接增加的是一个指向 同一 inode 号 的磁盘目录项;路径查找后通常会有另一个 dentry,但不会复制 inode、address_space 或数据块。
  • 软链接增加了目录项 latest → inode #15,而 inode #15 是一个独立的 symlink inode。它保存的内容是目标的路径字符串 report.txt,而不是 inode #14 或指向 inode #14 的指针。目标较短时,ext4 通常把这段字符串直接内联保存在 symlink inode 的数据区域中(常称为 fast symlink);放不下时才需要额外的数据块。
  • 查找 latest 时,VFS 先到达 symlink inode #15,取出字符串 report.txt,再把它作为 pathname 的一部分继续解析;相对目标从软链接所在目录继续,绝对目标则重新选择解析根。目标怎样接回待解析路径、如何限制符号链接逃逸,由待发布的第 04 篇展开。
  • 如果删掉 report.txtreport.hard 仍直接到达 inode #14latest 保存的字符串没有自动改变,因此会变成悬空软链接。

十四、新建:从“不存在的名字”到 inode 与缓存内容

这里仅观察 open("report.txt", O_CREAT|O_WRONLY) 之后对象关系如何变化。图中磁盘编号沿用总图这次实测结果;分配器在另一次运行中完全可能选择别的 inode 或数据块。

image.png

这张对象图只区分三个时刻:

  1. create 完成后,命名空间里已经能找到名字,dentry 已从负变正,inode 也已经存在。
  2. buffered write 返回后,内容已经能从该 inode 的缓存路径观察到,但这不等于已经分配最终物理块或达到持久化边界。
  3. dirty folio 怎样产生、writeback 与 fsync() 的保证,以及 ext4 延迟分配和 journal,分别在待发布的第 08、09、10 篇落地。

十五、删除:名字消失,为什么 fd 仍然能读

为了把 i_nlink 和打开引用分开,先保留上一节的硬链接,并打开原名:

fd 3 -> struct file -> dentry("report.txt") -> inode #14
report.txt  -> inode #14
report.hard -> inode #14

然后依次删除两个名字:

image.png

unlink() 的直接对象是“父目录中的名字”,不是进程 fd,也不是 page-cache folio。到 t2 时,ls 已经看不到文件,但 fd 3 的 struct file 仍持有 f_path,所以现有 fd 仍能读写 inode #14。工具常把这类 dentry 显示为 report.txt (deleted);这只是一个仍被引用、但已经不再能从目录树正常查到的名字。

t3 也不表示每个磁盘扇区在 close() 函数返回前同步擦除。准确含义是:最后引用消失后,VFS/ext4 才具备回收 inode 和数据块的条件;journal、writeback 和块设备缓存仍决定磁盘更新在时间上的具体完成方式。

十六、移动:同一文件系统内通常只改目录关系

假设已经创建 /mnt/vfs-lab/archive,再执行:

mv /mnt/vfs-lab/home/alice/report.txt /mnt/vfs-lab/archive/report.txt

在同一个 ext4 superblock 内,核心变化不是“搬运 10 bytes”,而是 rename 原子地改目录项:

image.png

dcache 也要让名字/父目录关系与 rename 结果一致,但打开的 fd 不需要换成另一个 struct file,文件偏移和 inode 也不因此重建。

如果源和目标位于不同 mount,单次 rename() 会返回 EXDEV;即便两个 mount 引用同一文件系统,Linux 也不允许跨 mount boundary rename。本文从 ext4 的 /mnt/vfs-lab/... 移到 overlayfs 的 /tmp/... 时还同时跨了 superblock。用户态 mv 常退化成“在目标文件系统创建新 inode 并复制数据,再 unlink 源名字”:这时 inode 号、superblock、address_space 和物理数据块都会改变,已经打开的源 fd 仍指向旧 inode。

十七、实验怎样复现

17.1 总图中的 ext4 路径、inode 表与数据块

总图来自 GitHub 上的完整脚本 assets/vfs-ext4-full-chain-demo.sh。脚本在一次性容器里创建 64 MiB 镜像、挂载 loop 设备、创建完整路径,然后用 findmntstatfilefragdumpe2fsdebugfs 从运行时与磁盘格式两侧交叉观察:

docker run --rm --privileged --platform linux/amd64 \
  -v "$PWD/os/filesystem/assets:/src:ro" \
  x86lab:latest \
  sh /src/vfs-ext4-full-chain-demo.sh

这条命令需要 --privileged,因为容器内要建立并挂载 loop 设备;脚本只应在这里所示的可丢弃容器里运行,不应直接以特权方式在宿主机执行。代表性输出:

/mnt/vfs-lab /dev/loop0 ext4 rw,relatime
path=/mnt/vfs-lab                 inode=2
path=/mnt/vfs-lab/home            inode=12
path=/mnt/vfs-lab/home/alice      inode=13
path=.../home/alice/report.txt    inode=14 size=10

logical 0..0 -> physical 2067..2067
Filesystem magic number:  0xEF53
Filesystem revision #:    1 (dynamic)
Filesystem features:      has_journal ... extent 64bit flex_bg ... metadata_csum
Inode count:              16384
First block:              0
Block count:              16384
Block size:               4096
Group descriptor size:    64
Reserved GDT blocks:      7
Blocks per group:         32768
Inodes per group:         16384
Inode blocks per group:   1024
Flex block group size:    16
First inode:              11
Inode size:               256
Journal inode:            8
Checksum type:            crc32c

Primary superblock at 0, Group descriptors at 1-1
Block bitmap at 9
Inode bitmap at 25
Inode table at 41-1064

/                  : data block 10
/home              : data block 2065
/home/alice        : data block 2066
/home/alice/report.txt: data block 2067

inode 表每项 256 bytes,所以 inode #14 是从零计的第 13 项:

inode table 起点       = 41 × 4096
inode #14 在表内偏移   = (14 - 1) × 256 = 3328
inode #14 的设备偏移   = 41 × 4096 + 3328 = 171,264 bytes

这些块号是这次受控镜像的实际输出,不是 ext4 ABI 保证的常量;镜像大小、mkfs 选项、分配历史或 e2fsprogs 版本变化后,应以脚本当次输出重画坐标。

17.2 depth-1 extent tree 实验

GitHub 上的完整脚本 assets/vfs-ext4-extent-tree-demo.sh 创建另一个可丢弃 ext4 镜像,向稀疏文件的 6 个不连续逻辑块写入数据,然后同时使用 filefragdebugfs dump_extents 查看映射和树层级:

docker run --rm --privileged --platform linux/amd64 \
  -v "$PWD/os/filesystem/assets:/src:ro" \
  x86lab:latest \
  sh /src/vfs-ext4-extent-tree-demo.sh

该脚本会将数据写入 logical block 0、2、4、6、8、10,因此稳定产生超过 inode 根的 4-entry 容量的 extent tree。分配到的物理块号可能随运行变化,应以当次 Level 0/1Level 1/1 输出为准。它同样需要 --privileged,只应在可丢弃容器中运行。

17.3 软硬链接的 inode 身份

不需要特权挂载的链接实验是 GitHub 上的完整脚本 assets/vfs-object-map-demo.sh,运行命令:

docker run --rm --platform linux/amd64 \
  -v "$PWD/os/filesystem/assets:/src:ro" \
  x86lab:latest \
  sh /src/vfs-object-map-demo.sh

脚本完整内容没有依赖仓库外的辅助程序生成数据;它创建原文件、硬链接和符号链接,再用 statfindmnt 观察。

实际输出:

path=.../original  inode=36235773 links=2 type=regular file
path=.../hard-link inode=36235773 links=2 type=regular file
path=.../symbolic-link inode=36235774 links=1 type=symbolic link

证据很直接:

  • original 和 hard-link 名字不同,inode 号相同;
  • symbolic-link 自己是另一个 inode,它保存的是符号链接语义;
  • 跟随符号链接后才会继续解析到 original。

17.4 相邻路径可以落在不同 mount/superblock 上

/      overlay ...
/proc  proc    ...

path=.../original device=66 inode=36235773
path=/proc/uptime device=89 inode=4026531987

容器的 /tmp 属于 root overlayfs mount,/proc/uptime 属于 procfs mount。路径字符串都从 / 开始,但 pathname walk 在 /proc 处跨过了挂载点。

数字 inode 只在对应文件系统实例/设备语境中有意义,不能拿不同 superblock 的 inode 号做全局身份。

十八、对象生命周期为什么故意分开

现在可以解释一些看似矛盾的现象:

   dentry 可被 dcache 回收
      != 磁盘文件被删除

   一个名字 unlink
      != inode 立即销毁

   最后一个 fd close
      != 同名路径必须消失

   page-cache folio 被回收
      != inode/dentry 一起消失

每类对象回答不同问题,引用来源和回收条件也不同。现有内存系列的 shrinker 文章已经解释 dentry/inode cache 如何参与回收;本系列后面会继续解释 page-cache folio 的数据路径。

十九、把母图收起来

最终用一句话记每个对象:

on-disk ext4 superblock  整个 ext4 的全局几何、格式能力与持久化状态
block group              一段连续 block 号和一组 inode slot 的逻辑管理单元
group descriptor         某组 bitmap、inode table 位置与摘要计数的地址簿
on-disk inode            一个文件对象的持久化元数据与数据映射入口
extent tree              文件逻辑块范围到 ext4 物理块范围的持久化映射

VFS struct super_block   已装载文件系统实例的内存统一对象
mount                    该实例在 mount namespace 中的连接
dentry                   某个父目录下的名字解析结果
VFS inode                文件系统对象的内存统一元数据身份
address_space            文件内容缓存及其 I/O 操作入口
struct file              一次打开实例
fd                       当前进程打开文件表的索引

本文已经用一个无符号链接、无并发改名的受控路径把对象图接起来,但只保留了 pathname walk 的稳定骨架:struct path 起点、逐分量 dcache/文件系统查找,以及一次 mount 切换。

下一篇《路径不是字符串查表:mount namespace 和 pathname walk》专门展开 namei 的动态规则:不同解析起点、RCU-walk 与 REF-walk、中间/最终分量、..、符号链接、慢查找、跨挂载点和 openat2() 约束。它回答“这条对象链在真实路径解析中怎样走出来”,而不是重复本文的静态对象定义。

参考源码