内容


调试 make

让 make 为我们工作而不是为我们制造麻烦

Comments

大部分 UNIX® 和 Linux® 程序都是通过运行 make 来编译的。make 工具会读取一个包含指令的文件(这个文件的名字通常都是 makefile 或 Makefile,不过后文中我们统一称之为 “makefile”),并执行各种操作来编译程序。在很多编译过程中,makefile 自己完全是由其他软件生成的;例如,autoconf/automake 程序就用来开发编译程序。其他程序可能会要求我们直接编辑 makefile,当然,新的开发还可能需要我们自己编写 makefile。

“make 工具”这个短语可能有些容易引起误解。经常使用的 make 工具至少有 3 个变种:GNU make、System V make 和 Berkeley make。它们都是从早期 UNIX 的一个核心规范发展而来的,每个变种都增加了一些新特性。这就导致出现了一种复杂的情况:很常用的一些特性,例如在 makefile 中通过引用来包含其他文件,都不能很好地移植!简单编写程序来创建 makefile 就是一种解决方案。由于 GNU make 是免费的,并且可以广泛地发布,因此有些开发人员就简单地为它来编写代码;类似地,有很多起源于 BSD 的项目都要求我们使用 Berkeley make(这也是免费的)。

稍微逊色一点但依然相关的 make 工具是 Jörg Schilling 的 smake 和 make 家族中的第五位(已不再使用) —— 早先的 make,后者定义了与其他 make 工具共享的一些公共特性的子集。尽管 smake 在任何系统上都不是默认的 make 工具,但是它也是一个很好的 make 实现,有些程序(尤其是 Schilling 的程序)都喜欢使用它。

下面先来回顾一下在使用 makefile 时所遇到的最常见的一些问题。

理解 makefile

要调试 make,需要读取 makefile。正如所了解的那样,makefile 的目标就是为编译程序提供一些指令。make 的主要特性之一就是 依赖性管理:只有在程序源码发生更新必须要重新编译程序时,make 才会真正重新编译程序。通常,这是通过一系列依赖性规则来表示的。其中一种依赖性规则如下所示:

清单 1. 依赖性规则的格式
target: dependencies
	instructions

人们在编写自己的第一个 makefile 时所碰到的主要问题在这个结构中可能看得出来,也可能看不出来:缩进使用的是制表符,而不是多少个空格。由于在这种格式中使用空格所产生的 Berkeley make 错误消息对人们也没什么帮助:

清单 2. Berkeley make 错误消息
make: "Makefile" line 2: Need an operator
make: Fatal errors encountered -- cannot continue

GNU make,尽管不能对这个文件进行处理,但却会给出一个更有用的建议:

清单 3. GNU make 错误消息
Makefile::2: *** missing separator (did you mean TAB instead of 8 spaces?).  Stop.

请注意依赖性和指令都是可选的;只有目标和冒号才是必须的。那么既然语法是这样,语义又该如何呢?其语义是:如果 make 希望编译 target ,那它就会首先查看依赖关系。实际上,它会递归地尝试编译目标;如果所依赖的内容碰巧又依赖其他内容,那么在这条规则继续之前,必须对所依赖的内容进行处理。如果 target 存在,并且至少比 dependencies 中所列出的所有内容都要新,那么就不会执行任何操作。如果 target 不存在,或者有一个或多个依赖内容更新,那么 make 就会执行 instructions 操作。依赖性是按照指定的顺序进行处理的。如果没有指定依赖性,那就总会执行 instructions。所依赖的内容也称为 源(source)

如果在命令行中给出了一个目标(例如 make foo),那么 make 就会试图编译这个目标。否则,它就试图编译文件中列出的第一个目标。一些开发人员采用的约定是让第一个目标看起来如下所示:

清单 4. 通常使用的第一个目标约定
default: all

有些人会假设之所以使用这条规则是因为它是 “默认的”。但实际上并非如此;它之所以这样使用是因为这是该文件中的第一条规则。可以按照自己希望的方式对其进行命名,不过名字 “default” 是一个很好的选择,因为这对于读者来说意义是显而易见的。记住 makefile 是会由人来阅读的,而不是只由 make 程序来使用的。

伪目标

通常我们可以说,目标的功能是从其他文件中创建一个文件。实际上并非总是如此。大部分 makefile 都至少有两条规则,它们从来都不会创建目标。请考虑下面的示例规则:

清单 5. 示例伪目标
all: hello goodbye fibonacci

这条规则会告诉 make —— 如果希望编译目标 all —— 首先要确保 hello、goodbye 和 fibonacci 都是最新的。然后,就什么也不做了。下面并没有提供指令。在这条规则完成之后,并不会创建名为 all 的文件。这个目标是一种假目标。在某些 make 变种中使用的技术术语称之为 “伪目标”。

伪目标是为了组织结构的目的而设计的,这在编写一个清晰的 makefile 时是种非常不错的技术。举例来说,我们可能会经常看到下面的规则:

清单 6. 伪目标的灵活用法
build: clean all install

这指定了编译过程执行的操作顺序。

特殊的目标和源

系统还定义了几个特殊的目标,它们对 make 可以产生一些特别的影响,提供一种可配置的机制。具体的目标集对于每个实现来说都是不同的;其中最通用的一个是 .SUFFIXES 目标,它使用的源是一系列模式,添加在可识别的文件后缀列表中。这些特殊目标并不会用作通用规则来把编译作为 makefile 中默认的第一条目标。

有些版本的 make 允许将特殊源与给定目标的依赖性一起指定,例如 .IGNORE,它说明从编译这个目标所使用的命令中生成的错误都应该忽略,仿佛它们前面都有一个短线一样。这些标记的可移植性并不好,但是对于理解 makefile 来说却是必须的。

通用规则

在 make 中有一些隐式规则用来根据文件名后缀执行通用转换。举例来说,如果现在没有 makefile,可以创建一个名为 “hello.c” 的文件,并运行 make hello 命令:

清单 7. C 文件的隐式规则的例子
$ make hello
cc -O2   -o hello hello.c

大型程序使用的 makefile 可能会简单地指定自己需要的对象模块清单(hello.o、world.o 等),然后为如何将 .c 文件转换成 .o 文件提供一条规则:

清单 8. 将 .c 文件转换成 .o 文件的规则
.c.o:
	cc $(CFLAGS) -c $<

实际上,大部分 make 工具都有一个早已内嵌到系统中的与此类似的规则;如果请求 make 来编译 file.o,而且现在已经有 file.c 文件了,那么它就可以正确地完成编译过程。术语 "$<" 是一个特殊的预定义的 make 变量,代表某条规则的 “源”。这使我们可以使用一些 make 变量。

通用规则取决于 “后缀” 的声明,它然后会被识别为文件扩展名,而不是文件名的一部分。

变量

make 程序使用了一些变量来简化通用值的重用。最常见的值可能是 CFLAGS。有关 make 变量有一些东西应该澄清一下。它们不一定必须是环境变量。如果所给出的名字没有对应的 make 变量,那么 make 就会去检查环境变量;然而,这并意味着 make 变量会被导出为环境变量。优先规则非常神秘;通常,它们的顺序从高到低依次为:

  1. 命令行变量设置
  2. 父 make 进程的 makefile 中的变量设置
  3. 本 make 进程的 makefile 中的变量设置
  4. 环境变量

因此,一个变量只有在没有在任何 makefile 或命令行中指定时,才会使用环境变量的设置(注意:父进程 makefile 变量有时候会传递下来,但不总会这样。正如可能已经猜测到的一样,这些规则在各个 make 工具中会有所不同)。

人们在使用 make 时常常碰到的一个问题是变量被变量名的一部分替换掉了:举例来说,$CFLAGS 就被替换成了 “FLAGS”。因此要引用一个 make 变量,就请将它的名字放到括号中:$(CFLAGS)。否则,所得到的将是 $C,后面加上一个 FLAGS

很多变量都有一些特殊的意义,这是正在使用它们的规则的一种功能。最常见的用法有:

  • $< —— 用来构建目标所使用的源文件
  • $* —— 目标名中基本的部分(不包含扩展名或目录)
  • $@ —— 目标的完整名

虽然 Berkeley make 没有使用这些变量,但是它们(到现在)都是可移植的。至少,是部分可移植的;其确切定义在不同的 make 实现中可能会有所不同。使用这些变量编写的任何复杂规则都可能到某个特定的实现就不能用了。

Shell 脚本

有时候可能还需要执行一些 make 中没法移植的内容。由于 make 是通过 shell 来运行所有操作的,因此常见的解决方案是编写一个内嵌的 shell 脚本来实现。下面是如何实现的过程。

首先,要知道 shell 脚本传统上来讲是在多行中编写的,它们可以使用分号来分割语句,从而将整个脚本压缩成一行。其次,要注意这样做可读性不好。解决方案是一种折衷:使用常见的缩进格式来编写脚本,但是在每行后面都加上一个 “;\” 符号。这在语法上使用分号结束了一个 shell 命令,但却会把一个 make 命令的文本部分一次传递给 shell。举例来说,下面的代码就可能会在某个最上层的 makefile 中出现:

清单 9. shell 脚本中的换行
all:
	for i in $(ALLDIRS) ; \
	do      ( cd $$i ; $(MAKE) all ) ; \
	done

其中给出了需要注意的 3 件事情。首先是分号和反斜线的用法。其次是 make 变量的用法 $(VARIABLE)。再次是使用 $$ 向 shell 传递一个 $ 符号。就是这样,这实际上都非常简单。

前缀

默认情况下,make 会打印出它所运行的每个命令,如果有任何命令失败,make 就会停止执行。在某些情况中,可能会出现某个命令看起来失败了,但是我们却希望整个编译过程继续进行。如果一个命令的第一个字符是连字符(-),那么该行中剩余的命令都会执行,不过其退出状态会被忽略。

如果并不希望回显命令,可以在前面加上 @ 符号作为前缀。这是显示消息最常用的方法:

清单 10. 禁止回显
all:
	@echo "Beginning build at:"
	@date
	@echo "--------"

如果没有 @ 符号,这就会产生下面的输出:

清单 11. 没有 @ 的命令
echo "Beginning build at:"
Beginning build at:
date
Sun Jun 18 01:13:21 CDT 2006
echo "--------"
--------

尽管 @ 符号不会真正改变 make 所做的事情,但是这却是一种非常受欢迎的特性。

不可移植的功能

有些人们非常希望实现的事情却不可移植。但是这些问题也有一些解决办法。

包含文件

历史上最难解决的一个兼容性问题是在 makefile 中对包含的处理。早先的 make 实现通常都没有提供方法来实现这种功能,但是现代的一些 make 变种似乎看起来都对这个问题进行了妥善处理。GNU make 语法非常简单,即 include file。传统的 Berkeley 语法是 .include "file"。至少有一种 Berkeley make 现在也可以支持 GNU 的符号了,但是目前还尚未全部支持。 autoconfImake 所提供的可移植解决方案只是将所希望使用的每个变量的赋值都包含进来。

有些程序可能会简单地要求使用 GNU make,有些则可能要求使用 Berkeley make,还有些可能要求使用 smake。如果需要包含的文件非常多,可以尝试简单指定一个 make 工具,用这个工具编译一个树(在这 3 种以源代码形式发布的可移植 make 工具中,我最喜欢的是 Berkeley make)。

使用变量进行嵌套编译

实际上并没有什么好方法来做这件事情。如果使用了一个包含文件,就可能会遇到此文件是否被干净地包含这样的移植性问题。如果在每个文件中都设置了变量,那么就很难全部重载这些变量。如果只在一个顶层文件中设置这些变量,那么子目录中一些独立的编译就会失败,因为还没有设置变量!

根据所使用的 make 版本的不同,一个理想的解决方案是在每个文件中都有条件地设置变量:只有在还没有设置这些变量时才需要进行设置;然后顶层文件中的变化在完全编译时就会影响到所有的子目录。当然,此时如果单独进入一个子目录并运行 make 会产生不同的并且不兼容的结果。

如果所包含的文件不存在,这样做的负面影响就会被放大,那些曾经在 Imake 数千行 makefile 中挣扎过的人都可以证明这点。

有些人提倡另外一种简单的解决方案:根本就不要递归使用 make。对于大部分项目来说,这是绝对可行的,可以急剧简化(并加速)整个编译过程。 Peter Miller 撰写的文章 “Recursive Make Considered Harmful”(请参阅 参考资料)就是一个非常规范的例子。

当出现问题时应该怎样做

首先,不要恐慌。开发人员在编写出一个完整的版本之前,可能需要解决很多怪异的 make 问题。隐式规则、没想到的变量替换以及嵌入式 shell 脚本中的语法错误,都可能会引发这种痛苦的享受。

此时需要仔细阅读错误消息。这是 make 自己产生的消息么?还是 make 所调用的东西产生的消息?如果有一个嵌套的编译,可能会需要通过对一组错误消息来仔细进行分析,才能找到确切的错误。

如果一个程序没有找到,首先要检查它是否已经安装了。如果已经安装了,那么就要检查路径设置是否正确;有些开发人员的习惯是在 makefile 中使用绝对路径,这在其他系统上可能会失败。如果将某些东西安装到 /opt 中,而 makefile 引用的却是 /usr/local/bin,那么编译就会失败。此时就需要修改路径的设置。

检查系统时钟;更重要的是,要检查编译树中文件的日期、系统中其他文件的日期以及系统的时钟。在面临输入数据的时间顺序不一致的情况时,make 的行为可能是无害的,也可能是不现实的。如果碰到了时钟问题(例如有些 “新” 文件被标记成 1970 年的),那么就需要修整这个问题了。 “touch” 工具是一个很好的帮手。在时钟问题中产生的错误消息通常都不太明显。

如果看到的错误消息显示有一些语法错误,或者有很多变量没有设置,或设置得不正确,那么可以尝试试验一下其他版本的 make;举例来说,有些程序在使用 gmake 编译时会产生一些非常含糊的错误,而使用 smake 时就能很好地进行编译。有些非常怪异的错误会说明正在使用 GNU make 来运行一个 Berkeley 的 makefile,反之亦然。Linux 特有的程序通常会假设使用 GNU make,使用其他 make 工具可能会碰到莫名其妙的错误,有些甚至在文档中都没有任何提示。

调试标记可能会非常有用。对于 GNU make ,-d 标记会提供大量的信息,其中有些是非常有用的。对于 Berkeley make ,-d 标记有一组标记;-d A 表示完整的集合,或者可以使用其中的一些子集;举例来说,-d vx 会给出有关变量赋值(v)的调试信息,这会导致通过 sh -x 来运行所有的命令,这样 shell 就会精确地回显自己接收到的命令。-n 调试标记会导致 make 打印它认为需要做的事情的一个列表;这并不总是正确的,不过通常可以为思考哪些地方出现了问题而提供一些思路。

在调试 makefile 时,目标是找到 make 正在试图编译什么东西,以及它认为哪些命令可以用来编译。如果 make 使用了正确的命令,但命令却出现了故障,那么这可能意味着完成了 make 调试 —— 但也许并不完全是。举例来说,如果试图编译程序时由于存在无法解析的符号而失败了,那么就可能是编译过程前面某个步骤出现了问题!如果不能定位命令中哪儿出现了问题,并且它看起来应该正常工作,那么很可能是 make 前面创建的某个文件没有被正确创建。

文档

通常情况下, GNU make 的主要文档都没有以 man 格式提供,这一点非常不幸;我们只好使用 info 系统,而且不能运行 man make 来查找有关的信息。不过这些文档还是非常齐全的。

要找到有关所有实现都能支持的特性的一个 “安全子集” 的文档非常难。Berkeley 和 GNU make 文档在描述扩展时都试图提及这个问题,不过多做些测试总是个好事,这样就不会全靠猜测去定义每个 make 工具的确切界限。

经过一段时间的发展,BSD 系列之间的微小偏移已经在 make 实现之间产生了一些差异。在三者之中,NetBSD 是 make 在其他系统上支持最为广泛的;NetBSD pkgsrc 系统现在还在其他平台上使用,它就严重依赖于 NetBSD 的 make 实现。


相关主题

  • 您可以参阅本文在 developerWorks 全球站点上的 英文原文
  • Recursive Make Considered Harmful” 介绍了与使用递归 make 有关的问题,将要解决的问题回溯到第一条规则,并提供了一种直观的解决方案。
  • Peter 早期的文章 “调试 configure”(developerWorks,2003 年 12 月)为那些已经饱受配置脚本问题之苦的人们提供了帮助,并为开发人员提供了有关如何将故障最小化的建议。
  • developerWorks Linux 专区 中可以找到为 Linux 开发人员准备的更多资源。
  • 使用 IBM 试用软件 构建您的下一个 Linux 开发项目,这些软件可以从 developerWorks 上直接下载。

评论

添加或订阅评论,请先登录注册

static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=10
Zone=Linux, Open source
ArticleID=175400
ArticleTitle=调试 make
publish-date=11202006