本文内容来自对 isaacs/Makefile 的翻译、修改和补充。

Hello, 这是个 Makefile 的基础教程。 你将在这学到为什么 make 命令如此受欢迎。尽管它有着奇怪的语法,但实际上它的表达能力强而且高效,应对构建程序任务时它是个强有力的解决方案。

我们开始吧

你需要在含有 Makefile 文件的文件夹中使用 make 命令,也可以使用 make -f <makefile> 指定不同的文件名。 Makefile 是一堆规则(rules)的集合。每个规则表明了要做的事情,比如 grunt task 或者 npm package.json 之类的。

一条规则的语法如下:

<target>: <prerequisites...>
  <commands>

目标(target)是必需的。前置条件(prerequisites)和命令(commands)都是可选,但两者之间至少存在一个。 下面是一个示例,用 make 命令试试看会发生什么:

tutorial:
	@# todo: have this actually run some kind of tutorial wizard?
	@echo "Please read the 'Makefile' file to go through this tutorial"

一般来说,如果 make 没有指定目标,那么它会选择运行第一个目标,所以在这个情况下输入 makemake tutorial 是一样的结果。

默认情况下,命令在执行前命令本身会被先打印出来,这样你就能看到当前是什么命令在运行。虽然这背离了 “success should be silent” 的 UNIX 教条,但不这么做的话,你很难从构建日志中看出当前在执行的命令。 想要关掉这个默认输出的话,我们可以在每条命令前加上 @ 符号。

每条命令都会分别调用 shell,所以如果你在直接在上一个命令中设置了变量,这个变量将不会出现在下一个命令中。你可以试着输入 make var-lost 看下一个示例会发生什么:

var-lost:
	export foo=bar
	echo "foo=[$$foo]"

你可能注意到了刚才的命令中我们用了两个 $,这是因为每行 Makefile 都会被 make 转义后再传入 shell。至于为什么转义 $,下文会有解释~

我们可以通过在命令结尾加 \ 来让两个命令同一个 shell 执行,试着输入 make var-kept

var-kept:
	export foo=bar; \
	echo "foo=[$$foo]"

接下来让我们开始加入依赖吧。在这个示例中,我们将会依赖 source.txt 并创建一个新的文件 result.txt

result.txt: source.txt
	@echo "building result.txt from source.txt"
	cp source.txt result.txt

输入试试看 make result.txt。Oops…我们出现了错误:

$ make result.txt
make: *** No rule to make target `source.txt', needed by `result.txt'.  Stop.

看起来问题来源于我们试图依赖 source.txt 创建 result.txt,但我们没有告诉 make 怎么去拿到 source.txt,而该文件也不在 make 运行的目录下(如果你没有事先创建的话~)。 我们可以加入一个目标作为依赖生成 source.txt

source.txt:
	@echo "building source.txt"
  echo "this is the source" > source.txt

输入 make result.txt 将会先创建 source.txt 然后复制出 result.txt,试着再一次执行 make result.txt,你会发现什么都没发生。因为它的依赖 source.txt 没有发生改变,所以也就没有必要重新构建一次 result.txt

执行 touch source.txt,并编辑它,你会发现 make result.txt 又会开始重新构建。

试想一下假如我们在一个项目中需要100个 .c 文件,编译成与之相对的 .o 文件,然后把这些 .o 链接到二进制文件中。(这个和将100个 .style 变成 .css,再将他们组合到一起成为 main.main.css)一条一条的创建规则简直就是一场灾难。

好在 make 可以简化这些。通过创建一条通用的规则匹配任何符合模式的文件。然后声明另外一种匹配模式来作转换。

自动变量(Automatic Variables)

我们可以使用一些特殊的语法来适配输入和输出文件。下面是一些自动变量(Automatic Variables),它们的值与当前规则有关:

  • $@ 指代当前的目标,你可以把它当作 shell 脚本中的 $@@ 就像是 arguments 的首字母 a 一样。当你输入 make foofoo 就是参数。
  • $< 指代第一个前置条件。你可以把 < 当作 shell 中的输入管道。就像 head < foo.txtfoo.txt 的内容当作输入。
  • $^ 指代不止是指代第一个前置条件,而是全部的前置条件。你可以把它当作 @^ 来记忆,他们只是方向不一样(<^ 的区别~)。如果一个文件出于一些原因多次出现在前置条件中,在 $^ 中仍然只会显示一次。
  • $? 指代比目标新的所有前置条件。$? 就像一个问题,”等一下,为什么你要这么干?什么文件发生了改动?“
  • $$ 指代字面意思上的 $ 符号,更多的美元符号等于更多的现金等于美元符号(有点拗口)
  • $* 指代 % 符号匹配到的部分。(下面会有示例具体说明)

你也可以使用特殊语法 $(@D)$(@F) 分别指向 $@ 的目录名和文件名。$(<D)$(<F) 对于 $< 也是一样的意思。你可以把 D/F 这个技巧用在任何自动变量上。 还有一些其他的自动变量,不过那些变量大多你都用不到,先放在一边。

我们可以用这些变量,拿到我们想要的数据,比如目标和依赖条件的值:

result-using-var.txt: source.txt
    @echo "buildling result-using-var.txt using the $$< and \$$@ vars"
    cp $< $@

虽然方便了许多,但比起一个一个的列出它们,我们可以用一点 shell 脚本生成他们,并放入变量中。

函数(Functions)

make 提供一些内置函数帮助我们完成更加复杂的任务,这边我们只列出几个,这也是本文例子中会用到或提及的:

  • shell 函数。 用来执行 shell 命令
  • wildcard 函数。 列出与模式相匹配的路径,像这样 $(wildcard *.c)
  • patsubst 函数。 用于模式匹配的替换,语法为 $(patsubst pattern,replacement,text)

同时,通常情况下你应该使用 $(wildcard src/*.txt) 因为一般来说项目中已经存在这些文件了。不过这是一个教程,而我们想用它来生成文件。

这将会用 shell 生成一个文件名列表:

srcfiles := $(shell echo src/{00..99}.txt)

那么我们怎么在 src 文件夹中创建这些文件呢? 我们可以使用 % 占位符来表示”所有文件路径为 src/*.txt 格式的文件名”然后将这些匹配到的文件名会被放入 $* 变量中。

src/%.txt:
	@# First things first, create the dir if it doesn't exist.
	@# Prepend with @ because srsly who cares about dir creation
	@[ -d src ] || mkdir src
	@# then, we just echo some data into the file
	@# The $* expands to the "stem" bit matched by %
	@# So, we get a bunch of files with numeric names, containing their number
	echo $* > $@

试着运行 make src/00.txtmake src/01.txt 看看结果。文件被创建出来了。

不过为了不每个文件都 make 一次,我们应该定义一个”伪(phony)”目标依赖所有我们需要创建的源文件。(在使用伪目标时,或者依赖它时,make 就不会检查这个目标文件是否真正存在。毕竟如果目录下有个文件和目标相同的话,make 会认为没有必要重新构建,导致不会执行命令。)

这时我们运行 make source 会在 src/ 下创建所有的文件。首先它会把 srcfiles 变量中的路径当作前置条件,接着 src/%.txt 目标会与之匹配。于是便会执行匹配到的目标,创建 src/ 文件夹,并输出匹配的字段到文件中。试着运行 make source 看看结果把:

.PHONY: source
source: $(srcfiles)

源文件有了,现在是时候创建结果文件了,当然,我们得先创建结果文件夹。可能你想匹配所有的源文件,然后用它来创建结果文件:

dest/%.txt: src/%.txt
  @[ -d dest ] || mkdir dest
	cp $< $@

非常好,但这需要我们执行 make dest/#.txt 100次!唔。。我猜应该没人会想这么干。

我们还需要再补充点东西。 看起来我们应该创建一个伪目标依赖所有的结果文件,就像依赖所以的目标文件一样。

这一次我们可以使用内置的 patsubst 函数,它会把所有的源文件路径替换结果标文件路径,这样我们可以不用重建一个结果文件列表(就像 srcfiles 一样)。

destfiles := $(patsubst src/%.txt,dest/%.txt,$(srcfiles))
.PHONY: destination
destination: $(destfiles)

既然 destination 不是一个真正的文件名,我们应该把它定义为伪目标。使用.PHONY 声明伪目标是一个好习惯。

现在,让我们把这些结果文件名合在一起”编译”把,使用 cat 命令来演示这个效果:

kitty: $(destfiles)
	@# Remember, $< is the input file, but $^ is ALL the input files.
	@# Cat them into the kitty.
	cat $^ > kitty

执行 make kitty 看看会发生什么。 每个结果文件都被创建了出来,而且 kitty 文明中出现了每个结果文件的名字。如果再运行 make kitty,它会说 “kitty is up to date”。

如果你像这样 touch src/25.txt; make kitty改动了某个源文件,然后执行 make kitty,神奇的事情出现了! 你会发现 make 很聪明,它只会对更改过的源文件 25.txt 产生反应,重建与之对应的结果文件,并重新”编译”到 kitty 中。它不会每次都重写生成源文件,再重新生成结果文件。

Makefile 中写一个 test 目标是一个好习惯,因为大家会参与你的项目中,如果你的项目里有 Makefile 的话,他们会希望能用 make test 做一些事情。 当然没有 kitty 的话是不能跑测试的,所以我们需要依赖它:

.PHONY: test
test: kitty
	@echo "miao" && echo "tests all pass!"

最后,make clean 需要总是能移除你用 Makefile 创建出来的东西,这样我们就可以移除一些过期的”坏东西”。

.PHONY: clean
clean:
	rm -rf *.txt src dest kitty

如果出错的话会发生什么事情呢?打个比方你在构建东西,然后一条命令失败了,那么 make 会终止并且拒绝执行接下来的命令,返回一个非零的错误码。 为了示范这个操作,试着执行下面这段”坏猫猫”规则,它会退出且返回错误码为1。

.PHONY: badkitty
badkitty:
	$(MAKE) kitty # The special var $(MAKE) means "the make currently in use"
	false # <-- this will fail
	echo "should not get here"

有一些补充

make kitty

我把上面提到的一些示例整合到了一起,你可以直接试试 make kitty ~

srcfiles := $(shell echo src/{00..99}.txt)
destfiles := $(patsubst src/%.txt,dest/%.txt,$(srcfiles))

src/%.txt:
	@# First things first, create the dir if it doesn't exist.
	@# Prepend with @ because srsly who cares about dir creation
	@[ -d src ] || mkdir src
	@# then, we just echo some data into the file
	@# The $* expands to the "stem" bit matched by %
	@# So, we get a bunch of files with numeric names, containing their number
	echo $* > $@

dest/%.txt: src/%.txt
	@[ -d dest ] || mkdir dest
	cp $< $@
	
.PHONY: source
source: $(srcfiles)

.PHONY: destination
destination: $(destfiles)

kitty: $(destfiles)
	@# Remember, $< is the input file, but $^ is ALL the input files.
	@# Cat them into the kitty.
	cat $^ > kitty

.PHONY: test
test: kitty
	@echo "miao" && echo "tests all pass!"

.PHONY: clean
clean:
	rm -rf src dest kitty

赋值符

变量可以指向另外一个变量。

foo = $(bar)

Makefile 提供四种赋值符,区别如下: - VARIABLE = value 懒惰赋值。在执行的时候递归的获取值。也是赋值默认的行为。 - VARIABLE := value 立即赋值。定义变量时就赋好值,不会随着 value 的变更而发生变更 - VARIABLE ?= value 为空赋值。如果 VARIABLE 为空,赋值。 - VARIABLE += value 追加赋值。将值追加到变量的末尾。

下面是个示例展示他们的区别:

foo = "foo"
a = $(foo)
b := $(foo)
c ?= $(foo)
d += $(foo)
d += $(foo)
foo = "bar"

echo:
    @echo a = $(a)
    @echo b = $(b)
    @echo c = $(c)
    @echo d = $(d)

make echo 结果:

a = bar
b = foo
c = bar
d = bar bar

环境变量

你可以在 Makefile 中直接引入环境变量,也可以定义新的全局环境变量:

export FOO=${PATH}:/foo/bin

.PHONY: env
env:
    echo $$FOO

这个示例会创建一个全局的环境变量 FOO ,可以在任何命令中使用。不过由于每个命令都是新的 shell ,所以在命令中更改这些环境变量,其他的命令中是看不到更改效果的。

示例中引用的环境变量 PATH 如果不存在于调用 makeshell 中,也可以通过 make PATH="bar" env 来指明 PATH 的值。

小坑

Makefiletab 很执着,如果缩进的时候用了空格会报错的哦

Makefile:3: *** missing separator.  Stop.

你可以用 cat -e -t -v Makefile 来检查用了空格还是 tab

test:$
^I@echo tab$
    @echo space$

命令前面有 ^I 的就是 tab,没有的就是空格。

还有一件事

想必你已经大致的了解到了 Makefile,以及它能干什么。想进一步了解 Makefile?可以去看看它的官方手册GNU make~