深度刨析makefile

2024-08-08 17:02:31 浏览数 (2)

前言

我们在 Windows 下开发,或者初学 Linux 的时候,可能几乎没有接触过 makefile ,甚至都不知道 makefile 是什么,这是因为在 Windows 下各种各样的集成开发工具,已经内置做好了 makefile 的工作,而初学 Linux 时编译一两个源文件似乎也用不到 makefile ,只要 gcc 一下就好了。而实际上,在 Linux 下的大型项目开发中,必须要用到 makefile ,会写 makefile 是Linux/Unix 程序员的必备技能之一,而且即便是 WIndows 程序员也应该掌握 makefile ,makefile 的编写能力一定程度上反映了一个程序员处理大型工程的能力。接下来,本文将带你走进 makefile 的世界,一起来探索 makefile 的语法规则以及工作原理,并通过实战演练来编写我们自己的 makefile 文件。

一、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 命令的时候显示指出文件

代码语言:javascript复制
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。

代码语言:javascript复制
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 的文件

代码语言:javascript复制
Src = *.c    Src变量表示 *.c 文件

要想使变量 Src 表示所有源文件,也就是让 * 作为通配符而不是文件名,需要借助一个函数 wildcard ,该函数就是表示通配符的意思,具体使用将在后面的函数章节介绍。

代码语言:javascript复制
Src=$(wildcard *.c)   Src变量表示 *.c 文件

还有一个通配符 [ ] 并不常用,在中括号中可以指定匹配的字符。比如说,[a-z] 表示匹配 a 到 z 中任何一个字符。 ② 模式匹配字符 % 第二种用于字符匹配的是 % , % 字符作用类似于通配符 * ,它和 * 的区别是,模式匹配字符可以对目标文件与依赖文件进行匹配。比如说我们在写 makefile 的时候,经常会写这样的一条规则

代码语言:javascript复制
%.o:%.c

这里的 % 代表的是一个文件名,也就是一个字符串。首先,所有的 .o 文件会组成一个列表,然后挨个被拿出来,% 表示当前拿出来的 %.o 文件的文件名,然后根据文件名 % 来寻找和 .o 文件同名的 %.c 文件,并把取出的 %.o 文件和寻找到的 %.c 文件用于执行后面的命令。这是 makefile 中自动匹配的一种规则。

(2)文件搜索

默认情况下,make 会在 makefile 文件所在目录进行搜索规则中所用到的文件,如果我们把所有的文件都和 makefile 文件放在同一个目录下,那肯定是没有问题的,但是世家开发中,我们用到的源文件、头文件、库文件可能会根据用途和种类分别位于不同的目录下,所以这就需要有文件搜索的功能。makefile 中文件搜搜主要有两种方法,一个是环境变量 VPATH 一个是关键字 vpath 。 ① VPATH 环境变量 环境变量的用法如下

代码语言:javascript复制
VPATH:=/mkdir1/:/mkdir2/

当使用环境变量指定上面的路径后,make 会现在当前目录搜索,然后去目录 /mkdir1/ 搜索,然后再去 /mkdir2/ 搜索,搜索的顺序是先当前目录,然后按照变量赋值中的顺序去搜索。这里的 := 是变量赋值的一种方式,表示在定义时立即展开应用的变量。另外,不同的目录之间要用 : 或者空格隔开。 附:变量赋值的几种方式(后面详细介绍)

  • := 简单赋值
  • = 递归赋值
  • ? = 条件赋值
  • = 追加赋值

② vpath 关键字 在上面的环境变量中,VPATH 是搜索指定路径的所有文件, vpath 关键字的搜索方式是选择性搜索,使用方法如下:

代码语言:javascript复制
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 也支持函数,可以通过函数来控制变量,函数的使用和变量类似,需要 () 或 {} 来标识,如果函数有参数的话直接在函数后面列出参数, 参数之间用 , 隔开,比如

(1)字符串处理函数

① 模式字符替换函数 patsubst
  • 函数原型
代码语言:javascript复制
$(patsubst <pattern>,<replacement>,<text>)
  • 函数功能:查找 text 中的单词(单词以空格、Tab 或回车换行分隔)是否符合模式 pattern,如果匹配的话,则用 replacement 替换。pattern 可以包括通配符 % ,表示任意长度的字符串。如果 replacement 中也包含 % ,那么, replacement 中的这个 % 将是 pattern 中的那个 % 所代表的字符串。
  • 函数返回:返回值为替换后的新字符串。
  • 用法示例
② 字符串替换函数 subst
  • 函数原型
代码语言:javascript复制
$(subst <from>,<to>,<text>)
  • 函数功能:把字符串中的 form 替换成 to。
  • 函数返回:返回值为替换后的新字符串。
  • 用法示例
③ 去空格函数 strip
  • 函数原型
代码语言:javascript复制
$(strip <string>)
  • 函数功能:去掉字符串的开头和结尾的空字符串,并且将字符串中的多个连续空格合并成为一个空格。(第一个字符之前和最后一个字符之后的空格去除,字符串内部连续多个空格合并为一个,字符串内部单个空格不处理)
  • 函数返回:去空格后的字符串。
  • 用法示例
④ 查找字符串函数 findstring
  • 函数原型
代码语言:javascript复制
$(findstring <find>,<in>)
  • 函数功能:查找 in 中的 find 字符串。
  • 函数返回:如果要查找的目标字符串存在,返回目标字符串 find,如果不存在就返回空字符串。
  • 用法示例
⑤ 过滤函数 filter
  • 函数原型
代码语言:javascript复制
$(filter <pattern>,<text>)
  • 函数功能:过滤出 text 中符合模式 pattern 的字符串,可以有多个 pattern ,保留符合模式 pattern 的字符串。
  • 函数返回:返回过滤后的字符串,即符合模式 pattern 的字符串。
  • 用法示例
⑥ 反过滤函数 filter-out
  • 函数原型
代码语言:javascript复制
$(filter-out <pattern>,<text>)
  • 函数功能:功能和 filter 函数相反,去除 text 字符串中符合模式 pattern 的字符串,可以有多个模式。
  • 函数返回:不符合符合模式 pattern 的字符串。
  • 用法示例
⑦ 排序函数 sort
  • 函数原型
代码语言:javascript复制
$(sort <list>)
  • 函数功能:函数的功能是将 list 中的单词升序排序,并且 sort 会去除重复的字符串。
  • 函数返回:排列后的字符串。
  • 用法示例
⑧ 取单词函数 word
  • 函数原型
代码语言:javascript复制
$(word <n>,<text>)
  • 函数功能:取字符串 text 中第 n 个单词。
  • 函数返回:返回字符串 text 中第 n 个单词,如果 n 大于 text 中的单词数则返回空字符串。
  • 用法示例
⑨ 单词个数统计函数 words
  • 函数原型
代码语言:javascript复制
$(words <text>)
  • 函数功能:统计 text 字符串中的单词个数。
  • 函数返回:返回 text 字符串中的单词个数。
  • 用法示例
⑩ 取首单词函数 firstword
  • 函数原型
代码语言:javascript复制
$(firstword <text>)
  • 函数功能:取字符串 text 中的第一个单词。
  • 函数返回:返回字符串 text 中的第一个单词。
  • 用法示例

(2)文件名操作函数

① 取目录函数 dir
  • 函数原型
代码语言:javascript复制
$(dir <names>)
  • 函数功能:从文件名序列 names 中取出目录部分,即最后一个反斜杠 / 之前的部分,如果 names 中没有 / ,则取出的值为 ./ 也就是当前目录的意思。
  • 函数返回:返回值为目录部分,即最后一个反斜杠之前的部分,如果没有反斜杠 / ,则返回当前目录 ./ 。
  • 用法示例
② 取文件函数 notdir
  • 函数原型
代码语言:javascript复制
$(notdir <names>)
  • 函数功能:从文件名序列 names 中取出非目录部分,非目录部分是指最后一个反斜杠 / 之后的部分。
  • 函数返回:names 中非目录部分。
  • 用法示例
③ 取后缀名函数 suffix
  • 函数原型
代码语言:javascript复制
$(suffix <names>)
  • 函数功能:从文件名序列中 names 中取出各个文件的后缀名。
  • 函数返回:返回值为文件名序列 names 中的后缀序列,如果文件没有后缀名,则返回空字符串。
  • 用法示例
④ 取前缀函数 basename
  • 函数原型
代码语言:javascript复制
$(basename <names>)
  • 函数功能:从文件名序列 names 中取出各个文件名的前缀部分。
  • 函数返回:返回值为被取出来的文件的前缀名,如果文件没有前缀名则返回空的字符串。
  • 用法示例
⑤ 添加后缀名函数 addsuffix
  • 函数原型
代码语言:javascript复制
$(addsuffix <suffix>,<names>)
  • 函数功能:把后缀 suffix 加到 names 中的每个单词后面。
  • 函数返回:返回值为添加上后缀的文件名序列。
  • 用法示例
⑥ 添加前缀名函数 addprefix
  • 函数原型
代码语言:javascript复制
$(addprefix <prefix>,<names>)
  • 函数功能:把前缀 prefix 加到 names 中的每个单词的前面。
  • 函数返回:返回值为添加上前缀的文件名序列。
  • 用法示例
⑦ 连接函数 join
  • 函数原型
代码语言:javascript复制
$(join <list1>,<list2>)
  • 函数功能:把 list2 中的单词对应的连接接到 list1 的后面。如果 list1 的单词要比 list2 的多,那么,list1 中多出来的单词将保持原样,如果 list1 中的单词要比 list2 中的单词少,那么 list2 中多出来的单词将保持原样。
  • 函数返回:拼接好的字符串。
  • 用法示例
⑧ 获取匹配模式文件名函数 wildcard
  • 函数原型
代码语言:javascript复制
$(wildcard PATTERN)
  • 函数功能:列出当前目录下所有符合模式的 PATTERN 的文件名。(该函数通常与通配符 * 搭配使用)
  • 函数返回:返回值当前目录下的所有符合模式 PATTERN 的文件名,文件名之间用空格分隔。
  • 用法示例

(3)逻辑判断函数

① foreach (遍历)
  • 函数原型
代码语言:javascript复制
$(foreach <var>,<list>,<text>)
  • 函数功能:把参数 list 中的单词逐一取出放到参数 var 所指定的变量中,然后再执行 text 所包含的表达式。每一次 text 会返回一个字符串,循环过程中, text 返回的每个字符串会以空格分割,最后当整个循环结束的时候, text 所返回的每个字符串组成的整个字符串(以空格分隔)将会是 foreach 函数的返回值。所以 var 最好是一个变量名, list 可以是一个表达式,而 text 中一般会只用 var 这个参数来一次枚举 list 中的单词。其实就是,把 list 中的值依次拿出来传给变量 var ,并依次使用 text 来处理已被赋值的 var ,每次得到的结果作为返回值序列的一个值。它有点类似于 STL 中的遍历算法 for_each ,挨个把 list 中的元素取出来赋给 var ,并执行 text ,并把每次的结果作为返回值序列的一个元素。(STL 中的 for_each 在我的专栏 C 与 STL 专栏,《for_each 源码刨析与函数对象本质刨析》一文中有详细介绍)
  • 函数返回:经过 foreach 算法处理后的字符串序列。
  • 用法示例
② if (条件判断)
  • 函数原型
代码语言:javascript复制
$(if <condition>,<then-part>)
$(if <condition>,<then-part>,<else-part>)
  • 函数功能:condition 是 if 的表达式,如果其返回的是非空的字符串,那么这个表达式返回真,执行 then-part ,否则执行 else-part 。if 函数可以包含else 部分,也可以不包含。其实,简单来说,这就类似于 c/c 中的 if 语句,满足 condition ,执行 then-part ,不满足 condition 则执行 else-part ,如果没有 else-part 分支,则不执行任何操作。
  • 函数返回:如果 condition 为真(非空字符串),那么 then-part 会是整个函数的返回值;如果 condition 为假(空字符串),那么 else-part 将会是这个函数的返回值;此时如果 else-part 没有被定义,那么整个函数返回空字串符。
  • 用法示例

(4)其他函数

① 表达式传参函数 call
  • 函数原型
代码语言:javascript复制
$(call <expression>,<parm1>,<parm2>,<parm3>,...)
  • 函数功能:依次使用 parm1,parm2,parm3,…, 来替代 expression 表达式中的参数1,参数2,参数3,… 。注意,在参数传递的时候,是有顺序的,并且顺序可以指定。也就是说,我们可以自定义一个含有任意个参数的表达式 expression ,然后我们可以使用 call 函数来为这个表达式传递参数。我们可以联想 c 语言中的 main 函数的参数列表 argc 和 argv[] ,在执行可执行文件 exe 的时候,我们可以 exe parm1 parm2 … 这样来为 main 的参数列表 argv[] 传值,这一块在我的 Linux 专栏《深入浅出 GDB 调试器》一文中有详细介绍。
  • 函数返回:返回表达式 expression 的返回值。
  • 用法示例
② 变量来源 origin
  • 函数原型
代码语言:javascript复制
$(origin <variable>)
  • 函数功能:告诉我们变量的来源。variable 是变量的名字,而不是引用,所以最好不要在 variable 中使用 $ 字符,origin 函数会用返回值来告诉我们这个变量的来源。比如我们有一个变量和环境变量同名,我们可以用这个函数判断当前的这个变量来源于环境还是来源于用户。
  • 函数返回:

返回值

含义

undefined

如果 variable 从来没有定义过,函数将返回这个值。

default

如果 variable 是一个默认的定义,比如说 CC 这个变量

environment

如果 variable 是一个环境变量并且当Makefile被执行的时候, -e 参数没有被打开。

file

如果 variable 这个变量被定义在 makefile 中,将会返回这个值。

command line

如果 variable 这个变量是被命令执行的,将会被返回。

override

如果 variable 是被 override 指示符重新定义的。

automatic

如果 variable 是一个命令运行中的自动化变量。

  • 用法示例
代码语言:javascript复制
ifdef bletch
ifeq "$(origin bletch)" "environment"
bletch = barf,gag,etc
endif
endif

③ shell shell 函数以 Linux 的 shell 命令为函数参数,并把执行 shell 命令后的输出作为函数返回值。 ④ make 控制函数

代码语言:javascript复制
$(error <text ...>)		产生一个致命的错误,<text ...>是错误信息。
$(warning <text ...>)	这个函数很像error函数,只是它并不会让make退出,只是输出一段警告信息,而make继续执行。

5. makefile 的伪目标

(1)什么是伪目标

有时候,我们并非真正的想要生成一个目标,而是想让 makefile 执行这个目标后面的命令,这时候我们可以通过伪目标来实现。最常见的使用伪目标的例子就是 make clean ,我们执行 make clean 可以把所有的目标文件删除

代码语言:javascript复制
.PHONY:clean all
clean:
	-@rm -f *.o
	-@rm exe

在这条规则中,clean 是一个伪目标,它没有依赖,我们也不需要去生成这个目标。在 clean 后面的命令并不是创建目标 clean 的命令,而是一条删除命令,用于删除所有的目标文件。我们在 shell 命令行执行

代码语言:javascript复制
make clean

就可以执行上面的删除语句,删除所有 .o 文件和终极目标 exe 可执行文件。这里的 .PHONY 是声明伪目标的意思。假如说我们不声明伪目标

代码语言:javascript复制
clean:
	-@rm -f *.o
	-@rm exe

直接通过上面的规则,也就是说 clean 是一个真正的目标,如果当前目录下存在一个名为 clean 的文件,当我们在 shell 中执行命令 make clean,由于这个规则没有依赖文件,所以目标 clean 被认为是最新的,所以不再去执行规则所定义的命令,也就是说 rm 命令将不会被执行。那么我们的目的也就无法达到了。声明为伪目标就可以解决这个问题,其实就是把 clean 作为特殊目标 .PHONY 的依赖,这样就保证了不管当前是否有 clean 同名文件,伪目标后面的命令都可以执行,并且 make 不会去推导构建伪目标的隐含规则,这也提高了编译效率。 伪目标还有两个用途,就是递归调用 makefile 和实现多文件编辑,这里不再详细介绍。下面列出 makefile 常用的一些伪目标以及他们的含义。

(2)GNU 编译、安装、打包相关的伪目标

下面列出的这些伪目标都是 GNU 的一些定义,我们在定义实现下面功能的伪目标时,应尽量使用下面列出的伪目标名称。在大型工程中,这些伪目标是非常有用的,并且它们类似于一种约定俗成的东西,用起来会更加统一。实际上,通过 make 指定伪目标为最终目标,在 make 中是非常常见的,比如 make clean,这在后面会有详细介绍。

伪目标

含义

all

这个伪目标是所有目标的目标,它的功能一般是编译所有的目标。

clean

这个伪目标功能是删除所有被 make 创建的文件。

install

这个伪目标功能是安装已编译好的程序,其实就是把目标执行文件拷贝到指定的目标中去。

print

这个伪目标的功能是例出改变过的源文件。

tar

这个伪目标功能是把源程序打包备份,也就是一个 tar 文件。

dist

这个伪目标功能是创建一个压缩文件,一般是把 tar 文件压成 gz 文件。

TAGS

这个伪目标功能是更新所有的目标,以备完整地重新编译使用。

check 和 test

这两个伪目标一般用来测试 makefile 的流程。

6. makefile 的隐含规则

(1)隐含规则的工作原理

隐含规则可以理解为一种惯例,它是一种早就约定好的规则,不需要再显示的写出来,makefile 会自动进行推导这种规则。隐含规则会使用系统变量,我们可以通过系统变量来改变隐含规则运行时的参数,比如系统变量 CFLAGS 可以控制编译器的参数等。另外,我们还可以通过模式规则来写自己的隐含规则或使用后缀规则来保证兼容性。说白了,隐含规则就是我们只写出目标(目标是必不可少的),由 make 自动推导生成目标的依赖和命令,当然,如果我们手动去显示写了命令,make 会执行我们显示写的命令。举例说明:

我们在 makefile 中只写了可执行文件(最终目标)以及依赖 .o 文件,以及通过 .o 文件生成可执行文件的命令,我们没有写 .o 文件的生成规则,并且文件夹中没有 .o 文件

这时,我们执行一下 make

可以看到,执行 make 之后,编译器自动执行了一条命令

代码语言:javascript复制
cc -c -o test.o test.c

这条命令在 makefile 中,我们并没有显示写出。这就是 makefile 中的隐含规则,他会自动推导出生成 .o 文件的命令。但是有一点要注意,隐含条件只能省略中间目标文件重建的命令和规则,但是最终目标的命令和规则不能省略。 实际上,make 中存在一个隐含规则库,这个隐含规则库中的每一条隐含规则都有相应的优先级顺序,优先级也就会越高(顺序在前的优先级高),也就会被优先使用。这里有一个预先设置的后缀列表

代码语言:javascript复制
.out、.a、.in、.o、.c、.cc、.C、.p、.f、.F、.r、.y、.l、.s、.S、.mod、.sym、.def、.h、.info、.dvi、.tex、.texinfo、.texi、.txinfo、.w、.ch、.web、.sh、.elc、.el

在有些时候,我们使用隐含规则可能会出现问题,比如我们在 makefile 中写一条规则

代码语言:javascript复制
test.o:test.cc

这里 .cc 是C 源文件,这条规则本意是使用 test.cc 生成 test.o 文件,但是我们没有显示指定命令。这时候,makefile 就会根据隐含规则去推导命令,在推导过程中,如果当前文件夹中同时存在 test.c 和 test.cc 文件,那么根据隐含规则的优先级,会默认选择 tes.c 去生成 test.o 文件(根据后缀列表可以看到优先级顺序),这就和我们本来希望的操作完全不符了,因为目标文件 test.o 已经生成了,所以不会再去继续推导了。 当然,我们可以使用 make 选项 -r 或 -n-builtin-rules 来取消所有的预设的隐含规则,但是即便使用参数,有些隐含规则也会生效。因为大部分隐含规则都是使用了后缀规则来定义的,所以,只要隐含规则中有后缀列表,那么隐含规则就会生效。

(2)隐含规则都有哪些

隐含规则按照执行顺序排列如下:

  • 编译 C 程序的隐含规则.o 的目标的依赖目标会自动推导为 .c ,并且其生成命令是 (CC) –c (CPPFLAGS)
  • 编译 C 程序的隐含规则.o 的目标的依赖目标会自动推导为 .cc 或 .C (建议使用 .cc 作为C 源文件的后缀,尽量不用 .C ),并且其生成命令是 (CXX) –c (CPPFLAGS)
  • 编译 Pascal 程序.o 的目标的依赖目标会自动推导为 .p ,并且其生成命令是 (PC) –c (PFLAGS) 。
  • 编译 Fortran/Ratfor 程序“.o”的目标的依赖目标会自动推导为“.r”或“.F”或“.f”,并且其生成命令是.f (FC) –c (FFLAGS).F (FC) –c (FFLAGS) (CPPFLAGS).f (FC) –c (FFLAGS) (RFLAGS)
  • 预处理 Fortran/Ratfor 程序.f 的目标的依赖目标会自动推导为 .r 或 .F 。这个规则只是转换 Ratfor 或有预处理的 Fortran 程序到一个标准的 Fortran 程序,使用的命令是.F (FC) –F (CPPFLAGS) (FFLAGS).r (FC) –F (FFLAGS) (RFLAGS)
  • 编译 Modula-2 程序.sym 的目标的依赖目标会自动推导为 .def ,并且其生成命令是(M2C) (M2FLAGS) (DEFFLAGS) ;.o 的目标的依赖目标会自动推导为 .mod ,并且其生成命令是(M2C) (M2FLAGS) (MODFLAGS) 。
  • 汇编和需要预处理的汇编程序.o 的目标的依赖目标会自动推导为 .s ,默认使用编译器 as,并且其生成命令是 (AS) (ASFLAGS);.s 的目标的依赖目标会自动推导为 .S ,默认使用C预编译器 cpp,并且其生成命令是 (AS) (ASFLAGS) 。
  • 链接单 object 文件的隐含规则目标依赖于 .o ,通过运行 C 的编译器来运行链接程序生成(一般是 ld),生成命令是 (CC) (LDFLAGS) .o (LOADLIBES) (LDLIBS) 。
  • Yacc C 程序时的隐含规则.c 的依赖文件被自动推导为 .y (Yacc 生成的文件),生成命令是 (YACC) (YFALGS) 。(Yacc 是一个语法、词法分析器)
  • Lex C 程序时的隐含规则.c 的依赖文件被自动推导为 n.l (Lex 生成的文件), 生成命令是 (LEX) (LFALGS) 。(Lex 是一个语法、词法分析器)
  • Lex Ratfor 程序时的隐含规则.r 的依赖文件被自动推导为 n.l (Lex生成的文件),生成命令是 (LEX) (LFALGS) 。
  • 从C程序、Yacc文件或Lex文件创建Lint库的隐含规则.ln (lint生成的文件)的依赖文件被自动推导为 n.c ,生成命令是 (LINT) (LINTFALGS)

实际上,我们完全可以使用模式规则来重载内建的隐含规则或者取消内建隐含规则,甚至是创建一个我们自己的隐含规则。模式规则将在后面介绍。 另一种创建隐含规则的方式是后缀规则,其中,双后缀规则定义了一对后缀,目标文件的后缀和依赖文件的后缀,比如 .c.o 相当于 %o : %c ;单后缀规则只定义一个后缀,也就是依赖文件的后缀,比如 .c 相当于 % : %.c 。注意,这些后缀必须是 make 认识的后缀。 这里要注意一个库文件的隐含规则,库文件 .a 就是 .o 文件通过 ar 打包出来的文件,关于链接库内容可见我的 Linux 专栏动态库与静态库文章。当 make 搜索一个目标的隐含规则时,有一个特殊的特性是,如果这个目标是 a(m) 形式的,make 会把目标变成 (m) 。比如,如果我们的成员是 %.o 的模式定义,并且如果我们使用 make 111.a(222.o) 的形式调用 makefile 时,隐含规则会去找 222.o 的规则,如果没有定义 222.o 的规则,那么内建隐含规则生效,make 会去找 222.c 文件来生成 222.o,如果找到了 222.c,make 会先生成 222.o,然后用生成的 .o 去用到规则中,最后删除 .o 文件。

(3)隐含变量

包含隐含规则的命令中所使用的变量都是预定义好的,这种变量称为隐含变量。我们可以通过命令行参数传值或者是修改系统环境变量的方式对隐含变量赋值或重定义,也可以通过 make 的 -R 或 --no– builtin-variables 参数来取消你自定义变量对隐含规则的作用。。在上面我们介绍的 makefile 的隐含规则中,都可以看到隐含变量的身影。下面介绍隐含变量。

① 代表命令的隐含变量

隐含变量

变量代表的含义

AR

函数库打包程序,可用于创建静态库 .a 文件(我的 Linux 专栏动静态库文章中已详细介绍 ar 命令),默认命令是 ar

AS

汇编语言编译程序,默认命令是 as

CC

C语言编译程序,默认命令是 cc

CXX

C 语言编译程序,默认命令是 g

CO

从 RCS文件中扩展文件程序,默认命令是 co

CPP

C程序的预处理器(输出是标准输出设备),默认命令是 $(CC) –E

FC

Fortran 和 Ratfor 的编译器和预处理程序,默认命令是 f77

GET

从SCCS文件中扩展文件的程序,默认命令是 get

LEX

Lex 方法分析器(针对于C或Ratfor),默认命令是 lex

PC

Pascal 语言编译程序,默认命令是 pc

YACC

Yacc 文法分析器(针对于C程序),默认命令是 yacc

YACCR

Yacc 文法分析器(针对于Ratfor程序),默认命令是 yacc –r

MAKEINFO

转换Texinfo 源文件(.texi)到 Info 文件程序,默认命令是 makeinfo

TEX

从 TeX 源文件创建 TeX DVI 文件的程序,默认命令是 tex

TEXI2DVI

从 Texinfo 源文件创建军 TeX DVI 文件的程序,默认命令是 texi2dvi

WEAVE

转换 Web 到 TeX 的程序,默认命令是 weave

CWEAVE

转换 C Web 到 TeX 的程序,默认命令是 cweave

TANGLE

转换 Web 到 Pascal 语言的程序,默认命令是 tangle

CTANGLE

转换 C Web 到 C,默认命令是 ctangle

RM

删除文件命令,默认命令是 rm –f

② 代表命令参数的隐含变量

这些隐含变量将作为上面代表命令的隐含变量的参数,并且变量的默认值为空,也就是说,如果不指定的话,相当于没有加命令参数。

隐含变量

变量代表的含义

ARFLAGS

函数库打包程序 AR 命令的参数,默认值是 rv

ASFLAGS

汇编语言编译器参数

CFLAGS

C 语言编译器参数

CXXFLAGS

C 语言编译器参数

COFLAGS

RCS 命令参数

CPPFLAGS

C 预处理器参数( C 和 Fortran 编译器也会用到)

FFLAGS

Fortran 语言编译器参数

GFLAGS

SCCS get 程序参数

LDFLAGS

链接器参数(比如 ld )

LFLAGS

Lex 文法分析器参数

PFLAGS

Pascal 语言编译器参数

RFLAGS

Ratfor 程序的 Fortran 编译器参数

YFLAGS

Yacc文法分析器参数

关于隐含变量的使用,比如说拿第一条隐含规则来举例:编译C程序的隐含规则的命令是 (CC) –c (CFLAGS) (CPPFLAGS),根据我们上面介绍的隐含变量表,make 默认使用的编译命令是 cc (CC 变量的默认值是 cc),如果我们把变量 (CC) 重定义成 g ,在使用 make 编译的时候就会使用 g 编译程序。另外,我们知道,代表命令参数的隐含变量默认值为空,也就是不加参数,如果我们把变量 (CFLAGS) 重定义成 -g ,那么,在 make 编译的时候就会加上 -g 选项参数来生成调试信息。这样,隐含规则中的命令经过我们的修改,就变成了 g –c -g (CPPFLAGS),使用 make 命令的时候,就会按这条命令来执行。

(4)隐含规则链与中间目标

有时候一个目标的生成可能会有多条隐含规则发生作用,比如一个 .o 文件的生成,如果当前目录中只有一个 .y 文件,没有 .c 文件,那么首先会先由 Yacc C 程序的隐含规则由 .y 生成 .c 然后再由 C 程序编译隐含规则将 .c 生成 .o 。多个隐含规则链式执行,所以把这些隐含规则称为隐含规则链。在这里,因为最开始没有 .c 文件,而 .o 文件的生成依赖 .c 文件,所以 make 会推导生成 .c 文件的规则,这时就通过当前已有的 .y 文件和 Yacc C 隐含规则来生成 .c 文件,因此, .c 文件也被称为中间目标。当然,这是在当前目录中没有 .c 文件的前提下,才会触发 Yacc C 隐含规则,如果当前目录中有 .c 文件,那么会直接使用 .c 文件生成目标 .o 文件,即使存在 .y 文件,也不会执行Yacc C 隐含规则,也就是说,只要生成了目标文件,make 就不会继续推导了。并且,生成目标文件后,中间目标文件会被自动删除。

(5)模式规则

① 什么是模式

模式规则也就是说在规则中存在模式匹配字符 % ,并且是一定要存在 % ,该字符用于对文件名进行匹配。模式匹配字符 % 我们在 makefile 的字符匹配章节已经介绍过了。通过模式规则可以指定多个目标和依赖,make 根据文件名去匹配哪个目标文件对应哪个依赖文件,比如 %.o:%.c ,make 会自动去根据文件名匹配 1.c 生成 1.o,2.c 生成 2.o并推导出这样的规则。 我们举个例子:

代码语言:javascript复制
%.o:%.c
$(CC) -c $(CFLAGS) $(CPPFLAGS) $< -o $@

这就是模式规则最常用的一个例子,我们通过自动化变量章节已经知道 @ 代表目标文件集, < 代表依赖文件集,通过这条命令,make 会把所有的 .c 文件挨个执行下面的命令来生成对应的 .o 文件,至于如何对应的,就是通过模式匹配字符 % ,根据相同的文件名把 .c 文件生成同名的 .o 文件。

② 模式的匹配

模式匹配字符 % 看可以代表任意个字符,在一个目标模式中必须要包含 % ,它可以代表文件后缀或者文件名。在模式中,把 % 所匹配的的内容叫做 茎 。比如说,我们在上面的例子中,%.o:%.c,假如当前目录中有 111.c 222.c 333.c ,那么 111 222 333 这些被 % 所代表的内容就是茎,并且当目标和依赖都包含 % 时,依赖的茎会传递给目标,作为目标的茎。比如上面命令生成的所有 .o 目标文件,都会用相应的 .c 文件的名称作为自己的名称,比如 111.c 生成 111.o,222.c 生成 222.o,333.c 生成 333.o,这就是茎的传递。 这里有一点要注意的是,如果模式中包含目录 / ,那么在模式匹配的时候会先去除目录,等模式匹配完成后再加上目录。比如现在有一个依赖的模式是 src/a%c.c,该模式的目标也含有一个模式 d%f.o,实际上就是这样的

代码语言:javascript复制
d%f.o:src/a%c.c

假如说,依赖文件的模式 src/a%c.c 被匹配为 src/abc.c 文件,那么这个模式的茎应该是 src/b,当把这个茎传递给目标的时候,b 匹配目标中的 % ,也就是 dbf.o ,在加上目录,最终的目标文件应该是 src/dbf.o。

7. makefile 的命令编写

(1)逻辑控制

关键字

功能

ifeq

判断参数是否相等,相等为 true,不相等为 false。

ifneq

判断参数是否相等,不相等为 true,相等为 false。

ifdef

判断是否有值,有值为 true,没有值为 false。

ifndef

判断是否有值,没有值为 true,有值为 false。

这四个关键字都要搭配 else 和 endif 来使用,并且以 endif 介绍。 例1:如果变量 CC 值为 gcc 使用 gcc 编译,否则使用 g 编译。

代码语言:javascript复制
ifeq($(CC), gcc)
	gcc src.c -o exe
else
	g   src.c -o exe
endif

例2:如果没有定义变量 Src 则定义该变量

代码语言:javascript复制
ifndef Src
Src=1.c 2.c
endif

以上四个条件控制关键字只对 makefile 命令起作用,对于 shell 的命令不起作用。

(2)命令回显

make 在执行命令的时候会把命令打印到标准输入输出设备,如果命令前加一个 @ 则不会打印。

可以看到,如果不加 @ 的话,执行 make 时会把要执行的命令打印出来,然后再执行这个命令。有时候还会在 @ 前面加一个 - ,表示即便出错也不报错,继续执行,这个在删除命令中更常见,因为如果重复删除某个文件,shell 命令会报错,可以通过在命令前加 - 或者 rm -f 两种方法解决。

代码语言:javascript复制
	-@echo rm *.c #如果 .c 不存在不会报错
	@echo rm -f *.c #强制删除,.c 不存在也不会报错

另外我们还可以通过 make 的参数来设置。 ① -n 和 –just-print 参数 执行时只打印所要执行的命令,不执行命令。make 会打印出所有要执行的命令,其中包括了使用的 @ 字符开始的命令。通过这个选项就可以按执行顺序打印出 makefile 中所需要执行的所有命令来达到查看待执行命令的目的。

可以看到,加了这两个参数后,make 只会打印 makefile 中待执行的 shell 命令(包括 @ 开头的),但是不会执行命令。 ② -s 和 –silent 参数 关闭 make 执行时所有的执行命令的回显,相当于所有的命令行都使用 @ 开头,在命令非常多的时候,要比 @ 更方便。

可以看到,加了这两个参数后,make 只会执行 makefile 中的 shell 命令,但是不会回显命令,即便不加 @ 也不会回显。 make 的更多参数可以通过 man 去查看。

(3)命令打包

通过 define 和 endef 可以定义一个命令包,来把一连串的命令打包在一起,在执行的时候,会把命令包中的命令一块执行。命令包的使用方法和变量一样,也需要 $ 符来说明。 define 的语法如下:

代码语言:javascript复制
define name #name是命令包的名字,通过name来调用命令包
cmd1 #多行命令
cmd2
cmd...
endef

(4)文件引入

和 c/c 引入头文件一样,makefile 也可以引入其他文件,通过 include 关键字来实现,其语法如下

代码语言:javascript复制
include <file> #file 是 shell 中所支持的文件
-include <file> #忽略文件不存在或无法创建等错误提示

make 命令在执行的时候,如果遇到 include 关键字,会在当前文件中暂停,转去 include 所引入的文件去读取。 include 关键字所在的行首可以含有(任意个)空格,这些空格在读取的时候会被自动忽略,但是,绝对不能使用 Tab 开始,因为 Tab 开始的都会被作为 shell 命令,会把 include 当作 shell 命令来处理。如果包含多个文件,要使用空格分隔开。使用 include 引入的 makefile 文件中,如果存在函数或者变量的引用,它们会在包含的 makefile 中展开。 make 在搜索引入文件时,假如使用 include 包含文件的时候使用相对路径或者当前目录下没有这个文件,make 会根据文件名首先在我们通过 make -I 或 make --include-dir 指定的路径中寻找文件(如果我们显示指定了这个参数的话),然后再去 usr/gnu/include 、 usr/local/include 和 usr/include 这几个目录中寻找。在寻搜索过程中,如果找到了那么就会停止搜索,如果没找到的话会按照上面的顺序依次寻找。如果都没有找到,make 将会提示一个文件没有找到的警告(如果不想看到这个警告,可以在 include 前面加一个 - ),但是不会退出,而是继续执行 makefile 的后续内容。当整个 makefile 扫描完毕后,make 会尝试通过规则来创建被 include 引入但搜索失败的的那个文件。如果创建失败,文件将会保存退出。

  • include:make 在处理程序的时候,如果文件列表中的任意文件搜索不到或者没有规则去创建的时候,make 程序将会提示错误并保存退出。
  • -include :当包含的文件不存在或者是没有规则去创建它的时候,make 将会继续执行程序,只有终极目标无法生成或刷新的时候,make 才会提示错误并保存退出。终极目标就是 makefile 中的第一个目标。

(5)链式命令

我们每写一个命令就会换行,重启一行加 Tab 键写第二个命令。这种不在同一行的命令是互不影响的,也就是上一个命令的结果不会影响下一个命令。如果我们希望上一条命令的结果应用在下一条命令时,可以使用分号 ; 分隔这两条命令 ,并把这两个命令写在同一行。

这样的效果有点像 shell 中 -exec、-ok 和 xargs 这样的选项所起到的效果,又有点像 C 中的链式编程,我就姑且叫它链式命令吧,哈哈哈。

(6)特殊目标

目标是规则中要生成的目标,在一个 makefile 中,至少要有一个最终目标。但是,目标是多种多样的,甚至有一些目标是不需要实际生成,比如前面说过的伪目标。下面介绍各种类型的目标。 ① 特殊目标

名称

功能

.PHONY:

这个目标的所有依赖被作为伪目标。伪目标是这样一个目标:当使用 make 命令行指定此目标时,这个目标所在的规则定义的命令、无论目标文件是否存在都会被无条件执行。

.SUFFIXES:

这个目标的所有依赖指出了一系列在后缀规则中需要检查的后缀名。

.DEFAULT:

Makefile 中,这个特殊目标所在规则定义的命令,被用在重建那些没有具体规则的目标,就是说一个文件作为某个规则的依赖,却不是另外一个规则的目标时,make 程序无法找到重建此文件的规则,这种情况就执行 “.DEFAULT” 所指定的命令。

.PRECIOUS:

这个特殊目标所在的依赖文件在 make 的过程中会被特殊处理:当命令执行的过程中断时,make 不会删除它们。而且如果目标的依赖文件是中间过程文件,同样这些文件不会被删除。

.INTERMEDIATE:

这个特殊目标的依赖文件在 make 执行时被作为中间文件对待。没有任何依赖文件的这个目标没有意义。

.SECONDARY:

这个特殊目标的依赖文件被作为中过程的文件对待。但是这些文件不会被删除。这个目标没有任何依赖文件的含义是:将所有的文件视为中间文件。

.IGNORE:

这个目标的依赖文件忽略创建这个文件所执行命令的错误,给此目标指定命令是没有意义的。当此目标没有依赖文件时,将忽略所有命令执行的错误。

.DELETE_ON_ERROR:

如果在 Makefile 中存在特殊的目标 “.DELETE_ON_ERROR” ,make 在执行过程中,荣国规则的命令执行错误,将删除已经被修改的目标文件。

.LOW_RESOLUTION_TIME:

这个目标的依赖文件被 make 认为是低分辨率时间戳文件,给这个目标指定命令是没有意义的。通常的目标都是高分辨率时间戳。

.SILENT:

出现在此目标 “.SILENT” 的依赖文件列表中的文件,make 在创建这些文件时,不打印出此文件所执行的命令。同样,给目标 “SILENT” 指定命令行是没有意义的。

.EXPORT_ALL_VARIABLES:

此目标应该作为一个简单的没有依赖的目标,它的功能是将之后的所有变量传递给子 make 进程。

.NOTPARALLEL:

Makefile 中如果出现这个特殊目标,则所有的命令按照串行的方式执行,即使是存在 make 的命令行参数 “-j” 。但在递归调用的子make进程中,命令行可以并行执行。此目标不应该有依赖文件,所有出现的依赖文件将会被忽略。

② 强制目标 如果一个规则没有依赖和命令,仅有目标,那么在执行这条规则的时候,规则中的目标会被认为是每次更新的。也就是说,每当执行这条规则的时候都会认为这个目标被更新过,那么以这个目标作为依赖的那条规则中的命令,就总是会被执行。我们常用的用法是

代码语言:javascript复制
clean:FORCE
	@rm -f *.o $(BIN)
FORCE:

这样,每次执行时, rm 命令都会被执行,相当于把 clean 定义为伪目标的效果。但是,伪目标需要 make 命令指定作为最终目标,比如 make clean 这样来指定 clean 为最终目标才可以执行。 我们做个测试,首先不加强制目标测试一下,会生成中间文件

我们在加强制目标删除中间文件

③ 空目标文件 类似于伪目标,与伪目标的区别是,空目标文件可以是一个具体的文件,但是文件的内容我们不关心,一般设置为空文件。在执行时,与伪目标一样需要 make 显示指定为最终目标。 空目标文件一般用来记录上一次执行这条规则的时间,一般本规则的实现是通过 shell 的 touch 命令实现的。在规则的命令部分,当所有命令执行完毕后,使用 touch 命令作为最后一条命令来更新目标文件的时间戳,以此实现记录命令执行时间的功能。执行 make 命令时需要指定此目标作为最终目标,如果当前目录不存在这个文件(空目标文件), touch 命令会在第一次执行时创建一个文件。

代码语言:javascript复制
Log:1.c
    lpr -p $? # $?列出比目标文件(print)更新的所有依赖文件,并由lpr命令提交给打印机
    touch Log

一般来说,一个空目标文件应该存在一个或者多个依赖文件,将这个空目标作为最终目标,当它所依赖的文件比它更新时,此目标所在的规则的命令行将被执行。也就是说,如果空目标文件的依赖文件被改变之后,空目标文件所在的规则中定义的命令会被执行。 ④ 多规则目标 如果将一个文件作为多个规则的目标,那么以这个文件为目标的规则的所有依赖文件都将会被合并为该目标文件的依赖文件列表,当这个依赖文件列表中的任何一个依赖文件比目标文件更新时,make 都会重建这个目标。虽然该目标是多个规则的目标,但是重建目标的命令只能出现在一个规则中。即使多个规则都含有重建该目标的命令,make 也只会使用最后一个规则中所定义的命令来重建,并且会提示一个错误信息。如果我们需要对相同的目标使用不同规则中定义的命令,就要使用双冒号规则来实现。

四、make 命令是如何运行的

1. make 的错误处理

(1)make 的返回值

每当 makefile 中的一条命令运行完毕,make 都会去检查命令的返回码,如果命令返回代表成功的返回码,那么 make 会继续去执行下一条命令。当一个规则中的所有命令都运行完毕且都返回成功,那么这个规则就运行成功了。如果一个规则中的某个命令出错了即命令退出码非零,那么 make 就会终止执行当前规则,并且有可能会终止所有规则的执行。 make 命令执行后有三个退出码:

返回值

含义

0

执行成功

1

运行时出错

2

使用make的 -q 选项,使得一些目标不需要更新

但实际上,命令执行完毕没有返回正确(返回码非0)并不说明就一定是错误的。比如我们执行 mkdir 建立一个目录,如果目录不存在,那么执行成功返回0,如果目录存在,那么就出错并停止执行。但是,目录存在并不代表有错误,我们的目的是达到的,只要结果是有了这个目录,我们的目的就达到了,也就是说,实际上这也是正确的,我们不希望 make 停止执行。解决方法是,在命令前加一个 - (Tab 键与命令之间),这样不管命令是否出错,是否返回0,都认为运行成功。另一个方法是,给 make 加上 -i 或 --ignore-errors 参数,代表忽略命令执行中的错误。这里还有一个参数 -k 或是 --keep-going ,这个参数代表,如果某规则中的命令出错了,那么就停止该规则的执行,但继续执行其它规则。 我们在 makefile 中的特殊目标中介绍过一个特殊目标 .IGNORE ,如果一个规则是以 .IGNORE 为目标,那么这个规则中的所有命令将会忽略错误。

(2)makefile 的出错控制函数

在 makefile 中有两个函数 error 和 warning 可以控制 make 运行过程中的出错处理。当 make 执行过程中检测到某些错误时为用户提示消息,并且可以控制 make 执行过程是否继续。 ① 致命错误并停止 error

  • 函数原型
代码语言:javascript复制
$(error TEXT...)
  • 函数功能:产生致命错误,提示 TEXT… 信息给用户,并退出 make 的执行。 error 函数是在函数展也就是函数被调用的时候才提示信息并结束 make 进程。如果函数出现在命令中或者一个递归的变量定义时,读取 makefile 时不会出现错误,只有包含 error 函数引用的命令被执行,或者定义中引用此函数的递归变量被展开时,才会提示致命错误信息 TEXT… 同时退出 make。所以, error 函数一般不出现在直接展开式的变量定义中,否则在 make 读取 makefile 文件时将会提示致命错误。
  • 函数返回:无
  • 用法示例:可以看到,在示例中,会打印 error 函数执行的错误信息,并打印出错位置,且停止 make 。

② 错误提示并继续 warning

  • 函数原型
代码语言:javascript复制
$(warning TEXT...)
  • 函数功能:用法与 error 相同,但是它不会导致致命错误(make 不会退出),仅提示 TEXT… ,且 make 继续执行。
  • 函数返回:无
  • 用法示例:对比 error 函数的示例可以看到,使用 error 函数,出错时会直接停止。而 warning 函数打印完错误信息后,make 会继续执行。

(3)常见错误

① 致命错误,通过 - 可以忽略错误,继续执行。 make 执行过程的致命错误都带有一个前缀字符串 ***

② 无法为重建目标 XXX 找到合适的规则,包括明确规则和隐含规则。

代码语言:javascript复制
No rule to make target XXX.
No rule to make target XXX, needed by xxx.

更多的错误不再列出,make 会打印出出错位置以及错误原因,一般根据这些信息去修改即可。

2. make 命令指定文件

GNU make 搜索默认的 makefile 的规则是在当前目录下依次寻找 GNUmakefile 、 makefile 和 Makefile ,并且是按照顺序寻找这三个文件,一旦找到,就开始读取这个文件并执行,并且不会再继续搜索。 我们可以测试一下,在当前目录创建GNUmakefile 、 makefile 和 Makefile 文件,并执行 make 且不指定文件,那么 make 的默认执行的文件按优先级排序为 GNUmakefile > makefile > Makefile。

当然,我们也可以给 make 命令指定一个 makefile 文件的名字。这个功能是通过 make 的参数 -f 或 --file 或 – makefile实现的。如果在 make 的命令行一次使用多个 -f 参数,那么,所有指定的 makefile 文件将会被连在一起传递给 make 执行。 这里需要区分一下:make target 是指定 target 为最终目标(如果规则会生成这个目标的话就生成 target),而 make -f target 是指执行 target 这个 makefile 文件。指定目标和指定文件是不同的,本节所讲为通过 -f 指定文件,指定目标将在后面讲解。

3. make 命令指定目标

默认情况下,make 的最终目标是 makefile 文件中的第一个目标,其他目标都是为了生成这个最终目标而设置的中间目标。在默认情况下执行 make 命令生成的就是 makefile 中的最终目标,当然,我们也可以在执行 make 命令的时候,显示指定 make 的最终目标,直接在 make 后面接目标名(makefile 文件中存在的目标)即可。但是, - 开头或者包含 = 的目标不能被指定为 make 的最终目标,因为包含这两个字符的目标会被解析为命令行参数或是变量。即使是没有被我们明确写出来的目标也可以指定为为 make 的终极目标,只要是 make 可以找到一个隐含规则推导出该规则,那么这个隐含目标同样可以被指定成终极目标。比如下面的例子,我们在 makefile 中并没有写以 1.o 为目标的规则,甚至都没有 1.o 这个目标,但是却可以通过 make 来指定 1.o 为最终目标,这是因为 make 根据隐含规则可以推导出 1.o 的规则,并生成 1.o 这个目标。

在 make 的环境变量中有一个 MAKECMDGOALS 变量,这个环境变量中会存放我们所指定的终极目标列表,如果在命令行没有指定目标,那么这个变量就是空值。通过这个环境变量,我们可以结合条件逻辑控制来根据条件去执行命令。比如下面的例子,只要我们输入的命令不是 make clean (环境变量 MAKECMDGOALS 的值不是 clean),那么 makefile 会自动包含 1.d 和 2.d 这两个 makefile。

代码语言:javascript复制
sources = 1.c 2.c
ifneq ( $(MAKECMDGOALS), clean)
include $(sources:.c=.d)
endif

make 命令同样可以指定最终目标为伪目标,在 makefile 发布时,常用的用于实现编译、安装、打包等功能的伪目标已经在前面 makefile 的伪目标章节列出,这些伪目标在大型工程中非常有用。

4. make 命令的参数

参数

<-n> 、 <–just-print> 、 <–dry-run> 、 <–recon>

作用

不执行命令,仅打印命令,不管目标是否更新,只是把规则和规则下的命令打印出来,但不执行。这些参数通常用于调试 makefile 来查看规则中要执行的命令。

参数

<-t>、 <–touch>

作用

把目标文件的时间更新,但不更改目标文件。也就是说,make 并不是真正的编译目标,只是把目标变成已编译过的状态。

参数

<-q> 、 <–question>

作用

搜索目标。如果目标存在,无输出且不会执行编译;如果目标不存在,打印出错信息。

参数

<-W> 、<–what-if> 、<–assume-new> 、<–new-file>

作用

后面都要加一个文件名,一般是源文件或依赖文件,Make 会根据规则推导来运行依赖于这个文件的命令,通常和 -n 参数一同使用,来查看这个依赖文件所发生的规则命令。

参数

<-B> 、<–always-make>

作用

重新编译,即认为所有目标都要更新

参数

<-f> 、<–file> 、<–makefile>

作用

后面加一个文件名,指定需要运行的 makefile 文件。

参数

<-I> 、<–include-dir>

作用

后面跟一个目录,指定一个 makefile 文件的搜索路径,可以使用多个 -I 参数来指定多个目录。

这里只是例举了几个常用的参数,更多 make 的参数,可以通过下面的命令来查看,通过 help 帮助参数,可以查看到 make 命令的所有参数。

代码语言:javascript复制
make -h
make --help

5. makefile 文件嵌套

(1)嵌套执行 make 命令

有时候我们会对整个工程的文件进行功能划分,划分好的每个模块都有自己的 makefile 编译规则。这时,就需要用到 makefile 的嵌套执行,也就是在一个 makefile 文件中包含另一个 makefile 文件,当 make 命令执行外层 makefile 的时候会转去执行它内部包含的内层 makefile 。 首先我们建立两个目录,每个目录下都包含一个 makefile 文件

两个 makefile 文件的内容如下,在 dir2 目录下的 makefile 调用了 dir1 目录下的 makefile 文件。

我们进入到 dir2 目录并执行 make 命令

我们看到,当执行到调用嵌套 makefile 文件的语句时,会提示进入被嵌套的 makefile 文件所在的目录,并执行被嵌套的 makefile 文件,执行完毕会提示离开被嵌套的 makefile 文件所在的目录,并且继续执行外层 makefile 。 这里有一条命令

代码语言:javascript复制
#进入 ../dir1/ 目录,并执行 make 命令
cd ../dir1/ && $(MAKE)

这句话表示,先通过 cd 命令切换到目标目录,然后在目标目录下执行 make 命令,也就是执行目标目录下的 makefile 文件。 其实,这句话也可以被替代为

代码语言:javascript复制
 $(MAKE) -C ../dir1/

在 make 中,有一个环境变量 CURDIR ,此变量代表 make 的工作目录。当使用 make 的选项 -C 时,就会进入指定的目录中去执行 make 命令,然后此变量就会被重新赋值。

一般我们把最外层的那个 makefile 文件叫做总控 makefile 。

(2)文件嵌套中的变量传递

指定变量是否传递给下一级(内嵌的)makefile 文件,使用下面两个关键字

代码语言:javascript复制
export val		#将 val 传递给下级 makefile
unexport val	#不将 val 传递给下级 makefile

如果我们仅用一个单一的关键字 export 而不指定变量名,则表示所有变量都传递给下一级 makefile。但是有两个特殊变量 SHELL 和 MAKEFLAGS,这两个变量不管是否使用关键字 export 都会传递给下一级 makefile 文件。其中MAKEFLAGS 变量中包含了 make 命令的参数信息。如果上层 makefile 文件中定义了 MAKEFLAGS 变量,或者说在执行 make 命令的时候使用了 make 的参数,那么这些参数将会被 MAKEFLAGS 变量传递到下一层 makefile 文件,并作为 make 的参数传递。make 命令中有几个参数选项不传递,它们是 -C 、 -f 、 -o 、 -h 和 -W 选项。如果不想传递 make 的参数,可以显示的把 MAKEFLAGS 定义为空,让它传递一个空参数给下层 makefile 。 下面举例说明 export 的用法,在这个例子中,dir1/ 目录中的 makefile 使用了一个未定义的变量 Vul,这个变量来自于它的上层 makefile (包含调用它的 makefile 文件),在 dir2/ 目录下的 makefile 嵌套了dir1/ 目录中的 makefile ,并且声明了一个 export 的变量 Vul ,那么这个 Vul 变量将传递给被他嵌套的所有其他 makefile 文件。

五、实战 makefile

上面讲了很多理论知识,这一章就来实战写一个 mkefile 文件。首先准备几个文件,头文件 my_print.h 中声明了一些函数接口,这些函数接口在 my_print.c 和 print_hello.c 中实现,并在 main.c 中调用。

① 初级:使用变量、函数、模式规则

代码语言:javascript复制
SrcFiles = $(wildcard *.c) #wildcard函数,匹配所有 .c 文件
ObjFiles = $(patsubst %.c, %.o, $(SrcFiles)) #内用替换函数及模式匹配
HeadDirs = ../header #头文件路径
 
#最终目标是可执行文件 exe
exe:$(ObjFiles)
    gcc $(SrcFiles) -o exe -I $(HeadDirs)
 
#使用模式匹配来生成中间目标
%.o:%.c
    gcc -c $< -o $@ -I $(HeadDirs)

clean:
    -@rm -f *.o
    -@rm -f exe

执行 make 命令来测试一下,这里可以明显的看到,根据模式匹配规则,每个 .c 都对应生成的 .o ,执行 make clean 可以删除目标文件。

② 中级:使用伪目标 all 来构建多个可执行文件,我们在使用的时候,只要把我们必须要执行的命令所在的规则中的目标作为 all 的依赖即可,这样就保证了这些命令一定会被执行。

代码语言:javascript复制
  1 .PHONY:all clean #伪目标,并不会真正生成相应文件
  2 SrcFiles = $(wildcard *.c) #wildcard函数,匹配所有 .c 文件
  3 ObjFiles = $(patsubst %.c, %.o, $(SrcFiles)) #内用替换函数及模式匹配
  4 HeadDirs = ../header #头文件路径
  5 
  6 #最终目标是伪目标 all,在这条规则中,all 是目标,所以 make 需要去构建它的依赖,也就是 exe1 exe2 ,这就保证了 exe1 exe2 对应规则中的命令一定会执行
  7 all:exe1 exe2
  8 
  9 exe1:$(ObjFiles)
 10     gcc $(SrcFiles) -o exe1 -I $(HeadDirs)
 11 
 12 exe2:$(ObjFiles)
 13     gcc $(SrcFiles) -o exe2 -I $(HeadDirs)
 14 
 15 #使用模式匹配来生成中间目标
 16 %.o:%.c
 17     gcc -c $< -o $@ -I $(HeadDirs)
 18 
 19 clean:
 20     -@rm -f *.o
 21     -@rm -f exe1 exe2

测试一下

需要特别强调的是。这里的伪目标 all 作为最终目标,把所有要生成的多个可执行文件作为 all 的依赖。这样,在构建最终目标 all 的时候,就需要去构建它的依赖,也就是所有的可执行文件,这样就保证了,所有可执行文件所在规则中的构建命令一定会执行,并生成这些可执行文件,而 all 作为一个伪目标,并不会真正生成。假如你不这么做,去掉伪目标 all ,直接去写 exe1 和 exe2 的规则,你会发现,它们俩只会生成一个,哪个在前面就生成哪一个,这是因为一个文件中,最终目标只有一个,make 的规则推导是以生成最终目标为目的的。

③ 高级:借助隐含规则,这里借助隐含规则自动推导 .c -> .o 的规则,不用再显示写出中间目标 .o 的规则。

代码语言:javascript复制
  1 .PHONY:all clean
  2 CC = gcc
  3 CFLAGS = -Wall -g
  4 BIN = exe
  5 HeadDirs = ../header
  6 SrcFiles = main.c print_hello.c my_print.c
  7 
  8 all:$(BIN)
  9 
 10 $(BIN):$(SrcFiles)
 11     $(CC) $(CFLAGS) $(SrcFiles) -o $(BIN) -I $(HeadDirs)
 12 
 13 clean:
 14     -@rm -f *.o $(BIN)

测试一下


总结

虽然说,在实际编写 makefile 的时候,有很多模板可以参考,并且大部分也都是使用的基本的规则命令。但是了解 makefile 的语法、变量、函数、规则等等更深层次的知识也是非常有必要的。 最后,作为 Linux 程序员或爱好者所必备的基本技能,shell 命令、VIM 编辑器、GCC 编译器、GDB 调试器、makefile 都已经讲解完毕,具体内容请查看本人 Linux 系列专栏中的文章,打好这些基本功是成为 Linux 开发高手的必备技能。

0 人点赞