Makefile 构建自动化速查表 - 目标、变量与规则大全
Makefile 的核心是依赖图而不是命令列表:只要目标的依赖文件时间戳没变,配方就不会执行,这也是它至今仍被前端、文档站点这类非 C 项目采用的原因——npm scripts 内部同样靠它做增量判断。真正需要掌握的是规则本身:%.o 之类的模式规则如何匹配文件名,$@、$<、$^ 这几个自动变量分别对应什么,以及 make 怎么决定先构建哪个目标。此外有两个反复踩的坑:命令行必须以 Tab 开头(写成空格会直接报 missing separator),以及 clean 这类不产出真实文件的目标必须写进 .PHONY,否则目录里恰好有个 clean 文件时 make 会认为任务已完成。表里按赋值、自动变量、规则、模式规则、内置函数、条件判断与实战示例分组。
典型使用场景
面向需要维护一个「别人 clone 下来就能编译」项目的 C/C++ 或多语言工程师。Makefile 的价值在于把编译顺序、依赖关系、开关配置写成可复现的声明,而不是散落在各人记忆里的命令行。本表覆盖三层:变量赋值五种形式(= / := / ?= / += / export)、自动变量($@ $< $^ $+ $* $?)与目录变体、模式规则与静态模式规则,以及内置函数(wildcard / patsubst / filter / shell / foreach / subst)和条件分支(ifeq / ifdef / ifndef)。真正容易出错的三个点是:递归赋值 = 的求值时机、recipe 行首 Tab 与空格的混用、以及模式规则没把头文件列成依赖导致增量构建形同虚设。
变量与赋值 6
CC = gccCC := gccCC ?= gccCFLAGS += -Wallexport PATH := /opt/bin:$(PATH)$(CC) 或 ${CC}自动变量 7
$@$<$^$+$*$?$(@D) / $(@F)规则与目标 5
target: deps
recipe.PHONY: clean installall: prog1 prog2clean:
rm -f *.o $(TARGET)install: target
cp target /usr/local/bin模式规则 5
%.o: %.c
$(CC) -c $< -o $@%.o: %.c $(HDRS)$(OBJDIR)/%.o: $(SRCDIR)/%.cOBJS := $(SRCS:.c=.o)$(OBJS): obj/%.o: src/%.c内置函数 8
$(wildcard *.c)$(patsubst %.c,%.o,$(SRCS))$(filter %.c %.h,$(SRCS))$(filter-out %.test,$(SRCS))$(strip $(string))$(shell date +%Y)$(subst from,to,text)$(foreach var,list,text)条件判断 5
ifeq ($(OS),Windows_NT)
...
endififneq ($(CC),gcc)
...
endififdef DEBUG
CFLAGS += -g
endififndef RELEASE
...
else
...
endif$(if $(DEBUG),-g,-O2)实用示例 6
SRCS := $(wildcard src/*.c)
OBJS := $(SRCS:.c=.o)include config.mk.DEFAULT_GOAL := installvpath %.c src:src2make -j4make CFLAGS="-O2 -g"参数矩阵
| 参数 | 作用 | 示例 |
|---|---|---|
CC = gcc | 递归赋值,变量在使用时才展开,后续修改会影响已经写过的地方 | CC = gcc |
CC := gcc | 简单赋值,定义时立即求值;关键配置推荐用它把值锁死 | CC := gcc |
CC ?= gcc | 仅当变量尚未定义时才赋值,是允许命令行或环境覆盖的标准写法 | CC ?= gcc |
CFLAGS += -Wall | 追加而非覆盖;但命令行传入的同名变量默认覆盖整个 Makefile 的追加结果 | CFLAGS += -Wall |
export PATH := /opt/bin:$(PATH) | 把变量导出给子进程(configure、测试脚本),是传递编译环境的唯一途径 | export PATH := /opt/bin:$(PATH) |
$@ | 当前规则的目标名,编译与链接命令里几乎必用 | $@ |
$< | 第一个依赖项。模式规则中它就是输入文件,与 -o $@ 是固定搭配 | $< |
$^ | 所有依赖去重后的有序列表,直接用于链接命令最常见 | $^ |
$+ | 所有依赖但不去重且保留顺序,用于同名单文件出现多次的场景 | $+ |
$* | 模式规则中的 stem(目标去后缀),或有后缀目标去掉目录后的文件名 | $* |
$? | 比目标更新的依赖列表,只在增量编译场景里用 | $? |
$(@D) / $(@F) | 目标所在目录 / 目标文件名,避免在规则里手工拆分路径 | $(@D) / $(@F) |
.PHONY: clean install | 声明伪目标;目录里存在同名文件夹时也不会被判定为已完成 | .PHONY: clean install |
all: prog1 prog2 | 多目标依赖会按序依次构建,适合把产物集中成一个入口 | all: prog1 prog2 |
%.o: %.c | 模式规则:任意 .c 生成同名 .o,recipe 行首必须是 Tab | %.o: %.c |
%.o: %.c $(HDRS) | 把头文件列为依赖,改头文件会触发重新编译,增量构建正确的关键 | %.o: %.c $(HDRS) |
$(OBJS): obj/%.o: src/%.c | 静态模式规则,源目录与目标目录不同名时用它替代通用模式规则 | $(OBJS): obj/%.o: src/%.c |
OBJS := $(SRCS:.c=.o) | 后缀替换,把源文件列表批量映射成目标文件列表 | OBJS := $(SRCS:.c=.o) |
$(wildcard *.c) | 展开为匹配到的文件名列表,不递归子目录,未匹配时返回空串 | $(wildcard *.c) |
$(patsubst %.c,%.o,$(SRCS)) | 按模式批量替换,比后缀替换更灵活 | $(patsubst %.c,%.o,$(SRCS)) |
$(filter %.c %.h,$(SRCS)) | 按模式筛选列表,滤出 .c 与 .h 文件 | $(filter %.c %.h,$(SRCS)) |
$(filter-out %.test,$(SRCS)) | 反选,从列表里剔除匹配项,常用于排除测试文件 | $(filter-out %.test,$(SRCS)) |
$(shell date +%Y) | 执行 shell 并捕获 stdout 作为字符串;每次展开都会真的执行一次 | $(shell date +%Y) |
$(subst from,to,text) | 按字面量做替换,不支持 % 通配 | $(subst from,to,text) |
$(foreach var,list,text) | 对列表每个元素展开一次 text,是批量生成规则的主力 | $(foreach var,list,text) |
$(strip $(string)) | 压缩多余空白并去掉首尾空格,用于拼接带条件的片段 | $(strip $(string)) |
ifeq ($(OS),Windows_NT) | 相等判断,用于跨平台分支;MSVC 与 MinGW 的路径差异几乎都靠它抹平 | ifeq ($(OS),Windows_NT) |
ifdef DEBUG / ifndef RELEASE | 判断变量是否被定义(只看定义不看值),控制 -g 之类调试开关 | ifdef DEBUG |
$(if $(DEBUG),-g,-O2) | 内联三元式,按变量是否为空选择编译选项,比整段 ifeq 更紧凑 | $(if $(DEBUG),-g,-O2) |
.DEFAULT_GOAL := install | 显式指定默认目标,否则 make 执行规则里出现的第一个目标 | .DEFAULT_GOAL := install |
vpath %.c src:src2 | 限定模式规则的搜索目录,让 src/ 与 src2/ 下的同名文件走同一套规则 | vpath %.c src:src2 |
include config.mk | 引入另一个 makefile 以拆分公共配置;前缀 - 可在缺失时忽略 | include config.mk |
make -j4 | 并行执行 4 个作业,编译时间显著缩短;规则若共写同一产物会竞争 | make -j4 |
make CFLAGS="-O2 -g" | 命令行赋值覆盖 Makefile 内部的同名变量,并使其后续 += 失效 | make CFLAGS="-O2 -g" |
易错点与避坑指南
现象make 报 "missing separator",或者 recipe 行被完全忽略。
原因recipe 行首不是 Tab。多数编辑器把 Tab 显示为空格,复制粘贴也会把它换成空格。
处置把该行行首换成真正的 Tab,并把编辑器设置成 Tab 显示为箭头便于辨认。
现象改了头文件却不重新编译,链接时出现 undefined reference。
原因模式规则只写了 %.o: %.c,没把头文件列入依赖,make 不知道 .o 依赖 .h。
处置规则改成 %.o: %.c $(HDRS);更稳的做法是对 .c 跑 -MMD -MP 生成 .d 再 include 它。
现象make 直接报 "up to date",但可执行文件根本还没编译出来。
原因目标名与某个同名目录重合,make 看到那个目录存在就认为目标已完成。
处置把这类目标写进 .PHONY,或改一个不与目录重名的目标名。
现象在命令行改了 CC/CFLAGS,Makefile 内部的追加完全没有体现在最终命令里。
原因命令行赋值优先级高于 Makefile 内的普通 = 与 +=,后者被整体忽略。
处置需要叠加时把变量改成 ?= 或 override CFLAGS += ...,并用 make -n 确认展开后的实际命令。
现象变量在某些位置展开成空串,echo 出来什么都没有。
原因递归赋值 = 引用了尚未定义的变量;赋值与引用的先后顺序决定了展开结果。
处置关键变量统一改用 := 立即赋值,并用 $(info VAR=$(VAR)) 在解析阶段打印确认。
现象串行 make 正常,加 make -j4 之后偶发链接失败或产物损坏。
原因并行下两个目标竞争同一中间产物,或某个目录的生成与使用之间没有依赖声明。
处置为所有目录类目标写出显式依赖(如 obj/%.o: src/%.c | obj),并把一条配方管多个目标的规则拆成独立模式规则。
现象$(wildcard *.c) 匹配不到任何文件。
原因wildcard 相对 Makefile 所在目录展开且不递归子目录;文件都在 src/ 下时自然匹配为空。
处置写明子目录路径或用 $(shell find src -name '*.c'),注意后者依赖 shell 环境。
现象Makefile 顶部某条 $(shell ...) 每跑一次 make 就重复执行多次,输出还被拆成多行破坏语法。
原因$(shell) 在每次变量展开时都会重新执行;递归赋值里引用它会放大这个次数。
处置改成 := 立即赋值让它只跑一次,并用 $(strip ...) 处理带换行的输出。
排障路径
1只打印不执行,检查展开后的真实命令
make -n target这一步最能暴露 Tab、空变量与路径拼接问题。
2打印规则数据库,确认目标与依赖是否正确
make -p -n-p 列出内置规则与变量,-n 只打印不执行。
3并行构建观察依赖是否声明充分
make -j4串行通过而并行失败,基本可确定存在未声明依赖或共享中间文件。
4强制某目标无条件重建
make -B target-B 排除「时间戳没变所以不构建」的干扰。
5确认使用的 make 实现与版本
make --versionGNU make 与 BSD make 行为不同,脚本需先确认目标环境。
命令示例
标准 C 编译规则(模式规则 + 自动变量)
CC := gcc
CFLAGS := -Wall -Wextra -O2
OBJS := $(SRCS:.c=.o)
%.o: %.c
$(CC) $(CFLAGS) -c $< -o $@
app: $(OBJS)
$(CC) $(CFLAGS) -o $@ $^$< 是第一个依赖(源文件),$@ 是目标名(.o),$^ 是全部依赖(链接阶段)。用 := 而非 = 立即展开,值才不会在引用时才求值。
Makefile 里调用 shell 命令
clean:
rm -f *.o
@echo "清理完成"
list:
@ls -1 *.o | wc -l命令行以 TAB 开头才是配方,空格会被当成普通文本。前缀 @ 抑制命令回显;不加 @ 会把 rm -f *.o 也打印一遍,CI 日志里很吵。
输出
清理完成
查看某个目标为何被重建
make -n app
make -d app 2>&1 | grep -i "newer than"-n 只打印不执行,用来预览会做什么;-d 输出完整决策过程,能看到 make 判定哪个依赖更新。排查「明明没改代码却总是重编」先看这条。
输出
gcc -Wall -O2 -c main.c -o main.o gcc -Wall -O2 -c util.c -o util.o
常见坑与注意事项
- 配方行必须以 TAB 开头而非空格,这是最常见的 Makefile 错误,报错通常是 missing separator。
- 目标名与实际文件同名时 make 会认为已是最新,加 .PHONY 声明可避免;clean 与 install 尤其需要。
- 用 = 定义含变量的变量时展开时机是引用处,容易出现「改了没生效」,路径与包含类变量建议一律用 :=。
- 写了通配源文件列表却不重新生成,make 不会感知新增文件;用 $(wildcard *.c) 在解析时收集。
- 并行 make -j 下若目标间存在未声明的隐式顺序依赖,可能产出错误结果。
提示
- Makefile 的配方行必须以 Tab 开头,不能用空格,这是最常见的错误。
- .PHONY 声明的目标不会检查同名文件是否存在,适合 clean、install 等操作。
- $@ 代表目标,$< 代表第一个依赖,$^ 代表所有依赖,这三个自动变量最常用。
- := 是立即展开,= 是延迟展开;复杂 Makefile 中推荐用 := 避免递归副作用。
- include other.mk 可以引入其他 Makefile,适合大型项目拆分配置。
- 命令行变量会覆盖 Makefile 中的赋值,用 override 关键字可防止被覆盖。
- make -n 只打印将要执行的命令不真跑,调配方时用它在不产生副作用的前提下检查展开结果。
- make -j4 启用 4 路并行,前提是规则本身没有共享输出文件的顺序依赖,否则会间歇性构建失败。
- include 的路径要加 - 容忍缺失:include - config.mk,配合 CONFIG_LOCAL ?= y 做本地覆盖。
常见问题
:= 和 = 在 Makefile 变量里有什么区别?
= 是递归展开,变量值里引用的其他变量要到使用时才求值;:= 是简单展开,定义时就立即展开。举例 CC = gcc 之后再改 CC 也不会影响已定义的地方,而 CC := gcc 会在定义那一刻就把值定死。含路径或其他变量的场景几乎都用 :=,否则容易出现「改了没生效」的困惑。
什么是 .PHONY,为什么 clean 目标要声明它?
Make 通过「目标文件是否存在且比依赖新」决定是否执行。如果目录里恰好存在一个名为 clean 的文件,make clean 就会认为该目标已是最新而拒绝执行。写 .PHONY: clean 明确告诉 make 这个目标不对应真实文件,永远执行。严格说所有不产出文件的目标都该声明 .PHONY。
$@、$<、$^、$* 各代表什么?
$@ 是当前目标名,$< 是第一个依赖(常用于编译),$^ 是所有依赖且已去重,$+ 与之类似但保留重复项(多次链接同一个对象库时需要),$* 在模式规则里表示 % 匹配到的部分。例如 %.o: %.c 加 $(CC) -c $< -o $@ 就是最标准的编译规则。
为什么加了新源文件后 make 不重新编译?
因为 Makefile 是按文件名找依赖的,写死的 src/*.o 不会自动包含新加的文件。可疑的写法是从固定列表推导目标,改用 $(wildcard src/*.c) 在解析时实时收集文件,再 $(SRCS:.c=.o) 批量替换出目标列表;或者用静态模式规则显式声明目标。切记别把变量定义和目标规则拆到不同文件里求值。
make -j4 的并行度怎么定?
一般设为核心数即可,make -j4 在四核机上能吃满。注意并行要求依赖关系声明准确,若目标之间有隐式顺序却没写成依赖,并行会产出错误结果。另外别把 -j 设得过大,链接阶段很吃内存,任务数远超核心数反而会因内存不足失败。
由 巧匠 维护
公开更新于 2026年7月19日,内容持续校对官方文档。