cmake从入门到完全放弃

前言

C++ 的构建系统就如同众多的C++ 编译器一样繁杂,不同构建系统的构建文件语法、操作方式都大相径庭,细节问题繁多。其中以生成不同平台的构建系统文件的跨平台特性为设计目标的CMake尤甚。如何一统各个构建系统、打通各个编译器。是CMake作为一个新构建系统首先要做的工作。本文是作者几天以来使用CMake构建软件时的笔记,兼作对C++ 新手的分享。

CMake构建一般流程

1
2
3
4
5
mkdir build && cd build
CC=clang CXX=clang++ cmake -G"Unix Makefiles" .. #选择编译器和生成器(generator),cmake使用环境变量中的CC和CXX,生成器列表可以用cmake --help查看
cmake -L #展示该项目可配置选项
cmake -D BUILD_EXAMPLES=OFF #设置一些必要的选项
cmake --build . #开始编译

第一次运行时CMake扫描源码目录(Source Directory)中的CMakeLists.txt读取项目配置,并以此在(Build Directory)生成CMakeCache.txt。CMakeCache.txt包含一些配置的缓存信息,用户可以使用程序cmake-gui编辑选项。这一个阶段叫配置(Configure)。

双击CMakeCache.txt就能打开cmake-gui(archlinux)

其中有一些通用的选项

  • CMAKE_BUILD_TYPE: 从Release、RelWithDebInfo、Debug等选择
  • CMAKE_INSTALL_PREFIX: 此工程的安装路径,对于unix默认是/usr/local。
  • BUILD_SHARED_LIBS: 设置ON或OFF控制生成共享库的默认行为

在构建(build)阶段可以手动使用目标构建系统(生成器)构建,也可以使用cmake帮忙调用目标构建系统(生成器)构建。

从一个CMakeLists.txt说起…

CMakeLists.txt语法由一系列类似函数调用的语句组成,CMake内置的函数叫命令(Command)。以下是一个非常简单的CMakeLists.txt。

1
2
3
4
5
6
7
project(Myproject)
add_library(MYLIB simple_lib.cpp simple_lib.hpp)
target_compile_definitions(MYLIB PUBLIC MYLIB_PUBLIC)
target_compile_definitions(MYLIB PRIVATE MYLIB_PRIVATE)
target_include_directories(MYLIB PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}")
add_executable(main simple_example.cpp)
target_link_libraries(main PUBLIC MYLIB)

CMakeLists.txt的解释是顺序执行的,首先我们定义了一个工程(project),名字叫Myproject,project()这个命令还能选择不同的编程语言和项目的一些元信息。然后我们使用add_library()添加了一个库目标,名字叫MYLIB、包含simple_lib.cpp simple_lib.hpp文件。然后使用target_compile_definitions()朝MYLIB目标添加了两个宏定义,其中MYLIB_PUBLIC是公共的(PUBLIC),我解释为“导出”的,会传播到之后依赖此目标的目标中(即main)。MYLIB_PRIVATE是私有的(PRIVATE)。然后我们使用target_include_directories()朝MYLIB目标添加了一个include目录。最终我们使用add_executable()添加可执行文件目标main,包含simple_example.cpp。然后将MYLIB目标公共链接到main。

你可以发现CMake的基础概念是目标。构建系统的核心也在于此:管理目标的构建、不同目标的依赖问题。在CMake中的目标/目录等等还附带有属性(property)target_compile_definitions等命令就是在操作目标的属性。最终由CMake分析生成对应构建系统/生成器的描述文件并构建。

CMake的图灵完备/变量、函数、控制流、计算

当然CMakeLists这样的DSL怎么能不去讨论它的图灵完备性呢,让我们借此机会来看看CMake中的计算。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function(fibonacci var n)
if(n LESS_EQUAL 2)
set("${var}" "1" PARENT_SCOPE)
else()
math(EXPR n1 "${n} - 1")
math(EXPR n2 "${n} - 2")
fibonacci(A "${n1}")
fibonacci(B "${n2}")
math(EXPR ret "${A} + ${B}")
set("${var}" "${ret}" PARENT_SCOPE)
endif()
endfunction()
fibonacci(A 10)
message("fib(10):${A}")
fibonacci(A 20)
message("fib(20):${A}")

cmake跟shell脚本很像,所有变量都是字符串类型,因此你可以看到这份代码的扭曲程度。这种意义上感觉CMake更像是汇编,只不过寄存器是无限的而且里面只能存字符串

  • 计算
    math(EXPR <variable> "<expression>")
  • 控制流
    if
1
2
3
4
5
6
7
if(<condition>)
<commands>
elseif(<condition>) # optional block, can be repeated
<commands>
else() # optional block
<commands>
endif()

foreach

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

while

1
2
3
while(<condition>)
<commands>
endwhile()
  • 变量
    set(<variable> <value>... [PARENT_SCOPE])

其中function()新增了一个作用域(SCOPE),set(... PARENT_SCOPE)设置父作用域。本代码中用其起到返回值的作用。

生成表达式(Generator Expression)

生成表达式是一系列需要计算的字符串表达式。有功能类似运算符的、也有引用目标属性的等等。本文着重讲解目标依赖表达式(Target-Dependent Expressions),更多请翻阅官方手册。

目标依赖表达式能够获取目标属性、目标include目录等字符串。当然你可能会有疑惑,CMakeLists的执行过程中不就是在生成这些字符串吗。如果能够引用这些字符串,那么岂不是还增加了一层变量的依赖?这不是很难解决吗?所以我们得先理解CMake的几个阶段。

  • 配置(configuration)– 配置项目,将目标的构建信息全部计算出来
  • 生成(generation)- 生成对应构建系统/生成器的构建文件
  • 构建(build) – 调用构建系统构建项目
  • 安装(install)- 安装生成出的目标代码

如C++的宏只能在预编译阶段处理一样,CMake的生成表达式只能在生成之后计算出对应的值。当然对于某些生成表达式,CMake允许在配置阶段计算,但能使用的命令有限制。

你可能会发现我们在第一节只解决了链接的依赖问题,解决include的依赖问题就需要用到生成表达式了。

这里我们以引用githubuser0xFFFF/Qt-Advanced-Docking-System(下文简称qtads)为例子。代码地址:OrbitZore/cmake-quickstart

qtads是一个灵感来源于现代IDE的qt的Dock窗口布局库。

去除掉引用qt库的部分,CMakeLists.txt核心如下

1
2
3
4
5
6
set(BUILD_EXAMPLES OFF CACHE INTERNAL "")
add_subdirectory("Qt-Advanced-Docking-System")

set(QtADS_target qt${QT_VERSION_MAJOR}advanceddocking)
target_include_directories(generator-expression PRIVATE "$<TARGET_PROPERTY:${QtADS_target},INTERFACE_INCLUDE_DIRECTORIES>")
target_link_libraries(generator-expression PRIVATE ${QtADS_target})

首先我们关闭qtads的样例构建(qads包含BUILD_EXAMPLES选项(option),需要新建BUILD_EXAMPLES CACHE覆盖其行为。INTERNAL表示无法被用户指定),然后使用add_subdirectory()尝试构建qtads。查阅qtads的CMakeLists.txt我们可以发现其库目标名为qt${QT_VERSION_MAJOR}advanceddocking

1
2
3
4
5
6
7
//files: Qt-Advanced-Docking-System/examples/hideshow/CMakeLists.txt
...
cmake_minimum_required(VERSION 3.5)
project(ads_example_hideshow VERSION ${VERSION_SHORT})
target_include_directories(HideShowExample PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/../../src")
target_link_libraries(HideShowExample PRIVATE qt${QT_VERSION_MAJOR}advanceddocking)
...

然后就可以使用$<TARGET_PROPERTY:${QtADS_target},INTERFACE_INCLUDE_DIRECTORIES>来引用Qt-Advanced-Docking-System的公开(Public)include目录了。

$<TARGET_PROPERTY:tgt,prop>

找包(Finding Packages)

find_package()应该是CMake最晦涩难懂的命令了,其查找模式、查找路径的复杂程度比C++的默认include目录更甚。首先我们看到的是这样简单一行的命令

1
find_package(MyPackage 1.2)

然后find_package()命令会顺序尝试三个模式

  • 模块模式(Module mode)
  • 配置模式(Config mode)
  • FetchContent重定向模式(FetchContent redirection mode)

模块模式会顺序尝试CMAKE_MODULE_PATH环境变量、CMake安装自带路径中查找名为FindMyPackage.cmake的文件。

配置模式会在几个地方查找名为mypackage-config.cmake或者MyPackageConfig.cmake的文件,包括MyPackage_DIR(如果该变量存在)。还会查找mypackage-config-version.cmake或者MyPackageConfigVersion.cmake来决定版本的选择。如果在命令中指定了NAMES还会顺序按着NAME作为MyPackage找。

一般安装了某个库就找到它的xxxConfig.cmake然后把路径设置为xx_DIR环境变量吧(

FetchContent重定向模式在下节讲述。

还记得qt项目都会有的find_packages()命令吗

1
2
3
find_package(QT NAMES Qt6 Qt5 COMPONENTS Widgets REQUIRED)
find_package(Qt${QT_VERSION_MAJOR} COMPONENTS Widgets REQUIRED)
target_link_libraries(xxx PRIVATE Qt${QT_VERSION_MAJOR}::Widgets)

对于qt5来说在archlinux中选择的是配置模式,在/usr/lib/cmake/Qt5下存放着Qt5Config.cmake Qt5ConfigVersion.cmake Qt5ModuleLocation.cmake。在windows中一般是配置模式,比如(Qt5.12.12版本)在C:/Qt/Qt5.12.12/5.12.12/msvc2017_64/lib/cmake/Qt5下存放着同样的东西。

现代CMake——FetchContent命令

只需要把上述的add_subdirectory()命令换成FetchContent系列即可。代码地址:OrbitZore/cmake-quickstart

1
2
3
4
5
6
7
8
include(FetchContent)
FetchContent_Declare(
qtads
GIT_REPOSITORY https://github.com/githubuser0xFFFF/Qt-Advanced-Docking-System
GIT_TAG 65600a4dcd072fd2773b661823816db6392c34eb # release-4.1.1
)
SET(BUILD_EXAMPLES OFF CACHE INTERNAL "")
FetchContent_MakeAvailable(qtads)

这样无需thridparty等方式就可以网络引用包了。

官方文档中还描述了其他几种来源方式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
FetchContent_Declare(
googletest
GIT_REPOSITORY https://github.com/google/googletest.git
GIT_TAG 703bd9caab50b139428cea1aaff9974ebee5742e # release-1.10.0
)

FetchContent_Declare(
myCompanyIcons
URL https://intranet.mycompany.com/assets/iconset_1.12.tar.gz
URL_HASH MD5=5588a7b18261c20068beabfb4f530b87 #文件MD5
)

FetchContent_Declare(
myCompanyCertificates
SVN_REPOSITORY svn+ssh://svn.mycompany.com/srv/svn/trunk/certs
SVN_REVISION -r12345
)

参考

More Modern CMake

CMake应用:生成器表达式

cmake-properties(7)

cmake-generator-expressions.7.html