18、子目录与嵌套项目:add_subdirectory 的用法
项目一大了,所有代码堆在一个 CMakeLists.txt 里,那画面太美我不敢看。说实话,我早期做 C++ 项目时就吃过这个亏——一个文件三千行,找条编译选项都得翻半天。后来接触了 add_subdirectory,才真正体会到什么叫「模块化」的快感。
今天我们就来聊聊,怎么用 add_subdirectory 把项目拆得清清爽爽,同时把作用域和变量传递玩明白。
add_subdirectory 的基本用法
先看最基础的写法:
# 顶层 CMakeLists.txt
cmake_minimum_required(VERSION 3.16)
project(MyProject VERSION 1.0)
add_subdirectory(src)
add_subdirectory(tests)
这行命令告诉 CMake:「去 src 目录下,执行它里面的 CMakeLists.txt」。说白了,就是把子目录当成一个独立的 CMake 项目来构建。子目录里可以定义自己的 target、自己的变量,甚至自己的 project() 命令。
核心要点: add_subdirectory 不是简单的文件包含,而是启动了一个新的 CMake 作用域。
作用域与变量传递
这里有个坑,我当年踩过。CMake 的作用域规则其实很简单:
- 子目录可以访问父目录的变量(默认继承)
- 父目录不能直接访问子目录定义的变量
- 子目录修改变量,不会影响父目录
为什么会这样?因为每个 add_subdirectory 调用都会创建一个新的变量作用域。子目录就像一间独立的房间,你能看到客厅里的东西,但你在房间里怎么折腾,客厅里看不到。
看个例子:
# 顶层 CMakeLists.txt
set(MY_VAR "hello")
add_subdirectory(lib)
message("After subdirectory: ${MY_VAR}") # 输出:hello
message("From lib: ${LIB_VAR}") # 输出:空(未定义)
# lib/CMakeLists.txt
message("In lib: ${MY_VAR}") # 输出:hello(继承了父目录)
set(MY_VAR "world")
set(LIB_VAR "secret")
message("After set: ${MY_VAR}") # 输出:world
运行结果:
In lib: hello
After set: world
After subdirectory: hello
From lib:
看到了吗?子目录改了 MY_VAR,父目录完全不受影响。这就是作用域隔离。
如何把变量传回父目录?
有时候我们确实需要子目录「汇报」一些信息。比如子目录检测到了某个第三方库,想告诉父目录。这时候可以用 PARENT_SCOPE:
# lib/CMakeLists.txt
set(LIB_FOUND TRUE PARENT_SCOPE)
set(LIB_INCLUDE_DIR "${CMAKE_CURRENT_SOURCE_DIR}/include" PARENT_SCOPE)
加上 PARENT_SCOPE 后,变量会写入父目录的作用域。我个人习惯只在必要的时候用这个,滥用会导致变量传递路径混乱,你想想看,一个变量从子目录传到父目录,再传到另一个子目录,调试起来多头疼。
小技巧: 如果你有一组变量需要传递,可以考虑用 return() 命令配合 PROPAGATE 参数(CMake 3.25+)。不过目前大多数项目还是用 PARENT_SCOPE 更稳妥。
多模块项目的组织方式
一个典型的 C++ 项目,我通常会这样组织:
my_project/
├── CMakeLists.txt # 顶层
├── cmake/
│ └── helpers.cmake # 辅助函数
├── libs/
│ ├── core/
│ │ ├── CMakeLists.txt
│ │ └── src/
│ ├── network/
│ │ ├── CMakeLists.txt
│ │ └── src/
│ └── utils/
│ ├── CMakeLists.txt
│ └── src/
├── apps/
│ ├── cli/
│ │ ├── CMakeLists.txt
│ │ └── main.cpp
│ └── gui/
│ ├── CMakeLists.txt
│ └── main.cpp
└── tests/
├── CMakeLists.txt
└── test_core.cpp
顶层 CMakeLists.txt 只做三件事:设置全局编译选项、添加子目录、定义安装规则。每个子目录只关心自己的事。
# 顶层 CMakeLists.txt
cmake_minimum_required(VERSION 3.16)
project(MyProject VERSION 1.0.0)
# 全局编译选项
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
# 添加各个模块
add_subdirectory(libs/core)
add_subdirectory(libs/network)
add_subdirectory(libs/utils)
# 可执行程序
add_subdirectory(apps/cli)
add_subdirectory(apps/gui)
# 测试
option(BUILD_TESTS "Build tests" ON)
if(BUILD_TESTS)
enable_testing()
add_subdirectory(tests)
endif()
每个 lib 子目录的 CMakeLists.txt 则专注于定义自己的 target:
# libs/core/CMakeLists.txt
add_library(core
src/core_impl.cpp
src/memory.cpp
)
target_include_directories(core
PUBLIC
${CMAKE_CURRENT_SOURCE_DIR}/include
PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/src
)
target_link_libraries(core
PUBLIC
utils
)
这种组织方式的好处是:每个模块的依赖关系清晰,编译时只重新编译改动的模块。我曾经在一个项目里见过把所有源文件塞进一个 target 的写法,改一行代码要重新编译十分钟……嗯,后来我花了两天帮他们拆模块。
控制子目录的构建范围
有时候我们不想构建所有子目录。比如发布版本不需要测试,或者某些模块只在特定平台编译。可以用 EXCLUDE_FROM_ALL:
add_subdirectory(tests EXCLUDE_FROM_ALL)
加上这个参数后,tests 目录下的 target 不会默认构建,只有显式指定 make tests 才会编译。我个人习惯把测试、示例、文档这类非核心内容都加上 EXCLUDE_FROM_ALL。
注意: EXCLUDE_FROM_ALL 只影响默认构建目标。如果其他 target 依赖了被排除的子目录中的 target,那它仍然会被构建。说白了,它只是不加入 ALL 目标,不是彻底禁用。
嵌套项目的变量隔离
如果你在子目录里调用 project() 命令,CMake 会创建一个新的项目作用域。这意味着:
- 子目录可以有自己的
PROJECT_NAME、PROJECT_VERSION - 子目录的
CMAKE_*变量不会影响父目录 - 但
add_subdirectory创建的作用域仍然有效
看个实际场景:
# libs/network/CMakeLists.txt
project(Network VERSION 2.1.0)
message("Project: ${PROJECT_NAME}, version: ${PROJECT_VERSION}")
# 输出:Project: Network, version: 2.1.0
在顶层,PROJECT_NAME 仍然是 MyProject。这种隔离机制在集成第三方库时特别有用——你不用担心子项目的设置污染主项目。
用 SVG 理清核心逻辑
说了这么多,我画张图帮你把整个流程串起来:
避坑指南
最后分享几个我实际踩过的坑:
- 路径问题: 在子目录里用
${CMAKE_SOURCE_DIR}还是${CMAKE_CURRENT_SOURCE_DIR}?记住,前者指向顶层目录,后者指向当前 CMakeLists.txt 所在目录。我建议在子目录里尽量用CMAKE_CURRENT_SOURCE_DIR,这样子目录挪位置也不会出问题。 - target 命名冲突: 不同子目录定义了同名的 target,CMake 会报错。我曾经在 core 和 utils 里都定义了
helper,编译时一脸懵。后来养成了加前缀的习惯,比如core_helper、utils_helper。 - 过度嵌套: 有人喜欢把子目录再套子目录,套个四五层。说实话,超过三层就不好维护了。我一般控制在两层以内:顶层 → 模块 → 实现细节。
个人建议: 每个子目录的 CMakeLists.txt 控制在 50 行以内。如果超过这个数,说明这个模块可能该拆分了。保持简洁,比什么都重要。
好了,关于 add_subdirectory 的用法就聊到这里。记住核心思想:每个子目录是一个独立的作用域,变量传递要显式处理,模块划分要清晰。做到这三点,你的项目结构就不会乱。
公众号:蓝海资料掘金营,微信 deep3321