在我写 Makefile 的头 10 年里,我养成了一个非常不好的习惯 -- 完全严格使用 GNU Make 的扩展名。过去我并不知道, GNU Make 与 POSIX 所保证的可移植特性之间的区别与联系。通常情况,它并不十分重要,但是当在非 Linux 系统上进行构建时,比如在各种 BSD 系统上,就会变成一件麻烦事儿。我不得不指定安装 GNU Make,然后在心里记住不要使用系统自带的 make ,而是使用 gmake 这样的工具来调用它。
我已经对 make 官方规范 十分熟悉,并且在过去的一年,我都在严格要求自己编写可移植的 Makefile。现在,我的构建不仅可以在各种类 unix 的系统之间进行移植,而且 Makefile 看起来更清晰与健壮。许多常见的 make 扩展名 -- 尤其是条件判断 -- 会导致不够健壮的却又复杂的 Makefile, 因此最好避免这些情况。能够确信你的构建系统能够各司其职,正常工作是非常重要的。
本指南不仅适用于之前从来没有写过 Makefile 的 make 初学者,同样适用于想要学习如何写出可移植 Makefile 的资深开发者。 但不管怎样,为了能够理解文中的示例,你必须首先对命令行(编译器,链接器,目标文件等等)构建程序的常规步骤十分熟悉。我不会建议使用任何花哨的技巧,也不会提供任何标准的初学者模板。当项目不大的时候,Makefile 应该是相当的简单,并且随着项目的成长,以一种可预见,清晰的方式不断丰富。
我不会覆盖 make 的每一个特性。如果想要学习所有完整的内容,你需要自行阅读它的规范。本指南将会详细讨论一些重要特性和约定俗成的规定。遵守已有的约定是非常重要的,这样使用你的 Makefile 的其他人,才能知道它能够完成和如何完成一些基本的任务。
如果你的系统是 Debian, 或是基于 Debian 的系统,比如 Ubuntu,bmake
和 freebsd-buildutils
包将会分别提供 bmake
和 fmake
程序。这些可供选择的 make 实现,对于测试 Makefile 的可移植性十分有用,尤其是当你不小心使用了 GNU Make 的特性。虽然每个实现都实现了与 GNU Make 完全相同的一些扩展,但是它会捕获一些常见的错误。
make 的核心就是一个或多个依赖树(dependency tree),这些依赖树是由 规则(rule)构造而来。树中的每个节点叫做“目标(target)”。构建(build)的最后产物(可执行程序,文档等等)位于树根。Makefile 指定了依赖树的内容,并且提供了 Shell 命令来从目标的 先决条件(prerequisite) 生成目标。
dependency tree
在上面的图示中,“.c” 结尾的文件是事先写好的源文件,而不是由命令生成的文件,所以它们没有先决条件。在依赖树中,指定一条或多条边的语法非常简单:
target [target...]: [prerequisite...]
从技术层面来讲,虽然多个目标可以通过一个单一规则指定,但是这种做法并不常见。典型地,每个目标会被它自己的构建规则来进行指定。比如,指定上述图示中的依赖:
game: graphics.o physics.o input.o
graphics.o: graphics.c
physics.o: physics.c
input.o: input.c
这些规则的先后顺序并不重要。在采取任何实际的动作之前,整个的 Makefile 都会被解析,所以树的节点和边可以被以任意顺序指定。只有一个意外:在 Makefile 中,第一个非特殊的目标会被认为是 默认目标(default target)。当调用 make 但是没有并没有指定一个目标时,这个默认目标就会被自动选择。它应该是看起来比较显然的一些东西,这样即使一个用户盲目地运行 make,也会得到一个有用的结果。
一个目标可以被指定多次。任何新的先决条件,都会被附加到已有的先决条件中。比如,下面的 Makefile 与上面的是一样的,不过实际上通常并不会这么写:
game: graphics.o
game: physics.o
game: input.o
graphics.o: graphics.c
physics.o: physics.c
input.o: input.c
有 6 个特殊目标(special target)用来改变 make 自身的行为。所有特殊目标都有大写的名字,并且开始于一个周期。符合这个模式的名字被 make 保留使用。根据标准,为了获得可靠的 POSIX 行为,Makefile 的第一个非注释行必须是 .POSIX
. 因为这是一个特殊的目标,所以它不能作为默认目标,故而 game
仍将作为默认目标:
.POSIX:
game: graphics.o physics.o input.o
graphics.o: graphics.c
physics.o: physics.c
input.o: input.c
在实际应用中,即使是一个简单的程序,也会有头文件。对于包含头文件的源文件,在依赖树也应该有指向源文件的边。如果头文件改变了,那么包含它的目标也应该被重新构建。
.POSIX:
game: graphics.o physics.o input.o
graphics.o: graphics.c graphics.h
physics.o: physics.c physics.h
input.o: input.c input.h graphics.h physics.h
虽然我们已经构造了一个依赖树,但是还没有告诉 make 如何真正地从目标的先决条件中构建出目标。规则也需要指定 Shell 命令,这些 Shell 命令会被用于从先决条件中生成目标。
如果你打算创建示例中的源文件,并调用 make, 你会发现它实际上已经知道了它该如何构建目标文件。这是因为 make 的初始配置已经有了一些 推断规则(inference rule),这部分将会在后面讨论。现在,我们会在开头加上 .SUFFIXES
这个特殊目标,擦除所有的内置推断规则。
在一个规则中,命令会随即跟在目标或先决条件那一行的后面。每个命令行必须以一个 tab 字符开头。如果你的编辑器不能进行相关配置的话,可能会非常麻烦。并且当你想要从拷贝本文的示例时,可能会遇到一些问题。
每个命令在属于自己的 Shell 中运行(译者:意思是每个 Shell 命令都是一个单独的进程),所以要注意:在使用像 cd
这样的命令时,它不会对后面的行造成影响。
要做的最简单的事情,就是就像在 Shell 输入一样逐字地输入同样的命令:
.POSIX:
.SUFFIXES:
game: graphics.o physics.o input.o
cc -o game graphics.o physics.o input.o
graphics.o: graphics.c graphics.h
cc -c graphics.c
physics.o: physics.c physics.h
cc -c physics.c
input.o: input.c input.h graphics.h physics.h
cc -c input.c
当调用 make 时,它会从依赖树中接受零个或多个目标, 如果目标过时(out-of-date)了,然后构建这些目标 -- 比如,运行目标规则中的命令。如果目标比其中的任一个先决条件要旧,那么这个目标就是过时了。
# build the "game" binary (default target)
$ make
# build just the object files
$ make graphics.o physics.o input.o
这会导致依赖树产生连锁效应,也就是说,一个目标的重建可能会导致它所涉及的更早期目标的重新构建,直到所有涉及的目标都是最新状态。因为树的不同分支可以被独立地进行更新,所以有很多并行化的空间。很多 make 的实现都支持通过 -j
选项进行并行构建。虽然这并非标准,但是在 Makefile 的一个非常棒的特性就是,它不需要任何特殊的东西就能正确地工作。
make 的 -k
("keep going")选项,功能与并行构建类似,是标准的。它会告诉 make 在遇到第一个错误时不要停下,而是继续更新不受该错误影响的目标。这对于 Vim’s quickfix list 和 Emacs’ compilation buffer 的填充非常好。
默认构建多个目标是十分常见的情况。如果第一个规则选择了默认目标,我们该如何解决需要多个默认目标的问题呢?传统方式是使用伪目标(phony target). 之所以用“伪”这个词,是因为它们没有相关文件与之关联,所以伪目标永远都不会是最新状态。习惯上,使用伪目标 all
作为默认目标。
我会用 game
作为新的 all
目标的一个先决条件。更多实际目标,可以作为必要条件加入到默认目标中。这个 Makefile 的使用者也可以使用 make all
来构建整个项目。
另一个常见的伪目标是 clean
,它会移除所有 make 创建的文件。用户可以使用 make clean
来删除所有构建生成的中间文件。
.POSIX:
.SUFFIXES:
all: game
game: graphics.o physics.o input.o
cc -o game graphics.o physics.o input.o
graphics.o: graphics.c graphics.h
cc -c graphics.c
physics.o: physics.c physics.h
cc -c physics.c
input.o: input.c input.h graphics.h physics.h
cc -c input.c
clean:
rm -f game graphics.o physics.o input.o
到目前为止,Makefile 是编译器硬编码为 cc
, 也没有使用任何的编译器标志(warning,optimization,hardening 等等)。虽然用户能够很容易控制所有这些事情,但是现在他们也不得不去编辑整个 Makefile 来这么做。可能用户同时安装了 gcc
和 clang
,并且想要选择一个或另一个不改变已安装的作为 cc
.
为了解决这一点,make 有宏(macro)的概念,当宏被引用时就会被展开为字符串。传统上,使用叫做 CC
的宏表示 C 编译器,CFLAGS
表示传递给 C 编译器的标志,LDFLAGS
表示当 C 编译器链接时的标志,LDLIBS
表示库链接时的标志。Makefile 应该在需要时提供默认值。
一个宏通过 $(...)
进行展开。引用一个尚未定义的宏是有效(也是常见)的,未定义的宏会被展开为一个空字符串。这就是下面的 LDFLAGS
情况。
宏的值可以包含其他宏,每当宏被展开时,它们会被递归展开。一些 make 的实现允许被展开为自身的宏的名字也是一个宏,这是图灵完备的, 但是这个行为并非是标准行为。
.POSIX:
.SUFFIXES:
CC = cc
CFLAGS = -W -O
LDLIBS = -lm
all: game
game: graphics.o physics.o input.o
$(CC) $(LDFLAGS) -o game graphics.o physics.o input.o $(LDLIBS)
graphics.o: graphics.c graphics.h
$(CC) -c $(CFLAGS) graphics.c
physics.o: physics.c physics.h
$(CC) -c $(CFLAGS) physics.c
input.o: input.c input.h graphics.h physics.h
$(CC) -c $(CFLAGS) input.c
clean:
rm -f game graphics.o physics.o input.o
通过 name=value
的形式,可以用命令行参数的方式对覆盖已有的宏定义。这是 make 其中一个非常强大,但是尚未被认识到的特性。
$ make CC=clang CFLAGS='-O3 -march=native'
如果用户不想在每次调用时指定这些宏,他们可以(小心)使用 make 的 -e
标志从环境中覆盖宏定义。
$ export CC=clang
$ export CFLAGS=-O3
$ make -e all
除了简单赋值(=), 一些 make 的实现有一些其他特殊的宏赋值操作符。这些并不是必要的,所以不用担心它们。
在三个不同的目标文件之间会有重复操作。如果有某种方式能够在这种模式通信不是更好吗?幸运的是,我们有 推断规则(inference rule)
。它说的是某个特定扩展名的目标,有另一个特定扩展名的先决条件,该目标通过某种确定的方式构建。用一个例子来说明更好一些。
在一个推断规则中,目标隐式表明了扩展名是什么。$<
宏展开为先决条件,这对使得推断规则变得更加通用十分重要。不幸的是,这个宏在目标规则中并不存在,这些都是有用的。
举个例子,下面是一个推断规则,它描述了如果从一个 C 源文件构建一个 .o
的目标文件。这个特殊的规则是 make 预先定义的,所以你不必自己去定义。为了完整性,我会包含这个:
.c.o:
$(CC) $(CFLAGS) -c $<
在它们生效之前,这些扩展名必须被加到 .SUFFIXES
。有了这个,生成目标文件规则的命令就可以被省略了。
.POSIX:
.SUFFIXES:
CC = cc
CFLAGS = -W -O
LDLIBS = -lm
all: game
game: graphics.o physics.o input.o
$(CC) $(LDFLAGS) -o game graphics.o physics.o input.o $(LDLIBS)
graphics.o: graphics.c graphics.h
physics.o: physics.c physics.h
input.o: input.c input.h graphics.h physics.h
clean:
rm -f game graphics.o physics.o input.o
.SUFFIXES: .c .o
.c.o:
$(CC) $(CFLAGS) -c $<
第一个空的 .SUFFIXES
会清空后缀列表(suffix list). 第二个 .SUFFIXES
将 .c
和 .o
加到现在是空的后缀列表中。
用户通常会希望有一个 install
目标,它会安装构建好的程序,库,man 手册等等。按照惯例,这个目标应该使用 PREFIX
和 DESTDIR
宏。
PREFIX
宏默认应该为 /usr/local, 因为它是一个可以覆盖的宏,用户可以选择覆盖它将程序安装到其他地方,比如安装到他们的用户目录。用户应该同时为构建和安装覆盖该值,因为 prefix 可能需要会需要构建到二进制中(比如,-DPREFIX=$(PREFIX)
).
DESTDIR
是一个用于 staged build(分段式构建)
的宏,为了打包的需要,它会安装到一个伪根目录。与 PREFIX
不同,它实际上不会从这个目录下运行。
.POSIX:
CC = cc
CFLAGS = -W -O
LDLIBS = -lm
PREFIX = /usr/local
all: game
install: game
mkdir -p $(DESTDIR)$(PREFIX)/bin
mkdir -p $(DESTDIR)$(PREFIX)/share/man/man1
cp -f game $(DESTDIR)$(PREFIX)/bin
gzip < game.1 > $(DESTDIR)$(PREFIX)/share/man/man1/game.1.gz
game: graphics.o physics.o input.o
$(CC) $(LDFLAGS) -o game graphics.o physics.o input.o $(LDLIBS)
graphics.o: graphics.c graphics.h
physics.o: physics.c physics.h
input.o: input.c input.h graphics.h physics.h
clean:
rm -f game graphics.o physics.o input.o
你可能也想要提供一个 uninstall
的伪目标来卸载程序。
make PREFIX=$HOME/.local install
其他常见的目标有 “mostlyclean”(与 clean 类似,但是不会删除构建缓慢的目标),"distclean" (与 “clean” 删除的更多),“test” (运行测试组件),“dist”(创建一个包)。
make 的一大缺点是当项目不断成长时,会变得越来越麻烦。
当你的项目被分为几个子目录,你可能会试图在每个子目录下放一个 Makefile ,然后递归调用。
不要使用递归的 Makefile。它会在几个分离的 make 实例之间打破依赖树,并且常常会产生脆弱的构建。使用递归的 Makefile 毫无益处。好的选择是在项目的根目录放置一个 Makefile, 在那里进行调用。你可能需要告诉你的编辑器如何做到这一点。
当涉及子目录下的文件时,在名字中包含子目录即可。所有 make 关心的内容都会跟之前一样正常工作,包括推断规则。
src/graphics.o: src/graphics.c
src/physics.o: src/physics.c
src/input.o: src/input.c
将你的目标文件从源文件中分离出来是一个不错的想法。当谈到 make 时,总是喜忧参半。
喜的是 make 能做。你可以为目标和先决条件设置任何你喜欢的文件名。
obj/input.o: src/input.c
忧的是,推断规则对于源文件之外的构建并不兼容。如果推断规则不存在,那么你就需要对每个规则重复同样的命令。对于大型项目,这太繁琐了,所以你可能想要有一些“配置”脚本,即使这些脚本是手写的,来为你生成这些重复的命令。实际上,这就是 CMake 所涉及的所有事情,再加上依赖管理。
项目规模越来越大的另一个问题是,在所有的源文件上跟踪所有改变过的依赖。除非你先 make clean
,否则缺失一个依赖,就意味着构建可能失败.
如果你打算用一个脚本来生成 Makefile 冗长的部分,GCC 和 Clang 都提供了一个生成所有 Makefile 依赖的特性(-MM, -MT),至少对 C 和 C++ 如此。有很多教程讲述了如何在构建时同时生成依赖,但是它很脆弱和缓慢。最好是在一次性完成,在 Makefile 中写好依赖,以便于 make 能够如期工作。如果依赖改变了,那么重新构建你的 Makefile.
举个例子,下面是在源文件之外的构建,它一个调用 gcc 的依赖生成器的例子,而不是虚构的 input.c
:
$ gcc $CFLAGS -MM -MT '$(BUILD)/input.o' input.c
$(BUILD)/input.o: input.c input.h graphics.h physics.h
注意,输出的是 Makefile 的规则格式。
不幸的是,这个特性去除了目标的路径头,所以,在实际中,使用它往往会它本来的要更复杂(比如,比要求使用 -MT).
微软有一个叫做 Nmake 的 make 实现,它与 Visual Studio 一起发行。它几乎是一个兼容 POSIX 的 make, 但是在一些地方对与标准不同。他们的 cl.exe
编译器使用 .obj
作为目标文件扩展名, .exe
作为二进制扩展名,这两个扩展名与 unix 系统都不同,所以它有一些不同的内置推断规则。Windows 同样也缺少一个 bash 和标准的 unix 工具,所以所有的命令都会有所不同。
在 Windows 上,并没有 rm -f
这样的替代品,所以在写 claen
目标时只能说好运了。del /f
并不能达到同样的效果。
所以,尽管它与 POSIX make 已经很接近,但是想要写一个 Makefile 能够同时被 POSIX make 和 Nmake 同时使用,是不太实际的。需要有两个不同的 Makfile.
有一个值得信赖,能够在任何地方工作的可移植 Makefile 是非常棒的一件事情。Code to the standards,然后你就不再需要特性测试或其他一些特殊处理了。
本文译自:A Tutorial on Portable Makefiles
附录:
伪目标惯例 | 意义 |
---|---|
all | 所有目标的目标,一般为编译所有的目标,对同时编译多个程序极为有用 |
clean | 删除由make创建的文件 |
install | 安装已编译好的程序,主要任务是完成目标执行文件的拷贝 |
列出改变过的源文件 | |
tar | 打包备份源程序,形成tar文件 |
dist | 创建压缩文件,一般将tar文件压缩成Z文件或gz文件 |
TAGS | 更新所有的目标,以备完整地重编译使用 |
check和test | 一般用来测试makefile的流程 |
附录来自清华的 MOOC 学堂在线课程 <<基于 Linux 的 C++ >>,第 12.12 - 12.14 节有讲 Makefile,初学者推荐看一下。