26、大型项目组织:add_subdirectory与目录结构、CMakeLists.txt的拆分与复用、include命令

项目一大了,所有代码塞在一个 CMakeLists.txt 里?

我见过不少团队这么干。结果呢?一个文件几千行,找某个目标配置得翻半天。更可怕的是,别人想复用你写的某个模块,得把整个文件拷过去——这哪是复用,这是「连坐」。

今天我们就聊聊怎么把 CMake 项目组织得清爽、可维护、可复用。说白了,就是学会拆和合。

26.1 目录结构:先想清楚再动手

我个人习惯,拿到一个项目先画目录树。不是画代码怎么跑,而是画 CMake 怎么组织。举个例子:

my_project/
├── CMakeLists.txt          # 根构建文件
├── src/                    # 主程序源码
│   ├── CMakeLists.txt
│   └── main.cpp
├── libs/                   # 内部库
│   ├── core/
│   │   ├── CMakeLists.txt
│   │   ├── core.h
│   │   └── core.cpp
│   └── utils/
│       ├── CMakeLists.txt
│       ├── utils.h
│       └── utils.cpp
├── tests/                  # 测试
│   ├── CMakeLists.txt
│   └── test_core.cpp
└── third_party/            # 第三方库
    └── googletest/
        └── CMakeLists.txt

看到没?每个有源码的目录,都放一个 CMakeLists.txt。根目录只做两件事:全局设置和分发构建任务。

核心原则:一个 CMakeLists.txt 只负责它所在目录的构建逻辑。不要越级管理。

26.2 add_subdirectory:把构建任务分下去

add_subdirectory 是组织大型项目的核心命令。它的作用很简单:告诉 CMake「去这个子目录里,执行它的 CMakeLists.txt」。

根目录的 CMakeLists.txt 大概长这样:

cmake_minimum_required(VERSION 3.16)
project(MyProject VERSION 1.0.0 LANGUAGES CXX)

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

# 添加子目录
add_subdirectory(libs/core)
add_subdirectory(libs/utils)
add_subdirectory(src)
add_subdirectory(tests)

每个子目录的 CMakeLists.txt 只关心自己:

# libs/core/CMakeLists.txt
add_library(core
    core.cpp
    core.h
)

target_include_directories(core PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})

这里有个细节:target_include_directoriesPUBLIC,这样 src 里的主程序链接 core 时,自动就能找到头文件。不用手动加 include_directories

小技巧:${CMAKE_CURRENT_SOURCE_DIR} 而不是硬编码路径。这样即使目录移动了,CMakeLists.txt 也不用改。

26.3 拆分与复用:别重复造轮子

项目大了,你会发现很多库的配置模式是重复的。比如每个库都要设置 C++ 标准、都要导出头文件路径、都要链接某些公共依赖。

我以前的做法是复制粘贴。后来维护起来想哭——改一个公共配置,得翻遍所有子目录的 CMakeLists.txt。

解决方案?用函数和宏。

26.3.1 用 function 封装重复逻辑

在根目录的 CMakeLists.txt 或者一个单独的 cmake/helpers.cmake 里定义:

function(add_internal_library LIB_NAME)
    add_library(${LIB_NAME} ${ARGN})
    target_compile_features(${LIB_NAME} PUBLIC cxx_std_17)
    target_include_directories(${LIB_NAME}
        PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}
    )
endfunction()

然后在子目录里调用:

# libs/core/CMakeLists.txt
add_internal_library(core core.cpp core.h)

这样,所有内部库自动获得统一的 C++ 标准和头文件导出策略。想改?只改函数定义一处就行。

26.3.2 用 include 引入公共模块

include 命令用来加载 CMake 模块文件。这些文件通常以 .cmake 结尾,放在 cmake/ 目录下。

# 根 CMakeLists.txt
list(APPEND CMAKE_MODULE_PATH "${CMAKE_SOURCE_DIR}/cmake")
include(Helpers)   # 自动查找 cmake/Helpers.cmake
include(FetchDeps) # 自动查找 cmake/FetchDeps.cmake

模块文件里可以放函数定义、宏定义、甚至一些全局变量的设置。我习惯把第三方依赖的查找逻辑也拆成模块:

# cmake/FetchDeps.cmake
include(FetchContent)

function(fetch_googletest)
    FetchContent_Declare(
        googletest
        GIT_REPOSITORY https://github.com/google/googletest.git
        GIT_TAG release-1.12.1
    )
    FetchContent_MakeAvailable(googletest)
endfunction()

然后在 tests/CMakeLists.txt 里:

include(FetchDeps)
fetch_googletest()

add_executable(test_core test_core.cpp)
target_link_libraries(test_core gtest_main core)
注意:includeadd_subdirectory 不同。include 只是把文件内容「粘贴」到当前位置,不会创建新的变量作用域。而 add_subdirectory 会创建独立的作用域。如果你在子目录里改了变量,父目录看不到——除非你用 PARENT_SCOPE

26.4 作用域与变量传递:别踩坑

我曾经被作用域坑过一次。项目里有个全局变量 USE_SANITIZERS,我在根目录设了 ON,结果子目录里死活读不到。查了半天,发现子目录里用 set(USE_SANITIZERS ON) 重新定义了——它没继承父作用域的变量。

记住几条规则:

  • 普通变量:父目录设的,子目录能读到。子目录改的,父目录看不到。
  • 缓存变量CACHE):全局可见,所有目录都能读写。
  • 目标属性:通过 target_* 命令设置,链接时自动传播。

我现在的做法是:全局配置用缓存变量,库的配置用目标属性,临时变量用普通变量且尽量局部化。

26.5 知识体系图

下面这张图总结了大型项目组织的核心逻辑:

根 CMakeLists.txt add_subdirectory include 模块 function/宏 子目录 CMakeLists.txt Helpers.cmake add_internal_library 独立作用域 全局可用 代码复用 目标:可维护、可复用、可扩展

26.6 避坑指南

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

  • 别在子目录里改全局变量:除非你用 CACHE 或者 PARENT_SCOPE,否则改了也白改。
  • 别用 include_directories:用 target_include_directories 替代。前者是全局的,容易污染其他目标。
  • 别把第三方库的源码直接拷进来:用 FetchContent 或者 ExternalProject 管理。我见过有人把整个 boost 拷进项目里……嗯,git 仓库直接炸了。
  • 别在 CMakeLists.txt 里写业务逻辑:CMake 是构建系统,不是脚本语言。复杂的条件判断、循环,尽量用函数封装。
总结:大型项目组织就三件事——拆(add_subdirectory)、合(include)、复用(function)。拆得清楚,合得优雅,复用得彻底。你的 CMake 项目就能从「能跑」变成「好维护」。

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