你的Makefile里是不是也有这种情节:同一个编译规则复制粘贴了十几遍,只改一下模块名和文件路径?我承认我干过,而且是在一个模拟项目的三十多个子模块上干的,每次新增模块都要手动补规则、补依赖、补清理清单,漏一条就让你在深夜对着构建日志发呆。后来真正把GNU Makefile里的eval函数用起来,才找到治本的办法:把规则写成模板,让make自己在解析阶段批量生成。eval的定位很朴素——它接收一段文本,把这段文本当作makefile内容现场解析,配合define、call、foreach,就能实现自动生成规则。这篇文章把从最小例子到生产级坑点的完整路径走一遍,适合正在维护多模块构建、已经受够手写重复规则的人。
1. 从第30条重复规则说起:eval到底帮我省了什么
1.1 手工规则膨胀的典型现场
我维护过一个模拟项目的构建脚本,结构大概是这样的:十几个子模块,每个模块都要先编译成目标文件,再链接成可执行程序,还要有一堆按模块区分的测试入口。手工写下去,Makefile长这样:
build/alpha.o: src/alpha.c $(CC) $(CFLAGS) -c $< -o $@ build/alpha.bin: build/alpha.o $(CC) $(LDFLAGS) $^ -o $@ build/beta.o: src/beta.c $(CC) $(CFLAGS) -c $< -o $@ build/beta.bin: build/beta.o $(CC) $(LDFLAGS) $^ -o $@模块一多,文件就变成"规则海洋"。问题不在于多写了几行,而在于每次改构建策略都要同步改几十处:某天我把CFLAGS统一加了一个-Werror,就得保证所有编译规则都改到;某个新模块忘记写目录依赖,第一次构建必挂。最要命的是,这类文件没法review出"结构",大家只会逐行看有没有拼错。
复制粘贴还有一个隐形成本:它让"规则的一致性"变成了一种人工约定。只要有一次只改了A模块没改B模块,后续的构建产物就开始悄悄不一致。我后来统计过,手工维护阶段,这类"漏改"导致的构建问题占了一半以上。
1.2 eval在make里的真正位置
简单说,make在执行recipe之前,要先花一段时间把整个makefile"读"进来:解析变量、规则、依赖,构建出一张依赖图,这个过程叫读入阶段。eval就是在读入阶段生效的——它把一段文本现场变成makefile文本,再现场解析。你可以把它理解为在makefile内部装了一个"宏处理器"。
这跟函数式编程里的"代码生成代码"很像:eval先把模板里的占位符换成真实值,生成一段makefile片段,然后这段片段和手写的规则没有任何区别,会被make当成普通规则加入依赖图。所以"生成的规则"和"手写的规则"是平级的,后续的依赖解析、并行构建、make -n试运行,全都一视同仁。
需要注意,这是GNU Make的功能特性。BSD make、nmake这类工具要么没有对应的eval,要么语法完全不同。如果项目明确要求跨多种make工具移植,用eval之前要想清楚,或者评估能否接受"仅支持GNU Make"这个前提。
1.3 什么项目值得上eval:两道判断题
我一般用两个问题来判断:
- 这些规则是不是同构的?也就是除了名字、路径、参数不同,结构完全一样。
- 数量是不是大到手动维护会出错?一般来说超过五六个同构目标,就开始值得了。
如果目标少于五个,而且每个规则差异都很大,比如有的要调内置工具、有的要跑外部脚本、有的要特殊后处理,那强行套eval反而把简单问题复杂化。eval最适合的场景是"大量同构目标 + 少量差异化参数",这时候它才能把规则的重复度压到最低,让新增模块变成一行列表项。
团队对GNU Make的接受度也要考虑。如果整个构建体系里已经大量使用了$(call)、$(foreach)这类函数式语法,那么引入eval几乎没有额外学习成本。如果团队还停留在"makefile就是写规则"的认知上,那这篇文章后面的内容反而是最好的入门教材。
2. 先跑通再理解:define、call与make的两遍解析
2.1 最小可复现例子
先从最简单的例子跑通。下面这个makefile定义了一个模板,生成两个目标:
define greeting_rule say_$(1): @echo "hello, $$@" endef $(eval $(call greeting_rule,one)) $(eval $(call greeting_rule,two))这里有个关键细节:say_$(1):下面那行recipe,缩进必须是真实的Tab字符,不是空格。define/endef会把中间的内容原样保存,包括这个Tab,eval解析时才能认出它是recipe。运行make say_one,你应该看到:
hello, say_one这短短几行里其实串起了四个概念:define定义多行变量;$(1)是模板参数占位符;$(call ...)把参数传入模板;$(eval ...)把展开后的文本当作makefile内容解析。整条链路就是"模板 -> 替换 -> 解析"。
我把这个例子拆开说,是因为很多人一上来就写复杂模板,结果编译报错也不知道是语法问题还是转义问题。先把最小链路跑通,再往上加东西,排查范围会小很多。
2.2 两遍解析:读入期与执行期
理解eval,必须理解make处理makefile的两遍过程。
第一遍是读入期。make逐行读取makefile,解析变量赋值、规则定义、依赖关系,最终形成一张完整的依赖图。$(eval ...)调用就发生在这一遍:它边生成文本边解析,相当于在读入过程中不断往依赖图里追加内容。
第二遍是执行期。make拿到依赖图之后,检查哪些目标过期,然后按依赖顺序执行recipe。注意,recipe里的变量不是读入期就展开的,而是延迟到执行期、每一条shell命令执行前才展开。这就是"recipe延迟展开"机制。
这两遍分开的代价是:eval的参数本身会被make先展开一次。写$(eval $(call xxx))的时候,make会先把$(call xxx)展开成一段makefile文本,然后才去解析这段文本。所以模板里凡是想"原样留给执行期"的变量引用,都必须多写一个$。这直接引出下面这个最容易被绕晕的转义问题。
2.3 立即展开、延迟展开与那对$$
我见过太多人在eval模板里被$$折磨。其实规则就一条:eval参数展开时,$()会立刻取值;$$()经过展开后蜕变成$(),原样写进解析结果为recipe,留到执行期再展开。
| 在模板里写 | eval展开后存入数据库 | 最终执行效果 |
|---|---|---|
$(CFLAGS) | CFLAGS的当前值 | 生成时快照,后续改动不影响 |
$$(CFLAGS) | 文本$(CFLAGS) | 执行recipe时才取CFLAGS的值 |
$$@ | $@ | 执行时自动变量:目标名 |
$@ | 空字符串 | 读入期没有目标上下文,直接丢掉 |
最后一行的坑最典型:$@是自动变量,只有执行recipe时才有意义。在eval参数展开阶段,make根本不在任何规则上下文里,$@就是空串。所以模板里的自动变量必须写成$$@、$$<、$$^。我的经验法则是:模板里所有希望最终在recipe里展开的东西,一律写成$$开头,宁可多转义,不要少转义。
3. 实战:从源文件列表批量生成编译规则
3.1 foreach + call + eval三连的完整骨架
现在把前面几块拼起来,做一个真正能用的编译规则生成器。假设有三个模块alpha、beta、gamma,每个模块有一个源文件:
APPS := alpha beta gamma define app_rule build/$(1).o: src/$(1).c | build_dir $$(CC) $$(CFLAGS) -c $$< -o $$@ build/$(1).bin: build/$(1).o $$(CC) $$(LDFLAGS) $$^ -o $$@ endef $(foreach app,$(APPS),$(eval $(call app_rule,$(app)))) build_dir: mkdir -p build .PHONY: build_dir$(foreach app,$(APPS),...)的语义是:遍历APPS列表,每轮把当前值赋给变量app,并展开循环体文本。展开体里调用了$(call app_rule,$(app)),于是每个模块名都会被代入模板,生成两到三行规则文本,最后交给$(eval)解析。
这么一来,APPS列表成了唯一的事实来源。新增一个模块,只需要在列表里加一行,编译规则、链接规则、目录依赖全部自动生成。我把这个模式叫作"三连":foreach负责遍历,call负责传参,eval负责落地。
| build_dir是order-only前置依赖,意思是"构建前必须先执行build_dir的recipe,但它的时间戳变化不触发本目标重编"。目录这种资源,时间戳每构建一次都会变,如果写成普通依赖,会导致所有目标每次都被判为过时,那是纯粹浪费。这个细节别忽略。
3.2 把差异化参数收进模板:每模块一套编译选项
同构规则最常见的变体是"编译参数不一样"。比如alpha要开-O2和宏定义,beta要开-g做调试。手工阶段,这类差异是靠复制粘贴不同CFLAGS行实现的;用eval,可以把参数做成"每模块一个变量",模板里按目标名拼变量名:
APPS := alpha beta CFLAGS_alpha := -O2 -DALPHA CFLAGS_beta := -O0 -g -Wall define app_rule obj/$(1).o: src/$(1).c $$(CC) $$(CFLAGS_$(1)) -c $$< -o $$@ endef $(foreach app,$(APPS),$(eval $(call app_rule,$(app))))注意$$(CFLAGS_$(1))的处理顺序:$(call)先把$(1)替换成模块名,比如alpha,模板就变成$$(CFLAGS_alpha);eval参数展开时,$$蜕变成$,存进数据库的是$(CFLAGS_alpha);等到执行recipe时,才真正去读CFLAGS_alpha变量的值。所以这个模板既捕获了"每模块不同参数"的差异,又保持了"参数可以后定义"的灵活性——CFLAGS_alpha放在foreach前还是后,都不影响最终结果。
这套写法比"每个模块复制一行CFLAGS"干净得多。以后想看某模块用了什么编译选项,直接看变量定义区;想统一加选项,改一处全局的也没问题。
3.3 顺手生成目录规则、clean入口与all收口
eval还有一个经常被忽略的能力:它生成的文本里可以包含普通的变量赋值语句。也就是说,eval不仅能生成规则,还能在生成的同一次解析中执行TARGETS += ...这类赋值。利用这一点,我们可以让模板自动收集自己生成的目标:
TARGETS := define app_rule bin/$(1): obj/$(1).o $$(CC) $$(LDFLAGS) $$< -o $$@ obj/$(1).o: src/$(1).c $$(CC) $$(CFLAGS) -c $$< -o $$@ TARGETS += bin/$(1) endef $(foreach app,$(APPS),$(eval $(call app_rule,$(app)))) all: $(TARGETS)这里有一个极其关键的顺序问题:all: $(TARGETS)这一行必须放在foreach之后。原因在于,make在解析all:规则时,会立刻展开$(TARGETS)来得到依赖列表(除非开了.SECONDEXPANSION)。如果all写在foreach前面,那一刻TARGETS还是空的,等于定义了all:没有任何依赖。这跟"变量要先用后算"是两回事,纯粹是解析顺序导致的。很多初学者在这里卡住,以为是eval没生效,其实是规则位置错了。
clean规则也可以顺手补上。既然所有构建产物都集中在build、obj、bin几个目录里,清理就很省事:
clean: rm -rf build obj bin整个构建脚本现在只剩三部分:模块清单、模板定义、foreach生成区。手工Makefile里那种"上百行规则海洋"彻底消失了。
4. 让生成的规则互相引用:元数据收集与自动变量细节
4.1 eval不仅能生成规则,还能生成声明和赋值
如果你以为eval只能生成目标: 依赖这种规则行,那就浪费了一半功力。eval生成的文本,可以是任何makefile合法内容:变量赋值、伪目标声明、include指令,甚至另一个$(eval)的调用文本。
比如给每个模块生成一个自检入口:
define app_rule .PHONY: run_$(1) run_$(1): bin/$(1) ./bin/$(1) --self-check TARGETS += bin/$(1) endef模板里.PHONY: run_$(1)这一行,经过eval解析后会变成真实的伪目标声明,跟手写的.PHONY效果一致。你也可以在模板里给某个变量做累加,把"生成规则"和"收集规则信息"合在一次eval里完成。这就是元数据收集:规则本身进了依赖图,规则的"名字"也进了变量,供后续统一处理。
回到4.1开头那句"包括另一个eval的调用文本"——技术上嵌套eval可用,但我并不推荐。嵌套会让转义层数指数级上升,排查问题时会极其痛苦。如果你发现自己需要在一个模板里再套一层eval,先停下来想想,是不是可以用两个foreach、两个模板分阶段完成。
4.2 自动变量转义对照表与模式规则取舍
前面提过自动变量在模板里的转义问题,这里给一份完整对照表,方便你写模板时直接查:
| 自动变量 | 含义 | eval模板里的写法 |
|---|---|---|
$@ | 目标名 | $$@ |
$< | 第一个前置依赖 | $$< |
$^ | 所有前置依赖(去重) | $$^ |
$? | 比目标新的前置依赖 | $$? |
$* | 模式规则中匹配的部分 | $$* |
$(@D) | 目标所在目录 | $$(@D) |
$(@F) | 目标文件名部分 | $$(@F) |
$(<D) | 第一个前置依赖的目录 | $$(<D) |
$(<F) | 第一个前置依赖的文件名 | $$(<F) |
这里要提醒一句:eval不是万能的,也不是所有规则都该用eval生成。如果你的目标名有严格的字符串规律,比如所有源文件都走obj/%.o: src/%.c,直接写一条模式规则就够了,一行解决,根本不需要模板。eval真正不可替代的场景是:目标之间是离散的,每个目标还带有独立参数。比如按模块生成不同名字的二进制、按配置生成不同后缀的打包产物,这种"同构但不满足模式匹配"的情况,才是eval的主场。
4.3 多模块交叉依赖:生成规则之间如何协调
实际项目里模块之间很少完全独立,常有"A模块链接时依赖B模块生成的库"这种情况。eval生成规则会不会导致依赖顺序出问题?不会。读入期结束时,make已经把所有生成的规则都收进依赖图了,图的解析发生在执行期。所以在文件里,bin模板写在lib模板前面、还是lib模板写在bin模板前面,对依赖图的最终形态没有影响。
举例:
define lib_rule lib/$(1).a: obj/$(1).o $$(AR) rcs $$@ $$^ endef define bin_rule bin/$(1): lib/libcore.a $$(CC) $$(LDFLAGS) $$^ -o $$@ endef即使bin_rule的foreach写在lib_rule前面,make依然能正确解析出bin/demo依赖lib/libcore.a。真正需要注意的是命名纪律:如果两个不同模板生成了同一个目标名,make会报"target given more than once"之类错误。我的习惯是所有生成目标都用$(1)做前缀或子目录隔离,从源头杜绝目标名冲突。
5. 我在真实构建里踩过的坑与调试脚手架
5.1 规则静默消失:展开顺序的典型错误
eval最坑的地方在于,出错了不一定报错,而是"静默生成空规则"。我踩过最典型的几个:
- 模板里写
$(CFLAGS),但CFLAGS在foreach之后才定义,eval展开时取到空值,生成出来的recipe变成一条没有编译参数的命令,编译却照样成功,只是产物不对。修法是模板里统一写$$(CFLAGS),让取值延迟到执行期。 all: $(TARGETS)写在foreach之前,依赖列表展开为空,make只输出"Nothing to be done"。这个前面说过,位置决定生死。- 试图在recipe里调用
$(eval ...),想让make在每次构建时动态生成新规则。这是认知错误:recipe是执行期跑的,读入期早已结束,eval不会生效。如果你确实需要"每次构建时重新生成规则",正确做法是用$(file ...)把生成的makefile片段写到磁盘,再用include动态包含它。
第二条的排查经验是:遇到"目标没生成"或"命令行为不对",先怀疑展开顺序,再看转义;顺序问题的特征是有据可循(比如all放在前面),转义问题的特征是生成文本里多了空参数。
5.2 空格缩进与missing separator
这是eval用户最容易在复制代码时踩的雷:make要求recipe行以Tab开头,这个要求对eval生成的文本同样生效。如果你的编辑器默认把Tab转成四个空格,define块里的recipe行就会变成空格缩进,eval解析时报错信息通常是"missing separator"——它不知道这一行该当成规则、变量还是recipe。
更隐蔽的情况是,模板里有recipe行用了空格缩进,但前面恰好有一行规则定义,make会把recipe行解析成"游离的杂散文本",有时不报错,只是那条命令永远不执行。这种"不报错的错"最费时间。我的建议是:在编辑器里关掉"Tab转空格"功能,或者在define块写完后,用sed -n l这种命令检查一下Tab字符是否真的存在。
5.3 $(shell)与$(warning)在eval里的特殊行为
eval参数里如果出现$(shell ...),它会在每次make解析时真的执行一次shell命令。这意味着:
- 有副作用的shell命令(写文件、改系统状态)放进eval里,等于每次构建都重复触发副作用,而且可能因为并行、失败重试等原因产生诡异行为。
- 纯查询类命令(读版本号、获取当前时间)可以用,但也要意识到它拖慢了读入阶段。
比$(shell)更适合调试eval的是$(warning)和$(info)。这两个函数在展开时打印消息、返回空字符串,用法很直观。在模板里插一行$(warning now generating rule for $(1)),能在foreach循环时看到每次调用的参数;如果放在call之前或之后,还能观察文本生成的位置。
我在模板里调试时最喜欢的一句是:
define app_rule $(info [template] target=$(1)) ... endef这样每次$(call)展开模板,都会打印一次当前模块名,一眼就能确认循环有没有跑、参数传对没有。
5.4 调试脚手架:先info打印,再eval,再make -p
最后给一套我屡试不爽的调试流程。
第一步,把eval临时改成info,只看生成的文本,不解析:
# 调试时先只打印,不解析 $(foreach app,$(APPS),$(info ===>> $(call app_rule,$(app)))) # 确认文本没问题再换成: $(foreach app,$(APPS),$(eval $(call app_rule,$(app))))这一步能区分"模板生成错了"和"规则解析错了"。生成文本如果不对,后面的eval报什么错都先别管,先把模板和转义理顺。
第二步,用make -pR导出make的完整数据库。-p会把所有读入的规则打印出来,-R禁用内置规则减少噪音。在输出里grep你的目标名,比如make -pR | grep -A5 'build/alpha',就能看到eval生成的规则是否真的进了依赖图,依赖写没写对、recipe是什么,全都清清楚楚。
第三步,make -n做试运行,只看生成的shell命令不执行。这条很关键,因为规则进没进图是一回事,recipe展开成什么样子是另一回事。make -n会让你直观地看到$$(CFLAGS)到底展开成了什么,如果展开结果里有空参数或者$(残留,那基本可以断定是转义层数出了问题。
还有一点心理准备:eval相关的报错信息经常指向makefile:0或者一个不存在的行号,因为出错的内容来自运行时生成的文本,make无法回溯到模板的原始位置。所以功能越复杂的eval模板,越要保住命名纪律和区域注释,否则半年后你自己也看不懂那段生成代码。我现在的构建脚本里,规则生成区占了将近一半,新增模块只需要在模块清单里加一行,剩下的事全部交给eval。如果让我给一个最重要的习惯,那就是:模板文件名统一加前缀清晰标识,foreach生成区集中放在文件底部,并配上大段注释说明"这个区域只允许改模块清单和模板,不要在这里手写规则"——这样团队里其他人接手时,才不会把eval生成的东西误当成普通规则去改。