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 配合 PUBLIC 或 INTERFACE,可以让依赖自动传递头文件路径。这样主程序那边不用手动加 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 和 README,别放源码
- src/:主程序入口,通常只包含 main 函数
- lib/:公共库,每个子目录一个模块
- tests/:测试代码,用 EXCLUDE_FROM_ALL 控制
- 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_network、lib_database。 - 路径引用:在子目录的 CMakeLists.txt 里,
${CMAKE_CURRENT_SOURCE_DIR}指向当前子目录,${CMAKE_SOURCE_DIR}指向根目录。别搞混了。 - 变量作用域:子目录里用
set()定义的变量默认只在当前目录有效。想传递到父目录?用set(PARENT_SCOPE)。
我曾经踩过的坑:在子目录里用 include_directories() 添加了头文件路径,结果主程序那边死活找不到。后来才发现 include_directories() 默认只影响当前目录及子目录。换成 target_include_directories 配合 PUBLIC 就解决了。
好了,子目录组织这块其实不难,核心就是"分而治之"。每个目录管好自己的事,根目录只做组装。这样项目再大,也能保持清爽。
公众号:蓝海资料掘金营,微信deep3321