开源应用架构(卷 1)
CMake

比尔·霍夫曼和肯尼斯·马丁

1999 年,美国国家医学图书馆委托一家名为 Kitware 的小公司开发一种更好的方法,以便在许多不同的平台上配置、构建和部署复杂的软件。这项工作是 Insight Segmentation and Registration Toolkit(ITK1)的一部分。Kitware 作为该项目的工程负责人,其任务是开发一个 ITK 研究人员和开发人员可以使用的构建系统。该系统必须易于使用,并允许研究人员以最高效的方式利用其编程时间。从这一指令中,CMake 作为构建软件的过时 autoconf/libtool 方法的替代方案应运而生。它的设计旨在解决现有工具的弱点,同时保持其优势。

除了构建系统之外,多年来 CMake 已发展成为一个开发工具家族:CMake、CTest、CPack 和 CDash。CMake 是负责构建软件的构建工具。CTest 是一个测试驱动程序工具,用于运行回归测试。CPack 是一个打包工具,用于为使用 CMake 构建的软件创建特定于平台的安装程序。CDash 是一个 Web 应用程序,用于显示测试结果并执行持续集成测试。

5.1. CMake 历史和需求

在开发 CMake 时,项目的常规做法是在 Unix 平台上使用配置脚本和 Makefile,在 Windows 平台上使用 Visual Studio 项目文件。这种构建系统的二元性使得许多项目的跨平台开发变得非常繁琐:仅仅向项目添加一个新源文件都非常痛苦。开发人员的明显目标是拥有一个统一的构建系统。CMake 的开发人员拥有解决统一构建系统问题的两种方法的经验。

一种方法是 1999 年的 VTK 构建系统。该系统由 Unix 的配置脚本和 Windows 的可执行文件 pcmaker 组成。pcmaker 是一个 C 程序,它读取 Unix Makefile 并为 Windows 创建 NMake 文件。pcmaker 的二进制可执行文件被检入 VTK CVS 系统存储库。一些常见情况,例如添加新库,需要更改该源代码并检入新的二进制文件。虽然从某种意义上说,这是一个统一的系统,但它也存在许多缺点。

开发人员有经验的另一种方法是基于 gmake 的 TargetJr 构建系统。TargetJr 是一个最初在 Sun 工作站上开发的 C++ 计算机视觉环境。最初,TargetJr 使用 imake 系统来创建 Makefile。但是,在某个时候,当需要 Windows 端口时,创建了 gmake 系统。此基于 gmake 的系统可以使用 Unix 编译器和 Windows 编译器。该系统要求在运行 gmake 之前设置多个环境变量。如果没有正确的环境,系统就会以难以调试的方式失败,特别是对于最终用户而言。

这两个系统都存在一个严重的缺陷:它们迫使 Windows 开发人员使用命令行。经验丰富的 Windows 开发人员更喜欢使用集成开发环境 (IDE)。这将鼓励 Windows 开发人员手动创建 IDE 文件并将其贡献给项目,从而再次创建双构建系统。除了缺乏 IDE 支持之外,上面描述的两个系统都使得组合软件项目变得极其困难。例如,VTK 几乎没有用于读取图像的模块,主要是因为构建系统使得使用 libtiff 和 libjpeg 等库变得非常困难。

因此,决定为 ITK 和 C++ 开发一个新的构建系统。新构建系统的基本约束如下

为了避免依赖任何额外的库和解析器,CMake 的设计只有一个主要依赖项,即 C++ 编译器(如果我们正在构建 C++ 代码,我们可以安全地假设我们拥有它)。当时,在许多流行的 UNIX 和 Windows 系统上构建和安装像 Tcl 这样的脚本语言很困难。在当今的现代超级计算机和没有互联网连接的安全计算机上,这仍然是一个问题,因此构建第三方库仍然可能很困难。由于构建系统是软件包的基本需求,因此决定不向 CMake 引入任何其他依赖项。这确实限制了 CMake 创建自己的简单语言,这是一个至今仍导致一些人不喜欢 CMake 的选择。然而,当时最流行的嵌入式语言是 Tcl。如果 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++ 软件时,重要的是要针对系统的功能进行编程,而不是针对特定系统进行编程。Autotools 有一种用于执行系统内省的模型,该模型涉及编译小的代码片段,检查和存储该编译的结果。由于 CMake 旨在跨平台,因此它采用了类似的系统内省技术。这允许开发人员针对规范系统进行编程,而不是针对特定系统进行编程。这对于实现未来的可移植性非常重要,因为编译器和操作系统会随着时间的推移而发生变化。例如,以下代码

#ifdef linux
// do some linux stuff
#endif

比以下代码更脆弱

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

另一个早期的 CMake 需求也来自 autotools:能够创建与源树分开的构建树。这允许对同一个源树执行多种构建类型。它还可以防止构建文件使源树混乱,这通常会使版本控制系统感到困惑。

构建系统最重要的功能之一是能够管理依赖项。如果源文件发生更改,则必须重新构建使用该源文件的所有产品。对于 C/C++ 代码,.c.cpp 文件包含的头文件也必须作为依赖项的一部分进行检查。跟踪由于依赖项信息不正确而导致只有部分应该编译的代码实际编译的问题可能非常耗时。

新构建系统的所有需求和功能都必须在所有支持的平台上发挥同等良好的作用。CMake 需要为开发人员提供一个简单的 API,以便他们能够创建复杂的软件系统,而无需了解平台细节。实际上,使用 CMake 的软件将构建复杂性外包给了 CMake 团队。一旦构建工具的愿景在基本需求集中创建,就需要以敏捷的方式进行实施。ITK 几乎从第一天起就需要一个构建系统。CMake 的早期版本没有满足愿景中提出的所有要求,但它们能够在 Windows 和 Unix 上构建。

5.2. CMake 的实现方式

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

5.2.1. CMake 过程

CMake 有两个主要阶段。第一个是“配置”步骤,其中 CMake 处理提供给它的所有输入,并创建要执行的构建的内部表示。然后下一个阶段是“生成”步骤。在此阶段,创建实际的构建文件。

环境变量(或不使用)

在 1999 年甚至今天的许多构建系统中,在项目的构建过程中使用 shell 级环境变量。项目通常有一个 PROJECT_ROOT 环境变量,指向源树根目录的位置。环境变量还用于指向可选的或外部的软件包。这种方法的问题在于,为了使构建正常工作,每次执行构建时都需要设置所有这些外部变量。为了解决此问题,CMake 有一个缓存文件,用于在一个位置存储构建所需的所有变量。这些不是 shell 或环境变量,而是 CMake 变量。第一次为特定构建树运行 CMake 时,它会创建一个 CMakeCache.txt 文件,其中存储该构建的所有持久变量。由于该文件是构建树的一部分,因此在每次运行期间,变量始终可用于 CMake。

配置步骤

在配置步骤期间,CMake 首先读取先前运行中存在的 CMakeCache.txt(如果存在)。然后它读取 CMakeLists.txt,该文件位于提供给 CMake 的源树的根目录中。在配置步骤期间,CMakeLists.txt 文件由 CMake 语言解析器解析。文件中找到的每个 CMake 命令都由命令模式对象执行。在此步骤期间,可以通过 includeadd_subdirectory CMake 命令解析其他 CMakeLists.txt 文件。CMake 针对可在 CMake 语言中使用的每个命令都有一个 C++ 对象。一些命令示例包括 add_libraryifadd_executableadd_subdirectoryinclude。实际上,CMake 的整个语言都实现为对命令的调用。解析器只是将 CMake 输入文件转换为命令调用和作为命令参数的字符串列表。

配置步骤从本质上“运行”用户提供的 CMake 代码。在所有代码执行完毕且所有缓存变量值都已计算后,CMake 就会拥有要构建的项目的内存表示。这将包括所有库、可执行文件、自定义命令以及创建所选生成器的最终构建文件所需的所有其他信息。此时,CMakeCache.txt 文件将保存到磁盘,以便在将来运行 CMake 时使用。

项目的内存表示是一组目标的集合,目标仅仅是可以构建的事物,例如库和可执行文件。CMake 还支持自定义目标:用户可以定义其输入和输出,并提供在构建时要运行的自定义可执行文件或脚本。CMake 将每个目标存储在 cmTarget 对象中。这些对象依次存储在 cmMakefile 对象中,cmMakefile 对象基本上是源代码树中给定目录中找到的所有目标的存储位置。最终结果是包含 cmTarget 对象映射的 cmMakefile 对象树。

生成步骤

完成配置步骤后,即可进行生成步骤。生成步骤是 CMake 为用户选择的目标构建工具创建构建文件的时间。此时,目标(库、可执行文件、自定义目标)的内部表示将转换为 IDE 构建工具(如 Visual Studio)的输入,或转换为由 make 执行的一组 Makefile。CMake 在配置步骤后的内部表示尽可能通用,以便可以在不同的构建工具之间共享尽可能多的代码和数据结构。

可以在图 5.1中看到该过程的概述。

[Overview of the CMake Process]

图 5.1:CMake 过程概述

5.2.2. CMake:代码

CMake 对象

CMake 是一个面向对象的系统,使用了继承、设计模式和封装。主要 C++ 对象及其关系可以在图 5.2中看到。

[CMake Objects]

图 5.2:CMake 对象

每个 CMakeLists.txt 文件的解析结果都存储在 cmMakefile 对象中。除了存储有关目录的信息外,cmMakefile 对象还控制 CMakeLists.txt 文件的解析。解析函数调用一个使用基于 lex/yacc 的解析器来解析 CMake 语言的对象。由于 CMake 语言语法变化很少,并且并非所有 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 "
      "instead 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 构建,CMake 会创建一个本机 IDE 输入文件,并让 IDE 处理文件级别的依赖项信息。目标级别的依赖项信息将转换为 IDE 用于指定依赖项信息的格式。

对于基于 Makefile 的构建,本机 make 程序不知道如何自动计算和保持依赖项信息是最新的。对于这些构建,CMake 会自动计算 C、C++ 和 Fortran 文件的依赖项信息。CMake 会自动完成这些依赖项的生成和维护。CMake 初次配置项目后,用户只需运行 make,CMake 就会完成其余工作。

尽管用户无需了解 CMake 如何完成这项工作,但查看项目的依赖项信息文件可能会有所帮助。每个目标的信息都存储在四个名为 depend.makeflags.makebuild.makeDependInfo.cmake 的文件中。depend.make 存储目录中所有目标文件的依赖项信息。flags.make 包含用于此目标源文件的编译标志。如果它们发生更改,则将重新编译文件。DependInfo.cmake 用于保持依赖项信息最新,并包含有关哪些文件是项目的一部分以及它们使用哪种语言的信息。最后,构建依赖项的规则存储在 build.make 中。如果目标的依赖项已过时,则将重新计算该目标的依赖项信息,从而使依赖项信息保持最新。之所以这样做是因为 .h 文件的更改可能会添加新的依赖项。

CTest 和 CPack

在此过程中,CMake 从一个构建系统发展成为一个用于构建、测试和打包软件的工具系列。除了命令行 cmake 和 CMake GUI 程序之外,CMake 还附带了一个测试工具 CTest 和一个打包工具 CPack。CTest 和 CPack 与 CMake 共享相同的代码库,但它们是单独的工具,对于基本构建而言并非必需。

ctest 可执行文件用于运行回归测试。项目可以使用 add_test 命令轻松创建供 CTest 运行的测试。可以使用 CTest 运行测试,CTest 还可以用于将测试结果发送到 CDash 应用程序以在 Web 上查看。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 的窗口应用程序和一个基于命令行的 curses 图形应用程序。这些 GUI 是 CMakeCache.txt 文件的图形编辑器。它们是相对简单的界面,有两个按钮“配置”和“生成”,用于触发 CMake 过程的主要阶段。基于 curses 的 GUI 可用于 Unix TTY 类型平台和 Cygwin。Qt GUI 可用于所有平台。可以在图 5.3图 5.4中看到这些 GUI。

[Command Line Interface]

图 5.3:命令行界面

[Graphics-based Interface]

图 5.4:基于图形的界面

这两个 GUI 的左侧都有缓存变量名称,右侧都有值。用户可以将右侧的值更改为适合构建的值。变量有两种类型:普通变量和高级变量。默认情况下,会向用户显示普通变量。项目可以在项目的 CMakeLists.txt 文件中确定哪些变量是高级变量。这使得用户只需为构建提供必要的选项即可。

由于可以在执行命令时修改缓存值,因此最终构建的收敛过程可能是迭代的。例如,启用某个选项可能会显示其他选项。因此,GUI 会禁用“生成”按钮,直到用户有机会至少查看一次所有选项。每次按下“配置”按钮时,尚未呈现给用户的新的缓存变量都会以红色显示。在配置运行期间不再创建新的缓存变量后,将启用“生成”按钮。

5.2.4. 测试 CMake

任何新的 CMake 开发人员首先都会了解 CMake 开发中使用的测试过程。该过程利用了 CMake 工具系列(CMake、CTest、CPack 和 CDash)。在开发代码并将其检入版本控制系统时,持续集成测试机器会使用 CTest 自动构建和测试新的 CMake 代码。结果将发送到 CDash 服务器,如果出现任何构建错误、编译器警告或测试失败,则服务器会通过电子邮件通知开发人员。

该过程是一个经典的持续集成测试系统。将新代码检入 CMake 存储库后,它会在 CMake 支持的平台上自动进行测试。鉴于 CMake 支持的大量编译器和平台,这种类型的测试系统对于开发稳定的构建系统至关重要。

例如,如果新的开发人员想要为新平台添加支持,则首先会询问他们是否可以为此系统提供一个夜间仪表板客户端。如果没有持续测试,不可避免的是,新系统在一段时间后将停止工作。

5.3. 经验教训

CMake 从第一天起就成功地构建了 ITK,这是该项目最重要的部分。如果我们能够重新开发 CMake,不会有太多变化。但是,总有一些事情可以做得更好。

5.3.1. 向后兼容性

维护向后兼容性对 CMake 开发团队非常重要。该项目的主要目标是简化软件构建。当项目或开发人员为构建工具选择 CMake 时,务必尊重该选择,并尽最大努力避免在将来的 CMake 版本中破坏该构建。CMake 2.6 实现了一个策略系统,其中对 CMake 的更改如果会破坏现有行为,则会发出警告,但仍会执行旧行为。每个 CMakeLists.txt 文件都需要指定它们期望使用的 CMake 版本。较新版本的 CMake 可能会发出警告,但仍会像旧版本一样构建项目。

5.3.2. 语言,语言,语言

CMake 语言旨在非常简单。但是,当新项目考虑使用 CMake 时,它是采用 CMake 的主要障碍之一。鉴于其有机增长,CMake 语言确实有一些怪癖。该语言的第一个解析器甚至不是基于 lex/yacc 的,而只是一个简单的字符串解析器。如果能重新设计语言,我们会花一些时间寻找一个已经存在的不错的嵌入式语言。Lua 是最适合的语言,它可能有效。它非常小巧简洁。即使不使用 Lua 这样的外部语言,我也会从一开始就更加重视现有的语言。

5.3.3. 插件无效

为了使项目能够扩展 CMake 语言,CMake 提供了一个插件类。这允许项目用 C 语言创建新的 CMake 命令。这在当时听起来像个好主意,并且接口是用 C 定义的,以便可以使用不同的编译器。但是,随着 32/64 位 Windows 和 Linux 等多个 API 系统的出现,插件的兼容性变得难以维护。虽然使用 CMake 语言扩展 CMake 并不那么强大,但它避免了 CMake 崩溃或无法构建项目,因为插件构建或加载失败。

5.3.4. 减少公开的 API

在 CMake 项目开发过程中,我们吸取了一个重要的教训:你不需要与用户无法访问的内容保持向后兼容性。在 CMake 的开发过程中,用户和客户多次要求将 CMake 打造成一个库,以便其他语言可以绑定到 CMake 功能。这不仅会使 CMake 用户社区分裂,出现许多不同的使用 CMake 的方式,而且还会给 CMake 项目带来巨大的维护成本。

脚注

  1. http://www.itk.org/