深入探究 Win32 PE 文件格式,第二部分
2010年11月09日
Matt Pietrek 这篇文章假定你熟悉 C++ 和 Win32。
概述 Win32 可移植可执行(PE)文件格式被设计为可在所有版本的操作系统、所有受支持的处理器上都可使用的标准可执行文件格式。自从它被引入以来,PE 格式经历过一些大的变化,特别是 64 位 Windows 的出现。这篇文章的第一部分介绍了 RVA、数据目录和文件头。在第二部分将探究可执行文件中的各种节。包括导出节、导出转送、绑定和延迟加载。还包括调试目录、线程本地存储和资源节。
在这篇文章的第一部分,我全面介绍了 PE 文件。描述了 PE 文件的历史和 PE 头部中的数据结构,还有节表。PE 头和节表告诉了你在可执行文件中有着什么样的代码和数据,以及在哪儿能找到它们。
在这里我将描述常见的一些节。再讲一下我对 PEDUMP 程序的升级和改进,它可以从 2002 年 2 月的 MSDN 杂志专栏中下载。如果你对基本的 PE 文件概念不熟悉,应该首先阅读一下这篇文章的第一部分。
上个月我讲了,节是一个代码块或数据块,这些代码或数据逻辑上被组织在一起。例如,组成可执行文件的导入表的所有数据都位于一个节中。让我们看一下你在可执行文件和 OBJ 文件中将会遇到的一些节。除非另外说明,图1中显示的节名都来自于 Microsoft 的工具。
图 1 节名 名称 描述 .text 默认的代码。 .data 默认的可读写的数据节。全局变量通常位于这里。 .rdata 默认的只读数据节。字符串文本和C++/COM的vtables位于这个节中。 .idata 导入表。.idata 节通常会被合并到其它节中,典型的是 .rdata 节。默认情况下,链接器只在创建一个发布版的可执行文件时才把 .idata 节合并到其它节中。 .edata 导出表。在创建一个包含导出 API 或数据时,链接器会生成一个 .EXP 文件。这个 .EXP 文件包含一个最终会被添加到可执行文件里的 .edata 节。和 .idata 节一样,.edata 节经常会被合并到 .text 或 .rdata 节中。 .rsrc 资源。这个节是只读的。无论如何,它不应该被命名为除 .rsrc 以外的其它名字,也不应该被合并到其它节中。 .bss 未初始化数据。在当前链接器创建的可执行文件中很少见。代替的,可执行文件的 .data 节的 VirtualSize 被扩大以便为未初始化数据保留足够的空间。 .crt 为支持 C++ 运行时(CRT)而添加的数据。比如,用来调用静态 C++ 对象的构造器和析构器的函数指针。具体请参见2001年1月的 Under The Hood 专栏。 .tls 支持通过 __declspec(thread) 声明的线程局部存储变量的数据。它包含数据的初始值和运行时需要的附加变量。 .reloc 在可执行文件中的基址重定位。通常只有 DLL 才需要基址重定位而 EXE 不需要。在 Release 模式,链接器不为 EXE 文件生成基址重定位。链接时可通过选项 /FIXED 去除重定位数据。 .sdata 可通过全局指针相对寻址的"短"可读/写数据。用于IA-64和其它使用全局指针寄存器的体系上。IA-64 上的正常大小的全局变量位于这个节中。 .srdata 可通过全局指针相对寻址的"短"只读数据。用于IA-64和其它使用全局指针寄存器的体系上。 .pdata 异常表。包含一个 IMAGE_RUNTIME_FUNCTION_ENTRY 类型的结构数组,这个结构是特定于 CPU 的。数据目录中的IMAGE_DIRECTORY_ENTRY_EXCEPTION 指向它。用于使用基于表的异常处理的体系,比如 IA-64。唯一一个不使用基于表的异常处理的体系是 x86。 .debug$S OBJ 文件中 Codeview 格式的符号。这是一个可变长的 CodeView 格式符号记录流。 .debug$T OBJ 文件中 Codeview 格式的类型记录。这是一个可变长的 CodeView 格式类型记录流。 .debug$P 当使用预编译头时会出现在 OBJ 文件中。 .drectve 只用于 OBJ 文件,包含一些链接器指令。这些指令是一些能被传递到链接器命令行的 ASCII 字符串。例如:
-defaultlib:LIBC
多个指令之间用空格隔开。 .didat 延迟加载的导入数据。可在用非 Release 模式创建的可执行文件中找到它。而在Release 模式,延迟加载数据被合并到其它节中。 导出节
当一个 EXE 导出代码或数据时,它的一些函数或变量就可被其它的EXE使用。为了让事情变得简单,我将用术语"符号"来表示导出函数和导出变量。要导出一些东西,至少被导出符号的地址必须能够通过某种方式得到。每个导出符号都有一个序数号和它相关联,通过这个序数号可以查找到它。另外,导出符号几乎总是有一个 ASCII 名称。传统上,导出符号名和源文件中指定的函数名或变量名是相同的,当然它们也可以是不同的。
通常,一个可执行文件导入一个符号时使用符号名而不是序号。然而,通过名称导入时, 系统也只是使用这个名称查找到对应的导出序号,再用这个序号得到地址。如果一开始就使用序号的话会更快一点。通过名字进行导出和导入是为了方便程序员。
在 .DEF 文件的导出节使用 ORDINAL 关键字可以让链接器创建一个使API只能通过序号导入而不能通过名称导入的导入库。
我将从图2显示的 IMAGE_EXPORT_DIRECTORY 结构开始。导出目录指向三个数组和一个ASCII字符串表。只有导出地址表(EAT)是必须的,它是一个函数指针数组,包括了导出函数的地址。导出序号就是这个数组的索引(参见图 3)。
图 2 IMAGE_EXPORT_DIRECTORY 结构成员 大小 成员 描述 DWORD Characteristics 关于导出表的一些标记。目前并没有被定义。 DWORD TimeDateStamp 导出表被创建的时间/日期。这个域和IMAGE_NT_HEADERS.FileHeader.TimeDateStamp域的定义相同(自1/1/1970以来的秒数 GMT)。 WORD MajorVersion 导出表的主版本号。没有使用,并设为0。 WORD MinorVersion 导出表的次版本号。没有使用,并设为0。 DWORD Name 一个RVA,它指向一个ASCII子符串。这个字符串是和这些导出数据相关联的DLL名称。(例如KERNEL32.DLL)。 DWORD Base 这个域包含可执行文件的导出函数使用的起始序号值。通常这个值是1,但这不是必须的。通过序号查找一个导出函数时,用序号减去这个域的值,把得到的结果作为一个基于0的索引就可从导出地址表(EAT)得到地址。 DWORD NumberOfFunctions EAT中项目的个数。要注意,EAT中有些项目可能是0,这表示了对应这个序号没有代码/数据被导出。 DWORD NumberOfNames 导出名称表(ENT)中项目的个数。这个值总是小于等于NumberOfFunctions域的值。当有符号仅通过序号被导出时这个值就会比NumberOfFunctions小。当在对齐的序号中有数字间隙时也会比NumberOfFunctions小。这个域也是导出序号表的大小。 DWORD AddressOfFunctions EAT的RVA。EAT是一个RVA数组。数组中每个非0的RVA都指向一个导出符号。 DWORD AddressOfNames ENT的RVA。ENT是一个指向ASCII字符串的RVA数组。每个ASCII字符串对应一个通过名字导出的符号。这个表是被排序的,因此这些ASCII字符串是按顺序排列的。这就允许加载器查找一个导出符号时进行二分查找。名称的排序是二进制的(就像C++ RTL 的strcmp函数),而不是特定环境的字母顺序。 DWORD AddressOfNameOrdinals 导出序号表的RVA。这个表是一个WORD数组。这个表从ENT映射一个数组索引到相应的导出地址表中的项目。
图 3 IMAGE_EXPORT_DIRECTORY 结构
让我们通过一个例子来看看导出表是怎样工作的。图4显示了KERNEL32.DLL中导出表的一部分。假定你调用GetProcAddress来得到KERNEL32中的AddAtomA 函数的地址。系统首先查找KERNEL32的IMAGE_EXPORT_DIRECTORY。从那里,可得到导出名称表(ENT)的起始地址,并可知道数组中共有0x3A0个项目。系统进行二分搜索直到找到字符串"AddAtomA"。
假设加载器发现AddAtomA是数组中第二个项目。那么加载器就读取导出序号表中第二个项目的值。这个值就是AddAtomA的导出序号。把这个导出序号作为EAT的索引(并且要考虑Base域的值),可得到AddAtomA的相对虚拟地址(RVA)是0x82C2。0x82C2 再和KERNEL32的加载地址相加就得到AddAtomA的实际地址。
图 4 KERNEL32 导出表 导出转向(Export Forwarding)
导出的一个独特的特点是可以"转送"一个导出到别一个DLL中。例如Windows NT??,Windows?? 2000和Windows XP中,KERNEL32的HeapAlloc函数被转送到NTLL导出的RtlAllocHeap函数。转送是链接时通过.DEF文件的导出节中一个特殊语法实现的。用HeapAlloc作为一个例子,KERNEL32的DEF文件将包含: 如何知道一个函数是被转送导出而不是按正常方式导出的?通常,EAT中包含导出符号的RVA。可是如果函数的RVA位于导出节之中(由数据目录的VirtualAddress和Size域给出),那么符号就是被转送导出的。
当一个符号被转送导出时,显然它的RVA就不能是当前模块的代码或数据的地址,而是指向一个ASCII串,这个ASCII串是被转送到的DLL和符号名组成的字符串。在前面例子中,就是NTDLL.RtlAllocHeap。
导入节
和导出一个函数或变量相对的就是导入。为了与上一节相一致,我也将使用术语"符号"来表示导入函数和导入变量。
导入数据位于 IMAGE_IMPORT_DESCRIPTOR 结构中。数据目录中用于导入表的那个项目指向一个这个结构的数组。对应于每个被导入的可执行文件都有一个IMAGE_IMPORT_DESCRIPTOR 结构。这个数组的末尾用一个所有域都是0的IMAGE_IMPORT_DESCRIPTOR 结构指出。图5显示了 IMAGE_IMPORT_DESCRIPTOR.结构的内容。
图 5 IMAGE_IMPORT_DESCRIPTOR 结构 大小 成员 描述 DWORD OriginalFirstThunk 这个域的命名不太合适。包含导入名称表(INT)的RVA。INT是一个IMAGE_THUNK_DATA 类型的结构数组。这个域被设为0表示已到达IMAGE_IMPORT_DESCRIPTOR结构数组的末尾。 DWORD TimeDateStamp 如果可执行文件不与被导入DLL绑定时为0。当以旧的样式绑定时(参见下面绑定一节),这个域包含时间/日期戳(number of seconds since 1/1/1970 GMT)。当以新的样式绑定时,这个域被设为-1。 DWORD ForwarderChain 这是第一个被转送API的索引。如果没有转送则设为-1。只用于旧样式的绑定,它不能有效地处理转送API。 DWORD Name 被导入DLL的ASCII名称的RVA。 DWORD FirstThunk 包含导入地址表(IAT)的RVA。IAT是一个IMAGE_THUNK_DATA类型的结构数组。 每个IMAGE_IMPORT_DESCRIPTOR都指向两个本质上相同的数组。这些数组有好几个名称,但最常用的两个名称是导入地址表(IAT)和导入名称表(INT)。图6显示了一个可执行文件正在从USER32.DLL导入一些API。
图 6 两个平行的指针数组
两个数组的元素的类型都是 IMAGE_THUNK_DATA,它是一个联合。每个IMAGE_THUNK_DATA 元素对应于一个被导入函数。这两个数组的末尾都通过一个值为0的 IMAGE_THUNK_DATA 元素指出。IMAGE_THUNK_DATA 联合是一个 DWORD,解释如下:
DWORD Function; // 导入函数的内存地址
DWORD Ordinal; // 导入API的序号
DWORD AddressOfData; // 指向一个包含导入API名称的IMAGE_IMPORT_BY_NAME的RVA
DWORD ForwarderString;// RVA指向一个转送字符串
IAT 中的 IMAGE_THUNK_DATA 结构有着双重含义。在可执行文件中,它们包含的是导入 API 的序号或者是一个指向 IMAGE_IMPORT_BY_NAME 结构的 RVA。IMAGE_IMPORT_BY_NAME 结构由一个 WORD 和后面跟着的导入 API 的名称字符串组成。这个 WORD 值对加载器是一个"提示",它指出了导入API的序号是多少。当加载器把可执行文件加载到内存中时,它用导入函数的实际地址重写IAT中的每个项目。理解这一点很关键。我强烈建议阅读 Russell Osterlund 的关于这个问题的文章,其中描述了Windows 加载器是如何进行处理的。
在可执行文件被加载之前,如何知道 IMAGE_THUNK_DATA 结构包含的是导入序号还是指向一个 IMAGE_IMPORT_BY_NAME 结构的 RVA 呢?关键在于IMAGE_THUNK_DATA 值的高位。如果被置位,低31位(对于64位可执行文件是低63位)被作为一个序号值。如果高位没有被置位,IMAGE_THUNK_ DATA 值就是一个指向IMAGE_IMPORT_BY_NAME 结构的 RVA。
另一个数组,也就是 INT,它本质上和 IAT 是相同的。它也是一个IMAGE_THUNK_DATA 类型的结构数组。主要的不同是加载到内存中后 INT 不会被加载器重写。为什么从一个 DLL 导入的每组 API 会有两个并行的数组呢?答案是在一个被称作绑定的概念中。当绑定进程重写文件中的 IAT 时(稍后描述),必须保留获取原始信息的方法。这个信息就保留在 INT 中。
加载一个可执行文件 INT 并不是必须的。然而,如果没有 INT,这个可执行文件就不能被绑定。Microsoft 的链接器似乎总是生成一个 INT,但很长一段时间,Borland的链接器(TLINK)却不生成。因此 Borland 链接器创建的可执行文件不能被绑定。
在早期的 Microsoft 链接器中,导入节对于链接器并不是特殊的。组成导入表的所有数据都来自于导入库。用 Dumpbin 或 PEDUMP 查看一个导入库时可以看到那些数据。你会发现存在一些像 .idata$3 和 .idata$4 的节。链接器只是简单的遵守它的规则合并节,于是所有的结构和数组都被放到了正确的位置。几年前,Microsoft 提出了一个新的导入库格式,可以创建更小的导入库,从而使链接器在创建导入数据时更具有主动性。
绑定
当可执行文件被绑定时(例如通过绑定程序),IAT 中的 IMAGE_THUNK_DATA 结构被用导入函数的实际地址重写。磁盘中的可执行文件的 IAT 中保存的是其它 DLL 的 API在内存中的实际地址。那么加载一个被绑定的可执行文件时,Windows 加载器就可以不必查找每个导入的 API 并把其地址写到 IAT 中。正确的地址已经在那儿了!然而,前提是必须正确对齐才行。我的 2000 年 5 月的专栏中包含一些测试基准,是关于绑定可执行文件可以得到多大的加载性能的提升的。
你也许会怀疑绑定可执行文件的安全性。如果绑定到可执行文件中的DLL发生改变时会怎么样呢?发生这种情况时,IAT中的所有地址就无效了。加载器会检测这种情况并从而进行处理。如果检测到IAT中的地址无效了,加载器就会根据INT中的信息重新获取导入API的地址。
绑定你的程序的最佳时机是在安装时。Windows installer的BindImage将会为你作这些工作。另外,IMAGEHLP.DLL中提供了BindImageEx函数也可以进行绑定。不管用哪种方法,绑定都是一个好注意。如果加载器认为绑定信息是正确的,可执行文件就会加载的更快。如果绑定信息过时了,也不会影响你的程序。
对于加载器,使绑定有效的一个关键步骤是确定 IAT 中的绑定信息是否有效。当可执行文件被绑定时,所引用的 DLL 的信息将被放到这个可执行文件中。加载器会检测这些信息以快速确定绑定的有效性。在绑定的最初实现中并没有添加这些信息。因此,可执行文件可以被按旧的方式绑定或者新的方式绑定。而在这里我将描述新的方法。
用于验证绑定的有效性的关键数据结构是 IMAGE_BOUND_IMPORT_DESCRIPTOR。被绑定的可执行文件中包含一个这些结构的列表。其中每个IMAGE_BOUND_IMPORT_DESCRIPTOR 结构都指出了一个被绑定的导入DLL的时间/日期戳。数据目录中的 IMAGE_DIRECTORY_ENTRY_BOUND_IMPORT 元素给出了这个列表的 RVA。IMAGE_BOUND_IMPORT_DESCRIPTOR 结构的元素是: TimeDateStamp,一个DWORD,包含了被导入DLL的时间/日期戳。
OffsetModuleName,一个WORD,包含了指向一个被导入DLL的名称字符串的偏移。这个域是从第一个IMAGE_BOUND_IMPORT_DESCRIPTOR 结构的偏移(而不是一个RVA)。
NumberOfModuleForwarderRefs,一个WORD,包含了这个结构后面紧跟的IMAGE_BOUND_FORWARDER_REF结构的数目。除最后一个WORD(NumberOfModuleForwarderRefs)被保留之外,这些结构和IMAGE_BOUND_IMPORT_DESCRIPTOR是一样的。
简单情况下,对应于每个被导入DLL的那些IMAGE_BOUND_IMPORT_DESCRIPTOR结构组成了一个数组。但是,当绑定一个被转送到其它DLL的API时,也必须检测被转送到的那个DLL的有效性。因而,IMAGE_BOUND_FORWARDER_REF结构和IMAGE_BOUND_IMPORT_DESCRIPTOR是交叉存放的。
假设你链接到了HeapAlloc,而它被转送到了NTDLL中的RtlAllocateHeap。然后对你的可执行文件运行BIND。在你的EXE文件中,将会有一个对应于KERNEL32.DLL的IMAGE_BOUND_IMPORT_DESCRIPTOR结构,它后面是一个对应于NTDLL.DLL的IMAGE_BOUND_FORWARDER_REF结构。紧跟着的可能是对应于你导入并绑定的其它DLL的IMAGE_ BOUND_IMPORT_DESCRIPTOR结构。
延迟加载数据
前面我讲的延迟加载DLL是一个介于隐式导入和通过LoadLibrary与GetProcAddress显示导入API之间的混合导入方式。现在我们来看一下相关的数据结构和延迟加载是如何工作的。
要记住延迟加载不是操作系统的特性。它是由链接器附加的一些代码和数据以及运行时库实现的。因此,你无法在WINNT.H中找到关于延迟加载的信息。然而,你能发现一些在延迟加载数据和正常导入数据之间类似的东西。
数据目录中的IMAGE_DIRECTORY_ENTRY_DELAY_IMPORT条目指向延迟加载数据。它是一个指向ImgDelayDescr结构数组的RVA,这个结构定义在Visual C++中的DelayImp.H。图 7显示了它的内容。对于每个被延迟导入的DLL都有一个ImgDelayDescr结构。
对于ImgDelayDescr结构关键的东西是它包含了其对应DLL的一个IAT和一个INT的地址。这些表的格式与正常导入时的对应表的格式是一样的,只是它们是由运行时库读写的而不是操作系统。当你第一次调用一个延迟加载DLL的API时,运行时调用LoadLibrary (如果需要),然后调用GetProcAddress。获取到的地址被保存在延迟加载IAT中,以后再调用时就可以直接得到那个API的地址。
关于延迟加载数据有一点需要说明。Visual C++ 6.0中,ImgDelayDescr的所有包含地址的域使用的是虚拟地址,而不是RVA。就是说,它们包含的是被延迟加载的数据的实际地址。这些域都是DWORD,x86下一个指针的大小。
现在要支持IA-64。于是,4个字节就不足以保存一个完整的地址。在这一点上,Microsoft 做了件正确的事情,即把包含地址的域改为RVA。如图7所示,我用的是修改过的结构定义的名称。
确定ImgDelayDescr中使用的是RVA还是虚拟地址就成了另外一个问题。这个结构有一个域可保存一些标记。当grAttrs域的"1"位打开时,结构成员就被看作RVA。这是和Visual Studio?? .NET与64位编译器一起出现的唯一一个选项。如果grAttrs中的那个位关掉,ImgDelayDescr结构的域就是虚拟地址。
图 7 ImgDelayDescr结构 大小 成员 描述 DWORD grAttrs 这个结构的属性。目前,唯一被定义的标记是dlattrRva (1),它表示这个结构的地址域应该被看作RVA,而不是虚拟地址。 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的导入地址表的绑 定拷贝。它与正常IAT的格式是一样的。目前,这个IAT的拷贝实际上并没有被绑定,但这个特性在以后版本的BIND程序中可能会被添加。 RVA rvaUnloadIAT 原始IAT的可选拷贝的RVA。指向这个DLL的导入地址表的未绑定拷贝。它和正常IAT的格式是一样的。目前总是被设为0。 DWORD dwTimeStamp 延迟导入DLL的日期/时间戳。通常被设为0。 资源节
PE文件的所有节中,资源是最复杂的。这里,我只描述一些数据结构,这些数据结构是用来找到实际资源数据的,例如图标、位图和对话框。而不会去探究资源数据的实际格式,因为那已经超出本文的范围了。
资源位于被称为.rsrc的节中。数据目录的IMAGE_DIRECTORY_ENTRY_RESOURCE条目包含了资源的RVA和大小。由于各种原因,资源用类似文件系统的方式组织,它也有目录和叶结点。
数据目录中的资源指针指向一个IMAGE_RESOURCE_DIRECTORY类型的结构。这个结构包含了未使用的Characteristic,TimeDateStamp和版本号域。我们只对两个域感兴趣,就是NumberOfNamedEntries和NumberOfIdEntries。
IMAGE_RESOURCE_DIRECTORY结构的后面是一个IMAGE_RESOURCE _DIRECTORY_ENTRY类型的结构数组。IMAGE_RESOURCE_DIRECTORY中的NumberOfNamedEntries和NumberOfIdEntries两个域的值之和就是这个数组中IMAGE_RESOURCE_DIRECTORY_ENTRY结构的数目。
目录条目即可以指向另一个资源目录也可以指向某个资源的数据。当目录条目指向另一个资源目录时,结构中第2个DWORD的高位被置位并且剩下的31位是指向那个资源目录的偏移。这个偏移是相对于资源节的开始处而不是一个RVA。
当目录条目指向一个实际资源实例时,第2个DWORD的高位被清除。剩下的31位是到资源实例(例如一个对话框)的偏移。这个偏移也是相对于资源节,面不是一个RVA。
目录条目可以被命名也可以通过一个ID值来标识。这和你在.RC文件中为一个资源实例指定一个名称或者ID是一样的。在目录条目中,当第一个DWORD的高位被置位时,剩下的31位是到资源名称字符串的偏移。如果高位被清除,低16位包含的是序数标识符。
理论知识已经足够了!让我们查看一个实际的资源节并解释它的含义。图8 显示了PEDUMP输出的ADVAPI32.DLL的资源的一小部分。每个以"ResDir"开头的行对应于一个IMAGE_RESOURCE_DIRECTORY结构。跟"ResDir"后面用圆括号括起来的是资源目录的名称。在这个例子中,资源目录的名称分别是0,MOFDATA,MOFRESOURCENAME,STRING,C36,RCDATA和66。名称后面是目录条目的总数(包括被命名的和以ID标识的)。这个例子中,顶层目录有三个目录条目,而所有其它目录都只有一个条目。
图 8 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
顶层目录和文件系统的根目录相类似。"根"下面的每个目录条目总是一个目录。每个二级目录对应于一种资源类型(字符串表,对话框,菜单等等)。在每个二级"资源类型"目录下面,是三级子目录。
对于每个资源实例都有三级子目录。例如,如果有五个对话框,就会有一个二级的DIALOG目录,它下面有五个目录条目。这五个目录条目都是一个目录。它们的名称对应于资源实例的名称或ID。在这些目录的每个下面只有一个条目,它包含了到资源数据的偏移。挺简单的,不是吗?
如果你认为通过阅读源代码来学习更有效率的话,可以看一下 PEDUMP 中的 dump 资源的那部分代码。除了显示所有的资源目录以及它们的条目之外,PEDUMP 也显示几种常见的资源类型,例如对话框。
基址重定位
你可以在可执行文件的许多地方发现一些内存地址。当链接一个可执行文件时,它被指定一个首选加载地址。只有当可执行文件加载到IMAGE_FILE_HEADER结构的ImageBase域指定的首选加载地址时那些内存地址才是正确的。
如果加载器必须把DLL加载到另外一个地址,那么可执行文件中的所有地址就会是错误的。这就使加载器必须作一些额外的工作。2000年5月的 Under The Hood专栏(前面提到过)描述了当多个DLL有着相同的首选加载地址时的冲突和REBASE工具怎样帮助解决这个冲突。
基址重定位告诉加载器如果可执行文件没有被加载到首选加载地址时这个可执行文件中的哪些地方需要被修改。幸运的是,加载器不必知道地址是如何被使用的任何细节。它只须知道有一个位置列表需要通过某种一致的方式来修改。
让我们来看一个基于x86的例子。假设你有下面一条指令,它读取某个全局变量(地址是0x0040D434)的值到ECX寄存器中。
00401020: 8B 0D 34 D4 40 00 mov ecx,dword ptr [0x0040D434]
这条指令位于地址0x00401020处,长度是6个字节。前面两个字节(0x8B 0x0D)是这个指令的操作码。剩下的4个字节保存了一个DWORD表示的地址 (0x0040D434)。在这个例子中,这条指令来自于一个首选加载地址是0x00400000的可执行文件。那个全局变量的RVA是0xD434。
如果可执行文件确实被加载到了地址0x00400000处,这条指令可以正确地运行。但是,假设不知什么原因可执行文件被加载到了地址0x00500000处。那么这条指令的最后四个字节必须被改为0x0050D434。
加载器如何进行修改呢?加载器会比较首选加载地址和实际加载地址并计算出一个差值。在这个例子中,差值就是0x00100000。把这个差值和那个DWORD表示的地址值相加就得到了变量的新地址。在前面例子中,对应于0x00401022地址处会有一个基址重定位,它是这条指令中的DWORD的位置。
基址重定位就是可执行文件中的一些内存位置的一个列表,一个差值必须被添加到那些内存处的内容上。可执行文件的页只有在需要时才被加载到内存中,基址重定位信息的格式反映了这种特性。基址重定位存在于一个被称为 .reloc 的节中,通过数据目录的IMAGE_DIRECTORY_ENTRY_BASERELOC 条目可以找到它。
基址重定位是一系列很简单的 IMAGE_BASE_RELOCATION 结构。VirtualAddress 域包含了重定位项所属内存区域的 RVA。SizeOfBlock 域指出了重定位信息的字节数,其中包括 IMAGE_BASE_RELOCATION 结构的大小。 紧跟IMAGE_BASE_RELOCATION结构之后的是一系列可变数目的WORD 值。这些WORD的数目可由SizeOfBlock域推断出。每个WORD由两部分组成。高4位指出了重定位的类型,WINNT.H中的一系列IMAGE_REL_BASED_xxx定义了重定位类型的取值。低12位是相对于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的EXE中的页大小是8KB,但基址重定位仍然使用4KB的块。
在Visual C++ 6.0中,Build一个发布版的EXE时链接器不生成重定位信息。这是因为EXE是首先被加载到地址空间中的,它总是会被加载到首选加载地址处。而DLL文件却不是这样的,所以基址重定位总是存在,除非你用/FIXED选项忽略它们。在Visual Studio .NET中,链接器会为Debug和Release模式的EXE文件都忽略掉重定位信息。
调试目录
生成一个包含调试信息的可执行文件时,按惯例应包含关于调试信息的格式以及位置的细节。操作系统运行一个可执行文件时不需要调试信息,但对于开发工具却很有用。一个EXE 可以有多种格式的调试信息;而数据结构调试目录指出了哪种格式可用。
通过数据目录的 IMAGE_DIRECTORY_ENTRY_DEBUG 条目可以找到调试目录。它由一个 IMAGE_DEBUG_DIRECTORY 类型的结构(参见图9)数组组成,其中每一个对应于一种调试信息类型。调试目录中元素的数目可以由数据目录中的 Size 域计算得到。
图 9 IMAGE_DEBUG_DIRECTORY的域 大小 成员 描述 DWORD Characteristics 没有使用并被设为0。 DWORD TimeDateStamp 调试信息的时间/日期戳(自1/1/1970经过的秒数,GMT)。 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样式的调试信息演化而来的。PDB 信息的存在是由一个IMAGE_DEBUG_TYPE_CODEVIEW 类型的调试目录条目指明的。如果你检查这个条目所指向的数据,你会发现一个短的 CodeView 样式头。这些调试数据大多是外部 PDB 文件的路径。在 Visual Studio 6.0 中,这个调试头以一个 NB10 签名开始。而在 Visual Studio .NET 中,以一个 RSDS 开头。
在 Visual Studio 6.0中,/DEBUGTYPE:COFF 链接器选项可以生成 COFF 调试信息。Visual Studio .NET 中取消了这个选项。帧指针省略(FPO)调试信息是随着优化的 x86 代码出现的,那里,函数可以没有一个常规的堆栈框架。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。这种方式类似于Visual Basic(在.NET之前)中的程序使用MSVBVM60.DLL的方式。.NET信息的起始点是IMAGE_COR20_HEADER结构,其定义在.NET Framework SDK中的CorHDR.H和最近版本的WINNT.H中。数据目录中的IMAGE_DIRECTORY_ENTRY_COM_DESCRIPTOR 条目指向这个IMAGE_COR20_HEADER结构。图10显示了IMAGE_COR20_HEADER中的域。IMAGE_COR20_HEADER所指向的元数据的格式以及其它的东西在以后的文章中描述。
图 10 IMAGE_COR20_HEADER结构 类型 成员 描述 DWORD cb 头部的字节大小。 WORD MajorRuntimeVersion 运行这个程序所需要的最小运行时版本。对于.NET的第一个发布版本,这个值是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/runtime为方法
// 保持调试信息。 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 一个需要进行修正的函数指针数组的RVA。这是为了支持非托管C++ vtables。 IMAGE_DATA_DIRECTORY ExportAddressTableJumps 一个 RVA 数组的 RVA。这个数组是用于导出的 JMP thunk 所写入的位置。这些thunk 允许托管方法被导出以便非托管代码可以调用它们。 IMAGE_DATA_DIRECTORY ManagedNativeHeader 由.NET运行时内部使用。在可执行文件设为0。 TLS初始化
当使用由__declspec(thread)声明的线程局部变量时,编译器把它们放在一个称为.tls的节中。当系统发现正在启动一个新的线程时,它从进程堆中分配内存以保存这个线程的线程局部变量。这些内存用.tls节中的值初始化。系统也在FS:[2Ch](x86体系)所指向的TLS数组中放置一个指针,指向被分配的内存。(on the x86 architecture).
数据目录中的IMAGE_DIRECTORY_ENTRY_TLS 条目指出了可执行文件中的线程局部存储(TLS)数据。如果非0,这个条目指向一个IMAGE_TLS_DIRECTORY结构,如图11所示。
有一点很重要,IMAGE_TLS_DIRECTORY 结构中的地址是虚拟地址而不是RVA。因此,如果可执行文件没有加载到它的首选加载地址的话这些地址就必须由基址重定位进行修正。另外,IMAGE_TLS_DIRECTORY本身并不在.tls节中,而是位于.rdata节。
图 11 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。 程序异常数据
一些体系结构(包括IA-64)并不像x86那样使用基于框架的异常处理。代替的,它们使用基于表的异常处理,表中包含了可能被异常展开影响到的每个函数的信息。这些信息包括函数的起始地址,结束地址和关于异常应该怎样以及在哪儿被处理的信息。当一个异常发生时,系统搜索这个表以定位适当的入口并且处理它。异常表是一个IMAGE_RUNTIME_FUNCTION_ENTRY 类型的结构数组。数据目录中的IMAGE_DIRECTORY_ENTRY_EXCEPTION条目指向这个数组。不同的体系中IMAGE_RUNTIME_FUNCTION_ENTRY结构有不同的格式。对于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 运行时头
除了可以导出 PE 可执行文件外,PEDUMP 也可以导出 COFF 格式的 OBJ 文件、COFF导入库 (新的和旧的格式)、COFF 符号表和 DBG 文件。
PEDUMP 是一个命令行程序。运行它导出上述某个类型的文件并且不带任何选项就是默认导出,可以包含更有用的数据结构信息。有几个命令行选项可以控制其输出(见图12)。
图 12命令行选项 /A 包含所有东西 /B 显示基址重定位 /H 包含节的 16 进制形式的数据 /I 包含导入地址表 thunk 的地址 /L 包括行号信息 /P 包括PDATA (运行时函数) /R 包括详细的资源(字符串表和对话框) /S 显示符号表 PEDUMP 的源代码有几个地方值得注意。它可以按 32 位或 64 位编译和运行。如果你手边有 Itanium 机器可以试一下。另外,PEDUMP 可以 dump 32 位和 64 位的可执行文件而不管它是如何被编译的。换句话说,32 位版本的 PEDUMP 可以 dump 32 位和 64 位的文件;并且 64 位版本的 PEDUMP 也可以 dump 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 path 的最上方。也需要确保正确设置了 64 位包含文件和库文件的路径。我机器上的工程文件已经进行了正确的设置,但在你的机器中则需要进行更改。
为了使 PEDUMP 尽可能的完善,必须使用最新版本的 Windows 头文件。我在开发 PEDUMP 时使用的是 2001 年 6 月的 Platform SDK,所需要的头文件位于 .\include\prerelease 和 .\Include\Win64\crt\ 目录中。而在 2001 年 8 月的 SDK 中,WINNT.H 已得到更新,因此不再需要使用 prerelease 目录了。重点是可以成功构建代码。你只需要安装最新的 Platform SDK 或者在构建 64 位版本时修改工程目录设置就行了。
结束语
PE格式是一个具有良好结构并且相对简单的可执行文件格式。PE文件可以被直接映射到内存,所以磁盘文件中的数据结构和在运行时Windows使用的数据结构是相同的。这一点非常好。在过去10年所发生的所有变化,包括转变到64位的Windows和.NET,PE格式都可以很好的支持,这太使我惊奇了。
尽管本文包含了 PE 文件的许多方面,但仍有一些主题没有涉及到。比如标记、属性和一些很少见的数据结构等,我决定不在这里描述它们。但是,我希望这篇文章对 PE 文件的讲解可以使你更容易的理解 Microsoft 的 PE 规范
2010年11月09日
Matt Pietrek 这篇文章假定你熟悉 C++ 和 Win32。
概述 Win32 可移植可执行(PE)文件格式被设计为可在所有版本的操作系统、所有受支持的处理器上都可使用的标准可执行文件格式。自从它被引入以来,PE 格式经历过一些大的变化,特别是 64 位 Windows 的出现。这篇文章的第一部分介绍了 RVA、数据目录和文件头。在第二部分将探究可执行文件中的各种节。包括导出节、导出转送、绑定和延迟加载。还包括调试目录、线程本地存储和资源节。
在这篇文章的第一部分,我全面介绍了 PE 文件。描述了 PE 文件的历史和 PE 头部中的数据结构,还有节表。PE 头和节表告诉了你在可执行文件中有着什么样的代码和数据,以及在哪儿能找到它们。
在这里我将描述常见的一些节。再讲一下我对 PEDUMP 程序的升级和改进,它可以从 2002 年 2 月的 MSDN 杂志专栏中下载。如果你对基本的 PE 文件概念不熟悉,应该首先阅读一下这篇文章的第一部分。
上个月我讲了,节是一个代码块或数据块,这些代码或数据逻辑上被组织在一起。例如,组成可执行文件的导入表的所有数据都位于一个节中。让我们看一下你在可执行文件和 OBJ 文件中将会遇到的一些节。除非另外说明,图1中显示的节名都来自于 Microsoft 的工具。
图 1 节名 名称 描述 .text 默认的代码。 .data 默认的可读写的数据节。全局变量通常位于这里。 .rdata 默认的只读数据节。字符串文本和C++/COM的vtables位于这个节中。 .idata 导入表。.idata 节通常会被合并到其它节中,典型的是 .rdata 节。默认情况下,链接器只在创建一个发布版的可执行文件时才把 .idata 节合并到其它节中。 .edata 导出表。在创建一个包含导出 API 或数据时,链接器会生成一个 .EXP 文件。这个 .EXP 文件包含一个最终会被添加到可执行文件里的 .edata 节。和 .idata 节一样,.edata 节经常会被合并到 .text 或 .rdata 节中。 .rsrc 资源。这个节是只读的。无论如何,它不应该被命名为除 .rsrc 以外的其它名字,也不应该被合并到其它节中。 .bss 未初始化数据。在当前链接器创建的可执行文件中很少见。代替的,可执行文件的 .data 节的 VirtualSize 被扩大以便为未初始化数据保留足够的空间。 .crt 为支持 C++ 运行时(CRT)而添加的数据。比如,用来调用静态 C++ 对象的构造器和析构器的函数指针。具体请参见2001年1月的 Under The Hood 专栏。 .tls 支持通过 __declspec(thread) 声明的线程局部存储变量的数据。它包含数据的初始值和运行时需要的附加变量。 .reloc 在可执行文件中的基址重定位。通常只有 DLL 才需要基址重定位而 EXE 不需要。在 Release 模式,链接器不为 EXE 文件生成基址重定位。链接时可通过选项 /FIXED 去除重定位数据。 .sdata 可通过全局指针相对寻址的"短"可读/写数据。用于IA-64和其它使用全局指针寄存器的体系上。IA-64 上的正常大小的全局变量位于这个节中。 .srdata 可通过全局指针相对寻址的"短"只读数据。用于IA-64和其它使用全局指针寄存器的体系上。 .pdata 异常表。包含一个 IMAGE_RUNTIME_FUNCTION_ENTRY 类型的结构数组,这个结构是特定于 CPU 的。数据目录中的IMAGE_DIRECTORY_ENTRY_EXCEPTION 指向它。用于使用基于表的异常处理的体系,比如 IA-64。唯一一个不使用基于表的异常处理的体系是 x86。 .debug$S OBJ 文件中 Codeview 格式的符号。这是一个可变长的 CodeView 格式符号记录流。 .debug$T OBJ 文件中 Codeview 格式的类型记录。这是一个可变长的 CodeView 格式类型记录流。 .debug$P 当使用预编译头时会出现在 OBJ 文件中。 .drectve 只用于 OBJ 文件,包含一些链接器指令。这些指令是一些能被传递到链接器命令行的 ASCII 字符串。例如:
-defaultlib:LIBC
多个指令之间用空格隔开。 .didat 延迟加载的导入数据。可在用非 Release 模式创建的可执行文件中找到它。而在Release 模式,延迟加载数据被合并到其它节中。 导出节
当一个 EXE 导出代码或数据时,它的一些函数或变量就可被其它的EXE使用。为了让事情变得简单,我将用术语"符号"来表示导出函数和导出变量。要导出一些东西,至少被导出符号的地址必须能够通过某种方式得到。每个导出符号都有一个序数号和它相关联,通过这个序数号可以查找到它。另外,导出符号几乎总是有一个 ASCII 名称。传统上,导出符号名和源文件中指定的函数名或变量名是相同的,当然它们也可以是不同的。
通常,一个可执行文件导入一个符号时使用符号名而不是序号。然而,通过名称导入时, 系统也只是使用这个名称查找到对应的导出序号,再用这个序号得到地址。如果一开始就使用序号的话会更快一点。通过名字进行导出和导入是为了方便程序员。
在 .DEF 文件的导出节使用 ORDINAL 关键字可以让链接器创建一个使API只能通过序号导入而不能通过名称导入的导入库。
我将从图2显示的 IMAGE_EXPORT_DIRECTORY 结构开始。导出目录指向三个数组和一个ASCII字符串表。只有导出地址表(EAT)是必须的,它是一个函数指针数组,包括了导出函数的地址。导出序号就是这个数组的索引(参见图 3)。
图 2 IMAGE_EXPORT_DIRECTORY 结构成员 大小 成员 描述 DWORD Characteristics 关于导出表的一些标记。目前并没有被定义。 DWORD TimeDateStamp 导出表被创建的时间/日期。这个域和IMAGE_NT_HEADERS.FileHeader.TimeDateStamp域的定义相同(自1/1/1970以来的秒数 GMT)。 WORD MajorVersion 导出表的主版本号。没有使用,并设为0。 WORD MinorVersion 导出表的次版本号。没有使用,并设为0。 DWORD Name 一个RVA,它指向一个ASCII子符串。这个字符串是和这些导出数据相关联的DLL名称。(例如KERNEL32.DLL)。 DWORD Base 这个域包含可执行文件的导出函数使用的起始序号值。通常这个值是1,但这不是必须的。通过序号查找一个导出函数时,用序号减去这个域的值,把得到的结果作为一个基于0的索引就可从导出地址表(EAT)得到地址。 DWORD NumberOfFunctions EAT中项目的个数。要注意,EAT中有些项目可能是0,这表示了对应这个序号没有代码/数据被导出。 DWORD NumberOfNames 导出名称表(ENT)中项目的个数。这个值总是小于等于NumberOfFunctions域的值。当有符号仅通过序号被导出时这个值就会比NumberOfFunctions小。当在对齐的序号中有数字间隙时也会比NumberOfFunctions小。这个域也是导出序号表的大小。 DWORD AddressOfFunctions EAT的RVA。EAT是一个RVA数组。数组中每个非0的RVA都指向一个导出符号。 DWORD AddressOfNames ENT的RVA。ENT是一个指向ASCII字符串的RVA数组。每个ASCII字符串对应一个通过名字导出的符号。这个表是被排序的,因此这些ASCII字符串是按顺序排列的。这就允许加载器查找一个导出符号时进行二分查找。名称的排序是二进制的(就像C++ RTL 的strcmp函数),而不是特定环境的字母顺序。 DWORD AddressOfNameOrdinals 导出序号表的RVA。这个表是一个WORD数组。这个表从ENT映射一个数组索引到相应的导出地址表中的项目。
图 3 IMAGE_EXPORT_DIRECTORY 结构
让我们通过一个例子来看看导出表是怎样工作的。图4显示了KERNEL32.DLL中导出表的一部分。假定你调用GetProcAddress来得到KERNEL32中的AddAtomA 函数的地址。系统首先查找KERNEL32的IMAGE_EXPORT_DIRECTORY。从那里,可得到导出名称表(ENT)的起始地址,并可知道数组中共有0x3A0个项目。系统进行二分搜索直到找到字符串"AddAtomA"。
假设加载器发现AddAtomA是数组中第二个项目。那么加载器就读取导出序号表中第二个项目的值。这个值就是AddAtomA的导出序号。把这个导出序号作为EAT的索引(并且要考虑Base域的值),可得到AddAtomA的相对虚拟地址(RVA)是0x82C2。0x82C2 再和KERNEL32的加载地址相加就得到AddAtomA的实际地址。
图 4 KERNEL32 导出表 导出转向(Export Forwarding)
导出的一个独特的特点是可以"转送"一个导出到别一个DLL中。例如Windows NT??,Windows?? 2000和Windows XP中,KERNEL32的HeapAlloc函数被转送到NTLL导出的RtlAllocHeap函数。转送是链接时通过.DEF文件的导出节中一个特殊语法实现的。用HeapAlloc作为一个例子,KERNEL32的DEF文件将包含: 如何知道一个函数是被转送导出而不是按正常方式导出的?通常,EAT中包含导出符号的RVA。可是如果函数的RVA位于导出节之中(由数据目录的VirtualAddress和Size域给出),那么符号就是被转送导出的。
当一个符号被转送导出时,显然它的RVA就不能是当前模块的代码或数据的地址,而是指向一个ASCII串,这个ASCII串是被转送到的DLL和符号名组成的字符串。在前面例子中,就是NTDLL.RtlAllocHeap。
导入节
和导出一个函数或变量相对的就是导入。为了与上一节相一致,我也将使用术语"符号"来表示导入函数和导入变量。
导入数据位于 IMAGE_IMPORT_DESCRIPTOR 结构中。数据目录中用于导入表的那个项目指向一个这个结构的数组。对应于每个被导入的可执行文件都有一个IMAGE_IMPORT_DESCRIPTOR 结构。这个数组的末尾用一个所有域都是0的IMAGE_IMPORT_DESCRIPTOR 结构指出。图5显示了 IMAGE_IMPORT_DESCRIPTOR.结构的内容。
图 5 IMAGE_IMPORT_DESCRIPTOR 结构 大小 成员 描述 DWORD OriginalFirstThunk 这个域的命名不太合适。包含导入名称表(INT)的RVA。INT是一个IMAGE_THUNK_DATA 类型的结构数组。这个域被设为0表示已到达IMAGE_IMPORT_DESCRIPTOR结构数组的末尾。 DWORD TimeDateStamp 如果可执行文件不与被导入DLL绑定时为0。当以旧的样式绑定时(参见下面绑定一节),这个域包含时间/日期戳(number of seconds since 1/1/1970 GMT)。当以新的样式绑定时,这个域被设为-1。 DWORD ForwarderChain 这是第一个被转送API的索引。如果没有转送则设为-1。只用于旧样式的绑定,它不能有效地处理转送API。 DWORD Name 被导入DLL的ASCII名称的RVA。 DWORD FirstThunk 包含导入地址表(IAT)的RVA。IAT是一个IMAGE_THUNK_DATA类型的结构数组。 每个IMAGE_IMPORT_DESCRIPTOR都指向两个本质上相同的数组。这些数组有好几个名称,但最常用的两个名称是导入地址表(IAT)和导入名称表(INT)。图6显示了一个可执行文件正在从USER32.DLL导入一些API。
图 6 两个平行的指针数组
两个数组的元素的类型都是 IMAGE_THUNK_DATA,它是一个联合。每个IMAGE_THUNK_DATA 元素对应于一个被导入函数。这两个数组的末尾都通过一个值为0的 IMAGE_THUNK_DATA 元素指出。IMAGE_THUNK_DATA 联合是一个 DWORD,解释如下:
DWORD Function; // 导入函数的内存地址
DWORD Ordinal; // 导入API的序号
DWORD AddressOfData; // 指向一个包含导入API名称的IMAGE_IMPORT_BY_NAME的RVA
DWORD ForwarderString;// RVA指向一个转送字符串
IAT 中的 IMAGE_THUNK_DATA 结构有着双重含义。在可执行文件中,它们包含的是导入 API 的序号或者是一个指向 IMAGE_IMPORT_BY_NAME 结构的 RVA。IMAGE_IMPORT_BY_NAME 结构由一个 WORD 和后面跟着的导入 API 的名称字符串组成。这个 WORD 值对加载器是一个"提示",它指出了导入API的序号是多少。当加载器把可执行文件加载到内存中时,它用导入函数的实际地址重写IAT中的每个项目。理解这一点很关键。我强烈建议阅读 Russell Osterlund 的关于这个问题的文章,其中描述了Windows 加载器是如何进行处理的。
在可执行文件被加载之前,如何知道 IMAGE_THUNK_DATA 结构包含的是导入序号还是指向一个 IMAGE_IMPORT_BY_NAME 结构的 RVA 呢?关键在于IMAGE_THUNK_DATA 值的高位。如果被置位,低31位(对于64位可执行文件是低63位)被作为一个序号值。如果高位没有被置位,IMAGE_THUNK_ DATA 值就是一个指向IMAGE_IMPORT_BY_NAME 结构的 RVA。
另一个数组,也就是 INT,它本质上和 IAT 是相同的。它也是一个IMAGE_THUNK_DATA 类型的结构数组。主要的不同是加载到内存中后 INT 不会被加载器重写。为什么从一个 DLL 导入的每组 API 会有两个并行的数组呢?答案是在一个被称作绑定的概念中。当绑定进程重写文件中的 IAT 时(稍后描述),必须保留获取原始信息的方法。这个信息就保留在 INT 中。
加载一个可执行文件 INT 并不是必须的。然而,如果没有 INT,这个可执行文件就不能被绑定。Microsoft 的链接器似乎总是生成一个 INT,但很长一段时间,Borland的链接器(TLINK)却不生成。因此 Borland 链接器创建的可执行文件不能被绑定。
在早期的 Microsoft 链接器中,导入节对于链接器并不是特殊的。组成导入表的所有数据都来自于导入库。用 Dumpbin 或 PEDUMP 查看一个导入库时可以看到那些数据。你会发现存在一些像 .idata$3 和 .idata$4 的节。链接器只是简单的遵守它的规则合并节,于是所有的结构和数组都被放到了正确的位置。几年前,Microsoft 提出了一个新的导入库格式,可以创建更小的导入库,从而使链接器在创建导入数据时更具有主动性。
绑定
当可执行文件被绑定时(例如通过绑定程序),IAT 中的 IMAGE_THUNK_DATA 结构被用导入函数的实际地址重写。磁盘中的可执行文件的 IAT 中保存的是其它 DLL 的 API在内存中的实际地址。那么加载一个被绑定的可执行文件时,Windows 加载器就可以不必查找每个导入的 API 并把其地址写到 IAT 中。正确的地址已经在那儿了!然而,前提是必须正确对齐才行。我的 2000 年 5 月的专栏中包含一些测试基准,是关于绑定可执行文件可以得到多大的加载性能的提升的。
你也许会怀疑绑定可执行文件的安全性。如果绑定到可执行文件中的DLL发生改变时会怎么样呢?发生这种情况时,IAT中的所有地址就无效了。加载器会检测这种情况并从而进行处理。如果检测到IAT中的地址无效了,加载器就会根据INT中的信息重新获取导入API的地址。
绑定你的程序的最佳时机是在安装时。Windows installer的BindImage将会为你作这些工作。另外,IMAGEHLP.DLL中提供了BindImageEx函数也可以进行绑定。不管用哪种方法,绑定都是一个好注意。如果加载器认为绑定信息是正确的,可执行文件就会加载的更快。如果绑定信息过时了,也不会影响你的程序。
对于加载器,使绑定有效的一个关键步骤是确定 IAT 中的绑定信息是否有效。当可执行文件被绑定时,所引用的 DLL 的信息将被放到这个可执行文件中。加载器会检测这些信息以快速确定绑定的有效性。在绑定的最初实现中并没有添加这些信息。因此,可执行文件可以被按旧的方式绑定或者新的方式绑定。而在这里我将描述新的方法。
用于验证绑定的有效性的关键数据结构是 IMAGE_BOUND_IMPORT_DESCRIPTOR。被绑定的可执行文件中包含一个这些结构的列表。其中每个IMAGE_BOUND_IMPORT_DESCRIPTOR 结构都指出了一个被绑定的导入DLL的时间/日期戳。数据目录中的 IMAGE_DIRECTORY_ENTRY_BOUND_IMPORT 元素给出了这个列表的 RVA。IMAGE_BOUND_IMPORT_DESCRIPTOR 结构的元素是: TimeDateStamp,一个DWORD,包含了被导入DLL的时间/日期戳。
OffsetModuleName,一个WORD,包含了指向一个被导入DLL的名称字符串的偏移。这个域是从第一个IMAGE_BOUND_IMPORT_DESCRIPTOR 结构的偏移(而不是一个RVA)。
NumberOfModuleForwarderRefs,一个WORD,包含了这个结构后面紧跟的IMAGE_BOUND_FORWARDER_REF结构的数目。除最后一个WORD(NumberOfModuleForwarderRefs)被保留之外,这些结构和IMAGE_BOUND_IMPORT_DESCRIPTOR是一样的。
简单情况下,对应于每个被导入DLL的那些IMAGE_BOUND_IMPORT_DESCRIPTOR结构组成了一个数组。但是,当绑定一个被转送到其它DLL的API时,也必须检测被转送到的那个DLL的有效性。因而,IMAGE_BOUND_FORWARDER_REF结构和IMAGE_BOUND_IMPORT_DESCRIPTOR是交叉存放的。
假设你链接到了HeapAlloc,而它被转送到了NTDLL中的RtlAllocateHeap。然后对你的可执行文件运行BIND。在你的EXE文件中,将会有一个对应于KERNEL32.DLL的IMAGE_BOUND_IMPORT_DESCRIPTOR结构,它后面是一个对应于NTDLL.DLL的IMAGE_BOUND_FORWARDER_REF结构。紧跟着的可能是对应于你导入并绑定的其它DLL的IMAGE_ BOUND_IMPORT_DESCRIPTOR结构。
延迟加载数据
前面我讲的延迟加载DLL是一个介于隐式导入和通过LoadLibrary与GetProcAddress显示导入API之间的混合导入方式。现在我们来看一下相关的数据结构和延迟加载是如何工作的。
要记住延迟加载不是操作系统的特性。它是由链接器附加的一些代码和数据以及运行时库实现的。因此,你无法在WINNT.H中找到关于延迟加载的信息。然而,你能发现一些在延迟加载数据和正常导入数据之间类似的东西。
数据目录中的IMAGE_DIRECTORY_ENTRY_DELAY_IMPORT条目指向延迟加载数据。它是一个指向ImgDelayDescr结构数组的RVA,这个结构定义在Visual C++中的DelayImp.H。图 7显示了它的内容。对于每个被延迟导入的DLL都有一个ImgDelayDescr结构。
对于ImgDelayDescr结构关键的东西是它包含了其对应DLL的一个IAT和一个INT的地址。这些表的格式与正常导入时的对应表的格式是一样的,只是它们是由运行时库读写的而不是操作系统。当你第一次调用一个延迟加载DLL的API时,运行时调用LoadLibrary (如果需要),然后调用GetProcAddress。获取到的地址被保存在延迟加载IAT中,以后再调用时就可以直接得到那个API的地址。
关于延迟加载数据有一点需要说明。Visual C++ 6.0中,ImgDelayDescr的所有包含地址的域使用的是虚拟地址,而不是RVA。就是说,它们包含的是被延迟加载的数据的实际地址。这些域都是DWORD,x86下一个指针的大小。
现在要支持IA-64。于是,4个字节就不足以保存一个完整的地址。在这一点上,Microsoft 做了件正确的事情,即把包含地址的域改为RVA。如图7所示,我用的是修改过的结构定义的名称。
确定ImgDelayDescr中使用的是RVA还是虚拟地址就成了另外一个问题。这个结构有一个域可保存一些标记。当grAttrs域的"1"位打开时,结构成员就被看作RVA。这是和Visual Studio?? .NET与64位编译器一起出现的唯一一个选项。如果grAttrs中的那个位关掉,ImgDelayDescr结构的域就是虚拟地址。
图 7 ImgDelayDescr结构 大小 成员 描述 DWORD grAttrs 这个结构的属性。目前,唯一被定义的标记是dlattrRva (1),它表示这个结构的地址域应该被看作RVA,而不是虚拟地址。 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的导入地址表的绑 定拷贝。它与正常IAT的格式是一样的。目前,这个IAT的拷贝实际上并没有被绑定,但这个特性在以后版本的BIND程序中可能会被添加。 RVA rvaUnloadIAT 原始IAT的可选拷贝的RVA。指向这个DLL的导入地址表的未绑定拷贝。它和正常IAT的格式是一样的。目前总是被设为0。 DWORD dwTimeStamp 延迟导入DLL的日期/时间戳。通常被设为0。 资源节
PE文件的所有节中,资源是最复杂的。这里,我只描述一些数据结构,这些数据结构是用来找到实际资源数据的,例如图标、位图和对话框。而不会去探究资源数据的实际格式,因为那已经超出本文的范围了。
资源位于被称为.rsrc的节中。数据目录的IMAGE_DIRECTORY_ENTRY_RESOURCE条目包含了资源的RVA和大小。由于各种原因,资源用类似文件系统的方式组织,它也有目录和叶结点。
数据目录中的资源指针指向一个IMAGE_RESOURCE_DIRECTORY类型的结构。这个结构包含了未使用的Characteristic,TimeDateStamp和版本号域。我们只对两个域感兴趣,就是NumberOfNamedEntries和NumberOfIdEntries。
IMAGE_RESOURCE_DIRECTORY结构的后面是一个IMAGE_RESOURCE _DIRECTORY_ENTRY类型的结构数组。IMAGE_RESOURCE_DIRECTORY中的NumberOfNamedEntries和NumberOfIdEntries两个域的值之和就是这个数组中IMAGE_RESOURCE_DIRECTORY_ENTRY结构的数目。
目录条目即可以指向另一个资源目录也可以指向某个资源的数据。当目录条目指向另一个资源目录时,结构中第2个DWORD的高位被置位并且剩下的31位是指向那个资源目录的偏移。这个偏移是相对于资源节的开始处而不是一个RVA。
当目录条目指向一个实际资源实例时,第2个DWORD的高位被清除。剩下的31位是到资源实例(例如一个对话框)的偏移。这个偏移也是相对于资源节,面不是一个RVA。
目录条目可以被命名也可以通过一个ID值来标识。这和你在.RC文件中为一个资源实例指定一个名称或者ID是一样的。在目录条目中,当第一个DWORD的高位被置位时,剩下的31位是到资源名称字符串的偏移。如果高位被清除,低16位包含的是序数标识符。
理论知识已经足够了!让我们查看一个实际的资源节并解释它的含义。图8 显示了PEDUMP输出的ADVAPI32.DLL的资源的一小部分。每个以"ResDir"开头的行对应于一个IMAGE_RESOURCE_DIRECTORY结构。跟"ResDir"后面用圆括号括起来的是资源目录的名称。在这个例子中,资源目录的名称分别是0,MOFDATA,MOFRESOURCENAME,STRING,C36,RCDATA和66。名称后面是目录条目的总数(包括被命名的和以ID标识的)。这个例子中,顶层目录有三个目录条目,而所有其它目录都只有一个条目。
图 8 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
顶层目录和文件系统的根目录相类似。"根"下面的每个目录条目总是一个目录。每个二级目录对应于一种资源类型(字符串表,对话框,菜单等等)。在每个二级"资源类型"目录下面,是三级子目录。
对于每个资源实例都有三级子目录。例如,如果有五个对话框,就会有一个二级的DIALOG目录,它下面有五个目录条目。这五个目录条目都是一个目录。它们的名称对应于资源实例的名称或ID。在这些目录的每个下面只有一个条目,它包含了到资源数据的偏移。挺简单的,不是吗?
如果你认为通过阅读源代码来学习更有效率的话,可以看一下 PEDUMP 中的 dump 资源的那部分代码。除了显示所有的资源目录以及它们的条目之外,PEDUMP 也显示几种常见的资源类型,例如对话框。
基址重定位
你可以在可执行文件的许多地方发现一些内存地址。当链接一个可执行文件时,它被指定一个首选加载地址。只有当可执行文件加载到IMAGE_FILE_HEADER结构的ImageBase域指定的首选加载地址时那些内存地址才是正确的。
如果加载器必须把DLL加载到另外一个地址,那么可执行文件中的所有地址就会是错误的。这就使加载器必须作一些额外的工作。2000年5月的 Under The Hood专栏(前面提到过)描述了当多个DLL有着相同的首选加载地址时的冲突和REBASE工具怎样帮助解决这个冲突。
基址重定位告诉加载器如果可执行文件没有被加载到首选加载地址时这个可执行文件中的哪些地方需要被修改。幸运的是,加载器不必知道地址是如何被使用的任何细节。它只须知道有一个位置列表需要通过某种一致的方式来修改。
让我们来看一个基于x86的例子。假设你有下面一条指令,它读取某个全局变量(地址是0x0040D434)的值到ECX寄存器中。
00401020: 8B 0D 34 D4 40 00 mov ecx,dword ptr [0x0040D434]
这条指令位于地址0x00401020处,长度是6个字节。前面两个字节(0x8B 0x0D)是这个指令的操作码。剩下的4个字节保存了一个DWORD表示的地址 (0x0040D434)。在这个例子中,这条指令来自于一个首选加载地址是0x00400000的可执行文件。那个全局变量的RVA是0xD434。
如果可执行文件确实被加载到了地址0x00400000处,这条指令可以正确地运行。但是,假设不知什么原因可执行文件被加载到了地址0x00500000处。那么这条指令的最后四个字节必须被改为0x0050D434。
加载器如何进行修改呢?加载器会比较首选加载地址和实际加载地址并计算出一个差值。在这个例子中,差值就是0x00100000。把这个差值和那个DWORD表示的地址值相加就得到了变量的新地址。在前面例子中,对应于0x00401022地址处会有一个基址重定位,它是这条指令中的DWORD的位置。
基址重定位就是可执行文件中的一些内存位置的一个列表,一个差值必须被添加到那些内存处的内容上。可执行文件的页只有在需要时才被加载到内存中,基址重定位信息的格式反映了这种特性。基址重定位存在于一个被称为 .reloc 的节中,通过数据目录的IMAGE_DIRECTORY_ENTRY_BASERELOC 条目可以找到它。
基址重定位是一系列很简单的 IMAGE_BASE_RELOCATION 结构。VirtualAddress 域包含了重定位项所属内存区域的 RVA。SizeOfBlock 域指出了重定位信息的字节数,其中包括 IMAGE_BASE_RELOCATION 结构的大小。 紧跟IMAGE_BASE_RELOCATION结构之后的是一系列可变数目的WORD 值。这些WORD的数目可由SizeOfBlock域推断出。每个WORD由两部分组成。高4位指出了重定位的类型,WINNT.H中的一系列IMAGE_REL_BASED_xxx定义了重定位类型的取值。低12位是相对于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的EXE中的页大小是8KB,但基址重定位仍然使用4KB的块。
在Visual C++ 6.0中,Build一个发布版的EXE时链接器不生成重定位信息。这是因为EXE是首先被加载到地址空间中的,它总是会被加载到首选加载地址处。而DLL文件却不是这样的,所以基址重定位总是存在,除非你用/FIXED选项忽略它们。在Visual Studio .NET中,链接器会为Debug和Release模式的EXE文件都忽略掉重定位信息。
调试目录
生成一个包含调试信息的可执行文件时,按惯例应包含关于调试信息的格式以及位置的细节。操作系统运行一个可执行文件时不需要调试信息,但对于开发工具却很有用。一个EXE 可以有多种格式的调试信息;而数据结构调试目录指出了哪种格式可用。
通过数据目录的 IMAGE_DIRECTORY_ENTRY_DEBUG 条目可以找到调试目录。它由一个 IMAGE_DEBUG_DIRECTORY 类型的结构(参见图9)数组组成,其中每一个对应于一种调试信息类型。调试目录中元素的数目可以由数据目录中的 Size 域计算得到。
图 9 IMAGE_DEBUG_DIRECTORY的域 大小 成员 描述 DWORD Characteristics 没有使用并被设为0。 DWORD TimeDateStamp 调试信息的时间/日期戳(自1/1/1970经过的秒数,GMT)。 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样式的调试信息演化而来的。PDB 信息的存在是由一个IMAGE_DEBUG_TYPE_CODEVIEW 类型的调试目录条目指明的。如果你检查这个条目所指向的数据,你会发现一个短的 CodeView 样式头。这些调试数据大多是外部 PDB 文件的路径。在 Visual Studio 6.0 中,这个调试头以一个 NB10 签名开始。而在 Visual Studio .NET 中,以一个 RSDS 开头。
在 Visual Studio 6.0中,/DEBUGTYPE:COFF 链接器选项可以生成 COFF 调试信息。Visual Studio .NET 中取消了这个选项。帧指针省略(FPO)调试信息是随着优化的 x86 代码出现的,那里,函数可以没有一个常规的堆栈框架。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。这种方式类似于Visual Basic(在.NET之前)中的程序使用MSVBVM60.DLL的方式。.NET信息的起始点是IMAGE_COR20_HEADER结构,其定义在.NET Framework SDK中的CorHDR.H和最近版本的WINNT.H中。数据目录中的IMAGE_DIRECTORY_ENTRY_COM_DESCRIPTOR 条目指向这个IMAGE_COR20_HEADER结构。图10显示了IMAGE_COR20_HEADER中的域。IMAGE_COR20_HEADER所指向的元数据的格式以及其它的东西在以后的文章中描述。
图 10 IMAGE_COR20_HEADER结构 类型 成员 描述 DWORD cb 头部的字节大小。 WORD MajorRuntimeVersion 运行这个程序所需要的最小运行时版本。对于.NET的第一个发布版本,这个值是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/runtime为方法
// 保持调试信息。 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 一个需要进行修正的函数指针数组的RVA。这是为了支持非托管C++ vtables。 IMAGE_DATA_DIRECTORY ExportAddressTableJumps 一个 RVA 数组的 RVA。这个数组是用于导出的 JMP thunk 所写入的位置。这些thunk 允许托管方法被导出以便非托管代码可以调用它们。 IMAGE_DATA_DIRECTORY ManagedNativeHeader 由.NET运行时内部使用。在可执行文件设为0。 TLS初始化
当使用由__declspec(thread)声明的线程局部变量时,编译器把它们放在一个称为.tls的节中。当系统发现正在启动一个新的线程时,它从进程堆中分配内存以保存这个线程的线程局部变量。这些内存用.tls节中的值初始化。系统也在FS:[2Ch](x86体系)所指向的TLS数组中放置一个指针,指向被分配的内存。(on the x86 architecture).
数据目录中的IMAGE_DIRECTORY_ENTRY_TLS 条目指出了可执行文件中的线程局部存储(TLS)数据。如果非0,这个条目指向一个IMAGE_TLS_DIRECTORY结构,如图11所示。
有一点很重要,IMAGE_TLS_DIRECTORY 结构中的地址是虚拟地址而不是RVA。因此,如果可执行文件没有加载到它的首选加载地址的话这些地址就必须由基址重定位进行修正。另外,IMAGE_TLS_DIRECTORY本身并不在.tls节中,而是位于.rdata节。
图 11 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。 程序异常数据
一些体系结构(包括IA-64)并不像x86那样使用基于框架的异常处理。代替的,它们使用基于表的异常处理,表中包含了可能被异常展开影响到的每个函数的信息。这些信息包括函数的起始地址,结束地址和关于异常应该怎样以及在哪儿被处理的信息。当一个异常发生时,系统搜索这个表以定位适当的入口并且处理它。异常表是一个IMAGE_RUNTIME_FUNCTION_ENTRY 类型的结构数组。数据目录中的IMAGE_DIRECTORY_ENTRY_EXCEPTION条目指向这个数组。不同的体系中IMAGE_RUNTIME_FUNCTION_ENTRY结构有不同的格式。对于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 运行时头
除了可以导出 PE 可执行文件外,PEDUMP 也可以导出 COFF 格式的 OBJ 文件、COFF导入库 (新的和旧的格式)、COFF 符号表和 DBG 文件。
PEDUMP 是一个命令行程序。运行它导出上述某个类型的文件并且不带任何选项就是默认导出,可以包含更有用的数据结构信息。有几个命令行选项可以控制其输出(见图12)。
图 12命令行选项 /A 包含所有东西 /B 显示基址重定位 /H 包含节的 16 进制形式的数据 /I 包含导入地址表 thunk 的地址 /L 包括行号信息 /P 包括PDATA (运行时函数) /R 包括详细的资源(字符串表和对话框) /S 显示符号表 PEDUMP 的源代码有几个地方值得注意。它可以按 32 位或 64 位编译和运行。如果你手边有 Itanium 机器可以试一下。另外,PEDUMP 可以 dump 32 位和 64 位的可执行文件而不管它是如何被编译的。换句话说,32 位版本的 PEDUMP 可以 dump 32 位和 64 位的文件;并且 64 位版本的 PEDUMP 也可以 dump 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 path 的最上方。也需要确保正确设置了 64 位包含文件和库文件的路径。我机器上的工程文件已经进行了正确的设置,但在你的机器中则需要进行更改。
为了使 PEDUMP 尽可能的完善,必须使用最新版本的 Windows 头文件。我在开发 PEDUMP 时使用的是 2001 年 6 月的 Platform SDK,所需要的头文件位于 .\include\prerelease 和 .\Include\Win64\crt\ 目录中。而在 2001 年 8 月的 SDK 中,WINNT.H 已得到更新,因此不再需要使用 prerelease 目录了。重点是可以成功构建代码。你只需要安装最新的 Platform SDK 或者在构建 64 位版本时修改工程目录设置就行了。
结束语
PE格式是一个具有良好结构并且相对简单的可执行文件格式。PE文件可以被直接映射到内存,所以磁盘文件中的数据结构和在运行时Windows使用的数据结构是相同的。这一点非常好。在过去10年所发生的所有变化,包括转变到64位的Windows和.NET,PE格式都可以很好的支持,这太使我惊奇了。
尽管本文包含了 PE 文件的许多方面,但仍有一些主题没有涉及到。比如标记、属性和一些很少见的数据结构等,我决定不在这里描述它们。但是,我希望这篇文章对 PE 文件的讲解可以使你更容易的理解 Microsoft 的 PE 规范