
你的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会把中间的内容原样保存包括这个Tabeval解析时才能认出它是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生成的东西误当成普通规则去改。