makefile自动化工程
在学习自动化构建工程之前,我们每要想编一个译文件,就得不厌其烦地编写一个gcc指令。自己编写指令比较麻烦还不是主要的问题,主要的问题是gcc指令写错造成的问题:
假设你有一个编写好代码的文件code.c,你用code.c文件拷贝出一份文件code1.c,当你想要编译code1.c,你应该执行的代码是:
gcc code1.c -o code1
但是,你将code1.c与code1的位置搞混,写成了:
gcc code1 -o code1.c
后果就是:不仅Bash报错,code1.c文件也丢失了。

为了解决这些问题,我们就需要学习make / makefile以构建自动化工程。
首先我们要清楚:
make:指令Makefile / makefile:文件 ------ 描述的是如何编译当前工程
我们先来见一见自动化工程构建的过程。
1、简单演示
这里有一个我们剩下的code.c文件,里面的内容也是正常的。

创建文件makefile,vim打开makefile,写下指令:
Bash
code:code.c
gcc code.c -o code
然后在外面执行make。就会发现,code.c文件被编译成了code:

接着我们再在makefile中添加指令:
bash
.PHONY:clean
clean:
rm -rf code
然后在外面执行make clean。就会发现,code被清除了:

我们就完成了一个简单的自动化工程,即对code.c文件进行自动化编译和清除。
创建自动化工程中,我们创建了一个文件makefile。现在我们试着理解makefile文件里到底写了些什么东西。
2、makefile内容的理解
依赖关系和依赖方法
对于指令:
bash
code:code.c
gcc code.c -o code
我们称:
code:code.c:依赖关系gcc code.c -o code:依赖方法
依赖关系与依赖方法,我们可以简单举两个例子:
- 你考入了大学,花钱上了大学,此时你与大学就建立了依赖关系。你想找工作,于是你好好学大学里的知识,然后大学老师会帮你找工作;学习知识与让老师帮你找工作,以达成你找工作的目的,就是依赖方法。
- 父亲与儿子的父子关系是依赖关系。月底儿子打电话给父亲,叫父亲给生活费,以达成自己需要得到生活费的目的,这就是依赖方法。
对应的,makefile里记录的就是依赖关系与依赖方法的集合。
目标文件与依赖文件列表
bash
code:code.c
# 冒号左边的是目标文件
# 冒号右边的是依赖文件列表
注意此处的目标文件,要与源文件翻译中出现的目标文件(.o文件)区分开来。
当然,我们观察与清除有关的指令:
bash
.PHONY:clean
clean:
rm -rf code
依赖文件列表,是可以为空的。
make指令的默认行为

我们这样做:
- 将当前makefile的code.c编译指令与程序清除指令的顺序颠倒
- 然后自行gcc编译code.c生成code

我们执行make:

code就被我们删掉了!
所以,默认情况下,make执行的是makefile中第一个依赖关系的依赖方法。
其实,默认情况下,make执行后生成的也只是从上到下第一个目标文件。
如果我们需要指定生成 哪个目标文件,只需在make后添加目标文件的名称。
对于mkaefile文件内容有了基本的认识,我们再来理解make / makefile到底是怎么形成可执行文件的。
3、自动化工程编译时的推导过程
我们知道,.c源文件编译的整个过程是:
- 预处理成.i文件
- 编译成.s文件
- 汇编成.o文件
- .o文件再进行链接,形成可执行程序
对于一个给定的文件源文件code.c,我们在makefile中反着设计code.c的编译过程:
bash
code:code.o
gcc code.o -o code
code.o:code.s
gcc -c code.s -o code.o
code.s:code.i
gcc -S code.i -o code.s
code.i:code.c
gcc -E code.c -o code.i

我们惊讶地发现,code被编译出来了!而且能够执行。
看上去,make进行了逆向的推导,那么make是怎么做到逆向的推导的?
事实上,make命令内部,维护了一个栈结构。
make命令会在当前目录下寻找makefile文件并解析 ,make对makefile的解析是自上而下的。
当前makefile内容:
bash
code:code.o
gcc code.o -o code
code.o:code.s
gcc -c code.s -o code.o
code.s:code.i
gcc -S code.i -o code.s
code.i:code.c
gcc -E code.c -o code.i
对于code:code.o,make会在当前目录下寻找依赖文件code.o。而当前目录下没有code.o,依赖方法也就执行不了。此时make就会将依赖方法gcc code.o -o code入栈:

make继续向下,找到code.o:code.s,make依旧没能在当前目录下找到code.s,依赖方法也执行不了。此时make也将依赖方法gcc -c code.s -o code.o入栈:

make一直向下,如果没有在当前目录下找到依赖文件,就将依赖方法入栈。直到make能够找到依赖文件:

make就开始循环执行栈顶依赖方法,出栈...直到栈为空。
以上过程,就是make编译时的逆向推导过程。
这里有两个细节:
- 实际上我们不会做
code.c -> code.i -> code.s -> code.o -> code这么麻烦的操作,我们顶多将code.c直接翻译为code.o,再进行链接。 - 编写makefile代码(脚本)的时候,只能用tab,不能用空格代替。
4、清除工程
工程被创建,即文件被统一编译好后,工程就可以执行;而工程不被需要的时候,我们就应该清除工程。清除工程,就是清除一些临时文件。
准备一个C语言文件code.c,建立code.c的makefile文件:

其中,清除临时文件的代码为:
bash
.PHONY:clean
clean:
rm -rf code
rm -rf code.c
其中有三个细节,我们将分别进行讲解。
4.1、前两个细节
依赖文件列表可以为空
这个我们之前已经提到过。
依赖方法的种类与数量
如上面代码所示,依赖方法可以有多个,只需缩进并列在依赖关系下即可。
其实,依赖方法可以是Linux的任意指令。比如我们添加一个创建文件的指令:
bash
.PHONY:clean
clean:
rm -rf code
rm -rf code.c
touch test1.txt

4.2、.PHONY
bash
.PHONY:clean
clean:
rm -rf code
rm -rf code.c
我们可以认为,.PHONY是一个修饰词。.PHONY修饰clean,就是告诉make,clean是一个伪目标。
.PHONY修饰的目标文件,目标文件对应的依赖方法,总是被执行。
总是被执行是什么意思?
4.2.1、"总是被执行",与最佳实践
对于一个完整的makefile文件:
bash
code:code.o
gcc -o code code.o
code.o:code.c
gcc -c code.c -o code.o
.PHONY:clean
clean:
rm -rf code
rm -rf code.o
当code.c还未被编译,或者code.c内容被修改的时候,我们执行第一次make可以成功,而接下来执行make就不成功了:

这就是不总是被执行。
而我们执行make clean,可以发现make clean每次都成功:

这就是总是被执行。
我们建议,.PHONY修饰clean,而不要修饰创建目标文件等其它依赖方法。
因为我们可以反复清理工程以保证临时文件被彻底消除,而已经编译好的文件就没必要再进行编译;只有文件还未被编译,或者文件内容被修改,才需要重新编译。
这个最佳实践本质上是为了进行有效编译,从而提高效率。
那么问题来了,make怎么知道code.c需要被重新编译?
4.2.2、code.c什么时候需要重新编译?
我们先来搞清楚文件的三个时间。
执行指令stat code.c:

我们主要关注这三个时间:
- Access:code.c文件上一次被访问的时间
- Modify:code.c文件上一次内容被修改的时间 ,或者code.c被创建的时间
- Change:code.c文件上一次属性被修改的时间
我们修改文件属性,文件的Change就会改变。比如我们修改文件code.c,使之对other不具有读权限:

接着使用stat查看:

code.c的Change果然被改变了,并且code.c的Access与Modify没有被改变。
我们修改文件的内容呢?

code.c的三个时间都被改变了。我们可以做这样的解释:修改文件内容需要打开文件,Access被改变;修改文件内容那么Modify一定会被改变;修改文件内容,文件的大小被改变,Change被改变。
访问文件内容,Access有时会改变,有时不会改变。文件被访问是最频繁的事 ,理论上Access应该是三个时间中改动最频繁的那个;而文件Access的每一次改变,都必须将结果写入磁盘以保存,Access的频繁改动,就会加重系统的IO负担。所以Access并不是每次访问都更新,而是有一个更新的策略,不同的系统有不同的更新策略。
如果我们touch已有的文件,文件的三个时间也会改变:

有了上面的知识的铺垫,我们就可以解释code.c什么时候需要重新编译。
正常情况下,code.c文件完成编译,生成code可执行文件,code的Modify肯定晚于code.c:


当code.c还未被编译,或者code可执行文件被删除了,code.c需要再次编译,此时code都没有,更别谈code的时间了。
当code.c第一次编译后,code.c的内容被修改。此时code.c的Modify被修改,code.c的modify就晚于code的modify:


由此,我们就能弄清楚code.c什么时候需要重新编译:
- 当可执行文件code存在,且code的Modify晚于code.c,code.c就不需要重新编译
- 当可执行文件code不存在,或者code存在,但code的Modify早于code.c,code.c就需要重新编译
我们也可以顺势推导出:.PHONY的作用,就是忽略以上对于Modify时间的对比 ,使得make执行后直接生成目标文件。
4、makefile的最佳实践
我们建议:
- 多个源文件,分别编译成.o文件
- 所有的.o文件再与第三方库链接形成可执行文件.exe
bash
code.exe:code.o
gcc -o code.exe code.o
code.o:code.c
gcc -c code.c -o code.o
.PHONY:clean
clean:
rm -rf code.exe code.o
5、makefile的实用语法
我们可以先给当前makefile备份,因为makefile待会儿是要被修改的,备份以防不时之需。(makefile-backup)
我们在最佳实践中写的makefile,依赖关系与依赖方法有很多重复使用的名字,太冗余了;当前makefile只能针对code.c这一个源文件进行自动化编译,应用范围太狭窄了。
所以我们来学习一些makefile的语法,使我们写的makefile更加简洁,更加通用。
首先,对于
bash
code.o:code.c
gcc -c code.c -o code.o
可以直接省略简写成:
bash
code.o:code.c
gcc -c code.c # -o code.o可省略
5.1、特殊变量
我们用@表示目标文件,用^表示所有依赖文件,用$提取。
那么,
bash
code.exe:code.o
gcc -o code.exe code.o
就可以改成
bash
code.exe:code.o
gcc -o $@ $^
其中,@与^就是特殊变量。
5.2、自定义变量
makefile里可以做到一种类似自定义变量的行为:
bash
Bin=code.exe
Obj=code.o
Src=code.c
注意以上写法不要带空格。
所以我们就可以对一些重复使用的文件名,替换成变量名:
bash
Bin=code.exe
Obj=code.o
Src=code.c
$(Bin):$(Obj)
gcc -o $@ $^
$(Obj):$(Src)
gcc -c code.c
.PHONY:clean
clean:
rm -rf $(Bin) $(Obj)
(copy内容到makefile上的时候,记得检查缩进,可以观察代码高亮的颜色)
其中,我们需要用$()提取变量的内容。
我们也可以认为,类似Bin=code.exe的语句,就好像C语言里的宏,其中Bin就是宏名,code.exe就是宏值。我们要想改变文件的文件名,就可以直接修改宏值。
5.3、禁止回显
为了方便调试代码,我们可能会在编译和链接的时候添加一些提示语句:
bash
Bin=code.exe
Obj=code.o
Src=code.c
$(Bin):$(Obj)
echo "我要开始链接了..."
gcc -o $@ $^
$(Obj):$(Src)
echo "我要开始编译了..."
gcc -c code.c
.PHONY:clean
clean:
rm -rf $(Bin) $(Obj)
我们这样设计好提示语句后,执行make,结果却有点冗余:

echo语句都打印上来了。有没有办法不要回显 ------ 依赖方法前加上@:
bash
Bin=code.exe
Obj=code.o
Src=code.c
$(Bin):$(Obj)
@echo "我要开始链接了..."
gcc -o $@ $^
$(Obj):$(Src)
@echo "我要开始编译了..."
gcc -c code.c
.PHONY:clean
clean:
rm -rf $(Bin) $(Obj)
实际上,依赖方法语句本身默认会回显在屏幕上。我们在每一个依赖方法后加@,都可以禁止其回显。
5.4、进一步优化
我们可以建立一个debug目标,以简单演示工程调试的过程,我们之后会用到:
bash
.PHONY:debug
debug:
@echo "Bin: $(Bin)"
@echo "Obj: $(Obj)"
@echo "Src: $(Src)"

由此我们可以发现:$()不仅可以在""外面提取宏值,还可以在""里面提取宏值。
工具的变量化
工具也可以变量化:
bash
Bin=code.exe
Obj=code.o
Src=code.c
Echo=echo # 工具变量化
CC=gcc
$(Bin):$(Obj)
@$(Echo) "我要开始链接了..." # $()提取
$(CC) -o $@ $^
$(Obj):$(Src)
@$(Echo) "我要开始编译了..."
$(CC) -c code.c
.PHONY:clean
clean:
rm -rf $(Bin) $(Obj)
选项的变量化
选项也可以变量化:
bash
Bin=code.exe
Obj=code.o
Src=code.c
Echo=echo
CC=gcc
Flags=-c -Wall
LD_Flags=-o
$(Bin):$(Obj)
@$(Echo) "我要开始链接了..."
$(CC) $(LD_Flags) $@ $^
$(Obj):$(Src)
@$(Echo) "我要开始编译了..."
$(CC) $(Flags) code.c
.PHONY:clean
clean:
rm -rf $(Bin) $(Obj)
当然,选项和工具,可以一起打包被变量化:
bash
RM=rm -rf
.PHONY:clean
clean:
$(RM) $(Bin) $(Obj)
特殊变量<,与%的使用
对于
bash
$(Obj):$(Src) # Obj: code.o Src: code.c
$(CC) $(Flags) code.c
我们也可以写成:
bash
%.o:%.c
$(CC) $(Flags) $<
对于之前的^,我们可以理解为:
- 依赖文件列表中,所有依赖文件一起,经过依赖方法形成目标文件。
而对于<,我们可以这样理解:
- 依赖文件列表中,每一个依赖文件,都单独经过依赖方法形成各自对应的目标文件。

至于%.o:%.c,我们可以类比通配符*,即%.c代表了所有的.c文件,%.o代表了所有将要形成的.o文件。
如何证明:每一个依赖文件,都单独经过依赖方法?
证明
这时我们就需要多文件。如下图,当我们添加好了code1.c ~ code1000.c这么1000个文件:

设置源文件变量Src,我们难道要将code.c ~ code1000.c这么1001个文件一个一个输进去吗?太麻烦了。
有两种做法:
Src=$(shell ls *.c)Src=$(wildcard *.c)。我们推荐这种方法。
所有的源文件都要分别翻译成.o文件,那么我们也要一个一个输入?也不是的。
我们可以执行:Obj=(Src:.c=.o)
执行make debug,我们就能观察到,多个.c文件与多个.o文件都有了。
至此,我们就完成了一个当前目录下多文件编译的makefile工程。
bash
Bin=code.exe
Obj=$(Src:.c=.o)
#Src=$(shell ls *.c)
Src=$(wildcard *.c)
Echo=echo
CC=gcc
Flags=-c -Wall
LD_Flags=-o
RM=rm -f
$(Bin):$(Obj)
@$(Echo) "我要开始链接了... $(Obj) ---> $(Bin)"
$(CC) $(LD_Flags) $@ $^
%.o:%.c
@$(Echo) "我要开始编译了... $< ---> $@"
$(CC) $(Flags) $<
.PHONY:clean
clean:
$(RM) $(Bin) $(Obj)
.PHONY:debug
debug:
@echo "Bin: $(Bin)"
@echo "Obj: $(Obj)"
@echo "Src: $(Src)"
我们执行make,也确实会看到每一个.c文件都是分别翻译为.o文件的。
编译有时间成本
我们写好上面的makefile,执行make的时候发现,多文件.c编译成.o的过程花了不少时间。所以随着文件越多越大,编译的时间是越长的。
所以,多个.c文件分开翻译成.o文件,并且要是修改了某几个文件,make对修改了的文件重新编译,而对没修改的文件不编译,再重新生成可执行文件,是能提高工程效率的。(你可以试试只修改两个文件,再重新make,就会发现编译非常快了)