17、子目录与嵌套项目:add_subdirectory 组织多级项目结构

项目一大了,所有代码堆在一个 CMakeLists.txt 里,那画面太美我不敢看。说实话,我早期带团队时就吃过这个亏——一个文件上千行,找条链接命令都得翻半天。后来我学乖了,用 add_subdirectory 把项目拆成多级结构,清爽多了。

为什么需要子目录?

你想想看,一个真实项目通常长这样:

  • src/ —— 源代码
  • lib/ —— 内部库
  • third_party/ —— 第三方依赖
  • test/ —— 单元测试
  • doc/ —— 文档

如果所有目标都写在根目录的 CMakeLists.txt 里,维护成本会直线上升。更麻烦的是,不同模块可能有不同的编译选项、不同的依赖关系。这时候,add_subdirectory 就是你的救星。

核心作用add_subdirectory 告诉 CMake 进入指定子目录,执行那里的 CMakeLists.txt。子目录里定义的目标、变量,默认对父目录可见。

基本用法

语法很简单:

add_subdirectory(source_dir [binary_dir] [EXCLUDE_FROM_ALL])
  • source_dir:子目录的路径(相对于当前 CMakeLists.txt)
  • binary_dir:构建产物存放路径(可选,默认是 source_dir 的相对路径)
  • EXCLUDE_FROM_ALL:排除该子目录的目标,不参与默认构建(比如测试代码)

举个例子,假设项目结构如下:

my_project/
├── CMakeLists.txt          # 根目录
├── src/
│   ├── CMakeLists.txt      # 源码子目录
│   └── main.cpp
├── lib/
│   ├── CMakeLists.txt      # 内部库子目录
│   ├── math_utils.cpp
│   └── math_utils.h
└── test/
    ├── CMakeLists.txt      # 测试子目录
    └── test_math.cpp

根目录的 CMakeLists.txt 可以这样写:

cmake_minimum_required(VERSION 3.16)
project(MyProject VERSION 1.0.0 LANGUAGES CXX)

# 添加内部库
add_subdirectory(lib)

# 添加主程序
add_subdirectory(src)

# 测试代码,不参与默认构建
add_subdirectory(test EXCLUDE_FROM_ALL)

lib/CMakeLists.txt:

add_library(math_utils STATIC math_utils.cpp math_utils.h)

# 设置头文件搜索路径,方便父目录引用
target_include_directories(math_utils PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})

src/CMakeLists.txt:

add_executable(my_app main.cpp)

# 链接内部库
target_link_libraries(my_app PRIVATE math_utils)

test/CMakeLists.txt:

add_executable(test_math test_math.cpp)
target_link_libraries(test_math PRIVATE math_utils)

个人习惯:我通常把测试代码用 EXCLUDE_FROM_ALL 包起来。这样日常构建时不会编译测试,只有需要跑测试时才手动 make test_mathcmake --build . --target test_math。省时间,也省磁盘。

变量作用域与传递

这里有个坑,我曾经踩过好几次。子目录里定义的变量,默认对父目录可见。但反过来,父目录的变量不会自动传递到子目录。

看个例子:

# 根目录 CMakeLists.txt
set(MY_FLAG "ON")
add_subdirectory(lib)

# lib/CMakeLists.txt
message("MY_FLAG = ${MY_FLAG}")  # 输出:MY_FLAG = ON

子目录能读到父目录的变量,因为 add_subdirectory 会继承父作用域。但如果你在子目录里改了 MY_FLAG,父目录不受影响——除非你用 set(... PARENT_SCOPE)

我曾经在子目录里改了编译选项变量,结果父目录完全没感知,导致链接时符号不一致。排查了半天才发现是作用域问题。所以,跨目录传递变量时,要么用 PARENT_SCOPE,要么用缓存变量 set(... CACHE ...)

嵌套层级与深度

add_subdirectory 支持任意层级的嵌套。比如:

project/
├── CMakeLists.txt
├── lib/
│   ├── CMakeLists.txt
│   └── net/
│       ├── CMakeLists.txt
│       └── tcp_socket.cpp
└── app/
    ├── CMakeLists.txt
    └── main.cpp

lib/CMakeLists.txt 里可以继续嵌套:

add_subdirectory(net)

lib/net/CMakeLists.txt:

add_library(net_utils STATIC tcp_socket.cpp)
target_include_directories(net_utils PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})

app/CMakeLists.txt 里链接时,直接引用目标名即可:

target_link_libraries(my_app PRIVATE math_utils net_utils)

CMake 会自动处理依赖关系,你不需要关心目标定义在哪个子目录里。

构建目录结构

默认情况下,CMake 会在构建目录里镜像源码目录结构。比如:

build/
├── CMakeCache.txt
├── lib/
│   ├── CMakeFiles/
│   └── libmath_utils.a
├── src/
│   ├── CMakeFiles/
│   └── my_app
└── test/
    ├── CMakeFiles/
    └── test_math

如果你想改变构建目录布局,可以用 binary_dir 参数:

add_subdirectory(lib build_lib)

这样 lib 的构建产物会放在 build/build_lib/ 下,而不是 build/lib/

我个人习惯:保持源码和构建目录结构一致,这样调试时找 .o 文件方便。除非有特殊需求(比如多个子目录共享同一个构建目录),否则不建议改 binary_dir。

知识结构图

下面这张图展示了 add_subdirectory 的核心逻辑:

根 CMakeLists.txt add_subdirectory(lib) lib/CMakeLists.txt src/CMakeLists.txt add_library(math_utils ...) add_executable(my_app ...) target_link_libraries 根目录 子目录 链接关系

避坑指南

add_subdirectory 时,有几个地方容易翻车:

  1. 路径写错:路径是相对于当前 CMakeLists.txt 的,不是相对于项目根目录。我见过有人写 add_subdirectory(../lib),结果构建目录乱成一锅粥。
  2. 目标名冲突:不同子目录里不能定义同名的目标。CMake 会报错。解决办法是给目标加前缀,比如 lib_math_utilsapp_my_app
  3. 忘记传递依赖:子目录里定义的库,父目录要链接的话,必须确保子目录的 target_include_directories 用了 PUBLICINTERFACE。否则父目录找不到头文件。
  4. EXCLUDE_FROM_ALL 的副作用:被排除的子目录,其目标不会自动构建,但如果你手动指定构建某个目标,它的依赖仍然会被构建。这个行为有时候会让人困惑。

我曾经在重构一个大型项目时,把所有模块都扔进子目录,结果忘了处理头文件路径。编译时一堆 "file not found",排查了半小时才发现是 target_include_directories 没加 PUBLIC。从那以后,我写子目录的 CMakeLists.txt 时,第一件事就是检查头文件可见性。

总结

add_subdirectory 是组织多级项目结构的核心工具。它让 CMakeLists.txt 保持简洁,让模块职责清晰,让依赖关系透明。说白了,就是把一个大问题拆成小问题,每个小问题在自己的目录里解决。

记住几个要点:变量作用域、目标名唯一性、头文件可见性。掌握了这些,你的项目结构就能像乐高积木一样,想怎么搭就怎么搭。


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