GNU Make 项目管理 第五章 命令

CreateTime 2021-04-18
UpdateTime 2021-04-22

我们已经介绍了 make 命令的许多基本元素,但是为了确保我们都在同一页面上,让我们稍微回顾一下。

命令本质上是一行 shell 脚本。实际上,make 抓取命令行行并将其传递给子 shell 执行。事实上,make 可以优化这个(相对)昂贵的fork/exec 算法,如果它能保证省略 shell 不会改变程序的行为。它通过扫描每个命令行来检查 shell 特殊字符,比如通配符和 I/O 重定向。如果没有找到,make 直接执行命令,而不将它传递给子 shell。

默认情况下,shell 使用 /bin/sh。这个 shell 由 make 变量 SHELL 控制,但它不是从环境中继承的。当 make 启动时,它从用户环境中导入除 SHELL 之外的所有变量作为 make 变量。这是因为用户对 shell 的选择不应该导致 makefile(可能包含在某些下载的软件包中)失败。如果用户真的想更改 make 使用的默认 shell,他可以在 makefile 中显式地设置 shell 变量。我们将在本章后面的“使用哪个 shell” 一节中讨论这个问题。

解析命令

在 make 目标之后,第一个字符是制表符的行被认为是命令(除非前一行用反斜杠继续)。GNU make 在其他上下文中处理制表符时尽可能地智能。例如,当不存在可能的歧义时,注释、变量赋值和 include 指令都可以使用制表符作为它们的第一个字符。如果 make 读取的命令行没有立即跟随目标,则会显示错误消息:

makefile:20: *** commands commence before first target. Stop.

这条消息的措辞有点奇怪,因为它经常出现在 makefile 的中间,在“第一个”目标被指定很久之后,但是我们现在可以很容易地理解它。对该消息更好的措辞可能是,“遇到目标上下文之外的命令”。

当解析器在合法上下文中看到一个命令时,它会切换到“命令解析”模式,一次一行地执行脚本。当遇到不可能是命令脚本的一部分的行时,它将停止向脚本追加内容。脚本在这里结束。命令脚本中可能会出现以下内容:

  • 以制表符开头的行是由子 shell 执行的命令。在“命令解析”模式下,即使通常被解释为 make 结构(例如,ifdef、注释、include 指令)的行也会被视为命令。
  • 空白行被忽略。它们不是由子 shell 执行的。
  • # 开头的行,可能带有前导空格(不是制表符),是 makefile 注释,会被忽略。
  • 条件处理指令,如 ifdefifeq,可以在命令脚本中正常识别和处理。

内置的 make 函数除非前面有一个制表符,否则将终止命令解析模式。这意味着它们必须扩展为有效的 shell 命令,或者什么也不做。函数warningeval 扩展成为空字符。

在命令脚本中允许空行和注释,这一事实一开始可能会让人感到惊讶。以下几行显示了它是如何执行的:

long-command:
        @echo Line 2: A blank line follows

        @echo Line 4: A shell comment follows
        # A shell comment (leading tab)
        @echo Line 6: A make comment follows
# A make comment, at the beginning of a line
        @echo Line 8: Indented make comments follow
  # A make comment, indented with leading spaces

        # Another make comment, indented with leading spaces
        @echo Line 11: A conditional follows
    ifdef COMSPEC
        @echo Running Windows
    endif
        @echo Line 15: A warning "command" follows
        $(warning A warning)
        @echo Line 17: An eval "command" follows
        $(eval $(shell echo Shell echo 1>&2))

注意,第 5 行和第 10 行看起来是相同的,但有很大的不同。第 5 行是 shell 注释,由一个前导制表符表示,而第 10 行是一个缩进 8 个空格的 make 注释。显然,我们不建议以这种方式格式化 make 注释(除非您打算进入一个模糊的 makefile 比赛)。正如你在下面的输出中看到的,make 注释不会被执行,也不会回显到输出中,即使它们发生在命令脚本的上下文中:

$ make
makefile:2: A warning
Shell echo
Line 2: A blank line follows
Line 4: A shell comment follows
# A shell comment (leading tab)
Line 6: A make comment follows
Line 8: Indented make comments follow
Line 11: A conditional follows
Running Windows
Line 15: A warning command follows
Line 17: An eval command follows

warningeval 函数的输出看起来是混乱的,但不要担心,事实并非如此。(我们将在本章后面的“计算命令”一节中讨论计算的顺序)命令脚本可以包含任意数量的空白行和注释,这是一个令人沮丧的错误来源。假设您无意中引入了带有前导制表符的行。如果前一个目标存在(带有或不带有命令),并且中间只有注释或空白行,make 将把偶然的制表符行视为与前一个目标关联的命令。如您所见,这是完全合法的,并且不会生成警告或错误,除非相同的目标在 makefile (或其中一个包含文件)的其他地方有规则。

如果幸运的话,makefile 将在偶然的制表符行和前面的命令脚本之间包含一个非空白、非注释。在这种情况下,您将获得“在第一个目标之前开始命令(commands commence before first target)”消息。

现在是简要介绍软件工具的好时机。我想现在每个人都同意,使用前导制表符来表示命令行是一个不幸的决定,但现在改变有点晚了。使用一个现代的、支持语法的编辑器可以通过明显地标记可疑的结构来帮助避免潜在的问题。GNU emacs 有一个非常好的编辑 makefile 的模式。该模式执行语法高亮显示,并查找简单的语法错误,如连续行后的空格和前导空格和制表符的混合。稍后我将更多地讨论使用 emacs 和 make。

拆分长命令

由于每个命令都在自己的 shell 中执行(至少看起来是这样),因此必须对需要一起运行的 shell 命令序列进行特殊处理。例如,假设需要生成一个 include 文件列表的文件。Java编译器接受这样一个文件来编译许多源文件。我可能会写这样一个命令脚本:

.INTERMEDIATE: file_list
file_list:
    for d in logic ui
    do
        echo $d/*.java
    done > $@

到目前为止,应该很清楚这是行不通的。它产生错误:

$ make
for d in logic ui
/bin/sh: -c: line 2: syntax error: unexpected end of file
make: *** [file_list] Error 2

我们的第一个修复是在每行中添加连续字符:

.INTERMEDIATE: file_list
file_list:
    for d in logic ui   \
    do                  \
        echo $d/*.java  \
    done > $@

然后产生错误:

$ make
for d in logic ui \
do \
echo /*.java \
done > file_list
/bin/sh: -c: line 1: syntax error near unexpected token `>'
/bin/sh: -c: line 1: `for d in logic ui do echo /*.java
make: *** [file_list] Error 2

发生了什么?两个问题。首先,需要转义对循环控制变量 d 的引用。第二,因为 for 循环是作为一行代码传递给子 shell 的,所以必须在文件列表和 for 循环语句之后添加分号分隔符:

.INTERMEDIATE: file_list
file_list:
    for d in logic ui;      \
    do                      \
        echo $$d/*.java;    \
    done > $@

目标被声明为 .INTERMEDIATE,这样 make 将在编译完成后删除这个临时目标。在一个更实际的示例中,目录列表将存储在 make 变量中。如果我们确定文件的数量相对较少,我们可以使用 make 函数在不使用 for 循环的情况下执行相同的操作:

.INTERMEDIATE: file_list
file_list:
    echo $(addsuffix /*.java,$(COMPILATION_DIRS)) > $@

make 命令脚本的另一个常见问题是如何切换目录。同样,应该清楚的是一个简单的命令脚本,如:

TAGS:
    cd src
    ctags --recurse

要获得我们想要的效果,我们必须要么将两个命令放在一行中,要么用反斜杠转义换行符(并用分号分隔命令):

TAGS:
    cd src; \
    ctags --recurse

更好的版本是在执行 ctags 程序之前检查 cd 的状态:

TAGS:
    cd src && \
    ctags --recurse

注意,在某些情况下,省略分号可能不会产生 make 或 shell 错误:

disk-free = echo "Checking free disk space..." \
df . | awk '{ print $$4 }'

这个例子打印了一条简单的消息,后面跟着当前设备上空闲块的数量。还是它? 我们无意中忽略了 echo 命令后面的分号,因此我们从未真正运行 df 程序。而是, 打印了出来:

Checking free disk space... df .

进入awk,它会如实地打印第四个字段,空格....

您可能会想到使用 define 指令,它旨在创建多行命令序列,而不是连续行。不幸的是,这不是一个完全相同的问题。当展开一个多行宏时,每一行都被插入到带有前导制表符的命令脚本中,并独立地处理每一行。宏的行不会在单个子 shell 中执行。因此,您还需要注意宏中的命令行延续。

命令修改器

一个命令可以被几个前缀修改。我们已经看到“静默”的前缀 @ 被多次使用。完整的前缀列表以及一些恐怖的细节如下:


  • @

不回显命令。为了与历史兼容,如果你想隐藏它的所有命令,你可以将你的目标作为特殊目标. .SILENT 的依赖。但是,最好使用 @,因为它可以应用于命令脚本中的单个命令。如果您想将此修饰符应用于所有目标(尽管很难想象为什么),您可以使用 --silent(或-s) 选项。

隐藏命令可以使 make 的输出看起来更容易,但是它也可以使调试命令变得更加困难。如果你发现自己经常删除 @ 修饰符并恢复它们,你可以创建一个包含 @ 修饰符的变量,比如 QUIET,并在命令上使用它:

QUIET = @
hairy_script:
    $(QUIET) complex script …

然后,如果你需要看到 make 运行的复杂脚本,只需从命令行重置 QUIET 变量:

$ make QUIET= hairy_script
complex script …

  • -

- 号前缀表示 make 可以忽略命令中的错误。默认情况下,make 执行命令时,它会检查程序或管道的退出状态,如果返回一个非零(失败)退出状态,make 将终止命令脚本剩余部分的执行并退出。这个修饰符指示 make 忽略被修改行的退出状态并继续,就像没有发生错误一样。我们将在下一节中更深入地讨论这个主题。

为了历史兼容性,您可以通过将目标作为 .IGNORE 特殊目标的依赖来忽略命令脚本中任何部分的错误。如果想忽略整个 makefile 中的所有错误,可以使用 --ignore-errors (-i) 选项。同样,这看起来也不是很有用。


  • +

+ 修饰符告诉 make 执行命令,即使给了 make --just-print (-n) 命令行选项。它用于编写递归生成文件。我们将在第6章的“递归 make” 一节中更详细地讨论这个主题。


所有这些修饰符都可以放在一行上。显然,在执行命令之前,修饰符会被删除。

错误与中断

每个使执行的命令都返回一个状态码。状态为 0 表示命令执行成功。非零状态表示某种类型的失败。有些程序使用返回状态代码来指示一些比简单的“错误”更有意义的东西。例如,grep 如果找到匹配,则返回 0 (成功),如果没有找到匹配,则返回 1,如果发生了某种错误,则返回2。

通常,当程序失败时(即返回一个非零退出状态),make 停止执行命令并退出,并返回一个错误状态。有时候你想要继续,努力完成尽可能多的目标。例如,您可能希望编译尽可能多的文件,以便在一次运行中查看所有编译错误。您可以使用 --keep-going (-k) 选项来实现这一点。

虽然 - 修饰符会导致 make 忽略个别命令中的错误,但尽量避免使用它。这是因为它使自动错误处理变得复杂,而且吼逼难看的。

当 make 忽略一个错误时,它将打印一个警告,并在方括号中显示目标的名称。例如,当 rm 试图删除一个不存在的文件时,输出如下:

rm non-existent-file
rm: cannot remove `non-existent-file': No such file or directory
make: [clean] Error 1 (ignored)

有些命令,如 rm,有一些选项可以抑制其错误退出状态。-f 选项将强制 rm 返回成功,同时也会抑制错误消息。使用这样的选项比依赖前面的 - 要好。

有时,您希望命令失败,并且希望在程序成功时得到一个错误。对于这些情况,你应该能够简单地否定程序的退出状态:

# Verify there are no debug statements left in the code.
.PHONY: no_debug_printf
no_debug_printf: $(sources)
    ! grep --line-number '"debug:' $^

不幸的是,make 3.80 中有一个 bug 阻碍了这种直接的使用。 make 不能识别 ! 字符,需要 shell 处理,并执行命令行本身,导致错误。在这种情况下,一个简单的解决方法便是为 make 添加一个 shell 特殊角色作为线索:

# Verify there are no debug statement left in the code
.PHONY: no_debug_printf
no_debug_printf: $(sources)
    ! grep --line-number '"debug:' $^ < /dev/null

意外命令错误的另一个常见来源是使用 shell 的 if 结构而没有使用 else

$(config): $(config_template)
    if [ ! -d $(dir $@) ];  \
    then                    \
        $(MKDIR) $(dir $@); \
    fi
    $(M4) $^ > $@

第一个命令测试输出目录是否存在,如果不存在,则调用 mkdir 创建它。不幸的是,如果目录确实存在,if 命令将返回一个失败退出状态(测试的退出状态),从而终止脚本。一个解决方案是添加一个 else 子句:

$(config): $(config_template)
    if [ ! -d $(dir $@) ];  \
    then                    \
        $(MKDIR) $(dir $@); \
    else                    \
        true;               \
    fi
    $(M4) $^ > $@

在 shell 中,冒号(:)是一个占位命令,它总是返回 true,可以用来代替 true。另一种可行的实现是:

$(config): $(config_template)
    [[ -d $(dir $@) ]] || $(MKDIR) $(dir $@)
    $(M4) $^ > $@

现在,当目录存在或 mkdir 成功时,第一个语句为真。另一种选择是使用 mkdir -p。这允许 mkdir 成功,即使目录已经存在。即使目录存在,所有这些实现也会在子 shell 中执行某些内容。通过使用通配符,如果目录存在,则可以完全省略执行。

# $(call make-dir, directory)
make-dir = $(if $(wildcard $1),,$(MKDIR) -p $1)

$(config): $(config_template)
    $(call make-dir, $(dir $@))
    $(M4) $^ > $@

因为每个命令都是在自己的 shell 中执行的,所以通常会有多行命令,每个组件之间用分号分隔。请注意,这些脚本中的错误可能不会终止脚本:

target:
    rm rm-fails; echo But the next command executes anyway

最好尽量减少命令脚本的长度,让您有机会管理退出状态和终止。例如:

path-fixup = -e "s;[a-zA-Z:/]*/src/;$(SOURCE_DIR)/;g" \
-e "s;[a-zA-Z:/]*/bin/;$(OUTPUT_DIR)/;g"

# A good version.
define fix-project-paths
    sed $(path-fixup) $1 > $2.fixed && \
    mv $2.fixed $2
endef

# A better version.
define fix-project-paths
    sed $(path-fixup) $1 > $2.fixed
    mv $2.fixed $2
endef

这个宏将 DOS 样式的路径(带有反斜杠)转换为特定源和输出树的目标路径。这个宏接受两个文件名,输入和输出文件。只有当 sed 命令正确完成时,才重写输出文件。好版本通过将 sed 和 mv 与 && 连接起来,以便它们在单个 shell 中执行。更好的版本将它们作为两个单独的命令执行,让 make 在 sed 失败时终止脚本。更好的版本不会更耗时(mv 不需要 shell,直接执行),更容易阅读,并在发生错误时提供更多信息(因为 make 将指出哪个命令失败)。

请注意,这与 cd 常见的问题不同:

TAGS:
    cd src && \
    ctags --recurse

在这种情况下,这两个语句必须在同一子 shell 中执行。因此,命令必须用某种语句连接器分隔,例如 ; 或者 &&

删除和保存目标文件

如果发生错误,make 假定目标不能重试。以当前目标为依赖的任何其他目标也不能重新创建,因此 make 将不会尝试它们,也不会执行它们的命令脚本的任何部分。如果使用 --keep-going (-k) 选项,将尝试下一个目标,否则,就退出。如果当前目标是一个文件,如果命令在完成其工作之前退出,它可能会损坏。不幸的是,由于历史兼容性的原因,make 将把这个可能损坏的文件留在磁盘上。因为文件的时间戳已经更新,后续执行 make 可能不会用正确的数据更新文件。您可以通过将目标文件作为 .DELETE_ON_ERROR 的依赖来避免这个问题,并让make 在发生错误时删除这些有问题的文件。如果 .DELETE_ON_ERROR 没有依赖,任何目标文件构建中的错误都会导致 make 删除目标。

当 make 被一个信号(如 Ctrl-C )打断时,就会出现一个相反的问题。在这种情况下,如果文件已被修改,make 将删除当前目标文件。有时删除文件是错误的。也许创建该文件的开销非常大,部分内容总比没有好,或者该文件必须存在,以便构建的其他部分继续进行。在这些情况下,您可以通过使它成为特殊目标 .PRECIOUS 的依赖来保护文件。

该用哪个 shell

当 make 需要将命令行传递给子 shell 时,它使用 /bin/sh。您可以通过设置 make 变量 SHELL 来更改 shell。在做这件事之前要仔细考虑。通常,使用 make 的目的是为开发者社区提供一种工具,以便从源代码组件构建系统。通过使用社区中其他开发者无法使用的工具,很容易创建一个无法实现此目标的 makefile。在任何广泛分布的应用程序(通过匿名 ftp 或打开 cvs 发布的应用程序)中使用 /bin/sh 以外的任何 shell 都被认为是非常糟糕的形式。我们将在第七章更详细地讨论可移植性。

但是,使用 make 还有另一个上下文。通常,在封闭的开发环境中,开发人员在有限的机器和操作系统上与一组批准的开发人员一起工作。事实上,这是我经常发现自己身处的环境。在这种情况下,定制期望在其下运行的环境是非常有意义的。开发人员将被指导如何设置他们的环境,以便让构建和生活一切如常。

在这样的环境中,我倾向于“预先”牺牲一些可移植性。我相信这能够让整个开发过程更加顺利。其中一个牺牲就是显式地将 SHELL 变量设置为 /usr/bin/bash bash shell 是一个可移植的、posix 兼容的 shell (因此是 sh 的超集),是 GNU/Linux 上的标准 shell。makefile 中的许多可移植性问题是由于在命令脚本中使用了不可移植的构造。这可以通过显式地使用一个标准 shell 而不是解决写作 sh 的便携式子集。保罗·史密斯 GNU 的维护者,在他的一个网页 “保罗的 makefile 规则”(http://make.paulandlesley.org/rules.html) 中声称:“不要麻烦写可移植 makefile, 使用可移植的 make!”,我还会说,“在可能的情况下,不要编写可移植的命令脚本,而是使用可移植的 shell (bash)。” bash shell 运行在大多数操作系统上,包括几乎所有的 Unix、Windows、BeOS、Amiga 和OS/2变种。

对于本书的其余部分,我将注意命令脚本何时使用特定于 bash 的特性。

空命令

空命令是什么也不做的命令。

header.h: ;

回想一下,目标的依赖列表可以后面跟着分号和命令。这里的分号后面没有任何内容,表示没有命令。您可以用只包含一个制表符的行跟随目标,但这是不可能读取的。空命令通常用于防止模式规则匹配目标并执行您不想要的命令。

请注意,在 make 的其他版本中,空目标有时被用作伪目标。在 GNU make 中,使用 .PHONY 特殊目标代替;它更安全,更清晰。

命令环境

make 执行的命令从 make 本身继承其处理环境。此环境包括当前工作目录、文件描述符和由 make 传递的环境变量。

当创建子 shell 时,make 会向环境中添加一些变量:

  • MAKEFLAGS
  • MFLAGS
  • MAKELEVEL

MAKEFLAGS 变量包含传递给 make 的命令行选项。MFLAGS 变量是 MAKEFLAGS 的镜像,由于历史原因而存在。MAKELEVEL 变量指示嵌套的 make 调用的数量。也就是说,当 make 递归调用 make 时,MAKELEVEL 变量增加 1。单个父进程 make 的子进程的 MAKELEVEL 为1。这些变量通常用于管理递归 make。我们将在第六章的 “递归 make” 一节中讨论它们。

当然,用户可以使用 export 指令将任何变量添加到子流程环境中。

被执行命令的当前工作目录是父 make 的工作目录。这通常与执行 make 程序的目录相同,但可以使用 --directory=directory (-C) 命令行选项。注意,简单地使用 --file 指定一个不同的 makefile 并不会改变当前目录,只会改变 makefile 的读取。

每个子进程生成的派生都继承了三个标准文件描述符:stdinstdoutstderr。这并不是特别值得注意的,除非注意到命令脚本可以读取它的 stdin。这是“合理的”和有效的。一旦脚本完成读取,其余的命令将按预期执行。但是通常认为 makefile 在没有这种交互的情况下运行。用户通常希望能够开始 make,然后“走开”,然后再回来查看结果。当然,读取 stdin 也倾向于与基于 cron 的自动化构建减少交互。

makefile 中的一个常见错误是无意中读取了 stdin:

$(DATA_FILE): $(RAW_DATA)
    grep pattern $(RAW_DATA_FILES) > $@

这里,grep 的输入文件是用一个变量指定的(在本例中是拼写错误的)。如果变量展开为空,grep 将读取 stdin,而没有提示或指示 make 为什么“挂起”。解决这个问题的一个简单方法是在命令行中始终包含 /dev/null 作为一个附加的“文件”:

$(DATA_FILE): $(RAW_DATA)
    grep pattern $(RAW_DATA_FILES) /dev/null > $@

这个 grep 命令永远不会尝试读取 stdin。当然,调试 makefile 也是合适的!

命令求值

命令脚本处理分为四个步骤:读取代码、展开变量、计算 make 表达式和执行命令。让我们看看如何将这些步骤应用于复杂的命令脚本。考虑这个(有点做作的) makefile。一个应用程序被链接,然后有选择地剥离符号并使用 upx 可执行打包器进行压缩:

# $(call strip-program, file)
define strip-program
    strip $1
endef

complex_script:
        $(CC) $^ -o $@
    ifdef STRIP
        $(call strip-program, $@)
    endif
        $(if $(PACK), upx --best $@)
        $(warning Final size: $(shell ls -s $@))

命令脚本的计算被延迟到它们被执行,但是 ifdef 指令在它们出现的任何地方都被立即处理。因此,make 读取命令脚本,忽略内容并存储每一行,直到它到达 ifdef STRIP 行。它对测试条件,如果 STRIP 没有定义,则 make 读取并丢弃直到并包括结束 endif 的所有文本。Make 然后继续读取和存储脚本的其余部分。

当要执行命令脚本时,make 首先扫描脚本,以获取需要展开或计算的 make 结构。展开宏时,在每行前面添加一个前置制表符。如果您没有准备好,那么在执行任何命令之前展开和计算可能会导致意外的执行顺序。在我们的示例中,脚本的最后一行是错误的。shellwarning 命令是在链接应用程序之前执行的。因此,ls 命令将在它所检查的文件被更新之前执行。这解释了前面“解析命令”一节中看到的“无序”输出。

另外,请注意,ifdef STRIP 行是在读取文件时计算的,但是 $(if…) 行是在 complex_script 命令执行之前立即计算的。使用 if 函数更加灵活,因为在定义变量时有更多的机会来控制它,但它不太适合管理大的文本块。

正如这个例子所示,始终关注是什么程序在求表达式的值(例如,make 或 shell),以及什么时候执行求值是很重要的:

$(LINK.c) $(shell find $(if $(ALL),$(wildcard core ext*),core) -name '*.o')

这个复杂的命令脚本试图链接一组目标文件。求值序列和执行运算(括号内)的程序为:

  1. 展开 $ALL (make).
  2. 求值 if (make).
  3. 求值 wildcard, 假设 ALL 不是空的 (make).
  4. 求值 shell (make).
  5. 求值 find (sh).
  6. 在完成 make 构造的扩展和计算之后,执行链接命令 (sh).

命令行限制

在处理大型项目时,您偶尔会遇到命令 make 尝试执行的命令超过了命令行长度的限制。不同操作系统的命令行限制有很大不同。Red Hat 9 GNU/Linux 似乎有一个大约 128K 字符的限制,而 Windows XP 有一个 32K 字符的限制。生成的错误消息也各不相同。在 Windows 上使用 Cygwin 端口,消息是:

C:\usr\cygwin\bin\bash: /usr/bin/ls: Invalid argument

当 ls 被赋予太长的参数列表时。在Red Hat 9上,信息是:

/bin/ls: argument list too long

对于一个命令行来说,即使 32K 听起来也是很多数据,但是如果项目包含 100 个子目录中的 3000 个文件,并且希望对它们进行操作,那么这个限制可能会受到限制。

有两种基本方法可以使您陷入这种混乱:使用 shell 工具展开一些基本值,或者使用 make 本身将变量设置为一个非常长的值。例如,假设我们想在一个命令行中编译所有的源文件:

compile_all:
    $(JAVAC) $(wildcard $(addsuffix /*.java,$(source_dirs)))

source_dirs 可能只包含几百个单词,但是在为 Java 文件添加通配符并使用通配符展开它之后,这个列表很容易超过系统的命令行限制。顺便说一下,make 没有内在的限制来约束我们。只要有可用的虚拟内存,make 将允许创建任何数量的数据。

当你发现自己处于这种情况时,你会觉得自己就像在老的冒险游戏中一样,“你处在一个曲折的迷宫中。”例如,你可以尝试使用 xargs 来解决上述问题,因为 xargs 将通过将参数分配到系统特定的长度来管理长命令行:

compile_all:
    echo $(wildcard $(addsuffix /*.java,$(source_dirs))) | \
    xargs $(JAVAC)

不幸的是,我们刚刚将命令行限制问题从 javac 命令行移到了 echo 命令行。类似地,我们不能使用 echo 或 printf 将数据写入文件(假设编译器可以从文件中读取文件列表)。

不,处理这种情况的方法是避免在一开始就一次性创建所有文件列表。相反,使用 shell 一次抓取一个目录:

compile_all:
for d in $(source_dirs);        \
    do                          \
        $(JAVAC) $$d/*.java;    \
    done

我们也可以通过管道将文件列表传递给 xargs,以减少执行次数:

compile_all:
for d in $(source_dirs);    \
    do                      \
    echo $$d/*.java;        \
    done |                  \
    xargs $(JAVAC)

遗憾的是,这两个命令脚本都不能正确地处理编译期间的错误。更好的方法是保存完整的文件列表并将其提供给编译器,如果编译器支持从文件中读取其参数。Java 编译器支持这个特性:

compile_all: $(FILE_LIST)
    $(JAVA) @$<

.INTERMEDIATE: $(FILE_LIST)
$(FILE_LIST):
    for d in $(source_dirs);    \
    do                          \
        echo $$d/*.java;        \
    done > $@

注意 for 循环中的细微错误。如果任何目录不包含 Java 文件,字符串 *. Java 将被包含在文件列表中,Java编译器将生成一个” file not found(文件未找到)”的错误。我们可以通过设置 nullglob 选项使 bash 将通配符模式压缩为空。

compile_all: $(FILE_LIST)
    $(JAVA) @$<
.INTERMEDIATE: $(FILE_LIST)
$(FILE_LIST):
    shopt -s nullglob; \
    for d in $(source_dirs); \
    do \
    echo $$d/*.java; \
    done > $@

许多项目都必须制作文件列表。下面是一个宏,其中包含一个生成文件列表的 bash 脚本。第一个参数是要更改的根目录。列表中的所有文件都将相对于这个根目录。第二个参数是用于搜索匹配文件的目录列表。第三和第四个参数是可选的,表示文件后缀。

# $(call collect-names, root-dir, dir-list, suffix1-opt, suffix2-opt)
define collect-names
    echo Making $@ from directory list...
    cd $1;                                                  \
    shopt -s nullglob;                                      \
    for f in $(foreach file,$2,'$(file)'); do               \
        files=( $$f$(if $3,/*.{$3$(if $4,$(comma)$4)}) );   \
        if (( $${#files[@]} > 0 ));                         \
        then                                                \
            printf '"%s"\n' $${files[@]};                   \
        else :; fi;                                         \
    done
endef

下面是一个创建图像文件列表的模式规则:

%.images:
@$(call collect-names,$(SOURCE_DIR),$^,gif,jpeg) > $@

宏的执行是隐藏的,因为脚本很长,而且很少有理由剪切和粘贴这段代码。前提条件中提供了目录列表。在切换到根目录之后,脚本启用空通配符。其余部分是一个for循环,用于处理要搜索的每个目录。文件搜索表达式是参数 $2 中传递的单词列表。脚本使用单引号保护文件列表中的单词,因为它们可能包含 shell 特殊字符。特别是,像 Java 这样的语言中的文件名可以包含 $ 符号:

for f in $(foreach file,$2,'$(file)'); do

我们通过用通配符的结果填充文件数组来搜索目录。如果文件数组包含任何元素,则使用 printf 打印后跟换行符的每个单词。使用数组允许宏正确处理带有嵌入空格的路径。这也是 printf 用双引号包围文件名的原因。文件列表由以下这行代码生成:

files=( $$f$(if $3,/*.{$3$(if $4,$(comma)$4)}) );

$$f 是宏的目录或文件参数。如果测试第三个参数是否为非空,则下面的表达式是 make。这就是实现可选参数的方法。如果第三个参数为空,则假定第四个参数也是空的。在这种情况下,用户传递的文件应该原样包含在文件列表中。这允许宏构建通配符模式不合适的任意文件列表。如果提供了第三个参数,则附加 /*.{$3} 到根文件。如果提供了第四个参数,它将在 $3 之后附加 $4。请注意我们在通配符模式中插入逗号时必须使用的诡计。通过在 make 变量中放置逗号,我们可以将它偷偷地通过解析器,否则,逗号将被解释为将 if 中的 then 部分和 else 部分分隔开。逗号的定义很简单:

comma := ,

前面所有 for 循环都受到命令行长度限制,因为它们使用通配符展开。不同之处在于通配符是随着单个目录的内容展开的,而这个目录的内容不太可能超过限制。

如果 make 变量包含我们的长文件列表,我们该怎么办? 那我们就有大麻烦了。我只发现了两种方法来传递一个很长的 make 变量给子 shell。第一种方法是通过过滤内容,仅将变量内容的子集传递给任何一个子 shell 执行。

compile_all:
    $(JAVAC) $(wordlist 1, 499, $(all-source-files))
    $(JAVAC) $(wordlist 500, 999, $(all-source-files))
    $(JAVAC) $(wordlist 1000, 1499, $(all-source-files))

也可以使用 filter 函数,但这可能更不确定,因为所选择的文件数量取决于所选择的模式空间内的分布。这里我们根据字母表选择一个模式:

compile_all:
    $(JAVAC) $(filter a%, $(all-source-files))
    $(JAVAC) $(filter b%, $(all-source-files))

其他模式可能使用文件名本身的特殊特征。请注意,进一步实现自动化是很困难的。我们可以尝试在 foreach 循环中封装字母表方法:

compile_all:
    $(foreach l,a b c d e ...,                      \
        $(if $(filter $l%, $(all-source-files)),    \
            $(JAVAC) $(filter $l%, $(all-source-files));))

但这行不通。make 将其扩展为一行文本,从而产生了行长问题。我们可以使用 eval:

compile_all:
    $(foreach l,a b c d e ...,                  \
      $(if $(filter $l%, $(all-source-files)),  \
        $(eval                                  \
          $(shell                               \
            $(JAVAC) $(filter $l%, $(all-source-files));))))

这是可行的,因为 eval 将立即执行 shell 命令,扩展为空。因此 foreach 循环扩展为空。问题是错误报告在这个上下文中是没有意义的,因此编译错误将不会被正确地传递给 make。

单词列表方法更糟糕。由于 make 有限的数值能力,技术上,无法将 wordlist 封装在一个循环中。通常,很少有令人满意的方法来处理庞大的文件列表。

参考资料