ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Makefile位置变量$(1)、$(2)完全解析:函数参数与自动变量的精髓

Makefile位置变量$(1)、$(2)完全解析:函数参数与自动变量的精髓 1. 先搞清楚$(1)、$(2)到底是什么宏和函数参数1.1 它和普通变量的本质区别很多刚接触Makefile的朋友会把$(1)、$(2)当成某种“特殊变量”其实它们本质上是函数参数。想象一下你写脚本时定义了一个函数func(a, b)在函数体里用a和b来引用参数。Makefile里的$(1)、$(2)就是这个意思只不过Makefile的函数定义方式有点特殊它用的是define来定义一个“宏”宏内部通过$(1)、$(2)来接收调用时传入的参数。普通变量是用或:赋值例如CC gcc你在任何地方$(CC)拿到的都是同一个值。但$(1)、$(2)这种位置变量离开了“函数调用”的上下文就是空字符串。这一点非常关键很多人踩坑就是因为把位置变量当普通变量用了。打个比方普通变量就像一个全局变量全程序可见可改而$(1)、$(2)就像函数的形式参数只有在函数内部才有意义出了函数体啥也不是。理解了这句话后面所有的用法你都能自己想明白了。1.2 用define定义一个带参数的“函数”Makefile里定义一个函数/宏的标准写法是defineendef。举个例子我要写一个编译并安装某个可执行文件的函数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 VARvalue$(1)、$(2)是函数内部的位置参数那整个Makefile能不能在命令行接收参数答案是可以而且非常常用。你运行make的时候直接带上变量赋值make会把它当成命令行变量在Makefile里用$(VAR)就能读到。make BUILD_TYPEreleaseMakefile里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: MODEproduction ./build.sh我实际踩过这个坑写了一个Makefile里面定义了一堆路径变量然后调用一个外部Python脚本脚本里读os.environ[DATA_DIR]。结果脚本跑到一半报KeyError原因就是Makefile的变量没有export子进程环境变量里根本没有。排查了一会儿才反应过来把变量加上export就好了。4.3 子make递归传递MAKEFLAGS与-C大型项目经常用递归make来管理子目录这就会涉及更复杂的变量传递问题。比如你在顶层执行make -C subdirmake会进入subdir目录查找那里的Makefile并执行。但顶层Makefile里定义的变量子make默认是看不到的。想让子make读到有几个办法export导出最简单ROOT_DIR : $(abspath .) export ROOT_DIR命令行传参all: $(MAKE) -C subdir VARhello注意要用$(MAKE)而不是直接写make这样MAKEFLAGS会把顶层命令行变量自动传给子make还能保留并行标志-j。通过MAKEFLAGS或MAKEOVERRIDES这机制比较高级简单说就是命令行传给顶层make的变量会被自动传递到子make。比如你执行make VARx顶层里$(MAKE) -C sub时子make自动能看到VARx因为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.含义是为了生成目标ymake需要依赖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)如果丢掉括号写成$1make会把$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.o和util.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)作为函数参数自动变量作为规则上下文命令行变量作为外部输入的“总开关”三者配合起来才能写出真正灵活、可维护的构建系统。上面这些坑我基本都踩过一轮希望这篇总结能帮你少走些弯路。
返回列表