开源软件架构-CMake

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

原文地址:http://www.aosabook.org/en/cmake.html;作者:Bill HoffmanKenneth Martin

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

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 也必须采用类似的策略,使得开发人员只需要针对标准平台编码,而不需要考虑特定的平台。由于编译器和操作系统时时在变,这个策略对于代码的可移植性非常重要。比如,下面的代码:

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

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

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

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

CMake 是怎样实现的

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

CMake 处理过程

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

环境变量与缓存

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

配置阶段

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

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

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

生成阶段

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

开源软件架构-CMake

CMake 的代码

CMake 中的对象

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

开源软件架构-CMake

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

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

依赖分析

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.make 和 DependInfo.cmake。depend.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 文件。

图形界面

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

开源软件架构-CMake

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

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

测试 CMake

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

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

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

经验教训

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

后向兼容

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

语言,语言,语言

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

插件不能工作

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

减少外部接口

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

相关文章

发表评论

电子邮件地址不会被公开。 必填项已用*标注