CMake学习笔记#1

最近需要写一个 C++ 项目。由于项目规模不算太小,还需要引入很多第三方库,我选择了 CMake 作为自动化构建系统。由于之前并没有用过 CMake,在正式开始项目搭建之前,我决定先通过 CMake 官方教程学习一下基本用法。在这里我会记录一下学习过程中的笔记和想法。

Step 1

第一章主要介绍了如何让 CMake 运行起来。教程给出了一个最基础的 CMakeLists.txt 文件:

# 指定允许的最低 CMake 版本
cmake_minimum_required(VERSION 3.10)

# 设置项目名称
project(Tutorial)

# 创建一个可执行目标,并添加一个源代码文件
add_executable(Tutorial tutorial.cxx)

让 CMake 运行起来很简单,教程中的做法如下:

  1. Help/guide/tutorial 目录中,创建一个 Step1_build 文件夹并进入:

    mkdir Step1_build
    cd Step1_build
  2. 生成构建系统:

    cmake ../Step1
  3. 编译项目:

    cmake --build .

理解这几个命令之前,需要理解一下 CMake 的工作原理。CMake 的工作分为两步:

  1. 配置+生成:基于 CMake 项目,生成适配当前平台的原生构建系统,例如根据 CMakeLists.txt 生成 Makefile
  2. 构建:调用原生构建系统编译项目,例如调用 Make 编译项目。

cmake 命令对应第一步,其详细用法如下(cmake(1)):

cmake [<options>] <path-to-source>
cmake [<options>] <path-to-existing-build>
cmake [<options>] -S <path-to-source> -B <path-to-build>

注意这里有两个不同的路径,其中 <path-to-source>源码目录的路径,也就是 CMake 项目的位置;<path-to-build>构建目录的路径,也就是 CMake 生成原生构建系统的位置。教程中使用了第一种签名,只指定了源码目录的路径,构建系统将默认生成在当前目录(pwd)下;也可以使用第三种签名同时指定源码目录和构建目录的位置,CLion 用的就是这个签名。

当然源码目录的路径和构建目录的路径也可以不是 Step1Step1_build。一种常见的做法是在源码目录下创建一个 build 目录作为构建目录。通常源码目录的路径和构建目录的路径是不同的,这叫“源外”构建(”out-of-source” build);否则就叫“源内”构建(”in-source” build)(What is an “out-of-source” build?)。

cmake --build 命令对应第二步,其详细用法如下(cmake(1)):

cmake --build <dir> [<options>] [-- <build-tool-options>]

其中构建目录的路径是必需的,在教程中就是当前目录 .

源码目录的路径对应着 PROJECT_SOURCE_DIR 变量;构建目录的路径对应着 PROJECT_BINARY_DIR 变量。

接着,教程讲述了如何用配置头文件来添加一个版本号。首先将 CMakeLists.txt 修改成:

cmake_minimum_required(VERSION 3.10)

# 设置项目名称,并指定版本号
project(Tutorial VERSION 1.0)

# 指定一个配置文件的映射关系,即当 CMake 配置时,源码目录中的 `TutorialConfig.h.in` 配置头文件会在构建目录中生成一个名为 `TutorialConfig.h` 的头文件
configure_file(TutorialConfig.h.in TutorialConfig.h)

add_executable(Tutorial tutorial.cxx)

# 给名为 `Tutorial` 的目标添加一个头文件搜索路径,`PROJECT_BINARY_DIR` 就是构建目录的路径
target_include_directories(Tutorial PUBLIC
                           "${PROJECT_BINARY_DIR}"
                           )

然后创建 TutorialConfig.h.in 文件:

// the configured options and settings for Tutorial
#define Tutorial_VERSION_MAJOR @Tutorial_VERSION_MAJOR@
#define Tutorial_VERSION_MINOR @Tutorial_VERSION_MINOR@

配置文件中的 @VAR@ 或者 ${VAR} 的会被替换成对应变量的值(configure_file)。project() 的实际作用是设置

  • PROJECT_NAME
  • PROJECT_SOURCE_DIR, <PROJECT-NAME>_SOURCE_DIR
  • PROJECT_BINARY_DIR, <PROJECT-NAME>_BINARY_DIR

等变量。当指定了版本号时,还会设置

  • PROJECT_VERSION, <PROJECT-NAME>_VERSION
  • PROJECT_VERSION_MAJOR, <PROJECT-NAME>_VERSION_MAJOR
  • PROJECT_VERSION_MINOR, <PROJECT-NAME>_VERSION_MINOR
  • PROJECT_VERSION_PATCH, <PROJECT-NAME>_VERSION_PATCH
  • PROJECT_VERSION_TWEAK, <PROJECT-NAME>_VERSION_TWEAK

等变量(project)。由于 configure_file() 之前已经调用了 project()Tutorial_VERSION_MAJORTutorial_VERSION_MINOR 两个变量是已经定义好了的。

tutorial.cxx 中,添加如下内容:

#include "TutorialConfig.h"

int main(int argc, char *argv[]) {
    if (argc < 2) {
        // report version
        std::cout << argv[0] << " Version " << Tutorial_VERSION_MAJOR << "."
                  << Tutorial_VERSION_MINOR << std::endl;
        std::cout << "Usage: " << argv[0] << " number" << std::endl;
        return 1;
    }
    // ...
    return 0;
}

就可以打印在 CMakeLists.txt 中指定的版本号了。

最后,教程讲了如何制定 C++ 标准。修改 CMakeFiles.txt 如下(注意,对 CMAKE_CXX_STANDARD 的申明需在 add_executable() 之前):

cmake_minimum_required(VERSION 3.10)

project(Tutorial VERSION 1.0)

# 指定 C++ 标准
set(CMAKE_CXX_STANDARD 11)
set(CMAKE_CXX_STANDARD_REQUIRED True)

configure_file(TutorialConfig.h.in TutorialConfig.h)

add_executable(Tutorial tutorial.cxx)

target_include_directories(Tutorial PUBLIC
                           "${PROJECT_BINARY_DIR}"
                           )

重新运行 CMake 即可。

探究 1

可以通过多个相同目标名称的 add_executable() 来给一个目标添加多个源代码文件吗?

不可以。标准情况下 add_executable() 的签名如下(add_executable):

add_executable(<name> [WIN32] [MACOSX_BUNDLE]
               [EXCLUDE_FROM_ALL]
               [source1] [source2 ...])

它的含义是添加一个名为 <name> 的可执行目标,添加两个同名的目标显然是不合法的。如果有两个 add_executable()<name> 相同,CMake 会在配置时报错。

那如何给一个可执行目标添加多个源代码文件呢?可以按照签名在 add_executable() 中提供多个源代码文件的路径;也可以利用 target_sources() 来给某个目标添加源代码文件,其签名如下(target_sources):

target_sources(<target>
  <INTERFACE|PUBLIC|PRIVATE> [items1...]
  [<INTERFACE|PUBLIC|PRIVATE> [items2...] ...])

CMakeLists.txt 中,可以这样写:

# 创建一个可执行目标
add_executable(Tutorial)

# 给名为 `Tutorial` 的目标添加一个源代码文件
target_sources(Tutorial PUBLIC tutorial.cxx)

由于用了 target_sources()add_executable() 中可以不提供源代码文件的路径。和 add_executable() 不同,多个相同目标名称的 target_sources() 是合法的。

:在使用 target_sources() 时,可能会遇到警告信息:

CMake Warning (dev) at CMakeLists.txt:14 (target_sources):
  Policy CMP0076 is not set: target_sources() command converts relative paths
  to absolute.  Run "cmake --help-policy CMP0076" for policy details.  Use
  the cmake_policy command to set the policy and suppress this warning.

  An interface source of target "Tutorial" has a relative path.
This warning is for project developers.  Use -Wno-dev to suppress it.

这是由于 CMake 在 3.13 版本时改变了 target_sources() 函数的行为(CMP0076)。而我们的 CMakeLists.txt 中指定的允许的最低 CMake 版本是 3.10。这意味着不同版本的 CMake 可能会有不同的行为。解决方法是在 CMakeLists.txt 中添加一行 cmake_policy() 来显式指定 CMake 的行为版本,其签名如下(cmake_policy):

cmake_policy(VERSION <min>[...<max>])

CMakeLists.txt 中,可以这样写:

cmake_policy(VERSION 3.13)

更直接的方法是将 cmake_minimum_required() 中的版本号改为 3.13 或更高的版本,因为 cmake_minimum_required() 会隐式调用 cmake_policy()cmake_policy)。

探究 2

可以有多个 project() 吗?project() 中的名称可以和 add_executable() 中的目标名称不一样吗?

两个问题的答案都是可以。project() 的本质作用就是设置一系列的变量(project)。如果有多个 project(),那这些变量就是最后一次调用的结果。由于 project() 只是设置了变量,其中的名称和 add_executable() 中的目标名称不一样也没有关系,只是后面用到变量时会不太方便。

Step 2

第二章中介绍了如何添加库。教程给出的示例代码中包含一个 MathFunctions 目录,里面含有一个头文件 MathFunctions.h,和一个源代码文件 mysqrt.cxx,这个目录就是要添加的库了。首先要做的就是将这个目录标记成一个 CMake 项目,在 MathFunctions 目录中创建一个 CMakeLists.txt 文件:

# 创建一个库目标,并添加一个源代码文件
add_library(MathFunctions mysqrt.cxx)

接着,修改顶层目录的 CMakeLists.txt 文件如下:

cmake_minimum_required(VERSION 3.10)

project(Tutorial VERSION 1.0)

set(CMAKE_CXX_STANDARD 11)
set(CMAKE_CXX_STANDARD_REQUIRED True)

configure_file(TutorialConfig.h.in TutorialConfig.h)

# 添加 `MathFunctions` 目录到构建中
add_subdirectory(MathFunctions)

add_executable(Tutorial tutorial.cxx)

# 给名为 `Tutorial` 的目标添加一个名叫 `MathFunctions` 的库目标
target_link_libraries(Tutorial PUBLIC MathFunctions)

# 由于会用到头文件 `MathFunctions/MathFunctions.h`,给名为 `Tutorial` 的目标添加 `${PROJECT_SOURCE_DIR}/MathFunctions` 这个头文件搜索路径
target_include_directories(Tutorial PUBLIC
                          "${PROJECT_BINARY_DIR}"
                          "${PROJECT_SOURCE_DIR}/MathFunctions"
                          )

现在 tutorial.cxx 中已经可以使用库中的 mysqrt() 函数了:

#include "TutorialConfig.h"
#include "MathFunctions.h"

int main(int argc, char* argv[])
{
    // ...
    const double outputValue = mysqrt(inputValue);
    // ...
    return 0;
}

接着,教程讲了如何让 MathFunctions 库变成可选的。首先,修改顶层目录的 CMakeLists.txt 文件,使其仅在需要时添加 MathFunctions 库:

cmake_minimum_required(VERSION 3.10)

project(Tutorial VERSION 1.0)

set(CMAKE_CXX_STANDARD 11)
set(CMAKE_CXX_STANDARD_REQUIRED True)

# 创建一个选项 `USE_MYMATH`,默认值为 `ON`
option(USE_MYMATH "Use tutorial provided math implementation" ON)

configure_file(TutorialConfig.h.in TutorialConfig.h)

# 使用 `if` 来根据 `USE_MYMATH` 进行判断
if(USE_MYMATH)
    add_subdirectory(MathFunctions)
    # 添加库目标到 `EXTRA_LIBS` 列表
    list(APPEND EXTRA_LIBS MathFunctions)
    # 添加头文件搜索路径到 `EXTRA_INCLUDES` 列表
    list(APPEND EXTRA_INCLUDES "${PROJECT_SOURCE_DIR}/MathFunctions")
endif()

add_executable(Tutorial tutorial.cxx)

# 给名为 `Tutorial` 的目标添加 `EXTRA_LIBS` 列表中的库目标
target_link_libraries(Tutorial PUBLIC ${EXTRA_LIBS})

# 添加 `EXTRA_INCLUDES` 列表中的头文件搜索路径
target_include_directories(Tutorial PUBLIC
                           "${PROJECT_BINARY_DIR}"
                           ${EXTRA_INCLUDES}
                           )

接下来修改 tutorial.cxx,使其仅在需要时引入 MathFunctions.h 头文件并使用 MathFunctions 库的方法,否则就使用原生的方法:

#include "TutorialConfig.h"
#ifdef USE_MYMATH
#  include "MathFunctions.h"
#endif

int main(int argc, char* argv[])
{
    // ...
#ifdef USE_MYMATH
    const double outputValue = mysqrt(inputValue);
#else
    const double outputValue = sqrt(inputValue);
#endif
    // ...
    return 0;
}

不过,USE_MYMATH 变量目前只是在 CMakeLists.txt 中定义了,还不能在 tutorial.cxx 文件中直接使用。因此,还需要在 TutorialConfig.h.in 文件中增加一行:

#cmakedefine USE_MYMATH

配置文件中的 #cmakedefine VAR 会在 VAR 变量被定义且为非假值时被替换成 #define VAR,否则被替换成 /* #undef VAR */

现在 MathFunctions 库已经变成可选的了。可以使用 cmake 命令配合 -D 参数来控制 USE_MYMATH 选项的值:

cmake ../Step2 -DUSE_MYMATH=OFF

也可以使用 ccmake 或是 cmake-gui 命令来控制选项的值:

ccmake ../Step2

探究 3

add_subdirectory() 中的 MathFunctionstarget_link_libraries() 中的 MathFunctions 是相同的吗?

它们是不同的。add_subdirectory() 需要的是次级 CMakeLists.txt 文件所在目录的路径(add_subdirectory);而 target_link_libraries() 需要的是目标名称(target_link_libraries),也就是次级 CMakeLists.txtadd_library() 所添加的目标的名称。

比如,将 MathFunctions 移动到 libs 目录下,只需要将

add_subdirectory(MathFunctions)

修改为

add_subdirectory(libs/MathFunctions)

而不需要修改 target_link_libraries()(当然,target_include_directories() 中的 ${PROJECT_SOURCE_DIR}/MathFunctions 也需要改成 {PROJECT_SOURCE_DIR}/libs/MathFunctions)。

探究 4

为什么次级 CMakeLists.txt 中没有 project() 命令?

前面提到过,project() 命令的作用是定义一系列变量,没有也是可以的。

探究 5

CMakeLists.txt 中的 option() 命令和 configure_file() 命令能不能互换位置?

不能,如果 option() 命令在 configure_file() 命令之后,那么 option() 命令定义的变量就无法被配置文件所引用了。

探究 6

if() 遇到哪些常量时会视为“真”,哪些常量会视为“假”?

根据 if1ONYESTRUEY,或是非零数字(包括浮点数)会被视为“真”;而 0OFFNOFALSENIGNORENOTFOUND,空字符串,或以 -NOTFOUND 结尾的字符串会被视为“假”。这些常量都是大小写不敏感的。

探究 7

#cmakedefine VAR ... 和之前遇到过的 #define VAR @VAR@ 有什么区别?

根据 configure_file#define VAR @VAR@ 中的 @VAR@ 会被替换为变量 VAR 的值;而 #cmakedefine VAR ... 会在变量 VAR 为被 if() 判断为非假值时被替换为:

#define VAR ...

否则被替换为:

/* #undef VAR */

另外还有一种 #cmakedefine01 VAR 的用法,它在变量 VAR 为被 if() 判断为非假值时被替换为:

#define VAR 1

否则被替换为:

#define VAR 0

探究 8

tutorial.cxx 中的 #include "TutorialConfig.h" 和后面的 #ifdef...endif 能不能互换位置?

不能,如果 USE_MYMATH 选项为非假值,TutorialConfig.h 中就会定义 USE_MYMATH。如果 #include "TutorialConfig.h"#ifdef...endif 之后,USE_MYMATH 就总会是未被定义的,MathFunctions.h 也总不会被引入。

探究 9

ccmake 命令中的 configuregenerate 有什么区别?

前面提到过,CMake 的工作分为两步,配置(configure)生成(generate)构成了第一步配置+生成配置(configure)主要包括读取 CMakeLists.txt、建立项目的一些内在联系、根据配置头文件生成头文件等等;生成(generate)则主要是根据上一步生成的内在联系输出原生构建系统(Why there are two buttons in GUI Configure and Generate when CLI does all in one command)。cmake 命令包括了这两步,且不可拆分。

探究 10

如何控制构建动态链接库还是静态链接库?

根据 add_libraryadd_library() 的签名如下:

add_library(<name> [STATIC | SHARED | MODULE]
            [EXCLUDE_FROM_ALL]
            [<source>...])

第二个参数中的 STATICSHARED 可以控制构建静态链接库还是动态链接库。如果不指定,将在变量 BUILD_SHARED_LIBS 为真值时构建动态链接库,否则构建静态链接库。

Step 3

第三章介绍了如何利用传递性来更好地控制可执行目标或是库目标所包含的头文件路径和所链接的库,也就是使用需求。CMake 中常用的使用需求命令有:

首先修改 MathFunctions 中的 CMakeLists.txt 为:

add_library(MathFunctions mysqrt.cxx)

# 给 `MathFunctions` 目标添加一个头文件搜索路径
target_include_directories(MathFunctions INTERFACE ${CMAKE_CURRENT_SOURCE_DIR})

接着,修改顶层 CMakeLists.txt 为:

cmake_minimum_required(VERSION 3.10)

project(Tutorial VERSION 1.0)

set(CMAKE_CXX_STANDARD 11)
set(CMAKE_CXX_STANDARD_REQUIRED True)

option(USE_MYMATH "Use tutorial provided math implementation" ON)

configure_file(TutorialConfig.h.in TutorialConfig.h)

# 删除添加头文件搜索路径到 `EXTRA_INCLUDES` 列表的语句
if(USE_MYMATH)
    add_subdirectory(MathFunctions)
    list(APPEND EXTRA_LIBS MathFunctions)
endif()

add_executable(Tutorial tutorial.cxx)

target_link_libraries(Tutorial PUBLIC ${EXTRA_LIBS})

# 删除添加 `EXTRA_INCLUDES` 列表中的头文件搜索路径的语句
target_include_directories(Tutorial PUBLIC
                           "${PROJECT_BINARY_DIR}"
                           )

重新运行 CMake 即可。

注意 MathFunctions/CMakeLists.txttarget_include_directories() 的第二个参数 INTERFACE。这个参数可以是三种值:PUBLICPRIVATEINTERFACE。以本教程中的 target_include_directories() 为例,它们的含义分别是:

  • PUBLICMathFunctions 目标本身和链接了 MathFunctions 库目标的 Tutorial 目标都会被添加这个头文件搜索路径;
  • PRIVATE:只有 MathFunctions 目标本身会被添加这个头文件搜索路径;
  • INTERFACE:只有链接了 MathFunctions 库目标的 Tutorial 目标会被添加这个头文件搜索路径。

由于只有 Tutorial 目标需要包含 MathFunctions.h,所以这里使用了 INTERFACE。不过,值得一提的是,即便如此也依然能够在 mysqrt.cxx 中包含 MathFunctions.h 头文件,因为源代码所在路径总会是头文件搜索路径。

target_include_directories() 类似,include_directories() 也可以添加头文件搜索路径。不同的是,target_include_directories() 给特定的目标添加头文件搜索路径,include_directories() 给当前 CMakeLists.txt 中所有的目标、以及在它之后调用的 add_subdirectory() 引用的子目录中的目标添加头文件搜索路径(What is the difference between include_directories and target_include_directories in CMake?)。

探究 11

在使用 add_subdirectory() 时,变量是如何传递的?

在执行 add_subdirectory() 时已经定义的变量会传递给子级 CMakeLists.txt。而且,子级 CMakeLists.txt 中对变量进行的定义不会影响到父级 CMakeLists.txt

探究 12

CMAKE_CURRENT_SOURCE_DIRCMAKE_CURRENT_BINARY_DIRCMAKE_SOURCE_DIRCMAKE_BINARY_DIRPROJECT_SOURCE_DIRPROJECT_BINARY_DIR<PROJECT-NAME>_SOURCE_DIR<PROJECT-NAME>_BINARY_DIR 有什么区别?

CMAKE_SOURCE_DIRCMAKE_BINARY_DIR 分别代表着顶层项目的源码目录和构建目录,而 CMAKE_CURRENT_SOURCE_DIRCMAKE_CURRENT_BINARY_DIR 分别代表着当前项目的源码目录和构建目录。教程中,需要在子项目中添加子项目的源码目录为头文件搜索路径,应此需要使用 CMAKE_CURRENT_SOURCE_DIR

PROJECT_SOURCE_DIRPROJECT_BINARY_DIR 两个变量是由 project() 设置的。如果子项目中没有调用 project(),那么根据变量传递的规则,PROJECT_SOURCE_DIRPROJECT_BINARY_DIR 会与 CMAKE_SOURCE_DIRCMAKE_BINARY_DIR 一致,分别代表着顶层 CMakeLists.txt 的源码目录和构建目录;如果子项目也调用了 project(),那么子项目中的 PROJECT_SOURCE_DIRPROJECT_BINARY_DIR 就会分别是子项目的源码目录和构建目录。同样的,根据变量传递的规则,即便是在父项目调用 add_subdirectory() 之后,父项目的 PROJECT_SOURCE_DIRPROJECT_BINARY_DIR 也依然分别是父项目的源码目录和构建目录,不会被子项目影响。

<PROJECT-NAME>_SOURCE_DIR<PROJECT-NAME>_BINARY_DIR 两个变量同样是由 project() 设置的。任何调用了 project() 的项目和其子项目都可以用 <PROJECT-NAME>_SOURCE_DIR<PROJECT-NAME>_BINARY_DIR 来分别代表 <PROJECT-NAME> 项目的源码目录和构建目录。比较神奇的是,如果子项目调用了 project(),那么父项目在调用 add_subdirectory() 之后,也可以用 <CHILD-PROJECT-NAME>_SOURCE_DIR<CHILD-PROJECT-NAME>_BINARY_DIR 来代表 <CHILD-PROJECT-NAME> 子项目的源码目录和构建目录,这和之前提到的变量传递规则有所不同。