程序编译
程序的编译过程如下图所示,分为预处理、编译、汇编、链接等几个阶段。
预处理:预处理相当于根据预处理命令组装成新的C程序,不过常以i为扩展名。
编译: 将得到的i文件翻译成汇编代码。s文件。
汇编: 将汇编文件翻译成机器指令,并打包成可重定位目标程序的O文件。该文件是二进制文件,字节编码是机器指令。
链接: 将引用的其他O文件并入到我们程序所在的o文件中,处理得到最终的可执行文件。
编译程序 把用高级书写的源程序,翻译成等价的或机器语言书写的的。
汇编程序
汇编代码:汇编语言编写的,机器不能直接识别,要由一种程序将汇编语言翻译成机器语言,这种起翻译作用的程序叫,汇编程序是中软件。
目标程序又称“目的程序”。
由编译程序将源程序编译成与之等价的由机器码构成的,计算机能直接运行的程序,该程序叫目标程序。
链接器 (linker) 将一个个的目标文件 ( 或许还会有若干程序库 ) 链接在一起生成一个完整的可执行文件。
在符号解析 (symbol resolution) 阶段,链接器按照所有目标文件和库文件出现在命令行中的顺序从左至右依次扫描它们,在此期间它要维护若干个集合 :
(1) 集合 E 是将被合并到一起组成可执行文件的所有目标文件集合;
(2) 集合 U 是未解析符号 (unresolved symbols ,比如已经被引用但是还未被定义的符号 ) 的集合;
(3) 集合 D 是所有之前已被加入到 E 的目标文件定义的符号集合。一开始, E 、 U 、 D 都是空的。
链接器的工作过程:
(1): 对命令行中的每一个输入文件 f ,链接器确定它是目标文件还是库文件,如果它是目标文件,就把 f 加入到 E ,并把 f 中未解析的符号和已定义的符号分别加入到 U 、 D 集合中,然后处理下一个输入文件。
(2): 如果 f 是一个库文件,链接器会尝试把 U 中的所有未解析符号与 f 中各目标模块定义的符号进行匹配。如果某个目标模块 m 定义了一个 U 中的未解析符号,那么就把 m 加入到 E 中,并把 m 中未解析的符号和已定义的符号分别加入到 U 、 D 集合中。不断地对 f 中的所有目标模块重复这个过程直至到达一个不动点 (fixed point) ,此时 U 和 D 不再变化。而那些未加入到 E 中的 f 里的目标模块就被简单地丢弃,链接器继续处理下一输入文件。
(3): 如果处理过程中往 D 加入一个已存在的符号 ,或者当扫描完所有输入文件时 U 非空,链接器报错并停止动作。否则,它把 E 中的所有目标文件合并在一起生成可执行文件。这种"翻译"通常有两种方式,即编译方式和解释方式。编译方式是指利用事先编好的一个称为编译程序的机器语言程序,
作为系统软件存放在计算机内,当用户将高级语言编写的源程序输入计算机后,编译程序便把源程序整个地翻译成用
机器语言表示的与之等价的目标程序,然后计算机再执行该目标程序,以完成源程序要处理的运算并取得结果。
解释方式是指源程序进入计算机后,解释程序边扫描边解释,逐句输入逐句翻译,计算机一句句执行,并不产生目标程序。
可执行文件格式
Microsoft引进了PE文件格式,更经常被称为PE格式,作为最初的Win32规范的一部分。
然而PE文件源自VAX/VMS上早期的通用目标文件格式(Common Object File Format,COFF)。
Microsoft编译器生成的OBJ文件也使用COFF格式。
PE文件一个非常好的地方就是它的数据结构在磁盘上与在内存(逻辑地址空间)中一样。加载一个可执行文件到内存(例如通过调用LoadLibrary函数)主要就是把PE文件中的某个部分映射到地址空间中。如果你知道如何在一个 PE文件中找到某些内容,你几乎可以确定当文件被加载进内存时可以找到同样的信息。
注意到PE文件并不是作为单一的内存映射文件被映射进内存的这一点非常重要。相反,Windows加载器查看PE文件并确定文件中的哪些部分需要被映射。当映射进内存时,文件中的高偏移相对于内存中的高地址。某项内容在磁盘文件中的偏移可能与它被加载进内存之后的偏移不同,但是将磁盘文件中的偏移转换成内存偏移需要的所有信息都存在(见下图)。
当PE文件由Windows加载器加载进内存时,它在内存中被称为模块(module)。文件被映射到的内存的起始地址被称为HMODULE。这是需要记住的一点:给你一个HMODULE,你就知道在那个地址处到底有什么样的数据结构,并且你可以根据PE文件的知识找到内存中所有其它的数据结构。这个强大的功能可以被用作其它用途,例如拦截API。
内存中的模块代表一个进程所需的可执行文件中的所有代码、数据和资源。PE文件中的其它部分可能会被读取,但并不被映射进内存(例如重定位节)。一些部分可能根本就不被映射,例如放在文件末尾的调试信息。PE文件头中的一个域告诉系统将这个可执行文件映射进内存时需要占用多少内存。不被映射的数据放在文件末尾,位于所有需要被映射的部分之后。
描述PE文件(和COFF文件)的关键位置是WINNT.H文件。在这个头文件中,你能找到几乎所有结构的定义、枚举类型以及使用PE文件或它在内存中的等价结构所需的定义。
有许多工具可以用来查看PE文件。Visual Studio附带的Dumpbin和Platform SDK附带的Depends就是其中的两个。
PE文件的节(section)
PE文件的节代表代码或某些类型的数据。虽然代码只能是代码,但数据却有许多种不同类型。除了可读/可写的程序数据(例如全局变量)外,节中其它类型的数据包括函数导入表和导出表、资源以及重定位信息等。每个节都有它自己的一组内存属性,其中包括节中是否包含代码,是只读的还是可读/可写的以及节中的数据是否在所有使用这个可执行文件的进程中是共享的等等。
一般说来,一个节中的所有代码和数据在逻辑上是相关的。通常一个PE文件中至少有两个节,一个是代码节,另一个是数据节。一般在PE文件中至少还有一种其它类型的数据节。第二部分中具体描述各种节。
每个节都有一个惟一的名字。它通常用来表示节的用途。例如一个名为.rdata的节表明它是一个只读数据节。使用节名只是为了方便人们处理文件,它对操作系统来说无关紧要。一个名为FOOBAR的节可能实际上是一个代码节,就像.text节一样。Microsoft通常在它们的节名前加一个圆点,但这并不是必须的。多少年来,Borland链接器使用的节名都是CODE和DATA。
节并不是完全由链接器生成的,在OBJ文件中就有它们的身影。这通常是由编译器放在那里的。链接器的工作就是把OBJ文件和库文件中所有需要的节组合成PE文件中最终相应的节。例如你的工程中的每个OBJ文件可能都至少有一个包含代码的.text节。链接器把各种OBJ文件中的所有.text节组合成单个的.text节放入PE文件。同样,各种OBJ文件中的所有.data节也被组合成PE文件中单个的.data节。
节有两种对齐值,一种是在文件中的对齐值,另一种是在内存中的对齐值。PE文件头中指定了这两种值,它们可以不同。每个节的起始地址都是对齐值的倍数。例如在PE文件中,典型的对齐值是0x200。因此每个节的起始起始地址都是0x200的倍数。
一旦映射进内存,节总是从页的边界开始。也就是说,当PE文件被映射进内存时,每个节的开头都对应于一个内存页的开始。在x86 CPU上,页按4KB对齐;在IA-64上,页按8KB对齐。以下是PEDUMP输出的Windows XP中的KERNEL32.DLL的.text节和.data节的情况:
Section Table
01 .text VirtSize: 00074658 VirtAddr: 00001000
raw data offs: 00000400 raw data size: 00074800
……
02 .data VirtSize: 000028CA VirtAddr: 00076000
raw data offs: 00074C00 raw data size: 00002400
上面的输出表明,.text节的文件偏移是0x400,在内存中它比KERNEL32的加载地址高0x1000字节。同样,.data节的文件偏移是0x74C00,它在内存中比KERNEL32的加载地址高0x76000字节。
可以创建一个PE文件,使它的节在文件中的偏移与在内存中的偏移相同。但这会使可执行文件相当大,不过可以提高它在Windows 9x或Windows Me中的加载速度。使用默认的/OPT:WIN98链接器选项(由Visual Studio 6.0引进)就可以创建这样的PE文件。在Visual Studio® .NET中,链接器可能使用也可能不使用这个选项,这取决于文件是不是足够小。
链接器一个比较有趣的功能就是合并节。如果两个节属性相似、相互兼容时,它们通常会在链接时被合并成一个节。这是通过链接器的/MERGE选项来完成的。例如以下的链接器选项会把.rdata节和.text节组合成单个的.text节:
/MERGE:.rdata=.text
把节合并起来的好处是可以节省在磁盘上和在内存中的空间。每个节至少要在内存中占用一个页面。如果你能将一个可执行文件中节的数目从四个减少到三个,你很可能就会少占用一页内存。当然,这取决于那两个被合并的节中未使用的空间加起来够不够一页。
当你合并节时就会发生一些有趣的情况,因为这并没有硬性的和快速的规则可以遵守。例如,把.rdata节合并到.text节当然可以,但是你不能把.rsrc节、.reloc节或者.pdata节合并到其它节中。在Visual Studio .NET之前,你可以把.idata节合并到其它节中。但是在Visual Studio .NET中,这是不允许的。但在创建程序的发行版本时,链接器自己却经常把部分的.idata节合并到其它节中,例如.rdata节。
由于导入表要被Windows加载器改写,你可能想知道它们怎么能被放在只读节中。这是因为在加载时系统临时将包含导入表的那些页面的属性设置为可读/可写。一旦导入表被初始化,这些页面就会被设置成原来的属性。
相对虚拟地址
在可执行文件中,许多地方都需要被指定一个在内存中的地址。例如在使用全局变量时需要它的地址。PE文件可以被加载到进程地址空间中的任何地方。虽然它有一个首选地址,但你却不能依赖可执行文件一定会被加载到那个地址。因此就需要按一定方式指定地址,使它们并不依赖于可执行文件的加载地址。
为了避免在PE文件中硬编码内存地址,因此就使用了RVA。RVA只是一个相对于PE文件在内存中的加载位置的偏移。例如假定一个EXE文件被加载在0x400000处,而它的代码节在0x401000处。那么这个代码节的RVA就是:
(目标地址)0x401000 - (加载地址)0x400000 = (RVA)0x1000
要把一个RVA转换成实际地址,只需要简单地逆着上述过程进行:将RVA与实际加载地址相加就能得到实际的内存地址。顺便说一下,按照PE格式中的说法,实际的内存地址被称为虚拟地址(Virtual Address,VA)。另外一种考虑VA的方式就是把它当成RVA加上首选加载地址。不要忘了我前面说过加载地址与HMODULE是一回事。
想在内存中探索一些DLL内部的数据结构吗?这里就是方法——用DLL的名称作为参数调用GetModuleHandle函数,它返回的HMODULE就是这个DLL的加载地址,你可以利用你学的关于PE文件结构的知识在这个模块中找到你想找到的一切。
数据目录
在可执行文件中有许多数据结构需要被快速地定位。导入表、导出表、资源以及基址重定位信息等就是一些明显的例子。所有这些广为人知的结构都是以同样的方式被定位的,这些位置被称为数据目录。
struct _IMAGE_DATA_DIRECTORY {
DWORD VirtualAddress;
DWORD Size;
} IMAGE_DATA_DIRECTORY, *PIMAGE_DATA_DIRECTORY;
#define IMAGE_NUMBEROF_DIRECTORY_ENTRIES 16
数据目录是一个有16个(WINNT.H中定义为IMAGE_NUMBEROF_DIRECTORY_ENTRIES)元素的结构数组。每个数组元素所指代的内容已经被预先定义好了。WINNT.H文件中的这些IMAGE_DIRECTORY_ENTRY_xxx定义就是数据目录的索引(从0到15)。下表描述了每个IMAGE_DIRECTORY_ENTRY_xxx值所指代的内容。由它们指向的许多数据结构将在本文的第二部分中详细描述。
值 |
描述 |
IMAGE_DIRECTORY_ENTRY_EXPORT |
指向导出表(IMAGE_EXPORT_DIRECTORY结构)。 |
IMAGE_DIRECTORY_ENTRY_IMPORT |
指向导入表(IMAGE_IMPORT_DESCRIPTOR结构数组)。 |
IMAGE_DIRECTORY_ENTRY_RESOURCE |
指向资源(IMAGE_RESOURCE_DIRECTORY结构)。 |
IMAGE_DIRECTORY_ENTRY_EXCEPTION |
指向异常处理程序表(IMAGE_RUNTIME_FUNCTION_ENTRY结构数组)。它特定于CPU,用于基于表的异常处理。适用于除x86之外所有类型的CPU。 |
IMAGE_DIRECTORY_ENTRY_SECURITY |
指向WIN_CERTIFICATE结构列表。此结构定义在WinTrust.H文件中。它并不作为映像的一部分被映射进内存。因此VirtualAddress域是文件偏移,而不是RVA。 |
IMAGE_DIRECTORY_ENTRY_BASERELOC |
指向基址重定位信息。 |
IMAGE_DIRECTORY_ENTRY_DEBUG |
指向IMAGE_DEBUG_DIRECTORY结构数组。其中的每个元素描述了映像中的一些调试信息。要获得IMAGE_DEBUG_DIRECTORY结构的数目,用Size域除以IMAGE_DEBUG_DIRECTORY结构的大小。早期的Borland链接器将这个IMAGE_DATA_DIRECTORY项的Size域设置成IMAGE_DEBUG_DIRECTORY结构的数目,而不是数组的大小。 |
IMAGE_DIRECTORY_ENTRY_ARCHITECTURE |
指向与平台相关的数据,这个数据是一个IMAGE_ARCHITECTURE_HEADER结构数组。x86平台和IA-64平台并不使用,但好像已经用于DEC/Compaq Alpha平台。 |
IMAGE_DIRECTORY_ENTRY_GLOBALPTR |
在某些平台上,其VirtualAddress域保存的是全局指针(Global Pointer ,GP)的RVA。x86平台上不使用,但IA-64平台上使用。Size域并未使用。要获取更多关于IA-64 GP方面的信息,可以参考 |
IMAGE_DIRECTORY_ENTRY_TLS |
指向线程局部存储(Thread Local Storage)初始化节。 |
IMAGE_DIRECTORY_ENTRY_LOAD_CONFIG |
指向IMAGE_LOAD_CONFIG_DIRECTORY结构。此结构中的信息特定于Windows NT、Windows 2000和Windows XP(例如GlobalFlag值)。如果你的可执行文件要使用这个结构,需要定义一个名称为__load_config_used,类型为IMAGE_LOAD_CONFIG_DIRECTORY的全局结构体。对于非x86平台,这个名称需要被定义成_load_config_used(单下划线)。如果你想使用IMAGE_LOAD_CONFIG_DIRECTORY结构,必须使用这个技巧才能在你的C++代码中得到正确的名字。链接器看到的符号名一定要是__load_config_used(带两个下划线)。C++编译器要在全局符号前加一个下划线。另外,它还使用类型信息来修饰(decorate)全局符号。因此要使一切正常,你应该像下面这个样子使用:
extern "C"
IMAGE_LOAD_CONFIG_DIRECTORY _load_config_used = {...} |
IMAGE_DIRECTORY_ENTRY_BOUND_IMPORT |
指向IMAGE_BOUND_IMPORT_DESCRIPTOR结构数组。每个结构对应于这个映像已经绑定的一个DLL。这个结构中的日期/时间戳(TimeDateStamp域)可以让加载器快速确定这个绑定是否是最新的。如果不是,加载器将忽略绑定信息,并正常地解析导入的函数。 |
IMAGE_DIRECTORY_ENTRY_IAT |
指向第一个导入地址表(IAT)的开头。对应于每一个导入的DLL都有一个相应的IAT,并且它们在内存中依次排列。Size域指出了所有IAT的总大小。加载器在解析导入符号期间使用这个地址和大小临时将包含IAT的页面标记为可读/可写。 |
IMAGE_DIRECTORY_ENTRY_DELAY_IMPORT |
指向延迟加载信息,它是CImgDelayDescr结构数组,这个结构被定义在Visual C++的DELAYIMP.H文件中。直到首次调用延迟加载的DLL中的函数时这个DLL才会被加载。特别需要注意的是:Windows并不知道关于延迟加载DLL方面的任何信息。延迟加载特性完全是由链接器与运行时库来实现的。 |
IMAGE_DIRECTORY_ENTRY_COM_DESCRIPTOR |
这个值在最新的系统头文件(CorHdr.h)中被更名为IMAGE_DIRECTORY_ENTRY_COMHEADER。它指向可执行文件中的.NET信息中的顶层信息,包括元数据。这个信息保存在IMAGE_COR20_HEADER结构中。 |
导入函数
当你使用其它DLL中的代码或数据时,就需要导入它们。当加载PE文件时,Windows加载器的工作之一就是定位所有导入的函数和数据,以便加载的PE文件可以使用它们。我把对完成这个任务所需的数据结构的详细讨论留给本文的第二部分,在这里仅给出一个整体概念。
当你直接链接其它DLL中的代码和数据时,实际上隐含链接到了相应的DLL上。你并不需要做任何工作来让你的程序使用这些导入的函数。加载器把这些全包了。另一种使用DLL的方式是显式链接(explicit linking)。这意味着你需要明确地加载这些DLL,然后查找这些函数的地址。这种方法几乎总是通过LoadLibrary和GetProcAddress这两个API来完成的。
当你隐含链接函数时,类似于使用LoadLibrary和GetProcAddress这两个API的代码仍然存在,但加载器自动为你做这些事。同时加载器确保这个被加载的PE文件所需的其它DLL也会被加载。例如用Visual C++®创建的程序一般都会链接到KERNEL32.DLL,而KERNEL32.DLL又从NTDLL.DLL中导入了函数。同样,如果你从GDI32.DLL导入函数,而实际上这个DLL依赖于USER32、ADVAPI32、NTDLL以及KERNEL32这些DLL。加载器会确保这些DLL都被加载,以便解析这些导入的函数。(Visual Basic 6.0和Microsoft .NET可执行文件并不直接链接到KERNEL32,而是链接到了其它的DLL上,但原理是一样的。)
当隐含链接(也称为隐式链接)时,对主要的EXE文件及其依赖的所有DLL的解析过程发生在程序启动时。如果这时出现任何问题(例如它引用的一个DLL找不到),相应的进程就会被终止。
Visual C++ 6.0引入了一个延迟加载(delayload)特性,它是隐含链接与显式链接的混合。当你延迟加载DLL时,链接器生成了一些非常类似于它为正常导入的DLL生成的数据那样的数据,但是操作系统却忽略这些数据。当你的程序在执行过程中首次调用这些延迟加载的函数其中之一时,由链接器为此生成的一部分代码就会执行,由它加载相应的DLL(如果尚未加载),然后调用GetProcAddress来确定要调用的函数的地址。这些额外的工作使得接下来对这个函数的调用就好像这个函数是正常导入的一样。
在PE文件中,对应于每一个导入的DLL有一个相应的结构数组。其中的每个结构都给出了导入的DLL的名称和一个指向函数指针数组的指针。这个函数指针数组被称为导入地址表(Import Address Talbe,IAT)。每个导入的函数都在IAT中有一个对应的位置,Windows加载器就在这个位置上写入这个导入函数的地址。这一点非常重要:一旦一个模块的加载过程结束,那么其IAT中就包含了导入函数的地址。
IAT的美妙之处就在于,在PE文件中,只有一个地方保存了导入函数的地址。无论在你的程序中对某个导入的函数调用多少次,所有调用使用的同样都是IAT中对应于这个函数的指针。
现在让我们来看一下如何调用导入函数。它分为两种情况:高效率方式与低效率方式。按最好的情况(高效率方式)来说,对一个导入函数的调用应该像下面这个样子:
CALL DWORD PTR [0x00405030]
如果你不熟悉x86汇编语言,我可以告诉你这条指令表示通过函数指针来调用相应的函数。在地址0x00405030处的一个DWORD类型的值就是CALL指令要将控制权转到的地方。在这个例子中,地址0x00405030在IAT中。
调用导入函数的低效率方式类似下面这个样子:
CALL 0x0040100C
...
0x0040100C:
JMP DWORD PTR [0x00405030]
在这种情况下,CALL指令把控制权转到了一个小占位程序(stub)中。这个占位程序只是一条JMP指令,用以跳转到保存在地址0x405030处的地址中。同样,记住0x405030是IAT中的一个元素。一句话,调用API的这种低效率方式使用了5个字节的附加代码(JMP指令是1字节,地址是4个字节),并且由于使用了额外的JMP指令,因此执行时要花费更长的时间。
你可能奇怪既然有高效率的调用方式,为什么还要使用低效率的调用方式呢?理由是很充足的。由于自身的限制,编译器并不能区分调用导入的函数与调用同一模块中的函数之间的区别。因此编译器为函数调用生成的指令是这样的:
CALL XXXXXXXX
而XXXXXXXX处是实际代码的地址,这个地址由链接器在后面填充。注意这最后的CALL指令并不是通过函数指针(调用函数的)。相反,它使用的是实际代码的地址。为了保持一致的方式,链接器需要用一个代码块来替换XXXXXXXX。最简单的做法就是调用一个JMP之类的占位程序,就像上面你所看到的那样。
那JMP指令是从哪里来的呢?令人惊讶的是,它来自于相应函数的导入库。如果你仔细查看导入库,并且查看与导入函数名称相连的代码时,就会看到类似上面的JMP指令。这意味着,默认情况下,如果没有任何干涉,调用导入函数使用的总是低效率的调用方式。
按照逻辑推理,下一个要问的问题一定是如何才能使用高效率的调用方式。答案是你必须给编译器一个提示。__declspec(dllimport)这个函数修饰符告诉编译器这个函数在其它的DLL中,编译器应该生成下面这样的指令:
CALL DWORD PTR [XXXXXXXX]
而不是下面这样的指令:
CALL XXXXXXXX
另外,编译器生成相应的信息来告诉链接器去解析上面那条指令中的函数指针部分时应该去找的符号是__imp_函数名(就是在相应的函数名称前加上__imp_)。例如,如果你调用MyFunction这个函数,那么相应的符号名应为__imp_MyFunction。看一下导入库,除了看到正常的符号名外,你还会看到一个以__imp_为前缀的同样的符号名。这个__imp_类型的符号被直接解析成了IAT项,而不是JMP占位程序。
这对你日常工作有什么影响呢?如果你编写导出函数并且提供了相应的.H文件,记得使用__declspec(dllimport)函数修饰符。例如:
__declspec(dllimport) void Foo(void);
如果你看一下Windows系统头文件,你会发现所有的Windows API都使用了__declspec(dllimport)。要想看到它并不容易,但是如果你搜索定义在WINNT.H中,并且用于像WinBase.h之类的头文件中的DECLSPEC_IMPORT宏,你就会发现__declspec(dllimport)是如何用于系统API声明的。
阅读(19689) | 评论(0) | 转发(1) |