Makefile 概念介绍

原始知乎链接

博客解释链接

另一个解释

本文主要介绍如何使用“基于目标的分层”方法来理解一个工具,编写概念,定义设计或部署一组代码。

本文首先是“Makefile 介绍”,然后是“基于目标的分层方法介绍”。

关于程序编译

Makefile 解决的是编译的问题。Makefile 最初是用来解决 C 语言的编译问题的,所以与 C 的关系特别密切,但并不是说 Makefile 只能用来解决 C 的编译问题。你用来处理 Java 一点问题没有,但对于 Java,显然 ant 比 Makefile 处理得更好。

比如说,你有 foo.c、bar.c、main.c 三个 C 文件,你要编译成一个 app.executable,你会怎么做呢?你会执行这样的命令:

1
2
3
4
gcc -Wall -c foo.c -o foo.o
gcc -Wall -c bar.c -o bar.o
gcc -Wall -c main.c -o main.o
gcc main.o foo.o bar.o -lpthread -o app.executable

按照程序员的惯例,凡是要一次次重新执行的命令,都应该写成脚本。所以,简单来说,你会把上面这个命令序列写成一个 build.sh,每次编译你只要执行这个脚本问题就解决了。

但这个脚本有问题,假设我修改了 foo.c,但我没有修改 bar.c 和 main.c,那么执行这个脚本是很浪费的,因为它会无条件也重新编译 bar.c 和 main.c。

这个脚本更合理的写法应该是这样的:

1
2
3
4
[ foo.o -ot foo.c ] && gcc -Wall -c foo.c -o foo.o
[ bar.o -ot bar.c ] && gcc -Wall -c bar.c -o bar.o
[ main.o -ot main.o] && gcc -Wall -c main.c -o main.o
[ app.executable -ot main.o ] && [ app.executable -ot foo.o ] && [ app.executable -ot bar.o ] && gcc main.o foo.o bar.o -lpthread -o app.executable

如果你面对一个问题,不要尝试重新去定义这个问题,而是看它和原来的问题相比,多出来的问题是什么,尝试解决那个多出来的问题就好了。那么这里,多出来的问题就是文件修改时间比较。这个就是 Makefile 要解决的基本问题了。我们定义一种新的“脚本语言”(只是不用 sh/bash/tch 来解释,而是用 make 来解释),可以用很简单的方法来说明我们需要做的文件比较。这样上面的脚本就可以写成这个样子了:

1
2
3
4
5
6
7
8
9
#sample1
foo.o: foo.c
gcc -Wall -c foo.c -o foo.o
bar.o: bar.c
gcc -Wall -c bar.c -o woo.o
main.o: main.c
gcc -Wall -c main.c -o main.o
app.executable: foo.o bar.o main.o
gcc main.o foo.o bar.o -lpthread -o app.executable

上面那个 Makefile 中,foo.o: foo.c 定义了一个“依赖”,说明 foo.o 是靠 foo.c 编译成的,它后面缩进的那些命令,就是简单的 shell 脚本,称为规则(rule)。而 Makefile 的作用是定义一组依赖,当被依赖的文件比依赖的文件新,就执行规则。这样,前面的问题就解决了。

IDE 中封装了 Makefile 的使用,但想要具体控制特定文件的编译细节,最终仍然需要面对这些问题,IDE 和 make 工具的对比,两者解决的是问题的不同层次。

Makefile 中的依赖定义构成了一个依赖链(树),比如上面这个 Makefile 中,app.executable 依赖于 main.o,main.o 又依赖于 main.c,所以,当你去满足 app.executable(这个目标)的依赖的时候,它首先去检查 main.o 的依赖,直到找到依赖树的叶子节点(main.c),然后进行时间比较。这个判断过程由 make 工具来完成,所以,和一般的脚本不一样。Makefile 的执行过程不是基于语句顺序的,而是基于依赖链的顺序的。依赖树

phony 依赖

make 命令执行的时候,后面跟一个“目标”(不带参数的话默认是第一个依赖的目标),然后以这个目标为根建立整个依赖树。依赖树的每个节点是一个文件,任何时候我们都可以通过比较每个依赖文件和被依赖文件的时间,以决定是否需要执行“规则”

但有时,我们希望某个规则总是被执行。这时,很自然地,我们会定义一下永远都不会被满足的依赖。

可能会这么写:

1
2
test:
DEBUG=1 ./app.executable

test 这个文件永远都不会被产生,所以,你只要执行这个依赖,rule 是必然会被执行的。这种形式看起来很好用,但由于 make 工具默认认为你这是个文件,当它成为依赖链的一部分的时候,很容易造成各种误会和处理误差。

所以,简化起见,Makefile 允许你显式地把一个依赖目标定义为假的(Phony)

1
2
3
.PHONY: test
test:
DEBUG=1 ./app.executable

这样 make 工具就不用多想了,也不用检查 test 这个文件的时间了,反正 test 就是假的,如果有人依赖它,无条件执行就对了。

前面的 sample1 明显还是有很多多余的成份,这些多余的成份可以简单通过引入“宏”定义来解决,比如上面的 Makefile,我们把重复的东西都用宏来写,就成了这样了:

1
2
3
4
5
6
7
8
9
10
11
12
#sample2
CC=gcc -Wall -c
LD=gcc

foo.o: foo.c
$(CC) foo.c -o foo.o
bar.o: bar.c
$(CC) bar.c -o bar.o
main.o: main.c
$(CC) main.c -o main.o
app.executable: foo.o woo.o main.o
$(LD) main.o foo.o bar.o -o app.executable

还是有“多余”的成份在,因为明明依赖中已经写了 foo.o 了,rule 中还要再写一次,我们可以把依赖的对象定义为 $@,被依赖的对象定义为 $^(这是当前 gnumake 的设计),这样就可以进一步化简:

1
2
3
4
5
6
7
8
9
10
11
12
#sample3
CC=gcc -Wall -c
LD=gcc

foo.o: foo.c
$(CC) $^ -o $@
bar.o: bar.c
$(CC) $^ -o $@
main.o: main.c
$(CC) $^ -o $@
app.executable: foo.o woo.o main.o
$(LD) $^ -o $@

很明显,这还是有重复,我们可以把重复的定义写成通配符:

1
2
3
4
5
6
7
8
9
10
11
#sample4
CC=gcc -Wall -c
LD=gcc

%.o: %.c
$(CC) $^ -o $@
foo.o: foo.c
woo.o: woo.c
main.o: main.c
app.executable: foo.o woo.o main.o
$(LD) $^ -o $@

实际上,你要化简,还有很多手段,比如 gnumake 其实是默认定义了一组 rule 的,上面这个整个你都可以不写,就这样就可以了:

1
2
3
4
5
6
#sample5
LDLIBS=-lpthead
SRC=$(wildcard *.c)
OBJ=$(SRC:.c=.o)
app.executable: $(OBJ)
#看不懂

这里其实没有定义 .o 到 .c 的依赖,但 gnumake 默认如果 .c 存在,.o 就依赖对应的 .c,而 .o 到 .c 的 rule,是通过宏默认定义的。你只要修改 CC,LDLIBS 这类的宏,就能解决大部分问题了。所以你又省掉了一组定义,这就可以写得很短。

头文件问题

现在我们把问题搞得复杂一点,增加三个头文件。比如 foo.h、bar.h 和 common.h,前两者定义 foo.c 和 bar.c 的对外接口,给 main.c 使用,common.h 定义所有文件都要用到的通用定义(foo.h 和 woo.h 中包含 common.h)。这样前面这个 sample1 就有毛病了。照理说,foo.h 更新的时候,foo.o 和 main.o 都需要重新编译,但根据那个定义,根本就没有这个比较。

我们的定义必须写成这个样子:

1
2
3
4
5
6
7
8
9
10
11
#sample4+
CC=gcc -Wall -c
LD=gcc

%.o: %.c
$(CC) $< -o $@
foo.o: foo.c foo.h common.h
bar.o: bar.c bar.h common.h
main.o: main.c foo.h bar.h common.h
app.executable: foo.o bar.o main.o
$(LD) $^ -o $@

(注:这个例子我们在 .o.c 依赖的规则中使用了 $< 宏,它和 $^ 的区别是,它不包括依赖列表中的所有文件,而仅仅是列表中的第一个文件)

这就又增加了复杂度了——头文件包含关系一变化,我就得更新这个 Makefile 的定义。这带来了升级时的冗余工作。按我们前面考虑一样的策略,我们尝试在已有的名称空间上解决这个问题。Makefile 已经可以定义依赖了,但我们不知道这个依赖本身。这个事情谁能解决?——把这个过程想一下——其实已经有人解决这个问题了,这个包含关系谁知道嘛?当然是编译器。编译器都已经用到那个头文件了,当然是它才知道这种包含关系是什么样的。比如 gcc 本身直接就提供了 -M 系列参数,可以自动帮你生成依赖关系。比如你执行 gcc -MM foo.c 就可以得到

1
foo.o: foo.c foo.h common.h

本文引自开头的链接,由于能力有限及需求不高,剩余的看的不是很懂,目前就到这里。