接手一套别人维护了七八年的老工程,打开根目录的Makefile,往上翻几屏,满眼都是$(1)、$(2)、$(3),甚至$(12)这种带两位数的引用,中间还夹着$$@、$(eval $(call ...))。我第一次遇到这阵仗是在一份嵌入式交叉编译脚本里,整个文件不到三百行,$(1)出现了上百次,当时的第一反应是怀疑这文件被脚本生成器蹂躏过。后来把机制弄明白才发现,这东西既不神秘也不难,它就是GNU Make的自定义函数参数,跟你在Shell脚本里写的$1、$2是同一个思路,只不过Make的语法表达能力更强,玩起来也更绕。这篇文章就把标题里那个问题彻底拆开:$(1)到底是什么意思,里面的值又是从哪传进来的。
1. 先分清:$(1)不是自动变量,是位置参数
1.1 自动变量和编号参数的根本区别
很多新手把$(1)和规则里常见的$@、$<、$^混为一谈,这俩看着像,来源完全不同。$@这类叫自动变量(Automatic Variables),是Make在执行规则时自动塞给你的:
| 自动变量 | 含义 |
|---|---|
$@ |
当前规则的目标名 |
$< |
第一个前置依赖 |
$^ |
所有前置依赖,去重 |
$? |
所有比目标新的前置依赖 |
$* |
模式规则中匹配到的“茎”(比如%.c匹配出的文件名主干) |
这些变量不用你定义,也不用传参,只要规则被执行,Make就帮你填好。它们的生命周期只在规则执行那一刻,平时你在文件顶层写@单独引用,拿到的是空值。
而$(1)、$(2)这类数字编号的引用,属于位置参数(Positional Parameters),概念来自编程里的函数形参。Make本身没有真正的函数定义语法,但通过define可以造出一种“宏函数”,函数体里用$(1)表示第一个参数、$(2)表示第二个参数。这些参数不是Make自动给的,必须要有人调用这个函数、把实参传进去,$(1)才有值。
一句话总结:$@是规则自带的情报,$(1)是调用方塞进来的条件。
1.2 为什么有的地方写$1,有的写$(1)
GNU Make的变量引用有个规则:变量名只有一个字符时可以省略括号,比如$@、$*、$1都合法;变量名超过一个字符就必须加括号,例如$(CC)、$(MAKE)。所以$1和$(1)完全等价,只是后者更显式、更容易在满屏代码里被搜到。很多老工程师习惯都带括号,因为等参数超过9个时,括号就是刚需了:
$(10)表示第十个参数$10会被解析成$1后面跟一个字符0,结果完全不是你以为的那个意思
另外还有两个容易被忽略的点。第一,$(0)不表示第零个参数,它表示当前函数名本身,常用于在调试信息里打印“我是谁”。第二,所有位置参数在没有被调用时都是空字符串,不是报错,也不会警告。这就是为什么很多人在普通赋值语句里写$(1),最后发现得到一团空白,还以为编译器出了毛病。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 参数从哪来:全靠$(call)这一下
2.1 $(call)的语法和执行时机
位置参数的传递只有一个入口,就是call函数。语法是:
makefile复制$(call 函数名, 参数1, 参数2, ...)
处理流程非常简单:
- Make找到名为“函数名”的变量(通常用
define定义); - 把函数体里的
$(1)替换成参数1、$(2)替换成参数2,以此类推; - 替换后的整段文本作为这次调用结果返回,继续参与Makefile解析。
注意一个关键点:$(call ...)是在解析Makefile的过程中立即展开的,不是等构建开始后才执行。也就是说,调用发生在“读文件”阶段,函数体里如果写了规则定义,就得配合eval才能把它们真正注册成规则。这个执行时机是理解后面所有坑的基础。
2.2 一个最小示例,亲手跑一遍
先看一个最简单、能立刻验证的宏函数:
makefile复制define say_hello
$(info Hello, $(1)! I got arg2 = '$(2)'.)
endef
$(call say_hello, World, 42)
执行make(没有目标也能跑,因为info在解析阶段就会输出),屏幕上会打出:
text复制Hello, World! I got arg2 = '42'.
拆开看:define say_hello到endef之间存了三行文本,里面的$(1)、$(2)当时只是占位符。文件末尾的$(call say_hello, World, 42)触发替换,$(1)变World,$(2)变42,然后整段内容展开,info函数把带参的文本打印出来。
如果把调用改成$(call say_hello, OnlyOne),第二个位置没有实参,$(2)就是空的,输出会变成:
text复制Hello, OnlyOne! I got arg2 = ''.
这也是标题那句“从哪传递进来的”最直接的答案:不是Make自动塞的,是写调用语句的人传的。调用点可以是文件顶层、可以是另一个函数体内,也可以是foreach循环里每次迭代生成的一个调用。
2.3 参数本身也可以是变量或表达式
实际工程里,没人会像我上面那样传字面量。更常见的是这样:
makefile复制$(call build_module, $(MODULE_NAME), $(wildcard src/*.c))
这里传给$(1)的实参是变量MODULE_NAME当前的值,传给$(2)的是wildcard函数展开后的文件名列表。也就是说,调用点的实参在传给函数之前会先完成自己那层展开,然后才替换进函数体。理解这个顺序很重要,因为有时候你会看到某个值在函数里“变没了”,往往就是展开时机的问题。
3. 大量$(1)的重灾区:define + eval 模板套路
3.1 define/endef和普通赋值有什么区别
define定义一个多行变量,本质上等价于递归展开变量=,但比=多两个本事:可以跨行,可以配合call当函数用。对比一下三种写法:
makefile复制A := $(1)-$(2) # 立即展开,此刻$(1)为空,A后面永远是"-"
B = $(1)-$(2) # 使用时展开,但没call的话照样为空
define C
$(1)-$(2)
endef # 存原文,等call时才有参数
很多人在函数模板里用:=定义,结果函数体里的$(1)在定义那一刻就被展开成空串,后面再怎么call都救不回来。这就是为什么模板函数几乎一律用define,因为它只存储原文,把“参数填充”这件事推迟到调用时再做。
3.2 批量生成多个目标,一层层展开给你看
实战中最容易见到满屏$(1)的场景,就是用函数模板批量生成规则。假设工程里有三个小程序:main、tool、demo,每个都是从同名.c编译出来。不用模板的话得复制三段几乎一样的规则,改用模板只需要写一次:
makefile复制APPS := main tool demo
define BUILD_APP
$(1): $(1).c
$$(CC) -o $$@ $$(CFLAGS) $(1).c
endef
$(foreach app,$(APPS),$(eval $(call BUILD_APP,$(app))))
这行是重头戏,我强烈建议你把这段代码存下来,对着下面的展开过程啃:
text复制第1步:foreach把app依次赋值为 main、tool、demo,取出第一个:app=main
第2步:生成调用表达式 $(call BUILD_APP,main)
第3步:call把函数体里的$(1)全部替换成main,同时把$$缩成一个$
原始函数体:
$(1): $(1).c
$$(CC) -o $$@ $$(CFLAGS) $(1).c
替换后:
main: main.c
$(CC) -o $@ $(CFLAGS) main.c
第4步:eval把这段文本当成makefile片段重新解析,注册成一条规则
第5步:执行 make main 时,配方才展开:
gcc -o main -O2 main.c (假设CFLAGS=-O2)
注意几个细节:
- 函数体里我们用的是
$$(CC)、$$@、$$(CFLAGS),而不是单美元$(CC)。因为call展开函数体时会把$$折叠成$,这样生成的规则文本里保留下来的是$(CC)、$@,等配方真正执行时才会展开成gcc和main。 - 如果贪省事直接在函数体里写
$(CC),call展开时就会立刻把它解析成gcc,等于把编译器的值焊死在解析阶段,后续在命令行make CC=clang覆盖就不生效了。模板函数里,想延迟到执行期解析的引用,一律用$$开头。 $(1).c没加$$,因为它就是要被call当场替换成main、tool这种字面量的。- 最后生成的配方是以Tab键开头的。
define块内部的Tab会被原样保留,这正是eval能否把文本识别成规则的关键。你要是用空格缩进,就会撞上著名的报错missing separator。
这个foreach + eval + call的组合,就是你在文件里看到一堆$(1)、$(2)、$(3)的最常见来源。一个函数模板往往就是一条“批量生产线”,每次循环喂不同的实参,就能生成N条结构相同但内容各异的规则。
3.3 $(0)和嵌套调用的参数边界
define函数体里$(0)会自动带上函数名。我在调试时经常这么用:
makefile复制define build_lib
$(warning [$(0)] building $(1) from $(2))
...
endef
这样展开时每个warning都会标明是哪个模板、正在处理哪个目标,日志一眼就能对上。嵌套调用也不难理解:内层call填充的是内层函数体的$(1),外层函数体的$(1)不受影响,各管各的。但如果内层函数要使用外层参数,必须通过内层调用的实参显式传下去,比如$(call inner, $(1)),位置参数不存在“闭包”一说,这点跟很多编程语言不一样。
4. 别搞混:foreach循环变量和$(1)同时出现怎么读
4.1 foreach的循环变量是具名变量
foreach的语法是$(foreach 变量名, 列表, 文本),里面的循环变量必须是一个普通名字,比如$(dir)、$(app):
makefile复制$(foreach dir,$(SUBDIRS),$(call compile_dir,$(dir)/src))
同样一行里,$(dir)是循环变量,它依次被取成SUBDIRS里的每个元素;而$(call compile_dir, $(dir)/src)中的$(1)是函数compile_dir的参数,恰好被传入了循环变量当前的值。很多时候函数体里同时出现$(1)和循环变量名,容易把人绕晕,但记住一条规则就好:括号里是数字的,看它属于哪个函数体;括号里是单词的,看它属于哪个循环。
4.2 实参里的逗号是地雷
调用表达式是按逗号切分参数的。如果你传的某个实参本身包含英文逗号,比如一个源文件列表foo.c,bar.c,Make会把它当成两个参数,函数里取到的$(2)就只剩半截。工程里通用的解法是先定义一个“逗号变量”:
makefile复制comma := ,
$(call my_func, foo.c$(comma)bar.c)
展开后$(call my_func, foo.c,bar.c)的实参又变回一个整体。这坑我踩过不止一次,排查时发现函数里莫名多出一个参数,十有八九是实参里混进了逗号。
4.3 调试时在函数体里打印参数
想知道某个模板到底收到了什么,在define函数体里插一行$(warning ...)最直接:
makefile复制define build_app
$(warning [$(0)] args = '$(1)' '$(2)' '$(3)')
$(1)-bin: $(2)
$$(CC) -o $$@ $$^
endef
每次call被展开时,warning就会打印当前实参内容。这比翻遍整个Makefile猜参数来源要快得多,是我排查位置参数问题时的第一动作。
5. 排查实录:$(1)不展开、为空、错位的三个典型现场
5.1 现场一:define定义完,顶层引用却是空的
有人这么写:
makefile复制define my_func
$(1)-debug
endef
$(info my_func = $(my_func))
输出是my_func = -debug,前面那个$(1)变成了空。原因不复杂:$(my_func)只是普通取变量值,并没有触发call,函数体里的$(1)当然没有值。宏函数文本本身可以通过取变量拿到,但想让位置参数生效,必须走$(call ...)。想清楚了这一点,就不会再纠结“变量明明定义了为什么里面是空的”。
5.2 现场二:eval出来的规则里$@全没了
这是最隐蔽、最常见的一个。有人写模板时图省事,把配方写成单美元:
makefile复制define bad_template
$(1): $(1).c
$(CC) -o $@ $< # 错误示范
endef
$(foreach t,$(TS),$(eval $(call bad_template,$(t))))
结果生成出来的规则变成了:
makefile复制main: main.c
gcc -o main.c
$@在解析阶段就被展开成空串了,因为自动变量只在规则执行时才有值。正确写法是把配方里所有希望“执行时才展开”的引用都写成双美元:
makefile复制define good_template
$(1): $(1).c
$$(CC) -o $$@ $$< # 正确示范
endef
双美元在define体里是“保留符号”,经过call和eval两层展开后正好变成单美元,落到规则文本里,等到配方执行时才真正解析。判断标准很简单:这个引用是希望现在取值,还是希望将来取值?将来取值的,一律$$。
5.3 现场三:call参数个数没对齐,函数静默“吞参”
位置参数不对齐不会报错,只会静默变成空或串位。比如函数体用了$(3)但调用只传了两个实参,$(3)就是空;传多了,多出来的部分也没人接。这种问题在模板函数里特别害人,因为表面上构建还能过,编译出来的东西却不对劲。我在工程里定的规矩是:每个模板函数第一行必须写清用法注释,标明期望几个参数、每个参数是什么。比如:
makefile复制# build_lib 用法:
# $(call build_lib, 库名, 前置依赖列表)
# 例:$(call build_lib, foo, foo.o bar.o)
配合注释维护模板,半年后回头看就不会再对着$(7)发呆。
5.4 三板斧:make -p、make -n、$(warning)
真到排查阶段,我基本靠这三样:
make -p:把整个Make数据库(所有变量、规则)全量导出,配合grep搜模板名,能直接看到函数体原文和变量展开后的值。make -n:干跑模式,只打印将要执行的命令,不实际执行。适合验证eval生成出来的规则是否符合预期,命令里有没有多出空参数一眼就能看到。$(warning ...)/$(info ...):插在函数体或调用点中间,打印展开过程中的现场值。这是定位“参数在哪一步丢的”最有效的办法。
我遇到过一个很刁钻的问题:模板函数里用了$(strip ...)处理传入的文件列表,某个文件名因为路径里带了空格,strip之后被拆得七零八落。最后就是靠make -p | grep BUILD_APP看到展开后的函数体,才锁定是strip在作怪。
6. 拿到一份满是$(1)的Makefile,怎么快速读薄它
6.1 先找调用点,再回看函数体
碰到不认识的一堆$(1),别从头读,先搜调用:
bash复制grep -n "call " Makefile*
把文件里所有$(call XXXXX, ...)列出来,数清楚每个调用传了几个实参,然后回到define XXXXX对应的函数体,逐个位置对照:第一个实参就是$(1),第二个就是$(2),以此类推。我通常会在打印出来的调用清单旁边手写注释,比如:
makefile复制# $(call gen_target, demo, demo/main.c, build/)
# $1=目标名 $2=源文件 $3=输出目录
一张“参数映射表”在手,再长的模板也能快速看懂。重点永远是调用点,函数体只是被动的“填空题”。
6.2 函数模板的工程规范
经验沉淀下来,我给自己定了三条硬规矩,也建议你照着做:
- 函数名用大写加下划线,比如
BUILD_APP、GEN_LIB。Make内置函数全是小写,大写能明显区分自定义模板,搜索时也不会和内置函数混淆。 - 每个
define体第一行写用法注释,必须包含参数含义和示例调用。别嫌啰嗦,一个模板被三个人维护过以后,注释就是唯一靠得住的文档。 - 块内需要延迟展开的Make变量一律写
$$,不要因为在某个特殊场景下碰巧能用单美元就偷懒。统一规范能避免一半以上的“神秘空变量”问题。
6.3 老工程改造,宁可慢也不要重写
拿到一份大量使用$(1)的老Makefile,如果它还能正常工作,我的建议是别手痒去“重构”——那套模板可能暗含了二十多个坑,重写的风险远大于收益。稳妥的做法是:先按上面说的方法读懂,给模板补注释;然后逐步把往函数里传参的调用点改成更清晰的命名变量,比如引入TARGET_NAME、SOURCE_DIR这种中间变量再传给模板;最后用make -p导出数据库、make -n验证每条生成命令,确认改动前后完全一致。
我当初接手那份嵌入式交叉编译脚本时,就是靠这张参数映射表啃下来的。最高纪录一个下午理清了四十多个模板函数的调用链,靠的还是那句老话:位置参数没有魔法,它和你写在括号外的东西一一对应,传了才有,不传就是空。
后来我自己再写Makefile,凡是定义模板函数,第一行必然写清用法注释,调用点必然标明每个参数的含义,模板体里该用$$的地方绝不含糊。这样做的效果立竿见影——半年后打开文件,不用再靠grep和make -p重新考古。
