第17章 目录与子项目:add_subdirectory、子项目构建、跨目录变量传递、目录作用域

说实话,刚开始用CMake的时候,我总觉得把所有代码塞进一个CMakeLists.txt里最省事。直到项目越来越大,编译一次要等五分钟,我才意识到——嗯,该拆分了。

这一章我们聊聊怎么用add_subdirectory管理子项目。说白了,就是教你把一个大项目拆成多个小模块,每个模块各管各的,互不干扰。

17.1 add_subdirectory 的基本用法

add_subdirectory是CMake里最常用的命令之一。它的作用很简单:告诉CMake去另一个目录里找CMakeLists.txt,然后执行它。

基本语法长这样:

add_subdirectory(source_dir [binary_dir] [EXCLUDE_FROM_ALL])

参数说明:

  • source_dir:子目录的路径,可以是相对路径或绝对路径
  • binary_dir:构建产物的输出目录,不指定的话默认和source_dir同名
  • EXCLUDE_FROM_ALL:加上这个参数,子项目的目标不会包含在ALL目标中

举个例子。假设你的项目结构是这样的:

project/
├── CMakeLists.txt
├── src/
│   ├── CMakeLists.txt
│   └── main.cpp
└── lib/
    ├── CMakeLists.txt
    └── utils.cpp

根目录的CMakeLists.txt可以这样写:

cmake_minimum_required(VERSION 3.10)
project(MyProject)

add_subdirectory(src)
add_subdirectory(lib)

这样CMake就会自动去src/lib/目录下执行各自的CMakeLists.txt。每个子目录可以定义自己的目标、变量、编译选项,互不干扰。

我的习惯:binary_dir参数我一般会省略,让CMake自动处理。除非你特别想把构建产物放到某个固定位置,否则默认行为就够用了。

17.2 子项目构建的两种模式

实际项目中,子项目通常有两种构建模式。我分别说说。

模式一:库作为子项目

最常见的情况——你的项目依赖一个内部库。这个库和主项目一起维护,放在同一个仓库里。

比如:

# lib/CMakeLists.txt
add_library(utils STATIC utils.cpp)

# src/CMakeLists.txt
add_executable(myapp main.cpp)
target_link_libraries(myapp PRIVATE utils)

主项目通过add_subdirectory引入lib目录,然后直接链接utils库。这种方式的好处是:库的源码就在手边,调试起来特别方便。

模式二:外部项目集成

有时候你需要引入第三方的库,但不想把它塞进你的源码树。这时候可以用add_subdirectory配合FetchContent或者直接指定路径。

我记得有个项目需要用到Google Test,我就是这么干的:

# 把gtest源码下载到build目录下的_gtest子目录
add_subdirectory(
    ${CMAKE_SOURCE_DIR}/third_party/googletest
    ${CMAKE_BINARY_DIR}/_gtest
    EXCLUDE_FROM_ALL
)

加上EXCLUDE_FROM_ALL很重要。不然你每次编译主项目,gtest的测试目标也会被编译,白白浪费时间。

注意:子项目的CMakeLists.txt里定义的变量、目标,默认对父项目是可见的。但反过来不行——父项目的变量不会自动传递到子项目。这就是所谓的「目录作用域」。

17.3 跨目录变量传递

变量传递这块,我刚开始也踩过坑。你想想看,一个变量在根目录定义了,到了子目录突然就没了——这谁受得了?

CMake的变量作用域规则其实很简单:

  • 每个add_subdirectory会创建一个新的作用域
  • 子作用域可以访问父作用域的变量
  • 子作用域修改变量,不会影响父作用域

看个例子就明白了:

# 根目录 CMakeLists.txt
set(MY_VAR "hello")
add_subdirectory(sub)

message("根目录: ${MY_VAR}")  # 输出: hello
# sub/CMakeLists.txt
message("子目录: ${MY_VAR}")  # 输出: hello
set(MY_VAR "world")
message("子目录修改后: ${MY_VAR}")  # 输出: world

运行结果:

子目录: hello
子目录修改后: world
根目录: hello

看到了吧?子目录改了变量,根目录的值没变。这就是作用域隔离。

那如果我真的想让子目录修改父目录的变量呢?用PARENT_SCOPE

# sub/CMakeLists.txt
set(MY_VAR "world" PARENT_SCOPE)

这样父目录的MY_VAR就会被改成"world"。

避坑指南:我曾经在一个项目里,子目录用set改了变量,以为父目录也能用。结果排查了半天,才发现是作用域的问题。后来我养成了一个习惯:需要跨目录传递的变量,要么用PARENT_SCOPE,要么用缓存变量

17.4 目录作用域的实战技巧

理解了作用域规则,我们来看看实际中怎么用。

技巧一:用目录作用域隔离编译选项

不同子目录可能需要不同的编译选项。比如一个目录需要C++17,另一个需要C++11。利用作用域隔离,可以轻松做到:

# sub_a/CMakeLists.txt
set(CMAKE_CXX_STANDARD 17)
add_library(a a.cpp)

# sub_b/CMakeLists.txt
set(CMAKE_CXX_STANDARD 11)
add_library(b b.cpp)

两个子目录互不影响,各用各的标准。

技巧二:用变量传递配置信息

我习惯在根目录定义一些全局配置,然后让子目录按需使用:

# 根目录
set(ENABLE_LOGGING ON)
set(LOG_LEVEL "info")

add_subdirectory(core)
add_subdirectory(plugin)
# core/CMakeLists.txt
if(ENABLE_LOGGING)
    target_compile_definitions(core PRIVATE LOGGING_ENABLED)
endif()

这样改一个地方,所有子目录都能感知到变化。

技巧三:小心缓存变量

缓存变量(用CACHE关键字定义的变量)是全局的,不受作用域限制。但正因为如此,用起来要格外小心:

# 任何目录都可以读写这个变量
set(MY_GLOBAL_VAR "value" CACHE STRING "全局变量")

我个人建议:除非你真的需要全局共享,否则尽量用普通变量配合PARENT_SCOPE传递。缓存变量改起来太容易出错了。

17.5 知识体系总览

这一章的内容比较多,我画了张图帮你理清思路:

第17章 目录与子项目 知识体系 add_subdirectory 基本用法 source_dir / binary_dir / EXCLUDE_FROM_ALL 子项目构建 库作为子项目 外部项目集成 跨目录变量传递 PARENT_SCOPE 缓存变量 (CACHE) 目录作用域 作用域隔离 / 编译选项隔离 核心:每个 add_subdirectory 创建一个新的变量作用域

17.6 总结

这一章我们聊了add_subdirectory的用法、子项目构建的两种模式、跨目录变量传递的规则,以及目录作用域的实际应用。

说白了,核心就一句话:每个add_subdirectory创建一个新的作用域,子目录能看到父目录的变量,但改不了。想改?用PARENT_SCOPE或者缓存变量。

我在实际项目中,一般会这样组织:

  • 根目录定义全局配置(项目名、版本号、编译选项)
  • 每个子目录只关心自己的事
  • 需要跨目录传递的信息,用PARENT_SCOPE显式传递

这样项目结构清晰,每个人负责自己的模块,互不干扰。你试试看,用起来真的很顺手。

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