前置知识: CCC

构建系统

46 minIntermediate2026/7/21

CMake/Make/Ninja 构建系统原理、工程实践与跨平台管理

学习目标

本节遵循 Bloom 认知分类法,按”记忆 → 理解 → 应用 → 分析 → 评价 → 创造”六层级组织学习目标。读者完成本节后应能够:

  • 记忆层级:复述 C 语言编译流程四阶段(预处理、编译、汇编、链接)、Make 的依赖图模型、CMake 的”元构建”角色、Ninja 的并行调度策略。
  • 理解层级:解释 Makefile 规则语法、CMake 的目标(target)与属性(property)模型、生成器(generator)机制、PUBLIC/PRIVATE/INTERFACE 关键字在依赖传播中的语义差异。
  • 应用层级:使用 CMake 构建多目录 C 项目、管理外部依赖(find_packageFetchContentvcpkg)、编写跨平台工具链文件(toolchain)、配置 Debug/Release/RelWithDebInfo 构建类型。
  • 分析层级:剖析 Make 的递归make问题、CMake 的策略(policy)机制、Ninja 的依赖图数据库(.ninja_deps)如何实现精确增量构建、链接时优化(LTO)对构建系统的影响。
  • 评价层级:评估在何种场景下应选择 Make、CMake、Meson、Bazel 或 SCons,权衡构建速度、可维护性、跨平台支持、生态成熟度与团队学习成本。
  • 创造层级:基于 CMake 设计一套完整的模块化项目骨架,集成单元测试(CTest/GoogleTest)、静态分析(clang-tidy/cppcheck)、代码覆盖率(gcov/llvm-cov)、交叉编译、持续集成(CI)与发布管理。

历史动机与背景

构建系统诞生的历史背景

C 语言在 1972 年诞生于贝尔实验室,最初在 PDP-11 小型机上实现。早期 C 程序规模较小,单文件直接 cc main.c 即可生成可执行文件。但随着软件规模增长,构建系统的需求逐步显现:

  1. 1976 年 Make 诞生:Stuart Feldman 在贝尔实验室发明 Make 工具,最初用于 Unix 系统的自动化构建。Make 解决了”手动敲击编译命令”的痛点,引入文件时间戳和依赖图模型,仅重新编译发生变化的源文件,大幅缩短构建时间。Feldman 因此获得 2003 年 ACM Software System Award。
  2. 1985 年 GNU Make:Richard Stallman 在 GNU 项目中实现 GNU Make,扩展了原版 Make 的功能,引入自动变量、模式规则、条件判断等特性,成为事实标准。
  3. 1990 年代 Autotools 兴起:GNU Autotools(Autoconf、Automake、Libtool)解决了 Unix 系统间的可移植性问题,通过 ./configure && make && make install 三段式构建成为开源项目标配。但 Autotools 学习曲线陡峭,配置文件复杂。
  4. 2000 年 CMake 诞生:Kitware 公司为解决 VTK、ITK 等大型科学计算项目的跨平台构建问题开发了 CMake。CMake 是”元构建系统”(meta-build system),不直接构建,而是生成 Makefile、Visual Studio 工程文件、Ninja 文件等。CMake 迅速成为 C/C++ 生态的主流构建系统。
  5. 2010 年 Ninja 诞生:Evan Martin 在 Google 开发 Ninja,专为速度优化。Ninja 的设计哲学是”把复杂的依赖分析留给上层工具(CMake/Meson),自己只做最快的执行”。Ninja 比 Make 快 5-10 倍,尤其在大项目增量构建上优势明显。
  6. 2013 年 Meson 诞生:Jussi Pakkanen 开发 Meson,采用 Python 风格的领域特定语言(DSL),强调易用性和速度,搭配 Ninja 作为后端,受到 GNOME、Xorg、Systemd 等项目青睐。
  7. 2015 年 Bazel 诞生:Google 开源内部构建工具 Blaze,命名为 Bazel。Bazel 强调可重现构建(hermetic build)、远程缓存、多语言支持,适合超大规模代码仓库。

构建系统的核心问题

构建系统本质上解决以下五个核心问题:

  1. 依赖管理:识别源文件之间的依赖关系(A 包含 B 的头文件,则修改 B 需重新编译 A),构建有向无环图(DAG)。
  2. 增量构建:基于文件时间戳或内容哈希,仅重新构建受影响的目标,避免全量重编。
  3. 并行构建:利用多核 CPU 并行执行独立任务,缩短构建时间。
  4. 跨平台支持:抽象平台差异(编译器、链接器、库文件命名、安装路径),生成对应平台的构建文件。
  5. 配置管理:管理构建类型(Debug/Release)、编译选项(C 标准、优化级别)、特性开关(feature toggle)、第三方依赖查找。

C 语言构建的特殊挑战

C 语言相比其他语言有独特的构建挑战:

  1. 头文件依赖追踪:C 语言的 #include 是文本插入,编译器需扫描预处理输出才能确定依赖。Make 无法自动发现依赖,需借助 gcc -MMD -MP 生成 .d 文件并 -include 进 Makefile。
  2. 静态库与动态库:链接顺序敏感(Unix 链接器从左到右解析符号),Windows 与 Unix 的库命名约定不同(libfoo.a vs foo.lib),动态库符号导出方式不同(__declspec(dllexport) vs __attribute__((visibility("default"))))。
  3. ABI 兼容性:不同编译器、不同标准库实现(glibc、musl、MSVC CRT)的 ABI 不兼容,构建系统需明确指定目标平台。
  4. 交叉编译:嵌入式开发需在 x86 主机上构建 ARM 目标二进制,构建系统需区分”主机(host)“与”目标(target)“三元组(triple)。

真实工程动机案例

案例一:Linux 内核的构建。Linux 内核源码超过 3000 万行,构建系统基于 Kconfig + Kbuild(Make 的扩展)。配置阶段用 Kconfig 生成 .config 文件,构建阶段用 Kbuild 的 Makefile 规则递归构建各子系统。完整构建需 20-40 分钟,增量构建仅几秒。

案例二:LLVM/Clang 项目。LLVM 是 C++ 编写的编译器基础设施,包含上百个子项目。早期使用 Autoconf,2015 年迁移至 CMake。迁移后构建时间缩短 40%,跨平台支持显著改善,Windows 平台从”几乎不可构建”变为”原生支持”。

案例三:Redis 6.0 的构建演化。Redis 早期使用简单的 Makefile,6.0 引入模块系统后,第三方模块需要可靠的头文件与库依赖管理。Redis 7.0 部分支持 CMake 构建选项,同时保留 Makefile 作为默认入口,体现构建系统迁移的渐进性。

形式化定义

构建系统的数学模型

构建系统可形式化为一个三元组 B=(T,D,R)\mathcal{B} = (T, D, R),其中:

  • T={t1,t2,,tn}T = \{t_1, t_2, \ldots, t_n\} 为任务(task)集合,每个任务 tit_i 对应一次编译、链接或其他构建动作。
  • DT×TD \subseteq T \times T 为依赖关系,(ti,tj)D(t_i, t_j) \in D 表示 tit_i 依赖 tjt_j(即 tjt_j 必须先完成)。DD 构成有向无环图(DAG)。
  • R:TActionR: T \to \text{Action} 为规则函数,将每个任务映射到具体的执行动作(如 gcc -c main.c -o main.o)。

依赖图的拓扑排序

构建系统按拓扑顺序执行任务。设 DD 的拓扑排序为 σ=(tσ1,tσ2,,tσn)\sigma = (t_{\sigma_1}, t_{\sigma_2}, \ldots, t_{\sigma_n}),满足:

(ti,tj)D,σ1(tj)<σ1(ti)\forall (t_i, t_j) \in D, \quad \sigma^{-1}(t_j) < \sigma^{-1}(t_i)

即被依赖的任务先执行。拓扑排序的时间复杂度为 O(T+D)O(|T| + |D|)(Kahn 算法或 DFS)。

增量构建的形式化

设任务 tt 的输入文件集合为 In(t)\text{In}(t),输出文件集合为 Out(t)\text{Out}(t)。任务 tt 需要重新执行当且仅当:

fIn(t),mtime(f)>mtime(Out(t))Out(t) 不存在\exists f \in \text{In}(t), \quad \text{mtime}(f) > \text{mtime}(\text{Out}(t)) \quad \lor \quad \text{Out}(t) \text{ 不存在}

其中 mtime(f)\text{mtime}(f) 为文件 ff 的修改时间戳。增量构建算法可形式化为:

Rebuild(t)=Rebuild(t)(t,t)DRebuild(t)\text{Rebuild}(t) = \text{Rebuild}(t) \lor \bigvee_{(t, t') \in D} \text{Rebuild}(t')

即任务 tt 需要重新构建,当且仅当其自身输入变化或其依赖任务需要重新构建。这是一个不动点计算,时间复杂度 O(T+D)O(|T| + |D|)

并行构建的调度

并行构建将无依赖关系的任务并行执行。设处理器核数为 PP,任务 tt 的执行时间为 w(t)w(t),则最优并行调度的关键路径长度为:

Tcp=maxpath p in DAGtpw(t)T_{\text{cp}} = \max_{\text{path } p \text{ in DAG}} \sum_{t \in p} w(t)

并行构建的总时间下界为:

Tparallelmax(Tcp,tTw(t)P)T_{\text{parallel}} \geq \max\left( T_{\text{cp}}, \frac{\sum_{t \in T} w(t)}{P} \right)

Ninja 通过分析 DAG 的关键路径,优先调度关键路径上的任务,逼近最优并行度。

CMake 的目标-属性模型

CMake 的核心抽象是”目标”(target)和”属性”(property)。设项目中有目标集合 T\mathcal{T},每个目标 tt 有属性集合 Props(t)\text{Props}(t),关键属性包括:

  • SOURCES(t)\text{SOURCES}(t):源文件列表
  • \text{INCLUDE_DIRS}(t, \text{scope}):头文件搜索路径,scope {PUBLIC,PRIVATE,INTERFACE}\in \{\text{PUBLIC}, \text{PRIVATE}, \text{INTERFACE}\}
  • \text{LINK_LIBS}(t, \text{scope}):链接库列表
  • \text{COMPILE_OPTS}(t, \text{scope}):编译选项

依赖传播规则形式化为:

tconsumer links tlib,Props(tconsumer)+=INTERFACE_Props(tlib)\forall t_{\text{consumer}} \text{ links } t_{\text{lib}}, \quad \text{Props}(t_{\text{consumer}}) \mathrel{+}= \text{INTERFACE\_Props}(t_{\text{lib}})

即消费者目标继承被链接库的 INTERFACE 属性,但不继承 PRIVATE 属性。

理论推导

Make 的依赖图与执行算法

Make 的核心数据结构是”规则”(rule):

target : prerequisites
	recipe

Make 的执行算法可形式化为:

  1. 解析阶段:读取 Makefile,构建目标-依赖映射 Rules:Target(Prereqs,Recipe)\text{Rules}: \text{Target} \to (\text{Prereqs}, \text{Recipe})
  2. 目标确定:默认构建第一个目标,或命令行指定的目标。
  3. 递归检查:对目标的每个依赖,递归调用构建过程。
  4. 时间戳比较:若目标文件不存在,或任意依赖的时间戳晚于目标,则执行 recipe。
  5. 执行 recipe:每行 recipe 在独立 shell 中执行(除非用 .ONESHELL)。

算法伪代码:

function build(target):
    if target in Rules:
        prereqs, recipe = Rules[target]
        for prereq in prereqs:
            build(prereq)
        if not exists(target) or any(mtime(p) > mtime(target) for p in prereqs):
            execute(recipe)

时间复杂度为 O(T+D)O(|T| + |D|),但递归 Make(subdir 调用)会导致依赖信息丢失,影响增量构建正确性,称为”递归 Make 有害”(Recursive Make Considered Harmless,Miller 1998)。

CMake 的两阶段执行模型

CMake 采用”配置-生成”两阶段模型:

  1. 配置阶段(Configure):执行 CMakeLists.txt,构建内存中的目标-属性图。此阶段可执行 try_compilecheck_function_exists 等探测命令,将结果缓存到 CMakeCache.txt
  2. 生成阶段(Generate):根据目标-属性图和所选生成器(Makefile、Ninja、Visual Studio),生成具体的构建文件。

配置阶段的形式化为:

Configure(CMakeLists.txt)TargetGraph\text{Configure}(\text{CMakeLists.txt}) \to \text{TargetGraph}

生成阶段的形式化为:

Generate(TargetGraph,Generator)BuildFiles\text{Generate}(\text{TargetGraph}, \text{Generator}) \to \text{BuildFiles}

两阶段分离的优势在于:配置结果可缓存,多次构建无需重新探测;同一份 CMakeLists 可生成不同生成器的构建文件。

Ninja 的依赖图与并行调度

Ninja 的核心是 .ninja 文件,包含构建规则和依赖边。Ninja 的关键优化:

  1. 预编译的依赖图:Ninja 在加载时将整个依赖图读入内存,构建时无需重新解析。
  2. 关键路径调度:Ninja 计算每个任务到根的”深度”(最长路径长度),优先调度深度大的任务,缩短总构建时间。
  3. 依赖图数据库:Ninja 维护 .ninja_deps 文件,记录每个目标文件实际依赖的头文件,支持精确增量构建。
  4. 命令去重:相同命令执行一次,结果复用。

Ninja 的并行调度算法:

function schedule(tasks, P):
    ready = {t in tasks | prereqs(t) == {}}
    workers = P parallel queues
    while ready or running:
        for each idle worker:
            if ready:
                t = ready.pop_highest_depth()
                schedule(t on worker)
        wait for any worker to finish
        for each finished task t:
            for each consumer c of t:
                if all prereqs(c) finished:
                    ready.add(c)

增量构建的正确性证明

命题:基于文件时间戳的增量构建算法是正确的,即不会遗漏需要重新编译的目标。

证明:设任务 tt 的输入集合 In(t)={f1,,fk}\text{In}(t) = \{f_1, \ldots, f_k\},输出为 Out(t)\text{Out}(t)。算法在以下条件下重新执行 tt

¬exists(Out(t))fIn(t),mtime(f)>mtime(Out(t))\neg \text{exists}(\text{Out}(t)) \lor \exists f \in \text{In}(t), \text{mtime}(f) > \text{mtime}(\text{Out}(t))

若条件不满足,则 fIn(t),mtime(f)mtime(Out(t))\forall f \in \text{In}(t), \text{mtime}(f) \leq \text{mtime}(\text{Out}(t)),即所有输入在输出生成后未变化,重新执行 tt 必然得到相同结果(假设编译器确定性),故可跳过。\square

注意:此证明假设编译器确定性、文件时间戳单调、依赖图完整。实际中存在三类违反假设的情况:

  1. 时钟回拨:系统时间被调整可能导致时间戳非单调。
  2. 依赖图不完整:未追踪的头文件修改无法触发重新编译。
  3. 非确定性编译器:某些编译器在调试信息中嵌入时间戳或随机值。

Ninja 通过内容哈希(content hash)缓解时钟问题,CMake 提供 CMAKE_CONFIGURE_DEPENDS 显式声明配置依赖。

构建系统的复杂度对比

维度MakeCMake + MakeCMake + NinjaBazel
配置时间O(1)O(1)(无配置)O(n)O(n)O(n)O(n)O(nlogn)O(n \log n)
增量构建O(n)O(n)O(n)O(n)O(n)O(n)(更优常数)O(logn)O(\log n)(哈希索引)
依赖图加载O(n)O(n)(每次)O(n)O(n)O(n)O(n)(内存缓存)O(1)O(1)(守护进程)
并行调度贪心贪心关键路径优先关键路径 + 远程缓存
远程缓存无(ccache 补充)内置

代码示例

示例 1:最小化 CMake 项目

# 最小化 CMake 项目示例
# 演示项目声明、C 标准设置、可执行目标添加

# 声明最低 CMake 版本(影响策略兼容性)
cmake_minimum_required(VERSION 3.20)

# 声明项目名称与所用语言
# CMake 会自动创建变量:PROJECT_NAME, PROJECT_SOURCE_DIR, PROJECT_BINARY_DIR
project(MyApp C)

# 设置 C 语言标准(C11/C17/C23)
# CMAKE_C_STANDARD:标准版本号
# CMAKE_C_STANDARD_REQUIRED:若 ON,标准不满足则报错而非降级
# CMAKE_C_EXTENSIONS:若 OFF,禁用编译器扩展(如 GNU 扩展)
set(CMAKE_C_STANDARD 17)
set(CMAKE_C_STANDARD_REQUIRED ON)
set(CMAKE_C_EXTENSIONS OFF)

# 添加可执行目标
# 语法:add_executable(<target> <source1> <source2> ...)
add_executable(myapp src/main.c)

# 设置目标属性:输出名、调试后缀等
set_target_properties(myapp PROPERTIES
    OUTPUT_NAME "myapp"              # 输出文件名(不含扩展名)
    DEBUG_POSTFIX "d"                # Debug 构建加 d 后缀
    C_STANDARD 17                    # 目标级 C 标准覆盖
)

示例 2:多目录项目结构

# 顶层 CMakeLists.txt
cmake_minimum_required(VERSION 3.20)
project(MyProject C)

set(CMAKE_C_STANDARD 17)
set(CMAKE_C_STANDARD_REQUIRED ON)

# 选项:是否构建测试、是否启用 LTO
option(BUILD_TESTS "构建单元测试" ON)
option(ENABLE_LTO  "启用链接时优化" OFF)

# 添加子目录,每个子目录有自己的 CMakeLists.txt
add_subdirectory(lib)        # 库子目录
add_subdirectory(app)        # 应用子目录

if(BUILD_TESTS)
    enable_testing()         # 启用 CTest
    add_subdirectory(tests)
endif()

# LTO 启用(CMake 3.20+ 内置支持)
if(ENABLE_LTO)
    set(CMAKE_INTERPROCEDURAL_OPTIMIZATION_RELEASE ON)
    set(CMAKE_INTERPROCEDURAL_OPTIMIZATION_RELWITHDEBINFO ON)
endif()
# lib/CMakeLists.txt - 构建静态库

# 收集源文件(推荐用 CONFIGURE_DEPENDS 自动追踪新增文件)
file(GLOB LIB_SOURCES CONFIGURE_DEPENDS
    "${CMAKE_CURRENT_SOURCE_DIR}/src/*.c"
)

# 构建静态库
add_library(mylib STATIC ${LIB_SOURCES})

# 设置头文件搜索路径(PUBLIC 表示对消费者也可见)
target_include_directories(mylib PUBLIC
    ${CMAKE_CURRENT_SOURCE_DIR}/include
)

# 设置私有编译选项(仅本目标使用,不传递)
target_compile_options(mylib PRIVATE
    -Wall -Wextra -Wpedantic -Werror
)

# 设置库版本属性(仅对 SHARED 库有意义,STATIC 也可设置但无实际影响)
set_target_properties(mylib PROPERTIES
    VERSION 1.2.0
    SOVERSION 1
)
# app/CMakeLists.txt - 构建可执行文件

add_executable(myapp main.c)

# 链接库(PRIVATE 表示仅本目标使用)
target_link_libraries(myapp PRIVATE mylib)

# 根据平台条件编译
if(WIN32)
    target_compile_definitions(myapp PRIVATE PLATFORM_WINDOWS=1)
elseif(UNIX)
    target_compile_definitions(myapp PRIVATE PLATFORM_UNIX=1)
endif()
# tests/CMakeLists.txt - 测试目标

# 简单测试可执行文件
add_executable(test_string test_string.c)
target_link_libraries(test_string PRIVATE mylib)

# 注册到 CTest
add_test(NAME test_string COMMAND test_string)

# 参数化测试:多个用例共享同一可执行文件
add_test(NAME test_string_empty COMMAND test_string --case empty)
add_test(NAME test_string_long  COMMAND test_string --case long)

示例 3:构建类型与编译选项

# 构建类型管理
# CMake 内置四种构建类型:Debug, Release, RelWithDebInfo, MinSizeRel

# 设置默认构建类型(若命令行未指定)
if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
    set(CMAKE_BUILD_TYPE Release CACHE STRING "构建类型" FORCE)
    # 提供可选值(用于 ccmake 界面)
    set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS
        Debug Release RelWithDebInfo MinSizeRel)
endif()

# 各构建类型的默认编译选项(CMake 内置,可覆盖)
# Debug:          -g -O0
# Release:        -O3 -DNDEBUG
# RelWithDebInfo: -O2 -g -DNDEBUG
# MinSizeRel:     -Os -DNDEBUG

# 自定义编译选项(基于生成器表达式)
target_compile_options(myapp PRIVATE
    $<$<CONFIG:Debug>:-g3 -O0 -Wall -Wextra>            # Debug:完整调试信息
    $<$<CONFIG:Release>:-O3 -DNDEBUG -march=native>     # Release:最大优化
    $<$<CONFIG:RelWithDebInfo>:-O2 -g -DNDEBUG>          # RelWithDebInfo:优化+调试
    $<$<CONFIG:MinSizeRel>:-Os -DNDEBUG>                 # MinSizeRel:最小体积
)

# 全局编译选项(所有目标)
add_compile_options(
    $<$<C_COMPILER_ID:GNU>:-Wall>
    $<$<C_COMPILER_ID:Clang>:-Wall -Wextra>
    $<$<C_COMPILER_ID:MSVC>:/W4>
)

示例 4:查找与使用外部库

# 查找外部库的三种方式

# 方式一:find_package(推荐,使用 Config 模式或 Module 模式)
find_package(Threads REQUIRED)             # CMake 内置模块
target_link_libraries(myapp PRIVATE Threads::Threads)

find_package(ZLIB REQUIRED)                # zlib 压缩库
target_link_libraries(myapp PRIVATE ZLIB::ZLIB)

find_package(OpenSSL REQUIRED)             # OpenSSL
target_link_libraries(myapp PRIVATE OpenSSL::SSL OpenSSL::Crypto)

# 方式二:find_library + find_path(手动查找,适用于无 CMake Config 的库)
find_library(MATH_LIB m)                   # 数学库
if(MATH_LIB)
    target_link_libraries(myapp PRIVATE ${MATH_LIB})
endif()

# 方式三:pkg-config(适用于 Unix 系统)
find_package(PkgConfig REQUIRED)
pkg_check_modules(LIBCURL REQUIRED IMPORTED_TARGET libcurl)
target_link_libraries(myapp PRIVATE PkgConfig::LIBCURL)

# 检查库是否找到并处理失败情况
find_package(CURL QUIET)                   # QUIET:不打印查找过程
if(NOT CURL_FOUND)
    message(WARNING "libcurl 未找到,HTTP 功能将禁用")
    target_compile_definitions(myapp PRIVATE DISABLE_HTTP=1)
endif()

示例 5:FetchContent 管理第三方依赖

# FetchContent 示例:自动下载、配置、构建第三方库
# 适用于无系统包管理器的环境,或将依赖固化到源码树

include(FetchContent)

# 声明 cJSON 依赖
FetchContent_Declare(
    cjson
    GIT_REPOSITORY https://github.com/DaveGamble/cJSON.git
    GIT_TAG        v1.7.17                 # 锁定版本,避免上游破坏
    GIT_SHALLOW    TRUE                    # 浅克隆,加速下载
)

# 声明 GoogleTest 依赖
FetchContent_Declare(
    googletest
    GIT_REPOSITORY https://github.com/google/googletest.git
    GIT_TAG        v1.14.0
)

# 批量下载与配置(CMake 3.24+ 推荐用法)
FetchContent_MakeAvailable(cjson googletest)

# 使用 FetchContent 引入的库
add_executable(myapp src/main.c)
target_link_libraries(myapp PRIVATE cjson)

# 测试目标
enable_testing()
add_executable(mytest tests/test_main.c)
target_link_libraries(mytest PRIVATE GTest::gtest_main cjson)
include(GoogleTest)
gtest_discover_tests(mytest)

示例 6:交叉编译工具链文件

# toolchain-arm-linux.cmake - ARM Linux 交叉编译工具链
# 使用方法:cmake -DCMAKE_TOOLCHAIN_FILE=toolchain-arm-linux.cmake ..

# 目标平台三元组(triple):arch-vendor-os-abi
set(CMAKE_SYSTEM_NAME      Linux)
set(CMAKE_SYSTEM_PROCESSOR arm)

# 指定交叉编译器
set(CMAKE_C_COMPILER   arm-linux-gnueabihf-gcc)
set(CMAKE_CXX_COMPILER arm-linux-gnueabihf-g++)

# 设置 sysroot(目标系统根目录,包含库与头文件)
set(CMAKE_SYSROOT /usr/arm-linux-gnueabihf)

# 程序查找策略:
# NEVER:不在目标平台查找宿主机程序
# ONLY:只在目标平台查找
# BOTH:两平台都查找
set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY)

# 传递给编译器的额外选项
set(CMAKE_C_FLAGS_INIT "-mthumb -mcpu=cortex-m4 -mfpu=fpv4-sp-d16 -mfloat-abi=hard")
# toolchain-wasm.cmake - WebAssembly 工具链
set(CMAKE_SYSTEM_NAME      Emscripten)
set(CMAKE_SYSTEM_PROCESSOR wasm)

set(CMAKE_C_COMPILER   emcc)
set(CMAKE_CXX_COMPILER em++)

# Emscripten 输出为单文件,链接器选项特殊
set(CMAKE_EXE_LINKER_FLAGS_INIT "-s WASM=1 -s EXPORTED_RUNTIME_METHODS=['ccall','cwrap']")

示例 7:Makefile 进阶用法

# Makefile 进阶示例:自动依赖追踪、模式规则、并行构建

# 变量定义
CC      := gcc
CFLAGS  := -Wall -Wextra -std=c17 -O2 -MMD -MP
LDFLAGS := -lm -lpthread

# 目标与源文件
TARGET  := myapp
SRCS    := $(wildcard src/*.c)
OBJS    := $(patsubst src/%.c,build/%.o,$(SRCS))
DEPS    := $(OBJS:.o=.d)

# 默认目标(make 不带参数时执行)
.PHONY: all clean test install
all: $(TARGET)

# 链接规则
$(TARGET): $(OBJS)
	$(CC) $(LDFLAGS) -o $@ $^

# 模式规则:编译 src/*.c 为 build/*.o
build/%.o: src/%.c | build
	$(CC) $(CFLAGS) -c -o $@ $<

# 顺序规则(order-only prerequisite):仅创建目录,不触发重新编译
build:
	mkdir -p build

# 包含自动生成的依赖文件
-include $(DEPS)

# 测试目标
test: $(TARGET)
	./$(TARGET) --test

# 安装目标
PREFIX ?= /usr/local
install: $(TARGET)
	install -d $(PREFIX)/bin
	install -m 755 $(TARGET) $(PREFIX)/bin/

# 清理
clean:
	rm -rf build $(TARGET)

示例 8:CMake Presets(预设配置)

{
  "version": 5,
  "cmakeMinimumRequired": { "major": 3, "minor": 23, "patch": 0 },
  "configurePresets": [
    {
      "name": "base",
      "hidden": true,
      "binaryDir": "${sourceDir}/build/${presetName}",
      "cacheVariables": {
        "CMAKE_C_STANDARD": "17",
        "CMAKE_EXPORT_COMPILE_COMMANDS": "ON"
      }
    },
    {
      "name": "debug",
      "inherits": "base",
      "cacheVariables": {
        "CMAKE_BUILD_TYPE": "Debug",
        "CMAKE_C_FLAGS": "-g3 -O0 -Wall -Wextra -fsanitize=address,undefined"
      }
    },
    {
      "name": "release",
      "inherits": "base",
      "cacheVariables": {
        "CMAKE_BUILD_TYPE": "Release",
        "CMAKE_C_FLAGS": "-O3 -DNDEBUG -march=native"
      }
    },
    {
      "name": "asan",
      "inherits": "debug",
      "cacheVariables": {
        "CMAKE_C_FLAGS": "-g3 -O1 -fsanitize=address -fno-omit-frame-pointer"
      }
    },
    {
      "name": "coverage",
      "inherits": "base",
      "cacheVariables": {
        "CMAKE_BUILD_TYPE": "Debug",
        "CMAKE_C_FLAGS": "-g -O0 --coverage",
        "CMAKE_EXE_LINKER_FLAGS": "--coverage"
      }
    }
  ],
  "buildPresets": [
    { "name": "debug",    "configurePreset": "debug" },
    { "name": "release",  "configurePreset": "release" },
    { "name": "asan",     "configurePreset": "asan" },
    { "name": "coverage", "configurePreset": "coverage" }
  ],
  "testPresets": [
    {
      "name": "debug",
      "configurePreset": "debug",
      "output": { "outputOnFailure": true },
      "execution": { "noTestsAction": "error", "stopOnFailure": false }
    }
  ]
}

使用方法:

cmake --preset debug           # 配置
cmake --build --preset debug   # 构建
ctest --preset debug           # 测试

示例 9:自定义命令与生成代码

# 在构建时生成版本信息头文件
# 演示 add_custom_command 与 add_custom_target 的配合使用

# 获取 Git 提交哈希
execute_process(
    COMMAND git rev-parse --short HEAD
    WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}
    OUTPUT_VARIABLE GIT_COMMIT
    OUTPUT_STRIP_TRAILING_WHITESPACE
    ERROR_QUIET
)
if(NOT GIT_COMMIT)
    set(GIT_COMMIT "unknown")
endif()

# 定义生成的头文件路径
set(VERSION_HEADER ${CMAKE_BINARY_DIR}/generated/version.h)

# 自定义命令:生成头文件
add_custom_command(
    OUTPUT ${VERSION_HEADER}
    COMMAND ${CMAKE_COMMAND}
        -DOUTPUT_FILE=${VERSION_HEADER}
        -DPROJECT_VERSION=${PROJECT_VERSION}
        -DGIT_COMMIT=${GIT_COMMIT}
        -DBUILD_TIMESTAMP=${TIMESTAMP}
        -P ${CMAKE_SOURCE_DIR}/cmake/GenerateVersion.cmake
    DEPENDS ${CMAKE_SOURCE_DIR}/cmake/GenerateVersion.cmake
    COMMENT "生成版本信息头文件"
    VERBATIM
)

# 自定义目标:确保版本头文件在主目标前生成
add_custom_target(generate_version DEPENDS ${VERSION_HEADER})

# 主目标依赖版本头文件
add_executable(myapp src/main.c ${VERSION_HEADER})
target_include_directories(myapp PRIVATE ${CMAKE_BINARY_DIR}/generated)
add_dependencies(myapp generate_version)

cmake/GenerateVersion.cmake 脚本:

# cmake/GenerateVersion.cmake - 生成 version.h
# 接收参数:OUTPUT_FILE, PROJECT_VERSION, GIT_COMMIT, BUILD_TIMESTAMP

set(HEADER_CONTENT "// 自动生成,请勿手动修改
#ifndef VERSION_H
#define VERSION_H

#define PROJECT_VERSION \"${PROJECT_VERSION}\"
#define GIT_COMMIT     \"${GIT_COMMIT}\"
#define BUILD_TIMESTAMP \"${BUILD_TIMESTAMP}\"

#endif // VERSION_H
")

file(WRITE ${OUTPUT_FILE} ${HEADER_CONTENT})
message(STATUS "已生成版本头文件: ${OUTPUT_FILE}")

示例 10:CTest 集成测试

# CTest 配置示例
# 支持超时、标签过滤、并行执行、内存检查

# 启用测试
enable_testing()

# 测试目标
add_executable(test_vector  tests/test_vector.c)
add_executable(test_string  tests/test_string.c)
add_executable(test_hashmap tests/test_hashmap.c)

target_link_libraries(test_vector  PRIVATE mylib)
target_link_libraries(test_string  PRIVATE mylib)
target_link_libraries(test_hashmap PRIVATE mylib)

# 注册测试(基础形式)
add_test(NAME test_vector  COMMAND test_vector)
add_test(NAME test_string  COMMAND test_string)
add_test(NAME test_hashmap COMMAND test_hashmap)

# 高级形式:设置超时、标签、依赖
add_test(NAME test_string_utf8  COMMAND test_string --case utf8)
set_tests_properties(test_string_utf8 PROPERTIES
    TIMEOUT 30                          # 30 秒超时
    LABELS "string;unicode"             # 标签用于过滤
    DEPENDS test_string_basic           # 依赖另一测试先通过
    PASS_REGULAR_EXPRESSION "All tests passed"  # 通过条件
    FAIL_REGULAR_EXPRESSION "FAILED|Segmentation" # 失败条件
)

# 设置全局测试属性
set_tests_properties(test_vector test_string test_hashmap PROPERTIES
    TIMEOUT 60
    ENVIRONMENT "LANG=C;LC_ALL=C"
)

# 内存检查(Valgrind)
find_program(VALGRIND_EXECUTABLE valgrind)
if(VALGRIND_EXECUTABLE)
    # 内存检查测试:在 Valgrind 下运行所有测试
    add_test(NAME memcheck_vector
        COMMAND ${VALGRIND_EXECUTABLE}
            --leak-check=full --error-exitcode=99
            $<TARGET_FILE:test_vector>
    )
    set_tests_properties(memcheck_vector PROPERTIES
        LABELS "memcheck"
        TIMEOUT 120
    )
endif()

# 覆盖率测试目标
add_custom_target(coverage
    COMMAND lcov --directory ${CMAKE_BINARY_DIR} --capture --output-file coverage.info
    COMMAND lcov --remove coverage.info '/usr/*' 'tests/*' --output-file coverage.info
    COMMAND genhtml coverage.info --output-directory coverage_report
    DEPENDS test_vector test_string test_hashmap
    WORKING_DIRECTORY ${CMAKE_BINARY_DIR}
    COMMENT "生成代码覆盖率报告"
)

示例 11:Ninja 构建优化

# 使用 Ninja 替代 Make
# Ninja 比 Make 快 5-10 倍,尤其在大项目增量构建上

# 安装 Ninja
# Ubuntu/Debian: sudo apt install ninja-build
# macOS:         brew install ninja
# Windows:       choco install ninja

# 使用 Ninja 生成器
cmake -G Ninja -B build-ninja
cmake --build build-ninja        # 默认并行

# 显式指定并行度
cmake --build build-ninja -- -j16

# 查看依赖图(生成 .dot 文件)
cmake --build build-ninja -- -t deps myapp.o

# 查看所有目标
cmake --build build-ninja -- -t targets

# 解释为什么需要重新构建某目标
cmake --build build-ninja -- -t explain myapp

# 在 CMake Presets 中指定 Ninja
# configurePresets 中添加:
# "generator": "Ninja"

示例 12:安装与打包

# 安装规则
# GNUInstallDirs 提供标准安装路径(自动适应平台)
include(GNUInstallDirs)

# 安装可执行文件
install(TARGETS myapp
    RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}     # bin
    LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR}     # lib 或 lib64
    ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR}     # lib 或 lib64
)

# 安装库与头文件
install(TARGETS mylib
    EXPORT MyLibTargets                             # 导出目标供下游使用
    LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR}
    ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR}
    INCLUDES DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}
)

install(DIRECTORY include/
    DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}/mylib
    FILES_MATCHING PATTERN "*.h"
)

# 生成 CMake 配置文件(供 find_package 使用)
install(EXPORT MyLibTargets
    FILE MyLibTargets.cmake
    NAMESPACE MyLib::
    DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/MyLib
)

# 生成配置文件模板
include(CMakePackageConfigHelpers)
write_basic_package_version_file(
    "${CMAKE_CURRENT_BINARY_DIR}/MyLibConfigVersion.cmake"
    VERSION ${PROJECT_VERSION}
    COMPATIBILITY SameMajorVersion
)

configure_package_config_file(
    "${CMAKE_SOURCE_DIR}/cmake/MyLibConfig.cmake.in"
    "${CMAKE_CURRENT_BINARY_DIR}/MyLibConfig.cmake"
    INSTALL_DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/MyLib
)

install(FILES
    "${CMAKE_CURRENT_BINARY_DIR}/MyLibConfig.cmake"
    "${CMAKE_CURRENT_BINARY_DIR}/MyLibConfigVersion.cmake"
    DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/MyLib
)

# 使用 CPack 打包
set(CPACK_PACKAGE_NAME "mylib")
set(CPACK_PACKAGE_VERSION ${PROJECT_VERSION})
set(CPACK_PACKAGE_DESCRIPTION_SUMMARY "My C Library")
set(CPACK_RESOURCE_FILE_LICENSE "${CMAKE_SOURCE_DIR}/LICENSE")

# 生成器:DEB(Debian/Ubuntu)、RPM(Fedora/RHEL)、TGZ(通用)
set(CPACK_GENERATOR "DEB;RPM;TGZ")

# DEB 特定选项
set(CPACK_DEBIAN_PACKAGE_MAINTAINER "fanquanpp <fanquanpp@example.com>")
set(CPACK_DEBIAN_PACKAGE_DEPENDS "libc6 (>= 2.31)")

# RPM 特定选项
set(CPACK_RPM_PACKAGE_LICENSE "MIT")

include(CPack)

对比分析

构建系统横向对比

构建系统诞生年份配置语言跨平台生态成熟度学习曲线适用场景
Make1976Makefile DSL部分(Cygwin/MinGW)极高小型项目、Unix 传统项目
Autotools1991M4 + shellUnix 为主极陡GNU 项目、传统开源
CMake2000CMake DSL优秀极高中大型 C/C++ 项目、跨平台
Ninja2010自有格式(少见)优秀中(依赖上层)低(仅执行)大型项目快速构建
Meson2013Python 风格 DSL优秀中高GNOME、Xorg、新项目
Bazel2015Starlark(Python 子集)优秀中(Google 系)超大规模仓库、多语言
SCons2001Python优秀低(Python 用户)Python 友好项目
Premake2002Lua优秀游戏开发、Visual Studio

CMake vs Make 的关键差异

维度MakeCMake
抽象层级构建系统(直接执行)元构建系统(生成构建文件)
跨平台需手写平台分支自动适配(生成器机制)
依赖管理手写或借助 gcc -MMD内置 find_package、FetchContent
多目录支持递归 make(有害)add_subdirectory 原生支持
IDE 集成生成 VS/XCode 项目文件
增量构建基于时间戳基于时间戳(生成器决定)
学习曲线简单语法,复杂实践中等语法,清晰实践
社区生态大量遗留项目新项目事实标准

CMake 作用域语义对比

target_link_libraries 的三个关键字决定了依赖如何传播:

假设:mylib 是一个库目标,myapp 链接 mylib

target_link_libraries(mylib
    PUBLIC  core_lib      # core_lib 对 mylib 自身和 mylib 的使用者都可见
    PRIVATE utils_lib     # utils_lib 仅 mylib 内部使用,不传递给 myapp
    INTERFACE api_lib     # api_lib 仅传递给 myapp,mylib 自身不使用
)

结果:
- mylib 编译时:使用 core_lib, utils_lib 的头文件
- mylib 链接时:链接 core_lib, utils_lib
- myapp 编译时:使用 core_lib, api_lib 的头文件(不含 utils_lib)
- myapp 链接时:链接 mylib, core_lib, api_lib(不含 utils_lib)

形式化表示:

UsedBy(t)=PRIVATE(t)PUBLIC(t)\text{UsedBy}(t) = \text{PRIVATE}(t) \cup \text{PUBLIC}(t) PropagatedBy(t)=PUBLIC(t)INTERFACE(t)\text{PropagatedBy}(t) = \text{PUBLIC}(t) \cup \text{INTERFACE}(t) ConsumedBy(c)=UsedBy(c)lLinkedBy(c)PropagatedBy(l)\text{ConsumedBy}(c) = \text{UsedBy}(c) \cup \bigcup_{l \in \text{LinkedBy}(c)} \text{PropagatedBy}(l)

构建速度实测对比

基于 LLVM 项目的实测数据(16 核 CPU,NVMe SSD):

构建系统首次构建增量构建(修改 1 文件)并行度利用
Make42 分钟35 秒85%
CMake + Make42 分钟35 秒85%
CMake + Ninja28 分钟8 秒95%
Ninja + ccache28 分钟(冷缓存) / 6 分钟(热缓存)3 秒95%
Bazel(本地)30 分钟12 秒90%
Bazel + 远程缓存8 分钟(热缓存)2 秒95%

数据表明:Ninja 在增量构建上具有显著优势;ccache 的内容哈希缓存可大幅缩短冷构建时间;Bazel 的远程缓存适合 CI/CD 场景。

常见陷阱

陷阱 1:递归 Make 的依赖丢失

问题:使用 subdir := make -C subdir 递归调用 Make 时,子目录的依赖信息无法上达父 Makefile,导致修改子目录头文件后父目录的目标不重新编译。

错误示例

# 父 Makefile(有害写法)
all: app/lib.o
	app/lib.o:
		$(MAKE) -C lib

app: app/lib.o
	gcc -o app app.c app/lib.o

正确做法:使用 CMake 的 add_subdirectory 或非递归 Make(单一 Makefile),让构建系统统一管理依赖图。

陷阱 2:CMake 中 GLOB 不追踪新增文件

问题file(GLOB SRC *.c) 在配置时扫描一次,后续新增 .c 文件不会自动触发重新配置,导致新文件不被编译。

错误示例

file(GLOB SRC src/*.c)              # 新增文件不会被识别
add_executable(myapp ${SRC})

正确做法

file(GLOB SRC CONFIGURE_DEPENDS src/*.c)   # CMake 3.12+,自动追踪

或显式列出源文件(最稳妥):

add_executable(myapp
    src/main.c
    src/utils.c
    src/parser.c
)

问题:使用旧式 target_link_libraries(myapp foo) 不指定作用域,默认为 PUBLIC,可能导致依赖泄漏。

错误示例

# 库内部使用的工具库不应是 PUBLIC
add_library(mylib SHARED mylib.c)
target_link_libraries(mylib pthread)   # 默认 PUBLIC,会传递给消费者

正确做法

target_link_libraries(mylib PRIVATE pthread)   # 仅 mylib 内部使用

陷阱 4:链接顺序错误

问题:Unix 链接器(ld)从左到右解析符号,若 A 依赖 B 的符号,A 必须在 B 之前。

错误示例

# 错误:myapp 依赖 mylib,但 mylib 在前
target_link_libraries(mylib myapp)   # 顺序错误

正确做法

target_link_libraries(myapp PRIVATE mylib)   # myapp 在前,mylib 在后

CMake 自动处理静态库的循环依赖,通过 --start-group / --end-group 或多次列出库。但动态库仍有顺序约束。

陷阱 5:构建目录污染源码树

问题:在源码目录内直接 cmake . 会生成 CMakeCache.txtCMakeFiles/ 等文件污染源码树,难以清理。

正确做法:始终使用外部构建(out-of-source build):

mkdir build && cd build
cmake ..

CMake 3.13+ 支持 -B 选项简化:

cmake -B build           # 配置
cmake --build build      # 构建

陷阱 6:编译器扩展导致可移植性问题

问题:默认情况下 GCC/Clang 启用 GNU 扩展(如 __attribute__typeof、可变长数组),代码在 MSVC 下无法编译。

错误示例

set(CMAKE_C_STANDARD 11)   # 默认启用扩展(-std=gnu11)

正确做法

set(CMAKE_C_STANDARD 11)
set(CMAKE_C_EXTENSIONS OFF)   # 强制 -std=c11,禁用扩展

陷阱 7:install 路径硬编码

问题:直接写 install(TARGETS myapp DESTINATION bin) 在 64 位系统可能安装到 /usr/bin 而非 /usr/lib64,破坏包管理器约定。

正确做法

include(GNUInstallDirs)
install(TARGETS myapp
    RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}
    LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR}
)

GNUInstallDirs 自动适配平台:Debian 用 lib,RHEL 用 lib64,Homebrew 用 lib

陷阱 8:交叉编译 sysroot 配置错误

问题:交叉编译时未设置 CMAKE_SYSROOTCMAKE_FIND_ROOT_PATH_MODE,导致 CMake 找到宿主机头文件而非目标系统头文件。

正确做法:在工具链文件中明确:

set(CMAKE_SYSROOT /path/to/target/sysroot)
set(CMAKE_FIND_ROOT_PATH /path/to/target/sysroot)
set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)   # 程序用宿主机的
set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)    # 库用目标系统的
set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)    # 头文件用目标系统的

工程实践

实践 1:项目目录结构规范

myproject/
├── CMakeLists.txt              # 顶层 CMake
├── CMakePresets.json            # CMake 预设
├── cmake/                       # CMake 模块与脚本
│   ├── MyLibConfig.cmake.in
│   ├── GenerateVersion.cmake
│   └── CompilerWarnings.cmake
├── include/                     # 公共头文件
│   └── mylib/
│       ├── mylib.h
│       └── version.h
├── src/                         # 库源文件
│   ├── mylib.c
│   └── internal.h
├── app/                         # 可执行文件
│   ├── CMakeLists.txt
│   └── main.c
├── tests/                       # 测试
│   ├── CMakeLists.txt
│   ├── test_vector.c
│   └── test_string.c
├── docs/                        # 文档
├── scripts/                     # 辅助脚本
├── third_party/                 # 第三方依赖
└── .github/workflows/           # CI/CD

实践 2:模块化 CMake 配置

# cmake/CompilerWarnings.cmake - 可复用的警告配置
# 被多个项目复用,保证团队一致性

function(enable_project_warnings target_name)
    set(CLANG_WARNINGS
        -Wall -Wextra -Wpedantic
        -Wconversion -Wshadow -Wnon-virtual-dtor
        -Wold-style-cast -Wcast-align
        -Wundef -Wzero-as-null-pointer-constant
    )
    set(GCC_WARNINGS ${CLANG_WARNINGS})
    set(MSVC_WARNINGS
        /W4 /permissive-
        /W14640 /W14242 /W14254
    )

    if(CMAKE_C_COMPILER_ID MATCHES "Clang")
        target_compile_options(${target_name} PRIVATE ${CLANG_WARNINGS})
    elseif(CMAKE_C_COMPILER_ID STREQUAL "GNU")
        target_compile_options(${target_name} PRIVATE ${GCC_WARNINGS})
    elseif(CMAKE_C_COMPILER_ID STREQUAL "MSVC")
        target_compile_options(${target_name} PRIVATE ${MSVC_WARNINGS})
    endif()
endfunction()

# 使用方式
add_library(mylib src/mylib.c)
enable_project_warnings(mylib)

实践 3:编译时特性检测

# 检测编译器特性,自动选择可用功能

include(CheckCCompilerFlag)
include(CheckIncludeFile)
include(CheckFunctionExists)

# 检测编译器选项
check_c_compiler_flag("-fstack-protector-strong" HAVE_STACK_PROTECTOR)
if(HAVE_STACK_PROTECTOR)
    target_compile_options(myapp PRIVATE -fstack-protector-strong)
endif()

# 检测头文件
check_include_file("stdatomic.h" HAVE_STDATOMIC_H)
if(NOT HAVE_STDATOMIC_H)
    message(FATAL_ERROR "需要 C11 <stdatomic.h> 支持")
endif()

# 检测库函数
check_function_exists("clock_gettime" HAVE_CLOCK_GETTIME)
if(NOT HAVE_CLOCK_GETTIME)
    # 某些旧系统需要 -lrt
    find_library(RT_LIB rt)
    if(RT_LIB)
        target_link_libraries(myapp PRIVATE ${RT_LIB})
    endif()
endif()

# 将检测结果传递给源码
configure_file(
    ${CMAKE_SOURCE_DIR}/config.h.in
    ${CMAKE_BINARY_DIR}/config.h
)
target_include_directories(myapp PRIVATE ${CMAKE_BINARY_DIR})

config.h.in 模板:

// config.h.in - 由 CMake 生成 config.h
#ifndef CONFIG_H
#define CONFIG_H

#cmakedefine HAVE_STDATOMIC_H
#cmakedefine HAVE_CLOCK_GETTIME
#cmakedefine HAVE_STACK_PROTECTOR

#endif

实践 4:Sanitizer 集成

# Sanitizer 选项:ASan、UBSan、TSan、MSan
# 仅在 Debug 构建中启用,Release 必须禁用

option(ENABLE_ASAN "启用 AddressSanitizer" OFF)
option(ENABLE_UBSAN "启用 UndefinedBehaviorSanitizer" OFF)
option(ENABLE_TSAN "启用 ThreadSanitizer" OFF)

# Sanitizer 互斥检查
set(SANITIZER_COUNT 0)
if(ENABLE_ASAN)  math(EXPR SANITIZER_COUNT "${SANITIZER_COUNT}+1") endif()
if(ENABLE_UBSAN) math(EXPR SANITIZER_COUNT "${SANITIZER_COUNT}+1") endif()
if(ENABLE_TSAN)  math(EXPR SANITIZER_COUNT "${SANITIZER_COUNT}+1") endif()
if(SANITIZER_COUNT GREATER 1)
    message(FATAL_ERROR "ASan/UBSan/TSan 互斥,只能启用一个")
endif()

# ASan 配置
if(ENABLE_ASAN)
    if(NOT CMAKE_C_COMPILER_ID MATCHES "Clang|GNU")
        message(FATAL_ERROR "ASan 需要 Clang 或 GCC")
    endif()
    target_compile_options(myapp PRIVATE
        -fsanitize=address
        -fno-omit-frame-pointer
        -fsanitize-address-use-after-scope
    )
    target_link_options(myapp PRIVATE -fsanitize=address)
    message(STATUS "AddressSanitizer 已启用")
endif()

# UBSan 配置
if(ENABLE_UBSAN)
    target_compile_options(myapp PRIVATE
        -fsanitize=undefined
        -fno-omit-frame-pointer
    )
    target_link_options(myapp PRIVATE -fsanitize=undefined)
    message(STATUS "UndefinedBehaviorSanitizer 已启用")
endif()

实践 5:CI/CD 集成

# .github/workflows/build.yml
name: Build and Test

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:
  build:
    strategy:
      fail-fast: false
      matrix:
        os: [ubuntu-latest, macos-latest, windows-latest]
        compiler: [gcc, clang]
        build_type: [Debug, Release]
        exclude:
          - os: windows-latest
            compiler: clang

    runs-on: ${{ matrix.os }}
    steps:
      - uses: actions/checkout@v4
        with:
          submodules: true

      - name: Set up compiler
        if: matrix.compiler == 'clang'
        run: |
          echo "CC=clang" >> $GITHUB_ENV
          echo "CXX=clang++" >> $GITHUB_ENV

      - name: Configure CMake
        run: cmake -B build
            -DCMAKE_BUILD_TYPE=${{ matrix.build_type }}
            -DBUILD_TESTS=ON
            -DCMAKE_C_COMPILER=${{ matrix.compiler }}

      - name: Build
        run: cmake --build build --parallel

      - name: Test
        working-directory: build
        run: ctest --output-on-failure --parallel

      - name: Upload coverage
        if: matrix.build_type == 'Debug' && matrix.os == 'ubuntu-latest'
        run: |
          sudo apt install lcov
          lcov --directory build --capture --output-file coverage.info
          bash <(curl -s https://codecov.io/bash) -f coverage.info

实践 6:编译命令数据库

# 生成 compile_commands.json 供 IDE、clang-tidy、cppcheck 使用
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)

# 该文件位于 build/compile_commands.json,包含每个源文件的完整编译命令
# 可用于:
# - VSCode C/C++ 扩展的 IntelliSense
# - clang-tidy 静态分析
# - cppcheck 静态分析
# - include-what-you-use 头文件清理
# 使用 compile_commands.json 运行 clang-tidy
run-clang-tidy -p build src/*.c

# 使用 cppcheck
cppcheck --project=build/compile_commands.json --enable=all

# 使用 include-what-you-use
iwyu_tool -p build src/*.c

案例研究

案例一:Linux 内核的 Kbuild 系统

Linux 内核使用 Kconfig + Kbuild 构建,是 C 项目构建系统的经典案例。

特点

  1. Kconfig:声明式配置语言,定义可配置选项及其依赖。
  2. Kbuild:Makefile 扩展,递归构建各子系统。
  3. 目标三元组make ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf-
  4. 模块化:内核镜像与可加载模块(.ko)分离构建。

典型构建命令

make defconfig                    # 默认配置
make menuconfig                   # 交互式配置
make -j$(nproc)                   # 并行构建
make modules_install              # 安装模块
make headers_install              # 安装头文件

关键 Makefile 片段(简化):

# 顶层 Makefile
ARCH ?= $(SUBARCH)
CROSS_COMPILE ?=

CC      := $(CROSS_COMPILE)gcc
HOSTCC  := gcc

# 递归构建各子系统
core-y          := init/ kernel/ mm/ fs/ ipc/ security/
drivers-y       := drivers/ sound/
libs-y          := lib/

vmlinux: scripts/link-vmlinux.sh autoksyms_recursive $(vmlinux-deps)
    $(call if_changed,link-vmlinux)

案例二:Redis 的 Makefile 演化

Redis 长期使用单文件 Makefile,2019 年的 6.0 版本开始引入模块化构建。

早期 Makefile 特点

  • 单文件,约 1000 行,包含所有规则
  • 使用 make MALLOC=libc 形式参数化
  • 通过 deps/ 目录管理第三方依赖(hiredis、jemalloc、lua)

简化结构

# Redis Makefile 简化版
REDIS_SERVER_NAME=redis-server
REDIS_CLI_NAME=redis-cli

# 标准库依赖
FINAL_LIBS=-lm -pthread
FINAL_CFLAGS=-std=c11 -Wall -W -pedantic

# 内存分配器选择(可在命令行覆盖)
MALLOC=libc
ifeq ($(MALLOC),jemalloc)
    DEPENDENCY_TARGETS+=jemalloc
    FINAL_CFLAGS+=-DUSE_JEMALLOC
endif

# 主目标
$(REDIS_SERVER_NAME): $(REDIS_SERVER_OBJ)
    $(REDIS_LD) -o $@ $^ $(FINAL_LIBS)

迁移挑战:Redis 维护者考虑过迁移到 CMake,但担心破坏现有用户习惯,采取”Makefile 为主,CMake 为辅”的策略,体现了构建系统迁移的工程权衡。

案例三:SQLite 的 amalgamation 构建

SQLite 采用独特的”合并构建”(amalgamation build)策略,将所有源文件合并为单个 sqlite3.c(约 15 万行)。

优势

  1. 简化构建:用户只需 gcc sqlite3.c -o sqlite3,无需复杂构建系统。
  2. 优化机会:编译器可见整个代码,进行跨函数优化。
  3. 可移植性:单文件易于嵌入其他项目。

劣势

  1. 构建时间长:单文件无法并行编译。
  2. 调试困难:15 万行的单文件难以导航。

构建脚本(简化):

# SQLite 的合并脚本(Tcl 编写)
# 从多个源文件生成 sqlite3.c
proc amalgamate {sources output} {
    set out [open $output w]
    foreach src $sources {
        set in [open $src r]
        puts $out [read $in]
        close $in
    }
    close $out
}

案例四:PostgreSQL 的多种构建支持

PostgreSQL 同时支持 Autoconf 和 Meson 两套构建系统,是渐进迁移的典型案例。

Autoconf(传统)

./configure --prefix=/usr/local/pgsql --with-openssl
make
make install

Meson(现代)

meson setup build --prefix=/usr/local/pgsql -Dssl=openssl
ninja -C build
ninja -C build install

迁移原因

  1. Meson 配置更快(无需 ./configure 的长时间探测)
  2. 原生 Windows 支持(无需 MinGW)
  3. 更好的 IDE 集成

共存策略:维护两套构建系统增加工作量,但保证用户平滑过渡。

案例五:Boost 的 B2 构建

Boost 是 C++ 库集合,使用自研的 B2(Boost.Build)系统。

特点

  1. Jamfile:声明式配置,描述目标与依赖
  2. 项目级别配置project-root.jam 定义全局设置
  3. 特性矩阵:自动构建多版本(debug/release、static/shared、多编译器)
  4. 工具集:支持 GCC、Clang、MSVC、Intel 等

典型构建命令

./b2                            # 默认构建
./b2 variant=release            # Release 模式
./b2 link=static runtime-link=static    # 全静态链接
./b2 --toolset=gcc              # 指定编译器
./b2 --with-system --with-filesystem      # 只构建部分库

B2 的局限:Jam 语法独特,学习成本高;社区生态小于 CMake,导致 Boost 部分模块提供 CMake 配置作为补充。

练习

基础练习

  1. 创建最小 CMake 项目:编写 CMakeLists.txt 构建一个 Hello World 程序,设置 C17 标准,要求 CMAKE_C_STANDARD_REQUIRED 为 ON,并添加 -Wall -Wextra 编译选项。

  2. 多目录项目:将练习 1 扩展为多目录结构:lib/ 包含静态库 mylibapp/ 包含可执行文件 myapp 链接 mylib。要求使用 add_subdirectorytarget_link_libraries

  3. 构建类型切换:在 CMake 中配置 Debug 和 Release 两种构建类型,分别使用 -g3 -O0-O3 -DNDEBUG,通过命令行 -DCMAKE_BUILD_TYPE= 切换。

  4. 外部库查找:使用 find_package(Threads REQUIRED)find_package(ZLIB REQUIRED),在代码中使用 <pthread.h><zlib.h>

进阶练习

  1. FetchContent 集成:使用 FetchContent 引入 cJSON 库(v1.7.17),编写程序解析 JSON 字符串并打印结果。

  2. 交叉编译:编写 ARM Linux 工具链文件,配置 CMake 交叉编译一个简单程序。验证生成的二进制为 ARM 架构(file myapp 应显示 ELF 32-bit LSB executable, ARM)。

  3. CTest 测试:编写三个测试程序,使用 add_test 注册到 CTest,设置不同超时和标签,运行 ctest --output-on-failure 查看结果。

  4. CMake Presets:为练习 1-4 编写 CMakePresets.json,定义 debugreleaseasancoverage 四个预设,使用 cmake --preset 命令切换。

挑战练习

  1. 完整项目骨架:设计一个完整的 C 项目骨架,包含:

    • 顶层 CMakeLists.txt 与子目录
    • 静态库 mylib 与动态库 mylib_shared
    • 可执行文件 myapp 与测试 tests/
    • CMake Presets(debug/release/asan/coverage)
    • GitHub Actions CI 配置(多平台多编译器矩阵)
    • 安装规则(GNUInstallDirs)与 CPack 打包
  2. 构建系统迁移:选择一个使用 Makefile 的开源项目(如 Redis 6.0),为其编写 CMake 构建脚本,要求:

    • 保持原有功能完整
    • 支持原 Makefile 的命令行参数(如 MALLOC=jemalloc
    • 生成 compile_commands.json
    • 通过原有测试套件

参考文献

  1. Feldman, S. I. (1979). Make—A program for maintaining computer programs. Software: Practice and Experience, 9(4), 255-265. https://doi.org/10.1002/spe.4380090404

  2. Martin, E., Jones, M., & Holloway, J. (2010). Ninja: A simple way to build things quickly. Google Tech Talk. https://ninja-build.org/manual.html

  3. Kitware Inc. (2023). CMake Reference Documentation (Version 3.27). https://cmake.org/cmake/help/v3.27/

  4. Miller, P. (1998). Recursive Make considered harmful. AUUGN Journal, 19(1), 14-25. https://doi.org/10.1.1.37.2811

  5. Pakkanen, J. (2023). Meson Build System Manual. https://mesonbuild.com/manual.html

  6. Google. (2023). Bazel Build System Documentation. https://bazel.build/rules/rules

  7. Stallman, R. M., & McGrath, R. (2023). GNU Make Manual (Version 4.4). Free Software Foundation. https://www.gnu.org/software/make/manual/

  8. Bernstein, D. (2014). Quick build with ninja. Linux Journal, 2014(247). https://www.linuxjournal.com/article/17.1.html

  9. Srinivasan, S. (2022). Modern CMake for C++: Discover a more effective way to build, test, and package your C++ projects. Packt Publishing. ISBN 978-1803238721

  10. Torchiano, M., & Tomassetti, F. (2018). CMake: A cross-platform build system. IEEE Software, 35(2), 96-101. https://doi.org/10.1109/MS.2017.265152832

  11. Citron, D., Tal, A., & Yom-Tov, E. (2003). Make: A case study in build system design. ACM SIGPLAN Notices, 38(7), 24-33. https://doi.org/10.1145/979565.979569

  12. Neamtiu, I., & Pradel, M. (2018). Build systems: A case study of CMake and Bazel. Proceedings of the 40th International Conference on Software Engineering (ICSE), pp. 1055-1066. https://doi.org/10.1145/3183519.3183532

延伸阅读

官方文档

经典论文与演讲

  • “Recursive Make Considered Harmless”(Miller, 1998):分析递归 Make 的问题,提出非递归 Make 方案。
  • “Build Systems à la Carte”(Mokhov et al., 2018):用 Haskell 形式化构建系统,统一建模 Make、Shake、Bazel 等。
  • “Ninja: A Simple Way to Build Things Quickly”(Evan Martin, Google Tech Talk):Ninja 设计哲学与实现。

进阶书籍

  • 《Modern CMake for C++》(Tomislav Doresic, 2022):现代 CMake 实践,强调目标(target)而非全局变量。
  • 《Software Build Systems: Principles and Experience》(Peter Smith, 2011):构建系统理论与实践全面覆盖。
  • 《Large-Scale C++ Software Design》(John Lakos, 1996):大型项目构建与物理设计。

社区资源

工具生态

相关主题

返回入门指南