GNU Make 工程手册:让时间戳依赖图在并行构建中保持真实
一个 Makefile 在串行构建中连续成功,并不能证明它正确。很多项目只是碰巧按文件排列顺序执行:生成头文件的命令先跑,编译步骤后跑,于是缺失的依赖边没有暴露。一旦 CI 打开 make -j,或者增量构建从另一个目标开始,偶然顺序消失,错误才以“随机缺文件”“偶发链接失败”或“重跑又成功”的形式出现。
GNU Make 的核心不是命令集合,而是一张以文件和目标为节点、以 prerequisite 为边的图。它根据时间戳判断哪些目标过期,再把 recipe 交给 Shell。架构师要维护的是这张图的真实性:输入有没有列全,输出是不是目标,副作用是否被建模,并行执行是否共享未声明资源。
Make 不是 Shell,recipe 才会进入 Shell
Makefile 同时存在两种语言。Make 负责读取规则、展开变量、选择目标和调度任务;每条 recipe 再由 Shell 解释。下面这条规则包含三个不同对象:
build/report.txt: data/input.csv scripts/render.sh
mkdir -p build
./scripts/render.sh data/input.csv > build/report.txtbuild/report.txt 是 target,也是 Make 判断是否需要重建的输出。data/input.csv 与 scripts/render.sh 是 prerequisites。两条以 Tab 开头的命令是 recipe。
执行 make build/report.txt 时,Make 先检查目标是否存在,再比较每个 prerequisite 的修改时间。目标缺失,或任一普通 prerequisite 更新,recipe 才会执行。
Shell 变量和 Make 变量使用不同的 $ 规则:
MODE := debug
show:
@echo "make mode=$(MODE)"
@name=worker; echo "shell name=$$name"$(MODE) 由 Make 展开;$$name 先由 Make 变成 $name,再交给 Shell。把两层混在一起,是 Makefile 中大量“变量为空”和“命令被截断”的根源。
安装时先确认拿到的确实是 GNU Make
Linux 和 BSD 系统可能同时存在不同 Make 实现,命令名也可能分别是 make、gmake。先记录身份和功能:
make --version
make --help
command -v make输出首行应明确显示 GNU Make。功能与语法变化应从 GNU Make 手册核对,源码与发布入口在 GNU Make 项目页;不要根据某台机器的系统 make 推断所有环境都支持 GNU 扩展。
常见开发机入口包括:
# Debian / Ubuntu
sudo apt-get update
sudo apt-get install make
# Fedora / RHEL 系列
sudo dnf install make
# macOS,先使用团队批准的包管理器
brew install make
gmake --versionmacOS 包管理器安装的 GNU Make 常使用 gmake,避免覆盖系统实现。Windows 更适合在批准的 MSYS2、Cygwin、WSL 或开发容器中运行 GNU Make;直接把 POSIX Makefile交给 cmd.exe 或 PowerShell,Shell 语法、路径、信号和工具集都会变化。
源码构建适合维护受控工具镜像,不适合每个开发者手工编译。下载后要校验发布签名或摘要,记录编译器、安装前缀和功能选项,并把最终 make --version 写入工具清单。
卸载应由最初的包管理器负责。手工删除二进制会遗留 man page、info 文档和并行安装版本,也可能删错系统 Make。
从一个可观察的增量项目开始
创建目录:
make-lab/
├── Makefile
├── include/
│ └── message.h
└── src/
├── main.c
└── message.cinclude/message.h:
#pragma once
const char *message(void);src/message.c:
#include "message.h"
const char *message(void) { return "make-model-ok"; }src/main.c:
#include <stdio.h>
#include "message.h"
int main(void) {
puts(message());
return 0;
}Makefile:
CC ?= cc
CPPFLAGS := -Iinclude
CFLAGS ?= -O2 -g -Wall -Wextra
BUILD_DIR := build
TARGET := $(BUILD_DIR)/make-lab
SOURCES := src/main.c src/message.c
OBJECTS := $(SOURCES:src/%.c=$(BUILD_DIR)/%.o)
.PHONY: all verify clean
all: $(TARGET)
$(TARGET): $(OBJECTS)
$(CC) $(OBJECTS) -o $@
$(BUILD_DIR)/%.o: src/%.c include/message.h | $(BUILD_DIR)
$(CC) $(CPPFLAGS) $(CFLAGS) -c $< -o $@
$(BUILD_DIR):
mkdir -p $@
verify: $(TARGET)
./$(TARGET)
clean:
rm -rf $(BUILD_DIR)执行完整闭环:
make --no-builtin-rules --warn-undefined-variables
make verify
make
touch src/message.c
make --trace
make clean第一次构建应生成两个对象和可执行文件;第二次 make 应显示无需工作;只修改 message.c 后,只重编译 message.o 并重新链接。make clean 删除项目产物,不碰编译器和用户级缓存。
这个例子仍把头文件写死在规则中,下一步会改为编译器生成依赖文件,让真实 include 关系进入图。
时间戳增量的三个判断条件
GNU Make 对普通文件目标的基本判断很直接:
target 不存在,需要执行。prerequisite 不存在且没有规则可生成,构建失败。任一 prerequisite 比 target 新,target 过期。
这意味着 recipe 的真实输出必须与 target 对齐。下面的规则会反复执行,因为 report.txt 从未生成:
report.txt: input.csv
./render input.csv > report-final.txt另一个陷阱是命令成功退出,却只写了一半目标。推荐让工具写临时文件,成功后原子替换,并启用失败删除:
.DELETE_ON_ERROR:
report.txt: input.csv
./render input.csv > $@.tmp
mv $@.tmp $@特殊目标手册说明 .DELETE_ON_ERROR 会在 recipe 失败且目标已改变时删除目标,避免下次把损坏文件当作最新结果。但它不能自动清理任意副作用,复杂生成器仍需显式清理策略。
普通 prerequisite 与 order-only prerequisite
构建目录必须先存在,但目录时间戳变化不应让所有对象重编译。竖线右侧的 order-only prerequisite 只建立顺序,不参与过期判断:
build/%.o: src/%.c | build
$(CC) -c $< -o $@
build:
mkdir -p $@适合 order-only 的通常是目录或纯顺序闸门。头文件、代码生成器、配置模板和编译选项文件会改变输出内容,必须作为普通 prerequisite。把真实输入放到竖线右侧,只是让增量构建更快地生成旧结果。
.PHONY 防止动作名被同名文件劫持
clean、test、lint 不是要生成同名文件的构建目标,而是每次请求都要执行的动作:
.PHONY: clean test lint
test: build/app
./scripts/test.sh
clean:
rm -rf build如果不声明 .PHONY,工作目录里一旦出现名为 clean 的文件,make clean 可能判断目标已经存在并跳过 recipe。phony target 也有性能价值,因为 Make 不再为它搜索隐式规则。
phony 目标不应作为真实文件目标的普通 prerequisite,否则真实目标每次都会过期。需要表达“先完成某动作”时,先判断能否把动作结果建模成一个真实 stamp、目录或生成文件。
变量展开时机决定配置是否稳定
Make 常用两种赋值:
ROOT = $(shell pwd)
BUILD_DIR := $(ROOT)/build= 创建递归展开变量,每次使用时再展开右侧;:= 创建立即展开变量,读取 Makefile 时就确定值。对 $(shell ...)、通配符和昂贵函数,误用递归展开会重复执行并引入时序差异。
常见赋值语义:
CC ?= cc # 仅在变量未定义时给默认值
CFLAGS += -Wall # 追加
override CFLAGS += -Werror # 明确覆盖命令行优先级,慎用用户可以在命令行覆盖变量:
make CC=clang CFLAGS='-O0 -g -Wall'团队应把可覆盖接口限定在编译器、构建类型、安装前缀等明确参数。安全或正确性关键选项不要只依赖默认值;如果允许覆盖,就在日志打印最终值并验证组合。
查看变量来源可临时加入:
$(info CC=$(CC) origin=$(origin CC) flavor=$(flavor CC))不要长期打印完整环境变量,里面可能含代理凭证、签名材料和内部路径。
自动变量让规则跟随当前目标
在 recipe 中,自动变量代表当前规则上下文:
$@:当前 target。$<:第一个 prerequisite。$^:所有 prerequisite,去重。
$+:所有 prerequisite,保留重复和顺序。$?:比 target 更新的 prerequisites。$*:模式规则匹配的 stem。
典型编译和链接规则:
build/%.o: src/%.c
$(CC) $(CPPFLAGS) $(CFLAGS) -c $< -o $@
build/app: $(OBJECTS)
$(CC) $^ $(LDLIBS) -o $@不要在 target 或 prerequisite 列表中直接使用 recipe 阶段才有意义的自动变量。GNU Make 支持 secondary expansion,但它增加读取阶段复杂度,只有普通变量和静态模式无法表达时才使用,并为规则补充最小验证。
Pattern rule 与 static pattern rule 解决不同问题
通用 pattern rule 用 % 表达一类转换:
build/%.o: src/%.c
$(CC) -c $< -o $@它适合目录结构稳定、转换一致的目标。多个隐式规则都能匹配时,Make 会搜索可用链路;规则过于宽泛会增加诊断成本,甚至选择非预期内置规则。
Static pattern rule 只作用于给定目标集合:
OBJECTS := build/main.o build/net.o
$(OBJECTS): build/%.o: src/%.c
$(CC) $(CPPFLAGS) $(CFLAGS) -c $< -o $@当不同目标组使用不同选项时,static pattern 比在 recipe 中写大量条件分支更清楚。诊断隐式规则选择可以运行:
make --no-builtin-rules --debug=implicit build/main.o多输出 recipe 必须告诉 Make 它们是同一次生成
代码生成器常在一次执行中产生 .c 与 .h。把两个目标写成普通独立规则,Make 可能并行执行生成器两次。GNU Make 的 grouped target 使用 &: 表示一次 recipe 同时更新多个目标:
generated/api.c generated/api.h &: schema/api.yaml tools/codegen
tools/codegen schema/api.yaml --out generated多目标规则说明指出,只要组内任一目标过期,整组都视为过期并执行一次 recipe。旧版 GNU Make 或其他 Make 实现未必支持该语法;跨版本项目要么锁定工具基线,要么使用 stamp 文件建模一次生成。
stamp 方案:
generated/.api.stamp: schema/api.yaml tools/codegen
tools/codegen schema/api.yaml --out generated
touch $@
generated/api.c generated/api.h: generated/.api.stampstamp 必须最后写入,生成器失败时不能留下“已完成”标记。
让编译器生成真实头文件依赖
手工维护每个 .c 包含哪些头文件很快会失真。GCC 和 Clang 可以在编译时生成 .d 文件:
CPPFLAGS := -Iinclude -MMD -MP
OBJECTS := $(SOURCES:src/%.c=build/%.o)
DEPS := $(OBJECTS:.o=.d)
build/%.o: src/%.c | build
$(CC) $(CPPFLAGS) $(CFLAGS) -c $< -o $@
-include $(DEPS)-MMD 记录用户头文件依赖,-MP 为已删除头文件生成空目标,减少清理后的 “No rule to make target” 干扰。前导 - 让首次构建时缺失 .d 文件不报错。
验证不能只看全量构建:
make clean && make -j4
touch include/message.h
make --trace所有包含该头文件的对象应重建,不相关对象不应重建。头文件依赖文件是构建产物,应和对象一起清理,不应提交跨机器生成的绝对路径版本。
Include 用于拆分图,不用于隐藏所有权
大型 Makefile 可以拆分:
include config/toolchain.mk
-include build/local-overrides.mk普通 include 缺失时 Make 会尝试寻找生成规则,最终仍失败;-include 允许文件不存在。工具链基线、平台映射等权威配置应使用强制 include,个人可选配置才适合 -include。
被 include 文件也是 Makefile 输入。它更新后,GNU Make 可能重读并重新启动自身。避免创建一个每次都被 phony 规则更新的 include 文件,否则会出现无限重启或每次全量重新解析。
并行构建会审计依赖图,而不是制造错误
打开并行:
make -j4如果串行成功、并行失败,优先怀疑:
一个目标读取了未声明的生成文件。两个 recipe 写同一个临时文件或输出目录。多输出生成器被执行多次。
recipe 依赖当前工作目录或全局环境副作用。递归 make 没有传递 jobserver。
不要用 .NOTPARALLEL 或把 -j1 永久写入 CI 掩盖缺边。先构造最小并行复现,并让目标拥有独立输出目录和临时文件。
确实必须串行的局部步骤,可以建立明确依赖边或使用 .WAIT 等受版本约束的能力。选择前查阅项目所锁 GNU Make 版本对应的并行执行章节,不要假设所有 Make 实现语义一致。
Jobserver 让递归构建共享并发预算
GNU Make 通过 jobserver 在递归调用之间分配并行槽位。递归 recipe 必须使用 $(MAKE):
.PHONY: all lib app
all: lib app
lib:
+$(MAKE) -C lib
app:
+$(MAKE) -C app不要写死 make -C lib -j4。它会绕开父级预算,每个子目录再启动一组任务,最终让 CPU、内存和链接器同时过载。$(MAKE) 还能正确传播命令行变量和 MAKEFLAGS。
第三方工具若自行并行,应明确它是否支持 GNU Make jobserver。父 Make -j8 加编译器内部 8 线程并不等于总共 8 个任务,可能演变为 64 个并发单元。容量判断要同时观察进程数、内存峰值、I/O 等待和失败模式。
递归 Make 的边界是子图所有权
递归 Make 适合真正独立、拥有自己输入输出和发布节奏的子项目。不适合用目录结构代替依赖关系。如果 app 链接 lib 的产物,顶层图必须表达这条边:
.PHONY: all lib-build app-build
all: app-build
lib-build:
+$(MAKE) -C lib all
app-build: lib-build
+$(MAKE) -C app all LIB_DIR=../lib/build这个例子仍把 lib-build 建模为 phony,因此每次都会进入子 Make。更完善的方案是以真实库产物作为目标,或由上层生成器建立完整跨目录图。
当跨目录依赖越来越多、构建配置要在多平台组合、IDE 需要项目模型时,应评估 CMake、Meson 或其他生成系统。Make 可以继续作为稳定入口,但不必继续承担所有上层建模职责。
每条 recipe 默认运行在独立 Shell 中
下面的 cd 不会影响下一行:
broken:
cd tools
./generate.sh应写在同一逻辑行:
generate:
cd tools && ./generate.shGNU Make 提供 .ONESHELL,但它会改变错误传播、前缀字符和多行脚本语义。启用前要给 Shell 设置可靠失败选项,并验证现有 recipe;不要为了少写 && 全局改变大型仓库行为。
recipe 中的管道也会掩盖失败:某些 Shell 默认只返回最后一个命令的状态。复杂脚本应移到独立、可测试的脚本文件,由 Make 只负责依赖和调用。
跨平台时先选择 Shell 合同
Makefile 中使用 rm、mkdir -p、sed、find、单引号和 / 路径,就已经依赖 POSIX 工具集。团队必须明确以下一种合同:
所有平台都通过 WSL、MSYS2 或开发容器提供 POSIX Shell。Makefile 使用目标平台原生命令,并按平台拆分实现。上层跨平台工具生成 Make/Ninja 等后端文件。
可以显式设置 Shell:
SHELL := /bin/sh但这不会自动在 Windows 创建 /bin/sh。同样,把 SHELL := powershell.exe 写入 Makefile 后,所有 quoting、退出码、管道和路径规则都要按 PowerShell 重写。
可移植性检查至少覆盖 Linux 与团队实际支持的另一平台,并从空 PATH 白名单开始,避免开发机上的偶然工具进入 recipe。
Dry-run、trace 和 database dump 是三种不同证据
先看将执行什么:
make --dry-run target
make --just-print target--dry-run 适合检查命令展开,但递归 make 等场景有特殊行为,不能把输出当作完全无副作用的安全沙箱。
查看为何重建:
make --trace target
make --debug=basic target
make --debug=implicit target查看读取后的规则和变量数据库:
make --print-data-base --no-builtin-rules --dry-run > make-db.txt数据库可能包含环境变量、内部路径和命令,分享前要脱敏。大型日志先保留目标、版本、命令行和首次失败位置,不要只截最后几十行。
清理规则要区分项目产物与共享状态
安全的 clean 只删除仓库内明确构建目录:
.PHONY: clean distclean
clean:
rm -rf -- build
distclean: clean
rm -f -- config.local.mk避免使用由环境变量拼接出的空路径、根路径或父目录。删除前可以加入守卫:
clean:
test -n "$(BUILD_DIR)"
test "$(BUILD_DIR)" != "/"
rm -rf -- "$(BUILD_DIR)"distclean、缓存清理和工具链卸载影响更大,应独立命名并默认不在 CI 普通任务中执行。共享编译缓存不属于 Make 目标图,删除策略由缓存 owner 和容量规则负责。
代理、凭证和远端材料属于显式构建输入
GNU Make 不负责 HTTP、Git 或制品认证,但 recipe 调用的下载器和包管理器会读取代理、CA 与凭证。把这些输入藏在开发者环境里,会让目标图无法解释“为什么这台机器能构建”。
下载规则至少固定 URL、输出、校验文件和失败清理:
downloads/source.tar.gz: checksums/source.sha256 | downloads
curl --fail --location --output $@.tmp "$(SOURCE_URL)"
cd downloads && sha256sum --check ../checksums/source.sha256
mv $@.tmp $@
downloads:
mkdir -p $@真实项目还应让 checksum 对应明确文件名,并保证校验的是临时文件;上面的简化规则需要根据校验清单命名调整后才能直接使用。更稳妥的结构是让下载器写固定临时名,校验通过后再原子移动。
代理地址与认证身份分开管理。CI 由密钥系统向需要联网的单个目标注入短期环境变量,后续编译和测试目标不继承发布凭证。不要把代理认证、私有制品 URL 或 SSH 私钥写进 Make 变量默认值、命令行日志和 --print-data-base 输出。
远端 Makefile 不应被运行时下载后直接 include。构建逻辑属于仓库可评审代码;必须使用外部规则时,固定不可变版本与内容摘要,先落入受控目录并完成审查,再由本地相对路径 include。
断网验证可以区分依赖材料是否完整:先在允许联网的提升任务准备受审查材料,再用无凭证、无外网的普通构建任务执行。若断网构建仍访问远端,保留目标名、调用工具和 URL 主机信息,修复材料清单,而不是给所有 recipe 扩大网络权限。
CI 中把 Make 当统一入口,而不是隐藏环境差异
CI 可以统一调用:
set -eu
make --version
cc --version
make clean
make --no-builtin-rules --warn-undefined-variables -j4 all
make test
git diff --exit-code权威入口应记录:
GNU Make、编译器、链接器、SDK 和系统库版本。显式目标与变量,不依赖工作站默认值。并行度的资源依据。
生成文件、对象、测试报告和最终产物位置。清理后的二次构建结果。
make all 成功不等于测试成功,也不等于产物可发布。构建、测试、打包和发布应有不同目标与权限;发布凭证绝不能作为普通构建变量传播到所有 recipe。
从故障现象反推缺失的图信息
No rule to make target
检查 prerequisite 拼写、相对路径基准、生成规则是否被 include、大小写和平台路径。运行 make --debug=implicit target 观察规则搜索,不要直接删掉报错 prerequisite。
每次都全量重建
用 make --trace 找到触发链,检查 phony prerequisite、每次被 touch 的生成文件、目录作为普通 prerequisite、时钟漂移和 recipe 总是改写相同内容。
修改头文件却没有重编译
依赖图缺少 include 边。启用编译器 .d 文件并验证;不要靠 make clean 作为长期正确性补丁。
make -j 偶发失败,-j1 成功
寻找共享临时文件、未声明生成输入、多输出规则和递归 jobserver 问题。重复运行并改变并行度有助于放大故障,但最终修复必须补图或隔离输出。
recipe 中变量为空
区分 Make 变量与 Shell 变量,检查 $ 是否需要写成 $$,再用 $(origin ...) 判断变量来自环境、命令行还是文件。
Windows 找不到命令或 quoting 异常
确认实际 SHELL、GNU Make 发行环境、PATH 和命令来源。不要同时混用 MSYS 路径转换、PowerShell quoting 和 cmd.exe 内建命令。
目标生成一半后下次被跳过
启用 .DELETE_ON_ERROR,让 recipe 使用临时输出和原子替换,并确保失败返回非零。检查脚本是否吞掉退出码。
从手写 Makefile 迁移时保留稳定入口
迁移到 CMake、Meson 或其他构建系统的理由应是项目模型、跨平台、依赖发现、IDE 集成或规模治理,而不是笼统地说“Make 太老”。迁移可以分三步:
先建立干净全量、增量、并行和测试基线,记录产物与命令。新系统生成同等产物,并与现有 Make 入口并行验证。保留薄 Makefile 作为兼容入口,内部转发到新系统,等调用方迁完再删除。
.PHONY: configure build test clean
configure:
cmake --preset dev
build: configure
cmake --build --preset dev
test: build
ctest --preset dev
clean:
cmake --build --preset dev --target clean薄入口不应重新实现新系统的依赖图。双轨期间比较产物、测试、编译定义、链接库和安装布局;回滚点是恢复旧入口与旧工具链基线,而不是只删除新配置文件。
网络文件系统会破坏时间戳假设
NFS、共享卷、宿主机挂载和时钟漂移可能让 prerequisite 与 target 的时间顺序失真,产生重复构建或漏构建。观察文件系统时间精度、主机时钟和挂载缓存;关键交付环境优先使用节点本地磁盘目录,并用干净构建兜底。
并行度是容量参数,不是越大越好
编译通常受 CPU 限制,链接和代码生成可能受内存或 I/O 限制。选择 -j 时记录峰值内存、交换、I/O 等待和失败率;容器里还要读取实际 CPU/内存配额,不能只看宿主机核心数。
环境变量会绕过仓库评审
CC、CFLAGS、LDFLAGS、PATH、SHELL 和代理变量都能改变结果。CI 采用白名单输入并打印非敏感最终配置;关键选项写入受审查配置,不从任意分支环境继承。
Recipe 是任意代码执行入口
运行不受信仓库的 make 等同于运行仓库脚本。隔离账号、只读凭证、受限网络和临时工作区比“先看默认目标”更可靠;禁止把生产密钥暴露给普通构建任务。
内置规则会让图看似自动工作
GNU Make 内置规则和变量可能在某台机器上补全缺失规则,换版本或加 -rR 后失效。正式项目应明确关键转换,并在 CI 至少有一条 --no-builtin-rules 验证路线。
清理命令拥有最高破坏半径
构建命令通常新增文件,clean 会删除文件。动态路径、空变量和符号链接都可能扩大删除范围。清理目标必须约束在工作区内,危险清理独立授权,并先在可删除副本验证。
递归子图让全局依赖不可见
目录级递归降低局部复杂度,却可能隐藏跨目录边和重复并发。子项目真正独立时保留递归;共享生成物密集时建立上层完整图或迁移到能表达全局 target 模型的系统。
团队落地检查
开发机与 CI 都明确运行 GNU Make,并记录版本和命令路径。target、prerequisite、recipe 和真实输出一一对应。头文件和生成文件依赖由工具生成或自动校验,不靠 clean 兜底。
phony、order-only、普通 prerequisite 的语义没有混用。串行、增量和并行构建都能得到一致产物和测试结果。递归调用统一使用 $(MAKE),并发预算不会层层放大。
Shell、路径和外部命令形成明确跨平台合同。--trace、debug 和依赖数据库能够定位首次错误原因。recipe 不接触超出任务需要的凭证、网络和共享写目录。
clean 只删除受控构建目录,危险清理有守卫和独立权限。CI 从干净目录构建,并保存版本、目标、退出码和产物证据。迁移到生成系统时保留双轨对比、调用方清单和可执行回滚。
遇到规则展开、自动变量、隐式规则、并行调度或递归行为差异时,优先从 GNU Make 在线手册按当前工具版本查证;本机也可以运行 info make、man make 与 make --help,把实际支持能力和仓库基线对齐后再修改构建图。
