Make与CMake构建
# 16.Make与CMake构建
Makefile 目标/依赖/命令三要素、自动变量
$@/$</$^、隐式规则与模式规则、伪目标.PHONY、递归 Make 的变量传递、CMake 最小CMakeLists.txt、add_library/target_link_libraries、find_package查找外部依赖、install规则、构建类型 Debug/Release 切换、交叉编译工具链指定
# 目录
# 1. 案例引入
# 1.1 改代码不生效
某即时通讯服务后端有一行关键的配置常量,某天凌晨运维紧急修改了这个值,重新编译部署后——
// config.h —— 全局配置头文件
#define MAX_CONNECTIONS 10000 // ← 从 5000 改成 10000
2
$ make
gcc -c server.c -o server.o # server.c #include "config.h"
gcc -c network.c -o network.o
gcc server.o network.o -o server
2
3
4
部署上线后,压测发现连接数还是在 5000 左右崩溃。看了 config.h——值确实是 10000。看了二进制——strings server | grep 10000 什么都没找到。
$ strings server | grep -E '10000|5000'
5000 # 💀 还是 5000!
2
直觉怀疑:是不是 make 没重新编译?重新跑一下:
$ make clean && make
$ strings server | grep 10000
10000 # 这次对了
2
3
问题:为什么 make(不加 clean)没有检测到 config.h 的变化?
# 1.2 顺藤摸到根因
追查:
- 假设 1:是不是 make 的依赖写错了?—— 打开 Makefile:
server: server.o network.o
gcc server.o network.o -o server
server.o: server.c # ← 只有 server.c!没有 config.h!
gcc -c server.c -o server.o
network.o: network.c
gcc -c network.c -o network.o
2
3
4
5
6
7
8
凶手找到了:server.o 的依赖列表里没有 config.h。make 检查时间戳——server.c 没变过 → 认为 server.o 是最新的 → 跳过重新编译 → 旧的 .o 里还是 #define MAX_CONNECTIONS 5000 展开后的值。
假设 2:为什么只有
server.c受影响?—— 因为network.c没有#include "config.h",所以它确实不需要重编。而server.c有 include,但 Makefile 不知道这层依赖。假设 3:那 GCC 能不能自动告诉 make 它依赖了哪些头文件?—— 能!
gcc -MM可以生成依赖关系:
$ gcc -MM server.c
server.o: server.c config.h # ← GCC 帮你算出了完整依赖!
2
但需要手动集成进 Makefile——很多人不做这一步。
这个 bug 是构建系统的幽灵——它不崩不报错,只是"改了代码但没重新编译"。依赖声明不完整、时间戳检查的粒度问题、并行构建的竞态——这些都是在手动 gcc 到 Make 到 CMake 的进化路上,构建系统逐步解决的问题。
这个事故里藏着至少 8 个原理点:
① Makefile 的目标/依赖/命令三要素怎么工作? → 第 3.1 节
② make 怎么判断一个 target 需不需要重新编译? → 第 3.2 节
③ $@ $< $^ 这些自动变量代表什么? → 第 4.1 节
④ 模式规则 % 是怎么做到"通配"的? → 第 4.2 节
⑤ .PHONY 是什么?为什么 clean 要声明为 .PHONY? → 第 3.4 节
⑥ CMake 怎么自动解决头文件依赖问题? → 第 5 章
⑦ find_package 怎么找到外部库? → 第 6.2 节
⑧ 交叉编译时 CMake 工具链文件怎么写? → 第 7.2 节
2
3
4
5
6
7
8
# 1.3 我们要回答什么
这个"改了头文件但没重编"的事故是每个 C/C++ 程序员都会遇到的血泪教训。它折射出构建系统的核心使命:正确地表达文件之间的依赖,只重新编译真正需要重编的部分。
本篇路线:
架构总图 (第 2 章)
↓
Makefile 三要素与时间戳 (第 3 章) ─→ 解开"make 怎么知道要不要重编"
↓
自动变量与模式规则 (第 4 章) ─→ 解开"怎么写通用的编译规则"
↓
CMake 最小工程 (第 5 章) ─→ 解开"CMakeLists.txt 到底做了什么"
↓
库与依赖管理 (第 6 章) ─→ 解开"怎么引入第三方库"
↓
构建类型与交叉编译 (第 7 章) ─→ 解开"Debug/Release/ARM 怎么切"
↓
综合案例 (第 8 章) ─→ 彻底剖开 + 速查卡
2
3
4
5
6
7
8
9
10
11
12
13
📌 本篇定位:第 11-15 章讲了"代码怎么变成二进制",本章讲"怎么把这一过程自动化、可重复化"。构建系统是工程的骨架——没有它,每改一行代码就要手动拼一堆 gcc 命令。Make 是元老(1976年),CMake 是现代标准(2000年,但 3.x 后才是真正的"现代 CMake")。
# 2. 架构概览
# 2.1 构建系统的进化树
1970s: 手动 gcc/g++ 一行行敲
│
▼
1976: Make —— 以"文件时间戳"为驱动的依赖管理器
│ 问题: 平台相关语法,不可移植
│
▼
1980s: autotools (autoconf + automake) —— 生成跨平台 Makefile
│ 问题: 配置脚本极慢,难以调试
│
▼
2000: CMake —— "meta-build" 系统: 生成 Makefile / Ninja / VS Project
│ 优势: 跨平台、语法现代、生态丰富
│
▼
2010s+: Meson, Bazel, Buck, xmake...
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Make 没有死——Linux 内核、glibc、GCC 本身都在用 Make。但新项目几乎清一色 CMake。
# 2.2 Make vs CMake 的职责边界
┌─────────────────────────────────────────────────────────────────┐
│ CMake (meta-build) │
│ │
│ 输入: CMakeLists.txt (描述"想构建什么") │
│ 输出: Makefile / Ninja / VS Solution / Xcode Project │
│ │
│ 做了: 检测平台 (OS/编译器/库是否存在) │
│ 管理头文件依赖 (.d 文件自动生成) │
│ 生成干净的 out-of-source 构建目录 │
│ 跨平台 (Linux/macOS/Windows/Android/iOS) │
└──────────────────────────────┬──────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Make (build executor) │
│ │
│ 输入: Makefile (描述"文件之间怎么依赖,怎么编译") │
│ 输出: 编译产物 (.o, .so, executable) │
│ │
│ 做了: 检查时间戳 → 只重编变化的部分 │
│ 并行执行 (-j8) │
│ 变量展开、函数调用 │
└─────────────────────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
关键:CMake 不直接编译代码——它生成 Makefile(或 Ninja 等),然后 Make 拿这个 Makefile 去调 gcc。CMake 是"构建系统的构建系统"(meta-build system)。
# 3. Makefile 三要素
# 3.1 目标、依赖、命令
Makefile 的最小单元是一条规则:
# 格式: 目标: 依赖1 依赖2 ...
# <TAB> 命令1
# <TAB> 命令2
server.o: server.c server.h
gcc -c server.c -o server.o
2
3
4
5
6
三要素:
- 目标 (target):要生成的文件(
server.o) - 依赖 (prerequisites):目标依赖的文件列表(
server.c,server.h) - 命令 (recipe):生成目标要执行的 shell 命令(必须以 TAB 开头)
# 注释 vs 命令:
# 这是注释
all:
# 这不是注释!这是传给 shell 的命令!
@echo "hello" # @ 符号抑制命令回显
2
3
4
第一个 target 是默认 target:
# 当你只敲 make 不带参数时,执行第一个 target
# 约定: 第一个 target 叫 all,依赖所有要构建的产品
all: server client
server: server.o network.o
gcc server.o network.o -o server
client: client.o network.o
gcc client.o network.o -o client
2
3
4
5
6
7
8
9
# 3.2 时间戳
疑惑:make 怎么知道 server.o 需不需要重新编译?
论证——make 的核心算法极简单:
对于每个 target T,其依赖列表为 [D1, D2, ...]:
1. 如果 T 文件不存在 → 必须执行命令
2. 如果 任意 Di 的时间戳 > T 的时间戳 (Di 比 T 新) → 必须执行命令
3. 否则 → 跳过,T 是最新的
2
3
4
第 1 章事故的根本原因:
server.o 的依赖列表: [server.c] ← 缺少 config.h!
config.h 的时间戳: 2026-06-10 03:00 (刚改的)
server.o 的时间戳: 2026-06-09 12:00 (旧的)
server.c 的时间戳: 2026-06-09 12:00 (没变)
make 检查: server.c 不比 server.o 新 → 跳过!
→ server.o 还是旧的 → 里面还是 MAX_CONNECTIONS=5000
2
3
4
5
6
7
头文件依赖问题的标准解法——自动生成 .d 文件:
# GCC 可以自动生成依赖
$ gcc -MM server.c
server.o: server.c config.h
# 集成进 Makefile:
%.d: %.c
@$(CC) -MM $< | sed 's/\($*\)\.o[ :]*/\1.o $@ : /g' > $@
include $(wildcard *.d) # 把自动生成的 .d 文件 include 进来
2
3
4
5
6
7
8
9
这样当头文件改变时,make 就知道需要重新编译哪些 .o。CMake 内置了这一机制——所以你用 CMake 时不需要手动写 -MM。
# 3.3 变量与条件判断
# 变量定义 (懒求值)
CC = gcc
CFLAGS = -Wall -Wextra -O2
LDFLAGS = -lm
SRCS = $(wildcard *.c)
OBJS = $(SRCS:.c=.o) # 替换后缀: foo.c → foo.o
TARGET = server
# 条件判断
ifeq ($(DEBUG), 1)
CFLAGS += -g -O0 -DDEBUG
else
CFLAGS += -O2 -DNDEBUG
endif
# 使用变量
$(TARGET): $(OBJS)
$(CC) $^ -o $@ $(LDFLAGS)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
= vs := vs ?= vs +=:
# = : 递归展开 (recursive) —— 使用时才求值
VAR1 = $(VAR2)
VAR2 = hello
# echo $(VAR1) → hello (VAR2 在后面才定义,但 OK)
# := : 简单展开 (simple) —— 定义时立刻求值
VAR3 := $(VAR4)
VAR4 = world
# echo $(VAR3) → (空) (VAR4 此时还未定义)
# ?= : 只在未定义时赋值
CC ?= gcc # 如果环境变量 CC 已设,就沿用;否则用 gcc
# += : 追加
CFLAGS += -O2
# 等价于 CFLAGS := $(CFLAGS) -O2
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Makefile 的函数:
SRCS = $(wildcard *.c) # 通配符 → foo.c bar.c
OBJS = $(patsubst %.c,%.o,$(SRCS)) # 模式替换 → foo.o bar.o
DIRS = $(shell ls -d */) # 执行 shell 命令
ADDS = $(addprefix objs/,$(OBJS)) # 加前缀 → objs/foo.o
2
3
4
# 3.4 .PHONY 与伪目标
.PHONY: clean all test
clean:
rm -f *.o $(TARGET)
test:
./run_tests.sh
2
3
4
5
6
7
为什么需要 .PHONY:
- 防止与文件名冲突——如果恰好有个文件名叫
clean,不加.PHONY的make clean会检查clean文件的时间戳,发现它"已是最新"就跳过执行。 - 性能优化——
.PHONY告诉 make "这个 target 不代表一个文件,每次都要执行"——make 可以跳过时间戳检查。
典型用法:
.PHONY: all clean install test format lint
all: $(TARGET)
clean:
rm -rf build/
format:
clang-format -i *.c *.h
2
3
4
5
6
7
8
9
# 4. 自动变量与模式规则
# 4.1 $@ $< $^ $? 四兄弟
自动变量让 Makefile 的规则可以复用——你不用在每个 target 里重复写文件名:
# 不用自动变量:啰嗦
server.o: server.c server.h
gcc -c server.c -o server.o
# 用自动变量:简洁 + 通用
%.o: %.c
$(CC) -c $< -o $@
2
3
4
5
6
7
| 变量 | 含义 | 示例 (%.o: %.c..h) |
|---|---|---|
$@ | 当前目标名 | server.o |
$< | 第一个依赖 | server.c |
$^ | 所有依赖(去重) | server.c server.h |
$? | 比目标新的依赖列表 | (只有改过的依赖) |
$* | 模式匹配的 stem(% 的部分) | server |
实战示例:
# 编译规则
%.o: %.c
@echo "[CC] $< → $@"
$(CC) $(CFLAGS) -c $< -o $@
# 链接规则
$(TARGET): $(OBJS)
@echo "[LD] $@"
$(CC) $^ -o $@ $(LDFLAGS)
# 展开后等价于:
# server: server.o network.o
# gcc server.o network.o -o server
2
3
4
5
6
7
8
9
10
11
12
13
$(@D) 和 $(@F) 拆分目录和文件名:
# 如果 $@ = build/objs/server.o
# $(@D) = build/objs (目录部分)
# $(@F) = server.o (文件名部分)
build/objs/%.o: src/%.c
@mkdir -p $(@D) # 确保输出目录存在
$(CC) -c $< -o $@
2
3
4
5
6
7
# 4.2 模式隐式规则
模式规则(pattern rule)——用 % 通配:
# 显式规则: 一个规则只匹配一个文件
server.o: server.c
# 模式规则: 一个规则匹配一类文件
%.o: %.c
$(CC) -c $< -o $@
2
3
4
5
6
GNU Make 的内置隐式规则:
$ make -p -f /dev/null | grep -A5 '^%.o: %.c'
%.o: %.c
# recipe to execute (built-in):
$(COMPILE.c) $(OUTPUT_OPTION) $<
# 你什么都不写,make 也知道怎么从 .c 生成 .o
# → 因为有一个内置的隐式规则: %.o: %.c
# → 甚至连 $(COMPILE.c) 这个变量都内置了:
# COMPILE.c = $(CC) $(CFLAGS) $(CPPFLAGS) $(TARGET_ARCH) -c
2
3
4
5
6
7
8
9
利用隐式规则,最短的 Makefile:
# 3 行就能编译一个单文件 C 程序
CC = gcc
CFLAGS = -Wall -O2
hello: hello.o # make 自动知道怎么从 hello.c 生成 hello.o
2
3
4
5
变量继承链(可通过 make -p 查看完整列表):
CC → gcc
CFLAGS → (空)
CPPFLAGS → (空)
CXX → g++
LDFLAGS → (空)
LDLIBS → (空)
2
3
4
5
6
# 4.3 递归Make陷阱
当项目很大时,你可能会在每个子目录里放一个 Makefile,然后用递归调用:
# 顶层 Makefile
SUBDIRS = src tests tools
all:
for dir in $(SUBDIRS); do \
$(MAKE) -C $$dir; \
done
clean:
for dir in $(SUBDIRS); do \
$(MAKE) -C $$dir clean; \
done
2
3
4
5
6
7
8
9
10
11
12
递归 Make 的坑——变量不自动传递:
# 顶层 Makefile
export CFLAGS = -O2
# 子目录 Makefile
# 如果顶层没有 export CFLAGS,子 make 看不到这个变量
# 方法1: export CFLAGS
# 方法2: $(MAKE) CFLAGS=$(CFLAGS) -C subdir
2
3
4
5
6
7
更好的做法——用 include 替代递归:
# 顶层 Makefile
include src/module.mk
include tests/module.mk
# 把所有子模块的 Makefile 片段 include 进一个"大 Makefile"
# → 所有变量在同一个命名空间,不需要递归传递
# → make 可以全局分析依赖图,做到更优的并行
2
3
4
5
6
# 4.4 并行构建 -j 与依赖顺序
$ make -j8 # 最多同时跑 8 个编译任务
$ make -j # 不限制并行数 (小心 OOM)
2
并行构建的关键:Makefile 中的依赖必须正确声明依赖关系,否则并行构建会出现竞态:
# ❌ 有竞态: A 和 B 没有声明依赖 → make 可能同时编译它们
# 如果 B 依赖 A 编译生成的 .h 文件 → 并行构建崩
A:
gcc -c a.c
generate_header "a.h" # 生成头文件
B:
gcc -c b.c # b.c 里 #include "a.h"
# 如果 A 还没跑完 → b.c 找不到 a.h!
# ✅ 正确声明:
B: A # B 依赖 A → make 保证 A 先编译完
gcc -c b.c
2
3
4
5
6
7
8
9
10
11
12
13
.NOTPARALLEL 禁用并行:
.NOTPARALLEL: # 整个 Makefile 串行
# 5. CMake 最小工程
# 5.1 从零搭建一个 CMake 工程
最小的 CMakeLists.txt:
cmake_minimum_required(VERSION 3.16)
project(MyServer VERSION 1.0.0 LANGUAGES C)
add_executable(server
src/main.c
src/server.c
src/network.c
)
target_include_directories(server PRIVATE include)
target_compile_options(server PRIVATE -Wall -Wextra)
target_link_libraries(server PRIVATE m pthread)
2
3
4
5
6
7
8
9
10
11
12
构建命令:
# 经典三步
$ mkdir build && cd build # 1. 创建独立的构建目录
$ cmake .. # 2. 配置 (生成 Makefile)
$ make # 3. 编译
# 或者用 CMake 的 --build (不用记底层是 make 还是 ninja)
$ cmake --build .
2
3
4
5
6
7
这段 CMakeLists.txt 背后做了什么:
cmake .. (配置阶段)
├─ 检测: 有没有 gcc? 什么版本? 在哪个路径?
├─ 检测: libm.so 在哪?
├─ 检测: pthread 库在哪?
├─ 生成: Makefile (包含所有编译规则 + 头文件依赖自动追踪)
└─ 生成: CMakeCache.txt (缓存检测结果,下次 cmake 更快)
make (构建阶段)
├─ 编译 src/main.c → src/main.c.o
├─ 编译 src/server.c → src/server.c.o
├─ 编译 src/network.c → src/network.c.o
└─ 链接 → server
2
3
4
5
6
7
8
9
10
11
12
# 5.2 target 的现代 CMake 范式
"Modern CMake"(3.0+)的核心思想:一切围绕 target 来组织,而不是全局变量:
# ❌ 老式 CMake (2.x 风格) —— 全局变量满天飞
include_directories(include) # 全局的 include 路径
link_libraries(m pthread) # 全局的链接库
add_definitions(-DENABLE_FEATURE) # 全局的宏定义
add_executable(server main.c)
# ✅ 现代 CMake —— 每个 target 有自己的属性
add_executable(server main.c server.c)
target_include_directories(server PRIVATE include) # server 的私有 include
target_link_libraries(server PRIVATE m pthread) # server 的私有链接
target_compile_definitions(server PRIVATE ENABLE_FEATURE)
add_library(network network.c) # 另一个 target: 库
target_include_directories(network PUBLIC include) # PUBLIC: 使用者也能继承
target_link_libraries(server PRIVATE network) # server 依赖 network
2
3
4
5
6
7
8
9
10
11
12
13
14
15
PRIVATE vs PUBLIC vs INTERFACE:
# PRIVATE: 只对当前 target 生效,不传播给依赖者
target_include_directories(server PRIVATE src/internal)
# → 编译 server.c 时用这个路径,但链接 server 的其他 target 看不到
# PUBLIC: 当前 target 和依赖者都能用
target_include_directories(network PUBLIC include)
# → 编译 network.c 时用,链接 network 的 server 也能用
# INTERFACE: 当前 target 不需要,但依赖者需要 (header-only 库)
add_library(header_only INTERFACE) # 没有 .c 文件
target_include_directories(header_only INTERFACE include)
target_compile_definitions(header_only INTERFACE USE_HEADER_ONLY)
2
3
4
5
6
7
8
9
10
11
12
# 5.3 生成器表达式
生成器表达式 (Generator Expression) 允许在生成阶段(cmake → Makefile)做条件判断:
# 根据构建类型选择编译选项
target_compile_options(server PRIVATE
$<$<CONFIG:Debug>:-g -O0>
$<$<CONFIG:Release>:-O2 -DNDEBUG>
)
# 如果编译器是 GCC/Clang 才加这个 warning flag
target_compile_options(server PRIVATE
$<$<OR:$<C_COMPILER_ID:GNU>,$<C_COMPILER_ID:Clang>>:-Wshadow>
)
# 链接时的条件
target_link_libraries(server PRIVATE
$<$<PLATFORM_ID:Linux>:dl pthread>
$<$<PLATFORM_ID:Darwin>:> # macOS 不需要 -ldl
)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
为什么用生成器表达式而不是 if():因为 if() 在配置阶段求值(cmake 时),而生成器表达式在生成阶段求值(make 时)。多配置生成器(如 VS/Xcode)需要后者——一份 CMakeLists.txt 同时支持 Debug 和 Release。
# 5.4 out-of-source
In-source build(反模式):
$ cmake . # 💀 在当前目录生成构建文件
$ ls
CMakeLists.txt
CMakeCache.txt # 污染源代码目录
Makefile
CMakeFiles/
src/ # 源代码和构建产物混在一起
2
3
4
5
6
7
Out-of-source build(标准做法):
$ mkdir build && cd build
$ cmake ..
$ ls ..
CMakeLists.txt # 源代码目录保持干净
src/
$ ls build
Makefile # 所有构建产物在 build/ 下
CMakeCache.txt
CMakeFiles/
2
3
4
5
6
7
8
9
收益:
- 多配置共存:
build_debug/、build_release/、build_arm/共存 .gitignore极简:只需要加一行build*/- 清理简单:
rm -rf build/就是彻底的 clean
# 6. 库与外部依赖管理
# 6.1 add_library 的四种形态
# 1. STATIC: 静态库 .a
add_library(mylib STATIC src/foo.c src/bar.c)
# → 生成 libmylib.a
# 2. SHARED: 动态库 .so
add_library(mylib SHARED src/foo.c src/bar.c)
# → 生成 libmylib.so
# 3. INTERFACE: 纯头文件库 (header-only)
add_library(mylib INTERFACE)
target_include_directories(mylib INTERFACE include)
# → 不生成二进制,只传递 include 路径和编译定义
# 4. OBJECT: 编译成 .o 但不链接成库 (减少重复编译)
add_library(mylib_obj OBJECT src/foo.c src/bar.c)
add_executable(server main.c $<TARGET_OBJECTS:mylib_obj>)
add_executable(client cli.c $<TARGET_OBJECTS:mylib_obj>)
# → foo.c/bar.c 只编译一次,但 server 和 client 各自链接
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 6.2 find_package 查找机制
find_package 的两种模式:
# 模式 1: Config 模式 (查找 .cmake 配置文件)
# 查找 <Package>Config.cmake 或 <package>-config.cmake
find_package(OpenSSL REQUIRED)
# REQUIRED: 找不到就报错退出 (QUIET: 找不到静默跳过)
# 模式 2: Module 模式 (查找 Find<Package>.cmake)
# CMake 内置了大量 Find*.cmake 模块
find_package(Threads REQUIRED)
find_package(ZLIB REQUIRED)
find_package(CURL REQUIRED)
# 使用找到的库
target_link_libraries(server PRIVATE
OpenSSL::SSL # CMake target (现代用法)
Threads::Threads
ZLIB::ZLIB
)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
find_package 的搜索路径优先级:
1. CMAKE_PREFIX_PATH 环境变量 / CMake 变量指定的路径
2. <Package>_ROOT 变量 (如 OpenSSL_ROOT=/opt/openssl)
3. 系统默认路径: /usr, /usr/local, /opt
4. CMAKE_SYSTEM_PREFIX_PATH
2
3
4
手写 Find 模块示例:
# FindMyLib.cmake
find_path(MyLib_INCLUDE_DIR mylib.h
PATHS ${MyLib_ROOT}/include)
find_library(MyLib_LIBRARY mylib
PATHS ${MyLib_ROOT}/lib)
include(FindPackageHandleStandardArgs)
find_package_handle_standard_args(MyLib
REQUIRED_VARS MyLib_LIBRARY MyLib_INCLUDE_DIR)
if(MyLib_FOUND AND NOT TARGET MyLib::MyLib)
add_library(MyLib::MyLib UNKNOWN IMPORTED)
set_target_properties(MyLib::MyLib PROPERTIES
IMPORTED_LOCATION "${MyLib_LIBRARY}"
INTERFACE_INCLUDE_DIRECTORIES "${MyLib_INCLUDE_DIR}")
endif()
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 6.3 FetchContent 自动下载依赖
CMake 3.11+ 的 FetchContent 可以在配置阶段下载并编译外部依赖:
include(FetchContent)
FetchContent_Declare(
zlib
GIT_REPOSITORY https://github.com/madler/zlib.git
GIT_TAG v1.3.1
)
FetchContent_MakeAvailable(zlib)
# → 自动 git clone → cmake → make → 编译成 zlib target
# → 你的代码可以直接 target_link_libraries(server PRIVATE zlib)
2
3
4
5
6
7
8
9
10
FetchContent vs git submodule vs 系统包管理器:
| 方案 | 优点 | 缺点 |
|---|---|---|
| FetchContent | 完全自动化,一个 cmake 搞定 | 首次慢(需下载+编译),CI 需缓存 |
| git submodule | 源码级可调试 | 需要手动 git submodule update --init |
| 系统包管理器 (apt/brew) | 预编译,极快安装 | 版本不可控,跨平台不一致 |
| vcpkg/conan | 预编译 + 跨平台 | 需要额外的包管理器安装 |
# 6.4 install 与导出目标
install 规则——让库"可被安装":
# 安装二进制
install(TARGETS mylib server
RUNTIME DESTINATION bin # .exe / 可执行文件
LIBRARY DESTINATION lib # .so
ARCHIVE DESTINATION lib/static # .a
)
# 安装头文件
install(DIRECTORY include/
DESTINATION include/mylib
FILES_MATCHING PATTERN "*.h"
)
# 安装 CMake 配置 (让 find_package 能找到)
install(EXPORT mylibTargets
NAMESPACE MyLib::
DESTINATION lib/cmake/mylib
)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
安装后,其他项目就可以通过 find_package(mylib REQUIRED) 找到它。
# 7. 构建类型与交叉编译
# 7.1 Debug/Release/RelWithDebInfo/MinSizeRel
CMake 内置四种构建类型:
$ cmake -DCMAKE_BUILD_TYPE=Debug ..
$ cmake --build .
# 对应的 GCC 标志:
# Debug: -g -O0
# Release: -O3 -DNDEBUG
# RelWithDebInfo: -O2 -g -DNDEBUG
# MinSizeRel: -Os -DNDEBUG
2
3
4
5
6
7
8
自定义标志:
# 追加而不是覆盖
set(CMAKE_C_FLAGS_DEBUG "${CMAKE_C_FLAGS_DEBUG} -fsanitize=address")
set(CMAKE_C_FLAGS_RELEASE "${CMAKE_C_FLAGS_RELEASE} -march=native")
# 添加新的构建类型
set(CMAKE_C_FLAGS_PROFILE "-O2 -g -pg")
set(CMAKE_EXE_LINKER_FLAGS_PROFILE "-pg")
2
3
4
5
6
7
多配置并存:
$ mkdir build_debug && cd build_debug && cmake -DCMAKE_BUILD_TYPE=Debug ..
$ mkdir build_release && cd build_release && cmake -DCMAKE_BUILD_TYPE=Release ..
# 两个构建目录互不干扰
2
3
# 7.2 交叉编译工具链文件
工具链文件 (toolchain file) 是 CMake 交叉编译的核心:
# arm-linux-gnueabihf.cmake —— 给 ARM Linux 交叉编译的工具链文件
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 /opt/arm-sysroot)
set(CMAKE_FIND_ROOT_PATH /opt/arm-sysroot)
# 查找库/头文件时只搜索 sysroot
set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) # 程序用本机
set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) # 库只在目标 sysroot 找
set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) # 头文件只在目标 sysroot 找
set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY) # find_package 只找目标
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
使用:
$ cmake -DCMAKE_TOOLCHAIN_FILE=arm-linux.cmake ..
$ make
2
Android NDK 交叉编译示例:
$ cmake -DCMAKE_TOOLCHAIN_FILE=$ANDROID_NDK/build/cmake/android.toolchain.cmake \
-DANDROID_ABI=arm64-v8a \
-DANDROID_PLATFORM=android-21 \
..
2
3
4
# 7.3 多平台编译
# 检测平台
if(CMAKE_SYSTEM_NAME STREQUAL "Linux")
target_link_libraries(server PRIVATE dl pthread)
elseif(CMAKE_SYSTEM_NAME STREQUAL "Darwin")
# macOS: 不需要 -ldl, pthread 已内置
target_link_libraries(server PRIVATE)
elseif(CMAKE_SYSTEM_NAME STREQUAL "Windows")
target_link_libraries(server PRIVATE ws2_32)
endif()
# 检测架构
if(CMAKE_SIZEOF_VOID_P EQUAL 8)
message(STATUS "Building for 64-bit")
add_definitions(-DARCH_64BIT)
endif()
# 检测编译器
if(CMAKE_C_COMPILER_ID STREQUAL "GNU")
target_compile_options(server PRIVATE -Wno-unused-parameter)
elseif(CMAKE_C_COMPILER_ID STREQUAL "Clang")
target_compile_options(server PRIVATE -Weverything)
elseif(MSVC)
target_compile_options(server PRIVATE /W4)
endif()
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# 7.4 ccache 与增量编译加速
# 安装 ccache
$ sudo apt install ccache # Ubuntu
$ brew install ccache # macOS
# 方式1: CMake 自动集成
$ cmake -DCMAKE_C_COMPILER_LAUNCHER=ccache \
-DCMAKE_CXX_COMPILER_LAUNCHER=ccache ..
# 方式2: 环境变量 (对所有 make 生效)
$ export CC="ccache gcc"
$ export CXX="ccache g++"
# 查看命中率
$ ccache -s
cache hit (direct) 12345
cache hit (preprocessed) 678
cache miss 890
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
ccache 的原理:缓存编译的"输入哈希 → 输出 .o"映射。当同一个文件的编译选项、头文件、源代码都没变时,直接返回缓存的 .o——全量重编译变成几乎零开销。
# 8. 综合案例串讲
# 8.1 案例真相揭晓
回到第 1 章 config.h 修改未重编的事故,八个疑问逐条作答:
| 疑问 | 答案 |
|---|---|
| ① Makefile 目标/依赖/命令怎么工作? | 第 3.1:target: deps → recipe。make 检查 deps 时间戳是否比 target 新 |
| ② make 怎么判断要不要重编? | 第 3.2:依赖比目标新 → 重编;否则跳过 |
③ $@ $< $^ 代表什么? | 第 4.1:$@=目标, $<=第一个依赖, $^=所有依赖, $?=变更的依赖 |
④ 模式规则 % 怎么通配? | 第 4.2:%.o: %.c 匹配所有 .c→.o 的转换,$* = stem |
⑤ .PHONY 为什么需要? | 第 3.4:防止与同名文件冲突,告诉 make "每次都执行" |
| ⑥ CMake 怎么解决头文件依赖? | 第 5.2:内置 .d 文件自动生成机制,target_include_directories 隐式追踪 |
⑦ find_package 怎么找到库? | 第 6.2:Config 模式(找 Config.cmake) 或 Module 模式(找 Find*.cmake) |
| ⑧ 交叉编译工具链怎么写? | 第 7.2:CMAKE_TOOLCHAIN_FILE 指定编译器 + sysroot + find_root_path |
第 1 章事故的完整根因链条:
config.h 修改 (MAX_CONNECTIONS: 5000 → 10000)
↓
make 检查 server.o 的依赖: [server.c] ← 缺少 config.h!
↓
server.c 时间戳没变 → make 认为 server.o 已是最新 → 跳过
↓
server.o 还是旧的 MAX_CONNECTIONS=5000
↓
链接 → 部署 → 上限仍然是 5000
2
3
4
5
6
7
8
9
修复方案:
方案 A:Makefile 正确声明头文件依赖(手动)
server.o: server.c config.h # ← 加上 config.h
gcc -c server.c -o server.o
2
方案 B:Makefile 自动生成 .d 依赖文件
DEPS = $(SRCS:.c=.d)
-include $(DEPS)
%.d: %.c
@$(CC) -MM $< | sed 's/\($*\)\.o[ :]*/\1.o $@ : /g' > $@
2
3
4
5
方案 C:切换到 CMake(自动搞定一切)
cmake_minimum_required(VERSION 3.16)
project(Server LANGUAGES C)
add_executable(server server.c network.c)
target_include_directories(server PRIVATE .)
# → CMake 自动追踪 #include 依赖,不需要手动维护
2
3
4
5
# 8.2 从 gcc 到 CMake 构建的进化之路
同一份 3 文件的 C 项目,三种构建方式的对比:
项目结构:
src/main.c
src/network.c
src/network.h
include/config.h
原始方式: 手动 gcc
$ gcc -c src/main.c -o build/main.o -Iinclude
$ gcc -c src/network.c -o build/network.o -Iinclude
$ gcc build/main.o build/network.o -o server
问题: 改了一个文件 → 要记得手动重编哪些 .o、怎么链接
Makefile 方式
$ make
问题: 需要手动维护依赖列表、手写编译规则、不跨平台
CMake 方式
$ mkdir build && cd build && cmake .. && make
自动: 跨平台、依赖追踪、多配置、find_package...
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 8.3 面试高频问题清单
1. Makefile 的目标/依赖/命令三要素分别是什么?
目标(target):要生成的文件。依赖(prerequisites):生成目标所依赖的文件列表。命令(recipe):生成目标要执行的 shell 命令。make 通过比较目标和依赖的时间戳来决定是否执行命令。
2. $@ $< $^ 分别代表什么?
$@= 当前目标名。$<= 第一个依赖(通常用于编译规则,如%.o: %.c中 $< = foo.c)。$^= 所有依赖(去重)。$?= 比目标更新的依赖列表。详见第 4.1 节。
3. .PHONY 的作用?不加有什么后果?
声明目标为"伪目标"——不代表一个文件,每次都要执行。不加的话,如果恰好存在一个叫
clean的文件,make clean会因为"clean 文件已是最新"而跳过执行。
4. Makefile 中的 = 和 := 区别?
=是递归展开(使用时才求值),:=是简单展开(赋值时立刻求值)。递归展开可能导致意料之外的循环展开问题。
5. CMake 的 PRIVATE/PUBLIC/INTERFACE 区别?
PRIVATE:只对当前 target 生效,不传播。PUBLIC:当前 target 和依赖者都能用。INTERFACE:当前 target 不需要但依赖者需要(header-only 库)。详见第 5.2 节。
6. CMake 怎么配置 Debug 和 Release ?
cmake -DCMAKE_BUILD_TYPE=Debug ..或Release。可以在CMakeLists.txt中按构建类型追加编译选项:set(CMAKE_C_FLAGS_DEBUG "...")。多配置并存:build_debug/和build_release/两个目录。
7. find_package 的查找机制是什么?
Config 模式:查找
<Package>Config.cmake(包自己提供的 CMake 配置)。Module 模式:查找 CMake 内置或用户写的Find<Package>.cmmake。搜索路径包括 CMAKE_PREFIX_PATH、<Package>_ROOT、系统默认路径。
8. 交叉编译时 CMake 怎么指定工具链?
编写工具链文件(
toolchain.cmake),设置CMAKE_C_COMPILER、CMAKE_SYSROOT等,然后用cmake -DCMAKE_TOOLCHAIN_FILE=toolchain.cmake ..配置。Android NDK 自带android.toolchain.cmake。
9. Make 的 -j 并行构建有什么注意事项?
make -j8最多同时 8 个任务。必须正确声明依赖关系,否则并行构建会出现竞态(如 B 需要 A 生成的头文件,但 A 还没跑完)。可以用.NOTPARALLEL禁用并行。
10. ccache 怎么加速 C/C++ 构建?
ccache 缓存编译的"输入哈希→输出 .o"映射。相同源码+编译选项+头文件 → 直接返回缓存。
cmake -DCMAKE_C_COMPILER_LAUNCHER=ccache ..或export CC="ccache gcc"。首次慢,后续快数倍。
# 8.4 构建系统速查卡
Makefile 快速参考:
# 常用变量
CC = gcc
CFLAGS = -Wall -O2
LDFLAGS = -lm
SRCS = $(wildcard src/*.c)
OBJS = $(patsubst src/%.c,build/%.o,$(SRCS))
TARGET = server
# 默认目标
.PHONY: all clean
all: $(TARGET)
# 模式规则
build/%.o: src/%.c
@mkdir -p $(@D)
$(CC) $(CFLAGS) -c $< -o $@
# 链接
$(TARGET): $(OBJS)
$(CC) $^ -o $@ $(LDFLAGS)
# 自动依赖生成
DEPS = $(OBJS:.o=.d)
-include $(DEPS)
build/%.d: src/%.c
@$(CC) -MM $< -MT $(@:.d=.o) > $@
# 清理
clean:
rm -rf build/
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
CMake 快速参考:
# 最小模板
cmake_minimum_required(VERSION 3.16)
project(MyProject VERSION 1.0.0 LANGUAGES C)
add_executable(myapp main.c util.c)
target_include_directories(myapp PRIVATE include)
target_link_libraries(myapp PRIVATE m)
target_compile_options(myapp PRIVATE -Wall -Wextra)
# 库
add_library(mylib STATIC lib.c)
target_include_directories(mylib PUBLIC include)
# 外部依赖
find_package(OpenSSL REQUIRED)
target_link_libraries(myapp PRIVATE OpenSSL::SSL)
# 安装
install(TARGETS myapp RUNTIME DESTINATION bin)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
常用命令:
# Make 相关
make # 构建默认目标
make -j$(nproc) # 并行构建 (所有核)
make target # 构建特定目标
make -n # dry-run (只打印命令不执行)
make V=1 # 显示完整命令 (默认只显示简化版)
make --debug=b # 调试: 显示依赖检查过程
# CMake 相关
cmake -S . -B build # 源码目录 . → 构建目录 build
cmake -DCMAKE_BUILD_TYPE=Debug .. # Debug
cmake -DCMAKE_BUILD_TYPE=Release .. # Release
cmake -DCMAKE_EXPORT_COMPILE_COMMANDS=ON .. # 生成 compile_commands.json (IDE/工具用)
cmake --build . -j $(nproc) # 构建 (不依赖底层是 make 还是 ninja)
ccmake .. # 终端 UI 配置界面
# 问题诊断
make -p # 打印所有规则和变量 (含内置)
cmake --system-information # 系统信息 dump
cmake --trace # 跟踪 CMake 脚本执行 (极详细)
cmake --graphviz=deps.dot .. # 生成依赖图
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
Makefile vs CMake 选型:
| 场景 | 推荐 |
|---|---|
| < 5 个文件的小工具 | Makefile (甚至不用,直接 shell 脚本) |
| 中等 C 项目 (10~100 文件) | Makefile 或 CMake,看团队偏好 |
| 大型 C/C++ 项目 | CMake (跨平台、依赖管理、生态系统) |
| 需要跨平台 (Win/Mac/Linux) | CMake 几乎是唯一选择 |
| 嵌入式/交叉编译 | CMake (工具链文件机制成熟) |
| Linux 内核/ glibc / GCC 本身 | Make (历史原因 + 极度复杂的构建逻辑) |
下一篇:17.GDB调试底层原理 —— 我们已经知道"怎么把代码编译成二进制",下一步进入调试:
ptrace系统调用怎么让 GDB 接管另一个进程?断点指令int3是怎么插入的?watchpoint用了什么硬件机制?core dump 文件里到底存了什么?