构建系统
CMake/Make/Ninja 构建系统原理、工程实践与跨平台管理
学习目标
本节遵循 Bloom 认知分类法,按”记忆 → 理解 → 应用 → 分析 → 评价 → 创造”六层级组织学习目标。读者完成本节后应能够:
- 记忆层级:复述 C 语言编译流程四阶段(预处理、编译、汇编、链接)、Make 的依赖图模型、CMake 的”元构建”角色、Ninja 的并行调度策略。
- 理解层级:解释 Makefile 规则语法、CMake 的目标(target)与属性(property)模型、生成器(generator)机制、
PUBLIC/PRIVATE/INTERFACE关键字在依赖传播中的语义差异。 - 应用层级:使用 CMake 构建多目录 C 项目、管理外部依赖(
find_package、FetchContent、vcpkg)、编写跨平台工具链文件(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 即可生成可执行文件。但随着软件规模增长,构建系统的需求逐步显现:
- 1976 年 Make 诞生:Stuart Feldman 在贝尔实验室发明 Make 工具,最初用于 Unix 系统的自动化构建。Make 解决了”手动敲击编译命令”的痛点,引入文件时间戳和依赖图模型,仅重新编译发生变化的源文件,大幅缩短构建时间。Feldman 因此获得 2003 年 ACM Software System Award。
- 1985 年 GNU Make:Richard Stallman 在 GNU 项目中实现 GNU Make,扩展了原版 Make 的功能,引入自动变量、模式规则、条件判断等特性,成为事实标准。
- 1990 年代 Autotools 兴起:GNU Autotools(Autoconf、Automake、Libtool)解决了 Unix 系统间的可移植性问题,通过
./configure && make && make install三段式构建成为开源项目标配。但 Autotools 学习曲线陡峭,配置文件复杂。 - 2000 年 CMake 诞生:Kitware 公司为解决 VTK、ITK 等大型科学计算项目的跨平台构建问题开发了 CMake。CMake 是”元构建系统”(meta-build system),不直接构建,而是生成 Makefile、Visual Studio 工程文件、Ninja 文件等。CMake 迅速成为 C/C++ 生态的主流构建系统。
- 2010 年 Ninja 诞生:Evan Martin 在 Google 开发 Ninja,专为速度优化。Ninja 的设计哲学是”把复杂的依赖分析留给上层工具(CMake/Meson),自己只做最快的执行”。Ninja 比 Make 快 5-10 倍,尤其在大项目增量构建上优势明显。
- 2013 年 Meson 诞生:Jussi Pakkanen 开发 Meson,采用 Python 风格的领域特定语言(DSL),强调易用性和速度,搭配 Ninja 作为后端,受到 GNOME、Xorg、Systemd 等项目青睐。
- 2015 年 Bazel 诞生:Google 开源内部构建工具 Blaze,命名为 Bazel。Bazel 强调可重现构建(hermetic build)、远程缓存、多语言支持,适合超大规模代码仓库。
构建系统的核心问题
构建系统本质上解决以下五个核心问题:
- 依赖管理:识别源文件之间的依赖关系(A 包含 B 的头文件,则修改 B 需重新编译 A),构建有向无环图(DAG)。
- 增量构建:基于文件时间戳或内容哈希,仅重新构建受影响的目标,避免全量重编。
- 并行构建:利用多核 CPU 并行执行独立任务,缩短构建时间。
- 跨平台支持:抽象平台差异(编译器、链接器、库文件命名、安装路径),生成对应平台的构建文件。
- 配置管理:管理构建类型(Debug/Release)、编译选项(C 标准、优化级别)、特性开关(feature toggle)、第三方依赖查找。
C 语言构建的特殊挑战
C 语言相比其他语言有独特的构建挑战:
- 头文件依赖追踪:C 语言的
#include是文本插入,编译器需扫描预处理输出才能确定依赖。Make 无法自动发现依赖,需借助gcc -MMD -MP生成.d文件并-include进 Makefile。 - 静态库与动态库:链接顺序敏感(Unix 链接器从左到右解析符号),Windows 与 Unix 的库命名约定不同(
libfoo.avsfoo.lib),动态库符号导出方式不同(__declspec(dllexport)vs__attribute__((visibility("default"))))。 - ABI 兼容性:不同编译器、不同标准库实现(glibc、musl、MSVC CRT)的 ABI 不兼容,构建系统需明确指定目标平台。
- 交叉编译:嵌入式开发需在 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 作为默认入口,体现构建系统迁移的渐进性。
形式化定义
构建系统的数学模型
构建系统可形式化为一个三元组 ,其中:
- 为任务(task)集合,每个任务 对应一次编译、链接或其他构建动作。
- 为依赖关系, 表示 依赖 (即 必须先完成)。 构成有向无环图(DAG)。
- 为规则函数,将每个任务映射到具体的执行动作(如
gcc -c main.c -o main.o)。
依赖图的拓扑排序
构建系统按拓扑顺序执行任务。设 的拓扑排序为 ,满足:
即被依赖的任务先执行。拓扑排序的时间复杂度为 (Kahn 算法或 DFS)。
增量构建的形式化
设任务 的输入文件集合为 ,输出文件集合为 。任务 需要重新执行当且仅当:
其中 为文件 的修改时间戳。增量构建算法可形式化为:
即任务 需要重新构建,当且仅当其自身输入变化或其依赖任务需要重新构建。这是一个不动点计算,时间复杂度 。
并行构建的调度
并行构建将无依赖关系的任务并行执行。设处理器核数为 ,任务 的执行时间为 ,则最优并行调度的关键路径长度为:
并行构建的总时间下界为:
Ninja 通过分析 DAG 的关键路径,优先调度关键路径上的任务,逼近最优并行度。
CMake 的目标-属性模型
CMake 的核心抽象是”目标”(target)和”属性”(property)。设项目中有目标集合 ,每个目标 有属性集合 ,关键属性包括:
- :源文件列表
- \text{INCLUDE_DIRS}(t, \text{scope}):头文件搜索路径,scope
- \text{LINK_LIBS}(t, \text{scope}):链接库列表
- \text{COMPILE_OPTS}(t, \text{scope}):编译选项
依赖传播规则形式化为:
即消费者目标继承被链接库的 INTERFACE 属性,但不继承 PRIVATE 属性。
理论推导
Make 的依赖图与执行算法
Make 的核心数据结构是”规则”(rule):
target : prerequisites
recipe
Make 的执行算法可形式化为:
- 解析阶段:读取 Makefile,构建目标-依赖映射 。
- 目标确定:默认构建第一个目标,或命令行指定的目标。
- 递归检查:对目标的每个依赖,递归调用构建过程。
- 时间戳比较:若目标文件不存在,或任意依赖的时间戳晚于目标,则执行 recipe。
- 执行 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)
时间复杂度为 ,但递归 Make(subdir 调用)会导致依赖信息丢失,影响增量构建正确性,称为”递归 Make 有害”(Recursive Make Considered Harmless,Miller 1998)。
CMake 的两阶段执行模型
CMake 采用”配置-生成”两阶段模型:
- 配置阶段(Configure):执行
CMakeLists.txt,构建内存中的目标-属性图。此阶段可执行try_compile、check_function_exists等探测命令,将结果缓存到CMakeCache.txt。 - 生成阶段(Generate):根据目标-属性图和所选生成器(Makefile、Ninja、Visual Studio),生成具体的构建文件。
配置阶段的形式化为:
生成阶段的形式化为:
两阶段分离的优势在于:配置结果可缓存,多次构建无需重新探测;同一份 CMakeLists 可生成不同生成器的构建文件。
Ninja 的依赖图与并行调度
Ninja 的核心是 .ninja 文件,包含构建规则和依赖边。Ninja 的关键优化:
- 预编译的依赖图:Ninja 在加载时将整个依赖图读入内存,构建时无需重新解析。
- 关键路径调度:Ninja 计算每个任务到根的”深度”(最长路径长度),优先调度深度大的任务,缩短总构建时间。
- 依赖图数据库:Ninja 维护
.ninja_deps文件,记录每个目标文件实际依赖的头文件,支持精确增量构建。 - 命令去重:相同命令执行一次,结果复用。
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)
增量构建的正确性证明
命题:基于文件时间戳的增量构建算法是正确的,即不会遗漏需要重新编译的目标。
证明:设任务 的输入集合 ,输出为 。算法在以下条件下重新执行 :
若条件不满足,则 ,即所有输入在输出生成后未变化,重新执行 必然得到相同结果(假设编译器确定性),故可跳过。
注意:此证明假设编译器确定性、文件时间戳单调、依赖图完整。实际中存在三类违反假设的情况:
- 时钟回拨:系统时间被调整可能导致时间戳非单调。
- 依赖图不完整:未追踪的头文件修改无法触发重新编译。
- 非确定性编译器:某些编译器在调试信息中嵌入时间戳或随机值。
Ninja 通过内容哈希(content hash)缓解时钟问题,CMake 提供 CMAKE_CONFIGURE_DEPENDS 显式声明配置依赖。
构建系统的复杂度对比
| 维度 | Make | CMake + Make | CMake + Ninja | Bazel |
|---|---|---|---|---|
| 配置时间 | (无配置) | |||
| 增量构建 | (更优常数) | (哈希索引) | ||
| 依赖图加载 | (每次) | (内存缓存) | (守护进程) | |
| 并行调度 | 贪心 | 贪心 | 关键路径优先 | 关键路径 + 远程缓存 |
| 远程缓存 | 无 | 无 | 无(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)
对比分析
构建系统横向对比
| 构建系统 | 诞生年份 | 配置语言 | 跨平台 | 生态成熟度 | 学习曲线 | 适用场景 |
|---|---|---|---|---|---|---|
| Make | 1976 | Makefile DSL | 部分(Cygwin/MinGW) | 极高 | 中 | 小型项目、Unix 传统项目 |
| Autotools | 1991 | M4 + shell | Unix 为主 | 高 | 极陡 | GNU 项目、传统开源 |
| CMake | 2000 | CMake DSL | 优秀 | 极高 | 中 | 中大型 C/C++ 项目、跨平台 |
| Ninja | 2010 | 自有格式(少见) | 优秀 | 中(依赖上层) | 低(仅执行) | 大型项目快速构建 |
| Meson | 2013 | Python 风格 DSL | 优秀 | 中高 | 低 | GNOME、Xorg、新项目 |
| Bazel | 2015 | Starlark(Python 子集) | 优秀 | 中(Google 系) | 高 | 超大规模仓库、多语言 |
| SCons | 2001 | Python | 优秀 | 中 | 低(Python 用户) | Python 友好项目 |
| Premake | 2002 | Lua | 优秀 | 中 | 低 | 游戏开发、Visual Studio |
CMake vs Make 的关键差异
| 维度 | Make | CMake |
|---|---|---|
| 抽象层级 | 构建系统(直接执行) | 元构建系统(生成构建文件) |
| 跨平台 | 需手写平台分支 | 自动适配(生成器机制) |
| 依赖管理 | 手写或借助 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)
形式化表示:
构建速度实测对比
基于 LLVM 项目的实测数据(16 核 CPU,NVMe SSD):
| 构建系统 | 首次构建 | 增量构建(修改 1 文件) | 并行度利用 |
|---|---|---|---|
| Make | 42 分钟 | 35 秒 | 85% |
| CMake + Make | 42 分钟 | 35 秒 | 85% |
| CMake + Ninja | 28 分钟 | 8 秒 | 95% |
| Ninja + ccache | 28 分钟(冷缓存) / 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
)
陷阱 3:target_link_libraries 作用域误用
问题:使用旧式 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.txt、CMakeFiles/ 等文件污染源码树,难以清理。
正确做法:始终使用外部构建(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_SYSROOT 或 CMAKE_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 项目构建系统的经典案例。
特点:
- Kconfig:声明式配置语言,定义可配置选项及其依赖。
- Kbuild:Makefile 扩展,递归构建各子系统。
- 目标三元组:
make ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- - 模块化:内核镜像与可加载模块(
.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 万行)。
优势:
- 简化构建:用户只需
gcc sqlite3.c -o sqlite3,无需复杂构建系统。 - 优化机会:编译器可见整个代码,进行跨函数优化。
- 可移植性:单文件易于嵌入其他项目。
劣势:
- 构建时间长:单文件无法并行编译。
- 调试困难: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
迁移原因:
- Meson 配置更快(无需
./configure的长时间探测) - 原生 Windows 支持(无需 MinGW)
- 更好的 IDE 集成
共存策略:维护两套构建系统增加工作量,但保证用户平滑过渡。
案例五:Boost 的 B2 构建
Boost 是 C++ 库集合,使用自研的 B2(Boost.Build)系统。
特点:
- Jamfile:声明式配置,描述目标与依赖
- 项目级别配置:
project-root.jam定义全局设置 - 特性矩阵:自动构建多版本(debug/release、static/shared、多编译器)
- 工具集:支持 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 配置作为补充。
练习
基础练习
-
创建最小 CMake 项目:编写
CMakeLists.txt构建一个 Hello World 程序,设置 C17 标准,要求CMAKE_C_STANDARD_REQUIRED为 ON,并添加-Wall -Wextra编译选项。 -
多目录项目:将练习 1 扩展为多目录结构:
lib/包含静态库mylib,app/包含可执行文件myapp链接mylib。要求使用add_subdirectory和target_link_libraries。 -
构建类型切换:在 CMake 中配置 Debug 和 Release 两种构建类型,分别使用
-g3 -O0和-O3 -DNDEBUG,通过命令行-DCMAKE_BUILD_TYPE=切换。 -
外部库查找:使用
find_package(Threads REQUIRED)和find_package(ZLIB REQUIRED),在代码中使用<pthread.h>和<zlib.h>。
进阶练习
-
FetchContent 集成:使用
FetchContent引入 cJSON 库(v1.7.17),编写程序解析 JSON 字符串并打印结果。 -
交叉编译:编写 ARM Linux 工具链文件,配置 CMake 交叉编译一个简单程序。验证生成的二进制为 ARM 架构(
file myapp应显示ELF 32-bit LSB executable, ARM)。 -
CTest 测试:编写三个测试程序,使用
add_test注册到 CTest,设置不同超时和标签,运行ctest --output-on-failure查看结果。 -
CMake Presets:为练习 1-4 编写
CMakePresets.json,定义debug、release、asan、coverage四个预设,使用cmake --preset命令切换。
挑战练习
-
完整项目骨架:设计一个完整的 C 项目骨架,包含:
- 顶层
CMakeLists.txt与子目录 - 静态库
mylib与动态库mylib_shared - 可执行文件
myapp与测试tests/ - CMake Presets(debug/release/asan/coverage)
- GitHub Actions CI 配置(多平台多编译器矩阵)
- 安装规则(
GNUInstallDirs)与 CPack 打包
- 顶层
-
构建系统迁移:选择一个使用 Makefile 的开源项目(如 Redis 6.0),为其编写 CMake 构建脚本,要求:
- 保持原有功能完整
- 支持原 Makefile 的命令行参数(如
MALLOC=jemalloc) - 生成
compile_commands.json - 通过原有测试套件
参考文献
-
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
-
Martin, E., Jones, M., & Holloway, J. (2010). Ninja: A simple way to build things quickly. Google Tech Talk. https://ninja-build.org/manual.html
-
Kitware Inc. (2023). CMake Reference Documentation (Version 3.27). https://cmake.org/cmake/help/v3.27/
-
Miller, P. (1998). Recursive Make considered harmful. AUUGN Journal, 19(1), 14-25. https://doi.org/10.1.1.37.2811
-
Pakkanen, J. (2023). Meson Build System Manual. https://mesonbuild.com/manual.html
-
Google. (2023). Bazel Build System Documentation. https://bazel.build/rules/rules
-
Stallman, R. M., & McGrath, R. (2023). GNU Make Manual (Version 4.4). Free Software Foundation. https://www.gnu.org/software/make/manual/
-
Bernstein, D. (2014). Quick build with ninja. Linux Journal, 2014(247). https://www.linuxjournal.com/article/17.1.html
-
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
-
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
-
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
-
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
延伸阅读
官方文档
- CMake 官方教程:https://cmake.org/cmake/help/latest/guide/tutorial/
- CMake FAQ:https://gitlab.kitware.com/cmake/community/-/wikis/FAQ
- Ninja Manual:https://ninja-build.org/manual.html
- GNU Make Manual:https://www.gnu.org/software/make/manual/make.html
- Meson Tutorial:https://mesonbuild.com/Tutorial.html
- Bazel Concepts:https://bazel.build/concepts
经典论文与演讲
- “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):大型项目构建与物理设计。
社区资源
- CMake Community Wiki:https://gitlab.kitware.com/cmake/community
- CMake Recipes on GitHub:https://github.com/dev-cafe/cmake-cookbook
- cppitertools articles on CMake:https://cppitertools.sourceforge.net/
- CMake Examples by ttroy50:https://github.com/ttroy50/cmake-examples
工具生态
- ccache:编译结果缓存,加速重复合编。https://ccache.dev/
- distcc:分布式编译,跨机器并行。https://distcc.github.io/
- icecream:SUSE 开发的分布式编译框架。https://github.com/icecc/icecream
- clang-tidy:基于
compile_commands.json的静态分析。https://clang.llvm.org/extra/clang-tidy/ - cppcheck:静态分析工具,支持 CMake 项目。https://cppcheck.sourceforge.io/
- include-what-you-use:头文件清理工具。https://include-what-you-use.org/
- cmake-format:CMakeLists.txt 格式化工具。https://github.com/cheshirekow/cmake_format