处理数据是一切程序的本质,所以先从数据结构学起。
数据有两大类,一类是存放磁盘上的静态数据——文件,另一类是加载到内存中的动态数据。
.net程序以assembly为存储单位,以domainmodule为运行单元。
一个assembly就是一个dll/exe文件,加载到内存后则是一个module,多个module彼此调用,共存于一个domain里。
module不只来源于加载磁盘上的文件,也可以在运行时动态创建(通过Emit IL Code),但是两者的格式结构是一样的。

dll和exe文件,都是pe文件。其结构如下:


几个归纳:

  1. 首先是Dos header,历史的遗留,不去深究了

  2. 接着是NT headers,也就是WindowsNT及后续系统中的可执行文件头部,既然有个复数s,说明不只一个:

    • COFF header:据说还是unix上的格式,现在unix不用了(改为elf),但windows却还用着 😂。这里面最重要就是两个NumberNumberOfSectionsSizeOfOptionalHeader,前者记录了Section的个数,后者则(间接)记录了Data Directory的个数。

    • Optional header,也叫PE header:分为前面固定长度和后面可变长度两块,固定部份没啥好说的,都是些标识类、长度类字段,特别的是后面这个可变区域,叫Data Directory,是一个数组,每个元素两个DWORD:起始位置和长度。数组具体有多少个元素呢?并没有直接的表示,但是上面所说SizeOfOptionalHeader,却是整个这两块的大小,所以减去前面固定长度再除以8,就是元素的数量了。虽说这个是可变的,但我观察了好几个dll文件,发现都是16个。

  3. 再接着是Section headers,这里SectionData Directory的概念有点迷糊,看起来它们都有多个,都是对数据分组分类存放的意思,那为什么会有两套体系呢?据我理解,Data Directory是按数据的逻辑用途来划分,如导入表、导出表、重定向表等等,而Section是按加载到内存后区域(页面)性质来划分,如只读可读可写可执行,两个不同的Data Directory如果在内存中具有相同的页面属性,那就可以合并到同一个Section里,所以通常Section不会太多(有的只3个),那怎么在已合并的Section中找到需要的某类逻辑信息呢?就是靠Data Directory表里记录的地址字段了!

  4. 再接着是……没有了!因为没有规定再往后要按顺序接个啥了!虽然图中显示后面是cli header,但那不是规则,只是恰好显示在后面的解析结果而已……从它和前一项的地址之差也能看出来,它在0x208,而前一项也就是最后一个Section结束于0x1EC。

  5. 操作系统在将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 DirectoryRVA上,就得到它在文件里的偏移了。

    查看这几个Section的内容:

    每个Section都有VirtualAddressVirtualSize,而且绝不重叠,通过遍历区间与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上