1.PE文件在磁盘上的数据结构布局与加载到内存后的数据结构布局完全一模一样。加载PE
文件的过程就是把PE文件的某个部分映射到内存的地址空间的过程。因而,像
IMAGE_NT_HEADERS这样的数据结构在内存中和在磁盘上完全一模一样。这样带来的一点
极大的好处就是如果你知道如何在一份PE文件中寻找某些东西,你也可以用相同的方法
在加载到内存的映像上寻找相同的东西。(这是很重要的一点,从原文翻译过来。)
2.必须要明白的一个点就是PE文件被映射到内存并不是像其它文件那样在磁盘上是什么样
就把它一股脑儿地原样映射到内存中。在Windows加载器映射PE文件的时候,它会遍历
PE文件,并且决定文件的哪些部分应当被映射以及哪些部分不映射。尽管会有不映射的
部分,但映射部分还是按照顺序来的。也就是说如果A和B在磁盘文件中出现的顺序是A在B的
前面,那么映射到内存后,A肯定也是在B的前面(A的起始地址小于B的起始地址),尽管
可能A与B之间的部分不会被映射。那么这个时候可能要和1中的论述出现冲突了,1中说
磁盘上的数据结构与内存中的数据结构完全一模一样。注意了,是数据结构完全一模一样,
但没有说相对偏移地址一模一样,而且既然有的映射有的不映射,相对偏移地址
肯定是不一样的了。那些无法用数据结构描述的东西,比如说程序中的代码和数据,它们也肯定要映射到内存中,但它们在内存中距离文件头的起始地址就和磁盘中距离文件头的起始地址不一样
了。这也是为什么IMAGE_OPTIONAL_HEADER数据结构中有文件对齐和内存对齐两个成员,
原文中附了一张图,有很好的演示效果。
作者同时举了几个PE文件中不会被映射到内存去的部分的例子。比如重定位信息,加载器
会读取它,但不会映射它。又比如调试信息放到文件末尾的话,是不会被映射的(如果
不是末尾呢,不知道,得写程序去验证)。文件头中指定了内存映像有多大,我想应该
就是COFF文件头中的SizeOfImage成员指定的吧。
3.当一个PE文件被Windows加载器加载到内存后,内存中的那个版本就被称为一个module。
而内存中的那个版本的起始地址就被HMODULE类型的指针描述。内存中的那份映像的起始
地址,如果采用内存映射文件,就是MapViweOfFile函数得到的地址,如果使用Imagehlp.dll
中的MapAndLoad函数,就是LOADED_IMAGE结构中的MappedAddress成员的值,如果在程序中,
就是GetModuleHandle函数得到的那个指针。只要明白了HMODULE所指的那块内存原来就是
PE文件被映射到内存后的起始地址,那么当然HMODULE处就是一个IMAGE_DOS_HEADER,
接下去就是DOS STUB,然后就是一个IMAGE_NT_HEADERS了。以后再遇到一大堆H打头的
指针不明白什么意义时,这里首先能够明白HMODULE以及MODULE是什么了。
4.所有有关PE文件结构的数据结构,枚举类型,以及宏的定义都放在WINNT.H中。自己
感觉其实winnt.h中的很多数据结构在MSDN中都没有文档,只能到winnt.h中去找。
5.通常来说,一个PE文件至少会有一个代码段和一个数据段。段的名字就像程序中的变量
名字一样,对人来说讲究可读性,但对操作系统来说则无任何意义。所以一个命名为
FOOBAR的段和命名为.text的段可以是完全一模一样的。段的名字不能用来区别两个段。
真正体现段的本质的是段的属性值,也就是IMAGE_SECTION_HEADER数据结构
中的Chracteristic成员的值。这让我想到MFC中的类的封装,同是一个list控件,有两
个不同的名字,一个叫做CListCtrl,一个叫做CListView,实际上两个类是一回事。
CListView中的GetListCtrl成员函数就是把this指针强制转换为CListCtrl类型的指针
然后返回*this的。微软通常会在段的名字前缀上一个小点,但这种命名并非标准。MSDN
上列出了好像是九个有特殊意义的段,如.text,.idata,.rdata,.directive等。同样,这些
只是一种语义上的约定。在程序设计中,完全可以指定自己的段名,方法是使用
#pragma指令,比如 #pragma data_seg("MY_DATA") 就使得所有的数据放到名字为
MY_DATA的段中,而不是默认为.data段。在编译过程中,每个源代码文件(*.c, *.cpp等)
各自独立产生一个对应的obj文件,每个obj文件中有其独立的代码段和数据段等,然后
在链接过程中,由链接器把所有obj文件中具有相同属性的段合并起来(不知道合并的
算法是怎么样的)。
6.段有两个对齐值(在IMAGE_OPTIONAL_HEADER数据结构中有两个对应的成员记录这两种对齐值),
一个是文件对齐,表示PE文件在磁盘中存储时每个段的对齐边界。另一个是内存对齐,表
示PE文件被映射到内存后每个段的对齐边界。所谓的对齐,就是说这个段的第一个字节
的地址必须是对齐值的整数倍。感觉这和struct或union这样的结构存储时的对齐一样。
段在内存中的对齐值一般都是一个页面文件大小。段如果没有一个页面那么大,那么
一个页中余下的地址处就会被填充。可以在这部分插入自己的代码来修改这个可执行文件。
段的两个对齐值通常是不一样的。用户可以创建一个在文件中段的起始地址和被映射到
内存后段的起始地址一样的可执行文件,这通常只需要指定一个链接器选项,但这么做
会使得可执行文件变大(为什么会变大的道理非常容易明白)。
7.可以将两个段进行合并。用链接器的/MERGE选项可以做到。合并段的好处很明显了,
可以节省磁盘和内存空间。但是对于合并段也没有硬性的规定。比如可以把.rdata段
合并到.text段中,但是不应该把.rsrc,.reloc,.pdata这样的段合并到其它段中(作
者是说不应该这样做,也说了没有硬性的规定,那么就是说如果用户故意这样做也完全
可以,那么这么做会产生什么样的后果,需要写程序验证)。
8.关于RVA。用相对位置来取代绝对位置,很简单的概念。注意的是RVA是指示内存中的
相对位置而不是磁盘文件中的相对位置。而VA是指内存中的实际地址,也就是PE文件
映射到内存中的起始地址加上RVA就得到VA。
9.关于DataDirectories。在可选头部(由IMAGE_OPTIONAL_HEADER数据结构描述)的最后
有一个IMAGE_DATA_DIRECTORY的数组。每个数据都是一个IMAGE_DATA_DIRECTORY数据结构。
在winnt.h中定义了一组宏来揭示该数组的每个索引中的元素的含义。
这些宏如下:
#define IMAGE_DIRECTORY_ENTRY_IMPORT 1 // Import Directory
#define IMAGE_DIRECTORY_ENTRY_RESOURCE 2 // Resource Directory
#define IMAGE_DIRECTORY_ENTRY_EXCEPTION 3 // Exception Directory
#define IMAGE_DIRECTORY_ENTRY_SECURITY 4 // Security Directory
#define IMAGE_DIRECTORY_ENTRY_BASERELOC 5 // Base Relocation Table
#define IMAGE_DIRECTORY_ENTRY_DEBUG 6 // Debug Directory
// IMAGE_DIRECTORY_ENTRY_COPYRIGHT 7 // (X86 usage)
#define IMAGE_DIRECTORY_ENTRY_ARCHITECTURE 7 // Architecture Specific Data
#define IMAGE_DIRECTORY_ENTRY_GLOBALPTR 8 // RVA of GP
#define IMAGE_DIRECTORY_ENTRY_TLS 9 // TLS Directory
#define IMAGE_DIRECTORY_ENTRY_LOAD_CONFIG 10 // Load Configuration Directory
#define IMAGE_DIRECTORY_ENTRY_BOUND_IMPORT 11 // Bound Import Directory in headers
#define IMAGE_DIRECTORY_ENTRY_IAT 12 // Import Address Table
#define IMAGE_DIRECTORY_ENTRY_DELAY_IMPORT 13 // Delay Load Import Descriptors
#define IMAGE_DIRECTORY_ENTRY_COM_DESCRIPTOR 14 // COM Runtime descriptor
数组的第0项存放Export Table……
PE文件的格式是首先一个IMAGE_DOS_HEADER--->DOS STUB---->IMAGE_NT_HEADERS--->
IMAGE_SECTION_HEADER---->SECTION 1--->SECTION 2--->...--->SECTION N。
这些数据目录表(Data Directories)自然是存放到某个section中的。
10.关于导入函数。
一个是导入库的两种导入方式,其一是程序开始运行时由加载器自动导入这些库,
这是隐式导入;其二是程序中使用LoadLibrary和GetProcAddress在程序运行时显式
导入库。对于隐式导入,加载器会遍历PE文件并且递归导入所有需要的库。所谓递归导入,
就是假如你的程序导入了库A,而库A又依赖于库B,但是你的程序中没有指定库B,
那么加载器会自动导入库B。在隐式导入中,总是在程序开始运行的那一刻就把所有需要的
库都导入进来。万一哪个库找不到了呀或者出了别的什么问题,程序就会退出。
第二是VC6.0加入了延迟导入的功能。就是只有在程序第一次调用了该库中的函数的时候
操作系统才把该库导入进来(如果这个时候该库还没有被导入的话)。这就和编译器
生成PE文件差不多,你大可声明或定义一大堆主程序中用不到的函数,链接器是不会
把它们放到PE文件中去的。
第三是关于导入函数和普通函数的调用。
编译器并不能区分出一个函数到底是从外部库中导入的还是在程序中程序员自己定义的。
因此,编译器为每一个函数调用都产生下列的汇编代码:
call XXXXXXXX
其中XXXXXXXX是实际的代码地址,而不是函数的指针。所以问题就很明显了,对于程序员
自己定义的函数,代码就在当前的程序段中,所以对于一个普通函数,XXXXXXXX既是
实际的代码地址又是函数指针,而对于从外部库中导入的函数,它的代码并不在当前的
代码段中,那么怎么办呢?没办法,编译器只好使用两级跳的方法,也就是下面所说的低
效的函数调用方法。文章中同时指出,这些JMP STUB实际上来自于导入的库为这些导入
函数准备的代码。并且说可以在一个输入库中看到这些代码(没有试过。)
文中举出了两种对于导入函数的调用方式,一种高效的调用,一种低效的调用。
高效的调用:
CALL DWORD PTR [0x00405030]
低效的调用:
CALL 0x0040100C
......
0x0040100C:
JMP DWORD PTR [0x00405030]
在文章中作者指出,这给我们带来的启示就是以后在写导出函数的头文件时一定
不要忘记了在函数前面用__declspec(dllimport)来修饰,这样可以告诉编译器这个函数是从外部导入的,一旦编译器知道了这个函数不是普通函数而是从外部导入的,就自然会为这个函数产生高效的调用代码。
下面是一个测试:
int main() ... {
printf("hello world/n");
return 0;
}
这是一个简单的C程序,调试执行到printf处,查看汇编代码:
00401018 8B F4 mov esi,esp
0040101A 68 6C 20 40 00 push offset string " hello world " (0040206c)
0040101F FF 15 00 20 40 00 call dword ptr [__imp__printf ( 00402000 )]
00401025 83 C4 04 add esp, 4
00401028 3B F4 cmp esi,esp
0040102A E8 19 00 00 00 call _chkesp ( 00401048 )
其中调用printf的形式是
call dword ptr [__imp__printf(00402000)]
这样的高效调用形式。现在在stdio.h文件中做个手脚,定们到stdio.h文件的第43行,有以下代码:
#ifdef _DLL
#define _CRTIMP __declspec(dllimport)
#else /* ndef _DLL */
#define _CRTIMP
#endif /* _DLL */
#endif /* _CRTIMP */
可以看到,如果定义过_DLL宏,就会定义__declspec(dllimport),于是,在这一段
宏定义的前面,添加一条语句。修改后的stdio.h文件中的这一部分如下:
#undef _DLL // 自己加上了这一句
#ifndef _CRTIMP
#ifdef _DLL
#define _CRTIMP __declspec(dllimport)
#else /* ndef _DLL */
#define _CRTIMP
#endif /* _DLL */
#endif /* _CRTIMP */
重新编译上面的C程序,再次调试到printf处,查看汇编代码,如下:
00401018 68 6C 20 40 00 push offset string " hello world " (0040206c)
0040101D E8 16 00 00 00 call printf ( 00401038 )
00401022 83 C4 04 add esp, 4
在内存地址00401038处有:
printf:
00401038 FF 25 00 20 40 00 jmp dword ptr [__imp__printf ( 00402000 )]
_chkesp:
0040103E FF 25 04 20 40 00 jmp dword ptr [__imp___chkesp ( 00402004 )]
这就是没有为printf函数添加__declspec(dllimport)修饰的结果。