使用 GCC 命令行进行程序编译,在单个文件下是比较方便的。但当工程中的文件逐渐增多,甚至变得十分庞大的时候,使用 GCC 命令编译就会变得力不从心。这种情况下,需要借助项目构造工具 make
来帮助我们完成这个艰巨的任务。
make 是一个命令工具,一个解释 Makefile 中指令的命令工具。make
工具在构造项目时需要加载一个 Makefile 文件,Makefile 关系到了整个工程的编译规则。一个工程中的源文件不计数,其按类型、功能、模块分别放在若干个目录中,Makefile 定义了一系列的规则来指定哪些文件需要先编译、哪些文件需要后编译、哪些文件需要重新编译,甚至于进行更复杂的功能操作。Makefile 就像一个 Shell 脚本一样,其中也可以执行操作系统的命令。
Makefile 带来的好处就是“自动化编译”,一旦写好,只需要一个 make
命令,整个工程完全自动编译,极大的提高了软件开发的效率。
Makefile 文件有两种命名方式 Makefile
和 makefile
。构建项目时,在哪个目录下执行构建命令 make
,则这个目录下的 Makefile 文件就会被加载。因此,在一个项目中可以有多个 Makefile 文件,分别位于不同的项目目录中。
本文转载并修改自:https://subingwen.cn/linux/makefile/
Makefile 的框架是由规则构成的。make 命令执行时先在 Makefile 文件中查找各种规则,对各种规则进行解析后运行规则。规则的基本格式为:
1 | # 每条规则的语法格式(command 前为 Tab 缩进,不能是空格): |
每条规则由三个部分组成分别是 目标 (target)
, 依赖 (depend)
和命令(command)
。
命令(command)
: 当前这条规则的动作,一般情况下这个动作就是一个 shell 命令。
依赖(depend)
: 规则所必需的依赖条件,在规则的命令中可以使用这些依赖。
*.o
)可以作为依赖使用。目标(target)
:规则中的目标,这个目标和规则中的命令是对应的。
伪目标
。关于上面的解释可能有些晦涩,下面通过一个例子来阐述一下:
1 | # 举例: 有源文件 a.c b.c c.c head.h,需要生成可执行程序 app |
在此主要为大家剖析一下通过提供的 Makefile 文件,构建工具 make
在什么时候编译项目中的所有文件,在什么时候只选择更新项目中的某几个文件。另外,再研究一下如果 Makefile 里有多个规则,它们之间是如何配合工作的。我们基于下边的例子,依次进行讲解。
当调用 make
命令编译程序时,首先找到 Makefile 文件中的第 1 个规则,然后执行相关的动作。但需要注意的是,很多时候动作(命令)中使用的依赖可能不存在,如果依赖不存在,该动作也不会执行。
对应的解决方案如下:
先将需要的依赖生成出来:在 Makefile 中添加新规则,将“不存在的依赖”作为目标,当新规则的命令执行完毕时,对应的目标就会生成。此时,其他规则中需要的依赖也就存在了。这样,某条规则在需要时会被其他规则调用,直到 Makefile 中的第一条规则的所有依赖都被生成。第一条规则中的命令可以基于这些依赖生成目标,完成 make 的任务。
1 | # 规则之间的嵌套 |
在这个例子中,执行
make
命令会根据 Makefile 中的 4 条规则编译三个源文件。当解析第一条规则时,发现其中的三个依赖都不存在,因此对应的命令不能执行。当依赖不存在时,
make
会查找其他规则,找到用来生成这些依赖的规则,并执行其命令。因此,规则 2、规则 3、规则 4 中的命令会依次执行。当规则 1 中的依赖全部生成后,其对应的命令也会执行,最终生成规则 1 的目标,make
的工作就结束了。
知识点拓展:
如果想要执行 Makefile 中非第一条规则对应的命令,那么就不能直接 make
,需要将那条规则的目标也写到 make 的后边,比如只需要执行规则 3 中的命令,就需要执行 make b.o
。
在执行 make
命令时,会 根据文件的时间戳来判断 是否执行 Makefile 文件中相关规则中的命令。
make
命令时检测到规则中的目标和依赖满足这个条件,则规则中的命令不会执行。1 | # 规则之间的嵌套 |
根据前文描述,首先执行
make
命令,根据 Makefile 编译这几个源文件生成对应的目标文件。然后修改例子中的a.c
文件。再次执行make
编译这几个源文件。在这种情况下,首先执行规则 2 更新目标文件a.o
,然后执行规则 1 更新目标文件app
。其余的规则不会被执行。
make 是一个功能强大的构建工具,尽管我们在编写 Makefile 时可能会出现不够严谨的情况,导致漏写一些构建规则,但程序仍然可以成功编译。这是因为 make 具有自动推导的能力,不完全依赖于 Makefile。
举例来说,当使用 make
命令编译扩展名为 .c
的 C 语言文件时,源文件的编译规则无需明确给出。这是因为 make 在进行编译时会使用一个默认的编译规则,按照默认规则完成对 .c
文件的编译,生成对应的 .o
文件。默认情况下,它使用命令 cc -c
来编译 .c
源文件。在 Makefile 中,只需给出需要构建的目标文件名(即一个 .o
文件),make 会自动为这个 .o
文件寻找合适的依赖文件(对应的 .c
文件),并使用默认的命令来构建这个目标文件。
假设本地项目目录中有以下几个源文件:
1 | tree |
目录中 Makefile 文件内容如下:
1 | # 这是一个完整的 Makefile 文件 |
通过 make 构建项目:
1 | make |
可以观察到上述的 Makefile 文件中只有一条规则。在依赖部分,所有的 .o
文件在本地项目目录中都不存在,并且没有其他规则用来生成这些依赖文件。在这种情况下,make
会使用内部默认的构建规则,首先生成这些依赖文件,然后执行规则中的命令,最终生成目标文件 calc
。
在使用 Makefile 进行规则定义时,为了增加灵活性,可以使用三种类型的变量:自定义变量、预定义变量、自动变量。
自定义变量:这些变量是用户自己定义的、没有类型,可以根据需要随时修改。通过定义变量,可以将一些常用的值或命令集中管理,以便在整个 Makefile 中重复使用。
1 | # 错误,只创建了变量名,没有赋值 |
在给 Makefile 中的变量赋值之后,如何在需要的时候将变量值取出来呢?
1 | $(LIBS) |
自定义变量使用举例:
1 | # 这是一个规则,普通写法 |
预定义变量:这些变量是 make 已经定义好的,用户可以直接在 Makefile 中使用,而不用进行定义。例如,CC
表示 C 编译器的名称,CFLAGS
表示编译 C 程序时需要的额外参数等。这些预定义变量的名字一般都是大写的,经常采用的预定义变量如下表所示:
变 量 名 | 含 义 | 默 认 值 |
---|---|---|
AR | 生成静态库库文件的程序名称 | ar |
AS | 汇编编译器的名称 | as |
CC | C 语言编译器的名称 | cc |
CPP | C 语言预编译器的名称 | $(CC) -E |
CXX | C++ 语言编译器的名称 | g++ |
FC | FORTRAN 语言编译器的名称 | f77 |
RM | 删除文件程序的名称 | rm -f |
ARFLAGS | 生成静态库库文件程序的选项 | 无默认值 |
ASFLAGS | 汇编语言编译器的编译选项 | 无默认值 |
CFLAGS | C 语言编译器的编译选项 | 无默认值 |
CPPFLAGS | C 语言预编译的编译选项 | 无默认值 |
CXXFLAGS | C++ 语言编译器的编译选项 | 无默认值 |
FFLAGS | FORTRAN 语言编译器的编译选项 | 无默认 |
一个使用了自定义变量和预定义变量的 Makefile:
1 | # 这是一个规则,普通写法 |
自动变量:这些变量的值由 make 在特定的上下文中自动赋值,无需用户手动定义。例如,在规则中使用 $@
表示目标文件的名称,在命令中使用 $<
表示第一个依赖文件的名称等。自动变量使得在规则中引用目标文件、依赖文件等更加方便。
自动变量只能在规则的命令中使用,下表中是一些常见的自动变量:
变 量 | 含 义 |
---|---|
$* | 表示目标文件的名称,不包含目标文件的扩展名 |
$+ | 表示所有的依赖文件,这些依赖文件之间以空格分开,按照出现的先后为顺序,其中可能 包含重复的依赖文件 |
$< | 表示依赖项中第一个依赖文件的名称 |
$? | 依赖项中,所有比目标文件时间戳晚的依赖文件,依赖文件之间以空格分开 |
$@ | 表示目标文件的名称,包含文件扩展名 |
$^ | 依赖项中,所有不重复的依赖文件,这些文件之间以空格分开 |
下面几个例子,演示一下自动变量如何使用。
1 | # 这是一个规则的普通写法 |
在介绍概念之前,先读一下下面的这个 Makefile 文件:
1 | calc: add.o div.o main.o mult.o sub.o |
在阅读过程中,能够发现从第二个规则开始到第六个规则做的是相同的事情。但是由于文件名不同,不得不书写多个规则,这就让 Makefile 文件看起来非常的冗余。我们可以将这一系列相同的操作整理成一个模板,所有类似的操作都可以通过模板去匹配。这样,Makefile 会精简不少,只是可读性会有所下降。
这个规则模板可以写成下边的样子,这种操作就称之为 模式匹配。
1 | # 第一个规则 |
对于上述使用模式匹配的 Makefile,第一个规则中的依赖(这里是所有的 .o
目标文件)的生成,都需要基于这个使用了模式匹配的规则来生成。在这里,模式规则被执行了 5 次,其中的 % 对应的文件名是不断变化的。因此,命令中依赖的名字,必须要使用自动变量。
Makefile 中有许多函数,它们都具有返回值。函数的格式与 C/C++ 中的函数不同,写法是 $(函数名 参数 1, 参数 2, 参数 3, ...)
,这样设计的目的是为了方便获取函数的返回值。
我将介绍两个在 Makefile 中使用频率较高的函数:wildcard
和 patsubst
。
wildcard
函数的作用是在指定目录下获取特定类型的文件名列表,返回以空格分隔的文件名字符串。函数原型如下:
1 | $(wildcard PATTERN...) |
参数功能:
PATTERN
指定了要搜索的目录和文件类型,比如 *.c
表示当前目录下的所有 .c
文件。返回值:
$(wildcard *.c ./sub/*.c)
可能返回 a.c b.c c.c d.c e.c f.c ./sub/aa.c ./sub/bb.c
。以下是函数的使用示例:
1 | # 示例:搜索三个不同目录下的 .c 格式的源文件 |
在这个示例中,src
变量获取了满足条件的文件列表,这些文件分别来自 /home/robin/a/
、/home/robin/b/
和当前目录。
patsubst
函数的作用是替换指定模式的 文件名后缀,函数原型如下:
1 | $(patsubst pattern, replacement, text) |
参数功能:
pattern
:需要匹配的模式字符串,指定要被替换的文件名后缀。路径和文件名不需要关系,可以使用通配符 %
表示表示。replacement
:替换后的新后缀模式字符串。仍然使用 %
表示原始路径和文件名。text
:待处理的文本,即原始数据。返回值:
举例:$(patsubst %.c, %.o, file1.c file2.c)
会将 file1.c
和 file2.c
替换为 file1.o
和 file2.o
。
下面基于一个简单的项目,为大家演示一下编写一个 Makefile 从不标准到标准的进化过程。
1 | # 项目目录结构 |
1 | calc: add.o div.o main.o mult.o sub.o |
.o
目标文件,太耗时、效率低。1 | # 默认所有的依赖都不存在,需要使用其他规则生成这些依赖 |
.o
目标文件。1 | # 添加自定义变量 |
1 | # 添加自定义变量 |
*.o
目标文件和可执行程序。*.o
目标文件和可执行程序。1 | # 添加自定义变量 |
make clean
就可以执行规则中的删除命令了。clean
是一个伪目标,让 make
放弃对它的时间戳检测。正常情况下,这个版本的 Makefile 是可以正常工作的,但是如果在这个项目目录中添加一个叫做 clean
的文件(和规则中的目标名称相同),再进行 make clean
发现这个规则就不能正常工作了。
1 | 在项目目录中添加一个叫 clean 的文件,然后在 make clean 这个规则中的命令就不工作了 |
这个问题的关键点在于 clean
是一个伪目标,不对应任何实体文件,在前边讲 关于文件时间戳更新 问题的时候说过:如果目标不存在,规则的命令肯定被执行;如果目标文件存在了,就需要比较规则中目标文件和依赖文件的时间戳,满足条件才执行规则的命令,否则不执行。
解决这个问题需要在 Makefile 中声明 clean
是一个伪目标,这样 make
就不会对文件的时间戳进行检测,规则中的命令也就每次都会被执行了。
在 Makefile 中声明一个伪目标需要使用 .PHONY
关键字,声明方式为: .PHONY: 伪文件名称
。
1 | # 添加自定义变量 |
如果觉得上边讲的内容看懂了,可以试着根据这个目录结构写出其对应的 Makefile 文件。
1 | 目录结构 |
根据上边的项目目录结构编写的 Makefile 文件如下:
1 | # 搜索多个指定目录下的.c 源文件 |
编译过程日志:
1 | make |
执行 make
后的项目目录结构:
1 | . |
如果觉得上边讲的内容看懂了,可以试着根据这个目录结构写出其对应的 Makefile 文件。
1 | . |
根据上边的项目目录结构编写的 Makefile 文件如下:
1 | CC = gcc |
编译过程日志:
1 | make |
执行 make
后的项目目录结构:
1 | . |
参考资料:
- 本文转载并修改自:https://subingwen.cn/linux/makefile/