第一次翻开一份带着复杂构建脚本的Makefile,满屏$(1)、$(2)、$(3),我一度怀疑自己是不是走错了片场。平时写规则最常用的不就是$@、$<、$^这几位吗?怎么这里冒出来的全是带编号的引用?而且更诡异的是,你在整个文件里搜"1 ="、"2 ="根本找不到它们是在哪里赋值的。别急,这个现象几乎所有维护过模板化Makefile的人都遇到过。今天这篇就专门把这层窗户纸捅破:这些$(1)到底是什么,它们又是从哪里把值传递进来的,以及你该怎么在当前项目里安全、正确地使用它们。这篇文章适合刚被Makefile宏折腾得头疼的人,也适合想系统理解call、eval这类高级特性的进阶用户。
1. 先说结论:$(1)是函数和宏模板里的"位置参数"
1.1 一个最小可运行的例子
最简单的验证方式:打开一个空Makefile,直接写这两行:
code复制define hello
你好,$(1),今天是$(2)。
endef
$(info $(call hello,老张,星期五))
然后运行make,无需任何目标文件,控制台会直接打印:
code复制你好,老张,今天是星期五。
在这个例子里,$(info ...)只是make内置函数,负责把内容打印出来。重点在于$(call hello,老张,星期五)。call函数把第一个参数老张绑定给宏体里的$(1),第二个参数星期五绑定给$(2),然后展开宏体,得到"你好,老张,今天是星期五。"整个过程没有任何人给名为"1"的变量赋值,是call这个函数在调用瞬间临时完成了绑定。
1.2 为什么它不是自动变量
自动变量大家都很熟:$@代表当前目标,$<代表第一个依赖,$^代表所有依赖。它们由make在生成规则、执行命令时自动填充,所以写成$@而不是$(1)。而$(1)里的"1"其实就是一个普通的变量名。GNU make允许变量名以数字开头,甚至可以单独叫"1"、"2"。平时没人会主动去写1 := foo这种代码,但在call函数内部,make会把位置参数偷偷塞进这些数字命名的变量里。
关键区别在下面这张表:
| 引用写法 | 类型 | 什么时候有值 |
|---|---|---|
$@、$<、$^ |
自动变量 | 规则被匹配、命令执行时 |
$(1)、$(2) |
位置参数变量 | 调用$(call ...)时 |
$(NAME) |
普通变量 | 定义或命令行赋值后 |
你写规则时用$@;你写可复用的函数模板时用$(1)。二者场景完全不同,别硬放在一起比较。如果去翻make的中文手册,通常会在函数调用那节看到call的说明,但很少有人直白告诉你$(1)就是参数占位符。我当年也是靠反复实验才彻底确认的。
1.3 和shell位置参数的区别
如果你熟练写shell脚本,看到$1很容易联想到./xxx.sh a b里的位置参数。但makefile的$(1)不是命令行参数,它只和$(call ...)绑定。一个Makefile可以从命令行接收变量,比如make VAR=hello,但接收后你用$(VAR)引用,和数字编号没有任何关系。换句话说,shell里的$1是脚本外部传进来的,makefile里的$(1)是模板调用时自己塞进来的,二者来源完全不同。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心机制:define、call、eval是如何串起来的
2.1 define/endef先做一个文本模板
再往下走,你会经常看到这种结构:
code复制define COMPILE
$(1).o: $(2)
$(CC) -c $(2) -o $(1).o
endef
define和endef之间的内容会被整体保存成一个变量,变量名就是define后面的COMPILE。这时候你什么都还没做,它只是把这段文本原样存下来。真正让模板“活”起来的是后面这两个函数:
$(call COMPILE,foo,foo.c):把foo绑定为$(1),把foo.c绑定为$(2),然后展开宏体,返回一段文本。$(eval ...):把返回的文本当作Makefile语法来解析并生效。
如果只调call,你会得到一段规则文本,但它只是普通字符串。只有配合eval,这段文本才会变成make真正认识的规则。这就是为什么你在大型项目里看到的通常都是:
code复制$(eval $(call COMPILE,foo,foo.c))
而不是单独一个$(call ...)。
2.2 展开流程拆解
假设你现在想给src/foo.c生成一条编译规则,宏体这样写:
code复制define COMPILE_C
build/$(notdir $(basename $(1))).o: $(1)
@mkdir -p build
$$(CC) $$(CFLAGS) -c $(1) -o $$@
endef
调用语句:
code复制$(eval $(call COMPILE_C,src/foo.c))
展开过程大致是:
$(call COMPILE_C,src/foo.c)先把参数展开,这里参数已经是具体路径,所以$(1)得到src/foo.c。- make展开宏体,
$(notdir $(basename $(1)))先变foo,于是目标文件变成build/foo.o;冒号后面的依赖是src/foo.c。 - 命令部分里的
$$(CC)和$$(CFLAGS)在这里先被转换成一个$(CC)、$(CFLAGS),目的是延迟到规则执行阶段再展开;$$@也先变成$@。 - 最终生成的文本类似:
code复制build/foo.o: src/foo.c
@mkdir -p build
$(CC) $(CFLAGS) -c src/foo.c -o $@
eval拿到这段文本,把它当成Makefile读入,规则正式生效。
注意第3步:宏体里我写的是$$@,不是$@。这是个非常容易踩的坑。因为call展开宏体时,$@还没有任何值,贸然写成$@,它会被展开成空字符串,生成的规则里就丢掉了目标变量。写成$$@,经过call这一步它变成$@,等规则真正执行时,make才会把它替换成当前目标。如果你只想打印模板展开后的效果,用$(info $(call COMPILE_C,src/foo.c))就能看到上面第四步的样子,非常直观。
2.3 为什么用call而不是直接展开变量
有人会说,既然define就是存文本,我直接$(COMPILE)不也一样吗?不一样。直接用$(COMPILE)展开,宏体里的$(1)会因为没有值变成空,出来的规则缺胳膊少腿。call的价值在于,它在展开之前先建立位置参数和值的对应关系。你可以把同一个模板调用无数次,每次传入不同参数,产生不同规则。这有点类似代码里的函数:模板是函数体,$(1)是形参,call是调用语句,逗号后面的参数是实参。
另外还要注意,call的参数是按“先展开、再绑定、再展开宏体”的顺序走的。比如:
code复制VAR := hello
define SHOW
$(1) world
endef
$(info $(call SHOW,$(VAR)))
这里$(VAR)在call传参阶段已经被展开为hello,然后绑定给$(1),所以输出hello world。如果你希望$(1)在后续某个时刻再次展开,就需要用$$做转义。理解这一点,才能避免“参数怎么多展开了一层”的困惑。
2.4 参数从哪来,又能去哪
注意call绑定参数的生命周期:只在$(call ...)这一次调用的展开过程中有效。调用结束后,$(1)、$(2)又恢复成“未定义”。因此你在一个eval里看不到另一个call给$(1)留下的值。参数内容则来自调用者,可以是字面量、普通变量、函数返回值,甚至可以嵌套$(call ...)。比如:
code复制$(eval $(call COMPILE_C,$(SRC_LIST)))
这里传给$(1)的就是变量SRC_LIST展开后的值。这种写法在批量生成规则时格外好用,相当于把一组数据循环喂给同一个模板。
3. 实操:用$(1)$(2)重构一个真实项目的Makefile
3.1 需求场景
假设有一个小型C项目,目录结构如下:
code复制project/
├── include/
│ ├── foo.h
│ └── bar.h
├── src/
│ ├── foo.c
│ ├── bar.c
│ └── baz.c
└── Makefile
目标有俩:
- 可执行文件
app,依赖foo.o和bar.o。 - 可执行文件
demo,依赖bar.o和baz.o。
如果用传统写法,你得把几乎一样的编译规则复制两份,链接规则再复制两份。等源文件多了,改一个编译器参数要在十几处同时改,迟早出错。用$(1)、$(2)模板化之后,情况完全不同。
3.2 重构后的Makefile
先看完整写法:
code复制SRCDIR := src
OBJDIR := build/obj
CC ?= gcc
CFLAGS ?= -O2 -Wall
SRCS := $(wildcard $(SRCDIR)/*.c)
all: app demo
define COMPILE_C
$(OBJDIR)/$(patsubst $(SRCDIR)/%,%,$(basename $(1))).o: $(1)
@mkdir -p $(OBJDIR)
$$(CC) $$(CFLAGS) -c $(1) -o $$@
endef
define LINK_EXE
$(1): $(2)
$$(CC) $$(LDFLAGS) -o $$@ $(2)
endef
$(foreach src,$(SRCS),$(eval $(call COMPILE_C,$(src))))
$(eval $(call LINK_EXE,app,$(OBJDIR)/foo.o $(OBJDIR)/bar.o))
$(eval $(call LINK_EXE,demo,$(OBJDIR)/bar.o $(OBJDIR)/baz.o))
clean:
rm -rf build
我把编译规则集中成一个COMPILE_C模板,链接规则集中成一个LINK_EXE模板。$(foreach src,$(SRCS),...)每次把当前源文件路径放进临时变量src,然后用$(eval $(call COMPILE_C,$(src)))生成对应规则。这里传给$(1)的值就是src/foo.c、src/bar.c、src/baz.c。LINK_EXE模板里,$(1)是目标可执行文件名,$(2)是一串依赖文件。
代码块里的命令缩进在真实Makefile里必须是Tab,编辑器设置要注意,很多人在这里被坑过。
3.3 为什么这样设计参数顺序
COMPILE_C模板只接受一个参数,因为每个源文件对应的规则很固定。LINK_EXE需要两个参数,我把目标名放前面、依赖列表放后面,因为调用时更容易阅读:$(call LINK_EXE,app,依赖列表)。位置参数的顺序没有强制规定,完全由你定义模板时决定,但建议保持“目标、源文件、额外选项”这种从大到小的顺序,项目里后续接手的人更不容易乱。
模板化之后,有个附带好处:以后要加一个新源文件,只要把它放进src/目录,wildcard会自动收集,foreach会为它生成规则,完全不用手写新增编译规则。这就是$(1)这类参数占位符带来的维护收益。
3.4 参数里的空格和逗号问题
你的依赖列表通常不止一个文件,比如$(OBJDIR)/foo.o $(OBJDIR)/bar.o。这里用空格分隔,作为第二个参数传入LINK_EXE后,$(2)会原样保留这些空格,最终生成的规则是合法的。但注意,call的参数分隔符是英文逗号,如果你在参数内部又写了逗号,make会认为那是参数分隔符,把它拆成两个参数。想传一个带逗号的值,建议先用普通变量转一下:
code复制LIB_LIST := a,b,c
define PRINT_LIBS
$(1)
endef
$(info $(call PRINT_LIBS,$(LIB_LIST)))
此时因为LIB_LIST已经展开成a,b,c,再传给PRINT_LIBS时才不会在逗号处被拆开。这个细节不处理,模板调试起来会一头雾水。
3.5 如何避免同名目标冲突
刚才的COMPILE_C模板里用了patsubst把src/前缀去掉当目标名。如果项目里有两个不同目录下都存在foo.c,直接$(notdir ...)会生成两个foo.o,规则就打架了。改成保留相对路径的子目录形式更安全:
code复制$(OBJDIR)/$(patsubst $(SRCDIR)/%,%,$(basename $(1))).o: $(1)
例如src/foo.c会生成build/obj/foo.o,other/foo.c会生成build/obj/other/foo.o,完全不冲突。模板化之后,这种路径处理逻辑只写一遍,所有文件都能用上。
4. 调试方法:怎么确认$(1)被传成了什么
4.1 用$(info)把call的结果打印出来
调试模板最高效的方法,是先不要eval,只打印$(call ...)展开后的内容。比如你把:
code复制$(eval $(call COMPILE_C,src/foo.c))
临时改成:
code复制$(info $(call COMPILE_C,src/foo.c))
运行make,控制台会直接显示这段宏展开后的规则文本。看到文本再手动检查:目标名对吗?依赖对吗?命令前的Tab还在吗?$@有没有被吞掉?确认没问题之后,再把info换回eval。这个技巧帮我排掉过至少一半的makefile模板问题。
4.2 打印多个模板和循环变量
如果配合foreach批量生成规则,还可以在循环里打印每次展开结果:
code复制$(foreach src,$(SRCS),$(info [DEBUG] $(call COMPILE_C,$(src))))
这样你能一次看到所有源文件会生成哪些规则。当某条规则依赖了错误文件时,这里会暴露得非常明显。注意$(info)只负责打印,不会影响后续构建,所以可以放心使用。
4.3 遇到vitis风格报错时怎么定位
很多嵌入式IDE在后台调用make时,会输出类似:
code复制make[2]: *** [makefile:18: libs] Error 1
这个格式信息量很大:make[2]表示这是第二层递归make;makefile:18是出错位置;libs是当时正在制作的目标名;Error 1是最后一条命令的返回码非0。即使规则来自模板展开,最终还是会归到某个Makefile行号上。排查时可以分三步走:
- 执行
make libs -n,让make只打印命令、不执行,确认它想干什么。 - 执行
make libs,找到实际失败的那条命令,单独在终端里跑一遍。 - 如果怀疑是模板展开的问题,在模板调用处临时加一行
$(info $(call ...)),对比实际生成的命令和预期是否一致。
下面整理一张常见错误速查表:
| 报错信息 | 常见原因 | 解决办法 |
|---|---|---|
missing separator. Stop. |
规则命令前用了空格,不是Tab | 把命令前的空格改成Tab |
*** empty variable name. Stop. |
出现$()或$$误用 |
检查变量引用是否少写了变量名 |
recipe commences before first target |
行首多出的Tab被当成了规则命令 | 调整缩进,让目标行顶格 |
make: *** No rule to make target ... |
规则没生成,或依赖文件名不对 | 打印$(call ...)确认模板参数 |
make: *** 没有指明目标并且找不到makefile |
当前目录没有Makefile或默认目标缺失 | 使用-f指定文件,或确保all存在 |
需要注意的是,Error 1并不是make本身出错,而是编译、链接等命令返回了非0退出码。这时候去改makefile模板很可能方向错了,先看命令本身。
5. 模板用久了自然会想到:这跟CMake有什么区别
5.1 什么时候适合用模板宏
Makefile的$(call)、$(eval)这套玩法,本质上是在make语法内部实现“元编程”。优点很明显:不需要额外安装工具,规则复用后维护量小,构建速度快。缺点也同样明显:展开时机、转义、Tab、逗号这些细节稍不注意就踩坑,可读性也不好。我见过很多项目把Makefile写成了“一行天书”,维护者宁愿重写也不愿去读。
5.2 Makefile与CMake的核心差异
| 对比项 | Makefile高级写法 | CMake |
|---|---|---|
| 本质 | 构建规则语言 | 构建系统生成器 |
| 依赖管理 | 需要自己写规则 | 提供强大依赖机制 |
| 跨平台 | 需要维护多套 | 自动适配常用工具链 |
| 学习曲线 | 入门易,高级难 | 入门略高,但模式直观 |
| 调试手段 | 偏向打印展开 | cmake --trace等 |
如果你的项目只是几十个源文件、固定工具链,用$(1)模板化Makefile完全够用。一旦需要跨平台、自动查找第三方库,或者团队里新人频繁接手,我更推荐直接上CMake。CMake里也有function、foreach,但变量作用域和生成流程比make的递归展开清晰不少。
5.3 给新手的路线建议
别一上来就抱着$(eval $(call ...))不放。先掌握普通变量、自动变量、模式规则,再把单个模板写好,最后才用foreach批量生成。这个顺序稳一点。如果以后要做复杂构建系统,从CMake开始也许更省心,但理解make的展开机制仍然会很有用,因为整个make生态里你总会碰到别人留下的模板。
6. 我在实际项目中攒下的经验和技巧
最后再分享几个我自己的习惯。第一,宏体里能写$$VAR就不要写$(VAR),除非你明确希望它在展开阶段立即求值。这个习惯能防止大量“为什么变量到了规则里就没了”的诡异问题。第二,给模板设计参数时,能少就少。两个参数能解决的问题,别设计成五个,否则调用处没人看得懂。真需要很多配置项,用一个空格分隔的“配置列表”参数,内部再用$(word 1,$(2))、$(word 2,$(2))去取。
第三,临时调试变量千万别删太勤。我会在Makefile里留一个DEBUG开关,用条件判断控制是否打印关键展开:
code复制ifneq ($(DEBUG),)
$(info [debug] $(call COMPILE_C,src/foo.c))
endif
出问题时直接make DEBUG=1,很快就能定位。第四,记住$(1)的生命周期只在一次call内,别指望它在另一个规则里还能用。如果确实要跨规则共享某个值,把它存进普通变量,再通过普通变量传给模板。
我印象最深的一次,是把一个六百行的Makefile用这套方法缩到不到两百行,虽然读起来还是需要一点耐心,但每次改编译选项再也不用全局替换了。这些东西看着琐碎,但真的能救命。特别是你想把Makefile重构得干净一点时,先把这些展开规则刻在脑子里,再动手,效率会高很多。
