你们好,下面开始静态连接部分的工做原理分析,因为这部份内容太多了,我计划分2个部分发出,先看下这部分的大纲:程序员
编译器的任务是将每个包含C++代码的源文件编译成包含二进制机器码的目标文件。因为在一个源文件中可能会调用到其它文件中的代码或数据,这些代码或者数据可能来自于静态库中,也可能来自于动态连接库中,也可能来自于其余的源文件中。在编译阶段,编译器只专一于对单个源文件的处理,对于这些外部符号,编译器没法解析。对于调用到外部符号的地方,编译器留出位置,并用一些假数据填充。所以,编译器输出的目标文件是不完整的,是须要修正的。windows
连接器的任务是修正目标文件中不完整的地方,解析在编译阶段没法解析的外部符号,而且将这些目标文件合并到一块儿,输出可执行文件。这些外部符号能够在连接阶段解析,能够在可执行程序加载到内存的阶段解析,甚至推迟到可执行程序执行的阶段。在连接阶段解析外部符号的工做被称为静态连接,在加载阶段解析外部符号的工做被称为隐式动态连接,在运行阶段解析外部符号的工做被称为显式动态连接。数据结构
在静态连接阶段,因为被引用的外部符号可能来自于不一样的地方,如:其余目标文件中,静态连接库中,动态连接库中,因此静态连接又能够分为三种状况:框架
静态连接的整体框架以下图所示:函数
输入的文件包括:目标文件,静态连接库文件,资源文件,动态连接库的导入库文件,以及与连接相关的定义文件(如:def文件)。在执行静态连接的时候,被输入的目标文件为一个到多个,每个目标文件对应一个C++源代码文件;因为C++程序是运行在C++运行库之上的,而C++运行库又是以静态连接库和动态连接库两种方式提供。所以在执行静态连接的时候,输入文件可能会包括静态连接库,好比:libcmt.lib。输入文件也多是动态连接库,好比:msvcp90.dll。可是动态连接库文件不直接参与静态连接,参与静态连接的是与该静态连接库相对应的导入库文件(该文件的扩展名也是.lib)。工具
连接器在执行静态连接的时候分为两个阶段,每一个阶段都包含一次对输入文件的扫描,在扫面的基础上执行一些处理操做,而后输出一些文件。this
在第一遍扫描的过程当中,连接器主要生成了全局符号表,段表,以及导出符号表。在创建全局符号表的时候,每一个目标文件中的全局符号都会被读入到该表中,而后以链表的形式将模块中定义或者引用了该全局符号的位置存储起来。当全局符号表创建完毕之后,在该表中,对于每个符号都会有一个定义,0到多个引用。在连接器扫描各个目标文件信息的时候,段信息也会被记录,包括:各段的大小,位置,属性等,这些信息被放入到段表中。段表为后续的段合并提供了信息支持。若是全局符号中包含导出符号(通常为生成动态连接库的状况),连接器会将这些导出符号写入到.edata段中,而后将.edata段输出到扩展名为.exp问临时文件中,该文件的格式为COFF格式。spa
在第二遍扫描的过程当中,连接器主要作的工做是:肯定各个段的地址,以及段内符号的地址;执行属性相同段的合并工做;符号解析和重定位;创建重定位段以及符号表信息;写入头部信息;加入少许的代码和数据,这些代码包括:桩代码(一些jump指令)和启动代码。操作系统
当静态连接执行完毕之后,连接器主要输出了可执行文件或者动态连接库文件,以及一些辅助性文件,如:符号文件(pdb),导入库文件(lib),导出表文件(exp)等。debug
连接的目标是要处理好符号的虚拟内存地址。下面将要介绍在各个阶段内,符号的地址演化状况。
从C/C++源代码的编写阶段,通过编译,连接,程序加载到内存,一直到程序的运行,各个符号的地址的演化流程以下图所示:
在代码编写阶段,使用变量名称,或者函数名称来表示一个符号。好比:变量的定义,int nVar = 10;,定义一个整形变量初始化为数值10。使用名称nVar来表示这个变量符号。
在执行编译后的目标文件中,使用文件偏移量来表示一个符号的地址,这个文件偏移量能够是相对于COFF文件的首位置的绝对偏移。如各个段的位置,重定位表和符号表的位置;也能够是相对与段首位置的相对偏移。如:数据段内定义的符号相对于数据段首位置的偏移。示例以下:
SECTION HEADER #5 //代码段的基本信息 .text name 0 physical address //物理地址 0 virtual address //虚拟地址,该地址均为零,由于编译阶段没有分配虚拟内存地址 39 size of raw data //代码段大小 1561 file pointer to raw data (00001561 to 00001599) //绝对偏移,代码段相对于文件首位置的偏移 0 file pointer to relocation table //重定位表的位置。零表示没有重定位信息 0 file pointer to line numbers 0 number of relocations 0 number of line numbers 60501020 flags Code COMDAT; sym= "public: class DemoMath & __thiscall DemoMath::operator=(class DemoMath const &)" (??4DemoMath@@QAEAAV0@ABV0@@Z) 16 byte align Execute Read
RAW DATA #5 //代码段的二进制数据内容。这些内容以字节为单位列出。每一个字节都有一个地址,这些地址是相对于代码段的偏移量。从下面的内容能够看出,这些字节从零开始编址,直到地址为30的位置。 这是相对偏移。若是要更改为绝对偏移来表示的话,绝对位置= 段相对文件首的位置+各字节相对段的偏移 00000000: 55 8B EC 81 EC CC 00 00 00 53 56 57 51 8D BD 34 U.ì.ìì...SVWQ.?4 00000010: FF FF FF B9 33 00 00 00 B8 CC CC CC CC F3 AB 59 ???13...?ììììó?Y 00000020: 89 4D F8 8B 45 08 8B 08 8B 55 F8 89 0A 8B 45 F8 .M?.E....U?...E? 00000030: 5F 5E 5B 8B E5 5D C2 04 00 _^[.?]?.. |
在上面示例的注释中,描述了绝对偏移和相对偏移的状况。
在执行连接后的PE文件中,使用虚拟内存地址表示各个符号的位置。这些虚拟内存地址是基于默认加载位置的虚拟内存地址。在32位的操做系统中,可执行文件(exe)的默认加载位置是:0x00400000,动态连接库(DLL)的默认加载位置是:0x10000000。
符号的虚拟内存地址的计算方式为:符号的虚拟内存地址 = 默认加载地址 + 段偏移 +段内偏移。在下面的示例中,变量nGlobalData的虚拟地址为:(0x00400000(默认加载地址)+0x00019000(段偏移)+0x00000004(段内偏移)=0x00419004)示例以下:
//DemoExe.exe数据段导出的内容 SECTION HEADER #4 //数据段的基本信息 .data name 5B4 virtual size //数据段的大小 19000 virtual address (00419000 to 004195B3)//数据段相对于默认加载位置的偏移。数据段的虚拟内存地址=默认加载位置(0x00400000)+ 0x00019000 200 size of raw data //数据段的大小 7800 file pointer to raw data (00007800 to 000079FF)//在PE文件中,数据段相对于文件首位置的绝对偏移。 0 file pointer to relocation table //零表示没有重定位段。必须为零,已经重定位完成了。 0 file pointer to line numbers 0 number of relocations 0 number of line numbers C0000040 flags Initialized Data Read Write
RAW DATA #4 //数据段的二进制内容。从下面的内容能够看出,对于每个字节,都有一个虚拟内存地址。该虚拟内存地址是基于默认加载位置的虚拟内存地址。下面红色的数据为变量nGlobalData的值。从地址0x00419004到0x0041907。该数据使用小尾方式排列,应该倒过来看,即:00 00 00 05。 00419000: 3C 77 41 00 05 00 00 00 00 00 00 00 4E E6 40 BB <wA.........N?@? 00419010: B1 19 BF 44 00 00 00 00 00 00 00 00 00 00 00 00 ±.?D............ 00419020: 01 00 00 00 01 00 00 00 01 00 00 00 01 00 00 00 ................ 00419030: 01 00 00 00 00 00 00 00 FE FF FF FF 01 00 00 00 ........t???.... 00419040: FF FF FF FF FF FF FF FF 00 00 00 00 44 82 41 00 ????????....D.A. 00419050: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................ |
在应用程序加载到内容的时候,并非每次都能加载到默认的内存位置。若是该内存位置被占用,那么必须执行基址重定位工做,即:从新选定模块要加载的内存基地址。这时候,该符号的虚拟内存地址的计算方式为:虚拟内存地址=当前基地址+段偏移+段内偏移。其中段偏移和段内偏移在连接阶段已经肯定,惟一变化的是当前基地址。
运行DemoExe应用程序,在Visual Studio中查看DemoExe当前的加载位置,具体状况以下图所示:
在上图中,DemoExe被加载到的内存位置是:0x00110000。这个值在每一次程序运行的过程当中均可能不同。
在运行时,变量nGlobalData的地址分配状况以下图所示:
变量nGlobalData的当前虚拟内存地址为:
0x00129004 = 0x00110000+0x00019000+0x00000004。符合前面公式所描述的规则。
静态连接的过程当中,在生成的PE文件内,符号的虚拟内存地址是基于默认加载位置的。符号解析和地址重定位工做都是在该规则下进行的。
能够修改IP寄存器的内容,或者同时修改CS寄存器和IP寄存器的内容的指令统称为转译指令。IP寄存器中保存了当前被执行指令的下一条指令的地址;CS寄存器保存了当前内存段的地址(或者选择子)。
按照转移的距离来分,转移指令分为三种,分别是:
经常使用的转移指令包括:Jump指令,Call指令等。其中,Call指令没有短转移功能,只能实现近转移和远转移。在短转移指令和近转移指令中,其所包含的操做数都是相对于(E)IP的偏移,而远转移指令的操做数包含的是目标的绝对地址。所以,在短转移指令和近转移指令中,对于跳转同一目标地址的状况下,其操做数是不一样的,并且应该不一样,由于是相对的;而远转移指令包含的操做数是绝对地址,所以跳转到同一地址的机器码指令是相同的。
因为运行在32位windows下的应用程序是基于平坦内存管理模式的,也就是说,整个进程的虚拟地址空间被划分红一个段,该段的基地址是0x00000000H,大小是4GB。在这种状况下,全部的转移都是在一个段内进行的,因此无需考虑远转移指令。
使用Dumpbin工具将PE文件的内容导出为汇编格式,在该汇编格式的文件中,涉及到的转移指令,包括Jump指令和Call指令,均为近转移指令。即:段内转移。
转移指令的格式为:
call 操做数 Jump 操做数 |
操做数的计算公式为:
操做数 = 符号虚拟内存地址 – IP寄存器的内容 |
符号的虚拟内存地址为:被调用函数或其余被定义的符号在虚拟内存中的绝对地址;IP寄存器的内容为:当前被执行指令的下一条指令的虚拟内存地址。在32位环境中,指令占一个字节,操做数(即符号地址)占4个字节,一共5个字节。所以,IP寄存器内容的计算公式为:
IP寄存器的内容 = 转移指令的当前地址 - 5 |
具体状况以下图所示:
使用Visual Studio创建C++项目之后,在该项目中可能会包含多个源文件,在编译阶段,每个源文件都被编译成目标文件。在某一个目标文件中,可能引用了定义在其余目标文件中的符号,所以在静态连接阶段须要对这些外部符号进行解析。在这一节中,目标文件都是由程序员编写的C++代码编译生成的,而不是来自于某个静态连接库或者动态连接库。
在静态连接的时候,连接器的工做分两步进行,每步执行一次扫描,具体的操做流程以下图所示:
Step1:扫描各个符号表。在执行该阶段的任务,扫描目标文件的时候,各个目标文件中所包含的符号表也一同被扫描。将这些属于各个目标文件的符号表合并到一块儿,造成一张全局符号表。
Step2:在目标文件所属的符号表中,因为各个符号尚未被分配虚拟内存地址,因此符号的值是中还没有包含符号的虚拟内存地址。这里所说的符号主要是指变量或者函数。当目标文件中各个符号的地址被肯定之后,须要将各个符号的值更改为该符号被分配的虚拟内存地址。
Step3:合并同名符号的记录。在目标文件A中引用了定义在目标文件B中的符号C。那么在目标文件A中,符号表就会包含这样一条记录,该记录的符号名为C,符号的“StorageClass”属性为:External(全局符号),符号的“SectionNumber”属性为:UNDEF(未定义);符号的值不定;在目标文件B中,符号表也会包含一条名称为C的符号记录,该记录的“StorageClass”属性为:External(全局符号),“SectionNumber”属性为:SECTn(表示符号位于某各段内),符号的值为符号的虚拟内存地址。在执行连接的时候,须要将这两条记录合并为一条记录,并肯定新记录在符号表中的索引。而后使用新记录的符号表索引去修正相关重定位表。由于重定位表引用了符号表的索引。
Step4:创建全局符号表。在全局符号表中,全部的符号都拥有正确的虚拟内存地址。全部的重定位表都引用了正确的符号表索引。在创建全局符号表的时候,每一个目标文件中的全局符号都会被读入到该表中,而后以链表的形式将模块中定义或者引用了该全局符号的位置存储起来。当全局符号表创建完毕之后,在该表中,对于每个符号都会有一个定义,0到多个引用
Step1:扫描各段信息。扫描全部参与连接的目标文件,肯定各个段的大小,属性和位置。在每一个目标文件的段表中,字段“VirtualSize”记录了该段被加载到内存之后所须要的内存空间的大小,段的大小是虚拟内存空间分配的依据;字段“Characteristics”记录了该段的属性。如:可读,可写,可执行,是代码段,仍是数据段等。段的属性是段合并的依据。
Step2:创建段表。在内存中为段表分配内存空间,而后将第一步得到的信息写入到内存中,造成段表,后续的段合并中将使用到段表。
Step1:扫描各目标文件。从新扫描各个目标文件,根据段表的信息,提取各段的内容。
Step2:肯定各段地址。根据段表中的信息,为提取到的各段分配虚拟内存地址,以及肯定各段占用的内存空间大小。即:肯定每一个段的段首在内存中的可能加载位置(固然,这个位置在加载时可能会变)。
Step3:肯定段内地址。在目标文件中,各个段内的符号没有虚拟内存地址,只有相对于各个段首的文件偏移量。在连接阶段,当肯定了各个段段首的虚拟内存地址之后,就能够根据符号的文件偏移量,计算出各段内符号的虚拟内存地址。符号的虚拟内存地址=段首虚拟内存地址+文件偏移量。
Step4:合并段并输出。将各个目标文件中的全部属性相同的段合并到一块儿,造成一个新段,并输出到一个新的文件中。这个文件将做为连接后的输出物,根据设定,能够是可执行文件,也能够是动态连接库等。在这里,合并的原则是属性相同,而不是逻辑相同。例如:全部的代码段被合并到一块儿,全部的数据段被合并到一块儿,全部的bss段被合并到一块儿。
完成该阶段工做之后,全部目标文件中的内容都被合并到了一块儿,而且肯定了符号的虚拟内存地址。若是该段拥有重定位表,那么重定位表的属性“VirtualAddress”的值也会被修正,使其指向正确的重定位位置。由于段的合并致使了段内符号的相对偏移量的变化,因此该值可能被修正。
Step1:扫描各段重定位表。通过前面的处理,全部的目标文件都已经被合并,而且将合并后的内容输出到一个新文件中,该文件将以PE格式存储。各个段的重定位表和新创建的全局符号表也存在于该文件中。连接器开始扫描重定位表,用来提供重定位信息。
Step2:肯定重定位的位置。经过对重定位表的扫描,取得了重定位表中字段VirtualAddress的值。该值是一个内存地址,在该内存地址所指向的内存处存储了一个指令的操做数。该操做数通常为一个变量或函数的内存地址。表示这个指令要使用这个变量的值,或者执行函数调用。在编译阶段,因为这个操做数所表明的变量或函数被定义其余目标文件中,因此没法立刻肯定该操做数的正确值。在连接阶段,这个操做数是须要被修正的,该操做数所在的位置即为重定位的位置。在32位操做系统中,重定位的位置为4个字节。
Step3:取得重定位符号的地址类型。在重定位表中,须要被修正的函数或变量的地址有两种类型,即:相对地址和绝对地址。在重定位表中,使用字段Type存储该类型。在地址重定位的时候,对这两种类型的地址的处理方式是不一样的。
Step4:处理相对地址。函数的虚拟内存地址的类型为相对地址,在进行符号解析和重定位的时候,须要在重定位的位置上填写4个字节的相对地址。相对地址的计算公式为:
相对地址 = 符号虚拟内存地址 – 指令虚拟内存地址 – 5 //该计算公式在32位模式下有效,具体解释见3.3节 |
编译C++源代码的时候,在debug模式中,采用了增量连接的方式,而在release模式中,采用了非增量连接的方式。在执行增量连接的状况下,在重定位的位置上,被填写的相对地址是相对于增量连接表中某个表项的相对地址,而不是被调用函数的相对地址;在非增量连接的状况下,在重定位的位置上,被填写的相对地址是相对于被调用函数的相对地址。将在3.7节详细介绍增量连接的概念。
Step5:处理绝对地址。变量的虚拟内存地址的类型为绝对地址,在进行符号解析和重定位的时候,须要在重定位的位置上填写4个字节的变量的虚拟内存地址。该地址值为变量的真实的虚拟内存地址。
关于地址计算部分,参见3.8的示例。
其余部分的工做包括:向PE文件中写入头部信息。包括:DOS头,PE头等信息;向PE文件中写入一些代码,包括桩代码和库的启动代码等,主要用于动态连接库;另外,根据连接的配置,还可能要进行一些文件的输出,好比:map文件,符号表文件等。
该阶段的工做是执行动态连接的准备工做,动态连接是相对于静态连接而言的。所谓静态连接是指把要调用的函数或者过程连接到可执行文件中,成为可执行文件的一部分。换句话说,函数和过程的代码就在程序的exe文件中,该文件包含了运行时所需的所有代码。当多个程序都调用相同函数时,内存中就会存在这个函数的多个拷贝,这样就浪费了宝贵的内存资源。
在动态连接中,被调用的函数代码没有被拷贝到应用程序的可执行文件中,而仅仅是在其中加入了所调用函数的描述信息(每每是一些重定位信息)。当应用程序被装入内存开始运行的时候,在Windows的管理下,创建起了应用程序与相应动态连接库之间的关系。当要执行所调用的DLL中的函数时,根据连接产生的重定位信息,Windows才转去执行DLL中相应的函数代码。通常状况下,若是在一个应用程序中使用了动态连接库,那么Win32系统保证内存中只有DLL的一份复制品。
动态连接的整个过程可分为两步:编译时的静态连接,以及加载运行时的动态连接。这部分静态连接的工做是为后续的动态连接所作的准备。即:在静态连接过程当中生成的数据结构,如导入表,导出表等,都将被加载器用来执行动态连接。整个过程的详细状况以下图所示:
在静态连接阶段,连接器除了要执行如3.4节所描述的目标文件之间的静态连接的工做外,为了处理应用程序对动态连接库中符号的引用,在进行两遍扫描的时候,连接器还须要作其余的额外工做,这些工做主要包括:
在静态连接阶段,动态连接库文件自己并不参与连接,参与连接的是与动态连接库相对应的导入库文件。导入库文件伴随动态连接库文件的生成而生成。
要使用动态连接库,首先涉及到的是动态连接库的建立,而后才会涉及到对动态连接库的使用。整个动态连接库的建立工做是在编译与静态连接下完成的;而对动态连接库的使用则涉及到两个过程:静态连接下的数据准备工做,以及加载时外部符号的解析工做。关于动态连接的过程将在“动态连接”相关的章节讲述,这里主要描述静态连接。