更现代化的 CMake

更现代化的 CMake

RayAlto OP

你疑似有点太现代化了。这里选择 CMake 3.9 版本,是 2017 年 7 月发布的,也就是 C++17 进入国际标准草案阶段之后 4 个月。

参考文献:

  1. CMake Tutorial — CMake Documentation
  2. An Introduction to Modern CMake · Modern CMake

1. 基本

一些基本用法

1.1. TL;DR

1
2
3
4
5
6
7
8
9
10
# 先看看选项的情况,后面还可以接 A(advanced), H(human-readable)
cmake -S . -L
# 生成
cmake -DCMAKE_BUILD_TYPE=Debug -S . -B ./build
# 编译
cmake --build ./build
# 安装(选择你的英雄)
cmake --build ./build --target install
cmake --install ./build # CMake 3.15+
make -C build install
  • -S 表示 source 目录
  • -L 让 CMake 列出 cache variable 的情况
  • -B 表示 build 目录
  • --build 表示进行构建。加上 -j N 可以并行编译( CMake 3.12+ )
  • --install 后接 build 目录表示安装这个 project

据说这样写是异类,正常人一般 cd./build 之后进行操作,不需要带上那些指定 ./build 为目录的 flag

1.2. 通用的选项

  • CMAKE_BUILD_TYPE 顾名思义,没指定的话 CMake 不会给底层工具链传相关 flag , i.e. 具体行为由底层工具链决定
  • CMAKE_INSTALL_PREFIX 顾名思义,默认为 /usr/local ,非 root 安装可以设置成 ~/.local
  • BUILD_SHARED_LIBSadd_library 时没指定 STATICSHARED 时会读取这个选项,如果是 ON 则会生成动态库,否则默认为静态
  • BUILD_TESTING 约定俗成表示启用测试,貌似 add_test 后默认启用

1.3. 建议

  • 别用作用于全局的函数 e.g. link_directories, include_directories
  • 别乱给 PUBLIC 的东西加上莫名其妙的 buff e.g. -Wall ,把这些上给 PRIVATE 的东西
  • 别用 GLOB ,用了之后不重新生成项目的话底层工具感知不到项目的变化
  • 尽量链接到 target ,不要直接链接某个库
  • 链接时尽量加上 PRIVATE/PUBLIC

2. CMake 也是一种编程语言

CMake 怎么不算编程语言?(振声)

2.1. 变量

foo bar

2.1.1. 局部变量

顾名思义有作用域

1
2
3
set(VARIABLE_NAME "variable value")
set(SOME_LIST "foo" "bar")
set(SOME_LIST "foo;bar")

2.1.2. Cache 变量

一般用于命令行,比如通过 -D 定义的变量,比如 CMAKE_BUILD_TYPE

1
set(VARIABLE_NAME "variable value" CACHE STRING "variable description")

可选的变量类型有 BOOL, FILEPATH, PATH, STRINGINTERNAL 。其中 BOOL 比较常见,所以有一个更简洁的写法:

1
option(OPTION_NAME "option description" OFF)

可以在 <build-directory>/CMakeCache.txt 看到所有 cache 变量

这样写不会覆盖从命令行传进来的值(如果覆盖的话,处理不当会导致命令行参数不生效),在后面加上 FORCE 可以进行覆盖,比如覆盖命令行的 CMAKE_BUILD_TYPE

1
set(CMAKE_BUILD_TYPE "Release" CACHE STRING "the type of build." FORCE)

2.1.3. 环境变量

一般来说不建议写入环境变量

1
2
message("$ENV{SHELL}")
set(ENV{variable_name} "variable value")

2.1.4. Property

一般作用于比如 TARGET, INSTALLTEST 之类的东西,大多数由 CMAKE_ 开头的变量初始化,比如 CXX_STANDARD 这个 property 由 CMAKE_CXX_STANDARD 变量初始化。比如设置使用 C++17 :

1
2
set_property(TARGET target1 target2 target3
PROPERTY CXX_STANDARD 17)

这种可以给多个东西设置一个 property ,还有一些特化版本可以给某一个特定类型的东西设置多个目标,比如给 TARGET

1
2
3
set_target_properties(target_name PROPERTIES
CXX_STANDARD 17
CXX_EXTENSIONS OFF)

2.2. 流程控制

1
2
3
4
5
if(variable)
# 变量值为 `ON`, `YES`, `TRUE`, `Y` 或非零
else()
# 变量表示否定
endif()

上面直接写了 variable 而不是 ${variable} 是由于历史原因, CMake 会自动展开 if 里面的变量,但这会导致歧义,比如:

1
2
3
4
5
6
set(foo "114514")
set(bar "foo")

if(${bar})
# ...
endif()

这里 ${bar} 会被展开为 foo ,语句变为 if(foo) ,而这里 foo 也是一个变量, if 是否应该继续展开? CMake 3.1 版本之后有了一个规则,双引号包起来的变量不会再被展开,所以可以这样写:

1
if("${bar}")

当然少不了逻辑运算符:

  • 一元: NOT, TARGET, EXISTS(file), DEFINED, …
  • 二元: STREQUAL, AND, OR, MATCHES(regexp), VERSION_LESS, VERSION_LESS_EQUAL(CMake 3.7+), …
  • 括号用来调整优先级

2.3. 生成表达式

上面提到的东西基本都是 CMake 生成项目时进行的工作,而生成表达式可以在编译时执行一些逻辑操作,语法与变量展开类似,为 $<...> ,比如:

1
target_include_directories(foo PRIVATE /opt/include/$<CXX_COMPILER_ID>)

编译 foo 时会根据底层工具的不同选择不同的目录,比如使用 GNU GCC 时会选择 /opt/include/GNU ,使用 LLVM Clang 时会选择 /opt/include/Clang 。再比如:

1
2
3
target_compile_definitions(foo PRIVATE
$<$<VERSION_LESS:$<CXX_COMPILER_VERSION>,4.2.0>:OLD_COMPILER>
)

如果编译器版本小于 4.2.0 的话,编译 fooOLD_COMPILER 宏会被定义

2.4. 宏和函数

宏与函数的唯一区别就是宏没有作用域一说,里面的变量在宏外依然可以访问,而函数需要使用 set 配合 PARENT_SCOPE 把变量传给父作用域

1
2
3
4
5
function(my_func)
message(STATUS "foo")
endfunction()

my_func()

可以定义命名参数:

1
2
3
function(my_func param1)
message(STATUS "param1: ${param1}")
endfunction()

还有两个特殊变量,比如 my_func(foo bar) 调用:

  • ARGV 表示所有参数,为 my_func,foo,bar
  • ARGN 表示匹配后(这里除了函数名 my_func 外,把 foo 匹配给了 param1 )剩下的参数,为 bar

CMake 3.5 后内置了 cmake_parse_arguments (之前版本可以引入 CMakeParseArguments 模块后使用),语法为:

1
2
3
4
5
6
7
8
9
function(my_func)
cmake_parse_arguments(
FOO
"OPTION_1;OPTION_2"
"ARG_1;ARG_2"
"ARGS"
${ARGN}
)
endfunction()
  • 第一行 FOO 表示为后面定义的变量加上 FOO_ 前缀,比如第二行的 OPTION_1 实际名为 FOO_OPTION_1
  • 第二行 OPTION_1;OPTION_2 表示匹配 BOOL 类型变量,比如在这个位置成功匹配到 OPTION_1 ,则 FOO_OPTION_1TRUE
  • 第三行 ARG_1;ARG_2 表示匹配含有单个值的变量,比如在这个位置成功匹配到 ARG_1 value1 ,则 FOO_ARG_1value1
  • 第四行 ARGS 表示匹配含有多个值的变量,比如在这个位置成功匹配到 ARGS foo bar ,则 ARGSfoo;bar
  • 第五行 ${ARGN} 把函数名以外的所有参数传给 cmake_parse_arguments

举个例子:

1
my_func(OPTION_2 ARG_1 value1 ARGS 114 514)

my_func 内变量的情况:

  • FOO_OPTION_1FALSE
  • FOO_OPTION_2TRUE
  • FOO_ARG_1value1
  • FOO_ARG_2 未定义
  • FOO_ARGS114;514

未被成功匹配的所有变量会被装进 FOO_UNPARSED_ARGUMENTS 。如果不想写 OPTION_1;OPTION_2, ARG_1;ARG_2 这样带有分号的多个变量可以利用变量展开,比如这样可以实现同样的效果:

1
2
3
4
5
6
7
8
9
10
11
12
function(my_func)
set(options OPTION_1 OPTION_2)
set(single_value_args ARG_1 ARG_2)
set(multi_value_args ARGS)
cmake_parse_arguments(
FOO
"${options}"
"${single_value_args}"
"${multi_value_args}"
${ARGN}
)
endfunction()

2.5. 与其他文件交互

我觉得这些设计有点蹩脚

2.5.1. 输出文件

configure_file 可以做一些替换,生成比如头文件:

1
2
3
4
5
6
// version.h.in
#define MY_VERSION_MAJOR @PROJECT_VERSION_MAJOR@
#define MY_VERSION_MINOR @PROJECT_VERSION_MINOR@
#define MY_VERSION_PATCH @PROJECT_VERSION_PATCH@
#define MY_VERSION_TWEAK @PROJECT_VERSION_TWEAK@
#define MY_VERSION "@PROJECT_VERSION@"

使用 configure_file

1
2
# CMakeLists.txt
configure_file(version.h.in version.h)

生成项目时会生成 version.h (具体版本号随便填的):

1
2
3
4
5
6
// version.h
#define MY_VERSION_MAJOR 1
#define MY_VERSION_MINOR 14
#define MY_VERSION_PATCH 5
#define MY_VERSION_TWEAK 14
#define MY_VERSION "1.14.5.14"

configure_file 会替换 ${VAR}@VAR@ 两种模式,可以 configure_file(... ... @ONLY) 使它只对 @VAR@ 进行替换,忽略 ${VAR} 格式。还有一种写法:

1
2
// foo.h.in
#cmakedefine FOO

如果在 CMake 里,变量 FOO 被定义了,则生成的 foo.h 为:

1
2
// foo.h
#define FOO

否则为:

1
2
// foo.h
/* #undef FOO */

当然 #cmakedefine VAR_NAME 后面还可以接 @VAR_NAME@ ,生成后变为 #define VAR_NAME value/* #undef VAR_NAME */ 。如果喜欢 #define VAR_NAME 0/#define VAR_NAME 1 的格式还可以用另一种写法:

1
2
// foo.h.in
#cmakedefine01 FOO

如果在 CMake 里,变量 FOO 被定义了,则生成的 foo.h 为:

1
2
// foo.h
#define FOO 1

否则为:

1
2
// foo.h
#define FOO 0

2.5.2. 读取文件

比如让 CMake 读取头文件,自动填写 project 里的版本号:

1
2
3
4
5
6
# 在 version.h 里找到符合 `#define FOO_VER ".+"` 的一行,放进 `version_line` 里
file(STRINGS "version.h" version_line REGEX "#define FOO_VER \".+\"")
# 把这一行里实际的版本号提取到 `version_str` 里
string(REGEX REPLACE "#define FOO_VER \"(.+)\"" "\\1" version_str "${version_line}")
# 放进 project 里
project(foo VERSION ${version_str})

2.6. 调用其他程序

可以用 execute_process 配合 find_program 调用其他程序,比如模拟 ls -a | sort -i

1
2
3
4
5
6
7
8
9
10
11
12
find_program(ls_exe ls)      # 把 ls 的绝对路径放进 `ls_exe`
find_program(sort_exe sort) # 把 sort 的绝对路径放进 `sort_exe`

if(ls_exe AND sort_exe) # 确保 ls 和 sort 都找到了
execute_process(
COMMAND "${ls_exe}" -a # ls -a
COMMAND "${sort_exe}" -i # sort -i
WORKING_DIRECTORY /home # 在 /home 目录执行
RESULT_VARIABLE ret # 返回值存进 `ret`
OUTPUT_VARIABLE stdout # 标准输出存进 `stdout`
ERROR_VARIABLE stderr) # 标准错误存进 `stderr`
endif()

上面指定了多个 COMMAND ,这些 COMMAND 会依次被管道线串起来。

除此之外,还有 add_custom_command 可以实现更精细的控制,比如项目需要在编译时生成某个文件:

1
2
3
4
5
6
7
add_custom_command(
OUTPUT "generated.cpp"
COMMAND "${CMAKE_CURRENT_SOURCE_DIR}/scripts/generate.py" --argument
DEPENDS other_targets)
add_custom_target(
generate
DEPENDS "generated.cpp")

add_custom_command 后,如果某个 target 的源文件里包含了 OUTPUT 指定的文件,则这个 COMMAND 就会被执行一次。如果多个可能并行编译的 target 都以这个文件为源文件,则建议使用 add_custom_target 把这个文件包装成 target ,然后再作为 DEPENDS 放进可能并行编译的 target 里

3. 组织项目文件

我的个人口味

应该像这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
.
├── CMakeLists.txt
├── cmake
│   ├── Find**.cmake
│   └── **.cmake
├── include
│   └── <project>
│   ├── **.hpp
│   └── **.hpp
├── LICENSE
├── readme.md
├── src
│   ├── **.cpp
│   ├── **.cpp
│   └── CMakeLists.txt
└── test
├── **.cpp
└── CMakeLists.txt

cmake 目录下可以放一些 CMake 模块,这样可以让 CMake 读取 cmake 目录:

1
set(CMAKE_MODULE_PATH "${PROJECT_SOURCE_DIR}/cmake" ${CMAKE_MODULE_PATH})

下面的代码可以拒绝在包含 CMakeLists.txt 的目录下生成/编译项目:

1
2
3
4
file(TO_CMAKE_PATH "${PROJECT_BINARY_DIR}/CMakeLists.txt" cmake_file)
if(EXISTS "${cmake_file}")
message(FATAL_ERROR "You cannot build in a source directory (or any directory with a CMakeLists.txt file).")
endif()

4. 给项目加 Buff

比如 C++ 标准,编译器自己的扩展

4.1. 半官方的设置默认编译模式的方式

CMake 如果没有被指定 CMAKE_BUILD_TYPE 的话具体编译成什么由底层工具决定,但他们的官方网站 Kitware.com 写了一篇设置默认编译模式的文章。大概是这样:

1
2
3
4
5
6
7
8
9
10
11
# 用 `default_build_type` 保存默认的编译模式,这里为 `Debug`
set(default_build_type "Debug")
# 如果 `CMAKE_BUILD_TYPE` 未定义
if(NOT CMAKE_BUILD_TYPE)
# 输出一段日志
message(STATUS "Setting build type to '${default_build_type}' as none was specified.")
# 设置 `CMAKE_BUILD_TYPE` 为 `default_build_type`
set(CMAKE_BUILD_TYPE "${default_build_type}" CACHE STRING "Choose the type of build." FORCE)
# 这一行可以让 cmake-gui 给用户提供 `CMAKE_BUILD_TYPE` 的可选项
set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS "Debug" "Release" "RelWithDebInfo" "MinSizeRel")
endif()

4.2. C++17

更加现代化的设置方式为( CMake 3.8+ ):

1
2
3
4
# 设置 C++ 标准为 C++17
target_compile_features(foo PRIVATE cxx_std_17)
# 可选,用于禁用编译器扩展,比如 -std=gnu++17 之类的
set_target_properties(foo PROPERTIES CXX_EXTENSIONS OFF)

target_compile_features 允许更细粒度的编译器特性的控制。如果版本低于 3.8 ,可以这样:

1
2
3
4
5
set_target_properties(
foo PROPERTIES # 给 foo 设置
CXX_STANDARD 17 # 设置为 C++17
CXX_STANDARD_REQUIRED ON # 设置 CXX_STANDARD 为必须依赖
CXX_EXTENSIONS OFF) # 禁用编译器扩展

这里如果没启用 CXX_STANDARD_REQUIRED ,当底层编译器不支持 C++17 时, CMake 不会报错,而是继续执行,并给 foo 设置一个最接近的 C++ 标准。虽然这样也可以设置 C++ 标准,但没有 target_compile_features 一样的 PUBLIC, PRIVATE 控制

不要手动设置 -std=c++11 之类的显式 flag

4.3. PIC (Position Independent Code)

CMake 对应变量为 POSITION_INDEPENDENT_CODE ,由命令行参数 CMAKE_POSITION_INDEPENDENT_CODE 初始化。对于 SHAREDMODULE 的库 target , POSITION_INDEPENDENT_CODE 默认为 ON ,否则为 OFF ,所以一般不需要手动指定,如果一定要指定的话可以这样:

1
2
3
4
# 全局启用
set(CMAKE_POSITION_INDEPENDENT_CODE ON)
# 对 foo 启用
set_target_properties(foo PROPERTIES POSITION_INDEPENDENT_CODE ON)

4.4. 链接外部的库

比如 -ldl 一般用来引入 dlopendlclose , CMake 提供了 CMAKE_DL_LIBS

1
target_link_libraries(foo PRIVATE ${CMAKE_DL_LIBS})

别的库可以先 find_package ,比如链接 json-c

1
2
find_package(json-c REQUIRED CONFIG)
target_link_libraries(${PROJECT_NAME} PRIVATE json-c::json-c)

最基本的为:

1
find_package(package_name)

4.4.1. 查找模式

具体有两种模式:

  • Module 模式:这个模式下 CMake 会去 CMAKE_MODULE_PATHFind<PackageName>.cmake ,这种一般是 CMake 或库的用户提供的, i.e. 非官方的。
  • Config 模式:这个模式下 CMake 会找 <package_name>-config[-version].cmake<PackageName>Config[Version].cmake查找的目录更加细致 ,这种一般由库提供, i.e. 官方的。

没有指定模式的情况下 CMake 会先使用 Module 模式,失败后 fallback 到 Config 模式。如果需要指定,可以:

1
2
find_package(package_name MODULE) # 仅使用 Module 模式,不 fallback 到 Config 模式
find_package(package_name CONFIG) # 直接使用 Config 模式

4.4.2. 如果没找到

默认情况下 CMake 会输出一些警告,继续完成项目的生成,还可以加上 QUIETREQUIRED 来禁用这些警告或停止下一步操作:

1
2
find_package(package_name QUIET)    # 没找到也不会输出警告,项目会照常生成
find_package(package_name REQUIRED) # 没找到会输出错误信息, CMake 会异常退出

4.5. 链接时优化

也就是 GCC 中的 -flto 选项, CMake 称其为 IPO (Interprocedural Optimization) 。 CMake 3.9+ 添加了 INTERPROCEDURAL_OPTIMIZATION 用于设置链接时优化,其值由 CMAKE_INTERPROCEDURAL_OPTIMIZATION 初始化,可以像这样启用:

1
set_target_properties(foo PROPERTIES INTERPROCEDURAL_OPTIMIZATION ON)

但如果编译器不支持 IPO , CMake 会输出错误信息并异常退出,可以配合 CheckIPOSupported 模块解决这个问题:

1
2
3
4
5
include(CheckIPOSupported)
check_ipo_supported(RESULT with_ipo_support)
if(with_ipo_support)
set_target_properties(foo PROPERTIES INTERPROCEDURAL_OPTIMIZATION ON)
endif()

4.6. 使用 CCache

可以把类似的程序(包装一个编译指令)放进 <LANG>_COMPILER_LAUNCHER 里作为 target 的 PROPERTY ,其值由 CMAKE_<LANG>_COMPILER_LAUNCHER 初始化,比如对 C++ 全局使用 CCache :

1
2
3
4
find_program(CCACHE ccache) # 找到 ccache ,把绝对路径放进 `CCACHE` 里
if(CCACHE)
set(CMAKE_CXX_COMPILER_LAUNCHER "${CCACHE}")
endif()