13. 4.1 多目录项目结构

引言:从“独栋别墅”到“规划社区”

在前面的章节中,我们的CMake项目都像是独栋别墅——所有的源代码、头文件、CMakeLists.txt都挤在同一个目录里。对于Hello World或者小型工具来说,这无可厚非;但当一个项目开始成长,源文件从几个变成几十个、几百个时,把所有文件堆在根目录就像把所有家具塞进客厅:不但找东西困难,而且不同功能模块的代码互相干扰,编译时间也会失控。

现实中的大型C++项目,比如Qt、LLVM、Boost,无一不是多目录、分层级、模块化的组织方式。从本章开始,我们将学习如何把CMake项目从“独栋”扩展为“规划社区”:通过add_subdirectory将不同楼栋(模块)纳入统一管理,同时理清变量如何在楼层间传递,以及源码树与构建树的最佳布局策略。

一、子目录添加:add_subdirectory的用法与限制

要让CMake进入子目录处理额外的CMakeLists.txt,核心命令是add_subdirectory。它的基本逻辑很简单:CMake会暂停当前目录的处理,进入指定的子目录执行其中的CMakeLists.txt,完成后再回到父目录继续。

1.1 基本语法与双目录参数

add_subdirectory的完整签名如下:

add_subdirectory(source_dir [binary_dir] [EXCLUDE_FROM_ALL])
  • source_dir:子目录的源码路径(相对于当前CMakeLists.txt的目录)。
  • binary_dir:可选,指定该子目录在构建树中的对应位置。如果不指定,CMake默认在构建树中创建与源码树同名的目录。
  • EXCLUDE_FROM_ALL:可选,表示该子目录中的目标默认不会被ALL目标包含(即执行cmake --build .时不会构建),除非你显式指定。

一个典型的使用场景是分离src和tests:

# 根 CMakeLists.txt
cmake_minimum_required(VERSION 3.20)
project(MyCommunity VERSION 1.0.0)

add_subdirectory(src)           # 构建树中对应 build/src
add_subdirectory(tests)         # 构建树中对应 build/tests
add_subdirectory(third_party/json EXCLUDE_FROM_ALL)  # 第三方库默认不构建

1.2 显式指定binary_dir的场景

有时源码目录名较长,或者你希望构建树中的目录名与源码树不同,可以显式指定第二个参数:

add_subdirectory(third_party/googletest-1.14.0 gtest_build)

这样,虽然源码位于third_party/googletest-1.14.0,但生成的中间文件会放在构建树的gtest_build目录下,保持构建树整洁。

1.3 关键限制与常见陷阱

虽然add_subdirectory很强大,但新手容易踩到以下几个坑:

  1. 目录必须是实际存在的子目录add_subdirectory要求指定的源码目录必须物理存在于文件系统中(或已被之前的命令创建),不能指向一个尚未生成的目录。
  2. 不能重复添加同一目录:同一个源码目录不能被add_subdirectory调用两次,否则CMake会报错。如果你需要复用同一个目录的逻辑,考虑将其封装为CMake模块(include)或者函数。
  3. 循环依赖是禁止的:目录A通过add_subdirectory引入目录B,而目录B又尝试引入目录A,这会导致CMake报错。目录树必须是一个有向无环图(DAG)。
  4. EXCLUDE_FROM_ALL的行为:被排除的目标不会自动构建,但如果其他目标依赖了它,该目标仍然会被编译。它影响的只是默认的构建范围,而非依赖解析。

二、父CMakeLists.txt的变量作用域:传递与隔离

当CMake从父目录进入子目录时,变量如何传递?子目录修改的变量会影响父目录吗?这是多目录项目中最容易混淆的问题之一。

2.1 目录作用域的基本规则

CMake的变量作用域遵循一个简单原则:子目录会继承父目录中已定义的所有普通变量和缓存变量,但子目录中对普通变量的修改不会回传到父目录。这类似于函数调用的值传递机制。

举个例子:

# 根 CMakeLists.txt
set(MY_FLAG "root")
message(STATUS "Before: MY_FLAG = ${MY_FLAG}")  # 输出 root
add_subdirectory(subdir)
message(STATUS "After: MY_FLAG = ${MY_FLAG}")   # 仍然是 root!

# subdir/CMakeLists.txt
set(MY_FLAG "child")
message(STATUS "Inside subdir: MY_FLAG = ${MY_FLAG}")  # 输出 child

可以看到,子目录对MY_FLAG的修改被限制在子目录及其后代目录的作用域内,父目录“看不见”这个变化。

2.2 使用PARENT_SCOPE突破边界

如果你确实需要让子目录向父目录“回传”变量,可以使用PARENT_SCOPE选项:

# subdir/CMakeLists.txt
set(MY_FLAG "child" PARENT_SCOPE)

此时,父目录在add_subdirectory返回后,MY_FLAG会被更新为child。但要注意:PARENT_SCOPE只向上跳一级,不会影响祖父目录。

2.3 缓存变量的全局可见性

缓存变量(通过set(... CACHE ...)定义或通过-D在命令行传入)是全局的。无论在哪个目录修改缓存变量,整个CMake项目都会感知到变化(因为缓存文件是全局共享的)。

# subdir/CMakeLists.txt
set(GLOBAL_OPTION "ON" CACHE BOOL "A global option" FORCE)

虽然缓存变量方便全局配置,但在Modern CMake中,我们更倾向于使用target_系列命令和接口库来传递信息,减少对全局缓存变量的依赖(参见3.1节和3.2节)。

2.4 策略(Policy)与变量隔离

CMake的策略(Policy)设置通常也具有目录作用域。cmake_policy(SET CMP0XXX NEW)在当前目录及其子目录生效,但不会向上影响父目录。这为不同子目录采用不同的行为标准提供了可能(尽管不推荐在一个项目中混用太多不同的策略标准)。

三、构建树与源码树的目录组织:最佳实践建议

在多目录项目中,源码树(Source Tree,你的源代码文件)和构建树(Build Tree,CMake生成的中间文件、目标文件)的组织方式直接决定了项目的可维护性。

3.1 源码外构建的强制原则

我们在1.3节中已经介绍过源码外构建(Out-of-source build)。在多目录项目中,这条原则变得更加重要。假设你的项目结构如下:

MyProject/
├── CMakeLists.txt
├── src/
│   ├── CMakeLists.txt
│   ├── main.cpp
│   └── utils/
│       ├── CMakeLists.txt
│       └── helper.cpp
├── tests/
│   ├── CMakeLists.txt
│   └── test_main.cpp
└── build/          <-- 手动创建的构建目录

你应该在build目录中运行:

cd build
cmake ..
cmake --build .

这样,所有生成的.o文件、Makefile、可执行文件都会镜像地出现在build/src/build/tests/中,与源码完全隔离。

3.2 构建树的镜像结构

CMake默认会为每个add_subdirectory的目录在构建树中创建对应的目录。这种镜像关系让查找编译产物变得直观:

  • 源码 src/utils/ → 构建 build/src/utils/
  • 源码 tests/ → 构建 build/tests/

如果你显式指定了binary_dir参数,则可以打破这种镜像,自定义构建树布局。例如,把所有第三方库的构建产物集中到build/deps/下:

add_subdirectory(third_party/spdlog deps/spdlog)
add_subdirectory(third_party/json deps/json)

3.3 避免在源码树中生成文件

某些旧项目或不当配置会导致CMake在源码目录中生成文件(如配置头文件通过configure_file输出到源码目录)。这在多目录项目中尤其危险,因为多个构建配置(Debug/Release)可能会覆盖同一个文件。始终将生成文件放在构建树内

四、大型项目的目录层级设计:分层架构与模块划分

当项目规模进一步扩大,例如包含多个可执行文件、多个共享库、外部依赖、工具脚本时,扁平化的目录结构就不再适用。下面介绍一种经过业界验证的目录层级设计模式。

4.1 标准目录模板

一个现代C++项目的典型顶层结构如下:

LargeProject/
├── CMakeLists.txt              # 根配置:项目信息、全局选项、子目录汇总
├── cmake/                      # CMake模块与工具链文件
│   ├── modules/
│   │   └── FindCustomLib.cmake
│   └── toolchains/
│       └── arm-linux-gnueabihf.cmake
├── src/                        # 主源码
│   ├── CMakeLists.txt
│   ├── app/                    # 可执行文件(入口)
│   │   ├── CMakeLists.txt
│   │   └── main.cpp
│   ├── core/                   # 核心业务逻辑库
│   │   ├── CMakeLists.txt
│   │   ├── engine.cpp
│   │   └── include/
│   │       └── engine.hpp
│   └── utils/                  # 工具库
│       ├── CMakeLists.txt
│       └── strings.cpp
├── tests/                      # 测试代码
│   ├── CMakeLists.txt
│   ├── unit/
│   └── integration/
├── third_party/                # 外部依赖(子模块或FetchContent)
│   ├── CMakeLists.txt
│   ├── fmt
│   └── catch2
├── docs/                       # 文档
├── scripts/                    # 构建辅助脚本
└── build/                      # 构建树(不提交到版本控制)

4.2 分层架构的设计哲学

在上述结构中,每一层都有明确的职责边界:

  • 根CMakeLists.txt:扮演“城市规划局”的角色。它定义项目元信息(名称、版本、语言标准)、设置全局策略(cmake_minimum_requiredcmake_policy)、引入共享配置(如编译警告接口库,参考3.2节),最后通过add_subdirectory把各个模块“批准开工”。
  • src/:内部再按功能划分为app/(可执行入口)、core/(核心库)、utils/(通用工具库)。每个子目录都是一个独立的CMake目标管理单元,只对外暴露必要的接口(通过target_include_directories(... PUBLIC))。
  • tests/:与源码分离,但可以通过target_link_libraries链接到src中的库目标。这种分离确保了测试代码不会污染主构建产物。
  • third_party/:存放外部依赖。对于Modern CMake项目,推荐使用FetchContent(见4.3节)或find_package,而不是直接把第三方源码塞进版本控制。如果必须包含,建议用EXCLUDE_FROM_ALL避免污染主构建。
  • cmake/:存放项目私有的Find模块、工具链文件和辅助函数。通过list(APPEND CMAKE_MODULE_PATH "${CMAKE_SOURCE_DIR}/cmake/modules")让CMake能找到它们。

4.3 模块划分的粒度控制

划分模块时,建议遵循“一个职责,一个目录,一个主要目标”的原则。不要把所有库塞进一个巨大的src/CMakeLists.txt里,也不要细到每个.cpp文件都建一个目录。

一个实用的判断标准是:如果一组源文件总是被其他模块作为一个整体来链接,那它们就应该被封装为一个独立目录(和目标)。例如:

# src/core/CMakeLists.txt
add_library(core STATIC
    engine.cpp
    renderer.cpp
    physics.cpp
)

target_include_directories(core PUBLIC
    $
    $
)

target_link_libraries(core PUBLIC
    utils
    spdlog::spdlog
)

这样,app模块只需要target_link_libraries(my_app PRIVATE core),就能自动获得core的所有PUBLIC依赖和头文件路径,完美体现了Modern CMake的传递性设计(参见3.1节)。

4.4 根CMakeLists.txt的示例骨架

最后,给出一个大型项目根CMakeLists.txt的参考骨架,供你在实战中直接使用:

cmake_minimum_required(VERSION 3.20)
project(LargeProject VERSION 2.1.0 LANGUAGES CXX)

# 1. 全局设置
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)

# 2. 自定义模块路径
list(APPEND CMAKE_MODULE_PATH "${CMAKE_SOURCE_DIR}/cmake/modules")

# 3. 全局接口库:统一编译警告(参见3.2节)
add_library(project_warnings INTERFACE)
target_compile_options(project_warnings INTERFACE
    $<$:/W4 /permissive->
    $<$<NOT:$>:-Wall -Wextra -Wpedantic>
)

# 4. 依赖管理
find_package(Threads REQUIRED)
include(FetchContent)  # 为后续third_party使用做准备

# 5. 子目录(注意顺序:被依赖的在前,依赖的在后)
add_subdirectory(src/utils)
add_subdirectory(src/core)
add_subdirectory(src/app)
add_subdirectory(tests)

# 6. 安装与打包配置(第六章将详细介绍)
include(GNUInstallDirs)

小结

在本节中,我们学习了如何将CMake项目从单目录扩展为多目录结构:

  • 使用add_subdirectory将子目录纳入构建体系,注意其二进制目录参数和EXCLUDE_FROM_ALL的妙用。
  • 理解CMake的目录作用域规则:子目录继承父变量但修改不回流,除非使用PARENT_SCOPE;缓存变量全局可见,但应谨慎使用。
  • 坚持源码外构建,保持源码树与构建树的清晰镜像关系,避免在源码目录生成文件。
  • 采用分层目录架构(src/app, src/core, tests, third_party, cmake)组织大型项目,让每个模块职责单一、依赖清晰。

掌握了多目录结构后,你的CMake项目就不再是一栋拥挤的平房,而是一个规划合理、易于扩展的现代化社区。下一节,我们将进一步学习如何通过模块与函数复用CMake代码,避免在每个子目录中重复编写相似的配置逻辑。

请登录后发表评论

    没有回复内容

正在唤醒异次元光景……