19、CMake中的多模块项目:add_subdirectory()、子项目组织、跨模块依赖管理
说实话,刚接触CMake那会儿,我习惯把所有代码都塞进一个CMakeLists.txt里。项目小的时候还好,一旦代码量上来,那个文件就变得又臭又长。后来我接手一个嵌入式项目,光驱动层就有十几个模块,互相依赖关系乱成一锅粥。嗯,那次之后我才真正体会到——多模块管理不是花架子,是刚需。
这一章,我们就来聊聊怎么用CMake把项目拆得清清楚楚。核心就三个东西:add_subdirectory()、子项目怎么组织、跨模块依赖怎么管。
19.1 为什么需要多模块?
你想想看,一个大型项目如果只有一个CMakeLists.txt,会是什么场景?
- 变量满天飞,不知道哪个变量被谁改了
- 编译目标几十个,找起来费劲
- 想单独测试某个模块?得把整个项目都编译一遍
多模块的好处很明显:
- 职责清晰:每个模块只管自己的事
- 编译快:改一个模块,只重新编译它和依赖它的模块
- 复用性强:模块可以独立拿出来给其他项目用
核心思路:每个子模块拥有自己的CMakeLists.txt,顶层文件只负责组织它们。
19.2 add_subdirectory() 的用法
add_subdirectory() 是CMake里最常用的多模块入口。它的作用很简单:告诉CMake去某个子目录里再执行一个CMakeLists.txt。
基本语法:
add_subdirectory(source_dir [binary_dir] [EXCLUDE_FROM_ALL])
- source_dir:子模块源码目录(必填)
- binary_dir:子模块构建目录(可选,不指定则默认在build下生成同名目录)
- EXCLUDE_FROM_ALL:排除该模块的默认构建目标(可选)
举个例子,假设项目结构是这样的:
my_project/
├── CMakeLists.txt # 顶层
├── src/
│ ├── CMakeLists.txt # 主程序
│ └── main.cpp
├── libs/
│ ├── math/
│ │ ├── CMakeLists.txt # 数学库
│ │ ├── add.cpp
│ │ └── add.h
│ └── net/
│ ├── CMakeLists.txt # 网络库
│ ├── tcp.cpp
│ └── tcp.h
└── tests/
├── CMakeLists.txt # 测试模块
└── test_math.cpp
顶层CMakeLists.txt可以这样写:
cmake_minimum_required(VERSION 3.16)
project(MyMultiModule VERSION 1.0.0 LANGUAGES CXX)
# 添加子模块
add_subdirectory(libs/math)
add_subdirectory(libs/net)
add_subdirectory(src)
add_subdirectory(tests)
每个子模块的CMakeLists.txt各自定义自己的目标。比如 libs/math/CMakeLists.txt:
add_library(MathLib STATIC add.cpp)
target_include_directories(MathLib PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
个人习惯:我一般会在每个子模块里用 CMAKE_CURRENT_SOURCE_DIR 来指定头文件路径,这样模块移动位置时不用改代码。
19.3 子项目组织的最佳实践
模块怎么分?我见过不少新手把模块拆得特别碎,一个目录里就一个.cpp文件。其实没必要。我个人建议按功能域来划分:
- libs/:存放可复用的库模块
- src/:主程序入口
- tests/:单元测试
- tools/:辅助工具
- third_party/:第三方依赖
每个子模块内部,我习惯保持这样的结构:
module_name/
├── CMakeLists.txt
├── include/ # 对外暴露的头文件
│ └── module_name/
│ └── api.h
├── src/ # 实现文件
│ └── impl.cpp
└── test/ # 模块内测试(可选)
└── test.cpp
为什么要多一层 include/module_name/?
这是为了防止头文件重名。你想想看,如果两个模块都有一个 utils.h,包含的时候就会打架。加一层命名空间式的目录,就能避免这个问题。
我曾经踩过的坑:有一次我把所有头文件都放在 include/ 根目录下,结果两个模块都定义了 config.h,内容还不一样。编译时一会儿用这个,一会儿用那个,排查了半天。从那以后,我强制要求每个模块的头文件必须放在 include/模块名/ 下。
19.4 跨模块依赖管理
模块之间难免有依赖。比如网络库依赖数学库,主程序依赖网络库和数学库。怎么管理这些依赖?
CMake提供了几种方式:
19.4.1 使用 target_link_libraries()
这是最直接的方式。在依赖方的CMakeLists.txt里声明:
# libs/net/CMakeLists.txt
add_library(NetLib STATIC tcp.cpp)
target_include_directories(NetLib PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
target_link_libraries(NetLib PUBLIC MathLib) # 依赖MathLib
注意这里用的是 PUBLIC。这意味着:
- NetLib 自己能看到 MathLib 的头文件
- 任何链接 NetLib 的目标,也会自动链接 MathLib
主程序那边就简单了:
# src/CMakeLists.txt
add_executable(myapp main.cpp)
target_link_libraries(myapp PRIVATE NetLib MathLib)
用 PRIVATE 表示这些依赖只在主程序内部使用,不会传递出去。
19.4.2 使用 find_package() 管理外部依赖
如果模块是独立开发的,或者来自第三方,可以用 find_package():
# 顶层 CMakeLists.txt
find_package(MathLib REQUIRED)
find_package(NetLib REQUIRED)
add_subdirectory(src)
这种方式要求每个模块提供 MathLibConfig.cmake 或 MathLibConfig.cmake 文件。我一般会在模块的CMakeLists.txt里用 install() 命令生成这些配置文件,方便其他项目引用。
19.4.3 依赖顺序问题
这里有个细节要注意:add_subdirectory() 的顺序。CMake是按顺序处理的,如果 NetLib 依赖 MathLib,那 MathLib 必须先被添加:
# 正确
add_subdirectory(libs/math)
add_subdirectory(libs/net)
# 错误:NetLib 找不到 MathLib
add_subdirectory(libs/net)
add_subdirectory(libs/math)
我建议:把基础库放在前面,上层应用放在后面。这样依赖关系自然就理顺了。
19.5 知识体系总览
下面这张图把本章的核心逻辑串起来了:
19.6 避坑指南
最后,分享几个我实际项目中遇到的坑:
- 循环依赖:模块A依赖B,B又依赖A。CMake会直接报错。解决办法是重新设计模块边界,或者把公共部分抽成第三个模块。
- 头文件路径混乱:用了
target_include_directories但没注意PUBLIC和PRIVATE的区别,导致依赖方找不到头文件。我的经验是:对外暴露的头文件用PUBLIC,内部实现用PRIVATE。 - 忘记加
GLOB的陷阱:有人用file(GLOB ...)自动收集源文件,但新增文件后CMake不会自动重新扫描。我建议还是手动列出源文件,或者用CONFIGURE_DEPENDS选项。
我曾经犯过的错:在一个大型项目中,我把所有模块的 add_subdirectory() 都放在顶层文件末尾,结果某个模块依赖的库还没被加载,编译直接失败。后来我养成了一个习惯——每次写完 add_subdirectory() 列表,都按依赖顺序从上到下检查一遍。
多模块管理其实不难,核心就是「分而治之」四个字。把大项目拆成小模块,每个模块独立维护,依赖关系用 target_link_libraries() 清晰表达。这样项目结构一目了然,团队协作也顺畅得多。
公众号:蓝海资料掘金营,微信deep3321