这两个月,我做开发的一个重头戏,就是给Zyn-Fusion重写编译系统。它是强悍的开源合成器ZynAddSubFX的全新界面,酷炫性和专业性并不比Serum、Sylenth这些老牌合成器逊色,有望成为电子音乐人的新宠。然而原作者设计的编译脚本(基于Shell和Ruby)在设计上存在不少问题,导致在Windows(Msys2)和Arch Linux下常常编译失败,因此我决定用Makefile重写编译系统。
就在这过程中,由于Makefile本身具有大量容易被忽略的细节,我在实践的过程中频频踩坑,这也是用Makefile组织有一定规模的编译系统时很容易出现的问题。记在这里,让有幸看到的小伙伴别踩坑了。
阅读之前先来个术语解释
- 目标(Target):Makefile中用于执行特定任务、生成特定文件的一组程序块,以特定的标识符(目标名加半角冒号)开始,碰到下一个标识符或Makefile语句(如赋值语句、
include
)结束。 - 配方(Recipe):目标中用于执行具体操作的程序语句,为shell命令,统一以一个制表符开头。
第一坑:缩进问题
除了Python,Makefile是另一个严格要求缩进的编程语言,把缩进视为语法的一部分。更重要的是,Makefile的缩进有这样的特殊规则,堪比大坑:
- 缩进只允许用Tab(制表符),不允许用空格。
- 缩进后的语句一律视为编译目标的一部分,作为shell语句解释。
因此,在Makefile中,用代码缩进增强可读性的方式不管用了。
举一个简单的例子:根据变量ENABLE_OPTIMIZATION
定义与否,给CFLAGS
赋不同的值。注意ifdef
分支下的语句是没有缩进的。
1 |
|
然而,一些像我当时一样的新手可能会觉得可读性不给力,于是习惯性地加上缩进:
1 |
|
这样做会存在一定的风险。
其一,如果上例放在文件开头,在一些Make版本中不成问题。我目前的Make版本(4.3
)仍会正常识别缩进后的赋值语句,而不解释为shell语句。比如这个完整的Makefile
:
1 |
|
在终端中运行,结果如下:
1 |
|
可见结果正常。但无法保证其他版本的Make能正常工作。
其二,如果上例放在目标当中,条件分支语句仍能正常解析,但被缩进的赋值语句已经被解释成了shell语句,出错是必然的。
略微修改“其一”中的代码清单,改成:
1 |
|
在终端中运行,结果如下:
1 |
|
可见,Make把那两句CFLAGS += ...
当作一个配方来解析了,不会给变量CFLAGS
赋值。
第二坑:不能在目标中使用赋值等Makefile语句
接着“第一坑:其二”中的例子,如果要在运行目标all
时通过条件判断给CFLAGS
变量赋值,似乎去掉赋值语句前的缩进就可以了:
1 |
|
但在运行后,却出现了这样的错误:
1 |
|
这是因为在一个目标之下只能出现配方,而不允许掺杂出现不属于配方的语句(例如赋值语句、include
语句)。条件分支语句虽也允许使用,但在目标中它只能用于控制在不同条件下执行哪些配方。在编写Makefile时应当尤其注意这一点。
第三坑:赋值语句的作用域
其他编程语言中,变量定义没有溯及既往的效力,即定义一个变量后,该变量定义之前的程序代码是不能使用该变量的。然而,Makefile却有,这非常类似于JavaScript函数的“先使用后定义”。且看下面的例子:
1 |
|
按理来说,变量VAR
定义在目标all
之后,运行make all
应该什么也不会输出。不过,Makefile的变量允许先使用后定义,所以运行all
时,变量VAR
已经被赋值了,最终是有输出的:
1 |
|
第四坑:变量赋值运算符=
和:=
这个坑,先用两个实例来抛砖引玉。
例一:设计Makefile时,如果要判断当前的系统版本,可以调用系统命令uname
,这里假设用下列变量来存贮该命令的输出,并尝试在它们当中搜索字符串"Linux"
:
1 |
|
运行结果如下。可见,只有UNAME_4
的值能最终被$(findstring)
函数正常识别并工作:
1 |
|
例二:在定义变量A时,有时需要先用其他的变量B、C给该变量赋值,但B、C的值不能马上确定。Makefile允许先使用后定义变量,但运算符的不同会影响到预期结果的实现。见下例:
1 |
|
最终输出结果如下。可见,只有使用了=
运算符的BUILD_TARGET_2
得到了正确的值。
1 |
|
从上述两个例子中不难推知,运算符=
和:=
在变量赋值的过程中作用千差万别。
如果定义变量A时使用了其他的变量B,那么:
:=
运算符只会把变量B简单展开一次(simply expanded)。这要求变量B必须先定义。=
运算符则会递归展开(recursively expanded)。这允许变量先定义后使用。
注意:
- 如果想给当前变量的值添加新字符,则必须使用
:=
,例如CFLAGS := $(CFLAGS) -O3
。这种情况下,使用=
会导致死循环。- Make版本更新到
4.3
后,似乎无论是:=
还是=
,都能够用来以函数的输出结果给变量赋值了。此前只能使用=
。
第五坑:如何保证不再重新生成一个目标(例如文件下载)
Make能够判断一个目标是否应该重新生成:如果源文件进行了修改,或目标文件被删除,则重新生成;否则保持原样。这个特性能够确保在只修改个别文件时,不至于一股脑儿全都重新编译,有助于保证程序员的工作效率。
但是,上述特性似乎只适用于编译,对于一些特殊的场合则不管用。例如编写一个下载文件的目标,让Make在检测到文件存在时不再重新下载:
1 |
|
然而事实上,Make总是会无条件重新生成这个目标,也就是总会重新下载test.gz
这个文件。实践中,这非常不利于资源利用,同时给网络环境不理想的用户造成了麻烦。
有鉴于此,我们应该在该目标中,手动判断文件是否存在,如果不存在则重新下载。修改上述代码清单如下:
1 |
|
这里,$(wildcard)
函数返回一个文件列表的字符串,可用来判断文件是否存在,不存在则自然返回空值。条件判断语句ifeq
比较两个表达式是否相等,第一个表达式为空。连起来,则是判断$(wildcard)
是否返回空值,一旦返回空值,则目标文件不存在,应当重新下载。
到此,Make不至于反复重新下载我们需要的文件了。
注意:一般情况下
wget
会在下载出错时删除未下载完成的文件,但有时却不会删除。因此还需自行编写检查文件有效性的代码。
第六坑:在Makefile编译目标中使用cd
等Shell命令
在编写一些目标时,有时会需要切换到一个特定目录下进行操作,比如运行某个子目录中子项目的configure
:
1 |
|
但是上面这种写法,一旦运行就直接提示“找不到./configure
”。这是因为Makefile的目标中,每一个配方都被视为一个独立的shell脚本,在不同的shell会话中运行,以便处理并行任务。
也正因如此,在使用cd
等要在同一个shell会话中运行的命令时,应当把它们写在同一行中。就像这样:
1 |
|
也可拆成多行,用反斜杠\
断行。在书写多条命令或较长命令时会显得非常清爽。
1 |
|
第七坑:调用子Makefile
有些大型的项目包含若干个子项目,每个项目都有自己的Makefile
。如果想要调用子项目的Makefile
,正确的做法并不是使用include
语句,而是用make
命令。
比如,项目Test
的结构如图所示,包含两个子项目doc
和src
:
1 |
|
如果想编译doc
和src
,则在Test/Makefile
中编写这样的目标:
1 |
|
即,运行一个新的make
程序,切换到对应目录下并读取其Makefile
。$(MAKE)
为内置变量,指向用于运行当前Makefile的make
命令路径。
对了……还有!
如果想参考我的实践成果,或是自己编译Zyn-Fusion来玩音乐,不妨移步我fork的Repo:
- 本文作者: 爱拼安小匠
- 本文链接: https://anclark.github.io/2021/02/09/Programming_Tips/Makefile_踩坑记/
- 版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-ND 3.0(署名-非商用-禁止演绎 3.0) 许可协议。转载请注明出处!