10、子目录与嵌套项目:add_subdirectory、构建树结构、跨目录变量传递、目录作用域
说实话,刚开始用CMake那会儿,我习惯把所有源文件都塞在一个CMakeLists.txt里。项目小还好说,一旦代码量上来,那个文件就变得又臭又长。后来我接手一个嵌入式项目,光驱动层就有十几个模块,每个模块还有自己的测试代码……嗯,那次之后,我彻底学会了用add_subdirectory来组织项目。
这一章,我们就来聊聊怎么把CMake项目拆成多个子目录,以及这背后的一些门道。
10.1 为什么要拆分子目录?
你想想看,一个大型项目,如果所有构建逻辑都写在一个文件里,会是什么样子?
- 查找某个模块的配置,得在几千行代码里翻来翻去
- 想单独编译某个模块?没门,只能全量编译
- 别人想复用你的某个模块?得把你的整个CMakeLists.txt搬过去
说白了,就是耦合太紧。用add_subdirectory把项目拆开,每个子目录有自己的CMakeLists.txt,各自管各自的依赖和编译选项。这样不仅清晰,还能实现增量编译——改了一个模块,只编译那个模块,不用等全项目重新构建。
核心思想:每个子目录都是一个独立的构建单元,拥有自己的作用域。父目录通过add_subdirectory把它“拉”进来。
10.2 add_subdirectory 的基本用法
语法很简单:
add_subdirectory(source_dir [binary_dir] [EXCLUDE_FROM_ALL])
- source_dir:子目录的路径,可以是相对路径(相对于当前CMakeLists.txt所在目录)
- binary_dir:构建产物的输出路径。如果不指定,默认在构建目录下生成与source_dir同名的文件夹
- EXCLUDE_FROM_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。每个子目录里定义的目标(比如add_executable或add_library),都会被添加到根项目的构建体系中。
我的习惯:我会把binary_dir显式指定为${CMAKE_CURRENT_BINARY_DIR}/子目录名,这样构建产物都集中在构建目录下,不会污染源码目录。比如:add_subdirectory(src ${CMAKE_CURRENT_BINARY_DIR}/src)
10.3 构建树结构:理解目录层级
用add_subdirectory之后,CMake会在内存中构建一棵构建树。每个add_subdirectory调用,都会在构建树中创建一个新的目录作用域。
我画了一张图,帮你理解这个结构:
从这张图可以看出,每个子目录都是构建树上的一个节点。CMake在处理时,会深度优先遍历这棵树。先处理根目录,然后进入src,处理完src里的所有内容,再出来处理lib。
注意:子目录的处理顺序,取决于你在根目录中调用add_subdirectory的顺序。如果lib依赖于src中定义的目标,记得把lib的add_subdirectory放在后面。
10.4 跨目录变量传递
这是初学者最容易踩坑的地方。我在项目中就遇到过好几次,明明在根目录定义了一个变量,到了子目录就读不到了。
为什么会这样?
因为CMake的变量作用域规则是:父目录的变量默认不会传递给子目录。每个add_subdirectory调用,都会创建一个新的变量作用域。子目录可以读取父目录的变量,但修改不会影响父目录。
来看个例子:
# 根目录 CMakeLists.txt
set(MY_VAR "hello")
add_subdirectory(sub)
# sub/CMakeLists.txt
message("MY_VAR = ${MY_VAR}") # 输出:MY_VAR = hello
set(MY_VAR "world") # 修改只影响当前作用域
message("MY_VAR = ${MY_VAR}") # 输出:MY_VAR = world
回到根目录后,MY_VAR的值仍然是"hello"。
那怎么把子目录的变量传回父目录呢?有两种方式:
方式一:使用 PARENT_SCOPE
# sub/CMakeLists.txt
set(MY_VAR "world" PARENT_SCOPE)
加上PARENT_SCOPE关键字,变量会传递到父作用域。但要注意,当前作用域中的MY_VAR不会被修改,它只影响父目录。
方式二:使用缓存变量
# sub/CMakeLists.txt
set(MY_VAR "world" CACHE STRING "A cached variable")
缓存变量是全局的,所有目录都能访问。但我不建议滥用,因为缓存变量会持久化到CMakeCache.txt中,清理起来比较麻烦。
我的建议:能用PARENT_SCOPE解决的问题,就别用缓存变量。如果确实需要跨多层目录传递,可以考虑用set_property(GLOBAL ...)或define_property,但大多数场景下,PARENT_SCOPE就够了。
10.5 目录作用域的实际应用
理解了作用域规则,我们就能玩出一些花样来。比如,给不同子目录设置不同的编译选项:
# 根目录 CMakeLists.txt
add_subdirectory(src)
add_subdirectory(lib)
# src/CMakeLists.txt
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -O2 -Wall")
add_executable(myapp main.cpp)
# lib/CMakeLists.txt
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -O0 -g")
add_library(mylib STATIC utils.cpp)
你看,src目录用优化选项,lib目录用调试选项。两者互不干扰。这就是目录作用域带来的好处——隔离性。
不过有一点要注意:CMAKE_CXX_FLAGS这类变量,在子目录中修改时,如果用了set(... PARENT_SCOPE),会影响父目录和其他兄弟目录。所以,除非你明确知道自己在做什么,否则不要轻易把子目录的编译选项传回父目录。
10.6 避坑指南:我踩过的几个坑
坑一:忘记指定binary_dir导致构建产物混乱
我曾经有一个项目,根目录和子目录的源文件同名。结果构建时,两个目标都输出到同一个目录,互相覆盖。后来我养成了显式指定binary_dir的习惯,再也没出过这个问题。
坑二:在子目录中修改全局变量
有一次,我在子目录里改了CMAKE_INSTALL_PREFIX,以为只影响当前目录。结果整个项目的安装路径都被改了。嗯,CMAKE_INSTALL_PREFIX是缓存变量,全局生效。所以,修改全局变量前,先确认它是不是缓存变量。
坑三:循环依赖
如果subA依赖subB,subB又依赖subA,CMake会报错。解决办法是重新设计模块划分,或者用target_link_libraries的INTERFACE关键字来打破循环。
10.7 小结
这一章我们聊了:
add_subdirectory的基本用法和参数- 构建树的结构和遍历顺序
- 变量作用域的规则:父传子、子不传父
- 用
PARENT_SCOPE和缓存变量实现跨目录传递 - 目录作用域的实际应用和常见坑
说白了,子目录管理是CMake项目从“能用”走向“好用”的关键一步。把项目拆得合理,后续的维护和扩展都会轻松很多。我个人习惯是:每个功能模块一个子目录,每个子目录只做一件事,做好一件事。
好了,这一章就到这里。记住,作用域是CMake的基石之一,理解它,你就能更好地掌控项目的构建过程。
公众号:蓝海资料掘金营,微信deep3321