导语
在前面的章节中,我们已经系统掌握了 Modern CMake 的核心语法:从目标(Target)的创建与管理,到源文件的组织、编译链接选项的精确控制,再到生成器表达式(Generator Expressions)和属性系统(Property System)的深层运用。可以说,单文件 CMakeLists.txt 能做的事情,你已经了然于胸。
然而,真正的工程实践从来不是”一个文件打天下”。当项目规模从几十个文件扩展到成百上千个文件,从单一可执行程序演变为”多个内部库 + 多个应用程序 + 测试套件”的大型工程时,将所有配置堆砌在根目录的一个 CMakeLists.txt 中,会导致文件臃肿不堪、逻辑混乱、协作困难。此时,多目录项目结构就成了必然选择。
从本章开始,我们将进入 CMake 项目组织的实战领域。本节作为第四章的开篇,先解决最基础也最关键的问题:如何把代码拆分到多个目录中,并让 CMake 正确地识别、构建和链接它们? 你将学到 add_subdirectory 的完整用法、父子目录间的变量作用域规则、源码树与构建树的组织最佳实践,以及大型项目的分层架构设计思路。
为什么需要多目录结构
在深入技术细节之前,先正视一个问题:为什么不能让所有代码和配置都挤在一个文件里?
- 可维护性崩溃: 一个超过 500 行的
CMakeLists.txt会让任何人(包括三个月后的你自己)望而生畏。 - 关注点分离: 核心算法库、网络模块、UI 层、单元测试,它们的构建逻辑本应相互独立。
- 团队协作: 多目录结构允许不同小组负责不同模块,减少版本冲突。
- 复用性: 独立的子目录更容易被其他项目通过
add_subdirectory或FetchContent复用。
Modern CMake 的哲学是”目录即作用域“。每个子目录的 CMakeLists.txt 都是一个相对独立的配置单元,通过显式的目标依赖(Target Dependency)而非全局变量来协同工作。
子目录添加:add_subdirectory 的用法与限制
基本语法
add_subdirectory 是组织多目录项目的核心命令,其完整语法如下:
add_subdirectory(source_dir [binary_dir] [EXCLUDE_FROM_ALL])
各参数含义:
source_dir:源码目录路径,必须包含一个 CMakeLists.txt 文件。binary_dir(可选):指定该子目录的构建输出目录。默认会生成到${CMAKE_BINARY_DIR}/source_dir。EXCLUDE_FROM_ALL(可选):排除在默认构建目标之外。通常用于测试、示例或可选模块。
最小可用示例
假设我们有如下项目结构:
multi_dir_demo/
├── CMakeLists.txt
├── main.cpp
└── math/
├── CMakeLists.txt
├── math.cpp
└── math.h
根目录的 CMakeLists.txt:
cmake_minimum_required(VERSION 3.20)
project(MultiDirDemo LANGUAGES CXX)
# 添加子目录。CMake 会进入 math/ 目录,执行其中的 CMakeLists.txt
add_subdirectory(math)
add_executable(demo main.cpp)
# math 子目录中定义的目标可以直接在这里引用!
target_link_libraries(demo PRIVATE math_lib)
子目录 math/CMakeLists.txt:
add_library(math_lib STATIC math.cpp)
target_include_directories(math_lib
PUBLIC
${CMAKE_CURRENT_SOURCE_DIR}
)
注意这里使用了 ${CMAKE_CURRENT_SOURCE_DIR} 而非简单的 .。这是多目录项目中的黄金法则:永远用 CMake 内置变量指代当前目录,避免相对路径的歧义。
EXCLUDE_FROM_ALL 的妙用
假设项目中有一个 examples/ 目录,里面存放示例程序。你希望在构建主项目时默认不编译它们,但允许开发者手动构建:
add_subdirectory(examples EXCLUDE_FROM_ALL)
此时执行 cmake --build build 不会编译 examples 下的目标。但如果开发者执行 cmake --build build --target some_example,仍然可以单独构建。
显式指定 binary_dir
有时源码目录名与构建目录结构不对应,或者需要避免路径冲突:
# 将 third_party/zlib 的构建输出放到 build/_deps/zlib-build
add_subdirectory(third_party/zlib _deps/zlib-build)
这在引入外部子项目或处理同名目录时非常有用。
add_subdirectory 的限制
使用 add_subdirectory 时必须遵守以下规则,否则 CMake 会直接报错:
- 必须存在 CMakeLists.txt: 指定的
source_dir下必须有CMakeLists.txt文件。 - 禁止循环引用: 如果目录 A 通过
add_subdirectory引入了目录 B,那么 B 及其子目录中绝不能再通过add_subdirectory回引 A。 - 路径限制: 不能添加当前源码目录的父目录(即不能
add_subdirectory(..)),也不能添加已经处理过的目录。
父目录与子目录的变量作用域
这是多目录项目中最容易踩坑的地方。理解变量作用域,是避免”子目录改了一个值,父目录莫名其妙受影响”(或相反)的关键。
基本规则:单向继承
CMake 的变量作用域遵循函数调用栈模型:
- 父目录中定义的普通变量,在子目录中可见(可以读取)。
- 子目录中对普通变量的修改,不会影响父目录的值(类似值传递)。
- 子目录中定义的变量,父目录默认不可见。
看一个直观的例子:
# 根 CMakeLists.txt
set(MY_VAR "I am parent")
message(STATUS "Before subdir: ${MY_VAR}") # I am parent
add_subdirectory(sub)
message(STATUS "After subdir: ${MY_VAR}") # I am parent(保持不变!)
# sub/CMakeLists.txt
set(MY_VAR "I am child")
message(STATUS "Inside subdir: ${MY_VAR}") # I am child
使用 PARENT_SCOPE 向上传递变量
如果子目录确实需要向父目录”回传”信息,必须使用 PARENT_SCOPE 关键字:
# sub/CMakeLists.txt
set(MY_VAR "I am child" PARENT_SCOPE)
message(STATUS "Inside subdir (set PARENT_SCOPE): ${MY_VAR}") # 这里打印的还是旧值!
重要提示:PARENT_SCOPE 只影响父作用域一层,且在当前作用域不会立即生效。它通常用于子目录收集了一组源文件后,把列表传回父目录统一处理(不过 Modern CMake 更推荐在子目录直接创建目标)。
缓存变量:全局可见的”公共公告栏”
通过 set(... CACHE ...) 定义的缓存变量,以及通过 option() 定义的选项,是全局可见的。无论父子目录,都能读取和修改(修改会写回缓存)。
# 根目录
option(BUILD_TESTS "Build the tests" ON)
# 子目录
if(BUILD_TESTS)
message(STATUS "Tests are enabled")
endif()
目标是全局符号
与变量不同,目标(Target)是全局的。在子目录中通过 add_library 或 add_executable 创建的目标,可以在项目的任何其他目录中通过 target_link_libraries 引用。这是 Modern CMake 多目录协作的基石。
# libs/math/CMakeLists.txt
add_library(math STATIC math.cpp)
add_library(MyProject::math ALIAS math) # 提供命名空间别名
# apps/calc/CMakeLists.txt
add_executable(calc main.cpp)
# 无需任何变量传递,直接链接另一个目录定义的目标
target_link_libraries(calc PRIVATE MyProject::math)
构建树与源代码树的目录组织
源码树(Source Tree)的推荐布局
一个结构清晰的项目不仅方便开发者阅读,也方便 CMake 管理。以下是业界广泛认可的 C++ 项目目录模板:
modern_cpp_project/
├── CMakeLists.txt # 根配置:项目元信息、全局约束
├── cmake/ # CMake 辅助脚本
│ ├── modules/ # 自定义 FindXXX.cmake
│ └── toolchains/ # 交叉编译工具链文件
├── include/ # 公共头文件(可选,视项目而定)
│ └── myproject/
├── src/ # 源码目录
│ ├── CMakeLists.txt
│ ├── core/
│ ├── utils/
│ └── main.cpp
├── libs/ # 内部子库
│ ├── networking/
│ └── storage/
├── apps/ # 可执行程序入口
│ ├── cli/
│ └── gui/
├── tests/ # 测试
│ ├── CMakeLists.txt
│ ├── unit/
│ └── integration/
├── docs/ # 文档
├── third_party/ # 内嵌第三方库(或外部依赖)
└── README.md
关键目录变量辨析
在多目录项目中,正确使用路径变量至关重要:
CMAKE_SOURCE_DIR:源码树根目录(始终指向最顶层)。CMAKE_CURRENT_SOURCE_DIR:当前正在处理的CMakeLists.txt所在目录。PROJECT_SOURCE_DIR:最近一次project()命令所在目录(子项目场景中可能与根目录不同)。CMAKE_BINARY_DIR:构建树根目录。CMAKE_CURRENT_BINARY_DIR:当前源码目录对应的构建输出目录。
一个调试脚本帮你理解它们的区别:
# 根 CMakeLists.txt
message(STATUS "ROOT: CMAKE_SOURCE_DIR = ${CMAKE_SOURCE_DIR}")
message(STATUS "ROOT: CMAKE_CURRENT_SOURCE_DIR = ${CMAKE_CURRENT_SOURCE_DIR}")
add_subdirectory(src)
# src/CMakeLists.txt
message(STATUS "SRC: CMAKE_SOURCE_DIR = ${CMAKE_SOURCE_DIR}")
message(STATUS "SRC: CMAKE_CURRENT_SOURCE_DIR = ${CMAKE_CURRENT_SOURCE_DIR}")
message(STATUS "SRC: CMAKE_CURRENT_BINARY_DIR = ${CMAKE_CURRENT_BINARY_DIR}")
构建树(Build Tree)的隔离原则
我们曾在 1.3 第一个 CMake 项目 中强调过源码外构建(Out-of-source build)。在多目录项目中,这一点更加重要。通过 add_subdirectory,CMake 会自动在构建树中镜像源码树的目录结构:
build/
├── CMakeFiles/
├── CMakeCache.txt
├── src/ # 对应源码 src/
│ ├── CMakeFiles/
│ └── main.cpp.o
├── libs/ # 对应源码 libs/
│ └── libnetworking.a
└── apps/
└── cli
└── calc # 可执行文件
这种镜像关系保证了不同目录的同名文件(如各目录都有的 utils.cpp)在编译时不会冲突。
大型项目的目录层级设计
分层架构:库与应用的分离
对于大型项目,强烈推荐采用库与应用分离的策略:
libs/:存放所有内部静态库或动态库。每个子目录应自包含,对外暴露add_library目标。apps/:存放可执行文件入口。它们通常很薄,主要职责是组装(wire)底层库并启动程序。tests/:测试代码独立目录,通过EXCLUDE_FROM_ALL可选加入。
这种结构天然支持依赖倒置:库之间可以相互依赖(形成 DAG 有向无环图),而应用层只依赖库层。
根 CMakeLists.txt 的职责最小化
根文件应该是项目的”总控制台”,而不是”具体实施者”。它的职责应限于:
- 设置最低 CMake 版本和项目名称。
- 全局编译标准(如 C++17)。
- 查找外部依赖(
find_package)。 - 添加子目录。
- 提供全局选项(如
BUILD_TESTS)。
# 根 CMakeLists.txt 范例(大型项目)
cmake_minimum_required(VERSION 3.20)
project(EnterpriseApp VERSION 2.5.0 LANGUAGES CXX)
# === 全局约束 ===
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_EXPORT_COMPILE_COMMANDS ON) # 为 clangd/IntelliSense 生成编译数据库
# === 选项 ===
option(BUILD_TESTS "Build unit and integration tests" ON)
option(BUILD_SHARED_LIBS "Build shared instead of static libraries" OFF)
# === 查找外部依赖 ===
find_package(Boost 1.75 REQUIRED COMPONENTS asio filesystem)
find_package(Threads REQUIRED)
find_package(spdlog CONFIG REQUIRED)
# === 添加内部组件 ===
add_subdirectory(libs/core)
add_subdirectory(libs/networking)
add_subdirectory(libs/storage)
add_subdirectory(apps/cli)
add_subdirectory(apps/server)
# === 测试 ===
if(BUILD_TESTS AND PROJECT_SOURCE_DIR STREQUAL CMAKE_SOURCE_DIR)
enable_testing()
add_subdirectory(tests)
endif()
子目录的自治范式
每个库目录的 CMakeLists.txt 应该能够独立理解,不依赖根文件的隐式上下文:
# libs/networking/CMakeLists.txt
file(GLOB_RECURSE NET_SOURCES CONFIGURE_DEPENDS "src/*.cpp")
add_library(networking ${NET_SOURCES})
# 自包含的头文件声明
target_include_directories(networking
PUBLIC
# 构建时使用本地 include 目录
$
# 安装后使用标准 include 前缀
$
PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/src
)
# 自包含的依赖声明
target_link_libraries(networking
PUBLIC
Boost::asio
Threads::Threads
MyProject::core # 依赖同项目的 core 库
PRIVATE
spdlog::spdlog
)
# 提供命名空间别名,强制外部通过 MyProject::networking 引用
add_library(MyProject::networking ALIAS networking)
这种模式被称为自包含目标(Self-contained Target):任何一个子目录被单独拷贝到另一个项目中,只要补上外部依赖,就能直接工作。
模块划分的粒度建议
目录不是分得越细越好。建议遵循以下原则:
- 单一职责: 一个目录对应一个内聚的功能域(如网络、数据库、图像处理)。
- 依赖单向: 严禁循环依赖。如果
libs/A依赖libs/B,那么 B 绝不能再依赖 A。 - 接口最小化: 每个库对外暴露的头文件应放在
include/或专门的接口目录中,实现细节藏在src/。 - 测试跟随源码: 可以在每个
libs/xxx/下建立tests/子目录,也可以在项目根目录统一放置。两种方式各有优劣,关键是团队统一。
常见陷阱与规避
陷阱 1:在子目录中使用全局作用域命令
老派 CMake 习惯在子目录中使用 include_directories、link_libraries 等全局命令。这在多目录项目中是灾难性的,会导致污染其他子目录的编译环境。
规避: 始终使用 target_include_directories、target_link_libraries 等目标级命令。
陷阱 2:相对路径的歧义
在子目录中写 add_executable(app main.cpp) 时,路径是相对于 CMAKE_CURRENT_SOURCE_DIR 的。但如果混用 ${CMAKE_SOURCE_DIR}/main.cpp 和裸写的 main.cpp,会导致路径混乱。
规避: 在引用跨目录文件时,始终显式使用 ${CMAKE_CURRENT_SOURCE_DIR} 或 ${CMAKE_SOURCE_DIR} 作为前缀。
陷阱 3:变量名冲突
父子目录如果都定义了 set(SOURCES ...),子目录的变量会遮蔽父目录的变量,但不会修改父目录的值。这可能导致父目录后续逻辑使用了错误的源文件列表。
规避: 采用命名空间化的变量名,如 CORE_SOURCES、NET_SOURCES,或在子目录使用完毕后用 unset(SOURCES) 清理。
小结
本节我们迈出了从”单文件玩具项目”到”多目录工程化项目”的关键一步。你需要记住以下核心要点:
add_subdirectory是组织多目录项目的核心命令,配合EXCLUDE_FROM_ALL可以灵活控制构建范围。- 变量作用域是单向继承的,子目录默认无法回改变量;需要回传时使用
PARENT_SCOPE,但更好的做法是让子目录直接创建目标。 - 目标是全局符号,这是 Modern CMake 多目录协作的基石。通过
target_link_libraries跨目录引用目标,远比传递变量列表优雅。 - 大型项目应采用 libs/ + apps/ + tests/ 的分层架构,根
CMakeLists.txt保持最小化,每个子目录自包含。
掌握了目录结构的基本划分后,下一节我们将进一步探讨模块与函数的复用:如何编写可复用的 .cmake 模块文件、自定义 CMake 函数与宏,以及参数解析的最佳实践,让你的 CMake 代码更加 DRY(Don’t Repeat Yourself)。


没有回复内容