0-前置知识及cmake入门

前置知识/背景

cpp文件的编译与链接

假设我们有个代码文件main.cpp,单个文件可以通过以下指令进行编译和运行:

# 编译
g++ main.cpp -o a.out
# 运行
./a.out

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

  • 所有的代码都堆在一起,不利于模块化管理
  • 单文件代码量很大时,编译时间很长,十分麻烦

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

# 分为m1模块和main主模块
# -c 编译生成临时的对象文件
g++ -c m1.cpp -o m1.o
g++ -c main.cpp -o main.o
# 对他们进行链接,得到最终可执行文件
g++ m1.o main.o -o a.out

这样做解决了单文件编译的缺点,但随着模块的增长,敲的指令也变多了,很麻烦、

Makefile

Makefile便诞生了,只需一个文件和一个指令即可完成上边的操作:

Makefile:

a.out: m1.o main.o
	g++ m1.o main.o -o a.out
m1.o: m1.cpp
	g++ -c m1.cpp -o m1.o
main.o: main.cpp
	g++ -c main.cpp -o main.o

指令:

make
# 加快速度
make -j

但是也有一些不足:

  • make指令在Unix上通用,但Windows不是
  • 要准确指明每个项目间的依赖关系,规模大的时候(如hit-oslab)会很头疼。
  • make语法简单,不能做很多判断。
  • 不同编译器的指令不一样。

库(library)

有时候会碰到多个可执行文件共用某些功能的情况,可以把这些共用的功能做成一个 ,方便共享。

库中的函数可以被可执行文件调用,也能被其它库文件调用。库文件分为 静态库文件 动态库文件(dll, so) 两种。静态库相当于直接把代码插到可执行文件中,会导致体积变大;动态库会在可执行文件相应位置生成“插桩”函数,执行时会先读取dll文件,将对应功能加载到内存空闲位置,执行到此处会跳转到加载后的地址(插桩在读取dll后会被替换)。

Windows中,先会在可执行文件所在目录查找dll,其次是环境变量%PATH%;Linux中,先在elf可执行文件的RPATH中查找,其次是/usrt/lib等。

CMake入门

为了解决make的以上问题,跨平台的CMake便诞生了。上边Makefile对应的CMakeLists.txt如下:

cmake_minimum_required(VERSION 3.12)
project(hellocmake LANGUAGES CXX)

add_executable(a.out main.cpp m1.cpp)

这是CMakeLists.txt文件的基本格式,其中,第4行的意思是,a.out为输出的文件,后面的全是输入的文件。

CMake的命令行调用

读取当前目录的CMakeLists.txt,并在build文件夹下生成build/Makefile:

cmake -B build

make读取build/Makefile,并开始构建a.out:

make -C build
# 也可以选择更跨平台的做法
make --build build

最后便可以执行build/a.out了。

CMake中的静态库与动态库

可以使用add_library生成库文件:

# 生成静态库 libtest.a/lib
add_library(test STATIC s1.cpp s2.cpp)
# 生成动态库 libtest.so/dll
add_library(test SHARED s1.cpp s2.cpp)

初学建议用静态库,动态库有坑,但他人提供的库大多数是作为动态库的。

创建库后,要在某个 可执行文件 中使用该库,需要:

# 为 myexec 链接刚刚制作的库 libtest.a
target_link_libraries(myexec PUBLIC test)

CMake中的子模块

在复杂的工程中,需要划分子模块,通常一个库一个目录。例如以下文件结构:

module-1
|- CMakeLists.txt
|- m1.cpp
|- m1.h
CMakeLists.txt
main.cpp

模块的CMakeLists.txt如下:

add_library(m1lib STATIC m1.cpp)

要想在根目录中使用子模块,CMakeLists.txt 可以这样写:

cmake_minimum_required(VERSION 3.12)
project(hellocmake LANGUAGES CXX)

# 使用子模块m1lib
add_subdirectory(m1lib)

add_executable(a.out main.cpp)
target_link_libraries(a.out PUBLIC m1lib)

此外,还得修改main.cppm1.h的路径。如果要避免修改代码,可以通过target_include_directories 指定a.out的头文件搜索目录:

# line 9
target_include_directories(a.out PUBLIC m1lib)

这样子指定的路径也被视为系统路径(可以用尖括号包裹起来)。

但是,以上做法违反了 不要重复自己(DRY) 原则,即如果还有其他可执行文件要使用m1模块,还得添加这一行。因此,可以直接将这句话添加到m1模块的CMakeLists.txt中:

# line 2
target_include_directories(m1lib PUBLIC .)

PUBLICPRIVATE决定一个属性要不要在被链接的时候传播。

目标(target)的一些其他选项

target_include_directories(myapp PUBLIC /usr/...):添加头文件搜索目录

target_link_libraries(myapp PUBLIC hellolib):添加要链接的库

target_add_definitions(myapp PUBLIC MY_MACRO=1):添加一个宏定义

target_add_definitions(myapp PUBLIC -DMY_MACRO=1):添加一个宏定义

target_compile_options(myapp PUBLIC -fopenmp):添加编译器命令行选项

target_sources(myapps hello.cpp other.cpp):添加要编译的源文件

引入第三方库

第三方库的引入主要分为三类:纯头文件作为子模块系统预安装

  • 纯头文件库:操作起来最简单,只需把他们的include目录或头文件下载下来,然后include_directories(xxxx/include)即可,例如:

    • 图形学相关的stb_image库,只需添加一个宏定义STB_IMAGE_IMPLEMENTATION并引入一个头文件即可。
    • magic_enum库(实现枚举类的自反射,枚举名以字符串形式输出)
    • glm库:模仿GLSL语法的数学矢量/矩阵库
    • Tencent/rapidjson:JSON解析库,无STL内容
    • range-v3:C++20的range受到他启发而成,但没他好用
    • fmt:提供std::format的替代品,需要宏定义FMT_HEADER_ONLY.

    缺点:函数直接实现在头文件里,没有提前编译,编译时间长。

  • 作为子模块:可以作为CMake子模块引入,即通过add_subdirectory

    • fmt
    • range-v3
    • glm
    • abseil-cpp:补充标准库没有的常用功能
    • backward-cpp:实现C++的堆栈回溯,便于调试
    • googletest:谷歌单元测试框架
    • benchmark:谷歌性能评估框架
    • glfw:OpenGL窗口和上下文管理
    • libigl:各种图形学算法大合集

    缺点:可能会出现菱形依赖,即某个模块被两个模块使用,发生重复定义。

  • 系统预安装:可以通过find_package命令寻找系统中的包/库:

    find_package(fmt REQUIRED)
    target_link_libraries(myexec PUBLIC fmt::fmt)

    现代CMake认为一个包可以提供多个库,又称为组件,为了避免冲突,每个包都享有一个独立的名字空间。例如TBB这个包中有tbb,tbbmalloc,tbbmalloc_proxy这三个组件,可以指定要用那几个组件:

    find_package(TBB REQUIRED COMPONENTS tbb tbbmalloc REQUIRED)
    target_link_libraries(myexec PUBLIC TBB::tbb TBB::tbbmalloc)

    常用package列表如下:

    • fmt::fmt
    • range-v3::range-v3
    • TBB::tbb
    • OpenVDB::openvdb
    • Boost::iostreams
    • Eigen3::Eigen
    • OpenMP::OpenMP_CXX

    不同的包之间常常有依赖关系,包管理器的作者会给find_package编写脚本(xxxConfig.cmake),可以自动且正确处理依赖项。

    包的引用格式和文档可以参考FindBLAS.

包管理器

Linux可以用包管理器来安装第三方库(如aptpacman)等,Windows没有自带的包管理器,可以用微软跨平台的vcpkg包管理器

使用方法:下载vcpkg的源码,放到项目根目录,如图:

然后执行以下命令:

cd vcpkg
./bootstrap-vcpkg.bat
./vcpkg intergrate install
./vcpkg install fmt:x64-windows	 # 安装fmt库(默认最新,无法指定版本)
cd ..
cmake -B build -DCMAKE_TOOL_CHAIN_FILE="%CD%/vcpkg/scripts/buildsystems/vcpkg.cmake"

参考资料

  • 小彭老师 - 高性能并行编程与优化