【Makefile 进阶】从自动发现源文件、模式规则到目录分离与多模块递归构建

🔥 星光编译者 · 个人主页

📚 学习专栏: 《C/C++ 成长笔记》 · 《Linux 实践手册》 · 《数据结构与算法》

🌄 向云端飞扬,编译属于自己的代码星河。


☕ 写在开篇

  你好,这里是 星光编译者

  这里记录我在 C/C++、Linux、数据结构与算法 学习中遇到的真实问题、亲手验证过的代码,以及那些容易被忽略的实现细节。

  比起简单罗列结论,我更愿意从问题出发,把一个知识点的来由讲清楚,把"为什么会这样"和"应该怎样解决"说明白,让每一次踩坑都沉淀成可以复用的经验。

  如果这篇记录能帮你少绕一点路,或让某个模糊的地方忽然变得清晰,那么这次分享便有了意义。愿我们在一次次阅读、编译与调试中稳步向前,慢慢搭起属于自己的技术世界。


🔥 本文定位:本文以一个不断扩大的 C 项目为主线,从"单目录、多源文件"出发,逐步演进到"源码与产物分离、多模块、头文件搜索、多 Makefile、多可执行程序"。重点不是背诵函数,而是理解 Makefile 怎样把目录结构转换成依赖图。

💡 学习目标 :掌握 wildcardforeachpatsubstaddprefix,区分 =:=,会写模式规则和自动变量,能把 .o.d、可执行程序放入独立目录,并知道顶层 Makefile 应怎样安全地驱动子 Makefile。

📌 阅读说明 :示例以 GNU make 和 GCC/Clang 风格命令为基础,默认运行在 Linux 或类 Unix 环境。Makefile 的配方行必须以 Tab 开头;本文代码块中的配方行均按这一规则书写。


文章目录


一、先建立全局视角:Makefile 的本质是依赖图

很多人第一次写 Makefile,会把它理解成"把几条 gcc 命令放进文件里"。这样确实能工作,却解释不了 make 最有价值的能力:

  1. 为什么第二次执行 make 时,它会提示没有目标需要更新?
  2. 为什么只修改 main.c,通常只重新编译 main.o
  3. 为什么头文件变了,有时所有相关源文件都应该重新编译?
  4. 为什么 cleanall 这类名字需要声明为伪目标?
  5. 为什么多模块项目可以并行构建,却不应该随便塞进一个 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.cmain.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) 表示最终程序依赖全部对象文件;
  • allclean 是动作名,不是真实文件,因此声明为 .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 工程中该怎样选

对文件清单、wildcardshell 结果,一般优先使用 :=

makefile 复制代码
SRCS := $(wildcard *.c)
OBJS := $(SRCS:.c=.o)

原因有两个:

  1. 目录扫描只需要发生一次,不必每次引用 SRCS 都重新执行;
  2. 变量的结果更稳定,更容易调试。

需要延迟到规则执行阶段、并依赖自动变量时,= 反而有用:

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:动作目标必须明确声明

cleanalltestprint 这些名称通常不会生成同名文件:

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_DIRBIN_DIR 使用明确、受控的值。不要让清理目标依赖未经验证的外部路径,也不要把项目根目录、主目录或空变量拼成递归删除目标。


六、场景三与四:多模块、单可执行程序

现在把项目拆成多个目录:

text 复制代码
.
├── main
│   └── main.c
├── Module0
│   ├── code1.c
│   └── code2.c
├── Module1
│   ├── code3.c
│   └── code4.c
└── Makefile

最终仍然只生成一个 project,但源文件不再集中在当前目录。问题变成:

  1. 怎样获得所有模块名称?
  2. 怎样逐目录扫描 .c
  3. 怎样把对象文件统一放入 objs/,同时保留目录层级?
  4. 怎样保证链接目标能拿到全部对象文件?

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 时它会自动进入 SRCSOBJS,不需要手动修改文件清单。


七、场景五:引入头文件,并补齐依赖跟踪

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 循环的两个局限

清理、发布这类聚合动作使用循环通常可以接受,但把主构建也写成单个循环有两个问题:

  1. 整个循环在 make 看来只是一条配方,模块之间的依赖关系不可见;
  2. 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/debugobjs/release,避免切换参数后错误复用旧对象文件。

9.4 一条值得坚持的设计原则

Makefile 中每个中间产物都应该能回答:

  1. 它由哪些输入生成?
  2. 用哪条规则生成?
  3. 输出路径是否唯一?
  4. 输入变化后,make 是否一定能发现?
  5. 清理目标是否只删除构建系统拥有的产物?

如果这五个问题都能准确回答,构建通常就具备可重复、可增量和可维护的基础。


十、常见误区、高频面试题与练习

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.omain.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 排查清单

遇到"没有编译""重复编译""链接不到"时,按下面顺序检查:

  1. 默认目标是不是预期的 all
  2. SRCS 是否真的包含新增源文件?
  3. OBJS 的路径是否与模式规则目标一致?
  4. 变量名是否有 SRC/SRCSOBJ/OBJS 之类拼写不一致?
  5. 模式规则使用的是 % 还是误写成 *
  6. 配方行开头是否是真正的 Tab?
  7. $@$<$^ 是否只在规则配方中使用?
  8. 目标父目录是否在编译前创建?
  9. 目录是否应该作为顺序依赖?
  10. CPPFLAGS 中是否有正确的 -I
  11. 是否生成并包含了 .d 文件?
  12. 链接命令中是否漏掉对象文件或库,库顺序是否正确?
  13. 伪目标是否全部写入 .PHONY
  14. 子模块是否使用 $(MAKE) -C
  15. shell 循环是否吞掉了子 make 的错误?
  16. 清理规则是否删除了错误路径或漏删旧产物?
  17. 切换 Debug/Release 后是否错误复用另一配置的对象文件?
  18. 并行构建时多个目标是否竞争同一个输出文件?

10.4 建议练习

练习一:观察增量构建

创建 main.ca.cb.c,完整构建后只修改 a.c,确认只有 a.o 与最终程序重新生成。

练习二:比较两种变量风味

分别用 =:= 定义包含 wildcard 的变量,在定义前后创建新文件,观察展开结果与执行时机。

练习三:补全函数流水线

给出 MODULES,独立写出 SRCSOBJSCPPFLAGS,再用 make print 验证每一步。

练习四:实现目录镜像

ModuleA/config.cModuleB/config.c 分别生成 objs/ModuleA/config.oobjs/ModuleB/config.o

练习五:验证头文件依赖

先不使用 .d,修改公共头文件观察错误复用;再加入 -MMD -MP-include,比较重建范围。

练习六:验证顺序依赖

分别把 bin/ 写成普通依赖和顺序依赖,改变目录时间戳后观察最终程序是否被重复链接。

练习七:构建多个模块

把每个模块声明为独立伪目标,执行 make -j4,再增加一个模块间顺序依赖。

练习八:设计 Debug / Release 隔离

让两种配置分别使用 objs/debugbin/debugobjs/releasebin/release,确认切换配置不会复用错误产物。

练习九:制造并修复错误

依次制造 missing separator、变量名拼错、目标目录不存在、缺少 -I、头文件变化不重编,记录每种错误的现象与排查步骤。

10.5 参考资料


总结

从单目录到多模块,Makefile 进阶的核心主线可以压缩为:

text 复制代码
wildcard 扫描真实源文件
        ↓
foreach 跨模块汇总文件列表
        ↓
patsubst 把 .c 映射成 objs/.../*.o
        ↓
模式规则复用一条编译逻辑
        ↓
$< 提供输入,$@ 提供输出,$^ 提供全部链接依赖
        ↓
$(dir $@) 创建目标父目录
        ↓
addprefix 生成 -I 搜索路径
        ↓
-MMD -MP 生成 .d,补齐头文件依赖
        ↓
顶层 Makefile 用 $(MAKE) -C 编排子模块
        ↓
make 根据依赖图和时间戳完成增量、并行构建

请牢记:

  1. Makefile 的核心是依赖图,不是命令集合
  2. =:= 的本质区别是展开时机
  3. 文件清单、目录扫描等稳定结果通常适合 :=,依赖自动变量的表达式可能需要 =
  4. wildcard 发现文件,foreach 遍历模块,patsubst 负责模式映射,addprefix 批量添加前缀
  5. 模式规则使用 %,不是 *
  6. $@ 是目标,$< 是第一个依赖,$^ 是全部依赖
  7. 对象目录镜像源码结构,可以避免跨模块同名文件冲突
  8. $(dir $@) 让目录创建逻辑自动适配目标路径
  9. -I 只解决头文件搜索,.d 文件才负责头文件依赖跟踪
  10. 输出目录适合使用顺序依赖,避免目录时间戳引发无意义重建
  11. 递归构建应使用 $(MAKE) -C,并尽量把模块暴露为独立目标
  12. 正确的构建系统必须同时经得住完整构建、增量构建、并行构建、清理和配置切换

如果本文对你有帮助,欢迎点赞、收藏。真正掌握 Makefile 进阶的标志,不是能背出几个函数,而是面对一个新的目录结构时,能够先画出依赖关系,再稳定地计算文件清单、设计唯一的产物路径,并让每一次源码或头文件变化只触发必要的构建链路。

相关推荐
见叶之秋37 分钟前
【C++】C++ 核心进阶(一):泛型编程基石 —— 模板初阶与 STL 体系开篇
开发语言·c++
做运维的阿瑞37 分钟前
Linux ELF 文件
linux·运维·服务器
星辰徐哥1 小时前
鸿蒙PC平台 Gnote 笔记应用适配实战:从 Linux 到 鸿蒙PC 的 Electron 迁移
linux·笔记·electron·harmonyos·gnote
艾莉丝努力练剑1 小时前
【AI大模型接入SDK】Gemini模型接入知识体系
java·开发语言·网络·人工智能·网络协议·学习·http
传奇开心果编程1 小时前
【Rust入门知识点学与练】第13课:枚举 Enum
开发语言·学习·rust
zhanghaha13141 小时前
Python进阶教程:28_queue 队列模块 零基础超详细教程
java·开发语言·python
一直C1 小时前
Linux应用软件编程|嵌入式轻量级数据库SQLite3完整学习笔记(命令行+C语言API)
linux·sqlite
司小豆1 小时前
第七课:DeepSeek Harness 服务与依赖注入
java·服务器·开发语言·github·ai编程
神威难绷泪1 小时前
TCP并发服务器:多进程 多线程 IO多路复用(select poll epoll)
linux·ipc·tcp并发服务器