看过此书,即成高手(9)。

 
 
2006-10-31 8:38:00
看过此书,即成高手(9)。
 

微软为 Windows NT 引入了一种新的可执行文件格式。 这种格式被称作 Portable Executable (PE) File Format。因为其目的就是方便一个可执行文件移植到所有 Microsoft 的32-bit operating systems 上。 同一个 PE 格式的可执行文件可以在任何版本的 Windows NT,Windows 95 和 Win32s 上运行。而且,这种格式也用在运行于非 Intel x86 处理器上的 Windows NT,这些处理器包括 MIPS、 Alpha 和 Power PC。32-bit 的 DLLs 和 Windows NT 驱动程序 也使用 这种 PE 格式。

加载到内存中的 PE files 与磁盘上的几乎相同,因此理解 PE 文件格式 就很有用了。学习 PE 格式对理解许多操作系统概念也很有帮助。比如操作系统是如何支持 DLL 函数 的动态链接的,与动态链接有关的 import table、export table 的数据结构是什么样的等等。

PE 格式并不是完全保密的。 WINNT.H 文件有一些 PE 格式的结构定义。Microsoft Developer’s Network (MSDN) CD-ROMs 里也有几篇关于 PE 格式的文章。但是,这些零零星星的东西只够窥一斑而不足以知全豹。本章我们就来尽量对 PE 格式做一个全面的介绍。

Microsoft 在 SDK 里提供了一个 DLL,其中有用来解释 PE 文件的工具函数。我们会结合着 PE 格式的相关内容介绍这些函数。


PE 文件概述

这一节里,我们来看一下 PE 文件的总体结构。在后面的部分中,我们再详细讨论 PE 格式。 一个 PE 文件由多个 sections 构成。因为微软的32位操作系统使用了 flat memory model,所以可执行文件中就不存在段。然而,一个可执行文件中的不同部分仍然有着不同的属性,比如代码和数据。 可执行文件中的不同属性的部分就分成了不同的 sections。因此,一个 PE 文件就是各个 sections 里的数据的组合。

Microsoft 的 linker 总要为 PE 文件生成几个固定的 sections。其它的 linkers 可能也会生成类似的sections,只是名字可能不一样。 由 Microsoft 的 linker 生成的 PE 文件有个 .text section 其中包含着代码,所有 OBJ 文件中的代码都被合并到这个 section 里。对于数据,又可分为几类。 .data section 中为所有已初始化的数据以及静态的数据;.bss section 里装的是未初始化的数据;诸如字符串和常量之类的只读数据都保存在 .rdata section。.rdata section 里还有一些其它的只读的结构,如 debug directory、 Thread Local Storage (TLS) directory 等等,本章后面讲解这些结构。 .edata section 里保存着从 DLL 中 exported 的函
数的信息;.idata section 则保存着从可执行文件/DLL 中的 imported 函数的信息。 .rsrc section 包含着各种资源的数据,比如菜单和对话框。.reloc section 保存着加载程序时所需的重定位信息。

sections 叫什么名字并不重要。正如前面提到的,不同的 linkers 会为同种的 sections 起不同的名字。 程序员还可以创建自己的新 sections。 若使用微软的编译器,则可以用 #pragma code_seg 和 #pragma data_seg 宏来创建新的 sections。操作系统的 loader 从文件 headers 里的 data directories中定位所需的信息。一会儿,我们先从总体上介绍文件 headers,之后再更细致地研究。


PE 文件结构

除了由 sections 构成的实际数据之外, PE 文件中还有各种 headers 来描述 sections 和 sections 里的重要信息。

要是你看一下 PE 文件的 hex dump,你会发现最开始的两个字节很眼熟。那不是 M 和 Z 吗? 对了,PE 文件是以 DOS 可执行文件的 header 开头的。后面是一个小程序,告诉你此程序不能在 DOS 方式下运行。16-bit Windows 的可执行文件也是用的这个把戏。只要在 DOS 下运行这个 PE image,就会执行这个程序。

DOS header 和 DOS executable stub 之后就是 PE header。DOS header 的一个域指向这个新的 header。 PE header 开头是个四字节的 signature,内容是“PE”和两个 nulls(0x00)。PE 格式来源于 Unix 使用的 Common Object File Format (COFF)。 PE signature 后面是从 COFF 那里借来的 object 文件的 header。这个 header 在微软的 32-bit compilers 生成的 object files 里也有。其中保存的都是些关于文件的一般信息,比如目标机器的 ID 、文件中 sections 的数目等等。COFF 风格的 header 之后就是 optional header。之所以叫它 optional 是因为 object files 不需要,在 executables 和 DLLs 里却是必须有的。optional header 分两
个部分,前一部分是从 COFF 那里继承来的,是所有 COFF 文件都有的。 第二部分是 NT 对 COFF 做的扩展。除了子系统类型等 NT-specific 的信息之外,这一部分还包含着 data directory。data directory 是一个数组,数组中的成员都指向一些重要的信息。其中有的成员指向可执行文件或 DLL 的 import table,还有的指向 DLL 的 export table 等等。

XREF: 后面我们会研究这些信息的详细格式。

data directory 的后面是 section table。section table 是一个 section headers 的数组。section header 中保存着相应 section 的重要信息。section table 之后就是各个 sections 本身了.

我们希望到这里你能够对 PE 文件的组织结构有了整体上的认识。下面来讨论一个 PE 文件中非常重要的概念。


RELATIVE VIRTUAL ADDRESS

PE 文件中的所有偏移都是用的 Relative Virtual Addresses (RVAs)。RVA 就是以可执行文件在内存中的首地址为基址的偏移。section 对齐的要求使得这个值和文件偏移并不一样。PE header 为executable image 指定了对齐要求。section 必须加载在数值为 section 对齐值整倍数的地址上。section 的对齐值又必须是页大小的整倍数。这是因为不同的 sections 有着不同的页属性要求,例如,.data section 需要 read-write 属性而 .text section 则需要 read-execute 属性。因此,一页不能跨越 section 的边界。

因为 PE 格式里用的都是 RVA,所以要在文件里定位所需信息的位置就困难了。访问 PE 文件时通常需要使用 Win32 内存映射 API 将文件映射入内存。在这个内存映射文件里计算某个 RVA 就有点儿复杂。首先要找出此 RVA 所在的 section,这可以靠循环枚举每一个 section 来完成。每一个 section header 都保存着 section 的 RVA 和 section 的大小。section 在内存中是保证连续的。因此,不管文件是被映射到内存还是被操作系统的 loader 加载执行,某块数据距离 section 的首地址的偏移都是一样的,因此要找到内存映射文件的地址,只需要将这个偏移值加到 section 的基地址上即可。 section 的基地址又可以通过 section 的文件偏移得到,而其文件偏移就保存在相应的 section header 里。过程很简单,不是吗?

ImageRvaToVa() 

别担心,这儿还有个简单的办法。微软为了方便我们提供了 IMAGEHLP.DLL。这个由 DLL export 的函数可以计算给定的 RVA 的内存映射文件的地址。 

LPVOID ImageRvaToVa( 
PIMAGE_NT_HEADERS NtHeaders, 
LPVOID Base, 
DWORD Rva, 
PIMAGE_SECTION_HEADER *LastRvaSection 
);

参数 NtHeaders 指向 IMAGE_NT_HEADERS 结构体的指针。这个结构体就代表着 PE header,定义在 WINNT.H 文件里。指向 PE 文件的指针可以用 IMAGEHLP.DLL export 的 ImageNtHeader() 得到。 

Base 用 Win32 的内存映射文件 API 将文件映射入内存时 PE 文件在内存中的基地址。

Rva 给定的 relative virtual address。LastRvaSection,前一个 RVA section。这是个可选的参数,可以传个NULL。 要是指定了具体的值,这个值就得指向一个变量,这个变量保存着上一次
做 RVA 到 VA 转换的 section 的 section 指针。这个参数只是用来优化section 的查找,以
免给定的 RVA 与前一次调用该函数得到的 RVA 都在同一个 section 里还要从头查找。函数首
先检查 LastRVASection,如果所给的 RVA 不在 LastRVASection 里再顺序查找每个 section。

返回值 
若函数成功执行,返回值就是映射文件中的 virtual address; 否则,返回 NULL。error number 可以用 GetLastError() 函数得到.

ImageNtHeader()

ImageRvaToVa() 函数需要一个指向 PE header 的指针。从 IMAGEHLP.DLL 中 export 的 ImageNtHeader 可以提供这个指针。

PIMAGE_NT_HEADERS ImageNtHeader

LPVOID ImageBase 
);

参数 ImageBase 用 Win32 的内存映射文件 API 将文件映射入内存时 PE 文件在内存中的基地址。

返回值 
若成功,返回值为一个指向文件中 PE header 的 IMAGE_NT_HEADERS 结构体的指针; 否则返回 NULL.

MapAndLoad() 

IMAGEHLP.DLL 还可以替你完成 PE 文件的内存映射。 MapAndLoad() 函数将请求的 PE 文件映射入内存并用该映
射文件的信息填充 LOADED_IMAGE 结构体。

BOOL MapAndLoad( 
LPSTR ImageName, 
LPSTR DllPath, 
PLOADED_IMAGE LoadedImage, 
BOOL DotDll, 
BOOL ReadOnly 
);

参数 
ImageName 载入的 PE 文件的文件名。

DllPath 定位文件的路径。若传递 NULL,则搜索 PATH 环境变量中的路径。

LoadedImage 结构体 LOADED_IMAGE 定义在 IMAGEHLP.H file。该结构体有以下成员: 

ModuleName 被加载文件的文件名。

hFile 调用 Create文件的到的句柄。 

MappedAddress 文件映射到的内存地址。 

FileHeader 指向映射文件的 PE header 的指针。

LastRvaSection 函数将此值设为第一个 section (见 ImageRvaToVa)。

NumberOfSections 所加载的 PE 文件中 sections 的数目。

Sections 指向映射文件的第一个 section header。

Characteristics PE 文件的特征(后面详细介绍)。

fSystemImage 指示是否是内核模式驱动/DLL 的标志。

fDOSImage 指示是否是 DOS 可执行文件的标志。

Links 所加载的 images 的列表。

SizeOfImage image 的大小。

加载 PE 文件后函数将这些成员设为正确的值。

DotDll 若需要查找该文件而且没有指定扩展名,则使用 .exe 或 .dll 作扩展名。 若 DotDll 标志设
为TRUE,则使用 .dll 扩展名; 否则用 .exe 扩展名。 

ReadOnly 若设为 TRUE,则文件被映射为 read-only。

返回值 
若成功,返回 TRUE; 否则返回 FALSE.


UnMapAndLoad() 

使用完映射文件后,应该调用 UnMapAndLoad() 函数。 此函数解除 PE 文件的映射并回收由 MapAndLoad() 分配
的资源。

BOOL UnMapAndLoad( PLOADED_IMAGE LoadedImage );

参数 
LoadedImage 指向 LOADED_IMAGE 结构体的指针,该指针就是前面调用 MapAndLoad() 
函数返回的指针。

返回值 
若成功,则返回 TRUE; 否则返回 FALSE.

在这一章的其它地方我们还会讨论这个 DLL 中其它有用的函数.


PE FORMAT 细节

WINNT.H 文件里有代表 PE format 的结构定义。描述 PE format 时,我们会引用这些结构定义。我们从头开始。从 PE 的角度看,PE 文件开头的 DOS header 并没有什么重要的信息。这个 header 的范围延伸至 DOS executable stub 结束。对 PE format 唯一重要的值就是 e_lfanew,它保存着 PE header 的偏移。将这个偏移加到内存映射文件的基址上就得到了 PE header 的地址。也可以用前面讲到的 ImageNtHeader() 函数,或者先调用 MapAndLoad() 函数再使用 LOADED_IMAGE 的 FileHeader 域。

在 WINNT.H 文件中,代表 PE header 的 IMAGE_NT_HEADERS 结构体定义如下: 
typedef struct _IMAGE_NT_HEADERS {
DWORD Signature;
IMAGE_FILE_HEADER FileHeader;
IMAGE_OPTIONAL_HEADER OptionalHeader;
} IMAGE_NT_HEADERS, *PIMAGE_NT_HEADERS;

前面讲过,signature 就是 PE 加上两个 nulls。COFF 风格的 header 是用 IMAGE_FILE_HEADER 表示的,它后面是用 IMAGE_OPTIONAL_HEADER 结构体表示的 optional。COFF 风格的 header 中的域如下:

Machine 目标机器的 ID。WINNT.H 文件中定义了各种取值——如,0x14C 表示 Intel 80386
(及其兼容),0x184 表示 Alpha AXP.

NumberOfSections 文件中 sections 的数目。

TimeDateStamp 文件的创建时间和日期。

PointerToSymbolTable COFF symbol table 中的偏移。仅用于带有 COFF 类型 调试信息的 COFF 类型的object files 和 PE files。

NumberOfSymbols symbol table 中 symbols 的数目。

SizeOfOptionalHeader 此 header 后面的 optional header 的大小,单位为字节。这个值可以用于定位 symbol table 后面的 string table。对于 object files 这个域为 0 因为它们中
没有 optional header。

Characteristics 文件的属性。标志的值定义在 WINNT.H 文件里。此域为一个用“或”运算连接的标志。重要的标志如下: 

IMAGE_FILE_EXECUTABLE_IMAGE             可执行文件。
IMAGE_FILE_SYSTEM                       内核模式驱动/DLL。
IMAGE_FILE_DLL                          动态链接库(DLL)。
IMAGE_FILE_UP_SYSTEM_ONLY 仅运行在      UP machine。
IMAGE_FILE_LINE_NUMS_STRIPPED COFF      行号已从文件中移去。
IMAGE_FILE_LOCAL_SYMS_STRIPPED COFF     symbol table 已从文件中移去。
IMAGE_FILE_DEBUG_STRIPPED               调试信息已从文件中移去。
IMAGE_FILE_RELOCS_STRIPPED              基址重定位信息已从文件中移去,并且文件只能加载在 preferred base address 上。若loader 不能将其加载在 preferred base address 上,就会因不能重定位 image 而致使加载失败。
IMAGE_FILE_AGGRESIVE_WS_TRIM            可能裁减 working set 。
IMAGE_FILE_BYTES_REVERSED_LO            Little endian: 在内存中最低位位于最高位之前,但以逆序存储。
IMAGE_FILE_BYTES_REVERSED_HI            Big endian: 在内存中最高位位于最低位之前,但以逆序存储。
IMAGE_FILE_32BIT_MACHINE                目标机器基于 32-bit-word 体系。
IMAGE_FILE_REMOVABLE_RUN_FROM_SWAP      若该标志置位且文件在移动介质上,如软盘上,loader 将文件拷贝入交换区并在那里运行。
IMAGE_FILE_NET_RUN_FROM_SWAP            域前一标志类似。若文件在网络驱动器上,则拷贝到交换区运行。 

注意: COFF 风格 header 后面是 optional header。object files 里没有 optional header。optional header 的格式定义为 WINNT.H 文件里的 IMAGE_OPTIONAL_HEADER 结构体。此结构体的前几个域是从 COFF 继承来的。

Magic 正常的 executable/DLL 里这个值被设为 0x10b。

MajorLinkerVersion, 生成该文件的 linker 的版本。
MinorLinkerVersion 


SizeOfCode code section 的大小。若有多个 code sections,则这个域为所有这些 sections
的长度之和。

SizeOfInitializedData 已初始化数据的 section 的大小。若有多个已初始化数据的 section,则这个域为所有这些 sections 的长度之和。

SizeOfUninitializedData 与 SizeOfInitializedData 类似,只是是用于未初始化数据(BSS)的 section 的。

AddressOfEntryPoint 入口点的 RVA。

BaseOfCode code section 起始点的 RVA。

BaseOfData data section 起始点的 RVA。

微软还为 optional header 加入了一些 NT-specific 的域。这些域如下:

ImageBase 若文件被加载到内存中的这个地址上,loader 就不需要做任何的基址重定位,linker 在链接时就解决了所有的基址重定位因为这时 linker 认为文件会加载在此地址上。我们在讨论 relocation table 时再详细讨论这些。现在,知道在 preferred base address 上加载文件能减少加载时间就足够了。文件可能会因为地址被占用而不能加载到 preferred base address 上。当可执行文件加载了多个 DLL 时就会出现这种情况。默认的 preferred base address 为 0x400000。为了使应用程序使用的 DLL 不与其它的 DLL 冲突,可以用一个 linker switch 改变这个preferred base address。还可以用 Win32 SDK 中的 rebase 工具修改文件的基
址。

ReBaseImage()

IMAGEHLP.DLL 中的 ReBaseImage() 函数也可以修改 preferred base address。

BOOL ReBaseImage(
LPSTR CurrentImageName,
LPSTR SymbolPath,
BOOL fReBase,
BOOL fRebaseSysfileOk,
BOOL fGoingDown,
DWORD CheckImageSize,
LPDWORD OldImageSize,
LPDWORD OldImageBase,
LPDWORD NewImageSize,
LPDWORD NewImageBase,
DWORD TimeStamp
);

参数
CurrentImageName 要修改基址的文件的文件名。
SymbolPath 为防止符号调试信息保存在另外的文件中,这个参数指定查找相应的符号文件的路
径。更新符号文件 header 信息和 timestamp 需要此参数。

fReBase 仅当此值为 TRUE 时,才真正修改基址。

fRebaseSysfileOk 若文件是 preferred base address 高于 0x80000000 的系统文件,则仅当此标志为 TRUE 时才修改基址。 

fGoingDown 要使文件的 image 完全加载在低于给定的地址,就将此标志设为 TRUE。例如,加
载的 DLL 的大小为 0x2000,fGoingDown 标志为 TRUE,给定的地址为 0x600000,调用该函数后 DLL 的基址就会改为 0x508000。

CheckImageSize 因 section 的对齐要求,Rebasing 可能会改变文件加载后的 image 的大小。若此值为非0值,则只有当所改变的大小小于此参数时,才修改基址。

OldImageSize 用于返回 rebase 操作前 image 的原始大小。

OldImageBase 用于返回 rebase 操作前 image 的基址。

NewImageSize 返回 rebase 操作后 loaded image 的新大小。

NewImageBase 新基地址。函数返回时,此参数的值为文件 rebase 后的实际基地址。

TimeStamp 文件的新 timestamp。 

返回值
若成功,返回 TRUE; 否则,返回 FALSE。

optional header 中的其它域如下:

SectionAlignment section 需要加载在数值为 section 对齐值整倍数的地址上。更多的信息
可以参考关于 RVA 的讨论

FileAlignment 在文件中,section 总是起始于数值为文件对齐值整倍数的偏移上。这个
值是扇区大小的倍数。

MajorOperatingSystemVersion, 执行该文件所需的最低的 operating system 版本。
MinorOperatingSystemVersion 

MajorImageVersion, 开发人员可以用这两个域为文件指定版本号。该域可以用一个 linker 
MinorImageVersion   标志指定。

MajorSubsystemVersion, 执行此文件的最低子系统版本号。
MinorSubsystemVersion 

Win32VersionValue 保留。

SizeOfImage 考虑 section 对齐后的 image 的大小。用于保留加载文件所需的虚存空
间。

SizeOfHeaders headers 的总大小,包括 DOS header、PE header 和 section table. 
在文件中,包含实际数据的 sections起始于此偏移。

CheckSum 此值仅用于内核模式驱动/DLLs。在用户模式的 executables/DLLs 中可设
为0。

Subsystem 文件使用的子系统。WINNT.H 文件中定义了以下各值: 

IMAGE_SUBSYSTEM_NATIVE          Image 不需要子系统。内核模式驱动和诸如 CSRSS.EXE 之类的 native applications 使用此值。 
IMAGE_SUBSYSTEM_WINDOWS_GUI     文件使用 Win32 GUI 接口。
IMAGE_SUBSYSTEM_WINDOWS_CUI     文件使用 character-based 用户接口。
IMAGE_SUBSYSTEM_OS2_CUI         文件需要 OS/2 子系统。
IMAGE_SUBSYSTEM_POSIX_CUI       文件使用 POSIX API。

DllCharacteristics 废弃。

SizeOfStackReserve 为堆栈保留的地址空间。仅用于虚拟地址——不分配交换空间.

SizeOfStackCommit 为堆栈提交的实际内存。初始时分配该域值大小的交换空间。提交的堆栈
大小可以应要求增加,直到达到 SizeOfStackReserve。

SizeOfHeapReserve 为堆保留的地址空间。与 SizeOfStackReserve 域类似.

SizeOfHeapCommit 实际提交的堆空间。与 SizeOfStackCommit 域类似。

LoaderFlags 废弃。

NumberOfRvaAndSizes 后面 data directory 的成员的数目,总是设为 16.

DataDirectory[IMAGE_NUMBEROF_DIRECTORY_ENTRIES] 前面提到过,data directory 的每个数组成员都指向某一块重要的信息。每个成员的类型都是 IMAGE_DATA_DIRECTORY,其定义如下:

typedef struct _IMAGE_DATA_DIRECTORY {
DWORD VirtualAddress;
DWORD Size;
} IMAGE_DATA_DIRECTORY,*PIMAGE_DATA_DIRECTORY;

ImageDirectoryEntryToData()

VirtualAddress域为相应信息块的 RVA,Size 域为数据的大小。在内存映射 PE 文件中要得到实际数据,需要将 RVA 转换为实际地址。这些工作可以用 IMAGEHLP.DLL export 的 ImageDirectoryEntryToData() 函数完成。 

PVOID ImageDirectoryEntryToData(
LPVOID Base,
BOOLEAN MappedAsImage,
USHORT DirectoryEntry,
PULONG Size
);

参数
Base 文件映射入内存后的基地址.

MappedAsImage 若系统 loader 映射了文件则将此标志设为 TRUE。否则,设为FALSE。

DirectoryEntry data directory 数组的索引.

Size 返回时,data directory 的大小填入此域。

返回值
若成功,返回值为内存映射文件中所需数据的地址。否则,返回 NULL.


DATA DIRECTORY 索引

data directory 中的每个索引值(除了后面几个仍未使用的)都代表着一些重要的信息块。在后面几节中,我们来讨论此数组中的一些重要的成员以及相应信息的存储格式。

Export Directory

索引值为 IMAGE_DIRECTORY_ENTRY_EXPORT 的 data directory 成员指向文件的 export directory。该 directory 成员中的 RVA 指向 .edata section。由该文件(一般是 DLL)export 的函数的信息都保存在这里。data directory 成员指向的 export directory 在 WINNT.H 文件里被定义为 IMAGE_EXPORT_DIRECTORY 结构体。此结构体中的域如下:

Characteristics 保留域。总为0。

TimeDateStamp 创建日起和时间。

MajorVersion, 开发人员可以设置 export table 的版本号.
MinorVersion 

Name DLL 的 zero-terminated 的名字的 RVA。

Base exported functions 的起始 ordinal——即,最小的 ordinals。一般来说,此域为
1。

NumberOfFunctions 从 DLL 中 export 的函数的总数。

NumberOfNames 用函数名 export 的函数的数目。一些函数可以只用 ordinal 来 export,因此这个值小于 NumberOfFunctions。

AddressOfFunctions 一个数组(我们称之为 export-functions 数组)的 RVA。对于每一个从 DLL export 的函数该数组都有成员与之对应。因此该数组的大小等于 NumberOfFunctions 域。索引为 i 的成员对应着 ordinal 为 i + Base 的函数。此数组的每个成员也是一个 RVA。若某个成员的 RVA 指向 export section 的内部,则该成员是一个 forwarder。Forwarder 的意思就是该 exported 函数并不在这个 DLL中,但它是对另一 DLL 中某函数的 forwarder reference。这种情况下,RVA 就指向保存着 DLL 名和函数名的 ASCIIZ 字符串,DLL 名与函数名用句点隔开。若 DLL 用 ordinal 来 export 函数,则函数的名字由 # 加上十进制的 ordinal 构成。例如,Windows NT 的 KERNEL32.DLL 将 HeapAlloc() 函数 forward 到了 NTDLL.DLL 的 RtlAllocateHeap() 函数。因此,相应的 RVA 就指向了 export section 中保存着字符串 NTDLL.RtlAllocateHeap 的那个位置。Win32 应用程序就可以从KERNEL32.DLL 来 import HeapAlloc() 函数而不用担心所有的这些细节。当程序运行在 Windows 95 上时,loader 会解决对 KERNEL32.DLL 中函数的 import 引用。当相同的程序运行在 Windows NT 上时,loader 发现函数被 forwarded 到 NTDLL.DLL。因此 loader 自动加载 NTDLL.DLL 并将 imported 函数引至 RtlAllocateHeap() 函数。当 export-functions 数组的成员不是forwarder 时——即,RVA 并不在 export section 范围内——RVA 就指向函数的入口点或是指向 exported 变量的地址单元.

export-functions 数组可以有空隙。这是因为在 exporting 函数时某些 ordinals 未被用到,还有的 ordinals 可能就没有对应的 export。这时,相应的数组成员就为 0.

AddressOfNames export-names array 数组的 RVA。每一个用名字 export 的函数,数组中都有对应的成员。因此,数组的大小等于 NumberOfNames 域。数组中的每个成员都是一个指
向 exported 函数名的 ASCIIZ 字符串的 RVA。数组以字典顺序排序以利于作折半
查找。

AddressOfNameOrdinals ordinals 数组的 RVA。该数组因此被称为 export-ordinals 数组。这个数组的大小与 AddressOfNames 数组的大小相同。所有这三个数组,即 export-names、
export-ordinals 和 export-functions,在解决用函数名 import 时都是必要的。对于解决用名字的 import,loader 首先在 export-names 数组中查找该名字。若此名字与索引为 i 的成员匹配,则 export-ordinals 数组的第 i 个成员就是函数的 ordinal。最后,函数的地址可以在export-functions 数组中找到。

Import Directory

data directory 中的下一个索引值,IMAGE_DIRECTORY_ENTRY_IMPORT,是为 executable/DLL 的 import directory 保留的。这个 data directory 成员里的 RVA 指向 import directory,import directory 是一个 IMAGE_IMPORT_DESCRIPTOR 的变长数组,每个 IMAGE_IMPORT_DESCRIPTOR 对应着一个 imported DLL。此结构体的第一个域是一个联合体,若该联合体的 Characteristics 域为0,就表示变长的 import descriptors 数组的结尾。否则该联合体就解释为另一成员—— OriginalFirstThunk。

OriginalFirstThunk 被微软称为 Import Lookup Table (ILT) 的结构体数组的 RVA。ILT 的每个成员都是一个 DWORD。若该数的最高位置位,则函数被视为是用 ordinal 来 import 的,第 0
至第31位就是 imported 函数的 ordinal。若最高位未置位,则该数就是 IMAGE_IMPORT_BY_NAME 结构体的 RVA。IMAGE_IMPORT_BY_NAME 结构体的第一个成员是一个提示值,用于在 imported DLL 的 export directory 中查找 imported 函数的函数名。loader 在为解决 import 引用作折半查找时使用此提示值作为 export-names 数组的起始索引。提示值后面的成员是 import 引用的 ASCIIZ 名。

WINNT.H 文件提供了 IMAGE_SNAP_BY_ORDINAL 宏来判断函数是否是用 ordinal 进行 import 的。文件还提供了 IMAGE_ORDINAL 宏,用于从 ILT 中的 DWORD 里取出 ordinal。ILT 是一个变长数组,结尾用0标记。

IMAGE_IMPORT_DESCRIPTOR 结构体的其它成员如下:

TimeDateStamp 若 imports 未被绑定,则此域为0。后面我们会讨论绑定一个 PE 文件的 imports 是什么意思。

ForwarderChain 仅当 imports 被绑定时,此域才有效.

Name 保存 imported DLL 文件名的 ASCIIZ 字符串的 RVA。

FirstThunk Import Address Table (IAT) 的 RVA。IAT 是一个与 ILT 形式相同的数组,若 
image 未被绑定,则两数组完全相同。IAT 也有一个 ordinals 和 IMAGE_IMPORT_BY_NAME 结构体指针构成的联合体。当解决 import 引用时,loader 用相应函数的实际地址替换掉 IAT 中的成员。令人吃惊的是,这些就是为动态链接所作的所有工作,其它的事情已经都由 linker 和 import librarian 做好了。我们来看一下所有这些程序是如何协同工作最终实现动态链接的。


PE 文件的动态链接

每个 DLL 都有一个 import library,这个 import library 可以由 import librarian 生成,也可以在创建此 DLL 时由 linker 自己生成。import library 中有个 stub,stub 中函数的名字与从 DLL 中export 的函数的名字相同。 import library 也有个 .idata section,其中有一个 import 表,表中的成员对应着 DLL 中的所有函数。stub 中的每个函数都是一个向 .idata section 的 IAT 中相应成员的间接跳转。当可执行文件与 import library 链接时,linker 解析出向 stub 函数的调用。linker 还要将 import library 中包含 stub 函数的 .text section 与生成的可执行文件的 .text section 连结起来,.idata sections 与 import directories 也要连结起来。这时加载前的工作就完成了。加载时,IAT 中的成员被实际的函数地址改写。当函
数被调用的时候,控制就被送至 stub 函数进行间接条转。因为 IAT 成员为 DLL 中函数的实际地址,控制就由此送至所需的函数。


绑定 PE 文件的 IMPORTS

大部分的加载时间都花在了解析 imports 上,因为 loader 必须查找 imported DLL 的 export directory 中的每一个 imported 符号来找到所要的符号。要是 IAT 成员的值是符号的地址而非符号名和 ordinal,加载时间就能大大减少。这样的 PE 文件就称为被绑定的 image。imported symbol 的地址是以加载时的 preferred base address 作为 DLL 的加载地址来计算的。被绑定的 PE 文件的 IMAGE_IMPORT_DESCRIPTOR 结构体们也要被修改。TimeDateStamp 域保存着 imported DLL 的 timestamp。若加载时此 timestamp 与 DLL 的 timestamp 不一致,则说明 IAT 已经被修改且不再包含符号名和 ordinals,这就需要再次解析 imports,而此时就要用到 ILT。 

绑定中的另一个问题就是 forwarded 的函数。forwarded 函数的地址无法在绑定时计算,因此这些函数就得在加载时解析。imported DLL 的所有 forwarded 函数的列表是由相应的 IMAGE_IMPORT_DESCRIPTOR 的 ForwarderChain 成员来维护的。ForwarderChain 保存着 forwarded 函数在 IAT 中的索引。索引处的 IAT 成员保存着下一个 forwarded 函数的索引,如此下去就形成了一个forwarded 函数的链表。这个链表以一个值为 -1 的成员结尾。 

BindImage()

Win32 SDK 提供的绑定工具程序用来进行 PE 文件的绑定。IMAGEHLP.DLL中的 BindImage 和 BindImageEx() 函
数也提供此功能.

BOOL BindImage(
LPSTR ImageName,
LPSTR DllPath,
LPSTR SymbolPath
);

PARAMETERS
ImageName 要被绑定的文件的名字。可以是文件名、部分路径或全部路径。

DllPath ImageName 中的文件不能打开时,进行查找所用的根路径.

SymbolPath 查找相应符号文件的根路径。若符号文件与之分开保存,就修改符号文件的 header 来反映 PE
文件的变化.

返回值
若成功,返回 TRUE; 否则返回 FALSE.

BindImageEx()

此函数与 BindImage 函数很相似,只是提供了更多的自定义项目,比如在绑定的处理过程中周期性地调用某回调
函数。

BOOL BindImageEx(
IN DWORD Flags,
IN LPSTR ImageName,
IN LPSTR DllPath,
IN LPSTR SymbolPath,
IN PIMAGEHLP_STATUS_ROUTINE StatusRoutine
);

参数
此函数有以下额外参数:

Flags 控制函数行为的域。其值为用“或”运算连结的一组标志值。这些标志值定义在 IMAGEHLP.H 文件里,定义如下:

BIND_NO_BOUND_IMPORTS  不生成新的 import address table.
BIND_NO_UPDATE         不对文件做任何改变。.
BIND_ALL_IMAGES        Bind 文件调用树中所有的 images.

StatusRoutine 指向一个状态函数的指针。在 image 的绑定过程中会调用此状态函数。

参数
若成功,返回 TRUE; 否则返回 FALSE.

调用 BindImage 等价于用 Flags 等于 0 和 StatusRoutine 等于 NULL 来调用 BindImageEx。也就是说调用

BindImage(ImageName,DllPath,SymbolPath) 

等价于调用

BindImageEx(0,ImageName,DllPath,SymbolPath,NULL).


Resource Directory

data directory 的下一个索引,IMAGE_DIRECTORY_ENTRY_RESOURCE,指向 PE 文件的 resource directory。resource directory 和资源本身一般都保存在名为 .rsrc section 的 section 里。资源的组织形式与文件系统类似,都是树状结构。根目录包含着子目录,子目录又包含着子目录或资源数据。子目录可以任意级的嵌套,但Windows NT 只使用了三级的结构。在每一级,resource directory 都要根据资源的特征进行分支。在第一级是资源的类型——位图、菜单等等。所有的位图都保存在一个子树下,所有的菜单保存在另一个子树下,如此类推。下一级是资源名,第三级根据语言 ID 对资源进行分类。第三级的 resource directory 指向保存着实际资源数据的叶子节点。

resource directory 由 directory 的概要信息构成。后面是 directory entries。每个 directory entry 都有一个名字或是 ID。此 ID 可视为类型 ID、 名字 ID 或语言 ID,这取决于 directory 的等级。directory entry 要么指向资源数据,要么指向一个有类似格式的子目录。

WINNT.H 中,resource directory 的格式被定义为 IMAGE_RESOURCE_DIRECTORY 结构体。

Characteristics 当前未使用,设为0。
TimeDateStamp 资源编译器生成该资源的日期和时间。
MajorVersion,
MinorVersion 可由用户设定。

NumberOfNamedEntries 有字符串名的 directory entries 的数目。这些 entries 经过排序位于 directory 概要信息的后面。

NumberOfIdEntries 将整数 ID 号作为名字的 directory entries 的数目。这些 entries 位于有字符串名的 entries 的后面。

概要信息后面是 directory entries。每个 directory entry 的格式都是 WINNT.H 中定义的 
IMAGE_RESOURCE_DIRECTORY_ENTRY 结构体。该结构体由两个联合体构成。第一个联合体保存着 entry 的 ID。若此域的最高位置位,则低31位为一个 Unicode 字符串的 RVA。这个 Unicode 字符串就是此 entry 的名字,它是由串的长度和16位的 Unicode 字符构成的。若最高位未置位,则联合体保存的是资源的 ID。可能是类型ID、名字 ID 或是语言 ID,这取决于目录的等级。IMAGE_RESOURCE_DIRECTORY_ENTRY 结构体中的第二个联合体要么指向另一个 resource directory,要么指向资源数据。若高位置位,则为另一子目录的 RVA;若最高位未置位,则
为资源数据 entry 的 RVA,这时的资源数据 entry 是 resource directory 树型结构的叶子节点。

在 WINNT.H 文件中,资源数据 entry 的格式定义为 IMAGE_RESOURCE_DATA_ENTRY 结构体,有以下成员:

OffsetToData 实际资源数据的 RVA。
Size 资源数据的大小。
CodePage 代码页,一般为 Unicode 代码页。

Relocation Table 

PE 文件只需要基址的重定位。linker 认为文件会加载在 preferred base address,并以此解析所有的相对重定位。例如,函数 foo 的 RVA 为 0x100,preferred base address 为 0x400000,则 linker 会将对函数 foo 的调用解析为对地址 0x400100 的调用。运行时,若文件加载在 preferred base address 0x400000 上,就不需要任何的重定位。若由于某种原因文件不能加载在基地址 0x400000 上,loader 就需要修正该调用。比如 loader 将文件加载到了基地址 0x600000 上,它就需要将调用的地址改为 0x600100。一般来说,所有的要修正的地址都要加上这 0x200000 的差值。这个过程就叫做基址重定位。要修正的一组地址又叫作 fixups,通常保存在 .reloc section 中的重定位表中,并且由索引值为 IMAGE_DIRECTORY_ENTRY_BASERELOC 的 data directory 成员所指向。重定位表就是一系列的重定位块,每个重定位块代表着一个 4K 页的 fixups。每个重定位块都有一个header,header 之后是相应页的重定位 entries。在 WINNT.H 文件中,重定位块的格式被定义为 IMAGE_BASE_RELOCATION 结构体,有以下各域:

VirtualAddress 要修正的页的 RVA。

SizeOfBlock 重定位块的总大小, 包括 header 和重定位 entries。

每个重定位 entry 都是一个16位的字。高4位指示重定位的类型,低12位为 4K 页中 fixup 的偏移。这个偏移再加上加载的基地址、所在页的 RVA 就得到了修正后的地址。WINNT.H 定义了重定位的类型——Intel 的机器只用了其中的两个: 

IMAGE_REL_BASED_ABSOLUTE 跳过重定位。此类型可用于为重定位块添加补齐区以使下一重定位块起始于4字节的边界。

IMAGE_REL_BASED_HIGHLOW 将基地址之差加到由12位偏移指向的32位双字上。 


Debug Directory 

操作系统并不关心 PE 文件中是否有调试信息,但调试工具却要访问它们。调试工具各式各样,而且使用不同的调试信息格式。相应的编译器/链接器又会生成不同的调试信息格式。PE 格式就能保存不同格式的调试信息,比如 COFF、Frame Pointer Omission (FPO)、CodeView (CV4) 等等。一个文件可以包含多于一种的格式。由 data directory 中索引为 IMAGE_DIRECTORY_ENTRY_DEBUG 的 debug directory 是一个数组,每个成员对应一种信息
格式,WINNT.H 文件中的 IMAGE_DEBUG_DIRECTORY 结构体定义了成员的格式: 

Characteristics 目前尚未使用,设为 0。

TimeDateStamp 创建调试信息的日起和时间。 

MajorVersion, 
MinorVersion 调试数据格式的版本。

Type 调试数据格式的类型。

SizeOfData 调试数据的大小。

AddressOfRawData 调试数据的 RVA。

PointerToRawData 调试数据的文件内偏移。

在不同的调试信息格式中, PE files 中常见的有三种。第一种就是由 CodeView 调试器使用的格式。此格式定义在 CV4 规范中。FPO 是用于描述非标准堆栈帧的。并非所有的 PE 文件都有 FPO 格式的 debug entry,没有它的文件就被认为是有正常堆栈帧的。第三种重要的格式就是 COFF,它是 PE 文件的 native 的调试信息格式。PE header 本身就指向一个 COFF 符号表。COFF 调试信息由符号和行号构成。


Thread Local Storage 

在进程中执行的所有线程共享全局数据空间。有时线程会需要一些自己的本地存储。例如每个线程都需要有一个自己本地的变量 i。

在这种情况下, 每个线程都有一个 i 的私有拷贝。当某一线程运行时,它自己私有的 i 就应当自动激活。在Windows NT 里,这是通过使用 Thread Local Storage (TLS) 机制实现的。来看一下它是如何工作的。

别把线程的本地数据同局部变量混淆起来,局部变量是创建在堆栈上的。每个线程都有自己独立的堆栈。线程独立地在堆栈中分配回收局部变量,使得堆栈增长或缩小。这一节里的线程本地数据是指每个线程有一份独立拷贝的全局变量。

对于操作系统中每一个正在运行的线程,系统都为其维护着一个叫做 Thread Environment Block (TEB) 的结构。FS 段寄存器总是处于使用状态,使得地址 FS:0 指向正在执行的线程的 TEB 

TEB 中有一个指向 TLS 数组的指针。TLS 数组是一个4字节双字的数组。与 TEB 类似,每个线程都有一个独立的TLS 数组。线程可以将其本地数据保存在 TLS 数组中。程序一般将指向本地数据的指针保存在 TLS 数组的某个成员里。TLS 数组成员的分配是由 API 函数 TlsAlloc() 和 TlsFree() 控制的。Win32 API 还提供了设置或取得 TLS 数组某个成员的值的函数。

总是用 API 函数访问 thread-specific 数据显得有点儿笨。简单点儿的方法就是用 __declspec(thread) 来声明需要为线程作私有拷贝的全局变量。编译器/链接器集齐所有像这样的变量,并自动为这堆数据中的每一个都分配一个 TLS 数组的索引。以此索引得到的 TLS 数组中有一个指向存有所有这些变量的缓冲区的指针。访问这些变量就像访问程序中其它普通变量一样。一有对这些变量的访问,编译器就生成代码来访问 TLS 数组成员进而在本地数据缓冲区中正确的偏移处访问本地数据。

讨论可能有点儿跑题,但对于讨论 IMAGE_DIRECTORY_ENTRY_TLS data directory entry 是绝对必要的。在 WINNT.H 中 TLS directory 的结构被定义为 IMAGE_TLS_DIRECTORY。下面来看一下这个结构体,看它是如何在 TLS 机制下工作的。

StartAddressOfRawData 每当新线程创建时, 操作系统就为线程分配一个新的本地数据缓冲区并用此域所指向的值来初始化该缓冲区。 注意,这个地址并不是 RVA,而是一个正确的虚拟地址。这个地址在 .reloc 

EndAddressOfRawData 已初始化数据的结束点的虚拟地址。本地数据缓冲区的其它地方都用0来填充

AddressOfIndex 在 loader 应该自动保存所分配的 TLS 索引的 data section 中的地址。访问 TLS 变量的代码从此处访问索引值。

AddressOfCallBacks 指向 TLS 回调函数数组的指针,这个数组以 null 结尾。只要创建新线程就会调用此数组中的函数,这些函数可以完成对 TLS 数据进行的额外的初始化工作(例如,调用构造函数)。TLS 回调函数与 DLL 的入口点函数有相同的参数。

SizeOfZeroFill 被初始化为 0 的本地数据的大小。本地数据的总大小为 
(EndAddressOfRawData - StartAddressOfRawData) + SizeOfZeroFill。

Characteristics 保留。

Section Table

我们已经讨论了 PE 格式的很多方面但还没有提到 section 的格式. 之所以可以推迟到现在是因为 data directory 直接就可以定位 PE 文件中的很多重要的信息。 完全不懂 sections 也一样可以解释 PE 文件。然而然而,如果需要修改一个 PE 文件,就需要了解 sections 和 section headers。 例如,可能需要添加、删除或扩展某个 section,这就需要修改 section table 和其它东西。

正如前面提到的,PE header 后面就是 section table. section table 是一个 section headers 的数组。在WINNT.H 文件中,section header 的格式被定义为 IMAGE_SECTION_HEADER 结构体。 section header 的成员如下:

Name 大小为 IMAGE_SIZEOF_SHORT_NAME 的字符数组,内容为 section 的名字。 

VirtualSize section 的大小。 

VirtualAddress 载入内存后 section 数据的 RVA。 

SizeOfRawData 文件中的 section 的大小。等于将 VirtualSize 圆整为下一文件对齐值的整倍数。 

PointerToRawData section 数据的文件内偏移。若将 PE 文件映射入内存,则需要用该域来指向 
section 数据。 

PointerToRelocations 仅用于 OBJ 文件。 

PointerToLinenumbers COFF 风格的行号信息的文件内偏移。 

NumberOfRelocations 仅用于 OBJ 文件。

NumberOfLinenumbers 行号信息中记录的数量。

Characteristics section 的属性。一组用“或”运算连结特征标志得到的值。 WINNT.H 文件中定义的 section 的特征标志如下:

IMAGE_SCN_CNT_CODE               Section 包含可执行代码。

IMAGE_SCN_CNT_INITIALIZED_DATA   Section 包含以初始化的数据。

IMAGE_SCN_CNT_UNINITIALIZED_DATA Section 包含未初始化的数据。

IMAGE_SCN_LNK_REMOVE             Section 不会成为被加载的 image 的一部分。.debug section 可能会设置此标志

IMAGE_SCN_MEM_DISCARDABLE        Section 可以被舍弃。 加载过程结束后,重定位表和调试信息可以被舍弃。 因此 .debug section 和 .reloc section 都设置了此标志。

IMAGE_SCN_MEM_NOT_CACHED         Section 不能被缓存。

IMAGE_SCN_MEM_NOT_PAGED          Section 不可分页。 

IMAGE_SCN_MEM_SHARED             Section 可在内存中共享。若 DLL 的 data section 设置了此标志,则所有不同进程中该 DLL 的实例共享相同的数据。

IMAGE_SCN_MEM_EXECUTE            Section 可执行. 对于 code sections,IMAGE_SCN_CNT_CODE 和 IMAGE_SCN_MEM_EXECUTE 标志都为 1。

IMAGE_SCN_MEM_READ               Section 可读。 
IMAGE_SCN_MEM_WRITE              Section 可写。


加载过程

我们来看一下 loader 是如何解释 PE 文件,又是如何为执行准备内存 image 的。 loader 需要找到空闲的虚拟地址空间来将文件映射到内存。 loader 尝试着将 image 加载在 preferred base address。成功后,loader 将 sections 映射入内存。loader 扫描 section table,用每一个 section 的 RVA 加上基地址算出 section 的加载地址,然后将 sections 加载在相应的地址上。页属性是根据 section 的特征要求设定的。将 section 映射入内存后,若基地址不等于 preferred base address,则 loader 开始进行基址重定位。之后检查 import table 并加载所需的 DLLs。加载 DLL 与加载可执行文件的过程一样——映射 sections,基址重定位,解析 
imports等等。所有的 DLL 都加载了之后,就修改 IAT 使之指向实际的 imported 函数的地址。

成了! image 已准备好执行了。


总结

微软为 Windows NT 引入了 Portable Executable (PE) file format。 PE 格式是微软所有32位操作系统的可执行文件格式(即,各版本的 Windows NT 和 Windows 95/98), 尽管这些操作系统仍然支持老式的可执行文件格式,比如 DOS 可执行文件格式。

PE 文件的很多地方都使用 relative virtual address (RVA) 来寻址。 IMAGEHLP.DLL 提供了工具函数来将文件映射入内存以及在 PE 文件中查找 RVA 对应的内存地址。PE 文件由 file headers,data directory,section table 和 各个 section 构成。data directory 指向 PE 文件的重要数据:export directory,import directory,relocation table,debug directory 和 Thread Local Storage。export directory 列出了所有从文件 export 的符号,该文件很可能是 DLL。import directory 列出了所有由 PE 文件 import的符号。当 PE 文件被加载进内存执行时,loader 将 imported symbols 解析为 export 这些符号的 DLL 中的实际虚拟地址。这个过程叫做动态链接。

PE headers 后面是 section table,section table 指向所有的 sections,包括由各种 data directory entries 指向的 sections。loader 读入 section table 并将 PE 文件的各个 sections 映射入内存。然后就重定位 image 的映射地址并在加载所需 DLL 之后解析各个 imported 符号。准备执行。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值