什么是编译器

编译器,是一个根据源代码生成机器码的程序。

> g++ main.cpp -o a.out

该命令会调用编译器程序 g++,让他读取 main.cpp 中的字符串(称为源码),并根据 C++ 标准生成相应的机器指令码,输出到 a.out 这个文件中,(称为可执行文件)。

> ./a.out

之后执行该命令,操作系统会读取刚刚生成的可执行文件,从而执行其中的机器码,调用系统提供的 printf 函数,并在终端显示出 hello, world。

// main.cpp
#include <cstdio>
​
int main()
{
    // 或 std::printf("Hello, world!\n");
    printf("Hello, world!\n");
    return 0;
}

厂商

C

C++

GNU

gcc

g++

LLVM

clang

clang++

多文件编译与链接

// hello.cpp
#include <cstdio>

void hello() 
{
    printf("Hello, world\n");
}
// main.cpp
#include <cstdio>

void hello();

int main()
{
    hello();
    return 0;
}
> g++ hello.cpp main.cpp -o a.out

单个文件编译虽然方便,但也有如下缺点:

  • 所有的代码都堆在一起,不利于模块化和理解;

  • 工程变大时,编译时间变得很长,改动一个地方就得全部重新编译。

因此,我们提出多文件编译的概念,文件之间通过符号声明相互引用。

> 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

为什么需要构建系统(Makefile)

文件越来越多,一个个调用 g++ 编译链接会变得很麻烦。于是,发明了 make 程序,只需写出不同文件之间的依赖关系,和生成各文件的规则。

> make a.out

敲下这个命令,就可以构建出 a.out 这个可执行文件。

Makefile 示例

a.out: hello.o main.o
  g++ hello.o main.o -o a.out
​
hello.o: hello.cpp
  g++ -c hello.cpp -o hello.o
​
main.o: main.cpp
  g++ -c main.cpp -o main.o

和直接用一个脚本写出完整的构建过程相比,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 不适用。

构建系统的构建系统(CMake)

为了解决 make 的以上问题,跨平台的 CMake 应运而生。只需要写一份 CMakeLists.txt,就能够在调用时生成当前系统所支持的构建系统。

示例

cmake_minimum_required(VERSION 3.12)
project(hellocmake LANGUAGES CXX)
​
add_executable(a.out main.cpp hello.cpp)
cmake -B build

CMake 可以自动检测源文件和头文件之间的依赖关系,导出到 Makefile 里。

CMake 具有相对高级的语法,内置的函数能够处理 configure,install 等常见需求。

CMake 可以自动检测当前的编译器,需要添加哪些 flag。比如 OpenMP,只需要在 CMakeLists.txt 中指明 target_link_libraries(a.out OpenMP::OpenMP_CXX) 即可。

示例

> cmake -Bbuild -DCMAKE_CXX_COMPILER=clang++ -DCMAKE_CXX_STANDARD=17

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

为什么需要库(library)

有时候我们会有多个可执行文件,他们之间用到的某些功能是相同的,我们想把这些共用的功能做成一个库,方便大家一起共享。库中的函数可以被可执行文件调用,也可以被其他库文件调用。

库文件又分为静态库文件动态库文件

其中静态库相当于直接把代码插入到生成的可执行文件中,会导致体积变大,但是只需要一个文件即可运行。而动态库则只在生成的可执行文件中生成"插桩"函数,当可执行文件被加载时会读取指定目录中的 .dll 文件,加载到内存中空闲的位置,并且替换相应的”插桩"指向的地址为加载后的地址,这个过程称为重定向。这样以后函数被调用就会跳转到动态加载的地址去。

Windows:dll 配套一个 lib(存放"插桩"函数),可执行文件同目录,其次是环境变量%PATH%;

Linux:ELF 格式可执行文件的 RPATH,其次是 /usr/lib 等。

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
cmake_minimum_required(VERSION 3.12)
project(hellocmake LANGUAGES CXX)
​
add_library(hellolib STATIC hello.cpp)
add_executable(a.out main.cpp)
target_link_libraries(a.out PUBLIC hellolib)

为什么 C++ 需要声明

为什么需要在 main.cpp 中声明 hello?

  1. 因为需要知道函数的参数和返回值类型:这样才能支持重载,隐式类型转换等特性。例如 show(3),如果声明了 void show(float x) ,那么编译器知道把 3 转换成 3.0f 才能调用;

  2. 让编译器知道 hello 这个名字是一个函数,不是一个变量或者类的名字:这样当我写下 hello() 的时候,知道是想调用 hello 这个函数,而不是创建一个叫 hello 的类的对象。

其实,C++ 是一种强烈依赖上下文信息的编程语言,举个例子:

vector<MyClass> a;  // 声明一个由 MyClass 组成的数组

如果编译器不知道 vector 是个模版类,那他完全可以把 vector 看做一个变量名,把 < 解释为小于号,从而理解成判断 vector 这个变量的值是否小于 MyClass 这个变量的值。正因如此,在 C++ 代码中看见这样的写法:typename decay<T>::type,因为 T 是不确定的,导致编译器无法确定 decay<T> 的 type 是一个类型,还是一个值。因此用 typename 修饰来让编译器确信只是一个类型名。

CMake 中的子模块

复杂的工程中,需要划分子模块,通常一个库一个目录,比如

hellolib
  CMakeLists.txt
  hello.cpp
  hello.h
CMakeLists.txt
main.cpp

把 hellolib 库的东西移到 hellolib 文件夹下,里面的 CMakeLists.txt 定义了 hellolib 的生成规则。要在根目录使用他,可以用 CMake 的 add_subdirectory 添加子目录,子目录也包含一个 CMakeLists.txt,其中定义的库在 add_subdirectory 之后就可以在外面使用。

子目录的 CMakeLists.txt 里路径名(比如 hello.cpp)都是相对路径,这也是方便点之一。

hellolib/CMakeLists.txt

add_library(hellolib STATIC hello.cpp)

CMakeLists.txt

cmake_minimum_required(VERSION 3.12)
project(hellocmake LANGUAGES CXX)
​
add_subdirectory(hellolib)
add_executable(a.out main.cpp)
target_link_libraries(a.out PUBLIC hellolib)

子模块的头文件如何处理

因为 hello.h 被移到了 hellolib 子文件夹里,因此 main.cpp 里也要改成:

#include <cstdio>
#include "hellolib/hello.h"
​
int main()
{
    hello();
    return 0;
}

如果要避免修改代码,可以通过 target_include_directories 指定。

add_executable(a.out main.cpp)
target_link_libraries(a.out PUBLIC hellolib)
target_include_directories(a.out PUBLIC hellolib)

a.out 的头文件搜索目录,第一个 hellolib 是库名,第二个是目录。

这样甚至可以用<hello.h>来引用这个头文件了,因为通过 target_include_directories 指定的路径会被视为与系统路径等价。

#include <cstdio>
#include <hello.h>
​
int main()
{
    hello();
    return 0;
}

但是这样如果另一个 b.out 也需要用 hellolib 这个库,难道也得再指定一遍搜索路径吗?

不需要,其实我们只需要定义 hellolib 的头文件搜索路径,引用他的可执行文件, CMake 会自动添加这个路径

add_library(hellolib STATIC hello.cpp)
target_include_directories(hellolib PUBLIC .)

这里用了 . 表示当前路径,因为子目录里的路径是相对路径,类似还有 .. 表示上一层目录。此外,如果不希望让引用 hellolib 的可执行文件自动添加这个路径,把 PUBLIC改成PRIVATE即可。这就是他们的用途:决定一个属性要不要在被 link 的时候传播。

目标的一些其他选项

除了头文件搜索目录以外,还有这些选项,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)         # 添加编译器命令行选项

windows 中

add_definitions(-DNOMINMAX)   # windows 坑点

第三方库-作为纯头文件引入

  1. nothings/stb:覆盖图像、声音和字体等;

  2. Neargye/magic_enum:枚举类型的发射,如枚举转字符串等;

  3. g-truc/glm:模仿 GLSL 语法的数学矢量/矩阵库;

  4. Tencent/rapidjson:单纯的 JSON 库,甚至没有依赖 STL;

  5. ericniebler/range-v3:C++20 ranges 库就是受到他启发;

  6. fmtlib/fmt:格式化库(需要 -DFMT_HEADER_ONLY);

  7. gabime/spdlog:适配控制台,安卓等多后端的日志库(和 fmt 冲突!);

只需要把他们的 include 目录或头文件下载下来,然后 include_directories(spdlog/include) 即可。缺点:函数直接实现在头文件里,没有提前编译,从而需要重复编译同样内容,编译时间长。

第三方库-作为子模块引入

通过 add_subdirectory

示例

cmake_minimum_required(VERSION 3.12)
project(hellocmake LANGUAGES CXX)
​
add_subdirectory(fmt)
​
add_executable(a.out main.cpp)
target_link_libraries(a.out PUBLIC fmt)
  1. fmtlib/fmt:格式化库(需要 -DFMT_HEADER_ONLY);

  2. gabime/spdlog:适配控制台,安卓等多后端的日志库(和 fmt 冲突!);

  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:各种图形学算法大合集。

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 这三个组件。因此为了避免冲突,每个包都享有一个独立的名字空间,以 :: 的分割。

示例

find_package(TBB REQUIRED COMPONENTS tbb tbbmalloc REQUIRED)
target_link_libraries(myexec PUBLIC TBB::tbb TBB:tbbmalloc)
cmake_minimum_required(VERSION 3.12)
project(hellocmake LANGUAGES CXX)
​
add_executable(a.out main.cpp)
​
find_package(fmt REQUIRED)
target_link_libraries(a.out PUBLIC fmt::fmt)

不同的包之间常常有依赖关系,而包管理器的作者为 find_package 编写的脚步(例如:/usr/lib/cmake/TBB/TBBConfig/cmake)能够自动查找所有依赖。

补充

# 搜索所有的 cpp, 加入 SRCS 变量中
aux_source_directory(. SRCS)

# 递归将本文件夹下所有 cpp 放到 FUNCS 中
file(GLOB_RECURSE FUNCS ./ *.cpp)

资源文件拷贝

需求是当我们工程中出现资源文件(图片、模型、音频、视频、动态链接库等),都需要拷贝到编译链接完成的 exe 所在目录下面,才能够被程序正确读取,所以需要拷贝功能。

# 把需要拷贝的资源路径都放到 ASSETS 里
file(GLOB ASSETS "./assets" "thirdParty/assimp-vc143-mtd.dll")

# 把 ASSETS 指代的目录集合的内容,都拷贝到可执行文件目录下
file(COPY ${ASSETS} DESTINATION ${CMAKE_BINARY_DIR})

当工程里,有多个 main 程序的时候,希望可以每次选择一个执行

add_executable(main "main.cpp")
add_executable(main2 "main2.cpp")

参考 https://www.bilibili.com/video/BV1fa411r7zp/?spm_id_from=333.999.0.0