什么是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.c
和B.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.o
和B.o
,而后两者又分别依赖于A.c
和B.c
。一旦被依赖项有所更新,必然导致依赖项连锁地更新。
这样一来,问题好像又变得复杂了。特别要注意,本文一直刻意忽略了A.h
和B.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著,姜宏 等 译, 电子工业出版社
我要评论