从时间戳到依赖图:广山哥教你读懂 Make 的六堂课

本书从历史、算法到工程实践,全方位解析 GNU Make 的语义:把规则看作 DAG、还原时间戳判定的三种过期来源、揭示隐式规则与变量展开的陷阱,并给出并行、递归与规模化的判据,帮助工程师判断在 2026 年该用 Make、还是把生成器与执行器拆开(CMake/Ninja),或直接用 Bazel 解决缓存与一致性问题。


《make 全解:从时间戳到依赖图》

作者:广山哥
整理:雨轩
完成时间:2026-11-01
版本:v1.0
全书约 6 万字,六章,附序言


序言:为什么还要读一本关于 make 的书

二〇二六年,一个平常的工作日,你在终端里敲下 make。

屏幕上可能只闪过一行 “已是最新”,也可能刷过几百行编译命令。无论哪种,你大概不会停下来想:这个程序第一次被运行是在一九七六年,那一年还没有 C 语言标准,没有 Git,没有互联网,写它的人用的是一个周末。

四十九年过去,make 依然在。Linux 内核用它(包在 Kbuild 里),几乎每个开源 C 库用它,每个 Linux 发行版的源码包里都躺着一份 Makefile,./configure && make && make install 这串咒语比大多数使用它的程序员年纪都大。ACM 在 2003 年给它的作者颁奖时写下的那句话至今没有过期:今天世界上大概没有哪个大型软件系统,没被 MAKE 的某个版本或后代处理过。

与此同时,几乎没有人真正读过它的手册。多数人是这样学会 make 的:抄一份别人的 Makefile,改几个变量名,能跑就停;出了问题,加 -n 看看,看不懂就删掉重写;再不行就换成 CMake,或者干脆写个 shell 脚本把所有文件都编一遍。

这不是懒惰,是信息结构的问题。make 的手册写得极其完整,却几乎不解释“为什么”。它告诉你 := 是立即展开、= 是递归展开,但不告诉你这两个符号背后是同一个问题的两个答案:一份构建描述,应该在什么时候变成具体的命令。它告诉你配方必须以 tab 开头,但不告诉你为什么是 tab 而不是别的,也不告诉你这个选择让多少人浪费了多少个小时。它告诉你 make 比较时间戳,但不告诉你这个决定在一九七六年是最优解,在二〇二六年是一个盲区。

这本书想补的,就是这一层。

三个问题

全书围绕三个问题展开,六章是它们的展开与收束。

第一个问题:make 到底在算什么。 这个问题听起来幼稚,但它决定了你能否预判 make 的行为。答案不是“它执行 Makefile 里的命令”,而是:它把你写的规则读成一张有向无环图,逐个比较目标与前置条件的修改时间,然后按图的顺序执行过期节点上的配方。理解这一点之后,很多困惑会自己消失:为什么 all 写在第一行却最后执行,为什么改了头文件有的文件重编有的不重编,为什么并行时偶尔会随机失败。

第二个问题:make 在什么条件下会安静地骗你。 这是全书最重要的问题,也是最容易被教程跳过的问题。make 不会猜测你的意图,它只忠实执行你画的图,包括图上的错误。你忘了声明一条头文件依赖,它就会给你一个“构建成功”的旧二进制;你的生成文件(代码生成器的产物)没有被纳入图,它就会在某次干净构建后消失,而在增量构建后依然存在。这类失败之所以危险,不在于它会报错,而在于它不报错。

第三个问题:它还该不该用。 这个问题不能靠情怀回答,也不能靠时髦回答。二〇〇〇年以后,构建系统分裂成两层:生成器(CMake、Meson、GN)负责描述工程、探测环境、生成构建文件;执行器(Make、Ninja、MSBuild)负责读文件、调度任务。make 输掉了生成器之争,稳住了执行器的一半。它没有被替代,而是被降级;被降级不等于被淘汰,但意味着你必须知道它现在站在哪一层,什么活还该交给它。

三条主线

六章之下有三条贯穿全书的主线,读者可以拿它们检验自己是否读懂了。

第一条:每一个“怪”设计,都能追溯到一九七六年的一个具体约束。 tab 缩进不是设计师的品味,是当时解析器实现方式的权宜;文本格式不是浪漫,是因为 Unix 的文化要求构建描述可打印、可 diff、可理解;时间戳判定不是偷懒,是因为在一台每秒几十万次运算的机器上,读一遍文件内容算哈希的成本高到不可接受,而 stat 只是一次系统调用。理解这些约束,你就不会再问“为什么它不改成更合理的样子”,而会问“如果我今天重新设计,我会付出什么代价”。

第二条:make 的正确性完全等价于你给它的图的正确性。 这句话在第二、三、五章反复出现。第一章里那个被浪费的上午,本质是一条缺失的边:源文件和可执行文件之间,没有任何东西记录“前者变了,后者要重做”。第二章的假阴性、第五章的递归 make 之害,本质都是图的不完整。make 的算法从来没有错过,错的是它的输入。

第三条:make 的遗产比 make 本身更重要。 声明式规则、增量判定、DAG 调度、配方即 shell、文本可调试——这五个设计被所有后继者继承,只是改造了实现方式。Ninja 把时间戳换成“命令哈希加时间戳”,把变量换成查表,把解析成本压到毫秒级;Bazel 把 mtime 换成内容哈希,把本地执行换成远程缓存。它们都在回答 make 提出的同一个问题:给定一堆文件和一些变换规则,怎样用最少的动作让输出与输入一致。

六章导读

第一章回到一九七六年四月的那个上午,讲 make 为什么被写出来、为什么一个周末的简化版本赢了、以及三个妥协怎样变成它的身份。这一章偏历史,但每一个历史细节都指向一个技术后果,不是轶事集。

第二章拆开 make 的核心算法:建图、判定过期、拓扑执行。这一章最需要耐心,也最值得耐心。读完它,你应该能对着任何一个 Makefile 说出“它会先做什么、后做什么、什么情况下什么都不做”。

第三章讲规则体系:显式规则、模式规则、隐式规则链,以及那一整套以点开头的特殊目标。这一章的核心论点是:规则写得越少,你对构建过程知道得越少;抽象省下的是字数,付出的是可观测性。

第四章讲变量与函数。很多人以为 Makefile 是配置文件,这一章要证明它其实是一门以文本替换为核心、以展开时机为语义的宏语言,$(eval) 让它能在解析期生成规则。理解了这一章,你看 Makefile 的眼光会从“配置”变成“程序”。

第五章讲并行与规模:-j 到底并行了什么、头文件依赖怎么自动生成、递归 make 为什么在结构上有缺陷、非递归 make 要付出什么纪律成本。这一章最贴近工程现场,也最容易引起争论。

第六章回答那个外部问题:make 在二〇二六年站在哪一层,和谁相邻,什么活还该交给它。它给出判据而不是口号,并且同时反对“make 已死”和“make 万能”两种说法。

每一章的最后都有一节“边界与反证”,专门写本章主张在什么条件下不成立。这不是谦逊的姿态,是写作纪律:一本只讲优点的技术书,读者无法用它做判断。

三条阅读路径

按你带着什么问题来,读法可以不同。

只想让项目编起来。 读第 3 章的 3.1 到 3.3,加第 5 章的 5.3(依赖自动生成),够用。遇到“改了头文件没反应”直接翻 5.3 拿方案;想知道判定为什么没触发、为什么会静默成功,再回来看 2.2 与 2.6。遇到“并行时随机失败”翻 5.2。其余章节可以完全跳过。

被 make 坑过,想知道坑从哪来。 按顺序读第 1、2 章,再跳到第 3 章的 3.6 与第 5 章的 5.4。第 1 章解释历史债务,第 2 章给你判断工具,后面两节是具体的坑。这条路径读完,你大概能在三十秒内判断一次构建出错是图的问题还是配方的问题。

在设计或选择构建系统。 从第 6 章读起,先看 6.7 的判据表,再回头补第 2 章与第 5 章——判据表里每一条理由都建立在那两章的机制上。第 3、4 章当参考书,需要时查。这条路径与前面两条相反,是结论先行。

这本书不是手册

需要说清楚的是,这本书不替代 info make。语法细节、函数全表、命令行选项,手册写得比我全,而且永远最新。这本书要做的是手册不做的事:解释设计动机、给出代价分析、把散落的坑串成一条因果链。

也不是一本“现代构建系统导论”。CMake、Ninja、Bazel 在第六章出现,只作为坐标,用来定位 make,不展开讲它们的用法。

更不是一本立场书。我不会劝你继续用 make,也不会劝你放弃它。构建工具的选择取决于项目规模、团队构成、目标平台、缓存需求,这些条件一变,答案就变。我能给的是判据,不是结论。

关于书中的实验

全书引用的终端输出,都在 GNU Make 4.4.1、gcc 14、Linux 环境下真实跑过,不是从别处抄来的示意。少数引用二手资料的地方(例如一个中文技术博客给出的 TensorFlow 构建耗时对比),文中会明确标注来源性质与不可复现性。

这些实验跑在一个隔离容器里,也就是文中间或出现的“沙箱”:Debian 13(trixie)的独立 rootfs,内核 6.6,进程树由 s6 监督,不是 Docker 镜像,也不是虚拟机。说这么细不是为了炫耀环境,是因为它影响复现:2.5 提到的“本沙箱的 overlay 文件系统不维护目录 mtime”,是该容器存储层的具体行为,你在 Docker 的 overlayfs 或本地 ext4 上大概率观察不到,那一节的结论因此只能靠 | 的语义与 $| 的取值来支撑,不能靠现象复现。其余实验只依赖 make 与 gcc 的语义,任何 Linux 都能跑出相同结论。

它每次重置都会清空临时目录与工作目录,所以部分输出保留了 /tmp/GMfifo2968 这类随机路径和当时的绝对路径。它们不影响结论,只是环境留下的指纹。读者在自己的机器上复跑,看到的数字会不同,相对关系应当一致。

实验的价值不只是“证明我说得对”。有几处实测结果与流行说法相反,例如 .NOTPARALLEL 带前置条件时的行为、cp -p 保留时间戳后到底是“永远重建”还是“永不重建”、CC ?= gcc 为什么最终得到 cc。这些地方我都按实测写,并说明了为什么直觉会出错。技术书最容易犯的错,是把上一本书里的结论抄下来,而上一本书也是抄的。这本书尽量不这么干。

一些约定

书里有几处约定,先说清楚,省得读者中途困惑。

未加限定时,“make”指 GNU make;提到 BSD make、Microsoft nmake、POSIX 规范时会明写。版本基线是 GNU Make 4.4.1,这是本书写作时各主流发行版能拿到的稳定版本,也是 4.3 引入 grouped targets、4.4 引入 .WAIT 之后的形态。更老的版本(3.81、3.82)在语义上差别不大,主要缺的是新特殊目标。

代码块里的缩进,配方行一律是真正的制表符,不是若干空格。复制粘贴时请确认编辑器没有把 tab 转成空格,否则你会得到一个和书中完全一致的报错:缺失分隔符。这句话本身也是本书要讨论的对象之一:一个五十岁的语法决定,今天仍在每天制造同样的困惑。

三个核心术语在全书保持固定含义:目标(target)是要生成的产物或动作名;前置条件(prerequisite)是规则右侧的输入文件;配方(recipe)是执行动作的 shell 命令行。三者合起来叫规则。读到后文若发现某个词似乎换了意思,那是笔误,不是术语扩展。

最后,命令行示例以 sh 为准,不假设 bash 才有扩展。make 的配方默认交给 /bin/sh,这一点在容器与嵌入式环境里经常成为“本地能跑、服务器不能跑”的真正原因。

致谢

这本书写给两类人。一类是被 Makefile 坑过、想知道坑从哪来的人;一类是正在设计或选择构建系统、需要知道前人踩过什么的人。如果你只想让项目编起来,手册加上几份好模板就够了,不必读这本书。如果你想在下一次构建出错时,能在三十秒内判断问题出在图上还是在配方上,那这本书值得你花几个晚上。

还要感谢那些把细节写下来的人:Stuart Feldman 关于那个上午的回忆,Peter Miller 关于递归 make 的论文,GNU make 手册里那些被反复引用却很少被读完的段落。一本书的价值,一半在于它整理了什么,另一半在于它让读者回去读原始材料。

最后一句留给 make 自己。它没有堂皇的设计文档,没有路线图,没有社区治理,只有一份持续更新了三十多年的手册,和一套从一九七六年延续至今的语义。它不优雅,但它诚实:你给它什么图,它就做什么事。

雨轩于听雨轩 🌧️🏠


目录

  • 第 1 章 一个被浪费的上午:make 的诞生与分叉
    • 1.1 1976 年 4 月的那个上午
    • 1.2 从“依赖分析器”到一个周末的简化
    • 1.3 三个决定命运的设计选择
    • 1.4 家族分叉:make 是一门语言,不是一个程序
    • 1.5 2003 年 ACM 奖与一个反直觉事实
    • 1.6 边界与反证
  • 第 2 章 make 到底在算什么:DAG、时间戳与三种过期
    • 2.1 规则不是命令,是图的边
    • 2.2 过期的三种来源与判定的精确算法
    • 2.3 用 --debug=b 看见判定过程
    • 2.4 时间戳的四个陷阱
    • 2.5 order-only:把“必须先有”和“必须更新”分开
    • 2.6 边界与反证
  • 第 3 章 规则体系:从一条显式规则到 173 条内置规则
    • 3.1 显式规则的全部写法
    • 3.2 模式规则与词干
    • 3.3 隐式规则链:make 如何“猜”出你没写的规则
    • 3.4 特殊目标清单
    • 3.5 VPATH 与 vpath:文件定位的代价
    • 3.6 边界与反证
  • 第 4 章 变量与函数:Makefile 其实是一门函数式语言
    • 4.1 四种赋值符号背后只有两个问题
    • 4.2 自动变量是一组“每次调用重新计算”的局部变量
    • 4.3 文本函数:make 的字符串处理库
    • 4.4 call、foreach、eval:从文本替换到元编程
    • 4.5 变量优先级全景
    • 4.6 边界与反证
  • 第 5 章 并行、递归与规模:make 在十万行代码面前
    • 5.1 -j 到底并行了什么
    • 5.2 并行带来的新问题
    • 5.3 头文件依赖的自动生成
    • 5.4 递归 make 的结构性缺陷
    • 5.5 非递归 make 的工程做法
    • 5.6 make 的性能画像
    • 5.7 边界与反证
  • 第 6 章 之后:make 在 2026 年的位置
    • 6.1 分层:生成器与执行器
    • 6.2 Ninja 赢在哪、没赢在哪
    • 6.3 Bazel 的另一条路
    • 6.4 make 作为任务运行器
    • 6.5 make 真正不可替代的场景
    • 6.6 遗产:五个被所有后继者继承的设计
    • 6.7 边界与反证
    • 6.8 速查表
    • 6.9 索引:按词找小节
    • 6.10 全书结论

第 1 章 一个被浪费的上午:make 的诞生与分叉

一九七六年四月的一个上午,贝尔实验室的一间办公室门被推开。来人是 Steve Johnson,yacc 的作者,他刚浪费掉一整个上午去调试一个其实已经正确的程序。源代码里的 bug 前一天就改好了,可执行文件却还是旧的那一版:他读着新代码,观察着旧行为,把每一处不符合预期的输出都当成新线索,一路查到中午,最后发现被怀疑的对象根本没错,错的是他手上那个二进制。

这个上午之所以值得写进一本技术书的开头,不是因为它有趣闻价值,而是因为它是可复现的。我在 2026 年的沙箱里把它重演了一遍,用的是 gcc 14 和 GNU Make 4.4.1:把 util.c 里的 x * 3 改成 x * 2,然后只做一次链接,程序仍然打印 21。工具链没有任何一环报错,因为没有任何一环真正看过 util.c。半个世纪过去,机器换了四代,编译器换了几十个,这个失败模式一个字都没变。

本章要回答的是三个问题:当年那个上午到底发生了什么技术故障,为什么 Feldman 用一个周末写出的简化版本反而赢了,以及那次简化留下的三个妥协是怎样变成 make 今天的身份与包袱的。

1.1 1976 年 4 月的那个上午

要理解那次故障,必须先把 1976 年的一次编译拆开看。当时的 C 工具链是流水线式的四段:cpp 处理预处理指令,cc 把 C 源码编译成汇编或目标码,as 汇编,ld 链接。cc 命令自己只是调度器,cc -c util.c 产出一个 util.o,而 cc -o prog main.o util.o 这一步只调用链接器,链接器只读 .o 文件。它不读 .c,不知道 .o 是从哪份 .c 生成的,也不知道那份 .c 在上次编译之后被改过。

于是整条链上出现了一个空档:没有任何一个程序负责回答“这个 .o 是不是当前这份 .c 的产物”。这个判断落到了人身上。人要做的事听起来很轻——记住自己改过哪几个文件——但它有三个系统性的劣化条件。

第一是规模。1976 年的 Unix 内核已有数百个文件,PWB 环境下的商业软件项目动辄上百个源文件;cc *.o -o prog 这种写法本身就说明当时的目标文件数量已经多到要用通配符。第二是时间。判断依赖的是记忆,而记忆的可靠性随时间指数衰减,隔一个晚上就足以让“我改过 util.c”这件事从工作记忆里消失。第三是责任不可见:忘记重编译不会产生任何报错,它只会在下游表现为行为异常。

第三点是最致命的。工程上,沉默失败比响亮失败危险一个数量级:响亮失败会把你指向出错的位置,沉默失败会把你指向错误的方向。下面是这次故障在 2026 年的完整重演,util.c 里第一次写进去的是故意的错版本:

$ cc -c main.c && cc -c util.c && cc -o prog main.o util.o  
$ ./prog  
scale(7) = 21  
  
-- 修好 util.c,手工只做一次链接 --  
$ ls -l --time-style=+%H:%M:%S util.c util.o  
-rwxrwxrwx 1 root root   53 21:30:02 util.c  
-rwxrwxrwx 1 root root 1104 21:29:59 util.o  
$ cc -o prog main.o util.o  
$ ./prog   # 期望 14  
scale(7) = 21  

请注意 ls 那一行的两个时间:源文件比目标文件新三秒。判断所需的全部信息就摆在文件系统里,只是没有程序去读它。cc -o prog main.o util.o 这个命令从头到尾没有打开过 util.c,所以“文件已经改了”这个事实根本没有进入构建过程。补上 cc -c util.c 之后,同一份 main.c、同一个链接命令,输出立刻变成 14。

这里值得把错误的类型说清楚,因为它决定了补救的方向。这不是编译错误(编译器没有意见),不是链接错误(符号齐全),甚至也不是逻辑错误(代码是对的)。它是工件与源之间的对应关系被打破,也就是常说的陈旧产物。它的诊断特征极其恶劣:所有局部证据都指向“代码有问题”,而代码恰恰没问题。Johnson 之所以耗掉一个上午,不是因为他不熟练,而是因为他在正确的代码里找 bug,每读一遍都更确信“这里应该是对的”,然后开始怀疑编译器。

更麻烦的是,这个错误在概率上必然随项目规模上升。假设一个项目有 n 个文件,每人每天改 m 次,那么需要人脑维护的映射是 n 乘 m 量级,而人脑不落盘。1976 年之前,工程上的对策只有两条,都不好用。第一条是全量重编,写个 shell 循环把每个 .c 都编一遍再链接;这保证了正确性,但在 PDP-11/45 上重编一个几百文件的项目要几十分钟到几小时,于是没人愿意每次保存都跑一遍。第二条是只编改过的文件,这保证了速度,于是错误又回来了。

这就是 make 诞生前夜的真实处境:正确性和速度在人工模式下不可兼得,而两条路都通向同一个上午的浪费。 Feldman 自己在收录于《Unix 编程艺术》的回忆里承认,他在前一天晚上刚踩过同一个坑:同事冲进办公室时,他脑子里还留着昨晚的余温。这一点很重要——make 不是给同事做的工具,是给自己做的工具,这解释了它后面所有的取舍为什么都偏向“够用就好”,而不是“完备”。

把同一件事交给 make,只需要一个纯文本文件:

prog: main.o util.o  
	cc -o prog main.o util.o  
  
main.o: main.c util.h  
	cc -c main.c  
  
util.o: util.c util.h  
	cc -c util.c  
$ make clean && make  
rm -f prog main.o util.o  
cc -c main.c  
cc -c util.c  
cc -o prog main.o util.o  
$ ./prog  
scale(7) = 14  
$ sed -i 's/x \* 2/x * 5/' util.c  
$ make  
cc -c util.c  
cc -o prog main.o util.o  
$ ./prog  
scale(7) = 35  

真正变化的是最后两行:改了一个文件,只有一条 cc -c util.c 被执行,main.o 没有被重编。这不是聪明,只是记账——账本从人的记忆,搬到了一个可以被程序读的文件里。make 的核心思想到这段代码就讲完了,剩下的五章都在这三行规则上做加法。

1.2 从“依赖分析器”到一个周末的简化

据多方记载,Feldman 最初的构想并不是今天这个样子。他想写的是一个精心设计的依赖分析器:一个能真正理解文件之间关系、能给出更完整分析的专门工具。而实际落地的版本,只是他在那个周末写出来的一个小程序:读一个文本描述文件,从指定目标出发做一遍深度优先搜索。这个落差经常被讲成“简化带来了成功”的励志故事,但真正值得分析的是:是什么样的约束,使得简化成为唯一可能成功的选择。

先看 1976 年的硬件约束。PDP-11 是十六位机,用户地址空间六十四 KB(后来的分体指令与数据空间也只是各六十四 KB),贝尔实验室的主力机型是 PDP-11/45 与 PDP-11/70。这个数字意味着:整张依赖图不能常驻内存,图的节点必须用定长结构体数组加线性查找,不能用动态容器;递归深度有限,深度优先搜索得小心爆栈;任何抽象层(通用图库、通用字符串库)都是奢侈品,因为每一层都要占用宝贵的内存与编译时间。

再看存储。RK05 磁盘容量约二点四 MB,一次随机寻道在当时是毫秒级动作。这个数字直接否掉了“二进制依赖数据库”的设计:一个需要原地随机更新的索引,要自己定义磁盘格式、处理崩溃后的一致性、处理版本升级时的格式迁移,工作量远超一个周末,而且在磁盘只有两 MB 的情况下,维护成本换不回性能。反过来,文本文件可以一次性读进内存按行扫,处理失败的代价只是丢一次缓存——它天然幂等,随时可以全量重建。

第三是库。1976 年没有标准库里的哈希表,没有 STL,没有现成的图算法包。所有容器要手写,所有图算法要从头实现。于是“能用线性扫描就不建索引”不是懒惰,是算过账的理性。

第四是人的约束,而且是最硬的一条:一个人,一个周末。它决定了功能集合必须落在一个能一口气写完的闭环里。凡是需要写设计文档、需要多轮讨论、需要先做实验再定方案的想法,都被时间砍掉了。于是最终留下的功能清单短得惊人:读文本文件,按行建图,从命令行指定的目标自顶向下遍历,比较时间戳,执行配方。没有依赖推断,没有构建缓存,没有并行,没有自动生成。

为什么这些砍掉的东西没有让 make 变废?这里有三条机制,值得逐条说清楚,因为它们是 make 能活五十年的真正原因。

第一条,也是最重要的一条:把“分析”换成了“声明”。 依赖分析器要自己推断依赖,推断错了责任在工具;make 只负责接收人写下的依赖,接收错了责任在写 Makefile 的人。责任的转移让实现可以小到极致,同时让适用范围大到没有边界——它不再是一个 C 编译辅助工具,而是一个通用的文件更新工具。make 的抽象不是“如何编译”,而是“何时重建”,这个抽象一旦成立,用它来排版文档、生成代码、转换图片、跑测试都顺理成章。

第二条,极小核心加上可组合。 一条规则只有三样东西:目标、前置条件、配方。复杂项目的复杂度由规则的数量承担,而不是由语法承担;于是工具的代码量与项目规模无关。这一点在后来的五十年里反复被验证:make 处理十万行代码和一百行代码,内核逻辑不变,变的只是图的规模。

第三条,落地快导致用户早,用户早导致缺陷固化。 这个机制有正面也有负面。正面是正反馈:工具出来的第一周就有人用,用得顺手就传开,传开就成为默认。负面在下一节会看到——几周之内用户到了十来个,那时一个明显的设计缺陷已经不能再改了。

分发的路径同样关键。make 首次随 PWB/UNIX 1.0 分发。PWB 是贝尔实验室为大规模软件开发建的工作台环境,用户数以千计,装机量在七十年代后期快速增长。随后 make 进入 1979 年的第七版 Unix,Feldman 也在同年把它写成论文《Make—A Program for Maintaining Computer Programs》,发表在 Software: Practice and Experience。论文里那句话后来被引用得最多,意思是:描述文件真正定义的是依赖图,make 对这张图做深度优先搜索,以决定哪些工作是真的必要的。

论文里还有一个细节值得注意:它坦率地承认了这个模型的边界——描述文件里的依赖是人写的,写漏了,make 不会发现,也不会提醒。这不是疏忽,而是设计的一部分:make 只对自己收到的图负责。这条边界在半个世纪里造成的错误,数量上大概不输给它避免的错误,第二章会把它当作核心问题处理。论文致谢里,Feldman 专门感谢 Johnson 提出了这套“程序维护控制”的思路;一个上午的抱怨,最后在论文里被写成了一行致谢。

这里可以下一个反直觉的结论:make 能成为标准,和“只花了一个周末”这个约束正相关。 因为周末版足够小,小到任何人读完论文都能重写一遍——BSD、GNU、微软都重写了——而一个能被反复重写的工具,才会变成一个家族。

1.3 三个决定命运的设计选择

make 的语法与行为里,有三个选择是在 1976 年的约束下做出的,也是这本书要反复回到的三条线索。它们的结构完全一致:在当时拿不到的信息上,用一个便宜的代理量替代真实语义。代理量便宜、可观测、好解释,代价是它与真实语义之间永远留着一道缝,而 make 今天所有的怪癖,都是这道缝的形状。

1.3.1 文本格式:用结构换可读

第一个选择是把构建描述写成纯文本。1976 年可用的持久化手段只有两类:文本文件,以及自己定义的二进制文件。而文本文件的基础设施已经全部就位——ed 能改,diff 能比,grep 能搜,awk 能生成,SCCS 能存档。选文本,等于免费继承了整个 Unix 工具集的全部能力;选二进制,等于要自己重造一套读写、版本兼容与崩溃恢复。

Feldman 的解释被《Unix 编程艺术》原样记录:Makefile 是文本文件,而不是神奇编码的二进制文件,因为那是 Unix 的精神——可打印、可调试、可理解的东西。这句话常常被当作美学表述,其实它是一笔非常具体的工程账:文本让 Makefile 同时成为“给人看的文档”和“给机器读的输入”,而且这两种用途不需要任何转换工具。

这条选择的收益在五十年后依然每天都在兑现。Makefile 可以被程序生成——configure 脚本、CMake、Meson、模板引擎,全部靠“输出文本”这一条路;Makefile 可以被 diff,于是代码评审能看见构建逻辑的变更;Makefile 可以被人在没有 make 的机器上读懂,于是可移植性留了一条后门。本书后面所有关于“Makefile 即程序”的讨论,根都在这里。

这条收益还带出一个副产品。Makefile 既然是文本,它自身也可以成为别人构建的目标,于是“什么时候重新生成 Makefile”变成了一个独立问题:autoconf 生成的 Makefile 里通常写着 Makefile: Makefile.in config.status 这样的规则,让 make 去管理自己的源头。这套自指的写法勉强能工作,也是很多人第一次读 autoconf 产物时最困惑的几行。

代价同样是结构性的。文本格式意味着没有类型系统、没有作用域、没有引用检查:所有值都是字符串,所有运算都是文本替换。一个写错的变量名不会报错,它展开成空串;一个变量在被使用前可能被别的文件改写,因为命名空间是全局的。代价的集中表现是:Makefile 里“看起来对”和“实际对”之间,缺少编译器检查这一层,只剩下执行结果这一层。 第四章会看到,GNU make 后来用函数、eval、二次展开把表达力补回来了,但没有一种补法能把类型检查补回来。

1.3.2 tab 缩进:把语义放在不可见字符上

第二个选择更需要拆开因果链。make 的语法必须在一行文本里区分三类东西:变量赋值、规则声明、以及交给 shell 执行的配方。前两类有明确的分隔符(= 与 :),第三类没有任何符号可用——配方行本身可以是任意 shell 命令,不能要求它以特定关键字开头。于是唯一的办法是用“行首前缀”标记它。

Feldman 在《Unix 编程艺术》里把这条链讲得很直白,也讲得很坦率:当时 yacc 是新东西,lex 是更新的东西,他都没用过,正好拿这个项目练手;用 lex 写第一版词法规则时把自己绕晕了,于是退回到一个最笨的模式——换行之后跟一个 tab,就是命令;它跑通了,就留下来了;几周之后他有了十来个用户,大半是朋友,他不想搞坏自己已经装好的基础,于是再没改。

这条链上有三个环节值得注意。第一,这是词法层的妥协,不是语法层的设计。区分“行首是 tab”与“行首是空格”是一个上下文相关的问题,用 yacc 写很别扭,得在 lexer 里解决——1991 年 comp.compilers 上还有人专门讨论过这件事,结论是这种区分“比看起来更粗暴”,本质就是“以 tab 开头的行”和“不以 tab 开头的行”。第二,tab 因此不是缩进风格,而是 token。它不可见、不可数、会被编辑器吃掉、会在复制粘贴时被转换;它承担了语义,却不承担任何视觉提示。第三,这个缺陷被“早期用户基数”锁死了,用户量只有十几个的时候是技术债,用户量到几百万的时候就成了宪法。

GNU Make 4.4.1 在中文本地化下的报错是这样的,我特地保留了原始字节:

$ printf 'hello:\n    echo hi\n' > Makefile  
$ od -c Makefile | head -3  
0000000   h   e   l   l   o   :  \n                   e   c   h   o  
0000020   h   i  \n  
$ make  
Makefile:2: *** 缺失分隔符。 停止。  

od 的输出说明这一行开头是四个空格(\n 之后紧接着空格),而 make 只说“缺失分隔符”,退出码 2。它不会告诉你“这一行看起来像 tab,其实是空格”——因为对 make 来说,那一行根本不是配方行,而是某种它无法解释的其它文本,它报的是“我没在这里找到我认识的分隔符”,而不是“你少打了一个 tab”。错误信息与真实原因之间的这道折损,是 tab 设计留下的最持久的成本。

Eric S. Raymond 对这条设计的评价被引用了三十年:它是 Unix 历史上最糟糕的设计失误之一。这个判断成立,但要注意它的成立条件:它糟糕,不是因为它错了,而是因为它对了太久。Feldman 自己在事后明确说过这是个糟糕的主意;GNU make 3.82 起允许用 .RECIPEPREFIX 换掉这个前缀;各家编辑器和 IDE 都内置了 Makefile 模式的 tab 高亮。不过 .RECIPEPREFIX 只能换掉前缀字符,改不掉“前缀即 token”这件事:换成可见的 >,行首的箭头又和 shell 重定向的视觉习惯打架。这些补丁全都在承认同一件事:一个 1976 年的权宜,需要后代持续付费维护。

1.3.3 只比较时间戳:用元数据代理内容

第三个选择最隐蔽,因为它不体现在语法里,而体现在判定逻辑里。要判断一个目标是否过期,严格的做法是判断“目标是不是由当前版本的前置条件产生的”,这需要内容的身份标识:哈希,或者完整比较。而 make 只比较修改时间。

1976 年的成本账必须算清楚,否则这个选择看起来像偷懒。stat 是 O(1) 系统调用:从 inode 里取几个字段,读几十个字节。哈希的开销与文件字节数成正比:打开文件、读全文、按块计算。而且这不是一次性开销——make 每次运行都要对整张图做这个判断,一个几百文件的项目意味着每次 make 都要把全部源码读一遍。在 PDP-11 的磁盘带宽与 CPU 速度下,这个开销足以让人放弃使用这个工具。

更关键的是,1976 年没有可用的强校验和。sum 一类的弱校验对内容变化的敏感度有限,md5 要到 1992 年,sha1 要到 1995 年。也就是说,Feldman 面对的选项不是“快而弱”与“慢而强”,而是“快而可用”与“慢而不可用”。

于是他选了便宜的那个,把正确性的另一半责任交给用户:内容没变但想强制重建,用 touch;想忽略时间戳,用 -B;目标不是文件,用 .PHONY。这些开关都是后来补的,但它们的存在本身就说明:make 从第一天起就不判断内容,只判断元数据。

代价在 2026 年仍然按两种方向收取。假阳性方向是白干活:下面这段实测里,util.h 的内容一个字节都没变,只是被 touch 了一下,make 就把两个目标文件和可执行文件全重编了一遍。

$ md5sum util.h  
bda7e28cef50bf43948c53a6044a94d5  util.h  
$ touch util.h  
$ md5sum util.h  
bda7e28cef50bf43948c53a6044a94d5  util.h  
$ make   # 内容与刚才完全一致  
cc -c main.c  
cc -c util.c  
cc -o prog main.o util.o  

假阴性方向更危险:少干活,也就是漏建。cp -p 保留时间戳、rsync -t、文件系统时钟跳变、从未来时间戳恢复的备份,都会让 make 认为目标比源新,于是该重建的不重建,产出陈旧但安静。第二章会把这四种情形逐个跑出来,这里先记住结论:make 的增量是时间戳的增量,不是内容的增量。

把三个选择放在一起看,它们的形状惊人地一致。文本替代了结构(因为二进制的基础设施造不起),tab 替代了 token(因为词法规则写不动),mtime 替代了内容(因为哈希算不起)。每一次都是用一个便宜、可观测、可解释的代理量,换掉了一个昂贵、精确、当时拿不到的语义量。 这个策略让 make 在 1976 年跑了起来,也让它在 2026 年依然带着三处已知的裂缝运行。

1.4 家族分叉:make 是一门语言,不是一个程序

前三节讲的是一个程序的设计。但从 1979 年之后,更准确的说法是:make 是一门有很多方言的语言,而且这门语言的标准化程度低到必须逐方言确认。

起点是公共子集太小。POSIX 对 make 的规定只覆盖最基本的部分:显式规则、后缀规则、少数几个变量、少数几个特殊目标。GNU make 手册里专门有一节讲“不兼容与缺失特性”,开头就点明 POSIX.2 标准并不要求各变体的那些扩展。换句话说,标准只保证“如果你有 make,它至少支持这些”,不保证“你写的 Makefile 在哪都能跑”。这个缺口被三个方向填满了。

BSD make 的取向是把 make 做成一门带字符串处理能力的领域语言。它最鲜明的特征是变量修饰符:${SRCS:S/.c/.o/} 做替换,${SRCS:M*.c} 做过滤,${SRCS:H} 取目录,${SRCS:T} 取文件名。这套后缀式语法与 ed、sed 的血缘关系一眼可见。它还提供真正意义上的条件与循环——.if、.ifdef、.else、.endif、.for、.endfor、.include——以及 .ORDER、.WAIT、!= 这类用于控制顺序与取 shell 输出的指令。BSD make 手册的兼容性一节把话说得很干脆:基本 make 语法在各版本之间是兼容的,但特殊变量、变量修饰符与条件并不兼容。

GNU make 走的是另一条路:函数化。它把条件写成 ifeq/ifdef,把字符串处理写成一整套函数(subst、patsubst、filter、sort、foreach、call),把赋值细分成 :=、::=、:::=、?=、+= 五种展开时机不同的形式,还提供了 $(eval) 这种在解析期生成规则的元编程能力。它的内置库也是三个家族里最庞大的:实验 0 在 4.4.1 上测得 92 条内置变量定义条目与 173 条内置规则相关条目。GNU make 的取向是把 make 变成一门小型的函数式语言,代价是可移植性彻底让位——一段用 ifeq 写的 Makefile 拿到 BSD 系统上,ifeq 那一行会被当成一条畸形规则,直接报缺失分隔符。

Microsoft nmake 是第三种情况,也是最容易骗人的一种。它的语法外壳与 Unix make 高度相似:同样是 目标: 前置条件 加缩进配方,同样用 $(VAR) 引用宏。但它的条件与包含是预处理指令 !IFDEF、!IFEND、!INCLUDE,推理规则写成 .c.obj: 而不是模式规则,配方交给 cmd.exe 而不是 sh 执行。这意味着命令里的分号、反斜杠、引号、退出码语义全部不同。结论是:nmake 的危险不在于它差异大,而在于它看起来一样。把 GNU Makefile 直接搬过去,失败往往发生在配方内部的第三行,而不是在语法解析时。

工程上判断方言的办法其实很便宜:先看 make --version 的第一行,GNU 会自报家门并同时提供 MAKE_VERSION 变量,BSD 系多半自称 bmake,微软的版本串里带 Microsoft。更可靠的是找 Makefile 里的第一个方言特征:.if、:S/、!= 指向 BSD,ifeq、$(eval)、$(shell) 指向 GNU,!IFDEF、.c.obj: 指向 nmake。三秒钟的检查,能省掉半小时的“为什么我本地能跑”。

最后一层是 POSIX 的定位,这一点常被忽略,但它是理解整个家族的钥匙。make 在 POSIX 里属于“软件开发实用程序”这个选项组,系统是否提供它由实现自行声明,用户可以用 getconf POSIX2_SW_DEV 查询。这句话的含义有三层:第一,标准承认 make 属于开发工具,而不是系统运行的必需部件;第二,一个合规的 POSIX 系统可以完全不装 make;第三,即使装了,标准只要求那个很小的子集。

这三层含义合起来产生了一个后果链:可移植的 Makefile 子集小到写不了真实项目,于是真实项目要么绑定某一个方言,要么在 Makefile 之上再加一层生成器,把可移植性问题交给生成器解决。CMake 与 autoconf 的存在,本质上是 POSIX 没有标准化 make 的后果。 第六章会完整展开这条线。

1.5 2003 年 ACM 奖与一个反直觉事实

2003 年,ACM 把软件系统奖授予 Feldman,引文是这句话:

there is probably no large software system in the world today that has not been processed by a version or offspring of MAKE

意译过来是:今天世界上大概没有哪个大型软件系统,没被 MAKE 的某个版本或后代处理过。这句话写于 2003 年,这一点值得停一下——那已经是 Java 与 IDE 的时代,Ant 出来了三年,MSBuild 正在成型,构建工具看上去早就该换代了。

为什么这句话仍然成立?原因不在 make 好用,而在它所在的位置。任何大型系统的依赖链底部,几乎总有一层 make 在干活:发行版打包脚本是 Makefile,Linux 内核的构建是 Makefile 加 kbuild,configure 的产物是 Makefile,大量老代码库与嵌入式 SDK 的入口是 Makefile。这些 make 不是被用户选择的,是被上游决定的。

第二个原因是语义的继承。Ant 的 target 与 depends、Rake 的 task 与依赖列表、MSBuild 的 Target 与 DependsOnTargets,词汇换了,结构没换:一个目标、一组前置条件、一段执行动作,按依赖关系决定执行顺序。奖项承认的不是那个程序,而是那个三元结构。 这也是本书把 make 当作一个“模型”而不是一个“工具”来讲的理由。

第三个原因,也是本章最想让人记住的一个反直觉事实,藏在 make 自己的内部数据库里。在 GNU Make 4.4.1 上执行 make -p -f /dev/null,也就是“用一个空的 Makefile 打印内部数据库”,得到的是这样一组数字:

$ make -p -f /dev/null 2>/dev/null > db.txt  
$ grep -c '^[a-zA-Z_.].*:' db.txt  
92  
$ grep -cE '^%|^\.[a-zA-Z]' db.txt  
173  

一个空 Makefile,92 条内置变量定义,173 条内置规则相关条目。里面有你熟悉的 CC = cc、COMPILE.c = $(CC) $(CFLAGS) $(CPPFLAGS) $(TARGET_ARCH) -c、OUTPUT_OPTION = -o $@,还有一整套后缀规则与模式规则,描述着从 .c 到 .o、从 .o 到可执行文件、从 .y 到 .c、从 .p 到 .c 的无数条“如何从 X 得到 Y”。

这个规模会直接骗到人。在一个只有 foo.c、没有任何自定义规则的空 Makefile 里执行 make foo.o,命令是成功的:

$ make foo.o  
cc    -c -o foo.o foo.c  

屏幕上没有任何“我用了内置规则”的提示,只有一条看起来像你自己写的编译命令。初学者容易把它理解成 make 的智能,其实只是出厂预设被激活了。四十多年前那几条后缀规则,今天仍然在替几百万个项目做决定。

这就是本章标题里那个说法的依据:你以为自己写的 make 很小,其实它自带一整套世界观。 你写三行规则,工具带着几百条默认知识进场;你没写编译器怎么调,它知道;你没写 .o 从哪来,它知道。这既是“零配置”的恩惠,也是“行为不是你写的”的根源——第三章会看到,调试一个隐式规则触发的构建,比调试一条显式规则难一个数量级。

需要补一句修正:这套世界观不是 1976 年就有的。原始版本的隐式规则只有寥寥几条,绝大多数内置变量与模式规则是五十年里一层层长出来的。这恰好说明 make 的生存策略——核心不变,外壳不断加厚。它也解释了为什么这个工具今天既不可替代又难以理解:不可替代是因为核心足够小,难以理解是因为外壳已经厚到看不见核心。

1.6 边界与反证

前面五节把 make 讲成了一个“在约束下做出正确妥协”的故事。这一节要把这个故事拆掉一半。因为“make 是天才设计”是一个流行叙事,而这个叙事的每一根支柱都有裂缝。三个妥协至今仍在收利息,而且利息的支付人是每一个新接触 Makefile 的工程师。

tab 的利息:报错信息不指向 tab。 1.3.2 那段实测已经给出证据:四个空格的缩进,报错是“缺失分隔符”,退出码 2,全文没有出现 tab 这个词。这个折损不是本地化问题,英文原文同样只说 missing separator。新人第一次遇到它,平均要花十几分钟到半小时;而这类错误会通过复制粘贴、模板、CI 配置在团队里反复传播。一个有五十年历史的工具,最高频的错误提示无法指出最高频的错误原因,这不是天才设计的特征,这是技术债的特征。

时间戳的利息:两个方向都会错。 内容相同但 mtime 变了,make 白干活——1.3.3 的 touch 实测就是最小复现。内容变了但 mtime 没变,make 漏建,产出陈旧二进制且完全安静,cp -p、rsync -t、时钟回拨都能触发。这个缺陷不是理论问题:GNU Autoconf 手册专门警告过 cp -p 与 make 的相互作用。现代构建系统给出的答案是改用内容哈希——Bazel 的 digest 机制就是把这层代理量换回真实语义;CMake 与 Ninja 仍然用 mtime,但把正确性责任转交给了生成器。

文本格式的利息:无类型、无作用域、不可静态分析。 变量是全局字典,命名靠前缀约定;未定义变量展开成空串;$(shell) 与 $(eval) 让 Makefile 在解析期执行任意逻辑并生成新规则。结果是构建过程无法在不执行的情况下被完整分析,IDE 的跳转与补全做不进去,增量分析工具只能靠猜。这条利息最贵,因为它的代价不在写 Makefile 的时候,而在读别人的 Makefile 的时候。

但反证也需要一个反证,否则就成了另一种正确的废话。在“零依赖、单一工具链、图规模不大”的条件下,这三个妥协几乎不产生成本:tab 一学就会,mtime 在单机顺序构建里足够可靠,文本格式带来的可调试性远超它带来的混乱。Linux 内核与嵌入式 SDK 至今选择 make,不是路径依赖,而是因为在那些环境里,“任何文本编辑器都能改、任何 diff 都能审、任何 shell 都能跑”是决定性优势。

如果就在这样的项目里工作,可以给三个妥协各配一个检查动作:确认 Makefile 里没有空格缩进的配方行,或者干脆把 .RECIPEPREFIX 显式写出来;确认时间戳链没有被 cp -p、rsync -t 污染过;对 $(shell) 与 $(eval) 的使用做一次静态搜索并记进文档。这些都不是根治,只是把裂缝画在图纸上——修不修得看项目规模。

本章主张在什么条件下不成立?如果项目的构建入口已经由生成器接管——CMake、Meson、Bazel——那么使用者从头到尾看不到这三个妥协,本章的叙事对他们是不可见的。但要注意,不可见不等于不存在:生成器把 Makefile 当目标文件生成,妥协被移到了生成器内部,语义遗产仍然在底下运行。理解这段历史的价值,不在于评判一个 1976 年的决定,而在于当你面对任何一个构建系统时,能问出正确的问题:它用什么代理量代替了真实语义,这道缝在什么条件下会裂开。

make 的诞生不是为了让人少打字,而是为了让人少相信自己的记忆。

(本章字数:8917,标点体检:通过)


第 2 章 make 到底在算什么:DAG、时间戳与三种过期

一个已经编译过一百次的项目,改掉某个 .c 文件里的一个字符,然后运行 make。屏幕上可能什么都不输出,只有一行 make: “app”已是最新。;也可能刷出几百行编译命令,把整个项目从头编一遍。两种结果的差别,在 make 内部只对应若干个整数比较。

大多数人对 make 的不信任都从这一刻开始:它凭什么认为不需要重编译?它又凭什么认为需要全部重编译?这两个问题看起来是同一个问题,其实不是。前者问的是判定规则,后者问的是图的质量。而 make 本身只做三件事:拿着你写的文本建一张有向无环图,挨个比较文件的时间戳决定哪些节点过期,然后按图的顺序把命令喂给 shell。本章要做的,是把这三件事拆到可以逐行校对的程度,然后指出这个模型在什么条件下会安静地骗人。

2.1 规则不是命令,是图的边

一条规则写出来是这个样子:目标、冒号、前置条件,换行,一个 tab,配方。用图论的语言重写一遍:目标是一个节点,每个前置条件也是一个节点,这条规则声明的是一条从每个前置条件指向目标的有向边,配方是挂在目标节点上的一个动作。

这个重写不是修辞练习,它直接决定了 make 的三个性质。

第一,边的方向决定了遍历的方向。make 要更新 app,就得先保证 main.o 和 util.o 已更新;要更新 main.o,就得先保证 main.c 已更新。所以更新是从被请求的目标出发,逆着箭头走到没有前置条件的叶子节点,再原路返回。make 从不从叶子往下推,它总是从你要的东西往回问:你要的东西,靠什么才能成立。

第二,图必须无环。环意味着“a 需要 b 先完成,而 b 又需要 a 先完成”,这样的图没有拓扑序,也就没有合法的执行顺序。make 对此的反应不是报错退出,而是悄悄丢掉一条边继续干活。实测:

$ cat Makefile  
a: b  
	@echo build a  
b: a  
	@echo build b  
  
$ make a  
build b  
build a  
make: 放弃循环依赖 b <- a 。  

这条警告是在构建完成之后才打印的,退出码是 0:含环的图不会让 make 停下来,只会让它少干一件你没批准它不干的事。环是图的缺陷,而 make 对图的缺陷一贯宽容,代价由使用者承担。

第三,图决定了并行度。两个节点之间如果没有路径,它们就没有先后关系,可以同时执行。-j 能做的事,本质上是数图上同一层里有多少个互不相关的节点。这解释了为什么并行 make 的正确性不依赖调度器的聪明,只依赖图的完整:图里没有的边,make 不会替你想出来,也就不会替你等待。

make 不是脚本解释器

把 make 当成“一个按顺序执行命令的脚本工具”是最常见的心智错误,而且它会带来非常具体的误判。三个反例足以推翻它。

反例一:执行顺序与书写顺序无关。下面这份 Makefile 把 all 写在第一行,all 的配方里留了一行标记:

all: app  
	@echo ">>> [3] 执行 all 的配方(书写位置第一,执行位置最后)"  
  
app: main.o  
	@echo ">>> [2] LINK app"  
	@gcc -o app main.o  
  
main.o: main.c  
	@echo ">>> [1] CC main.o"  
	@gcc -c -o main.o main.c  
$ make  
>>> [1] CC main.o  
>>> [2] LINK app  
>>> [3] 执行 all 的配方(书写位置第一,执行位置最后)  

三条配方的执行顺序与它们在文件里的出现顺序完全相反。用脚本解释器的模型去预测,你会认为 all 的 echo 最先出现;用图的模型去预测,你会立刻同意实际结果,因为 all 依赖 app,app 依赖 main.o,后序遍历必然叶子先动。

反例二:配方不在同一个 shell 里执行。每一行配方都会被交给一个新的 shell 进程,所以下面这种写法里 cd 不会延续到下一行:

wrong:  
	cd build  
	./configure  

第二行的 ./configure 仍然在 Makefile 所在目录里找。这不是缺陷,而是“一行配方一个 shell”的直接后果,也是 .ONESHELL 这个特殊目标存在的理由。脚本解释器不会这样切分,图执行器必须这样切分,因为它要支持 -j,而并行的基本单位就是配方行。

反例三:同一个目标的多条规则会被合并,配方只有一条生效。写两份带配方的 x 规则,make 不会执行两次,只会警告并保留后一份:

$ cat Makefile  
x: a  
	@echo "配方来自第 1 条规则(前置条件 a)"  
  
x: b  
	@echo "配方来自第 2 条规则(前置条件 b)"  
  
$ make x  
配方来自第 2 条规则(前置条件 b)  
Makefile:5: 警告:覆盖关于目标“x”的配方  
Makefile:2: 警告:忽略关于目标“x”的旧配方  

前置条件被合并了(a 和 b 都成了 x 的前置条件),只有配方是后来者覆盖前者。脚本里同名函数第二次定义会覆盖第一次,但绝不会把两个函数体的语句合并起来;make 恰好相反:对图宽容、对动作严格。

那书写顺序到底影响什么?只影响两件事。第一件是默认目标:make 没有拿到命令行参数时,从第一条规则里的第一个目标开始更新。实测 make -p 打印的内部变量可以确认这一点:

$ make -p -n 2>/dev/null | grep -m1 '.DEFAULT_GOAL'  
.DEFAULT_GOAL := x  

第二件是变量与函数的求值时机。立即展开的赋值在解析到那一行时就算出结果,递归展开的赋值要等到被引用时才算,$(eval) 更极端,它在解析期就生成新的规则。这条线索属于第 4 章,在本章只需要记住一个结论:Makefile 是两遍读的,第一遍算出图,第二遍执行图。

三段式执行

把上面这些拼起来,make 的一次运行可以切成三段。

第一段,读入并建图。按顺序读入 -f 指定的或默认的 Makefile,处理 include,展开变量,执行 $(eval) 与 $(shell),把规则累积成一张图。注意产物不是文本而是图:同一个目标的多次声明在这里合并,隐式规则在这里匹配成候选,.PHONY 在这里给节点打标记。所以“图是算出来的”比“图是抄下来的”更准确。

第二段,决定要更新什么。默认目标是第一条规则的第一个目标,或者由 .DEFAULT_GOAL 指定,或者由命令行参数指定。这一步决定了第三段的入口集合。

第三段,展开更新。从一个或多个入口出发做深度优先遍历,递归地把前置条件更新完,再判断当前节点是否过期,过期就执行配方。这一段是本章 2.2 的主题。

以实验 1 的最小依赖图为例,全量构建的真实输出是:

$ make  
CC    main.o <- main.c (新依赖: main.c util.h)  
gcc -c -o main.o main.c  
CC    util.o <- util.c (新依赖: util.c util.h)  
gcc -c -o util.o util.c  
LINK  app <- main.o util.o  
gcc -o app main.o util.o  

一句话总结这一节:书写顺序是给解析器看的,执行顺序是图算出来的。

2.2 过期的三种来源与判定的精确算法

先固定术语。“过期”对应英文 out of date,指的是目标比某个前置条件旧,或者目标根本不存在。这个词比“失效”“脏”都准确,因为它描述的是一个相对关系(相对于前置条件旧),而不是目标的绝对状态。一个编译于三年前的 .o 文件本身没有任何问题,它只是比昨天改过的 .c 旧。

make 判定一个目标过期,只有三种理由。

理由一:目标不存在。 这是最硬的理由,也是最容易理解为“必须构建”的理由。目标的文件在磁盘上找不到,配方就必须跑一次。如果目标不存在、同时也没有任何规则能生成它,make 会报错终止:

$ make  
make: *** 没有规则可制作目标“config.mk”,由“app” 需求。 停止。  

这句报错值得单独记住,因为它和“过期”是两回事:过期是“我知道怎么重建,只是还没重建”,无规则是“我不知道怎么重建”。

理由二:任一前置条件严格比目标新。 关键词是“严格”。相等不算新。实测把两个文件的修改时间设成完全一致,make 就认为目标是最新的:

$ touch -r out.txt src.txt  
$ stat -c '%n %.9Y' src.txt out.txt  
src.txt 1793482298.000000000  
out.txt 1793482298.000000000  
  
$ make  
make: “out.txt”已是最新。  

这条“相等即最新”的约定极其重要,因为 2.4 节的第一个和第二个陷阱都是它的直接后果:任何让前置条件的时间戳不比目标更新的机制,都能让 make 闭嘴,哪怕内容已经天差地别。这也不是 GNU 的实现细节,POSIX 对 make 的规定里明确写了“目标已存在、与部分前置条件同龄、且比其余前置条件新”也算 up-to-date——规范层面就把“同龄”划到了安全的一侧。

比较的对象只有 mtime:不是文件大小,不是 inode 编号,不是 ctime,更不是内容哈希。GNU make 的维护者 Paul Smith 在邮件列表里被问到“是不是还看文件大小”时回答得很干脆——mtime 是唯一被考虑的时间。这一点决定了 make 的性能画像(每个文件一次 stat)和它的正确性边界(所有不改变 mtime 的变化都不可见)。

比较的顺序也有讲究。make 先取目标的 mtime,再按前置条件的书写顺序从左到右逐个比,一旦发现某个前置条件更新就停下来,因为“一个就足够”。停下的是判定,不是遍历:所有前置条件仍然要先被更新一遍。

理由三:目标是伪目标。 被 .PHONY 声明过的目标,make 跳过“文件是否存在”和“时间戳谁新”两个检查,直接认为它过期。这是伪目标存在的全部意义:给一个不对应真实文件的名字(clean、test、all)一张免检通行证。缺了这张通行证会发生什么,实验 5 给了一个典型案例:目录里恰好有一个叫 clean 的文件,于是 make clean 输出的是

$ ls; make clean  
Makefile  clean  
make: “clean”已是最新。  

配方一次都没跑。加上 .PHONY: clean 之后才恢复执行。这个例子的教育意义在于:伪目标的失效是静默的,没有警告,退出码 0,只有当你发现该删的文件还在时才会察觉。

把三条理由写成伪代码,遍历的形状就清楚了:

update(目标 T):  
    if T 已经在本轮更新过且成功: return  
    对 T 的每个前置条件 P(按书写顺序):  
        update(P)                       # 递归,先更新前置条件  
        if P 既不存在、也没有规则可生成: 报错并终止  
    if T 不存在:  
        执行 T 的配方  
    elif 存在某个前置条件 P,使 mtime(P) > mtime(T):  
        执行 T 的配方  
    elif T 被声明为 .PHONY:  
        执行 T 的配方  
    else:  
        什么也不做,把 T 记为最新  

这段伪代码有两个细节容易被忽略。第一,递归发生在判定之前:make 不会先问“app 过期吗”再去看 main.o,它一定先把 main.o 更新好,再回来问 app。所以即使 app 自己毫无变化,它的前置条件也可能在这一轮被重建,从而让 app 过期。第二,判定只做一次:如果配方跑完之后目标仍然比前置条件旧(比如时间戳在未来,见 2.4),make 不会重试,它只在状态位里记下“已更新成功”。

沉默是最常见的输出

图完整、时间戳可信、没有任何改动的项目,make 的输出是一个字都不给。实验 1 的第二次构建就是这种状态:

$ make  
make: “app”已是最新。  

在很多构建日志里,这一行是唯一能证明 make 跑过的痕迹。它也是一个诊断信号:预期有东西被重建却没有,说明判定阶段就投了反对票,问题在时间戳或图上,不在配方里。

自动变量 $? 把“哪些前置条件比目标新”这件事直接暴露出来。实验 3 的 Makefile 让 out.lib 依赖 a.o 与 b.o,首次构建时七个自动变量一次打印出来,其中 $? 是 [a.o b.o],因为两个 .o 都是这一轮刚建出来的。touch a.c 之后再跑一次,输出变成:

$ touch a.c && make   # 观察 $? 只含更新的依赖  
stem=$*=a  src=$<=a.c  
目标    $@    = [out.lib]  
首依赖  $<    = [a.o]  
全依赖  $^    = [a.o b.o]  
有序    $+    = [a.o b.o]  
较新    $?    = [a.o]  
目录    $(@D) = [.]  
文件名  $(@F) = [out.lib]  

第二次运行里 $? 从 [a.o b.o] 收缩成 [a.o],因为只有 a.o 在这轮被重建了。这让 $? 成为在配方里做增量处理的天然入口(比如只对变动的源文件跑 lint),也让 $? 成为排查判定问题的第一手证据:如果 $? 里出现了你根本没改过的文件,那个文件的时间戳被动过了。

五个命令行开关:把判定权拿回来

判定规则是硬的,但 make 给了几个开关,允许人在命令行上改写它。

-B 无条件重建全部。它把每个目标都当成过期,跳过判定:

$ make  
make: “app”已是最新。  
  
$ make -B  
CC    main.o <- main.c  
CC    util.o <- util.c  
LINK  app <- main.o util.o  

-B 的用途不是“保险起见全编一遍”,而是“在判定不可信时退出判定”:时间戳被污染之后(比如刚从保留时间戳的归档里解出来),-B 是唯一能让 make 回到已知状态的手段。代价是全量构建的耗时,以及一条隐蔽的副作用——那些本来为了“只在必要时跑”而写的昂贵配方也会全部重跑。

-t 只 touch 不执行。它把目标的时间戳推到当前时间,让判定认为目标已经更新,配方一个字都不跑:

$ touch main.c  
$ make -n  
echo "CC    main.o <- main.c"  
gcc -c -o main.o main.c  
echo "LINK  app <- main.o util.o"  
gcc -o app main.o util.o  
  
$ make -t  
touch main.o  
touch app  
  
$ stat -c '%n mtime=%.9Y' main.c main.o app  
main.c mtime=1793482192.000000000  
main.o mtime=1793482193.000000000  
app mtime=1793482193.000000000  

注意 make -t 会把自己要做的动作打印出来(touch main.o、touch app),这是“预演里藏着真实动作”的例外。在真实构建目录里跑 -t 是最危险的用法:目标时间戳变成最新,内容还是旧的,make 从此永久闭嘴。它是制造假阴性的最快工具,因为这是设计如此。

-o file 假定 file 是旧的。判定时把该文件当成“永远不会比目标新”:

$ sleep 1 && touch main.c && make -o main.c  
make: “app”已是最新。  

main.c 明明刚被 touch 过,正常 make 一定重建,-o main.c 把这条边从判定里摘掉了。它的正当用途是处理“前置条件的时间戳不可信、但它确实没变”的场景,比如一个由外部系统生成、每次同步都刷新 mtime 的配置文件。

-W file 与 -o 恰好相反,假定 file 是新的:

$ make -W main.c  
CC    main.o <- main.c  
LINK  app <- main.o util.o  

-W 的正当用途是 CI:源码可能是从缓存恢复的、时间戳不可靠,但你可以确定“相对上次构建,这一批文件算改了”,于是用 -W 把它们标成新的,让 make 只重建受影响的子图。这比 -B 精细,比信任时间戳安全。

-n 预演,把要执行的配方打印出来但不动手。它和 -q 是一对:-n 回答“会做什么”,-q 只回答“有没有要做的事”,用退出码说话:

$ make -q; echo "exit=$?"  
exit=0  
  
$ sleep 1 && touch main.c && make -q; echo "exit=$?"  
exit=1  

0 表示全是最新,1 表示有东西需要更新,2 表示出错。-q 是 shell 脚本里判断“要不要跑构建”的正确方式,比解析 make 的输出可靠得多,因为输出文本会随 locale 变化,退出码不会。

这五个开关合起来说明一件事:make 的判定是“目标是否比输入旧”,而不是“目标是否与输入一致”。 前者是可以用命令行强行改写的局部判断,后者才是正确性判断。混淆这两者,就是本章后面所有陷阱的总根源。

2.3 用 --debug=b 看见判定过程

判定过程平时是隐形的,--debug=b 让它说话。这里的 b 是 basic,意思是只打印最基本的判定信息。实验 13 的两段输出对照着看,效果最好。

第一段是“什么也没发生”的情况:

$ make --debug=b  
GNU Make 4.4.1  
为 x86_64-pc-linux-gnu 编译  
Copyright (C) 1988-2023 Free Software Foundation, Inc.  
License GPLv3+: GNU GPL version 3 or later <https://gnu.org/licenses/gpl.html>  
This is free software: you are free to change and redistribute it.  
There is NO WARRANTY, to the extent permitted by law.  
正在读入 makefiles...  
正在更新 makefile....  
更新目标....  
make: “a.o”已是最新。  

中间那五行版本与许可声明是每次都打印的噪声,真正的调试信息只有三行:正在读入 makefiles... 对应建图,正在更新 makefile.... 对应 makefile 自身的重建(make 会先尝试更新它读进来的 Makefile,这是很多人不知道的一层),更新目标.... 对应更新入口目标。然后是一句 已是最新。

第二段是“有东西过期”的情况,只比上一段多了四行,但这四行信息量极大:

$ sleep 1 && touch a.c && make --debug=b  
# (此处省略开头五行版本与许可声明)  
正在读入 makefiles...  
正在更新 makefile....  
更新目标....  
 前提“a.c”比目标“a.o”新。  
必须重新制作目标“a.o”。  
编译 a.o  
重新制作目标文件“a.o”成功。  

逐行拆开。

前提“a.c”比目标“a.o”新。 这是判定算法里那句 mtime(P) > mtime(T) 的直接打印。注意两个用词:中文翻译把 prerequisite 译成“前提”,把比较结果说成“新”而不是“不同”。这句话只可能由时间戳比较产生,内容是否变化、变化多大,它一概不知。整个构建系统的世界观就压缩在这一行里。

必须重新制作目标“a.o”。 这是判定的结论,翻译成算法语言就是“本轮这个节点被标记为 out of date,配方将被调度”。上一行是证据,这一行是裁决;证据可以有多个(多个前置条件都比目标新),裁决只有一个。用 --debug=b 调试时,该关心的是证据行出现了几次、为谁出现。

编译 a.o 是配方自己的输出(Makefile 里写的是 @echo "编译 a.o"),不是 make 的调试信息。它出现在这里说明调度已经发生,shell 已经被启动。

重新制作目标文件“a.o”成功。 这是执行结果。它意味着配方以退出码 0 结束,make 把该节点标记为“已更新”。如果配方失败,这一行会变成错误信息,并且更新过程会在这里中止(除非用了 -k)。

三段信息(证据、裁决、结果)构成了一个完整的事件。当构建行为出现异常时,你要找的是缺失的那一段:有证据没裁决,说明判定逻辑被什么东西改写了(比如 -o);有裁决没结果,说明配方崩了;连证据都没有,说明时间戳被伪造了。

--debug 还有 v(verbose,含隐式规则搜索细节)、i、j、m、a 等档位,a 的输出量足以淹没真正有用的那几行。

-p:打印 make 的内部数据库

如果说 --debug=b 回答“这次为什么重建”,-p 回答的是另一个问题:make 心里到底装着什么。-p 在把所有规则和变量打印到标准输出之后,照常执行构建(配合 -n 可以只打印不执行)。

上一节那份 8 行的 Makefile,加上内置规则,make -p -n 的输出是 四位数的行数:同一目录下两次实测分别是 1261 行与 1299 行,把环境清空重跑是 1239 行,差值来自被一并打印的环境变量。这个数量级本身就是一条信息:你以为写的是八行构建描述,实际读进来的是一个自带 173 条隐式规则的庞大世界观。

输出的开头是变量区,按来源分组标注。以下四段都是逐字连续摘录:

# 变量  
  
# 默认  
PREPROCESS.S = $(CPP) $(CPPFLAGS)  
# makefile  
CURDIR := /sandbox/workspace/book-projects/make/lab/ch02/flags3  
# 环境  
# makefile (从“Makefile”,行 1)  
MAKEFILE_LIST := Makefile  
# 默认  
# makefile  
.DEFAULT_GOAL := app  
# 默认  

# 默认、# makefile、# 环境、# 命令行 这些注释说明了每个变量的来源。这是排查变量优先级的第一手材料:当你疑惑 CC 为什么是 cc 而不是你写的 gcc 时,make -p 会告诉你它来自“默认”。

规则区在后面,每条规则下面挂着一串以 # 开头的内部状态注释。摘录两条:

app: main.o  
#  对隐含规则的搜索尚未完成。  
#  File is secondary (prerequisite of .SECONDARY).  
#  最近更新 2026-10-31 21:31:15  
#  文件已经被更新。  
#  更新成功。  
#  要执行的配方 (从“Makefile”,行 2):  
	@echo "LINK  $@ <- $^"  
	@touch $@  
%.o: %.c  
#  要执行的配方 (内置):  
	$(COMPILE.c) $(OUTPUT_OPTION) $<  

第一段里最有用的是倒数第二行:要执行的配方 (从“Makefile”,行 2)。它直接回答“这条规则是谁给的”。第二段里同样位置写的是 (内置),说明这条模式规则来自 make 的出厂预设,不是你写的。这两种标注的差别,是排查隐式规则问题的关键:如果你看到某个目标的配方来自“内置”,而你从没写过它,那么你被 173 条内置规则之一接管了。

其余几行是 make 的内部状态位:对隐含规则的搜索尚未完成 表示这个节点还没走完隐式规则的候选匹配;最近更新 是这个节点在 make 内部记录的时间戳(注意它被格式化成人类可读形式,精度只有秒,这正是陷阱之一);文件已经被更新 与 更新成功 是执行状态的记录。File is secondary 那一行是 4.4 版本额外的内部标记,属于实现细节,日常排查可以忽略。

-p 与 -n 的区别可以用一句话概括:-n 说“我要做什么”,-p 说“我知道什么”。 排查“目标被意外重建”时,标准动作是 make -p -n 再抓出目标那一条,看三件事:前置条件列表里有没有你没想到的项(最常见的是目录)、配方来源是你的 Makefile 还是内置规则、记录的时间戳与 stat 是否一致。这三件事覆盖了绝大多数“make 疯了”的场景。

2.4 时间戳的四个陷阱

到这里,判定算法已经完全透明了。剩下的问题不是 make 算错了,而是它算得对、输入错了。时间戳是 make 唯一的判据,这个判据的脆弱程度决定了 make 的可靠性边界。四个陷阱,每一个都给出机制和复现方法。

陷阱一:同秒时间戳

机制。文件时间戳的分辨率决定了判定的颗粒度。传统 Unix 文件系统的时间戳是秒级,现代文件系统(ext4、XFS、APFS、NTFS)都支持纳秒,但支持不等于会发生。GNU Autoconf 手册第 12.20 节把这件事写得很清楚:传统上时间戳是 1 秒分辨率,make 用它判断新旧;许多现代文件系统是 1 纳秒分辨率;有些 make 实现看完整时间戳,有些忽略小数部分,这会导致错误结果;极端情况下需要用 sleep 1 这类技巧绕过截断 bug。

即使 stat 能给出纳秒,只要参与比较的一方(某个 make 实现、某个写文件的工具)丢了小数部分,判定就退化成秒级。GNU make 的 bug 跟踪系统里对此有专门讨论:即使 ext4 支持亚秒时间戳,也有大量文件的纳秒字段为 0,因为 tar 格式根本不支持亚秒,老版本的 touch 与 cp -p 也写不进去。纳秒不是“已经解决的问题”,而是“在特定工具链上偶然成立的条件”。

复现。本沙箱的 overlay 文件系统对普通文件只保留整秒,stat 的纳秒部分恒为 .000000000,因此这个陷阱可以确定性地复现:

$ cat Makefile  
out.txt: src.txt  
	@echo "重建 out.txt"  
	@cat src.txt > out.txt  
  
$ rm -f out.txt; printf 'v1\n' > src.txt; make  
重建 out.txt  
$ printf 'v2\n' >> src.txt; make  
make: “out.txt”已是最新。  
$ stat -c '%n %.9Y' src.txt out.txt  
src.txt 1793482319.000000000  
out.txt 1793482319.000000000  
$ cat out.txt  
v1  

源文件从 v1 变成了 v1 加 v2,目标还是 v1,make 说已是最新,退出码 0。这不是构造出来的边界情况,而是在一个普通的秒级文件系统上、在循环的第一次尝试里就命中的真实故障。改动的规模可以任意大:只要改完之后的时间戳落在与上次构建相同的整秒内,make 必然漏建。

缓解。第一,制造时间差:Autoconf 自己就用 sleep 1(sanity.m4 会先探测文件系统的时间戳分辨率再决定睡多久),在快速迭代的脚本里这是最直接也最丑陋的补丁。第二,.LOW_RESOLUTION_TIME: dest 把列出的文件按低分辨率处理,比较时“相等即视为更新”。第三,构建后统一 touch 目标,把时间戳推到构建结束之后。第四,换判据:内容哈希。

陷阱二:cp -p 与 touch -r 保留时间戳

机制。cp -p 的 -p 是 preserve,它会把源文件的权限、所有者、以及时间戳一起复制到目标上。于是下面这条规则的时间戳关系完全由 cp 的实现细节决定,不再由“这次构建发生在什么时候”决定:

dst: src  
	cp -p src dst  

Autoconf 手册对这一点的警告是逐字的:cp -p 与 touch -r 这类命令通常不会把文件时间戳按完整分辨率复制,因此应当警惕 dest: src 配合 cp -p src dest 的规则,因为在时间戳被截断之后,dest 常常看起来比 src 旧,这会让 make 在下次运行时做无谓的重复劳动。touch -r 是同一个问题的另一半:它明确地把参照文件的时间戳抄给另一个文件。

这段警告揭示了一个容易被忽略的事实:cp -p 的失效有两个相反的方向,取决于源文件和目标文件各自丢了什么。

方向一,目标变老,于是每次重建。src 的纳秒部分非零,而 cp -p 只把整秒抄过去,dst 因此比 src 老一个零头,make 每次都判定过期,每次重建完又立刻过期——“无谓的重复劳动”指的就是这个。Autoconf 给出的绕法不是修 cp,而是引入一个 stamp 文件:

dest-stamp: src  
	cp -p src dest  
	date >dest-stamp  

判定的对象从 dest 换成 dest-stamp,而 stamp 的时间戳由 date 在复制完成之后写入,永远晚于 src。这个模式在 Autotools 项目里到处都是,它的本质是把“构建发生的时间”重新变成一个可判定的量。

方向二,目标与源同龄,于是永不重建。如果 src 与 dst 都没有纳秒字段,cp -p 之后两者完全同龄,而 2.2 节已经确认“相等不算新”,于是 make 认为目标永远是最新的。实测:

$ cat Makefile  
dst: src  
	@echo "重建 dst(cp -p)"  
	@cp -p src dst  
  
$ printf 'v1\n' > src; touch -d "@1200000000" src  
$ make  
重建 dst(cp -p)  
$ stat -c '%n %.9Y' src dst  
src 1200000000.000000000  
dst 1200000000.000000000  
$ make; make  
make: “dst”已是最新。  
make: “dst”已是最新。  

这条规则从此变成了一个摆设,而且是一个看起来完全合理的摆设:谁会觉得“复制文件”这个动作有问题呢。方向二比方向一危险得多,因为它不产生任何可见症状——没有多余的构建,没有警告,只有一个永远不被更新的产物。

顺带记一条家族差异:Autoconf 手册提到,HP-UX 的 make 在目标与某个前置条件时间戳相等时判定为需要更新,这违反了 POSIX 规则,会导致重复运行时出现虚假重建。同一个“相等”边界,不同实现的取舍不同。

缓解。第一,不要用 cp -p 生成目标:用 cp 再 touch $@,让目标的时间戳反映“构建发生在现在”。第二,确实需要保留时间戳时(打包、镜像构建),用上面的 stamp 模式,或把这类文件列进 .LOW_RESOLUTION_TIME。第三,最根本的一条:在配方位置看到 cp -p、rsync -a、tar -x(保留时间戳),要意识到它们都在往判定里塞一个“未来会骗你”的时间戳。

陷阱三:分布式文件系统与时钟不同步

机制。mtime 是写入方的时钟打上去的,不是服务器的时钟。在 NFS、SMB、以及各种网络文件系统上,客户端 A 写文件时用的是 A 的时钟,客户端 B 读 stat 时看到的是 A 的时间。如果 A 的时钟比 B 快 5 秒,那么 A 刚写的文件在 B 看来是“5 秒之后才写的”,于是 B 上的 make 会认为这个文件永远是新的,永远重建;反过来,如果 A 的时钟慢 5 秒,A 的新改动在 B 看来比目标还旧,B 就永远漏建。后者的危险程度高一个量级,因为它是静默的。

容器和 CI 放大了这个问题:任务在容器 A 里编译,缓存挂在 NFS 上,下一个任务在容器 B 里跑,两个容器的时钟由不同宿主同步,偏差从毫秒到秒都可能。这不是理论问题,而是“本地能过、CI 挂了”的常见成因之一。

复现。这个陷阱无法在本沙箱复现(没有网络文件系统,也没有可控的时钟偏移),只能从机制上论证。单机上可以用时钟跳变模拟一半:把系统时钟往前拨,touch 一批文件,再拨回来,观察 make 是否认为这些文件在未来(见陷阱四)。另一半——两台机器互相看不见对方的时钟——需要真实的多机环境。

缓解。第一,所有参与构建的机器用同一组 NTP 源,接受毫秒级偏差。第二,绝不让两台机器共享同一个构建目录的增量状态;如果必须共享,就每次都 -B,把判定关掉。第三,用 -W 在 CI 里显式声明哪些文件算改了,绕开 mtime 的可信度问题。第四,改用基于内容哈希的构建系统(Bazel 的动作签名、Ninja 依赖 CMake 生成的文件与 restat 配合),它们的判据不依赖时钟。

陷阱四:未来时间戳

机制。目标的时间戳由本机当前时钟打上,源文件的时间戳可能是未来的某个时刻(比如从一个时钟超前的机器上打包的归档里解出来,或者有人调试时用 touch -d 设了一个未来时间)。于是 mtime(源) > mtime(目标) 恒成立:目标的 mtime 永远是“现在”,而“现在”小于那个未来值。结果是每次 make 都重建,每次重建完又立刻过期。

复现。实测:

$ cat Makefile  
dst.txt: src.txt  
	@echo "重建 dst.txt"  
	@cp src.txt dst.txt  
  
$ touch -d "2030-01-01" src.txt  
$ stat -c '%n %.9Y' src.txt dst.txt  
src.txt 1893456000.000000000  
dst.txt 1793482337.000000000  
  
$ make  
重建 dst.txt  
make: 警告:文件“src.txt”的修改时间在未来 99973662 秒后  
make: 警告:检测到时钟错误。您的构建版本可能是不完整的。  
  
$ make  
重建 dst.txt  
make: 警告:文件“src.txt”的修改时间在未来 99973661 秒后  
make: 警告:检测到时钟错误。您的构建版本可能是不完整的。  

两段警告值得逐字读。警告:文件“src.txt”的修改时间在未来 99973662 秒后 是 make 在 stat 之后的一次合理性检查:mtime 明显晚于当前时间时,它既报差值,也表明它知道这不是正常状态。99973662 秒 约等于 3.17 年,正好是从 2026 年 10 月到 2030 年 1 月的距离。警告:检测到时钟错误。您的构建版本可能是不完整的。 则是 make 的最后一层诚实:在时钟不可信的前提下,产出的正确性无法保证。

第二个 make 的差值少了一秒(99973661),因为两次运行之间真的过了一秒。

缓解。第一,不要用未来时间做 touch -d 的“技巧”,它会污染整个目录树。第二,CI 里加一道检查:find . -newermt "now" -print 找出所有未来时间戳的文件,构建前先修正。第三,解压归档时不要保留时间戳。第四,看到“检测到时钟错误”这行警告,不要当成噪声,它意味着这一轮的产出不可信,正确反应是清理后重建。

四条工业级缓解

把四个陷阱的缓解手段合并,可以得到一组可以直接写进团队规范的条目。

第一,不要用保留时间戳的命令生成目标。cp -p、rsync -a、touch -r、tar -x 出现在配方位置时,必须在后面补一句 touch $@,或者把它们从增量判定的链路里摘出去。

第二,构建后统一刷新时间戳。对于产出多个文件、且顺序不可控的配方,在配方末尾 touch 所有产物,能一次性消灭“同秒”与“产物之间互相更旧”两类问题。

第三,CI 里不要相信 mtime:用 -W 声明变更集,用 -B 做干净构建作为基线,或者在容器启动时把源码树的时间戳重置为固定值再按变更集 touch。

第四,长期方案是换判据。内容哈希(Bazel)、动作签名(Bazel 的 action key)、依赖文件携带的显式输入列表(Ninja 的 restat 与 deps)都不依赖时钟。这不是说它们没有代价,而是说它们的错误模式与时间戳不同:时间戳系统的错误是静默的假阴性,内容哈希系统的错误通常是明确的缓存未命中。

2.5 order-only:把“必须先有”和“必须更新”分开

有一条边,你希望它只表达顺序,不参与新旧比较。最典型的场景是目录:

out/binary: main.c out  
	cp main.c out/binary  

这条规则想说“先把 out 目录准备好,再往里放 binary”。但 out 处在普通前置条件的位置,于是它参与时间戳比较,而目录的 mtime 会在目录项被增删时变化——包括创建 out/binary 这个动作本身。第一次构建时 cp 创建了 out/binary,这个动作更新了 out 的 mtime;第二次 make 看到 out 比 out/binary 新,于是重建,重建又更新 out 的 mtime。这条规则于是成了每次必然触发的重建点,而且症状隐蔽:单个目标看不出来,几百个目标各带一个目录前置条件,增量构建就彻底失效。

make 的解法是把这类前置条件挪到竖线右边:

out/binary: main.c | out  
	cp main.c out/binary  

竖线右边的叫 order-only prerequisite,中文一般译作“仅顺序前置条件”。语义只有一句:它必须先被更新,但它不参与“目标是否过期”的判定。 用 2.2 的伪代码改写,就是 mtime(P) > mtime(T) 这一行在遍历前置条件时跳过 | 之后的节点,而 update(P) 那一行照旧执行。

实验 11 的实测把这个语义钉死了:

$ make  
创建目录 out  
普通依赖=[main.c]  仅顺序依赖=[out]  
  
$ sleep 1 && touch out && make   # 目录 mtime 变新  
make: “out/binary”已是最新。  

第二段是关键证据:目录的 mtime 被推到了目标之后,如果 out 是普通前置条件,这里必然重建;实际输出是“已是最新”,说明 | 右侧确实被排除在比较之外。第一段里 $| 的取值(仅顺序依赖=[out])则说明两边的前置条件在自动变量里也是分开的:$^ 只含普通前置条件,$| 只含仅顺序前置条件。

这里必须诚实说明:本沙箱的 overlay 文件系统不维护目录 mtime,对目录执行 touch、以及向目录内新增文件之后,目录的 mtime 都不变(始终是 1793482343.045020838)。所以“目录变新导致重建”这个反面案例无法在本地复现,上面的论证只能依靠 | 的语义定义与 $| 的实测取值。这本身也是一个提醒:你对时间戳的所有推理都建立在文件系统行为的假设上,而这个假设在不同的文件系统上会变。

| 的代价有三条,都值得写清楚。

第一,它只保证顺序,不保证内容。配方若要读目录里的某个文件,那个文件仍须作为普通前置条件声明,否则它的变化不会触发重建。| 的正确用法是“我只需要这个目录存在”,而不是“我依赖这个目录里的一切”。

第二,它掩盖了“目录被删除”这类变化。out 整个被删掉时,make 会重新执行生成 out 的规则(update(P) 照旧执行),但如果 out 是由 .PHONY 目标 mkdir -p 出来的,这次“重建”不产生任何副作用,目标仍是最新的,配方不会重跑,结果可能是错的。目录类规则最好配 .PHONY,或者干脆把 mkdir -p 内联进配方。

第三,它让图的语义变复杂了:| 右边的节点在 $^ 与 $? 里都看不见,在 make -p 的输出里也要和普通前置条件区分开,这些都是排查成本。用 | 的地方应该配一行注释,说明这里只需要顺序。

2.6 边界与反证

本章的主线可以概括成一句话:make 的行为等于“建图、判过期、按拓扑序执行”这三步的合成,因此它的正确性等价于输入图的正确性。这个主张有一个必须面对的反证:“增量构建总是更快更正确”是错的。 更快通常成立,更正确不成立。

把构建的错误分成两类。假阳性是“该不建却建了”:多花时间,产物正确。假阴性是“该建却没建”:省了时间,产物错误。增量构建的一切风险都集中在假阴性上,它有一个非常恶劣的性质——不报错。make 退出码 0,日志一片干净,二进制能跑,只是里面是旧代码。这种错误会一路延迟到运行时、测试环境、用户手上才暴露,而暴露时很难归因,因为构建日志说一切正常。

三种典型的假阴性来源,构成了“图不完整”的主要形态。

第一种:头文件依赖没有声明。 只写 main.o: main.c,改 util.h 不会触发重编译,因为图里没有那条边。这是最古老也最常见的一种,-MMD -MP 就是为了自动生成这条边而存在的(第 5 章会逐行拆解)。它的危险在于,症状是“改了头文件没生效”,工程师的第一反应往往是怀疑缓存、怀疑 IDE、怀疑编译器,最后才想到图。

第二种:生成文件没有纳入图。 代码生成器的产物(.pb.cc、.tab.c、.mo 文件、从模板生成的配置头)如果在图里只以“最终产物”的身份出现,而没有“由谁生成”这个身份,生成器的输入变化就不会触发任何重建。更糟的形态是把生成文件写进版本库:make 认为它一直存在、时间戳也不旧,生成器从此成了没人执行的代码。

第三种:环境变化不体现在时间戳上。 换编译器版本、改 -O 级别、改一个影响代码生成的宏、从 gcc 换成 clang——这些变化都不改变任何文件的 mtime,因此在时间戳模型里完全不可见。这是所有基于时间戳的系统的根本盲区,不是 make 的实现缺陷:判据里根本没有“环境”这个维度。手工绕过的办法是把编译选项写进某个 stamp 文件的依赖里(改选项就 touch 它),但这需要纪律,漏一项就前功尽弃。

第三种假阴性是 Ninja 与 Bazel 转向内容哈希与动作签名的真正动机。Bazel 的 action key 把命令、输入内容的哈希、环境变量、工具链版本一起算进签名,任何一项变了就重跑;Ninja 自己不哈希,但依赖生成器(CMake、GN)把完整输入列表写进 .ninja,并用 restat 处理“跑了但输出没变”。它们解决的不是“更快”,而是“让假阴性在结构上不可能”,速度是副产品。

但反证也要往另一个方向打一次。全量构建同样不保证正确:如果图是错的,全量构建只会更稳定地产出错误结果,只是每次错误都一样,所以更容易被发现。全量构建的价值不在正确性,而在消除假阴性——它把“可能没重建”这个不确定性从方程里删掉了。这就是为什么成熟的 CI 流程里必须有一次干净构建:不是不信任增量,而是不信任“这一轮的增量判定”。

所以本章主张的边界条件要说清楚:在图完整、时间戳可信、时钟单调这三个前提同时成立时,增量构建既更快也正确;三个前提缺一个,增量构建就从“优化”退化成“赌博”,而且赌输的时候不会告诉你。

make 从不猜测你的意图,它只忠实执行你画的图——包括图上的错误。

(本章字数:10170,标点体检:通过)


第 3 章 规则体系:从一条显式规则到 173 条内置规则

一个目录里只有一个 foo.c,没有任何 Makefile。执行 make foo.o,屏幕上出现了一行命令:cc -c -o foo.o foo.c。

这行命令不是任何人写的。

它来自 GNU make 出厂时自带的那批隐式规则。用 make -p -f /dev/null 把 make 的内部数据库打印出来,就能看到整张内置规则表;实验 0 统计它为 173 条。你写三行 Makefile,make 用的是一个被冻结在 1970 年代的、关于“如何从 X 得到 Y”的知识库。

本章按“从具体到抽象”的顺序拆这套体系。显式规则是你写下的事实:这个文件由那个文件生成,用这条命令。模式规则是你写下的规律:凡是 %.o 都由对应的 %.c 生成。隐式规则是 make 自带的出厂预设:.c 都能编译成 .o,.y 都能翻译成 .c。三层越往下越省字,也越难排查,因为排查的对象从“你写的文本”变成了“你从未见过的文本”。

3.1 显式规则的全部写法

一条显式规则只有三个位置:目标、前置条件、配方。但这三个位置的组合有六种常见形态,语义各不相同。把它们当成同一种东西的不同写法,是 Makefile 出错的第一大来源。

3.1.1 最小形状

app: main.o  
	@echo "LINK $@ <- $^"  
  
main.o: main.c  
	@echo "CC   $@ <- $<"  
$ make  
CC   main.o <- main.c  
LINK app <- main.o  
$ make  
CC   main.o <- main.c  
LINK app <- main.o  

第二次 make 又全量跑了一遍,因为这两个配方只打印、不产出文件:main.o 和 app 始终不存在,所以永远是过期的(第 2 章 2.2 的三种过期中第一种)。这不是 make 的问题,是示例的问题——但它顺手说明了一件事:make 判断的是文件系统,不是你执行过什么。

3.1.2 多目标:a b: c

a b: c  
	@echo "执行配方,本次目标=$@"  
$ make  
执行配方,本次目标=a  
$ make b  
执行配方,本次目标=b  

很多人第一眼把 a b: c 读成“一次运行、产出 a 和 b”。GNU make 手册对此的措辞是:用 : 分隔的多目标规则,等价于把这条规则按目标各写一遍,前置条件和配方都复制一份。所以:

  • 无参数的 make 只做第一个目标 a,b 根本不会被碰;
  • 一次 make a b 里,如果两个目标都过期,配方会被执行两次,$@ 每次不同;
  • 如果配方是 gcc -o $@ ...,你得到的是两个目标各自的正确产物;
  • 如果配方是“运行一个生成器,同时输出 a 与 b”,你得到的是生成器跑两遍,第二遍覆写第一遍的结果——并行时会同时写同一批文件。

第四种情况是真实的事故来源,GNU make 4.3 专门引入 &: 来修它(见 3.4.10)。

3.1.3 同一目标的多条规则:前置条件是加法,配方是覆盖

先看前置条件的合并:

out.bin: one.h  
out.bin: two.h  
	@echo "前置条件合并后 =[$^]"  
$ make  
前置条件合并后 =[two.h one.h]  

两条规则合成一条:冒号右边的名单被并起来,配方只有一条。这样拆分是有用的——把“跟我有关的头文件”和“我的配方”分开写,后者可以由条件分支或 include 的文件提供。

但要注意实测出来的顺序:two.h 排在了 one.h 前面,与书写顺序相反。这意味着依赖 $<(第一个前置条件)的配方在这里拿到的是 two.h,不是 one.h。合并会改变 $< 的值,而 $< 恰好是绝大多数编译配方唯一使用的自动变量。

现在让两条规则都带配方:

out.bin: one.h  
	@echo "配方一号"  
  
out.bin: two.h  
	@echo "配方二号"  
$ make  
Makefile:4: 警告:覆盖关于目标“out.bin”的配方  
Makefile:2: 警告:忽略关于目标“out.bin”的旧配方  
配方二号  

这就是 overriding recipe 警告。为什么不是“两条配方依次执行”?因为 make 的数据模型里,一个目标只有一个配方槽位。前置条件是列表,可以追加;配方是标量,只能替换。make 见到第二条配方时,除了覆盖没有别的选择,于是它警告——警告的对象不是语法,而是你的意图:它怀疑你以为自己在追加命令。

这个警告值得严肃对待。它出现时通常意味着三种错误之一:你在两个地方定义了同一个目标(include 了重复文件、或 all 写在两处);你想用双冒号但忘了写第二个性;你从别处复制了一段 Makefile 而没注意到目标重名。

3.1.4 双冒号规则 ::

:: 是“一个目标可以有多条规则”的合法写法:每条规则独立判断过期、独立执行配方。

log.txt:: a.in  
	@echo "第一条配方,前置=[$^]" >> log.txt  
  
log.txt:: b.in  
	@echo "第二条配方,前置=[$^]" >> log.txt  
$ make  
$ cat log.txt  
第一条配方,前置=[a.in]  
第二条配方,前置=[b.in]  
$ touch a.in && make  
$ cat log.txt  
第一条配方,前置=[a.in]  
第二条配方,前置=[b.in]  
第一条配方,前置=[a.in]  

只更新了 a.in,只有第一条规则过期,所以只追加了一行。这是 :: 与 : 的实质区别:: 的两条规则共用一个判定结果,:: 的两条规则各自判定。

:: 的典型用途:

  • 同一个目标有多种生成方式,条件不同(例如 parser.c 既可能从 .y 生成,也可能由某个脚本生成,各有各的前置条件);
  • 需要在目标过期时执行多个独立动作,而这些动作不该合并成一条配方(一条配方失败会中断,分开则可以独立处理)。

代价是自动变量的语义变了:$^ 只含本条规则的前置条件,$? 也只反映本条规则的比较结果。在一个大量使用 :: 的 Makefile 里,$^ 不再是“这个目标的全部前置条件”,读代码的人必须记住这一点。所以:: 属于“知道就好、尽量少用”的一类语法。

3.1.5 无配方的规则:只声明依赖

app: main.o util.o  
	@echo "LINK $@ <- $^"  
  
main.o util.o: conf.h  
$ make  
cc    -c -o main.o main.c  
cc    -c -o util.o util.c  
LINK app <- main.o util.o  
$ touch conf.h && make  
cc    -c -o main.o main.c  
cc    -c -o util.o util.c  
LINK app <- main.o util.o  

第二条规则没有配方,它只做一件事:往依赖图里加两条边(main.o 依赖 conf.h,util.o 依赖 conf.h)。而 main.o 的配方是 cc -c ——这条命令来自内置的隐式规则,不是你写的。

这个例子同时演示了两件事。第一,头文件依赖的标准手法就是这种“只声明、不实现”的规则,-MMD 自动生成的 .d 文件用的也是这个形状。第二,“有规则”与“有配方”是两件事:一条规则可以只有前置条件而没有配方,此时 make 会去找别的规则来提供配方,找不到才报错。这是隐式规则得以悄悄介入的入口,也是 3.3 要讲的全部问题的起点。

3.1.6 分号内联配方

hello: ; @echo "内联配方执行,目标=$@"  
$ make  
内联配方执行,目标=hello  

分号后面的内容被当作第一条配方命令,适用于大量同构的一行规则(clean: ; rm -rf build、distclean: ; rm -rf build *.o)。限制很硬:只能带一条命令,不能续行写多条配方,也不能用多行的形式混写。

3.1.7 续行

app: main.o \  
     util.o \  
     conf.h  
	@echo "前置条件=[$^]"  
$ make  
前置条件=[main.o util.o conf.h]  

反斜杠把多个物理行拼成一个逻辑行,拼接处变成一个空格。它只在前置条件列表、变量赋值、函数调用这些地方有意义;配方不需要续行——配方本来就是多行的,每一行一个 tab。一个常见误解是“配方太长可以用反斜杠续行省 tab”,实际上反斜杠续行在配方里意味着“这一行 shell 命令还没写完”,交给 shell 处理的语义完全不同。

六种写法看完,可以收一句:它们都在做同一件事——往依赖图里加边,并可选地给某个节点绑一条配方。:: 与 : 的差别、多目标与单目标的差别,都只是“加几条边、配方归谁”的差别。理解了这一点,这些语法就不需要背。

3.2 模式规则与词干

显式规则一条管一个目标。十个 .o 文件就要写十条规则,这时需要的是规律而不是事实:凡是 X.o 都由 X.c 生成。

3.2.1 % 与词干 $*

OBJS = a.o b.o  
all: $(OBJS)  
  
%.o: %.c  
	@echo "CC $@ <- $<  词干=$*"  
$ make  
CC a.o <- a.c  词干=a  
CC b.o <- b.c  词干=b  

% 匹配任意一段非空子串,匹配到的部分叫词干(stem),保存在 $* 里。前置条件模式里的 % 被同一个词干替换,于是 %.o: %.c 对 a.o 生成前置条件 a.c。

% 可以出现在模式的中间:lib/%.o 匹配 lib/x.o,词干是 x;%.test.o 匹配 foo.test.o,词干是 foo。词干是被两个固定部分夹住的那一段。

一个模式里写两个 % 会怎样?实测:

%.a%b:  
	@echo "目标=$@  词干=$*"  
$ make foo.abar  
make: *** 没有规则可制作目标“foo.abar”。 停止。  

GNU Make 4.4.1 下这条规则没有匹配 foo.abar。实践结论很简单:目标模式里只写一个 %,其余的 % 若要当字面字符,用 \% 转义。前置条件模式里的 % 数量也以对称为好,多写的那些在替换时没有对应的词干可用。

3.2.2 目录前缀:剥离、匹配、拼回

先看一个看起来完全正确的规则,和它必然的失败:

all: build/foo.o  
  
build/%.o: src/%.c  
	@echo "CC $@ <- $<  词干=$*"  
	gcc -c -o $@ $<  
$ make  
CC build/foo.o <- src/foo.c  词干=foo  
gcc -c -o build/foo.o src/foo.c  
Assembler messages:  
致命错误:can't create build/foo.o: 没有那个文件或目录  
make: *** [Makefile:4:build/foo.o] 错误 1  

注意第一行:匹配是完全正确的。目标 build/foo.o,词干 foo,前置条件 src/foo.c。失败的是目录 build 不存在,而 gcc 不会替你创建输出目录(它只会报一个不太友好的“Assembler messages”)。

修法是加一行:

all: build/foo.o  
  
build/%.o: src/%.c  
	@mkdir -p $(@D)  
	@echo "CC $@ <- $<  词干=$*  目录=$(@D)  文件名=$(@F)"  
	gcc -c -o $@ $<  
$ make  
CC build/foo.o <- src/foo.c  词干=foo  目录=build  文件名=foo.o  
gcc -c -o build/foo.o src/foo.c  
$ ls build  
foo.o  
$ make  
make: 对“all”无需做任何事。  

$(@D) 是目标的目录部分,$(@F) 是文件名部分。@mkdir -p $(@D) 大概是 out-of-tree 构建里出现频率最高的一行代码:它幂等、不产生输出、不参与时间戳比较。用 order-only 前置条件(第 2 章 2.5)也能表达“先建目录再编译”,但那需要为每个目录写一条规则,而 mkdir -p 一行覆盖所有层级。

还有一件更隐蔽的事:当目标模式里不含斜杠时,make 会先把目标名里的目录剥掉再匹配,然后把目录拼回前置条件里。实测(只放 src/a.c,没有 build/a.c):

all: build/a.o  
  
%.o: %.c  
	@echo "目标=$@ 词干=$* 前置=$<"  
	@touch $@  
$ make  
make: *** 没有规则可制作目标“build/a.o”,由“all” 需求。 停止。  
$ touch build/a.c && make  
目标=build/a.o 词干=build/a 前置=build/a.c  

make 把 build/ 剥掉,用 a 去对 %.o,命中后把 build/ 拼回去,得到前置条件 build/a.c——所以它找的是 build/a.c,不是 src/a.c,也不是 a.c。而 $* 打印出来是 build/a,带着目录。

这两件事解释了 %.o: %.c 的两个命运:在扁平目录下它完美工作;一旦产物要和源码分开放,它就会去产物目录里找源码,然后失败。这不是 bug,是“词干不含目录前缀、但生成的前置条件含目录前缀”这条规则的必然结果。

3.2.3 静态模式规则:目标集合先确定

OBJS = a.o b.o  
all: $(OBJS)  
  
$(OBJS): %.o: %.c  
	@echo "静态模式:$@ <- $<  词干=$*"  
$ make  
静态模式:a.o <- a.c  词干=a  
静态模式:b.o <- b.c  词干=b  

静态模式规则的语法是 目标列表: 目标模式: 前置条件模式,中间多了一个冒号。三种规则放在一起看,差别只在“谁决定这条规则管哪些目标”:

  • 显式规则:管你写出来的那个目标,一条一个;
  • 模式规则:管所有匹配这个模式的目标,但只有在目标没有别的配方、且前置条件能被找到时才生效;
  • 静态模式规则:管你列出的那批目标,一个不多一个不少。

差别在出错时最清楚。把 OBJS 换成一个没有源文件的目标:

OBJS = a.o z.o  
all: $(OBJS)  
$(OBJS): %.o: %.c  
	@echo "静态模式:$@ <- $<"  
$ make  
静态模式:a.o <- a.c  
make: *** 没有规则可制作目标“z.c”,由“z.o” 需求。 停止。  

z.o 被这条规则“认领”了,它必须由 z.c 生成,而 z.c 不存在、也没有规则能造出它,于是报错。如果换成普通模式规则 %.o: %.c,z.o 根本不会被这条规则认领——make 会继续找别的规则,最后报的错是“没有规则可制作目标 z.o”,指向的目标不同。

这个差别听起来细,实际很重要:静态模式规则的报错告诉你“我以为它该从 z.c 来”,普通模式规则的报错只说“我不知道 z.o 怎么来”。前者保留了你写下规则时的意图。

GNU 邮件列表上关于多词干模式规则的讨论里,有人给出了很到位的一句评价:“静态模式规则的名字容易误导,它跟模式规则没有关系,只是借用了 % 做匹配”。模式规则是给 make 增加一条隐式规则模板;静态模式规则是直接生成一批显式规则,只不过用 % 省掉了重复书写。

静态模式规则的两个实际好处(手册明确列出的):其一,文件名无法用词法规则分类时(比如 foo.elc 和 bar.o 混在一个变量里),可以明确指出哪些归自己管,其余用 filter 挑出来;其二,避免“多条隐式规则都能匹配时按定义顺序选一条”的不确定性——静态模式规则管的范围是写死的,不受规则顺序影响。

3.2.4 后缀规则:.c.o:

.SUFFIXES: .c .o  
  
.c.o:  
	@echo "后缀规则触发:$@ <- $<  词干=$*"  
$ make x.o  
后缀规则触发:x.o <- x.c  词干=x  

.c.o: 是 %.o: %.c 的前身,1976 年的原始语法。GNU make 保留它,并且在内部把它翻译成等价的模式规则;make -p 的输出里这些条目和 % 形态的条目同时存在,这也是 3.3 里那组“内置规则有多少条”的数字对不上的原因之一。

后缀规则生效的前提是后缀出现在 .SUFFIXES 列表里。.SUFFIXES: 清空列表,.SUFFIXES: .c .o 是设置列表——很多人只写 .SUFFIXES: .c .o 就以为够了,实际上那把默认列表替换掉了,x.o 之外的写法会突然不工作。

GNU make 4.3 的发布公告里有一条很说明态度的改动:带前置条件的后缀规则在 POSIX 模式下不再被当作后缀规则(旧行为作为兼容保留)。POSIX 规定后缀规则不能带前置条件,这条改动等于官方在提示——后缀规则是遗留状态,新代码没有理由用它。它唯一还值得知道的原因是:读老代码时必须认得它。

3.3 隐式规则链:make 如何“猜”出你没写的规则

3.3.1 它有多大,取决于你怎么数

$ make -p -f /dev/null | grep -c '^%\.'  
72  
$ make -p -f /dev/null | grep -cE '^\.[A-Za-z]+\.'  
35  
$ make -p -f /dev/null | grep -cE '^(%|\.[A-Za-z]+\.)[^ ]*:'  
126  

72 是以 % 开头的模式规则,35 条是 .c.o: 形态的后缀规则,126 条是“带冒号的规则行”,这三个数都能用上面三条命令当场复现。实验 0 的脚本数出来的是 173,它用的命令是 make -p -f /dev/null | grep -cE '^%|^\.[a-zA-Z]'——匹配所有以 % 或 .字母 开头的行,于是把规则本身和紧随其后的旁注行(要执行的配方)一起算了进去,是个偏宽的口径。本书正文引用 173 时,指的都是这个宽口径;想要一个可复现的窄口径,就用第三条命令得到的 126。

需要声明的是,这几个数只在本书的实测环境里成立:GNU Make 4.4.1、gcc 14、Linux x86_64。换一个发行版,内置规则表随版本与编译选项变化,数字会跟着变;但变的是口径与表的版本,不是机制。

这个不一致本身值得停一下:一个三十年前就冻结的规则库,连“它有多少条”都没有权威答案。这正是本章要说的性质——它的规模在你的感知之外。你不知道它有多少条,也就无从判断某次构建里到底有哪几条在起作用。

3.3.2 一条内置规则长什么样

$ make -p -f /dev/null | grep -A3 -m1 '^%.o: %.c'  
%.o: %.c  
#  要执行的配方 (内置):  
	$(COMPILE.c) $(OUTPUT_OPTION) $<  
  
$ make -p -f /dev/null | grep -E '^(CC|COMPILE\.c|OUTPUT_OPTION) ='  
COMPILE.c = $(CC) $(CFLAGS) $(CPPFLAGS) $(TARGET_ARCH) -c  
OUTPUT_OPTION = -o $@  
CC = cc  

把三层变量代进去,%.o: %.c 展开成 cc $(CFLAGS) $(CPPFLAGS) -c -o foo.o foo.c。所以CC ?= gcc 不会生效——CC 出厂已经是 cc(实验 19 实测 CC=cc,第 4 章 4.5 会再讲优先级)。

3.3.3 没有 Makefile 也能构建

$ ls  
foo.c  
$ make foo.o  
cc    -c -o foo.o foo.c  
$ make foo  
cc   foo.o   -o foo  

两步都由内置规则完成:%.o: %.c 负责编译,%: %.o 负责链接。这是 make 在 1976 年的目标受众眼里最像魔术的地方——新写一个 C 文件,敲 make,可执行文件就在那里了。这也是它当年能迅速流行的真正原因:不是因为它强大,而是因为它在你什么都不写的时候已经能用。

3.3.4 规则链搜索:按内置顺序试到第一条可行的

只有 foo.cc 时:

$ ls  
foo.cc  
$ make foo.o  
g++    -c -o foo.o foo.cc  

-p 里 %.o 的候选前置条件:

$ make -p -f /dev/null | grep -E '^%\.o:'   
%.o:  
%.o: %.c  
%.o: %.cc  
%.o: %.C  
%.o: %.cpp  
%.o: %.p  
%.o: %.f  
%.o: %.F  

make foo.o 时,make 按这张表的顺序逐条试:%.o: %.c 需要 foo.c,不存在;%.o: %.cc 需要 foo.cc,存在,采用。采用时不只换了前置条件,还换了工具链——$(COMPILE.cc) 里的 $(CXX) 默认是 g++,所以输出是 g++ 而不是 cc。

这个“试到第一条可行的链”的过程叫规则链搜索。链可以多级:要得到 foo.o,%.o: %.c 需要 foo.c,而 foo.c 又可能由 %.c: %.y 从 foo.y 生成,make 会继续往下找,直到所有叶子都能在文件系统里找到。找不到就报“没有规则可制作目标”。

关键的一句:链的选择取决于“此刻有哪些文件恰好存在”。同一个 Makefile,在两台机器上可能走不同的链(一台装了 yacc 生成过 .c,另一台没有),而两条链可能产出不同的结果。

这条“猜测”的性质值得再往下一层:它不是免费的便利,是拿文件系统的即时状态换来的。代价有三层。

第一层是试错。要构建 foo.o,make 依次 stat foo.c、foo.cc、foo.C、foo.cpp、foo.p、foo.f、foo.F,直到找到存在的那个。单看一个目标可以忽略,但一万个目标就是数万次多余的 stat;这些调用不会出现在 -n 的输出里,不会有任何提示,只表现为“make 启动后那几秒不知道在干什么”。这是 make 在大图上慢的一部分来源,第五章会把它与 Ninja 的“一次建全图”对照。

第二层是不可重现。链的选择取决于构建那一刻磁盘上有什么。同一份源码,在装过 yacc 的机器上,foo.c 可能由 foo.y 生成;在没装过的机器上,foo.c 若已存在就原地采用,若不存在就报“没有规则可制作目标”。同一个 Makefile,两台机器,两条链,两种产物。要判断一次构建究竟走了哪条链,只能靠 --debug 或 -p 把内部状态挖出来。

第三层最隐蔽:链上生成的中间文件被视为隐式中间文件,构建结束会被删掉(3.4.3 有实测)。于是日志里会同时出现“生成了 foo.c”和“rm foo.c”。如果这一步正好承担了你要交付的产物(例如生成的解析器源码要随发行版发布),它会在无人注意时消失,而构建仍然报告成功。

3.3.5 关掉它

$ make -r foo.o  
make: *** 没有规则可制作目标“foo.o”。 停止。  
$ make --no-builtin-variables h.o  
make: *** 没有规则可制作目标“h.o”。 停止。  

-r(--no-builtin-rules)清空规则表,--no-builtin-variables 清空内置变量。后者看起来只关变量,但 %.o: %.c 的配方是 $(COMPILE.c) $(OUTPUT_OPTION) $<,变量被清空后这条配方展开成空,于是同样构不出目标——两个开关的实际效果在小项目里往往没区别。

工程上的常见做法是在 Makefile 里写一行 MAKEFLAGS += -r 或直接要求 make -r,让构建只用你写下的规则。这不会让构建变快多少,但会让“为什么这么构建”这个问题有一个明确的答案范围。

3.3.6 出厂预设的世界观

内置规则覆盖的不只是 C。-p 的输出里能看到 .y(yacc)、.l(lex)、.f 与 .F(Fortran)、.p(Pascal)、.s(汇编)、.def/.sym(Windows 模块定义)、SCCS 与 RCS 的 co、get,还有 .tex → .dvi → .ps 的排版链、ctags、install。

这不是一堆零散的技术决定,而是一台 1970 年代贝尔实验室机器的快照:它当时装了哪些编译器、用哪套版本控制、排论文用什么工具,全被冻结成规则表,跟着每一份 GNU make 分发到 2026 年的每台机器上。

于是同一套机制在小项目和大项目里呈现出完全相反的面貌。

小项目:省事,而且正确率意外地高。一个二十行的 C 工具,不写任何规则,make 就能编译链接;加一个 .l 文件,make 甚至知道要调 lex。这些便利是真的,不是历史包袱的错觉。

大项目:致命。问题不是它做错了事,而是它在你看不见的地方替你做决定。一个 .c 文件被当成“应该由 %.c: %.y 生成”的目标、一个 Java 或 Python 项目里某个文件恰好命中 %: %.o 而去尝试链接、一个手写脚本的名字和内置规则的目标模式撞上——这类事故的共同点是:报错信息里出现的规则你没有写过,而你必须先把 make 的内置表在脑子里重建一遍才能读懂它在说什么。

调试的时候,你观察到的行为不来自你写的任何一行。这是隐式规则的真实成本,也是 3.6 节要展开的核心。

3.4 特殊目标清单

特殊目标是以 . 开头、名字被 make 认识的伪目标。它们不产出文件,改的是 make 自己的行为。下面每一条都有实测输出。

3.4.1 .PHONY:把文件系统从判定里摘出去

$ ls  
clean  Makefile  
$ make clean  
make: “clean”已是最新。  

目录里恰好有个叫 clean 的文件,于是 make 按第 2 章的规则判断:目标存在、没有前置条件、不需要重建,沉默退出。配方里的 rm -rf 一行都没执行,而你以为清理过了。

.PHONY: clean 让 make 跳过存在性与时间戳判断,每次调用都执行配方。它解决的是“动作型目标被同名文件挡住”这一类问题,clean、test、install、all、help 都该声明。

它的代价很小,但有一个:.PHONY 目标永远被视为过期,因此任何依赖它的目标每次都重建。把 .PHONY 加在真正的产物上(比如 app)会让增量构建彻底失效——这类错误在代码评审里很不显眼。

3.4.2 .DELETE_ON_ERROR:失败就删掉半成品

$ make; ls  
make: *** [Makefile:3:bad.txt] 错误 1  
bad.txt  
Makefile  

配方中途失败,bad.txt 留在原地。加上 .DELETE_ON_ERROR: 之后:

$ make  
make: *** [Makefile:4:bad.txt] 错误 1  
make: *** 正在删除文件“bad.txt”  
剩余文件: Makefile  

为什么必须删:半成品的时间戳可能比它的前置条件还新。下一次 make 会认为它是最新的,于是你得到一个“构建成功、内容是坏的”产物,而错误信息已经在上一轮被刷掉了。这类故障是增量构建里最难查的一种,因为它把失败伪装成了成功。

.DELETE_ON_ERROR 值得无脑加上。它默认关闭不是设计选择,而是历史原因——1976 年的 make 没有这个概念,加上它会改变已有 Makefile 的行为,所以只能作为可选项提供。

3.4.3 .INTERMEDIATE 与 .SECONDARY:中间文件的去留

$ make; ls  
生成 mid.tmp  
生成 final.out  
rm mid.tmp  
剩余文件: a.src final.out Makefile   

构建结束,make 主动执行了 rm mid.tmp,因为 .INTERMEDIATE: mid.tmp 把它声明成中间文件。中间文件指的是依赖链上“造出来只为给下一步用”的产物——预处理的 .i、汇编的 .s、代码生成器的 .tmp。它们占空间、且可以被重建,所以 make 在不再需要时删掉。

把 .INTERMEDIATE 换成 .SECONDARY,行为相反:

$ make; ls  
a.src  
final.out  
Makefile  
mid.tmp  

.SECONDARY 的含义是“它是中间文件,但请留下”。它同时也让这些文件在构建被中断时不被删除——这一点与 .PRECIOUS 有重叠,区别是 .SECONDARY 还带有“中间文件”的语义(不会因为“不存在”而触发重建判定之外的额外行为)。

.INTERMEDIATE 不带前置条件时作用于所有中间文件,这个用法比逐条声明常见得多。

3.4.4 .PRECIOUS:中断时别删

make 的默认行为是:构建被信号中断时,删掉正在生成的那个目标。理由是它可能只写了一半。.PRECIOUS: 目标 让它留下。

更常见的用法是和 .DELETE_ON_ERROR 一起用——留一个坏文件用于事后解剖。实测对照:

$ make        # 只有 .DELETE_ON_ERROR:  
make: *** [Makefile:4:bad.txt] 错误 1  
make: *** 正在删除文件“bad.txt”  
$ ls  
Makefile  
  
$ make        # .DELETE_ON_ERROR: 加 .PRECIOUS: bad.txt  
make: *** [Makefile:5:bad.txt] 错误 1  
$ ls  
bad.txt  
Makefile  

bad.txt 活了下来,且没有“正在删除”那行提示。这就是全部机制:.PRECIOUS 覆盖删除决定。

它的代价必须说清:坏产物留在原地,下一次 make 很可能认为它是最新的(它的时间戳比前置条件新),于是你为了这一次的可调试性,换掉了后续所有构建的增量正确性。修完之后记得删掉它。

3.4.5 其余六个:结论、坑与判据

剩下六个特殊目标,机制都不复杂,坑各有各的形状。逐条给结论,只留关键的那几行实测。

.NOTPARALLEL 把整张图拉回串行,是共享资源(同一日志、同一端口、同一临时目录)没有写进依赖时的逃生舱。代价是没有粒度:要么全串行,要么不管。GNU make 4.4 起它可以带前置条件,但含义比直觉窄——指的是“列出的目标,其前置条件要串行重建”,不是“列出的目标之间互不并行”。实测把 .NOTPARALLEL: t1 t2 写在两个并列目标身上,-j3 下它们依然同时开始:

$ make -j3        # .NOTPARALLEL: t1 t2,而 t1 t2 是并列目标  
开始 t1  
开始 t2  
开始 t3  

所以要串行化并列目标,仍得用无前置条件的全局写法,或用 .WAIT 把顺序写进列表。

.ONESHELL 让整个配方交给一个 shell,cd、export、循环变量才能跨行存活。默认是每行一个 shell,于是 cd /tmp 之后下一行的 pwd 仍打印 Makefile 所在目录——这是新手最常见的困惑之一。代价三条:@、-、+ 只在第一行被识别;make 只看整个 shell 的退出码,没有 set -e 时中间某行失败会被最后一行成功掩盖;/bin/sh 指向 bash 还是 dash 开始影响结果。

.DEFAULT 是没有规则可用的目标的兜底配方,最实用的场景是给拼错的目标名一个友好提示;.DEFAULT_GOAL 改写默认目标,让 help 可以放在文件顶部,而不至于让无参数的 make 变成打印帮助。

.SECONDEXPANSION 把前置条件推迟到第二阶段展开,main lib: $$($$@_OBJS) 才能按目标名取变量。代价是 $$ 让规则像被转义了两遍(它确实被展开两遍),而且报错时 make 会把未展开的 $($@_OBJS) 当成文件名打出来。

.SILENT 与 .IGNORE 是全局的 @ 与 -,1976 年的调试开关,今天不该用:前者关掉“构建在做什么”这条最重要的信息,后者让 make 失去唯一可靠的失败信号。粒度应该落在单个目标和单条命令上。

&:(4.3 起)解决多目标的重复执行:gen.h gen.c: gen.in 会把配方跑两遍,gen.h gen.c &: gen.in 只跑一次,并且知道任一目标过期时其余一起重建。它存在的直接原因是历史遗留的不一致——同样是多目标,显式规则用 : 表示“独立目标”,模式规则 %.tab.c %.tab.h: %.y 却是“一次调用产出全部”,同一个语法形状在两个位置含义相反。.WAIT(4.4 起)写在 all: a .WAIT b 里,保证 a 完全做完才开始 b,其余照旧并行;它承认图不完整,但先让构建跑对。

这六个里,日常真正会用到的是 &:(代码生成器一产多)与 .WAIT(顺序补丁),其余四个属于“知道它存在、出事时能想起来”的层级。

3.5 VPATH 与 vpath:文件定位的代价

源码放在 src/,头文件放在 include/,Makefile 放在顶层。最直接的想法是告诉 make“找不到就去这些目录里找”,这就是 VPATH。

VPATH = src:include  
all: v.o  
  
%.o: %.c  
	@echo "目标 $@ = [$@]"; echo "首前置 $< = [$<]"  
	@echo "目录 $(@D) = [$(@D)]"  
	@touch $@  
$ make  
目标 v.o = [v.o]  
首前置 src/v.c = [src/v.c]  
目录 . = [.]  
$ ls  
include  
Makefile  
src  
v.o  

VPATH 是一组“额外搜索目录”,对所有文件名生效,前置条件和目标都搜。找到 src/v.c 之后,$< 拿到的是完整路径 src/v.c,而 $@ 仍然是 v.o。

这就是 VPATH 最要紧的不对称:输入带路径,输出不带路径。gcc -c -o $@ $< 于是把 v.o 写在当前目录,源码留在 src/,产物和源码混在顶层。如果配方里手写文件名(gcc -c -o v.o v.c)而不是用 $<,连源码路径都会丢——这是 VPATH 用户遇到的第一个错误。

vpath 指令把搜索范围收窄到某个模式:

vpath %.c src  
vpath %.c gen  
$ make  
目标=[w.o] 前置=[src/w.c]  
$ make -p -f Makefile | grep vpath  
vpath %.c src  
vpath %.c gen  
  
# 2 “vpath”搜索路径。  

vpath %.c src 只对 .c 文件生效,比 VPATH 精确。多条 vpath 按书写顺序尝试,第一个命中就用——如果 src/ 和 gen/ 里都有 w.c,用哪个只取决于指令顺序,而与时间戳无关。这种“优先级藏在顺序里”的机制,是排查困难的常见来源。

GPATH 是最少人知道的一个。默认情况下 VPATH 只在“找输入”时起作用,找到的目标仍然在本地重建:

$ make          # VPATH = src  
重建 g.o <- src/g.c  
$ ls  
g.o  Makefile  src  
  
$ make          # VPATH = src 加 GPATH = src  
重建 src/g.o <- src/g.c  
$ ls  
Makefile  src  

有 GPATH 时,make 在搜索目录里找到目标本身之后,就在原地重建,保留路径。src/g.o 被更新,当前目录不再冒出 g.o。GPATH 用得很少,但它是理解“VPATH 只管输入、不管输出”的钥匙。

3.5.1 为什么现代项目更愿意显式写 build/%.o: src/%.c

VPATH 的历史使命很明确:同一份源码为多个架构构建。Autoconf 手册在讲构建目录时给出的理由就是这个——用 VPATH 找到源目录里的文件,让 .o 与可执行文件落在构建目录里,源码目录保持干净。Automake 手册则描述了标准做法:build tree 通常镜像 source tree 的目录结构,子目录由构建系统自动创建。

但 GNU make 里没有与 VPATH 对称的“输出目录”指令。StackGuides 上一句总结说得准:make 擅长在那儿找到东西、在这儿把它造出来,反过来不行。产物要去别处,只能靠你在规则里写路径。

于是现代项目更愿意显式写:

build/%.o: src/%.c  
	@mkdir -p $(@D)  
	$(CC) $(CFLAGS) -c -o $@ $<  

比较一下两种做法的可读性:

  • 显式规则里,路径关系写在规则本身:产物在 build/,源码在 src/,目录用 mkdir -p $(@D) 补。make -n 打出来的命令就是你将看到的路径,$< 与 $@ 的关系一眼可见。
  • VPATH 里,路径关系藏在一个变量里。$< 带路径而 $@ 不带,这条不对称必须靠记忆或踩坑获得。同一个 Makefile 在两台机器上的行为差异,还可能来自某个目录里多了一个同名文件。

显式写法的代价是真实的:每个目录层级要一条模式规则,目录创建要处理,源码目录换了地方要改规则而不是改变量。收益是路径关系成为可读文本,而不是搜索算法的隐含结果。

3.6 边界与反证

本章的主张是“越抽象越省字,也越难排查”,并暗示应该往显式化走。这个主张有两个明确的反面证据,必须先摆出来。

反证一:隐式规则省事,是真的省。 一个二十行的 C 小工具,不写任何模式规则,make 能编译能链接;加了 .y 文件,make 知道要调 yacc。数据管道、一次性的分析脚本、只在 POSIX 环境跑的小工具链,靠内置规则加上几条后缀规则活得很好。BSD 系的 make 至今把后缀规则当主推写法,内核之外的很多项目也从未显式写过 %.o: %.c。在几十个文件以下的尺度上,说“隐式规则是技术债”是不成立的。

反证二:显式化本身也有成本。 把每条规律都写成规则,Makefile 从 3 行长到 30 行,引入新的出错面:%.o:%.c 少一个空格照样能跑,直到某天它不跑了;$(OBJS): %.o: %.c 里的目标列表与 OBJS 变量在别处被修改,规则就悄悄少管了几个目标。显式化不是免费的,它把“猜 make 的行为”换成了“维护一份完整的规则表”,后者也会过时。

真正的成本落在可观测性上。一条 cc -c -o foo.o foo.c 到底来自你写的 %.o: %.c,还是来自内置表里的同一条?答案要靠 make -p 把内部数据库打出来,或者 --trace 逐条看。这两个工具都很好用,但它们的共同点是:事后取证,不是事前可读。团队里两个人对同一个 Makefile 的行为预期可以完全不同,而两个人都没有写错——一个人知道内置规则的存在,另一个人不知道。

所以判据不该是“显式比隐式好”,而是“这份构建会被谁、在多长时间里、以多大代价维护”。我认为下面四条是实用的分界线:

  1. 源文件超过 20 个。到这个规模,图的边数已经超过能靠记忆维护的范围,而内置规则只会往图里加你看不见的边。
  2. 需要跨平台。BSD make 与 nmake 没有同一套内置规则,依赖内置规则等于把项目锁死在 GNU make 上。跨平台项目要显式到什么程度,取决于目标平台里最弱的那一个。
  3. 需要精确增量。头文件依赖、生成的代码、多配置产物,这些边内置规则一条都不会替你声明。-MMD -MP 之所以是 C 项目的标配,正是因为内置规则不会读你的 #include。
  4. 需要被非作者维护。读者只能读你写下的文本,读不到 make 的内置表。一个只有作者能读懂的构建,会在作者离开那天变成黑盒。

还有一条反向判据同样重要:如果你只是要一个改完 .c 就能 make 出可执行文件的一次性工具,别显式化。 内置规则在这个场景里是正确的选择,为它写三十行规则是纯粹的浪费。显式化的目的不是好看,是让“为什么这么构建”这个问题有地方可查。

第 3 章从头到尾在拆同一件事的三层皮:你写的规则、你写的模式、make 带来的规则表。三层加起来才是 make 的实际行为,而你在报错信息里看到的往往只是第三层。下一章会进入另一套同样“看起来像配置、实际是程序”的东西——变量与函数,那里的展开时机问题比规则匹配更难直觉化。

规则写得越少,你对构建过程知道得越少。

(本章字数:8701,标点体检:通过)


第 4 章 变量与函数:Makefile 其实是一门函数式语言

有一个实验小到可以手抄,但它推翻了一个几乎所有人都默认的前提。同一个 Makefile,只把其中一行里的 := 改成 =,其余一个字不动,运行 make 得到的结果就变了。没有改规则,没有改文件,没有碰时间戳,也没有动任何编译选项,产出的文本却不一样了。

如果 Makefile 真是配置文件,这不可能发生。配置文件的值是值,改不改赋值符号不影响它是多少。但 make 的变量不是“存了一个值”,而是“存了一段还不一定被展开的文本”。赋值这个动作本身不计算任何东西,它只是在某个时刻决定:这段文本什么时候变成结果,变成结果之后还要不要每次都重算。

把这层想清楚,四种赋值符号就全部塌缩成两个问题:什么时候展开,展开几次。本章第一个小节只做一件事,就是把这两个问题落到实验台上,逐行复盘七行赋值产生的七个结果——其中有两个看起来完全相同,其实是巧合,而那个巧合恰好是绝大多数 Makefile 事故的源头。

4.1 四种赋值符号背后只有两个问题

实验 2 的 Makefile 一共十行,去掉配方就是七行赋值:

A = foo  
B = $(A)-recursive  
C := $(A)-simple  
A = bar  
D ?= default  
D ?= ignored  
E := start  
E += $(A)  
F = start  
F += $(A)  
G :::= $(A)-immediate  
all:  
	@echo "B=$(B)   <- 递归展开,取用时的 A"  
	@echo "C=$(C)   <- 立即展开,取赋值时的 A"  
	@echo "D=$(D)   <- ?= 只补空位"  
	@echo "E=$(E)   <- += 于简单变量:即刻展开"  
	@echo "F=$(F)   <- += 于递归变量:延迟展开"  
	@echo "G=$(G)"  

在 GNU Make 4.4.1 下运行,得到七行输出:

$ make  
B=bar-recursive   <- 递归展开,取用时的 A  
C=foo-simple   <- 立即展开,取赋值时的 A  
D=default   <- ?= 只补空位  
E=start bar   <- += 于简单变量:即刻展开  
F=start bar   <- += 于递归变量:延迟展开  
G=bar-immediate  

B 和 C 是同一个表达式 $(A) 加后缀,唯一差别是冒号。B 存下来的字节是 $(A)-recursive,那三个字符 $(A) 一直没被求值,直到 echo 这一行需要它,此时 A 已经是 bar,于是 bar-recursive。C 在定义的那一刻就把 $(A) 换成 foo,存下来的字节是 foo-simple,之后再没人问过 A 是什么。这就是第一个问题:何时展开。答案只有两个——赋值时,或者使用时。

为什么要有两种?答案在 1976 年的书写习惯里。Makefile 的顺序是由人排的,OBJS = a.o b.o 很可能写在引用它的规则之后,甚至写在另一个被 include 进来的文件里。如果 = 是立即展开,那么所有“先引用、后定义”的写法都会永久锁死成空字符串,书写顺序就成了强制约束。延迟展开换掉了这个约束:值在使用时才求值,顺序自由了,代价是“使用”这个时刻变得重要,而它在源码里没有任何标记——这是本章所有坑的总根源。

D 是第二个问题的一个变种,但它其实不涉及展开时机。?= 只判断“这个名字有没有被定义过”,没有就按 = 赋一个递归变量,有就整行作废。所以第二行 ?= ignored 什么也没做,D 仍然是 default。它看起来像赋值符号,实际是一个条件语句的语法糖,$(origin D) 的返回值才是它真正在管的东西。

真正麻烦的是 E 和 F。两行都是 start 加 += $(A),输出完全相同:start bar。如果只看这个实验,很容易得出一个错误结论:+= 就是追加,跟风味无关。事实是这两个 start bar 来自两条完全不同的路径:

  • E := start 先造出一个简单变量,它的值是字面量 start。执行 E += $(A) 时,make 读到 E 已经是简单变量,于是按简单变量的规矩追加——右侧立刻展开。那一刻 A 是 bar,所以存进去的是 bar 这个结果,E 最终的字面值是 start bar,里面没有任何变量引用。
  • F = start 造出的是递归变量。执行 F += $(A) 时,make 按递归变量的规矩只做文本拼接,存进去的是 start $(A) 这段还没展开的文本。等到 echo 使用 F,A 已经是 bar,展开得到 start bar。

两条路径在这里交汇,是因为从 F += $(A) 到 echo 之间,A 再也没有变过。这就是那个巧合:实验 2 里 A 的最后一次赋值是 bar,而 E 追加时 A 也已经是 bar,于是“赋值时的值”和“使用时的值”恰好相同。把 A 再改一次,两条路立刻分开。补一个实验:

A = one  
RL = $(A)  
RL += $(A)  
SL := $(A)  
SL += $(A)  
UL += $(A)  
A = two  
show:  
	@echo "RL=[$(RL)]  SL=[$(SL)]  UL=[$(UL)]"  
$ make show  
RL=[two two]  SL=[one one]  UL=[two]  

三个变量追加的是同一个 $(A),结果三个都不一样。RL 是递归变量,两个 $(A) 都在使用时求值,都是 two。SL 是简单变量,两处都在赋值时求值,都是 one。UL 此前从未定义,所以 += 退化成 =,造出一个递归变量,使用时求值得到 two——注意它只有一个 two,因为右侧只有一份 $(A)。

这行实验把 += 的真相说完:+= 自己没有任何语义。它先看这个名字现在的风味,然后按那个风味处理右侧。风味不是由赋值符号在追加时决定的,而是在变量第一次被创建的那一刻定死的,之后每次追加都要服从它。这就是最容易误解的地方:人们以为 += 是“立即追加”,其实它只是一个路由器。

递归展开的代价是它必须能停下来。如果一段文本在展开过程中又引用到正在展开的自己,make 会直接终止:

S = $(S) x  
all:  
	@echo "[$(S)]"  
$ make  
Makefile:1: *** 递归变量“S”(最终将会)引用自身。 停止。  

注意报错里那个括号:最终将会。make 允许间接的、暂时不触发的自引用,只有在真的展开并检测到循环时才报错。这是一个有限展开的守卫,它不阻止你写出危险的表达式,只在你真正踩上去的时候喊停。

G 用到的是第四个符号 :::=,GNU make 4.4 引入,并已被 POSIX Issue 8 收录。它的语义比名字难理解,而且几乎所有的二手资料都写错了。手册的原文是:立即展开,但产生的变量仍然是递归变量,每次使用都会重新展开;为了防止意外,展开结果里所有 $ 会被自动改写成 $$。也就是说它做了三件事:展开一次、把结果里的美元符号转义、然后把它当作普通递归变量存起来。后续的重新展开只是把 $$ 脱回 $,不会真的求值任何东西,所以它是安全的。

先看它和 := 表面相同的地方:

var = first  
OUT :::= $(var)  
SIM := $(var)  
var = second  
show:  
	@echo "OUT=[$(OUT)]  SIM=[$(SIM)]"  
$ make show  
OUT=[first]  SIM=[first]  

两者都拿到 first,看起来一模一样。差别藏在两处,手册都点明了。第一处是追加:因为 :::= 造的是递归变量,之后对它用 +=,右侧不会被立即展开。第二处是存储形式,用 make -p 可以直接看见。把例子改成手册里那个更刁钻的版本:

var = one$$two  
OUT :::= $(var)  
OUT += $(var)  
var = three$$four  
show:  
	@echo 'OUT=[$(OUT)]'  
$ make -f M3 show  
OUT=[one$two three$four]  

make -p 里这个变量长这样:

OUT = one$$two $(var)  

前半段是“展开后又被重新转义”的文本,后半段是 += 追加进来、根本没展开的原文。使用时它整体再展开一次:$$ 变回 $,$(var) 求值成 three$four,于是得到 one$two three$four。

不过用 -p 数 $ 的个数之前,得先知道它的打印规则:递归变量(= 与 :::= 造出来的)原样打印存储值;简单变量(:=、::=)则会把值里的每个 $ 再转义成 $$ 打印,因为读回去时它要立即展开。所以上面这行 OUT 显示两个 $,就是存储里真的有两个;而如果你拿 SIMPLE := one$$two 去试,-p 也会显示 SIMPLE := one$$two,可它的存储里其实只有一个 $。要确认任何变量的真实存储,用 $(value):$(value OUT) 实测返回 one$$two,与 -p 一致,这才证明 :::= 确实把 $ 翻倍存了进去。如果把这行换成 :=,OUT 会在第一行就锁死成 one$two,后面的追加会立刻展开成 three$four,得到的字符串虽然一样,但机制完全不同,而且在 $(SHELL) 拼接这种场景下行为会分叉。

:::= 的实际用途只有一个:你想现在算一次,但要把结果当成普通文本签下来,不让它以后再被当成变量引用。它是 := 与“字面量”之间的一个折中,日常写 Makefile 基本用不到。

最后一个边界是二次展开。默认情况下,规则前置条件里的变量在读入阶段就展开完了,而那时没有任何自动变量可用。实验:

foo_DEPS = a.c b.c  
foo: $($@_DEPS)  
	@echo "prereq=[$^]"  
$ make foo  
prereq=[]  

读这一行时 $@ 还是空字符串,$($@_DEPS) 于是变成 $( _DEPS),取不到东西,整条前置条件列表退化为空。用 .SECONDEXPANSION 打破这个限制,并把 $ 转义成 $$:

.SECONDEXPANSION:  
foo_DEPS = a.c b.c  
foo: $$($$@_DEPS)  
	@echo "prereq=[$^]"  
$ make foo  
prereq=[a.c b.c]  

第一次展开只把 $$ 脱成一个 $,真正的展开留到读入阶段结束之后,那时自动变量已经就位。手册里的经典例子同理:.SECONDEXPANSION: 之后写 myfile: $(ONEVAR) $$(TWOVAR),实测 prereq=[onefile twofile];而这个例子里去掉 .SECONDEXPANSION 输出完全一样,因为 $(TWOVAR) 在读取期就能展开。二次展开只在“前置条件里需要用到 $@ 这类当时还不存在的值”时才有意义,它不是“再多展开一次”的通用开关,它只作用于前置条件,配方一行都不受影响。

4.2 自动变量是一组“每次调用重新计算”的局部变量

自动变量经常被讲成一堆缩写,但它们真正的性质是作用域:它们不是 make 的全局变量,而是“某条规则在被执行的那一刻,make 临时绑定的形参”。理解这一点之后,很多看起来诡异的行为就自动合理了——比如为什么在规则外面写 $(info $@) 什么都不打印,为什么在前置条件里用 $@ 拿到空字符串(4.1 末尾那个 prereq=[] 就是这个原因),以及为什么它们不能被赋值。

完整的表不需要背,但需要能对上号:

写法 含义
$@ 目标名
$< 第一个前置条件
$^ 全部前置条件,去重
$+ 全部前置条件,不去重
$? 比目标新的前置条件
$* 模式规则的词干
$% 归档成员名
$(@D) $(@F) 目标所在目录 / 目标文件名
$(<D) $(<F) 首个前置条件的目录 / 文件名
$(^D) $(^F) 全部前置条件的目录 / 文件名
$(%D) $(%F) 归档成员所在目录 / 文件名

表里还缺一个 $|,它是仅顺序前置条件,也就是竖线后面那一组。因为它本身含竖线,写进表格会撞上列分隔符,所以单独放在这里说明:它只列出仅顺序前置条件,$^ 中不含它们,下面用实测数据对照。

实验 3 的输出可以直接逐行对照:

$ make  
stem=$*=a  src=$<=a.c  
stem=$*=b  src=$<=b.c  
目标    $@    = [out.lib]  
首依赖  $<    = [a.o]  
全依赖  $^    = [a.o b.o]  
有序    $+    = [a.o b.o]  
较新    $?    = [a.o b.o]  
目录    $(@D) = [.]  
文件名  $(@F) = [out.lib]  

前两行来自模式规则 %.o: %.c,$* 分别是 a 和 b,$< 分别是对应的 .c。这一条最值得记:$* 只在模式规则和后缀规则里有意义,它表示 % 匹配到的那一段。在显式规则里用 $*,GNU make 只会给你一个跟目标同名的退化值,容易误导。

后七行来自 out.lib 这条规则。$@、$<、$^、$+ 四个在这个例子里有两对相同,因为 out.lib 的前置条件 a.o b.o 没有重复,也没有目录前缀。要让它们分开,需要一个带重复前置条件的规则:

all: dup | sub  
dup: a.c b.c a.c  
	@echo "目标  $$@ = [$@]"  
	@echo "首前置 $$< = [$<]"  
	@echo "去重  $$^ = [$^]"  
	@echo "不去重 $$+ = [$+]"  
	@echo "较新  $$? = [$?]"  
	@echo "仅序  $$| = [$|]"  
	@echo "目录  $$(@D) = [$(@D)]"  
	@echo "文件  $$(@F) = [$(@F)]"  
	@echo "前置目录 $$(<D) = [$(<D)]"  
	@echo "前置文件 $$(<F) = [$(<F)]"  
	@echo "全前置目录 $$(^D) = [$(^D)]"  
$ make all  
目标  $@ = [dup]  
首前置 $< = [a.c]  
去重  $^ = [a.c b.c]  
不去重 $+ = [a.c b.c a.c]  
较新  $? = [a.c b.c]  
仅序  $| = []  
目录  $(@D) = [.]  
文件  $(@F) = [dup]  
前置目录 $(<D) = [.]  
前置文件 $(<F) = [a.c]  
全前置目录 $(^D) = [. .]  

$^ 去重,$+ 不去重,这是它们唯一的差别,也是唯一需要 $+ 的场合:链接器的顺序有意义时,重复的库名可能必须保留。$| 只列出竖线后面的仅顺序前置条件,$^ 里不含它们——这条规则把“必须先有”和“必须更新”分成了两个集合,正是第 2 章 order-only 那节的语义在变量层面的投影。$(^D) 对两个没有目录前缀的文件各返回一个 .,说明这些变体是纯文本切分,语义和 dir 函数一致:找不到斜杠就返回 ./ 或 .,不存在“没有目录”这个状态。

$(@D) 也不是“目录名”这么简单。目标写成 build/out.o 时它给 build,写成 out.o 时给 .,而且末尾不带斜杠。想拼出 $(@D)/$(@F) 那种路径,得自己补那根斜杠。$% 则专门为归档成员服务:目标是 lib.a(foo.o) 这种形式时,$@ 是整个 lib.a,$% 是括号里的 foo.o。这是 1976 年 ar 归档格式留下的痕迹,今天的 Makefile 里几乎见不到,但当你需要给静态库里每个成员单独写规则时,它会突然出现。

$? 是这组变量里唯一带时间戳语义的,也是唯一会在两次运行之间变化的。实验 3 的第二段:

$ touch a.c && make  
...  
较新    $?    = [a.o]  

a.c 被 touch 之后,a.o 成了过期目标,模式规则跑一次;b.o 没有变化,但它仍然在 $^ 里。到链接那一步,a.o 是刚生成的,b.o 是旧的,于是 $? 从 [a.o b.o] 收缩成 [a.o]。注意它衡量的是“比目标新”,不是“这一轮被重建过”。如果 b.o 因为别的原因被重建但时间戳仍然比 out.lib 旧(同秒构建就可能发生),$? 里不会有它。所以用 $? 做增量打包、增量部署的时候,判断依据仍然是时间戳,第 2 章那四个陷阱一个不少地适用。

还有一条常被忽略:自动变量不能赋值。写 $@ = foo 不会报错,但也不会改变任何东西,因为 $@ 在读取期根本不是一个变量,它是一族在配方展开时才被查表填入的占位符。

最后一个与之相关的细节:配方里每一行都由独立的 shell 执行,所以自动变量每行都能用,而 shell 变量不能跨行——除非开 .ONESHELL(实验 10 里那两行 echo 才跑在同一个 shell 内)。自动变量不受这个限制,因为它每一行都会被重新展开一次。这也正是“每次调用重新计算”的字面含义:它不是一个被存下来的值,而是一次查表的结果。

4.3 文本函数:make 的字符串处理库

把 make 的函数当函数看,会产生一堆错误直觉。正确的视角只有一句:输入是一串词,输出也是一串词,中间没有任何类型。没有整数,没有布尔,没有列表对象,没有返回值概念上的“返回”——所谓 $(words ...) 得到 1,得到的是字符串 "1",不是数字 1;所谓 $(if ...) 的判断,判断的是“展开后是不是空字符串”。

实验 7 里六行 $(info) 把这件事摊开了:

$ make  
[info] OBJS=main.o util.o parser.o  
[info] POBJS=build/main.o build/util.o build/parser.o  
[info] foreach=item-1 item-2 item-3  
[info] call=right left  
[info] filter=c cpp c | filter-out=cpp rust go | sort=c cpp go rust  
[info] words=1 files=gen.c  
生成 gen.out 来自 gen.c  

按输入输出的形状,常用的函数可以分成四组。

替换类:subst 和 patsubst。subst 是无脑字符串替换,$(subst .c,.o,$(SRCS)) 会把任何位置的 .c 都换掉,包括文件名中间的。patsubst 按词匹配、按 % 匹配,$(patsubst %.c,build/%.o,$(SRCS)) 只处理整词,并把 % 捕获的词干搬到输出位置,得到 POBJS 那行。简写 $(SRCS:.c=.o) 等价于 $(patsubst %.c,%.o,$(SRCS)),注意它是后缀替换而不是任意位置替换:.c=.o 这种写法只认词尾,这正是它和 subst 的分界线,也是 $(SRCS:.c=.o) 比 $(subst .c,.o,$(SRCS)) 安全的原因。

筛选类:filter、filter-out、sort、findstring。filter 保留匹配任一模式的词,filter-out 保留不匹配的。实验里 LANGS := c cpp rust c go,filter c cpp 得到 c cpp c——注意两个 c 都在,filter 不去重,也不排序,它严格保持输入顺序。filter-out c 得到 cpp rust go。而 sort 得到 c cpp go rust,一个不多一个不少,排序顺带去重。

这里要停一下,因为 sort 是全书最容易讲歪的一个函数。它不是“有副作用地排了序”,它返回的就是“排序且去重的那个新列表”。原来那个 LANGS 一个字没变。make 的函数没有能力修改任何东西,包括变量。所谓“sort 去重”是返回值的属性,不是动作。理解这一点之后,$(sort ...) 被当成“去重工具”到处用就不再神秘——它只是恰好是 make 里唯一一个会去重的函数。

切片类:word、wordlist、words、firstword、lastword。索引从 1 开始,越界返回空字符串而不是报错。实验里 COUNT := $(words $(FILES)) 得到 1,FILES 是 gen.c——因为 $(wildcard *.c) 在那个目录里只匹配到一个文件。这类函数最典型的用法是取路径:$(lastword $(MAKEFILE_LIST)) 就是当前正在读的 Makefile,5.x 里做 include 路径计算时会反复用到。

路径类:dir、notdir、basename、suffix、addprefix、addsuffix、join、abspath、realpath。前七个都是纯字符串操作,只认斜杠和点:$(dir foo) 返回 ./,$(notdir src/) 返回空,$(basename a.tar.gz) 返回 a.tar,$(suffix Makefile) 返回空。后两个不一样,abspath 和 realpath 会去问文件系统,realpath 还会解开符号链接并且要求路径存在。这个区别在写构建脚本时很关键:abspath 可以在文件还不存在时用来算绝对路径,realpath 不行。

四组之外还有两类值得单说。第一类不返回文本、只产生效果:$(info) 打印、$(warning) 警告、$(error) 立即终止、$(shell) 执行命令、$(wildcard) 读目录、$(file) 读写文件。这六个是 make 函数库里仅有的“碰外部世界”的成员,也是 4.6 那节反证的抓手。其余所有函数都是纯函数,同样的输入永远给同样的输出。

第二类是判断与元信息类。if、or、and 是仅有的三个会按条件跳过参数求值的函数,判断依据只有一条——展开后是不是空字符串。make 里没有布尔值,所谓“真”就是“非空”。strip 经常被误会成过滤器,它其实是把首尾空白去掉、把中间连续的空白压成一个空格,最典型的用途是清理 $(shell) 的输出。origin 报变量的来源层级,flavor 报风味,value 取出完全没展开的原文——这三个是调试工具而不是构建工具,4.5 会用到前两个。

关于求值顺序还有一点:函数严格由内向外展开。写 $(patsubst %.c,%.o,$(sort $(SRCS))) 时,sort 先跑完,patsubst 再拿到它的结果。这一点看起来显然,但因为 make 的展开只是“把文本拼出来继续拼”、中间不存在语法树,嵌套稍深的表达式读起来会比实际执行顺序更让人困惑——人按从左到右读,机器按从里到外算。读不懂的时候,把内层单独抽成一行变量,是最有效的调试手段。

最后一个结构性事实:这门语言没有引号,也没有转义。函数参数用逗号分隔,第一个参数前的字符串是函数名,空格会被当作参数的一部分而不是分隔符。所以参数里要带逗号必须绕道:

comma := ,  
empty :=  
space := $(empty) $(empty)  

这两行几乎是所有稍微复杂一点的 Makefile 的固定开场。它们不是为了好看,是因为没有它们就无法表达“带逗号的参数”和“一个空格”。一门没有引号的语言,只能用变量来当引号。

4.4 call、foreach、eval:从文本替换到元编程

实验 7 的后半段做了件在“配置语言”里不该被允许的事:它用 Makefile 生成了 Makefile。

define make_target  
$(1).out: $(1).c  
	@echo "生成 $(1).out 来自 $(1).c"  
	@touch $(1).out  
endef  
$(eval $(call make_target,gen))  

输出是 生成 gen.out 来自 gen.c,说明这三行文本真的变成了一条规则,进了规则库,并且被当成依赖图上的一个节点执行了。整个过程分三层,每一层的语义都不一样。

第一层,$(call make_target,gen)。call 不是函数调用。它没有栈,没有作用域,没有返回值。它的全部行为是:取出名为 make_target 的变量的值,把 $(1) 替换成 gen(顺带把 $(0) 设成 make_target、$(2) 及以后设成空),然后对结果再做一次展开。所以 reverse = $(2) $(1) 才能工作,实验里 call=right left 就是这么来的。这个“函数”没有参数列表,也没有局部变量,参数就是几个临时定义的单字符变量。

第二层,$(eval ...)。这是 make 里唯一一处“文本被解析两遍”的地方,也是整个元编程能力的来源。eval 的参数先被完整展开一次,得到一段纯文本;然后这段文本被送回 make 的解析器,像刚读到一个新文件那样解析。规则的左侧、右侧、配方就在这一刻被建起来。eval 本身返回空字符串,所以它通常单独占一行,看起来像个语句。

第三层,规则被真正注入。此后 gen.out 和手写的 gen.out: gen.c 没有区别,make 会照常判断它是否过期,make -p 会照常把它打印出来。

三件事需要单独记住,其中第二件是新手最容易踩的坑。

第一件:call 不是函数调用,define 也不是函数定义。 define 只是多行变量的语法糖,define X ... endef 和 X = ... 的唯一区别是值里允许换行,风味仍然是递归。define X := 只是顺手指定了风味。所以一个 define 块被 eval 一次,规则就生成一次;生成两次就是两条同名规则,make 会合并它们并给出警告。

第二件:eval 的参数会被再次展开,因此必须用 $$ 保护本该留给 shell 的 $。 这是 4.1 末尾那个 $$ 的同一个机制,只是位置不同。看反例:

define BAD  
out.txt: in.txt  
	@echo "prereq $$< is [$<]"  
	@wc -c $< > $@  
endef  
$(eval $(BAD))  
$ make  
prereq in.txt is []  
/bin/sh: 1: Syntax error: end of file unexpected  
make: *** [Makefile:7:out.txt] 错误 2  

$< 和 $@ 在 eval 展开参数的那一刻就被 make 求值了,而那时不在任何配方上下文里,它们都是空。wc -c $< > $@ 于是变成 wc -c > ,shell 看不懂,报语法错。改成 $$<、$$@ 之后:

$ make -f Makefile_ok out2.txt  
prereq in.txt is [in.txt]  

规则很简单:在 eval 里,凡是本该由配方在运行时才展开的 $,一律写 $$。第一次展开把它脱成 $,第二次(解析成配方之后、真正执行时)才生效。

第三件:eval 的代价是图变成了运行时的产物。 下面这个例子把这个代价和收益同时展示出来。用 foreach 加 eval,为若干模块批量生成编译、测试、清理三组目标:

MODULES := cache net  
CC ?= cc  
define MODULE_RULES  
$(1).out: src/$(1).c  
	@echo "[compile] $(1)"  
	@$(CC) -o $$@ $$<  
test-$(1): $(1).out  
	@echo "[test] $(1) ok"  
clean-$(1):  
	@rm -f $(1).out  
	@echo "[clean] $(1)"  
endef  
$(foreach m,$(MODULES),$(eval $(call MODULE_RULES,$(m))))  
.PHONY: all test clean  
all: $(addsuffix .out,$(MODULES))  
test: $(addprefix test-,$(MODULES))  
clean: $(addprefix clean-,$(MODULES))  
$ make all  
[compile] cache  
[compile] net  
$ make test  
[test] cache ok  
[test] net ok  
$ make clean  
[clean] cache  
[clean] net  

两个模块、十二行模板,生成了六条规则和三个聚合目标。加一个模块只需要往 MODULES 里加一个词。这就是元编程的收益:重复的消失不是靠复制粘贴,而是靠生成。

代价在同一份文件里能看到。make -n -p 把内部数据库倒出来时,里面同时存在两样东西——define 的原文(第 53、55 行)和展开后的规则(第 754 行起):

53:test-$(1): $(1).out  
55:clean-$(1):  
754:clean-cache:  
762:test-net: net.out  
1114:test-cache: cache.out  
1182:net.out: src/net.c  

前两行是文本,后面的是真相。任何一个想读懂这个 Makefile 的人或工具,都必须先把它执行一遍。这就是 4.6 那节要展开的反证的起点。

这里值得把 eval 和另外两个容易混淆的东西分开。一是 include:include 把一个已经存在的文件的内容读进来,所以文件系统里必须真的有那个文件;eval 把一段内存里的字符串当作文件内容解析,不需要任何文件存在。二是 .SECONDEXPANSION:两者都用 $$ 保护,但作用位置完全不同——二次展开只作用于前置条件列表,eval 作用于整段文本,规则行和配方都能生成。它们是同一个“双重展开”机制的两个出口,第三个出口是 $(call) 的嵌套。

顺带一个诊断技巧:怀疑 eval 写错了,先把 $(eval ...) 换成 $(info ...),让那段文本原样打印出来而不被解析。真正喂给解析器的字节,通常和作者以为的不一样。

顺带说一句 foreach。它本身也是纯文本函数:把第二个参数按词切开,逐个代入第三个参数里的 $(var) 展开,然后把结果用空格连起来。所以 $(foreach m,$(MODULES),$(eval ...)) 里那些 eval 是在 foreach 展开时逐个执行的——eval 是唯一一个返回空、却改变了 make 解析器状态的函数。它能被塞进 foreach 的第三个参数,靠的正是“展开即求值”这个副作用。

4.5 变量优先级全景

make 里同一个名字可能来自五个地方,它们的优先级不是拍脑袋定的,而是一层一层的覆盖关系:

  1. 环境变量(最低,除非加 -e);
  2. makefile 里的普通赋值;
  3. 命令行赋值(make V=x),它会压过 makefile;
  4. makefile 里带 override 的赋值,它能压过命令行;
  5. 自动变量与内置变量,它们规则特殊,不参与这个排序。

实测这四层。先看一个最朴素的 Makefile:

V = from-makefile  
show:  
	@echo "V=$(V) origin=$(origin V)"  
$ make show  
V=from-makefile origin=file  
$ V=from-env make show  
V=from-makefile origin=file  
$ V=from-env make -e show  
V=from-env origin=environment override  
$ V=from-env make show V=from-cmdline  
V=from-cmdline origin=command line  

四条命令说清了三件事。第一,环境变量默认打不过 makefile 里的赋值,-e 才让它翻盘,翻盘之后 origin 会明确告诉你它是 environment override。第二,命令行赋值总是赢,origin 是 command line。第三,$(origin ...) 是排查这类问题最快的工具,它直接告诉你是谁写的这个值,比 $(info) 打印值本身有用得多。origin 的可能返回值里还有 default(内置)、automatic(自动变量)、undefined(从未定义),三个都在本章别的实验里出现过。

?= 的坑正好落在 default 这一档上。实验 19:

CC ?= gcc  
FLAGS = -O2  
override LOCKED = fixed  
show:  
	@echo "CC=$(CC) FLAGS=$(FLAGS) LOCKED=$(LOCKED)"  
$ make show  
CC=cc FLAGS=-O2 LOCKED=fixed  
$ make show CC=clang FLAGS=-O0 LOCKED=hacked  
CC=clang FLAGS=-O0 LOCKED=fixed  

三条一次验证完。CC ?= gcc 完全没有生效,CC 是 cc 而不是 gcc——因为 GNU make 启动时就已经内置定义了 CC=cc,?= 看到“这个名字已定义”就直接放弃,它不区分这个定义是来自你自己写的还是出厂自带的。很多人写了 CC ?= gcc 以为能换掉默认编译器,实际上这句话在绝大多数机器上等于没写。

查一下它到底是什么:

$ make show  
CC=cc  origin=default  flavor=recursive  

origin=default 说明它来自内置变量表,flavor=recursive 说明它是个递归变量。想真正覆盖,要么直接 CC = gcc,要么用命令行,要么用 override CC = gcc。顺便说一句,加 -r 也救不了:

$ make -r show  
CC=cc  origin=default  flavor=recursive  

-r 关掉的是内置隐式规则(173 条那个),不是内置变量(92 条那个)。这两个数字是第 1 章实验 0 的两个数字,它们控制的东西不一样,经常被混为一谈。

export 和 unexport 管的是另一个方向的传递:makefile 变量默认不会进入配方的环境,只有 export 过的才会,而未 export 的变量在递归 make 里也看不到:

export EXPORTED = yes  
PRIVATE = secret  
SHELLVAR := $(shell echo "shell 看到 PRIVATE=$$PRIVATE")  
show:  
	@echo "SHELLVAR=[$(SHELLVAR)]"  
	@sh -c 'echo "子 shell 里 PRIVATE=$$PRIVATE EXPORTED=$$EXPORTED"'  
	@$(MAKE) -f sub.mk inner  
$ make  
SHELLVAR=[shell 看到 PRIVATE=]  
子 shell 里 PRIVATE= EXPORTED=yes  
递归层 MAKELEVEL=1  CURDIR=/sandbox/.../k2  EXPORTED=yes  PRIVATE=[]  

三行输出三个事实。$(shell) 在读取阶段就执行了,那时 PRIVATE 虽然已经赋值,但没进环境,所以子 shell 看不见它。配方里起的 sh -c 也一样,PRIVATE 是空,EXPORTED 有值。递归 make 那一层同样拿不到 PRIVATE,因为子 make 只继承了环境和命令行,不会继承父 make 的变量表。

这也解释了 MAKEFLAGS 为什么存在:-j、-k、-n 这些开关不能通过变量表传给子 make,只能序列化成字符串塞进环境,子 make 启动时再自己解析回来。这就是第 5 章实验 9 里 make -j4 能自动传递到子目录的原因,也是 make[1]: 这个前缀的来历——它由 MAKELEVEL 决定,顶层是 0。

同族里还有一个符号容易漏掉:!= 是 shell 赋值,VAR != cmd 看起来等价于 VAR := $(shell cmd),其实不等价。!= 确实立刻执行了命令,但它造出来的是递归变量。这意味着命令输出会被当作一段还会再次展开的文本存起来,如果输出里本身带 $(awk 打印的片段、printf 生成的脚本都常见),那个 $ 下一次使用就会被 make 当成变量引用,行为立刻失控。VAR := $(shell cmd) 没有这个问题,因为赋值时已经展开完毕,函数结果不会被重新扫描。手册对 != 特意写了警告,理由就在这里。

剩下几个特殊变量值得单独列一下。CURDIR 是 make 启动后所在的目录,绝对路径,origin 报的是 file;递归 make 用 -C 切换目录时它会跟着变,所以拿它做路径拼接比 $(PWD) 可靠。MAKEFILE_LIST 是已读入的 Makefile 列表,每 include 一个就长一个,$(lastword $(MAKEFILE_LIST)) 就是当前文件,$(firstword ...) 是主 Makefile。VPATH 与 vpath 是文件定位机制,实测 VPATH = src 之后 build/x.o: x.c 能匹配到 src/x.c,而且 $< 会变成带路径的 src/x.c——这正是它让配方变复杂的原因,第 3 章已经展开过,这里只提醒一句:VPATH 是变量,vpath 是带模式的指令,两者不是同一个东西的不同拼法。

4.6 边界与反证

本章的主张是“Makefile 其实是一门函数式语言”。这个说法在技术上是站得住的,但在工程实践里,最流行的说法恰恰相反:Makefile 是配置文件。两种说法都有人信,所以要给出反证。

先给支持“是程序”的证据,三条,都很硬。

$(shell) 能执行任意命令,并且把结果当数据用。实测:

NOW := $(shell date +%Y)  
N := $(shell echo $$(( 6 * 7 )))  
LN := $(shell printf 'abc\n' | wc -c)  
show:  
	@echo "NOW=$(NOW) N=$(N) LN=$(LN)"  
$ make show  
NOW=2026 N=42 LN=4  

N=42 说明 Makefile 在做算术——虽然算术是交给 shell 做的,但 make 能把它当整数一样参与后续比较,$(if $(filter ...)) 那套写法全靠这个。$(eval) 能生成任意规则,4.4 已经演示了从十二行模板生成六条规则。$(call) 能递归,$(call f,...) 里再 $(call f,...) 是合法的,没有尾递归优化,深了会耗尽内存。

这三者合起来,让 Makefile 成为一个能做任意计算的宏系统。它离图灵完备只差一点:没有真正的循环终止条件(foreach 只能遍历已知列表),但配合递归 call 加条件函数,表达式层面已经足够。

不过在下结论之前,得先站到对面看一次。在绝大多数真实项目里,Makefile 确实是配置:一堆变量赋常量、一堆 目标: 前置条件 加一行 gcc。$(eval) 的使用率低到可以忽略,$(call) 更是几乎没人写,$(shell) 出现的地方九成只是为了取个版本号。这门语言的能力被用了不到一成,剩下九成是模板化的声明。

所以更准确的措辞是:它不是一个“程序”,而是一个偶尔会被当成程序用的配置格式。这个措辞能直接解释它的失败模式——它的语法处处透着配置的假设(无引号、无类型、无错误处理、无作用域),一旦你按程序的方式用它,这些缺席就全部变成事故。反过来,只要你不越界,它的简单反而是优点:可打印、可 diff、可 grep,这正是 1976 年那个“文本而不是二进制”的选择在五十年后仍然兑现的部分。

越界之后,后果有三条,都是同一件事的不同侧面。

第一,无法做可靠的依赖分析工具。 任何静态分析器读 Makefile 时,读到的是程序的源码,不是依赖图。要拿到图,必须执行它——make -p 本质上是“执行到解析结束,然后把内部状态倒出来”。这不叫静态分析,叫部分求值。这也是为什么 IDE 对 Makefile 的支持永远比 CMakeLists 差一档:后者的语法是有限的,前者不是。

第二,编辑器无法补全。 变量名可以是 $($(DIR)_OBJECTS) 这种拼出来的,也可能是 $(eval) 里 $(1).out 生成的。补全器要算出候选集,需要一个 make 解释器。有人真的写了这种东西,但它的复杂度证明了问题本身。

第三,错误信息指向展开后的文本,而不是源码位置。 4.4 那个反例里,make: *** [Makefile:7:out.txt] 错误 2 指向的是 eval 那一行,真正的错误藏在 define 块的第四行;而且报错主体是 /bin/sh,不是 make。展开层数越多,报错位置离真凶越远。这是一个纯工程问题,没有优雅的解法。

Ninja 的选择正好相反,而且它是明说的。官方手册在 Philosophical overview 里把 non-goals 一条条写清楚:不打算提供手写构建文件的舒适语法(应该由别的程序生成),不带任何内置规则(出厂连编译 C 都没有),不提供构建期的定制(选项属于生成器),不提供条件判断与搜索路径这类构建期决策能力。理由只有一句:Making decisions is slow。

它用“不能编程”换“可被工具分析”。代价也写明在同一页:Ninja 比其他构建系统快,是因为它简单到痛苦。更准确的解读是:复杂度没有消失,只是被搬到了生成器里。CMake 或 GN 里那套条件、变量、函数一个都不少,只是它们跑在“生成 build.ninja”那一步,而不是构建的每一步。所以 Ninja 真正拒绝的不是表达能力,而是在构建时做决定这件事。

边界条件因此可以说清楚。当 Makefile 的读者主要是人、规则数量在几百条以内时,这套宏系统是净收益:foreach 加 eval 让重复消失,patsubst 让后缀转换不再手写,代价是多一层间接。当 Makefile 的读者主要是工具、或者规模到了需要工具来理解图的阶段时,它就是负债。判据不是写法优雅不优雅,而是还有没有第二个程序需要读这个文件。有,就用生成器加执行器的两层结构;没有,就用 make 的宏系统,够用且零依赖。

本章拆到最后,剩下的其实是一句很朴素的话:在这套体系里,赋值不是存储而是推迟,函数不是调用而是文本的再一次展开。记住这一句,Makefile 里那些看起来古怪的写法——为什么 = 要等、为什么 += 会看变量原本的类型、为什么 eval 要多加一层 $$——就都能还原成同一条规则的不同侧面。


第 5 章 并行、递归与规模:make 在十万行代码面前

先看三个数字。同一台机器,32 个各睡一秒的任务:扁平的单个 Makefile,make -j8 跑 5.055 秒。把同样这 32 个任务按目录切成 8 份,每份一个 Makefile,顶层用 $(MAKE) -C 逐个进入,还是 make -j8:5.883 秒,只慢了不到一秒。但把这 8 个子目录交给 8 个互不相识的 make -C d -j8 并发跑:2.512 秒。

第三个数最快,却是三个里最坏的一个:它意味着 8 个各自独立建立的令牌池、一共 64 个槽位,全局上限消失了。同一个 -j8,在三种调用方式下有三种语义。本章要拆开的就是这一层,以及它在十万行代码面前如何变成漏建与过建。

本章回答三个问题:-j 到底并行了什么,递归 make 为什么看起来最自然却会漏建,以及代码从一千行长到十万行时,make 的钱到底花在哪里。

5.1 -j 到底并行了什么

并行的单位不是命令,是任务——一个目标的一次配方执行。三行配方在同一个 shell 里串行跑完,-j 不会把它们拆开;两个目标各有一行配方,只要图上没有先后关系,它们就可以同时跑。--output-sync 与 .ONESHELL 这两个选项经常被混在一起谈,实际上它们处理的是两个不同层面:.ONESHELL 管“多行配方塞进同一个 shell”,jobserver 管“同一时刻有几个 shell 在跑”。

make 判断能不能并行的依据只有图:若目标 X 在目标 Y 的前置条件传递闭包里,Y 必须等 X 的配方结束;否则两条配方可以重叠执行。于是有两个量可以写下来:

  • 并行度上限等于依赖图的宽度,也就是任意时刻“前置条件已全部完成”的目标数;
  • 总耗时下限等于关键路径的配方时间之和与“总配方时间除以槽位数”两者中的较大者。

这两个量都不由 make 决定,由图决定。工程上“加了 -j 没用”的例子几乎都落在宽度不够上:all: a 下面挂着一根长长的链,或者所有目标都偷偷依赖同一个生成文件,或者最外层只有“进入各子目录”这一层配方——最后一种把并行度的上限变成目录数,本章开头那 5.883 秒对 5.055 秒的差距就是它的账单。

宽度够的时候,收益是线性的。八个互不相关的一秒任务:

$ make -j8  
real    0m1.026s  
  
$ make -j1  
real    0m8.055s  

同一份 Makefile,-j 从 1 到 8,时间从 8.055 秒掉到 1.026 秒,几乎正好八分之一。这里的 8 个目标是完全独立的,图上宽度就是 8,所以 8 个槽位全部用得上。

第二层机制跨进程:jobserver。make -j8 并不只是给自己开 8 个线程,它在启动时建立一个令牌池。GNU make 4.4 在支持命名管道的系统上用一个 FIFO 做池子,MAKEFLAGS 里出现的是 --jobserver-auth=fifo:/tmp/GMfifo2968 这样的字符串;在不支持命名管道的系统上退回匿名管道,形式是 --jobserver-auth=R,W,R 与 W 是读端和写端的文件描述符。要占一个并行槽位,就从管道里读一个字符;释放槽位时,把同一个字符写回去。子 make 只要解析 MAKEFLAGS 就能接入同一个池子——这就是 make -j8 递归调用不会变成 8×8 的全部机制。手册里对这件事的表述非常克制:make 能并行执行多条配方,并且能把并行总数封顶,即使跨越递归调用。

实测父子两层的 MAKEFLAGS:

$ make -j8  
TOP  MAKEFLAGS=[ -j8 --jobserver-auth=fifo:/tmp/GMfifo2968]  
TOP  MAKELEVEL=0  
make[1]: 进入目录“/sandbox/workspace/book-projects/make/lab/e5a/d4”  
SUB  MAKEFLAGS=[ -j8 --jobserver-auth=fifo:/tmp/GMfifo2968]  
SUB  MAKELEVEL=1  

父子两层看到的是同一个 FIFO 路径。子 make 拿到的 -j8 不是“再开 8 个”,而是“到那个 FIFO 里去领 8 个令牌”。把扁平结构与递归结构的几组测量并排放:

32 个任务,扁平 Makefile                       make -j4    real 0m9.070s  
32 个任务,扁平 Makefile                       make -j8    real 0m5.055s  
32 个任务,扁平 Makefile                       make -j32   real 0m2.074s  
32 个任务,递归 8 目录 × 4 任务                make -j4    real 0m9.703s  
32 个任务,递归 8 目录 × 4 任务                make -j8    real 0m5.883s  
32 个任务,递归 8 目录 × 4 任务                make -j32   real 0m2.515s  
32 个任务,8 个互不相识的 make -C d -j8 并发   无共享池    real 0m2.512s  

这七行数字的测量条件必须先交代,否则它们只是七个小数的对比,读者无从判断置信度。

机器是本书所用的 Linux 容器,nproc 报 3 核,GNU Make 4.4.1。任务不编译任何东西:32 个目标,每个目标的配方只有一次 sleep 1。这样构造是刻意的——如果把配方换成真编译,耗时会被编译器本身与磁盘 I/O 掩盖,-j 的语义差异就看不出来了。也正因为任务是睡眠型、几乎不占 CPU,-j32 在 3 核机器上依然有效,三行 -j32 的耗时落在 2 秒附近,而不是被核数卡在 10 秒。

计时用 bash 内建 time 的 real,其中含约 0.05 秒每任务的进程启动底噪;单次测量,未取多次中位数。所以这七行只用于横向比较,绝对值不可移植,换一台机器重跑会得到不同的小数;但三者的相对关系与任务构造无关,只要任务数远大于槽位数就成立:扁平略快于递归,无共享池最快且语义最坏。

MAKEFLAGS 里那个 /tmp/GMfifoNNNN 每次启动都不同,与耗时无关,写进日志只是为了证明父子两层接的是同一个池子。

倒数第一行和扁平版本的 -j32 几乎相同,是这一节最有信息量的地方:共享 jobserver 时,-j 的语义是全局上限;一旦 8 个子 make 各自建立自己的池子,8 个子目录就是实打实的 8×8 = 64 个槽位。递归 make 会不会退化成 8×8,取决于它是否被父 make 认出来是子 make。

中间三行还有第二层信息:递归版本在每个槽位数上都比扁平版本慢 0.4 到 0.8 秒。这部分与并行无关,是 8 次进程启动、8 次解析、以及每个子 make 各自 stat 一遍自己那一片文件系统的成本。它在 32 个任务时只占 15%,在一万个任务时会变成另一个量级的问题。

认出来的条件是两样东西:用 $(MAKE) 变量而不是字面的 make,以及在配方行上加 + 前缀。两者都做,-j、-k、-n 就会通过 MAKEFLAGS 传下去,jobserver 也传;如果 make 被藏在 shell 脚本里,或者写成 sh -c 'make ...',子 make 就被当成普通命令,它自己另建池子。本章开头那 2.512 秒就是这么来的——那不是 make 的缺陷,是“父进程不知道这条命令是 make”的必然后果。

递归的层级与开关是分开传的,这一点在实验 9 里能看到:

$ make  
顶层 MAKELEVEL=0  
make[1]: 进入目录“/sandbox/workspace/book-projects/make/lab/e9/sub”  
子层 MAKELEVEL=1  CURDIR=/sandbox/workspace/book-projects/make/lab/e9/sub  
make[1]: 离开目录“/sandbox/workspace/book-projects/make/lab/e9/sub”  
  
$ make -j4 -k -C .  
顶层 MAKELEVEL=0  
make[1]: 进入目录“/sandbox/workspace/book-projects/make/lab/e9/sub”  
子层 MAKELEVEL=1  CURDIR=/sandbox/workspace/book-projects/make/lab/e9/sub  
子进程可见 MAKEFLAGS 由 make 自动注入  

MAKELEVEL 是层级计数,make[1]: 前缀是它的可见化形式,MAKEFLAGS 是开关的载体。这三样都不是给业务逻辑用的,但在排查“为什么子目录没有并行”时是唯一线索:看前缀,看 MAKEFLAGS 里有没有 -j,有没有 --jobserver-auth。

还有一个容易漏掉的细节:make 给每条它启动的命令一个隐式槽位,也就是说一条命令至少能做一件并行的事,而不必先向 jobserver 申请,并且这个隐式槽位不归还。所以日志里看到的并发数常常比令牌数多几个。这不是 bug,是为了避免死锁:如果子进程必须先拿到令牌才能启动,而令牌已经发完,就没有任何进程能让出令牌。GNU make 之外的工具要参与这套协议,就得自己实现客户端——LLVM 直到 2025 年才给编译器驱动补上这项支持,动机正是让链接器别在 -j32 的构建里独占整台机器。

槽位数该给多少,没有理论答案,只有经验:等于核数、或核数加一(编译进程会阻塞在磁盘与内存上,多一个槽位能把等待填满),是常见做法。GNU make 还给了两个调节器:-l 按系统负载均值限制,机器上同时跑别的任务时有用,代价是行为不可预测——同一条命令在不同机器上并行度不同;不带数字的 -j 表示不设上限,只适合“目标数远小于核数”的小工程或一次性全量构建,因为它会按图的宽度一路开下去,一个开 32 线程的链接器配方就能把内存吃完。把 MAKEFLAGS += -j8 写进 Makefile 是另一种常见做法,我不推荐:并行度是机器属性,不是工程属性,写进文件的那一天起,它在别人的 8 核笔记本上就是错的。

理论上的天花板还剩下一条串行的尾巴:链接、打包、签名。一万个目标文件在 32 个槽位下几分钟就编完,最后的链接仍然是一分钟,而且必须等所有目标文件就绪。这就是大项目先操心“能不能少链接几次”(动态库、增量链接、按需拆产物)、再操心核数的原因。

一句话:并行度的上限是图的宽度,槽位只是许可证。

5.2 并行带来的新问题

并行把“正确性”卖给图,代价是把“可读性”“可复现性”“隐式顺序”全部推给了你。三类问题各有实测。

第一类是输出交错。实验 6 里 6 个各睡约半秒的任务:

$ time make -j6  
开始 t1  
开始 t2  
开始 t3  
开始 t4  
开始 t5  
开始 t6  
完成 t6  
完成 t5  
完成 t3  
完成 t1  
完成 t2  
完成 t4  
  
real    0m3.575s  
user    0m0.049s  
sys     0m0.119s  
  
$ make -j6 --output-sync=target  
开始 t6  
完成 t6  
开始 t5  
完成 t5  
开始 t3  
完成 t3  
开始 t1  
完成 t1  
开始 t2  
完成 t2  
开始 t4  
完成 t4  

前一组输出说明所有 6 条配方同时开跑,完成顺序按各自的睡眠时长;后一组说明 --output-sync=target 把每条配方的输出攒齐再一次性吐出。三个取值各有用途:target 按目标聚合,最常用;line 按行聚合,适合能自己管好行的工具;recurse 让递归的子 make 也遵守同一约定,否则你在顶层设了同步,输出交错会跑到子目录里去。代价同样存在:同步需要缓冲,输出大的配方会占内存,而且构建失败时你看不到“正在发生什么”,只能等它吐出整块。

--output-sync 有一个短别名 -O,还有一件事容易被忘掉:它只影响输出的呈现,不影响调度。想知道真正的调度顺序,用 --debug=j,它会把每条任务的启动与令牌申请打出来。排查“为什么我的 -j16 只跑出 3 的并发”时,这个选项比任何猜测都可靠——它直接告诉你 make 申请了几个槽、拿到了几个。

注意那行 real 0m3.575s 与 user 0m0.049s 的对比:六个任务睡了约三秒,CPU 时间几乎为零。并行缩短的是挂钟时间,不是工作量;-j 不会让编译变便宜,只会让等待重叠。

第二类是共享资源竞态。实验 18 的两个目标同时往同一个日志追加:

$ make -j2; cat shared.log  
A 写入  
B 写入  
A 完成  
B 完成  

四行的顺序就是证据:A 写到一半,B 就插了进来。这里每一次 echo >> shared.log 本身在大多数系统上是原子的,所以文件没被写坏;但“先读、再改、再写”的配方就会互相覆盖。更常见的形态不是日志,而是中间文件:两个目标的配方都把中间产物写到 build/tmp.o 或 /tmp/gen.h,谁后写谁赢,而且赢的可能是错的那个。这类 bug 的恶劣之处在于单线程永远不出现,只在 CI 的 16 核机器上周五下午出现。

第三类是未声明的隐式顺序依赖。这是三类里最危险的一类,因为它不会报错,只会给出错的结果。一个真实构造:gen 生成了 payload.txt,use 读它,但两者在图上没有边。

all: gen use  
  
gen:  
	@echo "gen 开始"; sleep 1; printf "v2\n" > payload.txt; echo "gen 完成"  
  
use:  
	@echo "use 读到: $$(cat payload.txt)"  
  
.PHONY: all gen use  

三次运行的实测:

$ make -j2                              # 没有守卫  
gen 开始  
use 读到: v1  
gen 完成  
  
$ 加 .NOTPARALLEL: 之后 make -j2  
gen 开始  
gen 完成  
use 读到: v2  
  
$ 改成 all: gen .WAIT use 之后 make -j2  
gen 开始  
gen 完成  
use 读到: v2  

第一组里 use 抢在 gen 之前跑完了,读到的是上一轮的 v1。构建退出码是 0,产物是错的——这就是“并行引入的新一类 bug”的标准形态:串行时代它们根本不存在,因为串行天然把顺序给了你,而并行把“你以为的顺序”变成了“你声明的顺序”。

三种解法,从治标到治本:

  • .NOTPARALLEL: 不给前置条件时,整个 make 退化为串行,包括递归下去的子 make(除非子 Makefile 也写了这个目标)。它是最粗的手术,适合“这个 Makefile 本来就只有一条链”的场景。带前置条件的写法 .NOTPARALLEL: gen use 在 4.4.1 上实测没有让 gen 与 use 串行,输出仍然是 gen 开始 之后 use 抢先执行;手册对这个写法的说明容易被读成“只让列出的目标互斥”,实测不支持这种期待,所以不建议用它来隔离一组有顺序要求的目标。
  • .WAIT 是 4.4 引入的显式等待,写在同一条规则的前置条件列表里:.WAIT 之前的目标全部完成后,才考虑它之后的。它精确、局部,是上面第三组的做法。代价是它只属于 GNU make 4.4 以后,跨平台的构建脚本不能依赖它。
  • 根本解法是把顺序写进图里。如果 use 确实需要 gen 的产物,那 payload.txt 就该是一个目标,use 就该依赖它,顺序是图的推论而不是补救。顺序前置条件(|)适合“目录必须先存在”这类只要求顺序、不参与时间戳比较的边;真实的数据依赖则必须写成普通前置条件。

同一个判据可以覆盖三类问题:并行暴露出来的麻烦,几乎都能翻译成“图少了一条边”。输出交错是展示层的问题,其余两类都是图的问题。

5.3 头文件依赖的自动生成

make 最经典的假阴性是这样发生的:main.c 里有 #include "conf.h",Makefile 里写着 main.o: main.c。改了 conf.h 之后跑 make,make 比较 main.o 与 main.c 的时间戳,得出“已是最新”,什么都不做。程序继续用旧的 main.o 去链接,而它编译时看到的是旧的结构体定义。

这个错误的恶性程度取决于头文件改了什么:改注释无害,改函数声明会产生隐式声明警告,改结构体布局则让两个目标文件对同一块内存有不同理解。后者不会报错,会崩溃,而且崩溃点离改动很远。

办法只有一个:让图上有这条边。手写不行——一个中等项目有几百个源文件、上千条 #include,手写维护的结局一定是漏。所以这条路从 1980 年代起就固定成了一个方案:让编译器顺手把依赖写出来,再让 make 把它读回去。

完整写法:

CC      := cc  
SRCS    := $(wildcard src/*.c)  
OBJS    := $(patsubst src/%.c,build/%.o,$(SRCS))  
DEPS    := $(OBJS:.o=.d)  
DEPFLAGS = -MMD -MP -MT $@ -MF $(@:.o=.d)  
  
all: app  
  
app: $(OBJS)  
	$(CC) $^ -o $@  
  
build/%.o: src/%.c | build  
	$(CC) -Iinclude $(DEPFLAGS) -c $< -o $@  
  
build:  
	mkdir -p build  
  
-include $(DEPS)  

实验 8 是这个方案跑通后的真实形态:

$ make; cat build/main.d  
gcc -Iinclude -MMD -MP -c src/main.c -o build/main.o  
LINK app  
gcc build/main.o -o app  
--- build/main.d ---  
build/main.o: src/main.c include/conf.h  
include/conf.h:  
  
$ sleep 1 && touch include/conf.h && make -n  
mkdir -p build  
gcc -Iinclude -MMD -MP -c src/main.c -o build/main.o  
echo "LINK app"  
gcc build/main.o -o app  

.d 文件只有两行,两行都值得逐字读。

第一行 build/main.o: src/main.c include/conf.h 是一条普通规则:目标、冒号、前置条件。它把编译器真正看到的头文件写进了图。此后 touch include/conf.h 就能触发重编,make -n 的输出证明了这条边生效(连 mkdir -p build 都重新出现,因为那条 recipe 属于 order-only 前置条件的创建动作)。

第二行 include/conf.h: 是一个没有配方的空规则,它由 -MP 添加。它防的是一个具体的报错场景:某天 include/conf.h 被删除或改名,.d 文件里却还留着对它的引用,make 读到这条前置条件时会说“没有规则可制作目标 include/conf.h”并停止。加上 -MP 之后,make 知道这个文件“本来就没有规则”,于是把它当作一条孤立的前置条件,重建 main.o 而不报错。所以 -MP 防的是“头文件被删后 make 拒绝启动”,不是防漏建。

-MMD 与 -MD 的差别只有一条:是否包含系统头。-MD 会把 /usr/include/stdio.h 以及它展开的几十个文件全部写进 .d,-MMD 只写用户头文件。系统头不会变(变了你也管不了),所以 -MMD 是唯一合理的选择:.d 体积小一个数量级,make 需要 stat 的文件也少一个数量级。

-include 与 include 的差别是“文件不存在时是否报错”。这里必须用 -include,因为第一次构建时 .d 还不存在——它是由正在被生成的规则产出的,属于“鸡生蛋”。另有一层更隐蔽的差别:include 的文件如果本身能被某条规则重建,make 会先尝试重建它(make 有“重新制作 makefile”这一阶段,--debug=b 的输出里能看到“正在更新 makefile……”);-include 对不存在的文件不触发这条路径,对存在的文件仍然会。所以一个坏掉的 .d(比如被中途打断写了半行)不是“被忽略”,而是可能被当成需要重建的目标,进而触发一次莫名其妙的失败。

在 out-of-tree 构建里,.d 文件里的目标名必须和 Makefile 里的目标名逐字相同,否则依赖被记到另一个目标名下,等于没写。上面用 -MF $(@:.o=.d) 把 .d 放到 .o 旁边,用 -MT $@ 强制目标名。关于 -MT 有一个值得说清的历史:早期 cpp 在执行 gcc -MD -c test.c -o subdir/bar.o 时,会在当前目录留下 test.d,内容是 test.o: test.c test.h——目标名丢掉了目录,GCC 后来才改成写 subdir/bar.o: 并把依赖文件放进 subdir/bar.d。用现代 gcc 实测,加不加 -MT build/main.o 得到的 .d 完全相同,所以 -MT 在新编译器上多数时候是冗余的;它仍然值得写,是因为“多数时候”不是“总是”——静态模式规则、把中间产物放到别处、生成代码先预处理再用 -MQ 转义空格和 $,这些情况都会让编译器的猜测与 make 的目标名分家。

最后一点工程细节:.d 里的路径是相对于编译时的当前目录的。如果某个配方 cd src && $(CC) ...,写出来的依赖路径就和顶层 Makefile 的相对路径不一致,整张图会悄悄错位。在 make 里 cd 是配方的常态,这也是“非递归 make 要求所有路径从项目根写起”的原因之一。

还有一个常被忽略的收尾动作:clean 必须同时删掉 .d 文件。删了 .o 却留着 .d,下次构建时 make 会先读入“这些东西依赖 include/conf.h”,而那个头文件可能已经被删掉或改名;有 -MP 时它被当作孤立前置条件安静处理,没 -MP 时构建直接停止。这也是我坚持把 -MP 算作必备项而不是可选项的理由。

结论很硬:头文件依赖这件事上,图上的边要么你手写、要么编译器写,没有第三条路。而手写的那条路在几百个源文件的规模上必然失败。

5.4 递归 make 的结构性缺陷

递归 make 的写法是:每个目录一个 Makefile,父目录的 Makefile 用 $(MAKE) -C sub 逐个进去。它之所以流行,原因不在技术,在组织——目录就是团队边界,每个人都只改自己那一个 Makefile,合并冲突小,心智负担小。

Peter Miller 在 1998 年的《Recursive Make Considered Harmful》里做的事,是把这些“各自独立的小麻烦”归到一个根因上。他的论证骨架值得重建而不是复述:先列症状(只想改一个文件却要等不可接受的长构建时间、脏构建、莫名其妙的重复构建、-j 加了没用),再说这些症状看似无关,但分析下来是同一个根因——依赖图被目录人为切成了若干块,每块只看到自己那一角。他给出的方案是让整个工程由一个 make 进程处理,用 include 把各目录的规则片段并进来。

要理解“图被切开”到底会造成什么,最好的办法不是讲道理,是构造一个两目录的反例。A 目录产出一个静态库 liba.a,其中的 a.c 需要 B 目录生成的头文件 b.h;B 目录产出一个程序 app,它要链接 A 的 liba.a。目录结构:

$ ls -R  
.:  
A  B  Makefile  
./A:  
a.c  a.h  Makefile  
./B:  
app.c  gen.c  Makefile  

两边的关键规则:

# A/Makefile  
all: liba.a  
  
a.o: a.c ../B/b.h  
	cc -c a.c -o a.o  
  
liba.a: a.o  
	ar rcs liba.a a.o  
# B/Makefile  
all: app  
  
gen: gen.c  
	cc gen.c -o gen  
  
b.h: gen  
	./gen > b.h  
  
app: app.o ../A/liba.a  
	cc app.o ../A/liba.a -o app  
  
app.o: app.c ../A/a.h  
	cc -c app.c -o app.o  

顶层按“B 在前”的顺序递归:

$ make  
make[1]: 进入目录“/sandbox/workspace/book-projects/make/lab/e5c/B”  
cc -c app.c -o app.o  
make[1]: *** 没有规则可制作目标“../A/liba.a”,由“app” 需求。 停止。  
make[1]: 离开目录“/sandbox/workspace/book-projects/make/lab/e5c/B”  
make: *** [Makefile:4:all] 错误 1  

换成“A 在前”:

$ make SUBDIRS="A B"  
make[1]: 进入目录“/sandbox/workspace/book-projects/make/lab/e5c/A”  
make[1]: *** 没有规则可制作目标“../B/b.h”,由“a.o” 需求。 停止。  
make[1]: 离开目录“/sandbox/workspace/book-projects/make/lab/e5c/A”  
make: *** [Makefile:4:all] 错误 1  

两个顺序都失败,而且失败的原因完全对称:B 需要 A 的库,A 需要 B 的头文件。这不是我故意把规则写坏——每一条规则在自己的目录里都是正确的、可解释的。错的是“一个目录一次 make”这个执行模型:它把一张有环的目录依赖关系硬塞进一个线性的目录列表里。

能跑通的唯一办法是让 B 进入两次:

$ make -C B b.h && make -C A && make -C B  
make: 进入目录“.../B”  
cc gen.c -o gen  
./gen > b.h  
make: 离开目录“.../B”  
make: 进入目录“.../A”  
cc -c a.c -o a.o  
ar rcs liba.a a.o  
make: 离开目录“.../A”  
make: 进入目录“.../B”  
cc app.o ../A/liba.a -o app  

注意第一句 make -C B b.h:它要求调用者知道 B 的内部目标名 b.h。顶层 Makefile 一旦开始引用子目录的内部目标,模块边界就已经不存在了,剩下的只是目录还在。

增量构建时的失败更隐蔽。在全部建好之后,只改 A 的头文件:

$ sleep 1 && touch A/a.h && make  
make[1]: 进入目录“.../B”  
cc -c app.c -o app.o  
cc app.o ../A/liba.a -o app  
make[1]: 离开目录“.../B”  
make[1]: 进入目录“.../A”  
make[1]: 对“all”无需做任何事。  

退出码 0,产物存在,看起来成功。实际上:B 里的 app.o 因为 ../A/a.h 变新而重编并重链了一次(过建),A 里的 a.o 完全没重建(漏建,因为 A 的规则里 a.o 只依赖 a.c 和 ../B/b.h),最后链接出来的 app 是新头文件与旧实现拼在一起的产物。父 make 还郑重宣布“对 all 无需做任何事”。一个构建系统最坏的行为不是报错,是这个。

把症状逐条摊开,可以看到它们是从同一个根因上长出来的:

  • 依赖图不完整。跨目录的边要么写成 ../A/liba.a 这种相对路径魔法(目录一移动就断),要么根本不写。图不完整的直接后果是漏建与过建同时存在,上一段就是现场。
  • 遍历顺序错误。父 make 能并行的上限是目录这一层的宽度:顶层那条 $(SUBS): $(MAKE) -C $@ 编出来只有 8 个目标,-j32 也只有 8 个(以及它们各自派生的子任务)在跑;而当目录数是 3、槽位数是 32 时,这条上限直接把并行度掐死在 3。此外父 make 若真是按 for 循环顺序进入(很多手写 Makefile 就是这么写的),并行度就退化成 1。本章开头 5.883 秒对 5.055 秒的那 0.8 秒就是这条上限加启动开销的账单。
  • 重复加载。每个子 make 都要重新解析自己的 Makefile 和内置规则。单次解析的成本可以用一个数字量级感受:空 Makefile 跑 make -p -f /dev/null 已经要打印 1234 行内置变量与规则(下一节详述)。Mozilla 的 Firefox 一度有大约 1500 个 Makefile,这是公开资料里递归 make 规模的极端案例——每个目录一次解析、一次进程启动、一次 stat 全目录。
  • 变量传递脆弱。命令行变量与 MAKEFLAGS 能跨进程,Makefile 里的赋值不能。父级 export CFLAGS 与子级 CFLAGS += ... 的相互作用,要靠每个人记住规则;override 一出现,优先级就开始反直觉。第 4 章实验 19 的那组优先级(命令行压过 Makefile、override 压过命令行)在单进程里已经够绕,跨进程时还要叠上“环境变量”这一层。
  • 全局目标语义不一致。clean、install、dist 这些目标在递归结构里必须逐目录调用,调用的顺序与依赖无关,失败处理变成父 make 的 shell 循环问题。-k 的“尽力而为”语义也会在进程边界上被打断:子 make 失败了,父 make 的 -k 只保证它继续进入下一个目录。

最后要点出一个诚实的观察:这些缺陷没有一条能让递归 make 从工程中消失。它流行是因为它解决的是人的问题——团队边界、代码所有权、增量上线。Miller 的论文说的是机器的问题,两者不在同一个坐标系里。第 5.7 节会把这条分界线画清楚。

变量传递这一条还值得一个具体形态:父级 Makefile 写 CFLAGS := -O2,子级 Makefile 写 CFLAGS += -g。子 make 通过环境拿到父级导出的值,+= 施加在递归展开变量上时会再展开一次,两种行为叠起来,最终编进目标文件的选项是什么,只有跑一遍 make -n 才知道。更要紧的是 make -n 在递归结构里只打印顶层那一条 $(MAKE) -C,子目录里发生的事它不知道——预演这个排查手段在递归 make 里基本失效。

5.5 非递归 make 的工程做法

非递归 make 的核心动作只有一个:把所有子 Makefile 片段 include 进同一个 make 进程。图完整了,并行度、增量判定、全局目标就全部回到 make 自己手里。

骨架:

# 顶层 Makefile —— 全项目只有一个 make 进程  
CC      := cc  
BUILD   := build  
MODULES := core net  
  
OBJS :=  
DEPS :=  
  
define module  
$(1)_SRCS := $$(wildcard $(1)/*.c)  
$(1)_OBJS := $$(patsubst $(1)/%.c,$(BUILD)/$(1)/%.o,$$($(1)_SRCS))  
OBJS      += $$($(1)_OBJS)  
DEPS      += $$(patsubst %.o,%.d,$$($(1)_OBJS))  
  
$(BUILD)/$(1)/%.o: $(1)/%.c | $(BUILD)/$(1)  
	$(CC) -MMD -MP -Iinclude -c $$< -o $$@  
  
$(BUILD)/$(1):  
	mkdir -p $$@  
endef  
  
$(foreach m,$(MODULES),$(eval $(call module,$(m))))  
  
all: $(BUILD)/app  
  
$(BUILD)/app: $(OBJS)  
	$(CC) $^ -o $@  
  
clean:  
	rm -rf $(BUILD)  
  
.PHONY: all clean  
-include $(DEPS)  

四个技术点,每一个都对应递归 make 的一个缺陷。

第一,include 合并。如果模块文件是分散在各目录里的片段,顶层用一行把它们全收进来:

include $(wildcard */module.mk) $(wildcard */*/module.mk)  

*/module.mk 覆盖两层,*/*/module.mk 覆盖三层,两个通配符就够一个三层目录的工程用。$(wildcard) 在这里不只是省字:如果某个 module.mk 不存在,裸 include 会报错,而 $(wildcard) 会把它变成空,等价于 -include。

第二,目录前缀做命名空间。目标名一律写成 build/<模块>/<文件名>.o,两个模块里都有 util.c 也不会撞。这条纪律是非递归 make 的地基:一旦有人写出裸的 util.o,整个工程的命名空间就漏了一个洞,而洞只会在某天两台机器上先后构建时暴露。

第三,$(eval) 批量生成规则。上面的 module 宏对每个模块展开一次,展开的结果再交给 $(eval) 变成真实规则。这是非递归 make 唯一能让“十个模块的规则”不变成“十条复制的规则”的手段。注意宏里所有内层变量都写成 $$:$(call) 先展开一层,把 $$(wildcard ...) 变成 $(wildcard ...),$(eval) 再展开这一层。这个双层展开是写这类宏时最容易翻车的地方。

第四,$(call) 定义模块模板,让模块文件只剩声明。每个 module.mk 里写的东西应当只有“我是哪个模块、我有哪些源文件、我依赖谁”:

# core/module.mk  
CORE_SRCS := core/parse.c core/util.c  
CORE_HDRS := core/parse.h core/util.h  
  
# net/module.mk  
NET_SRCS  := net/client.c net/util.c  
NET_HDRS  := include/net.h  

模块之间的真实依赖用显式边表达,而且必须是目标级的:

$(BUILD)/net/client.o: $(BUILD)/core/parse.o  

更好的做法是根本不写这类边——如果 net/client.c 包含了 core/parse.h,-MMD 会自动生成这条边,而且是文件级的。非递归 make 之所以能和自动依赖生成配合得好,就是因为所有 .d 都在同一个进程里被读进同一张图。

三层目录的完整结构(可以照着搭,不需要额外工具):

proj/  
├── Makefile               顶层:变量、module 宏、include、链接规则  
├── include/               对外公共头文件  
│   └── net.h  
├── core/  
│   ├── module.mk          声明 CORE_SRCS  
│   ├── parse.c  util.c  
│   └── parse.h  util.h  
├── net/  
│   ├── module.mk          声明 NET_SRCS  
│   ├── client.c  util.c  
│   └── util.h  
└── tools/  
    └── cli/  
        ├── module.mk      声明 CLI_SRCS  
        └── main.c  

顶层用 include $(wildcard */module.mk) $(wildcard */*/module.mk) 收到全部三层的声明,$(eval) 为每个模块生成 build/<模块>/%.o 的模式规则与 build/<模块>/ 的目录目标,链接规则只写一次。

代价必须诚实列出,而且它们都是真的:

  • 全局命名空间。所有变量、所有目标名都在一个作用域里。两个模块都想定义 SRCS,冲突必然发生,唯一的解法是命名约定(CORE_SRCS)和纪律。
  • 巨型 Makefile 的可读性。$(eval) 展开出来的规则你看不见,grep 也搜不到——它们只存在于 make -p 的输出里。一个非递归 Makefile 通常会在几百行里生成几千条规则,读代码的人和读 -p 输出的人看到的是两个不同的工程。
  • include 顺序影响求值。立即展开(:=)在读到那一行时就算完,所以“变量定义在 include 之前还是之后”会改变结果。递归 make 里每个子 Makefile 的环境是干净的,非递归 make 里前面所有文件都是你的环境。
  • 跨文件错误定位困难。一行报错可能来自某个 module.mk 里的 $(eval) 展开体,行号指向模板,变量值来自顶层,$@ 由模式规则匹配决定。要定位,得同时看三个文件和三份 -p 输出。

这四条代价换来的是一张完整的图,和“make -j 只有一层”这个简洁的执行模型。这笔交换是否划算,取决于工程规模和团队规模——这是 5.7 节要讨论的。

配套的调试手段因此也成了必需品,而不是可选项:make -p 打印内部数据库(记得它是 1234 行起步的),$(info ...) 在解析期打点,$(warning ...) 在宏展开处留标记,--debug=b 看判定过程。非递归 make 的可维护性,一半靠命名纪律,一半靠这几样工具真的用得住。谁只保留纪律、不练工具,谁的 Makefile 在半年后就会变成没人敢改的黑箱。

5.6 make 的性能画像

规模上来以后,“make 慢”的三个来源是可以分开计量的。

解析开销:每次启动都重新解析全部 Makefile,加上内置规则库。内置部分有多大,用一个空工程就能量出来:

$ make -p -f /dev/null | wc -l  
1234  

一个空的 Makefile,make 仍然要建立 1234 行的内部数据库(内置变量与内置隐式规则)。这是每次启动的固定成本。再叠上你自己的 Makefile:$(shell) 与条件分支都在解析期求值,一个 $(shell pkg-config --cflags ...) 就是每次构建都要付一次的子进程开销——哪怕这次构建什么也不用编。工程里那些“make help 也要等两秒”的项目,成本几乎全在这里。

进程创建开销:每条配方一次 shell。一万个编译动作就是一万次 fork + exec,加上 shell 自己的启动。.ONESHELL 能省掉多条配方行之间的重复 shell,但省不了“一个目标一次 shell”这个基本单位。这条成本是常数级的(微秒到毫秒),乘上图规模之后才是可观的:一万次乘以二毫秒就是二十秒。

图规模效应:遍历本身是 O(节点 + 边),不是瓶颈;真正的成本在 stat。每个目标、每个前置条件都要问一次文件系统“你什么时候改的”。冷缓存、NFS、容器里的 overlay 文件系统会把一次 stat 从微秒推到毫秒级,于是“图大”直接变成“启动慢”。这也是递归 make 吃亏的地方:同一个头文件会被十几个子 make 各自 stat 一遍。

解析这一块还有一笔容易被忽略的账:-include $(DEPS) 意味着每个 .d 文件都是一份要被解析的 Makefile 片段。一万个目标文件就是一万份片段,解析器要在启动时把它们全部读进来。这笔成本换来的是依赖正确,所以它值得付,但它也解释了为什么大型工程的 make 在“什么都不用做”的情况下仍然要花上几秒——那几秒里它没在编译,它在读一万份 .d。

给一个粗略的判据:图节点数在几千以内,make 的启动开销可以忽略,-j 拿到的是全部收益;到几万,解析与 stat 开始进入“每次构建都感觉得到”的区间,此时该让生成器接管一部分工作(第 6 章);到几十万,make 作为执行器的常数成本已经拼不过专门写的执行器,这正是 Ninja 存在的理由。

把这三块摆出来,Ninja 为什么在 Chrome 规模上快一个数量级就有了准确解释——它不是更聪明的算法,是更小的常数。Ninja 的构建文件是单文件、无变量、无函数、无隐式规则搜索,所有决策在生成期(GN、CMake)就算完了,运行时只剩“读文件、遍历、起进程”。Ninja 手册自己给出的对照很直接:Chromium 有三万多个源文件,其他构建系统(包括一个用自研非递归 Makefile 搭的)在改一个文件后要十秒才开始编译,Ninja 不到一秒。

要强调的是这个“快”的来源:Ninja 把决策从每次构建的启动期搬到了生成期。总成本没有被消灭,只是从“每次构建付一次”变成了“生成构建文件时付一次”。第 6 章会展开这条线索,它是全书主线“make 被降级为执行器”的实证。

5.7 边界与反证

本章的主张是“把图合成一张”,前面的反证义务是:证明这个主张在什么条件下不成立。

反证一是巨型单一 Makefile 的团队成本。一个五千行的非递归 Makefile,加上 $(eval) 展开出的几千条规则,评审成本高到没人愿意读。任何一个局部改动都要理解全局变量:我改 net/module.mk,会不会影响 core 的编译?答案取决于顶层那个 define 宏和 include 的顺序。模块边界在这种结构里不是消失了,是变成了纪律——纪律不会自动执行,只会被违反。

反证二是问题规模的上限。Mokhov 等人在 2016 年的《Non-recursive Make Considered Harmful》中指出:单一大图在超大规模下同样会遇到问题(图的构建成本、变更时的重算范围),他们的答案不是回到递归 make,而是换语言与换模型。这提醒我们:非递归 make 是一个区间内的最优解,不是终点。

反证三是实践里的第三条:非递归 make 的 include 顺序敏感性与全局命名空间,会让“新增一个模块”这件小事变成一次需要评审顶层文件的改动。递归结构里新增一个目录往往只需要在父 Makefile 里加一行——顺手、局部、不动别人。这是它顽固生命力的真正来源。

所以我给出的判据是三档,而不是一条结论:

  • 团队小于五人、模块耦合强、构建逻辑需要频繁调整:非递归收益最大。图完整、并行有效、增量准确,调试成本由小团队自己吸收。
  • 多个团队各自维护子树、接口稳定(每个子树只对外暴露一个库、一组头文件):递归 make 加上明确的接口约定反而可控。此时目录边界等于组织边界,父级 Makefile 只写 $(MAKE) -C 和几条链接规则,图的缺口被“接口约定 + 全量构建”掩盖。
  • 走中间路线:让 CMake、GN 这类生成器负责“把图算对”,让 make 或 Ninja 负责“把图执行快”。递归与非递归的争论在这一档里被消解了——图由生成器统一持有,执行器不需要理解目录结构。

三档之外还有一条通用建议:无论选哪一档,-MMD -MP 和 --output-sync 都该默认打开,.WAIT 或顺序前置条件都该显式写出来。这些不是风格问题,是把“并行”从赌运气变成工程的手段。

并行不是让 make 更快,是让 make 同时做很多件它本来就做对的事;而它能不能做对,取决于你给它的图有没有缺边。

(本章字数:9192,标点体检:通过)


第 6 章 之后:make 在 2026 年的位置

有一件事在 2026 年仍然成立,而且很少被拿出来说:你在任何一个 Linux 容器里下载一个 1998 年就没有再更新过的开源库源码包,敲下 ./configure && make && make install,它依然能编译、能安装。同一周,公司里的新项目被告知:不要用 make 了,我们用 CMake 加 Ninja,或者干脆上 Bazel。同一个程序,在一边是四十年不动的基础设施,在另一边是被建议淘汰的历史包袱。

这不是矛盾,是位置变了。前五章把 make 的内部拆完了:它的图、它的时间戳、它的规则体系、它的宏系统、它的并行模型。这一章不再拆内部,只回答一个外部问题:在 2026 年,make 站在哪一层,和谁相邻,什么活还该交给它。这个问题不能靠喜好回答,要靠分层、接口和代价回答。本章先把 2000 年以后构建系统的架构分化讲清楚,再看两个最有力的挑战者赢在哪里、没赢在哪里,然后回到那个最容易被忽视的用法——make 当任务运行器,最后给出判据和清单。

6.1 分层:生成器与执行器

1976 年到九十年代末,make 是一体机。一个 Makefile 同时写三件完全不同的事:工程结构(有哪些源文件、每个产物由什么构成)、平台差异(用哪个编译器、头文件在哪、哪个库要额外链接)、调度逻辑(先做哪一步、能不能并行、失败怎么报)。三件事用同一种语言写,被同一个进程读,好处是改哪儿都只改一个文件;坏处要到平台数量超过三个才显形。

第一个裂缝来自 autoconf。为了对付同一份代码在 SunOS、HP-UX、AIX 上需要不同宏定义和不同库的现实,社区的做法是先跑一个 shell 脚本探测环境,再从 Makefile.in 生成 Makefile。这是构建史上第一次把“生成构建文件”当成独立步骤。但这条路走得很别扭:configure 是几万行可移植 shell,本质是“用到处能跑的语言,生成到处不能跑的语言”,探测逻辑与生成的 Makefile 都难以阅读。

CMake 在 2000 年由 Kitware 发布,做的事情是把“生成”从 shell 脚本提升成一门专门语言。CMakeLists.txt 写的是目标、源文件、依赖、使用要求,不掺具体工具链的语法;到生成阶段由生成器翻译成某一层的构建文件:Unix Makefiles、Ninja、Visual Studio 工程、Xcode 工程。CMake 自己不编译任何东西,它只写文件。它在这一层的位置很干净:上面吃工程描述,下面吐构建文件,中间做平台适配。

这一层的核心价值是跨平台与配置探测,不是速度。make 做不了这些事不是因为慢,而是因为它的语言里没有“探测”这个概念:它能比较时间戳、能拼字符串、能调 shell,但没有正式机制表达“试试这个编译器收不收 -std=c++20,不收就退回”,也没有机制表达“这个库可能在两个位置,都找不到就报错”。用 $(shell ...) 拼得出来,但拼不成体系:没有缓存、没有统一输出格式、没有失败语义。CMake 把它做成了一等公民:try_compile、find_package。这就是分层的真正理由。

于是架构稳定成两层。上层是生成器:CMake、Meson、GN。下层是执行器:Make、Ninja、MSBuild。两层之间的接口是构建文件——一个纯文本、由上层写、由下层读、通常不由人写的文件。接口稳定很关键:换掉 CMake 用 Meson,下层不用动;换掉 Ninja 用 Make,上层不用动。这也解释了为什么 CMake 支持那么多种生成器却不需要重写工程描述。

为什么这个分工合理?因为两个关注点的变化频率不同。工程描述变得慢:一个新源文件、一次重构,以周计;它还必须跨平台,一份描述要在 Linux、macOS、Windows 上都能落地。调度逻辑变得快:判过期的算法、并行粒度、进度输出、缓存命中,以年为周期被反复重写。变化慢的在上、变化快的在下,各自按自己的节奏演进。

需要说清一句话:这不是 make 的退步,是分工。make 本来就是二合一——既是 DSL(变量、函数、条件、模式规则、隐式规则库),又是调度器(DAG 遍历、时间戳比较、jobserver 令牌)。分工之后两件事被拆开,上游拿走 DSL,下游拿走调度,make 同时被两边挤压:论 DSL 不如 CMake 会跨平台,论调度不如 Ninja 快、不如 Bazel 会缓存。它守住的位置是“下游的默认选项”——不是最好的那个,是到处都有的那个。

分工不是免费的,代价被下游用户吸收了,主要有两条。

第一条是调试变成两层。CMake 生成的 Makefile 有几千行,全是绝对路径、$(CMAKE_COMMAND) -E 这类伪命令和大量无意义的中间目标。make 报错时给的行号指向生成物,不是人写的源文件;想改一个编译选项,正确做法是改 CMakeLists.txt 再重新生成,改 Makefile 会被下次生成覆盖。这套约定对老 make 用户是反直觉的:他们习惯构建文件就是源文件。

第二条是可写性丧失。生成的 Makefile 无法手改,也就无法用 make 的语言做局部覆盖:给某个文件加一个特殊开关、在某个目标前插一条命令、让某个产物走另一套规则,都只能在生成器语言里表达。于是 make 那些原本最灵活的特性——命令行变量覆盖、-o 假装某文件是旧的、临时 make -n 试跑——在生成物上要么失效,要么语义变形。不是 make 变弱了,是它不再拥有自己的输入。

把这一层想清楚,后面的对比都可以归约成一个问句:这件事该写在上层(描述与探测),还是写在下层(判定与调度)?make 历史上的错误几乎都是把上层的事挤进下层,比如用一个巨大的 ifeq 树做平台分派。

6.2 Ninja 赢在哪、没赢在哪

Ninja 由 Evan Martin 于 2011 年在 Google 开发,动机写得很直白,官方手册开头就给了一组数字:Chromium 有三万多个源文件,改一个文件之后,其它构建系统(包括他们自研的那套非递归 Makefile)要十秒钟才开始编译,Ninja 不到一秒。

先把这句话的来源性质说清楚:这是 Ninja 作者写在自己项目手册里的陈述,不是第三方基准。原始测量的年份、机器配置、Chromium 的具体版本都没有公开,而这三个变量对“十秒”这个数影响极大——2011 年的机械硬盘与今天的 NVMe 相差一个数量级,三万文件与五万文件也差着一个量级。所以它只能当量级参考,不能当性能承诺。真正值得记住的是它的方向,而不是它的倍数:慢在“开始编译”之前,不在编译本身。

这句话里没有一个字说“编译更快”,说的是“开始编译更快”。这是理解 Ninja 全部设计的钥匙:它优化的不是构建,是编辑与编译之间那段等待。

Ninja 的自我定位是汇编器。官方的比喻是:其它构建系统是高级语言,Ninja 是汇编器。这句话不是修辞,是约束——汇编器的输出不需要人读,只需要机器读得快。所以 build.ninja 是产物,不是源码,官方手册明确建议由 CMake、Meson、GN 这类上层工具生成,而不是手写。

格式因此极简。语法只有几种东西:rule 定义命令模板,build 声明一条边(输出、规则名、输入),default 指定默认目标,加上变量、include、pool。没有 if,没有函数,没有 % 模式规则链,没有隐式规则,没有递归展开。它贫乏得有计划:手册原话是,构建系统变慢是因为它需要做决定。把决定搬到生成阶段,运行时只剩比较。

它快在哪?不是算法更聪明。make 的 DAG 遍历也是线性的,Ninja 的调度也是拓扑序加并行,两者在算法层面没有代差。Ninja 赢的是常数,具体是四件事。

第一,解析成本接近零。解析器由 re2c 生成,词法与语法分析是微秒级;路径在读入时就被规范化并驻留(intern),同一个路径在内存里只存一份,后续比较是整数比较而不是字符串比较。它也没有变量展开这一步。

第二,一次建全图。Ninja 启动就把所有 build 边读进内存并排序,再按计划并行执行。make 是深度优先、边走边判断,大图上意味着沿依赖链逐级展开的 stat;递归 make 还要每个子目录重读一遍 Makefile。那“十秒”不是编译慢,是判定慢。

第三,依赖信息预先算好。头文件依赖不再靠 -MMD 生成 .d 文件再 -include 回来解析,而是由 deps 日志(.ninja_deps)直接读入结构化记录。少一次文本解析,也少了“.d 缺失导致首次判定不准”这类问题。

第四,增量判定用双重签名:命令行哈希加 mtime,记在 .ninja_log。这一条在模型上强于 make——改了编译选项而源文件没动,make 认为目标最新,Ninja 会重建;同时它没丢掉 mtime 的便宜,内容哈希要读全文件,mtime 只要一次 stat。

还有一件小事,但它决定了体验:Ninja 默认并行,按 CPU 核数,不需要 -j。这仍然是“减少决定”的同一条原则——连“要不要并行”都不该由人每次回答。

那它没赢在哪?三处,每处都只在特定场景致命。

不能手写维护。Ninja 文件是产物,改一次就被下次生成覆盖。这意味着它无法承担“我临时加一条命令试试”这种日常工作,也意味着项目必须有一个上层生成器。

不能做任务编排。build 边的名字是文件路径,make test、make deploy 这种按名字调用动作的用法在 Ninja 里没有对应物。它有 phony 规则,语法上能拼出类似效果,但那是给生成器用的内部机制,没有自文档,也没人为它设计过人类可读的报错。

不能做构建之外的事。没有 $(shell),没有环境探测,没有条件判断,也没有办法在构建过程中计算一个变量。任何“如果是 Windows 就加这个库”的逻辑都必须在上层写死成两条 build 边。这是设计选择,不是缺陷,但它把 Ninja 死死钉在分层的下半层。

所以 Ninja 的定位只能是被生成。这和 6.1 的分层是同一件事的两面:Make 因为能自己写自己,所以可以当生成目标;Ninja 因为刻意不能手写,天生只能是产物。

最后要修正一句流行说法。“CMake 默认转向 Ninja”并不准确:在 Unix 上,CMake 不给 -G 时的默认生成器仍是 Unix Makefiles。真正转向的是项目模板与团队约定——CMakePresets.json 与 vcpkg 的脚手架默认写 Ninja,各大项目的构建文档第一句就是 cmake -G Ninja。默认值没变,习惯变了。

Ninja 赢的是每个编辑与编译循环里省下的几秒,没赢的是构建系统的全部职责。它证明的是一件对 make 有点残酷的事:速度可以靠减少决定来获得,而减少决定的代价是把复杂度推到上游——推到那些需要人写的地方。

6.3 Bazel 的另一条路

如果 Ninja 的路线是减少决定,Bazel 的路线是减少信任。这个差别很大,值得单独一节。

make、CMake、Ninja 有一个共同的隐含前提:相信环境。相信 cc 在路径上、相信头文件来自 /usr/include 而不是被覆盖过的副本、相信构建机的 libssl 与同事机器上一致。这种信任是“在我机器上能跑、在 CI 上挂了”的根源。make 尤其如此,它连编译器是什么都不知道——CC 只是一个变量。

Bazel 拒绝这种信任。它的模型是:每个动作(action)在一个沙箱里运行,看得见的只有它声明过的输入,能写的只有它声明过的输出,环境变量只有白名单里的那几个。这被称为封闭性(hermetic)。这个前提一旦成立,两个能力就自然长出来了。

第一是可信。如果动作的输出完全由输入决定,那么同一份源码在任何机器上跑,产出的字节应该一致。这不是理论承诺,是可检验的性质:换机器、换目录、换用户,结果应逐字节相同;不相同就说明有未声明的输入泄漏,可以查。

第二是可缓存。既然输出由输入决定,就可以给动作算一个 key:输入内容哈希加命令行加工具链标识。key 一样,结果必然一样,于是可以从本地缓存拿,也可以从远程缓存拿,还可以从别的机器、别的分支、昨天的 CI 构建里拿。这是 Bazel 真正的杠杆。

实现上是三阶段流水线:加载(loading)解析 BUILD、求值 Starlark、构造目标图;分析(analysis)校验依赖、算出每个动作的签名;执行(execution)按图并行跑,命中的直接取缓存。分析与执行的分离让远程缓存成为可能,因为 key 在本地就能算完。

Starlark 是这条路线上必要的一环,不是语言偏好。BUILD 文件是可执行的配置:要能写循环展开一批目标,要能写函数复用规则,但不能在读的时候就产生副作用,否则缓存不成立。所以 Starlark 是 Python 的方言,砍掉 I/O、砍掉不确定性。这和 make 的 $(eval) 形成对照:$(eval) 也能在解析期生成规则,但它旁边站着 $(shell),随时能把确定性打穿。

现在说性能,必须说得小心。有一组被反复引用的数字:TensorFlow 在 32 核机器上首次构建,Bazel 本地约 45 分钟,CMake 加 Make 约 90 分钟,CMake 加 Ninja 约 60 分钟。要交代三件事:来源是中文技术博客的实测贴,不是官方基准;机器配置是 32 核加 SSD,具体型号、编译器与框架版本都不可复现;这类数字逐年漂移。它只能当量级参考,不能当结论。

即便只当量级参考,也该读出正确的意思:Bazel 在首次构建上快,不是因为调度算法更聪明,而是因为它在分析期就把图算完整了——并行度、动作粒度、依赖关系都已知,执行阶段没有犹豫。首次构建是 Ninja 式的胜利加上高并行度,与缓存无关。

而 Bazel 真正的卖点根本不在首次构建。它的卖点是第二次:改了五个文件,其余几万个动作全部命中缓存,构建从几十分钟变成几分钟;在 CI 上,第 20 台机器复用第 1 台的结果;切回旧分支,立刻拿到那次的产物。这些收益都来自缓存,缓存来自封闭性。

代价同样要写清楚,而且代价比收益更容易被低估。

采用成本陡。BUILD 文件不是从 CMake 自动转过来的,它是对工程结构的重新描述:每个目标的输入、输出、可见性都要显式写出。一个中等规模的 C++ 项目,这项工作以周计,还要理解 Starlark 才能写出可维护的宏。迁到一半最难受:两套构建并存,开发者不知道该改哪边。

生态绑定。Bazel 对自己的规则库负责,但第三方库的构建方式通常不是 Bazel。绝大多数 C/C++ 库仍是 autotools 或 CMake,要用就得自己包一层 BUILD。这一层的摩擦是无底洞:每引入一个新依赖,就可能要写一段适配。

对工具链的强要求。封闭性意味着你不能依赖系统里的编译器,得自己声明一个工具链;一旦声明,就得维护,包括交叉编译的三元组、sysroot、目标平台。这对本来靠系统包管理器过日子的项目是巨大的额外负担。

还有一条不那么显性:Bazel 假设单体仓库与一致的命名空间。代码散在几十个 Git 仓库时,它会逼你先做仓库合并,那是组织决策,不是技术决策。

所以对 Bazel 的判断可以写得很硬:它不适合用来解决“构建有点慢”这个抱怨。项目只有几百个文件、只有一台构建机、没有远程缓存,Bazel 的成本照付而收益近零。反过来,多语言单体仓库、上千工程师、每天几十万次构建,这种规模下它的模型几乎站得住,因为那时你要的不是快,是“这次构建和上次到底差在哪里”能被回答。

一句话收束这一节:Ninja 把复杂度推给生成器,Bazel 把复杂度推给人。前者用工具消化,后者用规范消化,而规范需要用组织成本来付账。

6.4 make 作为任务运行器

回到 make 自己。这一节要讲一个被大量使用却很少被认真论证的用法:把 make 当成任务运行器(task runner),让它不编译任何东西。

这件事之所以成立,是因为 make 的规则语义里,目标不必是一个文件。用 .PHONY 声明,或者目标名恰好不对应一个存在的文件,make 就无条件执行配方。前五章的技术细节在这里合成一句话:make 有两个身份,构建器与调度器;构建器需要文件,调度器只需要图。把文件约束摘掉,剩下的是一个能表达依赖、能按序执行、能并行、出错即停的命令调度器。

实验 15 就是这件事的最小实现,机制只有三处,但三处都有讲究:

.DEFAULT_GOAL := help  
  
.PHONY: help  
help:  ## 显示帮助  
	@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \  
	  | awk 'BEGIN {FS = ":.*?## "}; {printf "  \033[36m%-14s\033[0m %s\n", $$1, $$2}'  
  
.PHONY: test  
test:  ## 跑测试  
	@echo "运行测试"  
  
.PHONY: lint  
lint:  ## 静态检查  
	@echo "运行 lint"  
  
.DEFAULT:  
	@echo "未知目标 '$(MAKECMDGOALS)',可用:$(MAKECMDGOALS)"  

在 GNU Make 4.4.1 下运行,输出是:

$ make        # 无参数 → 执行 help  
  help         显示帮助  
  test         跑测试  
  lint         静态检查  
  
$ make test lint  
运行测试  
运行 lint  
  
$ make deploy   # 未知目标  
未知目标 'deploy',可用:deploy  

三处机制各有讲究:.DEFAULT_GOAL := help 把默认目标从“文件里的第一个目标”改成显式声明,这在 4.0 之前做不到,老写法只能靠“把 help 放第一行”这种排版约定;自文档 help 用 grep 从 ## 注释里抽目标名和说明,再用 awk 对齐输出,注释就是文档,而文档不会过期,因为它和规则写在同一行;.DEFAULT 给未定义的目标兜底,避免 make 输出那句经典的“没有规则可制作目标”。

第三处的输出值得停一下。make deploy 打印的是“未知目标 'deploy',可用:deploy”——自己说自己可用,看起来荒谬。这不是笔误,是能力边界的证据:配方在运行时无法枚举自己的同级目标。make 没有任何自省机制能让配方问出“这个 Makefile 里有哪些目标”,兜底配方唯一能拿到的是 $(MAKECMDGOALS),只能原样回显。想让这一行变成真正的可用列表,只能再用一次 grep 读 $(MAKEFILE_LIST)——靠外部命令绕过 make 自身的限制。

把 make 当任务运行器,横向比过去有三个现实对手。

Just(Rust 实现,2016 年前后出现)专门为这件事设计。justfile 语法干净:缩进空格或 tab 都行,recipe 可以带参数和默认值,just --list 是内建自文档。它有一个重要设计:所有 recipe 都按 phony 处理,不判断文件是否过期。这让语义可预测——不必担心“make build 因为存在一个叫 build 的目录而什么都没做”,而这正是 make 作运行器时最常见的惊讶。代价是放弃时间戳模型:原本靠 mtime 增量的 Makefile 迁过去会每次全跑。

Task(Taskfile.yml)走 YAML 路线:跨平台一致(Windows 上不需要额外 shell 层)、有显式 deps、有变量与模板,还能用 sources 与 generates 做基于校验和的增量判定——这一点比 Just 更接近原始构建语义,代价是 YAML 的嵌套噪音。

npm scripts 是另一个极端:写在 package.json 里,有 Node 就可用,pre 与 post 钩子够用。但它没有依赖图,只能靠 npm-run-all 之类的插件补,而且绑定 JS 生态。

在这个对比里,make 的位置很清楚:它做任务运行器的唯一硬优势是零安装。任何 POSIX 系统上 make 已在 /usr/bin 里,容器与 CI 镜像里也有,不需要装、不需要版本协商。这在本地开发时几乎无感,但在 CI 里被放大:你不想为了一个任务入口去污染镜像,也不想让 CI 因工具版本漂移而挂掉。

劣势有四条,都是真的,都从 make 的设计里来,改不掉。

第一是 tab。缩进必须是 tab,这是 1976 年的解析权宜,四十九年过去仍然是新手第一坑,而且报错信息(缺失分隔符)不告诉你是 tab 的问题。

第二是隐式规则干扰。make 出厂带一百多条隐式规则,做运行器时全是噪音:写一个叫 install 的目标,make 会想“是不是从 install.c 编译来的”;写一个叫 test 的目标而目录里正好有 test/,make test 会告诉你“已是最新”。要干净就得写一串 .PHONY,忘写三个就漏一个。

第三是错误信息与调试体验。报错只有“recipe for target failed”加一个行号,不告诉你哪条命令;每行配方是独立 shell,cd 与变量导出都不跨行,“为什么我的 cd 没生效”是新手的常客问题。

第四是没有参数化。make 没有“给任务传一个参数”的语法,只能靠约定变量(make test PATTERN=auth)或者用 $(MAKECMDGOALS) 把位置参数偷出来再配一堆 ifeq。这不是工作不了,是很难读,也很难写文档。

所以结论是一句有点扫兴的话:make 是“你已经在用的那个任务运行器”,不是“最好的任务运行器”。选它的理由是零安装与团队已经会,不选它的是上面四条持续的摩擦。一个项目的 Makefile 里如果已有八成规则是 .PHONY,那它其实已经承认自己需要的是任务运行器,只是没换工具。

6.5 make 真正不可替代的场景

说 make 被降级,不等于说它已经无关。有些位置上它不只是“够用”,而是“没有对手”。这些位置的共同点不是技术先进,而是约束极紧:工具链不能选、依赖不能装、构建描述必须能被审计。以下五条判据,满足任意两条以上,就该优先考虑 make。

第一条,目标环境只有 POSIX 工具链。嵌入式交叉编译、BusyBox 环境、极简容器镜像(scratch、alpine、distroless)都属于这类。这些环境里能用的只是一个 shell、一个 sed、一个 awk、一个 make;Python 不一定在,网络不一定通。make 在这里的竞争力不是功能,而是“不需要被安装”。同一个理由让它在救援镜像、初始化脚本、路由器固件里活了四十年。

第二条,项目本身是 C 或 C++,且规模中等。大致是几百到几千个源文件、依赖不超过两位数、步骤是“编译加链接加少量代码生成”。此时两件事同时成立:make 的解析开销(万级目标才明显)还不构成问题;手写 Makefile 的成本远低于引入 CMake 的学习与维护成本。

第三条,需要零依赖、可审计、可被任何 CI 执行的胶水层。这一条最容易被低估。CI 里真正稳定的不是哪个框架,而是“make test 这个入口”:不管你用 GitHub Actions、GitLab CI 还是 Jenkins,它们都能执行一条 shell 命令,而 make 保证这条命令在本地和 CI 上是同一件事。把测试、打包、镜像构建收在一层薄薄的 Makefile 后面,换来的是换 CI 不用改流程。

第四条,数据管道与文档构建。这一条常被忽略,因为大家默认 make 只服务编译。但数据管道的形状和编译同构:输入是数据和脚本,输出是中间表和报告,任何一个环节改了就只重跑下游。pandoc 生成 PDF、pdflatex 多趟编译、图片批量转格式、数据清洗的每一步,都是“目标加前置条件加配方”。这类项目用 make 的收益甚至比 C 项目更大,因为每一步动辄几十秒。

第五条,已有成熟 make 体系的大型项目。Linux 内核最典型:它的 Kbuild 不是普通 Makefile,而是构建在 make 之上的一整套约定与脚本(Kbuild 文件、obj-y 变量、make menuconfig),但底层仍然是 make,仍然用它的并行与依赖。这类项目换构建系统不是技术问题,是重写全部构建描述加重新培训所有贡献者的问题。同类还有 U-Boot、Buildroot、Yocto 的 recipe 层。

反过来,也有四种情况明确不该用 make,写清楚比含糊更有用。

跨平台 Windows 原生构建。make 在 Windows 上要么靠 MSYS 与 Cygwin 提供 POSIX 层,要么用语法不同的 nmake。前者的路径、换行、shell 语义都与原生环境不一致,后者与 GNU make 不兼容。要生成 .vcxproj、要让调试器认得目标,正解是 CMake 生成器或 MSBuild。

需要远程缓存。make 的判定只看本地时间戳,没有 action key,也就没有“跨机器复用结果”的可能。硬要做得自己写一层缓存加包装命令,等于自己实现一个残缺的 Bazel。

超大型单体仓库。几万个目标、上千工程师、多语言并存时,make 的解析开销、递归结构、缺乏可见性与所有权机制都会成为瓶颈。它的模型里没有“目标属于谁、谁能依赖谁”这些组织概念。

需要精确的内容哈希增量。make 的时间戳模型在“内容没变但被 touch 过”、“从缓存恢复导致时间戳重置”、“时钟不同步”这些场景下会误判,第二章讲过的四个陷阱全属于这类。要内容级正确,只能把哈希写进模型,那是 Ninja 的双重签名或 Bazel 的 action key 才能给的。

把这两张清单并排放,能看出一个规律:make 赢在约束紧、规模中、要零依赖;输在跨平台、要缓存、规模大、要精确。这不是能力评价,是适用域划分。

6.6 遗产:五个被所有后继者继承的设计

make 输掉了生成器之争,稳住了执行器的一半,但它真正的输出不是这些胜负,是五个被所有后继者继承并改造的设计。这五条构成了现代构建系统的语法共识,几乎所有工具都能在其中找到自己的位置。

第一,声明式规则。目标、前置条件、配方三元组,这个形状是所有后继者的起点。Ant 用 <target depends="...">,Rake 用 task :name => [:deps],MSBuild 用 <Target DependsOnTargets>,Bazel 用 name 加 srcs 加 deps,Cargo 用 [[bin]] 加 [dependencies]。语言换了五代,骨架一次没变:这个三元组是“要做的事加前提加怎么做”的最小完备表达。

第二,增量判定。make 用 mtime 做“要不要重建”,第一次把决策从人的记忆移到机器可查的事实上。后继者的改造方向只有一个:把时间戳换成更可靠的东西。Ninja 用命令行哈希加 mtime 双签名,Bazel 用输入内容哈希,ccache 用预处理后的源文件哈希,cargo 用指纹文件,docker build 用每层指令的哈希。它们回答的都是同一个问题,只是把答案从“时间”换成了“内容”。

第三,DAG 调度。拓扑序加并行,然后按依赖关系收敛。make 的 -j 加上 jobserver 令牌是这套调度的第一个工业实现:并行粒度是目标,正确性由依赖图保证,共享资源靠令牌数控制。后继者在这里做的改造比前两条大:Ninja 把图预先算好再统一调度,调度质量更高;Bazel 把调度做成分布式,动作可以发到别的机器;scons、buck2 用更细粒度的动作图。但“按图并行、依赖满足就启动”这个内核没变。

第四,配方即 shell。动作不是内嵌在构建语言里的函数,而是外部命令。这一条给了 make 一个珍贵的性质:可组合性。编译器、链接器、代码生成器、pandoc、protoc、rsync,只要能命令行调用就能进构建,不需要为构建系统写插件。后继者大多保留了这一点(Ninja 的 command 就是一行 shell,Bazel 的 action 也是外部进程),代价是跨平台需要 shell 抽象层,收益是整个 Unix 工具生态免费可用。

第五,文本可调试。构建描述是人可读的纯文本,可以 diff、可以 grep、可以进版本控制、可以在评审里被讨论。Feldman 那句“Makefile 是文本文件,因为那是 Unix 的精神:可打印、可调试、可理解的东西”,说的是设计立场,不是实现细节。后继者全都遵守了这条,包括看似反例的 Ninja:build.ninja 是文本,虽然不给人写,但给人看、给人 grep。反过来,二进制构建缓存、私有格式的 IDE 工程文件之所以难维护,正是因为它们违反了这条遗产。

这五条里有一条是 make 独有、后继者没有继承的:隐式规则库。make 出厂自带一百多条“如何从 X 得到 Y”的知识,后继者全放弃了它。Ninja 明确不猜,Bazel 要求显式,CMake 靠生成器写全。代价在规模化后不可接受:行为不可见、调试成本高、跨平台假设不成立。它是 make 最有野心的一处设计,也是最没人继承的一处。

6.7 边界与反证

本章主张“make 被降级但没死,它是执行器的一半加语义的源头”。这个主张要向两个方向各接受一次反驳。

先反“make 已死”。最常见的论据是“新项目没人写 Makefile 了”。这个观察对,结论错。事实是:任何一个 Linux 发行版的源码包里都有 Makefile,./configure && make && make install 是四十年不变的通用咒语,GNU 编码规范至今要求提供 make all、make install、make check、make clean。更硬的证据是那些看不见 make 的地方:Android 曾用 Kati 把 Makefile 翻译成 Ninja 文件(因为生态里已有海量 Android.mk),Buildroot 与 Yocto 的最底层仍是 make,protobuf、openssl、sqlite 的构建入口仍是 make。make 没死,它变成了地基——你不会讨论地基好不好看,但每一层都压在它上面。

再反“make 万能”。另一边的论据是“够用,何必换”。这个观察在某些项目里成立,推广开就错。把 make 用到大型跨平台项目上,本质是把 1976 年的时间戳模型硬套在 2026 年的需求上:手写平台分派、手写头文件依赖、手写缓存、手写并行限流,最后得到一套只有一两个人能维护的私有构建系统。代价不是抽象的效率损失,是具体的人:每个新人踩同一批坑(tab、$(shell) 的求值时机、递归 make 的变量传递、cp -p 导致漏建)。准确的表述是:make 适合被用,不适合被扩张。

两个方向都打完,选型就可以落成一张判据表。它不是推荐排行榜,是“在这个约束下哪个代价最小”的查表。

场景 推荐 理由
数百至数千文件的 C/C++ 库或工具 手写 Makefile 或 CMake 加 Make 描述成本低,无额外依赖,调试路径最短。
跨平台桌面或移动应用 CMake 加 Ninja 生成器承担平台与 IDE 适配,Ninja 承担速度。
多语言单体仓库、上千工程师 Bazel 或 Buck2 需要可信、可缓存、可远程执行,规范成本由组织消化。
三五个人的服务端项目 Makefile 包一层任务入口 零安装,CI 任何环境都能执行。
只在 POSIX 环境跑的构建 make 或 Ninja 不引入无法审计的依赖。
数据管道、文档、静态站点 make 天然是数据流,增量收益大,工具链任意。
内核、固件、嵌入式交叉编译 现有 make 体系 已有成熟约定与社区,换的成本不可承受。
Windows 原生、需要 MSBuild 集成 CMake 加 Visual Studio 生成器 make 在 Windows 上没有一等公民地位。
需要跨机器复用构建结果 Bazel 加远程缓存 make 没有 action key,无法表达这件事。

make 没有赢,但它定义了赢的规则。

6.8 速查表

命令行开关

开关 含义 用法要点
-j [N] 并行 N 个配方 不带 N 为不限,输出会交错。
-k 出错后继续 一次看清全部失败点,末尾仍返回非零。
-n 只打印不执行 含 $(MAKE) 的行仍会执行。
-p 打印内部数据库 配 -f /dev/null 可看内置规则与变量。
-q 只问是否需更新 退出码 0 最新、1 需更新、2 出错。
-t 只 touch 目标 不执行配方,用于假装已构建。
-B 无条件重建 视所有目标过期。
-o FILE/-W FILE 假定 FILE 是旧的/新的 分别抑制或强制重建下游。
-C DIR 先切目录再执行 可嵌套,配合 -f 指定子目录构建。
-f FILE 指定 Makefile 可给多个,- 表示标准输入。
-r/-R 关闭内置规则/内置变量 去掉不可见的默认世界观。
-s 不打印配方 等同给每条配方加 @。
-l N 负载上限 系统负载超过 N 时不再起新作业。
--debug=b 打印判定过程 逐目标显示需要重做的原因。
--output-sync=target 按目标聚合输出 并行构建时保持输出可读。

特殊目标

目标 作用
.PHONY 声明伪目标,不查文件、总是执行配方。
.DEFAULT_GOAL 指定默认目标,取代“第一个目标”的规则。
.DEFAULT 无规则可匹配时的兜底配方。
.SUFFIXES 后缀规则识别的后缀表,清空即禁用后缀规则。
.DELETE_ON_ERROR 配方失败时删除已部分写入的目标。
.PRECIOUS 中断或失败时不删除目标文件。
.INTERMEDIATE/.SECONDARY 标记中间文件,构建结束删除/保留。
.ONESHELL 整个配方交给同一个 shell 执行。
.NOTPARALLEL 禁止并行,或只对某些前置条件生效。
.SILENT/.IGNORE 不打印配方/忽略配方错误。
.EXPORT_ALL_VARIABLES 所有变量导出到配方环境。
.SECONDEXPANSION 开启前置条件的二次展开。
.WAIT 作为前置条件使用的显式等待点,保证顺序。
.EXTRA_PREREQS 附加前置条件,但不进入自动变量。

自动变量

变量 含义
$@ 当前目标名。
$% 归档成员名,目标形如 lib.a(mem.o) 时使用。
$< 第一个前置条件。
$^/$+ 全部前置条件,去重/不去重。
$? 比目标新的前置条件列表。
$* 模式规则匹配出的词干。
$| 仅顺序前置条件(order-only)列表。
$(@D)/$(@F) 目标的目录部分/文件名部分。
$(*D)/$(*F) 词干的目录部分/文件名部分。
$(^D)/$(^F) 全部前置条件的目录部分/文件名部分。

常用函数

签名 作用
$(subst from,to,text) 文本替换。
$(patsubst %.c,%.o,list) 按模式替换,支持 %。
$(strip text) 去首尾空白并把内部连续空白压成一个。
$(filter pat...,text)/$(filter-out ...) 保留/剔除匹配模式的词。
$(sort list) 排序并去重。
$(word n,text)/$(wordlist s,e,text) 取第 n 个词/取区间。
$(words text)/$(firstword text)/$(lastword text) 计数/取首词/取末词。
$(dir names)/$(notdir names) 取目录部分/取文件名部分。
$(suffix names)/$(basename names) 取后缀/去后缀。
$(addprefix p,names)/$(addsuffix s,names) 批量加前缀/后缀。
$(wildcard pat)/$(realpath names)/$(abspath names) 通配展开/解析符号链接的绝对路径/不去链接的绝对路径。
$(if cond,then,else)/$(or ...)/$(and ...) 条件求值,非空即真。
$(foreach v,list,text) 逐项展开后拼接。
$(call var,args...) 以参数调用变量(宏)。
$(eval text) 把展开结果当作 Makefile 语法解析。
$(origin v)/$(flavor v)/$(value v) 变量来源/风味(递归或简单)/未展开的字面值。
$(shell cmd) 执行命令取标准输出,换行折成空格。
$(error text)/$(warning text)/$(info text) 终止/警告/打印信息。

6.9 索引:按词找小节

技术书的索引本该是出版环节的事,但这份书稿的章节耦合不深,按小节号回查比翻页更快。下面三组覆盖全书高频词,数字是小节号。

机制类

  • 增量构建、过期判定、三种过期来源:2.2
  • 依赖图(DAG)、拓扑序、书写顺序与执行顺序的错位:2.1
  • 时间戳、同秒、cp -p、未来时间:2.4
  • 时钟错误警告、假阴性、图不完整:2.4、2.6
  • order-only 前置条件(|)、$|:2.5
  • jobserver、令牌池、--jobserver-auth、关键路径:5.1
  • 并行交错、竞态、--output-sync、.WAIT:5.2

语法类

  • 显式规则、多目标、双冒号规则、内联配方:3.1
  • 模式规则、词干 $*、静态模式规则、后缀规则:3.2
  • 隐式规则、规则链搜索、-r、出厂预设:3.3
  • 四种赋值(=、:=、?=、+=)、:::=、二次展开:4.1
  • 自动变量($@ $< $^ $+ $? $* $| $(@D)):4.2
  • 文本函数(patsubst、wildcard、filter、sort、dir):4.3
  • call、foreach、eval、define、$(shell):4.4、4.6
  • 变量优先级、override、-e、MAKEFLAGS、MAKELEVEL、MAKEFILE_LIST:4.5
  • 特殊目标:.PHONY 3.4.1、.DELETE_ON_ERROR 3.4.2、.INTERMEDIATE 与 .SECONDARY 3.4.3、.PRECIOUS 3.4.4、.NOTPARALLEL .ONESHELL .DEFAULT .SECONDEXPANSION &: 3.4.5
  • VPATH、vpath、GPATH、out-of-tree:3.5

工程与外部工具

  • 依赖自动生成、-MMD、-MP、-MT、-include:5.3
  • 递归 make、$(MAKE) -C、非递归 make、include 合并:5.4、5.5
  • 解析开销、进程开销、图规模效应、ccache:5.6
  • 生成器与执行器、CMake、Meson、GN:6.1
  • Ninja、构建文件极简格式、命令哈希:6.2
  • Bazel、封闭性、动作缓存、Starlark:6.3
  • Just、Task、npm scripts、任务运行器:6.4
  • 选型判据表:6.7

6.10 全书结论

第一,make 的核心贡献不是自动化,是把“什么该重建”变成了一张可检查的图。在它之前,重建的判断依据是人的记忆,记忆无法 review、无法测试、无法交接;在它之后,判断依据是一组声明,声明可以 diff、可以争论、可以在出错时指出是哪条边错了。这本书用五章拆开的每个机制——时间戳比较、隐式规则、二次展开、jobserver、-MMD——都是为了让这张图既好写又好查。

第二,make 的所有缺陷都来自它在 1976 年选择的三样东西:文本、tab、时间戳。文本带来可调试,也带来“配置与程序分不清”;tab 带来当时可实现的解析,也带来半个世纪的第一坑;时间戳带来零成本的判定,也带来假阴性与时钟陷阱。这三样在当时都是正确的妥协,而正确性会过期:约束变了,妥协就成了包袱。

第三,学会 make 的收益不在 make 本身,而在于你从此能判断任何一个构建系统在骗你哪里。所有构建系统都在某处骗人:make 骗你说“比目标旧就该重建”,Ninja 骗你说“命令行哈希加 mtime 就够了”,Bazel 骗你说“你声明的输入就是全部输入”,容器构建骗你说“每一层都是干净的”。知道边界在哪,比知道语法更重要。

(本章字数:10749,标点体检:通过)