这个环节我们从库的使用者切换为库的开发者,假设我们开发了一个库 Abc,那么如何处理 Abc 库的安装和导入?在导入环节,如何按照 modern cmake 的规范提供 AbcConfig.cmake?

尤其在这部分内容,网上教程非常混乱,CMake 官方文档写的非常复杂难懂,似乎是为超复杂的大型库开发所准备的,而且早期语法和 modern cmake 语法混杂在一起,难以分辨。本文将从简单的示例开始,吸取各个教程的内容,逐渐完善。

方式一:直接安装

我们首先考虑一种最无脑的安装方式:直接把可执行文件,库文件和头文件放到需要的地方去,并且不考虑复用。(CMake 不会导出它的配置信息文件)

CMake 支持这种直接无脑的安装,不需要我们真的在文件系统中进行手动拷贝。

示例如下

1
2
3
install(TARGETS demo4lib1 demo4lib2 demo4exe1 demo4exe2)
install(DIRECTORY include/Demo4 TYPE INCLUDE)
install(FILES src/Demo4_b.h TYPE INCLUDE)

这里安装四个 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
2
3
4
5
6
7
8
9
10
11
12
13
install(TARGETS demo4lib1 demo4lib2 demo4exe1 demo4exe2
LIBRARY DESTINATION lib
ARCHIVE DESTINATION lib
RUNTIME DESTINATION bin
)

install(DIRECTORY include/Demo4
DESTINATION include
)

install(FILES src/Demo4_b.h
DESTINATION include
)

对于简单的只有可执行文件的项目,这种直接安装的做法是没有任何问题的。 但是对于复杂的库项目则是不太推荐的,因为 CMake 没有导出配置文件,不利于我们在别的项目中使用它。 接下来我们考虑的情况都是:在安装库的同时,库的配置文件(*Config.cmake*Targets.cmake)也被同时安装。此后我们仍然可以很方便地在 CMake 中导入这些库。

方式二:简单安装配置

先从简单的开始,假设项目名为 Demo,存在几个 target:可执行文件 demoexe,静态库 demolib1,动态库 demolib2,将它们全部导出安装,只需要在后面加两行命令

1
2
3
install(TARGETS demoexe demolib1 demolib2 EXPORT DemoTargets)
install(EXPORT DemoTargets FILE DemoConfig.cmake NAMESPACE Demo:: DESTINATION lib/cmake/Demo)
install(DIRECTORY include/func DESTINATION include)

这几条命令的含义分别为:

  • 首先把这些 target 导出到 DemoTargets;(使用名称 XXXTargets 是约定的,下文复杂情形下,还有 DemoTargets.cmake 这样的配置文件产生)
  • 然后将 DemoTargets 信息导出到 DemoConfig.cmake 配置文件,指定这个配置文件的路径为 lib/cmake/Demo(这里 EXPORT 对应的 DESTINATION 选项不可省略),在配置文件中将所有伪目标加上命名空间Demo::
  • 最后导出头文件目录,复制到指定位置

在其它项目中,我们可以直接使用,例如链接它的静态库组件或动态库组件,语法都是一样的

1
2
3
find_package(Demo REQUIRED)
target_link_libraries(XXX PRIVATE Demo::demolib1)
# target_link_libraries(XXX PRIVATE Demo::demolib2)

补充:

  • 对于安装,还支持设置安装目录的用户权限,略。
  • 还有如下的常用命令,可以将指定的若干文件复制到指定位置
1
install(FILES somefile.txt DESTINATION doc) # -> doc/somefile.txt
  • 除了安装时指定目的地,我们还可以指定 TYPE,CMake 会根据 TYPE 来决定安装目的地
1
2
install(DIRECTORY include/func TYPE INCLUDE) # -> include/func
install(FILES somefile.txt TYPE DOC) # -> share/doc/somefile.txt

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
2
3
4
target_include_directories(demolib2 PUBLIC
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/../include>
$<INSTALL_INTERFACE:${CMAKE_INSTALL_PREFIX}/include> # <prefix>/include/mylib
)

这里就区分了编译时在 ${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,还可能添加了下面的某些配置文件(取决于安装时使用的构建类型,也可能一个都没有)
    1. DemoConfig-debug.cmake
    2. DemoConfig-release.cmake
    3. 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
2
3
4
5
6
install(TARGETS MyLib
EXPORT MyLibTargets
LIBRARY DESTINATION mylib
ARCHIVE DESTINATION mylib
RUNTIME DESTINATION mybin
)

例如简单示例相当于

1
2
3
4
5
6
7
8
9
install(TARGETS demoexe demolib1 demolib2
EXPORT DemoTargets
LIBRARY DESTINATION lib
ARCHIVE DESTINATION lib
RUNTIME DESTINATION bin
)

install(EXPORT DemoTargets FILE DemoConfig.cmake NAMESPACE Demo:: DESTINATION lib/cmake/Demo)
install(DIRECTORY include/func DESTINATION include)

可以改为

1
2
3
4
5
6
7
8
9
install(TARGETS demoexe demolib1 demolib2
EXPORT DemoTargets
LIBRARY DESTINATION mylib
ARCHIVE DESTINATION mylib
RUNTIME DESTINATION mybin
)

install(EXPORT DemoTargets FILE DemoConfig.cmake NAMESPACE Demo:: DESTINATION mylib/cmake/Demo)
install(DIRECTORY include/func DESTINATION myinclude)

这样安装位置仍然在CMAKE_INSTALL_PREFIX之中,只不过变成了 myinclude/,mylib/,mybin/等非标准子目录,这些自定义路径全都被记录到 DemoTargets 里面,所以尽管放心,只要找到 DemoTargets,就可以找到其它位置。

方式三:复杂安装配置

接下来我们讨论更复杂的情形:对于大型库,可能需要在 XXXConfig.cmake 实现更多的事情(例如依赖别的库?),此时推荐的做法是:

  1. 将 CMake 自动生成的内容导入到 XXXTargets.cmake 文件
  2. 准备配置文件模板 XXXConfig.cmake.in(开发者提供),CMake 在构建时根据模板生成 XXXConfig.cmake(替换相应变量)
  3. 这里要求保证在 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
2
3
4
5
6
7
8
9
10
11
include(CMakePackageConfigHelpers)

write_basic_package_version_file(
DemoConfigVersion.cmake
VERSION ${PACKAGE_VERSION}
COMPATIBILITY AnyNewerVersion # 表示向下兼容
)

install(FILES "${CMAKE_CURRENT_BINARY_DIR}/DemoConfigVersion.cmake"
DESTINATION lib/cmake/Demo
)

以上三条命令含义分别为:

  • 导入 CMakePackageConfigHelpers 模块
  • 将版本信息以及向下兼容声明,写入 DemoConfigVersion.cmake
  • 将 DemoConfigVersion.cmake 安装到指定位置,即 DemoConfig.cmake 同一个目录下

通过模板生成配置文件

接下来通过模板 XXXConfig.cmake.in 生成 XXXConfig.cmake,模板通常也存放在项目的 cmake/文件夹下。

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

configure_package_config_file(${PROJECT_SOURCE_DIR}/cmake/DemoConfig.cmake.in
"${CMAKE_CURRENT_BINARY_DIR}/DemoConfig.cmake"
INSTALL_DESTINATION cmake/Demo
)

install(FILES "${CMAKE_CURRENT_BINARY_DIR}/DemoConfig.cmake"
DESTINATION lib/cmake/Demo
)

注意这里的INSTALL_DESTINATION需要和接下来 DemoConfig.cmake 被安装的位置保持一致。

配置文件模板

关注一下模板怎么写,首先要求开头部分至少有一行单独的@PACKAGE_INIT@,还可以有形如@MY_PACKAGE_INIT@的自定义占位符,会被MY_PACKAGE_INIT这个 CMake 变量的实际内容替换。

例如下面的配置文件模板

DemoConfig.cmake.in
1
2
3
4
5
6
7
8
9
10
11
12

message(STATUS "My config @PROJECT_NAME@ @PROJECT_VERSION@ begin")

@PACKAGE_INIT@

# 这是MY_PACKAGE_INIT部分的开始

@MY_PACKAGE_INIT@

# 这是MY_PACKAGE_INIT部分的结束

message(STATUS "My config @PROJECT_NAME@ @PROJECT_VERSION@ done")

根据模板生成的配置文件为

DemoConfig.cmake
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
30
31
32
33
34
35
36

message(STATUS "My config Demo 0.1 begin")


####### Expanded from @PACKAGE_INIT@ by configure_package_config_file() #######
####### Any changes to this file will be overwritten by the next CMake run ####
####### The input file was DemoConfig.cmake.in ########

get_filename_component(PACKAGE_PREFIX_DIR "${CMAKE_CURRENT_LIST_DIR}/../" ABSOLUTE)

macro(set_and_check _var _file)
set(${_var} "${_file}")
if(NOT EXISTS "${_file}")
message(FATAL_ERROR "File or directory ${_file} referenced by variable ${_var} does not exist !")
endif()
endmacro()

macro(check_required_components _NAME)
foreach(comp ${${_NAME}_FIND_COMPONENTS})
if(NOT ${_NAME}_${comp}_FOUND)
if(${_NAME}_FIND_REQUIRED_${comp})
set(${_NAME}_FOUND FALSE)
endif()
endif()
endforeach()
endmacro()

####################################################################################

# 这是MY_PACKAGE_INIT部分的开始

... (这一部分取决于MY_PACKAGE_INIT变量的值)

# 这是MY_PACKAGE_INIT部分的结束

message(STATUS "My config Demo 0.1 done")

可以发现 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
2
find_package(Demo REQUIRED)
find_package(Demo2 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 略。

Demo1/CMakeLists.txt
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
cmake_minimum_required(VERSION 3.15 FATAL_ERROR)

if(NOT CMAKE_BUILD_TYPE)
set(CMAKE_BUILD_TYPE Release)
endif()

set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)

project(Demo VERSION 0.1)

add_subdirectory(src)

install(TARGETS demoexe1 demoexe2 demolib1 demolib2 EXPORT DemoTargets)
install(EXPORT DemoTargets
FILE DemoConfig.cmake
NAMESPACE Demo::
DESTINATION lib/cmake/Demo)
install(DIRECTORY include/func TYPE INCLUDE)
Demo2/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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
cmake_minimum_required(VERSION 3.15 FATAL_ERROR)

if(NOT CMAKE_BUILD_TYPE)
set(CMAKE_BUILD_TYPE Release)
endif()

set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)

project(Demo2 VERSION 0.1)

find_package(Demo REQUIRED)

add_subdirectory(src)

# install(TARGETS demoexe demolib1 demolib2 EXPORT DemoTargets)
install(TARGETS demo2exe1 demo2exe2 demo2lib1 demo2lib2 EXPORT Demo2Targets)

install(EXPORT Demo2Targets
FILE Demo2Targets.cmake
NAMESPACE Demo2::
DESTINATION lib/cmake/Demo2)

include(CMakePackageConfigHelpers)

write_basic_package_version_file(
Demo2ConfigVersion.cmake
VERSION ${PACKAGE_VERSION}
COMPATIBILITY AnyNewerVersion
)

install(FILES "${CMAKE_CURRENT_BINARY_DIR}/Demo2ConfigVersion.cmake"
DESTINATION lib/cmake/Demo2
)

set(MY_PACKAGE_INIT "\
include(\$\{CMAKE_CURRENT_LIST_DIR\}/Demo2Targets.cmake)
")

configure_package_config_file(${PROJECT_SOURCE_DIR}/cmake/Demo2Config.cmake.in
"${CMAKE_CURRENT_BINARY_DIR}/Demo2Config.cmake"
INSTALL_DESTINATION cmake/Demo2
)

install(FILES "${CMAKE_CURRENT_BINARY_DIR}/Demo2Config.cmake"
DESTINATION lib/cmake/Demo2
)
Demo3/CMakeLists.txt
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
cmake_minimum_required(VERSION 3.15 FATAL_ERROR)

if(NOT CMAKE_BUILD_TYPE)
set(CMAKE_BUILD_TYPE Release)
endif()

set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)

project(Demo3 VERSION 0.1)

find_package(Demo REQUIRED)
find_package(Demo2 REQUIRED)

add_subdirectory(src)

安装结果

生成、编译和安装命令依次使用

1
2
3
4
5
cmake -Bbuild -GNinja -DCMAKE_INSTALL_PREFIX=your/path/  -DCMAKE_EXPORT_COMPILE_COMMANDS=ON

cmake --build build --config Release

cmake --install build

这里我们采用的 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

最终,链接静态库的 exe 均可以正常执行,如果配置好动态库路径的环节变量,链接动态库的 exe 也可以正常执行。

CPack 使用

接下来,我们将使用 CPack:将 CMake 项目打包,用于对外发布编译前的源码或编译后的二进制文件,比如放置到 github 上,或者分享给他人。

这两种方式是相互独立的,具体的行为也截然不同:

  • 源码方式:主要打包项目的所有文件得到一个压缩包,建议在其中排除重新编译所不需要的内容(例如二进制文件和 build 文件夹),见下文;
  • 二进制方式:主要打包可执行程序,或者库文件+头文件,可能是直接得到一个压缩包形式,也可能不是,例如在 windows 平台,我们通常会额外安装NSIS,这个开源软件可以帮助我们创建 GUI 形式的程序。

第一步,要保证我们的 DemoCPack 项目已经使用 install 命令导出了一些东西,例如一个可执行文件 democpackexe,一个静态库 democpacklib1,还有一个动态库 democpacklib2,以及它们附带的头文件。

1
2
install(TARGETS democpackexe democpacklib1 democpacklib2)
install(DIRECTORY include/DemoCPack TYPE INCLUDE)

第二步,我们还需要继续修改 CMakeLists.txt 以支持打包导出,这个留在下面仔细讨论。

第三步,进入build目录,使用cpack命令即可导出压缩包或安装包

1
2
cd ./build
cpack -C Release

现在我们关注在 CMakeLists.txt 还需要做什么处理,这里只在 windows 平台讨论,考虑如下三种情况:

  • 源码方式,
  • 二进制方式,不使用 NSIS
  • 二进制方式,使用 NSIS

打包的环节主要通过 CPack 模块,注意我们在 CMakeLists 的最后导入它

1
include(CPack)

关于包的属性,有几个最基本的属性:包的名称和版本,通常会直接继承项目的名称和版本,可以修改

1
2
set(CPACK_PACKAGE_NAME "${PROJECT_NAME}")
set(CPACK_PACKAGE_VERSION "${PROJECT_VERSION}")

还有系统名,会出现在二进制包的名称中,可以修改

1
set(CPACK_SYSTEM_NAME "win64")

还要注意一点,在 CMakeCookbook 中,提到了下面的路径设置

1
set(CPACK_PACKAGING_INSTALL_PREFIX "/opt/${PROJECT_NAME}")

这个选项在 windows 下不要使用,因为这里的路径选项似乎不支持出现盘符,例如E:/会导致错误。

源码方式

在 CMakeLists.txt 使用如下的几行,可以自动把源码打包得到压缩包,存放在build/中。

1
2
3
4
set(CPACK_SOURCE_GENERATOR "7Z;ZIP;TGZ")
set(CPACK_SOURCE_IGNORE_FILES "${PROJECT_BINARY_DIR};/.git/;.gitignore;/.cache/;/lib/;/bin/")

include(CPack)

解释一下:

  • 第一行:在源码打包方式中选择了 7Z、ZIP 和 TGZ,也可以缺省,默认是 7Z 和 ZIP,也可以只选择其中一种
  • 第二行:在源码打包时排除了几个目录或文件:build,.git 仓库,.gitignore,以及 bin 和 lib

执行如下命令

1
2
cmake -Bbuild
cmake --build build --target package_source

注意:这里只需要构建完成,并不需要编译完成,这条命令也不会执行编译。

最终产生了三个源码压缩包并存放在 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
2
3
set(CPACK_GENERATOR "ZIP;TGZ")

include(CPack)

我们选择 ZIP 和 TGZ 用于二进制方式的打包,注意这个语句不能省略,因为在 windows 上默认会选择 NSIS,而当前我们不使用或者尚未安装 NSIS。

执行如下命令

1
2
cmake -Bbuild
cmake --build build --target package

注意:这里需要编译已经完成,这条命令也会自动先执行编译。

最终产生了两个二进制文件压缩包并存放在 build 目录下:(这里名称对应项目名称和版本号)

  • DemoCPack-1.0.2-win64.tar.gz
  • DemoCPack-1.0.2-win64.zip

压缩包中的内容是:可执行文件,库文件以及头文件,按照 Linux 风格的默认目录进行组织

1
2
3
4
5
6
|-bin
...
|-include
...
|-lib
...

注意这里的目录结构是默认使用的,即使原项目中的二进制文件没有采用这种形式,ZIP 和 TGZ 打包中也会自动采用 bin/include/lib 目录。

二进制方式(使用 NSIS)

首先确保 windows 系统中已经安装 NSIS,其次我们需要向 NSIS 提供更丰富的包信息。

首先了解一下 NSIS 提供的 GUI 形式的安装过程:

  • 第一个界面:显示"欢迎使用 XXX 的安装程序",点击下一步取消
  • 第二个界面:显示许可证协议,即提供的 LICENSE 文件内容,点击上一步我接收取消
  • 第三个界面:显然输入安装位置,默认C:\Program Files\XXX,修改路径后,点击上一步下一步取消
  • 第四个界面:显示快捷方式名称,也可以勾选不创建快捷方式,点击上一步安装取消
  • 第五个界面:显示安装进度条和安装完成,点击完成

安装的实际结果也是和前面的类似,二进制文件和头文件存放在如下的目录结构中,不过它会在根目录下自动添加一个对应的卸载程序 uninstall.exe

1
2
3
4
5
6
7
|-bin
...
|-include
...
|-lib
...
uninstall.exe

然后我们回到 CMake 的使用,在 CMakeLists.txt 使用如下的几行

1
2
3
4
set(CPACK_GENERATOR "NSIS64")
set(CPACK_RESOURCE_FILE_LICENSE "${PROJECT_SOURCE_DIR}/LICENSE")

include(CPack)

解释一下:

  • 第一行:在二进制打包方式中选择了 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
2
3
install(FILES g:/mingw64/bin/libstdc++-6.dll TYPE BIN)
install(FILES g:/mingw64/bin/libgcc_s_seh-1.dll TYPE BIN)
install(FILES g:/mingw64/bin/libwinpthread-1.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 脚本等等。