卷1:第5章 CMake

原文链接:http://www.aosabook.org/en/cmake.html

作者:Bill Hoffman, Kenneth Martin

1999年, 国家医学图书馆(National Libray of Medicine)雇用了一个称为Kitware的小公司,为支持复杂软件的跨平台配置,构建和发布来实现一个更好的解决方案。这个工作是ITK(一个医学开源可视化软件,Insight Segmentation and Registration Toolkit)项目的一部分。作为ITK工程的主导单位,Kitware负责开发一个供ITK项目研发人员使用的软件构建系统,这个系统必须易于使用,并尽量不占用开发人员编程之外的时间。基于这个指导思想,CMake需要基于已有工具扬长避短,并能够取代古老的autoconf/libtool方式。

经过多年的发展,CMake从最初的软件构建系统演变成一个开发工具系列:CMake, CTest, CPack和CDash。CMake负责构建软件。CTest是测试驱动工具,用于回归测试(regression tests)。CPack是打包工具,将CMake构建的软件发布成面向不同平台的安装软件。CDash是一个Web应用程序,用于执行持续的集成测试并显示测试结果。

5.1 CMake的历史和需求

在开始开发CMake时,项目管理常见的做法是,在Unix平台上使用configure脚本和Makefile文件,在Windows平台上使用Visual Studio工程。这种构建系统的双重性使得跨平台开发变得十分枯燥,即使在工程中添加一个简单的源码文件都是非常痛苦的事情。开发者们希望能够拥有一个统一的软件构建系统,而CMake开发人员在这方面经验丰富。历史上,他们使用过两种方法来解决这个问题:

一种方法是1999年开发的VTK构建系统。在这个系统中,Unix系统中使用configure脚本,而在Window系统中使用一个叫做pcmaker的可执行程序。pcmaker是一个C程序,它通过解析Unix Makefile文件来生成Windows下的NMake文件。 pcmaker的二进制可执行程序后来被签入到了VTK 的CVS系统仓库中。虽然从某种意义上讲,这是一个统一的构建系统,但是其缺点是显然的。 对于一些常见的情况,比如添加一个新的模块,都需要修改pcmaker的源码,然后再更新其系统仓库中的可执行文件。

另外一种方式是为TargetJr开发的基于gmake的构建系统。TargetJr是一个C++编写的计算机可视化环境,最初在Sun工作站上开发。一开始,TargetJr使用imake构建系统来创建Makefiles。但当有一天需要移植到Window时,就不得不开发出另外一个gmake构建系统。gmake构建系统同时支持Unix编译器和Windows编译器,但在使用前需要设置一些环境变量,否则,用户特别是终端用户容易产生一些难于调试的错误。

这两种方法都有一个严重的不足: 它们要求Windows开发人员使用命令行。然而,熟练的Windows开发人员更倾向于使用集成开发环境(IDE),他们还是会选择手动生成IDE文件然后添加到工程中去,使得构建系统又重新退化成了"双系统"。除了缺乏IDE支持,上述两种方法也使得合并第三方软件的项目变得非常困难。比如,VTK中罕有图片加载模块,主要是因为其构建系统难于利用libtiff和libjpeg等第三方库。

因此,开发ITK和其它C++软件都需要一个新的软件构建系统。 这个新构建系统必须满足一些限制条件:

  • 对平台的唯一的依赖: 操作系统中需要安装C++编译器
  • 能够生成Visual Studio IDE输入文件
  • 易于创建基本的构建系统的目标文件,包括静态库,动态库,可执行文件,插件。
  • 能够运行构建时的代码生成器
  • 支持源码树和构建树的分离
  • 能够执行系统"自省"(introspection),即能够自动判断目标系统能够做什么,和不能够做什么
  • 能够自动扫描C/C++头文件的依赖关系
  • 所有特性对所支持的平台一视同仁

为了避免依赖于三方软件库和语法分析器,CMake在设计时只考虑了一个主要的依赖:C++编译器(因为要构建的是C++代码,所以我们可以放心地假设系统中已经安装好C++编译器)。当时,在许多流行的UNIX和Windows操作系统上构建和安装Tcl之类的脚本语言是非常困难的。即便到如今,给超级计算机和没联网的安全计算机安装软件也非易事,所以编译第三方软件库一直都是比较困难的。由于软件构建系统是一个基本工具,因此其设计不应再引入其它的依赖关系。这确实限制了CMake提供自己的简单的语言,导致至今都有人不太喜欢CMake。然而,如果CMake依赖于当时最流行的嵌入式Tcl语言,它大概不会达到今天这样的流行程度。

生成IDE工程的能力是CMake的重要卖点,但这也限制了CMake不能提供本地IDE支持之外的特性。不过,支持本地IDE工程的重要性完全能够弥补其不足。这个设计使得CMake的开发变得困难,却令使用CMake的项目(如ITK)的开发更为容易,因为开发人员更喜欢使用自己熟悉的并且效率也更高的工具。允许开发人员选择自己喜欢的工具, 项目就能充分利用最宝贵的人力资源。

所有的C/C++程序都至少包含以下的一个或多个基本的基本构建单元: 可执行文件,静态链接库,动态链接库和插件。 CMake必须具备在所有支持的平台上生成这些结果的能力。 虽然所有的平台上都支持生成这些结果, 但不同的平台和不同的编译器会导致编译器选项变化很大。 CMake将这些目标的构建过程抽象成一条条简单的命令, 它们在实现上的复杂性和差异性则被隐藏了起来, 从而开发人员能够同时在Windows, Unix和Mac上创建这些目标的本地版本。 这样,开发人员得以专心于工程本身,而不是纠结于如何编译一个动态链接库这样的细节上。

代码生成器为构建系统增加了额外的复杂性。最开始,VTK提供了一个系统来解析C++头文件,然后自动地将C++代码封装成Tcl,Python和Java代码,并自动地生成一个封装层。这要求构建系统先生成一个C/C++程序(封装生成器),然后在编译时运行此程序,以生成更多的C/C++源码(特定模块的封装代码)。随后,生成的源码将被编译成可执行文件或动态链接库。所有这些过程必须在IDE环境和生成的Makefile中实现。

当开发灵活的跨平台C/C++软件时,很重要的一点是面向功能编程, 而不是面向特定的平台。autotool工具支持系统"自省"(introspection),即通过编译少量的代码来查询平台特征并存储查询结果。由于跨平台的需要,CMake也必须采用类似的策略,使得开发人员只需要针对标准平台编码,而不需要考虑特定的平台。由于编译器和操作系统时时在变,这个策略对于代码的可移植性非常重要。比如,下面的代码:

#ifdef linux
// do some linux stuff
#endif

就显得非常脆弱,不如写成

#ifdef HAS_FEATURE
// do something with a feature
#endif

另外一个CMake早期的需求也来自于autotool: 在源码树外生成构建树。这个特性使得从同一个源码树可得到多个不同的构建,使得不同构建之间的文件不会冲突,结合版本控制系统的时候显得尤为有利。

构建系统一个最更要的功能是依赖关系的管理能力。 如果一个源码文件发生变化,所有使用了这个文件的生成结果都必须重新构建。 对于C/C++代码,被.c.cpp文件包含的头文件也需要检查依赖关系。如果依赖关系理解错误,只有部分修改的代码有可能导致全部重新编译,从而浪费大量时间。

这个新的构建系统的所有的需求和功能都必须对所有支持的平台一视同仁。CMake需要为开发者提供一个简单的API,不需要关心平台细节就可以创建复杂的软件系统。事实上,使用CMake的软件只不过是把构建复杂性转移给了CMake开发人员。一旦这些基本的需求确定下来,就需要用敏捷的方式来实现CMake。ITK项目从第一天开始就需要这样一个构建系统,但其第一个版本的CMake并没有满足所有的需求,但已足够支持在Windows和Unix上构建软件。

5.2 CMake是怎样实现的

如前所述,CMake的开发语言是C和C++。为解释其内部结构,本节将首先从用户的角度介绍CMake的处理过程,然后再描述其结构。

5.2.1 CMake处理过程

CMake有两个主要的阶段。首先是"配置(configure)",在此阶段CMake处理所有的输入然后创建软件构建过程的内部表达。第二个阶段是"生成(generate)",负责创建出实际的构建文件。

环境变量与缓存

对1999年甚至是今天的许多构建系统来说,生成工程时都要用到底层(shell级别)的环境变量。典型的情况是,用PROJECT_ROOT环境变量来指向源码树的根目录。环境变量还被用于指定可选软件包和外部软件包。但是使用环境变量的方法也有弊端,它需要每次构建时都重新设置环境变量。为解决这个问题,CMake使用缓存文件来存储生成过程中用到的所有变量。这些变量不再是环境变量,而是CMake变量。CMake针对某个特定构建树第一次运行时,会创建一个CMakeCache.txt文件,存储当前构建过程中需要用到的CMake变量。这个缓存文件属于构建树的一部分,所以在之后的每次针对该构建树的重新配置时, 这些变量都是可重用的。

配置阶段

在配置阶段,CMake首先尝试读取CMakeCache.txt文件,该文件在第一次运行时生成。然后,读取源码树根目录下的CMakeLists.txt文件,并使用CMake词法分析器处理。CMakeLists.txt中的每条命令都由一个命令模式对象执行。通过includeadd_subdirectory命令,更多的CMakeLists.txt得到执行。对于每条命令,CMake都有一个C++对象来处理,比如add_library, if, add_executable, add_subdirectory,include等。实际上,整个CMake语言就是以命令调用的方式实现的。词法分析器只不过将输入文件内容转化为命令和命令参数而已。

配置阶段主要是运行用户定义的CMake代码。等到执行完之后,以及所有缓存变量计算完成之后,CMake在内存中得到一个项目构建的内部表达。这个内存中的内部表达包括了所有的库文件,可执行文件,定制的命令,以及生成指定generator(指特定的编译环境)所需的其他必要信息。这时,CMakeCache.txt会被存储到磁盘上,供以后重新运行CMake时使用。

项目在内存中的表达实际上是一些待生成的目标的集合,包括基本的库文件和可执行文件。CMake还支持目标的定制,即用户可以定义输入和输出,并提供定制的可在构建过程中运行的可执行文件或脚本。CMake将每个目标存储在一个cmTarget对象中,然后多个cmTarget存储在一个cmMakefile对象中,cmMakefile对象实际上用来存储源码树中某个目录中的所有目标。最后得到的结果是一棵cmMakefile对象的树,树结点中存储cmTarget对象的映射。

生成阶段

一旦配置(configure)阶段完成,生成(generator)阶段就可以开始了。生成阶段将生成用户指定类型(如Visual Studio或GNU/Linux GCC)的构建文件。这时,目标的内部表达(库,可执行文件,定制目标)转化为本地构建工具的输入文件,如Visual Studio或Makefile文件。CMake由配置阶段获得的内部表达要尽可能地抽象和通用,这样的数据结构才能被不同的本地构建工具所共享。

CMake处理过程简图如图5.1所示。

enter image description here

图5.1 CMake处理过程简图

5.2.2 CMake的代码

CMake中的对象

CMake使用了继承,设计模式和封装等面向对象技术. 其主要的C++对象及相互关系如图5.2所示:

enter image description here 图5.2 CMake中的对象

每个CMakeLists.txt的解析结果都存储在一个cmMakefile对象中。 除了存储一个目录的信息, cmMakefile对象还控制对 CMakeLists.txt的解析. CMake语言的解析函数使用了基于lex/yacc的分析器。 由于CMake语言的语法很少发生变化,而lex和yacc在本地系统上并不能保证已经安装,因此lex和yacc的输出文件被处理和保存到了Source目录中,和其它手工编写的文件一起加入到版本控制系统中。

CMake另一个重要的类是cmCommand。这是CMake语言中所有命令的实现类的基类。每个子类不仅提供命令的实现, 还包括其文档。 比如, 下面cmUnsetCommand类的方法的作用是提供文档:

virtual const char* GetTerseDocumentation()
{
    return "Unset a variable, cache variable, or environment variable.";
}

/**
  * More documentation
  */
virtual const char* GetFullDocumentation() 
{
    return
        " unset(<variable> [CACHE])\n"
        "Removes the specified variable causing it to become undefined."
        "If CACHE is present then the variable is removed from the cache"
        "instaead of the current scope. \n"
        "<variable> can be an environment variable such as:\n"
        "  unset(ENV{LD_LIBRARY_PATH})\n"
        "in which case the variable will be removed from the current "
        "environment.";
}

依赖分析

CMake内置有强大的的依赖分析能力, 支持单个Fortran, C和C++的源码文件。 因为集成开发环境(IDE)能够支持和维护文件的依赖信息, 对于这类本地系统CMake将忽略依赖分析步骤, 只是创建一个本地IDE的输入文件, 由IDE自行处理文件层次的依赖信息。而目标层次的依赖信息则转换为IDE所支持的依赖信息格式.

对于基于Makefile的本地构建工具, 其make程序并不知道如何自动计算和更新依赖信息. 对于这样的本地构建系统, CMake自动计算源码(C,C++和Fortran)的依赖信息。 这些依赖关系的生成和维护都是由CMake完成的。 一旦一个项目由CMake首次配置完成, 用户只需要运行make, 剩下的工作将由CMake完成.

虽然用户不需要知道CMake是如何工作的, 但查看一个项目的依赖信息还是很有帮助的。 在CMake中,每个目标的依赖信息存储在四个文件中: depend.make, flags.make, build.makeDependInfo.cmakedepend.make存储指定目录中所有对象(object)文件的依赖信息。 flags.make包含了源码文件的编译选项,如果编译选项发生变化,目标文件将被重新编译。 DependInfo.cmake用来维护和更新依赖关系, 它还存储了工程中包含哪些文件和使用哪一种编码语言等信息。 build.make则存储创建依赖的规则。 如果一个目标的依赖关系过时了,其依赖信息将被重新计算,保持为最新状态。 比如, 添加一个.h头文件会导致增加一个新的依赖, 从而导致重新计算.

CTest和CPack

CMake由一个构建系统渐渐发展为集构建,测试和软件打包为一体的工具家族。除了命令行工具cmake及CMake图形界面(GUI)程序, CMake还包含测试工具CTest和打包工具CPack。 CTest和CPack共享CMake的底层代码,但它们相对独立并不依赖于基本的构建过程。

ctest可执行程序用于执行回归测试。简单地使用一个add_test命令,项目就可以使用CTest来创建测试。这些测试可使用CTest来运行,测试结果可以发送到CDash程序并显示在网络应用中。CTest和CDash结合起来就构成了类似于Hudson的测试工具。但两者有很明显的差别:CTest面向分布式测试环境, 客户可以从版本控制系统中获取代码,运行测试,然后将测试结果发送到CDash。而Hudson,客户机器必须给予Hudson足够的ssh权限来访问目标机器,测试才能进行。

cpack可执行程序用来生成项目的安装程序。 CPack的执行和CMake的构建过程非常类似: 它也依赖于本地的工具. 比如, 在Windows上使用NSIS打包工具来生成项目安装程序。 CPack执行项目的安装规则生成一棵安装树, 然后使用本地的打包工具(如NSIS)来获得安装程序。 CPack还支持创建RPM软件安装包, Debian的.deb文件, .tar文件, .tar.gz文件, 以及自解压的tar文件。

5.2.3 图形界面

许多用户对CMake的第一印象是CMake的用户界面。 CMake有两个主要的用户界面:基于Qt的图形界面程序,和基于命令行的图形界面程序。这些GUI实际上是CMakeCache.txt的可视化编辑器。这些界面都非常简单,只有两个按钮: 配置(configure)和生成(generate),对应于CMake的两个主要的阶段。命令行用户界面用于Unix的TTY类型的终端和Cygwin, 而Qt图形用户界面则支持所有平台。两种GUI如图5.3和图5.4所示。

enter image description here

图5.3 命令行用户界面

enter image description here

图5.4 图形用户界面

两种GUI都在左边显示缓存变量的名称,在右边显示变量的值,值可以由用户修改。其中有两种类型的变量,普通变量和高级变量。默认情况下只显示有普通变量。在CMakeLists.txt中,项目可以指定哪些变量是高级变量。这个功能可以让界面变得简单,用户配置时只需要考虑必要的选项。

由于缓存变量的值可能会随着CMake命令的执行而变化,整个生成(generate)过程可能是递归的。比如,打开一个选项可能会引入更多的选项。由于这个原因,GUI在配置(configure)过程中是禁用生成(generate)按钮的,只有当所有的选项都至少出现过一次时生成(generate)按钮才可使用。每次按下配置(configure)按钮,一些新出现的缓存变量将显示为红色。一旦不再有新的变量产生,生成(generate)按钮就可以使用了。

5.2.4 测试CMake

任何一个新的CMake开发人员都会被首先介绍CMake开发中的测试过程,这个过程用到了多个CMake工具家族中的成员(CMake, CTest, CPack和CDash)。当CMake代码经过开发并检入到版本控制系统中后,运行持续集成测试的机器将使用CTest来自动构建和测试新的CMake代码。其结果将发送到CDash服务器上,如果出现错误,警告或测试失败的情况,则通过邮件来通知开发者。

这个处理过程是一个典型的持续集成测试。当新的代码检入到CMake代码仓库中时,在CMake支持的测试平台上将自动实施测试过程。考虑到CMake需要支持大量的编译器和平台,这种测试系统对于开发一个稳定的系统是至关重要的。

比如, 如果一个新的开发者希望CMake能支持一个新的平台, 他(她)首要要回答的问题是能否为CMake测试系统提供一个每晚dashboard的客户端。 没有经常性不断的测试, 新系统就难以保证过一段时间后不会出问题。

5.3 经验教训

从构建ITK的第一天开始,CMake就一直在成功运行着,并成为了该项目的重要组成部分。如果重新来过,大概也不会有什么太大的不同。 但是,凡事有例外,总会有一些事情能够做得更好。

5.3.1 后向兼容

维护后向兼容性对CMake团队来说是很重要的。 CMake的主要目标是让构建软件更为简单。 当一个工程或一个开发者选择了CMake, 尊重其选择并且不破坏其已有工作是非常重要的。 CMake 2.6实现了一个策略系统, 它会在用户不遵守某个命令的当前行为时发出警告, 但仍会执行旧的行为。 每个CMakeList.txt都要求指定期望使用的CMake版本。如果当前运行的CMake版本比指定的版本更新,CMake会发出警告, 但仍然使用旧的版本的行为。

5.3.2 语言,语言,语言

CMake语言尽量设计得简单, 然而, 让一个新项目考虑使用CMake的主要障碍仍然是语言。 CMake固然发展得不错, 但CMake语言中确实存在一些古怪的行为。 CMake语言的第一个语法分析器居然只是一个简单的字符分析器, 而不是lex/yacc等高级工具。 如果有机会重新实现语言部分, 我们会花时间寻找一个漂亮的已有嵌入式语言。 Lua应该符合要求, 小且干净。 即便不用Lua这样的外部语言, 我也还是倾向于使用已有的语言。

5.3.3 插件不能工作

为了提供CMake语言的扩展能力,CMake有一个插件类,允许项目使用C语言创建一个新的CMake命令。当时,这听起来是个不错的主意。因为提供的是C语言接口,还可以支持多种编译器。但是,随着针对不同平台(Windows和Linux,32位和64位)的API的出现,插件的兼容性变得难以维护。虽然只使用CMake语言显得没那么强大,但是至少不会令程序崩溃,项目也不会因为插件不能工作而无法继续构建。

5.3.4 减少外部接口

在CMake的开发过程中得到的一个重要的教训是, 你不需要维护用户访问不到的功能的后向兼容性。 有些时候, 用户和客户要求CMake封装成一个软件库供其它语言来使用。但这样做不仅会因为不同的CMake使用方式而分裂CMake用户群,也会为CMake的开发带来巨大的维护成本。

脚注

  1. http://www.itk.org/
  2. 本译文由笔者先独立翻译,而后又参考他人的翻译成果http://www.sand-tower.net/archives/210。 主要是事先没有上网调查,导致重复劳动。