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_NAMEPROJECT_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 理清核心逻辑

说了这么多,我画张图帮你把整个流程串起来:

顶层 CMakeLists.txt project(MyProject) | 全局编译选项 | add_subdirectory(...) libs/core add_library(core ...) 作用域:独立 libs/network project(Network ...) 作用域:嵌套项目 tests EXCLUDE_FROM_ALL 默认不构建 变量传递规则 父 → 子:自动继承(子目录可读父变量) 子 → 父:需使用 PARENT_SCOPE 或 return(PROPAGATE) 子目录修改变量:不影响父目录

避坑指南

最后分享几个我实际踩过的坑:

  • 路径问题: 在子目录里用 ${CMAKE_SOURCE_DIR} 还是 ${CMAKE_CURRENT_SOURCE_DIR}?记住,前者指向顶层目录,后者指向当前 CMakeLists.txt 所在目录。我建议在子目录里尽量用 CMAKE_CURRENT_SOURCE_DIR,这样子目录挪位置也不会出问题。
  • target 命名冲突: 不同子目录定义了同名的 target,CMake 会报错。我曾经在 core 和 utils 里都定义了 helper,编译时一脸懵。后来养成了加前缀的习惯,比如 core_helperutils_helper
  • 过度嵌套: 有人喜欢把子目录再套子目录,套个四五层。说实话,超过三层就不好维护了。我一般控制在两层以内:顶层 → 模块 → 实现细节。

个人建议: 每个子目录的 CMakeLists.txt 控制在 50 行以内。如果超过这个数,说明这个模块可能该拆分了。保持简洁,比什么都重要。

好了,关于 add_subdirectory 的用法就聊到这里。记住核心思想:每个子目录是一个独立的作用域,变量传递要显式处理,模块划分要清晰。做到这三点,你的项目结构就不会乱。


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