文件系统-从 vmdk 镜像解析与导出文件

平淡无奇的周一, 老板突然说 7z 怎么无法在 esxi host 解析 vmdk 呢? 于是有这个这个工具的探索. 实现起来倒是不复杂, 只因为 AI 可以闭环验证, 很快就完成了

一、问题背景

在日常运维中,我们经常会遇到以下场景:

  • 虚拟机无法启动:操作系统损坏,但数据还在磁盘上
  • ESXi 环境受限:不能安装第三方工具
  • 快照数据恢复:需要理解 VMware 快照中的增量数据

传统方案需要挂载虚拟磁盘,这要求 root 权限和特定的文件系统驱动。但实际上,文件系统的本质就是按照特定规则组织的二进制数据。理解这些规则,是进行数据恢复、取证分析、存储系统开发的基础。

本文将详细讲解:

  1. 分区表结构:MBR 和 GPT 的二进制布局
  2. 文件系统原理:ext4、NTFS、XFS 的核心数据结构
  3. LVM 机制:逻辑卷管理器的元数据组织
  4. VMware SESparse:快照格式的增量存储原理

二、磁盘数据的层次结构

要理解磁盘数据恢复,首先需要理解数据的层次组织:

┌───────────────────────────────────────────────────────────┐
│               磁盘镜像 (Raw Disk Image)                   │
└────────────────────────────┬──────────────────────────────┘
                             │
                             ▼
┌───────────────────────────────────────────────────────────┐
│                  分区表层 (MBR / GPT)                     │
│              → 定义分区的起始偏移和大小                   │
└────────────────────────────┬──────────────────────────────┘
                             │
                             ▼
┌───────────────────────────────────────────────────────────┐
│                     LVM 层 (可选)                         │
│               → 物理卷 → 卷组 → 逻辑卷                    │
└────────────────────────────┬──────────────────────────────┘
                             │
                             ▼
┌───────────────────────────────────────────────────────────┐
│             文件系统层 (NTFS / ext4 / XFS)                │
│           → 元数据结构 → 目录树 → 文件数据                │
└───────────────────────────────────────────────────────────┘

关键魔数(Magic Numbers)

文件系统检测依赖特定位置的魔数签名:

类型 魔数 位置 说明
MBR 0x55AA 偏移 510 引导扇区签名
GPT EFI PART LBA 1 GPT 头部签名
LVM LABELONE 分区偏移 +512 LVM2 物理卷标签
ext4 0xEF53 分区偏移 +1080 Superblock 魔数
XFS 0x58465342 分区偏移 +0 ‘XFSB’ 大端序
NTFS NTFS 分区偏移 +3 OEM ID

三、分区表结构

3.1 MBR (Master Boot Record)

MBR 位于磁盘第一个扇区(512 字节),包含 4 个分区条目。

核心信息:每个分区条目记录分区类型起始 LBA

分区偏移 = 起始 LBA × 512

常见分区类型:0x07 NTFS、0x83 Linux、0x8E LVM、0xEE GPT 保护分区

3.2 GPT (GUID Partition Table)

GPT 是现代标准,支持超过 2TB 磁盘和 128+ 个分区。

核心信息:每个分区条目记录类型 GUID起始/结束 LBA分区名称

分区偏移 = 起始 LBA × 512(64 位 LBA,支持超大磁盘)

四、ext4 文件系统结构

ext4 是 Linux 最常用的文件系统,采用块组 + inode + extents三层结构。

4.1 整体架构

Superblock (偏移 1024) → 块组描述符表 → 块组 0/1/2...
                                            │
                              ┌─────────────┴─────────────┐
                              ▼                           ▼
                         Inode Table                 Data Blocks
                              │
                              ▼
                    Extents (连续块映射)

4.2 核心概念

概念 作用 关键点
Superblock 文件系统全局信息 魔数 0xEF53,块大小 = 1024 << log值
块组 将磁盘分区管理 每组有独立的 inode 表和数据块
Inode 文件元数据 根目录固定为 inode 2
Extents 数据块映射 魔数 0xF30A,B+ 树结构

4.3 Inode 定位

块组号 = (inode_num - 1) / inodes_per_group
组内索引 = (inode_num - 1) % inodes_per_group

4.4 Extents 树

ext4 用 extents 描述文件数据的物理位置,每个 extent 表示一段连续的物理块

Extent = (逻辑块号, 连续块数, 物理起始块)

物理偏移 = 物理起始块 × 块大小

大文件使用 B+ 树组织多个 extents(depth > 0 时有索引节点)。

五、NTFS 文件系统结构

NTFS 是 Windows 文件系统,核心设计是一切皆属性——文件名、时间戳、数据都是属性。

5.1 整体架构

Boot Sector → MFT (Master File Table) → 属性列表 → 数据
                     │
         ┌───────────┴───────────┐
         ▼                       ▼
    MFT 记录 0: $MFT        MFT 记录 5: 根目录
    (MFT 自身)              (目录索引)

5.2 核心概念

概念 作用 关键点
Boot Sector 分区元信息 OEM ID NTFS,记录 MFT 位置
MFT 文件索引表 每条记录 1024 字节,签名 FILE
属性 存储一切信息 $DATA 存数据,$FILE_NAME 存文件名
分配单位 MFT 偏移 = MFT_LCN × 簇大小

5.3 MFT 记录

每个文件/目录对应一条 MFT 记录,包含多个属性:

属性类型 名称 说明
0x10 $STANDARD_INFORMATION 时间戳、权限
0x30 $FILE_NAME 文件名
0x80 $DATA 文件数据
0x90 $INDEX_ROOT 目录索引

5.4 驻留 vs 非驻留

NTFS 的独特优化:小文件数据直接存在 MFT 记录内(约 700-900 字节以内)。

$DATA 属性
    │
    ├── 驻留 (flag=0): 数据在 MFT 记录内,无需额外 I/O
    │
    └── 非驻留 (flag=1): 数据在外部簇,通过 Data Runs 定位

5.5 Data Runs(数据运行)

Data Runs 用变长编码描述非驻留数据的物理位置:

编码格式: [Header][Length][Offset] ...

Header = (offset_size << 4) | length_size
Offset 是累积偏移: LCN_n = LCN_{n-1} + offset_n
Offset = 0 表示稀疏区域(虚拟空间)

示例31 01 02 03 → 1 个簇,起始于 LCN 0x030201

六、XFS 文件系统结构

XFS 是 CentOS/RHEL 默认文件系统,最大特点是大端序AG 分区管理

6.1 整体架构

Superblock (偏移 0) → AG 0 / AG 1 / AG 2 ...
                           │
              ┌────────────┴────────────┐
              ▼                         ▼
         Inode Table              Data Blocks
              │
              ▼
      128-bit Extents (大端序)

6.2 核心概念

概念 作用 关键点
Superblock 文件系统元信息 魔数 XFSB偏移 0(不是 1024)
AG 分配组 每个 AG 独立管理,支持并行 I/O
Inode 编码 inode 号含 AG 信息 inode = (ag_num << ag_ino_log) | offset
Extents 128 位结构 全部大端序

6.3 fsblock 的 AG 编码(最大陷阱)

XFS 的 fsblock 高位编码了 AG 号,不能直接乘块大小:

错误:disk_offset = fsblock × block_size

正确:
  ag_num = fsblock >> ag_blk_log
  ag_blk = fsblock & ((1 << ag_blk_log) - 1)
  actual_block = ag_num × ag_blocks + ag_blk
  disk_offset = actual_block × block_size

为什么? 因为 ag_blocks 不一定等于 2^ag_blk_log(最后一个 AG 可能不完整)

七、LVM 逻辑卷管理

Linux 常在分区之上使用 LVM,需要先解析 LVM 元数据找到逻辑卷位置。

7.1 LVM 层次结构

物理分区 → 物理卷 (PV) → 卷组 (VG) → 逻辑卷 (LV) → 文件系统

7.2 核心概念

概念 魔数/签名 说明
PV 标签 LABELONE 位于分区偏移 512 字节
元数据头 LVM2 包含文本格式的配置
extent_size - 每个 extent 的扇区数
pe_start - 物理 extent 起始扇区

7.3 逻辑卷偏移计算

LVM 元数据是人类可读的文本格式,关键信息:

lv_offset = (pe_start + stripe_offset × extent_size) × 512

找到 LV 偏移后,就可以在该位置解析 ext4/XFS 文件系统。

八、VMware SESparse 快照格式

VMware 在 VMFS6 上使用 SESparse 格式存储快照,这是一种增量存储格式

8.1 基本原理

Base Disk:     [sector 0][sector 1][sector 2]...[sector N]
                   ↑          ↑
SESparse:      [modified]  [unallocated → 读 base]

只有被修改的扇区存储在 SESparse 文件中,未修改的数据从 base disk 读取。

8.2 核心概念

概念 说明
魔数 0xCAFEBABE
粒度 grain_size,数据分配的最小单位
L1/L2 表 两级索引定位数据
状态位 0x0=未分配,0x2=零,0x3=已分配

8.3 透明合并

通过封装类提供统一接口,上层文件系统工具无需关心快照逻辑:

读取请求 → 查 L1/L2 表
         ├── 已分配 → 从 SESparse 读取
         ├── 零粒度 → 返回全零
         └── 未分配 → 从 Base Disk 读取

九、文件数据索引机制

三大文件系统采用不同策略定位文件数据的物理位置。

9.1 三者对比

特性 ext4 NTFS XFS
索引结构 Extents B+ 树 Data Runs 链表 128-bit Extents
小文件优化 驻留存储(≤900B) inline 存储
稀疏文件 未初始化 extent offset=0 未分配 extent
字节序 小端 小端 大端

9.2 ext4 - Extents B+ 树

inode → Extent Header (magic=0xF30A)
              │
    ┌─────────┴─────────┐
    ▼                   ▼
depth=0 (叶子)     depth>0 (索引)
    │                   │
    ▼                   ▼
Extent Entry       Index Entry → 递归
(逻辑块, 块数,
 物理起始块)

查找逻辑块 N:遍历 extents,找到包含 N 的 extent,计算物理块

9.3 NTFS - Data Runs

MFT Record → $DATA 属性
                │
     ┌──────────┴──────────┐
     ▼                     ▼
驻留 (flag=0)         非驻留 (flag=1)
数据在 MFT 内         Data Runs 链表
(≤900 bytes)         (累积偏移编码)

关键:Data Runs 使用累积偏移,LCN_n = LCN_{n-1} + offset_n

9.4 XFS - 128 位 Extent

inode → 128-bit Extent (大端序)
        ├── 逻辑块偏移 (54 bits)
        ├── 物理起始块 (52 bits, 含 AG 编码)
        └── 块数 (21 bits)

关键陷阱:fsblock 高位是 AG 号,必须先 AG 解码再计算物理偏移

十、目录遍历与文件查找

以查找 /etc/hosts 为例,看三大文件系统如何定位文件。

10.1 通用流程

根目录 → 查找 "etc" → 获取 etc 的 inode → 查找 "hosts" → 读取数据

10.2 三者对比

步骤 ext4 NTFS XFS
根目录 固定 inode 2 固定 MFT 记录 5 Superblock 中读取
目录结构 dirent 链表 B+ 树索引 shortform/block/btree
文件名匹配 顺序遍历 B+ 树查找 顺序或 hash
inode 定位 块组+组内索引 MFT 记录号×1024 AG 解码

10.3 目录格式

ext4:目录是特殊文件,内容是 dirent 链表(inode + 文件名)

NTFS:使用 B+ 树索引,小目录在 $INDEX_ROOT,大目录在 $INDEX_ALLOCATION

XFS:根据大小自动选择格式

  • shortform:小目录,数据在 inode 内
  • block:中等目录,单个数据块
  • leaf/node:大目录,B+ 树结构

10.4 安全考虑

从磁盘读取的文件名可能包含危险字符,必须过滤:

  • 路径分隔符:\ /
  • Windows 特殊字符:: * ? " < > |
  • Windows 保留名:CON, PRN, AUX, NUL, COM1-9, LPT1-9

十一、技术挑战与经验总结

11.1 字节序陷阱

文件系统 字节序 Python struct
ext4 小端 <
NTFS 小端 <
XFS 大端 >

11.2 常见坑点

问题 错误做法 正确做法
XFS fsblock 直接乘 block_size 先 AG 解码
NTFS Data Runs 当作绝对偏移 累积偏移计算
ext4 inode 从 0 开始 从 1 开始(inode_num - 1)

11.3 实践建议

  1. 用 hexdump 验证:不要盲目相信文档,用真实数据验证
  2. 内存管理:大文件分块读取,使用 LRU 缓存
  3. 边界检查:所有读取操作都要检查长度
  4. 安全处理:文件名必须过滤危险字符

十二、高级开发者应知的文件系统常识

以下是从实际数据恢复项目中提炼的核心认知,适合技术面试或架构讨论时展示底层理解。

12.1 文件系统的本质

一句话:文件系统 = 元数据索引 + 数据块管理

所有文件系统都在解决同一个问题:如何从文件名找到磁盘上的字节。差异只在于索引结构和空间分配策略。

用户视角:  /etc/hosts
           ↓
文件系统:  目录索引 → inode/MFT → 数据块映射 → 物理偏移
           ↓
磁盘视角:  sector 123456, offset 0x1E240000

12.2 三大设计哲学

文件系统 设计哲学 典型场景
ext4 块组隔离,元数据分散 通用 Linux,平衡性能与可靠性
NTFS 一切皆属性,小文件内联 Windows,元数据密集型
XFS AG 并行,大文件优化 企业存储,高吞吐场景

面试点

  • ext4 的 inode 2 是根目录,这是硬编码的
  • NTFS 小于 900B 的文件直接存在 MFT 记录里(驻留属性),零额外 I/O
  • XFS 全大端序,这是 SGI IRIX 的历史遗留

12.3 数据定位的两种范式

连续映射(Extent-based):ext4、XFS

"文件的第 0-99 块在磁盘的第 1000-1099 块"
优点:大文件高效,元数据紧凑

链式映射(Run-based):NTFS Data Runs

"从簇 1000 开始连续 50 簇,然后跳到簇 2000 连续 30 簇..."
优点:碎片化场景灵活,支持稀疏文件

12.4 必须知道的"坑"

现象 根因
XFS 地址计算错误 读出来全是垃圾 fsblock 高位是 AG 号,不能直接乘块大小
NTFS 偏移累加错误 只能读第一段数据 Data Runs 是累积偏移,不是绝对偏移
ext4 inode 越界 inode 2 读成 inode 3 inode 从 1 开始编号,计算时要 -1
LVM 找不到文件系统 分区检测失败 要先解析 LVM 元数据找到 LV 偏移

12.5 架构师视角

为什么理解文件系统底层很重要?

  1. 性能调优:知道 ext4 的 extent 树深度影响大文件随机读性能
  2. 故障诊断:磁盘坏块时能判断影响范围(元数据区 vs 数据区)
  3. 系统设计:设计存储系统时理解 COW、日志、快照的实现代价
  4. 安全意识:知道"删除"只是解除索引,数据仍在磁盘上

12.6 一句话总结

层次 核心问题 关键数据结构
分区表 磁盘怎么分割? MBR/GPT 分区条目
LVM 物理卷怎么组合? PV → VG → LV 映射
文件系统 文件名怎么找到数据? 目录索引 + inode + extent/run
快照 增量数据怎么存? COW + 两级索引表

终极认知:文件系统没有魔法,只有规则。掌握规则,就能在没有操作系统的情况下直接读取任何数据。