构建系统:Make 与 CMake
第 12.5 章给了一个能跑的 Makefile,但真实项目里手写 Makefile 会迅速变成一场灾难:文件一多,依赖关系靠人维护,漏一个头文件就编出奇怪的错误。C 语言工业界的标准答案叫 CMake——你只描述"有哪些 target、彼此怎么依赖",由它去生成 Makefile(或 Ninja、Xcode 工程)。这一章把 Make 讲到够用,把 CMake 讲到能用。
为什么单靠 gcc 不行:构建系统解决的三个问题
是什么:构建系统是"把源码变成可执行文件/库"的自动化工具。它的核心价值不是"少敲几个字",而是三件事:① 维护依赖图(谁依赖谁)、② 增量构建(只重编变了的东西)、③ 可复现(换台机器、换个人,编出来的东西一样)。
为什么必须用:项目只要超过 5 个源文件,"手敲 gcc"就会出现三种典型灾难:改了头文件忘了重编某个 .c(于是程序行为诡异,怎么也调不出来)、链接顺序写错导致 undefined reference、不同人机器上编译参数不一样导致"我这儿是好的"。
怎么用:记住一条判断标准——凡是"你每次都要重复输入的编译命令",都应该写进构建文件里。项目规模决定用哪个工具:
| Make(GNU Make 4.4+) | CMake(3.28+ / 4.x) | |
|---|---|---|
| 它是什么 | 构建执行器:读 Makefile,按依赖图执行命令。 | 构建生成器:读 CMakeLists.txt,生成 Makefile / Ninja / VS 工程。 |
| 写什么 | 直接写"目标文件 ← 源文件,用哪条命令造"。 | 只声明"有哪些 target、依赖谁、对外暴露什么"。 |
| 跨平台 | 基本只能 Linux/macOS(Windows 上很别扭)。 | 同一份 CMakeLists 在 Linux/macOS/Windows 都能生成原生工程。 |
| 依赖管理 | 要自己写头文件依赖(或用 -MMD 自动生成)。 | find_package/FetchContent 一行接一个库。 |
| 适合 | 单文件小工具、内核/嵌入式这类需要极致掌控的场景、以及"就是想搞懂底层"的学习者。 | 所有正经的 C/C++ 项目——包括你想在简历上写的每一个。 |
流一条命令背后的完整流水线
CMake 阶段(配置 / configure):读 CMakeLists.txt → 探测编译器、系统库、依赖 → 在 build/ 目录里生成构建文件(Makefile 或 build.ninja)+ CMakeCache.txt。
构建阶段(build):Make/Ninja 按依赖图执行真正的命令链:.c → 预处理 → 编译 → .o → 链接 → 可执行文件 / 库。
为什么要分两步:因为"配置"很慢(要探测环境),而"构建"很频繁。CMake 把慢的部分缓存下来,让你在改代码后只跑快的部分——这也是 build/ 目录必须保留、不能随手删的原因(当然,遇到怪问题,"删掉 build 重来"永远是有效的万能药)。
规则下面的命令行,必须以一个真正的 Tab 开头,不能用空格。这是 Make 从 1976 年保留至今的设计。报错长得像 Makefile:5: *** missing separator. Stop.。编辑器常见的坑:tab 被自动展开成 4 个空格。处理方法:cat -A Makefile | head 看命令行开头是 ^I(Tab)还是空格;VS Code 里给 Makefile 关掉 "Insert Spaces"。
Make 进阶:把 Makefile 写成"能维护的样子"
是什么:Makefile 由三类东西组成:变量(CC = gcc)、规则(目标: 依赖 + 命令行)、伪目标(.PHONY,如 clean、run)。
为什么值得了解细节:因为 CMake 生成的 Makefile 你能读懂、出问题时能看懂它到底在跑什么命令;而且很多开源库(Linux 内核、Redis、Nginx)全是用 Makefile 手写的,"能看懂 Makefile"是读源码的门票。
一个"够专业"的 Makefile:自动变量 + 模式规则 + 自动依赖 + 并行
| 变量 | 含义 |
|---|---|
$@ | 当前规则的目标名(冒号左边那个)。 |
$< | 第一个依赖(模式规则里就是对应的 .c 文件)。 |
$^ | 所有依赖,去重(链接时最常用:$(CC) -o $@ $^)。 |
$? | 所有比目标更新的依赖(增量场景用)。 |
怎么用:编译时加 -j 并行编译;调试"到底执行了哪条命令"用 -n(dry run)或 -B(强制重建)。
make -j16 时,多个编译任务同时输出,屏幕上的报错会互相穿插,你甚至看不清是哪个文件错了。做法:把输出重定向到文件(make -j 2>&1 | tee build.log),然后 grep -n -A3 'error:' 去找;或者干脆先 make -j1 跑一遍定位问题,再开并行。另外,Makefile 写得不严谨时,-j 会暴露隐藏的依赖缺失(两个目标本该有先后顺序却没人声明),表现为"串行没事、并行随机失败"。这也是 CMake 相比手写 Makefile 的一个隐性优势:它生成的依赖图是完整的。
CMake 入门:三句话讲清它和 Make 的关系
是什么:CMake 是"构建系统生成器"。你写 CMakeLists.txt,它根据当前平台生成对应的构建文件。它不是编译器,也不是构建执行器——它站在它们上面。
为什么现在都用它:因为 C 的依赖管理一直是地狱:不同系统库放在不同路径、同一个库有十几个版本、Windows 上根本没有 pkg-config。CMake 用 find_package 把这些差异收进"一个名字"里,让 target_link_libraries(app PRIVATE Threads::Threads) 在三个平台上都成立。
怎么用:永远使用 out-of-source build(在 build/ 子目录里构建,源码目录保持干净)。两步命令走天下:
这会把 CMakeCache.txt、CMakeFiles/、一堆 Makefile 直接撒进你的源码目录,把仓库弄得一团糟(git status 一片红)。永远用 cmake -S . -B build。如果不小心已经污染了,把那几个生成物删掉即可;更常见的后续问题是——改了 CMakeLists 里的关键设置(比如编译器路径、BUILD_TYPE)却没生效,这是因为 CMakeCache.txt 把旧值缓存了。解法:删掉整个 build/ 重新配置(rm -rf build,最省事),或者 cmake -U<变量名> -S . -B build 单独清某个缓存项。
CMakeLists.txt 实战:从零写一个多文件项目
是什么:CMakeLists.txt 里 90% 的内容就是三种命令:add_executable/add_library(造 target)、target_*(给 target 加属性)、find_package(找依赖)。
为什么这么设计:现代 CMake 的核心思想是"一切围绕 target"——不再有全局的 include_directories()、link_libraries() 那种"影响全项目"的命令,而是把每一条编译/链接需求挂在具体的 target 上。好处是:依赖关系显式、不会互相污染、可以放心地组合和复用。
项目结构(三文件小项目 + 一个静态库)
根目录的 CMakeLists.txt(逐行注释)
CMakePresets.json:把上面那串命令行固化成一句 cmake --preset dev
不设 CMAKE_BUILD_TYPE 时,CMake 会用一个"既不带优化、也不带调试信息"的空配置——你的程序会比别人慢好几倍,还查不出原因。两个 Build Type 的实际差别:Release = -O3 -DNDEBUG(NDEBUG 会让 assert() 全部失效!所以永远不要用 assert 检查生产环境的输入);Debug = -g 无优化(gdb 里变量能正常显示,但程序慢)。日常开发用 Debug,压测/发布用 Release,线上怀疑"优化导致行为不同"时用 RelWithDebInfo(开优化 + 带调试信息)——这也是排查内存问题的推荐配置。
target / link / PRIVATE / PUBLIC / INTERFACE:现代 CMake 的核心概念
是什么:CMake 里 add_executable / add_library 造出来的东西叫 target(目标)。每个 target 上都可以挂属性:头文件路径、编译选项、链接的库、需要的 C 标准。而 PRIVATE / PUBLIC / INTERFACE 决定这些属性"会不会传给使用它的人"。
为什么这是最重要的一节:因为它就是 CMake 的"作用域规则",等价于 C 语言里 static 和头文件的关系。写错了不会报错,只会让依赖关系悄悄泄漏——项目小的时候没事,模块一多就出现"编译能过但链接莫名失败"或"某个模块偷偷依赖了别人的私有头文件"。
| 关键字 | 这句话表示 | 典型场景 |
|---|---|---|
| PRIVATE | 这个属性只给我自己用,不传给别人。 | 自己内部的 -Wall;只在 .c 里用、不暴露到头文件的依赖。 |
| PUBLIC | 我自己要用,用我的人也要用。 | 我的公开头文件 greet.h 里 #include 了某个库的头 —— 那么包含我头文件的人也需要这个库的路径。 |
| INTERFACE | 我自己不用,但用我的人必须用。 | 头文件专用库(header-only)、纯接口库(add_library(mylib INTERFACE))。 |
一个能说明问题的例子:include 路径该用哪个关键字
链链接这件事,到底发生了什么
编译是把每个 .c 单独翻成 .o,此时所有"外部符号"(比如 printf、greet_hello)都还只是一张待办清单(未解析符号)。
链接是把所有 .o 和库按顺序过一遍,逐条销账:这个符号谁提供?静态库 .a 是"按需抽取",链接器只从 .a 里抽出真正用到的那几个 .o——这就是为什么 .a 的顺序有讲究:被依赖的库要放在依赖它的库后面(gcc -o app main.o -lfoo -lbar,其中 foo 用到 bar)。顺序反了就会 undefined reference。
动态库 .so 又多一层:链接时只记下"我需要 libgreet.so",运行时才由动态加载器去找它。所以会出现"编译通过、运行时 error while loading shared libraries: libgreet.so: cannot open shared object file"——这是运行时找不到库,和编译无关。
"运行时找不到 .so"的三种解法(按推荐度排序)
同一个报错,可能是完全不同的问题:① 函数真的没写(拼写大小写、C/C++ 混编忘了 extern "C");② 忘了链接库(少写 target_link_libraries);③ 库顺序错了(静态库 .a 被依赖的放前面了,调换顺序即可);④ 链接的是动态库但符号被隐藏(编译加了 -fvisibility=hidden 又没标 __attribute__((visibility("default"))));⑤ inline/static 函数写在头文件里但语义用错。排查手段:nm -C libfoo.a | grep 符号名 看库里到底有没有这个符号(大写 T 是已定义,U 是未定义引用),nm 的结果比猜有用一百倍。
第三条路:依赖管理与工具选择
是什么:到这一步你还差最后一块拼图——"要用第三方库怎么办"。CMake 提供了三种粒度:
| 方式 | 说明 |
|---|---|
find_package(X REQUIRED) | 最优先:找系统/包管理器已经装好的库(apt install libfoo-dev 之后)。找到的是"导入目标",用法干净。 |
pkg_check_modules | 给那些只提供 .pc 文件的老库(pkg-config 生态)兜底。需要 find_package(PkgConfig REQUIRED)。 |
FetchContent | 自己拉源码编进项目:FetchContent_Declare(foo GIT_REPOSITORY ... GIT_TAG v1.2.3)。适合小依赖,必须写死 tag/commit,别用分支名,否则构建不可复现。 |
| vcpkg / Conan | 跨平台的 C/C++ 包管理器,负责把依赖装好并告诉 CMake 路径。团队规模一大就该上,个人小项目可以先不上。 |
怎么用:一个最小的 FetchContent 例子(注意版本写死)。
最后给一张选型表:别再纠结"到底该学哪个"
| 你的情况 | 就用这个 |
|---|---|
| 写一个 3 个文件的课程作业、想搞懂编译流程 | 手写 Makefile。你会真的理解"依赖 + 增量"。写完再换 CMake,感受会非常明显。 |
| 正经的 C/C++ 项目(哪怕只有 10 个文件) | CMake + Ninja。cmake -S . -B build -G Ninja,Ninja 比 Make 快且输出更清爽。 |
| 要跨平台(Linux/macOS/Windows) | CMake,没有第二选择。配合 CMakePresets.json 把三套配置固化下来。 |
| 读 Linux 内核 / Redis / Nginx 源码 | 它们用手写 Makefile。会读 Makefile 是硬门槛,但不需要你自己去写那么复杂的。 |
| 想快速试一小段 C 代码 | 什么都不用,直接 gcc -Wall -Wextra a.c -o a,或者 Compiler Explorer 在线看汇编。 |
① Make 是执行器、CMake 是生成器;Make 的三个必备要素是变量、模式规则、-MMD -MP 自动依赖;命令行必须 Tab 开头。
② CMake 的心智模型是 target:add_library/add_executable 造 target,target_* 给它加属性,PRIVATE/PUBLIC/INTERFACE 控制"传不传给别人"。别再用全局的 include_directories / link_libraries。
③ 记住两条实战经验:永远 cmake -S . -B build(别污染源码目录);遇到怪问题先 rm -rf build 重配,再 ldd 查运行时库、nm 查符号。