CMake 工程指南 - 工程场景(3)

本文基于实战场景,从最简单的可执行文件构建出发,逐步拆解 CMake 核心指令与变量,让你彻底理解「从源码到可执行程序」的完整流程。

📌 target_include_directories() ------ CMake 头文件管理的核心 API


target_include_directories 是 CMake 中最基础、最重要的头文件管理 API。

它的核心作用是:为指定的目标(Target)添加头文件搜索路径(Include Path)

在官方文档中,它的基本形式如下:

bash 复制代码
target_include_directories(<target>
    [SYSTEM] [BEFORE]
    <INTERFACE|PUBLIC|PRIVATE> [items1...]
    [<INTERFACE|PUBLIC|PRIVATE> [items2...] ...])
  • <target> :目标名,可以是 add_executable 创建的可执行目标,也可以是 add_library 创建的库目标。
  • INTERFACE/PUBLIC/PRIVATE属性传递的核心关键字,用于控制头文件路径是否传递给依赖者。
  • [items...]:具体的头文件路径(可以是绝对路径,也可以是相对路径,通常相对于当前源码目录)。

这个函数并不是凭空生成 -I 参数,而是在操作目标的属性(Property)

关于属性上一篇我们应该是有比较清晰的认知了,下面走一遍流程:

1. 写入属性

当我们调用:

bash 复制代码
target_include_directories(MyMath PUBLIC include)

CMake 内部会自动将 include 这个路径,写入到目标 MyMath 的属性列表中。对应底层属性:

  • INTERFACE_INCLUDE_DIRECTORIES :存储通过 PUBLICINTERFACE 设置的路径。
  • BUILD_INTERFACE :存储在构建树内的路径(如 ${CMAKE_CURRENT_BINARY_DIR})。

2. 传递机制(触发点)

属性传递发生在建立依赖的那一刻。当在可执行目标中链接库时:

bash 复制代码
target_link_libraries(main PRIVATE MyMath)

CMake 会自动检测 MyMathINTERFACE_INCLUDE_DIRECTORIES 属性,并将其传递main 目标。

3. 最终生成

在构建阶段,CMake 会遍历所有目标的属性,生成对应的编译器参数。例如,main 目标拿到了 MyMath 的头文件路径后,gcc 编译时会自动添加:

复制代码
-I /path/to/my_lib/include

实战案例:jsoncpp 中的落地用法

下面根据具体的 jsoncpp 案例,我们看这个函数在真实项目中是如何配合导入目标(Imported Target)属性传递 工作的。

场景:引入系统安装好的 jsoncpp

假设 jsoncpp 已经安装到了 /usr/local,我们需要在项目中使用它。

1. 找到并创建导入目标(Imported Target) 首先,我们用 find_package 找到它,本质上是在内部创建了一个导入目标 JsonCpp::JsonCpp

2. 配置接口属性(关键步骤)jsoncpp 的配置脚本中,它内部是这样写的:

bash 复制代码
# 创建一个接口库目标
add_library(JsonCpp::JsonCpp INTERFACE IMPORTED)

# 设置接口属性:告诉使用者头文件在哪里
set_target_properties(JsonCpp::JsonCpp PROPERTIES
    INTERFACE_INCLUDE_DIRECTORIES "/usr/local/include"
)

注:target_include_directories(JsonCpp::JsonCpp INTERFACE "/usr/local/include") 效果是一样的。

3. 链接与属性传递

我们在自己的项目中进行:

bash 复制代码
target_link_libraries(my_app PRIVATE JsonCpp::JsonCpp)

发生了什么?

  • 因为 JsonCpp::JsonCppINTERFACE 库,它本身不编译。
  • 但它设置了 INTERFACE_INCLUDE_DIRECTORIES
  • my_app 链接它时,属性传递机制 自动把 /usr/local/include 传给 my_app
  • 编译器就能找到 json/json.h 了。

这个函数彻底替代了传统的 include_directories(全局变量),实现了模块化、作用域清晰 的现代构建规范。

我们后续编写如果可以,我们是更推荐现代写法,也就是 TARGET* 这一类的设置目标属性,追求最小范围原则!


理解了 target_include_directories 如何管理头文件路径和属性传递后,我们发现链接库 的行为是整个机制的触发器。下面,我们将深入讲解 target_link_libraries(),看它是如何建立依赖关系、如何触发属性传递、以及如何将静态库 / 动态库 / 导入库真正连接到我们的项目中的。

在上一节我们深入讲解了 target_include_directories() 如何管理头文件路径,而真正触发属性传递 、建立依赖关系 的核心 API,正是 target_link_libraries()。它不仅是 "链接库" 的命令,更是现代 CMake 构建系统中依赖传播与配置传递的中枢。


target_link_libraries() 的核心作用有两个:

  1. 设置目标的依赖库列表 :告诉 CMake 当前目标需要链接哪些库,最终在编译器参数中以 -l 形式体现。
  2. 建立依赖传播链路 :让被依赖目标的 INTERFACE/PUBLIC 属性(如头文件路径、链接选项、宏定义等),沿着依赖链自动传递给当前目标。

它的基本形式如下:

bash 复制代码
target_link_libraries(<target>
    <INTERFACE|PUBLIC|PRIVATE> [item...]
    [<INTERFACE|PUBLIC|PRIVATE> [item...] ...]
)
  • <target>:要配置的目标(可执行文件或库)。
  • <INTERFACE|PUBLIC|PRIVATE> :控制依赖与属性的传递范围,和 target_include_directories() 语义完全一致。
  • [item...] :依赖项,可以是库目标名、导入目标名(如 CURL::libcurl)或系统库名(如 pthread)。

底层原理:target_link_libraries() 本质是在操作目标的 LINK_LIBRARIESINTERFACE_LINK_LIBRARIES 两个向量,把依赖库名和属性写入其中。


我们下面看看完整流程:从库发布到项目使用(以 CURL 为例)

我们来拆解 target_link_libraries() 触发属性传递的完整生命周期:

第一步:库发布者 ------ 定义导入目标与传播属性

库作者在发布时,会通过 Config.cmake 脚本创建导入目标(Imported Target) ,并设置 INTERFACE 属性,告诉使用者 "你用我时必须满足这些要求":

bash 复制代码
# 创建导入目标:CURL::libcurl_static
add_library(CURL::libcurl_static STATIC IMPORTED)

# 设置接口属性:告诉使用者需要链接的依赖、头文件路径、宏定义
set_target_properties(CURL::libcurl_static PROPERTIES
  INTERFACE_COMPILE_DEFINITIONS "CURL_STATICLIB"
  INTERFACE_INCLUDE_DIRECTORIES "${_IMPORT_PREFIX}/include"
  INTERFACE_LINK_LIBRARIES "\$<LINK_ONLY:OpenSSL::Crypto>;\$<LINK_ONLY:ZLIB::ZLIB>;..."
)
  • INTERFACE_LINK_LIBRARIES:声明 "用我必须同时链接 OpenSSL、ZLIB 等库"。
  • INTERFACE_INCLUDE_DIRECTORIES:声明 "用我必须把我的头文件路径加进去"。

第二步:库使用者 ------ 建立依赖链路

在我们自己项目中,通过 find_package(CURL) 找到导入目标后,用 target_link_libraries() 建立依赖:

bash 复制代码
add_executable(main main.cpp)
target_link_libraries(main PRIVATE CURL::libcurl_static)

这一行就是属性传递的触发点 :CMake 检测到 main 依赖 CURL::libcurl_static,开始递归收集所有 INTERFACE 属性。

第三步:CMake 计算完整依赖与属性

CMake 会遍历整个依赖链,递归收集所有 INTERFACE 属性:

  1. CURL::libcurl_static 拿到 INTERFACE_INCLUDE_DIRECTORIESINTERFACE_LINK_LIBRARIES
  2. 继续从 OpenSSL::CryptoZLIB::ZLIB 等依赖中,收集它们的 INTERFACE 属性。
  3. 把所有属性合并到 main 目标的属性列表中。

第四步:生成最终编译 / 链接参数

CMake 将 main 目标的所有属性,翻译成 gcc 命令行参数:

  • 头文件路径:-I/usr/local/include
  • 链接库:-L/usr/local/lib -lcurl -lcrypto -lz
  • 宏定义:-DCURL_STATICLIB

下面我们实战对比:PRIVATE vs PUBLIC 的行为差异

我们用一个简单例子直观感受传递效果:

场景 1:库 addPRIVATE 链接 pthread

bash 复制代码
add_library(add STATIC add.cpp)
target_link_libraries(add PRIVATE pthread)

add_executable(main main.cpp)
target_link_libraries(main PRIVATE add)
  • add 自己链接 pthread,但 INTERFACE_LINK_LIBRARIES 为空。
  • main 链接 add 时,不会自动链接 pthread

场景 2:库 addPUBLIC 链接 pthread

bash 复制代码
add_library(add STATIC add.cpp)
target_link_libraries(add PUBLIC pthread)

add_executable(main main.cpp)
target_link_libraries(main PRIVATE add)
  • add 自己链接 pthread,同时把 pthread 写入 INTERFACE_LINK_LIBRARIES
  • main 链接 add 时,自动继承 pthread 依赖 ,CMake 会在 main 的链接参数里加上 -lpthread

📌 CMake 从 Install 到 Export:库的安装与标准化导出

到这里,我们已经成功创建并配置好了库目标(Target),也能够正常编译生成库文件与可执行程序。

所谓库目标 ,就是我们通过 add_library 定义的编译单元,它包含了库文件、头文件路径、编译选项、依赖关系等所有信息,是 CMake 管理项目的核心单元。

接下来,我们需要学习如何将这些文件 安装(Install)到系统目录中,方便其他项目调用。

一、基础 Install:最朴素的文件安装

按照最直观的理解,install 的作用似乎很简单:将编译好的头文件、库文件、可执行文件复制到系统指定目录 ,比如 /usr/local/include/usr/local/lib 等位置。这是最基础、最朴素的安装方式,也是我们最先想到的做法。

典型的基础 install 写法如下:

bash 复制代码
# 安装库文件与可执行文件
install(TARGETS MyLib
  ARCHIVE DESTINATION lib
  LIBRARY DESTINATION lib
  RUNTIME DESTINATION bin
)

# 安装头文件
install(DIRECTORY include/
  DESTINATION include
)
  • install:CMake 安装指令,负责将编译结果复制到指定安装目录。
  • TARGETS :表示要安装的是目标(Target) ,也就是我们用 add_library 创建的库。
  • MyLib:我们定义的库目标名称。
  • ARCHIVE DESTINATION lib静态库.a/.lib)安装到 lib 目录。
  • LIBRARY DESTINATION lib动态库.so/.dylib)安装到 lib 目录。
  • RUNTIME DESTINATION bin可执行程序 (如果有)安装到 bin 目录。
  • DIRECTORY :表示安装的是整个目录,而不是单个文件。
  • include/ :源项目中存放公共头文件的目录(末尾 / 表示只复制目录内容)。
  • DESTINATION include :将头文件安装到系统目录的 include 文件夹。

我们首先使用 install(TARGETS) 指令安装项目构建出的库文件与可执行文件,通过 ARCHIVELIBRARYRUNTIME 三个关键字分别指定静态库、动态库、可执行程序的安装位置,将它们统一安装到系统路径下的 lib 目录与 bin 目录。随后,使用 install(DIRECTORY) 指令将项目中的头文件目录 include 整体复制并安装到系统路径的 include 目录中,完成头文件的部署。通过这两步基础安装操作,我们将库的二进制文件和头文件分别放置到系统标准目录,实现了最基础的文件级安装。

上面这段 install 代码所做的事情,就是把文件安装到安装树 中。

安装树 = 安装到系统里的完整目录结构 包括:

  • lib
  • bin
  • include
  • (未来还有 cmake 配置目录)

它和我们编译时的构建树(build 目录)是完全分开的。

CMake 的默认安装前缀就是:

bash 复制代码
CMAKE_INSTALL_PREFIX = /usr/local

我们可以执自行更改的!

执行 make install 后,文件确实会被复制到对应的系统目录。看起来,我们已经完成了 "安装" 这件事。

二、普通 Install 的不足:看似完成,实则不够用

但是,仅仅这样安装,并不足以让库真正被 CMake 项目标准化使用

这种方式存在明显的短板:

  • 只搬运了文件,没有给 CMake 提供任何 "识别信息"
  • 其他项目无法自动找到库的位置
  • 无法自动处理库的依赖关系
  • 无法使用现代 CMake 标准的 find_package 方式
  • 用户必须手动填写头文件路径、库路径,繁琐且易出错

换句话说:**你把库装进了系统,但 CMake 不认识它。**这就是普通 install 最大的局限。

如果只做了基础 install,没有任何导出配置,用户必须在自己的 CMake 里手写以下所有内容

bash 复制代码
# 手动找头文件路径
include_directories(/usr/local/include)

# 手动指定库文件路径
link_directories(/usr/local/lib)

# 手动链接库名字
target_link_libraries(myapp MyLib)

虽然 /usr/local/lib 和 /usr/local/include 属于系统默认搜索路径,使得库文件与头文件可以被编译器间接找到,但这并不意味着 CMake 能够自动识别这个库。普通的 install 操作仅仅完成了文件的拷贝,没有为库提供任何 CMake 可识别的配置信息,因此 find_package 无法找到该库,CMake 也无法自动获取其版本、依赖、编译选项等关键信息。用户依然需要手动填写链接参数,这既不规范也不优雅,更不符合现代 CMake 的设计标准。要让库真正成为 CMake 可识别的标准包,就必须通过 export(导出目标) 生成配置文件,让 CMake 真正 "认识" 这个库。

三、Export 登场:让 CMake 认识你的库

为了解决上面的问题,我们需要引入一个关键概念:导出目标(Export Targets)

Export 的核心作用只有一句话:把库目标的完整信息(位置、头文件路径、依赖、编译选项)保存成 CMake 可以读取的配置文件。

所以库目标 是 CMake 在构建过程中内部维护的逻辑对象,仅存在于构建系统中,不对应独立的配置文件;而导出目标 则是将库目标的完整信息生成并保存为 .cmake 格式的配置文件,二者最核心的差别,就是这套用于被外部项目识别的 CMake 配置文件 ------ 没有它,库目标只能在当前项目使用,有了它,才能成为可被 find_package 识别和引用的标准库。

这些配置文件会告诉外部项目:

  • 库在哪里
  • 头文件在哪里
  • 需要链接哪些依赖
  • 如何正确使用这个库

具体是哪 3 个 .cmake 文件?

XXXTargets.cmake(最核心)

XXXConfig.cmake(入口文件)

XXXConfigVersion.cmake(版本文件)

具体来说就是:

  1. MyLibTargets.cmake: 记录库在哪里、头文件路径、依赖、编译选项。→ 由 export 和 install(EXPORT) 生成。

  2. MyLibConfig.cmake: CMake 找库时第一个看的入口文件 。→ 告诉 CMake:去加载 MyLibTargets.cmake。

  3. MyLibConfigVersion.cmake: 记录库的版本号,用于版本匹配检查。→ 告诉 CMake 当前库是 1.0 / 2.0 等。

库目标:只有 .a/.so/.h 文件,没有 cmake 配置。 导出目标:比库目标多了上面 3 个 .cmake 文件。

export 是 CMake 用来把库目标信息生成 .cmake 配置文件 的指令。它生成的文件可以让别的项目直接使用你的库,不需要安装,不需要 find_package,不需要写路径

一句话:export = 把库目标变成可共享的 cmake 配置文件

bash 复制代码
export(EXPORT <export-set>
       [NAMESPACE <namespace>]
       [FILE <filename>]
       [EXPORT_PACKAGE_DEPENDENCIES]
)

  1. EXPORT <export-set> 含义:指定从哪个 "导出集合" 里取库目标(这是必须写的,不能省略!)

那什么是导出集合(export set)?

导出集合 = 一个存放库目标的 "名单 / 盒子", 它是在 install(TARGETS) 时创建的:

bash 复制代码
install(TARGETS MyLib
    EXPORT MyLibTargets   # <-- 这里创建导出集合,名字叫 MyLibTargets
)

所以:

  • EXPORT MyLibTargets意思:从 MyLibTargets 这个导出集合里取出库目标

注意:export 不能直接操作库,只能从导出集合拿库!


  1. NAMESPACE <namespace> 含义:给导出的库加一个前缀(命名空间) (可选,但强烈推荐写,防止库名冲突)
bash 复制代码
NAMESPACE MyLib::

效果:别人使用时不再写 MyLib,而是写 MyLib::MyLib,更标准、更安全。


  1. FILE <filename> 含义:指定生成的 .cmake 文件保存路径(必须写)

通常写在构建目录(build 文件夹):

bash 复制代码
FILE ${PROJECT_BINARY_DIR}/MyLibTargets.cmake

生成路径:

bash 复制代码
你的项目/build/MyLibTargets.cmake

  1. EXPORT_PACKAGE_DEPENDENCIES 含义:自动导出库的依赖(可选)

如果你的库依赖 pthread、OpenCV 等,加这个选项,使用方不需要再手动链接依赖,cmake 自动帮你处理


我们看看具体实例:

bash 复制代码
export(
    EXPORT MyLibTargets            # 从导出集合取目标
    NAMESPACE MyLib::              # 命名空间
    FILE ${PROJECT_BINARY_DIR}/MyLibTargets.cmake  # 生成文件路径
    EXPORT_PACKAGE_DEPENDENCIES    # 自动导出依赖
)

所以 export 指令到底做了什么?

它只做一件事:把 "导出集合" 里的库目标信息,写入 .cmake 文件,让外部项目可以直接使用。

生成的 MyLibTargets.cmake 里记录了:

  • 库路径
  • 头文件路径
  • 编译选项
  • 依赖库
  • 版本信息

export 依赖导出集合,没有导出集合,export 无法使用

流程:

  1. install(TARGETS ... EXPORT 集合名) 创建导出集合
  2. export(EXPORT 集合名) 从集合生成 cmake 文件

通过 export 指令,我们可以在构建目录中生成 XXXTargets.cmake,让本地项目可以直接引用,无需安装。(啥意思?看下面!)

假设我们的项目目录:

这是我们 export 出来的

bash 复制代码
MyLib/
├── build/             <-- 构建目录
│   ├── libMyLib.so    <-- 编译出来的库
│   └── MyLibTargets.cmake   <-- ✅ export 生成在这里!
├── include/
└── src/

我们在 CMake 里写:

bash 复制代码
export(EXPORT MyLibTargets
  FILE ${PROJECT_BINARY_DIR}/MyLibTargets.cmake
)

这句话只在 build 目录生成文件不会安装不去 /usr/local和 install 毫无关系

另一个项目想使用它,直接写:

bash 复制代码
include(/home/xxx/MyLib/build/MyLibTargets.cmake)
target_link_libraries(app MyLib::MyLib)

能用!没安装!没去 /usr/local!

这就是:export = 不安装,本地直接用


当我们执行:

bash 复制代码
make install

这些文件才会去到:

bash 复制代码
/usr/local/include
/usr/local/lib
/usr/local/bin

但是我们只 install,不 install (EXPORT),那么 /usr/local 下面没有 cmake 文件夹

bash 复制代码
/usr/local/
├── include/
├── lib/
│   └── libMyLib.so    <-- 只有库,没有cmake文件
└── bin/

❌ 所以别人写:

bash 复制代码
find_package(MyLib)

找不到!因为 没有 MyLibConfig.cmake

那我们如果进行了 install EXPORT 呢?

bash 复制代码
install(EXPORT MyLibTargets
  DESTINATION lib/cmake/MyLib
)

make install

现在 /usr/local 变成:

bash 复制代码
/usr/local/
├── bin/          # 只有可执行文件!比如 mytool、myapp
├── include/      # 头文件
└── lib/          # 静态库 + 动态库 都在这里!
    ├── libMyLib.a      # 静态库 ✔
    ├── libMyLib.so     # 动态库 ✔
    └── lib/cmake/MyLib/
        ├── MyLibTargets.cmake
        ├── MyLibConfig.cmake
        └── MyLibConfigVersion.cmake

✅ 别人现在可以:

复制代码
find_package(MyLib)
target_link_libraries(app MyLib::MyLib)

四、Install (EXPORT):真正完整、规范的库发布

install 指令真正强大的地方,也正在于它对 EXPORT 的支持。它不仅能安装文件,还能将导出的配置文件一起安装,让库变成一个标准可被发现的 CMake 包

install (TARGETS) 【安装库目标】

bash 复制代码
install(TARGETS <target1> <target2> ...  # 【必填】指定要安装的库目标/可执行目标(可写多个)
        [EXPORT <export-set>]            # 【关键】创建导出集合,把目标登记进去,供export/install(EXPORT)使用
        
        [ARCHIVE DESTINATION <dir>]      # 安装静态库(.a/.lib)到指定目录,默认前缀/usr/local/lib
        [LIBRARY DESTINATION <dir>]      # 安装动态库(.so/.dylib)到指定目录,默认前缀/usr/local/lib
        [RUNTIME DESTINATION <dir>]      # 安装可执行文件到指定目录,默认前缀/usr/local/bin
        
        [PUBLIC_HEADER DESTINATION <dir>] # 安装对外公开的头文件(.h),默认前缀/usr/local/include
        [PRIVATE_HEADER DESTINATION <dir>] # 安装内部私有头文件,一般不对外暴露
        
        [CONFIGURATIONS [Debug|Release|...]] # 指定仅在Debug/Release模式下执行安装
        [PERMISSIONS permissions...]     # 设置安装后文件的权限(如只读、可执行)
)

install (EXPORT) 【安装 cmake 配置文件】

bash 复制代码
install(EXPORT <export-set>            # 【必填】指定要安装的【导出集合】(必须和之前install(TARGETS)定义的名字一致)
        FILE <filename>.cmake          # 【必填】指定生成的配置文件名(通常为 库名Targets.cmake)
        NAMESPACE <namespace>::        # 【可选】给库目标加命名空间前缀(如MyLib::),防止命名冲突
        DESTINATION <dir>              # 【必填】指定.cmake配置文件的安装路径(标准路径:lib/cmake/库名)
        CONFIGURATIONS [Debug|Release...] # 【可选】指定仅对Debug/Release版本执行安装
)

完整流程分为三步:

  1. 将目标关联到导出集合(桥梁)
bash 复制代码
install(TARGETS MyLib
  EXPORT MyLibTargets
  ARCHIVE DESTINATION lib
  LIBRARY DESTINATION lib
  RUNTIME DESTINATION bin
  PUBLIC_HEADER DESTINATION include
)
  1. 导出配置文件到构建目录(开发用)
bash 复制代码
export(EXPORT MyLibTargets
  NAMESPACE MyLib::
  FILE ${PROJECT_BINARY_DIR}/MyLibTargets.cmake
)
  1. 安装导出配置到系统(发布用)
bash 复制代码
install(EXPORT MyLibTargets
  FILE MyLibTargets.cmake
  NAMESPACE MyLib::
  DESTINATION lib/cmake/MyLib
)

安装完成后,在系统目录中会出现:

bash 复制代码
lib/cmake/MyLib/
├── MyLibTargets.cmake
├── MyLibConfig.cmake
└── MyLibConfigVersion.cmake

这时,其他项目只需要一行:

bash 复制代码
find_package(MyLib REQUIRED)
target_link_libraries(app PRIVATE MyLib::MyLib)

CMake 就会自动找到并配置我们的库。

这就是现代 CMake 库开发从安装到导出的完整、规范流程。

相关推荐
历程里程碑1 小时前
39. 从零实现UDP服务器实战(带源码) V1版本 - Echo server
服务器·开发语言·网络·c++·网络协议·udp·php
add45a1 小时前
C++与自动驾驶系统
开发语言·c++·算法
&星痕&1 小时前
从零开始手搓 (1)计算图 (c++,python语言实现)
c++·python·深度学习·机器学习
TsukasaNZ2 小时前
C++中的命令模式
开发语言·c++·算法
superkcl20222 小时前
指针常量有什么用呢?
开发语言·c++·算法
Yungoal2 小时前
C++基础语法3
开发语言·c++
今儿敲了吗2 小时前
python基础学习笔记第四章
c++·笔记·python·学习
無限進步D2 小时前
差分算法 cpp
c++·算法·蓝桥杯·竞赛
biter down2 小时前
C++ 精准控制对象的创建位置(堆 / 栈)
开发语言·c++