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

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

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

一、集成第三方库

在项目中集成第三方库是一种非常常见的需求,CMake 提供了两种方式来集成第三方库。

第一种:直接集成第三方库的源码。

说到集成第三方库的源码,我们第一时间想到的可能就是将其源码直接拷贝到项目目录中,然后提交到 git 仓库,更加高级一点可能会使用 git submodule 的方式。但我以为这两种方式都不够优雅,无法很好的管理、更新依赖库,特别是在项目的依赖库的比较多时。

CMake 提供了两个模块(两种方式)来集成第三方库的源码:

  • FetchContent
    该模块支持在 CMake 生成项目时就下载第三方库,还会自动将第三方库添加到项目中,不需要手动调用 add_subdirectory。
  • ExternalProject
    该模板支持在构建(编译)项目时下载第三方库。显然 ExternalProject 的下载时机要晚于 FetchContent 。

第二种:使用预编译好的第三方库。

这种方式需要先单独编译安装第三方库,然后使用 find_package 查找该库,最后设置目标的相关属性,如包含目录、依赖库等。

1.1 FetchContent

FetchContent 的使用大致分为两个步骤。

步骤一: 使用 FetchContent_Declare 命令记录要获取的内容,可以多次调用 FetchContent_Declare 命令来记录获取多个内容。

FetchContent_Declare 的定义如下:

1
2
3
4
5
6
7
8
FetchContent_Declare(
<name>
<contentOptions>...
[EXCLUDE_FROM_ALL]
[SYSTEM]
[OVERRIDE_FIND_PACKAGE |
FIND_PACKAGE_ARGS args...]
)

其中,<name> 可以是任何不带空格的字符串,并且该名称不区分大小写,但通常我们仅使用字母、数字和下划线。

下面示例分别演示了如何从 Git 仓库、https链接、SVN仓库获取内容:

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
)

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

步骤二: 调用 FetchContent_MakeAvailable 命令开始获取上面声明的内容。

如:

1
FetchContent_MakeAvailable(googletest myCompanyIcons myCompanyCertificates)

1.1.1 结果

如果 FetchContent 执行成功,下面几个变量会设置:

  • <lowercaseName>_POPULATED
    始终被设置为 TRUE
  • <lowercaseName>_SOURCE_DIR
    目标内容的源码目录
  • <lowercaseName>_BINARY_DIR
    目标内容的构建目录

1.1.2 使用代理

限于国内的网络环境,在获取内容时,很可能出现下载失败的情况,此时可以尝试使用 http 和 https 代理来解决该问题。

通过设置相应的环境变量即可设置 http(s) 代理,如:

1
2
set(ENV{http_proxy} "http://127.0.0.1:7890")
set(ENV{https_proxy} "http://127.0.0.1:7890")

1.2 find_package

find_package 用于查找已经安装到本机的包,将查找结果存储在 <PackageName>_FOUND 变量中(查找到包,值为 1,否则 为 0),包的安装路径存储在 <PackageName>_DIR 变量中,通常还会定义一些变量来指明包的版本、头文件目录的路径、.lib 或 .a 文件的路径等,这些变量名称的格式会根据查找方式的不同、包的不同而不同。

find_package 使用起来比较简单,通常我们只需要使用它的基础定义:

1
find_package(<PackageName> [<version>] [REQUIRED] [COMPONENTS <components>...])
  • <PackageName>
    指定包的名称,是唯一的必选参数。
  • <version>
    指定需要查找包的版本,major[.minor[.patch[.tweak]]],支持多种形式的版本,如 3、3.1、3.1.2 等,可以指定匹配大版本还是小版本匹配等,也可以通过指定 EXACT 选项来要求版本完全一致,当然也可以完全省略版本约束。
  • REQUIRED
    参数用于指定该包是必须找到,如果没有找到则停止执行该 CMake 脚本。

find_package 的完整定义可以见:full-signature

下面示例用于查找大版本为 3 的 OpenCV 包:

1
2
3
4
5
find_package(OpenCV 3 REQUIRED)

message(STATUS "OpenCV_DIR = ${OpenCV_DIR}")
message(STATUS "OpenCV_INCLUDE_DIRS = ${OpenCV_INCLUDE_DIRS}")
message(STATUS "OpenCV_LIBS = ${OpenCV_LIBS}")

不同的查找模式

find_package 有两种查找包的方式,优先使用 Module 模式,如果 Module 模式没有查找到,再使用 Config 模式查找。

Module 模式

find_package 使用 Module 模式查找包就是查找 Find<PackageName>.cmake 文件的过程,会尝试在下面位置中查找该文件,优先级从高到低依次为:

  • CMAKE_PREFIX_PATH
    该变量是以分号分隔的列表,默认为空,由用户设置;也可以定义环境变量 CMAKE_PREFIX_PATH,环境变量 $ENV{CMAKE_PREFIX_PATH} 定义的列表会附加到 ${CMAKE_PREFIX_PATH} 变量的后面。
  • CMAKE_MODULE_PATH
    该变量也是以分号分隔的列表,默认为空,由用户设置。

Find<PackageName>.cmake 文件从何而来?

如果你是库的开发者,你是不需要提供 Find<PackageName>.cmake 文件的,你只需要按照文章 Modern CMake 简明教程(中) “安装导出依赖项”章节介绍的那样,在安装时生成 <PackageName>Config.cmake(或 <lowercasePackageName>-config.cmake 文件)即可,该文件可以用于 find_package 的 Config 模式查找。

对于那些没有按照规范提供上述 Config 文件的库,才需要使用者来编写 Find<PackageName>.cmake,辅助查找包的安装路径。

Config 模式

find_package 指令在大多情况下都是通过 Config 模式来查找到包的具体位置的。

find_package 使用 Config 模式查找包就是查找 <PackageName>Config.cmake<lowercasePackageName>-config.cmake 文件的过程。

在 Config 模式下,<PackageName> 可以通过 NAMES 参数来指定多个需要匹配查找的包名(PackageName),例如下面查找 Qt5 或 Qt6 的方式:

1
find_package(QT NAMES Qt6 Qt5 REQUIRED COMPONENTS Widgets)

而且在该模式下,find_package 查找 config 文件的步骤也非常复杂,尤其是可以根据不同开关来查找不同的路径,下面介绍几个常用的查找 config 文件的位置(完整的见官方文档),按优先级从高到低依次为:

  1. <PackageName>_DIR 变量或环境变量所指定的目录,默认为空。
  2. CMAKE_PREFIX_PATH、CMAKE_FRAMEWORK_PATH、CMAKE_APPBUNDLE_PATH 变量或环境变量所指定的目录,该变量是以分号分隔的列表,默认为空。
  3. PATH 环境变量所指定的目录,该变量是以分号分隔的列表,默认为系统 PATH 环境变量的值。如果该变量中的路径以 bin 或 sbin 结尾,则自动回退到上一级目录进行查找。

与 Module 模式不同的是,在上述几个位置中,除了第 1 个指定的目录是“根目录”,CMake 只会在该目录的根目录下查找 <PackageName>Config.cmake<lowercasePackageName>-config.cmake 文件,不会进入其子目录中查找,如设置 OpenCV_DIR 为 /home/jack,CMake 只会查验如下文件:

1
2
/home/jack/OpenCVConfig.cmake
/home/jack/opencv-config.cmake

而第 2、3 所指定的目录都是“路径前缀”,CMake 不仅会在根目录下查找 <PackageName>Config.cmake<lowercasePackageName>-config.cmake 文件,还会进入其子目录内查找。当然 CMake 不会无脑的遍历所有子目录,而且只在特定的子目录内进行查找,不同系统环境下所查找的子目录也不同,具体如下表所示。

查找路径 系统环境
<prefix>/(cmake|CMake)/ Windows
<prefix>/<name>*/ Windows
<prefix>/<name>*/(cmake|CMake)/ Windows
<prefix>/<name>*/(cmake|CMake)/<name>*/ Windows
<prefix>/(lib/<arch>|lib*|share)/cmake/<name>*/ Uninx
<prefix>/(lib/<arch>|lib*|share)/<name>*/ Uninx
<prefix>/(lib/<arch>|lib*|share)/<name>*/(cmake|CMake)/ Uninx
<prefix>/<name>*/(lib/<arch>|lib*|share)/cmake/<name>*/ Windows、Uninx
<prefix>/<name>*/(lib/<arch>|lib*|share)/<name>*/ Windows、Uninx
<prefix>/<name>*/(lib/<arch>|lib*|share)/<name>*/(cmake|CMake)/ Windows、Uninx

在上表中,<prefix>就是第 2、3 项所指定的路径前缀;<name> 为包名,不区分大小写,<name>* 的意思是包名后面还可以接一些字符,如 OpenCV-3.0。

结果获取

无论是哪种查找模式,我们都需要获取查找的结果。目前我能确定的是,这两种查找模式都会定义 <PackageName>_FOUND<PackageName>_DIR 变量,对于不同的库还会定义不同的变量,如何知道他们到底定义了哪些变量呢?

我有一个粗暴但好用的方法:在调试时,遍历当前 CMake 中的所有变量。

下面 dump_all_variables 函数会输出当前 CMake 的所有变量:

1
2
3
4
5
6
function(dump_all_variables)
get_cmake_property(_VARS VARIABLES)
foreach (_V ${_VARS})
message(STATUS ">>>>>> ${_V} = ${${_V}}")
endforeach()
endfunction()

使用:

1
dump_all_variables()

1.3 集成 Qt

在项目中集成 Qt 库需要先使用 find_package 查找 Qt 的安装位置。对于 Qt4, CMake 使用 Module 模式进行查找(FindQt4.cmake 由 CMake 提供),而 对于 Qt5、Qt6,则是使用 Config 模式进行查找,相应的 config 文件位于类似下面的目录中 D:\Qt\5.15.2\msvc2019\lib\cmake

具体从哪些位置查找 Qt,参见上面的“find_package”章节。

示例:

1
2
3
4
5
6
7
8
9
10
11
find_package(Qt6 COMPONENTS Widgets DBus REQUIRED)
add_executable(publisher publisher.cpp)
target_link_libraries(publisher Qt6::Widgets Qt6::DBus)

find_package(Qt5 COMPONENTS Gui DBus REQUIRED)
add_executable(subscriber1 subscriber1.cpp)
target_link_libraries(subscriber1 Qt5::Gui Qt5::DBus)

find_package(Qt4 REQUIRED)
add_executable(subscriber2 subscriber2.cpp)
target_link_libraries(subscriber2 Qt4::QtGui Qt4::QtDBus)

众所周知,编译 Qt 代码需要依赖 Qt 提供的一些工具来生成相关的 C++ 代码,如:

  • moc
    元对象编译器,将 Qt 扩展的 C++ 语法(如 Q_OBJECT)转换成标准 C++ 语法。
  • rcc
    把 .qrc 资源文件编译成标准 C++ 代码。
  • uic
    把 .ui 文件编译成标准 C++ 代码。

在 CMake 中要使用这些工具并不复杂,只需要提前开启相关特性,CMake 就会自动调用相关工具。

1
2
3
4
5
set(CMAKE_AUTOMOC ON)

set(CMAKE_AUTOUIC ON)

set(CMAKE_AUTORCC ON)

下面是一个简单的 CMake Qt 项目示例,该示例仅使用了 Qt 的 QWidget 模块。

源文件结构如下:

1
2
3
4
5
CMakeLists.txt
main.cpp
mainwindow.cpp
mainwindow.h
mainwindow.ui

CMakeLists.txt 内容如下:

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
cmake_minimum_required(VERSION 3.16)

project(hello_qt)

set(CMAKE_AUTOUIC ON)
set(CMAKE_AUTOMOC ON)
set(CMAKE_AUTORCC ON)

find_package(Qt5 REQUIRED COMPONENTS Widgets)

add_executable(hello_qt
main.cpp
mainwindow.cpp
mainwindow.h
mainwindow.ui
)

target_link_libraries(hello_qt PRIVATE Qt5::Widgets)

if(MSVC)
target_compile_options(hello_qt PRIVATE /execution-charset:utf-8)

set_target_properties(hello_qt PROPERTIES
WIN32_EXECUTABLE TRUE
VS_DEBUGGER_ENVIRONMENT "PATH=${Qt5_DIR}/../../../bin;%PATH%"
)
endif()

install(TARGETS hello_qt)

在上面示例中,设置了可执行字符集为 utf-8,这种方式可以防止 Qt 在 MSVC 环境下出现中文乱码,详见之前的文章 拨开字符编码的迷雾(2)--编译器处理文件编码

设置 WIN32_EXECUTABLE 属性是为了让链接器使用 /SUBSYSTEM:WINDOWS 子系统,如下图所示:

而设置 VS_DEBUGGER_ENVIRONMENT 属性是为了设置 Visual Studio 的调试环境(如下图所示),确保在调试时能找到 Qt 的相关 dll 文件。

在之前的 Modern CMake 简明教程(上) 中已经介绍了目标属性的设置。

二、MSVC

2.1 设置 MSVC 运行库

MSVC 的运行库有 MD / MDd 和 MT / MTd 之分,下图是 Visual Studio 中设置运行库的界面。

CMake 针对 MSVC 环境默认使用的是 MD / MDd 运行库,通过下面的方式可以将运行库设置为 MT / MTd:

1
2
# 需要 CMake 3.15 及以上版本
set(CMAKE_MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>")

2.2 预编译头文件

CMake 在 3.16 版本中提供了 target_precompile_headers 指令来添加预编译头文件,语法如下:

1
2
3
target_precompile_headers(<target>
<INTERFACE|PUBLIC|PRIVATE> [header1...]
[<INTERFACE|PUBLIC|PRIVATE> [header2...] ...])

在 3.16 版本之前,需要支持预编译头,可以参考网络上的解决方案:

定义 USE_MSVC_PCH 宏:

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
macro(USE_MSVC_PCH PCH_TARGET PCH_HEADER_FILE PCH_SOURCE_FILE)
if(MSVC)
# 获取预编译头文件的文件名,通常是stdafx
get_filename_component(PCH_NAME ${PCH_HEADER_FILE} NAME_WE)

# 生成预编译文件的路径
if(CMAKE_CONFIGURATION_TYPES)
# 如果有配置选项(Debug/Release),路径添加以及配置选项
SET(PCH_DIR "${CMAKE_CURRENT_BINARY_DIR}/PCH/${CMAKE_CFG_INTDIR}")
else()
SET(PCH_DIR "${CMAKE_CURRENT_BINARY_DIR}/PCH")
endif()

# 创建预编译文件的路径
file(MAKE_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}/PCH)

# 设置项目属性,使用预编译头文件
set_target_properties(${PCH_TARGET} PROPERTIES COMPILE_FLAGS
"/Yu${PCH_HEADER_FILE} /FI${PCH_HEADER_FILE} /Fp${PCH_DIR}/${PCH_NAME}.pch")

# 预编译源文件(stdafx.cpp)设置属性,创建预编译文件
set_source_files_properties(${PCH_SOURCE_FILE} PROPERTIES COMPILE_FLAGS
"/Yc${PCH_HEADER_FILE}")

# 把预编译文件寄到清除列表
set_directory_properties(PROPERTIES
ADDITIONAL_MAKE_CLEAN_FILES ${PCH_DIR}/${PCH_NAME}.pch)
endif()
endmacro()

然后使用该宏添加预编译头文件:

1
USE_MSVC_PCH(test_app stdafx.h stdafx.cpp)

三、相关资料

https://github.com/ttroy50/cmake-examples

https://github.com/Akagi201/learning-cmake

https://github.com/KDE/extra-cmake-modules

https://gist.github.com/mbinna/c61dbb39bca0e4fb7d1f73b0d66a4fd1

https://hsf-training.github.io/hsf-training-cmake-webpage/aio/index.html

https://github.com/rpavlik/cmake-modules

https://github.com/onqtam/awesome-cmake

https://github.com/Lectem/cpp-boilerplate

https://github.com/CLIUtils/modern_cmake

https://github.com/dev-cafe/cmake-cookbook

https://github.com/BrightXiaoHan/CMakeTutorial

https://cliutils.gitlab.io/modern-cmake/

https://cgold.readthedocs.io/en/latest/

https://www.siliceum.com/en/blog/post/cmake_01_cmake-basics