CMake Tips
Cmake
为什么需要构建系统(Makefile)
当更新了hello.cpp时只会重新编译hello.o,而不需要把main.o也重新编译一遍
能够自动并行地发起对hello.cpp和main.cpp的编译,加快编译速度(make -j)
用通配符批量生成构建规则,避免针对每个.cpp和.o重复写 g++ 命令(%.o: %.cpp)
但坏处也很明显:
- make 在 Unix 类系统上是通用的,但在 Windows 则不然。
- 需要准确地指明每个项目之间的依赖关系,有头文件时特别头疼。
- make 的语法非常简单,不像 shell 或 python 可以做很多判断等。
- 不同的编译器有不同的 flag 规则,为 g++ 准备的参数可能对 MSVC 不适用。
构建系统的构建系统(CMake)
为了解决 make 的以上问题,跨平台的 CMake 应运而生!
只需要写一份 CMakeLists.txt,他就能够在调用时生成当前系统所支持的构建系统。
CMake 可以自动检测源文件和头文件之间的依赖关系,导出到 Makefile 里。
CMake 具有相对高级的语法,内置的函数能够处理 configure,install 等常见需求。
CMake 3.x (现代)相比 2.x (古代)有所不同:
现代 CMake 和古代 CMake 相比,使用更方便,功能更强大。
CMake 可以自动检测当前的编译器需要添加哪些 flag。比如 OpenMP,只需要在 CMakeLists.txt 中指明 target_link_libraries(a.out OpenMP::OpenMP_CXX) 即可。
CMake 的命令行调用
现代 CMake 提供了更方便的 -B 和 --build 指令,不同平台,统一命令:
1 |
|
cmake -B build 免去了先创建 build 目录再切换进去再指定源码目录的麻烦。 cmake --build build 统一了不同平台(Linux 上会调用 make,Windows 上调用 devenv.exe)。
配置阶段可以通过 -D 设置缓存变量。第二次配置时,之前的 -D 添加仍然会被保留。
1 |
|
-G 选项:指定要用的生成器。Linux 系统上的 CMake 默认用是 Unix Makefiles 生成器;Windows 系统默认是 Visual Studio 2019 生成器;MacOS 系统默认是 Xcode 生成器。 可以用 -G 参数改用别的生成器,例如 cmake -GNinja 会生成 Ninja 这个构建系统的构建规则。Ninja 是一个高性能,跨平台的构建系统,Linux、Windows、MacOS 上都可以用。Ninja 则是专为性能优化的构建系统,他和 CMake 结合都是行业标准了。
性能上:Ninja > Makefile > MSBuild。
1 |
|
读取当前目录的 CMakeLists.txt,并在 build 文件夹下生成 build/Makefile:
cmake -Bbuild
让 make 读取 build/Makefile,并开始构建 a.out:
make -C build
以下命令和上一个等价,但更跨平台:
cmake --build build
执行生成的 build/a.out.
示例:
hello.cpp
1 |
|
main.cpp
1 |
|
多种编译组织方式
普通编译
1 |
|
静态库
除了 add_executable 可以生成可执行文件外,还可以通过 add_library 生成库文件。
生成静态库 hellolib.a。
要在某个可执行文件中使用该库,只需要:
target_link_libraries(a.out PUBLIC hellolib) # 为 a.out 链接刚刚制作的库 hellolib.a
1 |
|
动态库
生成静态库 hellolib.so
1 |
|
对象库
1 |
|
对象库类似于静态库,但不生成 .a 文件,只由 CMake 记住该库生成了哪些对象文件。
对象库是 CMake 自创的,绕开了编译器和操作系统的各种繁琐规则,保证了跨平台统一性。
对象库可保证不会自动剔除没引用到的对象文件。而静态库会自动剔除。
动态库中链接静态库
让静态库编译时也生成位置无关的代码(PIC),这样才能装在动态库里。
1 |
|
PUBLIC 表示所有依赖lib的target都自动绑定了该头文件路径
PRIVATE 表示该头文件路径仅对本target有效
INTERFACE 表示该头文件路径仅对依赖该lib的target生效
PUBLIC = PRIVATE + INTERFACE
子模块
有工程文件:
1 |
|
cmake文件:
1 |
|
子目录下,cmake文件:
1 |
|
add_subdirectory 添加子目录,子目录也包含一个 CMakeLists.txt,其中定义的库在 add_subdirectory 之后就可以在主目录的文件中使用。
如果指定头文件搜索目录:
1 |
|
这样甚至可以用 <hello.h> 来引用这个头文件了,因为通过 target_include_directories 指定的路径会被视为与系统路径等价。这里指定了 hellolib 文件夹名称。
target_link_libraries(a.out PUBLIC hellolib) 中的 hellolib 为子模块的库名。
但是如果有 b.out 也引用了 hellolib:
1 |
|
要重复指定路径吗?
其实只需要在 hellolib 文件夹下的 cmakelists.txt 中指定 hellolib (库名) 的文件路径,设为 PUBLIC ,让使用到 hellolib 的可执行文件自动添加搜索路径即可。
1 |
|
此时主目录下的 cmakelists.txt :
1 |
|
不需要 target_include_directories 设置了。
其他选项
1 |
|
以及可以通过下列指令(不推荐使用),把选项加到所有接下来的目标去:
1 |
|
第三方库
纯头文件库
最友好的一类库莫过于纯头文件库了,这里是一些好用的 header-only 库:
nothings/stb - 大名鼎鼎的 stb_image 系列,涵盖图像,声音,字体等,只需单头文件!
Neargye/magic_enum - 枚举类型的反射,如枚举转字符串等(实现方式很巧妙)
g-truc/glm - 模仿 GLSL 语法的数学矢量/矩阵库(附带一些常用函数,随机数生成等)
Tencent/rapidjson - 单纯的 JSON 库,甚至没依赖 STL(可定制性高,工程美学经典)
ericniebler/range-v3 - C++20 ranges 库就是受到他启发(完全是头文件组成)
fmtlib/fmt - 格式化库,提供 std::format 的替代品(需要 -DFMT_HEADER_ONLY)
gabime/spdlog - 能适配控制台,安卓等多后端的日志库(和 fmt 冲突!)
只需要把他们的 include 目录或头文件下载下来,然后 include_directories(spdlog/include) 即可。
缺点:函数直接实现在头文件里,没有提前编译,从而需要重复编译同样内容,编译时间长。
1 |
|
git clone git@github.com:g-truc/glm.git --depth=1 # depth=1 更快,不想做贡献的话,这样就好
子模块
第二友好的方式则是作为 CMake 子模块引入,也就是通过 add_subdirectory。
方法就是把那个项目(以fmt为例)的源码放到你工程的根目录:
这些库能够很好地支持作为子模块引入:
fmtlib/fmt - 格式化库,提供 std::format 的替代品
gabime/spdlog - 能适配控制台,安卓等多后端的日志库
ericniebler/range-v3 - C++20 ranges 库就是受到他启发
g-truc/glm - 模仿 GLSL 语法的数学矢量/矩阵库
abseil/abseil-cpp - 旨在补充标准库没有的常用功能
bombela/backward-cpp - 实现了 C++ 的堆栈回溯便于调试
google/googletest - 谷歌单元测试框架
google/benchmark - 谷歌性能评估框架
glfw/glfw - OpenGL 窗口和上下文管理
libigl/libigl - 各种图形学算法大合集
1 |
|
引用系统中预安装的第三方库
预安装--可以解决子模块方式中可能会出现的菱形依赖的问题。
使用 apt 或者 pacman 或者 yum 安装。
可以通过 find_package 命令寻找系统中的包/库:
1 |
|
为什么是 fmt::fmt 而不是简单的 fmt?
现代 CMake 认为一个包 (package) 可以提供多个库,又称组件 (components),比如 TBB 这个包,就包含了 tbb, tbbmalloc, tbbmalloc_proxy 这三个组件。
因此为避免冲突,每个包都享有一个独立的名字空间,以 :: 的分割(和 C++ 还挺像的)。
你可以指定要用哪几个组件:
1 |
|
像Qt5 这种项目,find_package就需要指定出需要的组件的名称。
Windows 上找不到 Qt5 包怎么办?手动指定 Qt5_DIR。
1 |
|
或者在命令行编译cmake时,指定 -DQt5-DIR 参数。
Windows 则没有自带的包管理器。因此可以用跨平台的 vcpkg:https://github.com/microsoft/vcpkg
使用方法:下载 vcpkg 的源码,放到你的项目根目录,像这样:
1
2
3
4
5
6
7
8
9
10
11
12
13
> cd vcpkg
> .\bootstrap-vcpkg.bat
> .\vcpkg integrate install
> .\vcpkg install fmt:x64-windows
> cd ..
> cmake -Bbuild -DCMAKE_TOOLCHAIN_FILE="%CD%/vcpkg/scripts/buildsystems/vcpkg.cmake"
多源文件
GLOB_RECURSE 能自动包含所有子文件夹下的文件,注意先将所有源代码文件统一组织到 src 文件夹下,将build目录与src目录分开。
1 |
|
CONFIGURE_DEPENDS:自动更新新加的文件。
1 |
|
用 aux_source_directory,可以自动搜集需要的文件后缀名。
CMake 参数选项
构建的类型
Debug:
-O0 -g
Release:
-O3 -DNDEBUG
MinSizeRel:
-Os -DNDEBUG
RelWithDebInfo:
-O2 -g -DNDEBUG
初始化项目信息
1 |
|
PROJECT_SOURCE_DIR:当前项目源码路径(存放main.cpp的地方) PROJECT_BINARY_DIR:当前项目输出路径(存放main.exe的地方) CMAKE_SOURCE_DIR:根项目源码路径(存放main.cpp的地方) CMAKE_BINARY_DIR:根项目输出路径(存放main.exe的地方) PROJECT_IS_TOP_LEVEL:BOOL类型,表示当前项目是否是(最顶层的)根项目 PROJECT_NAME:当前项目名 CMAKE_PROJECT_NAME:根项目的项目名
利用 PROJECT_SOURCE_DIR 可以实现从子模块里直接获得项目最外层目录的路径。 不建议用 CMAKE_SOURCE_DIR,那样会让你的项目无法被人作为子模块使用。
LANGUAGES 字段支持的语言包括:
C:C语言 CXX:C++语言 ASM:汇编语言 Fortran:老年人的编程语言 CUDA:英伟达的 CUDA(3.8 版本新增) OBJC:苹果的 Objective-C(3.16 版本新增) OBJCXX:苹果的 Objective-C++(3.16 版本新增) ISPC:一种因特尔的自动 SIMD 编程语言(3.18 版本新增)
如果不指定 LANGUAGES,默认为 C 和 CXX。
使用CMAKE_CXX_STANDARD 变量,而不是使用 options 指定 -std=c++17。这样跨平台方便。
若设置了项目名,会自动设置另外 <项目名>_SOURCE_DIR 等变量。项目名>
对象的属性
1 |
|
windows 中 dll 的搜索路径在 exe 的同目录下,以及环境变量中。而 Linux 中,可执行文件会有RPATH属性字段,自动指向它链接到的 .so 库文件。
编译时消息
message 指令可以在编译时输出字符串内容。
message(STATUS “...”) 表示信息类型是状态信息,有 -- 前缀;
message(WARNING “...”) 表示是警告信息;
message(AUTHOR_WARNING “...”) 表示是仅仅给项目作者看的警告信息;
message(FATAL_ERROR “...”) 表示是错误信息,会终止 CMake 的运行;
message(SEND_ERROR “...”) 表示是错误信息,但之后的语句仍继续执行;
CMake缓存
CMake 第一遍需要检测编译器和 C++ 特性等比较耗时,检测完会把结果存储到缓存中,这样第二遍运行 cmake -B build 时就可以直接用缓存的值。
有时候外部的情况有所更新,这时候 CMake 里缓存的却是旧的值,会导致一系列问题。
最简单的办法就是删除 build 文件夹。
若不想从头重新编译,可以只删除 build/CMakeCache.txt 这个文件。
CMake跨平台
根据不同操作系统,定义宏:
1 |
|
使用生成器表达式可以写成:
1 |
|
CXX_COMPILER_ID 可以判断编译器类型:
1 |
|
变量与判断
父模块里定义的变量,会传递给子模块。但是子模块里定义的变量,不会传递给父模块。
可以使用 ${xx} 访问的是局部变量,$ENV{xx} 访问系统的环境变量,用 $CACHE{xx} 来访问缓存里的 xx 变量。
if (DEFINED ENV{xx}) 可以判断某环境变量是否存在。if (xx) 可以判断某变量是否存在且不为空字符串。因为空字符串等价于 FALSE。
CCache 编译加速缓存
1 |
|
gcc 编译使用方法,把 gcc -c main.cpp -o main 换成 ccache gcc -c main.cpp -o main 即可。
CMake 项目管理模块化指南
目录组织格式:
- 项目名/include/项目名/模块名.h
- 项目名/src/模块名.cpp
CMakeLists.txt 中写:
- target_include_directories(项目名 PUBLIC include)
源码文件中写:
- #include <项目名/模块名.h>
- 项目名::函数名();
头文件(项目名/include/项目名/模块名.h)中写:
1
2
3
4
#pragma once
namespace 项目名 {
void 函数名();
}
实现文件(项目名/src/模块名.cpp)中写:
1
2
3
4
#include <项目名/模块名.h>
namespace 项目名 {
void 函数名() { 函数实现 }
}
示例
示例组织方式:
1 |
|
大型的项目,往往会划分为几个子项目。即只有一个子项目,也建议创建一个子目录,方便以后追加新的子项目。
上面的例子中,在根目录下,创建了两个子项目 biology 和 pybmain,他们分别在各自的目录下有自己的 CMakeLists.txt。
1 |
|
在根项目的 CMakeLists.txt 中,设置了默认的构建模式,设置了统一的 C++ 版本等各种选项。
然后通过 project 命令初始化了根项目。随后通过 add_subdirectory 把两个子项目 pybmain 和 biology 添加进来,这会调用 pybmain/CMakeLists.txt 和 biology/CMakeLists.txt。
1 |
|
子项目的 CMakeLists.txt 就干净许多,只是创建了 biology 这个静态库对象,并通过 GLOB_RECRUSE 为他批量添加了所有位于 src 和 include 下源码和头文件。
根项目的 CMakeLists.txt 负责处理全局有效的设定。而子项目的 CMakeLists.txt 则仅考虑该子项目自身的设定,比如他的头文件目录,要链接的库等等。
这里给 biology 设置了头文件搜索路径 include。因为子项目的 CMakeLists.txt 里指定的路径都是相对路径,所以这里指定 include 实际上是:根/biology/include。
使用 PUBLIC 修饰符,这是为了让链接 biology 的 pybmain 也能够共享 根/biology/include 这个头文件搜索路径。
这里我们给 biology 批量添加了 src/*.cpp 下的全部源码文件。
明明只有 *.cpp 需要编译,为什么还添加了 include/*.h?为了头文件也能被纳入 Vs Code 的项目资源浏览器,方便编辑。
因为子项目的 CMakeLists.txt 里指定的路径都是相对路径,所以这里指定 src 实际上是:根/biology/src。
GLOB_RECURSE 相比 GLOB ,GLOB_RECURSE 会对 *.cpp 进行嵌套目录的搜索匹配。
CONFIGURE_DEPENDS 选项则是用于自动检测目录更新。如果不添加,每次添加新文件后,需要重新运行 cmake -B build 才能更新项目的符号链接之类的。如果添加了,运行 cmake --build 时检测到目录有新文件了,CMake 会自动帮你重新运行 cmake -B build 更新。
头文件和源文件组织
头文件和源文件应保持一一对应的关系。
通常每个头文件都有一个对应的源文件,两个文件名字应当相同,只有后缀名不一样。头文件中包含函数和类的声明,源文件则包含他们的实现。
如果是一个类,则文件名应和类名相同,方便查找(Animal.cpp)。
1 |
|
1 |
|
头文件中函数定义
有时会直接把实现直接写在头文件里,这时可以没有与之对应的源文件,只有一个头文件。
注意:在头文件里直接实现函数时,要加 static 或 inline 关键字。
1 |
|
添加新功能
添加一个新功能模块时,同时添加同名的源文件和头文件。头文件中的声明和源文件中的实现一一对应。
如果新模块中用到了其他模块的类或函数,则需要在新模块的头文件和源文件中都导入其他模块的头文件。
注意不论是项目自己的头文件还是外部的系统的头文件,请全部统一采用 <项目名/模块名.h> 的格式。不要用 “模块名.h” 这种相对路径的格式,避免模块名和系统已有头文件名冲突。
1 |
|
1 |
|
注意上面的 struct Animal;
写法,并没有导入头文件。
如果模块 Carer 的头文件 Carer.h 虽然引用了其他模块中的 Animal 类,但是他里面并没有解引用 Animal,只有源文件 Carer.cpp 解引用了 Animal。那么这个头文件是不需要导入 Animal.h 的,只需要一个前置声明 struct Animal,只有实际调用了 Animal 成员函数的源文件需要导入 Animal.h。
这样做的好处是,可以加快编译速度,防止循环引用。
在声明和定义外面都套一层名字空间,例如此处子项目名是 biology,那就使用 biology::Animal。避免暴露全局的 Animal。
这是因为万一有个“不拘一格”的第三方库也暴露个全局的 Animal,两个符号就会发生冲突。
由于类符号都具有 weak 属性,链接器会随机选择一个覆盖掉,非常危险!
如果一个子项目依赖另一个子项目,则需要链接他。
让 pybmain 链接上 biology:
1 |
|
由于 PUBLIC 属性具有传染性,根/biology/include 现在也加入 pybmain 的头文件搜索路径了,因此 pybmain 里可以 #include 到 biology 的头文件。
同理如果又有一个 target_link_libraries(zxxpig PUBLIC pybmain) 那么 zxxpig 也有 pybmain 和 biology 的所有头文件搜索路径了。
CMake 也有 include 功能
和 C/C++ 的 #include 一样,CMake 也有一个 include 命令。
你写 include(XXX) 时,则他会在 CMAKE_MODULE_PATH 这个列表中的所有路径下查找 XXX.cmake 这个文件。那么在 XXX.cmake 里,就可以写一些你常用的函数,宏,变量等。
1 |
|
导入方式是在 CMakeLists.txt 中添加:
1 |
|
macro 和 function 的区别
macro 相当于直接把代码粘贴过去,直接访问调用者的作用域。这里写的相对路径 include 和 src,是基于调用者所在路径。
function 则是会创建一个闭包,优先访问定义者的作用域。这里写的相对路径 include 和 src,则是基于定义者所在路径。
include 和 add_subdirectory 的区别
include 相当于直接把代码粘贴过去,直接访问调用者的作用域。这里创建的变量和外面共享,直接 set(key val) 则调用者也有 ${key} 这个变量了。
add_subdirectory 这类 function 中,则是基于定义者所在路径,优先访问定义者的作用域。这里需要在在被包含的子项目中 set(key val PARENT_SCOPE) ,才能修改到外面的变量。
第三方库依赖
find_package
1 |
|
find_package(OpenCV) 实际上是在找一个名为 OpenCVConfig.cmake 的文件。
出于历史兼容性考虑,除了 OpenCVConfig.cmake 以外 OpenCV-config.cmake 这个文件名也会被 CMake 识别到。
同理,find_package(Qt5) 则是会去找名为 Qt5Config.cmake 的文件。
Qt5Config.cmake 是你安装 Qt5 时,随 libQt5Core.so 等实际的库文件,一起装到你的系统中去的。以 Arch Linux 系统为例:包配置文件位于 /usr/lib/cmake/Qt5/Qt5Config.cmake。实际的动态库文件位于 /usr/lib/libQt5Core.so。
因此 find_package 并不是直接去找具体的动态库文件和头文件(例如 libQt5Core.so)。而是去找包配置文件(例如Qt5Config.cmake),这个配置文件里包含了包的具体信息,包括动态库文件的位置,头文件的目录,链接时需要开启的编译选项等等。
而且某些库都具有多个子动态库,例如 Qt 就有 libQt5Core.so、libQt5Widgets.so、libQt5Network.so。因此 CMake 要求所有第三方库作者统一包装成一个 Qt5Config.cmake 文件包含所有相关信息(类似于 nodejs 的 package.json)。
包配置文件由第三方库的作者(Qt的开发团队)提供,在这个库安装时(Qt的安装程序或apt install等)会自动放到 /usr/lib/cmake/XXX/XXXConfig.cmake 这个路径(其中XXX是包名),供 CMake 用户找到并了解该包的具体信息。
/usr/lib/cmake 这个位置是 CMake 和第三方库作者约定俗成的,由第三方库的安装程序负责把包配置文件放到这里。如果第三方库的作者比较懒,没提供 CMake 支持(由安装程序提供XXXConfig.cmake),那么得用另外的一套方法(FindXXX.cmake)。
一个搜索路径的例子:
1 |
|
find_package找到配置文件
手动指定一个变量告诉配置文件在哪儿
可以是普通变量 ${Qt5_DIR}
1
2
3# 项目的 CMakeLists.txt 最开头写一行
# 一定要加在最前面!!!
set(Qt5_DIR ”D:/Qt5.12.1/msvc2017/lib/cmake/Qt5”)可以是环境变量 $ENV{Qt5_DIR},添加一个环境变量 Qt5_DIR 值为 D:/Qt5.12.1/msvc2017/lib/cmake/Qt5,在Linux中,可以export Qt5_DIR="/opt/Qt5.12.1/lib/cmake/Qt5"
可以通过命令行 -DQt5_DIR=”C:/Program Files/Qt5.12.1/lib/cmake/Qt5” 设置
没有配置文件?
CMake 提供了一些 Find 配置:
1 |
|
github 上也有开源的FindXXX.cmake 配置文件。
使用方法
1 |
|
大部分第三方库都需要提前安装好,然后再 find_package 找到他,然后才能链接。也有少数第三方库为了方便,还支持作为子项目加到项目中来,这种就不需要 :: 语法。
1 |
|
Unix 软件从源码安装的通用方法
1 |
|
注:如果 -DCMAKE_INSTALL_PREFIX=/usr/local 则会拷贝到 /usr/local/lib/libtest.so
本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!