
写 Makefile 写到第十篇最近在项目里被问得最多的三件事就是条件判断怎么写、循环到底怎么循环、能不能像写函数一样把重复规则封装起来。这三个点放在一起其实是 Makefile 从“能跑”到“好维护”的分水岭。前几篇你可能会写一堆具体规则某个 .o 依赖某个 .c某个可执行文件依赖哪些对象文件。一旦目标变多、平台变多、编译选项变多手写规则就会膨胀到没法看。这篇我把条件判断的四个原语、foreach 的实际求值逻辑、还有 define/call/eval 怎么配合生成规则一次讲透。适合已经能写简单 Makefile、想进一步工程化的朋友也适合读别人项目里的复杂 Makefile 时容易头晕的人。1. 条件判断让构建逻辑长出分叉1.1 四个条件原语怎么选Makefile 里的条件判断没有太多花哨语法核心就是ifeq、ifneq、ifdef、ifndef这四个直接以if开头的指令。它们的用法非常直观但细节里全是坑。先看最常见的ifeqifeq ($(CC),gcc) CFLAGS -stdgnu11 else CFLAGS -stdc11 endififeq比较两个参数是否相等ifneq就是它的反义可以理解为“如果不相等则进入分支”。ifdef和ifndef则不太一样它们的参数变量名不写$()用来判断一个变量是否非空。比如ifdef EXTRA_FLAGS CFLAGS $(EXTRA_FLAGS) endif这里的EXTRA_FLAGS直接写名字不需要写成$(EXTRA_FLAGS)。如果EXTRA_FLAGS有非空值就进分支否则跳过。很多人会把ifdef理解成“判断变量是否存在”这不太准确。在 GNU Make 里ifdef FOO更多是判断FOO是否为非空。想精确区分“完全没定义”和“定义了但为空”建议用origin函数ifeq ($(origin DEBUG),undefined) DEBUG : 0 endif这是我在实际项目里比较喜欢用的写法尤其是变量可能从环境变量、命令行参数、Makefile 内部三个来源进入时用origin能避开空值导致的误判。1.2 展开时机条件在“读文件”时就定了条件判断最容易被忽视的一点是它是在 make 读取 Makefile 时就被处理的不是在某个 target 执行时才判断。也就是说make 一边读 Makefile一边做条件分支选择。被选中的行会正常进入构建逻辑没被选中的行会被直接忽略。很多人会因此踩坑把ifeq写在 recipe 里前面还加了一个 Tab结果 shell 直接报ifeq: command not found。因为带 Tab 的行被 make 当作 recipe 原样传给 shellshell 当然不认识ifeq。正确的习惯是ifeq/endif这类指令通常顶格写放在变量定义区、规则定义区不要混在 recipe 里面。想提取“当前是不是 release 版本”这种逻辑就应该在规则外面用条件判断把变量切好再让规则去引用变量。另外要注意条件分支里的展开时机。是递归展开:是立即展开。这两个语义和条件判断叠加以后容易出现“我以为变量已经是最新值其实到最后才展开”的错觉。所以我建议需要用于后续规则的核心变量尽量用:先在读 Makefile 阶段定死后面引用时行为更可控。1.3 一个跨平台和环境切换的例子条件判断最常见的落地场景就是跨平台。OS ? linux ifeq ($(OS),linux) RM : rm -f EXE : PLATFORM_SRC : linux/io.c else RM : del /Q EXE : .exe PLATFORM_SRC : win/io.c endif另一个常见场景是 debug/release 切换ifeq ($(BUILD_TYPE),release) CFLAGS : -O2 LDFLAGS : -s else CFLAGS : -g -O0 endif如果分支超过两种不建议硬凑else ifGNU Make 原生不提供else ifeq老老实实用嵌套ifeq ($(BUILD_TYPE),release) CFLAGS : -O2 else ifeq ($(BUILD_TYPE),test) CFLAGS : -O1 -g else CFLAGS : -g -O0 endif endif嵌套层级多了阅读体验会变差实际项目里我倾向于把这些分支判断独立成一个config.mk文件主 Makefile 用include config.mk引入团队协作时也更好维护。2. 循环Makefile 里的“遍历”不是你以为的 for2.1 foreach 的求值过程Makefile 没有 shell 那种“运行期 for 循环”但它有生成期的foreach函数。很多人第一次写会懵因为foreach的结果是一段文本而不是“执行了几次命令”。语法长这样$(foreach var,list,text)三个部分分别是临时变量名、要遍历的列表、每次要展开的文本。列表会先被展开按空格拆成一个个词然后依次把词赋给临时变量每轮展开text最后把所有结果用空格拼起来。举个例子MODULES : core net ui OBJS : $(foreach m,$(MODULES),$(m).o)OBJS的结果是core.o net.o ui.o。这里的临时变量是m在列表参数里写的是m不带$。在text里才写$(m)去取当前值。还可以把路径拼进去SRC_ROOT : src SRCS : $(foreach m,$(MODULES),$(SRC_ROOT)/$(m)/$(m).c)结果就是src/core/core.c src/net/net.c src/ui/ui.c。这种写法在收集多目录源码时很常见比手写一条条路径省事得多。2.2 双重展开陷阱与其他误区foreach的难点不在语法而在“变量展开的时机”。Make 的变量展开是一层套一层的最常见的坑是想在foreach里动态生成变量名。比如我想让每个模块都持有自己的源文件变量MODULE_core_SRCS : core/a.c core/b.c MODULE_net_SRCS : net/a.c net/b.c ALL_SRCS : $(foreach m,core net,$(MODULE_$(m)_SRCS))这里的$(MODULE_$(m)_SRCS)就是双重展开先把$(m)展开得到core拼出变量名MODULE_core_SRCS再展开这个变量。如果这里写成了$(m)_SRCS那就不是“变量名拼单词”而是一个独立变量结果完全不对。另一个容易被忽略的点是foreach只按空格切分列表。如果文件名或路径里有空格用foreach直接遍历会得到错误结果。这种情况要么换用$(wildcard)要么把整个列表存进一个变量再用别的方式处理不要硬在foreach里传带空格的路径。还有一点foreach的text里即使某次展开结果为空最终结果里也可能留下多余空格。建议拼接完整结果后加一层$(strip ...)OBJS : $(strip $(foreach f,$(SRC),$(f:.c.o)))这样输出更干净也避免后续规则出现空字符串导致的莫名问题。2.3 在 recipe 里需要 shell 循环时怎么办foreach更适合生成文本或变量内容。如果真要“执行一系列命令”应该放到 recipe 里交给 shell 处理。比如要跑每个模块的测试test-all: for m in $(MODULES); do \ echo run $$m; \ ./bin/$$m --test; \ done注意这里写的是$$m不是$m。因为 make 在把 recipe 交给 shell 之前会先做变量展开$m会被 make 当成自己需要展开的变量结果变成空。写成$$m后make 会把它转成$mshell 再当作自己的循环变量使用。做一个简单区分如果循环的目的只是生成一段文本、一批目标名、一批变量名用foreach。如果循环的目的是在某个 target 内部执行命令用 shell 的for。这两种循环的定位完全不同。3. 自定义函数把重复逻辑收编成函数3.1 define/call/endef 的基本用法Makefile 自定义函数用的是define和endef配合call调用。它本质上不是传统编程语言里的函数而是一段可以带参数的文本模板。先看最简单的例子define print_build_info echo building $(1) from $(2) endef demo: $(call print_build_info,app,main.c)define和endef之间定义的是函数体函数体里的$(1)、$(2)分别代表call调用时的第一个、第二个参数。上面这个demo执行时会输出building app from main.c。define定义的变量默认是递归展开变量也就是说函数体里的内容会保留到call真正展开时才处理。这给了它很强的灵活性可以用来生成变量、生成规则、生成 recipe。建议函数体里统一写$(1)、$(2)而不是$1、$2虽然两者都能用但在复杂表达式里$1会和后面的字符连在一起容易产生歧义。3.2 eval 把函数体“变成” Makefile 内容自定义函数只返回文本还不够真正让它发挥威力的是eval。eval会把它收到的字符串当成 Makefile 内容重新解析一次从而注册变量、规则、函数定义。看一个经典组合define add_target $(1): $(2) echo build $(1) from $(2) endef $(eval $(call add_target,hello,main.c))这行之后make 就真的多了一个叫hello的 target依赖main.crecipe 是打印一行信息。eval本身返回的结果是空但副作用已经发生规则进入 make 的内部数据库。如果要多生成几个目标就和foreach组合MODULES : foo bar $(foreach m,$(MODULES),$(eval $(call add_target,$(m),$(m).c)))foreach负责把每个模块名传给add_targetcall负责展开函数体eval负责把展开结果注册成规则。三个函数各管一段这也是 Makefile 实现“批量生成规则”的标准姿势。3.3 分层展开$ vs $$ 的换算define/call/eval最容易翻车的地方是美元符号的处理。很多人写出来的函数明明逻辑没错但一运行就发现$是空的或者变量名变成了一堆奇怪的展开结果。原因在于define的函数体会经历多层展开。如果函数体最终要被eval解析成规则而规则里又有 recipe那等于要经历两次“读入展开”第一次是call展开函数体第二次是eval解析规则到了 recipe 执行时 shell 还会再展开一层。看这个例子define make_tool $(1): $$(OBJ_$(1)) echo LD $$ $$(CC) $$^ -o $$ endef OBJ_tool : tool.o util.o $(eval $(call make_tool,tool))这里的$$、$$^、$$(CC)都是故意的。call展开函数体时$$会变成一个$所以eval最终看到的是tool: $(OBJ_tool) echo LD $ $(CC) $^ -o $如果函数体里直接写$call在展开时会立刻把$当成 make 自动变量展开。可惜call发生的时间点不在任何 recipe 执行环境里$是空的后面整个 recipe 就会坏掉。我给自己定了一个简单粗暴的换算原则函数体里想让我在最终 recipe 执行时再看到的$都写成$$。遇到$$(VAR)这种组合就说明这个变量希望在eval之后、目标执行时再展开。4. 完整实战条件循环函数重构一个多目标项目4.1 项目需求与目录结构理论知识说再多不如一个完整例子。我假设现在要维护一个多程序项目目录结构如下project/ ├── Makefile ├── common/ │ ├── log.c │ └── net.c ├── server/ │ └── main.c ├── client/ │ └── main.c └── tools/ └── main.c需求不复杂一条make要能构建server、client、tools三个可执行文件DEBUG1时增加调试选项新增一个程序时只改一个列表清理时要能删掉所有中间文件。这个需求正好把条件判断、foreach、define/call/eval全部用上。4.2 核心 Makefile 实现完整 Makefile 如下CC ? gcc CFLAGS ? -O2 -Wall -Wextra DEBUG ? 0 PROGRAMS : server client tools ifeq ($(DEBUG),1) CFLAGS -g -DDEBUG endif COMMON_SRC : common/log.c common/net.c define generate_program $(1)_SRC : $(2) $(1)_OBJ : $(patsubst %.c,%.o,$(2)) $(1): $$($(1)_OBJ) echo linking $$ $$(CC) $$^ -o $$ endef all: $(PROGRAMS) $(foreach p,$(PROGRAMS),$(eval $(call generate_program,$(p),$(COMMON_SRC) $(p)/main.c))) %.o: %.c echo compiling $$ $$(CC) $$(CFLAGS) -c $$ -o $$ clean: rm -f $(PROGRAMS) find . -name *.o -delete .PHONY: all clean执行make时输出大致是 compiling common/log.c compiling common/net.c compiling server/main.c linking server compiling common/log.c compiling common/net.c compiling client/main.c linking client compiling common/log.c compiling common/net.c compiling tools/main.c linking tools这里每一段都值得拆开看。先看generate_program函数。它接受两个参数第一个是程序名第二个是源文件列表。调用时我传的是$(COMMON_SRC) $(p)/main.c所以每个程序都会带上公共源码块再加上自己的main.c。函数体里用$(2)直接算源文件和目标文件列表而规则里的自动变量全部写成$$、$$^这个我在 3.3 说过是为了保证eval解析后仍保留正确的自动变量语义。foreach eval call那一行是关键$(foreach p,$(PROGRAMS),$(eval $(call generate_program,$(p),$(COMMON_SRC) $(p)/main.c)))它等效于对server、client、tools各展开一次generate_program最后生成三个完整规则。以后要加一个worker程序只需要把PROGRAMS改成server client tools worker再建worker/main.c就行了不需要手动去写目标名、依赖、链接命令。4.3 编译结果与扩展思路这个 Makefile 可以按需求走很多变体。我只想构建单个目标时直接make servermake 会只处理server这一条依赖链。想用命令行控制默认构建范围可以再加一层条件判断BUILD ? all ifeq ($(BUILD),all) TARGETS : $(PROGRAMS) else TARGETS : $(filter $(BUILD),$(PROGRAMS)) endif all: $(TARGETS)这样make BUILDserver client就只会构建server和client。filter函数负责从PROGRAMS里挑出存在的名字不存在的名字直接过滤掉避免 make 因为找不到目标而报错。如果项目规模更大我通常会把这个 Makefile 再拆成config.mk、rules.mk、targets.mk几个文件用include组合起来。条件和自定义函数部分尽量放rules.mk具体程序列表放targets.mk这样新成员加入时不用关心工具链细节。5. 常见问题与排查技巧5.1 条件判断不生效常见的条件判断不生效大多是空格问题。比如ifeq ($(CC),gcc)写成ifeq ($(CC), gcc)逗号后面多了一个空格比较就会失败因为 make 拿到的第二参数是gcc不是gcc。另一种是 Tab 惹的祸。条件指令前面一旦用了 Tabmake 会认为这是 recipe 的一部分直接传给 shell然后 shell 报不认识ifeq。条件指令一定要顶格写不要用 Tab 缩进。5.2 foreach 结果为空或不符合预期foreach生成结果为空第一反应先查变量定义顺序。用:赋值时右边在赋值瞬间就会展开。如果MODULES定义在这行之后那$(foreach ...)自然拿不到内容。# 错误MODULES 还没定义 OBJS : $(foreach m,$(MODULES),$(m).o) MODULES : core net # 正确先定义列表再展开 MODULES : core net OBJS : $(foreach m,$(MODULES),$(m).o)如果结果里有零散空格套一层$(strip ...)能有效清理。想确认foreach每一步展开成了什么可以临时加一行$(info ...)打印$(foreach m,$(MODULES),$(info module: $m))5.3 define/call 里自动变量消失这是函数化改造最常见的坑。$、$、$^这类自动变量只在目标执行时存在call展开函数体时它们还没有值。所以函数体里凡是 recipe 要用的自动变量都要写成$$、$$、$$^。同理如果函数体里想引用另一个 make 变量并且希望它在目标执行时才展开也要写成$$(VARNAME)。如果希望它在eval解析时就展开才写$(VARNAME)。多一个$少一个$结果可能完全不同。5.4 调试 Makefile 的三个土办法我平时排查复杂 Makefile 主要靠三个办法。第一用$(info ...)在读取阶段打印变量值。这是最快的方法能看出变量在某个位置到底有没有被赋值。第二用make -n做干跑。它只打印将要执行的命令不会真正执行适合检查最终生成的 recipe 有没有问题。第三用make -p导出完整数据库。文件很大一般配合grep使用比如make -p | grep -A5 ^server:能直接看到server目标最终展开成了什么依赖、什么 recipe。最后说一个我自己的习惯只要规则重复出现两次我就会考虑用函数封装只要项目要跨平台我第一天就把条件判断写好至于循环make 的循环从来不是用来做“运行时重复执行”的它服务于“生成规则”。牢记“展开期”和“运行期”的区别很多诡异问题都能解释清楚。