Matt Pietrek
这篇文章假定你熟悉C++和Win32。
概述 理解可移植可执行文件格式(PE)可以更好地了解操作系统。如果你知道DLL和EXE中都有些什么东西,那么你就是一个知识渊博的程序员。这一系列文章的第一部分,讨论最近这几年PE格式所发生的变化。
这次更新后,作者讨论了PE格式如何适应于用.NET开发的应用程序,包括PE节,RVA,数据目录,以及导入函数。附录中包含了相关的映像头结构以及它们的描述。
很早以前,我为微软系统期刊(现在叫做MSDN)写了一篇文章。那篇文章“Peering Inside the PE: A Tour of the Win32 Portable Executable File Format”比我所期望的更受人欢迎。直到现在,我仍然能收到使用那篇文章的人(甚至Microsoft里的人)的来信,那篇文章在MSDN中仍然能够找到。不幸的是,那篇文章中存在一些问题。这几年Win32发生了很大变化,那篇文章已经过时了。从这个月开始我将在一篇分成两部分的文章中改正那些问题。
你也许会奇怪为什么应该关心可执行文件的格式呢。答案还和过去一样:一个操作系统可执行文件的格式和数据结构揭示了这个底层操作系统的许多东西。通过理解EXE和DLL中到底有些什么,你会成为一个知识更加渊博的程序员。
当然,你从微软的规范中也能学到我所告诉你的许多东西。然而,微软的规范为了涵盖全面而牺牲了可读性。而我这篇文章的焦点主要就是讨论 PE 文件的格式,填补了不适合出现在正式的说明规范中的部分。另外,在这篇文章中也有一些在任何微软官方文档中都没有的好东西。
Bridging the Gap
先给出几个自从1994年我写了那篇文章之后 PE 文件格式都发生了哪些变化的例子。由于16位的 Windows 已经成为历史,所以没有必要再和 Win16 可执行文件格式进行比较了。而另一个淡出人们视野的是 Win32s®。在 Windows3.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运行时把这些可执行文件中的数据看作对.NET很重要的元数据和中间语言。在本文中,我将敲开 .NET 元数据格式的大门,但把对它的全部光彩的全面审视留给下一篇文章。
即使 Win32 所发生的变化不足以重写一片文章来描述现在的特殊效果,在以前的那篇文章中也存在一些错误,这使我很惭愧。例如,我关于线程本地存储(TLS)支持的描述是错误的。同样的,我对于文件中的时间戳的描述只是当你在美国西部标准时间地区时才精确!
另外,有许多东西以前正确但现在不正确了。我陈述过 .rdata 节并不是真的为每个重要的东西都使用了。而现在,它是。我也说过 .idata 节是一个可读写的节,但一些尝试进行 API 拦截的人发现它经常不正确。
随着在这篇文章中对新的 PE 格式的讨论,我也对用于显示 PE 文件内容的 PEDUMP 程序进行了大的修改。PEDUMP 可在 x86 和 IA-64 平台上编译和运行,可以 dump 32位和64位的 PE 文件。最重要的是,PEDUMP 的完整源代码可从这篇文章顶部的链接下载得到,这样你就有了本篇文章中所描述的概念和数据结构的一个示例程序。
PE文件格式总揽
Microsoft 引入了PE文件格式,也就是大家都熟悉的PE格式,是Win32规范的一部分。然而,PE文件来源于更早的基于VAX/VMS的公共对象文件格式(COFF)。由于最初的Windows NT小组成员很多都来自数字设备公司(DEC),于是很自然的这些开发者使用已存在的代码以加速新的Windows NT平台的开发。
使用术语“可移植可执行”的目的是为了在所有Windows平台和所有支持的CPU上都有一个统一的文件格式。Windows NT及其以后版本,Windows 95及其以后版本和Windows CE都使用了这个相同的格式,所以说在很大程度上, 这个目的达到了。
Microsoft编译器生成的OBJ文件使用COFF格式。通过观察COFF格式的一些域你能知道它有多么老了,那些域使用八进制编码!COFF OBJ 文件中有许多和PE文件一样的数据结构和枚举,随后我将提到它们中的一些。
对于64位的Windows, PE格式只是进行了很少的修改。这种新的格式被叫做PE32+。没有加入新的域,只有一个域被去除。剩下的改变只是一些域从32位扩展到了64位。在这种情况下,你能写出和32位与64位PE文件都能一起工作的代码。对于C++代码,Windows头文件的能力使这些改变很不明显。
EXE和DLL文件之间的不同完全是语义上的。它们都使用完全相同的PE格式。仅有的区别是用了一个单个的位来指出这个文件应该被作为EXE还是一个DLL。甚至DLL文件的扩展名也是不固定的,一些具有完全不同的扩展名的文件也是DLL,比如.OCX控件和控制面板程序(.CPL文件)。
PE文件一个方便的特点是磁盘上的数据结构和加载到内存中的数据结构是相同的。加载一个可执行文件到内存中 (例如,通过调用LoadLibrary)主要就是映射一个PE文件中的几个确定的区域到地址空间中。因此,一个数据结构比如IMAGE_NT_HEADERS (稍后我将会解释)在磁盘上和在内存中是一样的。关键的一点是如果你知道怎么在一个PE文件中找到一些东西,当这个PE文件被加载到内存中后你几乎能找到相同的信息。
要注意到PE文件并不仅仅是被映射到内存中作为一个内存映射文件。代替的,Windows加载器分析这个PE文件并决定映射这个文件的哪些部分。当映射到内存中时文件中偏移位置较高的数据映射到较高的内存地址处。一个项目在磁盘文件中的偏移也许不同于它被加载到内存中时的偏移。然而,所有被表现出来的信息都允许你进行从磁盘文件偏移到内存偏移的转换 (参见图1)。
图 1 偏移
通过Windows加载器加载PE文件到内存后,内存中的版本被称作一个模块。文件被映射到的起始地址称为HMODULE。有一点值得记住:得到一个HMODULE, 你就知道那个地址处有些什么数据结构,并且你能找到内存中其它所有的数据结构。这是个很有用的功能,能被用做一些其它目的例如拦截API(Windows CE下HMODULE和加载地址并不相同,这些以后再讲)。
内存中的模块描绘一个进程所需要的可执行文件的所有代码,数据,和资源。PE文件另一些部分只被读取,但不会被映射 (例如重定位信息)。一些部分根本就不被映射,例如,文件末尾的调试信息。PE头中的一个域可以告诉系统映射一个可执行文件到内存中需要多少内存。不被映射的数据放在文件末尾,这些数据之前的部分将会被映射。
描述PE格式(以及COFF文件)的主要地方是在WINNT.H文件中。在这个头文件中,你可以找到要和PE文件一起工作所必须的每个结构定义,枚举,和#define定义。当然,其它地方也有相关文档。例如,MSDN中有“Microsoft Portable Executable and Common Object File Format Specification” 这篇文章。但WINNT.H 文件最终决定了PE文件的格式。
有很多检查PE文件的工具。在它们之中有包含于Visual Studio中的Dumpbin,和包含于Platform SDK的Depends。我比较喜欢Depends因为它有一个检查一个文件的导入表和导出表的简洁的方式。Smidgeonsoft(http://www.smidgeonsoft.com)的PEBrowse专业版是一个很优秀的免费的PE观察器。这篇文章中包括的PEDUMP程序功能也很全面,实现了几乎Dumpbin的所有功能。
从API的角度来说,Microsoft的IMAGEHLP.DLL 提供了读取和编辑PE文件的机制。
在我开始讨论PE文件的详细内容之前,让我们首先回顾几个基本概念,这些概念贯穿于整个PE文件格式。下面,我将讨论PE文件的节,相对虚拟地址(RVAs),数据目录,和导入函数的方法。
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节到一个PE文件中的单个的.text节中。同样地,这些OBJ文件中的叫做.data的节被合并到PE文件中一个单个的.data节中。.LIB文件中的代码和数据通常也被包含在可执行文件中,但那个主题已经超出本文的范围了。
链接器遵循一整套规则来决定哪些节该被合并以及如何合并。OBJ文件中的某个节也许是提供给链接器使用的,并不会放到最终的可执行文件中去。像这样的节是由编译器用来以传递信息给链接器。
节有两种对齐值,一个是在磁盘文件中的偏移另一个是在内存中的偏移。PE文件头指定了这两个对齐值,它们可以是不同的。每个节起始于那个对齐值的倍数的位置。例如,在PE文件中,典型的对齐值是0x200。因此,每个节开始于一个0x200的倍数的文件偏移处。
一旦加载到内存中,节总是起始于至少一个页边界。就是说,当一个PE节被映射到内存中后,每个节的第一个字节都符合一个内存页。对于x86 CPUs,页是4KB,而IA-64,页是8KB。下面显示了PEDUMP输出的Windows XP KERNEL32.DLL 的.text节和.data节的一小部分。
节表
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节在PE文件中的偏移为0x400,而在内存中位于KERNEL32加载地址之上第0x1000个字节处。同样的,.data节在PE文件中的偏移为0x74C00,而在内存中位于KERNEL32加载地址之上第0x76000个字节处。
创建一个节在文件中的偏移和在内存中的偏移相同的PE文件是可能的。这会使可执行文件变得很大,但在Windows 9x或Windows Me.下可以提高加载速度。缺省的/OPT:WIN98 链接器选项(Visual Studio 6.0引入)可以以这种方式创建PE文件。在Visual Studio® .NET中,也许会或者也许不会使用/OPT:NOWIN98,这依赖于文件是否足够小。
链接器的一个有趣的特点是可以合并节。如果两个节有类似的,兼容的特性,它们通常可以在链接时被合并到一个节中。这可通过/merge 选项做到。例如,下面的链接器选项合并.rdata和.text节到一个单个的命名为.text的节中。
/MERGE:.rdata=.text
合并节的好处是可以节省磁盘文件和内存空间。每个节至少要占用一个内存页。如果你能把可执行文件中节的数量从4个减少到3个,你就可以少占用一个内存页。当然,这取决于这两个被合并的节的未使用空间是否达到一页。
对于合并节没有什么硬性的规定。例如,可以合并.rdata到.text中,但你不应该把.rsrc,.reloc,或者.pdata合并到其它节中。在Visual Studio .NET之前,你可以合并.idata到其它节中。Visual Studio .NET,,就不允放过样做了,但当链接一个发布版的时候,链接器经常合并.idata中的一部分到其它节中,例如.rdata。
既在一部分导入数据是当它们被加载到内存中时由加载器写入的,你也许很奇怪它们怎么能被写入一个只读内存节。这是因为在加载时系统临时把包含导入数据的页面的属性设为可读写。一旦导入表被初始化后,这些页被设置回它们最初的保护属性。
相对虚拟地址
在一个可执行文件中,有许多在内存中的地址必须被指定的位置。例如,当引用一个全局变量时就必须指定它的地址。PE文件可以被加载到进程地址空间的任何位置。虽然它们有一个首选加载地址,但你不能依赖于可执行文件真的会被加载到那个位置。因为这个原因,指定一个地址而不依赖于可执行文件的加载位置就很重要。
为了消除PE文件中对内存地址的硬编码,于是产生了RVA。一个RVA是在内存中相对于PE文件被加载的地址的一个偏移。例如,如果一个EXE文件被加载到地址0x400000,它的代码节位于地址0x401000处。那么代码节的RVA就是:
(目标地址) 0x401000 - (加载地址)0x400000 = (RVA)0x1000.
要把一个RVA转换为实际地址,进行相反的步骤就行了:把RVA和实际加载地址相加就可得到实际内存地址。顺便说一下,实际内存地址在PE中被称为虚拟地址(VA)。另外也可以认为一个VA是加上首选加载地址的RVA。不要忘了我以前说过的,加载地址和HMODULE是一样的。
你是否想研究一下一些DLL在内存中的数据结构呢?这里有一个方法。以这个DLL的名字作为参数调用GetModuleHandle函数。返回的HMODULE是一个加载地址;你可以应用你的PE文件结构的知识找到这个模块中的任何你想要的东西。
数据目录
在可执行文件中有许多数据结构需要被快速定位。一些明显的例子是导入表,导出表,资源,和基址重定位表。所有这些众所周知的数据结构都可通过一致的方式被找到,就是数据目录。
数据目录是一个由16个结构组成的数组。每个数组元素都预定义了它所代表的含意。IMAGEDIRECTORY_ENTRY xxx 定义了数据目录的数组索引(从0到15)。图2描述了每个IMAGE_DATA_DIRECTORY_xxx值分别表示了什么。这篇文章的第2部分包含了对其所指向的数据结构的更详细的描述。
图 2 IMAGE_DATA_DIRECTORY 值
值 | 描述 |
---|---|
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结构数组,其中每个结构描述了映像的一些调试信息。早期的Borland链接器设置这个IMAGE_DATA_DIRECTORY结构的Size域为结构的数目,而不是字节大小。要得到IMAGE_DEBUG_DIRECTORY结构的数目,用IMAGE_DEBUG_DIRECTORY 的大小除以这个Size域。 |
IMAGE_DIRECTORY_ENTRY_ARCHITECTURE | 指向特定架构数据,它是一个IMAGE_ARCHITECTURE_HEADER结构数组。不用于x86或IA-64,但看来已用于DEC/Compaq Alpha。 |
IMAGE_DIRECTORY_ENTRY_GLOBALPTR | 在某些架构体系上VirtualAddress域是一个RVA,被用来作为全局指针(gp)。不用于x86,而用于IA-64。Size域没有被使用。参见2000年11月的Under The Hood 专栏可得到关于IA-64 gp的更多信息。 |
IMAGE_DIRECTORY_ENTRY_TLS | 指向线程局部存储初始化节。 |
IMAGE_DIRECTORY_ENTRY_LOAD_CONFIG | 指向一个IMAGE_LOAD_CONFIG_DIRECTORY结构。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++ 编译器会在全局符号前加一个下划线。另外,它还用类型信息修饰全局符号名。因此,要使一切正常,在 C++ 中就必须像下面这样使用: extern “C” IMAGE_LOAD_CONFIG_DIRECTORY _load_config_used = {…} |
IMAGE_DIRECTORY_ENTRY_BOUND_IMPORT | 指向一个 IMAGE_BOUND_IMPORT_DESCRIPTOR结构数组,对应于这个映像绑定的每个DLL。数组元素中的时间戳允许加载器快速判断绑定是否是新的。如果不是,加载器忽略绑定信息并且按正常方式解决导入API。 |
IMAGE_DIRECTORY_ENTRY_IAT | 指向第一个导入地址表(IAT)的开始位置。对应于每个被导入DLL的IAT都连续地排列在内存中。Size域指出了所有IAT的总的大小。在写入导入函数的地址时加载器使用这个地址和Size域指定的大小临时地标记IAT为可读写。 |
IMAGE_DIRECTORY_ENTRY_DELAY_IMPORT | 指向延迟加载信息,它是一个CImgDelayDescr结构数组,定义在Visual C++的头文件DELAYIMP.H中。延迟加载的DLL直到对它们中的API进行第一次调用发生时才会被装入。Windows中并没有关于延迟加载DLL的知识,认识到这一点很重要。延迟加载的特征完全是由链接器和运行时库实现的。 |
IMAGE_DIRECTORY_ENTRY_COM_DESCRIPTOR | 在最近更新的系统头文件中这个值已被改名为IMAGE_DIRECTORY_ENTRY_COMHEADER。它指向可执行文件中.NET信息的最高级别信息,包括元数据。这个信息是一个IMAGE_COR20_HEADER结构。 |
导入函数
当你使用其它DLL中的代码或数据时,就要导入它。加载一个PE文件时,Windows 加载器的一个工作就是查找所有被导入的函数和数据并让那此函数和数据的地址可被加载的文件使用。完成这个工作所用到的数据结构的细节放到这篇文章的第二部分进行讨论,在这里学习一下这些概念。
当你直接调用到一个DLL的代码或数据时,你就是正在隐式地链接到这个DLL。要使被导入的API的地址可被你的代码使用你不需要做任何事情。加载器会完成所有需要做的工作。另外还有显式链接。意思就是说显式地加载目标DLL并查找API的地址。这几乎总是通过LoadLibrary和GetProcAddress来实现的。
当你隐式地链接一个API时,类似LoadLibrary和GetProcAddress的代码仍然被执行了,只不过是由加载器代替你自动执行的。加载器也会确保被加载的PE文件所需要的任何附加的DLL也被加载。例如,由Visual C++®链接器创建的每个正常的程序都要链接KERNEL32.DLL。而KERNEL32.DLL又从NTDLL.DLL导入函数。同样,如果你从GDI32.DLL导入函数,也将会依赖于USER32,ADVAPI32,NTDLL和KERNEL32 DLL。加载器会保证这些DLL都被加载并且解决所有导入问题。(Visual Basic 6.0和Microsoft .NET 可执行文件直接链接到另外一个DLL而不是KERNEL32,但原理是相同的。)
隐式链接时,对主EXE文件和所有依赖的DLL的处理发生在程序第一次启动时。如果出现了任何问题(例如,一个被引用的DLL没有找到),进程将被终止。
Visual C++ 6.0引入了延迟加载的功能,它是隐式链接和显式链接的混合体。在延迟加载一个DLL时,链接器生成一些和正常导入一个DLL时非常相似的数据。然而,操作系统忽略这些数据。代替的,第一次调用一个延迟加载的API时,DLL才会被加载(如果还没有加载到内存中),然后调用GetProcAddress方法得到被调用API的地址。以后如果再调用这个API将会和这个API被正常导入时有着一样的效率。
在PE文件中,对于每个被导入的DLL都有一个数据结构的数组。这些结构给出被导入DLL的名称并指向一个函数指针数组。这个函数指针数组就是导入地址表(IAT)。每个被导入的API在IAT中都有它自己的位置,导入函数的地址由Windows加载器写入到那个位置中。最后一点非常重要:一旦一个模块被加载,IAT中包含所要调用导入函数的地址。
IAT的优点是在一个PE文件中只有一个地方保存了被导入API的地址。不管源文件中多少次调用一个API,都会通过IAT中同一个函数指针来完成。
让我们看一下怎样调用一个被导入的API。需要考虑两种情况:高效的和低效的。最好的情况,调用一个导入API看起来应该像下面这样:
CALL DWORD PTR [0x00405030]
这是通过函数指针进行调用。无论怎样,0x405030地址处的DWORD值就是这个CALL指令将把控制转移到的地址。在前面例子中,地址0x405030就位于IAT中。
低效的调用看起来像下面这样:
CALL 0x0040100C
...
0x0040100C:
JMP DWORD PTR [0x00405030]
这种情况下,CALL把控制转到一个小的程序段处。这段程序通过JMP指令跳转到0x405030地址处。记住0x405030位于IAT中。低效调用导入函数用到了五个字节的额外代码,并且由于使用JMP指令花费了更长的执行时间。
你也许会奇怪为什么要使用低效的方法呢。有一个很好的解释。编译器无法区分导入函数调用和普通函数调用。因此,编译器生成同样形式的CALL指令
CALL XXXXXXXX
XXXXXXXX是一个稍后由链接器填充的实际地址。要注意这个CALL指令后面的地址并不是一个函数指针,而是一段实际代码的地址。链接器必须提供一块代码来替换这个XXXXXXXX。这样做的最简单的方法就是调用到一个JMP stub,就像你在上面看到的那样。
这个JMP stub从哪儿来呢?很令人惊奇,它来自于导入函数的导入库。如果你检查一个导入库,并且用导入API的名称来检查代码,你将会发现和上面JMP stub很相似的代码。这就是说缺省情况下将使用低效形式调用导入API。
那么,下一个要问的问题就是怎样才能得到优化的形式。答案是给编译器一个提示。__declspec(dllimport)函数修饰符告诉编译器这个函数位于其它DLL中,于是编译器将生成指令
CALL DWORD PTR [XXXXXXXX]
而不是:
CALL XXXXXXXX
另外,编译器也生成一些信息以告诉链接器把这个指令的函数指针部分解析为一个符号名imp_functionname。例如,如果你正在调用MyFunction,符号名就是impMyFunction。查看一个导入库,你会发现除了正常的符号名外,也有一个加了imp前缀的符号。_imp symbol可以直接定位到IAT入口,而不是通过那个JMP stub。
那么这对你以后每天的生活有什么影响呢?如果你正在编写导出函数并为它们提供一个头文件,记住要使用这个declspec(dllimport)修饰符:
declspec(dllimport) void Foo(void);
如果你查看Windows系统头文件,你会发现Windows API都使用了declspec(dllimport)。它并不容易被发现。你可在WINNT.H头文件中找到DECLSPEC_IMPORT 宏定义,而这个宏被用在一些文件中例如WinBase.H。到这里你就会明白declspec(dllimport)是如何被用在系统API声明上的。
PE 文件结构
现在来让我们研究PE文件的实际格式。我将从文件的开头开始,并描述在每个PE文件中都会出现的数据结构。然后,我将描述在一个PE节中的更特殊的数据结构(例如导入表和资源)。下面我将讨论的所有数据结构都定义在WINNT.H中,除非另有说明。
通常,这些结构都有 32 位和 64 位之分—-例如 IMAGE_NT_HEADERS32 和IMAGE_NT_HEADERS64。这些结构除了一些域被扩展为 64 位外几乎是一样的。如果你正在试着编写可移植的代码,WINNT.H 文件中有一些 #defines 定义可以用来选择使用32位还是 64 位的结构并且给它们起了一个与大小无关的别名(对于前面的例子这个别名就是IMAGE_NT_HEADERS)。具体选择哪一个结构依赖于你正在以哪种模式编译(是否定义了_WIN64)。只有在 PE 文件的目标执行平台的大小属性与正在编译的平台的大小属性不同时才需要使用特定的 32 位或 64 位版本的结构。
MS-DOS头
每个PE文件都以一个小的MS-DOS可执行体开头。在Windows早期很多消费者并没有安装Windows,所以就需要存在这个MS-DOS可执行体。当在没有安装Windows的机器上执行时,这段程序至少能打印一条信息来说明必须在Windows上才能执行这个可执行文件。
PE文件以一个传统的MS-DOS头开头,被称为IMAGE_DOS_HEADER。其中只有两个重要的值,它们是e_magic和e_lfanew。e_lfanew域包含PE头的文件偏移。e_magic域(一个WORD)必须被设为0x5A4D。对于这个值有个常量定义,叫做IMAGE_DOS_SIGNATURE。用ASCII字符表示, 0x5A4D就是“MZ”,这是MS-DOS最初设计者之一Mark Zbikowski名子的首字母大写。
IMAGE_NT_HEADERS头
IMAGE_NT_HEADERS 结构是存储 PE 文件细节信息的主要位置。它的偏移由这个文件开头的 IMAGE_DOS_HEADER 的 e_lfanew 域给出。实际上有两个版本的IMAGE_NT_HEADER 结构,一个用于 32 位可执行文件,另一个用于 64 位版本。它们之间的区别很小,在讨论中我将认为它们是相同的。区别这两种格式的唯一正确的、由Microsoft 认可的方法是通过 IMAGE_OPTIONAL_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表示就是“PE00”。 #define IMAGE_NT_SIGNATURE定义了这个值。第二个域是一个IMAGE_FILE_HEADER类型的结构,它包含了关于这个文件的一些基本的信息,最重要的是其中一个域指出了其后的可选数据的大小。在PE文件中,这个可选数据是必须的,但仍然被称为IMAGE_OPTIONAL_HEADER。
图3显示了IMAGE_FILE_HEADER 结构的域以及对这些域的注释。这个结构在COFF格式的OBJ文件开头也可以找到。图 4 列出了IMAGE_FILE_xxx通常的取值。图5显示了IMAGE_OPTIONAL_HEADER 结构的成员。
IMAGE_OPTIONAL_HEADER结构末尾的数据目录数组用来定位可执行文件中的重要数据的地址。每个数据目录条目看起来就像下面这样:
typedef struct _IMAGE_DATA_DIRECTORY {
DWORD VirtualAddress; // RVA of the data
DWORD Size; // Size of the data
};
图 3 IMAGE_FILE_HEADER
大小 | 域 | 描述 |
---|---|---|
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日以来所经过的秒数。这个值比文件系统的日期/时间更准确地指出了文件被创建的时间。使用_ctime 函数(对时区敏感)可以很容易地把这个值转换为人们可读的字符串形式。另一个有用的函数是gmtime。 |
DWORD | PointerToSymbolTable | COFF符号表的文件偏移,描述于Microsoft规范的5.4节。COFF符号表在PE文件中很少见,因为出现了新的调试格式。Visual Studio .NET之前,可通过指定链接器选项/DEBUGTYPE:COFF来创建COFF符号表。COFF符号表几乎总是会出现在OBJ文件中。如果没有符号表则设此值为0。 |
DWORD | NumberOfSymbols | 如果存在COFF符号表,此域表示其中的符号的数目。COFF符号是一个固定大小的结构,要找到COFF符号表的末尾就必须用到此域。紧跟COFF符号之后是一个用来保存较长符号名的字符串表。 |
WORD | SizeOfOptionalHeader | IMAGE_FILE_HEADER 之后的可选数据的大小。在PE文件中,这个数据称为IMAGE_OPTIONAL_HEADER。这个大小在32位和64位的文件中是不同的。 对于32位PE文件,这个域通常是224(0xE0) 对于64位PE32+文件,它通常是240 (0xF0) 然而,这些值只是所要求的最小值,更大的值也可能会出现。 |
WORD | Characteristics | 一组指示文件属性的位标。这些标记的有效值是定义于WINNT.H文件中的IMAGE_FILE_xxx值。一些常用的值在图4中列出。 |
**图 4 IMAGE_FILE_XXX
**
#define IMAGE_FILE_RELOCS_STRIPPED 0x0001 // Relocation info stripped from file.
#define IMAGE_FILE_EXECUTABLE_IMAGE 0x0002 // File is executable (i.e. no unresolved external references).
#define IMAGE_FILE_LINE_NUMS_STRIPPED 0x0004 // Line nunbers stripped from file.
#define IMAGE_FILE_LOCAL_SYMS_STRIPPED 0x0008 // Local symbols stripped from file.
#define IMAGE_FILE_AGGRESIVE_WS_TRIM 0x0010 // Aggressively trim working set
#define IMAGE_FILE_LARGE_ADDRESS_AWARE 0x0020 // App can handle >2gb addresses
#define IMAGE_FILE_BYTES_REVERSED_LO 0x0080 // Bytes of machine word are reversed.
#define IMAGE_FILE_32BIT_MACHINE 0x0100 // 32 bit word machine.
#define IMAGE_FILE_DEBUG_STRIPPED 0x0200 // Debugging info stripped from file in .DBG file
#define IMAGE_FILE_REMOVABLE_RUN_FROM_SWAP 0x0400 // If Image is on removable media, copy and run from the swap file.
#define IMAGE_FILE_NET_RUN_FROM_SWAP 0x0800 // If Image is on Net, copy and run from the swap file.
#define IMAGE_FILE_SYSTEM 0x1000 // System File.
#define IMAGE_FILE_DLL 0x2000 // File is a DLL.
#define IMAGE_FILE_UP_SYSTEM_ONLY 0x4000 // File should only be run on a UP machine
#define IMAGE_FILE_BYTES_REVERSED_HI 0x8000 // Bytes of machine word are reversed.
值 | 描述 |
---|---|
IMAGE_FILE_RELOCS_STRIPPED | 文件中不包括重定位信息。 |
IMAGE_FILE_EXECUTABLE_IMAGE | 文件是可执行的。 |
IMAGE_FILE_AGGRESIVE_WS_TRIM | 让操作系统强制整理工作区。 |
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 | 只能在单处理器机器中运行。 |
图 5 IMAGE_OPTIONAL_HEADER ( 32-bit 0x78 / 64-bit 0x88 )
Size | Structure Member | Description |
---|---|---|
WORD | Magic | 一个签名,确定这是什么类型的头。两个最常用的值是 IMAGE_NT_OPTIONAL_HDR32_MAGIC 0x10B IMAGE_NT_OPTIONAL_HDR64_MAGIC 0x20B |
BYTE | MajorLinkerVersion | 创建可执行文件的链接器的主版本号。对于Microsoft的链接器生成的PE文件,这个版本号的Visual Studio的版本号相一致(例如,版本6表示Visual Studio 6.0)。 |
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 选项来指定ImageBase,或者以后用REBASE工具重新设置。 |
DWORD | SectionAlignment | 加载到内存后节的对齐大小。这个值必须大于等于FileAlignment(下一个域)。缺省的对齐值是目标CPU的页大上。对于运行在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 95或Windows NT用户界面,而不是老版本的Windows NT界面。今天随着各种不同版本Windows的出现,这个域已不被系统使用,并且通常被设为4。可通过链接器选项/SUBSYSTEM设置这个域的值。 |
WORD | MinorSubsystemVersion | 可执行文件所要求的操作子系统的次版本号。 |
DWORD | Win32VersionValue | 另一个不被使用的域,通常设为0。 |
DWORD | SizeOfImage | 映像的大小。它表示了加载文件到内存中时系统必须保留的内存的数量。这个域的值必须是SectionAlignmnet的倍数。 |
DWORD | SizeOfHeaders | MS-DOS头,PE头和节表的总的大小。PE文件中所有这些项目出现在任何代码或数据节之前。这个域的值被调整为文件对齐大小的整数倍。 |
DWORD | CheckSum | 映像的校验和。IMAGEHLP.DLL中的CheckSumMappedFile函数可以计算出这个值。校验和用于内核模式的驱动和一些系统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的特性。对应于IMAGE_DLLCHARACTERISTICS_xxx定义。当前的值是: IMAGE_DLLCHARACTERISTICS_NO_BIND // 不要绑定这个映像 IMAGE_DLLCHARACTERISTICS_WDM_DRIVER // WDM模式的驱动程序 IMAGE_DLLCHARACTERISTICS_TERMINAL_SERVER_AWARE // 当终端服务加载一个不是 // Terminal- Services-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_ | DataDirectory[16] | 一个IMAGE_DATA_DIRECTORY结构数组。每个结构都包含了可执行文件中一些重要数据的RVA和大小(例如导入表,导出表和资源)。 |
节表
IMAGE_NT_HEADERS之后紧跟着节表。节表是一个IMAGE_SECTION_HEADER结构数组。IMAGE_SECTION_HEADER提供了和它关联的节的信息,包括位置,长度和属性。图6描述了IMAGE_SECTION_HEADER结构的各域。在IMAGE_FILE_HEADER结构中的NumberOfSections 域中提供了IMAGE_SECTION_HEADER 结构的数目。
可执行文件中的节的文件对齐对最终的文件大小有很大的影响。在Visual Studio 6.0中, 链接器缺省的对齐大小为4KB,除非使用了/OPT:NOWIN98或/ALIGN选项。Visual Studio .NET链接器也缺省使用了/OPT:WIN98选项,但它检测可执行文件的大小是否小于某个值,如果是则使用0x200字节进行对齐。
另一个值得注意的对齐方式来自.NET文件规范。它规定.NET可执行文件的内存对齐值是8KB,而不是x86平台的4KB。这是为了保证在x86平台上创建的可执行文件在IA-64平台上仍然可以运行。如果节的内存对齐值是4KB,IA-64加载器就不能加载这个文件,因为64位Windows的页大小是8KB。
图 6 IMAGE_SECTION_HEADER
大小 | 域 | 描述 |
---|---|---|
BYTE | Name[8] | 节的ASCII名称。节名不保证一定是以NULL结尾的。如果你指定了长于8个字符的节名,链接器会把它截短为8个字符。在OBJ文件中存在一个机制允许更长的节名。节名通常以一个句点开始,但这并不是必须的。节名中有一个“$”时链接器会对之进行特殊处理。前面带有“$”的相同名字的节将会被合并。合并的顺序是按照“$”后面字符的字母顺序进行合并的。关于名字中带有“$”的节以及这些节怎样被合并有很多的主题,但这些细节已超出本文所讨论的范围了。 |
DWORD | Misc.VirtualSize | 指出实际被使用的节的大小。这个域的值可以大于或小于SizeOfRawData域的值。如果VirtualSize的值大,SizeOfRawData就是可执行文件中已初始化数据的大小,剩下的字节用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结构数组。只在COFF行号被生成时使用。 |
WORD | NumberOfRelocations | PointerToRelocations 指向的重定位的数目。在可执行文件中应该是0。 |
WORD | NumberOfLinenumbers | NumberOfRelocations 域指向的行号的数目。只在COFF行号被生成时使用。 |
DWORD | Characteristics | 被或到一起的一些标记,用来表示节的属性。这些标记中很多都可以通过链接器选项/SECTION来设置。常用值在图7中列出。 |
图 7 Flags
值 | 描述 |
---|---|
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:name,S 链接器选项。 |
IMAGE_SCN_MEM_READ | 节是可读的。几乎总是被设置。 |
IMAGE_SCN_MEM_WRITE | 节是可写的。 |
IMAGE_SCN_LNK_INFO | 节中包含链接器使用的信息。只在OBJ文件中存在。 |
IMAGE_SCN_LNK_REMOVE | 节中的数据不会成为映像的一部分。只出现在OBJ文件中。 |
IMAGE_SCN_LNK_COMDAT | 节中的内容是公共数据(comdat)。公共数据是指可被定义在多个OBJ文件中的数据。链接器将选择一个包含到可执行文件中。Comdat 对于支持C++模板函数和在函数级别上的链接是至关重要的。Comdat节只出现在OBJ文件中。 |
IMAGE_SCN_ALIGN_XBYTES | 在最终的可执行文件中这个节中数据的对齐大小。它可有许多取值(_4BYTES,_8BYTES,_16BYTES等)。如果没有被指定,缺省是16字节。这些标记只在OBJ文件中被设置。 |
结束语
PE 文件头就讨论到这儿。在这篇文章的第2部分我将继续讨论经常遇到的一些节。然后我将描述那些节中的主要的数据结构,包括导入表,导出表以及资源。最后,我会讨论一下最新的、具有很大改进的 PEDUMP 程序的源代码。
译自:An In-Depth Look into the Win32 Portable Executable File Format 翻译:Jason Sun(木水鱼) 转自:深入理解 Win32 PE 文件格式