1 什么是编译器
编译器,是一个根据源代码生成机器码的程序。
> g++ main.cpp -o a.out
该命令会调用编译器程序g
++,
让他读取main.cpp中的字符串(称为源码
),
并根据C
++
标准生成相应的机器指令码,输出到
a.out
这个文件中
,(
称为可执行文件
)。
> ./a.out
之后执行该命令,操作系统会读取刚刚生成的可执行文件,从而执行其中编译成机器码,调用系统提供的printf函数,并在终端显示出Hello, world。
![](https://img-blog.csdnimg.cn/242fcf8975fa4ed9997784c547ceda5e.png)
1.1多文件编译与链接
单文件编译虽然方便,但也有如下缺点:
1.
所有的代码都堆在一起,不利于模块化和理解。
2.
工程变大时,编译时间变得很长,改动一个地方就得全部重新编译。
因此,我们提出多文件编译的概念,文件之间通过
符号声明
相互引用。
> g++ -c hello.cpp -o hello.o
> g++ -c main.cpp -o main.o
其中使用
-c
选项指定生成临时的
对象文件
main.o
,之后再根据一系列对象文件进行链接,得到最终的
a.out
:
•> g++ hello.o main.o -o a.out
1.2 为什么需要构建系统(Makefile)
•
文件越来越多时,一个个调用
g++
编译链接会变得很麻烦。
•
于是,发明了
make
这个程序,你只需写出不同文件之间的
依赖关系
,和生成各文件的规则。
•
> make
a.out
敲下这个命令,就可以构建出
a.out
这个可执行文件了。
和直接用一个脚本写出完整的构建过程相比, make 指明依赖关系的 好处 :1. 当更新了 hello.cpp 时只会重新编译 hello.o ,而不需要把 main.o 也重新编译一遍。2. 能够自动 并行 地发起对 hello.cpp 和 main.cpp 的编译, 加快编译速度( make -j ) 。3. 用通配符批量生成构建规则,避免针对每个 . cpp 和 .o 重复写 g++ 命令( %.o: %.cpp )。但坏处也很明显:1. make 在 Unix 类系统上是通用的,但在 Windows 则不然。2. 需要准确地指明每个项目之间的依赖关系,有头文件时特别头疼。3. make 的语法非常简单,不像 shell 或 python 可以做很多判断等。4. 不同的编译器有不同的 flag 规则,为 g++ 准备的参数可能对 MSVC 不适用。
2 构建系统的构建系统(CMake)
•
为了解决
make
的以上问题,
跨平台的 CMake
应运而生!
•
make
在
Unix
类系统上是通用的,但在
Windows
则不然。
•
只需要写一份
CMakeLists.txt
,他就能够在调用时
生成
当前系统所支持的构建系统。
•
需要准确地指明每个项目之间的依赖关系,有头文件时特别头疼。
•
CMake
可以自动检测源文件和头文件之间的依赖关系,导出到
Makefile
里。
•
make
的语法非常简单,不像
shell
或
python
可以做很多判断等。
•
CMake 具有相对高级的语法,内置的函数能够处理 configure,install 等常见需求
。
•
不同的编译器有不同的
flag
规则,为
g++
准备的参数可能对
MSVC
不适用。
•
CMake
可以自动检测当前的编译器,
需要添加哪些 flag
。比如
OpenMP
,只需要在
CMakeLists.txt
中指明
target_link_libraries(a.out OpenMP::OpenMP_CXX)
即可。
2.1 CMake 的命令行调用
•
读取当前目录的
CMakeLists.txt
,并在
build
文件夹下生成
build/
Makefile
:
•
>
cmake -B build
•
让
make
读取
build/
Makefile
,并开始构建
a.out
:
•
>
make -C build
•
以下命令和上一个等价,
但更跨平台
:
•
>
cmake
--build
build
•
执行生成的
a.out
:
•
> build/
a.out
2.2 为什么需要库(library)
有时候我们会有多个可执行文件,他们之间用到的某些功能是相同的,我们想把这些共用的功能做成一个
库
,方便大家一起共享。
库中的函数可以被可执行文件调用,也可以被其他库文件调用。
库文件又分为静态库文件和动态库文件。
- 静态库相当于直接把代码插入到生成的可执行文件中,会导致体积变大,但是只需要一个文件即可运行。
- 动态库则只在生成的可执行文件中生成“插桩”函数,当可执行文件被加载时会读取指定目录中的.dll文件,加载到内存中空闲的位置,并且替换相应的“插桩”指向的地址为加载后的地址,这个过程称为重定向。这样以后函数被调用就会跳转到动态加载的地址去。
![](https://img-blog.csdnimg.cn/7bc503d132c14ad6983cb1958e619d6a.png)
•
Windows:可执行文件同目录,其次是环境变量%PATH%
•Linux:ELF格式可执行文件的RPATH,其次是/usr/lib等
2.3 CMake 中的静态库与动态库
CMake
除了
add_executable
可以生成
可执行文件
外,还可以通过
add_library
生成
库文件
。
add_library
的语法与
add_executable
大致相同,除了他需要指定是
动态库
还是
静态库
:
add_library
(test
STATIC
source1.cpp source2.cpp)
#
生成静态库
libtest.a
add_library
(test
SHARED
source1.cpp source2.cpp)
#
生成动态库
libtest.so
动态库有很多坑,特别是 Windows 环境下,初学者自己创建库时,建议使用静态库。但是他人提供的库,大多是作为动态库的,我们之后会讨论如何使用他人的库。
创建库以后,要在某个
可执行文件
中使用该库,只需要:
target_link_libraries
(
myexec
PUBLIC test)
#
为
myexec
链接刚刚制作的库
libtest.a
其中
PUBLIC
的含义稍后会说明(
CMake
中有很多这样的大写修饰符)
2.4 为什么 C++ 需要声明
在多文件编译章中,说到了需要在 main.cpp 声明 hello()
才能引用。为什么?
- 因为需要知道函数的参数和返回值类型:这样才能支持重载,隐式类型转换等特性。例如 show(3),如果声明了 void show(float x),那么编译器知道把 3 转换成 3.0f 才能调用。
- 让编译器知道 hello 这个名字是一个函数,不是一个变量或者类的名字:这样当我写下 hello() 的时候,他知道我是想调用 hello 这个函数,而不是创建一个叫 hello 的类的对象。
其实,C++ 是一种强烈依赖上下文信息的编程语言,举个例子:
vector < MyClass > a;
// 声明一个由 MyClass 组成的数组
如果编译器不知道 vector 是个模板类,那他完全可以把 vector 看做一个变量名,把 < 解释为小于号,从而理解成判断‘vector’这个变量的值是否小于‘MyClass’这个变量的值。
正因如此,我们常常可以在 C++ 代码中看见这样的写法:
typename decay<T>::type
因为 T 是不确定的,导致编译器无法确定 decay<T> 的 type 是一个类型,还是一个值。
因此用 typename 修饰来让编译器确信这是一个类型名……
2.5 为什么需要头文件
为了使用
hello
这个函数,
我们刚才在
main.cpp
里声明了
void hello()
。
但是如果另一个文件
other.cpp
也需要用
hello
这个函数呢?也在里面声明一遍?
如果能够只写一遍,然后
自动插入
到需要用
hello
的那些
.
cpp
里就好了
……
2.6头文件 - 批量插入几行代码的硬核方式
没错,
C
语言的前辈们也想到了,他们说,既然每个
.
cpp
文件的这个部分是一模一样的,不如我把
hello()
的
声明
放到单独一个文件
hello.h
里,然后在需要用到
hello()
这个
声明
的地方,打上一个记号,
#include “
hello.h
”
。
然后用一个小程序,自动在编译前把引号内的文件名
hello.h
的内容
插入
到记号所在的位置,这样不就只用编辑
hello.h
一次了嘛
~
后来,这个编译前替换的步骤逐渐变成编译器的了一部分,称为预处理阶段,#define 定义的宏也是这个阶段处理的。
•此外,在实现的文件 hello.cpp 中导入声明的文件 hello.h 是个好习惯,可以保证当 hello.cpp 被修改时,比如改成 hello(int),编译器能够发现 hello.h 声明的 hello() 和定义的 hello(int) 不一样,避免“沉默的错误”。
实际上
cstdio
也无非是提供了
printf
等一系列函数声明的头文件而已,实际的实现是在
libc.so
这个动态库里
。
其中
<
cstdio
>
这种形式
表示
不要在当前目录下搜索
,只在系统目录里搜索
”
hello.h
”
这种形式则
优先搜索当前目录
下有没有这个文件,找不到再搜索系统目录
。
此外,在实现的文件 hello.cpp 中也导入声明的文件 hello.h 是个好习惯:
1. 可以保证当 hello.cpp 被修改时,比如改成 hello(int),编译器能够发现 hello.h 声明的 hello() 和定义的 hello(int) 不一样,避免“沉默的错误”(虽然对支持重载的 C++ 不奏效)2. 可以让 hello.cpp 中的函数需要相互引用时,不需要关心定义的顺序。
![](https://img-blog.csdnimg.cn/a8f8d0dc3d1e458db8eb9bacd8ba5f58.png)
2.7 头文件进阶 - 递归地使用头文件
在
C++
中常常用到很多的
类
,和函数一样,类的
声明
也会被放到头文件中。
有时候我们的函数声明需要使用到某些类,就需要用到声明了该类的头文件,像这样递归地
#include
即可:
![](https://img-blog.csdnimg.cn/9af73adb1c524146884a629f712c12be.png)
•但是这样造成一个问题,就是如果多个头文件都引用了 MyClass.h,那么 MyClass 会被重复定义两遍:
解决方案:在头文件前面加上一行:
#pragma once
这样当预处理器第二次读到同一个文件时,就会自动跳过
通常头文件都不想被重复导入,因此建议在每个头文件前加上这句话
2.8 CMake 中的子模块
复杂的工程中,我们需要划分子模块,
通常一个库一个目录,比如
:
这里我们把
hellolib
库的东西移到
hellolib
文件夹下了,里面的
CMakeLists.txt
定义了
hellolib
的生成规则。
要在根目录使用他,可以用
CMake
的
add_subdirectory
添加子目录,子目录也包含一个
CMakeLists.txt
,其中定义的库在
add_subdirectory
之后就可以在外面使用。
•
子目录的
CMakeLists.txt
里路径名(比如
hello.cpp
)都是相对路径,这也是很方便的一点。
2.9 子模块的头文件如何处理
•
因为
hello.h
被移到了
hellolib
子文件夹里,因此
main.cpp
里也要改成:
![](https://img-blog.csdnimg.cn/da6d613e37ab444cb0cb64b8b08c60a2.png)
•
如果要避免修改代码,我们可以通过
target_include_directories
指定
•
a.out
的头文件搜索目录:
(
其中第一个
hellolib
是库名,第二个是目录
)
![](https://img-blog.csdnimg.cn/75905306294b4d7aa6d617e2754335c3.png)
•
这样甚至可以用
<
hello.h
>
来引用这个头文件了,因为通过
target_include_directories
指定的路径会被视为与系统路径等价:
![](https://img-blog.csdnimg.cn/832f99f0301e4cbe97e33831b8553275.png)
但是这样如果另一个
b.out
也需要用
hellolib
这个库,难道也得再指定一遍搜索路径吗?
不需要,其实我们只需要定义
hellolib
的头文件搜索路径,引用他的可执行文件
CMake
会
自动添加这个路径
:
![](https://img-blog.csdnimg.cn/c3a4dc9297d941a1a9596c79d2222b98.png)
这里用了
.
表示当前路径,因为子目录里的路径是相对路径
..
表示上一层目录
。
此外,如果不希望让引用 hellolib 的可执行文件自动添加这个路径,把 PUBLIC 改成 PRIVATE 即可。这就是他们的用途:决定一个属性要不要在被 link 的时候传播。
2.9.2目标的一些其他选项
除了头文件搜索目录以外,还有这些选项,
PUBLIC
和
PRIVATE
对他们同理:
target_include_directories ( myapp PUBLIC / usr /include/eigen3) # 添加头文件搜索目录target_link_libraries ( myapp PUBLIC hellolib ) # 添加要链接的库target_add_definitions ( myapp PUBLIC MY_MACRO =1) # 添加一个 宏定义target_add_definitions ( myapp PUBLIC - DMY_MACRO =1) # 与 MY_MACRO =1 等价target_compile_options ( myapp PUBLIC - fopenmp ) # 添加编译器命令行选项target_sources ( myapp PUBLIC hello.cpp other.cpp) # 添加要编译的源文件
以及可以通过下列指令(不推荐使用),把选项加到所有接下来的目标去:
include_directories (/opt/ cuda /include) # 添加头文件搜索目录link_directories (/opt/ cuda ) # 添加库文件的 搜索路径add_definitions ( MY_MACRO =1) # 添加一个宏定义add_compile_options (- fopenmp ) # 添加编译器命令行选项
2.10 第三方库 - 作为纯头文件引入
有时候我们不满足于
C++
标准库的功能,难免会用到一些第三方库。
最友好的一类库莫过于纯头文件库了,这里是一些好用的
header-only
库:
1. nothings/stb - 大名鼎鼎的 stb_image 系列,涵盖图像,声音,字体等,只需单头文件!2. Neargye / magic_enum - 枚举类型的反射,如枚举转字符串等(实现方式很巧妙)3. g- truc / glm - 模仿 GLSL 语法的数学矢量 / 矩阵库(附带一些常用函数,随机数生成等)4. Tencent/rapidjson - 单纯的 JSON 库,甚至没依赖 STL(可定制性高,工程美学经典)5. ericniebler /range-v3 - C++20 ranges 库就是受到他启发(完全是头文件组成)6. fmtlib / fmt - 格式化库,提供 std::format 的替代品(需要 - DFMT_HEADER_ONLY )7. gabime / spdlog - 能适配控制台,安卓等多后端的日志库(和 fmt 冲突!)只需要把他们的 include 目录或头文件下载下来,然后 include_directories(spdlog/include) 即可。
缺点:
函数直接实现在头文件里,没有提前编译,从而需要重复编译同样内容,编译时间长。
3. glm - 使用这个神奇的数学库
![](https://img-blog.csdnimg.cn/3df14903e1de454289be9f475e422beb.png)
3.1 第三方库 - 作为子模块引入
•
第二友好的方式则是作为
CMake
子模块引入,也就是通过
add_subdirectory
。
•
方法就是把那个项目(以
fmt
为例)的源码放到你工程的根目录:
•
这些库能够很好地支持作为子模块引入:
1. fmtlib / fmt - 格式化库,提供 std::format 的替代品2. gabime / spdlog - 能适配控制台,安卓等多后端的 日志库3. ericniebler /range-v3 - C++20 ranges 库就是受到他启发4. g- truc / glm - 模仿 GLSL 语法的数学矢量 / 矩阵库5. abseil/abseil- cpp - 旨在补充标准库没有的常用功能6. bombela /backward- cpp - 实现了 C++ 的堆栈回溯便于调试7. google/ googletest - 谷歌单元测试框架8. google/benchmark - 谷歌性能评估框架9. glfw / glfw - OpenGL 窗口和上下文管理10. libigl / libigl - 各种图形学算法大合集
![](https://img-blog.csdnimg.cn/c690468c784846b9808a70bf547bdb66.png)
![](https://img-blog.csdnimg.cn/549e02d4e510476283f666f634a9c363.png)
3.2 CMake - 引用系统中预安装的第三方库
•
可以通过
find_package
命令寻找系统中的包
/
库:
•
find_package
(
fmt
REQUIRED)
•
target_link_libraries
(
myexec
PUBLIC
fmt
::
fmt
)
•
为什么是
fmt
::
fmt
而不是简单的
fmt
?
•
现代
CMake
认为一个
包
(package)
可以提供多个
库
,又称
组件
(components)
,比如
TBB
这个包,就包含了
tbb
,
tbbmalloc
,
tbbmalloc_proxy
这三个组件。
•
因此为避免冲突,每个包都享有一个独立的名字空间,以
::
的分割(和
C++
还挺像的)。
•
你可以指定要用哪几个组件:
•
find_package
(
TBB
REQUIRED COMPONENTS
tbb
tbbmalloc
REQUIRED)
•
target_link_libraries
(
myexec
PUBLIC
TBB
::
tbb
TBB
::
tbbmalloc
)
![](https://img-blog.csdnimg.cn/8f3dd50f92bb435f82e83f53fb842f8e.png)
3.3 第三方库 - 常用 package 列表
1. fmt :: fmt2. spdlog :: spdlog3. range-v3::range-v34. TBB :: tbb5. OpenVDB :: openvdb6. Boost::iostreams7. Eigen3::Eigen8. OpenMP:: OpenMP_CXX
不同的包之间常常有着依赖关系,而包管理器的作者为
find_package
编写的脚本(例如
/
usr
/lib/
cmake
/
TBB
/
TBBConfig.cmake
)能够自动查找所有依赖,并利用刚刚提到的
PUBLIC PRIVATE
正确处理依赖项,比如如果你引用了
OpenVDB
::
openvdb
那么
TBB
::
tbb
也会被自动引用。
其他包的引用格式和文档参考:https://cmake.org/cmake/help/latest/module/FindBLAS.html
3.4 安装第三方库 - 包管理器
Linux
可以用系统自带的包管理器(如
apt
)安装
C++
包。
>
pacman -S fmt
Windows
则没有自带的包管理器。因此可以用跨平台的
vcpkg
:https://github.com/microsoft/vcpkg
使用方法:下载
vcpkg
的源码,放到你的项目根目录,像这样:
![](https://img-blog.csdnimg.cn/7c90d2ea29fc478eb34a91e64e3539a6.png)
•> cd vcpkg
•> .\bootstrap-vcpkg.bat
•> .\vcpkg integrate install
•> .\vcpkg install fmt:x64-windows
•> cd ..
•> cmake -B build -DCMAKE_TOOLCHAIN_FILE="%CD%/vcpkg/scripts/buildsystems/vcpkg.cmake"
by 彭于斌
https://space.bilibili.com/263032155