模块化与组织:从混乱到有序的工程之道
说实话,我见过太多CMake项目,一开始就一个CMakeLists.txt,所有源文件堆在一起。项目小的时候还好,一旦超过10个源文件,那叫一个乱。今天我们就聊聊怎么用CMake把项目组织得井井有条。
add_subdirectory:项目的积木块
add_subdirectory 是CMake里最常用的模块化命令。它的作用很简单——告诉CMake去另一个目录里找CMakeLists.txt并执行。
# 顶层 CMakeLists.txt
cmake_minimum_required(VERSION 3.16)
project(MyApp VERSION 1.0)
add_subdirectory(src/core)
add_subdirectory(src/utils)
add_subdirectory(src/app)
每个子目录都有自己的CMakeLists.txt,各自管理自己的源文件和目标。这样每个模块的职责就清晰了。
我的习惯:每个子目录只放一个CMakeLists.txt,不要嵌套太深。超过3层嵌套,维护成本就开始飙升了。
目录作用域:变量到底去哪儿了?
这是新手最容易踩的坑。CMake里每个add_subdirectory调用都会创建一个新的作用域。父目录的变量默认可以往下传,但子目录的变量不会往上冒。
# 顶层
set(MY_VAR "hello")
add_subdirectory(sub)
# sub/CMakeLists.txt
message("${MY_VAR}") # 输出 hello
set(MY_VAR "world") # 只影响当前作用域
回到顶层,MY_VAR 还是 "hello"。为什么会这样?因为CMake的设计哲学就是隔离——每个子目录不应该意外地修改父目录的状态。
我曾经踩过的坑:在子目录里用 set() 修改了一个变量,以为父目录也能看到。结果排查了半天,最后发现是作用域隔离的问题。后来我养成了习惯——需要跨目录传递的变量,要么用 CACHE 变量,要么用 PARENT_SCOPE。
变量传递:三种方式
| 方式 | 命令 | 适用场景 |
|---|---|---|
| 向下传递 | set(VAR value) | 父目录→子目录,默认行为 |
| 向上传递 | set(VAR value PARENT_SCOPE) | 子目录→父目录,显式指定 |
| 全局共享 | set(VAR value CACHE INTERNAL "") | 整个项目可见,慎用 |
我个人最常用的是 PARENT_SCOPE。比如子目录里生成了一个库的路径,需要告诉父目录,就用这个。
# sub/CMakeLists.txt
add_library(my_lib STATIC ${SOURCES})
set(MY_LIB_PATH "${CMAKE_CURRENT_BINARY_DIR}" PARENT_SCOPE)
大型项目结构:一个实战案例
我参与过一个嵌入式项目,代码量大概50万行。我们用的结构是这样的:
project_root/
├── CMakeLists.txt # 顶层
├── cmake/ # 自定义模块和工具链
│ ├── toolchain-arm.cmake
│ └── FindMyLib.cmake
├── third_party/ # 第三方库
│ ├── CMakeLists.txt
│ ├── libusb/
│ └── json/
├── modules/ # 业务模块
│ ├── CMakeLists.txt
│ ├── core/
│ ├── network/
│ └── ui/
├── tests/ # 测试
│ ├── CMakeLists.txt
│ └── unit/
└── tools/ # 工具
└── gen_config.py
顶层CMakeLists.txt只做三件事:设置全局选项、添加子目录、定义安装规则。每个子目录自己管自己的事。
# 顶层 CMakeLists.txt
cmake_minimum_required(VERSION 3.16)
project(MyBigProject VERSION 2.0 LANGUAGES C CXX)
# 全局编译选项
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
# 添加子项目
add_subdirectory(third_party)
add_subdirectory(modules)
add_subdirectory(tests)
# 安装
install(TARGETS my_app DESTINATION bin)
核心原则:顶层只做调度,不做具体实现。每个子目录的CMakeLists.txt应该能独立运行(至少能独立生成目标)。
子项目 vs 子目录
很多人分不清 add_subdirectory 和 add_subdirectory 配合 project() 的区别。说白了:
- 子目录:没有自己的
project()命令,继承父项目的名称和版本 - 子项目:有自己的
project()命令,可以独立构建
我建议:如果这个模块需要单独发布或测试,就做成子项目。否则,子目录就够了。
# 子项目示例:modules/core/CMakeLists.txt
project(Core VERSION 1.0)
add_library(core ${SOURCES})
target_include_directories(core PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
知识体系总览
下面这张图总结了本章的核心逻辑:
避坑指南
最后分享几个我踩过的坑:
- 不要滥用全局变量:CACHE INTERNAL 虽然方便,但会让依赖关系变得隐晦。能用 PARENT_SCOPE 就别用 CACHE。
- 子目录的路径问题:在子目录里引用文件,用
${CMAKE_CURRENT_SOURCE_DIR}而不是${CMAKE_SOURCE_DIR}。否则换个目录结构就炸了。 - target 命名冲突:不同子目录里的 target 名字不能重复。我习惯加前缀,比如
core_lib、net_lib。
一个小技巧:在子目录的 CMakeLists.txt 开头加一行
message(STATUS "Loading ${CMAKE_CURRENT_SOURCE_DIR}"),调试时能清楚看到构建顺序。
模块化这件事,说白了就是「高内聚、低耦合」在构建系统里的体现。每个目录管好自己的事,通过明确的接口(变量、target)跟外界沟通。做到这一点,项目再大也不怕。
公众号:蓝海资料掘金营,微信deep3321