开头
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/<项目名>
- Linux / macOS:
- 修改方式:配置阶段指定
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_MANDIR | man 手册目录 | 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 DESTINATION | Linux/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 代码,核心做两件事:
- 创建导入目标
通过add_library创建一个IMPORTED(导入型)目标,代表这是一个 “已经编译好、从外部导入的库”:
add_library(demo::math STATIC IMPORTED)- 写入所有使用属性
通过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 的时候:
- cmake 配置一次(这时候
export(EXPORT)就已经把 Targets.cmake 生成在 build 目录了) - 改完代码执行编译
- 旁边那个独立的测试工程,配置的时候只要把
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.cmake | BoostConfig.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的文件:
- 用户自己写的:
CMAKE_MODULE_PATH里列出的目录 - CMake 自带的:
<cmake-install>/share/cmake-<ver>/Modules/Find<Pkg>.cmake
它长什么样
以FindOpenSSL.cmake为例,内部就是干三件事:
find_path(OpenSSL_INCLUDE_DIR openssl/ssl.h)— 找头文件find_library(OpenSSL_LIBRARY ssl)— 找库文件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 模式复杂得多:
<Pkg>_DIR变量直接指定的目录CMAKE_PREFIX_PATH里每个路径下的/lib/cmake/<Pkg>/、/share/<Pkg>/等- 包安装时自己的前缀(
CMAKE_INSTALL_PREFIX) - 系统默认路径:
/usr、/usr/local、Windows 注册表、macOS 的/Library/... - 环境变量
<Pkg>_ROOT
要做什么
它至少要做三件事:
- include
<Pkg>Targets.cmake; - 检查依赖(如果依赖别的包);
- 调用
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工作逻辑就两步:
- 读取你 CMakeLists.txt 里写的所有
install()规则 - 在一个系统临时空目录里,把你写的 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 语言连接编程详解