1.概述(general)
-------
所有的节在载入内存后都按“SectionAlignment”(节对齐)对齐,在文件中则以“FileAlignment”(文件对齐)对齐。节由节头中的相关项来描述:在文件中你可通过“PointerToRawData”(原始数据指针)来找到,在内存中你可通过“VirtualAddress”(虚拟地址)来找到;长度由“SizeOfRawData”(原始数据长度)决定。
根据节中包含的内容,可分为好几种节。大多数(并非所有)情况下,节中至少由一个数据目录,并在可选头的数据目录数组中有一个指针指向它。
2.代码节(code section)
------------------------
首先,我将提到代码节。此节,至少,要将“IMAGE_SCN_CNT_CODE”(代码内容节)、“IMAGE_SCN_MEM_EXECUTE”(内存可执行节)和“IMAGE_SCN_MEM_READ”(内存可读节)等标志位设为1,并且“AddressOfEntryPoint”(入口点地址)将指向节中的某个地方,指向开发者希望首先执行的那个函数的开始处。
“BaseOfCode”(代码基址)通常指向这一节的开始处,但是,如果一些非代码字节被放在代码之前的话,它也可能指向节中靠后的某个地方。
通常,除了可执行代码外,本节没有别的东东,并且通常只有一个代码节,但是不要太迷信这一点。
典型的节名有“.text”、“.code”、“AUTO”之类。
3.数据节(data section)
------------------------
我们要讨论的下一件事情就是已初始化变量;本节包含的是已初始化的静态变量(象“static int i = 5;”)。它将,至少,使“IMAGE_SCN_CNT_INITIALIZED_DATA”(已初始化数据内容节)、“IMAGE_SCN_MEM_READ”(内存可读节)和“IMAGE_SCN_MEM_WRITE”(内存可写节)等标志位被置为1。
一些链接器可能会将常量放在没有可写标志位的它们自己的节中。如果有一部分数据可共享,或者有其它的特定情况,那么可能会有更多的节,且它们的合适的标志位会被设置。
不管是一节,还是多节,它们都将处于从“BaseOfData”(数据基址)到“BaseOfData”+“SizeOfInitializedData”(数据基址+已初始化数据的大小)的范围之内。
典型的名称有“.data”、“.idata”、“DATA”、等等。
4.BSS节(bss section)
----------------------
其后就是未初始化的数据(一些象“static int k;”之类的静态变量);本节十分象已初始化的数据,但它的“PointerToRawData”(文件偏移量)却为0,表明它的内容不存储在文件中;并且“IMAGE_SCN_CNT_UNINITIALIZED_DATA”(未初始化数据内容节)而不是“IMAGE_SCN_CNT_INITIALIZED_DATA”(已初始化数据内容节)标志位被置为1,表明在载入时它的内容应该被置为0。这就意味着,在文件中只有节头,没有节身;节身将由装载器创建,并全部为0字节。
它的长度由“SizeOfUninitializedData”(未初始化数据大小)确定。
典型的名称有“.bss”、“BSS”之类。
有些节数据“没有”被数据目录指向。它们的内容和结构是由编译器而不是链接器提供。
(栈段和堆段不是二进制文件中的节,它们是由装载器根据可选头中的栈大小和堆大小项来创建的。)
5.版权(copyright)
-------------------
为了从一个简单的目录节开始讲解,让我们来看一看数据目录“IMAGE_DIRECTORY_ENTRY_COPYRIGHT”(版权目录项)项。它的内容是一个版权信息或ASCII形式的描述字符串(不是以0结尾的),象“Gonkulator control application, copyright (c) 1848 Hugendubel & Cie”这样。这个字符串,通常,是用命令行或者描述文件提供给链接器的。
这个字符串在运行时并不需要,并可能被丢弃。它是不可写的;事实上,应用程序根本不需要存取它。因此,如果已有一个可丢弃的、非可写的节存在,链接器就会找到它;如果没有,就创建一个(命名为“.descr”之类)。然后它就将那个字符串填入该节中并让版权目录项指针指向这个字符串。“IMAGE_SCN_CNT_INITIALIZED_DATA”(已初始化数据内容节)标志位应置为1。
6.输出符号(exported symbols)
------------------------------
(注意:本文的1993年03月12日之前的各个版本中,输出目录的描述有误。文中没有描述中转、只以序数输出、或者使用好几个名称输出等内容。)
下一件最简单的事情是输出目录,是由“IMAGE_DIRECTORY_ENTRY_EXPORT”(输出目录项)指向的。它是一个典型的在DLL中常见到的目录;包含一些输出函数的入口点(以及输出对象等的地址)。当然可执行文件也可能拥有输出符号但一般没有。
包含它们的节应该有“已初始化数据的”和“可读的”特性。这样的节应该是不可丢弃的,因为在运行时,进程有可能调用“GetProcAddress()”来寻找一个函数的入口点。如果单独成节的话,本节通常被称作“.edata”;更常见的是,它被并入象“已初始化数据”之类的节中。
输出表(“IMAGE_EXPORT_DIRECTORY”)的结构由一个头和输出数据,也就是:符号名称、它们的序号和它们的入口点偏移量等构成。
1)首先,我们有一个没被使用并通常为0的、32位的“Characteristics”(特性)。
2)然后是一个32位的“TimeDateStamp”(时间日期戳),大概是提供此表被创建的time_t格式的时间;天呀,它的值并不总是有效(有些链接器将它设置为0)。
3-4)往后我们看到2个16位的、有关版本信息的word单元(“MajorVersion”和“MinorVersion”,含义分别为‘主版本’和‘最小版本’),同样,它们很多地被设为0。
5)下一个东东是32位的“Name”(名称);它是一个指向以0结尾的ASCII字符串为DLL名称的RVA。(为防DLL被改名时的错误,名称是必须的----参见输入目录中的“绑定”部分。)
6)然后是32位的“Base”(基址)。稍后我们再讨论。
7)下一个32位值是输出条目的总数(“NumberOfFunctions”,意为‘函数数’)。除了它们的序数外,各条目还可能使用一个或多个名称来输出。接下来的一个32位数字是输出名称的总数(“NumberOfNames”,意为‘名字数’)。
在大多数情况下,每一个输出条目都准确的有一个相应的名称,并且将用这个名称来使用它;但是一个条目可能拥有好几个相关联的名称(那样它们的每一个名称都可访问);或者它也可能没有名称,此时它只能以它的序数来访问。无名输出项(只有序数)的使用是不鼓励的,因为此时输出DLL的所有版本都必须使用相同的序数法,而这会造成维护的问题。
8)下一个32位值“AddressOfFunctions”(函数地址)是指向输出条目列表的RVA。它指向一个32位值的“NumberOfFunctions”(函数数)数组,数组的每一项都是一个指向输出函数或变量的RVA。
关于此列表有两个怪事:第一,这样一个输出的RVA竟可能会为0,在此情况下,此值没被使用。第二,如果一RVA指向含有输出目录的节,那么它就是一个中转输出。一个中转输出就是指指向另一个二进制文件中的输出项的指针;如果使用了它,就可用另一个二进制文件中的被指向的输出项来代替使用。此时的RVA指向,正如已提到的,输出目录的节中,指向一个以以零结尾的字符串组成的、被指向的DLL的名称和一个用点分开的输出项的名称,象“otherdll.exportname”这样,或者是DLL的名称和输出序数,象“otherdll.#19”这样。
现在到了解释输出序数的时候了。一个输出项的序数就是函数地址数组中的索引值加上上面提到的“Base”(基址)的值的和。在大多数情况下,“Base”(基址)的值为1,这就意味着第一个输出项的序数为1,第二个输出项的序数为2,以此类推。
9-10)“AddressOfFunctions”(函数地址)RVA之后,我们发现二个RVA,一个指向符号名称的32位RVA的数组“AddressOfNames”(名字的地址),另一个指向16位序数“AddressOfNameOrdinals”(名字序数的地址)的数组。两个数组都有“NumberOfNames”(名字数)个元素。
符号名称可能会全部丢失,此时“AddressOfNames”(名字的地址)为0;否则,被指向的数组并行运行,这意味着它们的每个索引中的元素共同拥有。“AddressOfNames”(名字的地址)数组由以0结尾的输出名称的RVA组成;这些名称以一个分类的列表排列(即:数组的第一个成员是按照字母顺序排列的最小的名称的RVA;这使当按名称查找一个输出符号时,搜索的效率更高。)
根据PE规范,“AddressOfNameOrdinals”(名字序数的地址)数组每个名称拥有一个相应的序数,然而,我发现这个数组却将实际的索引包含到“AddressOfFunctions”(函数地址)数组中去。
我将画一个有关这三个表的图:
函数地址
|
|
|
v
带序数‘基址’的输出RVA
带序数‘基址+1’的输出RVA
...
带序数‘基址+函数数-1’的输出RVA
名字地址 名字序数地址
| |
| |
| |
v v
第一个名字的RVA <-> 第一个名字的输出索引
第二个名字的RVA <-> 第二个名字的输出索引
... ...
第‘名字数’个名字的RVA <-> 第‘名字数’个名字的输出索引
举一些例子是适宜的。
为按序数找到一个输出符号,先减去“Base”(基址)值以得到索引值,再根据“AddressOfFunctions”(函数地址)的RVA得到输出项数组,并用索引值去找到数组中的输出RVA。如果结果没有指向输出节中,你就完了。否则,它就指向那里的一个描述输出DLL和(输出项)名称或序数的字符串,之后你就得在那里查找中转输出。
为按名称找到一个输出符号,先跟随“AddressOfNames”(名字的地址)的RVA(如果是0就没有名称)找到输出名称的RVA数组。在列表中搜寻你要找的名称。用该名称在“AddressOfNameOrdinals”(名字序数的地址)数组中的索引,得到和找到的名称相应的16位数字。根据PE规范,这是一个序数,你需先减去“Base”(基址)值以得到输出索引值;但依据我的经验,这就是输出索引值,你不需要再减了。使用输出索引值,你就能在“AddressOfFunctions”(函数地址)数组中找到输出RVA了,要么是输出RVA本身,要么是一个描述中转输出的字符串的RVA。
7.输入符号(imported symbols)
------------------------------
当编译器发现一个对别的可执行文件(大多数是DLL文件)中的函数调用时,在最简单化的情况下,它会对此情况一无所知,只是简单地输出一个对那个符号的正常调用指令。链接器不得不修正那个符号的地址,就象它为任何其它的外部符号所做的那样。
链接器使用一个输入库来查找从哪个DLL文件输入了哪个符号,并为所有的输入符号都建立存根,每个存根包含一个跳转指令;存根就是实际的调用目标。这些跳转指令实际上将跳往从所谓的输入地址表中提取的一个地址。在更复杂的应用程序(使用“__declspec(dllimport)”时)中,编译器会知道函数是输入的,并直接输出一个位于输入地址表中的地址的调用,绕过跳转。
不管怎样,DLL文件中的函数地址总是必要的,并将于应用程序载入时,由加载器从输出DLL文件的输出目录中提供。加载器知道哪个库中的哪些符号需要被查找以及哪些地址需要通过搜索输入目录来修正。
我最好给你一个例子。有或无__declspec(dllimport)的调用如下所示:
源文件:
int symbol(char *);
__declspec(dllimport) int symbol2(char*);
void foo(void)
{
int i=symbol("bar");
int j=symbol2("baz");
}
汇编:
...
call _symbol ; without declspec(dllimport)
...
call [__imp__symbol2] ; with declspec(dllimport)
...
在第一种(没有__declspec(dllimport))情况下,编译器不知道“_symbol”位于一个DLL文件中,因此链接器必须要提供“_symbol”函数。因为此函数不存在,它就为输入符号提供一个存根函数,即一个间接跳转。所有输入存根的集合被称为“转移区”(有时也叫做“跳板”,因为你跳到那里的目的是为了跳到别的地方)。
典型地,此转移区位于代码节中(它不是输入目录的一部分)。每一个函数存根都是一个跳往DLL文件中的实际函数的跳转。转移区的形式象这样:
_symbol: jmp [__imp__symbol]
_other_symbol: jmp [__imp__other__symbol]
...
这意味着:如果你不指定“__declspec(dllimport)”来使用输入符号,那么链接器将会为它们产生一个由间接跳转所组成的转移区。如果你真指定了“__declspec(dllimport)”,那么编译器就会自己做间接(跳转),转移区也就不需要了。(这也意味着:如果你输入的是变量或其它东西,你就必须指定“__declspec(dllimport)”,因为一个具有jmp指令的存根只合适于函数。)
不管怎样,符号“x”的地址都被存在“__imp_x”的存储单元。所有这样的存储单元一起形成所谓的“输入地址表”,此表是由被用到的各DLL文件中的输入库提供给链接器的。输入地址表就是由下面这种形式的一组地址组成的:
__imp__symbol: 0xdeadbeef
__imp__symbol2: 0x40100
__imp__symbol3: 0x300100
...
这个输入地址表是输入目录的一部分,并且被IMAGE_DIRECTORY_ENTRY_IAT(输入地址表目录项)目录指针所指向(尽管有些链接器不设置此目录项,程序也能运行;很明显地,这是因为加载器不使用IMAGE_DIRECTORY_ENTRY_IAT(输入地址表目录项)目录也能解决输入问题)。
这些地