EXT4结构略解——教你自己解析EXT4文件系统

一、前言

EXT4 是最近版本的 Linux 最常用的系统,是 EXT3 的升级版本,并且可以向前兼容 EXT3 和 EXT2 。因此只要可以解析 EXT4 ,那么就可以解析相当一部分的 Linux 镜像了。

如果你没看过上一篇NTFS结构略解,建议先去看一下这篇文章的前置知识部分:NTFS结构略解 。这部分包含了扇区、CHS、LBA、MBR 等概念,对自己手动解析是很有帮助的,这里就不再说一遍了。

文章主要参考的是 Linux 内核文档:内核文档 ,由于 Linux 是开源的,因此学习起来也比 NTFS 方便很多。这篇文档内容非常全面,而本文只做一个入门性质的引导,还有很多没有提及的地方,因此如果你有更专业的需求还是有必要参考内核文档的。

这篇文章的文件结构解析同样都和实际镜像分析结合在一起,同样也推荐读者自己拿个一个 EXT4 镜像解析从引导记录到文件数据的每一部分,这对加深对 EXT4 的理解和掌握很有帮助。

二、主引导扇区

EXT4 的主引导扇区和 NTFS 的结构很类似,但还是有些许不同,因此这里再次展示一下主引导扇区的结构:

1
2
3
4
5
6
7
8
9
------ LBA0 ------
0000 - 60 7C 66 89 # 启动代码
01BE - 80 20 21 00 # MBR
80 # 分区状态,80为活动分区
20 21 00 # 分区起始的CHS,似乎没有意义(因为CHS已经不用了)
83 # 文件系统类型,83为ext4
FE FF FF # 分区结束的CHS,应该没有意义
00 08 00 00 # 分区开始的LBA,起始地址为0x800*512
00 F0 7F 02 # 分区大小,可以算出是20G

从 1BEH 开始每 16 个字节都是一个 MBR 条目。

这里与 NTFS 最大的不同有两个,一个是启动代码不同,另一个是第二个字段:分区起始的 CHS 意义不同。在 NTFS 中这一字段是可以转化成分区开始的 LBA 的,但是在这里似乎做不到,我怀疑是因为 CHS 已经不用了,所以这个字段值没有意义。总之解析 EXT4 时直接访问分区开始的 LBA 就行,不用管起始和结束的 CHS 。

三、块、块组、超级块和组描述符

EXT4 操作数据的基本单元是块,这个概念和 NTFS 的簇十分类似,都是将整数个扇区聚合在一起以加快操作速度。同时为了方便管理,EXT4 在块的基础上又封装了一层,由整数个块组成一个块组。为了工作效率,EXT4 会尽量保证一个文件的所有数据块都位于一个块组内。块的大小和块组的大小都由超级块来描述。

3.1 块组的结构

第 0 个块组中包含最多的信息,它的结构大概是这样的:

填充 超级块 组描述符表 保留 GDT 块 数据块位图 inode 位图 inode 表 数据块
1024 字节 1 块 许多块 1 块 1 块 1 块 许多块 许多块

这里需要注意的是,第 0 个块组中的第 0 个字节不是超级块,而且很大概率前 1024 个字节都是 0 。这个填充是为了方便安装 x86 引导扇区或者别的东西的。这也是为什么我要在第二节强调分区的起始地址的计算方法。因为如果没有注意到这一点的话,不管是用 CHS 算还是用 LBA 算都会指向一个全是 0 的区域,会让人一头雾水。

之后的块的结构就各不相同了,这涉及到 EXT4 的两个特性:稀疏超级块 和 元块组。

  1. 稀疏超级块(sparse super)

如果开启了稀疏超级块功能,那么只会在 3, 5, 7 的整数幂个块(包括第 1 块)的开头存放超级块的备份,否则将会在每一个块存放超级块的备份。

  1. 元块组(meta block group)

由于每个块组最多只能储存 128MB 的数据,块组的数量还是非常多的,这时组描述符表就会变得特别大。因此 EXT4 又在块组的基础上封装一层,可以将若干个块组合并成一个元块组。在元块组中,只有第 0 、1 和最后一个块组拥有块组描述符表的备份。如果没有开启元块组功能,那么每个块组都会有一个块组描述符表的备份。

如果一个分区同时开启了上面两个功能,并且元块组的个数为 64 个组,那么它的块组结构就会是这样:

1
2
3
4
5
6
第0组:填充 超级块 组描述符表 保留GDT块 块位图 inode位图 inode表 数据块
第1组:超级块 组描述符表 保留GDT块 块位图 inode位图 inode表 数据块
第2组:块位图 inode位图 inode表 数据块
第3组:超级块 块位图 inode位图 inode表 数据块
...
第63组:组描述符表 保留GDT块 块位图 indoe位图 inode表 数据块

对于组内项目的顺序,一般确定超级块和块组描述符号一定按顺序位于组的开头(如果有的话),但其余项目都是不固定的,例如位图可能位于 inode 表的后面,这个要通过查看组描述符中的属性来确定。

3.2 超级块

超级块储存整个 EXT4 的基本信息,它的内容十分丰富:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
----- LBA800 -----  # EXT4分区
00100000 - 00 00 00 00 # 前1024个字节全是0,为了允许安装x86引导扇区或者别的东西
00100400 - 00 00 14 00 # 超级块
00 04 | 00 00 14 00 # 总indoe数
04 04 | 00 FE 4F 00 # 总块数
08 04 | E6 FF 03 00 # 超级用户才能分配的块数
0C 04 | 8C 1E 3B 00 # 空闲块数
10 04 | 11 CC 11 00 # 空闲inode数
14 04 | 00 00 00 00 # 第一个数据块,块大小1k的系统至少是1,其他一般是0
18 04 | 02 00 00 00 # 块大小,2的10加这个数次方为块大小,这里是2^(10+2)=4K
1C 04 | 02 00 00 00 # 簇大小,和块大小算法一样,如果没有使用bigalloc
# 则和块大小一样
20 04 | 00 80 00 00 # 每组块数(含超级块、组描述符等)
24 04 | 00 80 00 00 # 每组簇数,如果没有使用bigalloc则必须和每组块数一样
28 04 | 00 20 00 00 # 每组inode数
2C 04 | 85 B6 83 62 # 挂载时间,自纪元(epoch)以来的秒数
30 04 | 28 45 84 62 # 写入时间,自纪元以来的秒数
34 02 | 03 00 # 自上次fsck以来的挂载次数
36 02 | FF FF # 超出fsck需要的挂载数
38 02 | 53 EF # 魔数,一定是EF53
3A 02 | 01 00 # 文件系统状态,1表示干净卸载,2表示检测到错误
# 4表示orphans被找回
3C 02 | 01 00 # 检测到错误时的行为,1表示继续,2表示以只读方式重挂载
# 3表示panic
3E 02 | 00 00 # 次要修订级别
40 04 | 7F 23 84 62 # 上次检查的时间,自纪元以来的秒数
44 04 | 00 00 00 00 # 检查间隔的最大秒数
48 04 | 00 00 00 00 # 创造者的操作系统,0是linux,1是Hurd,
# 2是Masix,3是FreeBSD,4是Lites
4C 04 | 01 00 00 00 # 修订级别
50 02 | 00 00 # 保留块的默认uid
52 02 | 00 00 # 保留块的默认gid
# 这些东西只适用于 EXT4_DYNAMIC_REV 超级块
#(没懂啥意思,不知道指上面的还是下面的)
54 04 | 0B 00 00 00 # 第一个非保留的inode
58 02 | 00 01 # inode结构的字节数
5A 02 | 00 00 # 本超级块的块组
5C 04 | 3C 00 00 00 # 兼容的功能集标志
60 04 | C2 02 00 00 # 不兼容的功能集
64 04 | 6B 04 00 00 # 只读功能集
68 16 | D9 8A .. # 卷的128bit UUID
78 16 | 00 00 .. # 卷标(char)
88 64 | 2F 00 61 .. # 文件系统上次被挂载的目录
C8 04 | 00 00 00 00 # 压缩用(e2fsprogs/Linux不用)
CC 01 | 0 # e2fsprogs/Linux不用
CD 01 | 0 # e2fsprogs/Linux不用
CE 02 | 00 04 # 为将来的系统扩展保留的GDT条目数
D0 16 | 00 00 .. # 日志超级块的UUID
E0 04 | 08 00 00 00 # 日志超级块的inode号
E4 04 | 00 00 00 00 # 日志超级块的设备号,如果开启了扩展日志功能
E8 04 | 00 00 00 00 # 要删除的孤立inode的起点
EC 16 | 87 EC 0A .. # HTREE哈希种子
FC 01 | 01 # 目录哈希的默认算法,0是Legacy,1是Half MD4等
FD 01 | 01 # 如果是0或1,那么s_jnl_blocks字段包含
# inode的i_block[]数组和i_size副本
FE 02 | 40 00 # 如果设置了64位不兼容功能,则表示组描述符的字节数
0100 04 | 0C 00 00 00 # 默认挂载选项
0104 04 | 00 00 00 00 # 如果开启了meta_bg功能,表示第一个元块块组
0108 04 | 7F 23 84 62 # 文件系统创建的时间,自纪元以来的秒数
010C 68 | 0A F3 01 .. # 日志inode的i_block[]数组前15个元素的备份
# 副本以及i_size_high和i_size的备份
# 仅当设置了EXT4_FEATURE_COMPAT_64BIT是64位支持才有效(没懂啥意思)
0150 04 | 00 00 00 00 # 块数的高32位(估计是为了扩展用)
0154 04 | 00 00 00 00 # 预留块数的高32位
0158 04 | 00 00 00 00 # 空闲块数的高32位
015C 02 | 20 00 # 所有inode至少有的字节数
015E 02 | 20 00 # 新inode应该预留的字节
0160 04 | 01 00 00 00 # 各种各样的标志,1是使用有符号hash等
0164 02 | 00 00 # RAID步幅(stride)
0166 02 | 00 00 # 好像没有用
0168 08 | 00 00 .. # 多重挂载保护数据的块
0170 04 | 00 00 00 00 # RAID步幅宽度
0174 01 | 04 # 灵活块组的大小,2的这个数次方,这里是16
0175 01 | 01 # 元数据校验和算法,唯一一个合法值是1(crc32c)
0176 02 | 00 00 # 填充
0178 08 | 05 B6 .. # 在这个文件系统的生命周期内写入的KiB数
# 这个镜像之后就全是0了,不写了
03FC 04 | 90 61 5E 94 # 超级块校验和

里边存放着文件系统开启的特性(如前面提到的元块组和稀疏超级块),这些内容在写解析器的时候要特别注意。限于篇幅没有在上表详细列出,如果有需要可以参考内核文档。另外,不兼容功能集中的“不兼容”并不是指这个系统没有开启的功能集,可能指的是和其他版本不兼容但是开启的功能。元块组功能就位于不兼容功能集。

通过解读超级块,我们可以知道这个 EXT4 系统有以下属性:

  1. 块的大小是 4KB
  2. 每组有 32768 个块
  3. 每组有 8192 个 inode
  4. 没有开元块组功能,但是开了稀疏超级块功能

所以在 0x00100000 + 4096 * 0x8000 和 0x00100000 + 4096 * 0x8000 * 3 应该各有一个超级块,而 0x00100000 + 4096 * 0x8000 * 2 没有超级块,如果查看镜像就可以发现确实是这样的。

3.3 组描述符

组描述符表紧跟在超级块后面(如果有的话),包含系统中所有组的信息。每个组的组描述符占用一个块,按组的顺序依次排列。

一个组描述符的结构是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
00101000 - 04 04 00 00  # 块组描述符
00 04 | 04 04 00 00 # 块位图位置的低32位
04 04 | 14 04 00 00 # inode位图位置的低32位
08 04 | 24 04 00 00 # inode表位置的低32位
0C 02 | D7 53 # 空闲块数的低16位
0E 02 | F1 1F # 空闲inode数的低16位
10 02 | 02 00 # 目录数的低16位
12 02 | 04 00 # 块组标志,1是inode标未初始化,2是块位图未初始化
# 4是inode表已经清零
14 04 | 00 00 00 00 # 快照排除(exclusion)位图位置的低32位
18 02 | BF 2F # 块位图校验和的低16位
1A 02 | 56 74 # inode位图校验和的低16位
1C 02 | F0 1F # 未使用inode数的低16位
1E 02 | 1B EF # 组描述符校验和,如果设置了GDT_CSUM功能则是crc16,
# 如果设置了METADATA_CSUM功能则是crc32c的低16位。
# 这个数算crc16的时候跳过,算crc32的时候置0
20 04 | 00 00 00 00 # 块位图位置的高32位
24 04 | 00 00 00 00 # inode位图位置的高32位
28 04 | 00 00 00 00 # inode表位置的高32位
2C 02 | 00 00 # 空闲块数的高16位
2E 02 | 00 00 # 空闲inode数的高16位
30 02 | 00 00 # 目录数的高16位
32 02 | 00 00 # 未使用inode数的高16位
34 04 | 00 00 00 00 # 快照排除位图的高32位
38 02 | CC 8C # 块位图校验和的高16位
3A 02 | 9A 3D # inode位图校验和的高16位
3C 04 | 00 00 00 00 # 填充

虽然项目很多,但是其实有用的信息只有 inode 表的位置,因为块位图和 inode 位图在读镜像中都没有用,而每组的 inode 数都是固定的,在超级块中定义。在这个例子中,inode 表的位置为

1
00100000 + 0x424 * 4096

四、Inode 和 Inode 表

Inode 全称是索引节点,在 EXT4 中,每个文件都由唯一的 inode 表示。有时我们可能会是说“ xx 文件的 inode 是多少”,这里的 inode 指的就是这个文件的 inode 编号。Inode 表和组描述符表的结构类似,每个 inode 占用一个块,并且按照顺序排列。

要定位一个 inode 的位置,首先需要从超级块中读出每块的组数 sb.s_inodes_per_group ,这样这个 inode 所在的块组就是

1
(inode_number - 1) / sb.s_inodes_per_group

这里注意,inode_number 要 -1 ,因为不存在实际上的 inode0 ,0 号 inode 会被用于一些特殊情况。此外注意算式中的除是整除。

之后再计算块在组内的位置:

1
(inode_number - 1) % sb.s_inodes_per_group

用这个值乘上一块的大小,再加上 inode 表起始的地址就可以了。

除了 inode0 并不实际存在以外,inode1 也有特殊用途,它用于跟踪磁盘上的坏块。因此在我的镜像上 inode1 为空 ,inode2 才表示根目录。

Inode 的格式大概是这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
00524100 - ED 41 00 00  # 根目录的inode记录
00 02 | ED 41 # 文件模式,1是其他人可以执行,2是其他人可能会写等
# 0x4000表示是目录(注意小端序储存)
02 02 | 00 00 # 所有者UID的低16位
04 04 | 00 10 00 00 # 字节数的低32位
08 04 | 7F 23 84 62 # 上次访问时间,但是如果设置了EA_INODE inode标志
# 则此inode存储扩展属性值,并且此字段包含该值的校验和
0C 04 | AB B3 83 62 # 上次inode改变时间
10 04 | AB B3 83 62 # 上次数据修改时间
14 04 | 00 00 00 00 # 删除时间
18 02 | 00 00 # GID的低16位
1A 02 | 18 00 # 硬连结数,启用DIR_NLINK功能后,ext4支持超过64998
# 个子目录,方法是将此字段设为1,表示不知道硬链接的数量
1C 04 | 08 00 00 00 # “块”数的低32位,如果文件系统未设置huge_file功能标志
# 则文件会占用这个数的 512 字节块。如果设置了huge_file
# 并且下一个字段未设置EXT4_HUGE_FILE_FL,则该文件占用
# 块数个512字节块。如果设置了huge_file并且下一个字段
# 设置了EXT4_HUGE_FILE_FL则该文件会占用块数个文件系统块
20 04 | 00 00 08 00 # inode标志
24 04 | 1F 00 00 00 # 根据创建者的不同该字段有多种含义,对Linux是索引节点版本
28 60 | 0A F3 .. # 块图或数据块树,详见“The Contents of inode.i_block"
# (内核文档里),ext4中是范围树,储存数据的链接。
# 如果数据小于60字节则直接放在这里
64 04 | 00 00 00 00 # 文件版本
68 04 | 00 00 00 00 # 扩展属性块的低32位
6C 04 | 00 00 00 00 # 文件/目录大小的高32位,在ext2/3里总是0
70 04 | 00 00 00 00 # 片段地址(过时了)
74 12 | 00 00 .. # 根据文件系统创建者的不同有多种含义
80 02 | 20 00 # 这个inode记录的大小-128,或者说是超出原始ext2 inode
# 的扩展inode字段的大小,包括此字段
82 02 | DD 49 # inode位图校验和的高16位
84 04 | 20 C9 59 C0 # 额外的更改时间位,提供亚秒级的精度
88 04 | 20 C9 59 C0 # 额外的修改时间位,提供亚秒级的精度
8C 04 | 00 00 00 00 # 额外的访问时间为,提供亚秒级的精度
90 04 | 7F 23 84 62 # 文件创建时间,自纪元以来的秒数
94 04 | 00 00 00 00 # 额外的创建时间位,提供亚秒级的精度
98 04 | 00 00 00 00 # 版本号的高32位
9C 04 | 00 00 00 00 # 项目标识

从中可以获取时间信息、大小信息(用块数计算)以及最重要的文件内容。EXT4 的文件内容是通过一种叫做范围树的结构组织的,位于 0x28 偏移,总共 60 字节,结构是这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
60字节的范围树(位于上面的0x28偏移量):
范围树头
00 02 | 0A F3 # 魔数,一定是0xF30A
02 02 | 01 00 # 树头之后的有效条目数
04 02 | 04 00 # 树头后的最大条目数
06 02 | 00 00 # 这个范围节点在范围树中的深度。0表示这个节点指向数据块,
# 否则指向其他范围节点
08 04 | 00 00 00 00 # 树的生成(Lustre使用,但不是标准的ext4)
范围树的内部节点,也称索引节点(这个 inode 这里没有这种节点)
00 04 | # 这个索引节点开始覆盖文件的块数
04 04 | # 下一个级别的扩展节点的块号的低32位
08 02 | # 前一个字段的高16位
0A 02 | # 未使用
范围树的叶子节点
00 04 | 00 00 00 00 # 此范围节点覆盖的第一个文件块号
04 02 | 01 00 # 范围覆盖的块数,如果<=32768则范围块已经初始化。如果
# 值>32768则范围块未初始化且实际长度是这个数-32768
06 02 | 00 00 # 这个范围节点的块号的高16位(应该是组内块号)
08 04 | 24 24 00 00 # 这个范围节点的块号的低32位

沿着内部节点遍历就可以找到想要的访问的文件的位置,可以通过节点开始覆盖文件的块数来判断目标位置是否在这个内部节点,这样就不需要每个树节点都遍历了。

上表中的范围树只有一个叶子节点,这个节点的表示的起始位置就是这个文件的起始位置。在这个例子中文件内容的起始位置为

1
0x100000 + 0x2424 * 4096

此外,如果 0x20 偏移的 inode 标志设置了 inline_data 特性并且文件的内容小于 60 字节,则会直接被放在这里。这样也不用担心文件开头会和魔数冲突的情况,因为可以通过 inline_data 是否打开来判断是范围树还是文件内容。在 ext2 和 ext3 中则采用一种线性索引结构来指示内容位置,这里就不多说了。

另一个值得注意的点是和 NTFS 不同,EXT4 的 inode 里是没有文件名属性的,而 NTFS 里会有 30H 属性来存储文件名。事实上,EXT4 的文件可能不止有一个文件名,因为可能会同时有多个目录中的链接指向这个 inode 。因此 inode 是文件的别名这种说法是错误的,inode 是文件的本体,而文件名甚至都不是 inode 的属性。

五、目录索引

目录文件的内容是目录索引,如果 inode 中设置了 EXT4_INDEX_FL 标志,则会使用哈希树来组织索引,否则是简单的线性结构。哈希树的结构比较复杂也比较巧妙,而且只有比较大的目录会打开,所以这里就简单介绍下线性结构。

线性结构由一系列目录条目紧密排列。EXT4 的目录结构默认为 ext4_dir_entry_2 ,如果 filetype 特性没有打开才会采用 ext4_dir_entry 结构。

ext4_dir_entry 的结构是这样的:

1
2
3
4
00 04 |             # 这个条目指向的inode,如果为0表示指向为空
04 02 | # 目录条目的长度,必须是4的倍数
06 02 | # 文件名的长度
08 xx | # 文件名

而 ext4_dir_entry_2 的结构是这样的:

1
2
3
4
5
00 04 | 02 00 00 00 # 这个条目指向的inode,为0表示空
04 02 | 0C 00 # 这个条目的长度,必须是4的倍数
06 01 | 01 # 文件名长度
07 01 | 02 # 文件类型(filetype),0是unknow,1是常规,2是目录等
08 xx | 2E # 文件名,这里是"."

可以看到,第 2 版相比第 1 版唯一的区别是把 2 字节的文件名长度拆分成文件名长度和文件类型各 1 字节。这样做的原因是文件名不可能超过 256 字节,所以不如把它拆开多储存一些属性,这样可以避免在遍历目录的时候需要加载每一个 inode 。

六、总结

至此,你已经学会了从磁盘镜像的第 0 个字节开始,一直解析到根目录的某个文件的过程了,虽然还有很多结构没有写到,例如哈希树,但是手动进行一下实践还是足够的。如果你需要更专业的操作,例如写一个解析器,或者是把解析 EXT4 作为你的专业技能,那么还是需要仔细阅读一遍内核文档(提醒一下,内核文档的链接放在开头了)。内核文档非常详细而且清晰,在学写了这篇文章作为导读之后,相信任何一个读者都可以轻松在内核文档中找到自己想要的内容。同时我也真诚希望不管是抱着业余了解还是专业入门的目的,每个人都能从我这篇略解中得到帮助。