一个操作系统的可执行文件格式在很多方面是这个系统的一面镜子。虽然学习一个可执行文件格式通常不是一个程序员的首要任务,但是你可以从这其中学到大量的知识。在这篇文章中,我会给出 MicroSoft 的所有基于 win32系统(如winnt,win9x)的可移植可执行(PE)文件格式的详细介绍。在可预知的未来,包括 Windows2000 , PE 文件格式在 MicroSoft 的操作系统中扮演一个重要的角色。如果你在使用 Win32 或 Winnt ,那么你已经在使用 PE 文件了。甚至你只是在 Windows3.1 下使用 Visual C++ 编程,你使用的仍然是 PE 文件(Visual C++ 的 32 位 MS-DOS 扩展组件用这个格式)。简而言之,PE 格式已经普遍应用,并且在不短的将来仍是不可避免的。现在是时候找出这种新的可执行文件格式为操作系统带来的东西了。
我最后不会让你盯住无穷无尽的十六进制Dump,也不会详细讨论页面的每一个单独的位的重要性。代替的,我会向你介绍包含在 PE 文件中的概念,并且将他们和你每天都遇到的东西联系起来。比如,线程局部变量的概念,如下所述:
declspec(thread) int i;
我快要发疯了,直到我发现它在可执行文件中实现起来是如此的简单并且优雅。既然你们中的许多人都有使用 16 Windows 的背景,我将把 Win32 PE 文件的构造追溯到和它等价的16 位 NE 文件。
除了一个不同的可执行文件格式, MicroSoft 还引入了一个用它的编译器和汇编器生成的新的目标模块格式。这个新的 OBJ 文件格式有许多和PE 文件共同的东东。我做了许多无用功去查找这个新的 OBJ 文件格式的文档。所以我以自己的理解对它进行解析,并且,在这里,除了 PE 文件,我会描述它的一部分。
大家都知道,Windows NT 继承了 VAX? VMS? 和 UNIX? 的传统。许多 Windows NT 的创始人在进入微软前都在这些平台上进行设计和编码。当他们开始设计 Windows NT 时,很自然的,为了最小化项目启动时间,他们会使用以前写好的并且已经测试过的工具。用这些工具生成的并且工作的可执行和 OBJ 文件格式叫做 COFF (Common Object File Format 的首字母缩写)。COFF 的相对年龄可以用八进制的域来指定。COFF 本身是一个好的起点,但是需要扩展到一个现代操作系统如 Windows 95 和 Windows NT 的需要。这个更新的结果就是(PE格式)可移植可执行文件格式。它被称为"可移植的"是因为在所有平台(如x86,Alpha,MIPS等等)上实现的WindowsNT 都使用相同的可执行文件格式。当然了,也有许多不同的东西如二进制代码的CPU指令。重要的是操作系统的装入器和程序设计工具不需要为任何一种CPU完全重写就能达到目的。
MicroSoft 抛弃现存的32位工具和可执行文件格式的事实证实了他们想让 WindowsNT 升级并且运行的更快的决心。为16位Windows编写的虚拟设备驱动程序用一种不同的32位文件布局--LE 文件格式--WindowsNT出现很早以前就存在了。比这更重要的是对 OBJ 文件的替换!在 WindowsNT 的 C 编译器以前,所有的微软编译器都用 Intel 的 OMF ( Object Module Format ) 规范。就像前面提到的,MicroSoft 的 Win32 编译器生成 COFF 格式的 OBJ 文件。一些微软的竞争者,如 Borland 和 Symentec ,选择放弃了 COFF 格式并坚持 Intel 的 OMF 文件格式。这样的结果是制作 OBJ 和 LIB 的公司为了使用多个不同的编译器,不得不为每个不同的编译器分发这些库的不同版本(如果他们不这么做)。
PE 文件格式在 winnt.h 头文件中文档化了(用最不精确的语言)!大约在 winnt.h 的中间部分标题为"Image Format"的一个快。在把 MS-DOS 的 MZ 文件头和 NE 文件头移入新的PE文件头之前,这个块就开始于一个小栏。WINNT.H提供PE文件用到的生鲜数据结构的定义,但只有很少有助于理解这些数据结构和标志变量的注释。不管谁为PE文件格式写出这样的头文件都肯定是一个信徒无疑(突然持续地冒出Michael J. O'Leary的名字来)。描述名字,连同深嵌的结构体和宏。当你配套winnt.h进行编码时,类似下面这样的表达式并不鲜见:
pNTHeader->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_DEBUG]
.VirtualAddress;
为了有助于逻辑的理解这些winnt.h中的信息,阅读可移植可执行和公共对象文件格式的规格说明,这些在MSDN既看光盘中是可用的,一直包括到2001年8月。
现在让我们转换到COFF格式的OBJ文件的主体上来,WINNT.H包括COFF OBJ和LIB的结构化定义和类型定义。不幸的是,我还没有找到上面提到的可执行文件格式的类似文档。既然PE文件和COFF OBJ文件是如此的相似,我决定是时间把这些文件带到重点上来,并且把它们也文档化。仅仅读过了关于PE文件的组成,你自己也想Dump一些PE文件来看这些概念。如果你用微软基于32位WINDOWS的开发工具,DUMPBIN 程序可以将PE文件和COFF OBJ/LIB文件转化为可读的形式。在所有的PEDump器中,DUMPBIN是最容易理解的。它恰好有一些很好的选项来反汇编它正解析的文件的代码块,Borland用户可以使用tdump来浏览PE文件,但tdump不能解析 COFF OBJ/LIB 文件。这不是一个重要的东西因为Borland的编译器首先就不生成 COFF 格式的OBJ文件。
我写了一个PE和COFF OBJ 文件的Dump程序--PEDUMP(见表1),我想提供一些比DUMPBIN更加可理解的输出。虽然它没有反汇编器以及和LIB库文件一起工作,它在其他方面和DUMPBIN是一样的,并且加入了一些新的特性来使它值得被认同。它的源代码在任何一个MSJ电子公报版上都可以找到,所有我不打算在这里把他全部列出。作为代替,我展示一些从PEDUMP得到的示例输出来阐明我为它们描述的概念。
译注:--说实话,我从这这份代码中几乎唯一学到的东西就是"如何处理命令行",其它的都没学到。
表 1 PEDUMP.C
file://--------------------
// PROGRAM: PEDUMP
// FILE: PEDUMP.C
// AUTHOR: Matt Pietrek - 1993
file://--------------------
#include <windows.h>
#include <stdio.h>
#include "objdump.h"
#include "exedump.h"
#include "extrnvar.h"
// Global variables set here, and used in EXEDUMP.C and OBJDUMP.C
BOOL fShowRelocations = FALSE;
BOOL fShowRawSectionData = FALSE;
BOOL fShowSymbolTable = FALSE;
BOOL fShowLineNumbers = FALSE;
char HelpText[] =
"PEDUMP - Win32/COFF .EXE/.OBJ file dumper - 1993 Matt Pietrek/n/n"
"Syntax: PEDUMP [switches] filename/n/n"
" /A include everything in dump/n"
" /H include hex dump of sections/n"
" /L include line number information/n"
" /R show base relocations/n"
" /S show symbol table/n";
// Open up a file, memory map it, and call the appropriate dumping routine
void DumpFile(LPSTR filename)
{
HANDLE hFile;
HANDLE hFileMapping;
LPVOID lpFileBase;
PIMAGE_DOS_HEADER dosHeader;
hFile = CreateFile(filename, GENERIC_READ, FILE_SHARE_READ, NULL,
OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, 0);
if ( hFile = = INVALID_HANDLE_VALUE )
{ printf("Couldn't open file with CreateFile()/n");
return; }
hFileMapping = CreateFileMapping(hFile, NULL,
PAGE_READONLY, 0, 0, NULL);
if ( hFileMapping = = 0 )
{
CloseHandle(hFile);
printf("Couldn't open file mapping with CreateFileMapping()/n");
return;
}
lpFileBase = MapViewOfFile(hFileMapping, FILE_MAP_READ, 0, 0, 0);
if ( lpFileBase = = 0 )
{
CloseHandle(hFileMapping);
CloseHandle(hFile);
printf("Couldn't map view of file with MapViewOfFile()/n");
return;
}
printf("Dump of file %s/n/n", filename);
dosHeader = (PIMAGE_DOS_HEADER)lpFileBase;
if ( dosHeader->e_magic = = IMAGE_DOS_SIGNATURE )
{ DumpExeFile( dosHeader ); }
else if ( (dosHeader->e_magic = = 0x014C) // Does it look like a i386
&& (dosHeader->e_sp = = 0) ) // COFF OBJ file???
{
// The two tests above aren't what they look like. They're
// really checking for IMAGE_FILE_HEADER.Machine = = i386 (0x14C)
// and IMAGE_FILE_HEADER.SizeOfOptionalHeader = = 0;
DumpObjFile( (PIMAGE_FILE_HEADER)lpFileBase );
}
else
printf("unrecognized file format/n");
UnmapViewOfFile(lpFileBase);
CloseHandle(hFileMapping);
CloseHandle(hFile);
}
// process all the command line arguments and return a pointer to
// the filename argument.
PSTR ProcessCommandLine(int argc, char *argv[])
{
int i;
for ( i=1; i < argc; i++ )
{
strupr(argv[i]);
// Is it a switch character?
if ( (argv[i][0] = = '-') || (argv[i][0] = = '/') )
{
if ( argv[i][1] = = 'A' )
{ fShowRelocations = TRUE;
fShowRawSectionData = TRUE;
fShowSymbolTable = TRUE;
fShowLineNumbers = TRUE; }
else if ( argv[i][1] = = 'H' )
fShowRawSectionData = TRUE;
else if ( argv[i][1] = = 'L' )
fShowLineNumbers = TRUE;
else if ( argv[i][1] = = 'R' )
fShowRelocations = TRUE;
else if ( argv[i][1] = = 'S' )
fShowSymbolTable = TRUE;
}
else // Not a switch character. Must be the filename
{ return argv[i]; }
}
}
int main(int argc, char *argv[])
{
PSTR filename;
if ( argc = = 1 )
{ printf( HelpText );
return 1; }
filename = ProcessCommandLine(argc, argv);
if ( filename )
DumpFile( filename );
return 0;
}
1 WIN32 与 PE 基本概念
让我们复习一下几个透过PE文件的设计了解到的基本概念(见图1)。我用术语"MODULE"来表示一个可执行文件或一个DLL载入内存的代码(CODE)、数据(DATA)、资源(RESOURCES),除了代码和数据是你的程序直接使用的,一个模块还可以由WINDOWS用来确定数据和代码载入的位置的支撑数据结构组成。在16位WINDOWS中,这些支撑数据结构在模块数据库(用一个HMODULE来指示的段)中。在WIN32里面,这些数据结构在PE文件头中,这些我将会简要地解释一下。
图1 PE文件略图
关于PE文件最重要的是,磁盘上的可执行文件和它被WINDOWS调入内存之后是非常相像的。WINDOWS载入器不必为从磁盘上载入一个文件而辛辛苦苦创建一个进程。载入器使用内存映射文件机制来把文件中相似的块映射到虚拟空间中。用一个构造式的分析模型,一个PE文件类似一个预制的屋子。它本质上开始于这样一个空间,这个空间后面有几个把它连到其余空间的机件(就是说,把它联系到它的DLL上,等等)。这对PE格式的DLL是一样容易应用的。一旦这个模块被载入,Windows 就可以有效的把它和其它内存映射文件同等对待。
和16位Windows不同的是。16位NE文件的载入器读取文件的一部分并且创建完全不同的数据结构在内存中表示模块。当数据段或者代码段需要载入时,载入器必须从全局堆中新申请一个段,从可执行文件中找出生鲜数据,转到这个位置,读入这些生鲜数据,并且要进行适当的修正。除此而外,每个16位模块都有责任记住当前它使用的所有段选择器,而不管这个段是否被丢弃了,如此等等。
对Win32来讲,模块所使用的所有代码,数据,资源,导入表,和其它需要的模块数据结构都在一个连续的内存块中。在这种形势下,你只需要知道载入器把可执行文件映射到了什么地方。通过作为映像的一部分的指针,你可以很容易的找到这个模块所有不同的块。
另一个你需要知道的概念是相对虚拟地址(RVA)。PE文件中的许多域都用术语RVA来指定。一个RVA只是一些项目相对于文件映射到内存的偏移。比如说,载入器把一个文件映射到虚拟地址0x10000开始的内存块。如果一个映像中的实际的表的首址是0x10464,那么它的RVA就是0x464。
(虚拟地址 0x10464)-(基地址 0x10000)=RVA 0x00464
为了把一个RVA转化成一个有用的指针,只需要把RVA值加到模块的基地址上即可。基地址是内存映射EXE和DLL文件的首址,在Win32中这是一个很重要的概念。为了方便起见,WindowsNT 和 Windows9x用模块的基地址作为这个模块的实例句柄(HINSTANCE)。在Win32中,把模块的基地址叫做HINSTANCE可能导致混淆,因为术语"实例句柄"来自16位Windows。一个程序在16位Windows中的每个拷贝得到它自己分开的数据段(和一个联系起来的全局句柄)来把它和这个程序其它的拷贝分别开来,就形成了术语"实例句柄"。在Win32中,每个程序不必和其它程序区别开来,因为他们不共享相同的地址空间。术语INSTANCE仍然保持16位windows和32位Windows之间的连续性。在Win32中重要的是你可以对任何DLL调用GetModuleHandle()得到一个指针去访问它的组件(译注)。
译注:如果 dllname 为 NULL,则得到执行体自己的模块句柄。这是非常有用的,如通常编译器产生的启动代码将取得这个句柄并将它作为一个参数hInstance传给WinMain !
你最终需要理解的PE文件的概念是"块(Section)"。PE文件中的一个块和NE文件中的一个段或者资源等价。块可以包含代码或者数据。和段不同的是,块是内存中连续的空间,而没有尺寸限制。当你的连接器和库为你建立,并且包含对操作系统非常重要的信息的其它的数据块时,这些块包含你的程序直接声明和使用的代码或数据。在一些PE格式的描述中,块也叫做对象。术语对象有如此多的涵义,以至于只能把代码和数据叫做"块"。
2 PE首部
和其它可执行文件格式一样,PE文件在众所周知的地方有一些定义文件其余部分面貌的域。首部就包含这样象代码和数据的位置和尺寸的地方,操作系统要对它进行干预,比如初始堆栈大小,和其它重要的块的信息,我将要简短的介绍一下。和微软其它可执行格式相比,主要的首部不是在文件的最开始。典型的PE文件最开始的数百个字节被DOS残留部分占用。这个残留部分是一个可以打印如"这个程序不能在DOS下运行!"这类信息的小程序。所以,你在一个不支持Win32的系统中运行这个程序,便可以得到这类错误信息。当载入器把一个Win32程序映射到内存,这个映射文件的第一个字节对应于DOS残留部分的第一个字节。那是无疑的。和你启动的任一个基于Win32 的程序一起,都有一个基于DOS的程序连带被载入。
和微软的其它可执行格式一样,你可以通过查找它的起始偏移来得到真实首部,这个偏移放在DOS残留首部中。WINNT.H头文件包含了DOS残留程序的数据结构定义,使得很容易找到PE首部的起始位置。e_lfanew 域是PE真实首部的偏移。为了得到PE首部在内存中的指针,只需要把这个值加到映像的基址上即可。
file://忽略类型转化和指针转化 ...
pNTHeader = dosHeader + dosHeader->e_lfanew;
一旦你有了PE主首部的指针,游戏就可以开始了!PE主首部是一个IMAGE_NT_HEADERS的结构,在WINNT.H中定义。这个结构由一个双字(DWORD)和两个子结构组成,布局如下:
DWORD Signature;
IMAGE_FILE_HEADER FileHeader;
IMAGE_OPTIONAL_HEADER OptionalHeader;
标志域用ASCII表示就是"PE/0/0"。如果在DOS首部中用了e_lfanew域,你得到一个NE标志而不是PE,那么这是16位NE文件。同样的,在标志域中的LE表示这是一个Windows3.x 的虚拟设备驱动程序(VxD)。LX表示这个文件是OS/2 2.0文件。
PE DWORD标志后的是结构 IMAGE_FILE_HEADER 。这个域只包含这个文件最基本的信息。这个结构表现为并未从它的原始COFF实现更改过。除了是PE首部的一部分,它还表现在微软Win32编译器生成的COFF OBJ 文件的最开始部分。IMAGE_FILE_HEADER的这个域显示在下面:
表2 IMAGE_FILE_HEADER Fields
WORD Machine
表示CPU的类型,下面定义了一些CPU的ID
0x14d Intel i860
0x14c Intel I386 (same ID used for 486 and 586)
0x162 MIPS R3000
0x166 MIPS R4000
0x183 DEC Alpha AXP
WORD NumberOfSections
这个文件中的块数目。
DWORD TimeDateStamp
连接器产生这个文件的日期(对OBJ文件是编译器),这个域保存的数是从1969年12月下午4:00开始到现在经过的秒数。
DWORD PointerToSymbolTable
COFF符号表的文件偏移量。这个域只用于有COFF调试信息的OBJ文件和PE文件,PE文件支持多种调试信息格式,所以调试器应该指向数据目录的IMAGE_DIRECTORY_ENTRY_DEBUG条目。
DWORD NumberOfSymbols
COFF符号表的符号数目。见上面。
WORD SizeOfOptionalHeader
这个结构后面的可选首部的尺寸。在OBJ文件中,这个域是0。在可执行文件中,这是跟在这个结构后的IMAGE_OPTIONAL_HEADER结构的尺寸。
WORD Characteristics
关于这个文件信息的标志。一些重要的域如下:
0x0001 这个文件中没有重定位信息
0x0002 可执行文件映像(不是OBJ或LIB文件)
0x2000 文件是动态连接库,而非程序
其它域定义在WINNT.H中。
PE首部的第三个组成部分是一个IMAGE_OPTIONAL_HEADER型的结构。对PE文件,这一部分当然不是"可选的"。COFF格式允许单独实现来定义一个超出标准IMAGE_FILE_HEADER附加信息的结构。IMAGE_OPTIONAL_HEADER里面的域是PE的实现者感到超出IMAGE_FILE_HEADER基本信息以外非常关键的信息。
并非 IMAGE_OPTIONAL_HEADER 的所有域都是重要的(见图4)。比较重要,需要知道的是ImageBase 和 SubSystem 域。你可以忽略其它域的描述。
表3 IMAGE_FILE_HEADER 的域:
WORD Magic
表现为一些类别的标志字,通常是0X010B 。
BYTE MajorLinkerVersion
BYTE MinorLinkerVersion
生成这个文件的连接器的版本。这个数字以十进制显示比用十六进制好。一个典型的连接器版本是2.23。
DWORD SizeOfCode
所有代码块的进位尺寸。通常大多数文件只有一个代码块,所以这个域和 .TEXT 块匹配。
DWORD SizeOfInitializedData
已初始化的数据组成的块的大小(不包括代码段)。然而,和它在文件中的表现形式并不一致。
DWORD SizeOfUninitializedData
载入器在虚拟内存中申请空间,但在磁盘上的文件中并不占用空间的块的尺寸。这些块在程序启动时不需要指定初值,因此术语名就是"未初始化的数据"。未初始化的数据通常在一个名叫 .bss 的块中。
DWORD AddressOfEntryPoint
载入器开始执行这个程序的地址,即这个PE文件的入口地址。这是一个RVA,通常在 .text 块中。
DWORD BaseOfCode
代码块起始地址的RVA 。在内存中,代码块通常在PE首部之后,数据块之前。在微软的连接器产生的EXE文件中,这个值通常是0x1000 。Borland 的连接器 TLINK32 也一样,把映像第一个代码块的RVA和映像基址相加,填入这个域。
译注:这个域好像一直没有什么用
DWORD BaseOfData
数据块起始地址的RVA 。在内存中,数据块经常在最后,在PE首部和代码块之后。
译注:这个域好像也一直没有什么用
DWORD ImageBase
连接器创建一个可执行文件时,它假定这个文件被映射到内存中的一个指定的地方,这个地址就存在这个域中,假定一个载入地址可以使连接器优化以便节省空间。如果载入器真的把这个文件映射到了这个地方,在运行之前代码不需要任何改变。在为WindowsNT 创建的可执行文件中,默认的ImageBase 是0x10000。对DLL,默认是0x40000。在Window95中,地址0x10000不能用来载入32位EXE文件,因为这个区域在一个被所有进程共享的线性地址空间中。因此,微软把Win32可执行文件的默认基址改为0x40000,假定基址为0x10000 的老程序坐在Windows95 中需要更长的载入时间,这是因为载入器需要重定位基址。
译注:这个域即"Prefered Load Address",如果没有什么意外,这就是该PE文件载入内存后的地址。
DWORD SectionAlignment
映射到内存中时,每个块都必须保证开始于这个值的整数倍。为了分页的目的,默认的SectionAlignment 是 0x1000。
DWORD FileAlignment
在PE文件中,组成每个块的生鲜数据必须保证开始于这个值的整数倍。默认值是0x200 字节,也许是为了保证块都开始于一个磁盘扇区(一个扇区通常是 512 字节)。这个域和NE文件中的段/资源对齐(segment/resource alignment)尺寸是等价的。和NE文件不同的是,PE文件通常没有数百个的块,所以,为了对齐而浪费的通常空间很少。
WORD MajorOperatingSystemVersion
WORD MinorOperatingSystemVersion
这个程序运行需要的操作系统的最小版本号。这个域有点含糊,因为Subsystem 域(后面将会说到)可以提供类似的功能。这个域在到目前为止的Win32中默认是1.0。
WORD MajorImageVersion
WORD MinorImageVersion
一个可由用户定义的域。这允许你有不同的EXE和DLL版本。你可以通过链接器的 /version 选项设置这个域的值。例如:"link /version:2.0 myobj.obj"。
WORD MajorSubsystemVersion
WORD MinorSubsystemVersion
这个程序运行需要的最小子系统版本号。这个域的一个典型值是3.10 (表示WindowsNT 3.1)。
DWORD Reserved1
通常是 0 。
DWORD SizeOfImage
载入器必须关心的这个映像所有部分的大小总和。是从映像的开始到最后一个块结尾这段区域的大小。最后一个块结尾按SectionAlignment进位。
译注:这个很重要,可以大,但不可以小!
DWORD SizeOfHeaders
PE首部和块表的大小。块的实际数据紧跟在所有首部组件之后。
DWORD CheckSum
这个文件的CRC校验和。在微软可执行格式中,这个域被忽略并且置为0 。这个规则的一个例外情况是信任服务,这类EXE文件必须有一个合法的校验和。
WORD Subsystem
可执行文件的用户界面使用的子系统类型。WINNT.H 定义了下面这些值:
NATIVE 1 不需要子系统(比如设备驱动)
WINDOWS_GUI 2 在Windows图形用户界面子系统下运行
WINDOWS_CUI 3 在Windows字符子系统下运行(控制台程序)
OS2_CUI 5 在OS/2字符子系统下运行(仅对OS/2 1.x)
POSIX_CUI 7 在 Posix 字符子系统下运行
WORD DllCharacteristics
指定在何种环境下一个DLL的初始化函数(比如DllMain)将被调用的标志变量。这个值经常被置为0 。但是操作系统在下面四种情况下仍然调用DLL的初始化函数。
下面的值定义为:
1 DLL第一次载入到进程中的地址空间中时调用
2 一个线程结束时调用
4 一个线程开始时调用
8 退出DLL时调用
DWORD SizeOfStackReserve
为初始线程保留的虚拟内存总数。然而并不是所有这些内存都被提交(见下一个域)。这个域的默认值是0x100000(1Mbytes)。如果你在CreateThread 中把堆栈尺寸指定为 0 ,结果将是用这个相同的值(0x10000)。
DWORD SizeOfStackCommit
开始提交的初始线程堆栈总数。对微软的连接器,这个域默认是0x1000字节(一页),TLINK32 是两页。
DWORD SizeOfHeapReserve
为初始进程的堆保留的虚拟内存总数。这个堆的句柄可以用GetPocessHeap 得到。并不是所有这些内存都被提交(见下一个域)。
DWORD SizeOfHeapCommit
开始为进程堆提交的内存总数。默认是一页。
DWORD LoaderFlags
从WINNT.H中可以看到,这些标志是和调试支持相联系的。我从没有见到过在哪个可执行文件中这些位都置位了,清除它让连接器来设置它。下面的值定义为:
1. 在开始进程前调用一个端点指令
2. 进程被载入时调用一个调试器
DWORD NumberOfRvaAndSizes
数据目录数组中的的条目数目(见下面)。当前的工具通常把这个值设为16。
IMAGE_DATA_DIRECTORY DataDirectory[IMAGE_NUMBEROF_DIRECTORY_ENTRIES]
一个IMAGE_DATA_DIRECTORY 结构数组。初始数组元素包含可执行文件的重要部分的起始RVA和大小。这个数组最末的一些元素现在没有使用。这个数组的第一个元素经常时导出函数表的地址和尺寸。第二个数组条目是导入函数表的地址和尺寸,等等。对一个完整的、已定义的数组条目,见IMAGE_DIRECTORY_ENTRY_XXX 在WINNT.H中的定义。这个数组允许载入器迅速查找这个映像的一个指定的块(例如,导入函数表),而不需要遍历映像的每个块,通过比较名字来确定。大部分数组条目描述一整块数据。然而,IMAGE_DIRECTORY_ENTRY_DEBUG项只包括 .rdata 块的一小部分字节。
3 块表
在PE首部和映像块之间的是块表。块表本质上是包含映像中每个块信息的电话本。映像中的块以他们的起始地址(RVA)排列,而不是按字母排列。
现在,我进一步澄清什么是一个块。在NE文件中,你的程序代码和数据存储在相互区别开来的段中。NE首部的一部分是一个结构数组,每个对应你的程序用到的一个段。数组中的每个结构包含一个段的信息。这些信息存储了段的类型(代码或数据)、大小、和它在文件中的位置。在PE文件中,块表和NE文件中的段表类似。和NE文件的段表不同,PE块表项不存储一个代码和数据块的选择子。代替的,每个块表项存储文件的生鲜数据映射到内存中以后的地址。于是块就和32位段类似,但他们实际上不是单独的段。它们实际上是进程虚拟空间的一个内存范围。
另一个PE文件和NE文件的不同之处是它怎样管理你的程序不用,但操作系统要用的支持数据;例如可执行文件使用的DLL列表或修正表的位置。在NE文件中,资源不被当作段。甚至分配给他们的选择子,资源的相关信息并未存储在NE文件首部的段表中。代替的,提交给一个分隔表的资源朝向PE首部的结尾。关于导入和导出函数的信息也没有授权给它自己的段;它交织在NE首部中。
PE文件的故事就不一样了。任何可能被认为是关键的代码或数据都存在一个完备的块中。于是,导入函数表的信息就存在它自己的块中,导出表也一样。对重定位数据也是一样的。程序或操作系统可能需要的任何代码或数据都可以得到它们自己的块。
在我讨论特定块之前,我需要先描述操作系统管理这些块的数据。在内存中紧跟在PE首部的是一个IMAGE_SECTION_HEADER数组。数组的元素个数在PE首部中给定(IMAGE_NT_HEADER.FileHeader.NumberOfSections域)。我用PEDUMP来输出块表和块的所有的域及其属性。表5 描述了用PEDUMP输出的一个典型EXE文件的块表,表6 给出了 Obj 文件的块表。
表 4 一个典型EXE文件的块表
01 .text VirtSize: 00005AFA VirtAddr: 00001000
raw data offs: 00000400 raw data size: 00005C00
relocation offs: 00000000 relocations: 00000000
line # offs: 00009220 line #'s: 0000020C
characteristics: 60000020
CODE MEM_EXECUTE MEM_READ
02 .bss VirtSize: 00001438 VirtAddr: 00007000
raw data offs: 00000000 raw data size: 00001600
relocation offs: 00000000 relocations: 00000000
line # offs: 00000000 line #'s: 00000000
characteristics: C0000080
UNINITIALIZED_DATA MEM_READ MEM_WRITE
03 .rdata VirtSize: 0000015C VirtAddr: 00009000
raw data offs: 00006000 raw data size: 00000200
relocation offs: 00000000 relocations: 00000000
line # offs: 00000000 line #'s: 00000000
characteristics: 40000040
INITIALIZED_DATA MEM_READ
04 .data VirtSize: 0000239C VirtAddr: 0000A000
raw data offs: 00006200 raw data size: 00002400
relocation offs: 00000000 relocations: 00000000
line # offs: 00000000 line #'s: 00000000
characteristics: C0000040
INITIALIZED_DATA MEM_READ MEM_WRITE
05 .idata VirtSize: 0000033E VirtAddr: 0000D000
raw data offs: 00008600 raw data size: 00000400
relocation offs: 00000000 relocations: 00000000
line # offs: 00000000 line #'s: 00000000
characteristics: C0000040
INITIALIZED_DATA MEM_READ MEM_WRITE
06 .reloc VirtSize: 000006CE VirtAddr: 0000E000
raw data offs: 00008A00 raw data size: 00000800
relocation offs: 00000000 relocations: 00000000
line # offs: 00000000 line #'s: 00000000
characteristics: 42000040
INITIALIZED_DATA MEM_DISCARDABLE MEM_READ
表 5 一个典型OBJ文件的块表
01 .drectve PhysAddr: 00000000 VirtAddr: 00000000
raw data offs: 000000DC raw data size: 00000026
relocation offs: 00000000 relocations: 00000000
line # offs: 00000000 line #'s: 00000000
characteristics: 00100A00
LNK_INFO LNK_REMOVE
02 .debug$S PhysAddr: 00000026 VirtAddr: 00000000
raw data offs: 00000102 raw data size: 000016D0
relocation offs: 000017D2 relocations: 00000032
line # offs: 00000000 line #'s: 00000000
characteristics: 42100048
INITIALIZED_DATA MEM_DISCARDABLE MEM_READ
03 .data PhysAddr: 000016F6 VirtAddr: 00000000
raw data offs: 000019C6 raw data size: 00000D87
relocation offs: 0000274D relocations: 00000045
line # offs: 00000000 line #'s: 00000000
characteristics: C0400040
INITIALIZED_DATA MEM_READ MEM_WRITE
04 .text PhysAddr: 0000247D VirtAddr: 00000000
raw data offs: 000029FF raw data size: 000010DA
relocation offs: 00003AD9 relocations: 000000E9
line # offs: 000043F3 line #'s: 000000D9
characteristics: 60500020
CODE MEM_EXECUTE MEM_READ
05 .debug$T PhysAddr: 00003557 VirtAddr: 00000000
raw data offs: 00004909 raw data size: 00000030
relocation offs: 00000000 relocations: 00000000
line # offs: 00000000 line #'s: 00000000
characteristics: 42100048
INITIALIZED_DATA MEM_DISCARDABLE MEM_READ
每个IAMGE_SECTION_HEADER都有一个如图7 描述的格式。注意每个块中存储的信息缺失了什么是很有趣的。首先,注意没有指明任何预载入的属性。NE文件格式允许你指定应该和模块一起载入的预载入段的属性。OS/2? 2.0 LX 格式有点类似,允许你指定预载入八页(内存页:译注,下同) 。PE格式就没有任何类似的东西。微软必须确保Win32 需求页面的载入性能。
表 6 IMAGE_SECTION_HEADER 的格式
BYTE Name[IMAGE_SIZEOF_SHORT_NAME]
这是一个为块命名的8字节ANSI名字(不UNICODE)。大部分块名开始于一个 ". "(比如".text"),但这并非必须的,就像你可能相信的一些PE文档一样。你可以在汇编语言中用任何一个段指示你自己的块。或者在微软C/C++编译器中用"#pragma data_seg"来指示。需要注意的是如果块名占满8个字节,就没有NULL结束字节了。如果你热衷于 printf ,你可以用 %8s来避免把这个名字拷贝到一个缓冲区中,然后又在结尾加上一个NULL字节。
union {
DWORD PhysicalAddress
DWORD VirtualSize
} Misc;
在EXE和OBJ中,这个域的意义不同。在EXE中,它保存代码或者数据的实际尺寸。这个尺寸是未经过校准文件对齐尺寸并进位的。后面要讲到的这个结构的SizeOfRawData 域(这个词有点不确切)保存了校准文件对齐尺寸并进位后的尺寸。Borland 的连接器调换了这两个域的意思,于是看上去就是正确的了。对OBJ文件,这个域指示块的物理尺寸。第一个块开始于地址0 。为找到OBJ 文件中的下一个块,把SizeOfRawData加到当前块基址上即可。
DWORD VirtualAddress
在EXE中,这个域保存决定载入器把这个块映射到内存中哪个位置的RVA 。为计算一个给定的块在内存中的实际起始地址,把这个映像的基址加上存储在这个域的VirtualAddress即可。用微软的工具,第一个块的默认RVA是0x1000 。在OBJ文件中,这个域没有意义,被置为0 。
DWORD SizeOfRawData
在EXE中,这个域包含这个块按文件对齐尺寸进位后的尺寸。比如说,假定一个文件的对齐尺寸是0x200 。如果这个块的VirtualAddress域(前面那个域)的是0x35a ,那么这个域就是0x400 。在OBJ文件中,这个域包含由编译器或汇编器提供的块的精确尺寸。换句话说,对OBJ ,它等价于EXE中的VirtualSize域。
DWORD PointerToRawData
这是一个基于文件的偏移,通过这个偏移,可以找到由编译器或汇编器产生的生鲜数据。如果你的程序自己要把一个PE或COFF文件映射到内存(而不是让操作系统来载入),那么这个域比VirtualAddress更重要。在这种情况下你有一个完全线性的文件映射,所以你会在这个偏移处找到块的数据,而不是在VirtualAddress域指定的RVA 处找到。
DWORD PointerToRelocations
在OBJ中,这是指向块的重定位信息的基于文件的偏移值。每个OBJ块的重定位信息紧跟在这个块的生鲜数据之后。在EXE中,这个域(和后面的)是没有意义的,被置为0 。连接器产生EXE时,它解决了大部分的这种修正值,只剩下基址的重定位和导入函数,将在载入时解决。关于基本重定位信息和导入函数保留在他们自己的块中,所以对一个EXE ,没有必要在每个块的生鲜数据之后都紧跟它的重定位信息。
DWORD PointerToLinenumbers
这是行号表基于文件的偏移量。行号表把源文件的一行和(编译器)为这一行产生的(机器)代码的首址联系起来。在如CodeView格式的现代调试格式中,行号信息存储为调试信息的一部分。然而,在COFF调试格式中,行号信息和符号名/型信息的存储是分开的。通常只有代码块(如 .text )有行号信息。在EXE文件中,行号信息在块的生鲜数据之后,朝着文件的结尾方向收集。在OBJ文件中,一个块的行号信息跟在生鲜块数据和这个块的重定位表之后。
WORD NumberOfRelocations
块的重定位表中的重定位项的数目(参考上面的PointerToRelocations域)。这个域似乎只和OBJ文件有关。
WORD NumberOfLinenumbers
块的行号表中的行号项的数目(参考上面的PointerToLinenumbers域)。
DWORD Characteristics
大部分程序员的称之为标志,COFF/PE格式称之为特征。这个域是指示块属性的标志集(如代码/数据,可读,可写)。一个对所有可能的块属性的完整的列表,见WINNT.H中的IMAGE_SCN_XXX_XXX的定义。如下是比较重要的一些标志:
0x00000020 这个块包含代码。通常和可执行标志(0x80000000)一起置位。
0x00000040 这个块包含已初始化的数据。除了可执行块和 .bss 块之外几乎所有的块的这个标志都置位。
0x00000080 这个块包含未初始化的数据(如 .bss 块)
0x00000200 这个块包含注释或其它的信息。这个块的一个典型用法是编译器产生的 .drectve 块,包含链接器命令。
0x00000800 这个块的内容不应放进最终的EXE文件中。这些块是编译器或汇编器用来给连接器传递信息的。0x02000000 这个块可以被丢弃,因为一旦它被载入,其进程就不需要它了。最通常的可丢弃块是基本重定位块( .reloc )。
0x10000000 这个块是可共享的。和DLL一起使用时,这个块的数据可以在使用这个DLL的进程之间共享。默认时数据块是非共享的,这意味着使用这个DLL的各个进程都有自己对这个块的数据的副本。在更专业的术语中,共享块告诉内存管理器把使用这个DLL的所有进程把的这个块的页面映射到内存中相同的物理页面。为使一个块可共享,在连接时用SHARE属性。如:
LINK /SECTION:MYDATA,RWS ...
告诉连接器叫做"MYDATA"的块是可读的,可写的,共享的。
0x20000000 这个块是可执行的。这个标志通常在"包含代码"标志(0x00000020)被置位时置位。
0x40000000 这个块是可读的。在EXE文件中,这个域几乎总被置位。
0x80000000 这个块是可写的。如果在一个EXE块中这个块未被置位,载入器会把这块的内存映射页面标为只读或"只执行"。有此属性的典型的块是 .data 和 .bss 。有趣的是,.idata 块也有这个属性。
PE格式中还缺少"页表"的概念。在LX格式中,OS/2的IMAGE_SECTION_TABLE等价物不直接指向文件中的代码或数据块。代替的,它指向一个指示块中特定范围的属性和位置的页查找表。PE格式分配所有的,并且确保所有的块中的数据将连续的存储在文件中。比较这两种格式:LX可以允许更大的灵活性,但PE风格更简单,更容易协同工作。我已经写了这两种文件的Dumper 。
PE格式另一个值得欢迎的改变是所有项目的位置都存储为简单的双字(DWORD)偏移。在NE格式中,几乎所有东西的位置都存储为它们的扇区值。为了得到实际的偏移,你第一步需要查找NE首部的对齐单元尺寸并把它转化为扇区尺寸(典型的是 16 和512 字节)。然后你需要把扇区尺寸乘以指定的扇区偏移才得到实际的文件偏移。如果NE文件的某些东西偶然存储为一个扇区偏移,这可能是相对于NE首部的。因为NE首部并不在文件的开始,你需要在自己的代码中调整这个文件的NE首部。总之,PE格式比NE,LX,或LE格式更容易协同工作(假定你能使用内存映像文件)。
4 通用块
已经看到了大体上块是什么和它们位于何处,让我们看一下你将会在EXE和OBJ文件中找到的通用块。这个列表决不是完整的,但包含了你每天都碰到的块(甚至你没有意识到的)。
.text 块是编译器或汇编器结束时产生的通用代码块。因为PE文件运行在32位模式下,并且没有16位段的限制,没有理由根据分开的源文件把代码分为分开的块。代替的,连接器把从不同的OBJ文件得来的 .text 块连接起来放到EXE文件中的一个大 .text 块中。如果你用 Borland C++ ,编译器把产生的代码放到名为 CODE 的块中。Borland C++ 生成的PE文件有一个名为 CODE 的块而不是名为 .text 。我将会简短的解释一下。
Figure 2. Calling a function in another module
对我来说,除了我用编译器创建的或从运行时库中得到的代码外,在 .text 块中找到附加的代码是比较有趣的。在一个PE文件中,当你在另一模块中调用一个函数时(比如在USER32.DLL中的GetMessage ),编译器产生的CALL 指令并不把控制直接转移到在DLL中的这个函数(见图8)。代替的,CALL 指令把把控制转移到一个也在 .text 中的
JMP DWORD PTR [XXXXXXXX]
指令处。这个 JMP 指令(译注1)通过一个在 .idata 中的DWORD变量间接的转移控制。 .idata 块的DWORD包含操作系统函数入口的实际地址。在对这进行一会儿回想之后,我开始理解为什么DLL调用用这种方式来实现。通过一个位置传送所有的对一个给定的DLL函数的调用,载入器不需要改变每个调用DLL的指令。所有的PE载入器必须做的是把目标函数的正确地址放到 .idata 的一个 DWORD 中。不需要改变任何call指令。在NE文件中就不同了,每个段都包含一个需要应用到这个段上的一个修正表。如果这个段把一个给定的DLL函数调用了20次,载入器必须把这个函数的地址写入到这个段的每个调用指令中。PE方法的缺点是你不能用一个DLL函数的真实地址来初始化一个变量。比如,你要考虑这样的情况:
FARPROC pfnGetMessage = GetMessage;
将把GetMessage的地址存到变量 pfnGetMessage 中。在16位Windows中,这可以工作,但在Win32中不能。在Win32中,变量pfnGetMessage最终存储的是我前面提到的JMP DWORD PTR [XXXXXXXX] 替换指示(译注2)。如果你想通过函数指针调用一个函数,事情也会如你所预料的一样。但是,如果你想读取 GetMessage 开始的字节,你将不能如愿(除非你自己做跟在 .idata 指针后的工作)。后面我将会返回到这个话题上--在导入表的讨论中。
译注1:英文 thunk,正统的计算机专业术语为"形实转换程序",类似宏(macro)替换,故我将它译为"替换指示",指在具体指令中xxxxxxxx 被替换,后面出现的替换指示同。
译注2:现在的编译器如VC6以上等等,产生的导入函数调用代码不再是先来一个相对Call指令到 jmp [xxxx] 处,然后再到 xxxx 处(真正的导入函数入口),而是用了一种效率更高,也更容易让人理解的方式:call [xxxx] 。以前用那种间接的方式多是为兼容编译器。但是现在仍有一些编译器,如MASM,直到版本7.0,还是用前面那种间接的方式,从这里也可以看出微软对ASM的态度了。
虽然 Borland 可以让编译器输出的代码块名为 .text ,但它是选择 NAME 作为默认的段名。为了确定PE文件中的块名,Borland 的连接器(TLINK32.EXE)从OBJ文件中取出段名并把它截断为8字符(如果有必要)。
当块名的不同只是一个小问题时,Borland PE 文件怎样链接到其它模块就是一个重要的不同。就像我在 .text 的描述中提到的,所有到OBJ的调用通过一个JMP DWORD PTR [XXXXXXXX]替换指示。在微软系统下,这条指令通过一个导入库到达 .text 块。因为库管理器(LIB32)当你链接外部DLL时才创建导入库(和这个替换指示),连接器自己不需要"知道"怎样生成这这个替换指示。导入库实际上只不过是链接到这个PE文件的一些更多的代码和数据。
Borland 处理导入函数的系统只是一个简单的16位NE文件方式扩展。Borland 连接器使用的导入库实际上只不过是一个函数名连同它所在的DLL名的列表。于是TLINK32就有责任确定外部DLL的修正,并生为它成一个适当的JMP DWORD PTR [XXXXXXXX] 替换指示 。TLINK32把这个替换指示存储在它创建的名为 .icode 块中。正像 .text 是默认的代码块,.data 块是已初始化数据的归宿。这些数据包含编译时初始化的全局和静态局部变量。它还包括文字字符串。连接器把从OBJ/LIB文件得来的所有 .data 块组合到EXE文件的一个 .data 块中。局部变量载入到一个线程的堆栈中,在 .data 或 .bss 中不占空间。
.bss 块是存储未初始化的全局和静态局部变量的地方。连接器把 OBJ/LIB 文件中的所有 .bss 块链接到EXE文件的一个 .bss 块中。在块表中,.bss 块的RawDataOffset 域置为0 ,表示这个块在文件中不占用任何空间。TLINK 不产生这个块。代替的,它扩展 DATA 块的虚拟尺寸(virtual size)。
.CRT 块是微软 C/C++ 运行时库利用的另一个已初始化数据的块(从名字)。我不能理解为什么这些数据不放在 .data 中。(译注)
译注:从CRT的字面意思看,应该是"C Run Time",即C运行时库。
.rsrc 块这个模块的所有资源。在Windows NT的早期,16位RC.EXE输出的RES文件是微软的PE连接器不能识别的格式。CVTRES 程序把这种格式的RES文件转换成COFF格式的OBJ文件,把资源数据放在 OBJ 的 .rsrc 块中。连接器就可以把这个资源OBJ当作另一个OBJ来链接了,允许连接器"知道"关于资源的特殊东西。微软最近发布的更多连接器可以直接处理RES文件。
.idata 块包含关于这个模块从其它DLL导入的函数(和数据)的信息(译注)。这个块和NE文件的模块引用表是等价的。一个关键的不同是PE文件导入的每个函数都明确的列在这个块中。为找到NE文件中的等价信息,你必须去挖掘这个段生鲜数据的结尾的重定位信息。
译注:现在许多编译器产生的EXE文件都没有这个块,然而ImportTable并不是没有了,代替的,ImportTable仅由DataDirectory[1]指示,一般指向.text块或.data块中。
.edata 块是这个PE文件导出到其它模块的函数和数据的列表。它的NE文件等价物是条目表的联合,驻留名表,和非驻留名表,和16位Windows不一样,很少有理由从一个EXE文件导出一些东西,所以你通常只在DLL中看到 .edata 块。当使用微软的工具时,.edata 块中的数据通过EXP文件来到PE文件中。换种方法,连接器不为它自己生成这个信息。代替的,它依赖库管理器(LIB32)来扫描OBJ文件,并创建EXP文件,连接器要把它要链接的模块的列表加入其中。是的,好!这些麻烦的EXP文件实际上只是扩展名不同的OBJ文件而已。
.reloc 块保持一个基本重定位表。基本重定位是一个对一条指令或已初始化的变量值的调整,如果载入器不能把这个文件载入到连接器假定的位置,这就是很重要的了。如果载入器能把这个映像载入到连接器建议(prefer)的基地址,载入器就完全忽略这个块的重定位信息。如果你愿意冒险,并且希望载入器可以始终把这个映像载入到假定的基址,你可以通过 /FIXED 选项告诉链接器去除这个信息。这样可以在可执行文件中节省空间,但会导致这个可执行文件在其它的Win32实现中不能工作。比如,假定你为Windows NT建立了一个EXE文件,并且把基址设为 0x10000 。如果你让连接器去除重定位信息,这个EXE文件在Windows95下将不能运行,因为在这里地址0x10000已被系统使用了。
注意编译器生成的JMP和CALL指令是很重要的,首选它使用相对偏移量的版本,而非32位平坦段中的真实偏移量版本。如果映像需要被载入非连接器假定的基址处,这些指令不需要改变,因为它使用的是相对寻址。结果就是,并不需要你想象的那么多的重定位。重定位通常只需要使用指向一些数据的32位偏移。举个例子,让我们看一下,你有如下的全局变量声明:
int i;
int *ptr = &i;
如果连接器假定一个0x10000的映像基址,变量i的地址将最终是一个特定值如0x12004 。在用来存放指针"ptr"的内存中,连接器将写进0x12004 ,因为这是变量 i 的地址。如果载入器由于某种原因决定把这个文件载入基址0x70000处,变量i的地址将是0x72004 。.reloc 块是映像中的一些内存位置的列表,这些内存位置在连接时连接器假定的载入地址和实际需要的载入地址是不同的,这个因素需要考虑。
当你使用编译器指令 __declspec(thread) 时,你定义的数据不在 .data 和 .bss 块种。它最终在 .tls 块中,这个块指示"线程局部存储",并且和Win32的TlsAlloc函数族相联系。处理 .tls 块时,内存管理器设置页表以便进程在任何时刻切换线程时,都有一个新的物理内存页集映射到 .tls 块的地址空间。这就允许线程内的全局变量。在大部分情况下,利用这种机制,比基于线程分配内存并把其指针存在一个 "TlsAlloc 过的"(注:原文TlsAlloc'ed)槽(注:原文Slot)中要容易的多。
不幸的是,有一点需要注意--必须深入研究.tls 块和 __declspec(thread) 的变量。在WindowsNT 和Windows95 中,如果DLL是被载入库动态载入的,这种线程局部存储机制将不能在这个DLL中工作。然而在EXE中或一个隐含载入的DLL中,一切都工作正常。如果你不隐含链接到这个DLL ,但需要按线程的数据,你必须会到过去并使用 TlsAlloc 和 TlsGetValue 这种原始方式来设置线程动态内存分配。
虽然 .rdata 块通常在 .data 和 .bss 块之间,你的程序一般看不见并使用这些块中的数据。.rdata 块至少在两种东西中使用。第一,在微软连接器生成的EXE中,.rdata 块存放调试目录,这只在EXE文件中出现。(在 TLINK32 的 EXE 中,调试目录在名为 ".DEBUG"的块中)。调试目录是一个IMAGE_DEBUG_DIRECTORY结构数组。这些结构保持存储在文件中的变量的类型,尺寸,和位置的调试信息。三种主要的调试信息类型显示如下:CodeView?, COFF,和 FPO,表9显示了PEDUMP输出的一个典型的调试目录。
表 7 一个典型的调试目录
Type Size Address FilePtr Charactr TimeDate Version
COFF 000065C5 00000000 00009200 00000000 2CF8CF3D 0.00
??? 00000114 00000000 0000F7C8 00000000 2CF8CF3D 0.00
FPO 000004B0 00000000 0000F8DC 00000000 2CF8CF3D 0.00
CODEVIEW 0000B0B4 00000000 0000FD8C 00000000 2CF8CF3D 0.00
调试目录不必在 .rdata 块的开始找到。为找到调试目录表的开始,使用数据目录的第七个条目(IMAGE_DIRECTORY_ENTRY_DEBUG)的RVA。数据目录在文件的PE首部结尾部分。为确定微软连接器生成的调试目录的条目数,用调试目录的尺寸(在数据目录条目的尺寸域)除以一个IMAGE_DEBUG_DIRECTORY结构的尺寸即可。TLINK32产生一个简单的数目,通常是1 。PEDUMP示例程序描述了这一点。
.rdata 域的另一个有用的部分是"描述串"。如果你在程序的DEF文件中指定一个DESCRIPTION条目,这个指定的描述串就出现在 .rdata 块中。在NE格式中,描述串总是非驻留名表的第一个条目。描述串是用来保持一个描述这个文件的有用的文本串的。不幸的是,我还没找到一条便捷的途径来得到它。我看到有些描述串在PE文件的调试目录之前,在另一些文件中它在调试目录之后。我找不到得到这个描述串的一致的方法(或甚至这种方法根本就不存在)。
.debug$S 和 .debug$T 块只出现在 OBJ 中。他们保存 CodeView 调试符号和类型信息。这些块名是从以前16位编译器(
![](http://s.csdnimg.cn/static/bower-libs/MathJax/fonts/HTML-CSS/TeX/png/Math/Italic/400/0053.png?rev=2.4-beta-2)
![](http://s.csdnimg.cn/static/bower-libs/MathJax/fonts/HTML-CSS/TeX/png/Math/Italic/400/0059.png?rev=2.4-beta-2)
![](http://s.csdnimg.cn/static/bower-libs/MathJax/fonts/HTML-CSS/TeX/png/Math/Italic/400/004D.png?rev=2.4-beta-2)
![](http://s.csdnimg.cn/static/bower-libs/MathJax/fonts/HTML-CSS/TeX/png/Math/Italic/400/0042.png?rev=2.4-beta-2)
![](http://s.csdnimg.cn/static/bower-libs/MathJax/fonts/HTML-CSS/TeX/png/Math/Italic/400/004F.png?rev=2.4-beta-2)
![](http://s.csdnimg.cn/static/bower-libs/MathJax/fonts/HTML-CSS/TeX/png/Math/Italic/400/004C.png?rev=2.4-beta-2)
![](http://s.csdnimg.cn/static/bower-libs/MathJax/fonts/HTML-CSS/TeX/png/Math/Italic/400/0053.png?rev=2.4-beta-2)
.drectve 块只出现在OBJ文件中。它包含用文本表示的连接器命令。比如,在我用微软编译器编译的任一OBJ中,下面的字符串都出现在 .drectve 块中:
-defaultlib:LIBC -defaultlib:OLDNAMES
当你在程序中用 __declspec(export) 时,编译器简单的把等价的命令行输出到 .drectve 块中(例如:"-exprot:MyFunction")。
在玩弄 PEDUMP 的过程中,我不时的遇到其它块。例如,在Window95的KERNEL32.DLL中,有LOCKCODE和LOCKDATA块。大概这是一种特殊的页处理方法,是为了避免缺页(译注)。
译注:缺页,在页式内存管理中,一条指令访问的虚拟内存未映射到物理内存中,此时将发生缺页中断,关于缺页中断,请参阅操作系统相关书籍。
从这里学到两个教训。第一:不要以为有约束而只使用编译器或汇编器提供的标准块。如果由于某种原因你需要一个分开的块,不要犹豫,自己去创建!在C/C++编译器中,使用 #pragma code_seg 和 #pragma data_seg 。在汇编语言中,只不过是创建一个名字和和标准块不同的32位的段(将成为一个块)。如果使用TLINK32 ,你必须使用一个不同的类,或者关掉代码段包装(packing)。其它要记住的东西是使用非标准块名你将会更透彻的理解特殊PE文件的意图和实现。
5 PE文件的导入表
前面,我描述了函数调用怎样到一个外部DLL中而不直接调用这个DLL 。代替的,在执行体中的 .text 块中(如果你用Borland C++ 就是 .icode 块),CALL指令到达一条
JMP DWORD PTR [XXXXXXXX]
指令处。JMP指令寻找的地址把控制转移到实际的目标地址。PE文件的 .idata 会包含一些必要的信息,这些信息是载入器用来确定目标函数的地址以及在执行体映像中去修正他们的。
.idata 块(或称导入表,我更喜欢这样叫)开始于一个IMAGE_IMPORT_DESCRIPTOR数组。每个DLL都有一个PE文件隐含链接上的IMAGE_IMPORT_DESCRIPTOR。没有指定这个数组中结构的数目的域。代替的,这个数组的最后一个元素是一个全NULL的IMAGE_IMPORT_DESCRIPTOR 。IMAGE_IMPORT_DESCRIPTOR的格式显示在表8 。
表 8 IMAGE_IMPORT_DESCRIPTOR Format
DWORD Characteristics
在一个时刻,这可能已是一个标志集。然而,微软改变了它的涵义并不再糊涂地升级WINNT.H 。这个月实际上是一个指向指针数组的偏移(RVA)。其中每个指针都指向一个IMAGE_IMPORT_BY_NAME结构。
DWORD TimeDateStamp
指示这个文件的创建时间。
DWORD ForwarderChain
这个域联系到前向链。前向链包括一个DLL函数向另一个DLL转送引用。比如,在WindowsNT中,NTDLL.DLL就出现了的一些前向的它向KERNEL32.DLL导出的函数。应用程序可能以为它调用的是NTDLL.DLL中的函数,但它最终调用的是KERNEL32.DLL中的函数。这个域还包含一个FirstThunk数组的索引(即刻描述)。用这个域索引得函数会前向引用到另一个DLL 。不幸的是,函数怎样前向引用的格式没有文档,并且前向函数的例子也很难找。
DWORD Name
这是导入DLL的名字,指向以NULL结尾的ASCII字符串。通用例子是KERNEL32.DLL和USER32.DLL 。
PIMAGE_THUNK_DATA FirstThunk
这个域是指向IMAGE_THUNK_DATA联合的偏移(RVA)。几乎在任何情况下,这个域都解释为一个指向的IMAGE_IMPORT_BY_NAME结构的指针。如果这个域不是这些指针中的一个,那它就被当作一个将从这个被导入的DLL的导出序数值。如果你实际上可以从序数导入一个函数而不是从名字导入,从文档看,这是不清楚的。
IMAGE_IMPORT_DESCRIPTOR 的一个重要部分是导入的DLL的名自和两个IMAGE_IMPORT_BY_NAME指针数组。在EXE文件中,这两个数组(由Characteristics域和FirstThunk域指向)是相互平行的,都是以NULL指针作为数组的最后一个元素。两个数组中的指针都指向 IMAGE_IMPORT_BY_NAME 结构。表3以图形显示了这种布局。表12显示了PEDUMP对一个导入表的输出。
图 3. 两个平行的指针数组
表 9. 一个EXE文件的导入表
GDI32.dll
Hint/Name Table: 00013064
TimeDateStamp: 2C51B75B
ForwarderChain: FFFFFFFF
First thunk RVA: 00013214
Ordn Name
48 CreatePen
57 CreateSolidBrush
62 DeleteObject
160 GetDeviceCaps
// Rest of table omitted...
KERNEL32.dll
Hint/Name Table: 0001309C
TimeDateStamp: 2C4865A0
ForwarderChain: 00000014
First thunk RVA: 0001324C
Ordn Name
83 ExitProcess
137 GetCommandLineA
179 GetEnvironmentStrings
202 GetModuleHandleA
// Rest of table omitted...
SHELL32.dll
Hint/Name Table: 00013138
TimeDateStamp: 2C41A383
ForwarderChain: FFFFFFFF
First thunk RVA: 000132E8
Ordn Name
46 ShellAboutA
USER32.dll
Hint/Name Table: 00013140
TimeDateStamp: 2C474EDF
ForwarderChain: FFFFFFFF
First thunk RVA: 000132F0
Ordn Name
10 BeginPaint
35 CharUpperA
39 CheckDlgButton
40 CheckMenuItem
// Rest of table omitted...
PE文件的导入表的每一个函数有一个 IMAGE_IMPORT_BY_NAME 结构。IMAGE_IMPORT_BY_NAME结构非常简单,看上去是这样:
WORD Hint;
BYTE Name[?];
第一个域是导入函数的导出序数的最佳猜测。和NE文件不同,这个值不是必须正确的。于是,载入器指示把它当作一个进行二分查找的建议开始值。下一个是导入函数的名字的ASCIIZ字符串。
为什么有两个平行的指针数组指向结构IMAGE_IMPORT_BY_NAME ?第一个数组(由Characteristics域指向的)单独的留下来,并不被修改。经常被称作提名表。第二个数组(由FirstThunk域指向的)将被PE载入器覆盖。载入器在这个数组中迭代每个指针,并查找每个IMAGE_IMPORT_BY_NAME结构指向的函数的地址。载入器然后用找到的函数地址覆盖这个指向IMAGE_IMPORT_BY_NAME结构的指针。JMP DWORD PTR [XXXXXXXX] 替换指示中的 [XXXXXXXX] 表示 FirstThunk 数组的一个条目。因为由载入器覆盖的这个指针数组实际上保持所有导入函数的地址,叫做"导入地址表"。
对Borland用户,上面的描述有点别扭。由TLINK32产生的PE文件缺少其中一个数组。在这样一个执行体中,IMAGE_IMPORT_DESCRIPTOR(提名数组)中Characteristics域的是0 。于是,仅有的由FirstThunk域(导入地址表)指向的数组在PE文件中就是必须的了。故事到这里应该结束了,除非在我写PEDUMP时深入一个有趣的问题中。在优化上无止境的探索,微软在WindowsNT中"优化"了系统DLL(KERNEL32.DLL等等)的thunk数组。在这个优化中,这个数组中的指针不再指向IMAGE_IMPORT_BY_NAME结构,它们已经包含了导入函数的地址。换句话说,载入器不需要去查找函数的地址并用导入函数的地址覆盖thunk数组(译注)。对希望这个数组包含指向IMAGE_IMPORT_BY_NAME结构的指针的PEDump程序,这导致了一个问题。你可能正在思考,"但是,Matt ,为什么呢不顺便使用提名表数组?"这可能是一个完美的解决方案,除非提名表数组在Borland文件中不存在。PEDUMP处理所有这些情况,但是代码理所当然的就有些杂乱。
译注: 这就是 Bound Import,关于Bound Import,请参阅:
Matt Pietrek "Inside Windows An In-Depth Look into the Win32 Portable Executable File Format, Part 2 " From MSDN Magazine March 2002 on Internet
URL :http://msdn.microsoft.com/msdnmag/issues/02/03/PE2/PE2.asp
因为导入地址表在一个可写的块中,拦截一个EXE或DLL对另一个DLL的调用就相对容易。只需要修改适当地导入地址条目去指向希望拦截的函数。不需要修改调用者或被调者的任何代码。
注意微软产生的PE文件的导入表并不是完全被连接器同步的,这一点很有趣。所有对另一个DLL中的函数的调用的指令都在一个导入库中。当你连接一个DLL时,库管理器(LIB32.EXE或LIB.EXE)扫描将要被连接的OBJ文件并且创建一个导入库。这个导入库完全不同于16位NE文件连接器使用的导入库。32位库管理器产生的导入库有一个.text块和几个.idata$块。导入库中的.text块包含 JMP [XXXX] 的替换指示,这个替换指示在OBJ文件的符号表中有一个名字来存储它。这个符号名对将从DLL中导出的所有函数名都是唯一的(例如:_Dispatch_Message@4)。导入库中的一个.idata$块包含一个从其中引用的替换指示(译注:即JMP [XXXX]中的XXXX)。另一个.idata$块有一个导入函数名之前的提示序号(hint ordinal)的空间。这两个域就组成了IMAGE_IMPORT_BY_NAME结构。当你晚连接一个使用导入库的PE文件时,导入库的块被加到连接器需要处理的在OBJ文件中的你的块的列表中。一旦导入库中的这个替换指示的名字和和要导入的函数名相同,连接器就假定这个替换指示就是这个导入函数,并修正对这个导入函数,使其指向这个替换指示。导入库中的这个替换指示在本质上就被当作这个导入函数本身了。
除了提供一个导入函数替换指示的代码部分,导入库还提供PE文件的.idata块(或称导入表)的片断。这些片断来自于库管理器放入导入库中的不同的.idata$块。简而言之,连接器实际上不知道出现在不同的OBJ文件中的导入函数和普通函数之间的不同。连接器只是按照它的边框调整规则去建立并结合块,于是,所有的事情就自然顺理成章了。
6 术语
生鲜数据:原文"RawData",意指未加工过的数据,即原原本本从磁盘上读入而未经过任何改动的数据。
替换指示:原文"thunk",本质上是一条指令,这条指令中有浮动的地址域。如文中的 jmp [xxxx],其中xxxx是一个浮动地址(floating address),或称可重定位地址(relocatable address)。
OBJ文件:Object文件,即编译器编译产生的目标文件,这种文件只有在(和LIB)连接之后,才能形成可执行文件。
LIB文件:库文件,这种文件中包含一些二进制的代码(数据)及其符号,一般情况下,用到LIB中的哪个符号,连接器连接时,关于那个符号的二进制代码(数据)才会放入最终的执行体中。
RES文件:Widows资源文件,由RC.EXE编译。
EXE文件:不用多说Windows下的可执行文件,这类文件一般有导入表(Import Table)。有少数这类文件有导出表(Export Table)。
DLL文件:Dinamic Link Library ,即动态连接库,用来向其它执行体导出函数(或数据等)。