Cpp构建和编译笔记——8.CMake依赖管理
这一篇关注 CMake 的依赖管理,这是最重要的部分:由于 C++没有如 pip,npm 那样统一的包管理(既有历史原因,也是 C/C++的包管理需求太复杂导致的),在使用第三方库时通常需要使用源码编译安装,然后手动管理依赖,涉及到的 CMake 操作非常繁琐。
本文围绕以下内容展开:
- 项目安装命令
- 第三方库的使用:
- 库已经安装到本地,并且支持 CMake:需要导入库的信息
- 库并不在本地:需要从仓库拉取源码,合并到当前项目中
我们面临很多问题:
- 不同平台的影响:例如 Windows 平台下的安装位置等非常混乱,而 Linux 则比较统一,使得安装和第三方库的导入都非常规范化
- 不同的语法风格:例如 CMake 早期的语法风格需要如何导入库,Modern CMake 的语法风格需要如何导入库。CMake 为了适应不同的第三方库,提供了许多不同的接口,语法越来越混乱。
项目安装命令
单独的安装命令通常如下(当前位置是项目根目录,而非在 build 子文件夹中)
1 | (1) |
这三个命令可以分成两类:
- (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
5cmake -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 模式; - 如果使用选项
CONFIG
或NO_MODULE
,则只使用 Config 模式。
库的位置
我们只需要让 CMake 可以正确找到库的配置文件,尤其是 Config 文件的位置。至于库文件的位置,在安装时会自动记录到 Config 文件中(通常会根据 Config.cmake.in 模板生成 Config.cmake),无需担心通过 Config 文件能否找到真正的库文件和头文件。但是仍然要留意的是,我们不能在安装后随意移动 Config 文件,因为 Config 文件中记录的其它位置通常是相对路径!
find_package
命令在两种模式下都会面临找不到库的问题(本质上是找不到库的配置文件),查找配置文件的具体细节很复杂,可以查看官方文档,下面是最主要的步骤。
对于Module模式,首先查找CMAKE_MODULE_PATH
变量中的路径,然后就是CMake内置的一些固定路径,在其中寻找Find<package>.cmake
。
对于Config模式,首先CMake会通过一些环境变量获取一组路径前缀,例如
1
2
3
4
5<package>_DIR
CMAKE_PREFIX_PATH
CMAKE_FRAMEWORK_PATH
CMAKE_APPBUNDLE_PATH
PATH
这里对于PATH
的处理很特殊:如果其中的路径以bin
或sbin
结尾,自动回退到上一级目录。
然后在路径前缀的基础上查找一些子目录(通常都是CMake自己生成的),例如
1
2
3
4<prefix>/(lib/<arch>|lib|share)/cmake/<name>*/
<prefix>/(lib/<arch>|lib|share)/<name>*/
<prefix>/(lib/<arch>|lib|share)/<name>*/(cmake|CMake)/
...
cmake找到这些子目录后,会开始依次找<package>Config.cmake
。
当我们使用find_package
命令报错找不到路径时,手动添加路径即可:
- Module
模式:添加路径到
CMAKE_MODULE_PATH
变量(与include()
命令共同使用这些路径) - Config
模式:添加路径到
Abc_DIR
变量(直接设置为环境变量也可以)
例如找到 QT 库所需要的配置文件,如果 QT 直接安装在 Windows 的 E 盘根目录,那么配置文件和库文件的位置可能是
1 | (配置文件) |
此时需要指定Qt6_DIR=E:/Qt/6.3.0/mingw_64/lib/cmake/Qt6
。(注意系统内可能有多个
Qt,还有多个编译器版本,需要找到正确位置的那个)
对于 Linux,安装位置和配置文件所在位置则会规范得多,QT
通常安装在/usr/
目录下,例如
1 | (配置文件) |
当然 QT
也可能被安装在其它路径,把/usr/
替换为/usr/local/
或/opt/Qt
等,需要指定相应的搜索路径。
基本用法
库的导入
以下列出了常见的用法,以 OpenCV 为例:
1 | find_package(OpenCV) |
这些都代表需要 OpenCV
这个库,REQUIRED
选项说明如果找不到会报错直接退出。
如果不使用REQUIRED
,代表这个依赖是可选的,此时需要使用OpenCV_FOUND
变量来判断是否找到了这个库。
1 | if(NOT OpenCV_FOUND) |
对于大型的库如 OpenCV,通常会分成很多组件(CMake 要求把所有组件的导入集中在同一个 XXXConfig.cmake 中),默认一次性导入所有组件,也可以按需导入,例如
1 | find_package(OpenCV REQUIRED COMPONENTS core videoio) |
这里也支持两种选择:COMPONENTS
——找不到组件就报错;OPTIONAL_COMPONENTS
——找不到相关组件不报错,需要通过${OpenCV_core_FOUND}
来判断具体结果。(REQUIRED
的效果只负责整个库,并不负责具体的组件)
注意 QT 有比较奇特的要求:它在缺省组件时不会全部导入,而是会报错,因此至少需要导入一个组件,例如
1 | find_package(Qt6 REQUIRED COMPONENTS Widgets) |
库的使用
对于 modern cmake 风格,库的使用例如:
1 | add_executable(Demo) |
注意通常链接库的一个组件,形如XXX::xxx
,例如Qt6::Widgets
。(这个组件其实是
CMake 的一个 IMPORTED target)
对于早期的 CMake,并没有使用 target 风格的语法,而是会把信息反馈到某些变量上,例如对 Abc 库的导入
Abc_FOUND
记录是否找到Abc_INCLUDE_DIRS
记录库的头文件Abc_LIBRARIES
记录库文件
这里的变量语法并不统一,需要自行去配置文件的注释部分寻找,绝大部分的库都会在配置文件的开头详细写明应当如何使用这个库。使用时例如
1 | find_package(Abc) |
注:大多数现代的库会同时兼容两种写法,但是建议尽可能用 modern cmake 的面向对象的语法。
库的版本要求
有时候我们需要关注导入库的版本信息,以 OpenCV
为例:导入之后可以通过OpenCV_VERSION
变量获取库的版本信息,
在导入时也可以直接限制版本:
1 | find_package(OpenCV 2.0.1 REQUIRED) |
第一个命令要求至少达到 2.0.1 版本,第二个命令要求恰好为 2.0.1 版本,版本号不全时末尾自动补 0,下面几个是等价的
1 | find_package(OpenCV 2 REQUIRED) |
冷门库的导入
如果 Abc 库没有提供 AbcConfig.cmake,并且 CMake 官方也没有提供,那么建议直接去 Github 搜索并下载,然后把 FindAbc.cmake 附带到当前项目的 cmake 文件夹中
1 | set(CMAKE_MODULE_PATH "${CMAKE_CURRENT_LIST_DIR}/cmake;${CMAKE_MODULE_PATH}") |
如果库非常小,源码的具体内容也很明确,不如直接重新用 CMake 生成编译安装完整走一遍,然后使用 CMake 生成的 AbcConfig.cmake。
配置文件的原理
我们简要介绍一下在 XXXConfig.cmake 配置文件中大概干了什么:
- 对于 modern
cmake:首先会根据相对路径或环境变量来寻找相应的头文件、库文件等,然后生成伪目标(IMPORTED
target),配置它的 INTERFACE 属性,从而可以被其它 target
直接调用,同时也会维护例如
Abc_FOUND
等基本的变量; - 对于早期 CMake
的配置文件,则非常简单粗暴,将所有的信息通过
Abc_FOUND
、Abc_INCLUDE_DIRS
、Abc_LIBRARIES
等变量返回。(语法很不统一,需要查看配置文件自己的注释)
注意:对于通过 find_package 获得的 IMPORTED target,存在作用域的问题,通常会被写在顶级的 CMakeLists,确保对其它 target 都可用,我们也可以修改下面的属性,将 IMPORTED target 提升到全局可见(以 Boost 为例)
1 | if(Boost_FOUND) |
这个例子取自 MoreModernCMake 的报告。
源码拉取
有的第三方库对 CMake
过于友好,可以直接复制整个源码到当前项目,然后使用add_subdirectory()
将其作为子项目参与生成和编译,然后在其它子项目中使用,无论本地项目有没有依赖这个子项目,它都会被生成和编译。
例如 spdlog 支持如下两种风格的使用:
- 作为外部项目,安装后导入
1 | find_package(spdlog REQUIRED) |
- 作为子项目,直接一起编译,需要事先把源码拷贝到项目中
1 | add_subdirectory(spdlog) |
作为子项目时,target 可能没有命名空间的修饰,也可能有,取决于这个库自身的支持情况。
复制源码到项目目录之下的过程,CMake 也可以代劳:
- 使用
ExternalProject
模块可以在编译时,直接从 github 仓库或其它 URL 拉取源码,拷贝到本地 - 使用
FetchContent
模块可以提前到生成时,进行源码的拉取(建议使用)
注意这里获取源码会导致生成时或编译时显著变慢,并且可能有网络问题,导致下载失败。这里推荐使用FetchContent
模块,比ExternalProject
模块更易用,用法如下:
1 | include(FetchContent) |
主要包括两个函数:
- 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 | include(FetchContent) |
此时它会首先尝试执行find_package(googletest NAMES GTest)
,注意这里还设置了
GTest 名称而非 googletest,这会引导 CMake
查找GTestConfig.cmake
或FindGTest.cmake
。
还可以绕过 CMake,从 git submodule 层面进行源码拉取,但是现代 CMake 不建议这么做,可能有其它的麻烦,Modern CMake 建议使用的命令主要是这两个
- find_package (导入已经安装的,并且支持 CMake 的库)
- FetchContent (从仓库拉取源码,合并到当前项目中)
有时候我们不需要下载一整个项目,只需要单个文件,通过file
命令也可以实现
1 | file(DOWNLOAD |
这里默认将下载的文件命名为demo.png
,并存放在 build
目录下,也可以使用含绝对路径的文件名${PROJECT_SOURCE_DIR}/img/demo.png
。
这里基于 FetchContent 模块,还有一个非官方的封装工具,叫做CPM,包括几个 cmake 脚本需要被复制到项目中(CPM.cmake、get_cpm.cmake 和 testing.cmake),说是进一步的封装,使得包管理更容易使用,但是我并没有看出来简化了多少。