资讯动态

CMake多可执行文件与第三方库集成:从目标传递到实战踩坑

发布时间:2026/10/18 19:28:42 来源:尧图企业网站定制
做这件事的起因很朴素一个工具集里有两三个可执行文件它们要共享同一套核心计算逻辑又都得调用一个开源 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。写法上如果你希望严格最小化暴露给下游应当把只在源文件里用到的依赖设为 PRIVATEtarget_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/jsonadd_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 目标替代手写 orderCMake 目标传递机制本身能解决大多数“忘记链接对应库”的问题但有一类坑它不一定能救你链接了一个静态库 AA 又依赖另一个静态库 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/libsLinux 下方便但不持久规范方式在 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 难用很多时候不是它真的难用而是我们在用它的时候还在用“目录”时代的旧思维。换成目标思维之后多可执行文件和第三方库调用这件事也就没那么吓人了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑