《Makefile 光学教程》之面向 Makefile 编程

此教程将计划以两部分内容呈现,目标是从零基础到 GNU make 最本原理的掌握:
🐣 Basic Concepts
🐣 Demo Projects
🐣 Basic Concepts
按照 GNU m4 宏编程经验, Macros 即代码生成工具,输入输出都是字符串,输入字符中所有宏符号都会被相应的宏定义内容替换。但是 make 作为一种宏编程工具,有些功能差异,它并不像 GNU m4 这种通用的宏编程工具,出于约束它的灵活性同时降低使用风险,make 增加了许多约束条件,比如在 Target 规则之外不能使用宏输出内容。
GNU m4 作为一个通用的宏编程工具,它的核心概念就是字符串流,输入输出都是字符串流。宏定义的功能就是替换输入流中匹配的字符串,再将替换后的数据发送到输出流,这个过程称之为宏展开 macro expannsion。
比如,以下是一个 m4tutor.m4 脚本,即字符串文件,它将作为 GNU m4 宏处理器的输入流:
宏处理器处理字符串输入流唯一规则就是按宏定义进行内容替换,最简单的宏定义就是使用 define 指令,或者直接通过命令行定义宏符号,如下:

那么这个执行过程就是:读取输入流中的第一行,得到一个宏定义,最后可以输出的只有换行符。然后读取第二行内容,是一个字符串,刚好 say_hello 这个字符串对应一个宏定义,那么替换它得到 Hello World! 包含换行符,然后再输出。最后读取到一个字符串 cc,同样它对应一个宏定义,从命令行中传入的宏定义,同样要替换,得到 List(1,2,3)。整个宏脚本输出内容如下:
以上就是宏编程的一个基本流程概念:字符串替换!Makefile 脚本编程也适用这一基本原理。
Makefile 宏编程核心概念有两个,*Target* 和 *Rule*,其次是指令 directive 用于实现 make 脚本功能的内置宏。另外就是附加的一些宏脚本编程能力,比如变量、宏指令、宏参数、include 其它脚本、Secondary Expansion 二次展开,以及各种特殊功能符号等等,它们功能上都类似 GNU m4 的宏替换过程。Makefile 规则定义就是描述如何生成 Targer 之间的逻辑关系,也就是 Target 之间可以形成的依赖网络。
默认配置下,recipe 部分编写的命令内容必须使用 Tab 符号作为行首字符。可以使用 .RECIPEPREFIX 内置变量来定义。但这个功能不一定所有 make 都支持,在不支持的情况下就会提示错误信息:missing separator.
Makefile 最基本的能力就是根据 Taraget 所设置的依赖决定是否需要执行 recipe 中编写的编译命令来生成最新的程序。这其中涉及了文件更新时间戳的检测行为,包括 Target 命令匹配的文件,以及规则中冒号右侧指定的依赖文件。
当然,不是所有规则都需要对应文件,像以上示范中的规则,一个命名为 all 的目标,没有任何依赖文件,它本身也不对应磁盘中的文件,这条规则只需要在命令行中执行一条 echo 命令,打印一段字符串。
依赖关系链由 Target 与先决条件 prerequisites 之间的联系产生,因为先决条件中的任何项都可以被定义为 Target,也就形成了 A_Target -> Prerequisite -> B_Target -> Prerequisite 这样的链条。当执行 make A_Target 时,根据依赖链,会一起递归到最尾端的 Target 并执行其规则定义的 recipes,然后逐级返回执行上一层的 recipes,直到 A_Target 的部分。
但是,只要这链条中间任何一环节破坏,Target 命名与上一层的先决条件名称不匹配,那么后面的 Target 定义即失效。除非调用 make 命令时,直接指定那些处于断链状态的 Target。依赖关系的判断,是根据宏扩展后的结果进行的,所以定义规则时,可以在规则中的 Targets 或先决条件中使用任意的宏函数,来灵活地构建依赖关系网络。
本质上,Makefile 就是一个描述依赖关系的脚本,例如如下一个 `Makefile` 规则定义:
先抛开 $ 宏调用等特殊功能符号,以上这个 Makefile 它描述的是以下这样的依赖关系,最终是构建出 all 这个目标,它代表要链接各种目标文件的可执行程序。整个依赖关系网络由规则定义,链接命令由依赖关系推断。make 命令知道扩展名为 .o 的目标文件的处理,以及如何调用 C/C++ 编译器和链接程序,通过 $(CC) 和 $(LEX) 分别调用 C/C++ 编译器和 lex 词法解释器生成命令,两个外部命令完成相应的编译工作。这种自动推断能力就是 make 的隐含能力,具有隐含功能的规则定义也就称为 Implicit Rules,参考手册 2.5 Letting make Deduce the Recipes。除非需要,开发者可以指定编译器的各种参数以修正默认的配置:
这里的 Makefile 编写了两条旧风格的 suffix rule,即通过后缀识别行为/定义的规则,包含 double-suffix 和 single-suffix 两种。其中 .c .o .l 三个都是后缀,对应了 C 语言源代码、目标文件和词法规则分析器三种源文件。这种连续使用 source suffix 和 target suffix 后缀的形式就是 double-suffix,也即是双后缀形式的规则定义,从其执行结果可以知道这种规则就是将前 source 文件处理成后 target 文件。Single-suffix 则是保留 source surfix 文件后缀。
Make 和 GNU m4 一样默认使用 # 作为注解符号。另外,如果行内容超长,可以在先进性行尾使用斜杠 \ 转义换行符号,便后一行内容与前一行内容拼接起来成为一行,即相当于断行连接。
一般的 Makefile 规则以冒号为分界,*Ordinary Rules*,左侧表示输出称为 `Target`,可以有多个输出,它本身就是一般的没有隐含功能的字符串标识,右侧表示输入称之为依赖或者先决条件。更复杂的规则可以参考官方文档,Complex Makefile 示例中有完整的规则参考。语法中的 recipe 单词为食谱、处方,也是规则实现、促使规则达成的意思,就是定义构建目标时要执行的命令。像以上这种规则,因为 Target 部分字符具有特殊功能的规则,称之为隐式规则 *Implict Rules*,与之对应的就是显式规则 *Explicit rules*,普通规则就是显式规则。
Implict Rules vs. Explicit rules,弄清楚隐式规则与显式规则的区别,是深入掌握各使用 make 的必要条件。
要区分什么规则是显式或者是隐式,根本上来说有一个参考标准:就是会不会触发 GNU make 内置的规则,如果会触发内置规则,那么就可以认定是隐式规则。内置规则之所以称为隐式规则,是因为它们不会用户在 Makefile 脚本中将这些规则编写出来,make 会根据目标的扩展名或者磁盘对应名称的文件类型去调用相应的内置规则。这个过程是隐式的,不透明的(Makefile 脚本没有相应规则定义)。参考手册 10.2 Catalogue of Built-In Rules。
所有内置规则可以通过 make -p 命令查询,它们涉及以下编程语言或者文件类型:
所有隐式规则涉及的文件扩展名可以通过 .SUFFIXES 内置变量中列表。
以下脚本演示了各种经常出现的规则定义形式,在这里它们都可以认为是显式规,除了最后一个注解掉规则。依赖关系链上没有不清晰的环节,所有依赖,从 all 到各个 .lex 文件的依赖都有定义,所以不会触发 GNU make 内置的规则,因此它们可认为是显式规则。
如果 all 只与最后一个注解掉规则 a.tex 搭配使用,那么就会触发 make 内置规则,这就有两种可能的结果:一是磁盘有相应的 .lex 文件,make 自动应用隐式规则的命令;二是缺失对应磁盘文件,Makefile 又不能提供完整的依赖关系,缺失了 b.lex 和 c.tex 两个依赖的目标规则,所以提示错误信息:No rule to make target 'b.tex' 等等。**因为其中一条依赖构建失败,那么就会导致上层目标构建失败,all 目标中的命令就不会执行。**
为何让最后一条规则也“显式”起来,那么可以搭配 %.tex 这样的模式匹配规则、静态模式匹配规则,或者使用变量等等方式来解决大量文件依赖关系的处理。注意:使用这些灵活的规则非常容易触发隐式规则,并且 Makefile 可以使用 inlude 指令来引用更多的脚本文件,这会使得脚本变得异常复杂。
其中,Static Pertern Rules 是非常特别的一种模式匹配规则,它不像其它规则(包括一般的模式匹配规则)可以不指定依赖,静态模式匹配规则就是为了批量处理依赖设计的。使用模式匹配符号 % 可以将匹配到的内容(stem)替换到依赖列表中的名称中形成新的依赖列表。
另外一个问题是规则的优先级问题,以下提供参考,其中前两条是定义总结:
1. Literal 字面量定义方式优先于 Double-Colon Rules,优先于 Pattern Rules;
2. 同类形规则,Target 命名使用的字面量字符越多越优先,即信息精细度高;
3. 单冒号规则中,新定义会覆盖旧定义,但保留依赖列表,只替换命令块;
4. 双冒号规则各自独立执行不存在定义覆盖,但是 Target 不能同时定义单、双冒号规则;
多目标规则与单目标规则不同,多目标规则中不能混用多种匹配方式,只使用字面量匹配,或者只使用模式匹配。否则就会得到 mixed implicit and normal rules 警告提示。
因为 Static Pertern Rules 会形成新的依赖列表,单独考虑。双冒号规则参考手册 4.13 Double-Colon Rules。
如果脚本中规则依赖已经存在定义,但是没有被执行,那么最有可能的原因有二:
1. 一是可能是优先级没处理好。
2. 二是依赖的目标文件已经存在对应的磁盘文件。
解决方法就是重新整理规则定义,对于磁盘文件已经存在,但是还要执行构建命令的规则(通常不需要这样做),就可以使用 .PHONY 内置目标规则,它会忽略磁盘文件的状态信息,无条件地执行命令块。
隐式规则中涉及文件搜索的算法过程参考手册:
10.4 Chains of Implicit Rules
10.8 Implicit Rule Search Algorithm
对于复杂的 Makefile 脚本的调用有两大法器:
1. 注解!使用注解可以最大程序地缩小 Makefile 的复杂度;
2. 清空!将脚本运行目录下的文件清空(使用新卡件夹)再运行;
3. 调试!GNU make 使用 -d 或者 --debug[=FLAGS] 参数打印解释器运行状态;
调试信息输出参考如下,它们完整打印从 make 加载隐式规则到目标文件检测的解释过程:
Make 在处理 makefiles 脚本依赖关系(include)期间,会触发 Remaking,涉及 MAKE_RESTARTS 内置变量值的更新。然后再处理构建目标的规则定义与执行命令块,这就是 make 命令执行的两个主要阶段。
提炼一下调试器输出信息,大体可以总结出以下几个步骤,以下展示一个目标及其一个依赖目标的构建过程,从文件搜索检测、到依赖目标的搜索检测,最后创建新进程构建目标:
很巧合,打印调试信息时出来一个 731,这是多么令人恐怖的数字啊!