理解 Make

August 1, 2026 · 访客

在我接触过的几乎每一个嵌入式和系统项目中都会出现 make 的身影——Yocto recipe、内核构建、U-Boot、各种小型固件仓库。它也是那种人们靠模仿使用了多年的工具:从上一个项目里复制一份 Makefile,却从来没有真正搞清楚它背后的模型。这篇文章就直接把这个模型讲清楚。

Make 是什么?

Make 是一个构建自动化工具:它读取一个文件(Makefile),其中描述了一组目标(target)、每个目标所依赖的先决条件(prerequisite),以及从这些先决条件生成该目标所需的配方(recipe,即 shell 命令)。有了这份描述,make 就能判断出哪些目标已经过期,并且只运行让它们重新变得最新所需要的配方——不多不少。

main: main.o utils.o
	gcc -o main main.o utils.o

main.o: main.c
	gcc -c main.c

运行 make 时,只有当 main.cmain.o 新(或者 main.o 还不存在)时才会构建 main.o,只有当 main.outils.o 发生变化时才会链接 main。没有任何东西会被不必要地重新构建。

它是为谁而生的?

Make 由 Stuart Feldman 于 1976 年 4 月在贝尔实验室(Bell Labs)编写。据他本人所说,直接的导火索是一位同事——yacc 的作者 Steve Johnson——花了一整个上午调试一个其实本身没有问题的程序;问题只是源码改动之后二进制文件没有重新链接而已。Feldman 因此获得了 2003 年的 ACM Software System Award。那个下午诞生的这个工具——把依赖关系记录一次,剩下的交给机器去判断需要重做什么——后来成为软件工具领域被复制得最多的理念之一。如今几乎人人都在用的 GNU Make,是 GNU 项目对它的一个自由软件重新实现 (1) (5) (6)。

在实践中,任何需要从更小的部分组装出一个非平凡产物的人都用得上它——无论是编译出的二进制文件、一份文档,还是一批生成出来的文件——在这种场景下,每次改动都从零重新构建整个东西会很浪费,也会很慢。这涵盖了 C/C++ 项目、内核和引导加载程序的构建、LaTeX 文档,以及大量与编译代码毫无关系的临时自动化任务。

什么时候该用它?

当以下三件事同时成立时,Make 就物有所值:

对于少数几个互不依赖的文件,一个 shell 脚本往往更简单、也更诚实。当"这东西是不是真的需要重新构建?"变成一个值得自动回答、而不是靠手动判断的问题时,Make 才真正开始物有所值。

它在工具链中处于什么位置?

Make 通常位于编译器之上一层,位于人类实际运行的东西之下一层。在一个典型的 C 项目中:源文件 →(编译器,由 Make 驱动)→ 目标文件 →(链接器,由 Make 驱动)→ 最终的二进制文件。Make 本身不编译也不链接任何东西——它只是在每条配方中以 shell 命令的形式,调用真正完成这些工作的工具。

它也往往位于更高层级的构建配置工具之下。例如 CMake、Autotools 以及 Yocto 的 BitBake,与其说它们取代了 Make,不如说它们生成 Makefile(或者驱动一个等价物)作为自己真正的执行后端。所以即便一个项目从不直接向你展示 Makefile,它也常常在流水线的某处运行着一个。

它为什么被设计成这样?

其核心设计选择——通过比较文件修改时间来判断哪些内容已经过期——正是 Make 在大型代码树上依然快速的原因:检查一个时间戳的代价很低,因此在一处单行改动之后重新构建一个庞大的项目,几乎可以跳过所有内容,同时依然保持正确——前提是提供给它的依赖图是准确的

而最后这个前提条件,正是人们遇到的几乎所有"Make 坏了"这类抱怨的根源,值得好好琢磨一下:Make 的正确性,永远取决于告诉它的那张依赖图有多正确。如果一个 .c 文件通过 #include 引入了一个头文件,而这个头文件却没有被列为对应 .o 文件的先决条件,那么修改这个头文件并不会触发重新构建——这并不是因为 Make 犯糊涂了,而是因为从来没有人告诉过它这个依赖关系的存在。这正是为什么真实世界中的 Makefile 几乎总是搭配一个由编译器生成依赖关系的步骤(在 GCC/Clang 上是 -MMD -MP),而不是手写头文件列表——编译器早已知道真实的 include 关系图,所以它才是唯一可靠的信息来源 (4)。

它到底是怎么工作的?

目标、先决条件、配方。 基本单元是:

target: prerequisite1 prerequisite2
	recipe line 1
	recipe line 2

配方行必须用**制表符(tab)**缩进,而不是空格——这是一个著名的历史遗留问题,至今仍然会让人摔跟头。

自动变量能减少重复:

%.o: %.c
	gcc -c $< -o $@

$@ 表示目标,$< 表示第一个先决条件——仅凭这一条模式规则,就能从对应的 .c 文件构建出每一个 .o,而不需要每个文件都单独写一行。

.PHONY 目标用来标记那些不对应真实文件的名字——比如 cleanalltest——这样即使碰巧存在一个同名文件,Make 也不会被搞糊涂,而是始终执行配方,而不是去检查时间戳: (2)

.PHONY: clean
clean:
	rm -f *.o main

变量能避免到处重复写标志位,也让跨项目复用更容易:

CC = gcc
CFLAGS = -Wall -O2

main.o: main.c
	$(CC) $(CFLAGS) -c main.c

自动依赖生成弥补了上面提到的那个缺口:

CFLAGS = -Wall -O2 -MMD -MP
-include $(wildcard *.d)

%.o: %.c
	$(CC) $(CFLAGS) -c $<

-MMD 让编译器为每个目标文件生成一个 .d 文件,列出该次编译实际引入的每一个头文件。-include 把这些文件重新纳入 Makefile,这样依赖图就能随着头文件的变化保持准确——不需要任何人手动维护它 (3)。

结语

Make 看起来像是"怪癖"的大部分特点——比较时间戳、坚持要用 tab、缺失的依赖会悄无声息地失败而不是大声报错——都源自 1976 年做出的一个设计决策:保持模型简单、检查成本低廉,并信任 Makefile 准确描述了现实。一旦用这个视角去看,大多数"Make 表现得很诡异"的时刻,最终都会归结为"我给它的依赖图并不完整"——而这是一件容易修复得多的事情。

相关阅读

参考资料

  1. Feldman, S. I. (1979). Make — A Program for Maintaining Computer Programs. Software: Practice and Experience, 9(4), 255–265. — Feldman 本人介绍 Make 的论文,其中包含了最初的动机。
  2. GNU Make Manual — 关于 GNU Make 语法和语义的完整权威参考文档。
  3. GNU Make Manual: Automatic Prerequisites — 维护者亲自讲解的 -MMD/-include 模式。
  4. GCC: Preprocessor Options (-M family)-MMD-MP 在底层究竟做了什么。
  5. Make (software) — Wikipedia — Make 各实现(BSD Make、GNU Make、NMAKE 等)的一般历史与传承脉络。
  6. Stuart Feldman — Wikipedia — 关于 Feldman 在贝尔实验室的工作以及 2003 年 ACM Software System Award 的背景资料。

评论