本书以真实沙箱实战为线索,系统讲解如何用 Make 的时间戳判据、模式规则、变量与 stamp 文件,把重复任务变成可断点续跑的可靠流水线,从而在受限/易中断环境中实现增量构建、并行安全与工程化运维。
《Make 极简入门:从一条规则到可断点续跑的流水线》
作者:广山哥
完成时间:2026-10-31
序言
序言
这本书为谁而写
如果你曾经在终端里反复敲同一串命令,如果你写过那种“跑完第一步就忘了第二步是什么”的 shell 脚本,如果你听说过 Makefile 但一直觉得那是 C 语言程序员才需要的东西——这本书就是为你写的。
它不要求你会 C,不要求你懂编译原理,也不要求你有任何构建系统的经验。你只需要有一样东西:一件需要重复执行的、有先后顺序的任务。
这本书怎么来的
2026 年 10 月底,我在一台腾讯云沙箱里写另一本书。那台机器有一个奇怪的脾气:每十到六十分钟被平台回收一次,所有进程消失,但磁盘上的文件原封不动。
我很快撞上一个具体问题。八章书稿要合成一本整稿、打成压缩包、上传到备份服务器。这三步如果写成一条龙脚本,每次被中断就得从头再来;如果写成带判断的脚本,判断条件会越写越长。三次中断之后,我决定换一个工具。
我换成 Make,只用了三行。
那三行解决的不只是“少打字”的问题,它解决的是一个更根本的问题:怎么让“做过”这件事变成磁盘上的事实,从而让中断变得无关紧要。 这个念头后来长成了这本书。书里所有的例子都来自那次真实的折腾,包括那些踩过的坑——从网页复制 Makefile 导致的制表符错误,到 crontab 里未转义的百分号让整条命令静默失效。
这本书的结构
五章,一条递进的线。
第一章回答“为什么值得学”。它用那台会被重置的沙箱作为起点,说明 Make 的一句话原理:目标不存在或依赖更新就执行,否则跳过。这一章结束时你应该有一个能跑的三行 Makefile。
第二章拆解一条规则。目标、依赖、命令三个角色各自负责什么,七个自动变量怎么用,为什么伪目标既重要又容易被滥用,为什么每一行命令是独立的 shell。这一章结束时你应该能读懂任何一行 Makefile。
第三章讲变量、模式规则和函数。四种赋值符号的差别、九个够用的函数、如何做到“新增一个文件只改零行”。这一章结束时,你的 Makefile 应该能从“能跑”升级到“可维护”。
第四章讲断点续跑。这是全书的核心,也是它区别于其他 Make 教程的地方。stamp 文件、幂等三原则、四核的并行纪律、失败处理与文件锁、谁来在重启后把它拉起来。这一章结束时,你应该能把一件四十分钟的活拆成可被随时中断、随时接续的任务。
第五章讲调试、陷阱与工程化。六件调试武器、十类经典坑、八条规范、一份三十行的起手模板。这一章结束时,你应该能在半年后还看得懂自己写的 Makefile。
书末另附一张速查表(附录 A),把 Make 的全部特殊符号按七类整理成表:结构符号、赋值符号、命令前缀、展开符号、自动变量、特殊目标、指令与内建变量。它不承担教学功能,只供写作与排错时随手翻查。
与同类书的差异
市面上的 Make 教程大多是编译教材的附属品:先讲 C 语言如何编译、目标文件如何链接,然后才提到 Make。这本书的顺序反过来——先讲任务,再讲判据,最后才在例子里出现编译。
这个顺序上的差别不是排版偏好,而是立场。Make 的核心抽象与编程语言无关,它的适用面远大于编译。把它锁在编译语境里,会让大量本该用它的人绕道去写脆弱的手写脚本。
第二个差异是环境。这本书的每一个例子都在一台真实受限的机器上跑过:四核、八吉内存、随时被重置。所以它不回避那些“理想环境里不会遇到”的问题——目录时间戳导致的重做、nproc 报出三十二而配额只有四、cron 的百分号转义。这些细节在教科书里通常不会出现,但在真实工作里天天出现。
怎么读这本书
想快速上手,读第一章加第二章第七节,然后抄第五章第九节的模板。
想把自动化做扎实,四章连读,边读边把手上最烦的那个脚本改写成 Makefile。
只想查问题,直接翻第五章第二节的十类坑,或者第五节的八个高频问题。
书里所有命令都可以直接复制执行。所有例子都在 Linux 上验证过,需要 macOS 或 Windows 的地方会额外标注。如果你的机器上没有 GNU Make,第一章第七节有一行安装命令。
一点提醒
Make 的语法不漂亮。制表符、四种赋值符号、一堆缩写,处处是历史包袱。你会在某一刻觉得它别扭,这很正常。
但请坚持到第四章。当你第一次看到一件被中断三次的任务仍然一次跑完、结果与连续执行完全一致时,那些别扭会突然变得可以忍受。因为你要的东西它真的给你了:中断不再是灾难。
写这本书的时候,我自己的沙箱又被重置了两次。每次恢复后我做的第一件事,就是 cd 到项目目录跑一次 make——它自己算出了还差什么。这大概就是这本书想传达的全部。
目录
- 序言
- 第1章 为什么 Make 还没死
- 第2章 一条规则的解剖
- 第3章 变量、模式规则与函数
- 第4章 断点续跑:让中断不再等于从头开始
- 第5章 调试、陷阱与工程化
- 附录A Make 特殊符号速查表
第1章 为什么 Make 还没死
第 1 章 为什么 Make 还没死
1.1 一个会被重置的沙箱
先讲一件真事。
2026 年 10 月底,我在一台腾讯云 Cloud Studio 的沙箱里做一个写书项目。这台机器的规格是 4 个虚拟核、8 GiB 内存、250 GiB 磁盘。它有一个奇怪的脾气:每 10 到 60 分钟就可能被清空一次。所谓清空,与其说是关机再开,不如说是整个容器被平台回收,换一个新的实例给我。正在跑的进程全部消失,网络接口重新来过,登录会话断开。
但它有一个我没预料到的性质:磁盘上的东西一件不少。前一天晚上写好的八章书稿、三十七个对话记录、几天前的中间文件,全部原封不动。
这两个事实放在一起,构成了一种非常特殊的工作环境:进程是易失的,文件是持久的。
我很快发现,这个环境对工作方法提出了一个硬性要求。你要做的每一件事,都必须能回答两个问题:
第一,如果做到一半被中断,重来的时候能不能从断点接着做?
第二,如果做到一半被中断,我怎么知道哪些部分已经做完了?
用 shell 脚本一条命令一条命令地手动跑,这两个问题都答不上来。八章书稿要合成一本整稿,再打成一个压缩包,再上传到备份服务器。如果你写成三条命令的顺序脚本,第一遍跑到第二条时被重置,那么你下次只能从头再跑一遍。三次重置之后,你会开始怀疑人生。
后来我把这件事交给了一个只有二十多年历史的工具,它用三行文本就解决了。这个工具就是 Make。
现在你可能会想,Make 不是给 C 语言编译用的老年工具吗?它跟写书有什么关系?
这个问题正是本书要回答的。简短地说:Make 的本质不是编译器配件,而是一台以文件为判据的“必要性判定机”。 它只回答一个问题:某个文件,现在需不需要重新生成?凡是可以被表述成“文件由文件生成”的任务,都能交给它。写书算,转码算,抓网页算,拼装发布包也算。
理解了这一条,你会发现 Make 是一台“任务台账”,而它的记账方式比绝大多数任务框架都稳,因为它把账记在磁盘上,不记在内存里。
1.2 如果没有 Make,你会怎么写
要理解 Make 为什么值得学,先看清楚没有它时的三种典型写法,以及它们各自的死法。
第一种:一条龙脚本。
#!/bin/bash
set -e
cat chapters/ch01.md chapters/ch02.md chapters/ch03.md > out/book.md
cd out && zip -q book.zip book.md
curl -s -u "$AUTH" -X POST https://example.com/upload -F "file=@book.zip"
echo "done"
这段脚本在正常环境下没有任何问题,快、直白、好读。它的问题只有一个:它没有记忆。
跑到第二行时机器被重置,你重新执行脚本,cat 会再跑一遍。这次只花两秒,无所谓。但如果第一行是“下载并解析 200 个网页,耗时 40 分钟”呢?重跑一次就是 40 分钟,而你的窗口只有 20 分钟,于是你永远跑不完。
一条龙脚本的死法叫“全或无”:要么全部完成,要么全部白做。
第二种:加一堆 if 判断。
有经验的人会立刻改进,给每一步加一个“做过就跳过”的判断:
if [ ! -f out/book.md ]; then
cat chapters/*.md > out/book.md
fi
if [ ! -f out/book.zip ]; then
cd out && zip -q book.zip book.md
fi
这就进步了,它有了记忆。但它引出两个新问题。
第一个问题:判断条件写得太粗。out/book.md 存在就跳过,可如果某个章节后来被修改了呢?你改好了第九章,脚本依然认为整稿已完成,永远不会重新拼装。要修就得再写判断:if [ out/book.md -ot chapters/ch09.md ]。章数一多,这串判断会长到没人愿意读。
第二个问题:顺序被写死了。脚本从头到尾串行执行,没有并行。你手上有 4 个核,但同一时刻只有一个核在干活。
第二种写法的死法叫“维护成本”:每加一个步骤、每加一个依赖关系,你就要手写一遍判断逻辑。
第三种:上个正经的任务框架。
有人会说,那我用 airflow、用 Luigi、用 make 的替代品不就行了。它们确实解决了依赖图和断点续跑,但代价是你得引入一个新系统:一个调度器、一个数据库、一套 API、一堆配置。为了把八个文件合成一个文件,这个代价显然不成比例。
第三种写法的死法是“杀鸡用牛刀”:工具的能力远超问题本身,而运维成本落在了问题之外。
现在回头看 Make 的位置。它正好卡在这三者之间:
- 有记忆(靠文件时间戳),不需要写
if [ -f ] - 有依赖图(靠冒号声明),不需要写顺序
- 有并行(靠
-j),不需要写线程池 - 零依赖(一个 200 KB 的二进制),不需要装任何东西
它的全部语法,入门阶段只有三样东西要记:冒号、制表符、变量。
1.3 Make 的一句话原理
Make 的判据只有一条,请把它抄在便签上:
目标文件不存在,或者任何一个依赖文件比目标文件新,就执行命令;否则什么都不做。
这句话包含了 Make 的全部哲学。它把“要不要干活”这个决策,交给了一个永远可靠的外部事实:文件修改时间。
请注意这个设计的精妙之处。它没有维护任何状态数据库,没有中间日志文件,没有进度表。状态就是磁盘上文件的修改时间——这些东西操作系统一直在帮你记,分毫不差。所以 Make 天然抗中断:被重置之后,文件还在,时间戳还在,判据就还在。
我们做一个实验。打开终端,建一个空目录,创建两个文件:
mkdir -p /tmp/makelab && cd /tmp/makelab
echo "source" > in.txt
echo "old" > out.txt
ls -l --time-style=+%T in.txt out.txt
你会看到两个文件的修改时间。假设 in.txt 是 10:00:05,out.txt 是 10:00:09。由于目标比依赖新,Make 会认为“已经做过了”。
现在如果想表达“out.txt 依赖 in.txt”,Makefile 写法是:
out.txt: in.txt
cp in.txt out.txt
注意第二行开头是一个制表符,不是四个空格。这一点后面会专门讲,现在先照做。
执行 make:
make: 'out.txt' is up to date.
Make 什么都没干,因为目标比依赖新。
现在动一下依赖:
echo "changed" >> in.txt
make
这次输出:
cp in.txt out.txt
它执行了。因为 in.txt 的修改时间变成了刚才,比 out.txt 新。
再执行一次 make,又会得到 is up to date。因为刚才那次 cp 已经把 out.txt 的时间推到了最新。
这三个动作——跳过、执行、再跳过——就是 Make 的全部。你后面会学到的变量、函数、模式规则、并行、锁,全部是在这一个判据上搭出来的脚手架。判据本身不会变。
顺便解释一个常见误解。很多人以为 Make 只在编译时有用,因为教材里永远是 gcc -c 的例子。其实 Make 完全不知道也不关心命令内容是什么。你让它跑 cp、跑 curl、跑 pandoc、跑 python3 train.py,它都一视同仁。它唯一关心的,是命令跑完之后,那个目标文件是不是真的出现了、时间是不是真的更新了。这就是为什么我在沙箱里能拿它管书稿——对 Make 来说,一章 Markdown 和一个 .o 文件没有任何区别。
1.4 第一个 Makefile:三行
我们在这个实验目录里写一个真正能干活的版本。
out/book.md: chapters/ch01.md chapters/ch02.md
@mkdir -p out
cat chapters/ch01.md chapters/ch02.md > $@
这里出现了两个新东西。
第一个是 @。它的意思是“这一行命令不要回显”。默认情况下 Make 会先把你写的命令打印出来,再执行。@ 让它安静执行,适合 mkdir 这种噪音命令。
第二个是 $@。这是自动变量,代表“当前目标的名字”。在这里它展开成 out/book.md。用它而不是重复手写路径,好处是你以后改目标名时只需要改一处。
现在把这两个文件真的造出来:
mkdir -p chapters
printf '# 第一章\n内容一\n' > chapters/ch01.md
printf '# 第二章\n内容二\n' > chapters/ch02.md
make
输出应该是:
cat chapters/ch01.md chapters/ch02.md > out/book.md
注意 mkdir -p out 没有出现,因为前面加了 @。但目录确实建好了,否则重定向会失败。
现在做一次关键实验,这个实验能让你彻底理解 Make 的判断逻辑。删掉产出文件再跑:
rm out/book.md
make
它会重做。因为目标不存在。
修改一个章节再跑:
echo "新增一段" >> chapters/ch02.md
make
它也会重做。因为依赖比目标新。
只改 Makefile 里的一行注释再跑:
make
这次它什么都不做。因为 Makefile 的修改不在依赖列表里,Make 看不见。
第四条最重要,它是新手最容易困惑的地方之一:Make 不读你的心,它只看你写在依赖里的东西。 你在命令里偷引了一个 Makefile 没声明的文件,Make 就永远不会因为那个文件变化而重跑。第 2 章会专门讲怎么把这类依赖补全。
1.5 制表符不是排版洁癖
新手在 Make 上摔的第一个跟头,99% 是这个:
Makefile:2: *** missing separator. Stop.
原因极其朴素:Make 要求命令行以真正的制表符开头,用空格会被拒绝。
为什么这么固执?历史原因是 1976 年 Stuart Feldman 写第一版 make 时,为了避免解析歧义,选了一个“正常人不会用在行首”的字符作为命令标记。这个决定被沿用了五十年,而且短期内不会改。
诊断方法很直接:
cat -A Makefile
如果第二行开头显示 ^I,那是制表符,正确。如果显示一串空格,那就错了。
修复有四种:
# 方法一:vim 里把空格换成制表符
:%s/^ /\t/
# 方法二:sed 批量替换(把行首四个空格换成 TAB)
sed -i 's/^ /\t/' Makefile
# 方法三:让编辑器显示不可见字符后再手改
# vim: :set list 或 VS Code: 开启 renderWhitespace
# 方法四(推荐):在编辑器里为 Makefile 单独设置"插入空格代替制表符 = 关闭"
方法四是根治。多数编辑器在检测到 Makefile 文件名时会自动切换到制表符模式,但如果它没识别出来(比如你叫 build.mk),一定要手动关掉那个“用空格缩进”的开关。
另有一个容易踩的变体:把缩进写对了,但行首混了“制表符 + 空格”。这种在 cat -A 下显示为 ^I ,肉眼几乎看不出来,但 Make 只接受纯制表符开头。诊断方法还是 cat -A。
最后提醒一句,很多中文教程在网页上贴 Makefile 时,制表符会被转成空格。从网页复制 Makefile 是这类报错的头号来源。 遇到 missing separator,先怀疑复制粘贴。
1.6 Make 能做什么、不能做什么
把边界说清楚,比罗列功能更有用。
适合交给 Make 的,有三个共同特征:
第一,有明确的文件产物。编译出 .o、转换出 .pdf、抓取生成 .html、拼装出 .zip、压缩出 .gz。产物存在,判据就能工作。
第二,依赖关系可以用文件名表达。A 文件由 B 和 C 生成,这句话本身就是一个依赖声明。
第三,单次任务可以在可接受的时间内跑完。这里的“可接受”取决于你的中断风险:如果环境每 20 分钟重置一次,那么单步应该控制在 15 分钟以内。
我在这台沙箱上实际用 Make 做过的事:
拼装书稿(八章合并成整稿,再打包上传);批量把 Markdown 转成 HTML 并生成目录;把一批音频转成统一码率;把备份文件按日期归档;启动后自动补做上一次没做完的批次。这些事情看起来五花八门,但都符合上面三条。
不适合交给 Make 的,也有明显特征:
长驻服务。Make 的目标是“做完就退出”,不是“一直活着”。守护进程该交给 supervisor、systemd 这类工具管。
需要事务语义的流程。Make 没有回滚,一条命令执行到一半失败,留下半成品,它不会帮你清理。虽然可以用 .DELETE_ON_ERROR 缓解(第 4 章讲),但那只是删掉当前目标,不是事务。
依赖清单高频变动的场景。如果你的任务清单是“从数据库里查出来的 3000 行”,每次都不一样,那么把清单写进 Makefile 会非常别扭。这种场景用 xargs -P4 或一个真正的队列系统更快。第 4 章会给两种写法的对比。
一句话总结边界:Make 擅长“清单稳定、产物明确、单步不长”的批量任务;不擅长“常驻、事务、动态清单”。
1.7 装好它,并确认版本
好消息是,你可能已经装了。绝大多数 Linux 发行版和 macOS(装了 Xcode 命令行工具后)都自带 make。
make --version
在这台沙箱里,输出是:
GNU Make 4.3
Built for x86_64-pc-linux-gnu
三个信息要留意:
第一,必须是 GNU Make。macOS 自带的 make 在旧版本里其实是 BSD make(叫 bmake),两者语法有差异,尤其是条件语法和部分函数。如果你在 macOS 上敲下版本命令后看到的不是 GNU 字样,用 brew install make 装 GNU 版,它会以 gmake 的名字出现,后续所有命令都用 gmake。
第二,版本号决定可用特性。4.0 以上有 .ONESHELL(让整个 recipe 用一个 shell 执行),4.3 以上有分组目标 &:。本书的例子都标注了最低版本要求,大多在 3.81 以上就能跑。
第三,make -p 能打印出它所有的内建变量和隐含规则,输出有几千行。这是本书最值得收藏的一条命令,第 5 章会详细讲怎么用它排查问题。
安装命令留在这里备用:
# Debian / Ubuntu
apt-get install -y make
# RHEL / CentOS / TencentOS
yum install -y make
# macOS
brew install make
# 验证
make --version | head -1
装好之后,回到 /tmp/makelab,把前面那个三行 Makefile 再跑一遍 make。当你在屏幕上看到 is up to date 与 cp in.txt out.txt 交替出现,并且能准确预测下一次会输出哪一个,第 1 章的目标就达成了。
你现在掌握的其实只有一条判据和三个符号。但请相信,后面四章所有的复杂技巧,包括模式规则、函数、并行、锁、断点续跑的工程化,都只是在这条判据上不断增加“让判据更准确”和“让任务更可切分”的手段。判据本身,已经在你手里了。
1.8 四个练习:把判据练成本能
光读不练,判据永远停在纸面上。下面四个练习都在 /tmp/makelab 里完成,每个都要求你先预测输出,再执行验证。预测错的那一次,就是你真正学到东西的地方。
练习一:预测三次 make。
Makefile 保持第一章那三行的样子,然后依次执行:
touch in.txt && make
make
touch out.txt && make
第一次会执行 cp,因为 in.txt 被 touch 后比目标新。第二次会输出 is up to date。第三次仍会输出 is up to date,因为 touch out.txt 把目标推到了最新,而 in.txt 没有变化。
这个练习的重点是理解:判据是“依赖新于目标”,不是“依赖改动过”。 Make 不知道你改没改文件内容,它只知道时间。所以文件内容变了但时间没变(例如某些同步工具会保留修改时间),Make 就看不见。
练习二:制造一个“改了却不重跑”的事故。
故意让 Makefile 漏掉一个依赖:
out/book.md: chapters/ch01.md
@mkdir -p out
cat chapters/ch01.md chapters/ch02.md > $@
然后:
rm -rf out
printf '甲\n' > chapters/ch01.md
printf '乙\n' > chapters/ch02.md
make # 第一次,生成 甲 乙
printf '丙\n' > chapters/ch02.md
make # 观察:它会不会重跑?
cat out/book.md
结果是它不会重跑,out/book.md 里仍然是“甲 乙”,而你明明改了第二章。原因就在依赖列表里:只写了 ch01.md,Make 看不见 ch02.md 的变化。
这个练习是针对新手最重要的一个教训。它说明两件事:第一,漏依赖不会报错,只会静默地产生错误结果;第二,因此写成通配的、自动推导的依赖比手写清单更安全,这正是第 3 章要用 $(wildcard) 的理由。
练习三:把制表符杀掉,看看会发生什么。
sed -i 's/^\t/ /' Makefile
cat -A Makefile | head -3
make
你会看到 Makefile:2: *** missing separator. Stop.。然后用 sed -i 's/^ /\t/' Makefile 修复,make 恢复正常。
请把这个报错的样子记住。它是新手上路最常见的一道墙,记住之后能省下大量搜索时间。
练习四:让 Make 做一件跟编译无关的事。
自己写一条规则,把一段文本转成大写并存成新文件。提示:可以用 tr。
out/shout.txt: in.txt
@mkdir -p out
tr 'a-z' 'A-Z' < $< > $@
跑通之后,再想一个问题:这条规则和 gcc -c 在 Make 眼里有区别吗?答案是没有任何区别。Make 只关心 $@ 有没有被创建出来。理解这一点,你就不会再把它当成“C 语言工具”了。
四个练习做完,你应该能在脑子里模拟出任意一个简单 Makefile 的执行轨迹。这种感觉很重要,因为后面所有的高级特性,都是在这条轨迹上增加分支,而不是替换它。
1.9 本章小结与常见误解
本章只讲了一件事:Make 的判据。
回过头看,这条判据之所以强大,不是因为聪明,而是因为笨得可靠。它不做任何推测,不维护任何内部状态,不问“上次是不是成功”,只做一次时间比较。这种笨,正是它四十年不退场的根本原因:任何外部的、可观察的事实,都比内部的、需要维护的状态更不容易出错。
四个常见误解,在这里一次性澄清。
误解一:Make 是给 C 语言用的。
事实是 Make 与语言完全无关。它执行的是 shell 命令,而 shell 命令能做的事就是它能做的事。本书后面四章没有一个例子需要 C 编译器。
误解二:Make 只能在 Linux 上用。
GNU Make 在 macOS、Windows(MinGW / WSL / MSYS2)、各类 BSD 上都能跑。真正需要注意的是“GNU 版”和“BSD 版”的差异,而不是操作系统的差异。
误解三:Makefile 越短越好。
短不是目标,可维护才是。一个把所有逻辑塞进一行的 Makefile,三个月后你自己也读不懂。第 5 章会给一条明确的规范:一个目标一件事,能命名就命名。
误解四:既然有现代任务运行器,Make 该退休了。
第 5 章会专门比较 just、task、npm scripts 与 Make 的差异。这里先给结论:它们大多更现代、更友好,但 Make 有一个别人替代不了的优势——它几乎必然已经装在你的机器上,包括那些不允许联网安装的服务器里。当你把一套构建脚本交给别人时,“零依赖”这件事的价值会立刻显现。
本章结束时,你手上应该有一个能跑的 /tmp/makelab/Makefile,以及一个明确的判断标准:凡是能写成“某文件由某文件生成”的任务,都可以试试 Make。
下一章把这条规则拆开看。目标是让你在看到任何一行 Makefile 时,能立刻回答三个问题:这是目标吗?它的依赖是什么?它的命令在什么条件下执行?这三个问题答得出来,复杂的 Makefile 就不再吓人了。
1.10 一份最小的心智模型:有向无环图
在继续之前,我需要给你一个比“一条判据”稍微完整一点的心智模型。这个模型只有一张图,但它能解释后面所有复杂现象。
把每个文件想成一个点,把每条依赖想成一条箭头。out/book.md 依赖 ch01.md 和 ch02.md,就画两个箭头从章节指向整稿。out/book.zip 依赖 out/book.md,再画一个箭头。这样叠起来,你会得到一张图:点是有向的,箭头永远指向“产物”。这张图有两个性质:
第一,它是有向的。 箭头只能从依赖指向目标,不能反过来。这个方向决定了执行顺序:要算出整稿,必先有章节;要算出压缩包,必先有整稿。
第二,它是无环的。 如果 A 依赖 B,B 依赖 C,C 又依赖 A,那就成了一个圈。Make 遇到圈会直接报错:
make: Circular out/a <- out/b dependency dropped.
这个报错值得留意。它多半并非真的写出了环,而是你无意中让两个目标互相依赖,比如两个目标共用同一个中间文件,又把中间文件声明成了彼此的前置条件。第 5 章会把这类情况归到“十类坑”里。
有了这张图,Make 的执行过程就可以用一句话描述:从你要的目标出发,沿着箭头逆行到底,然后反向依次执行。 这个过程有个正式名字叫“深度优先遍历”,但你不必记术语,只需记住它的行为后果:
- 它只访问与目标相关的部分。你的 Makefile 里可以有一百条规则,
make out/book.zip只会碰其中三条。 - 它保证顺序。依赖没做完,目标不会开始。
- 它天然去重。如果两个目标都依赖同一个文件,那个文件只会生成一次。
最后一条在并行时尤其重要。当你用 -j4 让 Make 同时跑四条支线时,它依然是按图调度,而不是按行调度。被人问起“Make 并行会不会把同一个文件重复编译”,你可以肯定地回答:如果依赖声明正确,不会。
反过来说,如果依赖声明不正确,并行会立刻把错误放大。串行时漏掉一个依赖,你只会得到一个过时的产物;并行时漏掉依赖,两个任务可能同时写同一个文件,得到一个损坏的产物。所以后面讲并行之前,必须先讲清楚依赖。
这张图另藏一层好处,它与本书的主线直接相关:因为图是声明出来的,不是写死的,所以你新增一个文件时,只需要让它出现在某个依赖位置,Make 会自动把它纳入调度。这正是“断点续跑”能成立的结构基础——重跑一次,图会自己算出哪条支路还没完成。
1.11 术语对照表
本章出现了七个术语,后面每一章都会反复用到。给出一张对照表,方便你随时回查。
| 术语 | 英文 | 一句话解释 | 在哪一章展开 |
|——-|——-|——-|——-|
| 目标 | target | 冒号左边的东西,通常是一个要被生成的文件 | 第 2 章 |
| 依赖 | prerequisite | 冒号右边的东西,目标存在的前提,也是重做的判据 | 第 2 章 |
| 命令 | recipe | 制表符开头的那一行,由 shell 执行 | 第 2 章 |
| 规则 | rule | 目标、依赖、命令三者合成的一条完整描述 | 第 2 章 |
| 判据 | timestamp rule | 目标不存在或依赖更新则执行,否则跳过 | 本章 |
| 产物 | artifact | 命令落在磁盘上的文件,是“做过”的唯一凭证 | 第 4 章 |
| 伪目标 | phony target | 不代表文件的名字,每次都执行 | 第 2 章 |
另有两个词本章没有正式定义,但你会立刻遇到,提前说一下:
Makefile。就是描述规则的文本文件,默认文件名是 Makefile(首字母大写),也可以是 makefile 或 GNUmakefile。Make 按这个顺序查找,所以 Makefile 优先级较高。
默认目标。Makefile 里写的第一条规则的目标,就是不带参数执行 make 时做的那件事。这条规则看起来琐碎,实际影响很大:如果第一个目标是 clean,你敲 make 就会把产物全删掉。所以惯例是在文件开头先把 all 写上,再写其他规则。
至此第 1 章结束。你现在拥有的是一张图、一条判据、三个符号,以及一个能跑的最小例子。下一章开始拆解这些符号。
1.12 十分钟实验:把三条命令改造成 Makefile
最后做一个完整的小实验,把“脚本思维”换成“规则思维”。整个过程十分钟,做完你会对判据有手感。
起点很普通。你需要把三个 Markdown 文件合成一份清单,统计总字数,然后打包:
mkdir -p out
cat notes/*.md > out/all.md
wc -c out/all.md > out/size.txt
zip -q out/notes.zip out/all.md out/size.txt
三条命令,跑一次不到一秒。现在问三个问题:如果只改了其中一个笔记,第二条和第三条需要重跑吗?如果被中断在第二条,重跑整个脚本会浪费什么?三个月后你还记得这三条命令的先后依赖吗?
改造成 Makefile,先写最小结构:
.PHONY: all
all: out/notes.zip out/size.txt
out/all.md: notes/a.md notes/b.md notes/c.md | out
cat $^ > $@
out/size.txt: out/all.md | out
wc -c $< > $@
out/notes.zip: out/all.md out/size.txt | out
cd out && zip -q -FS notes.zip all.md size.txt
out:
mkdir -p $@
对比一下收益。第一条规则里,$^ 替代了三个文件名的手写,以后加第四个笔记只改依赖列表一处。第二条规则里,$< 让命令不依赖路径写法。第三条规则里,cd out && 把两个动作放进同一个 shell,避免“每行独立 shell”的坑。
再跑两次 make。第一次全部生成,第二次全部 up to date。然后 touch notes/b.md,观察它只重做了 out/all.md、out/size.txt、out/notes.zip 三条,一条不多。
这个实验的价值不在于省了几次敲键盘,而在于你把“依赖关系”从脑子里搬到了文件里。搬到文件里的东西,三个月后还在;留在脑子里的,三天就没了。
1.13 读完本章的三条自查
一,你能不看笔记说出判据的完整句子吗?说不出来,就回到第一节末尾那段便签再抄一遍。
二,你能预测 touch 依赖 之后 make 会输出什么吗?不能预测,说明你还没把“时间戳”和“是否需要重做”这两件事连起来。
三,你手上有没有一件“重复敲了三次以上”的命令序列?有,明天就把它写成第一份 Makefile;没有,说明你需要先观察自己的工作习惯。
一个补充判断:如果你的任务是“每天定时跑一次,跑完把结果发出去”,它既有产物(结果文件)又有外部触发(定时器),那么正确分工是“定时器负责叫醒,Make 负责算差量”。别把定时逻辑写进 Makefile,那是两个不同职责的东西。
第2章 一条规则的解剖
第 2 章 一条规则的解剖
2.1 语法与三个角色
第 1 章那个三行的 Makefile,看起来朴素,其实是 Make 全部语法的缩影。我们把它放大到能看清每个字符的程度。
out/book.md: chapters/ch01.md chapters/ch02.md
@mkdir -p out
cat chapters/ch01.md chapters/ch02.md > $@
第一行是规则的“头”,由三部分组成:冒号左边是目标,冒号右边是依赖,用空白分隔。第二行和第三行是命令,每一行都必须以制表符开头。
三个角色各有明确职责,这个分工是理解 Make 的关键:
目标负责命名。 它是一个路径,告诉 Make“我要的这个东西叫什么”。Make 用它去磁盘上查:这个文件在不在?如果不在,这条规则必须执行。
依赖负责判断。 它是一个文件列表,告诉 Make“这个东西是从哪些东西来的”。Make 拿每个依赖的修改时间去和目标比,只要有任何一个比目标新,就认为目标过期了。
命令负责干活。 它是一串 shell 命令,数量不限。Make 只负责按顺序把它们喂给 shell,不检查它们做了什么。
请注意这三个职责之间的空白:命令没有任何约束,而判断完全依赖声明。 这条设计带来的后果需要反复强调:如果你在命令里读了一个文件,却没有把它写进依赖,Make 就永远不知道那个文件会影响结果。
为了说清这句话的分量,我们看一个真实的例子。这台沙箱里有一份书稿,八个章节加一个模板文件:
ls /book-projects/腾讯BuddyOne及AI整合策略/chapters/
假设你的规则写成这样:
out/book.md: chapters/ch01.md chapters/ch02.md chapters/ch03.md
而实际执行的是 cat chapters/ch*.md > $@,也就是读了八个文件。那么当你修改第八章时,Make 看了依赖列表,里面没有 ch08.md,于是判定“目标比所有依赖都新”,跳过不执行。你得到的是一个缺了最新修改的整稿,而且没有任何报错。
这类错误的可怕之处在于它是静默的。程序崩了你会知道,拼错了路径你会知道,但“少读了一个依赖”不会给你任何反馈,只会给你一个看起来正常、实际上过时的结果。第 3 章会用 $(wildcard) 把依赖列表改成自动推导,就是为了根治这个问题。
另外两个语法细节现在说清楚,后面不再重复。
注释用 #。 从 # 到行尾都是注释。注意命令里的 # 会被传给 shell,所以 @echo 123 # 注释 实际上会把 # 注释 一起传给 echo。这一点和 Shell 一致,不算坑,但经常有人误以为 Make 会先剥掉它。
空行和缩进不影响结构。 规则之间用空行分隔是惯例,不是语法要求。真正决定结构的是“有没有制表符开头”——制表符开头的行属于上一条规则,非制表符开头的行开启新内容。
2.2 亲手验证判据
判据只有一句话,但它有四种触发情形。把这四种情形都跑一遍,你就不会再对 Make 的行为感到意外。
准备实验环境:
rm -rf /tmp/makelab2 && mkdir -p /tmp/makelab2/chapters && cd /tmp/makelab2
printf '第一章\n' > chapters/ch01.md
cat > Makefile <<'EOF'
out/book.md: chapters/ch01.md
@mkdir -p out
@echo "--- 开始拼装 ---"
cat chapters/ch01.md > $@
@echo "--- 完成 ---"
EOF
这里用了 <<'EOF' 的写法,单引号很关键,它保证 $@ 原样写进文件而不被当前 shell 展开。这是新手写 Makefile 时另一个高频事故:以为文件里的变量没生效,其实是写文件时就被吃掉了。
情形一:目标不存在。
make
输出:
--- 开始拼装 ---
--- 完成 ---
执行了。因为 out/book.md 不存在。
情形二:目标比依赖新。
make
输出:
make: 'out/book.md' is up to date.
跳过了。因为刚才的 cat 已经把目标时间推到了最新。
情形三:依赖比目标新。
echo "新增一段" >> chapters/ch01.md
make
又执行了。因为 ch01.md 的时间戳刚刚被更新。
情形四:目标是目录,或者根本不存在但依赖也不存在。
先删依赖:
rm chapters/ch01.md
make
输出:
make: *** No rule to make target 'chapters/ch01.md', needed by 'out/book.md'. Stop.
报错了。这条报错信息值得逐字读懂:No rule to make target 说的是“没有规则能造出 chapters/ch01.md 这个文件”,而 needed by 'out/book.md' 说的是“它是谁需要的”。整句话的意思是:我需要 ch01.md,但磁盘上没有,你的 Makefile 里也没有告诉我它从哪来,所以我停在这里。
这条报错的诊断方法很固定:顺着 needed by 往上找,看是哪个依赖漏了文件,或者文件名拼错了。十次里有八次是拼写错误或大小写不一致。
四种情形跑完,再补一个重要的观察。你现在往 out/book.md 里手动追加一行内容,然后跑 make:
echo "手工改一下" >> out/book.md
make
它会跳过。因为修改目标文件只会让目标更新,不会让它过期。Make 不检查目标内容是否正确,它只看时间。
这个性质有时是便利,有时是陷阱。便利之处在于你可以手工修产物而不触发重建;陷阱在于你如果手工改坏了一个产物,make 永远不会帮你修回来,除非你删掉它或者 touch 一下依赖。第 5 章讲的 make -B 就是为了应付这种情况:无条件重做,不看时间。
2.3 自动变量全家
自动变量是 Make 最实用的一组语法。它们让你在命令里不必重复手写路径,从而把一条规则写成“模板”,而不是“实例”。
先看全表,再逐个实验。
| 变量 | 含义 | 举例(out/book.md: ch01.md ch02.md 时) |
|——-|——-|——-|
| $@ | 当前目标名 | out/book.md |
| $< | 第一个依赖 | ch01.md |
| $^ | 全部依赖,去重 | ch01.md ch02.md |
| $+ | 全部依赖,不去重 | ch01.md ch02.md(有重复时保留) |
| $? | 比目标新的依赖 | 只有被改过的那些 |
| $* | 模式规则中 % 匹配到的部分 | 模式为 %.o 时,$* 是文件名去掉后缀 |
| $| | 顺序依赖列表 | 竖线右边那部分,用于目录 |
$@ 的用法。 它是出现频率最高的一个。核心价值是“一条规则可以在不同目标上复用”:
out/a.md: chapters/a.src
cp $< $@
out/b.md: chapters/b.src
cp $< $@
两条规则的命令完全一样,只是目标不同。这意味着你可以把它们合并成一条模式规则(第 3 章),也意味着以后改路径时只需改一处。
$< 的用法。 当一条规则只有一个依赖时,$< 就是它的名字。多依赖时,$< 只取第一个。这个设计有个容易误解的地方:很多人以为 $< 是“最新那个依赖”,其实不是,它就是第一个。要拿最新的那些,用 $?。
$^ 的用法。 它是“全部依赖,自动去重”。这个去重很关键:如果你的规则写成 out/x: a a b,$^ 展开是 a b 而不是 a a b。去重的好处是命令行干净,坏处是如果你真的需要重复参数(比如链接顺序敏感的库),得改用 $+。
$? 的用法。 这个变量在增量场景里很有用。它只列出“比目标新”的依赖,也就是“这次真正需要处理的那几个”。举例:
out/log.txt: chapters/ch01.md chapters/ch02.md chapters/ch03.md
@echo "本次变化: $?" >> $@
第一次跑,$? 是三个文件;改一个章节再跑,$? 只有那一个。用它可以做很省事的“变更记录”。
$* 的用法。 它只在模式规则里有意义,代表 % 匹配到的那部分。写 out/%.html: src/%.md 时,目标是 out/a.html,$* 就是 a。这个变量是批量生成的核心,第 3 章会反复用到。
$| 的用法。 它对应“顺序依赖”,也就是依赖列表里竖线右边那部分。这个概念下一节专门讲。
一个注意事项:$@ 这类单字符变量在某些写法下会与 shell 变量混淆。规则是——在 Makefile 里,$x 总是 Make 的变量;要传给 shell,必须写 $$x。所以 echo $$HOME 才是 shell 的 $HOME,而 echo $HOME 会被 Make 解释成“变量 H 加字符串 OME”,结果通常是 OME。
这个坑在写复杂命令时几乎必然踩一次。记住补偿规律:凡是 shell 变量、shell 位置参数、awk 的 $1,一律加双美元。
2.4 伪目标与 .PHONY
现在轮到 Make 最常被误用的一条特性。
先看一个事故。你在 Makefile 里写:
clean:
rm -rf out
这天你手滑,在项目根目录建了一个叫 clean 的空文件:
touch clean
make clean
输出是:make: 'clean' is up to date.
清理没有执行,因为 Make 查了一下磁盘,发现有个叫 clean 的文件,时间比“依赖”(空的)新,于是判定目标已就绪。
这就是伪目标要解决的问题。伪目标的含义是:这个名字不对应任何文件,每次都必须执行。 写法是声明:
.PHONY: clean all help
clean:
rm -rf out
all: out/book.md
help:
@echo "make all 生成整稿"
@echo "make clean 清理产物"
加上 .PHONY 之后,touch clean 也拦不住它了,因为 Make 根本不去看磁盘。
什么时候必须用 .PHONY? 判断标准很简单:问自己“这个目标名有可能被一个真实文件占用吗”?如果可能,就声明。以下几类几乎总是需要:
clean、distclean、reset这类清理目标all、build、test、install这类动作目标help、usage、list这类输出目标
什么时候不该用 .PHONY? 当目标确实是文件时。有个经典反例:
.PHONY: out/book.md
out/book.md: chapters/ch01.md
cat $< > $@
这样写等于放弃了增量:每次 make 都重做,因为伪目标永远“过期”。如果你的构建要跑十分钟,这一行 .PHONY 会让你每天浪费几小时。
所以 .PHONY 不是“加了更安全”,而是“加了就放弃增量”。第 5 章会把“.PHONY 滥用”列进十类坑,理由就是这个。
另有一个进阶写法,很多人不知道:.PHONY 声明的目标,即使你不小心建了同名文件也不会出错;反过来,如果你声明了一个真正的文件目标为伪目标,Make 会认为它“永远需要重建”,并且不会把它当作其他目标的依赖来追踪时间。这类问题极难排查,所以遇到“每次都要重跑”的怪象时,第一件事就是检查 .PHONY。
2.5 顺序依赖与默认目标
有一个怪现象,几乎所有用 Make 组织复杂流程的人都遇到过:明明产物没变,make 却每次都重跑。
原因十有八九是目录。看这个写法:
out/book.md: chapters/ch01.md out
cat chapters/ch01.md > $@
这里把 out(目录)写成了依赖。Make 的处理方式是:检查目录是否存在,不存在就找规则创建。但问题在于,目录的修改时间会在里面任何文件被创建、删除、改名时更新。于是你写了 out/book.md,目录 out 的时间就变成了现在——比目标新——下次 make 就认为目标过期,再重做一遍。
正确的写法是“顺序依赖”,用竖线分隔:
out/book.md: chapters/ch01.md | out
cat chapters/ch01.md > $@
out:
mkdir -p $@
竖线左边是“内容依赖”,参与时间戳比较;竖线右边是“顺序依赖”,只保证“在命令执行前它已经存在”,不参与时间戳比较。
这个区别看起来很小,效果很大。用了顺序依赖之后,目录时间再变也不会触发重做。
顺带说一个相关技巧。如果目标目录很深,而你又不想为每层目录都写规则,可以用这条通用规则:
%:
mkdir -p $@
它的含义是“任何目标,如果 Make 不知道怎么做,就建一个同名目录”。危险之处在于它会吞掉所有“目标不存在且没有规则”的错误,让打错的路径静默变成目录。用之前想清楚,或者限定成 %/ 的形式。
默认目标。 Make 有个不成文的约定:如果 Makefile 里第一条规则的目标不是以点开头的名字,那么它就是默认目标,也就是不带参数跑 make 时执行的那个。这条规则带来两个实践建议。
第一,永远在文件顶部先写 all:
.PHONY: all
all: out/book.md out/book.zip
否则哪天你把 clean 挪到了第一行,make 就会删掉所有产物。
第二,如果确实想让别的东西当默认目标,用显式声明,别靠顺序:
.DEFAULT_GOAL := all
这条声明比“第一条规则”更可靠,因为它不受文件重排影响,也不怕别人在前面插规则。第 5 章讲的工程化规范里,这一条是必列的。
2.6 命令行的细节
命令部分是 Make 与 shell 的交界处,事故最集中。这里把四个必知细节讲透。
细节一:缩进必须是制表符。 第 1 章讲过,这里补一个诊断技巧。当报错是 missing separator 但你确信用了制表符时,检查有没有混入全角空格,或者行首是“制表符 + 空格”。cat -A 是唯一可靠的检查手段,^I 是制表符,^I 是制表符加空格。
细节二:每一行是独立的 shell。
这一条最反直觉,也是最容易出错的地方。看这段:
install:
cd out
zip -q book.zip book.md
你期待的行为是“进入 out 目录,然后打包”。实际行为是:第一行在子 shell 里 cd 到 out,子 shell 退出,cd 的效果消失;第二行在新的子 shell 里执行,当前目录还是项目根目录,于是找不到 book.md。
修复有两种,取决于你想表达的意图:
# 方案一:写成一行
install:
cd out && zip -q book.zip book.md
# 方案二(4.0+):整段用一个 shell
.ONESHELL:
install:
cd out
zip -q book.zip book.md
.ONESHELL 会让整条 recipe 交给一个 shell 执行,cd 因此能跨行生效。它很方便,但有两个副作用要知道:一是要求 GNU Make 4.0 以上;二是命令里的 @ 和 - 前缀只对整段的第一行生效,后面的行不能单独加前缀。所以在团队里混用 .ONESHELL 和不混用的 Makefile,是一个隐蔽的维护负担。
细节三:三个前缀符号。
@ 抑制回显,- 忽略错误,+ 强制执行(即使在 make -n 干跑模式下也执行)。
@ 的用法很直接,适合 mkdir、echo 这类噪音命令。但要小心:过度使用 @ 会让构建过程变成黑箱,出问题时你连它执行了什么都不知道。惯例是——成功的路径安静,失败的信息必须能看见。
- 用于“失败了也没关系”的命令,典型例子是清理:
clean:
-rm -rf out
这样即使 out 不存在、rm 报错退出,整个 clean 也不会中断。这个技巧在“幂等清理”里几乎是必需的。
+ 用得少,主要场景是“在干跑模式下也要真正执行的命令”,比如递归调用子 make(+$(MAKE) -C sub)。
细节四:错误会让整条规则停下。
Make 执行 recipe 的方式是逐行调用 shell,并检查每行退出码。只要某一行返回非零,Make 立刻停止这条规则,并报 Error 1。后续行不会执行。
这个行为带来一个重要推论:多步操作没有原子性。如果第一步成功了、第二步失败了,第一步的产物会留在磁盘上。而 Make 下次跑的时候,如果产物存在且比依赖新,它会认为“已就绪”,跳过——哪怕这个产物其实是半成品。
第 4 章讲的 .DELETE_ON_ERROR 就是为了解决这个问题:只要规则失败,自动删掉目标文件,让下次必定重做。写到这里先记住现象:失败留下半成品,是 Make 的默认行为,不是 bug。
2.7 第一个完整案例:书稿拼装
现在把本章所有零件组装起来,做一个真正能用的例子。目标是把八个章节合成一本整稿,再打成一个压缩包,同时带上清理与帮助。
目录假设在 /book-projects/腾讯BuddyOne及AI整合策略,内容如下:
chapters/ch01_BuddyOne发布与定位.md
chapters/ch02_统一账号体系.md
chapters/ch03_WorkBuddyAgent底座.md
chapters/ch04_跨应用打通.md
chapters/ch05_元宝搜索与实时数据.md
chapters/ch06_企业级Agent生产力系统.md
chapters/ch07_生态开放与合作伙伴.md
chapters/ch08_未来展望与挑战.md
第一版,手写依赖:
.PHONY: all clean help
.DEFAULT_GOAL := all
OUT := out
all: $(OUT)/book.zip
$(OUT)/book.md: chapters/ch01_BuddyOne发布与定位.md \
chapters/ch02_统一账号体系.md \
chapters/ch03_WorkBuddyAgent底座.md \
chapters/ch04_跨应用打通.md \
chapters/ch05_元宝搜索与实时数据.md \
chapters/ch06_企业级Agent生产力系统.md \
chapters/ch07_生态开放与合作伙伴.md \
chapters/ch08_未来展望与挑战.md | $(OUT)
@echo "拼装 $@"
@for f in $^; do cat "$$f"; printf '\n\n'; done > $@
$(OUT)/book.zip: $(OUT)/book.md | $(OUT)
@echo "打包 $@"
@cd $(OUT) && zip -q -FS book.zip book.md
$(OUT):
@mkdir -p $@
clean:
@rm -rf $(OUT)
@echo "已清理"
help:
@echo "make 生成 out/book.zip"
@echo "make clean 清理产物"
几个值得注意的地方。
\ 续行让一条长依赖列表变得可读。注意续行符后面不能有空格,否则会带进一个空格,变成空依赖项。
| $(OUT) 是顺序依赖,解决前面说的目录时间戳问题。
for f in $^ 里,$^ 会被 Make 展开成八个文件名的列表,然后整体交给 shell。'$$f' 的双美元保证 shell 看到的是 $f。这里必须加引号,因为你的文件名里有中文和特殊字符的风险。
@cd $(OUT) && zip -q -FS book.zip book.md 用 && 把 cd 和 zip 放到同一个 shell 里,避免“每行独立 shell”的问题。-FS 让 zip 同步删除包内多余的旧条目,否则每次打包都会累积过期文件。
跑起来:
make # 生成 out/book.zip
make # 第二次,应该全部 up to date
touch chapters/ch03_WorkBuddyAgent底座.md
make # 只重做 book.md 和 book.zip
make clean
第二遍的全量跳过,是这个 Makefile 可以接受的证据。如果它每次都重做,按前面讲的排查顺序查三件事:out 是不是被写成了普通依赖(应该用 |)、有没有误加 .PHONY、目标路径和命令里写的路径是否一致。
这一版有个明显的维护问题:八个文件名手写了八行,新增第九章要改两处(依赖列表和 all)。这个问题正是第 3 章要解决的——用 $(wildcard) 让依赖自动推导,做到“新增文件零改动”。
2.8 多目标、分组目标与规则的合并
前面所有例子都是一条规则一个目标。但 Makefile 里存在三种“一条规则管多个目标”的写法,它们的行为差别很大,混淆它们会产生很难查的问题。
写法一:多目标规则。
out/a.md out/b.md: chapters/src.md
cp $< $@
这条规则声明了两个目标,共享同一份依赖和同一条命令。Make 的处理方式是:把它当成两条独立的规则。执行 make out/a.md 时 $@ 是 out/a.md,执行 make out/b.md 时 $@ 是 out/b.md。
关键在于“独立”这两个字。加上 -j 之后,这两条规则会并行执行,也就是同一条命令会同时跑两遍(各带不同的 $@)。这在“一次命令生成多个文件”的场景里是致命的:两个进程同时写同一个中间状态,结果不可预测。
所以多目标规则的适用范围严格限定在:每条执行都能独立完成,且命令里只用 $@ 区分输出。前面那个 cp $< $@ 符合,但“跑一次脚本生成 a 和 b”不符合,后者应该用具名依赖加一条普通规则。
写法二:分组目标,&:。
GNU Make 4.3 引入了分组目标,专门解决上面那个问题:
out/a.md out/b.md &: chapters/src.md
python3 tools/split.py chapters/src.md out/
&: 的语义是“这条规则执行一次,同时产出所有目标”。Make 调度时只跑一遍,跑完之后认为 a 和 b 都就绪。这就是“一次命令产出多文件”的正确表达。
它带来一个新的判断细节:如果只删掉 b.md 保留 a.md,Make 会认为整组都需要重做,因为没有 a 就没法只补 b。这个行为是刻意的,也是安全的。
分组目标需要 4.3 以上。检查版本:
make --version | head -1
这台沙箱是 4.3,可以直接用。如果你的目标环境有更老的版本,退回“用 stamp 标记整组完成”的写法:
.stamps/split.done: chapters/src.md | .stamps
python3 tools/split.py chapters/src.md out/
@touch $@
写法三:静态模式规则。
$(OUTS): out/%.md: chapters/%.md | out
cp $< $@
它的作用是“对 $(OUTS) 里的每个文件,套用后面这个模式”。相比纯模式规则,它多了一层显式限定:只有列表里的文件才会被这条规则处理。
什么时候需要它?两种场景。一是纯模式规则被别的规则抢走了匹配(Make 按“非 % 部分最长”选规则),加上目标列表能锁死归属。二是你想让模式规则只作用于一个子集,比如只处理 ch01 到 ch08,不处理测试用的 ch99。
三种写法的选择标准可以归纳成一张表:
| 你的情况 | 用哪种 |
|——-|——-|
| 每个目标各跑一次命令,命令只靠 $@ 区分 | 多目标规则 |
| 一次命令产出多个文件 | 分组目标 &:(4.3+)或 stamp |
| 只想让规则作用于某批文件 | 静态模式规则 |
| 一类文件都适用,无例外 | 纯模式规则 |
最后提醒一个细节:多目标规则和分组目标都要求“冒号前的目标名不能包含变量展开后的空格”,否则会被拆成更多目标。中文路径里如果出现空格(很少见但可能),先重命名。
2.9 规则的优先级与“为什么它用了别的规则”
新手排查时最困惑的现象之一,是自己写的规则没有生效,但命令确实执行了。这种情况九成是“规则被抢走了”。
Make 选规则的过程有两层。第一层是显式规则优先:只要你的 Makefile 里有一条规则的目标完全匹配,Make 就用它,不看别的。第二层是隐含规则和模式规则:显式规则不存在时,Make 会尝试模式规则,再尝试内置的隐含规则。
内置规则数量很多,常见的有“从 .c 生成 .o”、“从 .p 生成 .c”、“用 tar 打包”等等。你不写 C 也可能被它们撞上。比如你有一个叫 out.tar 的目标,Make 会自动认为它可以从某个目录打包生成,于是执行一堆你没写过的命令。
诊断方法是这条命令:
make -p 2>/dev/null | grep -A3 "out.tar"
它会告诉你 Make 到底为这个目标准备了哪些规则,包括内置的。看到来源不是你的 Makefile,就知道被谁抢了。
修复有两种。第一种是明确声明你会自己生成它,把内置规则关掉:
out.tar: chapters/index.md
@tar -cf $@ -C chapters .
有了显式规则,内置规则就不再参与。第二种是全局关掉内置规则:
MAKEFLAGS += -r
-r 关掉所有内置规则(不含后缀规则),-R 连后缀规则也关。在纯任务编排(不编译 C)的 Makefile 里,我建议无条件加 -r:既省解析时间,又避免撞上不相干的内置规则。
这条建议背后有一个更一般的判断:Make 诞生于编译 C 的语境,它的默认值对编译友好,对通用任务编排则偏“过度热情”。做任务编排时,主动关掉那些用不上的默认行为,能让排查成本显著下降。
2.10 多行命令:三种可读性写法
真实项目里,一条命令常常很长。写法有三种,各有代价,值得对照着选用。
写法一:反斜杠续行。
out/report.pdf: out/report.md | out
pandoc $< -o $@ \
--toc \
--pdf-engine=xelatex \
--metadata title='年度报告'
\ 让逻辑上的一行在视觉上分成多行。它仍然是一条 shell 命令,所以不会遇到“每行独立 shell”的问题。
两个细节要注意。第一,\ 后面不能有空格,否则空格会进入命令,变成多余参数。第二,缩进只是给人看的,shell 会把它当空白处理,所以对齐不要用制表符跟制表符混排。
写法二:把复杂逻辑挪进脚本。
out/report.pdf: out/report.md | out
@tools/make_pdf.sh '$<' '$@'
当命令超过五行,或者中间有 if、循环、临时变量时,挪进脚本几乎总是更好的选择。原因有三个:脚本能单独测试、能被 shellcheck 检查、能被别人复用。Makefile 此时退回它最擅长的角色——判据与调度。
这是一个很重要的分工原则:Make 管“什么时候做”,脚本管“怎么做”。 把两者混在一起,会得到一堆既难调试又难复用的长 recipe。
写法三:define 块。
define PUBLISH_CMD
set -e
for f in $(1); do
echo "上传 $$f"
curl -s -u "$$AUTH" -X POST https://example.com/upload -F "file=@$$f"
done
endef
publish: out/notes.zip
@$(call PUBLISH_CMD,out/notes.zip)
define 定义一个多行变量,用 $(call) 取用,$(1) 是第一个参数。它的好处是可以复用一段多行命令。代价是可读性下降——读者要跳两处才能看懂一条 recipe。
我的经验是:define 只在同一段多行命令确实出现三次以上时才值得用。 少于三次,直接复制粘贴更清楚,因为复制粘贴的错误一眼能看出来,而 $(call) 传参错误往往要跑一遍才知道。
三种写法的取舍可以归纳成一句话:一行能写完就写一行;写不完就挪进脚本;只有在同一段逻辑被反复使用时才考虑 define。
2.11 一个常见误解:依赖必须是文件吗
不是。依赖可以是一个伪目标名,也可以是任何 Make 认识的名字。但一旦依赖不是真实文件,判据就失效了。
看这个写法:
all: build test
build:
$(CC) -o bin/app src/*.c
test: build
./bin/app --test
build 和 test 都不是文件,所以 Make 每次都认为它们“需要重建”。这意味着 make all 每次都编译一遍、测一遍,无论有没有改动。
小项目里这无所谓,几秒钟的事。但它埋了两个隐患:一是项目变大后时间成本急剧上升;二是你失去了“跳过”这个信号,无法从输出里判断“这次改动影响了哪些部分”。
正确的做法是把真实的产物写成目标:
all: bin/app
bin/app: $(wildcard src/*.c) | bin
$(CC) -o $@.tmp $^ && mv $@.tmp $@
test: bin/app
./$< --test
现在 bin/app 有对应文件,判据生效。build 这个“动作目标”被彻底取消——它本来就不该存在,因为它的实体就是那个二进制文件。
这条规律可以推广:凡是你想写一个“动作目标”,先问它有没有产物。有,就把产物当目标;没有,再考虑 stamp 或 .PHONY。 大多数“动作目标”其实是“产物目标”的懒惰写法,改成后者就能拿回增量能力。
关于 $? 另有一个实用变体。当你只想对“这次真正变化的依赖”做处理时,它比 $^ 更准确。例如给变更的章节单独记一条日志:
out/changes.log: $(SRCS) | out
@date '+%F %T' >> $@
@printf '变更: %s\n' '$?' >> $@
第一次跑列出全部,之后每次只列出被改动的那几个。用它可以低成本地获得一份“构建变更史”,排查“这版为什么不一样”时非常有用。
写规则时有一个自查习惯很有效:把命令里的每个文件名圈出来,逐个问“它在依赖列表里吗”。凡是不在的,要么补进依赖,要么确认它确实是一次性只读的常量(比如 tools/convert.py 这种不会变的脚本,也可以补进依赖,让它改了就重做)。这个动作一分钟,能挡掉绝大多数静默过时。
最后记住一句话:规则是声明,不是脚本。声明写对了,执行方式由工具决定;声明写歪了,再聪明的脚本也救不回来。
第3章 变量、模式规则与函数
第 3 章 变量、模式规则与函数
3.1 为什么要变量
第 2 章末尾那个书稿 Makefile 有一个明显的毛病:八个文件名写了两遍。新增第九章,你要改 all 的依赖列表,还要改拼装规则的依赖列表。改漏一处,产物就是错的,而且不报错。
重复是维护成本的源头。Make 提供的第一个武器是变量。
CHDIR := chapters
OUTDIR := out
$(OUTDIR)/book.md: $(CHDIR)/ch01.md $(CHDIR)/ch02.md
cat $^ > $@
现在要把输出目录从 out 改成 build,只需要改一行。这就是变量最朴素的价值:一个事实只写一次。
但 Make 的变量有一个让新手困惑的特性:它有四种赋值符号,行为各不相同。选错了,你会遇到“变量值怎么和我写的不一样”的诡异现象。这一节把四种符号讲透。
= 递归展开。 赋值时不求值,等用到的时候才展开:
A = $(B)
B = 后定义
test:
@echo $(A)
输出是“后定义”。因为 A 存的是字符串 $(B),直到 echo 那一刻才去查 B 的值。
:= 立即展开。 赋值时立刻求值:
A := $(B)
B := 后定义
test:
@echo $(A)
输出是空。因为赋 A 的时候 B 还没定义。
这两者的差别在多数简单 Makefile 里看不出来,但在一个场景里会致命:变量的值依赖 shell 命令结果时。
NOW = $(shell date +%s)
用 =,每次引用都会真的执行一次 date,于是同一个构建里两处引用的时间戳可能不同。用 :=,只在定义时执行一次,全构建共享一个值。做时间戳、版本号、当前目录这类时必须用 :=。
我的建议很直接:默认用 :=,只有在明确需要延迟求值时才用 =。 这条建议能避开绝大多数展开顺序的坑。
?= 条件赋值。 如果变量还没定义才赋值:
JOBS ?= 4
这行的用处是给外部覆盖留口子。用户执行 make JOBS=8 时,命令行传入的值优先,?= 不生效,JOBS 就是 8。没传时用默认 4。这是写“可配置的默认值”的标准做法。
+= 追加。
CFLAGS := -Wall
CFLAGS += -g
结果 -Wall -g。要注意一个细节:如果原变量是 := 定义的,+= 追加的内容会立即展开;如果是 = 定义的,追加的内容会延迟展开。这条规则不直观,所以实践上建议统一用 := 起步。
override 强制覆盖。 有些场景下,命令行传进来的值你不希望被覆盖,比如内部强制要求的编译选项:
override CFLAGS += -fno-omit-frame-pointer
这一行保证即使有人执行 make CFLAGS=-O2,你的选项依然生效。
export 传给子进程。 默认情况下,Make 变量不会进入 shell 的环境变量。如果命令里要读环境变量,必须导出:
export BOOK_TITLE := Make 极简入门
这样 shell 里就能用 $BOOK_TITLE。反过来,如果只是想在 Makefile 内部用,不必导出,免得污染子进程环境。
理解了这六种写法,再看一眼全局约定:变量名习惯全大写,这不是语法要求,而是为了和自动变量、函数名区分开。$(CURDIR) 是内建的,$(chdir) 是你自己写的,大写能让读者一眼分清。
3.2 内建变量与约定
Make 有一批内建变量,不声明也能用。最值得知道的六个:
| 变量 | 含义 | 典型值 |
|——-|——-|——-|
| CURDIR | 当前工作目录 | /book-projects/Make入门实战 |
| MAKE | make 程序自身的路径 | /usr/bin/make |
| MAKEFLAGS | 当前生效的命令行选项 | -j4 |
| MAKECMDGOALS | 用户指定的目标列表 | all 或空 |
| SHELL | 执行 recipe 的 shell | /bin/sh |
| CC、CFLAGS 等 | C 语言编译约定 | cc、空 |
CURDIR 特别有用,因为它让你可以在任意目录下执行 make -f /path/to/Makefile,而规则里的相对路径依然正确:
ROOT := $(CURDIR)
OUT := $(ROOT)/out
MAKEFLAGS 的价值在于“把选项传给子 make”。如果你在 Makefile 里递归调用自己($(MAKE) -C sub),选项会自动继承,不用手写一遍。这一点第 5 章讲递归时再展开。
SHELL 默认是 /bin/sh,不是 bash。这意味着你的 recipe 里不能用 [[ ]]、不能用数组、echo -e 的行为也可能不同。要换成 bash 必须显式声明:
SHELL := /bin/bash
但请注意,一旦改了 SHELL,Make 的部分内建行为也会跟着变(比如错误检测方式),所以更稳的做法是把 recipe 写成 POSIX sh 兼容的形式,而不是换 shell。这本书的例子全部按 sh 兼容写。
CC、CFLAGS、LDFLAGS 这些是给 C 语言用的隐含变量,Make 内置了“从 .c 生成 .o”的隐含规则,靠的就是它们。你不写 C 也可以无视,但要知道它们存在——因为如果你不小心定义了一个叫 CFLAGS 的变量,而某条隐含规则恰好在用,你会得到一个莫名其妙的编译选项注入。
3.3 模式规则与静态模式
变量解决“同一个值写多次”,模式规则解决“同一类规则写多次”。
先看重复:
out/a.html: src/a.md
pandoc $< -o $@
out/b.html: src/b.md
pandoc $< -o $@
两条规则的命令一模一样,只有文件名不同。模式规则把它们合成一条:
out/%.html: src/%.md
@mkdir -p $(dir $@)
pandoc $< -o $@
% 是通配,它匹配任意字符串;$* 就是匹配到的那部分。当目标是 out/a.html 时,% 匹配 a,$* 是 a,$< 是 src/a.md。
写模式规则有三个必须遵守的细节:
第一,% 在目标和依赖里必须成对出现。 如果只有目标里有 %,Make 无法推导出依赖名,规则不会生效——不是报错,是静默不匹配。这个失败模式很坑,所以遇到“模式规则不触发”时,先检查两侧的 % 位置是否对齐。
第二,$* 是“词干”,不是你写的那个文件名。 目标是 out/sub/a.html、模式是 out/%.html 时,$* 是 sub/a,不是 a。这意味着你可以在依赖里写 src/%.md 来构造任意深度路径。
第三,模式规则的匹配是有优先级的。 Make 会先找“最具体”的规则(模式中非 % 部分更长的),再找内置隐含规则。这个优先级机制在复杂项目里会导致“我以为它用了我的规则,其实用了内置的”——诊断手段是 make -d,第 5 章讲。
还有一类变体叫静态模式规则,它把“哪些文件适用这条规则”显式列出来:
OBJS := out/a.o out/b.o
$(OBJS): out/%.o: src/%.c
$(CC) -c $< -o $@
语法是“目标列表: 目标模式: 依赖模式”。它比纯模式规则多了一句“只对这些文件生效”,因此在排查时更清晰。当你发现纯模式规则“管得太宽”或者被别的规则抢走时,改用静态模式往往能立刻定位问题。
3.4 九个够用的函数
Make 的函数很多,但日常只需要九个。它们的共同点是:用 $(函数名 参数...) 的形式调用,参数用逗号分隔,函数名和参数之间有空格。
wildcard——列目录。
SRCS := $(wildcard chapters/*.md)
它返回匹配的文件名列表,用空格分隔。有两点需要注意:第一,它只展开已存在的文件,不像 shell 的 * 那样在命令执行时才展开;第二,如果没有任何匹配,它返回空字符串,不报错。第二条是后面“新增文件零改动”能成立的原因。
patsubst——批量改名。
OUTS := $(patsubst chapters/%.md, out/%.html, $(SRCS))
把每个章节路径换成对应的输出路径。这个函数是“源文件列表到产物列表”的通用转换器。
subst——字符串替换。
CLEAN := $(subst /,_,$(SRCS))
把路径里的斜杠换成下划线,适合生成“扁平化的名字”。
notdir 与 basename——取文件名、去后缀。
NAMES := $(basename $(notdir $(SRCS)))
这一行把 chapters/ch01_标题.md 变成 ch01_标题,常用于生成清单或日志标签。
addprefix / addsuffix——批量加前后缀。
STAMPS := $(addprefix .stamps/,$(addsuffix .done,$(NAMES)))
这是 stamp 模式的基础写法,第 4 章会用到。
foreach——循环。
$(foreach f,$(SRCS),$(info 处理 $(f)))
它的行为是“对列表每一项,展开一次表达式”。注意它返回的是展开结果的拼接,不执行命令。要真的执行,得配合 $(eval ...) 或写进 recipe。
shell——调外部命令。
GIT_REV := $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
它把命令的 stdout 当作变量值,行尾换行会被去掉。性能提醒:$(shell) 在变量展开时执行,如果放在递归变量里被多次引用,就会重复执行。所以它几乎总是配 := 使用。
if——条件选择。
LOADER := $(if $(wildcard $(OUT)/book.md),已有,缺失)
语法是 $(if 条件,真值,假值)。条件是空字符串时为假。它只能做表达式级选择,不能控制“这一行要不要执行”。要控制结构,得用下一节的 ifeq。
dir / filter / sort / words——常用小工具。
DIRS := $(sort $(dir $(SRCS))) # 去重排序的目录列表
MDS := $(filter %.md,$(FILES)) # 只留 .md
N := $(words $(SRCS)) # 计数
这四个函数加起来解决的问题是“我手里有一个列表,想按条件挑、去重、计数”。它们是写“自动发现文件”的逻辑基础。
把这九个函数放在一起看,你会发现它们有一个共同的哲学:Make 的函数都是纯函数,输入列表输出列表,不做副作用。 唯一例外是 shell,它真的执行命令。理解了这一点,你就不会指望用函数去“做事情”——做事情是 recipe 的职责。
3.5 include、条件与 vpath
include——拆分文件。
Makefile 变长之后,把变量定义、公共规则拆到单独文件是必要的:
include config.mk
include rules/common.mk
include 的语义是“把这个文件的内容原样插进来”。如果文件不存在,会报错;如果允许缺失,用 -include(连字符前缀),常见于“可选的本地覆盖”:
-include local.mk
这个写法的价值在于:团队成员可以放一个 local.mk 改本机路径,而它不会进版本库,也不会因为别人没这个文件而报错。
条件判断——按平台或环境分支。
ifeq ($(shell uname -s),Darwin)
SED := gsed
else
SED := sed
endif
ifeq / ifneq / ifdef / ifndef 四种形式,ifeq 最常用。缩进是自由的,但为了可读性建议统一。
一个实践建议:条件判断越少越好。 每加一个分支,你的 Makefile 就多了一条“只在某种环境下才走到”的路径,而这条路径往往没人测。如果分支超过两三个,优先考虑把它拆到不同的 Makefile 文件里,由外部选择。
vpath——在别的目录找源文件。
默认情况下,依赖必须写相对 Makefile 的路径。如果你想让 Make 自动去几个目录里找 %.md,可以:
vpath %.md src:docs
这样规则里写 %.md 时,Make 会先找当前目录,再找 src,再找 docs。
vpath 很强大,也很容易让路径变得不可追踪。我的建议是:小项目别用,大项目用 VPATH 变量而不是 vpath 指令,因为前者只影响搜索路径,不改变模式匹配的语义,排查时更容易理解。
3.6 案例升级:新增一章只改零行
现在把第 2 章的书稿案例改造成自动发现版本。
.PHONY: all clean help
.DEFAULT_GOAL := all
ROOT := $(CURDIR)
CHDIR := $(ROOT)/chapters
OUTDIR := $(ROOT)/out
SRCS := $(sort $(wildcard $(CHDIR)/*.md))
OUTS := $(patsubst $(CHDIR)/%.md,$(OUTDIR)/%.html,$(SRCS))
BOOK := $(OUTDIR)/book.md
ZIP := $(OUTDIR)/book.zip
all: $(ZIP)
$(BOOK): $(SRCS) | $(OUTDIR)
@echo "拼装 $(words $(SRCS)) 章 -> $@"
@for f in $^; do cat "$$f"; printf '\n\n'; done > $@
$(ZIP): $(BOOK) | $(OUTDIR)
@cd $(OUTDIR) && zip -q -FS book.zip book.md
$(OUTDIR):
@mkdir -p $@
clean:
@rm -rf $(OUTDIR)
help:
@echo "make 拼装并打包"
@echo "make clean 清理 out/"
关键变化只有三处:SRCS 用 $(wildcard) 自动列目录,sort 保证顺序稳定(否则不同文件系统的目录顺序会让产物内容不一致),依赖里用 $(SRCS) 而不是手写八行。
现在做实验验证。往 chapters/ 里丢一个新文件:
printf '# 第九章\n新增内容\n' > chapters/ch09_新章节.md
make
输出会显示“拼装 9 章”。你没有改 Makefile 一个字符。
再删掉它:
rm chapters/ch09_新章节.md
make
Make 会重新拼装,因为依赖列表变了——虽然时间戳没变,但依赖集合本身变了,Make 会认为目标需要重建。这一点和“文件内容变化”不同,值得亲手验证一次。
sort 那个细节值得多说一句。如果不加 sort,依赖顺序取决于 wildcard 返回的顺序,也就是文件系统枚举顺序,这在大多数环境里是稳定的,但不保证。一旦顺序变了,cat 出来的整稿章节顺序就会乱。加一个 sort 的成本极低,收益是产物可复现。
这一节的改造解决了一个真实的维护痛点,但它引入了一个新的隐忧:$(wildcard) 在 Makefile 解析时求值,如果构建过程中有别的步骤创建了新的章节文件,这一次构建是看不见的。这个“早求值”特性在复杂流水线里会造成“第一次跑漏了、第二次跑才对”的怪现象。判断方法很简单:如果某次构建的结果需要再跑一次才正确,那就是求值时机问题。
本章结束时,你手里的 Makefile 已经具备三个特征:改一个变量换目录、改一个模式加一类文件、新增一个源文件零改动。下一章要把这些能力用在更硬的地方:被中断之后如何只补做剩下的部分。
3.7 变量的作用域与环境传递
变量除了“怎么赋值”,还有“从哪来、能不能被覆盖、能不能传给子进程”三个问题。这三个问题在团队协作时会集中爆发。
优先级顺序。 同一个变量可能有四个来源,优先级从高到低是:命令行传入、Makefile 里的 override、Makefile 里的普通赋值、环境变量。
JOBS=8 make # 环境变量,优先级最低
make JOBS=8 # 命令行变量,优先级最高
这两行的差别很实际。JOBS=8 make 时,如果 Makefile 里写了 JOBS := 4,实际生效的是 4;而 make JOBS=8 时生效的是 8。所以“给用户的配置入口”应该设计成命令行参数,而不是环境变量。
环境变量如何进来。 默认情况下,Make 会把它启动时已有的环境变量导入为 Make 变量。这意味着外部环境能悄悄改变构建结果,是一个隐蔽的风险源。
有个选项可以关掉这个行为:
MAKEFLAGS += -e
-e 让环境变量优先于 Makefile 里的赋值——注意是反过来,不是关掉。要真正避免污染,正确做法是在 Makefile 里对关键变量用 := 显式赋值,把外部环境覆盖掉。
变量的传递到子进程。 这一点很容易混淆:Make 变量默认不会进入 shell 的环境变量。
TITLE := 我的书
show:
@echo "$$TITLE" # 空,因为 TITLE 不是环境变量
@echo "$(TITLE)" # 正确,Make 展开
要在 recipe 里当环境变量用,必须 export:
export TITLE := 我的书
show:
@echo "$$TITLE" # 现在有值
有一个例外:通过命令行传入的变量(make TITLE=x)会自动导出给子进程。这个设计是为了让递归 make 能拿到上层参数,但也意味着“命令行变量会改变子进程环境”,偶尔会有意外影响。
与 MAKEFLAGS 的关系。 你写的 -j4、-k、-r 都会进入 MAKEFLAGS,并自动传给递归调用的子 make。这是“选项继承”的机制来源。所以你在顶层写了 -j4,子目录的 make 也是四路并行——除非子 make 显式覆盖。
这里有一个实践建议:把并行度写进 MAKEFLAGS 而不是散落在命令行。
MAKEFLAGS += -j$(JOBS) -r
这样任何人跑 make 都自动拿到正确的并行度和关闭的内置规则,不依赖他记得加参数。代价是临时想串行调试时要显式写 make -j1,而命令行选项会覆盖 MAKEFLAGS 里的同名项,所以能覆盖,不必担心。
3.8 一次真实的变量排查记录
抽象地讲“延迟展开”很难记住,看一次真实排查更快。
某天构建出现怪现象:同一次 make 里,两次引用同一个变量,得到不同的值。日志摘录:
开始构建,版本号 v20261031-0321
...
上传,版本号 v20261031-0322
版本号里带了时间戳,一分钟的差说明它被执行了两次。回看 Makefile:
VERSION = v$(shell date +%Y%m%d-%H%M)
找到了。= 是递归变量,每次引用都执行一次 $(shell date ...)。构建跑了一分多钟,第一次引用和第二次引用跨过了分钟边界,于是版本号变了。
这类问题的诊断顺序很固定:
第一步,grep 出所有 $(shell 和偶数个 $ 的可疑变量,看它们用的是 = 还是 :=。
第二步,在 Makefile 顶部临时插一行 $(info VERSION=$(VERSION)),确认解析期的值。
第三步,在两条引用点各插一行 $(warning ...),看是不是同一个值。
修复只需一个字符:把 = 改成 :=。改完之后版本号在解析期就固定下来,全构建共享。
这个案例的价值在于它演示了一条通用规律:凡是有副作用的表达式,都不该反复求值。 $(shell) 有副作用(真的执行命令),$(wildcard) 也有副作用(真的读目录)。它们都该用 := 锁定一次。
再补一个相关的坑。$(shell) 的执行时机是“变量被展开时”,而变量被展开的时机可能是解析期,也可能是 recipe 执行期。所以下面两行看起来一样,实际不同:
A := $(shell date +%s)
B = $(shell date +%s)
t1:
@echo $(A) $(B)
t2:
@echo $(A) $(B)
A 在整个构建里是同一个值,B 在 t1 和 t2 各算一次。如果两个目标之间间隔了几十秒,你会看到 B 的值变了。这类问题在并行模式下更明显,因为目标执行顺序不确定。
3.9 把 Makefile 拆开的三种方式
项目变大之后,一个两百行的 Makefile 会开始难读。拆分有三种方式,各有代价。
方式一:include,平铺。
include config/paths.mk
include config/tools.mk
include rules/markdown.mk
include rules/publish.mk
所有变量最终在同一个命名空间里,include 就是文本拼接。优点是最简单,缺点是变量容易重名——两个文件都定义 OUT,后包含的赢,而且不报错。
应对办法是给变量加前缀命名:MD_OUT、PUB_OUT。听起来笨,但比调试“我的 OUT 被谁改了”要省时间。
方式二:$(MAKE) -C 子目录,分层。
all:
@$(MAKE) -C docs
@$(MAKE) -C assets
每个子目录有独立的 Makefile 和独立的变量空间。优点是隔离彻底,缺点是“跨目录依赖”很难表达:docs 的产物被 publish 需要时,上层 Makefile 得手写依赖关系。
所以这种方式适合“子项目之间没有细粒度依赖”的场景。一旦出现“A 的产物喂给 B”,就该考虑方式三。
方式三:单文件 + 分段注释。
听起来像退步,但对中等规模(两三百行以内)的项目,这是维护成本最低的方案。用醒目的注释分隔区块:
# ───────────── 变量 ─────────────
# ───────────── 通用规则 ─────────────
# ───────────── 具体目标 ─────────────
好处是全局依赖关系一眼可见,坏处是文件长度不受控。实践中的分界线大约在四百行:低于它,单文件更清晰;高于它,拆成 include 更好。
一个判断依据:如果你需要在脑子里记住“某个变量的值是多少”,说明该拆了。 需要记的东西越多,拆得越细。
3.10 小结:三个“零改动”目标
本章的三组工具可以归纳成三个可检验的目标,达到它们说明你已经把 Makefile 写成了可维护的形态。
目标一:换路径零改动。 把 OUTDIR 从 out 改成 build,只需要改一行。如果还需要改命令行里的路径,说明变量没覆盖干净。
目标二:加文件零改动。 往源目录丢一个新文件,make 自动把它纳入,Makefile 一个字符不动。这依赖 $(wildcard) 加 $(patsubst) 的组合。
目标三:加一类零改动。 想支持一种新格式,只加一条模式规则,已有规则一行不改。这说明你的规则是按“类”组织的,不是按“实例”组织的。
三个目标都可以用一条命令检验:改一处,跑 make -n,看输出是否如预期。如果 make -n 里出现了你没写的路径,就是变量泄漏;如果新文件没进列表,就是 wildcard 的位置放错了。
下一章换一个维度:不再关心“怎么写得更简洁”,而是关心“怎么在被中断之后活下来”。
3.11 五个可以背下来的变量片段
有些写法用得太频繁,值得直接记住。这五段覆盖了日常八成的需求。
片段一:取当前目录并锁定。
ROOT := $(CURDIR)
用 CURDIR 而不是 $(shell pwd),因为前者不执行命令,没有副作用,快且稳。
片段二:源文件到产物的路径映射。
OUTS := $(patsubst $(SRC)/%.md,$(OUT)/%.html,$(SRCS))
这是全书中出现最多的一行。记住它,就不需要为每个文件写一条规则。
片段三:列表计数与断言。
N := $(words $(SRCS))
ifeq ($(N),0)
$(error $(SRC) 下没有找到任何源文件)
endif
$(words) 配合条件判断,是最便宜的健康检查。构建脚本静默地什么都不做,比报错更浪费时间。
片段四:带默认值的并行度。
JOBS ?= 4
用 ?= 而不是 :=,是为了让 make JOBS=2 能覆盖。这两个符号的差别在这里体现得最直接。
片段五:可选配置文件。
-include local.mk
连字符前缀让“文件不存在”不报错。团队协作时,这个片段让每个人都能放一个自己的本地覆盖文件,而不污染公共 Makefile。
五个片段有一个共同特点:它们都不执行命令,都是纯表达式。这也提醒了一件事——Makefile 里最可靠的部分是声明,最容易出问题的部分是指令。 能用声明表达的,就别写成命令。
3.12 逐行注释:一份四十行 Makefile 的完整解读
抽象的语法最好的消化方式,是拿一份真实文件逐行过。下面这份是本书后面会用到的“整理素材并产出报告”的构建文件,四十行,覆盖了本章全部要点。
MAKEFLAGS += -r
.DEFAULT_GOAL := all
.DELETE_ON_ERROR:
JOBS ?= 4
ROOT := $(CURDIR)
SRC := $(ROOT)/src
MAT := $(ROOT)/materials
OUT := $(ROOT)/out
STAMP := $(ROOT)/.stamps
.PHONY: all clean help
SRCS := $(sort $(wildcard $(SRC)/*.md))
NAMES := $(basename $(notdir $(SRCS)))
HTMLS := $(patsubst %,$(OUT)/%.html,$(NAMES))
REPORT := $(OUT)/report.html
all: $(REPORT) $(HTMLS)
$(OUT)/%.html: $(SRC)/%.md | $(OUT)
@python3 tools/md2html.py '$<' '$@.tmp' && mv '$@.tmp' '$@'
$(REPORT): $(HTMLS) $(MAT)/style.css | $(OUT)
@python3 tools/report.py --parts $(HTMLS) --style $(MAT)/style.css > '$@.tmp'
@mv '$@.tmp' '$@'
@echo "报告已生成:$@($(words $(HTMLS)) 节)"
$(OUT) $(STAMP):
@mkdir -p $@
clean:
@rm -rf $(OUT)
@echo "已清理 $(OUT)"
help:
@echo 'make 生成报告与全部 HTML'
@echo 'make clean 清理 out/'
@echo 'make JOBS=2 降低并行度'
逐行看关键处。
MAKEFLAGS += -r 关掉内置规则。这份文件里没有 C,内置规则只会添乱。
.DEFAULT_GOAL := all 显式指定默认目标。哪怕以后有人在文件顶部插了别的规则,make 依然做 all。
.DELETE_ON_ERROR: 保证任何规则失败时删掉半成品。这是整份文件里最便宜的一行保险。
JOBS ?= 4 用条件赋值,让 make JOBS=2 能覆盖。写 := 就覆盖不了了,这是一个很容易搞错的细节。
ROOT := $(CURDIR) 用内建变量,不调 pwd。三个路径变量全部由它派生,所以整份文件没有任何相对路径。
SRCS := $(sort $(wildcard ...)) 里,sort 的作用不是“好看”,而是让顺序稳定。目录枚举顺序在多数文件系统上是稳定的,但没被承诺;一旦顺序变了,报告的分节顺序就会变,产物就不可复现。
NAMES := $(basename $(notdir $(SRCS))) 做了两次变换:先去目录,再去后缀。于是 src/导言.md 变成 导言,后面可以直接拼出 out/导言.html。
HTMLS := $(patsubst %,$(OUT)/%.html,$(NAMES)) 是本章出现最多的函数。注意这里模式写的是 % 而不是 $(SRC)/%.md,因为我们已经用 notdir 把目录去掉了,NAMES 里只有裸文件名。
$(OUT)/%.html: $(SRC)/%.md | $(OUT) 这条模式规则把“一个源文件”映射到“一个产物”。竖线右边是顺序依赖,防止目录时间戳导致的重做。
'$@.tmp' && mv '$@.tmp' '$@' 是原子写入。所有写成 .tmp 再改名的规则,都是为了“被中断也不会留下半个合法产物”。
$(REPORT) 依赖 $(HTMLS),所以新增一个源文件时,报告会自动重算,因为 HTMLS 变了。这就是“加文件零改动”的实现:依赖是一个推导出来的列表,不是手写的清单。
@echo '报告已生成:$@($(words $(HTMLS)) 节)' 里的 $(words ...) 在解析期就求值好了,运行时会打印出实际节数。这行输出是给人看的,属于可观测性的一部分。
clean 只删 $(OUT),不动 .stamps。这个约定很关键:清理产物与清理“已完成的凭证”是两件事。如果你希望 clean 之后强制重做全部,那就在 clean 里加一行 rm -rf $(STAMP),但要清楚自己在做什么。
四十行里,真正跟业务逻辑有关的只有两条命令(md2html.py 和 report.py),其余三十八行都在处理判据、路径、并行、清理、帮助。这个比例听起来不合理,但它正是构建文件的价值所在:把不变的规范写死,让变化的逻辑保持最短。
反过来说,如果你发现自己某个 Makefile 里,业务命令占了八成,那说明它只是“带 tag 的脚本集”,没必要用 Make。判断标准始终是那一句:有没有产物,要不要靠时间戳决定做不做。
3.13 变量命名与文件布局约定
最后给一张约定表。它没有语法强制力,但团队里统一之后,读别人的 Makefile 会快很多。
| 前缀或形式 | 含义 | 例子 |
|——-|——-|——-|
| 纯大写 | 普通变量 | JOBS、SRCS |
| .*_DIR | 目录 | OUT_DIR、SRC_DIR |
| .*_LIST | 文件列表 | SRC_LIST、OUT_LIST |
| .*_CMD | 外部命令 | PY_CMD、ZIP_CMD |
| 单个大写字母 + 大写 | 自动变量以外的短名 | H、W 少用 |
| 小写 | 局部或临时变量(仅 define 块内) | f、u |
三条硬建议:
第一,外部命令一律变量化。写成 PY := python3 而不是直接写 python3,将来换成 python3.12 或加 -u 参数时只改一行。
第二,目录变量以 / 结尾或不以 / 结尾,二者只用一种。混用会导致 $(OUT)/x 变成 out//x,虽然多数工具能容忍,但在字符串比较和日志里很难看。
第三,不要把时间戳、随机数、主机名写进产物文件名,除非你明确希望“每次构建都是新产物”。它们会让增量彻底失效,因为它们让目标名在两次构建之间就变了。
文件布局方面,一个够用的结构是:Makefile 在项目根、tools/ 放脚本、src/ 放源、out/ 放产物、.stamps/ 放凭证。.gitignore 里写 out/ 和 .stamps/。这五条摆好之后,Makefile 的正文通常能控制在一百行以内。
一个容易忽略的细节:$(wildcard) 返回的路径带不带目录,取决于你给的模式。写 $(wildcard src/*.md) 得到 src/a.md,写 cd src && $(wildcard *.md) 得到 a.md。后者在 Makefile 里不成立,因为 Make 函数不受 cd 影响。想让路径变短,用 notdir,别指望切换目录。
还有一条命名经验:变量名里带上单位或语义,比缩写更好维护。MAX_JOBS 比 MJ 清楚,CACHE_TTL_SECONDS 比 TTL 清楚。Makefile 不是压缩包,多敲几个字符换来的是三个月后不必猜。
写到这里,变量、模式规则、函数三件工具已经齐了。它们的共同作用是同一个:把“重复”从人脑转移到文件里。凡是需要你记住“这里还有一处要改”的地方,都该变成变量或推导。
另外提醒一句:变量多到难查时,把最关键的五个在文件顶部列成注释,比翻遍全文要快。可读性是有成本的,注释是成本最低的那一种。
一句话收束本章:变量的作用是让一处改动生效,模式规则的作用是让一类文件生效,函数的作用是让一份清单自动生成。三者齐备,Makefile 才会随项目长大而变简单。
第4章 断点续跑:让中断不再等于从头开始
第 4 章 断点续跑:让中断不再等于从头开始
4.1 产物即状态
第 1 章讲过一个沙箱,每十到六十分钟被重置一次。这一章讲怎么在那种环境里把一件四十分钟的活干完。
先说结论,再说方法:能不能扛住中断,取决于你把进度存在哪。
有三种常见的存法,抗中断能力差别巨大。
第一种,存在内存里。变量计数、列表推进、当前处理到第几个,全在进程的变量里。进程一死,进度归零。这是最脆弱的方案,但也是脚本里最常见的方案,因为它写起来最顺。
第二种,存在一个状态文件里。每处理完一项,往 progress.json 里写一条记录。这比内存强,因为它落了盘。但它引入了一个新问题:状态文件和真实产物之间可能不一致。程序写完状态、还没来得及写出产物就被杀,下次启动时它以为这项做完了,于是永远跳过。你必须额外写逻辑去校验“状态说完成”与“产物真的存在”是否一致。
第三种,让产物本身就是状态。不写任何额外的进度记录,只检查产物文件在不在、新不新。做完一项,产物就落一次盘;没做完,产物就不存在。进度是产物的推论,不是独立的记录。
Make 走的是第三条路。这就是为什么它天然抗中断:它压根没有“进度”这个概念,只有“文件在不在”。你去问它“做到哪了”,它没法回答;但你去让它干活,它自己会算出还差哪些。
这个设计的代价是:每个任务都必须有一个能落盘的产物。 如果一个任务跑完什么都不生成(比如“发一封通知”、“调用一次远程接口”),Make 就失去了判据。这就是下一节 stamp 要解决的问题。
理解了“产物即状态”,你还会得到一个副产品:幂等性变得容易达成。因为任务的全部效果都体现在产物文件上,重复执行只会覆盖同一个产物,不会累积副作用。而“追加写”是幂等的天敌,第 3 节会专门说。
顺便澄清一个误区。有人问:如果产物写到一半被中断,留下一个残文件,Make 下次会不会以为它做完了?答案是会。因为残文件存在,时间戳也可能比依赖新。这是一个真实的陷阱,.DELETE_ON_ERROR 就是专门对付它的,第 5 节讲。
4.2 stamp:给没有产物的任务造一个产物
发通知、上传、调用接口,这些动作没有天然产物。硬要用 Make 管,办法是人造一个:做完了就 touch 一个空文件当凭证。
这个模式叫 stamp 文件。完整写法:
.PHONY: all
all: .stamps/publish.done
.stamps:
@mkdir -p $@
.stamps/publish.done: out/book.zip | .stamps
@curl -s -u "$$AUTH" -X POST https://example.com/upload \
-F "file=@$<"
@touch $@
三个要点。
第一,touch 必须是最后一行,且前面不能有失败的命令。 因为 Make 逐行检查退出码,前面失败了整条规则就停止,touch 不会执行——这正是我们要的:只有上传成功才有凭证。
第二,stamp 文件也要进依赖链。 上面的例子里,publish.done 依赖 book.zip,所以整稿一更新,stamp 就过期,下次 make 会重新上传。这就是“内容变了才重发”的自动实现。
第三,.stamps 目录要用顺序依赖。 原因和第 2 章一样:目录时间戳会变,写成普通依赖会导致永远重做。
要强制重做,删掉 stamp 即可:
rm .stamps/publish.done && make
或者做一个语义化的目标:
.PHONY: republish
republish:
@rm -f .stamps/publish.done
@$(MAKE) all
这里出现了 $(MAKE),它是内建变量,指向 make 自身。用它而不是写 make,是为了让命令行选项(比如 -j4)能正确传给子调用。第 5 章讲递归时会展开。
stamp 的一个变体是把凭证内容写进文件,而不是留空:把上传返回的 URL 写进 .stamps/publish.done,以后想看“上次传到哪了”,cat 一下就知道。这个习惯在小项目里非常实用。
stamp 也有它的边界。如果某个动作必须每次都做(比如每次构建都重新拉一次最新数据),那它就不该用 stamp,而应该用 .PHONY。判断标准还是那句话:这个动作有没有“做过就不必再做”的性质。
4.3 幂等三原则
断点续跑的前提是幂等:同一条命令重复执行,结果与执行一次相同。这条性质不是自动获得的,需要三个具体的设计约束。
原则一:先检查,后执行。
任何有外部副作用的动作,执行前都先确认条件:
.stamps/fetch.done: | .stamps
@test -s out/raw/data.json || curl -s -o out/raw/data.json https://example.com/api
@touch $@
test -s 检查文件存在且非空。存在就跳过网络请求,省时间也省配额。
这条原则另有一个更严格的版本:用 Make 的判据替代手写检查。 上面那段其实可以写成两条规则,让 Make 自己判断,代码更少,语义更清楚:
out/raw/data.json: | out/raw
@curl -s -o $@ https://example.com/api
原则二:先写临时文件,再原子改名。
如果一条命令直接往最终路径写,写到一半被杀,就会留下半成品,而半成品在 Make 眼里是合法的产物。正确的做法是写到临时名,成功后 mv 过去:
out/big.csv: | out
@curl -s -o $@.tmp https://example.com/big.csv && mv $@.tmp $@
mv 在同一个文件系统内是原子的,所以外部永远看不到半个文件。这个技巧在所有“长时间下载或导出”的场景里都值得用。
原则三:绝不追加写。
>> 是幂等性的死敌。一个脚本如果每次执行都往日志里追加一行,那么重跑三次就有三行,结果不再等价。这不是说不能用 >>,而是说要区分两类文件:日志可以追加,产物不能追加。
判据是:这个文件是否参与后续计算?参与,就必须是覆盖写;不参与,随便追加。
三条原则合起来,就是断点续跑的完整条件:有判据(产物存在)、有原子性(临时名改名)、无累积副作用(不追加)。做到这三条,你的任务就变成了可以任意次重跑而不出错的部件。
4.4 并行:四核的纪律
这台沙箱的配额是四个虚拟核,这一点由 cgroup 保证:
cat /sys/fs/cgroup/cpu.max
# 400000 100000
四毫秒的配额除以一毫秒的周期,等于四。但 nproc 会告诉你三十二,因为它是宿主机的核数,容器没有做 CPU 亲和性屏蔽。
这个差异会坑人。任何按 nproc 自适应的工具,都会开三十二路去抢四个核,结果是上下文切换爆炸、单任务变慢、内存峰值升高。我在这台机器上见过 load average 长期停在四以上的状态,就是这么来的。
所以并行度要写死为四,不要用 $(nproc)。 想让它可配置:
JOBS ?= 4
all:
@$(MAKE) -j$(JOBS) build
然后 make JOBS=2 就能降级。为什么不直接用命令行 -j4?因为写在 Makefile 里可以保证“任何人执行 make 都拿到正确的并行度”,而不依赖他记得加参数。
-j 有两个容易混淆的近亲。-l(小写 L)是“负载上限”,意思是“当前系统负载超过这个数就不再开新任务”,它做的是节流而不是限并发。-k 是“出错继续”,跟并发无关,第 5 节讲。
并行带来的第一个现实问题是输出交错。四个任务同时往终端写,你会发现日志行互相插进对方的句子中间,完全没法读。解决办法是:
make -j4 --output-sync=target
输出同步选项 output-sync 会让每个目标的输出成块输出,不再交错。它需要 GNU Make 4.0 以上,且代价是内存占用略增(每个目标要缓存输出)。在日志可读性面前,这个代价几乎可以忽略。
第二个现实问题是依赖竞态。这个更严重:并行会放大“漏依赖”的后果。串行时漏一个依赖,你得到一个过时的产物;并行时漏一个依赖,两个任务可能同时写同一个文件,得到一个损坏的产物。所以:
在你把 Makefile 跑通、且 make 二次执行显示全部 up to date 之前,不要加 -j。 这是我在多处强调的一句话,因为大多数“并行之后结果不对”的问题,根因都是依赖声明不全,而不是 Make 的调度有 bug。
第三个问题是有些步骤确实不能并行。比如“先备份再清理”,两条规则之间没有文件依赖关系,但语义上有顺序。这时用 .NOTPARALLEL:
.NOTPARALLEL: cleanup
更精确的做法是声明空依赖来串行化:
cleanup: | backup
竖线让它成为顺序依赖,既保证顺序,又不引入时间戳判据。这个技巧比 .NOTPARALLEL 更细粒度,推荐优先使用。
4.5 失败处理与锁
Make 默认的行为是“一行失败,整条规则停止,退出码非零”。这个默认值在多数场景下是对的,但有四种情况需要干预。
需要忽略错误:- 前缀。
clean:
-rm -rf out
rm 在目录不存在时返回非零,加了 - 就不影响流程。这是“清理必须幂等”的必要条件。
需要继续做其他目标:-k。
make -k
默认情况下,一个目标失败,Make 立刻停止所有工作。-k 让它继续做“不受这个失败影响”的其他目标,最后再汇总失败列表。这个选项在批量任务里非常有用:一百个文件里有一个转码失败,你希望剩下九十九个照做,而不是全部放弃。
需要自动删除半成品:.DELETE_ON_ERROR。
.DELETE_ON_ERROR:
这条声明没有参数,作用全局:任何规则的命令失败时,自动删除它正在生成的目标。它精确地解决了前面说的“残文件被误认为完成”的问题。这是我最推荐无条件加进每个 Makefile 的一行。
需要避免并发执行同一个目标:flock。
如果你的 Makefile 可能被 cron 和人工同时触发,两个实例会互相覆盖。用文件锁:
all:
@flock -n /tmp/book.lock $(MAKE) build || echo "已有实例在跑,跳过"
-n 是“拿不到锁就立刻退出”,不阻塞。这个模式配合 cron 使用几乎是必需的,否则你会遇到“每十分钟一次的定时任务,在某次重跑变慢时叠了三个实例”的经典事故。
需要防止单个任务卡死:timeout。
out/raw/page.html: | out/raw
@timeout 30 curl -sL --max-time 25 -o $@.tmp "$(URL)" && mv $@.tmp $@
timeout 给整体执行时间设上限,curl 的 max-time 选项给网络请求设上限。两层保险在抓取类任务里是标准配置,因为一个卡住的连接会让整个构建停在那里,而你的时间窗口只有二十分钟。
这两个工具都几乎必然已装在 Linux 上,不需要额外依赖。用它们,比自己写信号处理和看门狗要可靠得多。
4.6 谁来启动它
脚本写好之后,紧接着的问题是:谁在机器重启之后把它拉起来?这个问题的答案取决于你的运行环境,而不同环境差别很大。
在这台沙箱里,实际的启动链路是这样:
docker-init
└─ supervisord
└─ include /usr/local/share/supervisor/*.conf
└─ watchdog.conf → command=/workspace/watchdog.sh
也就是说,容器一起来,supervisord 就会执行 watchdog.sh。这是一个已经存在的钩子,往里加三行就能实现“重置后自动补做未完成任务”:
if [ -f /workspace/tasks/Makefile ]; then
( cd /workspace/tasks && make -j4 >> run.log 2>&1 ) &
fi
注意位置。watchdog.sh 的末尾是一个 while true ... sleep 30 ... done 的死循环,如果把这三行加在文件最末尾,它就成了循环体的一部分,每三十秒跑一次 make,几十个进程叠起来能把四核吃干净。正确的位置是死循环之前。
如果想用 cron,要知道一个实测细节。Debian 系 cron 的 @reboot 并不是“每次 cron 启动都执行”,而是靠一个标记文件判断“这次是不是系统启动”:
/run/crond.reboot
/run 是 tmpfs,容器重启后它会消失。所以真正的重置之后,@reboot 会触发;而你手动 service cron restart 时,标记文件还在,@reboot 会被跳过。我第一次测试时就被这个机制骗了一轮,以为是 cron 不支持。
另有一个更隐蔽的坑,实测踩到:crontab 里 % 是特殊字符,表示“换行,后面是标准输入”。所以这条看似正常的规则:
@reboot echo "boot $(date +%T)" >> /tmp/probe.log
什么也不会发生,因为 %T 被解释成换行,命令被截断成一个不完整的字符串。正确写法是转义:
@reboot echo "boot $(date +\%T)" >> /tmp/probe.log
修正后立刻就能看到探针文件出现内容。这个坑和 Make 无关,但只要你的 Make 任务靠 cron 启动,你就一定会遇到它。
三种启动方式的取舍:
| 方式 | 优点 | 缺点 | 适用 |
|——-|——-|——-|——-|
| supervisor 钩子 | 与容器生命周期一致,自动重启 | 需要平台允许写配置 | 容器/沙箱环境 |
| cron @reboot | 无依赖,标准 | % 转义、/run 标记两个坑 | 传统服务器 |
| 手动 | 完全可控 | 需要人记得 | 交互式开发 |
我的选择是 supervisor 钩子优先,因为它的语义最直接:“进程起来了就跑一次”,没有额外状态。
4.7 三个真实案例
案例一:书稿成品化。
一件事需要四步:拼装整稿、生成目录、打包、上传。用 Make 组织:
all: .stamps/upload.done
.stamps/upload.done: out/book.zip | .stamps
@curl -s -u "$$AUTH" -X POST https://example.com/upload -F 'file=@$<'
@touch $@
out/book.zip: out/book.md | out
@cd $(@D) && zip -q -FS book.zip book.md
out/book.md: $(SRCS) | out
@for f in $^; do cat "$$f"; printf '\n\n'; done > $@
中断在打包那一步,下次 make 只会重做打包和上传,拼装跳过。中断在上传那一步,下次只重做上传。这就是断点续跑的实际效果。
案例二:批量抓取,以及何时该放弃 Make。
抓两百个页面,每页一个文件,看起来很适合 Make:
out/raw/%.html: urls.txt | out/raw
@curl -sL --max-time 25 -o $@.tmp "$$(grep $* urls.txt)" && mv $@.tmp $@
这条规则能跑,但写起来别扭:它需要从 urls.txt 里反查 URL,还要处理文件名与 URL 的映射。更糟的是,如果 urls.txt 变了,Make 无法推导出“哪些页面需要重抓”。
这类“清单来自外部文件”的任务,用 xargs 更直接:
cat urls.txt | xargs -P4 -I{} sh -c '
f=out/raw/$(printf "%s" "{}" | md5sum | cut -c1-12).html
[ -s "$f" ] && exit 0
mkdir -p out/raw && curl -sL --max-time 25 "$f" -o "$f" 2>/dev/null
'
-P4 限并发,[ -s '$f' ] && exit 0 就是断点续跑。一句话解决了 Make 需要绕半天的问题。
判据很清晰:清单能在写 Makefile 时就确定,用 Make;清单来自外部且会变,用 xargs。
案例三:转码批次。
把一批音频转成统一码率,天然有产物、天然有依赖,是 Make 的最优场景:
SRCS := $(wildcard in/*.m4a)
OUTS := $(patsubst in/%.m4a,out/%.mp3,$(SRCS))
all: $(OUTS)
out/%.mp3: in/%.m4a | out
@ffmpeg -y -loglevel error -i '$<' -b:a 128k '$@.tmp' && mv '$@.tmp' '$@'
一百个文件,四核并行,中途被打断十次,最终结果和一次跑完完全一致。这就是第 1 章那句“判据加产物”能带来的实际收益。
本章讲的是工程化的一半:让任务可中断、可重跑、可控并发、可控启动。另一半是排错与规范,这是第 5 章的内容。到那时你会具备把一套五行的 Makefile 维护半年的能力。
4.8 用 Make 组一个最小任务队列
前面所有例子都假设“任务清单固定”。现在加一层:清单每天变,但每项任务的执行方式一样。这时 Make 依然是可用的,只是要靠一点外部机制。
思路是把“队列”和“执行”分开:队列是一个文本文件,每行一项;执行器是一个 Makefile,参数化地处理一行。用一个批处理目标把两者串起来:
.PHONY: run-one
run-one:
@test -n '$(TASK)' || { echo '用法: make run-one TASK=xxx'; exit 1; }
@echo "[$(shell date +%H:%M:%S)] 开始 $(TASK)"
@python3 tools/handle.py '$(TASK)'
@touch .stamps/task-$(TASK).done
@echo "[$(shell date +%H:%M:%S)] 完成 $(TASK)"
外部用一个循环驱动:
for t in $(cat queue.txt); do
[ -f ".stamps/task-$t.done" ] && continue
make run-one TASK="$t" || break
done
这个写法有几个好处。任务之间彼此独立,一项失败不影响已完成项;.done 文件就是断点记录,被中断后重跑会自然跳过;每项的日志独立,排查时不会搅在一起。
它也有明确的边界:不适合有依赖关系的任务。如果 B 必须在 A 之后,你得在循环里判断,那就退化成手写脚本了。这种场景反而应该回到“把任务写进 Makefile 当静态规则”的做法,让依赖图来调度。
判断标准可以写成一句话:任务之间有先后依赖,用规则;任务之间互相独立只是数量多,用队列。 两者混用时,用 Make 管少量有依赖的骨架,用队列管骨架里的批量细节。
另有一个细节值得注意:$(shell date +%H:%M:%S) 是递归展开,每次引用都会重新取时间,这是这里想要的效果(开始时间和结束时间不同)。但它也提醒我们上一章的规则——有副作用的表达式要慎用。在这里它恰好是“故意”的用法,写代码时最好加一句注释说明意图,否则下一个人会误以为是 bug 而改成 :=。
队列方案另有一层隐藏收益:它把“进度”变成了可审计的文件。跑完一天之后,.stamps/ 里有三十个文件,就是三十项完成的证据。这比日志里翻“成功了”要可靠,因为文件不会因为日志轮转而消失。
4.9 中断之后,怎么知道发生了什么
断点续跑解决“接着做”,但还需要解决“上次到底做到哪、为什么断”。这需要三个习惯。
习惯一:让每一步都有唯一可识别的产物名。 产物名里带章节号、日期、ID,而不是 output.txt。这样你 ls 一下就知道进度分布。前面书稿案例里 out/book.md 是单一产物,看不到进度;改成 out/ch01.html、out/ch02.html 之后,ls out | wc -l 就是进度条。
习惯二:失败要留下可读的理由。 把错误输出重定向到与产物同名的 .err 文件:
out/%.html: src/%.md | out
@pandoc '$<' -o '$@.tmp' 2> '$@.err' && mv '$@.tmp' '$@'
失败时 .err 里有原因,成功时 .err 是空的。清理时一并删掉即可。这样你在重建时不需要翻几百行日志。
习惯三:记录“为什么重跑”。 在关键目标开头打一行带时间戳和依赖状态的信息:
out/book.md: $(SRCS) | out
@echo "重建 $@,触发原因:$?"
$? 只列出比目标新的依赖,所以这行输出会直接告诉你“是第九章改了”。开销一行,收益很大。
三个习惯合起来,让“中断”从一件需要调查的事,变成一件看一眼就知道的事。工程上的很多痛苦,本质是信息没有被写下来——而写在磁盘上的信息,恰好是抗中断的。
4.10 三条可以直接背下来的口诀
这一章内容多,但真正要记的只有三句。
口诀一:没有产物,就没有断点。
任何一个“跑完什么都不生成”的任务,都无法断点续跑。解决方式只有两种:让它生成一个真产物,或者用 stamp 造一个假产物。看到裸命令就想这一条。
口诀二:清单稳定的交给规则,清单会变的交给循环。
Make 的调度能力来自静态依赖图,图的节点必须在解析期确定。如果清单来自外部文件,用 xargs 或 for 循环反而更简单。别硬把动态清单塞进 Makefile。
口诀三:先串行跑绿,再加并行。
-j 表面上是加速手段,实际上是压力测试。它会把你所有的依赖漏洞一次性暴露成数据损坏。所以顺序应该是:写规则、串行两次全绿、加 -j4、再确认两次全绿。
三句口诀对应三类最典型的失败:漏产物、硬塞动态清单、未验证就并行。把这三条记住,剩下的细节可以随时回查。
4.11 边界实验:Make 与 xargs 的分工线
第 7 节说过“清单能静态确定就用 Make,清单来自外部就用 xargs”。这句话值得用一个实验钉死,因为很多人会反复纠结。
实验设定:处理二十个 URL,每个抓一次并落盘。两种实现。
实现一:Make。
URLS := $(shell cat urls.txt) # 解析期读一次
NUMS := $(shell seq 1 $(words $(URLS)))
all: $(addprefix out/p,$(addsuffix .html,$(NUMS)))
out/p%.html: | out
@u=$(word $(patsubst p%.html,%,$*),$(URLS)); \
curl -sL --max-time 25 -o '$@.tmp' "$$u" && mv '$@.tmp' '$@'
它能跑,但有三个别扭处。第一,URL 与文件名的对应关系要靠 $* 反查,可读性差。第二,urls.txt 变了之后,Make 无法推导“哪些页面要重抓”,只能全量重来或者手工删产物。第三,$(shell cat urls.txt) 在解析期执行,如果 urls.txt 是上一步刚生成的,这一次构建读到的还是旧内容。
实现二:xargs。
mkdir -p out
cat urls.txt | xargs -P4 -I{} sh -c '
f=out/$(printf "%s" "{}" | md5sum | cut -c1-12).html
[ -s "$f" ] && exit 0
curl -sL --max-time 25 "{}" -o "$f.tmp" && mv "$f.tmp" "$f"
'
五行,没有映射表,清单变了直接重跑,[ -s '$f' ] 就是断点续跑。
两者的分工因此很清楚:
| 判据 | Make | xargs |
|——-|——-|——-|
| 清单来源 | 写 Makefile 时确定 | 运行时从文件/管道来 |
| 依赖关系 | 有跨任务依赖(B 依赖 A 的产物) | 任务互相独立 |
| 增量粒度 | 文件级,精确 | 文件级,靠一行 test |
| 并行 | -j4,与依赖图配合 | -P4,只看数量 |
| 可读性 | 依赖图一眼可见 | 逻辑一眼可见 |
一个混合方案在真实项目里最实用:Make 管骨架,xargs 管批量。 骨架指那三五步有先后依赖的动作(抓取 → 清洗 → 汇总 → 发布),批量指其中某一步内部的成百上千个同类项。
out/all.json: .stamps/fetch.done | out
@python3 tools/merge.py out/raw > '$@.tmp' && mv '$@.tmp' '$@'
.stamps/fetch.done: urls.txt | .stamps out/raw
@cat urls.txt | xargs -P4 -I{} sh -c 'f=out/raw/$$(echo {} | md5sum | cut -c1-12).html; [ -s "$$f" ] || curl -sL --max-time 25 "{}" -o "$$f.tmp" && mv "$$f.tmp" "$$f"'
@touch $@
外层是 Make 的依赖图,内层是 xargs 的并行批处理。骨架稳定,批量灵活。这个结构在抓取、转码、批处理这类项目里几乎通用。
最后给一条经验值:当你的 Makefile 里出现 $(shell cat 某个文件) 来构造目标列表时,先停下来想一下,是不是该换成 xargs。 这个模式是“硬把动态清单塞进 Make”的典型信号。
4.12 一个可复用的检查脚本
最后给一段可以直接用的自检脚本,用来确认“这份 Makefile 真的能断点续跑”。
#!/bin/bash
set -u
cd "${1:?用法: check-resume.sh <项目目录>}"
echo "[1/4] 首次构建"
make -s || { echo "失败:首次构建就不通"; exit 1; }
echo "[2/4] 二次构建应为全量跳过"
out=$(make -n 2>&1)
if echo "$out" | grep -qvE "up to date|Nothing to be done"; then
echo "警告:二次构建仍有动作,增量可能失效"
echo "$out" | head -5
fi
echo "[3/4] 触碰一个源文件后应只重做相关部分"
first=$(ls src/* 2>/dev/null | head -1)
[ -n "$first" ] && touch "$first"
make -n 2>&1 | grep -c '^' | xargs -I{} echo "计划执行 {} 行命令"
echo "[4/4] 删除产物后应可重建"
rm -rf out
make -s >/dev/null || { echo "失败:删产物后无法重建"; exit 1; }
echo "通过:可重建"
这段脚本检查四件事:能不能跑通、二次是否跳过、改一个文件是否只重做局部、删掉产物能否重建。第三项是最有信息量的,它直接暴露漏依赖问题:如果你只改了一个源文件,而计划执行的命令里出现了不相关的目标,说明依赖声明有冗余;反之如果什么都没计划,说明依赖漏了。
把它接进 CI,每次提交跑一遍,比任何文档都更能保证“半年后还敢改”。
四步之外另有一个手工动作值得养成:随机删掉一个中间产物,然后 make,确认它能被补回来。 自动化脚本很难覆盖这个场景,但它恰好是断点续跑最核心的承诺。
4.13 三份清单:交给明天的自己
第一份是“设计时”清单。动手写之前,先确认三件事:每个任务有没有落盘产物、每条命令是不是幂等、单步能不能在中断窗口内跑完。三件事有任何一项答不上来,先改设计再写代码。
第二份是“运行前”清单。加 -j 之前,串行跑两次确认全绿;接 cron 之前,确认 flock 已就位;写 touch stamp 之前,确认它真的是最后一行。
第三份是“出事后”清单。先看产物在不在,再看时间戳谁更新,最后看 Make 认为依赖是哪些(make -p 里查)。按这个顺序查,八成问题在前两步就能定位。
三份清单加起来不到五十个字,但它们覆盖了断点续跑里几乎所有的失败模式。
另有一个常被忽略的观测点:产物数量。ls out | wc -l 与预期数量比对,是最便宜的健康检查。它能在“任务静默跳过”和“任务重复执行”两种异常之间做出区分:数量偏少说明有跳过,偏多说明有多余产物,两者都值得看一眼。
最后提醒一个与沙箱环境相关的实践:如果你的机器随时可能被重置,就把“启动后自动补跑”当成默认设计,而不是事后加的补丁。具体做法是在启动钩子里固定写三行——判断任务目录是否存在、用子 shell 切进去、后台执行并追加日志。三行代码,换来的是一整类“忘了手动重跑”的损失。
断点续跑的真正价值在于让任务变“可重复”。可重复的意思是:无论被打断多少次,最终产物与一次跑完完全相同。这个性质一旦成立,时间的碎片化就不再是问题,你把窗口用满即可。
还有一件事值得写进团队约定:把“如何判断构建是否健康”写成三条可执行命令,放进 README。新人不问人也能自检,这是断点续跑能长期活下来的社会条件。
写到这里的每一条技巧,都可以用同一句话复述:先让“做过”变成文件,再让“再做一次”变成安全操作,最后让“接着做”变成默认行为。顺序不能颠倒,因为后者依赖前者的正确性。
最后补一句操作建议:把你最常用的三条命令做成一个 make check 目标,让它依次验证可构建、可跳过、可重建。三条各十秒,比事后花半小时排查要划算得多。
一句话总结这一章:让中断变成一次无害的跳过,让重跑变成一次安全的覆盖。做到这两点,时间碎片就不再是成本。
第5章 调试、陷阱与工程化
第 5 章 调试、陷阱与工程化
5.1 六件调试武器
Makefile 出问题时的痛苦在于它是声明式的:你写的是“要什么”,而执行是 Make 算出来的,中间隔了一层推导。所以排查的核心手段不是读代码,而是让 Make 把它算出来的东西说给你听。
武器一:make -n,干跑。
它把要执行的每条命令打印出来,但一行都不执行。这是排查“它到底想干什么”的第一选择。比如你怀疑某条规则没被匹配,make -n 没有输出它,就说明规则压根没生效,而不是命令写错了。
一个细节:-n 下被 @ 静默的命令也会打印,这正是我们想要的。另外带 + 前缀的命令例外,它在 -n 下会真执行,这是给递归 make 用的。
武器二:make -B,无视时间戳全量重做。
当你怀疑“上次的产物是坏的,但因为时间戳是新的所以被跳过”,用 -B 强制重做。它等价于把所有目标都当成过期。注意它是全局的,会让一次构建变慢很多,所以只在排查时用。
一个更精细的替代是 touch 目标文件:
touch out/book.md && make
这只会让那一个目标过期,影响面小。
武器三:make -p,打印全部规则与变量。
输出几千行,包含 Make 的全部内建变量、隐含规则,以及你自己定义的规则。它的正确用法是配合 grep:
make -p 2>/dev/null | grep -A5 '^CFLAGS'
make -p 2>/dev/null | grep '^out/'
当你怀疑“某个变量不是我设的值”或者“某条规则被隐含规则抢走”时,这是唯一的真相来源。
武器四:trace 模式,逐条解释为什么执行。
它会在每条命令前打印出发原因,例如“目标不存在”或“依赖比目标新”。这比 -n 更有信息量,因为它回答的是“为什么”,而不只是“什么”。
武器五:make -d,全量调试信息。
它把解析、匹配、时间戳比较的全过程都打出来,输出极其冗长。真正有用的场景只有一个:模式规则为什么不匹配。用 make -d 2>&1 | grep -i 'pattern\|prerequisite' 过滤,能看到它尝试了哪些模式、为什么放弃。
武器六:$(info)、$(warning)、$(error),在 Makefile 里打日志。
$(info SRCS = $(SRCS))
$(warning JOBS 未设置,使用默认值)
$(error 缺少 config.mk,停止)
这三个都是解析期函数,在读取 Makefile 的时候执行。info 打一行就继续,warning 打一行并标记为警告,error 直接终止。
要注意它们的执行时机:解析期,不是构建期。所以它们适合排查变量值、条件分支,不适合排查“某条命令有没有执行”。后者应该用 @echo 在 recipe 里打。
这六件武器覆盖了三类问题:规则没匹配(-n、-d)、变量不是预期值(-p、$(info))、产物状态不对(-B、trace 模式)。遇到问题时先分类,再选武器,比漫无目的地试要快得多。
5.2 十类经典坑
下面十条覆盖了实践中九成以上的 Make 事故。每条给出症状、诊断、修复,值得当成检查表。
第一,空格代替制表符。 症状是 missing separator. Stop.。诊断用 cat -A Makefile 看行首是 ^I 还是空格。修复用 sed -i 's/^ /\t/',根治靠编辑器关掉“空格代替制表符”。从网页复制 Makefile 是头号来源。
第二,把命令行写成多行 shell。 症状是 cd 下一行就失效、变量赋值丢失。原因是每行独立 shell。诊断:在第二行加 @pwd 看当前目录。修复:写成一行用 &&,或用 .ONESHELL。
第三,$ 没转义。 症状是 shell 变量的值变成空或者只剩后缀($HOME 变成 OME)。原因是 Make 先吃掉一层 $。修复:所有 shell 变量写 $$。
第四,= 与 := 混用。 症状是同一个变量在构建中两次引用得到不同值,典型出现在时间戳、随机数、$(shell) 结果上。修复:默认用 :=。
第五,目录当普通依赖。 症状是每次 make 都重做。原因是目录时间戳随内部文件变化。修复:改成顺序依赖 | $(OUT)。
第六,漏依赖。 症状是改了源文件但产物没更新,且不报错。这是最危险的一类,因为它静默。诊断:make -n 看依赖是否包含那个文件;或者用 make -d 看时间戳比较。修复:用 $(wildcard) 自动推导,别手写清单。
第七,并行下的竞态。 症状是加了 -j 之后产物偶发损坏,串行跑就正常。原因是两条规则同时写一个文件,或漏依赖。诊断:make -j1 对比。修复:补依赖,或用 .NOTPARALLEL。
第八,.PHONY 滥用。 症状是每次都全量重做,-B 似的。诊断:grep PHONY Makefile 看有没有把文件目标声明成伪目标。修复:删掉那些声明。
第九,递归 make 用 make 而不是 $(MAKE)。 症状是 -j4、-n、-k 这些选项在子目录里失效。修复:一律写 $(MAKE),它会让选项通过 MAKEFLAGS 自动继承。
第十,$(wildcard) 空匹配静默通过。 症状是某次构建“什么也没做”却成功退出,因为列表是空的,依赖空,命令跑了个空循环。诊断:$(info 匹配到 $(words $(SRCS)) 个文件)。修复:在关键位置加断言:
ifeq ($(strip $(SRCS)),)
$(error chapters/ 下没有找到任何 .md 文件)
endif
这十条里,前四条是语法层,中三条是语义层,后三条是工程层。语法层的错误会报错,容易修;语义层的错误不报错,最难查;工程层的错误在规模变大后才显现。
5.3 工程化八条规范
一个能跑通的 Makefile 和一个能维护半年的 Makefile 之间,差的是下面八条。
规范一:产物统一进 out/。 源目录只有源文件,构建产物全部落在 out/(或 build/)。好处是 rm -rf out 就是干净状态,且源目录的脏文件不会污染 $(wildcard) 的结果。这条规范的价值在项目变大后急剧上升。
规范二:all 放第一行,并显式声明 .DEFAULT_GOAL。 别依赖“第一条规则”这种隐式行为。
规范三:每个 Makefile 都有 help 目标,且是伪目标。
.PHONY: help
help:
@echo 'make 构建全部产物'
@echo 'make clean 清理 out/'
@echo 'make test 跑测试'
这条规范的实际收益是:三个月后接手的人(可能是你自己)不用读 Makefile 就能知道怎么用。
规范四:.DELETE_ON_ERROR 无条件开启。 没有理由不开。
规范五:变量集中在文件顶部。 路径、并行度、外部命令名,全部写在开头几行,正文只引用变量。这样改环境只需要动一块地方。
规范六:并行度写死为核数,且可被环境变量覆盖。
JOBS ?= 4
不要用 $(nproc),原因见第 4 章:容器里 nproc 报的是宿主机的核数。
规范七:构建过程可观测。 每个目标开头打一行“我在做什么”,失败时能立刻定位到哪一步。同时把完整的构建日志重定向到文件:
make -j4 2>&1 | tee build.log
规范八:Makefile 进版本库,且与 CI 用同一条命令。 本地跑 make -j4,CI 也跑 make -j4。任何“CI 上要额外加参数”的差异,都是未来事故的种子。
八条规范里,第一、三、四条的性价比最高,能立刻减少日常摩擦。
5.4 与替代品的位置关系
现代任务运行器不少,把 Make 放进这个谱系里看,选择会更清楚。
just 是一个语法更友好的任务运行器。它没有“文件时间戳”这个概念,每个配方都是命令别名,靠人写 if 判断要不要跳过。适合“命令集合”场景,不适合“产物构建”场景。
task(Taskfile) 用 YAML 描述任务,有依赖、有变量,生态好。它的依赖是“任务依赖”而不是“文件依赖”,所以增量能力要靠它自己的 sources/generates 配置,本质上仍是把时间戳判断藏起来。
npm scripts 是 Node 生态的默认选择。它的优势是零学习成本(就在 package.json 里),劣势是没有依赖图、没有增量、并行要靠 & 或额外的库。适合前端项目的简单脚本集合。
Dockerfile 看起来有层缓存,很像增量构建,但它的缓存键是命令文本加文件内容哈希,粒度是“层”,不适合细粒度任务编排。
纯 shell 脚本 是最直接的方案。它的问题是第 1 章讲过的那三个:没有记忆、顺序写死、状态靠人记。
Make 的定位因此很清楚:它是唯一一个“文件级增量 + 依赖图 + 零依赖安装”三者兼得的工具。 当你的任务有明确产物、且希望“重跑就是继续”时,它仍然是最省事的选择。
反过来,如果你要的是“一组命令的别名”(跑个 lint、起个服务),just 或 npm scripts 更顺手;如果你要的是“复杂的有状态编排”(重试、分支、事务、可视化),那是 airflow 这类工具的领域。
选择标准可以简化为一句话:要不要靠文件时间戳做增量?要,就用 Make。
5.5 何时不该用 Make
三条硬边界。
第一,长驻服务。 Make 的目标是“做完就退出”。你要的是守护进程,那用 supervisor、systemd、docker restart policy。用 Make 起服务只能得到“起了就阻塞”或者“起了就变孤儿进程”两种结果。
第二,需要事务的流程。 转账、发布、迁移这类“要么全做要么全不做”的动作,Make 给不了保证。它只有 .DELETE_ON_ERROR 这一个删除动作,远达不到事务语义。
第三,清单高频变动且来自外部。 从数据库查出来的三千行任务,写进 Makefile 就是灾难。用 xargs -P4 或者写个真正的队列。
还有一条软边界值得提:团队里没人会 Make 时,别引入它。 工具的价值随使用者的理解程度变化。一个没人看得懂的 Makefile,比一个啰嗦但清晰的 shell 脚本更糟糕。
5.6 进阶路线
读完这本书,你已经覆盖了日常所需的全部内容。如果还想深入,按这个顺序:
先读 GNU Make 官方手册的“Rules”和“Variables”两章。本书讲的是它们的实践子集,手册会把边界情况讲全。
然后学 MAKEFLAGS 与递归 make 的配合。关键字是 +$(MAKE) -C dir 和选项自动继承。
接着是二次展开(.SECONDEXPANSION)。它解决“依赖列表本身需要动态计算”的问题,写法是 $$ 与普通 $ 混用,初读会觉得绕,理解了会很好用。
再往后是 .SECONDARY、.INTERMEDIATE、.PRECIOUS 这三个“中间文件”声明。它们控制“哪些中间产物在构建结束后保留”,在大型项目里能显著减少重建时间。
最后,如果构建规模真的到了几千个目标,可以看 Ninja。它是 Make 的“执行器替身”:语义更简单、速度更快,通常由 CMake 这类工具生成构建文件,而不是手写。
但在那之前,请先把一件事做扎实:给你手上的每一个“需要重跑”的流程,写一个带 all、clean、help 和 .DELETE_ON_ERROR 的 Makefile。这四件事做到位,你就已经比大多数人更会用它了。
本书的起点是一台会被重置的沙箱,终点是一套任务台账。中间那五章讲的所有技巧,本质都在回答同一个问题:怎么让“做过”这件事变成磁盘上的事实,从而让中断变得无关紧要。 Make 用时间戳回答这个问题,答案简单到有点土,但它四十年没被替代,因为它对。
5.7 一份可以照着念的审阅清单
写完一个 Makefile,不要立刻交付。按下面二十条过一遍,通常能当场发现三五个问题。清单按“会不会出错”排序,越靠前越致命。
关于判据的四条。
一,所有产物都有明确的落盘路径,没有“跑完什么都不生成”的规则。若有,必须改成 stamp。
二,没有任何目标被误声明为 .PHONY。逐个检查 .PHONY 那一行,确认每个名字都不是文件名。
三,目录一律用顺序依赖 |,没有把目录写进普通依赖。
四,$(wildcard) 的结果在空列表时会静默通过,关键位置有 $(error) 断言。
关于依赖的四条。
五,每条规则读的每个文件,都出现在依赖列表里。判断方法是逐个读命令,把 cat、cp、sed 涉及的文件名圈出来,和依赖对比。
六,shell 里用 * 展开的文件,在 Make 侧也要有对应依赖,否则 Make 看不见新增文件。
七,没有环形依赖。用 make -n 跑一遍,看到 Circular ... dependency dropped 就要处理。
八,并行之前,串行跑两次,第二次必须全部 up to date。
关于命令的四条。
九,每条 recipe 的命令行都以制表符开头,没有空格混入。
十,跨行需要共享状态的命令,用 && 连成一行,或者显式声明 .ONESHELL。
十一,shell 变量一律写成 $$。
十二,失败可接受的命令加了 - 前缀,且这些命令确实是“失败也没关系”的那种。
关于工程化的四条。
十三,文件顶部有 all,且有显式的 .DEFAULT_GOAL。
十四,有 help 目标,且是伪目标。
十五,有 .DELETE_ON_ERROR。
十六,路径、并行度、外部命令名都集中在顶部,正文里没有硬编码的绝对路径。
关于可观测的四条。
十七,每个目标有一行输出说明它在做什么,失败时能定位到步骤。
十八,构建日志可重定向,且不依赖终端颜色或交互。
十九,make -n 的输出人能读懂,没有一堆无意义的中间命令。
二十,清理目标 clean 是幂等的,重复执行不报错。
这二十条不用每次都全查。日常改动后查前八条,交付或交接前查全二十条。
5.8 八个高频问题
问:为什么我改了 Makefile,产物没有重建?
因为 Makefile 自己不在依赖列表里。产物只依赖源文件。如果 Makefile 里那条命令变了,Make 无从知道。解决办法是把 Makefile 也写成依赖:
out/book.md: $(SRCS) Makefile | out
问:为什么 make 什么都不做,但我知道产物是错的?
目标的时间戳比依赖新。Make 只信时间,不信内容。删掉产物重来,或者 make -B。
问:$(shell ...) 会不会每次都被执行?
取决于变量类型。用 = 定义的递归变量,每次引用都执行一次;用 := 只执行一次。所以带副作用的 $(shell) 一律配 :=。
问:并行会不会让两个目标同时写一个文件?
如果依赖声明正确,不会。如果漏了依赖,会,而且产物会损坏。所以 -j 的前提是串行两次全绿。
问:能不能在 recipe 里用 bash 的数组和 [[ ]]?
默认不能,因为 SHELL 是 /bin/sh。要么显式设 SHELL := /bin/bash,要么把复杂逻辑挪进一个 .sh 文件里,recipe 只负责调用它。后者更推荐,因为脚本可以单独测试。
问:多个 Makefile 怎么组织?
主 Makefile 负责编排,子目录各有一个,用 $(MAKE) -C 子目录 递归。关键是用 $(MAKE) 而不是 make,否则选项不会继承。
问:怎么让某个目标每次都跑?
声明为 .PHONY。但先想清楚:真的每次都跑吗?如果它其实有产物,你正在放弃增量。
问:Makefile 里的中文路径有问题吗?
一般没问题,但两个地方要加引号:cat '$$f'、上传时的 form 参数。另外 $(wildcard) 对中文文件名支持没问题,但某些旧版 Make 在模式匹配中文时会有字节级问题,遇到就往上升版本。
这八个问题覆盖了我在真实项目里被问得最多的场景。如果还有疑问,最快的路是 make -p 2>/dev/null | grep 关键字,它比任何文档都准确,因为它显示的是你这个 Makefile 在你这台机器上的真实展开结果。
5.9 一份可以直接抄走的起手模板
任何新项目开始时,把这三十行贴进 Makefile,再往中间填规则。它已经包含本书讲的全部工程化要点。
# ── 基本设置 ──────────────────────────────
.DEFAULT_GOAL := all
.DELETE_ON_ERROR:
MAKEFLAGS += -r
JOBS ?= 4
ROOT := $(CURDIR)
OUTDIR := $(ROOT)/out
STAMPS := $(ROOT)/.stamps
.PHONY: all clean help
# ── 产物清单 ──────────────────────────────
SRCS := $(sort $(wildcard $(ROOT)/src/*.md))
OUTS := $(patsubst $(ROOT)/src/%.md,$(OUTDIR)/%.html,$(SRCS))
all: $(OUTS)
# ── 通用规则 ──────────────────────────────
$(OUTDIR)/%.html: $(ROOT)/src/%.md | $(OUTDIR)
@echo "转换 $< -> $@"
@python3 tools/convert.py '$<' '$@.tmp' && mv '$@.tmp' '$@'
$(OUTDIR) $(STAMPS):
@mkdir -p $@
# ── 维护目标 ──────────────────────────────
clean:
@rm -rf $(OUTDIR)
@echo "已清理 $(OUTDIR)"
help:
@echo 'make 构建全部产物'
@echo 'make clean 清理 out/'
@echo 'make JOBS=2 降低并行度'
这段模板值得注意的地方有六处,每一处都对应前面讲过的一个坑:-r 关掉内置规则、JOBS ?= 4 而不是 $(nproc)、| $(OUTDIR) 用顺序依赖、'$@.tmp' && mv 保证原子性、.DELETE_ON_ERROR 清理半成品、help 作为人类入口。
真正要改的只有 SRCS 和那条模式规则。这也是模板的价值:把“容易忘的规范”固化成默认值,你只需要想业务逻辑。
5.10 结语:把 Make 当台账,而不是编译器
回到本书的起点。一台每十到六十分钟被清空的沙箱,一个必须干完的四十分钟的活。让这件事成立的,不是某个高级技巧,而是一个很朴素的约定:把“做过”写成磁盘上的事实。
Make 用文件时间戳完成这个约定。它的语法确实有历史包袱,制表符、自动变量、四种赋值符号都谈不上优雅。但它的核心抽象——目标、依赖、判据——极少出错,因为它不做推测。
所以我在实践中给 Make 的定位是“任务台账”,而不是“编译工具”。台账的特点是:每一笔都可查、可重算、可重放。当你用这个视角去看 Makefile,很多设计选择会突然变得合理:为什么它只信时间戳(因为台账只信凭证)、为什么它要求明确依赖(因为台账不能有隐含分录)、为什么它天然抗中断(因为账本在磁盘上)。
这个视角也会告诉你它的边界:台账管不了长驻服务,管不了事务回滚,管不了每天变化的清单。凡是想让它超出这些边界的人,最后都会写出一堆 hate-make 的文章。而知道边界的人,会把它用在最合适的地方——那些“清单稳定、产物明确、可以随便重跑”的活,而这恰好覆盖了日常自动化的一大半。
5.11 三个反模式案例
最后看三个真实的反模式。它们都很短,但每一个都曾在生产里造成过实际的损失。
反模式一:万能目标。
.PHONY: build
build:
./build.sh
看起来简洁,实质是把所有逻辑塞进脚本,Make 退化成一个命令别名。你失去了依赖图、增量、并行,只剩一个“记得敲 make build”的提醒。更好的做法是把 build.sh 里的步骤拆成规则,让每一步都有产物。
有一条例外:当脚本已经成熟、测试完备、且步骤之间确实没有可复用的中间产物时,保留它是对的。但那时你应该把它当“外部工具”调用,而不是假装它是 Make 规则。
反模式二:用 Make 管环境。
setup:
apt-get install -y python3 pandoc ffmpeg
pip install -r requirements.txt
这行命令有两个问题。第一,它不幂等,重跑会重复安装(虽然多数包管理器能容忍)。第二,它把“环境状态”这件不可靠的事写进了构建流程,导致每次全新环境都要联网、都要几分钟。
正确做法是把环境声明成外部依赖(Dockerfile、镜像、文档),Make 只负责在环境就绪后干活。判据很简单:如果这条命令依赖网络和外部服务的可用性,它就不该在构建路径上。
反模式三:把日志当产物。
out/report.html: logs/raw.log
grep ERROR logs/raw.log > $@
看起来有产物、有依赖,很规范。但依赖的是一个会持续追加的日志文件,意味着这个目标每次都会重做,而且产物内容随时间变化,无法复现。
这类“输入本身是流动的”场景,需要先固化输入:把日志切片、加上时间戳、当成不可变文件处理。不可复现的构建,等于没有构建。
三个反模式的共同点是:它们都能跑,都能“看起来完成任务”。工程里最难发现的往往并非坏掉的代码,而是看起来正常的坏代码。
5.12 逐行审阅:一份 thirty 行 Makefile 的七处问题
最后一节做一次实战审阅。下面这份 Makefile 是一个“能跑但不敢改”的典型,逐行挑问题。
all: build test
build:
cd src
gcc -o app *.c
test: build
./app --test
clean:
rm -rf src/app
它确实能跑:make 会编译并跑测试,make clean 会删掉二进制。但这段代码里有七处会在规模变大后咬人。
问题一:cd src 是空操作。 第二行 cd src 在子 shell 里生效,子 shell 退出后回到原目录;第三行的 gcc 在项目根目录执行,找不到 *.c。诡异之处在于:如果项目根目录恰好也有 .c 文件,它会编译错的文件;如果没有任何 .c,gcc 报错。这属于“看似正确”的经典错误。
修复:cd src && gcc -o app *.c。
问题二:all 依赖 build,test 也依赖 build,但 build 是伪目标语义。 它没有产物文件(产物在 src/app),所以 Make 永远不会认为它“已完成”。结果每次 make 都重编。小项目无所谓,三分钟编译的项目就是每天几小时的浪费。
修复:把产物声明成真正的目标。
src/app: $(wildcard src/*.c)
$(CC) -o $@ $^
问题三:没有 .PHONY。 如果哪天你建了一个叫 test 的目录(很常见),make test 会输出 up to date 然后什么都不做。
修复:.PHONY: all clean test。
问题四:*.c 是 shell 展开,Make 看不见。 新增一个源文件时,Make 不知道依赖变了,如果目标已存在就会跳过重编。这是“静默过时”的典型。
修复:用 $(wildcard src/*.c) 把依赖交给 Make 管理。
问题五:clean 的路径写错了一半。 它删 src/app,但 cd src 那个 bug 意味着实际产物可能在别处。
修复:让产物路径只有一个来源。
BIN := bin/app
clean:
@rm -rf $(dir $(BIN))
问题六:没有 .DELETE_ON_ERROR。 如果编译中途失败,会留下一个残缺的 app,下次 make 看到它存在,可能直接跳过。这个坑最隐蔽,因为失败通常发生在最不该跳过的时候。
修复:文件顶部加 .DELETE_ON_ERROR:。
问题七:test 依赖 build,但并行下可能出问题。 如果将来有人加 -j4,test 与 build 的关系还算安全(显式依赖),但任何“顺手加的”目标都可能与它们竞争同一个产物。缺 .NOTPARALLEL 或顺序约束是隐患。
修复:给“必须串行”的部分加顺序依赖,而不是指望运气。
审阅后的版本:
.PHONY: all test clean
.DELETE_ON_ERROR:
.DEFAULT_GOAL := all
CC := gcc
BIN := bin/app
SRC := $(wildcard src/*.c)
all: test
$(BIN): $(SRC) | bin
$(CC) -o $@.tmp $^ && mv $@.tmp $@
bin:
@mkdir -p $@
test: $(BIN)
./$(BIN) --test
clean:
@rm -rf bin
行数从十一行变成十七行,多了六行,换来的是:增量正确、路径正确、失败可恢复、并行安全。
这个对比值得记住:Makefile 的篇幅增加,往往换来的是可维护性的指数级提升。 反过来,一个十几行的 Makefile 如果能干很多事,通常意味着它在某处依赖了运气。
审阅别人的 Makefile 时,按这个顺序看效率最高:先看有没有 .PHONY(漏了会导致静默跳过),再看依赖是不是手写清单(漏依赖会导致产物过时),再看 cd 有没有跨行(错误最隐蔽),最后看有没有 .DELETE_ON_ERROR(失败后才暴露出问题)。
5.13 给新手的五条建议
一,先抄模板,再改业务。第五章第九节那份模板已经把该有的规范都写好了,你只需要替换 SRCS 和模式规则。
二,第一版不要写变量。先把路径写死,跑通,再把重复出现的字符串提成变量。反过来做,你会花很多时间调试“变量展开成空”。
三,每个 Makefile 只做一件事。构建和部署分开、文档和数据分开。一个文件干三件事,最终会变成谁都不敢动的黑箱。
四,把报错读完整。Make 的报错信息质量其实很高,No rule to make target 会告诉你缺谁、missing separator 会告诉你行号。大多数人只是不读。
五,每写一次就删一次产物重跑。这是验证“断点续跑”最便宜的方式,也是发现漏依赖最直接的方式。
最后一个习惯:给每个 Makefile 顶部写三行注释,说明它做什么、产物落在哪、怎么清理。这三行不需要维护,但能在半年后省下一整次反向工程。工程文件的可读性,最先毁于“没人写下来”。
关于审阅频率,给一个具体建议:每次 Makefile 改动超过二十行,就按第七节的清单过一遍前八条。改动越大,越值得花那五分钟。改动很小的时候(比如改个路径变量),跑一次 make -n 看一眼就够了。
一本书能帮你到的地方到此为止。接下来最有效的学习方式并非重读,而是把手上那件“每次都要手动跑”的活改写成 Makefile,然后故意中途按一次 Ctrl+C,看它能不能自己接上。
最后再强调一次工具选择的底线:不要为了用 Make 而用 Make。它的价值只在“有产物、要增量”这两件事同时成立时出现,其余场景里它只是增加了阅读成本。
如果你只带走一句话,希望是这句:判据对了,其余都是装修。
附录A Make 特殊符号速查表
适用版本:GNU Make 4.3(本机实测版本命令输出
GNU Make 4.3)
标注「4.x+」的条目需要相应最低版本;未标注的在 3.81 以上即可用。
本文所有示例均在本机执行验证。
make 的符号按功能分七类:结构符号、赋值符号、命令前缀、展开符号、自动变量、特殊目标、指令与特殊变量。下面逐类给出表格与可跑示例。
——-
一、结构符号(规则骨架)
| 符号 | 名称 | 作用 | 示例 |
|——-|——-|——-|——-|
| : | 冒号 | 目标 : 依赖 | out/a.html: src/a.md |
| \| | 竖线 | 顺序依赖,只保证存在,不参与时间戳比较 | out/a.html: src/a.md \| out |
| &: | 分组目标(4.3+) | 一次命令产出多个文件 | a.txt b.txt &: src.md |
| ; | 分号 | 把第一条命令内联在规则行 | out/a.html: src/a.md ; cp $< $@ |
| TAB | 制表符 | 命令行的唯一合法缩进 | 见下文 .RECIPEPREFIX |
| \ | 反斜杠 | 行继续,后接的换行被吞掉 | a: b \ ↵ c |
| # | 井号 | 注释,到行尾 | # 这是一行注释 |
| % | 百分号 | Make 的模式通配,用于目标与依赖 | out/%.html: src/%.md |
| * | 星号 | 不是 Make 的符号,由 shell 展开 | cat *.md |
关于 |(顺序依赖)的实测
把目录写成普通依赖会导致每次重做,因为目录的修改时间会随内部文件变化:
# 错误写法:目录时间戳变化触发重做
out/book.md: chapters/ch01.md out/report.html # ← out 在这里是普通依赖
正确写法是放到竖线右边:
$(OUT)/book.md: $(SRCS) | $(OUT)
@for f in $^; do cat "$$f"; printf '\n\n'; done > $@
$^ 不包含顺序依赖,所以命令里不会多出一个目录参数。
关于 &:(分组目标)的实测
a.txt b.txt &: in.txt
@touch a.txt b.txt
&: 的语义是“这条规则只跑一次,同时产出全部目标”。与之对比,普通多目标规则 a.txt b.txt: in.txt 在 -j4 下会并行执行两遍,各带不同的 $@——如果命令本身会生成多个文件,就会互相踩踏。
本机验证:
$ make -s a.txt && echo "&: OK"
&: OK
关于 .RECIPEPREFIX(替换制表符)
Make 允许把命令前缀字符从 TAB 换成别的符号:
.RECIPEPREFIX = >
all:
>@echo "RECIPEPREFIX 生效"
⚠️ 两个必须知道的约束:
- 一旦设定,全文件所有 recipe 都必须用该字符,混用 TAB 会立刻报
missing separator. Stop.(实测)。 - 它只影响“命令行的前缀”,不影响规则行的缩进写法。
实测输出:
$ make -s
RECIPEPREFIX 生效
——-
二、赋值符号(六种)
| 符号 | 展开时机 | 含义 | 典型用途 |
|——-|——-|——-|——-|
| = | 延迟(递归) | 每次引用都重新展开 | 值依赖后续定义的变量 |
| := | 立即 | 赋值时展开一次 | 推荐默认 |
| ::= | 立即 | 与 := 等价,POSIX 风格 | 需要 POSIX 兼容时 |
| ?= | 立即 | 仅当变量未定义才赋值 | 提供可被覆盖的默认值 |
| += | 继承原类型 | 追加,不改原赋值方式 | 追加选项 |
| != | 立即 | 执行命令,取标准输出 | 替代 $(shell ...) |
对照示例(可跑)
A = $(B)
B = 后定义
C := $(B)
X ?= 默认值
Y ?= 不会生效
Y := 已有值
OPT := -Wall
OPT += -g
show:
@echo "A=$(A)" # 后定义 ← 延迟展开
@echo "C=$(C)" # 空 ← 立即展开,B 尚未定义
@echo "X=$(X)" # 默认值
@echo "OPT=$(OPT)" # -Wall -g
修饰词
| 修饰词 | 作用 | 示例 |
|——-|——-|——-|
| override | 防止被命令行参数覆盖 | override CFLAGS += -g |
| export | 使变量进入子进程环境 | export TITLE := 我的书 |
| unexport | 取消导出 | unexport TMP |
| private(3.82+) | 不继承给目标级子作用域 | private V := x |
目标级变量(3.82+,实测有效)
tgt: V := 局部
tgt:
@echo "target-specific: $(V)"
$ make tgt
target-specific: 局部
同一个变量名可以为不同目标设不同值,这是按目标切换参数的标准做法。
——-
三、命令前缀(三个)
| 前缀 | 作用 | 示例 |
|——-|——-|——-|
| @ | 不回显该行命令本身 | @mkdir -p $(OUT) |
| - | 忽略该行失败,继续执行 | -rm -rf $(OUT) |
| + | 即使在 -n 干跑模式下也真执行 | +$(MAKE) -C sub |
三者可组合:@-rm -f tmp.txt。
一条经验:@ 用于噪音命令(mkdir、echo),但不要滥用——构建变黑箱后,排查成本会上升。
——-
四、展开符号
| 写法 | 含义 | 说明 |
|——-|——-|——-|
| $(VAR) | 引用变量 | 最常见 |
| ${VAR} | 引用变量 | 与上面完全等价 |
| $$ | 转义为单个 $ | 交给 shell 使用 |
| $X | 单字符变量 | 自动变量、$@、$< 等 |
| $(func arg1,arg2) | 函数调用 | 参数用逗号分隔,函数名与参数间用空格 |
$X 与 $$X 的关键区别(新手第一大坑)
show:
@echo "$$HOME" # 正确:输出 shell 的 HOME
@echo "$HOME" # 错误:被 Make 解释为变量 H + 字符串 OME
补偿规律:凡是 shell 变量、shell 位置参数、awk 的 $1,一律写双美元。
函数调用语法
NAMES := $(basename $(notdir $(wildcard src/*.md)))
COUNT := $(words $(NAMES))
- 函数名和第一个参数之间是空格,不是逗号
- 参数之间是逗号
- 嵌套时靠括号配对,不靠引号
——-
五、自动变量(7 个正体 + D/F 变体)
以 out/x.txt: in.txt 为例(本机实测):
| 变量 | 含义 | 实测值 |
|——-|——-|——-|
| $@ | 当前目标 | out/x.txt |
| $< | 第一个依赖 | in.txt |
| $^ | 全部依赖,去重 | in.txt |
| $+ | 全部依赖,不去重 | in.txt |
| $? | 比目标新的依赖 | in.txt |
| $* | 模式规则中的“词干” | 仅模式规则有效 |
| $| | 顺序依赖列表(竖线右边) | — |
D / F 变体(目录 / 文件名)
| 写法 | 含义 |
|——-|——-|
| $(@D) | 目标的目录部分 |
| $(@F) | 目标的文件名部分 |
| $(*D)、$(*F) | $* 的目录 / 文件名 |
| $(<D)、$(<F) | $< 的目录 / 文件名 |
| $(^D)、$(^F) | $^ 各级的目录 / 文件名列表 |
实测:
$ echo '目标=$@ 目录=$(@D) 文件=$(@F) 变化=$?'
目标=out/x.txt 目录=out 文件=x.txt 变化=in.txt
$(@D) 最常见的用法是免写冗余路径:
out/sub/%.html: src/%.md
@mkdir -p $(@D)
cp $< $@
$* 的边界
$* 只在模式规则中有意义;在显式规则里它通常为空。目标是 out/sub/a.html、模式为 out/%.html 时,$* 是 sub/a(包含子目录),不是 a。
——-
六、特殊目标(点开头)
| 目标 | 作用 | 备注 |
|——-|——-|——-|
| .PHONY | 声明伪目标,每次执行 | 声明了真文件目标会永久失去增量 |
| .DEFAULT_GOAL | 显式指定默认目标 | 比“第一条规则”可靠 |
| .DELETE_ON_ERROR | 规则失败时删除目标 | 建议无条件开启 |
| .ONESHELL(4.0+) | 整段 recipe 用一个 shell | 让 cd 跨行生效 |
| .NOTPARALLEL | 指定目标(或整体)串行 | -j 下用于保护有副作用的步骤 |
| .SECONDARY | 中间文件保留 | 避免被当中间产物自动删 |
| .INTERMEDIATE | 声明中间文件 | 构建结束后默认删除 |
| .PRECIOUS | 中断/失败也不删 | 保护耗时产物 |
| .SILENT | 整体不回显命令 | 等价于给所有命令加 @ |
| .IGNORE | 整体忽略错误 | 等价于给所有命令加 - |
| .SECONDEXPANSION | 开启二次展开 | 依赖列表本身需动态计算时用 |
| .SUFFIXES | 后缀规则列表 | 清空(.SUFFIXES:)即禁用后缀规则 |
| .WAIT(4.4+) | 并行中的屏障 | 本机 4.3 不支持 |
.PHONY 的正确与错误用法
.PHONY: all clean help test # ✅ 动作类目标
.PHONY: out/book.md # ❌ 这是真文件,加伪目标等于放弃增量
.ONESHELL 的副作用
.ONESHELL:
install:
cd out
zip -q book.zip book.md # 现在能正确在 out 里执行
副作用:@ 和 - 前缀只对整段的第一行生效,后续行无法单独加前缀。混用 .ONESHELL 的 Makefile 是团队维护的隐形负担。
.DELETE_ON_ERROR 解决什么
默认情况下,一条规则跑到一半失败,半成品会留在磁盘上;下次 make 看到产物存在且比依赖新,会误判为“已完成”。加上这一行即可:
.DELETE_ON_ERROR:
——-
七、指令与特殊变量
指令
| 指令 | 作用 |
|——-|——-|
| include file | 包含文件,不存在报错 |
| -include file / sinclude file | 包含文件,不存在不报错 |
| define … endef | 定义多行变量 |
| ifeq / ifneq / ifdef / ifndef | 条件开始 |
| else / else ifeq … | 条件分支 |
| endif | 条件结束 |
| vpath %.md src:docs | 指定模式搜索路径 |
| export / unexport | 环境变量进出口 |
特殊变量(内建,本机实测共 227 个变量)
| 变量 | 含义 |
|——-|——-|
| $(MAKE) | make 自身路径,递归调用必须用它 |
| $(MAKEFLAGS) | 当前选项串,自动传给子 make |
| $(MAKECMDGOALS) | 用户指定的目标列表 |
| $(CURDIR) | 当前工作目录(不执行命令) |
| $(SHELL) | recipe 使用的 shell,默认 /bin/sh |
| $(.VARIABLES) | 所有已定义变量名(排查神器) |
| $(.RECIPEPREFIX) | 当前命令前缀字符 |
| $(.DEFAULT_GOAL) | 当前默认目标 |
实测:
$ make -s show
变量总数: 227
递归调用为什么必须写 $(MAKE)
all:
@$(MAKE) -C sub # ✅ -j4、-n、-k 会通过 MAKEFLAGS 继承
@make -C sub # ❌ 选项丢失,子目录变串行
——-
八、三组最容易混淆的符号
| 组 | 区别 | 判据 |
|——-|——-|——-|
| % vs * | % 是 Make 的模式(规则里);* 是 shell 通配(命令里) | 出现在规则行 → %;出现在命令里 → * |
| $(wildcard *.md) vs *.md | 前者解析期求值、Make 可见;后者执行期由 shell 展开、Make 不可见 | 要让 Make 感知新增文件 → 用 $(wildcard) |
| $X vs $$X | 前者是 Make 变量;后者才是 shell 变量 | 在 recipe 里给 shell 用 → $$ |
为什么 * 会导致“静默过时”
out/book.md: chapters/*.md # ❌ 依赖里写 shell 通配:Make 把它当字面量
cat chapters/*.md > $@
Make 不会展开依赖里的 *,它把 chapters/*.md 当成一个不存在的文件,于是 No rule to make target 或者干脆判据失效。正确写法:
SRCS := $(wildcard chapters/*.md)
out/book.md: $(SRCS) | out
cat $^ > $@
——-
九、速查命令
make --version # 确认是 GNU 版及版本号
make -n # 干跑,只看不执行(排查首选)
make -B # 无视时间戳,全量重做
make -p # 打印全部规则与变量(约数千行)
make -p 2>/dev/null | grep '^\.' # 只看特殊目标
make -p 2>/dev/null | grep -A3 '已怀疑的目标' # 看某目标的依赖来源
make --trace # 逐条解释"为什么执行"
make -d 2>&1 | grep -i pattern # 模式规则为何不匹配
make -j4 --output-sync=target # 并行且输出不交错
make -k # 出错也继续做其他目标
make -j1 # 排查并行竞态时降级
cat -A Makefile | head # 查行首是 ^I(制表符)还是空格
——-
十、一页速记
结构: : | &: ; TAB \ # %
赋值: = := ::= ?= += != (+ override / export / private)
前缀: @ - +
展开: $(x) ${x} $$ $X $(fn a,b)
自动: $@ $< $^ $+ $? $* $| + $(@D) $(@F)
特殊目标: .PHONY .DEFAULT_GOAL .DELETE_ON_ERROR .ONESHELL .NOTPARALLEL
.SECONDARY .INTERMEDIATE .PRECIOUS .SILENT .IGNORE
.SECONDEXPANSION .SUFFIXES (.WAIT 需 4.4+)
指令: include -include define/endef ifeq/ifneq/ifdef/ifndef/else/endif vpath
内建: MAKE MAKEFLAGS MAKECMDGOALS CURDIR SHELL .VARIABLES .RECIPEPREFIX
——-
本文档为《Make 极简入门:从一条规则到可断点续跑的流水线》附录 A。
验证环境:GNU Make 4.3 / x86_64-pc-linux-gnu / Ubuntu 24.04