Makefile简明介绍

现在,只有非常小的项目,才会有人手写Makefile。 当然,例外还是有的,那就是Android(6.0以前)。

不写Makefile,不代表不需要去了解它。 实际上,对Makefile的理解,可以延伸出对整个软件编译流程、乃至持续集成系统的理解; 对Makefile技术的扩展、重构、发展,衍生出了当代实际使用的其它构建工具。

本文仅介绍Makefile最核心的东西,以及一些个人领悟。

Makefile是一个DSL

Makefile其实是make程序的配置文件,只是比XML、YAML这些东西复杂得多。 Makefile有其独特的语法,可以调用Shell,支持复杂的逻辑判断,但还不是图灵完全的一门编程语言。

所以,Makefile是一个DSL(Domain-Specific Languages,领域特定语言,由于这个通用翻译的水准较低,所以一般还是称为DSL)。

Makefile控制编译流程

作为一种DSL,Makefile的领域就是控制软件编译流程。

对于一个单文件C语言代码,比如hello.c,编译是比较简单的。

gcc hello.c -o hello

而如果文件数目增多,互相之间还有复杂的编译依赖关系,手动执行gcc就变得繁琐、易错。 用Shell脚本来做,是一个直观而且可行的方案。 不过,一个常见的问题是,当一个项目有大量文件,却只有少数几个发生改变时,如何进行增量编译? 难道每个中、大型项目都需要一个复杂的Shell编译脚本,并且都实现一个判断时间戳与依赖的功能?

于是,Makefile应运而生。

Makefile诞生的目标语言,是C语言。 而后,凡是静态类型,或者说需要编译的语言,都可以使用Makefile来组织项目。

值得一提的是,常见的静态类型语言中,Java是个例外。 Java的构建工具,从一开始就超越了Makefile,向着更深层次而发展。 因为,JDK中的编译器javac,其实已经自带了Makefile的核心功能。

Makefile的核心功能

专为控制软件编译过程而生的Makefile,其实只做了两件事。

  1. 管理编译依赖,决定编译顺序。
  2. 根据时间戳变化与依赖关系,实现增量编译。

以下举一个简单的示例:

file_a: file_b file_c
	touch file_a

file_b: file_d
	touch file_b

file_c: file_d
	touch file_c

file_d:
	touch file_d

这就是一个简单的Makefile。 file_a: file_b file_c就是说,需要得到file_a,先要有file_bfile_ctouch file_a是一个实验性质的手段,即产生一个空的file_a,或更新其时间戳。 总体来看,这是一个菱形依赖,A依赖于B、C,B、C又都分别依赖于D。

执行结果如下:

$ make
touch file_d
touch file_b
touch file_c
touch file_a
$ make
make: 'file_a' is up to date.
$ touch file_b
$ make
touch file_a

解释一下现象:

Makefile是一门发展多年的古老DSL,扩展出了很多功能。 但万变不离其宗,这就是它的核心功能。

基本概念

核心功能相关的概念,有四个(中文翻译仅供参考):

以下整个Makefile代码块,可以看做一个Rule。

targets : prerequisites
        recipe
        …

参照前面的样例,file_a: file_b file_c里的file_a就是Target, file_bfile_c就是Prerequisite, 而touch file_a就是产生file_a的Recipe。

第一个Target,就是默认Target。 如果需要改变,比如改成file_b,可以添加一行:

.DEFAULT_GOAL := file_b

默认情况下,Target都是文件的路径,可以是相对路径,也可以是绝对路径。 当然,通过内置的手段,可以让Target是文件夹,或者是非文件的Phony Target(虚假目标)。

另外,Prerequisite分两种。一种是普通,一种是有序。

targets : normal-prerequisites | order-only-prerequisites

|后面的Prerequisite,将严格按照排列顺序来产生,而普通的则无所谓顺序。

总之,再复杂的Makefile,都是这种形式的Rule组合而成。 理解了核心功能与基本概念后,Makefile已经没有什么难点,只剩细节了。

make命令

GNU make是执行Makefile的最常用软件。

通常,一个开源项目只需要两行就可编译、安装。

make
make install

其中,make是编译整个项目,make install是安装。 单单一个make就能编译,这需要Makefile的配合,让默认Target就是项目编译。 install是一个自定义的Phony Target,默认不存在,却是一种常用约定。 类似install的约定,还有uninstallclean等,需要在Makefile中自行实现。

除了常用的Target约定,make本身也有一些常用的参数。

$ make -h
Usage: make [options] [target] ...
Options:
  -B, --always-make           Unconditionally make all targets.
  -k, --keep-going            Keep going when some targets can't be made.
  -f FILE, --file=FILE, --makefile=FILE
                              Read FILE as a makefile.
  -h, --help                  Print this message and exit.
  -j [N], --jobs[=N]          Allow N jobs at once; infinite jobs with no arg.
  -p, --print-data-base       Print make's internal database.
  -q, --question              Run no recipe; exit status says if up to date.
...

上面只列出了帮助文档的一部分,完整内容可以自行查看。

-B是一个不太常用、但能救急的命令。 它强制完整编译整个项目,相当于禁用了增量编译。 在出现一些奇奇怪怪的编译问题时,可以用这一招再试一下。

-k是在编译出错时,尽量编译更多内容,触发所有错误,而非一出错就停下来。

-f是指定Makefile。 通常,make命令会默认使用当前目录下的Makefile文件,其次是makefile,如果都没有则会报错。 特殊情况下,可能Makefile文件需要命名为其它名称,或在其它目录,这时就可以以-f指定。

-j可以指定同时启动多少条线程来执行并行编译。 而并行编译的前提,就是判断有哪些文件的编译是可以并行的,这一点可以从Makefile自动判断。 前面的例子中,只有file_bfile_c是可以并行的,所以最多只需要-j2即可。 对大型项目来说,可以设置为最大,即CPU的核数。

-q会不执行Recipe,相当于dry-run。 -p会打印完整的make过程到命令行。 打印内容包含了很多默认的规则、环境变量等,至少1000行,不推荐直接输出到终端。 两个命令通常一起使用,make -qp > qp.makefile, 这样做可以合并多个Makefile,分析其中的细节。

相关资料

详细的Makefile资料,可以参考GNU make文档。

可以执行Makefile的其它程序

生成Makefile的程序

首先是GNU Autotools,一套Unix下的Makefile的生成工具。 以下是使用这套工具的流程图,从中可以感受到智慧与岁月。

Autotools的流程图

然后是CMake,一个跨平台的Makefile生成工具。 目前,它已经成为这类需求的主流工具。

这类工具的作用,是在更高的层级上去控制编译及相关流程,以实现复杂的操作。 而Makefile,只是它们的产物。

有趣的是,为什么一定要产生Makefile呢? Makefile真正核心的功能,其实并不复杂。 既然已经做了一个复杂的上层工具,为什么不干脆另起炉灶? GNU的思路是从UNIX哲学出发,用简单的工具来组合出复杂的用途,这个可以理解。 而另起炉灶的思路,也已经走通了,代表作就是Ninja


相关笔记