GNU Make学习总结(一)

Intro

写Linux代码也有段时间了,一直都是采用make的方式来编译工程,但对Makefile掌握的并不是很全面。前段时间看到了《GNU Make项目管理》这本书,决定系统的学习一下Makefile语法,这几篇博客是对学习内容的总结。

入门

简介

Make是一种将源代码转换成可执行文件的自动化工具,通过Make语言,描述了源文件、中间文件、可执行文件之间的关系。与此同时,Make工具可以对编译过程进行优化,在重新编译时会根据时间戳来决定哪些文件需要重新生成,在编译大型工程时,这会省下不少时间(每次第一次编内核都是输完Make后先睡一觉再说…)。Make有多种变种,其中GNU Make使用相对广泛,在大多Linux/UNIX系统都对其提供支持。

Make一般将细节放在Makefile中,make命令会自动在当前文件夹下找到Makefile并执行,而Makefile的核心内容就是规则,它是Makefile的主要组成。每项规则可以由三部分组成:目标(target),必要条件(prerequisite),命令(command)。书写格式如下所示,目标和条件之间由冒号隔开,命令写在下一行,并以TAB开头,每条规则中可以有多个目标,多个条件以及多条命令。

1
2
3
target1 target2: prereq1 prereq2
command1
command2

对于规则的理解,就是如果目标文件不存在或者必要条件中的某个文件时间戳比目标文件的时间戳要新,那么就执行下面的若干条命令,最后生成新的目标文件。

样例

下面通过一个简单的样例来说明如何书写Makefile。这是一个简单的评估打字速度和打字正确率的程序(其实就是给出一段英文单词看多久敲完~)。目录结构如下,其中main.c是主程序入口,timeutil.c中封装了关于时间的操作,wdshow.c中是主要的代码。

1
2
3
4
5
6
|-- InputSpeed
|-- main.c
|-- timeutil.c
|-- timeutil.h
|-- wdshow.c
|-- wdshow.h

代码都很短,为了方便,就放在一起了,前面的注释表明属于哪一个文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
//------------timeutil.h------------
#ifndef _TIMEUTIL_H_
#define _TIMEUTIL_H_

typedef long long LL;
LL getTimeMS();

#endif

//------------timeutil.c------------
#include "timeutil.h"
#include <stdio.h>
#include <sys/time.h>

LL getTimeMs(){ //返回当前时间对应的毫秒数
struct timeval tm;
gettimeofday(&tm, NULL);
return (LL)tm.tv_sec*1000+tm.tv_usec/1000;
}

//------------wdshow.h------------
#ifndef _WDSHOW_H_
#define _WDSHOW_H_

extern int runTime;
extern int chError;
extern int chTotal;

void wdShow();
void outResult();

#endif

//------------wdshow.c------------
#include <stdio.h>
#include <stdlib.h>
#include "wdshow.h"
#include "timeutil.h"

int chError = 0;
int chTotal = 0;
int runTime = 0;

void wdShow(int num){ //生成随机串显示,计算完成输入的时间,num表示字符的数目
char *wdShow = (char*)calloc(1, num + 1);
char *wdInput = (char*)calloc(1, num + 1);
int i;
chTotal = num;
for (i = 0; i < num; i++)
wdShow[i] = rand()%26+'a';
printf("%s\n", wdShow);
runTime = getTimeMs();
fgets(wdInput, num + 1, stdin);
for (i = 0; i < num; i++)
if (wdInput[i] != wdShow[i]) chError++;
runTime = getTimeMs() - runTime;
}
void outResult(){ //输出结果
printf("\nword Total: %d\nword Error: %d\ntime Total: %dms\n",
chTotal, chError, runTime);
}

//------------main.c------------
#include "wdshow.h"

int main(int argc, char *argv[]) {
sleep(1);
wdShow(10);
outResult();
return 0;
}

如果手动编译这份工程,首先要将三个.c源文件编译成.o目标文件,再将.o文件链接起来变为可执行文件,一共需要四条gcc命令(当然,一条命令是可以搞定的,这只是编译器默默做了其它的过程而已)。似乎我们可以写一个包含这几条gcc命令的脚本,然后通过执行脚本来完成自动编译,其实不然,在我们修改了某个.c文件后执行脚本重新编译时,脚本里的四条命令会依次执行,而实际上我们运行其中两条命令就可以了(重新生成该.c对应的.o并重新链接程序),这是因为脚本并不能判断哪些步骤不需要再做。这时,Makefile就登场了,它可以根据规则知道哪些命令需要执行,哪些命令不需要执行。
我们根据文件之间的依赖关系可以写出四条规则,每条规则对应一条命令,将这四条规则放在一起,该工程的对应的Makefile就完成了。

1
2
3
4
5
6
7
8
9
10
11
main: main.o timeutil.o wdshow.o
gcc timeutil.o wdshow.o main.o -o main

main.o: main.c wdshow.h
gcc -c main.c

wdshow.o: wdshow.c wdshow.h timeutil.h
gcc -c wdshow.c

timeutil.o: timeutil.c timeutil.h
gcc -c timeutil.c

Make会将第一条规则中的目标作为最终目标,也就是我们的可执行文件main,在生成main的时候,需要三个对象文件作为必要条件文件,如果对象文件不存在或不是最新,Make会继续去找是否有规则以该文件为目标文件,找到的话就执行对应的命令,否则就会报错。这个过程大致如下,是一个递归的算法

1
2
3
4
5
6
7
8
9
10
11
12
make(rule) {
for target in rule
for each prerequisite file in rule
if file exist and is up-to-date
return "ok";
else if there is a rule(called rule-file) for file
make(rule-file);
else
return "error"
for each command in rule
run command
}

执行make命令,输出如下。可以看到,第一条命令是最后执行的,这不难理解,因为Make查找的过程是一个递归过程,最先入栈的程序将在最后被执行。

1
2
3
4
gcc -c main.c
gcc -c timeutil.c
gcc -c wdshow.c
gcc main.o timeutil.o wdshow.o -o main

接下来执行./main就可以运行程序了,以下是运行结果, 手速慢勿喷,最后一个字母是为了显示错误结果故意打错的。

1
2
3
4
5
6
nwlrbbmqbh
nwlrbbmqba

word Total: 10
word Error: 1
time Total: 3787ms

通过以上这个样例我们看到了怎样通过Makefile来编译一个简单的小项目。但是,这个Makefile是有很多缺点的,当在一个有成百上千文件的大工程中编写Makefile时,如果要手工分析依赖关系并一条条写下来,工作量可想而知,而且当工程结构发生变化时,修改起来也是十分麻烦。一个书写良好的Makefile应该是有较好的可扩展性和兼容性的,工程中有所变化往往不需要修改Makefile,甚至对于一些结构固定的小项目,一份Makefile可以做到通用。

规则

这一章主要对生成规则进行了详细介绍,主要分为具体规则、模式规则、静态模式规则、隐含规则、后缀规则等等。在了解这些规则之前,首先了解一些其它的前提知识。

变量及自动变量

在Makefile中,使用$后跟一个字符或者一个加圆括号的单词表示变量,比如$x,$(xx)。关于变量的使用会在下一章详细介绍,这里主要说一下自动变量。自动变量一般用在命令中,会根据目标和条件自动替换成对应的内容。以下是几个常用的自动变量:

1
2
3
4
5
6
7
$@	目标文件名
$% 档案文件成员,是指以a.o(b.o)这种形式作为目标时,括号中的内容
$< 第一个必要条件文件名
$? 时间戳在目标文件之后的所有必要条件文件名,空格隔开
$^ 所有必要条件的文件名,空格隔开,这份列表删除了重复的文件名
$+ 和$^一样,只是未删除重复的文件名
$* 目标的主文件名(即不包括后缀)

以上变量都有两个变体,加D表示文件的目录部分,加F表示文件的文件名部分,注意要加括号,如$(@D),$($F)等。
我们可以使用自动变量来修改之前的Makefile,简化命令部分的书写

1
2
3
4
5
6
7
8
9
10
11
main: main.o timeutil.o wdshow.o
gcc $^ -o $@

main.o: main.c wdshow.h
gcc -c $<

wdshow.o: wdshow.c wdshow.h timeutil.h
gcc -c $<

timeutil.o: timeutil.c timeutil.h
gcc -c $<

假想目标

所谓假想目标,就是指不指向实际文件的目标。这类目标因为其不指向任何文件,所以总是要被更新的,也就是说他们对应的命令总会被执行,依赖的必要文件也总要保持最新,我们可以利用这个特性扩展Makefile的功能。比如使用一个目标为clean的规则来删除生成的目标文件,用一个目标为all的规则来指定多个需要生成的可执行文件。
但是需要注意的是,假如我们的工程中恰好有一个叫clean文件,那么make clean将总会返回文件已是最新的,因为make并不知道clean是一个假想目标还是一个实际文件,如果有clean文件存在make会将clean作为一个目标文件,而clean本身也没有任何条件,所以总是不需要进行更新。为了告诉编译器clean是一个假想目标,我们需要将clean指定成.PHONY的一个必要条件,这样clean就总会被更新了,这里.PHONY是一个特殊目标,它告诉Make它的必要条件都是假想目标。于是我们可以在原来的Makefile中加入以下几行:

1
2
3
4
.PHONY: clean all
all: main
clean:
rm main *.o

all和clean是通用的假想目标,all用于指定所有需要生成的可执行文件,clean用于指定清除操作,另外还有以下几个比较常用的通用假想目标

1
2
3
4
5
install		经过make all步骤后,在系统中安装生成的二进制程序
distclean 比clean更彻底的删除,包括由configure生成的Makefile文件
TAGS 提供可供编辑的标记表
info 从Textinfo源码创建GNU info源码
check 执行相关测试

.PHONY类似的常用特殊目标还有以下几个

1
2
3
4
5
.SUFFIXES		指定Makefile已知后缀列表,用于后缀规则
.INTERMEDIATE 必要条件视为中间文件,make过程如果生成了指定的中间文件,完成后会被删除
.SECONDARY 同样指定中间文件,但make完成后不会被删除
.PRECIOUS make运行中断时不删除指定的目标文件
.DELETE_ON_ERROR make运行中断时删除指定的目标文件

VPATH和vpath

在工程中,很少会把所有的文件都放在同一个文件夹下,常见的就是将头文件放在include下,将.c文件放在src下,我们可以将前一章样例中的代码更改为以下结构

1
2
3
4
5
6
7
8
9
|-- InputSpeed
\-- include
|-- wdshow.h
|-- timeutil.h
\-- src
|-- timeutil.c
|-- wdshow.c
|-- main.c
Makefile

这时候再在InputSpeed目录下执行make就会报找不到文件的错误,解决的方法之一是通过VPATH = src include将源文件加入到Make的搜索路径中,但这样并不是最好的方法,因为不同的文件夹下可能有同名的文件,VPATH只会返回第一个找到的文件,并不一定是我们想要的文件,我们可以使用vpath pattern directory的形式来指定在哪个目录下搜索哪个文件,具体用法可以看下面给出的Makefile,其中%类似于通配符,告诉Make在src下找.c文件,在include下找.h文件。现在make已经可以找到所有的文件了,但还不行,因为gcc命令找不到函数的原型,我们通过-I命令把头文件的位置告诉GCC。修改后的Makefile文件如下所示

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#VAPTH src include
vpath %.c src
vpath %.h include
CFLAGS = -I include

main: main.o timeutil.o wdshow.o
gcc $^ -o $@

main.o: main.c wdshow.h
gcc $(CFLAGS) -c $< -o $@

wdshow.o: wdshow.c wdshow.h timeutil.h
gcc $(CFLAGS) -c $< -o $@

timeutil.o: timeutil.c timeutil.h
gcc $(CFLAGS) -c $< -o $@

再次执行make,已经可以成功编译了。观察打印信息,我们发现自动变量也已经自动加上了文件路径。

1
2
3
4
gcc -I include -c src/main.c -o main.o
gcc -I include -c src/timeutil.c -o timeutil.o
gcc -I include -c src/wdshow.c -o wdshow.o
gcc main.o timeutil.o wdshow.o -o main

接下来介绍各种规则

具体规则

在上一章的样例中写的几条规则都属于具体规则,所谓具体规则,就是目标、条件、命令都明确给出的规则。

模式规则

在gcc编译器中一般使用后缀表示文件类型,gcc会将x.c文件认为是C源文件,并翻译成对应的x.o,这种文件名的对应关系是模式规则的基础,使得Makefile对于目标x.o会自动去寻找对应的x.c文件,因此对于很多规则,我们都可以简化。
在一个规则中,如果主文件名中包含了%就表示这是一个模式规则。所谓模式规则,是指对符合这个模式的目标都采用这个规则,注意%和通配符的不同,在Makefile中是可以使用通配符的,*.c表示的是所有以c结尾的文件的集合,而%.c表示所有以c结尾的文件都匹配这条规则,一定要注意区分。
所有的内置规则都是模式规则,使用make -p可以看到这些内置规则,用其中生成%.o的这一条作为例子

1
2
3
%.o: %.c
# commands to execute (built-in):
$(COMPILE.c) $(OUTPUT_OPTION) $<

其中COMPILE.c = $(CC) $(CFLAGS) $(CPPFLAGS) $(TARGET_ARCH) -c,OUTPUT_OPTION = -o $@,这条语句中用变量来定义了一些操作,这些变量都有默认值,我们也可以自己修改变量来更改执行的语句,比如CC=gcc,CFLAGS=-I include,那这条命令就是gcc -I include -c -o $@ $<,对于大多数.o文件,确实只要执行这一条命令就足够了。
当然,我们也可以编写自己的模式规则,格式与上面的内置规则完全一样。

静态模式规则

与模式规则基本一样,只是规定了模式的范围,格式如下,模式目标文件必须在指定的变量中

1
2
$(OBJECTS): %.o: %c
.......

后缀规则

这是一个已经过时的规则,在一些老的Makefile中可能会看到,目标名使用一个或者两个后缀,比如.c.o,相当于模式规则中的%.o: %.c,或者.p,相当于模式规则中的%: %.p

隐含规则

就是make自带的内置规则,不是模式规则就是后缀规则。在我们没有为规则编写命令时,make会自动去内置规则中找是否有对应的规则,上面模式规则中举的例子就是一个隐含规则,利用该隐含规则,我们可以进一步简化我们的Makefile,

1
2
3
4
5
6
7
8
vpath %.c src
vpath %.h include
CFLAGS = -I include

main: main.o timeutil.o wdshow.o
main.o: wdshow.h
wdshow.o: wdshow.h timeutil.h
timeutil.o: timeutil.h

隐含规则的出现,使我们的Makefile一句命令都没有用就完成了编译,也简洁了很多,实在是十分强大。

自动生成依赖

确实,隐含规则已经十分强大了,但还不够,因为我们还是要自己去填写头文件的依赖关系,对于小项目工作量不大,但对于大型项目,头文件包含头文件形成复杂的树形结构,人工添加依赖实在是十分吃力的事情。gcc中有一个神奇的选项-MM,我们来看看gcc -I include -MM main.c timeutil.c wdshow.c会输出什么

1
2
3
main.o: src/main.c include/wdshow.h
timeutil.o: src/timeutil.c include/timeutil.h
wdshow.o: src/wdshow.c include/wdshow.h include/timeutil.h

没错,它帮我们生成了依赖关系,所以只要将依赖关系导入到Makefile就真的解放了!那怎样导入呢?下面先给出一个比较容易想到的方法,Makefile内容如下。先执行make depend将依赖关系写入到depend文件中,再运行make命令,这时depend文件会被include到Makefile中从而完成编译。

1
2
3
4
5
6
7
8
vpath %.c src
vpath %.h include
CFLAGS = -I include
CC = gcc
main: main.o timeutil.o wdshow.o
depend: main.c timeutil.c wdshow.c
$(CC) -MM $(CFLAGS) $^ > $@
include depend

但是,这个脚本还是有缺点的,每次一点改动都有可能要重新生成depend文件才能保持正确性,有没有更好的方法呢?GNU Make手册中给了一个晦涩难懂但十分强大的写法,先看看使用该方法的Makefile

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
vpath %.c src
vpath %.h include
CFLAGS = -I include
CC = gcc
main: main.o timeutil.o wdshow.o
sources = main.c timeutil.c wdshow.c
include $(sources:.c=.d)
%.d: %.c
@set -e;rm -f $@; \
$(CC) -MM $(CFLAGS) $< > $@.$$$$; \
sed 's,\($*\)\.o[ :]*,\1.o $@ : ,g' < $@.$$$$ > $@; \
rm -f $@.$$$$
.PHONY: clean
clean:
rm main *.o *.d

首先,include $(sources:.c=.d)展开后变成include main.d timeutil.d wdshow.d。然后make有一个特性,就是在include一个文件的时候,会将这个文件作为目标,也就是说会执行规则%.d: %.c,而这个规则对应的命令实际上就是先用gcc生成依赖规则后,再用sed命令将%.o:...改成%.o %.d:...,最后输出到%.d文件中,这样就将%.d和%.o依赖相同的文件。在之后项目的开发过程中,如果某个文件有所改动,以它为目标的.d文件中的规则就会自动更改,从而保持规则的正确性。

文章目录
  1. 1. Intro
  2. 2. 入门
    1. 2.1. 简介
    2. 2.2. 样例
  3. 3. 规则
    1. 3.1. 变量及自动变量
    2. 3.2. 假想目标
    3. 3.3. VPATH和vpath
    4. 3.4. 具体规则
    5. 3.5. 模式规则
    6. 3.6. 静态模式规则
    7. 3.7. 后缀规则
    8. 3.8. 隐含规则
    9. 3.9. 自动生成依赖