了解C++编译连接过程,可以深入的理解C++编译的静态库、动态库的互相调用的规则,更容易发现调用过程中出现的各种问题。
内核主要是用来执行指令集的,指令集有很多,早期intel发明的x86指令集,32位地址总线,有哪此寄存器,有哪些汇编指令,如何加载执行可执行文件。后来AMD在x86的基础上扩展支持了64指令,这种新的架构也被称为x86-64,有时也会简称x64或AMD64。x64兼容32位,也即x64有两套指令集。x86,x64是intel和AMD共享专利的指令集,也即这两家的通用CPU指令集是一样的。指令集有很多,还有ARM公司的ARM指令集,还有开源的MIPS指令集。
函数调用过程中,会有一个参数入栈出栈的过程,但是参数是从左边先入栈还是从右边先入栈呢?这里有几种不同的规则。
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不是关键词,因此不能被程序员指定
可执行文件 (executable file) 指的是可以由操作系统进行加载执行的文件。在不同的操作系统环境下,可执行程序的呈现方式不一样。
在windows操作系统下,可执行程序可以是 .exe,.sys,.dll,.com,.obj等类型文件。这类文件被称作PE(Portable Executable)文件,意为可移植的执行的文件。PE文件总的来说就是由DOS文件头,DOS加载模块,PE文件头,区段表和区段这5部分构成的。
一个典型的PE文件包含以下区段:
.text区段 存放可执行的二进制代码区段
.data区段 初始化数据块,比如全局变量
.idata区段 程序所使用的动态链接库等外接函数和文件信息。
.rsrc区段 存放程序的资源,图标,菜单,版本信息等。
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++语言是在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] |
常用的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
C++是分离编译的,即一个源文件一个源文件单独编译的。编译主要做两方面的工
此阶段主要是由预处理器对预处理指令(#include、#define和#if)进行处理,简单来讲就是一个替换功能,将#include的文件都添加进入源文件,将#define的宏都展开,将#if 0内的代码都删除,将#if 1范围内的代码都使能。
编译即将预编译过的源文件编译生成为目标文件(MSVC的.obj文件,gcc下的.o文件)。函数是最小的可执行代码段,所以编译是以函数为最小单元进行的。编译函数A时,其中调用了函数B,此时编译器会直接去找函数B的声明,如果找不到,就直接报函数未定义了。如果找到了,编译器直接将函数B的声明分配一个对应的相对地址。函数A调用函数B时,就直接使用这个函数地址即可。也即,在编译此源文件时,函数B是否有定义(实现),不影响其编译。假如函数B在其他源文件中,此时的函数B不可能是真实地址。在编译期间的所有函数地址都只是一相相对地址,只有在链接时,才会为函数调用分配实际地址。
如上图,每一条汇编指令,都对应一条机器码指令。相同内核平台上的不同编译器,如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++也认识。
Rlease下,如果也添加宏_DEBUG,并且关闭优化,那么其实际效果和Debug就一样了。
链接,主要是将许多编译源文件生成的目标文件以及各种资源文件(如图标、菜单等)合并成一个可执行文件。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编译风格,这样才能正常调用。
#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
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。
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标准函数的实现是一样的,不会产生异常。
标准C库,msvcrtd.lib(debug),msvcrt.lib(Release)
标准C++库,LIBCMTD.lib(Debug), LIBCMT.lib(Release)
MFC库:UAFXCWD.lib(Debug),UAFXCW.lib(Release)
标准C库,msvcr100d.dll(debug),msvcr100.dll(Release)
标准C++库,msvcp100d.dll(Debug), msvcp100.dll(Release)
MFC库:MFC100D.DLL(Debug),MFC100.dll(Release)
MSVC还支持多字节和Unicode,上面的默认是多字节的库,其实Unicode的接口不一样,另外有一套库。
标准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 |
MSVC需要选择Debug版,并且生成PDB文件。PDB(Program Data Base),意即程序的基本数据,是VS编译链接时生成的文件。DPB文件主要存储了VS调试程序时所需要的基本信息,主要包括源文件名、变量名、函数名、FPO(帧指针)、对应的行号等等。因为存储的是调试信息,所以一般情况下PDB文件是在Debug模式下才会生成。这样在按F5进入调试时,当打一个断点(断点就是int3指令,即中断指令,即中断当前正在执行的代码。打断点,就是在直接修改正在执行的代码指令,通过PDB文件中函数代码行对应的机器码指令位置,将相应位置的代码改为int3,这样代码运行到此处即会中断停下来)。中断下来之后,程序进程内存中地有当前代码执行的堆栈,其和代码是一一对应的,PDB就是记录代码堆栈与源之间的对应关系,这样就可以找到相应变量当前的值,当前函数被谁调用的等调试信息。
PDB文件记录了当前源代码的绝对路径,并且PDB文件和生成的exe之间有md5校验,保证其是一一对应的。哪怕没有修改代码,重新编译生成的pdb文件和之前的exe也是不匹配的。
代码发生异常中止时,可以生成dump文件(linux下生成coredump文件),dump文件可以复用工具手动操作生成,也可以在代码中集成自动生成。dump文件就是当前进程的整个内存,所以其中记录了寄存器,堆栈等信息,再结合PDB文件,就可以完成调试了。
函数断点,我们经常用,鼠标移到某一行,按下F9,即设置了最普通的断点。断点还有很多高级功能,熟练掌握,能够让我们调试更方便。
断点除了我们常用的函数断点外,再就有的就是数据断点了。在Breakpoints窗口左上角可以选择New Data Breakpoint。
数据断点可以用来查找某个变量在复杂的代码里,究竟是在哪里产生的改变。另外,针对内存覆盖导致的崩溃,用数据断点也是比较方便。内存覆盖导致的崩溃,往往是某个重要变量被影响到了导致的。所以我只要找到重要变量,用数据断点监控起来就可以了。
内存申请没有相应的释放,就会导致内存泄露。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调用才能开启内存泄露检测。
但是有的时候,output报出的信息不太准确。此时可以使用第三方的内存检测工具。如:使用第三方工具,如Visual Leak Detector。下载安装,然后在你觉得最可能导致内存泄露的模块代码里,加上#include
即便是第三方工具,有时也不太准确。所以修改的代码及时上传服务器,这样如果新添加的代码产生了内存泄露,和之前的代码一对比,就非常容易找到出问题的地方。