深入理解Win32PE文件格式

合集下载
  1. 1、下载文档前请自行甄别文档内容的完整性,平台不提供额外的编辑、内容补充、找答案等附加服务。
  2. 2、"仅部分预览"的文档,不可在线预览部分如存在完整性等问题,可反馈申请退款(可完整预览的文档不适用该条件!)。
  3. 3、如文档侵犯您的权益,请联系客服反馈,我们会尽快为您处理(人工客服工作时间:9:00-18:30)。

深⼊理解Win32PE⽂件格式
深⼊理解 Win32 PE ⽂件格式
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⽂件的格式。

从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个结构组成的数组。

每个数组元素都预定义了它所代表的含意。

IMAGE_DIRECTORY_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,符号名就是__imp_MyFunction。

查看⼀个导⼊库,你会发现除了正常的符号名外,也有⼀个加了__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。

对于64位PE32+⽂件,它通常是
240。

然⽽,这些值只是所要求的最⼩值,更⼤的值也可能会出现。

WORD Characteristics⼀组指⽰⽂件属性的位标。

这些标记的有效值是定义于WINNT.H⽂件中的IMAGE_FILE_xxx值。

⼀些常⽤的值在图
4中列出。

图 4 IMAGE_FILE_XXX
值描述
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
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,缺省。

相关文档
最新文档