模块化与组织:从混乱到有序的工程之道

说实话,我见过太多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_subdirectoryadd_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})

知识体系总览

下面这张图总结了本章的核心逻辑:

顶层 CMakeLists.txt add_subdirectory 调度各模块 子目录 A 无 project() 子项目 B 有 project() 子目录 C 无 project() 继承父作用域 独立作用域 继承父作用域 变量传递机制 set(... PARENT_SCOPE) 向上 | set(...) 向下 | CACHE 全局

避坑指南

最后分享几个我踩过的坑:

  • 不要滥用全局变量:CACHE INTERNAL 虽然方便,但会让依赖关系变得隐晦。能用 PARENT_SCOPE 就别用 CACHE。
  • 子目录的路径问题:在子目录里引用文件,用 ${CMAKE_CURRENT_SOURCE_DIR} 而不是 ${CMAKE_SOURCE_DIR}。否则换个目录结构就炸了。
  • target 命名冲突:不同子目录里的 target 名字不能重复。我习惯加前缀,比如 core_libnet_lib
一个小技巧:在子目录的 CMakeLists.txt 开头加一行 message(STATUS "Loading ${CMAKE_CURRENT_SOURCE_DIR}"),调试时能清楚看到构建顺序。

模块化这件事,说白了就是「高内聚、低耦合」在构建系统里的体现。每个目录管好自己的事,通过明确的接口(变量、target)跟外界沟通。做到这一点,项目再大也不怕。


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