mono源代码学习(二) pe文件结构
处理数据是一切程序的本质,所以先从数据结构学起。
数据有两大类,一类是存放磁盘上的静态数据——文件,另一类是加载到内存中的动态数据。
.net程序以assembly
为存储单位,以domain
和module
为运行单元。
一个assembly
就是一个dll/exe文件
,加载到内存后则是一个module
,多个module
彼此调用,共存于一个domain
里。
module
不只来源于加载磁盘上的文件,也可以在运行时动态创建(通过Emit IL Code),但是两者的格式结构是一样的。
dll和exe文件,都是pe文件。其结构如下:
几个归纳:
-
首先是
Dos header
,历史的遗留,不去深究了 -
接着是
NT headers
,也就是WindowsNT
及后续系统中的可执行文件头部,既然有个复数s,说明不只一个:-
COFF header
:据说还是unix上的格式,现在unix不用了(改为elf),但windows却还用着 😂。这里面最重要就是两个Number
:NumberOfSections
和SizeOfOptionalHeader
,前者记录了Section
的个数,后者则(间接)记录了Data Directory
的个数。 -
Optional header
,也叫PE header
:分为前面固定长度和后面可变长度两块,固定部份没啥好说的,都是些标识类、长度类字段,特别的是后面这个可变区域,叫Data Directory
,是一个数组,每个元素两个DWORD:起始位置和长度。数组具体有多少个元素呢?并没有直接的表示,但是上面所说SizeOfOptionalHeader
,却是整个这两块的大小,所以减去前面固定长度再除以8,就是元素的数量了。虽说这个是可变的,但我观察了好几个dll文件,发现都是16个。
-
-
再接着是
Section headers
,这里Section
和Data Directory
的概念有点迷糊,看起来它们都有多个,都是对数据分组分类存放的意思,那为什么会有两套体系呢?据我理解,Data Directory
是按数据的逻辑用途来划分,如导入表、导出表、重定向表等等,而Section
是按加载到内存后区域(页面)性质来划分,如只读
、可读可写
、可执行
,两个不同的Data Directory
如果在内存中具有相同的页面属性,那就可以合并到同一个Section
里,所以通常Section
不会太多(有的只3个),那怎么在已合并的Section
中找到需要的某类逻辑信息呢?就是靠Data Directory
表里记录的地址字段了! -
再接着是……没有了!因为没有规定再往后要按顺序接个啥了!虽然图中显示后面是
cli header
,但那不是规则,只是恰好显示在后面的解析结果而已……从它和前一项的地址之差也能看出来,它在0x208,而前一项也就是最后一个Section
结束于0x1EC。 -
操作系统在将PE文件加载到内存时,是以
Section
为单位来映射的,每个Section
在文件中的位置、在内存中的位置都有定义,前者叫PointToRawData
,后者叫VirutalAddress
;而Data Directory
里的RVA
地址指的就是该数据段在内存中的位置(相对于起点)。这里就有引出个问题,如果我并没有用操作系统的API来加载该PE文件,只是自己在磁盘文件里找某个Data Directory
,该怎么找?比如上图所示。想找到文件中
cli header
的位置,首先根据PE规范,知道在第15个Data Directory
里定义了.net metadata
,也就是cli header
。然而在上图显示的.NET Metadata RVA
却是0x2008,这跟下面cli header
的首地址0x208相差不少!而且乍一看还搞不懂它们间的换算关系(既不是去掉高位,也不是低位取整,而是中间少个0 😂)这个问题很奇怪,怪在为什么
Data Directory
里要存一个RVA
而不是FilePointer
?但实际上又并不怪,因为PE文件
的本来用途就是Windows上的可执行文件容器,当它运行时,是几乎原封不动的将磁盘文件映射到内存中(除了对Section
的位置做一些挪动),加载器或运行时要找某个Data Directory
时,在映射好的内存中去找即可,而在内存中定位自然以RVA
最为直接。但是做为一个
.net assembly
,它并不是传统的可执行文件,也就是说将磁盘文件直接映射到内存是没意义的(因为映射后的内存里并没有可执行的机器码),却又要“借壳”PE文件格式
,所以要在它当中寻找某个Data Directory
时,内存中无迹可寻,RVA
无法使用,必须找到一个关系,将它换算成在文件中的偏移量。这个换算关系的重点,就是要知道一个Section
(准确点说就是这个Data Directory
所属的Section
)在文件中的偏移和在内存中的偏移,把这个差值
加到Data Directory
的RVA
上,就得到它在文件里的偏移了。查看这几个
Section
的内容:每个
Section
都有VirtualAddress
和VirtualSize
,而且绝不重叠,通过遍历区间与RVA
比较,就可以确定它落在哪个Section
中。然后用相应的PointerToRawData
来减VirtualAddress
,再加上RVA
,就得到此Data Directory
的“FilePointer”了。上例中,0x2008+(0x200-0x2000) = 0x208, 正好就是
cli header
在文件中的起始位置 😆
想通这个关系后,我常舒一口气。但又觉得这只是自己的推测,究竟是否真的如此呢?要验证,当然是调试mono源码,看它加载一个assembly时的处理逻辑了:
首先跟踪到这里,刚要加载cli_header,传递的正是datadir.pe_cli_header.rva
=0x2008
再跟进去一看:
确实如上所料:
- for循环是在确定区间
- 找到section后,两个地址相减加到rva上