Modern CMake 实践

随着 CMake 版本的迭代变更,使用 CMake 组织项目文件的方法也累积出了很大的变化。考虑到还有不少 CMake 教程停留在一些基础用法,我决定结合我的经验谈谈 CMake 在稍微复杂一些项目上的实践经验。

前言

CMake 其实不止能管理 C++项目,但是这里只讨论用 CMake 管理 C++ 跨平台项目的情况。本文所用的 CMake 特性,最低所需的版本是 CMake 3.20 版本。阅读本文需要有一定的 CMake 基础。

如何组织项目结构?

全凭个人喜好,我目前习惯的结构是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
ProjectName/
├── ProjectA/
│ ├── include/
│ ├── src/
│ └── CMakeLists.txt
├── ProjectB/
│ ├── include/
│ ├── src/
│ └── CMakeLists.txt
├── UnitTest/
│ ├── ProjectA/
│ ├── ProjectB/
│ └── CMakeLists.txt
└── CMakeLists.txt

在书籍《Modern CMake for C++》中推荐的结构是:

1
2
3
4
5
6
7
8
9
10
11
12
13
Project/
├── cmake/
│ ├── include
│ ├── module
│ └── script
├── src/
│ ├── app1
│ ├── app2
│ ├── lib1
│ └── lib2
├── doc/
├── test/
└── CMakeLists.txt

CMakeLists.txt 起手式

下面三行几乎每个文件都必须有,分别是:设置 CMake 所需最低版本、项目名称和版本(后面 install 部分要用到)、设置 C++ 标准。

1
2
3
cmake_minimum_required(VERSION 3.20)
project(ProjectName VERSION 1.0.0)
set(CMAKE_CXX_STANDARD 20)

如何添加源代码?

视头文件为源代码的一部分

头文件会在编译前被前处理器载入,简单地粘贴到到引用它的 C++ 源文件中。所以不把头文件加入到 CMake target 的源文件列表中不会影响编译。但是更好的做法是,把头文件也视为源文件的一部分,加入到源文件列表中。

因为这样做是 “IDE 友好” 的。如果你创建了一个头文件,它没有被任何的源文件引用,也没有被加入到 CMake target 的源文件列表中,我常用的 IDE(CLion)会拒绝对它进行分析,只有基础的语法高亮,而没有编写代码的提示。

记得仍然要设置 target 的头文件的目录:

1
2
3
target_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
2
add_library(lib_name STATIC lib.h lib.cpp) # 静态链接库
add_library(lib_name SHARED lib.h lib.cpp) # 动态链接库

决定使用动态链接库或静态链接库的因素有很多,比如使用动态链接库可以做到按需加载、替换自身达到功能升级等。甚至在链接开源程序时,考虑使用动态链接可以避免被开源许可证污染。使用静态链接也有一些好处,比如编译产物有更小的体积,或得到单文件程序。

我个人推荐的做法是,把决定权交给库的使用者,让他来决定编译成静态链接库还是动态链接库。这可以用下面的代码做到:

1
2
3
4
5
6
7
8
9
10
11
option(LibName_BUILD_SHARED_LIBS "Build LibName as a shared library." OFF)
if (LibName_BUILD_SHARED_LIBS)
set(LIBRARY_TYPE SHARED)
set(CMAKE_WINDOWS_EXPORT_ALL_SYMBOLS ON)
else ()
set(LIBRARY_TYPE STATIC)
endif ()

set(SOURCE a.hpp a.cpp)

add_library(LibName ${LIBRARY_TYPE} ${SOURCES})

上面的代码首先是创建了一个 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
2
3
4
5
6
7
8
9
10
11
#if defined(LibName_STATICLIB)
# define EXPORTED
#elif defined(_WIN32)
# if defined(LibName_WIN_EXPORT)
# define EXPORTED __declspec( dllexport )
# else
# define EXPORTED __declspec( dllimport )
# endif
#else
# define EXPORTED
#endif

编译动态链接库时,定义宏 LibName_WIN_EXPORT。在使用静态链接库时,定义宏 LibName_STATICLIB。在使用动态库时,则什么也不定义。

1
2
target_compile_definitions(TargetName PRIVATE LibName_WIN_EXPORT) # 编译动态链接库时添加该行
target_compile_definitions(TargetName PRIVATE LibName_STATICLIB) # 使用静态链接库时添加该行

对于要导出的符号,使用宏 EXPORTED 修饰。

1
2
3
4
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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
# CMakeLists.txt
option(LibTrussSolver_INSTALL "INSTALL_LibTrussSolver" ON)
if (LibTrussSolver_INSTALL)

include(CMakePackageConfigHelpers)
write_basic_package_version_file(
LibTrussSolverConfigVersion.cmake
VERSION ${PACKAGE_VERSION}
COMPATIBILITY AnyNewerVersion
)

install(DIRECTORY include
DESTINATION ${CMAKE_INSTALL_PREFIX}
)

install(TARGETS LibTrussSolver LibTrussDocument # 两个项目要一起被导出,因为 LibTrussSolver 依赖 LibTrussDocument。
EXPORT LibTrussSolverTargets
RUNTIME DESTINATION bin
ARCHIVE DESTINATION lib
LIBRARY DESTINATION lib
)

install(EXPORT LibTrussSolverTargets
FILE LibTrussSolverTargets.cmake
NAMESPACE Truss::
DESTINATION cmake/LibTrussSolver
)

configure_file(LibTrussSolverConfig.cmake.in LibTrussSolverConfig.cmake @ONLY)

install(FILES "${CMAKE_CURRENT_BINARY_DIR}/LibTrussSolverConfig.cmake"
"${CMAKE_CURRENT_BINARY_DIR}/LibTrussSolverConfigVersion.cmake"
DESTINATION cmake/LibTrussSolver
)

endif (LibTrussSolver_INSTALL)
1
2
3
4
5
6
7
8
9
10
# LibTrussSolverConfig.cmake.in
include(CMakeFindDependencyMacro)

# 被导出的项目的所有依赖都要写在这里
find_dependency(magic_enum REQUIRED) # LibTrussDocument 的依赖
find_dependency(Eigen3 REQUIRED) # LibTrussSolver 的依赖

if(NOT TARGET LibTrussSolver)
include("${CMAKE_CURRENT_LIST_DIR}/LibTrussSolverTargets.cmake")
endif()

上面的代码中,write_basic_package_version_fileconfigure_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
2
add_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) # 其他取值:981114172023

上面那个是全局设置,下面的指令可以针对某个 target 来设置:

1
set_property(TARGET <target> PROPERTY CXX_STANDARD <standard>)

总结

使用 CMake 组织项目结构时,不应局限于让项目正常编译,还要考虑到和 IDE 的适配效果,即所谓的 “IDE 友好”。不友好的 CMake 脚本可能会阻碍 IDE 智能感知代码的能力(比如智能提示、查找函数或变量的引用、重构功能等)。

写一半发现,我还需要再学习一下。

作者

uint128.com

发布于

2023-03-06

更新于

2023-03-11

许可协议

评论