好友
阅读权限10
听众
最后登录1970-1-1
|
本帖最后由 [VIP]_年华╮似 于 2016-6-9 16:31 编辑
深入剖析Win32可移植可执行文件格式
第一部分
作者:Matt Pietrek
很久以前,我开始为Microsoft Systems Journal(现在的MSDN® Magazine)写文章,其中有一篇名为“探索PE文件内幕——Win32可移植可执行文件格式之旅”的文章很受欢迎,大大超出了我的意料。直到现在,我还听说有人(甚至在Microsoft)仍然在使用那篇文章,它依旧被收录在MSDN Library中。不幸的是,文章的最大问题是它们是静止的。但是Win32®的世界在这些年已经发生了很大的变化,因此那篇文章已经严重过时了。我要从本月开始用两部分系列的文章来补救这种情况。
你可能想知道为什么要关注可执行文件的格式。答案永远是:操作系统的可执行文件格式和数据结构展现了操作系统内部许多信息。通过理解EXE和DLL的内部情况,你会发现你已经变成你周围一个更优秀的程序员。
当然,通过阅读Microsoft的PECOFF规范你可以获得许多我将要告诉你的内容。但是与大多数规范一样,它更注重完整性而不是可读性。在本文中,我把精力集中于解释整个故事中最重要的部分,同时填补那些并不适合出现在官方规范中的怎么样(How)以及为什么(Why)的问题。另外,在本文中我还会讲到一些非常有用的内容,它们并未出现在任何Microsoft官方文档中。
让我先举一些例子来说明自从1994年我写那篇文章以来有关可执行文件方面都发生了哪些变化。由于16位Windows®已经成为历史,因此没有必要再与Win16的NE(New Executable)格式相比较了。另一个已经脱离人们视野的是Win32s®。在Windows 3.1上运行Win32程序非常不稳定是最令人讨厌的事。
回到当时,Windows 95(当时代号为“Chicago”)甚至还未发行。Windows NT®还是3.5版。Microsoft链接器还未进行非常有效地优化。值得一提的是当时已经在MIPS和DEC Alpha上实现了Windows NT。
自从那篇文章以来都出现了什么新内容呢?64位Windows引进了它自己的变种的PE文件格式。Windows CE添加了许多的新型处理器。诸如DLL延迟加载、节合并以及绑定之类的优化已经铺天盖地。有许多新东西要加入到这个故事中。
让我们不要忘了Microsoft® .NET。该把它放在什么位置呢?对于操作系统来说,.NET可执行文件只不过是普通的Win32可执行文件。但是.NET运行时能够识别出这些可执行文件中的数据并把它作为元数据(metadata)和中间语言(Intermediate Language,IL),它们对.NET来说非常重要。在本文中,我要敲开.NET元数据结构的大门,但把对它全部光彩的彻底挖掘留给下一篇文章。
如果Win32世界中的所有这些加加减减还不足以成为我重新写那篇文章的理由的话,那么我只有列出原来那篇文章中的一些令我害怕的错误了。例如我对线程局部存储(TLS)支持情况的描述是错误的。同样,通篇我对日期/时间戳这个DWORD的描述仅在太平洋时区才是精确的!
另外,有许多内容在当时是正确的,但现在已经不正确了。我说过.rdata节并没有太大的作用。今天,诚然是这样。我也说过.IDAta节是可读/可写的节,但现在却有许多试图拦截API的人发现它在很多情况下都是不正确的。
伴随着在这篇文章中完全更新PE文件格式的故事,我也对用于显示PE文件内容的PEDUMP程序进行了彻底修改。PEDUMP现在可以在x86和IA-64平台上编译和运行,并且能够转储32位和64位PE文件。最重要的是,PEDUMP的源代码可以从本文开头的链接处下载。这样,你就有了一个用这里讲的概念和数据结构实际工作的例子。
PE文件格式概览
Microsoft引进了PE文件格式,更经常被称为PE格式,作为最初的Win32规范的一部分。然而PE文件源自VAX/VMS上早期的通用目标文件格式(Common Object File Format,COFF)。这是由于许多最初的Windows NT开发团队的成员都来自数字设备公司(Digital Equipment Corporation,DEC)。这些开发者很自然就使用现有的代码以便快速开始新的Windows NT平台。
之所以选择术语“可移植可执行”是打算要在所有支持的CPU上的所有版本的Windows上使用相同的可执行文件格式。从大的方面来说,这个目标已经实现,因为Windows NT及其后继操作系统、Windows 95及其后继操作系统以及Windows CE都使用相同的可执行文件格式。
Microsoft编译器生成的OBJ文件也使用COFF格式。从COFF格式的一些域使用的竟然是八进制编码你就能知道它是多么老。COFF格式的OBJ文件中有许多数据结构和枚举类型与PE文件相同,后面我会提到。
64位Windows需要做的只是修改PE格式的少数几个域。这种新的格式被称为PE32+。它并没有增加任何新域,仅从PE格式中删除了一个域。其余的改变就是简单地把某些域从32位扩展到64位。在大部分情况下,你都能写出同时适用于32位和64位PE文件的代码。Windows头文件有这种魔力可以使这些区别对于大多数基于C++的代码都不可见。
EXE文件与DLL文件的区别完全是语义上的。它们使用的是相同的PE格式。惟一的不同在于一个位,这个位用来指示文件应该作为EXE还是DLL。甚至DLL文件的扩展名也完全也是人为的。你可以给DLL一个完全不同的扩展名,例如.OCX控件和控制面板小程序(.CPL)都是DLL。
PE文件一个非常好的地方就是它的数据结构在磁盘上与在内存中一样。加载一个可执行文件到内存(例如通过调用LoadLibrary函数)主要就是把PE文件中的某个部分映射到地址空间中。因此像IMAGE_NT_HEADERS(后面我会讲到)这样的数据结构在磁盘上和在内存中是一样的。如果你知道如何在一个PE文件中找到某些内容,你几乎可以确定当文件被加载进内存时可以找到同样的信息。
注意到PE文件并不是作为单一的内存映射文件被映射进内存的这一点非常重要。相反,Windows加载器查看PE文件并确定文件中的哪些部分需要被映射。当映射进内存时,文件中的高偏移相对于内存中的高地址。某项内容在磁盘文件中的偏移可能与它被加载进内存之后的偏移
不同,但是将磁盘文件中的偏移转换成内存偏移需要的所有信息都存在(见下图)。
当PE文件由Windows加载器加载进内存时,它在内存中被称为模块(module)。文件被映射到的内存的起始地址被称为HMODULE。这是需要记住的一点:给你一个HMODULE,你就知道在那个地址处到底有什么样的数据结构,并且你可以根据PE文件的知识找到内存中所有其它的数据结构。这个强大的功能可以被用作其它用途,例如拦截API。(说得再准确一点,在Windows CE上HMODULE与加载地址并不相同,但那不是今天要讨论的内容。)
内存中的模块代表一个进程所需的可执行文件中的所有代码、数据和资源。PE文件中的其它部分可能会被读取,但并不被映射进内存(例如重定位节)。一些部分可能根本就不被映射,例如放在文件末尾的调试信息。PE文件头中的一个域告诉系统将这个可执行文件映射进内存时需要占用多少内存。不被映射的数据放在文件末尾,位于所有需要被映射的部分之后。
描述PE文件(和COFF文件)的关键位置是WINNT.H文件。在这个头文件中,你能找到几乎所有结构的定义、枚举类型以及使用PE文件或它在内存中的等价结构所需的定义。当然在其他地方有这方面的文档,例如MSDN中有“Microsoft可移植可执行文件和通用目标文件格式文件规范”(2001年十月MSDN的Specifications下)。但是WINNT.H确定了PE文件最终的样子。
有许多工具可以用来查看PE文件。Visual Studio附带的Dumpbin和Platform SDK附带的Depends就是其中的两个。我特别喜欢Depends,它以一种非常简洁的方式查看文件的导入表和导出表。另一个很好的PE文件查看工具是由Smidgeonsoft(http://www.smidgeonsoft.com)发行的PEBrowse Professional。本文中包含的PEDUMP程序也是一个非常全面的工具,几乎能做Dumpbin所能做到的一切。
从API的观点来看,Microsoft提供的读取和修改PE文件的主要途径是IMAGEHLP.DLL文件。
在开始看PE文件的细节之前,先复习一下贯穿于整个PE文件方面的几个基本概念是非常值得的。在以下的部分中,我将讨论PE文件的节(section)、相对虚拟地址(RVA)、数据目录(Data Directory)以及如何导入函数。
PE文件的节
PE文件的节代表代码或某些类型的数据。虽然代码只能是代码,但数据却有许多种不同类型。除了可读/可写的程序数据(例如全局变量)外,节中其它类型的数据包括函数导入表和导出表、资源以及重定位信息等。每个节都有它自己的一组内存属性,其中包括节中是否包含代码,是只读的还是可读/可写的以及节中的数据是否在所有使用这个可执行文件的进程中是共享的等等。
一般说来,一个节中的所有代码和数据在逻辑上是相关的。通常一个PE文件中至少有两个节,一个是代码节,另一个是数据节。一般在PE文件中至少还有一种其它类型的数据节。我将在下个月本文的第二部分中具体描述各种节。
每个节都有一个惟一的名字。它通常用来表示节的用途。例如一个名为.rdata的节表明它是一个只读数据节。使用节名只是为了方便人们处理文件,它对操作系统来说无关紧要。一个名为FOOBAR的节可能实际上是一个代码节,就像.text节一样。Microsoft通常在它们的节名前加一个圆点,但这并不是必须的。多少年来,Borland链接器使用的节名都是CODE和DATA。
虽然编译器生成的节都有标准设置,但那并没有什么神奇的。你可以创建和命名你自己的节,链接器很乐意把它们包含进可执行文件中。在Visual C++中,你可以告诉编译器把代码或数据插入到你使用#pragma语句命名的节中。例如以下语句 #pragma data_seg( "MY_DATA" ) 导致所有数据都被Visual C++放入一个称为MY_DATA的节中,而不是默认的.data节。大多数程序员都使用编译器生成的默认节,但偶尔你也可能需要把代码或数据放进自己定义的节中。
节并不是完全由链接器生成的,在OBJ文件中就有它们的身影。这通常是由编译器放在那里的。链接器的工作就是把OBJ文件和库文件中所有需要的节组合成PE文件中最终相应的节。例如你的工程中的每个OBJ文件可能都至少有一个包含代码的.text节。链接器把各种OBJ文件中的所有.text节组合成单个的.text节放入PE文件。同样,各种OBJ文件中的所有.data节也被组合成PE文件中单个的.data节。.LIB文件中的代码和数据通常也被包含进可执行文件中,但对它的讨论已经超出了本文的范围。
链接器遵守一组相当完整的规则来确定组合哪个节以及如何组合。我在MSJ杂志1997年七月的Under The Hood专栏已经介绍过链接器算法。OBJ文件中有的节是专供链接器使用的,它们并不被放入最后的可执行文件中。这样的节通常用于编译器向链接器传递信息。
节有两种对齐值,一种是在文件中的对齐值,另一种是在内存中的对齐值。PE文件头中指定了这两种值,它们可以不同。每个节的起始地址都是对齐值的倍数。例如在PE文件中,典型的对齐值是0x200。因此每个节的起始起始地址都是0x200的倍数。
一旦映射进内存,节总是从页的边界开始。也就是说,当PE文件被映射进内存时,每个节的开头都对应于一个内存页的开始。在x86 CPU上,页按4KB对齐;在IA-64上,页按8KB对齐。以下是PEDUMP输出的Windows XP中的KERNEL32.DLL的.text节和.data节的情况:
Section Table
01 .text VirtSize: 00074658 VirtAddr: 00001000
raw data offs: 00000400 raw data size: 00074800
……
02 .data VirtSize: 000028CA VirtAddr: 00076000
raw data offs: 00074C00 raw data size: 00002400
上面的输出表明,.text节的文件偏移是0x400,在内存中它比KERNEL32的加载地址高0x1000字节。同样,.data节的文件偏移是0x74C00,它在内存中比KERNEL32的加载地址高0x76000字节。
可以创建一个PE文件,使它的节在文件中的偏移与在内存中的偏移相同。但这会使可执行文件相当大,不过可以提高它在Windows 9x或Windows Me中的加载速度。使用默认的/OPT:WIN98链接器选项(由Visual Studio 6.0引进)就可以创建这样的PE文件。在Visual Studio® .NET中,链接器可能使用也可能不使用这个选项,这取决于文件是不是足够小。
链接器一个比较有趣的功能就是合并节。如果两个节属性相似、相互兼容时,它们通常会在链接时被合并成一个节。这是通过链接器的/MERGE选项来完成的。例如以下的链接器选项会把.rdata节和.text节组合成单个的.text节:
/MERGE:.rdata=.text
把节合并起来的好处是可以节省在磁盘上和在内存中的空间。每个节至少要在内存中占用一个页面。如果你能将一个可执行文件中节的数目从四个减少到三个,你很可能就会少占用一页内存。当然,这取决于那两个被合并的节中未使用的空间加起来够不够一页。
当你合并节时就会发生一些有趣的情况,因为这并没有硬性的和快速的规则可以遵守。例如,把.rdata节合并到.text节当然可以,但是你不能把.rsrc节、.reloc节或者.pdata节合并到其它节中。在Visual Studio .NET之前,你可以把.idata节合并到其它节中。但是在Visual Studio .NET中,这是不允许的。但在创建程序的发行版本时,链接器自己却经常把部分的.idata节合并到其它节中,例如.rdata节。
由于导入表要被Windows加载器改写,你可能想知道它们怎么能被放在只读节中。这是因为在加载时系统临时将包含导入表的那些页面的属性设置为可读/可写。一旦导入表被初始化,这些页面就会被设置成原来的属性。
相对虚拟地址
在可执行文件中,许多地方都需要被指定一个在内存中的地址。例如在使用全局变量时需要它的地址。PE文件可以被加载到进程地址空间中的任何地方。虽然它有一个首选地址,但你却不能依赖可执行文件一定会被加载到那个地址。因此就需要按一定方式指定地址,使它们并不依赖于可执行文件的加载地址。
为了避免在PE文件中硬编码内存地址,因此就使用了RVA。RVA只是一个相对于PE文件在内存中的加载位置的偏移。例如假定一个EXE文件被加载在0x400000处,而它的代码节在0x401000处。那么这个代码节的RVA就是:
(目标地址)0x401000 - (加载地址)0x400000 = (RVA)0x1000
要把一个RVA转换成实际地址,只需要简单地逆着上述过程进行:将RVA与实际加载地址相加就能得到实际的内存地址。顺便说一下,按照PE格式中的说法,实际的内存地址被称为虚拟地址(Virtual Address,VA)。另外一种考虑VA的方式就是把它当成RVA加上首选加载地址。不要忘了我前面说过加载地址与HMODULE是一回事。
想在内存中探索一些DLL内部的数据结构吗?这里就是方法——用DLL的名称作为参数调用GetModuleHandle函数,它返回的HMODULE就是这个DLL的加载地址,你可以利用你学的关于PE文件结构的知识在这个模块中找到你想找到的一切。
数据目录
在可执行文件中有许多数据结构需要被快速地定位。导入表、导出表、资源以及基址重定位信息等就是一些明显的例子。所有这些广为人知的结构都是以同样的方式被定位的,这些位置被称为数据目录。
数据目录是一个有16个(WINNT.H中定义为IMAGE_NUMBEROF_DIRECTORY_ENTRIES)元素的结构数组。每个数组元素所指代的内容已经被预先定义好了。WINNT.H文件中的这些IMAGE_DIRECTORY_ENTRY_xxx定义就是数据目录的索引(从0到15)。下表描述了每个IMAGE_DIRECTORY_ENTRY_xxx值所指代的内容。由它们指向的许多数据结构将在本文的第二部分中详细描述。
值
描述
IMAGE_DIRECTORY_ENTRY_EXPORT
指向导出表(IMAGE_EXPORT_DIRECTORY结构)。
IMAGE_DIRECTORY_ENTRY_IMPORT
指向导入表(IMAGE_IMPORT_DESCRIPTOR结构数组)。
IMAGE_DIRECTORY_ENTRY_RESOURCE
指向资源(IMAGE_RESOURCE_DIRECTORY结构)。
IMAGE_DIRECTORY_ENTRY_EXCEPTION
指向异常处理程序表(IMAGE_RUNTIME_FUNCTION_ENTRY结构数组)。它特定于CPU,用于基于表的异常处理。适用于除x86之外所有类型的CPU。
IMAGE_DIRECTORY_ENTRY_SECURITY
指向WIN_CERTIFICATE结构列表。此结构定义在WinTrust.H文件中。它并不作为映像的一部分被映射进内存。因此VirtualAddress域是文件偏移,而不是RVA。
IMAGE_DIRECTORY_ENTRY_BASERELOC
指向基址重定位信息。
IMAGE_DIRECTORY_ENTRY_DEBUG
指向IMAGE_DEBUG_DIRECTORY结构数组。其中的每个元素描述了映像中的一些调试信息。要获得IMAGE_DEBUG_DIRECTORY结构的数目,用Size域除以IMAGE_DEBUG_DIRECTORY结构的大小。早期的Borland链接器将这个IMAGE_DATA_DIRECTORY项的Size域设置成IMAGE_DEBUG_DIRECTORY结构的数目,而不是数组的大小。
IMAGE_DIRECTORY_ENTRY_ARCHITECTURE
指向与平台相关的数据,这个数据是一个IMAGE_ARCHITECTURE_HEADER结构数组。x86平台和IA-64平台并不使用,但好像已经用于DEC/Compaq Alpha平台。
值
描述
IMAGE_DIRECTORY_ENTRY_GLOBALPTR
在某些平台上,其VirtualAddress域保存的是全局指针(Global Pointer ,GP)的RVA。x86平台上不使用,但IA-64平台上使用。Size域并未使用。要获取更多关于IA-64 GP方面的信息,可以参考2000年11月的Under The Hood专栏。
IMAGE_DIRECTORY_ENTRY_TLS
指向线程局部存储(Thread Local Storage)初始化节。
IMAGE_DIRECTORY_ENTRY_LOAD_CONFIG
指向IMAGE_LOAD_CONFIG_DIRECTORY结构。此结构中的信息特定于Windows NT、Windows 2000和Windows XP(例如GlobalFlag值)。如果你的可执行文件要使用这个结构,需要定义一个名称为__load_config_used,类型为IMAGE_LOAD_CONFIG_DIRECTORY的全局结构体。对于非x86平台,这个名称需要被定义成_load_config_used(单下划线)。如果你想使用IMAGE_LOAD_CONFIG_DIRECTORY结构,必须使用这个技巧才能在你的C++代码中得到正确的名字。链接器看到的符号名一定要是__load_config_used(带两个下划线)。C++编译器要在全局符号前加一个下划线。另外,它还使用类型信息来修饰(decorate)全局符号。因此要使一切正常,你应该像下面这个样子使用:
extern "C"
IMAGE_LOAD_CONFIG_DIRECTORY _load_config_used = {...}
IMAGE_DIRECTORY_ENTRY_BOUND_IMPORT
指向IMAGE_BOUND_IMPORT_DESCRIPTOR结构数组。每个结构对应于这个映像已经绑定的一个DLL。这个结构中的日期/时间戳(TimeDateStamp域)可以让加载器快速确定这个绑定是否是最新的。如果不是,加载器将忽略绑定信息,并正常地解析导入的函数。
IMAGE_DIRECTORY_ENTRY_IAT
指向第一个导入地址表(IAT)的开头。对应于每一个导入的DLL都有一个相应的IAT,并且它们在内存中依次排列。Size域指出了所有IAT的总大小。加载器在解析导入符号期间使用这个地址和大小临时将包含IAT的页面标记为可读/可写。
IMAGE_DIRECTORY_ENTRY_DELAY_IMPORT
指向延迟加载信息,它是CImgDelayDescr结构数组,这个结构被定义在Visual C++的DELAYIMP.H文件中。直到首次调用延迟加载的DLL中的函数时这个DLL才会被加载。特别需要注意的是:Windows并不知道关于延迟加载DLL方面的任何信息。延迟加载特性完全是由链接器与运行时库来实现的。
IMAGE_DIRECTORY_ENTRY_COM_DESCRIPTOR
这个值在最新的系统头文件(CorHdr.h)中被更名为IMAGE_DIRECTORY_ENTRY_COMHEADER。它指向可执行文件中的.NET信息中的顶层信息,包括元数据。这个信息保存在IMAGE_COR20_HEADER结构中。
导入函数
当你使用其它DLL中的代码或数据时,就需要导入它们。当加载PE文件时,Windows加载器的工作之一就是定位所有导入的函数和数据,以便加载的PE文件可以使用它们。我把对完成这个任务所需的数据结构的详细讨论留给本文的第二部分,在这里仅给出一个整体概念。
当你直接链接其它DLL中的代码和数据时,实际上隐含链接到了相应的DLL上。你并不需要做任何工作来让你的程序使用这些导入的函数。加载器把这些全包了。另一种使用DLL的方式是显式链接(explicit linking)。这意味着你需要明确地加载这些DLL,然后查找这些函数的地址。这种方法几乎总是通过LoadLibrary和GetProcAddress这两个API来完成的。
当你隐含链接函数时,类似于使用LoadLibrary和GetProcAddress这两个API的代码仍然存在,但加载器自动为你做这些事。同时加载器确保这个被加载的PE文件所需的其它DLL也会被加载。例如用Visual C++®创建的程序一般都会链接到KERNEL32.DLL,而KERNEL32.DLL又从NTDLL.DLL中导入了函数。同样,如果你从GDI32.DLL导入函数,而实际上这个DLL依赖于USER32、ADVAPI32、NTDLL以及KERNEL32这些DLL。加载器会确保这些DLL都被加载,以便解析这些导入的函数。(Visual Basic 6.0和Microsoft .NET可执行文件并不直接链接到KERNEL32,而是链接到了其它的DLL上,但原理是一样的。)
当隐含链接(也称为隐式链接)时,对主要的EXE文件及其依赖的所有DLL的解析过程发生在程序启动时。如果这时出现任何问题(例如它引用的一个DLL找不到),相应的进程就会被终止。
Visual C++ 6.0引入了一个延迟加载(delayload)特性,它是隐含链接与显式链接的混合。当你延迟加载DLL时,链接器生成了一些非常类似于它为正常导入的DLL生成的数据那样的数据,但是操作系统却忽略这些数据。当你的程序在执行过程中首次调用这些延迟加载的函数其中之一时,由链接器为此生成的一部分代码就会执行,由它加载相应的DLL(如果尚未加载),然后调用GetProcAddress来确定要调用的函数的地址。这些额外的工作使得接下来对这个函数的调用就好像这个函数是正常导入的一样。
在PE文件中,对应于每一个导入的DLL有一个相应的结构数组。其中的每个结构都给出了导入的DLL的名称和一个指向函数指针数组的指针。这个函数指针数组被称为导入地址表(Import Address Talbe,IAT)。每个导入的函数都在IAT中有一个对应的位置,Windows加载器就在这个位置上写入这个导入函数的地址。这一点非常重要:一旦一个模块的加载过程结束,那么其IAT中就包含了导入函数的地址。
IAT的美妙之处就在于,在PE文件中,只有一个地方保存了导入函数的地址。无论在你的程序中对某个导入的函数调用多少次,所有调用使用的同样都是IAT中对应于这个函数的指针。
现在让我们来看一下如何调用导入函数。它分为两种情况:高效率方式与低效率方式。按最好的情况(高效率方式)来说,对一个导入函数的调用应该像下面这个样子: CALL DWORD PTR [0x00405030] 如果你不熟悉x86汇编语言,我可以告诉你这条指令表示通过函数指针来调用相应的函数。在地址0x00405030处的一个DWORD类型的值就是CALL指令要将控制权转到的地方。在这个例子中,地址0x00405030在IAT中。
调用导入函数的低效率方式类似下面这个样子:
CALL 0x0040100C ... 0x0040100C: JMP DWORD PTR [0x00405030]
在这种情况下,CALL指令把控制权转到了一个小占位程序(stub)中。这个占位程序只是一条JMP指令,用以跳转到保存在地址0x405030处的地址中。同样,记住0x405030是IAT中的一个元素。一句话,调用API的这种低效率方式使用了5个字节的附加代码(JMP指令是1字节,地址是4个字节),并且由于使用了额外的JMP指令,因此执行时要花费更长的时间。
你可能奇怪既然有高效率的调用方式,为什么还要使用低效率的调用方式呢?理由是很充足的。由于自身的限制,编译器并不能区分调用导入的函数与调用同一模块中的函数之间的区别。因此编译器为函数调用生成的指令是这样的: CALL XXXXXXXX 而XXXXXXXX处是实际代码的地址,这个地址由链接器在后面填充。注意这最后的CALL指令并不是通过函数指针(调用函数的)。相反,它使用的是实际代码的地址。为了保持一致的方式,链接器需要用一个代码块来替换XXXXXXXX。最简单的做法就是调用一个JMP之类的占位程序,就像上面你所看到的那样。
那JMP指令是从哪里来的呢?令人惊讶的是,它来自于相应函数的导入库。如果你仔细查看导入库,并且查看与导入函数名称相连的代码时,就会看到类似上面的JMP指令。这意味着,默认情况下,如果没有任何干涉,调用导入函数使用的总是低效率的调用方式。
按照逻辑推理,下一个要问的问题一定是如何才能使用高效率的调用方式。答案是你必须给编译器一个提示。__declspec(dllimport)这个函数修饰符告诉编译器这个函数在其它的DLL中,编译器应该生成下面这样的指令: CALL DWORD PTR [XXXXXXXX] 而不是下面这样的指令: CALL XXXXXXXX
另外,编译器生成相应的信息来告诉链接器去解析上面那条指令中的函数指针部分时应该去找的符号是__imp_函数名(就是在相应的函数名称前加上__imp_)。例如,如果你调用MyFunction这个函数,那么相应的符号名应为__imp_MyFunction。看一下导入库,除了看到正常的符号名外,你还会看到一个以__imp_为前缀的同样的符号名。这个__imp_类型的符号被直接解析成了IAT项,而不是JMP占位程序。
这对你日常工作有什么影响呢?如果你编写导出函数并且提供了相应的.H文件,记得使用__declspec(dllimport)函数修饰符。例如: __declspec(dllimport) void Foo(void); 如果你看一下Windows系统头文件,你会发现所有的Windows API都使用了__declspec(dllimport)。要想看到它并不容易,但是如果你搜索定义在WINNT.H中,并且用于像WinBase.h之类的头文件中的DECLSPEC_IMPORT宏,你就会发现__declspec(dllimport)是如何用于系统API声明的。
PE文件结构
现在让我们开始挖掘PE文件的实际格式吧。我要从文件的开头处开始,描述存在于所有PE文件中的数据结构。然后我会描述PE文件的节中具体的数据结构(例如导入表与资源)。下面我要讨论的所有数据结构都被定义在WINNT.H文件中,除非我特别声明。
在许多情况下,32位和64位数据结构都是成对出现的——例如IMAGE_NT_HEADERS32和IMAGE_NT_HEADERS64。这些结构几乎总是一样的,除了相应的64位结构中一些域的数据宽度更宽。如果你想编写可移植的代码,在WINNT.H文件中有相应的宏,这些宏可以选择合适的32位或64位结构,并且把它们用一个不能表明大小的别名来代替(在上面的例子中,它就是IMAGE_NT_HEADERS)。结构的选择依赖于你想在何种模式下编译(具体来说就是是否定义了_WIN64)。只有在你所需编译成的PE文件的大小属性与你正在编译的平台的大小属性不同时才需要使用具体的32位或64位结构。
MS-DOS文件头
每一个PE文件都以一个小的MS-DOS®可执行文件开始。早期的Windows需要这个小占位程序,因为那时很多用户还未使用Windows。当可执行文件在没有安装Windows的机器上运行时,这个程序至少可以输出一条消息,用来指明它需要运行在Windows平台上。
PE文件开头是传统的MS-DOS文件头,其中前面的一部分被称为IMAGE_DOS_HEADER。此结构中最重要的两个域是e_magic和e_lfanew。e_lfanew域保存的是真正的PE文件头的偏移。e_magic域需要被设置成0x5A4D。它被定义为IMAGE_DOS_SIGNATURE。如果用ASCII码表示,0x5A4D就是“MZ”,这是Mark Zbikowski的姓名缩写,他是最初的MS-DOS设计者之一。
IMAGE_NT_HEADERS文件头
IMAGE_NT_HEADERS结构是存储PE文件细节的主要位置。它的偏移由文件开头的IMAGE_DOS_HEADER结构的e_lfanew域给出。实际有两种版本的IMAGE_NT_HEADER结构,一种供32位可执行文件使用,另一种供64位使用。它们之间的差别非常小,因此我在讨论中把它们看作相同的结构。区别这两种结构惟一正确的、由Microsoft官方认可的方法是通过IMAGE_OPTION_HEADER结构(很快就会讲到)的Magic域。
IMAGE_NT_HEADER结构由三个域组成: typedef struct _IMAGE_NT_HEADERS { DWORD Signature; IMAGE_FILE_HEADER FileHeader; IMAGE_OPTIONAL_HEADER32 OptionalHeader; } IMAGE_NT_HEADERS32, *PIMAGE_NT_HEADERS32; 在一个合法的PE文件中,Signature域被设置成0x00004550。用ASCII码表示为“PE\0\0”。它被定义为IMAGE_NT_SIGNATURE。第二个域是一个类型为IMAGE_FILE_HEADER的结构,这个结构在PE文件出现之前就已经出现了。它包含了关于文件的一些基本信息。最重要的是,其中有一个域指明了跟在这个结构后面的可选文件头的大小。在PE文件中,这个可选文件头是必须的,但它仍然被称为IMAGE_OPTIONAL_HEADER。
下表列出了IMAGE_FILE_HEADER结构的各个域及相应的描述。这个结构也可以在COFF格式的OBJ文件开头找到。
大小
域
描述
WORD
Machine
目标平台CPU的类型。常用的值有:
IMAGE_FILE_MACHINE_I386 0x014c // Intel 386
IMAGE_FILE_MACHINE_IA64 0x0200 // Intel 64
WORD
NumberOfSections
指示节表中节的数目。节表紧跟着IMAGE_NT_HEADERS结构。
DWORD
TimeDateStamp
指示文件创建时间。这个值是从格林尼治时间(GMT)1970年1月1日00:00以来的总秒数。它比文件系统所指明的日期/时间更精确。使用_ctime函数可以很容易地把这个值转换成可读性比较好的字符串(这个函数与时区相关)。另一个可用于这个值的函数gmtime也比较有用。
DWORD
PointerToSymbolTable
COFF符号表的文件偏移。Microsoft的PECOFF规范5.4节描述了COFF符号表。COFF符号表在PE文件中非常少见,因为新的调试符号格式已经取代了它。在Visual Studio .NET之前,可以使用/DEBUGTYPE:COFF这个链接器选项来指定创建COFF符号表。它总是存在于OBJ文件中。如果不存在符号表的话,将它设置为0。
DWORD
NumberOfSymbols
符号表中的符号数(如果存在的话)。COFF符号是一个大小固定的结构,这个域用来定位COFF符号表的结尾。紧跟着COFF符号表的是一个字符串表,它用来保存长符号名。
WORD
SizeOfOptionalHeader
IMAGE_FILE_HEADER结构后面的可选数据的大小。在PE文件中,这个可选数据就是IMAGE_OPTIONAL_HEADER。这个大小在32位和64位文件中是不同的。对于32位PE文件来说,它通常是224;对于64位PE32+文件来说,它通常是240。但是,它们只是最小值,可能有更大的值。
WORD
Characteristics
指示文件属性的一组位标志。这些标志的合法值就是WINNT.H文件中定义的IMAGE_FILE_xxx值。一些常见的值列于下表。
下表列出了常用的IMAGE_FILE_xxx值:
标志
描述
IMAGE_FILE_RELOCS_STRIPPED
重定位信息已经从文件中移除。
IMAGE_FILE_EXECUTABLE_IMAGE
文件是可执行映像。
IMAGE_FILE_AGGRESSIVE_WS_TRIM
让操作系统尽量减小工作集(working set)。
IMAGE_FILE_LARGE_ADDRESS_AWARE
此应用程序可以处理大于2GB的地址。
IMAGE_FILE_32BIT_MACHINE
需要字长为32位的机器。
IMAGE_FILE_DEBUG_STRIPPED
调试信息已经被移到.DBG文件中。
IMAGE_FILE_REMOVABLE_RUN_FROM_SWAP
如果可执行映像在可移动媒体上,把它复制到交换文件中并从交换文件中运行。
IMAGE_FILE_NET_RUN_FROM_SWAP
如果可执行映像在网络上,把它复制到交换文件中并从交换文件中运行。
IMAGE_FILE_DLL
文件是DLL。
IMAGE_FILE_UP_SYSTEM_ONLY
只能运行于单处理器机器上。
下表列出了IMAGE_OPTIONAL_HEADER结构的成员:
大小
域
描述
WORD
Magic
一个特征字,用于表明文件头的类型。两个常用的值为:IMAGE_NT_OPTIONAL_HDR32_MAGIC 0x10b IMAGE_NT_OPTIONAL_HDR64_MAGIC 0x20b
BYTE
MajorLinkerVersion
用于创建这个可执行文件的链接器的主版本号。对于由Microsoft链接器生成的可执行文件来说,这个版本号对应于Visual Studio的版本号(例如Visual Studio 6.0就是版本6)。
BYTE
MinorLinkerVersion
用于创建这个可执行文件的链接器的次版本号
DWORD
SizeOfCode
带有IMAGE_SCN_CNT_CODE 属性的所有节的总大小。
DWORD
SizeOfInitializedData
所有由已初始化的数据组成的节的总大小。
DWORD
SizeOfUninitializedData
所有由未初始化的数据组成的节的总大小。它通常是0,因为链接器经常把未初始化的数据添加到正常的数据节的末尾。
DWORD
AddressOfEntryPoint
文件中首先被执行的代码的第一个字节的RVA。对于DLL来说,入口点在进程初始化和退出期间,以及线程创建和退出期间都会被调用。在大多数可执行文件中,这个地址并不是直接指向main、WinMain或者DllMain,而是指向调用上述函数的运行时库代码。对于DLL来说,这个域可以设为0,这样它就接收不到前面说的四个通知。/NOENTRY链接器选项可以将这个域设置为0
DWORD
BaseOfCode
加载进内存之后代码的第一个字节的RVA。
DWORD
BaseOfData
理论上这是加载进内存之后数据的第一个字节的RVA。但是这个域的值在不同版本的Microsoft链接器间是不一致的。64位可执行文件中并不存在这个域。
DWORD
ImageBase
这个文件在内存中的首选加载地址。如果有可能的话(也就是说这个内存当前并未被占用,并且它是对齐的,同时是一个合法的地址等等),加载器尽量把PE文件加载到这个地址。如果可执行文件被加载到这个地址,加载器就可以跳过基址重定位(将在本文的第二部分中描述)。对于EXE来说,默认的ImageBase为0x400000;对于DLL来说,它是0x10000000。可以在链接时使用/BASE选项或者以后使用REBASE工具来设定此值。
DWORD
SectionAlignment
加载进内存之后节的对齐值。这个对齐值必须大于或等于文件对齐值(下面将要讲到)。默认的对齐值是目标平台的页面大小。对于运行于Windows 9x或Windows Me上的用户模式的可执行文件来说,最小的对齐值是一个页面(4KB)。这个域的值可以使用/ALIGN链接器选项来设定。
大小
域
描述
DWORD
FileAlignment
节在PE文件中的对齐值。对于x86可执行文件来说,它或者是0x200,或者是0x1000。不同版本的Microsoft链接器的默认值不一样。这个值必须是2的幂,并且如果SectionAlignment域的值小于CPU的页面大小,这个值必须与SectionAlignment域的值匹配。链接器选项/OPT:WIN98将x86平台上的可执行文件的对齐值设为0x1000,而/OPT:NOWIN98选项将它设为0x200。
WORD
MajorOperatingSystemVersion
所需的操作系统的主版本号。随着众多版本Windows的到来,这个域已失去了它最初的意义。
WORD
MinorOperatingSystemVersion
所需的操作系统的次版本号。
WORD
MajorImageVersion
此文件的主版本号。系统并未使用这个域,可以设置为0。使用/VERSION链接器选项可以设定这个域的值。
WORD
MinorImageVersion
此文件的次版本号。
WORD
MajorSubsystemVersion
可执行文件所需的子系统的主版本号。以前相对于旧版本的Windows NT界面来说,用它来指明需要新的Windows 95或Windows NT 4.0用户界面。现在由于Windows版本繁多,这个域已经不使用了,通常被设为4。使用链接器选项/SUBSYSTEM可以设置这个域的值。
WORD
MinorSubsystemVersion
可执行文件所需的子系统的次版本号。
DWORD
Win32VersionValue
一个从来不用的域,通常设为0。
DWORD
SizeOfImage
SizeOfImage包含了假设存在于最后一个节之后的那个节的RVA。这等效于把此文件加载进内存时系统需要保留的内存数量。这个域的值必须是节的对齐值的倍数。
DWORD
SizeOfHeaders
MS-DOS文件头、PE文件头和节表的总大小。在PE文件中,这些内容出现于任何代码或数据节之前。这个域的值被向上舍入到文件对齐值的倍数。
DWORD
CheckSum
映像的校验和。IMAGEHLP.DLL中的CheckSumMappedFile API可以计算这个值。对于内核模式的驱动程序和一些系统DLL来说,校验和是必须的。否则这个域被设置为0。当使用/RELEASE链接器选项时,校验和会被放在文件中。
WORD
Subsystem
指示可执行文件所需子系统(用户界面类型)的一个枚举值。在EXE文件中这个值比较重要。一些重要的值如下:
IMAGE_SUBSYSTEM_NATIVE
// 不需要子系统
IMAGE_SUBSYSTEM_WINDOWS_GUI
// 使用Windows GUI
IMAGE_SUBSYSTEM_WINDOWS_CUI
// 控制台应用程序。当它运行时,操作系统为其创一
// 个控制台并提供stdin、stdout和stderr文件句柄。
大小
域
描述
WORD
DllCharacteristics
指示DLL特征的标志。这些值对应于WINNT.H文件中的IMAGE_DLLCHARACTERISTICS_xxx定义。当前值如下:
IMAGE_DLLCHARACTERISTICS_NO_BIND // 不绑定映像
IMAGE_DLLCHARACTERISTICS_WDM_DRIVER // 使用WDM模型的驱动程序
IMAGE_DLLCHARACTERISTICS_TERMINAL_SERVER_AWARE // 当终端服务器加载一个并没有准备运行于终端服务 // 器上的应用程序时,它同时加载包含兼容代码的DLL
DWORD
SizeOfStackReserve
在EXE文件中,它表示进程中的线程堆栈最初可以增长到的最大值。默认是1MB。并不是初始化时就提交这里指定的所有内存。
DWORD
SizeOfStackCommit
在EXE文件中,它表示初始化时提交的堆栈的大小。默认是4KB。
DWORD
SizeOfHeapReserve
在EXE文件中,它表示最初为默认进程堆保留的内存数量。默认是1MB。然而对于当前版本的Windows,在没有用户干预的情况下,堆可以超过这个值。
DWORD
SizeOfHeapCommit
在EXE文件中,它表示提交的堆的大小。默认是4KB。
DWORD
LoaderFlags
此域已经废弃不用。
DWORD
NumberOfRvaAndSizes
在IMAGE_NT_HEADERS结构末尾处是一个IMAGE_DATA_DIRECTORY结构数组。这个域包含了这个数组的元素数目。由于以前发行的Windows NT的原因,它被设置为16。
IMAGE_ DATA_ DIRECTORY
DataDirectory[16]
IMAGE_DATA_DIRECTORY结构数组。每个结构包含可执行文件中一些重要部分(例如导入表、导出表以及资源等)的RVA和大小。
IMAGE_OPTIONAL_HEADER结构末尾的DataDirectory数组就像是可执行文件中重要位置的地址簿。DataDirectory的每个元素结构如下:
typedef struct _IMAGE_DATA_DIRECTORY {
DWORD VirtualAddress; // 数据的RVA
DWORD Size; // 数据的大小
} IMAGE_DATA_DIRECTORY, *PIMAGE_DATA_DIRECTORY;
节表
紧跟着IMAGE_NT_HEADERS结构的是节表(section table)。节表是一个IMAGE_SECTION_HEADER结构数组。此结构提供了与它相关的节的信息,其中包括位置、长度和属性。下表给出了对此结构的描述。节表中此结构的数目由IMAGE_NT_HEADERS.FileHeader.NumberOfSections给出。
大小
域
描述
BYTE
Name[8]
节的名称(ASCII码)。节名并不保证以NULL结尾。如果你指定的节名大于8个字节,链接器在生成可执行文件时将其截断为8个字符。在OBJ文件中存在一种机制可以让节名更长。节名通常以圆点开始,但这并不是必需的。对于带有$字符的节名链接器会特殊对待。如果几个节名中$字符以前的部分相同,那么这些节会被合并。它们按$字符后面的部分在字母表中的顺序出现于最终的节中。关于节名中带有$字符的节和它们如何被合并方面还有很多内容,但对它的详细讨论已经超出了本文的范围。
DWORD
VirtualSize
指示节实际占用的内存大小。这个域的值可能比SizeOfRawData域的值大或小。如果大,SizeOfRawData域表示可执行文件中已初始化的数据的大小,VirtualSize域比它大的部分用0填充。在OBJ文件中,此域的值为0。
DWORD
VirtualAddress
在可执行文件中,它表示在内存中节的起始RVA。在OBJ文件中它被设置为0。
DWORD
SizeOfRawData
可执行文件或OBJ文件中的节中存储的数据的大小(以字节计)。对于可执行文件来说,它必须是PE文件头中给出的文件对齐值的倍数。如果它被设置为0,表示这个节中是未初始化的数据。
DWORD
PointerToRawData
节中数据起始的文件偏移。对于可执行文件来说,它必须是PE文件头中给出的文件对齐值的倍数。
DWORD
PointerToRelocations
节的重定位信息的文件偏移。它只用于OBJ文件,在可执行文件中它被设置为0。在OBJ文件中,如果它不为0,那么它指向一个IMAGE_RELOCATION结构。
DWORD
PointerToLinenumbers
节中COFF行号信息的文件偏移。如果它不为0,那么它指向一个IMAGE_LINENUMBER结构。
WORD
NumberOfRelocations
PointerToRelocations域指向的重定位信息的数目。在可执行文件中应该为0。
WORD
NumberOfLinenumbers
PointerToLinenumbers域指向的行号信息的数目。只有当生成COFF行号信息时才使用。
DWORD
Characteristics
指示节属性的标志(可以用“或”连接)。这些标志中的大部分可以使用链接器的/SECTION选项来设置。常用的值列于下表。
下表列出了常用的节属性标志:
标志
描述
IMAGE_SCN_CNT_CODE
节中包含代码。
IMAGE_SCN_MEM_EXECUTE
节是可执行的。
IMAGE_SCN_CNT_INITIALIZED_DATA
节中包含已初始化的数据。
IMAGE_SCN_CNT_UNINITIALIZED_DATA
节中包含未初始化的数据。
IMAGE_SCN_MEM_DISCARDABLE
这个节在最终的可执行文件中可以被丢弃。用于保存链接器使用的信息,包括.debug$节。
标志
描述
IMAGE_SCN_MEM_NOT_PAGED
这个节不能被交换到页面文件中,因此它应该总是存在于物理内存中。经常用于内核模式驱动程序。
IMAGE_SCN_MEM_SHARED
包含这个节的物理页面将在加载这个可执行文件的所有进程之间共享。因此每个进程看到的这个节中的数据的值完全一样。对于在进程的所有实例之间共享全局变量比较有用。要共享某个节,使用/SECTION:节名,S链接器选项。
IMAGE_SCN_MEM_READ
节是可读的。几乎总是设置这个值。
IMAGE_SCN_MEM_WRITE
节是可写的。
IMAGE_SCN_LNK_INFO
节中包含链接器使用的信息。仅存在于OBJ文件中。
IMAGE_SCN_LNK_REMOVE
这个节中的内容将不成为最终的映像的一部分。仅用于OBJ文件。
IMAGE_SCN_LNK_COMDAT
节中的内容是公共数据(comdat)。公共数据(Communal data)是可以被定义在多个OBJ文件中的数据(或代码)。链接器只将其中的一份副本包含进最终的可执行文件中。Comdats对于支持C++的模板函数和函数级的链接至关重要。它仅存在于OBJ文件中。
IMAGE_SCN_ALIGN_xBYTES
这个节中的数据在最终的可执行文件中的对齐值。有各种各样的值(_4BYTES,_8BYTES,_16BYTES等)。如果不指定,默认为16字节。仅在OBJ文件中才设置这些标志。
可执行文件中的节在文件中的对齐值对文件的大小有重要影响。在Visual Studio 6.0中,链接器默认的节对齐值是4KB,除非使用/OPT:NOWIN98选项或/ALIGN选项。对于Visual Studio .NET链接器,虽然仍是默认使用/OPT:NOWIN98选项,但它要确定可执行文件是否小于某一固定值,如果小于的话,它将使用0x200字节的对齐值。
另一个比较有用的对齐值来自.NET文件规范。这个规范说.NET可执行文件在内存中的对齐值应该为8KB,而不是x86上的4KB。这是为了确保用x86入口点代码创建的.NET可执行文件可以运行在IA-64中。如果在内存中节的对齐值为4KB,那么IA-64将不能加载这个文件,因为在64位Windows上页面是按8KB对齐的。
总结
这一部分主要讲了PE文件头。在本文的第二部分中,我会继续带领读者游历可执行文件中常见的节。然后讲一下这些节中主要的数据结构,其中包括导入表、导出表以及资源。最后我会讲一下最新的经过彻底改进的PEDUMP工具的源代码。
深入剖析Win32可移植可执行文件格式
第二部分
作者:Matt Pietrek
上个月在本文的第一部分中,我首先对可移植可执行文件进行了全面的介绍。我讲了PE文件的历史和组成PE文件头的数据结构,还讲了节表。PE文件头和节表告诉你在可执行文件中都包含什么类型的代码和数据,以及在哪里能找到它们。
本月我要讲一下常见的节。最后讲一下我的最新的经过彻底改进的PEDUMP程序,它可以在2002年2月的专栏中下载。如果你不熟悉PE文件的基本概念,应该首先读一下本文的第一部分。
上个月我讲了节是怎样的一个逻辑上属于一起的代码或数据块。例如可执行文件的所有导入信息都在一个节中。现在让我们来看一下在可执行文件和OBJ文件中经常遇到的一些节。除非特别说明,否则下表中的节名都来自Microsoft的工具。
名称
描述
.text
默认的代码节。
.data
默认的可读/可写数据节。全局变量通常在这个节中。
.rdata
默认的只读数据节。字符串常量和C++/COM虚表就放在这个节中。
.idata
导入表。实际上,链接器经常把.idata节合并到其它节中(或者是明确指定的,或者是通过链接器的默认行为)。默认情况下,链接器仅在创建发行版的程序时才把.idata节合并到其它节中。
.edata
导出表。当创建要导出函数或数据的可执行文件时,链接器会创建一个.EXP文件。这个.EXP文件包含一个.edata节,这个节被添加到最后的可执行文件中。与.idata节一样,.edata节也经常被合并到.text节或.rdata节中。
.rsrc
资源节。这个节是只读的。它不应该被命名为其它名称,也不应该被合并到其它节中。
.bss
未初始化的数据节。在最新的链接器创建的可执行文件中很少见到。链接器扩展可执行文件的.data节的VirtualSize域以便容纳未初始化的数据。
.crt
添加到可执行文件中的数据,用来支持C++运行时库(CRT)。一个比较好的例子就是用于调用静态C++对象的构造函数和析构函数的指针。要获取更详细的信息,可以参考2001年1月的Under The Hood专栏。
.tls
这个节中的数据用来支持使用__declspec(thread)语法创建的线程局部存储变量。它包括数据的初始值,以及运行时需要的附加变量。
.reloc
可执行文件中的基址重定位节。通常DLL需要基址重定位信息而EXE并不需要。在创建发行版的程序时,链接器并不为EXE文件生成基址重定位信息。可以使用/FIXED链接器选项移除基址重定位信息。
.sdata
通过全局指针(Global Pointer)相对寻址的“短(Short)”可读/可写数据。用于IA-64和其它使用全局指针寄存器的平台上。IA-64平台上正常大小的全局变量在这个节中。
.srdata
通过全局指针相对寻址的“短(Short)”只读数据。用于IA-64和其它使用全局指针寄存器的平台上。
.pdata
异常表。它包含一个IMAGE_RUNTIME_FUNCTION_ENTRY结构数组,这个结构与平台体系结构相关。数据目录中索引为IMAGE_DIRECTORY_ENTRY_EXCEPTION的项指向它。用于使用基于表的异常处理的平台,例如IA-64。惟一不使用基于表的异常处理的平台是x86(它使用的是
名称 描述
基于堆栈的异常处理)。
.debug$S
OBJ文件中的Codeview格式的调试符号(Symbol)信息。这是一列可变长度的CodeView格式的调试符号记录。
.debug$T
OBJ文件中的Codeview格式的调试类型(Type)记录。这是一列可变长度的CodeView格式的调试类型记录。
.debug$P
可以在使用预编译头(Precompiled Headers)生成的OBJ文件中找到这个节。
.drectve
这个节包含链接器指令,并且只存在于OBJ文件中。这些指令是传递到链接器命令行的ASCII码字符串,例如:-defaultlib:LIBC。指令之间用空格分开。
.didat
延迟加载导入数据。可以在非发行版本的可执行文件中找到。在发行版本中,延迟加载数据被合并到其它节中。
导出表
当一个EXE或DLL导出函数或变量时,其它EXE或DLL就可以使用这些导出的函数或变量。为了简单起见,我把导出的函数和导出的变量统称为“符号”。当导出一些符号时,最起码导出符号的地址需要能够以一种已定义好的方式被获取。每个导出的符号都有一个与之关联的序数,它可以用来查找这个符号。同时,几乎总有一个ASCII码格式的字符串名称与这个导出的符号关联。一般来说,导出的符号名与源文件中的符号名是一样的,尽管它们可以被修改的不一样。
通常,当可执行文件导入符号时,它使用的是符号的名称而不是它的序号。但是当通过名称导入时,系统仅使用这个名称去查找所需符号对应的导出序数,然后根据这个序数值去获取相应的地址。如果先使用的是序数值的话查找过程会快一点。通过名称导出和导入只是为了让程序员使用方便罢了。
在.DEF文件中的Exports节中使用ORDINAL关键字可以告诉链接器创建一个导入库,这个导入库强制函数只能通过序数导入而不能通过名称导入。
我首先介绍IMAGE_EXPORT_DIRECTORY结构,如下表所示:
大小
域
描述
DWORD
Characteristics
导出标志。当前未定义任何值。
DWORD
TimeDateStamp
导出数据的创建时间。这个域的定义与IMAGE_NT_HEADERS.FileHeader.TimeDateStamp相同(从GMT时间1970年1月1日00:00以来的总秒数)。
WORD
MajorVersion
导出数据的主版本号。未用,设置为0。
WORD
MinorVersion
导出数据的次版本号。未用,设置为0。
DWORD
Name
与导出符号相关的DLL的名称ASCII字符串的RVA(例如KERNEL32.DLL)。
DWORD
Base
这个域包含了这个可执行文件的导出符号所使用的序数值的起始值。通常情况下这个值为1,但并不总是这样。当通过序数查找导出符号时,将序数值减去这个域的值就得到了这个导出符号在导出地址表(Export Address Table ,EAT)中的索引。
大小 域 描述
DWORD
NumberOfFunctions
EAT中的元素数。注意EAT中的某些元素可能为0,这表明没有 代码/数据使用那个序数值导出。
DWORD
NumberOfNames
导出名称表(Export Names Table,ENT)中的元素数。这个域的值总是小于或等于NumberOfFunctions域的值。当某些符号仅使用序数导出时,它就小于那个域的值。如果导出序数之间有间隔,它同样也小于那个域的值。这个域的值也是导出序数表的大小(见下文)。
DWORD
AddressOfFunctions
EAT的RVA。EAT中的每个元素都是一个RVA。其中每个非0的RVA都对应一个导出符号。
DWORD
AddressOfNames
ENT的RVA。ENT中的每个元素都是一个ASCII码字符串的RVA。其中的每个ASCII码字符串都对应一个由名称导出的符号。这些字符串是按一定顺序排列的。这就使得加载器在查找导出符号时可以进行二进制搜索。名称字符串的排序是按二进制(与C++运行时库函数strcmp类似),而不是与位置相关的字母表顺序。
DWORD
AddressOfNameOrdinals
导出序号表的RVA。这个表是一个WORD类型的数组。它将ENT中的索引映射到导出地址表中相应的元素上。
导出目录(Export Directory)指向三个数组和一个ASCII码字符串表。其中只有导出地址表是必需的,它是一个由指向导出函数的指针组成的数组。导出序数是这个数组的索引(见下图)。
让我们通过例子来看一下导出表的工作原理。下图显示了KERNEL32.DLL导出表的部分内容:
exports table:
Name: KERNEL32.dll
Characteristics: 00000000
TimeDateStamp: 3B7DDFD8 -> Fri Aug 17 23:24:08 2001
Version: 0.00
Ordinal base: 00000001
# of functions: 000003A0
# of Names: 000003A0
Entry Pt Ordn Name
00012ADA 1 ActivateActCtx
000082C2 2 AddAtomA
•••remainder of exports omitted
假设你调用GetProcAddress来获取KERNEL32中的AddAtomA这个API的地址。这时系统开始查找KERNEL32的IMAGE_EXPORT_DIRECTORY结构。它从那里获取了导出名称表的起始地址,知道了在这个数组中有0x3A0个元素,它通过二进制搜索来查找字符串“AddAtomA”。
假设加载器发现AddAtomA是这个数组中的第二个元素。然后它从导出序数表(Export Ordinal Table)中读取相应的第二个值。这个值就是AddAtomA的导出序数。将这个导出序数作为EAT的索引(加上Base域的值),它最终获取AddAtomA的相对虚拟地址(RVA)是0x82C2。将此值与KERNEL32的加载地址相加就得到了AddAtomA的实际地址。
导出转发
导出表一个特别聪明的地方是它能将一个导出函数转发(Forwarding)到其它DLL。例如在Windows NT®、Windows® 2000和Windows XP中,KERNEL32中的HeapAlloc函数被转发到了NTDLL导出的RtlAllocHeap函数上。转发是在链接时通过.DEF文件中的EXPORTS节中的一种特殊语法形式来实现的。对于HeapAlloc这个例子,KERNEL32的.DEF文件一定包含下面的内容: EXPORTS ••• HeapAlloc = NTDLL.RtlAllocHeap
怎样才能区别转发的函数与正常导出的函数呢?这需要一些技巧。通常EAT中包含的是导出符号的RVA。但是如果这个RVA位于导出表中(通过相应的DataDirectory中的VirtualAddress域和Size域进行判断),那么它就是转发的。
当转发一个符号时,它的RVA很明显不能是当前模块中的代码或数据的地址。实际上,它的RVA指向一个由DLL和转发到的符号名称组成的字符串。在前面的例子中,这个字符串就是NTDLL.RtlAllocHeap。
导入表
与导出函数或变量相反的就是导入它们。为了与前面保持一致,我仍然使用“符号”这个术语来指代导入的函数和变量。
导入数据被保存在IMAGE_IMPORT_DESCRIPTOR结构中。对应着导入表的数据目录项就指向由这个结构组成的数组。每个IMAGE_IMPORT_DESCRIPTOR结构都与一个导入的可执行文件对应。这个数组的最后一个元素的所有域都被设置为0。下表是这个结构的内容:
大小
域
描述
DWORD
OriginalFirstThunk
这个域的命名太不恰当。它包含导入名称表的RVA。导入名称表是一个IMAGE_THUNK_DATA结构数组。这个域被设置为0表示IMAGE_IMPORT_DESCRIPTOR结构数组的结尾。
大小 域 描述
DWORD
TimeDateStamp
如果可执行文件并未绑定导入的DLL,这个域的值为0。当使用老的绑定类型进行绑定(参考“绑定”一节)时,这个域包含日期/时间戳。当使用新的绑定类型进行绑定时,这个域的值为-1。
DWORD
ForwarderChain
这是首个转发的函数的索引。如果没有转发的函数,这个域被设置为-1。它仅用于老的绑定类型,因为那种绑定类型不能很有效地处理转发的函数。
DWORD
Name
导入的DLL名称字符串(ASCII码格式)的RVA。
DWORD
FirstThunk
导入地址表的RVA。IAT是一个IMAGE_THUNK_DATA结构数组。
每个IMAGE_IMPORT_DESCRIPTOR结构指向两个数组,这两个数组实际上是一样的。它们有好几种叫法,但最常用的名称是导入地址表(Import Address Table,IAT)和导入名称表(Import Name Talbe,INT)。下图显示的是可执行文件从USER32.DLL中导入一些API时的情况。
这两个数组的元素均为IMAGE_THUNK_DATA类型的结构,这个结构是一个与指针大小相同的共用体(或者称为联合)。每个IMAGE_THUNK_DATA结构对应着从可执行文件中导入的一个函数。这两个数组最后都以一个值为0的IMAGE_THUNK_DATA结构作为结尾。这个共用体(实际是一个DWORD值)可以有如下几种含义:
DWORD ForwarderString;// 转发函数字符串的RVA(见上文)
DWORD Function; // 导入函数的内存地址
DWORD Ordinal; // 导入函数的序数
DWORD AddressOfData; // IMAGE_IMPORT_BY_NAME和导入函数名称的RVA(见下文)
IAT中的IMAGE_THUNK_DATA结构的用途可以分为两种。在可执行文件中,它们或者是导入函数的序数,或者是一个IMAGE_IMPORT_BY_NAME结构的RVA。IMAGE_IMPORT_BY_NAME结构只是一个WORD类型的值,它后面跟着导入函数的名称字符串。这个WORD类型的值是一个“提示(hint)”,它提示加载器导入函数的序号可能是什么。当加载器加载可执行文件时,它用导入函数的实际地址来覆盖IAT中的每个元素。这一点是理解下文的关键。我强烈建议你读一读本期杂志中Russell Osterlund的文章——揭开Windows加载器的神秘面纱,这篇文章详细讲述了Windows加载器的行为。
在可执行文件被加载之前,是否存在一种方法能够区分IMAGE_THUNK_DATA结构中到底包含的是导入函数的序数呢,还是IMAGE_IMPORT_BY_NAME结构的RVA呢?答案在IMAGE_THUNK_DATA
结构的最高位。如果它为1,那么低31位(在64位可执行文件中是低63位)中是导入函数的序数。如果最高位为0,那么IMAGE_THUNK_DATA结构的值就是IMAGE_IMPORT_BY_NAME结构的RVA。
另一个数组INT,本质上与IAT是一样的。它也是一个IMAGE_THUNK_DATA结构数组。关键的区别在于当加载器将可执行文件加载进内存时,它并不覆盖INT。为什么对于从DLL中导入的每组API都需要有两个并列的数组呢?答案在于一个称为绑定(binding)的概念。当在绑定过程(后面我会讲到)中覆盖可执行文件的IAT时,需要以某种方式保存原来的信息。而作为这个信息的副本的INT,正是这个用途。
INT对于可执行文件的加载并不是必需的。但是如果它不存在的话,那么这个可执行文件就不能被绑定。Microsoft链接器总是生成INT,但是长期以来,Borland链接器(TLINK)都不生成它。这样,由Borland链接器生成的可执行文件就不能被绑定。
在早期的Microsoft链接器中,导入节并不是专门针对于链接器的。组成可执行文件导入节的所有数据都来自导入库。你可以对一个导入库文件运行DUMPBIN或PEDUMP来看一下。你会发现一些节名类似于.idata$3和.idata$4的节。链接器只是简单地遵守它的规则来组合节,所有的结构和数组就神奇般地各就其位了。几年前Microsoft引进了一种新的导入库格式,这种导入库特别小,以便让链接器能在创建导入数据时更具主动性。
绑定
当可执行文件被绑定时(例如通过Bind程序),其IAT中的IMAGE_THUNK_DATA结构中是导入函数的实际地址。也就是说,磁盘上的可执行文件的IAT中存储的就是其导入的DLL中的函数在内存中的实际地址。当加载一个被绑定的可执行文件时,Windows加载器可以跳过查找每个导入函数并覆盖IAT这一步。因为IAT中已经是正确的地址了。但是这只有正确对齐时才行。我在2000年5月的Under the Hood专栏中讲了一些测试标准,你可以通过它们来确定绑定可执行文件能够对加载性能有多大提高。
你也许会怀疑将可执行文件绑定是否保险。你可能会想,如果绑定了可执行文件,但它导入的DLL发生了变化,这时怎么办呢?当这种情况发生时,IAT中的地址已经失效了。加载器会检查这种情况并随机应变。如果IAT中的地址已经失效,加载器会根据INT中的信息重新解析导入函数的地址。
在安装程序时对其进行绑定应该是最可能发生的情况了。Windows Installer中的BindImage这个动作可以替你做这件事。同样,IMAGEHLP.DLL中也提供了BindImageEx这个API。不管用哪一种方法,绑定都是个比较好的做法。如果加载器确定绑定信息是有效的,那么可执行文件就会被加载的更快。如果绑定信息失效,它也并不会比不绑定效果差。
对加载器来说,使绑定生效的一个关键步骤是确定IAT中的绑定信息是否有效。当可执行文件被绑定时,有关它导入的DLL的信息也被放在可执行文件中。加载器检查这个信息以快速确定绑定的有效性。在绑定的最初实现中并未添加这个信息,因此可执行文件可能按老的绑定方式进行绑定,或者按新的绑定方式进行绑定。我在这里讲的是新的绑定方式。
确定绑定信息有效性的一个关键数据结构是IMAGE_BOUND_IMPORT_DESCRIPTOR。被绑定的可执行文件中有一个此结构的列表。每个IMAGE_BOUND_IMPORT_DESCRIPTOR结构表示一个绑定到的
DLL的日期/时间戳。这个列表的RVA由数据目录中索引为IMAGE_DIRECTORY_ENTRY_BOUND_IMPORT的元素给出。IMAGE_BOUND_IMPORT_DESCRIPTOR结构中的成员如下:
• TimeDateStamp,这是包含导入的DLL的日期/时间戳的一个DWORD类型的值。
• OffsetModuleName,这是包含导入的DLL的名称字符串偏移地址的一个WORD类型 的值。这个域是相对于首个IMAGE_BOUND_IMPORT_DESCRIPTOR结构的偏移(而不是RVA)。
• NumberOfModuleForwarderRefs,这是一个WORD类型的值,它包含紧跟在这个结构后面的IMAGE_BOUND_FORWARDER_REF结构的数目。除了最后一个WORD类型的成员(NumberOfModuleForwarderRefs)是保留的外,IMAGE_BOUND_FORWARDER_REF结构与IMAGE_BOUND_IMPORT_DESCRIPTOR结构一样。
一般情况下,每个导入的DLL对应的IMAGE_BOUND_IMPORT_DESCRIPTOR结构简单地组成一个数组。但是当绑定的API转发到了另一个DLL上时,这个转发到的DLL的有效性也需要检查。在这种情况下,IMAGE_BOUND_FORWARDER_REF结构就与IMAGE_BOUND_IMPORT_DESCRIPTOR结构交叉在了一起。下面举一个例子来说明。
假设你链接到了KERNEL32.DLL中的HeapAlloc这个API上,而它实际上被转发到了NTDLL中的RtlAllocateHeap上,然后你绑定这个可执行文件。那么在这个可执行文件中,对应于KERNEL32.DLL这个导入的DLL就有一个相应的IMAGE_BOUND_IMPORT_DESCRIPTOR结构,同时它后面是一个对应于NTDLL.DLL的IMAGE_BOUND_FORWARDER_REF结构。紧跟在它们后面的可能是与你导入并绑定到的其它DLL对应的IMAGE_BOUND_IMPORT_DESCRIPTOR结构。
延迟加载数据
前面我已经讲过延迟加载(Delayload)一个DLL就是隐含导入与通过LoadLibrary和GetProcAddress显式导入这两种方式的混合。现在让我们来看一下延迟加载所需的数据结构以及它的工作原理。
一定要记住延迟加载并不是操作系统的功能。它完全是由链接器和运行时库添加的附加代码和数据来实现的。正因为如此,WINNT.H中并没有几个地方涉及到延迟加载。但是你会发现延迟加载数据和正常导入数据二者的定义是平行的。
DataDirectory中的IMAGE_DIRECTORY_ENTRY_DELAY_IMPORT元素指向延迟加载数据。这个元素中实际是一个ImgDelayDescr结构数组的RVA,这个结构被定义在Visual C++的DelayImp.H文件中。下表是这个结构的内容。对应于每一个导入的DLL都有一个相应的ImgDelayDescr结构。
大小
域
描述
DWORD
grAttrs
此结构的属性。当前惟一定义的标志是dlattrRva(值为1)。这个标志表明此结构中的地址域是RVA,而不是虚拟地址。设置这个标志表明延迟加载描述符是VC7.0或其后续版本。
RVA
rvaDLLName
导入的DLL的名称字符串的RVA。这个字符串被传递给LoadLibrary函数。
大小 域 描述
RVA
rvaHmod
一块HMODULE大小的内存的RVA。当延迟加载的DLL被加载进内存时,它的HMODULE被存储在这个位置。
RVA
rvaIAT
此DLL的导入地址表的RVA。它的格式与正常的IAT相同。
RVA
rvaINT
此DLL的导入名称表的RVA。它的格式与正常的INT相同。
RVA
rvaBoundIAT
可选的绑定IAT的RVA。它是此DLL的导入地址表的一个绑定副本的RVA。它的格式与正常的IAT相同。当前这个IAT副本并未绑定,但这个功能可能被添加到将来的BIND程序中。
RVA
rvaUnloadIAT
原始的IAT的可选副本的RVA。它是此DLL的导入地址表的一个未绑定的副本的RVA。它的格式与正常的IAT相同。当前总是设置为0。
DWORD
dwTimeStamp
延迟加载导入的DLL的日期/时间戳。通常设置为0。
我们从ImgDelayDescr结构中可以获取的主要内容就是它包含了DLL的IAT和INT的地址。这些表与正常情况下的表是一样的,只不过它们是由运行时库代码进行读写而不是由操作系统。当你调用延迟加载的DLL中的函数时,运行时库代码就调用LoadLibrary加载相应的DLL(如果需要的话),然后调用GetProcAddress来获取函数地址,最后将获取的地址存储在延迟加载IAT中,以便将来可以直接调用这个函数。
延迟加载所使用的数据结构在设计时有一个失误的地方需要解释一下。在Visual C++ 6.0中——这是它最初的形式,ImgDelayDescr结构中的所有包含地址的域使用的都是虚拟地址,而不是RVA。也就是说,它们包含了延迟加载数据所在位置的实际地址。这些域都是DWORD类型的,也就是x86上一个指针的大小。
现在要全面支持IA-64了。突然,4字节已经不够保存一个完整的地址了。哎呀!在这个时候,Microsoft做了一件正确的事,把包含地址的域都改为包含RVA了。如前面所示,我使用的是已经修订过的结构定义和名称。
还有一个问题就是确定ImgDelayDescr使用的是RVA还是虚拟地址。这个结构中有一个域包含了相关的标志。当grAttrs域为1时,这个结构中的成员中包含的是RVA。从Visual Studio® .NET和64位编译器开始,这是惟一选项。如果grAttrs不是1,ImgDelayDescr结构中的域包含的都是虚拟地址。
资源节
在PE文件的所有节中,在资源节中定位数据是最复杂的。在这里我只讲述一些获取诸如图标、位图以及对话框之类的资源的原始数据所需的一些数据结构。我不涉及它们的实际格式,那已经超出了本文的范围。
资源可以在一个叫做.rsrc的节中找到。DataDirectory中索引为IMAGE_DIRECTORY_ENTRY_RESOURCE的元素包含了资源的RVA和大小。由于多方面的原因,资源被组织得与文件系统类似——有目录和叶结点。
DataDirectory中的资源指针指向了一个IMAGE_RESOURCE_DIRECTORY类型的结构。这个结构中包含了目前尚未使用的Characteristics域、TimeDateStamp域以及版本号域(MajorVersion和MinorVersion)。这个结构中真正有用的域是NumberOfNamedEntries和NumberOfIdEntries。
每个IMAGE_RESOURCE_DIRECTORY结构后面是一个IMAGE_RESOURCE_DIRECTORY_ENTRY结构数组。另外,IMAGE_RESOURCE_DIRECTORY结构中的NumberOfNamedEntries和NumberOfIdEntries这两个域保存的就是这个数组中IMAGE_RESOURCE_DIRECTORY_ENTRY结构的数目。(如果你感觉这些数据结构的名称让你看得头疼,说句实在话,我将它们写下来也挺难受的!)
每个目录项(即IMAGE_RESOURCE_DIRECTORY_ENTRY结构)或者指向另一个资源目录,或者指向具体的资源数据。当它指向另一个资源目录时,这个结构中的第二个DWORD的最高位为1,其余的31位是那个资源目录的偏移。这个偏移是相对于资源节开头来说的,而不是RVA。
当它指向实际的某种资源时,第二个DWORD的最高位为0,其余的31位是具体资源(例如对话框)的偏移。同上面一样,这个偏移同样是相对于资源节开头来说的,而不是RVA。
每个目录项可以通过名称或者ID值来标识。它们就是你在.RC文件中为具体资源指定的名称或ID值。当目录项的第一个DWORD的最高位为1时,其余的31位是资源名称(字符串)的偏移;如果最高位为0,那么其低16位是资源标识(ID)的值。
理论已经足够了!现在让我们看一个实际的例子。下面是PEDUMP输出的ADVAPI32.DLL的资源节的部分内容:
Resources (RVA: 6B000)
ResDir (0) Entries:03 (Named:01, ID:02) TimeDate:00000000
———————————————————————————————
ResDir (MOFDATA) Entries:01 (Named:01, ID:00) TimeDate:00000000
ResDir (MOFRESOURCENAME) Entries:01 (Named:00, ID:01) TimeDate:00000000
ID: 00000409 DataEntryOffs: 00000128
DataRVA: 6B6F0 DataSize: 190F5 CodePage: 0
———————————————————————————————
ResDir (STRING) Entries:01 (Named:00, ID:01) TimeDate:00000000
ResDir (C36) Entries:01 (Named:00, ID:01) TimeDate:00000000
ID: 00000409 DataEntryOffs: 00000138
DataRVA: 6B1B0 DataSize: 0053C CodePage: 0
———————————————————————————————
ResDir (RCDATA) Entries:01 (Named:00, ID:01) TimeDate:00000000
ResDir (66) Entries:01 (Named:00, ID:01) TimeDate:00000000
ID: 00000409 DataEntryOffs: 00000148
DataRVA: 85908 DataSize: 0005C CodePage: 0
其中以“ResDir”开头的每一行对应于一个IMAGE_RESOURCE_DIRECTORY结构。“ResDir“后面的括号中是资源目录的名称。在这个例子中,资源目录的名称分别为0、MOFDATA、MOFRESOURCENAME、STRING、C36、RCDATA和66。名称后面是以名称标识的和以ID标识的资源目
录的总个数(后面的括号中是它们分别的个数)。在这个例子中,顶级目录一共有3个直接的子目录,所有其它目录都只有一个下级子目录。
顶级目录类似于文件系统中的根目录。根目录下的每个子目录项(也就是第二级目录)代表资源的类型(字符串表、对话框、菜单等等)。它们下面还有第三级子目录。
对于某种具体的资源类型来说,一般有三级目录。例如如果有五个对话框,那么第二级的DIALOG目录下面将会有五个子目录项。这五个子目录项本身也都是目录。在这五个目录下面都只有一项内容,它就是具体资源的原始数据的偏移地址。很简单,不是吗?
如果你更喜欢通过读源代码来学习的话,你可以仔细看一下PEDUMP中转储资源的那部分代码(PEDUMP的源代码可以从2002年2月本文的第一部分中下载)。除了显示所有的资源目录以及它们的元素个数外,PEDUMP还可以显示几种常见的资源类型,例如对话框等。
基址重定位
在可执行文件中的许多地方,你都会发现内存地址的踪迹。当链接器在生成可执行文件时,它假定这个可执行文件会被加载到内存中的某一个地址处(即首选地址)。只有在可执行文件被加载到其首选地址时,所有这些内存地址才是正确的。这个首选地址由IMAGE_FILE_HEADER结构中的ImageBase域给出。
如果加载器由于某种原因需要把可执行文件加载到其它地址处时,所有这些地址都变成不正确的了。这将会额外增加加载器的工作量。在2000年5月的Under The Hood专栏(前面已经提到)中我已经讲过当几个DLL首选加载地址相同时会导致性能损失,以及如何使用REBASE工具来解决这个问题。
基址重定位(Base Relocations)信息告诉加载器可执行文件不能被加载到其首选地址时需要进行修改的每一个位置。对于加载器来说,幸运的是它并不需要知道地址使用的细节问题。它只知道有一个地址列表,其中的每一个地址都需要以同样的方式进行修改。
让我们来看一个x86平台上的可执行文件的例子。假设有以下指令,它将一个全局变量(地址0x0040D434)的值加载到ECX寄存器中:
00401020: 8B 0D 34 D4 40 00 mov ecx,dword ptr [0x0040D434]
这条指令在地址0x00401020处,长为6个字节。前两个字节(0x8B 0x0D)是指令的机器码。剩下的四个字节是一个DWORD值的地址(0x0040D434)。在这个例子中,这条指令实际来自一个首选地址为0x00400000的可执行文件,因此这个全局变量的RVA为0xD434。
如果这个可执行文件被加载到了0x00400000处,这条指令当然可以正确执行。但是现在我们假设它被加载到了0x00500000处。如果真是这样,那么这条指令的最后的四个字节需要被改成0x0050D434。
那么加载器是如何做的呢?它比较首选加载地址与实际加载地址,然后计算出△(delta,音译为德耳塔,数学中的常用符号,表示差值的意思)。在这个例子中,△为0x00100000。这
个△被加到变量原来的地址值(大小为DWORD)上,形成新的地址。在前面的例子中,关于地址0x00401022处,即指令中的DWORD值处,将会有一个相应的重定位信息。
简而言之,基址重定位信息只是可执行文件中的一个地址列表,当加载进内存时,这些地址中的值都要再加上△。为了提高系统性能,可执行文件的页面只有在需要时才会被加载进内存(可执行文件的加载与内存映射文件类似),基址重定位信息的格式就反映了这个特性。基址重定位信息所在的节通常被称为.reloc节,但是查找它的正确方法是通过数据目录中索引为IMAGE_DIRECTORY_ENTRY_BASERELOC的那个元素。
基址重定位信息是一些非常简单的IMAGE_BASE_RELOCATION结构。此结构中的VirtualAddress域包含了需要进行重定位的内存范围的起始RVA。SizeOfBlock域给出了重定位信息的大小,其中包括IMAGE_BASE_RELOCATION自身的大小。
紧跟着IMAGE_BASE_RELOCATION结构后面是一组可变数目的WORD值。这些WORD值的数目可以从IMAGE_BASE_RELOCATION结构的SizeOfBlock域推出。其中每个WORD值由两部分组成。高4位指明了重定位的类型,由WINNT.H中的一系列IMAGE_REL_BASED_xxx值给出。低12位是相对于IMAGE_BASE_RELOCATION结构的VirtualAddress域的偏移,这是应该进行重定位的地方。
在前面那个关于基址重定位的例子中,我把情况简化了。实际上有多种类型的重定位方式。对于x86平台上的可执行文件来说,所有的重定位类型都是IMAGE_REL_BASED_HIGHLOW。你经常会在一组重定位信息之后看到类型为IMAGE_REL_BASED_ABSOLUTE的重定位信息。它们实际上并没有什么作用,只是为了填充空间以便下一个IMAGE_BASE_RELOCATION结构能够按4字节的边界对齐。
对于IA-64平台上的可执行文件来说,重定位类型好像总是IMAGE_REL_BASED_DIR64。与x86平台一样的是,通常也会有作为填充的IMAGE_REL_BASED_ABSOLUTE类型的重定位信息。有趣的一点是,尽管IA-64平台上每个页面是8KB,但基址重定位信息仍旧是分成4KB的块。
在Visual C++ 6.0中,链接器在创建发行版的EXE文件时并不生成重定位信息。这是由于EXE文件是最先被加载到进程的地址空间中的,因此可以绝对保证它能被加载到其首选地址上。DLL就没有这么幸运了,因此DLL中总是存在基址重定位信息,除非你使用/FIXED链接器选项明确忽略它们。在Visual Studio .NET中,链接器在生成调试版和发行版的EXE文件时都不产生基址重定位信息。
调试目录
当创建可执行文件并生成相应的调试信息时,通常文件中会包含这种信息格式的细节以及它的位置。操作系统运行可执行文件时并不需要调试信息,但它对于开发工具非常有用。一个EXE文件可以包含多种格式的调试信息,调试目录(Debug Directory)结构指出哪种格式可用。
可以通过数据目录中索引为IMAGE_DIRECTORY_ENTRY_DEBUG的元素找到调试目录。它是由IMAGE_DEBUG_DIRECTORY结构组成的数组,其中每一个结构对应一种类型的调试信息,如下表所示。调试目录中元素的数目可以使用数据目录中的Size域计算得出。
大小
域
描述
DWORD
Characteristics
未用,设置为0。
大小 域 描述
DWORD
TimeDateStamp
调试信息的日期/时间戳。
WORD
MajorVersion
调试信息的主版本号,未用。
WORD
MinorVersion
调试信息的次版本号,未用。
DWORD
Type
调试信息的类型。以下是经常遇到的类型:
IMAGE_DEBUG_TYPE_COFF
IMAGE_DEBUG_TYPE_CODEVIEW // 包含PDB文件
IMAGE_DEBUG_TYPE_FPO // 帧指针省略
IMAGE_DEBUG_TYPE_MISC // IMAGE_DEBUG_MISC
IMAGE_DEBUG_TYPE_OMAP_TO_SRC
IMAGE_DEBUG_TYPE_OMAP_FROM_SRC
IMAGE_DEBUG_TYPE_BORLAND // Borland格式
DWORD
SizeOfData
文件中调试数据的大小。不包括外部调试文件(例如.PDB文件)的大小。
DWORD
AddressOfRawData
当映射进内存时调试数据的RVA。如果调试信息不被映射,它被设置为0。
DWORD
PointerToRawData
调试数据的文件偏移(不是RVA)。
到目前为止,最流行的调试信息格式是PDB文件。PDB文件实质上是CodeView格式调试信息的发展。一个类型为IMAGE_DEBUG_TYPE_CODEVIEW的调试目录标志着PDB信息的存在。如果你检查由这个元素指向的数据,会发现一个短的CodeView格式的头部。这个调试数据主要是一个外部PDB文件的路径。在Visual Studio 6.0中,调试头开始处是一个NB10签名。在Visual Studio .NET中,这个头开始处是RSDS。
在Visual Studio 6.0中,可以使用/DEBUGTYPE:COFF链接器选项来生成COFF调试信息。Visual Studio .NET将这项功能移除了。对于经过优化的x86代码,由于函数可能没有正常的栈帧,所有使用帧指针省略(Frame Pointer Omission,FPO)调试信息。FPO数据允许调试器定位局部变量和参数。
有两种OMAP调试信息仅用于Microsoft的程序。Microsoft内部使用一种工具对可执行文件中的代码进行重新排列以减少分页。(它所做的不仅仅是Working Set Tuner所能做到的。)OMAP信息让工具可以在调试信息中的原始地址与重排后的代码中的新地址之间进行转换。
顺便说一下,DBG文件也包含了一个类似于我上面讲的调试目录。DBG文件流行于Windows NT 4.0时代,它们主要包含COFF调试信息,但是Windows XP偏爱PDB文件而将它们淘汰了。
.NET头部
对于开发工具生成的用于Microsoft .NET环境下的可执行文件来说,它们首先是PE文件。但是在大多数情况下.NET文件中正常的代码和数据是微不足道的。.NET可执行文件的主要目的是将.NET特定的信息,例如元数据和中间语言(IL),加载进内存。另外.NET可执行文件链接到了MSCOREE.DLL文件上。这个DLL是.NET进程的起点。当加载.NET可执行文件时,它的入口点通常是一个小的占位程序。这个占位程序只是跳转到MSCOREE.DLL的一个导出函数(_CorExeMain或_CorDllMain)上。从那里开始,MSCOREE获取控制权,开始使用可执行文件中的元数据和IL。这类似于(.NET版之前的)Visual Basic中的应用程序使用MSVBVM60.DLL所采用的方式。.NET信息的起点是IMAGE_COR20_HEADER结构,它当前被定义在.NET Framework SDK中的CorHDR.H文件以及最新的WINNT.H文件中。数据目录中索引为IMAGE_DIRECTORY_ENTRY_COM_DESCRIPTOR的项指向IMAGE_COR20_HEADER结构。下表列出了IMAGE_COR20_HEADER结构中的域。关于IMAGE_COR20_HEADER指向的元数据、方法IL以及其它内容将在后续文件中详细讲述。
类型
域
描述
DWORD
cb
头部的大小(以字节计)。
WORD
MajorRuntimeVersion
运行这个程序所需的运行时组件的最小版本号。对于第一个发行的.NET Framework而言,此值为2。
WORD
MinorRuntimeVersion
次版本号,当前为0。
IMAGE_DATA_DIRECTORY
MetaData
元数据表的RVA。
DWORD
Flags
包含这个映像属性的标志。当前定义了以下值:
COMIMAGE_FLAGS_ILONLY
// 映像仅包含IL代码,并不需要运// 行于特定CPU上
COMIMAGE_FLAGS_32BITREQUIRED // 仅运行于32位处理器上
COMIMAGE_FLAGS_IL_LIBRARY
STRONGNAMESIGNED
// 映像已经用散列数据签名
COMIMAGE_FLAGS_TRACKDEBUGDATA
// 让JIT或运行时组件为方法保持// 调试信息
DWORD
EntryPointToken
映像入口点的MethodDef的记号。.NET运行时调用这个方法开始托管执行。
IMAGE_DATA_DIRECTORY
Resources
.NET资源的RVA和大小。
IMAGE_DATA_DIRECTORY
StrongNameSignature
强名称散列数据的RVA。
IMAGE_DATA_DIRECTORY
CodeManagerTable
代码管理器表的RVA。代码管理器包含获取正在运行的程序的状态(例如堆栈跟踪和跟踪GC引用)所需的代码。
IMAGE_DATA_DIRECTORY
VTableFixups
需要被修正的函数指针组成的数组。用于支持非托管的C++虚表。
IMAGE_DATA_DIRECTORY
ExportAddressTableJumps
由对应于导出符号的JMP形实转换块被写入的位置(RVA)组成的数组的RVA。这些形实转换块允许托管方法被导出,这样非托管代码可以调用它们。
IMAGE_DATA_DIRECTORY
ManagedNativeHeader
在内存中供.NET运行时组件内部使用。在可执行文件中被设置为0。
TLS初始化
当使用__declspec(thread)定义线程局部变量时,编译器将它们放入一个名为.tls的节中。当系统创建新线程时,它从进程堆中分配内存来保存用于新线程的线程局部变量。这部分内存使用.tls节中的值进行初始化。系统将分配的内存的地址保存在TLS数组中,FS:[2Ch]指向这个数组(在x86平台上)。
如果数据目录中索引为IMAGE_DIRECTORY_ENTRY_TLS的元素不为0,那就表示可执行文件中存在线程局部存储(TLS)。而这个元素指向一个IMAGE_TLS_DIRECTORY结构,如下表所示。
大小
域
描述
DWORD
StartAddressOfRawData
用于在内存中初始化新线程的TLS数据的一段内存的起始地址。
DWORD
EndAddressOfRawData
用于在内存中初始化新线程的TLS数据的一段内存的结束地址。
DWORD
AddressOfIndex
当可执行文件被加载进内存时,如果它包含.tls节,加载器调用TlsAlloc给它分配一个TLS句柄,并将分配的句柄保存在这个域指定的位置处。运行时库使用这个句柄定位线程局部数据。
DWORD
AddressOfCallBacks
由PIMAGE_TLS_CALLBACK类型的函数指针组成的数组的地址。当创建或撤销线程时,这个列表中的每个函数都会被调用。最后一个元素的值为0,它标志着表的结尾。一般由Visual C++生成的可执行文件中这个表是空的。
DWORD
SizeOfZeroFill
已初始化数据中除了由StartAddressOfRawData和EndAddressOfRawData域组成的已初始化数据界限之外的大小(以字节计)。所有超出这个范围的用于单个线程的数据都被初始化为0。
DWORD
Characteristics
保留,当前被设置为0。
注意到IMAGE_TLS_DIRECTORY中的地址都是虚拟地址而不是RVA这一点很重要。因此如果可执行文件不能被加载到其首选加载地址时,它们都要进行基址重定位。同时,IMAGE_TLS_DIRECTORY结构本身并不在.tls节中,它位于.rdata节中。
程序异常数据
一些平台(包括IA-64)并不使用x86平台上的基于帧的异常处理,它们使用的是基于表的异常处理。在这种异常处理中有一个表,它包含了可能会被异常展开(unwinding)影响到的每一个函数的信息。这些信息主要包括每个函数的开始地址、结束地址以及在哪里并如何处理异常。当发生异常时,系统搜索整个表来寻找处理它的相应项并处理。异常表是一个由IMAGE_RUNTIME_FUNCTION_ENTRY结构组成的数组。数据目录中索引为IMAGE_DIRECTORY_ENTRY_EXCEPTION的元素引向此数组。这个结构的格式因平台而异。对于IA-64平台,它的结构如下:
DWORD BeginAddress;
DWORD EndAddress;
DWORD UnwindInfoAddress;
UnwindInfoAddress数据的结构并未在WINNT.H文件中给出。但是它的具体格式可以在Intel的"IA-64 Software Conventions and Runtime Architecture Guide"一书第11章中找到。
PEDUMP程序
现在我的PEDUMP程序与1994年时的相比已经有了很大改进。它可以显示本文中讲的所有结构,其中包括:
• IMAGE_NT_HEADERS
• 导入表/导出表
• 资源
• 基址重定位
• 调试目录
• 延迟导入表
• 绑定导入描述符
• IA-64异常处理表
• TLS初始化数据
• .NET运行时头
除了可以转储可执行文件外,PEDUMP还可以转储COFF格式的OBJ文件、COFF导入库(新格式以及老格式)、COFF符号表和DBG文件。
PEDUMP是一个命令行程序。对前面提到的各种文件运行PEDUMP时,如果不加任何选项,它默认输出的是最有用的数据结构信息。有好几个命令行选项可以用来添加其它的输出信息:
名称
描述
/A
转储所有内容
/B
显示基址重定位信息
/H
包括每个节中原始数据的十六进制形式
/I
包括导入地址表形实转换块的地址
/L
包括行号信息
/P
包括PDATA(运行时函数)
/R
包括详细的资源信息(字符串表和对话框)
/S
显示符号表
关于PEDUMP的源代码有几个地方值得注意。首先它可以按32位或64位可执行文件编译和运行。如果你手边有Itanium机器可以试一下。另外,无论PEDUMP以何种方式编译,它都可以同时转储32位和64位文件。换句话说,32位版的PEDUMP可以转储32位和64位文件,64位版的PEDUMP也可以转储32位和64位文件。
在考虑使PEDUMP可以同时处理32位和64位文件时,我想避免为32位结构和64位结构分别写一个函数。因此我使用了C++模板。
在好几个文件(特别是EXEDUMP.CPP)中,你都会发现各种模板函数。大多数情况下,模板函数的参数最终会被扩展为IMAGE_NT_HEADERS32结构或 IMAGE_NT_HEADERS64结构。当调用这些函数时,由代码自身确定是32位还是64位文件并用相应参数类型去调用相应的函数,引起相应的模板展开。
伴随PEDUMP源代码的还有一个Visual C++ 6.0工程文件。工程配置除了传统的x86 debug和 release外,还有相应的64位配置。要想使它正常工作,你需要把64位工具(当前在Platform SDK中)的路径添加到Tools | Options | Directories 选项卡最上面的Executable files路径中,同时还要设置相应的64位Include目录和Lib目录的路径。在我的机器上这个工程文件可以正常工作,但是在你的机器上可能需要进行少量修改才行。
为了使PEDUMP可以处理的内容尽可能全面,这就需要使用最新的Windows头文件。我在开发这个程序时使用的是2001年6月的Platform SDK,需要的这些文件都在.\include\prerelease和.\Include\Win64\crt目录中。在2001年8月的SDK中, WINNT.H文件已经被更新,因此也就不需要prerelease目录中的文件了。最终可以成功创建这个程序。你需要做的可能只是尽是安装最新的Platform SDK或在创建64位版的程序时对工程目录进行一些修改。
结束语
可移植可执行文件格式是一种结构非常好且相对简单的可执行文件格式。特别好的一点是PE文件可以被直接映射进内存,这样它在磁盘上的数据结构与运行时Windows使用的结构一致。
我同时非常惊奇于PE格式是如何经受住10多年来的各种变化,甚至包括移植到64位Windows以及.NET平台上,对它的影响。(PE格式的设计者竟然如此深谋远虑!)
尽管我讲了PE文件许多方面的内容,但是仍然还有一些主题我没有涉及到,其中包括一些标志、属性以及数据结构。我认为它们并不常用,因此也就没有在这里讲。但是我希望我在这里对PE文件的讲解能使你更容易理解Microsoft的PE规范。
(MSDN Magazine 2002年3月Under The Hood专栏)
译者:SmartTech 电子信箱:zhzhtst@163.com
|
免费评分
-
查看全部评分
|
发帖前要善用【论坛搜索】功能,那里可能会有你要找的答案或者已经有人发布过相同内容了,请勿重复发帖。 |
|
|
|
|