一、makefile 的来龙去脉
1. 为什么要有 makefile
我们在学习 Linux 的时候,一般都是直接通过 gcc 对源文件进行编译的,我们可以通过指定 gcc 的参数来指定生成什么样的文件、使用哪个库、在哪个路径搜索等等。但是想象一下,加入现在我们的项目工程中包含上百个源文件,并且不同的源文件包含了不同的库(动态库、静态库、标准库等),又甚至不同的非标准库存放在不同的目录,或者有的源文件要用到多线程。这样的话,如果像初学时一样,在 shell 下使用 gcc 命令变异的话,要使用无数的参数去指定不同路径,不同的链接库等等。这么繁琐复杂的命令显然是不太合理的,并且每次编译都要来这么一遍大大消耗了时间成本。那么这就是使用 makefile 的第一个原因,通过 makefile 可以制定好相应的编译与连接规则,先编译哪个文件后编译哪个文件、哪个需要编译哪个不需要编译、如何链接、如何生成、要生成什么文件等等全部都在 makefile 文件中提前制定好,在编译的时候只需要使用 make 工具,执行 make 命令就可以了。
另外,使用 makefile 的第二个原因是,我们在项目开发中难免会对源码进行修修改改,如果每次修改都要重新编译所有的源文件,那么将浪费大量的时间,我们可以在 makefile 中制定规则,只去编译被修改的源文件,其他文件不需要重新编译,就像我们使用 MDK 集成开发工具的时候,编译选项分为全部编译和只编译修改的文件。总之,有了 makefile 大型项目的编译效率将大大提高。
2. 什么是 makefile
make 是一个命令工具,它负责解释 makefile 中的指令,而 makefile 文件负责向 make 提供如何去执行的规则。在 makefile 文件中描述了整个工程所有文件的编译顺序、编译规则等。makefile 有自己的书写格式、关键字、函数,就像任何一门编程语言有自己的语法一样。而且在 makefile 中可以使用 shell 的命令来完成某些工作,也就是说 makefile 中可以使用 shell 命令,比如说,编译完成后删除所有的中间文件,可以使用 rm -f *.o 这样的 shell 命令。makefile 在绝大多数的集成开发环境中也都在使用的,只不过我们看不到而已,可以说,makefile 几乎已经成为一种工程编译的基本方法。
二、makefile 是怎么工作的
1. makefile 的三要素及命名
首先,makefile 中有三要素:目标(要生成什么)、依赖(用什么去生成)、和命令(如何去生成),这三个要素组成一个规则。实际上,三要素中必不可少的是目标,依赖和命令都可以没有,但是命令必须要有,这一点在后面的实战编写 makefile 的时候会有体现。
下面举个最简单的 makefile 的例子,直接在 shell 下输入 vim makefile ,然后输入 目标 : 依赖 ,换行 Tab+shell命令 即可,注意,命令前一定要加 Tab 键,然后再加一条 shell 命令。这是因为,makefile 中所有以 Tab 开始的行,make 都会交给 shell 去处理,所以在命令的前面一定要以 Tab 键开头。
在这里,目标是可执行文件 exe ,也就是要生成的文件,依赖是所有的 .c 文件,也就是用这些 .c 文件去生成可执行文件 exe ,生成所使用的命令是下面的 gcc 命令。
当我们执行 make 的时候,会显示 make 执行的命令
对于makefile 的命名,可以是 Makefile 也可以是 makefile 或者 GNUmakefile,三种方法都可以,但也只能是这三种。这样在 shell 中执行 make 命令就会直接使用这个 makefile 文件。当然,如果你取了其他名字也是可以的,不过要在 make 命令的时候显示指出文件
make -f makefile01
2. makefile 的工作原理
在执行 make 命令时,首先,make 会先去比较目标文件和依赖文件的修改日期,如果依赖文件的日期要比目标文件的日期新,或者目标文件不存在,那么 make 就会执行后面的命令。假如说在目标的后面没有依赖,比如我们经常用伪目标 clean 去清除中间文件,当 make 发现先冒号后面没有依赖的时候,它默认是不会执行后面的命令的,除非在 make 后面显示的指出这个目标的名字,这也就是我们经常使用的 make clean 命令。
总结来说,makefile 的工作原理可以理解为它是根据依赖去递推的。
① 执行 make 时,首先 make 工具会在当前目录查找名为 makefile 或 Makefile 的文件,如果我们在 make 命令后面指定了文件名,make 就在当前目录查找制定好的文件名。
② 如果找到了 makefile 文件,那么会先查找文件中的第一个目标,如果目标的依赖存在,并且依赖文件的更新时间比目标文件的更新时间新,那么就执行后面的命令重新生成目标文件;如果目标文件不存在,则生成目标文件;如果依赖不存在,那么不执行后面的命令。
③ 如果上一个目标文件的依赖存在,那么 make 会递推查找依赖文件的依赖,然后重复上面的操作。
所以说,makefile 是根据依赖一层一层递推的,不停的去递推寻找依赖。make 只负责在 makefile 中递推寻找依赖,并根据依赖执行命令,而不关心编译是否成功,只要最终的依赖可以找到,就能执行成功,如果最终的依赖没找到,那么 make 就会直接退出。而对于伪目标的执行,可以直接在 make 后面指定目标,这样即使目标后面没有依赖,也会执行命令。而正是 make 的这种依赖递推查找特性,以及根据更新时间决定是否生成的特性,我们可以把依赖分解,这样就能做到某单个源文件修改可以只编译这一个源文件,而不必所有源文件都重新编译。
我们可以验证一下这种根据时间去决定是否生成的特性,我们首先 make 一下,然后再次 make ,会看到提示“目标文件是最新的”
这时候我们修改随便一个文件的更新时间,就可以再次编译(touch 命令可以修改文件的最后访问时间)
三、makefile 世界的“规则”
1. makefile 中都有哪些规则
(1)makefile 的五大部分
① 显示规则
要生成的目标文件,依赖文件,生成目标文件要使用的命令。这些内容说明了要生成什么,用什么去生成,如何生成,其实就是包含了三要素。显示规则有 makefile 编写者显示写出。
② 隐含规则
也就是依赖 makefile 中的自动推导功能,来自动推导出我们模糊表达的语句,比如根据文件后缀推导文件名等等。 makefile 的自动推导功能是非常强大的,比如说我们定义变量 Src = 1.c 2.c 3.c ,假如说在生成 2.o 目标的时候,我们直接在 gcc 命令中使用变量 Src,它也可以推导出源文件为 2.c。
Src = 1.c 2.c 3.c obj:$(Src) gcc $(Src) -o obj
③ 注释
与 shell 一样,使用 # 作为注释符。也就是说,如果某一行的第一个非空字符是 # 那么就认为这一行是注释行。另外,在 makefile 中,反斜杠 \ 表示换行,所以如果注释行末尾有 \ 则代表下一行也是注释。如果想要使用 # 符号,可以使用转义 \ # ,类似于c语言中的转义符。
④ 变量定义
我们可以定义变量,比如说我们可以把所有的 .c 文件定义为一个 Src 变量,这样在编译命令中就可以用 Src 变量来代替所有的 .c 文件。
⑤ 指令
make 在读取 makefile 文件时执行某些特殊操作的指令,包括三个部分:一是在一个 makefile 中引用另一个 makefile,类似于c语言中的 #include ;二是指根据条件指定 makefile 中的有效部分,类似于c语言中的条件编译 #if ;三是定义一个多行的命令,通过 define 和 endef 关键字实现,类似于c语言中的 #define。
(2)makefile 的书写规则
最基本的就是按照三要素去写:目标、依赖和命令。
目标 : 依赖
Tab 命令
目标和命令都可以使用通配符、变量、函数去代替,命令是一条 shell 命令。目标可以是伪目标,依赖和命令可以没有,但是目标必须要有。
2. makefile 的字符匹配和文件搜索
(1)字符匹配
① 通配符
字符匹配首先想到的就是通配符,因为 makefile 中使用的是 shell 中的命令,所以 shell 中的通配符在 makefile 中也适用。我们使用的通配符主要有两个:
- * :匹配任意个字符
- ? :匹配一个字符
比如说,依赖是所有的 .c 文件,就可以用通配符来表示 *.c,但是如果我们在定义变量的时候要使用通配符的话,要注意一点,如果我们直接把 *.c 等号给变量的话,这个变量会默认去匹配文件名为 *.c 的文件
Src = *.c Src变量表示 *.c 文件
要想使变量 Src 表示所有源文件,也就是让 * 作为通配符而不是文件名,需要借助一个函数 wildcard ,该函数就是表示通配符的意思,具体使用将在后面的函数章节介绍。
Src=$(wildcard *.c) Src变量表示 *.c 文件
还有一个通配符 [ ] 并不常用,在中括号中可以指定匹配的字符。比如说,[a-z] 表示匹配 a 到 z 中任何一个字符。
② 模式匹配字符 %
第二种用于字符匹配的是 % , % 字符作用类似于通配符 * ,它和 * 的区别是,模式匹配字符可以对目标文件与依赖文件进行匹配。比如说我们在写 makefile 的时候,经常会写这样的一条规则
%.o:%.c
这里的 % 代表的是一个文件名,也就是一个字符串。首先,所有的 .o 文件会组成一个列表,然后挨个被拿出来,% 表示当前拿出来的 %.o 文件的文件名,然后根据文件名 % 来寻找和 .o 文件同名的 %.c 文件,并把取出的 %.o 文件和寻找到的 %.c 文件用于执行后面的命令。这是 makefile 中自动匹配的一种规则。
(2)文件搜索
默认情况下,make 会在 makefile 文件所在目录进行搜索规则中所用到的文件,如果我们把所有的文件都和 makefile 文件放在同一个目录下,那肯定是没有问题的,但是世家开发中,我们用到的源文件、头文件、库文件可能会根据用途和种类分别位于不同的目录下,所以这就需要有文件搜索的功能。makefile 中文件搜搜主要有两种方法,一个是环境变量 VPATH 一个是关键字 vpath 。
① VPATH 环境变量
环境变量的用法如下
VPATH:=/mkdir1/:/mkdir2/
当使用环境变量指定上面的路径后,make 会现在当前目录搜索,然后去目录 /mkdir1/ 搜索,然后再去 /mkdir2/ 搜索,搜索的顺序是先当前目录,然后按照变量赋值中的顺序去搜索。这里的 := 是变量赋值的一种方式,表示在定义时立即展开应用的变量。另外,不同的目录之间要用 : 或者空格隔开。
附:变量赋值的几种方式(后面详细介绍)
- := 简单赋值
- = 递归赋值
- ? = 条件赋值
- += 追加赋值
② vpath 关键字
在上面的环境变量中,VPATH 是搜索指定路径的所有文件, vpath 关键字的搜索方式是选择性搜索,使用方法如下:
vpath 1.c /mkdir/ 在 /mkdir/ 路径下搜索 1.c vpath 1.c 清除 1.c 的搜索路径 vpath 清除已设置好的所有搜索路径
3. makefile 的变量
(1)变量的基本语法
① 变量的赋值
makefile 中的变量类似于c语言中的宏定义,在执行的时候会用变量后面的值去替换变量所在的位置。变量的赋值就是在变量后面写上值文本字符串,在使用时直接用后面的文本字符串去替换变量本身。变量的赋值方式有四种,下面将通过一个例子来介绍四种赋值方式的区别
- := 简单赋值,是一种最普通的赋值,立即替换,也就是说在变量赋值的时候,立即把变量展开为后面的值,或者说当前的赋值只对当前的语句有效,和后面对该变量的赋值无影响,听起来比较费劲,我们可以看一下效果以及和其他几种赋值方式的对比来理解。
可以看下效果,他的逻辑就是从前往后,和我们在c语言中用的普通 = 赋值是一样的。
可以通过 @ 来屏蔽执行过程( @可以不显示命令,不输出在终端) - = 递归赋值,定义时并不真正赋值,在实际使用时才会进行展开,看下效果吧
执行后发现,B 中的 A 被展开为 AAA ,而不是 aaa 。 - ? = 条件赋值,如果变量是第一次赋值,则赋值生效,否则赋值无效。
在打印结果中,A 还是第一次赋值时的值 - += 追加赋值,在变量后面追加一个值,用空格与前面的值分隔开
可以看到,A 和 B 的值都会受影响
② 变量的使用
变量在使用的时候要在前面加一个 $ 符号,并使用 () 或 {} 把变量括起来,实际上变量的使用就是一个替换的原理,用括号括起来是为了使用的安全性。
③ 变量的替换
我们可以对变量的值进行替换,主要有如下两种方法
(2)自动化变量与模式变量
① 自动化变量
自动化变量是指 makefile 根据模式规则自动推导的变量,这类变量只能在命令中使用。实际上,自动化变量属于“规则型变量”,这种变量的值依赖于规则的目标和依赖目标的定义。下面是常用的自动化变量列表
自动化变量 | 说明 |
$@ | 代表目标文件,在模式规则中, $@ 就是目标中模式定义的相匹配的目标文件集合 |
$< | 第一个依赖文件,如果依赖是以模式 % 定义的,那么 $< 代表符合模式的一系列的文件集,在生成目标时,一个个的取出来去执行命令 |
$^ | 所有依赖文件(无重复文件),用空格分隔并且会自动去重 |
$? | 比目标更新的依赖文件(集合),也就是发生变化的依赖文件的集合 |
其他自动化变量列表
自动化变量 | 说明 |
$% | 当目标文件是一个静态库文件时起作用,代表静态库的一个成员名,比如目标是 1.a 那么 $% 表示 1.o, $@ 表示 1.a |
$+ | 类似“$^”,但是它保留了依赖文件中重复出现的文件(主要用在程序链接时库的交叉引用场合),也就是说他也代表所有依赖文件,但是不会去除重复文件 |
$* | 在模式规则和静态模式规则中,代表茎,茎是目标模式中 % 所代表的部分 |
$(@D) | 表示文件的目录部分(不以斜杠结尾),如果 $@ 表示的是 dir/1.c 那么 $(@D) 表示的值就是目录 dir |
$(@F) | 表示的是文件除目录外的部分即文件名,如果 $@ 表示的是 dir/1.c,那么 $@F 表示的是 1.c |
$(*D) $(*F) | 分别代表茎中的目录部分和文件名部分 |
$(%D) $(%F) | 当目标是静态库文件时,分别表示库文件成员中的目录部分和文件名部分 |
$(<D) $(<F) | 分别表示第一个依赖文件的目录部分和文件名部分 |
$(^D) $(^F) | 分别表示所有依赖文件的目录部分和文件部分(无重复文件) |
$(+D) $(+F) | 分别表示所有的依赖文件的目录部分和文件部分(保留了依赖文件中重复出现的文件) |
$(?D) $(?F) | 分别表示更新的依赖文件的目录部分和文件名部分 |
② 模式变量
模式变量(Pattern-specific Variable),它可以实现给定一种模式,可以把变量定义在符合这种模式的所有目标上。模式变量中至少包含一个模式匹配字符 % 。
4. makefile 的函数
makefile 也支持函数,可以通过函数来控制变量,函数的使用和变量类似,需要 $() 或 ${} 来标识,如果函数有参数的话直接在函数后面列出参数, 参数之间用 , 隔开,比如 $(func arg1, arg2)。下面根据用途分类来介绍 makefile 中的函数。
(1)字符串处理函数
① 模式字符替换函数 patsubst
- 函数原型
$(patsubst <pattern>,<replacement>,<text>)
- 函数功能:查找 text 中的单词(单词以空格、Tab 或回车换行分隔)是否符合模式 pattern,如果匹配的话,则用 replacement 替换。pattern 可以包括通配符 % ,表示任意长度的字符串。如果 replacement 中也包含 % ,那么, replacement 中的这个 % 将是 pattern 中的那个 % 所代表的字符串。
- 函数返回:返回值为替换后的新字符串。
- 用法示例
② 字符串替换函数 subst
- 函数原型
$(subst <from>,<to>,<text>)
- 函数功能:把字符串中的 form 替换成 to。
- 函数返回:返回值为替换后的新字符串。
- 用法示例
③ 去空格函数 strip
- 函数原型
$(strip <string>)
- 函数功能:去掉字符串的开头和结尾的空字符串,并且将字符串中的多个连续空格合并成为一个空格。(第一个字符之前和最后一个字符之后的空格去除,字符串内部连续多个空格合并为一个,字符串内部单个空格不处理)
- 函数返回:去空格后的字符串。
- 用法示例
④ 查找字符串函数 findstring
- 函数原型
$(findstring <find>,<in>)
- 函数功能:查找 in 中的 find 字符串。
- 函数返回:如果要查找的目标字符串存在,返回目标字符串 find,如果不存在就返回空字符串。
- 用法示例
⑤ 过滤函数 filter
- 函数原型
$(filter <pattern>,<text>)
- 函数功能:过滤出 text 中符合模式 pattern 的字符串,可以有多个 pattern ,保留符合模式 pattern 的字符串。
- 函数返回:返回过滤后的字符串,即符合模式 pattern 的字符串。
- 用法示例
⑥ 反过滤函数 filter-out
- 函数原型
$(filter-out <pattern>,<text>)
- 函数功能:功能和 filter 函数相反,去除 text 字符串中符合模式 pattern 的字符串,可以有多个模式。
- 函数返回:不符合符合模式 pattern 的字符串。
- 用法示例
⑦ 排序函数 sort
- 函数原型
$(sort <list>)
- 函数功能:函数的功能是将 list 中的单词升序排序,并且 sort 会去除重复的字符串。
- 函数返回:排列后的字符串。
- 用法示例
⑧ 取单词函数 word
- 函数原型
$(word <n>,<text>)
- 函数功能:取字符串 text 中第 n 个单词。
- 函数返回:返回字符串 text 中第 n 个单词,如果 n 大于 text 中的单词数则返回空字符串。
- 用法示例
⑨ 单词个数统计函数 words
- 函数原型
$(words <text>)
- 函数功能:统计 text 字符串中的单词个数。
- 函数返回:返回 text 字符串中的单词个数。
- 用法示例
⑩ 取首单词函数 firstword
- 函数原型
$(firstword <text>)
- 函数功能:取字符串 text 中的第一个单词。
- 函数返回:返回字符串 text 中的第一个单词。
- 用法示例
(2)文件名操作函数
① 取目录函数 dir
- 函数原型
$(dir <names>)
- 函数功能:从文件名序列 names 中取出目录部分,即最后一个反斜杠 / 之前的部分,如果 names 中没有 / ,则取出的值为 ./ 也就是当前目录的意思。
- 函数返回:返回值为目录部分,即最后一个反斜杠之前的部分,如果没有反斜杠 / ,则返回当前目录 ./ 。
- 用法示例
② 取文件函数 notdir
- 函数原型
$(notdir <names>)
- 函数功能:从文件名序列 names 中取出非目录部分,非目录部分是指最后一个反斜杠 / 之后的部分。
- 函数返回:names 中非目录部分。
- 用法示例
③ 取后缀名函数 suffix
- 函数原型
$(suffix <names>)
- 函数功能:从文件名序列中 names 中取出各个文件的后缀名。
- 函数返回:返回值为文件名序列 names 中的后缀序列,如果文件没有后缀名,则返回空字符串。
- 用法示例
④ 取前缀函数 basename
- 函数原型
$(basename <names>)
- 函数功能:从文件名序列 names 中取出各个文件名的前缀部分。
- 函数返回:返回值为被取出来的文件的前缀名,如果文件没有前缀名则返回空的字符串。
- 用法示例
⑤ 添加后缀名函数 addsuffix
- 函数原型
$(addsuffix <suffix>,<names>)
- 函数功能:把后缀 suffix 加到 names 中的每个单词后面。
- 函数返回:返回值为添加上后缀的文件名序列。
- 用法示例
⑥ 添加前缀名函数 addprefix
- 函数原型
$(addprefix <prefix>,<names>)
- 函数功能:把前缀 prefix 加到 names 中的每个单词的前面。
- 函数返回:返回值为添加上前缀的文件名序列。
- 用法示例
⑦ 连接函数 join
- 函数原型
$(join <list1>,<list2>)
- 函数功能:把 list2 中的单词对应的连接接到 list1 的后面。如果 list1 的单词要比 list2 的多,那么,list1 中多出来的单词将保持原样,如果 list1 中的单词要比 list2 中的单词少,那么 list2 中多出来的单词将保持原样。
- 函数返回:拼接好的字符串。
- 用法示例
⑧ 获取匹配模式文件名函数 wildcard
- 函数原型
$(wildcard PATTERN)
- 函数功能:列出当前目录下所有符合模式的 PATTERN 的文件名。(该函数通常与通配符 * 搭配使用)
- 函数返回:返回值当前目录下的所有符合模式 PATTERN 的文件名,文件名之间用空格分隔。
- 用法示例