背景介绍

1999年,国家医学图书馆雇用了一个叫Kitware的小公司来开发一个能更好配置,构建和发布复杂跨平台软件的方法。这项工作是ITK(the Insight Segmentation and Registration Toolkit,一种软件)项目的一部分。作为这个项目的工程领导,Kitware负责开发一个可供ITK项目的研究人员和开发人员使用的软件构建体系。这个系统必须使用简单,并且尽量不占用开发员推进主项目的时间。基于上述指导思想,CMake作为过去的软件构建工具autoconf/libtool的替代品,它应该扬其长避其短。 经过多年的发展,CMake从最初的软件构建体系发展成为了一系列的开发工具:CMake, CTest, CPack和CDash。CMake是软件构建工具,CTest是一个测试驱动工具,用来做回归测试。CPack是打包工具,它能为用CMake构建的软件创造各个平台的安装包。CDash是一个网页应用,能够持续执行集成测试并且展示测试结果。

5.1CMake的历史和需求

在开始开发CMake时,项目管理常见的做法是,对于Unix平台使用configure脚本和Makefile文件,对于Windows平台使用Visual Studio工程文件。这种构建系统的二重性使得跨平台开发对许多项目来说变得非常乏味:即使是简单地在一个项目中添加新的源文件都是痛苦的。对开发者而言,显而易见的目标就是拥有一个统一软件构建系统。CMake的开发者们有着通过两种方法来解决这个问题的经验。

一种方法是1999年开发的VTK构建系统。在这个系统中,Unix系统下使用configure脚本,而在Window系统中使用一个叫做pcmaker的可执行文件。pcmaker是一个C程序,它通过解析Unix Makefile文件来生成Windows下的NMake文件。 pcmaker的二进制可执行文件被嵌入了VTK 的CVS系统仓库中。类似于添加新库这样的几种常见情况需要修改源码,再更新系统仓库的可执行文件。尽管这从某种程度上讲是一个统一的构建系统,但它仍有许多缺点。

开发者们采用过的另一种方法是为TargetJr开发的基于gmake的构建系统。TargetJr是用C++编写的计算机可视化环境,最初在Sun工作站上开发。一开始,TargetJr使用imake构建系统来创建Makefile文件。然而为了满足有些时候Window下的需要,便开发出了gmake构建系统。gmake构建系统同时支持Unix编译器和Windows编译器。在运行gmake之前系统需要设置一些环境变量。没有正确的环境将导致系统产生一些难以调试的错误,特别是终端用户。

这两种方法都有一个严重的缺陷: 它们要求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。如果CMake是基于Tcl语言的构建系统,那么它大概不会达到今天这样的流行程度。

生成IDE工程文件的能力是CMake的重要卖点,但这也限制了CMake只能提供本地IDE支持之外的特性。不过,支持本地IDE工程文件的好处完全能弥补它的局限性。尽管这个决定使得CMake的发展变得困难,却令ITK和其他使用CMake的项目的开发更为容易。因为开发者使用自己熟悉的工具,不仅更快乐,效率也更高。允许开发者选择自己偏爱的工具, 项目就能充分利用它最宝贵的资源,即开发者。

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

代码生成器为构建系统增加了额外的复杂性。最开始,VTK提供了一个系统,它可以通过解析C++头文件,自动地将C++代码封装成Tcl,Python和Java代码,然后自动地生成一个封装层。这要求构建系统先生成一个C/C++程序(封装生成器),然后在编译时运行此程序以生成更多的C/C++源码(特定模块的封装代码)。生成的源码接着将被编译成可执行文件或动态链接库。所有这些过程必须在IDE环境和生成的Makefile中实现。 当开发灵活的跨平台C/C++软件时,很重要的一点是面向功能编程,而不是面向特定的平台。autotool工具支持系统”自省”(introspection),即通过编译少量的代码来检查并存储编译的结果。由于跨平台的需要,CMake也采用了类似系统自省的技术,使得开发者只需针对标准平台编码,而不需要考虑特定的平台。随着编译器和操作系统不断地变化,这个策略对于代码的可移植性非常重要。比如,下面的代码:

1
2
3
4
5
6
    #ifdef linux  
* do some linux stuff
#endif
```

对比如下,就显得更脆弱:

#ifdef HAS_FEATURE  
* do something with a feature  
#endif
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
另一个CMake早期的需求也来自于autotool: 生成与源码树分开的构建树的能力。这个使从同一个源码树得到的构建类型多样化,同时防止源码树与构建文件之间经常混淆了版本控制系统的冲突。

构建系统一个最更要的功能是对依赖关系的管理能力。如果一个源码文件发生变化,那么所有使用了这个源码文件的生成结果都必须重新构建。对于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,这个文件在第一次运行时生成。然后CMake读取源码根目录下的CMakeLists.txt文件,然后使用CMake语言词法分析器对其进行分析。CMakeLists.txt文件中的每一个CMake命令都由一个命令模式对象执行。通过include和add_subdirectory能执行CMakeLists.t来处理。xt文件中更多的命令。每条命令都有一个C++对象来处理。实际上,整个CMake语言都是以命令调用的方式来实现的。词法分析器只是将CMake转化为命令和命令参数而已。

配置阶段主要是执行用户提供的CMake代码。等到执行完之后,所有缓冲区变量的值都被计算出来了,CMake在内存中得到一个项目的内部表达。。这个内存中的内部表达包括了所有的库文件,可执行文件,定制的命令,以及生成指定generator(指特定的编译环境)所需的其他必要信息。至此,CMakeCach.txt这个包含着CMake以后要用到的所有信息的文件就被存储在了磁盘中。

项目在内存中的表达式一些未生成对象的集合,包含基本的库文件和可执行文件。CMake也支持自定义的对象:用户可以定义它们的输入和输出,和定制构建过程中需要的可执行文件和脚本。CMake将每个对象都存储在一个cmTarget对象中。而多个cmTarget对象构成一个cmMakefile对象,cmMakefile对象是用来存储源码树中所有构建的对象的。这最终形成了一个cmMakefile对象树,它包含着cmTarget对象的映射。

### 生成阶段 ###

一旦配置阶段完成,生成阶段就开始了。生成阶段将用户指定类型的构建文件。此时目标的内部表达(库,可执行文件,定制目标)转化为本地工具的构建工具比如:Visual Studio和Makefiles文件。Cmake在配置阶段之后生成的内部表达,要尽可能的普遍跟通用,这样才会有更多的代码和数据结构能够被不同的构建工具所共享。

CMake处理过程简图如5.1
![](/cdn/images/aosabook/42.png)

## 5.2.2 CMake的代码 ##

CMake中的对象

CMake是一种使用了继承,封装等面向对象技术。它的主要对象和它们之间的关系如下图:

![](/cdn/images/aosabook/43.png)

每个CMakeLists.txt的解析结果存储在一个cmMakefile里面。除了这个目录的信息,cmMakefile对象也控制着对CMakeLists.txt的解析。解析函数会调用基于lex/yacc的分析器。由于CMake的语法很少发生变化,而且lex和yacc有可能并不在CMake建立的地方,所以将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.make和DependInfo.make。depend.make存储指定目录中所有对象文件的依赖信息。flags.make包含了源码文件的编译选项,如果它们发生了改变,目标文件将被重新编译。DependInfo.make用来更新和维护依赖信息,它还存储了工程中包含了哪些文件和使用哪一种编码语言等信息。最后,建立依赖的规则存储在build.make中。如果一个目标的依赖信息过时了,那么依赖信息就会被重新计算,始终维护当下的依赖信息。这个过程存在的意义在于当a.h文件发生改变时,坑能回增加新的依赖关系。

CTest和CPack

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

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

cpack可执行程序用来生成项目的安装程序。CPack的执行和CMake的构建过程非常类似:它也依赖于本地的其他打包工具。比如,在Windows下使用的NSIS打包工具来生成项目的安装程序。CPack运行项目的安装规则来创建安装树,然后安装树被发送给像NSIS这样的安装包生成程序。CPack还支持创建RPM,Debian .deb文件,.tar, .tar.gz文件和一些自解压文件。

5.2.3 图形界面

通常用户们对于CMake的第一印象就是CMake的用户界面。CMake有两个用户交互程序:基于Qt的窗口型图形界面程序和基于命令行的图形界面应用。这些GUI都是CMakeCache.txt文件的可视化编辑器。它们都各自通过两个按钮进行交互,配置和生成——对应于两个主要的阶段。命令行的图形及面用于Unix的TTY类型的平台和Cygwin。而Qt的图形界面对于所有的平台都适用。下面图5.3和5.4展示了两种图形界面

图5.3

图5.4

两个图形界面都在左边显示缓存变量的名字,具体的值显示在右边。右边的变量值可以由用户修改。一共还有两种变量,普通变量和高级变量。默认情况下只将普通变量展示给用户看。在CMakeLists.txt文件中决定这一个变量是否是高级变量。这项功能可以让界面变得更简单,用户配置时只需要考虑必要的选项。

由于缓存变量的值可以随着CMake命令的执行而变化,整个生成的过程是递归的。例如,打开一个选项可能会引入更多的选项。因此,GUI使generate这个按钮无效,直到所有的选项都至少出现了一次。一旦配置按钮被点击,新的缓存变量(即未显示过的)就会显示为红色。只要在配置过程中没有新的缓存变量出现了,generate按钮就会变为有效。

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。CMake2.6完成了一个策略系统,使CMake会破坏现有行为的变化产生警告,但仍然执行原来的行为。每一个CMakeLists.txt文件需要指定哪个版本的CMake是希望被使用的。新的CMake版本也许会产生警告,但仍会像旧版本一样构建工程。

5.3.2 语言,语言,语言

CMake语言尽量设计得简单。然而,当一个新工程考虑CMake时,它是被采用的主要障碍之一。考虑到它的有机增长,CMake语言确实有一些怪癖。第一个语法分析器甚至不是基于lex/yacc而仅仅是一个简单的字符分析器。若是有再次做这个语言的机会,我们将花时间寻找一个现有的好的嵌入式语言。Lua是能生效的最适合的。它非常小而且诶干净。即使不使用一个类似Lua的外部语言,我在一开始也仍会考虑现有的语言。

5.3.3 插件不能工作

为了通过工程提供CMake语言的扩展能力,CMake有一个插件类。这使工程可以用C语言创建新的CMake命令。这在当时听起来是个不错的想法,而且为C语言定义了接口以便不同编译器都可以使用。然而,随着类似32/64位Windows和Linux的多种应用程序接口系统的出现,插件的兼容性开始变得难以维护。虽然用CMake语言延伸CMake没有那么强大,但它避免了CMake崩溃和由于一个出错或者加载失败的插件而不能构建工程的情况。

5.3.4 减少外部接口

在CMake工程的开发中学到了一个重大的经验,就是你不必维护用户访问不到的后向兼容性。有时候用户和客户要求CMake做成库以便于其他语言可以绑定使用CMAke的功能。这不仅会使通过许多不同方式使用CMake的用户社区分裂开,还将是CMake工程一项巨大的维护成本。

== Footnotes ==

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