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

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