Build System简述

什么是build system

在很多情况下,我们编写、修改的是源文件(譬如代码),但最终使用的却是目标文件(譬如可执行文件),那么必然存在从源文件到目标文件的转化过程。以我粗浅的理解,build system就是自动化完成这一过程的工具(集)。当然如今的build system兼有依赖管理、分发部署等扩展功能,但本文着重于其发明之初所针对的问题:编译自动化。

最朴素的构建流程:手动输入编译命令

以C代码为例,假设我们的源文件是A.c,而希望得到其编译之后的结果A。最为直观的方法便是在终端中输入命令,譬如:

gcc A.c –o A

gcc是c的编译器,-o选项用于指定生成的可执行文件的名称

每当我们修改了A.c,只要输入并运行上述命令,就能得到我们期望的结果了。看起来不过是短短一行命令就能搞定的事。

自动化的第一步:使用shell脚本

问题在于,所谓“短短一行”的命令可能会变成“长长的一行”。实际应用中,我们会向编译器传递一些参数来检查潜在的错误和优化生成的结果。比如这样:

gcc A.c –o A -Wall -Wextra -Wconversion -fno-builtin -fno-stack-protector-fno-asynchronous-unwind-tables -O3 -march=native

无须细究这些参数的作用,但估计你现在很不情愿完整地输入这么长一串。如果每次修改完A.c都要这么写一遍,实在是太浪费时间了。就算你的终端提供了历史记录功能,但如果你中途切换到了别的工作上,等回头翻找历史记录也是件麻烦事。

一个直观的解决方法是新建一个shell脚本build.sh,将上述命令写进该文件,这样每次只需要键入./build.sh即可。

基于依赖关系构建

但好景不长,随着编写规模的增加,现在我们拥有了两个源文件A.c, B.c,目标输出变为了AB。很容易从命名上看到,AB是由A.cB.c联合编译后得到的。而build.sh中的内容也变成了:

gcc A.cB.c –o AB <...more args...>

​ 这样的写法其实存在问题:假如我们仅仅改变了A.c的内容,但在重编译时却会把B.c扯进来一起编译,浪费了不必要的计算资源和时间。事实上,在涉及多个文件联合编译的时候,我们通常会把编译和链接分开来:

gcc A.c -c -o A.o <...more args...>
gcc B.c -c -o B.o <...more args...>
ld A.o B.o -o AB <...more args...>

-c选项表示生成编译未链接的结果,最后由ld完成链接的任务

​ 这样当我们仅更改了A.c的时候,只需要重新编译生成A.o,并用新的A.o和旧的B.o链接生成AB即可,省下了编译B.o的时间和算力。

​ 但这样一来,我们就不得不为每一条命令单独写一个shell脚本,然后人工判定哪些文件需要重新生成。判定的规则取决于依赖关系。我们称AB依赖于A.oB.o,而后两者又分别依赖于A.cB.c。一旦被依赖项有所更新,必然导致依赖项连锁地更新。

​ 这样一来,问题好像又变得复杂了。特别要注意,本文一直刻意忽略了A.hB.h。事实上,他们同样是对应.o文件的依赖项。更糟糕的是,头文件的引用会造成依赖项的增加,比如在B.c中#include<A.h>,那么A.h也成为了B.o的依赖项。

随着文件数目的增加,依赖关系可能会变得极其复杂,以至于人工管理变得完全不现实;这时候需要一套专用的工具来自动化地解决这个问题,也就是我们的build system。

build system的老将:make

make最初来源于Steve Johnson,据说这人就是被手动依赖管理给坑了,漏编译了一个文件,结果白花了半天去调一个并不存在的bug。忍无可忍的他写了一个自动依赖分析器make,用户只要给出每个单独文件的依赖,它就能生成得到完整的依赖关系,并在任何文件更新时,调用预设的命令重新生成依赖项。

这些依赖的声明基于make的语法,通常写在名为makefile的文件中。如前述例子对应的makefile就可以写成:

AB :A.o B.o
    ln $^ -o $@ <...more args...>

A.o: A.c A.h
    gcc $< -c -o $@ <...more args...>

B.o: B.c B.h
    gcc $< -c -o $@ <...more args...>

不必细究具体的语法,可以明显看到<依赖项>:<被依赖项>的声明形式,并紧跟着shell命令用于更新依赖项。一旦写完makefile,之后的编译构建就只需要输入一句

make

便能将所有的一切轻松搞定。

如果依赖关系发生变更,譬如B.c#include<A.h>,修改起来也非常简单:将第五行改写为B.o : B.cB.h A.h即可。

新的挑战:让我们构建makefile吧

make极大地简化了构建目标文件的流程,只需要写出正确的makefile,整个构建流程只需一句make就能轻松搞定。然而新的问题是,如何写出正确的makefile呢?

​ 当文件数目进一步增加,单文件的依赖关系进一步复杂(考虑到include的头文件还会include别的文件),人工维护makefile开始变得吃力且不可靠。

​ 于是历史再次重演,现在我们需要另一套自动化工具来生成makefile。这套工具主要解决两方面的问题:一、直接从源代码中分析和派生出完整的依赖关系;二、增强可移植性,针对具体的硬件和系统生成针对特定平台的makefile[1]

​ 在这些工具中,比较常用的有makedepend, Imake, autoconf, automake,cmake等。接下来简单介绍autoconf和cmake。

autoconf:填充makefile模板

autoconf主要解决了可移植性的问题,但并没有实现依赖分析和派生。所以依然需要用户事写好makefile——严格来说是makefile模板,因为和运行平台相关的地方可以留空。

接下来我们需要为每个项目生成一个configure shell。当用户在某个具体的平台上执行该脚本时,autoconf便会配置出一些shell代码去搜索当前系统的信息,检查软硬件配置是否符合编译的最低要求,并把所需的信息填充到makefile模板的留空处,从而生成得到完整的makefile文件。一旦生成得到适用于当前平台的makefile,那么再一次,只需要执行make就能完成整个构建流程。

所以大家会经常看到如下三句构建命令:

./configure
make
makeinstall

其中的./configure就是运行configure shell来填充makefile模板。

cmake:构建buildsystem脚本的强大工具

autoconf非常好用,而且曾经流行一时(即使是现在也很常见),但它有着明显的缺点:没有做到自动派生依赖关系,makefile模板还是需要自己写。

然而cmake(cross platform make)做到了这一点。cmake使用自己的一套语法来从高层次描述一个目标文件所需要的内容(源文件、库等),然后自动分析源文件间的依赖关系,动静态库在系统中的路径,测试编译器特性等信息,最终综合出build system脚本。这样一来,用户终于从底层复杂的依赖关系和文件细节中解脱出来,只要关注构建流程的头尾两端:我有什么,我希望得到什么。

注意这里所描述的生成结果是build system脚本而非makefile,因为cmake支持多种build system,make只是其中之一,另外还有微软的Visual Studio的项目等,从而赋予了cmake构筑各平台原生build system的能力,提供了良好的抽象能力和可移植性。

从开头就提到,本文着重论述的是build system在编译自动化中的发展和应用。在该领域中,它有效地减少了手动管理依赖的负担,避免了不必要的编译,同时增加了可靠性,并拥有对并行编译的支持,是软件开发流程中的重要工具。

当然,既然叫做build system而非compile system,它在编译之外还可以用来做很多事,小到文件同步、格式转换,大到集群构建、分发部署,都有着其应用场景,并由此衍生出很多专用工具(集),在此就不再展开了。


[1] 15.4.4节,《UNIX编程艺术》EricS. Raymond著,姜宏 等 译, 电子工业出版社

内容版权声明:除非注明,否则皆为本站原创文章。

转载注明出处:http://local_ubuntu/blog/?id=6

我要评论

◎欢迎参与讨论,请在这里发表您的看法、交流您的观点。