2026/10/19 8:32:48

Makefile eval函数实战:用模板自动生成构建规则

Makefile eval函数实战:用模板自动生成构建规则 做 Makefile 十多年了我见过最多的一种痛苦把一个几百行的构建脚本里同一套编译规则复制粘贴几十遍等到要加新程序、拆旧依赖、调编译选项的时候那种痛感会直接拉满。eval函数就是在这种场景下把我解救出来的简单说它能让 Makefile 像写模板一样自动生成规则把那些重复动作交给 make 自己完成。这篇文章不讲初级语法也不会绕一堆理论而是直接拿一个实际项目里见过的多程序构建场景把eval、call、define这三个东西拆开揉碎告诉你它们怎么配合、为什么这样写、踩过什么坑。如果你正被模块多、源文件多、每个目标编译参数还不一样的 Makefile 折磨这篇文章值得你花十分钟读完。1. 为什么要绕开手写规则从样板代码到规则生成1.1 真实项目中的“复制粘贴地狱”很多项目一开始只有一两个程序大家随手在 Makefile 里写bin/alpha: build/alpha/main.o build/alpha/utils.o gcc -o bin/alpha $^ build/alpha/main.o: src/alpha/main.c gcc -DALPHA -c src/alpha/main.c -o $ build/alpha/utils.o: src/alpha/utils.c gcc -DALPHA -c src/alpha/utils.c -o $这套东西跑起来没问题可一旦第二个程序出现事情就变了。你开始复制beta的三条规则把里面的alpha全替换成beta再把-DALPHA改成-DBETA。等加到第五个程序你会发现模板文件本身的修改成本和错误率已经高得没法看漏改一个目录编译产物跑偏多写一个依赖链接顺序出错想统一调整编译参数得全文搜索。我记得当时维护的某跨平台系统里直接由程序清单和源文件清单拼出来的手工规则累计超过上百行每次切分支、加功能都要在一堆规则中间翻来翻去。与其维护这些文案不如想办法让 make 自己按清单、按模板产出这些规则。1.2 模式规则为什么救不了你你可能会说那直接用模式规则啊%.o: %.c gcc $(CFLAGS) -c $ -o $模式规则在处理“所有目标格式完全一致”的情况时确实干净。它可以解决公共部分但解决不了差异。这里说的差异不只是编译选项还有目录结构、依赖关系、链接方式。比如上面的alpha和beta用的是同一套源文件布局但需要不同的宏定义模式规则里的$(CFLAGS)是全局的你总不能在每个目标前面单独把CFLAGS改掉那会破坏其他所有目标。静态模式规则稍微好一点$(OBJECTS): %.o: %.c它至少能把源文件和目标文件一一对应起来。可当你需要给不同目标附加不同的额外依赖头文件、生成代码、链接库时静态模式规则也会变得很笨拙。它的本质还是“一条规则管全部”而真实项目里往往需要的是“按清单管理每条规则略有差异”。1.3 eval 能带来什么eval函数给 Makefile 带来了真正的“元编程”能力。它把展开后的字符串当作一段新的 Makefile 代码继续解析。换句话说你可以在 Makefile 的运行期构造出规则、变量定义、甚至条件块再让 make 自己执行一遍。用三行话概括就是define保存一个多行模板call把模板里的参数替换成具体值eval把这个替换后的文本塞回 make 解释器。三兄弟一组合原本需要手写几十遍的规则全部由清单驱动自动生成。理解了这一点你会觉得 Makefile 不再是“写死规则”而是“写规则生成器”。2. 核心机制eval、call、define 怎么一起工作2.1 eval 的本质是“二次解析”先明确一点$(eval text)的返回值永远是空字符串它的全部作用都体现在副作用上让 make 去解析text把它当成构造规则、变量赋值、条件语句的源码。和我们写 shell 时用eval构造命令类似你完全可以理解成“把字符串再解释一次”。我在调试时经常把eval拆开看。你写$(eval $(call my_template,alpha,main))make 的处理顺序是这样的先执行括号里的call得到一个文本这个文本可能包含目标、冒号、分号或换行然后eval接收这个展开后的文本把它当作 Makefile 源码继续解析。因为eval本身不打印内容所以你直接看 Makefile 的时候看不到那个目标是怎么写进去的它会整个静默地成为 make 环境里的一部分。2.2 展开顺序的三层逻辑理解foreach、call、eval三者的配合最重要的是记下它们的执行顺序。我常用这张顺序表$(foreach item,$(LIST), $(eval $(call template,$(item))))分别对应三层动作。第一层foreach会遍历LIST每轮把当前值赋给item。第二层call开始展开模板模板中所有$(1)会先被替换成当前item的值同时模板里$$会缩减成$。第三层eval接收这个已经具体化的文本开始第二次解析此时文本里的普通变量比如$(OBJ_DIR)才会被求值。这个顺序非常重要如果你在模板里写了$那么在第二步替换时$可能被 make 解析为空因为$是自动变量它只有在真实规则执行时才有意义。你在定义模板时看到的并不是规则执行现场所以要用$$保住它。如果你在模板里写了$(CC)它会在第三步被求值得到实际的编译器名这是对的。如果你把$(CC)也写成$$(CC)那它就会被推迟到执行规则的时候再展开这在某些场景下是可以的但会让模板变得难读也容易出错。2.3 为什么$$一定是两个美元可以这样理解make 的变量展开过程非常贪婪遇到$就试图从后面截出一个变量名。在define模板里$$是给 make 的一层转义在call展开阶段一对$$会变成单个$等到eval拿到文本后看到$时因为这不是一个 make 变量引用自动变量在解析时并不存在它不会再去处理就原样保留在规则配方的文本里。最终 shell 执行该规则时$才被 shell 使用。这里踩坑的概率极高。新手常见问题是把模板写成define bad_template target: dep cc -o $ $ # 错误 endef由于$在eval解析时会被当作自动变量尝试展开但当时没有正在执行的规则结果通常是空字符串最后配方变成cc -o你说玄不玄。正确的写法是$$、$$、$$^、$$?这些自动变量必须透过call这一层保留到 shell 执行期。3. 实操构建一套自动生成规则的 Makefile3.1 一个够真实的多程序构建场景为了把原理落到地上我虚构了一个叫“某跨平台系统”的工程目录结构是这样prj/ ├── Makefile ├── src/ │ ├── alpha/ │ │ ├── main.c │ │ └── utils.c │ └── beta/ │ ├── main.c │ └── algo.c ├── build/ # 中间产物目录自动创建 └── bin/ # 可执行文件目录自动创建需求是编译出bin/alpha和bin/beta两个可执行文件。alpha 和 beta 各自的源码放在各自子目录里编译宏不同alpha 需要-DALPHAbeta 需要-DBETA后续还要再扩展更多程序。如果用传统的复制粘贴方式来写即使只有两个程序也要至少六条规则每加一个程序就得把所有规则重抄一遍。下面我们改成用清单驱动自动生成。3.2 先用变量把“清单”定义好我不是一上来就写eval而是先把可变的清单抽出来让整个 Makefile 的“输入数据”一目了然BUILD_DIR : build BIN_DIR : bin CC : gcc LDFLAGS : -lm CFLAGS_alpha : -O2 -DALPHA CFLAGS_beta : -O2 -DBETA SRC_alpha : main.c utils.c SRC_beta : main.c algo.c PROGRAMS : alpha beta这里的SRC_alpha存的是 alpha 程序源文件的相对路径SRC_beta是 beta 程序的源文件列表。以后想加一个gamma程序加三行数据就行了CFLAGS_gamma、SRC_gamma、把gamma加进PROGRAMS。规则部分完全不需要动。3.3 写define规则模板接下来进入重头戏。我们先为单个源文件的编译规则写一个模板。这里我会刻意使用“模板”这个说法因为它本质就是一段带占位符的 Makefile 片段define compile_one $(BUILD_DIR)/$(1)/$(2).o: src/$(1)/$(2).c mkdir -p $(BUILD_DIR)/$(1) $(CC) $(CFLAGS_$(1)) -c $$ -o $$ endef逐行解释$(BUILD_DIR)/$(1)/$(2).o这是目标文件路径。$(1)是程序名比如alpha$(2)是源文件去掉.c后的名字比如main。这两处都要在call时替换。src/$(1)/$(2).c这是对应的源文件。第二行配方里$(CC)会在eval阶段展开成gcc$(CFLAGS_$(1))会在eval阶段变成$(CFLAGS_alpha)或$(CFLAGS_beta)然后展开成对应宏。这里的$(1)已经被外层替换成具体程序名了所以$(CFLAGS_$(1))能正确选中属于该程序的编译选项。$$和$$必须写成两个美元作用前面说过了就是把$和$留给 shell 执行。同样再给链接阶段写一个模板。它要为每个程序生成一条bin/prog的规则而依赖关系是由该程序的源文件列表推导出来的define link_program $(BIN_DIR)/$(1): $(addprefix $(BUILD_DIR)/$(1)/, $(addsuffix .o, $(basename $(SRC_$(1))))) mkdir -p $(BIN_DIR) $(CC) -o $$ $$^ $(LDFLAGS_$(1)) endef$(basename $(SRC_$(1)))先把main.c utils.c变成main utils$(addsuffix .o, ...)把它变成main.o utils.o再用$(addprefix $(BUILD_DIR)/$(1)/, ...)拼出完整路径比如build/alpha/main.o build/alpha/utils.o。这样依赖列表完全由SRC_alpha驱动不会出现手写时漏一个目标的尴尬。3.4 用 foreach 把模板一键铺开模板定义好之后真正的自动生成只靠两行代码$(foreach prog,$(PROGRAMS),$(foreach src,$(SRC_$(prog)),$(eval $(call compile_one,$(prog),$(basename $(src)))))) $(foreach prog,$(PROGRAMS),$(eval $(call link_program,$(prog))))第一行的内层foreach遍历SRC_$(prog)对每个源文件调用compile_one外层foreach遍历PROGRAMS。于是 alpha 和 beta 会各自生成两条编译规则。第二行则分别为两个程序生成链接规则。整体看起来像是一个很长的函数调用但因为 Makefile 的foreach天然支持嵌套这种写法是标准的也完全符合 make 的解析逻辑。还需要一个入口目标和.PHONY让用户在命令行直接make就能构建全部程序.PHONY: all all: $(addprefix $(BIN_DIR)/,$(PROGRAMS))到这里一个自动生成规则的 Makefile 模板就成型了。整个 Makefile 不需要出现任何一条手工编译或手工链接规则规则完全由数据驱动。提示如果你是从网页复制这段代码一定要注意配方的缩进必须是 Tab 字符不能用空格替代。make 对 Tab 的依赖近乎偏执这里翻车的概率比eval翻车还高。3.5 验证你生成的规则写完之后我们怎么知道规则真的生成了我喜欢用两个命令验证。第一用-p打印数据库并配合grep看是否存在想要的规则make -pn | grep -A4 ^build/alpha/main.o:如果看到类似下面这种输出说明编译规则已经出现在 make 内部build/alpha/main.o: src/alpha/main.c mkdir -p build/alpha gcc -O2 -DALPHA -c src/alpha/main.c -o build/alpha/main.o第二用-n做“试跑”只看命令不执行make -n bin/alpha这个命令会把整个依赖链上的所有规则打印出来你可以逐条检查。我强烈建议在写新模板时先make -n预热一遍比反复执行真实编译快得多。如果看到gcc -O2 -DALPHA -c src/alpha/main.c -o build/alpha/main.o说明一切正常。要是-DALPHA变成空或者命令里出现莫名其妙的空参数那就是$/$转义出了问题可以回到第 2.2 节再看一遍。3.6 扩展新程序只需要三行数据这种方案的边际收益体现在加新功能的成本几乎被压到零。假设现在要加一个gamma它的源文件是src/gamma/main.c和src/gamma/task.c只需要在数据区写三行CFLAGS_gamma : -O2 -DGAMMA SRC_gamma : main.c task.c PROGRAMS : alpha beta gamma其他所有规则、可变依赖、编译命令都会自动生成。这正是eval最有价值的地方它把“写规则”变成“维护数据”而规则本身只存在一个数据源里想改编译命令改一处模板即可。4. 常见问题与排查技巧实录4.1 规则“没有生成”是怎么回事最典型的两种情况一是忘了在$(call)外面包$(eval ...)只写了$(call ...)那它只做了一次纯文本替换并不会让 make 执行这段新代码。二是define模板里的$(1)本身是空的传进去的参数没对齐导致目标行变成了空行或文件名不对。如果你在make输出里死活看不到对应规则先回头确认这两点。排查时我会直接在 Makefile 里加一行调试输出把模板展开后的文本打出来$(info compile_one alpha main ) $(info $(call compile_one,alpha,main))info会把文本原样打印到标准输出内容就是eval即将看到的内容。打印出来看一眼很多问题立刻就能定位。4.2 自动变量变成空串或者变成旧值这是eval用户最容易摔的跟头。在模板里写单美元$或$会让它们在 make 解析阶段被当成当前自动变量尝试展开可当时根本不在规则执行现场于是得到空值。正确写法一定是用$$保住这些变量。还有一种变体在模板里写了$$$$这会导致过多的转义层数结果 shell 看到一个奇怪的四美元符号配方直接报错。我的经验是遵守一个标准在规则配方里凡是 shell 要用的自动变量一律双美元普通 Makefile 变量一律单美元什么时候用$$看的是期望哪个阶段展开想清楚再落笔。4.3 重复定义规则警告使用自动生成规则时如果两个模板生成了同一个目标make 会给你一条overriding recipe for target之类的警告。实际情况里最容易发生的是同一个.c源文件被两个程序共用且两个程序都调用同一个compile_one模板生成了两条完全相同的路径。比如 alpha 和 beta 同时用到某个公共文件那么你有两条相同的目标规则make 会拿后一条覆盖前一条。解决办法也简单让自动生成的目标路径带上一级唯一前缀比如把程序名编进目标路径如果确实有跨程序共享的源文件就单独用一条静态模式规则生成公共目标再让各程序链接时依赖它而不是各自生成重复规则。4.4 目录没创建 / 每次编译都要重建目录我在配方里用了mkdir -p $(BUILD_DIR)/$(1)每次触发编译都会先创建目录。为什么这么写因为 make 本身不会自动为输出文件创建目录你不写首次编译就会因为找不到目录报错。把mkdir -p放在每次编译的配方里是最简单的做法代价是每次轻微重复执行但实际影响可忽略。如果你比较在意也可以把目录创建单独做成一个清空目标通过 order-only prerequisites 挂到规则前面例如$(BUILD_DIR): mkdir -p $ $(BUILD_DIR)/%.o: %.c | $(BUILD_DIR) $(CC) ...竖线后面的部分只在不存在时触发不会因为源文件更新而重复跑。不过这套用起来更绕我更建议小工程先保留配方里的mkdir -p简单可靠。4.5 我的排查三件套我的日常排查工具很固定一是$(info ...)/$(warning ...)打印模板文本二是make -pn | grep看规则是否注册三是make -n模拟执行看最终指令是否正确。如果还不行就加上make --debugbasic它会告诉你每个目标为什么重新构建依赖关系是哪条链触发的。这三板斧基本能解决 99% 的eval问题。5. 经验总结eval 的正确姿势与边界5.1 什么时候该用 eval什么时候别用这套技术不是万能钥匙。如果项目里就两三个源文件规则统一用模式规则或静态模式规则写成十行以内那完全没必要引入eval。硬上只会让 Makefile 变得比手工规则还难懂。相反如果你正面临多程序、多配置、多目录每个目标的编译参数还各不相同或者你需要根据清单动态生成依赖那eval就是最合适的工具。它尤其适合那种以后还会加模块的项目数据驱动能让扩展成本降到最低。5.2 维护自动生成代码的几条铁律自动生成的代码有一个通病就是不如手写的直观所以为了维护我给自己定了几个规矩。第一模板内部必须写清晰注释标注$(1)是什么、$(2)是什么、这里为什么要双美元。第二在生成区域前后加明显的分隔注释比如“以下规则由 eval 自动生成请勿手改”否则同事看到 Makefile 里没出现规则还以为你没写。第三把数据清单和生成器分开数据往上放生成器往下放保证任何人改新程序时只会动数据区。5.3 最后分享一个实战里的做法在实际项目中我通常不会让eval生成所有规则而是用模板生成独有部分加手写公共规则的组合。公共编译逻辑仍然保留静态模式规则动态的部分像按程序区分的依赖、按程序区分的LDFLAGS、分支条件规则才交给eval。这样两边都简单出了问题也好排查。有一次我在给某跨平台系统加一个自动生成配置工具链的时候发现用eval生成了上百条规则整个 Makefile 数据量反而比原来手写规则小了一半改一处公共编译命令所有目标同时生效。那种所有规则都由一个模板驱动、加一个模块只改三行的爽快感是手写规则永远给不了的。如果你也在长期维护这类多目标构建脚本我强烈建议你从今天这个最简单的compile_one模板开始先在测试目录里跑通再逐步推广到你的正式项目里。踩通eval的坑之后你会发现在 Makefile 里实现自动生成规则这件事其实比想象中要简单得多。