这一篇关注 CMake 的依赖管理,这是最重要的部分:由于 C++没有如 pip,npm 那样统一的包管理(既有历史原因,也是 C/C++的包管理需求太复杂导致的),在使用第三方库时通常需要使用源码编译安装,然后手动管理依赖,涉及到的 CMake 操作非常繁琐。

本文围绕以下内容展开:

  • 项目安装命令
  • 第三方库的使用:
    • 库已经安装到本地,并且支持 CMake:需要导入库的信息
    • 库并不在本地:需要从仓库拉取源码,合并到当前项目中

我们面临很多问题:

  • 不同平台的影响:例如 windows 平台下的安装位置等非常混乱,而 Linux 则比较统一,使得安装和第三方库的导入都非常规范化
  • 不同的语法风格:例如 CMake 早期的语法风格需要如何导入库,Modern CMake 的语法风格需要如何导入库。CMake 为了适应不同的第三方库,提供了许多不同的接口,语法越来越混乱。

项目安装命令

单独的安装命令通常如下(当前位置是项目根目录,而非在 build 子文件夹中)

1
2
3
4
5
6
7
8
(1)
make install

(2)
cmake --build build --target install

(3)
cmake --install build --prefix "../output"

这三个命令可以分成两类:

  • (2)相对于(1)的一般化,CMake会依托具体构建系统来进行,在安装之前会尝试进行编译一遍;
  • (3)完全由CMake自身执行,要求当前项目已经编译完成,因为这个命令不会执行编译过程。

安装主要做了如下事情:

  • 生成配置文件,并复制到安装位置
  • 把可执行文件复制到安装位置
  • 把头文件(通常是整个头文件目录下的所有文件)复制到安装位置
  • 把库文件(静态库,动态库)复制到安装位置
  • 把其它需要的文件也复制到安装位置(安装通常不会复制源文件)

CMake 在安装时,会详细输出每一条安装信息:将具体哪个文件从项目中安装到了系统的具体哪个位置。(再次进行安装时,CMake 会检查对比,对文件进行更新)

其实我们也可以直接进行手动安装:复制可执行文件/头文件/库文件到相应位置,但是这样的做法就脱离了 CMake 体系,无法再被 CMake 项目直接导入。

我们需要关注的是安装位置前缀:

  • 对于命令(1)(2),我们必须在编译时设置好CMAKE_INSTALL_PREFIX变量,Lunix 的默认值为/usr/local, Windows 的默认值为C:/Program Files/${PROJECT_NAME}。在 CMakeLists 中可以使用CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT判断安装位置前缀是否被修改过;
  • 对于命令(3),支持使用--prefix选项,这个选项会覆盖CMAKE_INSTALL_PREFIX,这个命令显然更加灵活,不需要在构建时就设置路径。

对于第三方库,如果从源码进行编译和安装,我们并不需要理会 CMakeLists 的任何细节,整个流程只需要执行几条命令即可,通常需要设置编译模式为 Release 或 Debug,并且设置安装位置

1
2
3
4
5
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=...

cmake --build build --config Release

cmake --build build --config Release --target install

第三方库的使用

假设我们的项目需要使用一个 Abc 库,不清楚这个库的实现细节,这个库已经被成功安装到本地,我们作为库的使用者,如何在新项目中导入它?

配置文件

CMake 在导入阶段支持两种风格的配置文件,对于 Abc 库,调用 find_package 时会按照两种模式查找对应的配置文件

  • (Module 模式)FindAbc.cmake
  • (Config 模式)AbcConfig.cmake 或 abc-config.cmake

两种模式的不同:(推荐使用 Config 模式)

  • Config 模式是 modern cmake 推荐的方式,建议库的作者在发布时同步提供 AbcConfig.cmake;
  • Module 模式是出于历史原因和兼容性考虑:由于某些冷门的库对 modern cmake 支持性较差,CMake 官方会附带常见库的 FindXXX.cmake,或者用户可以自行编写 FindAbc.cmake(必须非常熟悉这个库和 CMake 语法)

find_package 对两种模式的使用顺序:(推荐使用默认策略即可)

  • 默认先尝试 Module 模式,没有找到则尝试 Config 模式;(设置CMAKE_FIND_PACKAGE_PREFER_CONFIG为真,则会调转顺序)
  • 如果使用选项MODULE,则只使用 Module 模式;
  • 如果使用选项CONFIGNO_MODULE,则只使用 Config 模式。

库的位置

我们只需要让 CMake 可以正确找到库的配置文件,尤其是 Config 文件的位置。至于库文件的位置,在安装时会自动记录到 Config 文件中(通常会根据 Config.cmake.in 模板生成 Config.cmake),无需担心通过 Config 文件能否找到真正的库文件和头文件。但是仍然要留意的是,我们不能在安装后随意移动 Config 文件,因为 Config 文件中记录的其它位置通常是相对路径!

find_package 在两种模式下都会面临找不到库的问题(本质上是找不到库的配置文件),通常只会查找系统固定路径或根据环境变量去查找,我们可以手动添加路径:

  • Module 模式:添加路径到CMAKE_MODULE_PATH变量(与include()命令共同使用这些路径)
  • Config 模式:添加路径到Abc_DIR变量(直接设置为环境变量也可以)

例如找到 QT 库所需要的配置文件,如果 QT 直接安装在 windows 的 E 盘根目录,那么配置文件和库文件的位置可能是

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
(配置文件)
E:\Qt\6.3.0\msvc2019_64\lib\cmake\Qt6\Qt6Config.cmake
E:\Qt\6.3.0\mingw_64\lib\cmake\Qt6\Qt6Config.cmake

(动态库)
E:\Qt\6.3.0\msvc2019_64\bin\Qt6Core.dll
E:\Qt\6.3.0\mingw_64\bin\Qt6Core.dll

(静态库 MSVC和MinGW的静态库命名风格还不一样)
E:\Qt\6.3.0\msvc2019_64\lib\Qt6Core.lib
E:\Qt\6.3.0\mingw_64\lib\libQt6Core.a

(头文件)
E:\Qt\6.3.0\msvc2019_64\include\QtCore\qstring.h
E:\Qt\6.3.0\mingw_64\include\QtCore\qstring.h

此时需要指定Qt6_DIR=E:/Qt/6.3.0/mingw_64/lib/cmake/Qt6。(注意系统内可能有多个 Qt,还有多个编译器版本,需要找到正确位置的那个)

对于 Linux,安装位置和配置文件所在位置则会规范得多,QT 通常安装在/usr/目录下,例如

1
2
3
4
5
6
7
8
9
10
11
(配置文件)
/usr/lib/cmake/Qt6/Qt6Config.cmake

(动态库)
/usr/lib/libQt6Core.so

(静态库 MSVC和MinGW的静态库命名风格还不一样)
/usr/lib/libQt6Core.a

(头文件)
/usr/include/qt/QtCore/qstring.h

当然 QT 也可能被安装在其它路径,把/usr/替换为/usr/local//opt/Qt等,需要指定相应的搜索路径。

基本用法

库的导入

以下列出了常见的用法,以 OpenCV 为例:

1
2
3
4
5
6
7
8
find_package(OpenCV)

find_package(OpenCV REQUIRED)

find_package(OpenCV MODULE REQUIRED) # 指定Module模式
find_package(OpenCV CONFIG REQUIRED) # 指定Config模式

find_package(OpenCV QUIET) # 静默查找

这些都代表需要 OpenCV 这个库,REQUIRED选项说明如果找不到会报错直接退出。 如果不使用REQUIRED,代表这个依赖是可选的,此时需要使用OpenCV_FOUND变量来判断是否找到了这个库。

1
2
3
if(NOT OpenCV_FOUND)
# ...
endif()

对于大型的库如 OpenCV,通常会分成很多组件(CMake 要求把所有组件的导入集中在同一个 XXXConfig.cmake 中),默认一次性导入所有组件,也可以按需导入,例如

1
2
find_package(OpenCV REQUIRED COMPONENTS core videoio)
find_package(OpenCV REQUIRED OPTIONAL_COMPONENTS core videoio)

这里也支持两种选择:COMPONENTS——找不到组件就报错;OPTIONAL_COMPONENTS——找不到相关组件不报错,需要通过${OpenCV_core_FOUND}来判断具体结果。(REQUIRED的效果只负责整个库,并不负责具体的组件)

注意 QT 有比较奇特的要求:它在缺省组件时不会全部导入,而是会报错,因此至少需要导入一个组件,例如

1
find_package(Qt6 REQUIRED COMPONENTS Widgets)

库的使用

对于 modern cmake 风格,库的使用例如:

1
2
add_executable(Demo)
target_link_libraries(Demo PRIVATE Qt6::Widgets)

注意通常链接库的一个组件,形如XXX::xxx,例如Qt6::Widgets。(这个组件其实是 CMake 的一个 IMPORTED target)

对于早期的 CMake,并没有使用 target 风格的语法,而是会把信息反馈到某些变量上,例如对 Abc 库的导入

  • Abc_FOUND 记录是否找到
  • Abc_INCLUDE_DIRS 记录库的头文件
  • Abc_LIBRARIES 记录库文件

这里的变量语法并不统一,需要自行去配置文件的注释部分寻找,绝大部分的库都会在配置文件的开头详细写明应当如何使用这个库。使用时例如

1
2
3
4
5
6
7
8
9
find_package(Abc)

if(NOT Abc_FOUND)
# ...
endif()

add_executable(Demo)
target_link_libraries(Demo PRIVATE ${Abc_LIBRARIES})
target_include_directories(Demo PRIVATE ${Abc_INCLUDE_DIRS})

注:大多数现代的库会同时兼容两种写法,但是建议尽可能用 modern cmake 的面向对象的语法。

库的版本要求

有时候我们需要关注导入库的版本信息,以 OpenCV 为例:导入之后可以通过OpenCV_VERSION变量获取库的版本信息, 在导入时也可以直接限制版本:

1
2
find_package(OpenCV 2.0.1 REQUIRED)
find_package(OpenCV 2.0.1 EXACT REQUIRED)

第一个命令要求至少达到 2.0.1 版本,第二个命令要求恰好为 2.0.1 版本,版本号不全时末尾自动补 0,下面几个是等价的

1
2
3
find_package(OpenCV 2 REQUIRED)
find_package(OpenCV 2.0 REQUIRED)
find_package(OpenCV 2.0.0 REQUIRED)

冷门库的导入

如果 Abc 库没有提供 AbcConfig.cmake,并且 CMake 官方也没有提供,那么建议直接去 Github 搜索并下载,然后把 FindAbc.cmake 附带到当前项目的 cmake 文件夹中

1
2
3
set(CMAKE_MODULE_PATH "${CMAKE_CURRENT_LIST_DIR}/cmake;${CMAKE_MODULE_PATH}")
find_package(Abc REQUIRED)
...

如果库非常小,源码的具体内容也很明确,不如直接重新用 CMake 生成编译安装完整走一遍,然后使用 CMake 生成的 AbcConfig.cmake。

配置文件的原理

我们简要介绍一下在 XXXConfig.cmake 配置文件中大概干了什么:

  • 对于 modern cmake:首先会根据相对路径或环境变量来寻找相应的头文件、库文件等,然后生成伪目标(IMPORTED target),配置它的 INTERFACE 属性,从而可以被其它 target 直接调用,同时也会维护例如Abc_FOUND等基本的变量;
  • 对于早期 CMake 的配置文件,则非常简单粗暴,将所有的信息通过Abc_FOUNDAbc_INCLUDE_DIRSAbc_LIBRARIES等变量返回。(语法很不统一,需要查看配置文件自己的注释)

注意:对于通过 find_package 获得的 IMPORTED target,存在作用域的问题,通常会被写在顶级的 CMakeLists,确保对其它 target 都可用,我们也可以修改下面的属性,将 IMPORTED target 提升到全局可见(以 Boost 为例)

1
2
3
4
5
if(Boost_FOUND)
set_target_properties(Boost::boost Boost::program_options Boost::graph
PROPERTIES
IMPORTED_GLOBAL TRUE)
endif()

这个例子取自 MoreModernCMake 的报告。

源码拉取

有的第三方库对 CMake 过于友好,可以直接复制整个源码到当前项目,然后使用add_subdirectory()将其作为子项目参与生成和编译,然后在其它子项目中使用,无论本地项目有没有依赖这个子项目,它都会被生成和编译。

例如 spdlog 支持如下两种风格的使用:

  • 作为外部项目,安装后导入
1
2
find_package(spdlog REQUIRED)
target_link_libraries(demo PUBLIC spdlog::spdlog)
  • 作为子项目,直接一起编译,需要事先把源码拷贝到项目中
1
2
add_subdirectory(spdlog)
target_link_libraries(demo PUBLIC spdlog)

作为子项目时,target 可能没有命名空间的修饰,也可能有,取决于这个库自身的支持情况。

复制源码到项目目录之下的过程,CMake 也可以代劳:

  • 使用ExternalProject模块可以在编译时,直接从 github 仓库或其它 URL 拉取源码,拷贝到本地
  • 使用FetchContent模块可以提前到生成时,进行源码的拉取(建议使用)

注意这里获取源码会导致生成时或编译时显著变慢,并且可能有网络问题,导致下载失败。这里推荐使用FetchContent模块,比ExternalProject模块更易用,用法如下:

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
include(FetchContent)

FetchContent_Declare(
googletest
GIT_REPOSITORY https://github.com/google/googletest.git
GIT_TAG 703bd9caab50b139428cea1aaff9974ebee5742e # release-1.10.0
DOWNLOAD_EXTRACT_TIMESTAMP TRUE
# SOURCE_DIR ${CMAKE_BINARY_DIR}/_deps/googletest
)

FetchContent_Declare(
Catch2
GIT_REPOSITORY https://github.com/catchorg/Catch2.git
GIT_TAG 605a34765aa5d5ecbf476b4598a862ada971b0cc # v3.0.1
DOWNLOAD_EXTRACT_TIMESTAMP TRUE
)

FetchContent_Declare(
mylib
URL myurl/myfile.tar.gz
URL_HASH MD5=...
DOWNLOAD_EXTRACT_TIMESTAMP TRUE
)

FetchContent_MakeAvailable(googletest Catch2 mylib)

主要包括两个函数:

  • FetchContent_Declare:从 GIT 仓库或 URL 获取源码,可以指定版本,可以指定位置
  • FetchContent_MakeAvailable:让它作为当前的子项目,相当于执行了 add_subdirectory,并且做了一些保护措施

注:

  • FetchContent 模块会将依赖下载到${CMAKE_BINARY_DIR}/_deps目录即build/_deps,受到FETCHCONTENT_BASE_DIR变量控制,但不建议更改
  • 如果需要导入多个源码仓库,要求在最后一起使用 FetchContent_MakeAvailable,否则如果这些源码仓库相互依赖,可能有问题
  • FetchContent 得到的源码文件夹,其中的文件目录很可能超长,从而触发 windows 的长度限制,导致删除时不能被放进回收站,只能直接删除,有时还会遇到文件夹权限问题。
  • 这里加入了DOWNLOAD_EXTRACT_TIMESTAMP TRUE选项,缺失会涉及到如何处理解压后文件时间戳的问题,CMake会给一个警告比较烦。

如果我们希望和 find_package 兼容:能在本地找到就执行 find_package,否则执行 FetchContent_Declare,可以使用下面的语法

1
2
3
4
5
6
7
8
9
10
include(FetchContent)

FetchContent_Declare(
googletest
GIT_REPOSITORY https://github.com/google/googletest.git
GIT_TAG 703bd9caab50b139428cea1aaff9974ebee5742e # release-1.10.0
FIND_PACKAGE_ARGS NAMES GTest
DOWNLOAD_EXTRACT_TIMESTAMP TRUE
)
FetchContent_MakeAvailable(googletest)

此时它会首先尝试执行find_package(googletest NAMES GTest),注意这里还设置了 GTest 名称而非 googletest,这会引导 CMake 查找GTestConfig.cmakeFindGTest.cmake

还可以绕过 CMake,从 git submodule 层面进行源码拉取,但是现代 CMake 不建议这么做,可能有其它的麻烦,Modern CMake 建议使用的命令主要是这两个

  • find_package (导入已经安装的,并且支持 CMake 的库)
  • FetchContent (从仓库拉取源码,合并到当前项目中)

有时候我们不需要下载一整个项目,只需要单个文件,通过file命令也可以实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
file(DOWNLOAD
"https://url/to/png/demo.png"
demo.png
HTTPHEADER "User-Agent: Mozilla/5.0"
SHOW_PROGRESS
STATUS status
LOG log)

list(GET status 0 status_code)
list(GET status 1 status_string)

if(NOT status_code EQUAL 0)
message(FATAL_ERROR "error
status_code: ${status_code}
status_string: ${status_string}
log: ${log}")
endif()

这里默认将下载的文件命名为demo.png,并存放在 build 目录下,也可以使用含绝对路径的文件名${PROJECT_SOURCE_DIR}/img/demo.png

这里基于 FetchContent 模块,还有一个非官方的封装工具,叫做CPM,包括几个 cmake 脚本需要被复制到项目中(CPM.cmake、get_cpm.cmake 和 testing.cmake),说是进一步的封装,使得包管理更容易使用,但是我并没有看出来简化了多少。