文章目录
- [PE 文件寻址基石:彻底理解 FOA 与 RVA 的转换](#PE 文件寻址基石:彻底理解 FOA 与 RVA 的转换)
-
- 一、先搞清楚这两个概念是干什么用的
- 二、核心规则:你唯一需要记住的物理规律
- 三、符号定义(简单清楚)
- [四、RVA → FOA 的推导(从"搬家"看公式)](#四、RVA → FOA 的推导(从“搬家”看公式))
-
- 第一步:数据在内存中离区段起点有多远?
- 第二步:这个距离在文件里也是一样的
- [第三步:建立等式,解出 data_foa](#第三步:建立等式,解出 data_foa)
- 五、具体例子:手算一次就懂
- [六、反向转换:FOA → RVA](#六、反向转换:FOA → RVA)
- 七、直观图解:从文件到内存的完整映射(大图,含所有偏移数据)
- 八、弹簧比喻:区段之间被拉伸,但弹簧本身不变
- [九、为什么不能直接让 RVA = FOA?](#九、为什么不能直接让 RVA = FOA?)
- 十、一句话总结(你可以永远记住)
PE 文件寻址基石:彻底理解 FOA 与 RVA 的转换
如果你正在学习 PE 文件结构、逆向工程或恶意软件分析,你一定见过这两个词:FOA 和 RVA。很多教程一上来就甩出公式,让你死记硬背。但如果我们不理解背后的物理原因,很快就会混淆。
这篇博客的目标是:彻底丢掉笔记,从零开始建立正确的直觉。 我们将用"搬书"、"搬乐高"和"弹簧拉伸"这三个比喻,让你再也不会忘记 FOA 和 RVA 的关系。
一、先搞清楚这两个概念是干什么用的
FOA(File Offset Address)
数据在文件中的位置。从文件第一个字节开始数,第几个字节。
RVA(Relative Virtual Address)
数据在内存 中的位置。从模块基址(ImageBase)开始算,第几个字节的相对偏移。
为什么同一份数据需要两套坐标?
因为可执行文件就像是压缩包,在硬盘上为了节省空间,各部分紧凑排列 ;但当程序加载到内存中运行时,CPU 要求数据必须按页对齐 (通常是 4KB 边界),就像图书馆书架上的书必须按格子放好。这就导致同一个区段,在文件中和在内存中的起点位置不同。
二、核心规则:你唯一需要记住的物理规律
我们把可执行文件的每个区段(.text、.data 等)想象成一整块乐高积木 。当文件加载到内存时,操作系统把每一块积木原封不动地端起来,放到大桌子(内存)的另一处。
在这个过程中:
- 积木的整体位置变了(从书架 A 格搬到了 B 格)。
- 但积木上任意两个小凸点之间的相对距离不变。
由此得出唯一核心规则:
在同一个区段内部,任意数据相对于区段起点的偏移量,是固定不变的。
三、符号定义(简单清楚)
在开始计算前,先约定几个符号:
| 符号 | 含义 |
|---|---|
section_foa |
区段在文件中的起始地址 |
section_rva |
区段在内存中的起始地址 |
data_foa |
我们要找的数据在文件中的位置 |
data_rva |
我们要找的数据在内存中的位置 |
offset |
数据相对于所在区段起点的距离 |
四、RVA → FOA 的推导(从"搬家"看公式)
第一步:数据在内存中离区段起点有多远?
offset = data_rva - section_rva
这就是从"积木边缘"到"目标凸点"的距离。
第二步:这个距离在文件里也是一样的
因为积木是整体搬运,没有变形,所以:
offset = data_foa - section_foa
第三步:建立等式,解出 data_foa
既然 offset 相等,就有:
data_foa - section_foa = data_rva - section_rva
移项得到唯一的转换公式:
data_foa = section_foa + (data_rva - section_rva)
✅ 这就是 RVA → FOA 的全部秘密。
五、具体例子:手算一次就懂
假设已知:
| 项目 | 值 |
|---|---|
.text 区段在文件中的起点 section_foa |
0x200 |
.text 区段在内存中的起点 section_rva |
0x1000 |
某条指令在内存中的地址 data_rva |
0x1020 |
计算过程:
- 求偏移量:
offset = 0x1020 - 0x1000 = 0x20 - 文件地址:
data_foa = 0x200 + 0x20 = 0x220
✅ 结论:内存地址 0x1020 对应文件中的偏移 0x220。
六、反向转换:FOA → RVA
同样的逻辑,解出 data_rva:
data_rva = section_rva + (data_foa - section_foa)
用上面的例子验证:
data_rva = 0x1000 + (0x220 - 0x200) = 0x1000 + 0x20 = 0x1020,完美一致。
七、直观图解:从文件到内存的完整映射(大图,含所有偏移数据)
下面这张图展示了一个典型的 PE 文件在磁盘和内存中的布局。
关键特征:文件头部分 RVA 与 FOA 完全相等,区段数据则发生平移和对齐拉伸。
文件偏移(FOA) 内存偏移(RVA)
0x0000 ┌──────────────────────┐ 0x0000 ┌──────────────────────┐
│ DOS 头 │ │ DOS 头 │
│ PE 签名 │ │ PE 签名 │
│ 文件头 │ ◄──── 完全一致 ────► │ 文件头 │
│ 可选头 │ (逐字节复制) │ 可选头 │
│ 节表 │ │ 节表 │
0x01FF ├──────────────────────┤ 0x01FF ├──────────────────────┤
│ │ │ │
│ .text 节 │ │ 【节间填充】 │
│ 代码 │ │ 0x200 ~ 0xFFF │
│ 原始大小 0x800 │ 0x1000 ├──────────────────────┤
│ │ │ .text 节 │
│ │ │ 代码 │
│ │ │ 映射后大小 0x1000 │
0x09FF ├──────────────────────┤ 0x1FFF ├──────────────────────┤
│ │ │ │
│ .data 节 │ │ 【节间填充】 │
│ 已初始化数据 │ │ 0x2000 ~ 0x2FFF │
│ 原始大小 0x400 │ 0x2000 ├──────────────────────┤
│ │ │ .data 节 │
│ │ │ 数据 │
│ │ │ 映射后大小 0x1000 │
0x0DFF ├──────────────────────┤ 0x2FFF ├──────────────────────┤
│ │ │ │
│ .rsrc 节 │ │ 【节间填充】 │
│ 资源 │ │ 0x3000 ~ 0x3FFF │
│ 原始大小 0x600 │ 0x3000 ├──────────────────────┤
│ │ │ .rsrc 节 │
│ │ │ 资源 │
│ │ │ 映射后大小 0x1000 │
0x13FF ├──────────────────────┤ 0x3FFF ├──────────────────────┤
│ │ │ │
│ .reloc 节 │ │ 【节间填充】 │
│ 重定位表 │ │ 0x4000 ~ 0x4FFF │
│ 原始大小 0x200 │ 0x4000 ├──────────────────────┤
│ │ │ .reloc 节 │
│ │ │ 重定位表 │
│ │ │ 映射后大小 0x1000 │
0x15FF └──────────────────────┘ 0x4FFF └──────────────────────┘
图中的关键数据解读:
-
文件头区域(FOA
0x0000-0x01FF)整个 PE 头部(包括 DOS 头、PE 签名、文件头、可选头、节表)在磁盘上是连续的。
加载时,这一段被原样复制 到内存的
0x0000-0x01FF,因此 RVA 始终等于 FOA 。这是因为操作系统必须先在内存中读取头部才能解析节表,而头部本身不涉及对齐拉伸。
-
第一个节 .text
- 文件起始
section_foa = 0x0200 - 内存起始
section_rva = 0x1000(必须从 4KB 边界开始) - 文件中实际大小
0x800,内存中由于节对齐占用0x1000字节(尾部填充0至页边界) - 例如内存地址
0x1050:offset = 0x50,文件地址 =0x0200 + 0x50 = 0x250
- 文件起始
-
第二个节 .data
- 文件紧接 .text 之后:
0x0A00 - 内存中由于 .text 结束于
0x1FFF,下一个页边界是0x2000,因此 .data 从0x2000开始 - 文件中的
0x0A00~0x0DFF直接搬到0x2000~0x23FF,剩余0x2400~0x2FFF为零填充
- 文件紧接 .text 之后:
-
第三个节 .rsrc
- 文件
0x0E00,内存0x3000 - 文件大小
0x600,映射到0x3000~0x35FF,其余填充
- 文件
-
第四个节 .reloc
- 文件
0x1400,内存0x4000 - 文件大小
0x200,映射到0x4000~0x41FF
- 文件
为什么文件头是个特例?
因为 PE 头部本身就是控制信息,加载器在读取时并不移动它,而是直接从文件偏移 0 开始映射到内存地址 0(相对基址)。头部中的节表又指明了每个节应该被映射到哪个 RVA,此时头部已经占用了 0x0000~0x01FF 的区域,所以第一个节的 RVA 至少要从 0x1000 开始(满足页对齐),从而产生了 FOA 和 RVA 的分道扬镳。
八、弹簧比喻:区段之间被拉伸,但弹簧本身不变
文件中的区段是紧挨着的,内存中因为对齐要求,区段之间被塞入空白 ,就像一条弹簧被拉开。
但是,弹簧本身的每一圈(区段内部数据)纹理不变,相对位置没有改变。
文件(紧凑):
[====.text====][==.data==][==.rsrc==][.reloc]
↑ ↑ ↑ ↑
紧挨着
↓ 加载时,因页对齐拉伸 ↓
内存(拉伸):
[====.text====]....[==.data==]....[==.rsrc==]....[.reloc]
↑ ↑ ↑ ↑ ↑ ↑ ↑
起点 空白 起点 空白 起点 空白 起点
核心结论
- ✅ 弹簧圈内部:没有拉伸 ,偏移量不变 →
FOA - section_foa = RVA - section_rva - ✅ 弹簧圈之间:被拉伸(插入空白填充),不能直接套用同一个公式
九、为什么不能直接让 RVA = FOA?
因为除了文件头区域外,节数据的起点被平移了。如果强行认为所有数据 RVA 都等于 FOA,实际访问的内存位置就会完全错误,程序立刻崩溃。
你可以这么记:
FOA = 文件老家地址 + (内存新家地址 - 内存老家地址)
本质上就是:先在内存里找到离家多远的偏移,再把这个偏移套用到文件里的家。
十、一句话总结(你可以永远记住)
区段整体搬家,内部顺序不变。
先算离家多远,再在新家找到位置。
下次在十六进制编辑器或调试器中碰到 PE 地址转换时,闭上眼睛回想那张完整的映射图:
- 头部:文件什么样,内存就什么样,RVA=FOA。
- 节区:每个节都被端起来放到了 4KB 对齐的新位置,内部偏移不变,节间被拉大。
记住这个画面,公式就会自然浮现在你脑海中。
希望这篇博客能帮你彻底建立起 FOA 与 RVA 的直觉。如果还有模糊的地方,不妨回到那张"文件→内存全貌图"再想一次。