回顾 Make 半世纪发展,揭示其将“目标-依赖-时间戳”声明式思想变为增量构建基石的过程,并梳理 BSD、Sun、Microsoft 与 GNU 等分支如何在兼容与扩展中推动现代构建系统演化,说明其对当代软件工程的深远影响。
Make 工具五十年:一份万字发展史
引言:一行命令背后的半个世纪
1976 年 4 月的某个周末,贝尔实验室的一间办公室里,Stuart Feldman 敲下了一个工具的第一个版本。他没有想到,这个工具会在近五十年后仍然坐在全世界软件编译链的中心——ACM 在 2003 年给他颁发软件系统奖时,给出的评语是:"今天世界上可能没有任何一个大型软件系统,未曾被某个版本的 MAKE 或其子孙处理过。"
Make 值得被写成一部长篇历史,不只是因为它活得久,更因为它把一个极其朴素的想法——目标、依赖、时间戳——变成了整个软件工程的地基。今天所有现代构建系统,无论采用还是反叛,都必须先向 Make 这个坐标原点致敬。
第一章 前史:在没有 Make 的年代,程序员怎么构建软件
要理解 Make 的诞生,必须先理解它诞生之前的世界有多痛。
在 Make 出现之前,Unix 系统的"构建"通常由 shell 脚本承担。 这些脚本能批量执行命令、也能做一定程度的依赖检查,是把生成目标的过程自动化的早期形态。 但问题在于,这些脚本是命令式的——它对程序员说"请按这个顺序执行",而不是"我期望最终得到这个状态"。
在大型项目里,这种模式迅速崩溃。程序员必须靠脑子记住哪些文件需要重新编译:改了 1 个头文件,可能有 30 个 .c 文件受影响——但你并不知道具体是哪几个。于是只有两种选择:要么全部重编(慢),要么漏编(错)。 编译参数散落在终端历史、记事本和同事的记忆里,换个环境就无法复现;新同事加入,你只能口述"先敲这个,再敲那个"。
更致命的是依赖关系只存在于人脑里,而人脑会忘事。这正是 Make 故事的开端。
第二章 1976:一个愤怒的同事,一个周末,一个工具
2.1 Steve Johnson 的坏早晨
关于 Make 的起源,最广为流传的是 Feldman 本人的第一人称叙述:
"Make 源于 Steve Johnson(yacc 等工具的作者)的一次造访。他冲进我的办公室,诅咒命运——因为他浪费了一上午去调试一个本来是正确的程序(bug 已经修好了,但文件没有重新编译,cc *.o 因此没有受影响)。"
这就是问题的全部:Johnson 修的 bug 在源码里,但他一直测试的可执行文件仍是旧的。逻辑是对的,源码是对的,可二进制从来没有在修改后被重新编译——他读着今天的源码,却在验证昨天的程序。
Feldman 完全懂那种感受。他回忆说,自己前一天晚上也在一个项目上"应对同样的一场灾难"。
2.2 从复杂到简单,就在那个周末
灵感来得很直接:问题的核心不在于"如何"编译,而在于"何时"以及"什么"需要被编译。
Feldman 最初的构想其实更复杂——一个精细的依赖分析器。但它在那个周末被"煮"成了简单得多的东西,最终变成了 Make。
他在 1978 年的论文中用一句话概括了整个设计:
"描述文件实际上定义了依赖关系图;Make 对该图做深度优先搜索,以确定真正必要的工作是什么。"
那个周末的第一版,入口是 2.1 4/24/76 Base version。 而 Feldman 后来坦言:"我花了一个下午写出了 Make 的第一个版本。我没想到它会变得如此重要,以至于 40 多年后仍在使用。"
值得注意的是,"那个周末"只描述了第一版。在 1989 年的一次访谈中,他说"一两周后,我把整个东西重写了一遍"。
第三章 核心思想:只有三行,却能活五十年
Make 的全部智识内容,可以压缩成三行:
target: dependencies
recipe
如果任何一个依赖比目标更新,就执行 recipe;否则什么都不做。 这就是算法。
把它拆开,就是三个概念:
• 目标(target):要生成的文件;
• 依赖(prerequisite):目标所依赖的源文件;
• 规则/配方(recipe):如何从依赖生成目标的命令。
以及一个判决机制:文件系统时间戳。特别是——
• Make 检查目标与其依赖的修改时间。若依赖比目标新,或目标不存在,就重新生成;否则不动。
• Make 不靠数据库、不靠缓存服务器、不靠守护进程轮询变化,而是用文件系统本身作为缓存。stat() 就是查询,你正在运行的操作系统就是全部基础设施。
• 内置规则(例如"如何把 C 源文件变成目标文件")意味着许多依赖根本不需要写出来。
这一转变的深刻之处在于:它把命令式的"执行此流程"变成了声明式的"我需要这个状态"。用户不再编写繁琐流程,只描述关系,工具自行推导最小必要工作。 这就是增量构建概念的诞生——在那个算力极度匮乏的年代,它避免了"改一行代码就重编整个项目"的巨大浪费。
也正因如此,Make 保持了彻底的语言无关性:它不解析你的语言,不理解你的编译器,不管理你的包,不拉取依赖,不生成锁文件,不对目录结构、命名规范或版本控制系统发表意见。
一个算法,用文本文件解决了一个图论问题。
第四章 那些版本号:2.1 到 2.54 的演化
Make 诞生后并没有停下。它的源码里留有一段版本变更日志,开头写着:
char xxxvers = "\nMAKE. VERSION 2.54 29 AUGUST 1978\n";
/ 2.1 4/24/76 Base version
2.2 4/26/76 Error found by SRB in overriding pattern rules; corrected gram.y
2.3 4/27/76 Further correction for overriding pattern rules; corrected doname.c
2.4 Removed .CLEAR name, added .IGNORE.
...
2.20 Added -q flag for Mike Lesk ...
2.54 ... */
也就是说,从 4 月 24 日的基线版到 1978 年 8 月的 2.54,短短两年多时间里有54 次修订。
关键的传播节点:
• 1977 年 4 月的一个版本,已经进入 PWB(Programmer's Workbench)——贝尔系统内部面向软件开发小组的 Unix 发行版。 不过正如 Unix 研究部门负责人 Doug McIlroy 所说:"PWB 没有创造 make;是 Stu Feldman 在研究部门做出来的。"
• 1979 年,随 Version 7 Unix 发布的版本是 2.58,从此 Make 正式走向世界。 V7 的 make 大约只有 2500 行 C 代码。
• 1979 年 4 月,Feldman 在《Software: Practice and Experience》上正式发表论文《Make — A Program for Maintaining Computer Programs》。
第五章 分岔的年代:一个思想,多个方言(1980s)
由于 Make 随 Unix 一起扩散,各家厂商纷纷推出自己的变体,逐渐积累出互不兼容的扩展。 这是 Make 历史上最有意思的"物种分化"阶段。
5.1 BSD 支线
BSD Make(pmake、bmake 或 fmake)源自 Adam de Boor 的工作,其版本能够并行构建目标,并在 FreeBSD、NetBSD、OpenBSD 中经过不同程度的修改后留存至今。
它与众不同之处在于:具有在解析阶段应用的条件和迭代循环,可用于有条件地、编程化地构建 makefile,包括在运行时生成目标。 具体表现为条件(.if / .else / .endif)、循环(.for / .endfor)、并行构建和模式规则。 FreeBSD 的 bmake 如今约 29000 行。
5.2 Sun 支线
• Sun DevPro Make 于 1986 年随 SunOS-3.2 出现。在 SunOS-3.2 中它作为可选程序提供;到 SunOS-4.0,SunPro Make 成为默认的 Make 程序。
• 2006 年 12 月,作为开源 Solaris 努力的一部分,Sun DevPro Make 被开源。
• dmake(Distributed Make)随 Sun Solaris Studio 提供,作为其默认 Make,但不是 Solaris 操作系统(SunOS)的默认 Make。它最初是构建 OpenOffice 所必需的;2009 年其构建系统被重写为使用 GNU Make。 今天 Apache OpenOffice 仍混合着两套构建系统,而开发更活跃的 LibreOffice 只使用现代化的 "gbuild"。
5.3 Microsoft 支线
微软的 nmake 加入了这一生态系统。
各家实现只被 POSIX 的公共子集统一起来——这本身就说明了 Make 思想的普适性与碎片化之间的张力。
第六章 1988:GNU Make 登场,一统 Linux 与 macOS
1988 年,作为 GNU 项目的一部分,Richard Stallman 和 Roland McGrath 写出了 GNU Make,"gmake" 成了 Linux 和 macOS 上的标准实现。
它提供了对原始 Make 的几个关键扩展:
• 条件指令;
• 大量内置函数,可以消除在 makefile 规则中编写 shell 脚本的需要,也能操作 makefile 中设置和使用的变量(例如 foreach 可以迭代一个值列表,比如某目录下的文件名);
• include 其他 makefile;
• 自动依赖生成;
• 通过 make -j 和 jobserver 协议实现健壮的并行执行(该协议负责协调递归调用间的并行度)。
此后 GNU Make 成为事实标准,许多软件系统构建都离不开它,包括 GCC(自 3.4 版起)、Linux 内核等。
1997 年,Paul D. Smith 接手 GNU Make 的长期维护,这一角色他至今仍在担任。
现代里程碑
• 2010 年,GNU Make 3.82 引入 .RECIPEPREFIX 设置,允许 makefile 使用不同的字符替代制表符。
• 2013 年 10 月,GNU Make 4.0 发布,引入内嵌的 GNU Guile 脚本、用于让并行日志更整洁的输出同步(output-sync),以及可为原生扩展加载模块的 loadable-object 接口。
• 2022 年,GNU Make 4.4 发布,改进了 MAKEFLAGS 的传播方式和 jobserver 行为。
• 2023 年 2 月,GNU Make 4.4.1 作为 bug 修复版发布,至今仍是当前稳定版。
GNU Make 4.4 的代码规模约 39000 行——相比 1976 年的 2500 行,增长了十几倍,但核心算法没有变。
标准化
1992 年,Make 的核心行为被纳入 POSIX.2 标准(IEEE Std 1003.2),定义了一个可移植的公共子集。 这是 Make 从"某个厂商的工具"变成"一个行业契约"的关键一步。