GNU-Make-之书-全-
GNU Make 之书(全)
原文:
zh.annas-archive.org/md5/f0d18e05fdd70e869851cff7a56db34e译者:飞龙
前言
我不再记得第一次接触 make 程序是什么时候了,但我想,和许多程序员一样,我当时是在尝试构建别人写的软件。像许多程序员一样,我可能对 make 的语法简洁性感到惊讶并被其吸引,却没有意识到这个通用程序背后隐藏的深度与强大功能。
经过多年的实际使用各种真实的 makefile,写博客分享我的发现,回答博客读者关于 GNU make 的问题后,我获得了现实世界的见解,并对 GNU make 产生了深深的敬意。这些见解中的许多来自我创办的公司——Electric Cloud,其中一个项目就是完全复制 GNU make 的功能。为了实现这一点,我深入学习了 GNU make 的手册;编写了无数个测试 makefile 来确保我的“GNU make”(用 C++ 编写)像真实程序一样运行;并花了数小时测试我的版本,与我们客户提供的大型真实世界的 makefile 进行对比。
从我使用 GNU make 的经验中,产生了我写书的想法,目的是分享一些技巧、警告、解决方案以及一些大大小小的可能性,帮助程序员最大限度地发挥这个有时困难但最终不可或缺的程序的作用。核心的 make 语法使得生成的 makefile 简洁易懂(至少小部分是这样),但却可能难以维护。幸运的是,make 提供了足够的功能来完成软件构建,而没有过多地加入额外功能。许多 make 的替代品虽然找到了自己的市场,但却未能取代 GNU make(以及其他类似的 make 程序)。
我希望这本书能够成为那些每天处理 makefile 的人,或者任何曾经想过“现在,我如何使用 make 来做 那个?”的人的实际帮助。如果你是 GNU make 的新手,我建议你从 第一章开始,逐步阅读整本书。否则,可以根据需要跳过某些部分。不管怎样,我希望你能找到一些有用的想法,帮助你减少调试 makefile 的时间,更多地享受快速构建的过程。
注意
由于 GNU make 对不同类型的空白符很敏感,因此每当需要使用制表符时,我就用了 → 为了清晰起见。
我特别想感谢以下几位在我的 makefile 改造和 GNU make 编程过程中给予我鼓励的人:Mike Maciag、Eric Melski、Usman Muzaffar(他出现在 第四章)、John Ousterhout,以及 GNU make 的维护者 Paul Smith。最后,我非常感激 No Starch Press 团队,他们在我突然给他们发邮件提议出版关于 GNU make 的书时,毫不犹豫地答应了;他们是一个非常棒的合作团队。
第一章 基础回顾
本章涵盖的内容可能被视为基本的 GNU make知识,但我们将重点讲解常见的误解功能,并澄清一些 GNU make中的混淆部分。它还涵盖了 GNU make版本 3.79.1、3.81、3.82 和 4.0 之间的差异。如果你使用的是 3.79.1 之前的版本,建议你升级。
本章绝不是官方 GNU make手册(自由软件基金会,2004 年)的替代品。我强烈推荐拥有一本该手册的副本。你也可以在* www.gnu.org/make/manual *找到手册。
将环境变量导入 GNU make
在 GNU make启动时,任何在环境中设置的变量都将在 makefile 内作为 GNU make变量可用。例如,考虑以下简单的 makefile:
$(info $(FOO))
如果在运行 GNU make时,环境变量FOO被设置为foo,那么这个 makefile 将输出foo,从而验证FOO确实在 makefile 内被设置为foo。你可以通过使用 GNU make的$(origin)函数来发现FOO是从哪里得到这个值的。尝试将下面的内容添加到 makefile 中(新部分用加粗表示):
$(info $(FOO) **$(origin FOO)**)
如果变量FOO在环境中定义,并自动导入到 GNU make中,$(origin FOO)将返回environment。当你运行 makefile 时,它应该输出foo environment。
可以在 makefile 中覆盖从环境导入的变量。只需设置其值:
**FOO=bar**
$(info $(FOO) $(origin FOO))
这将输出bar file。请注意,$(origin FOO)的值从environment变为file,表明该变量的值是在 makefile 内设置的。
可以通过在 GNU make的命令行中指定-e(或--environment-overrides)选项,来防止 makefile 中的定义覆盖环境中的值。将FOO设置为foo并加上-e命令行选项来运行上述 makefile 时,会输出foo environment override。请注意,在这里FOO的值来自环境(foo),并且$(origin FOO)的输出已经变为environment override,告知我们该变量来自环境,尽管它在 makefile 中被重新定义。只有当变量定义被真正覆盖时,override这个词才会出现;如果变量在环境中定义且没有在 makefile 中重新定义,$(origin)函数将只返回environment(没有override)。
如果你关心的只是变量是否从环境中获取了值,那么使用$(firstword $(origin VAR))始终能保证返回字符串environment,如果变量VAR的值来自环境,无论是否指定-e选项。
假设你完全希望保证变量FOO的值来自 makefile,而不是来自环境。你可以使用override指令来做到这一点:
**override** FOO=bar
$(info $(FOO) $(origin FOO))
这将输出 bar override,无论环境中 FOO 的值是多少,或者你是否指定了 -e 命令行选项。注意,$(origin) 会告诉你这是一个通过返回 override 的覆盖。
设置变量的另一种方法是通过在 GNU make 的命令行中设置它。例如,将你的 makefile 恢复为以下内容:
FOO=bar
$(info $(FOO) $(origin FOO))
在命令行上运行 FOO=foo make -e FOO=fooey 将输出 fooey command line。此时 $(origin FOO) 返回了 command line。现在尝试将覆盖命令重新添加到 makefile 中:
**override**
FOO=bar $(info $(FOO) $(origin FOO))
如果你在命令行上运行相同的命令(FOO=foo make -e FOO=fooey),它现在输出 bar override。
迷茫了吗?有一个简单的规则可以帮助你理清楚这一切:override 指令优先于命令行,命令行优先于环境覆盖(-e 选项),环境覆盖优先于 makefile 中定义的变量,而 makefile 中定义的变量又优先于原始环境。或者,你总是可以使用 $(origin) 来找出发生了什么。
从外部设置变量
在 makefile 中设置可以通过命令行指定的选项是很常见的。例如,你可能希望改变正在执行的构建类型,或者指定一个目标架构,甚至是在 makefile 外部指定。
最常见的用例之一是一个调试选项,用于指定构建时是否应该创建可调试或发布代码。处理此问题的一种简单方法是使用一个名为 BUILD_DEBUG 的 makefile 变量,它在 makefile 中设置为 yes,并在构建发布版本时通过命令行覆盖。例如,makefile 可能在开始的地方有这一行:BUILD_DEBUG := yes。然后,BUILD_DEBUG 变量会在 makefile 的其他地方使用,以决定如何设置编译器的调试选项。因为在 makefile 中设置了 BUILD_DEBUG := yes,默认情况下会进行调试构建。然后,在发布时,可以从命令行覆盖此默认值:
$ **make BUILD_DEBUG=no**
接近发布时,可能会有冲动在 shell 启动脚本中(例如 .cshrc 或 .bashrc)将 BUILD_DEBUG 设置为 no,这样所有的构建都是发布版本,而不是调试版本。不幸的是,这种方法不起作用,因为 GNU make 会从环境中继承变量,而 makefile 中的变量会覆盖环境变量。
考虑这个简单的 makefile,它打印出 BUILD_DEBUG 的值,而该值在 makefile 开头被设置为 yes:
BUILD_DEBUG := yes
.PHONY: all
all: ; @echo BUILD_DEBUG is $(BUILD_DEBUG)
注意
在这个例子中,all 目标相关的命令通过使用分号与目标名称放在同一行。另一种方法是:
BUILD_DEBUG := yes
.PHONY: all
all:
→ @echo BUILD_DEBUG is $(BUILD_DEBUG)
但这需要使用制表符来开始命令。当命令可以放在一行时,使用 GNU make 提供的分号格式会更清晰。
现在尝试运行 makefile 三次:一次不设置选项,一次在 GNU make 的命令行上设置 BUILD_DEBUG,还有一次在环境中设置 BUILD_DEBUG:
$ **make**
BUILD_DEBUG is yes
$ **make BUILD_DEBUG=no**
BUILD_DEBUG is no
$ **export BUILD_DEBUG=no**
$ **make**
BUILD_DEBUG is yes
最后一行显示了在 makefile 中定义的变量覆盖环境中的值。但请注意,如果BUILD_DEBUG在 makefile 中根本没有定义,它将自动从环境中继承并导入到 makefile 中。
使用 GNU make工具可以通过-e选项来解决 makefile 中定义覆盖导入的环境变量的问题,该选项使环境变量优先。但这会影响所有变量。
$ **export BUILD_DEBUG=no**
$ **make**
BUILD_DEBUG is yes
$ **make -e**
BUILD_DEBUG is no
$ **make -e BUILD_DEBUG=maybe**
BUILD_DEBUG is maybe
需要记住的规则是:命令行优先于 makefile,makefile 优先于环境。在命令行中定义的变量优先于在 makefile 中定义的同名变量,而 makefile 中的变量又优先于环境中定义的同名变量。
可以有一个默认设置为yes的BUILD_DEBUG变量,它可以在命令行或环境中被覆盖。GNU make提供了两种方法来实现这一点,这两种方法都依赖于检查变量是否已经定义。
这里有一种方法。将原始 makefile 中的BUILD_DEBUG设置替换为:
ifndef BUILD_DEBUG
BUILD_DEBUG := yes
endif
如果BUILD_DEBUG尚未设置(这就是ndef的意思:未定义),它将被设置为yes;否则,它将保持不变。因为输入ifndef SOME_VARIABLE和endif有点笨重,GNU make提供了一个简便的方式来实现这一模式,即?=运算符:
BUILD_DEBUG ?= yes
.PHONY: all
all: ; @echo BUILD_DEBUG is $(BUILD_DEBUG)
?=运算符告诉 GNU make将BUILD_DEBUG设置为yes,除非它已经被定义,在这种情况下保持不变。重新运行测试得到:
$ **make**
BUILD_DEBUG is yes
$ **make BUILD_DEBUG=no**
BUILD_DEBUG is no
$ **export BUILD_DEBUG=no**
$ **make**
BUILD_DEBUG is no
这种技术提供了最终的灵活性。makefile 中的默认设置可以通过环境或命令行中的临时覆盖来覆盖:
$ **export BUILD_DEBUG=no**
$ **make BUILD_DEBUG=aardvark**
BUILD_DEBUG is aardvark
注意
实际上,ifndef和?=在处理已定义但设置为空字符串的变量时有细微的区别。ifndef的意思是如果未定义即使已定义,而?=运算符将空的已定义变量视为已定义。这个差异在第四章中有更详细的讨论。
命令使用的环境
GNU make在执行命令时(例如它执行的任何规则中的命令)使用的环境是 GNU make启动时的环境,再加上 makefile 中导出的任何变量—以及 GNU make自己添加的一些变量。
请考虑这个简单的 makefile:
FOO=bar
all: ; @echo FOO is $$FOO
首先,注意双$符号:它是一个转义的$,意味着 GNU make传递给 shell 的命令是echo FOO is $FOO。你可以使用双$来在 shell 中获得一个单一的$符号。
如果你在环境中没有定义FOO,运行这个 makefile 时,你会看到输出FOO is。因为 makefile 没有特别地将FOO导出到 GNU make用来运行命令的环境中,所以FOO的值没有被设置。因此,当 shell 执行all规则的echo命令时,FOO没有被定义。如果在运行 GNU make之前,环境中已经将FOO设置为foo,你将看到输出FOO is bar。这是因为FOO在 GNU make启动时已经存在于环境中,随后 makefile 中将bar的值赋给了它。
$ **export FOO=foo**
$ **make**
FOO is bar
如果你不确定FOO是否在环境中,但想确保它进入用于命令的环境,可以使用export指令。例如,你可以通过修改 makefile,确保FOO出现在子进程的环境中,如下所示:
**export** FOO=bar
all: ; @echo FOO is $$FOO
另外,你可以单独在一行写上export FOO。在这两种情况下,FOO将被导出到运行all规则命令的环境中。
你可以使用unexport将一个变量从环境中移除。为了确保FOO被排除在子进程的环境之外,无论它是否在父环境中设置,都可以运行以下命令:
FOO=bar
**unexport FOO**
all: ; @echo FOO is $$FOO
你会看到输出FOO is。
你可能会想知道,如果你export并unexport一个变量,会发生什么。答案是,最后一个指令会生效。
export指令也可以与目标特定变量一起使用,以便只修改某个特定规则的环境。例如:
export FOO=bar
all: export FOO=just for all
all: ; @echo FOO is $$FOO
makefile 将FOO设置为just for all用于all规则,而对其他任何规则则设置为bar。
注意,你不能使用目标特定的unexport从特定规则的环境中移除FOO。如果你写all: unexport FOO,你将得到一个错误。
GNU make还会向子进程环境中添加一些变量,特别是MAKEFLAGS、MFLAGS和MAKELEVEL。MAKEFLAGS和MFLAGS变量包含命令行上指定的标志:MAKEFLAGS包含用于 GNU make内部使用的格式化标志,而MFLAGS仅用于历史原因。在配方中不要使用MAKEFLAGS。如果确实需要,可以设置MFLAGS。MAKELEVEL变量包含递归make调用的深度,使用$(MAKE),从零开始。有关这些变量的更多细节,请参阅 GNU make手册。
你也可以通过单独写一行export或指定.EXPORT_ALL_VARIABLES:来确保每个 makefile 变量都会被导出。但这些“扫射式”的方法可能不好,因为它们会把一些无用的—甚至可能有害的—变量填充到子进程环境中。
$(shell)环境
你可能会期望$(shell)调用所使用的环境与规则命令执行时所使用的环境相同。事实上,并不是这样的。$(shell)使用的环境与 GNU make启动时的环境完全相同,没有任何变化。你可以通过以下 makefile 来验证这一点,该 makefile 从$(shell)调用和规则中获取FOO的值:
export FOO=bar
$(info $(shell printenv | grep FOO))
all: ; @printenv | grep FOO
这会输出:
$ **export FOO=foo**
$ **make**
FOO=foo
FOO=bar
无论你做什么,$(shell)都会获取父环境。
这是 GNU make中的一个错误(错误 #10593——详情请见 savannah.gnu.org/bugs/?10593)。之所以没有修复的部分原因是,显而易见的解决方案——在$(shell)中使用规则环境——会带来一个相当糟糕的后果。考虑这个 makefile:
export FOO=$(shell echo fooey)
all: ; @echo FOO is $$FOO
all规则中FOO的值是多少?要获取all环境中的FOO的值,必须展开$(shell),这就需要获取FOO的值——这又需要展开$(shell)调用,依此类推,无止境。
面对这个问题,GNU make的开发者选择了简单的解决方法:他们根本没有修复这个错误。
鉴于这个错误目前不会消失,因此需要一个解决方法。幸运的是,大多数合适的 shell 都有一种方法可以内联设置环境变量。所以这一节中的第一个 makefile 可以改成:
export FOO=bar
$(info $(shell **FOO=$(FOO)** printenv | grep FOO))
all: ; @printenv | grep FOO
这将得到期望的结果:
$ **make**
FOO=bar
FOO=bar
它通过在$(shell)函数使用的 shell 中设置FOO的值,采用FOO=$(FOO)语法来实现。因为$(shell)的参数在执行前就会展开,所以变成了FOO=bar,其值来自于 makefile 中设置的FOO的值。
如果只需要一个额外的变量在环境中,这种方法效果很好。但如果需要很多变量,就有点麻烦了,因为在一行命令中设置多个 shell 变量会变得混乱。
一个更全面的解决方案是编写一个替代$(shell)命令的脚本,该命令确实导出变量。以下是一个函数env_shell,它正是做了这件事:
env_file = /tmp/env
env_shell = $(shell rm -f $(env_file))$(foreach V,$1,$(shell echo export
$V=$($V) >> $(env_file)))$(shell echo '$2' >> $(env_file))$(shell /bin/bash -e
$(env_file))
在我解释它是如何工作的之前,这里是如何在之前的 makefile 中使用它的方法。你只需将$(shell)更改为$(call env_shell)。env_shell的第一个参数是需要添加到环境中的变量列表,而第二个参数是要执行的命令。以下是更新后的 makefile,其中FOO已被导出:
export FOO=bar
$(info $(**call env_shell,FOO,printenv** | grep FOO))
all: ; @printenv | grep FOO
当你运行这个时,你将看到如下输出:
$ **make**
FOO=bar
FOO=bar
现在回到env_shell是如何工作的。首先,它创建一个 shell 脚本,将其第一个参数中的所有变量添加到环境中;然后,它执行第二个参数中的命令。默认情况下,shell 脚本存储在env_file变量指定的文件中(该变量之前设置为/tmp/env)。
/tmp/env 最终包含:
export FOO=bar
printenv | grep FOO
我们可以将对env_shell的调用分解为四个部分:
-
它通过
$(shell rm -f $(env_file))删除/tmp/env。 -
它通过循环
$(foreach V,$1,$(shell echo export $V=$($V) >> $(env_file)))添加包含每个变量定义的行,这些变量在第一个参数($1)中指定。 -
它将实际的执行命令(位于第二个参数
$2中)附加到$(shell echo '$2' >> $(env_file))。 -
它使用
-e选项通过调用shell来运行/tmp/env:$(shell /bin/bash -e $(env_file))。
这不是一个完美的解决方案;如果 GNU make能自动决定应该放入环境中的内容,那就太好了。但这是一个可行的解决方案,直到 GNU make的开发者修复这个 bug。
目标特定变量和模式特定变量
每个 GNU make用户都熟悉 GNU make的变量。所有 GNU make用户都知道,变量本质上具有全局作用域。一旦它们在 makefile 中定义,就可以在 makefile 的任何地方使用。但有多少 GNU make用户熟悉 GNU make的局部作用域目标特定变量和模式特定变量呢?本节介绍了目标特定变量和模式特定变量,并展示了如何根据目标的名称或构建的目标来选择性地更改构建过程中的选项。
目标特定变量
示例 1-1 展示了一个简单的 makefile 示例,说明了 GNU make中全局作用域和局部作用域之间的区别:
示例 1-1。一个包含四个虚拟目标的 makefile 示例
.PHONY: all foo bar baz
➊ VAR = global scope
all: foo bar
all: ; @echo In $@ VAR is $(VAR)
foo: ; @echo In $@ VAR is $(VAR)
➋ bar: VAR = local scope
bar: baz
bar: ; @echo In $@ VAR is $(VAR)
baz: ; @echo In $@ VAR is $(VAR)
该 makefile 有四个目标:all,foo,bar和baz。所有四个目标都是虚拟的;因为我们现在仅关注展示全局和局部作用域,这个 makefile 实际上并不生成任何文件。
all目标要求构建foo和bar,而bar依赖于baz。每个目标的命令做的事情相同——它们使用shell echo打印变量VAR的值。
VAR变量最初在➊处定义为global scope。这是VAR在 makefile 中任何地方的值——除非,当然,这个值被目标特定或模式特定变量覆盖。
为了说明局部作用域,VAR在➋处被重新定义为local scope,用于创建bar的规则。目标特定变量的定义与普通变量的定义完全相同:它使用相同的=,:=,+=,和?=操作符,但它前面会加上目标名称(及其冒号),用于定义该目标的变量。
如果你在这个 makefile 上运行 GNU make,你将看到示例 1-2 所示的输出。
示例 1-2。来自示例 1-1 的输出,显示了全局和局部作用域变量
$ **make**
In foo VAR is global scope
In baz VAR is local scope
In bar VAR is local scope
In all VAR is global scope
你可以清楚地看到,GNU make遵循其标准的深度优先、从左到右的搜索模式。首先它构建foo,因为它是all的第一个先决条件。然后构建baz,它是bar的先决条件,all的第二个先决条件。接着构建bar,最后构建all。
果然,在bar的规则中,VAR的值是local scope。因为在all或foo中没有VAR的局部定义,所以在这些规则中,VAR的值是global scope。
那baz怎么办呢?makefile 的输出显示baz中的VAR值为local scope,但实际上并没有为baz显式地定义针对特定目标的VAR。这是因为baz是bar的先决条件,因此它具有与bar相同的局部作用域变量。
针对特定目标的变量不仅适用于目标本身,还适用于该目标的所有先决条件,以及所有它们的先决条件,依此类推。针对特定目标的变量作用域是整个目标树,从定义该变量的目标开始。
请注意,由于all、foo、bar和baz的配方完全相同,因此可以将它们写在一行上,如下所示:
all foo bar baz: ; @echo In $@ VAR is $(VAR)
但在这一节中,我避免了使用多个目标,因为这有时会引起混淆(许多 GNU make用户认为这一行代表一个单一规则,将同时为all、foo、bar和baz执行,但实际上它是四个独立的规则)。
特定模式变量
特定模式的变量工作方式与针对特定目标的变量类似。不过,它们不是为某个目标定义的,而是为某个模式定义的,并应用于所有匹配该模式的目标。以下示例类似于示例 1-1,但已修改为包含特定模式的变量:
.PHONY: all foo bar baz
VAR = global scope
all: foo bar
all: ; @echo In $@ VAR is $(VAR)
foo: ; @echo In $@ VAR is $(VAR)
bar: VAR = local scope
bar: baz
bar: ; @echo In $@ VAR is $(VAR)
baz: ; @echo In $@ VAR is $(VAR)
➊ f%: VAR = starts with f
最后一行 ➊ 将VAR的值设置为starts with f,适用于任何以f开头并跟着其他任何内容的目标(即%通配符)。(也可以使用多个目标来实现这一点,但现在先不讨论这个。)
现在,如果你运行make,你会看到如下输出:
$ **make**
In foo VAR is starts with f
In baz VAR is local scope
In bar VAR is local scope
In all VAR is global scope
这与示例 1-2 是相同的,只是foo规则中VAR的值已通过特定模式的定义设置为starts with f。
值得注意的是,这与 GNU make的模式规则无关。你可以使用特定模式的变量定义来更改常规规则中变量的值。你也可以在模式规则中使用它。
例如,假设一个 makefile 使用内建的%.o: %.c模式规则:
%.o: %.c
# commands to execute (built-in):
→ $(COMPILE.c) $(OUTPUT_OPTION) $<
可以使用特定模式的变量为每个该规则构建的.o文件设置一个变量。下面是如何为每个.o文件将-g选项添加到CFLAGS中的方法:
%.o: CFLAGS += -g
在一个项目中,通常会有一个标准的规则来编译文件,而对于某些特定的文件或文件集合,可能需要稍微不同版本的规则,尽管这些文件最终使用的是相同的命令。例如,以下是一个 makefile,它使用模式规则构建两个子目录(lib1 和 lib2)中的所有 .c 文件:
lib1_SRCS := $(wildcard lib1/*.c)
lib2_SRCS := $(wildcard lib2/*.c)
lib1_OBJS := $(lib1_SRCS:.c=.o)
lib2_OBJS := $(lib2_SRCS:.c=.o)
.PHONY: all
all: $(lib1_OBJS) $(lib2_OBJS)
➊ %.o: %.c ; @$(COMPILE.C) -o $@ $<
首先,makefile 会将lib1/下所有的 .c 文件列出并存入 lib1_SRCS 变量,将lib2/下的 C 文件列出并存入 lib2_SRCS。然后,它使用替换引用将这些文件转换为目标文件列表,将 .c 文件转换为 .o 文件,并将结果存储在 lib1_OBJS 和 lib2_OBJS 中。最后一行的模式规则 ➊ 使用了 GNU make 的内建变量 COMPILE.C 来运行编译器,将 .c 文件编译成 .o 文件。makefile 会构建 lib1_OBJS 和 lib2_OBJS 中的所有目标文件,因为它们是 all 的前提条件。lib1_OBJS 和 lib2_OBJS 都包含了对应 .c 文件的 .o 文件列表。当 GNU make 搜索 .o 文件(即 all 的前提条件)时,它发现这些文件缺失,但可以使用 %.o: %.c 规则来构建它们。
如果所有的 .c 文件使用相同的编译选项,这样做是没问题的。但假设 .c 文件lib1/special.c需要 -Wcomment 选项来防止编译器因注释书写不规范而发出警告。显然,可以通过在 makefile 中添加 CPPFLAGS += -Wcomment 这一行来全局更改 CPPFLAGS 的值。但是,这样的修改会影响每个编译过程,这可能并不是你想要的效果。
幸运的是,你可以使用目标特定变量仅为该单个文件更改 CPPFLAGS 的值,如下所示:
lib1/special.o: CPPFLAGS += -Wcomment
这一行会只在创建lib1/special.o时更改 CPPFLAGS 的值。
假设一个子目录需要一个特殊的 CPPFLAGS 选项来最大化速度优化(例如 gcc 的 -fast 选项)。在这种情况下,使用特定模式的变量定义是最理想的:
lib1/%.o: CPPFLAGS += -fast
这样就能解决问题。所有在lib1/下构建的 .o 文件都将使用 -fast 命令行选项来编译。
版本检查
因为 GNU make 经常更新并不断添加新特性,了解当前运行的 GNU make 版本或是否支持某些特定功能非常重要。你可以通过两种方式来做到这一点:要么查看 MAKE_VERSION 变量,要么查看 .FEATURES 变量(该变量在 GNU make 3.81 版本中添加)。你也可以检查特定的功能,比如 $(eval)。
MAKE_VERSION
MAKE_VERSION 变量包含正在处理该 makefile 的 GNU make 版本号。在这里是一个示例 makefile,它打印出 GNU make 的版本并停止执行:
.PHONY: all
all: ; @echo $(MAKE_VERSION)
下面是当 GNU make 3.80 解析这个 makefile 时生成的输出:
$ **make**
3.80
如果你想确定 GNU make 版本 3.80 或更高版本正在处理你的 Makefile,怎么办?如果假设版本号始终为 X.YY.Z 或 X.YY 形式,那么以下代码片段将在 need 中提到的版本小于或等于运行版本时,将 ok 变量设置为非空。
need := 3.80
ok := $(filter $(need),$(firstword $(sort $(MAKE_VERSION) $(need))))
如果 ok 不为空,则表示正在使用所需版本的 GNU make 或更高版本;如果为空,则表示版本过旧。该代码片段通过创建一个以空格分隔的 GNU make 运行版本列表(存储在 MAKE_VERSION 中)和所需版本(来自 need)来工作,并对该列表进行排序。假设运行的版本是 3.81,那么 $(sort $(MAKE_VERSION) $(need)) 将是 3.80 3.81。该列表的 $(firstword) 是 3.80,因此 $(filter) 调用将保留 3.80,从而 ok 变量将非空。
现在假设运行版本是 3.79.1,那么 $(sort $(MAKE_VERSION) $(need)) 将是 3.79.1 3.80,$(firstword) 将返回 3.79.1。$(filter) 调用将移除 3.79.1,因此 ok 将为空。
注意
此代码片段在 GNU make 版本从 10.01 开始时将无法正确工作,因为它假设主版本号为单数位数。幸运的是,这还需要很长时间!
.FEATURES
GNU make 3.81 引入了 .FEATURES 默认变量,该变量包含一个支持特性的列表。在 GNU make 3.81 中,.FEATURES 列出了并支持七个特性:
-
archives。使用archive(member)语法归档(ar)文件 -
check-symlink。-L和--check-symlink-times标志 -
else-if。非嵌套形式else if X的 else 分支 -
jobserver。使用作业服务器并行构建 -
order-only。order-only先决条件支持 -
second-expansion。先决条件列表的双重展开 -
target-specific。目标特定和模式特定变量
GNU make 3.82 添加并支持以下内容:
-
oneshell。.ONESHELL特殊目标 -
shortest-stem。在选择匹配目标的模式规则时使用最短的词干选项 -
undefine。undefine指令
并且 GNU make 4.0 添加了以下内容:
-
guile。如果 GNUmake是在支持 GNU Guile 的环境下构建的,那么该功能将会存在,并且$(guile)函数将被支持。 -
load。支持加载动态对象以增强 GNUmake功能。 -
output-sync。支持-O(和--output-sync)命令行选项。
你可以在 近期的 GNU make 版本:3.81,3.82 和 4.0 中找到更多关于这些以及其他许多特性的详细信息。
若要检查是否有特定功能可用,可以使用以下 is_feature 函数:如果请求的功能受支持,则返回 T,如果功能缺失,则返回空字符串:
is_feature = $(if $(filter $1,$(.FEATURES)),T)
例如,下面的 makefile 使用is_feature来回显archives特性是否可用:
.PHONY: all
all: ; @echo archives are $(if $(call is_feature,archives),,not )available
下面是使用 GNU make 3.81 时的输出:
$ **make**
archives are available
如果你想检查.FEATURES变量是否被支持,可以使用如 MAKE_VERSION 中所述的MAKE_VERSION,或者简单地展开.FEATURES并查看其是否为空。以下的 makefile 片段正是执行这一操作,如果.FEATURES变量存在并且包含任何特性,则将has_features设置为T(代表 true):
has_features := $(if $(filter default,$(origin .FEATURES)),$(if $(.FEATURES),T))
该片段首先使用$(origin)来检查.FEATURES变量是否是默认变量;这样,如果有人在 makefile 中定义了.FEATURES,has_features就不会被误导。如果它是默认变量,第二个$(if)会检查.FEATURES是否为空。
检测$(eval)
$(eval)函数是一个强大的 GNU make特性,新增于版本 3.80。$(eval)的参数会被展开,然后解析,仿佛它是 makefile 的一部分,从而允许你在运行时修改 makefile。
如果你使用$(eval),那么重要的是要检查该特性是否在读取你的 makefile 的 GNU make版本中可用。你可以使用前面提到的MAKE_VERSION来检查版本是否为 3.80。或者,你也可以使用以下代码片段,这段代码只有在$(eval)被实现时才会将eval_available设置为T:
$(eval eval_available := T)
如果$(eval)不可用,GNU make将寻找一个名为eval eval_available := T的变量并尝试获取其值。当然,这个变量并不存在,因此eval_available将被设置为空字符串。
你可以使用eval_available配合ifneq来生成一个致命错误,如果$(eval)没有被实现的话。
ifneq ($(eval_available),T)
$(error This makefile only works with a Make program that supports $$(eval))
endif
eval_available函数特别有用,如果你无法检查MAKE_VERSION,例如,如果你的 makefile 是通过非 GNU 的make工具运行的,如clearmake或emake。
使用布尔值
GNU make的$(if)函数和ifdef构造都将空字符串和未定义的变量视为 false,其他任何内容视为 true。但它们在评估参数时有细微的不同。
$(if)函数,也就是$(if X,if-part,else-part),会在X不为空时展开if-part,否则展开else-part。在使用$(if)时,条件会被展开,并且展开后的值会被测试是否为空。以下代码片段报告了它走了else-part分支:
EMPTY =
VAR = $(EMPTY)
$(if $(VAR),$(info if-part),$(info else-part))
而接下来的片段则走了if-part分支,因为HAS_A_VALUE具有非空的值。
HAS_A_VALUE = I'm not empty
$(if $(HAS_A_VALUE),$(info if-part),$(info else-part))
ifdef构造的工作方式略有不同:它的参数是一个变量的名称,并不会进行展开:
ifdef VAR
if-part...
else
else-part...
endif
上述示例会在变量VAR不为空时执行if-part,而在VAR为空或未定义时执行else-part。
条件中的未定义变量
因为 GNU make将未定义的变量视为空值,ifdef实际上应该叫做ifempty——特别是因为它将已定义但为空的变量视为未定义。例如,以下代码片段报告VAR未定义:
VAR =
ifdef VAR
$(info VAR is defined)
else
$(info VAR is undefined)
endif
在实际的 makefile 中,这可能不是预期的结果。你可以通过--warn-undefined-variables命令行选项来请求未定义变量的警告。
ifdef的另一个细微差别是,它不会展开变量VAR。它只是检查VAR是否已被定义为非空值。以下代码片段报告VAR已定义,即使其完全展开后的值是空字符串:
EMPTY =
VAR = $(EMPTY)
ifdef VAR
$(info VAR is defined)
else
$(info VAR is not defined)
endif
GNU make 3.81 版本为ifdef引入了另一个变化:它的参数会被展开,从而可以计算出被测试的变量名。这对条件语句,如ifdef VAR没有影响,但允许你编写如下代码:
VAR_NAME = VAR
VAR = some value
ifdef $(VAR_NAME)
$(info VAR is defined)
else
$(info VAR is not defined)
endif
这与以下内容完全相同:
VAR = some value
ifdef VAR
$(info VAR is defined)
else
$(info VAR is not defined)
endif
在这两种情况下,VAR被检查是否为空,就像之前描述的那样,在两个输出中都会显示VAR is defined。
一致的真值
GNU make将任何非空字符串视为真。但如果你经常与真值和$(if)打交道,使用一个一致的真值可能会更方便。以下make-truth函数将任何非空字符串转为T:
make-truth = $(if $1,T)
请注意,我们可以去掉$(if)中的else部分,因为它是空的。在本书中,我会省略那些不必要的参数,而不是用多余的尾随逗号污染 makefile。但如果让你更舒服,你完全可以写$(if $1,T,)。
以下所有对make-truth的call都会返回T:
➊ $(call make-truth, )
$(call make-truth,true)
$(call make-truth,a b c)
即使是➊也返回T,因为通过$(call)调用的函数的参数在放入$1、$2等变量之前,并不会进行任何修改——甚至不会去除首尾的空格。因此,第二个参数是一个包含单个空格的字符串,而不是空字符串。
以下所有代码都会返回空字符串(表示假):
➋ $(call make-truth,)
EMPTY =
$(call make-truth,$(EMPTY))
VAR = $(EMPTY)
$(call make-truth,$(VAR))
仔细观察➊和➋之间的区别:GNU make中的空格可能非常重要!
使用布尔值的逻辑操作
GNU make在 3.81 版本之前没有内建的逻辑运算符,直到那个版本才加入了$(or)和$(and)。然而,创建操作布尔值的用户自定义函数非常容易。这些函数通常使用 GNU make的$(if)函数来做决策。$(if)将任何非空字符串视为'true',将空字符串视为'false'。
用户自定义逻辑运算符
让我们创建一个用户自定义的最简单逻辑运算符or。如果任意一个参数为真(即非空字符串),结果也应该是非空字符串。我们可以通过简单地连接参数来实现这一点:
or = $1$2
你可以在 一致的布尔值 中使用 make-truth 函数来清理 or 的结果,使其变为 T(真)或空字符串(假):
or = $(call make-truth,$1$2)
或者,对于更简洁的版本,你只需要写:
or = $(if $1$2,T).
以下所有的返回 T:
$(call or, , )
$(call or,T,)
$(call or, ,)
$(call or,hello,goodbye my friend)
从 or 中返回假值的唯一方法是传入两个空的参数:
EMPTY=
$(call or,$(EMPTY),)
定义 and 稍微复杂一些,需要两次调用 $(if):
and = $(if $1,$(if $2,T))
不需要将其包装在 make-truth 中,因为如果其参数非空,它总是返回 T,如果任一参数为空,则返回空字符串。
定义 not 只是一个简单的 $(if):
not = $(if $1,,T)
在定义了 and、or 和 not 之后,你可以快速创建其他逻辑运算符:
nand = $(call not,$(call and,$1,$2)) nor = $(call not,$(call or,$1,$2))
xor = $(call and,$(call or,$1,$2),$(call not,$(call and,$1,$2)))
这些也有简化版本,只需要使用 $(if):
nand = $(if $1,$(if $2,,T),T)
nor = $(if $1$2,,T)
xor = $(if $1,$(if $2,,T),$(if $2,T))
作为练习,试着编写一个 xnor 函数!
内建逻辑运算符(GNU make 3.81 及以后版本)
GNU make 3.81 及以后版本有内建的 and 和 or 函数,这些函数比之前定义的版本更快,因此在可能的情况下,最好使用这些内建函数。你应该测试 and 和 or 函数是否已存在,只有在它们不存在时才定义你自己的版本。
确定 and 和 or 是否已定义的最简单方法是尝试使用它们:
have_native_and := $(and T,T)
have_native_or := $(or T,T)
这些变量只有在内建的 and 和 or 函数存在时才会是 T。在 GNU make 3.81 之前的版本(或类似 clearmake 的模拟程序)中,have_native_and 和 have_native_or 将为空,因为 GNU make 找不到名为 and 或 or 的函数,也找不到名为 and T、T 或 or T、T 的变量!
你可以使用 ifneq 来检查这些调用的结果,并仅在必要时定义你自己的函数,像这样:
ifneq ($(have_native_and),T)
and = $(if $1,$(if $2,T))
endif
ifneq ($(have_native_or),T)
or = $(if $1$2,T)
endif
$(info This will be T: $(call and,T,T))
你可能会担心,你已经在各处写了 $(call and,...) 和 $(call or,...),用 call 来调用你自己的逻辑运算符。你是不是需要将它们全部改成 $(and) 和 $(or)——去掉 call 来使用内建的运算符?
这是不必要的。GNU make 允许使用 call 关键字调用任何内建函数,因此 $(and...) 和 $(call and,...) 都会调用内建运算符。然而,相反的情况 并不 成立:无法通过编写 $(foo arg1,arg2) 来调用 用户定义 的函数 foo。你必须写成 $(call foo,arg1,arg2)。
因此,定义你自己的 and 和 or 函数,并在 GNU make 3.81 或更高版本下优雅地运行,只需要前面显示的几行来定义 and 和 or——不需要其他更改。
请注意,内建函数和用户定义的版本之间有一个重要区别。如果第一个参数完全决定了其真值,内建版本将不会评估第二个参数。例如,如果 $a 为假,则 $(and $a,$b) 不需要查看 $b 的值;如果 $a 为真,则 $(or $a,$b) 不需要查看 $b 的值。
如果您需要这种行为,则不能使用前面的用户定义版本,因为在执行$(call)函数时,所有参数都会被展开。替代方案是将$(call and,X,Y)替换为$(if X,$(if Y,T)),将$(call or,X,Y)替换为$(if X,T,$(if Y,T))。
命令检测
有时,在 makefile 中快速返回错误信息,如果构建系统中缺少特定软件会非常有用。例如,如果 makefile 需要curl程序,在解析时(即 make 加载 makefile 时)检查系统中是否存在curl会比在构建过程中才发现它不存在更为有用。
查找命令是否可用的最简单方法是使用which命令,并将其放在$(shell)调用中。如果命令不存在,则返回空字符串;如果命令存在,则返回命令的路径,这与make的空字符串表示假,非空字符串表示真逻辑非常契合。
例如,以下代码在curl存在时将HAVE_CURL设置为非空字符串:
HAVE_CURL := $(shell which curl)
然后,您可以使用HAVE_CURL来停止构建并在curl缺失时输出错误:
ifndef HAVE_CURL
$(error curl is missing)
endif
以下的assert-command-present函数将此逻辑封装为一个便捷的函数。调用assert-command-present并传入命令的名称,如果命令缺失,构建将立即退出并输出错误。以下示例使用assert-command-present检查curl和名为curly的命令是否存在:
assert-command-present = $(if $(shell which $1),,$(error '$1' missing and needed for this build))
$(call assert-command-present,curl)
$(call assert-command-present,curly)
如果在一个有curl但没有curly的系统上运行这段代码,会发生以下情况:
$ **make**
Makefile:4: *** 'curly' missing and needed for this build. Stop.
如果一个命令仅由某些构建目标使用,那么仅在相关目标下使用assert-command-present是有用的。以下的 makefile 将在download目标作为构建的一部分实际使用时,检查curly是否存在:
all: ; @echo Do all...
download: export _check = $(call assert-command-present,curly)
download: ; @echo Download stuff...
download目标的第一行设置了一个名为_check的目标特定变量,并将其导出为对assert-command-present调用的结果。这会导致$(call)仅在download作为构建的一部分时发生,因为当准备将其插入到配方的环境中时,_check的值会被展开。例如,make all不会检查curly是否存在:
$ **make**
Do all...
$ **make download**
Makefile:5: *** 'curly' missing and needed for this build. Stop.
请注意,这个 makefile 定义了一个名为_的变量,您可以通过$(_)甚至$_来访问它。使用下划线作为名称是一种表示该变量只是占位符,并且其值应该被忽略的方法。
延迟变量赋值
GNU make提供了两种定义变量的方式:简单的:=操作符和递归的=操作符。简单的:=操作符会立即评估右侧的值,并使用结果值来设置变量的值。例如:
BAR = before
FOO := $(BAR) the rain
BAR = after
这个代码片段会导致FOO的值为before the rain,因为当使用:=设置FOO时,BAR的值为before。
相比之下,
BAR = before
FOO = $(BAR) the rain
BAR = after
这导致FOO的值为$(BAR) the rain,而$(FOO)的值为after the rain。这是因为=定义了一个递归变量(可以包含其他变量引用的变量,使用$()或${}语法),其值在每次使用该变量时被确定。相比之下,使用:=定义的简单变量在定义时通过立即展开所有变量引用来确定一个固定的值。
简单变量具有明显的速度优势,因为它们是固定字符串,不需要每次使用时都进行展开。它们的使用可能有些棘手,因为 makefile 编写者常常假设变量可以按任意顺序设置,因为递归定义的变量(用=设置的变量)只有在使用时才会获得最终值。然而,简单变量通常比递归变量更快速访问,如果可能,我倾向于总是使用:=。
但是如果你能够兼顾两者的优点呢?一个变量,在首次使用时才会被设置,但它会被设定为一个固定值,且不会改变。如果变量的值需要大量计算,但最多只需要计算一次,甚至如果变量从未被使用则根本不计算,这将非常有用。这可以通过$(eval)函数实现。
考虑以下定义:
SHALIST = $(shell find . -name '*.c' | xargs shasum)
SHALIST变量将包含当前目录及所有子目录中每个.c文件的名称和 SHA1 加密哈希值。这个评估可能需要很长时间。而使用=定义SHALIST意味着每次使用SHALIST时都会发生这个昂贵的调用。如果使用多次,可能会显著降低 makefile 的执行速度。
另一方面,如果你使用:=定义SHALIST,$(shell)只会执行一次,但每次加载 makefile 时都会发生。如果SHALIST的值并不总是需要,比如在运行make clean时,这可能效率低下。
我们希望能够定义SHALIST,使得如果SHALIST从未使用,则$(shell)不会执行;而如果SHALIST被使用,则仅执行一次。下面是如何实现:
SHALIST = $(eval SHALIST := $(shell find . -name '*.c' | xargs shasum))$(SHALIST)
如果$(SHALIST)被评估,$(eval SHALIST := $(shell find . -name '*.c' | xargs shasum))部分将会被评估。因为这里使用了:=,它实际上会执行$(shell)并将SHALIST重新定义为该调用的结果。然后,GNU make会获取由$(eval)刚刚设置的$(SHALIST)的值。
你可以通过创建一个小的 makefile,使用$(value)函数(该函数显示变量的定义而不展开它)来查看SHALIST的值,而不对其进行评估:
SHALIST = $(eval SHALIST := $(shell find . -name '*.c' | xargs
shasum))$(SHALIST)
$(info Before use SHALIST is: $(value SHALIST))
➊ $(info SHALIST is: $(SHALIST))
$(info After use SHALIST is: $(value SHALIST))
使用目录中的一个foo.c文件运行该 makefile,结果会产生以下输出:
$ **make**
Before use SHALIST is: $(eval SHALIST := $(shell find . -name '*.c' | xargs
shasum))$(SHALIST)
SHALIST is: 3405ad0433933b9b489756cb3484698ac57ce821 ./foo.c
After use SHALIST is: 3405ad0433933b9b489756cb3484698ac57ce821 ./foo.c
显然,SHALIST的值自从第一次在➊使用时已经发生了变化。
简单的列表操作
在 GNU make 中,列表元素由空格分隔。例如,peter paul and mary 是一个包含四个元素的列表,C:\Documents And Settings\Local User 也是一个列表,包含四个元素。GNU make 提供了多个内置函数来操作列表:
-
$(firstword)。获取列表中的第一个单词。 -
$(words)。计算列表元素的数量。 -
$(word)。提取指定索引的单词(从 1 开始计数)。 -
$(wordlist)。从列表中提取一系列单词。 -
$(foreach)。允许你遍历一个列表。
获取列表中的第一个元素很简单:
MY_LIST = a program for directed compilation
$(info The first word is $(firstword $(MY_LIST)))
那将输出 The first word is a。
你可以通过计算列表中单词的数量 N 来获取最后一个元素,然后取出第 N 个单词。这里有一个 lastword 函数,它返回列表中的最后一个单词:
➊ lastword = $(if $1,$(word $(words $1),$1))
MY_LIST = a program for directed compilation
$(info The last word is $(call lastword,$(MY_LIST)))
➊ 处的 $(if) 是必须的,因为如果列表为空,$(words $1) 将返回 0,而 $(word 0,$1) 会导致致命错误。前面的示例输出是 The last word is compilation。
注意
GNU make 3.81 及更高版本内置了一个 lastword 函数,比之前的实现更快。
剪去列表中的第一个单词只需返回从第二个元素到最后的子列表范围即可。GNU make 的内置 $(wordlist S,E,LIST) 函数返回 LIST 中从索引 S 开始,到索引 E 结束(包括 E)的元素范围:
notfirst = $(wordlist 2,$(words $1),$1)
MY_LIST = a program for directed compilation
$(info $(call notfirst,$(MY_LIST)))
你不需要担心前面示例中的空列表,因为 $(wordlist) 如果第二个参数不是有效的索引,也不会报错。那个示例的输出是 program for directed compilation。
剪去列表中的最后一个元素需要一些额外的思考,因为在 make 中没有简单的算术运算方法:不能直接写 $(wordlist 1,$(words $1)–1, $1)。相反,我们可以定义一个 notlast 函数,通过在列表开头添加一个虚拟元素,并使用 原始 列表的长度作为 $(wordlist) 的结束索引,从而剪掉最后一个元素。然后,因为我们添加了一个虚拟元素,我们需要记得通过将 $(wordlist) 的起始索引设置为 2 来去除它:
notlast = $(wordlist 2,$(words $1),dummy $1)
MY_LIST = a program for directed compilation
$(info $(call notlast,$(MY_LIST)))
这将输出 a program for directed。
用户定义的函数
本节介绍如何在 makefile 中定义 make 函数。在第五章,你将学习如何修改 GNU make 的源代码,使用 C 定义更复杂的函数。在前面的章节中,我们使用了很多用户定义的函数,现在我们将更详细地探讨这个话题。
基础知识
这是一个非常简单的 make 函数,它接受三个参数,并通过在这三个参数之间插入斜杠来生成日期:
make_date = $1/$2/$3
要使用 make_date,你可以像这样调用它:$(call)。
today := $(call make_date,5,5,2014)
结果是 today 包含 5/5/2014。
该函数使用了特殊变量$1、$2和$3,它们包含了在$(call)中指定的参数。没有参数数量的上限,但如果使用超过九个参数,则需要使用括号——也就是说,你不能写$10,而必须使用$(10)。如果函数调用时缺少某些参数,这些变量的内容将是未定义的,并被视为空字符串。
特殊参数$0包含函数的名称。在前面的例子中,$0是make_date。
由于函数本质上是引用一些由 GNU make自动创建和填充的特殊变量(如果你在任何参数变量(如$1等)上使用$(origin)函数,它们会被分类为automatic,就像$@一样),你可以使用 GNU make的内建函数来构建复杂的函数。
这是一个使用$(subst)函数将路径中的每个/转换为\的函数:
unix_to_dos = $(subst /,\,$1)
不必担心代码中/和\的使用。GNU make几乎不做转义处理,字面上的\大部分时间都代表一个实际的反斜杠字符。你将在第四章中了解到更多关于make如何处理转义的内容。
参数处理陷阱
make在处理$(call)时,会通过逗号分隔参数列表来设置变量$1、$2等。然后展开这些参数,确保这些变量在引用之前被完全展开。这就像make使用:=来设置它们一样。如果展开一个参数时有副作用,比如调用$(shell),这个副作用会在$(call)执行时立即发生,即使该参数最终并未被调用的函数使用。
一个常见的问题是,如果参数中包含逗号,分割参数时可能会出错。例如,这里有一个简单的函数,它交换两个参数:
swap = $2 $1
如果你使用$(call swap,first,argument,second),make没有办法知道第一个参数是想表示first,argument还是仅仅是first。它会假设后者,并最终返回argument first,而不是second first,argument。
你有两种方法来解决这个问题。首先,你可以简单地将第一个参数隐藏在一个变量中。因为make在分割参数之前不会展开这些参数,所以变量中的逗号不会引起任何混淆:
FIRST := first,argument
SWAPPED := $(call swap,$(FIRST),second)
另一种方法是创建一个仅包含逗号的简单变量,并使用它:
c := ,
SWAPPED := $(call swap,first$cargument,second)
或者甚至可以调用这个,变量并使用它(带括号):
, := ,
SWAPPED := $(call swap,first$(,)argument,second)
正如我们将在第四章中看到的,给变量起一些巧妙的名字,如,,可能很有用,但也容易出错。
调用内建函数
你可以使用$(call)语法与make的内建函数一起使用。例如,你可以像这样调用$(info):
$(call info,message)
这意味着你可以将任何函数名作为参数传递给用户定义的函数,并使用$(call)来调用它,而无需知道它是否是内置函数;因此,它允许你创建作用于函数的函数。例如,你可以创建经典的函数式编程中的map函数,该函数将一个函数应用于列表中的每个成员,并返回结果列表:
map = $(foreach a,$2,$(call $1,$a))
第一个参数是要调用的函数,第二个参数是要遍历的列表。以下是map的一个示例用法——遍历一个变量名列表,并打印每个变量的定义值和扩展值:
print_variable = $(info $1 ($(value $1) -> $($1)) )
print_variables = $(call map,print_variable,$1)
VAR1 = foo
VAR2 = $(VAR1)
VAR3 = $(VAR2) $(VAR1)
$(call print_variables,VAR1 VAR2 VAR3)
print_variable函数将变量名作为它的第一个也是唯一的参数,并返回一个由变量名、定义和其值组成的字符串。print_variables函数只是使用map将print_variable应用于一组变量列表。以下是 makefile 代码片段的输出结果:
$ **make**
VAR1 (foo -> foo) VAR2 ($(VAR1) -> foo) VAR3 ($(VAR2) $(VAR1) -> foo foo)
make中的函数也可以是递归的:函数可以调用$(call)自身。下面是一个递归实现的reduce函数,来自函数式编程,它接受两个参数:一个会被reduce调用的函数和一个待处理的列表。
reduce = $(if $(strip $2),$(call reduce,$1,$(wordlist 2,$(words $2),$2), \
$(call $1,$(firstword $2),$3)),$3)
第一个参数(函数)会反复使用两个参数进行调用:列表中的下一个元素是reduce的第二个参数,前一次调用该函数的结果是第一个参数。
要查看其工作原理,下面是一个uniq函数,用于从列表中删除重复项:
check_uniq = $(if $(filter $1,$2),$2,$2 $1)
uniq = $(call reduce,check_uniq,$1)
$(info $(call uniq,c b a a c c b a c b a))
这里的输出是c b a。之所以能这样工作,是因为reduce会使用输入列表中的每个成员调用check_uniq,并从check_uniq的结果构建一个新列表。check_uniq函数仅仅是判断一个元素是否存在于给定的列表中(使用内置的filter函数),如果不存在,则返回一个将该元素附加到列表后的新列表。
要查看其实际效果,下面是一个修改版,使用$(info)在每次调用check_uniq时输出传递给它的参数:
check_uniq = $(info check_uniq ($1) ($2))$(if $(filter $1,$2),$2,$2 $1)
uniq = $(call reduce,check_uniq,$1)
$(info $(call uniq,c b a a c c b a c b a))
以下是输出结果:
$ make
check_uniq (c) ()
check_uniq (b) ( c)
check_uniq (a) ( c b)
check_uniq (a) ( c b a)
check_uniq (c) ( c b a)
check_uniq (c) ( c b a)
check_uniq (b) ( c b a)
check_uniq (a) ( c b a)
check_uniq (c) ( c b a)
check_uniq (b) ( c b a)
check_uniq (a) ( c b a)
c b a
如果不需要保留顺序,那么使用内置的$(sort)函数会比这个用户定义的函数更快,因为它也会删除重复项。
最新的 GNU make 版本:3.81、3.82 和 4.0
GNU make的变化很慢,新版本(包括主版本和次版本)通常每隔几年才发布一次。由于发布周期较慢,因此常常会遇到旧版本的 GNU make,了解它们之间的差异非常有用。本节假设最常用的旧版本是 3.79.1(发布于 2000 年 6 月 23 日),并重点介绍了 3.81、3.82 和 4.0 版本中的主要变化。
GNU make 3.81 中的新功能
GNU make 3.81 于 2006 年 4 月 1 日发布,比上一个版本(GNU make 3.80)晚了三年半,且新版本中加入了许多新特性:支持 OS/2、新的命令行选项、新的内建变量、新的条件语句和新函数。有关更改的完整列表,请参阅 GNU make 3.81 源代码分发包中的NEWS文件。
.SECONDEXPANSION
用户使用 GNU make时常遇到的一个令人沮丧的问题是,自动变量只有在规则的命令被执行时才有效并被赋值;它们在规则定义部分是无效的。例如,不能写foo: $@.c来表示foo应该由foo.c生成,尽管当该规则的命令被执行时,$@的值会是foo。这令人沮丧,因为如果不必像这样重复自己就好了:
foo:foo.c
在 3.81 版本之前,GNU make支持在规则的前提条件列表中使用$$@(注意两个$符号)(该语法来自 SysV make)。例如,可以写foo: $$@.c,它等同于foo: foo.c。也就是说,$$@具有在规则命令中$@的值。要在 GNU make 3.81 及更高版本中获得此功能,必须在 makefile 中定义.SECONDEXPANSION。作为附加功能,GNU make支持在规则定义中使用所有标准的自动变量(尽管请注意,像$$这样的自动变量始终为空,因为它们无法在解析 makefile 时计算)。这发生的原因是,GNU make会对规则的前提条件列表进行两次扩展:第一次是在读取 makefile 时,第二次是在查找要构建的目标时。
你可以使用第二次扩展(second expansion)不仅仅是自动变量。用户定义的变量也可以被第二次扩展,它们最终会得到在 makefile 中定义的最后一个值。例如,你可以这样做:
.SECONDEXPANSION:
FOO = foo
all: $$(FOO)
all: ; @echo Making $@ from $?
bar: ; @echo Making $@
FOO = bar
这将产生以下输出:
$ **make**
Making bar
Making all from bar
当 makefile 被读取时,all: $$(FOO)会被扩展为all: $(FOO)。后来,当决定如何构建all时,$(FOO)被扩展为bar——也就是说,这是FOO在 makefile 解析结束时的值。请注意,如果你启用了.SECONDEXPANSION并且文件名中有$符号,那么$符号需要通过写$$来转义。
else
GNU make 3.81 中引入的另一个新特性是通过将条件和else写在同一行来支持非嵌套的else分支。例如,可以写:
ifdef FOO
$(info FOO defined)
else ifdef BAR
$(info BAR defined)
else
$(info BAR not defined)
endif
这种语法对任何使用过支持else if、elseif或elsif的语言的人来说都很熟悉。这是 GNU make将else和if写在同一行的方式。
之前,代码会像这样:
ifdef FOO
$(info FOO defined)
else
ifdef BAR
$(info BAR defined)
else
$(info BAR not defined)
endif
endif
这种写法比起带有非嵌套else分支的版本,要乱得多,且更难以阅读。
-L 命令行选项
命令行选项 -L(及其长形式 --check-symlink-times)使 make 考虑符号链接的修改时间以及符号链接所指向文件的修改时间,以便决定哪些文件需要重新编译。较新的修改时间将被视为文件的修改时间。这在构建使用符号链接指向不同版本源文件时非常有用,因为改变符号链接将更改修改时间,并强制重新构建。
.INCLUDE_DIRS
.INCLUDE_DIRS 变量包含 make 在查找通过 include 指令包含的 makefile 时会搜索的目录列表。该变量由 GNU make 内置的标准目录列表设置,并可以通过 -I 命令行选项进行修改。尽管可以在实际的 makefile 中通过 = 或 := 来改变 .INCLUDE_DIRS 的值,但这不会影响 GNU make 查找 makefile 的方式。
例如,在 Linux 上运行 make -I /usr/foo 并使用以下 makefile 输出 /usr/foo /usr/local/include /usr/local/include /usr/include:
$(info $(.INCLUDE_DIRS))
all: ; @true
.FEATURES
.FEATURES 变量展开为 GNU make 支持的特性列表,可用于判断特定功能是否可用。在 Linux 上使用 GNU make 3.81 时,.FEATURES 的列表为 target-specific order-only second-expansion else-if archives jobserver check-symlink。这意味着 GNU make 3.81 支持特定目标和模式的变量,具有 orderonly 先决条件,支持第二次展开(.SECONDEXPANSION),支持 else if 非嵌套条件,支持 ar 文件,支持使用作业服务器进行并行编译,并支持用于检查符号链接的新 -L 命令行选项。
要测试特定功能是否可用,可以使用 $(filter)。例如:
has-order-only := $(filter order-only,$(.FEATURES))
这一行设置 has-order-only 为 true,前提是当前运行的 make 版本支持 order-only 先决条件。然而,这并不向后兼容;例如,在 GNU make 3.80 中,.FEATURES 会展开为一个空列表,表示即使特定目标变量可用,它们仍不可用。向后兼容的检查首先需要通过查看 .FEATURES 是否非空来判断它是否存在。
.DEFAULT_GOAL
通常,如果命令行中未指定目标,make 将构建它在第一个解析的 makefile 中看到的第一个目标。可以通过在 makefile 中的任何位置设置 .DEFAULT_GOAL 变量来覆盖此行为。例如,以下 makefile 即使第一个目标是 fail,在没有命令行目标的情况下运行时,仍将构建 all:
fail: ; $(error wrong)
.DEFAULT_GOAL = all
all: ; $(info right)
.DEFAULT_GOAL 变量也可以读取当前的默认目标;如果设置为空(.DEFAULT_GOAL :=),make 将自动选择它遇到的下一个目标作为默认目标。
MAKE_RESTARTS
MAKE_RESTARTS 变量表示 make 在执行 makefile 重建 时重启的次数。GNU make 有一个特殊功能,允许 makefile 由 make 自动重建。这种重建发生在任何通过 include 引入的 makefile 中,以及最初启动的 makefile 和通过 -f 命令行选项设置的 makefile。make 会检查是否有规则来重建任何 makefile。如果找到,makefile 会像任何其他文件一样被重建,且 GNU make 会重启。
如果 GNU make 尚未重启,MAKE_RESTARTS 是空白,而不是 0。
新函数
GNU make 3.81 还引入了多种内建函数:
-
$(infotext)。这个函数类似于现有的$(warning)函数,但它将展开后的text参数打印到STDOUT,而不报告 makefile 和行号。例如,以下 makefile 会生成Hello, World!输出:$(info Hello, World!) all: ; @true -
$(lastwordLIST)。该函数返回 GNUmake列表中的最后一个单词。之前可以通过写$(word $(wordsLIST),LIST)来实现,但$(lastword)更加高效。如果你使用 GNU Make Standard Library(GMSL),有一个名为last的函数,它与$(lastword)相同。如果你使用 GNUmake3.81 和 GMSL 1.0.6 或更高版本,last会自动使用内建的lastword来提高速度。 -
$(flavorVAR)。该函数返回变量的类型(如果是递归展开,则为recursive;如果是简单展开,则为simple)。例如,以下 makefile 会输出REC是递归的,SIM是简单的:REC = foo SIM := foo $(info REC is $(flavor REC)) $(info SIM is $(flavor SIM)) all: ; @true -
$(orarg1 arg2...) 和 $(and)。$(or)如果其任何一个参数非空,则返回非空字符串,而$(and)只有在所有参数都非空时才返回非空字符串。如果你使用 GMSL,and和or函数是库的一部分。如果你使用 GNUmake3.81 和 GMSL 1.0.6 或更高版本,新的内建函数不会被 GMSL 版本覆盖,这意味着使用 GMSL 的 makefile 与 GNUmake3.81 版本完全向后和向前兼容。 -
$(abspath DIR)。该函数返回相对于 GNUmake启动目录的DIR的绝对路径(考虑到任何-C命令行选项)。路径会解析所有的.和..元素,并删除重复的斜杠。请注意,GNUmake并不会检查路径是否存在;它只会解析路径元素以生成绝对路径。例如,以下 makefile 在我的机器上放在 /home/jgc 中时,会输出/home/jgc/bar:$(info $(abspath foo/./..//////bar)) all: ; @true -
$(realpath DIR)。该函数返回与$(abspath DIR)相同的结果,除了会解析任何符号链接。例如,如果bar是指向over-here的符号链接,以下 makefile 会从 /home/jgc 读取时返回/home/jgc/ over-here:$(info $(realpath ../jgc/./bar)) all: ; @true
GNU make 3.82 中的新变化
GNU make 3.82 在 3.81 发布四年后推出,引入了许多新特性——以及一些向后不兼容的变化。
向后不兼容性
GNU make 3.82 的NEWS文件以七个向后不兼容的警告开始。以下是快速概述:
-
在 GNU
make中,执行规则命令的 shell 是通过-c命令行选项调用的,该选项告诉 shell 从第一个非参数参数开始读取要执行的命令。例如,当执行以下小规则时,make实际上执行的是execve("/bin/sh", ["/bin/sh", "-c", "echo \"hello\""], ...)。要运行echo "hello",make使用 shell/bin/sh并为其添加-c命令行选项。all: ; @echo "hello"但是,POSIX 标准在 2008 年修改了
make的规定,要求必须在 shell 命令行中指定-e。GNUmake3.82 及更高版本的默认行为是不传递-e,除非指定了.POSIX特殊目标。任何在 makefile 中使用此目标的人需要注意这一变化。 -
$?自动变量包括所有导致重新构建的前提条件的名称,即使它们不存在。之前,任何不存在的前提条件不会被放入$?。 -
$(wildcard)函数一直返回一个已排序的文件列表,但这从未实际文档化。这个行为在 GNUmake3.82 中发生了变化,因此任何依赖于$(wildcard)的已排序列表的 makefile 都需要将其包裹在$(sort)的调用中;例如,执行$(sort $(wildcard *.c))以获取已排序的.c文件列表。 -
以前可以编写一个混合模式目标和显式目标的规则,像这样:
myfile.out %.out: ; @echo Do stuff with $@这一直没有文档说明,并且在 GNU
make3.81 中被完全移除,因为这从未打算如此工作。现在它会导致错误信息。 -
不再可能有一个包含
=符号的前提条件,即使使用\进行转义。例如,下面的写法不再有效:all: odd\=name odd%: ; @echo Make $@如果在目标或前提条件名称中需要一个等号,首先定义一个展开为
=的变量,如下所示:eq := = all: odd$(eq)name odd%: ; @echo Make $@ -
在 GNU
make3.82 中,变量名不能包含空格。之前是可以这样做的:has space := variable with space in name $(info $(has space))如果需要一个包含空格的变量名,首先定义另一个只包含空格的变量,并按照以下方式使用它。但请注意,这种做法可能是危险的,且难以调试。
sp := sp += has$(sp)space := variable with space in name $(info $(has space)) -
模式规则和模式特定变量应用的顺序曾经是按它们在 makefile 中出现的顺序。这个顺序在 GNU
make3.82 中发生了变化:它们现在按照“最短 stem”顺序应用。例如,下面的 makefile 展示了 GNUmake3.81 和 3.82 中不同模式规则的使用方法。all: output.o out%.o: ; @echo Using out%.o rule outp%.o: ; @echo Using outp%.o rulestem是模式中由
%匹配的部分。在 GNUmake3.81 及更早版本中,out%.o规则可以匹配,因为它是首先定义的:$ make-3.81 Using out%.o rule在 GNU
make3.82 及更高版本中,使用outp%.o规则,因为该规则的模板更短:$ make-3.82 Using outp%.o rule对模式特定变量也会出现类似的行为。
新的命令行选项:--eval
新的 --eval 命令行选项会使 make 在解析 Makefile 之前,将其参数通过 $(eval) 进行处理。例如,如果你有这个 Makefile,并运行 make --eval=FOO=bar,你将看到输出 FOO has value bar。
all: ; @echo FOO has value $(FOO)
这是因为在解析 Makefile 之前,FOO=bar 这一行会被当作 Makefile 的第一行,并将 FOO 设置为 bar。
新的特殊变量:.RECIPEPREFIX 和 .SHELLFLAGS
GNU make 3.82 引入了两个新的特殊变量:
-
.RECIPEPREFIX。GNUmake使用TAB字符作为规则中命令的有效空白字符。你可以通过.RECIPEPREFIX变量来更改此设置。(如果.RECIPEPREFIX是空字符串,则使用TAB)。例如:.RECIPEPREFIX = > all: > @echo Making all此外,
.RECIPEPREFIX可以根据需要在 Makefile 中反复更改。 -
.SHELLFLAGS。该变量包含在规则的命令运行时传递给 shell 的参数。默认情况下,它是-c(如果在 Makefile 中指定了.POSIX:,则为-ec)。如果使用不同的 shell,可以读取或更改此值。
.ONESHELL 目标
当规则的命令执行时,每行都会作为一个单独的 shell 调用发送到 shell 中。在 GNU make 3.82 中,引入了一个新的特殊目标 .ONESHELL 来改变这种行为。如果在 Makefile 中设置了 .ONESHELL:,则所有规则中的行将在同一个 shell 调用中执行。例如:
all:
→ @cd /tmp
→ @pwd
这不会输出 /tmp(除非 make 是在 /tmp 目录下启动的),因为每行命令都会在单独的 shell 中执行。但使用 .ONESHELL 特殊目标时,两行命令会在同一个 shell 中执行,pwd 会输出 /tmp。
.ONESHELL:
all:
→ @cd /tmp
→ @pwd
使用 private 和 undefine 关键字来更改变量
目标特定变量通常为目标及其所有前提条件定义。但如果目标特定变量以 private 关键字为前缀,则该变量仅为该目标定义,而不是其前提条件。
在以下 Makefile 中,DEBUG 只在 foo.o 目标上设置为 1,因为它被标记为 private:。
DEBUG=0
foo.o: private DEBUG=1
foo.o: foo.c
→ @echo DEBUG is $(DEBUG) for $@
foo.c: foo.in
→ @echo DEBUG is $(DEBUG) for $@
GNU make 3.82 中的另一个新关键字是 undefine,它使得可以取消定义一个变量:
SPECIAL_FLAGS := xyz
$(info SPECIAL_FLAGS $(SPECIAL_FLAGS))
undefine SPECIAL_FLAGS
$(info SPECIAL_FLAGS $(SPECIAL_FLAGS))
你可以使用 $(flavor) 函数来检测空变量与未定义变量之间的区别。例如,以下输出 simple,然后输出 undefined:
EMPTY :=
$(info $(flavor EMPTY))
undefine EMPTY
$(info $(flavor EMPTY))
在 GNU make 3.82 之前的版本中,define 指令(用于定义多行变量)总是会创建一个递归定义的变量。例如,这里的 COMMANDS 将是一个递归变量,每次使用时都会展开:
FILE = foo.c
define COMMANDS
wc -l $(FILE)
shasum $(FILE)
endef
在 GNU 3.82 中,可以在 define 语句中的变量名后添加可选的 =、:= 或 +=。默认行为是每次都递归展开新变量;这与添加 = 相同。添加 := 会创建一个简单变量,在定义时展开 define 的主体。添加 += 会将多行追加到现有变量中。
以下 makefile 创建了一个名为 COMMANDS 的简单变量,然后向其中添加行:
FILE = foo.c
define COMMANDS :=
wc -l $(FILE)
shasum $(FILE)
endef
define COMMANDS +=
➊
wc -c $(FILE)
endef
$(info $(COMMANDS))
注意➊处的额外空行。这是必要的,因为 wc -c $(FILE) 必须在 shasum $(FILE) 之后新的一行显示。如果没有它,wc -c $(FILE) 会被追加到 shasum $(FILE) 后面,并且没有换行符。
GNU make 4.0 的新特性
GNU make 4.0 的发布引入了两个主要特性:与 GNU Guile 语言的集成,以及一个实验性选项,允许动态加载对象以在运行时扩展 make 的功能。此外,新的命令行选项对于调试特别有帮助。
GNU Guile
GNU make 4.0 中最大的变化是新的 $(guile) 函数,其参数是用 GNU Guile 语言编写的代码。该代码被执行,并将返回值转换为字符串,该字符串将由 $(guile) 函数返回。
能够切换到另一种语言为 GNU make 添加了巨大的功能。以下是一个简单示例,使用 Guile 检查文件是否存在:
$(if $(guile (access? "foo.c" R_OK)),$(info foo.c exists))
使用 GNU Guile 内嵌在 GNU make 中的内容将在 第五章 中进一步详细介绍。
加载动态对象
本书中我们没有使用 load 操作符来定义 C 函数,但在 第五章 中解释了如何在 C 中定义函数和加载动态对象。
使用 --output-sync 同步输出
如果你使用递归的make或者使用作业服务器并行运行规则,make产生的输出可能会很难阅读,因为来自不同规则和子 make 的输出会交织在一起。
请考虑以下(稍微做过修改的)makefile:
all: one two three four
one two:
→ @echo $@ line start
→ @sleep 0.1s
→ @echo $@ line middle
→ @echo $@ line finish
three four:
→ @echo $@ line start
→ @sleep 0.2s
→ @echo $@ line middle
→ @echo $@ line finish
这个 makefile 包含四个目标:one、two、three 和 four。如果使用 -j 选项,目标将并行构建。为了模拟不同执行时间的命令,添加了两次 sleep 调用。
当使用 -j4 选项运行时,它会并行运行四个作业,输出可能如下所示:
$ **make -j4**
one line start
three line start
four line start
two line start
one line middle
two line middle
one line finish
two line finish
four line middle
three line middle
three line finish
four line finish
每个规则的输出行会混合在一起,使得很难辨别哪个输出属于哪个规则。指定 -Otarget(或 --output-sync=target)会使 make 跟踪哪些输出与哪个目标相关联,并且只在规则完成时刷新输出。现在每个目标的完整输出清晰可读:
$ **make -j4 -Otarget**
two line start
two line middle
two line finish
one line start
one line middle
one line finish
four line start
four line middle
four line finish
three line start
three line middle
three line finish
指定--output-sync=recurse可以处理递归子 make——即调用$(MAKE)的规则——通过缓存规则的所有输出,包括子 make 的输出,并一次性输出所有内容。这可以防止子 make 输出混合在一起,但可能导致make的输出出现长时间的暂停。
--trace 命令行选项
你可以使用新的--trace选项来追踪 makefile 中规则的执行情况。当在make命令行中指定时,执行的每条规则的命令会与该规则的定义位置及其执行原因一起打印出来。
例如,这个简单的 makefile 有四个目标:
all: part-one part-two
part-one: part-three
→ @echo Make $@
part-two:
→ @echo Make $@
part-three:
→ @echo Make $@
使用--trace运行它:
$ **make --trace**
makefile:10: target 'part-three' does not exist
echo Make part-three
Make part-three
makefile:4: update target 'part-one' due to: part-three
echo Make part-one
Make part-one
makefile:7: target 'part-two' does not exist
echo Make part-two
Make part-two
这会显示每条规则为何被执行,它在 makefile 中的位置,以及执行了哪些命令。
新的赋值运算符:!=和::=
你可以使用!=运算符执行一个 shell 命令,并将命令的输出设置为变量,这与$(shell)类似。例如,下面的代码行使用!=获取当前的日期和时间并存入变量:
CURRENTLY != date
使用!=时需要注意一个重要的细节:结果变量是递归的,因此每次使用变量时它的值都会被展开。如果执行的命令(即!=的右侧部分)返回了$,make会将其解释为变量引用并展开。因此,最好使用$(shell)与:=,而不是使用!=。(这是为了兼容 BSD make,也可能会被添加到 POSIX 中。)
::=运算符与:=完全相同,且是为了 POSIX 兼容性而添加的。
$(file)函数
你可以使用新的$(file)函数来创建或追加到一个文件。以下 makefile 使用$(file)在每次执行规则时创建一个文件并追加内容。它记录了 makefile 的执行日志:
LOG = make.log
$(file > $(LOG),Start)
all: part-one part-two
part-one: part-three
→ @$(file >> $(LOG),$@)
→ @echo Make $@
part-two:
→ @$(file >> $(LOG),$@)
→ @echo Make $@
part-three:
→ @$(file >> $(LOG),$@)
→ @echo Make $@
第一个$(file)使用>操作符创建日志文件,随后的$(file)调用使用>>将内容追加到日志中:
$ **make**
Make part-three
Make part-one
Make part-two
$ **cat make.log**
Start
part-three
part-one
part-two
很容易看出,$(file)函数是 GNU make的一个有用扩展。
GNU make 4.1 的新特性
当前版本的 GNU make(在本文写作时)是 4.1 版本。该版本于 2014 年 10 月 5 日发布,包含了两个有用的改动以及大量的错误修复和小幅改进。
新增了MAKE_TERMOUT和MAKE_TERMERR变量。如果make认为stdout和stderr(分别)被发送到控制台,则这两个布尔值会被设置为 true(即它们不是空的)。
$(file)函数已被修改,可以打开一个文件而不往其中写入任何内容。如果没有提供文本参数,文件会被简单地打开然后关闭;你可以用这个方法通过$(file > $(MY_FILE))创建一个空文件。
第二章. Makefile 调试
本章介绍了一些在调试 makefile 时可能有用的技巧。由于缺乏内置的调试工具,再加上追踪 make 中变量的复杂性,这使得理解为什么某个目标被(或更常见的是没有)构建变得非常具有挑战性。
本章中的第一个配方展示了你可以添加到 makefile 中的最有用的一行;它相当于在代码中插入一个用于调试的打印语句。
打印 Makefile 变量的值
如果你曾经查看过一个 makefile,你会意识到 makefile 变量(通常简称为变量)是任何 make 过程的骨干。变量通常定义了哪些文件将被编译、传递给编译器的命令行参数,甚至编译器的位置。如果你曾经试图调试一个 makefile,你知道自己问的第一个问题是,“变量 X 的值是什么?”
GNU make 没有内置调试器,也不像 Perl 或 Python 这样的脚本语言那样提供交互式的功能。那么,如何找出一个变量的值呢?
看看 示例 2-1 中展示的简单 makefile,它仅设置了几个变量:
示例 2-1. 一个设置各种变量的简单 makefile
X=$(YS) hate $(ZS)
Y=dog
YS=$(Y)$(S)
Z=cat
ZS=$(Z)$(S)
S=s
all:
X 的值是什么?
这个 makefile 的小巧和简洁使得追踪所有变量的赋值变得可行,但即便如此,要得出 X 的值是 dogs hate cats 还是需要一些工作。如果是一个拥有成千上万行的 makefile,充分利用 GNU make 的变量和函数,要弄清楚一个变量的值确实可能会非常繁琐。幸运的是,这里有一个小小的 make 配方,它可以为你完成所有的工作:
print-%: ; @echo $* = $($*)
现在,你可以使用以下命令来查找变量 X 的值:
$ **make print-X**
由于没有为 print-X 目标定义显式规则,make 会查找模式规则,找到 print-%(% 起到通配符的作用),并运行相关联的命令。这个命令使用 $*,一个特殊的变量,包含与规则中的 % 匹配的值,来打印变量的名称,然后使用 $($*) 来获取其值。这在 makefile 中是一个非常有用的技巧,因为它允许计算变量的名称。在这种情况下,要打印的变量名称来自另一个变量 $*。
下面是如何使用这个规则来打印在 示例 2-1 中定义的变量的值:
$ **make print-X**
X = dogs hate cats
$ **make print-YS**
YS = dogs
$ **make print-S**
S = s
有时了解一个变量是如何被定义的非常有用。make有一个$origin函数,它返回一个字符串,包含变量的类型——即变量是如何定义的,是否在 makefile 中、命令行中,或者在环境中定义。修改print-%以同时输出来源信息也很简单:
print-%: ; @echo $* = '$($*)' from $(origin $*)
现在我们看到YS是在 makefile 中定义的:
$ **make print-YS**
YS = 'dogs' from file
如果我们在命令行中覆盖了YS的值,我们将看到:
$ **make print-YS YS=fleas**
YS = 'fleas' from command line
由于YS是在make命令行中设置的,因此它的$(origin)现在是command line,而不再是file。
打印每个 Makefile 变量
上一部分展示了如何通过特殊规则打印单个 makefile 变量的值。那么,如果你想打印 makefile 中定义的所有变量呢?
幸运的是,GNU make 3.80 引入了几个新功能,使得通过单个规则打印 makefile 中定义的所有变量的值变得可行。
再次考虑示例 2-1。它设置了五个变量:X、Y、Z、S、YS和ZS。向示例中添加以下行会创建一个名为printvars的目标,该目标将打印 makefile 中定义的所有变量,如示例 2-2 所示。
示例 2-2. 打印所有变量的目标
.PHONY: printvars
printvars:
→ @$(foreach V,$(sort $(.VARIABLES)), \
→ $(if $(filter-out environ% default automatic, \
→ $(origin $V)),$(info $V=$($V) ($(value $V)))))
在我们仔细查看它是如何工作的之前,像示例 2-3 中的变量")所示那样先自己尝试一下。
示例 2-3. 使用printvars打印的示例 2-1 中的所有变量
$ **make printvars**
MAKEFILE_LIST= Makefile helper.mak ( Makefile helper.mak)
MAKEFLAGS= ()
S=s (s)
SHELL=/bin/sh (/bin/sh)
X=dogs hate cats ($(YS) hate $(ZS))
Y=dog (dog)
YS=dogs ($(Y)$(S))
Z=cat (cat)
ZS=cats ($(Z)$(S))
注意到make引入了三个额外的变量,这些变量并未明确在文件中定义——MAKEFILE_LIST、MAKEFLAGS和SHELL——但其他变量都在 makefile 中定义。每一行显示了变量的名称、其完全替换的值以及定义的方式。
当我们将打印变量的长且复杂的行重新格式化后,它会变得更容易理解:
$(foreach V,$(sort $(.VARIABLES)),
$(if
➊ $(filter-out environment% default automatic,$(origin $V)),
$(info $V=$($V) ($(value $V)))
)
)
.VARIABLES变量是 GNU make 3.80 中的一个新特性:它的值是一个包含 makefile 中定义的所有变量名称的列表。首先,代码将其排序:$(sort $(.VARIABLES))。然后,它逐个变量名地遍历排序后的列表,并将V设置为每个名称:$(foreach V,$(sort (.VARIABLES)),...)。
对于每个变量名,循环会决定是否打印该变量,或者忽略它,这取决于该变量是如何定义的。如果它是内建变量,如$@或$(CC),或者来自环境变量,它不应该被打印。这个决定由➊处的条件表达式做出。它首先通过调用$(origin $V)来确定变量$V是如何定义的。此调用返回一个字符串,描述了该变量的定义方式:environment表示环境变量,file表示在 makefile 中定义的变量,default表示make定义的内容。$(filter-out)语句表示,如果$(origin)的结果匹配任何模式environment%、default或automatic(对于make的自动变量,如$@、$<等,$(origin)会返回automatic),则返回空字符串;否则,保持原样。这意味着,$(if)的条件只有在变量是在 makefile 中定义或在命令行上设置时才为真。
如果$(if)的条件为真,那么$(info $V=$($V) ($(value $V)))会输出一条包含变量名、其完全展开的值以及其定义值的消息。$(value)函数是 GNU make 3.80 中的另一个新特性;它输出变量的值而不进行展开。在示例 2-3 中,$(YS)将返回值dogs,但是$(value YS)将返回$(Y)$(S)。也就是说,$(value YS)展示的是YS的定义方式,而不是其最终值。这是一个非常有用的调试特性。
跟踪变量值
随着 makefile 的增大,可能会很难找出一个变量的使用位置。尤其是因为 GNU make的递归变量:一个变量的使用可能被隐藏在 makefile 中某个其他变量定义的深处。本条食谱展示了如何跟踪各个变量在使用时的情况。
在这个例子中,我们将使用示例 2-4 中的 makefile(这些行已被编号,便于后续参考)。
示例 2-4. 用于跟踪的示例 makefile
1 X=$(YS) hate $(ZS)
2 Y=dog
3 YS=$(Y)$(S)
4 Z=cat
5 ZS=$(Z)$(S)
6 S=s
7
8 all: $(YS) $(ZS)
9 all: ; @echo $(X)
10
11 $(YS): ; @echo $(Y) $(Y)
12 $(ZS): ; @echo $(Z) $(Z)
运行时,这个 makefile 会打印:
dog dog
cat cat
dogs hate cats
如示例 2-4 所示,makefile 包含了许多递归定义的变量,并且在规则定义和命令中使用了它们。
跟踪变量的使用
如果你跟踪示例 2-4,你会看到变量$(Y)在第 8、9 和 11 行被使用,并且在第 12 行出现了两次。变量使用的频率真是惊人!原因是,make仅在需要时(即当变量被使用并展开时)获取递归展开变量的值(比如在示例 2-4 中的YS),而递归展开的变量通常是深度嵌套的。
跟踪一个变量通过示例 2-4 中的简单 Makefile 已经够麻烦了,但要跟踪一个真实的 Makefile 几乎是不可能的。幸运的是,可以通过以下代码让make为你完成这项工作,应该将其添加到要跟踪的 Makefile 的开始部分(它只会在显式调用时使用):
ifdef TRACE
.PHONY: _trace _value
_trace: ; @$(MAKE) --no-print-directory TRACE= \
$(TRACE)='$$(warning TRACE $(TRACE))$(shell $(MAKE) TRACE=$(TRACE) _value)'
_value: ; @echo '$(value $(TRACE))'
endif
在我们深入了解它是如何工作的之前,先看一个例子,展示如何在我们的示例 Makefile 中跟踪Y的值。要使用跟踪器,只需告诉make运行trace目标,方法是将TRACE变量设置为你要跟踪的变量的名称。跟踪变量Y的方式如下:
$ **make TRACE=Y**
Makefile:8: TRACE Y
Makefile:11: TRACE Y
Makefile:12: TRACE Y
Makefile:12: TRACE Y
dog dog
cat cat
Makefile:9: TRACE Y
dogs hate cats
从TRACE输出中,你可以看到Y首次出现在第 8 行的all目标定义中,该目标通过$(YS)引用了Y;然后在第 11 行,cats目标的定义中,也使用了$(YS);接着在第 12 行出现两次,直接引用了$(Y);最后,在第 9 行通过$(X)使用了$(YS),而$(YS)又引用了$(Y)。
同样,我们也可以使用这个跟踪器来查找$(S)被使用的位置:
$ **make TRACE=S**
Makefile:8: TRACE S
Makefile:8: TRACE S
Makefile:11: TRACE S
Makefile:12: TRACE S
dog dog
cat cat
Makefile:9: TRACE S
Makefile:9: TRACE S
dogs hate cats
输出显示,S首先在第 8 行使用了两次(all目标使用了XS和YS,这两者都使用了S)。然后,S再次出现在第 4 行(因为使用了YS)和第 12 行(因为使用了XS)。最后,S在第 9 行被使用了两次,当X被回显时,因为X被XS和YS使用,这两者都使用了S。
变量跟踪器的工作原理
GNU make有一个特殊的$(warning)函数,可以将警告信息输出到STDERR并返回空字符串。从高级别来看,我们的跟踪代码将要跟踪的变量的值更改为包含一个$(warning)消息。每次变量展开时,警告都会被打印,而每当make输出警告信息时,它会打印使用中的 Makefile 名称和行号。
例如,假设Y的定义从
Y=dog
更改为
Y=$(warning TRACE Y)dog
然后,每当$(Y)被展开时,就会生成一个警告,并且$(Y)的值会是dog。由于$(warning)不返回任何值,因此Y的值不受影响。
要添加这个$(warning)调用,跟踪器代码首先获取要跟踪的变量的未扩展值,然后将其与适当的$(warning)一起预先处理,最后使用经过特殊修改的变量值运行所需的make。它使用$(value)函数,正如你在示例 2-2 中看到的那样,$(value)可以让你获取变量的未扩展值。
这里是跟踪器如何工作的详细说明。如果TRACE已定义,make将处理跟踪器定义的代码块。在这种情况下,由于_trace是第一个遇到的目标,它将是默认运行的规则。_trace规则包含一个单一且复杂的命令:
@$(MAKE) --no-print-directory TRACE= \
$(TRACE)='$$(warning TRACE $(TRACE))$(shell $(MAKE) TRACE=$(TRACE) _value)'
在命令的右侧是一个$(shell)调用,它会使用不同的目标重新运行 makefile。例如,如果我们在跟踪YS,这个$(shell)调用会执行以下命令:
make TRACE=YS _value
这将运行_value规则,定义如下:
_value: ; @echo '$(value $(TRACE))'
因为TRACE已被设置为YS,所以这个规则只是回显YS的定义,即字面量字符串$(Y)$(S)。因此,$(shell)最终会评估为这个值。
那个$(shell)调用实际上是在一个命令行变量定义中(通常称为命令行覆盖):
$(TRACE)='$$(warning TRACE $(TRACE))$(shell $(MAKE)TRACE=$(TRACE) _value)'
这添加了$(warning),用于输出TRACE X消息。请注意,定义的变量名是一个计算值:它的名称包含在$(TRACE)中。当跟踪YS时,这个定义变成了:
YS='$(warning TRACE YS)$(Y)$(S)'
单引号用于防止 shell 看到$符号。双$$用于防止make看到$。在这两种情况下,都会发生变量扩展(无论是在make中还是由 shell 进行),我们希望延迟任何变量扩展,直到YS实际使用时。
最后,_trace规则会递归地运行make:
make TRACE= YS='$(warning TRACE YS)$(Y)$(S)'
TRACE的值被重置为空字符串,因为这个递归调用的make应该运行真实的规则,而不是跟踪规则。此外,它覆盖了YS的值。回想一下,命令行上定义的变量会覆盖 makefile 中的定义:即使YS在 makefile 中有定义,带有warning的命令行定义才是被使用的。现在,每次扩展YS时,都会打印一个警告。
请注意,这种技术不适用于目标特定的变量。make允许你按示例 2-5 的方式定义一个目标特定的变量:
示例 2-5. 定义目标特定变量
all: FOO=foo
all: a
all: ; @echo $(FOO)
a: ; @echo $(FOO)
变量FOO将在构建all规则和all的任何前提条件中具有值foo。在示例 2-5 中的 makefile 会打印foo两次,因为FOO在all和a规则中都被定义。跟踪器无法获取FOO的值,实际上会导致该 makefile 行为不正确。
跟踪器的工作原理是通过重新定义被跟踪的变量,如前所述。由于这发生在规则定义之外,跟踪器无法获取目标特定变量的值。例如,在示例 2-5 中,FOO仅在运行all或a规则时定义。跟踪器无法获取其值。在该 makefile 上使用跟踪器跟踪FOO会导致错误的行为:
$ **make TRACE=FOO**
Makefile:10: TRACE FOO
Makefile:8: TRACE FOO
这应该输出foo两次(一次是all规则,一次是a规则),但跟踪器已经重新定义了FOO并搞乱了它的值。不要将这个跟踪器用于目标特定变量。
$(warning)函数将其输出发送到STDERR,这使得可以将正常的make输出与跟踪输出分开。只需将STDERR重定向到跟踪日志文件。以下是一个示例:
$ **make TRACE=S 2> trace.log**
dog dog
cat cat
dogs hate cats
这个命令将把正常的make输出写到命令行,同时将跟踪输出重定向到trace.log。
跟踪规则执行
在 GNU make 4.0 之前,没有内建的方式来跟踪 makefile 目标的执行顺序。GNU make 4.0 增加了--trace选项,我在 GNU make 4.0 跟踪中有详细讲解,但如果你需要使用更早版本的make,有其他方法来跟踪 makefile 是很有用的。这里展示的技术适用于 GNU make 4.0 及更早版本。
注意
如果你曾经盯着一份晦涩的日志输出,心里想:“是什么规则导致了这个输出?”或者“foo规则的输出在哪里?”那么这一节正是为你准备的。说实话,谁没有好奇过 GNU make的日志文件输出意味着什么呢?
示例
本节使用以下示例 makefile:
.PHONY: all
all: foo.o bar
bar: ; @touch $@
它构建了两个文件:foo.o和bar。我们假设foo.c存在,这样make的内建规则就会创建foo.o;而bar是一个简单的规则,只是触碰$@。如果你第一次运行这个 makefile 的make,你会看到以下输出:
$ **make**
cc -c -o foo.o foo.c
该日志输出相当晦涩。没有看到bar的规则被执行的迹象(因为touch $@使用了@修饰符,这会阻止命令被打印)。也没有迹象表明是foo.o的规则生成了cc编译行。同样也没有显示all规则被使用的迹象。
当然,你可以使用make -n(它只会打印要执行的命令,而不会实际执行它们)来查看 GNU make将会执行的工作:
$ **make -n**
cc -c -o foo.o foo.c
touch bar
在这种情况下它是实用的,但通常 make -n 的输出可能像普通的日志文件一样晦涩,而且它没有提供将日志文件中的行与 makefile 中的行匹配的方法。
SHELL 黑客
增强 GNU make 输出的一种简单方法是重新定义 SHELL,这是一个内置变量,包含 make 执行命令时要使用的 shell 的名称。大多数 shell 都有一个 -x 选项,可以使它们打印出每个即将执行的命令;因此,如果你通过在 makefile 中附加 -x 来修改 SHELL,它会导致每个命令在 makefile 执行时都被打印出来。
这是一个使用 GNU make 的 += 操作符修改过的 makefile 示例,该操作符将 -x 附加到 SHELL:
SHELL += -x
.PHONY: all
all: foo.o bar
bar: ; @touch $@
在某些 shell 中,这可能不起作用(shell 可能期望接收单个选项单词)。在 GNU make 4.0 及更高版本中,一个名为 .SHELLFLAGS 的变量包含了 shell 的标志,并可以被设置来避免这个问题,而不需要修改 SHELL。
现在,makefile 输出显示 touch bar 是由 bar 规则生成的:
$ **make**
cc -c -o foo.o foo.c
+ cc -c -o foo.o foo.c
+ touch bar
SHELL 技术有一个缺点:它会使 make 变慢。如果 SHELL 保持不变,make 通常会避免使用 shell,前提是它知道可以直接执行命令——例如,对于简单的操作如编译和链接。但一旦在 makefile 中重新定义了 SHELL,make 就会始终使用 shell,从而导致变慢。
当然,这并不意味着这是一个糟糕的调试技巧:为了短暂的速度下降,获得额外的信息是一个非常小的代价。但是重新定义 SHELL 并不能帮助追踪日志文件中的行与 makefile 中的行之间的关系。幸运的是,通过更聪明地重新定义 SHELL,这是可以做到的。
更聪明的 SHELL 黑客
如果 SHELL 已被重新定义,make 会在执行每条规则的每一行之前扩展它的值。这意味着,如果 SHELL 的扩展输出信息,就可以在每条规则执行之前打印出信息。
正如你在追踪变量值中看到的,$(warning) 函数会帮助输出你选择的字符串,并附上 makefile 的名称和 $(warning) 所在行的行号。通过将 $(warning) 调用添加到 SHELL 中,每次 SHELL 扩展时都可以打印详细信息。以下代码片段就实现了这一点:
OLD_SHELL := $(SHELL)
SHELL = $(warning Building $@)$(OLD_SHELL)
.PHONY: all
all: foo.o bar
bar: ; @touch $@
第一行将 SHELL 的正常值捕获到一个名为 OLD_SHELL 的变量中。注意使用 := 来获取 SHELL 的最终值,而不是它的定义。第二行定义了 SHELL,使其包括旧的 shell 值和一个会打印正在构建的目标名称的 $(warning)。
现在运行 GNU make 会输出非常有用的信息:
$ **make**
make: Building foo.o
cc -c -o foo.o foo.c
Makefile:7: Building bar
输出的第一行是在即将执行内建模式规则以生成foo.o时产生的。由于没有打印 makefile 或行号信息,我们知道这里使用了内建规则。然后,你会看到内建规则的实际输出(即cc命令)。接着是另一条来自$(warning)的输出,表明bar即将使用 makefile 中第 7 行的规则进行构建。
我们在添加到SHELL中的$(warning)语句中使用了$@,但没有什么能阻止我们使用其他自动变量。例如,在示例 2-6 中,我们使用了$<,它保存了构建目标的第一个前提条件,和$?,它保存了比目标更新的前提条件列表,并告诉我们为什么要构建该目标。
示例 2-6. 使用SHELL技巧
OLD_SHELL := $(SHELL)
SHELL = $(warning Building $@$(if $<, (from $<))$(if $?, ($? newer)))$(OLD_SHELL)
.PHONY: all
all: foo.o bar
bar: ; touch $@
这里SHELL被重新定义为输出三条信息:正在构建的目标的名称($@),第一个前提条件的名称($<,它被包裹在$(if)中,以便在没有前提条件时不打印任何内容),以及任何更新的前提条件的名称($?)。
删除foo.o并在这个 makefile 上运行make,现在显示foo.o是从foo.c构建的,因为foo.c比foo.o更新(因为foo.o缺失了):
$ **make**
make: Building foo.o (from foo.c) (foo.c newer)
cc -c -o foo.o foo.c
Makefile:7: Building bar
没有什么能阻止我们将这个$(warning)技巧与-x结合使用,以显示哪些规则被执行了以及执行了哪些命令,如示例 2-7 技巧与-x 结合")中所示。
示例 2-7. 将$(warning)技巧与-x结合
OLD_SHELL := $(SHELL)
SHELL = $(warning Building $@$(if $<, (from $<))$(if $?, ($? newer)))$(OLD_SHELL) -x
.PHONY: all
all: foo.o bar
bar: ; @touch $@
下面是示例 2-7 技巧与-x 结合")中 makefile 的完整输出。
$ **make**
make: Building foo.o (from foo.c) (foo.c newer)
cc -c -o foo.o foo.c
+ cc -c -o foo.o foo.c
Makefile:7: Building bar
+ touch bar
这假设在运行make时,foo.c比foo.o更新(或者foo.o缺失)。
GNU make 4.0 跟踪
GNU make 4.0 增加了一个--trace命令行选项,可以用来跟踪规则执行。它提供的输出类似于示例 2-7 技巧与-x 结合")。下面是示例 2-6,去掉SHELL修改后的跟踪输出,使用 GNU make 4.0 时的结果:
$ **make --trace**
<builtin>: update target 'foo.o' due to: foo.c
cc -c -o foo.o foo.c
Makefile:4: target 'bar' does not exist
touch bar
当使用--trace选项调用时,GNU make 4.0 会覆盖@修饰符(在前面的示例中用于抑制touch bar),就像-n和--just-print标志一样。
Makefile 断言
大多数编程语言都有断言:当它们断言的值为真时不会执行任何操作,但如果不为真则会导致致命错误。它们通常作为运行时调试辅助工具,用于捕捉非常特殊的情况。C 语言中的典型断言可能看起来像assert( foo != bar ),如果foo和bar相同,则会导致致命错误。
不幸的是,GNU make没有任何内置的断言功能。但它们很容易通过现有的函数来创建,甚至在 GNU Make 标准库(GMSL)中定义了方便的断言函数。
GMSL 项目(在第六章中有介绍)提供了两个断言函数:assert和assert_exists。
assert
如果assert的第一个参数为 false,它将输出一个致命错误。与make的$(if)函数一样,GMSL 将任何非空字符串视为 true,空字符串视为 false。因此,如果assert的参数是空字符串,断言将导致致命错误;assert的第二个参数将作为错误的一部分被打印出来。例如,这个 makefile 会立刻中断,因为$(FOO)和$(BAR)相同:
include gmsl
FOO := foo
BAR := foo
$(call assert,$(call sne,$(FOO),$(BAR)),FOO and BAR should not be equal)
因为assert不是一个内置函数——它是在 GMSL 的 makefile 中用户定义的——所以我们必须使用$(call)。
我们得到以下信息:
Makefile:5: *** GNU Make Standard Library: Assertion failure: FOO and BAR should
not be equal. Stop.
断言使用了另一个 GMSL 函数,sne,它比较两个字符串,如果它们不相等则返回 true,否则返回 false。
因为 true 仅意味着非空字符串,所以很容易断言一个变量已定义:
include gmsl
$(call assert,$(FOO),FOO is not defined)
你可以使用这个断言,例如检查用户是否设置了所有必要的命令行变量;如果FOO是 makefile 正确运行所必需的,而用户忘记在命令行中设置它,断言将会导致错误。
你甚至可以使用断言来强制某些命令行标志不被使用。这里有一个例子,防止用户设置-i,即忽略错误标志:
include gmsl
$(foreach o,$(MAKEFLAGS),$(call assert,$(call sne,-i,$o),You can't use the -i option))
ifneq ($(patsubst -%,-,$(firstword $(MAKEFLAGS))),-)
$(call assert,$(call sne,$(patsubst i%,i,$(patsubst %i,i,$(firstword \
$(MAKEFLAGS)))),i),You can't use the -i option)
endif
这个例子比前两个更复杂,因为make可以以两种方式将-i标志存储在MAKEFLAGS中:作为常见形式的-i标志,或作为MAKEFLAGS中第一个单词的字符块。也就是说,设置命令行标志-i -k会导致MAKEFLAGS的值为ki。所以循环中的第一个assert查找-i,第二个assert则在MAKEFLAGS的第一个单词中查找i。
assert_exists
因为构建的成功依赖于所有必要文件的存在,GMSL 提供了一个专门设计的断言,用于在文件缺失时发出警告。assert_exists函数有一个参数:必须存在的文件名。例如,为了在 makefile 运行任何命令之前检查文件foo.txt是否存在,你可以在开头添加一个断言:
include gmsl
$(call assert_exists,foo.txt)
如果文件不存在,构建将停止:
Makefile:3: *** GNU Make Standard Library: Assertion failure: file 'foo.txt'
missing. Stop.
断言停止了构建,并且在 makefile 中显示了断言所在的行号——在本例中是第 3 行。
assert_target_directory
在构建实际项目中的 makefile 时,一个常见问题是你必须在构建过程中或之前创建目录层次结构。你可以通过创建一个特殊的 assert_target_directory 变量,确保在每个规则执行之前每个目录都已存在,正如 示例 2-8 所示。
示例 2-8. 创建 assert_target_directory 变量
include gmsl
assert_target_directory = $(call assert,$(wildcard $(dir $@)),Target directory $(dir $@) missing)
foo/all: ; @$(call assert_target_directory)echo $@
通过在每个规则或模式规则的配方开始处插入 $(call assert_target_directory),make 会自动检查目标文件将被写入的目录是否存在。例如,如果 foo/ 不存在,那么 示例 2-8 中的 makefile 会出现以下错误:
Makefile:6: *** GNU Make Standard Library: Assertion failure: Target directory
foo/ missing. Stop.
错误信息会给出出错的 makefile 名称和出错的行号,方便迅速找到问题所在。
最后一个技巧是,可以通过两行修改使 makefile 在检查每个规则时检查缺失的目录。与其在每个规则中添加 $(call assert_target_directory),不如重新定义 SHELL 变量,使其包含 $(call assert_target_directory)。这样做会稍微影响性能,但对于追踪某个深层嵌套 makefile 中缺失的目录非常有用:
include gmsl
assert_target_directory = $(call assert,$(wildcard $(dir $@)),Target directory $(dir $@) missing)
OLD_SHELL := $(SHELL)
SHELL = $(call assert_target_directory)$(OLD_SHELL)
foo/all: ; @echo $@
make 扩展 SHELL 的值,从而对每个执行的规则都调用 assert_target_directory。这一简单的修改意味着每个规则都会检查目标目录是否存在。
新的 SHELL 值包括对 assert_target_directory 的调用,该函数始终返回空字符串,后面跟着存储在 OLD_SHELL 中的旧 SHELL 值。注意 OLD_SHELL 是使用 := 定义的,以确保 SHELL 不会引用自身——OLD_SHELL 包含 SHELL 在运行时的值,可以安全地用来重新定义 SHELL。如果 OLD_SHELL 使用 = 定义,make 会因为循环引用而失败:SHELL 会引用 OLD_SHELL,而 OLD_SHELL 又会引用 SHELL,如此反复。
assert_target_directory 函数通过调用内建的 $(wildcard) 函数,并传入当前目标应写入的目录名来工作。$(wildcard) 函数简单地检查目录是否存在,如果存在,则返回目录名;如果目录缺失,则返回空字符串。目标由自动变量 $@ 定义,目录部分则通过 $(dir) 提取。
一个交互式 GNU make 调试器
尽管 GNU make 很受欢迎,但调试功能少之又少。GNU make 有一个 -d 选项,能够输出关于构建的广泛调试信息(但不一定有用),还有一个 -p 选项,会打印出 GNU make 的内部规则和变量数据库。本节展示了如何仅使用 GNU make 的内部函数和 shell read 命令来构建一个交互式调试器。
调试器有断点功能,能够在断点被触发时输出有关规则的信息,并允许交互式查询变量值和定义。
调试器实战
在你了解调试器如何工作之前,先来看一下如何使用它。调试器和这些示例都假设你正在使用 GNU make 3.80 或更高版本。示例 2-9 展示了一个示例 makefile,它从先决条件 foo 和 bar 构建 all。
示例 2-9. 使用 __BREAKPOINT 变量设置断点
MYVAR1 = hello
MYVAR2 = $(MYVAR1) everyone
all: MYVAR3 = $(MYVAR2)
all: foo bar
→ $(__BREAKPOINT)
→ @echo Finally making $@
foo bar:
→ @echo Building $@
为了演示如何使用调试器,断点被设置在 all 规则中,通过在规则的配方开始处插入一行仅包含变量 __BREAKPOINT。当规则执行时,$(__BREAKPOINT) 会展开,导致调试器中断执行,并在 all 规则即将运行时提示,如 示例 2-9 所示。
当执行这个 makefile 且没有名为 all、foo 或 bar 的文件时,会发生以下情况:
$ **make**
Building foo
Building bar
Makefile:51: GNU Make Debugger Break
Makefile:51: - Building 'all' from 'foo bar'
Makefile:51: - First prerequisite is 'foo'
Makefile:51: - Prerequisites 'foo bar' are newer than 'all'
1>
首先,你会看到执行 foo 和 bar 规则时的输出(即 Building foo 和 Building bar 行),接着进入调试器。调试器的断点会显示触发断点的行和所在的 makefile。在这个例子中,断点发生在 makefile 的第 51 行。(它是第 51 行,因为在 示例 2-9 中没有显示的是所有实际使调试器工作的 GNU make 变量。)
调试器还会输出正在构建的规则的信息。在这里,你可以看到 all 是从 foo 和 bar 构建的,第一个先决条件是 foo。这一点很重要,因为第一个先决条件会存储在 GNU make 的 $< 自动变量中。($< 通常作为编译时的源代码文件名使用。)调试器还会显示为什么 all 规则会运行:因为 foo 和 bar 都比 all 更新(因为它们刚刚由各自的规则构建)。
最后,调试器会提示 1> 输入命令。在自动继续执行 makefile 之前,调试器会接受 32 个命令。数字 1 表示这是第一个命令;一旦达到 32>,调试器会自动继续执行。首先,你可以通过输入 h 来请求帮助:
1< **h**
Makefile:51: c continue
Makefile:51: q quit
Makefile:51: v VAR print value of $(VAR)
Makefile:51: o VAR print origin of $(VAR)
Makefile:51: d VAR print definition of $(VAR)
2>
调试器提供了两种停止调试的方法:输入 c 会继续正常执行 makefile;输入 q 会退出 make。三个调试器命令 v、o 和 d 允许用户通过询问变量的值、来源(它是在哪里定义的)或定义来查询 GNU make 变量。例如,在 示例 2-9 中,makefile 包含两个变量——MYVAR1 和 MYVAR2——以及一个特定于 all 规则的变量:MYVAR3。第一步是向调试器询问这些变量的值:
2> **v MYVAR1**
Makefile:55: MYVAR1 has value 'hello'
3> **v MYVAR2**
Makefile:55: MYVAR2 has value 'hello everyone'
4> **v MYVAR3**
Makefile:55: MYVAR3 has value 'hello everyone'
5>
如果不清楚 MYVAR3 是如何获得其值的,你可以向调试器询问它的定义:
5> **d MYVAR3**
Makefile:55: MYVAR3 is defined as '$(MYVAR2)'
6>
这显示了 MYVAR3 被定义为 $(MYVAR2)。接下来的显而易见的步骤是找出 MYVAR2 是如何定义的(以及 MYVAR1):
6> **d MYVAR2**
Makefile:55: MYVAR2 is defined as '$(MYVAR1) everyone' 7
> **d MYVAR1**
Makefile:55: MYVAR1 is defined as 'hello'
8>
如果不清楚 MYVAR1 的值是如何获得的,o 命令将显示它的来源:
8> **o MYVAR1**
Makefile:55: MYVAR1 came from file
9>
这意味着 MYVAR1 在一个 makefile 中被定义。相比之下:
$ **make MYVAR1=Hello**
1> **v MYVAR1**
Makefile:55: MYVAR1 has value 'Hello'
2> **o MYVAR1**
Makefile:55: MYVAR1 came from command line
3>
如果用户在命令行上覆盖了 MYVAR1 的值(例如,运行 make MYVAR1=Hello),则 o 命令会反映这一点。
模式中的断点
除了在正常规则中设置断点,你还可以在模式中设置断点。每次使用该模式规则时,断点都会被触发。例如:
all: foo.x bar.x
%.x: FOO = foo
%.x: %.y
→ $(__BREAKPOINT)
→ @echo Building $@ from $<...
foo.y:
bar.y:
在这里,all 是由 foo.x 和 bar.x 构建的,这需要通过 %.x: %.y 规则从 foo.y 和 bar.y 构建它们。一个断点被插入到模式规则中,调试器会中断两次:一次是 foo.x,一次是 bar.x:
$ **make**
Makefile:66: GNU Make Debugger Break
Makefile:66: - Building 'foo.x' from 'foo.y'
Makefile:66: - First prerequisite is 'foo.y'
Makefile:66: - Prerequisites 'foo.y' are newer than 'foo.x'
1> **c**
Building foo.x from foo.y...
Makefile:66: GNU Make Debugger Break
Makefile:66: - Building 'bar.x' from 'bar.y'
Makefile:66: - First prerequisite is 'bar.y'
Makefile:66: - Prerequisites 'bar.y' are newer than 'bar.x'
1> **c**
Building bar.x from bar.y...
即使是特定于模式的变量也能正常工作:
$ **make**
Makefile:67: GNU Make Debugger Break
Makefile:67: - Building 'foo.x' from 'foo.y'
Makefile:67: - First prerequisite is 'foo.y'
Makefile:67: - Prerequisites 'foo.y' are newer than 'foo.x'
1> **v FOO**
Makefile:67: FOO has value 'foo'
2>
%.x 具有一个特定于模式的变量 FOO,其值为 foo;调试器的 v 命令可以在模式规则的断点处访问它。
Makefile 中的断点
此外,如果需要,你也可以直接在 makefile 中插入断点。makefile 的解析将在断点处暂停,以便你检查 makefile 中当前变量的状态。例如,在每次定义 FOO 后插入一个断点,你可以看到它的值如何变化:
FOO = foo
$(__BREAKPOINT)
FOO = bar
$(__BREAKPOINT)
下面是一个示例运行:
$ **make**
Makefile:76: GNU Make Debugger Break
1> **v FOO**
Makefile:76: FOO has value 'foo'
2> **c**
Makefile:78: GNU Make Debugger Break
1> **v FOO**
Makefile:78: FOO has value 'bar'
2>
这两个单独的断点会被激活(每次设置 FOO 后)。使用调试器的 v 命令可以显示在每个断点处 FOO 的值如何变化。
调试器内部实现
调试器调用了在 GMSL 中定义的函数(你可以在 第六章 中了解更多关于 GMSL 的信息)。调试器的第一行包括 GMSL 函数:
include gmsl
__LOOP := 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
调试器使用__PROMPT变量来输出n>并读取一个带有单一参数的命令。__PROMPT使用read shell 命令将命令和参数读取到 shell 变量$CMD和$ARG中,然后返回一个包含两个元素的列表:第一个元素是命令,第二个元素是参数。展开__PROMPT会提示并返回一个单一的命令和参数对:
__PROMPT = $(shell read -p "$(__HISTORY)> " CMD ARG ; echo $$CMD $$ARG)
你使用__BREAK变量来获取并处理单一命令。首先,它将__PROMPT的结果存储在__INPUT中,然后调用__DEBUG函数(处理调试器命令),并传入两个参数:由__PROMPT在__INPUT中返回的命令及其参数。
__BREAK = $(eval __INPUT := $(__PROMPT)) \
$(call __DEBUG, \
$(word 1,$(__INPUT)), \
$(word 2,$(__INPUT)))
__DEBUG函数处理调试器的核心部分。__DEBUG接受一个单字符命令作为第一个参数$1,以及命令的可选参数$2。$1存储在变量__c中,$2存储在__a中。然后,__DEBUG检查__c是否为支持的调试器命令之一(c、q、v、d、o或h);如果不是,$(warning)将输出错误消息。
__DEBUG由一组嵌套的$(if)语句组成,使用 GMSL 的seq函数来判断__c是否为有效的调试器命令。如果是,$(if)的第一个参数将被展开;如果不是,接下来的$(if)将被检查。例如,v命令(用于输出变量的值)是这样处理的:
$(if $(call seq,$(__c),v),$(warning $(__a) has value '$($(__a))'), ... next if ... )
如果__c命令是v,则使用$(warning)输出由__a命名的变量的值($($(__a))输出存储在__a中的变量名对应的值)。
当__DEBUG完成时,它返回$(true)或$(false)(空字符串)。$(true)表示调试器应停止提示命令并继续执行(q命令通过调用 GNU make的$(error)函数来引发致命错误,从而停止make):
__DEBUG = $(eval __c = $(strip $1)) \
$(eval __a = $(strip $2)) \
$(if $(call seq,$(__c),c), \
$(true), \
$(if $(call seq,$(__c),q), \
$(error Debugger terminated build), \
$(if $(call seq,$(__c),v), \
$(warning $(__a) has value '$($(__a))'), \
$(if $(call seq,$(__c),d), \
$(warning $(__a) is defined as '$(value $(__a))'), \
$(if $(call seq,$(__c),o), \
$(warning $(__a) came from $(origin $(__a))), \
$(if $(call seq,$(__c),h), \
$(warning c continue) \
$(warning q quit) \
$(warning v VAR print value of $$(VAR)) \
$(warning o VAR print origin of $$(VAR)) \
$(warning d VAR print definition of $$(VAR)), \
$(warning Unknown command '$(__c)')))))))
最后,我们来看看__BREAKPOINT的定义(这是我们在示例 2-9 中使用的断点变量)。它首先输出一个包含信息的横幅(稍后你将看到__BANNER的作用);然后它通过调用__BREAK来循环询问命令。循环会在__LOOP中没有更多项时结束(这里定义了 32 个命令的限制),或者当调用__BREAK返回$(true)时结束:
__BREAKPOINT = $(__BANNER) \
$(eval __TERMINATE := $(false)) \
$(foreach __HISTORY, \
$(__LOOP), \
$(if $(__TERMINATE),, \
$(eval __TERMINATE := $(__BREAK))))
__BANNER显示调试器已在断点处停止,并通过检查 GNU make自动变量,能够提供有关当前正在构建的规则的信息:
__BANNER = $(warning GNU Make Debugger Break) \
$(if $^, \
$(warning - Building '$@' from '$^'), \
$(warning - Building '$@')) \
$(if $<,$(warning - First prerequisite is '$<')) \
$(if $%,$(warning - Archive target is '$%')) \
$(if $?,$(warning - Prerequisites '$?' are newer than '$@'))
这是完整的调试器代码:
__LOOP := 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
__PROMPT = $(shell read -p "$(__HISTORY)> " CMD ARG ; echo $$CMD $$ARG)
__DEBUG = $(eval __c = $(strip $1)) \
$(eval __a = $(strip $2)) \
$(if $(call seq,$(__c),c), \
$(true), \
$(if $(call seq,$(__c),q), \
$(error Debugger terminated build), \
$(if $(call seq,$(__c),v), \
$(warning $(__a) has value '$($(__a))'), \
$(if $(call seq,$(__c),d), \
$(warning $(__a) is defined as '$(value $(__a))'), \
$(if $(call seq,$(__c),o), \
$(warning $(__a) came from $(origin $(__a))), \
$(if $(call seq,$(__c),h), \
$(warning c continue) \
$(warning q quit) \
$(warning v VAR print value of $$(VAR)) \
$(warning o VAR print origin of $$(VAR)) \
$(warning d VAR print definition of $$(VAR)), \
$(warning Unknown command '$(__c)')))))))
__BREAK = $(eval __INPUT := $(__PROMPT)) \
$(call __DEBUG, \
$(word 1,$(__INPUT)), \
$(word 2,$(__INPUT)))
__BANNER = $(warning GNU Make Debugger Break) \
$(if $^, \
$(warning - Building '$@' from '$^'), \
$(warning - Building '$@')) \
$(if $<,$(warning - First prerequisite is '$<')) \
$(if $%,$(warning - Archive target is '$%')) \
$(if $?,$(warning - Prerequisites '$?' are newer than '$@'))
__BREAKPOINT = $(__BANNER) \
$(eval __TERMINATE := $(false)) \
$(foreach __HISTORY, \
$(__LOOP), \
$(if $(__TERMINATE),, \
$(eval __TERMINATE := $(__BREAK))))
要查看最新版本,请访问 GNU make 调试器开源项目,地址是gmd.sf.net/。
GNU make 调试器中的动态断点
前一节展示了如何完全使用 GNU make 编写一个调试器。但它只有静态(硬编码)断点。本节展示了如何通过添加动态断点来增强调试器。这使得可以根据文件的名称(在 GNU make 语言中,称为 目标)设置和移除断点,而这个目标是 makefile 将要构建的。
不再需要在 makefile 中插入 $(__BREAKPOINT) 字符串。输入一个简单的设置断点命令就能产生相同的效果。另一个按键操作则列出当前生效的所有断点。
本节展示了新断点的使用以及如何编写代码。新代码完全使用 GNU make 的变量语言编写,并使用 GMSL 设置函数(详见第六章)来维护当前断点列表。
要激活断点,需要一些 GNU make 的魔法,但首先让我们看一个例子。
动态断点的实际应用
在了解调试器如何工作之前,先看看如何使用它。调试器和这些示例都假设你使用的是 GNU make 3.80 或更高版本。
这是一个示例 makefile,它从前置条件 foo 和 bar 构建 all。
include gmd
MYVAR1 = hello
MYVAR2 = $(MYVAR1) everyone
all: MYVAR3 = $(MYVAR2)
all: foo bar
all: ; @echo Finally making $@
foo bar: ; @echo Building $@
$(__BREAKPOINT)
为了说明调试器的使用,通过在 makefile 末尾插入一行仅包含变量 $(__BREAKPOINT) 的代码来设置断点。$(__BREAKPOINT) 会在 makefile 解析完毕时展开,导致调试器在任何规则执行前中断执行并提示输入。(调试器通过在开始处的 include gmd 命令引入。你可以从 GMD 网站获取 GMD 文件,地址是 gmd.sf.net/;所有代码都是开源的。)
当执行这个 makefile 且没有名为 all、foo 或 bar 的现有文件时,会发生以下情况:
$ **make**
Makefile:11: GNU Make Debugger Break
1> **h**
Makefile:11: c: continue
Makefile:11: q: quit
Makefile:11: v VAR: print value of $(VAR)
Makefile:11: o VAR: print origin of $(VAR)
Makefile:11: d VAR: print definition of $(VAR)
Makefile:11: b TAR: set a breakpoint on target TAR
Makefile:11: r TAR: unset breakpoint on target TAR
Makefile:11: l: list all target breakpoints
2>
调试器会立即中断并等待输入。首先要做的是输入 h 查看帮助文本以及三个新命令:b(设置断点),r(移除断点),和 l(列出当前断点)。
然后在 makefile 中设置两个断点:一个是当 foo 被构建时,另一个是为 all 设置的。(如果你回顾一下调试器实战,你会看到你也可以通过修改 makefile 来实现这一点,但这些新断点可以在运行时动态设置。)
设置断点后,使用 l 命令来验证它们是否已设置:
2> **b foo**
Makefile:11: Breakpoint set on `foo'
3> **b all**
Makefile:11: Breakpoint set on `all'
4> **l**
Makefile:11: Current target breakpoints: `all' `foo'
5>
通过输入 c 继续执行时,foo 断点会立即触发。foo 是 makefile 将构建的第一个目标(接着是 bar,最后是 all)。该断点表明 foo 的规则位于第 9 行:
5> **c**
Makefile:9: GNU Make Debugger Break
Makefile:9: - Building 'foo'
1>
继续执行时,首先会显示生成 bar 时的输出,然后触发 all 断点。
1> **c**
Building foo
Building bar
Makefile:7: GNU Make Debugger Break
Makefile:7: - Building 'all' from 'foo bar'
Makefile:7: - First prerequisite is 'foo'
Makefile:7: - Prerequisites 'foo bar' are newer than 'all'
1>
all 断点打印出的信息比 foo 断点多得多,因为 all 具有前置条件。
简单部分
为了将断点函数添加到 GNU make 调试器中,首先修改了处理键盘输入的调试器代码,以识别b、r和l命令,并调用用户定义的 GNU make 函数__BP_SET、__BP_UNSET和__BP_LIST。
定义断点的目标只是一个 GMSL 目标名称集合。最初,没有断点,因此该集合(称为__BREAKPOINTS)是空的:
__BREAKPOINTS := $(empty_set)
设置和删除断点只需调用 GMSL 函数set_insert和set_remove来向__BREAKPOINTS中添加或移除元素:
__BP_SET = $(eval __BREAKPOINTS := $(call set_insert,$1,$(__BREAKPOINTS))) \
$(warning Breakpoint set on `$1')
__BP_UNSET = $(if $(call set_is_member,$1,$(__BREAKPOINTS)), \
$(eval __BREAKPOINTS := $(call set_remove,$1,$(__BREAKPOINTS))) \
$(warning Breakpoint on `$1' removed), \
$(warning Breakpoint on `$1' not found))
两个函数都使用 GNU make $(eval) 函数来更改__BREAKPOINTS的值。$(eval FOO)将其参数FOO当作文本在解析 makefile 时进行求值:这意味着在运行时你可以更改变量值或定义新的规则。
__BP_UNSET使用 GMSL 函数set_is_member来判断要移除的断点是否已定义,并在用户尝试移除一个不存在的断点时(这可能是由于用户输入错误)输出一个有用的消息。
列出当前断点仅仅是输出存储在__BREAKPOINTS中的集合内容。因为该集合只是一个没有重复元素的列表,所以__BP_LIST将其值传递给 GNU make 函数$(addprefix)和$(addsuffix),以便在目标名称周围加上引号:
__BP_LIST = $(if $(__BREAKPOINTS), \
$(warning Current target breakpoints: \
$(addsuffix ',$(addprefix `,$(__BREAKPOINTS)))), \
$(warning No target breakpoints set))
__BP_LIST使用 GNU make $(if) 函数来选择:如果有断点,则列出断点;如果__BREAKPOINTS集合为空,则显示No target breakpoints set。
诀窍
要让 GNU make 进入调试器,它必须展开__BREAKPOINT变量,该变量输出有关断点的信息并提示输入命令。但为了实现这一点,我们需要一种方法来检查每次规则即将运行时定义了哪些断点。如果我们能做到这一点,那么make就可以在必要时展开$(__BREAKPOINT),导致make在断点处停止。
幸运的是,通过修改内置的SHELL变量,可以让make展开__BREAKPOINT。
每次命令准备在规则内部运行时,SHELL变量也会被展开。这使得它非常适合检查断点。以下是 GNU make 调试器中实际使用 SHELL 处理断点的代码:
__BP_OLD_SHELL := $(SHELL)
__BP_NEW_SHELL = $(if $(call seq,$(__BP_FLAG),$@), \
$(call $1,), \
$(__BP_CHECK))$(__BP_OLD_SHELL)
SHELL = $(call __BP_NEW_SHELL,$1)
首先,SHELL的实际值存储在__BP_OLD_SHELL中(请注意,GNU make := 操作符用于捕获SHELL的值,而不是定义)。然后,SHELL被重新定义为调用__BP_NEW_SHELL变量。
__BP_NEW_SHELL是执行有趣工作的地方。它的最后部分是$(__BP_OLD_SHELL),即原始SHELL变量的值。毕竟,一旦检查完断点,GNU make需要使用原始的shell来实际运行命令。在此之前,还有一个相当复杂的$(if)。集中精力看一下对$(__BP_CHECK)的调用。这个变量实际上会检查是否应该执行断点。它是这样定义的:
__BP_CHECK = $(if $(call set_is_member,$@, \
$(__BREAKPOINTS)), \
$(eval __BP_FLAG := $@) \
$(eval __IGNORE := $(call SHELL, \
__BREAKPOINT)))
__BP_FLAG :=
__BP_CHECK检查当前正在构建的目标(存储在标准 GNU make自动变量$@中)是否存在于断点列表中。它通过使用 GMSL 函数set_is_member来完成这一检查。如果目标存在,它会做两件事:设置一个名为__BP_FLAG的内部变量,将其值设为已激活断点的目标,并继续执行$(call)某个变量,并通过将结果存储在__IGNORE变量中来丢弃它。这么做是为了确保__BP_CHECK的返回值始终为空;毕竟它是用于定义SHELL的,而SHELL最终需要只是要执行的shell的名称。
有经验的 GNU make用户可能会抓耳挠腮,想弄清楚那个奇怪的语法$(call SHELL,__BREAKPOINT)。这时就涉及到一些 GNU make的火箭科学。
火箭科学
与其写$(call SHELL,__BREAKPOINT),人们更容易写$(__BREAKPOINT)来激活断点。但这样并不起作用。
这么做会导致致命的 GNU make错误。追溯变量链从__BP_CHECK开始,就会发现它已经展开,因为SHELL正在展开(因为规则即将运行)。跟进到__BREAKPOINT,会有一个令人吃惊的结果:调用$(shell)(在 GMD 代码中的加法与减法或者前一节中可以看到),这会导致SHELL展开。
危险,威尔·罗宾逊!SHELL是通过SHELL定义的,这会导致 GNU make发现递归并放弃。$(call SHELL,__BREAKPOINT)语法让我们可以玩火。每次在 GNU make中使用$(call)调用一个变量时,用于检查递归的标志会被禁用。因此,执行$(call SHELL,__BREAKPOINT)意味着SHELL的递归标志被关闭(避免了错误),并且SHELL的定义会调用__BP_NEW_SHELL并传入一个参数。该参数是词汇__BREAKPOINT。__BP_NEW_SHELL会检查__BP_FLAG是否与$@的值相同(通过 GMSL 的seq函数进行检查),然后继续执行$(call)第一个参数(即__BREAKPOINT);断点被触发,提示符出现。
当执行 $(shell) 并且 SHELL 被再次展开时,可能看起来会发生可怕的无限递归。两个因素阻止了这种情况:__BP_FLAG 与 $@ 保持一致(因此 __BP_CHECK 不会再次被调用),并且这次 SHELL 没有参数($1 的值为空),所以 $(call $1,) 什么也不做,递归停止。
remake 简介
remake 项目(bashdb.sourceforge.net/remake/) 是一个基于 GNU make 的分支,它通过修改 GNU make 源代码集成了一个完整的调试器。remake 从 GNU make 3.82 分支而来,目前版本为 3.82+dbg-0.9。
仅打印和跟踪
为了说明 remake 的操作,我们使用 示例 2-10,这是一个示例 makefile:
示例 2-10. 一个简单的 makefile 用于说明 remake
.PHONY: all
all: foo bar baz
foo: bar
→ @touch $@
bar:
→ @touch $@
baz: bam
→ @touch $@
bam:
→ @touch $@
运行标准的 GNU make -n(或 --just-print)选项来执行这个 makefile 会产生以下输出:
$ **make -n**
touch bar
touch foo
touch bam
touch baz
但是 remake 为每个规则提供了 makefile 和行号信息。这些信息显示了目标($@ 的值)和要执行的命令:
$ remake -n
##>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
Makefile:8: bar
touch bar
##<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<
##>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
Makefile:5: foo
touch foo
##<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<
##>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
Makefile:14: bam
touch bam
##<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<
##>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
Makefile:11: baz
touch baz
##<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<
当然,你必须运行任何实际的 makefile 来理解其执行过程。remake 提供了一个方便的跟踪选项 -x,它在运行 makefile 的同时输出有关为何构建目标的信息,并显示执行的命令及其输出:
$ **remake -x**
Reading makefiles...
Updating goal targets....
Makefile:2 File `all' does not exist.
Makefile:4 File `foo' does not exist.
Makefile:7 File `bar' does not exist.
Makefile:7 Must remake target `bar'.
##>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
Makefile:8: bar
touch bar
##<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<
+ touch bar
Makefile:7 Successfully remade target file `bar'.
Makefile:4 Must remake target `foo'.
##>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
Makefile:5: foo
touch foo
##<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<
+ touch foo
Makefile:4 Successfully remade target file `foo'.
Makefile:10 File `baz' does not exist.
Makefile:13 File `bam' does not exist.
Makefile:13 Must remake target `bam'.
##>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
Makefile:14: bam
touch bam
##<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<
+ touch bam
Makefile:13 Successfully remade target file `bam'.
Makefile:10 Must remake target `baz'.
##>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
Makefile:11: baz
touch baz
##<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<
+ touch baz
Makefile:10 Successfully remade target file `baz'.
Makefile:2 Must remake target `all'. Is a phony target.
Makefile:2 Successfully remade target file `all'.
跟踪选项在发生错误时非常有用。这里是当一个不存在的选项 -z 被添加到 touch 命令中以构建目标 bar 时的输出:
$ **remake -x**
Reading makefiles...
Updating goal targets....
Makefile:2 File `all' does not exist.
Makefile:4 File `foo' does not exist.
Makefile:7 File `bar' does not exist.
Makefile:7 Must remake target `bar'.
##>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
Makefile:8: bar
touch -z bar
##<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<
+ touch -z bar
touch: invalid option -- 'z'
Try `touch --help' for more information.
Makefile:8: *** [bar] Error 1
#0 bar at Makefile:8
#1 foo at Makefile:4
#2 all at Makefile:2
Command-line arguments:
"-x"
输出的最底部是调用栈,显示依赖于 bar 构建成功的目标列表,以及 touch 生成的错误、执行的实际命令和在 makefile 中的位置。
调试
因为 remake 包含一个交互式调试器,你可以用它来调试 touch 问题。运行 remake 时使用 -X 选项(大写的 X 用于调试器;小写的 x 用于跟踪),调试器会在第一个目标构建时断点:
$ **remake -X**
GNU Make 3.82+dbg0.9
Built for x86_64-unknown-linux-gnu
Copyright (C) 2010 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.
Reading makefiles...
Updating makefiles....
Updating goal targets....
Makefile:2 File `all' does not exist.
-> (Makefile:4)
foo: bar
remake<0>
所以,第一个断点是在 makefile 的第 2 行,它显示第一个目标是 all(并显示完整的前提条件列表)。输入 h 会提供完整的帮助信息:
remake<0> **h**
Command Short Name Aliases
---------------------- ---------- ---------
break [TARGET|LINENUM] [all|run|prereq|end]* (b) L
cd DIR (C)
comment TEXT (#)
continue [TARGET [all|run|prereq|end]*] (c)
delete breakpoint numbers.. (d)
down [AMOUNT] (D)
edit (e)
eval STRING (E)
expand STRING (x)
finish [AMOUNT] (F)
frame N (f)
help [COMMAND] (h) ?, ??
info [SUBCOMMAND] (i)
list [TARGET|LINE-NUMBER] (l)
next [AMOUNT] (n)
print {VARIABLE [attrs...]} (p)
pwd (P)
quit [exit-status] (q) exit, return
run [ARGS] (R) restart
set OPTION {on|off|toggle}
set variable VARIABLE VALUE (=)
setq VARIABLE VALUE (")
shell STRING (!) !!
show [SUBCOMMAND] (S)
source FILENAME (<)
skip (k)
step [AMOUNT] (s)
target [TARGET-NAME] [info1 [info2...]] (t)
up [AMOUNT] (u)
where (T) backtrace, bt
write [TARGET [FILENAME]] (w)
因为 touch 问题发生在 make 执行的后期(在 bar 规则中),所以只需继续执行,通过 s 单步调试:
remake<1> **s**
Makefile:4 File `foo' does not exist.
-> (Makefile:7)
bar:
remake<2> **s**
Makefile:7 File `bar' does not exist.
Makefile:7 Must remake target `bar'.
Invoking recipe from Makefile:8 to update target `bar'.
##>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
touch -z bar
##<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<
++ (Makefile:7)
bar
remake<3> **s**
touch: invalid option -- 'z'
Try 'touch --help' for more information.
Makefile:7: *** [bar] Error 1
#0 bar at Makefile:7
#1 foo at Makefile:4
#2 all at Makefile:2
***Entering debugger because we encountered a fatal error.
** Exiting the debugger will exit make with exit code 1.
!! (Makefile:7)
bar
remake<4>
在调试器中,你可以修复 makefile 中的错误,然后输入R来重新启动构建:
remake<4> **R**
Changing directory to /home/jgc and restarting...
GNU Make 3.82+dbg0.9
Built for x86_64-unknown-linux-gnu
Copyright (C) 2010 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.
Reading makefiles...
Updating makefiles....
Updating goal targets....
Makefile:2 File `all' does not exist.
-> (Makefile:4)
foo: bar
remake<0> **c**
现在,一切正常工作。
目标、宏值和展开
当在调试器中停止时,可以查询 makefile 中目标的信息,例如变量值(扩展和未扩展的)和命令。例如,在示例 2-10 中,当停在断点时,可以通过使用 target 命令查找 remake 关于 all 目标的所有信息:
$ **remake -X**
GNU Make 3.82+dbg0.9
Built for x86_64-unknown-linux-gnu
Copyright (C) 2010 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.
Reading makefiles...
Updating makefiles....
Updating goal targets....
/home/jgc/src/thirdparty/remake-3.82+dbg0.9/Makefile:2 File `all' does not exist.
-> (/home/jgc/src/thirdparty/remake-3.82+dbg0.9/Makefile:4)
foo: bar
remake<0> **target all**
all: foo bar baz
# Phony target (prerequisite of .PHONY).
# Implicit rule search has not been done.
# Implicit/static pattern stem: `'
# File does not exist.
# File has not been updated.
# Commands not yet started.
# automatic
# @ := all
# automatic
# % :=
# automatic
# * :=
# automatic
# + := foo bar baz
# automatic
# | :=
# automatic
# < := all
# automatic
# ^ := foo bar baz
# automatic
# ? :=
remake<1>
remake 显示 all 是一个虚拟目标,并打印出将为此规则设置的自动变量信息。没有任何限制可以查询当前目标:
remake<1> **target foo**
foo: bar
# Implicit rule search has not been done.
# Implicit/static pattern stem: `'
# File does not exist.
# File has not been updated.
# Commands not yet started.
# automatic
# @ := foo
# automatic
# % :=
# automatic
# * :=
# automatic
# + := bar
# automatic
# | :=
# automatic
# < := bar
# automatic
# ^ := bar
# automatic
# ? :=
# commands to execute (from `Makefile', line 5):
@touch $@
remake<2>
因为目标 foo 有命令,它们会列在底部(以及在哪里找到它们的 makefile)。要查看命令的扩展形式,请使用 target 命令的 expand 修饰符:
remake<2> **target foo expand**
foo:
# commands to execute (from `Makefile', line 5):
@touch foo
remake<3>
要获取关于变量的信息,我们使用方便的 print 和 expand 命令:print 给出变量的定义,而 expand 给出扩展后的值。以下是如何查找内置的 COMPILE.c 变量的定义(它包含用于编译 .c 文件的命令):
remake<4> **print COMPILE.c**
(origin default) COMPILE.c = $(CC) $(CFLAGS) $(CPPFLAGS) $(TARGET_ARCH) -c
要查看扩展后的值,expand它:
remake<7> **expand COMPILE.c**
(origin default) COMPILE.c := cc -c
remake 也可以使用 set(它扩展一个字符串并将变量设置为该值)和 setq(它将变量设置为一个未扩展的字符串)来设置变量的值。例如,将 CC 从 cc 改为 gcc 会改变 make 使用的 C 编译器:
remake<7> **expand COMPILE.c**
(origin default) COMPILE.c := cc -c
remake<8> **print CC**
(origin default) CC = cc
remake<9> **setq CC gcc**
Variable CC now has value 'gcc'
remake<10> **print CC**
(origin debugger) CC = gcc
remake<11> **expand COMPILE.c**
(origin default) COMPILE.c := gcc -c
remake<12>
remake 是一个非常有用的工具,可以添加到你的 make 工具包中。你不需要每天使用它,但当你遇到棘手问题时,如果你没有使用 GNU make 4.0 中新增的任何特性,将 make 切换到 remake 会毫不费力。
第三章 构建与重建
了解何时以及为何重新构建目标和执行配方是使用 GNU make 的基础。对于简单的 makefile,很容易理解为什么会构建某个特定的目标文件,但对于现实世界中的 makefile,构建和重新构建变得更加复杂。此外,GNU make 的依赖关系可能会受到限制,因为文件只有在先决条件的修改时间晚于目标文件时才会更新。而且在大多数情况下,只有一个目标会由一个规则更新。
本章解释了在 GNU make 中处理依赖关系的高级技巧,包括在目标的配方发生变化时重新构建,在文件的校验和发生变化时重新构建,如何最好地实现递归 make,以及如何在一个规则中构建多个目标。
当 CPPFLAGS 变化时重新构建
本节展示了如何实现 GNU make 中一个重要的“缺失功能”:当目标的构建命令发生变化时,重新构建目标的能力。GNU make 在目标过时时重新构建目标;也就是说,当某些先决条件比目标本身更新时,它会重新构建。但是如果目标看起来是最新的(通过文件时间戳判断),但实际构建该目标的命令已经发生变化怎么办?
例如,当一个非调试构建之后跟着一个调试构建(可能是先运行 make 然后运行 make DEBUG=1)时会发生什么?除非构建结构设计得当,使得目标的名称依赖于是否为调试或非调试构建,否则不会发生任何变化。
GNU make 无法检测某些目标是否应该重新构建,因为它没有考虑到配方中的命令发生变化的情况。例如,如果 DEBUG=1 导致传递给编译器的标志发生变化,那么目标应该重新构建。
本节将教你如何通过几行 GNU make 代码实现这一目标。
示例 makefile
本节中使用的示例 makefile 来自 示例 3-1,用于演示命令变化时重新构建系统。为了使系统的操作更加清晰,我避免使用内建的 GNU make 规则,因此这个 makefile 并不像它本可以那么简单:
示例 3-1:用于演示命令变化时重新构建系统的示例 makefile。
all: foo.o bar.o
foo.o: foo.c
→ $(COMPILE.C) -DDEBUG=$(DEBUG) -o $@ $<
bar.o: bar.c
→ $(COMPILE.C) -o $@ $<
该 makefile 通过编译相应的 .c 文件来创建两个 .o 文件,foo.o 和 bar.o。编译使用内建变量 COMPILE.C(通常是系统中适用的编译器名称,后面跟着类似 CPPFLAGS 的变量以及使用 $@ 和 $< 编译代码成目标文件)。
对 $(DEBUG) 的特定引用被转换为一个名为 DEBUG 的预处理器变量,使用编译器的 -D 选项。foo.c 和 bar.c 的内容已被省略,因为它们无关紧要。
这是在没有命令行选项的情况下运行make时发生的情况(这意味着DEBUG是未定义的):
$ **make**
g++ -c -DDEBUG= -o foo.o foo.c
g++ -c -o bar.o bar.c
现在,foo.o和bar.o已经被创建,因此再次输入make不会做任何事情:
$ **make**
make: Nothing to be done for `all'.
输入make DEBUG=1也不会产生任何效果,即使如果使用DEBUG定义重建,foo.o文件很可能会有所不同(例如,它可能会包含由#ifdef控制的额外调试代码,DEBUG变量在源代码中被使用):
$ **make DEBUG=1**
make: Nothing to be done for `all'.
下一节中的signature系统将修正这个问题,并且对 Makefile 维护者几乎不需要任何额外工作。
修改我们的示例 Makefile
为了解决前面一节中的问题,我们将使用一个辅助的 Makefile,名为signature。我们稍后会看看signature是如何工作的;首先,让我们看看如何修改示例 3-1 中的 Makefile 来使用它:
**include signature**
all: foo.o bar.o
foo.o: foo.c
→ **$(call do,$$(COMPILE.C) -DDEBUG=$$(DEBUG) -o $$@ $$<)**
bar.o: bar.c
→ **$(call do,$$(COMPILE.C) -o $$@ $$<)**
**-include foo.o.sig bar.o.sig**
文件做了三个更改:首先,include signature被添加到开头,这样处理更新签名的代码就被包含了。这些签名将捕获用于构建文件的命令,并在命令发生变化时用于重新构建。
其次,两个规则中的命令被包裹在$(call do,...)中,并且每个命令的$符号被用第二个$进行了转义。
第三,对于每个由signature管理的.o文件,都有一个对应的.sig文件的include。Makefile 的最后一行包括了foo.o.sig(对于foo.o)和bar.o.sig(对于bar.o)。请注意,使用了-include而不是单纯的include,以防.sig文件缺失(-include在要包含的文件不存在时不会产生错误)。
在你看到它是如何工作的之前,以下是一些它运行时的示例:
$ **make**
g++ -c -DDEBUG= -o foo.o foo.c
g++ -c -o bar.o bar.c
$ **make**
make: Nothing to be done for `all'.
首先,进行一次干净的构建(没有.o文件),然后重新运行make,以查看没有任何操作。
但是,现在在make命令行中将DEBUG设置为1会导致foo.o重新构建:
$ **make DEBUG=1**
g++ -c -DDEBUG=1 -o foo.o foo.c
这是因为它的签名(用于构建foo.o的实际命令)发生了变化。
当然,bar.o没有被重新构建,因为它确实是最新的(它的目标代码是新的,并且没有命令变化)。再次运行make DEBUG=1时,它会显示没有任何需要做的事情:
$ **make DEBUG=1**
make: Nothing to be done for `all'.
$ **make**
g++ -c -DDEBUG= -o foo.o foo.c
但是,只需输入make(回到非调试构建模式)就会再次重建foo.o,因为DEBUG现在未定义。
signature系统同样适用于递归变量中的变量。在 GNU make中,COMPILE.C实际上展开了CPPFLAGS以创建完整的编译器命令行。如果通过添加定义在 GNU make命令行中修改了CPPFLAGS,会发生以下情况:
$ **make CPPFLAGS+=-DFOO=foo**
g++ -DFOO=foo -c -DDEBUG= -o foo.o foo.c
g++ -DFOO=foo -c -o bar.o bar.c
由于CPPFLAGS发生了变化(并且CPPFLAGS是用于构建这两个目标文件的命令的一部分),所以foo.o和bar.o都被重新构建了。
当然,修改一个未被引用的变量并不会更新任何内容。例如:
$ **make**
g++ -c -DDEBUG= -o foo.o foo.c
g++ -c -o bar.o bar.c
$ **make SOMEVAR=42**
make: Nothing to be done for `all'.
这里我们从一个干净的构建开始,并重新定义了SOMEVAR。
签名如何工作
要理解signature是如何工作的,首先查看.sig文件。.sig文件是由signature makefile 中的规则自动生成的,适用于每个使用$(call do,...)形式的规则。
例如,下面是第一次干净构建运行后foo.o.sig文件的内容:
$(eval @ := foo.o)
$(eval % := )
$(eval < := foo.c)
$(eval ? := foo.o.force)
$(eval ^ := foo.c foo.o.force)
$(eval + := foo.c foo.o.force)
$(eval * := foo)
foo.o: foo.o.force
$(if $(call sne,$(COMPILE.C) -DDEBUG=$(DEBUG) -o $@ $<,\
g++ -c -DDEBUG= -o foo.o foo.c),$(shell touch foo.o.force))
前七行捕捉了在处理foo.o规则时定义的自动变量的状态。我们需要这些变量的值,以便将当前规则的命令(可能使用了自动变量)与上次运行该规则时的命令进行比较。
接下来是foo.o: foo.o.force这一行。这表示如果foo.o.force更新了,foo.o必须重新构建。正是这一行导致了foo.o在命令变化时被重建,下一行则会在命令发生变化时更新foo.o.force。
长的$(if)语句使用 GMSL 的sne(字符串不等)函数,将foo.o的当前命令(通过展开它们)与上次展开时的值进行比较。如果命令发生变化,则会调用$(shell touch foo.o.force)。
由于.sig文件在解析 makefile 时被处理(它们只是使用include读取的 makefile),所有.force文件将在任何规则运行之前被更新。因此,这个小小的.sig文件在命令发生变化时,完成了强制重建目标文件的工作。
.sig文件由signature创建:
include gmsl
last_target :=
dump_var = \$$(eval $1 := $($1))
define new_rule
@echo "$(call map,dump_var,@ % < ? ^ + *)" > $S
@$(if $(wildcard $F),,touch $F)
@echo $@: $F >> $S
endef
define do
$(eval S := $@.sig)$(eval F := $@.force)$(eval C := $(strip $1))
$(if $(call sne,$@,$(last_target)),$(call new_rule),$(eval last_target := $@))
@echo "S(subst ",\",$(subst $$,\$$,$$(if $$(call sne,$(strip $1),$C),$$(shell touch $F))))" >> $S
$C
endef
signature包含了 GMSL,并定义了用于封装规则中命令的关键do变量。当调用do时,它会创建相应的.sig文件,包含所有自动变量的状态。
do调用的new_rule函数捕捉了自动变量。它使用 GMSL 的map函数,调用另一个函数(dump_var),对每个@ % < ? ^ + *进行处理。new_rule函数还确保创建了相应的.force文件。
此外,do会写出复杂的$(if)语句,包含当前规则的命令的未展开和已展开版本。然后,它会在最后实际执行这些命令(那就是$C)。
限制
签名系统有一些限制,可能会让不小心的人陷入困境。首先,如果规则中的命令包含任何副作用——例如,如果它们调用了$(shell)——如果假设副作用只发生一次,系统可能会出现异常。
第二,必须确保在任何.sig文件之前包含signature。
第三,如果编辑了 makefile 并且规则中的命令发生了变化,签名系统不会注意到这一点。如果发生这种情况,必须重新生成相应的目标,以便更新.sig文件。
尝试在new_rule定义的末尾添加以下一行:
@echo $F: Makefile >> $S
你可以通过将 makefile 作为每个 makefile 目标的前置条件来让签名系统在 makefile 发生变化时自动重新构建。这行代码是实现这一点的最简单方法。
当文件的校验和发生变化时重新构建
除了让 GNU make 在命令更改时重新构建目标外,另一种常见的技术是,当文件内容发生变化时重新构建,而不仅仅是文件的时间戳。
这种情况通常出现是因为生成的代码的时间戳,或者从源代码控制系统提取的代码的时间戳,比相关对象的时间戳要旧,因此 GNU make 不知道需要重新构建该对象。即使文件的内容与上次构建该对象时不同,仍然可能发生这种情况。
一个常见的场景是,某个工程师在本地机器上进行构建,重新构建所有对象文件,然后从源代码控制中获取最新版本的源文件。一些较旧的源代码控制系统将源文件的时间戳设置为文件提交到源代码控制时的时间戳;在这种情况下,新构建的对象文件的时间戳可能比(可能已经更改的)源代码文件还要新。
在本节中,你将学习一种简单的技巧,让 GNU make 在源文件内容发生变化时正确地执行(重新构建)。
一个示例 Makefile
示例 3-2 中的简单 makefile 使用内置规则从 .c 文件构建 .o 文件,生成对象文件 foo.o,方法是从 foo.c 和 foo.h 中构建:
示例 3-2. 一个简单的 makefile,从 foo.c 和 foo.h 构建 foo.o
.PHONY: all
all: foo.o
foo.o: foo.c foo.h
如果 foo.c 或 foo.h 比 foo.o 更新,则 foo.o 将被重新构建。
如果 foo.h 发生变化而未更新其时间戳,GNU make 将不会做任何操作。例如,如果 foo.h 从源代码控制中更新,可能会导致该 makefile 做出错误的操作。
为了解决这个问题,我们需要一种方法强制 GNU make 考虑文件的内容,而不是时间戳。因为 GNU make 仅能处理时间戳,所以我们需要修改 makefile,使文件的时间戳与文件内容相关联。
处理文件内容
检测文件变化的一个简单方法是使用消息摘要函数(如 MD5)来生成文件的摘要。因为文件的任何变化都会导致摘要变化,所以只需检查摘要即可检测文件内容的变化。
为了强制 GNU make 检查每个文件的内容,我们将为每个待测试的源代码文件关联一个扩展名为 .md5 的文件。每个 .md5 文件将包含相应源代码文件的 MD5 校验和。
在示例 3-2 中,源代码文件foo.c和foo.h将分别有相关的.md5文件foo.c.md5和foo.h.md5。为了生成 MD5 校验和,我们使用md5sum工具:它输出一个包含输入文件 MD5 校验和的十六进制字符串。
如果我们确保当相关文件的校验和变化时,.md5文件的时间戳也会变化,GNU make就可以检查.md5文件的时间戳,而不需要实际的源文件。
在我们的示例中,GNU make会检查foo.c.md5和foo.h.md5的时间戳,以确定是否需要重新构建foo.o。
修改后的 Makefile
下面是完整的 Makefile,它检查源文件的 MD5 校验和,以便当这些文件的内容(从而其校验和)发生变化时重新构建对象:
to-md5 = $1 $(addsuffix .md5,$1)
.PHONY: all
all: foo.o
foo.o: $(call to-md5,foo.c foo.h)
%.md5: FORCE
→ @$(if $(filter-out $(shell cat $@ 2>/dev/null),$(shell md5sum $*)),md5sum $* > $@)
FORCE:
首先注意到,foo.o的先决条件列表已从foo.c foo.h更改为$(call to-md5,foo.c foo.h)。在 Makefile 中定义的to-md5函数会在其参数的所有文件名后添加.md5后缀。
因此,展开后,该行会变成:
foo.o: foo.c foo.h foo.c.md5 foo.h.md5.
这告诉 GNU make如果.md5文件中的任何一个更新,或者foo.c或foo.h中的任何一个更新,都需要重新构建foo.o。
为了确保.md5文件始终包含正确的时间戳,它们会被重新构建。每个.md5文件由%.md5: FORCE规则重新生成。使用空规则FORCE:意味着每次都会检查.md5文件。这里使用FORCE的方式有点类似于使用.PHONY:如果没有名为FORCE的文件,GNU make会构建它(由于没有配方,所以什么都不做),然后 GNU make会认为FORCE比%.md5文件更新,并重新构建它。由于我们不能使用.PHONY: %.md5,因此我们改用这个FORCE技巧。
%.md5: FORCE规则的命令只有在.md5文件不存在或.md5文件中存储的校验和与相应文件的校验和不同的情况下,才会实际重新构建.md5文件,其工作原理如下:
-
$(shell md5sum $*)对与%.md5的%部分匹配的文件进行校验和计算。例如,当这个规则用于生成foo.h.md5文件时,%匹配foo.h,然后foo.h会存储在$*中。 -
$(shell cat $@ 2>/dev/null)获取当前.md5文件的内容(如果文件不存在则为空;请注意2>/dev/null表示忽略错误)。然后,$(filter-out)比较从.md5文件中获取的校验和和通过md5sum生成的校验和。如果它们相同,$(filter-out)将为空字符串。 -
如果校验和发生了变化,规则将实际运行
md5sum $* > $@,这将更新.md5文件的内容和时间戳。存储的校验和将在稍后再次运行 GNUmake时用于检测,.md5文件的时间戳变化将导致相关的目标文件被重新构建。
操作中的黑客技术
为了查看这个黑客如何在其先决条件的校验和发生变化时更新目标文件,我们创建foo.c和foo.h文件并运行 GNU make:
$ **touch foo.c foo.h**
$ **ls**
foo.c foo.h makefile
$ **make**
cc -c -o foo.o foo.c
$ **ls**
foo.c foo.c.md5 foo.h foo.h.md5 foo.o makefile
GNU make生成目标文件foo.o和两个.md5文件,foo.c.md5和foo.h.md5。每个.md5文件包含该文件的校验和:
$ **cat foo.c.md5**
d41d8cd98f00b204e9800998ecf8427e foo.c
首先,我们验证所有内容是否都是最新的,然后验证更改foo.c或foo.h上的时间戳是否会导致重新构建foo.o:
$ **make**
make: Nothing to be done for `all'.
$ **touch foo.c**
$ **make**
cc -c -o foo.o foo.c
$ **make**
make: Nothing to be done for `all'.
$ **touch foo.h**
$ **make**
cc -c -o foo.o foo.c
为了演示更改源文件的内容会导致重新构建foo.o,我们可以通过更改例如foo.h的内容并执行touch foo.o使foo.o比foo.h更新来作弊,这通常意味着foo.o不会被重新构建。
因此,我们知道foo.o比foo.h更新,但自从上次构建foo.o以来,foo.h的内容已经发生了变化:
$ **make**
make: Nothing to be done for `all'.
$ **cat foo.h.md5**
d41d8cd98f00b204e9800998ecf8427e foo.h
$ **cat >> foo.h**
// Add a comment
$ **touch foo.o**
$ **make**
cc -c -o foo.o foo.c
$ **cat foo.h.md5**
65f8deea3518fcb38fd2371287729332 foo.h
你可以看到即使foo.o比所有相关的源文件都更新,它还是被重新构建了,而且foo.h.md5已更新为foo.h的新校验和。
改进代码
我们可以对代码做一些改进:第一个是优化。当文件的校验和发生变化时,更新规则会导致.md5文件实际上对同一个文件运行两次md5sum,结果相同。这是浪费时间。如果你使用的是 GNU make 3.80 或更高版本,可以将md5sum $*的输出存储在一个临时变量CHECKSUM中,并只使用该变量:
%.md5: FORCE
→ @$(eval CHECKSUM := $(shell md5sum $*))$(if $(filter-out \
$(shell cat $@ 2>/dev/null),$(CHECKSUM)),echo $(CHECKSUM) > $@)
第二个改进是让校验和对源文件中的空格变化不敏感。毕竟,如果两个开发人员对于正确缩进的意见不同,导致对象文件重建,而其他内容没有变化,那将是一种遗憾。
md5sum工具没有忽略空格的方法,但很容易将源文件通过tr过滤器去除空格,再交给md5sum计算校验和。(不过请注意,去除所有空格可能不是一个好主意,至少对于大多数语言而言不是。)
自动依赖关系生成
任何大于简单示例的项目都会面临依赖管理问题。随着工程师修改项目,依赖关系必须被生成并保持最新。GNU make并未提供处理这些问题的工具。GNU make提供的仅仅是一个机制,用于表达文件之间的关系,使用其熟悉的target : prerequisite1 prerequisite2 ...语法。
GNU make的依赖语法有缺陷,因为它不仅仅是一个先决条件列表:第一个先决条件具有特殊含义。:右边的任何东西都是先决条件,但第一个带有规则(即命令)的先决条件是特殊的:它是被自动变量$<赋值的先决条件,并且通常也是传递给编译器(或其他命令)以生成目标的先决条件。
$< 变量在另一个方面也很特殊。有时目标会有配方和其他规则来指定先决条件。例如,像下面这样并不罕见:
foo.o: foo.c
4 @compile -o $@ $<
foo.o: myheader.h string.h
$< 的值是通过具有配方的规则来设置的(在本例中将是 foo.c)。
看一下这个:
foo.o: foo.c header.h system.h
→ @echo Compiling $@ from $<...
它的输出是
$ **make**
Compiling foo.o from foo.c...
这里,如果 foo.c、header.h 或 system.h 改变,foo.o 会被重新构建,但规则也表明 foo.o 是由 foo.c 构建的。假设我们的例子是这样写的:
foo.o: foo.c
foo.o: header.h system.h
→ @echo Compiling $@ from $<...
输出将是:
$ **make**
Compiling foo.o from header.h...
这显然是错误的。
一个示例 Makefile
最大的问题是为大型项目生成所有表示所有依赖关系的规则。接下来的部分将使用以下构造的示例 makefile 作为起点:
.PHONY: all
all: foo.o bar.o baz.o
foo.o: foo.c foo.h common.h header.h
bar.o: bar.c bar.h common.h header.h ba.h
baz.o: baz.c baz.h common.h header.h ba.h
三个目标文件(foo.o、bar.o 和 baz.o)是从相应的 .c 文件(foo.c、bar.c 和 baz.c)构建的。每个 .o 文件依赖于不同的头文件,如 makefile 中最后三行所示。makefile 使用 GNU make 的内置规则,通过系统的编译器执行编译。
这里没有提到最终可执行文件的构建。原因是这个例子专注于处理源文件和目标文件之间的依赖关系;对象文件之间的关系通常更容易手动维护,因为它们较少且这些关系是产品设计的一部分。
makedepend 和 make depend
因为手动维护任何真实的 makefile 是不可能的,许多项目使用广泛可用的 makedepend 程序。makedepend 会读取 C 和 C++ 文件,查看 #include 语句,打开被包含的文件,并自动构建依赖关系。将 makedepend 集成到项目中的一种基本方式是使用特殊的 depend 目标,如 示例 3-3 所示。
示例 3-3. 在你的 makefile 中使用 makedepend
.PHONY: all
all: foo.o bar.o baz.o
SRCS = foo.c bar.c baz.c
DEPENDS = dependencies.d
.PHONY: depend
depend:
→ @makedepend -f - $(SRCS) > $(DEPENDS)
-include $(DEPENDS)
执行 make depend 时,makefile 会执行 depend 规则,这会对源文件(在 SRCS 变量中定义)运行 makedepend 并将依赖关系输出到名为 dependencies.d 的文件中(由 DEPENDS 变量定义)。
makefile 在最后一行通过包含 dependencies.d 文件来添加依赖关系。dependencies.d 的内容如下:
# DO NOT DELETE
foo.o: foo.h header.h common.h
bar.o: bar.h header.h common.h ba.h
baz.o: baz.h header.h common.h ba.h
请注意,makedepend 并不会尝试定义目标文件(如 foo.o)与它所生成的源文件(foo.c)之间的关系。在这种情况下,GNU make 的标准规则会自动找到相关的 .c 文件。
自动化 makedepend 和移除 make depend
make depend 风格存在两个问题。运行 make depend 可能很慢,因为即使没有变化,仍然必须搜索每个源文件。此外,它是一个手动步骤:在每次执行 make 之前,用户必须先执行 make depend 来确保依赖关系是正确的。解决这些问题的办法是自动化。
示例 3-4 显示了来自 示例 3-3 的 makefile 另一个版本:
示例 3-4. 当需要时自动运行 makedepend
.PHONY: all
all: foo.o bar.o baz.o
SRCS = foo.c bar.c baz.c
%.d : %.c
→ @makedepend -f - $< | sed 's,\($*\.o\)[ :]*,\1 $@ : ,g' > $@
-include $(SRCS:.c=.d)
这个版本仍然使用 makedepend 来生成依赖关系,但自动化了这个过程,并且只对已更改的源文件运行 makedepend。它通过将每个 .c 文件与一个 .d 文件关联来工作。例如,foo.o(由 foo.c 构建)有一个 foo.d 文件,其中仅包含 foo.o 的依赖关系行。
下面是 foo.d 的内容:
# DO NOT DELETE
foo.o foo.d : foo.h header.h common.h
请注意一项新增内容:这一行指定了何时重新构建 foo.o,但也指出在相同条件下应该重新构建 foo.d。如果任何与 foo.o 相关的源文件发生变化,foo.d 会被重新构建。foo.c 没有出现在这个列表中,因为它是作为重新构建 .d 文件的模式规则的一部分提到的(主 makefile 中的 %.d : %.c 规则意味着如果 foo.c 发生变化,foo.d 会被重新构建)。foo.d 是通过 makedepend 使用 示例 3-4 中显示的 sed 魔法添加到由 makedepend 创建的依赖关系行中的。
主 makefile 的最后一行包括所有 .d 文件:$(SRCS:.c=.d) 将 SRCS 变量中源文件的列表转换,将扩展名从 .c 更改为 .d。include 也告诉 GNU make 检查 .d 文件是否需要重新构建。
GNU make 将检查是否有规则来重新构建包含的 makefile(在这种情况下是 .d 文件),如有必要重新构建它们(按照 makefile 中指定的依赖关系),然后重新启动。这个 makefile 重新构建功能(www.gnu.org/software/make/manual/html_node/Remaking-Makefiles.html) 意味着只需键入 make 就会做正确的事:它会重新构建需要重新构建的任何依赖文件,但仅在源文件发生变化时。然后,GNU make 将根据新的依赖关系进行构建。
让已删除的文件从依赖关系中消失
不幸的是,如果源文件被删除,我们的 makefile 会因致命错误而中断。如果 header.h 不再需要,所有对它的引用都会从 .c 文件中移除,文件会从磁盘上删除,运行 make 会产生以下错误:
$ **make**
No rule to make target `header.h', needed by `foo.d'.
这是因为 header.h 仍然在 foo.d 中作为 foo.d 的前提条件被提到;因此,foo.d 无法重新构建。你可以通过让 foo.d 的生成更加智能来修复这个问题:
# DO NOT DELETE
foo.d : $(wildcard foo.h header.h common.h)
foo.o : foo.h header.h common.h
新的 foo.d 分别包含了 foo.o 和 foo.d 的依赖关系。foo.d 的依赖关系被包裹在调用 GNU make 的 $(wildcard) 函数中。
这是更新后的 makefile,它通过新一轮的 makedepend 调用和一个 sed 行来创建修改后的 .d 文件:
.PHONY: all
all: foo.o bar.o baz.o
SRCS = foo.c bar.c baz.c
%.d : %.c
→ @makedepend -f - $< | sed 's,\($*\.o\)[ :]*\(.*\),$@ : $$\(wildcard \2\)\n\1 : \2,g' > $@
-include $(SRCS:.c=.d)
现在移除头文件不会破坏make:当解析foo.d时,foo.d的依赖行会通过$(wildcard)处理。当文件名中没有通配符如*或?时,$(wildcard)充当一个简单的存在性过滤器,将所有不存在的文件从列表中移除。所以如果header.h被移除,foo.d的第一行将相当于以下内容:
foo.d : foo.h common.h
make会正常工作。这个示例 makefile 现在在添加.c文件时可以正常工作(用户只需更新SRCS,新的.d文件会自动创建),在删除.c文件时(用户更新SRCS,旧的.d文件会被忽略),在添加头文件时(因为这需要修改现有的.c或.h文件,.d文件会重新生成),以及在删除头文件时($(wildcard)隐藏了删除操作,.d文件会重新生成)。
一个可能的优化是通过将生成.d文件的规则与生成.o文件的规则合并,来避免 GNU make重启的需要:
.PHONY: all
all: foo.o bar.o baz.o
SRCS = foo.c bar.c baz.c
%.o : %.c
→ @makedepend -f - $< | sed 's,\($*\.o\)[ :]*\(.*\),$@ : $$\(wildcard \2\)\n\1 : \2,g' > $*.d
→ @$(COMPILE.c) -o $@ $<
-include $(SRCS:.c=.d)
因为只有在.o文件需要更新时,.d文件才会更新(当任何.o文件的源文件发生变化时,两者都会更新),所以可以在编译的同时进行makedepend。
这个规则使用了$*,这是另一个 GNU make变量。$*是模式%.c中与%匹配的部分。如果这个规则正在从foo.c构建foo.o,那么$*就是foo。$*生成makedepend写入的.d文件的名称。
这个版本不使用 GNU make的 makefile 重建系统。没有创建.d文件的规则(它们是作为创建.o文件的副作用生成的),因此 GNU make不需要重启。这提供了准确性和速度的最佳结合。
通常,创建多个文件的规则是一个不好的主意,因为 GNU make无法找到由其他操作副作用创建的文件的规则。在这种情况下,这种行为是我们想要的:我们希望将.d文件的创建隐藏起来,以免 GNU make尝试生成它们并导致重启。
Tom Tromey 提出了一个类似的想法,没有使用$(wildcard)技巧。你可以在 GNU make的维护者 Paul Smith 的网站上找到更多关于构建依赖文件的信息,网址是make.mad-scientist.net/papers/advanced-auto-dependency-generation/。
摆脱 makedepend
此外,如果使用的是 GNU gcc、llvm、clang或类似的编译器,可以完全省略makedepend。
-MD选项在编译的同时完成makedepend的工作:
.PHONY: all
all: foo.o bar.o baz.o
SRCS = foo.c bar.c baz.c
%.o : %.c
→ @$(COMPILE.c) -MD -o $@ $<
→ @sed -i 's,\($*\.o\)[ :]*\(.*\),$@ : $$\(wildcard \2\)\n\1 : \2,g' $*.d
-include $(SRCS:.c=.d)
例如,foo.o的编译步骤会从foo.c生成foo.d。然后,会对foo.d运行sed,为foo.d添加包含$(wildcard)的额外行。
使用 gcc -MP
gcc还有一个-MP选项,它试图通过创建空规则来“构建”缺失的文件,从而解决消失文件的问题。例如,可以完全消除sed魔法,使用-MP选项代替-MD:
.PHONY: all
all: foo.o bar.o baz.o
SRCS = foo.c bar.c baz.c
%.o : %.c
→ @$(COMPILE.c) -MP -o $@ $<
-include $(SRCS:.c=.d)
foo.d文件将如下所示:
foo.o : foo.h header.h common.h
foo.h :
header.h :
common.h :
举例来说,如果foo.h被删除,make不会报错,因为它会找到空规则(foo.h :)来构建它,从而避免了缺失文件的错误。然而,每次构建foo.o时,更新foo.d文件是至关重要的。如果没有更新,foo.d中仍会包含foo.h作为前提条件,而每次运行make时,foo.o都会重新构建,因为make会尝试用空规则来构建foo.h(从而强制构建foo.o)。
GNU make 中的原子规则
GNU make物理学的一个基本法则是每个规则只构建一个文件(称为目标)。这个规则是有例外的(我们将在本节后面看到),但无论如何,对于任何正常的 GNU make规则,像
a: b c
→ @command
左侧:的地方只有一个文件名被提及。这个文件名会被放入$@自动变量中。预计command会实际更新该文件。
本节解释了如果一个命令更新多个文件该怎么办,并且如何表达这一点,以便 GNU make知道有多个文件被更新并且正确地执行。
不该做的事
假设有一个命令可以在一个步骤中通过相同的前提条件构建两个文件(a和b)。在这一节中,这个命令通过touch a b来模拟,但实际上它可能比这复杂得多。
示例 3-5 展示了不该做的事情:
示例 3-5. 不该做的事
.PHONY: all
all: a b
a b: c d
→ touch a b
乍一看,示例 3-5 看起来是正确的;它似乎说明了a和b是通过一个命令从c和d构建的。如果你在make中运行它,你可能会得到类似这样的输出(尤其是在你使用-j选项来进行并行构建时):
$ **make**
touch a b
touch a b
命令被运行了两次。在这种情况下这是无害的,但对于一个真正执行工作的命令,运行两次几乎肯定是不对的。此外,如果你使用-j选项进行并行构建,命令可能会同时多次运行。
原因在于 GNU make实际上是这样解释 makefile 的:
.PHONY: all
all: a b
a: c d
→ touch a b
b: c d
→ touch a b
有两个独立的规则(一个声明它构建a,另一个声明它构建b),这两个规则都构建a和b。
使用模式规则
GNU make确实有一种方式可以在单个规则中构建多个目标,使用模式规则。模式规则可以拥有任意数量的目标模式,仍然被视为一个规则。
举例来说:
%.foo %.bar %.baz:
→ command
这意味着具有.foo、.bar和.baz扩展名的文件(当然还有与%匹配的相同前缀)将在一次command调用中构建。
假设 makefile 像这样:
.PHONY: all
all: a.foo a.bar a.baz
%.foo %.bar %.baz:
→ command
然后,command只会被调用一次。事实上,仅指定一个目标并运行模式规则就足够了:
.PHONY: all
all: a.foo
%.foo %.bar %.baz:
→ command
这非常有用。例如:
$(OUT)/%.lib $(OUT)/%.dll: $(VERSION_RESOURCE)
→ link /nologo /dll /fixed:no /incremental:no \
/map:'$(call to_dos,$(basename $@).map)' \
/out:'$(call to_dos,$(basename $@).dll)' \
/implib:'$(call to_dos,$(basename $@).lib)' \
$(LOADLIBES) $(LDLIBS) \
/pdb:'$(basename $@).pdb' \
/machine:x86 \
$^
这是一个实际的规则,来自一个真实的 makefile,它一次性构建了.lib及其相关的.dll。
当然,如果文件的名称中没有共同部分,使用模式规则将不起作用。它在本节开始时的简单示例中无法使用,但有一种替代方法。
使用哨兵文件
一个可能的替代方法是引入一个单独的文件,用来指示多目标规则中的任何目标是否已经构建。创建一个单一的“指示”文件将多个文件转化为一个文件,而 GNU make可以理解单一文件。以下是示例 3-5 的重写版本:
.PHONY: all
all: a b
a b: .sentinel
→ @:
.sentinel: c d
→ touch a b
→ touch .sentinel
构建a和b的规则只能运行一次,因为只指定了一个前提条件(.sentinel)。如果c或d较新,.sentinel会被重新构建(从而a和b也会被重新构建)。如果 makefile 请求a或b中的任何一个,它们会通过.sentinel文件重新构建。
a b规则中的有趣@:命令只是意味着有构建a和b的命令,但它们什么也不做。
使这一过程透明化会很不错。这就是atomic函数的作用。atomic函数会根据要构建的目标名称自动设置哨兵文件,并创建必要的规则:
sp :=
sp +=
sentinel = .sentinel.$(subst $(sp),_,$(subst /,_,$1))
atomic = $(eval $1: $(call sentinel,$1) ; @:)$(call sentinel,$1): $2 ; touch $$@
.PHONY: all
all: a b
$(call atomic,a b,c d)
→ touch a b
我们所做的只是将原来的a b : c d规则替换为对atomic的调用。第一个参数是需要原子化构建的目标列表;第二个参数是前提条件列表。
atomic使用sentinel函数创建一个唯一的哨兵文件名(对于目标a b,哨兵文件名为.sentinel.a_b),然后设置必要的规则。
在这个 makefile 中展开atomic就相当于这样做:
.PHONY: all
all: a b
a b: .sentinel.a_b ; @:
.sentinel.a_b: c d ; touch $@
→ touch a b
这种技术有一个缺陷。如果删除了a或b,你还必须删除相关的哨兵文件,否则文件不会重新构建。
为了解决这个问题,你可以让 makefile 在必要时删除哨兵文件,通过检查是否有任何正在构建的目标丢失。以下是更新后的代码:
sp :=
sp +=
sentinel = .sentinel.$(subst $(sp),_,$(subst /,_,$1))
atomic = $(eval $1: $(call sentinel,$1) ; @:)$(call sentinel,$1): \
$2 ; touch $$@ $(foreach t,$1,$(if $(wildcard $t),,$(shell rm -f \
$(call sentinel,$1))))
.PHONY: all
all: a b
$(call atomic,a b,c d)
→ touch a b
现在atomic遍历这些目标。如果有任何目标丢失(通过$(wildcard)检测),则会删除哨兵文件。
无痛非递归 make
一旦 makefile 项目达到一定规模(通常是当它依赖于子项目时),构建管理者就不可避免地会写出包含对$(MAKE)调用的规则。而就在这时,构建管理者创建了递归的make:一个执行整个make过程的make。这样做非常诱人,因为从概念上讲,递归make很简单:如果你需要构建一个子项目,只需进入其目录并通过$(MAKE)运行make。
但是它有一个主要的缺陷:一旦启动了一个单独的 make 进程,所有关于依赖的信息都会丢失。父 make 并不知道子项目的 make 是否真的需要执行,所以它每次都必须运行,这可能会很慢。解决这个问题并不容易,但一旦实现,非递归 make 是非常强大的。
对使用非递归 make 的一个常见反对意见是,使用递归 make 时,可以在源代码树的任何地方输入 make。这样做通常会构建在该层次的 makefile 中定义的对象(如果 makefile 递归,还可能构建下面的对象)。
非递归的 make 系统(基于 include 语句而非 make 调用)通常无法提供这种灵活性,而 GNU make 必须在顶层目录中运行。尽管非递归的 GNU make 通常更高效(从顶层目录运行应该很快),但能够为开发者提供与递归 make 系统相同的功能是很重要的。
本节概述了一种非递归的 GNU make 系统模式,它支持递归的 GNU make 系统中常见的 make 随处可用的风格。在一个目录中输入 make 会构建该目录及以下的所有内容,但没有递归的 $(MAKE) 调用。运行的唯一一个 make 知道跨项目和子项目的所有依赖关系,并且能够高效地构建。
一个简单的递归 make
想象一个包含以下子目录的项目:
/src/
/src/library/
/src/executable/
/src/ 是顶层目录,在这里你可以输入 make 来进行完整的构建。在 /src/ 目录下有一个 library/ 目录,它从源文件 lib1.c 和 lib2.c 构建一个名为 lib.a 的库:
/src/library/lib1.c
/src/library/lib2.c
/src/executable/ 目录从两个源文件(foo.c 和 bar.c)构建一个名为 exec 的可执行文件,并与库 lib.a 链接:
/src/executable/foo.c
/src/executable/bar.c
经典的递归 make 解决方案是在每个子目录中放置一个 makefile。每个 makefile 包含构建该目录对象的规则,而顶层的 makefile 会递归进入每个子目录。以下是一个递归 makefile (/src/makefile) 的内容:
SUBDIRS = library executable
.PHONY: all
all:
→ for dir in $(SUBDIRS); do \
→ $(MAKE) -C $$dir; \
→ done
这会依次进入每个目录,并运行 make 来先构建库,再构建可执行文件。可执行文件和库之间的依赖关系(即库需要先于可执行文件构建)在 SUBDIRS 中指定的目录顺序中是隐式的。
下面是使用 for 循环和每个目录的虚假目标来改进的一个例子:
SUBDIRS = library executable
.PHONY: $(SUBDIRS)
$(SUBDIRS):
→ $(MAKE) -C $@
.PHONY: all
all: $(SUBDIRS)
executable: library
你需要解开 all 规则中的循环,为每个子目录创建独立的规则,并明确指定 executable 和 library 之间的依赖关系。这段代码更清晰,但它仍然是递归的,每个子目录都有单独的 make 调用。
一个灵活的非递归 make 系统
当转向非递归 make 时,理想的顶级 makefile 应该像 示例 3-6 这样。
示例 3-6:一个小型非递归 makefile
SUBDIRS = library executable
include $(addsuffix /makefile,$(SUBDIRS))
这仅仅是说要包含每个子目录中的 makefile。诀窍是如何使其工作!在你看到如何做之前,这里是 library 和 executable 子目录中 makefile 内容的框架:
# /src/library/Makefile
include root.mak
include top.mak
SRCS := lib1.c lib2.c
BINARY := lib
BINARY_EXT := $(_LIBEXT)
include bottom.mak
和
# /src/executable/Makefile
include root.mak
include top.mak
SRCS := foo.c foo.c
BINARY := exec
BINARY_EXT := $(_EXEEXT)
include bottom.mak
每个 makefile 都指定了要构建的源文件(在 SRCS 变量中)、最终链接的二进制文件的名称(在 BINARY 变量中)和二进制文件的类型(使用 BINARY_EXT 变量,该变量由特殊变量 _LIBEXT 和 _EXEEXT 设置)。
这两个 makefile 都 include 了位于 /src/ 目录中的公共 makefile root.mak、top.mak 和 bottom.mak。
因为包含的 .mak makefile 不在子目录中,所以 GNU make 需要去寻找它们。要在 /src 中找到 .mak 文件,可以这样做:
$ **make -I /src**
在这里,你使用 -I 命令行选项将目录添加到 include 搜索路径中。
要求用户在 make 命令行中添加任何内容是令人遗憾的。为了避免这种情况,你可以创建一个简单的方法,自动向上遍历源代码树以找到 .mak 文件。以下是 /src/library 的实际 makefile:
sp :=
sp +=
_walk = $(if $1,$(wildcard /$(subst $(sp),/,$1)/$2) $(call _walk,$(wordlist 2,$(words $1),x $1),$2))
_find = $(firstword $(call _walk,$(strip $(subst /, ,$1)),$2))
_ROOT := $(patsubst %/root.mak,%,$(call _find,$(CURDIR),root.mak))
include $(_ROOT)/root.mak
include $(_ROOT)/top.mak
SRCS := lib1.c lib2.c
BINARY := lib
BINARY_EXT := $(_LIBEXT)
include $(_ROOT)/bottom.mak
_find 函数从 $1 中指定的目录开始向上遍历目录树,查找名为 $2 的文件。实际的查找是通过调用 _walk 函数实现的,该函数沿着树向上遍历,找到 $1 中每个逐渐缩短的路径中 $2 文件的每个实例。
makefile 开头的代码块找到 root.mak 的位置,它与 top.mak 和 bottom.mak 在同一目录下(即 /src),并将该目录保存在 _ROOT 中。
然后,makefile 可以使用 $(_ROOT)/ 来 include root.mak、top.mak 和 bottom.mak makefile,而无需输入除 make 之外的任何内容。
以下是第一个包含的 makefile (root.mak) 的内容:
_push = $(eval _save$1 := $(MAKEFILE_LIST))
_pop = $(eval MAKEFILE_LIST := $(_save$1))
_INCLUDE = $(call _push,$1)$(eval include $(_ROOT)/$1/Makefile)$(call _pop,$1)
DEPENDS_ON = $(call _INCLUDE,$1)
DEPENDS_ON_NO_BUILD = $(eval _NO_RULES := T)$(call _INCLUDE,$1)$(eval _NO_RULES :=)
目前,忽略其内容,回到这些函数在查看模块之间的依赖关系时的作用。实际工作从 top.mak 开始:
_OUTTOP ?= /tmp/out
.PHONY: all
all:
_MAKEFILES := $(filter %/Makefile,$(MAKEFILE_LIST))
_INCLUDED_FROM := $(patsubst $(_ROOT)/%,%,$(if $(_MAKEFILES), \
$(patsubst %/Makefile,%,$(word $(words $(_MAKEFILES)),$(_MAKEFILES)))))
ifeq ($(_INCLUDED_FROM),)
_MODULE := $(patsubst $(_ROOT)/%,%,$(CURDIR))
else
_MODULE := $(_INCLUDED_FROM)
endif
_MODULE_PATH := $(_ROOT)/$(_MODULE)
_MODULE_NAME := $(subst /,_,$(_MODULE))
$(_MODULE_NAME)_OUTPUT := $(_OUTTOP)/$(_MODULE)
_OBJEXT := .o
_LIBEXT := .a
_EXEEXT :=
_OUTTOP 变量定义了所有二进制输出(目标文件等)将被放置的顶级目录。这里它的默认值是 /tmp/out,并且它是用 ?= 定义的,因此可以在命令行中覆盖。
接下来,top.mak 设置 GNU make 的默认目标为经典的 all。这里它没有依赖项,但之后会为每个将要构建的模块添加依赖项。
之后,多个变量会将_MODULE_PATH设置为正在构建的模块目录的完整路径。例如,在构建library模块时,_MODULE_PATH将是/src/library。设置这个变量是复杂的,因为确定模块目录必须独立于执行 GNU make的目录(这样库文件既可以从顶层目录构建,适用于make all,也可以从单独的library目录构建,适用于单个开发者构建,甚至可以将库文件作为依赖项包含在另一个模块中)。
_MODULE_NAME只是相对于树根路径的路径,其中/被替换为_。在示例 3-5 中,这两个模块有_MODULE_NAME:library和executable。但是如果library有一个包含名为sublibrary的模块的子目录,那么它的_MODULE_NAME将是library_sublibrary。
_MODULE_NAME还用于创建$(_MODULE_NAME)_OUTPUT特殊变量,它的名称是基于_MODULE_NAME计算得出的。所以对于library模块,创建了变量library_OUTPUT,它包含将library的目标文件写入的目录的完整路径。输出路径是基于_OUTTOP和相对于正在构建模块的路径。因此,/tmp/out目录结构会镜像源代码目录结构。
最后,设置了一些用于文件名扩展名的标准定义。这里使用的是 Linux 系统的定义,但这些定义可以很容易地更改为不使用.o作为目标文件或.a作为库文件的系统(例如 Windows)。
bottom.mak使用这些变量来设置实际构建模块的规则:
$(_MODULE_NAME)_OBJS := $(addsuffix $(_OBJEXT),$(addprefix \
$($(_MODULE_NAME)_OUTPUT)/,$(basename $(SRCS)))) $(DEPS)
$(_MODULE_NAME)_BINARY := $($(_MODULE_NAME)_OUTPUT)/$(BINARY)$(BINARY_EXT)
ifneq ($(_NO_RULES),T)
ifneq ($($(_MODULE_NAME)_DEFINED),T)
all: $($(_MODULE_NAME)_BINARY)
.PHONY: $(_MODULE_NAME)
$(_MODULE_NAME): $($(_MODULE_NAME)_BINARY)
_IGNORE := $(shell mkdir -p $($(_MODULE_NAME)_OUTPUT))
_CLEAN := clean-$(_MODULE_NAME)
.PHONY: clean $(_CLEAN)
clean: $(_CLEAN)
$(_CLEAN):
→ rm -rf $($(patsubst clean-%,%,$@)_OUTPUT)
$($(_MODULE_NAME)_OUTPUT)/%.o: $(_MODULE_PATH)/%.c
→ @$(COMPILE.c) -o '$@' '$<'
$($(_MODULE_NAME)_OUTPUT)/$(BINARY).a: $($(_MODULE_NAME)_OBJS)
→ @$(AR) r '$@' $^
→ @ranlib '$@'
$($(_MODULE_NAME)_OUTPUT)/$(BINARY)$(_EXEEXT): $($(_MODULE_NAME)_OBJS)
→ @$(LINK.cpp) $^ -o'$@'
$(_MODULE_NAME)_DEFINED := T
endif
endif
bottom.mak首先设置两个带有计算名称的变量:$(_MODULE_NAME)_OBJS(它是从SRCS变量通过转换扩展名计算得出的模块目标文件列表)和$(_MODULE_NAME)_BINARY(它是模块创建的二进制文件的名称;通常是正在构建的库文件或可执行文件)。
我们包含了DEPS变量,因此$(_MODULE_NAME)_OBJS变量也包括该模块需要但不构建的任何目标文件。稍后你将看到如何使用这个变量在库和可执行文件之间定义依赖关系。
接下来,如果该模块的规则尚未设置(由$(_MODULE_NAME)_DEFINED变量控制),并且未被_NO_RULES变量明确禁用,则定义构建该模块的实际规则。
在这个示例中,展示了 Linux 的规则。这是你为其他操作系统更改此示例的地方。
all包含当前的二进制文件,来自$(_MODULE_NAME)_BINARY,它作为前提条件添加到模块中,这样在执行完整构建时该模块会被构建。接着有一个规则将模块名与模块二进制文件关联,这样在顶层执行make library时,只会构建库文件。
然后是一个通用的clean规则和一个模块特定的清理规则(对于library模块,有一个叫做clean-library的规则,仅清理它的对象文件)。clean通过简单的rm -rf实现,因为所有的输出文件都被组织在_OUTTOP的特定子目录中。
接下来,使用$(shell)来设置模块输出文件的目录。最后,特定的规则将该模块输出目录中的目标文件与该模块源代码目录中的源文件关联起来。
在建立了所有这些基础设施之后,我们终于可以查看executable目录中的 makefile 了:
sp :=
sp +=
_walk = $(if $1,$(wildcard /$(subst $(sp),/,$1)/$2) $(call _walk,$(wordlist 2,$(words $1),x $1),$2))
_find = $(firstword $(call _walk,$(strip $(subst /, ,$1)),$2))
_ROOT := $(patsubst %/root.mak,%,$(call _find,$(CURDIR),root.mak))
include $(_ROOT)/root.mak
$(call DEPENDS_ON,library)
include $(_ROOT)/top.mak
SRCS := foo.c bar.c
BINARY := exec
BINARY_EXT := $(_EXEEXT)
DEPS := $(library_BINARY)
include $(_ROOT)/bottom.mak
这看起来很像库的 makefile,但也有一些不同之处。因为可执行文件需要库,所以DEPS行指定了可执行文件依赖于库所创建的二进制文件。由于每个模块有独特的对象和二进制文件变量,所以可以通过引用$(library_BINARY)来轻松定义这种依赖关系,它会展开为由库模块创建的库文件的完整路径。
为了确保$(library_BINARY)被定义,需要包含来自library目录的 makefile。root.mak文件提供了两个使这一过程变得简单的函数:DEPENDS_ON和DEPENDS_ON_NO_BUILD。
DEPENDS_ON_NO_BUILD仅设置指定模块的变量,以便在 makefile 中使用。如果在executable的 makefile 中使用该函数,库文件(lib.a)必须存在,才能使可执行文件成功构建。另一方面,DEPENDS_ON在这里用于确保在必要时构建library。
DEPENDS_ON_NO_BUILD提供了类似经典递归构建的功能,虽然它不知道如何构建该库,但却依赖于它。DEPENDS_ON更加灵活,因为在没有递归的情况下,你可以指定关系,并确保代码能够被构建。
使用非递归 make 系统
非递归的make系统提供了很大的灵活性。以下是一些例子,展示了非递归的make系统与递归的make系统一样灵活(甚至更灵活!)。
从顶层构建所有内容很简单,只需运行make(在这些示例中,我们使用命令make -n,这样命令就会清晰地显示出来):
$ **cd /src**
$ **make -n**
cc -c -o '/tmp/out/library/lib1.o' '/home/jgc/doc/nonrecursive/library/lib1.c'
cc -c -o '/tmp/out/library/lib2.o' '/home/jgc/doc/nonrecursive/library/lib2.c'
ar r '/tmp/out/library/lib.a' /tmp/out/library/lib1.o /tmp/out/library/lib2.o
ranlib '/tmp/out/library/lib.a'
cc -c -o '/tmp/out/executable/foo.o' '/home/jgc/doc/nonrecursive/executable/foo.c'
cc -c -o '/tmp/out/executable/bar.o' '/home/jgc/doc/nonrecursive/executable/bar.c'
g++ /tmp/out/executable/foo.o /tmp/out/executable/bar.o /tmp/out/library/lib.a -o'/tmp/out/
executable/exec'
清理所有内容也很简单:
$ **cd /src**
$ **make -n clean**
rm -rf /tmp/out/library
rm -rf /tmp/out/executable
从顶层目录开始,可以请求构建或清理任何单独的模块:
$ **cd /src**
$ **make -n clean-library**
rm -rf /tmp/out/library
$ **make -n library**
cc -c -o '/tmp/out/library/lib1.o' '/home/jgc/doc/nonrecursive/library/lib1.c'
cc -c -o '/tmp/out/library/lib2.o' '/home/jgc/doc/nonrecursive/library/lib2.c'
ar r '/tmp/out/library/lib.a' /tmp/out/library/lib1.o /tmp/out/library/lib2.o
ranlib '/tmp/out/library/lib.a'
如果我们要求构建executable模块,由于依赖关系,library模块也会同时被构建:
$ **cd /src**
$ **make -n executable**
cc -c -o '/tmp/out/executable/foo.o' '/home/jgc/doc/nonrecursive/executable/foo.c'
cc -c -o '/tmp/out/executable/bar.o' '/home/jgc/doc/nonrecursive/executable/bar.c'
cc -c -o '/tmp/out/library/lib1.o' '/home/jgc/doc/nonrecursive/library/lib1.c'
cc -c -o '/tmp/out/library/lib2.o' '/home/jgc/doc/nonrecursive/library/lib2.c'
ar r '/tmp/out/library/lib.a' /tmp/out/library/lib1.o /tmp/out/library/lib2.o
ranlib '/tmp/out/library/lib.a'
g++ /tmp/out/executable/foo.o /tmp/out/executable/bar.o /tmp/out/library/lib.a -o'/tmp/out/
executable/exec'
好的,关于顶层就讲到这里。如果我们进入library模块,就可以像构建或清理其他模块一样轻松地操作:
$ **cd /src/library**
$ **make -n clean**
rm -rf /tmp/out/library
$ **make -n**
cc -c -o '/tmp/out/library/lib1.o' '/home/jgc/doc/nonrecursive/library/lib1.c'
cc -c -o '/tmp/out/library/lib2.o' '/home/jgc/doc/nonrecursive/library/lib2.c'
ar r '/tmp/out/library/lib.a' /tmp/out/library/lib1.o /tmp/out/library/lib2.o
ranlib '/tmp/out/library/lib.a'
当然,在executable目录中这样做也会构建library:
$ **cd /src/executable**
$ **make -n**
cc -c -o '/tmp/out/library/lib1.o' '/home/jgc/doc/nonrecursive/library/lib1.c'
cc -c -o '/tmp/out/library/lib2.o' '/home/jgc/doc/nonrecursive/library/lib2.c'
ar r '/tmp/out/library/lib.a' /tmp/out/library/lib1.o /tmp/out/library/lib2.o
ranlib '/tmp/out/library/lib.a'
cc -c -o '/tmp/out/executable/foo.o' '/home/jgc/doc/nonrecursive/executable/foo.c'
cc -c -o '/tmp/out/executable/bar.o' '/home/jgc/doc/nonrecursive/executable/bar.c'
g++ /tmp/out/executable/foo.o /tmp/out/executable/bar.o /tmp/out/library/lib.a -o'/tmp/out/
executable/exec'
那么,子模块如何处理呢?
假设源代码树实际上是
/src/
/src/library/
/src/library/sublibrary
/src/executable/
其中在library下还有一个额外的sublibrary,该sublibrary使用以下 makefile 从slib1.c和slib2.c构建slib.a:
sp :=
sp +=
_walk = $(if $1,$(wildcard /$(subst $(sp),/,$1)/$2) $(call _walk,$(wordlist 2,$(words $1),x $1),$2))
_find = $(firstword $(call _walk,$(strip $(subst /, ,$1)),$2))
_ROOT := $(patsubst %/root.mak,%,$(call _find,$(CURDIR),root.mak))
include $(_ROOT)/root.mak
include $(_ROOT)/top.mak
SRCS := slib1.c slib2.c
BINARY := slib
BINARY_EXT := $(_LIBEXT)
include $(_ROOT)/bottom.mak
指定library依赖sublibrary非常简单,只需在library目录中的 makefile 里添加一个DEPENDS_ON调用:
sp :=
sp +=
_walk = $(if $1,$(wildcard /$(subst $(sp),/,$1)/$2) $(call _walk,$(wordlist 2,$(words $1),x $1),$2))
_find = $(firstword $(call _walk,$(strip $(subst /, ,$1)),$2))
_ROOT := $(patsubst %/root.mak,%,$(call _find,$(CURDIR),root.mak))
include $(_ROOT)/root.mak
$(call DEPENDS_ON,library/sublibrary)
include $(_ROOT)/top.mak
SRCS := lib1.c lib2.c
BINARY := lib
BINARY_EXT := $(_LIBEXT)
include $(_ROOT)/bottom.mak
在这个示例中,没有DEPS行,因此library在对象级别上并不依赖于sublibrary。我们只是声明sublibrary是library的一个子模块,当library构建时,sublibrary也需要被构建。
回顾并重复前面的示例,我们可以看到sublibrary已经成功地包含在library的构建中(并且自动包含在executable的构建中)。
这是从头开始的完整构建,接下来是一个clean操作:
$ **cd /src**
$ **make -n**
cc -c -o '/tmp/out/library/sublibrary/slib1.o' '/home/jgc/doc/nonrecursive/library/sublibrary/
slib1.c'
cc -c -o '/tmp/out/library/sublibrary/slib2.o' '/home/jgc/doc/nonrecursive/library/sublibrary/
slib2.c'
ar r '/tmp/out/library/sublibrary/slib.a' /tmp/out/library/sublibrary/slib1.o /tmp/out/library/
sublibrary/slib2.o
ranlib '/tmp/out/library/sublibrary/slib.a'
cc -c -o '/tmp/out/library/lib1.o' '/home/jgc/doc/nonrecursive/library/lib1.c'
cc -c -o '/tmp/out/library/lib2.o' '/home/jgc/doc/nonrecursive/library/lib2.c'
ar r '/tmp/out/library/lib.a' /tmp/out/library/lib1.o /tmp/out/library/lib2.o
ranlib '/tmp/out/library/lib.a'
cc -c -o '/tmp/out/executable/foo.o' '/home/jgc/doc/nonrecursive/executable/foo.c'
cc -c -o '/tmp/out/executable/bar.o' '/home/jgc/doc/nonrecursive/executable/bar.c'
g++ /tmp/out/executable/foo.o /tmp/out/executable/bar.o /tmp/out/library/lib.a -o'/tmp/out/
executable/exec'
$ **make -n clean**
rm -rf /tmp/out/library/sublibrary
rm -rf /tmp/out/library
rm -rf /tmp/out/executable
在这里,我们要求构建sublibrary:
$ **cd /src**
$ **make -n clean-library_sublibrary**
rm -rf /tmp/out/library/sublibrary
$ **make -n library_sublibrary**
cc -c -o '/tmp/out/library/sublibrary/slib1.o' '/home/jgc/doc/nonrecursive/library/sublibrary/
slib1.c'
cc -c -o '/tmp/out/library/sublibrary/slib2.o' '/home/jgc/doc/nonrecursive/library/sublibrary/
slib2.c'
ar r '/tmp/out/library/sublibrary/slib.a' /tmp/out/library/sublibrary/slib1.o /tmp/out/library/
sublibrary/slib2.o
ranlib '/tmp/out/library/sublibrary/slib.a'
如果我们要求构建executable模块,那么library会同时被构建(并且sublibrary也会被构建),因为有这个依赖关系:
$ **cd /src/executable**
$ **make -n executable**
cc -c -o '/tmp/out/library/sublibrary/slib1.o' '/home/jgc/doc/nonrecursive/library/sublibrary/
slib1.c'
cc -c -o '/tmp/out/library/sublibrary/slib2.o' '/home/jgc/doc/nonrecursive/library/sublibrary/
slib2.c'
ar r '/tmp/out/library/sublibrary/slib.a' /tmp/out/library/sublibrary/slib1.o /tmp/out/library/
sublibrary/slib2.o
ranlib '/tmp/out/library/sublibrary/slib.a'
cc -c -o '/tmp/out/library/lib1.o' '/home/jgc/doc/nonrecursive/library/lib1.c'
cc -c -o '/tmp/out/library/lib2.o' '/home/jgc/doc/nonrecursive/library/lib2.c'
ar r '/tmp/out/library/lib.a' /tmp/out/library/lib1.o /tmp/out/library/lib2.o
ranlib '/tmp/out/library/lib.a'
cc -c -o '/tmp/out/executable/foo.o' '/home/jgc/doc/nonrecursive/executable/foo.c'
cc -c -o '/tmp/out/executable/bar.o' '/home/jgc/doc/nonrecursive/executable/bar.c'
g++ /tmp/out/executable/foo.o /tmp/out/executable/bar.o /tmp/out/library/lib.a -o'/tmp/out/
executable/exec'
尽管这种非递归的系统比递归make更复杂,但它非常灵活。它允许模块之间的单独二进制文件之间存在依赖关系,而递归make无法做到这一点,并且它允许在不失去“去任何目录并输入make”这一工程师熟知的理念的情况下实现这一点。
GNU make功能非常强大(这也是它多年仍然存在的部分原因),但当项目变得庞大时,makefile 可能变得难以管理。通过本章所学的内容,你现在可以简化 makefile,解决 GNU make的不足,使大型项目变得更加简单和可靠。
第四章:陷阱与问题
在本章中,你将学习如何应对随着项目规模扩大,makefile 维护者所面临的问题。那些在小型 makefile 中看似简单的任务,在大型的、可能是递归的 make 进程中会变得更加困难。随着 makefile 变得更加复杂,容易遇到一些边缘情况的问题,或者 GNU make 的行为难以理解的情况。
在这里,你将看到解决“递归 make 问题”的完整方案,如何克服 GNU make 处理包含空格的文件名的问题,如何处理跨平台的文件路径等等。
GNU make 注意事项:ifndef 和 ?=
检查变量是否已定义的两种方式 ifndef 和 ?= 很容易让人迷惑,因为它们做的是相似的事情,但一个名字具有误导性。ifndef 并不真正检查变量是否已定义,它只检查变量是否为空,而 ?= 则根据变量是否已定义来做决定。
比较以下两种在 makefile 中有条件设置变量 FOO 的方式:
ifndef FOO
FOO=New Value
endif
和
FOO ?= New Value
它们看起来应该做相同的事情,实际上它们差不多。
?= 的作用
GNU make 中的 ?= 运算符将其左侧的变量设置为右侧的值,前提是左侧的变量尚未定义。例如:
FOO ?= New Value
这个 makefile 将 FOO 设置为 New Value。
但以下内容则不返回此值:
FOO=Old Value
FOO ?= New Value
即使 FOO 最初为空,这个也不会返回:
FOO=
FOO ?= New Value
实际上,?= 与以下 makefile 是相同的,它使用 GNU make $(origin) 函数来判断变量是否未定义:
ifeq ($(origin FOO),undefined)
FOO = New Value
endif
$(origin FOO) 将返回一个字符串,显示 FOO 是否以及如何定义。如果 FOO 未定义,则 $(origin FOO) 的值为 undefined。
请注意,使用 ?= 定义的变量会像使用 = 运算符定义的变量一样进行展开。它们在使用时会展开,但在定义时不会展开,就像普通的 GNU make 变量一样。
ifndef 的作用
如前所述,ifndef 测试变量是否为空,但并不检查变量是否已定义。ifndef 意味着 如果变量未定义或已定义但为空。因此,以下内容:
ifndef FOO
FOO=New Value
endif
如果 FOO 未定义或 FOO 为空,则会将 FOO 设置为 New Value。因此,ifndef 可以重写为:
ifeq ($(FOO),)
FOO=New Value
endif
因为未定义的变量在读取时总是被视为具有空值。
$(shell) 和 := 一起使用
本节中的建议通常通过适当放置冒号来加速 makefile 的执行。要理解一个冒号如何带来如此大的变化,你需要了解 GNU make 的 $(shell) 函数以及 = 和 := 之间的区别。
$(shell) 解释
$(shell) 是 GNU make 中与 shell 中反引号(`)操作符相对应的函数。它执行一个命令,将结果展平(把所有空白字符,包括换行符,转换为空格),并返回最终的字符串。
例如,如果你想将date命令的输出存入一个名为NOW的变量,你可以这样写:
NOW = $(shell date)
如果你想统计当前目录中的文件数量,并将该数量存入FILE_COUNT,可以这样做:
FILE_COUNT = $(shell ls | wc -l )
因为$(shell)会将输出展平,获取当前目录中所有文件的名称并将其存入一个变量,以下方法有效:
FILES = $(shell ls)
文件之间的换行符被替换为一个空格,使得FILES成为一个用空格分隔的文件名列表。
常见的做法是执行pwd命令,将当前工作目录存入一个变量(在此例中为CWD):
CWD = $(shell pwd)
我们稍后会查看pwd命令,考虑如何优化一个示例 makefile,避免重复多次获取当前工作目录。
=和:=的区别
百分之九十九的情况下,你会看到在 makefile 中使用=形式的变量定义,像这样:
FOO = foo
BAR = bar
FOOBAR = $(FOO) $(BAR)
all: $(FOOBAR)
➊ $(FOOBAR):
→ @echo $@ $(FOOBAR)
FOO = fooey
BAR = barney
在这里,变量FOO、BAR和FOOBAR是递归展开的变量。这意味着,当需要一个变量的值时,任何它引用的变量都会在此时展开。例如,如果需要$(FOOBAR)的值,GNU make会获取$(FOO)和$(BAR)的值,将它们合并并在中间加上空格,最终返回foo bar。通过必要的多级变量展开会在变量使用时完成。
在这个 makefile 中,FOOBAR有两个不同的值。运行它会输出:
$ **make**
foo fooey barney
bar fooey barney
FOOBAR的值用于定义all规则的先决条件列表,并被展开为foo bar;同样的事情也发生在下一个规则➊中,该规则定义了foo和bar的规则。
但是当规则被执行时,在echo中使用的FOOBAR的值会产生fooey barney。(你可以通过查看$@的值来验证,在规则定义时FOOBAR的值是foo bar,$@是正在构建的目标,规则执行时可以查看它的值)。
请记住以下两种情况:
-
当在 makefile 中定义规则时,变量会评估为那个时刻在 makefile 中的值。
-
在配方中使用的变量(即在命令中)会有最终的值:无论变量在 makefile 的末尾时是什么值。
如果将FOOBAR的定义改为使用:=而不是=,运行 makefile 将产生完全不同的结果:
$ **make**
foo foo bar
bar foo bar
现在FOOBAR在所有地方都有相同的值。这是因为:=强制在 makefile 解析时立刻展开定义的右侧内容。GNU make没有将$(FOO) $(BAR)作为FOOBAR的定义,而是存储了$(FOO) $(BAR)的展开结果,在那个时刻就是foo bar。即使后来在 makefile 中重新定义了FOO和BAR,也不影响结果;FOOBAR已经被展开并设置为固定字符串。GNU make将这种方式定义的变量称为简单展开。
一旦一个变量变为简单展开变量,它就会保持这种状态,除非通过=操作符重新定义。这意味着当文本追加到一个简单展开变量时,它会在添加到变量之前进行展开。
例如,这个:
FOO=foo
BAR=bar
BAZ=baz
FOOBAR := $(FOO) $(BAR)
FOOBAR += $(BAZ)
BAZ=bazzy
导致FOOBAR变为foo bar baz。如果使用=而不是:=,当$(BAZ)被追加时,它不会被展开,结果是FOOBAR将变为foo baz bazzy。
=的隐性成本
看看这个示例的 makefile:
CWD = $(shell pwd)
SRC_DIR=$(CWD)/src/
OBJ_DIR=$(CWD)/obj/
OBJS = $(OBJ_DIR)foo.o $(OBJ_DIR)bar.o $(OBJ_DIR)baz.o
$(OBJ_DIR)%.o: $(SRC_DIR)%.c ; @echo Make $@ from $<
all: $(OBJS)
→ @echo $? $(OBJS)
它将当前工作目录获取到CWD中,定义源目录和目标目录为CWD的子目录,定义一组对象(foo.o、bar.o和baz.o),将在OBJ_DIR中构建,设置一个模式规则,展示如何从.c文件构建.o文件,最后声明默认情况下,makefile 应构建所有对象并打印出那些过时的对象的列表($?是过时规则的前提条件列表),以及所有对象的完整列表。
你可能会惊讶地发现,这个 makefile 最终只为了获取CWD值就进行了八次 shell 调用。试想一下,在一个包含成百上千个对象的真实 makefile 中,GNU make会进行多少次耗时的 shell 调用!
由于 makefile 使用了递归展开变量(即变量的值在使用时确定,而不是在定义时确定),因此会进行许多$(shell)调用:OBJS引用OBJ_DIR三次,每次都引用CWD;每次引用OBJS时,都会对$(shell pwd)进行三次调用。任何对SRC_DIR或OBJ_DIR的引用(例如,模式规则定义)都会导致另一次$(shell pwd)调用。
但一个快速的解决方法是将CWD的定义更改为通过插入:来展开,将=变为:=。因为工作目录在make过程中不会改变,所以我们可以安全地获取一次:
CWD := $(shell pwd)
现在,通过对 shell 进行一次调用来获取工作目录。在真实的 makefile 中,这可能是一个巨大的节省时间的办法。
因为在 makefile 中追踪变量的使用可能会很困难,你可以使用一个简单的技巧,使make打印出变量展开的确切位置。在CWD的定义中插入$(warning Call to shell),使其定义变为如下:
CWD = **$(warning Call to shell)**$(shell pwd)
然后,当你运行make时,会得到以下输出:
$ **make**
makefile:8: Call to shell
makefile:8: Call to shell
makefile:10: Call to shell
makefile:10: Call to shell
makefile:10: Call to shell
Make /somedir/obj/foo.o from /somedir/src/foo.c
Make /somedir/obj/bar.o from /somedir/src/bar.c
Make /somedir/obj/baz.o from /somedir/src/baz.c
makefile:11: Call to shell
makefile:11: Call to shell
makefile:11: Call to shell
/somedir/obj/foo.o /somedir/obj/bar.o /somedir/obj/baz.o /somedir/obj/foo.o
/somedir/obj/bar.o /somedir/obj/baz.o
$(warning)不会改变CWD的值,但它会输出一条消息到STDERR。从输出中,你可以看到八次对 shell 的调用以及这些调用在 makefile 中是由哪几行引起的。
如果CWD是使用:=定义的,$(warning)技巧可以验证CWD仅被展开一次:
$ **make**
makefile:1: Call to shell
Make /somedir/obj/foo.o from /somedir/src/foo.c
Make /somedir/obj/bar.o from /somedir/src/bar.c
Make /somedir/obj/baz.o from /somedir/src/baz.c
/somedir/obj/foo.o /somedir/obj/bar.o /somedir/obj/baz.o /somedir/obj/foo.o
/somedir/obj/bar.o /somedir/obj/baz.o
一个快速的检查 makefile 是否使用了=和$(shell)这种耗时组合的方法是运行以下命令:
grep -n \$\(shell makefile | grep -v :=
这会打印出包含$(shell)且不包含:=的每一行的行号和详细信息。
$(eval) 和变量缓存
在前面的章节中,你学习了如何使用 := 来通过避免反复执行 $(shell) 来加速 makefile。不幸的是,重新修改 makefile 以使用 := 可能会有问题,因为它们可能依赖于能够按照任何顺序定义变量。
在本节中,你将学习如何使用 GNU make 的 $(eval) 函数,在使用 = 扩展变量的递归优势的同时,获得类似于 := 的速度提升。
关于 $(eval)
$(eval) 的参数会被扩展,然后像 makefile 中的部分内容一样解析。因此,在 $(eval) 中(它可能位于变量定义内),你可以编程式地定义变量、创建规则(显式或模式规则)、包含其他 makefile 等等。这是一个强大的函数。
这里是一个例子:
set = $(eval $1 := $2)
$(call set,FOO,BAR)
$(call set,A,B)
这导致 FOO 的值为 BAR,A 的值为 B。显然,这个例子可以在没有 $(eval) 的情况下实现,但很容易看出如何使用 $(eval) 对 makefile 中的定义进行编程式修改。
一个 $(eval) 副作用
$(eval) 的一种用途是创建副作用。例如,这里有一个变量,实际上是一个自动递增的计数器(它使用了 GMSL 的算术函数):
include gmsl
c-value := 0
counter = $(c-value)$(eval c-value := $(call plus,$(c-value),1))
每次使用 counter 时,它的值都会增加 1。例如,以下 $(info) 函数序列会按顺序输出从 0 开始的数字:
$(info Starts at $(counter))
$(info Then it's $(counter))
$(info And then it's $(counter))
这里是输出结果:
$ **make**
Starts at 0
Then it's 1
And then it's 2
你可以使用像这样的简单副作用来找出 GNU make 重新评估某个变量的频率。你可能会对结果感到惊讶。例如,在构建 GNU make 时,它的 makefile 中的变量 srcdir 被访问了 48 次;OBJEXT 被访问了 189 次,而这只是一个非常小的项目。
GNU make 通过重复访问相同的字符串浪费时间来访问不变的变量。如果被访问的变量很长(例如长路径)或包含 $(shell) 调用或复杂的 GNU make 函数,那么变量处理的性能可能会影响 make 的整体运行时间。
如果你试图通过并行化 make 来最小化构建时间,或者开发人员正在运行只需要重新构建少数文件的增量构建,这一点尤其重要。在这两种情况下,GNU make 的长启动时间可能会非常低效。
缓存变量值
GNU make 确实提供了解决反复重新评估变量问题的方案:使用 := 代替 =。使用 := 定义的变量会将其值设置为一次性确定,右侧的表达式只会被评估一次,结果值被设置到变量中。使用 := 可以使 makefile 解析更快,因为右侧只会被评估一次。但它确实引入了一些限制,因此很少使用。一个限制是它要求变量定义的顺序必须特定。例如,如果按如下顺序排列:
FOO := $(BAR)
BAR := bar
如果按照这种顺序排列,FOO 中的结果将与其它顺序下的值完全不同:
BAR := bar
FOO := $(BAR)
在第一个代码片段中,FOO是空的,而在第二个代码片段中,FOO是bar。
与以下内容的简洁性相比:
FOO = $(BAR)
BAR = bar
这里,FOO 的值是 bar。大多数 makefile 都是以这种方式编写的,只有非常用心(且注重速度)的 makefile 编写者才会使用 :=。
另一方面,几乎所有这些递归定义的变量在使用时只有一个值。复杂的递归定义变量的长时间求值对于 makefile 编写者来说是一种便利。
理想的解决方案是缓存变量值,以便保留=样式的灵活性,但变量只在首次计算时进行求值,从而提高速度。显然,这会导致灵活性略有丧失,因为变量不能取两个不同的值(这在 makefile 中有时是有用的)。但对于大多数用途来说,这会显著提升速度。
使用缓存的速度提升
请参见示例 makefile 在 示例 4-1 中。
示例 4-1。在这个 makefile 中,FOO和C被无意义地反复求值。
C := 1234567890 ABCDEFGHIJKLMNOPQRSTUVWXYZ
C += $C
C += $C
C += $C
C += $C
C += $C
C += $C
C += $C
C += $C
C += $C
C += $C
C += $C
FOO = $(subst 9,NINE,$C)$(subst 8,EIGHT,$C)$(subst 7,SEVEN,$C) \
$(subst 6,SIX,$C)$(subst 5,FIVE,$C)$(subst 4,FOUR,$C) \
$(subst 3,THREE,$C)$(subst 2,TWO,$C)$(subst 1,ONE,$C)
_DUMMY := $(FOO)
--*snip*--
.PHONY: all
all:
它定义了一个变量 C,这是一个长字符串(实际上是 1234567890 重复 2,048 次,再加上字母表重复 2,048 次,最后加上空格,总共有 77,824 个字符)。在这里使用 :=,以便快速创建 C。C 旨在模拟在 makefile 中生成的长字符串(例如,带路径的源文件长列表)。
然后定义一个变量 FOO,使用内建的 $(subst) 函数来操作 C。FOO 模拟了 makefile 中的操作(例如,将文件名扩展名从 .c 改为 .o)。
最后,$(FOO)在小而实际的 makefile 中被求值 200 次,模拟了FOO的使用。这个 makefile 什么也不做;最后有一个虚拟的、空的 all 规则。
在我的笔记本上,使用 GNU make 3.81,这个 makefile 运行大约需要 3.1 秒。这是大量时间都花在反复操作 C 和 FOO,但并没有进行实际的构建。
使用来自 An $(eval) Side Effect Side Effect") 的 counter 技巧,你可以计算出在这个 makefile 中 FOO 和 C 被求值的次数。FOO 被求值了 200 次,而 C 被求值了 1600 次。令人惊讶的是,这些求值可以加起来非常快。
但 C 和 FOO 的值只需要计算一次,因为它们不会改变。假设你修改了 FOO 的定义,使用 :=:
FOO := $(subst 9,NINE,$C)$(subst 8,EIGHT,$C)$(subst 7,SEVEN,$C) \
$(subst 6,SIX,$C)$(subst 5,FIVE,$C)$(subst 4,FOUR,$C) \
$(subst 3,THREE,$C)$(subst 2,TWO,$C)$(subst 1,ONE,$C)
这将运行时间降至 1.8 秒,C 被求值九次,而 FOO 只被求值一次。但当然,这需要使用 :=,并且会带来它的所有问题。
一个缓存函数
另一种缓存功能是这个简单的缓存方案:
cache = $(if $(cached-$1),,$(eval cached-$1 := 1)$(eval cache-$1 := $($1)))$(cache-$1)
首先,定义了一个名为cache的函数,它会在变量第一次被评估时自动缓存该变量的值,并在随后的每次尝试获取该值时从缓存中取出。
cache使用两个变量来存储变量的缓存值(在缓存变量A时,缓存值存储在cache-A中)以及该变量是否已被缓存(在缓存变量A时,已缓存标志是cached-A)。
首先,它检查变量是否已经缓存;如果缓存过,则$(if)什么也不做。如果没有缓存,则在第一次$(eval)中设置该变量的缓存标志,然后扩展变量的值(注意$($1),它获取变量的名称并获取其值),并进行缓存。最后,cache返回缓存中的值。
要更新 makefile,只需将任何对变量的引用改为调用cache函数。例如,你可以通过简单的查找和替换,将示例 4-1 中的所有$(FOO)更改为$(call cache,FOO)。结果如示例 4-2 所示。
示例 4-2. 使用cache函数的示例 4-1 的修改版。
C := 1234567890 ABCDEFGHIJKLMNOPQRSTUVWXYZ
C += $C
C += $C
C += $C
C += $C
C += $C
C += $C
C += $C
C += $C
C += $C
C += $C
C += $C
FOO = $(subst 9,NINE,$C)$(subst 8,EIGHT,$C)$(subst 7,SEVEN,$C) \
$(subst 6,SIX,$C)$(subst 5,FIVE,$C)$(subst 4,FOUR,$C) \
$(subst 3,THREE,$C)$(subst 2,TWO,$C)$(subst 1,ONE,$C)
_DUMMY := $(call cache,FOO)
--*snip*--
.PHONY: all
all:
在我的机器上运行此代码后,显示现在有一次访问FOO,仍然是九次访问C,并且运行时间为 2.4 秒。这不如:=版本(耗时 1.8 秒)快,但仍然快了 24%。在一个大的 makefile 中,这种技术可能会带来实际的差异。
总结
处理变量的最快方式是尽可能使用:=,但这需要小心和注意,最好只在新的 makefile 中进行(想象一下尝试回去重新设计一个已有的 makefile 来使用:=)。
如果你被=困住了,这里介绍的cache函数可以提供一个速度提升,尤其是对于进行增量短构建的开发者来说,这将是非常有用的。
如果只需要更改单个变量的定义,可以消除cache函数。例如,下面是将FOO的定义更改为神奇地从递归定义切换到简单定义的示例:
FOO = $(eval FOO := $(subst 9,NINE,$C)$(subst 8,EIGHT,$C)$(subst 7,SEVEN,$C) \
$(subst 6,SIX,$C)$(subst 5,FIVE,$C)$(subst 4,FOUR,$C)$(subst 3,THREE,$C) \
$(subst 2,TWO,$C)$(subst 1,ONE,$C))$(value FOO)
第一次引用$(FOO)时,会触发$(eval),将FOO从递归定义的变量变为简单定义(使用:=)。最后的$(value FOO)返回存储在FOO中的值,使得这个过程变得透明。
隐藏目标的问题
查看示例 4-3 中的 makefile:
示例 4-3。在这个 makefile 中,生成 foo 的规则也会生成 foo.c。
.PHONY: all
all: foo foo.o foo.c
foo:
→ touch $@ foo.c
%.o: %.c
→ touch $@
它包含了一个危险的陷阱,可能会导致 make 报告奇怪的错误,停止 -n 选项的正常工作,并阻止快速的并行 make。它甚至可能导致 GNU make 做错工作,并更新一个已经是最新的文件。
从表面看,这个 makefile 看起来很简单。如果你通过 GNU make 执行它,它会先构建 foo(生成文件 foo 和 foo.c),然后使用底部的模式从 foo.c 生成 foo.o。它最终会运行以下命令:
touch foo foo.c
touch foo.o
但其中有一个致命的缺陷。这个 makefile 中没有提到生成 foo 的规则实际上也生成了 foo.c。因此,foo.c 是一个隐藏目标,这是一个已经构建但 GNU make 不知道的文件,而隐藏目标会引发无数问题。
GNU make 在跟踪目标、需要构建的文件以及目标之间的依赖关系方面非常擅长。但 make 程序的表现好坏取决于其输入。如果你没有告诉 make 两个文件之间的关系,它不会自己发现这个关系,而且它会因为假设自己对文件及其关系拥有完美的了解而犯错误。
在这个例子中,make 之所以能工作,是因为它按从左到右的顺序构建 all 的先决条件。首先它遇到 foo,构建了它,并副作用地创建了 foo.c,然后再使用模式构建 foo.o。如果你改变 all 的先决条件的顺序,使得它不先构建 foo,构建就会失败。
隐藏目标至少有五个可怕的副作用。
如果隐藏目标缺失,会发生意外错误
假设 foo 存在,但 foo.c 和 foo.o 丢失:
$ **rm -f foo.c foo.o**
$ **touch foo**
$ **make**
No rule to make target `foo.c', needed by `foo.o'.
make 试图更新 foo.o,但因为它不知道如何生成 foo.c(因为它没有被列为任何规则的目标),调用 GNU make 会导致错误。
-n 选项失效
GNU make 中有一个有用的 -n 调试选项,它会告诉 make 打印出它将要运行的命令,而不是实际运行它们:
$ **make -n**
touch foo foo.c
No rule to make target `foo.c', needed by `foo.o'.
你已经看到,make 实际上会执行两个 touch 命令(touch foo foo.c,然后是 touch foo.o),但执行 make -n(没有 foo* 文件时)会导致错误。make 不知道生成 foo 的规则还会生成 foo.c,而且因为它没有实际运行 touch 命令,foo.c 就缺失了。因此,-n 不代表 make 实际会执行的命令,这使得它在调试时没有用处。
你无法并行化 make
GNU make 提供了一个方便的功能,允许它同时运行多个作业。如果构建中有多个编译任务,可以指定 -j 选项(后面跟一个数字,表示同时运行的作业数),以最大化 CPU 使用率并缩短构建时间。
不幸的是,一个隐藏的目标破坏了这个计划。以下是运行make -j3在我们的示例 makefile 中同时运行三个任务时的输出,参考自示例 4-3:
$ **make -j3**
touch foo foo.c
No rule to make target `foo.c', needed by `foo.o'.
Waiting for unfinished jobs....
GNU make尝试同时构建foo、foo.o和foo.c,并发现它不知道如何构建foo.c,因为它无法知道应该等待foo被构建。
如果隐藏目标被更新,make 会做错工作
假设foo.c文件在运行make时已经存在。因为make不知道foo的规则会影响到foo.c,它会被更新,即使它已经是最新的。在示例 4-2 中,foo.c被一个无害的touch操作修改,只有文件的时间戳被改变,但不同的操作可能会破坏或覆盖文件的内容:
$ **touch foo.c**
$ **rm -f foo foo.o**
$ **make**
touch foo foo.c
touch foo.o
make重建了foo,因为它缺失,并同时更新了foo.c,即使它显然是最新的。
你不能直接让 make 构建 foo.o
你希望输入make foo.o会导致 GNU make从foo.c构建foo.o,并在必要时构建foo.c。但是make不知道如何构建foo.c。当构建foo时,foo.c恰好被构建出来:
$ **rm -f foo.c**
$ **make foo.o**
No rule to make target `foo.c', needed by `foo.o'.
所以如果foo.c缺失,make foo.o会导致错误。
希望现在你已经相信隐藏目标是一个坏主意,并且可能会导致各种构建问题。
GNU make 的转义规则
有时候你需要在 makefile 中插入特殊字符。也许你需要在$(error)消息中插入换行符、在$(subst)中插入空格字符,或者作为 GNU make函数的参数插入逗号。这三项简单的任务在 GNU make中可能会让人非常沮丧;本节将带你通过简单的语法,消除这些沮丧。
GNU make在包含命令的任何行开头使用制表符字符是一个著名的语言特性,但一些其他特殊字符也可能会让你困惑。GNU make处理$、%、?、*、[、~、\和#的方式都是特殊的。
处理$
每个 GNU make用户都熟悉$,它用于开始变量引用。你可以写$(variable)(带括号)或${variable}(带大括号)来获取variable的值,如果变量名是单个字符(如a),你可以省略括号,直接使用$a。
要获取字面量的$,你需要写$$。因此,要定义一个包含单个$符号的变量,你可以写:dollar := $$。
玩转%
转义%不像$那么简单,但只需要在三种情况中做转义,并且相同的规则适用于每种情况:在vpath指令中,在$(patsubst)中,以及在模式或静态模式规则中。
转义%的三个规则是:
-
你可以用一个单独的
\字符来转义%(也就是说,\%就变成了字面量的%)。 -
如果你需要在
%前加一个字面量的\(也就是说,你希望\不转义%),则使用\进行转义(换句话说,\\%变成了字面量的\后跟一个%字符,这个%将用于模式匹配)。 -
不用担心在模式的其他地方转义
\。它会被当作字面量处理。例如,\hello就是\hello。
通配符和路径
当符号 ?、*、[ 和 ] 出现在文件名中时,它们会被特殊处理。一个包含以下内容的 makefile:
*.c:
→ @command
它实际上会搜索当前目录中的所有 .c 文件,并为每个文件定义一个规则。目标(以及 include 指令中提到的先决条件和文件)如果包含通配符字符,则会被 glob(文件系统被搜索,文件名与通配符字符匹配)。这些 glob 字符的意义与 Bourne shell 中相同。
~ 字符在文件名中也有特殊处理,会被扩展为当前用户的主目录。
所有这些特殊的文件名字符都可以通过 \ 来转义。例如:
\*.c:
→ @command
这个 makefile 为名为(字面上的)*.c 的文件定义了一个规则。
续行
除了转义功能外,你还可以在行尾使用 \ 作为续行字符:
all: \
prerequisite \
something else
→ @command
在这里,all 的规则有三个先决条件:prerequisite、something 和 else。
注释
你可以使用 # 字符来开始注释,也可以通过 \ 转义将其变成字面量:
pound := \#
在这里,$(pound) 是一个单一字符:#。
我只想要一个换行符!
GNU make 尽最大努力将你与换行符隔离开。你不能转义换行符——没有特殊字符的语法(例如,你不能写 \n),即使是 $(shell) 函数也会从返回值中去掉换行符。
但是你可以使用 define 语法定义一个包含换行符的变量:
define newline
endef
请注意,这个定义包含了两行空白行,但使用 $(newline) 只会展开成一个换行符,这对于格式化错误消息非常有用:
$(error This is an error message$(newline)with two lines)
由于 GNU make 相当宽松的变量命名规则,可以定义一个名为 \n 的变量。所以,如果你喜欢保持熟悉的外观,可以这样做:
define \n
endef
$(error This is an error message $(\n)with two lines)
我们将在下一节中更详细地讨论特殊的变量名。
函数参数:空格和逗号
许多 GNU make 用户遇到的一个问题是处理 GNU make 函数参数中的空格和逗号。考虑以下 $(subst) 的用法:
spaces-to-commas = $(subst ,,,$1)
这需要三个由逗号分隔的参数:from 文本,to 文本,以及要更改的字符串。
它定义了一个名为 spaces-to-commas 的函数,用于将参数中的所有空格转换为逗号(这对于制作 CSV 文件可能很有用)。不幸的是,它由于两个原因无法正常工作:
-
$(subst)的第一个参数是一个空格。GNUmake会去掉函数参数两端的所有空白字符。在这种情况下,第一个参数会被解释为空字符串。 -
第二个参数是一个逗号。GNU
make无法区分用作参数分隔符的逗号和作为参数的逗号。此外,没有办法转义逗号。
如果你知道 GNU make在展开参数之前会进行空白字符的剥离和参数分隔,那么你可以绕过这两个问题。所以,如果我们能定义一个包含空格的变量和一个包含逗号的变量,我们可以写出如下的代码来达到预期效果:
spaces-to-commas = $(subst $(space),$(comma),$1)
定义一个包含逗号的变量很简单,如下所示:
comma := ,
但是空格有点复杂。你可以通过几种方式定义一个空格。一个方法是利用每次向变量添加内容(使用+=)时,都会在添加的文本前插入一个空格:
space :=
space +=
另一种方法是先定义一个不包含任何内容的变量,然后用它来围绕空格,以防空格被 GNU make剥离:
blank :=
space := $(blank) $(blank)
你也可以使用这个技巧将一个字面上的制表符字符放入变量中:
blank :=
tab := $(blank)→$(blank)
就像上一节中定义了$(\n)一样,定义特别命名的空格和逗号变量也是可能的。GNU make的规则足够宽松,允许我们这么做:
, := ,
blank :=
space := $(blank) $(blank)
$(space) := $(space)
第一行定义了一个名为,的变量(可以用$(,)甚至$,),其内容是一个逗号。
最后三行定义了一个名为space的变量,其内容是一个空格字符,然后用它来定义一个名为(没错,它的名字就是一个空格字符)的变量,该变量包含一个空格。
使用这个定义,你可以写$( )甚至$(在那个$后面有一个空格)来获得一个空格字符。请注意,这样做可能会在未来的make更新中引发问题,因此像这样玩弄技巧可能是危险的。如果你不喜欢冒险,最好使用名为space的变量,避免使用$( )。因为空白字符在 GNU make中是特殊的,通过像$( )这样的技巧将make的解析器推向极限可能会导致破坏。
使用这些定义,可以将spaces-to-commas函数写成:
spaces-to-commas = $(subst $( ),$(,),$1)
这个看起来很奇怪的定义通过subst将空格替换为逗号。它之所以有效,是因为$( )会被subst展开,并且本身就是一个空格。这个空格会成为第一个参数(即将被替换的字符串)。第二个参数是$(,),当它被展开时,会变成一个逗号。结果是,spaces-to-commas将空格转化为逗号,而不会让 GNU make混淆空格和逗号字符。
《暮光之区》
可以像定义$( )和$(\n)这样的变量定义一样,进一步发展,定义像=、#或:这样的变量名。以下是一些其他有趣的变量定义:
# Define the $= or $(=) variable which has the value =
equals := =
$(equals) := =
# Define the $# or $(#) variable which has the value #
hash := \#
$(hash) := \#
# Define the $: or $(:) variable which has the value :
colon := :
$(colon) := :
# Define the $($$) variable which has the value $
dollar := $$
$(dollar) := $$
这些定义可能没有太大用处,但如果你想将 GNU make的语法推向极限,可以尝试以下方法:
+:=+
是的,这定义了一个名为 + 的变量,内容是一个 +。
$(wildcard) 的问题
$(wildcard) 函数是 GNU make 的模式匹配函数。它是获取 makefile 中文件列表的一个有用方法,但它可能会表现得出乎意料。它并不总是提供与运行 ls 相同的结果。继续阅读,了解为什么会这样以及该怎么做。
$(wildcard) 解释
你可以在 makefile 或规则中任何地方使用 $(wildcard) 来获取与一个或多个 glob 风格模式匹配的文件列表。例如,$(wildcard *.foo) 返回一个以 .foo 结尾的文件列表。回想一下,列表是一个字符串,其中元素之间用空格分隔,因此 $(wildcard *.foo) 可能返回 a.foo b.foo c.foo。(如果文件名中包含空格,返回的列表可能会看起来不正确,因为无法区分列表分隔符(空格)和文件名中的空格。)
你还可以传递一个模式列表给 $(wildcard),因此 $(wildcard *.foo *.bar) 会返回所有以 .foo 或 .bar 结尾的文件。$(wildcard) 函数支持以下模式匹配操作符:*(匹配 0 或更多字符)、?(匹配 1 个字符)和 [...](匹配字符,[123],或字符范围,[a-z])。
$(wildcard) 的另一个有用功能是,如果传给它的文件名不包含模式,它只是检查文件是否存在。如果文件存在,它返回文件名;否则,$(wildcard) 返回一个空字符串。因此,$(wildcard) 可以与 $(if) 结合使用,创建一个 if-exists 函数:
if-exists = $(if ($wildcard $1),$2,$3)
if-exists 有三个参数:要检查的文件名、文件存在时要执行的操作,以及文件不存在时要执行的操作。以下是其使用的一个简单示例:
$(info a.foo is $(call if-exists,a.foo,there,not there))
如果 a.foo 存在,它将打印 a.foo is there;如果不存在,它将打印 a.foo is not there。
意外的结果
以下每个示例使用两个变量来获取特定目录中以 .foo 结尾的文件列表:WILDCARD_LIST 和 LS_LIST 分别通过调用 $(wildcard) 和 $(shell ls) 来返回以 .foo 结尾的文件列表。变量 DIRECTORY 存储示例查找文件的目录;对于当前目录,DIRECTORY 保持为空。
起始的 makefile 如下所示:
WILDCARD_LIST = wildcard returned \'$(wildcard $(DIRECTORY)*.foo)\'
LS_LIST = ls returned \'$(shell ls $(DIRECTORY)*.foo)\'
.PHONY: all
all:
→ @echo $(WILDCARD_LIST)
→ @echo $(LS_LIST)
在当前目录中只有一个文件 a.foo 时,运行 GNU make 结果如下:
$ **touch a.foo**
$ **make**
wildcard returned 'a.foo'
ls returned 'a.foo'
现在扩展 makefile,使其通过 touch 创建一个名为 b.foo 的文件。这个 makefile 应该如下所示:示例 4-4 返回不同的结果。"):
示例 4-4。当你运行这个 makefile 时,ls 和 $(wildcard) 返回不同的结果。
WILDCARD_LIST = wildcard returned \'$(wildcard $(DIRECTORY)*.foo)\'
LS_LIST = ls returned \'$(shell ls $(DIRECTORY)*.foo)\'
.PHONY: all
all: b.foo
→ @echo $(WILDCARD_LIST)
→ @echo $(LS_LIST)
b.foo:
→ @touch $@
通过 GNU make 运行这个 makefile(仅有已存在的 a.foo 文件)会产生以下令人惊讶的输出:
$ **touch a.foo**
$ **make**
wildcard returned 'a.foo'
ls returned 'a.foo b.foo'
ls 返回正确的列表(因为b.foo在all规则执行时已经被创建),但$(wildcard)没有;$(wildcard)似乎显示的是b.foo创建之前的状态。
在子目录中使用.foo文件(而不是当前工作目录中的文件)会导致不同的输出,如示例 4-5 返回相同的结果。")所示。
示例 4-5. 这次,ls 和 $(wildcard) 返回相同的结果。
DIRECTORY=subdir/
.PHONY: all
all: $(DIRECTORY)b.foo
→ @echo $(WILDCARD_LIST)
→ @echo $(LS_LIST)
$(DIRECTORY)b.foo:
→ @touch $@
这里,Makefile 已更新,以便使用DIRECTORY变量来指定子目录subdir。有一个预先存在的文件subdir/a.foo,Makefile 将会创建subdir/b.foo。
运行这个 Makefile 会得到:
$ **touch subdir/a.foo**
$ **make**
wildcard returned 'subdir/a.foo subdir/b.foo'
ls returned 'subdir/a.foo subdir/b.foo'
在这里,$(wildcard)和ls都返回相同的结果,并且都显示了两个.foo文件的存在:subdir/a.foo,它在运行make之前就已经存在,以及subdir/b.foo,它是由 Makefile 创建的。
在我解释发生了什么之前,让我们看看最后一个 Makefile (示例 4-6 返回不同的结果。")):
示例 4-6. 一个小的变化使得ls和$(wildcard)返回不同的结果。
DIRECTORY=subdir/
$(warning Preexisting file: $(WILDCARD_LIST))
.PHONY: all
all: $(DIRECTORY)b.foo
→ @echo $(WILDCARD_LIST)
→ @echo $(LS_LIST)
$(DIRECTORY)b.foo:
→ @touch $@
在这个 Makefile 中,使用了$(warning)来打印出子目录中已经存在的.foo文件列表。
以下是输出:
$ **touch subdir/a.foo**
$ **make**
makefile:6: Preexisting file: wildcard returned 'subdir/a.foo'
wildcard returned 'subdir/a.foo'
ls returned 'subdir/a.foo subdir/b.foo'
请注意,现在 GNU make 的行为看起来像是示例 4-4 返回不同的结果。")中的行为;即使subdir/b.foo文件已由 Makefile 创建,$(wildcard)仍然看不到它并未显示,尽管它已经被创建并且ls找到了它。
意外结果解释
我们得到意外且显然不一致的结果,因为 GNU make 包含它自己的目录条目缓存。$(wildcard)是从这个缓存中读取(而不是像ls那样直接从磁盘读取)来获取结果。了解何时填充缓存对于理解$(wildcard)返回的结果至关重要。
GNU make 只有在被迫时才会填充缓存(例如,当它需要读取目录条目以满足$(wildcard)或其他模式匹配请求时)。如果你知道 GNU make 只有在需要时才会填充缓存,那么就可以解释结果。
在示例 4-4 返回不同的结果。")中,GNU make在开始时会填充当前工作目录的缓存。因此,文件b.foo不会出现在$(wildcard)的输出中,因为它在缓存填充时并不存在。
在示例 4-5返回相同的结果。")中,GNUmake直到需要时才会填充来自subdir的缓存条目。这些条目首次被\((wildcard)` 需要,而 `\)(wildcard)是在subdir/b.foo创建后执行的,因此subdir/b.foo会出现在$(wildcard)` 输出中。
在示例 4-6 返回不同的结果。")中,\((warning)` 在 Makefile 开始时触发并填充了缓存(因为它执行了 `\)(wildcard)),因此 subdir/b.foo在那次make过程中没有出现在$(wildcard)` 的输出中。
预测缓存何时被填充非常困难。$(wildcard) 会填充缓存,但规则的目标或先决条件列表中使用像 * 这样的通配符操作符也会填充缓存。示例 4-7 缓存可能很难理解。")是一个 Makefile,它构建了两个文件(subdir/b.foo和subdir/c.foo),并执行了几个 $(wildcard)` 操作:
示例 4-7。 当 GNU make 填充缓存时,$(wildcard) 缓存可能很难理解。
DIRECTORY=subdir/
.PHONY: all
all: $(DIRECTORY)b.foo
→ @echo $(WILDCARD_LIST)
→ @echo $(LS_LIST)
$(DIRECTORY)b.foo: $(DIRECTORY)c.foo
→ @touch $@
→ @echo $(WILDCARD_LIST)
→ @echo $(LS_LIST)
$(DIRECTORY)c.foo:
→ @touch $@
输出可能会让你感到惊讶:
$ **make**
wildcard returned 'subdir/a.foo subdir/c.foo'
ls returned 'subdir/a.foo subdir/c.foo'
➊ wildcard returned 'subdir/a.foo subdir/c.foo'
ls returned 'subdir/a.foo subdir/b.foo subdir/c.foo'
即使第一个 $(wildcard) 已经在生成 subdir/b.foo 的规则中执行,并且在创建了 subdir/b.foo 后执行了 touch,但在 $(wildcard) 的输出中并没有提到 subdir/b.foo ➊。ls 的输出中也没有提到 subdir/b.foo。
原因在于,整个命令块在规则中的任何一行执行之前就已经扩展成其最终形式。因此,$(wildcard) 和 $(shell ls) 会在 touch 执行之前完成。
如果在使用 -j 开关并行执行 make,$(wildcard) 的输出会变得更加不可预测。在这种情况下,规则执行的确切顺序无法预测,因此 $(wildcard) 的输出可能变得更加不可预测。
我建议你:不要在规则中使用 $(wildcard);只在解析时(在任何规则开始执行之前)在 Makefile 中使用 $(wildcard)。如果你将 $(wildcard) 的使用限制在解析时,你可以确保结果一致:$(wildcard) 将显示在 GNU make 执行之前的文件系统状态。
创建目录
现实世界中的 Makefile 黑客常遇到的一个问题是,在构建之前,或者至少在使用这些目录的命令运行之前,需要构建目录层次结构。最常见的情况是,Makefile 黑客希望确保将创建目标文件的目录已存在,并且他们希望这个过程自动化。本节将探讨在 GNU make 中实现目录创建的多种方法,并指出一个常见的陷阱。
一个示例 Makefile
以下 makefile 使用 GNU make 内建变量 COMPILE.C 从 foo.c 构建目标文件 /out/foo.o,通过运行编译器将 .c 文件转换为 .o 文件。
foo.c 和 makefile 在同一目录下,但 foo.o 会被放到 /out/ 目录中:
.PHONY: all
all: /out/foo.o
/out/foo.o: foo.c
→ @$(COMPILE.C) -o $@ $<
这个示例在 /out/ 存在的情况下工作良好。但如果它不存在,你会收到类似下面的编译错误:
$ **make**
Assembler messages:
FATAL: can't create /out/foo.o: No such file or directory
make: *** [/out/foo.o] Error 1
显然,你希望的是 makefile 在 /out/ 不存在时能自动创建它。
不应该做的事
因为 GNU make 擅长创建不存在的东西,所以看起来很明显,应该将 /out/ 作为 /out/foo.o 的前提条件,并为创建目录编写一个规则。这样,当我们需要构建 /out/foo.o 时,目录就会被创建。
示例 4-8 展示了修改后的 makefile,其中目录作为前提条件,并使用 mkdir 创建目录的规则。
示例 4-8. 这个 makefile 最终可能会做不必要的工作。
OUT = /out
.PHONY: all
all: $(OUT)/foo.o
$(OUT)/foo.o: foo.c $(OUT)/
→ @$(COMPILE.C) -o $@ $<
$(OUT)/:
→ mkdir -p $@
为了简化,输出目录的名称存储在一个名为 OUT 的变量中,并且 mkdir 命令使用 -p 选项,这样它就会一次性构建所有必要的父目录。在这个例子中,路径很简单:就是 /out/,但 -p 选项意味着 mkdir 可以一次性创建一条长路径的所有目录。
对于这个基础示例来说,这个方法工作良好,但存在一个重大问题。因为目录的时间戳通常在目录更新时(例如,文件被创建、删除或重命名时)会更新,所以这个 makefile 可能会做过多的工作。
例如,仅仅在 /out/ 目录下创建另一个文件就会强制重新构建 /out/foo.o。在更复杂的示例中,这可能意味着许多目标文件会因为其他文件在同一目录下被重建而无故重建。
解决方案 1:在解析 makefile 时创建目录
在示例 4-8 中,解决问题的一个简单方法是,在解析 makefile 时直接创建目录。通过快速调用 $(shell) 可以实现:
OUT = /out
.PHONY: all
all: $(OUT)/foo.o
$(OUT)/foo.o: foo.c
→ @$(COMPILE.C) -o $@ $<
$(shell mkdir -p $(OUT))
在创建任何目标或运行任何命令之前,makefile 会被读取和解析。如果你在 makefile 中的某个位置放置 $(shell mkdir -p $(OUT)),GNU make 每次加载 makefile 时都会运行 mkdir。
一个可能的缺点是,如果需要创建多个目录,这个过程可能会比较慢。而且 GNU make 会做不必要的工作,因为每次运行 make 时,它都会尝试构建这些目录。某些用户也不喜欢这种方法,因为即使某些目录在 makefile 中的规则并未使用,所有目录还是会被创建。
通过首先测试目录是否存在,可以进行一些小的改进:
ifeq ($(wildcard $(OUT)/.),)
$(shell mkdir -p $(OUT))
endif
在这里,$(wildcard)与/.一起使用,以检查目录是否存在。如果目录缺失,$(wildcard)将返回一个空字符串,$(shell)将会被执行。
解决方案 2:仅在构建 all 时创建目录
一个相关的解决方案是仅在构建all时才创建目录。这意味着在每次解析 makefile 时,目录不会被创建(这可以避免在你输入make clean或make depend时进行不必要的工作):
OUT = /out
.PHONY: all
all: make_directories $(OUT)/foo.o
$(OUT)/foo.o: foo.c
→ @$(COMPILE.C) -o $@ $<
.PHONY: make_directories
make_directories: $(OUT)/
$(OUT)/:
→ mkdir -p $@
这个解决方案有些杂乱,因为你必须将make_directories指定为任何目标的前提条件,该目标可能是在make后由用户指定的。如果不这样做,可能会遇到目录未创建的情况。你应该避免使用这种技术,特别是因为它会完全破坏并行构建。
解决方案 3:使用目录标记文件
如果你回头看看示例 4-8,你会注意到一个相当不错的特性:它只为特定目标构建所需的目录。在一个更复杂的例子中(有许多这样的目录需要构建),能够使用类似的解决方案会很好,同时避免目录时间戳变化导致的不断重建问题。
为此,你可以在目录中存储一个特殊的空文件,我称之为标记文件,并将其作为前提条件使用。因为它是一个普通文件,普通的 GNU make重建规则适用,并且其时间戳不会受到目录变化的影响。
如果你添加一个规则来构建标记文件(并确保其目录存在),你可以通过指定标记文件作为目录的代理,来指定目录作为前提条件。
OUT = /out
.PHONY: all
all: $(OUT)/foo.o
$(OUT)/foo.o: foo.c $(OUT)/.f
→ @$(COMPILE.C) -o $@ $<
$(OUT)/.f:
→ mkdir -p $(dir $@)
→ touch $@
注意,构建$(OUT)/.f的规则会在必要时创建目录,并触及.f文件。因为目标是一个文件(.f),它可以安全地作为$(OUT)/foo.o规则的前提条件。
$(OUT)/.f规则使用 GNU make函数$(dir FILE)来提取目标的目录部分(即.f文件的路径),并将该目录传递给mkdir。
唯一的缺点是,对于每个可能需要创建的目录中的目标构建规则,都必须指定.f文件。
为了简化使用,你可以创建函数,自动生成创建目录的规则,并计算.f文件的正确名称:
marker = $1.f
make_dir = $(eval $1.f: ; @mkdir -p $$(dir $$@) ; touch $$@)
OUT = /out
.PHONY: all
all: $(OUT)/foo.o
$(OUT)/foo.o: foo.c $(call marker,$(OUT))
→ @$(COMPILE.C) -o $@ $<
$(call make-dir,$(OUT))
在这里,marker和make-dir用于简化 makefile。
解决方案 4:使用仅顺序前提条件来创建目录
在 GNU make 3.80 及以后版本中,另一种解决方案是使用仅顺序前提条件。仅顺序前提条件在目标之前正常构建,但当前提条件发生变化时不会导致目标重新构建。通常情况下,当前提条件被重新构建时,目标也会被重新构建,因为 GNU make 假设目标依赖于前提条件。而仅顺序前提条件则不同:它们在目标之前被构建,但目标不会因为仅顺序前提条件的构建而更新。
这正是我们希望在示例 4-8 中的原始破损示例中实现的——确保目录按需重建,但不会在每次目录的时间戳更改时重新构建 .o 文件。
仅顺序的前提条件是指那些出现在竖线符号 | 后面的前提条件,且必须放在任何正常前提条件之后。
事实上,仅仅在示例 4-8 中的破损示例中添加这一字符,就能使其正确工作:
OUT = /out
.PHONY: all
all: $(OUT)/foo.o
$(OUT)/foo.o: foo.c | $(OUT)/
→ @$(COMPILE.C) -o $@ $<
➊ $(OUT)/:
→ mkdir -p $@
如果目录缺失,$(OUT)/ ➊ 的规则将会被执行,但对目录的更改不会导致 $(OUT)/foo.o 被重新构建。
解决方案 5:使用模式规则、第二次展开和标记文件
在典型的 makefile 中(不是像书中这样的简单示例),目标通常通过模式规则构建,如下所示:
OUT = /out
.PHONY: all
all: $(OUT)/foo.o
$(OUT)/%.o: %.c
→ @$(COMPILE.C) -o $@ $<
但我们可以改变这个模式规则,通过使用标记文件自动构建目录。
在 GNU make 3.81 及以后版本中,有一个令人兴奋的功能叫做第二次展开(通过在 makefile 中指定 .SECONDEXPANSION 目标来启用)。通过第二次展开,任何规则的前提条件列表在规则被使用之前会进行第二次展开(第一次展开发生在读取 makefile 时)。通过用第二个 $ 转义任何 $ 符号,可以在前提条件列表中使用 GNU make 的自动变量(如 $@)。
使用每个目录的标记文件和第二次展开,你可以创建一个 makefile,通过在任何规则的前提条件列表中简单地添加一项内容,自动仅在必要时创建目录:
OUT = /tmp/out
.SECONDEXPANSION:
all: $(OUT)/foo.o
$(OUT)/%.o: %.c $$(@D)/.f
→ @$(COMPILE.C) -o $@ $<
%/.f:
→ mkdir -p $(dir $@)
→ touch $@
.PRECIOUS: %/.f
用于生成 .o 文件的模式规则有一个特殊的前提条件 $$(@D)/.f,它利用第二次展开功能来获取目标要构建的目录。它通过对 $@ 应用 D 修饰符来实现这一点,$@ 获取目标的目录(而 $@ 本身获取目标的名称)。
该目录将在构建 .f 文件的过程中通过%/.f模式规则生成。请注意,.f 文件被标记为珍贵,以便 GNU make 不会删除它们。如果没有这一行,.f 文件会被视为无用的中间文件,并且在退出时会被 GNU make 清理掉。
解决方案 6:在规则中直接创建目录
也可以在需要目录的规则中创建目录;这称为“在规则中创建目录”。例如:
OUT = /out
.PHONY: all
all: $(OUT)/foo.o
$(OUT)/foo.o: foo.c
→ mkdir -p $(@D)
→ @$(COMPILE.C) -o $@ $<
在这里,我修改了$(OUT)/foo.o规则,使得每次都使用-p创建目录。只有当少数规则需要创建目录时,这种方式才有效。更新每条规则以添加mkdir是非常繁琐的,并且很容易遗漏某些规则。
GNU make 遇到带空格的文件名
GNU make将空格字符视为列表分隔符;任何包含空格的字符串都可以视为由空格分隔的单词列表。这是 GNU make的基本原理,空格分隔的列表随处可见。不幸的是,当文件名包含空格时,这会带来问题。本节将探讨如何解决“文件名中的空格问题”。
一个示例 Makefile
假设你需要创建一个 makefile,处理两个名为foo bar和bar baz的文件,其中foo bar是从bar baz构建的。处理包含空格的文件名可能会很棘手。
在 makefile 中天真地编写的方式是这样的:
foo bar: bar baz
→ @echo Making $@ from $<
但这不起作用。GNU make无法区分文件名中空格是其一部分,还是仅仅是分隔符。实际上,天真地编写的 makefile 与以下内容完全相同:
foo: bar baz
→ @echo Making $@ from $<
bar: bar baz
→ @echo Making $@ from $<
将文件名用引号括起来也不起作用。如果你尝试这样做:
"foo bar": "bar baz"
→ @echo Making $@ from $<
GNU make认为你在谈论四个文件,分别是"foo、bar"、"bar和baz"。GNU make忽略了双引号,并像往常一样按空格拆分列表。
使用\转义空格
解决空格问题的一种方法是使用 GNU make的转义运算符\,你可以用它来转义敏感字符(如字面意义上的#,以防它开始注释,或者字面意义上的%,以防它被用作通配符)。
因此,对于包含空格的文件名的规则,使用\来转义空格。我们的示例 makefile 可以重写如下:
foo\ bar: bar\ baz
→ @echo Making $@ from $<
它将正确工作。\字符在解析 makefile 时被移除,因此实际的目标和前提条件名称正确地包含空格。这将在自动变量(如$@)中反映出来。
当foo bar需要更新时,简单的 makefile 会输出:
$ **make**
Making foo bar from bar baz
你还可以在 GNU make的$(wildcard)函数中使用相同的转义机制。要检查foo bar是否存在,可以使用$(wildcard foo\ bar),GNU make将把foo bar作为一个单独的文件名,在文件系统中查找。
不幸的是,GNU make的其他处理空格分隔列表的函数并不尊重空格的转义。例如,$(sort foo\ bar)的输出是列表bar foo\,而不是你可能期待的foo\ bar。实际上,$(wildcard)是唯一一个尊重\字符来转义空格的 GNU make函数。
如果你必须处理包含目标列表的自动变量时,这会引发问题。考虑这个稍微复杂一些的例子:
foo\ bar: bar\ baz a\ b
→ @echo Making $@ from $<
现在 foo bar 有两个前提条件 bar baz 和 a b。在这种情况下,$^(所有前提条件的列表)的值是什么?它是 bar baz a b:转义符已经去掉,甚至如果没有去掉,只有 $(wildcard) 会处理 \,这意味着它将是无用的。从 GNU make 的角度来看,$^ 是一个包含四个元素的列表。
查看自动变量的定义可以告诉我们哪些在文件名中存在空格时是安全使用的。表 4-1 显示了每个自动变量及其是否安全。
表 4-1. 自动变量的安全性
| 自动变量 | 它安全吗? |
|---|---|
$@ |
是 |
$< |
是 |
$% |
是 |
$* |
是 |
$? |
否 |
$^ |
否 |
$+ |
否 |
那些本身就是列表的变量($?、$^ 和 $+)是不安全的,因为 GNU make 的列表是由空格分隔的;其他的则是安全的。
情况变得更糟了。即使表中的前四个自动变量是安全使用的,它们的修改版本(带有 D 和 F 后缀,用于提取相应自动变量的目录和文件名部分)也不安全。这是因为它们是通过 dir 和 notdir 函数定义的。
考虑这个示例 makefile:
/tmp/foo\ bar/baz: bar\ baz a\ b
→ @echo Making $@ from $<
$@ 的值是 /tmp/foo bar/baz,如预期,但 $(@D) 的值是 /tmp bar(而不是 /tmp/foo bar),而 $(@F) 的值是 foo baz(而不是仅 baz)。
将空格转换为问号
另一种解决空格问题的方法是将空格转换为问号。这里是转换后的原始 makefile:
foo?bar: bar?baz
→ @echo Making $@ from $<
因为 GNU make 会对目标和前提条件的名称进行通配符匹配(并且会尊重其中的空格),所以这将会起作用。但结果是不可预测的。
如果 foo bar 存在,当这个 makefile 执行时,模式 foo?bar 将被转换为 foo bar,并且该值将被用于 $@。如果该文件在解析 makefile 时不存在,那么模式(因此 $@)将保持为 foo?bar。
另一个问题也存在:? 可能匹配到除了空格以外的其他字符。例如,如果系统上有一个名为 foombar 的文件,makefile 可能会错误地处理错误的文件。
为了绕过这个问题,Robert Mecklenburg 在 Managing Projects with GNU Make, 3rd edition(O'Reilly, 2004)中定义了两个函数来自动添加和删除空格。sq 函数将每个空格转化为问号(sq 表示空格变问号);qs 函数做相反的操作(它将每个问号转换为空格)。以下是更新后的 makefile,使用了两个函数(sq 和 qs)来添加和删除问号。除非某个文件名中包含问号,否则该方法有效,但需要将所有文件名的使用都包装在 sq 和 qs 的调用中。
sp :=
sp +=
qs = $(subst ?,$(sp),$1)
sq = $(subst $(sp),?,$1)
$(call sq,foo bar): $(call sq,bar baz)
→ @echo Making $(call qs,$@) from $(call qs,$<)
无论哪种方式,由于我们仍然不能确定自动变量中是否会包含问号,因此使用基于列表的自动变量或任何 GNU make列表函数仍然是不可能的。
我的建议
由于 GNU make在处理文件名中的空格时存在困难,应该怎么办呢?以下是我的建议:
如果可能,重命名文件以避免空格。
然而,这对许多人来说是不可能的,因为文件名中的空格可能是由第三方添加的。
使用 8.3 文件名。
如果你在使用 Windows,可能可以使用短的 8.3 文件名,这样你仍然可以在磁盘上使用空格,但在 makefile 中避免使用它们。
使用\进行转义。
如果你需要空格,可以使用\进行转义,这会得到一致的结果。只要确保避免使用在表 4-1 中列为不安全的自动变量。
如果你使用\进行转义,并且需要处理包含空格的文件名列表,最好的做法是将空格替换为其他字符,然后再将其恢复。
例如,下面代码中的s+和+s函数将转义空格替换为+符号,再将其恢复。然后,你可以安全地使用所有 GNU make函数来处理文件名列表。只要确保在规则中使用这些名称之前,移除+符号即可。
space :=
space +=
s+ = $(subst \$(space),+,$1)
+s = $(subst +,\$(space),$1)
下面是一个示例,演示如何使用它们将带有转义空格的源文件列表转换为目标文件列表,然后将这些目标文件用作定义all规则的前提条件:
SRCS := a\ b.c c\ d.c e\ f.c
SRCS := $(call s+,$(SRCS))
OBJS := $(SRCS:.c=.o)
all: $(call +s,$(OBJS))
源文件存储在SRCS中,其中的文件名空格已被转义。因此,SRCS包含三个文件,分别是a b.c、c d.c和e f.c。GNU make使用\转义来保留每个文件名中的转义空格。将SRCS转换为OBJS中的目标文件列表时,通常使用.c=.o来替换每个.c扩展名为.o,但首先通过s+函数修改SRCS,将转义的空格变为+符号。结果,GNU make将看到SRCS作为包含三个元素的列表,分别是a+b.c、c+d.c和e+f.c,并且扩展名更改将正确执行。当稍后在 makefile 中使用OBJS时,+符号将通过调用+s函数被还原为转义空格。
路径处理
Makefile 的创建者通常需要操作文件系统路径,但 GNU make提供的路径操作函数很少。而跨平台的make由于路径语法差异而变得困难。本节将介绍如何在 GNU make中操作路径,并在跨平台的复杂环境中导航。
目标名称匹配
看下面这个示例 makefile,假设../foo文件丢失了。这个 makefile 能成功创建它吗?
.PHONY: all
all: ../foo
.././foo:
→ touch $@
如果你使用 GNU make运行这个 makefile,你可能会惊讶地看到以下错误:
$ **make**
make: *** No rule to make target `../foo', needed by `all'. Stop.
如果你将 makefile 改成这样:
.PHONY: all
all: ../foo
./../foo:
→ touch $@
你会发现它按预期工作,并执行touch ../foo。
第一个 makefile 会失败,因为 GNU make 不对目标名称进行路径处理,所以它会将 ../foo 和 .././foo 视为两个不同的目标,导致无法将它们关联起来。第二个 makefile 则工作正常,因为我在前述句子中说谎了。实际上,GNU make 确实会做一点路径处理:它会去掉目标名称前面的 ./。因此,在第二个 makefile 中,两个目标都是 ../foo,并按预期工作。
GNU make 目标的普遍规则是,它们被视为字面字符串,不会以任何方式进行解释。因此,当你在 makefile 中引用目标时,确保使用相同的字符串是非常重要的。
处理路径列表
需要再次强调的是,GNU make 列表仅仅是字符串,其中任何空格都被视为列表分隔符。因此,不推荐路径中有空格,因为这会导致无法使用许多 GNU make 的内建函数,且路径中的空格会给目标带来问题。
例如,假设目标是/tmp/sub directory/target,我们可以为它写出如下规则:
/tmp/sub directory/target:
→ @do stuff
GNU make 实际上会将其解释为两个规则,一个针对 /tmp/sub,另一个针对 directory/target,就像你写的是这样:
/tmp/sub:
→ @do stuff
directory/target:
→ @do stuff
你可以通过使用 \ 来转义空格来解决这个问题,但 GNU make 对这个转义的支持不太好(它仅在目标名称和 $(wildcard) 函数中有效)。
除非必须使用空格,否则避免在目标名称中使用空格。
VPATH 和 vpath 中的路径列表
另一个在 GNU make 中出现路径列表的地方是指定 VPATH 或 vpath 指令,用来指定 GNU make 查找前提条件的位置。例如,可以设置 VPATH 来在一系列 : 或空格分隔的路径中查找源文件:
VPATH = ../src:../thirdparty/src /src
vpath %c ../src:../thirdparty/src /src
GNU make 会在冒号或空格处正确地拆分路径。在 Windows 系统上,GNU make 的原生构建使用 ; 作为 VPATH(和 vpath)的路径分隔符,因为 : 被用于驱动器字母。在 Windows 上,GNU make 实际上会智能地在冒号处拆分路径,除非它看起来像一个驱动器字母(一个字母后跟一个冒号)。这种驱动器字母的智能会在路径中有单个字母的目录名时造成问题:在这种情况下,必须使用 ; 作为路径分隔符。否则,GNU make 会认为它是一个驱动器:
VPATH = ../src;../thirdparty/src /src
vpath %c ../src;../thirdparty/src /src
在 POSIX 和 Windows 系统中,路径中的空格是 VPATH 和 vpath 中的分隔符。所以,使用空格是跨平台 makefile 的最佳选择。
使用 / 或 \
在 POSIX 系统中,/ 是路径分隔符,而在 Windows 系统中是 \。在 makefile 中常见的路径构建方式如下:
SRCDIR := src
MODULE_DIR := module_1
MODULE_SRCS := $(SRCDIR)/$(MODULE_DIR)
理想情况下,应该删除 POSIX-only 的 /,并用一个能够与不同分隔符兼容的东西来替代它。一个方法是定义一个名为 / 的变量(GNU make 允许几乎任何东西作为变量名),并用它代替 /:
/ := /
SRCDIR := src
MODULE_DIR := module_1
MODULE_SRCS := $(SRCDIR)$/$(MODULE_DIR)
如果这让你感到不舒服,可以简单地称它为 SEP:
SEP := /
SRCDIR := src
MODULE_DIR := module_1
MODULE_SRCS := $(SRCDIR)$(SEP)$(MODULE_DIR)
现在,当你切换到 Windows 时,只需将 /(或 SEP)重新定义为 \。由于 GNU make 将 \ 解释为行继续符且无法转义,因此很难将字面上的 \ 作为变量值分配,因此这里使用 $(strip) 来定义它。
/ := $(strip \)
SRCDIR := src
MODULE_DIR := module_1
MODULE_SRCS := $(SRCDIR)$/$(MODULE_DIR)
但是,请注意,GNU make 的 Windows 版本也会接受 / 作为路径分隔符,因此像 c:/src 这样的路径是合法的。使用这些路径可以简化 makefile,但在将它们传递给期望使用 \ 分隔路径的本地 Windows 工具时需要小心。如果需要这样做,可以改用以下方法:
forward-to-backward = $(subst /,\,$1)
这个简单的函数将把正斜杠路径转换为反斜杠路径。
Windows 特殊情况:不区分大小写但保持大小写
在 POSIX 系统上,文件名是区分大小写的;而在 Windows 上则不是。在 Windows 上,File、file 和 FILE 是同一个文件。但 Windows 的一个特殊之处在于,第一次访问文件时,会记录并保留所使用的具体大小写。因此,如果我们 touch File,它会显示为 File,但可以作为 FILE、file 或其他任何大小写组合来访问。
默认情况下,GNU make 执行区分大小写的目标比较,因此以下 makefile 的行为可能并不是你预期的:
.PHONY: all
all: File
file:
→ @touch $@
如此使用时,这个文件会导致错误,但你可以在 Windows 上编译 GNU make 以进行不区分大小写的比较(使用 HAVE_CASE_INSENSITIVE_FS 构建选项)。
当 makefile 中指定的目标也出现在通配符搜索中时,这个特殊情况更容易发生,因为操作系统可能会返回与 makefile 中使用的大小写不同的文件名。目标名称可能会在大小写上有所不同,这可能导致意外的 No rule to make 错误。
内置路径函数和变量
你可以使用内置的 CURDIR 来确定当前工作目录。请注意,CURDIR 会跟随符号链接。如果你在 /foo 目录下,但 /foo 实际上是指向 /somewhere/foo 的符号链接,那么 CURDIR 会报告目录为 /somewhere/foo。如果你需要不跟随符号链接的目录名称,可以使用环境变量 PWD 的值:
CURRENT_DIRECTORY := $(PWD)
但一定要在 makefile 的其他部分更改 PWD 之前获取其值:它可以像从环境导入的任何其他变量一样被修改。
你还可以使用 GNU make 3.80 引入的 MAKEFILE_LIST 变量来找到当前 makefile 存储的目录。在 makefile 开头,可以通过以下方式提取其目录:
CURRENT_MAKEFILE := $(word $(words $(MAKEFILE_LIST)),$(MAKEFILE_LIST))
MAKEFILE_DIRECTORY := $(dir $(CURRENT_MAKEFILE))
GNU make 提供了用于拆分路径为组件的函数:dir、notdir、basename 和 suffix。
假设文件名 /foo/bar/source.c 存储在变量 FILE 中。你可以使用 dir、notdir、basename 和 suffix 函数来提取目录、文件名和后缀。所以,要获取目录,例如,使用 $(dir $(FILE))。表 4-2 显示了这些函数及其结果。
表 4-2. dir、notdir、basename 和 suffix 的结果
| 函数 | 结果 |
|---|---|
dir |
/foo/bar/ |
notdir |
source.c |
basename |
source |
suffix |
.c |
可以看到,目录部分、非目录部分、后缀(或扩展名)以及去除后缀的非目录部分都已被提取。这四个函数使得文件名操作变得简单。如果没有指定目录,GNU make 使用当前目录(./)。例如,假设 FILE 只是 source.c。表 4-3 显示了每个函数的结果。
表 4-3. 没有指定目录时,dir、notdir、basename 和 suffix 的结果
| 函数 | 结果 |
|---|---|
dir |
./ |
notdir |
source.c |
basename |
source |
suffix |
.c |
因为这些函数通常与 GNU make 的自动变量(如 $@)一起使用,所以 GNU make 提供了一种修饰符语法。向任何自动变量添加 D 或 F 等价于在其上调用 $(dir) 或 $(notdir)。例如,$(@D) 等价于 $(dir $@),而 $(@F) 与 $(notdir $@) 相同。
3.81 版本中的有用函数:abspath 和 realpath
realpath 是 GNU make 对 C 库 realpath 函数的封装,它移除 ./、解析 ../、删除重复的 / 并跟随符号链接。realpath 的参数必须存在于文件系统中。realpath 返回的路径是绝对路径。如果路径不存在,该函数将返回一个空字符串。
例如,你可以像这样找到当前目录的完整路径:current := $(realpath ./)。
abspath 类似,但不会跟随符号链接,并且其参数不必指向现有的文件或目录。
乌斯曼定律
make clean 并不一定会清理干净。这就是乌斯曼定律(以我一位聪明的同事命名,他花了几个月时间与真实世界的 makefile 一起工作)。make clean 的目的是恢复到一个可以从头开始重新构建的状态,但往往并不会。继续阅读以了解为什么。
人为因素
OpenSSL makefile 中的 clean 规则如下所示:
clean:
→ rm -f *.o *.obj lib tags core .pure .nfs* *.old *.bak fluff $(EXE)
注意,它是一个长长的、明显由人工维护的目录、模式和文件名列表,这些都需要被删除才能回到干净的状态。人工维护意味着人工错误。假设有人添加了一个规则,创建一个带有固定名称的临时文件。这个临时文件应该添加到 clean 规则中,但它很可能不会被添加。
乌斯曼定律再次出现。
糟糕的命名
这是许多自动生成的 Makefile 中找到的一个代码片段:
mostlyclean::
→ rm -f *.o
clean:: mostlyclean
→ -$(LIBTOOL) --mode=clean rm -f $(program) $(programs)
→ rm -f $(library).a squeeze *.bad *.dvi *.lj
extraclean::
→ rm -f *.aux *.bak *.bbl *.blg *.dvi *.log *.pl *.tfm *.vf *.vpl
→ rm -f *.*pk *.*gf *.mpx *.i *.s *~ *.orig *.rej *\#*
→ rm -f CONTENTS.tex a.out core mfput.* texput.* mpout.*
在这个例子中,三种 clean 似乎有不同的清洁程度:mostlyclean、clean 和 extraclean。
mostlyclean 只是删除从源代码编译的目标文件。clean 做到这一点,并删除生成的库和一些其他文件。你可能会认为 extraclean 会删除比其他两个更多的文件,但它实际上删除的是一组不同的文件。我还见过包含 reallyclean、veryclean、deepclean,甚至 partiallyclean 规则的 Makefile!
当你从命名中无法判断到底是做什么时,它很容易导致未来潜在的问题。
乌斯曼定律再次出现。
静默失败
这是另一个有时能工作的 Makefile 代码片段:
clean:
→ @-rm *.o &> /dev/null
@ 表示命令不会被回显。- 表示忽略返回的任何错误,并且所有输出都会被重定向到 /dev/null,使其不可见。由于 rm 命令上没有 -f 选项,任何失败(例如权限问题)将完全不被注意到。
乌斯曼定律再次出现。
递归清理
许多 Makefile 是递归的,因此 make clean 也必须是递归的,因此你会看到如下模式:
SUBDIRS = library executable
.PHONY: clean
clean:
→ for dir in $(SUBDIRS); do \
→ $(MAKE) -C $$dir clean; \
→ done
这样的问题在于,它意味着 make clean 必须在 SUBDIR 中的每个目录中都能正确工作,从而增加了出错的机会。
乌斯曼定律再次出现。
GNU make 并行化的陷阱与好处
许多构建过程需要运行数小时,因此构建管理人员通常输入 make 命令后就回家睡觉。GNU make 解决这个问题的方法是并行执行:一个简单的命令行选项,指示 GNU make 使用 Makefile 中的依赖信息并行运行任务,确保按正确的顺序执行。
然而,在实践中,GNU make 的并行执行受到一个严重限制,即几乎所有 Makefile 都假设它们的规则将按顺序执行。在编写 Makefile 时,Makefile 的作者很少会考虑并行性。这会导致隐藏的陷阱,导致构建失败并显示致命错误,或者更糟糕的是,构建“成功”但在 GNU make 以并行模式运行时生成不正确的二进制文件。
本节将探讨 GNU make 并行化的陷阱以及如何绕过它们,以获得最大程度的并行性。
使用 -j(或 -jobs)
要以并行模式启动 GNU make,可以在命令行上指定 -j 或 --jobs 选项。该选项的参数是 GNU make 将并行运行的最大进程数。
例如,输入make --jobs=4允许 GNU make同时运行最多四个子进程,这样理论上可以获得 4 倍的加速。然而,理论上的时间被 Makefile 中的限制严重限制。要计算最大实际加速,您可以使用阿姆达尔定律(在阿姆达尔定律与并行化的极限中有讲解)。
在并行 GNU make中发现的一个简单但令人讨厌的问题是,由于作业不再按顺序执行(顺序取决于作业的执行时机),因此 GNU make的输出将根据作业执行的实际顺序随机排序。
幸运的是,这个问题在 GNU make 4.0 中通过--output-sync选项得到了处理,具体内容在第一章中有描述。
考虑 示例 4-9 中的例子:
示例 4-9. 一个简单的 Makefile 来说明并行构建
.PHONY: all
all: t5 t4 t1
→ @echo Making $@
t1: t3 t2
→ touch $@
t2:
→ cp t3 $@
t3:
→ touch $@
t4:
→ touch $@
t5:
→ touch $@
它构建了五个目标:t1、t2、t3、t4 和 t5。除了 t2(它是从 t3 复制的)之外,其他目标都只是简单地进行了触碰。
通过标准的 GNU make运行 示例 4-9,没有并行选项的情况下,会输出如下:
$ **make**
touch t5
touch t4
touch t3
cp t3 t2
touch t1
Making all
执行顺序每次都会相同,因为 GNU make会遵循先处理前置条件的深度优先顺序,并从左到右执行。注意,左到右的执行顺序(例如在all规则中,t5在t4之前构建,t4在t1之前构建)是 POSIX make标准的一部分。
现在如果以并行模式运行make,显然t5、t4和t1可以同时运行,因为它们之间没有依赖关系。同样,t3和t2也不相互依赖,因此它们可以并行执行。
并行运行 示例 4-9 的输出可能是:
$ **make --jobs=16**
touch t4
touch t5
touch t3
cp t3 t2
touch t1
Making all
或者甚至是:
$ **make --jobs=16**
touch t3
cp t3 t2
touch t4
touch t1
touch t5
Making all
这使得任何检查日志文件以检测构建问题的过程(例如比较日志文件)变得困难。不幸的是,在没有--output-sync选项的情况下,GNU make 没有简单的解决方案,因此除非你升级到 GNU make 4.0,否则只能忍受这种情况。
缺少的依赖项
示例 4-9 中的例子有一个额外的问题。作者在编写 Makefile 时陷入了经典的从左到右的陷阱,因此在并行执行时,可能会发生以下情况:
$ **make --jobs=16**
touch t5
touch t4
cp t3 t2
cp: cannot stat `t3': No such file or directory
make: *** [t2] Error 1
原因是,当并行运行时,构建t2的规则可能会先于构建t3的规则,而t2需要t3已经被构建。这在串行情况下没有发生,因为存在从左到右的假设:构建t1的规则是t1: t3 t2,这意味着t3会在t2之前构建。
但是,在 makefile 中并没有明确声明t3必须在t2之前构建。解决方法很简单:只需在 makefile 中添加t2: t3即可。
这是一个简单的例子,展示了当 makefile 并行运行时缺失或隐式(从左到右的)依赖关系所带来的真实问题。如果 makefile 在并行运行时出错,值得立即检查是否缺少依赖关系,因为这些问题非常常见。
隐藏的临时文件问题
GNU make在并行运行时的另一种可能出错的方式是多个规则使用相同的临时文件。考虑示例 4-10 中的 makefile 例子:
示例 4-10. 一个隐藏的临时文件破坏并行构建
TMP_FILE := /tmp/scratch_file
.PHONY: all
all: t
t: t1 t2
→ cat t1 t2 > $@
t1:
→ echo Output from $@ > $(TMP_FILE)
→ cat $(TMP_FILE) > $@
t2:
→ echo Output from $@ > $(TMP_FILE)
→ cat $(TMP_FILE) > $@
在没有并行选项的情况下,GNU make会生成以下输出:
$ **make**
echo Output from t1 > /tmp/scratch_file
cat /tmp/scratch_file > t1
echo Output from t2 > /tmp/scratch_file
cat /tmp/scratch_file > t2
cat t1 t2 > t
并且t文件包含:
Output from t1
Output from t2
但在并行运行时,示例 4-10 会给出以下输出:
$ make --jobs=2
echo Output from t1 > /tmp/scratch_file
echo Output from t2 > /tmp/scratch_file
cat /tmp/scratch_file > t1
cat /tmp/scratch_file > t2
cat t1 t2 > t
现在t包含:
Output from t2
Output from t2
这是因为t1和t2之间没有依赖关系(因为它们都不需要对方的输出),所以它们可以并行运行。在输出中,你可以看到它们是并行运行的,但两个规则的输出是交错的。由于两个echo语句先运行,t2覆盖了t1的输出,因此临时文件(由两个规则共享)在最终执行cat到t1时具有错误的值,导致t的值错误。
这个例子看起来可能有些牵强,但在实际的 makefile 中,当并行运行时,会发生相同的情况,导致构建失败或生成错误的二进制文件。例如,yacc程序会生成名为y.tab.c和y.tab.h的临时文件。如果在同一目录下同时运行多个yacc,错误的文件可能会被错误的进程使用。
对于示例 4-10 中的 makefile,一种简单的解决方案是将TMP_FILE的定义改为TMP_FILE = /tmp/scratch_file.$@,这样其名称就会依赖于正在构建的目标。现在并行运行将如下所示:
$ **make --jobs=2**
echo Output from t1 > /tmp/scratch_file.t1
echo Output from t2 > /tmp/scratch_file.t2
cat /tmp/scratch_file.t1 > t1
cat /tmp/scratch_file.t2 > t2
cat t1 t2 > t
一个相关的问题是,当 makefile 中的多个任务写入共享文件时,即使它们从不读取该文件(例如,它们可能写入日志文件),为了写入访问而锁定文件也会导致竞争的任务停滞,从而降低并行构建的整体性能。
请参考示例 4-11 中的 makefile 示例,它使用lockfile命令锁定一个文件并模拟写锁。尽管文件被锁定,但每个任务会等待若干秒:
示例 4-11。锁定共享文件可能会锁住并行构建,使其以串行方式运行。
LOCK_FILE := lock.me
.PHONY: all
all: t1 t2
→ @echo done.
t1:
→ @lockfile $(LOCK_FILE)
→ @sleep 10
→ @rm -f $(LOCK_FILE)
→ @echo Finished $@
t2:
→ @lockfile $(LOCK_FILE)
→ @sleep 20
→ @rm -f $(LOCK_FILE)
→ @echo Finished $@
在串行构建中运行示例 4-11 大约需要 30 秒:
$ **time make**
Finished t1
Finished t2
done.
make 0.01s user 0.01s system 0% cpu 30.034 total
但是即使t1和t2应该能够并行运行,它在并行中也并不更快:
$ **time make -j4**
Finished t1
Finished t2
done.
make -j4 0.01s user 0.02s system 0% cpu 36.812 total
实际上,这会更慢,因为lockfile检测锁可用性的方法。正如您想象的那样,写锁文件可能会导致在本应支持并行的 makefile 中出现类似的延迟。
与文件锁定问题相关的是有关归档文件(ar文件)的风险。如果多个ar进程同时在同一归档文件上运行,归档文件可能会被损坏。在并行构建中,必须对归档更新进行锁定;否则,您需要防止依赖项同时在同一文件上运行多个ar命令。
防止并行问题的一种方法是在 makefile 中指定.NOTPARALLEL。如果看到该标志,整个make执行将以串行方式运行,-j或--jobs命令行选项将被忽略。.NOTPARALLEL是一个非常直接的工具,因为它会影响整个 GNU make的调用,但在递归的make情境下,它可能会很有用,例如在使用不支持并行的第三方 makefile 时。
正确的递归 make 做法
GNU make足够智能,能够在子 make 之间共享并行性,只要使用$(MAKE)的 makefile 在调用子 make 时小心处理。GNU make具有跨平台的消息传递机制(Windows 支持在 GNU make 4.0 中加入),使得子 make 能够使用通过-j或--jobs指定的所有可用任务,通过管道在make进程之间传递令牌。
唯一需要注意的地方是,您必须以一种实际允许子 make 并行运行的方式编写 makefile。经典的递归make样式使用 shell for循环处理每个子 make,不允许一次运行多个子 make。例如:
SUBDIRS := foo bar baz
.PHONY: all
all:
→ for d in $(SUBDIRS); \
→ do \
→ $(MAKE) –directory=$$d; \
→ done
这个代码有一个大问题:如果子 make 失败,make看起来会像是成功了。虽然可以修复这个问题,但修复方案越来越复杂:其他方法会更好。
在并行模式下运行时,all 规则会遍历每个子目录,并等待其 $(MAKE) 完成。尽管这些子 make 可以并行运行,但整体的 make 并不能并行,这意味着加速比不理想。例如,如果 bar 目录中的 make 一次只能运行四个作业,那么在一台 16 核心机器上运行也不会比在 4 核心机器上更快。
解决方案是移除 for 循环,并为每个目录替换为一个单独的规则:
SUBDIRS := foo bar baz
.PHONY: all $(SUBDIRS)
all: $(SUBDIRS)
$(SUBDIRS):
→ $(MAKE) --directory=$@
每个目录被认为是一个虚拟目标,因为目录本身并不会被实际构建。
现在每个目录都可以在其他目录运行的同时执行,并且并行度达到了最大;甚至可能存在目录间的依赖关系,导致某些子 make 在其他子 make 之前运行。当某个子 make 必须在另一个子 make 之前运行时,目录依赖关系非常有用。
阿姆达尔定律与并行化的极限
此外,项目中的并行化也存在实际限制。查看示例 4-12:
示例 4-12. 使用 sleep 模拟需要时间完成的作业的 makefile
.PHONY: all
all: t
→ @echo done
t: t1 t2 t3 t4 t5 t6 t7 t8 t9 t10 t11 t12
→ @sleep 10
→ @echo Made $@
t1:
→ @sleep 11
→ @echo Made $@
t2:
→ @sleep 4
→ @echo Made $@
t3: t5
→ @sleep 7
→ @echo Made $@
t4:
→ @sleep 9
→ @echo Made $@
t5: t8
→ @sleep 10
→ @echo Made $@
t6:
→ @sleep 2
→ @echo Made $@
t7:
→ @sleep 12
→ @echo Made $@
t8:
→ @sleep 3
→ @echo Made $@
t9: t10
→ @sleep 4
→ @echo Made $@
t10:
→ @sleep 6
→ @echo Made $@
t11: t12
→ @sleep 1
→ @echo Made $@
t12:
→ @sleep 9
→ @echo Made $@
在串行模式下运行时,完成整个过程大约需要 88 秒:
$ **time make**
Made t1
Made t2
Made t8
Made t5
Made t3
Made t4
Made t6
Made t7
Made t10
Made t9
Made t12
Made t11
Made t
done
make 0.04s user 0.03s system 0% cpu 1:28.68 total
假设可以根据需要提供任意数量的 CPU,最大加速比是多少?逐步分析 makefile,你会发现 t 的构建时间为 10 秒,其他所有任务必须在此之前完成。t1、t2、t4、t6 和 t7 都是独立的,其中最长的一个需要 12 秒。t3 等待 t5,而 t5 又依赖于 t8:这条链总共需要 20 秒。t9 依赖于 t10,需要 10 秒,t11 依赖于 t12,也需要 10 秒。
因此,这次构建的最长串行部分是 t、t3、t5、t8 的顺序,总共需要 30 秒。这次构建的速度永远不会超过 30 秒(或者说是比串行的 88 秒快 2.93 倍)。需要多少处理器才能达到这种加速?
通常,最大加速比由阿姆达尔定律控制:如果 F 是无法并行化的构建部分的比例,N 是可用处理器的数量,那么最大加速比为 1 / ( F + ( 1 - F ) / N )。
在示例 4-12 中,34% 的构建无法并行化。表 4-4 显示了应用阿姆达尔定律的结果:
表 4-4. 基于处理器数量的最大加速比
| 处理器数量 | 最大加速比 |
|---|---|
| 2 | 1.49x |
| 3 | 1.79x |
| 4 | 1.98x |
| 5 | 2.12x |
| 6 | 2.22x |
| 7 | 2.30x |
| 8 | 2.37x |
| 9 | 2.42x |
| 10 | 2.46x |
| 11 | 2.50x |
| 12 | 2.53x |
对于这个小型构建,Amdahl 定律预测的最大加速比在大约八个处理器时达到平台期。实际的加速平台期进一步受到限制,因为构建中只有 13 个可能的任务。
查看构建的结构,我们可以看到最多使用八个处理器,因为五个任务可以并行运行且没有任何依赖关系:t1、t2、t4、t6 和 t7。然后三个小任务链可以各自使用一个处理器:t3、t5 和 t8;t9 和 t10;以及 t11 和 t12。构建 t 时可以重用其中一个空闲的处理器,因为到那时所有的处理器都将处于空闲状态。
Amdahl 定律在构建时间上的实际影响,通常出现在具有链接步骤的语言中,比如 C 和 C++。通常,所有目标文件在链接步骤之前就已构建完毕,然后会进行一个单独的(通常非常大的)链接过程。这个链接过程通常无法并行化,成为构建并行化的限制因素。
使 $(wildcard) 递归
内建的 $(wildcard) 函数不是递归的:它仅在单个目录中查找文件。你可以在 $(wildcard) 中使用多个通配符模式,并利用这些模式查找子目录中的文件。例如,$(wildcard */*.c) 可以查找当前目录下所有子目录中的 .c 文件。但如果你需要在任意目录树中进行查找,则没有内建的方法来实现。
幸运的是,创建一个递归版本的 $(wildcard) 非常简单,如下所示:
rwildcard=$(foreach d,$(wildcard $1*),$(call rwildcard,$d/,$2) $(filter $(subst *,%,$2),$d))
rwildcard 函数接受两个参数:第一个是开始查找的目录(此参数可以为空,表示从当前目录开始查找),第二个是要在每个目录中查找的文件的通配符模式。
例如,要查找当前目录(以及其子目录)下的所有 .c 文件,可以使用以下命令:
$(call rwildcard,,*.c)
或者要查找 /tmp 下的所有 .c 文件,可以使用以下命令:
$(call rwildcard,/tmp/,*.c)
rwildcard 也支持多个模式。例如:
$(call rwildcard,/src/,*.c *.h)
这将查找 /src/ 下的所有 .c 和 .h 文件。
我在哪个 Makefile 中?
一个常见的问题是:有没有方法查找当前 makefile 的名称和路径?通常所说的“当前”是指 GNU make 当前正在解析的 makefile。没有内建的快捷方法来获取答案,但可以通过使用 GNU make 变量 MAKEFILE_LIST 来实现。
MAKEFILE_LIST 是当前加载或 include 的 makefile 列表。每次加载或 include 一个 makefile 时,MAKEFILE_LIST 会将其路径和名称添加到列表中。变量中的路径和名称是相对于当前工作目录的(即 GNU make 启动时所在的目录,或者通过 -C 或 --directory 选项更改的目录),但你可以通过 CURDIR 变量访问当前目录。
所以,使用这个方法,你可以定义一个 GNU make 函数(我们称之为 where-am-i),它将返回当前的 makefile(它使用 $(word) 从列表中获取最后一个 makefile 的名称)。
where-am-i = $(CURDIR)/$(word $(words $(MAKEFILE_LIST)),$(MAKEFILE_LIST))
然后,每当你想要查找当前 makefile 的完整路径时,只需在文件顶部写入以下内容:
THIS_MAKEFILE := $(call where-am-i)
这行代码放在文件顶部非常重要,因为任何 include 语句都会改变 MAKEFILE_LIST 的值,因此你希望在发生改变之前先获取当前 makefile 的位置。
示例 4-13 展示了一个使用 where-am-i 并从 foo/ 子目录中包含另一个 makefile 的示例,而该 makefile 又包含了来自 foo/bar/ 目录的 makefile。
示例 4-13. 一个可以确定其在文件系统中位置的 makefile
where-am-i = $(CURDIR)/$(word ($words $(MAKEFILE_LIST)),$(MAKEFILE_LIST)
include foo/makefile
foo/makefile 的内容见 示例 4-14。
示例 4-14. 一个由示例 4-13 包含的 makefile
THIS_MAKEFILE := $(call where-am-i)
$(warning $(THIS_MAKEFILE))
include foo/bar/makefile
foo/bar/makefile 的内容见 示例 4-15。
示例 4-15. 一个由示例 4-14 包含的 makefile
THIS_MAKEFILE := $(call where-am-i)
$(warning $(THIS_MAKEFILE))
将 示例 4-13、示例 4-14 和 示例 4-15 的三个 makefile 放在 /tmp(及其子目录)下,并运行 GNU make,会得到如下输出:
foo/makefile:2: /tmp/foo/makefile
foo/bar/makefile:2: /tmp/foo/bar/makefile
在本章中,我们探讨了 makefile 创建者和维护者在实际工作中常遇到的一些问题。在任何一个使用make的大型项目中,你很可能会遇到一个或多个(甚至全部!)这样的问题。
第五章 推动极限
在本章中,你会发现一些通常不需要的技巧,但有时它们会非常有用。例如,有时通过在 C 语言或甚至 Guile 中创建新函数来扩展 GNU make 的语言是非常有用的。本章将展示如何做到这一点以及更多内容。
做算术
GNU make 没有内建的算术功能。但我们可以为整数的加法、减法、乘法和除法创建函数。还可以为整数比较(例如大于、不等于)创建函数。这些函数完全通过 GNU make 的内建列表和字符串处理函数实现:$(subst)、$(filter)、$(filter-out)、$(words)、$(wordlist)、$(call)、$(foreach) 和 $(if)。在定义完我们的算术函数后,我们将实现一个简单的计算器。
要创建一个算术库,我们首先需要一个数字的表示法。表示一个数字的简单方法是使用包含该数量项的列表。例如,对于算术库,数字就是由字母 x 组成的列表。因此,数字 5 表示为 x x x x x。
给定这种表示法,我们可以使用 $(words) 函数将内部形式(所有 x)转换为人类可读的形式。例如,以下代码将输出 5:
five := x x x x x
all: ; @echo $(words $(five))
让我们创建一个用户自定义函数 decode,将 x 表示法转换为数字:
decode = $(words $1)
在 Makefile 中使用 decode,我们需要使用 GNU make 的 $(call) 函数,它可以通过一组参数调用用户自定义的函数:
five := x x x x x
all: ; @echo $(call decode,$(five))
参数将存储在名为 $1、$2、$3 等临时变量中。在 decode 中,它接受一个参数——即要解码的数字——我们只需使用 $1。
加法与减法
现在我们已经有了表示法,可以定义加法、增量(加 1)和减量(减 1)的函数:
plus = $1 $2
increment = x $1
decrement = $(wordlist 2,$(words $1),$1)
plus 函数将其两个参数组成一个列表;通过连接字符串就能实现带有 x 表示法的加法运算。increment 函数向其参数中添加一个单独的 x。decrement 函数通过从索引 2 开始请求整个由 x 组成的字符串,去除其参数中的第一个 x。例如,以下代码将输出 11:
two := x x
three := x x x
four := x x x x
five := x x x x x
six := x x x x x x
all: ; @echo $(call decode,$(call plus,$(five),$(six)))
注意在 decode 调用中的 plus 函数的嵌套调用,以便我们输出数字 11,而不是由 11 个 x 组成的列表。
我们可以创建另一个简单的函数 double,它将参数翻倍:
double = $1 $1
实现减法比加法更具挑战性。但在我们进行减法实现之前,先来实现 max 和 min 函数:
max = $(subst xx,x,$(join $1,$2))
min = $(subst xx,x,$(filter xx,$(join $1,$2)))
max 函数使用了两个 GNU make 内建函数:$(join) 和 $(subst)。$(join LIST1,LIST2) 将两个列表作为参数,按顺序连接两个列表:将 LIST1 的第一个元素与 LIST2 的第一个元素连接,依此类推。如果一个列表比另一个长,剩余的项将直接附加到列表的末尾。
$(subst FROM,TO,LIST)遍历一个列表,并将匹配FROM模式的元素替换为TO值。为了了解max如何工作,考虑在计算$(call max,$(five),$(six))时发生的事件顺序:
$(call max,$(five),$(six))
→ $(call max,x x x x x,x x x x x x)
→ $(subst xx,x,$(join x x x x x,x x x x x x))
→ $(subst xx,x,xx xx xx xx xx x)
→ x x x x x x
首先,$(join)将包含五个x的列表与包含六个x的列表连接,结果是一个包含六个元素的列表,其中前五个是xx。然后,$(subst)将前五个xx转换为x。最终结果是六个x,这是最大值。
为了实现min,我们使用类似的技巧,但我们只保留xx并丢弃x:
$(call min,$(five),$(six))
→ $(call min,x x x x x,x x x x x x)
→ $(subst xx,x,$(filter xx,$(join x x x x x,x x x x x x)))
→ $(subst xx,x,$(filter xx,xx xx xx xx xx x))
→ $(subst xx,x,xx xx xx xx xx)
→ x x x x x
xx表示两个列表可能连接的位置。较短的列表只会包含xx。$(filter PATTERN,LIST)函数会遍历列表并移除与模式不匹配的元素。
类似的模式适用于减法操作:
subtract = $(if $(call gte,$1,$2), \
$(filter-out xx,$(join $1,$2)), \
$(warning Subtraction underflow))
暂时忽略定义中的$(warning)和$(if)部分,专注于$(filter-out)。$(filter-out)是$(filter)的反操作:它从列表中移除与模式匹配的元素。例如,我们可以看到这里的$(filter-out)实现了减法操作:
$(filter-out xx,$(join $(six),$(five)))
→ $(filter-out xx,$(join x x x x x x,x x x x x))
→ $(filter-out xx,xx xx xx xx xx x)
→ x
不幸的是,如果将五和六的位置反转,这种方法也会奏效,因此我们首先需要检查第一个参数是否大于或等于第二个参数。在subtract定义中,特殊函数gte(大于或等于)将在第一个参数大于第二个参数时返回一个非空字符串。我们使用gte来决定是否进行减法操作或使用$(warning)输出警告消息。
gte函数是通过两个其他函数来实现的,分别用于大于(gt)和等于(eq):
gt = $(filter-out $(words $2),$(words $(call max,$1,$2)))
eq = $(filter $(words $1),$(words $2))
gte = $(call gt,$1,$2)$(call eq,$1,$2)
如果gt或eq返回非空字符串,gte将返回一个非空字符串。
eq函数有点让人费解。它计算其两个参数中元素的数量,将一个参数视为模式,另一个作为列表,并使用$(filter)来判断它们是否相同。以下是一个它们相等的例子:
$(call eq,$(five),$(five))
→ $(call eq,x x x x x,x x x x x)
→ $(filter $(words x x x x x),$(words x x x x x))
→ $(filter 5,5)
→ 5
eq函数将两个$(five)都转换为由五个x组成的列表。然后,这些列表都通过$(words)转换为数字 5。将这两个 5 传入$(filter)。由于$(filter)的两个参数相同,结果是 5,而 5 不是空字符串,因此它被解释为真。
当它们不相等时,发生了以下情况:
$(call eq,$(five),$(six))
→ $(call eq,x x x x x,x x x x x x)
→ $(filter $(words x x x x x),$(words x x x x x x))
→ $(filter 5,6)
这与$(call eq,$(five),$(five))的过程类似,只不过用$(six)替换了其中一个$(five)。由于$(filter 5,6)是一个空字符串,结果为假。
因此,$(filter)函数充当了一种字符串相等运算符;在我们的例子中,两个字符串分别是两个数字字符串的长度。gt函数的实现方式类似:如果第一个数字字符串的长度不等于两个数字字符串中的最大值,它返回一个非空字符串。下面是一个例子:
$(call gt,$(six),$(five))
→ $(call gt,x x x x x x,x x x x x)
→ $(filter-out $(words x x x x x),
$(words $(call max,x x x x x x,x x x x x)))
→ $(filter-out $(words x x x x x),$(words x x x x x x))
→ $(filter-out 5,6)
→ 6
gt 函数的工作方式与 eq(前面描述的)类似,但使用 $(filter-out) 而不是 $(filter)。它将两个 x 表示的数字转换为数字,但使用 $(filter-out) 比较它们中的第一个与两者的最大值。当第一个数字大于第二个时,两个不同的数字会传递给 $(filter-out)。由于它们不同,$(filter-out) 会返回一个非空字符串,表示真。
这里有一个例子,其中第一个数字小于第二个:
$(call gt,$(five),$(six))
→ $(call gt,x x x x x,x x x x x x)
→ $(filter-out $(words x x x x x x),
$(words $(call max,x x x x x x,x x x x x)))
→ $(filter-out $(words x x x x x x),$(words x x x x x x))
→ $(filter-out 6,6)
这里,因为两个数字的 max 与第二个数字相同(因为它是最大的),所以 $(filter-out) 被传入相同的数字并返回一个空字符串,表示假。
类似地,我们可以定义 不等于 (ne),小于 (lt),和 小于或等于 (lte) 操作符:
lt = $(filter-out $(words $1),$(words $(call max,$1,$2)))
ne = $(filter-out $(words $1),$(words $2))
lte = $(call lt,$1,$2)$(call eq,$1,$2)
lte 是通过 lt 和 eq 定义的。因为非空字符串意味着 真,所以 lte 只会将 lt 和 eq 返回的值连接起来;如果其中任何一个返回真,那么 lte 就返回真。
乘法和除法
在我们定义了另外三个函数:multiply、divide 和 encode 后,我们将拥有一个非常强大的算术包。encode 是将一个整数转化为一串 x 字符的方式;我们将把它留到最后,并实现我们的计算器。
乘法使用 $(foreach VAR,LIST,DO) 函数。它将名为 VAR 的变量设置为 LIST 中的每个元素,并执行 DO 中指定的操作。因此,乘法很容易实现:
multiply = $(foreach a,$1,$2)
multiply 将其第二个参数与第一个参数中有多少个 x 字符拼接在一起。例如:
$(call multiply,$(two),$(three))
→ $(call multiply,x x,x x x)
→ $(foreach a,x x,x x x)
→ x x x x x x
divide 是其中最复杂的函数,因为它需要递归:
divide = $(if $(call gte,$1,$2), \
x $(call divide,$(call subtract,$1,$2),$2),)
如果第一个参数小于第二个,除法将返回 0,因为 $(if) 的 ELSE 部分是空的(见结尾的 ,))。如果可以进行除法,divide 会通过从第一个参数中反复减去第二个参数来工作,使用 subtract 函数。每次减去时,它会添加一个 x 并再次调用 divide。以下是一个例子:
$(call divide,$(three),$(two))
→ $(call divide,x x x,x x)
→ $(if $(call gte,x x x,x x),
x $(call divide,$(call subtract,x x x,x x),x x),)
→ x $(call divide,$(call subtract,x x x,x x),x x)
→ x $(call divide,x,x x)
→ x $(if $(call gte,x,x x),
x $(call divide,$(call subtract,x,x x),x x),)
→ x
首先,gte 返回一个非空字符串,因此会发生递归。接下来,gte 返回一个空字符串,因此不会再发生递归。
我们可以通过在除以 2 的特殊情况下避免递归;我们定义 halve 函数,它是 double 的反操作:
halve = $(subst xx,x, \
$(filter-out xy x y, \
$(join $1,$(foreach a,$1,y x))))
到现在为止,你已经看过 halve 中使用的所有函数。通过一个例子,假设 $(call halve,$(five)),来查看它是如何工作的。
唯一需要注意的事情是将用户输入的数字转换成一串 x 字符。encode 函数通过从预定义的 x 字符串中删除一个子串来完成这项工作:
16 := x x x x x x x x x x x x x x x x
input_int := $(foreach a,$(16), \
$(foreach b,$(16), \
$(foreach c,$(16),$(16)))))
encode = $(wordlist 1,$1,$(input_int))
在这里,我们限制了最多输入到 65536。我们可以通过改变 input_int 中的 x 数量来解决这个问题。一旦我们得到了编码中的数字,只有可用的内存限制了我们可以处理的整数大小。
使用我们的算术库:一个计算器
为了真正展示这个库,下面是一个完全用 GNU make 函数编写的逆波兰表示法计算器实现:
stack :=
push = $(eval stack := $$1 $(stack))
pop = $(word 1,$(stack))$(eval stack := $(wordlist 2,$(words $(stack)),$(stack)))
pope = $(call encode,$(call pop))
pushd = $(call push,$(call decode,$1))
comma := ,
calculate = $(foreach t,$(subst $(comma), ,$1),$(call handle,$t))$(stack)
seq = $(filter $1,$2)
handle = $(call pushd, \
$(if $(call seq,+,$1), \
$(call plus,$(call pope),$(call pope)), \
$(if $(call seq,-,$1), \
$(call subtract,$(call pope),$(call pope)), \
$(if $(call seq,*,$1), \
$(call multiply,$(call pope),$(call pope)), \
$(if $(call seq,/,$1), \
$(call divide,$(call pope),$(call pope)), \
$(call encode,$1))))))
.PHONY: calc
calc: ; @echo $(call calculate,$(calc))
操作符和数字被传递到 GNU make 中的 calc 变量中,且通过逗号分隔。例如:
$ **make calc="3,1,-,3,21,5,*,+,/"**
54
显然,这不是 GNU make 的设计初衷,但它展示了 GNU make 函数的强大功能。下面是完整的注释版 makefile:
# input_int consists of 65536 x's built from the 16 x's in 16
16 := x x x x x x x x x x x x x x x x
input_int := $(foreach a,$(16),$(foreach b,$(16),$(foreach c,$(16),$(16)))))
# decode turns a number in x's representation into an integer for human
# consumption
decode = $(words $1)
# encode takes an integer and returns the appropriate x's
# representation of the number by chopping $1 x's from the start of
# input_int
encode = $(wordlist 1,$1,$(input_int))
# plus adds its two arguments, subtract subtracts its second argument
# from its first if and only if this would not result in a negative result
plus = $1 $2
subtract = $(if $(call gte,$1,$2), \
$(filter-out xx,$(join $1,$2)), \
$(warning Subtraction underflow))
# multiply multiplies its two arguments and divide divides its first
# argument by its second
multiply = $(foreach a,$1,$2)
divide = $(if $(call gte,$1,$2),x $(call divide,$(call subtract,$1,$2),$2),)
# max returns the maximum of its arguments and min the minimum
max = $(subst xx,x,$(join $1,$2))
min = $(subst xx,x,$(filter xx,$(join $1,$2)))
# The following operators return a non-empty string if their result is true:
#
# gt First argument is greater than second argument
# gte First argument is greater than or equal to second argument
# lt First argument is less than second argument
# lte First argument is less than or equal to second argument
# eq First argument is numerically equal to the second argument
# ne First argument is not numerically equal to the second argument
gt = $(filter-out $(words $2),$(words $(call max,$1,$2)))
lt = $(filter-out $(words $1),$(words $(call max,$1,$2)))
eq = $(filter $(words $1),$(words $2))
ne = $(filter-out $(words $1),$(words $2))
gte = $(call gt,$1,$2)$(call eq,$1,$2)
lte = $(call lt,$1,$2)$(call eq,$1,$2)
# increment adds 1 to its argument, decrement subtracts 1\. Note that
# decrement does not range check and hence will not underflow, but
# will incorrectly say that 0 - 1 = 0
increment = $1 x
decrement = $(wordlist 2,$(words $1),$1)
# double doubles its argument, and halve halves it
double = $1 $1
halve = $(subst xx,x,$(filter-out xy x y,$(join $1,$(foreach a,$1,y x))))
# This code implements a Reverse Polish Notation calculator by
# transforming a comma-separated list of operators (+ - * /) and
# numbers stored in the calc variable into the appropriate calls to
# the arithmetic functions defined in this makefile.
# This is the current stack of numbers entered into the calculator. The push
# function puts an item onto the top of the stack (the start of the list), and
# pop removes the top item.
stack :=
push = $(eval stack := $$1 $(stack))
pop = $(word 1,$(stack))$(eval stack := $(wordlist 2,$(words $(stack)),$(stack)))
# pope pops a number off the stack and encodes it
# and pushd pushes a number onto the stack after decoding
pope = $(call encode,$(call pop))
pushd = $(call push,$(call decode,$1))
# calculate runs through the input numbers and operations and either
# pushes a number on the stack or pops two numbers off and does a
# calculation followed by pushing the result back. When calculate is
# finished, there will be one item on the stack, which is the result.
comma := ,
calculate=$(foreach t,$(subst $(comma), ,$1),$(call handle,$t))$(stack)
# seq is a string equality operator that returns true (a non-empty
# string) if the two strings are equal
seq = $(filter $1,$2)
# handle is used by calculate to handle a single token. If it's an
# operator, the appropriate operator function is called; if it's a
# number, it is pushed.
handle = $(call pushd, \
$(if $(call seq,+,$1), \
$(call plus,$(call pope),$(call pope)), \
$(if $(call seq,-,$1), \
$(call subtract,$(call pope),$(call pope)), \
$(if $(call seq,*,$1), \
$(call multiply,$(call pope),$(call pope)), \
$(if $(call seq,/,$1), \
$(call divide,$(call pope),$(call pope)), \
$(call encode,$1))))))
.PHONY: calc
calc: ; @echo $(call calculate,$(calc))
你将在第六章中更详细地了解这些技术,当你学习 GNU Make 标准库时。
制作 XML 材料清单
使用标准的 GNU make 输出,很难回答构建了什么以及为什么。这一节介绍了一种简单的技术,可以让 GNU make 创建一个包含材料清单(BOM)的 XML 文件。BOM 包含了 makefile 构建的所有文件的名称,并且通过嵌套结构显示每个文件的先决条件。
示例 Makefile 和 BOM
示例 5-1 展示了一个示例 makefile。我们将查看其 BOM,然后反向追溯,以了解 BOM JSON 文件是如何生成的。
示例 5-1. 一个简单的 makefile 用来说明 BOM
all: foo bar
→ @echo Making $@
foo: baz
→ @echo Making $@
bar:
→ @echo Making $@
baz:
→ @echo Making $@
这个示例生成 all,由 foo 和 bar 构成。反过来,foo 是由 baz 构成的。在 GNU make 中运行这段代码,会产生如下输出:
$ **make**
Making baz
Making foo
Making bar
Making all
从输出中,无法识别构建的树形顺序或哪些文件依赖于哪些文件。在这种情况下,makefile 很小,手动追踪相对容易;但在实际的 makefile 中,手动追踪几乎是不可能的。
生成如示例 5-2 中所示的输出是很好的,它展示了构建了什么以及为什么:
示例 5-2. 一个展示示例 makefile 结构的 XML 文档
<rule target="all">
<prereq>
<rule target="foo">
<prereq>
<rule target="baz" />
</prereq>
</rule>
<rule target="bar" />
</prereq>
</rule>
在这里,每个由 makefile 执行的规则都添加了一个 <rule> 标签,并通过 target 属性给出了该规则构建的目标名称。如果规则有任何先决条件,在 <rule>/</rule> 标签对中,会用 <prereq>/</prereq> 标签包围一个先决条件规则的列表。
你可以看到 makefile 的结构通过标签的嵌套反映出来。将 XML 文档加载到 XML 编辑器中(或者直接加载到网页浏览器)可以让你自由地展开和收缩树形结构,以探索 makefile 的结构。
工作原理
要创建如示例 5-2 中所示的输出,示例 makefile 被修改,加入了一个使用标准 include bom 方法的特殊 bom makefile。加入后,我们可以通过运行 GNU make 使用命令行,如 make bom-all 来生成 XML 输出。
bom-all指示 GNU make从all目标开始构建 BOM。就像你输入了make all,但现在将创建一个 XML 文档。
默认情况下,XML 文档的名称与 makefile 相同,但附加.xml。如果示例 makefile 是example.mk,则创建的 XML 文档将被命名为example.mk.xml。
示例 5-3 显示了包含bom makefile 的内容:
示例 5-3. 创建 XML BOM 的bom makefile
➊ PARENT_MAKEFILE := $(word $(words $(MAKEFILE_LIST)),x $(MAKEFILE_LIST))
➋ bom-file := $(PARENT_MAKEFILE).xml
➌ bom-old-shell := $(SHELL)
➍ SHELL = $(bom-run)$(bom-old-shell)
bom-%: %
➎ → @$(shell rm -f $(bom-file))$(call bom-dump,$*)
bom-write = $(shell echo '$1' >> $(bom-file))
➏ bom-dump = $(if $(bom-prereq-$1),$(call bom-write,<rule target="$1">) \
$(call bom-write,<prereq>)$(foreach p,$(bom-prereq-$1), \
$(call bom-dump,$p))$(call bom-write,</prereq>)$(call bom-write,</rule>), \
$(call bom-write,<rule target="$1" />))
➐ bom-run = $(if $@,$(eval bom-prereq-$@ := $^))
首先,我们通过提取包含bom的 makefile 的名称到PARENT_MAKEFILE ➊,将.xml附加到该名称上,并将结果存储在bom-file ➋中,从而确定 XML 文件的正确名称。
然后我们使用本书中多次出现的一个技巧:SHELL黑客。GNU make将在执行 makefile 中的每条规则时展开$(SHELL)的值。当$(SHELL)被展开时,每条规则的自动变量(如$@)已经被设置。因此,通过修改SHELL,我们可以在每条规则执行时为 makefile 中的每个规则执行某些任务。
在➌处,我们使用立即赋值(:=)将SHELL的原始值存储在bom-old-shell中,然后在➍处重新定义SHELL为$(bom-run)的展开值和原始 shell。因为$(bom-run)实际上展开为空字符串,所以其效果是bom-run在 makefile 的每条规则中展开,但实际使用的 shell 不受影响。
bom-run在➐处定义。它使用$(eval)存储当前正在构建的目标($(if)确保$@已定义)与其前提条件之间的关系。例如,当构建foo时,将调用bom-run,并将$@设置为foo,$^(所有前提条件的列表)设置为baz。bom-run将bom-prereq-foo的值设置为baz。稍后,这些bom-prereq-X变量的值将用于打印 XML 树。
在➎处,我们定义了处理bom-%目标的模式规则。由于bom-%的前提条件是%,因此这个模式规则的效果是构建与%匹配的目标,然后构建bom-%。在我们的例子中,运行make bom-all会与这个模式规则匹配,先构建all,然后运行与bom-%关联的命令,$*设置为all。
bom-%的命令首先删除bom-file,然后从$*开始递归地转储 XML。在这个例子中,当用户执行make bom-all时,bom-%的命令会调用bom-dump,并传递参数all。
我们在➏处定义了bom-dump。它相当常规:它使用一个辅助函数bom-write将 XML 片段回显到bom-file,并为每个它正在转储的目标的前提条件中的每个目标调用自身。前提条件是从bom-run创建的bom-prereq-X变量中提取的。
注意事项
示例 5-3 中的技术有一些坑。一个坑是,这种技术最终可能会产生大量输出。这是因为它会打印任何目标下的整个树。如果一个目标在树中多次出现,那么即使是小项目,XML 的转储时间也会很长。
作为一种变通方法,我们可以修改 bom-dump 的定义,只为每个目标转储一次先决条件信息。这比示例 5-3 中的方法要快得多,并且可以通过像以下这样的脚本来处理,以帮助理解 make 的结构:
bom-%: %
→ @$(shell rm -f $(bom-file))$(call bom-write,<bom>)$(call bom-dump,$*)$(call bom-write,</bom>)
bom-write = $(shell echo '$1' >> $(bom-file))
bom-dump = $(if $(bom-prereq-$1),$(call bom-write,<rule target="$1">) \
$(call bom-write,<prereq>)$(foreach p,$(bom-prereq-$1), \
$(call bom-write,<rule target="$p" />))$(call bom-write,</prereq>) \
$(call bom-write,</rule>),$(call bom-write,<rule target="$1" />)) \
$(foreach p,$(bom-prereq-$1),$(call bom-dump,$p))$(eval bom-prereq-$1 := )
在示例 5-1 中的示例 makefile,XML 文档现在如下所示:
<bom>
<rule target="all">
<prereq>
<rule target="foo" />
<rule target="bar" />
</prereq>
</rule>
<rule target="foo">
<prereq>
<rule target="baz" />
</prereq>
</rule>
<rule target="baz" />
<rule target="bar" />
</bom>
另一个坑是,如果 makefile 包含没有命令的规则,这些规则会导致示例 5-3 中的技术输出的树中断。例如,如果示例 makefile 如下:
all: foo bar
→ @echo Making $@
foo: baz
bar:
→ @echo Making $@
baz:
→ @echo Making $@
生成的 XML 完全不会提到 baz,因为 foo 的规则没有任何命令。因此 SHELL 不会被展开,黑客方法无法生效。以下是这种情况下的 XML:
<bom>
<rule target="all">
<prereq>
<rule target="foo" />
<rule target="bar" />
</prereq>
</rule>
<rule target="foo" />
<rule target="bar" />
</bom>
作为一种变通方法,我们可以修改 foo: baz,并为其添加一个无用的命令:
foo: baz ; @true
现在将生成正确的结果。
高级用户定义函数
在第一章中,我们讨论了在 GNU make 中创建用户定义的函数。现在我们将深入了解 GNU make 源代码,看看如何通过编写 C 代码来增强 GNU make,使其支持我们自己的内建函数。
首先,我们从自由软件基金会获取 GNU make 源代码。对于这一部分,我使用的是 GNU make 3.81。对于 GNU make 3.82 或 4.0,变化不大。
下载 make-3.81.tar.gz,然后使用 gunzip 和 untar 解压,再使用标准的 configure 和 make 构建 GNU make:
$ **cd make-3.81**
$ **./configure**
$ **make**
完成之后,我们得到一个在同一目录下工作的 GNU make。
开始修改 GNU make
能够知道自己正在运行哪个版本的 GNU make 非常方便。因此,作为第一次修改,我们将更改打印版本信息时显示的消息。默认信息如下:
$ **./make -v**
GNU Make 3.81
Copyright (C) 2006 Free Software Foundation, Inc.
This is free software; see the source for copying conditions.
There is NO warranty; not even for MERCHANTABILITY or FITNESS FOR A
PARTICULAR PURPOSE.
This program built for i386-apple-darwin9.2.0
正如你所看到的,我正在使用 Mac(该字符串会根据你使用的机器有所不同),并且使用的是 GNU make 版本 3.81。
让我们更改该消息,使其在版本号后打印 (with jgc's modifications)。为此,我们需要在文本编辑器中打开 main.c 文件,找到 print_version 函数(位于第 2,922 行),它看起来像这样:
/* Print version information. */
static void
print_version (void)
{
static int printed_version = 0;
char *precede = print_data_base_flag ? "# " : "";
if (printed_version)
/* Do it only once. */
return;
/* Print this untranslated. The coding standards recommend translating the
(C) to the copyright symbol, but this string is going to change every
year, and none of the rest of it should be translated (including the
word "Copyright", so it hardly seems worth it. */
printf ("%sGNU Make %s\n\
%sCopyright (C) 2006 Free Software Foundation, Inc.\n",
precede, version_string, precede);
printf (_("%sThis is free software; see the source for copying conditions.\n\
%sThere is NO warranty; not even for MERCHANTABILITY or FITNESS FOR A\n\
%sPARTICULAR PURPOSE.\n"),
precede, precede, precede);
if (!remote_description || *remote_description == '\0')
printf (_("\n%sThis program built for %s\n"), precede, make_host);
else
printf (_("\n%sThis program built for %s (%s)\n"),
precede, make_host, remote_description);
printed_version = 1;
/* Flush stdout so the user doesn't have to wait to see the
version information while things are thought about. */
fflush (stdout);
}
print_version 中的第一个 printf 是打印版本号的地方。我们可以像这样修改它:
printf ("%sGNU Make %s (with jgc's modifications)\n\
%sCopyright (C) 2006 Free Software Foundation, Inc.\n",
precede, version_string, precede);
保存文件后,再次运行 make。现在输入 make -v:
$ **./make -v**
GNU Make 3.81 (with jgc's modifications)
Copyright (C) 2006 Free Software Foundation, Inc.
This is free software; see the source for copying conditions.
There is NO warranty; not even for MERCHANTABILITY or FITNESS FOR A
PARTICULAR PURPOSE.
This program built for i386-apple-darwin9.2.0
现在我们知道我们正在使用哪个版本。
内置函数的结构
GNU make 的内置函数在 function.c 文件中定义。为了开始理解这个文件如何工作,可以查看 GNU make 知道的函数表。这个表叫做 function_table_init[],位于第 2,046 行。由于这个表比较大,我删除了其中的一些行:
static struct function_table_entry function_table_init[] =
{
/* Name/size */ /* MIN MAX EXP? Function */
{ STRING_SIZE_TUPLE("abspath"), 0, 1, 1, func_abspath},
{ STRING_SIZE_TUPLE("addprefix"), 2, 2, 1,
func_addsuffix_addprefix},
{ STRING_SIZE_TUPLE("addsuffix"), 2, 2, 1,
func_addsuffix_addprefix},
{ STRING_SIZE_TUPLE("basename"), 0, 1, 1, func_basename_dir},
{ STRING_SIZE_TUPLE("dir"), 0, 1, 1, func_basename_dir},
--*snip*--
{ STRING_SIZE_TUPLE("value"), 0, 1, 1, func_value},
{ STRING_SIZE_TUPLE("eval"), 0, 1, 1, func_eval},
#ifdef EXPERIMENTAL
{ STRING_SIZE_TUPLE("eq"), 2, 2, 1, func_eq},
{ STRING_SIZE_TUPLE("not"), 0, 1, 1, func_not},
#endif
};
每一行都定义了一个单独的函数,并包含五个信息项:函数的名称、函数必须具有的最小参数数量、最大参数数量(指定最小值非零且最大值为零,意味着该函数可以有无限数量的参数)、参数是否需要展开,以及实际执行函数的 C 函数的名称。
例如,下面是 findstring 函数的定义:
{ STRING_SIZE_TUPLE("findstring"), 2, 2, 1, func_findstring},
findstring 至少需要两个参数,最多两个参数,并且在调用 C 函数func_findstring之前,参数应当被展开。func_findstring(位于 function.c 文件的第 819 行)完成实际的工作:
static char*
func_findstring (char *o, char **argv, const char *funcname UNUSED)
{
/* Find the first occurrence of the first string in the second. */
if (strstr (argv[1], argv[0]) != 0)
o = variable_buffer_output (o, argv[0], strlen (argv[0]));
return o;
}
实现 GNU make 内置函数的 C 函数有三个参数:o(指向一个缓冲区的指针,函数的输出将写入该缓冲区),argv(函数的参数,作为一个以 null 结尾的字符串数组),以及 funcname(一个包含函数名称的字符串;大多数函数不需要这个,但如果一个 C 函数处理多个 GNU make 函数时,它会很有用)。
你可以看到,func_findstring 仅使用标准 C 库中的 strstr 函数来查找第二个参数(在 argv[1] 中)是否出现在第一个参数中(在 argv[0] 中)。
func_findstring 使用了一个方便的 GNU make C 函数,名为 variable_buffer_output(定义在 expand.c 文件的第 57 行)。variable_buffer_output 将一个字符串复制到 GNU make 函数的输出缓冲区 o 中。第一个参数应该是输出缓冲区,第二个是要复制的字符串,最后一个是要复制的字符串的长度。
func_findstring 要么将第一个参数的全部内容复制到输出缓冲区 o 中(如果 strstr 成功),要么不改变 o(因此,o 将保持为空,因为它在调用 func_findstring 之前被初始化为空字符串)。
有了这些信息,我们就有足够的基础开始编写我们自己的 GNU make 函数了。
反转一个字符串
在 GNU make 中没有直接的方法来反转一个字符串,但编写一个 C 函数来实现这一点并将其插入到 GNU make 中是很容易的。
首先,我们将把 reverse 的定义添加到 GNU make 知道的函数列表中。reverse 将有一个必须展开的单一参数,并且将调用一个名为 func_reverse 的 C 函数。
下面是需要添加到 function_table_init[] 中的条目:
{ STRING_SIZE_TUPLE("reverse"), 1, 1, 1, func_reverse},
现在我们可以定义func_reverse,它通过交换字符来反转argv[0]中的字符串,并更新输出缓冲区o,如示例 5-4 所示:
示例 5-4. 使用 C 定义 GNU make 函数
static char*
func_reverse(char *o, char **argv, const char *funcname UNUSED)
{
int len = strlen(argv[0]);
if (len > 0) {
char * p = argv[0];
int left = 0;
int right = len - 1;
while (left < right) {
char temp = *(p + left);
*(p + left) = *(p + right);
*(p + right) = temp;
left++;
right--;
}
o = variable_buffer_output(o, p, len);
}
return o;
}
这个函数通过从字符串的起始和结尾同时遍历,并交换字符对,直到left和right在中间相遇。
为了测试它,我们可以编写一个简单的 makefile,尝试三种情况:一个空字符串、一个长度为偶数的字符串,以及一个长度为奇数的字符串,所有这些都调用新的内建函数reverse:
EMPTY :=
$(info Empty string: [$(reverse $(EMPTY))]);
EVEN := 1234
$(info Even length string: [$(reverse $(EVEN))]);
ODD := ABCDE
$(info Odd length string: [$(reverse $(ODD))]);
输出显示它正常工作:
$ **./make**
Empty string: []
Even length string: [4321]
Odd length string: [EDCBA]
使用 C 语言编写可以访问完整的 C 标准库函数;因此,你可以创建的 GNU make内建函数仅受你的想象力限制。
GNU make 4.0 可加载对象
将reverse函数添加到 GNU make中相当复杂,因为我们需要修改 GNU make的源代码。但是使用 GNU make 4.0 或更高版本,你可以在不更改源代码的情况下将 C 函数添加到 GNU make中。GNU make 4.0 添加了一个load指令,允许你加载一个包含用 C 语言编写的 GNU make函数的共享对象。
你可以将示例 5-4 中的reverse函数转换为可加载的 GNU make对象,只需将其保存在名为reverse.c的文件中,并做一些小修改。下面是完整的reverse.c文件:
#include <string.h>
#include <gnumake.h>
➊ int plugin_is_GPL_compatible;
char* func_reverse(const char *nm, unsigned int argc, char **argv)
{
int len = strlen(argv[0]);
if (len > 0) {
➋ char * p = gmk_alloc(len+1);
*(p+len) = '\0';
int i;
for (i = 0; i < len; i++) {
*(p+i) = *(argv[0]+len-i-1);
}
return p;
}
return NULL;
}
int reverse_gmk_setup()
{
➌ gmk_add_function("reverse", func_reverse, 1, 1, 1);
return 1;
}
通过在➌处调用gmk_add_function,将reverse函数添加到 GNU make中。然后,reverse函数就可以像任何其他 GNU make内建函数一样使用。字符串的实际反转由func_reverse处理,它在➋处调用 GNU make的 API 函数gmk_alloc为新字符串分配空间。
➊处是一个特殊的、未使用的变量plugin_is_GPL_compatible,它在任何可加载模块中都是必需的。
要使用新的reverse函数,你需要将reverse.c文件编译成.so文件并加载到 GNU make中:
all:
--*snip*--
load reverse.so
➍ reverse.so: reverse.c ; @$(CC) -shared -fPIC -o $@ $<
load指令加载.so文件,规则➍从.c文件构建.so文件。如果在 GNU make遇到load指令时.so文件缺失,GNU make会根据规则构建该文件并重新启动,从头解析 makefile。
加载后,你可以如下使用reverse:
A_PALINDROME := $(reverse saippuakivikauppias)
注意,使用$(call)并非必要。reverse函数就像任何其他内建的 GNU make函数一样。
在 GNU make 中使用 Guile
GNU make 4.0 引入了一个重要的变化,即$(guile)函数。这个函数的参数被发送到内置的 Guile 语言并由它执行。(GNU Guile 是 Scheme 的一个实现,而 Scheme 本身就是 Lisp。)$(guile)的返回值是执行后的 Guile 代码返回的值,经过转换成 GNU make能识别的类型。严格来说,GNU make没有数据类型(所有东西都是字符串),尽管它有时会把字符串当作其他类型(例如,包含空格的字符串在许多函数中会被当作列表)。
这是如何使用$(guile)和 Guile 函数reverse反转一个列表的方法:
NAMES := liesl friedrich louisa kurt brigitta marta gretl
➊ $(info $(guile (reverse '($(NAMES)))))
执行时,这个 Makefile 将输出:
$ make
gretl marta brigitta kurt louisa friedrich liesl
值得深入研究➊,看看会发生什么,因为有几个微妙的细节。$(guile)的参数首先由 GNU make展开,所以➊变成了:
$(info $(guile (reverse '(liesl friedrich louisa kurt brigitta marta gretl))))
所以要执行的 Guile 代码是(reverse '(liesl friedrich louisa kurt brigitta marta gretl))。GNU make变量$(NAMES)已经被扩展成名字列表,并通过用'(...)将其包装变成了 Guile 列表。因为 Guile 有数据类型,你必须使用正确的语法:在这种情况下,你需要用圆括号括住一个列表,并用单引号引起来,告诉 Guile 这是一个字面意义的列表(而不是函数调用)。
Guile 的reverse函数反转这个列表并返回反转后的列表。GNU make然后将 Guile 列表转换为 GNU make列表(一个包含空格的字符串)。最后,$(info)显示该列表。
由于 Guile 是一种功能强大的语言,因此可以创建更复杂的函数。例如,这里有一个名为file-exists的 GNU make函数,它使用 Guile 的access?函数来判断一个文件是否存在。它返回一个布尔值,将 Guile 的#t/#f(真/假)值通过转换成 GNU make的布尔值(非空字符串表示真,空字符串表示假):
file-exists = $(guile (access? "$1" R_OK))
注意参数$1周围的双引号。Guile 需要知道文件名实际上是一个字符串。
你可以通过在 Makefile 中使用 Guile 的http-get函数从网络下载数据来构建一个更复杂的例子:
define setup
(use-modules (web uri))
(use-modules (web client))
(use-modules (ice-9 receive))
endef
$(guile $(setup))
UA := "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_0) \
AppleWebKit/537.36 (KHTML, like Gecko) Chrome/40.0.2214.115 \
Safari/537.36"
define get-url
(receive (headers body)
(http-get
(string->uri "$1")
#:headers '((User-Agent . $(UA))))
body)
endef
utc-time = $(guile $(call get-url,http://www.timeapi.org/utc/now))
$(info $(utc-time))
在这里,http-get从一个 Web 服务中获取当前的 UTC 时间,该服务以字符串形式在 HTTP 响应的正文中返回时间。
utc-time变量包含当前的 UTC 时间。它通过使用存储在get-url变量中的 Guile 代码从www.timeapi.org/utc/now/获取时间。get-url中的 Guile 代码使用http-get函数来获取网页的头部和正文,并只返回正文。
注意如何使用 GNU make define指令来创建大量的 Guile 代码块。如果 Guile 代码变得庞大,可以这样做:
$(guile (load "myfunctions.scm"))
这是你如何将 Guile 代码存储在文件中并加载它。
自文档化的 Makefile
遇到一个新的 makefile,许多人会问:“这个 makefile 做什么?”或者“我需要了解哪些重要的目标?”对于任何规模较大的 makefile,回答这些问题可能会很困难。在本节中,我将介绍一个简单的 GNU make 技巧,你可以使用它使 makefile 自动生成文档,并自动输出帮助信息。
在我向你展示它是如何工作的之前,这里有一个小示例。这个 makefile 有三个目标,创建者认为你需要了解:all、clean 和 package。他们通过为每个目标附加一些额外信息来记录这个 makefile:
include help-system.mk
all: $(call print-help,all,Build all modules in Banana Wumpus system)
→ ...commands for building all ...
clean: $(call print-help,clean,Remove all object and library files)
→ ...commands for doing a clean ...
package: $(call print-help,package,Package application-must run all target first)
→ ...commands for doing package step ...
对于每个需要文档化的目标,makefile 维护者添加了对用户自定义函数 print-help 的调用,传递了两个参数:目标的名称和该目标的简短描述。对 print-help 的调用不会干扰规则的前提条件定义,因为它始终返回(或扩展为)空字符串。
输入 make 和这个 makefile 会输出:
$ **make**
Type 'make help' to get help
输入 make help 会显示:
$ **make help**
Makefile:11: all -- Build all modules in Banana Wumpus system
Makefile:17: clean -- Remove all object and library files
Makefile:23: package -- Package application-must run all target first
make 会自动打印出有趣的目标名称,并包括关于它们所做的事情的解释,以及 makefile 中你可以找到更多目标相关命令的信息所在的行号。
有趣的工作是由包含的 makefile help-system.mak 完成的。help-system.mak 首先定义了用户自定义函数 print-help。print-help 是为每个需要文档化的目标调用的函数:
define print-help
$(if $(need-help),$(warning $1 -- $2))
endef
print-help 使用 GNU make 的 $(warning) 函数,根据传递给它的两个参数输出相应的消息。第一个参数(存储在 $1 中)是目标的名称,第二个参数(在 $2 中)是帮助文本;它们通过 -- 分隔。$(warning) 将消息写入控制台并返回一个空字符串;因此,你可以安全地在规则的前提条件列表中使用 print-help。
print-help 通过检查 need-help 变量来决定是否需要打印任何消息。如果用户在 make 命令行中指定了 help,则 need-help 变量的值为字符串 help,否则为空字符串。在任何情况下,print-help 的扩展值都是空字符串。need-help 通过检查内置变量 MAKECMDGOALS 来判断用户是否在命令行中输入了 help,MAKECMDGOALS 是一个空格分隔的目标列表,列出了命令行中指定的所有目标。need-help 会过滤掉任何不匹配文本 help 的目标,因此,如果 MAKECMDGOALS 中包含 help,need-help 为字符串 help,否则为空。
need-help := $(filter help,$(MAKECMDGOALS))
need-help 和 print-help 的定义是我们所需要的,当命令行中输入 help 时,make 会打印出每个目标的帮助信息。help-system.mak 的其余部分会在用户只输入 make 时打印出消息 Type 'make help' to get help。
它为 makefile 定义了一个默认目标,名为 help,如果命令行中没有指定其他目标,则会运行此目标:
help: ; @echo $(if $(need-help),,Type \'$(MAKE)$(dash-f) help\' to get help)
如果用户请求了help(通过need-help变量确定),此规则将不输出任何内容;否则,它将输出包含make程序名称(存储在$(MAKE)中)以及加载 makefile 所需的适当参数的消息。这最后一部分是微妙的。
如果包含help-system.mak的 makefile 文件名仅为Makefile(或makefile或GNUmakefile),那么 GNU make会自动查找它,输入make help即可获得帮助。如果不是这种情况,则需要使用-f参数指定实际的 makefile 文件名。
该规则使用名为dash-f的变量来输出正确的命令行。如果使用了默认的 makefile 文件名,则dash-f不包含任何内容;否则,它包含-f,后跟正确的 makefile 文件名:
dash-f := $(if $(filter-out Makefile makefile GNUmakefile, \
$(parent-makefile)), -f $(parent-makefile))
dash-f查看名为parent-makefile的变量的值,该变量包含了包含help-system.mak的 makefile 的文件名。如果它不是标准名称,dash-f会返回带有-f选项的父 makefile 文件名。
parent-makefile是通过查看MAKEFILE_LIST来确定的。MAKEFILE_LIST是一个按顺序列出已读取的所有 makefile 的列表。help-system.mak首先确定它自己的文件名:
this-makefile := $(call last-element,$(MAKEFILE_LIST))
然后它通过从MAKEFILE_LIST中移除this-makefile(即help-system.mak)来获取所有其他包含的 makefile 的列表:
other-makefiles := $(filter-out $(this-makefile),$(MAKEFILE_LIST))
other-makefiles的最后一个元素将是help-system.mak的父级:
parent-makefile := $(call last-element,$(other-makefiles))
你可以使用last-element函数获取以空格分隔的列表中的最后一个元素:
define last-element
$(word $(words $1),$1)
endef
last-element通过使用$(words)获取单词计数并返回相应的单词,来返回列表中的最后一个单词。由于 GNU make的列表是从位置 1 开始计数的,$(words LIST)表示最后一个元素的索引。
使用print-help文档化 Makefiles
使用print-help文档化 makefile 非常简单。只需将相关的$(call print-help,target,description)添加到每个要文档化的目标的前置条件列表中。如果将调用添加到用于该目标的命令旁边,帮助系统不仅会打印帮助,还会自动将用户指向 makefile 中查看更多信息的地方。
由于目标的描述实际上是目标定义的一部分,而不是单独的帮助列表,因此保持文档的更新也很容易。
完整的 help-system.mak
最后,这是完整的help_system.mak文件:
help: ; @echo $(if $(need-help),,Type \'$(MAKE)$(dash-f) help\' to get help)
need-help := $(filter help,$(MAKECMDGOALS))
define print-help
$(if $(need-help),$(warning $1 -- $2))
endef
define last-element
$(word $(words $1),$1)
endef
this-makefile := $(call last-element,$(MAKEFILE_LIST))
other-makefiles := $(filter-out $(this-makefile),$(MAKEFILE_LIST))
parent-makefile := $(call last-element,$(other-makefiles))
dash-f := $(if $(filter-out Makefile makefile GNUmakefile, \
$(parent-makefile)), -f $(parent-makefile))
只需include help-system.mak,即可在需要文档的 makefile 中开始使用该系统。
在第六章中,我们将介绍一个有用的资源——GMSL 项目。创建 GNU make内置函数很简单,但它确实带来了一个维护问题:下一次更新 GNU make时,我们需要将更改移植到新版本。如果我们能够在不修改源代码的情况下使用 GNU make内置功能完成需求,那么 makefile 将会更具可移植性。GMSL 提供了大量额外的功能,而无需修改 GNU make的源代码。
第六章。GNU Make 标准库
GNU Make 标准库 (GMSL) 是一个托管在 SourceForge 上的开源项目,由我发起,旨在收集 makefile 作者经常重复编写的常见函数。为了防止 makefile 编写者重复造轮子,GMSL 实现了常见的函数,例如反转列表、将字符串转为大写,或对列表的每个元素应用一个函数。
GMSL 包含了列表和字符串操作函数、完整的整数算术库,以及数据结构的函数。还包括 GNU make 对关联数组、集合和栈的实现,并提供内建的调试功能。
在本章中,你将学习如何在实际的 makefile 中使用 GMSL 函数。此外,你将看到 GMSL 函数的不同类别的完整参考。要查看 GMSL 的最新版本,请访问 gmsl.sf.net/。
导入 GMSL
GMSL 实现为一对名为 gmsl 和 __gmsl 的 makefile。__gmsl 被 gmsl 导入,因此要在你的 makefile 中包含 GMSL,只需添加以下内容:
include gmsl
你可以在任意多个文件中执行此操作。为了防止多次定义和不必要的错误信息,GMSL 会自动检测是否已经包含过。
当然,GNU make 必须能够找到 gmsl 和 __gmsl。为了实现这一点,GNU make 默认会在多个地方查找 makefile,包括 /usr/local/include、/usr/gnu/include/、/usr/include、当前目录,以及任何通过 GNU make -I(或 --include-dirL)命令行选项指定的目录。
将 gmsl 和 __gmsl 放置在 /usr/local/include 是一个好地方,这样它们将对所有你的 makefile 可用。
如果 GNU make 无法找到 gmsl 或 __gmsl,你将看到常规的 GNU make 错误信息:
Makefile:1: gmsl: No such file or directory
GMSL 使用了一个小技巧,使得 gmsl 的位置完全灵活。由于 gmsl 使用 include 来查找 __gmsl,因此 gmsl makefile 需要知道在哪里找到 __gmsl。
假设 gmsl 存储在 /foo 中,并通过 include /foo/gmsl 来包含。为了使这个工作正常,而无需修改 gmsl 来硬编码 __gmsl 的位置,gmsl 会使用 MAKEFILE_LIST 来确定它的位置,然后将适当的路径添加到 include __gmsl 前面:
# Try to determine where this file is located. If the caller did
# include /foo/gmsl then extract the /foo/ so that __gmsl gets
# included transparently
__gmsl_root := $(word $(words $(MAKEFILE_LIST)),$(MAKEFILE_LIST))
# If there are any spaces in the path in __gmsl_root then give up
ifeq (1,$(words $(__gmsl_root)))
__gmsl_root := $(patsubst %gmsl,%,$(__gmsl_root))
else
__gmsl_root :=
endif
include $(__gmsl_root)__gmsl
如果你希望你的 makefile 具有位置独立性,这是一项非常有用的技巧。
调用 GMSL 函数
GMSL 中的函数实现为普通的 GNU make 函数声明。例如,函数 last(返回列表的最后一个元素)是这样声明的:
last = $(if $1,$(word $(words $1),$1))
该函数是通过 GNU make 的内建 $(call) 来调用的。例如,要返回列表 1 2 3 的最后一个元素,可以这样做:
$(call last,1 2 3)
这将返回 3。$(call) 展开其第一个参数中指定的变量(在这个例子中是 last),并将特殊的本地变量($1、$2、$3 等)设置为传递给 $(call) 的参数。所以在这个例子中,$1 是 1 2 3。
GMSL 定义了布尔值true和false,它们只是变量,可以通过$()或${}访问:例如,$(true)或${false}。false是一个空字符串,true是字母T;这些定义对应于 GNU make中 true(非空字符串)和 false(空字符串)的概念。你可以在 GNU make的$(if)函数中或在预处理器ifeq中使用true和false:
$(if $(true),It's true!,Totally false)
ifeq ($(true),$(true))
--*snip*--
*endif*
这些例子是虚构的。你应该期望$(true)在$(if)中的返回值和ifeq中的第一个$(true)是来自函数调用的返回值,而不是常量值。
检查 GMSL 版本
GMSL 包含一个可以用来检查包含的版本是否与你使用的 GMSL 版本兼容的函数。函数gmsl_compatible检查包含的 GMSL 版本号是否大于或等于传入参数的版本号。
在撰写本文时,当前的 GMSL 版本是v1.1.7。要检查包含的 GMSL 是否至少是v1.1.2,请调用gmsl_compatible并传入一个包含三个元素的列表参数:1 1 2。
$(call gmsl_compatible,1 1 2)
这将返回$(true),因为当前的 GMSL 版本是v1.1.7,大于v1.1.2。如果我们请求的是v2.0.0,我们将得到$(false)的响应:
$(call gmsl_compatible,2 0 0)
确保你使用正确版本的 GMSL 的一个简单方法是将对gmsl_compatible的调用包装在一个断言中:
$(call assert,$(call gmsl_compatible,1 0 0),Wrong GMSL version)
如果发现不兼容的 GMSL 版本,这将停止make进程并报错。
示例:现实世界中的 GMSL 使用
现在你已经设置好了 GMSL,让我们看一些例子。这些例子解决了现实世界中 makefile 必须处理的一些问题,比如不区分大小写的比较和在路径中搜索文件。
不区分大小写的比较
GMSL 包含两个函数,允许你创建一个简单的函数来进行不区分大小写的字符串比较:
ifcase = $(call seq,$(call lc,$1),$(call lc,$2))
它通过将两个参数转换为小写(使用 GMSL 的lc函数),然后调用seq(GMSL 的字符串相等函数)来检查它们是否相同。以下是使用ifcase的一种方式:
CPPFLAGS += $(if $(call ifcase,$(DEBUG),yes),-DDEBUG,)
在这里,它用于查看DEBUG变量是否已设置为yes;如果是,它会将-DDEBUG添加到CPPFLAGS中。
在路径中查找程序
这是一个搜索PATH中可执行文件的函数定义:
findpath = $(call first,$(call map,wildcard,$(call addsuffix,/$1,$(call split,:,$(PATH)))))
例如,$(call findpath,cat)将搜索PATH中第一个cat程序。它使用了 GMSL 中的三个函数:first、map和split。同时,它还使用了两个内置函数:wildcard和addsuffix。
调用split将PATH变量拆分为一个列表,并在冒号处进行分隔。然后调用内置的addsuffix函数,它将/$1添加到PATH的每个元素中。$1包含findpath的参数,即我们正在搜索的程序名称(在这种情况下是cat)。
然后调用 GMSL 的map函数,在每个可能的程序文件名上执行内建的wildcard。如果文件名中没有通配符字符,wildcard将返回文件名(如果存在)或空字符串。因此,map的作用是通过依次测试每个文件,找到cat在PATH上的位置(或多个位置)。
最后,调用 GMSL 函数first返回map函数返回的列表中的第一个元素(即PATH中所有cat程序的第一个位置)。
GMSL 的调试功能之一是能够追踪对 GMSL 函数的调用。通过将GMSL_TRACE设置为1,GMSL 会输出每个对 GMSL 函数的调用及其参数。例如:
Makefile:8: split(':', '/home/jgc/bin:/usr/local/bin:/usr/bin:/usr/X11R6/bin:/
bin:/usr/games:/opt/gnome/bin:/opt/kde3/bin:/usr/lib/java/jre/bin')
Makefile:8: map('wildcard',' /home/jgc/bin/make /usr/local/bin/make /usr/bin/
make /usr/X11R6/bin/make /bin/make /usr/games/make /opt/gnome/bin/make /opt/
kde3/bin/make /usr/lib/java/jre/bin/make')
Makefile:8: first(' /usr/bin/make')
在这里,我们使用启用了追踪功能的findpath函数搜索cat。
使用断言检查输入
通常,makefile是在指定构建目标的情况下执行的(或者假设在makefile的开始部分有一个all目标或类似目标)。此外,通常还会有影响构建的环境变量(例如调试选项、架构设置等)。检查这些变量是否已正确设置的快速方法是使用 GMSL 断言函数。
这是一个示例,检查DEBUG是否已设置为yes或no,ARCH是否包含Linux,我们是否在OUTDIR变量中指定了输出目录,并且该目录是否存在:
$(call assert,$(OUTDIR),Must set OUTDIR)
$(call assert_exists,$(OUTDIR),Must set OUTDIR)
$(call assert,$(if $(call seq,$(DEBUG),yes),$(true),$(call seq,$(DEBUG),no)),DEBUG must be yes or no)
$(call assert,$(call findstring,Linux,$(ARCH)),ARCH must be Linux)
如果断言函数的第一个参数是$(false)(即空字符串),它们会生成一个致命错误。
第一个断言检查$(OUTDIR)是否已设置。如果它有一个非空值,则断言通过;否则,会生成一个错误:
Makefile:3: *** GNU Make Standard Library: Assertion failure: Must set OUTDIR.
Stop.
第二个断言是assert_exists形式,用来检查它的第一个参数在文件系统中是否存在。在这个例子中,它检查$(OUTDIR)所指向的目录是否存在。它不检查该路径是否是一个目录。如果需要,还可以添加另一个断言来检查这一点,如下所示:
$(call assert,$(wildcard $(OUTDIR)/.),OUTDIR must be a directory)
这段代码检查$(OUTDIR)是否包含一个点 (.)。如果没有,$(OUTDIR)就不是一个目录,调用wildcard将返回一个空字符串,从而导致断言失败。
第三个断言检查DEBUG是否为yes或no,通过 GMSL 的seq函数来验证其值。最后,我们使用findstring断言$(ARCH)必须包含单词Linux(且L为大写)。
DEBUG是否设置为 Y?
GMSL 有逻辑运算符and、or、xor、nand、nor、xnor和not,这些运算符与 GNU make的布尔值概念以及 GMSL 变量$(true)和$(false)一起工作。
你可以将 GNU make(和 GMSL)的布尔值与 GMSL 函数以及 GNU make的内建$(if)一起使用。GMSL 的逻辑运算符是为与$(if)和 GNU make预处理器ifeq指令配合使用而设计的。
假设一个 makefile 有一个调试选项,通过将 DEBUG 环境变量设置为 Y 来启用。使用 GMSL 函数 seq(字符串相等)和 or 运算符,你可以轻松确定是否需要调试:
include gmsl
debug_needed := $(call or,$(call seq,$(DEBUG),Y),$(call seq,$(DEBUG),y))
因为 GMSL 有一个小写函数(lc),你可以不用 or 就写出这个示例:
include gmsl
debug_needed := $(call seq,$(call lc,$(DEBUG)),y)
但是逻辑运算符 or 让我们可以更加宽容,接受 YES 和 Y 作为调试选项的值:
include gmsl
debug_needed := $(call or,$(call seq,$(call lc,$(DEBUG)),y),$(call seq,$(call lc,$(DEBUG)),yes))
debug_needed 函数对大小写不敏感。
DEBUG 是否设置为 Y 或 N?
逻辑运算符的另一个可能用途是强制 makefile 的用户将 DEBUG 设置为 Y 或 N,从而避免如果他们忘记调试选项而引发的问题。GMSL 断言函数 assert 会在其参数不为真时输出致命错误。所以我们可以用它来断言 DEBUG 必须是 Y 或 N:
include gmsl
$(call assert,$(call or,$(call seq,$(DEBUG),Y),$(call seq,$(DEBUG),N)),DEBUG must be Y or N)
这里有一个示例:
$ **make DEBUG=Oui**
Makefile:1: *** GNU Make Standard Library: Assertion failure: DEBUG must be Y or N.
Stop.
如果用户犯了把 DEBUG 设置为 Oui 的错误,断言会产生这个错误。
在预处理器中使用逻辑运算符
因为 GNU make 的预处理器(它有 ifeq、ifneq 和 ifdef 指令)没有逻辑运算,所以很难编写复杂的语句。例如,要在 GNU make 中定义一个 makefile 的部分,当 DEBUG 被设置为 Y 或 Yes 时,你必须重复一段代码(糟糕!)或者写一个难以理解的语句:
ifeq ($(DEBUG),$(filter $(DEBUG),Y Yes))
--*snip*--
endif
这个方法通过使用 $(DEBUG) 的值来过滤列表 Y Yes,如果 $(DEBUG) 不是 Y 或 Yes,则返回空列表,如果是,则返回 $(DEBUG) 的值。然后 ifeq 会将结果值与 $(DEBUG) 进行比较。这种做法很丑陋、难以维护,并且包含一个微妙的 bug。(如果 $(DEBUG) 为空会发生什么?提示:空值等同于 Y 或 Yes。)修复这个 bug 需要做类似这样的事情:
ifeq (x$(DEBUG)x,$(filter x$(DEBUG)x,xYx xYesx))
--*snip*--
endif
GMSL 的 or 运算符使这一点变得更加清晰:
include gmsl
ifeq ($(true),$(call or,$(call seq,$(DEBUG),Y),$(call seq,$(DEBUG),Yes)))
--*snip*--
endif
这种方法更容易维护。它通过对两个 seq 调用进行 or 操作,并将结果与 $(true) 进行比较来工作。
从列表中移除重复项
GMSL 函数 uniq 从列表中移除重复项。GNU make 有一个内建的 sort 函数,可以对列表进行排序并移除重复项;uniq 移除重复项但不排序列表(如果列表顺序很重要,这可以很有用)。
例如,$(sort c b a a c) 会返回 a b c,而 $(call uniq,c b a a c) 会返回 c b a。
假设你需要通过移除重复项并保留顺序来简化 PATH 变量。PATH 通常是一个以冒号分隔的路径列表(如 /usr/bin:/bin:/usr/local/bin:/bin)。这里的 simple-path 是去除重复项并保留顺序后的 PATH:
include gmsl
simple-path := $(call merge,:,$(call uniq,$(call split,:,$(PATH))))
这使用了三个 GMSL 函数:uniq、split(它将字符串按照某个分隔符字符拆分成列表;在此例中是冒号)和 merge(它将列表合并成一个字符串,列表项之间用一个字符分隔;在此例中是冒号)。
自动递增版本号
当软件发布时,能够自动增加版本号是非常方便的。假设一个项目包含一个名为 version.c 的文件,其中包含当前版本号的字符串:
char * ver = "1.0.0";
理想的情况是,只需输入 make major-release、make minor-release 或 make dot-release,并让版本号的三个部分之一自动更新,version.c 文件也随之更改。
下面是如何实现的:
VERSION_C := version.c
VERSION := $(shell cat $(VERSION_C))
space :=
space +=
PARTS := $(call split,",$(subst $(space),,$(VERSION)))
VERSION_NUMBER := $(call split,.,$(word 2,$(PARTS)))
MAJOR := $(word 1,$(VERSION_NUMBER))
MINOR := $(word 2,$(VERSION_NUMBER))
DOT := $(word 3,$(VERSION_NUMBER))
major-release minor-release dot-release:
➊ → @$(eval increment_name := $(call uc,$(subst -release,,$@)))
➋ → @$(eval $(increment_name) := $(call inc,$($(increment_name))))
➌ → @echo 'char * ver = "$(MAJOR).$(MINOR).$(DOT)";' > $(VERSION_C)
VERSION 变量包含 version.c 文件的内容,类似于 char * ver = "1.0.0";。PARTS 变量是一个列表,通过先去除 VERSION 中的所有空白字符,再按双引号分割来创建的。这将 VERSION 分割成 char*ver= 1.0.0 ; 这个列表。
所以 PARTS 是一个包含三个元素的列表,第二个元素是当前的版本号,它被提取到 VERSION_NUMBER 中,并转化为一个包含三个元素的列表:1 0 0。
接下来,从 VERSION_NUMBER 中提取名为 MAJOR、MINOR 和 DOT 的变量。如果 version.c 中的版本号是 1.2.3,那么 MAJOR 将是 1,MINOR 将是 2,DOT 将是 3。
最后,定义了三个规则,用于主版本、次版本和修订版发布。这些规则使用一些 $(eval) 技巧,利用相同的规则体来更新主版本、次版本或修订版号,具体取决于命令行中指定的 major-release、minor-release 或 dot-release。
为了理解它是如何工作的,可以跟随 make minor-release 的过程,假设当前版本号是 1.0.0。
$(eval increment_name := $(call uc,$(subst -release,,$@))) ➊ 首先使用 $(subst) 去除目标名称中的 -release(因此 minor-release 就变成了 minor)。
然后它调用 GMSL 的 uc 函数(该函数将字符串转换为大写),将 minor 转换为 MINOR。它将其存储在名为 increment-name 的变量中。这里是关键部分:increment-name 将用作要增加的变量的名称(MAJOR、MINOR 或 DOT 之一)。
在 ➋,$(eval $(increment_name) := $(call inc,$($(increment_name)))) 实际上执行了这个工作。它使用 GMSL 的 inc 函数来增加存储在名为 increment-name 的变量中的值(注意 $($(increment-name)),它用于查找另一个变量中存储的变量名的值),然后将该值设置为增加后的值。
最后,它创建一个新的 version.c 文件,其中包含新的版本号 ➌。例如:
$ **make -n major-release**
echo 'char * ver = "2.0.0";' > version.c
$ **make -n minor-release**
echo 'char * ver = "1.1.0";' > version.c
$ **make -n dot-release**
echo 'char * ver = "1.0.1";' > version.c
这是使用 -n 选项时,从版本 1.0.0 开始并要求不同可能的发布版本的结果。
GMSL 参考
本节是 GNU Make 标准库版本 1.1.7 的完整参考,涵盖了 GMSL 逻辑运算符、整数函数、列表、字符串和集合操作函数、关联数组和命名堆栈。对于每一类 GMSL 函数,你将看到函数的简介,接着是一个快速参考部分,列出了参数和返回值。要查看最新版本的完整参考,请访问 GMSL 网站:gmsl.sf.net/。
如果你对高级 GNU make编程感兴趣,值得研究 GMSL 的源代码(特别是__gmsl文件)。创建各个 GMSL 函数时使用的技巧通常在其他情况下也很有用。
逻辑运算符
GMSL 有布尔值$(true),它是一个非空字符串,实际上设置为单个字符T,以及$(false),它是一个空字符串。你可以使用以下运算符与这些变量或返回这些值的函数一起使用。
尽管这些函数在返回值上始终是$(true)或$(false),但它们对任何表示真的非空字符串都比较宽容。例如:
$(call or,$(wildcard /tmp/foo),$(wildcard /tmp/bar))
这会测试两个文件/tmp/foo和/tmp/bar中的任意一个是否存在,使用了$(wildcard)和 GMSL 的or函数。执行$(wildcard /tmp/foo)会返回/tmp/foo(如果文件存在),或者返回空字符串(如果文件不存在)。因此,$(wildcard /tmp/foo)的输出可以直接传递给or,其中/tmp/foo会被解释为真,空字符串则为假。
如果你更喜欢只使用$(true)和$(false)这样的值,可以像这样定义一个make-bool函数:
make-bool = $(if $(strip $1),$(true),$(false))
这将把任何非空字符串(去除空格后)转换为$(true),而把空字符串(或仅包含空格的字符串)留作$(false)。make-bool在函数返回值中可能包含空格时非常有用。
例如,下面是一个小的 GNU make变量,如果当前月份是 1 月,它的值为$(true):
january-now := $(call make-bool,$(filter Jan,$(shell date)))
这会运行date shell 命令,提取单词Jan,并通过make-bool将其转换为真值。像这样使用$(filter)会把date的结果当作一个列表,然后过滤掉列表中任何不是Jan的词。这种技术在其他情况下也可以用来提取字符串的部分内容。
你可以创建一个通用函数来判断一个列表中是否包含某个词:
contains-word = $(call make-bool,$(filter $1,$2))
january-now := $(call contains-word,Jan,$(shell date))
使用contains-word,你可以重新定义january-now。
not
GMSL 包含所有常见的逻辑运算符。最简单的是not函数,它对其参数进行逻辑取反:
**not**
Argument: A single boolean value
Returns: $(true) if the boolean is $(false) and vice versa
例如,$(call not,$(true))返回$(false)。
and
and函数仅在其两个参数都为真时返回$(true):
**and**
Arguments: Two boolean values
Returns: $(true) if both of the arguments are $(true)
例如,$(call and,$(true),$(false))返回$(false)。
or
or函数在其任一参数为真时返回$(true):
**or**
Arguments: Two boolean values
Returns: $(true) if either of the arguments is $(true)
例如,$(call or,$(true),$(false))返回$(true)。
xor
xor函数是异或:
**xor**
Arguments: Two boolean values
Returns: $(true) if exactly one of the booleans is true
例如,$(call xor,$(true),$(false)) 返回 $(true)。
nand
nand 就是 非与:
**nand**
Arguments: Two boolean values
Returns: Value of 'not and'
例如,$(call nand,$(true),$(false)) 返回 $(true),而 $(call and,$(true),$(false)) 返回 $(false)。
nor
nor 就是 非或:
**nor**
Arguments: Two boolean values
Returns: Value of 'not or'
例如,$(call nor,$(true),$(false)) 返回 $(false),而 $(call or,$(true),$(false)) 返回 $(true)。
xnor
很少使用的 xnor 是 非异或:
**xnor**
Arguments: Two boolean values
Returns: Value of 'not xor'
请注意,GMSL 逻辑函数 and 和 or 不是 短路;这两个函数的两个参数会在执行逻辑 and 或 or 前展开。GNU make 3.81 引入了内建的 and 和 or 函数,它们是短路的:它们首先评估第一个参数,然后决定是否有必要评估第二个参数。
整数算术函数
在第五章中,您已经看到如何通过将非负整数表示为 x 的列表,在 GNU make 中执行算术运算。例如,4 是 x x x x。GMSL 使用相同的整数表示法,并提供广泛的函数来进行整数计算。
算术库函数有两种形式:一种形式的函数接受整数作为参数,另一种形式接受编码参数(由调用 int_encode 创建的 x)。例如,有两个 plus 函数:plus(使用整数参数调用,返回整数)和 int_plus(使用编码参数调用,返回编码结果)。
plus 比 int_plus 慢,因为它的参数和结果必须在 x 格式和整数之间转换。如果您进行复杂的计算,请使用带有输入单一编码和输出单一解码的 int_* 形式。对于简单的计算,您可以使用直接形式。
int_decode
int_decode 函数接受一个 x 表示法的数字并返回它表示的十进制整数:
**int_decode**
Arguments: 1: A number in x-representation
Returns: The integer for human consumption that is represented
by the string of x's
int_encode
int_encode 是 int_decode 的逆运算:它接受一个十进制整数并返回 x 表示法:
**int_encode**
Arguments: 1: A number in human-readable integer form
Returns: The integer encoded as a string of x's
int_plus
int_plus 在 x 表示法中将两个数字相加,并返回它们的和,以 x 表示法返回:
**int_plus**
Arguments: 1: A number in x-representation
2: Another number in x-representation
Returns: The sum of the two numbers in x-representation
plus
要加法十进制整数,请使用 plus 函数,它会在 x 表示法和整数之间转换,并调用 int_plus:
**plus** (wrapped version of int_plus)
Arguments: 1: An integer
2: Another integer
Returns: The sum of the two integers
int_subtract
int_subtract 在 x 表示法中减去两个数字,并返回它们的差值,仍然以 x 表示法返回:
**int_subtract**
Arguments: 1: A number in x-representation
2: Another number in x-representation
Returns: The difference of the two numbers in x-representation,
or outputs an error on a numeric underflow
如果差值小于 0(无法表示),则会发生错误。
subtract
要减去十进制整数,请使用 subtract 函数,它会在 x 表示法和整数之间转换,并调用 int_subtract:
**subtract** (wrapped version of int_subtract)
Arguments: 1: An integer
2: Another integer
Returns: The difference of the two integers, or outputs an error on a
numeric underflow
如果差值小于 0(无法表示),则会发生错误。
int_multiply
int_multiply 在 x 表示法中乘以两个数字:
**int_multiply**
Arguments: 1: A number in x-representation
2: Another number in x-representation
Returns: The product of the two numbers in x-representation
multiply
multiply 将两个十进制整数相乘并返回它们的积。它会自动在 x 表示法和整数之间转换,并调用 int_multiply:
**multiply** (wrapped version of int_multiply)
Arguments: 1: An integer
2: Another integer
Returns: The product of the two integers
int_divide
int_divide 将一个数字除以另一个;两个数字都以 x 表示法表示,结果也是如此:
**int_divide**
Arguments: 1: A number in x-representation
2: Another number in x-representation
Returns: The result of integer division of argument 1 divided
by argument 2 in x-representation
divide
divide 函数调用 int_divide 来除以两个十进制整数,自动进行 x 表示法的转换:
**divide** (wrapped version of int_divide)
Arguments: 1: An integer
2: Another integer
Returns: The integer division of the first argument by the second
int_max 和 int_min
int_max 和 int_min 分别返回两个数字中的最大值和最小值,结果是 x 表示法:
**int_max**, **int_min**
Arguments: 1: A number in x-representation
2: Another number in x-representation
Returns: The maximum or minimum of its arguments in x-representation
max 和 min
int_max 和 int_min 的十进制整数等价物分别是 max 和 min;它们会自动转换为 x 表示法并从中转换:
**max**, **min**
Arguments: 1: An integer
2: Another integer
Returns: The maximum or minimum of its integer arguments
int_inc
int_inc 是一个小的辅助函数,它仅仅将一个 x 表示法的数字加一:
**int_inc**
Arguments: 1: A number in x-representation
Returns: The number incremented by 1 in x-representation
inc
inc 函数将一个十进制整数加一:
**inc**
Arguments: 1: An integer
Returns: The argument incremented by 1
int_dec
int_inc 的反操作是 int_dec:它将一个数字减去一:
**int_dec**
Arguments: 1: A number in x-representation
Returns: The number decremented by 1 in x-representation
dec
dec 函数将一个十进制整数减一:
**dec**
Arguments: 1: An integer
Returns: The argument decremented by 1
int_double
double 和 halve 函数(以及它们的 int_double 和 int_halve 等价函数)是为了性能考虑而提供的。如果你需要乘以二或除以二,这些函数的执行速度会比乘法和除法更快。
int_double 会将整数乘以二:
**int_double**
Arguments: 1: A number in x-representation
Returns: The number doubled (* 2) and returned in x-representation
double
double 会将一个十进制整数乘以二:
**double**
Arguments: 1: An integer
Returns: The integer times 2
它在内部将其转换为 x 表示法并调用 int_double。
int_halve
你可以通过对一个 x 表示法的数字调用 int_halve 来执行整数除以二的操作:
**int_halve**
Arguments: 1: A number in x-representation
Returns: The number halved (/ 2) and returned in x-representation
halve
最后是 halve:
**halve**
Arguments: 1: An integer
Returns: The integer divided by 2
这是 int_halve 的十进制整数等价物。
整数比较函数
所有的整数比较函数返回 $(true) 或 $(false):
**int_gt**, **int_gte**, **int_lt**, **int_lte**, **int_eq**, **int_ne**
Arguments: Two x-representation numbers to be compared
Returns: $(true) or $(false)
int_gt First argument is greater than second argument
int_gte First argument is greater than or equal to second argument
int_lt First argument is less than second argument
int_lte First argument is less than or equal to second argument
int_eq First argument is numerically equal to the second argument
int_ne First argument is not numerically equal to the second argument
这些函数可以与 GNU make 和 GMSL 函数一起使用,也可以与需要布尔值的指令一起使用(如 GMSL 逻辑运算符)。
但是你更可能使用这些比较函数的版本:
**gt**, **gte**, **lt**, **lte**, **eq**, **ne**
Arguments: Two integers to be compared
Returns: $(true) or $(false)
int_gt First argument is greater than second argument
int_gte First argument is greater than or equal to second argument
int_lt First argument is less than second argument
int_lte First argument is less than or equal to second argument
int_eq First argument is numerically equal to the second argument
int_ne First argument is not numerically equal to the second argument
这些函数作用于十进制整数,而不是 GMSL 使用的内部 x 表示法。
杂项整数函数
大多数情况下,你不需要做任何复杂的 GNU make 算术运算,但是这里详细介绍的杂项函数用于基本转换和数字序列的生成。有时它们会很有用。
sequence
你可以使用 sequence 函数来生成一个数字序列:
**sequence**
Arguments: 1: An integer
2: An integer
Returns: The sequence [arg1 arg2] if arg1 >= arg2 or [arg2 arg1] if arg2 > arg1
例如,$(call sequence,10,15) 将会得到列表 10 11 12 13 14 15。要创建一个递减的序列,你只需要反转 sequence 的参数。例如,$(call sequence,15,10) 将会得到列表 15 14 13 12 11 10。
dec2hex、dec2bin 和 dec2oct
dec2hex、dec2bin 和 dec2oct 函数用于在十进制数字和十六进制、二进制、八进制之间进行转换:
**dec2hex**, **dec2bin**, **dec2oct**
Arguments: 1: An integer
Returns: The decimal argument converted to hexadecimal, binary or octal
例如,$(call dec2hex,42) 会得到 2a。
没有用于填充前导零的选项。如果需要,可以使用 GMSL 字符串函数。例如,下面是一个填充版的 dec2hex,它接受两个参数:一个十进制数字要转换为十六进制,以及输出的位数:
__repeat = $(if $2,$(call $0,$1,$(call rest,$2),$1$3),$3)
repeat = $(call __repeat,$1,$(call int_encode,$2),)
这个通过定义一些辅助函数来实现。首先,repeat会创建一个由若干个相同字符串组成的字符串。例如,$(call repeat,10,A) 将返回 AAAAAAAAAA。
这个定义中发生了一些微妙的事情。repeat函数会用三个参数调用__repeat:$1是要重复的字符串,$2是重复$1的次数,$3在$(call)调用repeat时通过尾随逗号被设置为空字符串。$0变量包含当前函数的名称;在__repeat中,它将是__repeat。
__repeat函数是递归的,并且使用$2作为递归的终止条件。repeat函数将所需的重复次数转换为 GMSL 算术函数使用的x表示法,并将其传递给__repeat。例如,$(call repeat,Hello,5) 会变成 $(call __repeat,Hello,x x x x x,),然后__repeat会每次从$2中去掉一个x,直到$2为空。
使用repeat函数后,我们只需要一种方法来将字符串填充到指定的字符数,并用填充字符来填充。pad函数实现了这个功能:
pad = $(call repeat,$1,$(call subtract,$2,$(call strlen,$3)))$3
paddeddec2hex = $(call pad,0,$2,$(call dec2hex,$1))
它的三个参数分别是填充字符、填充后的输出宽度(字符数)和要填充的字符串。例如,$(call pad,0,4,2a) 将返回 002a。由此,可以轻松地定义一个填充后的dec2hex。它接受两个参数:第一个是要转换为十六进制的十进制数字,第二个是填充到的字符数。
正如你所预期的那样,$(call paddeddec2hex,42,8) 返回 0000002a。
列表操作函数
在 GNU make和 GMSL 中,列表是由空格分隔的字符字符串。GNU make内建的对列表操作的函数和 GMSL 函数都将多个空格视为一个空格。所以,1 2 3和1 2 3是相同的。
我将在接下来的几节中详细解释一些列表操作函数。这些函数在使用上比其他函数更为复杂,通常在函数式语言中可用。
将函数应用到列表上,使用 map
当你使用 GNU make函数(无论是内建的还是自定义的)时,实际上你是在一个简单的函数式语言中编程。在函数式编程中,常常会有一个map函数,它会将一个函数应用于列表中的每个元素。GMSL 定义了map来做到这一点。例如:
SRCS := src/FOO.c src/SUBMODULE/bar.c src/foo.c
NORMALIZED := $(call uniq,$(call map,lc,$(SRCS)))
给定一个包含文件名(可能带有路径)的列表SRCS,这将确保所有文件名都转为小写,并应用uniq函数来获取一个唯一的源文件列表。
这使用了 GMSL 函数lc来将SRCS中的每个文件名转为小写。你可以将map函数与内建函数和用户自定义函数一起使用。在这里,NORMALIZED将会是src/foo.c src/submodule/bar.c。
map的另一个使用场景是获取每个源文件的大小:
size = $(firstword $(shell wc -c $1))
SOURCE_SIZES := $(call map,size,$(SRCS))
在这里我们定义了一个size函数,它使用$(shell)来调用wc,然后我们将其应用到SRCS中的每个文件。
这里的 SOURCE_SIZES 可能是类似 1538 1481 的内容,每个源文件对应一个元素。
创建一个 reduce 函数
在函数式编程语言中,另一个常见的函数是 reduce。reduce 对列表的连续元素应用一个接受两个参数的函数,并将该函数的返回值作为参数传递给下一个调用。GMSL 没有内置的 reduce 函数,但你可以很容易地定义一个:
reduce = $(if $2,$(call $0,$1,$(call rest,$2),$(call $1,$3,$(firstword $2))),$3)
使用 reduce 对数字列表求和
将 reduce 与 plus 函数结合使用,你可以轻松创建一个 GNU make 函数来对数字列表求和:
sum-list = $(call reduce,plus,$1,0)
sum-list 函数接受一个参数,即一个数字列表,并返回这些数字的总和。它将三个参数传递给 reduce:每个列表元素调用的函数名称(在此为 plus),数字列表,以及一个起始值(在此为 0)。
下面是它的工作原理。假设调用了 $(call sum-list,1 2 3 4 5)。接下来会依次调用 plus 函数:
$(call plus,1,0) which returns 1
$(call plus,1,2) which returns 3
$(call plus,3,3) which returns 6
$(call plus,6,4) which returns 10
$(call plus,10,5) which returns 15
第一次调用使用列表的第一个元素和起始值 0。每一次后续的调用使用列表中的下一个元素和上次调用 plus 函数的结果。
你可以将 sum-list 与 SOURCE_SIZES 变量结合使用,以获取源代码的总大小:
TOTAL_SIZE := $(call sum-list,$(SOURCE_SIZES))
在这种情况下,TOTAL_SIZE 会是 3019。
对一对列表映射函数
GMSL 为列表定义的另一个有趣的函数是 pairmap。它接受三个参数:两个列表(它们应该有相同的长度)和一个函数。该函数依次作用于每个列表的第一个元素、第二个元素、第三个元素,依此类推。
假设 SRCS 包含一个源文件列表。使用我们定义的 size 函数,结合 map,我们定义了 SOURCE_SIZES,它包含了每个源文件的大小列表。通过使用 pairmap,我们可以将这两个列表压缩在一起,输出每个文件的名称及其大小:
zip = $1:$2
SOURCES_WITH_SIZES := $(call pairmap,zip,$(SRCS),$(SOURCE_SIZES))
zip 函数依次作用于每个源文件名和文件大小,并生成一个用冒号分隔文件名和文件大小的字符串。使用我们在本节中的示例文件和大小,SOURCES_WITH_SIZES 可能会是 src/foo.c:1538 src/submodule/bar.c:1481。
first
first 函数接收一个列表并返回其第一个元素:
**first**
Arguments: 1: A list
Returns: Returns the first element of a list
请注意,first 与 GNU make 函数 $(firstword) 是相同的。
last
last 函数返回列表的最后一个元素:
**last**
Arguments: 1: A list
Returns: The last element of a list
GNU make 3.81 引入了 $(lastword),它的工作方式与 last 相同。
其余部分
rest 函数几乎是 first 的相反。它返回列表中的所有元素,除了第一个元素:
**rest**
Arguments: 1: A list
Returns: The list with the first element removed
chop
要移除列表中的最后一个元素,请使用 chop 函数:
**chop**
Arguments: 1: A list
Returns: The list with the last element removed
map
map 函数遍历一个列表(它的第二个参数),并对每个列表元素调用一个函数(函数名在第一个参数中)。每次调用该函数时返回的值将组成一个列表,并返回该列表:
**map**
Arguments: 1: Name of function to $(call) for each element of list
2: List to iterate over calling the function in 1
Returns: The list after calling the function on each element
pairmap
pairmap 类似于 map,但它遍历一对列表:
**pairmap**
Arguments: 1: Name of function to $(call) for each pair of elements
2: List to iterate over calling the function in 1
3: Second list to iterate over calling the function in 1
Returns: The list after calling the function on each pair of elements
第一个参数中的函数被调用时,会传入两个参数:来自每个被迭代列表的一个元素。
leq
leq 列表相等性测试函数会正确地为完全相同的列表返回 $(true),即使它们仅因空格不同而有所差异:
**leq**
Arguments: 1: A list to compare against...
2: ...this list
Returns: $(true) if the two lists are identical
例如,leq 会认为 1 2 3 和 1 2 3 是相同的列表。
lne
lne 是 leq 的反操作:当两个列表不相等时,它返回 $(true):
**lne**
Arguments: 1: A list to compare against...
2: ...this list
Returns: $(true) if the two lists are different
reverse
将列表 reverse 反转可能是有用的(特别是因为它可以作为输入传递给 $(foreach) 并反向迭代)。
**reverse**
Arguments: 1: A list to reverse
Returns: The list with its elements in reverse order
uniq
内建的 $(sort) 函数会去重列表,但它会在排序的同时进行去重。而 GMSL 的 uniq 函数则会去重列表,同时保留元素第一次出现的顺序:
**uniq**
Arguments: 1: A list to deduplicate
Returns: The list with elements in the original order but without duplicates
例如,$(call uniq,a c b a c b) 将返回 a c b。
length
要找出列表中的元素数量,可以调用 length:
**length**
Arguments: 1: A list
Returns: The number of elements in the list
length 函数与 GNU make $(words) 函数相同。
字符串操作函数
字符串是由任何字符组成的序列,包括空格。字符串相等性(和字符串不等式)函数 seq 即使处理包含空格或仅由空格组成的字符串时也能正常工作。例如:
# space contains the space character
space :=
space +=
# tab contains a tab
tab :=→ # needed to protect the tab character
$(info $(call seq,White Space,White Space))
$(info $(call seq,White$(space)Space,White Space))
$(info $(call sne,White$(space)Space,White$(tab)Space))
$(info $(call seq,$(tab),$(tab)))
$(info $(call sne,$(tab),$(space)))
这将输出 T 五次,表示每次调用 seq 或 sne 都返回了 $(true)。
与列表操作函数类似,我将在接下来的部分详细介绍一些更复杂的函数。
将 CSV 数据拆分成 GNU make 列表
你可以使用 split 函数将 CSV 格式的值转换为 GNU make 列表。例如,以逗号为分隔符将 CSV 行分割成一个列表,然后可以从中提取各个项:
CSV_LINE := src/foo.c,gcc,-Wall
comma := ,
FIELDS := $(call split,$(comma),$(CSV_LINE))
$(info Compile '$(word 1,$(FIELDS))' using compiler '$(word 2,$(FIELDS))' with \
options '$(word 3,$(FIELDS))')
注意变量 comma 如何被定义为包含逗号字符,以便它可以在 $(call) 中传递给 split 函数。这个技巧在第一章中有讨论。
从目录列表创建 PATH
merge 函数的作用与 split 相反:它通过某个字符分隔列表项,将列表转化为一个字符串。例如,要将一个目录列表转换为适合 PATH 的格式(通常由冒号分隔),可以按如下方式定义 list-to-path:
DIRS := /usr/bin /usr/sbin /usr/local/bin /home/me/bin
list-to-path = $(call merge,:,$1)
$(info $(call list-to-path,$(DIRS)))
这将输出 /usr/bin:/usr/sbin:/usr/local/bin:/home/me/bin。
使用 tr 转换字符
最复杂的字符串函数是 tr,它的操作方式类似于 tr shell 程序。它将一个字符集中的每个字符转换为第二个列表中的相应字符。GMSL 为 tr 定义了一些常见的字符类。例如,它定义了名为 [A-Z] 和 [a-z] 的变量(是的,它们真的是这个名字),分别包含大写字母和小写字母。
我们可以使用 tr 创建一个函数,将其转换为黑客语言(leet-speak):
leet = $(call tr,A E I O L T,4 3 1 0 1 7,$1)
$(info $(call leet,I AM AN ELITE GNU MAKE HAXOR))
这将输出 1 4M 4N 31173 GNU M4K3 H4X0R。
seq
命名略显混乱的 seq 函数测试两个字符串是否相等:
**seq**
Arguments: 1: A string to compare against...
2: ...this string
Returns: $(true) if the two strings are identical
sne
相反的字符串不等式可以通过 sne 来测试:
**sne**
Arguments: 1: A string to compare against...
2: ...this string
Returns: $(true) if the two strings are not the same
streln
length函数获取列表的长度;对于字符串,等效的函数是strlen:
**strlen**
Arguments: 1: A string
Returns: The length of the string
substr
可以使用substr函数提取子字符串:
**substr**
Arguments: 1: A string
2: Starting offset (first character is 1)
3: Ending offset (inclusive)
Returns: A substring
注意,在 GMSL 中,字符串从位置 1 开始,而不是 0。
split
要将字符串分割成列表,可以使用split函数:
**split**
Arguments: 1: The character to split on
2: A string to split
Returns: A list separated by spaces at the split character in the
first argument
注意,如果字符串包含空格,结果可能不符合预期。GNU make使用空格作为列表分隔符,使得同时处理空格和列表变得非常困难。有关 GNU make如何处理空格的更多信息,请参见第四章。
merge
merge是split的相反操作。它接受一个列表,并在每个列表元素之间插入一个字符输出字符串:
**merge**
Arguments: 1: The character to put between fields
2: A list to merge into a string
Returns: A single string, list elements are separated by the character in
the first argument
tr
使用tr函数可以转换单个字符,它是创建uc和lc函数的构建块:
**tr**
Arguments: 1: The list of characters to translate from
2: The list of characters to translate to
3: The text to translate
Returns: The text after translating characters
uc
uc对字母 a-z 进行简单的大写转换:
**uc**
Arguments: 1: Text to uppercase
Returns: The text in uppercase
lc
最后,我们有了lc:
**lc**
Arguments: 1: Text to lowercase
Returns: The text in lowercase
该函数对字母 A-Z 进行简单的小写转换。
集合操作函数
集合通过排序去重的列表表示。要从列表中创建集合,可以使用set_create,或者从empty_set开始并使用set_insert插入各个元素。空集合由变量empty_set定义。
例如,一个 makefile 可以使用在创建目录中讨论的标记技术来跟踪它创建的所有目录:
MADE_DIRS := $(empty_set)
marker = $1.f
make_dir = $(eval $1.f: ; @$$(eval MADE_DIRS := $$(call \
set_insert,$$(dir $$@),$$(MADE_DIRS))) mkdir -p $$(dir $$@); \
touch $$@)
all: $(call marker,/tmp/foo/) $(call marker,/tmp/bar/)
→ @echo Directories made: $(MADE_DIRS)
$(call make_dir,/tmp/foo/)
$(call make_dir,/tmp/bar/)
通过在make_dir函数(用于创建目录的规则)中调用set_insert,意味着变量MADE_DIRS将跟踪已创建的目录集合。
在一个真实的 makefile 中,可能会构建许多目录,使用集合是一种简单的方式来发现任何时刻哪些目录已经被构建。
注意,由于集合是作为 GNU make列表实现的,因此无法插入包含空格的项目。
set_create
你可以通过使用set_create函数来创建一个集合:
**set_create**
Arguments: 1: A list of set elements
Returns: The newly created set
它接受一个元素列表并将它们添加到集合中。集合本身会被返回。注意,集合元素不能包含空格。
set_insert
一旦通过set_create创建了集合,可以使用set_insert向其中添加元素:
**set_insert**
Arguments: 1: A single element to add to a set
2: A set
Returns: The set with the element added
set_remove
要从集合中移除一个元素,可以调用set_remove:
**set_remove**
Arguments: 1: A single element to remove from a set
2: A set
Returns: The set with the element removed
从集合中移除一个元素时,如果该元素不存在,则不会报错。
set_is_member
要测试一个元素是否是集合的成员,可以调用set_is_member。它返回一个布尔值,指示该元素是否存在:
**set_is_member**
Arguments: 1: A single element
2: A set
Returns: $(true) if the element is in the set
set_union
通过对两个集合调用set_union函数,你可以将两个集合合并。合并后的集合会被返回:
**set_union**
Arguments: 1: A set
2: Another set
Returns: The union of the two sets
set_intersection
要确定两个集合的共同元素,可以使用set_intersection。它返回作为参数传入的两个集合中都存在的元素集合:
**set_intersection**
Arguments: 1: A set
2: Another set
Returns: The intersection of the two sets
set_is_subset
有时,了解一个集合是否是另一个集合的子集是很有用的,可以通过调用set_is_subset来进行测试:
**set_is_subset**
Arguments: 1: A set
2: Another set
Returns: $(true) if the first set is a subset of the second
set_is_subset返回一个布尔值,指示第一个集合是否是第二个集合的子集。
set_equal
要确定两个集合是否相等,请调用set_equal:
**set_equal**
Arguments: 1: A set
2: Another set
Returns: $(true) if the two sets are identical
set_equal返回$(true),如果两个集合具有完全相同的元素。
关联数组
一个关联数组将一个键值(没有空格的字符串)映射到一个单一的值(任意字符串)。关联数组有时也被称为映射(maps)或哈希表(尽管那是一个实现细节,GMSL 的关联数组并不使用哈希)。
你可以使用关联数组作为查找表。例如:
C_FILES := $(wildcard *.c)
get-size = $(call first,$(shell wc -c $1))
$(foreach c,$(C_FILES),$(call set,c_files,$c,$(call get-size,$c)))
$(info All the C files: $(call keys,c_files))
$(info foo.c has size $(call get,c_files,foo.c))
这个小的 Makefile 获取当前目录中所有 .c 文件及其大小的列表,然后将文件名和大小之间建立关联数组映射。
get-size函数使用wc获取文件中的字节数。C_FILES变量包含当前目录中的所有.c文件。$(foreach)使用 GMSL 的set函数在名为c_files的关联数组中设置每个.c文件及其大小的映射。
以下是一个示例运行:
$ **make**
All the C files: bar.c foo.c foo.c
has size 551
第一行是所有.c文件的列表;它是通过keys函数获取关联数组中的所有键来打印的。第二行是通过使用get查找foo.c的长度来得到的。
set
GMSL 会跟踪命名的关联数组,但不需要显式创建它们。只需调用set来添加元素到数组中,如果数组不存在,它会自动创建。请注意,数组的键不能包含空格。
**set**
Arguments: 1: Name of associative array
2: The key value to associate
3: The value associated with the key
Returns: Nothing
获取
要从关联数组中检索项,请调用get。如果键不存在,get将返回一个空字符串。
**get**
Arguments: 1: Name of associative array
2: The key to retrieve
Returns: The value stored in the array for that key
键
keys函数返回关联数组中所有键的列表。你可以使用它和$(foreach)来遍历关联数组:
**keys**
Arguments: 1: Name of associative array
Returns: A list of all defined keys in the array
defined
要测试某个键是否存在于关联数组中,请调用defined:
**defined**
Arguments: 1: Name of associative array
2: The key to test
Returns: $(true) if the key is defined (i.e., not empty)
defined返回一个布尔值,表示键是否已定义。
命名栈
一个栈是一个有序的字符串列表(其中不包含空格)。在 GMSL 中,栈是内部存储的,并且它们有名称,像关联数组一样。例如:
traverse-tree = $(foreach d,$(patsubst %/.,%,$(wildcard $1/*/.)), \
$(call push,dirs,$d)$(call traverse-tree,$d))
$(call traverse-tree,sources)
dump-tree = $(if $(call sne,$(call depth,dirs),0),$(call pop,dirs) \
$(call dump-tree))
$(info $(call dump-tree))
这个小的 Makefile 使用栈来跟踪目录树。
traverse-tree
traverse-tree函数使用$(wildcard)函数查找其参数(存储在$1中)中的所有子目录,寻找始终存在于目录中的.文件。它使用$(patsubst)函数去除每个由$(wildcard)返回的值中的尾部/.,以获得完整的目录名。
在遍历该目录之前,它会将找到的目录推送到名为dirs的栈中。
dump-tree
dump-tree函数会从dirs树中逐个弹出元素,直到没有剩余的元素(直到depth变为0)。
示例 6-1 展示了一个目录结构。
示例 6-1. 目录结构
$ **ls -R sources**
sources:
bar foo
sources/bar:
barsub
sources/bar/barsub:
sources/foo:
subdir subdir2
sources/foo/subdir:
subsubdir
sources/foo/subdir/subsubdir:
sources/foo/subdir2:
如果这个目录结构存在于sources下,Makefile 将输出:
sources/foo sources/foo/subdir2 sources/foo/subdir sources/foo/subdir/
subsubdir sources/bar sources/bar/barsub
如果希望以深度优先的方式遍历目录树,可以使用栈函数来定义 dfs,它会搜索目录树并构建包含目录的深度优先顺序的 dirs 栈:
__dfs = $(if $(call sne,$(call depth,work),0),$(call push,dirs,$(call \
peek,work)$(foreach d,$(patsubst %/.,%,$(wildcard $(call \
pop,work)/*/.)),$(call push,work,$d)))$(call __dfs))
dfs = $(call push,work,$1)$(call __dfs)
$(call dfs,sources)
dump-tree = $(if $(call sne,$(call depth,dirs),0),$(call pop,dirs) $(call \
dump-tree))
$(info $(call dump-tree,dirs))
dump-tree 函数没有变化(它通过多次调用 pop 来输出栈中的所有内容)。但 dfs 函数是新的。它使用一个名为 work 的工作栈来跟踪待访问的目录。它首先将起始目录推送到 work 栈中,然后调用 __dfs 辅助函数。
实际工作由 __dfs 完成。它将当前目录推送到 dirs 栈中,将该目录的所有子目录推送到 work 栈中,然后递归。当 work 栈为空时,递归停止。
对于目录结构的输出,参见 示例 6-1 现在是:
sources/bar/barsub sources/bar sources/foo/subdir/subsubdir sources/foo/subdir
sources/foo/subdir2 sources/foo sources.
推送
任何使用过栈的人都对推入和弹出元素非常熟悉。GMSL 栈函数非常相似。要将元素添加到栈顶,调用 push:
**push**
Arguments: 1: Name of stack
2: Value to push onto the top of the stack (must not contain
a space)
Returns: None
弹出
要获取栈顶元素,调用 pop:
**pop**
Arguments: 1: Name of stack
Returns: Top element from the stack after removing it
查看
peek 函数的作用类似于 pop,但不会移除栈顶元素;它只返回该元素的值:
**peek**
Arguments: 1: Name of stack
Returns: Top element from the stack without removing it
深度
最后,你可以调用 depth:
**depth**
Arguments: 1: Name of stack
Returns: Number of items on the stack
depth 确定栈中当前有多少个元素。
函数记忆化
为了减少对慢速函数(如 $(shell))的调用,提供了一个单一的记忆化函数。例如,假设一个 Makefile 需要知道各种文件的 MD5 值,并定义了一个 md5 函数。
md5 = $(shell md5sum $1)
这是一个相当昂贵的函数调用(因为 md5sum 执行时会消耗时间),因此希望每个文件只调用一次。md5 函数的记忆化版本如下所示:
md5once = $(call memoize,md5,$1)
它会对每个输入的文件名仅调用一次 md5sum 函数,并将返回的值内部记录,以便后续对相同文件名的 md5once 调用可以直接返回 MD5 值,而无需重新运行 md5sum。例如:
$(info $(call md5once,/etc/passwd))
$(info $(call md5once,/etc/passwd))
这会打印出 /etc/passwd 的 MD5 值两次,但仅执行一次 md5sum。
实际的 memoize 函数是使用 GMSL 关联数组函数定义的:
**memoize**
Arguments: 1: Name of function to memoize
2: String argument for the function
Returns: Result of $1 applied to $2 but only calls $1 once for each unique $2
杂项和调试功能
表 6-1 显示了 GMSL 定义的常量。
表 6-1. GMSL 常量
| 常量 | 值 | 目的 |
|---|---|---|
| true | T | 布尔值 true |
| false | (一个空字符串) | 布尔值 false |
| gmsl_version | 1 1 7 | 当前 GMSL 版本号(主版本号、次版本号、修订号) |
你可以像访问普通 GNU make 变量一样,通过将它们包裹在 $() 或 ${} 中来访问这些常量。
gmsl_compatible
你已经在 检查 GMSL 版本 中了解了 gmsl_compatible 函数:
**gmsl_compatible**
Arguments: List containing the desired library version number (major minor
revision)
Returns: $(true) if the current version of the library is compatible
with the requested version number, otherwise $(false)
在第一章中,你看到了一个使用模式规则和目标print-%输出变量值的示例。由于这是一个非常有用的规则,GMSL 定义了自己的gmsl-print-%目标,你可以用它来打印任何在包含 GMSL 的 makefile 中定义的变量的值。
例如:
include gmsl
FOO := foo bar baz
all:
gmsl-print-%
gmsl-print-%可以用来打印任何 makefile 变量,包括 GMSL 内部的变量。例如,make gmsl-print-gmsl_version会打印当前的 GMSL 版本。
**gmsl-print-%** (target not a function)
Arguments: The % should be replaced by the name of a variable that you
wish to print
Action: Echoes the name of the variable that matches the % and its value
assert
如 Makefile 断言中所讨论的,makefile 中的断言是很有用的。GMSL 提供了两个断言函数:assert和assert_exists。
**assert**
Arguments: 1: A boolean that must be true or the assertion will fail
2: The message to print with the assertion
Returns: None
assert_exists
要断言某个文件或目录存在,GMSL 提供了assert_exists函数:
**assert_exists**
Arguments: 1: Name of file that must exist, if it is missing an assertion
will be generated
Returns: None
环境变量
表 6-2 显示了 GMSL 环境变量(或命令行覆盖项),这些变量控制着各种功能。
表 6-2. GMSL 环境变量
| 变量 | 目的 |
|---|---|
| GMSL_NO_WARNINGS | 如果设置了,防止 GMSL 输出警告信息。例如,算术函数可能会生成下溢警告。 |
| GMSL_NO_ERRORS | 如果设置了,防止 GMSL 生成致命错误:例如除零错误或断言失败都会被认为是致命的。 |
| GMSL_TRACE | 启用函数追踪。调用 GMSL 函数时,会追踪函数名和参数。有关 makefile 追踪的讨论,请参见追踪变量值。 |
这些环境变量都可以在环境中或命令行中设置。
例如,这个 makefile 包含一个总是失败的断言,导致make过程停止:
include gmsl
$(call assert,$(false),Always fail)
all:
设置GMSL_NO_ERRORS可以防止断言停止make过程。在这种情况下,assert的输出会被隐藏,make会正常继续:
$ **make**
Makefile:5: *** GNU Make Standard Library: Assertion failure: Always fail.
Stop.
$ **make GMSL_NO_ERRORS=1**
make: Nothing to be done for `all'.
在 makefile 中放置一些适当的 GMSL 断言可以产生很大的效果。通过检查 makefile 的前提条件(比如特定文件的存在,或者编译器的版本号),一个有责任心的 makefile 编写者可以在不迫使用户调试make中常常晦涩的输出信息的情况下,提醒用户潜在的问题。
附录 A. 更新
访问 nostarch.com/gnumake/ 获取更新、勘误和其他信息。
更多实用的书籍来自
NO STARCH PRESS

LINUX 编程接口
Linux 和 UNIX^® 系统编程手册
作者:MICHAEL KERRISK
2010 年 10 月,1552 页,$99.95
ISBN 978-1-59327-220-3
精装本

如何使用 LINUX,第二版
每个超级用户应该知道的事情
作者:BRIAN WARD
2014 年 11 月,392 页,$39.95
ISBN 978-1-59327-567-9

LINUX 命令行
完全介绍
作者:WILLIAM E. SHOTTS, JR.
2012 年 1 月,480 页,$39.95
ISBN 978-1-59327-389-7

像程序员一样思考
创意思维问题解决导论
作者:V. ANTON SPRAUL
2012 年 8 月,256 页,$34.95
ISBN 978-1-59327-424-5

绝对 OPENBSD,第二版
实用偏执的 UNIX
作者:MICHAEL W. LUCAS
2013 年 4 月,536 页,$59.95
ISBN 978-1-59327-476-4

R 编程艺术
统计软件设计巡礼
作者:NORMAN MATLOFF
2011 年 10 月,400 页,$39.95
ISBN 978-1-59327-384-2
电话:
800.420.7240 或
415.863.9900
电子邮件:
sales@nostarch.com
网站:


浙公网安备 33010602011771号