2004-9-2 19:29
6. 引入函数
当你使用别的DLL中的代码或数据,称为引入。当PE载入时,载入器的工作之一就是定位所有引入函数及数据,使那些地址对于载入的PE可见。具体细节在后面讨论,在这里只是大概讲一下。
当你用到了一个DLL中的代码或数据,你就暗中连接到这个DLL。但是你不必为“把这些地址变得对你的代码有效”做任何事情,载入器为你做这些。方法之一就是显式连接,这样你就要确定DLL已被载入,及函数的地址。调用LOADLIBARY和GETPROCADDRESS就可以了。
当你暗式连接DLL,LOADLIBARY和GETPROCADDRESS同样还是执行了的。只不过载入器为你做了这些。载入器还保证PE文件所需得任何附加的DLL都已被载入。比如,当你连接了KERNEL32.DLL,而它又引入了NTDLL.DLL的函数,又比如当你连接了GDI32.DLL,而它又依赖于USER32, ADVAPI32,NTDLL, 和 KERNEL32 DLLs的函数,载入器会保证这些DLL被载入及函数的决议。
暗式连接时,决议过程在PE文件在载入时就发生了。如果这时有什么问题(比如这个DLL文件找不到),进程终止。
VISUAL C++ 6.0 加入了DLL的延迟载入的特征。它是暗式连接和显式连接的混合。当你延迟载入DLL,连接器做出一些和引入标准规则DLL类似的东西,但是操作系统却不管这些东西,而是在第一次调用这个DLL中的函数的时候载入(如果还没载入),然后调用GetProcAddress取得函数的地址。
对于pe文件要引入的dll都有一个对应的结构数组,每个结构指出这个dll的名字及指向一个函数指针数组的指针,这个函数指针数组就是所谓的IAT(IMORT ADDRESS TABLE)。每个输入函数,在IAT中都有一个保留槽,载入器将在那里写入真正的函数地址。最后特别重要一点的是:模块一旦载入,IAT中包含所要调用的引入函数的地址。
把所有输入函数放在IAT一个地方是很有意义的,这样无论代码中多少次调用一个引入函数,都是通过IAT中的一个函数指针。
让我们看看是怎样调用一个引入函数的。有两种情况需要考虑:有效率的和效率差的。最好的情况像下面这样:
CALL DWORD PTR [0x00405030]
直接调用[0x405030]中的函数,0x405030位于IAT部分。效率差的方式如下:
CALL 0x0040100C
...
0x0040100C:
JMP DWORD PTR [0x00405030]
这种情况,CALL把控制权转到一个子程序,子程序中的JMP指令跳转到位于IAT中的0x00405030,简单说,它多用了5字节和JMP多花的时间。
你可能惊讶引入函数就采用了这种方式,有个很好的解释,编译器无法区别引入函数的调用和普通函数调用,对于每个函数调用,编译器只产生如下指令:
CALL XXXXXXXX
XXXXXXXX是一个由连接器填入的RVA。注意,这条指令不是通过函数指针来的,而是代码中的实际地址。
为了因果的平衡,连接器必须产生一块代码来代替取代XXXXXXXX,简单的方法就是象上面所示调用一个JMP STUB.
那么JMP STUB 从那里来呢?令人惊异的是,它取自输入函数的引入库。如果你去察看一个引入库,在输入函数名字的关联处,你会发现与上面JMP STUB相似的指令。
接着,另一个问题就是如何优化这种形式,答案是你给编译器的修饰符,__declspec(import) 修饰符告诉编译器,这个函数来自另一个dll,这样编译器就会产生第一种指令。另外,编译器将给函数加上__imp_前缀然后送给连接器决议,这样可以直接把__imp_xxx送到iat,就不需要jmp stub了。
对于我们这有什么意义呢,如果你在写一个引出函数的东西并提供一个头文件的话,别忘了在函数前加上修饰符__declspec(import)
__declspec(dllimport) void Foo(void);
在winnt.h等系统头文件中就是这样做的。
7. PE 文件结构
现在让我们开始研究PE文件格式,我将从文件的头部开始,描述每个PE文件中都有的各种数据结构,然后,我将讨论更多的专门的数据结构比如引入表和资源,除非特殊说明,这些结构都定义在WINNT.H中。
一般地,这些结构都有32和64位之分,如IMAGE_NT_HEADERS32 ,IMAGE_NT_HEADER64等,他们基本上是一样的,除了64位的扩展了某些字段。通过#DEFINE WINNT.H都屏蔽了这些区别,选择那个数据结构取决于你要如何编译了(如,是否定义_WIN64)
The MS-DOS Header
每个PE文件是以一个DOS程序开始的,这让人想起WINDOWS在没有如此可观的使用者的早期年代。当可执行文件在非WINDOWS平台上运行的时候至少可以显示出一条信息表示它需要WINDOWS。
PE文件的开头是一个IMAGE_DOS_HEADER结构,结构中只有两个重要的字段e_magic and e_lfanew。e_lfanew指出pe file header的偏移,e_magic需要设定位0x5a4d,被#define 成IMAGE_DOS_SIGNATURE 它的ascii为’MZ’,Mark Zbikowski的首字母,DOS 的原始构建者之一。
The IMAGE_NT_HEADERS Header
这个结构是PE文件的主要定位信息的所在。它的偏移由IMAGE_DOS_HEADER的e_lfanew给出
确实有64和32位之分,但我在讨论中将不作考虑,他们几乎没有区别。
typedef struct _IMAGE_NT_HEADERS {
DWORD Signature;
IMAGE_FILE_HEADER FileHeader;
IMAGE_OPTIONAL_HEADER32 OptionalHeader;
} IMAGE_NT_HEADERS32, *PIMAGE_NT_HEADERS32;
在一个有效的pe文件里,Signture被设为0x00004500,ascii 为’PE00’,#define IMAGE_NT_SIGNTURE 0X00004500;第二个字段是一个IMAGE_FILE_HEADER结构,它包含文件的基本信息,特别重要的是它指出了IMAGE_OPTIONAL_HEADER的大小(重要吗?);在PE文件中,IMAGE_OPTIONAL_HEADER是非常重要的,但是仍称作IMAGE_OPTIONAL_HEADER。
IMAGE_OPTIONAL_HEADER结构的末尾就是用来定位pe文件中重要信息的地址簿-数据目录,它的定义如下:
typedef struct _IMAGE_DATA_DIRECTORY {
DWORD VirtualAddress; // RVA of the data
DWORD Size; // Size of the data
};
The Section Table
紧接着IMAGE_NT_HEADERS后的就是节表,节表就是IMAGE_SECTION_HEADER的数组。IMAGE_SECTION_HEADER包含了它所关联的节的信息,如位置,长度,特征;该数组的数目由IMAGE_NT_HEADERS.FileHeader.NumberOfSections指出。具体见下图
PE中的节的大小的总和最后是要对齐的,Visual Studio 6.0中的默认值是4k,除非你使用/OPT:NOWIN98 或/ALIGN开关;在.NET中,依然用了默认的/OPT:WIN98,但是如果文件小于一特定大小时,就会采用0X200为对齐值。
.NET文档中有关于对齐的另一件有趣的事。.NET文件的内存对齐值为8K而不是普通X86平台上的4K,这样就保证了在X86平台编译的程序可以在IA-64平台上运行。如果内存对齐值为4K,那么IA-64的载入器就不能载入这个程序,因为它的页为8K。