今日学习:Modern CMake 工程化

前端有 npm + webpack/vite,C++ 桌面工程有 CMake + vcpkg/Conan。今天把这条"工程链"彻底打通——这是你从"会写 C++ 代码"到"能交付可维护的桌面客户端工程"的分水岭。

01

今日主题

过去十天的学习都在解决"单文件内"的问题:资源管理、信号槽、事件循环、多线程。但真实桌面客户端是几十个模块、上百个源文件、十几个第三方依赖的集合体——谁来描述它们的依赖关系?谁来保证在 Windows/macOS/Linux 三端、本地与 CI 上构建结果一致?答案就是 CMake。

前端工程师很容易低估 CMake 的重要性,因为 npm 把依赖和构建的复杂度都"藏"起来了。而 C++ 没有统一的包源、没有隐式依赖图,一切依赖与构建规则都必须显式声明。今天学的是Modern CMake:以 target 为核心的声明式构建体系,它是 Qt6 唯一官方推荐的构建系统,也是通往"大型客户端软件设计能力"的必经之路。

一句话说清 CMake:它是"构建系统的生成器"。你用 CMakeLists.txt 声明 target 及其依赖关系,CMake 负责把这份声明翻译成 Makefile/Ninja 工程,再交给编译器执行。你描述"是什么",它决定"怎么编"。
02

核心知识

1. 两阶段模型:configure 与 build

CMake 的工作分两个阶段。第一阶段 cmake -S . -B build(configure):读取 CMakeLists.txt,探测编译器与平台、查找依赖(find_package)、把声明翻译成具体的构建脚本(Makefile 或 Ninja)。第二阶段 cmake --build build:执行构建脚本,真正调用编译器。关键认知是:CMakeLists.txt 是"构建系统的源代码",它描述的是一张构建图(谁依赖谁),而不是构建过程本身。绝大多数命令只在 configure 期执行一次;改依赖关系后需要重新 configure,改源文件只需增量编译。

2. Target 是核心抽象,属性靠链接传播

Modern CMake 的全部精髓就是一句话:一切挂在 target 上,用 target_link_libraries 传播。add_executable(app main.cpp)、add_library(core STATIC core.cpp) 创建 target;target_include_directories(core PUBLIC include)、target_compile_definitions(core PRIVATE FOO=1) 定义属性;而 target_link_libraries(app PRIVATE core) 会把 core 的 PUBLIC/INTERFACE 属性自动带给 app——头文件路径、编译定义、编译选项、链接库全部跟着依赖关系"流"下去。三个关键字对应三种可见性:PRIVATE(只有自己的 .cpp 需要)、PUBLIC(自己的头文件暴露给下游,下游也需要)、INTERFACE(纯头文件库,自己不需要编译)。为什么禁止 include_directories、add_definitions 这类全局命令?因为它们破坏依赖边界,让编译通过与否取决于"哪个文件先被解析"。

3. 依赖查找的三条路

find_package(Qt6 REQUIRED COMPONENTS Widgets) 查找已安装的 config 包——Qt6 本身用 CMake 编写,自带完整的 CMake 配置文件,这是 Qt6 全面 CMake 化的红利。找不到的依赖用 FetchContent 在 configure 期拉源码直接构建,适合小型或需固定提交版本的库;而大型工程的系统化依赖管理交给 vcpkg 或 Conan:在仓库里放一份 vcpkg.json 声明依赖,配合 vcpkg.lock 锁版本,通过 CMAKE_TOOLCHAIN_FILE 注入构建——这就是 C++ 版的 package.json + lockfile。

4. 生成器表达式:构建期的"计算"

$<...> 在生成期求值,是 CMake 实现"同一份声明、多种场景"的机制。最典型的 $<BUILD_INTERFACE:...> 与 $<INSTALL_INTERFACE:...>:开发时头文件在源码树里,安装后头文件在 include/ 目录下,路径完全不同——用生成器表达式区分,同一份 target 两种场景都正确。它就像 Web 里的环境变量与条件编译,但粒度精确到单个属性。

5. Qt6 的 CMake API:自动化的红利

qt_standard_project_setup() 一键设置 C++17、默认警告与自动 moc/rcc/uic;qt_add_executable(app) 自动把带 Q_OBJECT 的头文件喂给 moc 生成器。你不需要手写任何 moc 规则——这正是 Qt 团队在自家巨型工程中沉淀出的最佳实践,通过官方函数固化下来。

6. CMakePresets.json:统一入口

把 configure/build/test 的命令、参数、构建目录、工具链全部固化进 CMakePresets.json,团队与 CI 统一执行 cmake --preset configure / cmake --preset build,彻底消灭"我机器上能编译"。

心智模型

把 CMake 工程想象成一张依赖图:节点是 target(可执行文件、库),边是 target_link_libraries 声明的依赖。configure 期构造这张图并检查完整性(依赖是否存在、版本是否满足),build 期按拓扑序编译链接。你在 Web 里用 package.json 声明依赖、由 npm 解析出 node_modules 树——本质是同一件事,只是 C++ 连"编译顺序"都要自己声明清楚。

03

实际案例

Qt6 自己:最大规模的 Modern CMake 工程之一

Qt 6 从 qmake 全面迁移到 CMake,qmake 停止新特性开发、仅做维护。整个 Qt 仓库包含约 200 个库模块,qt_add_executable、qt_standard_project_setup 这些官方 API 正是 Qt 团队在自家巨型工程中反复打磨的产物。这个案例说明两件事:一是 target-based 的 Modern CMake 在超大规模工程上被验证可行;二是官方推荐的工具链(Qt6 + CMake + Ninja)本身就是自洽的,跟着官方走就是最佳实践。

KDE Plasma:构建系统也要"抽象与复用"

KDE 是几千个 target 的巨型开源桌面生态。它的构建工程不是靠复制粘贴 CMakeLists,而是基于 ECM(Extra CMake Modules)——一套可复用的 CMake 模块,把安装规则、编译器警告、格式检查、版本检查等公共逻辑封装成函数,各项目统一调用。这与前端把 webpack 配置抽成 create-vite / create-react-app 预设、把公共逻辑抽成 npm 包是完全同构的思路:构建脚本也是需要架构设计的代码。

LLVM/Clang 与三大 IDE 的押注

LLVM 用 CMake 管理几十个组件和大量可选 target,支撑编译器这种"配置最繁复、平台差异最大"的工程交付。而 IDE 侧,CLion 把 CMake 当作一等项目模型(打开工程即解析 target 依赖图),Qt Creator 原生支持 CMake,VSCode 靠 CMake Tools 扩展提供 IntelliSense 与调试配置。为什么所有工具都押 CMake?因为它语义清晰、无中心化服务器依赖、跨平台,并且 target 依赖图可被 IDE 精确解析——IDE 只需要编译你改动过的 target 的依赖子树,就能给出准确的代码补全。反观变量式的 qmake,工具链无法从中提取结构信息。

04

常见错误

1. 全局命令污染:add_definitions / include_directories 满天飞

从根 CMakeLists 一路 add_definitions(-DFOO)、include_directories(...),短期"省事",长期埋雷:子模块编译期能过、链接期报 undefined reference,或者头文件悄悄依赖了从未声明的东西——这就是幽灵依赖。换一个模块顺序、换一台机器就爆炸。避免:一切属性挂在 target 上,靠 target_link_libraries 显式传播。

2. 忘开 AUTOMOC,Q_OBJECT 类链接失败

编译报 undefined reference to vtable for Xxx。原因是带 Q_OBJECT 的类需要 moc 预处理,而 moc 生成的 .cpp 没有被编进 target。新手常以为"头文件不用列进源文件列表",于是 moc 永远没机会执行。避免:用 qt_add_executable/qt_add_library,并确保 .h 出现在源文件列表里——这是 Qt6 CMake API 存在的意义。

3. file(GLOB) 收集源文件

file(GLOB src "src/*.cpp") 只在 configure 期展开一次,新增 .cpp 后不会自动进入构建;CI 与本地 configure 时机不同,行为漂移。加 CONFIGURE_DEPENDS 也只是"让 CMake 每次构建前检查目录",绕远路。避免:显式列出源文件——顺便逼你认真设计文件组织。

4. 静态库链接顺序问题

静态库之间互相依赖时(a 用到 b 的符号),Makefile 生成器按链接命令从左到右解析,顺序错了就报 undefined reference。新手靠"调顺序"碰运气。避免:用 target_link_libraries(a PRIVATE b) 声明依赖,让 CMake 自己排拓扑序——这也是 Ninja 生成器更省心的原因之一。

5. 没有统一入口,配置各自为政

每个人手敲不同的 -D 参数、用不同的构建目录,CI 又一套参数,最终"我机器上能编"。避免:仓库根放 CMakePresets.json + vcpkg.json,团队和 CI 只执行 cmake --preset ...,把配置作为代码纳入 Code Review。

05

最佳实践

1. 声明最低版本与语言,从 3.21 起步

cmake_minimum_required(VERSION 3.21) + project(demo LANGUAGES CXX)。别为了兼容古董环境写 3.5 老语法——2026 年的 Qt6 生态默认 CMake 3.21+,老语法(变量满天飞、add_definitions)只会让你用 Modern 风格时处处踩坑。什么时候可以放宽:必须支持老发行版时,用版本分支做兼容。

2. 以 target 为中心,PUBLIC/PRIVATE/INTERFACE 语义必须精确

头文件下游也要用就 PUBLIC,只是实现细节就 PRIVATE,纯头文件库用 INTERFACE。语义错了的典型症状:下游编译报"找不到头文件"(PUBLIC 写成 PRIVATE),或下游被强塞了不需要的编译选项(PRIVATE 写成 PUBLIC,导致重编译风暴)。

3. 依赖分层:find_package 优先,vcpkg 锁版本,FetchContent 兜底

系统/官方包有的用 find_package(Qt6 就是);团队级依赖用 vcpkg manifest 模式(vcpkg.json + vcpkg.lock 进 Git,可复现);小依赖或必须钉死提交的库用 FetchContent。Trade-off:vcpkg 首次编译慢(源码编译),但缓存后构建可复现、版本可审计;FetchContent 零安装成本,但每次 configure 都检查网络、版本管控弱。三者混用的风险:同一个库两处引入导致 ODR 违规——统一由根工程仲裁。

4. CMakePresets.json + Ninja + ccache 作为团队标配

presets 统一入口;Ninja 并行度与增量编译优于 Makefiles;ccache 缓存编译产物,CI 与本地共用一套配置。什么时候不要:单文件教学工程没必要上 presets,但"团队仓库"必须从第一天就有。

5. 库要写 install/export,别让下游手动拼路径

为库 target 配 install(TARGETS ... EXPORT ...) + install(EXPORT ...),下游就能 find_package(core) 而非手写 include 路径。这是"模块化/插件化"架构的地基——你的架构师路线图里,迟早要做组件复用。

06

与Web技术的联系

你是带着一整套工程心智来的,这套心智在 C++ 里几乎逐一对得上,只是"替换组件"而已:

  • npm/pnpm/yarn ↔ vcpkg/Conan:都是"声明依赖 → 解析 → 锁版本 → 可复现构建"。区别是 npm 有中心化 registry 和现成二进制包,C++ 没有统一包源,多数依赖要源码编译——所以 C++ 的"npm install"慢一个量级,这决定了 C++ 工程更依赖二进制缓存(ccache / CI 缓存)与分层依赖策略。
  • webpack/vite 依赖图 ↔ CMake target 图:webpack 解析 import 构建 module graph 决定打包顺序,CMake 解析 target_link_libraries 构建依赖图决定编译链接顺序。都是声明式描述、工具负责拓扑排序与增量。
  • package.json 的 scripts ↔ CMakePresets.json:统一命令入口,把"怎么跑"固化进仓库。npm run dev 之于 cmake --preset build,一模一样的心智。
  • tsconfig 的 paths ↔ target_include_directories:模块解析路径的显式声明。Web 里漏配 paths 报 "Cannot find module",C++ 里漏配报 "file not found"。
  • 运行时解析 vs 链接期解析:Node 的 require 在运行时解析模块,所以"模块找不到"是运行时错误;C++ 的符号解析发生在链接期,所以 undefined reference 是构建期错误——这是为什么 C++ 工程对"依赖声明完整性"的要求远高于 Web。
  • .d.ts ↔ 头文件:接口契约。改头文件 = 改 .d.ts,下游全部重编译,这就是"头文件要稳"的深层原因。

最大的思维迁移点:Web 里"隐式"的(依赖树、构建图、版本解析)在 C++ 里全部"显式"了。你不是在学新东西,是在把早已内化的工程思维翻译成 CMake 的语法。

07

管理者视角

作为 Team Leader,构建系统是团队效率与质量的地基,值得像对待业务代码一样对待:

  • 统一规范:维护一个"模板仓库"(CMakePresets + vcpkg + CI 流水线 + 代码风格检查),新项目 5 分钟拉起来,新人第一天就能编译运行——这是 onboarding 体验的关键。
  • Code Review 关注点:CMakeLists.txt 是否 target-based?有没有全局命令污染?依赖版本是否锁进 vcpkg.lock?presets 与 CI 是否同步?构建脚本的 review 标准要和业务代码一致。
  • 用数据驱动优化:CI 里记录构建时长。全量构建超过 10 分钟就该拆模块、开 ccache、上预编译头文件——构建速度直接决定团队的迭代节奏。
  • 培养新人:让新人先改依赖(加一个库)、再改构建配置(切 Ninja)、最后独立搭一个新模块——由浅入深建立"构建也是工程"的意识。
08

延伸阅读

  • 《Professional CMake: A Practical Guide》(Craig Scott)——CMake 领域最权威的深度参考,Craig 是 CMake 核心维护者。适合系统学习与查漏,读完你对 configure 期的每个细节都不再心虚。
  • 《Modern CMake for C++》(Svyatoslav Rakita)——聚焦 Modern target-based 风格与工程实践(含 vcpkg/Conan、CI、测试),比官方文档更贴合"工程落地"场景,与今天的内容衔接最紧。
  • An Introduction to Modern CMake(Henry Schreiner,GitHub)——免费入门神作,半小时过一遍能建立完整心智框架,强烈建议先读这个再读书。
  • Qt 官方 CMake 手册(qt_add_executable / qt_standard_project_setup)——Qt6 团队官方封装的最佳实践 API 文档,写 Qt 工程前必读,能避免 90% 的 moc/uic 坑。
  • vcpkg 官方文档(Manifest mode 章节)——依赖管理的最佳实践模板,vcpkg.json + lockfile 的完整语义都在这里。
09

今日思考题

为什么 CMake 要分成 configure 与 build 两个阶段?对比 npm install 与 npm run build 的划分,二者的"阶段边界"为什么不同?

PUBLIC/PRIVATE 语义写错时,分别会出现什么症状(编译错还是链接错)?如何从错误信息反推出是传播属性配置错了?

C++ 生态为什么至今没有类似 npm registry 的统一包源?这给依赖管理带来了哪些根本性约束,vcpkg/Conan 又是如何绕过的?

如果让你设计团队的 C++ 工程模板仓库,你会把哪些决策固化进 CMakePresets.json?哪些决策应该留给各项目自己?

FetchContent 与 vcpkg 的适用边界在哪里?混合使用(同库两处引入)会导致什么严重后果,如何设计仲裁机制?

10

今日实践任务

30~60 分钟 · 搭建一个 Qt6 + CMake 双 target 工程

建目录 daily_demo,放入下面四个文件。要求:库 target 与可执行 target 分离,验证 PUBLIC 属性传播、AUTOMOC 自动生效、增量编译。

CMakeLists.txt

cmake_minimum_required(VERSION 3.21)
project(daily_demo LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

find_package(Qt6 REQUIRED COMPONENTS Core)
qt_standard_project_setup()

add_library(greeter STATIC greeter.h greeter.cpp)
target_link_libraries(greeter PUBLIC Qt6::Core)

qt_add_executable(app main.cpp)
target_link_libraries(app PRIVATE greeter)

greeter.h

#pragma once
#include <QObject>
#include <QString>

class Greeter : public QObject {
    Q_OBJECT
public:
    explicit Greeter(QObject *parent = nullptr);
    QString greet(const QString &name) const;
};

greeter.cpp

#include "greeter.h"

Greeter::Greeter(QObject *parent) : QObject(parent) {}

QString Greeter::greet(const QString &name) const {
    return QStringLiteral("Hello, %1! 来自 CMake 工程。").arg(name);
}

main.cpp

#include <QCoreApplication>
#include <QDebug>
#include "greeter.h"

int main(int argc, char *argv[]) {
    QCoreApplication app(argc, argv);
    Greeter g;
    qInfo().noquote() << g.greet(QStringLiteral("架构师"));
    return 0;
}

验证步骤

  • 安装依赖:sudo apt install qt6-base-dev(或走 vcpkg manifest 模式)
  • 配置并构建:cmake -S . -B build && cmake --build build -j
  • 运行:./build/app,预期输出 Hello, 架构师! 来自 CMake 工程。
  • 检查 build/.../moc_greeter.cpp 存在——证明 AUTOMOC 生效

挑战项(加分):① 把 greeter 改成纯头文件库(加 INTERFACE 语义),观察属性传播变化;② 增加 CMakePresets.json 用 Ninja 生成器;③ 给工程加一个 ctest 单元测试 target,体会"测试也是构建图的一部分"。

11

一句话总结

CMake 的本质是用 target 依赖图显式声明"谁依赖谁"——你从 Web 带来的工程化心智完全适用,只是把隐式变显式、把运行期变构建期。