深入剖析Win32可移植可执行文件格式(第一部分)2008-05-03 20:38:32| 分类: windows操作系统 |字号 订阅 深入剖析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./)发行的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值所指代的内容。由它们指向的许多数据结构将在本文的第二部分中详细描述。
导入函数 当你使用其它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文件开头找到。
下表列出了常用的IMAGE_FILE_xxx值:
下表列出了IMAGE_OPTIONAL_HEADER结构的成员:
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给出。
下表列出了常用的节属性标志:
可执行文件中的节在文件中的对齐值对文件的大小有重要影响。在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工具的源代码。 |
|