☰
CMake 多项目管理实战:依赖关系、target 拆分与构建优化
2026/10/19 13:35:19 网站建设 项目流程

大概一年多前,我接手了一个已经长到二十多万行的C++代码库。最开始只有一个孤零零的main.cpp,后来不断加模块,有人往里塞算法,有人往里塞工具类,还有人把平台相关的东西也扔进来。整个目录越拉越大,编译一次要喝两杯咖啡,改一个公共头文件能触发全量重建,CMakeLists.txt里密密麻麻堆了上百个add_executable。当时我做的第一件事,就是把它重构成 CMake 管理的多项目结构。

这个博客想聊的就是这套东西:CMake 管理多项目,不只是把文件按文件夹放好,而是要在构建系统层面把“依赖关系”“可见范围”“输出物归属”理顺。我自己的处理方式比较直接:一个顶层CMakeLists.txt定义全局约定,下面每个子目录负责自己的模块,模块之间靠 target 而不是靠全局变量通信。下面我会顺着这个思路,把它拆成几个关键的决策点,结合具体踩坑经历写出来,希望能给正在搭这种工程的读者一些参考。

1. 多项目不是“多几个目录”,而是依赖关系的显式化

1.1 一个文件堆不下的真实场景

先说清楚为什么要做多项目拆分。我自己经历过的典型场景是这样的:某个工具模块刚写出来时只有两个源文件,直接塞进主程序目录没什么问题。但随着需求增加,这个模块开始被其他模块依赖,接着有人发现它需要单独测试,再后来产品说“这个功能要复用到一个新工具里”。如果不拆,你只能在主工程里再挂一个 target,然后被迫把一堆头部路径指向某个遥远的源码目录。

拆成多项目之后,每个子项目就是一个清晰的边界:它有自己独立的CMakeLists.txt、头文件目录、源文件列表和公开依赖说明。外部使用时,只知道这个 target 提供了哪些接口,不需要关心内部有几个.cpp文件。这是“依赖关系显式化”的核心价值——你不需要再人肉维护一份“这个目录到底被谁依赖”的文档,构建系统自己就能表达清楚。

而且多项目会让后期维护心态完全不同。单一目录的工程,改内部实现时总担心“会不会还有别的入口在用这个文件”;拆完之后,改一个子项目内部的实现,只要接口稳定,外部完全无感。连续编译那一块,效果也比想象中明显:我重构的项目从全量 8 分钟降到了增量 30 秒左右,很大一部分原因是不同模块的编译缓存终于可以分开命中,而不是每次改动都把所有源文件打包进一个 target 重编。

1.2 拆分的边界在哪里

但“拆”也不是拆得越细越好,这点我后面会专门写一节,这里先给个直观标准:如果两个目录之间有稳定的“单向依赖”关系,而且其中一个将来可能被另一个工程复用,就应该考虑拆成独立子项目;如果只是为了让CMakeLists.txt里少写几行,那拆了反而多一层跳转,收益不大。

判断时可以问三个问题:

  • 这个模块的编译单元是不是可以独立于主程序存在?
  • 它会不会被两个以上的上游 target 依赖?
  • 它是否有自己独立的测试用例?

三个问题里有两个以上回答“是”,拆。否则可以继续留在原地,等真需要时再说。没人规定“拆”必须是第一选择,因为重构目标始终是让构建关系更清楚,而不是制造更多构造块。

2. 两条主流路线:add_subdirectory 与 find_package

2.1 add_subdirectory 路线的画像

CMake 管理多项目,最常见的方式就是在一个“超级工程”根目录下用add_subdirectory()把各个子项目连接起来。这样所有 target 在同一个构建系统里可见,它们之间可以直接通过target_link_libraries()建立依赖。

这条路线的典型特征是:一次配置、统一构建、IDE 体验集成。你在 Visual Studio、CLion 或 Xcode 里打开顶层工程,所有子项目都会出现在解决方案里,能直接从主程序出发追踪到任何一个底层库的源码,断点也可以跨子项目命中,调试起来非常顺手。

代价也有:所有子项目的配置选项、编译特性都共享同一个 CMake 上下文,稍不注意就会产生“隐形耦合”。比如某个子项目为了自己方便定义了一个宏,又忘记了把它设成PRIVATE,下游项目就能看到它,久而久之又回到全局变量满天飞的老路。所以用这种方式组织多项目,必须守一条铁规:子项目对外只暴露 target,不暴露内部变量和宏。

2.2 find_package 路线的画像

另一条路线是每个子项目完全独立,各自维护一个CMakeLists.txt,构建时会生成并安装自己的 package 配置文件。别的工程通过find_package()找到它,像调用第三方库一样使用。

这条路线的核心价值是“发布边界清晰”。如果两个模块由不同团队维护,更新节奏也不同,把它们彻底拆开很合理。某个模块升级不再影响其他模块构建,只需要对方依旧能拿到你已经发布的包即可。

但它的隐形成本更高:你需要维护好版本号、ABI 兼容性、导出符号列表,还要保证安装路径和 config 文件能被下游正确找到。我见过不少走这条路的项目,最后卡在“为什么换了台机器就找不到包”这种环境问题上。所以如果不是真正需要独立发布,不要轻率选择这条路。

两者不是互斥的。一个仓库内的多项目通常适合add_subdirectory;如果某一天某个模块要提供给其他仓库使用,再去把它抽出来部署成独立包也不迟。

2.3 我的选择框架

结合项目规模和发布节奏,我一般按这个框架做决定:

判断维度偏向 add_subdirectory偏向 find_package
仓库结构所有子项目在同一仓库子项目独立仓库或单独发版
团队边界同一团队协作开发跨团队、跨组织使用
构建体验一次构建全部产物先构建依赖,再构建本体
版本管理跟随整体版本各模块有独立版本号
调试便利性跨模块断点方便需要额外符号配置
维护成本低成本,依赖同步需维护安装导出规则

大多数情况下,我自己更倾向先走add_subdirectory,因为同一仓库里最大的成本往往是“改了 A 模块,不知道哪里会受影响”,而超级工程恰好能让依赖关系一览无余。真正需要独立推版的再拆出去,那时再用install(EXPORT ...)配合CMakePackageConfigHelpers生成标准 package 文件。

3. 顶层 CMakeLists 的骨架:全局约定要写在所有 add_subdirectory 之前

3.1 顶层该放什么

顶层CMakeLists.txt不是我推荐用来堆逻辑的地方,它的职责很明确:声明项目基本信息、设置全局编译约定、统一输出目录、然后按顺序引入子目录。一个典型骨架长这样:

cmake_minimum_required(VERSION 3.20) project(module_demo VERSION 1.2.0 LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_EXTENSIONS OFF) set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin) set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/lib) set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/lib) option(BUILD_TOOLS "Build developer tools" ON) option(ENABLE_TRACE_LOG "Enable trace log" OFF) add_subdirectory(common) add_subdirectory(platform) add_subdirectory(core) add_subdirectory(app) if(BUILD_TOOLS) add_subdirectory(tools) endif()

每个项目在写顶层文件时都会习惯性加点东西,但要克制。比如编译器警告选项、硬编码的平台判断,最好放在 CMakePresets 或工具链文件里,而不是全部挤在顶层。否则一旦子项目引入了不同的依赖,你就得回来改顶层这个“环卫工”,谁都不喜欢这种工作。

project(module_demo VERSION 1.2.0 ...)这一行看起来不起眼,实际作用大:它会生成PROJECT_VERSION_MAJOR等变量,后面的安装脚本、打包脚本、版本检查都需要用它。如果一开始不写VERSION,后面补就要费事得多。

3.2 输出目录是管理多项目最容易忽略的雷区

之前吃过一个亏:各子项目都没有设置输出目录时,可执行文件会跑进各自子目录的CMAKE_CURRENT_BINARY_DIR下,库文件也到处散落。最终产物如果要统一打包,要么写一堆拷贝路径赋值,要么靠 install 规则一个个处理,非常麻烦。

所以在顶层把三种输出目录集中指到同一个bin和lib下,能省一大半后面的事:

set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin) set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/lib) set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/lib)

注意ARCHIVE_OUTPUT_DIRECTORY里存的是静态库和导入库,Windows 下 DLL 的.lib文件会出现在这里,不设置会出现“exe 旁边有 dll,但链接时找不到导入库”的诡异现象。集中输出后,运行测试、打包、发布脚本都只需要认准两个目录。

顺便补一句:CMAKE_PREFIX_PATH和CMAKE_INSTALL_PREFIX我不会在顶层写死,而是靠命令行或 CMakePresets 传进来。不同构建场景需要的安装路径不一样,写死就会逼着开发人员每次改文件,反而不合算。

3.2.1 添加子目录的先后顺序有讲究

大家最关心的典型问题之一:add_subdirectory的顺序是不是无所谓?这个事的答案取决于 target 之间的依赖关系。

CMake 是脚本式处理的,add_subdirectory(common)这行执行时,会立刻进入common/CMakeLists.txt并把其中定义的 target 记录下来。但target_link_libraries(app PRIVATE core)如果发生在一个尚未通过add_subdirectory引入的目录之前,就会因为找不到 target 直接报错。

所以我在顶层会按“低层依赖优先”的顺序排列子目录。比如依赖关系是common->platform->core->app,那添加顺序也保持这个方向。先被依赖的模块永远先出现,下游模块后出现。这样构建脚本读起来既是逻辑链路,也避免了“明明写对了,就是配置失败”的情况。

4. 子目录里的 target:把 include、链接和依赖绑在一起

4.1 老式 CMake 的全局头文件噩梦

以前写 CMake 时,很多人习惯这样组织:

include_directories(${CMAKE_CURRENT_SOURCE_DIR}/include) link_directories(...)

这套做法最大的问题在于“泄漏”:只要 add_subdirectory 某个子目录,它的include_directories就会对后面所有子目录生效。模块一多,头文件搜索路径成了全局共享池,完全分不清某个 include 到底来自哪里。我接手某项目时,曾亲眼看到底层模块为了绕过某个目录问题,加了一句include_directories(${CMAKE_CURRENT_SOURCE_DIR}/../core/include),这个路径让整个全局依赖变得更加混乱。

现代 CMake 我们是坚决不这么写的。所有头文件路径和依赖关系都绑定在 target 上,外部只会看到“链接了某个库”,而不是“手动修改了全局搜索路径”。

4.2 一个标准的子项目 CMakeLists 长什么样

拿一个叫core的子项目举例:

add_library(core src/engine.cpp src/scheduler.cpp ) add_library(demo::core ALIAS core) target_include_directories(core PUBLIC $<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include> $<INSTALL_INTERFACE:include> PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src ) target_link_libraries(core PUBLIC demo::common PRIVATE demo::platform )

关键之处在于给 target 加一个demo::core前缀别名。多项目环境中 target 名很容易撞车(每个模块都来一个core、util),一旦撞上 CMake 会直接报错,而给每个库都加功能前缀的别名能有效减轻这种命名冲突。另一个好处是,所有上层 target 统一用带前缀的名字链接,即使以后内部把 target 名改为别的,上层代码不需要动。

PUBLIC/PRIVATE的选择逻辑其实很简单:PUBLIC表示“我公开的头文件会用到这个东西,下游也必须看到”;PRIVATE表示“只有我自身编译时用得到,对外不可见”。

比如core的公开头文件里包含了common的类型,那demo::common就得是PUBLIC;而platform只是在src目录内部被使用,那它就是PRIVATE。按照这个原则去选择,才能避免“下游编译时疯狂报找不到头文件”和“无关依赖被莫名其妙传递”两个极端。

4.3 header-only 模块的 INTERFACE 玩法

多项目里经常会有 header-only 的模块,比如一个只放数学工具函数的库。这种 target 不需要编译,自然也没有输出物,但它的头文件路径和下依赖关系还是要给消费者。

CMake 提供了INTERFACE类型的库来承载这类纯头文件依赖:

add_library(mathextra INTERFACE) add_library(demo::mathextra ALIAS mathextra) target_include_directories(mathextra INTERFACE $<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>) target_link_libraries(mathextra INTERFACE demo::common)

这里要特别注意$<BUILD_INTERFACE:...>的写法。BUILD_INTERFACE表示“开发阶段(还在构建这个工程时)”使用的路径,通常指向源目录里的 include 目录;INSTALL_INTERFACE则表示“安装之后”消费者看到的路径,比如include。因为同一个 target 在本地构建和安装后头文件位置往往不同,不用生成器表达式硬编码就会埋下“本地编译通过、安装后找不到头文件”的雷。

这个小例子也回答了多项目工程里很多人问的问题:不是只有“库”才需要用 target 表示,一切“对外提供接口的信息”都可以封装成 target。这种方式比全局变量、全局函数更干净。

5. 实测踩坑记录:GLOB、子目录顺序和同名 target

5.1 不要轻易用 GLOB 自动收集源文件

刚开始管理多项目时,我偷懒写过这样的东西:

file(GLOB_RECURSE CORE_SOURCES CONFIGURE_DEPENDS "src/*.cpp") add_library(core ${CORE_SOURCES})

好处是新加.cpp文件不用手动改 CMakeLists,听起来很智能。但很快我就在团队合作里遇到两个麻烦。

第一个是 IDE 和命令行增量构建对“新增文件”的识别并不可靠。即便加了CONFIGURE_DEPENDS,某些生成器(尤其是旧的 VS 项目)不会自动重新跑 CMake,结果就是别人新加一个源文件,我这边构建时根本感知不到,然后出现一堆 undefined reference,第一反应还以为是代码问题,排查一圈才发现是 CMake 没刷新源文件列表。

第二个是GLOB_RECURSE会把测试文件、示例文件、甚至临时生成的源文件都卷进来,很难做精细控制。构建系统的可预测性会显著降低。

所以我的习惯是:源文件列表显式写清楚。每个子项目的src下大概也就几行到几十行文件,维护成本完全可接受,换来的是“源文件列表就是现状清单”的确定性。越是多人协作、越需要这种确定性。

5.2 子项目之间写循环依赖,CMake 直接给你上课

有次我给core增加新功能,它需要调用platform的一个接口,而platform恰好早就通过core实现了某个回调机制。我用“之前没出问题”的侥幸心态,直接在两边各自写了target_link_libraries(...)相互引用。

结果非常经典:CMake 配置阶段报错The link interface of target "core" contains: platform, but the target was not found,在修改 CMakeLists 时需要彻底理顺依赖方向。

解决时我没有强行去解“理论上的环”,而是把公共部分抽出来变成一个新的粒子项目common_serverity。原先互相调用的逻辑拆成:platform依赖common_serverity,core也依赖common_serverity,两者之间不再有直接链接关系。依赖图从“环”变成“有向无环图”,CMake 瞬间安静了。

这其实就是多项目组织中本质问题:模块之间的关系必须是有向无环图。任何构建系统都不会接受循环依赖,因为那意味着构建次序无法确定。尽早把公共部分抽出来,比在 CMake 里写各种条件规避要可靠得多。

5.3 重复 target 名,错误信息又长又难懂

管理子项目多了之后,我遇到过最哭笑不得的报错是:add_library(core ...)执行到第二个子目录时,CMake 提示target "core" already exists。

原因是我们团队当时在两个不同子目录里想当然定义了同名的core库。但 CMake 的 target 命名空间是全局的,不是“目录内局部有效”。只要属于同一个构建系统,不管写在哪个子目录,target 名都是唯一的。

解决方式除了最初说的“带前缀别名”,还有一个习惯是:每个子项目内部使用统一的主名,但不直接用裸名去add_library,而是用文件名本身来定义,例如demo_core、demo_platform。若项目风格允许,还能用add_library(demo::core ALIAS demo_core)让读者看得更明白。

再补一条:如果确实想在每个目录里用相同的逻辑名,CMake 里也可以借助文件路径或目录名前缀来区分,但那样会让链接代码变“长”且难读。多项目环境的约定优先,其次才是技术手段。

5.4 缓存变量导致的新模块“被隐藏”

还有一次,我新加了一个子项目,在它的 CMakeLists 里写了下拉菜单式的设置项,比如选择启用某种后端实现。配置完成后发现:命令行重跑 cmake,新模块的选项根本不出现。

这个坑的根源是 CMake 缓存变量一旦生成,不会自动引入新加的option()。如果第一次配置时模块还没被add_subdirectory,它的 option 就不会进入缓存,之后即使加回了add_subdirectory,缓存里也不会有这个条目。

解决手段倒简单:删掉构建目录的CMakeCache.txt重新配置,或者在 ccmake/CMake GUI 里手动清一次缓存。但这种坑碰一次就长记性了:任何新增子项目,影响面越大,越是要在干净的构建目录里先验证再合并。

6. 用 option 控制构建范围,让“工具链”和“发布版”解耦

6.1 一个典型的多模块开关脚本

大型多项目工程不会永远构建所有模块。典型情况:日常开发需要构建全部测试和工具,发布时可能只编译主程序和核心库,而内部专用工具则不能带进发布包。这个场景用 option 控制add_subdirectory,非常顺手。

在顶层定义option(BUILD_TOOLS "Build developer tools" ON),然后:

if(BUILD_TOOLS) add_subdirectory(tools) add_subdirectory(tests) endif()

命令行构建时就可以:

cmake -DBUILD_TOOLS=OFF -B build-release -S . cmake --build build-release --config Release

开发时保持默认ON,走cmake -B build -S .即可。这个模式还可以延伸到更多维度,比如用option(ENABLE_TRACE_LOG "Enable trace log" OFF)控制某个日志库是否需要以脱机模式参与构建。开关多了以后,建议用CMakePresets.json把常用组合保存为 preset,而不是让每个开发人员在命令行手抄一长串参数。

6.2 第三方依赖的搜索时机:子目录还是顶层

多项目情况下,第三方依赖(例如某个网络库、压缩库)的find_package应该写在哪一层?

我的经验是:如果所有模块都共享同一个第三方依赖,就在顶层find_package一次,然后把结果用target_link_libraries(...)传下去。比如顶层:

find_package(zlib REQUIRED)

子项目里再写target_link_libraries(core PRIVATE ZLIB::ZLIB)。这样每个子项目只管消费,不用关心依赖去哪里找。

如果一个依赖只有某个特殊子项目用到,就在那个子项目的 CMakeLists 里独立写find_package,不用在顶层全局搜索。这样顶层的职责保持“公共部分”,子项目保留个性,配置时间也更容易定位。我踩过的额外小坑是,find_package找到的 target 名因为版本差异而不同,比如有的版本导出zlib,有的导出ZLIB。跨子项目共享时,建议在顶层统一做一个变量映射,避免每个子目录各自适应。

6.3 测试子目录集成:编译器自带测试框架与 CTest 的配合

多项目结构的另一个好处是测试可以跟着模块走。core子目录里放它的单元测试,platform子目录里放平台相关测试,互不影响。顶层只需要两行:

include(CTest) if(BUILD_TESTING) add_subdirectory(tests) endif()

include(CTest)会在顶层定义BUILD_TESTING选项,并引入enable_testing()。子项目里用add_test()注册测试用例,主工程构建完成后执行ctest就能把所有测试跑起来。

要注意BUILD_TESTING默认是ON,发布构建时用-DBUILD_TESTING=OFF关掉即可。这样开发构建默认跑测试,发布构建又能省掉编译测试用例的时间,逻辑很清晰。

7. 三个常见的过拆分和欠拆分对象

7.1 过度拆分带来的“CMakeLists 满天飞”

见过一些工程为了体现“分工明确”,每个目录只有一两个源文件也要搞一个CMakeLists.txt,顶层 add_subdirectory 长达几十行。结果维护者每找一个 target 要跳好多个目录,CMake 配置过程中一大半时间在遍历各种空壳目录。

真正的多项目结构不应该追求“每个目录都是子项目”,而应该追求“每个构建产物或者被复用接口都是一个 target 边界”。如果你发现多个子项目之间没有实质的依赖关系,只是“长得像不同模块”,那就可以先把它们合并成一个子项目,内部再用add_library分出多个 target。合并之后,配置文件少了,目录层级浅了,但依赖关系依旧清晰。

7.2 判断粒度是否合适的三个标尺

我自己判断当前项目里的多项目管理粒度是否合适,通常会看三点:

  1. 每个子项目是否能被一句话解释清楚它“提供什么接口”。如果讲不清,说明它太碎。
  2. 修改某个子项目时,是否会牵动大量其他子项目的配置逻辑。如果会,说明依赖边界没画对。
  3. 子项目数量是否超过了团队能够轻松维护的范围。超过 8 到 10 个就要开始谨慎了,除非内部有非常清晰的目录分层。

拿某图像处理 Demo 举例:里面有core(像素缓冲与基本算子)、plugin(格式插件)、app(命令行工具)。这三个的接口都能一句话讲清楚,依赖关系天然是plugin依赖core、app依赖core和plugin。这样拆出来,每个目录都很稳。

反过来,如果把“图像缩放”和“颜色转换”拆成两个子项目,就会出现“每个子项目都依赖 pixel buffer”的尴尬:新增接口时要在好几个地方同步修改,整体收益不大,反而增加维护成本。

7.3 这些判断最终会沉淀成一个团队约定

多项目管理到后期,最值钱的部分已经不是 CMake 语法,而是一份团队公认的“模块边界守则”。比如“子项目之间禁止循环依赖”“target 一律带命名空间别名”“公共头文件必须放在 include 目录下”“新增源文件必须显式更新 CMakeLists”。把这些定下来,新人加入时照着做,就不容易把工程搞乱。

我自己还习惯每隔几次迭代就检查一遍顶层 add_subdirectory 列表,看有没有可以合并的模块,有没有依赖关系不够清晰的地方。这个过程很费功夫,但重要程度一点不低于写代码本身——因为构建系统的健康度直接决定了团队日常开发体验,而多项目结构又是保障这套系统长期干净的关键。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询