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

CMake 是构建系统生成器,而不是构建系统,CMake 支持生成不同构建系统所支持的工程文件,如 Visual Studio,XCode,Makefile 等。

本教程作为 CMake 的简明教程,不会事无巨细的讲述 CMake 的每一个语法,而是以实用为目的,介绍 CMake 的基础语法和常用指令。

虽然只是简明教程,但通过本教程,你仍然可以掌握 CMake 的脉络,熟练应用 CMake 于项目中。

一、Modern CMake

CMake 距今已有 20 多年的历史,CMake 从 3.0 开始引入 Target 概念,有了 Target 和 Property 的定义,CMake 也就更加地现代化。

我们将引入 Target 概念之前(也就是 3.0 之前)的 CMake 称之为老式 CMake,之后的称之为现代 CMake(Modern CMake)。

现代 CMake 是围绕 Target 和 Property 来定义的,在现代 CMake 中不应该出现诸如下面的指令:

  • add_compile_options
  • include_directories
  • link_directories
  • link_libraries

因为这些指令都是目录级别的,在该目录(含子目录)上定义的所有目标都会继承这些属性,这样会导致出现很多隐藏依赖和多余属性的情况。

我们最好直接针对 Target 进行操作,如:

1
2
3
4
5
6
7
add_executable(hello main.cpp)

# 老式写法
include_directories(./include)

# 现代写法
target_include_directories(hello PRIVATE ./include)

本文讲述的知识点只适用于现代 CMake,让我们脱掉沉重的历史包袱,轻装上阵吧!

二、基础概念

所有的构建系统都需要通过某个入口点来定义项目(如 Visual Studio 的 .sln 文件),CMake 作为构建系统生成器也不例外,CMake 使用的是 CMakeLists.txt 的文件,该文件以 UTF-8 编码(也支持 UTF-8 BOM 文件头),其中存储了符合 CMake 语言规范的脚本代码。

2.1 项目结构

CMake 没有强制规定 CMakeLists.txt 文件的位置以及项目的目录结构,但目前大多数项目都会采用相似的目录结构。

如果项目名称为 my_project,且该项目包含一个名为 lib 的库和一个名为 app 的程序,则目录结构通常如下面所示:

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
- my_project
- .gitignore
- README.md
- LICENSE.md
- CMakeLists.txt
- cmake
- FindSomeLib.cmake
- something_else.cmake
- include
- my_project
- lib.h
- src
- CMakeLists.txt
- lib.cpp
- apps
- CMakeLists.txt
- app.cpp
- tests
- CMakeLists.txt
- testlib.cpp
- docs
- CMakeLists.txt
- extern
- googletest
- scripts
- helper.py

当然,上面的名称并不是一成不变的,可以根据自己的喜好来定义,例如 my_project 可以是任意的项目名,如果不喜欢复数,可以将 tests 改成test,如果没有 python 代码,也可以移除 python 目录,cmake 目录则用于存放 CMake 辅助脚本。

从上面的目录结构可以看到,CMakeLists.txt 文件分散在各个子目录中,但在 include 目录中没有 CMakeList.txt 文件,这样是为了防止暴露不必要的文件给库的使用者,因为 include 目录中存放的是库的头文件,在安装时通常都会将该目录拷贝到指定位置(如Linux系统的 /usr/include)。

extern 目录用于存放第三方依赖库的源码,这些库可以通过 git submodule 的形式来管理,也可以直接将源码拷贝到此,并提交到项目 git 中。但无论使用哪种方式,依赖库最好能支持 CMake,这样可以方便的使用 add_subdirectory 命令将项目添加到工程中(add_subdirectory 可以添加任何包含 CMakeLists.txt 文件的目录到项目中)。

2.2 一个简单的示例

我们先从一个简单的示例开始,了解 CMake 的基本玩法。

该示例是只包含一个 main.cpp 文件,我们期望编译该文件能生成 hello_cmake 程序。

目录结构如下:

1
2
3
- hello_cmake
- main.cpp
- CMakeLists.txt

main.cpp 文件的内容非常简单:

1
2
3
4
5
6
#include <stdio.h>

int main() {
printf("hello cmake");
return 0;
}

CMakeLists.txt 内容如下:

1
2
3
4
5
6
7
8
9
10
# 设置 CMake 的最低版本
cmake_minimum_required(VERSION 3.16)

# 设置项目名称
project (hello_cmake)

# 添加一个名为 hello_cmake 的目标
# 目标类型为可执行文件
# 使用 main.cpp 来编译生成 hello_cmake 可执行文件(如hello_cmake.exe)
add_executable(hello_cmake main.cpp)

完成上面步骤,我们就可以使用 CMake GUI 或命令行(当然你需要提前安装 CMake,这不在本文的介绍范围之内)就可以生成相应的工程了。

通过 CMake 命令行生成 Visual Studio 工程的命令如下:

1
cmake.exe -G "Visual Studio 15 2017" -S .\hello_cmake -B .\hello_cmake\build

2.3 源码外构建

我们通常会将构建目录指定到一个单独的子目录内,这个目录名称的通常是 build。如果不这样做,CMake 生成的工程文件和临时缓存文件会污染源码目录。这种方式有个学名叫“源码外构建” (out-of-source build)。

使用源码外构建时,我们通常还会将 build 目录添加到 .gitignore 文件中。

2.4 工作流程

编写 CMake 脚本的基本流程如下:

  1. 在脚本第一行使用 cmake_minimum_required 指定运行当前脚本所需的 CMake 最低版本。
  2. 使用 project 指定项目名称。
  3. 使用 add_executable 或 add_library 创建目标。
  4. 为目标设置包含目录、链接库等属性(可选)。
  5. 安装(可选)。

编写完 CMake 脚本以后,就可以使用 CMake GUI 或命令行来生成对应的工程文件了。以 Visual Studio 为例,对于有 my_lib 库 和 app 应用程序的项目,CMake 会生成如下图所示的 5 个项目:

下面介绍 CMake 自动生成的一些项目的作用:

  • 编译 ALL_BUILD 项目会自动编译除 INSTALL 项目外的所有项目。
  • 编译 INSTALL 项目会执行 CMake 脚本中指定的安装操作。
  • 编译 ZERO_CHECK 项目会再次执行 CMake 脚本,重新生成项目。因此若 CMake 脚本有更新,既可以使用 CMake 工具来重新生成项目,也可以是重新编译 ZERO_CHECK 项目。

2.5 注释

在 CMake 中使用 # 来声明单行注释,这是我们使用最多的注释方法。虽然也支持使用 #[[ ]] 来声明多行注释(也称块注释),但是使用的比较少,例如:

1
2
3
4
#[[
这是多行注释也称块注释
你明白了吗?
]]

2.6 CMake最低版本

cmake_minimum_required 是我们接触到第一个 CMake 指令,该指令用于指定编译该脚本所需的最低 CMake 版本。

你可以在 CMakeList.txt 文件的第一行都使用该指令来指定运行当前脚本需要的最低 CMake 版本,但我们通常只需要在主 CMakeList.txt (何为主 CMakeList.txt?见下面的“项目名称”小节) 中的第一行使用该指令即可。

1
cmake_minimum_required(VERSION <min>[...<policy_max>] [FATAL_ERROR])

如果运行 CMake 的版本低于要求的版本,则将停止处理该脚本并返回错误。

我们始终应该选择一个比编译器晚发布的 CMake 版本,因为只有这样,CMake 才能支持新的编译器选项。但最低版本不应低于 3.0,实际项目中通常最低版本不会低于 3.16(该版本于2020年09月15日发布),本教程也是以此为标准进行讲解的。

2.7 项目名称

使用 project 指定项目名称。

项目名称区别于目标(Target)名称,以 Visual Studio 为例,project 指定的名称对应“解决方案名称”,而 add_executable 或 add_library 等指定的名称才对应具体项目名和生成的“目标文件名”。

设置项目名称后,CMake 会自动定义一些变量(变量的具体用法会在稍后的“3.1 变量”小节进行介绍)。为了方便介绍各个变量的含义,假设我们是通过如下命令来运行 CMake 的:

1
cmake.exe -G "Visual Studio 15 2017" -S D:\hello_cmake -B D:\hello_cmake\build

下面列举了一些 CMake 自动定义的变量:

  • PROJECT_NAME
    项目名称,如 hello_cmake
  • CMAKE_PROJECT_NAME
    如果 CMakeLists.txt 位于项目的顶级目录,还会定义 CMAKE_PROJECT_NAME 变量,值与 PROJECT_NAME 一致。
  • PROJECT_SOURCE_DIR
    项目的根目录(绝对路径),即 -S 参数指定的目录,如 D:\hello_cmake
  • <PROJECT-NAME>_SOURCE_DIR
    值与 PROJECT_SOURCE_DIR 相同,只是变量名不同,如 hello_cmake_SOURCE_DIR
  • PROJECT_BINARY_DIR
    项目的构建目录(绝对路径),即 -B 参数指定的目录,如 D:\hello_cmake\build
  • <PROJECT-NAME>_BINARY_DIR
    值与 PROJECT_BINARY_DIR 相同,只是变量名不同,如 hello_cmake_BINARY_DIR

主CMakeLists.txt

主 CMakeLists.txt 即项目根目录下的 CMakeLists.txt 文件。可以通过检查 CMAKE_PROJECT_NAME 与 PROJECT_NAME 变量是否相同来判断当前的 CMakeLists.txt 文件是否为主 CMakeLists.txt。

1
2
3
if(CMAKE_PROJECT_NAME STREQUAL PROJECT_NAME)

endif()

2.8 目标类型

既然现代 CMake 是围绕目标(Target)工作的,Target 如此重要,那我们首先就需要创建一个 Target。

在 C/C++ 开发中,常见的 Target 类型有:可执行文件、静态库、动态库,CMake 还额外提供了一个 MODULE 类型。

下面列举了不同类型的目标的创建方式。

可执行文件

使用 add_executable 指令可以创建可执行文件类型的目标。

1
add_executable(my_exe main.cpp)

动态库和静态库

通过为 add_library 指令指定不同的参数,可以创建动态库和静态库。

1
add_library(<name> [<type>] [EXCLUDE_FROM_ALL] <sources>...)
1
2
3
4
5
# 动态库
add_library(my_lib SHARED main.cpp)

# 静态库
add_library(my_lib STATIC main.cpp)

我们也可以在 add_library 中不指定类型参数,改为通过设置 BUILD_SHARED_LIBS 变量来切换静态库和动态库。下面示例在脚本中设置了 BUILD_SHARED_LIBS 变量值为 ON (ON / OFF 对应 CMake 中的开/关):

1
2
3
4
5
6
7
cmake_minimum_required(VERSION 3.16)

project (hello_cmake)

set(BUILD_SHARED_LIBS ON)

add_library(hello_cmake main.cpp)

也可以通过命令行参数进行指定 BUILD_SHARED_LIBS 变量:

1
cmake.exe -G "Visual Studio 15 2017" -DBUILD_SHARED_LIBS=ON -S .\hello_cmake -B .\hello_cmake\build

亦可以在 GUI 界面上设置 BUILD_SHARED_LIBS 变量,如:

三、基础语法

3.1 变量

3.1.1 变量的定义

在 CMake 中使用 setunset 命令来定义和取消定义变量。

CMake 的变量没有类型一说,因为其变量值始终是字符串类型。

基于上述原因,在 CMake 中不能直接使用 +-*\ 等操作符对变量进行数学运算,需要使用 math 指令,也不能直接使用 ><== 等操作符对变量进行逻辑运算,需要使用 LESS、EQUAL 进行判断,详见下面的“条件判断”章节。

CMake 的变量名是大小写敏感的,而且其变量名不像其他语言那样有各种限制,它可以包含任何字符,如空格、问号等,但如果变量名中包含#(该符号用于行注释),则需要使用 \# 进行转义。

下面的语句都是合法的:

1
2
3
4
5
6
7
8
9
10
set(abc 123)
set(ABC "123")
set("ab c" "456")
set("ab?c" "789")
set("/usr/bin/bash" "987")
set("C:\\Program Files\\" "654")
set(" " "321")
set(\# "321")

unset(a)

虽然 CMake 允许变量名为任意字符串,但我们仍然建议在变量名称中仅包含字母、数字、-_ ,而且字母为大写字母,如:

1
2
3
# 建议的命名方式
set(QT_VERSION "5.15.2")
set(LIB_NAME "my_lib")

CMake 还会保留一些标识符,我们在定义变量时尽量不要使用这些名称(你执意要用,CMake 也不会报错):

  • CMAKE_ 开头的。
  • _CMAKE_ 开头的。
  • _<cmake command> 开头的,如 _file,完整的 command 列表见:cmake-commands

在定义完变量以后,就可以通过 ${variable} 的形式进行引用了,如:

1
2
3
set(QT_VERSION "5.15.2")

message(STATUS "QT_VERSION is ${QT_VERSION}")

在 CMake 中,还可以通过引用变量的方式来定义新的变量:

1
2
3
4
5
6
7
8
9
set(a "xyz")

set(b "${a}_321")
set(${a}_1 "456")
set(variable_${a} "${a} + ${b} + 15")

message("b: '${b}'") # b: 'xyz_321'
message("xyz_1: '${xyz_1}'") # xyz_1: '456'
message("variable_xyz: '${variable_xyz}'") # variable_xyz: 'xyz + xyz_321 + 15'

环境变量的引用方式有所不同,在下面的环境变量章节会详细介绍,而且在 if 条件中可以省略 ${},直接使用变量名,如 if(QT_VERSION)

CMake 还允许使用未定义的变量,未定义的变量的值为空字符串。

CMake 也允许重复定义变量,变量的值采用最后定义的值。

3.1.2 调试输出

message

为了方便调试脚本,我们可以使用 messsage 指令来输出变量和调试信息。

1
message([<mode>] "message text" ...)

<mode> 关键字是可选的,它用于指定消息的类型,消息类型会影响 CMake 对该消息的处理方式。

常用的消息类型有:

  • FATAL_ERROR
    致命错误(红色),只有该类型会导致脚本终止执行。
  • WARNING
    警告(红色),脚本继续执行。
  • NOTICE(默认)
    需要特别关注的消息(红色),脚本继续执行。
  • STATUS
    普通输出,正常颜色,脚本也会继续执行。
1
2
message("Current version is 1.0.0.1")
message(STATUS "BUILD_SHARED_LIBS value is ${BUILD_SHARED_LIBS}")

cmake_print_variables

如果只是单纯地想打印变量的值,使用 messsage 显得有些繁琐,我们可以使用 cmake_print_variables 函数来打印变量的值,该函数以 variable=value 格式输出每个变量的值,方便进行观察。

由于该函数由 CMake 的 CMakePrintHelpers 模块提供,因此在使用之前,需要先 include(CMakePrintHelpers)

1
2
3
4
5
6
include(CMakePrintHelpers)

set(MY_NAME "jack")
set(MY_ADDRESS "Hubei")

cmake_print_variables(MY_NAME MY_ADDRESS) # MY_NAME="jack" ; MY_ADDRESS="Hubei"

3.1.3 列表

列表就是简单地包含一系列值,使用空格分割每个值:

1
set(MY_LIST a b "c" 1 2 ${MY_NAME} 3)

也可以使用 ; 来代替空格:

1
2
3
set(MY_LIST "a;b;c;1;2;${MY_NAME};3")

set(MY_LIST a;b;c;1;2;${MY_NAME};3)

list 命令

list 命令提供了众多针对列表的操作,如获取元素个数、查找、添加、删除、排序等。

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
# 获取元素个数
list(LENGTH <list> <out-var>)

# 获取指定下标(可以指定多个下标)的元素
list(GET <list> <element index> [<index> ...] <out-var>)

# 拼接2个列表
list(JOIN <list> <glue> <out-var>)

# 获取指定位置指定长度的一段列表
list(SUBLIST <list> <begin> <length> <out-var>)

# 查找,<out-var>中存储查找到的元素下标,如果没找到则为-1
list(FIND <list> <value> <out-var>)

# 在列表尾部添加若干元素
list(APPEND <list> [<element>...])

# 按条件过滤列表
list(FILTER <list> <INCLUDE|EXCLUDE> REGEX <regular_expression>)

# 在指定位置插入若干元素
list(INSERT <list> <index> [<element>...])

# 弹出尾部的若干元素(如果没有指定<out-var>变量,则只弹出一个)
list(POP_BACK <list> [<out-var>...])

# 弹出头部的若干元素(如果没有指定<out-var>变量,则只弹出一个)
list(POP_FRONT <list> [<out-var>...])

# 将若干元素插入到列表的头部,如果<list>没有被定义,则相当于创建了一个新的列表
list(PREPEND <list> [<element>...])

# 移除列表中的若干元素
list(REMOVE_ITEM <list> <value>...)

# 移除列表中指定位置(可以指定多个)的元素
list(REMOVE_AT <list> <index>...)

# 删除列表中的重复项目。保留项目的相对顺序,但如果遇到重复项,则仅保留第一个实例。
list(REMOVE_DUPLICATES <list>)

# 按照<ACTION>对列表元素进行转换
list(TRANSFORM <list> <ACTION> [<SELECTOR>] [OUTPUT_VARIABLE <output variable>])

# 列表反转
list(REVERSE <list>)

# 列表排序
list(SORT <list> [COMPARE <compare>] [CASE <case>] [ORDER <order>])

下面仅简单地演示 2 个列表功能的用法:

1
2
3
4
5
6
7
set(NAME_LIST jack jim jeff tom)

list(LENGTH NAME_LIST NAME_COUNT)
message(STATUS "name count: ${NAME_COUNT}") # name count: 4

list(FIND NAME_LIST jim JIM_INDEX)
message(STATUS "jim at: ${JIM_INDEX}") # jim at: 1

遍历列表

可以使用 foreach 来遍历列表,下面示例演示了如何使用 foreach 遍历输出 MY_LIST 列表中每个元素,其中 _ITEM 变量的作用域仅限 foreach 代码块。

1
2
3
4
5
set(MY_LIST hello world)

foreach(_ITEM ${MY_LIST})
message(STATUS "${_ITEM}")
endforeach()

我们还可以使用 while 来遍历列表,详见下面的“循环”章节。

3.1.4 双引号的作用

学习到这里,也许你会感到困扰,在定义变量时,为什么有时候使用双引号把值包围起来,有时候又不使用呢?

我们已经学习完了列表的相关知识,现在就可以解释加不加引号的区别了。

在定义变量时,若变量值中包含空格,此时不使用双引号包裹,则等同于定义列表;使用双引号包裹,则等同于定义字符串变量。

1
2
3
4
5
# 定义的MY_VAR为列表,包含2个元素:hello、world
set(MY_VAR hello world)

# 定义的MY_VAR为字符串
set(MY_VAR "hello world")

对于列表类型的变量,在使用时是否使用双引号包裹也会有区别:有双引号包裹时,会将数组元素以分号作为分隔符进行拼接,否则会直接拼接各元素。

1
2
3
4
set(MY_LIST hello world)

message(STATUS "${MY_LIST}") # hello;world
message(STATUS ${MY_LIST}) # helloworld

3.1.5 三种不同的变量

CMake 中的变量分为普通变量、缓存变量和环境变量,三者都可以通过 set 指令进行定义。

普通变量

下面使用 set 定义的变量就是普通变量。

1
set(MY_NAME "jack")

也可以在 CMake 命令行中通过 -D 参数定义普通变量,如下面示例定义了 BUILD_SHARED_LIBS 和 TEST 变量:

1
cmake.exe -G "Visual Studio 15 2017" -DBUILD_SHARED_LIBS=ON -DTEST=123 -S .\hello_cmake -B .\hello_cmake\build

缓存变量

缓存变量也是通过 set 指令定义的,但需要添加额外的参数:

1
set(<variable> <value>... CACHE <type> <docstring> [FORCE])

下面示例定义了一个名为 LIB_VERSION 的缓存变量:

1
2
3
4
5
6
7
8
9
cmake_minimum_required(VERSION 3.16)

set(LIB_VERSION "1.0.0.1" CACHE STRING "the version of library")

project (hello_cmake)

add_library(hello_cmake main.cpp)

message(STATUS "LIB_VERSION = ${LIB_VERSION}")

使用 CMake GUI 程序执行该脚本,可以看到界面上多出了一个名为 LIB_VERSION 的文本输入框,而且输入框有默认值 1.0.0.1。

将输入框中的文本修改为 1.0.0.2,再次执行该脚本,可以发现调试输出的内容是 LIB_VERSION = 1.0.0.2,而且无论我们执行多少次脚本,始终输出的都是该内容。

这是因为 CMake 会将缓存变量及其值存储到 “构建目录\CMakeCache.txt” 文件中,下次运行脚本时,会优先从该文件中加载变量,该文件内容格式大致如下:

1
2
3
4
5
6
7
8
# build\CMakeCache.txt 文件

......

//the version of library
LIB_VERSION:STRING=1.0.0.2

......

其中,<type> 用于指定变量的输入类型。请注意,<type> 指定的不是变量的类型,因为 CMake 的所有变量都是字符串类型。<type> 指定的是输入类型,仅用于帮助 CMake GUI 程序显示不同的用户输入控件,如文本输入框、复选框、文件选择对话框等。

<type> 的取值必须是下面列表中的一个:

  • BOOL
    开关ON或OFF,在 CMake GUI 上提供一个复选框。
  • PATH
    文件夹的路径,在 CMake GUI 上提供一个文本输入框和一个按钮来打开文件夹选择对话框。
  • FILEPATH
    文件的路径,在 CMake GUI 上提供一个文本输入框和一个按钮来打开文件选择对话框。
  • STRING
    文本字符串,在 CMake GUI 上提供一个文本输入框。
  • STRINGS
    文本字符串,但在 CMake GUI 上会提供一个下拉列表选择框。
  • INTERNAL
    虽然也是文本字符串,但不会显示在 CMake GUI 上,因此用户无法在界面上修改该变量的值。

如果我们不希望某些缓存变量直接展示 CMake GUI 上,可以使用 mark_as_advanced 指令将缓存变量设置为高级状态,这样除非用户打开了 “Show Advanced” 选项,否则高级变量不会显示在 CMake GUI 中。在脚本模式下,高级/非高级状态是无效的。

1
2
set(DEBUG_LIBNAME_SUFFIX "-d" CACHE STRING "Optional suffix to append to the library name for a debug build")
mark_as_advanced(DEBUG_LIBNAME_SUFFIX)

option

虽然 option 也可用于定义缓存变量,但其只能定义“开/关”类型的变量。

1
option(ENABLE_TEST "enable test or not" ON)

如果未指定初始值,默认为 OFF。

环境变量

环境变量的定义方式如下:

1
set(ENV{<variable>} [<value>])

环境变量在引用时,需要在前面添加 ENV 标识,如 $ENV{<variable>}

示例:

1
2
3
set(ENV{USER_NAME} "jack")

message(STATUS "User name is $ENV{USER_NAME}")

CMake 程序在启动时,会加载系统的环境变量,同时还会设置一些内置的环境变量,内置的环境变量见 cmake-env-variables

CMake 虽然会加载系统的环境变量,我们也可以修改该环境变量,但该修改操作不会影响到系统的环境变量。

定义或加载的环境变量只会作用于当前的 CMake 进程,而且对当前进程所运行的所有脚本都可见,但不会影响到其他 CMake 进程和系统中的其他进程。

3.1.6 变量作用域

在上面介绍三种不同的变量时,我一直在竭力避免讨论一个话题,那就是变量的作用域。事实上,上面三种变量还有个不同之处就是作用域的不同。

缓存变量和环境变量都是全局的,它们可以跨文件、目录、函数进行读写,因此作用域主要针对普通变量而言。

CMake 中作用域分为“目录级别作用域”和“函数作用域”(也可以使用 block() 来显式的创建一个作用域,但这种使用方式非常少)。

函数有自己的作用域,在函数中定义的变量只能在函数体中使用,函数体外无法访问。除非在定义变量时添加 PARENT_SCOPE 来将变量作用域设置为上一级目录。

include

使用 include 指令加载和执行其他 CMake 脚本文件(名称通常为 *.cmake),如:

1
2
3
4
5
# utils.cmake

set(NORMAL_VAR_IN_UTILS "I'm NORMAL_VAR_IN_UTILS")
set(CACHE_VAR_IN_UTILS "I'm CACHE_VAR_IN_UTILS" CACHE STRING "")
set(ENV{ENV_VAR_IN_UTILS} "I'm ENV_VAR_IN_UTILS")

当然,在 *.cmake 文件中也可以使用 include 指令加载执行其他 cmake 文件。

假设在 A(CMakeLists.txt或*.cmake)中调用 include(B.cmake),则 A 和 B 之间可以相互读写彼此的变量(包含普通变量、缓存变量和环境变量)。

include 会在 CMAKE_MODULE_PATH 变量指定的目录列表中搜索指定文件。

add_subdirectory

使用 add_subdirectory 指令可以添加一个子目录到项目构建中,但是被添加的子目录中必须包含 CMakeLists.txt 文件。

假设在 A (CMakeLists.txt文件) 中调用 add_subdirectory(lib) 添加了 lib 子目录,则 lib 目录中的 CMakeLists.txt 可以读写 A 中的普通变量,但 A 不能读写 lib 目录 CMakeLists.txt 的普通变量。但可以在定义变量时添加 PARENT_SCOPE 选项来突破该限制,将变量作用域设置为上一级作用域,即父目录的作用域,如:

1
2
# lib\CMakeLists.txt 文件
set(LIB_NAME "MyTestLib" PARENT_SCOPE)
1
2
3
4
add_subdirectory(lib)

# 可以访问 LIB_NAME 变量
message(STATUS "${LIB_NAME}")

3.2 数学运算

在前面章节已经提到了 CMake 中的值都是以字符串类型存储的,不能直接使用数学运算符符进行运算,需要使用 math 指令进行数学运算。

math 的语法如下:

1
math(EXPR <variable> "<expression>" [OUTPUT_FORMAT <format>])

其中,<variable>变量如果没有定义,math 会自动定义该变量;

OUTPUT_FORMAT 选项用于指定计算结果的进制(十六进制或十进制):

  • HEXADECIMAL 十六进制
  • DECIMAL 十进制(默认)

math 支持如下运算符:

1
+ - * / % | & ^ ~ << >>  (...)

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# value 等于 "300"
math(EXPR value "100 * 3")

# value 等于 "1000"
math(EXPR value "100 * 0xA")

# value 等于 "0x12c"
math(EXPR value "100 * 3" OUTPUT_FORMAT HEXADECIMAL)

set(value 3)
math(EXPR value "100 * ${value}") # value 等于 "300"

# value 等于 "306"
math(EXPR value "(100 + 2) * 3")

3.3 条件判断

在 CMake 中,使用 if 进行条件判断,语法如下:

1
2
3
4
5
6
7
if(<condition>)
<commands>
elseif(<condition>) # 可选的,可以有多个
<commands>
else() # 可选的
<commands>
endif()

与众多语言中的 if 一样,当括号中的条件为真时,才执行指定的 commands。

示例:

1
2
3
4
5
6
7
set(a 9)

if(a LESS 10)
message(STATUS "a < 10")
else()
message(STATUS "a >= 10")
endif()

else() 和 endif() 括号中的内容可以为空,但如果需要指定,则就必须与 if 中的条件完全一致,如下面示例所示:

1
2
3
4
5
6
7
set(a 9)

if(a LESS 10)
message(STATUS "a < 10")
else(a LESS 10)
message(STATUS "a >= 10")
endif(a LESS 10)

我们通常不在 else() 和 endif() 中指定条件,因为这样太繁琐了。

3.3.1 真值与假值

何为真,何为假,人生真真假假,难以分辨,程序的真假却清清楚楚。

下列常量始终为真(不区分大小写):

  • 1 和其他非零数字(包含浮点型),如 1、2、3.14
  • ON
  • YES
  • TRUE
  • Y

下列常量始终为假(不区分大小写):

  • 0
  • OFF
  • NO
  • FALSE
  • N
  • IGNORE
  • NOTFOUND
  • 空字符串
  • 被引号包裹的字符串(除始终为真的字符串外,如 TRUE、Y 等)

3.3.2 逻辑运算符

在 CMake 中,逻辑运算符的与、或、非分别使用 AND、OR、NOT 表示。

1
2
3
4
5
6
7
if(NOT <condition>)

if(<cond1> AND <cond2>)

if(<cond1> OR <cond2>)

if((condition) AND (condition OR (condition)))

3.3.3 关系运算符

由于 CMake 变量都是以字符串类型存储的,因此即便是数字也不能直接使用 ><== 这样的运算符来直接比较。

针对数值类型,CMake 支持的关系运算符如下:

  • LESS 小于
  • LESS_EQUAL 小于等于
  • GREATER 大于
  • GREATER_EQUAL 大于等于
  • EQUAL 等于

CMake 还支持字符串比较,即从左到右依次比较字符串中的每个字符,出现不相同时立即返回,类似于 C 语言中的 strcmp 函数。

  • STRLESS
  • STRLESS_EQUAL
  • STRGREATER
  • STRGREATER_EQUAL
  • STREQUAL

3.3.4 存在性校验

CMake 提供了一些判断变量是否定义、目标是否创建、元素是否存在于列表中、文件/目录是否存在等方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 给定名称是否是可以调用的指令
if(COMMAND <command-name>)

# 给定名称是否是已创建的目标,即通过add_executable()、add_library() 或 add_custom_target() 命令创建
if(TARGET <target-name>)

# 给定名称的普通变量、缓存变量、环境变量是否已定义
if(DEFINED <name>|CACHE{<name>}|ENV{<name>})

# 给定的元素是否包含在列表中
if(<variable|string> IN_LIST <variable>)

# 给定的文件或目录是否存在
if(EXISTS <path-to-file-or-directory>)

3.4 循环

CMake 有两种循环方式:

二者都可以使用 break() 提前退出循环和 continue() 跳过本次循环。

3.4.1 foreach

下面是 foreach 的基本语法形式,这种形式在之前的“遍历列表”章节已经使用到了:

1
2
3
foreach(<loop_var> <items>)
<commands>
endforeach()

示例:

1
2
3
4
5
set(MY_LIST 1 2 3 4 5 6 7)

foreach(_ITEM ${MY_LIST})
message(STATUS "${_ITEM}")
endforeach()

foreach 还支持下面两种语法形式,这两种形式都不需要指定列表 <items> 参数,作用类似于 C 语言中的 for 语句:

1
2
3
4
5
# 循环 [0 ~ <stop>],步长为 1
foreach(<loop_var> RANGE <stop>)

# 循环 [<start> ~ <stop>],步长为 <step>(可选)
foreach(<loop_var> RANGE <start> <stop> [<step>])

示例:

1
2
3
4
5
6
7
8
9
10
11
foreach(_I RANGE 3)
message(STATUS "${_I}") # 依次输出 0 1 2 3
endforeach()

foreach(_I RANGE 2 5)
message(STATUS "${_I}") # 依次输出 2 3 4 5
endforeach()

foreach(_I RANGE 2 5 2)
message(STATUS "${_I}") # 依次输出 2 4
endforeach()

3.4.2 while

while 的语法形式如下,其中 <condition> 为真时(参考 if 条件判断章节),执行代码块中的 commands 命令。

1
2
3
while(<condition>)
<commands>
endwhile()

下面示例演示了如何使用 while 来遍历列表:

1
2
3
4
5
6
7
8
9
set(MY_LIST 1 2 3)
list(LENGTH MY_LIST LIST_COUNT)

set(INDEX 0)
while(INDEX LESS LIST_COUNT)
list(GET MY_LIST ${INDEX} VALUE) # 获取 ${INDEX} 位置的元素
message(STATUS "element at ${INDEX} = ${VALUE}")
math(EXPR INDEX "${INDEX} + 1") # 自增 INDEX
endwhile()

上面示例依次输出如下内容:

1
2
3
element at 0 = 1
element at 1 = 2
element at 2 = 3

3.5 函数和宏

3.5.1 函数

使用 function 定义函数:

1
2
3
function(<name> [<arg1> ...])
<commands>
endfunction()

函数有自己的作用域,而宏没有自己的作用域,在函数体里面定义的普通变量默认只能在函数体中被访问,除非在定义时指定了 PARENT_SCOPE 选项,或者改为定义缓存变量、环境变量。

函数在被调用时,函数名是不区分大小写的,如我们定义了名为 foo 的函数,就可以使用 foo()Foo()FOO() 等形式来调用,但我们还是建议保持与函数定义时的名称一致。

参数

关于函数的参数,我们可以在定义函数时就指定各个参数的名称,如:

1
2
3
4
5
6
function(my_func NAME AGE)
message(STATUS "name: ${NAME}") # name: jack
message(STATUS "age: ${AGE}") # age: 18
endfunction()

my_func("jack" 18)

在调用函数时,调用参数(实参)的个数可以超过定义的参数个数(形参),但不能少于定义的参数个数,否则会报错。超出的参数,可以通过下面的形式获取:

  • 使用 ARGV0, ARGV1, ARGV2, ... 变量获取函数的每个参数。
  • 使用 ARGV 变量获取函数的参数列表,通过 ARGN 变量获取参数的个数。

返回值

使用 return() 可以从函数体中提前返回,但不能直接使用 return() 带出返回值,需要借用 set(<variable> <value> PARENT_SCOPE) 方式,来间接的带出返回值。

下面示例演示了函数的定义、调用、参数的获取以及返回值的用法。

1
2
3
4
5
6
7
8
9
10
11
function(my_func NAME AGE)
message(STATUS "name: ${NAME}") # name: jack
message(STATUS "parameter count: ${ARGC}") # parameter count: 3
message(STATUS "parameter list: ${ARGV}") # parameter list: jack;18;Hubei
message(STATUS "parameter 0: ${ARGV0}") # parameter 0: jack
message(STATUS "parameter 2: ${ARGV1}") # parameter 2: Hubei
set(FUNC_RET "OK" PARENT_SCOPE)
endfunction()

my_func("jack" 18 "Hubei")
message(STATUS "return value: ${FUNC_RET}") # return value: OK

但在实际项目中,除需要传入不定个数的参数情况外,我们通常在定义函数时,就约定好了参数名称和返回参数的名称,如下面示例:

1
2
3
4
5
6
7
8
function(my_func NAME AGE OUT_RET)
# 使用 ${NAME} ${AGE} 访问参数
message(STATUS "name: ${NAME}") # name: jack
set(OUT_RET "OK" PARENT_SCOPE)
endfunction()

my_func("jack" 18 OUT_RET)
message(STATUS "return value: ${OUT_RET}") # return value: OK

3.5.2 宏

使用 macro 定义宏:

1
2
3
macro(<name> [<arg1> ...])
<commands>
endmacro()

CMake 中的宏和 C 语言中的宏一样,是在调用处进行语句替换后再执行,因此在宏中使用 return() 时要格外小心,可能会终止整个脚本的执行。

建议优先使用函数。

3.6 字符串操作

字符串操作由 string 指令提供,详见官方文档。

3.7 内置变量

本节列举了在项目中经常用到的 CMake 内置变量。

  • CMAKE_SOURCE_DIR
    始终存储的是项目的根目录。

  • CMAKE_BINARY_DIR
    始终存储的是项目的根构建目录。

  • PROJECT_SOURCE_DIR
    与 CMAKE_SOURCE_DIR 一样,也始终存储的是项目的根目录,但该变量需要使用 project 创建项目以后,才会被定义。

  • PROJECT_BINARY_DIR
    与 CMAKE_BINARY_DIR 一样,也始终存储的是项目的构建目录,但该变量需要使用 project 创建项目以后,才会被定义。

  • CMAKE_CURRENT_SOURCE_DIR
    存储的是当前正在执行脚本所在的目录。

  • CMAKE_CURRENT_BINARY_DIR
    一个工程中可能包括多个项目,每个项目的构建目录不同,该变量存储的是当前项目的构建目录。

  • CMAKE_CURRENT_LIST_FILE
    当前脚本代码所在文件的完整路径。

  • CMAKE_CURRENT_LIST_LINE
    当前脚本代码所在行数。

  • CMAKE_MODULE_PATH
    通过设置改变变量,可以控制 CMake 查找 .cmake 文件的路径,在使用 include 时就可以直接使用文件名了。如:

    1
    2
    3
    set(CMAKE_MODULE_PATH "${CMAKE_SOURCE_DIR}/cmake")

    include(utils)

    需要注意的是,当在 include 中仅指定了文件名时,不能再添加 .cmake 扩展名,否则会导致查找不到相应文件。

  • WIN32
    在 Windows 系统上,定义该变量,值为 1。

  • APPLE
    在 Apple 系统上,定义该变量,值为 1。

  • UNIX
    在类 UNIX 系统上,定义该变量,值为 1。

  • CMAKE_SYSTEM_NAME
    当前构建所选定目标系统,但该变量需要使用 project 创建项目以后,才会被定义。

    该变量常见的值有:Android、iOS、Linux、FreeBSD、MSYS、Windows、Darwin,完整的列表见:CMAKE_SYSTEM_NAME

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    if (CMAKE_SYSTEM_NAME STREQUAL "Linux")
    message(STATUS "current platform: Linux ")
    elseif (CMAKE_SYSTEM_NAME STREQUAL "Windows")
    message(STATUS "current platform: Windows")
    elseif (CMAKE_SYSTEM_NAME STREQUAL "FreeBSD")
    message(STATUS "current platform: FreeBSD")
    elseif (CMAKE_SYSTEM_NAME STREQUAL "Darwin")
    message(STATUS "current platform: macOS")
    elseif (CMAKE_SYSTEM_NAME STREQUAL "Android")
    message(STATUS "current platform: Android")
    elseif (CMAKE_SYSTEM_NAME STREQUAL "iOS")
    message(STATUS "current platform: iOS")
    else ()
    message(STATUS "other platform: ${CMAKE_SYSTEM_NAME}")
    endif()
  • MSVC
    当编译器是 Microsoft Visual C++ 的某个版本或模拟 Visual C++ cl 命令行语法的其他编译器时设置为 true。

  • MSVC_VERSION
    正在使用的 Microsoft Visual C/C++ 版本(如果有)。如果正在使用模拟 Visual C++ 的编译器,则此变量将设置为 _MSC_VER 预处理器定义所给定的模拟工具集版本。

四、目标的属性

4.1 属性调试

在介绍如何设置目标的属性之前,我们先学习一下如何调试输出目标属性,方便在开发中检查属性设置是否出错。

使用 CMakePrintHelpers 模块提供 cmake_print_properties 函数可以打印输出目标的属性,该函数的原型如下:

1
2
3
4
5
6
cmake_print_properties(<TARGETS       [<target1> ...] |
SOURCES [<source1> ...] |
DIRECTORIES [<dir1> ...] |
TESTS [<test1> ...] |
CACHE_ENTRIES [<entry1> ...] >
PROPERTIES [<prop1> ...] )

以打印输出目标的“包含目录”属性为例:

1
2
3
4
5
6
add_library(hello_cmake main.cpp)

target_include_directories(hello_cmake PRIVATE "include")

include(CMakePrintHelpers)
cmake_print_properties(TARGETS hello_cmake PROPERTIES INCLUDE_DIRECTORIES)

输出:

1
2
Properties for TARGET hello_cmake:
hello_cmake.INCLUDE_DIRECTORIES = "D:/cmake-sample/hello_cmake/include"

4.2 包含目录

使用 target_include_directories 指定目标包含一个或多个目录。指定的目录路径可以是绝对路径也可以是相对路径,如果是相对路径,则该路径是相对于当前脚本文件的。

1
2
3
target_include_directories(<target> [SYSTEM] [AFTER|BEFORE]
<INTERFACE|PUBLIC|PRIVATE> [items1...]
[<INTERFACE|PUBLIC|PRIVATE> [items2...] ...])

这就相当于在 Visual Studio 中设置“附加包含目录”。

可以针对一个目标重复调用 target_include_directories,会按照调用顺序依次附加包含目录,也可以使用 BEFORE 选项,将本次设置的包含目录插入到最前面。

我们需要特别花精力理解 INTERFACE、PUBLIC、PRIVATE 三者的区别,这三者的区别,我们先按下不表,稍后介绍。

在使用 PUBLIC、PRIVATE 设置包含目录时,会自动设置 INCLUDE_DIRECTORIES 属性;在使用 INTERFACE 设置包含目录时,会自动设置 INTERFACE_INCLUDE_DIRECTORIES 属性。

示例:

1
2
3
4
5
6
7
target_include_directories(hello_cmake 
PUBLIC "include"
PRIVATE "./extern/jsoncpp")

target_include_directories(hello_cmake BEFORE
PUBLIC "include"
PRIVATE "./extern/rpclib")

4.3 预编译宏

使用 target_compile_definitions 设置目标的预编译宏。

1
2
3
target_compile_definitions(<target>
<INTERFACE|PUBLIC|PRIVATE> [items1...]
[<INTERFACE|PUBLIC|PRIVATE> [items2...] ...])

可以针对一个目标重复调用 target_compile_definitions,附加多个预编译宏。

使用 PUBLIC、PRIVATE 设置包含目录时,会自动设置 COMPILE_DEFINITIONS 属性;使用 INTERFACE 设置包含目录时,会自动设置 INTERFACE_COMPILE_DEFINITIONS 属性。

示例:

1
2
3
4
5
6
7
# 定义2个预编译宏
target_compile_definitions(hello_cmake
PUBLIC USING_BOOST USING_THRIFT)

# 再定义一个预编译宏
target_compile_definitions(hello_cmake
INTERFACE USING_ZLIB)

4.4 依赖库

使用 target_link_libraries 指令设置目标的依赖库,该指令有很多原型,但常用的原型有:

1
2
3
target_link_libraries(<target>
[<PRIVATE|PUBLIC|INTERFACE> <item>...]
[<PRIVATE|PUBLIC|INTERFACE> <item>...]...)

item 可以是如下几种类型:

  • lib 文件的绝对路径或相对路径,CMake 不会校验文件是否存在
  • 其他的 CMake 目标。
  • 表达式生成器(见“生成器表达式”章节)。
  • - 开头的链接标志,但从 CMake 3.13 版本开始,可以直接使用 target_link_options() 替代。

可以针对一个目标重复调用 target_link_libraries,附加多个依赖库。

<PRIVATE|PUBLIC|INTERFACE> 可以省略,如果省略,则默认为 PUBLIC。

使用 PUBLIC、PRIVATE 设置依赖库时,会自动设置 LINK_LIBRARIES 属性;使用 INTERFACE 设置依赖库时,会自动设置 INTERFACE_LINK_LIBRARIES 属性。

4.5 INTERFACE、PUBLIC 和 PRIVATE

INTERFACE、PUBLIC 和 PRIVATE 用于指定属性的可见性传递方案。

目标类型 可见性传递 自身是否应用该属性 使用者是否应用该属性
可执行文件 INTERFACE 否(可执行文件不存在使用者)
可执行文件 PUBLIC 否(可执行文件不存在使用者)
可执行文件 PRIVATE 否(可执行文件不存在使用者)
库(动态或静态) INTERFACE
库(动态或静态) PUBLIC
库(动态或静态) PRIVATE

4.6 编译和链接选项

通过 target_compile_options 指令设置编译选项。

1
2
3
target_compile_options(<target> [BEFORE]
<INTERFACE|PUBLIC|PRIVATE> [items1...]
[<INTERFACE|PUBLIC|PRIVATE> [items2...] ...])

通过 target_link_options 指令设置链接选项。

1
2
3
target_link_options(<target> [BEFORE]
<INTERFACE|PUBLIC|PRIVATE> [items1...]
[<INTERFACE|PUBLIC|PRIVATE> [items2...] ...])

这两个指令的用法与前面介绍的 target_include_directories 类似。

示例:

1
2
3
4
5
# 编译选项
target_compile_options(my_lib PRIVATE /utf8)

# 链接选项
target_link_options(app PRIVATE /NOLOGO)

4.7 其他属性

CMake 为目标还提供了其他属性,详见:target-properties

针对这些属性,需要使用 set_target_properties 指令进行设置。

例如,使用 OUTPUT_NAME 和 DEBUG_OUTPUT_NAME 设置目标的输出文件名。

1
2
3
set_target_properties(zoe PROPERTIES 
OUTPUT_NAME Zoe
DEBUG_OUTPUT_NAME Zoe-d)