本文是对C++应用程序在Windows下的编译、连接的深刻理解和分析,文章的目录以下:ios
咱们先看第一章概述部分。程序员
cl.exe是windows平台下的编译器,link.exe是Windows平台下的连接器,C++源代码在使用它们编译、连接后,生成的可执行文件可以在windows操做系统下运行。cl.exe和link.exe集成在Visual Studio中,随着开发工具Visual Studio的安装,它们也被安装到与VC相关的目录下。编程
使用该编译器的方式有两种,一种是在Visual Studio开发环境中,直接点击命令按钮,经过Visual Studio启动编译器;另一种方式是在命令行窗口中经过c l命令编译C++源代码文件。windows
在集成开发环境Visual Studio中,已经设定好了c l命令的各类默认参数,当使用Visual Studio编译C++源代码的时候,最终会调用到这个编译工具,而且使用这些事先设定好的默认参数。多线程
在安装Visual Studio的时候,安装程序在命令行工具“Visual Studio 2008 Command Prompt”中设定了编译器(cl.exe)和连接器(link.exe)须要的各类参数和变量,所以,在“Visual Studio 2008 Command Prompt”工具的命令行窗口中,可使用c l命令编译C++源代码。Visual Studio 2008 Command Prompt工具的路径是:开始-》全部程序-》Visual Studio-》Visual Studio Tools-》Visual Studio 2008 Command Prompt。编程语言
在编译C++源代码的时候,编译器须要使用到三个环境变量,它们分别是:函数
若是咱们在系统环境变量中设定了这三个环境变量,那么就能够在普通的命令行窗口中使用cl命令编译C++源代码,而不是使用Visual Studio的集成工具“Visual Studio 2008 Command Prompt”。工具
本文将以以下应用程序示例展开论述,经过对应用程序的编译,连接过程的介绍,着重讲解PE文件的数据格式,以及在应用程序加载的过程当中,操做系统是如何进行“重定基地址”,以及执行各个DLL之间的“动态连接”。开发工具
示例应用程序各模块之间的调用关系以下图所示:优化
在示例应用程序中,各源代码文件的说明以下表:
序号 |
文件名称 |
描述 |
1 |
DemoDef.h |
定义函数的导入,导出;定义全局变量和全局函数 |
2 |
DemoMath.h |
数学操做类的定义 |
3 |
DemoOutPut.h |
信息输出类的定义 |
4 |
DemoMath.cpp |
数学操做类的实现,全局函数的定义,全局变量的定义 |
5 |
DemoOutPut.cpp |
信息输出类的实现 |
6 |
main.cpp |
主函数 |
示例应用程序的源代码以下:
------------------------------------main.cpp-------------------------------------------- #include "DemoDef.h" #include "DemoMath.h" #include <iostream> using namespace std; int nGlobalData = 5; int main() { DemoMath objMath; objMath.AddData(10,15); objMath.SubData(nGlobalData,3); objMath.DivData(10,0); objMath.DivData(10,nGlobalData); objMath.Area(2.5); int ntimes = GetOperTimes(); cout << "操做次数为:" << ntimes << endl; //用于中止命令行 int k = 0; cin >> k; } ----------------------------------------DemoDef.h------------------------------------ #ifndef _DemoDef_H #define _DemoDef_H #include <stdio.h> //定义函数的导入,导出 #ifdef DEMODLL_EXPORTS #define DemoDLL_Export _declspec(dllexport) #else #define DemoDLL_Export _declspec(dllimport) #endif
//文件做用域中的符号常量,将要执行常量折叠 const double PI = 3.14;
//声明全局变量,记录操做的次数 extern int nOperTimes;
//声明全局函数,返回操做的次数 int DemoDLL_Export GetOperTimes(); #endif -------------------------------------DemoMath.h------------------------------------- #ifndef _DemoMath_H #define _DemoMatn_H
#include "DemoDef.h"
class DemoOutPut;
class DemoDLL_Export 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
--------------------------------------------------DemoOutPut.h----------------------------------------- #ifndef _DemoOutPut_H #define _DemoOutPut_H
//执行信息输出 class DemoOutPut { public: DemoOutPut(); ~DemoOutPut();
//输出数值 void OutPutInfo(double a); //输出字符串 void OutPutInfo(const char* pStr); }; #endif
-----------------------------------------------------DemoMath.cpp----------------------------------------- #include "DemoMath.h" #include "DemoOutPut.h"
//全局变量的定义 int nOperTimes = 0;
//全局函数的定义 int GetOperTimes() { return nOperTimes; }
//类方法的实现 DemoMath::DemoMath() { m_pOutPut = new DemoOutPut(); }
DemoMath::~DemoMath() { if(m_pOutPut != NULL) { delete m_pOutPut; m_pOutPut = NULL; } }
void DemoMath::AddData(double a, double b) { nOperTimes++; m_pOutPut->OutPutInfo(a + b); }
void DemoMath::SubData(double a, double b) { nOperTimes++; m_pOutPut->OutPutInfo(a - b); }
void DemoMath::MulData(double a, double b) { nOperTimes++; m_pOutPut->OutPutInfo(a * b); }
void DemoMath::DivData(double a, double b) { if (b == 0) { m_pOutPut->OutPutInfo("除数不能为零"); return; }
nOperTimes++; m_pOutPut->OutPutInfo(a / b); }
void DemoMath::Area(double r) { nOperTimes++; m_pOutPut->OutPutInfo( r * r * PI); }
---------------------------------------------------------DemoOutPut.cpp--------------------------------------------- #include <iostream> #include "DemoOutPut.h"
DemoOutPut::DemoOutPut() { }
DemoOutPut::~DemoOutPut() { }
void DemoOutPut::OutPutInfo(double a) { std::cout << "计算的结果为:" << a << std::endl; }
void DemoOutPut::OutPutInfo(const char *pStr) { std::cout << pStr << std::endl; } |
在编写C++源代码的时候,若是要使用一个类库,那么就必须引入这个类库的头文件,就必须知道这个类型头文件的具体路径。在上面的代码示例中,使用了“#include <stdio.h>”这种形式引入了一个C运行库的头文件。在这个引用中,咱们没有设定该头文件的具体路径,也没有在其余位置设定该头文件的具体路径,可是集成开发环境Visual Studio可以找到该文件的具体位置。具体缘由是这样的:在安装Visual Studio的时候,安装程序已经在Visual Studio中设定了C运行库头文件的具体位置。经过菜单“Tool-Options->Projects and Solutions->VC++Directories”能够查看到这些事先设定的信息。具体状况以下图:
在上图中,经过下拉窗口“Show directories for”,能够选择要设定的路径的类型,包括:头文件的路径(Include files),lib文件的路径(Library files),源代码文件(Source files)的路径等。
在头文件路径的设定中,一共设定了四类头文件的路径,分别是C运行库头文件的路径,MFC类库头文件的路径,Win32API开发相关的头文件路径,以及与FrameWork相关的头文件的路径。
除了系统事先设定好的各类路径外,咱们也能够在该窗口中设定咱们须要的各类其余路径。
在编译C++源代码的时候,整个编译过程能够划分为两个阶段,分别是编译阶段和连接阶段。在编译阶段,以程序员编写的C++源代码(头文件+源文件)为输入,通过编译器的处理后,输出COFF格式的二进制目标文件;在连接阶段,以编译阶段输出的目标文件为输入,通过连接器的连接,输出PE格式的可执行文件。整个编译的过程以下图所示:
编译阶段又能够进一步细分为三个子阶段,分别是:预编译,编译和汇编。在每个子阶段中,都会对应不一样的工做内容,以及输出不一样的输出物。
因为程序员是在C/C++运行库的基础上开发出来的C++应用程序,因此在连接阶段,除了要将编译阶段输出的目标文件进行连接外,还要加入对C/C++运行库中相关目标文件的连接。这种连接分为两种状况。一种状况是:因为C++源代码中显式地调用了C/C++运行库中的函数而引发的连接。例如:在C++源代码中调用了C/C++运行库中的函数:printf(),那么在连接的时候,就须要把printf()所在的目标文件也连接进来。另一种状况是隐式地,由连接器自动完成。在C++应用程序运行的时候,它必需要获得C/C++运行库的支持,所以在连接的时候,那些支持C++应用程序运行的库文件也被连接器自动地连接过来。不管哪一种状况,C/C++运行库都必须被连接到C++应用程序中。
在命令行窗口中,可使用c l命令对C++源代码进行编译。在编译的时候,能够设定不一样的编译选项,进而得到不一样的输出结果。好比:能够一步完成编译工做,直接得到PE格式的可执行文件。在这种状况下,cl.exe在完成编译后,会自动调用link.exe执行连接工做。也能够经过分阶段编译的方式得到不一样阶段的编译结果,经过设定不一样的c l命令选项,能够将编译过程细分。好比:/C命令表示只编译,不连接,经过这个命令就能够得到目标文件;/P命令表示只执行预编译,经过它能够查看预编译的结果;/Fa命令表示执行汇编操做,经过它能够得到汇编语言格式的程序文件。
在C++源代码的编译过程当中,各个步骤的详细描述以下表所示:
序号 |
步骤 |
输入 |
输出 |
描述 |
C l命令 |
1 |
预编译 |
C++源文件 |
.i文件 |
输出通过预处理后的文件。 |
Cl /P xxx.cpp |
2 |
编译 |
C++源文件 |
.asm文件 |
输出汇编文件。 |
Cl /Fa xxx.cpp |
3 |
汇编 |
C++源文件 |
.obj文件 |
输出目标文件 |
Cl /C xxx.cpp |
4 |
连接 |
.obj文件 |
.exe文件 |
输出可执行文件 |
Cl xxx.obj |
每一种高级编程语言都有它本身的编译器,在特定的操做系统平台上,编译器为该编程语言提供运行库的支持,而且将该编程语言编写的源文件编译成目标文件。
经过提供C/C++运行库的方式,Cl编译器支持C/C++应用程序的开发。C/C++运行库是由编译器厂商提供的,每支持在一个操做系统系下的编译,编译器就须要提供一个可以在该操做系统下运行的C/C++运行库。经过对操做系统API的封装,C/C++运行库实现了C/C++标准库的接口。因为标准库的接口是统一的,原则上来讲,使用C++语言开发出来的应用程序是能够运行在不一样操做系统平台上的。只须要针对该操做系统实现其运行库。
不一样的CPU硬件可能会要求不一样的指令格式。编译器在将高级语言翻译成机器语言的时候,是依赖于计算机系统硬件的。根据不一样的硬件,会产生不一样的指令格式。编译器屏蔽了计算机系统硬件的细节。
对上支持高级语言的程序编写工做,对下封装计算机系统硬件的细节,编译器负责将高级语言编写的源程序翻译成底层计算机系统硬件可以识别的二进制机器代码,并将这些二进制机器代码以统一的格式输出,这个文件格式就是COFF格式。
经过产生统一格式的COFF文件,使编译和连接可以互相隔离。也就是说,连接器的实现不会依赖具体的编译器。连接器只关注COFF格式的目标文件,只要目标文件的格式统一,那么连接器就能够连接由不一样编译器编译出来的目标文件。
在预编译阶段,主要是处理那些源代码文件中以“#”开头的预编译指令,如:“#include”,“#define”等,主要的处理规则描述以下:
通过预编译的处理之后,头文件被合并到源文件中,而且全部的宏定义都被展开。
示例一:对源文件“DemoOutPut.cpp”进行预编译操做,命令格式以下:
Cl /P DemoOutPut.cpp |
执行预编译之后,将会输出“demooutput.i”文件,该文件的部份内容以下:
#line 2 "demooutput.cpp" #line 1 "e:\\demo\\DemoOutPut.h" class DemoOutPut { public: DemoOutPut(); ~DemoOutPut(); void OutPutInfo(double a); void OutPutInfo(const char* pStr); }; #line 18 "e:\\demo\\DemoOutPut.h" #line 3 "demooutput.cpp" DemoOutPut::DemoOutPut() { } DemoOutPut::~DemoOutPut() { } void DemoOutPut::OutPutInfo(double a) { std::cout << "计算的结果为:" << a << std::endl; } void DemoOutPut::OutPutInfo(const char *pStr) { std::cout << pStr << std::endl; } |
在“demooutput.i”文件中,除了加入了行号信息外,类DemoOutPut的头文件和源文件已经合并到了一块儿。在编写C++源代码的时候,若是咱们没法肯定宏定义是否正确,那么就能够输出“.i”文件,进而肯定问题。
以预编译的输出为输入,将C++源代码翻译成计算机系统应将可以识别的二进制机器指令,并将编译的输出结果存储在COFF格式的目标文件中。在编译的中间过程当中,还能够经过c l命令选择性地输出汇编语言格式的中间文件。
编译器在编译的时候,通常会分为以下步骤,具体状况以下表描述:
序号 |
步骤 |
描述 |
1 |
词法分析 |
扫描C++源代码,识别各类符号。这些被识别的符号包括:C++系统关键字,函数名称,变量名称,字面值常量,以及特殊字符。函数名称,变量名称将被保存到符号表中,字面值常量将被保存到文字表中。 |
2 |
语法分析 |
将词法分析阶段产生的各类符号进行语法分析,产生语法树。每一个语法树的节点都是一个表达式。 |
3 |
语义分析 |
此阶段开始分析C++语句的真正意义。编译器只能进行静态语义分析,包括:声明和类型的匹配,类型转换等。通过语义分析,语法树的表达式都被标识了类型。 |
4 |
源代码级优化 |
执行源代码级别的优化。好比:表达式3+8会被求值成11。 将语法树转换成中间代码,它是语法树的顺序表达。这个中间代码已经很是接近目标代码了,可是它和目标机器以及运行时环境是无关的。好比:不包含数据的尺寸,变量的地址,寄存器的名称等。 中间代码将编译器划分红两部分,第一部分负责产生与机器无关的中间代码;第二部分将中间代码转化成目标机器代码。 |
5 |
目标代码生成及优化 |
将中间语言代码转化成目标机器相关的机器代码。同时执行一些优化。 |
在执行编译的时候,编译器以“.cpp”文件为单位,对于每个“.cpp”文件,编译器都会输出一个目标文件。在COFF格式的目标文件中,按照二进制文件内容的功能和属性的不一样,会将文件内容划分红不一样的段。COFF文件所包含的段种类以下图所示:
各个主要段的详细信息描述以下表:
序号 |
段名 |
描述 |
1 |
.text |
在该段中包含C++程序的源代码,这些源代码已经被编译成计算机系统硬件可以识别的二进制指令。每个二进制指令都必须对应一个虚拟内存地址 |
2 |
.data |
已初始化的全局变量,静态变量存储在该段中 |
3 |
.bss |
未初始化的全局变量存储在该段中 |
4 |
.rdata |
只读的数据存储在该段中 |
5 |
.debug$S |
包含与调试符号相关的调试信息 |
6 |
.debug$T |
包含与类型相关的调试信息 |
7 |
.drectve |
包含连接指示信息,如采用哪一个版本的运行库,以及函数的导出等。 |
85 |
重定位表 |
在该段中存储着属于其余段的重定位信息。在编译阶段,某些二进制指令的虚拟内存地址是暂时没法肯定的,在重定位段将会记录这些没法肯定虚拟内存地址的位置。在连接阶段,将使用这些重定位信息。在重定位段中,主要的信息字段包括:须要重定位的位置,重定位地址的类型。对应的符号表索引等 |
9 |
行号表 |
在行号表中存储的信息描述了二进制代码和C++源代码之间的对应关系,应用于程序调试。 |
10 |
符号表 |
在编译的时候,函数名称,变量名称都会被看成符号来处理。编译器将C++源代码中出现的符号统一地存储在符号表中。连接阶段须要使用符号表中的信息。 |
11 |
字符串表 |
字符串表用于辅助符号表。若是符号表中符号名的长度超过8个字节,那么这个名称将被保存到符号表中。而在符号表中,符号名称的位置保存了字符串表中相关项的地址。 |
在C++程序的开发过程当中,程序代码是以“.cpp“文件为单位来组织的。在各个文件之间又会存在调用关系。好比:A.CPP文件调用B.CPP文件中的函数。
在C++程序的编译阶段,编译器是以“.CPP”文件为单位进行编译的。也就是说,对于每个“.CPP”文件,都会生成一个“.obj”目标文件。在目标文件中,对于每一条指令或者指令要操做的数据,都应该生成一个虚拟内存的地址。若是一个目标文件中要使用的函数或者数据被定义在另一个目标文件中,如:在A.obj文件中调用了B.obj文件中定义的函数。在将A.CPP生成A.obj的过程当中,是没法立刻肯定该被调用函数的地址的。由于该函数的地址记录在B.obj文件中。
连接器执行连接的过程就是将多个目标文件合并在一块儿,造成可执行文件的过程。在造成可执行文件的过程当中,连接器须要将在编译阶段没法肯定的被调用符号(函数,变量)的虚拟内存地址肯定下来。这就是连接的主要目标。
注:关于每一个指令的虚拟内存地址,在目标文件中,该地址以相对于文件某个位置的偏移来表示;直到PE文件生成的时候,才会将这些偏移值转换成虚拟内存地址。
首先看一个示例,在使用Visual Studio开发C++应用程序的时候,首先会创建一个解决方案,而后在解决方案中包含若干个项目,这些C++源代码是以项目的形式组织在一块儿的。它们的关系以下图所示:
解决方案“DemoDLL”中包含了两个项目,分别是:“DemoDLL”,“DemoExe”。在项目“DemoDLL”中包含了两个源文件,分别是:“DemoMath.cpp”,“DemoOutPut.cpp”。项目“DemoExe”引用了项目“DemoDLL”中的函数。在编译的时候,项目“DemoDLL”被编译成了动态连接库;项目“DemoExe”被编译成了可执行文件。在编译这两个项目的时候,C运行库和C++运行库也被连接了进来。
由上一节的描述能够得知,连接的主要目的是肯定被调用函数的地址。即:使主调函数知道被调用函数的位置。在处理这个问题的时候,能够采用不一样的方式和方法,所以也就有了不一样的连接类型,具体的连接分类以下图所示:
连接能够被分为静态连接和动态连接两种状况。而动态连接又被进一步划分为隐式动态连接和显式动态连接。
在上面的示例中,将源文件“DemoMath.cpp”和源文件“DemoOutPut.cpp”编译成动态连接库的时候,这两个源文件之间采用的连接类型是静态连接。静态连接的特色描述以下:
在上面的示例中,在项目“DemoExe”中调用了项目“DemoDLL”中的函数,在编译的时候,这两个项目之间的连接类型是动态连接。动态连接的特色描述以下:
通常状况下,在同一个项目中,好比项目“DemoDLL”中,由程序员编写的C++源代码之间的连接方式是静态连接;在多个项目之间,好比:项目“DemoExe”和项目“DemoDLL”之间,采用的连接方式是动态连接。
由程序员开发出来的可执行程序或动态连接库,在运行的时候,它们是须要C/C++运行库支持的。这些项目和运行库之间的连接方式能够是静态连接,也能够是动态连接。能够在编译源程序的时候进行设定,肯定是采用静态连接方式仍是采用动态连接方式。若是采用静态连接方式,C/C++运行库中的相关函数的代码被加入到目标项目中,而后合并成一个文件发布,这个文件相对较大;若是是采用动态连接,只是将C/C++运行库中的相关函数的符号写入到了目标项目中。在程序发布的时候,须要将生成可执行文件和C/C++运行库的动态连接库文件一同发布。这时候生成的可执行文件相对较小。
在Visual Studio中,能够经过以下方式更改C++应用程序与C/C++运行库的连接方式,具体状况以下图所示。
该窗体的打开路径以下:在解决方案中选择一个项目,而后鼠标右键选择“属性”选项,在弹出的窗体中,选择C/C++标签中的“代码生成”项。
在上图“运行时库”项目中,能够设定要连接的方式。一共有四种能够被选择的连接方式,分别是:多线程静态连接,多线程静态连接调试版,多线程动态连接,多线程动态连接调试版。默认连接的类型为多线程动态连接。
若是选择了动态连接方式,将会使用C/C++运行库的动态连接版本,使用工具Dependency将生成的可执行文件打开后,各个组件之间的关系以下图所示:
MSVCR90.dll是C运行库所在的动态连接库,MSVCP90.dll是C++运行库所在的动态连接库,Kerner32.dll和NTDLL.dll是操做系统的组件,它们以动态连接库的形式提供。C/C++运行库与Kerner32.dll之间采用动态连接的方式。在上图中,可执行文件DemoExe除了与DemoDll进行了动态连接外,还与C运行库,C++运行库,以及组件Kerner32.dll进行了动态连接;由程序员开发出来的动态连接库DemoDLL.dll也与C运行库,C++运行库,以及组件Kerner32.dll进行了动态连接。C/C++运行库又动态连接了组件Kerner32.dll,组件Kerner32.dll动态连接了组件NTDLL.dll。
若是选择了静态连接方式,将会使用C/C++运行库的静态连接版本。使用工具Dependency将可执行文件打开,各个组件之间的关系以下图所示:
因为设定了静态连接的方式,DemoExe和DemoDLL与C/C++运行库之间的连接方式变成了静态连接。可是DemoExe与DemoDLL之间的连接方式,已经C/C++运行库与组件Kerner32.dll之间的连接方式依然是动态连接。因此,在上图中能够看出,C/C++运行库的相关代码已经被合并到DemoDLL.dll以及DemoExe中,已经看不到MSVCR90.dll和MSVCP90.dll的存在。可是因为C/C++运行库与组件Kerner32.dll之间是动态连接,因此DemoExe和DemoDLL继承了种连接方式,它们与组件Kerner32.dll之间的连接方式依然是动态连接。
由上面的分析能够看出,在Visual Studio中设定的连接方式,只能影响应用程序与C/C++运行库之间的连接。程序员开发出来的C++应用程序与其余组件之间的关系以下图所示:
程序员开发的应用程序受到C/C++运行库的支持,而C/C++运行库在实现C/C++标准库接口的时候,是须要受到操做系统组件的支持的。在Windows平台上,它们分别是Kerner32.dll,以及NTDLL.dll。这些组件包含了对win32API的封装,也就是说,在实现C/C++标准库接口的时候,C/C++运行库调用了Win32API中的相关函数。
动态连接的另一种方式是显式动态连接。当进行这种动态连接的时候,只要当真正执行函数的调用的时候,才会肯定被调用函数的地址。隐式动态连接与显式动态连接的区别是:隐式动态连接在程序加载的时候肯定被调用函数的地址,而显式动态连接将这个过程推后到具体函数调用的时候。可使用函数LoadLibrary和函数GetProcAddress实现显示动态连接。
在执行了连接之后,将多个目标文件合并在一块儿,输出了可执行文件或者是动态连接库。可执行文件和动态连接库的二进制内容是以PE格式存储的。在PE文件中所包含的段的种类以下图所示:
各个主要段的详细信息描述以下表:
序号 |
段名 |
描述 |
1 |
.text |
在该段中包含C++程序的源代码,这些源代码已经被编译成计算机系统硬件可以识别的二进制指令。每个二进制指令都必须对应一个虚拟内存地址 |
2 |
.data |
已初始化的全局变量,静态变量存储在该段中 |
3 |
.textbss |
该段为代码段,在PE文件中不占用存储空间,在虚拟内存中占用虚拟内存的地址空间。在执行增量连接的时候,新修改过的函数的代码可能会被放到该段中。用于debug模式下。 |
4 |
.rdata |
只读的数据存储在该段中,例如:字符串文本。导入,导出表会被合并到该段中。 |
5 |
.idata |
导入表。在建立release版本的时候,该节常常被合并到.rdata节中。 |
6 |
.edata |
导出表。在建立一个包含导出 API 或数据的时候,连接器会生成一个 .EXP 文件。这个 .EXP 文件包含一个最终会被添加到可执行文件里的 .edata 节。和 .idata 节同样,.edata 节常常会被合并到 .text 或 .rdata 节中。 |
7 |
.rsrc |
资源节,该节只读,不能被合并到其余节。 |
8 |
reloc |
基址重定位节。 |
9 |
.crt |
为支持 C++ 运行时(CRT)而添加的数据。好比,用来调用静态 C++ 对象的构造器和析构器的函数指针 |