QuanZhou's Wiki

C++ 项目构建工具学习路线:从 Make 到 CMake

~/ C/C++#C++工程#构建工具#CMake

之前做过 15445 Bustub6.s081 xv6 这类项目,其实已经接触过构建工具了。

比如在 xv6 里经常执行:

make qemu
make grade

Bustub 里经常执行:

mkdir build
cd build
cmake ..
make -j

但如果只是照着项目文档跑命令,很容易形成一种感觉:

我会用这些命令,但不知道它们到底在帮我做什么。

这篇文章想系统学习一下现代 C++ 项目的构建工具,重点是把 MakeCMakeNinja、依赖管理这些东西放在同一张图里理解。

构建工具到底解决什么问题?

先不考虑工具,一个最小的 C++ 程序可能这样编译:

g++ main.cpp -o main

但真实项目不会只有一个文件。它通常会有:

头文件
源文件
第三方库
编译选项
链接选项
测试程序
benchmark
不同平台
Debug / Release 模式
代码生成
安装规则

所以构建工具本质上解决的是:

从一堆源文件、配置和依赖,稳定地生成最终产物。

最终产物可能是:

可执行文件
静态库
动态库
测试二进制
编译数据库 compile_commands.json
安装目录
打包产物

先理解编译过程

学习 MakeCMake 之前,要先理解 C++ 项目的基本构建流程。

一个简单项目:

demo/
  include/
    add.h
  src/
    add.cpp
    main.cpp

include/add.h

#pragma once

int add(int a, int b);

src/add.cpp

#include "add.h"

int add(int a, int b) {
    return a + b;
}

src/main.cpp

#include <iostream>

#include "add.h"

int main() {
    std::cout << add(1, 2) << std::endl;
    return 0;
}

如果不用构建工具,可以手动编译:

mkdir -p build
g++ -std=c++20 -Iinclude -c src/add.cpp -o build/add.o
g++ -std=c++20 -Iinclude -c src/main.cpp -o build/main.o
g++ build/add.o build/main.o -o build/demo

这里其实分成了两步:

compile: .cpp -> .o
link:    .o   -> executable

-Iinclude 表示告诉编译器去哪里找头文件。

-std=c++20 表示使用 C++20 标准。

-c 表示只编译,不链接。

最后一步把多个 .o 文件链接成可执行文件。

所以构建工具做的第一件事就是把这些命令自动化。

头文件和源文件的关系

刚开始学构建系统时,很容易以为:

.h 和 .cpp 是一一对应的。

但从编译器视角看,不是这样。

编译器真正编译的是 .cpp 文件。每个 .cpp 文件加上它 #include 进来的所有头文件,形成一个编译单元,也叫 translation unit

例如:

main.cpp
  -> iostream
  -> add.h

main.cpp 被编译时,add.h 的内容会被预处理器展开进去。

因此,头文件变化也可能导致 .cpp 需要重新编译。

这就是构建工具需要追踪依赖的原因:

main.cpp 依赖 add.h
add.cpp  依赖 add.h

如果 add.h 变了,main.cppadd.cpp 对应的 .o 都应该重新生成。

Make:把命令和依赖写清楚

Make 是非常经典的构建工具。它的核心思想是:

目标文件依赖哪些文件。
如果依赖比目标更新,就执行对应命令。

一个最小的 Makefile

CXX := g++
CXXFLAGS := -std=c++20 -Iinclude -Wall -Wextra

build/demo: build/main.o build/add.o
	$(CXX) build/main.o build/add.o -o build/demo

build/main.o: src/main.cpp include/add.h
	mkdir -p build
	$(CXX) $(CXXFLAGS) -c src/main.cpp -o build/main.o

build/add.o: src/add.cpp include/add.h
	mkdir -p build
	$(CXX) $(CXXFLAGS) -c src/add.cpp -o build/add.o

.PHONY: clean
clean:
	rm -rf build

执行:

make

make 会默认构建第一个目标,也就是 build/demo

这条规则:

build/demo: build/main.o build/add.o
	$(CXX) build/main.o build/add.o -o build/demo

意思是:

想得到 build/demo,必须先有 build/main.o 和 build/add.o。
如果它们不存在,或者它们比 build/demo 更新,就执行下面的链接命令。

这里最关键的语法是:

target: prerequisites
	recipe

也就是:

目标: 依赖
	生成目标的命令

注意 recipe 前面通常必须是 tab,不是普通空格。

xv6 里的 Makefile 在做什么?

6.s081 xv6 里直接用 make qemu,背后大致包含几类工作:

编译 kernel 代码。
编译 user 程序。
把 user 程序打包进文件系统镜像。
链接 kernel。
启动 qemu。

所以 make qemu 不是一个单纯的“运行 qemu”命令,它会先确保内核、用户程序、文件系统镜像都已经是最新的。

这也解释了为什么改了某个 .c 文件后,再执行 make qemu,它不一定会全部重编译,而是只重编译受影响的部分。

在这类教学项目里,直接写 Makefile 很合适,因为:

项目结构固定。
目标平台固定。
构建规则需要显式展示。
很多步骤不是标准 C++ 项目流程,比如生成镜像、启动模拟器。

Make 的局限

Make 很直接,但大型 C++ 项目完全手写 Makefile 会遇到很多问题:

跨平台困难。
依赖库查找麻烦。
头文件依赖维护麻烦。
Debug / Release 配置容易混乱。
不同编译器选项差异很大。
项目拆成多个库后规则重复。
IDE 和语言服务器支持不够直接。

例如一个项目要同时支持:

Linux + GCC
macOS + Clang
Windows + MSVC
Ninja
Unix Makefiles
Visual Studio

这时如果只用手写 Makefile,复杂度会很快上升。

于是现代 C++ 项目通常会引入 CMake

CMake:生成构建系统的构建系统

CMake 不是直接替代编译器,也不一定直接替代 make

更准确地说:

CMake 是 meta build system。

它读取 CMakeLists.txt,然后生成真正执行构建的后端文件。

例如:

CMakeLists.txt
  -> Unix Makefiles
  -> Ninja files
  -> Visual Studio project
  -> Xcode project

所以平时执行:

cmake ..
make -j

可以理解成两步:

cmake ..  生成 Makefile。
make -j   根据 Makefile 真正编译。

如果使用 Ninja

cmake -S . -B build -G Ninja
cmake --build build -j

可以理解成:

cmake -S . -B build -G Ninja  生成 Ninja 构建文件。
cmake --build build -j        调用 Ninja 真正编译。

一个最小 CMake 项目

对前面的 demo 项目,可以写一个 CMakeLists.txt

cmake_minimum_required(VERSION 3.20)

project(demo LANGUAGES CXX)

add_executable(demo
    src/main.cpp
    src/add.cpp
)

target_compile_features(demo PRIVATE cxx_std_20)

target_include_directories(demo
    PRIVATE
        ${PROJECT_SOURCE_DIR}/include
)

构建:

cmake -S . -B build
cmake --build build -j

这里几个概念很重要。

cmake_minimum_required()

cmake_minimum_required(VERSION 3.20)

表示当前项目至少需要哪个版本的 CMake

不要把这个版本写得特别老,因为新版本 CMake 的行为更清晰,现代写法也更多。

project()

project(demo LANGUAGES CXX)

定义项目名,并说明这个项目使用 C++

add_executable()

add_executable(demo
    src/main.cpp
    src/add.cpp
)

定义一个可执行文件目标,名字叫 demo

这里的 demo 不是普通变量,而是一个 target

现代 CMake 的核心就是围绕 target 组织工程。

target_compile_features()

target_compile_features(demo PRIVATE cxx_std_20)

表示 demo 这个目标需要 C++20

这比直接写:

set(CMAKE_CXX_STANDARD 20)

更贴近现代 CMake 的风格,因为它把需求绑定到具体目标上。

target_include_directories()

target_include_directories(demo
    PRIVATE
        ${PROJECT_SOURCE_DIR}/include
)

表示编译 demo 时,需要把 include/ 加入头文件搜索路径。

这对应手写命令里的:

-Iinclude

现代 CMake 的核心:target

刚开始写 CMake 时,最容易写成全局变量风格:

include_directories(include)
add_compile_options(-Wall -Wextra)
link_libraries(...)

这些写法不是完全不能用,但大型项目里容易污染全局配置。

现代 CMake 更推荐围绕 target 写:

target_include_directories(...)
target_compile_options(...)
target_compile_definitions(...)
target_link_libraries(...)
target_compile_features(...)

原因是一个项目里可能有很多目标:

核心库
命令行工具
单元测试
benchmark
示例程序
代码生成工具

每个目标需要的头文件、编译选项、链接库都可能不同。

所以更好的组织方式是:

不要把选项撒到全局。
把选项挂到需要它的 target 上。

PRIVATE、PUBLIC、INTERFACE 是什么?

CMake 里经常看到:

PRIVATE
PUBLIC
INTERFACE

这三个词是在描述“属性是否传递给依赖者”。

假设有一个库:

add_library(math_utils src/add.cpp)

target_include_directories(math_utils
    PUBLIC
        ${PROJECT_SOURCE_DIR}/include
)

target_compile_features(math_utils PUBLIC cxx_std_20)

再有一个程序依赖它:

add_executable(demo src/main.cpp)

target_link_libraries(demo
    PRIVATE
        math_utils
)

如果 math_utils 的头文件在 include/,并且使用者也需要包含这些头文件,那么 include/ 就应该是 PUBLIC

可以这样理解:

PRIVATE:   只给自己用。
PUBLIC:    自己用,依赖自己的目标也要用。
INTERFACE: 自己不编译,只给依赖自己的目标用。

常见判断方式:

如果只影响当前 target 的 .cpp,用 PRIVATE。
如果出现在当前 target 的头文件接口里,用 PUBLIC。
如果这个 target 本身不产生编译产物,只表达使用要求,用 INTERFACE。

例如:

库内部 .cpp 需要的 include path -> PRIVATE
库 public header 需要的 include path -> PUBLIC
header-only library 的 include path -> INTERFACE

静态库和动态库

C++ 项目通常不会把所有 .cpp 都直接塞进一个可执行文件,而是会拆成库。

add_library(math_utils
    src/add.cpp
)

target_include_directories(math_utils
    PUBLIC
        ${PROJECT_SOURCE_DIR}/include
)

target_compile_features(math_utils PUBLIC cxx_std_20)

add_executable(demo src/main.cpp)

target_link_libraries(demo
    PRIVATE
        math_utils
)

这里的依赖关系是:

demo -> math_utils

CMake 会知道:

先编译 math_utils。
再编译 demo。
最后把 demo 链接到 math_utils。

如果没有特别指定,add_library() 生成静态库还是动态库,可能取决于 BUILD_SHARED_LIBS

也可以显式指定:

add_library(math_utils STATIC src/add.cpp)
add_library(math_utils SHARED src/add.cpp)

静态库常见后缀:

.a
.lib

动态库常见后缀:

.so
.dylib
.dll

对初学阶段来说,先理解一件事就够了:

库也是 target,可执行文件通过 target_link_libraries() 依赖库。

Debug 和 Release

构建项目时经常要区分:

Debug:   保留调试信息,优化较少,方便 gdb/lldb 调试。
Release: 开启优化,性能更接近真实运行。

使用 CMake 时,可以这样配置:

cmake -S . -B build-debug -DCMAKE_BUILD_TYPE=Debug
cmake --build build-debug -j

或者:

cmake -S . -B build-release -DCMAKE_BUILD_TYPE=Release
cmake --build build-release -j

Debug 通常类似:

-g -O0

Release 通常类似:

-O3 -DNDEBUG

在学习性能、并发、编译器优化时,一定要注意当前构建类型。

比如一个数据竞争问题,在 Debug 下看起来“正常”,在 Release 下可能完全变成另一种行为。

Ninja:更快的构建后端

NinjaMake 一样,是真正执行构建的工具。

区别是:

Makefile 通常可以人手写。
Ninja 文件通常由 CMake / Meson 等工具生成。

使用方式:

cmake -S . -B build -G Ninja
cmake --build build -j

或者直接:

ninja -C build

Ninja 的特点是简单、快、适合作为生成器后端。

很多现代 C++ 项目会选择:

CMake 负责描述项目。
Ninja 负责执行构建。

在使用体验上,你不一定要直接写 build.ninja,但要知道:

cmake --build build

背后可能调用的是 make,也可能调用的是 ninja,取决于配置时选择的 generator。

compile_commands.json

现代 C++ 开发里还有一个很重要的文件:

compile_commands.json

它记录每个源文件真实的编译命令。

例如:

[
  {
    "directory": "/path/to/demo/build",
    "command": "g++ -I/path/to/demo/include -std=c++20 -c /path/to/demo/src/main.cpp",
    "file": "/path/to/demo/src/main.cpp"
  }
]

这个文件通常给这些工具使用:

clangd
clang-tidy
clang-format 辅助工具
代码跳转
静态分析
IDE

CMake 生成:

cmake -S . -B build -DCMAKE_EXPORT_COMPILE_COMMANDS=ON

如果用 clangd,这个文件非常关键。没有它,语言服务器可能不知道真实的头文件路径、宏定义和编译标准,导致跳转、补全、报错都不准确。

第三方依赖怎么处理?

真实项目一定会依赖外部库,例如:

gtest
fmt
spdlog
protobuf
grpc
boost
openssl

CMake 里,最理想的方式是把依赖也变成 target

例如:

find_package(fmt REQUIRED)

add_executable(demo src/main.cpp)

target_link_libraries(demo
    PRIVATE
        fmt::fmt
)

这里 fmt::fmt 就是一个导入的 target

它不仅表示链接 fmt,还可能携带:

头文件路径
编译宏
链接选项
依赖的其他库

也就是说,现代 CMake 里,依赖库最好不是一串裸路径,而是一个带使用要求的 target

常见依赖管理方式包括:

系统包管理器:apt、brew
C++ 包管理器:vcpkg、Conan
源码拉取:FetchContent
Git submodule
公司内部依赖管理系统

初学阶段不需要马上精通所有方式,但要知道它们解决的是同一个问题:

第三方库从哪里来,版本如何固定,如何暴露给构建系统。

FetchContent:直接拉源码参与构建

CMake 自带的 FetchContent 可以在配置阶段拉取第三方源码,并让它参与当前项目构建。

例如使用 googletest

include(FetchContent)

FetchContent_Declare(
    googletest
    GIT_REPOSITORY https://github.com/google/googletest.git
    GIT_TAG v1.14.0
)

FetchContent_MakeAvailable(googletest)

add_executable(demo_test tests/demo_test.cpp)

target_link_libraries(demo_test
    PRIVATE
        GTest::gtest_main
)

这样项目就可以使用 GTest::gtest_main 这个 target

不过 FetchContent 也有代价:

配置时可能需要网络。
依赖源码会参与当前项目构建。
大型依赖可能拖慢配置和编译。
版本升级需要谨慎。

所以真实工程里,FetchContentvcpkgConan、系统包管理器都可能出现,取决于项目的历史和部署环境。

Bustub 里的 CMake 在做什么?

15445 Bustub 是一个比较典型的教学型现代 C++ 项目。

它使用 CMake 的原因大概是:

项目由多个模块组成。
需要构建库、测试、benchmark。
需要集成 gtest。
需要统一编译选项。
需要支持 clang-tidy、格式化、sanitizer 等工具。

平时执行:

mkdir build
cd build
cmake ..
make -j

从构建系统视角看,含义是:

创建 out-of-source build 目录。
CMake 读取根目录和子目录里的 CMakeLists.txt。
CMake 检查编译器、依赖和配置选项。
CMake 生成 Makefile。
make 根据依赖图编译库、测试和工具。

这里的 build/ 目录是构建产物目录,不应该和源码混在一起。

更现代、更不容易出错的写法是:

cmake -S . -B build
cmake --build build -j

这个写法不用手动 cd build,也更清楚地区分:

-S source directory
-B build directory

常见命令速查

配置项目:

cmake -S . -B build

指定 Ninja

cmake -S . -B build -G Ninja

指定构建类型:

cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug

生成 compile_commands.json

cmake -S . -B build -DCMAKE_EXPORT_COMPILE_COMMANDS=ON

编译:

cmake --build build -j

只构建某个目标:

cmake --build build --target demo -j

运行测试:

ctest --test-dir build --output-on-failure

清理构建目录:

rm -rf build

如果项目使用 Makefile

make
make -j
make clean
make target_name

如果项目使用 Ninja

ninja -C build
ninja -C build demo

学习路线

如果从 Bustubxv6 的基础继续往下学,可以按这个顺序:

1. 手写 g++ 命令,理解编译和链接。
2. 手写一个小 Makefile,理解 target、prerequisite、recipe。
3. 阅读 xv6 Makefile,理解 make target 如何串起编译、打包和运行。
4. 写一个最小 CMakeLists.txt,构建一个可执行文件。
5. 把项目拆成 library + executable,理解 target_link_libraries()。
6. 学会 PRIVATE / PUBLIC / INTERFACE。
7. 学会 Debug / Release、compile_commands.json、Ninja generator。
8. 用 gtest 写测试,理解 enable_testing() 和 ctest。
9. 学一种依赖管理方式,比如 FetchContent 或 vcpkg。
10. 阅读 Bustub 的 CMakeLists.txt,把命令和真实工程对应起来。

这个顺序的关键是:

不要一上来背 CMake 语法。
先理解编译、链接和依赖图。
再理解 CMake 如何把这些关系抽象成 target。

Make、CMake、Ninja 怎么区分?

最后把几种工具放在一起对比:

Make:
  直接执行构建规则。
  适合简单项目、教学项目、系统项目、自定义流程。

CMake:
  描述 C++ 工程结构。
  生成 Makefile、Ninja、Visual Studio 等构建文件。
  现代 C++ 项目的主流选择。

Ninja:
  快速执行构建。
  通常不手写,由 CMake 生成。

vcpkg / Conan:
  管理第三方 C++ 依赖。
  负责依赖下载、版本、编译和暴露给 CMake。

ctest:
  CMake 配套测试工具。
  负责发现和运行测试。

可以用一句话总结:

CMake 描述项目,Ninja / Make 执行构建,vcpkg / Conan 管理依赖,ctest 运行测试。

小结

C++ 项目来说,构建系统是工程能力的一部分。

只会写 C++ 代码,但看不懂构建系统,会在很多真实项目里卡住:

为什么头文件找不到?
为什么链接失败?
为什么 Debug 能跑 Release 崩?
为什么 clangd 一直报错?
为什么测试没有被编译?
为什么加了库但链接不上?

学习构建工具时,最重要的是把所有问题都放回这条链路:

源文件
  -> 编译选项
  -> 目标文件
  -> 链接选项
  -> 库
  -> 可执行文件
  -> 测试和安装

先掌握这条主线,再去看 MakefileCMakeLists.txtNinjavcpkg,很多命令就不会只是黑盒了。