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_group、bg_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 文件系统 | 该分区或块设备范围的字节 0 | ext4 block number、块内偏移 | block 2067 |
| VFS 内存对象 | 内核挂载后创建的对象 | super_block *、inode *、dentry * | &ext4_sb、&inode#14 |
本文说“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、-O | block size、inode size/密度、group 容量、feature flags |
mke2fs.conf 与文件系统 profile | small、big、huge、news 等用途 | 未显式指定参数的默认值 |
因此“分区大小确定”并不能唯一推出所有 superblock 字段;block size、inode 密度、journal、64bit、flex_bg、metadata_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 内偏移 | 宽度 | 字段 | 作用 |
|---|---|---|---|
0x000 | 4 | s_inodes_count | 全文件系统 inode 总数 |
0x004 | 4 | s_blocks_count_lo | block 总数低 32 位;启用 64bit 时与 s_blocks_count_hi 组合 |
0x008 | 4 | s_r_blocks_count_lo | 为特权用户保留的 block 数低位 |
0x00C | 4 | s_free_blocks_count_lo | 全局空闲 block 摘要计数低位 |
0x010 | 4 | s_free_inodes_count | 全局空闲 inode 摘要计数 |
0x014 | 4 | s_first_data_block | block-group 块号空间的起点 |
0x018 | 4 | s_log_block_size | block_size = 2^(10 + value) bytes |
0x01C | 4 | s_log_cluster_size | bigalloc 的 cluster_size = 2^(10 + value) bytes;未启用时与 block size 一致 |
0x020 | 4 | s_blocks_per_group | 每个 block group 最多管理多少 block |
0x024 | 4 | s_clusters_per_group | 每 group 的 cluster 数;未启用 bigalloc 时等于 blocks/group |
0x028 | 4 | s_inodes_per_group | 每 group 分配多少 inode 槽位 |
0x02C | 4 | s_mtime | 最后挂载时间低位 |
0x030 | 4 | s_wtime | 最后写 superblock 的时间低位 |
0x034 | 2 | s_mnt_count | 自上次 fsck 以来的挂载次数 |
0x036 | 2 | s_max_mnt_count | 触发检查建议的挂载次数阈值 |
0x038 | 2 | s_magic | ext2/3/4 签名,必须为 0xEF53 |
0x03A | 2 | s_state | cleanly unmounted、errors detected 等状态位 |
0x03C | 2 | s_errors | 检测到错误时 continue / remount-ro / panic 策略 |
0x040 | 4 | s_lastcheck | 上次文件系统检查时间 |
0x044 | 4 | s_checkinterval | 检查时间间隔 |
0x048 | 4 | s_creator_os | 创建者 OS 编码 |
0x04C | 4 | s_rev_level | 磁盘格式 revision |
0x054 | 4 | s_first_ino | 第一个非保留 inode 号,现代 ext4 通常为 11 |
0x058 | 2 | s_inode_size | 每条 on-disk inode 记录的字节数 |
0x05A | 2 | s_block_group_nr | 当前这份 superblock 副本所在的 group 号 |
0x05C | 4 | s_feature_compat | 不识别某位时仍可读写挂载的兼容特性 |
0x060 | 4 | s_feature_incompat | 不识别某位必须拒绝挂载的不兼容特性 |
0x064 | 4 | s_feature_ro_compat | 不识别某位时最多只能读挂载的特性 |
0x068 | 16 | s_uuid | 文件系统 UUID,也参与多种 metadata checksum |
0x078 | 16 | s_volume_name | volume label |
0x088 | 64 | s_last_mounted | 最近一次挂载目录字符串 |
0x0CE | 2 | s_reserved_gdt_blocks | 为未来扩容保留的 GDT block 数 |
0x0D0 | 16 | s_journal_uuid | 外部 journal 的 UUID,如果使用 |
0x0E0 | 4 | s_journal_inum | 内部 journal 文件的 inode 号 |
0x0E4 | 4 | s_journal_dev | 外部 journal 设备号 |
0x0E8 | 4 | s_last_orphan | 旧式 orphan inode 链的起点 |
0x0EC | 16 | s_hash_seed | htree 目录哈希种子 |
0x0FC | 1 | s_def_hash_version | 默认目录哈希算法 |
0x0FE | 2 | s_desc_size | 启用 64bit 时的 group descriptor 字节数 |
0x100 | 4 | s_default_mount_opts | 文件系统内记录的默认挂载选项 |
0x104 | 4 | s_first_meta_bg | meta_bg 布局的起始 meta block group |
0x108 | 4 | s_mkfs_time | 文件系统创建时间低位 |
0x150 | 4 | s_blocks_count_hi | block 总数高 32 位 |
0x154 | 4 | s_r_blocks_count_hi | 保留 block 数高 32 位 |
0x158 | 4 | s_free_blocks_count_hi | 空闲 block 数高 32 位 |
0x15C | 2 | s_min_extra_isize | 已有 inode 的最小扩展字节数 |
0x15E | 2 | s_want_extra_isize | 新 inode 期望保留的扩展字节数 |
0x164 | 2 | s_raid_stride | RAID chunk 大小提示,单位为文件系统 block |
0x168 | 8 | s_mmp_block | multiple-mount protection 元数据块号 |
0x170 | 4 | s_raid_stripe_width | RAID stripe width 提示 |
0x174 | 1 | s_log_groups_per_flex | 每个 flex group 包含 2^value 个 block group |
0x175 | 1 | s_checksum_type | metadata checksum 算法类型,当前有效值为 CRC32C |
0x194..0x1FF | 多字段 | first/last error 记录 | 记录错误时间、inode、block、内核函数和行号 |
0x240..0x280 | 多字段 | quota、backup groups、checksum seed、encoding、orphan file | 新特性的全局入口 |
0x284..0x3FB | 376 | s_reserved | 保留到 1024-byte 末尾 |
0x3FC | 4 | s_checksum | 超级块 CRC32C checksum |
把偏移细节收起来,superblock 对下游结构的控制关系如下:
这张表最重要的不是记住偏移,而是看出五类信息:
- 几何:block/inode 总数、block size、每 group 容量、inode/descriptor size。
- 摘要状态:全局 free block/free inode 计数、保留量。
- 格式能力:
compat/incompat/ro_compat决定后续字节应如何解码。 - 身份与恢复:UUID、label、state、error policy、journal/orphan 入口。
- 完整性: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 函数调用顺序的逐行转录:
对本文的 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 0、1,以及组号为 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,实际边界可由 dumpe2fs 的 Reserved GDT blocks at 2-8 交叉验证。
更大文件系统中,只有 sparse_super 或 sparse_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_block、s_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 = 0、blocks_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 同时拥有四类资源:
这里的“拥有”表示逻辑归属。启用 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_super、sparse_super2、meta_bg 和 resize 预留会改变备份布局,因此不应把“每个 group 都完整复制 GDT”当作无条件规则。
4.2 struct ext4_group_desc 的关键字段
完整代码:Linux v6.12.65 struct ext4_group_desc。
| descriptor 内偏移 | 字段 | 作用 |
|---|---|---|
0x00 | bg_block_bitmap_lo | block bitmap 物理块号低 32 位 |
0x04 | bg_inode_bitmap_lo | inode bitmap 物理块号低 32 位 |
0x08 | bg_inode_table_lo | inode table 起始物理块号低 32 位 |
0x0C | bg_free_blocks_count_lo | 本组空闲 block 数低 16 位 |
0x0E | bg_free_inodes_count_lo | 本组空闲 inode 数低 16 位 |
0x10 | bg_used_dirs_count_lo | 本组目录 inode 数低 16 位 |
0x12 | bg_flags | inode/block bitmap 是否延迟初始化、inode table 是否已清零 |
0x18 | bg_block_bitmap_csum_lo | block bitmap checksum 低位 |
0x1A | bg_inode_bitmap_csum_lo | inode bitmap checksum 低位 |
0x1C | bg_itable_unused_lo | inode table 末尾尚未使用的记录数低位 |
0x1E | bg_checksum | descriptor 自身 checksum 低 16 位 |
0x20 | bg_block_bitmap_hi | block bitmap 块号高 32 位,仅扩展 descriptor |
0x24 | bg_inode_bitmap_hi | inode bitmap 块号高 32 位 |
0x28 | bg_inode_table_hi | inode table 块号高 32 位 |
0x2C..0x32 | count/unused 高位 | free blocks、free inodes、used dirs、unused inode 计数高位 |
0x38 | bg_block_bitmap_csum_hi | block bitmap checksum 高位 |
0x3A | bg_inode_bitmap_csum_hi | inode bitmap checksum 高位 |
高位与低位字段组合后才是完整块号,例如:
inode_table_start = (bg_inode_table_hi << 32) | bg_inode_table_lo
descriptor 存的是块号,不是 C 指针。它们全部位于同一 ext4 文件系统块号空间中。
4.3 descriptor 指出的三个并列结构
三者不是“bitmap 指向 inode table”的三层指针链,而是使用同一 group-local index 的并列结构。
block bitmap
未启用 bigalloc 时,本组 block bitmap 的 bit n 表示“本组第 n 个文件系统 block 是否已占用”:
是否启用 bigalloc 由 superblock 的 s_feature_ro_compat 中 BIGALLOC feature bit 决定,dumpe2fs -h 的 Filesystem 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_block | on-disk superblock 的 s_first_data_block;dumpe2fs -h 显示为 First block | block group 编号空间的起点;4 KiB 文件系统通常为 0,1 KiB 文件系统通常为 1 |
blocks_per_group | on-disk superblock 的 s_blocks_per_group;dumpe2fs -h 显示为 Blocks per group | 每个完整 block group 包含多少个文件系统 block |
group | 由第一条公式计算,不是磁盘中的独立字段 | physical_block 属于哪个 block group |
group_first_block | 由 first_data_block、group 和 blocks_per_group 计算 | 该 group 的第一个物理文件系统块号 |
local_block_index | 由目标块号减去组首块号得到 | 目标块在本 group 内的零基 block index,也就是 block bitmap 中的 bit index n |
bitmap_byte | 由 local_block_index / 8 计算 | 目标 bit 位于 block bitmap 内容的第几个 byte;这是相对 bitmap 起点的 byte offset |
bit_in_byte | 由 local_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_size,s_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_group | on-disk superblock 的 s_inodes_per_group;dumpe2fs -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_byte | 由 local_index / 8 计算 | 对应 bit 位于 inode bitmap 内容的第几个 byte,是相对 bitmap 起点的 offset |
bit_in_byte | 由 local_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_group | superblock 的 s_inodes_per_group | 本 group 的 inode-table slot 总数 |
inode_size | superblock 的 s_inode_size;dumpe2fs -h 显示为 Inode size | 每条 on-disk inode 记录占用的字节数 |
block_size | 由 superblock 的 s_log_block_size 计算:1024 << s_log_block_size;dumpe2fs -h 显示为 Block size | 一个 ext4 文件系统 block 的字节数 |
inode_table_bytes | 由 inodes_per_group × inode_size 计算 | 一个 group 的全部 inode 记录合计需要多少 bytes |
inode_table_blocks | 由 ceil(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/lo。flex_bg 正是利用“descriptor 记录真实位置”这一点,让多个 group 的 inode tables 物理集中。
以 inode #14 为例,把 inode number → group → group descriptor → inode table slot 这条定位链展开如下:
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
公式中的变量来源与含义如下。这里的 / 是向下取整的整数除法,% 是取余;group、index 和块内偏移都从 0 开始。
| 变量/字段 | 从哪里取得 | 含义 |
|---|---|---|
ino | 查找文件时来自父目录的 directory entry;inode 进入内存后对应 VFS inode.i_ino | 要定位的 ext4 inode number,从 1 开始编号 |
inodes_per_group | on-disk superblock 的 s_inodes_per_group;dumpe2fs -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_size | superblock 的 s_inode_size;dumpe2fs -h 显示为 Inode size | 一条 on-disk inode 记录占用的字节数 |
byte_offset_in_table | 由 index × inode_size 计算 | 目标 inode 记录相对 inode table 起点的字节偏移 |
block_size | 由 superblock 的 s_log_block_size 计算:1024 << s_log_block_size;dumpe2fs -h 显示为 Block size | 一个 ext4 文件系统 block 的字节数 |
inode_block | 由 table_start + byte_offset_in_table / block_size 计算 | 实际包含目标 inode 记录的物理文件系统块号 |
offset_in_block | 由 byte_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,与普通文件定位最相关的布局如下:
| 记录内偏移 | 大小 | 磁盘字段 | 含义 |
|---|---|---|---|
0x00 | 2 | i_mode | 文件类型和权限 |
0x02 | 2 | i_uid_lo | UID 低 16 位 |
0x04 | 4 | i_size_lo | 文件长度低 32 位 |
0x08 | 4 | i_atime | 访问时间低位 |
0x0C | 4 | i_ctime | inode 状态变更时间低位 |
0x10 | 4 | i_mtime | 文件内容修改时间低位 |
0x14 | 4 | i_dtime | 删除时间,现代 ext4 的部分语义受 orphan 机制影响 |
0x18 | 2 | i_gid_lo | GID 低 16 位 |
0x1A | 2 | i_links_count | 硬链接计数 |
0x1C | 4 | i_blocks_lo | 占用空间计数低位;通常以 512-byte sector 计,huge_file inode flag 可改变组合后计数的单位 |
0x20 | 4 | i_flags | ext4 inode flags,包括 EXT4_EXTENTS_FL 等 |
0x24 | 4 | i_osd1 | OS 相关字段 |
0x28..0x63 | 60 | i_block[15] | extent tree 根、旧式 block map、fast symlink 或其他内联内容 |
0x64 | 4 | i_generation | inode generation,可用于 NFS 文件句柄等 |
0x68 | 4 | i_file_acl_lo | 扩展属性 block 号低 32 位 |
0x6C | 4 | i_size_high | 普通文件 size 高 32 位 |
0x70 | 4 | i_obso_faddr | 已废弃的 fragment address |
0x74..0x7F | 12 | i_osd2 | blocks/UID/GID 高位和 inode checksum 低位等 OS 相关字段 |
0x80 | 2 | i_extra_isize | 128-byte 基础记录之后已使用的 ext4 扩展字节数 |
0x82 | 2 | i_checksum_hi | inode checksum 高位 |
0x84..0x93 | 16 | i_ctime_extra/i_mtime_extra/i_atime_extra/i_crtime | 高精度时间与创建时间低位 |
0x94..0x9F | 12 | i_crtime_extra/i_version_hi/i_projid | 创建时间扩展、version 高位、project ID |
0xA0..0xFF | 剩余空间 | inode record 的剩余区域 | 可存放内联 extended attributes,实际起点受 i_extra_isize 影响 |
两个根本性事实:
- on-disk inode 中没有
i_ino字段。 inode 号就是这条记录在全文件系统 inode tables 中的编号。 - 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。
| 记录内偏移 | 大小 | 字段 | 含义 |
|---|---|---|---|
0x00 | 4 | inode | 该名字对应的 inode number;0 表示未使用记录 |
0x04 | 2 | rec_len | 本条目录项占用的总字节数,也用于跳到下一条 |
0x06 | 1 | name_len | 文件名字节数 |
0x07 | 1 | file_type | regular/dir/symlink 等类型提示 |
0x08 | name_len | name[] | 不自带 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 是一棵按文件逻辑块号有序、高度平衡的多路索引树。内部节点保存“逻辑起点 + 子节点物理块号”,真正的逻辑块到数据块映射只在叶子节点。
不论树多高,根始终位于 on-disk inode 的 60-byte i_block。根放不下所有叶子记录时,才分配额外 extent metadata block,并把 inode 内记录变成 index。
每个节点都以 12-byte struct ext4_extent_header 开头:
| 字段 | 宽度 | 意义 |
|---|---|---|
eh_magic | 16 bits | 固定为 0xF30A,用于验证节点格式 |
eh_entries | 16 bits | 当前有效 extent/index 记录数 |
eh_max | 16 bits | 当前节点最大记录容量 |
eh_depth | 16 bits | 从当前节点到叶子还有多少层;0 表示当前就是叶子 |
eh_generation | 32 bits | extent 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_extent、struct ext4_extent_idx 与 struct ext4_extent_header。
内部节点的每条 struct ext4_extent_idx 占 12 bytes:
| 字段 | 宽度 | 含义 |
|---|---|---|
ei_block | 32 bits | 该子树覆盖的第一个文件逻辑块 |
ei_leaf_lo | 32 bits | 子节点物理块号低 32 位 |
ei_leaf_hi | 16 bits | 子节点物理块号高 16 位 |
ei_unused | 16 bits | 保留 |
index 只保存子树的逻辑起点,不保存单独的逻辑终点。某条 index 覆盖到哪里,由下一条 index 的 ei_block 边界和子树中实际 extent 共同决定。
叶子节点的每条 struct ext4_extent 也占 12 bytes:
| 字段 | 宽度 | 含义 |
|---|---|---|
ee_block | 32 bits | extent 覆盖的第一个文件逻辑块 |
ee_len | 16 bits | extent 长度,同时编码 initialized/unwritten 状态 |
ee_start_hi | 16 bits | 数据物理起始块号高 16 位 |
ee_start_lo | 32 bits | 数据物理起始块号低 32 位 |
initialized extent 表示物理块已包含可读的文件数据。unwritten extent 表示空间已预分配,但未写入范围在文件语义上仍必须读为零,以防暴露介质中的旧数据。ee_len > 32768 时表示 unwritten,实际长度为 ee_len - 32768;ee_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()。其核心步骤是:
- 从 inode
i_block内的根 header 读取eh_depth。 - 若
eh_depth > 0,在按ei_block升序排列的 index 中二分查找,选择最大的ei_block <= logical_block。 - 把
(ei_leaf_hi << 32) | ei_leaf_lo组成子节点物理块号,读入该 extent metadata block,在下一层重复。 - 到达
eh_depth = 0的叶子后,在按ee_block升序排列的 extent 中选择最大的ee_block <= logical_block。 - 检查
logical_block < ee_block + actual_len。成立则命中;不成立则目标位于 extent 之间,是 hole。 - 命中后计算
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 size | 4096 | superblock s_log_block_size,dumpe2fs -h |
| block count | 16384 | superblock s_blocks_count_hi/lo |
| first data block | 0 | superblock s_first_data_block |
| blocks per group | 32768 | superblock s_blocks_per_group |
| inodes per group | 16384 | superblock s_inodes_per_group |
| inode size | 256 | superblock s_inode_size |
| group 0 block bitmap | block 9 | group_desc[0].bg_block_bitmap |
| group 0 inode bitmap | block 25 | group_desc[0].bg_inode_bitmap |
| group 0 inode table | block 41..1064 | group_desc[0].bg_inode_table = 41 |
| ext4 根目录 | inode #2 | ext4 格式约定 |
home / alice / report.txt | inode #12 / #13 / #14 | 目录项查找与 debugfs ls |
report.txt 数据 | block 2067 | inode #14 的 extent |
8.1 从根目录逐级得到 inode #14
ext4 根目录 inode number 固定为 2。每一步都是“定位当前目录 inode → 通过 extent 读目录数据 block → 在目录项中找名字”:
| 正在查的分量 | 当前目录 inode | 目录数据 block | 命中的磁盘目录项 |
|---|---|---|---|
home | #2 | 10 | home → inode #12 |
alice | #12 | 2065 | alice → inode #13 |
report.txt | #13 | 2066 | report.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 |
16384 | superblock 的 s_inodes_per_group | 每个 block group 管理 16384 个 inode slot |
4096 | 由 superblock 的 s_log_block_size 得到的 block size | 每个文件系统 block 占 4096 bytes |
256 | superblock 的 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 = 41 | inode 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 位于磁盘分区中,还要加上分区起始字节偏移。
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 记录内偏移 | 字段 | 值 | 这一步解码出的含义 |
|---|---|---|---|---|
0x00 | 0x28 | eh_magic | 0xF30A | 合法 extent 节点 |
0x02 | 0x2A | eh_entries | 1 | 当前只有 1 条有效 extent |
0x04 | 0x2C | eh_max | 4 | inode 内联根最多容纳 4 条记录 |
0x06 | 0x2E | eh_depth | 0 | 根同时是叶子,后续记录按 ext4_extent 解码 |
0x0C | 0x34 | ee_block | 0 | 这条 extent 从文件逻辑块 0 开始 |
0x10 | 0x38 | ee_len | 1 | 连续覆盖 1 个文件逻辑块 |
0x12 | 0x3A | ee_start_hi | 0 | 物理起始块号的高 16 位 |
0x14 | 0x3C | ee_start_lo | 2067 | 物理起始块号的低 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_size | 8.3 从 on-disk inode 的 i_size_lo/high 解码,本例为 10 | 文件的有效字节长度;首先要确认 0 ≤ file_offset < i_size |
block_size | superblock 的 s_log_block_size,本例为 4096 | 一个 ext4 文件系统 block 的字节数 |
logical_block | 由 file_offset / block_size 计算 | 目标字节位于文件的第几个逻辑块;文件逻辑块从 0 开始,与磁盘物理块号不是同一编号空间 |
offset_in_block | 由 file_offset % block_size 计算 | 目标字节在该文件逻辑块内的 byte offset;这里不是 8.2 中 inode-table block 内的偏移 |
ee_block | 8.3 从叶子 struct ext4_extent 解码,本例为 0 | 这条 extent 覆盖范围的起始文件逻辑块号 |
ee_len | 8.3 从叶子 extent 解码,本例为 1 | 这条 extent 连续覆盖的文件逻辑块数量 |
ee_start_hi/lo | 8.3 从叶子 extent 解码,本例为 0/2067 | extent 物理起始块号的高 16 位和低 32 位 |
extent_start | 由 ee_start_hi/lo 组合 | 这条 extent 对应的第一个 ext4 物理文件系统块号 |
relative_index | 由 logical_block - ee_block 计算 | 目标逻辑块距离本 extent 起点有几个 block |
physical_block | 由 extent_start + relative_index 计算 | 实际保存目标文件逻辑块的 ext4 物理文件系统块号 |
device_offset | 由 physical_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\n;i_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 运行时对象:
三个名字相似但不是同一对象:
| 对象 | 位置/生命期 | 作用 |
|---|---|---|
on-disk struct ext4_super_block | 持久化在文件系统字节 1024..2047 | ext4 全局几何、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_t | loop 设备的 major/minor |
| 实例身份 | s_type | 文件系统类型与挂载实现 | ext4 |
| 实例身份 | s_id / s_uuid | 日志用人类可读标识与 UUID 的内存表示 | 由 ext4 挂载初始化 |
| 几何/限制 | s_blocksize / s_blocksize_bits | 已验证的文件系统 block size 及其 log2 | 4096 / 12 |
| 几何/限制 | s_maxbytes | 该实例支持的最大文件字节偏移 | 由 ext4 根据特性设定 |
| 几何/限制 | s_time_gran / s_time_min / s_time_max | inode 时间粒度与可表示范围 | 由 ext4 格式能力设定 |
| 格式身份 | s_magic | 文件系统 magic 的内存表示 | 0xEF53 |
| 对象根 | s_root | 该文件系统实例的根 dentry | ext4 root inode #2 的 dentry |
| 操作表 | s_op | super_operations | inode 回写/释放、statfs、sync/freeze 等实例操作 |
| 操作表 | s_d_op | 该实例 dentry 的默认 dentry_operations | ext4 需要时设定 |
| 操作表 | s_export_op / s_xattr | exportfs/NFS 和 xattr 入口 | 由 ext4 挂载初始化 |
| 具体文件系统 | s_fs_info | 具体文件系统私有指针 | struct ext4_sb_info * |
| 后端 I/O | s_bdev / s_bdev_file | 块设备后端及内核打开引用;内存/伪文件系统可不使用 | /dev/loop0 |
| 后端 I/O | s_bdi | writeback 后端信息 | 与 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_active | superblock 内存引用与活跃引用计数 | 挂载/卸载流程管理 |
| 生命周期 | s_umount | mount/unmount/freeze 等实例级状态变更的读写信号量 | 挂载期间存在 |
| 冻结 | s_writers | 分级跟踪写入、写缺页和文件系统内部修改,用于 freeze/thaw | 运行时同步状态 |
| 错误报告 | s_wb_err | 为 syncfs() 等记录该实例的 writeback error sequence | 运行时错误状态 |
| 状态位 | s_flags / s_iflags | superblock 级运行语义与内部状态 | 不等于 on-disk feature flags |
运行时 flags 还要再分一层:super_block.s_flags 保存 SB_RDONLY 等文件系统实例级状态;vfsmount.mnt_flags 保存 MNT_NOEXEC、MNT_NOSUID、MNT_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 superblock 和 VFS struct super_block 明确区分。
十、从文件系统实例向下展开 VFS 内存对象
10.1 mount 回答“这个实例接在命名空间哪里”
VFS struct super_block 表示“被装载的文件系统实例是谁”,mount 则表示“它在某个 mount namespace 中接在哪里”。路径位置因此不能只用 dentry 表示:
Linux 源码同时出现 struct mount 和 struct vfsmount。前者是 mount 子系统的完整内部对象,后者内嵌在前者中,是 struct path 等 VFS 公共接口持有的视图:
完整代码:Linux v6.12.65 struct mount 与 struct 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.txt 的 struct 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)。本节只解释替换前后各是什么对象;nameidata、step_into()、handle_mounts()、lookup_mnt() 以及 RCU/REF-walk 的动态过程放到待发布的第 04 篇《路径不是字符串查表:mount namespace 和 pathname walk》展开。
11.2.1 查路径之前:mount(2) 已经把两个文件系统接好
第 9.4 节已经给出挂载的内部准备过程。对本例而言,mount(2) 成功返回前,内核已经完成:
- 解析挂载目标
/mnt/vfs-lab,保留父文件系统中的挂载点struct path。 - 打开并识别源块设备
/dev/loop0,读取 ext4 on-disk superblock。 - 建立 ext4 的 VFS
struct super_block,使s_bdev指向/dev/loop0。 - 读取 ext4 root inode
#2,建立 root dentry,使s_root指向它。 - 建立子 mount,使它的
mnt_sb和mnt_root分别指向这个 superblock 和 root dentry。 - 在 mount namespace 中登记覆盖关系:“父 mount 中的
vfs-labdentry 被这个子 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_root 是 struct vfsmount 中的内存 dentry 指针,来自“被挂载文件系统要暴露出来的根”。本例的建立链是:
完整代码入口:ext4 建立 s_root、get_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_sb 和 mnt_root,还处理空 root、mount 分配、内核内部 mount flags、superblock 活跃引用、初始父子关系,以及把 mount 挂入 super_block.s_mounts;本文不再用两行摘录代替完整函数。
| 字段 | 类型 | 直接指向 | 本例关系 |
|---|---|---|---|
mnt_root | struct dentry * | mount 对外暴露的根 dentry | mnt_root->d_inode->i_ino = 2 |
mnt_sb | struct super_block * | 该 mount 所属的 VFS superblock | mnt_root->d_sb == mnt_sb |
mnt_sb->s_root | struct dentry * | 整个文件系统实例的根 dentry | 普通整树挂载中 mnt_root == mnt_sb->s_root |
dget(fc->root) 不会复制一个 root dentry,只会取得对同一对象的引用并延长其生命期。因此本例中 fc->root、mnt_root 和 mnt_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 后换了哪两个对象
跨越前后的 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 = 2,mkfs.ext4 创建文件系统时也明确使用这个编号建立根目录。低编号 inode 的约定为:
| inode number | 约定用途 |
|---|---|
0 | 不存在,不是有效 inode number |
1 | bad-blocks inode,用于记录坏块 |
2 | root directory inode |
3..10 | quota、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 #2 | home | 由 inode #2 extent 定位到 block 10 | home → inode #12 | home dentry + inode #12 |
inode #12 | alice | 由 inode #12 extent 定位到 block 2065 | alice → inode #13 | alice dentry + inode #13 |
inode #13 | report.txt | 由 inode #13 extent 定位到 block 2066 | report.txt → inode #14 | report.txt dentry + inode #14 |
这些 block 号描述缓存为空时磁盘侧如何提供答案;如果相应 dentry、inode 或目录 folio 已在缓存中,本次查找可以少做部分磁盘 I/O,但名字到 inode 的逻辑关系不变。
11.4 本例的完整对象链
把流程代入本文具体路径,可以压缩成下面这条对象与磁盘坐标链:
进程的 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 记录和数据块。
打开 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_operations、struct file_operations 与 struct address_space_operations。
| 操作表 | 挂在哪里 | 解决什么问题 |
|---|---|---|
inode_operations | inode->i_op | lookup/create/unlink/rename/getattr 等名字和元数据动作 |
file_operations | inode->i_fop、打开后 file->f_op | read_iter/write_iter/poll/ioctl/fsync 等打开实例动作 |
address_space_operations | mapping->a_ops | read_folio/readahead/write_begin/write_end/writepages 等缓存与后端交换 |
这里的表格只标出对象上的操作入口,不展开调用顺序。为什么字节读写不放在 inode_operations、普通文件为何会连续经过 file_operations 与 address_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
硬链接和软链接都表现为“多了一个名字”,但对象关系完全不同:
因此:
- 硬链接增加的是一个指向 同一 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.txt,report.hard仍直接到达 inode#14;latest保存的字符串没有自动改变,因此会变成悬空软链接。
十四、新建:从“不存在的名字”到 inode 与缓存内容
这里仅观察 open("report.txt", O_CREAT|O_WRONLY) 之后对象关系如何变化。图中磁盘编号沿用总图这次实测结果;分配器在另一次运行中完全可能选择别的 inode 或数据块。
这张对象图只区分三个时刻:
- create 完成后,命名空间里已经能找到名字,dentry 已从负变正,inode 也已经存在。
- buffered write 返回后,内容已经能从该 inode 的缓存路径观察到,但这不等于已经分配最终物理块或达到持久化边界。
- 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
然后依次删除两个名字:
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 原子地改目录项:
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 设备、创建完整路径,然后用 findmnt、stat、filefrag、dumpe2fs 和 debugfs 从运行时与磁盘格式两侧交叉观察:
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 个不连续逻辑块写入数据,然后同时使用 filefrag 与 debugfs 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/1 和 Level 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
脚本完整内容没有依赖仓库外的辅助程序生成数据;它创建原文件、硬链接和符号链接,再用 stat 与 findmnt 观察。
实际输出:
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() 约束。它回答“这条对象链在真实路径解析中怎样走出来”,而不是重复本文的静态对象定义。
参考源码
- Linux
v6.12.65完整 VFS 定义:include/linux/fs.h(struct file、struct inode、struct address_space、struct super_block)与 include/linux/dcache.h(struct dentry)。 - Linux
v6.12.65完整 mount 实现:fs/super.c、fs/mount.h、include/linux/mount.h 与 fs/namespace.c。 - Linux
v6.12.65完整 pathname 实现与文档:fs/namei.c 与 Documentation/filesystems/path-lookup.rst。 - Linux
v6.12.65完整 ext4 核心实现:fs/ext4/ext4.h、fs/ext4/super.c、fs/ext4/namei.c 与 fs/ext4/inode.c。 - Linux
v6.12.65完整 extent 实现:fs/ext4/ext4_extents.h、fs/ext4/extents.c 与 fs/ext4/extents_status.c。 - e2fsprogs
v1.47.2完整mke2fs(8)源文件:misc/mke2fs.8.in。 - Linux
v6.12.65ext4 格式文档源码:super.rst、overview.rst、inodes.rst、special_inodes.rst、directory.rst、ifork.rst、group_descr.rst 与 bitmaps.rst。