第2章:构建系统入门:CMake 核心概念

写代码只是开始,把源代码变成能跑的程序才是第一关——这一章,我们把「构建」这件木工活里的家伙什儿认个遍

🔨

本章导师:鲁班

核心方法论:工欲善其事,必先利其器

「木匠做活,三分手艺七分家伙。上一章你见识了 C++ 的工具箱,可家伙什儿再多,不会使也白搭。这一章我带你磨第一把斧子——CMake。它不写一行代码,却管着你写的每一行代码怎么变成能跑的程序。磨好这把斧子,后面十几章的工程实践才砍得动木头。」

2.1 零基础铺垫:为什么 C++ 需要构建系统

先从一个最简单的场景说起。假设你只有一个源文件 hello.cpp,把它变成可执行文件,一条命令就够了:

// hello.cpp:人生第一个 C++ 程序
#include <iostream>
int main() {
  std::cout << "Hello, C++!" << std::endl;
  return 0;
}
# 一条命令搞定单文件编译
g++ hello.cpp -o hello
./hello

但如果项目变成几十个文件呢?比如你的程序拆成了 main.cppmath.cpputils.cpp,还引用了第三方库,直接敲 g++ 就不灵了——你要手动记住每一个文件的编译命令,还得按正确的顺序执行。手动编译多文件工程,大概长这样:

# 场景:main.cpp 依赖 math.cpp 和 utils.cpp
g++ -c main.cpp -o main.o
g++ -c math.cpp -o math.o
g++ -c utils.cpp -o utils.o
g++ main.o math.o utils.o -o app

这才三个源文件,命令就开始变得啰嗦。工程一旦上了规模,手动编译会撞上三堵墙。第一堵是依赖顺序:C++ 的链接器(linker)处理目标文件时,被依赖的库要放在依赖它的库后面,顺序错了就会报 undefined reference(未定义的引用)——说白了就是"链接器找不到你要的函数"。第二堵是重复编译:你只改了一个文件,却要把几十个文件全部重新编译一遍,浪费时间。第三堵是跨平台:Linux 上编译用 g++,Windows 上用的是 MSVC 的 cl,命令参数完全两套,同一个项目换个系统就得重写一遍编译命令。

# 链接时库的顺序有讲究:被依赖的库要放在后面
g++ main.o -lmath -lutils -o app    # 正确
g++ main.o -lutils -lmath -o app    # 如果 utils 依赖 math,这里就会 undefined reference

# 同一段代码,Windows 上是另一套命令
cl /EHsc main.cpp math.cpp utils.cpp /Fe:app.exe

于是构建系统(build system)就诞生了。说人话:构建系统就是自动帮你回答三个问题的程序——哪些文件要编译、按什么顺序、用什么参数,而且它还会记住上次编译的结果,你改了一个文件,它就只重编那一个。C++ 生态里的构建系统也有个演进谱系:1977 年问世的 make 是最古老也最普及的一位(至今仍是 Linux 构建的基石),后来出现了自动化程度更高的 autotools(GNU 工具链的官方配置方案),2000 年诞生的 CMake 则凭借跨平台能力成为现代 C++ 的事实标准,再往后还有追求更快的 ninja(2011 年)和更现代的 meson(2013 年)。本章的主角就是 CMake。

痛点手动 g++ 编译构建系统处理
依赖顺序自己记库的先后顺序,错了报 undefined reference按 CMakeLists.txt 声明自动排序
重复编译改一个文件,全部文件重编只重编有改动的文件(增量编译)
跨平台g++ / cl 两套命令,到处重写一份配置,各平台生成对应构建文件
第三方库手动拼 -I(头文件路径)和 -l(库名)用 find_package / target_link_libraries 声明式解决
鲁班提示

先分清两个词:编译(compile)是把单个源文件变成机器码目标文件(.o),干这活的是编译器;构建(build)是决定"编译哪些文件、按什么顺序、最后怎么拼成可执行文件"的整套流程,干这活的是构建系统。别把构建当编译,也别把编译器当构建系统——工具分工不同,磨斧子之前先认清楚哪把是斧子。

2.2 CMake 是什么:跨平台构建系统生成器

CMake 是开源的跨平台构建工具,它的官方定位是"build system generator"——构建系统生成器。这个定位很关键:CMake 自己并不编译代码,它只读一份描述工程结构的文件(CMakeLists.txt),然后为你生成一份"施工图":在 Linux 上生成 Makefile,在 Windows 上生成 Visual Studio 工程,在 macOS 上生成 Xcode 工程,也可以生成 ninja 的构建文件。说人话:CMake 是"构建系统的制造商",你给它一份图纸,它按图纸造出你当前平台能用的施工文件,真正的砌墙(编译)交给 make、ninja 这些"施工队"。

这套设计带来一个巨大的好处:一次编写,到处生成。工程换平台时,CMakeLists.txt 一行不用改,重新跑一次 CMake,就得到新平台对应的构建文件。这就是为什么现代 C++ 项目(包括 vcpkg、jsoncpp、spdlog 这些库)几乎清一色用 CMake 组织工程。先确认你机器上的 CMake 可用:

# 查看 CMake 版本(输出形如 cmake version 3.22.1)
cmake --version
# CMakeLists.txt —— 一份最简"图纸"(后面 2.3 节逐行讲解)
cmake_minimum_required(VERSION 3.10)
project(hello CXX)
add_executable(hello main.cpp)

CMake 的工作分两个阶段。配置阶段(configure):读取 CMakeLists.txt,检查编译器、头文件、库是否就绪,把结果写进构建目录;生成阶段(generate):根据配置结果生成构建文件。跑起来是这个样子(版本不同,输出细节略有差异):

# -S 指定源码目录,-B 指定构建目录(CMake 3.13+ 的新写法)
cmake -S . -B build
# 输出示例:
# -- The C compiler identification is GNU 11.4.0
# -- The CXX compiler identification is GNU 11.4.0
# -- Configuring done
# -- Generating done
# -- Build files have been written to: /path/to/project/build

CMake 支持的生成器(generator)不止一种,常用的大致如下——记住"生成器 = CMake 生成的施工图类型"就够了:

生成器生成的构建文件典型平台
Unix Makefiles(默认)MakefileLinux / macOS 命令行
Ninjabuild.ninja跨平台,构建速度最快
Visual Studio 17 2022.sln / .vcxprojWindows + MSVC
Xcode.xcodeprojmacOS + Xcode IDE

注意cmake -S . -B build 只完成了"配置 + 生成",并没有编译你的代码。要真正得到可执行文件,还得再执行 cmake --build build(或进入 build 目录跑 make)。新手最常见的困惑就是"我明明 cmake 了,怎么没有可执行文件"——因为 CMake 不是编译器,它只是把施工图画好了,砖还没砌。

2.3 CMakeLists.txt 基本结构:从配方到工程

CMakeLists.txt 是 CMake 的"配方"文件(名字固定,大小写敏感,不能写成别的),放在工程根目录。它由一条条命令(command)组成,语法形如 命令名(参数...)。核心命令只有几个,先把它们认全:cmake_minimum_required(声明最低 CMake 版本)、project(声明工程名和语言)、add_executable(声明要生成的可执行文件)、add_library(声明要生成的库)、target_link_libraries(声明目标之间的链接关系)、target_include_directories(声明头文件搜索目录)。

一个完整的最小工程长这样。注意顺序cmake_minimum_required 必须写在第一行(它是 CMake 的"版本准入"声明),project 紧随其后,之后才能创建目标:

# CMakeLists.txt —— 完整的最小工程
cmake_minimum_required(VERSION 3.10)   # 第一行:声明最低 CMake 版本
project(hello CXX)                  # 工程名 hello,语言 C++
add_executable(hello main.cpp)        # 用 main.cpp 编译出可执行文件 hello
# 工程目录结构:源码 + 一份 CMakeLists.txt 就够了
hello/
  CMakeLists.txt
  main.cpp

# 配置 + 构建 + 运行(三连)
cmake -S . -B build
cmake --build build
./build/hello

工程变大后,通常会把代码按模块拆成(library),再链接进主程序。add_library 的第二个参数指定库的类型:STATIC(静态库,编译期就拼进可执行文件)、SHARED(动态库,运行时加载)、INTERFACE(纯头文件库,后面 2.5 节细说)。链接关系用 target_link_libraries 声明,头文件搜索目录用 target_include_directories 声明:

# 模块化:把 math 和 utils 编成静态库,再链接进主程序
add_library(mathlib STATIC math.cpp)                    # 生成 libmathlib.a
add_library(utils STATIC utils.cpp)                     # 生成 libutils.a
add_executable(app main.cpp)
target_include_directories(mathlib PUBLIC include)      # 头文件在 include/ 目录
target_link_libraries(app PRIVATE mathlib utils)       # app 链接两个库

再配合两个常用的"全局设置"命令:set 设置变量(比如指定 C++ 标准),message 在配置时打印信息方便调试。注意 CMAKE_CXX_STANDARD 这类 CMAKE_* 开头的名字是 CMake 预定义的缓存变量(cache variable),它们的值会被记住,后面 2.6 节还会用到:

# 指定 C++ 标准 + 配置期打印一行信息
set(CMAKE_CXX_STANDARD 17)               # 用 C++17
set(CMAKE_CXX_STANDARD_REQUIRED ON)    # 编译器不支持 C++17 就直接报错
message(STATUS "配置完成,构建目标: app")
鲁班提示

CMake 命令名不区分大小写(ADD_EXECUTABLEadd_executable 等价),但社区惯例是全小写,跟着惯例走。反过来,变量名是大小写敏感的${CMAKE_CXX_STANDARD}${cmake_cxx_standard} 是两个完全不同的变量。还有一处细节:命令名和左括号之间不能有空格(add_executable (app main.cpp) 是错的,官方文档明确不推荐),参数之间则随意。

2.4 out-of-source 构建:别把工地建在图纸上

CMake 配置时会在目标目录里生成一大堆中间文件:CMakeCache.txt(缓存的配置变量)、CMakeFiles/(各种中间产物)、Makefile 等。如果你直接在源码目录里跑 cmake .,这些文件就会和你的源代码混在一起——这就是 in-source 构建(源码内构建),新手很容易踩。它的坏处有三个:源码目录被弄得乱七八糟;CMakeCache.txt 里残留的旧配置会影响下次配置;一个源码目录同时只能有一种构建配置(不能既配 Release 又配 Debug)。

正确的姿势是 out-of-source 构建(源码外构建):单独建一个 build/ 目录,所有构建产物都放进去,源码目录保持干净。经典的三步流程是 mkdircdcmakemake,很多老教程都是这么教的:

# 经典流程:建目录 → 进入 → 配置 → 构建
mkdir build && cd build
cmake ..
make
# 新式流程(CMake 3.13+):-S 指定源码目录,-B 指定构建目录,不用 cd
cmake -S . -B build
cmake --build build

两种写法效果一样,推荐用新式——-S/-B 把"源码在哪、构建在哪"写在了命令里,一目了然。构建目录里那个 CMakeCache.txt 值得认识一下:它是本次配置的"记忆",记录了所有缓存变量的值。下次改完 CMakeLists.txt 重新跑 cmake -S . -B build,CMake 会复用缓存、只更新变化的部分。如果改了什么关键配置导致缓存混乱(比如换了编译器),最干脆的解决办法是把 build 目录整个删掉重新配置——反正它里面全是可再生中间产物:

# 查看缓存变量(-L 只列普通缓存变量,-LH 额外显示帮助文本)
cmake -LH build

# 彻底重新配置:删掉 build 重来,缓存不残留
rm -rf build && cmake -S . -B build

注意build/ 目录属于构建产物,不要提交进 git。它动辄几百 MB、每次构建都在变,提交它只会让仓库爆炸、冲突不断。在工程根目录的 .gitignore 里写一行 build/ 就能把它挡在版本库之外——这也是"源码目录保持干净"的延伸。

2.5 CMake 核心概念:target 为中心、属性、生成器表达式

target(目标)是 CMake 最重要的概念。说人话:target 就是"你要构建出来的东西"——一个可执行文件(add_executable 创建的)或一个库(add_library 创建的)都是一个 target。CMake 3.0 之后,现代 CMake 的一切组织方式都以 target 为中心:依赖关系写在 target 上,而不是全局变量里。链接时的三个关键字 PRIVATEPUBLICINTERFACE 就是用来描述"这条依赖的可见范围"的:

# 三种可见性,一句话版:
# PRIVATE   —— 只有我自己用(下游不知道、也不需要)
# PUBLIC    —— 我自己用,链接我的下游也会继承
# INTERFACE —— 我自己不用,只负责传给下游(典型:纯头文件库)
target_link_libraries(app PRIVATE mathlib)      # mathlib 只是 app 内部实现细节
target_link_libraries(gui PUBLIC app)         # 谁链接 gui,谁也能看到 app
关键字对当前 target 生效?传递给下游?典型用途
PRIVATE库内部实现依赖
PUBLIC头文件目录、对外 API 依赖
INTERFACE纯头文件库(如只含 .hpp 的库)

第二个概念是属性(property)。每个 target 都带着一份"档案",记录它的编译标准、输出文件名等配置;读写这份档案用 set_target_properties / get_target_property。常用属性有 CXX_STANDARD(C++ 标准)、OUTPUT_NAME(生成的文件名)、POSITION_INDEPENDENT_CODE(是否生成位置无关代码,动态库必须为 ON):

# 给 target 设置属性:C++20 + 输出文件改名成 myapp
set_target_properties(app PROPERTIES
  CXX_STANDARD 20
  OUTPUT_NAME "myapp"
)

# 读取属性(调试用)
get_target_property(out app OUTPUT_NAME)
message(STATUS "输出文件是 ${out}")

第三个概念是生成器表达式(generator expression)。说人话:它是一段 $<...> 包裹的"占位符",在生成阶段(而不是配置阶段)才被求值,因此能根据最终的构建配置决定具体内容。比如 $<TARGET_FILE:mathlib> 会展开成库文件的完整路径,$<CONFIG:Debug> 在当前是 Debug 配置时求值为 1(否则为空)。它在 target 的链接参数、编译选项里特别常用:

# 生成时展开为库文件完整路径
message(STATUS "mathlib 位于: $<TARGET_FILE:mathlib>")

# 条件编译:只有 Debug 配置才定义 DEBUG_MODE 宏
target_compile_definitions(app PRIVATE
  $<$<CONFIG:Debug>:DEBUG_MODE>
)
鲁班提示

初学阶段,生成器表达式 $<...> 很容易和变量引用 ${...} 搞混,记住一句口诀:双美元看时间——${VAR} 在配置阶段就替换成变量值,$<EXPR> 拖到生成阶段才按最终配置算。什么时候必须用后者?当内容依赖"最终是 Debug 还是 Release、生成的文件叫什么名字"这类只有生成时才知道的信息时。

2.6 常用命令速查:cmake 命令行手册

cmake 的命令行参数不多,掌握这一节就够日常使用了。第一类是配置命令-S 指定源码目录、-B 指定构建目录、-D 设置缓存变量(可以写多个)。注意 -D 的本质是"往 CMakeCache.txt 里写一个缓存变量",所以它必须在配置阶段传,之后想改就重新配置。最常用的两个配置项:CMAKE_BUILD_TYPE(构建类型,Debug/Release/RelWithDebInfo)和 CMAKE_INSTALL_PREFIX(安装路径,默认 /usr/local):

# 配置:源码 . → 构建 build,并设置构建类型为 Release
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release

# 多个 -D 可以连写:Release + 自定义安装路径
cmake -S . -B build \
      -DCMAKE_BUILD_TYPE=Release \
      -DCMAKE_INSTALL_PREFIX=/home/helios/opt/myapp

第二类是构建与安装命令--build 执行构建(等价于进入 build 目录跑 make,但不用 cd)、-j 指定并行任务数、--target 只构建某个目标、--install 把构建产物安装到指定前缀(CMake 3.15+):

# 构建:4 个任务并行
cmake --build build -j4

# 只构建 mathlib 这一个目标(跳过 app)
cmake --build build --target mathlib

# 安装到 2.6 上面配置的前缀目录
cmake --install build

剩下的命令按需查阅即可,汇总成一张速查表,建议贴在手边:

命令作用
cmake --version查看 CMake 版本
cmake -S 源码 -B 构建配置 + 生成(3.13+ 推荐写法)
cmake -D<变量>=<值>设置缓存变量,如 -DCMAKE_BUILD_TYPE=Debug
cmake --build 构建目录执行构建(可加 -jN 并行、--target 名字 指定目标)
cmake --install 构建目录安装到前缀目录(3.15+)
cmake -LH 构建目录列出缓存变量(带帮助)
cmake --trace逐行打印 CMakeLists.txt 执行过程,排查配置问题
cmake --help查看帮助;cmake --help-command add_executable 查具体命令
鲁班提示

查 CMake 文档不用每次都开浏览器:cmake --help-command add_executable 能在终端直接看到该命令的完整官方说明和参数列表(输出的其实就是 cmake.org 文档的本地副本)。离线也能查,这个习惯在没网的环境里特别救命。

2.7 与 Makefile 的关系:生成器在背后做了什么

Linux 上 CMake 的默认生成器是 "Unix Makefiles",也就是说 cmake -S . -B build 之后,build 目录里躺着的就是一份 Makefile。你执行 cmake --build build 时,CMake 其实是在背后帮你调用了 makemake 读 Makefile,按里面的规则逐条执行编译命令。想看看 CMake 到底生成了什么、make 到底执行了什么命令,两个手段:一是直接 ls build,二是构建时加 VERBOSE=1

# 配置后 build 目录长这样(Makefile 就是 CMake 生成的施工图)
ls build
# CMakeCache.txt  CMakeFiles/  Makefile  cmake_install.cmake

# 让 make 把执行的命令原样打印出来(VERBOSE=1)
make -C build VERBOSE=1
# 输出示例(能看到真实的 g++ 调用):
# /usr/bin/c++ -MD -MT CMakeFiles/app.dir/main.cpp.o \
#   -MF CMakeFiles/app.dir/main.cpp.o.d -o CMakeFiles/app.dir/main.cpp.o -c main.cpp

make 之所以能做到"只重编改过的文件",靠的是时间戳比较:Makefile 里记录了每个目标文件依赖哪些源文件(上面输出里的 -MD -MF 就是让编译器生成这种依赖清单 .d 文件),构建时 make 比较源文件和目标文件的修改时间,源文件更新才重新编译。这套增量编译机制由 CMake 生成的 Makefile 自动维护,你完全不用操心。想看看当前工程有哪些可用的 make 目标,直接问 make:

# 列出 Makefile 里的全部目标
make -C build help
# 输出示例(节选):
# ... app
# ... mathlib
# The following are some of the valid targets for this Makefile:
# ... all (the default if no target is provided)
# ... clean
# ... rebuild_cache

那么问题来了:既然最后跑的还是 make,为什么不直接手写 Makefile?答案是跨平台与可维护性。Makefile 本身是一门方言——GNU make 的语法和 BSD make 不完全一样,Windows 上还没有 make 的对应物;而且手写 Makefile 要自己处理依赖扫描、编译器探测、跨平台差异这些脏活。CMake 的价值在于:你写一份声明式的 CMakeLists.txt,它替你生成各平台各自"正宗"的构建文件。什么时候值得手写 Makefile?只有一种情况:工程极小(一两个文件)、且确定只在 Linux 上用 make 构建——但即便如此,现在的主流选择依然是"小工程也上 CMake",因为后续加库、加测试、加安装时不用推倒重来。追求更快的构建时,还可以给 CMake 指定 ninja 生成器(cmake -G Ninja -S . -B build,配合 cmake --build build),构建文件从 Makefile 换成 build.ninja,用法完全一样。

至此你已经有了一套完整的"构建"技能:认识构建系统、写 CMakeLists.txt、out-of-source 构建、理解 target 与生成器表达式、查命令行、知道 make 在背后干什么。下一章我们就要动手了——从源码编译安装编译器 GCC 本身,看看工具链最底层的那块砖是怎么砌起来的。到时候你会发现,手里这把 CMake 的斧子,正好用来验证安装好的 GCC 能不能正常干活。

章末练习

练习 1:概念四连 入门

用一句话分别解释:构建系统CMaketargetout-of-source 构建

提示

四个概念对应"调度员 / 图纸制造商 / 产品 / 工地选址"四个角色。

参考答案

构建系统:自动决定"哪些文件要编译、按什么顺序、用什么参数"并支持增量编译的程序(如 make);CMake:跨平台的构建系统生成器,读 CMakeLists.txt 生成各平台的构建文件,自己不做编译;target:CMake 里"要构建出来的东西",可执行文件或库都是一个 target,现代 CMake 以 target 为中心组织依赖;out-of-source 构建:把构建产物放在单独的 build/ 目录(而非源码目录),保持源码干净、支持多种配置并存。

练习 2:补全 CMakeLists.txt 进阶

工程结构如下:main.cpp 调用了 math.h 里的函数,math.hinclude/ 目录,实现文件是 math.cpp。要求:可执行文件叫 app,math 部分编成静态库 mathlib 再链接。请写出完整的 CMakeLists.txt(提示:需要 5 个命令)。

提示

用到的命令:cmake_minimum_required / project / add_library / add_executable / target_include_directories / target_link_libraries——数一数,其实六个,顺序别乱。

参考答案
cmake_minimum_required(VERSION 3.10)
project(demo CXX)

add_library(mathlib STATIC math.cpp)
add_executable(app main.cpp)

target_include_directories(mathlib PUBLIC include)   # main.cpp 要 #include <math.h>
target_link_libraries(app PRIVATE mathlib)

要点:target_include_directoriesPUBLIC——因为 math.h 不仅 mathlib 内部用,链接它的 app 也要能找到;若用 PRIVATE,app 编译时会报找不到头文件。

练习 3:链接顺序与可见性 进阶

(a) 为什么手动 g++ main.o -lutils -lmath 在 utils 依赖 math 时会报 undefined reference,而 CMake 里 target_link_libraries(app PRIVATE utils math) 就不会?(b) PRIVATEPUBLICINTERFACE 三者对"下游 target"的可见性分别是什么?

提示

(a) 想想链接器从左到右处理库的顺序;(b) 回想 2.5 节的表格——"当前 target 用不用"和"下游知不知道"是两个维度。

参考答案

(a) 链接器从左到右扫描库,被依赖的库必须出现在依赖它的库之后,否则扫描到 utils 时 math 还没被加载,符号找不到就报 undefined reference。CMake 会自动按依赖关系重排库的链接顺序,所以声明式写法不会踩这个坑——这正是构建系统的价值之一。(b) PRIVATE:当前 target 内部可见,下游完全不知道;PUBLIC:当前 target 内部可见,下游也继承;INTERFACE:当前 target 内部不可见(它自己不链接),只把依赖传给下游,典型场景是纯头文件库。

练习 4:动手搭一个工程 挑战

实操:按练习 2 的结构建一个真实工程(main.cpp 里随便调用一个 math 函数),完成以下步骤并记录输出——① cmake -S . -B build 配置;② cmake --build build -j4 构建并运行;③ 修改 main.cpp 加一行代码,再构建,观察 make 是否只重编了 main.cpp 这一个文件;④ 用 make -C build VERBOSE=1 看一次真实的 g++ 编译命令;⑤ 重新用 -DCMAKE_BUILD_TYPE=Release 配置一次,对比 build 目录里可执行文件大小的变化。

提示

①②③④的观察点分别在 2.3、2.4、2.7 节;第⑤步注意——改了 -D 后要重新跑 cmake -S . -B build 让新配置生效(为什么?想想 -D 写进哪里)。

参考答案

① 配置输出以 -- Configuring done / -- Generating done / -- Build files have been written to 结尾;② 可执行文件在 build/app;③ 增量编译生效,输出里只有 main.cpp.o 被重建(math.cpp.o 没有动静);④ VERBOSE=1 能看到形如 /usr/bin/c++ -MD -MT ... -c main.cpp 的真实命令;⑤ Release 不携带调试符号、带优化,可执行文件通常显著变小——但注意:-DCMAKE_BUILD_TYPE 写入的是 CMakeCache.txt,所以必须重新跑一次配置(cmake -S . -B build -DCMAKE_BUILD_TYPE=Release)再 cmake --build build,直接 --build 不会生效。