大概一年多前我接手了一个已经长到二十多万行的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_package2.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、子目录顺序和同名 target5.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_serveritycore也依赖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_TOOLSOFF -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_TESTINGOFF关掉即可。这样开发构建默认跑测试发布构建又能省掉编译测试用例的时间逻辑很清晰。7. 三个常见的过拆分和欠拆分对象7.1 过度拆分带来的“CMakeLists 满天飞”见过一些工程为了体现“分工明确”每个目录只有一两个源文件也要搞一个CMakeLists.txt顶层 add_subdirectory 长达几十行。结果维护者每找一个 target 要跳好多个目录CMake 配置过程中一大半时间在遍历各种空壳目录。真正的多项目结构不应该追求“每个目录都是子项目”而应该追求“每个构建产物或者被复用接口都是一个 target 边界”。如果你发现多个子项目之间没有实质的依赖关系只是“长得像不同模块”那就可以先把它们合并成一个子项目内部再用add_library分出多个 target。合并之后配置文件少了目录层级浅了但依赖关系依旧清晰。7.2 判断粒度是否合适的三个标尺我自己判断当前项目里的多项目管理粒度是否合适通常会看三点每个子项目是否能被一句话解释清楚它“提供什么接口”。如果讲不清说明它太碎。修改某个子项目时是否会牵动大量其他子项目的配置逻辑。如果会说明依赖边界没画对。子项目数量是否超过了团队能够轻松维护的范围。超过 8 到 10 个就要开始谨慎了除非内部有非常清晰的目录分层。拿某图像处理 Demo 举例里面有core像素缓冲与基本算子、plugin格式插件、app命令行工具。这三个的接口都能一句话讲清楚依赖关系天然是plugin依赖core、app依赖core和plugin。这样拆出来每个目录都很稳。反过来如果把“图像缩放”和“颜色转换”拆成两个子项目就会出现“每个子项目都依赖 pixel buffer”的尴尬新增接口时要在好几个地方同步修改整体收益不大反而增加维护成本。7.3 这些判断最终会沉淀成一个团队约定多项目管理到后期最值钱的部分已经不是 CMake 语法而是一份团队公认的“模块边界守则”。比如“子项目之间禁止循环依赖”“target 一律带命名空间别名”“公共头文件必须放在 include 目录下”“新增源文件必须显式更新 CMakeLists”。把这些定下来新人加入时照着做就不容易把工程搞乱。我自己还习惯每隔几次迭代就检查一遍顶层 add_subdirectory 列表看有没有可以合并的模块有没有依赖关系不够清晰的地方。这个过程很费功夫但重要程度一点不低于写代码本身——因为构建系统的健康度直接决定了团队日常开发体验而多项目结构又是保障这套系统长期干净的关键。