C++的编译与链接简介

1. 前言

了解C++编译连接过程,可以深入的理解C++编译的静态库、动态库的互相调用的规则,更容易发现调用过程中出现的各种问题。

1.1. 内核架构

内核主要是用来执行指令集的,指令集有很多,早期intel发明的x86指令集,32位地址总线,有哪此寄存器,有哪些汇编指令,如何加载执行可执行文件。后来AMD在x86的基础上扩展支持了64指令,这种新的架构也被称为x86-64,有时也会简称x64或AMD64。x64兼容32位,也即x64有两套指令集。x86,x64是intel和AMD共享专利的指令集,也即这两家的通用CPU指令集是一样的。指令集有很多,还有ARM公司的ARM指令集,还有开源的MIPS指令集。

1.2. 函数调用约定

函数调用过程中,会有一个参数入栈出栈的过程,但是参数是从左边先入栈还是从右边先入栈呢?这里有几种不同的规则。
1.__cdecl
所谓的C调用规则。按从右至左的顺序压参数入栈,由调用者把参数弹出栈。切记:对于传送参数的内存栈是由调用者来维护的。返回值在EAX中因此,对于象printf这样变参数的函数必须用这种规则。编译器在编译的时候对这种调用规则的函数生成修饰名的饿时候,仅在输出函数名前加上一个下划线前缀,格式为_functionname。
**2.__stdcall **
按从右至左的顺序压参数入栈,由被调用者把参数弹出栈。_stdcall是Pascal程序的缺省调用方式,通常用于Win32 Api中,切记:函数自己在退出时清空堆栈,返回值在EAX中。__stdcall调用约定在输出函数名前加上一个下划线前缀,后面加上一个“@”符号和其参数的字节数,格式为_functionname@number。如函数int func(int a, double b)的修饰名是_func@12
同样使用标准调用约定编译函数:
void __stdcall ASCEFunc(int a, int b);
在C++编译器中会编译成符号?ASCEFunc@@YGXHH@Z,而在C编译器中会编译成符号_ASCEFunc@8。C++复杂的函数符号名是为了支持函数的重载功能。
指定extern "C"即是指明用C编译器风格来编译。
3.__thiscall
仅仅应用于"C++"成员函数。this指针存放于CX寄存器,参数从右到左压。thiscall不是关键词,因此不能被程序员指定

2. 可执行文件

可执行文件 (executable file) 指的是可以由操作系统进行加载执行的文件。在不同的操作系统环境下,可执行程序的呈现方式不一样。

2.1. Windows

在windows操作系统下,可执行程序可以是 .exe,.sys,.dll,.com,.obj等类型文件。这类文件被称作PE(Portable Executable)文件,意为可移植的执行的文件。PE文件总的来说就是由DOS文件头,DOS加载模块,PE文件头,区段表和区段这5部分构成的。
一个典型的PE文件包含以下区段:
.text区段 存放可执行的二进制代码区段
.data区段 初始化数据块,比如全局变量
.idata区段 程序所使用的动态链接库等外接函数和文件信息。
.rsrc区段 存放程序的资源,图标,菜单,版本信息等。

2.2. Linux

Linux下可执行文件格式为ELF(Executable and Linkable Format),包括可扫行文件、中间目标文件".o"以及静态库".a"和动态链接库".so"文件。
1 .text字段:用于保存程序中的代码片段
2 .data字段:用于保存已经初始化的全局变量和局部变量
3 .bss字段:用于保存未初始化的全局变量和局部变量
4 .rodata:顾名思义,保存只读的变量
5 .comment:保存编译器版本信息
6 .symtab:符号表,各个目标文件链接的接口
7 .strtab:字符串表,保存符号的名字,因为各个字符串大小不一,所以统一把所有字符串放到这个段里,后续其他段通过某个符号在字符串标中的偏移可以取到符号。
8 .rela.text:因为程序声明使用了未在程序内部定义的函数或者变量,所以需要等到链接时(定义在别的目标文件或者库里)对这个符号的地址进行重新定位,不然会引用到错误的地址。
9 .shstrtab:和strtab类似,不过保存是段名,也就是说里面保存的字符串是所有段的名字
10 Section Header Table:段表,保存了所有段的信息,本身通过Elf头找到,可以解析出所有段的位置。
可以通过"objdump"和"readelf"来查看目标文件内的细节。C++的编译与链接简介_第1张图片

3. C++语言

C++语言是在C语言的基础上开发的,C++基本兼容C语言的语法。1998年,第一版C++标准发布,包括语言特性,C++标准库和STL库。此后经历了多次大的修改。

标准版本 发布时间 正式名称 更新内容
C++ 03 2003年 ISO/IEC 14882:2003 对C++ 98版本的漏洞做了部分修改。 [14]
C++ 11 2011年8月12日 ISO/IEC 14882:2011 对容器类的方法做了三项主要修改:
1、新增了右值引用,可以给容器提供移动语义。
2、新增了模板类initilizer_list,因此可将initilizer_list作为参数的构造函数和赋值运算符。
3、新增了可变参数模板(variadic template)和函数参数包(parameter pack),可以提供就地创建(emplacement)方法。 [15]
C++ 14 2014年8月18日 ISO/IEC 14882:2014 C++11的增量更新。主要是支持普通函数的返回类型推演,泛型lambda,扩展的lambda捕获,对constexpr函数限制的修订,constexpr变量模板化等。 [18]
C++ 17 2017年12月6日 ISO/IEC 14882:2017 新增UTF-8 字符文字、折叠表达式(fold expressions):用于可变的模板、内联变量(inline variables):允许在头文件中定义变量;在if和switch语句内可以初始化变量;结构化绑定(Structured Binding):for(auto [key,value] : my_map){…};类模板参数规约(Class Template Argument Deduction):用pair p{1, 2.0}; 替代pair{1, 2.0};;>;static_assert的文本信息可选;删除trigraphs;在模板参数中允许使用typename(作为替代类);来自 braced-init-list 的新规则用于自动推导;嵌套命名空间的定义;允许命名空间和枚举器的属性;新的标准属性:[[fallthrough]], [[maybe_unused]] 和 [[nodiscard]];对所有非类型模板参数进行常量评估;Fold表达式,用于可变的模板;A compile-time static if with the form if constexpr(expression);结构化的绑定声明,允许auto [a, b]=getTwoReturnValues()。 [24]
C++ 20 2020年12月7日 ISO/IEC 14882:2020 新增模块(Modules)、协程(Coroutines)、范围 (Ranges)、概念与约束 (Constraints and concepts)、指定初始化 (designated initializers)、操作符“<=> != ==”;constexpr支持:new/delete、dynamic_cast、try/catch、虚拟、constexpr向量和字符串;计时:日历、时区支持。 [20]

3. 编译器

常用的C++编译器有Microsoft C++和g++。编译器除了支持不同版本的语言特性外,同时也会提供相应的标准库。g++是开源的,且提供相应的标准库libstdc++.so (动态库),libstdc++.a(静态库)。Microsoft C++是与Visual Stduio同时发布的编译工具,收费不开源,其提供的标准库是msvcp100.dll(动态库),msvcprt.lib(静态库)。C++标准提出语言特性和标准库的功能,但是编译器厂商不一定会实现所有标准,可能会在不同版本中逐步实现,甚至像Microsoft会修改部分标准,提供一些不同的功能。
下面的连接有详细记录不同的编译器对C++标准的实现情况。
https://zh.cppreference.com/w/cpp/compiler_support#cpp2a

4. 编译

C++是分离编译的,即一个源文件一个源文件单独编译的。编译主要做两方面的工
C++的编译与链接简介_第2张图片

4.1. 预编译

此阶段主要是由预处理器对预处理指令(#include、#define和#if)进行处理,简单来讲就是一个替换功能,将#include的文件都添加进入源文件,将#define的宏都展开,将#if 0内的代码都删除,将#if 1范围内的代码都使能。

4.2. 编译

编译即将预编译过的源文件编译生成为目标文件(MSVC的.obj文件,gcc下的.o文件)。函数是最小的可执行代码段,所以编译是以函数为最小单元进行的。编译函数A时,其中调用了函数B,此时编译器会直接去找函数B的声明,如果找不到,就直接报函数未定义了。如果找到了,编译器直接将函数B的声明分配一个对应的相对地址。函数A调用函数B时,就直接使用这个函数地址即可。也即,在编译此源文件时,函数B是否有定义(实现),不影响其编译。假如函数B在其他源文件中,此时的函数B不可能是真实地址。在编译期间的所有函数地址都只是一相相对地址,只有在链接时,才会为函数调用分配实际地址。
image.png
如上图,每一条汇编指令,都对应一条机器码指令。相同内核平台上的不同编译器,如MSVC和g++,其编译出的汇编指令可能略有不同,但是其汇编指令都是相同的。也即MSVC编译出的目标文件和g++编译出的目标文件有可能互相调用。但是不同编译器实现重载的方式不同,导致生成的函数名不一样。例如MSVC:
int Test1(char var1,unsigned long)-----“?Test1@@YGHPADK@Z”
void Test2() -----“?Test2@@YGXXZ”
这就导致g++如果想去MSVC生成的目标文件中Test2函数的定义时,可能找不到,因为规则不一样。C++编译的规则不一样,但是C语言的调用约定大家是统一的,都是
*__cdecl**方式。这样只要MSVC导出一个C接口,也即通过extern "C"来修改函数编译约定,这样编译出的接口,g++也认识。

4.3. Debug & Release

  1. Debug,即用来调试的,那么代码不能被优化了,例如,函数不能内联了,for循环不能因为优化而变形自动展开了等。C++的编译与链接简介_第3张图片
  2. Debug中还定义了一个宏_DEBUG。C++标准库中,MFC库中都有大量的Debug代码,用来检查参数的,会影响代码的效率。所以这些代码只在定义了宏_DEBUG才执行。C++的编译与链接简介_第4张图片
  3. 生成调试信息文件,调试文件即PDB文件。g++生成的调试信息直接嵌入到可执行文件中,方式和MSVC不一样。

C++的编译与链接简介_第5张图片
Rlease下,如果也添加宏_DEBUG,并且关闭优化,那么其实际效果和Debug就一样了。

5. 链接

链接,主要是将许多编译源文件生成的目标文件以及各种资源文件(如图标、菜单等)合并成一个可执行文件。a.cpp对应的目标文件a.obj中的funA调用了b.cpp函数中的funB。那么链接a.obj时,就会去其他obj文件中找是否有funB的实现,如果找不到,就会报链接错误,没有找到funB的实现函数。找到funB函数的实现,就会将原来编译期间设置的相对地址改为一个实际的链接地址。
假设a.cpp是由MSVC编译的,b.cpp由g++编译的,那么funA如何正常调用funB呢?
必须extern “C” void funB();这样来限定其为C编译风格,这样才能正常调用。

5.2. 示例

#include 

int main(int argc, char *argv[])
{
	std::cout << "Hello world!" << std::endl;
}

MSVC命令行编译链接:

call "C:\Program Files (x86)\Microsoft Visual Studio 10.0\VC\vcvarsall.bat"
cl /nologo /MDd /D "_DEBUG" /D "_AFXDLL"  /c main.cpp
link /OUT:"main_vc.exe" /nologo /subsystem:console /TLBID:1 /DYNAMICBASE /NXCOMPAT main.obj
main_vc.exe
pause

g++命令编译链接:

g++ -std=c++11 -Wall -Wextra -g -Iinclude -c main.cpp  -o main.o
g++ -std=c++11 -Wall -Wextra -g -Iinclude -o main.exe main.o  -Llib
main.exe
pause

Demosrc.zip

6. 静态库

MSVC静态库以.lib结尾,g++静态库以.a结尾。静态库非常简单,可以理解为将几个.obj文件合并一个压缩文件。.lib文件都可以压缩工具解压为一个个.obj目标文件的。如:静态库math.lib/math.a中包含两个目标文件a.obj和b.obj。
此时有一个main.cpp的代码,其中用到了a.cpp和b.cpp中的函数,怎么办呢?
方法一:MSVC直接将a.obj和b.obj添加进工程中,链接的时候,自动会去a.obj或b.obj中去找。
g++ main.cpp a.o b.o -o main.exe
方法二:MSVC直接添加math.lib进程中,#pragma commet(lib, “math.lib”)
g++ main.cpp -o main.exe -L./ -ladd
C++标准库的静态库为Libcmtd.lib,MFC静态库为Nafxcwd.lib;
MSVC还提供MFC框架,封装了一些UI开发的,还有一些CFile等类。MFC的相关实现代码放在mfc100.dll中。MFC中有重新实现new,delete函数,添加了一些调试信息。mfc工程test.exe中有用到new,math.dll则一个纯C++工程,里面只用到标准C++库的函数,有new。MFC库中的new兼容标准库中的new。但是如果test.exe调用math.dll后,先连接C++标准库中的new时,可能就会有异常。此时必须告诉编译器,先链接mfc库中的new。

7. 动态库

MSVC的动态库以.dll结尾,g++的动态库以.so结尾。链接后生成的动态库,就是一个可执行代码,只不过其中没有启动代码,所以无法单独执行,只能被其他模块调用。动态库导出的函数接口,如果是C风格,那么只要支持C风格的编译器生成的exe都可以调用(基本上所有的语言都识别C风格的接口)。如果导出的接口是C++风格,那么就只能被相同的编译器使用了。
一份代码,MS2010可以编译,MS2019也可以编译,MS2010编译的main.cpp生成的main.exe,MS2019编译的math.cpp生成的math.dll。main.exe是否可以调用math.dll呢?math.dll导出的是C风格的接口,理论上main.exe应该是可以调用的。但是我们前面讲到编译器提供了两大功能,一个是编译链接器,一个是标准库。如果main.cpp中用到一个标准库的函数std::calc,math.cpp中也用到一这个标准库的函数std::calc。如果MS2019的标准库中这个函数有升级修改,添加新了参数。那么实际运行中,内存中先加载了MS2010中的标准库,在运行math.cpp中的std::calc时,会先去当前内存中有没有这个函数的实现,有就调用。此时已经有VS2010的标准库的std::calc的实现,那么就直接调用,然而实际的参数都改变了,这样调用就会异常了。
但是math.cpp中完全不用标准库的代码,这个时候就不会出现标准库不同导致的异常了。
当然这只是特殊情况,一般情况下,我们都多少会用到标准库的函数。因为exe/dll之间的相互调用,默认都应该保持相同版本的编译器来实现,避免可能细小的不同导致的异常。
MSVC支持标准C,即MSVC可以编译.c文件,标准C库的函数也可以使用。.cpp中也可以使用标准C的函数。MSVC编译.c时,使用的是C编译器,此时的标准库为msvcr100.dll中的实现函数。如果.cpp中使用标准C函数如strlen,其实现是在msvcrp100.dll中。msvcr100.dll和msvcrp100.dll是相同编译器编译的,其中C标准函数的实现是一样的,不会产生异常。

8. MSVC运行时库介绍

8.1. 静态库

标准C库,msvcrtd.lib(debug),msvcrt.lib(Release)
标准C++库,LIBCMTD.lib(Debug), LIBCMT.lib(Release)
MFC库:UAFXCWD.lib(Debug),UAFXCW.lib(Release)

8.2. 动态库

标准C库,msvcr100d.dll(debug),msvcr100.dll(Release)
标准C++库,msvcp100d.dll(Debug), msvcp100.dll(Release)
MFC库:MFC100D.DLL(Debug),MFC100.dll(Release)

8.3. 其他

MSVC还支持多字节和Unicode,上面的默认是多字节的库,其实Unicode的接口不一样,另外有一套库。

8.4. 选择

标准C/C++/MFC库,都是exe运行时需要加载的库,那么是选择使用这些库的静态版本还是动态版本呢?
前面说了静态库相当于obj的压缩集合,使用静态库,相当把所有文件编译进exe/DLL中,这样更方便,但是文件更大。如果使用动态库,那么运行时就必须加载这些库。装有MSVC的电脑,系统目录中已经装了这些动态库,所以这种方式下的exe会直接去系统目标中加载这些运行时库。但是如果想发布给别人的电脑,别人电脑上没有装这三个库,那么我们需要将我们开发电脑上的这三个文件(不同MSVC版本,dll名字不同,MS2010对应100)。
使用静态库,编译的最终结果更大,使用共享库发布版本需要附加3个运行时库。因为共享库在使用内存时更方便,一般建议使用共享库方式。但是有时只需要发布一个exe时,就需要静态库版本。

版 本 类 型 使用的library 被忽略的library 备注
Release静态 多线程 libcmt.lib libc.lib, msvcrt.lib, libcd.lib, libcmtd.lib, msvcrtd.lib MT
Release动态 使用DLL的多线程 msvcrt.lib libc.lib, libcmt.lib, libcd.lib, libcmtd.lib, msvcrtd.lib MD
Debug静态 多线程 libcmtd.lib libc.lib, libcmt.lib, msvcrt.lib, libcmtd.lib, msvcrtd.lib MTd
debug动态 使用DLL的多线程 msvcrtd.lib libc.lib, libcmt.lib, msvcrt.lib, libcd.lib, libcmtd.lib MDd

C++的编译与链接简介_第6张图片

9. 调试原理

9.1. pdb

MSVC需要选择Debug版,并且生成PDB文件。PDB(Program Data Base),意即程序的基本数据,是VS编译链接时生成的文件。DPB文件主要存储了VS调试程序时所需要的基本信息,主要包括源文件名、变量名、函数名、FPO(帧指针)、对应的行号等等。因为存储的是调试信息,所以一般情况下PDB文件是在Debug模式下才会生成。这样在按F5进入调试时,当打一个断点(断点就是int3指令,即中断指令,即中断当前正在执行的代码。打断点,就是在直接修改正在执行的代码指令,通过PDB文件中函数代码行对应的机器码指令位置,将相应位置的代码改为int3,这样代码运行到此处即会中断停下来)。中断下来之后,程序进程内存中地有当前代码执行的堆栈,其和代码是一一对应的,PDB就是记录代码堆栈与源之间的对应关系,这样就可以找到相应变量当前的值,当前函数被谁调用的等调试信息。
PDB文件记录了当前源代码的绝对路径,并且PDB文件和生成的exe之间有md5校验,保证其是一一对应的。哪怕没有修改代码,重新编译生成的pdb文件和之前的exe也是不匹配的。

9.2. dump

代码发生异常中止时,可以生成dump文件(linux下生成coredump文件),dump文件可以复用工具手动操作生成,也可以在代码中集成自动生成。dump文件就是当前进程的整个内存,所以其中记录了寄存器,堆栈等信息,再结合PDB文件,就可以完成调试了。

10. 断点

函数断点,我们经常用,鼠标移到某一行,按下F9,即设置了最普通的断点。断点还有很多高级功能,熟练掌握,能够让我们调试更方便。

10.1. 条件断点

条件只要是表达式即可
C++的编译与链接简介_第7张图片

10.2. 触发次数断点

C++的编译与链接简介_第8张图片

10.3. 过滤断点

C++的编译与链接简介_第9张图片

10.4. 数据断点。

断点除了我们常用的函数断点外,再就有的就是数据断点了。在Breakpoints窗口左上角可以选择New Data Breakpoint。
C++的编译与链接简介_第10张图片
数据断点可以用来查找某个变量在复杂的代码里,究竟是在哪里产生的改变。另外,针对内存覆盖导致的崩溃,用数据断点也是比较方便。内存覆盖导致的崩溃,往往是某个重要变量被影响到了导致的。所以我只要找到重要变量,用数据断点监控起来就可以了。

11. 内存泄露

11.1. 开启检测

内存申请没有相应的释放,就会导致内存泄露。MSVC提供一个接口,用于检测内存泄露。

#include 
_CrtSetDbgFlag(_CRTDBG_ALLOC_MEM_DF|_CRTDBG_LEAK_CHECK_DF);

其原理是,在每处理调用new,malloc等申请内存的时候,都记录有哪些内存申请了并记录详细的函数及行数,然后在delete、free等释放内存的地方去年已经申请的内存。如果程序完全退出了,还有没有释放的内存,就通过output输出相关信息。如:

Dumping objects ->
d:\marius\vc++\debuggingdemos\debuggingdemos.cpp(103) : {341} normal block at 0x00F71F38, 8 bytes long.
Data: <        > CD CD CD CD CD CD CD CD 
Object dump complete.

MFC工程,默认已经调用了_CrtSetDbgFlag,开启了内存泄露检测。Win32工程没有开启,需要手动添加_CrtSetDbgFlag调用才能开启内存泄露检测。

11.2. 方法

但是有的时候,output报出的信息不太准确。此时可以使用第三方的内存检测工具。如:使用第三方工具,如Visual Leak Detector。下载安装,然后在你觉得最可能导致内存泄露的模块代码里,加上#include 即可。如果有stdafx.h,#include 直接放在这个文件里也是可以的。然后编译,调试运行,退出,然后就会在Output窗口里显示内存泄露的有关详细信息。
即便是第三方工具,有时也不太准确。所以修改的代码及时上传服务器,这样如果新添加的代码产生了内存泄露,和之前的代码一对比,就非常容易找到出问题的地方。

你可能感兴趣的:(C-C++,c++,开发语言)