🔥 星光编译者 · 个人主页
📚 学习专栏: 《C/C++ 成长笔记》 · 《Linux 实践手册》 · 《数据结构与算法》
🌄 向云端飞扬,编译属于自己的代码星河。
☕ 写在开篇
你好,这里是 星光编译者。
这里记录我在 C/C++、Linux、数据结构与算法 学习中遇到的真实问题、亲手验证过的代码,以及那些容易被忽略的实现细节。
比起简单罗列结论,我更愿意从问题出发,把一个知识点的来由讲清楚,把"为什么会这样"和"应该怎样解决"说明白,让每一次踩坑都沉淀成可以复用的经验。
如果这篇记录能帮你少绕一点路,或让某个模糊的地方忽然变得清晰,那么这次分享便有了意义。愿我们在一次次阅读、编译与调试中稳步向前,慢慢搭起属于自己的技术世界。

🔥 本文定位:本文以一个不断扩大的 C 项目为主线,从"单目录、多源文件"出发,逐步演进到"源码与产物分离、多模块、头文件搜索、多 Makefile、多可执行程序"。重点不是背诵函数,而是理解 Makefile 怎样把目录结构转换成依赖图。
💡 学习目标 :掌握
wildcard、foreach、patsubst、addprefix,区分=与:=,会写模式规则和自动变量,能把.o、.d、可执行程序放入独立目录,并知道顶层 Makefile 应怎样安全地驱动子 Makefile。📌 阅读说明 :示例以 GNU make 和 GCC/Clang 风格命令为基础,默认运行在 Linux 或类 Unix 环境。Makefile 的配方行必须以 Tab 开头;本文代码块中的配方行均按这一规则书写。
文章目录
- [一、先建立全局视角:Makefile 的本质是依赖图](#一、先建立全局视角:Makefile 的本质是依赖图)
- 二、场景一:构建单目录、多源文件项目
- 三、变量与函数:让文件清单自动生成
- 四、模式规则与自动变量:把重复规则压缩成一条
- 五、场景二:让源码、目标文件与可执行程序分离
- 六、场景三与四:多模块、单可执行程序
- 七、场景五:引入头文件,并补齐依赖跟踪
- [八、场景六:多 Makefile、多可执行程序](#八、场景六:多 Makefile、多可执行程序)
- [九、综合实战:一份可直接复用的工程化 Makefile](#九、综合实战:一份可直接复用的工程化 Makefile)
- 十、常见误区、高频面试题与练习
- 总结
一、先建立全局视角:Makefile 的本质是依赖图
很多人第一次写 Makefile,会把它理解成"把几条 gcc 命令放进文件里"。这样确实能工作,却解释不了 make 最有价值的能力:
- 为什么第二次执行
make时,它会提示没有目标需要更新? - 为什么只修改
main.c,通常只重新编译main.o? - 为什么头文件变了,有时所有相关源文件都应该重新编译?
- 为什么
clean、all这类名字需要声明为伪目标? - 为什么多模块项目可以并行构建,却不应该随便塞进一个 shell 循环?
真正的答案是:
Makefile 首先描述目标、依赖和更新规则;make 根据这些信息建立依赖图,再用文件时间戳判断图中哪些节点已经过期。
一条规则的基本形式如下:
makefile
目标: 依赖1 依赖2 ...
生成目标的命令
例如:
makefile
project: main.o util.o
gcc main.o util.o -o project
main.o: main.c
gcc -c main.c -o main.o
util.o: util.c
gcc -c util.c -o util.o
这里不是三段互不相关的命令,而是一张小型依赖图:
text
main.c ──> main.o ──┐
├──> project
util.c ──> util.o ──┘
如果 main.c 比 main.o 新,main.o 过期;而 project 又依赖 main.o,因此链接目标也过期。util.c 没有变化时,util.o 可以直接复用。

1.1 make 判断"是否需要重建"的基本规则
对一个真实文件目标,make 通常在以下两种情况下执行配方:
- 目标文件不存在;
- 任意依赖文件比目标文件更新。
所以 Makefile 的价值不是"替你输入命令",而是尽量缩小每次构建的影响范围。项目越大,增量构建节省的时间越明显。
可以用下面的实验观察:
bash
make
make
touch main.c
make
第一次会完整构建;第二次通常不会执行编译;touch main.c 更新源文件时间戳后,第三次只重建 main.c → main.o → project 这条链路。
1.2 本文六个场景是一条连续的工程演进线
原始课程材料用六个场景逐步提高复杂度:
| 阶段 | 项目形态 | 要解决的问题 |
|---|---|---|
| 场景一 | 单目录、多源文件 | 自动收集源码,批量编译并链接 |
| 场景二 | 单目录、产物分离 | 把 .o 和可执行文件移出源码目录 |
| 场景三 | 多模块、单可执行程序 | 跨目录收集源文件 |
| 场景四 | 多模块、产物分离 | 在 objs/ 中镜像模块结构 |
| 场景五 | 多模块并引入头文件 | 生成 -I 搜索路径,并跟踪头文件依赖 |
| 场景六 | 多 Makefile、多可执行程序 | 顶层编排,子模块独立构建和发布 |
这条路线背后可以压缩成四个问题:
text
源文件在哪里?
↓
每个源文件对应哪个目标文件?
↓
目标文件应该放在哪里?
↓
由谁负责协调各模块与最终产物?
后面所有变量、函数和模式规则,都是在回答这四个问题。
二、场景一:构建单目录、多源文件项目
假设当前目录中有大量 .c 文件:
text
.
├── main.c
├── code1.c
├── code2.c
├── code3.c
└── Makefile
如果手写每一个对象文件:
makefile
project: main.o code1.o code2.o code3.o
gcc main.o code1.o code2.o code3.o -o project
文件一多,列表很快就会失控。新增 code4.c 时,需要同时修改对象列表和链接命令;删除文件时也容易漏改。更好的做法是让 make 根据磁盘上的实际文件生成清单。
2.1 第一版:自动收集、编译、链接
makefile
CC := gcc
CPPFLAGS :=
CFLAGS := -Wall -Wextra -O2
LDFLAGS :=
LDLIBS :=
TARGET := project
SRCS := $(wildcard *.c)
OBJS := $(SRCS:.c=.o)
.PHONY: all clean
all: $(TARGET)
$(TARGET): $(OBJS)
$(CC) $(LDFLAGS) $^ $(LDLIBS) -o $@
%.o: %.c
$(CC) $(CPPFLAGS) $(CFLAGS) -c $< -o $@
clean:
$(RM) $(OBJS) $(TARGET)
这份文件已经包含一个小项目最重要的骨架:
SRCS自动收集当前目录中的.c文件;OBJS把每个.c的后缀替换成.o;%.o: %.c用一条模式规则覆盖所有源文件;$(TARGET): $(OBJS)表示最终程序依赖全部对象文件;all和clean是动作名,不是真实文件,因此声明为.PHONY。
2.2 为什么要保留编译和链接两个阶段
也可以一次性执行:
bash
gcc *.c -o project
但这样每次修改任意源文件,所有源文件都要重新参与编译。拆成 .c → .o → 可执行程序 后,未变化的对象文件可以复用。
text
编译阶段:每个 .c 独立变成 .o
链接阶段:所有 .o 合并成最终程序
这种拆分也是并行构建的基础。执行:
bash
make -j4
make 可以同时调度多个彼此独立的 .o 目标,而不是把所有工作锁在一条长命令里。
2.3 编译选项为什么要分组
建议把选项按职责拆开:
| 变量 | 典型内容 | 作用阶段 |
|---|---|---|
CPPFLAGS |
-Iinclude、-DDEBUG |
预处理 |
CFLAGS |
-Wall -O2 -g |
C 编译 |
CXXFLAGS |
C++ 编译选项 | C++ 编译 |
LDFLAGS |
-L/path、链接器选项 |
链接 |
LDLIBS |
-lm -lpthread |
链接库 |
链接命令中通常把库放在对象文件之后:
makefile
$(CC) $(LDFLAGS) $^ $(LDLIBS) -o $@
这样更符合许多 Unix 链接器按顺序解析符号的习惯。
2.4 为什么第一目标通常写成 all
直接执行 make 时,默认目标通常是 Makefile 中出现的第一个普通目标。把 all 放在前面,可以把默认入口写得非常明确:
makefile
.PHONY: all
all: $(TARGET)
all 自己不产生文件,只是把真正需要构建的目标组织起来。以后项目有多个程序时,也可以写成:
makefile
.PHONY: all
all: server client tools
三、变量与函数:让文件清单自动生成
工程规模扩大后,Makefile 里真正复杂的部分往往不是 gcc 命令,而是"怎样从目录结构计算出源文件、对象文件和编译参数"。
3.1 = 与 :=:不是赋值结果不同,而是展开时机不同
先看一段最经典的实验:
makefile
a = hello
b = $(a) bit
a = world
.PHONY: all
all:
@echo $(a)
@echo $(b)
输出:
text
world
world bit
= 定义的是递归展开变量 。右侧文本先被保存下来,等变量真正被引用时再展开。因此 b 保存的更接近"$(a) bit 这条表达式",使用时读到的是 a 的最终值。
改成:
makefile
a = hello
b := $(a) bit
a = world
.PHONY: all
all:
@echo $(a)
@echo $(b)
输出:
text
world
hello bit
:= 定义的是简单展开变量 。make 读到定义时就计算右侧,并把当时的结果保存到 b 中。

可以用一句话记忆:
=保存表达式,引用时展开;:=保存当时的结果,定义时展开。
3.2 工程中该怎样选
对文件清单、wildcard、shell 结果,一般优先使用 :=:
makefile
SRCS := $(wildcard *.c)
OBJS := $(SRCS:.c=.o)
原因有两个:
- 目录扫描只需要发生一次,不必每次引用
SRCS都重新执行; - 变量的结果更稳定,更容易调试。
需要延迟到规则执行阶段、并依赖自动变量时,= 反而有用:
makefile
DEPFLAGS = -MMD -MP -MF $(patsubst %.o,%.d,$@)
这里故意不使用 :=。因为读取 Makefile 时 $@ 还没有当前目标;等配方真正为某个 .o 执行时再展开,才能得到对应的 .d 路径。
3.3 一个危险的自引用
原始示例会先让 BIN 保存文件名,再给它添加目录:
makefile
BIN := project
BIN_DIR := bin
BIN := $(addprefix $(BIN_DIR)/,$(BIN))
最终得到 bin/project。第二次赋值必须在读取时使用旧的 BIN 计算出新结果。
如果写成:
makefile
BIN = $(addprefix $(BIN_DIR)/,$(BIN))
BIN 展开时又引用自己,会形成递归引用,GNU make 会报错。工程中更清晰的写法是避免同名覆盖:
makefile
TARGET_NAME := project
BIN_DIR := bin
TARGET := $(BIN_DIR)/$(TARGET_NAME)
3.4 另外两个常用赋值符号
条件赋值 ?=
makefile
BUILD_TYPE ?= release
只有变量尚未定义时才赋值,适合提供默认值。调用者可以覆盖:
bash
make BUILD_TYPE=debug
追加 +=
makefile
CFLAGS := -Wall -Wextra
CFLAGS += -O2
结果是:
text
-Wall -Wextra -O2
但要注意,+= 追加内容的展开方式会受到变量原本风味影响。大型 Makefile 中,变量初始化方式最好保持一致。
3.5 wildcard:获取实际存在的文件
makefile
SRCS := $(wildcard *.c)
wildcard 返回匹配模式的、实际存在的文件名,并用空格连接。例如:
text
code1.c code2.c main.c
如果没有文件匹配,结果为空,不会原样保留 *.c。GNU make 还会对每个模式的匹配结果排序。
多目录时可以写:
makefile
SRCS := $(wildcard main/*.c Module0/*.c Module1/*.c)
当模块数量继续增加,再用 foreach 生成每个目录的模式。
3.6 后缀替换引用与 patsubst
简单的后缀替换可以写:
makefile
OBJS := $(SRCS:.c=.o)
如果:
text
SRCS = main.c code1.c code2.c
则:
text
OBJS = main.o code1.o code2.o
更通用的 patsubst 语法是:
makefile
$(patsubst 匹配模式,替换模式,文本列表)
例如把对象文件放入 objs/:
makefile
OBJS := $(patsubst %.c,objs/%.o,$(SRCS))
% 表示匹配到的"主干"。Module0/code1.c 的主干是 Module0/code1,因此结果是 objs/Module0/code1.o。
3.7 foreach:对空格分隔列表逐项展开
语法:
makefile
$(foreach 临时变量,列表,每一项要展开的文本)
例如:
makefile
MODULES := main Module0 Module1 Module2
SRCS := $(foreach module,$(MODULES),$(wildcard $(module)/*.c))
make 的处理过程可以近似理解为:
text
module = main → wildcard main/*.c
module = Module0 → wildcard Module0/*.c
module = Module1 → wildcard Module1/*.c
module = Module2 → wildcard Module2/*.c
最后把每次展开结果用空格连接
foreach 处理的是 make 的文本列表,不是启动一个 shell 循环;临时变量只在第三个参数展开期间使用。
3.8 addprefix:给每个名字添加相同前缀
makefile
MODULES := main Module0 Module1
CPPFLAGS := $(addprefix -I,$(MODULES))
结果:
text
-Imain -IModule0 -IModule1
同理:
makefile
NAMES := a.o b.o c.o
OBJS := $(addprefix objs/,$(NAMES))
得到:
text
objs/a.o objs/b.o objs/c.o
3.9 四个函数怎样串起来

把核心逻辑放在一起:
makefile
MODULES := main Module0 Module1 Module2
SRCS := $(foreach module,$(MODULES),$(wildcard $(module)/*.c))
OBJS := $(patsubst %.c,objs/%.o,$(SRCS))
CPPFLAGS := $(addprefix -I,$(MODULES))
思维顺序是:
text
目录列表 MODULES
├── foreach + wildcard ──> 源文件列表 SRCS
│ ↓
│ patsubst
│ ↓
│ 目标文件列表 OBJS
│
└── addprefix -I ───────> 头文件搜索参数 CPPFLAGS
当这段能读懂时,多模块 Makefile 就不再是一堆括号,而是一条清晰的数据变换流水线。
3.10 调试变量,不要靠猜
可以临时增加:
makefile
.PHONY: print
print:
@echo "MODULES = $(MODULES)"
@echo "SRCS = $(SRCS)"
@echo "OBJS = $(OBJS)"
@echo "CPPFLAGS= $(CPPFLAGS)"
然后执行:
bash
make print
也可以在读取 Makefile 时打印:
makefile
$(info SRCS=$(SRCS))
前者是一个显式调试目标,后者每次解析 Makefile 都会输出。调试完成后,建议移除或用条件变量控制。
四、模式规则与自动变量:把重复规则压缩成一条
4.1 模式规则中的 %
makefile
%.o: %.c
$(CC) $(CPPFLAGS) $(CFLAGS) -c $< -o $@
%.o 与 %.c 中的 % 共享同一个主干:
| 目标 | 主干 | 推导出的依赖 |
|---|---|---|
main.o |
main |
main.c |
code17.o |
code17 |
code17.c |
Module0/code1.o |
Module0/code1 |
Module0/code1.c |
要特别注意:模式规则使用 %,不是 shell 通配符 *。下面这条不是正确的通用对象文件模式规则:
makefile
*.o: %.c
正确写法是:
makefile
%.o: %.c
4.2 自动变量解决"当前到底在编译谁"
同一条模式规则会反复用于不同文件,配方不能写死文件名。make 会为当前规则提供自动变量:

| 自动变量 | 含义 | 在编译规则中的典型值 |
|---|---|---|
$@ |
当前目标文件名 | objs/Module0/code1.o |
$< |
第一个依赖文件名 | Module0/code1.c |
$^ |
全部依赖,重复项去重 | main.o util.o |
$+ |
全部依赖,保留重复及顺序 | 需要重复库参数时有用 |
$? |
比目标更新的依赖 | 增量更新归档时常用 |
$* |
模式匹配主干 | Module0/code1 |
所以编译规则:
makefile
objs/%.o: %.c
$(CC) -c $< -o $@
对 objs/Module0/code1.o 执行时,近似展开为:
bash
gcc -c Module0/code1.c -o objs/Module0/code1.o
链接规则:
makefile
$(TARGET): $(OBJS)
$(CC) $^ -o $@
会把所有对象文件放入 $^,最终目标路径放入 $@。
4.3 自动变量的作用域
自动变量是"当前规则"的上下文,只在配方执行时拥有有意义的值。下面的写法通常得不到想要的结果:
makefile
BROKEN := $@
读取 Makefile 时还没有正在执行的目标,因此 $@ 为空。
这也是前面 DEPFLAGS 使用递归展开变量的原因:
makefile
DEPFLAGS = -MMD -MP -MF $(patsubst %.o,%.d,$@)
它会等到当前对象文件规则执行时,才使用具体的 $@。
4.4 .PHONY:动作目标必须明确声明
clean、all、test、print 这些名称通常不会生成同名文件:
makefile
.PHONY: all clean test print
如果不声明,目录里恰好出现一个名为 clean 的文件,make 可能认为目标已经存在且无需更新,从而跳过清理命令。
伪目标还有一个好处:make 不需要为它搜索隐式规则。
不要把真实产物声明为伪目标,也不要让一个每次都执行的伪目标无条件成为真实文件的依赖,否则真实文件会失去增量构建效果。
4.5 目录目标与顺序依赖
当最终程序要写入 bin/ 时,目录必须先存在:
makefile
BIN_DIR := bin
TARGET := $(BIN_DIR)/project
$(TARGET): $(OBJS) | $(BIN_DIR)
$(CC) $^ -o $@
$(BIN_DIR):
mkdir -p $@
竖线后面的 $(BIN_DIR) 是顺序依赖:
text
$(TARGET): 普通依赖 | 顺序依赖
含义是:
- 构建
TARGET前,BIN_DIR必须存在; - 但目录时间戳变化不应单独导致程序重新链接。
如果把目录写成普通依赖,在目录中创建或删除文件可能改变目录时间戳,让可执行程序发生不必要的重建。
五、场景二:让源码、目标文件与可执行程序分离
对象文件和源码混在同一目录时,项目很快会变成:
text
code1.c
code1.o
code2.c
code2.o
main.c
main.o
project
更清晰的目标结构是:
text
.
├── code1.c
├── code2.c
├── main.c
├── Makefile
├── objs
│ ├── code1.o
│ ├── code2.o
│ └── main.o
└── bin
└── project
5.1 路径映射的完整 Makefile
makefile
CC := gcc
CPPFLAGS :=
CFLAGS := -Wall -Wextra -O2
LDFLAGS :=
LDLIBS :=
TARGET_NAME := project
OBJ_DIR := objs
BIN_DIR := bin
SRCS := $(wildcard *.c)
OBJS := $(patsubst %.c,$(OBJ_DIR)/%.o,$(SRCS))
TARGET := $(BIN_DIR)/$(TARGET_NAME)
.PHONY: all clean
all: $(TARGET)
$(TARGET): $(OBJS) | $(BIN_DIR)
$(CC) $(LDFLAGS) $^ $(LDLIBS) -o $@
$(OBJ_DIR)/%.o: %.c
@mkdir -p $(dir $@)
$(CC) $(CPPFLAGS) $(CFLAGS) -c $< -o $@
$(BIN_DIR):
@mkdir -p $@
clean:
$(RM) -r $(OBJ_DIR) $(BIN_DIR)

5.2 $(dir $@) 为什么比固定写 objs 更可靠
对目标 objs/code1.o:
makefile
$(dir $@)
结果是:
text
objs/
将来多模块后,目标会变成:
text
objs/Module0/code1.o
此时 $(dir $@) 会得到:
text
objs/Module0/
因此:
makefile
@mkdir -p $(dir $@)
不关心模块有几层,始终创建当前目标真正需要的父目录。-p 让目录已经存在时也不会报错。
5.3 为什么对象目录要镜像源码结构
假设两个模块都有 config.c:
text
ModuleA/config.c
ModuleB/config.c
如果只取文件名并全部放入 objs/:
text
objs/config.o
两者会发生命名冲突。镜像结构则得到:
text
objs/ModuleA/config.o
objs/ModuleB/config.o
路径本身保留了模块身份,也让源文件与对象文件能一一对应。
5.4 clean 只删除已知产物
比起在源码目录里执行宽泛的 rm -rf *,更安全的做法是只删除由构建系统拥有的目录:
makefile
clean:
$(RM) -r $(OBJ_DIR) $(BIN_DIR)
前提是 OBJ_DIR 和 BIN_DIR 使用明确、受控的值。不要让清理目标依赖未经验证的外部路径,也不要把项目根目录、主目录或空变量拼成递归删除目标。
六、场景三与四:多模块、单可执行程序
现在把项目拆成多个目录:
text
.
├── main
│ └── main.c
├── Module0
│ ├── code1.c
│ └── code2.c
├── Module1
│ ├── code3.c
│ └── code4.c
└── Makefile
最终仍然只生成一个 project,但源文件不再集中在当前目录。问题变成:
- 怎样获得所有模块名称?
- 怎样逐目录扫描
.c? - 怎样把对象文件统一放入
objs/,同时保留目录层级? - 怎样保证链接目标能拿到全部对象文件?
6.1 模块列表最好显式、稳定
如果模块集合相对稳定,直接声明最清晰:
makefile
MODULES := main Module0 Module1 Module2
如果模块遵循固定命名,也可以自动发现:
makefile
MODULES := main $(sort $(patsubst %/,%,$(wildcard Module*/)))
这里每一步的含义是:
wildcard Module*/只匹配目录形式的路径;patsubst %/,%去掉末尾斜杠;sort排序并顺便去除重复项;- 最前面显式加入入口模块
main。
原始示例使用:
makefile
MODULES += $(shell ls -d Module*)
在简单 Linux 实验中能工作,但它启动了额外 shell,依赖外部 ls 的输出格式,并且对包含空格的名字不友好。能用 make 自带函数表达时,通常更容易移植和调试。
6.2 用 foreach + wildcard 收集跨目录源码
makefile
SRCS := $(foreach module,$(MODULES),$(wildcard $(module)/*.c))
假设:
text
MODULES = main Module0 Module1
展开结果可能是:
text
main/main.c Module0/code1.c Module0/code2.c Module1/code3.c Module1/code4.c
随后生成对象路径:
makefile
OBJ_DIR := objs
OBJS := $(patsubst %.c,$(OBJ_DIR)/%.o,$(SRCS))
得到:
text
objs/main/main.o
objs/Module0/code1.o
objs/Module0/code2.o
objs/Module1/code3.o
objs/Module1/code4.o
6.3 场景三:对象文件仍留在各模块目录
最简单的跨模块写法是:
makefile
MODULES := main Module0 Module1
SRCS := $(foreach module,$(MODULES),$(wildcard $(module)/*.c))
OBJS := $(SRCS:.c=.o)
TARGET := project
.PHONY: all clean
all: $(TARGET)
$(TARGET): $(OBJS)
$(CC) $^ -o $@
%.o: %.c
$(CC) -c $< -o $@
clean:
$(RM) $(OBJS) $(TARGET)
它会形成:
text
main/main.c → main/main.o
Module0/code1.c → Module0/code1.o
Module1/code3.c → Module1/code3.o
功能上没有问题,但源码和中间产物仍然混在一起。
6.4 场景四:统一放入 objs,并镜像模块结构
把对象映射改成:
makefile
OBJS := $(patsubst %.c,$(OBJ_DIR)/%.o,$(SRCS))
模式规则对应改为:
makefile
$(OBJ_DIR)/%.o: %.c
@mkdir -p $(dir $@)
$(CC) $(CPPFLAGS) $(CFLAGS) -c $< -o $@
关键点在于 % 可以包含斜杠。以 objs/Module0/code1.o 为目标时:
text
目标模式:objs/%.o
匹配主干:Module0/code1
依赖模式:%.c
实际依赖:Module0/code1.c
所以一条规则就能覆盖所有模块,并把结构完整映射到 objs/。
6.5 一个多模块、单可执行程序版本
makefile
CC := gcc
CFLAGS := -Wall -Wextra -O2
MODULES := main $(sort $(patsubst %/,%,$(wildcard Module*/)))
OBJ_DIR := objs
TARGET := project
SRCS := $(foreach module,$(MODULES),$(wildcard $(module)/*.c))
OBJS := $(patsubst %.c,$(OBJ_DIR)/%.o,$(SRCS))
.PHONY: all clean print
all: $(TARGET)
$(TARGET): $(OBJS)
$(CC) $^ -o $@
$(OBJ_DIR)/%.o: %.c
@mkdir -p $(dir $@)
$(CC) $(CFLAGS) -c $< -o $@
clean:
$(RM) -r $(OBJ_DIR) $(TARGET)
print:
@echo "MODULES=$(MODULES)"
@echo "SRCS=$(SRCS)"
@echo "OBJS=$(OBJS)"
执行:
bash
make print
make -j4
./project
如果新增 Module2/code5.c,下次解析 Makefile 时它会自动进入 SRCS 和 OBJS,不需要手动修改文件清单。
七、场景五:引入头文件,并补齐依赖跟踪
当 main/main.c 需要包含多个模块头文件时:
c
#include "code1.h"
#include "code20.h"
编译器必须知道去哪些目录搜索。可以从模块列表生成 -I 参数:
makefile
CPPFLAGS := $(addprefix -I,$(MODULES))
如果:
text
MODULES = main Module0 Module1
则:
text
CPPFLAGS = -Imain -IModule0 -IModule1
编译规则变为:
makefile
$(OBJ_DIR)/%.o: %.c
@mkdir -p $(dir $@)
$(CC) $(CPPFLAGS) $(CFLAGS) -c $< -o $@
7.1 -I 只解决搜索路径
一个很容易遗漏的事实是:
编译器能找到头文件,不代表 make 已经知道对象文件依赖这个头文件。
如果规则只有:
makefile
objs/Module0/code1.o: Module0/code1.c
那么修改 Module0/code1.h 后,make 只看见 code1.c 没变,可能错误地复用旧对象文件。

7.2 用 -MMD -MP 自动生成依赖文件
GCC 和 Clang 可以在编译时生成 .d 文件:
makefile
DEPFLAGS = -MMD -MP -MF $(patsubst %.o,%.d,$@)
$(OBJ_DIR)/%.o: %.c
@mkdir -p $(dir $@)
$(CC) $(CPPFLAGS) $(CFLAGS) $(DEPFLAGS) -c $< -o $@
例如编译 objs/Module0/code1.o 时,同时生成:
text
objs/Module0/code1.d
其中会记录近似内容:
makefile
objs/Module0/code1.o: Module0/code1.c Module0/code1.h common/config.h
于是头文件变化能沿依赖图传播到正确的对象文件。
7.3 把生成的 .d 重新包含进 Makefile
先计算依赖文件列表:
makefile
DEPS := $(OBJS:.o=.d)
再在 Makefile 末尾加入:
makefile
-include $(DEPS)
前面的减号表示:文件暂时不存在时不要报错。第一次构建前还没有任何 .d,这是正常状态;完成编译后,后续 make 就会读取这些依赖描述。
-MP 会为头文件生成空目标,避免删除旧头文件后因 .d 中残留路径而直接报"没有规则可生成该目标"。
7.4 为什么 DEPFLAGS 使用 =
再次观察:
makefile
DEPFLAGS = -MMD -MP -MF $(patsubst %.o,%.d,$@)
这里需要当前规则的 $@。如果写成:
makefile
DEPFLAGS := -MMD -MP -MF $(patsubst %.o,%.d,$@)
读取 Makefile 时 $@ 为空,-MF 后面的路径会计算错误。这个例子正好说明:
:=并非永远优于=;求值时机必须与变量所依赖的上下文一致。
7.5 头文件保护仍然不能省
每个头文件应使用:
c
#pragma once
void fun1(void);
或者传统 include guard:
c
#ifndef MODULE0_CODE1_H
#define MODULE0_CODE1_H
void fun1(void);
#endif
头文件保护解决"同一个翻译单元重复包含"的问题;-I 解决"到哪里查找";.d 解决"哪些目标依赖它"。三者职责不同。
八、场景六:多 Makefile、多可执行程序
如果每个模块都能独立测试或独立发布,可以让它们各自拥有 Makefile:
text
.
├── Makefile
├── Module0
│ ├── Makefile
│ ├── main.c
│ ├── code1.c
│ └── code1.h
├── Module1
│ ├── Makefile
│ ├── main.c
│ ├── code2.c
│ └── code2.h
└── bin
顶层 Makefile 不再知道每个源文件的细节,只负责:
- 发现或声明子模块;
- 决定模块之间的构建顺序;
- 调用子 Makefile;
- 汇总清理、测试和发布动作。
子 Makefile 只负责本模块:
- 收集本地源文件;
- 编译本地对象;
- 生成本模块可执行程序;
- 清理或发布本模块产物。

8.1 子模块 Makefile
makefile
CC := gcc
CFLAGS := -Wall -Wextra -O2
TARGET ?= project
SRCS := $(wildcard *.c)
OBJS := $(SRCS:.c=.o)
.PHONY: all clean publish
all: $(TARGET)
$(TARGET): $(OBJS)
$(CC) $^ -o $@
%.o: %.c
$(CC) $(CFLAGS) -c $< -o $@
clean:
$(RM) $(OBJS) $(TARGET)
publish: $(TARGET)
@test -n "$(DEST_DIR)"
@mkdir -p "$(DEST_DIR)"
cp "$(TARGET)" "$(DEST_DIR)/"
顶层可以传入不同目标名:
bash
make -C Module0 TARGET=project0
make -C Module1 TARGET=project1
发布时使用 cp 而不是 mv,可以保留模块本地构建结果,重复执行更可预测。
8.2 顶层 Makefile:把模块本身变成目标
makefile
MODULES := $(sort $(patsubst %/,%,$(wildcard Module*/)))
.PHONY: all clean publish $(MODULES)
all: $(MODULES)
$(MODULES):
$(MAKE) -C $@ TARGET=project$(patsubst Module%,%,$@)
clean:
@for module in $(MODULES); do \
$(MAKE) -C $$module clean || exit $$?; \
done
publish: all
@mkdir -p bin
@for module in $(MODULES); do \
index=$${module#Module}; \
$(MAKE) -C $$module publish TARGET=project$$index DEST_DIR=$(abspath bin) || exit $$?; \
done
核心构建规则不是把所有模块塞进一个循环,而是:
makefile
all: $(MODULES)
$(MODULES):
$(MAKE) -C $@
这样每个模块都是独立目标,执行 make -j4 时,make 才有机会并行调度多个模块。
8.3 为什么必须使用 $(MAKE),不要写死 make
递归构建应写:
makefile
$(MAKE) -C $@
而不是:
makefile
make -C $@
$(MAKE) 是 GNU make 为递归调用提供的专用变量,它能正确传递命令行选项、变量覆盖、MAKEFLAGS 和并行构建所需的信息。
-C Module0 表示进入该目录后读取 Makefile,比:
makefile
cd Module0 && $(MAKE)
更直接,也更容易阅读日志。
8.4 shell 循环的两个局限
清理、发布这类聚合动作使用循环通常可以接受,但把主构建也写成单个循环有两个问题:
- 整个循环在 make 看来只是一条配方,模块之间的依赖关系不可见;
- make 无法跨模块充分并行,错误处理也只能靠 shell 手动维护。
如果模块之间有顺序关系,应直接写到依赖图中:
makefile
Module1: Module0
这表示 Module1 必须在 Module0 成功后再构建。执行并行构建时,make 仍能在不违反依赖的前提下调度其他独立模块。
8.5 多 Makefile 的代价
递归 make 能建立清晰的模块边界,但也会把整体依赖图切成多份。跨模块头文件或库依赖如果只存在于开发者脑中,增量构建就可能不完整。
因此要明确:
- 模块输入和输出是什么;
- 哪些模块必须先构建;
- 头文件或库如何暴露;
- 顶层是否负责统一配置;
- 清理和发布是否可重复执行;
- 并行构建时是否存在共享目录竞争。
模块自治是优点,依赖隐藏是风险。
九、综合实战:一份可直接复用的工程化 Makefile
下面给出一个"多模块、单可执行程序、对象与二进制分离、自动头文件依赖"的完整版本。
项目结构:
text
.
├── main
│ └── main.c
├── Module0
│ ├── code1.c
│ └── code1.h
├── Module1
│ ├── code2.c
│ └── code2.h
└── Makefile
Makefile:
makefile
CC := gcc
TARGET_NAME := project
OBJ_DIR := objs
BIN_DIR := bin
TARGET := $(BIN_DIR)/$(TARGET_NAME)
MODULES := main $(sort $(patsubst %/,%,$(wildcard Module*/)))
SRCS := $(foreach module,$(MODULES),$(wildcard $(module)/*.c))
OBJS := $(patsubst %.c,$(OBJ_DIR)/%.o,$(SRCS))
DEPS := $(OBJS:.o=.d)
CPPFLAGS := $(addprefix -I,$(MODULES))
CFLAGS := -Wall -Wextra -O2
LDFLAGS :=
LDLIBS :=
DEPFLAGS = -MMD -MP -MF $(patsubst %.o,%.d,$@)
.PHONY: all clean print rebuild
all: $(TARGET)
$(TARGET): $(OBJS) | $(BIN_DIR)
$(CC) $(LDFLAGS) $^ $(LDLIBS) -o $@
@echo "linked: $@"
$(OBJ_DIR)/%.o: %.c
@mkdir -p $(dir $@)
$(CC) $(CPPFLAGS) $(CFLAGS) $(DEPFLAGS) -c $< -o $@
@echo "compiled: $< -> $@"
$(BIN_DIR):
@mkdir -p $@
clean:
$(RM) -r $(OBJ_DIR) $(BIN_DIR)
rebuild: clean
$(MAKE) all
print:
@echo "CC = $(CC)"
@echo "MODULES = $(MODULES)"
@echo "SRCS = $(SRCS)"
@echo "OBJS = $(OBJS)"
@echo "DEPS = $(DEPS)"
@echo "CPPFLAGS = $(CPPFLAGS)"
@echo "CFLAGS = $(CFLAGS)"
@echo "TARGET = $(TARGET)"
-include $(DEPS)
9.1 这份 Makefile 的执行链
第一次执行:
bash
make
make 的逻辑近似为:
text
读取 MODULES
↓
收集所有模块中的 .c
↓
映射成 objs/.../*.o
↓
逐个创建目标父目录并编译
↓
同时生成 objs/.../*.d
↓
创建 bin/
↓
链接为 bin/project
第二次执行 make,如果没有文件变化,所有目标都应保持最新。
修改某个 .c:
bash
touch Module0/code1.c
make
只重编对应对象并重新链接。
修改被多个源文件包含的头文件:
bash
touch Module0/code1.h
make
.d 文件会让所有真正依赖它的对象进入重建链路。
9.2 常用命令
bash
# 默认构建
make
# 并行构建
make -j$(nproc)
# 查看展开后的关键变量
make print
# 清理产物
make clean
# 完整重建
make rebuild
# 临时覆盖编译器和优化级别
make CC=clang CFLAGS="-Wall -Wextra -O0 -g"
命令行变量通常比 Makefile 中的普通赋值优先,因此可以在不修改文件的情况下切换编译器或构建选项。
9.3 一个 Debug / Release 扩展示例
makefile
BUILD_TYPE ?= release
ifeq ($(BUILD_TYPE),debug)
CFLAGS := -Wall -Wextra -O0 -g
else ifeq ($(BUILD_TYPE),release)
CFLAGS := -Wall -Wextra -O2 -DNDEBUG
else
$(error unknown BUILD_TYPE: $(BUILD_TYPE))
endif
使用:
bash
make BUILD_TYPE=debug
make BUILD_TYPE=release
更严格的工程会把不同配置放入不同对象目录,例如 objs/debug 与 objs/release,避免切换参数后错误复用旧对象文件。
9.4 一条值得坚持的设计原则
Makefile 中每个中间产物都应该能回答:
- 它由哪些输入生成?
- 用哪条规则生成?
- 输出路径是否唯一?
- 输入变化后,make 是否一定能发现?
- 清理目标是否只删除构建系统拥有的产物?
如果这五个问题都能准确回答,构建通常就具备可重复、可增量和可维护的基础。
十、常见误区、高频面试题与练习
10.1 常见误区
误区一:Makefile 只是命令脚本
Makefile 的核心是目标和依赖组成的图。配方只是"目标过期时怎样更新它"。
误区二:= 与 := 只是两种等价写法
= 在引用时递归展开,:= 在定义时立即展开。它们会影响变量看到的值、函数执行次数和自动变量是否可用。
误区三:模式规则应该写成 *.o: %.c
* 是常见 shell 通配符,make 模式规则使用 %。正确形式是 %.o: %.c。
误区四:wildcard 没匹配到文件时会保留原模式
wildcard 没有匹配项时返回空;规则上下文中的未匹配通配符行为不同,不能混为一谈。
误区五:变量名写错只是少编一个文件
例如定义 SRCS,却写成:
makefile
OBJS := $(SRC:.c=.o)
SRC 未定义时通常展开为空,可能让目标"什么也不做"或直接链接失败。调试文件清单时应使用 make print。
误区六:-I 已经建立了头文件依赖
-I 只告诉编译器去哪里搜索。要让头文件变化触发重编,还需要显式依赖或 -MMD -MP 生成的 .d。
误区七:把所有对象文件放在一个平面目录即可
不同模块可能出现同名源文件。镜像目录结构能避免 config.o、main.o 等名称冲突。
误区八:创建目录后,把目录写成普通依赖最省事
目录时间戳变化可能引发无意义重建。只要求构建前存在时,使用顺序依赖 |。
误区九:递归构建里直接写 make 没区别
应使用 $(MAKE),它负责向子 make 传递选项、变量和并行构建上下文。
误区十:把所有模块放进一个 shell 循环,等价于声明多个目标
循环隐藏了模块级依赖图,也限制 make 的跨模块并行调度能力。主构建优先把每个模块声明为独立目标。
误区十一:配方行用空格缩进也可以
经典 Makefile 语法要求配方以 Tab 开头。编辑器把 Tab 自动转成空格时,常见报错是 missing separator。
误区十二:@ 让命令更快
配方前的 @ 只是禁止回显命令文本,不改变执行逻辑。调试时移除 @ 往往更容易看清真实命令。
误区十三:clean 不需要 .PHONY
目录中出现同名文件后,make 可能跳过配方。所有动作型目标都应明确声明。
误区十四:清理越彻底越好
清理规则应该精确删除构建产物,而不是依赖宽泛通配符或不受控变量递归删除目录。
误区十五:能完整编译一次,就说明依赖关系正确
完整构建只证明命令能执行;必须再测试增量构建、头文件变化、删除文件、并行构建和清理后重建。
10.2 高频面试题
问题 1:make 如何判断目标需要重新生成?
通常在目标不存在,或任意依赖比目标更新时执行该目标的配方。
问题 2:$@、$<、$^ 分别是什么?
$@ 是当前目标,$< 是第一个依赖,$^ 是全部依赖且会去除重复项。
问题 3:= 与 := 有什么区别?
前者保存未完全展开的文本,在引用时递归展开;后者在定义时立即展开并保存结果。
问题 4:wildcard 与 shell 的 * 有什么区别?
wildcard 是 make 函数,在 Makefile 展开阶段返回实际匹配文件;shell 通配符通常在配方交给 shell 后展开。
问题 5:foreach 会启动 shell 吗?
不会。它是 make 的文本展开函数,对空格分隔列表逐项绑定临时变量并展开第三个参数。
问题 6:patsubst 中的 % 表示什么?
表示匹配一个单词中的主干,并把该主干带入替换模式。
问题 7:为什么 clean 要声明为 .PHONY?
因为它代表动作而非文件,声明后可避免与同名文件冲突,并跳过无意义的隐式规则搜索。
问题 8:为什么用 $(dir $@) 创建目录?
它直接从当前目标路径计算父目录,能够自动适配多层模块结构。
问题 9:-I 和 -MMD -MP 的职责有何不同?
-I 提供头文件搜索路径;-MMD -MP 生成依赖描述,使头文件变化能触发正确重编。
问题 10:为什么要使用 -include $(DEPS)?
它把编译器生成的 .d 文件读回依赖图;前导减号允许第一次构建时文件尚不存在。
问题 11:顺序依赖 | 有什么用?
它保证某目标先被准备好,但其时间戳变化不会让当前目标仅因此过期,常用于输出目录。
问题 12:为什么递归 make 要写 $(MAKE) -C dir?
这样能正确传播 make 自身的选项、变量、并行 jobserver 与退出语义。
问题 13:多 Makefile 的优缺点是什么?
优点是模块可独立构建、职责边界清楚;缺点是整体依赖图被切分,跨模块依赖需要显式维护。
问题 14:怎样验证增量构建是否正确?
分别修改单个源文件、公共头文件、构建参数,观察重建范围;还要测试 make -j、删除中间文件和 make clean && make。
10.3 排查清单
遇到"没有编译""重复编译""链接不到"时,按下面顺序检查:
- 默认目标是不是预期的
all? SRCS是否真的包含新增源文件?OBJS的路径是否与模式规则目标一致?- 变量名是否有
SRC/SRCS、OBJ/OBJS之类拼写不一致? - 模式规则使用的是
%还是误写成*? - 配方行开头是否是真正的 Tab?
$@、$<、$^是否只在规则配方中使用?- 目标父目录是否在编译前创建?
- 目录是否应该作为顺序依赖?
CPPFLAGS中是否有正确的-I?- 是否生成并包含了
.d文件? - 链接命令中是否漏掉对象文件或库,库顺序是否正确?
- 伪目标是否全部写入
.PHONY? - 子模块是否使用
$(MAKE) -C? - shell 循环是否吞掉了子 make 的错误?
- 清理规则是否删除了错误路径或漏删旧产物?
- 切换 Debug/Release 后是否错误复用另一配置的对象文件?
- 并行构建时多个目标是否竞争同一个输出文件?
10.4 建议练习
练习一:观察增量构建
创建 main.c、a.c、b.c,完整构建后只修改 a.c,确认只有 a.o 与最终程序重新生成。
练习二:比较两种变量风味
分别用 = 和 := 定义包含 wildcard 的变量,在定义前后创建新文件,观察展开结果与执行时机。
练习三:补全函数流水线
给出 MODULES,独立写出 SRCS、OBJS、CPPFLAGS,再用 make print 验证每一步。
练习四:实现目录镜像
让 ModuleA/config.c 与 ModuleB/config.c 分别生成 objs/ModuleA/config.o 和 objs/ModuleB/config.o。
练习五:验证头文件依赖
先不使用 .d,修改公共头文件观察错误复用;再加入 -MMD -MP 与 -include,比较重建范围。
练习六:验证顺序依赖
分别把 bin/ 写成普通依赖和顺序依赖,改变目录时间戳后观察最终程序是否被重复链接。
练习七:构建多个模块
把每个模块声明为独立伪目标,执行 make -j4,再增加一个模块间顺序依赖。
练习八:设计 Debug / Release 隔离
让两种配置分别使用 objs/debug、bin/debug 与 objs/release、bin/release,确认切换配置不会复用错误产物。
练习九:制造并修复错误
依次制造 missing separator、变量名拼错、目标目录不存在、缺少 -I、头文件变化不重编,记录每种错误的现象与排查步骤。
10.5 参考资料
- 本篇课程原始 PDF:加餐 - Makefile进阶
- GNU make 手册:变量的两种主要展开风味
- GNU make 手册:wildcard 函数
- GNU make 手册:文本处理函数与 patsubst
- GNU make 手册:foreach 函数
- GNU make 手册:文件名函数与 addprefix、dir
- GNU make 手册:自动变量
- GNU make 手册:伪目标
- GNU make 手册:递归调用 make
总结
从单目录到多模块,Makefile 进阶的核心主线可以压缩为:
text
wildcard 扫描真实源文件
↓
foreach 跨模块汇总文件列表
↓
patsubst 把 .c 映射成 objs/.../*.o
↓
模式规则复用一条编译逻辑
↓
$< 提供输入,$@ 提供输出,$^ 提供全部链接依赖
↓
$(dir $@) 创建目标父目录
↓
addprefix 生成 -I 搜索路径
↓
-MMD -MP 生成 .d,补齐头文件依赖
↓
顶层 Makefile 用 $(MAKE) -C 编排子模块
↓
make 根据依赖图和时间戳完成增量、并行构建
请牢记:
- Makefile 的核心是依赖图,不是命令集合;
=与:=的本质区别是展开时机;- 文件清单、目录扫描等稳定结果通常适合
:=,依赖自动变量的表达式可能需要=; wildcard发现文件,foreach遍历模块,patsubst负责模式映射,addprefix批量添加前缀;- 模式规则使用
%,不是*; $@是目标,$<是第一个依赖,$^是全部依赖;- 对象目录镜像源码结构,可以避免跨模块同名文件冲突;
$(dir $@)让目录创建逻辑自动适配目标路径;-I只解决头文件搜索,.d文件才负责头文件依赖跟踪;- 输出目录适合使用顺序依赖,避免目录时间戳引发无意义重建;
- 递归构建应使用
$(MAKE) -C,并尽量把模块暴露为独立目标; - 正确的构建系统必须同时经得住完整构建、增量构建、并行构建、清理和配置切换。
如果本文对你有帮助,欢迎点赞、收藏。真正掌握 Makefile 进阶的标志,不是能背出几个函数,而是面对一个新的目录结构时,能够先画出依赖关系,再稳定地计算文件清单、设计唯一的产物路径,并让每一次源码或头文件变化只触发必要的构建链路。