2026/10/19 8:32:48

GNU Make eval 函数实战:自动生成构建规则的全解析

GNU Make eval 函数实战:自动生成构建规则的全解析 我入行那年第一次在别人项目里看到一串$(eval ...)的时候整个人是懵的。明明写的是 Makefile看起来却像 Lisp一行函数把整个构建系统“变”了出来。后来花了两周时间把这套东西啃下来才意识到这玩意才是 GNU Make 真正的分水岭——会用eval的人和不会用eval的人写出来的构建系统根本是两个物种。这篇文章我想把这几年在实际项目里用eval函数自动生成规则的思路、代码和踩过的坑一次性说清楚。适合已经在写 Makefile、但感觉重复规则太多、想进一步提升抽象能力的人。你放心我会把机制讲明白也会把可直接抄的模板给你保证不是那种网上复制来复制去的玩具 demo。1. 为什么需要 evalMakefile 里的“元编程”缺口1.1 当模式规则解决不了问题时绝大多数人会先接触模式规则pattern rule像%.o: %.c这种写法确实能搞定“一种输入对应一种输出”的编译场景。但是真实项目里规则往往不是那么整齐的。我给你举一个实际例子。某个跨平台系统里有 40 多个子模块每个模块需要从.proto文件生成 C 源码再编译成静态库。生成的源文件路径、依赖的头文件、额外编译参数每个模块都不一样有的模块生成两个.cc文件有的生成四个有的需要额外的 protobuf 依赖路径有的需要在编译时额外定义某个宏。如果用模式规则你得为每一种“形状”单独写一条规则40 个模块写下来光规则就 200 行。更麻烦的是新增一个模块要在三个地方同步改内容漏一处就直接编译失败。我见过同事改模块列表的时候漏改了编译参数排查了一个下午。模式规则本质上是对“同构”任务做匹配。但现实世界的构建任务是半结构化的——共同骨架 少量差异。你需要的不是把相同规则复制四十遍而是写一次规则模板然后对每个模块填充不同的参数。1.2 eval 帮你把“规则”变成“数据”eval函数的本质是在 Makefile 的解析过程中把一段字符串当作 Makefile 语法来解析和执行。这意味着你可以在运行时动态地生成变量定义规则定义define函数定义目标专属变量赋值换句话说eval把 Makefile 变成了一个“可以写代码的代码”。你先把规则抽象成模板然后调用模板并传入不同参数Make 在解析到$(eval ...)的那一刻会把展开后的文本“变成”真正的规则。我最初使用时的直观感受是以前写规则是在“描述构建过程”用eval之后是在“定义规则应该怎么被生成”。这个思维转换很关键一旦转过来了处理大量相似规则就不慌了。1.3 与其他“类似手段”的对比很多人问$(foreach...)难道不是也能做到类似的事吗这里容易混淆我先理清foreach是做文本替换的它返回一段文本但不解析这段文本。eval是把文本当作 Makefile 代码来解析执行的。两者结合才能实现“遍历列表为每个元素生成真正的规则”。如果把构建规则比作做菜的话foreach是给你一份菜谱列表eval才是把菜谱真的做成一道道菜。光有菜谱列表厨房是不会自己开火的。2. eval 函数的工作机制与正确打开方式2.1 函数签名与基本行为eval的用法非常单一$(eval text)它接收一段文本把这段文本在“解析位置”展开并解析。一个我之前一直忽略的细节是eval完成后它的返回值是空字符串。所以你不能写$(eval ...)something这种试图把返回值跟别的内容拼接的代码那是无效的。eval执行的时机是它被展开的时刻。Makefile 是先读进来整个文件变成内部数据结构然后才开始执行构建目标的。所以如果$(eval ...)出现在 Makefile 顶层它会在解析阶段生效。如果出现在某一规则的配方recipe里它要等那条规则被真正执行时才展开。这个场景很少见但可以用来做非常 hacky 的动态生成。2.2 与 define / call / foreach 的组合拳因为eval的作用对象是文本最常见的搭配是define generate-rule $(1): $(2) echo build $$ from $$^ endef $(foreach target,foo bar,$(eval $(call generate-rule,$(target),$(target).c)))这段代码的展开过程我会在下一节详细讲。这里先强调一点在define块内部写配方时$$是给eval保留的转义符。我第一次写的时候就是漏了这个双美元符号变量成了空值查了很久很久。2.3 解析期展开 vs 执行期展开我画个对比表给你理解这个表基本就理解eval的本质了场景展开时机典型用途顶层$(eval ...)解析阶段生成全局规则、变量定义规则内的$(eval ...)规则配方执行前动态生成临时变量极端少见与$(foreach...)结合解析阶段批量生成规则最常用与$(call...)结合调用时传参自定义规则模板这里有一个容易忽视的点解析阶段和执行阶段并不是“一前一后”那么简单。GNU Make 在解析时会对规则、变量做多遍扫描因为变量可以延迟展开如果$(eval ...)引用了延迟展开的变量实际内容要到变量最终被求值时才确定。这就带来了一个非常重要的实践准则——生成规则时模板里的变量引用必须能确定求值时机否则你会体验到“明明有值生成出来却是空的”这种玄学 bug。2.4 为什么说 eval 是“图灵完备”的最小单元如果你的 Makefile 里有了eval、call、foreach和变量系统其实你已经可以在 Makefile 里写一套完整的逻辑了。它不再只是“执行 shell 命令的编排工具”而是具备编程语言基本要素的构建平台。这带来一个思想转变规则本身也是数据。你可以从配置文件读取模块列表根据模块属性生成对应规则。后续我会给你看一个从文件生成规则的例子你就能感受到这种抽象能力带来的效率提升。3. 实战自动生成编译规则的完整方案3.1 从“拆解需求”到“抽取参数”在动手写eval模板之前我建议先做一步把你现有所有的规则抄下来找出它们的共同骨架和差异点。这一步看着啰嗦却是后面所有工作的基础。拿上面那个 40 模块的跨平台系统举例每个模块的编译规则大概长这样build/module_a/libmodule_a.a: $(GEN)/module_a/*.cc $(GEN)/module_a/*.h $(CXX) $(CXXFLAGS) -c $(^:%.cc%.o) $(AR) rcs $ $(^:%.cc%.o)差异点主要有三个模块名决定目标文件路径和库名依赖文件集合proto 生成物路径每个模块特有的编译参数把差异点变成参数骨架变成define块就完成了第一步。这一步的核心是参数收敛你要保证模板的参数数量尽可能少能让调用方一眼看懂。我见过有人把参数拆到十几个结果“自动化”比手写还难维护。3.2 第一版模板用 call 传参的基础骨架我们先写一个简化版本先把整套链路跑通# 模板定义生成单个库的编译规则 # 参数1模块名 # 参数2依赖文件列表 # 参数3额外编译参数 define build-module-template $(BUILD_DIR)/lib$(1).a: $(2) $(CXX) $(CXXFLAGS) $(3) -c $(filter %.cc,$(2)) $(AR) rcs $$ $(filter %.cc,$(2):%.cc%.o) endef # 模块定义 MODULES : module_a module_b module_c # 这里用 call 展开模板 eval 注册规则 $(foreach mod,$(MODULES),$(eval $(call build-module-template,$(mod),$(wildcard $(GEN_DIR)/$(mod)/*.cc $(GEN_DIR)/$(mod)/*.h),-DMODULE_$(mod))))是不是有点晕确实第一次看这段代码的人多数会被$(foreach ...)后面长长的$(eval ...)吓到。但其实逻辑非常简单用foreach遍历模块列表对每个模块用call把参数填入模板用eval把填充后的文本当作规则解析执行。展开之后的实际效果等价于你手写了 3 条完整的规则。这里有个关键的坑define块里的$$。因为整段文本要经历两轮展开第一轮是call的实参替换第二轮是eval把文本当作 Makefile 语法解析。如果你写$在call展开时就被当作 Makefile 变量尝试解析了结果往往是空值。用$$才能确保转义到第二轮才展开成真正的自动变量。3.3 进阶从外部文件读取模块列表第一版模板的问题是模块列表还写在 Makefile 里如果模块特别多维护列表也逐渐变成负担。更好的做法是把模块信息放到一个独立的配置文件里Makefile 负责读取和生成规则。假设有个modules.conf文件每行格式是模块名:依赖目录:额外参数# modules.conf module_a:proto/module_a:-DMODULE_A_ENABLED module_b:proto/module_b: module_c:proto/module_c:-DEXTRA_DEBUG然后在 Makefile 里这样解析# 读取配置文件每行一条模块信息 MODULE_LINES : $(shell cat modules.conf) # 从每一行中拆分字段 # 用 filter 或者自写函数都行这里演示用 patter替换 extract-name $(word 1,$(subst :, ,$(1))) extract-dir $(word 2,$(subst :, ,$(1))) extract-flags $(word 3,$(subst :, ,$(1))) $(foreach line,$(MODULE_LINES),$(eval $(call build-module-template,$(call extract-name,$(line)),$(wildcard $(GEN_DIR)/$(call extract-dir,$(line))/*.cc),$(call extract-flags,$(line)))))这里我用subst把冒号分隔的一行文本转成空格分隔再用word取字段。这算是最朴素的配置解析方式没有外部依赖。如果你的项目已经用了 Python 或者别的脚本也可以让脚本生成这些行Makefile 只负责消费。这么做的好处是新增模块时只改一行配置不用动 Makefile 本体。结构和规则逻辑彻底分离项目大了以后这个优势会非常突出。3.4 处理源文件动态生成的情况上面例子依赖wildcard去展开文件列表但还有一种场景源文件本身就是构建产物。比如.proto文件要生成.cc、.h如果你用wildcard去找.cc第一次构建时这个目录是空的规则会因为找不到依赖而出问题。解决方案是把“生成源文件”的规则也纳入模板。完整的流程是用规则生成.cc和.h用规则编译.cc用规则打包.a。这个链条可以在同一个模板里完成。你可以把模板扩展一下define build-proto-module-template $(GEN_DIR)/$(1).pb.cc $(GEN_DIR)/$(1).pb.h: $(PROTO_SRC)/$(1).proto $(PROTOC) --cpp_out$(GEN_DIR) $(PROTOC_FLAGS) $$ $(BUILD_DIR)/lib$(1).a: $(GEN_DIR)/$(1).pb.cc $(GEN_DIR)/$(1).pb.h $(CXX) $(CXXFLAGS) $(2) -c $$(filter %.cc,$$^) $(AR) rcs $$ $$(filter %.cc,$$^:%.cc%.o) endef注意看回源生成规则里的$$、$$^同样要双写美元符号因为整个模板文本要经过eval的二次解析。不加双写的话它们在call阶段就会被解析成空值规则就废了。我个人建议给这种模板写一段注释说明每个参数的作用以及为什么这里要双写美元符号。因为三个月后你回来看这段代码大概率已经忘了当初为什么这么写。如果你不带注释后面接手的人会非常痛苦。3.5 用 eval 生成目标专属变量除了生成规则eval还有一个很实用的场景为不同目标设置不同的专属变量值。这是很多人忽略的。比如上面每个模块的编译参数不同你可以不把参数写进规则里而是为每个模块设置 target-specific 变量define set-module-vars $(BUILD_DIR)/lib$(1).a: MODULE_CFLAGS : $(2) $(BUILD_DIR)/lib$(1).a: MODULE_LDFLAGS : $(3) endef $(foreach mod,$(MODULES),$(eval $(call set-module-vars,$(mod),$(flags_$(mod)),$(ldflags_$(mod)))))这种方式的好处是规则模板本身不用改只用读取变量。比如你在.c转.o的通用规则里可以写$(CXX) $(CXXFLAGS) $(MODULE_CFLAGS) ...如果这个变量设了值就用没设就是空。这样“规则写一次参数各自配”可维护性高很多。3.6 模板化后的目录管理项目越来越大生成物目录、源码目录、中间目录就会变得很关键。建议在 Makefile 顶部统一用变量定义目录模板里不要出现硬编码路径。BUILD_DIR : build/obj LIB_DIR : build/lib GEN_DIR : build/gen PROTO_SRC : proto $(shell mkdir -p $(BUILD_DIR) $(LIB_DIR) $(GEN_DIR))$(shell mkdir -p ...)这种写法很常见虽然没有用eval但和eval搭配起来非常顺手。它在解析阶段就执行 shell 命令创建目录后续的规则就可以放心地往这些目录写文件了。4. 从入门到进阶几个容易踩的坑4.1$$和$的转义地狱这是所有eval使用者第一个遇到的坑也是最具杀伤力的一个。我梳理一下规则在define块中最终需要保留到展开后的自动变量$、$、$^必须写成$$、$$、$$^。在define块中在call传参时就想展开的普通变量写单$但要注意展开时机。如果模板里要调用 shell 变量比如$$HOME那得根据展开次数决定写几个$。我自己摘了一个比较实用的对照表意图写法展开时机在最终规则中使用$$$eval解析后在模板里引用已定义的 Make 变量$(VAR)call展开时立即求值在配方里传给 shell 使用$$VARshell 执行时求值在define里原样保留$$$$$$最终展开成$$看到第四行别笑我确实见过有人为了在生成的内容里再放一个$连环嵌套了三层eval最后自己也分不清了。遇到这种需求我的建议是想办法拆成两步或者干脆放弃 eval改成显式写规则。过度嵌套带来的心智负担已经超过了它节省的代码量。4.2 变量展开时机的三种剪不断理还乱的做法Makefile 里的变量赋值有延迟展开、:立即展开、::立即展开同:、?、等多种再叠加eval的二次解析变量展开时机就成了重灾区。举一个典型 bugMODULES : a b c define gen $(1)-target: $(1).c echo building $(1) endef $(foreach m,$(MODULES),$(eval $(call gen,$(m))))这段代码看着没问题实际跑起来也正常。但如果有人把MODULES改成延迟展开的形式MODULES a b c前面这段还是一样的一般也不会出事。可如果你在模板里继续引用了MODULES本身或者引用了一个在eval之后才被重新赋值的变量那展开结果就会出乎意料。处理这个问题的核心原则是模板参数尽量通过call显式传入而不是在模板体内部引用外部变量。把参数显式化展开时机就变得可控出问题的概率大幅下降。4.3 空格和换行的隐形破坏力call传参时参数首尾的空格会被当作参数的一部分。这意味着你在写模块列表时加了空格生成的规则里就会出现一个名为module_a的目标注意末尾多了一个空格然后 Make 怎么都找不到对应文件报错信息还会让你误以为是编译问题。解决方法是写列表时不要手滑加多余空格另外可以用$(strip ...)做一次清理$(foreach mod,$(strip $(MODULES)),$(eval ...))还有一个更隐蔽的换行问题。eval解析的文本里换行是规则结束的标志。如果你在define块里不小心多了一个空行可能会导致规则中间被打断生成出两条不完整的规则。排查这类问题建议用$(info ...)把call展开后的文本打印出来看我可以给你一个调试图$(foreach mod,$(MODULES),$(info generated rule for $(mod) )$(info $(call gen-template,$(mod)))$(eval $(call gen-template,$(mod))))把eval换成info就能在不实际注册规则的情况下看到将要生成的内容。这是我在调试eval时最常用的手段比盲改高效得多。4.4 多行文本和define的边界define可以定义多行变量但如果你想把整个规则都塞进去要特别注意define块和endef之间的文本边界。endef之后不能直接跟其他内容我记得有一次就是不小心在endef后面加了个空格结果 Make 报了一个奇怪的语法错误。另外define块里如果包含空行这些空行也会被保留并真实传递。在eval解析时空行就是规则分隔符。不想在生成规则之间留空行就得在模板写作时严格控制空行的位置。我通常在模板需要空行的位置显式写一个空的规则分隔备注方便其他人理解。4.5 调试技巧让看不见的展开过程现形我总结了一套自己常用的调试流程打印展开结果用$(info $(call ...))看模板填充后的文本。打印最终解析结果用$(info $(eval ...))没法直接看所以我会先打印文本再手动脑补或者用make --dry-run来查最终生成的目标。使用--debug选项加上make --debugv可以看到每个目标的依赖分析过程如果规则生成错了目标和依赖会看起来非常奇怪帮你快速定位。实际项目里我遇到过因为eval生成规则时少了一个依赖文件结果整个目标被跳过因为已经是最新但二进制却没更新。这种“假成功”的 bug 比编译失败难查得多因为它不报错。后来我在构建脚本里增加了一个指纹文件作为总依赖这才彻底解决。5. 真实项目中的扩展思路5.1 从构建到代码生成一条龙模板eval不只适合编译规则。我在项目里见过很多人用它来做代码生成、文档生成、打包配置等任务。比如项目里需要从数据库 schema 生成 ORM 模型每个表对应一个文件TABLES : users orders products define gen-orm $(GEN_DIR)/$(1).orm.go: $(SCHEMA_DIR)/$(1).sql python scripts/gen_orm.py $$ $$ $(BUILD_DIR)/$(1).o: $(GEN_DIR)/$(1).orm.go $(GO) build -o $$ $$ endef $(foreach t,$(TABLES),$(eval $(call gen-orm,$(t))))这个模式跟编译.proto本质上完全一样。模板的骨架是输入文件 生成命令 输出文件 下游消费者。一旦你把这个骨架抽象出来任何“批量生成 批量消费”的场景都能套用。5.2 结合include做模块化组装还有一种进阶玩法是把eval生成的规则写入一个临时文件再include进来。这有点绕但它在大型项目里非常实用GENERATED_RULES : $(BUILD_DIR)/generated.mk $(GENERATED_RULES): modules.conf python scripts/gen_rules.py $ $ include $(GENERATED_RULES)这种做法的好处是生成的规则可以跨 Makefile 复用也可以被其他脚本读取规则生成逻辑甚至不一定用 Make 的原生语法写了可以交给 Python 生成。缺点是调试时要在两个地方找原因生成脚本和模板逻辑。我建议先尝试纯eval方案只有在规则规模大到 “Make 原生语法表达起来很不舒服”时才考虑引入外部脚本生成。5.3 性能考量eval 会不会让构建变慢很多人担心eval是运行时动态解析会不会拖慢构建速度。实测下来我的经验是几百个模块的规模完全不用担心解析阶段的额外开销几乎可以忽略。你真正的性能瓶颈在编译本身。但是有一类情况要特别注意如果eval生成的规则依赖$(shell ...)去执行命令或者读取文件而且这些命令没有缓存Make 每次解析都会重新执行一次。我曾见过一个项目每次构建都要跑几十次$(shell find ...)解析时间比编译时间还长。解决办法是把$(shell ...)的结果存入变量再用$(eval ...)引用这个变量而不是在模板内部直接调用$(shell ...)。这样至少保证解析时只执行一次。如果你对性能有更高的要求还可以考虑把模块列表生成的部分放到独立脚本里用文件缓存结果让 Make 只在文件变化时才重新读取。5.4 过度的元编程是一种“罪恶”最后说一点个人体会也许是最重要的一点eval虽强不要滥用。我在代码评审时见过一个“惊为天人”的模板系统它用eval实现了多重继承式的规则覆盖还能根据环境变量决定生成哪套规则。表面上很酷可一旦构建出了问题打开 Makefile 根本看不出规则从哪来最后只能用逐步展开的方式把生成的 Makefile 打出来才定位到问题。这个度怎么把握我的判断标准是如果重复规则超过 5 条并且每条之间的差异只有参数不同那用eval是值得的如果规则之间有复杂的耦合关系比如 A 生成的变量会影响 B 的依赖我会谨慎评估确认自己真的能把展开时机控制好如果组里还有其他不熟悉 GNU Make 的同事我会在模板顶部写清楚展开流程必要的时候附一个生成结果示例。说到底eval是一个强大的工具但不是每个问题都该用锤子解决。规则极简时可以不用模板规则复杂但逻辑混乱时第一步应该是把规则理顺而不是用eval把混乱包装起来。5.5 顺手写一个“模板注释规范”给模板加注释时不要解释语法要解释“为什么这么设计”。我常用的注释头是这样的# 模板说明 # 展开流程顺序 # 1. call 将参数替换进模板 # 2. eval 解析替换后的文本注册规则 # 参数说明 # 1. 模块名 # 2. 依赖文件列表一定要是绝对路径或已定义变量的相对路径 # 注意事项 # - 自动变量必须写 $$、$$^不能写 $、$^ # - 模板内不要引用尚未定义的变量这段注释虽然看着啰嗦但它能避免接手的人踩掉所有我踩过的坑。我甚至见过一些团队把这种注释格式固化成了规范新模块加入时只需复制模板并修改参数效率非常可观。6. 写在最后的一个小技巧有一次排查一个特别诡异的问题生成的规则和目标文件永远触发重新编译。我各种排查最后才发现是因为eval生成的规则里依赖文件的路径含有一个尾部斜杠导致 Make 认为路径指向的是目录每次都要重新检查目录时间戳自然就总认为目标过期。写规则模板的时候统一在外部处理好路径格式用$(abspath ...)或$(realpath ...)清洗路径这种折腾人的问题就能从根上避免。其实真正用好eval的关键不在于记住几个函数怎么嵌套而在于建立一种“规则亦是数据”的思维。当你习惯了先把重复模式抽象成参数化模板再通过call和eval组装出完整规则你手里的 Makefile 就不再是一堆重复粘贴的文本而是一个有着清晰结构的构建系统。这种能力在项目规模上来之后给你省下的时间和头发都不是一星半点。