Makefile 构建防坑:用 .DELETE_ON_ERROR 清理失败目标
问题:失败构建留下半成品
Makefile 常把生成文件的命令直接写到目标上,例如编译器或代码生成器直接覆盖 build/app.txt。如果命令在中途失败,目标文件可能已经被创建或截断,且修改时间比依赖更新。下一次执行 make 时,make 只比较时间戳,可能认为目标已经是最新,从而跳过重建,留下一个内容不完整的文件。
仅靠删除目标并不能覆盖所有失败路径:命令可能在写了一半时退出,也可能在替换目标后才发现内容校验失败。更稳妥的做法是两层配合:生成时先写同目录临时文件,成功后再用 mv 原子替换;同时在 Makefile 中声明 .DELETE_ON_ERROR,让 make 在 recipe 失败且目标已被修改时删除目标。
最小示例
目录中先准备两个输入文件,其中一个包含 FAIL,用于触发失败。下面的 Makefile 可直接保存为 Makefile:
.DELETE_ON_ERROR:
.PHONY: all bad clean
all: build/ok.txt
bad: build/bad.txt
build:
mkdir -p $@
build/ok.txt: src/ok.in | build
@tmp=$@.tmp.$$$$; trap 'rm -f $tmp' EXIT; sed 's/^/generated: /' $< > $tmp; mv $tmp $@
build/bad.txt: src/bad.in | build
@tmp=$@.tmp.$$$$; trap 'rm -f $tmp' EXIT; cp $< $tmp; mv $tmp $@; if grep -q FAIL $@; then echo post-check failed >&2; exit 1; fi
clean:
rm -rf build
这里每个 recipe 行都是一条完整的 shell 命令。tmp=$@.tmp.$$$$ 经过 make 展开后,shell 看到的是 tmp=目标.tmp.$$,其中 $$ 是当前 shell 的进程号,用来降低并行构建时临时文件重名的概率。trap 在 shell 退出时清理临时文件;mv 成功后再做一次内容检查,检查失败就返回非零。
运行方法
在 Makefile 所在目录执行:
mkdir -p src
printf 'hello\n' > src/ok.in
printf 'FAIL\n' > src/bad.in
make all
make bad
ls build/bad.txt
make bad
预期行为是:make all 生成 build/ok.txt;make bad 在 mv 之后发现 FAIL 并失败,.DELETE_ON_ERROR 删除已经替换上去的 build/bad.txt;因此 ls build/bad.txt 应提示文件不存在;再次执行 make bad 时,make 不会认为目标是最新,而是重新执行生成命令。这样就把失败状态留给了下一次 make。
两层保护的分工
临时文件加 mv 解决的是“命令中途失败”的问题:目标要么保持旧版本,要么完全不被替换,不会出现只写了一半的目标。mv 在同一文件系统内是原子替换,所以临时文件应放在目标文件所在目录,例如 $@.tmp.$$,而不是放到 /tmp 后再跨文件系统移动。
.DELETE_ON_ERROR 解决的是“目标已经被替换,但 recipe 后续步骤失败”的问题。比如示例中的内容检查在 mv 之后执行,此时目标已经更新;make 发现 recipe 返回非零,并且目标已被修改,就会删除该目标。下一次构建不会因为时间戳而误判。
边界与限制
.DELETE_ON_ERROR只在 recipe 失败且目标已发生变化时删除目标;如果命令在创建目标之前就失败,通常没有目标可删,旧目标仍由依赖时间戳决定是否重建。.PRECIOUS可以阻止 make 删除指定目标;如果目标被声明为 precious,本示例的自动删除不会生效。- 该机制不能替代正确的依赖声明。如果输入内容变化但时间戳没有变化,make 仍可能不重建;应通过完整依赖或显式 phony 目标处理。
trap不能覆盖SIGKILL、断电等场景,临时文件可能残留;可以在clean中按明确模式清理,避免误删其他文件。- 示例假设路径不含空格。实际使用时建议给 shell 变量加引号,并注意
$@、$<的展开位置。 mv的原子性依赖源和目标在同一文件系统;跨文件系统时可能退化为复制再删除,不能保证原子。
- 点赞
- 收藏
- 关注作者
评论(0)