Makefile位置变量$(1)、$(2)完全解析:函数参数与自动变量的精髓
2026/9/7 16:58:43 网站建设 项目流程

1. 先搞清楚$(1)、$(2)到底是什么:宏和函数参数

1.1 它和普通变量的本质区别

很多刚接触Makefile的朋友会把$(1)、$(2)当成某种“特殊变量”,其实它们本质上是函数参数。想象一下你写脚本时定义了一个函数func(a, b),在函数体里用ab来引用参数。Makefile里的$(1)$(2)就是这个意思,只不过Makefile的函数定义方式有点特殊,它用的是define来定义一个“宏”,宏内部通过$(1)$(2)来接收调用时传入的参数。

普通变量是用=:=赋值,例如CC = gcc,你在任何地方$(CC)拿到的都是同一个值。但$(1)$(2)这种位置变量离开了“函数调用”的上下文,就是空字符串。这一点非常关键,很多人踩坑就是因为把位置变量当普通变量用了。

打个比方:普通变量就像一个全局变量,全程序可见可改;而$(1)$(2)就像函数的形式参数,只有在函数内部才有意义,出了函数体啥也不是。理解了这句话,后面所有的用法你都能自己想明白了。

1.2 用define定义一个带参数的“函数”

Makefile里定义一个函数/宏的标准写法是define+endef。举个例子,我要写一个编译并安装某个可执行文件的函数:

define build_and_install @echo "Building $(1) from source: $(2)" $(CC) -o $(1) $(2) $(CFLAGS) install -m 755 $(1) /usr/local/bin/$(1) endef

这里$(1)表示第一个参数,$(2)表示第二个参数。光定义还不行,你得调用它才会实际执行。调用方式是$(call ...)

all: $(call build_and_install, myapp, src/main.c)

当make解析到$(call build_and_install, myapp, src/main.c)时,它会展开成define块里面的内容,同时把$(1)替换成myapp,把$(2)替换成src/main.c。最终执行效果等同于:

all: @echo "Building myapp from source: src/main.c" gcc -o myapp src/main.c $(CFLAGS) install -m 755 myapp /usr/local/bin/myapp

注意一个关键点:$(call ...)是在make解析阶段完成展开的,不是shell运行时展开的。所以如果define块里的命令用到了shell变量(比如$$HOME这种),一定要写成双美元符$$,让make先把$$转成$留给shell解释,否则make会尝试展开$HOME,结果就是空值。

1.3 参数匹配规则:$(1)对应第几个参数

$(call)的语法是$(call 函数名, 参数1, 参数2, 参数3, ...)。参数之间用逗号分隔,make解析时会把每个参数分别绑定到$(1)$(2)$(3)……这样依次对应下去。

我画个简单的对应关系:

call调用写法函数内部引用实际值
$(call f, a, b)$(1)a
$(call f, a, b)$(2)b
$(call f, a, b)$(3)
$(call f, x, y, z)$(3)z

参数数量超过定义时的预期也没关系,多传的会被忽略,少传的会成为空字符串。所以写函数的时候,尽量对参数做防御性处理,比如用$(if $(2),$(2),default)给默认值。

提示:$(call ...)的第一个参数是函数名,但它本身也可以是返回值,比如$(call $(FUNC_NAME), arg1),这种动态选择函数的写法在某些场景下特别有用。

2. 参数传递的细节与四个常见坑

2.1 参数里的空格和逗号,处理不好就翻车

Makefile的$(call ...)解析规则里,逗号是参数分隔符。如果你的参数本身需要包含逗号(比如传一个带逗号的列表),直接写是不行的,make会把逗号当作分隔符把参数劈成两半。解决办法是用变量中转一下:

COMMA := , define print_list @echo "List: $(1)" endef all: $(call print_list, a$(COMMA)b$(COMMA)c)

这里的$(COMMA)在call展开前就会被解析成逗号,然后再作为参数传进去,最终输出a,b,c。这个技巧我在写复杂构建脚本时经常用到,尤其是传多个flag组合的时候。

空格问题更隐蔽。$(call f, word)中,第一个字符空格会被当作参数的分隔符吞掉一部分,但参数中间和结尾的空格会被保留。看个例子:

define show @echo "start[$(1)] end[$(2)]" endef all: $(call show, hello , world)

这行调用里,第一个参数实际上会保留hello后面的空格,第二个参数会保留前面的前导空格。如果参数内容有严格匹配需求(比如拼路径、拼文件名),这种多余空格会造成“路径找不到”的诡异错误。排查思路是打日志时用[$(1)]这种包裹形式,一眼就能看出有没有粘着空格。

2.2 $(0)指向函数名本身,这个细节90%的人不知道

除了$(1)$(2)之外,还有一个$(0),它表示当前函数/宏的名字。看例子:

define my_debug @echo "Calling function: $(0), args: $(1)" endef all: $(call my_debug, test)

输出会是Calling function: my_debug, args: test。这个特性在调试多个宏的时候很有用,特别是在宏里面嵌套调用别的宏,想知道当前执行到哪个宏、参数是什么,直接$(0)打出来就行。

另外注意,$(call)展开时如果函数名或宏名写错了,make不会报错,而是把它当空值处理,这也是一个比较让人头疼的情况。我建议宏定义好后,写个简单的target专门验证一下展开结果:

debug_macro: $(info [$(call my_debug, hello)])

$(info ...)把展开结果打印出来,能直接看到宏有没有被正确解析。

2.3 参数嵌套:宏里再调宏,实现复用

位置变量的真正威力在于宏之间可以互相调用。你做了一套构建工具函数,A宏可以调B宏,B宏可以调C宏,参数可以继续往下传。比如我封装一个“编译并打包”的宏:

# 编译单个源文件 define compile_c $(CC) $(CFLAGS) -c $(1) -o $(2) endef # 编译并打包成静态库 define build_lib $(call compile_c, $(1), $(2).o) $(AR) rcs $(2).a $(2).o endef all: $(call build_lib, src/utils.c, libutils)

这里build_lib收到两个参数后,在自己内部调用compile_c,把参数继续传进去。最终会展开成:

gcc $(CFLAGS) -c src/utils.c -o libutils.o ar rcs libutils.a libutils.o

这种嵌套调用模式在大型项目里非常普遍,相当于把Makefile写成了“模板语言”。我自己的体会是,宏的粒度要控制在“一件事”的级别,比如“编译”一个宏、“链接”一个宏、“拷贝产物”一个宏,而不是写一个超长宏干所有事,否则维护起来是真痛苦,改一处参数,影响一大片。

3. 规则里的“位置变量”:自动变量全家桶

3.1 $@、$<、$^、$?四大金刚

说完了函数参数里的$(1)$(2),再讲另一批更常用、更容易混淆的“位置变量”——自动变量。它们自动出现在规则中,不需要你传参,make会自己填好值。

自动变量含义典型用途
$@当前目标文件名编译、链接、生成文件的命令主体
$<第一个依赖文件编译单个源文件时取输入
$^所有依赖文件(去重)链接时把全部.o文件拼起来
$?比目标新的依赖列表增量拷贝/增量处理
$*目标去掉扩展名的部分生成同名辅助文件

一个最典型的编译规则:

src/%.o: src/%.c $(CC) $(CFLAGS) -c $< -o $@
  • $@被替换成目标src/foo.o
  • $<被替换成第一个依赖src/foo.c
  • 如果写成$^也会得到src/foo.c,因为这里依赖只有一个

再看链接的例子:

myapp: main.o utils.o logger.o $(CC) -o $@ $^

$^会展开成main.o utils.o logger.o,正好拼成链接命令。用$^比手写依赖列表强多了,新增一个模块,只需要改依赖行,不用改命令。

$?用的场景相对少,但在做“打包变更文件”这种操作时很顶用。比如:

backup: $(SRCS) @tar czf backup.tar.gz $?

$?会列出所有比backup目标新的源文件,实现“只打包这次改过的文件”。

3.2 $(@D)、$(@F)等目录与文件名变体

基础自动变量解决了“目标是什么、依赖是什么”,但还有一个很实际的需求:目标或依赖的目录和文件名要分开处理。比如目标路径是build/obj/foo.o,你想创建build/obj目录,或者把目标名去掉路径打印出来,该怎么办?这时候就要用到带D和F后缀的变体变量。

变量含义示例(目标为 build/obj/foo.o)
$(@D)目标的目录部分build/obj
$(@F)目标的文件名部分foo.o
$(<D)第一个依赖的目录部分看依赖路径
$(<F)第一个依赖的文件名部分看依赖路径

注意写法是$(@D)$(@F),这种形式看起来像函数调用,其实是make内建语法,括号里是变量名。

我用这种变体写过一套“目录自动创建”的规则:

build/%.o: src/%.c @mkdir -p $(@D) $(CC) $(CFLAGS) -c $< -o $@

每次编译之前先mkdir -p目标所在目录,这样即使构建目录不存在也不会报错。$(@D)会自动展开成build或者build/obj这种层级路径,完全不用手动维护目录列表。

3.3 自动变量和函数参数组合拳

自动变量和位置变量不是互斥的,它们经常组合在一起用。比如我写一个宏来处理“一组目标文件列表”的编译规则:

define generate_rule build/$(1).o: src/$(1).c @mkdir -p $$(@D) $(CC) $(CFLAGS) -c $< -o $@ endef OBJS := main utils logger $(foreach obj, $(OBJS), $(eval $(call generate_rule, $(obj))))

这里用foreach遍历OBJS,每次调用generate_rule生成一条规则,并在规则里同时使用了函数参数$(1)和自动变量$@$<。注意在define块里,自动变量必须写成$$(@D)$$@,因为$(call)展开时先做一层变量解析,双美元符会转成单美元符,这样后面的自动变量才能被规则引擎正确识别。

这一套组合拳让我彻底摆脱了“一个源文件一条规则”的重复劳动,加新文件只需要往列表里加名字就行。

4. 跨Makefile与命令行的变量传递

4.1 命令行传参:make VAR=value

$(1)$(2)是函数内部的位置参数,那整个Makefile能不能在命令行接收参数?答案是可以,而且非常常用。你运行make的时候直接带上变量赋值,make会把它当成命令行变量,在Makefile里用$(VAR)就能读到。

make BUILD_TYPE=release

Makefile里:

ifeq ($(BUILD_TYPE), release) CFLAGS += -O2 else CFLAGS += -g endif

命令行变量的优先级极高,它甚至可以覆盖Makefile里用=:=?=定义的变量。所以如果你用CFLAGS这种通用名字,用户在命令行一传,你就得按用户的来。想强制忽略命令行传值,用override指令:

override CFLAGS += -Wall

这样就算用户在命令行传了CFLAGS-Wall也会被追加进去,不会被覆盖掉。

4.2 export与环境变量:子进程能不能看到

另外一个容易让人困惑的点:Makefile里的变量默认不会自动变成shell环境变量,除非你export它。

MODE := production # 这样下面的shell命令看不到MODE

想让shell命令(比如make里跑build.sh)读取这个变量,有两种方式:

export MODE := production

或者运行时单独指定:

all: MODE=production ./build.sh

我实际踩过这个坑:写了一个Makefile,里面定义了一堆路径变量,然后调用一个外部Python脚本,脚本里读os.environ["DATA_DIR"]。结果脚本跑到一半报KeyError,原因就是Makefile的变量没有export,子进程环境变量里根本没有。排查了一会儿才反应过来,把变量加上export就好了。

4.3 子make递归传递:MAKEFLAGS与-C

大型项目经常用递归make来管理子目录,这就会涉及更复杂的变量传递问题。比如你在顶层执行:

make -C subdir

make会进入subdir目录查找那里的Makefile并执行。但顶层Makefile里定义的变量,子make默认是看不到的。想让子make读到,有几个办法:

  1. export导出(最简单):

    ROOT_DIR := $(abspath .) export ROOT_DIR
  2. 命令行传参

    all: $(MAKE) -C subdir VAR=hello

    注意要用$(MAKE)而不是直接写make,这样MAKEFLAGS会把顶层命令行变量自动传给子make,还能保留并行标志-j

  3. 通过MAKEFLAGS或MAKEOVERRIDES:这机制比较高级,简单说就是命令行传给顶层make的变量,会被自动传递到子make。比如你执行make VAR=x,顶层里$(MAKE) -C sub时,子make自动能看到VAR=x,因为make把命令行变量放进了MAKEFLAGS

我比较推荐方案1加方案2结合:需要共享的项目级配置用export,临时修改某个模块参数时用命令行传参。这样两层职责清晰,不容易互相污染。

写个小例子:

# 顶层Makefile BUILD_DIR := build export BUILD_DIR export CFLAGS := -O2 -Wall all: $(MAKE) -C src $(MAKE) -C tests

src/Makefile里就能直接用$(BUILD_DIR)$(CFLAGS)了。

5. 常见错误与排查实录

5.1 make报“No rule to make target”是怎么回事

这个报错几乎每个写Makefile的人都会遇到。完整的报错长这样:

make: *** No rule to make target 'xxx.c', needed by 'y'. Stop.

含义是:为了生成目标y,make需要依赖xxx.c,但找不到生成xxx.c的规则,也找不到这个文件。常见原因如下:

  • 文件路径写错了,比如文件名大小写不对、目录层级不对
  • 依赖文件确实不存在,尤其是自动生成的文件漏了生成规则
  • 通配符模式匹配出问题,%.o: %.c没有对应上的.c文件

排查思路是先看看依赖文件在不在:

ls -l xxx.c

如果文件存在但make还是找不到,多半是路径问题。很多人在变量里拼路径时引用了位置参数,传进去的参数带着空格或者目录前缀不对,就会触发这个错。此时把Makefile里的变量打印出来看,比如加一句$(info xxx.c path: $<)临时调试。

5.2 “没有指明目标并且找不到makefile”

还有一句极高频率的报错:

make: *** No targets specified and no makefile found. Stop.

这个报错启动时就会发生,根本进入不了规则解析。原因很单纯:当前目录下没有Makefile,或者你指定的Makefile文件不存在。检查一下:

ls Makefile makefile GNUmakefile

如果文件名是Makefile.build或者别的自定义名,就要用-f指定:

make -f Makefile.build

其实这句报错还藏着一个细节:make查找的默认文件优先级是GNUmakefile->makefile->Makefile。如果你系统里碰巧有多个名字,make会优先用GNUmakefile,这个优先级在特殊项目里可能会坑到你,比如你跟同事共用仓库,有人提交了一个GNUmakefile,你原来的makefile就不生效了,诡异得很。

5.3 变量为空或展开时机错误

位置变量最常见的翻车场景是:写宏的时候觉得值应该传进来了,展开后却是空的。我归纳了三种情况:

  • 原因一:调用时参数多传了空格$(call f, arg1, arg2)看起来没问题,但arg2后面多个空格可能被当作参数的一部分,导致逻辑判断失败。
  • 原因二:宏定义里用了=而没注意递归展开=是递归展开,:=是立即展开。在宏体内引用$(1)一般没问题,但如果你在某个变量里缓存了宏名,用=定义时可能导致一层层重复展开,展开结果和预期差异很大。建议能用:=的地方别用=
  • 原因三:在菜谱(recipe)里用$1而不是$(1)。在shell命令行里,Makefile的变量是$(1),如果丢掉括号写成$1,make会把$1解释成$后面跟1,这等于引用了一堆奇怪的自动变量/变量名,结果基本是空或者错。所以老老实实写$(1)

下面这个调试手法帮我省了大量时间:

define my_func $(if $(1),,$(error my_func: argument 1 is empty!)) @echo "arg1=$(1)" endef all: $(call my_func,)

在宏体开头加一个$(if ...)检查,参数为空直接$(error),构建立刻停下来,还能打出具体是哪个宏的哪个参数出了问题。

5.4 依赖解析问题:像“依赖某个固件”最终却指向别处

网络热词里有个报错是关于某个内核模块的Makefile依赖了另一个固件包,导致整个构建失败。这类问题本质上是依赖关系不完整或路径错误。在Makefile里,如果你声明了一个依赖,make就会严格去找它。

比如:

obj-m += mydriver.o mydriver-objs := main.o util.o

这里mydriver.o依赖main.outil.o,如果其中任何一个源文件构建路径不对,整个模块编译就会失败。排查的时候一定要往“依赖链”上游看,不能只看报错的那一层。

我遇到过一种很隐蔽的情况:多个子目录同时生成同名文件(比如build/util.o),顶层并行make时出现了资源竞争,导致某个顺序依赖的文件时有时无。解决方法是给这类共享中间文件加上明确的中间规则,或者调整$(MAKE)子目录的调用顺序,必要时用ORDER_ONLY依赖:

build/obj/%.o: src/%.c | build/obj $(CC) -c $< -o $@ build/obj: mkdir -p $@

竖线后面的build/obj是order-only依赖:它只在不存在时才去创建,文件存在与否不影响目标是否重建,但能保证目录先建好。这种写法比在recipe里到处写mkdir -p干净多了。

6. 一些我自己常用的调试与组织技巧

文章最后分享几个我写Makefile时的习惯,希望对你有参考价值。

技巧一:把复杂的宏集中放到单独的文件里。我通常建一个rules.mk,所有define宏和公共变量定义放里面,业务Makefile通过include rules.mk引入。这样宏的复用性极高,新项目拷贝一份就有一整套构建工具函数。

技巧二:用$(info ...)$(warning ...)做展开调试。位置变量最容易出问题的就是“展开后是什么样”。不确定时,直接打印:

$(info call result: [$(call my_func, hello)])

在终端看到的就是make真正展开后的文本,一眼能看出空格、路径、参数有没有传对。这个习惯救过我无数次。

技巧三:传参时统一加引号还是不加引号,想清楚再决定。如果你的文件路径里可能有空格(macOS上常见),建议在宏内部用$(strip $(1))把参数前后空格去掉,再用引号包住路径:

define compile_one $(CC) $(CFLAGS) -c "$(strip $(1))" -o "$(strip $(2))" endef

如果路径本身含空格,不加引号的话shell会把它拆成多个参数,效果等于把文件路径搞碎。但加了引号之后,如果参数里又带了其它引号,反而会多重嵌套出错。所以我的经验是:源文件路径一律用相对路径且不含空格,宏内部再strip一次,从根上规避问题。

技巧四:注意make的并行对位置变量的影响。$(call ...)是在make解析阶段展开的,所以并行make -j8下位置变量的解析没有并发问题。但如果你在宏里调用shell命令并想用$(1)的值传入shell,一定要确保宏在每次调用时展开成独立命令。不要在define块里用全局的临时文件名,并行构建时两个目标可能同时写同一个临时文件,互相覆盖,跑完一次构建经常出现“玄学失败”。需要临时文件时,用目标的自动变量加$$$$(shell PID)拼唯一文件名:

define gen_temp @echo temp > /tmp/$(1)_$$$$.tmp @cat /tmp/$(1)_$$$$.tmp endef

$$$$会被make解析成$$再让shell解析成PID,每次执行都不同,基本不会冲突。

写Makefile这件事,难点从来不是语法,而是变量展开的时机和层次。位置变量$(1)$(2)作为函数参数,自动变量作为规则上下文,命令行变量作为外部输入的“总开关”,三者配合起来才能写出真正灵活、可维护的构建系统。上面这些坑我基本都踩过一轮,希望这篇总结能帮你少走些弯路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询