CMake 是一个开源、跨平台的构建系统生成器(Build-system Generator)。

本文是 Modern CMake 简明教程系列的中篇,上篇请移步 《Modern CMake 简明教程(上)》

本教程默认 CMake 最低版本为 3.16,即 cmake_minimum_required(VERSION 3.16)

一、生成器表达式

不知你是否思考过这样一个问题:我们在编译项目时,通常有不同的配置,如 Debug 和 Release,如何在不同的配置中定义不同的预编译宏、包含不同的目录、依赖不同的库呢?

其实要解决这个问题,并不困难,只需要使用到 CMake 中的 生成器表达式。我们在开源项目的 CMake 脚本中经常看到的 $<...> 这样的表达式就是生成器表达式,但通常都会嵌套使用,如 $<$<...>:...>

生成器表达式通常有下面几种形式:

  • $<condition:true_string>
  • $<IF:condition,true_string,false_string>
  • $<BOOL:string>
  • 特定的语法形式,如 $<CONFIG:cfgs> 表示当前配置存在于 cfgs 列表中时,表达式结果为 1,否则为 0。

前三种形式比较简单,最后一种形式虽然有多种类型,但我们通常只需要记忆几种常用的,有需要时查阅官方文档

1.1 $<condition:true_string>

condition 只允许为 0 或 1,其他任何值都会报错。

当 condition 为 1 时,表达式返回 true_string;

当 condition 为 0 时,表达式返回空字符串。

如:

1
2
3
4
5
6
set(ENABLE_JSONCPP 1)

target_include_directories(
my_lib PUBLIC
$<${ENABLE_JSONCPP}:${CMAKE_SOURCE_DIR}/jsoncpp/include>
)

1.2 $<IF:condition,true_string,false_string>

condition 只允许为 0 或 1,其他任何值都会报错。

当 condition 为 1 时,表达式返回 true_string;

当 condition 为 0 时,表达式返回 false_string。

如下面示例,当设置了 ENABLE_JSONCPP 为 1 时,包含 jsoncpp 头文件,否则包含 rapidjson 头文件:

1
2
3
4
5
6
set(ENABLE_JSONCPP 1)

target_include_directories(
my_lib PUBLIC
$<IF:${ENABLE_JSONCPP},${CMAKE_SOURCE_DIR}/jsoncpp/include,${CMAKE_SOURCE_DIR}/rapidjson/include>
)

1.3 $<BOOL:string>

前面 2 种形式的 condition 都只允许为 0 或 1,这种限制未免有些呆板,如果 ENABLE_JSONCPP 的值不是 1 ,而是 ON 时,该怎么办呢?

此时可以使用 $<BOOL:string> 将其转换成 0 或 1。

何时转换成 0,何时转换成 1?可以参考前面的《Modern CMake 简明教程(上)》 的“条件判断”章节,在该章节中介绍的所有为假的情况都会转换成 0,其他情况则转换成 1。

该表达式的作用注定了其单独使用意义不大,通常都是和其他表达式一起使用,如将上面示例中的 ENABLE_JSONCPP 设置为 ON:

1
2
3
4
5
6
set(ENABLE_JSONCPP ON)

target_include_directories(
my_lib PUBLIC
$<IF:$<BOOL:${ENABLE_JSONCPP}>,${CMAKE_SOURCE_DIR}/jsoncpp/include,${CMAKE_SOURCE_DIR}/rapidjson/include>
)

1.4 逻辑运算

运算符表达式也支持与、或、非三种逻辑运算,语法如下:

1
2
3
4
5
$<AND:conditions>

$<OR:conditions>

$<NOT:condition>

其中,condition 都只允许是 0 或 1,conditions(复数形式)表示可以是由逗号分割的多个条件列表。

1.5 其他常见表达式条件

CMake 还支持很多类型的生成器表达式,下面列举了一些常见的表达式,这些表达式通常都是用来作为上面介绍的表达式中的条件。

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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
# 字符串比较,string1 与 string2 相等则为 1,反之为 0
$<STREQUAL:string1,string2>

# 数值比较,相等为 1,反之为 0
$<EQUAL:value1,value2>

# 版本比较,v1 小于 v2 时为 1,反之为 0
$<VERSION_LESS:v1,v2>

# 其他版本比较
$<VERSION_GREATER:v1,v2>
$<VERSION_EQUAL:v1,v2>
$<VERSION_LESS_EQUAL:v1,v2>
$<VERSION_GREATER_EQUAL:v1,v2>

# string 存在于 list列表中时为 1,反之为 0
$<IN_LIST:string,list>

# 当前配置名称
$<CONFIG>

# 当前配置存在于cfgs列表中时为 1,反之为 0
$<CONFIG:cfgs>

# 平台ID,见 CMAKE_SYSTEM_NAME
$<PLATFORM_ID>

# 当前平台ID存在于 platform_ids 列表中时为 1,反之为 0
$<PLATFORM_ID:platform_ids>

# C++编译器版本
$<CXX_COMPILER_VERSION>

# C++编译器版本与 version 匹配时为 1,反之为 0
# $<CXX_COMPILER_VERSION:version>

# 当前的C++编译器ID
$<CXX_COMPILER_ID>

# 当前的C++编译器ID存在于 compiler_ids 列表中时为 1,反之为 0
$<CXX_COMPILER_ID:compiler_ids>

# 目标存在时为 1,反之为 0
$<TARGET_EXISTS:tgt>

# 返回tgt目标的prop属性的值,如果未设置该属性,则返回空字符串
$<TARGET_PROPERTY:tgt,prop>

# 返回正在计算表达式的目标上的prop属性的值,如果未设置该属性,则返回空字符串
$<TARGET_PROPERTY:prop>

# 当使用 install(EXPORT) 导出属性时,返回 ... 内容,否则返回空
$<INSTALL_INTERFACE:...>

# 当使用 export()导出属性或者被同一构建系统内的另一个目标使用时,返回 ... 内容,否则返回空
$<BUILD_INTERFACE:...>

1.6 转义字符

在生成器表达式中如果需要使用特殊字符,可以使用其转移字符。

1
2
3
4
5
6
7
8
9
10
11
12
# >
$<ANGLE-R>

# ,
$<COMMA>

# ;
$<SEMICOLON>

# "
# 需要 CMake >= 3.30
$<QUOTE>

二、安装

侠义的“安装”是将目标编译生成的文件拷贝到指定位置,CMake 中的安装包含但不限于拷贝文件,还可以执行脚本、修改权限等操作。

上面这句话中,涉及到了2个术语,有必要解释一下。

“目标编译生成的文件”在 CMake 中有个学名,叫 Output Artifacts,直译为输出工件,我更愿意将其翻译为“输出品”,不同类型的目标有不同的输出品,例如在 Windows 平台上,可执行程序输出 .exe 文件,动态库输出 .dll 文件(也可能包含 .lib 文件),静态库输出 .lib 文件…..

请牢记 Artifacts 这个单词,在后面的很多定义中都会出现该词。

“指定位置”,顾名思义,我们可以指定一个安装位置。CMake 会从 CMAKE_INSTALL_PREFIX 变量中读取安装位置,该变量有默认值,我们也可以修改该变量来改变安装位置。

在不同的操作系统上,CMAKE_INSTALL_PREFIX 的默认值不同:

  • Windows 系统:C:/Program Files/${PROJECT_NAME}
    写入该目录需要管理员权限,因此如果安装失败,请检查是否具有管理员权限。
  • UNIX 系统:/usr/local

CMAKE_INSTALL_PREFIX 指定的位置是安装目录的根目录,不同类型的输出品会存放在其不同的子目录中,如 .lib 文件存放在 lib 目录,.dll 和 .exe 文件存放在 bin 目录,头文件存放在 include 目录,这些子目录的具体名称可以通过 GNUInstallDirs 提供的若干变量来获取,如 CMAKE_INSTALL_BINDIRCMAKE_INSTALL_LIBDIRCMAKE_INSTALL_INCLUDEDIR 等。

CMake 定义了下列常用的输出品种类(artifact-kind),这些种类在后面的 install 选项中会使用到:

  • ARCHIVE
    这种类型的输出品包含下列文件(默认位于 lib 目录):
    • 静态库,Windows 上是 .lib 文件,Linux 上是 .a 文件,但在 macOS 上标记为 FRAMEWORK 的除外。
    • 动态库的导入库,如 .lib 文件。
    • 在macOS系统上,为启用 ENABLE_EXPORTS 的共享库所创建的链接器导入文件(但标记为 FRAMEWORK 的情况除外)。
  • LIBRARY
    这种类型的输出品很少用到。
  • RUNTIME
    这种类型的输出品包含下来文件(默认位于 bin 目录):
    • 各个系统所支持的可执行文件,如 Windows 上的 .exe。
    • 动态库,如 .dll 和 .so 文件。
  • OBJECTS
    与对象库(使用add_library(<name> OBJECT ...)方式定义)关联的对象文件。
  • FRAMEWORK
    在 macOS 上,标有 FRAMEWORK 属性的静态库和共享库都被视为 FRAMEWORK 类型。
  • BUNDLE
    在 macOS 上,标有 MACOSX_BUNDLE 属性的可执行文件被视为 BUNDLE 类型。
  • PUBLIC_HEADER
  • PRIVATE_HEADER

2.1 install

使用 install 指令可以定义在安装时需要执行的操作,install 可以定义很多类型的操作,包含但不限于拷贝输出品到指定位置、执行脚本等。

下面是 install 指令支持的调用形式:

1
2
3
4
5
6
7
8
install(TARGETS <target>... [...])
install(IMPORTED_RUNTIME_ARTIFACTS <target>... [...])
install({FILES | PROGRAMS} <file>... [...])
install(DIRECTORY <dir>... [...])
install(SCRIPT <file> [...])
install(CODE <code> [...])
install(EXPORT <export-name> [...])
install(RUNTIME_DEPENDENCY_SET <set-name> [...])

通过执行 make install 命令或者编译 CMake 生成的 INSTALL 项目,就可以执行 install 指令预先定义的安装操作。

2.2 不同的安装命令

2.2.1 安装 Target

所谓安装 Target 就是将目标的输出品及其关联文件拷贝到指定位置。

1
2
3
4
5
6
install(TARGETS <target>... [EXPORT <export-name>]
[RUNTIME_DEPENDENCIES <arg>...|RUNTIME_DEPENDENCY_SET <set-name>]
[<artifact-option>...]
[<artifact-kind> <artifact-option>...]...
[INCLUDES DESTINATION [<dir> ...]]
)

<artifact-option> 用于指定与输出品相关的选项,可以是下面选项中的一个或多个(常用的选项主要是 DESTINATION 和 PERMISSIONS):

1
2
3
4
5
6
7
[DESTINATION <dir>]
[PERMISSIONS <permission>...]
[CONFIGURATIONS <config>...]
[COMPONENT <component>]
[NAMELINK_COMPONENT <component>]
[OPTIONAL] [EXCLUDE_FROM_ALL]
[NAMELINK_ONLY|NAMELINK_SKIP]

第一组 <artifact-option> 所设置的选项应用于在本次调用中没有指定输出品类型时。

我们通常会为不同类型的输出品指定不同的选项,如下面示例为不同类型的输出品指定了不同的安装位置:

1
2
3
4
5
6
7
set_target_properties(hello_cmake PROPERTIES PUBLIC_HEADER include/my_lib.h)

install(TARGETS hello_cmake
ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR} # 仅为演示,这样写多此一举
RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR} # 仅为演示,这样写多此一举
PUBLIC_HEADER DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}/my_lib
)

在实际项目中,我们不会使用 PUBLIC_HEADER 来安装头文件,而是使用下面介绍的 install(DIRECTORY ... ) 命令。

EXPORT选项

EXPORT 是一个非常有用的选项,当我们的项目需要作为库被第三方使用时,为了让第三方能够通过 find_package 所查找到所安装的库,就需要在安装时生成 xxxConfig.cmake 文件。

当然,仅仅通过在此指定 EXPORT 选项还不够,此处的 EXPORT 选项仅仅表示将 Target 所安装的输出品绑定到 <export-name> 上,后面我们还需要使用单独的 install(EXPORT ...) 语句来生成 xxxConfig.cmake 文件,详见下面的 “安装导出依赖项” 节。

2.2.2 拷贝目录

拷贝目录到指定位置,语法如下:

1
2
3
4
5
6
7
8
9
10
install(DIRECTORY dirs...
TYPE <type> | DESTINATION <dir>
[FILE_PERMISSIONS <permission>...]
[DIRECTORY_PERMISSIONS <permission>...]
[USE_SOURCE_PERMISSIONS] [OPTIONAL] [MESSAGE_NEVER]
[CONFIGURATIONS <config>...]
[COMPONENT <component>] [EXCLUDE_FROM_ALL]
[FILES_MATCHING]
[[PATTERN <pattern> | REGEX <regex>]
[EXCLUDE] [PERMISSIONS <permission>...]] [...])

可以使用 TYPE 或 DESTINATION 来指定目标路径,其中 TYPE 的取值来自于 GNUInstallDirs 提供的若干变量,如 BIN 等同于 CMAKE_INSTALL_BINDIR 变量。

示例:

1
2
3
install(DIRECTORY ./common TYPE INCLUDE)

install(DIRECTORY ./common DESTINATION "${CMAKE_INSTALL_PREFIX}/${CMAKE_INSTALL_INCLUDEDIR}")

2.2.3 拷贝文件

拷贝文件到指定位置,语法如下:

1
2
3
4
5
6
install(FILES <file>...
TYPE <type> | DESTINATION <dir>
[PERMISSIONS <permission>...]
[CONFIGURATIONS <config>...]
[COMPONENT <component>]
[RENAME <name>] [OPTIONAL] [EXCLUDE_FROM_ALL])

2.2.4 执行脚本

在安装时执行脚本文件或者脚本代码,语法如下:

1
2
3
install([[SCRIPT <file>] [CODE <code>]]
[ALL_COMPONENTS | COMPONENT <component>]
[EXCLUDE_FROM_ALL] [...])

如果脚本文件的路径是相对路径,则该路径相对于当前项目的根目录(即 CMAKE_SOURCE_DIR 变量存储的路径)。

示例:

1
install(SCRIPT "./my_lib/helper.cmake")
1
install(CODE "message(\"Sample install message.\")")

2.2.5 安装导出依赖项

如果在安装 Target(见上面的5.2.1节) 时已经指定了 EXPORT 选项,则可以通过 install(EXPORT ...) 的形式来安装需要导出的依赖性,即生成 xxxConfig.cmake 文件,该文件名不是固定的,可以通过 FILE 选项来指定其他名称,但必须是 .cmake 类型的文件。

1
2
3
4
5
6
7
8
9
install(EXPORT <export-name> DESTINATION <dir>
[NAMESPACE <namespace>] [FILE <name>.cmake]
[PERMISSIONS <permission>...]
[CONFIGURATIONS <config>...]
[CXX_MODULES_DIRECTORY <directory>]
[EXPORT_LINK_INTERFACE_LIBRARIES]
[COMPONENT <component>]
[EXCLUDE_FROM_ALL]
[EXPORT_PACKAGE_DEPENDENCIES])

<export-name> 选项所指定的名称需要与在 install(TARGET ... EXPORT ...) 语句中指定名称一致。

通常为了防止和其他库命名冲突,我们会使用 NAMESPACE 添加命名空间。

在生成 xxxConfig.cmake 文件以后,就可以使用 find_package 来查找并引用依赖库了:

1
2
find_package(MyLib REQUIRED)
target_link_libraries(OtherApp PRIVATE my_lib::my_lib)

三、与 CMake 交互

CMake 与构建项目交互的方式有两种:

  1. 使用 configure_file 指令动态生成配置文件,通过配置文件的方式来将数据传递给项目,如在 C/C++ 项目中动态生成 .h 文件。
  2. 使用 file 指令创建配置文件,file 指令的功能非常强大,包含众多与文件相关的操作,如读写文件、下载上传文件、遍历目录等。

本节只介绍 configure_file 指令,该指令用于根据模板文件在指定位置生成新的文件。

1
2
3
4
5
configure_file(<input> <output>
[NO_SOURCE_PERMISSIONS | USE_SOURCE_PERMISSIONS |
FILE_PERMISSIONS <permissions>...]
[COPYONLY] [ESCAPE_QUOTES] [@ONLY]
[NEWLINE_STYLE [UNIX|DOS|WIN32|LF|CRLF] ])

我们首先需要通过 <input> 选项来指定一个模板文件,虽然模板文件可以是任意的后缀名,但我们通常使用 .in 后缀名,例如我们需要通过模板文件生成 version.h 文件,则模板文件名为 version.h.in

configure_file 指令会将 <input> 模板文件中的诸如 @VAR@${VAR}$CACHE{VAR}$ENV{VAR} 形式的变量都替换为对应变量的值,如果变量没有被定义,则替换为空字符串。

我们通常还会指定 @ONLY 选项,指定该选项后,就只有 @VAR@ 形式的变量会被替换,而其他形式的变量会保留不变,这种方式虽然在生成 .h 文件时没有什么用途,但谁又说 configure_file 只能生成 .h 文件了?如果是生成 .cmake 文件,是不是就有作用了咧。

1
2
3
4
5
6
7
// version.h.in

#define VERSION_MAJOR @VERSION_MAJOR@
#define VERSION_MINOR @VERSION_MINOR@
#define VERSION_PATCH @VERSION_PATCH@

#define BUILD_TIMESTAMP "@BUILD_TIMESTAMP@"
1
2
3
4
5
6
7
8
9
10
11
12
# CMakeLists.txt

string(TIMESTAMP BUILD_TIMESTAMP "%Y-%m-%d %H:%M:%S")

set(VERSION_MAJOR 1)
set(VERSION_MINOR 0)
set(VERSION_PATCH 1)

configure_file (
"${CMAKE_SOURCE_DIR}/include/version.h.in"
"${CMAKE_SOURCE_DIR}/include/version.h"
)

执行 CMake 脚本,动态生成的 version.h 内容如下:

1
2
3
4
5
#define VERSION_MAJOR 1
#define VERSION_MINOR 0
#define VERSION_PATCH 1

#define BUILD_TIMESTAMP "2024-09-30 16:51:57"

上述变量替换的方式有一个弊端:虽然能动态替换模板语句中变量的值,但却不能控制语句是否存在。比如我们经常在 C/C++ 项目中根据宏是否被定义来做判断,而不是根据宏的值来做判断:

1
2
3
#ifdef BUILD_SHARED_LIBS

#endif

这个是时候就需要使用另外一个语法形式了:#cmakedefine VAR ...,在这种形式中是直接使用变量名的,而不需要使用 @ @ 进行包裹。

当定义了 VAR 变量时,将替换为(…就是模板文件中 VAR 后面的内容):

1
#define VAR ...

当没有定义 VAR 变量时,将替换为:

1
/* #undef VAR */