MS-DOS头部/实模式头部
如上所述,PE文件格式的第一个组成部分是MS-DOS头部。在PE文件格式中,它并非一个新概念,因为它与MS-DOS 2.0以来就已有的MS-DOS头部是完全一样的。保留这个相同结构的最主要原因是,当你尝试在Windows 3.1以下或MS-DOS 2.0以上的系统下装载一个文件的时候,操作系统能够读取这个文件并明白它是和当前系统不相兼容的。换句话说,当你在MS-DOS 6.0下运行一个Windows NT可执行文件时,你会得到这样一条消息:“This program cannot be run in DOS mode.”如果MS-DOS头部不是作为PE文件格式的第一部分的话,操作系统装载文件的时候就会失败,并提供一些完全没用的信息,例如:“The name specified is not recognized as an internal or external command, operable program or batch file.”
MS-DOS头部占据了PE文件的头64个字节,描述它内容的结构如下:
WINNT.H
typedef struct _IMAGE_DOS_HEADER { // DOS的.EXE头部
USHORT e_magic; // 魔术数字
USHORT e_cblp; // 文件最后页的字节数
USHORT e_cp; // 文件页数
USHORT e_crlc; // 重定义元素个数
USHORT e_cparhdr; // 头部尺寸,以段落为单位
USHORT e_minalloc; // 所需的最小附加段
USHORT e_maxalloc; // 所需的最大附加段
USHORT e_ss; // 初始的SS值(相对偏移量)
USHORT e_sp; // 初始的SP值
USHORT e_csum; // 校验和
USHORT e_ip; // 初始的IP值
USHORT e_cs; // 初始的CS值(相对偏移量)
USHORT e_lfarlc; // 重分配表文件地址
USHORT e_ovno; // 覆盖号
USHORT e_res[4]; // 保留字
USHORT e_oemid; // OEM标识符(相对e_oeminfo)
USHORT e_oeminfo; // OEM信息
USHORT e_res2[10]; // 保留字
LONG e_lfanew; // 新exe头部的文件地址
} IMAGE_DOS_HEADER, *PIMAGE_DOS_HEADER;
第一个域e_magic,被称为魔术数字,它被用于表示一个MS-DOS兼容的文件类型。所有MS-DOS兼容的可执行文件都将这个值设为0x5A4D,表示ASCII字符MZ。MS-DOS头部之所以有的时候被称为MZ头部,就是这个缘故。还有许多其它的域对于MS-DOS操作系统来说都有用,但是对于Windows NT来说,这个结构中只有一个有用的域——最后一个域e_lfnew,一个4字节的文件偏移量,PE文件头部就是由它定位的。对于Windows NT的PE文件来说,PE文件头部是紧跟在MS-DOS头部和实模式程序残余之后的。
实模式残余程序
实模式残余程序是一个在装载时能够被MS-DOS运行的实际程序。对于一个MS-DOS的可执行映像文件,应用程序就是从这里执行的。对于Windows、OS/2、Windows NT这些操作系统来说,MS-DOS残余程序就代替了主程序的位置被放在这里。这种残余程序通常什么也不做,而只是输出一行文本,例如:“This program requires Microsoft Windows v3.1 or greater.”当然,用户可以在此放入任何的残余程序,这就意味着你可能经常看到像这样的东西:“You can't run a Windows NT application on OS/2, it's simply not possible.”
当为Windows 3.1构建一个应用程序的时候,链接器将向你的可执行文件中链接一个名为WINSTUB.EXE的默认残余程序。你可以用一个基于MS-DOS的有效程序取代WINSTUB,并且用STUB模块定义语句指示链接器,这样就能够取代链接器的默认行为。为Windows NT开发的应用程序可以通过使用-STUB:链接器选项来实现。
PE文件头部与标志
PE文件头部是由MS-DOS头部的e_lfanew域定位的,这个域只是给出了文件的偏移量,所以要确定PE头部的实际内存映射地址,就需要添加文件的内存映射基地址。例如,以下的宏是包含在PEFILE.H源文件之中的:
PEFILE.H
#define NTSIGNATURE(a) ((LPVOID)((BYTE *)a + ((PIMAGE_DOS_HEADER)a)->e_lfanew))
在处理PE文件信息的时候,我发现文件之中有些位置需要经常查阅。既然这些位置仅仅是对文件的偏移量,那么用宏来实现这些定位就比较容易,因为它们较之函数有更好的表现。
请注意这个宏所获得的是PE文件标志,而并非PE文件头部的偏移量。那是由于自Windows与OS/2的可执行文件开始,.EXE文件都被赋予了目标操作系统的标志。对于Windows NT的PE文件格式而言,这一标志在PE文件头部结构之前。在Windows和OS/2的某些版本中,这一标志是文件头的第一个字。同样,对于PE文件格式,Windows NT使用了一个DWORD值。
以上的宏返回了文件标志的偏移量,而不管它是哪种类型的可执行文件。所以,文件头部是在DWORD标志之后,还是在WORD标志处,是由这个标志是否Windows NT文件标志所决定的。要解决这个问题,我编写了ImageFileType函数(如下),它返回了映像文件的类型:
PEFILE.C
DWORD WINAPI ImageFileType (LPVOID lpFile)
{
/* 首先出现的是DOS文件标志 */
if (*(USHORT *)lpFile == IMAGE_DOS_SIGNATURE)
{
/* 由DOS头部决定PE文件头部的位置 */
if (LOWORD (*(DWORD *)NTSIGNATURE (lpFile)) ==
IMAGE_OS2_SIGNATURE ||
LOWORD (*(DWORD *)NTSIGNATURE (lpFile)) ==
IMAGE_OS2_SIGNATURE_LE)
return (DWORD)LOWORD(*(DWORD *)NTSIGNATURE (lpFile));
else if (*(DWORD *)NTSIGNATURE (lpFile) ==
IMAGE_NT_SIGNATURE)
return IMAGE_NT_SIGNATURE;
else
return IMAGE_DOS_SIGNATURE;
}
else
/* 不明文件种类 */
return 0;
}
以上列出的代码立即告诉了你NTSIGNATURE宏有多么有用。对于比较不同文件类型并且返回一个适当的文件种类来说,这个宏就会使这两件事变得非常简单。WINNT.H之中定义的四种不同文件类型有:
WINNT.H
#define IMAGE_DOS_SIGNATURE 0x5A4D // MZ
#define IMAGE_OS2_SIGNATURE 0x454E // NE
#define IMAGE_OS2_SIGNATURE_LE 0x454C // LE
#define IMAGE_NT_SIGNATURE 0x00004550 // PE00
首先,Windows的可执行文件类型没有出现在这一列表中,这一点看起来很奇怪。但是,在稍微研究一下之后,就能得到原因了:除了操作系统版本规范的不同之外,Windows的可执行文件和OS/2的可执行文件实在没有什么区别。这两个操作系统拥有相同的可执行文件结构。
现在把我们的注意力转向Windows NT PE文件格式,我们会发现只要我们得到了文件标志的位置,PE文件之后就会有4个字节相跟随。下一个宏标识了PE文件的头部:
PEFILE.C
#define PEFHDROFFSET(a) ((LPVOID)((BYTE *)a + ((PIMAGE_DOS_HEADER)a)->e_lfanew + SIZE_OF_NT_SIGNATURE))
这个宏与上一个宏的唯一不同是这个宏加入了一个常量SIZE_OF_NT_SIGNATURE。不幸的是,这个常量并未定义在WINNT.H之中,于是我将它定义在了PEFILE.H中,它是一个DWORD的大小。
既然我们知道了PE文件头的位置,那么就可以检查头部的数据了。我们只需要把这个位置赋值给一个结构,如下:
PIMAGE_FILE_HEADER pfh;
pfh = (PIMAGE_FILE_HEADER)PEFHDROFFSET(lpFile);
在这个例子中,lpFile表示一个指向可执行文件内存映像基地址的指针,这就显出了内存映射文件的好处:不需要执行文件的I/O,只需使用指针pfh就能存取文件中的信息。PE文件头结构被定义为:
WINNT.H
typedef struct _IMAGE_FILE_HEADER {
USHORT Machine;
USHORT NumberOfSections;
ULONG TimeDateStamp;
ULONG PointerToSymbolTable;
ULONG NumberOfSymbols;
USHORT SizeOfOptionalHeader;
USHORT Characteristics;
} IMAGE_FILE_HEADER, *PIMAGE_FILE_HEADER;
#define IMAGE_SIZEOF_FILE_HEADER 20
请注意这个文件头部的大小已经定义在这个包含文件之中了,这样一来,想要得到这个结构的大小就很方便了。但是我觉得对结构本身使用sizeof运算符(译注:原文为“function”)更简单一些,因为这样的话我就不必记住这个常量的名字IMAGE_SIZEOF_FILE_HEADER,而只需要记住结构IMAGE_FILE_HEADER的名字就可以了。另一方面,记住所有结构的名字已经够有挑战性的了,尤其在是这些结构只有WINNT.H中才有的情况下。
PE文件中的信息基本上是一些高级信息,这些信息是被操作系统或者应用程序用来决定如何处理这个文件的。第一个域是用来表示这个可执行文件被构建的目标机器种类,例如DEC(R) Alpha、MIPS R4000、Intel(R) x86或一些其它处理器。系统使用这一信息来在读取这个文件的其它数据之前决定如何处理它。
Characteristics域表示了文件的一些特征。比如对于一个可执行文件而言,分离调试文件是如何操作的。调试器通常使用的方法是将调试信息从PE文件中分离,并保存到一个调试文件(.DBG)中。要这么做的话,调试器需要了解是否要在一个单独的文件中寻找调试信息,以及这个文件是否已经将调试信息分离了。我们可以通过深入可执行文件并寻找调试信息的方法来完成这一工作。要使调试器不在文件中查找的话,就需要用到IMAGE_FILE_DEBUG_STRIPPED这个特征,它表示文件的调试信息是否已经被分离了。这样一来,调试器可以通过快速查看PE文件的头部的方法来决定文件中是否存在着调试信息。
WINNT.H定义了若干其它表示文件头信息的标记,就和以上的例子差不多。我把研究这些标记的事情留给读者作为练习,由你们来看看它们是不是很有趣,这些标记位于WINNT.H中的IMAGE_FILE_HEADER结构之后。
PE文件头结构中另一个有用的入口是NumberOfSections域,它表示如果你要方便地提取文件信息的话,就需要了解多少个段——更明确一点来说,有多少个段头部和多少个段实体。每一个段头部和段实体都在文件中连续地排列着,所以要决定段头部和段实体在哪里结束的话,段的数目是必需的。以下的函数从PE文件头中提取了段的数目:
PEFILE.C
int WINAPI NumOfSections(LPVOID lpFile)
{
/* 文件头部中所表示出的段数目 */
return (int)((PIMAGE_FILE_HEADER)
PEFHDROFFSET (lpFile))->NumberOfSections);
}
如你所见,PEFHDROFFSET以及其它宏用起来非常方便。(未完待续)