8、CMake子目录与项目组织:add_subdirectory、构建多模块项目、源文件组织最佳实践

说实话,我见过太多C++项目,刚开始就一个main.cpp,写着写着就变成了一个几千行的怪物。然后呢?没人敢动,一改就崩。这其实不是代码能力的问题,是项目组织从一开始就没想清楚。

今天我们就聊聊,怎么用CMake把项目拆得清清楚楚。说白了,就是学会用add_subdirectory来管理多模块项目。我个人习惯,一个新项目启动的头半小时,一定先把目录结构搭好,后面才不会乱。

8.1 为什么需要子目录?

你想想看,一个项目如果所有源文件都堆在根目录下,会发生什么?

  • 编译慢:改一个文件,整个项目重新编译
  • 耦合高:模块之间没有边界,谁都能调用谁
  • 维护难:新同事来了,根本不知道从哪里下手

我在项目中遇到过最夸张的一次,一个项目有200多个.cpp文件全在一个目录里。每次编译要等15分钟,改个日志打印都要重新链接全部。后来我花了两天时间重构,拆成8个子模块,编译时间直接降到3分钟。

所以,子目录不是「为了好看」,它是为了解耦和加速构建。

8.2 add_subdirectory 的基本用法

add_subdirectory 是CMake里最核心的命令之一。它的作用很简单:让CMake进入一个子目录,去执行那个子目录里的CMakeLists.txt

基本语法:

add_subdirectory(source_dir [binary_dir] [EXCLUDE_FROM_ALL])
  • source_dir:子目录的路径,相对于当前CMakeLists.txt
  • binary_dir:构建产物的输出路径(可选)。不指定的话,默认在build目录下镜像源目录结构
  • EXCLUDE_FROM_ALL:让这个子目录的目标不包含在默认的ALL目标中(比如测试代码、示例代码)

来看一个最简单的例子:

# 根目录 CMakeLists.txt
cmake_minimum_required(VERSION 3.16)
project(MyApp VERSION 1.0.0)

add_subdirectory(src)
add_subdirectory(tests EXCLUDE_FROM_ALL)

嗯,这里要注意:tests 目录我加了 EXCLUDE_FROM_ALL。为什么?因为平时开发时,我只想编译主程序,测试代码等需要的时候再单独编译。这样可以节省不少时间。

8.3 多模块项目的典型结构

我个人比较推荐的分层结构是这样的:

my_project/
├── CMakeLists.txt          # 根构建文件
├── cmake/                  # 自定义CMake模块
│   └── FindMyLib.cmake
├── src/                    # 主程序
│   ├── CMakeLists.txt
│   ├── main.cpp
│   └── app/
│       ├── CMakeLists.txt
│       └── app.cpp
├── modules/                # 功能模块
│   ├── core/
│   │   ├── CMakeLists.txt
│   │   ├── include/core/
│   │   │   └── core.h
│   │   └── src/
│   │       └── core.cpp
│   ├── network/
│   │   ├── CMakeLists.txt
│   │   ├── include/network/
│   │   │   └── network.h
│   │   └── src/
│   │       └── network.cpp
│   └── utils/
│       ├── CMakeLists.txt
│       ├── include/utils/
│       │   └── utils.h
│       └── src/
│           └── utils.cpp
├── tests/                  # 测试代码
│   ├── CMakeLists.txt
│   ├── test_core.cpp
│   └── test_network.cpp
└── third_party/            # 第三方库
    └── googletest/

这个结构的好处是什么?每个模块都有自己的CMakeLists.txt,模块之间通过target_link_libraries建立依赖关系。边界清晰,谁依赖谁一目了然。

8.4 子目录中的 CMakeLists.txt 怎么写

每个子目录的CMakeLists.txt应该只关心自己目录里的东西。我见过有人把整个项目的逻辑写在一个文件里,那还不如不拆。

来看一个模块的典型写法:

# modules/core/CMakeLists.txt
add_library(core
    src/core.cpp
)

target_include_directories(core
    PUBLIC
        ${CMAKE_CURRENT_SOURCE_DIR}/include
)

target_link_libraries(core
    PUBLIC
        utils
)

这里有几个关键点:

  • PUBLIC:这个模块的include目录,会传递给依赖它的目标
  • PRIVATE:只对当前模块可见,外部看不到
  • INTERFACE:自己不编译,只给依赖者提供头文件(比如纯模板库)

我曾经犯过一个错误:把所有头文件路径都写成绝对路径。结果项目换了个目录,全部报错。后来我学乖了,一律用 ${CMAKE_CURRENT_SOURCE_DIR} 这种相对路径。

8.5 模块间的依赖管理

模块多了,依赖关系就复杂了。我建议你画一张依赖图,避免出现循环依赖。

核心原则:依赖方向应该是从上到下,从具体到抽象。底层模块不依赖上层模块。

来看一个实际的依赖关系:

# 根目录 CMakeLists.txt
add_subdirectory(modules/utils)
add_subdirectory(modules/core)
add_subdirectory(modules/network)
add_subdirectory(src/app)

# app 依赖 network 和 core
target_link_libraries(app
    PRIVATE
        network
        core
)

# network 依赖 core
target_link_libraries(network
    PRIVATE
        core
)

# core 依赖 utils
target_link_libraries(core
    PRIVATE
        utils
)

这样一层层下来,依赖关系非常清晰。如果哪天你想把network模块替换掉,只需要改app的依赖,其他模块不受影响。

8.6 源文件组织的最佳实践

关于源文件怎么放,我总结了几条经验:

  1. 头文件和源文件分离include/ 放头文件,src/ 放源文件。头文件按模块名再建子目录,防止重名。
  2. 一个模块一个目录:不要把所有工具函数都塞到一个utils目录里。如果某个工具函数只在一个模块中使用,就放在那个模块内部。
  3. 测试文件单独放:测试代码不要和源码混在一起。用EXCLUDE_FROM_ALL控制,平时不编译。
  4. 第三方库统一管理:所有第三方库放在third_party目录下,用FetchContent或git submodule管理。

小技巧:如果你用file(GLOB_RECURSE)来收集源文件,记得在CMakeLists.txt里加上CONFIGURE_DEPENDS。否则新增文件后,CMake不会自动重新扫描。

8.7 构建时的输出控制

项目大了,构建产物也会很多。我建议统一输出目录:

# 根目录 CMakeLists.txt
set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/lib)
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/lib)
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin)

这样所有静态库、动态库、可执行文件都分开放,找起来方便。我在一个项目里见过库文件和可执行文件混在一起,部署的时候还得手动挑,太痛苦了。

8.8 知识体系总览

下面这张图,是我对本章内容的一个总结。你可以把它当作一个检查清单:

CMake子目录与项目组织知识体系 add_subdirectory 目录结构设计 src/ 主程序目录 modules/ 功能模块 tests/ 测试代码 模块CMakeLists.txt写法 add_library 定义模块 target_include_directories target_link_libraries 依赖 依赖关系管理 PUBLIC / PRIVATE / INTERFACE 避免循环依赖 从具体到抽象 构建输出控制 统一 lib/ bin/ 目录 EXCLUDE_FROM_ALL 控制 CMAKE_CURRENT_SOURCE_DIR

8.9 避坑指南

我曾经踩过的坑,你千万别再踩了:

  • 不要在子目录里用add_subdirectory(..)回退到父目录。这会让构建逻辑变得混乱,而且容易产生循环。
  • 不要用file(GLOB)自动收集源文件。除非你加上CONFIGURE_DEPENDS,否则新增文件后CMake不会重新扫描,导致编译漏文件。
  • 不要把所有头文件都设为PUBLIC。只暴露必要的接口,内部实现细节用PRIVATE隐藏起来。这样别人用你的模块时,不会看到一堆不该看到的头文件。
  • 不要忽略binary_dir参数。如果你有多个子目录生成同名的目标,一定要指定不同的binary_dir,否则会冲突。

好了,关于子目录和项目组织,今天就聊到这里。记住一句话:好的项目结构,是改出来的,不是设计出来的。先搭个框架,随着项目变大,慢慢调整。别一开始就想搞个完美的结构,那反而会束缚手脚。


公众号:蓝海资料掘金营,微信deep3321