CMake
C++程序的执行过程
C++程序从 .cpp文件经过两个过程:
-
编译过程:C++源文件 → 汇编文件
-
链接过程:汇编文件 → 可执行文件
编译过程
编译过程又可以分成两个小过程:1)编译 2) 汇编
编译:词法分析,语法分析,语义分析,中间代码生成,代码优化,代码生成。编译器读取源程序(字符流)通过以上技术生成等效的汇编代码。这里有两个阶段:预处理阶段和编译阶段。
预处理过程:是对源程序的一些伪指令进行处理,伪指令主要包括4个方面:宏定义,条件编译,头文件和特殊符号。这个阶段编译器主要进行替代工作,保证程序没有伪指令。
编译阶段:此时编译器正式进行以上的操作,这里要说明所谓代码优化是有两个部分,一种是针对中间代码的优化,另一种是针对生成代码的优化。前者与硬件无关,主要工作是删除公共表达式、循环优化(代码外提、强度削弱、变换循环控制条件、已知量的合并等)、复写传播,以及无用赋值的删除等等。后者的工作主要是针对不同类型的机器进行的优化,其内容是充分利用机器的各个硬件寄存器存放有关变量的值,以减少对于内存的访问次数等等。最后生成的汇编代码可以被机器所执行。
汇编:这个阶段是将汇编指令翻译成机器码的过程。对于被翻译系统处理的每一个C语言源程序,都将最终经过这一处理而得到相应的目标文件。目标文件中所存放的也就是与源程序等效的目标的机器语言代码。目标文件由段组成,分别是代码段和数据段。代码段包含的主要是程序的指令。该段一般是可读和可执行的,但一般却不可写。数据段主要存放程序中要用到的各种全局变量或静态的数据。一般数据段都是可读,可写,可执行的。
具体过程:编译和汇编阶段对应以下命令:
gcc -e main.c -o main.i #预编译,生成main.i文件
gcc -s main.i #编译,生成main.S文件
gcc -c main.S #汇编,生成main.o文件
二进制可重定位文件(Linux下的.o文件,windows下的.obj文件)的结构和布局:整个obj文件是由ELF header和各种段组成的。其中二进制可重定位文件的头部,可以看到ELF header占64个字节,里面存放着文件类型、支持的平台、程序入口点地址等信息。之后是由段组织而成的而段中又有很多节,这其中包括text段:代码节,数据节,未初始化的全局数据节(.bss),全局偏移表,动态符号节,重定位节等等。
链接过程
在编译过程中还有没有解决的问题:库的问题。在这个过程会将有关的目标文件彼此相连接,也即将在一个文件中引用的符号同该符号在另外一个文件中的定义连接起来,使得所有的这些目标文件成为一个能够被操作系统装入执行的统一整体。分成两步:1)合并所有目标文件的段,并调整段偏移和段长度,分配内存。2)符号重定位。这里的链接方式有两种:静态链接和动态链接。
静态链接
在这种链接方式下,函数的代码将从其所在的静态链接库中被拷贝到最终的可执行程序中。这样该程序在被执行时这些代码将被装入到该进程的虚拟地址空间中。静态链接库实际上是一个目标文件的集合,其中的每个文件含有库中的一个或者一组相关函数的代码。
静态链接形成的库称为静态库,所谓静态库可以简单看成一组目标文件的集合。
以下面这个图来简单说明一下从静态链接到可执行文件的过程,根据在源文件中包含的头文件和程序中使用到的库函数,如stdio.h中定义的printf()函数,在libc.a中找到目标文件printf.o(这里暂且不考虑printf()函数的依赖关系),然后将这个目标文件和我们hello.o这个文件进行链接形成我们的可执行文件。从上面的图中可以看到静态运行库里面的一个目标文件只包含一个函数,如libc.a里面的printf.o只有printf()函数,strlen.o里面只有strlen()函数。实际上,链接器在链接静态链接库的时候是以目标文件为单位的。比如我们引用了静态库中的printf()函数,那么链接器就会把库中包含printf()函数的那个目标文件链接进来,如果很多函数都放在一个目标文件中,很可能很多没用的函数都被一起链接进了输出结果中。由于运行库有成百上千个函数,数量非常庞大,每个函数独立地放在一个目标文件中可以尽量减少空间的浪费,那些没有被用到的目标文件就不要链接到最终的输出文件中。
静态链接的缺点很明显,一是浪费空间,因为每个可执行程序中对所有需要的目标文件都要有一份副本,所以如果多个程序对同一个目标文件都有依赖,如多个程序中都调用了printf()函数,则这多个程序中都含有printf.o,所以同一个目标文件都在内存存在多个副本;另一方面就是更新比较困难,因为每当库函数的代码修改了,这个时候就需要重新进行编译链接形成可执行程序。
静态链接的优点就是,在可执行程序中已经具备了所有执行程序所需要的任何东西,在执行的时候运行速度快。
创建和使用静态链接库
想创建静态链接库,首先不能有main函数,因为main是调用者提供的。同时还需要提供.h(函数声明),以及.cpp(函数实现)。
-
第一步,当你完成编码后,将指定的源文件编译为目标文件:
例如 : g++ -c greeting.cpp name.cpp
使用ls查看:function.h aa.cpp aa.o main.cpp bb.cpp bb.o 这两个黄色的.o文件就是我们生成的目标文件。
-
使用 ar 压缩指令,以及 rcs 各选项的含义和功能 [具体可以参考shell编程的Linux ar命令相关部分]。需要特别说明的是,静态链接库的命名时有规定的 libxxx.a ,其中lib是你必须要加的前缀,而.a是静态链接库的后缀。
其格式 : ar rcs 静态链接库名称 目标文件1 目标文件2 … → ar rcs libmymodel.a aa.o bb.o
- 在Linux发行版系统中,静态链接库文件的后缀名通常用 .a 表示
- 在 Windows系统中,静态链接库文件的后缀名为 .lib 表示
当你打包完成时,就会出现 libmymodel.a (这个名字是我自己起的) 。需要注意的是 这样操作 编译器会在当前目录里面找.h 你可以使用-l参数指定.h文件的位置。因为一个库必须同时提供.h 和 .a (或者.h 和 .so 也就是动态链接库)
-
当你需要把静态链接库链接进程序时,需要使用-l参数指定.h位置 (如果没有编译器会在当前目录里面找.h)
-
先将 main.cpp 编译成.o文件, g++ -c main.cpp
-
g++ -static main.o libmymodel.a 注意,这样链接的话编译器会在当前目录里面找.h。可以使用-l参数添加路径:g++ -static main.o -L /home/testlib/cpp/src -lmyfunction
-l 参数(小写)用来指定 程序需要的库,-l 后紧接库名
如果是自己的目录,则需要-L指定,若在/home/bing/mylib里面 就直接 -L/home/bing/mylib -lmytest test.cpp
-
动态链接
动态链接的基本思想是把程序按照模块拆分成各个相对独立部分,在程序运行时才将它们链接在一起形成一个完整的程序,而不是像静态链接一样把所有程序模块都链接成一个单独的可执行文件。在此种方式下,函数的代码被放到称作是动态链接库或共享对象的某个目标文件中。链接程序此时所作的只是在最终的可执行程序中记录下共享对象的名字以及其它少量的登记信息(注生成“插桩”函数,所谓“插桩”函数只是一个跳转语句,跳转到动态链接文件中,这里“插桩”函数就存在lib文件中,这个lib文件最后会在运行时查找到DLL中,但是Linux并没有和动态链接库(so文件)配套的文件而是直接形成“插桩”函数,Linux和Windows实现动态链接库的方式不同)。在此可执行文件被执行时,动态链接库的全部内容将被映射到运行时相应进程的虚地址空间。动态链接程序将根据可执行程序中记录的信息找到相应的函数代码(替换“插桩”函数),这个过程称之为 重定向。
[例]假设现在有两个程序program1.o和program2.o,这两者共用同一个库lib.o,假设首先运行程序program1,系统首先加载program1.o,当系统发现program1.o中用到了lib.o,即program1.o依赖于lib.o,那么系统接着加载lib.o,如果program1.o和lib.o还依赖于其他目标文件,则依次全部加载到内存中。当program2运行时,同样的加载program2.o,然后发现program2.o依赖于lib.o,但是此时lib.o已经存在于内存中,这个时候就不再进行重新加载,而是将内存中已经存在的lib.o映射到program2的虚拟地址空间中,从而进行链接(这个链接过程和静态链接类似)形成可执行程序。
动态链接的优点显而易见,就是即使需要每个程序都依赖同一个库,但是该库不会像静态链接那样在内存中存在多分,副本,而是这多个程序在执行时共享同一份副本;另一个优点是,更新也比较方便,更新时只需要替换原来的目标文件,而无需将所有的程序再重新链接一遍。当程序下一次运行时,新版本的目标文件会被自动加载到内存并且链接起来,程序就完成了升级的目标。但是动态链接也是有缺点的,因为把链接推迟到了程序运行时,所以每次执行程序都需要进行链接,所以性能会有一定损失。
对于可执行文件中的函数调用,可分别采用动态链接或静态链接的方法。使用动态链接能够使最终的可执行文件比较短小,并且当共享对象被多个进程使用时能节约一些内存,因为在内存中只需要保存一份此共享对象的代码。但并不是使用动态链接就一定比使用静态链接要优越。在某些情况下动态链接可能带来一些性能上损害。据估算,动态链接和静态链接相比,性能损失大约在5%以下。经过实践证明,这点性能损失用来换区程序在空间上的节省和程序构建和升级时的灵活性是值得的
第一步:合并段和分配内存
1)合并段:所有相同属性的段进行合并,组织在一个页面上,这样更节省空间。如.text段的权限是可读可执行,.rodata段也是可读可执行,所以将两者合并组织在一个页面上;同理合并.data段和.bss段。
2)合并符号表:链接阶段只处理所有obj文件的global符号,local符号不作任何处理。
3)符号解析:符号解析指的是所有引用符号的地方都要找到符号定义的地方。
4)分配内存地址:在编译过程中不分配地址(给的是零地址和偏移),直到符号解析完成以后才分配地址。
第二步:符号重定位:因为在编译过程中不分配地址,所以在目标文件所以数据出现的地方都给的是零地址,所有函数调用的地方给的是相对于下一条指令的地址的偏移量。在符号重定位时,要把分配的地址回填到数据和函数调用出现的地方,而且对于数据而言填的是绝对地址,而对函数调用而言填的是偏移量。
创建和使用动态链接库
- 在Linux中动态链接库后缀为 .so
- 在Windows中,动态链接库后缀为 .dll
可以使用源文件创建动态链接库,也可以用目标文件创建动态链接库。同样其命名方式与静态链接库一样。
- 使用源文件 gcc -fpic -shared 源文件名... -o 动态链接库名
- -fpic : (写成fPIC也可以)让编译器生成动态链接库时,表示各目标文件中函数、类等功能模块的地址使用相对地址,而非绝对地址。这样,无论将来链接库被加载到内存的什么位置,都可以正常使用
- -shared 编译器选项用于生成动态链接库
- 使用目标文件
- 先用g++ -c 生成目标文件 g++ -c -fPIC aa.cpp bb.cpp
- 再用目标文件生成动态链接库 g++ -shared greeting.o name.o -o libmymodel.so
在运行由动态链接库生成的可执行文件时,必须确保程序在运行时可以找到这个动态链接库。可以使用ldd xxx来查看xxx运行所需要的动态链接库的位置(如果ldd找不到,则显示 not found),让程序能找打动态链接库的方式有以下几种:
- 将链接库文件移动到标准库目录下 (/user/lib, /user/lib64, /lib, /lib64)
- 在终端输入 export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:xxx 其中xxx为动态链接库文件的绝对存储路径(此方式仅在当前终端有效,关闭终端后无效)
- ~/.bashrc 或者 ~/.bash_profile文件,在最后一行添加 export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:xxx 其中xxx为动态链接库文件的绝对存储路径,保存之后执行source bashrc指令(此方式仅对当前登陆用户有效)
可执行文件
当双击一个可执行程序时,首先解析其文件头部ELF header获取entry point address程序入口点地址,然后按照两个load项的指示将相应的段通过mmap()函数映射到虚拟页面中(虚拟页面存在于虚拟地址空间中),最后再通过多级页表映射将虚拟页面映射到物理页面中,这个分成三步:1)首先是创建虚拟地址到物理内存的映射(创建内核地址映射结构体),创建页目录和页表;2) 再就是加载代码段和数据段;3)把可执行文件的入口地址写到CPU的PC寄存器中。
地址映射过程
在保护模式下,局部变量存放在栈中,而栈的信息存放在栈寄存器SS中,首先我们通过栈寄存器的低两位判断是存在用户空间中还是内核空间中,应用程序肯定是在用户空间中。然后通过第3位判断使用的是LDT(局部段描述符表)还是GDT(全局段描述符表),实验发现32位Linux下使用的是LDT,此时SS的高13位则作为索引,判断该局部变量的存放的段的信息在LDT的哪一项。
GDT中存放的是LDT每一项的具体信息,如LDT的其实地址等信息。此时要根据LDTR来找到该信息存放到了GDT的哪一项,此时可以通过LDTR作为GDT的索引,找到LDT的起始地址。 找到LDT的起始地址以后,再根据SS寄存器中的高13位作为索引,找到段的存放数据的段的起始地址(32位),将起始地址加上偏移量即可得到线性地址。那这个偏移量又怎么得到呢,很简单,这个偏移量也就是我们所谓的逻辑地址,也是CPU发出来的地址,我们可以通过在程序中对该局部变量取地址即可得到。 得到线性地址以后,查看CR0寄存器的最高位PG位,这一位为0表示没有开启内存分页,如果为1则表示开启了内存分页。Linux下基本都会开启内存分页机制。此时得到的线性地址也叫做虚拟地址。这个地址总共32位,分成10+10+12三段,其中高10位地址指示页目录项,次高10位地址指示也表项,最后的12位指示该局部变量在物理内存页面中的偏移量。 从线性地址到物理地址的具体映射过程如下。首先根据CR3寄存器中的值得到页目录的起始地址,然后根据高10位找到指示的页表项,再根据次高10位找到对应的物理页面的起始地址,最后加上低12位的偏移量即可得到局部变量的物理地址。
Makefile
经过编译和链接最终生成了可执行文件。在类Unix系统中,可以使用一个指令来完成这些操作。
g++ main.cpp -o a.out // 其中,out后缀的文件是Unix的可执行文件
对于多文件编译,文件之间使用符号声明的方式相互引用:
g++ -c hello.cpp -o hello.o
g++ -c main.cpp -o main.o
g++ hello.o main.o -o a.out
这时如果hello.cpp进行了修改,那么需要手动的编译刚刚修改过的文件,不然所有的文件都需要重新编译。
[优点]当文件越来越多时,发明了make软件,只要使用make命令并在makefile中指明各个文件依赖关系的链就可以直接构建可执行文件。同时如果hello.cpp进行了修改那么只需要重新编译hello.cpp这个过程可以自动完成。而且可以并行的编译的,也加速了项目的编译。
[缺陷]但是make也是有缺陷的:
1)在大规模工程中,写Makefile也是一件非常麻烦的事情,因为要明确指定各个文件之间的关系
2)make的语法简单,没有条件判断等语句
3)make仅仅在类Unix系统上使用,不能跨平台
4)不同编译器有不同的规则
CMake
为了解决Makefile的问题,产生了CMake,CMake是一个自动构建Makefile的工具。
1)跨平台
2)CMake可以自动检测源文件和头文件之间的依赖关系,不需要指明项目之间的依赖关系
3)CMake有相对高级的语法
4)CMake可以自动检测编译器
CMake的命令行调用
读取当前目录的CMakeList.txt文件,并在build文件夹下生成build/Makefile
cmke -B build
让make读取build/Makefile 并构建a.out
make -C build 其等价命令:cmake —bulid build
执行生成的a.out: build/a.out
CMake的基本语法
基础
创建build目录
编写CMakeList.txt文件,其中需要制定以下几项:cmake版本、工程名、构建目标app的源文件
# cmake版本
cmake_minimum_required(VERSION 3.10)
# 工程名
project(CalculateSqrt)
# 设置C++标准
set(CMAKE_CXX_STANDARD 11)
set(CMAKE_CXX_STANDARD_REQUIRED True)
# 构建目标app的源文件
add_executable(CalculateSqrt hello.cxx)
使用静态库和动态库(Linux)
# 生成静态库 .a文件
add_library(test STATIC source1.cpp source2.cpp)
# 生成动态库 .so文件
add_library(test SHARED source1.cpp source2.cpp)
# 为myexec链接公关制作的库libtest.a
target_link_libraries(mysexe PUBLIC test)
子模块
[例] 把hello库的内容移入file1中,file1中CMakeLists定义了生成规则。此时
# ./ 下
cmake_minimum_required(VERSION 3.10)
# 工程名
project(CalculateSqrt)
# 设置C++标准
set(CMAKE_CXX_STANDARD 11)
set(CMAKE_CXX_STANDARD_REQUIRED True)
# 加入子文件
add_subdirectory(file1)
# 构建目标app的源文件
add_executable(a.out main.cpp)
target_link_libraries(a.out PUBLIC file1)
# ./file1/
add_library(hello STATIC hello.cpp)
由于main在根目录下,而hello.h在file1文件,所以对应的main中#include “hello.h”也要改成#include ”file1/hello.h”。
如果要避免修改,需要通过target_include_directories来指定
target_include_directories(a.out PUBLIC file1)
此时甚至可以使用 #include <hello.h> 来引用。target_include_directories指定的路径会被视为与系统路径等价。
此时的问题是target_link_libraries(a.out PUBLIC file1)和target_include_directories(a.out PUBLIC file1)指定目录重复了。所以使用
add_library(hello STATIC hello.cpp)
target_include_directories(hello PUBLIC .)
# . 就表示当前目录。类似的, .. 表示上一层目录
此外,如果不希望让引用file1的可执行文件自动添加这个路径,把PUBLIC改成PRIVATE即可。这就是它们的用途:决定一个属性要不要在被链接时传播
其他选项
target_include_directories(myapp PUBLIC /usr/include/eigen3):添加头文件搜索目录
target_link_libraries(myapp PUBLIC hellolib):添加要链接的库
target_add_definition(myapp PUBLIC MY_MACRO=1):添加一个宏定义,相当于在文件中#define MY_MACRO 1
target_compile_options(myapp PUBLIC -fopenmp):添加编译器命令行选项target_include_directories(myapp PUBLIC hello.cpp other.cpp):添加要编译的源文件
第三方库
作为纯头文件引用:(github中)
nothings/stb :stb_image系列,涵盖图像,声音,字体
Neargye/magic_enum: 枚举类型反射,给一个枚举能返回枚举的名字,即枚举转字符串
g-truc/glm : 模仿GLSL语法的数学矢量/矩阵库
Tencent/rapidjson :json库,没有依赖STL,可定制性高
ericniebler/range-v3 : C++2.0 的ranges库
fmtlib/fmt:格式化库,提供std::format的替代品
gabime/spdlog :适配控制台,安卓等多后端的日志库。和fmt冲突,因为内置fmt
缺点:函数直接实现在头文件里,没有提前编译,从而需要重复编译同样内容,编译时间长
在C++标准库中也有NumPy , std::valarray
作为子模块引用:(github中)可以通过add_subbirectory来引用
# 例如fmt,把fmt下载到工程根目录
# 使用 get clone .... (GitHub地址)
add_subdirectory(fmt)
add_executable(a.out main.cpp)
target_link_libraries(a.out PUBLIC fmt)
avseil/abseil-cpp : 旨在补充标准库中没有的功能
bombela/backward-cpp : 实现了C++的堆栈回溯便于调试
google/gooletest :谷歌单元测试框架
google/benchmarl :谷歌性能测试框架
glfw/glfw : OpenGL窗口上下文管理
libigl.libigl:各种图形学算法集合
CMake引用系统预安装的第三方库:
通过find_package命令来找系统的包或库:
find_package(fmt REQUIRED)
target_link_libraries(myexec PUBLIC fmt::fmt)
其中 :: 的含义是 现代CMake认为package可以提供多个库,又称组件component。为了避免冲突每个包都有一个命名空间以 :: 来分割
也可以指定要用哪些组件:
find_package(TBB REQUIRED COMPONENTS tbb tbbmalloc REQUIRED)
target_link_kibraries(myexec PUBLIC TBB::tbb TBB::tbbmalloc)
Windows下包管理器:vcpkg - github.com/microsoft/vcpkg
最后推荐一位C++优秀博主 BiliBili :双笙子佯谬(小彭老师)。本篇博客很大程度上是小彭老师课上的笔记以及一点个人补充