【CMake】第3期 CMake 安装、导出与打包体系完整详解

发布时间:2026/10/31 11:11:29
【CMake】第3期 CMake 安装、导出与打包体系完整详解

开头

OK了,直接马不停蹄的开始我们的这一期话题——关于CMake的安装、导出与打包。直接开始。

CMake 的安装与导出体系,核心是解决「如何把自研库从「本地自用」变成「可被外部项目规范引用」的问题。整个体系是一条逐层递进的交付链路:
文件安装 → 目标导出 → 包配置生成 → 打包分发
每一步只解决一个问题,最终实现外部项目通过find_package一行代码即可接入使用

一.文件的安装:从构建树到安装树

编译完成后,所有产物都散落在build/构建目录中,路径混杂、不适合直接交付。install系列命令的作用,就是按照规范的目录结构,把需要交付的文件复制到「安装树」中

1. 安装树

CMake 的安装树是项目执行安装(cmake --install/make install)后,所有产物在安装前缀目录下形成的标准化目录结构

它是项目的「最终分发形态」—— 只保留运行、开发、部署所需的文件,去掉编译中间产物,供最终用户或下游项目使用

(1)根目录

安装树的根目录——CMAKE_INSTALL_PREFIX

  • 默认值:
    • Linux / macOS:/usr/local
    • Windows:C:/Program Files/<项目名>
  • 修改方式:配置阶段指定
cmake -DCMAKE_INSTALL_PREFIX=/opt/myproject ..

(2)标准目录布局:CNUInstallDirs模板

规范的 CMake 项目不会硬编码bin/lib/include,而是通过GNUInstallDirs模块获取跨平台兼容的标准目录变量。

include(GNUInstallDirs)

该模块定义的核心变量(均为相对路径,相对于CMAKE_INSTALL_PREFIX):

变量含义默认值
CMAKE_INSTALL_BINDIR用户可执行文件目录bin
CMAKE_INSTALL_LIBDIR库文件目录lib(64 位部分系统为lib64)
CMAKE_INSTALL_INCLUDEDIR头文件目录include
CMAKE_INSTALL_DATADIR架构无关数据文件share/<项目名>
CMAKE_INSTALL_MANDIRman 手册目录share/man
CMAKE_INSTALL_SYSCONFDIR配置文件目录etc

2.install() 命令

install()命令的唯一作用:指定「什么文件」放到「安装树的哪个位置」,并附带权限、组件、导出等属性

(1)install (TARGETS):安装编译目标

用于安装通过add_executable、add_library生成的目标,是最核心的安装命令。

基础语法

install(TARGETS <目标1> <目标2> ... [EXPORT <导出集名称>] RUNTIME DESTINATION <可执行文件安装目录> LIBRARY DESTINATION <动态库安装目录> ARCHIVE DESTINATION <静态库安装目录> [INCLUDES DESTINATION <头文件安装目录>] [COMPONENT <组件名>]

核心参数详解

参数含义设计原因
EXPORT <名称>将目标归入指定「导出集」,后续用于生成导出脚本导出目标的前置标记,配合install(EXPORT)使用
RUNTIME DESTINATION可执行文件、Windows 平台 DLL 动态库的安装路径不同平台产物类型定义不同:Windows 下 DLL 属于运行时文件,归入 RUNTIME
LIBRARY DESTINATIONLinux/macOS 下的动态库(.so/.dylib)安装路径类 Unix 系统动态库归为 LIBRARY 类别
ARCHIVE DESTINATION静态库(.a/.lib)、Windows DLL 导入库的安装路径静态库始终属于归档文件
INCLUDES DESTINATION声明该目标对应的头文件安装目录导出后自动写入目标的INTERFACE_INCLUDE_DIRECTORIES属性,使用者无需手动配置头文件路径
COMPONENT对安装内容分组(如 Runtime/Development)支持按需安装组件,打包时也可拆分

示例


(2)install (FILES):安装单个文件

用于安装头文件、配置文件、文档等零散文件。

install(FILES <路径+文件名> DESTINATION <安装到哪> [PERMISSIONS OWNER_READ OWNER_WRITE] [RENAME <重命名>] [OPTIONAL])
  • RENAME:安装时重命名文件
  • OPTIONAL:文件不存在也不报错,适合可选文件

示例


(3)install (DIRECTORY):安装整个目录

用于批量安装一整个目录的文件,比如整个头文件目录。

install(DIRECTORY <目录> DESTINATION <安装到哪> FILES_MATCHING PATTERN "*.h" PATTERN "*.hpp" )

⚠️最容易踩的坑:末尾斜杠

  • install(DIRECTORY include/ ...):复制目录内的内容,结果为install/include/math.h✅
  • install(DIRECTORY include ...):复制目录本身,结果为install/include/include/math.h❌

示例

二.目标导出:将目标元信息序列化

在没有目标导出的年代,交付一个 C/C++ 库非常繁琐:

  • 你需要给对方提供:库文件(.a/.so/.lib/.dll)+ 头文件
  • 对方拿到后,要手动配置:头文件搜索路径、库文件搜索路径、链接依赖、编译宏
  • 如果你的库还依赖第三方库(比如 pthread、fmt),对方还要自己手动补上所有依赖,非常容易配错。

目标导出的本质:把「这个库该怎么用」的所有规则(头文件目录、编译选项、依赖关系),全部打包成标准的 CMake 脚本。外部项目只需要一行target_link_libraries,就能自动获得完整的编译链接配置,零手动配置

1.目标导出的核心原理

导出最终会生成一个xxxTargets.cmake文件,这个文件就是纯 CMake 代码,核心做两件事:

  1. 创建导入目标

通过add_library创建一个IMPORTED(导入型)目标,代表这是一个 “已经编译好、从外部导入的库”:

add_library(demo::math STATIC IMPORTED)
  1. 写入所有使用属性

通过set_target_properties把使用该库需要的所有信息,全部设置到这个导入目标上:

属性作用
IMPORTED_LOCATION库文件本身的绝对路径,告诉 CMake 库文件在哪
INTERFACE_INCLUDE_DIRECTORIES头文件目录。使用者链接这个目标时,自动把头文件路径加入自己的搜索路径
INTERFACE_COMPILE_DEFINITIONS编译宏。使用者自动带上这些宏定义
INTERFACE_LINK_LIBRARIES依赖的其他库。使用者链接时,自动把依赖库也一起链接上

最终效果:使用者只需要写

target_link_libraries(app PRIVATE demo::math)

CMake 会自动把头文件路径、编译宏、依赖库全部配置好,不用手动写任何路径

2.install (EXPORT):安装导出

在上面install(TARGETS)的时候,里面有一个字段EXPORT <导出集>,你肯定有疑惑:这个导出集是啥东西??

我们通过add_executable()和add_library()创建出来的CMake目标对象,它有一个关键局限:

目标只存在于当前项目的构建过程中,是内存里的临时对象;项目编译完、安装出去之后,这个目标对象就消失了

层级存在阶段作用范围路径基准
普通 Target(目标)CMake 配置 + 构建期仅当前项目内部可见基于本地源码树、构建树的绝对路径
EXPORT 导出集安装 + 分发阶段外部下游项目(find_package)可见基于安装前缀的可重定位路径

EXPORT做的第一件事,就是把这些属性序列化写到一个.cmake文件里(比如mylibTargets.cmake),随着安装树一起分发出去。

下游项目执行find_package(mylib)时,会自动 include 这个文件,在下游的 CMake 环境里重新创建出一个IMPORTED导入目标(比如mylib::mylib),效果和你本地add_library出来的目标几乎一模一样,下游直接target_link_libraries就能用,头文件、依赖、编译选项自动带上

现在,我们需要根据导出集,生成.cmake文件,并安装到指定路径下


install(EXPORT)语法

install(EXPORT <导出集> FILE <.cmake文件名> NAMESPACE <命名空间> DESTINATION <安装到哪>

参数详解

参数作用注意事项
FILE <文件名>生成的导出脚本文件名建议命名为<项目名>Targets.cmake
NAMESPACE <前缀>::给所有导出目标加上命名空间前缀形成demo::math格式,避免和其他库重名,是 CMake 官方推荐规范
DESTINATION导出文件的安装目录标准路径为lib/cmake/<包名>/,find_package会自动在此查找

示例

3.export(EXPORT)

上面我们学习的install(EXPORT)是把导出集(XXXTargets.cmake)安装到安装树目录下,系统默认路径,发生在执行make install / cmake --install之后

这就会有一个很麻烦的点:在本地的工程想要使用到这个库,传统流程里,要等到执行完make install后,在系统安装目录下生成了XXXTargets.cmake文件,这个本地工程才能使用,但如果我需要对库进行修改,每修改一次,就得重新执行install安装操作,反复往系统目录写文件,开发效率极低

这就引出了export(EXPORT,它就干一件事:

把本来要等 install 步骤才生成的 Targets.cmake,提前在 cmake 配置阶段(就是你第一次执行cmake -S . -B build的时候)直接生成在你的 build 目录里,而且这个文件里写的库路径,直接指向 build 目录里刚编译出来的库文件,根本不用复制到系统目录。

在这个 build 目录里的 Targets.cmake
它存在的唯一价值,就是把开发时的 “编译→install→重新配置下游工程” 三步循环,砍成 “编译” 一步:
例如你写 MyMath 的时候:

  1. cmake 配置一次(这时候export(EXPORT)就已经把 Targets.cmake 生成在 build 目录了)
  2. 改完代码执行编译
  3. 旁边那个独立的测试工程,配置的时候只要把CMAKE_PREFIX_PATH指向 MyMath 的 build 目录,就能读到这个 Targets.cmake,直接链接 build 目录里刚编译出来的最新库
    全程不需要执行一次 install,也不会往系统目录写任何文件

基本语法

export(EXPORT <导出集> FILE ${PROJECT_BINARY_DIR}/cmake/XXXTargets.cmake NAMESPACE <命名空间>

4.生成器

(1)why


如上图,如果我们现在构建,会出现上面的报错

这是咋回事??????


在构建库目标的CMakeLists.txt文件中,我们添加了库其中头文件路径这个属性,使用的是CMAKE_CURRENT_SOURCE_DIR这个变量

也就是说,通过这样的方式设置的头文件路径是在当前这台机器的绝对路径,如果把/home/yht/cmake/blog/my_lib/include这个本地绝对路径,一起导出到安装后的TextTargets.cmake里,下游用户find_package导入时,他的电脑上根本不存在这个路径,头文件必然找不到

问题的核心矛盾

同一个头文件路径,在两个场景下必须是不同的值:

  • 本项目构建时:必须用本地源码的绝对路径,否则自己编译找不到头文件
  • 导出安装后:必须用相对于安装前缀的相对路径,否则下游用户用不了

生成器表达式,就是专门解决这个问题的

(2)基本语法

先回忆CMake的三个执行过程:

阶段执行时机做什么哪些语法在这个阶段执行
配置阶段(Configure)执行cmake -S . -B build时解析 CMakeLists、展开变量、执行if/set/function逻辑、缓存配置普通变量${}、if()、message()、大部分命令
生成阶段(Generate)配置完成后,生成构建文件前根据配置生成最终的 Makefile/VS 工程,计算所有延迟求值的内容生成器表达式
构建阶段(Build)执行make/cmake --build时编译器实际编译链接代码编译器、链接器执行

生成器表达式的名字就来自这里:它只在「生成阶段」才会被计算求值,配置阶段它就是一串普通文本,不会被解析

生成器表达式是 CMake 的「延迟求值语法」:

$<关键字:参数>

现在我们要解决这个问题,就可以使用下面两个关键字:

表达式什么时候生效
$<BUILD_INTERFACE:路径>在本工程(构建树)里使用 target 时
$<INSTALL_INTERFACE:路径>安装的时候(路径相对安装前缀)

a.条件布尔类

用来实现「满足条件时才生效某个值」,格式$<条件:生效内容>,条件为真时返回内容,为假时返回空。

表达式含义
$<0:...>永远为假,内容始终丢弃
$<1:...>永远为真,内容始终保留
$<CONFIG:Debug>当前构建类型是 Debug 时为真
$<CONFIG:Release>当前构建类型是 Release 时为真
$<CXX_COMPILER_ID:GNU>C++ 编译器是 GCC 时为真
$<PLATFORM_ID:Linux>目标平台是 Linux 时为真

最经典的用法:Debug 加调试选项

target_compile_options(MyMath PRIVATE $<$<CONFIG:Debug>:-g -O0> $<$<CONFIG:Release>:-O3> )

注意这里是嵌套写法:外层$<条件:内容>,内层条件也是一个表达式。Debug 配置下最终展开为-g -O0,Release 下展开为-O3


b.逻辑运算类

用于组合多个条件:

表达式含义
$<AND:a,b>逻辑与,a 和 b 都为真时结果为 1
$<OR:a,b>逻辑或,任一为真时结果为 1
$<NOT:a>逻辑非

示例:Linux + Debug 才生效

$<$<AND:$<PLATFORM_ID:Linux>,$<CONFIG:Debug>>:-fsanitize=address>

c. 目标信息查询类

在生成阶段获取目标的真实路径、属性,常用于复制文件、自定义命令:

表格

表达式含义
$<TARGET_FILE:目标名>目标输出文件的完整绝对路径(如libMyMath.a的完整路径)
$<TARGET_FILE_DIR:目标名>目标输出文件所在的目录
$<TARGET_NAME>当前目标的名称

三.包配置文件:支持 find_package

现在我们作为库的制作方,如下图是我们的目录结构

其中的CMakeLists.txt的实现:

执行以后是这样的结果:

现在身份切换——变成库的使用方

现在来执行一下:

这是怎么回事,没关系,我们来一步步学习

1.find_package()

(1)基本语法

find_package(<包名> [最低版本] [REQUIRED] [mod])
  • <包名>:你要找的包名,CMake 会拿这个名字按固定规则拼搜索的文件名
  • <最低版本>:你需要的最低版本号。写了之后,CMake 找包的同时会做版本兼容性检查,低的包直接跳过
  • REQUIRED:找不到包就直接报错终止配置;不写的话找不到就静默继续往下走
  • mode:指定执行模式:MODULE和CONFIG

(2)两种执行模式

find_package(<Pkg>)本质上是让 CMake 去磁盘上找一个 “描述这个包在哪、怎么用” 的.cmake文件并执行它。根据找的是谁写的.cmake文件,分成两种模式

表格

维度Module 模式Config 模式
找的文件Find<Pkg>.cmake<Pkg>Config.cmake或<pkg>-config.cmake
谁写的CMake 官方 / 用户 / 第三方贡献者包作者自己在安装包时生成
典型命名FindBoost.cmakeBoostConfig.cmake/boost-config.cmake
输出变量<Pkg>_FOUND、<Pkg>_INCLUDE_DIRS、<Pkg>_LIBRARIES同样有变量,但主推 imported target(如Boost::boost)
可靠性一般,靠外部模块猜测包结构高,作者最懂自己的包
是否处理传递依赖通常不处理通常会(Config 文件里写好了)
找到后做什么include()那个模块文件include()那个 Config 文件

a.Module 模式

查找逻辑

CMake 按顺序找一个叫Find<Pkg>.cmake的文件:

  1. 用户自己写的:CMAKE_MODULE_PATH里列出的目录
  2. CMake 自带的:<cmake-install>/share/cmake-<ver>/Modules/Find<Pkg>.cmake

它长什么样

以FindOpenSSL.cmake为例,内部就是干三件事:

  1. find_path(OpenSSL_INCLUDE_DIR openssl/ssl.h)— 找头文件
  2. find_library(OpenSSL_LIBRARY ssl)— 找库文件
  3. include(FindPackageHandleStandardArgs)→ 统一设置变量OpenSSL_FOUND、OpenSSL_INCLUDE_DIRS、OpenSSL_LIBRARIES

特点

  • 包作者不参与。这个模块是 CMake 社区写的,作者只负责把包装到标准位置,模块去 “猜” 它在哪。
  • 只给你一堆变量(OpenSSL_LIBRARIES),你得自己写target_link_libraries(mytarget ${OpenSSL_LIBRARIES})。
  • 包升级了路径变了,模块可能就失效,需要 CMake 官方更新

b.Config 模式

查找逻辑

找<Pkg>Config.cmake(或小写<pkg>-config.cmake)。搜索路径比 Module 模式复杂得多:

  1. <Pkg>_DIR变量直接指定的目录
  2. CMAKE_PREFIX_PATH里每个路径下的/lib/cmake/<Pkg>/、/share/<Pkg>/等
  3. 包安装时自己的前缀(CMAKE_INSTALL_PREFIX)
  4. 系统默认路径:/usr、/usr/local、Windows 注册表、macOS 的/Library/...
  5. 环境变量<Pkg>_ROOT

要做什么
它至少要做三件事:

  1. include<Pkg>Targets.cmake;
  2. 检查依赖(如果依赖别的包);
  3. 调用check_required_components。

特点

  • 包作者亲自写的(或安装脚本生成的),最清楚自己包的头文件、库文件、依赖关系
  • 直接提供imported target:find_package(Boost)之后你写target_link_libraries(mytarget Boost::filesystem)就行,include 目录、编译选项、传递依赖全自动带过来
  • 会处理传递依赖:比如你 find_package (OpenCV),Config 文件里会顺手把它依赖的 Eigen、FFMPEG 也找齐

CMake先按 Module 模式找Find<Pkg>.cmake;
如果没找到,再按 Config 模式找<Pkg>Config.cmake

2.configure_package_config_file()

configure_package_config_file()这个函数就是专门来创建Config.cmake`文件的,并且自动更换为相对路径

configure_package_config_file( <.cmake.in输入模板> # 参数1:输入模板文件的路径 <Config.cmake文件在哪输出> # 参数2:生成出来的最终Config文件放哪 INSTALL_DESTINATION <最终安装到哪> # 参数3:这个Config文件最终install到哪个目录 )

这个.cmake.in就是一个普通文本文件

它里面其实只写三件事:

@PACKAGE_INIT@ # ① 到对方机器上先算出安装前缀 include("${CMAKE_CURRENT_LIST_DIR}/DemoTargets.cmake") # ② 把我的 target 清单拿进来 find_dependency(Threads) # ③ 我依赖谁,替对方也找一下 check_required_components(Demo) # ④ 检查组件

一个类比:.cmake.in是带空格的快递单模板 → configure_package_config_file 是打印机 → DemoConfig.cmake 是打印出来的单子 → find_package 是收件人拿着单子去取货。模板本身不会被寄出去

注意:使用configure_package_config_file()要include(CMakePackageConfigHelpers)


3.代码展示

(1)库的发布方


(2)库的使用方


四.打包分发:CPack

现在有个新问题,安装树是一堆零散的文件,我怎么发布给别人呢???

这就需要打包这个功能——CPack
简单来说,CPack工作逻辑就两步:

  1. 读取你 CMakeLists.txt 里写的所有install()规则
  2. 在一个系统临时空目录里,把你写的 install 规则完整执行一遍,最后把这个临时目录里的所有文件打成你要的包格式

基本用法

在CMakeLists.txt最后(所有install之后)加:

set(<参数> <值>) include(CPack)

include(CPack)本身没有参数,它的行为由CPACK_*` 变量控制:

变量含义常用值
CPACK_GENERATOR包格式ZIP、TGZ、DEB、RPM、NSIS
CPACK_PACKAGE_NAME包名demo
CPACK_PACKAGE_VERSION版本${PROJECT_VERSION}
CPACK_PACKAGE_VENDOR厂商—
CPACK_PACKAGE_DESCRIPTION_SUMMARY描述—
CPACK_PACKAGE_INSTALL_DIRECTORY解压后的顶层目录名默认包名
CPACK_SOURCE_GENERATOR源码包格式TGZ
CPACK_COMPONENTS_ALL组件列表配合install(... COMPONENT ...)

现在我想把我刚刚构建好的库打包,我可以这样:


结尾

OK了,这一期的CMake学习就到这里了,我们下期继续。如果对你的学习有所帮助,感谢你的点赞、收藏、关注,我主页里有更好康的呦!!

往期回顾

1.【CMake】第2期 详解 CMake 多目录工程、变量作用域与核心语法
2.【CMake】第1期 从 CMake 初识到库的构建
3. 【MySQL】第4期 用户管理与 C 语言连接编程详解