C++应用程序在Windows下的编译、链接:第三部分 静态链接(一)

大家好,下面开始静态链接部分的工作原理分析,由于这部分内容太多了,我计划分2个部分发出,先看下这部分的大纲:

3静态链接

3.1概述

编译器的任务是将每一个包含C++代码的源文件编译成包含二进制机器码的目标文件。由于在一个源文件中可能会调用到其它文件中的代码或数据,这些代码或者数据可能来自于静态库中,也可能来自于动态链接库中,也可能来自于其他的源文件中。在编译阶段,编译器只专注于对单个源文件的处理,对于这些外部符号,编译器无法解析。对于调用到外部符号的地方,编译器留出位置,并用一些假数据填充。因此,编译器输出的目标文件是不完整的,是需要修正的。

链接器的任务是修正目标文件中不完整的地方,解析在编译阶段无法解析的外部符号,并且将这些目标文件合并到一起,输出可执行文件。这些外部符号可以在链接阶段解析,可以在可执行程序加载到内存的阶段解析,甚至推迟到可执行程序执行的阶段。在链接阶段解析外部符号的工作被称为静态链接,在加载阶段解析外部符号的工作被称为隐式动态链接,在运行阶段解析外部符号的工作被称为显式动态链接。

在静态链接阶段,由于被引用的外部符号可能来自于不同的地方,如:其他目标文件中,静态链接库中,动态链接库中,所以静态链接又可以分为三种情况:

  • 目标文件之间的静态链接。
  • 目标文件与静态链接库之间的静态链接。
  • 目标文件与导入库之间的静态链接。

静态链接的总体框架如下图所示:

输入的文件包括:目标文件,静态链接库文件,资源文件,动态链接库的导入库文件,以及与链接相关的定义文件(如:def文件)。在执行静态链接的时候,被输入的目标文件为一个到多个,每一个目标文件对应一个C++源代码文件;由于C++程序是运行在C++运行库之上的,而C++运行库又是以静态链接库和动态链接库两种方式提供。因此在执行静态链接的时候,输入文件可能会包括静态链接库,比如:libcmt.lib。输入文件也可能是动态链接库,比如:msvcp90.dll。但是动态链接库文件不直接参与静态链接,参与静态链接的是与该静态链接库相对应的导入库文件(该文件的扩展名也是.lib)。

链接器在执行静态链接的时候分为两个阶段,每个阶段都包含一次对输入文件的扫描,在扫面的基础上执行一些处理操作,然后输出一些文件。

在第一遍扫描的过程中,链接器主要生成了全局符号表,段表,以及导出符号表。在建立全局符号表的时候,每个目标文件中的全局符号都会被读入到该表中,然后以链表的形式将模块中定义或者引用了该全局符号的位置存储起来。当全局符号表建立完毕以后,在该表中,对于每一个符号都会有一个定义,0到多个引用。在链接器扫描各个目标文件信息的时候,段信息也会被记录,包括:各段的大小,位置,属性等,这些信息被放入到段表中。段表为后续的段合并提供了信息支持。如果全局符号中包含导出符号(一般为生成动态链接库的情况),链接器会将这些导出符号写入到.edata段中,然后将.edata段输出到扩展名为.exp问临时文件中,该文件的格式为COFF格式。

在第二遍扫描的过程中,链接器主要做的工作是:确定各个段的地址,以及段内符号的地址;执行属性相同段的合并工作;符号解析和重定位;建立重定位段以及符号表信息;写入头部信息;加入少量的代码和数据,这些代码包括:桩代码(一些jump指令)和启动代码。

当静态链接执行完毕以后,链接器主要输出了可执行文件或者动态链接库文件,以及一些辅助性文件,如:符号文件(pdb),导入库文件(lib),导出表文件(exp)等。

3.2符号地址的演化

链接的目标是要处理好符号的虚拟内存地址。下面将要介绍在各个阶段内,符号的地址演化情况。

从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文件内,符号的虚拟内存地址是基于默认加载位置的。符号解析和地址重定位工作都是在该规则下进行的。

3.3转移指令

可以修改IP寄存器的内容,或者同时修改CS寄存器和IP寄存器的内容的指令统称为转译指令。IP寄存器中保存了当前被执行指令的下一条指令的地址;CS寄存器保存了当前内存段的地址(或者选择子)。

按照转移的距离来分,转移指令分为三种,分别是:

  • 短转移指令。只能在256字节范围了转移;
  • 近转移指令。可以在一个段范围内转移;
  • 远转移指令。可以在段间转移。

常用的转移指令包括:Jump指令,Call指令等。其中,Call指令没有短转移功能,只能实现近转移和远转移。在短转移指令和近转移指令中,其所包含的操作数都是相对于(E)IP的偏移,而远转移指令的操作数包含的是目标的绝对地址。因此,在短转移指令和近转移指令中,对于跳转同一目标地址的情况下,其操作数是不同的,而且应该不同,因为是相对的;而远转移指令包含的操作数是绝对地址,因此跳转到同一地址的机器码指令是相同的。

由于运行在32位windows下的应用程序是基于平坦内存管理模式的,也就是说,整个进程的虚拟地址空间被划分成一个段,该段的基地址是0x00000000H,大小是4GB。在这种情况下,所有的转移都是在一个段内进行的,所以无需考虑远转移指令。

使用Dumpbin工具将PE文件的内容导出为汇编格式,在该汇编格式的文件中,涉及到的转移指令,包括Jump指令和Call指令,均为近转移指令。即:段内转移。

转移指令的格式为:

call 操作数

Jump 操作数

操作数的计算公式为:

操作数 = 符号虚拟内存地址 – IP寄存器的内容

符号的虚拟内存地址为:被调用函数或其他被定义的符号在虚拟内存中的绝对地址;IP寄存器的内容为:当前被执行指令的下一条指令的虚拟内存地址。在32位环境中,指令占一个字节,操作数(即符号地址)占4个字节,一共5个字节。因此,IP寄存器内容的计算公式为:

IP寄存器的内容 = 转移指令的当前地址 - 5

具体情况如下图所示:

3.4目标文件之间的静态链接

使用Visual Studio建立C++项目以后,在该项目中可能会包含多个源文件,在编译阶段,每一个源文件都被编译成目标文件。在某一个目标文件中,可能引用了定义在其他目标文件中的符号,因此在静态链接阶段需要对这些外部符号进行解析。在这一节中,目标文件都是由程序员编写的C++代码编译生成的,而不是来自于某个静态链接库或者动态链接库。

在静态链接的时候,链接器的工作分两步进行,每步执行一次扫描,具体的操作流程如下图所示:

3.4.1建立全局符号表

Step1:扫描各个符号表。在执行该阶段的任务,扫描目标文件的时候,各个目标文件中所包含的符号表也一同被扫描。将这些属于各个目标文件的符号表合并到一起,形成一张全局符号表。

Step2:在目标文件所属的符号表中,由于各个符号还没有被分配虚拟内存地址,所以符号的值是中尚未包含符号的虚拟内存地址。这里所说的符号主要是指变量或者函数。当目标文件中各个符号的地址被确定以后,需要将各个符号的值更改成该符号被分配的虚拟内存地址。

Step3:合并同名符号的记录。在目标文件A中引用了定义在目标文件B中的符号C。那么在目标文件A中,符号表就会包含这样一条记录,该记录的符号名为C,符号的“StorageClass”属性为:External(全局符号),符号的“SectionNumber”属性为:UNDEF(未定义);符号的值不定;在目标文件B中,符号表也会包含一条名称为C的符号记录,该记录的“StorageClass”属性为:External(全局符号),“SectionNumber”属性为:SECTn(表示符号位于某各段内),符号的值为符号的虚拟内存地址。在执行链接的时候,需要将这两条记录合并为一条记录,并确定新记录在符号表中的索引。然后使用新记录的符号表索引去修正相关重定位表。因为重定位表引用了符号表的索引。

Step4:建立全局符号表。在全局符号表中,所有的符号都拥有正确的虚拟内存地址。所有的重定位表都引用了正确的符号表索引。在建立全局符号表的时候,每个目标文件中的全局符号都会被读入到该表中,然后以链表的形式将模块中定义或者引用了该全局符号的位置存储起来。当全局符号表建立完毕以后,在该表中,对于每一个符号都会有一个定义,0到多个引用

3.4.2建立段表

Step1:扫描各段信息。扫描所有参与链接的目标文件,确定各个段的大小,属性和位置。在每个目标文件的段表中,字段“VirtualSize”记录了该段被加载到内存以后所需要的内存空间的大小,段的大小是虚拟内存空间分配的依据;字段“Characteristics”记录了该段的属性。如:可读,可写,可执行,是代码段,还是数据段等。段的属性是段合并的依据。

     Step2:建立段表。在内存中为段表分配内存空间,然后将第一步获得的信息写入到内存中,形成段表,后续的段合并中将使用到段表。

3.4.3段合并

     Step1:扫描各目标文件。重新扫描各个目标文件,根据段表的信息,提取各段的内容。

     Step2:确定各段地址。根据段表中的信息,为提取到的各段分配虚拟内存地址,以及确定各段占用的内存空间大小。即:确定每个段的段首在内存中的可能加载位置(当然,这个位置在加载时可能会变)。

     Step3:确定段内地址。在目标文件中,各个段内的符号没有虚拟内存地址,只有相对于各个段首的文件偏移量。在链接阶段,当确定了各个段段首的虚拟内存地址以后,就可以根据符号的文件偏移量,计算出各段内符号的虚拟内存地址。符号的虚拟内存地址=段首虚拟内存地址+文件偏移量。

Step4:合并段并输出。将各个目标文件中的所有属性相同的段合并到一起,形成一个新段,并输出到一个新的文件中。这个文件将作为链接后的输出物,根据设定,可以是可执行文件,也可以是动态链接库等。在这里,合并的原则是属性相同,而不是逻辑相同。例如:所有的代码段被合并到一起,所有的数据段被合并到一起,所有的bss段被合并到一起。

    完成该阶段工作以后,所有目标文件中的内容都被合并到了一起,并且确定了符号的虚拟内存地址。如果该段拥有重定位表,那么重定位表的属性“VirtualAddress”的值也会被修正,使其指向正确的重定位位置。因为段的合并导致了段内符号的相对偏移量的变化,所以该值可能被修正。

3.4.5符号解析

Step1:扫描各段重定位表。经过前面的处理,所有的目标文件都已经被合并,并且将合并后的内容输出到一个新文件中,该文件将以PE格式存储。各个段的重定位表和新建立的全局符号表也存在于该文件中。链接器开始扫描重定位表,用来提供重定位信息。

Step2:确定重定位的位置。通过对重定位表的扫描,取得了重定位表中字段VirtualAddress的值。该值是一个内存地址,在该内存地址所指向的内存处存储了一个指令的操作数。该操作数一般为一个变量或函数的内存地址。表示这个指令要使用这个变量的值,或者执行函数调用。在编译阶段,由于这个操作数所代表的变量或函数被定义其他目标文件中,所以无法马上确定该操作数的正确值。在链接阶段,这个操作数是需要被修正的,该操作数所在的位置即为重定位的位置。在32位操作系统中,重定位的位置为4个字节。

Step3:取得重定位符号的地址类型。在重定位表中,需要被修正的函数或变量的地址有两种类型,即:相对地址和绝对地址。在重定位表中,使用字段Type存储该类型。在地址重定位的时候,对这两种类型的地址的处理方式是不同的。

Step4:处理相对地址。函数的虚拟内存地址的类型为相对地址,在进行符号解析和重定位的时候,需要在重定位的位置上填写4个字节的相对地址。相对地址的计算公式为:

相对地址 = 符号虚拟内存地址 – 指令虚拟内存地址 – 5

//该计算公式在32位模式下有效,具体解释见3.3节

编译C++源代码的时候,在debug模式中,采用了增量链接的方式,而在release模式中,采用了非增量链接的方式。在执行增量链接的情况下,在重定位的位置上,被填写的相对地址是相对于增量链接表中某个表项的相对地址,而不是被调用函数的相对地址;在非增量链接的情况下,在重定位的位置上,被填写的相对地址是相对于被调用函数的相对地址。将在3.7节详细介绍增量链接的概念。

Step5:处理绝对地址。变量的虚拟内存地址的类型为绝对地址,在进行符号解析和重定位的时候,需要在重定位的位置上填写4个字节的变量的虚拟内存地址。该地址值为变量的真实的虚拟内存地址。

关于地址计算部分,参见3.8的示例。

3.4.6其他工作

    其他部分的工作包括:向PE文件中写入头部信息。包括:DOS头,PE头等信息;向PE文件中写入一些代码,包括桩代码和库的启动代码等,主要用于动态链接库;另外,根据链接的配置,还可能要进行一些文件的输出,比如:map文件,符号表文件等。

3.5目标文件与导入库之间的静态链接

3.5.1概述

该阶段的工作是执行动态链接的准备工作,动态链接是相对于静态链接而言的。所谓静态链接是指把要调用的函数或者过程链接到可执行文件中,成为可执行文件的一部分。换句话说,函数和过程的代码就在程序的exe文件中,该文件包含了运行时所需的全部代码。当多个程序都调用相同函数时,内存中就会存在这个函数的多个拷贝,这样就浪费了宝贵的内存资源。

在动态链接中,被调用的函数代码没有被拷贝到应用程序的可执行文件中,而仅仅是在其中加入了所调用函数的描述信息(往往是一些重定位信息)。当应用程序被装入内存开始运行的时候,在Windows的管理下,建立起了应用程序与相应动态链接库之间的关系。当要执行所调用的DLL中的函数时,根据链接产生的重定位信息,Windows才转去执行DLL中相应的函数代码。一般情况下,如果在一个应用程序中使用了动态链接库,那么Win32系统保证内存中只有DLL的一份复制品。

动态链接的整个过程可分为两步:编译时的静态链接,以及加载运行时的动态链接。这部分静态链接的工作是为后续的动态链接所做的准备。即:在静态链接过程中生成的数据结构,如导入表,导出表等,都将被加载器用来执行动态链接。整个过程的详细情况如下图所示:

在静态链接阶段,链接器除了要执行如3.4节所描述的目标文件之间的静态链接的工作外,为了处理应用程序对动态链接库中符号的引用,在进行两遍扫描的时候,链接器还需要做其他的额外工作,这些工作主要包括:

  • 在动态链接库所属的导入库的支持下,生成可执行文件的导入表;
  • 根据生成导入表的内容和符号表的内容,解析外部符号。这些符号在可执行程序中使用,而在动态链接库中定义。

在静态链接阶段,动态链接库文件本身并不参与链接,参与链接的是与动态链接库相对应的导入库文件。导入库文件伴随动态链接库文件的生成而生成。

要使用动态链接库,首先涉及到的是动态链接库的创建,然后才会涉及到对动态链接库的使用。整个动态链接库的创建工作是在编译与静态链接下完成的;而对动态链接库的使用则涉及到两个过程:静态链接下的数据准备工作,以及加载时外部符号的解析工作。关于动态链接的过程将在“动态链接”相关的章节讲述,这里主要描述静态链接。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值