GNU Make 项目管理 第八章 C 和 C++

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

第六章中所展示的问题和技术将在本章中增强,并应用于 C 和 C++ 项目。我们将继续在非递归生成文件上构建 mp3 播放器示例。

分离源代码和二进制文件

如果我们想要支持一个带有多个平台的单一源代码树,以及每个平台的多个构建,那么分离源代码树和二进制树是必要的,那么我们该怎么做呢?最初编写 make 程序是为了能够很好地处理单个目录中的文件。尽管从那时起它已经发生了巨大的变化,但它并没有忘记它的初心。当它正在更新的文件位于当前目录(或其子目录)中时,make 最适合用于多个目录。

简单的方法

让 make 将二进制文件与源代码放在单独的目录中,最简单的方法是从二进制目录启动 make 程序。输出文件使用相对路径访问,如前一章所示,而输入文件必须通过显式路径或通过 vpath 搜索找到。在这两种情况下,我们都需要在几个地方引用源目录,所以我们先用一个变量来保存它:

SOURCE_DIR := ../mp3_player

在前面的 makefile 的基础上构建,source-to-object 函数没有改变,但是子目录函数现在需要考虑到源的相对路径。

# $(call source-to-object, source-file-list)
source-to-object = $(subst .c,.o,$(filter %.c,$1))  \
                    $(subst .y,.o,$(filter %.y,$1)) \
                    $(subst .l,.o,$(filter %.l,$1))

# $(subdirectory)
subdirectory = $(patsubst $(SOURCE_DIR)/%/module.mk,%,  \
                $(word                                  \
                $(words $(MAKEFILE_LIST)),$(MAKEFILE_LIST)))

在我们的新 makefile 中,MAKEFILE_LIST 中的文件列表将包括到源文件的相对路径。因此,为了提取模块目录的相对路径,我们必须去掉 module.mk 的前缀和后缀。

接下来,为了帮助找到源代码,我们使用 vpath 特性:

vpath %.y $(SOURCE_DIR)
vpath %.l $(SOURCE_DIR)
vpath %.c $(SOURCE_DIR)

这允许我们为源文件和输出文件使用简单的相对路径。当 make 需要一个源文件时,如果在输出树的当前目录中找不到该文件,它将搜索SOURCE_DIR。接下来,我们必须更新 include_dirs 变量:

include_dirs := lib $(SOURCE_DIR)/lib $(SOURCE_DIR)/include

除了源目录之外,这个变量现在还包括二进制树中的 lib 目录,因为生成的 yacc 和 lex 头文件将放在那里。make include 指令必须更新以访问该模块。因为 make 不使用 vpath 来查找 include 文件:

include $(patsubst %,$(SOURCE_DIR)/%/module.mk,$(modules))

最后,我们创建输出目录:

create-output-directories :=                \
    $(shell for f in $(modules);            \
        do                                  \
            $(TEST) -d $$f || $(MKDIR) $$f; \
        done)

这个赋值创建了一个虚拟变量,它的值从未被使用过,但是由于这个简单的变量赋值,我们可以保证在 make 执行任何其他工作之前创建目录。我们必须“手动”创建目录,因为 yacc、lex 和依赖项文件生成本身不会创建输出目录。

确保创建这些目录的另一种方法是将这些目录作为依赖添加到依赖项文件(.d 文件)。这不是一个好主意,因为目录并不是真正的依赖。yacc、lex 或依赖项文件不依赖于目录的内容,也不应该仅仅因为目录时间戳被更新就重新生成它们。事实上,如果在从输出目录中添加或删除文件时重新创建项目,这将是低效率的来源。

module.mk 修改,使之更简单:

local_src := $(addprefix $(subdirectory)/,playlist.y scanner.l)

$(eval $(call make-library, $(subdirectory)/libdb.a, $(local_src)))

.SECONDARY: $(call generated-source, $(local_src))

$(subdirectory)/scanner.d: $(subdirectory)/playlist.d

这个版本省略了通配符来查找源代码。恢复这个特性很简单,留给读者作为练习。有一个小问题,似乎是一个错误的原始生成文件。当这个例子运行时,我发现 scanner.d 在它所依赖的 playlist.h 之前生成了。最初的 makefile 中没有这个依赖项,但是它能够正常工作纯属偶然。使所有的依赖关系正确是一项困难的任务,即使是在小项目中。

假设源文件在 mp3_player 子目录中,下面是我们如何使用新的 makefile 构建项目:

$ mkdir mp3_player_out
$ cd mp3_player_out
$ make --file=../mp3_player/makefile

makefile 是正确的,工作得很好,但是如果强制将目录更改到输出目录,然后强制添加 --file (-f) 选项,那就相当麻烦了。这可以通过一个简单的 shell 脚本来搞定:

#! /bin/bash
if [[ ! -d $OUTPUT_DIR ]]
then
    if ! mkdir -p $OUTPUT_DIR
    then
        echo "Cannot create output directory" > /dev/stderr
        exit 1
    fi
fi

cd $OUTPUT_DIR
make --file=$SOURCE_DIR/makefile "$@"

该脚本假定源目录和输出目录分别存储在环境变量 SOURCE_DIROUTPUT_DIR 中。这在实践中是一种标准,允许开发人员轻松切换树,但仍然避免过于频繁地输入路径。

最后一个需要警觉的地方。在 make 和我们的 makefile 中没有任何东西,可以阻止开发人员从源代码树中执行 makefile,即使它应该从二进制树中执行。这是一个常见的错误,一些命令脚本的行为可能很糟糕。例如,clean 目标:

.PHONY: clean
clean:
    $(RM) -r *

这会删除用户的整个源代码树,卧槽!明智的做法是在最高级别的 makefile 中添加对这种可能性的检查。这里有一个合理的检查:

$(if $(filter $(notdir $(SOURCE_DIR)),$(notdir $(CURDIR))),\
    $(error Please run the makefile from the binary tree.))

这段代码测试当前工作目录 ($(notdir $(CURDIR))) 的名称是否与源目录 ($(notdir $(SOURCE_DIR)) 相同。如果是,打印错误并退出。由于 iferror 函数扩展为空,因此可以将这两行直接放在 SOURCE_DIR 的定义之后。

困难的方法

一些开发人员发现不得不 cd 进入二进制树,这是非常烦人的,他们将竭尽所能地避免它,或者也许 makefile 维护者工作在一个不适合shell 脚本包装器或别名的环境中。在任何情况下,都可以修改 makefile,以允许从源代码树运行 make,并通过在所有输出文件名前面加上一个路径,将二进制文件放在单独的输出树中。此时,我通常使用绝对路径,因为这提供了更多的灵活性,尽管它确实加剧了命令行长度限制的问题。输入文件继续使用来自 makefile 目录的简单相对路径。

例 8-1 显示了修改后的 makefile,允许从源树执行 make 并将二进制文件写入二进制树。

例 8-1 一个将源代码和二进制文件分开的 makefile,可以从源代码树中执行

SOURCE_DIR := /test/book/examples/ch07-separate-binaries-1
BINARY_DIR := /test/book/out/mp3_player_out

# $(call source-dir-to-binary-dir, directory-list)
source-dir-to-binary-dir = $(addprefix $(BINARY_DIR)/, $1)

# $(call source-to-object, source-file-list)
source-to-object = $(call source-dir-to-binary-dir, \
                    $(subst .c,.o,$(filter %.c,$1)) \
                    $(subst .y,.o,$(filter %.y,$1)) \
                    $(subst .l,.o,$(filter %.l,$1)))

# $(subdirectory)
subdirectory = $(patsubst %/module.mk,%,    \
                $(word                      \
                $(words $(MAKEFILE_LIST)),$(MAKEFILE_LIST)))

# $(call make-library, library-name, source-file-list)
define make-library
    libraries += $(BINARY_DIR)/$1
    sources += $2

    $(BINARY_DIR)/$1: $(call source-dir-to-binary-dir,  \
                        $(subst .c,.o,$(filter %.c,$2)) \
                        $(subst .y,.o,$(filter %.y,$2)) \
                        $(subst .l,.o,$(filter %.l,$2)))
    $(AR) $(ARFLAGS) $$@ $$^
endef

# $(call generated-source, source-file-list)
generated-source = $(call source-dir-to-binary-dir,     \
                    $(subst .y,.c,$(filter %.y,$1))     \
                    $(subst .y,.h,$(filter %.y,$1))     \
                    $(subst .l,.c,$(filter %.l,$1)))    \
                    $(filter %.c,$1)

# $(compile-rules)
define compile-rules
    $(foreach f, $(local_src),\
    $(call one-compile-rule,$(call source-to-object,$f),$f))
endef

# $(call one-compile-rule, binary-file, source-files)
define one-compile-rule
    $1: $(call generated-source,$2)
        $(COMPILE.c) -o $$@ $$<

    $(subst .o,.d,$1): $(call generated-source,$2)
        $(CC) $(CFLAGS) $(CPPFLAGS) $(TARGET_ARCH) -M $$< | \
        $(SED) 's,\($$(notdir $$*)\.o\) *:,$$(dir $$@)\1 $$@: ,' > $$@.tmp
        $(MV) $$@.tmp $$@
endef

modules := lib/codec lib/db lib/ui app/player
programs :=
libraries :=
sources :=

objects = $(call source-to-object,$(sources))
dependencies = $(subst .o,.d,$(objects))

include_dirs := $(BINARY_DIR)/lib lib include
CPPFLAGS += $(addprefix -I ,$(include_dirs))
vpath %.h $(include_dirs)

MKDIR := mkdir -p
MV := mv -f
RM := rm -f
SED := sed
TEST := test

create-output-directories := \
    $(shell for f in $(call source-dir-to-binary-dir,$(modules)); \
        do \
            $(TEST) -d $$f || $(MKDIR) $$f; \
        done)

all:

include $(addsuffix /module.mk,$(modules))

.PHONY: all
all: $(programs)

.PHONY: libraries
libraries: $(libraries)

.PHONY: clean
clean:
    $(RM) -r $(BINARY_DIR)

ifneq "$(MAKECMDGOALS)" "clean"
    include $(dependencies)
endif

在这个版本中,source-to-object 函数被修改为在二进制树的路径的前面。这个前缀操作被执行了多次,所以把它写成一个函数:

SOURCE_DIR := /test/book/examples/ch07-separate-binaries-1
BINARY_DIR := /test/book/out/mp3_player_out

# $(call source-dir-to-binary-dir, directory-list)
source-dir-to-binary-dir = $(addprefix $(BINARY_DIR)/, $1)

# $(call source-to-object, source-file-list)
source-to-object = $(call source-dir-to-binary-dir, \
                    $(subst .c,.o,$(filter %.c,$1)) \
                    $(subst .y,.o,$(filter %.y,$1)) \
                    $(subst .l,.o,$(filter %.l,$1)))

make-library 函数也被类似地修改为在输出文件前加上 BINARY_DIR 前缀。subdirectory 函数被恢复到以前的版本,因为包含路径还是一个简单的相对路径。一个小问题,make 3.80 中的一个 bug 阻止了在新版本的 make-library 中调用 source-to- object。这个 bug 在 3.81 中被修复。我们可以通过手动展开源到对象函数来解决这个问题。

现在我们来看看真正丑陋的部分。当输出文件不能从相对于 makefile 的路径直接访问时,隐式规则不再触发。例如,基本的编译规则 %.o: %.c 在两个文件位于同一目录时运行良好,或者即使 C 文件在子目录中,如 lib/codec/codec.c。当源文件位于远程目录中时,我们可以指示 make 使用 vpath 特性来搜索源文件。但是,当目标文件位于远程目录中时,make 无法确定目标文件的驻留位置,目标/依赖链就会中断。

通知 make 输出文件位置的唯一方法是提供一个链接源文件和目标文件的显式规则:

$(BINARY_DIR)/lib/codec/codec.o: lib/codec/codec.c

每个对象文件都必须这样做。

更糟糕的是,这个 目标/依赖对 没有匹配隐式规则 %.o: %.c 。这意味着我们还必须提供命令脚本,复制隐式数据库中的任何内容,并可能多次重复该脚本。这个问题也适用于我们一直在使用的自动依赖生成规则。如果手动为 makefile 中的每个对象文件添加两个显式规则,那么维护起来将是一场噩梦。但是,为了最小化重复代码和维护,我们可以通过编写一个函数来生成这些规则:

# $(call one-compile-rule, binary-file, source-files)
define one-compile-rule
    $1: $(call generated-source,$2)
        $(COMPILE.c) $$@ $$<

    $(subst .o,.d,$1): $(call generated-source,$2)
        $(CC) $(CFLAGS) $(CPPFLAGS) $(TARGET_ARCH) -M $$< | \
        $(SED) 's,\($$(notdir $$*)\.o\) *:,$$(dir $$@)\1 $$@: ,' > $$@.tmp
        $(MV) $$@.tmp $$@
endef

函数的前两行是对象到源依赖关系的显式规则。该规则的依赖必须使用我们在第六章中编写的 generate-source 函数进行计算,因为有些源文件是 yacc 和 lex 文件,当它们出现在命令脚本(例如,用 $^ 展开)时,将导致编译失败。自动变量用引号括起来,以便以后执行命令脚本时展开,而不是在自定义的函数中用 eval 求值时展开。generated-source 函数已经被修改,以返回未修改的 C 文件以及生成的 yacc 和 lex 的源代码:

# $(call generated-source, source-file-list)
generated-source = $(call source-dir-to-binary-dir,     \
                    $(subst .y,.c,$(filter %.y,$1))     \
                    $(subst .y,.h,$(filter %.y,$1))     \
                    $(subst .l,.c,$(filter %.l,$1)))    \
                    $(filter %.c,$1)

有了这个变化,函数现在产生如下输出:

Argument                Result
lib/db/playlist.y       /c/mp3_player_out/lib/db/playlist.c
                        /c/mp3_player_out/lib/db/playlist.h
lib/db/scanner.l        /c/mp3_player_out/lib/db/scanner.c
app/player/play_mp3.c   app/player/play_mp3.c

生成依赖项的显式规则与此类似。同样,请注意依赖项脚本需要的额外引号($$ 符号)。

我们的新函数现在必须针对模块中的每个源文件展开:

# $(compile-rules)
define compile-rules
  $(foreach f, $(local_src),\
    $(call one-compile-rule,$(call source-to-object,$f),$f))
endef

个函数依赖于 module.mk 文件使用的全局变量 local_src。一种更通用的方法是将此文件列表作为参数传递,但在这个项目中似乎没有必要这样做。这些函数很容易添加到我们的 moudle.mk 文件中:

local_src := $(subdirectory)/codec.c

$(eval $(call make-library,$(subdirectory)/libcodec.a,$(local_src)))

$(eval $(compile-rules))

必须使用 eval,因为 compile-rules 函数扩展成不止一行的 make 代码。

还有最后一个问题。如果标准 C 编译模式规则无法与二进制输出路径匹配,那么 lex 的隐式规则和 yacc 的模式规则也将失败。我们可以很容易地手动更新这些。因为它们不再适用于其他 lex 或 yacc 文件,我们可以将它们移到 lib/db/module.mk:

local_dir := $(BINARY_DIR)/$(subdirectory)
local_src := $(addprefix $(subdirectory)/,playlist.y scanner.l)

$(eval $(call make-library,$(subdirectory)/libdb.a,$(local_src)))

$(eval $(compile-rules))

.SECONDARY: $(call generated-source, $(local_src))

$(local_dir)/scanner.d: $(local_dir)/playlist.d

$(local_dir)/%.c $(local_dir)/%.h: $(subdirectory)/%.y
    $(YACC.y) --defines $<
    $(MV) y.tab.c $(dir $@)$*.c
    $(MV) y.tab.h $(dir $@)$*.h

$(local_dir)/scanner.c: $(subdirectory)/scanner.l
    @$(RM) $@
    $(LEX.l) $< > $@

lex 规则实现为一个普通的显式规则,但是 yacc 规则是一个模式规则。为什么?因为 yacc 规则用于构建两个目标,一个 C 文件和一个头文件。如果我们使用普通的显式规则,make 将执行命令脚本两次,一次用于创建 C 文件,一次用于头文件。但是假设一个具有多个目标的模式规则通过一次执行更新两个目标。

如果可能,我将使用更简单的方法从二进制树编译,而不是本节中所示的 makefile。如您所见,当试图从源代码树编译时,复杂性立即出现(而且似乎越来越严重)。

只读源代码

一旦源代码树和二叉树分开,如果构建生成的唯一文件是放置在输出树中的二进制文件,那么将引用源代码树变为只读的能力通常是轻而易举的。但是,如果生成了源代码文件,则必须注意将它们放入二进制树中。

在更简单的“从二进制树编译”方法中,生成的文件会自动写入二进制树,因为 yacc 和 lex 程序是从二叉树执行的。在“从源代码树编译”方法中,我们必须为源文件和目标文件提供显式路径,因此指定二进制树文件的路径并不需要额外的工作,除非我们必须记住这样做。

使引用源树为只读的其他障碍通常是自行设置的。老旧的构建系统通常会包括在源代码树中创建文件的操作,因为原始作者没有考虑到只读源代码树的优点。示例包括生成的文档、日志文件和临时文件。将这些文件移到输出树中有时会很费力,但是如果需要从一个源构建多个二进制树,那么另一种方法是维护多个相同的源树并保持它们同步。

依赖生成

我们在第二章的“自动依赖生成”一节中简要介绍了依赖生成,但是它留下了几个问题没有解决。因此,本节提供了一些已经描述过的简单解决方案的替代方案。特别的是,前面描述的简单方法和 GNU make 手册中描述的方法存在以下缺陷:

本节中的大部分内容是由 Tom Tromey (tromey@cygnus.com) 为 GNU automake 程序发明的,并摘自 Paul Smith (GNU make的维护者)在其网站 http://make.paulandlesley.org 上发表的优秀摘要文章。

  • 效率低下。当 make 发现依赖项文件丢失或过期时,它会更新 .d 文件并重启自身。如果在读取 makefile 和分析依赖关系图期间执行了许多任务,那么重新读取 makefile 可能会效率很低。
  • 当你第一次建立一个目标和每次你添加新的源文件时,make 将生成一个警告。此时,与新源文件相关联的依赖项文件还不存在,所以当尝试读取依赖项文件时,它将在生成依赖项文件之前生成一条警告消息。这并不致命,只是令人恼火。
  • 如果你删除了一个源文件,在后续的构建过程中会出现致命错误。在这种情况下,存在一个包含已删除文件作为依赖的依赖项文件。因为 make 无法找到被删除的文件,也不知道如何创建它,make 将打印如下信息:
make: *** No rule to make target foo.h, needed by foo.d. Stop.

此外,由于此错误,make无法重建依赖文件。唯一的办法是手动删除依赖项文件,但是由于这些文件通常很难找到,所以用户通常会删除所有依赖项文件并执行一个干净的构建。重命名文件时也会发生此错误。

请注意,这个问题在删除或重命名头文件时最为明显,而不是 .c 文件。这是因为 .c 文件将自动从依赖项文件列表中删除,不会给构建带来麻烦。

Tromey 的方法

让我们单独解决这些问题。

我们如何避免重新启动 make?

经过仔细考虑,我们可以看到重新启动 make 是不必要的。如果更新了依赖项文件,则意味着它的至少一个依赖发生了改变,这意味着我们必须更新目标。在 make 的执行中不需要知道更多的信息,因为更多的依赖关系信息不会改变 make 的行为。但是我们希望更新依赖项文件,以便下一次 make 运行时拥有完整的依赖项信息。

因为我们在 make 的执行中不需要依赖文件,所以我们可以在更新目标的同时生成该文件。我们可以通过重写编译规则来更新依赖项文件来实现这一点。

# $(call make-depend,source-file,object-file,depend-file)
define make-depend
    $(CC) $(CFLAGS) $(CPPFLAGS) $(TARGET_ARCH) -M $1 | \
    $(SED) 's,\($$(notdir $2)\) *:,$$(dir $2) $3: ,' > $3.tmp
    $(MV) $3.tmp $3
endef

%.o: %.c
    $(call make-depend,$<,$@,$(subst .o,.d,$@))
    $(COMPILE.c) -o $@ $<

我们使用 make-depend 函数来实现依赖生成特性,该函数接受源、对象和依赖的文件名。如果以后需要在不同的上下文中重用该函数,这就提供了最大的灵活性。当我们这样修改我们的编译规则时,必须删除 %.d: %.c 模式规则,以避免生成两次依赖文件。

现在,目标文件和依赖文件在逻辑上是链接的:如果一个存在,另一个也必须存在。因此,我们并不真正关心是否缺少依赖项文件。如果是,目标文件也会丢失,并且这两个都将在下一次构建时更新。因此,我们现在可以忽略由于缺少 .d 文件而产生的任何警告。

在第三章的“包含和依赖”一节中,我介绍了 include 指令的另一种形式,-include (sinclude),它会忽略错误并且不会产生警告:

ifneq "$(MAKECMDGOALS)" "clean"
    -include $(dependencies)
endif

这解决了第二个问题,即当依赖项文件不存在时产生的烦人消息。

最后,我们可以通过一个小技巧来避免在发现缺失依赖时的警告。诀窍是为缺少的文件创建一个目标,该目标不包含任何先决条件和命令。例如,假设我们的依赖文件生成器创建了这个依赖:

target.o target.d: header.h

现在假设,由于代码重构,header.h 不再存在。下次我们运行 makefile 时,我们会得到错误:

make: *** No rule to make target header.h, needed by target.d. Stop.

但如果我们在依赖文件中添加一个没有 header.h 命令的目标,则不会发生错误:

target.o target.d: header.h
header.h:

这是因为,如果 header.h 不存在,它将被认为是过时的,任何使用它作为依赖的目标将被更新。因此,依赖项文件将在没有 header.h 的情况下重新生成,因为它不再被引用。如果 header.h 存在,make 认为它是最新的并继续执行。因此,我们需要做的就是确保每个依赖都有一个相关的空规则。你可能还记得,我们第一次遇到这种规则是在第二章的“假目标”一节。这是一个添加了新目标的 make-depend 版本:

# $(call make-depend,source-file,object-file,depend-file)
define make-depend
    $(CC) $(CFLAGS) $(CPPFLAGS) $(TARGET_ARCH) -M $1 | \
    $(SED) 's,\($$(notdir $2)\) *:,$$(dir $2) $3: ,' > $3.tmp
    $(SED)  -e 's/#.*//'        \
            -e 's/^[^:]*: *//'  \
            -e 's/ *\\$$$$//'   \
            -e '/^$$$$/ d'      \
            -e 's/$$$$/ :/' $3.tmp >> $3.tmp
    $(MV) $3.tmp $3
endef

我们在依赖项文件上执行一个新的 sed 命令来生成额外的规则。sed 代码块执行5个转换:

  1. 删除注释
  2. 删除目标文件和随后的空格
  3. 删除尾随空格
  4. 删除空白行
  5. 在每行末尾添加冒号

(GNU sed 能够从一个文件中读取并在一个命令行中附加它,从而避免我们必须使用第二个临时文件。此功能可能无法在其他系统上工作。)新的 sed 命令将接受如下输入:

# any comments
target.o target.d: prereq1 prereq2 prereq3 \
prereq4

and transform it into:

prereq1 prereq2 prereq3:
prereq4:

因此 make-depend 将这个新的输出附加到原始依赖项文件中。这解决了“无目标规则”的错误。

make-depend 程序

到目前为止,我们已经满足于使用大多数编译器提供的 -M 选项,但是如果这个选项不存在怎么办?或者,有比简单的 -M 更好的选择吗?

现在,大多数 C 编译器都支持从源代码生成依赖项,但事实并非如此。在 X Window System 项目的早期,他们实现了一个工具makedepend,该工具从一组 C 或 C++ 源代码中计算依赖关系。这个工具可以在因特网上免费获得。使用 makedepend 有点笨拙,因为它被编写为将其输出附加到 makefile,这是我们不希望做的。makedepend 的输出假设目标文件与源文件位于同一个目录中。这意味着,sed 表达式必须更改:

# $(call make-depend,source-file,object-file,depend-file)
define make-depend
    $(MAKEDEPEND) -f- $(CFLAGS) $(CPPFLAGS) $(TARGET_ARCH) $1 | \
    $(SED) 's,^.*/\([^/]*\.o\) *:,$(dir $2)\1 $3: ,' > $3.tmp
    $(SED)  -e 's/#.*//'        \
            -e 's/^[^:]*: *//'  \
            -e 's/ *\\$$$$//'   \
            -e '/^$$$$/ d'      \
            -e 's/$$$$/ :/' $3.tmp >> $3.tmp
    $(MV) $3.tmp $3
endef

-f- 选项告诉 makedepend 将其依赖项信息写入标准输出。

使用 makedepend 或本机编译器的另一种选择是使用 gcc。它为生成依赖信息提供了一组令人困惑的选项。最适合我们当前需求的是:

ifneq "$(MAKECMDGOALS)" "clean"
    -include $(dependencies)
endif

# $(call make-depend,source-file,object-file,depend-file)
define make-depend
    $(GCC)  -MM             \
            -MF $3          \
            -MP             \
            -MT $2          \
            $(CFLAGS)       \
            $(CPPFLAGS)     \
            $(TARGET_ARCH)  \
            $1
endef

%.o: %.c
    $(call make-depend,$<,$@,$(subst .o,.d,$@))
    $(COMPILE.c) $(OUTPUT_OPTION) $<

-MM 选项会导致 gcc 从依赖列表中省略“system”头。这是很有用的,因为这些文件很少发生变化,而且随着构建系统变得更加复杂,减少混乱会有所帮助。最初,这样做可能是出于性能原因。在今天的处理器中,性能差异几乎无法衡量。

-MF 选项指定依赖项文件名。这将是用 .d 后缀代替 .o 的对象文件名。还有另一个 gcc 选项 -MD-MMD,它使用类似的替换自动生成输出文件名。理想情况下,我们更愿意使用这个选项,但是替换没有包含到目标文件目录的适当的相对路径,而是将 .d 文件放在当前目录中。所以,我们被迫使用 -MF 来完成这项工作。

-MP 选项指示 gcc 为每个依赖包含伪目标。这完全消除了 make-depend 函数中由五部分组成的混乱 sed 表达式。似乎是发明了 .PHONY 技术的 automake 开发人员导致这个选项被添加到 gcc 中。

最后,-MT 选项指定用于依赖文件中的目标的字符串。同样,如果没有这个选项,gcc 将无法包含到目标文件输出目录的相对路径。

通过使用 gcc,我们可以将之前生成依赖项所需的四个命令减少为一个命令。即使使用了专有编译器,也可以使用 gcc 进行依赖关系管理。

多二进制树支持

一旦修改 makefile,将二进制文件写入一个单独的树中,支持许多树就变得非常简单。对于交互式或开发人员调用的构建(开发人员从键盘发起构建),几乎不需要任何准备工作。开发人员创建输出目录,cd 指向它,并调用 make。

$ mkdir -p ~/work/mp3_player_out
$ cd ~/work/mp3_player_out
$ make -f ~/work/mp3_player/makefile

如果流程比这更复杂,那么 shell 脚本包装器通常是最佳解决方案。这个包装器还可以解析当前目录,并设置一个环境变量(如 BINARY_DIR) 以供 makefile 使用。

#! /bin/bash

# Assume we are in the source directory.
curr=$PWD
export SOURCE_DIR=$curr
while [[ $SOURCE_DIR ]]
do
    if [[ -e $SOURCE_DIR/[Mm]akefile ]]
    then
        break;
    fi
        SOURCE_DIR=${SOURCE_DIR%/*}
done

# Print an error if we haven't found a makefile.
if [[ ! $SOURCE_DIR ]]
then
    printf "run-make: Cannot find a makefile" > /dev/stderr
    exit 1
fi
# Set the output directory to a default, if not set.
if [[ ! $BINARY_DIR ]]
then
    BINARY_DIR=${SOURCE_DIR}_out
fi

# Create the output directory
mkdir --parents $BINARY_DIR

# Run the make.
make --directory="$BINARY_DIR" "$@"

这个脚本有点奇特。它首先在当前目录中搜索 makefile,然后在树上的父目录中搜索 makefile,直到找到 makefile。然后检查是否设置了二进制树的变量。如果不是,则通过将 “_out” 附加到源目录来赋值。然后脚本创建输出目录并执行 make。

如果构建是在不同的平台上执行的,则需要一些用于区分不同平台的方法。最简单的方法是要求开发人员为每种平台类型设置一个环境变量,并根据该变量向 makefile 和源代码中添加条件。更好的方法是根据 uname 的输出自动设置平台类型。

space := $(empty) $(empty)
export MACHINE := $(subst $(space),-,$(shell uname -smo))

如果构建是从 cron 自动调用的,那么我发现 helper shell 脚本是比让 cron 调用 make 本身更好的方法。包装器脚本为自动构建的设置、错误恢复和终结提供了更好的支持。该脚本也是设置变量和命令行参数的合适位置。最后,如果一个项目支持一组固定的树和平台,您可以使用目录名来自动识别当前的构建。例如:

ALL_TREES :=    /builds/hp-386-windows-optimized    \
                /builds/hp-386-windows-debug        \
                /builds/sgi-irix-optimzed           \
                /builds/sgi-irix-debug              \
                /builds/sun-solaris8-profiled       \
                /builds/sun-solaris8-debug

BINARY_DIR := $(foreach t,$(ALL_TREES),\
                $(filter $(ALL_TREES)/%,$(CURDIR)))

BUILD_TYPE := $(notdir $(subst -,/,$(BINARY_DIR)))

MACHINE_TYPE := $(strip             \
                $(subst /,-,        \
                $(patsubst %/,%,    \
                $(dir               \
                $(subst -,/,        \
                $(notdir $(BINARY_DIR)))))))

ALL_TREES 变量保存了所有有效二进制树的列表。foreach 循环根据每个有效的二进制树匹配当前目录。只有一个可以匹配。一旦确定了二进制树,我们就可以从构建目录名中提取构建类型(例如,优化、调试或分析)。我们通过将以 - 分隔的单词转换为以斜杠分隔的单词并使用 notdir 获取最后一个单词来检索目录名的最后一个组件。类似地,我们通过获取最后一个单词并使用相同的技术删除最后一个 - 来检索机器类型。

部分源代码树

在真正大型的项目中,仅仅检出和维护源代码就可能成为开发人员的负担。如果一个系统由许多模块组成,而一个特定的开发人员只修改了其中的局部部分,那么检出和编译整个项目可能会耗费大量的时间。相反,一个每晚执行的集中管理构建可以用来填补开发人员源代码和二进制树中的漏洞。这样做需要两种类型的搜索。首先,当编译器需要一个丢失的头文件时,必须指示它在引用源文件树中搜索。其次,当 makefile 需要一个丢失的库时,必须告诉它在参考二进制树中搜索。为了帮助编译器找到源代码,我们可以简单地在指定本地目录的 -I 选项之后添加额外的 -I 选项。为了帮助查找库,我们可以向 vpath 添加额外的目录。

SOURCE_DIR      := ../mp3_player
REF_SOURCE_DIR  := /reftree/src/mp3_player
REF_BINARY_DIR  := /binaries/mp3_player

include_dirs := lib $(SOURCE_DIR)/lib $(SOURCE_DIR)/include
CPPFLAGS += $(addprefix -I ,$(include_dirs)) \
            $(addprefix -I $(REF_SOURCE_DIR)/,$(include_dirs))
vpath %.h   $(include_dirs) \
            $(addprefix $(REF_SOURCE_DIR)/,$(include_dirs))

vpath %.a $(addprefix $(REF_BINARY_DIR)/lib/, codec db ui)

这种方法假定 CVS 检出的“粒度”是一个库或程序模块。在这种情况下,如果开发人员选择不检出缺失的库和程序目录,make 可以被设计为跳过它们。当需要使用这些库时,搜索路径将自动填充缺失的文件。在 makefile 中,modules 变量列出了要 module.mk 搜索的一组子目录。如果没有检出子目录,则必须编辑此列表以删除子目录。或者,modules 变量可以通过通配符设置:

modules := $(dir $(wildcard lib/*/module.mk))

这个表达式将找到包含 moudle.mk 的所有子目录。并返回目录列表。注意,由于 dir 函数的工作方式,每个目录将包含一个末尾斜杠。

make 也可以在单个文件级别上管理部分源代码树,通过从本地开发人员树中收集一些目标文件和从引用树中收集丢失的文件来构建库。然而,根据我的经验,这是相当混乱的,开发人员对此并不满意。

引用构建、库、和安装包

至此,我们已经基本涵盖了实现引用构建所需的所有内容。定制单个顶级 makefile 来支持该特性是很简单的。只需将对 SOURCE_DIRBINARY_DIR 的简单赋值替换为 ?= 赋值。从 cron 运行的脚本可以使用以下基本方法:

  1. 重定向输出并设置日志文件的名称
  2. 清除旧的构建并清除引用源树
  3. 检出最新源代码
  4. 设置源和二进制目录变量
  5. 调用 make
  6. 扫描日志,寻找错误
  7. 计算标记文件,如果可能,则更新定位数据库
    > locate 数据库是文件系统中所有文件名的汇编。这是执行按名称查找的一种快速方法。我发现这个数据库对于管理大型源代码树非常有用,并且喜欢在构建完成后更新它。
  8. 发布有关构建成功或失败的信息

在参考构建模型中,可以方便地维护一组旧构建,以防恶意签入破坏树。我通常保存 7 到 14 个夜间构建。当然,每晚构建脚本会将其输出记录到存储在构建本身附近的文件中,脚本会清除旧的构建和日志。扫描日志以寻找错误通常使用 awk 脚本。最后,我通常让脚本维护一个最新的符号链接。为了确定构建是否有效,我在每个 makefile 中包含一个验证目标。此目标执行构建目标的简单验证。

.PHONY: validate_build
validate_build:
    test $(foreach f,$(RELEASE_FILES),-s $f -a) -e .

这个命令脚本只是简单地测试一组预期的文件是否存在并且不是空的。当然,这并不能代替测试,而是对构建进行方便的完整性检查。如果测试返回失败,make 返回失败,夜间构建脚本会留下指向旧构建的最新符号链接。

第三方库的管理总是有点麻烦。我同意一个普遍的观点,即在 CVS 中存储大型二进制文件是不好的。这是因为 CVS 不能将增量存储为差异,而底层 RCS 文件可能会增长到非常大。

CVS 存储库中的非常大的文件会减慢许多常见的 CVS 操作,从而影响所有的开发。

如果第三方库没有存储在 CVS 中,则必须以其他方式对它们进行管理。我目前的首选是在引用树中创建一个库目录,并在目录名中记录库版本号,如图所示。

这些目录名由 makefile 引用:

ORACLE_9011_DIR ?= /reftree/third_party/oracle-9.0.1.1/Ora90
ORACLE_9011_JAR ?= $(ORACLE_9011_DIR)/jdbc/lib/classes12.jar

当供应商更新其库时,在引用树中创建一个新目录,并在 makefile 中声明新变量。通过这种方式,使用标记和分支正确维护的 makefile 总是显式地反映所使用的版本。

安装包也是一个难题。我认为将基本构建过程与创建安装包映像分离是一件好事。当前的安装工具是复杂和脆弱的。将它们合并到构建系统中(通常也是复杂和脆弱的)会产生难以维护的系统。相反,基本构建可以将其结果写入一个 “release” 目录,该目录包含安装程序构建工具所需的所有(或大部分)数据。这个工具可以由它自己的生成文件驱动,生成一个可执行的安装映像。

参考资料