Tuesday, February 01, 2005

 

PE/COFF File Format

诚如jiurl在http://jiurl.nease.net/document/jiurlpe/jiurlpe1.htm 中所指出的,PE/COFF文件的问题早已基本解释清楚。这几篇blog只是通过rivershan和redleaves的笔记建立一个快速简练的速查手册而已。

Microsoft Portable Executable and Common Object File Format Specification
www.osdever.net/documents/PECOFF.pdf?the_id=49

Common Object File Format (COFF)
http://support.microsoft.com/?id=121460

《Windows95系统程式设计大奥秘》
第8章 PE 与COFF OBJ 档案格式
Matt Pietrek 著 侯杰译

Iczelion的PE教程
Iczelion's Win32 Assembly Homepage
http://win32asm.cjb.net/

Inside Windows
An In-Depth Look into the Win32 Portable Executable File Format
Matt Pietrek
http://msdn.microsoft.com/msdnmag/issues/02/02/PE/default.aspx

1.
from http://dev.csdn.net/develop/article/16/16568.shtm
http://dev.csdn.net/develop/article/16/16625.shtm

PE 的意思就是 Portable Executable(可移植的执行体)。PE文件结构的总体层次分布图:

--------------
|DOS MZ Header |
|--------------|
|DOS Stub |
|--------------|
|PE Header |
|--------------|
|Section Table |
|--------------|
|Section 1 |
|--------------|
|Section 2 |
|--------------|
|Section ... |
|--------------|
|Section n |
--------------

一、PE文件格式的概要

1.1、DOS MZ Header:
所有 PE文件(甚至32位的 DLLs)必须以一个简单的 DOS MZ Header 开始。有了它,一旦程序在DOS下执行,DOS就能识别出这是有效的执行体,然后运行紧随 MZ Header 之后的 DOS Stub。

1.2、DOS Stub:
DOS Stub(存根)实际上是个有效的 MS-DOS .EXE 或者.COM 程序(如果文件格式不对会报错),在不支持 PE文件格式的操作系统中,它将通过简单调用中断21h服务9来显示字符串"This program cannot run in DOS mode"或者根据程序员自己的意图实现完整的 DOS 代码。它的大小一般不能确定。利用链接器(linker)的 /STUB:filename 选项可以替换这个程序。

1.3、PE Header:
紧接着 DOS Stub 的是 PE Header。PE Header 是PE相关结构 IMAGE_NT_HEADERS 的简称,其中包含了许多PE装载器用到的重要域。执行体在支持PE文件结构的操作系统中执行时,PE装载器将从 DOS MZ Header (IMAGE_DOS_HEADER)中找到 PE Header 的起始偏移量。因而跳过了DOS Stub 直接定位到真正的文件头PE Header。

1.4、Section Table:
PE Header 接下来的数组结构 Section Table (节表)。如果PE文件里有5个节,那么此 Section Table 结构数组内就有5个成员,每个成员包含对应节的属性、文件偏移量、虚拟偏移量等。

1.5、Sections:
PE文件的真正内容被划分成块,称之为Section(节)。每个标准节的名字均以圆点开头。Sections 是以其起始位址来排列,而不是以其字母次序来排列。下面是常见的节名及作用:

节名 作用
.arch 最初的构建信息(Alpha Architecture Information)
.bss 未经初始化的数据
.CRT C运行期只读数据
.data 已经初始化的数据
.debug 调试信息
.didata 延迟输入文件名表
.edata 导出文件名表
.idata 导入文件名表
.pdata 异常信息(Exception Information)
.rdata 只读的初始化数据
.reloc 重定位表信息
.rsrc 资源
.text .exe或.dll文件的可执行代码
.tls 线程的本地存储器
.xdata 异常处理表

节的划分是基于各组数据的共同属性,而不是逻辑概念。每节是一块拥有共同属性的数据,比如代码/数据、读/写等。如果PE文件中的数据/代码拥有相同属性,它们就能被归入同一节中。节名称仅仅是个区别不同节的符号而已,类似"data", "code"的命名只为了便于识别,惟有节的属性设置决定了节的特性和功能。

1.6、装载一PE文件的主要步骤:

1.当PE文件被执行,PE装载器检查 DOS MZ Header 里的 PE Header 偏移量。如果找到,则跳转到 PE Header。
2.PE装载器检查 PE Header 的有效性。如果有效,就跳转到PE Header的尾部。
3.紧跟 PE Header 的是节表。PE装载器读取其中的节信息,并采用文件映射方法将这些节映射到内存,同时付上节表里指定的节属性。
4.PE文件映射入内存后,PE装载器将处理PE文件中类似 Import Table(导入表)逻辑部分。


二、DOS MZ Header 和 PE Header

2.1、DOS MZ Header 定义成结构 IMAGE_DOS_HEADER(64字节) 。结构定义如下:

typedef struct _IMAGE_DOS_HEADER { // DOS .EXE Header
WORD e_magic; // Magic number
WORD e_cblp; // Bytes on last page of file
WORD e_cp; // Pages in file
WORD e_crlc; // Relocations
WORD e_cparhdr; // Size of Header in paragraphs
WORD e_minalloc; // Minimum extra paragraphs needed
WORD e_maxalloc; // Maximum extra paragraphs needed
WORD e_ss; // Initial (relative) SS value
WORD e_sp; // Initial SP value
WORD e_csum; // Checksum
WORD e_ip; // Initial IP value
WORD e_cs; // Initial (relative) CS value
WORD e_lfarlc; // File address of relocation table
WORD e_ovno; // Overlay number
WORD e_res[4]; // Reserved words
WORD e_oemid; // OEM identifier (for e_oeminfo)
WORD e_oeminfo; // OEM information; e_oemid specific
WORD e_res2[10]; // Reserved words
LONG e_lfanew; // File address of new exe Header
} IMAGE_DOS_HEADER, *PIMAGE_DOS_HEADER;

IMAGE_DOS_HEADER 结构的e_lfanew成员就是指向 PE Header 的 RVA。e_magic 包含字符串"MZ"。

2.2、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;

IMAGE_NT_HEADERS 结构成员含义:

1.Signature:一DWORD 类型,值为50h, 45h, 00h, 00h(PE\0\0)。如果IMAGE_NT_HEADERS的Signature域值等于"PE\0\0",那么就是有效的PE文件。Microsoft定义了常量IMAGE_NT_SIGNATURE供我们使用,定义如下:

#define IMAGE_DOS_SIGNATURE 0x5A4D // MZ
#define IMAGE_OS2_SIGNATURE 0x454E // NE
#define IMAGE_OS2_SIGNATURE_LE 0x454C // LE
#define IMAGE_VXD_SIGNATURE 0x454C // LE
#define IMAGE_NT_SIGNATURE 0x00004550 // PE00

2.FileHeader:该结构域包含了关于PE文件物理分布的信息,比如节数目、文件执行机器等。

3.OptionalHeader:该结构域包含了关于PE文件逻辑分布的信息,虽然域名有"可选"字样,但实际上本结构总是存在的。

2.3、检验PE文件的有效性步骤总结如下:

1.首先检验文件头部第一个字的值是否等于 IMAGE_DOS_SIGNATURE,是则 DOS MZ Header 有效。
2.一旦证明文件的 DOS Header 有效后,就可用e_lfanew来定位 PE Header 了。
3.比较 PE Header 的第一个字的值是否等于 IMAGE_NT_HEADER。如果前后两个值都匹配,那我们就认为该文件是一个有效的PE文件。

下面将通过一个VC++ 6.0的例子来检验PE文件的有效性:

我们首先调用打开文件通用对话框(GetOpenFileName),选择打开一个文件并映射到内存(CreateFile,CreateFileMapping、MapViewOfFile等),获得目标文件大小(m_buffer = new unsigned char[m_size];)。然后获取目标文件的头2个字节(((unsigned short*)m_buffer)[0];),看是否为"MZ"。如果相同,获得目标文件PE header的位置(((unsigned int*)(2*m_buffer + 0x3c));), 与0x00004550(PE)比较。由此验证PE有效性。

三、File Header(文件头)

File Header(IMAGE_FILE_HEADER)包含在PE Header(IMAGE_NT_HEADERS)里面,其结构定义:

typedef struct _IMAGE_FILE_HEADER {
WORD Machine;
WORD NumberOfSections;
DWORD TimeDateStamp;
DWORD PointerToSymbolTable;
DWORD NumberOfSymbols;
WORD SizeOfOptionalHeader;
WORD Characteristics;
} IMAGE_FILE_HEADER, *PIMAGE_FILE_HEADER;

IMAGE_FILE_HEADER 结构成员含义:

1.Machine:该文件运行所要求的CPU。对于Intel平台,该值是IMAGE_FILE_MACHINE_I386 (14Ch)。我们尝试了LUEVELSMEYER的pe.txt声明的14Dh和14Eh,但Windows不能正确执行。

一些CPU识别码的定义:

Intel I386 0x14C
Intel i860 0x14D
MIPS R300 0x162
MIPS R400 0x166
DEC Alpha AXP 0x184
Power PC 0x1F0(little endian)
Motorola 68000 0x268
PA RISC 0x290(Precision Architecture)

#define IMAGE_FILE_MACHINE_UNKNOWN 0
#define IMAGE_FILE_MACHINE_I386 0x014c // Intel 386.
#define IMAGE_FILE_MACHINE_R3000 0x0162 // MIPS little-endian, 0x160 big-endian
#define IMAGE_FILE_MACHINE_R4000 0x0166 // MIPS little-endian
#define IMAGE_FILE_MACHINE_R10000 0x0168 // MIPS little-endian
#define IMAGE_FILE_MACHINE_WCEMIPSV2 0x0169 // MIPS little-endian WCE v2
#define IMAGE_FILE_MACHINE_ALPHA 0x0184 // Alpha_AXP
#define IMAGE_FILE_MACHINE_POWERPC 0x01F0 // IBM PowerPC Little-Endian
#define IMAGE_FILE_MACHINE_SH3 0x01a2 // SH3 little-endian
#define IMAGE_FILE_MACHINE_SH3E 0x01a4 // SH3E little-endian
#define IMAGE_FILE_MACHINE_SH4 0x01a6 // SH4 little-endian
#define IMAGE_FILE_MACHINE_ARM 0x01c0 // ARM Little-Endian
#define IMAGE_FILE_MACHINE_THUMB 0x01c2
#define IMAGE_FILE_MACHINE_IA64 0x0200 // Intel 64
#define IMAGE_FILE_MACHINE_MIPS16 0x0266 // MIPS
#define IMAGE_FILE_MACHINE_MIPSFPU 0x0366 // MIPS
#define IMAGE_FILE_MACHINE_MIPSFPU16 0x0466 // MIPS
#define IMAGE_FILE_MACHINE_ALPHA64 0x0284 // ALPHA64
#define IMAGE_FILE_MACHINE_AXP64 IMAGE_FILE_MACHINE_ALPHA64

2.NumberOfSections:文件的节数目。如果我们要在文件中增加或删除一个节,就需要修改这个值。

3.TimeDateStamp:文件创建日期和时间。其格式是自从1969年12 月31 日4:00 P.M. 之后的总秒数。据我计算,0xFFFFFFFFh是136.19251950152207001522070015221 年。

4.PointerToSymbolTable:COFF 符号表格的偏移位置。此域只对COFF 除错信息有用。

5.NumberOfSymbols:COFF 符号表格中的符号个数。

6.SizeOfOptionalHeade:指示紧随本结构之后的 Optional Header(IMAGE_OPTIONAL_HEADER)结构大小,必须为有效值。

7.Chracteristics:关于本文件信息的标记。一些比较重要的性质如下:

0x0001 文件中没有重定位(relocation)
0x0002 文件是一个可执行程序exe(也就是說不是OBJ 或LIB)
0x2000 文件是dll,不是exe。

一般情况下,如果要遍历节表就得使用 NumberOfSections,其它的几个域作用不大。


四、Optional Header

4.1、RVA 及其相关概念:

RAV 代表相对虚拟地址。RVA是虚拟空间中到参考点的一段距离。RVA就是类似文件偏移量的东西。当然它是相对虚拟空间里的一个地址,而不是文件头部。举例说明,如果PE文件装入虚拟地址(VA)空间的400000h处,且进程从虚址401000h开始执行,我们可以说进程执行起始地址在RVA 1000h。每个RVA都是相对于模块的起始VA的。虛址(VA)0x401000h - 基址(BA)0x400000h = RVA 0x1464h。基址(Base Address)用来描述被映射到内存中的exe或者dll的起始位置。

为什么PE文件格式要用到RVA呢? 这是为了减少PE装载器的负担。因为每个模块都有可能被重载到任何虚拟地址空间,如果让PE装载器修正每个重定位项,这肯定是个梦魇。相反,如果所有重定位项都使用RVA,那么PE装载器就不必操心那些东西了: 它只要将整个模块重定位到新的起始VA。这就象相对路径和绝对路径的概念: RVA类似相对路径,VA就象绝对路径。

在PE文件中大多数地址多是RVAs 而 RVAs只有当PE文件被PE装载器装入内存后才有意义。如果直接将文件映射到内存而不是通过PE装载器载入,则不能直接使用那些RVAs。必须先将那些RVAs转换成文件偏移量。

4.2、Optional Header 结构是 IMAGE_NT_HEADERS 中的最后成员。包含了PE文件的逻辑分布信息。该结构共有31个域,一些是很关键,另一些不太常用。其结构定义:

typedef struct _IMAGE_OPTIONAL_HEADER {
WORD Magic;
BYTE MajorLinkerVersion;
BYTE MinorLinkerVersion;
DWORD SizeOfCode;
DWORD SizeOfInitializedData;
DWORD SizeOfUninitializedData;
DWORD AddressOfEntryPoint;
DWORD BaseOfCode;
DWORD BaseOfData;
DWORD ImageBase;
DWORD SectionAlignment;
DWORD FileAlignment;
WORD MajorOperatingSystemVersion;
WORD MinorOperatingSystemVersion;
WORD MajorImageVersion;
WORD MinorImageVersion;
WORD MajorSubsystemVersion;
WORD MinorSubsystemVersion;
DWORD Win32VersionValue;
DWORD SizeOfImage;
DWORD SizeOfHeaders;
DWORD CheckSum;
WORD Subsystem;
WORD DllCharacteristics;
DWORD SizeOfStackReserve;
DWORD SizeOfStackCommit;
DWORD SizeOfHeapReserve;
DWORD SizeOfHeapCommit;
DWORD LoaderFlags; DWORD NumberOfRvaAndSizes;
IMAGE_DATA_DIRECTORY DataDirectory[IMAGE_NUMBEROF_DIRECTORY_ENTRIES];
} IMAGE_OPTIONAL_HEADER, *PIMAGE_OPTIONAL_HEADER;

IMAGE_OPTIONAL_HEADER 结构成员含义:

1.Magic:用来定义 image 的状态

0x0107(IMAGE_ROM_OPTIONAL_HDR_MAGIC):一个 ROM image
0x010B(IMAGE_NT_OPTIONAL_HDR_MAGIC): 一个正常的(一般的)EXE image。大部份PE 文件都含此值。

2.MajorLinkerVersion、MinorLinkerVersion:产生此PE文件的链接器的版本。以十进制而非十六进制表示。例如2.23 版。

3.SizeOfCode:所有code section 的总和大小。大部分程序只有一个 code section,所以此域通常就是 .text section 的大小。
4.SizeOfInitializedData:所有包含初始化内容的 sections(但不包括 code section)的总和大小。似乎不包括 initialized data sections 在内。

5.SizeOfUninitializedData:所有需要PE装载器将内存地址空间赋予它但是却不占用硬盘空间的所有 sections 的大小总和。这些 sections 在程序启动时并不需要特别内容,所以导致 Uninitialized Data 这种叫法。为初始化的内容通常放在 .bss section 中。

6.AddressOfEntryPoint:这是PE文件开始执行的位置。这是一个RVA,通常会落在 .text section.此域适用于 exe 或 dll。

7.BaseOfCode:一个RVA,表示程序中的 code section 从何开始。code section 通常在 data section 之前,在PE 表头之后。微软链接器所产生的exes 中,此值通常为0x1000。Borland 的TLINK32则通常指定此值为0x10000。因为预设情况下TLINK时以64k为对齐粒度的,而MS用的是4k。

8.BaseOfData:一个RVA,表示程序中的 data section 从何开始。data section 一般位于code section 和 PE 表头之后。

9.ImageBase:PE文件的优先装载地址(Base Address)。比如,如果该值是400000h,PE装载器将尝试把文件装到虚拟地址空间的400000h处。字眼"优先"表示若该地址区域已被其他模块占用,那PE装载器会选用其他空闲地址。

10.SectionAlignment:内存中节对齐的粒度。例如,如果该值是4096 (1000h),那么每节的起始地址必须是4096的倍数。若第一节从401000h开始且大小是10个字节,则下一节必定从402000h开始,即使401000h和402000h之间还有很多空间没被使用。

11.FileAlignment:文件中节对齐的粒度。例如,如果该值是(200h),,那么每节的起始地址必须是512的倍数。若第一节从文件偏移量200h开始且大小是10个字节,则下一节必定位于偏移量400h,即使偏移量512和1024之间还有很多空间没被使用或定义。预设值就是0x200h。

12.MajorOperatingSystemVersion/MinorOperatingSystemVersion:使用此可执行程序的操作系统的最小版本。WIN32程序的这两个域通常指定为1.0。

13.MajorSubsystemVersion/MinorSubsystemVersion:WIN32子系统版本。若PE文件是专门为WIN32设计的,该子系统版本必定是4.0否则对话框不会有3维立体感。

14.MajorImageVersion/MinorImageVersion:使用者自定义的域,允许你拥有不同版本的exe或dll。可以利用链接器的 /VERSION 选项设定其值。例如:LINK /VERSION:2.0 myobj.obj。

15.Reserved1:似乎总是0。

16.SizeOfImage:内存中整个PE映像体的尺寸。它是所有头和节经过节对齐处理后的大小。也就是从image base 开始,直到最后一个 section为止。最后一个section 的尾端必需是SectionAlignment 的倍数。

17.SizeOfHeaders:所有头 + 节表的大小,也就等于文件尺寸减去文件中所有节的尺寸。可以以此值作为PE文件第一节的文件偏移量。

18.CheckSum:此程序的一个CRC 校验和。PE中此域通常被忽略并被设为0。然而,所有的driver DLLs、所有在开机时载入的DLLs、以及server DLLs 都必须有一个合法的 CheckSum。其演算法可以在IMAGEHLP.DLL中获得。IMAGEHLP.DLL 的代码可以在WIN32 SDK中找到。

19.Subsystem:用来识别PE文件属于哪个子系统。对于大多数Win32程序,只有两类值: Windows GUI 和 Windows CUI (控制台)。WINNT.h中定义如下:

#define IMAGE_SUBSYSTEM_UNKNOWN 0 Unknown subsystem.
#define IMAGE_SUBSYSTEM_NATIVE 1 不需要子系統(例如驱动程序)
#define IMAGE_SUBSYSTEM_WINDOWS_GUI 2 在Windows GUI 子系统中运行
#define IMAGE_SUBSYSTEM_WINDOWS_CUI 3 在Windows 字符模式子系统中运行(也就是console 应用程序)
#define IMAGE_SUBSYSTEM_OS2_CUI 5 在OS/2 字符模式子系统中运行(也就是OS/2 1.x 应用程序)
#define IMAGE_SUBSYSTEM_POSIX_CUI 7 在Posix 字符模式子系统中运行
#define IMAGE_SUBSYSTEM_NATIVE_WINDOWS 8 一个Win9x驱动
#define IMAGE_SUBSYSTEM_WINDOWS_CE_GUI 9 在Win CE 子系统中运行

20.DllCharacteristics:一组标志位,用来指出dll的初始化函数(例如 DllMain)在什么环境下被调用。这个值总是0,但是操作系统会在四种情况发生式调用dll的初始化函数。此值的四个值的意义如下:

0x0001:当DLL被载入一个进程的地址空间时
0x0002:当一个线程结束时
0x0004:但一个线程开始时
0x0008:当DLL退出时
0x2000:一个WDM驱动

21.SizeOfStackReserve:线程初始堆栈的保留大小。然而并不是所有的这些内存都被系统指定。此值预设为0x100000(1MB)。如果你的程序中调用CreateThread 并指定其堆栈大小为0,获得的线程就有一个与此值相同大小的堆栈。

22.SizeOfStackCommit:一开始就被指定给执行线程初始堆栈的内存数量。微软的链接器预设此值为0x1000(一个page),Borland 的TLINK32把它设为0x2000(两个page)。

23.SizeOfHeapReserve:保留给最初的进程堆(process heap)的虚拟内存数量。这个堆的句柄可以利用GetProcessHeap 获得。并不是所有的这些内存都被指定。

24.SizeOfHeapCommit:一开始就被指定给进程堆(process heap)的内存数量。此值预设为0x1000个字节(位元组)。

25.LoaderFlags:Debug用。可能作用:
a.在开始这个进程之前引发一个中断?
b.在进程被载入之后引发一个除错器执行?

26.NumberOfRvaAndSizes:在DataDirectory(下一个域)数组的成员结构个数。目前的工具总是把此值设为16。

27.DataDirectory[IMAGE_NUMBEROF_DIRECTORY_ENTRIES]:一个IMAGE_DATA_DIRECTORY 结构数组。每个结构给出一个重要数据结构的RVA。数组的第一个元素代表 Exported Function Table(如果有的话)的地址和大小,第二个元素代表Imported Function Table 的地址和大小,依此类推。下面是其顺序的完整列表:

// Directory Entries
#define IMAGE_DIRECTORY_ENTRY_EXPORT 0 // Export Directory
#define IMAGE_DIRECTORY_ENTRY_IMPORT 1 // Import Directory
#define IMAGE_DIRECTORY_ENTRY_RESOURCE 2 // Resource Directory
#define IMAGE_DIRECTORY_ENTRY_EXCEPTION 3 // Exception Directory
#define IMAGE_DIRECTORY_ENTRY_SECURITY 4 // Security Directory
#define IMAGE_DIRECTORY_ENTRY_BASERELOC 5 // Base Relocation Table
#define IMAGE_DIRECTORY_ENTRY_DEBUG 6 // Debug Directory
#define IMAGE_DIRECTORY_ENTRY_COPYRIGHT 7 // Description String
#define IMAGE_DIRECTORY_ENTRY_GLOBALPTR 8 // Machine Value (MIPS GP)
#define IMAGE_DIRECTORY_ENTRY_TLS 9 // TLS Directory
#define IMAGE_DIRECTORY_ENTRY_LOAD_CONFIG 10 // Load Configuration Directory
#define IMAGE_DIRECTORY_ENTRY_BOUND_IMPORT 11 // Bound Import Directory in headers
#define IMAGE_DIRECTORY_ENTRY_IAT 12 // Import Address Table

96/112 8 Export Table Export Table address and size.
104/120 8 Import Table Import Table address and size
112/128 8 Resource Table Resource Table address and size.
120/136 8 Exception Table Exception Table address and size.
128/144 8 Certificate Table Attribute Certificate Table address and size.
136/152 8 Base Relocation Table Base Relocation Table address and size.
144/160 8 Debug Debug data starting address and size.
152/168 8 Architecture Architecture-specific data address and size.
160/176 8 Global Ptr Relative virtual address of the value to be stored in the global pointer register. Size member of this structure must be set to 0.
168/184 8 TLS Table Thread Local Storage (TLS) Table address and size.
176/192 8 Load Config Table Load Configuration Table address and size.
184/200 8 Bound Import Bound Import Table address and size.
192/208 8 IAT Import Address Table address and size.
200/216 8 Delay Import Descriptor Address and size of the Delay Import Descriptor.
208/224 8 COM+ Runtime Header COM+ Runtime Header address and size
216/232 8 Reserved

六、Import Table(导入表)

6.1、导入函数:

一个导入函数是被某模块调用的但又不在调用者模块中的函数,因而命名为"import(导入)"。导入函数实际位于一个或者更多的DLL里。调用者模块里只保留一些函数信息,包括函数名及其驻留的DLL名。

PE 程序被载入到内存之前,存放在 PE 文件的 .data 中的内容是给装载器用来决定函数位置并修补它们以便完成image 用的。而在被载入之后,.idata内含有的是指向 EXE/DLL 的导入函数的指针。

6.2、Data Directory:

Data Directory 是一个 IMAGE_DATA_DIRECTORY 结构数组,共有16个成员。Data Directory 包含了PE文件中各重要数据结构的位置和尺寸信息。 每个成员包含了一个重要数据结构的信息。

Data Directory 的每个成员都是 IMAGE_DATA_DIRECTORY 结构类型的,其定义如下所示:

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

IMAGE_DATA_DIRECTORY 结构成员含义:

1.VirtualAddress: 实际上是数据结构的相对虚拟地址(RVA)。比如,如果该结构是关于Import Symbols的,该域就包含指向IMAGE_IMPORT_DESCRIPTOR 数组的RVA。

2.Size: 含有VirtualAddress所指向数据结构的字节数。

6.3、找寻PE文件中重要数据结构的一般方法:

1、从 DOS Header 定位到 PE Header。
2、从 Optional Header 读取 Data Directory 的地址。
3、IMAGE_DATA_DIRECTORY 结构尺寸乘上找寻结构的索引号:比如您要找寻Import Symbols的位置信息,必须用IMAGE_DATA_DIRECTORY 结构尺寸(8 bytes)乘上1(Import Symbols 在 Data Diectory 中的索引号)。
4、将上面的结果加上 Data Diectory 地址,我们就得到包含所查询数据结构信息的 IMAGE_DATA_DIRECTORY 结构项。

6.5、导入表:

Data Directory 数组第一项的 VirtualAddress 包含导入表地址。导入表实际上是一个 IMAGE_IMPORT_DESCRIPTOR 结构数组。每个结构包含PE文件导入函数的一个相关DLL的信息。该数组以一个全0的成员结尾。

IMAGE_IMPORT_DESCRIPTOR结构组成:

typedef struct _IMAGE_IMPORT_DESCRIPTOR {
union {
DWORD Characteristics; // 0 for terminating null import descriptor
DWORD OriginalFirstThunk; // RVA to original unbound IAT (PIMAGE_THUNK_DATA)
};
DWORD TimeDateStamp; // 0 if not bound,
// -1 if bound, and real date\time stamp
// in IMAGE_DIRECTORY_ENTRY_BOUND_IMPORT (new BIND)
// O.W. date/time stamp of DLL bound to (Old BIND)

DWORD ForwarderChain; // -1 if no forwarders
DWORD Name;
DWORD FirstThunk; // RVA to IAT (if bound this IAT has actual addresses)
} IMAGE_IMPORT_DESCRIPTOR;

IMAGE_IMPORT_DESCRIPTOR 结构成员含义:

1.结构第一项是一个union子结构。事实上,这个union子结构只是给 OriginalFirstThunk 增添了个别名,您也可以称其为"Characteristics"。该成员项含有指向一个 IMAGE_THUNK_DATA 结构数组的RVA。

2.TimeDateStamp:程序生成的时刻。此域通常为0。微软的 BIND 程序可以将此 IMAGE_IMPORT_DESCRIPTOR 所对应的dll的生成时刻写到这里来。

3.ForwarderChain:此域涉及到 forwarding(转交),意味着一个dll 函数在调用另一个 dll。例如,在 WINNT 中,Kernel32.dll 将它的某些输出函数转交给 NTDLL.dll。应用程序可能以为它调用 Kernel32.dll,而事实上它调用的事NTDLL.dll。这个域中含有一个索引,指向 FirstThunk 数组。被这个索引所指定的函数就是一个转交函数。

3.Name:含有指向DLL名字的RVA,即指向DLL名字的指针,也是一个ASCII字符串。

4.FirstThunk:与 OriginalFirstThunk 非常相似,它也包含指向一个 IMAGE_THUNK_DATA 结构数组的RVA(当然这是另外一个IMAGE_THUNK_DATA 结构数组)。

IMAGE_IMPORT_DESCRIPTOR 数组中,最重要的部分是 imported DLL 的名称以及两个 IMAGE_THUNK_DATA 数组。每个 IMAGE_THUNK_DATA 对应一个导入函数。在exe中,两个数组(分别由 Characteristics 和 FirstThunk 域指向)平行存在,并且都以 NULL 位结束符。

为什么需要两个平行数组?第一个数组(由 Characteristics 指向)从不被修改,有时它被称为 hint-name table。第二个数组(由 FirstThunk 指向)则被装载器改写。装载器一一检查每一个 IMAGE_THUNK_DATA 并且找出它所记录的函数的地址,然后把地址写入 IMAGE_THUNK_DATA 这个 DWORD 之中。由于这个 IMAGE_THUNK_DATA 数组内容已经被装载器改写为输入函数的地址,所以它又被叫做 Import Address Table(IAT)。IAT 是一个可写区域。API Hook 就利用到这一特性。PE装载器载入PE后,FirstThunk 指向的 IMAGE_THUNK_DATA 被改写,而 Characteristics 所指向的 IMAGE_THUNK_DATA 没有被改写。所以若还反过头来查找导入函数名,PE装载器还能够根据 Characteristics 所指向的 IMAGE_THUNK_DATA 找寻到函数名。

6.6、IMAGE_THUNK_DATA:

IMAGE_THUNK_DATA是一个DWORD类型的集合。通常我们将其解释为指向一个 IMAGE_IMPORT_BY_NAME 结构的指针。注意 IMAGE_THUNK_DATA 包含了指向一个 IMAGE_IMPORT_BY_NAME 结构的指针,而不是结构本身。

IMAGE_THUNK_DATA 结构定义:

typedef struct _IMAGE_THUNK_DATA32 {
union {
PBYTE ForwarderString;
PDWORD Function;
DWORD Ordinal;
PIMAGE_IMPORT_BY_NAME AddressOfData;
} u1;
} IMAGE_THUNK_DATA32;

IMAGE_THUNK_DATA 实在PE被载入之后才被决定的。WIN32装载器使用 IMAGE_THUNK_DATA 的初始内容(可能是函数名称也可能是函数序号)来寻找输入函数的位置。然后装载器就以获得的地址改写 IMAGE_THUNK_DATA 的内容。

6.7、IMAGE_IMPORT_BY_NAME:

IMAGE_IMPORT_BY_NAME 结构定义:

typedef struct _IMAGE_IMPORT_BY_NAME {
WORD Hint;
BYTE Name[1];
} IMAGE_IMPORT_BY_NAME, *PIMAGE_IMPORT_BY_NAME;

1.Hint:指示本函数在其所驻留DLL的导出表中的索引号。该域被PE装载器用来在DLL的导出表里快速查询函数。该值不是必须的,一些连接器将此值设为0。

2.Name:含有导入函数的函数名。函数名是一个ASCII字符串。注意这里虽然将Name的大小定义成字节,其实它是可变尺寸域,只不过我们没有更好方法来表示结构中的可变尺寸域。这个结构被提供用于查阅描述名字的结构。

有些情况下一些函数仅由序数导出,也就是说不能用函数名来调用它们,只能用它们的位置来调用。此时,调用者模块中就不存在该函数的 IMAGE_IMPORT_BY_NAME 结构。不同的,对应该函数的 IMAGE_THUNK_DATA 值的低位字指示函数序数,而最高二进位 (MSB)设为1。例如,如果一个函数只由序数导出且其序数是1234h,那么对应该函数的 IMAGE_THUNK_DATA 值是80001234h。Microsoft提供了一个方便的常量来测试dword值的MSB位,就是 IMAGE_ORDINAL_FLAG32,其值为80000000h。

6.8、列出某个PE文件的所有导入函数步骤:

1、校验文件是否是有效的PE。
2、从 DOS Header 定位到 PE Header。
3、获取位于 OptionalHeader 数据目录地址。
4、转至数据目录的第二个成员提取其VirtualAddress值。
5、利用上值定位第一个 IMAGE_IMPORT_DESCRIPTOR 结构。
6、检查 OriginalFirstThunk值。若不为0,顺着 OriginalFirstThunk 里的RVA值转入那个RVA数组。若 OriginalFirstThunk 为0,就改用FirstThunk值。有些连接器生成PE文件时会置OriginalFirstThunk值为0,这应该算是个bug。不过为了安全起见,我们还是检查 OriginalFirstThunk值先。
7、对于每个数组元素,我们比对元素值是否等于IMAGE_ORDINAL_FLAG32。如果该元素值的最高二进位为1,那么函数是由序数导入的,可以从该值的低字节提取序数。
8、如果元素值的最高二进位为0,就可将该值作为RVA转入 IMAGE_IMPORT_BY_NAME 数组,跳过 Hint 就是函数名字了。
9、再跳至下一个数组元素提取函数名一直到数组底部(它以null结尾)。现在我们已遍历完一个DLL的导入函数,接下去处理下一个DLL。
10、即跳转到下一个 IMAGE_IMPORT_DESCRIPTOR 并处理之,如此这般循环直到数组见底。(IMAGE_IMPORT_DESCRIPTOR 数组以一个全0域元素结尾)。

6.9、Bound Import:

当PE装载器装入PE文件时,检查导入表并将相关DLLs映射到进程地址空间。然后象我们这样遍历IMAGE_THUNK_DATA 数组并用导入函数的真实地址替换IMAGE_THUNK_DATAs 值。这一步需要很多时间。如果程序员能事先正确预测函数地址,PE装载器就不用每次装入PE文件时都去修正IMAGE_THUNK_DATAs 值了。Bound import就是这种思想的产物。

Microsoft 出品的类似Visual Studio的编译器多提供了bind.exe这样的工具,由它检查PE文件的导入表并用导入函数的真实地址替换IMAGE_THUNK_DATA 值。当文件装入时,PE装载器必定检查地址的有效性,如果DLL版本不同于PE文件存放的相关信息,或则DLLs需要重定位,那么装载器认为原先计算的地址是无效的,它必定遍历OriginalFirstThunk指向的数组以获取导入函数新地址。

七、Export Table(导出表)

当PE装载器执行一个程序,它将相关DLLs都装入该进程的地址空间。然后根据主程序的导入函数信息,查找相关DLLs中的真实函数地址来修正主程序。PE装载器搜寻的是DLLs中的导出函数。PE 程序把它的导出函数相关信息放在.edata 中。

DLL/EXE要导出一个函数给其他DLL/EXE使用,有两种实现方法: 通过函数名导出或者仅仅通过序数导出。比如某个DLL要导出名为"GetSysConfig"的函数,如果它以函数名导出,那么其他DLLs/EXEs若要调用这个函数,必须通过函数名,就是GetSysConfig。另外一个办法就是通过序数导出。序数是唯一指定DLL中某个函数的16位数字,在所指向的DLL里是独一无二的。例如在上例中,DLL可以选择通过序数导出,假设是16,那么其他DLLs/EXEs若要调用这个函数必须以该值作为GetProcAddress调用参数。这就是所谓的仅仅靠序数导出。

7.1 导出表是数据目录的第一个成员,又可称为 IMAGE_EXPORT_DIRECTORY。结构定义:

typedef struct _IMAGE_EXPORT_DIRECTORY {
DWORD Characteristics;
DWORD TimeDateStamp;
WORD MajorVersion;
WORD MinorVersion;
DWORD Name;
DWORD Base;
DWORD NumberOfFunctions;
DWORD NumberOfNames;
DWORD AddressOfFunctions; // RVA from base of image
DWORD AddressOfNames; // RVA from base of image
DWORD AddressOfNameOrdinals; // RVA from base of image
} IMAGE_EXPORT_DIRECTORY, *PIMAGE_EXPORT_DIRECTORY;

IMAGE_EXPORT_DIRECTORY 结构成员含义:

1.Characteristics:此域没有用途,总是为0。

2.TimeDateStamp:程序被生成的时刻。

3.MajorVersion/MinorVersion:无实际用途,0。

4.Name:一个 RVA 值,指向一个 ASCIIZ 字串(dll 名称,如MYDLL.dll)。模块的真实名称。本域是必须的,因为文件名可能会改变。这种情况下,PE装载器将使用这个内部名字。

3.Base:基数,加上序数就是函数地址数组的索引值了。

4.NumberOfFunctions:模块导出的函数/符号总数。

5.NumberOfNames:通过名字导出的函数/符号数目。该值不是模块导出的函数/符号总数,这是由上面的NumberOfFunctions给出。本域可以为0,表示模块可能仅仅通过序数导出。如果模块根本不导出任何函数/符号,那么数据目录中导出表的RVA为0。

6.AddressOfFunctions:模块中有一个指向所有函数/符号的RVAs数组,本域就是指向该RVAs数组的RVA。简言之,模块中所有函数的RVAs都保存在一个数组里,本域就指向这个数组的首地址。

7.AddressOfNames:类似上个域,模块中有一个指向所有函数名的RVAs数组,本域就是指向该RVAs数组的RVA。

9.AddressOfNameOrdinals:RVA,指向包含上述 AddressOfNames数组中相关函数之序数的16位数组。

导出表的设计是为了方便PE装载器工作。

首先,模块必须保存所有导出函数的地址以供PE装载器查询。模块将这些信息保存在AddressOfFunctions域指向的数组中,而数组元素数目存放在NumberOfFunctions域中。 因此,如果模块导出40个函数,则AddressOfFunctions指向的数组必定有40个元素,而NumberOfFunctions值为40。

现在如果有一些函数是通过名字导出的,那么模块必定也在文件中保留了这些信息。这些名字的RVAs存放在一数组中以供PE装载器查询。该数组由AddressOfNames指向,NumberOfNames包含名字数目。考虑一下PE装载器的工作机制,它知道函数名,并想以此获取这些函数的地址。至今为止,模块已有两个模块: 名字数组和地址数组,但两者之间还没有联系的纽带。因此我们还需要一些联系函数名及其地址的东东。PE参考指出使用到地址数组的索引作为联接,因此PE装载器在名字数组中找到匹配名字的同时,它也获取了指向地址表中对应元素的索引。而这些索引保存在由AddressOfNameOrdinals域指向的另一个数组(最后一个)中。由于该数组是起了联系名字和地址的作用,所以其元素数目必定和名字数组相同,比如,每个名字有且仅有一个相关地址,反过来则不一定: 每个地址可以有好几个名字来对应。因此我们给同一个地址取"别名"。为了起到连接作用,名字数组和索引数组必须并行地成对使用,譬如,索引数组的第一个元素必定含有第一个名字的索引,以此类推。

7.2 如果我们有了导出函数名并想以此获取地址,可以这么做:

1、定位到PE Header。
2、从数据目录读取导出表的虚拟地址。
3、定位导出表获取名字数目(NumberOfNames)。
4、并行遍历AddressOfNames和AddressOfNameOrdinals指向的数组匹配名字。如果在AddressOfNames 指向的数组中找到匹配名字,从AddressOfNameOrdinals 指向的数组中提取索引值。例如,若发现匹配名字的RVA存放在AddressOfNames 数组的第77个元素,那就提取AddressOfNameOrdinals数组的第77个元素作为索引值。如果遍历完NumberOfNames 个元素,说明当前模块没有所要的名字。
5、从AddressOfNameOrdinals 数组提取的数值作为AddressOfFunctions 数组的索引。也就是说,如果值是5,就必须读取AddressOfFunctions 数组的第5个元素,此值就是所要函数的RVA。

7.3 假设我们只有函数的序数,那么怎样获取函数地址呢,可以这么做:

1、定位到PE Header。
2、从数据目录读取导出表的虚拟地址。
3、定位导出表获取nBase值。
4、减掉nBase值得到指向AddressOfFunctions 数组的索引。
5、将该值与NumberOfFunctions作比较,大于等于后者则序数无效。
6、通过上面的索引就可以获取AddressOfFunctions 数组中的RVA了。

(按:另外,JIURL PE 格式学习总结四篇也颇可一观,尤其是第四篇。限于篇幅,不引了)
http://jiurl.nease.net/document/jiurlpe/jiurlpe1.htm
http://jiurl.nease.net/document/jiurlpe/jiurlpe2.htm
http://jiurl.nease.net/document/jiurlpe/jiurlpe3.htm
http://jiurl.nease.net/document/jiurlpe/jiurlpe4.htm

by redleaves
from http://dev.csdn.net/Develop/article/21/21076.shtm
http://dev.csdn.net/Develop/article/21/21543.shtm

COFF – 通用对象文件格式(Common Object File Format),是一种很流行的对象文件格式(注意:这里不说它是“目标”文件,是为了和编译器产生的目标文件(*.o/*.obj)相区别,因为这种格式不只用于目标文件,库文件、可执行文件也经常是这种格式)。大家可能会经常使用VC吧?它所产生的目标文件(*.obj)就是这种格式。其它的编译器,如GCC(GNU Compiler Collection)、ICL(Intel C/C++ Compiler)、VectorC,也使用这种格式的目标文件。不仅仅是C/C++,很多其它语言也使用这种格式的对象文件。统一格式的目标文件为混合语言编程带来了极大的方便。
当然,并不是只有这一种对象文件格式。常用格式的还有OMF-对象模型文件(Object Module File)以及ELF-可执行及连接文件格式(Executable and Linking Format)。OMF是一大群IT巨头在n年制定的一种格式,在Windows平台上很常见。大家喜欢的Borland公司现在使用的目标文件就是这种格式。MS和Intel在n年前用的也是这种格式,现在都改投异侧,用COFF格式了。ELF格式在非Windows平台上使用得比较多,在Windows平台基本上没见过。做为程序员,很有必要认识一下这些你经常打交道的家伙!不过这次让我介绍COFF先!

COFF的文件结构
让我们先来看一下COFF文件的整体结构,看看它到底长得什么样!
File Header

Optional Header

Section Header 1

......

Section Header n

Section Data

Relocation Directives

Line Numbers

Symbol Table

String Table

如左图:
COFF文件一共有8种数据,自上而下分别为:
1. 文件头(File Header)
2. 可选头(Optional Header)
3. 段落头(Section Header)
4. 段落数据(Section Data)
5. 重定位表(Relocation Directives)
6. 行号表(Line Numbers)
7. 符号表(Symbol Table)
8. 字符串表(String Table)

其中,除了段落头可以有多个节(因为可以有多个段落)以外,其它的所有类型的节最多只能有一个。
文件头:顾名思义,它就是COFF文件的头,它用来保存COFF文件的基本信息,如文件标识,各个表的位置等等。
可选头:再顾名思义,它也是一个头,还是可选的,而且可有可无。在目标文件中,基本上都没有这个头;但在其它的文件中(如:可执行文件)这个段用来保存在文件头中没有描述到的信息。
段落头:又顾……(不顾了,再顾有人要打我了J),这个头(怎么这么多的头啊?!)是用来描述段落信息的,每个段落都有一个段落头来描述。段落的数目在文件头中会指出。
段落数据:这通常是COFF文件中最大的数据段,每个段落真正的数据就保存在这个位置。至于怎么区分这些数据是哪个段落的,不要问我,去问段落头。
重定位表:这个表通常只存在于目标文件中,它用来描述COFF文件中符号的重定位信息。至于为什么要重定位,请回家看看你的操作系统的书籍。
符号表:这个表用来保存COFF文件中所用到的所有符号的信息,连接多个COFF文件时,这个表帮助我们重定位符号。调试程序时也要用到它。
字符串表:不用我说,大家也知道它用来保存字符串的。可是字符串保存给谁看呢?不知道了吧!?问我啊!J符号表是以记录的形式来描述符号信息的,但它只为符号名称留置了8个字符的空间,早期的小程序还将就能行,可在现在的程序中,一个符号名动不动就数十个字符,8个字符怎么能够?没办法,只好把这些名称存在字符串表中。而符号表中只记录这些字符串的位置。
文件的结构大体上就是这样了。长得是丑了点,不过还算它的设计者有点远见。可扩充性设计得不错,以致于沿用至今。了解了文件的整体结构,现在让我们来逐个段落分析它。

文件头
文件头,自然是从文件的0偏移处开始,它的结构很简单。用C的结构描述如下:
typedef struct {
unsigned short usMagic; // 魔法数字
unsigned short usNumSec; // 段落(Section)数
unsigned long ulTime; // 时间戳
unsigned long ulSymbolOffset; // 符号表偏移
unsigned long ulNumSymbol; // 符号数
unsigned short usOptHdrSZ; // 可选头长度
unsigned short usFlags; // 文件标记
} FILEHDR;
结构中usMagic成员是一个魔法数字(Magic Number),在I386平台上的COFF文件中它的值为0x014c。如果COFF文件头中魔法数字不为0x014c,那就不用看了,这不是一个I386平台的COFF文件。其实这就是一个平台标识。
第二个成员usNumSec是一个无符号短整型,它用来描述段落的数量。段落头(Section Header)的数目就是它。
ulTime成员是一个时间戳,它用来描述COFF文件的建立时间。当COFF文件为一个可执行文件时,这个时间戳经常用来当做一个加密用的比对标识。
ulSymbolOffset是符号表在文件中的偏移量,这是一个绝对偏移量,要从文件头开始计数。在COFF文件的其它节中,也存在这种偏移量,它们都是绝对偏移量。
ulNumSymbol成员给出了符号表中符号记录的数量。
usOptHdrSZ是可选头的长度,通常它为0。而可选头的类型也是从这个长度得知的,针对不同的长度,我们就要选择不同的处理方式。
usFlag是COFF文件的属性标记,它标识了COFF文件的类型,COFF文件中所保存的数据等等信息。
其值如下:

值 名称 说明
0x0001 F_RELF插L入G 无重定位信息标记。这个标记指出COFF文件中没有重定位信息。通常在目标文件中这个标记们为0,在可执行文件中为1。
0x0002 F_EXEC 可执行标记。这个标记指出 COFF 文件中所有符号已经解析, COFF 文件应该被认为是可执行文件。
0x0004 F_LNNO 文件中所有行号已经被去掉。
0x0008 F_LSYMS 文件中的符号信息已经被去掉。
0x0100 F_AR32WR 些标记指出文件是 32 位的 Little-Endian COFF 文件。

注:Little-Endian,记不得它的中文名称了。它是指数据的排列方式。比如:十六进制的0x1234以Little-Endian方式在内存中的顺序为0x34 0x12。与之相反的是Big-Endian,这种方式下,在内存中的顺序是0x12 0x34。
这个表的内容并不全面,但在目标文件中,常用的也就只有这些。其它的标记我将在以后介绍PE格式时给出。
可选头
可选头接在文件头的后面,也就是从COFF文件的0x0014偏移处开始。长度可以为0。不同长度的可选头,其结构也不同。标准的可选头长度为24或28字节,通常是28啦。这里我就只介绍长度为28的可选头。(因为这种头的长度是自定义的,不同的人定义的结果就不一样,我只能选一种最常用的头来介绍,别的我也不知道)
这种头的结构如下:
typedef struct {
unsigned short usMagic; // 魔法数字
unsigned short usVersion; // 版本标识
unsigned long ulTextSize; // 正文(text)段大小
unsigned long ulInitDataSZ; // 已初始化数据段大小
unsigned long ulUninitDataSZ; // 未初始化数据段大小
unsigned long ulEntry; // 入口点
unsigned long ulTextBase; // 正文段基址
unsigned long ulDataBase; // 数据段基址(在PE32中才有)
} OPTHDR;
第一个成员usMagic还是魔法数字,不过这回它的值应该为0x010b或0x0107。当值为0x010b时,说明COFF文件是一个一般的可执行文件;当值为,0x0107时,COFF则为一个ROM镜像文件。
usVersion是COFF文件的版本,ulTextSize是这个可执行COFF的正文段长度,ulInitDataSZ和ulUninitDataSZ分别为已初始化数据段和未初始化数据段的长度。
ulEntry是程序的入口点,也就是COFF载入内存时正文段的位置(EIP寄存器的值),当COFF文件是一个动态库时,入口点也就是动态库的入口函数。
ulTextBase是正文段的基址。
ulDataBase是数据段基址。
其实在这些成员中,只要注意usMagic和ulEntry就可以了。


段落头
段落头紧跟在可选头的后面(如果可选头的长度为0,那么它就是紧跟在文件头后)。它的长度为36个字节,如下:
typedef struct {
char cName[8]; // 段名
unsigned long ulVSize; // 虚拟大小
unsigned long ulVAddr; // 虚拟地址
unsigned long ulSize; // 段长度
unsigned long ulSecOffset; // 段数据偏移
unsigned long ulRelOffset; // 段重定位表偏移
unsigned long ulLNOffset; // 行号表偏移
unsigned short ulNumRel; // 重定位表长度
unsigned short ulNumLN; // 行号表长度
unsigned long ulFlags; // 段标识
} SECHDR;
这个头可是个重要的头头,我们要用到的最终信息就由它来描述。一个COFF文件可以不要其它的节,但文件头和段落头这两节是必不可少的。
cName用来保存段名,常用的段名有.text,.data,.comment,.bss等。.text段是正文段,通常也就是代码段;.data是数据段,在这个数据段中所保存的数据是初始化过的数据;.bss段也可以用来保存数据,不过这里的数据是未初始化的,这个段也是一个空段;.comment段,看名字也知道,它是注释段,用来保存一些编译信息,算是对COFF文件的注释。
ulVSize是段数据载入内存时的大小。只在可执行文件中有效,在目标文件中总为0。如果它的长度大于段的实际长度,则多的部分将用0来填充。
ulVAddr是段数据载入或连接时的虚拟地址。对于可执行文件来说,这个地址是相对于它的地址空间而言。当可执行文件被载入内存时,这个地址就是段中数据的第一个字节的位置。而对于目标文件而言,这只是重定位时,段数据当前位置的一个偏移量。为了计算方便,便定位的计算简化,它通常设为0。
ulSize这才是段中数据的实际长度,也就是段数据的长度,在读取段数据时就由它来确定要读多少字节。
ulSecOffset是段数据在COFF文件中的偏移量。
ulRelOffset是该段的重定位信息的偏移量。它指向了重定位表的一个记录。
ulLNOffset是该段的行号表的偏移量。它指向的是行号表中的一个记录。
ulNumRel是重定位信息的记录数。从ulRelOffset指向的记录开始,到第ulNumRel个记录为止,都是该段的重定位信息。
ulNumLN和ulNumRel相似。不过它是行号信息的记录数。
ulFlags是该段的属性标识。其值如下表:
值 名称 说明
0x0020 STYP_TEXT 正文段标识,说明该段是代码。
0x0040 STYP_DATA 数据段标识,有些标识的段将用来保存已初始化数据。
0x0080 STYP_BSS 有这个标识段也是用来保存数据,不过这里的数据是未初始化数据。
注意,在BSS段中,ulVSize、ulVAddr、ulSize、ulSecOffset、ulRelOffset、ulLNOffset、ulNumRel、ulNumLN的值都为0。(上表只是部分值,其它值在PE格式中介绍,后同)


段数据
“人”如其名,这里是保存各个段的数据的位置。不同类型的段,数据的内容、结构也不尽相同。但在目标文件中,这些数据都是原始数据(Raw Data)。不存在什么特别的格式。

重定位表
这个表所保存的是各个段的重定位信息。这是一张很大的表,因为所有段的重定位信息都在这个表里。各个段落头记录了自己的重定位信息的偏移和数量。要用到重定位信息时就到这个表里来读。当然,你也可以把整个重定位表看成多个重定位表,每个段落都有一个自己的重定位表。这个表只在目标文件中有,可执行文件中是不存在这个表的。
既然有表,那么就会有记录。重定位表中的每一条记录就是一条重定位信息。这个记录的结构很简单,如下:
typedef struct {
unsigned long ulAddr; // 定位偏移
unsigned long ulSymbol; // 符号
unsigned short usType; // 定位类型
} RELOC;
有够简单吧,一共只三个成员!ulAddr是要定位的内容在段内偏移。比如:一个正文段,起始位置为0x010,ulAddr的值为0x05,那你的定位信息就要写在0x15处。而且信息的长度要看你的代码的类型,32位的代码要写4个字节,16位的就只要字2个字节。
ulSymbol是符号索引,它指向符号表中的一个记录。注意,这里是索引,不是偏移!它只是符号表中的一个记录的记录号。这个成员指明了重定位信息所对映的符号。
usType是重定位类型的标识。32位代码中,通常只用两种定位方式。一是绝对定位,二是相对定位。其代码如下:
值 名称 说明
6 RELOC_ADDR32 32位绝对定位。
20 RELOC_REL32 32位相对定位。
对于不同的处理器,这些值也不尽相同。这里给出的是i386平台上最常用的两个种定位方式的标识。
其定位方式如下:
绝对定位
在绝对定位方式下,你要给出符号的绝对地址(注意,有时候这里可能不是地址,而是值,对于常量来说,你不用给出它的地值,只用给出它的值)。当然,这个地址也不是现成的,你要用符号的相对地址+它所在段的相对地址来得到它的绝对地址。
公式:符号绝对地址=段偏移+符号偏移
这些偏移量你要分别从段落头和符号表中得到。当段落要重定位时,当然还要先重定位段落,才能定位其中的符号。
相对定位
相对定位要复杂一些。它所要的地址信息是相对于当前位置的偏移,这个当前位置就是ulAddr所指向的这个偏移的绝对地址后四个字节(32位代码是四个字节,16位是两个字节)的位置。也就是用定位偏移+当前段偏移+机器字长÷8
公式:当前地址=定位偏移+当前段偏移+机器字长÷8
有了当前地址,相对地址就好计算了。只要用符号的绝对地址减去当前地址就可以了。
公式:相对地址=符号绝对地址-当前地址
计算好了地址,把它写到ulAddr所指向的位置,就一切OK!你已经完成了重定位的工作了。

行号表
行号表在调试时很有用。它把可执行的二进制代码与源代码的行号之间建立了对映关系。这样,当程序执行不正确时(其实正确的也可以J),我们就可以根据当前执行代码的位置得知出错源代码的行号,再加以修改。如果没有它的话,鬼才知道是哪一行出了毛病!
它的格式也很简单。只有两个成员,如下:
typedef struct {
unsigned long ulAddrORSymbol; // 代码地址或符号索引
unsigned short usLineNo; // 行号
} LINENO;
让我们先看第二个成员,usLineNo。这是一个从1开始计数的计数器,它代表源代码的行号。第一个成员ulAddrORSymbol在行号大于0时,代表源代码的地址;而当行号为0时,它就成了行号所对映的符号在符号表中的索引。下面让我们来看看符号表吧!

符号表
符号表是对象文件中用来保存符号信息的一张表,也是COFF文件中最为复杂的一张表。所有段落使用到的符号都在这个表里。它也是由很多条记录组成,每条记录都以如下结构保存:
typedef struct {
union {
char cName[8]; // 符号名称
struct {
unsigned long ulZero; // 字符串表标识
unsigned long ulOffset; // 字符串偏移
} e;
} e;
unsigned long ulValue; // 符号值
short iSection; // 符号所在段
unsigned short usType; // 符号类型
unsigned char usClass; // 符号存储类型
unsigned char usNumAux; // 符号附加记录数
} SYMENT;
cName符号名称,和前面所有的名称一样,它也是8个字节,但不同的是它在一个联合体中。和它占相同的存储空间的还有ulZero和ulOffset这两个成员。如果符号的名称只有8个字符,那很好,可以直接放到这个cName中;可是,如果名称的长度大于8个字节,这里就放不下了,只好放到字符串表中。这时候,ulZero的值就会为0,而在ulOffset中会给出我们所用的符号的名称在字符串表中的偏移。
一个符号有了名称不够,它还要有值!ulValue就是这个符号所代表的值。
iSection成员指出了这个符号所在的段落。如果它的值为0,那么这个符号就是一个外部符号,要从其它的COFF文件中解析(连接多个目标文件就是要解析这种符号)。当它的值为-1时,说明这个符号的值是一个常量,不是它在段落中的偏移。而当它的值为-2时,这个符号只是一个调试符号,只有在调试时才会用到它。当它大于0时,才是符号所在段的索引值。
usType是符号的类型标识。它用来说明这个符号的类型,是函数?整型?还是其它什么。这个标识是两个字节。
低字节的低四位是基本标识,它指出了符号的基本类型,如整型,字符,结构,联合等。高四位指出了符号的高级类型,如指针(0001b),函数(0010b),数组(0011b),无类型(0000b)等。现在的编译器,通常不使用基本类型,只使用高级类型。所以,符号的基本类型通常被设为0。
高字节通常未用。
usClass是符号的存储类型标识。它指明了符号的存储方式。
其值与意义见下表:
值 名称 说明
NULL 0 无存储类型。
AUTOMATIC 1 自动类型。通常是在栈中分配的变量。
EXTERNAL 2 外部符号。当为外部符号时,iSection的值应该为0,如果不为0,则ulValue为符号在段中的偏移。
STATIC 3 静态类型。ulValue为符号在段中的偏移。如果偏移为0,那么这个符号代表段名。
REGISTER 4 寄存器变量。
MEMBER_OF_STRUCT 8 结构成员。ulValue值为该符号在结构中的顺序。
STRUCT_TAG 10 结构标识符。
MEMBER_OF_UNION 11 联合成员。ulValue值为该符号在联合中的顺序。
UNION_TAG 12 联合标识符。
TYPE_DEFINITION 13 类型定义。
FUNCTION 101 函数名。
FILE 102 文件名。

最后一个成员usNumAux是附加记录的数量。附加记录是用来描述符号的一些附加信息,为了便于保存,这些附加记录通常选择成为一条符号信息记录的整数倍(多数为1)。所以,如果这个成员的值为1,那么就表示在当前符号信息记录后附加了一条记录,用来保存附加信息。
附加信息的结构是与符号的类型以及存储类型相关的。不同的类型的符号,其附加信息(如果有的话)的结构也不同。如果你不在意这些内容,也可以把它们乎略。
当段的类型为FILE时,附加信息就是一个字符串,它是目标文件对应源文件的名称。其它类型在介绍PE时再进行详细讨论。

字符串表
不用多说,瞎子也能看出这个表是用来保存字符串的。它紧接在符号表后。至于为什么要保存字符串,前面已经说过了。这里就不再多说了,只说说字符串的保存格式。
字符串表是所有节中最简单一节。如下图:
0 4
字符串表长度 字符串1\0
.... 字符串n\0


字符串表的前四个字节是字符串表的长度,以字节为单位。其后就是以0结尾的字符串(C风格字符串)。要注意的是,字符串表的长度不仅仅是字符串的长度(这个长度要包括每个字符串后的‘\0’)的总合,它还包括这个长度域的四个字节。符号表中ulOffset成员所指出的偏移就是从字符串表起始处的偏移。比如:指像每一个字符串的符号,ulOffset的值总为4。
下面给出的代码,是从字符串表中读取字符串的典型C代码。

int iStrlen,iCur=4; // iStrLen是字符串表的长度,iCur是当前字符串偏移
char *str; // 字符串表
read(fn, &iStrlen, 4); // 得到字符串表长度
str = (char *)malloc(iStrlen); // 为字符串表分配空间
while (iCur< iStrlen ) // 读字符串表,直到全部读入内存
iCur+=read(fn, str+iCur, iStrlen- iCur);
iCur=4; // 把当前字符串偏移指到每一个字符串
while (iCur< iStrlen ) { // 显示每一个字符串
printf("String offset 0x%04X : %s\n", iCur, str + iCur);
iCur+=(strlen(str+iCur)+1); // 计算偏移时不要忘了计算‘\0’字符所占的1个字节!
}
free(str); // 释放字符串表空间

直到这里,整个COFF的结构已经全部介绍完了。很多了解PE格式的朋友一定会奇怪,好像少了很多内容!?是的,标准的COFF文件只有这么多的东西。但MS为了和DOS的可执行文件兼容,以及对可执行文件功能的扩展,在COFF格式中加了很多它自己的标准。让我差点就认不出COFF了。但了解了COFF文件以后,再来学习PE文件的格式,那就很简单了。
现在大家可以自己动手,写一个COFF文件解析器或是一个简单的连接程序了!但是如果你试着做一个应用程序的连接器(Linker),就会发现,仅仅有目标文件是不够的。我们在连接程序时,不仅仅要用到目标文件,库文件也是必不可少的。
库文件是怎么样的结构呢?
其实,库文件的结构也很简单。它就是“一堆”目标文件的集合。把目标文件做成库以后,我们在使用目标文件中所实现的功能时,连接程序会自动在库文件里查找相应的目标文件,并使用它。这大大减少了我们对目标文件的管理工作,减轻了代码重用的负担。
Lib文件中的节
COFF格式中所用到的“节”的概念再次出现在Lib格式中。不过,Lib文件的节要简单得多。先让我们来看看它的整体结构:

如右图所示:
Lib格式只有四种类型的节(Section),即First Sec,Second Sec,Longname Sec和Obj Sec;其中Second Sec与Longname Sec是可选节,很多Lib文件中都没有。而开头的Singature只是一个标识,它相当于COFF目标文件中的魔法数字。它是一个长度为8的字符串,值为“!\n”。
First Sec,顾名思义,就是第一个节。它包含了库中所有的符号名以及这些符号所在的目标文件在库中的位置(绝对偏移)。
Second Sec就是第二节。它的内容和First Sec是相同的。不同的是,Second Sec是一个有序表,通过它来查找库中的符号比通过First Sec来查找要快很多。 Signature
First Sec
Second Sec
Longname Sec
Obj Sec1
Obj Sec2
……

Longname Sec是长名称节。这一节是一个字符串表。它包含了所有长目标文件名。如果后面的Obj Sec中没有给出相应的目标文件名,我们就要到这一节中来查找。
Obj Sec就是目标文件节。这些节中存储着不同的目标文件的原始数据。

在库文件中,每一节都有两个部分。一个部分是头,另一个部分才是该节的数据;数据紧跟在头的后面。头描述了该节数据的类型、长度等信息。这些头的格式都是相同的。其结构用C语言描述如下:
typedef struct {
char Name[16]; // 名称
char Time[12]; // 时间
char UserID[6]; // 用户ID
char GroupID[6]; // 组ID
char Mode[8]; // 模式
char Size[10]; // 长度
char EndOfHeader[2];// 结束符
} SectionHeader;
可以看到,头中的数据全都是字符串。用字符串的好处是可以提高格式的兼容性,因为在不同的机器上,数据的排列方式是不同的。有的机器是以Little-Endian方式工作,还有的是以Big-Endian方式工作,它们互不兼容(这两种方式的区别!?请看我的《COFF格式》一文,其中的文件头一节有说明)。用字符串就不会有这种问题(后面我们将会遇到)。但它也有不方便的地方,就是必须把字符串转换成数值,多了一个步骤。
在这个结构中,最常用的Name、Size以及EndOfHeader三个成员。Name就是节的名称啦!Size也很好理解,就是该节数据的长度。现在要注意的就是这个EndOfHeader成员了!这个成员标志着头的结束,其内容为“`\n”(注意,这里没有打错,是两个字符“`”和“\n”)。怎么样?有点奇怪吧?为什么要有这个结束符?每一节的头长度一定,每节中的数据长度也知道。按顺序向下读不行吗?答案是:不行!因为每一节之间存在间隙!通常是一个字节或零个字节。如果是零个字节倒好,按顺序向下读是OK的。可是如果不为零的话,这样读就要错位了。要知道错位没有,只好用一个结束符来定位了。如果在读头的时候发现结束符不对,那就要一个字节一个字节地向下查找,直到找到结束符,才能算是对齐了。切记!切记!
当然,通过First Sec或Second Sec中给出的偏移来读数据就不存在这个问题。不会发生错位,放心读吧!
现在让我们来看看每一节中的数据是什么样子。

First Sec
第一节,通常就是Lib中的每一个小节。它的名称是“/”。其数据部分的结构如下:
typedef struct {
unsigned long SymbolNum; // 库中符号的数量
unsigned long SymbolOffset[n]; // 符号所在目标节的偏移
char StrTable[m]; // 符号名称字符串表
}FirstSec;
第一个成员SymbolNum是符号的数量。注意!它是以Big-Endian方式储存的(x86平台上的数据是以Little-Endian方式储存的。这里应该注意转换。后面给出的convert函数可以在Little-Endian格式与Big-Endian格式之间进行相互转换)。
第二个成员SymbolOffset是一个数组,它的长度n就是符号的数量,也就是SymbolNum。这个数组储存了每一个符号所在的目标节的偏移。我们可以方便地通过它来查找符号所在的目标文件。注意!它也是以Big-Endian格式储存的。
第三个成员StrTable是一个字符串表,它的长度m就是SectionHeader.Size的值减去(SymbolNum+1)*4。其结构很简单,就是一堆以‘\0’结尾的字符串(和COFF文件中的字符串表结构相同)。在有的系统中,它还可能是以“/\n”这两个字符结尾的字符串的集合。
很简单的一个结构,不过有两个成员的长度是不定的。怎么才能方便地从Lib中读出这些数据,留给大家自己想吧!下面我只给出一个进行Little-Endian与Big-Endian互转的函数。
inline void convert(void * p // 要转换的数据的指针
,size_t size = 4 // 数据的长度,long为4,short为2
) {
char * buf=(char*)p;
char temp;
for ( size_t i=0;i temp=buf[i];
buf[i]=buf[size-i-1];
buf[size-i-1]=temp;
}
}

Second Sec
现在看看第二节。
这一节与第一节很相似!它通常也就是Lib文件的第二个节。它的名字也是“/”(注意:文件中第一个叫“/”的节是第一节,第二个就是第二节)。不过它的结构与第一节有些不同,如下:
typedef struct {
unsigned long ObjNum; // Obj Sec的数量
unsigned long ObjOffset[x]; // 每一个Obj Sec的偏移
unsigned long SymbolNum; // 库中符号的数量
unsigned short SymbolIdx[n]; // 符号在ObjOffset表中的索引
char StrTable[m]; // 符号名称字符串表
}SecondSec;
第一个成员ObjNum是库中Obj Sec的数量。
第二个成员ObjOffset是一个偏移表,它记录了库中所有Obj Sec的偏移。这个表的记录数x就是ObjNum。
第三个成员SymbolNum与First Sec中的SymbolNum意义相同。
第四个成员SymbolIdx变成了一个索引,它记录了相应名称字符串在ObjOffset这个表中的位置,我们要通过两次索引才能找到我们所要符号的Obj Sec位置。它的项目数n为SymbolNum。但请注意,这个索引是unsigned short型,不再是unsigned long型。
第五个成员StrTable结构与First Sec中的一样。不过,它的长度m为SectionHeader.Size的值减去((ObjNum+1)*4+(SymbolNum+2)*2)。
值得注意的是,这里的所有数据都是Little-Endian格式的。千万不要弄错了!

Longname Sec
这个小节就是一个字符串表,它的名称为“//”,其结构同FirstSec.StrTable。这里就不多说了。

Obj Sec
这一节中的数据就是COFF文件的原始数据,把它读出来存成文件,就是一个COFF文件。它的格式请参考《COFF格式》一文。
要指出的是它的命名方式有些特殊。如果Obj文件的名称少于16个字符,它就会被保存在SectionHeader的Name成员中,以‘/’字符结尾。如果无法保存在Name成员中,则Name成员的第一个字符就为‘/’,之后再跟上这个名称在Longname Sec中的偏移。

例如:
!\n
……
LongName Sec:
This_Is_Long_Name0001\0
This_Is_Long_Name0002\0
……
Obj Sec1:
Name[16]:“shortname/”
……
Obj Sec2:
Name[16]:“/0” // 这里使用了第一个长文件名This_Is_Long_Name0001
……
Obj Sec3:
Name[16]:“/22” // 这里使用了第二个长文件名This_Is_Long_Name0002
……

OK!现在已经介绍完了Lib文件的结构。大家的连接器可以加新功能了。不过这里只给出了最基本的Lib文件结构,动态连接库(DLL)的导出库有点特别,我将在PE文件格式中进行详细介绍。



<< Home

This page is powered by Blogger. Isn't yours?