现代 C++ 项目的 CMake 工程组织

在多平台、多编译器、多依赖并存的现实环境中,C++ 项目的核心复杂度已由语言本身转移至工程构建与依赖管理。

本文从零构建一个现代 C++ 项目的视角出发,系统阐述 CMake 在工程描述、依赖封装与跨平台构建中的角色,并给出一套可执行、可约束的工程组织框架,以降低维护成本并提升项目的长期可演进性。

参考文献:CMake 凭借什么成为了 C/C++ 构建系统的事实"标准"? - 知乎

target:唯一工程单元

在现代 CMake 体系中,target 是唯一稳定、可组合、可传递的工程抽象。它封装了构建实体所需的全部关键信息,包括源文件集合、头文件可见性(通过 PUBLIC、PRIVATE、INTERFACE 精确控制)、编译选项与宏定义,以及链接关系及其传递属性。因此,target 不应被简单视为构建"产物",而应被理解为一个具有明确接口与实现边界的工程构件。


1. target 导向的基本约束

在项目初始阶段必须确立并长期坚持:

  • 一个库或可执行文件对应一个 target
  • 所有编译与链接信息必须绑定到 target
  • 禁止使用全局命令修改构建状态

典型约束示例:

cmake 复制代码
add_library(math src/math.cpp)
target_include_directories(math PUBLIC include)
target_compile_definitions(math PRIVATE MATH_INTERNAL)

该约束直接排除了以全局变量或目录作用域驱动构建的旧式模式。

2. 顶层结构

在 target 作为核心抽象的前提下,项目目录结构可自然推导。

text 复制代码
project-root/
├─ CMakeLists.txt
├─ CMakePresets.json
├─ cmake/
├─ toolchains/
├─ src/
└─ third_party/

其工程语义为:

  • CMakeLists.txt:工程结构与 target 描述
  • cmake/:项目级 CMake 基础设施与依赖封装
  • toolchains/:平台与编译器差异
  • src/:项目自身代码
  • third_party/:外部依赖(只读)

3. 模块级结构

每个模块独立维护自身 target:

text 复制代码
src/lib/math/
├─ CMakeLists.txt
├─ include/
└─ src/

模块内部只描述本 target 的构建属性,不对外部环境产生副作用。


4.依赖管理的唯一合法形式

在现代 CMake 项目中,依赖关系只能以 target → target 的形式存在:

cmake 复制代码
target_link_libraries(app PRIVATE fmt::fmt)

明确禁止:

  • 直接链接库文件路径
  • 全局 include 目录
  • 手工拼接编译或链接参数

这确保了依赖关系的显式性、可传递性与可验证性。


third_party:规范化的外部依赖管理

当第三方依赖通过 git submodule 引入时,它们应被视为只读的外部输入,而非项目代码的一部分。其职责是提供第三方源码或构建产物,而不应承担任何项目私有的逻辑或 CMake 配置。所有第三方库的管理应当在项目内部统一封装,并确保依赖关系的明确和稳定。


1. 使用 git submodule 引入第三方 C++ 库

在工程中使用 git submodule 引入第三方 C++ 库,其目的在于锁定依赖来源与版本,第三方库应统一引入至 third_party/ 目录中,例如:

bash 复制代码
git submodule add https://github.com/fmtlib/fmt.git third_party/fmt

该操作仅完成依赖获取,submodule 内容应被视为只读输入。其如何被编译、如何暴露为稳定 target,必须由项目自身在 cmake/ 目录中完成封装,而不应直接依赖 third_party 内部结构。


2. 外置 target 的封装原则

在 third_party 只读的前提下:

  • 禁止在 submodule 内新增或修改 CMake 文件
  • 所有 target 封装必须放在项目自身控制的 cmake/ 目录中
  • 采用扁平结构,避免多余层级
text 复制代码
cmake/
├─ FindFoo.cmake
├─ Bar.cmake
└─ Baz.cmake

3. 封装策略的优先级

对第三方库的接入应遵循以下顺序:

  1. 官方 Config 模式

    cmake 复制代码
    find_package(Foo CONFIG REQUIRED)
  2. 官方 CMake 可用但不规范

    cmake 复制代码
    add_library(Foo::Foo ALIAS foo_internal)
  3. 无 CMake 或不可用

    cmake 复制代码
    add_library(Foo::Foo UNKNOWN IMPORTED)

无论采用何种方式,最终目标一致:

项目内部只依赖稳定命名的 target,而不感知 third_party 细节。


Presets:确定性构建配置

Presets 通过结构化的 JSON 描述,将以下信息显式化并纳入版本控制:构建生成器(如 Ninja、Unix Makefiles、MSVC)、构建类型(Debug / Release / RelWithDebInfo 等)、Toolchain 文件的选择,以及必要且可控的 cache 变量。从工程角度看,Preset 定义的是"一次合法构建实例",而不是零散的参数组合。


1. 最小示意

json 复制代码
{
  "configurePresets": [
    {
      "name": "linux-release",
      "displayName": "Linux Release",
      "generator": "Ninja",
      "binaryDir": "${sourceDir}/build/linux-release",
      "cacheVariables": {
        "CMAKE_BUILD_TYPE": "Release"
      },
      "toolchainFile": "toolchains/linux.cmake"
    }
  ]
}

该示例明确表达了以下工程语义:

  • 使用 Ninja 作为唯一生成器
  • Release 构建具有固定输出目录
  • 构建行为绑定到指定 Toolchain
  • 所有构建相关变量均显式声明

Preset 本身不依赖调用者的命令行环境。

2. 统一的构建入口

在启用 Presets 后,工程应当只暴露唯一合法的构建方式:

复制代码
cmake --preset linux-release
cmake --build --preset linux-release

禁止以下行为作为工程构建入口:

  • 直接调用 cmake ..
  • 在命令行中手动指定 -DCMAKE_BUILD_TYPE
  • 在 CI 或文档中拼接零散参数

Toolchain:跨平台构建与依赖管理

在现代 CMake 工程中,Toolchain 文件不仅仅是一个可选配置,而是平台差异和依赖管理的核心。它的目标是精确、可复现地描述目标平台的编译环境,而不干扰项目结构。通过 Toolchain 文件,工程能够避免平台相关的配置泄露到 CMakeLists.txt 中,从而保持构建过程的稳定性与一致性。一旦平台配置被直接嵌入到工程代码中,构建系统就会变得臃肿且不易维护,破坏了 CMake 中 target 的抽象和模块化设计。


1. 最小示意

以下示例展示一个典型的 Linux x86_64 Toolchain:

cmake 复制代码
# toolchains/linux-x86_64.cmake

set(CMAKE_SYSTEM_NAME Linux)
set(CMAKE_SYSTEM_PROCESSOR x86_64)

set(CMAKE_C_COMPILER   /usr/bin/gcc)
set(CMAKE_CXX_COMPILER /usr/bin/g++)

set(CMAKE_SYSROOT /)

set(CMAKE_C_STANDARD 11)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

该文件仅描述"编译环境是什么",而不涉及"工程要做什么"。


2. 平台默认编译语义的集中管理

Toolchain 是设置平台级默认行为的唯一位置,例如:

cmake 复制代码
# 通用安全与兼容性选项
add_compile_options(
    -fPIC
    -fno-omit-frame-pointer
)

add_link_options(
    -Wl,--as-needed
)

这些选项在 Toolchain 中设置后:

  • 对所有 target 自动生效
  • 无需在工程代码中重复声明
  • 不破坏 target 的接口封装性

3. 包管理工具的引入

在现代 CMake 工程中,包管理工具(如 vcpkg、Conan)应通过 Toolchain 文件进行配置,避免在 CMakeLists.txt 中直接引入包管理器的命令。包管理器的职责是获取和配置外部依赖,而不应影响平台差异的设置。Toolchain 文件专注于平台配置,而包管理工具通过 Toolchain 引入并管理依赖。

例如,在 toolchains/vcpkg.cmake 文件中,我们可以设置 vcpkg 的路径和工具链:

cmake 复制代码
# toolchains/vcpkg.cmake

set(VCPKG_ROOT "path/to/vcpkg")
set(CMAKE_TOOLCHAIN_FILE "${VCPKG_ROOT}/scripts/buildsystems/vcpkg.cmake" CACHE STRING "VCPKG toolchain")
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

然后在 CMakePresets.json 文件中通过 toolchainFile 引入该配置:

json 复制代码
{
  "configurePresets": [
    {
      "name": "linux-release",
      "generator": "Ninja",
      "toolchainFile": "toolchains/vcpkg.cmake",
      "cacheVariables": {
        "CMAKE_BUILD_TYPE": "Release"
      }
    }
  ]
}

这样,包管理器(如 vcpkg)将处理依赖的获取和配置,而项目的构建过程则保持清晰、可复现。通过这种方式,CMake 工程中的依赖管理与平台差异完全分离,确保了构建过程的稳定性和可维护性。


结论

从零构建一个现代 C++ 项目,其工程质量并不取决于使用了多少高级特性,而取决于是否建立了一套稳定、可约束、可长期演进的工程抽象体系。在实践中,这一体系至少应当满足以下基本原则:

  • target 是唯一的工程构件:所有源码、编译选项、宏定义与依赖关系均必须绑定到明确的 target 上,工程状态不应依赖全局或隐式配置。
  • third_party 是只读输入:第三方代码仅作为外部依赖存在,不承载项目私有的工程逻辑,其构建细节不应向工程内部泄露。
  • cmake/ 是依赖与工程适配层:所有对第三方库的封装、兼容与命名规范均集中在可控的工程边界内完成。
  • Presets 固化构建入口:构建配置应以显式、可版本控制的形式存在,避免隐式参数组合导致的不可复现行为。
  • Toolchain 隔离平台差异:平台与编译器语义应集中描述,避免通过条件分支污染工程结构。

在现代 CMake 工程中,三者的职责边界必须严格区分:

  • CMakeLists.txt 负责描述工程结构与 target 之间的关系
  • Toolchain 负责描述"平台是什么"
  • Presets 负责描述"在该平台上如何构建一次工程"

通过对工程抽象边界的持续约束,CMake 不再只是一个"能把代码编译出来的工具",而成为一种描述工程结构、依赖关系与构建语义的工程语言。在当前 C++ 生态条件下,这种受控使用方式,是在复杂现实环境中维持工程可维护性与可演进性的必要前提。

相关推荐
H CHY2 小时前
C++代码
c语言·开发语言·数据结构·c++·算法·青少年编程
xiaolang_8616_wjl2 小时前
c++题目_传桶(改编于atcoder(题目:Heavy Buckets))
数据结构·c++·算法
小小8程序员2 小时前
除了 gcc/g++,还有哪些常用的 C/C++ 编译器?
c语言·开发语言·c++
希望_睿智3 小时前
实战设计模式之中介者模式
c++·设计模式·架构
博语小屋4 小时前
转义字符.
c语言·c++
Lhan.zzZ4 小时前
Qt跨线程网络通信:QSocketNotifier警告及解决
开发语言·c++·qt
Aevget4 小时前
QtitanDocking 如何重塑制造业桌面应用?多视图协同与专业界面布局实践
c++·qt·界面控件·ui开发·qtitandocking
-森屿安年-4 小时前
STL中 Map 和 Set 的模拟实现
开发语言·c++
历程里程碑4 小时前
双指针巧解LeetCode接雨水难题
java·开发语言·数据结构·c++·python·flask·排序算法