做这件事的起因很朴素:一个工具集里有两三个可执行文件,它们要共享同一套核心计算逻辑,又都得调用一个开源 JSON 解析库。当时我以为只要在 CMakeLists.txt 里写两行add_executable就万事大吉,结果头文件找不到、链接 stage 各种 undefined reference、动态库运行时加载失败,一个问题接着一个问题。后来我才意识到,多可执行文件和调用第三方库根本不是两件独立的事,它俩被 CMake 的目标传递机制牢牢绑在一起。这篇就当是我整理后的完整记录,适合那种同一代码仓里同时编译多个程序、又离不开外部依赖的工程,尤其是 C++ 项目。
1. 多可执行文件场景下,真正要解决的问题是什么
1.1 可执行文件一多,麻烦的不是“多”,而是“共享”
假设你手头有一个典型工具集:一个服务端程序,一个客户端程序,再加一个数据处理脚本。C++ 工程几乎不会把三个入口拆成三个独立仓库,那样维护成本太高。更常见的做法是放在同一个仓库里,因为它们共用一套核心代码。如果每个可执行文件都复制一份核心代码,改一个 bug 要改三处,测试要测三遍,没人受得了。所以合理的组织就是:把核心逻辑编成一个库,多个可执行文件分别链接这个库。
这种结构到了 CMake 里,就是add_library和多个add_executable的组合。但真正让你崩溃的不是“多”,而是“共享”之后,头文件搜索路径和链接库该怎么传给每一个可执行文件。很多人一开始会手动给每个可执行文件配 include 路径、手动链接第三方库,短期能跑,可一旦新增一个可执行文件,就很容易漏配某个依赖,或者路径写错。我从这件事里得到的教训是:核心逻辑千万别写成扁平的一堆源文件,一定要先抽象成一个库,再让所有可执行文件去依赖它。
1.2 调用第三方库,本质上就三件事
把第三方库用起来,很多人的第一反应是“它有头文件,我 include 进来就行”。这句话只说对三分之一。一个库真正能被你的程序调用,需要满足三件事:
- 头文件路径要可见:编译器得能找到头文件,
#include指令才能展开; - 链接目标要可见:链接器得能找到库文件或库目标,把你引用过的符号解析出来;
- 运行时可加载:如果是动态库,程序运行起来之后,操作系统还需要知道去哪儿加载这个动态库。
这三件事任何一环断了,最后的表现都很像“代码没问题但编译不过 / 链接不过 / 运行闪退”。从 CMake 的角度看,头文件路径、库目录、链接库名,本来相当于你手动传给编译器、链接器的一堆参数。CMake 存在的意义之一,就是把这一堆参数变成目标的属性和依赖关系,让它们自动流转。后面讲实操时我会反复回到这个点,因为几乎所有报错,追根溯源都是这三大件事里的某一环没打通。
2. 思维转变:在 CMake 里,一切皆目标
2.1 别急着写路径,先画一张依赖图
我相信很多人一开始是从“路径”的角度理解 CMake 的:include_directories添加头文件路径,link_directories添加库路径,再用target_link_libraries把库名和可执行文件绑起来。这套思路在老工程里很常见,但它是面向目录的,不面向目标。多个可执行文件一多,全局目录设置经常互相干扰,A 工具能编译,B 工具莫名找不到头文件。
新一点的 CMake 工程建议彻底换一个思路:从目标的角度思考。add_executable产生一个目标,add_library产生一个目标,find_package找到的外部库通常也会包装成一个目标。目标之间通过依赖关系连接,一旦连接成立,头文件路径、编译选项、链接库等属性就可以沿着依赖关系自动传递。这样一来,每个可执行文件根本不需要知道第三方库在哪个目录,它只需要知道“我链接了 core”,core 负责把需要的一切传递下去。
我实际动手前会先画依赖图:节点是各个目标和外部库,边是依赖关系。可执行文件在最上层,核心库在中间,第三方库在底层。线画清楚再回去写 CMakeLists.txt,基本不会出现哪个库忘了配、哪个路径传漏的情况。这个过程很土,但确实比凭记忆写 CMake 高效太多。
2.2 PUBLIC / PRIVATE / INTERFACE 到底是什么
很多教程会把这三个关键字讲得很玄乎,其实用一个生活化的例子就能说清楚。假设你开了一家奶茶店,核心配方是你的库。你对外公开的原料采购渠道是 PUBLIC,因为你希望加盟商用你店里的配方时,能顺着这个渠道买到原料;你在后厨自己用的特殊工具是 PRIVATE,加盟商不需要知道;至于品牌合作宣传册是 INTERFACE,你自己店里不直接用,但加盟商需要拿它去拉客户。
放到 CMake 代码里:
| 关键字 | 对当前目标自己编译 | 对链接当前目标的其他目标 | 典型场景 |
|---|---|---|---|
| PUBLIC | 生效 | 生效 | 头文件被本目标实现使用,也被使用者使用 |
| PRIVATE | 生效 | 不生效 | 内部实现细节,不想暴露给外部 |
| INTERFACE | 不生效 | 生效 | header-only 库,或纯接口库 |
这里最容易犯的错就是把 PRIVATE 当成“链接了但只自己用,别人别碰”。如果你的库的头文件里#include了某个第三方库的头文件,那这个依赖就必须是 PUBLIC,因为使用你的库的人,编译时也要能看见那个第三方库的头文件,否则他 100% 会撞上“头文件找不到”。我之前就摔过这个跟头,core 库内部用了一个第三方头文件,编写时想当然写成 PRIVATE,结果所有链接 core 的可执行文件编译全部失败。所以判断 PUBLIC 还是 PRIVATE,核心就看一个问题:你的下游目标在编译时,需不需要因为你的头文件而间接看见这个依赖。
2.3 为什么我不再建议用全局 include_directories
新手兜底办法永远是include_directories和link_directories全局设置。我不能说绝对不能用,但多可执行文件项目里,强烈不建议。原因很简单:全局设置是给整个目录树下所有目标加属性,你可能只想让核心库看见某个路径,结果所有工具都看见了。一时没出事是运气好,一旦两个第三方库有同名头文件,或者两个库的版本不兼容,这种全局污染会把问题变成一场灾难。
正确做法是使用target_include_directories搭配target_link_libraries传递。前者定义的是目标自己的接口属性,后者定义的是目标之间的传递关系。这样每个目标能看到什么,是由它的依赖关系决定的,而不是由“它在哪个目录下”决定的。这种写法初期会感觉繁琐,但工程一复杂,优势立刻体现:每个新增可执行文件只需要三行——add_executable、target_link_libraries、完事。头文件路径和第三方库依赖全都被核心库封装好了。
3. 第三方库的三种常见形态,处理方式完全不同
3.1 头文件库:最简单,也最容易想当然
头文件库也叫 header-only,比如很多现代 C++ 小库就是直接给你一堆.hpp,编译时把实现全部展开在头文件里,没有独立编译产生的.a或.so。用这种库最省心,本质上只需让编译器能找到头文件即可。
在 CMake 里,如果它提供了 CMake 目标,比如某些库自带xxx::xxx目标,直接find_package然后target_link_libraries就行。如果不提供目标,你手动指定头文件路径也没问题:
add_executable(my_app main.cpp) target_include_directories(my_app PRIVATE /path/to/header_only_lib/include)但注意,这种手动路径方式只能解单体项目的燃眉之急。一旦这个 header-only 库被你的核心库使用,而核心库又被多个可执行文件共享,最好还是用目标传递,否则每个可执行文件都得手动加一次路径。所以即便是 header-only 库,我也建议优先用add_subdirectory或find_package把它包装成目标,而不是直接散装路径。
3.2 预编译的静态库和动态库:链接和运行时是两回事
预编译库一般会提供静态库文件(.a、.lib)或动态库文件(.so、.dll、.dylib),同时伴随头文件。这类库的处理核心是三段式检查:
- 头文件路径:编译时需要;
- 库文件路径与库名:链接时需要;
- 动态库运行时路径:运行时需要。
CMake 里比较标准的做法是使用find_library或find_package。find_library适合库本身没有完整 CMake 配置文件的场景,它会搜索指定路径并返回库文件完整路径:
find_library(MYLIB_LIBRARY NAMES mylib PATHS /path/to/lib) if(NOT MYLIB_LIBRARY) message(FATAL_ERROR "找不到 mylib 库") endif() add_executable(my_app main.cpp) target_link_libraries(my_app PRIVATE ${MYLIB_LIBRARY})如果库作者提供了 CMake 配置文件,推荐find_package拿它导出的目标,这样头文件路径、库文件路径、依赖关系会一起封装好。至于运行时动态库找不到的问题,我这里先说一句:开发阶段可以把动态库所在目录加入环境变量,部署阶段则要考虑 rpath 或安装目录。后面第 5 章专门讲。
3.3 需要源码构建的库:用 add_subdirectory 和 FetchContent
还有一种库需要你从源码参与构建,常见的情况是你想定制编译选项,或者库没提供预编译产物。这时有两种主流方式:
方式一,把源码放进你的工程目录,用add_subdirectory把它加进来,然后直接链接它导出的目标。这种方式管理起来最直接,缺点是你的项目对第三方源码产生了物理依赖,升级时需要手动替换目录内容。
方式二,用 CMake 自带的 FetchContent 模块,配置阶段自动下载源码并构建。典型的用法:
include(FetchContent) FetchContent_Declare( some_lib GIT_REPOSITORY https://example.com/some_lib.git GIT_TAG v1.2.0 ) FetchContent_MakeAvailable(some_lib)之后你就可以拿它生成的目标和项目里的其他目标互联。
两种方式我都有实际经验。源码基于add_subdirectory时要注意,如果第三方库的 CMakeLists 写得比较粗糙,它的全局变量可能污染你的工程;FetchContent 则对网络有一定要求,而且不同版本、不同 CMake 版本下行为有细微差异。我的建议是:优先用find_package找系统预装版本,找不到再用 FetchContent 拉源码,最后才考虑手动复制源码进工程。
4. 实操:完整搭建一个多可执行文件并接入第三方库的项目
4.1 项目结构设计
我以一个虚拟的工具集为例,它叫 utilbox。项目里有两条工具链,共用一个核心库,核心库要调用两个第三方库:用来做 JSON 解析的 header-only 库、用来打日志的日志库。项目文件结构如下:
utilbox/ ├── CMakeLists.txt ├── src/ │ ├── core/ │ │ ├── CMakeLists.txt │ │ ├── include/ │ │ │ └── core/ │ │ │ └── processor.h │ │ └── src/ │ │ └── processor.cpp │ └── tools/ │ ├── CMakeLists.txt │ ├── metric_analyzer/ │ │ └── main.cpp │ └── report_writer/ │ └── main.cpp这个结构的核心思想是:顶层 CMakeLists 只负责全局配置和添加子目录,核心库的 CMakeLists 负责自己以及它所依赖的第三方库,工具层的 CMakeLists 只负责把可执行文件和核心库绑在一起。每一层的职责清晰,出了问题也知道去哪一层找。
4.2 顶层 CMakeLists.txt 的写法
顶层文件不长,但它的作用很关键:
cmake_minimum_required(VERSION 3.16) project(utilbox VERSION 0.1.0 LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) option(UTILBOX_BUILD_TOOLS "构建 utilbox 附带工具" ON) add_subdirectory(src/core) if(UTILBOX_BUILD_TOOLS) add_subdirectory(src/tools) endif()这么写的意图是:
- 统一设置 C++ 标准,避免每个子目录各写一遍;
- 用
option控制整组工具是否构建,方便在只需要核心库的 CI 场景下跳过工具编译; - 先加
src/core,再加src/tools,保证核心库目标在任何可执行文件之前定义。
顶层不建议写include_directories或link_directories,把全局污染的可能性直接按死。
4.3 核心库 CMakeLists.txt:所有依赖在这里汇聚
核心库的 CMakeLists 是整个项目的咽喉,我把它在这里放完整代码:
add_library(core STATIC src/processor.cpp ) target_include_directories(core PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include ) find_package(nlohmann_json 3.11.0 REQUIRED) find_package(spdlog 1.10.0 REQUIRED) target_link_libraries(core PUBLIC nlohmann_json::nlohmann_json spdlog::spdlog )注意processor.h的头文件默认会包含第三方库的头文件吗?这个决定 PUBLIC 还是 PRIVATE。我这里假设processor.h接口中使用了 JSON 类型,所以 JSON 库用 PUBLIC;而日志库只可能在processor.cpp内部使用,理论上可以写成 PRIVATE,但为了简化示例,我统一 PUBLIC。写法上如果你希望严格最小化暴露给下游,应当把只在源文件里用到的依赖设为 PRIVATE:
target_link_libraries(core PUBLIC nlohmann_json::nlohmann_json ) target_link_libraries(core PRIVATE spdlog::spdlog )这样,下游可执行文件编译时不需要知道 spdlog 的存在,只有 core 自己编译时才需要它。前提是你的核心库头文件绝对没有#include <spdlog/...>。这种精细粒度在不同团队协作时尤其重要,能显著减小“头文件污染”面积。
4.4 工具层 CMakeLists.txt:可执行文件只有三行
到了工具层,代码就非常简单了:
add_executable(metric_analyzer metric_analyzer/main.cpp ) target_link_libraries(metric_analyzer PRIVATE core) add_executable(report_writer report_writer/main.cpp ) target_link_libraries(report_writer PRIVATE core)每个可执行文件只需要两件事:列出自己的源文件,链接核心库。第三方库的头文件路径、链接库、依赖关系,全被 core 通过 PUBLIC 传递给了它们。如果你想让某个工具额外使用一个别的库,不用改 core,直接给那个工具加一行target_link_libraries即可。
实际构建时,还可以只构建其中某一个可执行文件,不需要整个工程全部编译:
cmake -S . -B build cmake --build build --target metric_analyzer -j4这种“目标级”构建粒度就是 CMake 目标模型带来的直接好处。要是用全局变量那套写法,你极难做到只影响一个目标。
4.5 手动指定第三方库路径时的补充方案
有些时候系统环境里没有预装第三方库,find_package会失败。我常用的兜底方案是给CMAKE_PREFIX_PATH指定安装前缀:
cmake -S . -B build -DCMAKE_PREFIX_PATH=/opt/mylibsCMake 会在该前缀下寻找对应的配置文件。如果库压根没有安装,只是源码在某处,也可以用add_subdirectory把库源码直接加入进来。比如把 JSON 库源码放到third_party/json:
add_subdirectory(third_party/json)然后核心库依然链接它的目标就可以了。这种方式的好处是不需要提前安装,缺点是 CMake 配置时间会变长。对我个人来说,日常小项目我更喜欢把第三方库装进一个固定目录,然后用CMAKE_PREFIX_PATH管理,构建环境更干净。
5. 我实际踩过的坑,以及排查思路
5.1 编译时报“头文件找不到”怎么办
错误信息大概长这样:
fatal error: nlohmann/json.hpp: No such file or directory遇到这种问题,第一反应不要是“它没装好”,而是“这个编译单元的头文件搜索路径里根本没有它”。最常见的三种情况:
- 目标之间的依赖没有建立:可执行文件直接引用第三方头文件,却没链接对应的库目标;
- 依赖建立了,但第三方库的头文件路径没有用 PUBLIC 传播出去;
- 手动路径写错了,或路径只对当前目标生效,下游目标看不见。
排查时先把依赖图拿出来过一遍:谁编译时要用这个头文件,它直接或间接依赖了哪个目标。如果依赖链完整,那问题基本出在 PUBLIC/PRIVATE 设置上。这里最保险的验证方法是临时把依赖改成 PUBLIC 试试,能过就说明原来确实传不到下游。
5.2 编译通过,链接却报 undefined reference
这种情况比头文件找不到更让人抓狂,因为编译阶段没问题,说明头文件和接口声明都正常,但实际实现找不到。可能的原因有:
| 原因 | 特征 | 解决办法 |
|---|---|---|
| 忘记链接对应库 | 链接错误指向你自己写的函数 | 补上target_link_libraries |
| 链接了静态库,但库依赖的其他库没链接 | 错误指向第三方库内部符号 | 把库的系统依赖也加进链接 |
| 库文件与头文件版本不匹配 | 符号签名对不上 | 检查头文件版本和库文件版本 |
| 链接顺序不对 | 传统静态库顺序问题 | 用 CMake 目标替代手写 order |
CMake 目标传递机制本身能解决大多数“忘记链接对应库”的问题,但有一类坑它不一定能救:你链接了一个静态库 A,A 又依赖另一个静态库 B,而 B 没有作为目标被传递到你的可执行文件。这时候链接器会报一堆来自 A 的 undefined reference。解决办法是手动把 B 也加进可执行文件的target_link_libraries,或者让 A 的 CMake 配置正确把 B 声明为 PUBLIC 依赖。
5.3 编译链接都通过,运行时却提示找不到动态库
典型的报错:
error while loading shared libraries: libfoo.so: cannot open shared object file: No such file or directory这是我在接入动态库时最容易忽略的一环:开发时编译链接都通过了,一运行就露馅。原因很简单,动态库在链接时被记录,但运行时系统需要根据默认路径或环境变量去搜索它。常见解决办法有三个:
- 临时方式:
export LD_LIBRARY_PATH=/path/to/libs,Linux 下方便但不持久; - 规范方式:在 CMake 中配置 rpath,让可执行文件运行时优先找指定路径;
- 最终方式:把动态库安装到系统默认搜索路径或可执行文件同目录。
我日常开发时会在顶层 CMakeLists 加上这样一段,让构建产物自动带上库路径:
set(CMAKE_INSTALL_RPATH_USE_LINK_PATH TRUE)这样安装时 CMake 会尽量把库的搜索路径写入可执行文件,部署省去一堆环境变量配置。
5.4 find_package 找到了包,但版本不对或找错位置
find_package经常出现“找到了一个版本,但不是你要的”这种情况。排查思路是看xxx_DIR指向哪里:
cmake -S . -B build -Dnlohmann_json_DIR=/path/to/nlohmann_json或者配置时直接打印搜索到的结果:
find_package(nlohmann_json 3.11.0 REQUIRED) message(STATUS "nlohmann_json_DIR=${nlohmann_json_DIR}") message(STATUS "nlohmann_json_INCLUDE_DIR=${nlohmann_json_INCLUDE_DIR}")一旦发现搜索路径不对,优先检查CMAKE_PREFIX_PATH是否设置正确,然后检查环境变量里有没有旧的配置路径在捣乱。实际业务中我遇到最多的情况是机器上装了多个版本,find_package 默认匹配到了旧版本。用-Dxxx_DIR显式指定是最省力的做法。
6. 一些可能对你有用的经验
6.1 多可执行文件项目,从下往上搭最稳
我的习惯是先写核心库的 CMakeLists,确认它在单独构建时能顺利编过、第三方依赖都正常,再写工具层。核心库是整个项目的基石,它上面的依赖关系一旦明确,后面的可执行文件基本就是套模板。如果一开始就从上往下写,各个可执行文件堆了一堆第三方库路径,出了问题你根本分不清是哪个依赖传错了。
6.2 头文件接口尽量保持干净
这算是设计层面的事,但它直接影响 CMake 的 PUBLIC/PRIVATE 策略。核心库对外暴露的头文件,尽量少直接包含第三方库头文件。比如把第三方库的具体类型封装在实现里,对外头文件用前置声明或抽象接口。这样你的 PUBLIC 依赖会少很多,下游可执行文件被第三方库“波及”的概率也低。有次我把一个 JSON 类型直接写进接口头文件,所有下游目标被迫继承了一大堆 JSON 库的编译配置,改起来非常痛苦。
6.3 CMake 配置文件别写成一个巨型文件
哪怕项目只有三个可执行文件,我也建议按层拆 CMakeLists:顶层管全局,库一层,工具一层。好处是职责清楚,团队协作时各改各的,冲突概率小得多。更重要的是,当项目规模变大时,你能快速判断问题出在依赖配置层还是业务编译层。我见过把全部add_executable和add_library塞进一个文件里的项目,三千行 CMakeLists,看着就头大。
6.4 不要迷信某一套“万能写法”
CMake 是个演化中的构建系统,不同版本、不同库的 CMake 支持程度差别很大。有的库只提供老式的xxx_LIBRARIES变量,有的库只有xxx::xxx目标,还有的库两者都提供。我不建议死守某一种写法,核心原则只有一个:尽量把任何依赖都转化为目标,再通过目标之间的关系去组织构建。只要这个原则立得住,格式细节都是可以灵活调整的。
这套结构我用在实际项目里之后,新增一个可执行文件基本就是几分钟的事:写 main.cpp,在工具层 CMakeLists 加三段代码,然后 build。第三方库换版本、换路径,也只需要改核心库一层,所有下游目标自动跟着变。所谓的 CMake 难用,很多时候不是它真的难用,而是我们在用它的时候还在用“目录”时代的旧思维。换成目标思维之后,多可执行文件和第三方库调用这件事,也就没那么吓人了。