22. 6.2 导出目标与配置包

引言:从“交钥匙”到“出图纸”

在上一节(6.1)中,我们学会了如何把编译好的可执行文件、库、头文件和配置文件,整整齐齐地“搬进”系统的安装目录。这就像是建筑工程的交付仪式:钥匙已经交到业主手里,房间打扫干净了。但问题来了——如果另一位开发商(另一个 CMake 项目)想在隔壁盖一栋楼,想直接利用你这栋楼的地基和钢结构(你的库),他该怎么找到你?

如果仅仅是把 .a 或 .so 文件Copy过去,对方项目还得手动写 include_directorieslink_libraries,甚至要搞清楚你的依赖链里还藏着哪些第三方库。这显然违背了 Modern CMake “基于目标(Target)”的哲学。

正确的做法是:在交付建筑实体的同时,交付一套完整的“建筑图纸和使用说明书”。这套文件能让对方的 CMake 通过一句 find_package(MyLib REQUIRED) 就自动找回你的目标、头文件路径、依赖库和编译选项。这一节,我们就要学会制作这套图纸——导出目标(Export Targets)配置包(Config Package)

install(EXPORT …):生成目标的“身份档案”

当你用 install(TARGETS) 把库文件安放到系统目录时,CMake 其实只搬运了“实体”(二进制文件)。但目标本身携带的诸多属性——比如头文件搜索路径、链接依赖、宏定义——这些“灵魂信息”还停留在你的构建树里。你需要一份导出文件,把这些目标的完整定义誊写成 CMake 能读懂的脚本。

这份导出文件的生成,靠的就是 install(EXPORT ...) 指令。

基本写法

# 假设你定义了一个库目标
add_library(mylib SHARED src/mylib.cpp)
target_include_directories(mylib PUBLIC
    <BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
    <INSTALL_INTERFACE:include>  # 安装后使用的路径
)

# 1. 先为目标注册到导出集(EXPORT)
install(TARGETS mylib
    EXPORT MyLibTargets          # 注册到名为 MyLibTargets 的导出集
    LIBRARY DESTINATION lib
    ARCHIVE DESTINATION lib
    RUNTIME DESTINATION bin
    INCLUDES DESTINATION include
)

# 2. 安装导出集本身:生成 MyLibTargets.cmake
install(EXPORT MyLibTargets
    FILE MyLibTargets.cmake      # 生成的文件名
    NAMESPACE MyLib::            # 给所有目标加命名空间前缀
    DESTINATION lib/cmake/MyLib  # 安装到哪里
)

上面这段代码会在安装目录的 lib/cmake/MyLib/MyLibTargets.cmake 处生成一个脚本。里面大致是这样的内容:

# 伪代码示意
add_library(MyLib::mylib SHARED IMPORTED)
set_target_properties(MyLib::mylib PROPERTIES
    IMPORTED_LOCATION "${_IMPORT_PREFIX}/lib/libmylib.so"
    INTERFACE_INCLUDE_DIRECTORIES "${_IMPORT_PREFIX}/include"
)

这意味着,只要消费者项目 include() 了这个文件,MyLib::mylib 这个目标就“复活”了,它携带了所有 INTERFACE 属性,消费者只需要 target_link_libraries(consumer PRIVATE MyLib::mylib) 即可。

构建树导出 vs 安装树导出:两把钥匙开两把锁

CMake 其实提供了两种导出场景。很多初学者在这里混淆,导致本地开发能跑,一安装到系统就报错。

场景一:构建树导出(Build Tree Export)—— 开发期自用

假设你的库项目和消费项目在同一个大仓库里,或者你正在开发库本身,想在不执行 make install 的情况下,让外面的项目直接链接你构建目录里的产物。这时你需要构建树导出

export(EXPORT MyLibTargets
    FILE "${CMAKE_CURRENT_BINARY_DIR}/MyLibTargets.cmake"
    NAMESPACE MyLib::
)

这会在你的 build/ 目录下生成 MyLibTargets.cmake。外部项目可以通过 -DMyLib_DIR=/path/to/build 或者直接在 CMAKE_PREFIX_PATH 里加入构建目录来找到它。

注意:构建树导出使用的是绝对路径,因为库还没被安装到标准位置。它只适合你本地开发调试,不适合发布。

场景二:安装树导出(Install Tree Export)—— 正式发布

上一节讲的 install(EXPORT ...) 就是安装树导出。它生成的是相对路径(基于 _IMPORT_PREFIX),因此无论用户把库安装到 /usr/local 还是 /home/user/custom,只要消费者用 find_package 找到了配置目录,路径都能自动解析正确。

最佳实践:两者都要做

一个优秀的 CMake 项目通常会同时提供两种导出:

  • 构建树导出:方便自己或同事在超级构建(Superbuild)中直接引用。
  • 安装树导出:方便最终用户通过包管理器或手动安装后使用。
# 构建树导出(可选,但强烈推荐)
export(EXPORT MyLibTargets
    FILE "${CMAKE_CURRENT_BINARY_DIR}/MyLibTargets.cmake"
    NAMESPACE MyLib::
)

# 安装树导出(必须,面向最终用户)
install(EXPORT MyLibTargets
    FILE MyLibTargets.cmake
    NAMESPACE MyLib::
    DESTINATION lib/cmake/MyLib
)

创建包配置文件 Config.cmake:给 find_package 的“使用说明书”

光有 MyLibTargets.cmake 还不够。当用户写下 find_package(MyLib REQUIRED) 时,CMake 会按照搜索顺序去找 MyLibConfig.cmake(或 mylib-config.cmake)。这个文件就是包配置文件,相当于图书馆的索引卡片:它告诉 CMake 去哪里加载目标导出文件,以及你的包有哪些公开的依赖需要提前找到。

最简单的手写 Config.cmake

如果你的库没有任何外部依赖,理论上可以直接手写一个极简版:

# MyLibConfig.cmake
include("${CMAKE_CURRENT_LIST_DIR}/MyLibTargets.cmake")

把上面这个文件也安装到 lib/cmake/MyLib/ 就行。但这太理想了。现实中,如果你的库本身依赖了 Boost::filesystemEigen3,并且这些依赖是 PUBLIC 的,那么消费者在链接你的库之前,必须先找到这些依赖。否则目标 MyLib::mylib 身上的 INTERFACE_LINK_LIBRARIES 指向的是未定义的目标,CMake 会直接报错。

处理依赖:find_dependency 宏

CMake 提供了 CMakeFindDependencyMacro 模块,里面封装了 find_dependency 宏。它和 find_package 类似,但如果找不到依赖,会给出更友好的错误信息,并且能把 QUIETREQUIRED 等参数正确透传。

# MyLibConfig.cmake.in(模板文件,注意后缀 .in)
@PACKAGE_INIT@

include(CMakeFindDependencyMacro)

# 列出本库对外公开的所有依赖
find_dependency(Boost 1.70 REQUIRED COMPONENTS filesystem)
find_dependency(Eigen3 3.3 REQUIRED)

# 加载导出的目标
include("${CMAKE_CURRENT_LIST_DIR}/MyLibTargets.cmake")

# 检查必须的组件是否都存在(可选)
check_required_components(MyLib)

这里有几个关键点:

  • @PACKAGE_INIT@:这是一个占位符,后面会用 configure_package_config_file 替换为一组标准初始化宏。暂时可理解为“启用可重定位支持”的开关。
  • find_dependency:只写 PUBLICINTERFACE 依赖。如果是 PRIVATE 依赖(比如只在你库的 .cpp 里用了,头文件没暴露),不需要也不应该写在这里。
  • check_required_components():如果用户使用了 find_package(MyLib REQUIRED COMPONENTS xxx),这个宏负责检查 xxx 是否被满足。

configure_package_config_file:让配置包“走到哪都能用”

如果你直接用传统的 configure_file(MyLibConfig.cmake.in MyLibConfig.cmake) 来处理上面的模板,会有一个隐患:路径被写死为构建机的绝对路径。比如 @CMAKE_INSTALL_PREFIX@ 在打包机上是 /home/ci/workspace/install,但用户安装到了 /opt/mylib,这就彻底失效了。

CMake 的 CMakePackageConfigHelpers 模块提供了一个更聪明的工具:configure_package_config_file。它会生成可重定位(Relocatable)的配置文件,无论包被安装到哪个前缀目录,都能自动推导出正确路径。

完整用法

include(CMakePackageConfigHelpers)

configure_package_config_file(
    "${CMAKE_CURRENT_SOURCE_DIR}/cmake/MyLibConfig.cmake.in"
    "${CMAKE_CURRENT_BINARY_DIR}/MyLibConfig.cmake"
    INSTALL_DESTINATION lib/cmake/MyLib
)

它会读取你的 .in 模板,做两件事:

  1. @PACKAGE_INIT@ 替换为一组宏定义,其中最重要的是设置 PACKAGE_PREFIX_DIR 变量指向安装前缀(即 MyLibConfig.cmake 文件所在位置的 ../../../ 相对推算结果)。
  2. 处理模板里使用 @PACKAGE_XXX_DIR@ 形式的变量,自动计算基于安装前缀的相对路径。

模板中可使用的特殊变量

.in 模板里,除了常规的 @VAR@,还可以使用 @PACKAGE_CMAKE_INSTALL_INCLUDEDIR@ 等由 GNUInstallDirs 定义的路径变量,这些都会被转换成相对于安装前缀的路径。

write_basic_package_version_file:版本兼容性检查

现代 CMake 项目几乎都支持版本约束,比如消费者写:

find_package(MyLib 2.5 REQUIRED)

这就要求你提供一个 ConfigVersion.cmake 文件(也叫 MyLibConfigVersion.cmake),CMake 通过它来快速判断已安装的版本是否符合消费者的要求,而无需启动复杂的探测逻辑。

生成方法

write_basic_package_version_file(
    "${CMAKE_CURRENT_BINARY_DIR}/MyLibConfigVersion.cmake"
    VERSION ${PROJECT_VERSION}          # 例如 2.5.1
    COMPATIBILITY SameMajorVersion      # 兼容性策略
)

COMPATIBILITY 参数非常关键,它决定了什么样的版本被视为“兼容”。常见策略有:

  • AnyNewerVersion:只要已安装版本 ≥ 要求的版本,就兼容。最宽松。
  • SameMajorVersion:主版本号必须一致。例如要求 2.5,安装 2.8 可以通过,但 3.0 不行。这是最常用的语义化版本策略。
  • SameMinorVersion:主次版本都必须一致。要求 2.5,安装 2.6 都不行。
  • ExactVersion:必须完全一致,连补丁号都不能差。极少使用。

生成后,别忘了安装它:

install(FILES
    "${CMAKE_CURRENT_BINARY_DIR}/MyLibConfig.cmake"
    "${CMAKE_CURRENT_BINARY_DIR}/MyLibConfigVersion.cmake"
    DESTINATION lib/cmake/MyLib
)

完整实战:打造支持 find_package 的 C++ 数学库

让我们把前面所有碎片拼起来,看一个完整示例。假设项目叫 MathUtils,版本 1.2.0,它依赖 Eigen3(PUBLIC 依赖,因为头文件暴露了 Eigen 类型)。

目录结构

MathUtils/
├── CMakeLists.txt
├── cmake/
│   └── MathUtilsConfig.cmake.in
├── include/
│   └── mathutils/
│       └── vector_ops.h
└── src/
    └── vector_ops.cpp

CMakeLists.txt

cmake_minimum_required(VERSION 3.15)
project(MathUtils VERSION 1.2.0 LANGUAGES CXX)

# 1. 查找本库自己的依赖(构建时)
find_package(Eigen3 3.3 REQUIRED NO_MODULE)

# 2. 创建库
add_library(mathutils src/vector_ops.cpp)
target_include_directories(mathutils PUBLIC
    <BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
    <INSTALL_INTERFACE:include>
)
target_link_libraries(mathutils PUBLIC Eigen3::Eigen)
target_compile_features(mathutils PUBLIC cxx_std_17)

# 3. 安装规则
include(GNUInstallDirs)

install(TARGETS mathutils
    EXPORT MathUtilsTargets
    LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR}
    ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR}
    RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}
    INCLUDES DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}
)

install(DIRECTORY include/
    DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}
)

# 4. 导出目标(构建树 + 安装树)
export(EXPORT MathUtilsTargets
    FILE "${CMAKE_CURRENT_BINARY_DIR}/MathUtilsTargets.cmake"
    NAMESPACE MathUtils::
)

install(EXPORT MathUtilsTargets
    FILE MathUtilsTargets.cmake
    NAMESPACE MathUtils::
    DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/MathUtils
)

# 5. 生成配置文件
include(CMakePackageConfigHelpers)

configure_package_config_file(
    "${CMAKE_CURRENT_SOURCE_DIR}/cmake/MathUtilsConfig.cmake.in"
    "${CMAKE_CURRENT_BINARY_DIR}/MathUtilsConfig.cmake"
    INSTALL_DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/MathUtils
)

write_basic_package_version_file(
    "${CMAKE_CURRENT_BINARY_DIR}/MathUtilsConfigVersion.cmake"
    VERSION ${PROJECT_VERSION}
    COMPATIBILITY SameMajorVersion
)

# 6. 安装配置文件
install(FILES
    "${CMAKE_CURRENT_BINARY_DIR}/MathUtilsConfig.cmake"
    "${CMAKE_CURRENT_BINARY_DIR}/MathUtilsConfigVersion.cmake"
    DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/MathUtils
)

cmake/MathUtilsConfig.cmake.in 模板

@PACKAGE_INIT@

include(CMakeFindDependencyMacro)

# 公开依赖必须在这里再找一遍
find_dependency(Eigen3 3.3)

# 加载导出的目标
include("${CMAKE_CURRENT_LIST_DIR}/MathUtilsTargets.cmake")

# 如果用户用了 REQUIRED COMPONENTS,这里做最终检查
check_required_components(MathUtils)

消费者的使用方式

安装完成后(比如 cmake --install build --prefix /opt/mathutils),其他项目可以无缝使用:

cmake_minimum_required(VERSION 3.15)
project(Consumer)

find_package(MathUtils 1.2 REQUIRED)

add_executable(app main.cpp)
target_link_libraries(app PRIVATE MathUtils::mathutils)

CMake 会自动处理:

  1. 找到 /opt/mathutils/lib/cmake/MathUtils/MathUtilsConfigVersion.cmake,确认 1.2 ≤ 1.2.0 且主版本兼容。
  2. 执行 MathUtilsConfig.cmake,里面调用 find_dependency(Eigen3),先确保 Eigen3 存在。
  3. 包含 MathUtilsTargets.cmake,恢复 MathUtils::mathutils 目标。
  4. target_link_libraries 不仅链接了 libmathutils.so,还自动继承了 Eigen3 的头文件路径和 C++17 标准设定。

常见问题排雷

Q1: 构建树导出能用,为什么安装后 find_package 报错找不到目标?

通常是因为 install(EXPORT ...) 时指定的 DESTINATIONconfigure_package_config_file 里的 INSTALL_DESTINATION 不一致。CMake 通过后者计算 PACKAGE_PREFIX_DIR,如果对不上号,路径推算就会出错。

Q2: 我的库有 PRIVATE 依赖(比如只用在 .cpp 里的 zlib),需要写进 Config.cmake.in 吗?

不需要。 find_dependency 只应该列出消费者也必须链接的 PUBLICINTERFACE 依赖。PRIVATE 依赖只影响你的库本身编译,不影响消费者的接口。但要注意:如果你生成的是静态库,链接器可能需要解析所有符号,那时 PRIVATE 依赖的库文件也必须能被找到,但路径问题通常由安装时的 RPATH 或消费者环境解决,而不是通过 Config.cmake 暴露。

Q3: NAMESPACE 一定要加吗?

技术上不是强制的,但强烈推荐。加命名空间(如 MyLib::target)可以避免目标名冲突,也明确告诉消费者“这是一个 IMPORTED 目标,不是你的本地目标”。不加命名空间,万一消费者自己也定义了一个叫 mathutils 的目标,就会产生难以调试的冲突。

总结:制作配置包的三件套

要让一个 CMake 项目能够被他人通过 find_package 优雅地使用,你需要准备好以下三件套

  1. 目标导出文件*Targets.cmake):由 install(EXPORT ...) 生成,内含目标的完整属性定义。
  2. 包配置文件*Config.cmake):由 configure_package_config_file 从模板生成,负责查找公开依赖并加载目标导出文件。
  3. 包版本文件*ConfigVersion.cmake):由 write_basic_package_version_file 生成,负责版本兼容性检查。

同时,提供构建树导出export(EXPORT ...))可以极大提升开发效率,让超级构建或本地多项目联调无需频繁执行安装。

掌握了这套流程,你的库就不再是一堆孤零零的二进制文件,而是一个具备“自描述能力”的现代 CMake 包。下一节(6.3),我们将继续深入,学习如何用 CPack 把这些内容打包成 .deb、.rpm 或安装程序,真正做到一键分发。

请登录后发表评论

    没有回复内容

正在唤醒异次元光景……