前言
之前接触并且使用过 Makefile
,但是一直没有彻底的理解 Makefile
。最近的一个项目要在不同的架构运行时,之前一直是使用 .sh
文件进行代码编译和部署的。但后来要维护的 .sh
文件越来越多,想着如何把这些文件合并到一个文件当中,这时突然就想到了 Makefile
,所以才打算再重新学习并且深入理解一下 Makefile
Makefile
Makefile
一般是用于进行大型项目的编译和构建的。最常见的就是 C,C++
项目的编译,其他的语言一般都会有自己的构建工具。在 C
或 C++
中,可以通过文件的改动与否来决定哪一部分需要重新编译,哪一部分不需要,这样可以节省时间和资源。当然,除了编译以外,Makefile
也可以执行一系列不同的指令,来生成对应的内容。Makefile
的本质就是要进行 make
,而 make
的本意就是制作。根据依赖的文件和对应的指令 make
出目标文件
举个例子,创建一个文件名为 Makefile
的文件,写入以下内容:
bash
hello.txt:
echo "Hello world!" > hello.txt
在终端执行 make
之后,上面的例子就会在当前目录下 make
出一个 hello.txt
的文件,文件的内容就是 Hello word!
。由此可见,make
的本意就是制作,根据命令制作出目标文件。
Makfile 语法
Makefile
有一套自己的语法规则,常见的 Makfile
的规则如下:
bash
targets: prerequisites
command
command
command
targets
:用空格分开的文件名,一般只有一个prerequisites
: 也是空格分开的文件名,它是targets
依赖的文件。这些文件要在命令make
目标文件targets
时存在command
:一系列的用于make
目标文件targets
的命令。要注意的是,在command
的最前头是一个tab
,而不是空格
下面通过一个例子来解释一下不同部分具体的含义:
bash
hello.txt:exmaple.txt
cat exmaple.txt > hello.txt
保存之后,我们在终端执行 make hello.txt
,就会生成一个 hello.txt
文件,文件的内容就是 exmaple.txt
的内容。在这个例子中,
target
为hello.txt
。make
的目标hello.txt
的依赖文件为exmaple.txt
。make
时要执行的命令就是cat exmaple.txt > hello.txt
,读取exmaple.txt
文件的内容,写入到hello.txt
文件
上面的例子中 exmaple.txt
文件要在执行 make
之前存在于该目录下面,否则 make
就会执行失败
Makefile 的本质
上面的例子简单的介绍了 Makefile
。下面通过一个复杂一点的例子来看看,Makefile
的运行原理。因为 Makefile
非常适合编译 C,C++
语言,那么就用来编译一个 C
语言的例子
首先,我们创建一个 blah.c
的文件,如下:
csharp
// blah.c
int main() { return 0; }
接着,我们编写 Makefile
来编译 C
程序,如下:
makefile
blah:
cc blah.c -o blah
我们运行 make
(如果在 make
之后没有指定目标参数,那么文件中的第一个目标对应的命令就会运行)。运行完成之后,就会编译出一个 blah
程序。如果我们再运行一次时,就会出现 make: 'blah' is up to date
的提示了。即便我们重新更改了 blah.c
之后,再执行 make
还是会出现同样的提示,除非再 make
之前删除 blah
程序
为了有一种更好的体验,我们可以对 Makefile
进行如下的更改,
makefile
blah: blah.c
cc blah.c -o blah
这个例子和上面的区别就是给目标 blah
添加了一个依赖 blah.c
。此时,我们再次 make
还是会产生一个 blah
文件。如果我们不更改 blah.c
,再次执行 make
,还是会出现 make: 'blah' is up to date
的提示,这说明 blah
程序是最新的,无需重新编译。
如果我们更改 blah.c
的内容,再执行 make
,那么就会执行成功。并且用新产生的 blah
文件覆盖掉之前的 blah
文件。这就是 make
的本质,当目标文件的依赖文件不变时,重新 make
时,对应的命令不会执行。而目标文件的依赖若是变了,重新编译就会产生新的目标文件且覆盖掉之前的目标文件
那 make
是如何觉察目标的依赖变了还是没变呢?它是根据文件的时间戳,每次 make
之后都会记下生成当前目标文件时所有依赖文件的时间戳。下次 make
时,如果依赖的时间戳变了就会重新 make
,否则就不会。但是,此处有一个问题,就是如果你更改了文件,并且更改了文件的时间戳为之前的时间戳,那么 make
就无法正确的判断文件是否发生了变化
目标依赖其他目标
在上面的例子中,目标依赖的是其他的目标。同样的,目标也可以依赖其他的目标。如下面的例子所示:
makefile
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
依赖 blah.o
,blah.o
又依赖 blah.c
。当我们在执行 make
时,首先会去执行第一个目标 blah
的命令,因为看到这个目标依赖 blah.o
,所以就会转去执行 blah.o
,而在执行 blah.o
时发现目标依赖 blah.c
,那么就会再去执行 blah.c
。等 blah.c
完成之后,再会依次执行 blah.o
和 blah
。
在这个例子中,为了 make
获得 blah
,产生了一堆中间文件 blah.c, blah.o
。所以执行完成之后需要清理,也可以通过 Makefile
实现,具体如下:
makefile
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
clean:
rm blah.c blah.o
在执行完 make
之后,再执行 make clean
就会清楚产生的中间文件了。如果你闲麻烦,不想执行两次 make
,也可以使用 all
ALL
为了能够一次 make
完成编译,并且在编译之后清除中间文件,可以更改 Makefile
如下:
makefile
all: blah clean
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
clean:
rm blah.c blah.o
在上面的例子中,我们添加一个目标 all
,然后它的依赖就是其他下面两个目标。那么,此时我们执行 make all
,就会按顺序执行下面的两个命令了,这样就不需要 make
两次了
.PHONY
在上面介绍了 Makefile
的语法和一些简单的例子,下面通过更多的例子来揭露 Makefile
的本质。如下面这个例子:
bash
hello:
echo "Hello, World"
我们运行 make hello
,只要 hello
文件不存在,这个命令就会运行。这个 Makefile
中的 target hello
和之前的那个 target hello.txt
不太一样。这个 Makefile
在 make hello
之后,只是在终端输出 Hello, World
,不会去创建 hello
文件。但是,此处有一个问题,如果在 Makefile
同级目录下面正好有一个 hello
的文件,那会怎么样呢?
如果在同级目录下面 make hello
之前,已经存在一个 hello
文件了,那么这个命令就不会执行了。因为要 make
的目标已经存在了,而且是最新的,按照 Makefile
的规则就不会再会去 make hello
了。为了解决这个问题,就需要引入一个新的关键字 .phony
了。
我们还以上面的例子来说明,加入现在同级目录下面已经存在一个 hello
文件了,为了使得上面的命令能够继续执行,我们需要修改 Makefile
如下:
makefile
.PHONY: hello
hello:
echo "Hello, World"
修改保存之后,此时再运行 make hello
就会执行命令,在终端输出 Hello World
了。上面的例子中的 .PHONY
的作用就是告诉 make
这是一个虚拟目标,不会产生该目标的文件,所以就不会受到统计目录下面 hello
文件名的影响了
Makfile 中的变量
在 Makefile
中也可以使用变量,一遍变量的声明方式为 :=
,直接使用 =
也可以。下面是一个使用变量的例子:
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
变量的引用一般是使用 ${}
或者 $()
。如下面的例子:
bash
x := dude
all:
echo $(x)
echo ${x}
# Bad practice, but works
echo $x
在 Makefile
中有一些常见的内置变量,如下所示:
- $@: 表示当前目标文件的名称。
- $^: 表示所有的依赖文件,以空格分隔。
- $<: 表示第一个依赖文件。
$*
: 表示目标文件名称的主干部分(不包含扩展名),比如:目标 hello.c ,那么$*
表示hello
。- MAKE: make命令本身的名称。
- MAKECMDGOALS: 命令行中指定的目标列表。
- MAKEFILES: 包含被载入的Makefile的文件列表。
- MAKELEVEL: 当前Make的嵌套深度。
- MAKEFILE_LIST: 已读取的Makefile文件列表。
Makefile 模式匹配
Makefile
中支持两种模式匹配符号:*
和 %
。*
表示在你的文件系统重搜索匹配的文件名称,并且经常和 wildcard
函数一起使用。*
可以用在目标,依赖或者是 wildcar
函数中。一般不建议单独使用,you如下一些原因:
- 单独使用时,
*
是在shell 中展开,而不是在make
中展开 - 不同的操作系统或者shell 会有不同的表现,
wildcard
提供了一致的行为 - 单独使用时,如果匹配不到值时,
*
会被原样保留。而使用wildcard
函数会返回空列表 - wildcard 提供了更加复杂的模式匹配,比如:$(wildcard *.c *.h)
所以,一般使用时:
ini
# 不推荐:可能无法正确展开或在某些情况下出错
SRCS = *.c
# 推荐:使用 wildcard 函数
SRCS = $(wildcard *.c)
% 通配符
在 %
通配符中,有两种类型的操作:模式匹配和字符串替换。在模式匹配当中,被匹配到的部分 stem
(词干)。可以在目标和依赖中应用模式匹配 %
。也可以用 $*
引用 stem
。下面具体例子说明:
makefile
%.o: %.c
gcc -c $< -o $@
在这个例子中:
- %.o 是目标模式
- %.c 是依赖模式
- % 匹配相同的字符串(stem)在目标和依赖中
假设我们有一个文件叫 main.c,当 Make 处理它时:
- % 匹配 "main"
- "main" 就是这个规则的 stem
- $< 展开为 main.c(第一个依赖)
- $@ 展开为 main.o(目标)
所以这个规则实际上会执行:
css
gcc -c main.c -o main.o
stem 的概念允许我们编写通用的规则,可以应用于多个文件,而不需要为每个文件单独写规则。
一些进阶用法:
-
在命令中使用 stem:
makefile%.o: %.c @echo "Compiling $* (stem)" gcc -c $< -o $@
-
在依赖中使用 stem:
makefile%.o: %.c %.h gcc -c $< -o $@
-
复杂模式:
makefilelib%.a: %.o ar rcs $@ $
这会将 example.o 转换为 libexample.a。
下面是一些字符替换的例子:
go
sources := foo.c bar.c baz.c
objects := $(sources:.c=.o)
这里 $(sources:.c=.o)
表示将 sources 变量中所有 .c 结尾的文件名替换为 .o 结尾。结果是 objects 变成 "foo.o bar.o baz.o"
下面是一个更加复杂的例子:
go
files := a.txt b.txt c.txt
newfiles := $(files:%.txt=%.md)
这将 files 中的所有 .txt 文件替换为 .md 文件。newfiles 的值将是 "a.md b.md c.md"。
将模式匹配和字符替换结合
makefile
prog_sources := $(wildcard *.c)
prog_objects := $(prog_sources:.c=.o)
%.o: %.c
gcc -c $< -o $@
program: $(prog_objects)
gcc $^ -o $@
这个例子结合了通配符(wildcard)、模式替换和模式规则:
- 首先找到所有 .c 文件
- 然后生成对应的 .o 文件列表
- 使用模式规则编译每个 .c 文件
- 最后链接所有 .o 文件生成最终的程序
stem 的概念使得 Makefile 能够更灵活地处理文件名模式,大大简化了构建规则的编写。
Makefile 的运行环境
在 Makefile
中,每一行命令默认都是在一个新的 shell
实例中执行的,这意味着:
- 每条命令都在一个全新的环境中运行
- 命令之间没有状态共享
- 一行中的多个命令(用分号分隔)会在同一个 Shell 中执行
下面具体例子来说明:
bash
one:
cd ..
# The cd above does not affect this line, because each command is effectively run in a new shell
echo `pwd`
two:
# This cd command affects the next because they are on the same line
cd ..;echo `pwd`
three:
# Same as above
cd ..; \
echo `pwd`
目标 one
是进入上层目录,然后打印上层的绝对路径,但因为 makefile
中每一行命令都是在一个新的 shell
中执行的,所以 one
目标的 echo pwd
不会打印上层目录的绝对路径,而是当前目录的绝对路径。要实现以上功能,可以使用 make two
。它的命令是在同一行,中间用 ;
分隔开。目标 three
和目标 two
效果相同
在 Makefile
中默认的shell 是 /bin/sh
,也可以通过指定变量 SHELL
来更改:
bash
SHELL=/bin/bash
cool:
echo "Hello from bash"
参考: