Modern CMake 实践
随着 CMake 版本的迭代变更,使用 CMake 组织项目文件的方法也累积出了很大的变化。考虑到还有不少 CMake 教程停留在一些基础用法,我决定结合我的经验谈谈 CMake 在稍微复杂一些项目上的实践经验。
前言
CMake 其实不止能管理 C++项目,但是这里只讨论用 CMake 管理 C++ 跨平台项目的情况。本文所用的 CMake 特性,最低所需的版本是 CMake 3.20 版本。阅读本文需要有一定的 CMake 基础。
如何组织项目结构?
全凭个人喜好,我目前习惯的结构是:
1 | ProjectName/ |
在书籍《Modern CMake for C++》中推荐的结构是:
1 | Project/ |
CMakeLists.txt 起手式
下面三行几乎每个文件都必须有,分别是:设置 CMake 所需最低版本、项目名称和版本(后面 install 部分要用到)、设置 C++ 标准。
1 | cmake_minimum_required(VERSION 3.20) |
如何添加源代码?
视头文件为源代码的一部分
头文件会在编译前被前处理器载入,简单地粘贴到到引用它的 C++ 源文件中。所以不把头文件加入到 CMake target 的源文件列表中不会影响编译。但是更好的做法是,把头文件也视为源文件的一部分,加入到源文件列表中。
因为这样做是 “IDE 友好” 的。如果你创建了一个头文件,它没有被任何的源文件引用,也没有被加入到 CMake target 的源文件列表中,我常用的 IDE(CLion)会拒绝对它进行分析,只有基础的语法高亮,而没有编写代码的提示。
记得仍然要设置 target 的头文件的目录:1
2
3target_include_directories(TargetName PUBLIC
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
$<INSTALL_INTERFACE:include>)
aux_source_directory
优点:简单
缺点:IDE 不友好。有新的源文件时必须手动重新建立 CMake 缓存,IDE 不会自动刷新 CMake 缓存。
简单列出的文件列表
优点:IDE 友好。添加源文件时必须修改 CMakeLists.txt 文件,IDE 会自动刷新 CMake 缓存。
缺点:手动添加每一个文件比较繁琐,但是如果通过 IDE(Visual Studio、CLion)添加头文件或源文件,有自动修改 CMakeLists.txt 源文件列表的功能。
更复杂的文件列表
印象中见过,但博主还没用过,学会了再给大伙总结下优缺点。
如何创建库项目?
这一节简要解释两个常见的问题:如何创建跨平台动态库?以及如何让自己的库可以被 “CMake install”,然后被 CMake 中的 “find_package” 找到。
静态链接库与动态链接库
在 CMake 中创建静态链接库或是动态链接库,仅仅是 add_library
命令的第二个参数有区别。
1 | add_library(lib_name STATIC lib.h lib.cpp) # 静态链接库 |
决定使用动态链接库或静态链接库的因素有很多,比如使用动态链接库可以做到按需加载、替换自身达到功能升级等。甚至在链接开源程序时,考虑使用动态链接可以避免被开源许可证污染。使用静态链接也有一些好处,比如编译产物有更小的体积,或得到单文件程序。
我个人推荐的做法是,把决定权交给库的使用者,让他来决定编译成静态链接库还是动态链接库。这可以用下面的代码做到:
1 | option(LibName_BUILD_SHARED_LIBS "Build LibName as a shared library." OFF) |
上面的代码首先是创建了一个 Boolean 选项,默认值为 OFF,用来控制是否编译成动态链接库。
如果编译为动态库,则变量 ${LIBRARY_TYPE}
为 SHARED
,否则为 STATIC
。这样,库的使用者就可以在生成 CMake 缓存时决定要将这个库编译成什么。
另外,在编译成动态库时,还做了一步就是将 CMAKE_WINDOWS_EXPORT_ALL_SYMBOLS
设置为 On
。设置这个之后,在编译 Windows 平台的动态链接库,会默认导出所有的符号(除了全局变量)。下面会有更多的解释。
最后,为了做到 “IDE友好”,我建议将源文件列表单独放到一个变量中,即 ${SOURCES}
。如果不这么做,CLion 会把 ${LIBRARY_TYPE}
误认为是源文件列表。每次通过 CLion 添加源文件时,CLion 都会想要把新的源文件添加到变量 ${LIBRARY_TYPE}
末尾。 (Visual Studio 暂未做测试)
GCC 编译的动态链接库,默认是导出所有符号的。而 MSVC 编译的动态链接库,默认是不导出任何符号的。
说实话这一块知识我没具体探究过,比如是否 MinGW 的 g++ 编译器也不需要指定要导出的符号?挖坑以后再研究…
因此在使用 MSVC 编译动态链接库时,需要在代码中使用 __declspec(dllexport)
指定要导出的符号:全局变量或常量、函数、类。而如果要使用 DLL 中符号,则需要在符号的声明中指定 __declspec(dllimport)
。具体规则可以见微软的文档,General Rules and Limitations
一般来说会定义一个宏,在编译动态库时标记为 __declspec(dllexport)
,而在使用动态库时标记为 __declspec(dllimport)
。
1 |
在编译动态链接库时,定义宏 LibName_WIN_EXPORT
。在使用静态链接库时,定义宏 LibName_STATICLIB
。在使用动态库时,则什么也不定义。
1 | target_compile_definitions(TargetName PRIVATE LibName_WIN_EXPORT) # 编译动态链接库时添加该行 |
对于要导出的符号,使用宏 EXPORTED
修饰。
1 | class EXPORTED Utility |
上文提到,CMake 提供了一个变量 CMAKE_WINDOWS_EXPORT_ALL_SYMBOLS
,当它为 On
时,会导出所有的符号(除了全局变量)。我推荐用这个方法,因为用了它之后,就不需要给每一个要导出的符号指定 __declspec(dllexport)
和 __declspec(dllimport)
了。
让你的库能被 find_package 找到
find_package 做了什么?
CMake 中的 find_package 有两种模式:Module 模式和 Config 模式。下面只是简要介绍,建议大家阅读 CMake 的详细文档。
在 Module 模式下,CMake 会在 CMAKE_MODULE_PATH
中寻找 Find<PackageName>.cmake
文件。这个 cmake
文件负责寻找 package 的安装路径并检查版本是否符合要求。
Module 模式并不常用,一般都使用 Config 模式。比如下面这段很常见的代码,CONFIG 指的就是 Config 模式。REQUIRED 表示这个库是必须的,如果没找到这个库就中止 CMake 脚本的运行。
1 | find_package(PackageName CONFIG REQUIRED) |
在 Config 模式下,CMake 会去寻找 <lowercasePackageName>-config.cmake
或 <PackageName>Config.cmake
文件(下面简称 config.cmake)。如果指定了版本号,还会去寻找 <lowercasePackageName>-config-version.cmake
或 <PackageName>ConfigVersion.cmake
文件(下面简称 version.cmake)。
通过指定缓存变量 <PackageName>_DIR
的值,来告诉 CMake 应该去哪里寻找 config.cmake 和 version.cmake 这两个文件。
这两个文件通常和库的安装目录放在一起。而且它们通常不是库作者自己写的,而是通过一系列 CMake 脚本自动生成的。
使用 install 指令
要让自己的库能被别人使用 find_package 指令找到,必须要生成 config.cmake 文件和 version.cmake 文件。除此之外,还要复制编译产物、头文件目录等。
下面两段代码来自我自己的一个项目 Truss。先贴出来看看,有个整体的概念,再做进一步分解。
下面的代码中涉及到两个项目:LibTrussSolver 依赖 LibTrussDocument,因为 LibTrussDocument 项目是在上一级目录中通过 add_subdirectory
找到的,所以要一起被 install。另外 LibTrussSolver 依赖第三方库 Eigen,LibTrussDocument 依赖第三方库 magic_enum。
下面的代码可以作为模板使用,只需要把项目名称替换成自己的就可以用了。
1 | # CMakeLists.txt |
1 | # LibTrussSolverConfig.cmake.in |
上面的代码中,write_basic_package_version_file
和 configure_file
的作用分别是创建 version.cmake 文件和 config.cmake 文件。
在 write_basic_package_version_file
中,COMPATIBILITY AnyNewerVersion
指的是,如果版本号比指定的版本号新,那么就认为是兼容的。还有其他的兼容模式可以选择,比如主版本号一致才认为是兼容的,具体见 CMake 的文档。
configure_file
指令的作用是将 LibTrussSolverConfig.cmake.in
文件复制为 LibTrussSolverConfig.cmake
。(该指令会在复制过程中进行了一些变量替换的处理,但是这个特性在我们的例子中没有用到)
而在文件 LibTrussSolverConfig.cmake.in
中,指定了两个依赖(根据自己项目的实际情况进行修改,可能更多依赖,也可以能没有依赖),并且包含了 LibTrussSolverTargets.cmake 文件(下面简称 targets.cmake)。
这个 targets.cmake 文件是由 install(TARGETS)
和 install(EXPORT)
两个指令生成的。
突然发现我对这两个命令的细节并不那么了解,还需要学习一下,再完善这部分。
至于 install(DIRECTORY)
和 install(FILES)
的作用则是复制 头文件目录 和 config.cmake, version.cmake 到安装目标路径。
杂项
设置编译器参数
通常跨平台项目的源代码都是 utf-8 编码(因为这是 Linux 系统的默认系统编码)。但是,MSVC 编译器对 utf-8 编码的源文件支持不好,需要额外为 MSVC 编译器设置 /utf-8
参数才能正常编译 utf-8 编码的源文件,否则会出现很多编译错误。
我常使用下面两行代码为 MSVC 编译器添加 /utf-8
参数。这两行代码用到了 CMake Generator Expressions。
如果编译器是 MSVC,那么这个表达式的值就是 /utf-8
,CMake 会添加它作为编译参数。如果编译器不是 MSVC,那么这个表达式的值是空文本,add_compile_options
遇到空文本是不会起作用的。1
2add_compile_options("$<$<C_COMPILER_ID:MSVC>:/utf-8>")
add_compile_options("$<$<CXX_COMPILER_ID:MSVC>:/utf-8>")
设置 C++ 标准
通过设置 CMAKE_CXX_STANDARD 变量的值来要求编译采用指定的 C++ 标准:1
set(CMAKE_CXX_STANDARD 20) # 其他取值:98、11、14、17、20、23
上面那个是全局设置,下面的指令可以针对某个 target 来设置:
1 | set_property(TARGET <target> PROPERTY CXX_STANDARD <standard>) |
总结
使用 CMake 组织项目结构时,不应局限于让项目正常编译,还要考虑到和 IDE 的适配效果,即所谓的 “IDE 友好”。不友好的 CMake 脚本可能会阻碍 IDE 智能感知代码的能力(比如智能提示、查找函数或变量的引用、重构功能等)。
写一半发现,我还需要再学习一下。
Modern CMake 实践