GNU Make 项目管理 第三章 变量和宏

创建时间 2021-04-18
更新时间 2021-04-21

我们知道 makefile 变量已经有一段时间了,我们看到了许多在内置规则和用户定义规则中使用它们的例子。但我们看到的例子只是触及了表面。变量和宏会变得更加复杂,并赋予 GNU make 强大的功能。

在我们继续之前,重要的是要理解 make 是两种语言的结合体。第一种语言描述由目标和依赖组成的依赖关系图。(这门语言在第二章中介绍过) 第二语言是执行文本替换的宏语言。您可能熟悉的其他语言的宏,例如,C 预处理器、m4、TEX 和 宏汇编器。与其他宏语言一样,make 允许您为更长的字符序列定义一个简写术语,并在程序中使用这个简写。宏处理器会识别你的简写术语,并用它们的展开形式替换它们。尽管把 makefile 变量看作传统编程语言变量很容易,但宏 “变量” 和 “传统” 变量是有区别的。宏变量 被 “就地” 展开以产生一个字符串,然后可以进一步展开。随着我们的深入,这种区别将变得更加清楚。

变量名可以包含几乎任何字符,包括大多数标点符号。即使是空格也是允许的,但如果你想保持理智,就应该避免使用空格。变量名中不允许使用的字符只有 :, #=

变量是区分大小写的,所以 ccCC 指向不同的变量。要获取变量的值,请将变量名括在 $() 中。作为一种特殊情况,单字母变量名可以省略圆括号,直接使用$letter。这就是为什么自动变量可以不用圆括号来写。一般来说,应该使用括号形式,而避免使用单个字母的变量名。

变量也可以像在 ${CC} 中那样使用花括号展开,你经常会看到这种形式,特别是在旧的 makefile 中。使用其中一种并没有什么优势,所以选择一种并坚持使用。有些人在变量引用时使用花括号,在函数调用时使用圆括号,类似于 shell 使用它们的方式。大多数现代的 makefile 都使用圆括号,这也是我们在整本书中都会用到的。

按照惯例,用户希望在命令行或环境中自定义的常量都是全大写的。单词之间用下划线分隔。仅出现在 makefile 中的变量都是小写的,单词之间用下划线分隔。最后,在本书中,用户定义的函数在变量和宏中使用小写字母,中间用破折号隔开。其他命名约定将在它们发生的地方进行解释。(下面的示例使用了我们还没有讨论的特性。我用它们来说明变量命名约定,现在不要太关心右边的内容。)

# Some simple constants.
CC := gcc
MKDIR := mkdir -p

# Internal variables.
sources = *.c
objects = $(subst .c,.o,$(sources))

# A function or two.
maybe-make-dir = $(if $(wildcard $1),,$(MKDIR) $1)
assert-not-null = $(if $1,,$(error Illegal null value.))

变量的值由赋值符号右侧的所有单词组成,并去掉前面的空格。不去掉后面的空格。这有时会造成麻烦,例如,如果变量中包含末尾的空格,然后使用在命令脚本中:

LIBRARY = libio.a # LIBRARY has a trailing space.
missing_file:
    touch $(LIBRARY)
    ls -l | grep '$(LIBRARY)'

变量赋值包含一个尾随空格,注释使其更加明显(但尾随空格也可以不带尾随注释)。当这个 makefile 运行时,我们得到:

$ make
touch libio.a
ls -l | grep 'libio.a '
make: *** [missing_file] Error 1

糟糕,grep 搜索字符串还包括尾随空格,无法在 ls 的输出中找到该文件。稍后我们将更详细地讨论空格问题。现在,让我们更详细地继续研究变量。

变量是用来干嘛的

一般来说,使用变量来表示外部程序是一个好主意。这允许 makefile 的用户更容易地使 makefile 适应他们特定的环境。

例如,在一个系统上通常有几个版本的 awk: awk、nawk、gawk。通过创建一个变量 AWK 来保存 AWK 程序的名称,可以使 makefile 的其他用户更容易使用它。另外,如果您的环境中存在安全问题,一个好的做法是使用绝对路径访问外部程序,以避免用户路径出现问题。绝对路径还可以减少出现问题的可能性,如果系统程序的木马版本已经安装在用户路径的某个地方。当然,绝对路径也会降低 makefile 对其他系统的可移植性。这得看你的需求。

虽然变量的第一次使用应该是用来保存简单常量,但它们也可以存储用户定义的命令序列,比如:

DF = df
AWK = awk
free-space := $(DF) . | $(AWK) 'NR = = 2 { print $$4 }'

用于获取空闲的磁盘空间。我们将会看到,变量被用于这两个目的外,还能做别的。

变量类型

在 make 中有两种类型的变量,简单展开变量和递归展开变量。使用 := 赋值操作符定义一个简单展开的变量(或简单变量):

MAKE_DEPEND := $(CC) -M

它被称为“简单展开”,因为它的右侧在读取 makefile 中的行后立即展开。右侧的任何 make 变量引用都将展开,并将结果文本保存为变量的值。这种行为与大多数编程和脚本语言相同。例如,这个变量的正常展开结果是:

gcc -M

然而,如果上面的 CC 还没有赋值,那么上面赋值的值将是:

<space>-M

$(CC) 被扩展为它的值(不包含字符),并收缩为空。变量没有定义不是错误。事实上,这非常有用。大多数隐式命令包括未定义的变量,这些变量充当用户自定义的占位符。如果用户没有自定义一个变量,它将压缩为空。现在注意前导空格。右手边首先由 make 进行解析,生成字符串 $(CC) -M。当变量引用被压缩为空时,make 不会重新扫描值并修剪空白。空白部分保持完整。

第二种类型的变量称为递归扩展变量。递归展开变量(或递归变量)使用 = 赋值操作符定义:

MAKE_DEPEND = $(CC) -M

它被称为“递归展开”,因为它的右边只是被 make 吃掉,并存储为变量的值,而不需要求值或以任何方式展开。相反,在使用变量时执行展开。对于这个变量,一个更好的术语可能是惰性展开变量,因为计算被推迟到实际使用它的时候。这种扩展风格的一个令人惊讶的效果是,作业可以“无序”执行

MAKE_DEPEND = $(CC) -M
...

# Some time later
CC = gcc

这里,命令脚本中的 MAKE_DEPEND 的值是 gcc -M,即使在分配 MAKE_DEPEND 时没有定义 CC

实际上,递归变量并不是真正的惰性赋值(至少不是普通的惰性赋值)。每次使用递归变量时,都会对其右侧重新求值。对于根据简单常量定义的变量,如上面的 MAKE_DEPEND,这种区别是没有意义的,因为右边的所有变量也是简单常量。但是想象一下,如果右手边的一个变量表示程序的执行,比如 date。每次递归变量展开时,都会执行日期程序,并且每个变量展开都会有不同的值(假设它们至少间隔一秒执行)。有时这是非常有用的。在其他时候,它是非常烦人的!

其他赋值类型

在前面的例子中,我们已经看到了两种类型的赋值 = 用于创建递归变量,:= 用于创建简单变量。make 还提供了另外两个赋值操作符。

?= 操作符称为条件变量赋值操作符。这很复杂,我们称之为条件赋值。只有当变量还没有值时,该操作符才会执行所请求的变量赋值。

# Put all generated files in the directory $(PROJECT_DIR)/out.
OUTPUT_DIR ?= $(PROJECT_DIR)/out

这里,我们设置了输出目录变量 OUTPUT_DIR,只有在前面没有设置它的情况下才设置它。这个特性可以很好地与环境变量交互。我们将在本章后面的“变量从何而来”一节讨论这个问题。

另一个赋值操作符 += 通常被称为追加。顾名思义,这个操作符将文本追加到一个变量。这看起来似乎没什么特别的,但在使用递归变量时,这是一个重要的特性。具体来说,赋值右边的值被附加到变量上,而不改变变量中的原始值。“有什么大不了的,这不就是 append 一直在做的吗?” 我听到你说。是的,但等等,这有点棘手。

添加一个简单的变量是非常明显的。+= 操作符可以这样实现:

simple := $(simple) new stuff

由于简单变量中的值已经进行了展开,make 可以展开 $(simple)、追加文本并完成赋值。但是递归变量带来了一个问题。不允许像下面这样的实现。

recursive = $(recursive) new stuff

这是一个错误,因为 make 没有好的方法来处理它。如果 make 存储了递归的当前定义加上新东西,make 就不能在运行时再次展开它。此外,尝试展开包含对自身引用的递归变量会产生无限循环。

$ make
makefile:2: *** Recursive variable `recursive' references itself (eventually). Stop.

因此,实现 += 是为了允许将文本添加到递归变量中,并执行正确的操作。这个操作符对于将值递增地收集到变量中特别有用。

变量可以很好地将值存储为一行文本,但如果我们有一个多行值,比如想要在几个地方执行的命令脚本,该怎么办?例如,可以使用以下命令序列从 Java .class 文件创建 Java 存档 (或 jar):

echo Creating $@...
$(RM) $(TMP_JAR_DIR)
$(MKDIR) $(TMP_JAR_DIR)
$(CP) -r $^ $(TMP_JAR_DIR)
cd $(TMP_JAR_DIR) && $(JAR) $(JARFLAGS) $@ .
$(JAR) -ufm $@ $(MANIFEST)
$(RM) $(TMP_JAR_DIR)

在像这样的长序列的开头,我喜欢打印一条简短的消息。它可以使阅读 make 的输出更容易。在收到消息后,我们将类文件收集到一个干净的临时目录中。因此,我们删除临时 jar 目录,以防旧的 jar 目录仍然存在,然后我们创建一个新的临时目录。接下来,我们将依赖(及其所有子目录)复制到临时目录中。然后切换到我们的临时目录并创建带有目标文件名的 jar。我们将清单文件添加到 jar 中,最后进行清理。显然,我们不希望在 makefile 中复制这个命令序列,因为这将成为将来的维护问题。我们可以考虑将所有这些命令打包到一个递归变量中,但当 make 回显命令行时,这样做既不方便维护,也难以阅读 (整个序列被回显为一个巨大的文本行)。

相反,我们可以使用由 define 指令创建的 GNU make “罐装序列”。术语 “罐装序列” 有点尴尬,所以我们称之为 。宏只是 make 中定义变量的另一种方式,它可以包含内嵌的换行符,GNU make 手册似乎交替使用变量和宏这两个词。在本书中,我们将特别使用宏这个词来表示使用 define 指令定义的变量,以及用变量表示仅在使用赋值时才定义的变量。

define create-jar
    @echo Creating $@...
    $(RM) $(TMP_JAR_DIR)
    $(MKDIR) $(TMP_JAR_DIR)
    $(CP) -r $^ $(TMP_JAR_DIR)
    cd $(TMP_JAR_DIR) && $(JAR) $(JARFLAGS) $@ .
    $(JAR) -ufm $@ $(MANIFEST)
    $(RM) $(TMP_JAR_DIR)
endef

define 指令后面跟着变量名和换行符。变量的主体包含了直到 endf 关键字的所有文本,该关键字必须单独出现在一行中。用 define 创建的变量与其他变量展开的方式非常相似,只是在命令脚本的上下文中使用时,宏的每行前面都有一个制表符。一个例子是:

$(UI_JAR): $(UI_CLASSES)
    $(create-jar)

注意,我们在 echo 命令前添加了一个 @ 字符。带 @ 前缀的命令行在执行命令时不会被 make 回显。因此,当我们运行 make 时,它不打印 echo 命令,只打印该命令的输出。如果在宏中使用 @ 前缀,则前缀字符将应用于使用它的单个行。但是,如果在宏引用中使用了前缀字符,则整个宏体将被隐藏:

$(UI_JAR): $(UI_CLASSES)
    @$(create-jar)

这只显示:

$ make
Creating ui.jar...

@ 的使用在第五章的“命令修饰符”一节中有更详细的介绍。

何时展开变量

在前面几节中,我们开始了解变量扩展的一些微妙之处。结果很大程度上取决于之前定义的内容和地点。您可以很容易地得到您不想要的结果,即使找不到任何错误。那么展开变量的规则是什么呢?这究竟是如何运作的呢?

当 make 运行时,它将分两个阶段执行它的工作。在第一阶段,make 读取 makefile 和包含的任何 makefile。此时,变量和规则被加载到 make 的内部数据库中,并创建了依赖关系图。在第二阶段,make 分析依赖关系图并确定需要更新的目标,然后执行命令脚本来执行所需的更新。

当一个递归变量或定义指令被 make 处理时,变量或宏体中的行会被存储,包括未展开的换行。宏定义的最后一行换行不作为宏的一部分存储。否则,当宏展开时,make 将读取额外的换行符。

当宏被展开时,展开的文本会立即被扫描,以查找进一步的宏或变量引用,这些宏或变量会被展开,以此类推,递归地进行。如果在操作的上下文中展开宏,则宏的每一行都插入一个前导制表符。

总结一下,以下是 makefile 元素展开时的规则:

  • 对于变量赋值,当 make 在其第一阶段读取该行时,赋值的左侧总是立即展开。
  • =?= 的右边被推迟,直到在第二阶展开它们。
  • := 的右侧立即展开。
  • 如果左边最初定义为一个简单变量,则 += 的右边立即展开。否则,它的评估将被推迟。
  • 对于宏定义(使用 define 的定义),宏变量名会立即展开,宏体会推迟到使用时。
  • 对于规则,目标和依赖总是立即展开,而命令总是推迟。

表 3-1 总结了变量展开时的情况。

表 3-1 立即和推迟展开的规则

定义 a 的展开 b 的展开
a = b 立刻 推迟
a ?= b 立刻 推迟
a := b 立刻 立刻
a += b 立刻 推迟 或 立刻
define ... endef 立刻 推迟

作为一条通用规则,在使用变量和宏之前一定要定义它们。特别是,需要在使用目标或依赖中使用的变量之前定义它。

下面的示例将使这一切更加清楚。假设我们重新实现了 free-space 宏。我们将一次处理一点示例,然后在最后把它们放在一起。

BIN := /usr/bin
PRINTF := $(BIN)/printf
DF := $(BIN)/df
AWK := $(BIN)/awk

我们定义了三个变量来保存宏中使用的程序的名称。为了避免代码重复,我们将 bin 目录分解为第四个变量。读取这四个变量定义,并立即展开它们的右侧,因为它们是简单的变量。因为 BIN 是在其他文件之前定义的,所以可以将它的值插入到它们的值中。

接着,我们定义 free-space 宏。

define free-space
    $(PRINTF) "Free disk space "
    $(DF) . | $(AWK) 'NR = = 2 { print $$4 }'
endef

define 指令后面跟着一个立即展开的变量名。在这种情况下,不需要展开。读取和存储宏的主体是未展开的。

最后,我们在规则中使用宏。

OUTPUT_DIR := /tmp

$(OUTPUT_DIR)/very_big_file:
    $(free-space)

当读取 $(OUTPUT_DIR)/very_big_file 时,在目标和依赖中使用的任何变量都会立即展开。在这里,$(OUTPUT_DIR) 被扩展到 /tmp,形成 /tmp/very_big_file 目标文件。接下来,读取此目标的命令脚本。命令行可由前导制表符识别,并可以读取和存储,但不能展开。

下面是整个示例 makefile。为了说明 make 的评估算法,对文件中元素的顺序进行了有意置乱。

OUTPUT_DIR := /tmp

$(OUTPUT_DIR)/very_big_file:
    $(free-space)

define free-space
    $(PRINTF) "Free disk space "
    $(DF) . | $(AWK) 'NR = = 2 { print $$4 }'
endef

BIN := /usr/bin
PRINTF := $(BIN)/printf
DF := $(BIN)/df
AWK := $(BIN)/awk

注意,尽管 makefile 中的行顺序似乎是向后的,但它执行得很好。这是递归变量令人惊讶的效果之一。它可能非常有用,但同时也令人困惑。这个 makefile 工作的原因是命令脚本和宏体的扩展被推迟到实际使用时。因此,它们发生的相对顺序对 makefile 的执行并不重要。

在处理的第二阶段,读取 makefile 之后,make 标识目标、执行依赖项分析并为每个规则执行操作。这里唯一的目标 $(OUTPUT_DIR)/very_big_file 没有先决条件,因此 make 将简单地执行操作(假设文件不存在)。命令为 $(free-space)。所以 make 展开它,就像程序员写的那样:

/tmp/very_big_file:
    /usr/bin/printf "Free disk space "
    /usr/bin/df . | /usr/bin/awk 'NR = = 2 { print $$4 }'

一旦所有变量都展开,它就开始一次执行一个命令。

让我们看一下 makefile 中顺序很重要的两个部分。如前所述,立即展开目标 $(OUTPUT_DIR)/very_big_file。如果变量 OUTPUT_DIR 的定义遵循了该规则,则目标的展开将生成 /very_big_file。可能不是用户想要的。类似地,如果 BIN 的定义在 AWK 之后被移除,这三个变量就会展开为 /printf/df/AWK,因为使用 := 会立即对赋值的右侧进行计算。然而,在这种情况下,我们可以通过更改 :== 来避免 PRINTFDFAWK 的问题,使它们成为递归变量。

最后一个细节。请注意,将 OUTPUT_DIRBIN 的定义更改为递归变量并不会改变前面排序问题的效果。重要的问题是,当 $(OUTPUT_DIR)/very_big_file 以及 PRINTFDFAWK 的右侧展开时,它们的展开会立即发生,因此它们引用的变量必须已经定义。

特定目标的模式变量

在 makefile 执行期间,变量通常只有一个值。这是由 makefile 处理的两阶段性质所保证的。在第一阶段,读取 makefile,分配和扩展变量,并构建依赖关系图。在第二阶段,分析并遍历依赖关系图。因此,当执行命令脚本时,所有的变量处理都已经完成。但是假设我们想为单个规则或模式重新定义一个变量。

在这个例子中,我们正在编译的文件需要一个额外的命令行选项 -DUSE_NEW_MALLOC=1,这个选项不应该提供给其他编译器:

gui.o: gui.h
    $(COMPILE.c) -DUSE_NEW_MALLOC=1 $(OUTPUT_OPTION) $<

这里,我们通过复制编译命令脚本并添加新的选项解决了这个问题。这种方法在几个方面不太令人满意。首先,我们在复制代码。如果规则发生变化,或者我们选择用自定义模式规则替换内置规则,则需要更新这段代码,但我们可能会忘记更新。其次,如果许多文件需要特殊处理,那么粘贴这段代码的任务很快就会变得非常繁琐并且容易出错(想象一下有 100 个这样的文件)。

为了解决这个问题和其他问题,make 提供了特定目标的变量。这些是附加在目标上的变量定义,仅在处理该目标及其任何依赖期间有效。使用这个特性,我们可以这样改写前面的例子:

gui.o: CPPFLAGS += -DUSE_NEW_MALLOC=1
gui.o: gui.h
    $(COMPILE.c) $(OUTPUT_OPTION) $<

变量 CPPFLAGS 内置在默认的 C 编译规则中,它包含用于 C 预处理器的选项。通过使用 += 形式的赋值,我们将新选项附加到任何已经存在的值上。现在编译命令脚本可以完全删除:

gui.o: CPPFLAGS += -DUSE_NEW_MALLOC=1
gui.o: gui.h

gui.o 目标正在被处理时,CPPFLAGS 的值将包含 -DUSE_NEW_MALLOC=1,除了其原始内容。当目标完成后,CPPFLAGS 将恢复到原始值。

特定目标变量的通用语法是:

target...: variable = value
target...: variable := value
target...: variable += value
target...: variable ?= value

如您所见,所有不同形式的赋值对于特定目标的变量都是有效的。变量在赋值之前不需要存在。

而且,直到开始处理目标时,才会实际执行变量赋值。因此,赋值的右侧本身可以是另一个特定目标的变量中设置的值。该变量在处理所有依赖的期间也是有效的。

变量来自何处

到目前为止,大多数变量都是在我们自己的 makefile 中显式定义的,但是变量可以有更复杂的起源。例如,我们已经看到变量可以在 make 命令行上定义。事实上,make 变量有以下几个来源:


  • 文件

当然,变量可以在 makefile 中定义,也可以在 makefile 中包含的文件中定义(我们稍后将讨论 include 指令)。


  • 命令行

变量可以直接从 make 命令行定义或重新定义:

$ make CFLAGS=-g CPPFLAGS='-DBSD -DDEBUG'

包含 = 的命令行参数是一个变量赋值。命令行上的每个变量赋值都必须是一个单 shell参数。如果变量的值(或者变量本身)包含空格,参数必须用引号括起来,或者空格必须转义。

在命令行上对变量的赋值将覆盖环境中的任何值和 makefile 中的任何赋值。命令行赋值可以分别使用 :== 设置简单变量或递归变量。可以使用 override 指令来允许使用 makefile 赋值来代替命令行赋值。

# Use big-endian objects or the program crashes!
override LDFLAGS = -EB

当然,您应该只在最紧急的情况下才忽略用户明确的赋值请求(除非你只是想激怒你的用户)。


  • 环境变量

在 make 启动时,环境中的所有变量都会自动定义为 make 变量。这些变量的优先级很低,因此 makefile 或命令行参数中的赋值将覆盖环境变量的值。您可以使用 --environment-overrides (-e) 命令行选项使环境变量覆盖 makefile 变量。

当 make 被递归调用时,来自父 make 的一些变量会通过环境变量传递给子 make。默认情况下,只有那些最初来自环境的变量被导出到子环境中,但是任何变量都可以通过使用 export 指令导出到环境中:

export CLASSPATH := $(HOME)/classes:$(PROJECT)/classes
SHELLOPTS = -x
export SHELLOPTS

你可以用以下方式导出所有的变量:

export

注意,make 甚至会导出那些名称中包含无效 shell 变量字符的变量。例如:

export valid-variable-in-make = Neat!
show-vars:
    env | grep '^valid-'
    valid_variable_in_shell=Great
    invalid-variable-in-shell=Sorry
$ make
env | grep '^valid-'
valid-variable-in-make=Neat!
valid_variable_in_shell=Great
invalid-variable-in-shell=Sorry
/bin/sh: line 1: invalid-variable-in-shell=Sorry: command not found
make: *** [show-vars] Error 127

通过导出 valid-variable-in-make 创建了一个无效的 shell 变量。这个变量不能通过普通 shell 语法访问,只能通过在环境上运行 grep 这样的技巧来访问。然而,这个变量会被任何有效和可访问的子 make 所继承。我们将在第二部分讨论 “递归” make 的使用。

你还可以防止将环境变量导出到子进程:

unexport DISPLAY

导出和取消导出指令的工作方式与 sh 中的对应指令相同。

条件赋值操作符可以很好地与环境变量交互。假设您在 makefile 中设置了一个默认的输出目录,但是您希望用户能够轻松地覆盖这个默认目录。条件赋值非常适合这种情况:

# Assume the output directory $(PROJECT_DIR)/out.
OUTPUT_DIR ?= $(PROJECT_DIR)/out

这里,只有在从未设置OUTPUT_DIR时才执行赋值。我们可以用几乎相同的效果但更冗长的方式:

ifndef OUTPUT_DIR
    # Assume the output directory $(PROJECT_DIR)/out.
    OUTPUT_DIR = $(PROJECT_DIR)/out
endif

不同之处是,如果变量以任何方式设置,条件赋值操作符将跳过赋值操作,即使设置为空值,而 ifdefifndef 操作符将测试是否为非空值。因此,OUTPUT_DIR= 被认为是由条件操作符设置的,而不是由 ifdef 定义的。

需要注意的是,过度使用环境变量会使您的 makefile 的可移植性大大降低,因为其他用户不太可能拥有相同的环境变量集。正是因此,我很少使用这个特性。


  • 自动变量

最后,make 在执行规则的命令脚本之前立即创建自动变量。


传统上,环境变量用于帮助管理开发人员机器之间的差异。例如,根据 makefile 中引用的环境变量创建开发环境(源代码、编译后的输出树和工具)是很常见的。makefile 将引用每个树根的环境变量。如果源文件树是从变量 PROJECT_SRC 引用的,从 PROJECT_BIN 引用的二进制输出文件,从 PROJECT_LIB 引用的库,那么开发人员可以自由地将这些树放置在任何适当的地方。

当没有设置这些 “根” 变量时,这种方法(以及通常使用的环境变量)会产生一个潜在的问题。一种解决方案是在 makefile 中使用 $? 形式的任务:

PROJECT_SRC ?= /dev/$(USER)/src
PROJECT_BIN ?= $(patsubst %/src,%/bin,$(PROJECT_SRC))
PROJECT_LIB ?= /net/server/project/lib

通过使用这些变量来访问项目组件,您可以创建一个适应于不同机器布局的开发环境。(我们将在第二部分看到更全面的例子),但是,要注意不要过度依赖环境变量。一般来说,makefile 应该能够在开发环境的最小支持下运行,因此请确保提供合理的默认值并检查关键组件的存在。

条件和包含处理

在使用条件处理指令读取 makefile 时,可以省略或选择 makefile 的某些部分。控制选择的条件可以有几个形式,如 “是否已定义” 或“是否相等”。例如:

# COMSPEC is defined only on Windows.
ifdef COMSPEC
    PATH_SEP := ;
    EXE_EXT := .exe
else
    PATH_SEP := :
    EXE_EXT :=
endif

如果定义了变量 COMSPEC,这将选择条件的第一个分支。

条件指令的基本语法是:

if-condition
    text if the condition is true
endif

或者

if-condition
    text if the condition is true
else
    text if the condition is false
endif

条件分支可以是:

ifdef variable-name
ifndef variable-name
ifeq test
ifneq test

对于 ifdef/ifndef 条件,变量名不应该被 $() 包围。最后,条件可以表示为一下任意一种:

"a" "b"
(a,b)

其中单引号或双引号可以互换使用(但必须使用匹配的引号)。

条件处理指令可以在宏定义和命令脚本中使用,也可以在 makefile 的顶层使用:

libGui.a: $(gui_objects)
    $(AR) $(ARFLAGS) $@ $<
    ifdef RANLIB
        $(RANLIB) $@
    endif

我喜欢缩进条件语句,但不小心缩进会导致错误。在前面的行中,条件指令缩进四个空格,而包含的命令有一个前导制表符。如果所包含的命令没有以制表符开头,则make将不能识别它们为命令。如果条件指令有前置选项卡,它们将被错误地识别为命令并传递给子 shell。

ifeqifneq 条件会测试它们的参数是否相等。条件处理中的空格可能很难处理。例如,当使用条件的圆括号形式时,逗号后面的空格被忽略,但其他所有的空格都是重要的:

ifeq (a, a)
    # These are equal
endif

ifeq ( b, b )
    # These are not equal - ' b' != 'b '
endif

就我个人而言,我坚持用引号的形式:

ifeq "a" "a"
    # These are equal
endif

ifeq 'b' 'b'
    # So are these
endif

即便如此,变量展开时经常会包含意料之外的空格。这可能会导致一些问题,因为条件中包含所有字符。要创建更健壮的 makefile,请使用strip 函数:

ifeq "$(strip $(OPTIONS)) "-d"
    COMPILATION_FLAGS += -DDEBUG
endif

include 指令

我们第一次看到 include 指令是在第二章 “自动依赖生成” 的一节。(现在让我们更详细地复习一下)

makefile 可以包含其他文件。这通常是为了在 make 头文件中放置通用 make 定义或包含自动生成的依赖信息。include 指令是这样使用的:

include definitions.mk

指令可以指定任意数量的文件和 shell 通配符,也允许使用 make 变量。

include 和依赖

当 make 遇到 include 指令时,它展开通配符和变量引用,然后尝试读取 include 的文件。如果文件存在,则正常继续。但是,如果文件不存在,则 make 打印问题并继续读取 makefile 的其余部分。当所有读取完成后,make 在规则数据库中查找任意规则来更新 include 文件。如果找到匹配项,make 将遵循更新目标的正常流程。如果任何包含文件被规则更新,那么 make 将清除其内部数据库并重新读取整个 makefile。如果,在完成读取、更新和重读的过程后,仍然有 include 指令由于缺少文件而失败,make将以错误状态终止。

通过下面的两个文件示例,我们可以看到这个过程的实际情况。我们使用内置的 warning 函数来打印来自 make 的一条简单消息。(本功能和其他功能将在第四章中详细介绍。),下面是 makefile:

# Simple makefile including a generated file.
include foo.mk
$(warning Finished include)

foo.mk: bar.mk
    m4 --define=FILENAME=$@ bar.mk > $@

这是 bar.mk,include 文件的源文件:

# bar.mk - Report when I am being read.
$(warning Reading FILENAME)

当运行时,我们看到:

$ make
Makefile:2: foo.mk: No such file or directory
Makefile:3: Finished include
m4 --define=FILENAME=foo.mk bar.mk > foo.mk
foo.mk:2: Reading foo.mk
Makefile:3: Finished include
make: `foo.mk' is up to date.

第一行显示 make 无法找到包含文件,但第二行显示 make 继续读取和执行 makefile。读取完成后,make 会发现一条规则来创建包含文件 foo.mk,它确实这样干。然后,make 再次启动整个过程,这一次读取 include 文件不会遇到任何困难。

现在是时候提一下 make 也会把 makefile 本身作为一个可能的目标。在读取了整个 makefile 之后,make 将寻找一条规则来重新生成当前正在执行的 makefile。如果找到一个,make 将处理规则,然后检查 makefile 是否已更新。如果是这样,make 将清除其内部状态并重新读取 makefile,再次执行整个分析。下面是一个基于这种行为的无限循环的愚蠢例子:

.PHONY: dummy
makefile: dummy
    touch $@

当 make 执行这个 makefile 时,它看到这个 makefile 过期了(因为 .PHONY 目标 dummy 过期了),所以它执行 touch 命令,该命令更新 makefile 的时间戳。然后,make 重新读取文件,发现 makefile 已过期....嗯,你懂的。

make 在哪里查找 include 的文件?显然,如果 include 的参数是一个绝对文件引用,make 将读取该文件。如果文件引用是相对的,make 首先在其当前工作目录中查找。如果 make 无法找到该文件,它将使用 --include-dir (-I) 选项继续搜索您在命令行上指定的任何目录。之后,将搜索设置为类似于:/usr/local/include/usr/gnu/include/usr/include 的编译搜索路径。由于make 的编译方式,这条路径可能会有轻微的变化。

如果 make 无法找到包含文件,并且它不能使用规则创建它,则 make 会在退出时出错。如果你想让 make 忽略它无法加载的 include 文件,在 include 指令前加上 -:

-include i-may-not-exist.mk

为了与其他 make 兼容,sinclude-include 的别名。

标准 make 变量

除了自动变量,make 维护显示其自身状态的变量,以及用于自定义内置规则的变量:


  • MAKE_VERSION

这是 GNU make 的版本号。在撰写本文时,其值为 3.80,而 CVS 存储库中的值为 3.81rc1。 make 的早期版本 3.79.1 不支持eval 和 value 函数(除其他更改外),它仍然很常见。因此,当我编写需要这些功能的 makefile 时,我使用此变量来测试正在运行的 make 的版本。我们将在第四章的 “流程控制” 部分中看到一个示例。


  • CURDIR

这个变量包含正在执行的 make 进程的当前工作目录(cwd)。除非使用 --directory (-C) 选项,否则这个目录将与执行 make 程序的目录相同(并且与 shell 变量 PWD 相同)。--directory 选项指示 make 在搜索任何 makefile 之前更改到一个不同的目录。该选项的完整形式是 --directory=directory-name-C directory-name。如果使用 --directory, CURDIR 将包含 --include-dir 的目录参数。

我通常在编码时从 emacs 调用 make。例如,我当前的项目是用 Java 编写的,并在顶级目录(不一定是包含代码的目录)中使用单个 makefile。在本例中,使用 --directory 选项允许我从源树中的任何目录调用 make,并且仍然访问 makefile。在 makefile中,所有路径都是相对于 makefile 目录的。偶尔需要绝对路径,可以使用 CURDIR 访问它们。


  • MAKEFILE_LIST

这个变量包含 make 读取的每个文件的列表,包括默认的 makefile 和命令行或通过 include 指令指定的 makefile。在读取每个文件之前,名称被追加到 MAKEFILE_LIST 变量。因此,makefile 总是可以通过检查列表的最后一个单词来确定自己的名称。


  • MAKECMDGOALS

MAKECMDGOALS 变量包含在命令行上为 make 的当前执行指定的所有目标的列表。它不包括命令行选项或变量赋值。例如:

$ make -f- FOO=bar -k goal <<< 'goal:;# $(MAKECMDGOALS)'
# goal

该示例使用了“技巧”,即告诉 make 使用 -f- (--file)选项从 stdin 读取 makefile。使用 bash 的内嵌字符串 <<< 语法,从命令行字符串中重定向 stdin。makefile 本身包含默认目标,而命令脚本在同一行中,用分号分隔目标和命令。命令脚本只包含一行:

# $(MAKECMDGOALS)

当目标需要特殊处理时,通常使用 MAKECMDGOALS。主要的例子是 “clean” 目标。当调用 “clean” 时,make 不应该执行通常由include 触发的依赖文件生成(在第2章 “自动依赖生成” 一节中讨论)。为了防止这种情况,可以使用 ifneqMAKECMDGOALS:

ifneq "$(MAKECMDGOALS)" "clean"
    -include $(subst .xml,.d,$(xml_src))
endif

  • .VARIABLES

它包含到目前为止所读取的 makefile 中定义的所有变量的名称列表,特定于目标的变量除外。该变量是只读的,对它的任何赋值都会被忽略。

list:
    @echo "$(.VARIABLES)" | tr ' ' '\015' | grep MAKEF
$ make
MAKEFLAGS
MAKEFILE_LIST
MAKEFILES

如您所见,变量还用于自定义 make 内置的隐式规则。C/C++ 的规则是这些变量在所有编程语言中的典型形式。下图显示了从一种文件类型控制转换到另一种文件类型的变量。

3-1

这些变量的基本形式是 ACTION.suffix。编译 ACTION 用于创建对象文件,LINK 用于创建可执行文件,或者“特殊”操作PREPROCESS, YACC, LEX 用于运行 C 预处理器,yacc ,或 lex。suffix 表示源文件的类型。

通过这些变量的标准“路径”,比如 c++,使用两个规则。首先,将 c++ 源文件编译为目标文件。然后将目标文件链接到可执行文件中。

%.o: %.C
    $(COMPILE.C) $(OUTPUT_OPTION) $<

%: %.o
    $(LINK.o) $^ $(LOADLIBES) $(LDLIBS) -o $@

第一条规则使用了这些变量定义:

COMPILE.C = $(COMPILE.cc)
COMPILE.cc = $(CXX) $(CXXFLAGS) $(CPPFLAGS) $(TARGET_ARCH) -c
CXX = g++
OUTPUT_OPTION = -o $@

GNU make 支持用 .C.cc 两种后缀来表示 c++ 源文件。CXX 变量表示要使用的 c++ 编译器,默认为 g++变量CXXFLAGSCPPFLAGSTARGET_ARCH 没有默认值。它们的目的是供最终用户自定义构建过程。这三个变量分别保存 c++ 编译器标志、C 预处理器标志和特定于体系架构的编译选项。OUTPUT_OPTION 包含输出文件选项。

链接规则更简单一些:

LINK.o = $(CC) $(LDFLAGS) $(TARGET_ARCH)
CC = gcc

该规则使用C编译器将目标文件组合成可执行文件。C 编译器的默认值是 gcc。LDFLAGSTARGET_ARCH 没有默认值。LDFLAGS 变量保存链接的选项,比如 -L 标记。LOADLIBESLDLIBS 变量包含要链接的库列表。包含两个变量主要是为了可移植性。

这是对 make 变量走马观花。还有更多的变量,但这让您了解了变量如何与规则集成。另一组变量处理 TEX,并有自己的一组规则。递归 make 是变量支持的另一个特性。我们将在第六章讨论这个主题。

参考资料