18、子目录与项目组织:add_subdirectory(),构建多个子项目,跨目录访问目标

项目一大了,把所有代码塞进一个 CMakeLists.txt 里,那画面太美我不敢看。说实话,我早期带团队时就吃过这个亏——一个文件上千行,改个路径都得全局搜索,谁改谁崩溃。

后来我学乖了。用 add_subdirectory() 把项目拆开,每个模块各管各的,清爽多了。今天咱们就聊聊这个。

为什么需要子目录?

你想想看,一个实际项目通常长这样:

  • 一个主程序入口
  • 几个功能模块(比如网络、数据库、UI)
  • 一堆第三方库
  • 测试代码

全写在一个 CMakeLists.txt 里?维护成本直接起飞。用子目录的好处很明显:

  • 职责清晰:每个子目录只管自己的事
  • 构建可控:可以单独开关某个子项目
  • 复用方便:子项目可以独立拿出来给别的工程用

核心原则:每个子目录一个 CMakeLists.txt,根目录只做"组装"工作。

add_subdirectory() 的基本用法

语法很简单:

add_subdirectory(source_dir [binary_dir] [EXCLUDE_FROM_ALL])

参数说明:

参数 含义
source_dir 子目录的源码路径(相对于当前 CMakeLists.txt)
binary_dir 构建输出目录(可选,不指定则默认与 source_dir 同名)
EXCLUDE_FROM_ALL 排除该子目录的默认构建目标(可选)

举个例子,项目结构如下:

my_project/
├── CMakeLists.txt          # 根目录
├── src/
│   ├── CMakeLists.txt      # 主程序
│   └── main.cpp
├── lib/
│   ├── CMakeLists.txt      # 公共库
│   ├── network.cpp
│   └── network.h
└── tests/
    ├── CMakeLists.txt      # 测试
    └── test_network.cpp

根目录的 CMakeLists.txt 这样写:

cmake_minimum_required(VERSION 3.16)
project(MyProject VERSION 1.0)

add_subdirectory(src)
add_subdirectory(lib)
add_subdirectory(tests)

每个子目录各自定义自己的目标。比如 lib/CMakeLists.txt

add_library(network network.cpp network.h)

主程序那边想用这个库?直接 target_link_libraries 就行。嗯,这里有个关键点——跨目录访问目标,CMake 会自动处理依赖关系。

跨目录访问目标

子目录里定义的目标,在根目录或者其他子目录里都能直接引用。比如 src/CMakeLists.txt

add_executable(my_app main.cpp)
target_link_libraries(my_app PRIVATE network)

这里 network 是 lib 子目录里定义的目标,CMake 会自动找到它。我个人习惯把公共库放在 lib/common/ 下,主程序和测试都链接它。

小技巧:用 target_include_directories 配合 PUBLICINTERFACE,可以让依赖自动传递头文件路径。这样主程序那边不用手动加 include 路径。

控制构建范围:EXCLUDE_FROM_ALL

有时候你不想默认构建所有子项目。比如测试代码,平时开发不跑测试,只有 CI 时才需要。这时候用 EXCLUDE_FROM_ALL

add_subdirectory(tests EXCLUDE_FROM_ALL)

这样执行 make 时,tests 目录下的目标不会被构建。只有你显式指定 make test_network 时才会编译。我在项目中经常用这招,把性能测试、集成测试都标记为 EXCLUDE_FROM_ALL,避免每次编译都等半天。

自定义构建输出目录

默认情况下,子目录的构建产物会放在 build/子目录名 下。但有时候你想统一管理,比如把所有 .so 文件放到 build/lib 里:

add_subdirectory(lib lib_output)

第二个参数就是自定义的构建目录名。注意,这个路径是相对于构建根目录的,不是源码目录。

避坑指南:我曾经把 binary_dir 设成源码目录下的某个文件夹,结果构建产物和源码混在一起,git status 一片红。记住,binary_dir 一定要在构建目录里,别污染源码树。

知识结构图

下面这张图帮你理清子目录组织的核心逻辑:

根 CMakeLists.txt add_subdirectory(src) add_subdirectory(lib) add_subdirectory(tests) add_executable(my_app) add_library(network) add_executable(test_net) target_link_libraries 每个子目录独立管理自己的 CMakeLists.txt 根目录只做"组装",不关心内部细节 目标可以跨目录直接引用,CMake 自动处理依赖

实际项目中的组织建议

根据我多年的经验,推荐这样组织项目:

  1. 根目录:只放 CMakeLists.txt 和 README,别放源码
  2. src/:主程序入口,通常只包含 main 函数
  3. lib/:公共库,每个子目录一个模块
  4. tests/:测试代码,用 EXCLUDE_FROM_ALL 控制
  5. third_party/:第三方库,用 FetchContent 或 find_package

举个例子,一个稍微复杂点的项目结构:

my_project/
├── CMakeLists.txt
├── src/
│   ├── CMakeLists.txt
│   └── main.cpp
├── lib/
│   ├── network/
│   │   ├── CMakeLists.txt
│   │   ├── network.cpp
│   │   └── network.h
│   ├── database/
│   │   ├── CMakeLists.txt
│   │   ├── database.cpp
│   │   └── database.h
│   └── CMakeLists.txt      # 汇总 lib 下的所有子目录
├── tests/
│   ├── CMakeLists.txt
│   ├── test_network.cpp
│   └── test_database.cpp
└── third_party/
    └── CMakeLists.txt

lib 目录下的 CMakeLists.txt 可以这样汇总:

add_subdirectory(network)
add_subdirectory(database)

这样根目录只需要 add_subdirectory(lib) 一次,lib 内部怎么拆是它自己的事。这种分层结构,说白了就是"让每个层级的复杂度可控"。

个人习惯:我一般会在 lib 目录里放一个 CMakeLists.txt 做汇总,而不是让根目录直接引用每个子模块。这样哪天想加一个新模块,只需要改 lib/CMakeLists.txt,根目录纹丝不动。

常见问题与避坑

  • 目标名冲突:不同子目录里不要定义同名的目标,CMake 会报错。我习惯给目标加前缀,比如 lib_networklib_database
  • 路径引用:在子目录的 CMakeLists.txt 里,${CMAKE_CURRENT_SOURCE_DIR} 指向当前子目录,${CMAKE_SOURCE_DIR} 指向根目录。别搞混了。
  • 变量作用域:子目录里用 set() 定义的变量默认只在当前目录有效。想传递到父目录?用 set(PARENT_SCOPE)

我曾经踩过的坑:在子目录里用 include_directories() 添加了头文件路径,结果主程序那边死活找不到。后来才发现 include_directories() 默认只影响当前目录及子目录。换成 target_include_directories 配合 PUBLIC 就解决了。

好了,子目录组织这块其实不难,核心就是"分而治之"。每个目录管好自己的事,根目录只做组装。这样项目再大,也能保持清爽。


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