
1. 为什么一个35年前的构建工具至今没有退役1.1 构建这件事本质上在解决什么问题先聊一个我自己的经历。很多年前我在一个C语言项目里打杂那个项目的代码量不算大但每次修改一个头文件全工程重新编译要跑二十多分钟。后来有前辈跟我说你去看一眼Makefile我翻了半天连规则都没看懂。那时候我最大的困惑是为什么编译还要靠一个“文件”来管直接把编译命令写成脚本不行吗这个困惑其实代表了很多人对构建工具的刻板印象。脚本完全可以编译——它能把所有命令按顺序跑完但脚本有一个致命的缺陷它没有“记忆”。脚本执行的时候不管文件有没有变过都得老老实实从头跑到尾。而Makefile的核心贡献是它把“构建”这件事抽象成了三个东西目标target、依赖prerequisite、规则recipe。你告诉它你想得到什么、它依赖什么、怎么生成它就能靠文件的时间戳自动判断哪些步骤需要重新执行哪些可以跳过。换个生活化的说法Makefile像是一张菜谱加上一个聪明的管家。菜谱告诉你做红烧肉需要哪些材料、先做什么后做什么管家则会在你准备工作时检查冰箱发现肉还没解冻就提示你发现生抽已经用完了就停下来而不会傻乎乎地把已经烧好的菜重新做一遍。这里的“时间戳比较”就是管家的判断依据。这个设计在1980年代提出到现在依然稳如磐石。虽然今天的构建工具层出不穷像Ninja、CMake、Bazel但Make的存量工程之多、生态之广远超一般人想象。Linux内核、许多老牌C/C项目、驱动库、嵌入式固件几乎都离不开Makefile。就算你平时接触的是上层业务代码也难免在某一天要打开一个开源项目的Makefile来改点什么。1.2 Makefile给日常开发带来的实际收益很多人在IDE环境里呆久了会觉得Makefile是“古董玩意”。但真到了命令行环境、CI流水线、交叉编译、多平台构建这些场景Makefile的优势非常明显。第一增量构建节省的时间是肉眼可见的。这不是玄学而是基于文件时间戳的精确调度。你改了一个.c文件make只会重新编译这个文件再重新链接生成可执行文件不会碰其他没变化的编译单元。对一个上千源文件的项目来说改动一行代码只花几秒钟完成重编译而不是几分钟的全量编译。第二依赖关系被显式写下来之后构建顺序有了保障。比如你有一个代码生成器它先生成一批源文件然后编译这些源文件最后链接成库。在Makefile里这种先后顺序可以被精确表达不需要靠注释提醒“记得先跑生成器”。第三Makefile本身是可重复执行的描述。换一台机器、换一个人只要环境一样跑出来的结果一致。这对团队协作、对持续集成来说尤其重要。构建不再依赖某个人在终端里默默敲过一串“神秘咒语”。我做过的很多项目自己动手写过几套Makefile之后再看别人写的基本一眼就能判断出水平。问题往往不出在格式和命令上而是出在写的人有没有真正理解“目标—依赖—规则”这一层抽象。如果这一步通了后面都是细节问题。1.3 什么人现在还需要认真学Makefile说实话如果你只做前端开发、纯Java开发日常里有更顺手的构建工具帮你封装好一切你大概率不会主动碰Makefile。但只要你有以下任何一种情况我还是建议至少把Make的常用语法吃透你要阅读和修改开源C/C项目比如某些底层库、算法包、嵌入式SDK它们的构建系统很可能还是Makefile你需要做交叉编译或者定制编译选项很多工具链的示例工程都是Makefile的你要在CI脚本里编译、测试、打包偶尔需要写一个Makefile作为统一的入口把一堆命令规范化你自己维护一个小型项目不想引入厚重的构建框架只想干干净净地维护好“怎么从源码变成产物”。哪怕已经把CMake作为主要构建系统的人也会发现Makefile的知识没有白学因为很多CMake的底层概念目标、依赖、生成器都是从Make这套逻辑生根发源的。观念打通了学什么构建工具都容易。2. 把Makefile拆开看目标、依赖、规则这三件事2.1 一个最小Makefile的解剖先写一个最经典的示例hello: main.o utils.o gcc -o hello main.o utils.o main.o: main.c utils.h gcc -c main.c utils.o: utils.c utils.h gcc -c utils.c clean: rm -f hello main.o utils.o这段代码一个曾未接触过Makefile的人也能大概猜出含义。hello是目标它依赖main.o和utils.o生成它的规则是调用gcc链接。main.o是目标依赖main.c和utils.h生成它的规则是编译main.c。关键点在于整个文件是一个个规则块组成的每个规则块的结构一致目标: 依赖1 依赖2 ... 命令前面必须是Tab构建时你执行make hellomake会先检查hello的依赖main.o和utils.o是否存在。如果不存在它就去查找能生成这两个文件的规则并执行那些规则。这个过程递归往下直到所有依赖就位。这种感觉很像一个后进先出的栈先处理最底层的依赖再一步步向上构建。有一个细节值得强调命令行的前面必须是Tab字符不是四个空格也不是八个空格。这个约定让无数新手摔过跟头写到后面会单独展开。2.2 make是怎么判断“要不要重做”的Make的核心调度逻辑说白了就是一条非常朴素的规则如果目标不存在就执行规则生成它如果目标存在就把它依赖里时间戳最新的那个文件与目标自己的时间戳做比较——只要任一依赖比目标新就重新执行规则。举个具体例子。你运行make hello此时hello已经存在了当前时间是10:00所有依赖的.o文件也都是9:50生成。make发现没有任何依赖比hello更新于是说“hello已是最新”连接口都不会调。但如果你改动过utils.cutils.o的时间戳变成了10:05那么make发现utils.o比hello新就会重新链接。而在进入链接之前它自然也会检查utils.o自己需不需要重新生成。这套基于时间戳的纯度逻辑写起来简单但实际工作中给无数人埋过雷。比如你用git checkout切换分支文件内容完全变了但文件时间戳没变或者变成了同一个时间make就会认为“没有变更”不重新编译。再比如你把系统时间调了回去某些文件的时间戳错乱也会导致make疯狂重编译或者天真地跳过某一步。理解了这个底层逻辑你排查起这类诡异问题来会快很多。另外还需注意一个词并非shell命令。Makefile里每一条规则被拿出来执行的时候都是交给系统的shell去跑的默认是/bin/sh。所以你在规则里写循环、写条件判断、用管道、用重定向都是可以的但务必注意不同shell之间的语法差异。比如sh和bash对source、[[ ... ]]的支持就不一样这在某些嵌入式环境里会变成很头疼的问题。2.3 变量、函数和赋值时机的坑变量在Makefile里到处都是。定义一个变量然后用$(变量名)引用是最基本的。但真正决定了Makefile好写难写的是赋值方式。常用的赋值操作符有四个操作符名称行为递归展开赋值使用变量时才展开展开结果是最终值允许往后引用:立即展开赋值定义时立即展开当前值不保留后向引用?条件赋值变量未定义时才赋值追加赋值在原有值后面追加内容这四个操作符的差别一句话说不清楚我用例子说话。A one B $(A) two A three如果用B最终的值是“three two”。因为make在展开B的时候A已经变成了three。如果用:B最终是“one two”因为定义B的那一刻A还是one后面再改A不影响B。这个差异带来的连锁反应会直接决定你的Makefile好不好排查。我自己建议在大多数情况下优先用:因为它更接近“程序的直觉”逻辑确定性强不会出现那种“变量在某个地方还没定义、到另一个地方又有了定义”的灵异现象。函数的用法也有讲究。$(wildcard *.c)可以获得当前目录下所有.c文件的列表$(patsubst %.c,%.o,$(wildcard *.c))能把它们逐一替换成.o文件名列表。这些函数组合起来就是自动化构建的基石。我遇到很多明明可以靠函数自动收集的文件列表硬生生被写成了几百行的手工罗列不仅容易漏维护起来还痛苦。3. 新手最容易翻车的几个Makefile细节3.1 那个著名的“魔鬼空格”如果你已经在终端里敲过几次Makefile十有八九遇到过这个提示Makefile:5: *** missing separator. Stop.第一次遇到时我盯着屏幕看了半天不明白“缺分隔符”是什么意思。用cat -A一看才发现命令行的开头是四个空格不是Tab。Make要求命令行的开头必须是Tab空格再多也不行这算是这门语言最反直觉的规定之一。我在给某位同学看问题Makefile的时候他还嘴硬说“我用的编辑器会自动把Tab转成四个空格这不是更规范吗”是的对Python来说缩进统一是好事但Makefile偏偏只认Tab。所以你最好把编辑器里的“自动将Tab转换为空格”选项关掉或者干脆每次出现missing separator先检查这个问题。怎么检查最靠谱在vim里执行:set listTab会显示成^I空格是·一眼就能分辨命令行里可以用sed -n 5p Makefile | od -An -tx1看看那行开头是不是09Tab的十六进制。排查思路比死记规则更重要。这类问题还有一个变种结构没问题但某个规则的缩进里混了一个Tab加几个空格Make照样报错或者行为诡异。这种混合缩进比纯空格还难排查因为肉眼几乎看不出来。3.2 伪目标与同名文件冲突clean这种坑我们习惯性给Makefile写一个clean目标用来删除编译产物。但如果当前目录恰好有一个叫做clean的文件麻烦就来了。你执行make cleanmake发现目标clean已经存在检查它的依赖空依赖——没有任何依赖比它新于是告诉你“clean已是最新”里面的rm命令根本不会执行。这不是段子。现实中经常发生某人把一个空文件命名为clean放在目录里然后所有同事的clean任务都失效了直到有人用rm -f clean手动删掉才恢复。标准的解法是声明伪目标.PHONY: clean clean: rm -f hello main.o utils.o.PHONY标识的目标永远不检查文件时间戳明确告诉make“这只是一个操作名不是真实文件”。所有不生成文件的“操作型目标”比如all、clean、install、test都应该放进.PHONY。忘了这行代码相当于给自己埋了一颗随时会失效的定时炸弹。3.3 命令行Shell的隐性问题Makefile里的规则默认是由/bin/sh来执行的。这里的“默认”很有讲究。如果你在Makefile里写了一段bash特有的语法比如[[ -f file ]]或者source script.sh在Debian系机器上默认/bin/sh指向dash这会直接报语法错误在有些环境里又恰好指向bash于是又没事。同一个人在不同机器上构建时开心时崩溃往往就是这个问题导致的。解决办法有两个方向。要么只在规则里写可移植的POSIX shell语法避免非必要的bash扩展要么在文件头部声明SHELL : /bin/bash明确指定解释器。既然现代环境里bash无处不在我的习惯是直接指定SHELL : /bin/bash省得在奇怪的地方被坑。还有一点容易被忽略make的每条规则是在单独的shell进程里执行的。你在这条规则里cd /some/dir下一条规则并不会停留在这个目录里因为每个进程是独立的。所以如果你有“先进目录再跑命令”的需求要么把cd和后面的命令写在同一条规则里用连接要么用$(MAKE) -C dir这种递归方式。很多人写的命令明明没错就是跑到一半找不到文件多半是把这个模型理解错了。3.4 变量展开时机的实际问题有个真实场景是这样的我需要生成一个时间戳变量在每次构建时都不一样。用赋值的话每次用到$(BUILD_TIME)会重新执行一次date命令得到新的时间用:赋值的话它只会在Makefile解析时执行一次整个构建期间都用同一个值。看似小差别实际影响很大——如果你想让日志里的构建时间一致且稳定就必须用:如果你想每次调用自动刷新就得用或$(shell date %s)放在规则的命令行里。同样的道理适用于读取文件内容、运行脚本等场景。新手最容易犯的错误是把$(shell ...)到处乱放结果一条规则里调用了三五次外部命令本来就慢还要重复执行。正确的做法通常是在Makefile顶部定义一次后面反复引用变量。4. 进阶玩法模式规则、自动变量、函数调用4.1 用模式规则消灭重复代码上面的最小例子里main.o和utils.o的规则几乎一模一样都是编译同一个名字的.c文件只是依赖的头文件列表略有不同。如果项目有几十个源文件这么写下去谁都会疯。这时候模式规则就该登场了。比如%.o: %.c utils.h gcc -c $ -o $%.o: %.c的意思是任何需要的.o文件都尝试用同名.c文件作为依赖来生成。你说“我需要foo.o”make会自动去找foo.c如果找到了就执行这条规则。$表示第一个前置依赖也就是那个.c文件$表示目标也就是生成的.o文件。这两个是自动变量后面会专门说。加上头文件依赖时要注意模式规则里的依赖列表如果写死就失去了灵活性。一般做法是模式规则里只写%.o: %.c对具体的头文件依赖用单独规则补充。Make支持同一目标有多条规则依赖会被合并。比如%.o: %.c gcc -c $ -o $ main.o: utils.h utils.o: utils.h这样编译命令只需要维护一份模式规则头文件依赖可以单独追加可读性和可维护性都很高。这是我在中小型C项目里最喜欢的写法。4.2 自动变量缩短你命令行长度的利器自动变量是在规则执行时由make自动填充的占位符。常用的是这几个自动变量含义$当前规则的目标文件$第一个前置依赖$^所有前置依赖去重$?比目标更新的前置依赖列表有了它们很多命令可以写得非常简短。例如链接命令hello: main.o utils.o gcc -o $ $^$等价于hello$^等价于main.o utils.o。等工程扩大新增了对象文件只需要改依赖列表命令行本身不用动。我一直认为写Makefile的时候多用自动变量少抄目录名和文件名是保持文件清爽的关键。不同的人维护同一个Makefile经常因为路径写得不一样导致风格混乱自动变量至少能逼着大家统一写法。4.3 wildcard、patsubst与文件列表自动化手工把每个源文件都写进变量等于给自己增加维护负担。更稳的方法是让Make自己去找SRCS : $(wildcard src/*.c) OBJS : $(patsubst src/%.c,obj/%.o,$(SRCS))这两行的意思是把src目录下所有.c文件收集到SRCS然后把其中每个文件名从src/xxx.c替换成obj/xxx.o存入OBJS。这样你添加一个源文件Makefile一个字符都不用改。这里有个细节wildcard匹配的路径是按模式来的如果你的源文件分布在多个子目录可能需要多个wildcard调用再合并。也可以用$(shell find src -name *.c)来收集但要注意这会引入对所有子目录的递归遍历项目大的时候有一定性能开销。权衡下来绝大多数中小项目用两层wildcard就完全够用。4.4 条件判断与自定义函数Makefile还支持条件判断可以按编译器类型、系统平台、编译模式走不同的分支ifeq ($(CC),gcc) CFLAGS -Wall -Wextra else ifeq ($(CC),clang) CFLAGS -Wall -Wextra -Wpedantic else CFLAGS -O2 endif这种写法在做跨平台编译时非常实用。你在Linux下用gcc在嵌入式环境里用某芯片厂商的交叉工具链都能通过CC变量同一套Makefile走不同路径。自定义函数稍微进阶一点用define可以封装一段逻辑。比如模拟一个notdir行为define get_basename $(patsubst src/%.c,%,$(1)) endef调用时用$(call get_basename,a.c)。说实话自定义函数在Makefile里的使用频率不算高因为Make的字符串处理能力有限写得复杂了反而晦涩。我的建议是浅浅地会用就行遇到真正复杂的逻辑用shell脚本或者Python脚本处理再让Make调用往往更清晰。5. 真实工程里的Makefile应该怎么组织5.1 一个分层的C项目Makefile示例假设项目结构是这样project/ ├── Makefile ├── src/ │ ├── main.c │ ├── utils.c │ └── parser.c └── include/ └── utils.h一个兼顾清晰和自动化的Makefile可以这样写SHELL : /bin/bash CC : gcc CFLAGS : -Wall -Wextra -O2 -Iinclude TARGET : app SRCS : $(wildcard src/*.c) OBJS : $(patsubst src/%.c,build/%.o,$(SRCS)) .PHONY: all clean all: $(TARGET) $(TARGET): $(OBJS) $(CC) -o $ $^ build/%.o: src/%.c $(CC) $(CFLAGS) -c $ -o $ clean: rm -rf build $(TARGET)这里有几个设计意图。OBJS被放到了build子目录避免源文件目录和编译输出目录混在一起模式规则把src下的.c编译到build下的.o最终链接命令用自动变量组装。整个文件不到20行却包含了增量编译的全部核心内容。实际跑一下的效果第一次执行make会看到make依次编译所有.c文件并链接第二次执行它只会说“app已是最新”不会做任何多余的事情。如果你改了parser.c它只重新编译parser.o并重新链接不会碰到main.o和utils.o。这就是增量构建带来的日常体验。5.2 嵌套Makefile用顶层入口管理多模块项目变大之后把所有内容塞进一个Makefile并不明智。更常见的做法是每个子模块维护自己的Makefile顶层Makefile用递归的方式把它们串起来。经典的递归写法.PHONY: all clean submodule all: submodule submodule: $(MAKE) -C lib $(MAKE) -C tools$(MAKE)和make有一点微妙的不同前者会继承顶层命令行上带有的某些变量和参数比如-j并行参数这在深层嵌套时很重要。如果你写死make -C lib并行度不会传递下去用$(MAKE)则能保持一致性。递归Makefile的好处是有清晰的分层边界每个目录可以独立构建、独立测试。缺点是依赖关系的跨目录表达比较弱容易在一个模块改动了接口之后另一个模块没有被重新构建。所以递归模式适合模块间边界清晰、改动低频的场景如果模块之间接口耦合紧密我更推荐考虑把整个项目拉平成单一Makefile让make依靠全局依赖图完成调度。5.3 并行构建-j参数的双刃剑现代CPU核心多构建耗时的项目大家都会自然而然加上make -j$(nproc)。并行构建确实能把多核性能用满但有一个前提你的Makefile依赖关系必须写准确或者至少是合理的。如果规则之间的依赖不完整并行时make可能会“抢跑”——例如main.o还没生成就在链接app导致链接器报错找不到文件。这种错误往往只出现在高并行度下单线程构建反而碰不到属于典型的隐蔽问题。排查的时候可以先用make -j1确认所有规则都能正常完成再逐步调高-j值。如果某一档开始报错基本可以确定是某个依赖缺失。好一点的自动化手段是打开make的调试信息执行make -j4 --debugbasic它会打印每个目标的判断和调用过程很容易看出某条规则是否在条件不满足时被误触发。这个调试标志在排查各类Makefile问题时都是利器值得记下来。5.4 清理、安装、测试等附属目标干净的Makefile通常不只是“构建”本身还会包含几个常用附属目标比如clean、install、test。这些目标本质上都是操作型接口要注意几个细节clean务必加入.PHONY否则可能被同名文件干扰install的路径不要写死到个人目录应该通过变量如PREFIX控制默认/usr/local这样他人可以通过make PREFIX$HOME/local install自定义test目标里尽量引用构建产物而不是默认假设它已经存在可以声明依赖$(TARGET)。我在自己维护的项目里习惯把make help也写进去用注释列出可用的目标和主要变量。这不算标准做法但对团队协作帮助极大新人看一眼就知道怎么接手构建系统。6. 我踩过的那些坑以及现在怎么绕开6.1 时间戳陷阱版本控制与incremental构建打架前面提过时间戳是Make的生命线但版本控制工具会破坏这条生命线。切换分支时即使文件内容变了文件系统的时间戳可能不更新或者某些工具在检出文件时把所有文件都标成最新时间导致make认为一切都需要重编。这个问题的现象是要么日志显示“一切已是最新”但实际上代码已经变了要么构建突然全量重跑明显不对劲。我的处理习惯是在关键分支切换、拉取代码之后如果怀疑构建状态异常先执行一次make clean再重新构建。虽然浪费一点时间但能最大限度避免“假增量”带来的诡异行为。更好的做法是构建系统里加入版本或哈希标记比如把当前git rev-parse --short HEAD写入一个头文件生成目标依赖它这样切分支后文件的哈希必然变化从而触发重编。这个方案在自己的工程里可以落地但对别人维护的开源项目不宜滥用。6.2 头文件依赖缺失经典但不死模式规则里只写%.o: %.c不写头文件依赖会出现什么后果你改了某个头文件但所有包含它的.o文件不会重新编译最终链接出来的程序行为怪异甚至崩溃。这种错误极其隐蔽因为它不报错。解决思路有几个。简单的方案是给每个.o追加显式头文件依赖懒一点的方案是使用gcc -MMD生成依赖文件再用-include把它们拉进来。-MMD会为每个.c生成一个对应的.d文件里面记录了该.o真正依赖的完整头文件列表Make规则里用-include引入这些.d文件依赖就自动完整了。CFLAGS -MMD DEP_FILES : $(OBJS:.o.d) -include $(DEP_FILES)这样写每次编译都会顺带刷新依赖文件头文件改动之后自动触发正确范围的重新编译维护成本很低。这是我在所有非玩具项目中首选的方案。6.3 命令不回显前缀的妙用Make打印每一条执行的命令是默认行为这个特性对调试友好但有时候命令非常长日志刷屏让人看不清重点。在命令行前面加可以抑制回显install: echo Installing to $(PREFIX) cp app $(PREFIX)/bin/推荐的做法是复杂项目里将关键流程用自己的echo输出提示普通命令前加。这样输出的是“人在意的信息”而不是“命令原文”。这也是很多成熟构建系统的通用做法——看一下Bootstrap或是内核模块的构建过程就能感受到这种输出组织方式的美感。6.4 用-include和include的区别Makefile里用include引入其他Makefile片段时如果文件不存在make会直接报错。而-include注意前面的减号在文件不存在时只是默默跳过不停止构建。这个设计非常适合配合自动生成的依赖文件第一次构建时.d文件还不存在跳过是正常的后面构建才真正把它们包含进来。区分这两者能避免很多莫名其妙的“首次构建失败”。如果你写了普通include并且包含的文件第一次不存在make还没开始干活就先撂挑子了这绝对不是你想要的效果。7. 一些关于Makefile学习路线的建议如果你是零基础看完这篇直接从第一个「hello」目标开始动手把示例复制到自己机器上跑一遍再故意改几次把报错都踩一遍学到的东西远比我多说十句话有用。如果你有一定基础重点对照我列出的坑去检查自己手头的Makefile——尤其是头文件依赖和.PHONY这两项。很多开源项目的“小毛病”就出在这些地方。我个人的学习路径是先复刻一套最简单的单目录构建能够编译和清理然后把源文件挪进子目录把编译输出挪进另一个目录再引入wildcard和模式规则最后加依赖文件生成与并行构建。这四步走完现实中绝大多数工程构建需求都够用了。更高阶的元编程玩法等真正需要的时候再深入研究也不迟。现在回头看35年前的Make之所以历久弥新不在于它语法多优美、功能多强大而在于它把构建领域最核心的“依赖关系”模型摆到了台面上。后来的各类构建系统本质上都在围绕这个模型做优化和包装。你只要把这个模型的资源品透了阅读任何构建工具的文档都像是看一个老朋友的近况。