原文地址:www.aosabook.org/en/cmake.ht…
原文作者:www.aosabook.org/en/intro1.h…
发布时间:
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的时候,通常的做法是一个项目有一个配置脚本和Makefiles用于Unix平台,而Visual Studio项目文件用于Windows。这种构建系统的双重性使得许多项目的跨平台开发变得非常繁琐:简单的为项目添加一个新的源文件是非常痛苦的。对开发者来说,显而易见的目标是拥有一个统一的构建系统。CMake的开发者有两种解决统一构建系统问题的方法。
一种方法是1999年的VTK构建系统。该系统由一个Unix的配置脚本和一个Windows的可执行文件pcmaker组成。pcmaker是一个C程序,它可以读取Unix的Makefiles并为Windows创建NMake文件。pcmaker的二进制可执行文件被检查到VTK CVS系统仓库中。一些常见的情况下,比如添加一个新的库,需要改变该源文件并检查新的二进制文件。虽然这在某种意义上是一个统一的系统,但它有很多不足之处。
开发人员有经验的另一种方法是基于gmake的TargetJr的构建系统。 TargetJr是一个C++计算机视觉环境,最初是在Sun工作站上开发的。最初TargetJr使用imake系统来创建Makefile。然而,在某些时候,当需要移植到Windows时,就创建了gmake系统。Unix 编译器和 Windows 编译器都可以使用这个基于 gmake 的系统。在运行gmake之前,系统需要设置几个环境变量。如果没有正确的环境,系统就会以难以调试的方式失败,特别是对终端用户而言。
这两个系统都存在一个严重的缺陷:它们迫使Windows开发者使用命令行。有经验的Windows开发者更喜欢使用集成开发环境(IDE)。这将鼓励Windows开发人员手工创建IDE文件,并将其贡献给项目,再次创建双构建系统。除了缺乏IDE支持外,上述两种系统还使软件项目的组合变得非常困难。例如,VTK中读取图像的模块非常少,主要是因为构建系统使得使用libtiff和libjpeg等库非常困难。
我们决定为ITK和一般的C++开发一个新的构建系统。新的构建系统的基本限制如下。
- 只依赖系统中安装的C++编译器。
- 它必须能够生成Visual Studio IDE的输入文件。
- 它必须易于创建基本的构建系统目标,包括静态库、共享库、可执行文件和插件。
- 它必须能够运行构建时的代码生成器。
- 它必须支持从源代码树中分离出构建树。
- 它必须能够执行系统自省,即能够自动判断目标系统能做什么,不能做什么。
- 它必须自动对C/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有两个主要阶段。第一个是 "configure "步骤,在这个步骤中,CMake处理所有给它的输入,并创建一个要执行的构建的内部表示。然后下一个阶段是 "生成 "步骤。在这个阶段,实际的构建文件被创建。
环境变量 (或不)
在1999年甚至今天的许多构建系统中,在项目的构建过程中都会使用shell级别的环境变量。典型的情况是,一个项目有一个PROJECT_ROOT环境变量,指向源代码树根的位置。环境变量也被用来指向可选包或外部包。这种方法的问题是,为了使构建工作顺利进行,每次执行构建时都需要设置所有这些外部变量。为了解决这个问题,CMake有一个缓存文件,将构建所需的所有变量存储在一个地方。这些变量不是 shell 或环境变量,而是 CMake 变量。当CMake第一次运行某个特定的构建树时,它会创建一个CMakeCache.txt文件,存储该构建树的所有持久变量。因为这个文件是构建树的一部分,所以在每次运行过程中,CMake 都可以使用这些变量。
配置步骤
在配置步骤中,CMake 首先读取 CMakeCache.txt,如果它在之前的运行中存在的话。然后它读取CMakeLists.txt,这些文件在给CMake的源代码树的根目录下。在配置步骤中,CMakeLists.txt 文件会被 CMake 语言解析器解析。在文件中找到的每一个CMake命令都会被一个命令模式对象执行。额外的CMakeLists.txt文件可以在这一步中通过include和add_subdirectory CMake命令进行解析。CMake有一个C++对象,用于CMake语言中的每个命令。一些命令的例子是add_library, if, add_executable, add_subdirectory, 和include。实际上,整个 CMake 的语言都是通过调用命令来实现的。解析器只是将 CMake 输入文件转换为命令调用和作为命令参数的字符串列表。
configure步骤主要是 "运行 "用户提供的CMake代码。在所有的代码被执行,所有的缓存变量值被计算之后,CMake就有了一个要构建的项目的内存表示。这将包括所有的库、可执行文件、自定义命令和所有其他信息,以创建所选生成器的最终构建文件。这时,CMakeCache.txt 文件会被保存到磁盘上,供将来运行 CMake 时使用。
项目的内存表示是一个目标的集合,这些目标只是可能被构建的东西,比如库和可执行文件。CMake 也支持自定义目标:用户可以定义它们的输入和输出,并提供自定义的可执行文件或脚本在构建时运行。CMake 将每个目标存储在 cmTarget 对象中。这些对象依次存储在 cmMakefile 对象中,它基本上是一个存储所有在源树的给定目录中找到的目标的地方。最终的结果是一个包含 cmTarget 对象映射的 cmMakefile 对象树。
生成步骤
一旦配置步骤完成,就可以进行生成步骤。生成步骤是CMake为用户选择的目标构建工具创建构建文件的时候。此时,目标的内部表示(库、可执行文件、自定义目标)被转换为IDE构建工具(如Visual Studio)的输入,或者是一组Makefiles被make执行。在配置步骤之后,CMake的内部表示是尽可能通用的,这样就可以在不同的构建工具之间共享尽可能多的代码和数据结构。
这个过程的概述可以在图5.1中看到。
图5.1: CMake过程概述
5.2.2. CMake:代码
CMake对象
CMake是一个使用继承、设计模式和封装的面向对象系统。主要的C++对象及其关系可参见图5.2。
图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.make、flags.make、build.make和DependInfo.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来运行,它也可以用来将测试结果发送到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的窗口化应用程序,一个是基于命令行光标图形的应用程序。这些图形用户界面是CMakeCache.txt文件的图形编辑器。它们是相对简单的界面,有两个按钮,配置和生成,用于触发CMake过程的主要阶段。基于curses的GUI可以在Unix TTY型平台和Cygwin上使用。Qt的GUI在所有平台上都可以使用。图5.3和图5.4可以看到GUI。
图5.3: 命令行界面
图5.4:基于图形的界面
两种GUI的缓存变量名在左边,值在右边。右边的值可以由用户修改成适合构建的值。变量有两种类型,普通变量和高级变量。默认情况下,普通变量会显示给用户。一个项目可以在项目的CMakeLists.txt文件中决定哪些变量是高级变量。这允许用户在构建过程中尽可能少的选择。
由于缓存值可以在命令执行的过程中被修改,所以最终构建的过程可以是反复的。例如,打开一个选项可能会显示额外的选项。出于这个原因,GUI禁用了 "生成 "按钮,直到用户有机会看到所有选项至少一次。每次按下configure按钮,尚未显示给用户的新缓存变量都会以红色显示。一旦在配置运行期间没有新的缓存变量创建,生成按钮就会被启用。
5.2.4. 测试 CMake
任何一个新的CMake开发者首先会被介绍到CMake开发中使用的测试过程。这个过程利用了CMake系列工具(CMake、CTest、CPack和CDash)。随着代码的开发和检查到版本控制系统中,持续集成测试机会使用CTest自动构建和测试新的CMake代码。测试结果被发送到CDash服务器,如果有任何构建错误、编译器警告或测试失败,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项目带来巨大的维护成本。
脚注
通过www.DeepL.com/Translator(免费版)翻译