入门指南
为什么会有 Makefile?
Makefile 的存在,是为了帮助判断一个大型程序中哪些部分需要重新编译。在绝大多数情况下,Makefile 用于编译 C 或 C++ 文件。其他编程语言通常都有类似于 Make 的工具来完成类似的工作。事实上,Make 不仅限于代码编译;当你需要根据文件变化来执行一系列操作时,它也非常有用。
本教程将聚焦于 Make 在 C/C++ 编译场景下的使用。
下面是一个你可能会用 Make 构建的依赖关系图示例。如果某个文件的依赖发生了变化,那么该文件就会被重新编译:
Make 的替代方案有哪些?
在 C/C++ 领域,常见的替代构建系统包括 SCons、CMake、Bazel 和 Ninja。一些代码编辑器,比如 Microsoft Visual Studio,也内置了自己的构建工具。
对于 Java,有 Ant、Maven 和 Gradle 这样的构建工具。而其他语言,比如 Go、Rust 和 TypeScript,也都有各自专属的构建系统。像 Python、Ruby 和原生 JavaScript 这样的解释型语言则通常不需要类似 Makefile 的东西。Makefile 的主要目的是根据哪些文件发生了变化来决定需要编译哪些文件。但对于解释型语言来说,当文件发生变化时,并不需要重新编译——程序在运行时会自动使用最新的源文件。
Make 的版本和类型
Make 有多种不同的实现,但本指南中的内容基本适用于你正在使用的任何版本。不过,它是专门为 GNU Make 编写的,这是 Linux 和 macOS 上的标准实现。文中的所有示例都兼容 Make 的第 3 版和第 4 版,这两者在功能上几乎没有差别,只有一些少见的细节差异。
运行示例
要运行这些示例,你需要一个终端,并确保已安装 make 工具。对于每个示例,把内容保存到一个名为 Makefile
的文件中,然后在该目录下运行命令 make 即可。我们先从最简单的 Makefile 示例开始:
hello:
echo "Hello, World"
tips: Makefile 中的缩进必须使用 Tab 键,不能使用空格,否则 make 命令会执行失败。
这是运行上面那个简单示例后的输出结果:
$ make
echo "Hello, World"
Hello, World
Makefile 语法简介
一个 Makefile 是由一组 规则(rules) 组成的。每条规则通常看起来像这样:
targets: prerequisites
command
command
command
- 目标是文件名,用空格分隔。通常每条规则只包含一个目标。
- 命令是一系列通常用于生成目标的步骤。这些命令行的开头必须使用 Tab 字符,而不能使用空格。
- 先决条件(prerequisites)也是文件名,用空格分隔。这些文件在执行目标的命令之前必须存在,它们也被称为依赖(dependencies)。
Make 的本质
我们从一个简单的 Hello World 示例开始:
hello:
echo "Hello, World"
echo "This line will print if the file hello does not exist."
这里有很多内容需要理解,我们来逐步解析:
- 我们有一个名为 hello 的目标(target)。
- 这个目标有两个命令。
- 这个目标没有先决条件(prerequisites)。
接下来我们运行 make hello。只要 hello 文件不存在,命令就会执行。如果 hello 文件存在,则命令不会执行。
需要注意的是,我在这里既提到 hello 作为目标,也提到它作为文件。这是因为目标和文件是紧密相连的。通常,当一个目标被执行(也就是执行目标的命令时),命令会创建一个与目标同名的文件。在这个例子中,hello 目标不会创建 hello 文件。
接下来,我们将创建一个更典型的 Makefile —— 一个用来编译单个 C 文件的 Makefile。在此之前,请创建一个名为 blah.c 的文件,并将以下内容写入其中:
// blah.c
int main() { return 0; }
然后创建一个 Makefile(如同往常一样,命名为 Makefile):
blah:
cc blah.c -o blah
这次,尝试直接运行 make
。由于没有为 make
命令提供目标参数,因此会运行第一个目标。在这个例子中,只有一个目标(blah
)。第一次运行时,blah
文件会被创建。第二次运行时,你会看到 make: 'blah' is up to date
,因为 blah 文件已经存在。但这里有一个问题:如果我们修改了 blah.c
文件,然后再次运行 make
,它不会重新编译。
我们通过添加一个先决条件来解决这个问题:
blah: blah.c
cc blah.c -o blah
当我们再次运行 make
时,以下步骤会发生:
- 选择第一个目标,因为第一个目标是默认目标。
- 这个目标有一个先决条件
blah.c
。 - Make 会决定是否应该运行
blah
目标。只有在blah
文件不存在,或者blah.c
比blah
文件更新时,Make 才会运行。
最后一步是至关重要的,它是 make
的核心。它的目的是判断 blah
的先决条件自上次编译以来是否发生了变化。也就是说,如果 blah.c
被修改了,运行 make
应该重新编译该文件。反之,如果 blah.c
没有变化,那么就不需要重新编译。
为了实现这一点,make
使用文件系统的时间戳作为判断是否有变更的依据。这是一种合理的启发式方法,因为文件的时间戳通常只有在文件被修改时才会更新。但需要注意的是,这并不总是准确的。比如你可以修改一个文件,然后将它的修改时间戳改成一个较早的时间。如果你这么做了,make
就会错误地判断该文件没有被修改,因此会忽略它。
呃呃呃,这一段信息量很大。请务必理解这一点——它是 Makefile 的核心内容,可能需要你花几分钟时间才能真正掌握。如果你还感到困惑,可以多尝试上面的例子。
更多快速示例
下面这个 Makefile 最终会运行三个目标。当你在终端中运行 make 时,它会通过一系列步骤构建一个名为 blah
的程序:
- Make 选择目标
blah
,因为第一个目标是默认目标。 -
blah
依赖于blah.o
,所以make
查找blah.o
目标。 -
blah.o
依赖于blah.c
,所以make
查找blah.c
目标。 -
blah.c
没有依赖项,因此会运行echo
命令。 - 然后执行
cc -c
命令,因为blah.o
的所有依赖已经完成。 - 最上面的
cc
命令接着执行,因为blah
的所有依赖也都完成了。 - 就这样,
blah
被编译成了一个C
程序。
blah: blah.o
cc blah.o -o blah # Runs third
blah.o: blah.c
cc -c blah.c -o blah.o # Runs second
# Typically blah.c would already exist, but I want to limit any additional required files
blah.c:
echo "int main() { return 0; }" > blah.c # Runs first
- 如果你删除
blah.c
,三个目标都会被重新执行。 - 如果你修改了它(从而使时间戳比
blah.o
更新),那么前两个目标会被执行。 - 如果你运行了
touch blah.o
(使其时间戳比 blah 更新),那就只有第一个目标会执行。 - 如果你什么都没改,所有目标都不会被执行。可以自己试试看!
下面这个例子没有引入新内容,但依然是个很好的额外示例。它总是会执行两个目标,因为 some_file
依赖于 other_file
,而 other_file
从未被创建过。
some_file: other_file
echo "This will always run, and runs second"
touch some_file
other_file:
echo "This will always run, and runs first"
Make clean
clean
通常被用作一个目标,用来移除其他目标生成的输出,但它在 Make 中并不是一个特殊的关键词。你可以使用 make
和 make clean
来创建或删除 some_file
。
这里的 clean
做了两个新的事情:
- 它是一个既不是第一个(默认)目标、也不是任何目标的前置条件的目标。这意味着它只有在你显式运行
make clean
时才会执行。 - 它不是用于生成的文件名。如果你碰巧有一个名为
clean
的文件,这个目标将不会执行,这不是我们想要的结果。可以通过后面将要讲到的.PHONY
来解决这个问题。
some_file:
touch some_file
clean:
rm -f some_file
变量
变量在 Makefile 中只能是字符串。通常你会想使用 :=
,但 =
也可以使用。详见后续的“变量(第二部分)”。
下面是一个使用变量的示例:
files := file1 file2
some_file: $(files)
echo "Look at this variable: " $(files)
touch some_file
file1:
touch file1
file2:
touch file2
clean:
rm -f file1 file2 some_file
单引号或双引号对 Make 来说没有特殊意义。它们只是被当作字符分配给变量。不过对 shell/bash 来说,引号是有意义的,你在像 printf
这样的命令中是需要引号的。
在这个示例中,这两个命令的行为是一样的:
a := one two# a is set to the string "one two"
b := 'one two' # Not recommended. b is set to the string "'one two'"
all:
printf '$a'
printf $b
引用变量时可以使用 ${}
或 $()
这两种方式。
x := dude
all:
echo $(x)
echo ${x}
# Bad practice, but works
echo $x
目标(Targets)
all 目标
你想一次运行多个目标?那就创建一个 all
目标。由于它是列出的第一个规则,当不指定目标运行 make
时,它会作为默认执行的目标。
all: one two three
one:
touch one
two:
touch two
three:
touch three
clean:
rm -f one two three
多个目标
当一个规则有多个目标时,命令会针对每个目标执行。$@
是一个自动变量,它包含当前目标的名称。
all: f1.o f2.o
f1.o f2.o:
echo $@
# Equivalent to:
# f1.o:
# echo f1.o
# f2.o:
# echo f2.o
自动变量和通配符
* 通配符
*
和 %
都被称为 Make 中的通配符,但它们的含义完全不同。*
用于在文件系统中搜索匹配的文件名。我建议你总是将它包裹在 wildcard
函数中,否则你可能会陷入下面描述的常见陷阱。
# Print out file information about every .c file
print: $(wildcard *.c)
ls -la $?
*
可以在目标、前提条件或 wildcard
函数中使用。
危险:*
不能直接用于变量定义中。
危险:当 *
不匹配任何文件时,它会保持原样(除非在 wildcard
函数中使用)。
thing_wrong := *.o # Don't do this! '*' will not get expanded
thing_right := $(wildcard *.o)
all: one two three four
# Fails, because $(thing_wrong) is the string "*.o"
one: $(thing_wrong)
# Stays as *.o if there are no files that match this pattern :(
two: *.o
# Works as you would expect! In this case, it does nothing.
three: $(thing_right)
# Same as rule three
four: $(wildcard *.o)
% 通配符
%
非常有用,但由于它可以在多种情况下使用,因此有些令人困惑。
- 当用于“匹配”模式时,它匹配字符串中的一个或多个字符。这个匹配被称为“词干”(stem)。
- 当用于“替换”模式时,它会将匹配到的词干替换到字符串中。
-
%
最常用于规则定义和一些特定的函数中。
自动变量
有很多自动变量,但通常只有一些会出现:
hey: one two
# Outputs "hey", since this is the target name
echo $@
# Outputs all prerequisites newer than the target
echo $?
# Outputs all prerequisites
echo $^
# Outputs the first prerequisite
echo $<
touch hey
one:
touch one
two:
touch two
clean:
rm -f hey one two
......持续更新