1. 先弄清楚$(1)、$(2)在makefile里到底是谁
1.1 一个让很多人当场卡住的使用场景
如果你在别人的makefile里看到这类写法,多半会有点懵:
makefile复制define compile_one
$(CC) -c $(CFLAGS) -o $(1).o $(1).c
endef
$(call compile_one, hello)
第一反应往往是:$(1)是哪个变量?变量名叫“1”?怎么从来没在makefile头部看到它的赋值?于是有人去搜“makefile位置变量”,得到的答案又夹杂着$@、$<、$^这些自动变量,越看越混乱。
先给一个结论:$(1)、$(2)这类写法,在GNU make里是用户自定义函数的参数占位符,也叫位置参量。它和$@这类自动变量不是一回事,和shell脚本里的$1也不是一回事,更不是你在环境变量里export的东西。它只在一个场景下有意义:你在makefile里用define或变量定义了一段“可复用文本”,然后通过$(call ...)把实参传进去,这时函数体里写的$(1)、$(2)才分别代表第一个、第二个实参。
如果你是刚开始接触,没关系,下面用最小实验把这层关系彻底拆开。
1.2 一个最短的验证案例
创建一个空目录,里面放一个简单的makefile:
makefile复制define show_args
$(info first arg = $(1), second arg = $(2))
endef
$(call show_args, hello, makefile)
然后执行make,不需要有任何目标,make会在解析阶段输出:
code复制first arg = hello, second arg = makefile
这个例子包含了三个关键点:
define ... endef定义了一个名叫show_args的“函数体”。本质上它是在定义一个变量,只是变量的值是多行文本。- 函数体里的
$(1)和$(2),在还没被调用时并不等于任何东西。你可以把函数体当作一张表,变量名1和2只是模板上的插槽。 $(call show_args, hello, makefile)把第一个参数hello传给$(1),把第二个参数makefile传给$(2),然后整个函数体在当前位置展开。
为什么make不用“真正的函数参数”?因为make的定义模型非常原始:它没有独立的函数栈、没有作用域,所谓函数其实就是一个变量。你把一个多行文本存到变量里,然后让make在展开这个变量时,顺便把变量1、2临时赋上值。调用结束后,变量1、2的内容就消失了,下回再调用又重新赋值。
这就是理解$(1)最重要的一点:它只是名字叫“1”的一个make变量,在调用期间被临时赋值。
1.3 $(0)、$1、$$1 这些近亲别认错
在实际代码中,经常出现几个长得像、含义完全不同的东西,我列个对照:
| 写法 | 含义 | 出现位置 |
|---|---|---|
$(1) |
自定义函数第1个参数 | define...endef函数体内 |
$(0) |
当前自定义函数的名字 | define...endef函数体内 |
$(VAR) |
普通make变量,比如$(CC) |
任意位置 |
$@, $<, $^ |
规则的自动变量 | 规则recipe内 |
$1 |
make一般不会这样用 | 容易手滑出错 |
$$1 |
转义后传给shell的$1 |
自定义函数recipe内 |
额外说一句$(0)。它在GNU make里代表函数名,自己调用自己时可以用来递归。比如:
makefile复制define log
$(if $(1),$(call log,),$(info reached the end))
endef
虽然实际递归用得不多,但$(0)在打印调试信息时很好用,可以知道当前是哪个函数在打印。
还有一个常见误用是recipe里写shell变量。make在展开recipe时,会把$当成make变量符号,所以如果你想让shell看到$1,在makefile里必须写$$1。这个规则在自定义函数里同样生效,后面第5章会展开讲。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 用$(call)把实参灌进$(1)、$(2):生命周期和返回值
2.1 $(call)的基本写法
GNU make里调用一个自定义函数的语法是:
makefile复制$(call func_name, arg1, arg2, arg3)
call本身是make一个内置函数,它的作用是:
- 读取第一个参数
func_name,把它当成一个变量名。 - 从第二个参数开始,按逗号分隔出若干参数。
- 把参数按顺序临时赋给变量
1、2、3。 - 展开变量
func_name的内容,把结果放到当前文本位置。
所以最简单的调用可以放在任何make表达式能出现的地方:
makefile复制result := $(call my_func, a.txt, b.c)
这里$(call ...)的返回值就是函数体展开后的文本。可以把它赋值给变量,也可以直接放进规则,还可以包在$(eval)里让它生成新的makefile规则。
2.2 参数在调用过程中的“生命周期”
理解生命周期,得先分清楚两种展开时机:
:=是立即展开赋值。右边所有$(...)会在make读到这一行时立刻算完。=是递归展开赋值。右边内容先原样保存,等真正使用这个变量时才展开。
$(call ...)本身也是一种展开,它总会当场执行。区别只是“当场”发生在什么时候。
看这个例子:
makefile复制MY_VAR := $(call show_args, alpha, beta)
如果show_args里有$(info ...),那么make在读这一行时就会打印,因为:=要求右侧立即展开,call只能现在就工作。
如果把:=改成=:
makefile复制MY_VAR = $(call show_args, alpha, beta)
那么这次打印会延迟,直到你第一次使用$(MY_VAR)时才发生。如果你一直不用它,它就永远不打印。
规则recipe里的$(call ...)同样遵循make的展开规则:recipe行一般会在make决定执行这条规则时展开,所以如果你用$(call ...)生成一条命令,它通常会在运行前才展开。
把这段逻辑想通后,基本就不会再出现“为什么我明明调用了,却不打印”这种问题了——你要先检查这个call所在的文本到底有没有被make真正展开。
2.3 $(call)的结果怎么“接走”
GNU make的自定义函数没有关键字形式的return。所谓“返回值”,就是函数体整体展开后的文本。文本放在哪里,哪里就成了函数的返回值。
最常见的三个接法:
- 拼接给变量:
my_cmd := $(call build_cmd, xx) - 丢给
$(info)打印:$(info $(call build_cmd, xx)) - 丢给
$(eval)生成规则/变量:$(eval $(call build_rule, xx))
第三种是makefile模块化最重要的姿势。因为大部分自定义函数最后不是返回一个“字符串”,而是返回一段“makefile源码”。用$(eval)把这段源码合并进当前makefile,就相当于写了个规则生成器。
看一个例子:
makefile复制define gen_echo_target
$(1):
@echo "target $(1) was called"
endef
$(eval $(call gen_echo_target, demo))
上面的makefile会在解析阶段生成一个名叫demo的目标。运行make demo就会输出target demo was called。
实际工程里,函数体内部通常同时包含变量赋值、规则定义、phony声明,这些靠普通变量赋值接不回来,只有配合$(eval)才有效。因此:如果你想用自定义函数往makefile里“写入”规则,就记住 $(eval $(call ...)) 这个固定组合。
2.4 用$(foreach)一次调用多次,避免手工复制
位置变量的价值不只是让一段文本复用,还在于可以放进循环批量展开。最常用的组合是:
makefile复制$(foreach name, app tool util, $(eval $(call gen_echo_target, $(name))))
这个表达式的执行过程可以拆开看:
$(foreach name, ...)让make依次把app、tool、util赋给循环变量name。- 每轮循环执行
$(call gen_echo_target, $(name)),此时位置变量$(1)依次变成app、tool、util。 $(eval ...)把call生成的文本当成makefile代码合并回去。
最后makefile里就多了三个目标。你可以在命令行直接运行:
bash复制make app
make tool
make util
这就是位置变量在makefile里最常见的使用方式:让数据驱动生成规则,而不是把一模一样的目标手写三遍。
3. 实战:把编译规则抽成带位置变量的函数
3.1 一个典型的对象文件编译规则
假设项目里有main.c、utils.c、report.c,都要编译成.o文件。最常规的写法是:
makefile复制main.o: main.c
$(CC) -c $(CFLAGS) -o $@ $<
utils.o: utils.c
$(CC) -c $(CFLAGS) -o $@ $<
report.o: report.c
$(CC) -c $(CFLAGS) -o $@ $<
三行规则长得一模一样,区别只有文件名。这时就可以定义一个“规则生成函数”,用$(1)接受文件名:
makefile复制define create_obj_rule
$(1).o: $(1).c
$(CC) -c $(CFLAGS) -o $@ $<
endef
接下来不要手动写三条规则,而是用foreach生成:
makefile复制SRCS := main utils report
$(foreach src, $(SRCS), $(eval $(call create_obj_rule, $(src))))
这段makefile等价于前面三条规则,但以后新增一个network.c,只需要把network加进SRCS列表即可。
这里有一个容易踩的细节:为什么函数体内写的是$@,不是$$@?
答案和define的展开时机有关。define定义变量时,make会保留函数体的原始文本,里面的$@不会立刻展开,因为它是一个递归展开变量。当$(eval $(call ...))执行时,函数体文本被展开并作为规则的一部分合并回去,$@是在规则实际执行时才取值,所以单美元符在这条路径上是正确的。
但如果你在另一个地方提前把函数体“展开”成了普通文本,例如直接写:
makefile复制my_rule_text := $(call create_obj_rule, hello)
这时$@可能已经变成了空值或错误值,因为此时还没有任何规则上下文。很多从shell转过来的朋友会在这里翻车。
3.2 带路径、带额外依赖的更完整版本
真实项目里文件名常常带着目录。把函数设计成“参数1=输出文件全名,参数2=源文件全名,参数3=额外的头文件路径”会更灵活:
makefile复制define compile_target
build/$(1): src/$(2).c include/config.h
mkdir -p build
$(CC) -c $(CFLAGS) -Iinclude -o build/$(1).o src/$(2).c
$(LD) -o build/$(1) build/$(1).o
endef
调用时传入多个值:
makefile复制$(eval $(call compile_target, web_server, server))
$(eval $(call compile_target, report_gen, reporter))
这种写法的好处非常明显:构建参数、目录结构、额外依赖都集中在一个函数定义里。以后项目要调整编译选项,只改一处,所有目标同时生效。如果你看过那些维护了几年的大项目,makefile里到处是这种“模板函数”,核心宗旨就是把重复收敛到一个逻辑点。
3.3 位置变量作为函数间继续传递的“话柄”
自定义函数不仅能接收普通字符串,还能接收其他函数的名字,然后在内部把它传入的位置变量传给下一个函数。
假设你有一个“发布”函数,希望针对不同程序执行不同的检查函数:
makefile复制define run_checks
@echo "check tmp"
$(1) $(2)
endef
define check_web
@echo "check web: $(1)"
endef
check:
$(call run_checks, $(call check_web, index.html), /tmp/web)
接收$(1)检查函数?不,例子不同。更直接:
makefile复制define dispatch
$(call $(1), $(2))
endef
define build_a
@echo "build a with $(1)"
endef
all:
$(call dispatch, build_a, hello)
也就是说,位置参数可以是另一个函数的名字,然后用$(call $(1), ...)动态调用。这在做插件式任务流时很好用,比如把编译器和打包器统一注册成函数名,外层任务通过位置参数决定要调度哪一个。
4. 实战进阶:批量生成安装、卸载、清理目标
4.1 一个安装程序模板
把位置变量用到批量目标管理上,是最能体现“减少重复”价值的场景。
先定义一个生成安装/卸载目标的函数:
makefile复制# $(1): 要安装的可执行文件名
# $(2): 源文件或构建产物的路径
# $(3): 安装目录
define generate_install_rule
.PHONY: install-$(1) uninstall-$(1)
install-$(1): $(2)
install -m 0755 $(2) $(3)/$(1)
uninstall-$(1):
rm -f $(3)/$(1)
endef
然后为每个要发布的程序调用一次:
makefile复制$(eval $(call generate_install_rule, app, build/app, /usr/local/bin))
$(eval $(call generate_install_rule, tool, build/tool, /usr/local/bin))
$(eval $(call generate_install_rule, helper, scripts/helper.sh, /usr/local/bin))
这条函数在解析阶段会产生六个目标:
install-appuninstall-appinstall-tooluninstall-toolinstall-helperuninstall-helper
之后你可以定义总的安装入口:
makefile复制.PHONY
