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 源文件组织的最佳实践
关于源文件怎么放,我总结了几条经验:
- 头文件和源文件分离:
include/放头文件,src/放源文件。头文件按模块名再建子目录,防止重名。 - 一个模块一个目录:不要把所有工具函数都塞到一个
utils目录里。如果某个工具函数只在一个模块中使用,就放在那个模块内部。 - 测试文件单独放:测试代码不要和源码混在一起。用
EXCLUDE_FROM_ALL控制,平时不编译。 - 第三方库统一管理:所有第三方库放在
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 知识体系总览
下面这张图,是我对本章内容的一个总结。你可以把它当作一个检查清单:
8.9 避坑指南
我曾经踩过的坑,你千万别再踩了:
- 不要在子目录里用
add_subdirectory(..)回退到父目录。这会让构建逻辑变得混乱,而且容易产生循环。 - 不要用
file(GLOB)自动收集源文件。除非你加上CONFIGURE_DEPENDS,否则新增文件后CMake不会重新扫描,导致编译漏文件。 - 不要把所有头文件都设为
PUBLIC。只暴露必要的接口,内部实现细节用PRIVATE隐藏起来。这样别人用你的模块时,不会看到一堆不该看到的头文件。 - 不要忽略
binary_dir参数。如果你有多个子目录生成同名的目标,一定要指定不同的binary_dir,否则会冲突。
好了,关于子目录和项目组织,今天就聊到这里。记住一句话:好的项目结构,是改出来的,不是设计出来的。先搭个框架,随着项目变大,慢慢调整。别一开始就想搞个完美的结构,那反而会束缚手脚。