Cpp构建和编译笔记——4.make与Makefile
make 介绍
make(GNU make)是一个项目构建工具,用于方便地编译、链接多个源代码文件,自动决定哪些源文件需要重新编译,从而高效地构建自己地项目,普遍用于处理 c/c++项目,但也可以用于其他语言。
make 的通常用法是:
- 在项目目录下把编译链接的命令,写入 Makefile(指定文件名的一个文本文件,类似于 shell 脚本)
- 在项目命令下,执行
make
命令,会自动读取当前目录下的 Makefile 并解析执行 make
命令可以后接参数:make
,相对于执行make all
,通常代表从头编译整个项目make install
,通常代表把当前项目安装到系统中,还可以附加一些参数指定安装位置等,例如prefix=/usr/local
make clean
,通常代表清理项目中的杂项文件,不包括源文件- 注意这些只是约定俗成的作法,具体的命令行为其实完全取决于 Makefile 中写入的内容
这里的 make 是 GNU make,但是其实在各种 IDE 中,也都集成了一个类似的项目构建工具,例如 VS 的 nmake,它们发挥着类似的作用。
为什么用 make
对于简单的
c/c++文件,我们可以敲几行gcc ...
命令完成编译,但是对于复杂一点的文件,比如需要拆分成几个部分分别编译,最后链接,比如需要使用很多的库,需要指定头文件,需要一些编译选项等,会导致我们每次修改之后,都需要把
n 行的编译命令重新敲一遍,非常的繁琐低效。
一个很直接的想法是把重复使用的编译命令写入 shell 脚本,每次执行一遍 shell 脚本即可,为什么还要专门的 make 和 Makefile?make 解析 Makefile 之后,确实还是要交给 shell 去执行命令,但是相比于直接使用 shell 脚本,使用 Makefile 还有以下几点优势:
- 结构化脚本:Makefile 把很多行命令分成了若干个 target,target 之间存在依赖关系,而 shell 脚本只能简单地从上到下顺序执行
- 选择性更新:对于每一个 target,它都可以理解为输入文件,执行命令和输出文件三要素组成,如果在本次对源文件的修改中,当前 target 所依赖的输入源文件并没有被改动,那么 make 就会直接跳过这个 target,不做无意义的重复编译,节约时间
- 定制的语法:由于 Makefile 不依赖于具体的 shell,因此可以使用自己独有的一套通配符,更适合编译的命令语法,这使得 Makefile 的书写更加简洁(但也更加晦涩难读),最终会通过字符串替换的形式解析成更具体的编译命令,传递给 shell 执行,需要注意的是,每行命令都在一个单独的 shell 中执行,也就是不同的进程,这些 Shell 之间没有继承关系
使用 make 编译一个项目工程(由若干源文件,头文件和库组成),我们希望采用如下的"偷懒"策略,来节约每次改动后的重新编译时间:
- 如果这个项目没有编译过,那么我们所有的源文件都要编译并被链接
- 如果这个工程的某几个源文件被修改,那么我们只编译被修改的源文件,并链接目标程序
- 如果这个工程的几个头文件被改变了,那么我们需要编译引用了这几个头文件的源文件,并链接目标程序。
在正确编写 Makefile 文件的前提下,这三个优化目标是很容易达到的:Makefile 的语法并不是苛刻的,只是建议我们以一种合理的方式去写 Makefile,因为 make 会按照一种固定的逻辑去组织编译顺序,跳过一些无意义的重复编译环节。我们需要理解并利用 make 的执行逻辑,就可以达到这三个优化目标。
make 的每次执行,都会首先解析当前目录下的 Makefile 文件,然后才能明白需要执行哪些命令,当然我们也可以指定任何一个名称的文件被 make 所使用,例如
1 | make # 使用Makefile文件 |
Makefile 语法
准备语法
- 注释:井号
#
表示单行注释 - 回显:默认情况下,make
会把执行的命令(包括命令部分的注释)依次具体地打印出来,可以在某一行命令开头加上
@
关闭回显,make 就会静默执行它。通常会对注释和echo
语句关闭命令的回显,不然看起来很多余,而对其他的命令保留回显,这会让我们明白 make 到底干了些什么 - 通配符: Makefile 支持和 Bash
一致的通配符规则,例如
*.o
代表所有的.o
结尾的文件 - 忽略错误: make
会收集每一条命令的执行结果,来判断是否遇到错误,是否继续执行下一题命令,对于有的命令我们希望忽略它的错误,例如删除了不存在的文件,可以在命令开头使用
-
,代表忽略这条命令的错误,例如-rm *.o
1 | # 没有忽略的命令错误,可能会中断整个编译过程 |
编译目标
一个 Makefile 主要由若干个 target 顺序排列组成,每个 target 的语法结构如下
1 | <target> : <prerequisites> |
包括以下内容:
- 编译目标(target):既是这个子结构的标签,通常也是这一环节得到的目标文件
- target 既可以是一个文件名,也可以是多个文件名,中间用空格分隔
- 可以有多个子结构使用同一个 target,相当于把这 target 拆分成多个部分写在 Makefile 中。解析时会把这些部分合在一起进行处理,而不视作名称冲突
- 前置依赖(prerequisites):就是执行这个编译目标之前所需要的前置依赖文件,中间用空格分隔
- 命令(commands):就是具体要执行的编译命令
一个最简单的 target 如下,target 为 a.out,目标文件为
a.out,prerequisites 为 hello.c,commands
只有一条gcc
编译命令。
1 | a.out : hello.c |
注:
- 在本文中,尽量使用 target,prerequisites,commands 这些名称来强调在这个语法结构中的角色,避免歧义
- 在本文中,目标文件的含义为一个 target 结构中经过编译命令,最终产生的文件,例如.o 文件,可执行文件等,而非通常语境下的.o 文件
- 特别注意,命令前面必须是 tab,而非连续的空格,考虑到 tab
在一些编辑器中可能会被自动替换为连续空格,可以使用下列设置把命令的识别前缀改成
>
(写在 Makefile 中,最好是开头)
1 | .RECIPEPREFIX = > |
- Makefile 中的每一行命令都被送出一个单独的 shell
进程中执行,因此不能相互传递 shell
变量等,避免的方法是使用分号分隔符
;
在一行中写入多条命令,如果写在一起太长的话,还可以在第一行结尾加上\
表示续行,比如每一行命令以;\
结尾。如果不希望 make 为每一行命令单独开一个 shell 进程,还可以使用如下设置
1 | .ONESHELL: |
树形依赖关系
为了下文的方便,我们把所有的出现在 prerequisites 部分的前置依赖文件,都理解为一个自动产生的、无效的 target
1 | a.out : hello.c hi.h |
这里的[END]
是便于理解而加上去的,并不是合法的 Makefile
语法。并且 Makefile 实际上要求 target 不能为空:prerequisites 和
commands 至少要存在一个。
在这样的理解下,我们可以说:一个 target 的 prerequisites 只能由 0 个或几个 target 组成,所有的 target 会根据依赖关系构成一个有向图,其中节点为 target 或者无效的 target。
这里为了简化问题,我们不考虑有向图中存在回路的情形,只考虑一个大致的树结构:一般的节点(target)都只能指向一个父节点,但是允许叶子(无效 target)指向多个父节点。 在这个有向图结构中,每个节点(target)和叶子(无效 target)都有自己的非负权重,取决于这个文件的时间戳:更新时间越晚,权重越大,如果这个文件不存在,权重为零。
我们考虑一个稍复杂的例子
1 | edit : main.o kbd.o |
根据 prerequisites 自动补充几个无意义的 target:main.o,kdb.o,main.c,defs.h,kdb.c,command.h, 其中名为 main.o 和 kdb.o 的 target 我们已经具体给出了,因此合并到一起不再是无效的 target。对应的有向图结构为
执行逻辑
一般来说,make 的默认执行的 target 是 Makefile 中的第一个 target,这也是编译的最终目标,树的根节点。其他的 target 一般是作为它的 prerequisites 由这个目标牵连出来的。当然,我们也可以要求 make 完成指定的 target
make
: make 命令默认执行第一个 targetmake targetname
: make 会执行名为 targetname 的 target,例如make main.o
make 的执行逻辑: make 执行一个 target 时,会反向检索以当前节点为根节点的整个子树结构,然后从叶子往上一层层地执行每一个节点
- 对于叶子节点,由于是无效 target,没有具体的 commands 去执行,只会检查它是否存在,如果不存在会报错:make 既找不到它,也找不到生成它的方法
1 | No rule to make target X, needed by Y. Stop.; |
- 对于一般节点,也就是正常的 target,如果它存在,并且权重比所有指向它的节点的权重都严格地大,那么无需重复编译,直接跳过;否则就逐条执行它的 commands
伪目标
1 |
|
上面的 Makefile 文件定义了三个 target,但是这些 target 都不是对应子结构中真正的生成文件名,这些子结构也没有生成什么目标文件,这样的 target 被称为伪目标(phony target)。
使用.PHONY:
语句可以设置某个或某些编译目标为伪目标,.PHONY:
语句不是必须在
Makefile 的开头,例如
1 | .PHONY: clean |
伪目标的作用:make
不会试图查找一个名为clean
的文件,并且把它的时间戳作为权重,而是永远把这个
target 的权重设为 0。
不使用.PHONY:
设置通常也没事,但是如果 make 真的找到了于
target 同名的文件,就会错误地给这个 target 赋予一个权重,可能导致 make
跳过这个 target 不执行。 伪目标同样可以作为其他 target 的依赖。
Makefile 约定俗称的一些伪目标如下
- all 通常是最终目标,其功能一般是执行其它所有的目标,编译整个项目
- clean 通常是删除所有在编译过程中,被 make 创建的文件,也包括最终的可执行文件
- install 通常是安装已在本地编译好的程序,其实就是把目标可执行文件(或者库文件和附带的头文件)拷贝到指定的位置
Makefile 示例
如下是一个使用了多个源文件和头文件的 Makefile 示例,注释解释了每一个编译目标。
1 | edit : main.o kbd.o command.o display.o insert.o search.o files.o utils.o |
Makefile 进阶语法
自动变量
前文中的示例是一个标准的 Makefile 文件,但是存在一个问题:每次重复地把所有文件名写在 prerequisites 和 commands 中,太过繁琐。在 Makefile 中,支持如下的自动变量来简化 commands 的书写
$@
指代当前的 target,也就是这个环节最终产生的目标文件名$^
指代 prerequisites 中的所有项,以空格分隔$<
指代 prerequisites 中的第一个$?
指代 prerequisites 中比目标文件的时间戳更新的那些文件$(@D)
和$(@F)
对$@
拆解得到的编译目标存放的目录和编译目标的纯文件名$(<D)
和$(<F)
同上,只不过是对$<
拆解得到的目录和纯文件名
例如
1 | edit : main.o kbd.o command.o display.o insert.o search.o files.o utils.o |
自定义变量
支持和 shell 中类似的变量语法,例如
1 | txt = Hello World |
这里首先定义了名为 txt
的变量,然后使用${txt}
获取它的值,也就是字符串Hello world
,并输出。
但是基于字符串替换的变量赋值存在一个问题:何时,怎么样把一个变量名扩展为一个字符串,为了解决这个问题,make 使用如下四种赋值语句
1 | VARIABLE = value # 在执行时扩展,允许递归扩展 |
例如可以使用如下的语句,给变量 DEBUG 赋予默认值,但是如果 make 命令自带了 DEBUG 的定义,就不执行这个赋值,给 OBJ_DIR 和 OBJ_DIR_FOR_SUB 赋予了目录值,在执行时才展开
1 | # 区分是否使用debug模式,默认debug |
流程控制
以一个例子说明 if 结构
1 | DEBUG ?= 1 |
在这个例子中,如果 make 自带了 DEBUG 的定义就使用,否则 DEBUG 使用默认值 1,然后进入 if 语句,根据 DEBUG 的值分别进入一个分支,设置编译选项 CFLAGES 和目录 BIN_DIR。
同样地,还有 for 循环结构
1 | LIST = one two three |
子目录中的 Makefile
还有一个需要注意的点,就是项目拆分为多个子目录时,可以在每个子目录中使用 Makefile,在项目根目录中使用 Makefile 依次调用它们。 这里需要解决的问题有两个:
- 变量的传递,和 shell 中一样,可以使用 export 把当前的变量导出,这样在下层的 Makefile 中仍然可以解析这个变量
1 | OBJ_DIR = ${BIN_DIR}/obj |
- 进入子目录,例如
1 | # 获取所有需要进入的子目录 |
多层 Makefile 项目示例
这是我自己写的一个有限元多重网格法的实验,项目组织不太标准,但至少是 work 的,项目结构如下
1 | |-bin |
在项目根目录下的 Makefile(0)
1 | # Makefile(0) |
在 MyMatrixBase、MyOutput、MyCG 和 MyMG 的 Makefile 依次为
1 | # Makefile(1) |
在 src 下的 Makefile 为
1 | # Makefile(5) |
在 bin 目录下的最后两个 Makefile
1 | # Makefile(6) debug |
最后
随着软件发展的越来越复杂,时至今日,make 和 Makefile 也不够用了,写起来太长,而且需要考虑跨平台等适配性问题,使得编写 Makefile 过于复杂。 最终在 make 和 Makefile 的上层,又出现了构建工具 cmake 和 CMakeLists,cmake 重点解决的是跨平台问题。 我们可以通过编写 CMakeLists.txt,使用 cmake 自动生成需要的 Makefile,然后 make 根据 Makefile 生成具体要执行的 gcc 命令等,交给 shell 去执行。
一句话,这篇学习笔记的 make 和 Makefile 都白学了,哈哈哈哈。