Cpp构建和编译笔记——9.CMake库的开发
这个环节我们从库的使用者切换为库的开发者,假设我们开发了一个库 Abc,那么如何处理 Abc 库的安装和导入?在导入环节,如何按照 modern cmake 的规范提供 AbcConfig.cmake?
尤其在这部分内容,网上教程非常混乱,CMake 官方文档写的非常复杂难懂,似乎是为超复杂的大型库开发所准备的,而且早期语法和 modern cmake 语法混杂在一起,难以分辨。本文将从简单的示例开始,吸取各个教程的内容,逐渐完善。
方式一:直接安装
我们首先考虑一种最无脑的安装方式:直接把可执行文件,库文件和头文件放到需要的地方去,并且不考虑复用。(CMake 不会导出它的配置信息文件)
CMake 支持这种直接无脑的安装,不需要我们真的在文件系统中进行手动拷贝。
示例如下
1 | install(TARGETS demo4lib1 demo4lib2 demo4exe1 demo4exe2) |
这里安装四个 target:可执行文件 demo4exe1 和 demo4exe2,库文件 demo4lib1 和 demo4lib2。 并且附带头文件目录 include/Demo4,还有额外的头文件 src/Demo4_b.h。
安装的效果如下:(安装到 CMAKE_INSTALL_PREFIX 目录下)
- 将可执行文件 demo4exe1,demo4exe2 安装到 bin/子目录下
- 将静态库和动态库的.lib 部分,安装到 lib/子目录下
- 将动态库的.dll 部分,安装到 bin/子目录下
- 将头文件目录安装到 include 下,得到 include/Demo4 头文件夹
- 将头文件 src/Demo4_b.h 复制到 inclulde/子目录下
这些位置都是 Linux 风格的,也是 CMake 默认采用的,但是我们也分别指定,下面的 bin/lib/include 就是默认情形,可以改成自定义位置
1 | install(TARGETS demo4lib1 demo4lib2 demo4exe1 demo4exe2 |
对于简单的只有可执行文件的项目,这种直接安装的做法是没有任何问题的。
但是对于复杂的库项目则是不太推荐的,因为 CMake
没有导出配置文件,不利于我们在别的项目中使用它。
接下来我们考虑的情况都是:在安装库的同时,库的配置文件(*Config.cmake
,*Targets.cmake
)也被同时安装。此后我们仍然可以很方便地在
CMake 中导入这些库。
方式二:简单安装配置
先从简单的开始,假设项目名为 Demo,存在几个 target:可执行文件 demoexe,静态库 demolib1,动态库 demolib2,将它们全部导出安装,只需要在后面加两行命令
1 | install(TARGETS demoexe demolib1 demolib2 EXPORT DemoTargets) |
这几条命令的含义分别为:
- 首先把这些 target 导出到 DemoTargets;(使用名称 XXXTargets 是约定的,下文复杂情形下,还有 DemoTargets.cmake 这样的配置文件产生)
- 然后将 DemoTargets 信息导出到 DemoConfig.cmake
配置文件,指定这个配置文件的路径为 lib/cmake/Demo(这里 EXPORT 对应的
DESTINATION
选项不可省略),在配置文件中将所有伪目标加上命名空间
Demo::
; - 最后导出头文件目录,复制到指定位置
在其它项目中,我们可以直接使用,例如链接它的静态库组件或动态库组件,语法都是一样的
1 | find_package(Demo REQUIRED) |
补充:
- 对于安装,还支持设置安装目录的用户权限,略。
- 还有如下的常用命令,可以将指定的若干文件复制到指定位置
1 | install(FILES somefile.txt DESTINATION doc) # -> doc/somefile.txt |
- 除了安装时指定目的地,我们还可以指定 TYPE,CMake 会根据 TYPE 来决定安装目的地
1 | install(DIRECTORY include/func TYPE INCLUDE) # -> include/func |
RPATH
这里补充一下 RPATH 的内容,找不到动态库始终是动态库在实际使用时最大的问题。
对于 Linux 系统,在编译时可以直接在可执行文件写入运行时动态库的查找路径,这里的优先级最高(比 LD_LIBRARY_PATH 高),确保运行时找到正确的动态库,例如直接使用如下选项
1 | set(CMAKE_EXE_LINKER_FLAGS "${CMAKE_EXE_LINKER_FLAGS} -Wl,-rpath -Wl,${RPATH}") |
这里的 RPATH 是希望运行时可以优先检索的动态库路径,可以通过环境变量传递。(Windows 不支持,因此这里的选项只适合 Linux)
更优雅的做法是了解并使用 CMake 的 RPATH 机制:CMake 的机制参考CMake-RPATH。
关于 RPATH 需要区分本地执行和安装后执行:
- 对于本地执行,CMake 在编译时自动添加需要引用的项目内部的动态库完整路径作为 RPATH,确保在项目本地可以正确运行;
- 对于安装后执行,相应的目录位置早就发生改变不能使用,CMake 会自动修改可执行程序或动态库内部的 RPATH(不是重新编译,而是直接修改),默认情形下是清空所有 RPATH。
下面的是 target 相关的 RPATH
属性,也可以加上CMAKE_
前缀进行全局设置:
BUILD_RPATH
:编译时的 RPATH,在你想要从编译目录中运行程序可能会需要的路径;INSTALL_RPATH
:安装时的 RPATH,在你想要从安装目录中运行程序可能会需要的路径;(默认为空)SKIP_BUILD_RPATH
:布尔选项,是否跳过编译时的 RPATH 配置;(默认 FALSE)BUILD_WITH_INSTALL_RPATH
:布尔选项,在编译时使用与安装时一样的 RPATH 配置;INSTALL_RPATH_USE_LINK_PATH
:将编译时确定的部分 RPATH(即通常指向项目之外的动态库的路径)用作安装时的 RPATH;(默认 FALSE)BUILD_RPATH_USE_ORIGIN
:是否在编译时使用$ORIGIN
,用于 RPATH 的相对路径;INSTALL_REMOVE_ENVIRONMENT_RPATH
:安装时是否移除工具链相关的 RPATH;
编译时路径 vs 安装后路径
与此同时,还有一个需要关注的问题:头文件/库文件路径问题,在构建时和安装后,target 应该使用不同的路径!可以使用 CMake 提供的专门的表达式在编译时/安装时启用不同的头文件路径,例如
1 | target_include_directories(demolib2 PUBLIC |
这里就区分了编译时在
${CMAKE_CURRENT_SOURCE_DIR}/../include
查找头文件,而安装后在${CMAKE_INSTALL_PREFIX}/include
查找头文件。
注:
- 如果考虑安装,则所有的路径几乎都需要上述处理;
- 关于相对路径还是绝对路径:如果不考虑安装或者移植性,那么使用绝对路径是最可靠的,可以确保编译顺利;如果考虑可移植性或安装,则倾向于使用相对路径;
- 在源文件中使用的头文件,建议至少套一层文件夹名,例如
#include "func/func.h"
,比#include "func.h"
更安全可靠,并且可以避免在 CMakeLists 中写入过于详细特殊的路径。
简单示例解读
我们观察这个简单例子对应的具体细节:(Windows-MinGW)
- 在安装位置的 bin/ 子目录,添加了可执行文件 demoexe.exe 以及动态库(主要部分) libdemolib2.dll
- 在安装位置的 lib/ 子目录,添加了静态库 libdemolib1.a 以及动态库的辅助部分 libdemolib2.dll.a
- 在安装位置的 include/ 子目录,将头文件夹 func 全部复制过来,得到 include/func/
- 在安装位置的 lib/cmake/Demo/ 子目录,添加了配置文件
DemoConfig.cmake,还可能添加了下面的某些配置文件(取决于安装时使用的构建类型,也可能一个都没有)
- DemoConfig-debug.cmake
- DemoConfig-release.cmake
- DemoConfig-noconfig.cmake(没有使用 CMAKE_BUILD_TYPE 时)
DemoConfig.cmake 可能在执行中通过 include 调用 DemoConfig-xxx.cmake,去完成相应构建模式下的设置。
通过简单示例可以发现,CMake 可以自动为我们生成了相应的配置文件,并不需要我们处理细节。 但是对于大型库来说,需要在配置文件中进行某些个性化的处理,此时 CMake 无法自动为我们生成,需要通过更复杂的流程来生成配置文件:开发者提供一个配置文件模板 XXXConfig.cmake.in,然后 CMake 将其中变量替换,生成最终的 XXXConfig.cmake.in
自定义安装位置
注意到,在简单示例中,除了头文件和配置文件的安装目的地,我们并没有指定其它部分的最终目的地。事实上,这些位置的默认路径都是 Linux 风格的,依赖 CMake 内置模块 GNUInstallDirs.cmake,参考官方文档。
我们也可以具体指定路径,按照可执行文件/库文件/...分别指定它们的安装目的地
1 | install(TARGETS MyLib |
例如简单示例相当于
1 | install(TARGETS demoexe demolib1 demolib2 |
可以改为
1 | install(TARGETS demoexe demolib1 demolib2 |
这样安装位置仍然在CMAKE_INSTALL_PREFIX
之中,只不过变成了
myinclude/,mylib/,mybin/等非标准子目录,这些自定义路径全都被记录到
DemoTargets 里面,所以尽管放心,只要找到
DemoTargets,就可以找到其它位置。
方式三:复杂安装配置
接下来我们讨论更复杂的情形:对于大型库,可能需要在 XXXConfig.cmake 实现更多的事情(例如依赖别的库?),此时推荐的做法是:
- 将 CMake 自动生成的内容导入到 XXXTargets.cmake 文件
- 准备配置文件模板 XXXConfig.cmake.in(开发者提供),CMake 在构建时根据模板生成 XXXConfig.cmake(替换相应变量)
- 这里要求保证在 XXXConfig.cmake 中通过 include 调用 XXXTargets.cmake 文件
即对于复杂项目,在配置文件流程中多加了一个环节,需要多提供一个配置模板文件。
我们使用 CMake 官方提供内置模块 CMakePackageConfigHelpers.cmake,它可以辅助我们完成配置文件的生成,这个库需要手动导入
1 | include(CMakePackageConfigHelpers) |
这个模块只有两个主要功能,下面分别要使用:
write_basic_package_version_file()
这个命令用于生成 ConfigVersion 文件,主要是版本信息;configure_package_config_file()
这个命令用于填充模板,生成 Config 文件,似乎是 configure_file()的高级替代,后者会复制一个文件,并根据规则使用 CMake 变量替换其中的内容。
生成版本信息文件
我们可以附带生成DemoConfigVersion.cmake
文件,它可以提供库的版本信息,兼容性等更丰富的信息。
1 | include(CMakePackageConfigHelpers) |
以上三条命令含义分别为:
- 导入 CMakePackageConfigHelpers 模块
- 将版本信息以及向下兼容声明,写入 DemoConfigVersion.cmake
- 将 DemoConfigVersion.cmake 安装到指定位置,即 DemoConfig.cmake 同一个目录下
通过模板生成配置文件
接下来通过模板 XXXConfig.cmake.in 生成 XXXConfig.cmake,模板通常也存放在项目的 cmake/文件夹下。
1 | include(CMakePackageConfigHelpers) |
注意这里的INSTALL_DESTINATION
需要和接下来
DemoConfig.cmake 被安装的位置保持一致。
配置文件模板
关注一下模板怎么写,首先要求开头部分至少有一行单独的@PACKAGE_INIT@
,还可以有形如@MY_PACKAGE_INIT@
的自定义占位符,会被MY_PACKAGE_INIT
这个
CMake 变量的实际内容替换。
例如下面的配置文件模板
1 |
|
根据模板生成的配置文件为
1 |
|
可以发现 CMake
把必要的占位符@PACKAGE_INIT@
替换为了一些提示信息,顺便提供了两个宏可以使用:(也可以通过选项关闭它们)
set_and_check(a,b)
,将 a 定义为 b,如果 b 此前没有被定义则报错check_required_components(a)
,检查必要组件是否被找到,并反馈到整体的 FOUND 变量
至于自定义的占位符通常包括如下内容:(注意占位符是字符串,因此需要很多转义字符)
- 一些变量的设置,这里可以利用
set_and_check()
宏 - include 真正的配置文件 DemoTargets.cmake
- 如果当前导入的目标还存在依赖,则调用 find_dependency()进一步导入依赖(这是对 find_package()进行封装的宏)
- 最后通常会检查一下组件是否成功导入,可以使用宏
check_required_components()
复杂依赖
以上我们只考虑了形如a<-b
的简单依赖关系,在实践中可能有更复杂的依赖关系,例如
1 | a <- b <- c |
此时一种可行做法是在使用时依次导入
1 | find_package(Demo REQUIRED) |
另一种做法是在配置文件(或配置文件模板)加入依赖项的导入,过于复杂,这里不讨论。
完整示例
这里我们提供三个简单项目(Demo,Demo2,Demo3)作为库的开发和依赖管理示例。
项目介绍
- Demo1 项目,包括
demoexe1,demoexe2,demolib1(静态库),demolib2(动态库)
- demoexe1 链接到静态库 demolib1
- demoexe2 链接到动态库 demolib2
- Demo2 项目,包括
demo2exe1,demo2exe2,demo2lib1(静态库),demo2lib2(动态库)
- demo2lib1 链接到 Demo::demolib1
- demo2lib2 链接到 Demo::demolib2
- demo2exe1 链接到静态库 demolib1
- demo2exe2 链接到动态库 demolib2
- Demo3 项目,包括 demo3exe1,demo3exe2
- demo3exe1 链接到静态库 demo2lib1
- demo3exe2 链接到动态库 demo2lib2
CMakeLists
Demo1 项目使用简单安装配置的方式并安装到默认位置,Demo2 项目使用复杂安装配置的方式并安装到默认位置,Demo3 项目不需要安装,需要处理好动态库路径的环境变量问题。
各个项目的主目录下的 CMakeLists.txt 如下, src 目录下的 CMakeLists.txt 略。
1 | cmake_minimum_required(VERSION 3.15 FATAL_ERROR) |
1 | cmake_minimum_required(VERSION 3.15 FATAL_ERROR) |
1 | cmake_minimum_required(VERSION 3.15 FATAL_ERROR) |
安装结果
生成、编译和安装命令依次使用
1 | cmake -Bbuild -GNinja -DCMAKE_INSTALL_PREFIX=your/path/ -DCMAKE_EXPORT_COMPILE_COMMANDS=ON |
这里我们采用的 Ninja 和 MinGW,对于 VS 构建的可能静态库名称略有不同。
安装结果如下:
- 在安装目录下的 bin 目录增加了如下的文件
- demoexe1.exe
- demoexe2.exe
- demo2exe1.exe
- demo2exe2.exe
- libdemolib2.dll
- libdemo2lib2.dll
- 在安装目录下的 include 目录增加了相应的头文件
- 在安装目录下的 lib 目录下增加了如下的文件
- libdemolib1.a
- libdemolib2.dll.a
- libdemo2lib1.a
- libdemo2lib2.dll.a
- 在安装目录下的 lib/cmake 目录下增加了如下的文件
- 通过简单配置方式安装的 Demo 只有两个文件
- Demo/DemoConfig.cmake
- Demo/DemoConfig-release.cmake
- 通过复杂配置方式安装的 Demo2 有更多文件
- Demo2/Demo2Config.cmake
- Demo2/Demo2ConfigVersion.cmake
- Demo2/Demo2Targets.cmake
- Demo2/Demo2Targets-release.cmake
- 通过简单配置方式安装的 Demo 只有两个文件
最终,链接静态库的 exe 均可以正常执行,如果配置好动态库路径的环节变量,链接动态库的 exe 也可以正常执行。
CPack 使用
接下来,我们将使用 CPack:将 CMake 项目打包,用于对外发布编译前的源码或编译后的二进制文件,比如放置到 github 上,或者分享给他人。
这两种方式是相互独立的,具体的行为也截然不同:
- 源码方式:主要打包项目的所有文件得到一个压缩包,建议在其中排除重新编译所不需要的内容(例如二进制文件和 build 文件夹),见下文;
- 二进制方式:主要打包可执行程序,或者库文件+头文件,可能是直接得到一个压缩包形式,也可能不是,例如在 Windows 平台,我们通常会额外安装NSIS,这个开源软件可以帮助我们创建 GUI 形式的程序。
第一步,要保证我们的 DemoCPack 项目已经使用 install 命令导出了一些东西,例如一个可执行文件 democpackexe,一个静态库 democpacklib1,还有一个动态库 democpacklib2,以及它们附带的头文件。
1 | install(TARGETS democpackexe democpacklib1 democpacklib2) |
第二步,我们还需要继续修改 CMakeLists.txt 以支持打包导出,这个留在下面仔细讨论。
第三步,进入build目录,使用cpack命令即可导出压缩包或安装包
1
2cd ./build
cpack -C Release
现在我们关注在 CMakeLists.txt 还需要做什么处理,这里只在 Windows 平台讨论,考虑如下三种情况:
- 源码方式,
- 二进制方式,不使用 NSIS
- 二进制方式,使用 NSIS
打包的环节主要通过 CPack 模块,注意我们在 CMakeLists 的最后导入它
1 | include(CPack) |
关于包的属性,有几个最基本的属性:包的名称和版本,通常会直接继承项目的名称和版本,可以修改
1 | set(CPACK_PACKAGE_NAME "${PROJECT_NAME}") |
还有系统名,会出现在二进制包的名称中,可以修改
1 | set(CPACK_SYSTEM_NAME "win64") |
还要注意一点,在 CMakeCookbook 中,提到了下面的路径设置
1 | set(CPACK_PACKAGING_INSTALL_PREFIX "/opt/${PROJECT_NAME}") |
这个选项在 Windows
下不要使用,因为这里的路径选项似乎不支持出现盘符,例如E:/
会导致错误。
源码方式
在 CMakeLists.txt
使用如下的几行,可以自动把源码打包得到压缩包,存放在build/
中。
1 | set(CPACK_SOURCE_GENERATOR "7Z;ZIP;TGZ") |
解释一下:
- 第一行:在源码打包方式中选择了 7Z、ZIP 和 TGZ,也可以缺省,默认是 7Z 和 ZIP,也可以只选择其中一种
- 第二行:在源码打包时排除了几个目录或文件:build,.git 仓库,.gitignore,以及 bin 和 lib
执行如下命令
1 | cmake -Bbuild |
注意:这里只需要构建完成,并不需要编译完成,这条命令也不会执行编译。
最终产生了三个源码压缩包并存放在 build 目录下:(这里名称对应项目名称和版本号)
- DemoCPack-1.0.2-Source.7z
- DemoCPack-1.0.2-Source.tar.gz
- DemoCPack-1.0.2-Source.zip
它们的内容就相当于直接把项目文件夹压缩,只不过根据命令排除了一些项。
注意:必须排除 build 目录,否则有压缩包套娃问题:如果不排除 build 文件夹,那么一次打包之后在 build 文件夹可能会产生若干压缩包,在第二次打包时可能会无限套娃,把旧的源码压缩包也打包进最终的结果!导致一次次套娃,压缩包急剧增大~ 甚至在这里一次生成的三个压缩包也会因为先后顺序,导致第二个也会包含第一个,第三个也会包含第一个和第二个,套娃压缩包。
二进制方式(不使用 NSIS)
在 CMakeLists.txt
使用如下的几行,可以自动把二进制打包得到压缩包,存放在build/
中。
1 | set(CPACK_GENERATOR "ZIP;TGZ") |
我们选择 ZIP 和 TGZ 用于二进制方式的打包,注意这个语句不能省略,因为在 Windows 上默认会选择 NSIS,而当前我们不使用或者尚未安装 NSIS。
执行如下命令
1 | cmake -Bbuild |
注意:这里需要编译已经完成,这条命令也会自动先执行编译。
最终产生了两个二进制文件压缩包并存放在 build 目录下:(这里名称对应项目名称和版本号)
- DemoCPack-1.0.2-win64.tar.gz
- DemoCPack-1.0.2-win64.zip
压缩包中的内容是:可执行文件,库文件以及头文件,按照 Linux 风格的默认目录进行组织
1 | |-bin |
注意这里的目录结构是默认使用的,即使原项目中的二进制文件没有采用这种形式,ZIP 和 TGZ 打包中也会自动采用 bin/include/lib 目录。
二进制方式(使用 NSIS)
首先确保 Windows 系统中已经安装 NSIS,其次我们需要向 NSIS 提供更丰富的包信息。
首先了解一下 NSIS 提供的 GUI 形式的安装过程:
- 第一个界面:显示"欢迎使用 XXX
的安装程序",点击
下一步
或取消
; - 第二个界面:显示许可证协议,即提供的 LICENSE
文件内容,点击
上一步
、我接收
或取消
; - 第三个界面:显然输入安装位置,默认
C:\Program Files\XXX
,修改路径后,点击上一步
、下一步
或取消
; - 第四个界面:显示快捷方式名称,也可以勾选不创建快捷方式,点击
上一步
、安装
或取消
; - 第五个界面:显示安装进度条和安装完成,点击
完成
。
安装的实际结果也是和前面的类似,二进制文件和头文件存放在如下的目录结构中,不过它会在根目录下自动添加一个对应的卸载程序 uninstall.exe
1 | |-bin |
然后我们回到 CMake 的使用,在 CMakeLists.txt 使用如下的几行
1 | set(CPACK_GENERATOR "NSIS64") |
解释一下:
- 第一行:在二进制打包方式中选择了 NSIS64(NSIS 是 32 位的,其实差不多)
- 第二行:添加 LICENSE 文件,因为 NSIS 的 GUI 安装过程有一个版权信息确认的环节,将 LICENSE 文本文件放在对应位置即可;
最终产生了名为 DemoCPack-1.0.2-win64.exe 的可执行文件(不再是压缩包了),仍然存放在 build 中。
注:
- 可以开启如下选项,此时 NSIS 会自动判断是否同一个项目已经被安装(无论它被安装在哪),再次安装时会提示先卸载,但是这里的卸载会保留文件夹和里面的 uninstall.exe,这个选项默认关闭,意味着可以重复安装
1 | set(CPACK_NSIS_ENABLE_UNINSTALL_BEFORE_INSTALL ON) |
- 可以使用如下选项设置名称,这个名称会被 NSIS 在 GUI 界面采用,会影响快捷方式的默认名,但是不影响路径和二进制文件名
1 | set(CPACK_NSIS_PACKAGE_NAME "another-name") |
- 使用 NSIS 也可以完成源码方式的打包,得到的是 DemoCPack-1.0.2-Source.exe 文件,GUI 流程也一样,只不过最终结果是把源码放在指定位置
二进制方式附带 DLL
为了确保我们的二进制程序在别的机器上仍然可以正常运行,我们有时需要添加必要的 DLL,例如 MSVCRT 或 UCRT(这些在 Windows10 都有了,在低版本时可能需要),对于 MinGW,我们还需要专门的 DLL,因为目标机器很可能没有所需要的 DLL,例如
1 | install(FILES g:/mingw64/bin/libstdc++-6.dll TYPE BIN) |
找到并添加这三个 DLL,它们作为单独的文件也会在安装/打包时出现在 bin 目录中,确保程序可以正常运行。程序还可能需要其它 DLL,也应该一起添加。
最后
终于整理完成了 CMake 的这几篇学习笔记,陆陆续续边学边用,系统性地整理了一下。CMake 的使用远不至于此,至少还有如下的内容:
- 让 CMake 执行自定义的 shell 命令,甚至把它伪装成一个 target;
- 让 CMake 从 github 自动拉取依赖,只需要提供相应的 URL;
- 除了配置文件模板,还有头文件模板(传递 CMake 变量,例如版本信息)XXX.h.in,自动生成 XXX.h;
- ...
但是感觉这些暂时用不上,或者功能不需要通过 CMake 来实现,因此略去。
列一下 CMake 部分的参考教程:
- CMake 的官方文档(CMake 官方文档怎么能写的如此垃圾!!!)
- Github 的UCMake(最开始的 CMake 学习动力之一,就是搞懂这个仓库)
- CSDN 的一个专栏,变量部分的主要参考
- B 站的一个视频,依赖管理的主要参考
- GitLab 的一个wiki,RPATH 的参考
- 知乎的一个文章,依赖管理的参考
这个系列还可以继续补充,我将尝试拆解几个著名但是结构比较简单的库,例如 spdlog,学习它们的文件组织,测试,cmake 脚本等等。