3.5.2动态链接库的创建
3.5.2.1动态链接库的创建流程
动态链接库的创建流程如下图所示:
在系统设计阶段,主要的设计内容包括:类结构的设计以及功能类之间的关系,动态链接库的接口。在动态链接库中,包含两类函数:一类是内部函数,一类是外部函数。内部函数只能在动态链接库的内部使用,不能被动态链接库以外的模块调用;外部函数是该动态链接库的接口,可以被外部模块调用。
为了使外部函数能够被系统外的模块调用,在进行C++代码编写的时候,必须对外部函数执行导出。导出的级别有两种:函数级别的导出和类级别的导出。在函数级别的导出中,只将该函数导出;在类级别的导出中,将这个类所属的函数和数据导出。在进行导出的时候,使用关键字“_declspec(dllexport)”。
如果外部模块要调用动态链接库中的函数,那么必须对该函数执行导入。导入的级别有两种:函数级别的导入和类级别的导入。在函数级别的导入中,只能将该函数导入;在类级别的导入中,可以将整个类所属的函数和数据导入,在进行导入的时候,使用关键字“_declspec(dllimport)”。
在使用Visual Studio建立动态链接库的时候,首先是创建工程项目,并且选择项目类型为动态链接库类型,即:Application type的DLL选项。Static Library表示创建静态链接库,Windows application表示创建到窗口的可执行程序,Console application表示创建带命令行的可执行程序。具体情况如下图所示:
建立完毕工程项目以后,向工程项目中添加各个类的头文件,以及源文件,开始各个功能类的编写。在执行函数级别的导出的时候,具体的C++代码样式如下:
#ifndef _DemoMath_H #define _DemoMatn_H class DemoOutPut; class DemoDLL_Export DemoMath { public: DemoMath(); ~DemoMath(); _declspec(dllexport) void AddData(double a,double b); //成员函数AddData被导出 _declspec(dllexport) void SubData(double a,double b);//成员函数SubData被导出 void MulData(double a,double b); //成员函数没有被导出,不能被该dll之外的函数调用 void DivData(double a,double b); void Area(double r); private: DemoOutPut * m_pOutPut; }; #endif |
执行类级别导出的时候,具体的C++代码的样式如下:
#ifndef _DemoMath_H #define _DemoMatn_H class DemoOutPut; class _declspec(dllexport) DemoMath //将整个类导出。类中所有的函数均可被外部模块调用 { public: DemoMath(); ~DemoMath(); void AddData(double a,double b); void SubData(double a,double b); void MulData(double a,double b); void DivData(double a,double b); void Area(double r); private: DemoOutPut * m_pOutPut; }; #endif |
为了方便对导入,导出的管理,一般会将导入,导出的信息定义到一个头文件中,在需要进行导入,导出的时候,将这个头文件引入即可。
这个头文件包含了导入和导出两方面的功能,在使用该头文件之前定义宏DeMODLL_EXPORTS,则执行导出功能;如果没有定义该宏,则执行导入功能。具体的定义内容如下:
#ifndef _DemoDef_H #define _DemoDef_H //定义函数的导入,导出 #ifdef DEMODLL_EXPORTS #define DemoDLL_Export _declspec(dllexport) #else #define DemoDLL_Export _declspec(dllimport) #endif #endif |
在类级别的导出中,该头文件的使用方式描述如下:
#ifndef _DemoMath_H #define _DemoMatn_H #include "DemoDef.h" //引入头文件 class DemoOutPut; class DemoDLL_Export DemoMath //类级别导出,使用该标记前,必须定义宏:DEMODLL_EXPORTS { public: DemoMath(); ~DemoMath(); void AddData(double a,double b); void SubData(double a,double b); void MulData(double a,double b); void DivData(double a,double b); void Area(double r); private: DemoOutPut * m_pOutPut; }; #endif |
在执行导出的时候,在使用头文件DemoDef.h之前,必须定义DemoDLL_EEPORTS。有两种方式定义该宏,一种方式是直接手工填写代码,如:
#ifndef _DemoMath_H #define _DemoMatn_H #define DEMODLL_EXPORTS //手工定义宏,必须位于include “DemoDef.h” 之前 #include “DemoDef.h” Class ... #endif |
另外一种方式是在项目属性中定义该宏,如下图所示:
打开工程项目属性窗口,在C/C++标签的Preprocessor Definitions项目中,添加宏定义即可。
3.5.2.2编译动态链接库
当动态链接库的代码编写完毕以后,就可以通过编译、链接来生成该动态链接库,在执行编译、链接的时候,具体的输入,输出情况如下图所示:
如果在创建该动态链接库的时候引用了第三方静态链接库中的函数,那么在链接的时候,需要将该静态链接库中相关的函数合并到输出的PE文件中;如果在创建该动态链接库的时候引用了另外的动态链接库,那么在执行链接的时候,被引用动态链接库的导入库文件需要参与链接;目标文件中包含的内容是整个动态链接库的核心内容,被引入的第三方库,无论是静态链接库,还是动态链接库,都是为目标文件中的功能代码提供支持的;另外,在链接的时候,也可以提供链接空间文件Def,在该文件中定义了链接选项的各个方面。
编译、链接执行完毕以后,在默认情况下,链接器除了输出PE文件以外,还会输出.exp文件,.lib导入库文件,以及符号文件.pdb。通过链接配置,还可以输出其他功能的文件,如:.map文件。
被输出的PE文件有两种类型,可执行文件和动态链接库文件。输出的类型可以在创建项目的时候,在application type栏选择。
3.5.2.3导出表的创建
对于一个动态链接库来说,至少要存在一个导出函数。一般情况下,在一个动态链接库中,会存在导出若干个函数。这些被导出函数的信息被存放到导出表中。当编译、链接完成以后,导出表中的信息被保存到PE文件中。
导出表由一个被数据目录所指向的数据结构IMAGE_EXPORT_DIRECTORY开始,并关联到三个数组,这三个数组分别存储被导出函数的地址,被导出函数的名称,以及被导出函数名称与序号之间的关系。在程序加载的时候,导出表中的信息被加载器用来执行动态链接。
链接器生成导出表的过程如下图所示:
链接器在执行第一遍扫描的时候,会以各个目标文件为输入。在扫描的过程中,链接器收集导出函数的信息,然后将这些信息写入到一个临时文件中。该临时文件以.exp为扩展名,但实际上它是一个COFF格式的文件,该文件格式与目标文件是一致的。在.exp文件中,链接器创建了导出表“.edata”。
链接器在进行第二次扫描的时候,会将各个目标文件和第一扫描生成的.exp文件链接到一起。将.exp文件中的导出表的信息提取出来,并写入到PE文件中。在PE文件中,导出表不会单独存在,它一般会被合并到.rdata节,该节存储只读数据。
当链接执行完毕以后,.exp文件的使命结束,在后续的工作中,一般不会使用到它。
3.5.2.4导入库
在编译、链接动态链接库的时候,另一个重要的输出物就是导入库,导入库的扩展名是.lib。该扩展名与静态链接库的扩展名一致。但是,导入库与静态链接库是不同的。
静态链接库是一系列目标文件的集合,这些目标文件以特定的格式打包、压缩在静态链接库中。当执行静态链接的时候,静态链接库中的相关代码和数据需要被复制到要生成的PE文件中。程序运行的时候不需要静态链接库参与。
导入库中存储的不是动态链接库的代码和数据,而是动态链接库中被导出函数的描述信息,每一个动态链接库都会对应一个导入库。在链接阶段,导入库参与链接;在程序运行阶段,动态链接库参与运行。
当在一个目标文件中引用了一个动态链接库中函数的时候,在编译阶段,就需要将该目标文件和被引用的动态链接库的导入库一起链接。在导入库的支持下,将会生成PE文件的导入表。实际上,PE文件的导入表就是由多个导入库中的信息合并到一起而生成的。
在导入库中,主要包含如下内容:
在导入库中,主要包含了三部分内容,分别是:桩代码,启动代码,以及导出函数的符号。在执行链接的时候,启动代码,桩代码要被合并到输出的PE文件中;而被导出的函数的符号则以两种形式提供,用于支持在PE文件中导入表的建立,以及对外部符号的解析工作。
桩代码包含了一系列jump指令,每条jump指令都会跳转到导出函数的地址处,用于支持函数调用。桩代码,导出函数的符号(两种形式),以及导出函数的地址之间的关系如下图所示:
在导入库中,被导出的函数名称以两种方式提供,方式一:_imp_函数名 + 修饰;方式二:函数名 + 修饰。例如上图中的_imp_Fun1和Fun1形式。
使用工具dumpbin导出DemoDlld.lib的内容,在该内容中,一个符号导出了两种形式,具体情况如下:
Dump of file demodlld.lib File Type: LIBRARY Archive member name at 8: / 51C17BC5 time/date Wed Jun 19 17:37:09 2013 uid gid 0 mode 2A2 size correct header end 21 public symbols 5CE __IMPORT_DESCRIPTOR_DemoDLLd 7FC __NULL_IMPORT_DESCRIPTOR 934 DemoDLLd_NULL_THUNK_DATA B6C ??4DemoMath@@QAEAAV0@ABV0@@Z B6C __imp_??4DemoMath@@QAEAAV0@ABV0@@Z D50 ?GetOperTimes@@YAHXZ D50 __imp_?GetOperTimes@@YAHXZ A88 ??0DemoMath@@QAE@XZ A88 __imp_??0DemoMath@@QAE@XZ AFA ??1DemoMath@@QAE@XZ AFA __imp_??1DemoMath@@QAE@XZ BE6 ?AddData@DemoMath@@QAEXNN@Z BE6 __imp_?AddData@DemoMath@@QAEXNN@Z E3C ?SubData@DemoMath@@QAEXNN@Z E3C __imp_?SubData@DemoMath@@QAEXNN@Z DC2 ?MulData@DemoMath@@QAEXNN@Z DC2 __imp_?MulData@DemoMath@@QAEXNN@Z CD6 ?DivData@DemoMath@@QAEXNN@Z CD6 __imp_?DivData@DemoMath@@QAEXNN@Z C60 ?Area@DemoMath@@QAEXN@Z C60 __imp_?Area@DemoMath@@QAEXN@Z |
在方式二中,每一个函数名称都会对应一个jump指令,该函数名称的地址是jump指令的入口。通过jump指令,可以跳转到方式一形式的函数名称处。
在方式一形式的函数名称处,每一个函数名称都会对应到动态链接库中的一个函数的地址。
在使用该动态链接库的时候,可以通过两种方式来调用该动态链接库中的函数,具体情况如下:
方式一: Call _imp_Fun //高效方式,需要关键字_declspec(dllimport)支持。 方式二: Call Fun //低效方式,执行了额外的jump指令跳转 Fun: Jump _imp_Fun |
3.5.2.5动态链接库的发布
在发布动态链接库的时候,要包含如下文件:
- 头文件。头文件中定义了动态链接库的接口,在编码阶段中,在使用动态链接库的时候,其他模块需要引入动态链接库的头文件;
- 导入库文件。导入库文件包含了动态链接库的导出函数的信息,在执行链接的时候,链接器需要导入库中的信息,为PE文件建立导入表,以及执行符号解析;
- 动态链接库文件。动态链接库文件中包含了函数和数据,在程序执行阶段,动态链接库文件要被加载到内存,并且和调用该动态链接库文件的可执行程序执行动态链接。
3.5.3动态链接库的使用
3.5.3.1在编码阶段对动态链接库头文件的使用
在发布动态链接库的时候,动态链接库的发布者需要同时发布该动态链接库的头文件。在编码阶段,当需要调用动态链接库中的方法的时候,程序员需要使用#include命令将该动态链接库的头文件引入。
在使用动态链接库中的函数的时候,可以有两种方式。一种方式是:使用关键字_declspec(dllimport)将需要的函数显式地导入,这些被导入的函数必须位于动态链接库被导出函数的集合中;另外一种方式是:直接使用动态链接库中被导出的函数,不做任何显式地导入。
使用_declspec(dllimport)导入动态链接库中的函数的时候,将会使用高效地函数调用方式,而不使用_declspec(dllimport)导入动态链接库中的函数的时候,将会使用低效地函数调用方式。
当使用关键字_declspec(dllimport)修饰被调用函数名称的时候,在编译阶段,函数的名称将被处理成“_imp_函数名称 + 修饰”的形式。当执行静态链接的时候,Call指令后面的操作数被解析成被调用函数的地址。因此,通过一次对Call指令的执行,就可以完成对动态链接库中被调用函数的调用。
如果不使用关键字_declspec(dllimport)修饰被调用函数的名称,那么在编译阶段,函数的名称将被处理成“函数名称 + 修饰”的形式。当执行静态链接的时候,导入库中的桩代码被合并到PE文件中。而Call指令后面的操作数被解析成一段桩代码的地址,在这段桩代码中,通过jump指令才能跳转到被调用函数的地址处。具体情况可参见3.5.2.4节的描述。因此,通过执行两步指令才能完成对动态链接库中函数的调用。
为了实习那高效地函数调用,在使用动态链接库中的导出函数的时候,需要明确地将这些函数导入。如3.5.2.1节描述的那样,首先将导入、导出的信息定义到一个头文件中,在实现动态链接库的时候,将这个包含定义信息的头文件引入。在实现动态链接库,在执行函数导出的时候,需要特别定一个宏标记;在使用被发布的动态链接库的时候,需要明确地执行函数的导入,这时候,只需要引入随该动态链接库一起发布的头文件即可,不需要做任何宏定义。
3.5.3.2在静态链接阶段对动态链接库的导入库的处理
在静态链接阶段,链接器引入动态链接库的导入库,并执行链接的过程如下图所示:
从上图可以看出,在执行静态链接的时候,输入的数据为多个目标文件和多个导入库(如果该模块调用了多个动态链接库中的函数),链接器经过处理以后,会输出PE格式的文件(可以是可执行文件或者动态链接库),同时还可能会输出一些其他用途的文件。
链接器在执行静态链接的时候,经历了两次扫描的过程。除了要处理目标文件之间的链接问题外,如果目标文件引用了其他动态链接库中的函数,那么链接器还需要进行额外的处理工作,这些工作主要是:导入表的建立,代码的加入,以及外部符号解析。这些被链接器额外加入到PE文件中的代码来自于导入库中,主要是桩代码,以及动态链接库启动的代码。
导入表的建立过程如下图所示:
PE文件的导入表是一个数组,数组元素的类型是IMAGE_IMPORT_DESCRIPTOR类型的数据结构。该数据结构有两个指针,分别指向导入地址表(IAT)和导入名称表(INT)这两个数组。在链接阶段,这两个数组中存储的都是符号的名称信息。
对于导入表数组中,每一个数组元素都会对应一个动态链接库的信息。具体的情况是,在扫描的时候,用导入库中的信息去填写IMAGE_IMPORT_DESCRIPTOR结构体中的字段。如:动态链接库的名称,创建时间等。
在导入库中,包含一些命名为.idata$4,.idata$5形式的节,这些节中包含的信息就是动态链接库中被导出的函数的信息,如名称,地址等。在生成IAT,以及INT数组的时候,这些节中的数据要被合并到IAT或者INT数组中来。
在当前模块中被引用,而定义在动态链接库中的符号,相对与当前模块来说,它是外部符号。外部符号的解析过程如下图所示:
在上图中,通过重定位表和全局符号表可以查找到外部符号,以及外部符号出现在代码中的位置。存在于这些位置上的指令的格式为:
Call dword ptr[xxxx] //xxxx是内存中的某个地址的值。 dword ptr[xxxx]表示取内存中某个地址开始处的内容,大小4字节。该地址由“xxxx”这个操作数指定。 |
在编译阶段,由于是外部符号,在目标文件中,占位于“xxxx”的位置上的操作数的值未知;在静态链接阶段,需要修正xxxx的值,使之与动态链接库中被调用的函数建立关系即:使Call指令直接地或者间接地指向IAT数组中的某各位置。在程序加载到内存的过程中,加载器会将IAT数组中的函数名称更改成动态链接库中相关函数的地址,这时候才真正完成了动态链接。
如果函数的名称是“函数名+修饰”的形式(该函数没有使用_declspec(dllimport)导入),那么在静态链接的时候,xxxx被解析成桩代码中某个jump指令的地址。如:Fun1的地址,或者Fun2的地址等;如果函数的名称是“_imp_函数名+修饰”的形式(该函数使用了_declspec(dllimport)导入),那么在静态链接的时候,xxxx被解析成IAT数组中某个数组元素的地址。在当前阶段,该数组元素中存储的是对应函数的函数名称。
经过这些处理,Call指令后面的地址被修正正确,它直接地或者间接地指向了IAT数组中某各数组元素的位置,该数组元素存储的是动态链接库中被调用函数的信息。在静态链接完成以后,这里存储的还是函数的名称,因此在执行程序的时候,是无法正确跳转到动态链接库中被调用函数的位置。这个问题将在程序加载时,由动态链接来解决。具体情况,参见第四章。
3.6目标文件与静态链接库之间的静态链接
目标文件与静态链接库之间的静态链接过程与目标文件之间的静态链接过程类似。静态链接库也是由一系列的目标文件组成的,在执行静态链接的时候,静态链接库中的相关目标文件会被拷贝到输出的PE文件中。
3.7增量链接
3.7.1代码调试的流程
一般情况下,程序员在调试C++程序的时候,其操作流程如下图所示:
由上图可以看出,代码调试的过程是一个不断循环,迭代的过程。试想一下,如果当前被调试的C++程序是一个很庞大的C++程序,它包含相当多的源文件,比如:上千个源文件。那么执行第二步和第三步的时候,将会耗费相当多的时间,而程序员所关心的是第六步和第八步。为了解决这个问题,在Visual Studio中引入了“Edit and Continue”功能。
在使用“Edit and Continue”功能的时候,程序员的调试流程有所更改,具体的流程过程如下图所示:
在该流程中,初次启动调试的时候,其操作过程与非“Edit and Continue”模式下是一致的,都需要设定断点,启动调试(如果需要编译,则执行之),以及程序的加载和运行。当程序运行的设定的断点后,程序员开始单步执行程序代码,并跟踪各个变量的状态直到发现代码错误。
在非“Edit and Continue”模式下,程序员需要关闭调试,然后修改代码错误,重新编译源代码,然后再次启动调试。在代码量小,程序加载和运行所需要的时间少的时候,是可以这么做的;
在“Edit and Continue”模式下,当程序员发现代码错误的时候,不需要关闭当前调试。在开启调试的情况下,程序员直接更改错误代码,更改完毕以后,设定新的断点,然后按F10键继续执行程序。在上图中,蓝色部分描述了“Edit and Continue”模式下程序员的调试流程。在程序员修改错误代码的时候,该错误代码必须位于当前运行点之下。具体情况如下图所示:
在上图中,当前运行点是代码“nOpertimes++”所在的位置,新修改的代码必须位于该行代码之下。
为了使用“Edit and Continue”功能,必须在项目的属性窗口中进行设定,具体情况如下图所示:
在“C/C++”分组的“General”标签中,需要将“Debug Information Format”选项的值设定为:“Program Database for Edit & Continue(/ZI)”。
当程序员按下F10键以后,为了支持“Edit and Continue”功能,编译器开始执行增量链接。为了使用增量链接功能,必须在项目的属性窗口中进行设定,具体情况如下图所示:
在“Linker”分组的“General”标签中,需要将“Enable Incremental Linking”的值设定为:Yes(/INCREMENTAL)。设定该值后,在执行编译的时候,编译器将开启增量链接功能。
3.7.2增量链接的原理
3.7.2.1场景描述
在C++代码中,我们实现了两个函数:Fun1和Fun2。在将C++源文件编译成PE文件以后,我们假设这两个函数被紧挨着放到了一起。Fun1被放到了地址:0x40002000处,Fun1的大小为0x100。那么Fun2的地址就应该是:0x40002000 + 0x100 = 0x40002100。
在程序员调试C++代码的时候,需要不断地修改代码,并执行编译,链接。当程序员将Fun1函数的内容修改以后,函数的大小发生了变化,比如变化为0x200。在这种情况下,由于两个函数被紧紧地放在了一起,那么Fun2的地址就要被被向后推移0x100,变成了0x400022000。在这种情况下,一般的解决思路是:重新洗牌,将现有的编译好的exe删除,然后重新编译源文件,执行链接。在这个过程中,需要重新布局所有的函数,重新生成全局符号表,重新执行符号解析和重定位工作…。对于大型的软件项目来说,这个过程是漫长而痛苦的。在极端情况下,程序员因为修改了一小段代码,就必须要等待一个漫长的编译、链接过程。
为了解决这个问题,visual Stuio在debug模式下,使用了增量链接的功能。在使用了该功能以后,我们获得了更快的编译速度,由此带来的副作用是:被编译出来的PE文件更加庞大,该PE文件的运行效率更加低下。由于debug模式下编译出来的程序是供程序员调试使用的,而不是提供给最终用户的,所以我们可以忽略这些副作用。
在Release模式下,由于没有使用增量链接,所以在被编译出来的PE中,其内容更加紧凑,其运行效率更加高效。
3.7.2.2 Padding 以及.textbss段
当一个函数被修改以后,由于其大小发生了变化,所以可能会引起其他函数入口地址的更改。在增量链接模式下,为了避免由于一个函数大小的变化而影响到其他函数的入口地址的问题。采用了两种解决方式:在函数间以及段间填充二进制数据“0xcc”,以及使用.textbss段。
在函数间以及段间填充二进制数据“0xcc”方式
该方式主要是为了加快编译速度。在该方式下,链接器不会将各个函数紧凑地存放在一起,而是在各个函数之间填充一定数量的二进制数据“0xcc”。在各个段之间,比如:.text段和.data段之间,填充大量的二进制数据“0xcc”。该二进制数据代表汇编指令:INT 3。该windows下,执行该指令会导致异常,从而中断程序的运行。这是出于安全方面的考虑,由于一些原因,使程序执行到了填充空间中,由于INT 3指令的存在,程序被终止运行。
在修改函数的时候,当函数的大小发生较小的变化的时候,将会在函数间被填充的空间之中,为更改后的函数分配新空间。因此,各个函数的入口地址都不会发生变化,只是被填充的空间的大小发生了变化。在编译的时候,只需要编译发生变化的源文件,由于函数地址均未发生变化,所以也不需要重新执行地址解析和重定位工作。因此加快了编译速度。
在修改函数的时候,当函数的大小发生较大变化的时候,即:函数间被填充的空间不足以支持被更改函数的大小。在这种情况下,使用段间填充的空间为新函数分配空间。这时候,新函数的入口地址放生变化。将会使用增量链接表处理函数入口地址发生变化的问题。
使用dumpbin工具解析debug模式下编译出来的PE文件,其部分内容如下:
10011490: CC CC CC CC CC CC CC CC CC CC CC CC CC CC CC CC ìììììììììììììììì 100114A0: CC CC CC CC CC CC CC CC CC CC CC CC CC CC CC CC ìììììììììììììììì 100114B0: CC CC CC CC CC CC CC CC CC CC CC CC CC CC CC CC ìììììììììììììììì 100114C0: CC CC CC CC CC CC CC CC CC CC CC CC CC CC CC CC ìììììììììììììììì 100114D0: CC CC CC CC CC CC CC CC CC CC CC CC CC CC CC CC ìììììììììììììììì 100114E0: 55 8B EC 81 EC CC 00 00 00 53 56 57 51 8D BD 34 U.ì.ìì...SVWQ.?4 100114F0: FF FF FF B9 33 00 00 00 B8 CC CC CC CC F3 AB 59 ???13...?ììììó?Y 10011500: 89 4D F8 8B 45 08 8B 08 8B 55 F8 89 0A 8B 45 F8 .M?.E....U?...E? 10011510: 5F 5E 5B 8B E5 5D C2 04 00 CC CC CC CC CC CC CC _^[.?]?..ììììììì 10011520: CC CC CC CC CC CC CC CC CC CC CC CC CC CC CC CC ìììììììììììììììì 10011530: 55 8B EC 81 EC C0 00 00 00 53 56 57 8D BD 40 FF U.ì.ìà...SVW.?@? 10011540: FF FF B9 30 00 00 00 B8 CC CC CC CC F3 AB A1 40 ??10...?ììììó??@ 10011550: 91 01 10 5F 5E 5B 8B E5 5D C3 CC CC CC CC CC CC ..._^[.?]?ìììììì 10011560: CC CC CC CC CC CC CC CC CC CC CC CC CC CC CC CC ìììììììììììììììì 10011570: 55 8B EC 6A FF 68 AE 56 01 10 64 A1 00 00 00 00 U.ìj?h?V..d?.... 10011580: 50 81 EC E8 00 00 00 53 56 57 51 8D BD 0C FF FF P.ìè...SVWQ.?.?? 10011590: FF B9 3A 00 00 00 B8 CC CC CC CC F3 AB 59 A1 04 ?1:...?ììììó?Y?. 100115A0: 90 01 10 33 C5 50 8D 45 F4 64 A3 00 00 00 00 89 ...3?P.E?d£..... 100115B0: 4D EC 6A 01 E8 4A FC FF FF 83 C4 04 89 85 20 FF Mìj.èJü??.?... ? 100115C0: FF FF C7 45 FC 00 00 00 00 83 BD 20 FF FF FF 00 ???Eü.....? ???. 100115D0: 74 13 8B 8D 20 FF FF FF E8 C7 FB FF FF 89 85 0C t... ???è????... 100115E0: FF FF FF EB 0A C7 85 0C FF FF FF 00 00 00 00 8B ????.?..???..... 100115F0: 85 0C FF FF FF 89 85 14 FF FF FF C7 45 FC FF FF ..???...????Eü?? 10011600: FF FF 8B 4D EC 8B 95 14 FF FF FF 89 11 8B 45 EC ??.Mì...???...Eì 10011610: 8B 4D F4 64 89 0D 00 00 00 00 59 5F 5E 5B 81 C4 .M?d......Y_^[.? 10011620: F4 00 00 00 3B EC E8 92 FB FF FF 8B E5 5D C3 CC ?...;ìè.???.?]?ì |
在上面的代码示例中,红色的部分表示的是被填充的二进制数据。
使用.textbss段方式
该方式是为了实现“Edit and Continue”功能。.textbss段只存在于debug模式下生成的PE文件中,用于存放程序代码的二进制数据。在PE文件中,该段只是一个逻辑概念,它不占用文件中的存储空间;在程序加载运行的时候,在进程的虚拟地址空间中,需要为该段分配地址空间。
在调试阶段,“Edit and Continue”模式下,当程序员修改了某个函数并按F10键继续执行该程序以后,编译器开始执行增量链接。在该情况下,链接器会将更改后的函数的二进制代码存放到内存中为.textbss段分配的地址空间中。因此该函数的入口地址放生了变化。将会使用增量链接表处理函数入口地址发生变化的问题。这些过程都是在程序运行阶段发生的。
3.7.2.3增量链接表(ITL)
无论是使用段间填充的方式,还是使用.textbss段的方式,函数的入口地址都发生了变化。因此,在执行链接的时候,对于每一处调用该函数的地方,都需要修正该函数的地址值。这必然会影响程序的编译速度,为了解决这个问题,引入了增量链接表的概念。
增量链接表存在于debug模式下生成的PE文件中,位于.text段。该表中存储一系列jump指令。每个指令后面都会跟随一个函数的相对地址。使用dumpbin工具解析debug模式下生成的PE文件以后,增量链接表的部分内容如下所示:
10011000: CC CC CC CC CC E9 76 08 00 00 E9 9B 36 00 00 E9 ......v.....6... @ILT+11(_DebugBreak@0): 10011010: EA 36 00 00 E9 A7 1E 00 00 E9 08 2C 00 00 E9 BD .6.........,.... @ILT+27(??4DemoMath@@QAEAAV0@ABV0@@Z): 10011020: 04 00 00 E9 68 15 00 00 E9 C5 36 00 00 E9 3E 13 ....h.....6...>. @ILT+43(??Bsentry@?$basic_ostream@DU?$char_traits@D@std@@@std@@QBE_NXZ): 10011030: 00 00 E9 B9 2B 00 00 E9 C8 36 00 00 E9 BF 2F 00 ....+....6..../. @ILT+59(_DllMain@12): 10011040: 00 E9 1A 16 00 00 E9 35 1F 00 00 E9 60 1E 00 00 .......5....`... |
在上面的代码中,如:“E9 B9 2B 00 00”,表示执行近跳转,要跳转的地址是:00002bb9,这是一个相对地址;E9是汇编指令jump的机器码。
在未引入增量链接表之前,在Call指令中是直接调用函数的。函数调用的指令格式描述如下:
Call foo。//Foo为被调用函数的相对地址。 |
在引入了增量链接表之后,通过增量链接表间接调用函数。函数调用的指令格式描述如下:
Call foo_stub //foo_stub为增量链接表中某一个表项的相对地址 Foo_stub: Jump foo //foo为被调用函数的相对地址。 |
由此可以看出,在引入了增量链接表以后,Call指令会调用增量链接表中的某一个表项,在增量链接表的表项中,再通过jump指令跳转到该函数的真正位置。在引入了增量链接表以后,当函数的入口地址放生变化时,只需要修改jump指令后面的地址数据,而不是修改每一个函数调用处的地址数据。通过这种方式,将需要修改n处代码位置的情况,简化为只需要修改一处代码位置。
3.7.2.4增量链接的流程
在“Edit and Continue”模式下,执行增量链接的流程如下图所示:
首先需要编译被更改过的源文件,然后将被更改后的函数的二进制代码存储到.textbss段所在的虚拟内存的地址空间中,最后还需要修改增量链接表中的某个表项的数据。
在“Edit and Continue”模式下,由于被调试程序还处于运行状态。因此,当修改完毕增量链接表的表项以后,还需要检查所有线程的TIB,如果该线程的EIP还指向老的函数的地址(函数被修改前该函数的地址),就需要将该地址修正为新函数的地址。
以上所有的操作均是对被调试程序内存的操作,包括对内存数据的读取和修改。而完成这项工作的,是Visual Studio调试器。
3.8目标文件之间的静态链接示例分析
3.8.1场景描述
在1.2.1C++源代码示例中,类DemoMath中的成员函数AddData调用了全局变量nOpertimes,以及类DemoOutPut中的成员函数OutPutInfo。具体代码格式如下:
void DemoMath::AddData(double a, double b) { nOperTimes++; m_pOutPut->OutPutInfo(a + b); } |
在执行编译,链接的时候,在DemoMath.cpp生成的目标文件DemoMath.obj中,全局变量nOpertimes的地址需要执行重定位操作;相对于目标文件DemoMath.obj,目标文件DemoOutPut.obj中定义的符号OutPutInfo是外部符号,它也需要被解析和重定位。
在地址重定位的时候,变量的地址是绝对类型,函数的地址是相对类型。
3.8.2输出的文件
3.8.2.1目标文件的输出
在编译阶段,编译器将C++源文件编译成目标文件。使用工具dumpbin将目标文件DemoMath.obj中关于函数AddData所在的代码段的内容导出,该内容包括:摘要信息,二进制代码,以及符号表,具体内容如下:
//这是代码段关于AddData函数部分的摘要信息 SECTION HEADER #14 //段名称 .text name 0 physical address //物理地址,尚未分配,应该为零 0 virtual address //虚拟地址,尚未分配,应该为零 5C size of raw data //该段二进制数据的大小 1EB2 file pointer to raw data (00001EB2 to 00001F0D) //该段距离文件首位置的偏移 1F0E file pointer to relocation table //重定位表距离文件首位置的偏移 0 file pointer to line numbers //行号表的位置,为零表示没有行号表 4 number of relocations //重定位表中元素的个数 0 number of line numbers 60501020 flags //标记 Code //表示该段为代码 COMDAT; sym= "public: void __thiscall DemoMath::AddData(double,double)" (?AddData@DemoMath@@QAEXNN@Z) 16 byte align //16字节对齐 Execute Read //可执行,可读 //以下内容为代码段关于AddData函数的二进制内容 RAW DATA #14 相对于代码段偏移量 二进制内容 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 A1 00 00 00 00 83 C0 01 A3 00 00 00 00 .M??.....à.£.... 00000030: DD 45 08 DC 45 10 83 EC 08 DD 1C 24 8B 45 F8 8B YE.üE..ì.Y.$.E?. 00000040: 08 E8 00 00 00 00 5F 5E 5B 81 C4 CC 00 00 00 3B .è...._^[.?ì...; 00000050: EC E8 00 00 00 00 8B E5 5D C2 10 00 ìè.....?]?.. //以下为重定位表的内容。从左到右,各字段的含义是: //offset 需要重定位的位置。这些位置位于“RAW DATA #14”所描述的二进制代码中,红色显示部分。 //Type 重定位的类型。Dir32表示绝对定位;Rel32表示相对定位 //Applied To 未知 //Symbol Index 需要重定位的符号在符号表中的索引。通过此字段,将符号表和重定位表关联 //Symbol Name 符号名称。 RELOCATIONS #14 Symbol Symbol Offset Type Applied To Index Name -------- ---------------- ----------------- -------- ------ 00000024 DIR32 00000000 F ?nOperTimes@@3HA (int nOperTimes) 0000002C DIR32 00000000 F ?nOperTimes@@3HA (int nOperTimes) 00000042 REL32 00000000 59 ?OutPutInfo@DemoOutPut@@QAEXN@Z (public: void __thiscall DemoOutPut::OutPutInfo(double)) 00000052 REL32 00000000 3F __RTC_CheckEsp |
(表一)
在上面被导出的内容中,二进制代码不容易阅读,所以将其内容转换为汇编格式,具体内容如下:
?AddData@DemoMath@@QAEXNN@Z (public: void __thiscall DemoMath::AddData(double,double)): 00000000: 55 push ebp 00000001: 8B EC mov ebp,esp 00000003: 81 EC CC 00 00 00 sub esp,0CCh 00000009: 53 push ebx 0000000A: 56 push esi 0000000B: 57 push edi 0000000C: 51 push ecx 0000000D: 8D BD 34 FF FF FF lea edi,[ebp-0CCh] 00000013: B9 33 00 00 00 mov ecx,33h 00000018: B8 CC CC CC CC mov eax,0CCCCCCCCh 0000001D: F3 AB rep stos dword ptr es:[edi] 0000001F: 59 pop ecx 00000020: 89 4D F8 mov dword ptr [ebp-8],ecx //此处引用了全局变量nOperTimes。该符号尚未解析,地址暂时用零填充 00000023: A1 00 00 00 00 mov eax,dword ptr [?nOperTimes@@3HA] 00000028: 83 C0 01 add eax,1 0000002B: A3 00 00 00 00 mov dword ptr [?nOperTimes@@3HA],eax 00000030: DD 45 08 fld qword ptr [ebp+8] 00000033: DC 45 10 fadd qword ptr [ebp+10h] 00000036: 83 EC 08 sub esp,8 00000039: DD 1C 24 fstp qword ptr [esp] 0000003C: 8B 45 F8 mov eax,dword ptr [ebp-8] 0000003F: 8B 08 mov ecx,dword ptr [eax] //该地址为函数OutPutInfo的地址,该地址尚未解析,暂时用零填充 00000041: E8 00 00 00 00 call ?OutPutInfo@DemoOutPut@@QAEXN@Z 00000046: 5F pop edi 00000047: 5E pop esi 00000048: 5B pop ebx 00000049: 81 C4 CC 00 00 00 add esp,0CCh 0000004F: 3B EC cmp ebp,esp 00000051: E8 00 00 00 00 call __RTC_CheckEsp 00000056: 8B E5 mov esp,ebp 00000058: 5D pop ebp 00000059: C2 10 00 ret 10h |
(表二)
在上面的代码中,红色部分为符号nOperTimes的虚拟内存地址,由于该符号尚未解析,所以该地址未知,暂时用零代替。蓝色部分为符号OutPutInfo的虚拟内存地址,由于该符号尚未解析,所以该地址未知,暂时用零代替。需要重定位的位置与重定位表的描述吻合。
目标文件DemoMath.obj的符号表的部分内容如下:
//符号nOpertimes在符号表中的内容 //00F表示符号在符号表中的索引。 //SECT4表示该符号位于第四个段中,也就是说,该符号位于当前目标文件中 //notype表示该符号为变量 //External表示该符号为全局符号,未见外部可见。Static表示该符号只文件内可见 //?nOperTimes@@3HA (int nOperTimes)是符号名称 00F 00000000 SECT4 notype External | ?nOperTimes@@3HA (int nOperTimes) //符号OutPutInfo在目标文件DemoMath.obj所属符号表的内容 //UNDEF表示该符号未定义,该符号的定义位于其他目标文件中。该符号需要解析 034 00000000 UNDEF notype () External | ??0DemoOutPut@@QAE@XZ |
(表三)
在目标文件中,汇编内容显示了编译以后,链接之前各个需要重定位符号的信息情况;结合重定位表中的信息,可以很容易地找到需要重定位的位置;重定位表与通过索引字段与符号表关联,符号表中各个符号的值,就是符号的地址。在该阶段,这些地址尚未使用虚拟地址表示。在链接阶段,将会根据各个目标文件中的符号表生成全局符号表。在全局符号表中,各个符号的值使用虚拟地址表示。
3.5.2.2增量链接模式下PE文件的输出
开启增量链接模式,在链接阶段,将目标文件链接在一起,输出PE文件。使用工具dumpbin将DemoDlld.dll的内容解析为汇编格式,其中函数AddData的汇编代码的内容如下:
?AddData@DemoMath@@QAEXNN@Z: 10011780: 55 push ebp 10011781: 8B EC mov ebp,esp 10011783: 81 EC CC 00 00 00 sub esp,0CCh 10011789: 53 push ebx 1001178A: 56 push esi 1001178B: 57 push edi 1001178C: 51 push ecx 1001178D: 8D BD 34 FF FF FF lea edi,[ebp-0CCh] 10011793: B9 33 00 00 00 mov ecx,33h 10011798: B8 CC CC CC CC mov eax,0CCCCCCCCh 1001179D: F3 AB rep stos dword ptr es:[edi] 1001179F: 59 pop ecx 100117A0: 89 4D F8 mov dword ptr [ebp-8],ecx //符号nOperTimes已经被解析,地址为0x10019140。该地址为绝对地址 100117A3: A1 40 91 01 10 mov eax,dword ptr [?nOperTimes@@3HA] 100117A8: 83 C0 01 add eax,1 100117AB: A3 40 91 01 10 mov dword ptr [?nOperTimes@@3HA],eax 100117B0: DD 45 08 fld qword ptr [ebp+8] 100117B3: DC 45 10 fadd qword ptr [ebp+10h] 100117B6: 83 EC 08 sub esp,8 100117B9: DD 1C 24 fstp qword ptr [esp] 100117BC: 8B 45 F8 mov eax,dword ptr [ebp-8] 100117BF: 8B 08 mov ecx,dword ptr [eax] //符号OutPutInfo已经被解析,地址为0xFFFFF9CA。该地址为相对地址 100117C1: E8 CA F9 FF FF call @ILT+395(?OutPutInfo@DemoOutPut@@QAEXN@Z) 100117C6: 5F pop edi 100117C7: 5E pop esi 100117C8: 5B pop ebx 100117C9: 81 C4 CC 00 00 00 add esp,0CCh 100117CF: 3B EC cmp ebp,esp 100117D1: E8 E7 F9 FF FF call @ILT+443(__RTC_CheckEsp) 100117D6: 8B E5 mov esp,ebp 100117D8: 5D pop ebp 100117D9: C2 10 00 ret 10h |
(表四)
在增量链接模式下,编译器会生成增量链接表,其部分内容如下所示:
@ILT+395(?OutPutInfo@DemoOutPut@@QAEXN@Z): 10011190: E9 0B 09 00 00 E9 40 35 00 00 E9 FF 34 00 00 E9 //红色部分的汇编代码为:jump 0B 09 00 00 @ILT+411(_QueryPerformanceCounter@4): 100111A0: 7E 35 00 00 E9 77 08 00 00 E9 84 34 00 00 E9 81 |
(表五)
在增量链接模式下,全局符号表的相关内容为:
符号类型 |
符号地址 |
长度 |
符号名称 |
Function |
11AA0 |
126 |
DemoOutPut::OutPutInfo |
Data |
19140 |
4 |
nOperTimes |
(表六)
在符号表中,符号地址为相对于默认加载位置的相对地址。真正的符号地址应该是0x10000000 + 符号地址。
3.5.2.3在非增量链接模式下PE文件的输出
关闭增量链接模式,重新编译文件,将输出的PE文件内容导出。函数AddData的汇编内容描述如下:
?AddData@DemoMath@@QAEXNN@Z: 10001220: 55 push ebp 10001221: 8B EC mov ebp,esp 10001223: 81 EC CC 00 00 00 sub esp,0CCh 10001229: 53 push ebx 1000122A: 56 push esi 1000122B: 57 push edi 1000122C: 51 push ecx 1000122D: 8D BD 34 FF FF FF lea edi,[ebp-0CCh] 10001233: B9 33 00 00 00 mov ecx,33h 10001238: B8 CC CC CC CC mov eax,0CCCCCCCCh 1000123D: F3 AB rep stos dword ptr es:[edi] 1000123F: 59 pop ecx 10001240: 89 4D F8 mov dword ptr [ebp-8],ecx //变量nOpertimes的地址已经被解析。该地址为绝对地址0x10006030 10001243: A1 30 60 00 10 mov eax,dword ptr [?nOperTimes@@3HA] 10001248: 83 C0 01 add eax,1 1000124B: A3 30 60 00 10 mov dword ptr [?nOperTimes@@3HA],eax 10001250: DD 45 08 fld qword ptr [ebp+8] 10001253: DC 45 10 fadd qword ptr [ebp+10h] 10001256: 83 EC 08 sub esp,8 10001259: DD 1C 24 fstp qword ptr [esp] 1000125C: 8B 45 F8 mov eax,dword ptr [ebp-8] 1000125F: 8B 08 mov ecx,dword ptr [eax] //函数OutPut的地址已经被解析。该地址为相对地址00 00 02 2A 10001261: E8 2A 02 00 00 call ?OutPutInfo@DemoOutPut@@QAEXN@Z 10001266: 5F pop edi 10001267: 5E pop esi 10001268: 5B pop ebx 10001269: 81 C4 CC 00 00 00 add esp,0CCh 1000126F: 3B EC cmp ebp,esp 10001271: E8 0A 0B 00 00 call __RTC_CheckEsp 10001276: 8B E5 mov esp,ebp 10001278: 5D pop ebp 10001279: C2 10 00 ret 10h 1000127C: CC CC CC CC |
(表七)
在非增量链接模式下,全局符号表的部分内容为:
符号类型 |
符号地址 |
长度 |
符号名称 |
Function |
1490 |
126 |
DemoOutPut::OutPutInfo |
Data |
6030 |
4 |
nOperTimes |
(表八)
在符号表中,符号地址为相对于默认加载位置的相对地址。真正的符号地址应该是0x10000000 + 符号地址。
3.8.3地址解析和重定位
3.8.3.1变量地址的解析和重定位
在表一中,根据重定位表的信息可以得知,位于00000024位置的符号nOperTimes需要被重定位。在目标文件中,它的内容为:00000020: 89 4D F8 A1 00 00 00 00。
执行静态链接以后,输出PE格式的Dll文件,其内容被导出到表四中。符号nOperTimes已经完成了重定位,其内容为:100117A3: A1 40 91 01 10。符号nOperTimes的地址被解析成:0x10019140。
在全局符号表(表五)中,符号nOperTimes的地址是:19140。该地址为基于默认加载位置的相对地址,加上0x10000000即为符号的虚拟地址0x10019140。
在变量类型的符号的解析和重定位的时候,用全局符号表中的符号值(即变量地址:0x10019140)去重写目标文件中需要重定位的位置(00000020: 89 4D F8 A1 00 00 00 00),得到PE文件中重定位后的结果(100117A3: A1 40 91 01 10)。
变量的地址类型为绝对地址,在重定位的时候,使用全局符号表中符号的虚拟地址直接重写需要重定位的位置即可。
3.8.3.2增量链接模式下函数地址的解析和重定位
在增量链接模式下,函数调用的过程如下图所示:
在增量链接的模式下,经过两级调用才能完成对被调用函数的调用。首先在函数调用点,以call指令方式执行一次函数调用。经过对Call指令的调用,控制流程转到了增量链接表中jump指令的位置。然后开始执行jump指令,经过对jump指令的调用,控制流程才真正到达被调用函数处。
在表四中,符号(函数)OutPutInfo已经被解析和重定位,该函数调用点处的机器码为:100117C1: E8 CA F9 FF FF。E8是Call指令的二进制机器码,其后是四个字节的操作数,表示增量链接表中jump指令的相对位置。Call指令后的操作数的计算公式为:操作数 = jump指令地址 – IP寄存器内容。当前IP寄存器内容为:Call指令的地址 + 5,即:100117C1 + 5 = 100117C6。经表五的内容(10011190: E9 0B 09 00 00)得知,jump指令的地址为:0x10011190。那么操作数 = 0x10011190 - 100117C6 = FF FF F9 CA。与预期结果符合。
在表五中,jump指令的内容为:10011190: E9 0B 09 00 00。经过jump指令,调用流程才真正转到被调用函数处。Jump指令的操作数的计算公式为:操作数 = 被调用函数的地址 – IP寄存器的内容。当前IP寄存器的内容为:jump指令的地址 + 5,即:10011190 + 5 = 10011195。通过表六得知,被调用函数的地址为:0x10011AA0。因此,jump指令的操作数 = 10011AA0 – 10011195 = 00 00 090B。该结果与预期符合。
3.8.3.3非增量链接模式下函数地址的解析和重定位
在非增量链接模式下,经过一次Call指令的调用,即可完成从主调函数到被调函数控制流程的转换。通过表七得知,函数调用点处的机器指令码为:10001261: E8 2A 02 00 00。E8为Call指令的二进制码,其后为四个字节的操作数,该操作数是被调用函数OutPutInfo的相对地址。
Call指令操作数的计算公式为:操作数 = 符号的地址 – IP寄存器的内容。当前IP寄存器的内容为:Call指令的地址 + 5,即:0x10001261 + 0x5 = 0x10001266。通过表八得知,函数OutPutInfo的地址为:0x10001490。因此,操作数 = 0x10001490 – 0x10001266 = 00 00 02 2A。该值预期结果符合。