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.cmakeMathLibConfig.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 知识体系总览

下面这张图把本章的核心逻辑串起来了:

顶层 CMakeLists.txt add_subdirectory() 调用 libs/math 基础数学库 libs/net 网络库 src 主程序 依赖 依赖 跨模块依赖管理要点 • target_link_libraries() 声明依赖关系 • PUBLIC / PRIVATE / INTERFACE 控制传递范围 • add_subdirectory() 顺序:基础库在前,应用在后

19.6 避坑指南

最后,分享几个我实际项目中遇到的坑:

  • 循环依赖:模块A依赖B,B又依赖A。CMake会直接报错。解决办法是重新设计模块边界,或者把公共部分抽成第三个模块。
  • 头文件路径混乱:用了 target_include_directories 但没注意 PUBLICPRIVATE 的区别,导致依赖方找不到头文件。我的经验是:对外暴露的头文件用 PUBLIC,内部实现用 PRIVATE
  • 忘记加 GLOB 的陷阱:有人用 file(GLOB ...) 自动收集源文件,但新增文件后CMake不会自动重新扫描。我建议还是手动列出源文件,或者用 CONFIGURE_DEPENDS 选项。

我曾经犯过的错:在一个大型项目中,我把所有模块的 add_subdirectory() 都放在顶层文件末尾,结果某个模块依赖的库还没被加载,编译直接失败。后来我养成了一个习惯——每次写完 add_subdirectory() 列表,都按依赖顺序从上到下检查一遍。

多模块管理其实不难,核心就是「分而治之」四个字。把大项目拆成小模块,每个模块独立维护,依赖关系用 target_link_libraries() 清晰表达。这样项目结构一目了然,团队协作也顺畅得多。


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