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_math 或 cmake --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 的核心逻辑:
避坑指南
用 add_subdirectory 时,有几个地方容易翻车:
- 路径写错:路径是相对于当前 CMakeLists.txt 的,不是相对于项目根目录。我见过有人写
add_subdirectory(../lib),结果构建目录乱成一锅粥。 - 目标名冲突:不同子目录里不能定义同名的目标。CMake 会报错。解决办法是给目标加前缀,比如
lib_math_utils、app_my_app。 - 忘记传递依赖:子目录里定义的库,父目录要链接的话,必须确保子目录的
target_include_directories用了PUBLIC或INTERFACE。否则父目录找不到头文件。 - EXCLUDE_FROM_ALL 的副作用:被排除的子目录,其目标不会自动构建,但如果你手动指定构建某个目标,它的依赖仍然会被构建。这个行为有时候会让人困惑。
我曾经在重构一个大型项目时,把所有模块都扔进子目录,结果忘了处理头文件路径。编译时一堆 "file not found",排查了半小时才发现是 target_include_directories 没加 PUBLIC。从那以后,我写子目录的 CMakeLists.txt 时,第一件事就是检查头文件可见性。
总结
add_subdirectory 是组织多级项目结构的核心工具。它让 CMakeLists.txt 保持简洁,让模块职责清晰,让依赖关系透明。说白了,就是把一个大问题拆成小问题,每个小问题在自己的目录里解决。
记住几个要点:变量作用域、目标名唯一性、头文件可见性。掌握了这些,你的项目结构就能像乐高积木一样,想怎么搭就怎么搭。