CMake-秘籍-全-

CMake 秘籍(全)

原文:zh.annas-archive.org/md5/ecf89da6185e63c44e748e0980911fef

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

计算机软件几乎存在于我们日常生活的方方面面:它触发我们的闹钟,推动我们的通信、银行业务、天气预报、公交时刻表、日历、会议、旅行、相册、电视、音乐流媒体、社交媒体、餐饮和电影预订,从黎明到黄昏。

我们周围的软件包含许多层:应用程序建立在框架之上,框架建立在库之上,库使用更小的库和可执行文件,一直到底层更小的软件组件。库和可执行文件反过来需要从源代码构建。我们通常只看到最外层,但所有这些层都需要仔细组织和构建。本书是关于如何使用 CMake 从源代码构建库和可执行文件的。

CMake 及其姊妹工具 CTest、CPack 和 CDash 已经成为从源代码构建软件的领先工具集,在使用和受欢迎程度上超过了许多其他类似工具,如备受尊敬的 GNU Autotools 和较新的基于 Python 的 SCons 构建系统。

(搜索兴趣随时间变化,针对三个流行的构建系统:CMake、Automake 和 SCons。兴趣是通过对相关术语的搜索次数来衡量的。该图表是使用 Google 趋势提供的数据获得的。)

CMake 项目的历史始于 1999 年,当时 Kitware,即其开发背后的公司,受委托设计一套新的工具来简化研究人员日常软件工作。目标很明确:提供一套工具,使得在不同平台上配置、构建、测试和部署同一项目变得更加容易。关于 CMake 项目随后设计选择的精彩叙述可以在www.aosabook.org/en/cmake.html找到。

CMake 是一个构建系统生成器,提供了一个强大的领域特定语言(DSL)来描述构建系统应该实现的目标。在我们看来,这是 CMake 的主要优势之一,因为它允许使用相同的 CMake 脚本生成平台原生构建系统。CMake 软件工具集让开发者完全控制一个项目的整个生命周期:

  • CMake 让你描述你的项目,无论是构建可执行文件、库还是两者,都必须如何在所有主要硬件和操作系统上进行配置、构建和安装。

  • CTest 允许你定义测试、测试套件以及设置它们应该如何执行。

  • CPack 提供了一个 DSL 来满足你所有的打包需求,无论你的项目应该以源代码还是平台原生二进制形式打包和分发。

  • CDash 将有助于将项目测试结果报告到在线仪表板上。

一句古老的谚语说,你挖得越深,找到的石头就越多。为了准备这本书,我们深入挖掘了许多软件层,CMake 是我们的矿场。在各种平台上构建各种软件组件和库时,我们遇到了许多石头和文物,每个都有自己的怪癖,有时感到沮丧。但我们相信我们已经清理了许多石头,并很高兴与您,我们的读者分享我们的发现和配方。总会有石头留下,但每块石头都会带来新的见解,我们鼓励您与社区分享这些见解。

本书的目标读者

编写能够在多种平台上原生、可靠且高效运行的软件对于各行各业和社会至关重要。软件构建系统在这一任务中占据中心位置。它们是软件开发生命周期管理的关键部分:从孵化和原型开发到测试,直至打包、部署和分发。CMake 旨在帮助您管理这些操作:如果您是希望使用 CMake 管理构建系统的软件开发者,或者希望理解和修改他人编写的 CMake 代码,那么本书适合您。

本书内容概述

我们编写这本书作为一系列逐步的任务和配方。在每个点,我们介绍足够的 CMake 信息来展示如何实现我们的目标,而不会让您被细节淹没。到本书结束时,您将能够处理越来越复杂的操作,并自信地利用配方中的内容在您自己的实际项目中。

本书将涵盖以下主题:

  • 使用 CMake 配置、构建、测试和安装代码项目

  • 检测操作系统、处理器、库、文件和程序以进行条件编译

  • 提高代码的可移植性

  • 借助 CMake 将大型代码库重构为模块

  • 构建多语言项目

  • 了解如何调整他人编写的 CMake 配置文件

  • 打包项目以供分发

  • 将项目迁移至 CMake

由 CMake 管理项目的流程发生在多个阶段,我们称之为时刻。这些可以简洁地概括在以下图中:

  • CMake 时刻配置时刻。这是 CMake 运行的时候。在这个阶段,CMake 将处理您项目中的CMakeLists.txt文件并进行配置。

  • 生成时刻。在成功配置后,CMake 将生成由本地构建工具执行项目后续步骤所需的脚本。

  • 构建时间。这是在平台上调用本地构建工具的时候,这些工具会使用之前由 CMake 生成的平台和工具本地的构建脚本。在这个阶段,编译器将被调用,目标(可执行文件和库)将在特定的构建目录中构建。注意递归的 CMake 时间箭头:这可能看起来令人困惑,但它是一种机制,我们将在本书中多次使用它来实现真正平台无关的构建。

  • CTest 时间测试时间。这是我们运行项目测试套件以检查目标是否按预期执行的时候。

  • CDash 时间报告时间。这是将测试项目的结果上传到仪表板以与其他开发人员共享的时候。

  • 安装时间。这是将项目的目标、源文件、可执行文件和库从构建目录安装到安装位置的时候。

  • CPack 时间打包时间。这是我们打包项目以供分发的时候,无论是作为源代码还是二进制。

  • 包安装时间。这是新创建的包被系统全局安装的时候。

本书的组织结构如下:

第一章,从简单的可执行文件到库,展示了如何开始使用 CMake 配置和构建简单的可执行文件和库。

第二章,检测环境,解释了如何使用简单的 CMake 命令与操作系统和处理器架构交互。

第三章,检测外部库和程序,展示了 CMake 如何简化项目依赖项的检测。

第四章,创建和运行测试,解释了如何利用 CMake 和 CTest 的力量来定义和运行测试。

第五章,配置时间和构建时间操作,展示了如何使用跨平台的 CMake 命令在构建过程的不同阶段执行自定义操作。

第六章,生成源代码,讨论了 CMake 命令,用于自动生成源代码。

第七章,项目结构化,展示了强大的 CMake 语法,用于组织项目,使其更易于维护。

第八章,超级构建模式,解释了强大的 CMake 超级构建模式,用于管理关键项目依赖项,同时控制副作用。

第九章,混合语言项目,展示了如何使用 CMake 构建混合不同编程语言的项目。

第十章,编写安装程序,负责使用 CMake 的跨平台能力来安装项目。

第十一章,打包项目,展示了如何使用 CPack 生成源代码和平台原生源代码存档,以及如何构建 Python 和 Conda 包以供分发。

第十二章,构建文档,展示了如何使用 CMake 为你的代码构建文档。

第十三章,替代生成器和交叉编译,展示了如何使用 CMake 在不同平台之间交叉编译项目。

第十四章,测试仪表板,展示了如何将测试结果报告给在线仪表板。

第十五章,将项目移植到 CMake,展示了将项目移植到基于 CMake 的构建系统的最佳实践、技巧和诀窍。

为了从本书中获得最大收益

这是一本由程序员为程序员编写的书。我们假设具备以下基本知识和熟悉度:

  • 你最喜欢的操作系统上的命令行

  • 你最喜欢的操作系统上用于构建软件的原生工具

  • 编译语言 C++、C 或 Fortran,以及你最喜欢的操作系统上的相应编译器

  • Python 编程语言

下载示例代码文件

你可以从github.com/dev-cafe/cmake-cookbook下载本书的示例代码。更多详情请参阅设置你的系统部分。

下载彩色图像

我们还提供了一个包含本书中使用的屏幕截图/图表的彩色图像的 PDF 文件。你可以在这里下载:www.packtpub.com/sites/default/files/downloads/CMakeCookbook_ColorImages.pdf

使用的约定

本书中使用了多种文本约定。

CodeInText:表示文本中的代码命令、文件夹名、文件名、模块名和目标名。

代码块设置如下:

cmake_minimum_required(VERSION 3.5 FATAL_ERROR)

project(recipe-01 LANGUAGES CXX)

add_executable(hello-world hello-world.cpp)

任何命令行输入都以粗体书写,并在要输入的命令前包含一个**$**提示符:

$ mkdir -p build
$ cd build
$ cmake ..

为了区分命令行输入和输出,我们将输出保持非粗体:

$ ./hello-world

Hello World!

重要提示以这种方式出现。

提示和技巧以这种方式出现。

额外的阅读资源

CMake 的在线文档非常全面,我们将在本书中引用它:cmake.org/documentation/

在准备本书时,我们还受到了其他资源的启发:

我们还推荐浏览 Viktor Kirilov 精心收集的 CMake 资源、脚本、模块和示例列表:github.com/onqtam/awesome-cmake

值得一提的是,我们的书籍并不是市面上唯一涵盖 CMake 的书籍:

  • 《Mastering CMake》由 Ken Martin 和 Bill Hoffman 于 2015 年编写,Kitware Inc.出版。

  • 《Professional CMake》由 Craig Scott 编写:crascit.com/professional-cmake/

联系我们

我们始终欢迎读者的反馈。

源代码改进和问题:请将拉取请求直接发送到github.com/dev-cafe/cmake-cookbook,并通过github.com/dev-cafe/cmake-cookbook/issues报告特定食谱的问题。

一般反馈:发送电子邮件至feedback@packtpub.com,并在您的消息主题中提及书名。如果您对本书的任何方面有疑问,请发送电子邮件至questions@packtpub.com

勘误:尽管我们已经尽一切努力确保内容的准确性,但错误仍然会发生。如果您在这本书中发现了错误,我们非常感谢您向我们报告。请访问www.packtpub.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入详细信息。

盗版:如果您在互联网上遇到任何形式的我们作品的非法副本,我们非常感谢您提供位置地址或网站名称。请通过copyright@packtpub.com与我们联系,并附上材料链接。

如果您有兴趣成为作者:如果您在某个领域拥有专业知识,并且对撰写或参与编写书籍感兴趣,请访问authors.packtpub.com

评论

请留下评论。在阅读和使用本书后,为何不在购买它的网站上留下评论呢?潜在读者可以看到并使用您的客观意见来做出购买决定,我们 Packt 可以了解您对我们产品的看法,我们的作者可以看到您对他们书籍的反馈。谢谢!

如需了解更多关于 Packt 的信息,请访问packtpub.com

第一章:设置您的系统

在深入了解 CMake 和本书中的食谱之前,您需要设置您的系统以成功运行所有示例。在本节中,我们将讨论以下主题:

  1. 如何获取食谱的代码

  2. 如何在 GNU/Linux、macOS 和 Windows 上安装运行代码示例所需的所有工具

  3. 仓库的自动化测试是如何工作的

  4. 如何报告食谱中的问题并提出改进建议

我们努力使本书中主题的讨论尽可能对初学者友好。然而,本书并不是从零开始的。我们假设您对所选平台上的原生软件构建工具有基本的了解。拥有使用 Git 进行版本控制的基本经验也是有帮助的(但不是必需的),以便与保存食谱源代码的仓库进行交互。

获取代码

本书中食谱的源代码可在 GitHub 上找到,地址为github.com/dev-cafe/cmake-cookbook。代码采用标准的开源 MIT 许可证:这是一种宽容的软件许可证,您可以以任何您认为合适的方式重用和混编代码,只要在软件/源代码的任何副本中包含原始版权和许可证通知即可。许可证的全文可在opensource.org/licenses/MIT查看。

为了自己测试食谱,您需要安装 Git,安装方法如下:

  • 所有主要的 GNU/Linux 发行版都提供了预打包的 Git,通过它们的包管理器。如果不是您的情况,可以从 Git 项目网站git-scm.com下载二进制分发版。

  • 在 macOS 上,可以使用 Homebrew 或 MacPorts 来安装 Git。

  • 在 Windows 上,您可以从 Git 项目网站git-scm.com下载 Git 可执行文件。

或者,您可以通过 GitHub 桌面客户端访问示例,地址为desktop.github.com

另一种选择是从github.com/dev-cafe/cmake-cookbook下载并提取 ZIP 文件。

安装 Git 后,您可以将其克隆到本地机器上,如下所示:

$ git clone https://github.com/dev-cafe/cmake-cookbook.git

这将创建一个名为cmake-cookbook的文件夹。本书和仓库按章节和食谱组织。章节编号和仓库中食谱的顺序反映了文本中的顺序。每个食谱进一步组织成示例文件夹。有些食谱有多个示例,通常是在不同的编程语言中说明类似的 CMake 概念时。

食谱在 GNU/Linux、macOS 和 Windows 上使用最先进的持续集成服务进行测试。我们将在稍后讨论测试设置。

我们已经为本书中示例对应的精确版本打上了标签v1.0。为了与书中的文本最大限度地重叠,你可以按照以下方式获取这个特定版本:

$ git clone --single-branch -b v1.0 https://github.com/dev-cafe/cmake-cookbook.git

我们预计会收到错误修复,并且 GitHub 仓库会不断发展。为了获取最新更新,你可能更愿意关注仓库的master分支。

Docker 镜像

你可能会发现,在软件环境中测试本书的配方(该环境包含所有预装的依赖项)最简单的方法是使用我们基于 Ubuntu 18.04 设置的 Docker 镜像。你可以在你喜欢的操作系统上安装 Docker,按照官方文档的指导进行操作,网址为docs.docker.com

安装 Docker 后,你可以运行我们的镜像,并在完整的软件环境中测试配方,如下所示:

$ docker run -it devcafe/cmake-cookbook_ubuntu-18.04
$ git clone https://github.com/dev-cafe/cmake-cookbook.git
$ cd cmake-cookbook
$ pipenv install --three
$ pipenv run python testing/collect_tests.py 'chapter-*/recipe-*'

安装预置软件

在容器中运行本书配方的替代方法是直接在主机操作系统上安装依赖项。为此,我们组装了一个最小工具栈,可以作为我们所有配方的基本起点。你需要安装以下内容:

  1. CMake

  2. 特定语言的工具,即编译器

  3. 构建自动化工具

  4. Python

我们还将详细说明如何安装一些配方所需的额外依赖项。

获取 CMake

本书所需的 CMake 最低版本为 3.5。只有少数特定配方和示例会展示在 3.5 版本之后引入的有用功能,这些将需要更新的 CMake 版本。每个配方的介绍中都有一个信息框,指出代码的可用位置、给出的示例以及所需的最低 CMake 版本。信息框将如下所示:

本配方的代码可在github.com/dev-cafe/cmake-cookbook/tree/v1.0/chapter-03/recipe-10找到,并包含一个 C 语言示例。该配方适用于 CMake 版本 3.5(及以上),并在 GNU/Linux、macOS 和 Windows 上进行了测试。

尽管大多数配方仍然适用于较旧版本的 CMake,但我们没有尝试测试这一假设,因为我们认为 CMake 3.5 是大多数系统和发行版上的默认版本。我们也认为升级到较新版本的 CMake 是一个简单的步骤。

CMake 可以通过多种方式安装。下载并提取 Kitware 维护的二进制分发版将在所有平台上工作。下载页面位于cmake.org/download/

大多数 GNU/Linux 发行版在其包管理器中都有 CMake 可用。然而,在某些发行版上,打包的版本可能相当陈旧,因此下载由 Kitware 维护的二进制文件仍然是首选选项。以下命令将从 CMake 打包的版本下载并安装 CMake 3.5.2 到$HOME/Deps/cmake(根据您的喜好调整此路径):

$ cmake_version="3.5.2"
$ target_path=$HOME/Deps/cmake/${cmake_version}
$ cmake_url="https://cmake.org/files/v${cmake_version%.*}/cmake-${cmake_version}-Linux-x86_64.tar.gz"
$ mkdir -p "${target_path}"
$ curl -Ls "${cmake_url}" | tar -xz -C "${target_path}" --strip-components=1
$ export PATH=$HOME/Deps/cmake/${cmake_version}/bin${PATH:+:$PATH}
$ cmake --version

Homebrew for macOS 可靠地提供最新版本的 CMake:

$ brew upgrade cmake

在 Windows 上,您可以使用提供 CMake 支持的 Visual Studio 2017。Visual Studio 2017 的安装在第十三章,替代生成器和交叉编译,食谱 1,使用 Visual Studio 2017 构建 CMake 项目中有详细记录。

或者,您可以从www.msys2.org下载 MSYS2 安装程序,按照其中的说明更新包列表,然后使用包管理器pacman安装 CMake。以下代码假设我们正在构建 64 位版本:

$ pacman -S mingw64/mingw-w64-x86_64-cmake

对于 32 位版本,请使用以下命令(尽管为了简洁起见,我们将来只会提及 64 位版本):

$ pacman -S mingw64/mingw-w64-i686-cmake

MSYS2 的另一个不错的特点是,它为 Windows 提供了一个终端,感觉和行为类似于 Unix 类操作系统上的终端,提供了一个有用的开发环境。

编译器

我们将需要 C++、C 和 Fortran 的编译器。这些应该相当新,因为我们在大多数食谱中需要对最新语言标准的支持。CMake 对许多编译器提供了非常好的支持,无论是商业的还是非商业的供应商。为了使食谱保持跨平台一致性,并尽可能操作系统独立,我们使用了开源编译器:

  • 在 GNU/Linux 上,GNU 编译器集合(GCC)是显而易见的选择。它是免费的,适用于所有发行版。例如,在 Ubuntu 上,您可以按照以下方式安装编译器:
$ sudo apt-get install g++ gcc gfortran 
  • Clang,属于 LLVM 家族,也是 C++和 C 的一个好选择:
$ sudo apt-get install clang clang++ gfortran
  • 在 macOS 上,随 XCode 一起提供的 LLVM 编译器适用于 C++和 C。在我们的 macOS 测试中,我们使用了 GCC 的 Fortran 编译器。这需要单独安装,使用包管理器。例如,Homebrew 的命令如下:
$ brew install gcc
  • 在 Windows 上,您可以使用 Visual Studio 进行 C++和 C 食谱。或者,您可以使用 MSYS2 安装程序并安装整个工具链,包括 C++、C 和 Fortran 编译器,使用以下单个命令在 MSYS2 环境中(对于 64 位版本):
$ pacman -S mingw64/mingw-w64-x86_64-toolchain

构建自动化工具

这些构建自动化工具将为构建和链接本食谱中介绍的项目提供基础设施。您最终安装和使用的工具很大程度上取决于您的操作系统和个人喜好:

  • 在 GNU/Linux 上,安装编译器时,GNU Make 很可能会自动安装。

  • 在 macOS 上,XCode 将提供 GNU Make。

  • 在 Windows 上,Visual Studio 将为你提供完整的基础设施。在 MSYS2 环境中,GNU Make 作为mingw64/mingw-w64-x86_64-toolchain包的一部分安装,这是我们之前安装的。

为了最大程度的可移植性,我们尽可能地使配方对这些系统依赖细节保持中立。这种方法的一个明显优势是,配置、构建和链接对于每个平台和每组编译器都是本地的。

Ninja 程序是一个不同的构建自动化工具,适用于 GNU/Linux、macOS 和 Windows。Ninja 是一个新的构建工具,专注于速度,特别是增量重建。预打包的二进制文件可以在项目的 GitHub 仓库中找到,网址为github.com/ninja-build/ninja/releases

使用 CMake 和 Ninja 与 Fortran 项目需要一些注意。需要 CMake 3.7.2 或更高版本,以及 Kitware 维护的 Ninja 版本,可在github.com/Kitware/ninja/releases找到。

在 GNU/Linux 上,你可以通过以下一系列命令安装 Ninja:

$ mkdir -p ninja
$ ninja_url="https://github.com/Kitware/ninja/releases/download/v1.8.2.g3bbbe.kitware.dyndep-1.jobserver-1/ninja-1.8.2.g3bbbe.kitware.dyndep-1.jobserver-1_x86_64-linux-gnu.tar.gz"
$ curl -Ls ${ninja_url} | tar -xz -C ninja --strip-components=1
$ export PATH=$HOME/Deps/ninja${PATH:+:$PATH} 

在 Windows 上,使用 MSYS2 环境(假设是 64 位版本),执行以下命令:

$ pacman -S mingw64/mingw-w64-x86_64-ninja

我们建议阅读www.aosabook.org/en/posa/ninja.html上的文章,以获得关于 Ninja 的历史和设计选择的启发性讨论。

Python

本书是关于 CMake 的,但其中一些配方,以及整个用于测试的基础设施,需要 Python。因此,首先,你需要一个可用的 Python 安装:解释器、头文件和库。Python 2.7 的生命周期已于 2020 年结束,因此我们将使用 Python 3.5。

在 Ubuntu 14.04 LTS 上(这是 Travis CI 使用的环境,我们将在后面讨论),可以按如下方式安装 Python 3.5:

$ sudo apt-get install python3.5-dev

在 Windows 上,使用 MSYS2 环境,可以按如下方式安装 Python 环境(假设是 64 位版本):

$ pacman -S mingw64/mingw-w64-x86_64-python3
$ pacman -S mingw64/mingw-w64-x86_64-python3-pip
$ python3 -m pip install pipenv 

还需要特定的 Python 模块,以便运行我们设置的测试机制。这些可以通过使用你喜欢的包管理器全局安装,或者在隔离环境中安装。后者方法强烈推荐,因为它提供了以下优势:

  • 你可以安装包并清理安装,而不会影响系统环境。

  • 无需管理员权限即可安装包。

  • 你降低了版本和依赖冲突的风险。

  • 你可以更好地控制包依赖关系,以实现可重复性。

我们已经为此准备了一个Pipfile。结合其Pipfile.lock,您可以使用 Pipenv(pipenv.readthedocs.io)来生成一个隔离环境,其中安装了所有软件包。要在配方示例仓库中创建此环境,请在仓库的顶级目录中运行以下命令:

$ pip install --user pip pipenv --upgrade
$ pipenv install --python python3.5

pipenv shell命令将使您进入一个命令行环境,其中包含特定版本的 Python 和所有可用的软件包。执行exit将带您回到一个干净的环境。您也可以使用pipenv run直接在隔离环境中执行命令。

或者,可以使用仓库中的requirements.txt文件,结合 Virtualenv(docs.python-guide.org/en/latest/dev/virtualenvs/)和pip,来达到同样的效果:

$ virtualenv --python=python3.5 venv
$ source venv/bin/activate
$ pip install -r requirements.txt

可以通过使用deactivate命令退出虚拟环境。

另一种选择是使用 Conda 环境。为此,我们建议安装 Miniconda。以下指令将安装最新的 Miniconda 到目录$HOME/Deps/conda,适用于 GNU/Linux(从repo.continuum.io/miniconda/Miniconda3-latest-Linux-x86_64.sh下载)或 macOS(从repo.continuum.io/miniconda/Miniconda3-latest-MacOSX-x86_64.sh下载):

$ curl -Ls https://repo.continuum.io/miniconda/Miniconda3-latest-Linux-x86_64.sh > miniconda.sh
$ bash miniconda.sh -b -p "$HOME"/Deps/conda &> /dev/null
$ touch "$HOME"/Deps/conda/conda-meta/pinned
$ export PATH=$HOME/Deps/conda/bin${PATH:+:$PATH}
$ conda config --set show_channel_urls True
$ conda config --set changeps1 no
$ conda update --all
$ conda clean -tipy

在 Windows 上,您可以从repo.continuum.io/miniconda/Miniconda3-latest-Windows-x86_64.exe下载最新的 Miniconda。可以使用 PowerShell 按照以下方式安装软件包:

$basedir = $pwd.Path + "\"
$filepath = $basedir + "Miniconda3-latest-Windows-x86_64.exe"
$Anaconda_loc = "C:\Deps\conda"
$args = "/InstallationType=JustMe /AddToPath=0 /RegisterPython=0 /S /D=$Anaconda_loc"
Start-Process -FilePath $filepath -ArgumentList $args -Wait -Passthru

$conda_path = $Anaconda_loc + "\Scripts\conda.exe"
$args = "config --set show_channel_urls True"
Start-Process -FilePath "$conda_path" -ArgumentList $args -Wait -Passthru
$args = "config --set changeps1 no"
Start-Process -FilePath "$conda_path" -ArgumentList $args -Wait -Passthru
$args = "update --all"
Start-Process -FilePath "$conda_path" -ArgumentList $args -Wait -Passthru
$args = "clean -tipy"
Start-Process -FilePath "$conda_path" -ArgumentList $args -Wait -Passthru

安装 Conda 后,可以按照以下方式安装 Python 模块:

$ conda create -n cmake-cookbook python=3.5
$ conda activate cmake-cookbook
$ conda install --file requirements.txt

执行conda deactivate将使您退出隔离环境。

额外软件

一些配方将需要额外的软件,这些将在以下部分中介绍。

BLAS 和 LAPACK

大多数 Linux 发行版都提供了 BLAS 和 LAPACK 的软件包。例如,在 Ubuntu 14.04 LTS 上,您可以运行以下命令:

$ sudo apt-get install libatlas-dev liblapack-dev liblapacke-dev

在 macOS 上,随 XCode 一起提供的 Accelerate 库足以满足我们的需求。

在 Windows 上,使用 MSYS2 环境,可以按照以下方式安装这些库(假设是 64 位版本):

$ pacman -S mingw64/mingw-w64-x86_64-openblas

或者,您可以从 GitHub 下载 BLAS 和 LAPACK 的参考实现(github.com/Reference-LAPACK/lapack),并从源代码编译这些库。商业供应商可能会为其 BLAS 和 LAPACK API 的实现提供软件包,这些软件包作为适用于您平台的安装程序提供。

消息传递接口(MPI)

有许多商业和非商业的 MPI 实现。对于我们的入门目的,安装任何免费提供的非商业实现就足够了。在 Ubuntu 14.04 LTS 上,我们推荐 OpenMPI。可以使用以下命令安装它:

$ sudo apt-get install openmpi-bin libopenmpi-dev

对于 macOS,Homebrew 分发 MPICH:

$ brew install mpich

也可以从www.open-mpi.org/software/公开的源代码编译 OpenMPI。

对于 Windows,Microsoft MPI 实现可以通过msdn.microsoft.com/en-us/library/bb524831(v=vs.85).aspx安装。

Eigen 线性代数模板库

有些配方需要 Eigen 线性代数模板库,版本 3.3 或更高。如果您的包管理器不提供 Eigen,您可以从在线源代码存档(eigen.tuxfamily.org)安装它。例如,在 GNU/Linux 和 macOS 上,您可以将 Eigen 安装到目录$HOME/Deps/eigen,如下所示:

$ eigen_version="3.3.4"
$ mkdir -p eigen
$ curl -Ls http://bitbucket.org/eigen/eigen/get/${eigen_version}.tar.gz | tar -xz -C eigen --strip-components=1
$ cd eigen
$ cmake -H. -Bbuild_eigen -DCMAKE_INSTALL_PREFIX="$HOME/Deps/eigen" &> /dev/null
$ cmake --build build_eigen -- install &> /dev/null

Boost 库

Boost 包适用于每个操作系统;大多数 Linux 发行版都通过其包管理器提供包。例如,在 Ubuntu 14.04 LTS 上,可以使用以下命令安装 Boost Filesystem、Boost Python 和 Boost Test 库:

$ sudo apt-get install libboost-filesystem-dev libboost-python-dev libboost-test-dev

对于 macOS,MacPorts 和 Homebrew 都为较新版本的 Boost 提供了包。我们在 macOS 上的测试设置按如下方式安装 Boost:

$ brew cask uninstall --force oclint
$ brew uninstall --force --ignore-dependencies boost
$ brew install boost 
$ brew install boost-python3

预构建的 Windows 二进制分发版也可以从 Boost 网站www.boost.org下载。或者,您可以从www.boost.org下载源代码并自行编译库。

交叉编译器

在 Debian/Ubuntu 类系统上,可以使用以下命令安装交叉编译器:

$ sudo apt-get install gcc-mingw-w64 g++-mingw-w64 gfortran-mingw-w64

在 macOS 上,使用 Brew,交叉编译器可以按如下方式安装:

$ brew install mingw-w64

其他包管理器提供相应的包。

使用打包的交叉编译器的替代方案是使用 M 交叉环境(mxe.cc)从源代码构建它们。

ZeroMQ、pkg-config、UUID 和 Doxygen

在 Ubuntu 14.04 LTS 上,这些包可以按如下方式安装:

$ sudo apt-get install pkg-config libzmq3-dev doxygen graphviz-dev uuid-dev

在 macOS 上,我们建议使用 Brew 安装:

$ brew install ossp-uuid pkg-config zeromq doxygen

pkg-config程序和 UUID 库仅在类 Unix 系统上可用。

在 Windows 上,使用 MSYS2 环境,这些依赖项可以按如下方式安装(假设是 64 位版本):

$ pacman -S mingw64/mingw-w64-x86_64-zeromq
$ pacman -S mingw64/mingw-w64-x86_64-pkg-config
$ pacman -S mingw64/mingw-w64-x86_64-doxygen
$ pacman -S mingw64/mingw-w64-x86_64-graphviz

Conda 构建和部署工具

探索使用 Conda 打包的配方将需要系统上安装了 Miniconda 和 Conda 构建和部署工具。之前给出了安装 Miniconda 的说明。要在 GNU/Linux 和 macOS 上安装 Conda 构建和部署工具,请运行以下命令:

$ conda install --yes --quiet conda-build anaconda-client jinja2 setuptools
$ conda clean -tipsy
$ conda info -a

这些工具可以按如下方式在 Windows 上安装:

$conda_path = "C:\Deps\conda\Scripts\conda.exe"

$args = "install --yes --quiet conda-build anaconda-client jinja2 setuptools"
Start-Process -FilePath "$conda_path" -ArgumentList $args -Wait -Passthru

$args = "clean -tipsy"
Start-Process -FilePath "$conda_path" -ArgumentList $args -Wait -Passthru

$args = "info -a"
Start-Process -FilePath "$conda_path" -ArgumentList $args -Wait -Passthru

测试配方

这些配方在先进的持续集成(CI)服务上进行了测试:Travis(travis-ci.org)用于 GNU/Linux 和 macOS,Appveyor(www.appveyor.com)用于 Windows,以及 CircleCI(circleci.com)用于使用商业编译器的额外 GNU/Linux 测试。CI 服务的配置文件可以在仓库中找到(github.com/dev-cafe/cmake-cookbook/):.travis.yml 用于 Travis,.appveyor.yml 用于 Appveyor,以及 .circleci/config.yml 用于 CircleCI。Travis 和 Appveyor 的额外安装脚本可以在 testing/dependencies 文件夹中找到。

我们在 Travis GNU/Linux 基础设施上使用 CMake 3.5.2 和 CMake 3.12.1 测试这些配方。在 Travis macOS 基础设施上使用 CMake 3.12.1。在 Appveyor 上,测试使用 CMake 3.11.3。在 CircleCI 上,使用 CMake 3.12.1。

测试机制是一套包含在 testing 文件夹中的 Python 脚本。脚本 collect_tests.py 将运行测试并报告它们的状态。可以单独测试配方,也可以批量测试;collect_tests.py 接受一个正则表达式作为命令行输入,例如:

$ pipenv run python testing/collect_tests.py 'chapter-0[1,7]/recipe-0[1,2,5]'

此命令将运行第一章和第七章中第 1、2 和 5 个配方的测试。输出示例如下:

要获取更详细的输出,请设置VERBOSE_OUTPUT=ON

$ env VERBOSE_OUTPUT=ON pipenv run python testing/collect_tests.py 'chapter-*/recipe-*'

报告问题和提出改进建议

请在github.com/dev-cafe/cmake-cookbook/issues报告问题。

为了贡献更改,我们建议分叉仓库github.com/dev-cafe/cmake-cookbook并使用拉取请求提交更改,遵循help.github.com/articles/creating-a-pull-request-from-a-fork/

对于非简单的更改,我们建议首先在github.com/dev-cafe/cmake-cookbook/issues上打开一个问题来描述和讨论提议的更改,然后再发送拉取请求。

第二章:从简单的可执行文件到库

在本章中,我们将介绍以下内容:

  • 将单个源文件编译成可执行文件

  • 切换生成器

  • 构建和链接静态和共享库

  • 使用条件控制编译

  • 向用户展示选项

  • 指定编译器

  • 切换构建类型

  • 控制编译器标志

  • 设置语言标准

  • 使用控制流结构

引言

本章中的示例将引导您完成构建代码所需的基本任务:编译可执行文件、编译库、根据用户输入执行构建操作等。CMake 是一个构建系统生成器,特别适合于平台和编译器无关。我们努力在本章中展示这一方面。除非另有说明,所有示例都与操作系统无关;它们可以在不加修改的情况下在 GNU/Linux、macOS 和 Windows 上运行。

本书中的示例主要针对 C++项目,并使用 C++示例进行演示,但 CMake 也可用于其他语言的项目,包括 C 和 Fortran。对于任何给定的示例,只要合理,我们都尝试包括 C++、C 和 Fortran 的示例。这样,您就可以选择您喜欢的语言的示例。有些示例是专门为突出特定语言选择时需要克服的挑战而定制的。

将单个源文件编译成可执行文件

本示例的代码可在github.com/dev-cafe/cmake-cookbook/tree/v1.0/chapter-01/recipe-01找到,并提供了 C++、C 和 Fortran 的示例。本示例适用于 CMake 版本 3.5(及以上),并在 GNU/Linux、macOS 和 Windows 上进行了测试。

在本示例中,我们将演示如何运行 CMake 来配置和构建一个简单的项目。该项目由单个源文件组成,用于单个可执行文件。我们将讨论 C++项目,但 GitHub 存储库中提供了 C 和 Fortran 的示例。

准备工作

我们希望将以下源代码编译成一个单独的可执行文件:

#include <cstdlib>
#include <iostream>
#include <string>

std::string say_hello() { return std::string("Hello, CMake world!"); }

int main() {
  std::cout << say_hello() << std::endl;
  return EXIT_SUCCESS;
}

如何操作

除了源文件外,我们还需要向 CMake 提供一个描述,说明如何为构建工具配置项目。描述使用 CMake 语言完成,其全面的文档可以在cmake.org/cmake/help/latest/在线找到。我们将把 CMake 指令放入一个名为CMakeLists.txt的文件中。

文件名是区分大小写的;它必须被称为CMakeLists.txt,以便 CMake 能够解析它。

详细来说,以下是遵循的步骤:

  1. 使用您喜欢的编辑器打开一个文本文件。该文件将被命名为CMakeLists.txt

  2. 第一行设置 CMake 的最低要求版本。如果使用的 CMake 版本低于该版本,将发出致命错误:

cmake_minimum_required(VERSION 3.5 FATAL_ERROR)
  1. 第二行声明了项目的名称(recipe-01)和支持的语言(CXX代表 C++):
project(recipe-01 LANGUAGES CXX)
  1. 我们指示 CMake 创建一个新的目标:可执行文件hello-world。这个可执行文件是通过编译和链接源文件hello-world.cpp生成的。CMake 将使用所选编译器和构建自动化工具的默认设置:
add_executable(hello-world hello-world.cpp)
  1. 将文件保存在与源文件hello-world.cpp相同的目录中。请记住,它只能被命名为CMakeLists.txt

  2. 我们现在准备通过创建并进入构建目录来配置项目:

$ mkdir -p build
$ cd build
$ cmake ..

-- The CXX compiler identification is GNU 8.1.0
-- Check for working CXX compiler: /usr/bin/c++
-- Check for working CXX compiler: /usr/bin/c++ -- works
-- Detecting CXX compiler ABI info
-- Detecting CXX compiler ABI info - done
-- Detecting CXX compile features
-- Detecting CXX compile features - done
-- Configuring done
-- Generating done
-- Build files have been written to: /home/user/cmake-cookbook/chapter-01/recipe-01/cxx-example/build
  1. 如果一切顺利,项目配置已经在构建目录中生成。我们现在可以编译可执行文件了:
$ cmake --build .

Scanning dependencies of target hello-world
[ 50%] Building CXX object CMakeFiles/hello-world.dir/hello-world.cpp.o
[100%] Linking CXX executable hello-world
[100%] Built target hello-world

它是如何工作的

在这个示例中,我们使用了一个简单的CMakeLists.txt来构建一个“Hello world”可执行文件:

cmake_minimum_required(VERSION 3.5 FATAL_ERROR)

project(recipe-01 LANGUAGES CXX)

add_executable(hello-world hello-world.cpp)

CMake 语言是不区分大小写的,但参数是区分大小写的。

CMake 中,C++是默认的编程语言。然而,我们建议始终在project命令中使用LANGUAGES选项明确声明项目的语言。

为了配置项目并生成其构建系统,我们必须通过命令行界面(CLI)运行 CMake。CMake CLI 提供了许多开关,cmake --help将输出屏幕上列出所有可用开关的完整帮助菜单。我们将在本书中了解更多关于它们的信息。正如您将从cmake --help的输出中注意到的,大多数开关将允许您访问 CMake 手册。生成构建系统的典型命令序列如下:

$ mkdir -p build
$ cd build
$ cmake ..

在这里,我们创建了一个目录,build,其中将生成构建系统,我们进入了build目录,并通过指向CMakeLists.txt的位置调用了 CMake(在这种情况下位于父目录中)。可以使用以下调用来实现相同的效果:

$ cmake -H. -Bbuild

这个调用是跨平台的,并引入了-H-BCLI 开关。使用-H.我们指示 CMake 在当前目录中搜索根CMakeLists.txt文件。-Bbuild告诉 CMake 在名为build的目录中生成所有文件。

注意,cmake -H. -Bbuild调用 CMake 仍在进行标准化:cmake.org/pipermail/cmake-developers/2018-January/030520.html。这就是为什么我们在这本书中将使用传统方法(创建一个构建目录,进入它,并通过指向CMakeLists.txt的位置来配置项目)。

运行cmake命令会输出一系列状态消息来通知您配置情况:

$ cmake ..

-- The CXX compiler identification is GNU 8.1.0
-- Check for working CXX compiler: /usr/bin/c++
-- Check for working CXX compiler: /usr/bin/c++ -- works
-- Detecting CXX compiler ABI info
-- Detecting CXX compiler ABI info - done
-- Detecting CXX compile features
-- Detecting CXX compile features - done
-- Configuring done
-- Generating done
-- Build files have been written to: /home/user/cmake-cookbook/chapter-01/recipe-01/cxx-example/build

CMakeLists.txt所在的同一目录中运行cmake .原则上足以配置项目。但是,CMake 随后会将所有生成的文件写入项目的根目录。这将是一个源内构建,通常是不希望的,因为它混合了项目的源代码和构建树。我们演示的源外构建是首选实践。

CMake 是一个生成器构建系统。您描述了构建系统(如 Unix Makefiles、Ninja、Visual Studio 等)需要执行的操作类型,以便编译您的代码。然后,CMake 为所选的构建系统生成相应的指令。默认情况下,在 GNU/Linux 和 macOS 系统上,CMake 使用 Unix Makefiles 生成器。在 Windows 上,Visual Studio 是默认生成器。我们将在下一个配方中更详细地了解生成器,并在第十三章,替代生成器和交叉编译中重新审视生成器。

在 GNU/Linux 上,CMake 将默认生成 Unix Makefiles 以构建项目:

  • Makefilemake将运行以构建项目的一组指令。

  • CMakeFiles:该目录包含 CMake 用于检测操作系统、编译器等的临时文件。此外,根据所选的生成器,它还包含特定于项目的文件。

  • cmake_install.cmake:一个 CMake 脚本,用于处理安装规则,在安装时使用。

  • CMakeCache.txt:正如文件名所示,这是 CMake 的缓存文件。在重新运行配置时,CMake 会使用此文件。

要构建示例项目,我们运行了以下命令:

$ cmake --build .

此命令是一个通用的跨平台包装器,用于所选生成器的本地构建命令,在本例中为make。我们不应忘记测试我们的示例可执行文件:

$ ./hello-world

Hello, CMake world!

最后,我们应该指出,CMake 不强制要求特定的名称或特定的位置用于构建目录。我们可以将其完全放置在项目路径之外。这将同样有效:

$ mkdir -p /tmp/someplace
$ cd /tmp/someplace
$ cmake /path/to/source
$ cmake --build .

还有更多

官方文档位于cmake.org/runningcmake/,提供了运行 CMake 的简明概述。由 CMake 生成的构建系统,在上面的示例中为Makefile,将包含构建给定项目的对象文件、可执行文件和库的目标和规则。在当前示例中,hello-world可执行文件是我们唯一的目标,但是运行命令:

$ cmake --build . --target help

The following are some of the valid targets for this Makefile:
... all (the default if no target is provided)
... clean
... depend
... rebuild_cache
... hello-world
... edit_cache
... hello-world.o
... hello-world.i
... hello-world.s

揭示了 CMake 生成的目标比仅构建可执行文件本身所需的目标更多。可以使用cmake --build . --target <target-name>语法选择这些目标,并实现以下目标:

  • all(或使用 Visual Studio 生成器时的ALL_BUILD)是默认目标,将构建项目中的所有其他目标。

  • clean,是选择删除所有生成的文件的目标。

  • depend,将调用 CMake 为源文件生成任何依赖项。

  • rebuild_cache,将再次调用 CMake 来重建CMakeCache.txt。如果需要从源代码中添加新条目,这是必要的。

  • edit_cache,这个目标将允许你直接编辑缓存条目。

对于更复杂的项目,包括测试阶段和安装规则,CMake 将生成额外的便利目标:

  • test(或使用 Visual Studio 生成器时的RUN_TESTS)将使用 CTest 运行测试套件。我们将在第四章,创建和运行测试中详细讨论测试和 CTest。

  • install,将执行项目的安装规则。我们将在第十章,编写安装程序中讨论安装规则。

  • package,这个目标将调用 CPack 来为项目生成可重新分发的包。打包和 CPack 将在第十一章,打包项目中讨论。

切换生成器

本配方的代码可在github.com/dev-cafe/cmake-cookbook/tree/v1.0/chapter-01/recipe-02找到,并提供了 C++、C 和 Fortran 的示例。本配方适用于 CMake 3.5(及以上)版本,并在 GNU/Linux、macOS 和 Windows 上进行了测试。

CMake 是一个构建系统生成器,单个CMakeLists.txt可以用于为不同平台上的不同工具链配置项目。你可以在CMakeLists.txt中描述构建系统需要运行的操作来配置和编译你的代码。基于这些指令,CMake 将为所选构建系统(Unix Makefiles、Ninja、Visual Studio 等)生成相应的指令。我们将在第十三章,替代生成器和交叉编译中重新讨论生成器。

准备工作

CMake 支持大量不同平台的原生构建工具。无论是命令行工具,如 Unix Makefiles 和 Ninja,还是集成开发环境(IDE)工具,都得到支持。你可以通过运行以下命令来获取你平台和已安装的 CMake 版本上可用的生成器的最新列表:

$ cmake --help

此命令的输出将列出 CMake 命令行界面的所有选项。在底部,你将找到可用生成器的列表。例如,这是在安装了 CMake 3.11.2 的 GNU/Linux 机器上的输出:

Generators

The following generators are available on this platform:
  Unix Makefiles = Generates standard UNIX makefiles.
  Ninja = Generates build.ninja files.
  Watcom WMake = Generates Watcom WMake makefiles.
  CodeBlocks - Ninja = Generates CodeBlocks project files.

  CodeBlocks - Unix Makefiles = Generates CodeBlocks project files.
  CodeLite - Ninja = Generates CodeLite project files.
  CodeLite - Unix Makefiles = Generates CodeLite project files.
  Sublime Text 2 - Ninja = Generates Sublime Text 2 project files.
  Sublime Text 2 - Unix Makefiles = Generates Sublime Text 2 project files.
  Kate - Ninja = Generates Kate project files.
  Kate - Unix Makefiles = Generates Kate project files.
  Eclipse CDT4 - Ninja = Generates Eclipse CDT 4.0 project files.
  Eclipse CDT4 - Unix Makefiles= Generates Eclipse CDT 4.0 project files.

通过本配方,我们将展示为同一项目切换生成器是多么容易。

如何操作

我们将重用之前的配方中的hello-world.cppCMakeLists.txt。唯一的区别在于 CMake 的调用方式,因为我们现在必须使用-G命令行开关显式传递生成器。

  1. 首先,我们使用以下命令配置项目:
$ mkdir -p build
$ cd build
$ cmake -G Ninja ..

-- The CXX compiler identification is GNU 8.1.0
-- Check for working CXX compiler: /usr/bin/c++
-- Check for working CXX compiler: /usr/bin/c++ -- works
-- Detecting CXX compiler ABI info
-- Detecting CXX compiler ABI info - done
-- Detecting CXX compile features
-- Detecting CXX compile features - done
-- Configuring done
-- Generating done
-- Build files have been written to: /home/user/cmake-cookbook/chapter-01/recipe-02/cxx-example/build
  1. 在第二步,我们构建项目:
$ cmake --build .

[2/2] Linking CXX executable hello-world

它是如何工作的

我们已经看到,配置步骤的输出与之前的配方相比没有变化。然而,编译步骤的输出和构建目录的内容将会有所不同,因为每个生成器都有其特定的文件集:

  • build.ninjarules.ninja:包含 Ninja 的所有构建语句和构建规则。

  • CMakeCache.txt:无论选择哪种生成器,CMake 总是会在此文件中生成自己的缓存。

  • CMakeFiles:包含 CMake 在配置过程中生成的临时文件。

  • cmake_install.cmake:处理安装规则的 CMake 脚本,用于安装时使用。

注意 cmake --build . 是如何将 ninja 命令包装在一个统一的跨平台接口中的。

另请参阅

我们将在 第十三章,替代生成器和交叉编译中讨论替代生成器和交叉编译。

CMake 文档是了解生成器的良好起点:cmake.org/cmake/help/latest/manual/cmake-generators.7.html

构建和链接静态和共享库

本配方的代码可在 github.com/dev-cafe/cmake-cookbook/tree/v1.0/chapter-01/recipe-03 获取,并提供了 C++ 和 Fortran 的示例。本配方适用于 CMake 3.5(及以上)版本,并在 GNU/Linux、macOS 和 Windows 上进行了测试。

一个项目几乎总是由多个源文件构建的单个可执行文件组成。项目被拆分到多个源文件中,通常分布在源树的不同子目录中。这种做法不仅有助于在项目中组织源代码,而且极大地促进了模块化、代码重用和关注点分离,因为可以将常见任务分组到库中。这种分离还简化了项目开发过程中的重新编译并加快了速度。在本配方中,我们将展示如何将源分组到库中,以及如何将目标链接到这些库。

准备工作

让我们回到最初的例子。然而,我们不再使用单一的源文件来编译可执行文件,而是引入一个类来封装要打印到屏幕的消息。这是我们更新的 hello-world.cpp

#include "Message.hpp"

#include <cstdlib>
#include <iostream>

int main() {
  Message say_hello("Hello, CMake World!");

  std::cout << say_hello << std::endl;

  Message say_goodbye("Goodbye, CMake World");

  std::cout << say_goodbye << std::endl;

  return EXIT_SUCCESS;
}

Message 类封装了一个字符串,提供了对 << 操作符的重载,并由两个源文件组成:Message.hpp 头文件和相应的 Message.cpp 源文件。Message.hpp 接口文件包含以下内容:

#pragma once

#include <iosfwd>
#include <string>

class Message {
public:
  Message(const std::string &m) : message_(m) {}

  friend std::ostream &operator<<(std::ostream &os, Message &obj) {
    return obj.printObject(os);
  }

private:
  std::string message_;
  std::ostream &printObject(std::ostream &os);
};

相应的实现包含在 Message.cpp 中:

#include "Message.hpp"

#include <iostream>
#include <string>

std::ostream &Message::printObject(std::ostream &os) {
  os << "This is my very nice message: " << std::endl;
  os << message_;

  return os;
}

如何操作

这两个新文件也需要编译,我们需要相应地修改 CMakeLists.txt。然而,在这个例子中,我们希望先将它们编译成一个库,而不是直接编译成可执行文件:

  1. 创建一个新的 目标,这次是静态库。库的名称将是目标的名称,源代码列表如下:
add_library(message 
  STATIC
    Message.hpp
    Message.cpp
  )
  1. 创建 hello-world 可执行文件的目标未作修改:
add_executable(hello-world hello-world.cpp) 
  1. 最后,告诉 CMake 库目标需要链接到可执行目标:
target_link_libraries(hello-world message)
  1. 我们可以使用与之前相同的命令进行配置和构建。这次将编译一个库,与 hello-world 可执行文件一起:
$ mkdir -p build
$ cd build
$ cmake ..
$ cmake --build .

Scanning dependencies of target message
[ 25%] Building CXX object CMakeFiles/message.dir/Message.cpp.o
[ 50%] Linking CXX static library libmessage.a
[ 50%] Built target message
Scanning dependencies of target hello-world
[ 75%] Building CXX object CMakeFiles/hello-world.dir/hello-world.cpp.o
[100%] Linking CXX executable hello-world
[100%] Built target hello-world

$ ./hello-world

This is my very nice message: 
Hello, CMake World!
This is my very nice message: 
Goodbye, CMake World

工作原理

前面的示例介绍了两个新命令:

  • add_library(message STATIC Message.hpp Message.cpp):这将生成将指定源代码编译成库所需的构建工具指令。add_library 的第一个参数是目标的名称。在整个 CMakeLists.txt 中可以使用相同的名称来引用该库。生成的库的实际名称将由 CMake 通过在前面添加前缀 lib 和作为后缀的适当扩展名来形成。库扩展名是根据第二个参数 STATICSHARED 以及操作系统来确定的。

  • target_link_libraries(hello-world message):将库链接到可执行文件。此命令还将确保 hello-world 可执行文件正确依赖于消息库。因此,我们确保消息库总是在我们尝试将其链接到 hello-world 可执行文件之前构建。

成功编译后,构建目录将包含 libmessage.a 静态库(在 GNU/Linux 上)和 hello-world 可执行文件。

CMake 接受 add_library 的第二个参数的其他有效值,我们将在本书的其余部分遇到所有这些值:

  • STATIC,我们已经遇到过,将用于创建静态库,即用于链接其他目标(如可执行文件)的对象文件的归档。

  • SHARED 将用于创建共享库,即可以在运行时动态链接和加载的库。从静态库切换到动态共享对象(DSO)就像在 CMakeLists.txt 中使用 add_library(message SHARED Message.hpp Message.cpp) 一样简单。

  • OBJECT 可用于将传递给 add_library 的列表中的源代码编译成目标文件,但不将它们归档到静态库中,也不将它们链接到共享对象中。如果需要一次性创建静态库和共享库,使用对象库尤其有用。我们将在本示例中演示这一点。

  • MODULE 库再次是 DSOs。与 SHARED 库不同,它们不在项目内链接到任何其他目标,但可能会在以后动态加载。这是构建运行时插件时要使用的参数。

CMake 还能够生成特殊类型的库。这些库在构建系统中不产生输出,但在组织目标之间的依赖关系和构建要求方面非常有帮助:

在本例中,我们直接使用add_library收集源文件。在后面的章节中,我们将展示使用target_sourcesCMake 命令来收集源文件,特别是在第七章,项目结构化中。也可以参考 Craig Scott 的这篇精彩博文:crascit.com/2016/01/31/enhanced-source-file-handling-with-target_sources/,它进一步说明了使用target_sources命令的动机。

还有更多

现在让我们展示 CMake 中提供的对象库功能的使用。我们将使用相同的源文件,但修改CMakeLists.txt

cmake_minimum_required(VERSION 3.5 FATAL_ERROR)

project(recipe-03 LANGUAGES CXX)

add_library(message-objs
  OBJECT
    Message.hpp
    Message.cpp
  )

# this is only needed for older compilers
# but doesn't hurt either to have it
set_target_properties(message-objs
  PROPERTIES
    POSITION_INDEPENDENT_CODE 1
  )

add_library(message-shared
  SHARED
    $<TARGET_OBJECTS:message-objs>
  )

add_library(message-static
  STATIC
    $<TARGET_OBJECTS:message-objs>
  )

add_executable(hello-world hello-world.cpp)

target_link_libraries(hello-world message-static)

首先,注意add_library命令已更改为add_library(message-objs OBJECT Message.hpp Message.cpp)。此外,我们必须确保编译为对象文件生成位置无关代码。这是通过使用set_target_properties命令设置message-objs目标的相应属性来完成的。

对于目标显式设置POSITION_INDEPENDENT_CODE属性的需求可能只在某些平台和/或使用旧编译器时才会出现。

现在,这个对象库可以用来获取静态库(称为message-static)和共享库(称为message-shared)。需要注意的是,用于引用对象库的生成器表达式语法$<TARGET_OBJECTS:message-objs>。生成器表达式是 CMake 在生成时(即配置时间之后)评估的构造,以产生特定于配置的构建输出。另请参阅:cmake.org/cmake/help/latest/manual/cmake-generator-expressions.7.html。我们将在第五章,配置时间和构建时间操作中深入探讨生成器表达式。最后,hello-world可执行文件与message库的静态版本链接。

是否可以让 CMake 生成两个同名的库?换句话说,它们是否可以都称为message而不是message-staticmessage-shared?我们需要修改这两个目标的属性:

add_library(message-shared
  SHARED
    $<TARGET_OBJECTS:message-objs>
  )
set_target_properties(message-shared
  PROPERTIES
    OUTPUT_NAME "message"
  )

add_library(message-static
  STATIC
    $<TARGET_OBJECTS:message-objs>
  )
set_target_properties(message-static
  PROPERTIES
    OUTPUT_NAME "message"
  )

我们可以链接 DSO 吗?这取决于操作系统和编译器:

  1. 在 GNU/Linux 和 macOS 上,无论选择哪个编译器,它都能正常工作。

  2. 在 Windows 上,它无法与 Visual Studio 配合使用,但可以与 MinGW 和 MSYS2 配合使用。

为什么?生成好的 DSO 需要程序员限制符号可见性。这是通过编译器的帮助实现的,但在不同的操作系统和编译器上约定不同。CMake 有一个强大的机制来处理这个问题,我们将在第十章,编写安装程序中解释它是如何工作的。

使用条件控制编译

本节代码可在github.com/dev-cafe/cmake-cookbook/tree/v1.0/chapter-01/recipe-04找到,并包含一个 C++示例。本节适用于 CMake 版本 3.5(及以上),并在 GNU/Linux、macOS 和 Windows 上进行了测试。

到目前为止,我们研究的项目相对简单,CMake 的执行流程是线性的:从一组源文件到一个单一的可执行文件,可能通过静态或共享库。为了确保对项目构建过程中所有步骤的执行流程有完全的控制,包括配置、编译和链接,CMake 提供了自己的语言。在本节中,我们将探讨使用条件结构if-elseif-else-endif

CMake 语言相当庞大,包括基本控制结构、CMake 特定命令以及用于模块化扩展语言的新函数的基础设施。完整的概述可以在线找到:cmake.org/cmake/help/latest/manual/cmake-language.7.html

如何操作

让我们从与上一个配方相同的源代码开始。我们希望能够在这两种行为之间切换:

  1. Message.hppMessage.cpp编译成一个库,无论是静态还是共享,然后将生成的库链接到hello-world可执行文件中。

  2. Message.hppMessage.cpphello-world.cpp编译成一个单一的可执行文件,不生成库。

让我们构建CMakeLists.txt以实现这一点:

  1. 我们首先定义最小 CMake 版本、项目名称和支持的语言:
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)

project(recipe-04 LANGUAGES CXX)
  1. 我们引入了一个新变量,USE_LIBRARY。这是一个逻辑变量,其值将被设置为OFF。我们还打印其值供用户查看:
set(USE_LIBRARY OFF)

message(STATUS "Compile sources into a library? ${USE_LIBRARY}")
  1. 将 CMake 中定义的BUILD_SHARED_LIBS全局变量设置为OFF。调用add_library并省略第二个参数将构建一个静态库:
set(BUILD_SHARED_LIBS OFF)
  1. 然后,我们引入一个变量_sources,列出Message.hppMessage.cpp
list(APPEND _sources Message.hpp Message.cpp)
  1. 然后,我们根据USE_LIBRARY的值引入一个if-else语句。如果逻辑开关为真,Message.hppMessage.cpp将被打包成一个库:
if(USE_LIBRARY)
  # add_library will create a static library
  # since BUILD_SHARED_LIBS is OFF
  add_library(message ${_sources})

  add_executable(hello-world hello-world.cpp)

  target_link_libraries(hello-world message)
else()
  add_executable(hello-world hello-world.cpp ${_sources})
endif()
  1. 我们可以再次使用相同的命令集进行构建。由于USE_LIBRARY设置为OFF,所有源文件将被编译成hello-world可执行文件。这可以通过在 GNU/Linux 上运行objdump -x命令来验证。

工作原理

我们引入了两个变量:USE_LIBRARYBUILD_SHARED_LIBS。两者都设置为OFF。正如 CMake 语言文档中所详述的,真或假值可以用多种方式表达:

  • 逻辑变量在以下情况下为真:设置为1ONYESTRUEY或非零数字。

  • 逻辑变量在以下情况下为假:设置为0OFFNOFALSENIGNORENOTFOUND、空字符串或以-NOTFOUND结尾。

USE_LIBRARY变量将在第一种和第二种行为之间切换。BUILD_SHARED_LIBS是 CMake 提供的一个全局标志。记住,add_library命令可以在不传递STATIC/SHARED/OBJECT参数的情况下调用。这是因为,内部会查找BUILD_SHARED_LIBS全局变量;如果为假或未定义,将生成一个静态库。

这个例子展示了在 CMake 中引入条件语句以控制执行流程是可能的。然而,当前的设置不允许从外部设置开关,也就是说,不通过手动修改CMakeLists.txt。原则上,我们希望将所有开关暴露给用户,以便在不修改构建系统代码的情况下调整配置。我们将在稍后展示如何做到这一点。

else()endif()中的()可能会在你开始阅读和编写 CMake 代码时让你感到惊讶。这些的历史原因是能够指示作用域。例如,如果这有助于读者理解,可以使用if(USE_LIBRARY) ... else(USE_LIBRARY) ... endif(USE_LIBRARY)。这是一个品味问题。

在引入_sources变量时,我们向代码的读者表明这是一个不应在当前作用域外使用的局部变量,方法是将其前缀加上一个下划线。

向用户展示选项

本食谱的代码可在github.com/dev-cafe/cmake-cookbook/tree/v1.0/chapter-01/recipe-05找到,并包含一个 C++示例。该食谱适用于 CMake 版本 3.5(及以上),并在 GNU/Linux、macOS 和 Windows 上进行了测试。

在上一食谱中,我们以相当僵硬的方式引入了条件:通过引入具有硬编码真值的变量。有时这可能很有用,但它阻止了代码用户轻松切换这些变量。僵硬方法的另一个缺点是,CMake 代码没有向读者传达这是一个预期从外部修改的值。在项目构建系统生成中切换行为的推荐方法是使用option()命令在CMakeLists.txt中将逻辑开关作为选项呈现。本食谱将向您展示如何使用此命令。

如何操作

让我们回顾一下上一食谱中的静态/共享库示例。我们不再将USE_LIBRARY硬编码为ONOFF,而是更倾向于将其作为具有默认值的选项公开,该默认值可以从外部更改:

  1. 将上一食谱中的set(USE_LIBRARY OFF)命令替换为具有相同名称和默认值为OFF的选项。
option(USE_LIBRARY "Compile sources into a library" OFF)
  1. 现在,我们可以通过将信息传递给 CMake 的-D CLI 选项来切换库的生成:
$ mkdir -p build
$ cd build
$ cmake -D USE_LIBRARY=ON ..

-- ...
-- Compile sources into a library? ON
-- ...

$ cmake --build .

Scanning dependencies of target message
[ 25%] Building CXX object CMakeFiles/message.dir/Message.cpp.o
[ 50%] Linking CXX static library libmessage.a
[ 50%] Built target message
Scanning dependencies of target hello-world
[ 75%] Building CXX object CMakeFiles/hello-world.dir/hello-world.cpp.o
[100%] Linking CXX executable hello-world
[100%] Built target hello-world

-D开关用于为 CMake 设置任何类型的变量:逻辑值、路径等。

工作原理

option命令接受三个参数:

 option(<option_variable> "help string" [initial value])
  • <option_variable>是代表选项的变量名。

  • "帮助字符串"是记录选项的字符串。此文档在 CMake 的终端或图形用户界面中可见。

  • [初始值]是选项的默认值,可以是ONOFF

还有更多

有时需要引入依赖于其他选项值的选项。在我们的示例中,我们可能希望提供生成静态或共享库的选项。但是,如果USE_LIBRARY逻辑未设置为ON,则此选项将没有意义。CMake 提供了cmake_dependent_option()命令来定义依赖于其他选项的选项:

include(CMakeDependentOption)

# second option depends on the value of the first
cmake_dependent_option(
  MAKE_STATIC_LIBRARY "Compile sources into a static library" OFF
  "USE_LIBRARY" ON
  )

# third option depends on the value of the first
cmake_dependent_option(
  MAKE_SHARED_LIBRARY "Compile sources into a shared library" ON
  "USE_LIBRARY" ON
  )

如果USE_LIBRARY设置为ON,则MAKE_STATIC_LIBRARY默认为OFF,而MAKE_SHARED_LIBRARY默认为ON。因此,我们可以运行以下命令:

$ cmake -D USE_LIBRARY=OFF -D MAKE_SHARED_LIBRARY=ON ..

这仍然不会构建库,因为USE_LIBRARY仍然设置为OFF

如前所述,CMake 通过包含模块来扩展其语法和功能,这些模块可以是 CMake 自带的,也可以是自定义的。在这种情况下,我们包含了一个名为CMakeDependentOption的模块。如果没有包含语句,cmake_dependent_option()命令将不可用。另请参阅cmake.org/cmake/help/latest/module/CMakeDependentOption.html

任何模块的手册页也可以使用cmake --help-module <name-of-module>从命令行访问。例如,cmake --help-option CMakeDependentOption将打印刚刚讨论的模块的手册页。

指定编译器

本食谱的代码可在github.com/dev-cafe/cmake-cookbook/tree/v1.0/chapter-01/recipe-06获取,并包含一个 C++/C 示例。该食谱适用于 CMake 版本 3.5(及更高版本),并在 GNU/Linux、macOS 和 Windows 上进行了测试。

到目前为止,我们没有过多考虑的一个方面是编译器的选择。CMake 足够复杂,可以根据平台和生成器选择最合适的编译器。CMake 还能够将编译器标志设置为一组合理的默认值。然而,我们通常希望控制编译器的选择,在本食谱中,我们将展示如何做到这一点。在后面的食谱中,我们还将考虑构建类型的选择,并展示如何控制编译器标志。

如何操作

我们如何选择特定的编译器?例如,如果我们想使用 Intel 或 Portland Group 编译器怎么办?CMake 为每种语言的编译器存储在CMAKE_<LANG>_COMPILER变量中,其中<LANG>是任何受支持的语言,对我们来说,CXXCFortran。用户可以通过以下两种方式之一设置此变量:

  1. 通过在 CLI 中使用-D选项,例如:
$ cmake -D CMAKE_CXX_COMPILER=clang++ ..
  1. 通过导出环境变量CXX用于 C++编译器,CC用于 C 编译器,FC用于 Fortran 编译器。例如,使用此命令将 clang++作为 C++编译器:
$ env CXX=clang++ cmake ..

到目前为止讨论的任何配方都可以通过传递适当的选项配置为与任何其他编译器一起使用。

CMake 了解环境,并且许多选项可以通过其 CLI 的-D开关通过环境变量设置。前者机制覆盖后者,但我们建议始终使用-D显式设置选项。显式优于隐式,因为环境变量可能设置为不适合当前项目的值。

我们在这里假设额外的编译器在 CMake 进行查找的标准路径中可用。如果不是这种情况,用户需要传递编译器可执行文件或包装器的完整路径

我们建议使用-D CMAKE_<LANG>_COMPILER CLI 选项设置编译器,而不是导出CXXCCFC。这是唯一保证跨平台兼容且与非 POSIX shell 兼容的方法。它还可以避免用可能影响与项目一起构建的外部库的环境的变量污染您的环境。

它是如何工作的

在配置时,CMake 执行一系列平台测试,以确定哪些编译器可用,以及它们是否适合手头的项目。合适的编译器不仅由我们工作的平台决定,还由我们要使用的生成器决定。CMake 执行的第一个测试基于项目语言的编译器名称。例如,如果cc是一个工作的 C 编译器,那么它将用作 C 项目的默认编译器。在 GNU / Linux 上,使用 Unix Makefiles 或 Ninja,GCC 家族的编译器将最有可能被默认选择用于 C ++,C 和 Fortran。在 Microsoft Windows 上,如果选择 Visual Studio 作为生成器,则将选择 Visual Studio 中的 C ++和 C 编译器。如果选择 MinGW 或 MSYS Makefiles 作为生成器,则默认使用 MinGW 编译器。

还有更多

我们可以在哪里找到 CMake 将为我们平台选择哪些默认编译器和编译器标志?CMake 提供了--system-information标志,该标志会将有关您系统的所有信息转储到屏幕或文件中。要查看此信息,请尝试以下操作:

$ cmake --system-information information.txt

在文件(在本例中为information.txt)中搜索,您将找到CMAKE_CXX_COMPILERCMAKE_C_COMPILERCMAKE_Fortran_COMPILER选项的默认值,以及它们的默认标志。我们将在下一个配方中查看这些标志。

CMake 提供了其他变量来与编译器交互:

  • CMAKE_<LANG>_COMPILER_LOADED:如果为项目启用了语言<LANG>,则设置为TRUE

  • CMAKE_<LANG>_COMPILER_ID:编译器识别字符串,对于编译器供应商是唯一的。例如,对于 GNU 编译器集合,这是GCC,对于 macOS 上的 Clang,这是AppleClang,对于 Microsoft Visual Studio 编译器,这是MSVC。但是请注意,不能保证此变量对所有编译器或语言都定义。

  • CMAKE_COMPILER_IS_GNU<LANG>:如果语言<LANG>的编译器是 GNU 编译器集合的一部分,则此逻辑变量设置为TRUE。请注意,变量名称的<LANG>部分遵循 GNU 约定:对于 C 语言,它将是CC,对于 C ++语言,它将是CXX,对于 Fortran 语言,它将是G77

  • CMAKE_<LANG>_COMPILER_VERSION:此变量包含给定语言的编译器版本的字符串。版本信息以major[.minor[.patch[.tweak]]]格式给出。但是,与CMAKE_<LANG>_COMPILER_ID一样,不能保证此变量对所有编译器或语言都定义。

我们可以尝试使用不同的编译器配置以下示例CMakeLists.txt。在这个例子中,我们将使用 CMake 变量来探测我们正在使用的编译器及其版本:

cmake_minimum_required(VERSION 3.5 FATAL_ERROR)

project(recipe-06 LANGUAGES C CXX)

message(STATUS "Is the C++ compiler loaded? ${CMAKE_CXX_COMPILER_LOADED}")
if(CMAKE_CXX_COMPILER_LOADED)
  message(STATUS "The C++ compiler ID is: ${CMAKE_CXX_COMPILER_ID}")
  message(STATUS "Is the C++ from GNU? ${CMAKE_COMPILER_IS_GNUCXX}")
  message(STATUS "The C++ compiler version is: ${CMAKE_CXX_COMPILER_VERSION}")
endif()

message(STATUS "Is the C compiler loaded? ${CMAKE_C_COMPILER_LOADED}")
if(CMAKE_C_COMPILER_LOADED)
  message(STATUS "The C compiler ID is: ${CMAKE_C_COMPILER_ID}")
  message(STATUS "Is the C from GNU? ${CMAKE_COMPILER_IS_GNUCC}")
  message(STATUS "The C compiler version is: ${CMAKE_C_COMPILER_VERSION}")
endif()

请注意,此示例不包含任何目标,因此没有要构建的内容,我们只关注配置步骤:

$ mkdir -p build
$ cd build
$ cmake ..

...
-- Is the C++ compiler loaded? 1
-- The C++ compiler ID is: GNU
-- Is the C++ from GNU? 1
-- The C++ compiler version is: 8.1.0
-- Is the C compiler loaded? 1
-- The C compiler ID is: GNU
-- Is the C from GNU? 1
-- The C compiler version is: 8.1.0
...

输出当然取决于可用和选择的编译器以及编译器版本。

切换构建类型

本配方的代码可在github.com/dev-cafe/cmake-cookbook/tree/v1.0/chapter-01/recipe-07找到,并包含一个 C++/C 示例。该配方适用于 CMake 版本 3.5(及以上),并在 GNU/Linux、macOS 和 Windows 上进行了测试。

CMake 具有构建类型或配置的概念,例如DebugRelease等。在一种配置中,可以收集相关选项或属性,例如编译器和链接器标志,用于DebugRelease构建。控制生成构建系统时使用的配置的变量是CMAKE_BUILD_TYPE。该变量默认情况下为空,CMake 识别的值包括:

  1. Debug 用于构建您的库或可执行文件,不带优化且带有调试符号,

  2. Release 用于构建您的库或可执行文件,带有优化且不带调试符号,

  3. RelWithDebInfo 用于构建您的库或可执行文件,具有较不激进的优化和调试符号,

  4. MinSizeRel 用于构建您的库或可执行文件,优化不会增加对象代码大小。

如何操作

在本配方中,我们将展示如何为示例项目设置构建类型:

  1. 我们首先定义了最小 CMake 版本、项目名称和支持的语言:
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)

project(recipe-07 LANGUAGES C CXX)
  1. 然后,我们设置了一个默认构建类型(在这种情况下,Release),并将其打印在消息中供用户查看。请注意,该变量被设置为CACHE变量,以便随后可以通过缓存进行编辑:
if(NOT CMAKE_BUILD_TYPE)
  set(CMAKE_BUILD_TYPE Release CACHE STRING "Build type" FORCE)
endif()

message(STATUS "Build type: ${CMAKE_BUILD_TYPE}")
  1. 最后,我们打印出由 CMake 根据构建类型设置的相应编译标志:
message(STATUS "C flags, Debug configuration: ${CMAKE_C_FLAGS_DEBUG}")
message(STATUS "C flags, Release configuration: ${CMAKE_C_FLAGS_RELEASE}")
message(STATUS "C flags, Release configuration with Debug info: ${CMAKE_C_FLAGS_RELWITHDEBINFO}")
message(STATUS "C flags, minimal Release configuration: ${CMAKE_C_FLAGS_MINSIZEREL}")

message(STATUS "C++ flags, Debug configuration: ${CMAKE_CXX_FLAGS_DEBUG}")
message(STATUS "C++ flags, Release configuration: ${CMAKE_CXX_FLAGS_RELEASE}")
message(STATUS "C++ flags, Release configuration with Debug info: ${CMAKE_CXX_FLAGS_RELWITHDEBINFO}")
message(STATUS "C++ flags, minimal Release configuration: ${CMAKE_CXX_FLAGS_MINSIZEREL}")
  1. 现在让我们验证默认配置的输出:
$ mkdir -p build
$ cd build
$ cmake ..

...
-- Build type: Release
-- C flags, Debug configuration: -g
-- C flags, Release configuration: -O3 -DNDEBUG
-- C flags, Release configuration with Debug info: -O2 -g -DNDEBUG
-- C flags, minimal Release configuration: -Os -DNDEBUG
-- C++ flags, Debug configuration: -g
-- C++ flags, Release configuration: -O3 -DNDEBUG
-- C++ flags, Release configuration with Debug info: -O2 -g -DNDEBUG
-- C++ flags, minimal Release configuration: -Os -DNDEBUG
  1. 现在,让我们切换构建类型:
$ cmake -D CMAKE_BUILD_TYPE=Debug ..

-- Build type: Debug
-- C flags, Debug configuration: -g
-- C flags, Release configuration: -O3 -DNDEBUG
-- C flags, Release configuration with Debug info: -O2 -g -DNDEBUG
-- C flags, minimal Release configuration: -Os -DNDEBUG
-- C++ flags, Debug configuration: -g
-- C++ flags, Release configuration: -O3 -DNDEBUG
-- C++ flags, Release configuration with Debug info: -O2 -g -DNDEBUG
-- C++ flags, minimal Release configuration: -Os -DNDEBUG

它是如何工作的

我们已经演示了如何设置默认构建类型以及如何从命令行覆盖它。通过这种方式,我们可以控制项目是使用优化标志构建,还是关闭所有优化并启用调试信息。我们还看到了不同可用配置使用的标志类型,这取决于所选的编译器。除了在 CMake 运行期间明确打印标志外,还可以通过运行cmake --system-information来查看当前平台、默认编译器和语言组合的预设。在下一个配方中,我们将讨论如何为不同的编译器和不同的构建类型扩展或调整编译器标志。

还有更多

我们已经展示了CMAKE_BUILD_TYPE变量(文档链接:cmake.org/cmake/help/v3.5/variable/CMAKE_BUILD_TYPE.html)如何定义生成的构建系统的配置。在评估编译器优化级别的影响时,例如,构建项目的ReleaseDebug配置通常很有帮助。对于单配置生成器,如 Unix Makefiles、MSYS Makefiles 或 Ninja,这需要运行 CMake 两次,即对项目进行完全重新配置。然而,CMake 还支持多配置生成器。这些通常是由集成开发环境提供的项目文件,最著名的是 Visual Studio 和 Xcode,它们可以同时处理多个配置。这些生成器的可用配置类型可以通过CMAKE_CONFIGURATION_TYPES变量进行调整,该变量将接受一个值列表(文档链接:cmake.org/cmake/help/v3.5/variable/CMAKE_CONFIGURATION_TYPES.html)。

以下是使用 Visual Studio 的 CMake 调用:

$ mkdir -p build
$ cd build
$ cmake .. -G"Visual Studio 12 2017 Win64" -D CMAKE_CONFIGURATION_TYPES="Release;Debug"

将生成ReleaseDebug配置的构建树。然后,您可以使用--config标志决定构建哪一个:

$ cmake --build . --config Release

当使用单配置生成器开发代码时,为ReleaseDebug构建类型创建单独的构建目录,两者都配置相同的源代码。这样,您可以在两者之间切换,而不会触发完全重新配置和重新编译。

控制编译器标志

本示例的代码可在github.com/dev-cafe/cmake-cookbook/tree/v1.0/chapter-01/recipe-08找到,并包含一个 C++示例。本示例适用于 CMake 版本 3.5(及更高版本),并在 GNU/Linux、macOS 和 Windows 上进行了测试。

之前的示例展示了如何向 CMake 查询有关编译器的信息,以及如何调整项目中所有目标的编译器优化。后一项任务是控制项目中使用哪些编译器标志的一般需求的一个子集。CMake 提供了调整或扩展编译器标志的很大灵活性,您可以选择两种主要方法之一:

  • CMake 将编译选项视为目标的属性。因此,可以在不覆盖 CMake 默认设置的情况下,为每个目标设置编译选项。

  • 通过使用-D CLI 开关,您可以直接修改CMAKE_<LANG>_FLAGS_<CONFIG>变量。这些变量将影响项目中的所有目标,并覆盖或扩展 CMake 的默认设置。

在本示例中,我们将展示这两种方法。

准备工作

我们将编译一个计算不同几何形状面积的示例程序。代码在名为compute-areas.cpp的文件中有一个main函数:

#include "geometry_circle.hpp"
#include "geometry_polygon.hpp"
#include "geometry_rhombus.hpp"
#include "geometry_square.hpp"

#include <cstdlib>
#include <iostream>

int main() {
  using namespace geometry;

  double radius = 2.5293;
  double A_circle = area::circle(radius);
  std::cout << "A circle of radius " << radius << " has an area of " << A_circle
            << std::endl;

  int nSides = 19;
  double side = 1.29312;
  double A_polygon = area::polygon(nSides, side);
  std::cout << "A regular polygon of " << nSides << " sides of length " << side
            << " has an area of " << A_polygon << std::endl;

  double d1 = 5.0;
  double d2 = 7.8912;
  double A_rhombus = area::rhombus(d1, d2);
  std::cout << "A rhombus of major diagonal " << d1 << " and minor diagonal " << d2
            << " has an area of " << A_rhombus << std::endl;

  double l = 10.0;
  double A_square = area::square(l);
  std::cout << "A square of side " << l << " has an area of " << A_square
            << std::endl;

  return EXIT_SUCCESS;
}

各种函数的实现包含在其他文件中:每个几何形状都有一个头文件和一个对应的源文件。总共,我们有四个头文件和五个源文件需要编译:

.
├── CMakeLists.txt
├── compute-areas.cpp
├── geometry_circle.cpp
├── geometry_circle.hpp
├── geometry_polygon.cpp
├── geometry_polygon.hpp
├── geometry_rhombus.cpp
├── geometry_rhombus.hpp
├── geometry_square.cpp
└── geometry_square.hpp

我们不会为所有这些文件提供列表,而是引导读者参考github.com/dev-cafe/cmake-cookbook/tree/v1.0/chapter-01/recipe-08

如何操作

现在我们有了源文件,我们的目标将是配置项目并尝试使用编译器标志:

  1. 我们设置 CMake 的最低要求版本:
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)
  1. 我们声明项目的名称和语言:
project(recipe-08 LANGUAGES CXX)
  1. 然后,我们打印当前的编译器标志集。CMake 将使用这些标志来编译所有 C++目标:
message("C++ compiler flags: ${CMAKE_CXX_FLAGS}")
  1. 我们为我们的目标准备了一份标志列表。其中一些在 Windows 上可能不可用,我们确保考虑到这种情况:
list(APPEND flags "-fPIC" "-Wall")
if(NOT WIN32)
  list(APPEND flags "-Wextra" "-Wpedantic")
endif()
  1. 我们添加一个新的目标,geometry库及其源依赖项:
add_library(geometry
  STATIC
    geometry_circle.cpp
    geometry_circle.hpp
    geometry_polygon.cpp
    geometry_polygon.hpp
    geometry_rhombus.cpp
    geometry_rhombus.hpp
    geometry_square.cpp
    geometry_square.hpp
  )
  1. 我们为这个库目标设置编译选项:
target_compile_options(geometry
  PRIVATE
    ${flags}
  )
  1. 然后,我们为compute-areas可执行文件添加一个目标:
add_executable(compute-areas compute-areas.cpp)
  1. 我们还为可执行目标设置编译选项:
target_compile_options(compute-areas
  PRIVATE
    "-fPIC"
  )
  1. 最后,我们将可执行文件链接到geometry库:
target_link_libraries(compute-areas geometry)

它是如何工作的

在这个例子中,警告标志-Wall-Wextra-Wpedantic将被添加到geometry目标的编译选项中;compute-areasgeometry目标都将使用-fPIC标志。编译选项可以通过三种可见性级别添加:INTERFACEPUBLICPRIVATE

可见性级别具有以下含义:

  • 使用PRIVATE属性,编译选项将仅应用于给定目标,而不会应用于其他消费它的目标。在我们的示例中,设置在geometry目标上的编译器选项不会被compute-areas继承,尽管compute-areas会链接到geometry库。

  • 使用INTERFACE属性,给定目标的编译选项将仅应用于消费它的目标。

  • 使用PUBLIC属性,编译选项将应用于给定目标以及所有其他消费它的目标。

目标属性的可见性级别是现代 CMake 使用的核心,我们将在本书中经常并广泛地回顾这个主题。以这种方式添加编译选项不会污染CMAKE_<LANG>_FLAGS_<CONFIG>全局 CMake 变量,并给你对哪些选项用于哪些目标的精细控制。

我们如何验证标志是否如我们所愿正确使用?换句话说,你如何发现一个 CMake 项目实际上使用了哪些编译标志?一种方法是使用 CMake 传递额外的参数,在这种情况下是环境变量VERBOSE=1,给本地构建工具:

$ mkdir -p build
$ cd build
$ cmake ..
$ cmake --build . -- VERBOSE=1

... lots of output ...

[ 14%] Building CXX object CMakeFiles/geometry.dir/geometry_circle.cpp.o
/usr/bin/c++ -fPIC -Wall -Wextra -Wpedantic -o CMakeFiles/geometry.dir/geometry_circle.cpp.o -c /home/bast/tmp/cmake-cookbook/chapter-01/recipe-08/cxx-example/geometry_circle.cpp
[ 28%] Building CXX object CMakeFiles/geometry.dir/geometry_polygon.cpp.o
/usr/bin/c++ -fPIC -Wall -Wextra -Wpedantic -o CMakeFiles/geometry.dir/geometry_polygon.cpp.o -c /home/bast/tmp/cmake-cookbook/chapter-01/recipe-08/cxx-example/geometry_polygon.cpp
[ 42%] Building CXX object CMakeFiles/geometry.dir/geometry_rhombus.cpp.o
/usr/bin/c++ -fPIC -Wall -Wextra -Wpedantic -o CMakeFiles/geometry.dir/geometry_rhombus.cpp.o -c /home/bast/tmp/cmake-cookbook/chapter-01/recipe-08/cxx-example/geometry_rhombus.cpp
[ 57%] Building CXX object CMakeFiles/geometry.dir/geometry_square.cpp.o
/usr/bin/c++ -fPIC -Wall -Wextra -Wpedantic -o CMakeFiles/geometry.dir/geometry_square.cpp.o -c /home/bast/tmp/cmake-cookbook/chapter-01/recipe-08/cxx-example/geometry_square.cpp

... more output ...

[ 85%] Building CXX object CMakeFiles/compute-areas.dir/compute-areas.cpp.o
/usr/bin/c++ -fPIC -o CMakeFiles/compute-areas.dir/compute-areas.cpp.o -c /home/bast/tmp/cmake-cookbook/chapter-01/recipe-08/cxx-example/compute-areas.cpp

... more output ...

前面的输出确认编译标志根据我们的指示正确设置。

控制编译器标志的第二种方法不涉及对CMakeLists.txt的任何修改。如果想要为该项目中的geometrycompute-areas目标修改编译器选项,只需使用一个额外的参数调用 CMake 即可。

$ cmake -D CMAKE_CXX_FLAGS="-fno-exceptions -fno-rtti" ..

正如你可能已经猜到的,这个命令将编译项目,禁用异常和运行时类型识别(RTTI)。

这两种方法也可以结合使用。可以使用一组基本的标志全局设置,同时保持对每个目标发生的情况的控制。我们可以使用CMakeLists.txt并运行这个命令:

$ cmake -D CMAKE_CXX_FLAGS="-fno-exceptions -fno-rtti" ..

这将使用-fno-exceptions -fno-rtti -fPIC -Wall -Wextra -Wpedantic配置geometry目标,同时使用-fno-exceptions -fno-rtti -fPIC配置compute-areas

在本书的其余部分,我们通常会为每个目标设置编译器标志,这是我们推荐您项目采用的做法。使用 target_compile_options() 不仅允许对编译选项进行细粒度控制,而且还更好地与 CMake 的更高级功能集成。

还有更多

大多数情况下,标志是编译器特定的。我们当前的示例仅适用于 GCC 和 Clang;其他供应商的编译器将不理解许多,如果不是全部,这些标志。显然,如果一个项目旨在真正跨平台,这个问题必须解决。有三种方法可以解决这个问题。

最典型的方法是将所需的一组编译器标志附加到每个配置类型的 CMake 变量,即 CMAKE_<LANG>_FLAGS_<CONFIG>。这些标志设置为已知适用于给定编译器供应商的内容,因此将包含在

if-endif 子句检查 CMAKE_<LANG>_COMPILER_ID 变量,例如:

if(CMAKE_CXX_COMPILER_ID MATCHES GNU)
  list(APPEND CMAKE_CXX_FLAGS "-fno-rtti" "-fno-exceptions")
  list(APPEND CMAKE_CXX_FLAGS_DEBUG "-Wsuggest-final-types" "-Wsuggest-final-methods" "-Wsuggest-override")
  list(APPEND CMAKE_CXX_FLAGS_RELEASE "-O3" "-Wno-unused")
endif()

if(CMAKE_CXX_COMPILER_ID MATCHES Clang)  
  list(APPEND CMAKE_CXX_FLAGS "-fno-rtti" "-fno-exceptions" "-Qunused-arguments" "-fcolor-diagnostics")
  list(APPEND CMAKE_CXX_FLAGS_DEBUG "-Wdocumentation")
  list(APPEND CMAKE_CXX_FLAGS_RELEASE "-O3" "-Wno-unused")
endif()

一种更精细的方法根本不修改 CMAKE_<LANG>_FLAGS_<CONFIG> 变量,而是定义项目特定的标志列表:

set(COMPILER_FLAGS)
set(COMPILER_FLAGS_DEBUG)
set(COMPILER_FLAGS_RELEASE)

if(CMAKE_CXX_COMPILER_ID MATCHES GNU)
  list(APPEND CXX_FLAGS "-fno-rtti" "-fno-exceptions")
  list(APPEND CXX_FLAGS_DEBUG "-Wsuggest-final-types" "-Wsuggest-final-methods" "-Wsuggest-override")
  list(APPEND CXX_FLAGS_RELEASE "-O3" "-Wno-unused")
endif()

if(CMAKE_CXX_COMPILER_ID MATCHES Clang)  
  list(APPEND CXX_FLAGS "-fno-rtti" "-fno-exceptions" "-Qunused-arguments" "-fcolor-diagnostics")
  list(APPEND CXX_FLAGS_DEBUG "-Wdocumentation")
  list(APPEND CXX_FLAGS_RELEASE "-O3" "-Wno-unused")
endif()

稍后,它使用生成器表达式以每个配置和每个目标为基础设置编译器标志:

target_compile_option(compute-areas
  PRIVATE
    ${CXX_FLAGS}
    "$<$<CONFIG:Debug>:${CXX_FLAGS_DEBUG}>"
    "$<$<CONFIG:Release>:${CXX_FLAGS_RELEASE}>"
  )

我们在当前的配方中展示了这两种方法,并明确推荐后者(项目特定变量和 target_compile_options())而不是前者(CMake 变量)。

这两种方法都有效,并在许多项目中广泛使用。然而,它们也有缺点。正如我们已经提到的,CMAKE_<LANG>_COMPILER_ID并不保证为所有编译器供应商定义。此外,某些标志可能会被弃用,或者可能在编译器的较新版本中引入。与CMAKE_<LANG>_COMPILER_ID类似,CMAKE_<LANG>_COMPILER_VERSION变量并不保证为所有语言和供应商定义。尽管检查这些变量非常流行,但我们认为更稳健的替代方案是检查给定编译器是否支持所需的标志集,以便仅在项目中实际使用有效的标志。结合使用项目特定变量、target_compile_options和生成器表达式,这种方法非常强大。我们将在第 3 个示例中展示如何使用这种检查和设置模式,即第七章中的“编写一个函数来测试和设置编译器标志”。

设置语言标准

本示例代码可在github.com/dev-cafe/cmake-cookbook/tree/v1.0/chapter-01/recipe-09获取,包含 C++和 Fortran 示例。本示例适用于 CMake 版本 3.5(及更高版本),并在 GNU/Linux、macOS 和 Windows 上进行了测试。

编程语言有不同的标准可供选择,即提供新改进语言结构的不同版本。启用新标准是通过设置适当的编译器标志来实现的。我们在前面的示例中展示了如何做到这一点,无论是针对特定目标还是全局设置。CMake 3.1 版本引入了针对 C++和 C 语言标准的平台和编译器无关机制:为目标设置<LANG>_STANDARD属性。

准备工作

对于以下示例,我们将要求 C++编译器符合 C++14 标准或更高版本。本示例代码定义了一个动物的多态层次结构。我们在层次结构的基类中使用std::unique_ptr

std::unique_ptr<Animal> cat = Cat("Simon");
std::unique_ptr<Animal> dog = Dog("Marlowe);

我们没有明确使用各种子类型的构造函数,而是使用工厂方法的实现。工厂使用 C++11 的可变参数模板实现。它保存了继承层次结构中每个对象的创建函数映射:

typedef std::function<std::unique_ptr<Animal>(const std::string &)> CreateAnimal;

它根据预先分配的标签进行分派,以便对象的创建将如下所示:

std::unique_ptr<Animal> simon = farm.create("CAT", "Simon");
std::unique_ptr<Animal> marlowe = farm.create("DOG", "Marlowe");

在工厂使用之前,将标签和创建函数注册到工厂:

Factory<CreateAnimal> farm;
farm.subscribe("CAT", [](const std::string & n) { return std::make_unique<Cat>(n); });
farm.subscribe("DOG", [](const std::string & n) { return std::make_unique<Dog>(n); });

我们使用 C++11 的lambda函数定义创建函数。注意使用std::make_unique来避免引入裸new操作符。这个辅助函数是在 C++14 中引入的。

此 CMake 功能是在版本 3.1 中添加的,并且一直在不断发展。CMake 的后续版本为 C++标准的后续版本和不同的编译器提供了越来越好的支持。我们建议您检查您的首选编译器是否受支持,请访问文档网页:cmake.org/cmake/help/latest/manual/cmake-compile-features.7.html#supported-compilers

如何做到这一点

我们将逐步构建 CMakeLists.txt 并展示如何要求特定的标准(在本例中为 C++14):

  1. 我们声明了所需的最低 CMake 版本、项目名称和语言:
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)

project(recipe-09 LANGUAGES CXX)
  1. 我们要求在 Windows 上导出所有库符号:
set(CMAKE_WINDOWS_EXPORT_ALL_SYMBOLS ON)
  1. 我们需要为库添加一个目标。这将编译源代码成一个共享库:
add_library(animals
  SHARED
    Animal.cpp
    Animal.hpp
    Cat.cpp
    Cat.hpp
    Dog.cpp
    Dog.hpp
    Factory.hpp
  )
  1. 现在我们为目标设置 CXX_STANDARDCXX_EXTENSIONSCXX_STANDARD_REQUIRED 属性。我们还设置了 POSITION_INDEPENDENT_CODE 属性,以避免在某些编译器上构建 DSO 时出现问题:
set_target_properties(animals
  PROPERTIES
    CXX_STANDARD 14
    CXX_EXTENSIONS OFF
    CXX_STANDARD_REQUIRED ON
    POSITION_INDEPENDENT_CODE 1
  )
  1. 然后,我们为 animal-farm 可执行文件添加一个新的目标并设置其属性:
add_executable(animal-farm animal-farm.cpp)

set_target_properties(animal-farm
  PROPERTIES
    CXX_STANDARD 14
    CXX_EXTENSIONS OFF
    CXX_STANDARD_REQUIRED ON
  )
  1. 最后,我们将可执行文件链接到库:
target_link_libraries(animal-farm animals)
  1. 让我们也检查一下我们的例子中的猫和狗有什么要说的:
$ mkdir -p build
$ cd build
$ cmake ..
$ cmake --build .
$ ./animal-farm

I'm Simon the cat!
I'm Marlowe the dog!

它是如何工作的

在步骤 4 和 5 中,我们为 animalsanimal-farm 目标设置了一系列属性:

  • CXX_STANDARD 规定了我们希望采用的标准。

  • CXX_EXTENSIONS 告诉 CMake 只使用将启用 ISO C++标准的编译器标志,而不使用编译器特定的扩展。

  • CXX_STANDARD_REQUIRED 指定所选标准版本是必需的。如果该版本不可用,CMake 将以错误停止配置。当此属性设置为 OFF 时,CMake 将查找下一个最新的标准版本,直到设置了适当的标志。这意味着首先查找 C++14,然后是 C++11,然后是 C++98。

在撰写本文时,还没有 Fortran_STANDARD 属性可用,但可以使用 target_compile_options 设置标准;请参阅 github.com/dev-cafe/cmake-cookbook/tree/v1.0/chapter-01/recipe-09

如果语言标准是所有目标共享的全局属性,您可以将 CMAKE_<LANG>_STANDARDCMAKE_<LANG>_EXTENSIONSCMAKE_<LANG>_STANDARD_REQUIRED 变量设置为所需值。所有目标上的相应属性将使用这些值进行设置。

还有更多

CMake 通过引入编译特性的概念,提供了对语言标准的更精细控制。这些特性是由语言标准引入的,例如 C++11 中的可变参数模板和 lambda,以及 C++14 中的自动返回类型推导。您可以通过target_compile_features()命令要求特定目标支持某些特性,CMake 会自动为该标准设置正确的编译器标志。CMake 还可以为可选的编译器特性生成兼容性头文件。

我们建议阅读cmake-compile-features的在线文档,以全面了解 CMake 如何处理编译特性和语言标准:cmake.org/cmake/help/latest/manual/cmake-compile-features.7.html

使用控制流结构

本食谱的代码可在github.com/dev-cafe/cmake-cookbook/tree/v1.0/chapter-01/recipe-10找到,并附有一个 C++示例。该食谱适用于 CMake 版本 3.5(及以上),并在 GNU/Linux、macOS 和 Windows 上进行了测试。

在本章之前的食谱中,我们已经使用了if-elseif-endif结构。CMake 还提供了创建循环的语言设施:foreach-endforeachwhile-endwhile。两者都可以与break结合使用,以提前从封闭循环中跳出。本食谱将向您展示如何使用foreach遍历源文件列表。我们将对一组源文件应用这样的循环,以降低编译器优化,而不引入新的目标。

准备就绪

我们将重用本章第 8 个食谱中引入的geometry示例,控制编译器标志。我们的目标是通过将它们收集到一个列表中,对一些源文件的编译器优化进行微调。

如何操作

以下是在CMakeLists.txt中需要遵循的详细步骤:

  1. 与第 8 个食谱,控制编译器标志一样,我们指定了所需的最低 CMake 版本、项目名称和语言,并声明了geometry库目标:
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)

project(recipe-10 LANGUAGES CXX)

add_library(geometry
  STATIC
    geometry_circle.cpp
    geometry_circle.hpp
    geometry_polygon.cpp
    geometry_polygon.hpp
    geometry_rhombus.cpp
    geometry_rhombus.hpp
    geometry_square.cpp
    geometry_square.hpp
  )
  1. 我们决定以-O3编译器优化级别编译库。这作为目标的PRIVATE编译选项设置:
target_compile_options(geometry
  PRIVATE
    -O3
  )
  1. 然后,我们生成一份需要以较低优化级别编译的源文件列表:
list(
  APPEND sources_with_lower_optimization
    geometry_circle.cpp
    geometry_rhombus.cpp
  )
  1. 我们遍历这些源文件,将它们的优化级别调整至-O2。这是通过使用它们的源文件属性来完成的:
message(STATUS "Setting source properties using IN LISTS syntax:")
foreach(_source IN LISTS sources_with_lower_optimization)
  set_source_files_properties(${_source} PROPERTIES COMPILE_FLAGS -O2)
  message(STATUS "Appending -O2 flag for ${_source}")
endforeach()
  1. 为了确保设置了源属性,我们再次遍历并打印每个源的COMPILE_FLAGS属性:
message(STATUS "Querying sources properties using plain syntax:")
foreach(_source ${sources_with_lower_optimization})
  get_source_file_property(_flags ${_source} COMPILE_FLAGS)
  message(STATUS "Source ${_source} has the following extra COMPILE_FLAGS: ${_flags}")
endforeach()
  1. 最后,我们添加了compute-areas可执行目标,并将其与geometry库链接:
add_executable(compute-areas compute-areas.cpp)

target_link_libraries(compute-areas geometry)
  1. 让我们验证在配置步骤中标志是否正确设置:
$ mkdir -p build
$ cd build
$ cmake ..

...
-- Setting source properties using IN LISTS syntax:
-- Appending -O2 flag for geometry_circle.cpp
-- Appending -O2 flag for geometry_rhombus.cpp
-- Querying sources properties using plain syntax:
-- Source geometry_circle.cpp has the following extra COMPILE_FLAGS: -O2
-- Source geometry_rhombus.cpp has the following extra COMPILE_FLAGS: -O2
  1. 最后,使用VERBOSE=1检查构建步骤。您将看到-O2标志被附加到-O3标志上,但最终的优化级别标志(在这种情况下是-O2)“胜出”:
$ cmake --build . -- VERBOSE=1

它是如何工作的

foreach-endforeach语法可以用来表达对一组变量的重复任务。在我们的例子中,我们使用它来操作、设置和获取项目中特定文件的编译器标志。这个 CMake 代码片段引入了两个额外的新的命令:

  • set_source_files_properties(file PROPERTIES property value),它为给定文件设置属性到传递的值。与目标类似,文件在 CMake 中也有属性。这允许对构建系统生成进行极其精细的控制。源文件可用属性的列表可以在这里找到:cmake.org/cmake/help/v3.5/manual/cmake-properties.7.html#source-file-properties

  • get_source_file_property(VAR file property),它检索给定文件的所需属性的值,并将其存储在 CMake 的VAR变量中。

在 CMake 中,列表是由分号分隔的字符串组。列表可以通过list命令或set命令创建。例如,set(var a b c d e)list(APPEND a b c d e)都创建了列表a;b;c;d;e

为了降低一组文件的优化级别,将它们收集到一个单独的目标(库)中,并为该目标显式设置优化级别,而不是附加一个标志,这可能更清晰。但在本例中,我们的重点是foreach-endforeach

还有更多

foreach()构造可以以四种不同的方式使用:

  • foreach(loop_var arg1 arg2 ...):提供了一个循环变量和一个明确的项列表。当打印sources_with_lower_optimization中项的编译器标志集时,使用了这种形式。请注意,如果项列表在一个变量中,它必须被显式展开;也就是说,必须将${sources_with_lower_optimization}作为参数传递。

  • 作为对整数的循环,通过指定一个范围,例如foreach(loop_var RANGE total),或者替代地

    foreach(loop_var RANGE start stop [step])

  • 作为对列表值变量的循环,例如foreach(loop_var IN LISTS [list1 [...]])。参数被解释为列表,并且它们的内含物会自动相应地展开。

  • 作为对项的循环,例如foreach(loop_var IN ITEMS [item1 [...]])。参数的内容不会展开。

第三章:检测环境

在本章中,我们将介绍以下食谱:

  • 发现操作系统

  • 处理依赖于平台的源代码

  • 处理依赖于编译器的源代码

  • 发现主机处理器架构

  • 发现主机处理器指令集

  • 为 Eigen 库启用矢量化

引言

尽管 CMake 是跨平台的,在我们的项目中我们努力使源代码能够在不同平台、操作系统和编译器之间移植,但有时源代码并不完全可移植;例如,当使用依赖于供应商的扩展时,我们可能会发现有必要根据平台以略有不同的方式配置和/或构建代码。这对于遗留代码或交叉编译尤其相关,我们将在第十三章,替代生成器和交叉编译中回到这个话题。了解处理器指令集以针对特定目标平台优化性能也是有利的。本章提供了检测此类环境的食谱,并提供了如何实施此类解决方案的建议。

发现操作系统

本食谱的代码可在github.com/dev-cafe/cmake-cookbook/tree/v1.0/chapter-02/recipe-01找到。该食谱适用于 CMake 版本 3.5(及以上),并在 GNU/Linux、macOS 和 Windows 上进行了测试。

尽管 CMake 是一套跨平台的工具,但了解配置或构建步骤在哪个操作系统(OS)上执行仍然非常有用。这种操作系统检测可以用来调整 CMake 代码以适应特定的操作系统,根据操作系统启用条件编译,或者在可用或必要时使用编译器特定的扩展。在本食谱中,我们将展示如何使用 CMake 来检测操作系统,并通过一个不需要编译任何源代码的示例来说明。为了简单起见,我们只考虑配置步骤。

如何操作

我们将通过一个非常简单的CMakeLists.txt来演示操作系统检测:

  1. 我们首先定义最小 CMake 版本和项目名称。请注意,我们的语言要求是NONE
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)

project(recipe-01 LANGUAGES NONE)

  1. 然后我们希望根据检测到的操作系统打印一条自定义消息:
if(CMAKE_SYSTEM_NAME STREQUAL "Linux")
  message(STATUS "Configuring on/for Linux")
elseif(CMAKE_SYSTEM_NAME STREQUAL "Darwin")
  message(STATUS "Configuring on/for macOS")
elseif(CMAKE_SYSTEM_NAME STREQUAL "Windows")
  message(STATUS "Configuring on/for Windows")
elseif(CMAKE_SYSTEM_NAME STREQUAL "AIX")
  message(STATUS "Configuring on/for IBM AIX")
else()
  message(STATUS "Configuring on/for ${CMAKE_SYSTEM_NAME}")
endif()

在尝试之前,首先检查前面的代码块,并考虑你期望在你的系统上看到的行为。

  1. 现在我们准备测试并配置项目:
$ mkdir -p build
$ cd build
$ cmake ..
  1. 在 CMake 的输出中,有一行在这里很有趣——在 Linux 系统上,这是感兴趣的行(在其他系统上,输出可能会有所不同):
-- Configuring on/for Linux

它是如何工作的

CMake 正确地为目标操作系统定义了CMAKE_SYSTEM_NAME,因此通常不需要使用自定义命令、工具或脚本来查询此信息。该变量的值随后可用于实现操作系统特定的条件和解决方法。在具有uname命令的系统上,该变量设置为uname -s的输出。在 macOS 上,该变量设置为"Darwin"。在 Linux 和 Windows 上,它分别评估为"Linux"和"Windows"。现在我们知道,如果我们需要在特定操作系统上执行特定的 CMake 代码,该如何操作。当然,我们应该尽量减少这种定制,以便简化迁移到新平台的过程。

为了在从一个平台迁移到另一个平台时尽量减少麻烦,应避免直接使用 Shell 命令,并避免使用显式的路径分隔符(Linux 和 macOS 上的正斜杠和 Windows 上的反斜杠)。在 CMake 代码中只使用正斜杠作为路径分隔符,CMake 会自动为所涉及的操作系统环境进行转换。

处理平台依赖的源代码

本食谱的代码可在github.com/dev-cafe/cmake-cookbook/tree/v1.0/chapter-02/recipe-02找到,并包含一个 C++示例。该食谱适用于 CMake 版本 3.5(及以上),并在 GNU/Linux、macOS 和 Windows 上进行了测试。

理想情况下,我们应该避免平台依赖的源代码,但有时我们别无选择——尤其是当我们被给予需要配置和编译的代码时,而这些代码并非我们自己编写的。在本食谱中,我们将演示如何使用 CMake 根据操作系统有条件地编译源代码。

准备工作

对于这个例子,我们将修改来自第一章,从简单可执行文件到库,食谱 1,将单个源文件编译成可执行文件hello-world.cpp示例代码:

#include <cstdlib>
#include <iostream>
#include <string>

std::string say_hello() {
#ifdef IS_WINDOWS
  return std::string("Hello from Windows!");
#elif IS_LINUX
  return std::string("Hello from Linux!");
#elif IS_MACOS
  return std::string("Hello from macOS!");
#else
  return std::string("Hello from an unknown system!");
#endif
}

int main() {
  std::cout << say_hello() << std::endl;
  return EXIT_SUCCESS;
}

如何操作

让我们构建一个对应的CMakeLists.txt实例,这将使我们能够根据目标操作系统有条件地编译源代码:

  1. 我们首先设置最小 CMake 版本、项目名称和支持的语言:
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)

project(recipe-02 LANGUAGES CXX)
  1. 然后我们定义可执行文件及其对应的源文件:
add_executable(hello-world hello-world.cpp)
  1. 然后我们通过定义以下目标编译定义来让预处理器知道系统名称:
if(CMAKE_SYSTEM_NAME STREQUAL "Linux")
  target_compile_definitions(hello-world PUBLIC "IS_LINUX")
endif()
if(CMAKE_SYSTEM_NAME STREQUAL "Darwin")
  target_compile_definitions(hello-world PUBLIC "IS_MACOS")
endif()
if(CMAKE_SYSTEM_NAME STREQUAL "Windows")
  target_compile_definitions(hello-world PUBLIC "IS_WINDOWS")
endif()

在继续之前,先检查前面的表达式并考虑在你的系统上你期望的行为。

  1. 现在我们准备测试并配置项目:
$ mkdir -p build
$ cd build
$ cmake ..
$ cmake --build .
$ ./hello-world

Hello from Linux!

在 Windows 系统上,你会看到Hello from Windows!;其他操作系统将产生不同的输出。

工作原理

hello-world.cpp示例中,有趣的部分是基于预处理器定义IS_WINDOWSIS_LINUXIS_MACOS的条件编译:

std::string say_hello() {
#ifdef IS_WINDOWS
  return std::string("Hello from Windows!");
#elif IS_LINUX
  return std::string("Hello from Linux!");
#elif IS_MACOS
  return std::string("Hello from macOS!");
#else
  return std::string("Hello from an unknown system!");
#endif
}

这些定义在配置时由 CMake 在CMakeLists.txt中使用target_compile_definitions定义,然后传递给预处理器。我们可以实现一个更紧凑的表达式,而不重复if-endif语句,我们将在下一个食谱中演示这种重构。我们还可以将if-endif语句合并为一个if-elseif-elseif-endif语句。

在这一点上,我们应该指出,我们可以使用add_definitions(-DIS_LINUX)(当然,根据所讨论的平台调整定义)而不是使用target_compile_definitions来设置定义。使用add_definitions的缺点是它修改了整个项目的编译定义,而target_compile_definitions给了我们限制定义范围到特定目标的可能性,以及通过使用PRIVATEPUBLICINTERFACE限定符限制这些定义的可见性。这些限定符具有与编译器标志相同的含义,正如我们在第一章,从简单的可执行文件到库,第 8 个食谱,控制编译器标志中已经看到的:

  • 使用PRIVATE限定符,编译定义将仅应用于给定目标,而不会被其他消费目标应用。

  • 使用INTERFACE限定符,编译定义将仅应用于消费该定义的目标。

  • 使用PUBLIC限定符,编译定义将应用于给定目标以及所有其他消费目标。

尽量减少项目中依赖于平台的源代码,以便更容易移植。

处理依赖于编译器的源代码

本食谱的代码可在github.com/dev-cafe/cmake-cookbook/tree/v1.0/chapter-02/recipe-03找到,并包含 C++和 Fortran 示例。本食谱适用于 CMake 版本 3.5(及更高版本),并在 GNU/Linux、macOS 和 Windows 上进行了测试。

本食谱与前一个食谱类似,因为我们使用 CMake 来适应依赖于环境的条件源代码的编译:在这种情况下,它将依赖于所选的编译器。同样,为了便携性,这是我们在编写新代码时尽量避免的情况,但这也是我们几乎肯定会在某个时候遇到的情况,尤其是在使用遗留代码或处理依赖于编译器的工具(如 sanitizers)时。从本章和前一章的食谱中,我们已经具备了实现这一点的所有要素。尽管如此,讨论处理依赖于编译器的源代码的问题仍然很有用,因为我们有机会介绍一些新的 CMake 方面。

准备就绪

在本配方中,我们将从 C++示例开始,稍后我们将展示一个 Fortran 示例,并尝试重构和简化 CMake 代码。

让我们考虑以下hello-world.cpp源代码:

#include <cstdlib>
#include <iostream>
#include <string>

std::string say_hello() {
#ifdef IS_INTEL_CXX_COMPILER
  // only compiled when Intel compiler is selected
  // such compiler will not compile the other branches
  return std::string("Hello Intel compiler!");
#elif IS_GNU_CXX_COMPILER
  // only compiled when GNU compiler is selected
  // such compiler will not compile the other branches
  return std::string("Hello GNU compiler!");
#elif IS_PGI_CXX_COMPILER
  // etc.
  return std::string("Hello PGI compiler!");
#elif IS_XL_CXX_COMPILER
  return std::string("Hello XL compiler!");
#else
  return std::string("Hello unknown compiler - have we met before?");
#endif
}

int main() {
  std::cout << say_hello() << std::endl;
  std::cout << "compiler name is " COMPILER_NAME << std::endl;
  return EXIT_SUCCESS;
}

我们还将使用相应的 Fortran 示例(hello-world.F90):

program hello

  implicit none

#ifdef IS_Intel_FORTRAN_COMPILER
  print *, 'Hello Intel compiler!'
#elif IS_GNU_FORTRAN_COMPILER
  print *, 'Hello GNU compiler!'
#elif IS_PGI_FORTRAN_COMPILER
  print *, 'Hello PGI compiler!'
#elif IS_XL_FORTRAN_COMPILER
  print *, 'Hello XL compiler!'
#else
  print *, 'Hello unknown compiler - have we met before?'
#endif

end program

如何做到这一点

我们将在转向 Fortran 示例之前从 C++示例开始:

  1. CMakeLists.txt文件中,我们定义了现在熟悉的最低版本、项目名称和支持的语言:
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)

project(recipe-03 LANGUAGES CXX)
  1. 然后我们定义可执行目标及其对应的源文件:
add_executable(hello-world hello-world.cpp)
  1. 然后我们通过定义以下目标编译定义,让预处理器了解编译器名称和供应商:
target_compile_definitions(hello-world PUBLIC "COMPILER_NAME=\"${CMAKE_CXX_COMPILER_ID}\"")

if(CMAKE_CXX_COMPILER_ID MATCHES Intel)
    target_compile_definitions(hello-world PUBLIC "IS_INTEL_CXX_COMPILER")
endif()
if(CMAKE_CXX_COMPILER_ID MATCHES GNU)
    target_compile_definitions(hello-world PUBLIC "IS_GNU_CXX_COMPILER")
endif()
if(CMAKE_CXX_COMPILER_ID MATCHES PGI)
    target_compile_definitions(hello-world PUBLIC "IS_PGI_CXX_COMPILER")
endif()
if(CMAKE_CXX_COMPILER_ID MATCHES XL)
    target_compile_definitions(hello-world PUBLIC "IS_XL_CXX_COMPILER")
endif()

之前的配方已经训练了我们的眼睛,现在我们甚至可以预见到结果:

$ mkdir -p build
$ cd build
$ cmake ..
$ cmake --build .
$ ./hello-world

Hello GNU compiler!

如果您使用的是不同的编译器供应商,那么此示例代码将提供不同的问候。

在前面的示例和之前的配方中的CMakeLists.txt文件中的if语句似乎是重复的,作为程序员,我们不喜欢重复自己。我们能更简洁地表达这一点吗?确实可以!为此,让我们转向 Fortran 示例。

在 Fortran 示例的CMakeLists.txt文件中,我们需要执行以下操作:

  1. 我们需要将语言调整为 Fortran:
project(recipe-03 LANGUAGES Fortran)
  1. 然后我们定义可执行文件及其对应的源文件;在这种情况下,使用大写的.F90后缀:
add_executable(hello-world hello-world.F90)
  1. 然后我们通过定义以下目标编译定义,让预处理器非常简洁地了解编译器供应商:
target_compile_definitions(hello-world
  PUBLIC "IS_${CMAKE_Fortran_COMPILER_ID}_FORTRAN_COMPILER"
)

剩余的 Fortran 示例行为与 C++示例相同。

它是如何工作的

预处理器定义是在配置时由 CMake 在CMakeLists.txt中定义的,并传递给预处理器。Fortran 示例包含一个非常紧凑的表达式,我们使用CMAKE_Fortran_COMPILER_ID变量来构造预处理器定义,使用target_compile_definitions。为了适应这一点,我们不得不将“Intel”的案例从IS_INTEL_CXX_COMPILER更改为IS_Intel_FORTRAN_COMPILER。我们可以通过使用相应的CMAKE_C_COMPILER_IDCMAKE_CXX_COMPILER_ID变量为 C 或 C++实现相同的效果。但是请注意,CMAKE_<LANG>_COMPILER_ID并不保证为所有编译器或语言定义。

对于应该被预处理的 Fortran 代码,使用.F90后缀,对于不应该被预处理的代码,使用.f90后缀。

探索主机处理器架构

本配方的代码可在github.com/dev-cafe/cmake-cookbook/tree/v1.0/chapter-02/recipe-04获取,并包含一个 C++示例。该配方适用于 CMake 版本 3.5(及更高版本),并在 GNU/Linux、macOS 和 Windows 上进行了测试。

20 世纪 70 年代超级计算中 64 位整数运算的出现以及 21 世纪初个人计算机中 64 位寻址的出现扩大了内存寻址范围,并且投入了大量资源将硬编码为 32 位架构的代码移植到支持 64 位寻址。许多博客文章,例如www.viva64.com/en/a/0004/,都致力于讨论在将 C++代码移植到 64 位平台时遇到的典型问题和解决方案。非常建议以避免明确硬编码限制的方式编程,但您可能处于需要容纳硬编码限制的代码配置与 CMake 的情况,在本菜谱中,我们希望讨论检测宿主处理器架构的选项。

准备工作

我们将使用以下arch-dependent.cpp示例源代码:

#include <cstdlib>
#include <iostream>
#include <string>

#define STRINGIFY(x) #x
#define TOSTRING(x) STRINGIFY(x)

std::string say_hello() {
  std::string arch_info(TOSTRING(ARCHITECTURE));
  arch_info += std::string(" architecture. ");
#ifdef IS_32_BIT_ARCH
  return arch_info + std::string("Compiled on a 32 bit host processor.");
#elif IS_64_BIT_ARCH
  return arch_info + std::string("Compiled on a 64 bit host processor.");
#else
  return arch_info + std::string("Neither 32 nor 64 bit, puzzling ...");
#endif
}
int main() {
  std::cout << say_hello() << std::endl;
  return EXIT_SUCCESS;
}

如何操作

现在让我们转向 CMake 方面。在CMakeLists.txt文件中,我们需要应用以下内容:

  1. 我们首先定义可执行文件及其源文件依赖项:
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)

project(recipe-04 LANGUAGES CXX)

add_executable(arch-dependent arch-dependent.cpp)
  1. 我们检查void指针类型的大小。这在CMAKE_SIZEOF_VOID_P CMake 变量中定义,并将告诉我们 CPU 是 32 位还是 64 位。我们通过状态消息让用户知道检测到的大小,并设置一个预处理器定义:
if(CMAKE_SIZEOF_VOID_P EQUAL 8)
  target_compile_definitions(arch-dependent PUBLIC "IS_64_BIT_ARCH")
  message(STATUS "Target is 64 bits")
else()
  target_compile_definitions(arch-dependent PUBLIC "IS_32_BIT_ARCH")
  message(STATUS "Target is 32 bits")
endif()
  1. 然后我们通过定义以下目标编译定义让预处理器知道宿主处理器架构,同时在配置期间打印状态消息:
if(CMAKE_HOST_SYSTEM_PROCESSOR MATCHES "i386")
  message(STATUS "i386 architecture detected")
elseif(CMAKE_HOST_SYSTEM_PROCESSOR MATCHES "i686")
  message(STATUS "i686 architecture detected")
elseif(CMAKE_HOST_SYSTEM_PROCESSOR MATCHES "x86_64")
  message(STATUS "x86_64 architecture detected")
else()
  message(STATUS "host processor architecture is unknown")
endif()

target_compile_definitions(arch-dependent
  PUBLIC "ARCHITECTURE=${CMAKE_HOST_SYSTEM_PROCESSOR}"
  )
  1. 我们配置项目并记录状态消息(当然,确切的消息可能会发生变化):
$ mkdir -p build
$ cd build
$ cmake ..

...
-- Target is 64 bits
-- x86_64 architecture detected
...
  1. 最后,我们构建并执行代码(实际输出将取决于宿主处理器架构):
$ cmake --build .
$ ./arch-dependent

x86_64 architecture. Compiled on a 64 bit host processor.

它是如何工作的

CMake 定义了CMAKE_HOST_SYSTEM_PROCESSOR变量,其中包含当前正在运行的处理器的名称。这可以设置为“i386”、“i686”、“x86_64”、“AMD64”等,当然,这取决于当前的 CPU。CMAKE_SIZEOF_VOID_P被定义为持有指向void类型的指针的大小。我们可以在 CMake 级别查询这两个变量,以便修改目标或目标编译定义。使用预处理器定义,我们可以根据检测到的宿主处理器架构分支源代码编译。正如在前面的菜谱中讨论的那样,在编写新代码时应避免这种定制,但在处理遗留代码或进行交叉编译时,有时是有用的,这是第十三章,替代生成器和交叉编译的主题。

使用CMAKE_SIZEOF_VOID_P是检查当前 CPU 是 32 位还是 64 位架构的唯一真正可移植的方法。

还有更多内容

除了CMAKE_HOST_SYSTEM_PROCESSOR,CMake 还定义了CMAKE_SYSTEM_PROCESSOR变量。前者包含 CMake当前正在运行的 CPU 的名称,后者将包含我们当前正在构建的 CPU 的名称。这是一个微妙的区别,在交叉编译时起着非常基本的作用。我们将在第十三章,替代生成器和交叉编译中了解更多关于交叉编译的信息。

让 CMake 检测主机处理器架构的替代方法是使用 C 或 C++中定义的符号,并使用 CMake 的try_run函数来构建并尝试执行源代码(参见第五章,配置时间和构建时间操作,第 8 个配方,探测执行),该操作由预处理器符号分支。这会返回可以在 CMake 侧捕获的定义良好的错误(此策略的灵感来自github.com/axr/solar-cmake/blob/master/TargetArch.cmake):

#if defined(__i386) || defined(__i386__) || defined(_M_IX86)
    #error cmake_arch i386
#elif defined(__x86_64) || defined(__x86_64__) || defined(__amd64) || defined(_M_X64)
    #error cmake_arch x86_64
#endif

此策略也是检测目标处理器架构的首选方法,其中 CMake 似乎没有提供便携式内置解决方案。

还存在另一种替代方案。它将仅使用 CMake,完全摆脱预处理器,代价是每个情况都有一个不同的源文件,然后使用target_sources CMake 命令将其设置为可执行目标arch-dependent的源文件:

add_executable(arch-dependent "")

if(CMAKE_HOST_SYSTEM_PROCESSOR MATCHES "i386")
  message(STATUS "i386 architecture detected")
  target_sources(arch-dependent
    PRIVATE
      arch-dependent-i386.cpp
    )
elseif(CMAKE_HOST_SYSTEM_PROCESSOR MATCHES "i686")
  message(STATUS "i686 architecture detected")
  target_sources(arch-dependent
    PRIVATE
      arch-dependent-i686.cpp
    )
elseif(CMAKE_HOST_SYSTEM_PROCESSOR MATCHES "x86_64")
  message(STATUS "x86_64 architecture detected")
  target_sources(arch-dependent
    PRIVATE
      arch-dependent-x86_64.cpp
    )
else()
  message(STATUS "host processor architecture is unknown")
endif()

这种方法显然需要对现有项目进行更多工作,因为源文件需要分开。此外,不同源文件之间的代码重复可能确实成为一个问题。

发现主机处理器指令集

本配方的代码可在github.com/dev-cafe/cmake-cookbook/tree/v1.0/chapter-02/recipe-05获取,并包含一个 C++示例。该配方适用于 CMake 版本 3.10(及更高版本),并在 GNU/Linux、macOS 和 Windows 上进行了测试。

在本配方中,我们将讨论如何借助 CMake 发现主机处理器指令集。此功能相对较新地添加到 CMake 中,并需要 CMake 3.10 或更高版本。检测到的主机系统信息可用于设置相应的编译器标志,或根据主机系统实现可选的源代码编译或源代码生成。在本配方中,我们的目标是检测主机系统信息,使用预处理器定义将其传递给 C++源代码,并将信息打印到输出。

准备就绪

我们的示例 C++源文件(processor-info.cpp)包含以下内容:

#include "config.h"

#include <cstdlib>
#include <iostream>

int main() {
  std::cout << "Number of logical cores: "
            << NUMBER_OF_LOGICAL_CORES << std::endl;
  std::cout << "Number of physical cores: "
            << NUMBER_OF_PHYSICAL_CORES << std::endl;

  std::cout << "Total virtual memory in megabytes: "
            << TOTAL_VIRTUAL_MEMORY << std::endl;
  std::cout << "Available virtual memory in megabytes: "
            << AVAILABLE_VIRTUAL_MEMORY << std::endl;
  std::cout << "Total physical memory in megabytes: "
            << TOTAL_PHYSICAL_MEMORY << std::endl;
  std::cout << "Available physical memory in megabytes: "
            << AVAILABLE_PHYSICAL_MEMORY << std::endl;

  std::cout << "Processor is 64Bit: "
            << IS_64BIT << std::endl;
  std::cout << "Processor has floating point unit: "
            << HAS_FPU << std::endl;
  std::cout << "Processor supports MMX instructions: "
            << HAS_MMX << std::endl;
  std::cout << "Processor supports Ext. MMX instructions: "
            << HAS_MMX_PLUS << std::endl;
  std::cout << "Processor supports SSE instructions: "
            << HAS_SSE << std::endl;
  std::cout << "Processor supports SSE2 instructions: "
            << HAS_SSE2 << std::endl;
  std::cout << "Processor supports SSE FP instructions: "
            << HAS_SSE_FP << std::endl;
  std::cout << "Processor supports SSE MMX instructions: "
            << HAS_SSE_MMX << std::endl;
  std::cout << "Processor supports 3DNow instructions: "
            << HAS_AMD_3DNOW << std::endl;
  std::cout << "Processor supports 3DNow+ instructions: "
            << HAS_AMD_3DNOW_PLUS << std::endl;
  std::cout << "IA64 processor emulating x86 : "
            << HAS_IA64 << std::endl;

  std::cout << "OS name: "
            << OS_NAME << std::endl;
  std::cout << "OS sub-type: "
            << OS_RELEASE << std::endl;
  std::cout << "OS build ID: "
            << OS_VERSION << std::endl;
  std::cout << "OS platform: "
            << OS_PLATFORM << std::endl;

  return EXIT_SUCCESS;
}

该文件包含config.h,我们将从config.h.in生成,如下所示:

#pragma once

#define NUMBER_OF_LOGICAL_CORES @_NUMBER_OF_LOGICAL_CORES@
#define NUMBER_OF_PHYSICAL_CORES @_NUMBER_OF_PHYSICAL_CORES@
#define TOTAL_VIRTUAL_MEMORY @_TOTAL_VIRTUAL_MEMORY@
#define AVAILABLE_VIRTUAL_MEMORY @_AVAILABLE_VIRTUAL_MEMORY@
#define TOTAL_PHYSICAL_MEMORY @_TOTAL_PHYSICAL_MEMORY@
#define AVAILABLE_PHYSICAL_MEMORY @_AVAILABLE_PHYSICAL_MEMORY@
#define IS_64BIT @_IS_64BIT@
#define HAS_FPU @_HAS_FPU@
#define HAS_MMX @_HAS_MMX@
#define HAS_MMX_PLUS @_HAS_MMX_PLUS@
#define HAS_SSE @_HAS_SSE@
#define HAS_SSE2 @_HAS_SSE2@
#define HAS_SSE_FP @_HAS_SSE_FP@
#define HAS_SSE_MMX @_HAS_SSE_MMX@
#define HAS_AMD_3DNOW @_HAS_AMD_3DNOW@
#define HAS_AMD_3DNOW_PLUS @_HAS_AMD_3DNOW_PLUS@
#define HAS_IA64 @_HAS_IA64@
#define OS_NAME "@_OS_NAME@"
#define OS_RELEASE "@_OS_RELEASE@"
#define OS_VERSION "@_OS_VERSION@"
#define OS_PLATFORM "@_OS_PLATFORM@"

如何做到这一点

我们将使用 CMake 来填充config.h中对我们平台有意义的定义,并将我们的示例源文件编译成可执行文件:

  1. 首先,我们定义最小 CMake 版本、项目名称和项目语言:
cmake_minimum_required(VERSION 3.10 FATAL_ERROR)

project(recipe-05 CXX)
  1. 然后,我们定义目标可执行文件、其源文件和包含目录:
add_executable(processor-info "")

target_sources(processor-info
  PRIVATE
    processor-info.cpp
  )

target_include_directories(processor-info
  PRIVATE
    ${PROJECT_BINARY_DIR}
  )
  1. 然后,我们继续查询主机系统信息的一系列键:
foreach(key
  IN ITEMS
    NUMBER_OF_LOGICAL_CORES
    NUMBER_OF_PHYSICAL_CORES
    TOTAL_VIRTUAL_MEMORY
    AVAILABLE_VIRTUAL_MEMORY
    TOTAL_PHYSICAL_MEMORY
    AVAILABLE_PHYSICAL_MEMORY
    IS_64BIT
    HAS_FPU
    HAS_MMX
    HAS_MMX_PLUS
    HAS_SSE
    HAS_SSE2
    HAS_SSE_FP
    HAS_SSE_MMX
    HAS_AMD_3DNOW
    HAS_AMD_3DNOW_PLUS
    HAS_IA64
    OS_NAME
    OS_RELEASE
    OS_VERSION
    OS_PLATFORM
  )
  cmake_host_system_information(RESULT _${key} QUERY ${key})
endforeach()
  1. 定义了相应的变量后,我们配置config.h
configure_file(config.h.in config.h @ONLY)
  1. 现在我们准备好配置、构建和测试项目了:
$ mkdir -p build
$ cd build
$ cmake ..
$ cmake --build .
$ ./processor-info

Number of logical cores: 4
Number of physical cores: 2
Total virtual memory in megabytes: 15258
Available virtual memory in megabytes: 14678
Total physical memory in megabytes: 7858
Available physical memory in megabytes: 4072
Processor is 64Bit: 1
Processor has floating point unit: 1
Processor supports MMX instructions: 1
Processor supports Ext. MMX instructions: 0
Processor supports SSE instructions: 1
Processor supports SSE2 instructions: 1
Processor supports SSE FP instructions: 0
Processor supports SSE MMX instructions: 0
Processor supports 3DNow instructions: 0
Processor supports 3DNow+ instructions: 0
IA64 processor emulating x86 : 0
OS name: Linux
OS sub-type: 4.16.7-1-ARCH
OS build ID: #1 SMP PREEMPT Wed May 2 21:12:36 UTC 2018
OS platform: x86_64
  1. 输出当然会根据处理器而变化。

它是如何工作的

CMakeLists.txt中的foreach循环查询多个键的值,并定义相应的变量。本食谱的核心功能是cmake_host_system_information,它查询 CMake 运行所在的主机系统的系统信息。此函数可以一次调用多个键,但在这种情况下,我们为每个键使用一次函数调用。然后,我们使用这些变量来配置config.h.in中的占位符,并生成config.h。此配置是通过configure_file命令完成的。最后,config.h被包含在processor-info.cpp中,一旦编译,它将打印值到屏幕上。我们将在第五章,配置时间和构建时间操作,和第六章,生成源代码中重新审视这种方法。

还有更多

对于更精细的处理器指令集检测,请考虑使用此模块:github.com/VcDevel/Vc/blob/master/cmake/OptimizeForArchitecture.cmake。我们还想指出,有时构建代码的主机可能与运行代码的主机不同。这在计算集群中很常见,登录节点的架构可能与计算节点的架构不同。解决此问题的一种方法是提交配置和编译作为计算步骤,并将其部署到计算节点。

我们没有使用cmake_host_system_information中的所有可用键。为此,请参考cmake.org/cmake/help/latest/command/cmake_host_system_information.html

为 Eigen 库启用矢量化

此食谱的代码可在github.com/dev-cafe/cmake-cookbook/tree/v1.0/chapter-02/recipe-06找到,并包含一个 C++示例。该食谱适用于 CMake 版本 3.5(及更高版本),并在 GNU/Linux、macOS 和 Windows 上进行了测试。

现代处理器架构的向量能力可以显著提高代码的性能。对于某些类型的操作,这一点尤其明显,而线性代数是其中最突出的。本食谱将展示如何启用向量化以加速使用 Eigen C++库进行线性代数的简单可执行文件。

准备就绪

我们将使用 Eigen C++模板库进行线性代数运算,并展示如何设置编译器标志以启用向量化。本食谱的源代码是linear-algebra.cpp文件:

#include <chrono>
#include <iostream>

#include <Eigen/Dense>

EIGEN_DONT_INLINE
double simple_function(Eigen::VectorXd &va, Eigen::VectorXd &vb) {
  // this simple function computes the dot product of two vectors
  // of course it could be expressed more compactly
  double d = va.dot(vb);
  return d;
}

int main() {
  int len = 1000000;
  int num_repetitions = 100;

  // generate two random vectors
  Eigen::VectorXd va = Eigen::VectorXd::Random(len);
  Eigen::VectorXd vb = Eigen::VectorXd::Random(len);

  double result;
  auto start = std::chrono::system_clock::now();
  for (auto i = 0; i < num_repetitions; i++) {
    result = simple_function(va, vb);
  }
  auto end = std::chrono::system_clock::now();
  auto elapsed_seconds = end - start;

  std::cout << "result: " << result << std::endl;
  std::cout << "elapsed seconds: " << elapsed_seconds.count() << std::endl;
}

我们期望向量化能够加速simple_function中点积操作的执行。

如何操作

根据 Eigen 库的文档,只需设置适当的编译器标志即可启用向量化代码的生成。让我们看看CMakeLists.txt

  1. 我们声明一个 C++11 项目:
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)

project(recipe-06 LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 11)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
  1. 由于我们希望使用 Eigen 库,因此我们需要在系统上找到其头文件:
find_package(Eigen3 3.3 REQUIRED CONFIG)
  1. 我们包含CheckCXXCompilerFlag.cmake标准模块文件:
include(CheckCXXCompilerFlag)
  1. 我们检查-march=native编译器标志是否有效:
check_cxx_compiler_flag("-march=native" _march_native_works)
  1. 我们还检查了替代的-xHost编译器标志:
check_cxx_compiler_flag("-xHost" _xhost_works)
  1. 我们设置一个空变量_CXX_FLAGS,以保存我们刚刚检查的两个标志中找到的一个有效标志。如果我们看到_march_native_works,我们将_CXX_FLAGS设置为-march=native。如果我们看到_xhost_works,我们将_CXX_FLAGS设置为-xHost。如果两者都不起作用,我们将保持_CXX_FLAGS为空,向量化将被禁用:
set(_CXX_FLAGS)
if(_march_native_works)
  message(STATUS "Using processor's vector instructions (-march=native compiler flag set)")
  set(_CXX_FLAGS "-march=native")
elseif(_xhost_works)
  message(STATUS "Using processor's vector instructions (-xHost compiler flag set)")
  set(_CXX_FLAGS "-xHost")
else()
  message(STATUS "No suitable compiler flag found for vectorization")
endif()
  1. 为了进行比较,我们还为未优化的版本定义了一个可执行目标,其中我们不使用前面的优化标志:
add_executable(linear-algebra-unoptimized linear-algebra.cpp)

target_link_libraries(linear-algebra-unoptimized
  PRIVATE
    Eigen3::Eigen
  )
  1. 此外,我们还定义了一个优化版本:
add_executable(linear-algebra linear-algebra.cpp)

target_compile_options(linear-algebra
  PRIVATE
    ${_CXX_FLAGS}
  )

target_link_libraries(linear-algebra
  PRIVATE
    Eigen3::Eigen
  )
  1. 让我们比较这两个可执行文件——首先我们进行配置(在这种情况下,-march=native_works):
$ mkdir -p build
$ cd build
$ cmake ..

...
-- Performing Test _march_native_works
-- Performing Test _march_native_works - Success
-- Performing Test _xhost_works
-- Performing Test _xhost_works - Failed
-- Using processor's vector instructions (-march=native compiler flag set)
...
  1. 最后,让我们编译并比较时间:
$ cmake --build .

$ ./linear-algebra-unoptimized 
result: -261.505
elapsed seconds: 1.97964

$ ./linear-algebra 
result: -261.505
elapsed seconds: 1.05048

工作原理

大多数现代处理器提供向量指令集。精心编写的代码可以利用这些指令集,并在与非向量化代码相比时实现增强的性能。Eigen 库在编写时就明确考虑了向量化,因为线性代数操作可以从中大大受益。我们所需要做的就是指示编译器为我们检查处理器,并为当前架构生成原生指令集。不同的编译器供应商使用不同的标志来实现这一点:GNU 编译器通过-march=native标志实现这一点,而 Intel 编译器使用-xHost标志。然后我们使用CheckCXXCompilerFlag.cmake模块提供的check_cxx_compiler_flag函数:

check_cxx_compiler_flag("-march=native" _march_native_works)

该函数接受两个参数:第一个是要检查的编译器标志,第二个是用于存储检查结果的变量,即truefalse。如果检查结果为正,我们将工作标志添加到_CXX_FLAGS变量中,然后该变量将用于设置我们可执行目标的编译器标志。

还有更多

这个配方可以与之前的配方结合使用;可以使用cmake_host_system_information查询处理器能力。

第四章:检测外部库和程序

在本章中,我们将涵盖以下食谱:

  • 检测 Python 解释器

  • 检测 Python 库

  • 检测 Python 模块和包

  • 检测 BLAS 和 LAPACK 数学库

  • 检测 OpenMP 并行环境

  • 检测 MPI 并行环境

  • 检测 Eigen 库

  • 检测 Boost 库

  • 检测外部库:I. 使用pkg-config

  • 检测外部库:II. 编写一个查找模块

引言

项目通常依赖于其他项目和库。本章演示了如何检测外部库、框架和项目以及如何链接到这些。CMake 有一个相当广泛的预打包模块集,用于检测最常用的库和程序,例如 Python 和 Boost。你可以使用cmake --help-module-list获取现有模块的列表。然而,并非所有库和程序都被覆盖,有时你将不得不提供自己的检测脚本。在本章中,我们将讨论必要的工具并发现 CMake 命令的查找家族:

  • find_file来查找一个指定文件的完整路径

  • find_library来查找一个库

  • find_package来查找并加载来自外部项目的设置

  • find_path来查找包含指定文件的目录

  • find_program来查找一个程序

你可以使用--help-command命令行开关来打印任何 CMake 内置命令的文档到屏幕上。

检测 Python 解释器

本食谱的代码可在github.com/dev-cafe/cmake-cookbook/tree/v1.0/chapter-03/recipe-01找到。该食谱适用于 CMake 版本 3.5(及以上),并在 GNU/Linux、macOS 和 Windows 上进行了测试。

Python 是一种非常流行的动态语言。许多项目将用 Python 编写的工具与它们的主程序和库一起打包,或者在配置或构建过程中使用 Python 脚本。在这种情况下,确保运行时依赖于 Python 解释器也得到满足是很重要的。本食谱将展示如何在配置步骤中检测和使用 Python 解释器。我们将介绍find_package命令,该命令将在本章中广泛使用。

如何操作

我们将逐步构建CMakeLists.txt文件:

  1. 我们首先定义最小 CMake 版本和项目名称。请注意,对于这个例子,我们将不需要任何语言支持:
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)

project(recipe-01 LANGUAGES NONE)
  1. 然后,我们使用find_package命令来查找 Python 解释器:
find_package(PythonInterp REQUIRED)
  1. 接着,我们执行一个 Python 命令并捕获其输出和返回值:
execute_process(
  COMMAND
    ${PYTHON_EXECUTABLE} "-c" "print('Hello, world!')"
  RESULT_VARIABLE _status
  OUTPUT_VARIABLE _hello_world
  ERROR_QUIET
  OUTPUT_STRIP_TRAILING_WHITESPACE
  )
  1. 最后,我们打印 Python 命令的返回值和输出:
message(STATUS "RESULT_VARIABLE is: ${_status}")
message(STATUS "OUTPUT_VARIABLE is: ${_hello_world}")
  1. 现在,我们可以检查配置步骤的输出:
$ mkdir -p build
$ cd build
$ cmake ..

-- Found PythonInterp: /usr/bin/python (found version "3.6.5") 
-- RESULT_VARIABLE is: 0
-- OUTPUT_VARIABLE is: Hello, world!
-- Configuring done
-- Generating done
-- Build files have been written to: /home/user/cmake-cookbook/chapter-03/recipe-01/example/build

它是如何工作的

find_package是 CMake 模块的包装命令,用于发现和设置软件包。这些模块包含用于在系统上的标准位置识别软件包的 CMake 命令。CMake 模块的文件称为Find<name>.cmake,当发出find_package(<name>)调用时,它们包含的命令将在内部运行。

除了实际在系统上发现请求的软件包之外,查找模块还设置了一组有用的变量,反映实际找到的内容,可以在自己的CMakeLists.txt中使用。对于 Python 解释器,相关模块是FindPythonInterp.cmake,随 CMake 一起提供,并设置以下变量:

  • PYTHONINTERP_FOUND,一个布尔值,表示是否找到了解释器

  • PYTHON_EXECUTABLE,Python 解释器可执行文件的路径

  • PYTHON_VERSION_STRING,Python 解释器的完整版本号

  • PYTHON_VERSION_MAJOR,Python 解释器的主版本号

  • PYTHON_VERSION_MINOR,Python 解释器的小版本号

  • PYTHON_VERSION_PATCH,Python 解释器的补丁号

可以强制 CMake 查找特定版本的软件包。例如,使用此方法请求 Python 解释器的版本大于或等于 2.7:

find_package(PythonInterp 2.7)

也可以强制要求满足依赖关系:

find_package(PythonInterp REQUIRED)

在这种情况下,如果在常规查找位置找不到适合的 Python 解释器可执行文件,CMake 将中止配置。

CMake 有许多用于查找广泛使用的软件包的模块。我们建议始终在 CMake 在线文档中搜索现有的Find<package>.cmake模块,并在使用它们之前阅读其文档。find_package命令的文档可以在cmake.org/cmake/help/v3.5/command/find_package.html找到。在线文档的一个很好的替代方法是浏览github.com/Kitware/CMake/tree/master/Modules中的 CMake 模块源代码 - 它们的标题文档说明了模块使用的变量以及模块设置的变量,可以在自己的CMakeLists.txt中使用。

还有更多

有时,软件包未安装在标准位置,CMake 可能无法正确找到它们。可以使用 CLI 开关-D告诉 CMake 在特定位置查找特定软件以传递适当的选项。对于 Python 解释器,可以按以下方式配置:

$ cmake -D PYTHON_EXECUTABLE=/custom/location/python ..

这将正确识别安装在非标准/custom/location/python目录中的 Python 可执行文件。

每个包都不同,Find<package>.cmake模块试图考虑到这一点并提供统一的检测接口。当系统上安装的包无法被 CMake 找到时,我们建议您阅读相应检测模块的文档,以了解如何正确指导 CMake。您可以直接在终端中浏览文档,例如使用cmake --help-module FindPythonInterp

无论检测包的情况如何,我们都想提到一个方便的打印变量的辅助模块。在本食谱中,我们使用了以下内容:

message(STATUS "RESULT_VARIABLE is: ${_status}")
message(STATUS "OUTPUT_VARIABLE is: ${_hello_world}")

调试的一个便捷替代方法是使用以下内容:

include(CMakePrintHelpers)
cmake_print_variables(_status _hello_world)

这将产生以下输出:

-- _status="0" ; _hello_world="Hello, world!"

关于打印属性和变量的便捷宏的更多文档,请参见cmake.org/cmake/help/v3.5/module/CMakePrintHelpers.html

检测 Python 库

本食谱的代码可在github.com/dev-cafe/cmake-cookbook/tree/v1.0/chapter-03/recipe-02找到,包含一个 C 语言示例。该食谱适用于 CMake 版本 3.5(及以上),并在 GNU/Linux、macOS 和 Windows 上进行了测试。

如今,使用 Python 工具分析和操作编译程序的输出已经非常普遍。然而,还有其他更强大的方法将解释型语言(如 Python)与编译型语言(如 C 或 C++)结合。一种方法是通过提供新的类型和在这些类型上的新功能来扩展Python,通过将 C 或 C++模块编译成共享库。这将是第九章,混合语言项目中食谱的主题。另一种方法是嵌入Python 解释器到一个 C 或 C++程序中。这两种方法都需要以下内容:

  • 一个可用的 Python 解释器版本

  • 可用的 Python 头文件Python.h

  • Python 运行时库libpython

这三个组件必须锁定到完全相同的版本。我们已经演示了如何找到 Python 解释器;在本食谱中,我们将展示如何找到成功嵌入所需的两个缺失成分。

准备工作

我们将使用 Python 文档页面上找到的一个简单的 Python 嵌入到 C 程序的示例。源文件名为hello-embedded-python.c

#include <Python.h>

int main(int argc, char *argv[]) {
  Py_SetProgramName(argv[0]); /* optional but recommended */
  Py_Initialize();
  PyRun_SimpleString("from time import time,ctime\n"
                     "print 'Today is',ctime(time())\n");
  Py_Finalize();
  return 0;
}

这些代码示例将在程序中初始化 Python 解释器的一个实例,并使用 Python 的time模块打印日期。

嵌入示例代码可以在 Python 文档页面上在线找到,网址为docs.python.org/2/extending/embedding.htmldocs.python.org/3/extending/embedding.html

如何操作

在我们的CMakeLists.txt中,需要遵循以下步骤:

  1. 第一块包含最小 CMake 版本、项目名称和所需语言:
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)

project(recipe-02 LANGUAGES C)
  1. 在本食谱中,我们强制使用 C99 标准进行 C 语言编程。这严格来说不是链接 Python 所必需的,但可能是您想要设置的东西:
set(CMAKE_C_STANDARD 99)
set(CMAKE_C_EXTENSIONS OFF)
set(CMAKE_C_STANDARD_REQUIRED ON)
  1. 找到 Python 解释器。现在这是一个必需的依赖项:
find_package(PythonInterp REQUIRED)
  1. 找到 Python 头文件和库。适当的模块称为FindPythonLibs.cmake
find_package(PythonLibs ${PYTHON_VERSION_MAJOR}.${PYTHON_VERSION_MINOR} EXACT REQUIRED)
  1. 我们添加一个使用hello-embedded-python.c源文件的可执行目标:
add_executable(hello-embedded-python hello-embedded-python.c)
  1. 可执行文件包含Python.h头文件。因此,此目标的包含目录必须包含 Python 包含目录,可通过PYTHON_INCLUDE_DIRS变量访问:
target_include_directories(hello-embedded-python
  PRIVATE
    ${PYTHON_INCLUDE_DIRS}
  )
  1. 最后,我们将可执行文件链接到 Python 库,通过PYTHON_LIBRARIES变量访问:
target_link_libraries(hello-embedded-python
  PRIVATE
    ${PYTHON_LIBRARIES}
  )
  1. 现在,我们准备运行配置步骤:
$ mkdir -p build
$ cd build
$ cmake ..

...
-- Found PythonInterp: /usr/bin/python (found version "3.6.5") 
-- Found PythonLibs: /usr/lib/libpython3.6m.so (found suitable exact version "3.6.5")
  1. 最后,我们执行构建步骤并运行可执行文件:
$ cmake --build .
$ ./hello-embedded-python

Today is Thu Jun 7 22:26:02 2018

它是如何工作的

FindPythonLibs.cmake模块将在标准位置查找 Python 头文件和库。由于这些是我们项目的必需依赖项,如果找不到这些依赖项,配置将停止并出现错误。

请注意,我们明确要求 CMake 检测 Python 可执行文件的安装。这是为了确保可执行文件、头文件和库具有匹配的版本。这对于确保运行时不会出现版本不匹配导致的崩溃至关重要。我们通过使用FindPythonInterp.cmake中定义的PYTHON_VERSION_MAJORPYTHON_VERSION_MINOR实现了这一点:

find_package(PythonInterp REQUIRED)
find_package(PythonLibs ${PYTHON_VERSION_MAJOR}.${PYTHON_VERSION_MINOR} EXACT REQUIRED)

使用EXACT关键字,我们已约束 CMake 检测特定且在这种情况下匹配的 Python 包含文件和库版本。为了更精确匹配,我们可以使用精确的PYTHON_VERSION_STRING

find_package(PythonInterp REQUIRED)
find_package(PythonLibs ${PYTHON_VERSION_STRING} EXACT REQUIRED)

还有更多

我们如何确保即使 Python 头文件和库不在标准安装目录中,它们也能被正确找到?对于 Python 解释器,可以通过将PYTHON_LIBRARYPYTHON_INCLUDE_DIR选项通过-D选项传递给 CLI 来强制 CMake 在特定目录中查找。这些选项指定以下内容:

  • PYTHON_LIBRARY,Python 库的路径

  • PYTHON_INCLUDE_DIRPython.h所在的路径

这确保将选择所需的 Python 版本。

有时需要将-D PYTHON_EXECUTABLE-D PYTHON_LIBRARY-D PYTHON_INCLUDE_DIR传递给 CMake CLI,以便找到所有必需的组件并将它们固定到完全相同的版本。

另请参见

要精确匹配 Python 解释器及其开发组件的版本可能非常困难。这在它们安装在非标准位置或系统上安装了多个版本的情况下尤其如此。CMake 在其版本 3.12 中添加了新的 Python 检测模块,旨在解决这个棘手的问题。我们的CMakeLists.txt中的检测部分也将大大简化:

find_package(Python COMPONENTS Interpreter Development REQUIRED)

我们鼓励您阅读新模块的文档:cmake.org/cmake/help/v3.12/module/FindPython.html

检测 Python 模块和包

本配方的代码可在github.com/dev-cafe/cmake-cookbook/tree/v1.0/chapter-03/recipe-03找到,并包含一个 C++示例。本配方适用于 CMake 版本 3.5(及更高版本),并在 GNU/Linux、macOS 和 Windows 上进行了测试。

在前一个配方中,我们展示了如何检测 Python 解释器以及如何编译一个简单的 C 程序,嵌入 Python 解释器。这两项任务是结合 Python 和编译语言时的基础。通常,你的代码会依赖于特定的 Python 模块,无论是 Python 工具、嵌入 Python 的编译程序,还是扩展它的库。例如,NumPy 在涉及矩阵代数的问题中在科学界变得非常流行。在依赖于 Python 模块或包的项目中,确保这些 Python 模块的依赖得到满足是很重要的。本配方将展示如何探测用户的环境以找到特定的 Python 模块和包。

准备工作

我们将在 C++程序中尝试一个稍微更复杂的嵌入示例。该示例再次取自 Python 在线文档(docs.python.org/3.5/extending/embedding.html#pure-embedding),并展示了如何通过调用编译的 C++可执行文件来执行用户定义的 Python 模块中的函数。

Python 3 示例代码(Py3-pure-embedding.cpp)包含以下源代码(有关相应的 Python 2 等效内容,请参见docs.python.org/2/extending/embedding.html#pure-embedding):

#include <Python.h>

int main(int argc, char *argv[]) {
  PyObject *pName, *pModule, *pDict, *pFunc;
  PyObject *pArgs, *pValue;
  int i;

  if (argc < 3) {
    fprintf(stderr, "Usage: pure-embedding pythonfile funcname [args]\n");
    return 1;
  }

  Py_Initialize();

  PyRun_SimpleString("import sys");
  PyRun_SimpleString("sys.path.append(\".\")");

  pName = PyUnicode_DecodeFSDefault(argv[1]);
  /* Error checking of pName left out */

  pModule = PyImport_Import(pName);
  Py_DECREF(pName);

  if (pModule != NULL) {
    pFunc = PyObject_GetAttrString(pModule, argv[2]);
    /* pFunc is a new reference */

    if (pFunc && PyCallable_Check(pFunc)) {
      pArgs = PyTuple_New(argc - 3);
      for (i = 0; i < argc - 3; ++i) {
        pValue = PyLong_FromLong(atoi(argv[i + 3]));
        if (!pValue) {
          Py_DECREF(pArgs);
          Py_DECREF(pModule);
          fprintf(stderr, "Cannot convert argument\n");
          return 1;
        }
        /* pValue reference stolen here: */
        PyTuple_SetItem(pArgs, i, pValue);
      }
      pValue = PyObject_CallObject(pFunc, pArgs);
      Py_DECREF(pArgs);
      if (pValue != NULL) {
        printf("Result of call: %ld\n", PyLong_AsLong(pValue));
        Py_DECREF(pValue);
      } else {
        Py_DECREF(pFunc);
        Py_DECREF(pModule);
        PyErr_Print();
        fprintf(stderr, "Call failed\n");
        return 1;
      }
    } else {
      if (PyErr_Occurred())
        PyErr_Print();
      fprintf(stderr, "Cannot find function \"%s\"\n", argv[2]);
    }
    Py_XDECREF(pFunc);
    Py_DECREF(pModule);
  } else {
    PyErr_Print();
    fprintf(stderr, "Failed to load \"%s\"\n", argv[1]);
    return 1;
  }
  Py_Finalize();
  return 0;
}

我们希望嵌入的 Python 代码(use_numpy.py)使用 NumPy 设置一个矩阵,其中所有矩阵元素都设置为 1.0:

import numpy as np

def print_ones(rows, cols):

    A = np.ones(shape=(rows, cols), dtype=float)
    print(A)

    # we return the number of elements to verify
    # that the C++ code is able to receive return values
    num_elements = rows*cols
    return(num_elements)

如何操作

在下面的代码中,我们希望使用 CMake 检查 NumPy 是否可用。首先,我们需要确保 Python 解释器、头文件和库都在我们的系统上可用。然后,我们将继续确保 NumPy 可用:

  1. 首先,我们定义最小 CMake 版本、项目名称、语言和 C++标准:
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)

project(recipe-03 LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 11)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
  1. 找到解释器、头文件和库的过程与之前的脚本完全相同:
find_package(PythonInterp REQUIRED)
find_package(PythonLibs ${PYTHON_VERSION_MAJOR}.${PYTHON_VERSION_MINOR} EXACT REQUIRED)
  1. 正确打包的 Python 模块知道它们的安装位置和版本。这可以通过执行一个最小的 Python 脚本来探测。我们可以在CMakeLists.txt内部执行这一步骤:
execute_process(
  COMMAND
    ${PYTHON_EXECUTABLE} "-c" "import re, numpy; print(re.compile('/__init__.py.*').sub('',numpy.__file__))"
  RESULT_VARIABLE _numpy_status
  OUTPUT_VARIABLE _numpy_location
  ERROR_QUIET
  OUTPUT_STRIP_TRAILING_WHITESPACE
  )
  1. _numpy_status变量将在找到 NumPy 时为整数,否则为带有某些错误消息的字符串,而_numpy_location将包含 NumPy 模块的路径。如果找到 NumPy,我们将其位置保存到一个简单的名为NumPy的新变量中。请注意,新变量被缓存;这意味着 CMake 创建了一个持久变量,用户可以稍后修改它:
if(NOT _numpy_status)
  set(NumPy ${_numpy_location} CACHE STRING "Location of NumPy")
endif()
  1. 下一步是检查模块的版本。再次,我们在CMakeLists.txt中部署一些 Python 魔法,将版本保存到一个_numpy_version变量中:
execute_process(
  COMMAND
    ${PYTHON_EXECUTABLE} "-c" "import numpy; print(numpy.__version__)"
  OUTPUT_VARIABLE _numpy_version
  ERROR_QUIET
  OUTPUT_STRIP_TRAILING_WHITESPACE
  )
  1. 最后,我们让FindPackageHandleStandardArgsCMake 包设置NumPy_FOUND变量并以正确格式输出状态信息:
include(FindPackageHandleStandardArgs)
find_package_handle_standard_args(NumPy
  FOUND_VAR NumPy_FOUND
  REQUIRED_VARS NumPy
  VERSION_VAR _numpy_version
  )
  1. 一旦所有依赖项都被正确找到,我们就可以编译可执行文件并将其链接到 Python 库:
add_executable(pure-embedding "")

target_sources(pure-embedding
  PRIVATE
    Py${PYTHON_VERSION_MAJOR}-pure-embedding.cpp
  )

target_include_directories(pure-embedding
  PRIVATE
    ${PYTHON_INCLUDE_DIRS}
  )

target_link_libraries(pure-embedding
  PRIVATE
    ${PYTHON_LIBRARIES}
  )
  1. 我们还必须确保use_numpy.py在构建目录中可用:
add_custom_command(
  OUTPUT
    ${CMAKE_CURRENT_BINARY_DIR}/use_numpy.py
  COMMAND
    ${CMAKE_COMMAND} -E copy_if_different ${CMAKE_CURRENT_SOURCE_DIR}/use_numpy.py
                                          ${CMAKE_CURRENT_BINARY_DIR}/use_numpy.py
  DEPENDS
    ${CMAKE_CURRENT_SOURCE_DIR}/use_numpy.py
  )

# make sure building pure-embedding triggers the above custom command
target_sources(pure-embedding
  PRIVATE
    ${CMAKE_CURRENT_BINARY_DIR}/use_numpy.py
  )
  1. 现在,我们可以测试检测和嵌入代码:
$ mkdir -p build
$ cd build
$ cmake ..

-- ...
-- Found PythonInterp: /usr/bin/python (found version "3.6.5") 
-- Found PythonLibs: /usr/lib/libpython3.6m.so (found suitable exact version "3.6.5") 
-- Found NumPy: /usr/lib/python3.6/site-packages/numpy (found version "1.14.3")

$ cmake --build .
$ ./pure-embedding use_numpy print_ones 2 3

[[1\. 1\. 1.]
 [1\. 1\. 1.]]
Result of call: 6

它是如何工作的

在这个 CMake 脚本中,有三个新的 CMake 命令:execute_processadd_custom_command,它们总是可用的,以及find_package_handle_standard_args,它需要include(FindPackageHandleStandardArgs)

execute_process命令将执行一个或多个作为当前发出的 CMake 命令的子进程的命令。最后一个子进程的返回值将被保存到作为参数传递给RESULT_VARIABLE的变量中,而标准输出和标准错误管道的内容将被保存到作为参数传递给OUTPUT_VARIABLEERROR_VARIABLE的变量中。execute_process允许我们执行任意命令,并使用它们的结果来推断我们系统的配置。在我们的例子中,我们首先使用它来确保 NumPy 可用,然后获取模块的版本。

find_package_handle_standard_args命令提供了处理与在给定系统上找到的程序和库相关的常见操作的标准工具。版本相关的选项,REQUIREDEXACT,在引用此命令时都得到了正确处理,无需进一步的 CMake 代码。额外的选项QUIETCOMPONENTS,我们很快就会遇到,也由这个 CMake 命令在幕后处理。在这个脚本中,我们使用了以下内容:

include(FindPackageHandleStandardArgs)
find_package_handle_standard_args(NumPy
  FOUND_VAR NumPy_FOUND
  REQUIRED_VARS NumPy
  VERSION_VAR _numpy_version
  )

当所有必需的变量都被设置为有效的文件路径(NumPy)时,该命令将设置变量以发出模块已被找到的信号(NumPy_FOUND)。它还将设置版本到传递的版本变量(_numpy_version),并为用户打印出状态消息:

-- Found NumPy: /usr/lib/python3.6/site-packages/numpy (found version "1.14.3")

在本食谱中,我们没有进一步使用这些变量。我们可以做的是,如果NumPy_FOUND被返回为FALSE,则停止配置。

最后,我们应该对将use_numpy.py复制到构建目录的代码段进行评论:

add_custom_command(
  OUTPUT
    ${CMAKE_CURRENT_BINARY_DIR}/use_numpy.py
  COMMAND
    ${CMAKE_COMMAND} -E copy_if_different ${CMAKE_CURRENT_SOURCE_DIR}/use_numpy.py
                                          ${CMAKE_CURRENT_BINARY_DIR}/use_numpy.py
  DEPENDS
    ${CMAKE_CURRENT_SOURCE_DIR}/use_numpy.py
  )

target_sources(pure-embedding
  PRIVATE
    ${CMAKE_CURRENT_BINARY_DIR}/use_numpy.py
  )

我们本可以使用file(COPY ...)命令来实现复制。在这里,我们选择使用add_custom_command以确保每次文件更改时都会复制文件,而不仅仅是在我们首次运行配置时。我们将在第五章, 配置时间和构建时间操作中更详细地回顾add_custom_command。还请注意target_sources命令,它将依赖项添加到${CMAKE_CURRENT_BINARY_DIR}/use_numpy.py;这样做是为了确保构建pure-embedding目标会触发前面的自定义命令。

检测 BLAS 和 LAPACK 数学库

本食谱的代码可在github.com/dev-cafe/cmake-cookbook/tree/v1.0/chapter-03/recipe-04找到,并包含一个 C++示例。本食谱适用于 CMake 版本 3.5(及更高版本),并在 GNU/Linux、macOS 和 Windows 上进行了测试。

许多数值代码严重依赖于矩阵和向量运算。例如,考虑矩阵-向量和矩阵-矩阵乘积、线性方程组的解、特征值和特征向量的计算或奇异值分解。这些操作可能在代码库中无处不在,或者必须在大数据集上运行,以至于高效的实现变得绝对必要。幸运的是,有专门为此目的的库:基本线性代数子程序(BLAS)和线性代数包(LAPACK)提供了标准API,用于涉及线性代数操作的许多任务。不同的供应商提供不同的实现,但它们都共享相同的 API。尽管数学库底层实现所用的实际编程语言随时间而变化(Fortran、C、汇编),但留下的历史痕迹是 Fortran 调用约定。在本食谱中,我们的任务将是链接到这些库,并展示如何无缝地使用用不同语言编写的库,考虑到上述调用约定。

准备工作

为了演示数学库的检测和链接,我们希望编译一个 C++程序,该程序接受矩阵维数作为命令行输入,生成一个随机方阵A,一个随机向量b,并解决随之而来的线性方程组:Ax = b。此外,我们将用一个随机因子缩放随机向量b。我们需要使用的子程序是来自 BLAS 的DSCAL,用于执行缩放,以及来自 LAPACK 的DGESV,用于找到线性方程组的解。示例 C++代码的列表包含在(linear-algebra.cpp)中:

#include "CxxBLAS.hpp"
#include "CxxLAPACK.hpp"

#include <iostream>
#include <random>
#include <vector>

int main(int argc, char **argv) {
  if (argc != 2) {
    std::cout << "Usage: ./linear-algebra dim" << std::endl;
    return EXIT_FAILURE;
  }

  // Generate a uniform distribution of real number between -1.0 and 1.0
  std::random_device rd;
  std::mt19937 mt(rd());
  std::uniform_real_distribution<double> dist(-1.0, 1.0);

  // Allocate matrices and right-hand side vector
  int dim = std::atoi(argv[1]);
  std::vector<double> A(dim * dim);
  std::vector<double> b(dim);
  std::vector<int> ipiv(dim);
  // Fill matrix and RHS with random numbers between -1.0 and 1.0
  for (int r = 0; r < dim; r++) {
    for (int c = 0; c < dim; c++) {
      A[r + c * dim] = dist(mt);
    }
    b[r] = dist(mt);
  }

  // Scale RHS vector by a random number between -1.0 and 1.0
  C_DSCAL(dim, dist(mt), b.data(), 1);
  std::cout << "C_DSCAL done" << std::endl;

  // Save matrix and RHS
  std::vector<double> A1(A);
  std::vector<double> b1(b);

  int info;
  info = C_DGESV(dim, 1, A.data(), dim, ipiv.data(), b.data(), dim);
  std::cout << "C_DGESV done" << std::endl;
  std::cout << "info is " << info << std::endl;

  double eps = 0.0;
  for (int i = 0; i < dim; ++i) {
    double sum = 0.0;
    for (int j = 0; j < dim; ++j)
      sum += A1[i + j * dim] * b[j];
    eps += std::abs(b1[i] - sum);
  }
  std::cout << "check is " << eps << std::endl;

  return 0;
}

我们使用 C++11 中引入的随机库来生成-1.0 到 1.0 之间的随机分布。C_DSCALC_DGESV是 BLAS 和 LAPACK 库的接口,分别负责名称修饰,以便从不同的编程语言调用这些函数。这是在以下接口文件中与我们将进一步讨论的 CMake 模块结合完成的。

文件CxxBLAS.hpp使用extern "C"链接包装 BLAS 例程:

#pragma once

#include "fc_mangle.h"

#include <cstddef>

#ifdef __cplusplus
extern "C" {
#endif

extern void DSCAL(int *n, double *alpha, double *vec, int *inc);

#ifdef __cplusplus
}
#endif

void C_DSCAL(size_t length, double alpha, double *vec, int inc);

相应的实现文件CxxBLAS.cpp包含:

#include "CxxBLAS.hpp"

#include <climits>

// see http://www.netlib.no/netlib/blas/dscal.f
void C_DSCAL(size_t length, double alpha, double *vec, int inc) {
  int big_blocks = (int)(length / INT_MAX);
  int small_size = (int)(length % INT_MAX);
  for (int block = 0; block <= big_blocks; block++) {
    double *vec_s = &vec[block * inc * (size_t)INT_MAX];
    signed int length_s = (block == big_blocks) ? small_size : INT_MAX;
    ::DSCAL(&length_s, &alpha, vec_s, &inc);
  }
}

文件CxxLAPACK.hppCxxLAPACK.cpp为 LAPACK 调用执行相应的翻译。

如何做到这一点

相应的CMakeLists.txt包含以下构建块:

  1. 我们定义了最小 CMake 版本、项目名称和支持的语言:
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)

project(recipe-04 LANGUAGES CXX C Fortran)
  1. 我们要求使用 C++11 标准:
set(CMAKE_CXX_STANDARD 11)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
  1. 此外,我们验证 Fortran 和 C/C++编译器是否能协同工作,并生成处理名称修饰的头部文件。这两项功能均由FortranCInterface模块提供:
include(FortranCInterface)

FortranCInterface_VERIFY(CXX)

FortranCInterface_HEADER(
 fc_mangle.h
 MACRO_NAMESPACE "FC_"
 SYMBOLS DSCAL DGESV
 )
  1. 然后,我们要求 CMake 查找 BLAS 和 LAPACK。这些是必需的依赖项:
find_package(BLAS REQUIRED)
find_package(LAPACK REQUIRED)
  1. 接下来,我们添加一个包含我们源代码的库,用于 BLAS 和 LAPACK 包装器,并链接到LAPACK_LIBRARIES,这也引入了BLAS_LIBRARIES
add_library(math "")

target_sources(math
  PRIVATE
    CxxBLAS.cpp
    CxxLAPACK.cpp
  )

target_include_directories(math
  PUBLIC
    ${CMAKE_CURRENT_SOURCE_DIR}
    ${CMAKE_CURRENT_BINARY_DIR}
  )

target_link_libraries(math
  PUBLIC
    ${LAPACK_LIBRARIES}
  )
  1. 注意,该目标的包含目录和链接库被声明为PUBLIC,因此任何依赖于数学库的额外目标也会在其包含目录中设置这些目录。

  2. 最后,我们添加一个可执行目标,并链接到math

add_executable(linear-algebra "")

target_sources(linear-algebra
  PRIVATE
    linear-algebra.cpp
  )

target_link_libraries(linear-algebra
  PRIVATE
    math
  )
  1. 在配置步骤中,我们可以专注于相关的输出:
$ mkdir -p build
$ cd build
$ cmake ..

...
-- Detecting Fortran/C Interface
-- Detecting Fortran/C Interface - Found GLOBAL and MODULE mangling
-- Verifying Fortran/C Compiler Compatibility
-- Verifying Fortran/C Compiler Compatibility - Success
...
-- Found BLAS: /usr/lib/libblas.so 
...
-- A library with LAPACK API found.
...
  1. 最后,我们构建并测试可执行文件:
$ cmake --build .
$ ./linear-algebra 1000

C_DSCAL done
C_DGESV done
info is 0
check is 1.54284e-10

它是如何工作的

FindBLAS.cmakeFindLAPACK.cmake将在标准位置查找提供标准 BLAS 和 LAPACK API 的库。对于前者,模块将查找 Fortran 实现的SGEMM函数,用于单精度矩阵-矩阵乘法,适用于一般矩阵。对于后者,模块搜索 Fortran 实现的CHEEV函数,用于计算复数、Hermitian 矩阵的特征值和特征向量。这些查找是通过内部编译一个调用这些函数的小程序并尝试链接到候选库来执行的。如果失败,则表明系统上没有符合要求的库。

每个编译器在生成机器代码时都会对符号进行名称混淆,不幸的是,这项操作的约定不是通用的,而是编译器依赖的。为了克服这个困难,我们使用了FortranCInterface模块(cmake.org/cmake/help/v3.5/module/FortranCInterface.html)来验证 Fortran 和 C/C++编译器是否能协同工作,并生成一个与所讨论编译器兼容的 Fortran-C 接口头文件fc_mangle.h。生成的fc_mangle.h然后必须包含在接口头文件CxxBLAS.hppCxxLAPACK.hpp中。为了使用FortranCInterface,我们不得不在LANGUAGES列表中添加 C 和 Fortran 支持。当然,我们可以定义自己的预处理器定义,但代价是有限的移植性。

我们将在第九章,混合语言项目中更详细地讨论 Fortran 和 C 的互操作性。

如今,许多 BLAS 和 LAPACK 的实现已经附带了一个围绕 Fortran 子程序的薄 C 层包装器。这些包装器多年来已经标准化,被称为 CBLAS 和 LAPACKE。

还有更多内容

许多数值代码严重依赖于矩阵代数操作,正确地链接到高性能的 BLAS 和 LAPACK API 实现非常重要。不同供应商在不同架构和并行环境下打包其库的方式存在很大差异。FindBLAS.cmakeFindLAPACK.cmake很可能无法在所有可能的情况下定位现有的库。如果发生这种情况,您可以通过 CLI 的-D选项显式设置库。

检测 OpenMP 并行环境

本食谱的代码可在github.com/dev-cafe/cmake-cookbook/tree/v1.0/chapter-03/recipe-05找到,并包含 C++和 Fortran 示例。该食谱适用于 CMake 版本 3.9(及以上),并在 GNU/Linux、macOS 和 Windows 上进行了测试。在github.com/dev-cafe/cmake-cookbook/tree/v1.0/chapter-03/recipe-05,我们还提供了与 CMake 3.5 兼容的示例。

如今,市场上的基本任何计算机都是多核机器,对于专注于性能的程序,我们可能需要关注这些多核 CPU,并在我们的编程模型中使用并发。OpenMP 是多核 CPU 共享内存并行性的标准。现有的程序通常不需要进行根本性的修改或重写,以从 OpenMP 并行化中受益。一旦在代码中确定了性能关键部分,例如使用分析工具,程序员可以添加预处理器指令,这些指令将指示编译器为这些区域生成并行代码。

在本教程中,我们将展示如何编译包含 OpenMP 指令的程序,前提是我们使用的是支持 OpenMP 的编译器。许多 Fortran、C 和 C++编译器都可以利用 OpenMP 的并行性。CMake 对 C、C++或 Fortran 的相对较新版本提供了非常好的 OpenMP 支持。本教程将向您展示如何在使用 CMake 3.9 或更高版本时,为简单的 C++和 Fortran 程序检测并链接 OpenMP 使用导入的目标。

根据 Linux 发行版的不同,默认版本的 Clang 编译器可能不支持 OpenMP。本教程不适用于macOS,除非使用单独的 libomp 安装(iscinumpy.gitlab.io/post/omp-on-high-sierra/)或非 Apple 版本的 Clang(例如,由 Conda 提供)或 GNU 编译器。

准备工作

C 和 C++程序可以通过包含omp.h头文件并链接正确的库来访问 OpenMP 功能。编译器将根据性能关键部分之前的预处理器指令生成并行代码。在本教程中,我们将构建以下示例源代码(example.cpp)。该代码将 1 到N的整数求和,其中N作为命令行参数给出:

#include <iostream>
#include <omp.h>
#include <string>

int main(int argc, char *argv[]) {
  std::cout << "number of available processors: " << omp_get_num_procs()
            << std::endl;
  std::cout << "number of threads: " << omp_get_max_threads() << std::endl;

  auto n = std::stol(argv[1]);
  std::cout << "we will form sum of numbers from 1 to " << n << std::endl;

  // start timer
  auto t0 = omp_get_wtime();

  auto s = 0LL;
#pragma omp parallel for reduction(+ : s)
  for (auto i = 1; i <= n; i++) {
    s += i;
  }

  // stop timer
  auto t1 = omp_get_wtime();

  std::cout << "sum: " << s << std::endl;
  std::cout << "elapsed wall clock time: " << t1 - t0 << " seconds" << std::endl;

  return 0;
}

在 Fortran 中,需要使用omp_lib模块并链接到正确的库。在性能关键部分之前的代码注释中再次可以使用并行指令。相应的example.F90包含以下内容:

program example

  use omp_lib

  implicit none

  integer(8) :: i, n, s
  character(len=32) :: arg
  real(8) :: t0, t1

  print *, "number of available processors:", omp_get_num_procs()
  print *, "number of threads:", omp_get_max_threads()

  call get_command_argument(1, arg)
  read(arg , *) n

  print *, "we will form sum of numbers from 1 to", n

  ! start timer
  t0 = omp_get_wtime()

  s = 0
!$omp parallel do reduction(+:s)
  do i = 1, n
    s = s + i
  end do

  ! stop timer
  t1 = omp_get_wtime()

  print *, "sum:", s
  print *, "elapsed wall clock time (seconds):", t1 - t0

end program

如何操作

我们的 C++和 Fortran 示例的CMakeLists.txt将遵循一个在两种语言之间大体相似的模板:

  1. 两者都定义了最小 CMake 版本、项目名称和语言(CXXFortran;我们将展示 C++版本):
cmake_minimum_required(VERSION 3.9 FATAL_ERROR)

project(recipe-05 LANGUAGES CXX)
  1. 对于 C++示例,我们需要 C++11 标准:
set(CMAKE_CXX_STANDARD 11)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
  1. 两者都调用find_package来搜索 OpenMP:
find_package(OpenMP REQUIRED)
  1. 最后,我们定义可执行目标并链接到FindOpenMP模块提供的导入目标(在 Fortran 情况下,我们链接到OpenMP::OpenMP_Fortran):
add_executable(example example.cpp)

target_link_libraries(example
  PUBLIC
    OpenMP::OpenMP_CXX
  )
  1. 现在,我们可以配置并构建代码:
$ mkdir -p build
$ cd build
$ cmake ..
$ cmake --build .
  1. 首先让我们在并行环境下测试一下(本例中使用四个核心):
$ ./example 1000000000

number of available processors: 4
number of threads: 4
we will form sum of numbers from 1 to 1000000000
sum: 500000000500000000
elapsed wall clock time: 1.08343 seconds
  1. 为了比较,我们可以将示例重新运行,将 OpenMP 线程数设置为 1:
$ env OMP_NUM_THREADS=1 ./example 1000000000

number of available processors: 4
number of threads: 1
we will form sum of numbers from 1 to 1000000000
sum: 500000000500000000
elapsed wall clock time: 2.96427 seconds

它是如何工作的

我们的简单示例似乎有效:代码已编译并链接,并且在运行于多个核心时我们观察到了加速。加速不是OMP_NUM_THREADS的完美倍数并不是本教程的关注点,因为我们专注于需要 OpenMP 的项目中的 CMake 方面。我们发现由于FindOpenMP模块提供的导入目标,链接 OpenMP 极其简洁:

target_link_libraries(example
  PUBLIC
    OpenMP::OpenMP_CXX
  )

我们不必担心编译标志或包含目录——这些设置和依赖关系都编码在库OpenMP::OpenMP_CXX的定义中,该库属于IMPORTED类型。正如我们在第 3 个配方中提到的,构建和链接静态和共享库,在第一章,从简单的可执行文件到库中,IMPORTED库是伪目标,它们完全编码了外部依赖的使用要求。要使用 OpenMP,需要设置编译器标志、包含目录和链接库。所有这些都作为属性设置在OpenMP::OpenMP_CXX目标上,并通过使用target_link_libraries命令间接应用于我们的example目标。这使得在我们的 CMake 脚本中使用库变得非常容易。我们可以使用cmake_print_properties命令打印接口的属性,该命令由CMakePrintHelpers.cmake标准模块提供:

include(CMakePrintHelpers)
cmake_print_properties(
  TARGETS
    OpenMP::OpenMP_CXX
  PROPERTIES
    INTERFACE_COMPILE_OPTIONS
    INTERFACE_INCLUDE_DIRECTORIES
    INTERFACE_LINK_LIBRARIES
  )

请注意,所有感兴趣的属性都带有前缀INTERFACE_,因为这些属性的使用要求适用于任何希望接口并使用 OpenMP 目标的目标。

对于 CMake 版本低于 3.9 的情况,我们需要做更多的工作:

add_executable(example example.cpp)

target_compile_options(example
  PUBLIC
    ${OpenMP_CXX_FLAGS}
  )

set_target_properties(example
  PROPERTIES
    LINK_FLAGS ${OpenMP_CXX_FLAGS}
  )

对于 CMake 版本低于 3.5 的情况,我们可能需要为 Fortran 项目明确定义编译标志。

在本配方中,我们讨论了 C++和 Fortran,但论点和方法同样适用于 C 项目。

检测 MPI 并行环境

本配方的代码可在github.com/dev-cafe/cmake-cookbook/tree/v1.0/chapter-03/recipe-06找到,并包含 C++和 C 的示例。该配方适用于 CMake 版本 3.9(及以上),并在 GNU/Linux、macOS 和 Windows 上进行了测试。在github.com/dev-cafe/cmake-cookbook/tree/v1.0/chapter-03/recipe-06,我们还提供了一个与 CMake 3.5 兼容的 C 示例。

与 OpenMP 共享内存并行性的一种替代且通常互补的方法是消息传递接口(MPI),它已成为在分布式内存系统上并行执行程序的事实标准。尽管现代 MPI 实现也允许共享内存并行性,但在高性能计算中,典型的方法是使用 OpenMP 在计算节点内结合 MPI 跨计算节点。MPI 标准的实现包括以下内容:

  1. 运行时库。

  2. 头文件和 Fortran 90 模块。

  3. 编译器包装器,它调用用于构建 MPI 库的编译器,并带有额外的命令行参数来处理包含目录和库。通常,可用的编译器包装器包括mpic++/mpiCC/mpicxx用于 C++,mpicc用于 C,以及mpifort用于 Fortran。

  4. MPI 启动器:这是您应该调用的程序,用于启动编译代码的并行执行。其名称取决于实现,通常是以下之一:mpirunmpiexecorterun

本示例将展示如何在系统上找到合适的 MPI 实现,以便编译简单的 MPI“Hello, World”程序。

准备工作

本示例代码(hello-mpi.cpp,从www.mpitutorial.com下载),我们将在本示例中编译,将初始化 MPI 库,让每个进程打印其名称,并最终关闭库:

#include <iostream>

#include <mpi.h>

int main(int argc, char **argv) {
  // Initialize the MPI environment. The two arguments to MPI Init are not
  // currently used by MPI implementations, but are there in case future
  // implementations might need the arguments.
  MPI_Init(NULL, NULL);

  // Get the number of processes
  int world_size;
  MPI_Comm_size(MPI_COMM_WORLD, &world_size);

  // Get the rank of the process
  int world_rank;
  MPI_Comm_rank(MPI_COMM_WORLD, &world_rank);

  // Get the name of the processor
  char processor_name[MPI_MAX_PROCESSOR_NAME];
  int name_len;
  MPI_Get_processor_name(processor_name, &name_len);

  // Print off a hello world message
  std::cout << "Hello world from processor " << processor_name << ", rank "
            << world_rank << " out of " << world_size << " processors" << std::endl;

  // Finalize the MPI environment. No more MPI calls can be made after this
  MPI_Finalize();
}

如何操作

在本示例中,我们旨在找到 MPI 实现:库、头文件、编译器包装器和启动器。为此,我们将利用FindMPI.cmake标准 CMake 模块:

  1. 首先,我们定义最小 CMake 版本、项目名称、支持的语言和语言标准:
cmake_minimum_required(VERSION 3.9 FATAL_ERROR)

project(recipe-06 LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 11)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
  1. 然后我们调用find_package来定位 MPI 实现:
find_package(MPI REQUIRED)
  1. 我们定义可执行文件的名称和源代码,并且与前面的示例类似,链接到导入的目标:
add_executable(hello-mpi hello-mpi.cpp)

target_link_libraries(hello-mpi
  PUBLIC
    MPI::MPI_CXX
  )
  1. 让我们配置并构建可执行文件:
$ mkdir -p build
$ cd build
$ cmake -D CMAKE_CXX_COMPILER=mpicxx ..

-- ...
-- Found MPI_CXX: /usr/lib/openmpi/libmpi_cxx.so (found version "3.1") 
-- Found MPI: TRUE (found version "3.1")
-- ...

$ cmake --build .
  1. 为了并行执行此程序,我们使用mpirun启动器(在这种情况下,使用两个任务):
$ mpirun -np 2 ./hello-mpi

Hello world from processor larry, rank 1 out of 2 processors
Hello world from processor larry, rank 0 out of 2 processors

工作原理

请记住,编译器包装器是围绕编译器的一层薄层,用于构建 MPI 库。在底层,它将调用相同的编译器,并为其添加额外的参数,如包含路径和库,以成功构建并行程序。

包装器在编译和链接源文件时实际应用哪些标志?我们可以使用编译器包装器的--showme选项来探测这一点。要找出编译器标志,我们可以使用:

$ mpicxx --showme:compile

-pthread

要找出链接器标志,我们使用以下方法:

$ mpicxx --showme:link

-pthread -Wl,-rpath -Wl,/usr/lib/openmpi -Wl,--enable-new-dtags -L/usr/lib/openmpi -lmpi_cxx -lmpi

与前一个 OpenMP 示例类似,我们发现链接到 MPI 非常简洁,这得益于相对现代的FindMPI模块提供的导入目标:

target_link_libraries(hello-mpi
  PUBLIC
    MPI::MPI_CXX
 )

我们不必担心编译标志或包含目录 - 这些设置和依赖关系已经作为INTERFACE类型属性编码在 CMake 提供的IMPORTED目标中。

正如在前一个示例中讨论的,对于 CMake 版本低于 3.9 的情况,我们需要做更多的工作:

add_executable(hello-mpi hello-mpi.c)

target_compile_options(hello-mpi
  PUBLIC
    ${MPI_CXX_COMPILE_FLAGS}
  )

target_include_directories(hello-mpi
  PUBLIC
    ${MPI_CXX_INCLUDE_PATH}
  )

target_link_libraries(hello-mpi
  PUBLIC
    ${MPI_CXX_LIBRARIES}
  )

在本示例中,我们讨论了 C++,但参数和方法同样适用于 C 或 Fortran 项目。

检测 Eigen 库

本示例的代码可在github.com/dev-cafe/cmake-cookbook/tree/v1.0/chapter-03/recipe-07找到,包含一个 C++示例。本示例适用于 CMake 版本 3.9(及以上),并在 GNU/Linux、macOS 和 Windows 上进行了测试。在github.com/dev-cafe/cmake-cookbook/tree/v1.0/chapter-03/recipe-07,我们还提供了一个与 CMake 3.5 兼容的 C++示例。

BLAS 库为涉及矩阵和向量的常见操作提供了一个标准化的接口。然而,这个接口是针对 Fortran 语言标准化的。虽然我们已经展示了如何从 C++中或多或少直接使用这些库,但在现代 C++程序中可能希望有一个更高层次的接口。

Eigen 库作为头文件使用模板编程来提供这样的接口。其矩阵和向量类型易于使用,甚至在编译时提供类型检查,以确保不混合不兼容的矩阵维度。密集和稀疏矩阵操作,如矩阵-矩阵乘积、线性系统求解器和特征值问题,也使用表达式模板实现效率。从版本 3.3 开始,Eigen 可以链接到 BLAS 和 LAPACK 库,这提供了灵活性,可以将某些操作卸载到这些库中提供的实现以获得额外的性能。

本配方将展示如何找到 Eigen 库,并指示它使用 OpenMP 并行化并将部分工作卸载到 BLAS 库。

准备就绪

在本例中,我们将编译一个程序,该程序分配一个随机方阵和从命令行传递的维度的向量。然后,我们将使用 LU 分解求解线性系统Ax=b。我们将使用以下源代码(linear-algebra.cpp):

#include <chrono>
#include <cmath>
#include <cstdlib>
#include <iomanip>
#include <iostream>
#include <vector>

#include <Eigen/Dense>

int main(int argc, char **argv) {
  if (argc != 2) {
    std::cout << "Usage: ./linear-algebra dim" << std::endl;
    return EXIT_FAILURE;
  }

  std::chrono::time_point<std::chrono::system_clock> start, end;
  std::chrono::duration<double> elapsed_seconds;
  std::time_t end_time;

  std::cout << "Number of threads used by Eigen: " << Eigen::nbThreads()
            << std::endl;

  // Allocate matrices and right-hand side vector
  start = std::chrono::system_clock::now();
  int dim = std::atoi(argv[1]);
  Eigen::MatrixXd A = Eigen::MatrixXd::Random(dim, dim);
  Eigen::VectorXd b = Eigen::VectorXd::Random(dim);
  end = std::chrono::system_clock::now();

  // Report times
  elapsed_seconds = end - start;
  end_time = std::chrono::system_clock::to_time_t(end);
  std::cout << "matrices allocated and initialized "
            << std::put_time(std::localtime(&end_time), "%a %b %d %Y   
%r\n")
            << "elapsed time: " << elapsed_seconds.count() << "s\n";

  start = std::chrono::system_clock::now();
  // Save matrix and RHS
  Eigen::MatrixXd A1 = A;
  Eigen::VectorXd b1 = b;
  end = std::chrono::system_clock::now();
  end_time = std::chrono::system_clock::to_time_t(end);
  std::cout << "Scaling done, A and b saved "
            << std::put_time(std::localtime(&end_time), "%a %b %d %Y %r\n")
            << "elapsed time: " << elapsed_seconds.count() << "s\n";

  start = std::chrono::system_clock::now();
  Eigen::VectorXd x = A.lu().solve(b);
  end = std::chrono::system_clock::now();

  // Report times
  elapsed_seconds = end - start;
  end_time = std::chrono::system_clock::to_time_t(end);

  double relative_error = (A * x - b).norm() / b.norm();

  std::cout << "Linear system solver done "
            << std::put_time(std::localtime(&end_time), "%a %b %d %Y %r\n")
            << "elapsed time: " << elapsed_seconds.count() << "s\n";
  std::cout << "relative error is " << relative_error << std::endl;

  return 0;
}

矩阵-向量乘法和 LU 分解在 Eigen 中实现,但可以选择卸载到 BLAS 和 LAPACK 库。在本配方中,我们只考虑卸载到 BLAS 库。

如何做到这一点

在本项目中,我们将找到 Eigen 和 BLAS 库,以及 OpenMP,并指示 Eigen 使用 OpenMP 并行化,并将部分线性代数工作卸载到 BLAS 库:

  1. 我们首先声明 CMake 的最低版本、项目名称以及使用 C++11 语言:
cmake_minimum_required(VERSION 3.9 FATAL_ERROR)

project(recipe-07 LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 11)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
  1. 我们还请求 OpenMP,因为 Eigen 可以利用共享内存并行性进行密集操作:
find_package(OpenMP REQUIRED)
  1. 我们通过调用find_packageCONFIG模式下搜索 Eigen(我们将在下一节讨论这一点):
find_package(Eigen3 3.3 REQUIRED CONFIG)
  1. 如果找到 Eigen,我们会打印出有帮助的状态消息。请注意,我们正在使用Eigen3::Eigen目标。正如我们在前两个配方中学到的,这是一个IMPORTED目标,由 Eigen 分发的原生 CMake 脚本提供:
if(TARGET Eigen3::Eigen)
  message(STATUS "Eigen3 v${EIGEN3_VERSION_STRING} found in ${EIGEN3_INCLUDE_DIR}")
endif()
  1. 接下来,我们为我们的源文件声明一个可执行目标:
add_executable(linear-algebra linear-algebra.cpp)
  1. 然后我们找到 BLAS。请注意,依赖项现在不是必需的:
find_package(BLAS)
  1. 如果找到 BLAS,我们为可执行目标设置相应的编译定义和链接库:
if(BLAS_FOUND)
  message(STATUS "Eigen will use some subroutines from BLAS.")
  message(STATUS "See: http://eigen.tuxfamily.org/dox-devel/TopicUsingBlasLapack.html")
  target_compile_definitions(linear-algebra
    PRIVATE
      EIGEN_USE_BLAS
    )
  target_link_libraries(linear-algebra
    PUBLIC
      ${BLAS_LIBRARIES}
    )
else()
  message(STATUS "BLAS not found. Using Eigen own functions")
endif()
  1. 最后,我们链接到导入的Eigen3::EigenOpenMP::OpenMP_CXX目标。这足以设置所有必要的编译和链接标志:
target_link_libraries(linear-algebra
  PUBLIC
    Eigen3::Eigen
    OpenMP::OpenMP_CXX
  )
  1. 我们现在已经准备好配置项目:
$ mkdir -p build
$ cd build
$ cmake ..

-- ...
-- Found OpenMP_CXX: -fopenmp (found version "4.5") 
-- Found OpenMP: TRUE (found version "4.5") 
-- Eigen3 v3.3.4 found in /usr/include/eigen3
-- ...
-- Found BLAS: /usr/lib/libblas.so 
-- Eigen will use some subroutines from BLAS.
-- See: http://eigen.tuxfamily.org/dox-devel/TopicUsingBlasLapack.html
  1. 最后,我们编译并测试代码。请注意,在这种情况下,二进制文件使用了四个可用线程:
$ cmake --build .
$ ./linear-algebra 1000

Number of threads used by Eigen: 4
matrices allocated and initialized Sun Jun 17 2018 11:04:20 AM
elapsed time: 0.0492328s
Scaling done, A and b saved Sun Jun 17 2018 11:04:20 AM
elapsed time: 0.0492328s
Linear system solver done Sun Jun 17 2018 11:04:20 AM
elapsed time: 0.483142s
relative error is 4.21946e-13

它是如何工作的

Eigen 提供了原生的 CMake 支持,这使得使用它来设置 C++ 项目变得简单。从版本 3.3 开始,Eigen 提供了 CMake 模块,导出适当的 target,即 Eigen3::Eigen,我们在这里使用了它。

您可能已经注意到 find_package 命令的 CONFIG 选项。这向 CMake 发出信号,表明包搜索不会通过 FindEigen3.cmake 模块进行,而是通过 Eigen3 包在标准位置提供的 Eigen3Config.cmakeEigen3ConfigVersion.cmakeEigen3Targets.cmake 文件进行,即 <installation-prefix>/share/eigen3/cmake。这种包位置模式称为“Config”模式,比我们迄今为止使用的 Find<package>.cmake 方法更灵活。有关“Module”模式与“Config”模式的更多信息,请查阅官方文档:cmake.org/cmake/help/v3.5/command/find_package.html

还要注意,尽管 Eigen3、BLAS 和 OpenMP 依赖项被声明为 PUBLIC 依赖项,但 EIGEN_USE_BLAS 编译定义被声明为 PRIVATE。我们不是直接链接可执行文件,而是可以将库依赖项收集到一个单独的库目标中。使用 PUBLIC/PRIVATE 关键字,我们可以调整相应标志和定义对库目标依赖项的可见性。

还有更多

CMake 会在预定义的位置层次结构中查找配置模块。首先是 CMAKE_PREFIX_PATH,而 <package>_DIR 是下一个搜索路径。因此,如果 Eigen3 安装在非标准位置,我们可以使用两种替代方法来告诉 CMake 在哪里查找它:

  1. 通过传递 Eigen3 的安装前缀作为 CMAKE_PREFIX_PATH
$ cmake -D CMAKE_PREFIX_PATH=<installation-prefix> ..
  1. 通过传递配置文件的位置作为 Eigen3_DIR
$ cmake -D Eigen3_DIR=<installation-prefix>/share/eigen3/cmake/

检测 Boost 库

本食谱的代码可在 github.com/dev-cafe/cmake-cookbook/tree/v1.0/chapter-03/recipe-08 获取,并包含一个 C++ 示例。该食谱适用于 CMake 版本 3.5(及以上),并在 GNU/Linux、macOS 和 Windows 上进行了测试。

Boost 库是一系列通用目的的 C++ 库。这些库提供了许多现代 C++ 项目中可能必不可少的特性,而这些特性在 C++ 标准中尚未提供。例如,Boost 提供了元编程、处理可选参数和文件系统操作等组件。许多这些库后来被 C++11、C++14 和 C++17 标准采纳,但对于需要保持与旧编译器兼容性的代码库,许多 Boost 组件仍然是首选库。

本食谱将向您展示如何检测并链接 Boost 库的某些组件。

准备就绪

我们将编译的源代码是 Boost 提供的文件系统库的示例之一,用于与文件系统交互。该库方便地跨平台,并将操作系统与文件系统的差异抽象成一个连贯的高级 API。以下示例代码(path-info.cpp)将接受一个路径作为参数,并将其组件的报告打印到屏幕上:

#include <iostream>

#include <boost/filesystem.hpp>

using namespace std;
using namespace boost::filesystem;

const char *say_what(bool b) { return b ? "true" : "false"; }

int main(int argc, char *argv[]) {
  if (argc < 2) {
    cout
        << "Usage: path_info path-element [path-element...]\n"
           "Composes a path via operator/= from one or more path-element arguments\n"
           "Example: path_info foo/bar baz\n"
#ifdef BOOST_POSIX_API
           " would report info about the composed path foo/bar/baz\n";
#else // BOOST_WINDOWS_API
           " would report info about the composed path foo/bar\\baz\n";
#endif
    return 1;
  }

  path p;
  for (; argc > 1; --argc, ++argv)
    p /= argv[1]; // compose path p from the command line arguments

  cout << "\ncomposed path:\n";
  cout << " operator<<()---------: " << p << "\n";
  cout << " make_preferred()-----: " << p.make_preferred() << "\n";

  cout << "\nelements:\n";
  for (auto element : p)
    cout << " " << element << '\n';

  cout << "\nobservers, native format:" << endl;
#ifdef BOOST_POSIX_API
  cout << " native()-------------: " << p.native() << endl;
  cout << " c_str()--------------: " << p.c_str() << endl;
#else // BOOST_WINDOWS_API
  wcout << L" native()-------------: " << p.native() << endl;
  wcout << L" c_str()--------------: " << p.c_str() << endl;
#endif
  cout << " string()-------------: " << p.string() << endl;
  wcout << L" wstring()------------: " << p.wstring() << endl;

  cout << "\nobservers, generic format:\n";
  cout << " generic_string()-----: " << p.generic_string() << endl;
  wcout << L" generic_wstring()----: " << p.generic_wstring() << endl;

  cout << "\ndecomposition:\n";
  cout << " root_name()----------: " << p.root_name() << '\n';
  cout << " root_directory()-----: " << p.root_directory() << '\n';
  cout << " root_path()----------: " << p.root_path() << '\n';
  cout << " relative_path()------: " << p.relative_path() << '\n';
  cout << " parent_path()--------: " << p.parent_path() << '\n';
  cout << " filename()-----------: " << p.filename() << '\n';
  cout << " stem()---------------: " << p.stem() << '\n';
  cout << " extension()----------: " << p.extension() << '\n';

  cout << "\nquery:\n";
  cout << " empty()--------------: " << say_what(p.empty()) << '\n';
  cout << " is_absolute()--------: " << say_what(p.is_absolute()) << 
  '\n';
  cout << " has_root_name()------: " << say_what(p.has_root_name()) << 
  '\n';
  cout << " has_root_directory()-: " << say_what(p.has_root_directory()) << '\n';
  cout << " has_root_path()------: " << say_what(p.has_root_path()) << 
  '\n';
  cout << " has_relative_path()--: " << say_what(p.has_relative_path()) << '\n';
  cout << " has_parent_path()----: " << say_what(p.has_parent_path()) << '\n';
  cout << " has_filename()-------: " << say_what(p.has_filename()) << 
  '\n';
  cout << " has_stem()-----------: " << say_what(p.has_stem()) << '\n';
  cout << " has_extension()------: " << say_what(p.has_extension()) <<  
  '\n';

  return 0;
}

如何操作

Boost 包含许多不同的库,这些库几乎可以独立使用。在内部,CMake 将这个库集合表示为组件集合。FindBoost.cmake模块不仅可以搜索整个库集合的安装,还可以搜索集合中特定组件及其依赖项(如果有的话)。我们将逐步构建相应的CMakeLists.txt

  1. 我们首先声明了最低 CMake 版本、项目名称、语言,并强制使用 C++11 标准:
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)

project(recipe-08 LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 11)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
  1. 然后,我们使用find_package来搜索 Boost。对 Boost 的依赖是强制性的,因此使用了REQUIRED参数。由于在本例中我们只需要文件系统组件,因此我们在COMPONENTS关键字后传递该组件作为参数给find_package
find_package(Boost 1.54 REQUIRED COMPONENTS filesystem)
  1. 我们添加了一个可执行目标,用于编译示例源文件:
add_executable(path-info path-info.cpp)
  1. 最后,我们将目标链接到 Boost 库组件。由于依赖关系被声明为PUBLIC,依赖于我们目标的其他目标将自动获取该依赖关系:
target_link_libraries(path-info
  PUBLIC
    Boost::filesystem
  )

工作原理

FindBoost.cmake模块,在本例中使用,将尝试在标准系统安装目录中定位 Boost 库。由于我们链接到导入的Boost::filesystem目标,CMake 将自动设置包含目录并调整编译和链接标志。如果 Boost 库安装在非标准位置,可以在配置时使用BOOST_ROOT变量传递 Boost 安装的根目录,以指示 CMake 也在非标准路径中搜索:

$ cmake -D BOOST_ROOT=/custom/boost/

或者,可以同时传递BOOST_INCLUDEDIRBOOST_LIBRARYDIR变量,以指定包含头文件和库的目录:

$ cmake -D BOOST_INCLUDEDIR=/custom/boost/include -D BOOST_LIBRARYDIR=/custom/boost/lib

检测外部库:I. 使用 pkg-config

本例的代码可在github.com/dev-cafe/cmake-cookbook/tree/v1.0/chapter-03/recipe-09找到,并包含一个 C 语言示例。本例适用于 CMake 3.6(及以上)版本,并在 GNU/Linux、macOS 和 Windows(使用 MSYS Makefiles)上进行了测试。在github.com/dev-cafe/cmake-cookbook/tree/v1.0/chapter-03/recipe-09,我们还提供了一个与 CMake 3.5 兼容的示例。

到目前为止,我们已经讨论了两种检测外部依赖的方法:

  • 使用 CMake 附带的 find-modules。这通常是可靠且经过良好测试的。然而,并非所有包在 CMake 的官方发布版中都有一个 find-module。

  • 使用包供应商提供的<package>Config.cmake<package>ConfigVersion.cmake<package>Targets.cmake文件,这些文件与包本身一起安装在标准位置。

如果某个依赖项既不提供 find-module 也不提供 vendor-packaged CMake 文件,我们该怎么办?在这种情况下,我们有两个选择:

  • 依赖pkg-config实用程序来发现系统上的包。这依赖于包供应商在.pc配置文件中分发有关其包的元数据。

  • 为依赖项编写我们自己的 find-package 模块。

在本食谱中,我们将展示如何从 CMake 内部利用pkg-config来定位 ZeroMQ 消息库。下一个食谱,检测外部库:II. 编写 find-module,将展示如何为 ZeroMQ 编写自己的基本 find-module。

准备工作

我们将构建的代码是 ZeroMQ 手册中的一个示例,网址为zguide.zeromq.org/page:all。它由两个源文件hwserver.chwclient.c组成,将构建为两个单独的可执行文件。执行时,它们将打印熟悉的“Hello, World”消息。

如何操作

这是一个 C 项目,我们将使用 C99 标准。我们将逐步构建CMakeLists.txt文件:

  1. 我们声明一个 C 项目并强制执行 C99 标准:
cmake_minimum_required(VERSION 3.6 FATAL_ERROR)

project(recipe-09 LANGUAGES C)

set(CMAKE_C_STANDARD 99)
set(CMAKE_C_EXTENSIONS OFF)
set(CMAKE_C_STANDARD_REQUIRED ON)
  1. 我们查找pkg-config,使用 CMake 附带的 find-module。注意传递给find_packageQUIET参数。只有当所需的pkg-config未找到时,CMake 才会打印消息:
find_package(PkgConfig REQUIRED QUIET)
  1. 当找到pkg-config时,我们将能够访问pkg_search_module函数来搜索任何带有包配置.pc文件的库或程序。在我们的例子中,我们查找 ZeroMQ 库:
pkg_search_module(
  ZeroMQ
  REQUIRED
    libzeromq libzmq lib0mq
  IMPORTED_TARGET
  )
  1. 如果找到 ZeroMQ 库,将打印状态消息:
if(TARGET PkgConfig::ZeroMQ)
  message(STATUS "Found ZeroMQ")
endif()
  1. 然后我们可以添加两个可执行目标,并与 ZeroMQ 的IMPORTED目标链接。这将自动设置包含目录和链接库:
add_executable(hwserver hwserver.c)

target_link_libraries(hwserver PkgConfig::ZeroMQ)

add_executable(hwclient hwclient.c)

target_link_libraries(hwclient PkgConfig::ZeroMQ)
  1. 现在,我们可以配置并构建示例:
$ mkdir -p build
$ cd build
$ cmake ..
$ cmake --build .
  1. 在一个终端中启动服务器,它将响应类似于以下示例的消息:
Current 0MQ version is 4.2.2
  1. 然后,在另一个终端启动客户端,它将打印以下内容:
Connecting to hello world server…
Sending Hello 0…
Received World 0
Sending Hello 1…
Received World 1
Sending Hello 2…
...

工作原理

一旦找到pkg-config,CMake 将提供两个函数来封装这个程序提供的功能:

  • pkg_check_modules,用于在传递的列表中查找所有模块(库和/或程序)

  • pkg_search_module,用于在传递的列表中查找第一个可用的模块

这些函数接受REQUIREDQUIET参数,就像find_package一样。更详细地说,我们对pkg_search_module的调用如下:

pkg_search_module(
  ZeroMQ
  REQUIRED
    libzeromq libzmq lib0mq
  IMPORTED_TARGET
  )

这里,第一个参数是用于命名存储 ZeroMQ 库搜索结果的目标的前缀:PkgConfig::ZeroMQ。注意,我们需要为系统上的库名称传递不同的选项:libzeromqlibzmqlib0mq。这是因为不同的操作系统和包管理器可能会为同一个包选择不同的名称。

pkg_check_modulespkg_search_module函数在 CMake 3.6 中获得了IMPORTED_TARGET选项和定义导入目标的功能。在此之前的 CMake 版本中,只会为稍后使用定义变量ZeroMQ_INCLUDE_DIRS(包含目录)和ZeroMQ_LIBRARIES(链接库)。

检测外部库:II. 编写查找模块

本配方的代码可在github.com/dev-cafe/cmake-cookbook/tree/v1.0/chapter-03/recipe-10获取,并包含一个 C 示例。本配方适用于 CMake 版本 3.5(及更高版本),并在 GNU/Linux、macOS 和 Windows 上进行了测试。

本配方补充了之前的配方,检测外部库:I. 使用 pkg-config。我们将展示如何编写一个基本的查找模块来定位系统上的 ZeroMQ 消息库,以便在非 Unix 操作系统上进行库检测。我们将重用相同的服务器-客户端示例代码。

如何操作

这是一个 C 项目,我们将使用 C99 标准。我们将逐步构建CMakeLists.txt文件:

  1. 我们声明一个 C 项目并强制执行 C99 标准:
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)

project(recipe-10 LANGUAGES C)

set(CMAKE_C_STANDARD 99)
set(CMAKE_C_EXTENSIONS OFF)
set(CMAKE_C_STANDARD_REQUIRED ON)
  1. 我们将当前源目录,CMAKE_CURRENT_SOURCE_DIR,添加到 CMake 查找模块的路径列表中,CMAKE_MODULE_PATH。这是我们自己的FindZeroMQ.cmake模块所在的位置:
list(APPEND CMAKE_MODULE_PATH ${CMAKE_CURRENT_SOURCE_DIR})
  1. 我们稍后将讨论FindZeroMQ.cmake,但现在FindZeroMQ.cmake模块可用,我们搜索库。这是我们项目的必要依赖项。由于我们没有使用find_packageQUIET选项,当找到库时,将自动打印状态消息:
find_package(ZeroMQ REQUIRED)
  1. 我们继续添加hwserver可执行目标。使用ZeroMQ_INCLUDE_DIRSZeroMQ_LIBRARIES变量指定包含目录和链接库,这些变量由成功的find_package命令设置:
add_executable(hwserver hwserver.c)

target_include_directories(hwserver
  PRIVATE
    ${ZeroMQ_INCLUDE_DIRS}
  )

target_link_libraries(hwserver
  PRIVATE
    ${ZeroMQ_LIBRARIES}
  )
  1. 最后,我们对hwclient可执行目标也做同样的事情:
add_executable(hwclient hwclient.c)

target_include_directories(hwclient
  PRIVATE
    ${ZeroMQ_INCLUDE_DIRS}
  )

target_link_libraries(hwclient
  PRIVATE
    ${ZeroMQ_LIBRARIES}
  )

本配方的主要CMakeLists.txt与之前配方中使用的不同之处在于使用了FindZeroMQ.cmake模块。该模块使用find_pathfind_libraryCMake 内置命令搜索 ZeroMQ 头文件和库,并使用find_package_handle_standard_args设置相关变量,正如我们在配方 3 中所做的,检测 Python 模块和包

  1. FindZeroMQ.cmake中,我们首先检查用户是否设置了ZeroMQ_ROOT CMake 变量。此变量可用于指导检测 ZeroMQ 库到非标准安装目录。用户可能已经将ZeroMQ_ROOT设置为环境变量,我们也检查了这一点:
if(NOT ZeroMQ_ROOT)
  set(ZeroMQ_ROOT "$ENV{ZeroMQ_ROOT}")
endif()
  1. 然后,我们在系统上搜索zmq.h头文件的位置。这是基于_ZeroMQ_ROOT变量,并使用 CMake 的find_path命令:
if(NOT ZeroMQ_ROOT)
  find_path(_ZeroMQ_ROOT NAMES include/zmq.h)
else()
  set(_ZeroMQ_ROOT "${ZeroMQ_ROOT}")
endif()

find_path(ZeroMQ_INCLUDE_DIRS NAMES zmq.h HINTS ${_ZeroMQ_ROOT}/include)
  1. 如果成功找到头文件,则将ZeroMQ_INCLUDE_DIRS设置为其位置。我们继续查找可用的 ZeroMQ 库版本,使用字符串操作和正则表达式:
set(_ZeroMQ_H ${ZeroMQ_INCLUDE_DIRS}/zmq.h)

function(_zmqver_EXTRACT _ZeroMQ_VER_COMPONENT _ZeroMQ_VER_OUTPUT)
  set(CMAKE_MATCH_1 "0")
  set(_ZeroMQ_expr "^[ \\t]*#define[ \\t]+${_ZeroMQ_VER_COMPONENT}[ \\t]+([0-9]+)$")
  file(STRINGS "${_ZeroMQ_H}" _ZeroMQ_ver REGEX "${_ZeroMQ_expr}")
  string(REGEX MATCH "${_ZeroMQ_expr}" ZeroMQ_ver "${_ZeroMQ_ver}")
  set(${_ZeroMQ_VER_OUTPUT} "${CMAKE_MATCH_1}" PARENT_SCOPE)
endfunction()

_zmqver_EXTRACT("ZMQ_VERSION_MAJOR" ZeroMQ_VERSION_MAJOR)
_zmqver_EXTRACT("ZMQ_VERSION_MINOR" ZeroMQ_VERSION_MINOR)
_zmqver_EXTRACT("ZMQ_VERSION_PATCH" ZeroMQ_VERSION_PATCH)
  1. 然后,我们为find_package_handle_standard_args命令准备ZeroMQ_VERSION变量:
if(ZeroMQ_FIND_VERSION_COUNT GREATER 2)
  set(ZeroMQ_VERSION "${ZeroMQ_VERSION_MAJOR}.${ZeroMQ_VERSION_MINOR}.${ZeroMQ_VERSION_PATCH}")
else()
  set(ZeroMQ_VERSION "${ZeroMQ_VERSION_MAJOR}.${ZeroMQ_VERSION_MINOR}")
endif()
  1. 我们使用find_library命令来搜索ZeroMQ库。在这里,我们需要在 Unix 基础和 Windows 平台之间做出区分,因为库的命名约定不同:
if(NOT ${CMAKE_C_PLATFORM_ID} STREQUAL "Windows")
  find_library(ZeroMQ_LIBRARIES 
      NAMES 
        zmq 
      HINTS 
        ${_ZeroMQ_ROOT}/lib
        ${_ZeroMQ_ROOT}/lib/x86_64-linux-gnu
      )
else()
  find_library(ZeroMQ_LIBRARIES
      NAMES
        libzmq
        "libzmq-mt-${ZeroMQ_VERSION_MAJOR}_${ZeroMQ_VERSION_MINOR}_${ZeroMQ_VERSION_PATCH}"
        "libzmq-${CMAKE_VS_PLATFORM_TOOLSET}-mt-${ZeroMQ_VERSION_MAJOR}_${ZeroMQ_VERSION_MINOR}_${ZeroMQ_VERSION_PATCH}"
        libzmq_d
        "libzmq-mt-gd-${ZeroMQ_VERSION_MAJOR}_${ZeroMQ_VERSION_MINOR}_${ZeroMQ_VERSION_PATCH}"
        "libzmq-${CMAKE_VS_PLATFORM_TOOLSET}-mt-gd-${ZeroMQ_VERSION_MAJOR}_${ZeroMQ_VERSION_MINOR}_${ZeroMQ_VERSION_PATCH}"
      HINTS
        ${_ZeroMQ_ROOT}/lib
      )
endif()
  1. 最后,我们包含标准的FindPackageHandleStandardArgs.cmake模块并调用相应的 CMake 命令。如果找到所有必需的变量并且版本匹配,则将ZeroMQ_FOUND变量设置为TRUE
include(FindPackageHandleStandardArgs)

find_package_handle_standard_args(ZeroMQ
  FOUND_VAR
    ZeroMQ_FOUND
  REQUIRED_VARS
    ZeroMQ_INCLUDE_DIRS
    ZeroMQ_LIBRARIES
  VERSION_VAR
    ZeroMQ_VERSION
  )

我们刚才描述的FindZeroMQ.cmake模块是从github.com/zeromq/azmq/blob/master/config/FindZeroMQ.cmake改编而来的。

它是如何工作的

查找模块通常遵循特定的模式:

  1. 检查用户是否为所需包提供了自定义位置。

  2. 使用find_家族的命令来搜索所需包的已知必需组件,即头文件、库、可执行文件等。我们已经使用find_path来找到头文件的完整路径,并使用find_library来找到一个库。CMake 还提供了find_filefind_programfind_package。这些命令具有以下一般签名:

find_path(<VAR> NAMES name PATHS paths)
  1. 在这里,<VAR>将持有搜索的结果,如果成功,或者<VAR>-NOTFOUND如果失败。NAMESPATHS是 CMake 应该查找的文件的名称和搜索应该指向的路径,分别。

  2. 从这次初步搜索的结果中,提取版本号。在我们的例子中,ZeroMQ 头文件包含库版本,可以使用字符串操作和正则表达式提取。

  3. 最后,调用find_package_handle_standard_args命令。这将处理find_package命令的标准REQUIREDQUIET和版本参数,此外还设置ZeroMQ_FOUND变量。

任何 CMake 命令的完整文档都可以从命令行获取。例如,cmake --help-command find_file 将输出 find_file 命令的手册页。对于 CMake 标准模块的手册页,使用 --help-module CLI 开关。例如,cmake --help-module FindPackageHandleStandardArgs 将屏幕输出 FindPackageHandleStandardArgs.cmake 模块的手册页。

还有更多

总结一下,在发现软件包时,有四种可用的路线:

  1. 使用软件包供应商提供的 CMake 文件 packageConfig.cmakepackageConfigVersion.cmakepackageTargets.cmake,并将其安装在与软件包本身一起的标准位置。

  2. 使用所需的软件包的 find-module,无论是由 CMake 还是第三方提供的。

  3. 采用本食谱中所示的 pkg-config 方法。

  4. 如果这些都不适用,编写自己的 find-module。

四种替代路线已经按相关性排名,但每种方法都有其挑战。

并非所有软件包供应商都提供 CMake 发现文件,但这变得越来越普遍。这是因为导出 CMake 目标使得第三方代码消费库和/或程序所依赖的额外依赖项变得非常容易。

Find-modules 自 CMake 诞生之初就是依赖定位的工作马。然而,它们中的大多数仍然依赖于设置由依赖方消费的变量,例如 Boost_INCLUDE_DIRSPYTHON_INTERPRETER 等。这种方法使得为第三方重新分发自己的软件包并确保依赖项得到一致满足变得困难。

使用 pkg-config 的方法可以很好地工作,因为它已经成为基于 Unix 的系统的事实标准。因此,它不是一个完全跨平台的方法。此外,正如 CMake 文档所述,在某些情况下,用户可能会意外地覆盖软件包检测,导致 pkg-config 提供错误的信息。

最后的选择是编写自己的 find-module CMake 脚本,正如我们在本食谱中所做的那样。这是可行的,并且依赖于我们简要讨论过的 FindPackageHandleStandardArgs.cmake 模块。然而,编写一个完全全面的 find-module 远非易事;有许多难以发现的边缘情况,我们在寻找 Unix 和 Windows 平台上的 ZeroMQ 库文件时展示了这样一个例子。

这些关注点和困难对于所有软件开发者来说都非常熟悉,这一点在 CMake 邮件列表上的热烈讨论中得到了证明:cmake.org/pipermail/cmake/2018-May/067556.htmlpkg-config在 Unix 软件包开发者中被广泛接受,但它不容易移植到非 Unix 平台。CMake 配置文件功能强大,但并非所有软件开发者都熟悉 CMake 语法。Common Package Specification 项目是一个非常新的尝试,旨在统一pkg-config和 CMake 配置文件的软件包发现方法。您可以在项目网站上找到更多信息:mwoehlke.github.io/cps/

在第十章《编写安装程序》中,我们将讨论如何通过使用前述讨论中概述的第一条路径,即在项目旁边提供自己的 CMake 发现文件,使您自己的软件包对第三方应用程序可发现。

第五章:创建和运行测试

在本章中,我们将介绍以下内容:

  • 创建一个简单的单元测试

  • 使用 Catch2 库定义单元测试

  • 定义单元测试并链接到 Google Test

  • 定义单元测试并链接到 Boost 测试

  • 使用动态分析检测内存缺陷

  • 测试预期失败

  • 为长时间测试设置超时

  • 并行运行测试

  • 运行测试的子集

  • 使用测试夹具

引言

测试是代码开发工具箱的核心组成部分。通过使用单元和集成测试进行自动化测试,不仅可以帮助开发者在早期检测功能回归,还可以作为新加入项目的开发者的起点。它可以帮助新开发者提交代码变更,并确保预期的功能得以保留。对于代码的用户来说,自动化测试在验证安装是否保留了代码功能方面至关重要。从一开始就为单元、模块或库使用测试的一个好处是,它可以引导程序员编写更加模块化和不那么复杂的代码结构,采用纯粹的、函数式的风格,最小化并局部化全局变量和全局状态。

在本章中,我们将演示如何将测试集成到 CMake 构建结构中,使用流行的测试库和框架,并牢记以下目标:

  • 让用户、开发者和持续集成服务轻松运行测试套件。在使用 Unix Makefiles 时,应该简单到只需输入make test

  • 通过最小化总测试时间来高效运行测试,以最大化测试经常运行的概率——理想情况下,每次代码更改后都进行测试。

创建一个简单的单元测试

本示例的代码可在github.com/dev-cafe/cmake-cookbook/tree/v1.0/chapter-04/recipe-01找到,并包含一个 C++示例。该示例适用于 CMake 版本 3.5(及以上),并在 GNU/Linux、macOS 和 Windows 上进行了测试。

在本食谱中,我们将介绍使用 CTest 进行单元测试,CTest 是作为 CMake 一部分分发的测试工具。为了保持对 CMake/CTest 方面的关注并最小化认知负荷,我们希望尽可能简化要测试的代码。我们的计划是编写并测试能够求和整数的代码,仅此而已。就像在小学时,我们在学会加法后学习乘法和除法一样,此时,我们的示例代码只会加法,并且只会理解整数;它不需要处理浮点数。而且,就像年轻的卡尔·弗里德里希·高斯被他的老师测试从 1 到 100 求和所有自然数一样,我们将要求我们的代码做同样的事情——尽管没有使用高斯所用的聪明分组技巧。为了展示 CMake 对实现实际测试的语言没有任何限制,我们将不仅使用 C++可执行文件,还使用 Python 脚本和 shell 脚本来测试我们的代码。为了简单起见,我们将不使用任何测试库来完成这个任务,但我们将在本章后面的食谱中介绍 C++测试框架。

准备就绪

我们的代码示例包含三个文件。实现源文件sum_integers.cpp负责对整数向量进行求和,并返回总和:

#include "sum_integers.hpp"

#include <vector>

int sum_integers(const std::vector<int> integers) {
  auto sum = 0;
  for (auto i : integers) {
    sum += i;
  }
  return sum;
}

对于这个例子,无论这是否是最优雅的向量求和实现方式都无关紧要。接口被导出到我们的示例库中的sum_integers.hpp,如下所示:

#pragma once

#include <vector>

int sum_integers(const std::vector<int> integers);

最后,main.cpp中定义了主函数,它从argv[]收集命令行参数,将它们转换成一个整数向量,调用sum_integers函数,并将结果打印到输出:

#include "sum_integers.hpp"

#include <iostream>
#include <string>
#include <vector>

// we assume all arguments are integers and we sum them up
// for simplicity we do not verify the type of arguments
int main(int argc, char *argv[]) {

  std::vector<int> integers;
  for (auto i = 1; i < argc; i++) {
    integers.push_back(std::stoi(argv[i]));
  }
  auto sum = sum_integers(integers);

  std::cout << sum << std::endl;
}

我们的目标是使用 C++可执行文件(test.cpp)、Bash shell 脚本(test.sh)和 Python 脚本(test.py)来测试这段代码,以证明 CMake 并不真正关心我们偏好哪种编程或脚本语言,只要实现能够返回零或非零值,CMake 可以将其解释为成功或失败,分别。

在 C++示例(test.cpp)中,我们通过调用sum_integers验证 1 + 2 + 3 + 4 + 5 等于 15:

#include "sum_integers.hpp"

#include <vector>

int main() {
  auto integers = {1, 2, 3, 4, 5};

  if (sum_integers(integers) == 15) {
    return 0;
  } else {
    return 1;
  }
}

Bash shell 脚本测试示例调用可执行文件,该文件作为位置参数接收:

#!/usr/bin/env bash

EXECUTABLE=$1

OUTPUT=$($EXECUTABLE 1 2 3 4)

if [ "$OUTPUT" = "10" ]
then
    exit 0
else
    exit 1
fi

此外,Python 测试脚本直接调用可执行文件(使用--executable命令行参数传递),并允许它使用--short命令行参数执行:

import subprocess
import argparse

# test script expects the executable as argument
parser = argparse.ArgumentParser()
parser.add_argument('--executable',
                    help='full path to executable')
parser.add_argument('--short',
                    default=False,
                    action='store_true',
                    help='run a shorter test')
args = parser.parse_args()

def execute_cpp_code(integers):
    result = subprocess.check_output([args.executable] + integers)
    return int(result)

if args.short:
    # we collect [1, 2, ..., 100] as a list of strings
    result = execute_cpp_code([str(i) for i in range(1, 101)])
    assert result == 5050, 'summing up to 100 failed'
else:
    # we collect [1, 2, ..., 1000] as a list of strings
    result = execute_cpp_code([str(i) for i in range(1, 1001)])
    assert result == 500500, 'summing up to 1000 failed'

如何操作

现在我们将逐步描述如何为我们的项目设置测试,如下所示:

  1. 对于这个例子,我们需要 C++11 支持、一个可用的 Python 解释器以及 Bash shell:
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)

project(recipe-01 LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 11)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

find_package(PythonInterp REQUIRED)
find_program(BASH_EXECUTABLE NAMES bash REQUIRED)
  1. 然后我们定义了库、主可执行文件的依赖项以及测试可执行文件:
# example library
add_library(sum_integers sum_integers.cpp)

# main code
add_executable(sum_up main.cpp)
target_link_libraries(sum_up sum_integers)
# testing binary
add_executable(cpp_test test.cpp)
target_link_libraries(cpp_test sum_integers)
  1. 最后,我们开启测试功能并定义了四个测试。最后两个测试调用同一个 Python 脚本;首先是没有任何命令行参数,然后是使用--short
enable_testing()

add_test(
  NAME bash_test
  COMMAND ${BASH_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test.sh $<TARGET_FILE:sum_up>
  )

add_test(
  NAME cpp_test
  COMMAND $<TARGET_FILE:cpp_test>
  )

add_test(
  NAME python_test_long
  COMMAND ${PYTHON_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test.py --executable $<TARGET_FILE:sum_up>
  )

add_test(
  NAME python_test_short
  COMMAND ${PYTHON_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test.py --short --executable $<TARGET_FILE:sum_up>
  )
  1. 现在,我们准备好配置和构建代码了。首先,我们手动测试它:
$ mkdir -p build
$ cd build
$ cmake ..
$ cmake --build .
$ ./sum_up 1 2 3 4 5

15
  1. 然后,我们可以使用ctest运行测试集。
$ ctest

Test project /home/user/cmake-recipes/chapter-04/recipe-01/cxx-example/build
    Start 1: bash_test
1/4 Test #1: bash_test ........................ Passed 0.01 sec
    Start 2: cpp_test
2/4 Test #2: cpp_test ......................... Passed 0.00 sec
    Start 3: python_test_long
3/4 Test #3: python_test_long ................. Passed 0.06 sec
    Start 4: python_test_short
4/4 Test #4: python_test_short ................ Passed 0.05 sec

100% tests passed, 0 tests failed out of 4

Total Test time (real) = 0.12 sec
  1. 您还应该尝试破坏实现,以验证测试集是否捕获了更改。

它是如何工作的

这里的两个关键命令是enable_testing(),它为这个目录及其所有子文件夹(在本例中,整个项目,因为我们将其放在主CMakeLists.txt中)启用测试,以及add_test(),它定义一个新测试并设置测试名称和运行命令;例如:

add_test(
  NAME cpp_test
  COMMAND $<TARGET_FILE:cpp_test>
  )

在前面的示例中,我们使用了一个生成器表达式:$<TARGET_FILE:cpp_test>。生成器表达式是在构建系统生成时间评估的表达式。我们将在第五章,配置时间和构建时间操作,第 9 个配方,使用生成器表达式微调配置和编译中更详细地返回生成器表达式。目前,我们可以声明$<TARGET_FILE:cpp_test>将被替换为cpp_test可执行目标的完整路径。

生成器表达式在定义测试的上下文中非常方便,因为我们不必将可执行文件的位置和名称硬编码到测试定义中。以可移植的方式实现这一点将非常繁琐,因为可执行文件的位置和可执行文件后缀(例如,Windows 上的.exe后缀)可能在操作系统、构建类型和生成器之间有所不同。使用生成器表达式,我们不必明确知道位置和名称。

还可以向测试命令传递参数以运行;例如:

add_test(
  NAME python_test_short
  COMMAND ${PYTHON_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test.py --short --executable $<TARGET_FILE:sum_up>
  )

在本例中,我们按顺序运行测试(第 8 个配方,并行运行测试,将向您展示如何通过并行执行测试来缩短总测试时间),并且测试按定义的顺序执行(第 9 个配方,运行测试子集,将向您展示如何更改顺序或运行测试子集)。程序员负责定义实际的测试命令,该命令可以用操作系统环境支持的任何语言编程。CTest 唯一关心的是决定测试是否通过或失败的测试命令的返回代码。CTest 遵循标准约定,即零返回代码表示成功,非零返回代码表示失败。任何可以返回零或非零的脚本都可以用来实现测试用例。

既然我们知道如何定义和执行测试,了解如何诊断测试失败也很重要。为此,我们可以向代码中引入一个错误,并让所有测试失败:

    Start 1: bash_test
1/4 Test #1: bash_test ........................***Failed 0.01 sec
    Start 2: cpp_test
2/4 Test #2: cpp_test .........................***Failed 0.00 sec
    Start 3: python_test_long
3/4 Test #3: python_test_long .................***Failed 0.06 sec
    Start 4: python_test_short
4/4 Test #4: python_test_short ................***Failed 0.06 sec

0% tests passed, 4 tests failed out of 4

Total Test time (real) = 0.13 sec
The following tests FAILED:
    1 - bash_test (Failed)
    2 - cpp_test (Failed)
    3 - python_test_long (Failed)
    4 - python_test_short (Failed)
Errors while running CTest

如果我们希望了解更多信息,可以检查文件Testing/Temporary/LastTestsFailed.log。该文件包含测试命令的完整输出,是进行事后分析时的第一个查看地点。通过使用以下 CLI 开关,可以从 CTest 获得更详细的测试输出:

  • --output-on-failure:如果测试失败,将打印测试程序产生的任何内容到屏幕上。

  • -V:将启用测试的详细输出。

  • -VV:启用更详细的测试输出。

CTest 提供了一个非常方便的快捷方式,可以仅重新运行先前失败的测试;使用的 CLI 开关是--rerun-failed,这在调试过程中证明极其有用。

还有更多内容。

考虑以下定义:

add_test(
  NAME python_test_long
  COMMAND ${PYTHON_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test.py --executable $<TARGET_FILE:sum_up>
  )

前面的定义可以通过显式指定脚本将在其中运行的WORKING_DIRECTORY来重新表达,如下所示:

add_test(
  NAME python_test_long
  COMMAND ${PYTHON_EXECUTABLE} test.py --executable $<TARGET_FILE:sum_up>
  WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}
  )

我们还将提到,测试名称可以包含/字符,这在按名称组织相关测试时可能很有用;例如:

add_test(
  NAME python/long
  COMMAND ${PYTHON_EXECUTABLE} test.py --executable $<TARGET_FILE:sum_up>
  WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}
  )

有时,我们需要为测试脚本设置环境变量。这可以通过set_tests_properties实现。

set_tests_properties(python_test
  PROPERTIES 
    ENVIRONMENT
      ACCOUNT_MODULE_PATH=${CMAKE_CURRENT_SOURCE_DIR}
      ACCOUNT_HEADER_FILE=${CMAKE_CURRENT_SOURCE_DIR}/account/account.h
      ACCOUNT_LIBRARY_FILE=$<TARGET_FILE:account>
  )

这种方法可能并不总是跨不同平台都健壮,但 CMake 提供了一种绕过这种潜在健壮性不足的方法。以下代码片段等同于上述代码片段,并通过CMAKE_COMMAND预先添加环境变量,然后执行实际的 Python 测试脚本:

add_test(
  NAME
    python_test
  COMMAND
    ${CMAKE_COMMAND} -E env ACCOUNT_MODULE_PATH=${CMAKE_CURRENT_SOURCE_DIR}
                            ACCOUNT_HEADER_FILE=${CMAKE_CURRENT_SOURCE_DIR}/account/account.h
                            ACCOUNT_LIBRARY_FILE=$<TARGET_FILE:account>
    ${PYTHON_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/account/test.py
  )

再次注意,使用生成器表达式$<TARGET_FILE:account>来传递库文件的位置,而无需显式硬编码路径。

我们使用ctest命令执行了测试集,但 CMake 还将为生成器创建目标(对于 Unix Makefile 生成器使用make test,对于 Ninja 工具使用ninja test,或对于 Visual Studio 使用RUN_TESTS)。这意味着还有另一种(几乎)便携的方式来运行测试步骤:

$ cmake --build . --target test

不幸的是,在使用 Visual Studio 生成器时这会失败,我们必须使用RUN_TESTS代替:

$ cmake --build . --target RUN_TESTS

ctest命令提供了丰富的命令行参数。其中一些将在后面的食谱中探讨。要获取完整列表,请尝试ctest --help。命令cmake --help-manual ctest将输出完整的 CTest 手册到屏幕上。

使用 Catch2 库定义单元测试

本食谱的代码可在github.com/dev-cafe/cmake-cookbook/tree/v1.0/chapter-04/recipe-02获取,并包含一个 C++示例。该食谱适用于 CMake 版本 3.5(及更高版本),并在 GNU/Linux、macOS 和 Windows 上进行了测试。

在前一个配方中,我们在test.cpp中使用整数返回码来表示成功或失败。这对于简单的测试来说是可以的,但通常我们希望使用一个提供基础设施的测试框架,以便运行更复杂的测试,包括固定装置、与数值容差的比较,以及如果测试失败时更好的错误报告。一个现代且流行的测试库是 Catch2(github.com/catchorg/Catch2)。这个测试框架的一个很好的特点是它可以作为单个头文件库包含在你的项目中,这使得编译和更新框架特别容易。在本配方中,我们将使用 CMake 与 Catch2 结合,测试在前一个配方中介绍的求和代码。

准备就绪

我们将保持main.cppsum_integers.cppsum_integers.hpp与之前的配方不变,但将更新test.cpp

#include "sum_integers.hpp"

// this tells catch to provide a main()
// only do this in one cpp file
#define CATCH_CONFIG_MAIN
#include "catch.hpp"

#include <vector>

TEST_CASE("Sum of integers for a short vector", "[short]") {
  auto integers = {1, 2, 3, 4, 5};
  REQUIRE(sum_integers(integers) == 15);
}

TEST_CASE("Sum of integers for a longer vector", "[long]") {
  std::vector<int> integers;
  for (int i = 1; i < 1001; ++i) {
    integers.push_back(i);
  }
  REQUIRE(sum_integers(integers) == 500500);
}

我们还需要catch.hpp头文件,可以从github.com/catchorg/Catch2(我们使用了 2.0.1 版本)下载,并将其放置在项目根目录中,与test.cpp并列。

如何做

为了使用 Catch2 库,我们将修改前一个配方的CMakeLists.txt,执行以下步骤:

  1. 我们可以保持CMakeLists.txt的大部分内容不变:
# set minimum cmake version
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)

# project name and language
project(recipe-02 LANGUAGES CXX)

# require C++11
set(CMAKE_CXX_STANDARD 11)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

# example library
add_library(sum_integers sum_integers.cpp)

# main code
add_executable(sum_up main.cpp)
target_link_libraries(sum_up sum_integers)

# testing binary
add_executable(cpp_test test.cpp)
target_link_libraries(cpp_test sum_integers)
  1. 与前一个配方相比,唯一的改变是删除所有测试,只保留一个,并重命名它(以明确我们改变了什么)。请注意,我们向我们的单元测试可执行文件传递了--success选项。这是 Catch2 的一个选项,即使在成功时也会从测试中产生输出:
enable_testing()

add_test(
  NAME catch_test
  COMMAND $<TARGET_FILE:cpp_test> --success
  )
  1. 就这样!让我们配置、构建并测试。测试将使用 CTest 中的-VV选项运行,以从单元测试可执行文件获取输出:
$ mkdir -p build
$ cd build
$ cmake ..
$ cmake --build .
$ ctest -V

UpdateCTestConfiguration from :/home/user/cmake-cookbook/chapter-04/recipe-02/cxx-example/build/DartConfiguration.tcl
UpdateCTestConfiguration from :/home/user/cmake-cookbook/chapter-04/recipe-02/cxx-example/build/DartConfiguration.tcl
Test project /home/user/cmake-cookbook/chapter-04/recipe-02/cxx-example/build
Constructing a list of tests
Done constructing a list of tests
Updating test list for fixtures
Added 0 tests to meet fixture requirements
Checking test dependency graph...
Checking test dependency graph end
test 1
 Start 1: catch_test

1: Test command: /home/user/cmake-cookbook/chapter-04/recipe-02/cxx-example/build/cpp_test "--success"
1: Test timeout computed to be: 10000000
1: 
1: ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
1: cpp_test is a Catch v2.0.1 host application.
1: Run with -? for options
1: 
1: ----------------------------------------------------------------
1: Sum of integers for a short vector
1: ----------------------------------------------------------------
1: /home/user/cmake-cookbook/chapter-04/recipe-02/cxx-example/test.cpp:10
1: ...................................................................
1: 
1: /home/user/cmake-cookbook/chapter-04/recipe-02/cxx-example/test.cpp:12: 
1: PASSED:
1: REQUIRE( sum_integers(integers) == 15 )
1: with expansion:
1: 15 == 15
1: 
1: ----------------------------------------------------------------
1: Sum of integers for a longer vector
1: ----------------------------------------------------------------
1: /home/user/cmake-cookbook/chapter-04/recipe-02/cxx-example/test.cpp:15
1: ...................................................................
1: 
1: /home/user/cmake-cookbook/chapter-04/recipe-02/cxx-example/test.cpp:20: 
1: PASSED:
1: REQUIRE( sum_integers(integers) == 500500 )
1: with expansion:
1: 500500 (0x7a314) == 500500 (0x7a314)
1: 
1: ===================================================================
1: All tests passed (2 assertions in 2 test cases)
1:
1/1 Test #1: catch_test ....................... Passed 0.00 s

100% tests passed, 0 tests failed out of 1

Total Test time (real) = 0.00 sec
  1. 我们也可以直接尝试运行cpp_test二进制文件,并直接从 Catch2 看到输出:
$ ./cpp_test --success

~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
cpp_test is a Catch v2.0.1 host application.
Run with -? for options

-------------------------------------------------------------------
Sum of integers for a short vector
-------------------------------------------------------------------
/home/user/cmake-cookbook/chapter-04/recipe-02/cxx-example/test.cpp:10
...................................................................

/home/user/cmake-cookbook/chapter-04/recipe-02/cxx-example/test.cpp:12: 
PASSED:
  REQUIRE( sum_integers(integers) == 15 )
with expansion:
  15 == 15

-------------------------------------------------------------------
Sum of integers for a longer vector
-------------------------------------------------------------------
/home/user/cmake-cookbook/chapter-04/recipe-02/cxx-example/test.cpp:15
...................................................................

/home/user/cmake-cookbook/chapter-04/recipe-02/cxx-example/test.cpp:20: 
PASSED:
  REQUIRE( sum_integers(integers) == 500500 )
with expansion:
  500500 (0x7a314) == 500500 (0x7a314)

===================================================================
All tests passed (2 assertions in 2 test cases)
  1. Catch 将生成一个具有命令行界面的可执行文件。我们邀请你也尝试执行以下命令,以探索单元测试框架提供的选项:
$ ./cpp_test --help

它是如何工作的

由于 Catch2 是一个单头文件框架,因此不需要定义和构建额外的目标。我们只需要确保 CMake 能够找到catch.hpp来构建test.cpp。为了方便,我们将其放置在与test.cpp相同的目录中,但我们也可以选择不同的位置,并使用target_include_directories指示该位置。另一种方法是将头文件包装成一个INTERFACE库。这可以按照 Catch2 文档中的说明进行(https://github.com/catchorg/Catch2/blob/master/docs/build-systems.md#cmake):

# Prepare "Catch" library for other executables
set(CATCH_INCLUDE_DIR ${CMAKE_CURRENT_SOURCE_DIR}/catch)
add_library(Catch INTERFACE)
target_include_directories(Catch INTERFACE ${CATCH_INCLUDE_DIR})

那么我们将按照以下方式链接库:

target_link_libraries(cpp_test Catch)

我们从第一章,从简单可执行文件到库中的食谱 3,构建和链接静态和共享库的讨论中回忆起,INTERFACE库是 CMake 提供的伪目标,对于指定项目外部的目标使用要求非常有用。

还有更多

这是一个简单的例子,重点在于 CMake。当然,Catch2 提供了更多功能。要获取 Catch2 框架的完整文档,请访问github.com/catchorg/Catch2

另请参阅

Catch2 代码仓库包含一个由贡献的 CMake 函数,用于解析 Catch 测试并自动创建 CMake 测试,而无需显式键入add_test()函数;请参阅github.com/catchorg/Catch2/blob/master/contrib/ParseAndAddCatchTests.cmake

定义单元测试并链接 Google Test

本食谱的代码可在github.com/dev-cafe/cmake-cookbook/tree/v1.0/chapter-04/recipe-03找到,并包含一个 C++示例。本食谱适用于 CMake 版本 3.11(及更高版本),并在 GNU/Linux、macOS 和 Windows 上进行了测试。代码仓库还包含一个与 CMake 3.5 兼容的示例。

在本食谱中,我们将演示如何使用 CMake 和 Google Test 框架实现单元测试。与之前的食谱不同,Google Test 框架不仅仅是一个头文件;它是一个包含多个需要构建和链接的文件的库。我们可以将这些文件与我们的代码项目放在一起,但为了让代码项目更轻量级,我们将在配置时下载 Google Test 源代码的明确定义版本,然后构建框架并与之链接。我们将使用相对较新的FetchContent模块(自 CMake 版本 3.11 起可用)。我们将在第八章,超级构建模式中重新讨论FetchContent,在那里我们将讨论模块在幕后是如何工作的,以及我们还将说明如何使用ExternalProject_Add来模拟它。本食谱的灵感来自(并改编自)cmake.org/cmake/help/v3.11/module/FetchContent.html的示例。

准备工作

我们将保持main.cppsum_integers.cppsum_integers.hpp与之前的食谱不变,但将更新test.cpp源代码,如下所示:

#include "sum_integers.hpp"
#include "gtest/gtest.h"

#include <vector>

int main(int argc, char **argv) {
  ::testing::InitGoogleTest(&argc, argv);
  return RUN_ALL_TESTS();
}

TEST(example, sum_zero) {
  auto integers = {1, -1, 2, -2, 3, -3};
  auto result = sum_integers(integers);
  ASSERT_EQ(result, 0);
}

TEST(example, sum_five) {
  auto integers = {1, 2, 3, 4, 5};
  auto result = sum_integers(integers);
  ASSERT_EQ(result, 15);
}

如前述代码所示,我们选择不在我们的代码项目仓库中显式放置gtest.h或其他 Google Test 源文件,而是通过使用FetchContent模块在配置时下载它们。

如何操作

以下步骤描述了如何逐步设置CMakeLists.txt,以使用 GTest 编译可执行文件及其相应的测试:

  1. CMakeLists.txt的开头与前两个配方相比大部分未变,只是我们需要 CMake 3.11 以访问FetchContent模块:
# set minimum cmake version
cmake_minimum_required(VERSION 3.11 FATAL_ERROR)

# project name and language
project(recipe-03 LANGUAGES CXX)

# require C++11
set(CMAKE_CXX_STANDARD 11)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

set(CMAKE_WINDOWS_EXPORT_ALL_SYMBOLS ON)

# example library
add_library(sum_integers sum_integers.cpp)

# main code
add_executable(sum_up main.cpp)
target_link_libraries(sum_up sum_integers)
  1. 然后我们引入了一个 if 语句,检查ENABLE_UNIT_TESTS。默认情况下它是ON,但我们希望有可能将其关闭,以防我们没有网络下载 Google Test 源码:
option(ENABLE_UNIT_TESTS "Enable unit tests" ON)
message(STATUS "Enable testing: ${ENABLE_UNIT_TESTS}")

if(ENABLE_UNIT_TESTS)
  # all the remaining CMake code will be placed here
endif()
  1. 在 if 语句内部,我们首先包含FetchContent模块,声明一个新的要获取的内容,并查询其属性:
include(FetchContent)

FetchContent_Declare(
  googletest
  GIT_REPOSITORY https://github.com/google/googletest.git
  GIT_TAG release-1.8.0
)

FetchContent_GetProperties(googletest)
  1. 如果内容尚未填充(获取),我们获取并配置它。这将添加一些我们可以链接的目标。在本例中,我们对gtest_main感兴趣。该示例还包含一些使用 Visual Studio 编译的解决方法:
if(NOT googletest_POPULATED)
  FetchContent_Populate(googletest)

  # Prevent GoogleTest from overriding our compiler/linker options
  # when building with Visual Studio
  set(gtest_force_shared_crt ON CACHE BOOL "" FORCE)
  # Prevent GoogleTest from using PThreads
  set(gtest_disable_pthreads ON CACHE BOOL "" FORCE)

  # adds the targers: gtest, gtest_main, gmock, gmock_main
  add_subdirectory(
    ${googletest_SOURCE_DIR}
    ${googletest_BINARY_DIR}
    )

  # Silence std::tr1 warning on MSVC
  if(MSVC)
    foreach(_tgt gtest gtest_main gmock gmock_main)
      target_compile_definitions(${_tgt}
        PRIVATE
          "_SILENCE_TR1_NAMESPACE_DEPRECATION_WARNING"
        )
    endforeach()
  endif()
endif()
  1. 然后我们定义了cpp_test可执行目标,并使用target_sources命令指定其源文件,使用target_link_libraries命令指定其链接库:
add_executable(cpp_test "")

target_sources(cpp_test
  PRIVATE
    test.cpp
  )

target_link_libraries(cpp_test
  PRIVATE
    sum_integers
    gtest_main
  )
  1. 最后,我们使用熟悉的enable_testingadd_test命令来定义单元测试:
enable_testing()

add_test(
  NAME google_test
  COMMAND $<TARGET_FILE:cpp_test>
  )
  1. 现在,我们准备好配置、构建和测试项目了:
$ mkdir -p build
$ cd build
$ cmake ..
$ cmake --build .
$ ctest

Test project /home/user/cmake-cookbook/chapter-04/recipe-03/cxx-example/build
    Start 1: google_test
1/1 Test #1: google_test ...................... Passed 0.00 sec

100% tests passed, 0 tests failed out of 1

Total Test time (real) = 0.00 sec
  1. 我们也可以尝试直接运行cpp_test,如下所示:
$ ./cpp_test

[==========] Running 2 tests from 1 test case.
[----------] Global test environment set-up.
[----------] 2 tests from example
[ RUN      ] example.sum_zero
[       OK ] example.sum_zero (0 ms)
[ RUN      ] example.sum_five
[       OK ] example.sum_five (0 ms)
[----------] 2 tests from example (0 ms total)

[----------] Global test environment tear-down
[==========] 2 tests from 1 test case ran. (0 ms total)
[  PASSED  ] 2 tests.

它是如何工作的

FetchContent模块允许在配置时填充内容,通过任何ExternalProject模块支持的方法,并且已成为 CMake 3.11 版本的标准部分。而ExternalProject_Add()在构建时下载(如第八章,超级构建模式所示),FetchContent模块使内容立即可用,以便主项目和获取的外部项目(在本例中为 Google Test)可以在 CMake 首次调用时处理,并且可以使用add_subdirectory嵌套。

为了获取 Google Test 源码,我们首先声明了外部内容:

include(FetchContent)

FetchContent_Declare(
  googletest
  GIT_REPOSITORY https://github.com/google/googletest.git
  GIT_TAG release-1.8.0
)

在这种情况下,我们获取了一个带有特定标签(release-1.8.0)的 Git 仓库,但我们也可以从 Subversion、Mercurial 或 HTTP(S)源获取外部项目。有关可用选项,请参阅cmake.org/cmake/help/v3.11/module/ExternalProject.html上相应ExternalProject_Add命令的选项。

我们在调用FetchContent_Populate()之前使用FetchContent_GetProperties()命令检查内容填充是否已经处理;否则,如果FetchContent_Populate()被调用多次,它会抛出一个错误。

FetchContent_Populate(googletest)命令填充源码并定义googletest_SOURCE_DIRgoogletest_BINARY_DIR,我们可以使用它们来处理 Google Test 项目(使用add_subdirectory(),因为它恰好也是一个 CMake 项目):

add_subdirectory(
  ${googletest_SOURCE_DIR}
  ${googletest_BINARY_DIR}
  )

上述定义了以下目标:gtestgtest_maingmockgmock_main。在本示例中,我们只对gtest_main目标感兴趣,作为单元测试示例的库依赖项:

target_link_libraries(cpp_test
  PRIVATE
    sum_integers
    gtest_main
  )

在构建我们的代码时,我们可以看到它如何正确地触发了 Google Test 的配置和构建步骤。有一天,我们可能希望升级到更新的 Google Test 版本,我们可能需要更改的唯一一行是详细说明GIT_TAG的那一行。

还有更多

我们已经初步了解了FetchContent及其构建时的表亲ExternalProject_Add,我们将在第八章,超级构建模式中重新审视这些命令。对于可用选项的详细讨论,请参考cmake.org/cmake/help/v3.11/module/FetchContent.html

在本示例中,我们在配置时获取了源代码,但我们也可以在系统环境中安装它们,并使用FindGTest模块来检测库和头文件(cmake.org/cmake/help/v3.5/module/FindGTest.html)。从版本 3.9 开始,CMake 还提供了一个GoogleTest模块(cmake.org/cmake/help/v3.9/module/GoogleTest.html),该模块提供了一个gtest_add_tests函数。这个函数可以用来自动添加测试,通过扫描源代码中的 Google Test 宏。

另请参阅

显然,Google Test 有许多超出本示例范围的功能,如github.com/google/googletest所列。

定义单元测试并链接到 Boost 测试

本示例的代码可在github.com/dev-cafe/cmake-cookbook/tree/v1.0/chapter-04/recipe-04找到,并包含一个 C++示例。本示例适用于 CMake 版本 3.5(及以上),并在 GNU/Linux、macOS 和 Windows 上进行了测试。

Boost 测试是 C++社区中另一个非常流行的单元测试框架,在本示例中,我们将演示如何使用 Boost 测试对我们的熟悉求和示例代码进行单元测试。

准备工作

我们将保持main.cppsum_integers.cppsum_integers.hpp与之前的示例不变,但我们将更新test.cpp作为使用 Boost 测试库的单元测试的简单示例:

#include "sum_integers.hpp"

#include <vector>

#define BOOST_TEST_MODULE example_test_suite
#include <boost/test/unit_test.hpp>

BOOST_AUTO_TEST_CASE(add_example) {
  auto integers = {1, 2, 3, 4, 5};
  auto result = sum_integers(integers);
  BOOST_REQUIRE(result == 15);
}

如何操作

以下是使用 Boost 测试构建我们项目的步骤:

  1. 我们从熟悉的CMakeLists.txt结构开始:
# set minimum cmake version
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)

# project name and language
project(recipe-04 LANGUAGES CXX)

# require C++11
set(CMAKE_CXX_STANDARD 11)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

# example library
add_library(sum_integers sum_integers.cpp)

# main code
add_executable(sum_up main.cpp)
target_link_libraries(sum_up sum_integers)
  1. 我们检测 Boost 库并链接cpp_test
find_package(Boost 1.54 REQUIRED COMPONENTS unit_test_framework)

add_executable(cpp_test test.cpp)

target_link_libraries(cpp_test
  PRIVATE
    sum_integers
    Boost::unit_test_framework
  )

# avoid undefined reference to "main" in test.cpp
target_compile_definitions(cpp_test
  PRIVATE
    BOOST_TEST_DYN_LINK
  )
  1. 最后,我们定义单元测试:
enable_testing()

add_test(
  NAME boost_test
  COMMAND $<TARGET_FILE:cpp_test>
  )
  1. 以下是我们需要配置、构建和测试代码的所有内容:
$ mkdir -p build
$ cd build
$ cmake ..
$ cmake --build .
$ ctest

Test project /home/user/cmake-recipes/chapter-04/recipe-04/cxx-example/build
    Start 1: boost_test
1/1 Test #1: boost_test ....................... Passed 0.01 sec

100% tests passed, 0 tests failed out of 1

Total Test time (real) = 0.01 sec

$ ./cpp_test

Running 1 test case...

*** No errors detected

工作原理

我们使用了find_package来检测 Boost 的unit_test_framework组件(请参阅第三章,检测外部库和程序,第八部分,检测 Boost 库)。我们坚持认为这个组件是REQUIRED,如果无法在系统环境中找到,配置将停止。cpp_test目标需要知道在哪里找到 Boost 头文件,并需要链接到相应的库;这两者都由IMPORTED库目标Boost::unit_test_framework提供,该目标由成功的find_package调用设置。我们从第一章,从简单可执行文件到库中的第三部分,构建和链接静态和共享库的讨论中回忆起,IMPORTED库是 CMake 提供的伪目标,用于表示预先存在的依赖关系及其使用要求。

还有更多内容

在本节中,我们假设 Boost 已安装在系统上。或者,我们可以在编译时获取并构建 Boost 依赖项(请参阅第八章,超级构建模式,第二部分,使用超级构建管理依赖项:I. Boost 库)。然而,Boost 不是一个轻量级依赖项。在我们的示例代码中,我们仅使用了最基本的基础设施,但 Boost 提供了丰富的功能和选项,我们将引导感兴趣的读者访问www.boost.org/doc/libs/1_65_1/libs/test/doc/html/index.html

使用动态分析检测内存缺陷

本节的代码可在github.com/dev-cafe/cmake-cookbook/tree/v1.0/chapter-04/recipe-05找到,并提供了一个 C++示例。本节适用于 CMake 版本 3.5(及更高版本),并在 GNU/Linux、macOS 和 Windows 上进行了测试。

内存缺陷,例如越界写入或读取内存,或者内存泄漏(已分配但从未释放的内存),可能会产生难以追踪的讨厌错误,因此尽早检测它们是有用的。Valgrind(valgrind.org)是一个流行且多功能的工具,用于检测内存缺陷和内存泄漏,在本节中,我们将使用 Valgrind 来提醒我们使用 CMake/CTest 运行测试时的内存问题(请参阅第十四章,测试仪表板,以讨论相关的AddressSanitizerThreadSanitizer)。

准备就绪

对于本节,我们需要三个文件。第一个是我们希望测试的实现(我们可以将文件称为leaky_implementation.cpp):

#include "leaky_implementation.hpp"

int do_some_work() {

  // we allocate an array
  double *my_array = new double[1000];

  // do some work
  // ...

  // we forget to deallocate it
  // delete[] my_array;

  return 0;
}

我们还需要相应的头文件(leaky_implementation.hpp):

#pragma once

int do_some_work();

我们需要测试文件(test.cpp):

#include "leaky_implementation.hpp"

int main() {
  int return_code = do_some_work();

  return return_code;
}

我们期望测试通过,因为return_code被硬编码为0。然而,我们也希望检测到内存泄漏,因为我们忘记了释放my_array

如何操作

以下是如何设置CMakeLists.txt以执行代码的动态分析:

  1. 我们首先定义了最低 CMake 版本、项目名称、语言、目标和依赖项:
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)

project(recipe-05 LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 11)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

add_library(example_library leaky_implementation.cpp)


add_executable(cpp_test test.cpp)
target_link_libraries(cpp_test example_library)
  1. 然后,我们不仅定义了测试,还定义了MEMORYCHECK_COMMAND
find_program(MEMORYCHECK_COMMAND NAMES valgrind)
set(MEMORYCHECK_COMMAND_OPTIONS "--trace-children=yes --leak-check=full")

# add memcheck test action
include(CTest)

enable_testing()

add_test(
  NAME cpp_test
  COMMAND $<TARGET_FILE:cpp_test>
  )
  1. 运行测试集报告测试通过,如下所示:
$ ctest 
Test project /home/user/cmake-recipes/chapter-04/recipe-05/cxx-example/build
    Start 1: cpp_test
1/1 Test #1: cpp_test ......................... Passed 0.00 sec

100% tests passed, 0 tests failed out of 1

Total Test time (real) = 0.00 sec
  1. 现在,我们希望检查内存缺陷,并可以观察到内存泄漏被检测到:
$ ctest -T memcheck

   Site: myhost
   Build name: Linux-c++
Create new tag: 20171127-1717 - Experimental
Memory check project /home/user/cmake-recipes/chapter-04/recipe-05/cxx-example/build
    Start 1: cpp_test
1/1 MemCheck #1: cpp_test ......................... Passed 0.40 sec

100% tests passed, 0 tests failed out of 1

Total Test time (real) = 0.40 sec
-- Processing memory checking output:
1/1 MemCheck: #1: cpp_test ......................... Defects: 1
MemCheck log files can be found here: ( * corresponds to test number)
/home/user/cmake-recipes/chapter-04/recipe-05/cxx-example/build/Testing/Temporary/MemoryChecker.*.log
Memory checking results:
Memory Leak - 1
  1. 作为最后一步,你应该尝试修复内存泄漏,并验证ctest -T memcheck报告没有错误。

工作原理

我们使用find_program(MEMORYCHECK_COMMAND NAMES valgrind)来查找 Valgrind 并将其完整路径设置为MEMORYCHECK_COMMAND。我们还需要显式包含CTest模块以启用memcheck测试动作,我们可以通过使用ctest -T memcheck来使用它。此外,请注意我们能够使用set(MEMORYCHECK_COMMAND_OPTIONS "--trace-children=yes --leak-check=full")将选项传递给 Valgrind。内存检查步骤创建一个日志文件,可用于详细检查内存缺陷。

一些工具,如代码覆盖率和静态分析工具,可以类似地设置。然而,使用其中一些工具更为复杂,因为需要专门的构建和工具链。Sanitizers 就是一个例子。有关更多信息,请参阅github.com/arsenm/sanitizers-cmake。此外,请查看第十四章,测试仪表板,以讨论AddressSanitizerThreadSanitizer

还有更多

本食谱可用于向夜间测试仪表板报告内存缺陷,但我们在这里演示了此功能也可以独立于测试仪表板使用。我们将在第十四章,测试仪表板中重新讨论与 CDash 结合使用的情况。

另请参阅

有关 Valgrind 及其功能和选项的文档,请参阅valgrind.org

测试预期失败

本食谱的代码可在github.com/dev-cafe/cmake-cookbook/tree/v1.0/chapter-04/recipe-06找到。该食谱适用于 CMake 版本 3.5(及以上),并在 GNU/Linux、macOS 和 Windows 上进行了测试。

理想情况下,我们希望我们的所有测试在每个平台上都能始终通过。然而,我们可能想要测试在受控环境中是否会发生预期的失败或异常,在这种情况下,我们将预期的失败定义为成功的结果。我们相信,通常这应该是测试框架(如 Catch2 或 Google Test)的任务,它应该检查预期的失败并将成功报告给 CMake。但是,可能会有情况,你希望将测试的非零返回代码定义为成功;换句话说,你可能想要反转成功和失败的定义。在本节中,我们将展示这样的情况。

准备工作

本节的成分将是一个微小的 Python 脚本(test.py),它总是返回1,CMake 将其解释为失败:

import sys

# simulate a failing test
sys.exit(1)

如何操作

逐步地,这是如何编写CMakeLists.txt来完成我们的任务:

  1. 在本节中,我们不需要 CMake 提供任何语言支持,但我们需要找到一个可用的 Python 解释器:
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)

project(recipe-06 LANGUAGES NONE)

find_package(PythonInterp REQUIRED)
  1. 然后我们定义测试并告诉 CMake 我们期望它失败:
enable_testing()

add_test(example ${PYTHON_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test.py)

set_tests_properties(example PROPERTIES WILL_FAIL true)
  1. 最后,我们验证它被报告为成功的测试,如下所示:
$ mkdir -p build
$ cd build
$ cmake ..
$ cmake --build .
$ ctest

Test project /home/user/cmake-recipes/chapter-04/recipe-06/example/build
    Start 1: example
1/1 Test #1: example .......................... Passed 0.00 sec

100% tests passed, 0 tests failed out of 1

Total Test time (real) = 0.01 sec

它是如何工作的

使用set_tests_properties(example PROPERTIES WILL_FAIL true),我们将属性WILL_FAIL设置为true,这会反转成功/失败的状态。然而,这个功能不应该用来临时修复损坏的测试。

还有更多

如果你需要更多的灵活性,你可以结合使用测试属性PASS_REGULAR_EXPRESSIONFAIL_REGULAR_EXPRESSIONset_tests_properties。如果设置了这些属性,测试输出将被检查与作为参数给出的正则表达式列表进行匹配,如果至少有一个正则表达式匹配,则测试分别通过或失败。还有许多其他属性可以设置在测试上。可以在cmake.org/cmake/help/v3.5/manual/cmake-properties.7.html#properties-on-tests找到所有可用属性的完整列表。

为长时间测试设置超时

本节的代码可在github.com/dev-cafe/cmake-cookbook/tree/v1.0/chapter-04/recipe-07找到。本节适用于 CMake 版本 3.5(及以上),并在 GNU/Linux、macOS 和 Windows 上进行了测试。

理想情况下,测试集应该只需要很短的时间,以激励开发者频繁运行测试集,并使得对每次提交(变更集)进行测试成为可能(或更容易)。然而,有些测试可能会耗时较长或卡住(例如,由于高文件 I/O 负载),我们可能需要实施超时机制来终止超时的测试,以免它们堆积起来延迟整个测试和部署流水线。在本节中,我们将展示一种实施超时的方法,可以为每个测试单独调整。

准备工作

本食谱的成分将是一个微小的 Python 脚本(test.py),它总是返回0。为了保持超级简单并专注于 CMake 方面,测试脚本除了等待两秒钟之外不做任何事情;但是,我们可以想象在现实生活中,这个测试脚本会执行更有意义的工作:

import sys
import time

# wait for 2 seconds
time.sleep(2)

# report success
sys.exit(0)

如何操作

我们需要通知 CTest,如果测试超时,需要终止测试,如下所示:

  1. 我们定义项目名称,启用测试,并定义测试:
# set minimum cmake version
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)

# project name
project(recipe-07 LANGUAGES NONE)

# detect python
find_package(PythonInterp REQUIRED)

# define tests
enable_testing()

# we expect this test to run for 2 seconds
add_test(example ${PYTHON_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test.py)
  1. 此外,我们为测试指定了一个TIMEOUT,并将其设置为 10 秒:
set_tests_properties(example PROPERTIES TIMEOUT 10)
  1. 我们知道如何配置和构建,我们期望测试通过:
$ ctest 
Test project /home/user/cmake-recipes/chapter-04/recipe-07/example/build
    Start 1: example
1/1 Test #1: example .......................... Passed 2.01 sec

100% tests passed, 0 tests failed out of 1

Total Test time (real) = 2.01 sec
  1. 现在,为了验证TIMEOUT是否有效,我们将test.py中的睡眠命令增加到 11 秒,并重新运行测试:
$ ctest

Test project /home/user/cmake-recipes/chapter-04/recipe-07/example/build
    Start 1: example
1/1 Test #1: example ..........................***Timeout 10.01 sec

0% tests passed, 1 tests failed out of 1

Total Test time (real) = 10.01 sec

The following tests FAILED:
          1 - example (Timeout)
Errors while running CTest

工作原理

TIMEOUT是一个方便的属性,可用于通过使用set_tests_properties为单个测试指定超时。如果测试超过该时间,无论出于何种原因(测试停滞或机器太慢),测试都会被终止并标记为失败。

并行运行测试

本食谱的代码可在github.com/dev-cafe/cmake-cookbook/tree/v1.0/chapter-04/recipe-08找到。该食谱适用于 CMake 版本 3.5(及更高版本),并在 GNU/Linux、macOS 和 Windows 上进行了测试。

大多数现代计算机都有四个或更多的 CPU 核心。CTest 的一个很棒的功能是,如果你有多个核心可用,它可以并行运行测试。这可以显著减少总测试时间,减少总测试时间才是真正重要的,以激励开发者频繁测试。在这个食谱中,我们将演示这个功能,并讨论如何优化你的测试定义以获得最大性能。

准备就绪

让我们假设我们的测试集包含标记为a, b, ..., j的测试,每个测试都有特定的持续时间:

测试 持续时间(以时间单位计)
a, b, c, d 0.5
e, f, g 1.5
h 2.5
i 3.5
j 4.5

时间单位可以是分钟,但为了保持简单和短,我们将使用秒。为了简单起见,我们可以用一个 Python 脚本来表示消耗 0.5 时间单位的测试a

import sys
import time

# wait for 0.5 seconds
time.sleep(0.5)

# finally report success
sys.exit(0)

其他测试可以相应地表示。我们将把这些脚本放在CMakeLists.txt下面的一个目录中,目录名为test

如何操作

对于这个食谱,我们需要声明一个测试列表,如下所示:

  1. CMakeLists.txt非常简短:
# set minimum cmake version
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)

# project name
project(recipe-08 LANGUAGES NONE)

# detect python
find_package(PythonInterp REQUIRED)

# define tests
enable_testing()

add_test(a ${PYTHON_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/a.py)
add_test(b ${PYTHON_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/b.py)
add_test(c ${PYTHON_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/c.py)
add_test(d ${PYTHON_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/d.py)
add_test(e ${PYTHON_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/e.py)
add_test(f ${PYTHON_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/f.py)
add_test(g ${PYTHON_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/g.py)
add_test(h ${PYTHON_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/h.py)
add_test(i ${PYTHON_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/i.py)
add_test(j ${PYTHON_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/j.py)
  1. 我们可以使用ctest配置项目并运行测试,总共需要 17 秒:
$ mkdir -p build
$ cd build
$ cmake ..
$ ctest

      Start 1: a
 1/10 Test #1: a ................................ Passed 0.51 sec
      Start 2: b
 2/10 Test #2: b ................................ Passed 0.51 sec
      Start 3: c
 3/10 Test #3: c ................................ Passed 0.51 sec
      Start 4: d
 4/10 Test #4: d ................................ Passed 0.51 sec
      Start 5: e
 5/10 Test #5: e ................................ Passed 1.51 sec
      Start 6: f
 6/10 Test #6: f ................................ Passed 1.51 sec
      Start 7: g
 7/10 Test #7: g ................................ Passed 1.51 sec
      Start 8: h
 8/10 Test #8: h ................................ Passed 2.51 sec
      Start 9: i
 9/10 Test #9: i ................................ Passed 3.51 sec
      Start 10: j
10/10 Test #10: j ................................ Passed 4.51 sec

100% tests passed, 0 tests failed out of 10

Total Test time (real) = 17.11 sec
  1. 现在,如果我们碰巧有四个核心可用,我们可以在不到五秒的时间内将测试集运行在四个核心上:
$ ctest --parallel 4

      Start 10: j
      Start 9: i
      Start 8: h
      Start 5: e
 1/10 Test #5: e ................................ Passed 1.51 sec
      Start 7: g
 2/10 Test #8: h ................................ Passed 2.51 sec
      Start 6: f
 3/10 Test #7: g ................................ Passed 1.51 sec
      Start 3: c
 4/10 Test #9: i ................................ Passed 3.63 sec
 5/10 Test #3: c ................................ Passed 0.60 sec
      Start 2: b
      Start 4: d
 6/10 Test #6: f ................................ Passed 1.51 sec
 7/10 Test #4: d ................................ Passed 0.59 sec
 8/10 Test #2: b ................................ Passed 0.59 sec
      Start 1: a
 9/10 Test #10: j ................................ Passed 4.51 sec
10/10 Test #1: a ................................ Passed 0.51 sec

100% tests passed, 0 tests failed out of 10

Total Test time (real) = 4.74 sec

工作原理

我们可以看到,在并行情况下,测试j, i, he同时开始。并行运行时总测试时间的减少可能是显著的。查看ctest --parallel 4的输出,我们可以看到并行测试运行从最长的测试开始,并在最后运行最短的测试。从最长的测试开始是一个非常好的策略。这就像打包搬家箱子:我们从较大的物品开始,然后用较小的物品填充空隙。比较在四个核心上从最长测试开始的a-j测试的堆叠,看起来如下:

        --> time
core 1: jjjjjjjjj
core 2: iiiiiiibd
core 3: hhhhhggg
core 4: eeefffac

按照定义的顺序运行测试看起来如下:

        --> time
core 1: aeeeiiiiiii
core 2: bfffjjjjjjjjj
core 3: cggg
core 4: dhhhhh

按照定义的顺序运行测试总体上需要更多时间,因为它让两个核心大部分时间处于空闲状态(这里,核心 3 和 4)。CMake 是如何知道哪些测试需要最长的时间?CMake 知道每个测试的时间成本,因为我们首先按顺序运行了测试,这记录了每个测试的成本数据在文件Testing/Temporary/CTestCostData.txt中,看起来如下:

a 1 0.506776
b 1 0.507882
c 1 0.508175
d 1 0.504618
e 1 1.51006
f 1 1.50975
g 1 1.50648
h 1 2.51032
i 1 3.50475
j 1 4.51111

如果我们刚配置完项目就立即开始并行测试,它将按照定义的顺序运行测试,并且在四个核心上,总测试时间会明显更长。这对我们意味着什么?这是否意味着我们应该根据递减的时间成本来排序测试?这是一个选项,但事实证明还有另一种方法;我们可以自行指示每个测试的时间成本:

add_test(a ${PYTHON_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/a.py)
add_test(b ${PYTHON_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/b.py)
add_test(c ${PYTHON_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/c.py)
add_test(d ${PYTHON_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/d.py)
set_tests_properties(a b c d PROPERTIES COST 0.5)

add_test(e ${PYTHON_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/e.py)
add_test(f ${PYTHON_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/f.py)
add_test(g ${PYTHON_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/g.py)
set_tests_properties(e f g PROPERTIES COST 1.5)

add_test(h ${PYTHON_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/h.py)
set_tests_properties(h PROPERTIES COST 2.5)

add_test(i ${PYTHON_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/i.py)
set_tests_properties(i PROPERTIES COST 3.5)

add_test(j ${PYTHON_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/j.py)
set_tests_properties(j PROPERTIES COST 4.5)

COST参数可以是估计值或从Testing/Temporary/CTestCostData.txt提取。

还有更多内容。

除了使用ctest --parallel N,你还可以使用环境变量CTEST_PARALLEL_LEVEL,并将其设置为所需的级别。

运行测试子集

本示例的代码可在github.com/dev-cafe/cmake-cookbook/tree/v1.0/chapter-04/recipe-09找到。本示例适用于 CMake 版本 3.5(及以上),并在 GNU/Linux、macOS 和 Windows 上进行了测试。

在前面的示例中,我们学习了如何借助 CMake 并行运行测试,并讨论了从最长的测试开始的优势。虽然这种策略可以最小化总测试时间,但在特定功能的代码开发或调试过程中,我们可能不希望运行整个测试集。我们可能更倾向于从最长的测试开始,特别是在调试由短测试执行的功能时。对于调试和代码开发,我们需要能够仅运行选定的测试子集。在本示例中,我们将介绍实现这一目标的策略。

准备工作

在本例中,我们假设总共有六个测试;前三个测试较短,名称分别为feature-afeature-bfeature-c。我们还有三个较长的测试,名称分别为feature-dbenchmark-abenchmark-b。在本例中,我们可以使用 Python 脚本来表示这些测试,其中我们可以调整睡眠时间:

import sys
import time

# wait for 0.1 seconds
time.sleep(0.1)

# finally report success
sys.exit(0)

如何操作

以下是对我们的CMakeLists.txt内容的详细分解:

  1. 我们从一个相对紧凑的CMakeLists.txt开始,定义了六个测试:
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)

# project name
project(recipe-09 LANGUAGES NONE)

# detect python
find_package(PythonInterp REQUIRED)

# define tests
enable_testing()

add_test(
  NAME feature-a
  COMMAND ${PYTHON_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/feature-a.py
  )
add_test(
  NAME feature-b
  COMMAND ${PYTHON_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/feature-b.py
  )
add_test(
  NAME feature-c
  COMMAND ${PYTHON_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/feature-c.py
  )
add_test(
  NAME feature-d
  COMMAND ${PYTHON_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/feature-d.py
  )

add_test(
  NAME benchmark-a
  COMMAND ${PYTHON_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/benchmark-a.py
  )
add_test(
  NAME benchmark-b
  COMMAND ${PYTHON_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/benchmark-b.py
  )
  1. 此外,我们将较短的测试标记为"quick",将较长的测试标记为"long"
set_tests_properties(
  feature-a
  feature-b
  feature-c
  PROPERTIES
    LABELS "quick"
  )

set_tests_properties(
  feature-d
  benchmark-a
  benchmark-b
  PROPERTIES
    LABELS "long"
  )
  1. 我们现在准备运行测试集,如下所示:
$ mkdir -p build
$ cd build
$ cmake ..
$ ctest

    Start 1: feature-a
1/6 Test #1: feature-a ........................ Passed 0.11 sec
    Start 2: feature-b
2/6 Test #2: feature-b ........................ Passed 0.11 sec
    Start 3: feature-c
3/6 Test #3: feature-c ........................ Passed 0.11 sec
    Start 4: feature-d
4/6 Test #4: feature-d ........................ Passed 0.51 sec
    Start 5: benchmark-a
5/6 Test #5: benchmark-a ...................... Passed 0.51 sec
    Start 6: benchmark-b
6/6 Test #6: benchmark-b ...................... Passed 0.51 sec
100% tests passed, 0 tests failed out of 6

Label Time Summary:
long = 1.54 sec*proc (3 tests)
quick = 0.33 sec*proc (3 tests)

Total Test time (real) = 1.87 sec

工作原理

现在每个测试都有一个名称和一个标签。在 CMake 中,所有测试都有编号,因此它们也具有唯一编号。定义了测试标签后,我们现在可以运行整个集合,也可以根据测试的名称(使用正则表达式)、标签或编号来运行测试。

通过名称运行测试(这里,我们运行所有名称匹配feature的测试)如下所示:

$ ctest -R feature

    Start 1: feature-a
1/4 Test #1: feature-a ........................ Passed 0.11 sec
    Start 2: feature-b
2/4 Test #2: feature-b ........................ Passed 0.11 sec
    Start 3: feature-c
3/4 Test #3: feature-c ........................ Passed 0.11 sec
    Start 4: feature-d
4/4 Test #4: feature-d ........................ Passed 0.51 sec

100% tests passed, 0 tests failed out of 4

通过标签运行测试(这里,我们运行所有long测试)产生:

$ ctest -L long

    Start 4: feature-d
1/3 Test #4: feature-d ........................ Passed 0.51 sec
    Start 5: benchmark-a
2/3 Test #5: benchmark-a ...................... Passed 0.51 sec
    Start 6: benchmark-b
3/3 Test #6: benchmark-b ...................... Passed 0.51 sec

100% tests passed, 0 tests failed out of 3

通过编号运行测试(这里,我们运行第 2 到第 4 个测试)得到:

$ ctest -I 2,4

    Start 2: feature-b
1/3 Test #2: feature-b ........................ Passed 0.11 sec
    Start 3: feature-c
2/3 Test #3: feature-c ........................ Passed 0.11 sec
    Start 4: feature-d
3/3 Test #4: feature-d ........................ Passed 0.51 sec

100% tests passed, 0 tests failed out of 3

不仅如此

尝试使用**$ ctest --help**,您将看到大量可供选择的选项来定制您的测试。

使用测试夹具

本例的代码可在github.com/dev-cafe/cmake-cookbook/tree/v1.0/chapter-04/recipe-10找到。本例适用于 CMake 版本 3.5(及以上),并在 GNU/Linux、macOS 和 Windows 上进行了测试。

本例灵感来源于 Craig Scott 的工作,我们建议读者也参考相应的博客文章以获取更多背景信息,网址为crascit.com/2016/10/18/test-fixtures-with-cmake-ctest/。本例的动机是展示如何使用测试夹具。对于需要测试前设置动作和测试后清理动作的更复杂的测试来说,这些夹具非常有用(例如创建示例数据库、设置连接、断开连接、清理测试数据库等)。我们希望确保运行需要设置或清理动作的测试时,这些步骤能以可预测和稳健的方式自动触发,而不会引入代码重复。这些设置和清理步骤可以委托给测试框架,如 Google Test 或 Catch2,但在这里,我们展示了如何在 CMake 级别实现测试夹具。

准备就绪

我们将准备四个小型 Python 脚本,并将它们放置在test目录下:setup.pyfeature-a.pyfeature-b.pycleanup.py

如何操作

我们从熟悉的CMakeLists.txt结构开始,并添加了一些额外的步骤,如下所示:

  1. 我们准备好了熟悉的基础设施:
# set minimum cmake version
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)

# project name
project(recipe-10 LANGUAGES NONE)

# detect python
find_package(PythonInterp REQUIRED)

# define tests
enable_testing()
  1. 然后,我们定义了四个测试步骤并将它们与一个固定装置绑定:
add_test(
  NAME setup
  COMMAND ${PYTHON_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/setup.py
  )
set_tests_properties(
  setup
  PROPERTIES
    FIXTURES_SETUP my-fixture
  )

add_test(
  NAME feature-a
  COMMAND ${PYTHON_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/feature-a.py
  )
add_test(
  NAME feature-b
  COMMAND ${PYTHON_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/feature-b.py
  )
set_tests_properties(
  feature-a
  feature-b
  PROPERTIES
    FIXTURES_REQUIRED my-fixture
  )

add_test(
  NAME cleanup
  COMMAND ${PYTHON_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/cleanup.py
  )
set_tests_properties(
  cleanup
  PROPERTIES
    FIXTURES_CLEANUP my-fixture
  )
  1. 运行整个集合并不会带来任何惊喜,正如以下输出所示:
$ mkdir -p build
$ cd build
$ cmake ..
$ ctest

    Start 1: setup
1/4 Test #1: setup ............................ Passed 0.01 sec
    Start 2: feature-a
2/4 Test #2: feature-a ........................ Passed 0.01 sec
    Start 3: feature-b
3/4 Test #3: feature-b ........................ Passed 0.00 sec
    Start 4: cleanup
4/4 Test #4: cleanup .......................... Passed 0.01 sec

100% tests passed, 0 tests failed out of 4
  1. 然而,有趣的部分在于当我们尝试单独运行测试feature-a时。它正确地调用了setup步骤和cleanup步骤:
$ ctest -R feature-a

 Start 1: setup
1/3 Test #1: setup ............................ Passed 0.01 sec
 Start 2: feature-a
2/3 Test #2: feature-a ........................ Passed 0.00 sec
 Start 4: cleanup
3/3 Test #4: cleanup .......................... Passed 0.01 sec

100% tests passed, 0 tests failed out of 3

工作原理

在本例中,我们定义了一个文本固定装置并将其命名为my-fixture。我们为设置测试赋予了FIXTURES_SETUP属性,为清理测试赋予了FIXTURES_CLEANUP属性,并且使用FIXTURES_REQUIRED确保测试feature-afeature-b都需要设置和清理步骤才能运行。将这些绑定在一起,确保我们始终以明确定义的状态进入和退出步骤。

还有更多内容

如需了解更多背景信息以及使用此技术进行固定装置的出色动机,请参阅crascit.com/2016/10/18/test-fixtures-with-cmake-ctest/

第六章:配置时间和构建时间操作

在本章中,我们将涵盖以下食谱:

  • 使用平台无关的文件操作

  • 在配置时间运行自定义命令

  • 在构建时间运行自定义命令:I. 使用 add_custom_command

  • 在构建时间运行自定义命令:II. 使用 add_custom_target

  • 在构建时间对特定目标运行自定义命令

  • 探测编译和链接

  • 探测编译器标志

  • 探测执行

  • 使用生成器表达式微调配置和编译

引言

在本章中,我们将学习如何在配置时间和构建时间执行自定义操作。让我们简要回顾一下与由 CMake 管理的项目工作流程相关的时间概念:

  1. CMake 时间配置时间:这是当 CMake 正在运行并处理项目中的CMakeLists.txt文件时。

  2. 生成时间:这是当生成用于本地构建工具的文件,如 Makefiles 或 Visual Studio 项目文件时。

  3. 构建时间:这是当平台和工具本地的构建工具被调用时,在之前由 CMake 生成的平台和工具本地的构建脚本上。此时,编译器将被调用,目标(可执行文件和库)将在特定的构建目录中被构建。

  4. CTest 时间测试时间:当我们运行测试套件以检查目标是否按预期执行时。

  5. CDash 时间报告时间:当测试项目的结果上传到一个仪表板以与其他开发者共享时。

  6. 安装时间:当从构建目录到安装位置安装目标、源文件、可执行文件和库时。

  7. CPack 时间打包时间:当我们打包我们的项目以供分发,无论是作为源代码还是二进制。

  8. 包安装时间:当新制作的包被系统全局安装时。

完整的流程及其对应的时间在下图中描述:

本章关注于在配置时间和构建时间自定义行为。我们将学习如何使用这些命令:

  • execute_process 以从 CMake 内部执行任意进程并检索其输出

  • add_custom_target 以创建将执行自定义命令的目标

  • add_custom_command 以指定必须执行以生成文件或在其他目标的特定构建事件上的命令

使用平台无关的文件操作

本食谱的代码可在github.com/dev-cafe/cmake-cookbook/tree/v1.0/chapter-05/recipe-01 获取,并包含一个 C++示例。该食谱适用于 CMake 版本 3.5(及更高版本),并在 GNU/Linux、macOS 和 Windows 上进行了测试。

在构建某些项目时,我们可能需要与主机平台文件系统进行交互。与文件的交互可能只是检查文件是否存在,创建一个新文件来存储临时信息,创建或提取存档等等。使用 CMake,我们不仅能够在不同的平台上生成构建系统,还能够执行这些操作,而不需要复杂的逻辑来抽象不同的操作系统。本节将展示如何以可移植的方式提取先前下载的存档。

准备就绪

我们将展示如何提取包含 Eigen 库的存档,并使用提取的源文件来编译我们的项目。在本节中,我们将重用来自第三章,检测外部库和程序,第七部分,检测 Eigen 库的线性代数示例linear-algebra.cpp。本节还假设包含 Eigen 源代码的存档已下载在与项目本身相同的目录中。

如何做到这一点

项目需要解包 Eigen 存档,并相应地设置目标的包含目录:

  1. 让我们首先声明一个 C++11 项目:
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)

project(recipe-01 LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 11)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
  1. 我们向构建系统添加一个自定义目标。该自定义目标将在构建目录内提取存档:
add_custom_target(unpack-eigen
  ALL
  COMMAND
    ${CMAKE_COMMAND} -E tar xzf ${CMAKE_CURRENT_SOURCE_DIR}/eigen-eigen-5a0156e40feb.tar.gz
  COMMAND
    ${CMAKE_COMMAND} -E rename eigen-eigen-5a0156e40feb eigen-3.3.4
  WORKING_DIRECTORY
    ${CMAKE_CURRENT_BINARY_DIR}
  COMMENT
    "Unpacking Eigen3 in ${CMAKE_CURRENT_BINARY_DIR}/eigen-3.3.4"
  )
  1. 我们为源文件添加一个可执行目标:
add_executable(linear-algebra linear-algebra.cpp)
  1. 由于我们的源文件的编译依赖于 Eigen 头文件,我们需要明确指定可执行目标对自定义目标的依赖:
add_dependencies(linear-algebra unpack-eigen)
  1. 最后,我们可以指定我们需要编译源文件的包含目录:
target_include_directories(linear-algebra
  PRIVATE
    ${CMAKE_CURRENT_BINARY_DIR}/eigen-3.3.4
  )

它是如何工作的

让我们更仔细地看一下add_custom_target的调用:

add_custom_target(unpack-eigen
  ALL
  COMMAND
    ${CMAKE_COMMAND} -E tar xzf ${CMAKE_CURRENT_SOURCE_DIR}/eigen-eigen-5a0156e40feb.tar.gz
  COMMAND
    ${CMAKE_COMMAND} -E rename eigen-eigen-5a0156e40feb eigen-3.3.4
  WORKING_DIRECTORY
    ${CMAKE_CURRENT_BINARY_DIR}
  COMMENT
    "Unpacking Eigen3 in ${CMAKE_CURRENT_BINARY_DIR}/eigen-3.3.4"
  )

我们正在向构建系统引入一个名为unpack-eigen的目标。由于我们传递了ALL参数,该目标将始终被执行。COMMAND参数允许您指定要执行的命令。在本例中,我们希望提取存档并将提取的目录重命名为eigen-3.3.4。这是通过这两个命令实现的:

  1. ${CMAKE_COMMAND} -E tar xzf ${CMAKE_CURRENT_SOURCE_DIR}/eigen-eigen-5a0156e40feb.tar.gz

  2. ${CMAKE_COMMAND} -E rename eigen-eigen-5a0156e40feb eigen-3.3.4

注意我们是如何调用 CMake 命令本身,使用-E标志来执行实际的工作。对于许多常见操作,CMake 实现了一个在它运行的所有操作系统上都通用的接口。这使得构建系统的生成在很大程度上独立于特定的平台。add_custom_target命令中的下一个参数是工作目录,在我们的例子中对应于构建目录:CMAKE_CURRENT_BINARY_DIR。最后一个参数COMMENT用于指定在执行自定义目标时 CMake 应该打印出什么消息。

还有更多

add_custom_target 命令可用于在构建过程中执行一系列没有输出的自定义命令。正如我们在本食谱中所展示的,自定义目标可以被指定为项目中其他目标的依赖项。此外,自定义目标也可以依赖于其他目标,从而提供了在我们的构建中设置执行顺序的可能性。

使用 CMake 的 -E 标志,我们可以以操作系统无关的方式运行许多常见操作。在特定操作系统上可以运行的完整命令列表可以通过运行 cmake -Ecmake -E help 获得。例如,这是一个在 Linux 系统上的命令摘要:

Usage: cmake -E <command> [arguments...]
Available commands: 
  capabilities - Report capabilities built into cmake in JSON format
  chdir dir cmd [args...] - run command in a given directory
  compare_files file1 file2 - check if file1 is same as file2
  copy <file>... destination - copy files to destination (either file or directory)
  copy_directory <dir>... destination - copy content of <dir>... directories to 'destination' directory
  copy_if_different <file>... destination - copy files if it has changed
  echo [<string>...] - displays arguments as text
  echo_append [<string>...] - displays arguments as text but no new line
  env [--unset=NAME]... [NAME=VALUE]... COMMAND [ARG]...
                            - run command in a modified environment
  environment - display the current environment
  make_directory <dir>... - create parent and <dir> directories
  md5sum <file>... - create MD5 checksum of files
  remove [-f] <file>... - remove the file(s), use -f to force it
  remove_directory dir - remove a directory and its contents
  rename oldname newname - rename a file or directory (on one volume)
  server - start cmake in server mode
  sleep <number>... - sleep for given number of seconds
  tar [cxt][vf][zjJ] file.tar [file/dir1 file/dir2 ...]
                            - create or extract a tar or zip archive
  time command [args...] - run command and return elapsed time
  touch file - touch a file.
  touch_nocreate file - touch a file but do not create it.
Available on UNIX only:
  create_symlink old new - create a symbolic link new -> old

在配置时运行自定义命令

本食谱的代码可在 github.com/dev-cafe/cmake-cookbook/tree/v1.0/chapter-05/recipe-02 获取。该食谱适用于 CMake 版本 3.5(及以上),并在 GNU/Linux、macOS 和 Windows 上进行了测试。

运行 CMake 会生成构建系统,从而指定本地构建工具必须执行哪些命令来构建您的项目,以及以什么顺序执行。我们已经看到 CMake 在配置时运行许多子任务,以找出工作编译器和必要的依赖项。在本食谱中,我们将讨论如何在配置时通过使用 execute_process 命令来运行自定义命令。

如何做到这一点

在 第三章,检测外部库和程序,食谱 3,检测 Python 模块和包中,我们已经展示了在尝试查找 NumPy Python 模块时使用 execute_process 的情况。在这个例子中,我们将使用 execute_process 命令来检查特定的 Python 模块(在这种情况下,Python CFFI)是否存在,如果存在,我们将发现其版本:

  1. 对于这个简单的示例,我们将不需要任何语言支持:
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)

project(recipe-02 LANGUAGES NONE)
  1. 我们将要求 Python 解释器执行一个简短的 Python 代码片段,为此我们使用 find_package 来发现解释器:
find_package(PythonInterp REQUIRED)
  1. 然后我们调用 execute_process 来运行一个简短的 Python 代码片段;我们将在下一节中更详细地讨论这个命令:
# this is set as variable to prepare
# for abstraction using loops or functions
set(_module_name "cffi")

execute_process(
  COMMAND
    ${PYTHON_EXECUTABLE} "-c" "import ${_module_name}; print(${_module_name}.__version__)"
  OUTPUT_VARIABLE _stdout
  ERROR_VARIABLE _stderr
  OUTPUT_STRIP_TRAILING_WHITESPACE
  ERROR_STRIP_TRAILING_WHITESPACE
  )
  1. 然后,我们打印结果:
if(_stderr MATCHES "ModuleNotFoundError")
  message(STATUS "Module ${_module_name} not found")
else()
  message(STATUS "Found module ${_module_name} v${_stdout}")
endif()
  1. 一个示例配置产生以下结果(假设 Python CFFI 包已安装在相应的 Python 环境中):
$ mkdir -p build
$ cd build
$ cmake ..

-- Found PythonInterp: /home/user/cmake-cookbook/chapter-05/recipe-02/example/venv/bin/python (found version "3.6.5") 
-- Found module cffi v1.11.5

它是如何工作的

execute_process 命令会在当前执行的 CMake 进程中产生一个或多个子进程,从而提供了一种强大且方便的方式来在配置项目时运行任意命令。在一次 execute_process 调用中可以执行多个命令。然而,请注意,每个命令的输出将被管道传输到下一个命令。该命令接受多个参数:

  • WORKING_DIRECTORY 允许您指定在哪个目录中执行命令。

  • RESULT_VARIABLE将包含运行进程的结果。这要么是一个整数,表示成功执行,要么是一个包含错误条件的字符串。

  • OUTPUT_VARIABLEERROR_VARIABLE将包含执行命令的标准输出和标准错误。请记住,由于命令的输出被输入,只有最后一个命令的标准输出将被保存到OUTPUT_VARIABLE中。

  • INPUT_FILEOUTPUT_FILEERROR_FILE指定最后一个命令的标准输入和标准输出文件名,以及所有命令的标准错误文件名。

  • 通过设置OUTPUT_QUIETERROR_QUIET,CMake 将分别忽略标准输出和标准错误。

  • 通过设置OUTPUT_STRIP_TRAILING_WHITESPACEERROR_STRIP_TRAILING_WHITESPACE,可以分别去除标准输出和标准错误中运行命令的尾随空格。

通过这些解释,我们可以回到我们的示例:

set(_module_name "cffi")

execute_process(
  COMMAND
    ${PYTHON_EXECUTABLE} "-c" "import ${_module_name}; print(${_module_name}.__version__)"
  OUTPUT_VARIABLE _stdout
  ERROR_VARIABLE _stderr
  OUTPUT_STRIP_TRAILING_WHITESPACE
  ERROR_STRIP_TRAILING_WHITESPACE
  )

if(_stderr MATCHES "ModuleNotFoundError")
  message(STATUS "Module ${_module_name} not found")
else()
  message(STATUS "Found module ${_module_name} v${_stdout}")
endif()

该命令检查python -c "import cffi; print(cffi.__version__)"的输出。如果找不到模块,_stderr将包含ModuleNotFoundError,我们在 if 语句中对此进行检查,在这种情况下,我们会打印找不到 cffi 模块。如果导入成功,Python 代码将打印模块版本,该版本被输入到_stdout,以便我们可以打印以下内容:

message(STATUS "Found module ${_module_name} v${_stdout}")

还有更多内容

在本示例中,我们仅打印了结果,但在实际项目中,我们可以警告、中止配置或设置可以查询以切换某些配置选项的变量。

将代码示例扩展到多个 Python 模块,如 Cython,避免代码重复,这将是一个有趣的练习。一种选择可能是使用foreach循环遍历模块名称;另一种方法可能是将代码抽象为函数或宏。我们将在第七章,项目结构化中讨论此类抽象。

在第九章,混合语言项目中,我们将使用 Python CFFI 和 Cython,而本节内容可以作为一个有用且可复用的代码片段,用于检测这些包是否存在。

在构建时运行自定义命令:I. 使用add_custom_command

本节代码可在github.com/dev-cafe/cmake-cookbook/tree/v1.0/chapter-05/recipe-03找到,并包含一个 C++示例。本节内容适用于 CMake 版本 3.5(及以上),并在 GNU/Linux、macOS 和 Windows 上进行了测试。

项目构建目标可能依赖于只能在构建时执行的命令的结果,即在构建系统生成完成后。CMake 提供了三种选项来在构建时执行自定义命令:

  1. 使用add_custom_command生成要在目标内编译的输出文件。

  2. 使用 add_custom_target 执行没有输出的命令。

  3. 使用 add_custom_command 执行没有输出的命令,在目标构建之前或之后。

这三个选项强制特定的语义,并且不可互换。接下来的三个配方将阐明它们的使用案例。

准备就绪

我们将重用 第三章,检测外部库和程序,第 4 个配方,检测 BLAS 和 LAPACK 数学库 中的 C++ 示例,以说明 add_custom_command 第一种变体的使用。在该代码示例中,我们探测现有的 BLAS 和 LAPACK 库,并编译了一个微小的 C++ 包装器库,以调用我们需要的线性代数例程的 Fortran 实现。

我们将代码分成两部分。linear-algebra.cpp 的源文件与 第三章,检测外部库和程序,第 4 个配方,检测 BLAS 和 LAPACK 数学库 相比没有变化,并将包含线性代数包装器库的头文件并链接到编译库。然而,该库的源文件将被打包成一个与示例项目一起交付的压缩 tar 存档。该存档将在构建时提取,并在可执行文件之前编译线性代数包装器库。

如何做到这一点

我们的 CMakeLists.txt 将不得不包含一个自定义命令来提取线性代数包装器库的源文件。让我们详细看一下:

  1. 我们从熟悉的 CMake 版本、项目名称和支持的语言的定义开始:
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)

project(recipe-03 LANGUAGES CXX Fortran)
  1. 我们一如既往地选择 C++11 标准:
set(CMAKE_CXX_STANDARD 11)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
  1. 然后是时候在我们的系统上寻找 BLAS 和 LAPACK 库了:
find_package(BLAS REQUIRED)
find_package(LAPACK REQUIRED)
  1. 我们声明一个变量 wrap_BLAS_LAPACK_sources,用于保存 wrap_BLAS_LAPACK.tar.gz 存档中包含的源文件的名称:
set(wrap_BLAS_LAPACK_sources
  ${CMAKE_CURRENT_BINARY_DIR}/wrap_BLAS_LAPACK/CxxBLAS.hpp
  ${CMAKE_CURRENT_BINARY_DIR}/wrap_BLAS_LAPACK/CxxBLAS.cpp
  ${CMAKE_CURRENT_BINARY_DIR}/wrap_BLAS_LAPACK/CxxLAPACK.hpp
  ${CMAKE_CURRENT_BINARY_DIR}/wrap_BLAS_LAPACK/CxxLAPACK.cpp
  )
  1. 我们声明自定义命令以提取 wrap_BLAS_LAPACK.tar.gz 存档并更新提取文件的时间戳。请注意,wrap_BLAS_LAPACK_sources 变量的内容是自定义命令的预期输出:
add_custom_command(
  OUTPUT
    ${wrap_BLAS_LAPACK_sources}
  COMMAND
    ${CMAKE_COMMAND} -E tar xzf ${CMAKE_CURRENT_SOURCE_DIR}/wrap_BLAS_LAPACK.tar.gz
  COMMAND
    ${CMAKE_COMMAND} -E touch ${wrap_BLAS_LAPACK_sources}
  WORKING_DIRECTORY
    ${CMAKE_CURRENT_BINARY_DIR}
  DEPENDS
    ${CMAKE_CURRENT_SOURCE_DIR}/wrap_BLAS_LAPACK.tar.gz
  COMMENT
    "Unpacking C++ wrappers for BLAS/LAPACK"
  VERBATIM
  )
  1. 接下来,我们添加一个库目标,其源文件是新提取的文件:
add_library(math "")

target_sources(math
  PRIVATE
    ${CMAKE_CURRENT_BINARY_DIR}/wrap_BLAS_LAPACK/CxxBLAS.cpp
    ${CMAKE_CURRENT_BINARY_DIR}/wrap_BLAS_LAPACK/CxxLAPACK.cpp
  PUBLIC
    ${CMAKE_CURRENT_BINARY_DIR}/wrap_BLAS_LAPACK/CxxBLAS.hpp
    ${CMAKE_CURRENT_BINARY_DIR}/wrap_BLAS_LAPACK/CxxLAPACK.hpp
  )

target_include_directories(math
  INTERFACE
    ${CMAKE_CURRENT_BINARY_DIR}/wrap_BLAS_LAPACK
  )

target_link_libraries(math
  PUBLIC
    ${LAPACK_LIBRARIES}
  )
  1. 最后,添加了 linear-algebra 可执行目标。此可执行目标链接到包装器库:
add_executable(linear-algebra linear-algebra.cpp)

target_link_libraries(linear-algebra
  PRIVATE
    math
  )
  1. 有了这个,我们就可以配置、构建和执行示例:
$ mkdir -p build
$ cd build
$ cmake ..
$ cmake --build .
$ ./linear-algebra 1000

C_DSCAL done
C_DGESV done
info is 0
check is 4.35597e-10

它是如何工作的

让我们更仔细地看一下 add_custom_command 的调用:

add_custom_command(
  OUTPUT
    ${wrap_BLAS_LAPACK_sources}
  COMMAND
    ${CMAKE_COMMAND} -E tar xzf ${CMAKE_CURRENT_SOURCE_DIR}/wrap_BLAS_LAPACK.tar.gz
  COMMAND
    ${CMAKE_COMMAND} -E touch ${wrap_BLAS_LAPACK_sources}
  WORKING_DIRECTORY
    ${CMAKE_CURRENT_BINARY_DIR}
  DEPENDS
    ${CMAKE_CURRENT_SOURCE_DIR}/wrap_BLAS_LAPACK.tar.gz
  COMMENT
    "Unpacking C++ wrappers for BLAS/LAPACK"
  VERBATIM
  )

add_custom_command 向目标添加规则,以便它们知道如何通过执行命令来生成输出。任何目标add_custom_command 的同一目录中声明,即在同一个 CMakeLists.txt 中,并且使用输出中的 任何文件 作为其源文件,将在构建时被赋予生成这些文件的规则。目标和自定义命令之间的依赖关系在构建系统生成时自动处理,而源文件的实际生成发生在构建时。

在我们特定的情况下,输出是包含在压缩的 tar 存档中的源文件。为了检索和使用这些文件,必须在构建时解压缩存档。这是通过使用 CMake 命令本身与-E标志来实现的,以实现平台独立性。下一个命令更新提取文件的时间戳。我们这样做是为了确保我们不会处理陈旧的源文件。WORKING_DIRECTORY指定执行命令的位置。在我们的例子中,这是CMAKE_CURRENT_BINARY_DIR,即当前正在处理的构建目录。DEPENDS关键字后面的参数列出了自定义命令的依赖项。在我们的例子中,压缩的 tar 存档是一个依赖项。COMMENT字段将由 CMake 用于在构建时打印状态消息。最后,VERBATIM告诉 CMake 为特定的生成器和平台生成正确的命令,从而确保完全的平台独立性。

让我们也仔细看看创建带有包装器的库的方式:

add_library(math "")

target_sources(math
  PRIVATE
    ${CMAKE_CURRENT_BINARY_DIR}/wrap_BLAS_LAPACK/CxxBLAS.cpp
    ${CMAKE_CURRENT_BINARY_DIR}/wrap_BLAS_LAPACK/CxxLAPACK.cpp
  PUBLIC
    ${CMAKE_CURRENT_BINARY_DIR}/wrap_BLAS_LAPACK/CxxBLAS.hpp
    ${CMAKE_CURRENT_BINARY_DIR}/wrap_BLAS_LAPACK/CxxLAPACK.hpp
  )

target_include_directories(math
  INTERFACE
    ${CMAKE_CURRENT_BINARY_DIR}/wrap_BLAS_LAPACK
  )

target_link_libraries(math
  PUBLIC
    ${LAPACK_LIBRARIES}
  )

我们声明一个没有源文件的库目标。这是因为我们随后使用target_sources来填充目标的源文件。这实现了非常重要的任务,即让依赖于此目标的其他目标知道它们需要哪些包含目录和头文件,以便成功使用该库。C++源文件对于目标是PRIVATE,因此仅用于构建库。头文件是PUBLIC,因为目标及其依赖项都需要使用它们来成功编译。使用target_include_directories指定包含目录,并将wrap_BLAS_LAPACK声明为INTERFACE,因为只有math目标的依赖项才需要它。

add_custom_command的这种形式有两个限制:

  • 只有当所有依赖于其输出的目标都在同一个CMakeLists.txt中指定时,它才有效。

  • 对于不同的独立目标使用相同的输出,add_custom_command可能会重新执行自定义命令规则。这可能导致冲突,应予以避免。

第二个限制可以通过仔细使用add_dependencies引入依赖关系来避免,但为了规避这两个问题,正确的方法是使用add_custom_target命令,我们将在下一个示例中详细说明。

在构建时运行自定义命令:II. 使用 add_custom_target

本示例的代码可在github.com/dev-cafe/cmake-cookbook/tree/v1.0/chapter-05/recipe-04找到,并包含一个 C++示例。该示例适用于 CMake 版本 3.5(及更高版本),并在 GNU/Linux、macOS 和 Windows 上进行了测试。

正如我们在前一个配方中讨论的,add_custom_command有一些局限性,可以通过使用add_custom_target来规避。这个 CMake 命令将在构建系统中引入新的目标。这些目标反过来执行不返回输出的命令,与add_custom_command相反。命令add_custom_targetadd_custom_command可以结合使用。这样,自定义目标可以在与其依赖项不同的目录中指定,这在为项目设计模块化 CMake 基础设施时非常有用。

准备工作

对于这个配方,我们将重用前一个配方的源代码示例。然而,我们将稍微修改源文件的布局。特别是,我们不再将压缩的 tar 存档存储在顶层目录中,而是将其放置在一个名为deps的子目录中。这个子目录包含自己的CMakeLists.txt,它将被主CMakeLists.txt包含。

如何操作

我们将从主CMakeLists.txt开始,然后转到deps/CMakeLists.txt

  1. 与之前一样,我们声明一个 C++11 项目:
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)

project(recipe-04 LANGUAGES CXX Fortran)

set(CMAKE_CXX_STANDARD 11)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
  1. 此时,我们转到deps/CMakeLists.txt。这是通过add_subdirectory命令实现的:
add_subdirectory(deps)
  1. deps/CMakeLists.txt内部,我们首先定位必要的库(BLAS 和 LAPACK):
find_package(BLAS REQUIRED)
find_package(LAPACK REQUIRED)
  1. 然后,我们将 tarball 存档的内容收集到一个变量MATH_SRCS中:
set(MATH_SRCS
  ${CMAKE_CURRENT_BINARY_DIR}/wrap_BLAS_LAPACK/CxxBLAS.cpp
  ${CMAKE_CURRENT_BINARY_DIR}/wrap_BLAS_LAPACK/CxxLAPACK.cpp
  ${CMAKE_CURRENT_BINARY_DIR}/wrap_BLAS_LAPACK/CxxBLAS.hpp
  ${CMAKE_CURRENT_BINARY_DIR}/wrap_BLAS_LAPACK/CxxLAPACK.hpp
  )
  1. 列出要提取的源文件后,我们定义一个自定义目标和一个自定义命令。这种组合在${CMAKE_CURRENT_BINARY_DIR}中提取存档。然而,我们现在处于不同的作用域,并引用deps/CMakeLists.txt,因此 tarball 将被提取到主项目构建目录下的deps子目录中:
add_custom_target(BLAS_LAPACK_wrappers
  WORKING_DIRECTORY
    ${CMAKE_CURRENT_BINARY_DIR}
  DEPENDS
    ${MATH_SRCS}
  COMMENT
    "Intermediate BLAS_LAPACK_wrappers target"
  VERBATIM
  )

add_custom_command(
  OUTPUT
    ${MATH_SRCS}
  COMMAND
    ${CMAKE_COMMAND} -E tar xzf ${CMAKE_CURRENT_SOURCE_DIR}/wrap_BLAS_LAPACK.tar.gz
  WORKING_DIRECTORY
    ${CMAKE_CURRENT_BINARY_DIR}
  DEPENDS
    ${CMAKE_CURRENT_SOURCE_DIR}/wrap_BLAS_LAPACK.tar.gz
  COMMENT
    "Unpacking C++ wrappers for BLAS/LAPACK"
  )
  1. 然后,我们将math库作为目标添加,并指定相应的源文件、包含目录和链接库:
add_library(math "")

target_sources(math
  PRIVATE
    ${MATH_SRCS}
  )

target_include_directories(math
  INTERFACE
    ${CMAKE_CURRENT_BINARY_DIR}/wrap_BLAS_LAPACK
  )

# BLAS_LIBRARIES are included in LAPACK_LIBRARIES
target_link_libraries(math
  PUBLIC
    ${LAPACK_LIBRARIES} 
  )
  1. 一旦deps/CMakeLists.txt中的命令执行完毕,我们返回到父作用域,定义可执行目标,并将其与我们在下一目录定义的math库链接:
add_executable(linear-algebra linear-algebra.cpp)

target_link_libraries(linear-algebra
  PRIVATE
    math
  )

它是如何工作的

使用add_custom_target,用户可以在目标内部执行自定义命令。这与我们之前讨论的add_custom_command配方有所不同。通过add_custom_target添加的目标没有输出,因此总是被执行。因此,可以在子目录中引入自定义目标,并且仍然能够在顶层的CMakeLists.txt中引用它。

在本例中,我们通过结合使用add_custom_targetadd_custom_command提取了一个源文件归档。随后,这些源文件被用来编译一个库,我们设法在不同的(父)目录范围内将其链接起来。在构建CMakeLists.txt文件时,我们简要注释了 tarball 在deps下被提取,即项目构建目录的下一级子目录。这是因为,在 CMake 中,构建树的结构模仿了源树的层次结构。

在这个配方中,有一个值得注意的细节,我们应该讨论的是,我们将数学库源文件标记为PRIVATE的奇特事实:

set(MATH_SRCS
  ${CMAKE_CURRENT_BINARY_DIR}/wrap_BLAS_LAPACK/CxxBLAS.cpp
  ${CMAKE_CURRENT_BINARY_DIR}/wrap_BLAS_LAPACK/CxxLAPACK.cpp
  ${CMAKE_CURRENT_BINARY_DIR}/wrap_BLAS_LAPACK/CxxBLAS.hpp
  ${CMAKE_CURRENT_BINARY_DIR}/wrap_BLAS_LAPACK/CxxLAPACK.hpp
  )

# ...

add_library(math "")

target_sources(math
  PRIVATE
    ${MATH_SRCS}
  )

# ...

尽管这些源文件是PRIVATE,我们在父作用域中编译了linear-algebra.cpp,并且该源代码包含了CxxBLAS.hppCxxLAPACK.hpp。为什么在这里使用PRIVATE,以及如何可能编译linear-algebra.cpp并构建可执行文件?如果我们将头文件标记为PUBLIC,CMake 会在 CMake 时停止并报错,“找不到源文件”,因为尚未在文件树中生成(提取)的源文件不存在。

这是一个已知的限制(参见gitlab.kitware.com/cmake/cmake/issues/14633,以及相关博客文章:samthursfield.wordpress.com/2015/11/21/cmake-dependencies-between-targets-and-files-and-custom-commands)。我们通过将源文件声明为PRIVATE来规避这个限制。这样做,我们在 CMake 时没有得到任何对不存在源文件的文件依赖。然而,CMake 内置的 C/C++文件依赖扫描器在构建时识别了它们,并且源文件被编译和链接。

在构建时为特定目标运行自定义命令

本配方的代码可在github.com/dev-cafe/cmake-cookbook/tree/v1.0/chapter-05/recipe-05找到,并包含一个 Fortran 示例。该配方适用于 CMake 版本 3.5(及以上),并在 GNU/Linux、macOS 和 Windows(使用 MSYS Makefiles)上进行了测试。

本配方将展示如何使用add_custom_command的第二个签名来执行无输出的自定义操作。这对于在特定目标构建或链接之前或之后执行某些操作非常有用。由于自定义命令仅在目标本身需要构建时执行,我们实现了对它们执行的目标级控制。我们将通过一个示例来演示这一点,在该示例中,我们在目标构建之前打印其链接行,然后在编译后的可执行文件之后测量其静态大小分配。

准备工作

在本配方中,我们将使用以下示例 Fortran 代码(example.f90):

program example

  implicit none

  real(8) :: array(20000000)
  real(8) :: r
  integer :: i

  do i = 1, size(array)
    call random_number(r)
    array(i) = r
  end do

  print *, sum(array)

end program

这段代码是 Fortran 的事实对后续讨论影响不大,但我们选择 Fortran 是因为那里有很多遗留的 Fortran 代码,其中静态大小分配是一个问题。

在这段代码中,我们定义了一个包含 20,000,000 个双精度浮点的数组,我们期望这个数组占用 160MB 内存。我们在这里所做的并不是推荐的编程实践,因为在一般情况下,无论代码中是否使用,都会消耗内存。更好的方法是在需要时动态分配数组,并在使用后立即释放。

示例代码用随机数填充数组并计算它们的总和 - 这是为了确保数组确实被使用,编译器不会优化分配。我们将使用一个 Python 脚本(static-size.py)来测量示例二进制文件的静态分配大小,该脚本围绕 size 命令:

import subprocess
import sys

# for simplicity we do not check number of
# arguments and whether the file really exists
file_path = sys.argv[-1]

try:
    output = subprocess.check_output(['size', file_path]).decode('utf-8')
except FileNotFoundError:
    print('command "size" is not available on this platform')
    sys.exit(0)

size = 0.0
for line in output.split('\n'):
    if file_path in line:
        # we are interested in the 4th number on this line
        size = int(line.split()[3])

print('{0:.3f} MB'.format(size/1.0e6))

为了打印链接行,我们将使用第二个 Python 辅助脚本(echo-file.py)来打印文件内容:

import sys

# for simplicity we do not verify the number and
# type of arguments
file_path = sys.argv[-1]

try:
    with open(file_path, 'r') as f:
        print(f.read())
except FileNotFoundError:
    print('ERROR: file {0} not found'.format(file_path))

如何实现

让我们看一下我们的 CMakeLists.txt

  1. 我们首先声明一个 Fortran 项目:
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)

project(recipe-05 LANGUAGES Fortran)
  1. 这个例子依赖于 Python 解释器,以便我们可以以可移植的方式执行辅助脚本:
find_package(PythonInterp REQUIRED)
  1. 在这个例子中,我们默认使用 "Release" 构建类型,以便 CMake 添加优化标志,以便我们稍后有东西可以打印:
if(NOT CMAKE_BUILD_TYPE)
  set(CMAKE_BUILD_TYPE Release CACHE STRING "Build type" FORCE)
endif()
  1. 现在,我们定义可执行目标:
add_executable(example "")

target_sources(example
  PRIVATE
    example.f90
  )
  1. 然后,我们定义一个自定义命令,在链接 example 目标之前打印链接行:
add_custom_command(
  TARGET
    example
  PRE_LINK
  COMMAND
    ${PYTHON_EXECUTABLE}
      ${CMAKE_CURRENT_SOURCE_DIR}/echo-file.py
      ${CMAKE_CURRENT_BINARY_DIR}/CMakeFiles/example.dir/link.txt
  COMMENT
    "link line:"
  VERBATIM
  )
  1. 最后,我们定义一个自定义命令,在成功构建后打印可执行文件的静态大小:
add_custom_command(
  TARGET
    example
  POST_BUILD
  COMMAND
    ${PYTHON_EXECUTABLE}
      ${CMAKE_CURRENT_SOURCE_DIR}/static-size.py
      $<TARGET_FILE:example>
  COMMENT
    "static size of executable:"
  VERBATIM
  )
  1. 让我们来测试一下。观察打印出的链接行和可执行文件的静态大小:
$ mkdir -p build
$ cd build
$ cmake ..
$ cmake --build .

Scanning dependencies of target example
[ 50%] Building Fortran object CMakeFiles/example.dir/example.f90.o
[100%] Linking Fortran executable example
link line:
/usr/bin/f95 -O3 -DNDEBUG -O3 CMakeFiles/example.dir/example.f90.o -o example 

static size of executable:
160.003 MB
[100%] Built target example

工作原理

一旦声明了库或可执行目标,就可以通过使用 add_custom_command 将附加命令附加到目标上。正如我们所见,这些命令将在特定时间执行,与它们所附加的目标的执行上下文相关。CMake 理解以下选项,用于自定义命令的执行顺序:

  • PRE_BUILD:用于在执行与目标相关的任何其他规则之前执行的命令。但是,这只支持 Visual Studio 7 或更高版本。

  • PRE_LINK:使用此选项,命令将在目标编译后但在链接器或归档器调用之前执行。使用 PRE_BUILD 与 Visual Studio 7 或更高版本以外的生成器将被解释为 PRE_LINK

  • POST_BUILD:如前所述,命令将在执行给定目标的所有规则之后运行。

在这个例子中,我们向可执行目标添加了两个自定义命令。PRE_LINK 命令将屏幕上打印出 ${CMAKE_CURRENT_BINARY_DIR}/CMakeFiles/example.dir/link.txt 的内容。该文件包含链接命令,在我们的例子中,链接行结果如下:

link line:
/usr/bin/f95 -O3 -DNDEBUG -O3 CMakeFiles/example.dir/example.f90.o -o example

我们为此使用了一个 Python 包装器,以不依赖于可能不具有可移植性的 shell 命令。

在第二步中,POST_BUILD自定义命令调用了 Python 辅助脚本static-size.py,其参数为生成器表达式$<TARGET_FILE:example>。CMake 将在生成时间,即构建系统生成时,将生成器表达式扩展为目标文件路径。Python 脚本static-size.py反过来使用size命令来获取可执行文件的静态分配大小,将其转换为 MB,并打印结果。在我们的例子中,我们得到了预期的 160 MB:

static size of executable:
160.003 MB

探究编译和链接

本食谱的代码可在github.com/dev-cafe/cmake-cookbook/tree/v1.0/chapter-05/recipe-06找到,并提供了一个 C++示例。该食谱适用于 CMake 版本 3.9(及以上),并在 GNU/Linux、macOS 和 Windows 上进行了测试。代码仓库还包含了一个与 CMake 3.5 兼容的示例。

在构建系统生成过程中最常见的操作之一是评估我们试图在哪种系统上构建项目。这意味着尝试找出哪些功能有效,哪些无效,并相应地调整项目的编译,无论是通过发出依赖项未满足的信号,还是在我们的代码库中启用适当的变通方法。接下来的几个食谱将展示如何使用 CMake 执行这些操作。特别是,我们将考虑以下内容:

  1. 如何确保特定的代码片段能够成功编译成可执行文件。

  2. 如何确保编译器理解所需的标志。

  3. 如何确保特定的代码片段能够成功编译成运行的可执行文件

准备就绪

本食谱将展示如何使用相应的Check<LANG>SourceCompiles.cmake标准模块中的check_<lang>_source_compiles函数,以评估给定的编译器是否能够将预定义的代码片段编译成可执行文件。该命令可以帮助您确定:

  • 您的编译器支持所需的功能。

  • 链接器工作正常并理解特定的标志。

  • 使用find_package找到的包含目录和库是可用的。

在本食谱中,我们将展示如何检测 OpenMP 4.5 标准中的任务循环功能,以便在 C++可执行文件中使用。我们将使用一个示例 C++源文件来探测编译器是否支持这样的功能。CMake 提供了一个额外的命令try_compile来探测编译。本食谱将展示如何使用这两种方法。

您可以使用 CMake 命令行界面来获取特定模块(cmake --help-module <module-name>)和命令(cmake --help-command <command-name>)的文档。在我们的例子中,cmake --help-module CheckCXXSourceCompiles将输出check_cxx_source_compiles函数的文档到屏幕,而cmake --help-command try_compile将做同样的事情,为try_compile命令。

如何操作

我们将同时使用try_compilecheck_cxx_source_compiles,并比较这两个命令的工作方式:

  1. 我们首先创建一个 C++11 项目:
cmake_minimum_required(VERSION 3.9 FATAL_ERROR)

project(recipe-06 LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 11)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
  1. 我们找到编译器的 OpenMP 支持:
find_package(OpenMP)

if(OpenMP_FOUND)
  # ... <- the steps below will be placed here
else()
  message(STATUS "OpenMP not found: no test for taskloop is run")
endif()
  1. 如果找到了 OpenMP,我们继续前进并探测所需功能是否可用。为此,我们设置一个临时目录。这将由try_compile用于生成其中间文件。我们将这个放在前一步引入的 if 子句中:
set(_scratch_dir ${CMAKE_CURRENT_BINARY_DIR}/omp_try_compile)
  1. 我们调用try_compile来生成一个小项目,尝试编译源文件taskloop.cpp。成功或失败将被保存到omp_taskloop_test_1变量中。我们需要为这个小样本编译设置适当的编译器标志、包含目录和链接库。由于我们使用的是导入的目标 OpenMP::OpenMP_CXX,这只需通过设置LINK_LIBRARIES选项为try_compile来简单完成。如果编译成功,那么任务循环功能是可用的,我们向用户打印一条消息:
try_compile(
  omp_taskloop_test_1
  ${_scratch_dir}
  SOURCES
    ${CMAKE_CURRENT_SOURCE_DIR}/taskloop.cpp
  LINK_LIBRARIES
    OpenMP::OpenMP_CXX
  ) 
message(STATUS "Result of try_compile: ${omp_taskloop_test_1}")
  1. 为了使用check_cxx_source_compiles函数,我们需要包含CheckCXXSourceCompiles.cmake模块文件。这是随 CMake 一起分发的,与 C(CheckCSourceCompiles.cmake)和 Fortran(CheckFortranSourceCompiles.cmake)的类似文件一起:
include(CheckCXXSourceCompiles)
  1. 我们通过使用file(READ ...)命令读取其内容,将我们尝试编译和链接的源文件的内容复制到 CMake 变量中:
file(READ ${CMAKE_CURRENT_SOURCE_DIR}/taskloop.cpp _snippet)
  1. 我们设置CMAKE_REQUIRED_LIBRARIES。这是为了在下一步中正确调用编译器所必需的。注意使用了导入的 OpenMP::OpenMP_CXX目标,这将同时设置适当的编译器标志和包含目录:
set(CMAKE_REQUIRED_LIBRARIES OpenMP::OpenMP_CXX)
  1. 我们调用check_cxx_source_compiles函数并传入我们的代码片段。检查的结果将被保存到omp_taskloop_test_2变量中:
check_cxx_source_compiles("${_snippet}" omp_taskloop_test_2)
  1. 在调用check_cxx_source_compiles之前,我们取消设置之前定义的变量,并向用户打印一条消息:
unset(CMAKE_REQUIRED_LIBRARIES)
message(STATUS "Result of check_cxx_source_compiles: ${omp_taskloop_test_2}"
  1. 最后,我们测试这个配方:
$ mkdir -p build
$ cd build
$ cmake ..

-- ...
-- Found OpenMP_CXX: -fopenmp (found version "4.5") 
-- Found OpenMP: TRUE (found version "4.5") 
-- Result of try_compile: TRUE
-- Performing Test omp_taskloop_test_2
-- Performing Test omp_taskloop_test_2 - Success
-- Result of check_cxx_source_compiles: 1

工作原理

try_compilecheck_cxx_source_compiles都将编译并链接一个源文件到一个可执行文件。如果这些操作成功,那么输出变量,对于前者是omp_task_loop_test_1,对于后者是omp_task_loop_test_2,将被设置为TRUE。这两个命令完成任务的方式略有不同,然而。check_<lang>_source_compiles系列命令是try_compile命令的一个简化包装。因此,它提供了一个最小化的接口:

  1. 要编译的代码片段必须作为 CMake 变量传递。大多数情况下,这意味着必须使用 file(READ ...) 读取文件,正如我们在示例中所做的那样。然后,该片段将保存到构建目录的 CMakeFiles/CMakeTmp 子目录中的文件中。

  2. 通过在调用函数之前设置以下 CMake 变量来微调编译和链接:

    • CMAKE_REQUIRED_FLAGS 用于设置编译器标志

    • CMAKE_REQUIRED_DEFINITIONS 用于设置预处理器宏

    • CMAKE_REQUIRED_INCLUDES 用于设置包含目录列表

    • CMAKE_REQUIRED_LIBRARIES 用于设置链接到可执行文件的库列表

  3. 在调用 check_<lang>_compiles_function 后,必须手动取消设置这些变量,以确保同一变量的后续使用不会包含虚假内容。

在 CMake 3.9 中引入了 OpenMP 导入目标,但当前的方案也可以通过手动设置所需的标志和库,使其与早期版本的 CMake 兼容,方法如下:set(CMAKE_REQUIRED_FLAGS ${OpenMP_CXX_FLAGS})set(CMAKE_REQUIRED_LIBRARIES ${OpenMP_CXX_LIBRARIES})

对于 Fortran,CMake 假定样本片段采用固定格式,但这并不总是正确的。为了克服假阴性,需要为 check_fortran_source_compiles 设置 -ffree-form 编译器标志。这可以通过 set(CMAKE_REQUIRED_FLAGS "-ffree-form") 实现。

这种最小接口反映了测试编译是通过在 CMake 调用中直接生成和执行构建和链接命令来进行的。

try_compile 命令提供了更完整的接口和两种不同的操作模式:

  1. 第一种方式接受一个完整的 CMake 项目作为输入,并根据其 CMakeLists.txt 配置、构建和链接它。这种操作模式提供了更多的灵活性,因为要编译的项目可以任意复杂。

  2. 第二种方式,我们使用的方式,提供了一个源文件以及用于包含目录、链接库和编译器标志的配置选项。

try_compile 因此基于调用 CMake 的项目,要么是已经存在 CMakeLists.txt 的项目(在第一种操作模式下),要么是根据传递给 try_compile 的参数动态生成的项目。

还有更多

本方案中概述的检查类型并不总是万无一失的,可能会产生假阳性和假阴性。例如,你可以尝试注释掉包含 CMAKE_REQUIRED_LIBRARIES 的行,示例仍将报告“成功”。这是因为编译器将忽略 OpenMP 指令。

当你怀疑返回了错误的结果时,应该怎么办?CMakeOutput.logCMakeError.log文件位于构建目录的CMakeFiles子目录中,它们提供了出错线索。它们报告了 CMake 运行的操作的标准输出和标准错误。如果你怀疑有误报,应该检查前者,通过搜索设置为保存编译检查结果的变量。如果你怀疑有漏报,应该检查后者。

调试try_compile需要小心。CMake 会删除该命令生成的所有文件,即使检查不成功。幸运的是,--debug-trycompile将阻止 CMake 进行清理。如果你的代码中有多个try_compile调用,你将只能一次调试一个:

  1. 运行一次 CMake,不带--debug-trycompile。所有try_compile命令都将运行,并且它们的执行目录和文件将被清理。

  2. 从 CMake 缓存中删除保存检查结果的变量。缓存保存在CMakeCache.txt文件中。要清除变量的内容,可以使用-UCLI 开关,后跟变量的名称,该名称将被解释为全局表达式,因此可以使用*?

$ cmake -U <variable-name>
  1. 再次运行 CMake,使用--debug-trycompile选项。只有清除缓存的检查会被重新运行。这次执行目录和文件不会被清理。

try_compile提供了更多的灵活性和更清晰的接口,特别是当要编译的代码不是简短的代码片段时。我们建议在需要测试编译的代码是简短、自包含且不需要广泛配置的情况下,使用check_<lang>_source_compiles。在所有其他情况下,try_compile被认为是更优越的替代方案。

探测编译器标志

本节代码可在github.com/dev-cafe/cmake-cookbook/tree/v1.0/chapter-05/recipe-07获取,并包含一个 C++示例。本节适用于 CMake 版本 3.5(及以上),并在 GNU/Linux、macOS 和 Windows 上进行了测试。

设置编译器标志至关重要,以确保代码正确编译。不同的编译器供应商为相似的任务实现不同的标志。即使是同一供应商的不同编译器版本,也可能在可用的标志上略有差异。有时,会引入新的标志,这些标志对于调试或优化目的极为方便。在本节中,我们将展示如何检查所选编译器是否支持某些标志。

准备工作

消毒器(参考github.com/google/sanitizers)已经成为静态和动态代码分析的极其有用的工具。只需使用适当的标志重新编译代码并链接必要的库,您就可以调查和调试与内存错误(地址消毒器)、未初始化读取(内存消毒器)、线程安全(线程消毒器)和未定义行为(未定义行为消毒器)相关的问题。与类似的分析工具相比,消毒器通常引入的性能开销要小得多,并且往往提供更详细的问题检测信息。缺点是您的代码,可能还有部分工具链,需要使用额外的标志重新编译。

在本教程中,我们将设置一个项目以使用激活的不同消毒器编译代码,并展示如何检查正确的编译器标志是否可用。

如何操作

消毒器已经有一段时间与 Clang 编译器一起可用,并且后来也被引入到 GCC 工具集中。它们是为 C 和 C++程序设计的,但最近的 Fortran 版本将理解相同的标志并生成正确检测的库和可执行文件。然而,本教程将重点介绍一个 C++示例。

  1. 通常,我们首先声明一个 C++11 项目:
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)

project(recipe-07 LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 11)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
  1. 我们声明一个列表CXX_BASIC_FLAGS,包含构建项目时始终使用的编译器标志,-g3-O1
list(APPEND CXX_BASIC_FLAGS "-g3" "-O1")
  1. 我们包含 CMake 模块CheckCXXCompilerFlag.cmake。类似的模块也可用于 C(CheckCCompilerFlag.cmake)和 Fortran(CheckFortranCompilerFlag.cmake,自 CMake 3.3 起):
include(CheckCXXCompilerFlag)
  1. 我们声明一个ASAN_FLAGS变量,它包含激活地址消毒器所需的标志,并设置CMAKE_REQUIRED_FLAGS变量,该变量由check_cxx_compiler_flag函数内部使用:
set(ASAN_FLAGS "-fsanitize=address -fno-omit-frame-pointer")
set(CMAKE_REQUIRED_FLAGS ${ASAN_FLAGS})
  1. 我们调用check_cxx_compiler_flag以确保编译器理解ASAN_FLAGS变量中的标志。调用函数后,我们取消设置CMAKE_REQUIRED_FLAGS
check_cxx_compiler_flag(${ASAN_FLAGS} asan_works)
unset(CMAKE_REQUIRED_FLAGS)
  1. 如果编译器理解这些选项,我们将变量转换为列表,方法是替换空格为分号:
if(asan_works)
  string(REPLACE " " ";" _asan_flags ${ASAN_FLAGS})
  1. 我们为我们的代码示例添加一个带有地址消毒器的可执行目标:
  add_executable(asan-example asan-example.cpp)
  1. 我们将可执行文件的编译器标志设置为包含基本和地址消毒器标志:
  target_compile_options(asan-example
    PUBLIC
      ${CXX_BASIC_FLAGS}
      ${_asan_flags}
    )
  1. 最后,我们将地址消毒器标志也添加到链接器使用的标志集中。这关闭了if(asan_works)块:
  target_link_libraries(asan-example PUBLIC ${_asan_flags})
endif()

完整的教程源代码还展示了如何为线程、内存和未定义行为消毒器编译和链接示例可执行文件。这些在这里没有详细讨论,因为我们使用相同的模式来检查编译器标志。

一个用于在您的系统上查找消毒器支持的自定义 CMake 模块可在 GitHub 上获得:github.com/arsenm/sanitizers-cmake

它是如何工作的

check_<lang>_compiler_flag函数只是check_<lang>_source_compiles函数的包装器,我们在上一节中讨论过。这些包装器为常见用例提供了一个快捷方式,即不重要检查特定的代码片段是否编译,而是检查编译器是否理解一组标志。

对于 sanitizer 的编译器标志来说,它们还需要传递给链接器。为了使用check_<lang>_compiler_flag函数实现这一点,我们需要在调用之前设置CMAKE_REQUIRED_FLAGS变量。否则,作为第一个参数传递的标志只会在调用编译器时使用,导致错误的否定结果。

在本节中还有一个要点需要注意,那就是使用字符串变量和列表来设置编译器标志。如果在target_compile_optionstarget_link_libraries函数中使用字符串变量,将会导致编译器和/或链接器错误。CMake 会将这些选项用引号括起来,导致解析错误。这就解释了为什么需要以列表的形式表达这些选项,并进行后续的字符串操作,将字符串变量中的空格替换为分号。我们再次提醒,CMake 中的列表是分号分隔的字符串。

另请参阅

我们将在第七章,项目结构化,第三部分,编写测试和设置编译器标志的函数中重新审视并概括测试和设置编译器标志的模式。

探测执行

本节的代码可在github.com/dev-cafe/cmake-cookbook/tree/v1.0/chapter-05/recipe-08找到,并提供了一个 C/C++示例。本节适用于 CMake 版本 3.6(及以上),并在 GNU/Linux 和 macOS 上进行了测试。代码仓库还包含了一个与 CMake 3.5 兼容的示例。

到目前为止,我们已经展示了如何检查给定的源代码片段是否能被选定的编译器编译,以及如何确保所需的编译器和链接器标志可用。本节将展示如何检查代码片段是否可以在当前系统上编译、链接和运行。

准备工作

本节的代码示例是对第三章,检测外部库和程序,第九部分,检测外部库:I. 使用pkg-config的轻微变体。在那里,我们展示了如何在系统上找到 ZeroMQ 库并将其链接到 C 程序中。在本节中,我们将检查使用 GNU/Linux 系统 UUID 库的小型 C 程序是否可以实际运行,然后再生成实际的 C++程序。

如何操作

我们希望检查 GNU/Linux 上的 UUID 系统库是否可以链接,然后再开始构建我们自己的 C++项目。这可以通过以下一系列步骤实现:

  1. 我们首先声明一个混合 C 和 C++11 程序。这是必要的,因为我们要编译和运行的测试代码片段是用 C 语言编写的:
cmake_minimum_required(VERSION 3.6 FATAL_ERROR)

project(recipe-08 LANGUAGES CXX C)

set(CMAKE_CXX_STANDARD 11)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
  1. 我们需要在我们的系统上找到 UUID 库。这可以通过使用 pkg-config 来实现。我们要求搜索返回一个 CMake 导入目标,使用 IMPORTED_TARGET 参数:
find_package(PkgConfig REQUIRED QUIET)
pkg_search_module(UUID REQUIRED uuid IMPORTED_TARGET)
if(TARGET PkgConfig::UUID)
  message(STATUS "Found libuuid")
endif()
  1. 接下来,我们包含 CheckCSourceRuns.cmake 模块。对于 C++ 有一个类似的 CheckCXXSourceRuns.cmake 模块。然而,对于 Fortran 语言,在 CMake 3.11 中没有这样的模块:
include(CheckCSourceRuns)
  1. 我们声明一个包含要编译和运行的 C 代码片段的 _test_uuid 变量:
set(_test_uuid
  "
#include <uuid/uuid.h>

int main(int argc, char * argv[]) {
  uuid_t uuid;

  uuid_generate(uuid);

  return 0;
}
  ")
  1. 我们声明 CMAKE_REQUIRED_LIBRARIES 变量以微调对 check_c_source_runs 函数的调用。接下来,我们使用测试代码片段作为第一个参数和对 _runs 变量作为第二个参数调用 check_c_source_runs,以保存执行的检查结果。我们还取消设置 CMAKE_REQUIRED_LIBRARIES 变量:
set(CMAKE_REQUIRED_LIBRARIES PkgConfig::UUID)
check_c_source_runs("${_test_uuid}" _runs)
unset(CMAKE_REQUIRED_LIBRARIES)
  1. 如果检查未成功,可能是因为代码片段未编译或未运行,我们以致命错误停止配置:
if(NOT _runs)
  message(FATAL_ERROR "Cannot run a simple C executable using libuuid!")
endif()
  1. 否则,我们继续添加 C++ 可执行文件作为目标并链接到 UUID:
add_executable(use-uuid use-uuid.cpp)

target_link_libraries(use-uuid
  PUBLIC
    PkgConfig::UUID
  )

工作原理

check_<lang>_source_runs 函数对于 C 和 C++ 的操作原理与 check_<lang>_source_compiles 相同,但在实际运行生成的可执行文件时增加了额外步骤。与 check_<lang>_source_compiles 一样,check_<lang>_source_runs 的执行可以通过以下变量进行指导:

  • CMAKE_REQUIRED_FLAGS 用于设置编译器标志

  • CMAKE_REQUIRED_DEFINITIONS 用于设置预处理器宏

  • CMAKE_REQUIRED_INCLUDES 用于设置包含目录列表

  • CMAKE_REQUIRED_LIBRARIES 用于设置链接到可执行文件的库列表

由于我们使用了由 pkg_search_module 生成的导入目标,因此只需将 CMAKE_REQUIRES_LIBRARIES 设置为 PkgConfig::UUID,即可正确设置包含目录。

正如 check_<lang>_source_compilestry_compile 的包装器,check_<lang>_source_runs 是 CMake 中另一个更强大的命令 try_run 的包装器。因此,可以通过适当地包装 try_run 来编写一个提供与 C 和 C++ 模块相同功能的 CheckFortranSourceRuns.cmake 模块。

pkg_search_module 仅在 CMake 3.6 中学会了如何定义导入目标,但当前的配方也可以通过手动设置 check_c_source_runs 所需的包含目录和库来与早期版本的 CMake 一起工作,如下所示:set(CMAKE_REQUIRED_INCLUDES ${UUID_INCLUDE_DIRS})set(CMAKE_REQUIRED_LIBRARIES ${UUID_LIBRARIES})

使用生成器表达式微调配置和编译

本配方的代码可在github.com/dev-cafe/cmake-cookbook/tree/v1.0/chapter-05/recipe-09获取,并包含一个 C++示例。该配方适用于 CMake 版本 3.9(及更高版本),并在 GNU/Linux、macOS 和 Windows 上进行了测试。

CMake 提供了一种特定于领域的语言来描述如何配置和构建项目。自然地,描述特定条件的变量被引入,并且基于这些变量的条件语句被包含在CMakeLists.txt中。

在本配方中,我们将重新审视生成器表达式,我们在第四章,创建和运行测试中广泛使用它们,以紧凑地引用明确的测试可执行路径。生成器表达式提供了一个强大而紧凑的模式,用于逻辑和信息表达,这些表达在构建系统生成期间被评估,并产生特定于每个构建配置的信息。换句话说,生成器表达式对于引用仅在生成时已知的信息非常有用,但在配置时未知或难以知道;这在文件名、文件位置和库文件后缀的情况下尤其如此。

在本例中,我们将使用生成器表达式来有条件地设置预处理器定义,并有条件地链接消息传递接口(MPI)库,使我们能够构建相同的源代码,无论是顺序执行还是使用 MPI 并行性。

在本例中,我们将使用一个导入的目标来链接 MPI,该功能仅从 CMake 3.9 开始提供。然而,生成器表达式的方面可以转移到 CMake 3.0 或更高版本。

准备就绪

我们将编译以下示例源代码(example.cpp):

#include <iostream>

#ifdef HAVE_MPI
#include <mpi.h>
#endif

int main() {
#ifdef HAVE_MPI
  // initialize MPI
  MPI_Init(NULL, NULL);

  // query and print the rank
  int rank;
  MPI_Comm_rank(MPI_COMM_WORLD, &rank);
  std::cout << "hello from rank " << rank << std::endl;

  // initialize MPI
  MPI_Finalize();
#else
  std::cout << "hello from a sequential binary" << std::endl;
#endif /* HAVE_MPI */
}

代码包含预处理器语句(#ifdef HAVE_MPI ... #else ... #endif),以便我们可以使用相同的源代码编译顺序或并行可执行文件。

如何操作

在编写CMakeLists.txt文件时,我们将重用我们在第三章,检测外部库和程序,第 6 个配方,检测 MPI 并行环境中遇到的构建块:

  1. 我们声明一个 C++11 项目:
cmake_minimum_required(VERSION 3.9 FATAL_ERROR)

project(recipe-09 LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 11)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
  1. 然后,我们引入一个选项USE_MPI,以选择 MPI 并行化,并默认设置为ON。如果它是ON,我们使用find_package来定位 MPI 环境:
option(USE_MPI "Use MPI parallelization" ON)

if(USE_MPI)
  find_package(MPI REQUIRED)
endif()
  1. 然后,我们定义可执行目标,并根据条件设置相应的库依赖项(MPI::MPI_CXX)和预处理器定义(HAVE_MPI),我们将在稍后解释:
add_executable(example example.cpp)

target_link_libraries(example
  PUBLIC
    $<$<BOOL:${MPI_FOUND}>:MPI::MPI_CXX>
  )

target_compile_definitions(example
  PRIVATE
    $<$<BOOL:${MPI_FOUND}>:HAVE_MPI>
  )
  1. 如果找到 MPI,我们还打印由FindMPI.cmake导出的INTERFACE_LINK_LIBRARIES,以演示非常方便的cmake_print_properties()函数:
if(MPI_FOUND)
  include(CMakePrintHelpers)
  cmake_print_properties(
    TARGETS MPI::MPI_CXX
    PROPERTIES INTERFACE_LINK_LIBRARIES
    )
endif()
  1. 让我们首先使用默认的 MPI 并行化开关ON配置代码。观察cmake_print_properties()的输出:
$ mkdir -p build_mpi
$ cd build_mpi
$ cmake ..

-- ...
-- 
 Properties for TARGET MPI::MPI_CXX:
 MPI::MPI_CXX.INTERFACE_LINK_LIBRARIES = "-Wl,-rpath -Wl,/usr/lib/openmpi -Wl,--enable-new-dtags -pthread;/usr/lib/openmpi/libmpi_cxx.so;/usr/lib/openmpi/libmpi.so"
  1. 我们编译并运行并行示例:
$ cmake --build .
$ mpirun -np 2 ./example

hello from rank 0
hello from rank 1
  1. 现在,让我们向上移动一个目录,创建一个新的构建目录,这次构建顺序版本:
$ mkdir -p build_seq
$ cd build_seq
$ cmake -D USE_MPI=OFF ..
$ cmake --build .
$ ./example

hello from a sequential binary

工作原理

项目的构建系统由 CMake 在两个阶段生成:配置阶段,其中解析CMakeLists.txt,生成阶段,实际生成构建环境。生成器表达式在这个第二阶段评估,并可用于使用只能在生成时知道的信息调整构建系统。因此,生成器表达式在交叉编译时特别有用,其中一些信息只有在解析CMakeLists.txt后才可用,或者在多配置项目中,构建系统为项目的所有不同配置(如DebugRelease)一次性生成。

在我们的例子中,我们将使用生成器表达式来有条件地设置链接依赖和编译定义。为此,我们可以关注这两个表达式:

target_link_libraries(example
  PUBLIC
    $<$<BOOL:${MPI_FOUND}>:MPI::MPI_CXX>
  )

target_compile_definitions(example
  PRIVATE
    $<$<BOOL:${MPI_FOUND}>:HAVE_MPI>
  )

如果MPI_FOUND为真,那么$<BOOL:${MPI_FOUND}>将评估为 1。在这种情况下,$<$<BOOL:${MPI_FOUND}>:MPI::MPI_CXX>将评估为MPI::MPI_CXX,第二个生成器表达式将评估为HAVE_MPI。如果我们设置USE_MPIOFFMPI_FOUND为假,两个生成器表达式都将评估为空字符串,因此不会引入链接依赖,也不会设置预处理器定义。

我们可以通过引入 if 语句来实现相同的效果:

if(MPI_FOUND)
  target_link_libraries(example
    PUBLIC
      MPI::MPI_CXX
    )

  target_compile_definitions(example
    PRIVATE
      HAVE_MPI
    )
endif()

这个解决方案可能不那么紧凑,但可能更易读。我们经常可以使用生成器表达式重新表达 if 语句,选择通常是个人喜好的问题。然而,生成器表达式在需要访问或操作显式文件路径时特别有用,因为这些路径使用变量和 if 子句构造起来可能很困难,在这种情况下,我们明显倾向于使用生成器表达式以提高可读性。在第四章,创建和运行测试中,我们使用生成器表达式来解析特定目标的文件路径。在第十一章,打包项目中,我们也会欣赏生成器表达式。

还有更多

CMake 提供了三种类型的生成器表达式:

  • 逻辑表达式,基本模式为$<condition:outcome>。基本条件是0表示假,1表示真,但任何布尔值都可以用作条件,只要使用正确的关键字即可。

  • 信息表达式,基本模式为$<information>$<information:input>。这些表达式评估为某些构建系统信息,例如,包含目录,目标属性等。这些表达式的输入参数可能是目标的名称,如表达式$<TARGET_PROPERTY:tgt,prop>,其中获取的信息将是tgt目标的prop属性。

  • 输出表达式,基本模式为$<operation>$<operation:input>。这些表达式生成输出,可能基于某些输入参数。它们的输出可以直接在 CMake 命令中使用,也可以与其他生成器表达式结合使用。例如,-I$<JOIN:$<TARGET_PROPERTY:INCLUDE_DIRECTORIES>, -I>将生成一个包含正在处理的目标的包含目录的字符串,每个目录前都添加了-I

另请参阅

如需查看生成器表达式的完整列表,请查阅cmake.org/cmake/help/latest/manual/cmake-generator-expressions.7.html

第七章:生成源代码

在本章中,我们将介绍以下配方:

  • 在配置时生成源代码

  • 使用 Python 在配置时生成源代码

  • 使用 Python 在构建时生成源代码

  • 记录项目版本信息以确保可复现性

  • 从文件记录项目版本

  • 在配置时记录 Git 哈希

  • 在构建时记录 Git 哈希

引言

对于大多数项目,源代码是通过版本控制系统进行跟踪的;它通常作为构建系统的输入,构建系统将其转换为对象、库和可执行文件。在某些情况下,我们使用构建系统在配置或构建步骤中生成源代码。这可以用于根据在配置步骤中收集的信息来微调源代码,或者自动化原本容易出错的重复代码的机械生成。生成源代码的另一个常见用例是记录配置或编译信息以确保可复现性。在本章中,我们将展示使用 CMake 提供的强大工具生成源代码的各种策略。

在配置时生成源代码

本配方的代码可在github.com/dev-cafe/cmake-cookbook/tree/v1.0/chapter-06/recipe-01找到,包括一个 Fortran/C 示例。该配方适用于 CMake 版本 3.10(及以上),并在 GNU/Linux、macOS 和 Windows(使用 MSYS Makefiles)上进行了测试。

最直接的代码生成发生在配置时。例如,CMake 可以检测操作系统和支持的库;基于这些信息,我们可以定制构建哪些源代码,以向我们的库或程序的最终用户提供最佳性能。在本章和后续的一些配方中,我们将展示如何生成一个简单的源文件,该文件定义了一个函数来报告构建系统配置。

准备就绪

本配方的代码示例是 Fortran 和 C 语言的,为第九章,混合语言项目,其中将讨论混合语言编程。主程序是一个简单的 Fortran 可执行文件,它调用一个 C 函数print_info(),该函数将打印配置信息。值得注意的是,使用 Fortran 2003,编译器将处理名称重整(给定 C 函数的适当接口声明),正如我们在简单的example.f90源文件中看到的:

program hello_world

  implicit none

  interface
    subroutine print_info() bind(c, name="print_info")
    end subroutine
  end interface

  call print_info()

end program

print_info() C 函数在模板文件print_info.c.in中定义。以@开始和结束的变量将在配置时被替换为其实际值:

#include <stdio.h>
#include <unistd.h>

void print_info(void) {
  printf("\n");
  printf("Configuration and build information\n");
  printf("-----------------------------------\n");
  printf("\n");
  printf("Who compiled | %s\n", "@_user_name@");
  printf("Compilation hostname | %s\n", "@_host_name@");
  printf("Fully qualified domain name | %s\n", "@_fqdn@");
  printf("Operating system | %s\n",
         "@_os_name@, @_os_release@, @_os_version@");
  printf("Platform | %s\n", "@_os_platform@");
  printf("Processor info | %s\n",
         "@_processor_name@, @_processor_description@");
  printf("CMake version | %s\n", "@CMAKE_VERSION@");
  printf("CMake generator | %s\n", "@CMAKE_GENERATOR@");
  printf("Configuration time | %s\n", "@_configuration_time@");
  printf("Fortran compiler | %s\n", "@CMAKE_Fortran_COMPILER@");
  printf("C compiler | %s\n", "@CMAKE_C_COMPILER@");
  printf("\n");

  fflush(stdout);
}

如何操作

在我们的CMakeLists.txt中,我们首先必须收集配置选项,然后可以用它们的值替换print_info.c.in中相应的占位符;我们将 Fortran 和 C 源文件编译成一个可执行文件:

  1. 我们创建一个混合 Fortran-C 项目,如下所示:
cmake_minimum_required(VERSION 3.10 FATAL_ERROR)

project(recipe-01 LANGUAGES Fortran C)
  1. 我们通过使用execute_process获得配置项目的用户的用户名:
execute_process(
  COMMAND
    whoami
  TIMEOUT
    1
  OUTPUT_VARIABLE
    _user_name
  OUTPUT_STRIP_TRAILING_WHITESPACE
  )
  1. 使用cmake_host_system_information()函数(我们在第二章,检测环境,第 5 个配方,发现主机处理器指令集中已经遇到过),我们可以查询更多系统信息:
# host name information
cmake_host_system_information(RESULT _host_name QUERY HOSTNAME)
cmake_host_system_information(RESULT _fqdn QUERY FQDN)

# processor information
cmake_host_system_information(RESULT _processor_name QUERY PROCESSOR_NAME)
cmake_host_system_information(RESULT _processor_description QUERY PROCESSOR_DESCRIPTION)

# os information
cmake_host_system_information(RESULT _os_name QUERY OS_NAME)
cmake_host_system_information(RESULT _os_release QUERY OS_RELEASE)
cmake_host_system_information(RESULT _os_version QUERY OS_VERSION)
cmake_host_system_information(RESULT _os_platform QUERY OS_PLATFORM)
  1. 我们还通过使用字符串操作函数获得配置的时间戳:
string(TIMESTAMP _configuration_time "%Y-%m-%d %H:%M:%S [UTC]" UTC)
  1. 我们现在准备通过使用 CMake 自己的configure_file函数来配置模板文件print_info.c.in。请注意,我们只要求以@开始和结束的字符串被替换:
configure_file(print_info.c.in print_info.c @ONLY)
  1. 最后,我们添加一个可执行目标并定义目标源,如下所示:
add_executable(example "")

target_sources(example
  PRIVATE
    example.f90
    ${CMAKE_CURRENT_BINARY_DIR}/print_info.c
  )
  1. 以下是示例输出:
$ mkdir -p build
$ cd build
$ cmake ..
$ cmake --build .
$ ./example

Configuration and build information
-----------------------------------

Who compiled                | somebody
Compilation hostname        | laptop
Fully qualified domain name | laptop
Operating system            | Linux, 4.16.13-1-ARCH, #1 SMP PREEMPT Thu May 31 23:29:29 UTC 2018
Platform                    | x86_64
Processor info              | Unknown P6 family, 2 core Intel(R) Core(TM) i5-5200U CPU @ 2.20GHz
CMake version               | 3.11.3
CMake generator             | Unix Makefiles
Configuration time          | 2018-06-25 15:38:03 [UTC]
Fortran compiler            | /usr/bin/f95
C compiler                  | /usr/bin/cc

它是如何工作的

configure_file命令可以复制文件并将它们的內容替换为变量值。在我们的示例中,我们使用configure_file来修改我们的模板文件的内容,并将其复制到一个可以编译到我们的可执行文件的位置。让我们看看我们对configure_file的调用:

configure_file(print_info.c.in print_info.c @ONLY)

第一个参数是脚手架的名称:print_info.c.in。CMake 假设输入文件位于相对于项目根目录的位置;也就是说,在${CMAKE_CURRENT_SOURCE_DIR}/print_info.c.in中。第二个参数是我们选择的配置文件的名称,即print_info.c。输出文件假设位于相对于项目构建目录的位置;也就是说,在${CMAKE_CURRENT_BINARY_DIR}/print_info.c中。

当仅限制为两个参数,即输入和输出文件时,CMake 不仅会配置形如@VAR@的变量,还会配置形如${VAR}的变量。当${VAR}是语法的一部分且不应被修改时(例如在 shell 脚本中),这可能会造成不便。为了在这方面指导 CMake,应该将选项@ONLY传递给configure_file的调用,正如我们之前所展示的。

还有更多

请注意,将占位符替换为值时,期望 CMake 中的变量名与待配置文件中使用的变量名完全相同,并且位于@标记之间。在调用configure_file时定义的任何 CMake 变量都可以使用。这包括所有内置的 CMake 变量,例如CMAKE_VERSIONCMAKE_GENERATOR,在我们的示例中。此外,每当模板文件被修改时,重新构建代码将触发构建系统的重新生成。这样,配置的文件将始终保持最新。

完整的内部 CMake 变量列表可以通过使用cmake --help-variable-list从 CMake 手册中获得。

file(GENERATE ...)命令提供了一个有趣的替代configure_file的方法,因为它允许生成器表达式作为配置文件的一部分进行评估。然而,file(GENERATE ...)每次运行 CMake 时都会更新输出文件,这迫使所有依赖于该输出的目标重新构建。另请参见crascit.com/2017/04/18/generated-sources-in-cmake-builds/

使用 Python 在配置时生成源代码

本方法的代码可在github.com/dev-cafe/cmake-cookbook/tree/v1.0/chapter-06/recipe-02找到,包括一个 Fortran/C 示例。本方法适用于 CMake 版本 3.10(及以上),并在 GNU/Linux、macOS 和 Windows 上使用 MSYS Makefiles 进行了测试。

在本方法中,我们将回顾之前的示例,并再次从模板print_info.c.in生成print_info.c。然而,这一次,我们将假设 CMake 函数configure_file()尚未被发明,并将使用 Python 脚本来模拟它。本方法的目标是学习如何通过使用一个熟悉的示例在配置时生成源代码。当然,在实际项目中,我们可能会更倾向于使用configure_file(),但是当我们面临在配置时使用 Python 生成源代码的挑战时,我们将知道如何操作。

我们应该指出,这个方法有一个严重的局限性,无法完全模拟configure_file()。我们在这里介绍的方法无法生成自动依赖项,该依赖项会在构建时重新生成print_info.c。换句话说,如果在配置步骤后删除了生成的print_info.c,该文件将不会被重新生成,构建步骤将会失败。为了正确模仿configure_file()的行为,我们需要使用add_custom_command()add_custom_target(),我们将在接下来的第 3 个方法中使用,即“使用 Python 在构建时生成源代码”,在那里我们将克服这个限制。

在本方法中,我们将使用一个相对简单的 Python 脚本,下面我们将详细介绍。该脚本将读取print_info.c.in,并使用从 CMake 传递给 Python 脚本的参数替换文件中的占位符。对于更复杂的模板,我们推荐使用外部工具,如 Jinja(参见jinja.pocoo.org)。

准备工作

print_info.c.inexample.f90文件与前一个方法相比没有变化。此外,我们将使用一个 Python 脚本configurator.py,它提供了一个函数:

def configure_file(input_file, output_file, vars_dict):

    with input_file.open('r') as f:
        template = f.read()

    for var in vars_dict:
        template = template.replace('@' + var + '@', vars_dict[var])

    with output_file.open('w') as f:
        f.write(template)

该函数读取一个输入文件,遍历vars_dict字典的所有键,将模式@key@替换为其对应值,并将结果写入输出文件。键值对将由 CMake 提供。

如何操作

与上一个配方类似,我们需要配置一个模板文件,但这次,我们将用 Python 脚本来模拟configure_file()函数。我们基本上保持CMakeLists.txt不变,但我们用一组命令替换了configure_file(print_info.c.in print_info.c @ONLY),我们将逐步介绍这些命令:

  1. 首先,我们构造一个变量,_config_script,它将保存我们稍后要执行的 Python 脚本:
set(_config_script
"
from pathlib import Path
source_dir = Path('${CMAKE_CURRENT_SOURCE_DIR}')
binary_dir = Path('${CMAKE_CURRENT_BINARY_DIR}')
input_file = source_dir / 'print_info.c.in'
output_file = binary_dir / 'print_info.c'

import sys
sys.path.insert(0, str(source_dir))

from configurator import configure_file
vars_dict = {
    '_user_name':             '${_user_name}',
    '_host_name':             '${_host_name}',
    '_fqdn':                  '${_fqdn}',
    '_processor_name':        '${_processor_name}',
    '_processor_description': '${_processor_description}',
    '_os_name':               '${_os_name}',
    '_os_release':            '${_os_release}',
    '_os_version':            '${_os_version}',
    '_os_platform':           '${_os_platform}',
    '_configuration_time':    '${_configuration_time}',
    'CMAKE_VERSION':          '${CMAKE_VERSION}',
    'CMAKE_GENERATOR':        '${CMAKE_GENERATOR}',
    'CMAKE_Fortran_COMPILER': '${CMAKE_Fortran_COMPILER}',
    'CMAKE_C_COMPILER':       '${CMAKE_C_COMPILER}',
}
configure_file(input_file, output_file, vars_dict)
")
  1. 然后,我们使用find_package来确保 CMake 可以使用 Python 解释器:
find_package(PythonInterp QUIET REQUIRED)
  1. 如果找到了 Python 解释器,我们可以在 CMake 内部执行_config_script,以生成print_info.c文件:
execute_process(
  COMMAND
    ${PYTHON_EXECUTABLE} "-c" ${_config_script}
  )
  1. 之后,我们定义了可执行目标和依赖项,但这与上一个配方中的相同。同样,得到的输出也没有变化。

工作原理

让我们通过倒叙的方式来审视我们对CMakeLists.txt所做的更改。

我们执行了一个生成print_info.c的 Python 脚本。为了运行 Python 脚本,我们首先必须检测 Python 并构造 Python 脚本。Python 脚本导入了我们在configurator.py中定义的configure_file函数。它要求我们提供读写文件的位置,以及一个保存 CMake 变量及其值作为键值对的字典。

这个配方展示了一种生成配置报告的替代方法,该报告可以编译成可执行文件,甚至是一个库目标,通过将源的生成委托给外部脚本。我们在上一个配方中讨论的第一个方法更干净、更简单,但通过本配方中提出的方法,我们可以在原则上实现 Python(或其他语言)允许的任何配置时步骤。使用当前的方法,我们可以执行超出cmake_host_system_information()当前提供的功能的操作。

然而,我们需要记住这种方法的局限性,它无法生成自动依赖项,以便在构建时重新生成print_info.c。在下一个配方中,我们将克服这个限制。

还有更多

可以更简洁地表达这个配方。我们不必显式地构造vars_dict,这感觉有些重复,而是可以使用get_cmake_property(_vars VARIABLES)来获取此时定义的所有变量的列表,并可以遍历_vars的所有元素来访问它们的值:

get_cmake_property(_vars VARIABLES)
foreach(_var IN ITEMS ${_vars})
  message("variable ${_var} has the value ${${_var}}")
endforeach()

采用这种方法,可以隐式地构建vars_dict。然而,必须注意转义包含诸如""这类字符的值,因为 Python 会将其解释为终止指令。

使用 Python 在构建时生成源代码

本食谱的代码可在github.com/dev-cafe/cmake-cookbook/tree/v1.0/chapter-06/recipe-03找到,包括一个 C++示例。该食谱适用于 CMake 版本 3.5(及以上),并在 GNU/Linux、macOS 和 Windows 上进行了测试。

能够在构建时生成源代码是实用开发者工具箱中的一个强大功能,他们希望根据某些规则生成可能冗长且重复的代码,同时避免在源代码仓库中显式跟踪生成的代码。例如,我们可以想象根据检测到的平台或架构生成不同的源代码。或者,我们可以使用 Python 的简单性在构建时根据配置步骤中收集的输入生成明确且高效的 C++代码。其他相关的例子包括解析器生成器,如 Flex(github.com/westes/flex)和 Bison(www.gnu.org/software/bison/),元对象编译器,如 Qt moc(doc.qt.io/qt-5/moc.html),以及序列化框架,如 Google protobuf(developers.google.com/protocol-buffers/)。

准备工作

为了提供一个具体的例子,我们设想需要编写一段代码来验证一个数是否为质数。存在许多算法,例如,我们可以使用埃拉托色尼筛法来区分质数和非质数。如果我们需要验证很多数,我们不希望为每一个数都运行埃拉托色尼筛法算法。相反,我们希望一次性列出所有质数,直到某个上限,并使用查表法来验证大量数字。

在这个例子中,我们将使用 Python 在编译时生成查找表(一个质数向量)的 C++代码。当然,为了解决这个特定的编程问题,我们也可以使用 C++在运行时生成查找表。

让我们从一个名为generate.py的 Python 脚本开始。这个脚本接受两个命令行参数——一个将限制搜索的整数和一个输出文件名:

"""
Generates C++ vector of prime numbers up to max_number
using sieve of Eratosthenes.
"""
import pathlib
import sys

# for simplicity we do not verify argument list
max_number = int(sys.argv[-2])
output_file_name = pathlib.Path(sys.argv[-1])

numbers = range(2, max_number + 1)
is_prime = {number: True for number in numbers}

for number in numbers:
    current_position = number
    if is_prime[current_position]:
        while current_position <= max_number:
            current_position += number
            is_prime[current_position] = False

primes = (number for number in numbers if is_prime[number])
code = """#pragma once

#include <vector>

const std::size_t max_number = {max_number};

std::vector<int> & primes() {{
  static std::vector<int> primes;

{push_back}

  return primes;
}}
"""
push_back = '\n'.join(['  primes.push_back({:d});'.format(x) for x in primes])
output_file_name.write_text(
    code.format(max_number=max_number, push_back=push_back))

我们的目标是生成一个头文件primes.hpp,在编译时生成,并在以下示例代码中包含它:

#include "primes.hpp"

#include <iostream>
#include <vector>

int main() {
  std::cout << "all prime numbers up to " << max_number << ":";

  for (auto prime : primes())
    std::cout << " " << prime;

  std::cout << std::endl;

  return 0;
}

如何实现

以下是对CMakeLists.txt中命令的分解:

  1. 首先,我们需要定义项目并检测 Python 解释器,如下所示:
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)

project(recipe-03 LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 11)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

find_package(PythonInterp QUIET REQUIRED)
  1. 我们决定将待生成的代码放在${CMAKE_CURRENT_BINARY_DIR}/generated下,我们需要指示 CMake 创建这个目录:
file(MAKE_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}/generated)
  1. 这个 Python 脚本期望得到一个质数的上限,通过以下命令,我们可以设置一个默认值:
set(MAX_NUMBER "100" CACHE STRING "Upper bound for primes")
  1. 接下来,我们定义一个自定义命令来生成头文件:
add_custom_command(
  OUTPUT
    ${CMAKE_CURRENT_BINARY_DIR}/generated/primes.hpp
  COMMAND
    ${PYTHON_EXECUTABLE} generate.py ${MAX_NUMBER} ${CMAKE_CURRENT_BINARY_DIR}/generated/primes.hpp
  WORKING_DIRECTORY
    ${CMAKE_CURRENT_SOURCE_DIR}
  DEPENDS
    generate.py
  )
  1. 最后,我们定义了可执行文件及其目标,包括目录和依赖项:
add_executable(example "")

target_sources(example
  PRIVATE
    example.cpp
    ${CMAKE_CURRENT_BINARY_DIR}/generated/primes.hpp
  )

target_include_directories(example
  PRIVATE
    ${CMAKE_CURRENT_BINARY_DIR}/generated
  )
  1. 我们现在准备测试实现,如下所示:
$ mkdir -p build
$ cd build
$ cmake ..
$ cmake --build .
$ ./example

all prime numbers up to 100: 2 3 5 7 11 13 17 19 23 29 31 37 41 43 47 53 59 61 67 71 73 79 83 89 97

它是如何工作的

为了生成头文件,我们定义了一个自定义命令,该命令执行generate.py脚本,并接受${MAX_NUMBER}和文件路径(${CMAKE_CURRENT_BINARY_DIR}/generated/primes.hpp)作为参数:

add_custom_command(
  OUTPUT
    ${CMAKE_CURRENT_BINARY_DIR}/generated/primes.hpp
  COMMAND
    ${PYTHON_EXECUTABLE} generate.py ${MAX_NUMBER} ${CMAKE_CURRENT_BINARY_DIR}/generated/primes.hpp
  WORKING_DIRECTORY
    ${CMAKE_CURRENT_SOURCE_DIR}
  DEPENDS
    generate.py
  )

为了触发源代码生成,我们需要在可执行文件的定义中将其添加为源代码依赖项,这一任务可以通过target_sources轻松实现:

target_sources(example
  PRIVATE
    example.cpp
    ${CMAKE_CURRENT_BINARY_DIR}/generated/primes.hpp
  )

在前述代码中,我们不必定义一个新的自定义目标。头文件将作为example的依赖项生成,并且每当generate.py脚本更改时都会重新构建。如果代码生成脚本生成多个源文件,重要的是所有生成的文件都被列为某个目标的依赖项。

还有更多内容

我们提到所有生成的文件都应该被列为某个目标的依赖项。然而,我们可能会遇到这样的情况:我们不知道这些文件的列表,因为它是根据我们提供给配置的输入由生成文件的脚本决定的。在这种情况下,我们可能会倾向于使用file(GLOB ...)来收集生成的文件到一个列表中(参见cmake.org/cmake/help/v3.5/command/file.html)。

然而,请记住,file(GLOB ...)是在配置时执行的,而代码生成发生在构建时。因此,我们可能需要一个额外的间接层,将file(GLOB ...)命令放在一个单独的 CMake 脚本中,我们使用${CMAKE_COMMAND} -P执行该脚本,以便在构建时获取生成的文件列表。

记录项目版本信息以确保可重复性

本配方的代码可在github.com/dev-cafe/cmake-cookbook/tree/v1.0/chapter-06/recipe-04找到,包括 C 和 Fortran 示例。本配方适用于 CMake 版本 3.5(及更高版本),并在 GNU/Linux、macOS 和 Windows 上进行了测试。

代码版本不仅对可重复性很重要,而且对于记录 API 能力或简化支持请求和错误报告也很重要。源代码通常在某种版本控制下,并且可以使用 Git 标签等附加语义版本号(参见例如semver.org)。然而,不仅源代码需要版本化,可执行文件也需要记录项目版本,以便它可以打印到代码输出或用户界面。

在本例中,我们将在 CMake 源代码中定义版本号。我们的目标是记录程序版本,以便在配置项目时将其记录到头文件中。生成的头文件随后可以在代码中的正确位置和时间被包含,以便将代码版本打印到输出文件或屏幕上。

准备就绪

我们将使用以下 C 文件(example.c)来打印版本信息:

#include "version.h"

#include <stdio.h>

int main() {
  printf("This is output from code %s\n", PROJECT_VERSION);
  printf("Major version number: %i\n", PROJECT_VERSION_MAJOR);
  printf("Minor version number: %i\n", PROJECT_VERSION_MINOR);

  printf("Hello CMake world!\n");
}

在这里,我们假设version.h中定义了PROJECT_VERSION_MAJORPROJECT_VERSION_MINORPROJECT_VERSION。我们的目标是根据以下骨架生成version.h,即version.h.in

#pragma once

#define PROJECT_VERSION_MAJOR @PROJECT_VERSION_MAJOR@
#define PROJECT_VERSION_MINOR @PROJECT_VERSION_MINOR@
#define PROJECT_VERSION_PATCH @PROJECT_VERSION_PATCH@

#define PROJECT_VERSION "v@PROJECT_VERSION@"

我们将使用预处理器定义,但也可以使用字符串或整数常量以获得更多类型安全性(我们稍后将演示)。从 CMake 的角度来看,方法是一样的。

如何操作

我们将按照以下步骤在我们的模板头文件中注册版本:

  1. 为了追踪代码版本,我们可以在CMakeLists.txt中调用 CMake 的project命令时定义项目版本:
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)

project(recipe-04 VERSION 2.0.1 LANGUAGES C)
  1. 我们随后根据version.h.in配置version.h
configure_file(
  version.h.in
  generated/version.h
  @ONLY
  )
  1. 最后,我们定义可执行文件并提供目标包含路径:
add_executable(example example.c)

target_include_directories(example
  PRIVATE
    ${CMAKE_CURRENT_BINARY_DIR}/generated
  )

工作原理

当使用VERSION参数调用 CMake 的project命令时,CMake 将为我们的项目设置PROJECT_VERSION_MAJORPROJECT_VERSION_MINORPROJECT_VERSION_PATCH。本食谱中的关键命令是configure_file,它接受一个输入文件(在这种情况下,version.h.in)并生成一个输出文件(在这种情况下,generated/version.h),通过将所有@之间的占位符扩展为其对应的 CMake 变量。它将@PROJECT_VERSION_MAJOR@替换为2,以此类推。使用关键字@ONLY,我们限制configure_file仅扩展@variables@,但不触及${variables}。后一种形式在version.h.in中没有使用,但它们经常出现在使用 CMake 配置 shell 脚本时。

生成的头文件可以包含在我们的示例代码中,并且版本信息可供打印:

$ mkdir -p build
$ cd build
$ cmake ..
$ cmake --build .
$ ./example

This is output from code v2.0.1
Major version number: 2
Minor version number: 0
Hello CMake world!

CMake 理解以X.Y.Z.t格式给出的版本号,并将设置PROJECT_VERSION<project-name>_VERSION变量为传入的值。此外,PROJECT_VERSION_MAJOR<project-name>_VERSION_MAJOR),PROJECT_VERSION_MINOR<project-name>_VERSION_MINOR),PROJECT_VERSION_PATCH<project-name>_VERSION_PATCH),和PROJECT_VERSION_TWEAK<project-name>_VERSION_TWEAK)将被设置为XYZ,和t,分别。

还有更多

为了确保预处理器变量仅在 CMake 变量被视为真常量时定义,可以在即将配置的头文件中使用#cmakedefine而不是#define,通过使用configure_file

根据 CMake 变量是否被定义并且评估为真常量,#cmakedefine YOUR_VARIABLE将被替换为#define YOUR_VARIABLE .../* #undef YOUR_VARIABLE */。还有#cmakedefine01,它将根据变量是否定义将变量设置为01

从文件记录项目版本

本食谱的代码可在github.com/dev-cafe/cmake-cookbook/tree/v1.0/chapter-06/recipe-05找到,包括一个 C++示例。该食谱适用于 CMake 版本 3.5(及以上),并在 GNU/Linux、macOS 和 Windows 上进行了测试。

本食谱的目标与前一个相似,但起点不同;我们的计划是从文件中读取版本信息,而不是在CMakeLists.txt内部设置它。将版本信息保存在 CMake 源代码之外的单独文件中的动机是允许其他构建框架或开发工具使用该信息,独立于 CMake,而不在几个文件中重复信息。您可能希望与 CMake 并行使用的构建框架的一个例子是 Sphinx 文档框架,它生成文档并将其部署到 Read the Docs 服务以在线提供您的代码文档。

准备工作

我们将从一个名为VERSION的文件开始,其中包含以下内容:

2.0.1-rc-2

这一次,我们将选择更注重类型安全,并将PROGRAM_VERSION定义为version.hpp.in中的字符串常量:

#pragma once

#include <string>

const std::string PROGRAM_VERSION = "@PROGRAM_VERSION@";

我们将在下面的示例源代码(example.cpp)中包含生成的version.hpp

// provides PROGRAM_VERSION
#include "version.hpp"

#include <iostream>

int main() {
  std::cout << "This is output from code v" << PROGRAM_VERSION
                                            << std::endl;

  std::cout << "Hello CMake world!" << std::endl;
}

如何操作

以下展示了我们如何一步步完成任务:

  1. CMakeLists.txt定义了最低版本、项目名称、语言和标准:
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)

project(recipe-05 LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 11)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
  1. 我们按照以下方式从文件中读取版本信息:
if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/VERSION")
  file(READ "${CMAKE_CURRENT_SOURCE_DIR}/VERSION" PROGRAM_VERSION)
  string(STRIP "${PROGRAM_VERSION}" PROGRAM_VERSION)
else()
  message(FATAL_ERROR "File ${CMAKE_CURRENT_SOURCE_DIR}/VERSION not found")
endif()
  1. 然后我们配置头文件:
configure_file(
  version.hpp.in
  generated/version.hpp
  @ONLY
  )
  1. 最后,我们定义了可执行文件及其依赖项:
add_executable(example example.cpp)

target_include_directories(example
  PRIVATE
    ${CMAKE_CURRENT_BINARY_DIR}/generated
  )
  1. 然后我们准备测试它:
$ mkdir -p build
$ cd build
$ cmake ..
$ cmake --build .
$ ./example

This is output from code v2.0.1-rc-2
Hello CMake world!

它是如何工作的

我们使用了以下结构从名为VERSION的文件中读取版本字符串:

if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/VERSION")
  file(READ "${CMAKE_CURRENT_SOURCE_DIR}/VERSION" PROGRAM_VERSION)
  string(STRIP "${PROGRAM_VERSION}" PROGRAM_VERSION)
else()
  message(FATAL_ERROR "File ${CMAKE_CURRENT_SOURCE_DIR}/VERSION not found")
endif()

在这里,我们首先检查该文件是否存在,如果不存在则发出错误消息。如果存在,我们将文件内容读入名为PROGRAM_VERSION的变量中,并去除任何尾随空格。一旦设置了变量PROGRAM_VERSION,就可以用来配置version.hpp.in以生成generated/version.hpp,如下所示:

configure_file(
  version.hpp.in
  generated/version.hpp
  @ONLY
  )

在配置时记录 Git 哈希

本食谱的代码可在github.com/dev-cafe/cmake-cookbook/tree/v1.0/chapter-06/recipe-06找到,包括一个 C++示例。该食谱适用于 CMake 版本 3.5(及以上),并在 GNU/Linux、macOS 和 Windows 上进行了测试。

大多数现代源代码仓库都使用 Git 作为版本控制系统进行跟踪,这一事实可以归因于仓库托管平台 GitHub 的巨大流行。因此,在本食谱中,我们将使用 Git;然而,动机和实现将适用于其他版本控制系统。如果我们以 Git 为例,一个提交的 Git 哈希值唯一地确定了源代码的状态。因此,为了唯一地标记可执行文件,我们将尝试通过在头文件中记录哈希字符串来将 Git 哈希值烧录到可执行文件中,该头文件可以在代码中的正确位置包含和使用。

准备工作

我们需要两个源文件,都与之前的食谱非常相似。一个将使用记录的哈希值进行配置(version.hpp.in),如下所示:

#pragma once

#include <string>

const std::string GIT_HASH = "@GIT_HASH@";

我们还需要一个示例源文件(example.cpp),它将打印哈希值到屏幕上:

#include "version.hpp"

#include <iostream>

int main() {
  std::cout << "This code has been configured from version " << GIT_HASH
            << std::endl;
}

这个食谱还假设我们处于至少有一个提交的 Git 仓库中。因此,使用 git init 初始化这个示例,并通过 git add <filename>git commit 创建提交,以获得有意义的示例。

如何操作

以下步骤说明了如何从 Git 记录版本信息:

  1. CMakeLists.txt 中,我们首先定义项目和语言支持:
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)

project(recipe-06 LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 11)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
  1. 然后,我们使用以下代码片段来定义一个变量,GIT_HASH
# in case Git is not available, we default to "unknown"
set(GIT_HASH "unknown")

# find Git and if available set GIT_HASH variable
find_package(Git QUIET)
if(GIT_FOUND)
  execute_process(
    COMMAND ${GIT_EXECUTABLE} log -1 --pretty=format:%h
    OUTPUT_VARIABLE GIT_HASH
    OUTPUT_STRIP_TRAILING_WHITESPACE
    ERROR_QUIET
    WORKING_DIRECTORY
      ${CMAKE_CURRENT_SOURCE_DIR}
    )
endif()

message(STATUS "Git hash is ${GIT_HASH}")
  1. 其余的 CMakeLists.txt 与之前的食谱中的相似:
# generate file version.hpp based on version.hpp.in
configure_file(
  version.hpp.in
  generated/version.hpp
  @ONLY
  )

# example code
add_executable(example example.cpp)

# needs to find the generated header file
target_include_directories(example
  PRIVATE
    ${CMAKE_CURRENT_BINARY_DIR}/generated
  )
  1. 我们可以通过以下方式验证输出(哈希值会有所不同):
$ mkdir -p build
$ cd build
$ cmake ..
$ cmake --build .
$ ./example

This code has been configured from version d58c64f

工作原理

我们使用 find_package(Git QUIET) 来检测系统上是否安装了 Git。如果安装了(如果 GIT_FOUND 为真),我们运行一个 Git 命令:${GIT_EXECUTABLE} log -1 --pretty=format:%h。这个命令给我们提供了当前提交哈希的简短版本。当然,我们完全有灵活性来运行另一个 Git 命令,而不是这个。我们要求 execute_process 命令将命令的结果放入一个名为 GIT_HASH 的变量中,然后我们去除任何尾随的空白。使用 ERROR_QUIET,我们要求命令在 Git 命令由于某种原因失败时不停止配置。

由于 Git 命令可能会失败(源代码可能已经在 Git 仓库之外分发)或者系统上甚至可能没有安装 Git,我们希望为变量设置一个默认值,如下所示:

set(GIT_HASH "unknown")

这个食谱的一个问题是 Git 哈希值是在配置时记录的,而不是在构建时。在下一个食谱中,我们将演示如何实现后一种方法。

在构建时记录 Git 哈希值

这个食谱的代码可以在 github.com/dev-cafe/cmake-cookbook/tree/v1.0/chapter-06/recipe-07 找到,包括一个 C++ 示例。这个食谱适用于 CMake 版本 3.5(及更高版本),并且已经在 GNU/Linux、macOS 和 Windows 上进行了测试。

在之前的配方中,我们在配置时记录了代码仓库的状态(Git 哈希),并且在可执行文件中记录仓库状态非常有用。然而,之前方法的一个不满意之处是,如果我们更改分支或提交更改后配置代码,源代码中包含的版本记录可能会指向错误的 Git 哈希。在本配方中,我们希望更进一步,并演示如何在构建时记录 Git 哈希(或一般而言,执行其他操作),以确保每次我们构建代码时都会运行这些操作,因为我们可能只配置一次,但构建多次。

准备工作

我们将使用与之前配方相同的version.hpp.in,并且只会对example.cpp文件进行最小限度的修改,以确保它打印出构建时的 Git 哈希值:

#include "version.hpp"

#include <iostream>

int main() {
  std::cout << "This code has been built from version " << GIT_HASH << std::endl;
}

如何操作

在构建时将 Git 信息保存到version.hpp头文件将需要以下操作:

  1. 我们将把之前配方中CMakeLists.txt的大部分代码移动到一个单独的文件中,并将其命名为git-hash.cmake
# in case Git is not available, we default to "unknown"
set(GIT_HASH "unknown")

# find Git and if available set GIT_HASH variable
find_package(Git QUIET)
if(GIT_FOUND)
  execute_process(
    COMMAND ${GIT_EXECUTABLE} log -1 --pretty=format:%h
    OUTPUT_VARIABLE GIT_HASH
    OUTPUT_STRIP_TRAILING_WHITESPACE
    ERROR_QUIET
    )
endif()

message(STATUS "Git hash is ${GIT_HASH}")

# generate file version.hpp based on version.hpp.in
configure_file(
  ${CMAKE_CURRENT_LIST_DIR}/version.hpp.in
  ${TARGET_DIR}/generated/version.hpp
  @ONLY
  )
  1. CMakeLists.txt现在剩下我们非常熟悉的部分:
# set minimum cmake version
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)

# project name and language
project(recipe-07 LANGUAGES CXX)

# require C++11
set(CMAKE_CXX_STANDARD 11)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

# example code
add_executable(example example.cpp)

# needs to find the generated header file
target_include_directories(example
  PRIVATE
    ${CMAKE_CURRENT_BINARY_DIR}/generated
  )
  1. CMakeLists.txt的剩余部分记录了每次我们构建代码时 Git 哈希值,如下所示:
add_custom_command(
  OUTPUT
    ${CMAKE_CURRENT_BINARY_DIR}/generated/version.hpp
    ALL
  COMMAND
    ${CMAKE_COMMAND} -D TARGET_DIR=${CMAKE_CURRENT_BINARY_DIR} -P ${CMAKE_CURRENT_SOURCE_DIR}/git-hash.cmake
  WORKING_DIRECTORY
    ${CMAKE_CURRENT_SOURCE_DIR}
  )

# rebuild version.hpp every time
add_custom_target(
  get_git_hash
  ALL
  DEPENDS
    ${CMAKE_CURRENT_BINARY_DIR}/generated/version.hpp
  )

# version.hpp has to be generated
# before we start building example
add_dependencies(example get_git_hash)

它是如何工作的

在本配方中,我们实现了在构建时执行 CMake 代码。为此,我们定义了一个自定义命令:

add_custom_command(
  OUTPUT
    ${CMAKE_CURRENT_BINARY_DIR}/generated/version.hpp
    ALL
  COMMAND
    ${CMAKE_COMMAND} -D TARGET_DIR=${CMAKE_CURRENT_BINARY_DIR} -P ${CMAKE_CURRENT_SOURCE_DIR}/git-hash.cmake
  WORKING_DIRECTORY
    ${CMAKE_CURRENT_SOURCE_DIR}
  )

我们还定义了一个自定义目标,如下所示:

add_custom_target(
  get_git_hash
  ALL
  DEPENDS
    ${CMAKE_CURRENT_BINARY_DIR}/generated/version.hpp
  )

自定义命令调用 CMake 执行git-hash.cmakeCMake 脚本。这是通过使用-PCLI 开关来实现的,以传递脚本的位置。请注意,我们可以使用-DCLI 开关传递选项,就像我们通常所做的那样。git-hash.cmake脚本生成${TARGET_DIR}/generated/version.hpp。自定义目标添加到ALL目标,并依赖于自定义命令的输出。换句话说,当我们构建默认目标时,我们确保自定义命令被执行。此外,请注意自定义命令将ALL目标作为输出。这样,我们确保每次都会生成version.hpp

还有更多

我们可以增强配方,以便在记录的 Git 哈希之外包含额外信息。检测构建环境是否“脏”(即是否包含未提交的更改和未跟踪的文件)或“干净”并不罕见。可以使用git describe --abbrev=7 --long --always --dirty --tags检测此信息。根据可重复性的雄心,甚至可以将git status的完整输出记录到头文件中,但我们将其作为练习留给读者。

第八章:项目结构

在本章中,我们将涵盖以下配方:

  • 使用函数和宏实现代码复用

  • 将 CMake 源代码拆分为模块

  • 编写一个函数来测试和设置编译器标志

  • 使用命名参数定义函数或宏

  • 重新定义函数和宏

  • 弃用函数、宏和变量

  • 使用add_subdirectory限制作用域

  • 使用target_sources避免全局变量

  • 组织 Fortran 项目

引言

在前几章中,我们已经探索了使用 CMake 配置和构建项目所需的多个构建块。在本章中,我们将讨论如何组合这些构建块,并引入抽象概念以避免庞大的CMakeLists.txt文件,并最小化代码重复、全局变量、全局状态和显式排序。我们的目标是展示模块化 CMake 代码结构的范式,并限制变量的作用域。我们将讨论的策略也将帮助我们控制中等至大型代码项目的 CMake 代码复杂性。

使用函数和宏实现代码复用

本配方的代码可在github.com/dev-cafe/cmake-cookbook/tree/v1.0/chapter-07/recipe-01获取,并包含一个 C++示例。该配方适用于 CMake 版本 3.5(及以上),并在 GNU/Linux、macOS 和 Windows 上进行了测试。

在任何编程语言中,函数允许我们抽象(隐藏)细节并避免代码重复,CMake 也不例外。在本配方中,我们将讨论宏和函数作为示例,并引入一个宏,使我们定义测试和设置测试顺序更加方便。我们不是通过调用add_testset_tests_properties来定义每个集合并设置每个测试的预期COST(第四章,创建和运行测试,配方 8,并行运行测试),我们的目标是定义一个宏,能够一次性处理这两项任务。

准备工作

我们将从第四章,创建和运行测试,配方 2,使用 Catch2 库定义单元测试中介绍的示例开始。main.cppsum_integers.cppsum_integers.hpp文件保持不变,可以用来计算作为命令行参数提供的整数之和。单元测试的源代码(test.cpp)也保持不变。我们还需要 Catch2 头文件catch.hpp。与第四章,创建和运行测试,配方 2,使用 Catch2 库定义单元测试不同,我们将把源文件结构化为子目录,并形成以下文件树(稍后我们将讨论 CMake 代码):

.
├── CMakeLists.txt
├── src
│   ├── CMakeLists.txt
│   ├── main.cpp
│   ├── sum_integers.cpp
│   └── sum_integers.hpp
└── tests
    ├── catch.hpp
    ├── CMakeLists.txt
    └── test.cpp

如何操作

让我们按照所需的步骤进行:

  1. 顶层的CMakeLists.txt文件定义了最低 CMake 版本、项目名称和支持的语言,并要求使用 C++11 标准:
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)

project(recipe-01 LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 11)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
  1. 我们进一步根据 GNU 标准定义了二进制和库路径:
include(GNUInstallDirs)

set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY
  ${CMAKE_BINARY_DIR}/${CMAKE_INSTALL_LIBDIR})
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY
  ${CMAKE_BINARY_DIR}/${CMAKE_INSTALL_LIBDIR})
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY
  ${CMAKE_BINARY_DIR}/${CMAKE_INSTALL_BINDIR})
  1. 最后,我们使用add_subdirectory调用来将我们的 CMake 代码结构化为src/CMakeLists.txttests/CMakeLists.txt部分。我们还启用了测试:
add_subdirectory(src)

enable_testing()
add_subdirectory(tests)
  1. src/CMakeLists.txt文件中定义了源代码目标:
set(CMAKE_INCLUDE_CURRENT_DIR_IN_INTERFACE ON)

add_library(sum_integers sum_integers.cpp)

add_executable(sum_up main.cpp)

target_link_libraries(sum_up sum_integers)
  1. tests/CMakeLists.txt中,我们首先构建并链接cpp_test可执行文件:
add_executable(cpp_test test.cpp)
target_link_libraries(cpp_test sum_integers)
  1. 然后我们定义了一个新的宏,add_catch_test,我们将在后面讨论它:
macro(add_catch_test _name _cost)
  math(EXPR num_macro_calls "${num_macro_calls} + 1")
  message(STATUS "add_catch_test called with ${ARGC} arguments: ${ARGV}")

  set(_argn "${ARGN}")
  if(_argn)
    message(STATUS "oops - macro received argument(s) we did not expect: ${ARGN}")
  endif()

  add_test(
    NAME
      ${_name}
    COMMAND
      $<TARGET_FILE:cpp_test>
      [${_name}] --success --out
      ${PROJECT_BINARY_DIR}/tests/${_name}.log --durations yes
    WORKING_DIRECTORY
      ${CMAKE_CURRENT_BINARY_DIR}
    )

  set_tests_properties(
    ${_name}
    PROPERTIES
      COST ${_cost}
    )
endmacro()
  1. 最后,我们使用add_catch_test定义了两个测试,此外,我们还设置并打印了一个变量的值:
set(num_macro_calls 0)

add_catch_test(short 1.5)
add_catch_test(long 2.5 extra_argument)

message(STATUS "in total there were ${num_macro_calls} calls to add_catch_test")
  1. 现在,我们准备测试一下。首先,我们配置项目(显示的有趣输出行):
$ mkdir -p build
$ cd build
$ cmake ..

-- ...
-- add_catch_test called with 2 arguments: short;1.5
-- add_catch_test called with 3 arguments: long;2.5;extra_argument
-- oops - macro received argument(s) we did not expect: extra_argument
-- in total there were 2 calls to add_catch_test
-- ...
  1. 最后,我们构建并运行测试:
$ cmake --build .
$ ctest
  1. 注意,首先启动的是长测试:
    Start 2: long
1/2 Test #2: long ............................. Passed 0.00 sec
    Start 1: short
2/2 Test #1: short ............................ Passed 0.00 sec

100% tests passed, 0 tests failed out of 2

它是如何工作的

本食谱中的新特性是add_catch_test宏。宏期望两个参数,_name_cost,我们可以在宏内部使用这些参数来调用add_testset_tests_properties。前面的下划线是我们的选择,但通过这种方式,我们向读者表明这些参数具有局部作用域,并且只能在宏内部访问。还要注意,宏会自动填充${ARGC}(参数数量)和${ARGV}(参数列表),我们在输出中验证了这一点:

-- add_catch_test called with 2 arguments: short;1.5
-- add_catch_test called with 3 arguments: long;2.5;extra_argument

宏还定义了${ARGN},它保存了最后一个预期参数之后的参数列表。此外,我们还可以使用${ARGV0}${ARGV1}等来引用参数。观察我们是如何在这个调用中捕获意外参数(extra_argument)的:

add_catch_test(long 2.5 extra_argument)

我们使用以下方式完成了这一步骤:

set(_argn "${ARGN}")
if(_argn)
  message(STATUS "oops - macro received argument(s) we did not expect: ${ARGN}")
endif()

在这个条件检查中,我们不得不引入一个新的变量,并且不能直接查询ARGN,因为它在通常的 CMake 意义上不是一个变量。通过这个宏,我们不仅能够通过名称和命令定义测试,还能够指示预期的成本,这导致由于COST属性,“长”测试在“短”测试之前启动。

我们可以使用具有相同语法的函数而不是宏来实现这一点:

function(add_catch_test _name _cost)
  ...
endfunction()

宏和函数之间的区别在于它们的变量作用域。宏在调用者的作用域内执行,而函数有自己的变量作用域。换句话说,如果我们需要设置或修改应该对调用者可用的变量,我们通常使用宏。如果没有设置或修改输出变量,我们更倾向于使用函数。我们注意到,在函数中也可以修改父作用域的变量,但这必须使用PARENT_SCOPE明确指示:

set(variable_visible_outside "some value" PARENT_SCOPE)

为了展示范围,我们在宏定义之后编写了以下调用:

set(num_macro_calls 0)

add_catch_test(short 1.5)
add_catch_test(long 2.5 extra_argument)

message(STATUS "in total there were ${num_macro_calls} calls to add_catch_test")

在宏内部,我们将num_macro_calls增加 1:

math(EXPR num_macro_calls "${num_macro_calls} + 1")

这是产生的输出:

-- in total there were 2 calls to add_catch_test

如果我们把宏改为函数,测试仍然有效,但num_macro_calls在整个父作用域的调用过程中将保持为 0。想象一下,CMake 宏就像函数一样,它们直接被替换到调用它们的位置(在 C 语言中称为内联)。想象一下,CMake 函数就像黑盒子,除非你明确将其定义为PARENT_SCOPE,否则什么都不会返回。CMake 函数没有返回值。

还有更多

在宏中嵌套函数调用和在函数中嵌套宏调用是可能的,但我们需要仔细考虑变量的作用域。如果可以使用函数实现某个功能,那么这可能比宏更可取,因为它提供了对父作用域状态的更多默认控制。

我们还应该提到在src/CMakeLists.txt中使用CMAKE_INCLUDE_CURRENT_DIR_IN_INTERFACE

set(CMAKE_INCLUDE_CURRENT_DIR_IN_INTERFACE ON)

此命令将当前目录添加到此CMakeLists.txt文件中定义的所有目标的INTERFACE_INCLUDE_DIRECTORIES属性中。换句话说,我们不需要使用target_include_directories来指示cpp_test的头文件位置。

将 CMake 源代码拆分为模块

本例的代码可在github.com/dev-cafe/cmake-cookbook/tree/v1.0/chapter-07/recipe-02找到。本例适用于 CMake 版本 3.5(及更高版本),并在 GNU/Linux、macOS 和 Windows 上进行了测试。

项目通常从一个CMakeLists.txt文件开始,但随着时间的推移,这个文件会增长,在本例中,我们将演示一种将CMakeLists.txt拆分为较小单元的方法。将CMakeLists.txt拆分为可以在主CMakeLists.txt或其他模块中包含的模块有几个动机:

  • 主要的CMakeLists.txt更容易阅读。

  • CMake 模块可以在其他项目中重用。

  • 结合函数,模块可以帮助我们限制变量的作用域。

在本例中,我们将演示如何定义和包含一个宏,该宏允许我们获取彩色的 CMake 输出(用于重要状态消息或警告)。

准备工作

在本例中,我们将使用两个文件,主CMakeLists.txtcmake/colors.cmake

.
├── cmake
│   └── colors.cmake
└── CMakeLists.txt

cmake/colors.cmake文件包含彩色输出的定义:

# colorize CMake output

# code adapted from stackoverflow: http://stackoverflow.com/a/19578320
# from post authored by https://stackoverflow.com/users/2556117/fraser

macro(define_colors)
  if(WIN32)
    # has no effect on WIN32
    set(ColourReset "")
    set(ColourBold "")
    set(Red "")
    set(Green "")
    set(Yellow "")
    set(Blue "")
    set(Magenta "")
    set(Cyan "")
    set(White "")
    set(BoldRed "")
    set(BoldGreen "")
    set(BoldYellow "")
    set(BoldBlue "")
    set(BoldMagenta "")
    set(BoldCyan "")
    set(BoldWhite "")
  else()
    string(ASCII 27 Esc)
    set(ColourReset "${Esc}m")
    set(ColourBold "${Esc}[1m")
    set(Red "${Esc}[31m")
    set(Green "${Esc}[32m")
    set(Yellow "${Esc}[33m")
    set(Blue "${Esc}[34m")
    set(Magenta "${Esc}[35m")
    set(Cyan "${Esc}[36m")
    set(White "${Esc}[37m")
    set(BoldRed "${Esc}[1;31m")
    set(BoldGreen "${Esc}[1;32m")
    set(BoldYellow "${Esc}[1;33m")
    set(BoldBlue "${Esc}[1;34m")
    set(BoldMagenta "${Esc}[1;35m")
    set(BoldCyan "${Esc}[1;36m")
    set(BoldWhite "${Esc}[1;37m")
  endif()
endmacro()

如何操作

这就是我们如何使用颜色定义来生成彩色状态消息的方法:

  1. 我们从一个熟悉的开头开始:
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)

project(recipe-02 LANGUAGES NONE)
  1. 然后,我们将cmake子目录添加到 CMake 将搜索的模块路径列表中:
list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/cmake")
  1. 然后,我们包含colors.cmake模块并调用其中定义的宏:
include(colors)
define_colors()
  1. 最后,我们打印几条不同颜色的消息:
message(STATUS "This is a normal message")
message(STATUS "${Red}This is a red${ColourReset}")
message(STATUS "${BoldRed}This is a bold red${ColourReset}")
message(STATUS "${Green}This is a green${ColourReset}")
message(STATUS "${BoldMagenta}This is bold${ColourReset}")
  1. 让我们测试一下(如果使用 macOS 或 Linux,此输出应该以彩色显示在屏幕上):

![

工作原理

这是一个示例,其中不编译任何代码,也不需要语言支持,我们通过LANGUAGES NONE表明了这一点:

project(recipe-02 LANGUAGES NONE)

我们定义了define_colors宏,并将其放置在cmake/colors.cmake中。我们选择使用宏而不是函数,因为我们还希望在调用范围内使用宏内部定义的变量来改变消息的颜色。我们包含了宏,并使用以下行调用了define_colors

include(colors)
define_colors()

然而,我们还需要告诉 CMake 在哪里查找宏:

list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/cmake")

include(colors)命令指示 CMake 在${CMAKE_MODULE_PATH}中搜索名为colors.cmake的模块。

而不是这样写:

list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/cmake")

include(colors)

我们可以使用以下明确的包含:

include(cmake/colors.cmake)

还有更多

推荐的做法是在模块中定义宏或函数,然后调用宏或函数。使用模块包含作为函数调用是不好的做法。包含模块不应该做更多的事情,除了定义函数和宏以及发现程序、库和路径。实际的包含命令不应该定义或修改变量,这样做的原因是,重复的包含,可能是意外的,不应该引入任何不希望的副作用。在食谱 5,“重新定义函数和宏”中,我们将创建一个防止意外包含的保护措施。

编写一个测试和设置编译器标志的函数

本食谱的代码可在github.com/dev-cafe/cmake-cookbook/tree/v1.0/chapter-07/recipe-03找到,并包含一个 C/C++示例。该食谱适用于 CMake 版本 3.5(及以上),并在 GNU/Linux、macOS 和 Windows 上进行了测试。

在前两个食谱中,我们使用了宏;在本食谱中,我们将使用一个函数来抽象细节并避免代码重复。在示例中,我们将实现一个接受编译器标志列表的函数。该函数将尝试使用这些标志逐一编译测试代码,并返回编译器理解的第一标志。通过这样做,我们将学习一些新特性:函数、列表操作、字符串操作以及检查编译器是否支持编译器标志。

准备

遵循前一个食谱的推荐实践,我们将在一个模块(set_compiler_flag.cmake)中定义函数,包含该模块,然后调用该函数。该模块包含以下代码,我们将在后面讨论:

include(CheckCCompilerFlag)
include(CheckCXXCompilerFlag)
include(CheckFortranCompilerFlag)
function(set_compiler_flag _result _lang)
  # build a list of flags from the arguments
  set(_list_of_flags)
  # also figure out whether the function
  # is required to find a flag
  set(_flag_is_required FALSE)
  foreach(_arg IN ITEMS ${ARGN})
    string(TOUPPER "${_arg}" _arg_uppercase)
    if(_arg_uppercase STREQUAL "REQUIRED")
      set(_flag_is_required TRUE)
    else()
      list(APPEND _list_of_flags "${_arg}")
    endif()
  endforeach()

  set(_flag_found FALSE)
  # loop over all flags, try to find the first which works
  foreach(flag IN ITEMS ${_list_of_flags})

    unset(_flag_works CACHE)
    if(_lang STREQUAL "C")
      check_c_compiler_flag("${flag}" _flag_works)
    elseif(_lang STREQUAL "CXX")
      check_cxx_compiler_flag("${flag}" _flag_works)
    elseif(_lang STREQUAL "Fortran")
      check_Fortran_compiler_flag("${flag}" _flag_works)
    else()
      message(FATAL_ERROR "Unknown language in set_compiler_flag: ${_lang}")
    endif()

    # if the flag works, use it, and exit
    # otherwise try next flag
    if(_flag_works)
      set(${_result} "${flag}" PARENT_SCOPE)
      set(_flag_found TRUE)
      break()
    endif()
  endforeach()

  # raise an error if no flag was found
  if(_flag_is_required AND NOT _flag_found)
    message(FATAL_ERROR "None of the required flags were supported")
  endif()
endfunction()

如何操作

这是我们如何在CMakeLists.txt中使用set_compiler_flag函数的方法:

  1. 在前言中,我们定义了最低 CMake 版本、项目名称和支持的语言(在这种情况下,C 和 C++):
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)

project(recipe-03 LANGUAGES C CXX)
  1. 然后,我们明确地包含set_compiler_flag.cmake
include(set_compiler_flag.cmake)
  1. 然后,我们尝试一组 C 标志:
set_compiler_flag(
  working_compile_flag C REQUIRED
  "-foo"             # this should fail
  "-wrong"           # this should fail
  "-wrong"           # this should fail
  "-Wall"            # this should work with GNU
  "-warn all"        # this should work with Intel
  "-Minform=inform"  # this should work with PGI
  "-nope"            # this should fail
  )

message(STATUS "working C compile flag: ${working_compile_flag}")
  1. 我们尝试一组 C++标志:
set_compiler_flag(
  working_compile_flag CXX REQUIRED
  "-foo"    # this should fail
  "-g"      # this should work with GNU, Intel, PGI
  "/RTCcsu" # this should work with MSVC
  )

message(STATUS "working CXX compile flag: ${working_compile_flag}")
  1. 现在,我们可以配置项目并验证输出。仅显示相关输出,输出可能因编译器而异:
$ mkdir -p build
$ cd build
$ cmake ..
-- ...
-- Performing Test _flag_works
-- Performing Test _flag_works - Failed
-- Performing Test _flag_works
-- Performing Test _flag_works - Failed
-- Performing Test _flag_works
-- Performing Test _flag_works - Failed
-- Performing Test _flag_works
-- Performing Test _flag_works - Success
-- working C compile flag: -Wall
-- Performing Test _flag_works
-- Performing Test _flag_works - Failed
-- Performing Test _flag_works
-- Performing Test _flag_works - Success
-- working CXX compile flag: -g
-- ...

工作原理

我们在这里使用的模式是:

  1. 定义一个函数或宏并将其放入模块中

  2. 包含模块

  3. 调用函数或宏

从输出中,我们可以看到代码检查列表中的每个标志,一旦检查成功,它就会打印出成功的编译标志。让我们看看set_compiler_flag.cmake模块内部。这个模块反过来包含了三个模块:

include(CheckCCompilerFlag)
include(CheckCXXCompilerFlag)
include(CheckFortranCompilerFlag)

这些是标准的 CMake 模块,CMake 将在${CMAKE_MODULE_PATH}中找到它们。这些模块提供了check_c_compiler_flagcheck_cxx_compiler_flagcheck_fortran_compiler_flag宏,分别。然后是函数定义:

function(set_compiler_flag _result _lang)
  ...
endfunction()

set_compiler_flag函数期望两个参数,我们称它们为_result(这将保存成功的编译标志或空字符串"")和_lang(指定语言:C、C++或 Fortran)。

我们希望能够这样调用函数:

set_compiler_flag(working_compile_flag C REQUIRED "-Wall" "-warn all")

这个调用有五个参数,但函数头只期望两个。这意味着REQUIRED"-Wall""-warn all"将被放入${ARGN}中。从${ARGN}中,我们首先使用foreach构建一个标志列表。同时,我们从标志列表中过滤掉REQUIRED,并使用它来设置_flag_is_required

# build a list of flags from the arguments
set(_list_of_flags)
# also figure out whether the function
# is required to find a flag
set(_flag_is_required FALSE)
foreach(_arg IN ITEMS ${ARGN})
  string(TOUPPER "${_arg}" _arg_uppercase)
  if(_arg_uppercase STREQUAL "REQUIRED")
    set(_flag_is_required TRUE)
  else()
    list(APPEND _list_of_flags "${_arg}")
  endif()
endforeach()

现在,我们将循环遍历${_list_of_flags},尝试每个标志,如果_flag_works被设置为TRUE,我们将_flag_found设置为TRUE并终止进一步的搜索:

set(_flag_found FALSE)
# loop over all flags, try to find the first which works
foreach(flag IN ITEMS ${_list_of_flags})

  unset(_flag_works CACHE)
  if(_lang STREQUAL "C")
    check_c_compiler_flag("${flag}" _flag_works)
  elseif(_lang STREQUAL "CXX")
    check_cxx_compiler_flag("${flag}" _flag_works)
  elseif(_lang STREQUAL "Fortran")
    check_Fortran_compiler_flag("${flag}" _flag_works)
  else()
    message(FATAL_ERROR "Unknown language in set_compiler_flag: ${_lang}")
  endif()
  # if the flag works, use it, and exit
  # otherwise try next flag
  if(_flag_works)
    set(${_result} "${flag}" PARENT_SCOPE)
    set(_flag_found TRUE)
    break()
  endif()
endforeach()

unset(_flag_works CACHE)这一行是为了确保check_*_compiler_flag的结果不会在多次调用中使用相同的_flag_works结果变量时被缓存。

如果找到标志并且_flag_works被设置为TRUE,我们定义映射到_result的变量:

set(${_result} "${flag}" PARENT_SCOPE)

这需要使用PARENT_SCOPE,因为我们希望修改的变量在函数体外打印和使用。此外,请注意我们是如何使用${_result}语法从父作用域传递的变量_result进行解引用的。这是必要的,以确保在调用函数时,无论其名称如何,都将工作标志设置为从父作用域传递的变量的值。如果没有找到标志并且提供了REQUIRED关键字,我们通过错误消息停止配置:

# raise an error if no flag was found
if(_flag_is_required AND NOT _flag_found)
  message(FATAL_ERROR "None of the required flags were supported")
endif()

还有更多

我们可以通过宏来完成这项任务,但使用函数,我们可以更好地控制作用域。我们知道,函数只能修改结果变量。

此外,请注意,某些标志需要在编译和链接时都设置,通过为check_<LANG>_compiler_flag函数设置CMAKE_REQUIRED_FLAGS来正确报告成功。正如我们在第五章,配置时间和构建时间操作,第 7 个配方,探测编译标志中讨论的,这是针对 sanitizers 的情况。

定义一个带有命名参数的函数或宏

本食谱的代码可在github.com/dev-cafe/cmake-cookbook/tree/v1.0/chapter-07/recipe-04找到,并包含一个 C++示例。该食谱适用于 CMake 版本 3.5(及以上),并在 GNU/Linux、macOS 和 Windows 上进行了测试。

在前面的食谱中,我们探索了函数和宏并使用了位置参数。在本食谱中,我们将定义一个带有命名参数的函数。我们将增强来自食谱 1 的示例,即使用函数和宏重用代码,并使用以下方式定义测试:

add_catch_test(short 1.5)

我们将能够调用以下内容:

add_catch_test(
  NAME
    short
  LABELS
    short
    cpp_test
  COST
    1.5
  )

准备就绪

我们将使用来自食谱 1 的示例,即使用函数和宏重用代码,并保持 C++源文件不变,文件树基本相同:

.
├── cmake
│   └── testing.cmake
├── CMakeLists.txt
├── src
│   ├── CMakeLists.txt
│   ├── main.cpp
│   ├── sum_integers.cpp
│   └── sum_integers.hpp
└── tests
    ├── catch.hpp
    ├── CMakeLists.txt
    └── test.cpp

如何做到这一点

我们将在 CMake 代码中引入小的修改,如下所示:

  1. 由于我们将包含位于cmake下的模块,因此在顶层CMakeLists.txt中只添加了一行额外的代码:
list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/cmake")
  1. 我们保持src/CMakeLists.txt不变。

  2. tests/CMakeLists.txt中,我们将add_catch_test函数定义移动到cmake/testing.cmake,并定义了两个测试:

add_executable(cpp_test test.cpp)
target_link_libraries(cpp_test sum_integers)

include(testing)

add_catch_test(
  NAME
    short
  LABELS
    short
    cpp_test
  COST
    1.5
  )

add_catch_test(
  NAME
    long
  LABELS
    long
    cpp_test
  COST
    2.5
  )
  1. add_catch_test函数现在在cmake/testing.cmake中定义:
function(add_catch_test)
  set(options)
  set(oneValueArgs NAME COST)
  set(multiValueArgs LABELS DEPENDS REFERENCE_FILES)
  cmake_parse_arguments(add_catch_test
    "${options}"
    "${oneValueArgs}"
    "${multiValueArgs}"
    ${ARGN}
    )

  message(STATUS "defining a test ...")
  message(STATUS " NAME: ${add_catch_test_NAME}")
  message(STATUS " LABELS: ${add_catch_test_LABELS}")
  message(STATUS " COST: ${add_catch_test_COST}")
  message(STATUS " REFERENCE_FILES: ${add_catch_test_REFERENCE_FILES}")

  add_test(
    NAME
      ${add_catch_test_NAME}
    COMMAND
      $<TARGET_FILE:cpp_test>
      [${add_catch_test_NAME}] --success --out
      ${PROJECT_BINARY_DIR}/tests/${add_catch_test_NAME}.log --durations yes
    WORKING_DIRECTORY
      ${CMAKE_CURRENT_BINARY_DIR}
    )

  set_tests_properties(${add_catch_test_NAME}
    PROPERTIES
      LABELS "${add_catch_test_LABELS}"
    )

  if(add_catch_test_COST)
    set_tests_properties(${add_catch_test_NAME}
      PROPERTIES
        COST ${add_catch_test_COST}
      )
  endif()

  if(add_catch_test_DEPENDS)
    set_tests_properties(${add_catch_test_NAME}
      PROPERTIES
        DEPENDS ${add_catch_test_DEPENDS}
      )
  endif()

  if(add_catch_test_REFERENCE_FILES)
    file(
      COPY
        ${add_catch_test_REFERENCE_FILES}
      DESTINATION
        ${CMAKE_CURRENT_BINARY_DIR}
      )
  endif()
endfunction()
  1. 我们准备好测试输出。首先,我们配置以下内容:
$ mkdir -p build
$ cd build
$ cmake ..

-- ...
-- defining a test ...
--     NAME: short
--     LABELS: short;cpp_test
--     COST: 1.5
--     REFERENCE_FILES: 
-- defining a test ...
--     NAME: long
--     LABELS: long;cpp_test
--     COST: 2.5
--     REFERENCE_FILES:
-- ...
  1. 然后,编译并测试代码:
$ cmake --build .
$ ctest

它是如何工作的

本食谱中的新内容是命名参数,因此我们可以专注于cmake/testing.cmake模块。CMake 提供了cmake_parse_arguments命令,我们用函数名(add_catch_test)调用它,选项(在我们的例子中没有),单值参数(这里,NAMECOST),以及多值参数(这里,LABELSDEPENDS,和REFERENCE_FILES):

function(add_catch_test)
  set(options)
  set(oneValueArgs NAME COST)
  set(multiValueArgs LABELS DEPENDS REFERENCE_FILES)
  cmake_parse_arguments(add_catch_test
    "${options}"
    "${oneValueArgs}"
    "${multiValueArgs}"
    ${ARGN}
    )
  ...
endfunction()

cmake_parse_arguments命令解析选项和参数,并在我们的情况下定义以下内容:

  • add_catch_test_NAME

  • add_catch_test_COST

  • add_catch_test_LABELS

  • add_catch_test_DEPENDS

  • add_catch_test_REFERENCE_FILES

然后我们可以在函数内部查询和使用这些变量。这种方法为我们提供了实现具有更健壮接口和更易读的函数/宏调用的函数和宏的机会。

还有更多

选项关键字(在本示例中未使用)由cmake_parse_arguments定义为TRUEFALSE。对add_catch_test函数的进一步增强可能是提供测试命令作为命名参数,为了更简洁的示例,我们省略了这一点。

cmake_parse_arguments命令在 CMake 3.5 发布之前在CMakeParseArguments.cmake模块中可用。因此,可以通过在cmake/testing.cmake模块文件的顶部使用include(CMakeParseArguments)命令来使本食谱与早期版本的 CMake 兼容。

重新定义函数和宏

本食谱的代码可在github.com/dev-cafe/cmake-cookbook/tree/v1.0/chapter-07/recipe-05找到。该食谱适用于 CMake 版本 3.5(及以上),并在 GNU/Linux、macOS 和 Windows 上进行了测试。

我们提到过,模块包含不应该用作函数调用,因为模块可能会被(意外地)多次包含。在本食谱中,我们将编写我们自己的简单包含保护,如果我们尝试多次包含模块,它将警告我们。内置的include_guard命令自 CMake 3.10 版本起可用,并且行为类似于 C/C++头文件的#pragma once。对于这个版本的 CMake,我们将讨论和演示如何重新定义函数和宏。我们将展示如何检查 CMake 版本,对于 3.10 以下的版本,我们将使用我们自己的自定义包含保护。

准备工作

在本例中,我们将使用三个文件:

.
├── cmake
│   ├── custom.cmake
│   └── include_guard.cmake
└── CMakeLists.txt

自定义的custom.cmake模块包含以下代码:

include_guard(GLOBAL)

message(STATUS "custom.cmake is included and processed")

稍后我们将讨论cmake/include_guard.cmakeCMakeLists.txt

如何操作

这是我们的三个 CMake 文件的逐步分解:

  1. 在本食谱中,我们不会编译任何代码,因此我们的语言要求是NONE
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)

project(recipe-05 LANGUAGES NONE)
  1. 然后我们定义一个include_guard宏,我们将其放置在一个单独的模块中:
# (re)defines include_guard
include(cmake/include_guard.cmake)
  1. cmake/include_guard.cmake文件包含以下内容(我们稍后将详细讨论):
macro(include_guard)
  if (CMAKE_VERSION VERSION_LESS "3.10")
    # for CMake below 3.10 we define our
    # own include_guard(GLOBAL)
    message(STATUS "calling our custom include_guard")

    # if this macro is called the first time
    # we start with an empty list
    if(NOT DEFINED included_modules)
        set(included_modules)
    endif()

    if ("${CMAKE_CURRENT_LIST_FILE}" IN_LIST included_modules)
      message(WARNING "module ${CMAKE_CURRENT_LIST_FILE} processed more than once")
    endif()

    list(APPEND included_modules ${CMAKE_CURRENT_LIST_FILE})
  else()
    # for CMake 3.10 or higher we augment
    # the built-in include_guard
    message(STATUS "calling the built-in include_guard")

    _include_guard(${ARGV})
  endif()
endmacro()
  1. 在主CMakeLists.txt中,然后我们模拟意外地两次包含自定义模块:
include(cmake/custom.cmake)
include(cmake/custom.cmake)
  1. 最后,我们使用以下命令进行配置:
$ mkdir -p build
$ cd build
$ cmake ..
  1. 使用 CMake 3.10 及以上版本的结果如下:
-- calling the built-in include_guard
-- custom.cmake is included and processed
-- calling the built-in include_guard
  1. 使用 CMake 3.10 以下版本的结果如下:
-- calling our custom include_guard
-- custom.cmake is included and processed
-- calling our custom include_guard
CMake Warning at cmake/include_guard.cmake:7 (message):
  module
  /home/user/example/cmake/custom.cmake
  processed more than once
Call Stack (most recent call first):
  cmake/custom.cmake:1 (include_guard)
  CMakeLists.txt:12 (include)

它是如何工作的

我们的include_guard宏包含两个分支,一个用于 CMake 3.10 以下版本,另一个用于 CMake 3.10 及以上版本:

macro(include_guard)
  if (CMAKE_VERSION VERSION_LESS "3.10")
    # ...
  else()
    # ...
  endif()
endmacro()

如果 CMake 版本低于 3.10,我们进入第一个分支,内置的include_guard不可用,因此我们定义我们自己的:

message(STATUS "calling our custom include_guard")

# if this macro is called the first time
# we start with an empty list
if(NOT DEFINED included_modules)
    set(included_modules)
endif()

if ("${CMAKE_CURRENT_LIST_FILE}" IN_LIST included_modules)
  message(WARNING "module ${CMAKE_CURRENT_LIST_FILE} processed more than once")
endif()

list(APPEND included_modules ${CMAKE_CURRENT_LIST_FILE})

如果宏第一次被调用,那么included_modules变量未定义,因此我们将其设置为空列表。然后我们检查${CMAKE_CURRENT_LIST_FILE}是否是included_modules列表的元素。如果是,我们发出警告。如果不是,我们将${CMAKE_CURRENT_LIST_FILE}添加到此列表中。在 CMake 输出中,我们可以验证第二次包含自定义模块确实会导致警告。

对于 CMake 3.10 及以上的情况则不同;在这种情况下,存在一个内置的include_guard,我们用我们自己的宏接收的参数调用它:

macro(include_guard)
  if (CMAKE_VERSION VERSION_LESS "3.10")
    # ...
  else()
    message(STATUS "calling the built-in include_guard")

    _include_guard(${ARGV})
  endif()
endmacro()

在这里,_include_guard(${ARGV})指向内置的include_guard。在这种情况下,我们通过添加自定义消息(“调用内置的include_guard”)来增强内置命令。这种模式为我们提供了一种重新定义自己的或内置的函数和宏的机制。这在调试或记录目的时可能很有用。

这种模式可能很有用,但应谨慎应用,因为 CMake 不会警告宏或函数的重新定义。

弃用函数、宏和变量

本示例的代码可在 github.com/dev-cafe/cmake-cookbook/tree/v1.0/chapter-07/recipe-06 获取。该示例适用于 CMake 版本 3.5(及以上),并在 GNU/Linux、macOS 和 Windows 上进行了测试。

弃用是在项目发展过程中向开发者发出信号的重要机制,表明某个函数、宏或变量将在未来的某个时候被移除或替换。在一定时期内,该函数、宏或变量将继续可用,但会发出警告,最终可以升级为错误。

准备就绪

我们将从以下 CMake 项目开始:

cmake_minimum_required(VERSION 3.5 FATAL_ERROR)

project(recipe-06 LANGUAGES NONE)

macro(custom_include_guard)
  if(NOT DEFINED included_modules)
    set(included_modules)
  endif()

  if ("${CMAKE_CURRENT_LIST_FILE}" IN_LIST included_modules)
    message(WARNING "module ${CMAKE_CURRENT_LIST_FILE} processed more than once")
  endif()

  list(APPEND included_modules ${CMAKE_CURRENT_LIST_FILE})
endmacro()

include(cmake/custom.cmake)

message(STATUS "list of all included modules: ${included_modules}")

这段代码定义了一个自定义的包含保护,包含了一个自定义模块(与前一个示例相同的模块),并打印了所有包含的模块列表。对于 CMake 3.10 及以上版本,我们知道从之前的示例中有一个内置的 include_guard。但不是简单地移除 custom_include_guard${included_modules},我们将通过弃用警告来弃用宏和变量,这样在某个时刻我们可以将其转换为 FATAL_ERROR,使代码停止并强制开发者切换到内置命令。

如何操作

弃用函数、宏和变量可以按如下方式进行:

  1. 首先,我们定义一个函数,用于弃用变量:
function(deprecate_variable _variable _access)
  if(_access STREQUAL "READ_ACCESS")
    message(DEPRECATION "variable ${_variable} is deprecated")
  endif()
endfunction()
  1. 然后,如果 CMake 版本大于 3.9,我们重新定义 custom_include_guard 并将 variable_watch 附加到 included_modules
if (CMAKE_VERSION VERSION_GREATER "3.9")
  # deprecate custom_include_guard
  macro(custom_include_guard)
    message(DEPRECATION "custom_include_guard is deprecated - use built-in include_guard instead")
    _custom_include_guard(${ARGV})
  endmacro()

  # deprecate variable included_modules
  variable_watch(included_modules deprecate_variable)
endif()
  1. 在 CMake 版本低于 3.10 的项目中配置会产生以下结果:
$ mkdir -p build
$ cd build
$ cmake ..

-- custom.cmake is included and processed
-- list of all included modules: /home/user/example/cmake/custom.cmake
  1. CMake 3.10 及以上版本将产生预期的弃用警告:
CMake Deprecation Warning at CMakeLists.txt:26 (message):
  custom_include_guard is deprecated - use built-in include_guard instead
Call Stack (most recent call first):
  cmake/custom.cmake:1 (custom_include_guard)
  CMakeLists.txt:34 (include)

-- custom.cmake is included and processed
CMake Deprecation Warning at CMakeLists.txt:19 (message):
  variable included_modules is deprecated
Call Stack (most recent call first):
  CMakeLists.txt:9999 (deprecate_variable)
  CMakeLists.txt:36 (message)

-- list of all included modules: /home/user/example/cmake/custom.cmake

工作原理

弃用函数或宏相当于重新定义它,如前一个示例所示,并打印带有 DEPRECATION 的消息:

macro(somemacro)
  message(DEPRECATION "somemacro is deprecated")
  _somemacro(${ARGV})
endmacro()

弃用变量可以通过首先定义以下内容来实现:

function(deprecate_variable _variable _access)
  if(_access STREQUAL "READ_ACCESS")
    message(DEPRECATION "variable ${_variable} is deprecated")
  endif()
endfunction()

接下来,该函数将附加到即将被弃用的变量上:

variable_watch(somevariable deprecate_variable)

如果在这种情况下读取了 ${included_modules}READ_ACCESS),则 deprecate_variable 函数会发出带有 DEPRECATION 的消息。

使用 add_subdirectory 限制作用域

本示例的代码可在 github.com/dev-cafe/cmake-cookbook/tree/v1.0/chapter-07/recipe-07 获取,并包含一个 C++示例。该示例适用于 CMake 版本 3.5(及以上),并在 GNU/Linux、macOS 和 Windows 上进行了测试。

在本章剩余的食谱中,我们将讨论项目结构化的策略,以及如何限制变量和副作用的范围,目的是降低代码复杂性并简化项目的维护。在本食谱中,我们将把一个项目拆分成多个CMakeLists.txt文件,每个文件都有有限的范围,这些文件将使用add_subdirectory命令进行处理。

准备工作

由于我们希望展示和讨论如何组织一个非平凡的项目,我们需要一个比“hello world”项目更复杂的示例。我们将开发一个相对简单的代码,它可以计算并打印基本细胞自动机:

我们的代码将能够计算 256 种基本细胞自动机中的任何一种,例如规则 90(Wolfram 代码):

$ ./bin/automata 40 15 90

length: 40
number of steps: 15
rule: 90
                    *
                   * *
                  *   *
                 * * * *
                *       *
               * *     * *
              *   *   *   *
             * * * * * * * *
            *               *
           * *             * *
          *   *           *   *
         * * * *         * * * *
        *       *       *       *
       * *     * *     * *     * *
      *   *   *   *   *   *   *   *
     * * * * * * * * * * * * * * * *

我们示例代码项目的结构如下:

.
├── CMakeLists.txt
├── external
│   ├── CMakeLists.txt
│   ├── conversion.cpp
│   ├── conversion.hpp
│   └── README.md
├── src
│   ├── CMakeLists.txt
│   ├── evolution
│   │   ├── CMakeLists.txt
│   │   ├── evolution.cpp
│   │   └── evolution.hpp
│   ├── initial
│   │   ├── CMakeLists.txt
│   │   ├── initial.cpp
│   │   └── initial.hpp
│   ├── io
│   │   ├── CMakeLists.txt
│   │   ├── io.cpp
│   │   └── io.hpp
│   ├── main.cpp
│   └── parser
│       ├── CMakeLists.txt
│       ├── parser.cpp
│       └── parser.hpp
└── tests
    ├── catch.hpp
    ├── CMakeLists.txt
    └── test.cpp

在这里,我们将代码拆分为多个库,以模拟现实世界中的中型到大型项目,其中源代码可以组织成库,然后链接到可执行文件中。

主函数在src/main.cpp中:

#include "conversion.hpp"
#include "evolution.hpp"
#include "initial.hpp"
#include "io.hpp"
#include "parser.hpp"

#include <iostream>
int main(int argc, char *argv[]) {

  // parse arguments
  int length, num_steps, rule_decimal;
  std::tie(length, num_steps, rule_decimal) = parse_arguments(argc, argv);

  // print information about parameters
  std::cout << "length: " << length << std::endl;
  std::cout << "number of steps: " << num_steps << std::endl;
  std::cout << "rule: " << rule_decimal << std::endl;

  // obtain binary representation for the rule
  std::string rule_binary = binary_representation(rule_decimal);

  // create initial distribution
  std::vector<int> row = initial_distribution(length);

  // print initial configuration
  print_row(row);

  // the system evolves, print each step
  for (int step = 0; step < num_steps; step++) {
    row = evolve(row, rule_binary);
    print_row(row);
  }
}

external/conversion.cpp文件包含将十进制转换为二进制的代码。我们在这里模拟这段代码是由src之外的“外部”库提供的:

#include "conversion.hpp"

#include <bitset>
#include <string>

std::string binary_representation(const int decimal) {
  return std::bitset<8>(decimal).to_string();
}

src/evolution/evolution.cpp文件在时间步长内传播系统:

#include "evolution.hpp"

#include <string>
#include <vector>

std::vector<int> evolve(const std::vector<int> row, const std::string rule_binary) {
  std::vector<int> result;
  for (auto i = 0; i < row.size(); ++i) {

    auto left = (i == 0 ? row.size() : i) - 1;
    auto center = i;
    auto right = (i + 1) % row.size();

    auto ancestors = 4 * row[left] + 2 * row[center] + 1 * row[right];
    ancestors = 7 - ancestors;

    auto new_state = std::stoi(rule_binary.substr(ancestors, 1));

    result.push_back(new_state);
  }

  return result;
}

src/initial/initial.cpp文件生成初始状态:

#include "initial.hpp"

#include <vector>

std::vector<int> initial_distribution(const int length) {

  // we start with a vector which is zeroed out
  std::vector<int> result(length, 0);

  // more or less in the middle we place a living cell
  result[length / 2] = 1;

  return result;
}

src/io/io.cpp文件包含打印一行的函数:

#include "io.hpp"

#include <algorithm>
#include <iostream>
#include <vector>

void print_row(const std::vector<int> row) {
  std::for_each(row.begin(), row.end(), [](int const &value) {
    std::cout << (value == 1 ? '*' : ' ');
  });
  std::cout << std::endl;
}

src/parser/parser.cpp文件解析命令行输入:

#include "parser.hpp"

#include <cassert>
#include <string>
#include <tuple>

std::tuple<int, int, int> parse_arguments(int argc, char *argv[]) {
  assert(argc == 4 && "program called with wrong number of arguments");

  auto length = std::stoi(argv[1]);
  auto num_steps = std::stoi(argv[2]);
  auto rule_decimal = std::stoi(argv[3]);

  return std::make_tuple(length, num_steps, rule_decimal);
}

最后,tests/test.cpp包含使用 Catch2 库的两个单元测试:

#include "evolution.hpp"

// this tells catch to provide a main()
// only do this in one cpp file
#define CATCH_CONFIG_MAIN
#include "catch.hpp"

#include <string>
#include <vector>

TEST_CASE("Apply rule 90", "[rule-90]") {
  std::vector<int> row = {0, 1, 0, 1, 0, 1, 0, 1, 0};
  std::string rule = "01011010";
  std::vector<int> expected_result = {1, 0, 0, 0, 0, 0, 0, 0, 1};
  REQUIRE(evolve(row, rule) == expected_result);
}

TEST_CASE("Apply rule 222", "[rule-222]") {
  std::vector<int> row = {0, 0, 0, 0, 1, 0, 0, 0, 0};
  std::string rule = "11011110";
  std::vector<int> expected_result = {0, 0, 0, 1, 1, 1, 0, 0, 0};
  REQUIRE(evolve(row, rule) == expected_result);
}

相应的头文件包含函数签名。有人可能会说,对于这个小小的代码示例来说,项目包含的子目录太多了,但请记住,这只是一个简化的示例,通常每个库都包含许多源文件,理想情况下像这里一样组织在单独的目录中。

如何做

让我们深入了解所需的 CMake 基础设施的详细解释:

  1. 顶层的CMakeLists.txt与食谱 1,使用函数和宏的代码重用非常相似:
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)

project(recipe-07 LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 11)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

include(GNUInstallDirs)
set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY
  ${CMAKE_BINARY_DIR}/${CMAKE_INSTALL_LIBDIR})
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY
  ${CMAKE_BINARY_DIR}/${CMAKE_INSTALL_LIBDIR})
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY
  ${CMAKE_BINARY_DIR}/${CMAKE_INSTALL_BINDIR})

# defines targets and sources
add_subdirectory(src)

# contains an "external" library we will link to
add_subdirectory(external)

# enable testing and define tests
enable_testing()
add_subdirectory(tests)
  1. 目标和源文件在src/CMakeLists.txt中定义(转换目标除外):
add_executable(automata main.cpp)

add_subdirectory(evolution)
add_subdirectory(initial)
add_subdirectory(io)
add_subdirectory(parser)

target_link_libraries(automata
  PRIVATE
    conversion
    evolution
    initial
    io
    parser
  )
  1. 转换库在external/CMakeLists.txt中定义:
add_library(conversion "")

target_sources(conversion
  PRIVATE
    ${CMAKE_CURRENT_LIST_DIR}/conversion.cpp
  PUBLIC
    ${CMAKE_CURRENT_LIST_DIR}/conversion.hpp
  )

target_include_directories(conversion
  PUBLIC
    ${CMAKE_CURRENT_LIST_DIR}
  )
  1. src/CMakeLists.txt文件添加了更多的子目录,这些子目录又包含CMakeLists.txt文件。它们的结构都类似;src/evolution/CMakeLists.txt包含以下内容:
add_library(evolution "")

target_sources(evolution
  PRIVATE
    evolution.cpp
  PUBLIC
    ${CMAKE_CURRENT_LIST_DIR}/evolution.hpp
  )
target_include_directories(evolution
  PUBLIC
    ${CMAKE_CURRENT_LIST_DIR}
  )
  1. 单元测试在tests/CMakeLists.txt中注册:
add_executable(cpp_test test.cpp)

target_link_libraries(cpp_test evolution)

add_test(
  NAME
    test_evolution
  COMMAND
    $<TARGET_FILE:cpp_test>
  )
  1. 配置和构建项目会产生以下输出:
$ mkdir -p build
$ cd build
$ cmake ..
$ cmake --build .

Scanning dependencies of target conversion
[ 7%] Building CXX object external/CMakeFiles/conversion.dir/conversion.cpp.o
[ 14%] Linking CXX static library ../lib64/libconversion.a
[ 14%] Built target conversion
Scanning dependencies of target evolution
[ 21%] Building CXX object src/evolution/CMakeFiles/evolution.dir/evolution.cpp.o
[ 28%] Linking CXX static library ../../lib64/libevolution.a
[ 28%] Built target evolution
Scanning dependencies of target initial
[ 35%] Building CXX object src/initial/CMakeFiles/initial.dir/initial.cpp.o
[ 42%] Linking CXX static library ../../lib64/libinitial.a
[ 42%] Built target initial
Scanning dependencies of target io
[ 50%] Building CXX object src/io/CMakeFiles/io.dir/io.cpp.o
[ 57%] Linking CXX static library ../../lib64/libio.a
[ 57%] Built target io
Scanning dependencies of target parser
[ 64%] Building CXX object src/parser/CMakeFiles/parser.dir/parser.cpp.o
[ 71%] Linking CXX static library ../../lib64/libparser.a
[ 71%] Built target parser
Scanning dependencies of target automata
[ 78%] Building CXX object src/CMakeFiles/automata.dir/main.cpp.o
[ 85%] Linking CXX executable ../bin/automata
[ 85%] Built target automata
Scanning dependencies of target cpp_test
[ 92%] Building CXX object tests/CMakeFiles/cpp_test.dir/test.cpp.o
[100%] Linking CXX executable ../bin/cpp_test
[100%] Built target cpp_test
  1. 最后,我们运行单元测试:
$ ctest

Running tests...
    Start 1: test_evolution
1/1 Test #1: test_evolution ................... Passed 0.00 sec

100% tests passed, 0 tests failed out of 1

它是如何工作的

我们本可以将所有代码放入一个源文件中。这样做是不切实际的;每次编辑都需要完全重新编译。将源文件分割成更小、更易管理的单元是有意义的。我们同样可以将所有源文件编译成一个单一的库或可执行文件,但在实践中,项目更倾向于将源文件的编译分割成更小、定义明确的库。这样做既是为了限定作用域和简化依赖扫描,也是为了简化代码维护。这意味着,像我们这里所做的那样,使用多个库构建项目是一个典型的情况。

为了讨论 CMake 结构,我们可以从定义每个库的单个CMakeLists.txt文件开始,例如src/evolution/CMakeLists.txt

add_library(evolution "")

target_sources(evolution
  PRIVATE
    evolution.cpp
  PUBLIC
    ${CMAKE_CURRENT_LIST_DIR}/evolution.hpp
  )

target_include_directories(evolution
  PUBLIC
    ${CMAKE_CURRENT_LIST_DIR}
  )

这些单独的CMakeLists.txt文件尽可能靠近源代码定义库。在这个例子中,我们首先用add_library定义库名,然后定义其源文件和包含目录,以及它们的目标可见性:实现文件(这里为evolution.cpp)是PRIVATE,而接口头文件evolution.hpp被定义为PUBLIC,因为我们将在main.cpptest.cpp中访问它。将目标尽可能靠近代码定义的优点是,了解该库且可能对 CMake 框架知识有限的代码开发人员只需要编辑此目录中的文件;换句话说,库依赖关系被封装了。

向上移动一级,库在src/CMakeLists.txt中组装:

add_executable(automata main.cpp)

add_subdirectory(evolution)
add_subdirectory(initial)
add_subdirectory(io)
add_subdirectory(parser)

target_link_libraries(automata
  PRIVATE
    conversion
    evolution
    initial
    io
    parser
  )

这个文件反过来又被引用在顶层的CMakeLists.txt中。这意味着我们使用CMakeLists.txt文件的树构建了我们的项目,从一棵库的树开始。这种方法对许多项目来说是典型的,并且它可以扩展到大型项目,而不需要在目录之间携带全局变量中的源文件列表。add_subdirectory方法的一个额外好处是它隔离了作用域,因为在一个子目录中定义的变量不会自动在父作用域中访问。

还有更多

使用add_subdirectory调用树构建项目的一个限制是,CMake 不允许我们在当前目录作用域之外使用target_link_libraries与目标链接。这对于本食谱中所示的示例来说不是问题。在下一个食谱中,我们将展示一种替代方法,其中我们不使用add_subdirectory,而是使用模块包含来组装不同的CMakeLists.txt文件,这允许我们链接到当前目录之外定义的目标。

CMake 可以使用 Graphviz 图形可视化软件(www.graphviz.org)来生成项目的依赖关系图:

$ cd build
$ cmake --graphviz=example.dot ..
$ dot -T png example.dot -o example.png

生成的图表将显示不同目录中目标之间的依赖关系:

在本书中,我们一直在进行源外构建,以保持源代码树和构建树分离。这是推荐的实践,允许我们使用相同的源代码配置不同的构建(顺序或并行,DebugRelease),而不需要复制源代码,也不需要在源代码树中散布生成的和对象文件。通过以下代码片段,您可以保护您的项目免受源内构建的影响:

if(${PROJECT_SOURCE_DIR} STREQUAL ${PROJECT_BINARY_DIR})
    message(FATAL_ERROR "In-source builds not allowed. Please make a new directory (called a build directory) and run CMake from there.")
endif()

认识到构建树的结构模仿了源代码树的结构是很有用的。在我们的示例中,在src/CMakeLists.txt中插入以下message打印输出是相当有教育意义的:

message("current binary dir is ${CMAKE_CURRENT_BINARY_DIR}")

在配置项目以进行build时,我们会看到打印输出指向build/src

另请参见

我们注意到,从 CMake 3.12 版本开始,OBJECT库是组织大型项目的另一种可行方法。我们对示例的唯一修改将是在库的CMakeLists.txt文件中。源代码将被编译成对象文件:既不会被归档到静态归档中,也不会被链接到共享库中。例如:

add_library(io OBJECT "")

target_sources(io
  PRIVATE
    io.cpp
  PUBLIC
    ${CMAKE_CURRENT_LIST_DIR}/io.hpp
  )

target_include_directories(io
  PUBLIC
    ${CMAKE_CURRENT_LIST_DIR}
  )

顶层CMakeLists.txt保持不变:automata可执行目标将这些对象文件链接到最终的可执行文件中。使用要求,如包含目录、编译标志和链接库设置在OBJECT库上将正确继承。有关 CMake 3.12 中引入的OBJECT库新功能的更多详细信息,请参阅官方文档:cmake.org/cmake/help/v3.12/manual/cmake-buildsystem.7.html#object-libraries

使用 target_sources 避免全局变量

本配方的代码可在github.com/dev-cafe/cmake-cookbook/tree/v1.0/chapter-07/recipe-08找到,并包含一个 C++示例。本配方适用于 CMake 3.5(及以上)版本,并在 GNU/Linux、macOS 和 Windows 上进行了测试。

在本配方中,我们将讨论与前一个配方不同的方法,并使用模块包含而不是使用add_subdirectory来组装不同的CMakeLists.txt文件。这种方法受到crascit.com/2016/01/31/enhanced-source-file-handling-with-target_sources/的启发,允许我们使用target_link_libraries链接到当前目录之外定义的目标。

准备工作

我们将使用与之前配方相同的源代码。唯一的变化将在CMakeLists.txt文件中,我们将在接下来的章节中讨论这些变化。

如何操作

让我们详细看看 CMake 所需的各个文件:

  1. 顶层CMakeLists.txt包含以下内容:
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)

project(recipe-08 LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 11)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
include(GNUInstallDirs)
set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY
  ${CMAKE_BINARY_DIR}/${CMAKE_INSTALL_LIBDIR})
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY
  ${CMAKE_BINARY_DIR}/${CMAKE_INSTALL_LIBDIR})
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY
  ${CMAKE_BINARY_DIR}/${CMAKE_INSTALL_BINDIR})

# defines targets and sources
include(src/CMakeLists.txt)
include(external/CMakeLists.txt)

enable_testing()
add_subdirectory(tests)
  1. external/CMakeLists.txt文件与之前的配方相比没有变化。

  2. src/CMakeLists.txt 文件定义了两个库(automatonevolution):

add_library(automaton "")
add_library(evolution "")

include(${CMAKE_CURRENT_LIST_DIR}/evolution/CMakeLists.txt)
include(${CMAKE_CURRENT_LIST_DIR}/initial/CMakeLists.txt)
include(${CMAKE_CURRENT_LIST_DIR}/io/CMakeLists.txt)
include(${CMAKE_CURRENT_LIST_DIR}/parser/CMakeLists.txt)

add_executable(automata "")

target_sources(automata
  PRIVATE
    ${CMAKE_CURRENT_LIST_DIR}/main.cpp
  )

target_link_libraries(automata
  PRIVATE
    automaton
    conversion
  )
  1. src/evolution/CMakeLists.txt 文件包含以下内容:
target_sources(automaton
  PRIVATE
    ${CMAKE_CURRENT_LIST_DIR}/evolution.cpp
  PUBLIC
    ${CMAKE_CURRENT_LIST_DIR}/evolution.hpp
  )

target_include_directories(automaton
  PUBLIC
    ${CMAKE_CURRENT_LIST_DIR}
  )

target_sources(evolution
  PRIVATE
    ${CMAKE_CURRENT_LIST_DIR}/evolution.cpp
  PUBLIC
    ${CMAKE_CURRENT_LIST_DIR}/evolution.hpp
  )

target_include_directories(evolution
  PUBLIC
    ${CMAKE_CURRENT_LIST_DIR}
  )
  1. 剩余的CMakeLists.txt文件与src/initial/CMakeLists.txt相同:
target_sources(automaton
  PRIVATE
    ${CMAKE_CURRENT_LIST_DIR}/initial.cpp
  PUBLIC
    ${CMAKE_CURRENT_LIST_DIR}/initial.hpp
  )

target_include_directories(automaton
  PUBLIC
    ${CMAKE_CURRENT_LIST_DIR}
  )
  1. 配置、构建和测试的结果与之前的配方相同:
$ mkdir -p build
$ cd build
$ cmake ..
$ cmake --build build
$ ctest

Running tests...
 Start 1: test_evolution
1/1 Test #1: test_evolution ................... Passed 0.00 sec

100% tests passed, 0 tests failed out of 1

它是如何工作的

与之前的配方不同,我们定义了三个库:

  • conversion(在external中定义)

  • automaton(包含除转换之外的所有源文件)

  • evolution(在src/evolution中定义,并由cpp_test链接)

在这个例子中,我们通过使用include()引用CMakeLists.txt文件来保持父作用域中所有目标的可用性:

include(src/CMakeLists.txt)
include(external/CMakeLists.txt)

我们可以构建一个包含树,记住当我们进入子目录(src/CMakeLists.txt)时,我们需要使用相对于父作用域的路径:

include(${CMAKE_CURRENT_LIST_DIR}/evolution/CMakeLists.txt)
include(${CMAKE_CURRENT_LIST_DIR}/initial/CMakeLists.txt)
include(${CMAKE_CURRENT_LIST_DIR}/io/CMakeLists.txt)
include(${CMAKE_CURRENT_LIST_DIR}/parser/CMakeLists.txt)

这样,我们可以在通过include()语句访问的文件树中的任何地方定义和链接目标。然而,我们应该选择一个对维护者和代码贡献者来说最直观的地方来定义它们。

还有更多

我们可以再次使用 CMake 和 Graphviz(www.graphviz.org/)来生成这个项目的依赖图:

$ cd build
$ cmake --graphviz=example.dot ..
$ dot -T png example.dot -o example.png

对于当前的设置,我们得到以下依赖图:

组织 Fortran 项目

本配方的代码可在github.com/dev-cafe/cmake-cookbook/tree/v1.0/chapter-07/recipe-09找到,并包含一个 Fortran 示例。该配方适用于 CMake 版本 3.5(及更高版本),并在 GNU/Linux、macOS 和 Windows 上使用 MSYS Makefiles 进行了测试。

我们用一个配方来讨论如何结构化和组织 Fortran 项目,原因有二:

  1. 仍然有许多 Fortran 项目存在,特别是在数值软件领域(对于更全面的通用目的 Fortran 软件项目列表,请参见fortranwiki.org/fortran/show/Libraries)。

  2. Fortran 90(及以后版本)对于不使用 CMake 的项目来说,构建起来可能会更加困难,因为 Fortran 模块文件要求编译顺序。换句话说,对于手动编写的 Makefile,通常需要为 Fortran 模块文件编写一个依赖扫描器。

正如我们将在本配方中看到的,现代 CMake 允许我们以非常紧凑和模块化的方式表达配置和构建过程。作为一个例子,我们将使用前两个配方中的基本细胞自动机,现在移植到 Fortran。

准备就绪

文件树结构与前两个配方非常相似。我们用 Fortran 源代码替换了 C++,在这种情况下,我们没有头文件:

.
├── CMakeLists.txt
├── external
│   ├── CMakeLists.txt
│   ├── conversion.f90
│   └── README.md
├── src
│   ├── CMakeLists.txt
│   ├── evolution
│   │   ├── ancestors.f90
│   │   ├── CMakeLists.txt
│   │   ├── empty.f90
│   │   └── evolution.f90
│   ├── initial
│   │   ├── CMakeLists.txt
│   │   └── initial.f90
│   ├── io
│   │   ├── CMakeLists.txt
│   │   └── io.f90
│   ├── main.f90
│   └── parser
│       ├── CMakeLists.txt
│       └── parser.f90
└── tests
    ├── CMakeLists.txt
    └── test.f90

主程序在src/main.f90中:

program example

  use parser, only: get_arg_as_int
  use conversion, only: binary_representation
  use initial, only: initial_distribution
  use io, only: print_row
  use evolution, only: evolve

  implicit none

  integer :: num_steps
  integer :: length
  integer :: rule_decimal
  integer :: rule_binary(8)
  integer, allocatable :: row(:)
  integer :: step

  ! parse arguments
  num_steps = get_arg_as_int(1)
  length = get_arg_as_int(2)
  rule_decimal = get_arg_as_int(3)

  ! print information about parameters
  print *, "number of steps: ", num_steps
  print *, "length: ", length
  print *, "rule: ", rule_decimal

  ! obtain binary representation for the rule
  rule_binary = binary_representation(rule_decimal)

  ! create initial distribution
  allocate(row(length))
  call initial_distribution(row)

  ! print initial configuration
  call print_row(row)

  ! the system evolves, print each step
  do step = 1, num_steps
    call evolve(row, rule_binary)
    call print_row(row)
  end do

  deallocate(row)

end program

与之前的配方一样,我们将conversion模块放在external/conversion.f90中:

module conversion

  implicit none
  public binary_representation
  private

contains

  pure function binary_representation(n_decimal)
    integer, intent(in) :: n_decimal
    integer :: binary_representation(8)
    integer :: pos
    integer :: n

    binary_representation = 0
    pos = 8
    n = n_decimal
    do while (n > 0)
      binary_representation(pos) = mod(n, 2)
      n = (n - binary_representation(pos))/2
      pos = pos - 1
    end do
  end function

end module

evolution库,它实现了时间步长,被人工分为三个文件。大部分内容收集在src/evolution/evolution.f90

module evolution

  implicit none
  public evolve
  private

contains

  subroutine not_visible()
    ! no-op call to demonstrate private/public visibility
    call empty_subroutine_no_interface()
  end subroutine

  pure subroutine evolve(row, rule_binary)
    use ancestors, only: compute_ancestors

    integer, intent(inout) :: row(:)
    integer, intent(in) :: rule_binary(8)
    integer :: i
    integer :: left, center, right
    integer :: ancestry
    integer, allocatable :: new_row(:)

    allocate(new_row(size(row)))

    do i = 1, size(row)
      left = i - 1
      center = i
      right = i + 1

      if (left < 1) left = left + size(row)
      if (right > size(row)) right = right - size(row)

      ancestry = compute_ancestors(row, left, center, right)
      new_row(i) = rule_binary(ancestry)
    end do

    row = new_row
    deallocate(new_row)

  end subroutine

end module

祖先的计算在src/evolution/ancestors.f90中执行:

module ancestors

  implicit none
  public compute_ancestors
  private

contains

  pure integer function compute_ancestors(row, left, center, right) result(i)
    integer, intent(in) :: row(:)
    integer, intent(in) :: left, center, right

    i = 4*row(left) + 2*row(center) + 1*row(right)
    i = 8 - i
  end function

end module

我们还在src/evolution/empty.f90中有一个“空”模块:

module empty

  implicit none
  public empty_subroutine
  private

contains

  subroutine empty_subroutine()
  end subroutine

end module

subroutine empty_subroutine_no_interface()
  use empty, only: empty_subroutine
  call empty_subroutine()
end subroutine

我们将在下一节解释这些选择。

起始条件的代码位于src/initial/initial.f90

module initial

  implicit none
  public initial_distribution
  private

contains

  pure subroutine initial_distribution(row)
    integer, intent(out) :: row(:)

    row = 0
    row(size(row)/2) = 1
  end subroutine

end module

src/io/io.f90文件包含一个打印行的函数:

module io

  implicit none
  public print_row
  private

contains

  subroutine print_row(row)
    integer, intent(in) :: row(:)
    character(size(row)) :: line
    integer :: i

    do i = 1, size(row)
      if (row(i) == 1) then
        line(i:i) = '*'
      else
        line(i:i) = ' '
      end if
    end do

    print *, line
  end subroutine

end module

src/parser/parser.f90文件解析命令行参数:

module parser

  implicit none
  public get_arg_as_int
  private

contains

  integer function get_arg_as_int(n) result(i)
    integer, intent(in) :: n
    character(len=32) :: arg

    call get_command_argument(n, arg)
    read(arg , *) i
  end function

end module

最后,我们有测试源文件在tests/test.f90

program test

  use evolution, only: evolve

  implicit none

  integer :: row(9)
  integer :: expected_result(9)
  integer :: rule_binary(8)
  integer :: i

  ! test rule 90
  row = (/0, 1, 0, 1, 0, 1, 0, 1, 0/)
  rule_binary = (/0, 1, 0, 1, 1, 0, 1, 0/)
  call evolve(row, rule_binary)
  expected_result = (/1, 0, 0, 0, 0, 0, 0, 0, 1/)
  do i = 1, 9
    if (row(i) /= expected_result(i)) then
      print *, 'ERROR: test for rule 90 failed'
      call exit(1)
    end if
  end do
  ! test rule 222
  row = (/0, 0, 0, 0, 1, 0, 0, 0, 0/)
  rule_binary = (/1, 1, 0, 1, 1, 1, 1, 0/)
  call evolve(row, rule_binary)
  expected_result = (/0, 0, 0, 1, 1, 1, 0, 0, 0/)
  do i = 1, 9
    if (row(i) /= expected_result(i)) then
      print *, 'ERROR: test for rule 222 failed'
      call exit(1)
    end if
  end do

end program

如何做到这一点

我们现在将讨论相应的 CMake 结构:

  1. 顶层的CMakeLists.txt与第 7 个配方类似;我们只将CXX替换为Fortran并删除 C++11 要求:
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)

project(recipe-09 LANGUAGES Fortran)

include(GNUInstallDirs)
set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY
  ${CMAKE_BINARY_DIR}/${CMAKE_INSTALL_LIBDIR})
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY
  ${CMAKE_BINARY_DIR}/${CMAKE_INSTALL_LIBDIR})
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY
  ${CMAKE_BINARY_DIR}/${CMAKE_INSTALL_BINDIR})

# defines targets and sources
add_subdirectory(src)

# contains an "external" library we will link to
add_subdirectory(external)

# enable testing and define tests
enable_testing()
add_subdirectory(tests)
  1. 目标和源文件在src/CMakeLists.txt中定义(除了conversion目标):
add_executable(automata main.f90)

add_subdirectory(evolution)
add_subdirectory(initial)
add_subdirectory(io)
add_subdirectory(parser)

target_link_libraries(automata
  PRIVATE
    conversion
    evolution
    initial
    io
    parser
  )
  1. 转换库在external/CMakeLists.txt中定义:
add_library(conversion "")

target_sources(conversion
  PUBLIC
    ${CMAKE_CURRENT_LIST_DIR}/conversion.f90
  )
  1. src/CMakeLists.txt文件添加了进一步的子目录,这些子目录又包含CMakeLists.txt文件。它们的结构都类似;例如,src/initial/CMakeLists.txt包含以下内容:
add_library(initial "")

target_sources(initial
  PUBLIC
    ${CMAKE_CURRENT_LIST_DIR}/initial.f90
  )
  1. 例外是src/evolution/CMakeLists.txt中的evolution库,我们将其分为三个源文件:
add_library(evolution "")

target_sources(evolution
  PRIVATE
    empty.f90
  PUBLIC
    ${CMAKE_CURRENT_LIST_DIR}/ancestors.f90
    ${CMAKE_CURRENT_LIST_DIR}/evolution.f90
  )
  1. 单元测试在tests/CMakeLists.txt中注册:
add_executable(fortran_test test.f90)

target_link_libraries(fortran_test evolution)

add_test(
  NAME
    test_evolution
  COMMAND
    $<TARGET_FILE:fortran_test>
  )
  1. 配置和构建项目会产生以下输出:
$ mkdir -p build
$ cd build
$ cmake ..
$ cmake --build .

Scanning dependencies of target conversion
[ 4%] Building Fortran object external/CMakeFiles/conversion.dir/conversion.f90.o
[ 8%] Linking Fortran static library ../lib64/libconversion.a
[ 8%] Built target conversion
Scanning dependencies of target evolution
[ 12%] Building Fortran object src/evolution/CMakeFiles/evolution.dir/ancestors.f90.o
[ 16%] Building Fortran object src/evolution/CMakeFiles/evolution.dir/empty.f90.o
[ 20%] Building Fortran object src/evolution/CMakeFiles/evolution.dir/evolution.f90.o
[ 25%] Linking Fortran static library ../../lib64/libevolution.a
[ 25%] Built target evolution
Scanning dependencies of target initial
[ 29%] Building Fortran object src/initial/CMakeFiles/initial.dir/initial.f90.o
[ 33%] Linking Fortran static library ../../lib64/libinitial.a
[ 33%] Built target initial
Scanning dependencies of target io
[ 37%] Building Fortran object src/io/CMakeFiles/io.dir/io.f90.o
[ 41%] Linking Fortran static library ../../lib64/libio.a
[ 41%] Built target io
Scanning dependencies of target parser
[ 45%] Building Fortran object src/parser/CMakeFiles/parser.dir/parser.f90.o
[ 50%] Linking Fortran static library ../../lib64/libparser.a
[ 50%] Built target parser
Scanning dependencies of target example
[ 54%] Building Fortran object src/CMakeFiles/example.dir/__/external/conversion.f90.o
[ 58%] Building Fortran object src/CMakeFiles/example.dir/evolution/ancestors.f90.o
[ 62%] Building Fortran object src/CMakeFiles/example.dir/evolution/evolution.f90.o
[ 66%] Building Fortran object src/CMakeFiles/example.dir/initial/initial.f90.o
[ 70%] Building Fortran object src/CMakeFiles/example.dir/io/io.f90.o
[ 75%] Building Fortran object src/CMakeFiles/example.dir/parser/parser.f90.o
[ 79%] Building Fortran object src/CMakeFiles/example.dir/main.f90.o
[ 83%] Linking Fortran executable ../bin/example
[ 83%] Built target example
Scanning dependencies of target fortran_test
[ 87%] Building Fortran object tests/CMakeFiles/fortran_test.dir/__/src/evolution/ancestors.f90.o
[ 91%] Building Fortran object tests/CMakeFiles/fortran_test.dir/__/src/evolution/evolution.f90.o
[ 95%] Building Fortran object tests/CMakeFiles/fortran_test.dir/test.f90.o
[100%] Linking Fortran executable
  1. 最后,我们运行单元测试:
$ ctest

Running tests...
 Start 1: test_evolution
1/1 Test #1: test_evolution ................... Passed 0.00 sec

100% tests passed, 0 tests failed out of 1

它是如何工作的

按照第 7 个配方,使用add_subdirectory限制范围,我们将从下至上讨论 CMake 结构,从定义每个库的单独CMakeLists.txt文件开始,例如src/evolution/CMakeLists.txt

add_library(evolution "")

target_sources(evolution
  PRIVATE
    empty.f90
  PUBLIC
    ${CMAKE_CURRENT_LIST_DIR}/ancestors.f90
    ${CMAKE_CURRENT_LIST_DIR}/evolution.f90
  )

这些单独的CMakeLists.txt文件尽可能接近源文件定义库,遵循与前两个配方相同的推理:了解此库的代码开发人员,可能对 CMake 框架的了解有限,只需要编辑此目录中的文件:分而治之。

我们首先使用add_library定义库的名称,然后定义其源文件和包含目录,以及它们的目标可见性。在这种情况下,ancestors.f90evolution.f90都是PUBLIC,因为它们的模块接口被库外部访问,而empty.f90的模块接口没有被库外部访问,因此我们将此源文件标记为PRIVATE

向上移动一级,库在src/CMakeLists.txt中组装:

add_executable(automata main.f90)

add_subdirectory(evolution)
add_subdirectory(initial)
add_subdirectory(io)
add_subdirectory(parser)

target_link_libraries(automata
  PRIVATE
    conversion
    evolution
    initial
    io
    parser
  )

反过来,此文件在顶层的CMakeLists.txt中被引用。这意味着我们使用CMakeLists.txt文件的树构建了我们的项目库树,使用add_subdirectory添加。如第 7 个配方,使用add_subdirectory限制范围所述,这种方法可以扩展到大型项目,无需在目录之间携带源文件列表的全局变量,并且具有隔离作用域和命名空间的额外好处。

将此 Fortran 示例与 C++版本(配方 7)进行比较,我们可以注意到,在 Fortran 情况下,我们不得不做的 CMake 工作较少;我们不需要使用target_include_directories,因为没有头文件,接口是通过生成的 Fortran 模块文件进行通信的。此外,我们也不必担心源文件在target_sources中列出的顺序,也不必在库之间施加任何显式依赖关系!CMake 能够从源文件依赖关系中推断出 Fortran 模块依赖关系。结合使用target_sourcesPRIVATEPUBLIC,我们可以以紧凑且稳健的方式表达接口。

还有更多内容。

在本配方中,我们没有指定 Fortran 模块文件应放置的目录,并保持了这种透明性。可以通过设置CMAKE_Fortran_MODULE_DIRECTORY CMake 变量来指定模块文件的位置。请注意,也可以将其设置为目标属性,即Fortran_MODULE_DIRECTORY,从而实现更精细的控制。请参阅cmake.org/cmake/help/v3.5/prop_tgt/Fortran_MODULE_DIRECTORY.html

第九章:超级构建模式

在本章中,我们将涵盖以下内容:

  • 使用超级构建模式

  • 使用超级构建管理依赖:I. Boost 库

  • 使用超级构建管理依赖:II. FFTW 库

  • 使用超级构建管理依赖:III. Google Test 框架

  • 将项目作为超级构建进行管理

引言

每个项目都必须处理依赖关系,而 CMake 使得在配置项目的系统上查找这些依赖关系变得相对容易。第三章,检测外部库和程序,展示了如何在系统上找到已安装的依赖项,并且到目前为止我们一直使用相同的模式。然而,如果依赖关系未得到满足,我们最多只能导致配置失败并告知用户失败的原因。但是,使用 CMake,我们可以组织项目,以便在系统上找不到依赖项时自动获取和构建它们。本章将介绍和分析ExternalProject.cmakeFetchContent.cmake标准模块以及它们在超级构建模式中的使用。前者允许我们在构建时间获取项目的依赖项,并且长期以来一直是 CMake 的一部分。后者模块是在 CMake 3.11 版本中添加的,允许我们在配置时间获取依赖项。通过超级构建模式,我们可以有效地利用 CMake 作为高级包管理器:在您的项目中,您将以相同的方式处理依赖项,无论它们是否已经在系统上可用,或者它们是否需要从头开始构建。接下来的五个示例将引导您了解该模式,并展示如何使用它来获取和构建几乎任何依赖项。

两个模块都在网上有详尽的文档。对于ExternalProject.cmake,我们建议读者参考cmake.org/cmake/help/v3.5/module/ExternalProject.html。对于FetchContent.cmake,我们建议读者参考cmake.org/cmake/help/v3.11/module/FetchContent.html

使用超级构建模式

本示例的代码可在github.com/dev-cafe/cmake-cookbook/tree/v1.0/chapter-08/recipe-01找到,并包含一个 C++示例。该示例适用于 CMake 3.5(及以上)版本,并在 GNU/Linux、macOS 和 Windows 上进行了测试。

本示例将通过一个非常简单的示例介绍超级构建模式。我们将展示如何使用ExternalProject_Add命令来构建一个简单的“Hello, World”程序。

准备工作

本示例将构建以下源代码(hello-world.cpp)中的“Hello, World”可执行文件:

#include <cstdlib>
#include <iostream>
#include <string>

std::string say_hello() { return std::string("Hello, CMake superbuild world!"); }

int main() {
  std::cout << say_hello() << std::endl;
  return EXIT_SUCCESS;
}

项目结构如下,包含一个根目录CMakeLists.txt和一个src/CMakeLists.txt文件:

.
├── CMakeLists.txt
└── src
    ├── CMakeLists.txt
    └── hello-world.cpp

如何操作

首先让我们看一下根文件夹中的CMakeLists.txt

  1. 我们声明一个 C++11 项目,并指定最低要求的 CMake 版本:
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)

project(recipe-01 LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 11)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
  1. 我们为当前和任何底层目录设置EP_BASE目录属性。这将在稍后讨论:
set_property(DIRECTORY PROPERTY EP_BASE ${CMAKE_BINARY_DIR}/subprojects)
  1. 我们包含ExternalProject.cmake标准模块。该模块提供了ExternalProject_Add函数:
include(ExternalProject)
  1. 通过调用ExternalProject_Add函数,将我们的“Hello, World”示例的源代码作为外部项目添加。外部项目的名称为recipe-01_core
ExternalProject_Add(${PROJECT_NAME}_core
  1. 我们使用SOURCE_DIR选项设置外部项目的源目录:
SOURCE_DIR
${CMAKE_CURRENT_LIST_DIR}/src
  1. src子目录包含一个完整的 CMake 项目。为了配置和构建它,我们通过CMAKE_ARGS选项将适当的 CMake 选项传递给外部项目。在我们的情况下,我们只需要传递 C++编译器和对 C++标准的要求:
CMAKE_ARGS
  -DCMAKE_CXX_COMPILER=${CMAKE_CXX_COMPILER}
  -DCMAKE_CXX_STANDARD=${CMAKE_CXX_STANDARD}
  -DCMAKE_CXX_EXTENSIONS=${CMAKE_CXX_EXTENSIONS}
  -DCMAKE_CXX_STANDARD_REQUIRED=${CMAKE_CXX_STANDARD_REQUIRED}
  1. 我们还设置了 C++编译器标志。这些标志通过CMAKE_CACHE_ARGS选项传递给ExternalProject_Add命令:
CMAKE_CACHE_ARGS
  -DCMAKE_CXX_FLAGS:STRING=${CMAKE_CXX_FLAGS}
  1. 我们配置外部项目,使其始终处于构建状态:
BUILD_ALWAYS
  1
  1. 安装步骤不会执行任何操作(我们将在第 4 个配方中重新讨论安装,即“编写安装程序”中的“安装超级构建”):
INSTALL_COMMAND
  ""
)

现在让我们转向src/CMakeLists.txt。由于我们将“Hello, World”源代码作为外部项目添加,这是一个完整的CMakeLists.txt文件,用于独立项目:

  1. 同样,这里我们声明了最低要求的 CMake 版本:
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)
  1. 我们声明一个 C++项目:
project(recipe-01_core LANGUAGES CXX)
  1. 最后,我们从hello-world.cpp源文件添加一个可执行目标,即hello-world
add_executable(hello-world hello-world.cpp)

配置和构建我们的项目按照常规方式进行:

$ mkdir -p build
$ cmake ..
$ cmake --build .

构建目录的结构现在稍微复杂一些。特别是,我们注意到subprojects文件夹及其内容:

build/subprojects/
├── Build
│   └── recipe-01_core
│       ├── CMakeCache.txt
│       ├── CMakeFiles
│       ├── cmake_install.cmake
│       ├── hello-world
│       └── Makefile
├── Download
│   └── recipe-01_core
├── Install
│   └── recipe-01_core
├── Stamp
│   └── recipe-01_core
│       ├── recipe-01_core-configure
│       ├── recipe-01_core-done
│       ├── recipe-01_core-download
│       ├── recipe-01_core-install
│       ├── recipe-01_core-mkdir
│       ├── recipe-01_core-patch
│       └── recipe-01_core-update
└── tmp
    └── recipe-01_core
        ├── recipe-01_core-cache-.cmake
        ├── recipe-01_core-cfgcmd.txt
        └── recipe-01_core-cfgcmd.txt.in

recipe-01_core已构建到build/subprojects的子目录中,称为Build/recipe-01_core,这是我们设置的EP_BASE

hello-world可执行文件已在Build/recipe-01_core下创建。额外的子文件夹tmp/recipe-01_coreStamp/recipe-01_core包含临时文件,例如 CMake 缓存脚本recipe-01_core-cache-.cmake,以及 CMake 为构建外部项目执行的各种步骤的标记文件。

它是如何工作的

ExternalProject_Add命令可用于添加第三方源代码。然而,我们的第一个示例展示了如何将我们自己的项目作为不同 CMake 项目的集合来管理。在这个示例中,根目录和叶目录的CMakeLists.txt都声明了一个 CMake 项目,即它们都使用了project命令。

ExternalProject_Add有许多选项,可用于微调外部项目的配置和编译的所有方面。这些选项可以分为以下几类:

  • 目录选项:这些用于调整外部项目的源代码和构建目录的结构。在我们的例子中,我们使用了 SOURCE_DIR 选项让 CMake 知道源代码可在 ${CMAKE_CURRENT_LIST_DIR}/src 文件夹中找到,因此不应从其他地方获取。构建项目和存储临时文件的目录也可以在此类选项中指定,或者作为目录属性指定。我们通过设置 EP_BASE 目录属性遵循了后者的方式。CMake 将为各种子项目设置所有目录,布局如下:
TMP_DIR      = <EP_BASE>/tmp/<name>
STAMP_DIR    = <EP_BASE>/Stamp/<name>
DOWNLOAD_DIR = <EP_BASE>/Download/<name>
SOURCE_DIR   = <EP_BASE>/Source/<name>
BINARY_DIR   = <EP_BASE>/Build/<name>
INSTALL_DIR  = <EP_BASE>/Install/<name>
  • 下载选项:外部项目的代码可能需要从在线存储库或资源下载。此类选项允许您控制此步骤的所有方面。

  • 更新补丁选项:这类选项可用于定义如何更新外部项目的源代码或如何应用补丁。

  • 配置选项:默认情况下,CMake 假设外部项目本身使用 CMake 进行配置。然而,正如后续章节将展示的,我们并不局限于这种情况。如果外部项目是 CMake 项目,ExternalProject_Add 将调用 CMake 可执行文件并传递选项给它。对于我们当前的示例,我们通过 CMAKE_ARGSCMAKE_CACHE_ARGS 选项传递配置参数。前者直接作为命令行参数传递,而后者通过 CMake 脚本文件传递。在我们的示例中,脚本文件位于 build/subprojects/tmp/recipe-01_core/recipe-01_core-cache-.cmake。配置将如下所示:

$ cmake -DCMAKE_CXX_COMPILER=g++ -DCMAKE_CXX_STANDARD=11 
-DCMAKE_CXX_EXTENSIONS=OFF -DCMAKE_CXX_STANDARD_REQUIRED=ON 
-C/home/roberto/Workspace/robertodr/cmake-cookbook/chapter-08/recipe-01/cxx-example/build/subprojects/tmp/recipe-01_core/recipe-01_core-cache-.cmake "-GUnix Makefiles" /home/roberto/Workspace/robertodr/cmake-cookbook/chapter-08/recipe-01/cxx-example/src
  • 构建选项:这类选项可用于调整外部项目的实际编译。我们的示例使用了 BUILD_ALWAYS 选项以确保外部项目总是被新鲜构建。

  • 安装选项:这些是配置外部项目应如何安装的选项。我们的示例将 INSTALL_COMMAND 留空,我们将在 第十章,编写安装程序中更详细地讨论使用 CMake 进行安装。

  • 测试选项:对于从源代码构建的任何软件,运行测试总是一个好主意。ExternalProject_Add 的这类选项就是为了这个目的。我们的示例没有使用这些选项,因为“Hello, World”示例没有任何测试,但在第五章,将您的项目作为超级构建管理中,我们将触发测试步骤。

ExternalProject.cmake 定义了命令 ExternalProject_Get_Property,顾名思义,这对于检索外部项目的属性非常有用。外部项目的属性在首次调用 ExternalProject_Add 命令时设置。例如,检索配置 recipe-01_core 时传递给 CMake 的参数可以通过以下方式实现:

ExternalProject_Get_Property(${PROJECT_NAME}_core CMAKE_ARGS)
message(STATUS "CMAKE_ARGS of ${PROJECT_NAME}_core ${CMAKE_ARGS}")

ExternalProject_Add的完整选项列表可以在 CMake 文档中找到:cmake.org/cmake/help/v3.5/module/ExternalProject.html#command:externalproject_add

还有更多

我们将在以下配方中详细探讨ExternalProject_Add命令的灵活性。然而,有时我们想要使用的外部项目可能需要执行额外的、非标准的步骤。为此,ExternalProject.cmake模块定义了以下附加命令:

  1. ExternalProject_Add_Step。一旦添加了外部项目,此命令允许将附加命令作为自定义步骤附加到该项目上。另请参见:cmake.org/cmake/help/v3.5/module/ExternalProject.html#command:externalproject_add_step

  2. ExternalProject_Add_StepTargets。它允许您在任何外部项目中定义步骤,例如构建和测试步骤,作为单独的目标。这意味着可以从完整的外部项目中单独触发这些步骤,并允许对项目内的复杂依赖关系进行精细控制。另请参见:cmake.org/cmake/help/v3.5/module/ExternalProject.html#command:externalproject_add_steptargets

  3. ExternalProject_Add_StepDependencies。有时外部项目的步骤可能依赖于项目之外的目标,此命令旨在处理这些情况。另请参见:cmake.org/cmake/help/v3.5/module/ExternalProject.html#command:externalproject_add_stepdependencies

使用超级构建管理依赖项:I. Boost 库

本配方的代码可在github.com/dev-cafe/cmake-cookbook/tree/v1.0/chapter-08/recipe-02 获取,并包含一个 C++示例。该配方适用于 CMake 版本 3.5(及更高版本),并在 GNU/Linux、macOS、Windows(使用 MSYS Makefiles 和 Ninja)上进行了测试。

Boost 库提供了丰富的 C++编程基础设施,并且受到 C++开发者的欢迎。我们已经在第三章,检测外部库和程序中展示了如何在系统上找到 Boost 库。然而,有时您的项目所需的 Boost 版本可能不在系统上。本食谱将展示如何利用超级构建模式来确保缺少的依赖不会阻止配置。我们将重用来自第三章,检测外部库和程序中第 8 个食谱,检测 Boost 库的代码示例,但将其重新组织为超级构建的形式。这将是项目的布局:

.
├── CMakeLists.txt
├── external
│   └── upstream
│       ├── boost
│       │   └── CMakeLists.txt
│       └── CMakeLists.txt
└── src
    ├── CMakeLists.txt
    └── path-info.cpp

您会注意到项目源代码树中有四个CMakeLists.txt文件。以下部分将引导您了解这些文件。

如何操作

我们将从根CMakeLists.txt开始:

  1. 我们像往常一样声明一个 C++11 项目:
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)

project(recipe-02 LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 11)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
  1. 我们设置EP_BASE目录属性:
set_property(DIRECTORY PROPERTY EP_BASE ${CMAKE_BINARY_DIR}/subprojects)
  1. 我们设置STAGED_INSTALL_PREFIX变量。该目录将用于在我们的构建树中安装依赖项:
set(STAGED_INSTALL_PREFIX ${CMAKE_BINARY_DIR}/stage)
message(STATUS "${PROJECT_NAME} staged install: ${STAGED_INSTALL_PREFIX}")
  1. 我们的项目需要 Boost 库的文件系统和系统组件。我们声明一个列表变量来保存此信息,并设置所需的最小 Boost 版本:
list(APPEND BOOST_COMPONENTS_REQUIRED filesystem system)
set(Boost_MINIMUM_REQUIRED 1.61)
  1. 我们添加external/upstream子目录,它将依次添加external/upstream/boost子目录:
add_subdirectory(external/upstream)
  1. 然后,我们包含ExternalProject.cmake标准 CMake 模块。这定义了,除其他外,ExternalProject_Add命令,这是协调超级构建的关键:
include(ExternalProject)
  1. 我们的项目位于src子目录下,并将其作为外部项目添加。我们使用CMAKE_ARGSCMAKE_CACHE_ARGS传递 CMake 选项:
ExternalProject_Add(${PROJECT_NAME}_core
  DEPENDS
    boost_external
  SOURCE_DIR
    ${CMAKE_CURRENT_LIST_DIR}/src
  CMAKE_ARGS
    -DCMAKE_CXX_COMPILER=${CMAKE_CXX_COMPILER}
    -DCMAKE_CXX_STANDARD=${CMAKE_CXX_STANDARD}
    -DCMAKE_CXX_EXTENSIONS=${CMAKE_CXX_EXTENSIONS}
    -DCMAKE_CXX_STANDARD_REQUIRED=${CMAKE_CXX_STANDARD_REQUIRED}
  CMAKE_CACHE_ARGS
    -DCMAKE_CXX_FLAGS:STRING=${CMAKE_CXX_FLAGS}
    -DCMAKE_INCLUDE_PATH:PATH=${BOOST_INCLUDEDIR}
    -DCMAKE_LIBRARY_PATH:PATH=${BOOST_LIBRARYDIR}
  BUILD_ALWAYS
    1
  INSTALL_COMMAND
    ""
  )

现在让我们看看external/upstream中的CMakeLists.txt文件。该文件只是将boost文件夹添加为附加目录:

add_subdirectory(boost)

external/upstream/boost中的CMakeLists.txt描述了满足对 Boost 依赖所需的操作。我们的目标很简单,如果所需版本未安装,下载源代码存档并构建它:

  1. 首先,我们尝试找到所需的最小版本的 Boost 组件:
find_package(Boost ${Boost_MINIMUM_REQUIRED} QUIET COMPONENTS "${BOOST_COMPONENTS_REQUIRED}")
  1. 如果找到这些选项,我们会添加一个接口库,boost_external。这是一个虚拟目标,用于在我们的超级构建中正确处理构建顺序:
if(Boost_FOUND)
  message(STATUS "Found Boost version ${Boost_MAJOR_VERSION}.${Boost_MINOR_VERSION}.${Boost_SUBMINOR_VERSION}")
  add_library(boost_external INTERFACE)
else()    
  # ... discussed below
endif()
  1. 如果find_package不成功或者我们强制进行超级构建,我们需要设置一个本地的 Boost 构建,为此,我们进入前一个条件语句的 else 部分:
else()
  message(STATUS "Boost ${Boost_MINIMUM_REQUIRED} could not be located, Building Boost 1.61.0 instead.")
  1. 由于这些库不使用 CMake,我们需要为它们的原生构建工具链准备参数。首先,我们设置要使用的编译器:
  if(CMAKE_CXX_COMPILER_ID MATCHES "GNU")
    if(APPLE)
      set(_toolset "darwin")
    else()
      set(_toolset "gcc")
    endif()
  elseif(CMAKE_CXX_COMPILER_ID MATCHES ".*Clang")
    set(_toolset "clang")
  elseif(CMAKE_CXX_COMPILER_ID MATCHES "Intel")
    if(APPLE)
      set(_toolset "intel-darwin")
    else()
      set(_toolset "intel-linux")
    endif()
  endif()
  1. 我们根据所需组件准备要构建的库列表。我们定义了一些列表变量:_build_byproducts,用于包含将要构建的库的绝对路径;_b2_select_libraries,用于包含我们想要构建的库列表;以及_bootstrap_select_libraries,这是一个内容相同但格式不同的字符串:
  if(NOT "${BOOST_COMPONENTS_REQUIRED}" STREQUAL "")
    # Replace unit_test_framework (used by CMake's find_package) with test (understood by Boost build toolchain)
    string(REPLACE "unit_test_framework" "test" _b2_needed_components "${BOOST_COMPONENTS_REQUIRED}")
    # Generate argument for BUILD_BYPRODUCTS
    set(_build_byproducts)
    set(_b2_select_libraries)
    foreach(_lib IN LISTS _b2_needed_components)
      list(APPEND _build_byproducts ${STAGED_INSTALL_PREFIX}/boost/lib/libboost_${_lib}${CMAKE_SHARED_LIBRARY_SUFFIX})
      list(APPEND _b2_select_libraries --with-${_lib})
    endforeach()
    # Transform the ;-separated list to a ,-separated list (digested by the Boost build toolchain!)
    string(REPLACE ";" "," _b2_needed_components "${_b2_needed_components}")
    set(_bootstrap_select_libraries "--with-libraries=${_b2_needed_components}")
    string(REPLACE ";" ", " printout "${BOOST_COMPONENTS_REQUIRED}")
    message(STATUS "  Libraries to be built: ${printout}")
  endif()
  1. 我们现在可以将 Boost 项目作为外部项目添加。首先,我们在下载选项类中指定下载 URL 和校验和。将DOWNLOAD_NO_PROGRESS设置为1以抑制打印下载进度信息:
include(ExternalProject)
ExternalProject_Add(boost_external
  URL
    https://sourceforge.net/projects/boost/files/boost/1.61.0/boost_1_61_0.zip
  URL_HASH
    SHA256=02d420e6908016d4ac74dfc712eec7d9616a7fc0da78b0a1b5b937536b2e01e8
  DOWNLOAD_NO_PROGRESS
    1
  1. 接下来,我们设置更新/修补配置选项:
 UPDATE_COMMAND
   ""
 CONFIGURE_COMMAND
   <SOURCE_DIR>/bootstrap.sh
     --with-toolset=${_toolset}
     --prefix=${STAGED_INSTALL_PREFIX}/boost
     ${_bootstrap_select_libraries}
  1. 使用BUILD_COMMAND指令设置构建选项。将BUILD_IN_SOURCE设置为1以指示构建将在源目录内发生。此外,我们将LOG_BUILD设置为1以将构建脚本的输出记录到文件中:
  BUILD_COMMAND
    <SOURCE_DIR>/b2 -q
         link=shared
         threading=multi
         variant=release
         toolset=${_toolset}
         ${_b2_select_libraries}
  LOG_BUILD
    1
  BUILD_IN_SOURCE
    1
  1. 使用INSTALL_COMMAND指令设置安装选项。注意使用LOG_INSTALL选项也将安装步骤记录到文件中:
  INSTALL_COMMAND
    <SOURCE_DIR>/b2 -q install
         link=shared
         threading=multi
         variant=release
         toolset=${_toolset}
         ${_b2_select_libraries}
  LOG_INSTALL
    1
  1. 最后,我们将我们的库列为BUILD_BYPRODUCTS并关闭ExternalProject_Add命令:
  BUILD_BYPRODUCTS
    "${_build_byproducts}"
  )
  1. 我们设置了一些对指导新安装的 Boost 检测有用的变量:
set(
  BOOST_ROOT ${STAGED_INSTALL_PREFIX}/boost
  CACHE PATH "Path to internally built Boost installation root"
  FORCE
  )
set(
  BOOST_INCLUDEDIR ${BOOST_ROOT}/include
  CACHE PATH "Path to internally built Boost include directories"
  FORCE
  )
set(
  BOOST_LIBRARYDIR ${BOOST_ROOT}/lib
  CACHE PATH "Path to internally built Boost library directories"
  FORCE
  )
  1. 在条件分支的最后执行的操作是取消设置所有内部变量:
  unset(_toolset)
  unset(_b2_needed_components)
  unset(_build_byproducts)
  unset(_b2_select_libraries)
  unset(_boostrap_select_libraries)

最后,让我们看看src/CMakeLists.txt。该文件描述了一个独立项目:

  1. 我们声明一个 C++项目:
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)

project(recipe-02_core LANGUAGES CXX)
  1. 项目依赖于 Boost,我们调用find_package。从根目录的CMakeLists.txt配置项目保证了依赖项始终得到满足,无论是使用系统上预装的 Boost 还是我们作为子项目构建的 Boost:
find_package(Boost 1.61 REQUIRED COMPONENTS filesystem)
  1. 我们添加我们的示例可执行目标,描述其链接库:
add_executable(path-info path-info.cpp)

target_link_libraries(path-info
  PUBLIC
    Boost::filesystem
  )

虽然导入目标的使用很整洁,但并不能保证对任意 Boost 和 CMake 版本组合都能正常工作。这是因为 CMake 的FindBoost.cmake模块手动创建了导入目标,所以如果 CMake 发布时不知道 Boost 版本,将会有Boost_LIBRARIESBoost_INCLUDE_DIRS,但没有导入目标(另请参见stackoverflow.com/questions/42123509/cmake-finds-boost-but-the-imported-targets-not-available-for-boost-version)。

工作原理

本食谱展示了如何利用超级构建模式来集结项目的依赖项。让我们再次审视项目的布局:

.
├── CMakeLists.txt
├── external
│   └── upstream
│       ├── boost
│       │   └── CMakeLists.txt
│       └── CMakeLists.txt
└── src
    ├── CMakeLists.txt
    └── path-info.cpp

我们在项目源树中引入了四个CMakeLists.txt文件:

  1. 根目录的CMakeLists.txt将协调超级构建。

  2. 位于external/upstream的文件将引导我们到boost叶目录。

  3. external/upstream/boost/CMakeLists.txt将负责处理 Boost 依赖项。

  4. 最后,位于src下的CMakeLists.txt将构建我们的示例代码,该代码依赖于 Boost。

让我们从external/upstream/boost/CMakeLists.txt文件开始讨论。Boost 使用自己的构建系统,因此我们需要在ExternalProject_Add中稍微详细一些,以确保一切正确设置:

  1. 我们保留目录选项的默认值。

  2. 下载步骤将从 Boost 的在线服务器下载所需版本的存档。因此,我们设置了URLURL_HASH。后者用于检查下载存档的完整性。由于我们不希望看到下载的进度报告,我们还设置了DOWNLOAD_NO_PROGRESS选项为 true。

  3. 更新步骤留空。如果需要重新构建,我们不希望再次下载 Boost。

  4. 配置步骤将使用 Boost 提供的本地配置工具,在CONFIGURE_COMMAND中。由于我们希望超级构建是跨平台的,我们使用<SOURCE_DIR>变量来引用解压源代码的位置:

CONFIGURE_COMMAND
  <SOURCE_DIR>/bootstrap.sh
  --with-toolset=${_toolset}
  --prefix=${STAGED_INSTALL_PREFIX}/boost
  ${_bootstrap_select_libraries}
  1. 构建选项声明了一个源码内构建,通过将BUILD_IN_SOURCE选项设置为 true。BUILD_COMMAND使用 Boost 的本地构建工具b2。由于我们将进行源码内构建,我们再次使用<SOURCE_DIR>变量来引用解压源代码的位置。

  2. 接下来,我们转向安装选项。Boost 使用相同的本地构建工具进行管理。实际上,构建和安装命令可以很容易地合并为一个。

  3. 输出日志选项LOG_BUILDLOG_INSTALL指示ExternalProject_Add为构建和安装操作编写日志文件,而不是输出到屏幕。

  4. 最后,BUILD_BYPRODUCTS选项允许ExternalProject_Add在后续构建中跟踪新近构建的 Boost 库,即使它们的修改时间可能不会更新。

Boost 构建完成后,构建目录中的${STAGED_INSTALL_PREFIX}/boost文件夹将包含我们所需的库。我们需要将此信息传递给我们的项目,其构建系统在src/CMakeLists.txt中生成。为了实现这一目标,我们在根CMakeLists.txt中的ExternalProject_Add中传递两个额外的CMAKE_CACHE_ARGS

  1. CMAKE_INCLUDE_PATH:CMake 查找 C/C++头文件的路径

  2. CMAKE_LIBRARY_PATH:CMake 查找库的路径

通过将这些变量设置为我们新近构建的 Boost 安装,我们确保依赖项将被正确地检测到。

在配置项目时将CMAKE_DISABLE_FIND_PACKAGE_Boost设置为ON,将跳过 Boost 库的检测并始终执行超级构建。请参阅文档:cmake.org/cmake/help/v3.5/variable/CMAKE_DISABLE_FIND_PACKAGE_PackageName.html

使用超级构建管理依赖项:II. FFTW 库

本示例的代码可在github.com/dev-cafe/cmake-cookbook/tree/v1.0/chapter-08/recipe-03找到,并包含一个 C 语言示例。该示例适用于 CMake 版本 3.5(及以上),并在 GNU/Linux、macOS 和 Windows 上进行了测试。

超级构建模式可用于管理 CMake 支持的所有语言项目的相当复杂的依赖关系。如前一示例所示,各个子项目并非必须由 CMake 管理。与前一示例相反,本示例中的外部子项目将是一个 CMake 项目,并将展示如何使用超级构建下载、构建和安装 FFTW 库。FFTW 是一个快速傅里叶变换库,可免费在www.fftw.org获取。

准备就绪

本示例的目录布局展示了超级构建的熟悉结构:

.
├── CMakeLists.txt
├── external
│   └── upstream
│       ├── CMakeLists.txt
│       └── fftw3
│           └── CMakeLists.txt
└── src
    ├── CMakeLists.txt
    └── fftw_example.c

我们项目的代码fftw_example.c位于src子目录中,并将计算源代码中定义的函数的傅里叶变换。

如何操作

让我们从根CMakeLists.txt开始。此文件组合了整个超级构建过程:

  1. 我们声明一个 C99 项目:
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)

project(recipe-03 LANGUAGES C)

set(CMAKE_C_STANDARD 99)
set(CMAKE_C_EXTENSIONS OFF)
set(CMAKE_C_STANDARD_REQUIRED ON)
  1. 与前一示例一样,我们设置EP_BASE目录属性和暂存安装前缀:
set_property(DIRECTORY PROPERTY EP_BASE ${CMAKE_BINARY_DIR}/subprojects)

set(STAGED_INSTALL_PREFIX ${CMAKE_BINARY_DIR}/stage)
message(STATUS "${PROJECT_NAME} staged install: ${STAGED_INSTALL_PREFIX}")
  1. FFTW 的依赖关系在external/upstream子目录中进行检查,我们继续将此子目录添加到构建系统中:
add_subdirectory(external/upstream)
  1. 我们包含ExternalProject.cmake模块:
include(ExternalProject)
  1. 我们声明recipe-03_core外部项目。该项目的源代码位于${CMAKE_CURRENT_LIST_DIR}/src文件夹中。该项目设置为使用FFTW3_DIR选项选择正确的 FFTW 库:
ExternalProject_Add(${PROJECT_NAME}_core
  DEPENDS
    fftw3_external
  SOURCE_DIR
    ${CMAKE_CURRENT_LIST_DIR}/src
  CMAKE_ARGS
    -DFFTW3_DIR=${FFTW3_DIR}
    -DCMAKE_C_STANDARD=${CMAKE_C_STANDARD}
    -DCMAKE_C_EXTENSIONS=${CMAKE_C_EXTENSIONS}
    -DCMAKE_C_STANDARD_REQUIRED=${CMAKE_C_STANDARD_REQUIRED}
  CMAKE_CACHE_ARGS
    -DCMAKE_C_FLAGS:STRING=${CMAKE_C_FLAGS}
    -DCMAKE_PREFIX_PATH:PATH=${CMAKE_PREFIX_PATH}
  BUILD_ALWAYS
    1
  INSTALL_COMMAND
    ""
  )

external/upstream子目录中还包含一个CMakeLists.txt

  1. 在此文件中,我们将fftw3文件夹添加为构建系统中的另一个子目录:
add_subdirectory(fftw3)

external/upstream/fftw3中的CMakeLists.txt负责我们的依赖关系:

  1. 首先,我们尝试在系统上查找 FFTW3 库。请注意,我们使用了find_packageCONFIG参数:
find_package(FFTW3 CONFIG QUIET)
  1. 如果找到了库,我们可以使用导入的目标FFTW3::fftw3与之链接。我们向用户打印一条消息,显示库的位置。我们添加一个虚拟的INTERFACEfftw3_external。这在超级构建中子项目之间的依赖树正确修复时是必需的:
find_package(FFTW3 CONFIG QUIET)

if(FFTW3_FOUND)
  get_property(_loc TARGET FFTW3::fftw3 PROPERTY LOCATION)
  message(STATUS "Found FFTW3: ${_loc} (found version ${FFTW3_VERSION})")
  add_library(fftw3_external INTERFACE) # dummy
else()
  # this branch will be discussed below
endif()
  1. 如果 CMake 无法找到预安装的 FFTW 版本,我们进入条件语句的 else 分支,在其中我们使用ExternalProject_Add下载、构建和安装它。外部项目的名称为fftw3_externalfftw3_external项目将从官方在线档案下载。下载的完整性将使用 MD5 校验和进行检查:
message(STATUS "Suitable FFTW3 could not be located. Downloading and building!")

include(ExternalProject)
ExternalProject_Add(fftw3_external
  URL
    http://www.fftw.org/fftw-3.3.8.tar.gz
  URL_HASH
    MD5=8aac833c943d8e90d51b697b27d4384d
  1. 我们禁用下载的进度打印,并将更新命令定义为空:
  DOWNLOAD_NO_PROGRESS
    1
  UPDATE_COMMAND
    ""
  1. 配置、构建和安装输出将被记录到文件中:
  LOG_CONFIGURE
    1
  LOG_BUILD
    1
  LOG_INSTALL
    1
  1. 我们将fftw3_external项目的安装前缀设置为之前定义的STAGED_INSTALL_PREFIX目录,并关闭 FFTW3 的测试套件构建:
  CMAKE_ARGS
    -DCMAKE_INSTALL_PREFIX=${STAGED_INSTALL_PREFIX}
    -DBUILD_TESTS=OFF
  1. 如果我们在 Windows 上构建,我们通过生成表达式设置WITH_OUR_MALLOC预处理器选项,并关闭ExternalProject_Add命令:
  CMAKE_CACHE_ARGS
    -DCMAKE_C_FLAGS:STRING=$<$<BOOL:WIN32>:-DWITH_OUR_MALLOC>
  )
  1. 最后,我们定义了FFTW3_DIR变量并将其缓存。该变量将由 CMake 用作导出的FFTW3::fftw3目标的搜索目录:
include(GNUInstallDirs)

set(
  FFTW3_DIR ${STAGED_INSTALL_PREFIX}/${CMAKE_INSTALL_LIBDIR}/cmake/fftw3
  CACHE PATH "Path to internally built FFTW3Config.cmake"
  FORCE
  )

位于src文件夹中的CMakeLists.txt文件相当简洁:

  1. 同样在这个文件中,我们声明了一个 C 项目:
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)

project(recipe-03_core LANGUAGES C)
  1. 我们调用find_package来检测 FFTW 库。再次使用CONFIG检测模式:
find_package(FFTW3 CONFIG REQUIRED)
get_property(_loc TARGET FFTW3::fftw3 PROPERTY LOCATION)
message(STATUS "Found FFTW3: ${_loc} (found version ${FFTW3_VERSION})")
  1. 我们将fftw_example.c源文件添加到可执行目标fftw_example中:
add_executable(fftw_example fftw_example.c)
  1. 我们为目标可执行文件设置链接库:
target_link_libraries(fftw_example
  PRIVATE
    FFTW3::fftw3
  )

工作原理

本示例展示了如何下载、构建和安装由 CMake 管理的构建系统的外部项目。与之前的示例不同,那里必须使用自定义构建系统,这种超级构建设置相对简洁。值得注意的是,find_package命令使用了CONFIG选项;这告诉 CMake 首先查找FFTW3Config.cmake文件以定位 FFTW3 库。这样的文件将库作为目标导出,供第三方项目使用。目标包含版本、配置和库的位置,即有关目标如何配置和构建的完整信息。如果系统上未安装该库,我们需要告诉 CMakeFFTW3Config.cmake文件的位置。这可以通过设置FFTW3_DIR变量来完成。这是在external/upstream/fftw3/CMakeLists.txt文件的最后一步,通过使用GNUInstallDirs.cmake模块,我们将FFTW3_DIR设置为缓存变量,以便稍后在超级构建中被拾取。

在配置项目时将CMAKE_DISABLE_FIND_PACKAGE_FFTW3设置为ON,将跳过 FFTW 库的检测并始终执行超级构建。请参阅文档:cmake.org/cmake/help/v3.5/variable/CMAKE_DISABLE_FIND_PACKAGE_PackageName.html

使用超级构建管理依赖项:III. Google Test 框架

本示例的代码可在github.com/dev-cafe/cmake-cookbook/tree/v1.0/chapter-08/recipe-04找到,并包含一个 C++示例。该示例适用于 CMake 版本 3.11(及以上),并在 GNU/Linux、macOS 和 Windows 上进行了测试。代码仓库还包含一个与 CMake 3.5 兼容的示例。

在第四章,创建和运行测试,第 3 个菜谱,定义单元测试并链接到 Google Test,我们使用 Google Test 框架实现了单元测试,并在配置时使用相对较新的FetchContent模块(自 CMake 3.11 起可用)获取了 Google Test 源码。在本章中,我们将重温这个菜谱,减少对测试方面的关注,并深入探讨FetchContent,它提供了一个紧凑且多功能的模块,用于在配置时组装项目依赖。为了获得更多见解,以及对于 CMake 3.11 以下的版本,我们还将讨论如何使用ExternalProject_Add 在配置时模拟FetchContent

准备工作

在本菜谱中,我们将构建并测试与第四章,创建和运行测试,第 3 个菜谱,定义单元测试并链接到 Google Test中相同的源文件,main.cppsum_integers.cppsum_integers.hpptest.cpp。我们将使用FetchContentExternalProject_Add在配置时下载所有必需的 Google Test 源码,并且在本菜谱中只关注在配置时获取依赖,而不是实际的源码及其单元测试。

如何操作

在本菜谱中,我们将只关注如何获取 Google Test 源码以构建gtest_main目标。关于如何使用该目标测试示例源码的讨论,我们请读者参考第四章,创建和运行测试,第 3 个菜谱,定义单元测试并链接到 Google Test

  1. 我们首先包含FetchContent模块,它将提供我们所需的函数来声明、查询和填充依赖:
include(FetchContent)
  1. 接着,我们声明内容——其名称、仓库位置以及要获取的确切版本:
FetchContent_Declare(
  googletest
  GIT_REPOSITORY https://github.com/google/googletest.git
  GIT_TAG release-1.8.0
)
  1. 然后我们查询内容是否已经被获取/填充:
FetchContent_GetProperties(googletest)
  1. 之前的函数调用定义了googletest_POPULATED。如果内容尚未填充,我们将获取内容并配置子项目:
if(NOT googletest_POPULATED)
  FetchContent_Populate(googletest)

  # ...

  # adds the targets: gtest, gtest_main, gmock, gmock_main
  add_subdirectory(
    ${googletest_SOURCE_DIR}
    ${googletest_BINARY_DIR}
    )

  # ...

endif()
  1. 注意内容是在配置时获取的:
$ mkdir -p build
$ cd build
$ cmake ..
  1. 这将生成以下构建目录树。Google Test 源码现在已就位,可以由 CMake 处理并提供所需的目标:
build/
├── ...
├── _deps
│   ├── googletest-build
│   │   ├── ...
│   │   └── ...
│   ├── googletest-src
│   │   ├── ...
│   │   └── ...
│   └── googletest-subbuild
│       ├── ...
│       └── ...
└── ...

它是如何工作的

FetchContent模块允许在配置时填充内容。在我们的例子中,我们获取了一个带有明确 Git 标签的 Git 仓库:

FetchContent_Declare(
  googletest
  GIT_REPOSITORY https://github.com/google/googletest.git
  GIT_TAG release-1.8.0
)

FetchContent模块支持通过ExternalProject模块支持的任何方法获取内容 - 换句话说,通过Subversion、Mercurial、CVS 或 HTTP(S)。内容名称“googletest”是我们的选择,有了这个,我们将能够在查询其属性、填充目录以及稍后配置子项目时引用内容。在填充项目之前,我们检查内容是否已经获取,否则如果FetchContent_Populate()被调用超过一次,它将抛出错误:

if(NOT googletest_POPULATED)
  FetchContent_Populate(googletest)

  # ...

endif()

只有在那时我们才配置了子目录,我们可以通过googletest_SOURCE_DIRgoogletest_BINARY_DIR变量来引用它。这些变量是由FetchContent_Populate(googletest)设置的,并根据我们在声明内容时给出的项目名称构建的。

add_subdirectory(
  ${googletest_SOURCE_DIR}
  ${googletest_BINARY_DIR}
  )

FetchContent模块有许多选项(参见cmake.org/cmake/help/v3.11/module/FetchContent.html),这里我们可以展示一个:如何更改外部项目将被放置的默认路径。之前,我们看到默认情况下内容被保存到${CMAKE_BINARY_DIR}/_deps。我们可以通过设置FETCHCONTENT_BASE_DIR来更改此位置:

set(FETCHCONTENT_BASE_DIR ${CMAKE_BINARY_DIR}/custom)

FetchContent_Declare(
  googletest
  GIT_REPOSITORY https://github.com/google/googletest.git
  GIT_TAG release-1.8.0
)

FetchContent已成为 CMake 3.11 版本中的标准部分。在下面的代码中,我们将尝试在配置时间使用ExternalProject_Add来模拟FetchContent。这不仅对旧版本的 CMake 实用,而且有望让我们更深入地了解FetchContent层下面发生的事情,并提供一个有趣的替代方案,以替代使用ExternalProject_Add在构建时间获取项目的典型方式。我们的目标是编写一个fetch_git_repo宏,并将其放置在fetch_git_repo.cmake中,以便我们可以这样获取内容:

include(fetch_git_repo.cmake)

fetch_git_repo(
  googletest
  ${CMAKE_BINARY_DIR}/_deps
  https://github.com/google/googletest.git
  release-1.8.0
)

# ...

# adds the targets: gtest, gtest_main, gmock, gmock_main
add_subdirectory(
  ${googletest_SOURCE_DIR}
  ${googletest_BINARY_DIR}
  )

# ...

这感觉类似于使用FetchContent。在幕后,我们将使用ExternalProject_Add。现在让我们揭开盖子,检查fetch_git_repofetch_git_repo.cmake中的定义:

macro(fetch_git_repo _project_name _download_root _git_url _git_tag)

  set(${_project_name}_SOURCE_DIR ${_download_root}/${_project_name}-src)
  set(${_project_name}_BINARY_DIR ${_download_root}/${_project_name}-build)

  # variables used configuring fetch_git_repo_sub.cmake
  set(FETCH_PROJECT_NAME ${_project_name})
  set(FETCH_SOURCE_DIR ${${_project_name}_SOURCE_DIR})
  set(FETCH_BINARY_DIR ${${_project_name}_BINARY_DIR})
  set(FETCH_GIT_REPOSITORY ${_git_url})
  set(FETCH_GIT_TAG ${_git_tag})

  configure_file(
    ${CMAKE_CURRENT_LIST_DIR}/fetch_at_configure_step.in
    ${_download_root}/CMakeLists.txt
    @ONLY
    )

  # undefine them again
  unset(FETCH_PROJECT_NAME)
  unset(FETCH_SOURCE_DIR)
  unset(FETCH_BINARY_DIR)
  unset(FETCH_GIT_REPOSITORY)
  unset(FETCH_GIT_TAG)

  # configure sub-project
  execute_process(
    COMMAND
      "${CMAKE_COMMAND}" -G "${CMAKE_GENERATOR}" .
    WORKING_DIRECTORY
      ${_download_root}
    )
  # build sub-project which triggers ExternalProject_Add
  execute_process(
    COMMAND
      "${CMAKE_COMMAND}" --build .
    WORKING_DIRECTORY
      ${_download_root}
    )
endmacro()

宏接收项目名称、下载根目录、Git 仓库 URL 和 Git 标签。宏定义了${_project_name}_SOURCE_DIR${_project_name}_BINARY_DIR,我们使用宏而不是函数,因为${_project_name}_SOURCE_DIR${_project_name}_BINARY_DIR需要在fetch_git_repo的作用域之外存活,因为我们稍后在主作用域中使用它们来配置子目录:

add_subdirectory(
  ${googletest_SOURCE_DIR}
  ${googletest_BINARY_DIR}
  )

fetch_git_repo宏内部,我们希望使用ExternalProject_Add配置时间获取外部项目,我们通过一个三步的技巧来实现这一点:

  1. 首先,我们配置fetch_at_configure_step.in
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)

project(fetch_git_repo_sub LANGUAGES NONE)

include(ExternalProject)

ExternalProject_Add(
  @FETCH_PROJECT_NAME@
  SOURCE_DIR "@FETCH_SOURCE_DIR@"
  BINARY_DIR "@FETCH_BINARY_DIR@"
  GIT_REPOSITORY
    @FETCH_GIT_REPOSITORY@
  GIT_TAG
    @FETCH_GIT_TAG@
  CONFIGURE_COMMAND ""
  BUILD_COMMAND ""
  INSTALL_COMMAND ""
  TEST_COMMAND ""
  )

使用configure_file,我们生成一个CMakeLists.txt文件,其中之前的占位符被替换为在fetch_git_repo.cmake中定义的值。注意,之前的ExternalProject_Add命令被构造为仅获取,而不进行配置、构建、安装或测试。

  1. 其次,我们在配置时间(从根项目的角度)使用配置步骤触发ExternalProject_Add
# configure sub-project
execute_process(
  COMMAND
    "${CMAKE_COMMAND}" -G "${CMAKE_GENERATOR}" . 
  WORKING_DIRECTORY
    ${_download_root}
  ) 
  1. 第三个也是最后一个技巧在fetch_git_repo.cmake中触发配置时间构建步骤:
# build sub-project which triggers ExternalProject_Add
execute_process(
  COMMAND
    "${CMAKE_COMMAND}" --build . 
  WORKING_DIRECTORY
    ${_download_root}
  )

这个解决方案的一个很好的方面是,由于外部依赖项不是由ExternalProject_Add配置的,我们不需要通过ExternalProject_Add调用将任何配置设置传递给项目。我们可以使用add_subdirectory配置和构建模块,就好像外部依赖项是我们项目源代码树的一部分一样。巧妙的伪装!

另请参阅

有关可用的FetchContent选项的详细讨论,请咨询cmake.org/cmake/help/v3.11/module/FetchContent.html

配置时间ExternalProject_Add解决方案的灵感来自 Craig Scott 的工作和博客文章:crascit.com/2015/07/25/cmake-gtest/

将您的项目作为超级构建进行管理

本示例的代码可在github.com/dev-cafe/cmake-cookbook/tree/v1.0/chapter-08/recipe-05获取,并且有一个 C++示例。本示例适用于 CMake 版本 3.6(及更高版本),并在 GNU/Linux、macOS 和 Windows 上进行了测试。

ExternalProjectFetchContent是 CMake 工具箱中的两个非常强大的工具。之前的示例应该已经说服了您超级构建方法在管理具有复杂依赖关系的项目方面的多功能性。到目前为止,我们已经展示了如何使用ExternalProject来处理以下内容:

  • 存储在您的源代码树中的源代码

  • 从在线服务器上的档案中检索来源

之前的示例展示了如何使用FetchContent来处理来自开源 Git 存储库的依赖项。本示例将展示如何使用ExternalProject达到相同的效果。最后一个示例将介绍一个将在第 4 个示例中重复使用的示例,即安装超级构建,在第十章,编写安装程序

准备工作

这个超级构建的源代码树现在应该感觉很熟悉:

.
├── CMakeLists.txt
├── external
│   └── upstream
│       ├── CMakeLists.txt
│       └── message
│           └── CMakeLists.txt
└── src
    ├── CMakeLists.txt
    └── use_message.cpp

根目录有一个CMakeLists.txt,我们已经知道它将协调超级构建。叶目录srcexternal托管我们自己的源代码和满足对message库的依赖所需的 CMake 指令,我们将在本示例中构建该库。

如何操作

到目前为止,设置超级构建的过程应该感觉很熟悉。让我们再次看一下必要的步骤,从根CMakeLists.txt开始:

  1. 我们声明了一个具有相同默认构建类型的 C++11 项目:
cmake_minimum_required(VERSION 3.6 FATAL_ERROR)

project(recipe-05 LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 11)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

if(NOT DEFINED CMAKE_BUILD_TYPE OR "${CMAKE_BUILD_TYPE}" STREQUAL "")
  set(CMAKE_BUILD_TYPE Release CACHE STRING "Build type" FORCE)
endif()

message(STATUS "Build type set to ${CMAKE_BUILD_TYPE}")
  1. 设置了EP_BASE目录属性。这将固定由ExternalProject管理的所有子项目的布局:
set_property(DIRECTORY PROPERTY EP_BASE ${CMAKE_BINARY_DIR}/subprojects)
  1. 我们设置了STAGED_INSTALL_PREFIX。与之前一样,此位置将用作构建树中依赖项的安装前缀:
set(STAGED_INSTALL_PREFIX ${CMAKE_BINARY_DIR}/stage)
message(STATUS "${PROJECT_NAME} staged install: ${STAGED_INSTALL_PREFIX}")
  1. 我们添加external/upstream子目录:
add_subdirectory(external/upstream)
  1. 我们自己的项目也将由超级构建管理,因此使用ExternalProject_Add添加:
include(ExternalProject)
ExternalProject_Add(${PROJECT_NAME}_core
  DEPENDS
    message_external
  SOURCE_DIR
    ${CMAKE_CURRENT_SOURCE_DIR}/src
  CMAKE_ARGS
    -DCMAKE_BUILD_TYPE=${CMAKE_BUILD_TYPE}
    -DCMAKE_CXX_COMPILER=${CMAKE_CXX_COMPILER}
    -DCMAKE_CXX_STANDARD=${CMAKE_CXX_STANDARD}
    -DCMAKE_CXX_EXTENSIONS=${CMAKE_CXX_EXTENSIONS}
    -DCMAKE_CXX_STANDARD_REQUIRED=${CMAKE_CXX_STANDARD_REQUIRED}
    -Dmessage_DIR=${message_DIR}
  CMAKE_CACHE_ARGS
    -DCMAKE_CXX_FLAGS:STRING=${CMAKE_CXX_FLAGS}
    -DCMAKE_PREFIX_PATH:PATH=${CMAKE_PREFIX_PATH}
  BUILD_ALWAYS
    1
  INSTALL_COMMAND
    ""
  )

external/upstream中的CMakeLists.txt只包含一个命令:

add_subdirectory(message)

跳转到message文件夹,我们再次看到管理我们对message库依赖的常用命令:

  1. 首先,我们调用find_package来找到一个合适的库版本:
find_package(message 1 CONFIG QUIET)
  1. 如果找到,我们通知用户并添加一个虚拟的INTERFACE库:
get_property(_loc TARGET message::message-shared PROPERTY LOCATION)
message(STATUS "Found message: ${_loc} (found version ${message_VERSION})")
add_library(message_external INTERFACE) # dummy
  1. 如果未找到,我们再次通知用户并继续使用ExternalProject_Add
message(STATUS "Suitable message could not be located, Building message instead.")
  1. 该项目托管在一个公共 Git 仓库中,我们使用GIT_TAG选项来指定下载哪个分支。像之前一样,我们让UPDATE_COMMAND选项保持空白:
include(ExternalProject)
ExternalProject_Add(message_external
  GIT_REPOSITORY
    https://github.com/dev-cafe/message.git
  GIT_TAG
    master
  UPDATE_COMMAND
    ""
  1. 外部项目使用 CMake 进行配置和构建。我们传递所有必要的构建选项:
 CMAKE_ARGS
   -DCMAKE_INSTALL_PREFIX=${STAGED_INSTALL_PREFIX}
   -DCMAKE_BUILD_TYPE=${CMAKE_BUILD_TYPE}
   -DCMAKE_CXX_COMPILER=${CMAKE_CXX_COMPILER}
   -DCMAKE_CXX_STANDARD=${CMAKE_CXX_STANDARD}
   -DCMAKE_CXX_EXTENSIONS=${CMAKE_CXX_EXTENSIONS}
   -DCMAKE_CXX_STANDARD_REQUIRED=${CMAKE_CXX_STANDARD_REQUIRED}
 CMAKE_CACHE_ARGS
   -DCMAKE_CXX_FLAGS:STRING=${CMAKE_CXX_FLAGS}
  1. 我们决定在项目安装后进行测试:
  TEST_AFTER_INSTALL
    1
  1. 我们不希望看到下载进度,也不希望屏幕上显示配置、构建和安装的信息,我们关闭ExternalProject_Add命令:
  DOWNLOAD_NO_PROGRESS
    1
  LOG_CONFIGURE
    1
  LOG_BUILD
    1
  LOG_INSTALL
    1
  )
  1. 为了确保子项目在超级构建的其余部分中可被发现,我们设置message_DIR目录:
if(WIN32 AND NOT CYGWIN)
  set(DEF_message_DIR ${STAGED_INSTALL_PREFIX}/CMake)
else()
  set(DEF_message_DIR ${STAGED_INSTALL_PREFIX}/share/cmake/message)
endif()

file(TO_NATIVE_PATH "${DEF_message_DIR}" DEF_message_DIR)
set(message_DIR ${DEF_message_DIR}
    CACHE PATH "Path to internally built messageConfig.cmake" FORCE)

最后,让我们看看src文件夹中的CMakeLists.txt

  1. 再次,我们声明一个 C++11 项目:
cmake_minimum_required(VERSION 3.6 FATAL_ERROR)

project(recipe-05_core
  LANGUAGES CXX
  )

set(CMAKE_CXX_STANDARD 11)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
  1. 这个项目需要message库:
find_package(message 1 CONFIG REQUIRED)
get_property(_loc TARGET message::message-shared PROPERTY LOCATION)
message(STATUS "Found message: ${_loc} (found version ${message_VERSION})")
  1. 我们声明一个可执行目标,并将其链接到我们依赖项提供的message-shared库:
add_executable(use_message use_message.cpp)

target_link_libraries(use_message
  PUBLIC
    message::message-shared
  )

它是如何工作的

这个配方突出了ExternalProject_Add命令的一些新选项:

  1. GIT_REPOSITORY:这可以用来指定包含我们依赖源代码的仓库的 URL。CMake 还可以使用其他版本控制系统,如 CVS(CVS_REPOSITORY)、SVN(SVN_REPOSITORY)或 Mercurial(HG_REPOSITORY)。

  2. GIT_TAG:默认情况下,CMake 将检出给定仓库的默认分支。然而,依赖于一个已知稳定的定义良好的版本是更可取的。这可以通过这个选项来指定,它可以接受 Git 识别为“版本”信息的任何标识符,如 Git 提交 SHA、Git 标签,或者仅仅是一个分支名称。对于 CMake 理解的其他版本控制系统,也有类似的选项。

  3. TEST_AFTER_INSTALL:很可能,你的依赖项有自己的测试套件,你可能想要运行测试套件以确保超级构建过程中一切顺利。这个选项将在安装步骤之后立即运行测试。

下面是ExternalProject_Add理解的额外测试选项:

  • TEST_BEFORE_INSTALL,它将在安装步骤之前运行测试套件

  • TEST_EXCLUDE_FROM_MAIN,我们可以使用它从测试套件中移除对外部项目主要目标的依赖

这些选项假设外部项目使用 CTest 管理测试。如果外部项目不使用 CTest 管理测试,我们可以设置TEST_COMMAND选项来执行测试。

引入超级构建模式,即使对于项目中包含的模块,也会带来额外的层次,重新声明小型 CMake 项目,并通过ExternalProject_Add显式传递配置设置。引入这一额外层次的好处是变量和目标作用域的清晰分离,这有助于管理复杂性、依赖关系和由多个组件组成的项目的命名空间,这些组件可以是内部的或外部的,并通过 CMake 组合在一起。

第十章:混合语言项目

在本章中,我们将涵盖以下示例:

  • 构建使用 C/C++库的 Fortran 项目

  • 构建使用 Fortran 库的 C/C++项目

  • 使用 Cython 构建 C++和 Python 项目

  • 使用 Boost.Python 构建 C++和 Python 项目

  • 使用 pybind11 构建 C++和 Python 项目

  • 使用 Python CFFI 混合 C、C++、Fortran 和 Python

引言

有许多现有的库在特定任务上表现出色。通常,在我们的代码库中重用这些库是一个非常好的主意,因为我们可以依赖其他专家团队多年的经验。随着计算机架构和编译器的演变,编程语言也在发展。过去,大多数科学软件都是用 Fortran 编写的,而现在,C、C++和解释型语言——尤其是 Python——正占据主导地位。将编译型语言编写的代码与解释型语言的绑定相结合变得越来越普遍,因为它提供了以下好处:

  • 终端用户可以自定义和扩展代码本身提供的能力,以完全满足他们的需求。

  • 人们可以将 Python 等语言的表达力与编译型语言的性能相结合,这种编译型语言在内存寻址方面更接近“硬件层面”,从而获得两者的最佳效果。

正如我们在之前的各个示例中一直展示的那样,project命令可以通过LANGUAGES关键字来设置项目中使用的语言。CMake 支持多种编译型编程语言,但并非全部。截至 CMake 3.5 版本,各种汇编语言(如 ASM-ATT、ASM、ASM-MASM 和 ASM-NASM)、C、C++、Fortran、Java、RC(Windows 资源编译器)和 Swift 都是有效选项。CMake 3.8 版本增加了对两种新语言的支持:C#和 CUDA(详见此处发布说明:cmake.org/cmake/help/v3.8/release/3.8.html#languages)。

在本章中,我们将展示如何将用不同编译型(C、C++和 Fortran)和解释型(Python)语言编写的代码集成到一个可移植和跨平台的解决方案中。我们将展示如何利用 CMake 和不同编程语言固有的工具来实现集成。

构建使用 C/C++库的 Fortran 项目

本示例的代码可在github.com/dev-cafe/cmake-cookbook/tree/v1.0/chapter-09/recipe-01找到,并包含两个示例:一个是混合 Fortran 和 C,另一个是混合 Fortran 和 C++。该示例适用于 CMake 3.5 版本(及以上)。两个版本的示例都已在 GNU/Linux 和 macOS 上进行了测试。

Fortran 作为高性能计算语言有着悠久的历史。许多数值线性代数库仍然主要用 Fortran 编写,许多需要与过去几十年积累的遗留代码保持兼容的大型数字处理软件包也是如此。虽然 Fortran 在处理数值数组时提供了非常自然的语法,但在与操作系统交互时却显得不足,主要是因为直到 Fortran 2003 标准发布时,才强制要求与 C 语言(计算机编程的事实上的通用语言)的互操作层。本食谱将展示如何将 Fortran 代码与 C 系统库和自定义 C 代码接口。

准备工作

如第七章,项目结构化所示,我们将把项目结构化为树状。每个子目录都有一个CMakeLists.txt文件,其中包含与该目录相关的指令。这使我们能够尽可能地将信息限制在叶目录中,如下例所示:

.
├── CMakeLists.txt
└── src
    ├── bt-randomgen-example.f90
    ├── CMakeLists.txt
    ├── interfaces
    │   ├── CMakeLists.txt
    │   ├── interface_backtrace.f90
    │   ├── interface_randomgen.f90
    │   └── randomgen.c
    └── utils
        ├── CMakeLists.txt
        └── util_strings.f90

在我们的例子中,我们有一个包含源代码的src子目录,包括我们的可执行文件bt-randomgen-example.f90。另外两个子目录,interfacesutils,包含将被编译成库的更多源代码。

interfaces子目录中的源代码展示了如何封装 backtrace C 系统库。例如,interface_backtrace.f90包含:

module interface_backtrace

  implicit none

  interface
    function backtrace(buffer, size) result(bt) bind(C, name="backtrace")
      use, intrinsic :: iso_c_binding, only: c_int, c_ptr
      type(c_ptr) :: buffer
      integer(c_int), value :: size
      integer(c_int) :: bt
    end function

    subroutine backtrace_symbols_fd(buffer, size, fd) bind(C, name="backtrace_symbols_fd")
      use, intrinsic :: iso_c_binding, only: c_int, c_ptr
      type(c_ptr) :: buffer
      integer(c_int), value :: size, fd
    end subroutine
  end interface

end module

上述示例展示了以下用法:

  • 内置的iso_c_binding模块,确保了 Fortran 和 C 类型及函数的互操作性。

  • interface声明,它将函数绑定到单独库中的符号。

  • bind(C)属性,它固定了声明函数的名称混淆。

这个子目录包含另外两个源文件:

  • randomgen.c,这是一个 C 源文件,它使用 C 标准的rand函数公开一个函数,用于在区间内生成随机整数。

  • interface_randomgen.f90,它封装了用于 Fortran 可执行文件中的 C 函数。

如何操作

我们有四个CMakeLists.txt实例需要查看:一个根目录和三个叶目录。让我们从根目录的CMakeLists.txt开始:

  1. 我们声明了一个混合语言的 Fortran 和 C 项目:
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)

project(recipe-01 LANGUAGES Fortran C)
  1. 我们指示 CMake 在构建目录的lib子目录下保存静态和共享库。可执行文件将保存在bin下,而 Fortran 编译模块文件将保存在modules下:
set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}/lib)
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}/lib)
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}/bin)
set(CMAKE_Fortran_MODULE_DIRECTORY
  ${CMAKE_CURRENT_BINARY_DIR}/modules)
  1. 接下来,我们转到第一个叶目录,通过添加src子目录来编辑CMakeLists.txt
add_subdirectory(src)
  1. src/CMakeLists.txt文件添加了另外两个子目录:
add_subdirectory(interfaces)
add_subdirectory(utils)

interfaces子目录中,我们执行以下操作:

  1. 我们包含了FortranCInterface.cmake模块,并验证 C 和 Fortran 编译器可以正确地相互通信:
include(FortranCInterface)
FortranCInterface_VERIFY()
  1. 接下来,我们找到 backtrace 系统库,因为我们想在 Fortran 代码中使用它:
find_package(Backtrace REQUIRED)
  1. 然后,我们使用回溯包装器、随机数生成器及其 Fortran 包装器的源文件创建一个共享库目标:
add_library(bt-randomgen-wrap SHARED "")

target_sources(bt-randomgen-wrap
  PRIVATE
    interface_backtrace.f90
    interface_randomgen.f90
    randomgen.c
  )
  1. 我们还为新生成的库目标设置了链接库。我们使用PUBLIC属性,以便链接我们的库的其他目标能够正确看到依赖关系:
target_link_libraries(bt-randomgen-wrap
  PUBLIC
    ${Backtrace_LIBRARIES}
  )

utils子目录中,我们还有一个CMakeLists.txt。这是一个一行代码:我们创建一个新的库目标,该子目录中的源文件将被编译到这个目标中。这个目标没有依赖关系:

add_library(utils SHARED util_strings.f90)

让我们回到src/CMakeLists.txt

  1. 我们添加一个可执行目标,使用bt-randomgen-example.f90作为源文件:
add_executable(bt-randomgen-example bt-randomgen-example.f90)
  1. 最后,我们将CMakeLists.txt叶中生成的库目标链接到我们的可执行目标:
target_link_libraries(bt-randomgen-example
  PRIVATE
    bt-randomgen-wrap
    utils
  )

它是如何工作的

在确定了要链接的正确库之后,我们需要确保我们的程序能够正确调用它们定义的函数。每个编译器在生成机器代码时都会执行名称重整,不幸的是,这项操作的约定并不是通用的,而是依赖于编译器。我们已经在《第三章》(c1fec057-4e5f-4a9b-b404-30dc74f5d7b7.xhtml),检测外部库和程序,第 4 个配方,检测 BLAS 和 LAPACK 数学库中遇到的FortranCInterface,检查所选 C 编译器与 Fortran 编译器的兼容性。对于我们当前的目的,名称重整并不是真正的问题。Fortran 2003 标准为函数和子程序定义了一个bind属性,它接受一个可选的name参数。如果提供了这个参数,编译器将使用程序员固定的名称为这些子程序和函数生成符号。例如,回溯函数可以从 C 暴露给 Fortran,保留名称,如下所示:

function backtrace(buffer, size) result(bt) bind(C, name="backtrace")

还有更多

interfaces/CMakeLists.txt中的 CMake 代码也表明,可以从不同语言的源文件创建库。显然,CMake 能够执行以下操作:

  • 确定使用哪个编译器从列出的源文件获取目标文件。

  • 选择适当的链接器来从这些目标文件构建库(或可执行文件)。

CMake 如何确定使用哪个编译器?通过在project命令中指定LANGUAGES选项,CMake 将检查您的系统上是否存在适用于给定语言的工作编译器。当添加目标并列出源文件时,CMake 将根据文件扩展名适当地确定编译器。因此,以.c结尾的文件将使用已确定的 C 编译器编译为对象文件,而以.f90(或需要预处理的.F90)结尾的文件将使用工作的 Fortran 编译器进行编译。同样,对于 C++,.cpp.cxx扩展名将触发使用 C++编译器。我们仅列出了 C、C++和 Fortran 语言的一些可能的有效文件扩展名,但 CMake 可以识别更多。如果项目中的文件扩展名由于任何原因不在识别的扩展名之列,该怎么办?可以使用LANGUAGE源文件属性来告诉 CMake 在特定源文件上使用哪个编译器,如下所示:

set_source_files_properties(my_source_file.axx
  PROPERTIES
    LANGUAGE CXX
  )

最后,链接器呢?CMake 如何确定目标的链接器语言?对于不混合编程语言的目标,选择很简单:通过用于生成对象文件的编译器命令调用链接器。如果目标确实混合了编程语言,如我们的示例,链接器语言的选择基于在语言混合中偏好值最高的那个。在我们的示例中混合了 Fortran 和 C,Fortran 语言的偏好高于 C 语言,因此被用作链接器语言。当混合 Fortran 和 C++时,后者具有更高的偏好,因此被用作链接器语言。与编译器语言一样,我们可以通过在目标上设置相应的LINKER_LANGUAGE属性来强制 CMake 为我们的目标使用特定的链接器语言:

set_target_properties(my_target
   PROPERTIES
     LINKER_LANGUAGE Fortran
   )

构建使用 Fortran 库的 C/C++项目

本配方的代码可在github.com/dev-cafe/cmake-cookbook/tree/v1.0/chapter-09/recipe-02找到,并提供了一个混合 C++、C 和 Fortran 的示例。该配方适用于 CMake 版本 3.5(及以上),并在 GNU/Linux 和 macOS 上进行了测试。

第三章的配方 4,检测 BLAS 和 LAPACK 数学库,在第三章,检测外部库和程序,展示了如何检测用 Fortran 编写的 BLAS 和 LAPACK 线性代数库,以及如何在 C++代码中使用它们。在这里,我们将重新审视这个配方,但这次从不同的角度出发:更少关注检测外部库,而是更深入地讨论混合 C++和 Fortran 以及名称修饰的方面。

准备工作

在本食谱中,我们将重用来自第三章,检测外部库和程序,食谱 4,检测 BLAS 和 LAPACK 数学库的源代码。尽管我们不会修改实际的实现源文件或头文件,但我们将根据第七章,项目结构中讨论的建议修改项目树结构,并得出以下源代码结构:

.
├── CMakeLists.txt
├── README.md
└── src
    ├── CMakeLists.txt
    ├── linear-algebra.cpp
    └── math
        ├── CMakeLists.txt
        ├── CxxBLAS.cpp
        ├── CxxBLAS.hpp
        ├── CxxLAPACK.cpp
        └── CxxLAPACK.hpp

这里我们收集了所有 BLAS 和 LAPACK 的包装器,它们在src/math下提供了math库。主程序是linear-algebra.cpp。所有源文件都组织在src子目录下。为了限定范围,我们将 CMake 代码拆分到三个CMakeLists.txt文件中,现在我们将讨论这些文件。

如何操作

这个项目混合了 C++(主程序的语言)、Fortran(因为这是库所写的语言)和 C(需要用来包装 Fortran 子例程)。在根CMakeLists.txt文件中,我们需要执行以下操作:

  1. 将项目声明为混合语言并设置 C++标准:
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)

project(recipe-02 LANGUAGES CXX C Fortran)

set(CMAKE_CXX_STANDARD 11)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
  1. 我们使用GNUInstallDirs模块来指导 CMake 将静态和共享库以及可执行文件保存到标准目录中。我们还指示 CMake 将 Fortran 编译的模块文件放置在modules下:
include(GNUInstallDirs)
set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY
  ${CMAKE_BINARY_DIR}/${CMAKE_INSTALL_LIBDIR})
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY
  ${CMAKE_BINARY_DIR}/${CMAKE_INSTALL_LIBDIR})
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY
  ${CMAKE_BINARY_DIR}/${CMAKE_INSTALL_BINDIR})
set(CMAKE_Fortran_MODULE_DIRECTORY ${PROJECT_BINARY_DIR}/modules)
  1. 然后我们转到下一个叶子子目录:
add_subdirectory(src)

src/CMakeLists.txt文件中,我们添加了另一个子目录math,其中包含了线性代数包装器。在src/math/CMakeLists.txt中,我们需要执行以下操作:

  1. 我们调用find_package来获取 BLAS 和 LAPACK 库的位置:
find_package(BLAS REQUIRED)
find_package(LAPACK REQUIRED)
  1. 我们包含FortranCInterface.cmake模块,并验证 Fortran、C 和 C++编译器是否兼容:
include(FortranCInterface)
FortranCInterface_VERIFY(CXX)
  1. 我们还需要生成预处理器宏来处理 BLAS 和 LAPACK 子例程的名称修饰。再次,FortranCInterface通过在当前构建目录中生成一个名为fc_mangle.h的头文件来提供帮助:
FortranCInterface_HEADER(
  fc_mangle.h
  MACRO_NAMESPACE "FC_"
  SYMBOLS DSCAL DGESV
  )
  1. 接下来,我们为 BLAS 和 LAPACK 包装器添加一个库,并指定头文件和库所在的目录。注意PUBLIC属性,它将允许依赖于math的其他目标正确获取其依赖项:
add_library(math "")

target_sources(math
  PRIVATE
    CxxBLAS.cpp
    CxxLAPACK.cpp
  )

target_include_directories(math
  PUBLIC
    ${CMAKE_CURRENT_SOURCE_DIR}
    ${CMAKE_CURRENT_BINARY_DIR}
  )

target_link_libraries(math
  PUBLIC
    ${LAPACK_LIBRARIES}
  )

回到src/CMakeLists.txt,我们最终添加了一个可执行目标,并将其链接到我们的 BLAS/LAPACK 包装器的math库:

add_executable(linear-algebra "")

target_sources(linear-algebra
  PRIVATE
    linear-algebra.cpp
  )

target_link_libraries(linear-algebra
  PRIVATE
    math
  )

它是如何工作的

使用find_package,我们已经确定了要链接的正确库。与之前的食谱一样,我们需要确保我们的程序能够正确调用它们定义的函数。在第三章,检测外部库和程序,第 4 个食谱,检测 BLAS 和 LAPACK 数学库,我们面临编译器依赖的符号修饰问题。我们使用FortranCInterface CMake 模块来检查所选 C 和 C++编译器与 Fortran 编译器的兼容性。我们还使用FortranCInterface_HEADER函数来生成包含宏的头文件,以处理 Fortran 子程序的符号修饰。这是通过以下代码实现的:

FortranCInterface_HEADER(
  fc_mangle.h
  MACRO_NAMESPACE "FC_"
  SYMBOLS DSCAL DGESV
  )

此命令将生成包含符号修饰宏的fc_mangle.h头文件,如 Fortran 编译器所推断,并将其保存到当前二进制目录CMAKE_CURRENT_BINARY_DIR。我们小心地将CMAKE_CURRENT_BINARY_DIR设置为math目标的包含路径。考虑以下生成的fc_mangle.h

#ifndef FC_HEADER_INCLUDED
#define FC_HEADER_INCLUDED

/* Mangling for Fortran global symbols without underscores. */
#define FC_GLOBAL(name,NAME) name##_

/* Mangling for Fortran global symbols with underscores. */
#define FC_GLOBAL_(name,NAME) name##_

/* Mangling for Fortran module symbols without underscores. */
#define FC_MODULE(mod_name,name, mod_NAME,NAME) __##mod_name##_MOD_##name

/* Mangling for Fortran module symbols with underscores. */
#define FC_MODULE_(mod_name,name, mod_NAME,NAME) __##mod_name##_MOD_##name

/* Mangle some symbols automatically. */
#define DSCAL FC_GLOBAL(dscal, DSCAL)
#define DGESV FC_GLOBAL(dgesv, DGESV)

#endif

本示例中的编译器使用下划线进行符号修饰。由于 Fortran 不区分大小写,子程序可能以小写或大写形式出现,因此需要将两种情况都传递给宏。请注意,CMake 还将为隐藏在 Fortran 模块后面的符号生成修饰宏。

如今,许多 BLAS 和 LAPACK 的实现都附带了一个围绕 Fortran 子程序的薄 C 层包装器。这些包装器多年来已经标准化,并分别称为 CBLAS 和 LAPACKE。

由于我们已将源文件仔细组织成一个库目标和一个可执行目标,我们应该对目标的PUBLICINTERFACEPRIVATE可见性属性进行注释。这些对于清晰的 CMake 项目结构至关重要。与源文件一样,包含目录、编译定义和选项,当与target_link_libraries一起使用时,这些属性的含义保持不变:

  • 使用PRIVATE属性,库将仅被链接到当前目标,而不会被链接到以它作为依赖的其他目标。

  • 使用INTERFACE属性,库将仅被链接到以当前目标作为依赖的目标。

  • 使用PUBLIC属性,库将被链接到当前目标以及任何以它作为依赖的其他目标。

使用 Cython 构建 C++和 Python 项目

本食谱的代码可在github.com/dev-cafe/cmake-cookbook/tree/v1.0/chapter-09/recipe-03找到,并包含一个 C++示例。该食谱适用于 CMake 版本 3.5(及以上),并在 GNU/Linux、macOS 和 Windows 上进行了测试。

Cython 是一个优化的静态编译器,允许为 Python 编写 C 扩展。Cython 是一个非常强大的工具,使用基于 Pyrex 的扩展 Cython 编程语言。Cython 的一个典型用例是加速 Python 代码,但它也可以用于通过 Cython 层将 C/C++与 Python 接口。在本食谱中,我们将专注于后一种用例,并演示如何使用 CMake 帮助下的 Cython 将 C/C++和 Python 接口。

准备就绪

作为一个例子,我们将使用以下 C++代码(account.cpp):

#include "account.hpp"

Account::Account() : balance(0.0) {}

Account::~Account() {}

void Account::deposit(const double amount) { balance += amount; }

void Account::withdraw(const double amount) { balance -= amount; }

double Account::get_balance() const { return balance; }

这段代码提供了以下接口(account.hpp):

#pragma once

class Account {
public:
  Account();
  ~Account();

  void deposit(const double amount);
  void withdraw(const double amount);
  double get_balance() const;

private:
  double balance;
};

使用这段示例代码,我们可以创建起始余额为零的银行账户。我们可以向账户存款和取款,也可以使用get_balance()查询账户余额。余额本身是Account类的私有成员。

我们的目标是能够直接从 Python 与这个 C++类交互——换句话说,在 Python 方面,我们希望能够这样做:

account = Account()

account.deposit(100.0)
account.withdraw(50.0)

balance = account.get_balance()

为了实现这一点,我们需要一个 Cython 接口文件(我们将称这个文件为account.pyx):

# describe the c++ interface
cdef extern from "account.hpp":
    cdef cppclass Account:
        Account() except +
        void deposit(double)
        void withdraw(double)
        double get_balance()

# describe the python interface
cdef class pyAccount:
    cdef Account *thisptr
    def __cinit__(self):
        self.thisptr = new Account()
    def __dealloc__(self):
        del self.thisptr
    def deposit(self, amount):
        self.thisptr.deposit(amount)
    def withdraw(self, amount):
        self.thisptr.withdraw(amount)
    def get_balance(self):
        return self.thisptr.get_balance()

如何操作

让我们看看如何生成 Python 接口:

  1. 我们的CMakeLists.txt开始定义 CMake 依赖项、项目名称和语言:
# define minimum cmake version
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)

# project name and supported language
project(recipe-03 LANGUAGES CXX)

# require C++11
set(CMAKE_CXX_STANDARD 11)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
  1. 在 Windows 上,最好不要让构建类型未定义,这样我们就可以使此项目的构建类型与 Python 环境的构建类型相匹配。这里我们默认使用Release构建类型:
if(NOT CMAKE_BUILD_TYPE)
  set(CMAKE_BUILD_TYPE Release CACHE STRING "Build type" FORCE)
endif()
  1. 在本食谱中,我们还将需要 Python 解释器:
find_package(PythonInterp REQUIRED)
  1. 以下 CMake 代码将允许我们构建 Python 模块:
# directory cointaining UseCython.cmake and FindCython.cmake
list(APPEND CMAKE_MODULE_PATH ${CMAKE_CURRENT_SOURCE_DIR}/cmake-cython)

# this defines cython_add_module
include(UseCython)

# tells UseCython to compile this file as a c++ file
set_source_files_properties(account.pyx PROPERTIES CYTHON_IS_CXX TRUE)

# create python module
cython_add_module(account account.pyx account.cpp)

# location of account.hpp
target_include_directories(account
  PRIVATE
    ${CMAKE_CURRENT_SOURCE_DIR}
  )
  1. 现在我们定义一个测试:
# turn on testing
enable_testing()

# define test
add_test(
  NAME
    python_test
  COMMAND
    ${CMAKE_COMMAND} -E env ACCOUNT_MODULE_PATH=$<TARGET_FILE_DIR:account>
    ${PYTHON_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test.py
  )
  1. python_test执行test.py,在其中我们进行了几次存款和取款,并验证了余额:
import os
import sys
sys.path.append(os.getenv('ACCOUNT_MODULE_PATH'))

from account import pyAccount as Account

account1 = Account()

account1.deposit(100.0)
account1.deposit(100.0)

account2 = Account()

account2.deposit(200.0)
account2.deposit(200.0)

account1.withdraw(50.0)

assert account1.get_balance() == 150.0
assert account2.get_balance() == 400.0
  1. 有了这些,我们就可以配置、构建和测试代码了:
$ mkdir -p build
$ cd build
$ cmake ..
$ cmake --build .
$ ctest

 Start 1: python_test
1/1 Test #1: python_test ...................... Passed 0.03 sec

100% tests passed, 0 tests failed out of 1

Total Test time (real) = 0.03 sec

工作原理

在本食谱中,我们通过一个相对紧凑的CMakeLists.txt文件实现了 Python 与 C++的接口,但我们通过使用FindCython.cmakeUseCython.cmake模块实现了这一点,这些模块被放置在cmake-cython下。这些模块通过以下代码包含:

# directory contains UseCython.cmake and FindCython.cmake
list(APPEND CMAKE_MODULE_PATH ${CMAKE_CURRENT_SOURCE_DIR}/cmake-cython)

# this defines cython_add_module
include(UseCython)

FindCython.cmake包含在UseCython.cmake中,并定位和定义${CYTHON_EXECUTABLE}。后一个模块定义了cython_add_modulecython_add_standalone_executable函数,这些函数可用于创建 Python 模块和独立可执行文件。这两个模块都已从github.com/thewtex/cython-cmake-example/tree/master/cmake下载。

在本食谱中,我们使用cython_add_module来创建一个 Python 模块库。请注意,我们将非标准的CYTHON_IS_CXX源文件属性设置为TRUE,这样cython_add_module函数就会知道将pyx文件编译为 C++文件:

# tells UseCython to compile this file as a c++ file
set_source_files_properties(account.pyx PROPERTIES CYTHON_IS_CXX TRUE)

# create python module
cython_add_module(account account.pyx account.cpp)

Python 模块在${CMAKE_CURRENT_BINARY_DIR}内部创建,为了让 Python test.py脚本能够找到它,我们通过自定义环境变量传递相关路径,该变量在test.py内部用于设置PATH变量。注意COMMAND是如何设置为调用 CMake 可执行文件本身以在执行 Python 脚本之前正确设置本地环境的。这为我们提供了平台独立性,并避免了用无关变量污染环境:

add_test(
  NAME
    python_test
  COMMAND
    ${CMAKE_COMMAND} -E env ACCOUNT_MODULE_PATH=$<TARGET_FILE_DIR:account>
    ${PYTHON_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test.py
  )

我们还应该查看account.pyx文件,它是 Python 和 C++之间的接口文件,描述了 C++接口:

# describe the c++ interface
cdef extern from "account.hpp":
    cdef cppclass Account:
        Account() except +
        void deposit(double)
        void withdraw(double)
        double get_balance()

Account类构造函数中可以看到except +。这个指令允许 Cython 处理由 C++代码引发的异常。

account.pyx接口文件还描述了 Python 接口:

# describe the python interface
cdef class pyAccount:
    cdef Account *thisptr
    def __cinit__(self):
        self.thisptr = new Account()
    def __dealloc__(self):
        del self.thisptr
    def deposit(self, amount):
        self.thisptr.deposit(amount)
    def withdraw(self, amount):
        self.thisptr.withdraw(amount)
    def get_balance(self):
        return self.thisptr.get_balance()

我们可以看到cinit构造函数、__dealloc__析构函数以及depositwithdraw方法是如何与相应的 C++实现对应部分匹配的。

总结一下,我们找到了一种通过引入对 Cython 模块的依赖来结合 Python 和 C++的机制。这个模块可以通过pip安装到虚拟环境或 Pipenv 中,或者使用 Anaconda 安装。

还有更多内容

C 也可以类似地耦合。如果我们希望利用构造函数和析构函数,我们可以围绕 C 接口编写一个薄的 C++层。

Typed Memoryviews 提供了有趣的功能,可以直接在 Python 中映射和访问由 C/C++分配的内存缓冲区,而不会产生任何开销:cython.readthedocs.io/en/latest/src/userguide/memoryviews.html。它们使得可以直接将 NumPy 数组映射到 C++数组。

使用 Boost.Python 构建 C++和 Python 项目

本节的代码可在github.com/dev-cafe/cmake-cookbook/tree/v1.0/chapter-09/recipe-04找到,并包含一个 C++示例。本节适用于 CMake 版本 3.5(及以上),并在 GNU/Linux、macOS 和 Windows 上进行了测试。

Boost 库提供了另一种流行的选择,用于将 C++代码与 Python 接口。本节将展示如何使用 CMake 为依赖于 Boost.Python 的 C++项目构建,以便将它们的功能作为 Python 模块暴露出来。我们将重用前一节的示例,并尝试与 Cython 示例中的相同 C++实现(account.cpp)进行交互。

准备工作

虽然我们保持account.cpp不变,但我们修改了前一节的接口文件(account.hpp):

#pragma once

#define BOOST_PYTHON_STATIC_LIB
#include <boost/python.hpp>

class Account {
public:
  Account();
  ~Account();

  void deposit(const double amount);
  void withdraw(const double amount);
  double get_balance() const;

private:
  double balance;
};

namespace py = boost::python;

BOOST_PYTHON_MODULE(account) {
  py::class_<Account>("Account")
      .def("deposit", &Account::deposit)
      .def("withdraw", &Account::withdraw)
      .def("get_balance", &Account::get_balance);
}

如何操作

以下是使用 Boost.Python 与您的 C++项目所需的步骤:

  1. 与前一节一样,我们首先定义最小版本、项目名称、支持的语言和默认构建类型:
# define minimum cmake version
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)

# project name and supported language
project(recipe-04 LANGUAGES CXX)

# require C++11
set(CMAKE_CXX_STANDARD 11)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

# we default to Release build type
if(NOT CMAKE_BUILD_TYPE)
  set(CMAKE_BUILD_TYPE Release CACHE STRING "Build type" FORCE)
endif()
  1. 在本配方中,我们依赖于 Python 和 Boost 库以及 Python 解释器进行测试。Boost.Python 组件的名称取决于 Boost 版本和 Python 版本,因此我们探测几个可能的组件名称:
# for testing we will need the python interpreter
find_package(PythonInterp REQUIRED)

# we require python development headers
find_package(PythonLibs ${PYTHON_VERSION_MAJOR}.${PYTHON_VERSION_MINOR} EXACT REQUIRED)
# now search for the boost component
# depending on the boost version it is called either python,
# python2, python27, python3, python36, python37, ...

list(
  APPEND _components
    python${PYTHON_VERSION_MAJOR}${PYTHON_VERSION_MINOR}
    python${PYTHON_VERSION_MAJOR}
    python
  )

set(_boost_component_found "")

foreach(_component IN ITEMS ${_components})
  find_package(Boost COMPONENTS ${_component})
  if(Boost_FOUND)
    set(_boost_component_found ${_component})
    break()
  endif()
endforeach()

if(_boost_component_found STREQUAL "")
  message(FATAL_ERROR "No matching Boost.Python component found")
endif()
  1. 使用以下命令,我们定义了 Python 模块及其依赖项:
# create python module
add_library(account
  MODULE
    account.cpp
  )

target_link_libraries(account
  PUBLIC
    Boost::${_boost_component_found}
    ${PYTHON_LIBRARIES}
  )

target_include_directories(account
  PRIVATE
    ${PYTHON_INCLUDE_DIRS}
  )
# prevent cmake from creating a "lib" prefix
set_target_properties(account
  PROPERTIES
    PREFIX ""
  )

if(WIN32)
  # python will not import dll but expects pyd
  set_target_properties(account
    PROPERTIES
      SUFFIX ".pyd"
    )
endif()
  1. 最后,我们为这个实现定义了一个测试:
# turn on testing
enable_testing()

# define test
add_test(
  NAME
    python_test
  COMMAND
    ${CMAKE_COMMAND} -E env ACCOUNT_MODULE_PATH=$<TARGET_FILE_DIR:account>
    ${PYTHON_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test.py
  )
  1. 现在可以配置、编译和测试代码:
$ mkdir -p build
$ cd build
$ cmake ..
$ cmake --build .
$ ctest

    Start 1: python_test
1/1 Test #1: python_test ......................   Passed    0.10 sec

100% tests passed, 0 tests failed out of 1

Total Test time (real) =   0.11 sec

它是如何工作的

与依赖 Cython 模块不同,本配方现在依赖于在系统上定位 Boost 库,以及 Python 开发头文件和库。

使用以下命令搜索 Python 开发头文件和库:

find_package(PythonInterp REQUIRED)

find_package(PythonLibs ${PYTHON_VERSION_MAJOR}.${PYTHON_VERSION_MINOR} EXACT REQUIRED)

请注意,我们首先搜索解释器,然后搜索开发头文件和库。此外,对PythonLibs的搜索要求开发头文件和库的相同主要和次要版本与解释器发现的版本相同。这是为了确保在整个项目中使用一致的解释器和库版本。然而,这种命令组合并不能保证会找到完全匹配的两个版本。

在定位 Boost.Python 组件时,我们遇到了一个难题,即我们尝试定位的组件名称取决于 Boost 版本和我们的 Python 环境。根据 Boost 版本,组件可以称为pythonpython2python3python27python36python37等。我们通过从特定到更通用的名称进行搜索,并且只有在找不到匹配项时才失败来解决这个问题:

list(
  APPEND _components
    python${PYTHON_VERSION_MAJOR}${PYTHON_VERSION_MINOR}
    python${PYTHON_VERSION_MAJOR}
    python
  )

set(_boost_component_found "")

foreach(_component IN ITEMS ${_components})
  find_package(Boost COMPONENTS ${_component})
  if(Boost_FOUND)
    set(_boost_component_found ${_component})
    break()
  endif()
endforeach()
if(_boost_component_found STREQUAL "")
  message(FATAL_ERROR "No matching Boost.Python component found")
endif()

可以通过设置额外的 CMake 变量来调整 Boost 库的发现和使用。例如,CMake 提供以下选项:

  • Boost_USE_STATIC_LIBS可以设置为ON以强制使用 Boost 库的静态版本。

  • Boost_USE_MULTITHREADED可以设置为ON以确保选择并使用多线程版本。

  • Boost_USE_STATIC_RUNTIME可以设置为ON,以便我们的目标将使用链接 C++运行时静态的 Boost 变体。

本配方引入的另一个新方面是在add_library命令中使用MODULE选项。我们从第 3 个配方,构建和链接共享和静态库,在第一章,从简单可执行文件到库中已经知道,CMake 接受以下选项作为add_library的第二个有效参数:

  • STATIC,用于创建静态库;即,用于链接其他目标(如可执行文件)的对象文件的档案

  • SHARED,用于创建共享库;即,可以在运行时动态链接和加载的库

  • OBJECT,用于创建对象库;即,不将对象文件归档到静态库中,也不将它们链接成共享对象

这里引入的MODULE选项将生成一个插件库;也就是说,一个动态共享对象(DSO),它不会被动态链接到任何可执行文件中,但仍然可以在运行时加载。由于我们正在用自己编写的 C++功能扩展 Python,Python 解释器将需要在运行时能够加载我们的库。这可以通过使用add_libraryMODULE选项并阻止在我们的库目标名称中添加任何前缀(例如,Unix 系统上的lib)来实现。后者操作是通过设置适当的 target 属性来完成的,如下所示:

set_target_properties(account
  PROPERTIES
    PREFIX ""
  )

所有展示 Python 和 C++接口的示例都有一个共同点,那就是我们需要向 Python 代码描述如何与 C++层连接,并列出应该对 Python 可见的符号。我们还可以(重新)命名这些符号。在前面的示例中,我们在一个单独的account.pyx文件中完成了这一点。当使用Boost.Python时,我们直接在 C++代码中描述接口,最好靠近我们希望接口的类或函数的定义:

BOOST_PYTHON_MODULE(account) {
  py::class_<Account>("Account")
      .def("deposit", &Account::deposit)
      .def("withdraw", &Account::withdraw)
      .def("get_balance", &Account::get_balance);
}

BOOST_PYTHON_MODULE模板包含在<boost/python.hpp>中,负责创建 Python 接口。该模块将暴露一个Account Python 类,该类映射到 C++类。在这种情况下,我们不必显式声明构造函数和析构函数——这些会为我们自动创建,并在 Python 对象创建时自动调用:

myaccount = Account()

当对象超出作用域并被 Python 垃圾回收机制收集时,析构函数会被调用。同时,注意BOOST_PYTHON_MODULE是如何暴露depositwithdrawget_balance这些函数,并将它们映射到相应的 C++类方法上的。

这样,编译后的模块可以在PYTHONPATH中找到。在本示例中,我们实现了 Python 和 C++层之间相对干净的分离。Python 代码在功能上不受限制,不需要类型注释或重命名,并且保持了pythonic

from account import Account

account1 = Account()

account1.deposit(100.0)
account1.deposit(100.0)

account2 = Account()

account2.deposit(200.0)
account2.deposit(200.0)

account1.withdraw(50.0)

assert account1.get_balance() == 150.0
assert account2.get_balance() == 400.0

还有更多内容

在本示例中,我们依赖于系统上已安装的 Boost,因此 CMake 代码尝试检测相应的库。或者,我们可以将 Boost 源代码与我们的项目一起打包,并将此依赖项作为项目的一部分进行构建。Boost 是一种便携式的方式,用于将 Python 与 C++接口。然而,考虑到编译器支持和 C++标准的可移植性,Boost.Python 并不是一个轻量级的依赖。在下面的示例中,我们将讨论 Boost.Python 的一个轻量级替代方案。

使用 pybind11 构建 C++和 Python 项目

本示例的代码可在github.com/dev-cafe/cmake-cookbook/tree/v1.0/chapter-09/recipe-05找到,并包含一个 C++示例。该示例适用于 CMake 版本 3.11(及更高版本),并在 GNU/Linux、macOS 和 Windows 上进行了测试。

在前一个示例中,我们使用了 Boost.Python 来实现 Python 与 C(++)的接口。在这个示例中,我们将尝试使用 pybind11 作为轻量级替代方案,该方案利用了 C++11 特性,因此需要支持 C++11 的编译器。与前一个示例相比,我们将展示如何在配置时获取 pybind11 依赖项,并使用我们在第四章,创建和运行测试,示例 3,定义单元测试并与 Google Test 链接中遇到的 FetchContent 方法构建我们的项目,包括 Python 接口,并在第八章,超级构建模式,示例 4,使用超级构建管理依赖项:III. Google Test 框架中进行了讨论。在第十一章,打包项目,示例 2,通过 PyPI 分发使用 CMake/pybind11 构建的 C++/Python 项目中,我们将重新访问此示例,并展示如何打包它并通过 pip 安装。

准备就绪

我们将保持account.cpp相对于前两个示例不变,只修改account.hpp

#pragma once

#include <pybind11/pybind11.h>

class Account {
public:
  Account();
  ~Account();

  void deposit(const double amount);
  void withdraw(const double amount);
  double get_balance() const;

private:
  double balance;
};

namespace py = pybind11;

PYBIND11_MODULE(account, m) {
  py::class_<Account>(m, "Account")
      .def(py::init())
      .def("deposit", &Account::deposit)
      .def("withdraw", &Account::withdraw)
      .def("get_balance", &Account::get_balance);
}

我们将遵循 pybind11 文档中的“使用 CMake 构建”指南(pybind11.readthedocs.io/en/stable/compiling.html#building-with-cmake),并介绍使用add_subdirectory添加 pybind11 的 CMake 代码。然而,我们不会将 pybind11 源代码明确放入我们的项目目录中,而是演示如何在配置时使用FetchContentcmake.org/cmake/help/v3.11/module/FetchContent.html)获取 pybind11 源代码。

为了在下一个示例中更好地重用代码,我们还将所有源代码放入子目录中,并使用以下项目布局:

.
├── account
│   ├── account.cpp
│   ├── account.hpp
│   ├── CMakeLists.txt
│   └── test.py
└── CMakeLists.txt

如何操作

让我们详细分析这个项目中各个CMakeLists.txt文件的内容:

  1. 根目录的CMakeLists.txt文件包含熟悉的头部信息:
# define minimum cmake version
cmake_minimum_required(VERSION 3.11 FATAL_ERROR)

# project name and supported language
project(recipe-05 LANGUAGES CXX)

# require C++11
set(CMAKE_CXX_STANDARD 11)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
  1. 在此文件中,我们还查询将用于测试的 Python 解释器:
find_package(PythonInterp REQUIRED)
  1. 然后,我们包含账户子目录:
add_subdirectory(account)
  1. 之后,我们定义单元测试:
# turn on testing
enable_testing()

# define test
add_test(
  NAME
    python_test
  COMMAND
    ${CMAKE_COMMAND} -E env ACCOUNT_MODULE_PATH=$<TARGET_FILE_DIR:account>
    ${PYTHON_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/account/test.py
  )
  1. account/CMakeLists.txt文件中,我们在配置时获取 pybind11 源代码:
include(FetchContent)

FetchContent_Declare(
  pybind11_sources
  GIT_REPOSITORY https://github.com/pybind/pybind11.git
  GIT_TAG v2.2
)

FetchContent_GetProperties(pybind11_sources)

if(NOT pybind11_sources_POPULATED)
  FetchContent_Populate(pybind11_sources)

  add_subdirectory(
    ${pybind11_sources_SOURCE_DIR}
    ${pybind11_sources_BINARY_DIR}
    )
endif()
  1. 最后,我们定义 Python 模块。再次使用add_libraryMODULE选项。我们还为我们的库目标设置前缀和后缀属性为PYTHON_MODULE_PREFIXPYTHON_MODULE_EXTENSION,这些属性由 pybind11 适当地推断出来:
add_library(account
  MODULE
    account.cpp
  )

target_link_libraries(account
  PUBLIC
    pybind11::module
  )

set_target_properties(account
  PROPERTIES
    PREFIX "${PYTHON_MODULE_PREFIX}"
    SUFFIX "${PYTHON_MODULE_EXTENSION}"
  )
  1. 让我们测试一下:
$ mkdir -p build
$ cd build
$ cmake ..
$ cmake --build .
$ ctest

 Start 1: python_test
1/1 Test #1: python_test ...................... Passed 0.04 sec

100% tests passed, 0 tests failed out of 1

Total Test time (real) = 0.04 sec

它是如何工作的

pybind11 的功能和使用与 Boost.Python 非常相似,不同的是 pybind11 是一个更轻量级的依赖项——尽管我们需要编译器的 C++11 支持。在account.hpp中的接口定义与前一个示例中的定义相当相似:

#include <pybind11/pybind11.h>

// ...

namespace py = pybind11;

PYBIND11_MODULE(account, m) {
  py::class_<Account>(m, "Account")
      .def(py::init())
      .def("deposit", &Account::deposit)
      .def("withdraw", &Account::withdraw)
      .def("get_balance", &Account::get_balance);
}

再次,我们可以清楚地看到 Python 方法是如何映射到 C++函数的。解释PYBIND11_MODULE的库在导入的目标pybind11::module中定义,我们使用以下方式包含它:

add_subdirectory(
  ${pybind11_sources_SOURCE_DIR}
  ${pybind11_sources_BINARY_DIR}
  )

与前一个配方相比,有两个不同之处:

  • 我们不要求系统上安装了 pybind11,因此不会尝试定位它。

  • 在项目开始构建时,包含 pybind11 CMakeLists.txt${pybind11_sources_SOURCE_DIR}子目录并不存在。

解决此挑战的一种方法是使用FetchContent模块,该模块在配置时获取 pybind11 源代码和 CMake 基础设施,以便我们可以使用add_subdirectory引用它。采用FetchContent模式,我们现在可以假设 pybind11 在构建树中可用,这使得我们能够构建并链接 Python 模块。

add_library(account
  MODULE
    account.cpp
  )

target_link_libraries(account
  PUBLIC
    pybind11::module
  )

我们使用以下命令确保 Python 模块库获得一个与 Python 环境兼容的定义良好的前缀和后缀:

set_target_properties(account
  PROPERTIES
    PREFIX ${PYTHON_MODULE_PREFIX}
    SUFFIX ${PYTHON_MODULE_EXTENSION}
  )

顶级CMakeLists.txt文件的其余部分用于测试(我们使用与前一个配方相同的test.py)。

还有更多

我们可以将 pybind11 源代码作为项目源代码仓库的一部分,这将简化 CMake 结构并消除在编译时需要网络访问 pybind11 源代码的要求。或者,我们可以将 pybind11 源路径定义为 Git 子模块(git-scm.com/book/en/v2/Git-Tools-Submodules),以简化更新 pybind11 源依赖关系。

在本例中,我们使用FetchContent解决了这个问题,它提供了一种非常紧凑的方法来引用 CMake 子项目,而无需显式跟踪其源代码。此外,我们还可以使用所谓的超级构建方法来解决这个问题(参见第八章,The Superbuild Pattern)。

另请参阅

若想了解如何暴露简单函数、定义文档字符串、映射内存缓冲区以及获取更多阅读材料,请参考 pybind11 文档:pybind11.readthedocs.io

使用 Python CFFI 混合 C、C++、Fortran 和 Python

本配方的代码可在github.com/dev-cafe/cmake-cookbook/tree/v1.0/chapter-09/recipe-06找到,并包含 C++和 Fortran 示例。这些配方适用于 CMake 版本 3.5(及更高版本)。这两个版本的配方已在 GNU/Linux、macOS 和 Windows 上进行了测试。

在前三个菜谱中,我们讨论了 Cython、Boost.Python 和 pybind11 作为连接 Python 和 C++的工具,提供了一种现代且清晰的方法。在前面的菜谱中,主要接口是 C++接口。然而,我们可能会遇到没有 C++接口可供连接的情况,这时我们可能希望将 Python 与 Fortran 或其他语言连接起来。

在本菜谱中,我们将展示一种使用 Python C Foreign Function Interface(CFFI;另见cffi.readthedocs.io)的替代方法来连接 Python。由于 C 是编程语言的通用语,大多数编程语言(包括 Fortran)都能够与 C 接口通信,Python CFFI 是一种将 Python 与大量语言连接的工具。Python CFFI 的一个非常好的特点是,生成的接口是薄的且不侵入的,这意味着它既不限制 Python 层的语言特性,也不对 C 层以下的代码施加任何限制,除了需要一个 C 接口。

在本菜谱中,我们将应用 Python CFFI 通过 C 接口将 Python 和 C++连接起来,使用在前述菜谱中介绍的银行账户示例。我们的目标是实现一个上下文感知的接口,可以实例化多个银行账户,每个账户都携带其内部状态。我们将通过本菜谱结束时对如何使用 Python CFFI 将 Python 与 Fortran 连接进行评论。在第十一章,打包项目,菜谱 3,通过 CMake/CFFI 构建的 C/Fortran/Python 项目通过 PyPI 分发,我们将重新审视这个示例,并展示如何打包它,使其可以通过 pip 安装。

准备工作

我们将需要几个文件来完成这个菜谱。让我们从 C++实现和接口开始。我们将把这些文件放在一个名为account/implementation的子目录中。实现文件(cpp_implementation.cpp)与之前的菜谱类似,但包含了额外的assert语句,因为我们将在一个不透明的句柄中保持对象的状态,并且我们必须确保在尝试访问它之前创建了对象:

#include "cpp_implementation.hpp"

#include <cassert>

Account::Account() {
  balance = 0.0;
  is_initialized = true;
}

Account::~Account() {
  assert(is_initialized);
  is_initialized = false;
}

void Account::deposit(const double amount) {
  assert(is_initialized);
  balance += amount;
}

void Account::withdraw(const double amount) {
  assert(is_initialized);
  balance -= amount;
}

double Account::get_balance() const {
  assert(is_initialized);
  return balance;
}

接口文件(cpp_implementation.hpp)包含以下内容:

#pragma once

class Account {
public:
  Account();
  ~Account();

  void deposit(const double amount);
  void withdraw(const double amount);
  double get_balance() const;

private:
  double balance;
  bool is_initialized;
};

此外,我们隔离了一个 C—C++接口(c_cpp_interface.cpp)。这将是我们尝试使用 Python CFFI 连接的接口:

#include "account.h"
#include "cpp_implementation.hpp"

#define AS_TYPE(Type, Obj) reinterpret_cast<Type *>(Obj)
#define AS_CTYPE(Type, Obj) reinterpret_cast<const Type *>(Obj)

account_context_t *account_new() {
  return AS_TYPE(account_context_t, new Account());
}

void account_free(account_context_t *context) { delete AS_TYPE(Account, context); }

void account_deposit(account_context_t *context, const double amount) {
  return AS_TYPE(Account, context)->deposit(amount);
}

void account_withdraw(account_context_t *context, const double amount) {
  return AS_TYPE(Account, context)->withdraw(amount);
}

double account_get_balance(const account_context_t *context) {
  return AS_CTYPE(Account, context)->get_balance();
}

account目录下,我们描述了 C 接口(account.h):

/* CFFI would issue warning with pragma once */
#ifndef ACCOUNT_H_INCLUDED
#define ACCOUNT_H_INCLUDED

#ifndef ACCOUNT_API
#include "account_export.h"
#define ACCOUNT_API ACCOUNT_EXPORT
#endif

#ifdef __cplusplus
extern "C" {
#endif

struct account_context;
typedef struct account_context account_context_t;

ACCOUNT_API
account_context_t *account_new();

ACCOUNT_API
void account_free(account_context_t *context);

ACCOUNT_API
void account_deposit(account_context_t *context, const double amount);

ACCOUNT_API
void account_withdraw(account_context_t *context, const double amount);

ACCOUNT_API
double account_get_balance(const account_context_t *context);

#ifdef __cplusplus
}
#endif

#endif /* ACCOUNT_H_INCLUDED */

我们还描述了 Python 接口,我们将在下面进行评论(__init__.py):

from subprocess import check_output
from cffi import FFI
import os
import sys
from configparser import ConfigParser
from pathlib import Path

def get_lib_handle(definitions, header_file, library_file):
    ffi = FFI()
    command = ['cc', '-E'] + definitions + [header_file]
    interface = check_output(command).decode('utf-8')

    # remove possible \r characters on windows which
    # would confuse cdef
    _interface = [l.strip('\r') for l in interface.split('\n')]

    ffi.cdef('\n'.join(_interface))
    lib = ffi.dlopen(library_file)
    return lib

# this interface requires the header file and library file
# and these can be either provided by interface_file_names.cfg
# in the same path as this file
# or if this is not found then using environment variables
_this_path = Path(os.path.dirname(os.path.realpath(__file__)))
_cfg_file = _this_path / 'interface_file_names.cfg'
if _cfg_file.exists():
    config = ConfigParser()
    config.read(_cfg_file)
    header_file_name = config.get('configuration', 'header_file_name')
    _header_file = _this_path / 'include' / header_file_name
    _header_file = str(_header_file)
    library_file_name = config.get('configuration', 'library_file_name')
    _library_file = _this_path / 'lib' / library_file_name
    _library_file = str(_library_file)
else:
    _header_file = os.getenv('ACCOUNT_HEADER_FILE')
    assert _header_file is not None
    _library_file = os.getenv('ACCOUNT_LIBRARY_FILE')
    assert _library_file is not None

_lib = get_lib_handle(definitions=['-DACCOUNT_API=', '-DACCOUNT_NOINCLUDE'],
                      header_file=_header_file,
                      library_file=_library_file)

# we change names to obtain a more pythonic API
new = _lib.account_new
free = _lib.account_free
deposit = _lib.account_deposit
withdraw = _lib.account_withdraw
get_balance = _lib.account_get_balance

__all__ = [
    '__version__',
    'new',
    'free',
    'deposit',
    'withdraw',
    'get_balance',
]

这是一堆文件,但是,正如我们将看到的,大部分接口工作是通用的和可重用的,实际的接口相当薄。总之,这是我们项目的布局:

.
├── account
│   ├── account.h
│   ├── CMakeLists.txt
│   ├── implementation
│   │   ├── c_cpp_interface.cpp
│   │   ├── cpp_implementation.cpp
│   │   └── cpp_implementation.hpp
│   ├── __init__.py
│   └── test.py
└── CMakeLists.txt

如何操作

现在让我们使用 CMake 将这些文件组合成一个 Python 模块:

  1. 顶层CMakeLists.txt文件包含一个熟悉的标题。此外,我们还根据 GNU 标准设置了编译库的位置:
# define minimum cmake version
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)

# project name and supported language
project(recipe-06 LANGUAGES CXX)

# require C++11
set(CMAKE_CXX_STANDARD 11)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

# specify where to place libraries
include(GNUInstallDirs)
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY
  ${CMAKE_BINARY_DIR}/${CMAKE_INSTALL_LIBDIR})
  1. 第二步是在 account 子目录下包含接口定义和实现源代码,我们将在下面详细介绍:
# interface and sources
add_subdirectory(account)
  1. 顶层的 CMakeLists.txt 文件以定义测试(需要 Python 解释器)结束:
# turn on testing
enable_testing()

# require python
find_package(PythonInterp REQUIRED)

# define test
add_test(
  NAME
    python_test
  COMMAND
    ${CMAKE_COMMAND} -E env ACCOUNT_MODULE_PATH=${CMAKE_CURRENT_SOURCE_DIR}
                            ACCOUNT_HEADER_FILE=${CMAKE_CURRENT_SOURCE_DIR}/account/account.h
                            ACCOUNT_LIBRARY_FILE=$<TARGET_FILE:account>
    ${PYTHON_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/account/test.py
  )
  1. 包含的 account/CMakeLists.txt 定义了共享库:
add_library(account
  SHARED
    implementation/c_cpp_interface.cpp
    implementation/cpp_implementation.cpp
  )

target_include_directories(account
  PRIVATE
    ${CMAKE_CURRENT_SOURCE_DIR}
    ${CMAKE_CURRENT_BINARY_DIR}
  )
  1. 然后我们生成一个可移植的导出头文件:
include(GenerateExportHeader)
generate_export_header(account
  BASE_NAME account
  )
  1. 现在我们准备好了对 Python—C 接口进行测试:
$ mkdir -p build
$ cd build
$ cmake ..
$ cmake --build .
$ ctest

    Start 1: python_test
1/1 Test #1: python_test ...................... Passed 0.14 sec

100% tests passed, 0 tests failed out of 1

它是如何工作的

虽然前面的示例要求我们显式声明 Python—C 接口并将 Python 名称映射到 C(++) 符号,但 Python CFFI 会根据 C 头文件(在我们的例子中是 account.h)自动推断此映射。我们只需要向 Python CFFI 层提供描述 C 接口的头文件和包含符号的共享库。我们已经在主 CMakeLists.txt 文件中使用环境变量完成了此操作,并在 __init__.py 中查询了这些环境变量:

# ...

def get_lib_handle(definitions, header_file, library_file):
    ffi = FFI()
    command = ['cc', '-E'] + definitions + [header_file]
    interface = check_output(command).decode('utf-8')

    # remove possible \r characters on windows which
    # would confuse cdef
    _interface = [l.strip('\r') for l in interface.split('\n')]

    ffi.cdef('\n'.join(_interface))
    lib = ffi.dlopen(library_file)
    return lib

# ...

_this_path = Path(os.path.dirname(os.path.realpath(__file__)))
_cfg_file = _this_path / 'interface_file_names.cfg'
if _cfg_file.exists():
    # we will discuss this section in chapter 11, recipe 3
else:
    _header_file = os.getenv('ACCOUNT_HEADER_FILE')
    assert _header_file is not None
    _library_file = os.getenv('ACCOUNT_LIBRARY_FILE')
    assert _library_file is not None

_lib = get_lib_handle(definitions=['-DACCOUNT_API=', '-DACCOUNT_NOINCLUDE'],
                      header_file=_header_file,
                      library_file=_library_file)

# ...

get_lib_handle 函数打开并解析头文件(使用 ffi.cdef),加载库(使用 ffi.dlopen),并返回库对象。前面的文件原则上具有通用性,可以不经修改地重用于其他连接 Python 和 C 或其他使用 Python CFFI 语言的项目。

_lib 库对象可以直接导出,但我们又多做了一步,以便在 Python 端使用时 Python 接口感觉更 pythonic

# we change names to obtain a more pythonic API
new = _lib.account_new
free = _lib.account_free
deposit = _lib.account_deposit
withdraw = _lib.account_withdraw
get_balance = _lib.account_get_balance

__all__ = [
    '__version__',
    'new',
    'free',
    'deposit',
    'withdraw',
    'get_balance',
]

有了这个改动,我们可以这样写:

import account

account1 = account.new()

account.deposit(account1, 100.0)

另一种方法则不那么直观:

from account import lib

account1 = lib.account_new()

lib.account_deposit(account1, 100.0)

请注意,我们能够使用上下文感知的 API 实例化和跟踪隔离的上下文:

account1 = account.new()
account.deposit(account1, 10.0)

account2 = account.new()
account.withdraw(account1, 5.0)
account.deposit(account2, 5.0)

为了导入 account Python 模块,我们需要提供 ACCOUNT_HEADER_FILEACCOUNT_LIBRARY_FILE 环境变量,就像我们为测试所做的那样:

add_test(
  NAME
    python_test
  COMMAND
    ${CMAKE_COMMAND} -E env ACCOUNT_MODULE_PATH=${CMAKE_CURRENT_SOURCE_DIR}
                            ACCOUNT_HEADER_FILE=${CMAKE_CURRENT_SOURCE_DIR}/account/account.h
                            ACCOUNT_LIBRARY_FILE=$<TARGET_FILE:account>
    ${PYTHON_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/account/test.py
  )

在 第十一章《打包项目》中,我们将讨论如何创建一个可以使用 pip 安装的 Python 包,其中头文件和库文件将安装在定义良好的位置,这样我们就不必定义任何环境变量来使用 Python 模块。

讨论了接口的 Python 方面之后,现在让我们考虑接口的 C 方面。account.h 的本质是这一部分:

struct account_context;
typedef struct account_context account_context_t;

ACCOUNT_API
account_context_t *account_new();

ACCOUNT_API
void account_free(account_context_t *context);

ACCOUNT_API
void account_deposit(account_context_t *context, const double amount);

ACCOUNT_API
void account_withdraw(account_context_t *context, const double amount);

ACCOUNT_API
double account_get_balance(const account_context_t *context);

不透明的句柄 account_context 保存对象的状态。ACCOUNT_APIaccount_export.h 中定义,该文件由 CMake 在 account/interface/CMakeLists.txt 中生成:

include(GenerateExportHeader)
generate_export_header(account
  BASE_NAME account
  )

account_export.h 导出头文件定义了接口函数的可见性,并确保以可移植的方式完成。我们将在 第十章《编写安装程序》中更详细地讨论这一点。实际的实现可以在 cpp_implementation.cpp 中找到。它包含 is_initialized 布尔值,我们可以检查该值以确保 API 函数按预期顺序调用:上下文不应在创建之前或释放之后被访问。

还有更多内容

在设计 Python-C 接口时,重要的是要仔细考虑在哪一侧分配数组:数组可以在 Python 侧分配并传递给 C(++)实现,或者可以在 C(++)实现中分配并返回一个指针。后一种方法在缓冲区大小事先未知的情况下很方便。然而,从 C(++)-侧返回分配的数组指针可能会导致内存泄漏,因为 Python 的垃圾回收不会“看到”已分配的数组。我们建议设计 C API,使得数组可以在外部分配并传递给 C 实现。然后,这些数组可以在__init__.py内部分配,如本例所示:

from cffi import FFI
import numpy as np

_ffi = FFI()

def return_array(context, array_len):

    # create numpy array
    array_np = np.zeros(array_len, dtype=np.float64)

    # cast a pointer to its data
    array_p = _ffi.cast("double *", array_np.ctypes.data)

    # pass the pointer
    _lib.mylib_myfunction(context, array_len, array_p)

    # return the array as a list
    return array_np.tolist()

return_array函数返回一个 Python 列表。由于我们已经在 Python 侧完成了所有的分配工作,因此我们不必担心内存泄漏,可以将清理工作留给垃圾回收。

对于 Fortran 示例,我们建议读者参考以下配方仓库:github.com/dev-cafe/cmake-cookbook/tree/v1.0/chapter-09/recipe-06/fortran-example。与 C++实现的主要区别在于,账户库是由 Fortran 90 源文件编译而成,我们在account/CMakeLists.txt中对此进行了考虑:

add_library(account
  SHARED
    implementation/fortran_implementation.f90
  )

上下文保存在用户定义的类型中:

type :: account
  private
  real(c_double) :: balance
  logical :: is_initialized = .false.
end type

Fortran 实现能够通过使用iso_c_binding模块解析未更改的account.h中定义的符号和方法:

module account_implementation

  use, intrinsic :: iso_c_binding, only: c_double, c_ptr

  implicit none

  private

  public account_new
  public account_free
  public account_deposit
  public account_withdraw
  public account_get_balance

  type :: account
    private
    real(c_double) :: balance
    logical :: is_initialized = .false.
  end type

contains

  type(c_ptr) function account_new() bind (c)
    use, intrinsic :: iso_c_binding, only: c_loc
    type(account), pointer :: f_context
    type(c_ptr) :: context

    allocate(f_context)
    context = c_loc(f_context)
    account_new = context
    f_context%balance = 0.0d0
    f_context%is_initialized = .true.
  end function

  subroutine account_free(context) bind (c)
    use, intrinsic :: iso_c_binding, only: c_f_pointer
    type(c_ptr), value :: context
    type(account), pointer :: f_context

    call c_f_pointer(context, f_context)
    call check_valid_context(f_context)
    f_context%balance = 0.0d0
    f_context%is_initialized = .false.
    deallocate(f_context)
  end subroutine

  subroutine check_valid_context(f_context)
    type(account), pointer, intent(in) :: f_context
    if (.not. associated(f_context)) then
        print *, 'ERROR: context is not associated'
        stop 1
    end if
    if (.not. f_context%is_initialized) then
        print *, 'ERROR: context is not initialized'
        stop 1
    end if
  end subroutine

  subroutine account_withdraw(context, amount) bind (c)
    use, intrinsic :: iso_c_binding, only: c_f_pointer
    type(c_ptr), value :: context
    real(c_double), value :: amount
    type(account), pointer :: f_context

    call c_f_pointer(context, f_context)
    call check_valid_context(f_context)
    f_context%balance = f_context%balance - amount
  end subroutine

  subroutine account_deposit(context, amount) bind (c)
    use, intrinsic :: iso_c_binding, only: c_f_pointer
    type(c_ptr), value :: context
    real(c_double), value :: amount
    type(account), pointer :: f_context

    call c_f_pointer(context, f_context)
    call check_valid_context(f_context)
    f_context%balance = f_context%balance + amount
  end subroutine

  real(c_double) function account_get_balance(context) bind (c)
    use, intrinsic :: iso_c_binding, only: c_f_pointer
    type(c_ptr), value, intent(in) :: context
    type(account), pointer :: f_context

    call c_f_pointer(context, f_context)
    call check_valid_context(f_context)
    account_get_balance = f_context%balance
  end function

end module

另请参阅

本配方和解决方案的灵感来源于 Armin Ronacher 的帖子“Beautiful Native Libraries”,lucumr.pocoo.org/2013/8/18/beautiful-native-libraries/

第十一章:编写安装程序

在本章中,我们将涵盖以下节:

  • 安装你的项目

  • 生成导出头文件

  • 导出你的目标

  • 安装超级构建

引言

在前几章中,我们已经展示了如何使用 CMake 配置、构建和测试我们的项目。安装项目是开发者工具箱中同样重要的一部分,本章将展示如何实现这一点。本章的节涵盖了以下图中概述的安装时操作:

我们将引导你完成精简一个简单的 C++项目安装的各个步骤:从确保项目中构建的重要文件被复制到正确的目录,到确保依赖于你的工作的其他项目可以使用 CMake 检测到它。本章的四个节将基于第一章,从简单可执行文件到库,第三部分,构建和链接共享和静态库中给出的简单示例。在那里我们尝试构建一个非常简单的库并将其链接到一个可执行文件中。我们还展示了如何从相同的源文件构建静态和共享库。在本章中,我们将更深入地讨论并正式化安装时发生的事情。

安装你的项目

本节的代码可在github.com/dev-cafe/cmake-cookbook/tree/v1.0/chapter-10/recipe-01找到,并包含一个 C++示例。本节适用于 CMake 版本 3.6(及更高版本),并在 GNU/Linux、macOS 和 Windows 上进行了测试。

在本节的第一节中,我们将介绍我们的小项目以及将在后续节中使用的一些基本概念。安装文件、库和可执行文件是一项非常基本的任务,但它可能会带来一些陷阱。我们将引导你了解这些陷阱,并展示如何使用 CMake 有效地避免其中的许多陷阱。

准备工作

来自第一章,从简单可执行文件到库,第三部分,构建和链接共享和静态库的代码几乎未作改动地被使用:我们仅添加了对 UUID 库的依赖。这种依赖是有条件的,如果找不到 UUID 库,我们将通过预处理器排除使用它的代码。代码被适当地组织到自己的src子目录中。项目的布局如下:

.
├── CMakeLists.txt
├── src
│   ├── CMakeLists.txt
│   ├── hello-world.cpp
│   ├── Message.cpp
│   └── Message.hpp
└── tests
    └── CMakeLists.txt

我们已经可以看到,我们有一个根CMakeLists.txt,在src子目录下有一个叶子,在tests子目录下有另一个叶子。

Message.hpp头文件包含以下内容:

#pragma once

#include <iosfwd>
#include <string>

class Message {
public:
  Message(const std::string &m) : message_(m) {}

  friend std::ostream &operator<<(std::ostream &os, Message &obj) {
    return obj.printObject(os);
  }

private:
  std::string message_;
  std::ostream &printObject(std::ostream &os);
};

std::string getUUID();

这是Message.cpp中相应的实现:

#include "Message.hpp"

#include <iostream>
#include <string>

#ifdef HAVE_UUID
#include <uuid/uuid.h>
#endif

std::ostream &Message::printObject(std::ostream &os) {
  os << "This is my very nice message: " << std::endl;
  os << message_ << std::endl;
  os << "...and here is its UUID: " << getUUID();

  return os;
}

#ifdef HAVE_UUID
std::string getUUID() {
  uuid_t uuid;
  uuid_generate(uuid);
  char uuid_str[37];
  uuid_unparse_lower(uuid, uuid_str);
  uuid_clear(uuid);
  std::string uuid_cxx(uuid_str);
  return uuid_cxx;
}
#else
std::string getUUID() { return "Ooooops, no UUID for you!"; }
#endif

最后,示例hello-world.cpp可执行文件如下:

#include <cstdlib>
#include <iostream>

#include "Message.hpp"

int main() {
  Message say_hello("Hello, CMake World!");

  std::cout << say_hello << std::endl;

  Message say_goodbye("Goodbye, CMake World");

  std::cout << say_goodbye << std::endl;

  return EXIT_SUCCESS;
}

如何操作

让我们首先浏览一下根CMakeLists.txt文件:

  1. 我们像往常一样,首先要求一个最小 CMake 版本并定义一个 C++11 项目。请注意,我们已使用VERSION关键字为project命令设置了项目版本:
# CMake 3.6 needed for IMPORTED_TARGET option
# to pkg_search_module
cmake_minimum_required(VERSION 3.6 FATAL_ERROR)

project(recipe-01
  LANGUAGES CXX
  VERSION 1.0.0
  )

# <<< General set up >>>

set(CMAKE_CXX_STANDARD 11)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
  1. 用户可以通过CMAKE_INSTALL_PREFIX变量定义安装前缀。CMake 将为该变量设置一个合理的默认值:在 Unix 上是/usr/local,在 Windows 上是C:\Program Files。我们打印一条状态消息报告其值:
message(STATUS "Project will be installed to ${CMAKE_INSTALL_PREFIX}")
  1. 默认情况下,我们为项目首选Release配置。用户将能够使用CMAKE_BUILD_TYPE变量设置此项,我们检查是否是这种情况。如果不是,我们将其设置为我们自己的默认合理值:
if(NOT CMAKE_BUILD_TYPE)
  set(CMAKE_BUILD_TYPE Release CACHE STRING "Build type" FORCE)
endif()

message(STATUS "Build type set to ${CMAKE_BUILD_TYPE}")
  1. 接下来,我们告诉 CMake 在哪里构建可执行文件、静态库和共享库目标。这便于用户在不打算实际安装项目的情况下访问这些构建目标。我们使用标准的 CMake GNUInstallDirs.cmake模块。这将确保一个合理且可移植的项目布局:
include(GNUInstallDirs)

set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY
  ${PROJECT_BINARY_DIR}/${CMAKE_INSTALL_LIBDIR})
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY
  ${PROJECT_BINARY_DIR}/${CMAKE_INSTALL_LIBDIR})
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY
  ${PROJECT_BINARY_DIR}/${CMAKE_INSTALL_BINDIR})
  1. 虽然之前的命令固定了构建输出在构建目录内的位置,但以下命令需要固定可执行文件、库和包含文件在安装前缀内的位置。这些将大致遵循相同的布局,但我们定义了新的INSTALL_LIBDIRINSTALL_BINDIRINSTALL_INCLUDEDIRINSTALL_CMAKEDIR变量,用户可以覆盖这些变量,如果他们愿意的话:
# Offer the user the choice of overriding the installation directories
set(INSTALL_LIBDIR ${CMAKE_INSTALL_LIBDIR} CACHE PATH "Installation directory for libraries")
set(INSTALL_BINDIR ${CMAKE_INSTALL_BINDIR} CACHE PATH "Installation directory for executables")
set(INSTALL_INCLUDEDIR ${CMAKE_INSTALL_INCLUDEDIR} CACHE PATH "Installation directory for header files")
if(WIN32 AND NOT CYGWIN)
  set(DEF_INSTALL_CMAKEDIR CMake)
else()
  set(DEF_INSTALL_CMAKEDIR share/cmake/${PROJECT_NAME})
endif()
set(INSTALL_CMAKEDIR ${DEF_INSTALL_CMAKEDIR} CACHE PATH "Installation directory for CMake files")
  1. 我们向用户报告组件将被安装到的路径:
# Report to user
foreach(p LIB BIN INCLUDE CMAKE)
  file(TO_NATIVE_PATH ${CMAKE_INSTALL_PREFIX}/${INSTALL_${p}DIR} _path )
  message(STATUS "Installing ${p} components to ${_path}")
  unset(_path)
endforeach()
  1. CMakeLists.txt文件中的最后指令添加了src子目录,启用了测试,并添加了tests子目录:
add_subdirectory(src)

enable_testing()

add_subdirectory(tests)

我们现在继续分析src/CMakeLists.txt叶文件。该文件定义了实际要构建的目标:

  1. 我们的项目依赖于 UUID 库。如第五章,配置时间和构建时间操作,配方 8,探测执行所示,我们可以使用以下代码片段找到它:
# Search for pkg-config and UUID
find_package(PkgConfig QUIET)
if(PKG_CONFIG_FOUND)
  pkg_search_module(UUID uuid IMPORTED_TARGET)
  if(TARGET PkgConfig::UUID)
    message(STATUS "Found libuuid")
    set(UUID_FOUND TRUE)
  endif()
endif()
  1. 我们希望从源代码构建一个共享库,并声明一个名为message-shared的目标:
add_library(message-shared SHARED "")
  1. 使用target_sources命令指定此目标的源:
target_sources(message-shared
  PRIVATE
    ${CMAKE_CURRENT_LIST_DIR}/Message.cpp
  )
  1. 我们为目标声明编译定义和链接库。请注意,所有这些都是PUBLIC,以确保所有依赖目标将正确继承它们:
target_compile_definitions(message-shared
  PUBLIC
    $<$<BOOL:${UUID_FOUND}>:HAVE_UUID>
  )

target_link_libraries(message-shared
  PUBLIC
    $<$<BOOL:${UUID_FOUND}>:PkgConfig::UUID>
  )
  1. 然后我们设置目标的额外属性。我们将在稍后对此进行评论。
set_target_properties(message-shared
  PROPERTIES
    POSITION_INDEPENDENT_CODE 1
    SOVERSION ${PROJECT_VERSION_MAJOR}
    OUTPUT_NAME "message"
    DEBUG_POSTFIX "_d"
    PUBLIC_HEADER "Message.hpp"
    MACOSX_RPATH ON
    WINDOWS_EXPORT_ALL_SYMBOLS ON
  )
  1. 最后,我们为我们的“Hello, world”程序添加一个可执行目标:
add_executable(hello-world_wDSO hello-world.cpp)
  1. hello-world_wDSO可执行目标与共享库链接:
target_link_libraries(hello-world_wDSO
  PUBLIC
    message-shared
  )

src/CMakeLists.txt文件也包含了安装指令。在考虑这些之前,我们需要为我们的可执行文件固定RPATH

  1. 通过 CMake 路径操作,我们设置了message_RPATH变量。这将适当地为 GNU/Linux 和 macOS 设置RPATH
# Prepare RPATH
file(RELATIVE_PATH _rel ${CMAKE_INSTALL_PREFIX}/${INSTALL_BINDIR} ${CMAKE_INSTALL_PREFIX})
if(APPLE)
  set(_rpath "@loader_path/${_rel}")
else()
  set(_rpath "\$ORIGIN/${_rel}")
endif()
file(TO_NATIVE_PATH "${_rpath}/${INSTALL_LIBDIR}" message_RPATH)
  1. 我们现在可以使用这个变量来为我们的可执行目标hello-world_wDSO修复RPATH。这是通过目标属性实现的。我们还设置了额外的属性,稍后我们将对这些属性进行更多评论:
set_target_properties(hello-world_wDSO
  PROPERTIES
    MACOSX_RPATH ON
    SKIP_BUILD_RPATH OFF
    BUILD_WITH_INSTALL_RPATH OFF
    INSTALL_RPATH "${message_RPATH}"
    INSTALL_RPATH_USE_LINK_PATH ON
  )
  1. 我们终于准备好安装我们的库、头文件和可执行文件了!我们使用 CMake 提供的安装命令来指定这些文件应该去哪里。请注意,路径是相对的;我们将在下面进一步详细说明这一点:
install(
  TARGETS
    message-shared
    hello-world_wDSO
  ARCHIVE
    DESTINATION ${INSTALL_LIBDIR}
    COMPONENT lib
  RUNTIME
    DESTINATION ${INSTALL_BINDIR}
    COMPONENT bin
  LIBRARY
    DESTINATION ${INSTALL_LIBDIR}
    COMPONENT lib
  PUBLIC_HEADER
    DESTINATION ${INSTALL_INCLUDEDIR}/message
    COMPONENT dev
  )

测试目录中的CMakeLists.txt文件包含简单的指令,以确保“Hello, World”可执行文件运行正确:

add_test(
  NAME test_shared
  COMMAND $<TARGET_FILE:hello-world_wDSO>
  )

现在让我们配置、构建并安装项目,然后查看结果。一旦添加了任何安装指令,CMake 就会生成一个名为install的新目标,该目标将运行安装规则:

$ mkdir -p build
$ cd build
$ cmake -G"Unix Makefiles" -DCMAKE_INSTALL_PREFIX=$HOME/Software/recipe-01
$ cmake --build . --target install

在 GNU/Linux 上,构建目录的内容将是以下内容:

build
├── bin
│   └── hello-world_wDSO
├── CMakeCache.txt
├── CMakeFiles
├── cmake_install.cmake
├── CTestTestfile.cmake
├── install_manifest.txt
├── lib64
│   ├── libmessage.so -> libmessage.so.1
│   └── libmessage.so.1
├── Makefile
├── src
├── Testing
└── tests

另一方面,在安装前缀下,你可以找到以下结构:

$HOME/Software/recipe-01/
├── bin
│   └── hello-world_wDSO
├── include
│   └── message
│       └── Message.hpp
└── lib64
    ├── libmessage.so -> libmessage.so.1
    └── libmessage.so.1

这意味着安装指令中给出的位置是相对于用户给出的CMAKE_INSTALL_PREFIX实例的。

它是如何工作的

这个配方有三个要点需要我们更详细地讨论:

  • 使用GNUInstallDirs.cmake来定义我们目标安装的标准位置

  • 共享库和可执行目标设置的属性,特别是RPATH的处理

  • 安装指令

安装到标准位置

对于你的项目安装来说,一个好的布局是什么?只要你自己的项目是唯一的消费者,这个问题就只有有限的关联性。然而,一旦你开始向外界发货,人们就会期望你在安装项目时提供一个合理的布局。幸运的是,有一些标准我们可以遵守,而 CMake 可以帮助我们做到这一点。实际上,GNUInstallDirs.cmake模块所做的是定义一组变量。这些变量是不同类型的文件应该被安装的子目录的名称。在我们的例子中,我们使用了以下内容:

  • CMAKE_INSTALL_BINDIR:这将给出用户可执行文件应位于的子目录,即所选安装前缀下的bin目录。

  • CMAKE_INSTALL_LIBDIR:这扩展到对象代码库应位于的子目录,即静态和共享库。在 64 位系统上,这是lib64,而在 32 位系统上,它只是lib

  • CMAKE_INSTALL_INCLUDEDIR:最后,我们使用这个变量来获取我们的 C 头文件的正确子目录。这个变量扩展为include

然而,用户可能想要覆盖这些选择。我们在根CMakeLists.txt文件中允许了以下节:

# Offer the user the choice of overriding the installation directories
set(INSTALL_LIBDIR ${CMAKE_INSTALL_LIBDIR} CACHE PATH "Installation directory for libraries")
set(INSTALL_BINDIR ${CMAKE_INSTALL_BINDIR} CACHE PATH "Installation directory for executables")
set(INSTALL_INCLUDEDIR ${CMAKE_INSTALL_INCLUDEDIR} CACHE PATH "Installation directory for header files")

这实际上重新定义了INSTALL_BINDIRINSTALL_LIBDIRINSTALL_INCLUDEDIR便利变量,以便在我们的项目中使用。我们还定义了额外的INSTALL_CMAKEDIR变量,但其作用将在接下来的几个配方中详细讨论。

GNUInstallDirs.cmake模块定义了额外的变量,这些变量将帮助将安装的文件放置在所选安装前缀的预期子目录中。请咨询 CMake 在线文档:cmake.org/cmake/help/v3.6/module/GNUInstallDirs.html

目标属性和 RPATH 处理

让我们更仔细地看一下设置在共享库目标上的属性。我们必须设置以下内容:

  • POSITION_INDEPENDENT_CODE 1: 这设置了生成位置无关代码所需的编译器标志。有关更多详细信息,请咨询en.wikipedia.org/wiki/Position-independent_code

  • SOVERSION ${PROJECT_VERSION_MAJOR}: 这是我们的共享库提供的应用程序编程接口(API)的版本。遵循语义版本,我们决定将其设置为与项目的主要版本相同。CMake 目标也有一个VERSION属性。这可以用来指定目标的构建版本。注意SOVERSIONVERSION可能不同:我们可能希望随着时间的推移提供同一 API 的多个构建。在本示例中,我们不关心这种粒度控制:仅设置 API 版本与SOVERSION属性就足够了,CMake 将为我们设置VERSION为相同的值。有关更多详细信息,请参阅官方文档:cmake.org/cmake/help/latest/prop_tgt/SOVERSION.html

  • OUTPUT_NAME "message": 这告诉 CMake 库的基本名称是message,而不是目标名称message-shared:在构建时将生成libmessage.so.1。还会生成到libmessage.so的适当符号链接,正如前面给出的构建目录和安装前缀的内容所示。

  • DEBUG_POSTFIX "_d": 这告诉 CMake,如果我们以Debug配置构建项目,则要在生成的共享库中添加_d后缀。

  • PUBLIC_HEADER "Message.hpp": 我们使用此属性来设置定义库提供的 API 函数的头文件列表,在这种情况下只有一个。这主要是为 macOS 上的框架共享库目标设计的,但它也可以用于其他操作系统和目标,正如我们目前所做的。有关更多详细信息,请参阅官方文档:cmake.org/cmake/help/v3.6/prop_tgt/PUBLIC_HEADER.html

  • MACOSX_RPATH ON: 这将在 macOS 上将共享库的“install_name”字段的目录部分设置为@rpath

  • WINDOWS_EXPORT_ALL_SYMBOLS ON:这将强制在 Windows 上编译时导出所有符号。请注意,这通常不是一种好的做法,我们将在第 2 个菜谱中展示,即“生成导出头文件”,如何在不同平台上处理符号可见性。

现在让我们讨论RPATH。我们正在将hello-world_wDSO可执行文件链接到libmessage.so.1。这意味着当调用可执行文件时,将加载共享库。因此,为了使加载器成功完成其工作,需要在某个地方编码有关库位置的信息。关于库位置有两种方法:

  • 可以通过设置环境变量让链接器知道:

    • 在 GNU/Linux 上,这需要将路径附加到LD_LIBRARY_PATH环境变量。请注意,这很可能会污染系统上所有应用程序的链接器路径,并可能导致符号冲突(gms.tf/ld_library_path-considered-harmful.html)。

    • 在 macOS 上,您可以同样设置DYLD_LIBRARY_PATH变量。这和 GNU/Linux 上的LD_LIBRARY_PATH有同样的缺点,但可以通过使用DYLD_FALLBACK_LIBRARY_PATH变量来部分缓解这种情况。请参阅以下链接中的示例:stackoverflow.com/a/3172515/2528668

  • 它可以被编码到可执行文件中,使用RPATH设置运行时搜索路径。

后一种方法更可取且更稳健。但是,在设置动态共享对象的RPATH时应该选择哪个路径?我们需要确保无论是在构建树还是在安装树中运行可执行文件,它总是能找到正确的共享库。这是通过为hello-world_wDSO目标设置RPATH相关属性来实现的,以便查找相对于可执行文件本身位置的路径,无论是通过$ORIGIN(在 GNU/Linux 上)还是@loader_path(在 macOS 上)变量:

# Prepare RPATH
file(RELATIVE_PATH _rel ${CMAKE_INSTALL_PREFIX}/${INSTALL_BINDIR} ${CMAKE_INSTALL_PREFIX})
if(APPLE)
  set(_rpath "@loader_path/${_rel}")
else()
  set(_rpath "\$ORIGIN/${_rel}")
endif()
file(TO_NATIVE_PATH "${_rpath}/${INSTALL_LIBDIR}" message_RPATH)

一旦设置了message_RPATH变量,目标属性将完成剩余的工作:

set_target_properties(hello-world_wDSO
  PROPERTIES
    MACOSX_RPATH ON
    SKIP_BUILD_RPATH OFF
    BUILD_WITH_INSTALL_RPATH OFF
    INSTALL_RPATH "${message_RPATH}"
    INSTALL_RPATH_USE_LINK_PATH ON
  )

让我们详细检查这个命令:

  • SKIP_BUILD_RPATH OFF:告诉 CMake 生成适当的RPATH,以便能够在构建树内运行可执行文件。

  • BUILD_WITH_INSTALL_RPATH OFF:关闭生成具有与安装树相同的RPATH的可执行目标。这将阻止我们在构建树内运行可执行文件。

  • INSTALL_RPATH "${message_RPATH}":将安装的可执行目标的RPATH设置为先前计算的路径。

  • INSTALL_RPATH_USE_LINK_PATH ON:告诉 CMake 将链接器搜索路径附加到可执行文件的RPATH

关于加载器在 Unix 系统上如何工作的更多信息,可以在这篇博客文章中找到:longwei.github.io/rpath_origin/

安装指令

最后,让我们考虑安装指令。我们需要安装一个可执行文件、一个库和一个头文件。可执行文件和库是构建目标,因此我们使用install命令的TARGETS选项。可以一次性设置多个目标的安装规则:CMake 知道它们是什么类型的目标;也就是说,它们是可执行文件、共享库还是静态库:

install(
  TARGETS
    message-shared
    hello-world_wDSO

可执行文件将被安装在RUNTIME DESTINATION,我们将其设置为 ${INSTALL_BINDIR}。共享库被安装到LIBRARY DESTINATION,我们将其设置为 ${INSTALL_LIBDIR}。静态库将被安装到ARCHIVE DESTINATION,我们也将其设置为 ${INSTALL_LIBDIR}

  ARCHIVE
    DESTINATION ${INSTALL_LIBDIR}
    COMPONENT lib
  RUNTIME
    DESTINATION ${INSTALL_BINDIR}
    COMPONENT bin
  LIBRARY
    DESTINATION ${INSTALL_LIBDIR}
    COMPONENT lib

请注意,我们不仅指定了DESTINATION,还指定了COMPONENT。当使用cmake --build . --target install命令安装项目时,所有组件都如预期那样被安装了。然而,有时可能只希望安装其中一些组件。这就是COMPONENT关键字可以帮助我们的地方。例如,要仅安装库,我们可以运行以下命令:

$ cmake -D COMPONENT=lib -P cmake_install.cmake

由于Message.hpp头文件被设置为项目的公共头文件,我们可以使用PUBLIC_HEADER关键字将其与其他目标一起安装到选定的目的地: ${INSTALL_INCLUDEDIR}/message。库的用户现在可以通过以下方式包含头文件:#include <message/Message.hpp>,前提是正确的位置通过-I选项传递给编译器。

安装指令中的各种目的地被解释为相对路径,除非使用绝对路径。但是相对于什么?CMake 可以根据触发安装的工具以不同的方式计算绝对路径。当我们使用cmake --build . --target install时,正如我们所做的那样,路径将相对于CMAKE_INSTALL_PREFIX计算。然而,当使用 CPack 时,绝对路径将相对于CPACK_PACKAGING_INSTALL_PREFIX计算。CPack 的使用将在第十一章,打包项目,第 1 个配方,生成源代码和二进制包中展示。

另一种机制在 Unix Makefiles 和 Ninja 生成器中可用:DESTDIR。可以将整个安装树重新定位到由DESTDIR指定的目录下。也就是说,env DESTDIR=/tmp/stage cmake --build . --target install将相对于CMAKE_INSTALL_PREFIX安装项目,并在/tmp/stage目录下。您可以在这里了解更多信息:www.gnu.org/prep/standards/html_node/DESTDIR.html

还有更多

正确设置 RPATH 可能相当棘手,但对于第三方用户来说至关重要。默认情况下,CMake 设置可执行文件的 RPATH,假设它们将从构建树中运行。然而,在安装时,RPATH 被清除,导致用户想要运行 hello-world_wDSO 时出现问题。在 Linux 上使用 ldd 工具,我们可以检查构建树中的 hello-world_wDSO 可执行文件,以查看加载器将在哪里查找 libmessage.so

libmessage.so.1 => /home/user/cmake-cookbook/chapter-10/recipe-01/cxx-example/build/lib64/libmessage.so.1 (0x00007f7a92e44000)

在安装前缀中运行 ldd hello-world_wDSO 将导致以下结果:

    libmessage.so.1 => Not found

这显然是错误的。然而,始终将 RPATH 硬编码指向构建树或安装前缀也同样错误:这两个位置中的任何一个都可能被删除,导致可执行文件损坏。这里提出的解决方案为构建树中的可执行文件和安装前缀中的可执行文件设置了不同的 RPATH,以便它总是指向“有意义”的地方;也就是说,尽可能靠近可执行文件。在构建树中运行 ldd 显示相同的结果:

libmessage.so.1 => /home/roberto/Workspace/robertodr/cmake-cookbook/chapter-10/recipe-01/cxx-example/build/lib64/libmessage.so.1 (0x00007f7a92e44000)

另一方面,在安装前缀中,我们现在得到以下结果:

libmessage.so.1 => /home/roberto/Software/ch10r01/bin/../lib64/libmessage.so.1 (0x00007fbd2a725000)

我们已经使用带有 TARGETS 签名的 CMake 安装命令,因为我们需要安装构建目标。但是,该命令还有四个额外的签名:

生成导出头文件

本节的代码可在github.com/dev-cafe/cmake-cookbook/tree/v1.0/chapter-10/recipe-02找到,并包含一个 C++示例。本节适用于 CMake 版本 3.6(及以上),并在 GNU/Linux、macOS 和 Windows 上进行了测试。

让我们设想一下,我们介绍的小型库已经变得非常流行,许多人都在使用它。然而,一些客户也希望在安装时提供一个静态库。其他客户注意到,共享库中的所有符号都是可见的。最佳实践规定,共享库只应公开最小数量的符号,从而限制代码中定义的对象和函数对外界的可见性。我们希望确保默认情况下,我们共享库中定义的所有符号对库外都是隐藏的。这将迫使项目贡献者明确界定库与外部代码之间的接口,因为他们必须明确标记那些也打算在项目外部使用的符号。因此,我们希望做以下事情:

  • 从同一组源文件构建共享和静态库。

  • 确保只有共享库中的符号可见性得到适当界定。

第三部分,构建和链接静态和共享库,在第一章,从简单的可执行文件到库,已经展示了 CMake 提供了实现第一点的平台无关功能。然而,我们没有解决符号可见性的问题。我们将使用当前的节重新审视这两点。

准备工作

我们仍将主要使用与上一节相同的代码,但我们需要修改src/CMakeLists.txtMessage.hpp头文件。后者将包含新的自动生成的头文件messageExport.h

#pragma once

#include <iosfwd>
#include <string>

#include "messageExport.h"

class message_EXPORT Message {
public:
  Message(const std::string &m) : message_(m) {}

  friend std::ostream &operator<<(std::ostream &os, Message &obj) {
    return obj.printObject(os);
  }

private:
  std::string message_;
  std::ostream &printObject(std::ostream &os);
};

std::string getUUID();

message_EXPORT预处理器指令在Message类的声明中被引入。这个指令将允许编译器生成对库用户可见的符号。

如何操作

除了项目名称之外,根目录的CMakeLists.txt文件保持不变。让我们首先看一下src子目录中的CMakeLists.txt文件,所有额外的工作实际上都在这里进行。我们将根据上一节中的文件来突出显示更改:

  1. 我们声明了我们的SHARED库目标及其消息库的源文件。请注意,编译定义和链接库保持不变:
add_library(message-shared SHARED "")

target_sources(message-shared
  PRIVATE
    ${CMAKE_CURRENT_LIST_DIR}/Message.cpp
  )

target_compile_definitions(message-shared
  PUBLIC
    $<$<BOOL:${UUID_FOUND}>:HAVE_UUID>
  )

target_link_libraries(message-shared
  PUBLIC
    $<$<BOOL:${UUID_FOUND}>:PkgConfig::UUID>
  )
  1. 我们还设置了目标属性。我们在PUBLIC_HEADER目标属性的参数中添加了${CMAKE_BINARY_DIR}/${INSTALL_INCLUDEDIR}/messageExport.h头文件。CXX_VISIBILITY_PRESETVISIBILITY_INLINES_HIDDEN属性将在下一节讨论:
set_target_properties(message-shared
  PROPERTIES
    POSITION_INDEPENDENT_CODE 1
    CXX_VISIBILITY_PRESET hidden
    VISIBILITY_INLINES_HIDDEN 1
    SOVERSION ${PROJECT_VERSION_MAJOR}
    OUTPUT_NAME "message"
    DEBUG_POSTFIX "_d"
    PUBLIC_HEADER "Message.hpp;${CMAKE_BINARY_DIR}/${INSTALL_INCLUDEDIR}/messageExport.h"
    MACOSX_RPATH ON
  )
  1. 我们包含了标准的 CMake 模块GenerateExportHeader.cmake,并调用了generate_export_header函数。这将生成位于构建目录子目录中的messageExport.h头文件。我们很快将详细讨论这个函数和生成的头文件:
include(GenerateExportHeader)
generate_export_header(message-shared
  BASE_NAME "message"
  EXPORT_MACRO_NAME "message_EXPORT"
  EXPORT_FILE_NAME "${CMAKE_BINARY_DIR}/${INSTALL_INCLUDEDIR}/messageExport.h"
  DEPRECATED_MACRO_NAME "message_DEPRECATED"
  NO_EXPORT_MACRO_NAME "message_NO_EXPORT"
  STATIC_DEFINE "message_STATIC_DEFINE"
  NO_DEPRECATED_MACRO_NAME "message_NO_DEPRECATED"
  DEFINE_NO_DEPRECATED
  )
  1. 每当需要将符号的可见性从默认的隐藏值更改时,都应该包含导出头文件。我们在Message.hpp头文件中做到了这一点,因为我们希望在库中暴露一些符号。现在我们将${CMAKE_BINARY_DIR}/${INSTALL_INCLUDEDIR}目录列为message-shared目标的PUBLIC包含目录:
target_include_directories(message-shared
  PUBLIC
    ${CMAKE_BINARY_DIR}/${INSTALL_INCLUDEDIR}
  ) 

现在我们可以将注意力转向静态库的生成:

  1. 我们添加了一个库目标来生成静态库。将使用与共享库相同的源代码编译来获得这个目标:
add_library(message-static STATIC "")

target_sources(message-static
  PRIVATE
    ${CMAKE_CURRENT_LIST_DIR}/Message.cpp
  )
  1. 我们设置了编译定义、包含目录和链接库,就像我们为共享库目标所做的那样。然而,请注意,我们添加了message_STATIC_DEFINE编译定义。这是为了确保我们的符号被正确暴露:
target_compile_definitions(message-static
  PUBLIC
    message_STATIC_DEFINE
    $<$<BOOL:${UUID_FOUND}>:HAVE_UUID>
  )

target_include_directories(message-static
  PUBLIC
    ${CMAKE_BINARY_DIR}/${INSTALL_INCLUDEDIR}
  )

target_link_libraries(message-static
  PUBLIC
    $<$<BOOL:${UUID_FOUND}>:PkgConfig::UUID>
  )
  1. 我们还为message-static目标设置了属性。这些将在下一节中讨论:
set_target_properties(message-static
  PROPERTIES
    POSITION_INDEPENDENT_CODE 1
    ARCHIVE_OUTPUT_NAME "message"
    DEBUG_POSTFIX "_sd"
    RELEASE_POSTFIX "_s"
    PUBLIC_HEADER "Message.hpp;${CMAKE_BINARY_DIR}/${INSTALL_INCLUDEDIR}/messageExport.h"
  )
  1. 除了链接message-shared库目标的hello-world_wDSO可执行目标之外,我们还定义了另一个可执行目标hello-world_wAR。这个目标链接的是静态库:
add_executable(hello-world_wAR hello-world.cpp)

target_link_libraries(hello-world_wAR
  PUBLIC
    message-static
  )
  1. 安装指令现在列出了额外的message-statichello-world_wAR目标,但其他方面没有变化:
install(
  TARGETS
    message-shared
    message-static
    hello-world_wDSO
    hello-world_wAR
  ARCHIVE
    DESTINATION ${INSTALL_LIBDIR}
    COMPONENT lib
  RUNTIME
    DESTINATION ${INSTALL_BINDIR}
    COMPONENT bin
  LIBRARY
    DESTINATION ${INSTALL_LIBDIR}
    COMPONENT lib
  PUBLIC_HEADER
    DESTINATION ${INSTALL_INCLUDEDIR}/message
    COMPONENT dev
  )

它是如何工作的

这个配方展示了如何为共享库设置符号的可见性。最佳实践是默认隐藏所有符号,只明确暴露我们希望被库依赖者使用的那些符号。这通过两个步骤实现。首先,我们需要指示编译器隐藏符号。当然,不同的编译器将有不同的选项可用,直接在我们的CMakeLists.txt中手动设置这些将不是跨平台的。CMake 提供了一种设置符号可见性的健壮且跨平台的方法,即通过在共享库目标上设置两个属性:

  • CXX_VISIBILITY_PRESET hidden:这将隐藏所有符号,除非明确标记为其他。当使用 GNU 编译器时,这为目标添加了-fvisibility=hidden标志。

  • VISIBILITY_INLINES_HIDDEN 1:这将隐藏内联函数的符号。如果使用 GNU 编译器,这对应于-fvisibility-inlines-hidden

在 Windows 上,这是默认行为。实际上,回想一下,在前一个配方中,我们需要通过将WINDOWS_EXPORT_ALL_SYMBOLS属性设置为ON来覆盖它。

我们如何标记我们希望可见的符号?这是由预处理器决定的,因此我们需要提供预处理器宏,这些宏扩展为给定编译器在所选平台上将理解的可见性属性。再次,CMake 通过GenerateExportHeader.cmake模块文件来救援。该模块定义了generate_export_header函数,我们按如下方式调用它:

include(GenerateExportHeader)
generate_export_header(message-shared
  BASE_NAME "message"
  EXPORT_MACRO_NAME "message_EXPORT"
  EXPORT_FILE_NAME "${CMAKE_BINARY_DIR}/${INSTALL_INCLUDEDIR}/messageExport.h"
  DEPRECATED_MACRO_NAME "message_DEPRECATED"
  NO_EXPORT_MACRO_NAME "message_NO_EXPORT"
  STATIC_DEFINE "message_STATIC_DEFINE"
  NO_DEPRECATED_MACRO_NAME "message_NO_DEPRECATED"
  DEFINE_NO_DEPRECATED
  )

该函数生成包含所需预处理器宏的messageExport.h头文件。文件在目录${CMAKE_BINARY_DIR}/${INSTALL_INCLUDEDIR}中生成,如通过EXPORT_FILE_NAME选项所请求。如果此选项留空,头文件将在当前二进制目录中生成。该函数的第一

  • BASE_NAME:这设置生成的头文件和宏的基本名称为传入的值。

  • EXPORT_MACRO_NAME:这设置导出宏的名称。

  • EXPORT_FILE_NAME:这设置生成的导出头文件的名称。

  • DEPRECATED_MACRO_NAME:这设置废弃宏的名称。这用于标记废弃代码,如果客户端使用它,编译器将发出废弃警告。

  • NO_EXPORT_MACRO_NAME:这设置不导出宏的名称。

  • STATIC_DEFINE:这是用于当也从相同源代码编译静态库时使用的宏的名称。

  • NO_DEPRECATED_MACRO_NAME:这设置用于排除编译中废弃代码的宏的名称。

  • DEFINE_NO_DEPRECATED:这指示 CMake 生成预处理器代码,以排除编译中的废弃代码。

在 GNU/Linux 上使用 GNU 编译器时,CMake 将生成以下messageExport.h导出头文件:

#ifndef message_EXPORT_H
#define message_EXPORT_H

#ifdef message_STATIC_DEFINE
#  define message_EXPORT
#  define message_NO_EXPORT
#else
#  ifndef message_EXPORT
#    ifdef message_shared_EXPORTS
        /* We are building this library */
#      define message_EXPORT __attribute__((visibility("default")))
#    else
        /* We are using this library */
#      define message_EXPORT __attribute__((visibility("default")))
#    endif
#  endif

#  ifndef message_NO_EXPORT
#    define message_NO_EXPORT __attribute__((visibility("hidden")))
#  endif
#endif

#ifndef message_DEPRECATED
#  define message_DEPRECATED __attribute__ ((__deprecated__))
#endif

#ifndef message_DEPRECATED_EXPORT
#  define message_DEPRECATED_EXPORT message_EXPORT message_DEPRECATED
#endif

#ifndef message_DEPRECATED_NO_EXPORT
#  define message_DEPRECATED_NO_EXPORT message_NO_EXPORT message_DEPRECATED
#endif

#if 1 /* DEFINE_NO_DEPRECATED */
#  ifndef message_NO_DEPRECATED
#    define message_NO_DEPRECATED
#  endif
#endif

#endif

我们可以通过在类和函数前加上message_EXPORT宏来向用户公开它们。通过在前面加上message_DEPRECATED宏可以实现废弃。

静态库由相同的源代码构建。然而,所有符号都应在静态档案中可见,并且从messageExport.h头文件的内容可以看出,message_STATIC_DEFINE宏来救援。一旦目标被声明,我们就将其设置为编译定义。静态库上的额外目标属性如下:

  • ARCHIVE_OUTPUT_NAME "message":这将确保库文件的名称只是 message,而不是 message-static。

  • DEBUG_POSTFIX "_sd":这将给定的后缀附加到库。这独特地将库标识为在Debug配置中的静态

  • RELEASE_POSTFIX "_s":这与前面的属性类似,但仅在目标在Release配置中构建时附加后缀给静态库。

还有更多内容

在构建共享库时隐藏内部符号是一种良好的实践。这意味着库的尺寸会缩小,因为你向用户暴露的内容少于库中实际拥有的内容。这定义了应用程序二进制接口(ABI),大多数情况下应该与应用程序编程接口(API)一致。这分为两个阶段进行:

  1. 我们使用适当的编译器标志。

  2. 我们使用预处理器变量(在我们的例子中是message_EXPORT)来标记要导出的符号。在编译时,这些符号(如类和函数)的隐藏将被解除。

静态库只是对象文件的存档。因此,首先将源代码编译成对象文件,然后存档器将它们捆绑成一个存档。这里没有 ABI 的概念:所有符号默认都是可见的,编译器的可见性标志不影响静态存档。然而,如果你打算从相同的源文件构建共享库和静态库,你需要一种方法来赋予message_EXPORT预处理器变量在代码中两种情况下出现的意义。这就是GenerateExportHeader.cmake模块的作用。它将定义一个包含所有逻辑的头文件,用于给出这个预处理器变量的正确定义。对于共享库,它将根据平台和编译器的组合提供所需的内容。请注意,意义也会根据我们是构建还是使用共享库而改变。幸运的是,CMake 为我们处理了这一点,无需进一步干预。对于静态库,它将扩展为一个空字符串,做我们期望的事情:什么都不做。

细心的读者会注意到,按照这里所示构建静态库和共享库实际上需要编译源代码两次。对于我们简单的例子来说,这不是一个昂贵的操作,但对于比我们例子稍大的项目来说,这显然可能会变得相当繁重。为什么我们选择这种方法而不是在第 3 个菜谱中展示的使用OBJECT库的方法,即“构建和链接静态和共享库”,在第一章“从简单的可执行文件到库”中?OBJECT库负责编译库的第一步:从源代码到对象文件。在这一步中,预处理器介入并评估message_EXPORT。由于OBJECT库的编译只发生一次,message_EXPORT要么被评估为与构建共享库或静态库兼容的值。因此,为了避免歧义,我们选择了更稳健的方法,即编译两次,让预处理器正确评估可见性变量。

关于动态共享对象、静态存档和符号可见性的更多详细信息,我们建议阅读这篇文章:people.redhat.com/drepper/dsohowto.pdf

导出你的目标

本食谱的代码可在github.com/dev-cafe/cmake-cookbook/tree/v1.0/chapter-10/recipe-03找到,并包含一个 C++示例。该食谱适用于 CMake 版本 3.6(及以上),并在 GNU/Linux、macOS 和 Windows 上进行了测试。

我们可以想象我们的消息库在开源社区中取得了巨大的成功。人们非常喜欢它,并在自己的项目中使用它来将消息打印到屏幕上。用户特别喜欢每条打印的消息都有一个唯一标识符的事实。但用户也希望库在编译和安装到他们的系统后更容易被发现。本食谱将展示如何使用 CMake 导出我们的目标,以便使用 CMake 的其他项目可以轻松地获取它们。

准备工作

源代码与前一个食谱相比未更改,项目的结构如下:

.
├── cmake
│   └── messageConfig.cmake.in
├── CMakeLists.txt
├── src
│   ├── CMakeLists.txt
│   ├── hello-world.cpp
│   ├── Message.cpp
│   └── Message.hpp
└── tests
    ├── CMakeLists.txt
    └── use_target
        ├── CMakeLists.txt
        └── use_message.cpp

请注意,我们添加了一个包含messageConfig.cmake.in文件的cmake子目录。该文件将包含我们导出的目标。我们还添加了一个测试,以检查项目的安装和导出是否按预期工作。

如何操作

再次,根CMakeLists.txt文件与前一个食谱相比未更改。转到包含我们源文件的叶目录src

  1. 我们需要找到 UUID 库,我们可以重用之前食谱中使用的代码:
# Search for pkg-config and UUID
find_package(PkgConfig QUIET)
if(PKG_CONFIG_FOUND)
  pkg_search_module(UUID uuid IMPORTED_TARGET)
  if(TARGET PkgConfig::UUID)
    message(STATUS "Found libuuid")
    set(UUID_FOUND TRUE)
  endif()
endif()
  1. 接下来,我们设置我们的共享库目标并生成导出头文件,如前一个食谱所示:
add_library(message-shared SHARED "")

include(GenerateExportHeader)
generate_export_header(message-shared
  BASE_NAME "message"
  EXPORT_MACRO_NAME "message_EXPORT"
  EXPORT_FILE_NAME "${CMAKE_BINARY_DIR}/${INSTALL_INCLUDEDIR}/messageExport.h"
  DEPRECATED_MACRO_NAME "message_DEPRECATED"
  NO_EXPORT_MACRO_NAME "message_NO_EXPORT"
  STATIC_DEFINE "message_STATIC_DEFINE"
  NO_DEPRECATED_MACRO_NAME "message_NO_DEPRECATED"
  DEFINE_NO_DEPRECATED
  )

target_sources(message-shared
  PRIVATE
    ${CMAKE_CURRENT_LIST_DIR}/Message.cpp
  )
  1. 我们为目标设置PUBLICINTERFACE编译定义。注意后者使用$<INSTALL_INTERFACE:...>生成器表达式:
target_compile_definitions(message-shared
  PUBLIC
    $<$<BOOL:${UUID_FOUND}>:HAVE_UUID>
  INTERFACE
    $<INSTALL_INTERFACE:USING_message>
  )
  1. 接下来,设置包含目录。再次注意使用$<BUILD_INTERFACE:...>$<INSTALL_INTERFACE:...>生成器表达式。我们将在后面对此进行评论:
target_include_directories(message-shared
  PUBLIC
    $<BUILD_INTERFACE:${CMAKE_BINARY_DIR}/${INSTALL_INCLUDEDIR}>
    $<INSTALL_INTERFACE:${INSTALL_INCLUDEDIR}>
  )
  1. 我们通过列出链接库和目标属性来完成共享库目标。这些与前一个食谱中未更改:
target_link_libraries(message-shared
  PUBLIC
    $<$<BOOL:${UUID_FOUND}>:PkgConfig::UUID>
  )

set_target_properties(message-shared
  PROPERTIES
    POSITION_INDEPENDENT_CODE 1
    CXX_VISIBILITY_PRESET hidden
    VISIBILITY_INLINES_HIDDEN 1
    SOVERSION ${PROJECT_VERSION_MAJOR}
    OUTPUT_NAME "message"
    DEBUG_POSTFIX "_d"
    PUBLIC_HEADER "Message.hpp;${CMAKE_BINARY_DIR}/${INSTALL_INCLUDEDIR}/messageExport.h"
    MACOSX_RPATH ON
  )

同样,对于message-static库目标也是如此:

  1. 我们首先声明它并列出其源文件:
add_library(message-static STATIC "")

target_sources(message-static
  PRIVATE
    ${CMAKE_CURRENT_LIST_DIR}/Message.cpp
  )
  1. 我们给出PUBLICINTERFACE编译定义,就像在前一个食谱中一样,但现在使用$<INSTALL_INTERFACE:...>生成器表达式:
target_compile_definitions(message-static
  PUBLIC
    message_STATIC_DEFINE
    $<$<BOOL:${UUID_FOUND}>:HAVE_UUID>
  INTERFACE
    $<INSTALL_INTERFACE:USING_message>
  )
  1. 我们使用与共享目标相同的命令列出包含目录:
target_include_directories(message-static
  PUBLIC
    $<BUILD_INTERFACE:${CMAKE_BINARY_DIR}/${INSTALL_INCLUDEDIR}>
    $<INSTALL_INTERFACE:${INSTALL_INCLUDEDIR}>
  )
  1. 链接库和目标属性与前一个食谱相比未更改:
target_link_libraries(message-static
  PUBLIC
    $<$<BOOL:${UUID_FOUND}>:PkgConfig::UUID>
  )

set_target_properties(message-static
  PROPERTIES
    POSITION_INDEPENDENT_CODE 1
    ARCHIVE_OUTPUT_NAME "message"
    DEBUG_POSTFIX "_sd"
    RELEASE_POSTFIX "_s"
    PUBLIC_HEADER "Message.hpp;${CMAKE_BINARY_DIR}/${INSTALL_INCLUDEDIR}/messageExport.h"
  )
  1. 使用与前一个食谱中完全相同的命令生成可执行文件:
add_executable(hello-world_wDSO hello-world.cpp)

target_link_libraries(hello-world_wDSO
  PUBLIC
    message-shared
  )

# Prepare RPATH

file(RELATIVE_PATH _rel ${CMAKE_INSTALL_PREFIX}/${INSTALL_BINDIR} ${CMAKE_INSTALL_PREFIX})
if(APPLE)
  set(_rpath "@loader_path/${_rel}")
else()
  set(_rpath "\$ORIGIN/${_rel}")
endif()
file(TO_NATIVE_PATH "${_rpath}/${INSTALL_LIBDIR}" message_RPATH)

set_target_properties(hello-world_wDSO
  PROPERTIES
    MACOSX_RPATH ON
    SKIP_BUILD_RPATH OFF
    BUILD_WITH_INSTALL_RPATH OFF
    INSTALL_RPATH "${message_RPATH}"
    INSTALL_RPATH_USE_LINK_PATH ON
  )

add_executable(hello-world_wAR hello-world.cpp)

target_link_libraries(hello-world_wAR
  PUBLIC
    message-static
  )

我们现在准备查看安装规则:

  1. 我们将所有目标的安装规则列在一起,因为 CMake 可以正确地将每个目标放置在适当的目的地。这次,我们添加了EXPORT关键字,以便 CMake 将为我们导出的目标生成一个导出的目标文件:
install(
  TARGETS
    message-shared
    message-static
    hello-world_wDSO
    hello-world_wAR
  EXPORT
    messageTargets
  ARCHIVE
    DESTINATION ${INSTALL_LIBDIR}
    COMPONENT lib
  RUNTIME
    DESTINATION ${INSTALL_BINDIR}
    COMPONENT bin
  LIBRARY
    DESTINATION ${INSTALL_LIBDIR}
    COMPONENT lib
  PUBLIC_HEADER
    DESTINATION ${INSTALL_INCLUDEDIR}/message
    COMPONENT dev
  )
  1. 自动生成的导出目标文件名为messageTargets.cmake,我们需要为它明确指定安装规则。该文件的目的地是在根CMakeLists.txt文件中定义的INSTALL_CMAKEDIR
install(
  EXPORT
    messageTargets
  NAMESPACE
    "message::"
  DESTINATION
    ${INSTALL_CMAKEDIR}
  COMPONENT
    dev
  )
  1. 最后,我们需要生成适当的 CMake 配置文件。这些文件将确保下游项目能够找到由 message 库导出的目标。为此,我们首先包含CMakePackageConfigHelpers.cmake标准模块:
include(CMakePackageConfigHelpers)
  1. 我们让 CMake 生成一个包含我们库版本信息的文件:
write_basic_package_version_file(
  ${CMAKE_CURRENT_BINARY_DIR}/messageConfigVersion.cmake
  VERSION ${PROJECT_VERSION}
  COMPATIBILITY SameMajorVersion
  )
  1. 使用configure_package_config_file函数,我们生成实际的 CMake 配置文件。这是基于模板cmake/messageConfig.cmake.in文件:
configure_package_config_file(
  ${PROJECT_SOURCE_DIR}/cmake/messageConfig.cmake.in
  ${CMAKE_CURRENT_BINARY_DIR}/messageConfig.cmake
  INSTALL_DESTINATION ${INSTALL_CMAKEDIR}
  )
  1. 作为最后一步,我们为这两个自动生成的配置文件设置安装规则:
install(
  FILES
    ${CMAKE_CURRENT_BINARY_DIR}/messageConfig.cmake
    ${CMAKE_CURRENT_BINARY_DIR}/messageConfigVersion.cmake
  DESTINATION
    ${INSTALL_CMAKEDIR}
  )

cmake/messageConfig.cmake.in模板文件的内容是什么?该文件的头部作为对其用户的文档。让我们看看实际的 CMake 命令:

  1. 我们从一个占位符开始,该占位符将被configure_package_config_file命令替换:
@PACKAGE_INIT@
  1. 我们包含目标的自动生成的导出文件:
include("${CMAKE_CURRENT_LIST_DIR}/messageTargets.cmake")
  1. 然后我们使用 CMake 提供的check_required_components函数检查静态库、共享库以及两个“Hello, World”可执行文件是否存在:
check_required_components(
  "message-shared"
  "message-static"
  "message-hello-world_wDSO"
  "message-hello-world_wAR"
  )
  1. 我们检查目标PkgConfig::UUID是否存在。如果不存在,我们再次搜索 UUID 库,但仅限于不在 Windows 系统上时:
if(NOT WIN32)
  if(NOT TARGET PkgConfig::UUID)
    find_package(PkgConfig REQUIRED QUIET)
    pkg_search_module(UUID REQUIRED uuid IMPORTED_TARGET)
  endif()
endif()

让我们尝试一下:

$ mkdir -p build
$ cd build
$ cmake -DCMAKE_INSTALL_PREFIX=$HOME/Software/recipe-03 ..
$ cmake --build . --target install

安装树具有以下结构:

$HOME/Software/recipe-03/
├── bin
│   ├── hello-world_wAR
│   └── hello-world_wDSO
├── include
│   └── message
│       ├── messageExport.h
│       └── Message.hpp
├── lib64
│   ├── libmessage_s.a
│   ├── libmessage.so -> libmessage.so.1
│   └── libmessage.so.1
└── share
    └── cmake
        └── recipe-03
            ├── messageConfig.cmake
            ├── messageConfigVersion.cmake
            ├── messageTargets.cmake
            └── messageTargets-release.cmake

您会注意到出现了一个share子目录,其中包含了所有我们要求 CMake 自动生成的文件。从现在开始,使用我们的message库的用户可以在他们自己的CMakeLists.txt文件中通过设置message_DIRCMake 变量指向安装树中的share/cmake/message目录来定位message库:

find_package(message 1 CONFIG REQUIRED)

它是如何工作的

这个配方涵盖了很多内容;让我们来理解它。CMake 目标是对构建系统将要执行的操作非常有用的抽象。使用PRIVATEPUBLICINTERFACE关键字,我们可以设置同一项目内的目标将如何相互作用。实际上,这让我们定义了目标 A 的依赖项将如何影响依赖于 A 的目标 B。当其他项目想要将一个库作为依赖项使用时,可以充分体会到这种机制的强大之处。如果库维护者提供了适当的 CMake 配置文件,那么所有依赖项都可以很容易地用很少的 CMake 命令来解决。

这个问题可以通过遵循message-staticmessage-sharedhello-world_wDSOhello-world_wAR目标的配方中概述的模式来解决。我们将单独分析message-shared目标的 CMake 命令,但这里的讨论是通用的:

  1. 在项目构建中生成目标并布置其依赖项。对于message-shared,链接 UUID 库是一个PUBLIC要求,因为它将用于构建项目内的目标以及下游项目中的目标。编译定义和包含目录需要在PUBLIC INTERFACE级别设置。其中一些将用于构建项目内的目标,而其他一些仅与下游项目相关。此外,其中一些仅在项目安装后才相关。这就是$<BUILD_INTERFACE:...>$<INSTALL_INTERFACE:...>生成器表达式发挥作用的地方。只有message库外部的下游目标才需要这些,也就是说,只有在目标安装后它们才会变得可见。在我们的示例中,以下适用:

    • $<BUILD_INTERFACE:${CMAKE_BINARY_DIR}/${INSTALL_INCLUDEDIR}>仅在message-shared库目标在我们的项目内使用时,才会扩展为${CMAKE_BINARY_DIR}/${INSTALL_INCLUDEDIR}

    • $<INSTALL_INTERFACE:${INSTALL_INCLUDEDIR}>仅在message-shared库目标作为另一个构建树内的导出目标使用时,才会扩展为${INSTALL_INCLUDEDIR}

  2. 描述目标的安装规则,包括 CMake 将生成的EXPORT文件的名称。

  3. 描述 CMake 生成的导出文件的安装规则。messageTargets.cmake文件将安装到INSTALL_CMAKEDIR。目标导出文件的安装规则的NAMESPACE选项将在目标名称前加上给定的字符串。这有助于避免来自不同项目的目标之间的潜在名称冲突。INSTALL_CMAKEDIR变量在根CMakeLists.txt文件中设置:

if(WIN32 AND NOT CYGWIN)
  set(DEF_INSTALL_CMAKEDIR CMake)
else()
  set(DEF_INSTALL_CMAKEDIR share/cmake/${PROJECT_NAME})
endif()
set(INSTALL_CMAKEDIR ${DEF_INSTALL_CMAKEDIR} CACHE PATH "Installation directory for CMake files")

我们CMakeLists.txt的最后一部分生成配置文件。在包含CMakePackageConfigHelpers.cmake模块之后,这分为三个步骤完成:

  1. 我们调用write_basic_package_version_file CMake 函数来生成一个包版本文件。宏的第一个参数是版本文件的路径:messageConfigVersion.cmake。然后,我们使用PROJECT_VERSION CMake 变量以 Major.Minor.Patch 格式指定版本。还可以指定与库的新版本的兼容性。在我们的例子中,我们保证当库具有相同的 major 版本时兼容,因此使用了SameMajorVersion参数。

  2. 接下来,我们配置模板文件messageConfig.cmake.in;该文件位于项目的cmake子目录中。

  3. 最后,我们为新生成的文件设置安装规则。两者都将安装在INSTALL_CMAKEDIR下。

还有更多内容

消息库的客户端现在非常满意,因为他们终于可以在自己的系统上安装该库,并且让 CMake 为他们发现它,而无需对其自己的CMakeLists.txt进行太多修改。

find_package(message VERSION 1 REQUIRED)

客户端现在可以按以下方式配置他们的项目:

$ cmake -Dmessage_DIR=/path/to/message/share/cmake/message ..

我们示例中包含的测试展示了如何检查目标的安装是否按计划进行。查看tests文件夹的结构,我们注意到use_target子目录:

tests/
├── CMakeLists.txt
└── use_target
    ├── CMakeLists.txt
    └── use_message.cpp

该目录包含一个使用导出目标的小型项目。有趣的部分在于指定测试的CMakeLists.txt文件:

  1. 我们测试小型项目是否可以配置为使用已安装的库。这是使用目标测试夹具的设置步骤,如第四章,创建和运行测试,食谱 10,使用测试夹具所示:
add_test(
  NAME use-target_configure
  COMMAND
    ${CMAKE_COMMAND} -H${CMAKE_CURRENT_LIST_DIR}/use_target
                     -B${CMAKE_CURRENT_BINARY_DIR}/build_use-target
                     -G${CMAKE_GENERATOR}
                     -Dmessage_DIR=${CMAKE_INSTALL_PREFIX}/${
                     INSTALL_CMAKEDIR}
                     -DCMAKE_BUILD_TYPE=$<CONFIGURATION>
  )
set_tests_properties(use-target_configure
  PROPERTIES
    FIXTURES_SETUP use-target
  )
  1. 我们测试小型项目是否可以构建:
add_test(
  NAME use-target_build
  COMMAND
    ${CMAKE_COMMAND} --build ${CMAKE_CURRENT_BINARY_DIR}/build_use-target
                     --config $<CONFIGURATION>
  )
set_tests_properties(use-target_build
  PROPERTIES
    FIXTURES_REQUIRED use-target
  )
  1. 小型项目的测试也会运行:
set(_test_target)
if(MSVC)
  set(_test_target "RUN_TESTS")
else()
  set(_test_target "test")
endif()
add_test(
  NAME use-target_test
  COMMAND
    ${CMAKE_COMMAND} --build ${CMAKE_CURRENT_BINARY_DIR}/build_use-target
                     --target ${_test_target}
                     --config $<CONFIGURATION>
  )
set_tests_properties(use-target_test
  PROPERTIES
    FIXTURES_REQUIRED use-target
  )
unset(_test_target)
  1. 最后,我们拆卸夹具:
add_test(
  NAME use-target_cleanup
  COMMAND
    ${CMAKE_COMMAND} -E remove_directory ${CMAKE_CURRENT_BINARY_DIR}/build_use-target
  )
set_tests_properties(use-target_cleanup
  PROPERTIES
    FIXTURES_CLEANUP use-target
  )

请注意,这些测试只能在项目安装之后运行。

安装超级构建

本食谱的代码可在github.com/dev-cafe/cmake-cookbook/tree/v1.0/chapter-10/recipe-04找到,并包含一个 C++示例。该食谱适用于 CMake 版本 3.6(及以上),并在 GNU/Linux、macOS 和 Windows 上进行了测试。

我们的示例message库取得了巨大成功,许多其他程序员都在使用它,并且非常满意。您也想在自己的项目中使用它,但不确定如何正确管理依赖关系。您可以将message库的源代码与您自己的代码一起打包,但如果该库已经在系统上安装了呢?第八章,超级构建模式,展示了这是一个典型的超级构建场景,但您不确定如何安装这样的项目。本食谱将引导您了解安装超级构建的细节。

准备就绪

本食谱将构建一个简单的可执行文件,该文件链接到message库。项目的布局如下:

├── cmake
│   ├── install_hook.cmake.in
│   └── print_rpath.py
├── CMakeLists.txt
├── external
│   └── upstream
│       ├── CMakeLists.txt
│       └── message
│           └── CMakeLists.txt
└── src
    ├── CMakeLists.txt
    └── use_message.cpp

CMakeLists.txt文件协调超级构建。external子目录包含处理依赖关系的 CMake 指令。cmake子目录包含一个 Python 脚本和一个模板 CMake 脚本。这些将用于微调安装,首先配置 CMake 脚本,然后执行以调用 Python 脚本打印已安装的use_message可执行文件的RPATH

import shlex
import subprocess
import sys

def main():
    patcher = sys.argv[1]
    elfobj = sys.argv[2]

    tools = {'patchelf': '--print-rpath', 'chrpath': '--list', 'otool': '-L'}
    if patcher not in tools.keys():
        raise RuntimeError('Unknown tool {}'.format(patcher))
    cmd = shlex.split('{:s} {:s} {:s}'.format(patcher, tools[patcher], elfobj))
    rpath = subprocess.run(
        cmd,
        bufsize=1,
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE,
        universal_newlines=True)
    print(rpath.stdout)

if __name__ == "__main__":
    main()

使用平台原生工具打印RPATH很容易,我们将在本食谱后面讨论这些工具。

最后,src子目录包含实际项目要编译的CMakeLists.txt和源文件。use_message.cpp源文件包含以下内容:

#include <cstdlib>
#include <iostream>

#ifdef USING_message
#include <message/Message.hpp>
void messaging() {
  Message say_hello("Hello, World! From a client of yours!");
  std::cout << say_hello << std::endl;

  Message say_goodbye("Goodbye, World! From a client of yours!");
  std::cout << say_goodbye << std::endl;
}
#else
void messaging() {
  std::cout << "Hello, World! From a client of yours!" << std::endl;

  std::cout << "Goodbye, World! From a client of yours!" << std::endl;
}
#endif

int main() {
  messaging();
  return EXIT_SUCCESS;
}

如何操作

我们将从查看协调超级构建的根CMakeLists.txt文件开始:

  1. 其序言与之前的食谱相比没有变化。我们首先声明一个 C++11 项目,设置一个合理的默认安装前缀、构建类型、目标的输出目录以及安装树中组件的布局:
cmake_minimum_required(VERSION 3.6 FATAL_ERROR)

project(recipe-04
  LANGUAGES CXX
  VERSION 1.0.0
  )

# <<< General set up >>>

set(CMAKE_CXX_STANDARD 11)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

if(NOT CMAKE_BUILD_TYPE)
  set(CMAKE_BUILD_TYPE Release CACHE STRING "Build type" FORCE)
endif()

message(STATUS "Build type set to ${CMAKE_BUILD_TYPE}")

message(STATUS "Project will be installed to ${CMAKE_INSTALL_PREFIX}")

include(GNUInstallDirs)

set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY
  ${PROJECT_BINARY_DIR}/${CMAKE_INSTALL_LIBDIR})
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY
  ${PROJECT_BINARY_DIR}/${CMAKE_INSTALL_LIBDIR})
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY
  ${PROJECT_BINARY_DIR}/${CMAKE_INSTALL_BINDIR})

# Offer the user the choice of overriding the installation directories
set(INSTALL_LIBDIR ${CMAKE_INSTALL_LIBDIR} CACHE PATH "Installation directory for libraries")
set(INSTALL_BINDIR ${CMAKE_INSTALL_BINDIR} CACHE PATH "Installation directory for executables")
set(INSTALL_INCLUDEDIR ${CMAKE_INSTALL_INCLUDEDIR} CACHE PATH "Installation directory for header files")
if(WIN32 AND NOT CYGWIN)
  set(DEF_INSTALL_CMAKEDIR CMake)
else()
  set(DEF_INSTALL_CMAKEDIR share/cmake/${PROJECT_NAME})
endif()
set(INSTALL_CMAKEDIR ${DEF_INSTALL_CMAKEDIR} CACHE PATH "Installation directory for CMake files")

# Report to user
foreach(p LIB BIN INCLUDE CMAKE)
  file(TO_NATIVE_PATH ${CMAKE_INSTALL_PREFIX}/${INSTALL_${p}DIR} _path )
  message(STATUS "Installing ${p} components to ${_path}")
  unset(_path)
endforeach()
  1. 我们设置EP_BASE目录属性。这将设置超级构建中子项目的布局。所有子项目都将在CMAKE_BINARY_DIRsubprojects文件夹下检出和构建:
set_property(DIRECTORY PROPERTY EP_BASE ${CMAKE_BINARY_DIR}/subprojects)
  1. 然后我们声明STAGED_INSTALL_PREFIX变量。该变量指向构建目录下的stage子目录。项目将在构建期间安装在这里。这是一种沙盒化安装过程的方法,并给我们一个机会来检查整个超级构建是否将按照正确的布局安装:
set(STAGED_INSTALL_PREFIX ${CMAKE_BINARY_DIR}/stage)
message(STATUS "${PROJECT_NAME} staged install: ${STAGED_INSTALL_PREFIX}")
  1. 我们添加external/upstream子目录。这包含管理我们的上游依赖项的 CMake 指令,在我们的例子中,是message库:
add_subdirectory(external/upstream)
  1. 然后我们包含ExternalProject.cmake标准模块:
include(ExternalProject)
  1. 我们将自己的项目作为外部项目添加,调用ExternalProject_Add命令。SOURCE_DIR选项指定源代码位于src子目录中。我们还传递了所有适当的 CMake 参数来配置我们的项目。注意使用STAGED_INSTALL_PREFIX作为子项目的安装前缀:
ExternalProject_Add(${PROJECT_NAME}_core
  DEPENDS
    message_external
  SOURCE_DIR
    ${CMAKE_CURRENT_SOURCE_DIR}/src
  CMAKE_ARGS
    -DCMAKE_INSTALL_PREFIX=${STAGED_INSTALL_PREFIX}
    -DCMAKE_BUILD_TYPE=${CMAKE_BUILD_TYPE}
    -DCMAKE_CXX_COMPILER=${CMAKE_CXX_COMPILER}
    -DCMAKE_CXX_FLAGS=${CMAKE_CXX_FLAGS}
    -DCMAKE_CXX_STANDARD=${CMAKE_CXX_STANDARD}
    -DCMAKE_CXX_EXTENSIONS=${CMAKE_CXX_EXTENSIONS}
    -DCMAKE_CXX_STANDARD_REQUIRED=${CMAKE_CXX_STANDARD_REQUIRED}
    -Dmessage_DIR=${message_DIR}
  CMAKE_CACHE_ARGS
    -DCMAKE_PREFIX_PATH:PATH=${CMAKE_PREFIX_PATH}
  BUILD_ALWAYS
    1
  )
  1. 现在我们为recipe-04_core目标构建的use_message可执行文件添加一个测试。这将运行位于构建树内的use_message可执行文件的临时安装:
enable_testing()

add_test(
  NAME
    check_use_message
  COMMAND
    ${STAGED_INSTALL_PREFIX}/${INSTALL_BINDIR}/use_message
  )
  1. 最后,我们可以声明安装规则。这次它们相当简单。由于所需的一切都已按照正确的布局安装在临时区域中,我们只需要将临时区域的全部内容复制到安装前缀:
install(
  DIRECTORY
    ${STAGED_INSTALL_PREFIX}/
  DESTINATION
    .
  USE_SOURCE_PERMISSIONS
  )
  1. 我们使用SCRIPT参数声明一个额外的安装规则。CMake 脚本install_hook.cmake将被执行,但仅限于 GNU/Linux 和 macOS。该脚本将打印已安装可执行文件的RPATH并运行它。我们将在下一节中详细讨论这一点:
if(UNIX)
  set(PRINT_SCRIPT "${CMAKE_CURRENT_LIST_DIR}/cmake/print_rpath.py")
  configure_file(cmake/install_hook.cmake.in install_hook.cmake @ONLY)
  install(
    SCRIPT
      ${CMAKE_CURRENT_BINARY_DIR}/install_hook.cmake
    )
endif()

您可能已经注意到,-Dmessage_DIR=${message_DIR}作为 CMake 参数传递给了我们自己的项目。这将正确设置消息库依赖项的位置。message_DIR的值在external/upstream/message目录下的CMakeLists.txt文件中定义。该文件处理对message库的依赖——让我们看看它是如何处理的:

  1. 我们首先尝试找到该软件包。可能用户已经在系统上的某个地方安装了它,并在配置时传递了message_DIR选项:
find_package(message 1 CONFIG QUIET)
  1. 如果情况确实如此,并且找到了message,我们向用户报告目标的位置和版本,并添加一个虚拟的message_external目标。虚拟目标是正确处理超级构建依赖项所必需的:
if(message_FOUND)
  get_property(_loc TARGET message::message-shared PROPERTY LOCATION)
  message(STATUS "Found message: ${_loc} (found version ${message_VERSION})")
  add_library(message_external INTERFACE)  # dummy
  1. 如果未找到该库,我们将把它作为外部项目添加,从其在线 Git 存储库下载并编译它。安装前缀、构建类型和安装目录布局都是从根CMakeLists.txt文件设置的,C++编译器和标志也是如此。该项目将被安装到STAGED_INSTALL_PREFIX,然后进行测试:
else()
  include(ExternalProject)
  message(STATUS "Suitable message could not be located, Building message instead.")
  ExternalProject_Add(message_external
    GIT_REPOSITORY
      https://github.com/dev-cafe/message.git
    GIT_TAG
      master
    UPDATE_COMMAND
      ""
    CMAKE_ARGS
      -DCMAKE_INSTALL_PREFIX=${STAGED_INSTALL_PREFIX}
      -DCMAKE_BUILD_TYPE=${CMAKE_BUILD_TYPE}
      -DCMAKE_CXX_COMPILER=${CMAKE_CXX_COMPILER}
    CMAKE_CACHE_ARGS
      -DCMAKE_CXX_FLAGS:STRING=${CMAKE_CXX_FLAGS}
    TEST_AFTER_INSTALL
      1
    DOWNLOAD_NO_PROGRESS
      1
    LOG_CONFIGURE
      1
    LOG_BUILD
      1
    LOG_INSTALL
      1
    )
  1. 最后,我们将message_DIR目录设置为指向新构建的messageConfig.cmake文件的位置。请注意,路径被保存到 CMake 缓存中:
  if(WIN32 AND NOT CYGWIN)
    set(DEF_message_DIR ${STAGED_INSTALL_PREFIX}/CMake)
  else()
    set(DEF_message_DIR ${STAGED_INSTALL_PREFIX}/share/cmake/message)
  endif()
  file(TO_NATIVE_PATH "${DEF_message_DIR}" DEF_message_DIR)
  set(message_DIR ${DEF_message_DIR}
      CACHE PATH "Path to internally built messageConfig.cmake" FORCE)
endif()

我们终于准备好编译我们自己的项目,并成功地将其链接到message库,无论是系统上已有的还是为了这个目的新构建的。由于这是一个超级构建,位于src子目录下的代码是一个完全独立的 CMake 项目:

  1. 我们声明一个 C++11 项目,一如既往:
cmake_minimum_required(VERSION 3.6 FATAL_ERROR)

project(recipe-04_core
  LANGUAGES CXX
  )

set(CMAKE_CXX_STANDARD 11)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

include(GNUInstallDirs)

set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY
  ${CMAKE_BINARY_DIR}/${CMAKE_INSTALL_LIBDIR})
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY
  ${CMAKE_BINARY_DIR}/${CMAKE_INSTALL_LIBDIR})
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY
  ${CMAKE_BINARY_DIR}/${CMAKE_INSTALL_BINDIR})
  1. 我们尝试查找message库。在我们的超级构建中,配置将正确设置message_DIR
find_package(message 1 CONFIG REQUIRED)
get_property(_loc TARGET message::message-shared PROPERTY LOCATION)
message(STATUS "Found message: ${_loc} (found version ${message_VERSION})")
  1. 我们准备好添加我们的可执行目标use_message。这是从use_message.cpp源文件构建的,并链接了message::message-shared目标:
add_executable(use_message use_message.cpp)

target_link_libraries(use_message
  PUBLIC
    message::message-shared
  )
  1. 为目标属性设置use_message。再次注意RPATH修复:
# Prepare RPATH
file(RELATIVE_PATH _rel ${CMAKE_INSTALL_PREFIX}/${CMAKE_INSTALL_BINDIR} ${CMAKE_INSTALL_PREFIX})
if(APPLE)
  set(_rpath "@loader_path/${_rel}")
else()
  set(_rpath "\$ORIGIN/${_rel}")
endif()
file(TO_NATIVE_PATH "${_rpath}/${CMAKE_INSTALL_LIBDIR}" use_message_RPATH)

set_target_properties(use_message
  PROPERTIES
    MACOSX_RPATH ON
    SKIP_BUILD_RPATH OFF
    BUILD_WITH_INSTALL_RPATH OFF
    INSTALL_RPATH "${use_message_RPATH}"
    INSTALL_RPATH_USE_LINK_PATH ON
  )
  1. 最后,我们为use_message目标设置安装规则:
install(
  TARGETS
    use_message
  RUNTIME
    DESTINATION ${CMAKE_INSTALL_BINDIR}
    COMPONENT bin
  )

现在让我们看看install_hook.cmake.in模板 CMake 脚本的内容:

  1. CMake 脚本在我们的主项目范围之外执行,因此对在那里定义的变量或目标没有任何概念。因此,我们设置一个变量,其中包含已安装的use_message可执行文件的完整路径。请注意使用@INSTALL_BINDIR@,它将由configure_file解析:
set(_executable ${CMAKE_INSTALL_PREFIX}/@INSTALL_BINDIR@/use_message)
  1. 我们需要找到用于打印已安装可执行文件的RPATH的平台原生工具的可执行文件。我们将搜索chrpathpatchelfotool。一旦找到其中一个已安装的工具,搜索就会退出,并向用户显示有帮助的状态消息:
set(_patcher)
list(APPEND _patchers chrpath patchelf otool)
foreach(p IN LISTS _patchers)
  find_program(${p}_FOUND
    NAMES
      ${p}
    )
  if(${p}_FOUND)
    set(_patcher ${p})
    message(STATUS "ELF patching tool ${_patcher} FOUND")
    break()
  endif()
endforeach()
  1. 我们检查_patcher变量是否不为空。这意味着没有可用的 ELF 修补工具,我们想要执行的操作将会失败。我们发出致命错误,并通知用户需要安装其中一个 ELF 修补工具:
if(NOT _patcher)
  message(FATAL_ERROR "ELF patching tool NOT FOUND!\nPlease install one of chrpath, patchelf or otool")
  1. 如果找到了 ELF 修补工具之一,我们继续进行。我们调用print_rpath.py Python 脚本,将_executable变量作为参数传递。我们为此目的使用execute_process
find_package(PythonInterp REQUIRED QUIET)
execute_process(
  COMMAND
    ${PYTHON_EXECUTABLE} @PRINT_SCRIPT@ "${_patcher}"  
 "${_executable}"
  RESULT_VARIABLE _res
  OUTPUT_VARIABLE _out
  ERROR_VARIABLE _err
  OUTPUT_STRIP_TRAILING_WHITESPACE
  )
  1. 我们检查_res变量以获取返回代码。如果执行成功,我们打印在_out变量中捕获的标准输出流。否则,我们在退出前打印捕获的标准输出和错误流,并显示致命错误:
  if(_res EQUAL 0)
    message(STATUS "RPATH for ${_executable} is ${_out}")
  else()
    message(STATUS "Something went wrong!")
    message(STATUS "Standard output from print_rpath.py: ${_out}")
    message(STATUS "Standard error from print_rpath.py: ${_err}")
    message(FATAL_ERROR "${_patcher} could NOT obtain RPATH for ${_executable}")
  endif()
endif()
  1. 我们再次调用execute_process来运行已安装的use_message可执行文件:
execute_process(
  COMMAND ${_executable}
  RESULT_VARIABLE _res
  OUTPUT_VARIABLE _out
  ERROR_VARIABLE _err
  OUTPUT_STRIP_TRAILING_WHITESPACE
  )
  1. 最后,我们向用户报告execute_process的结果:
if(_res EQUAL 0)
  message(STATUS "Running ${_executable}:\n ${_out}")
else()
  message(STATUS "Something went wrong!")
  message(STATUS "Standard output from running ${_executable}:\n ${_out}")
  message(STATUS "Standard error from running ${_executable}:\n ${_err}")
  message(FATAL_ERROR "Something went wrong with ${_executable}")
endif()

工作原理

超级构建是我们 CMake 工具箱中非常有用的模式。它允许我们通过将它们分成更小、更易于管理的子项目来管理复杂项目。此外,我们可以将 CMake 用作项目构建的包管理器。CMake 可以搜索我们的依赖项,如果它们在系统上找不到,可以为我们新构建它们。基本模式需要三个CMakeLists.txt文件:

  • CMakeLists.txt文件包含项目和依赖项共享的设置。它还将我们自己的项目作为外部项目包含在内。在我们的例子中,我们选择了名称${PROJECT_NAME}_core;也就是说,recipe-04_core,因为项目名称recipe-04用于超级构建。

  • 外部CMakeLists.txt文件将尝试找到我们的上游依赖项,并包含根据是否找到依赖项来切换导入目标或构建它们的逻辑。为每个依赖项提供单独的子目录,并包含结构类似的CMakeLists.txt文件,这是一个好习惯。

  • 最后,我们自己的项目的CMakeLists.txt文件是一个独立的 CMake 项目文件,因为原则上,我们可以单独配置和构建它,而不需要超级构建提供的额外依赖管理设施。

首先,我们将考虑在message库的依赖未得到满足时的超级构建配置:

$ mkdir -p build
$ cd build
$ cmake -DCMAKE_INSTALL_PREFIX=$HOME/Software/recipe-04 ..

我们将让 CMake 为我们找到库,这是我们得到的输出:

-- The CXX compiler identification is GNU 7.3.0
-- Check for working CXX compiler: /nix/store/gqg2vrcq7krqi9rrl6pphvsg81sb8pjw-gcc-wrapper-7.3.0/bin/g++
-- Check for working CXX compiler: /nix/store/gqg2vrcq7krqi9rrl6pphvsg81sb8pjw-gcc-wrapper-7.3.0/bin/g++ -- works
-- Detecting CXX compiler ABI info
-- Detecting CXX compiler ABI info - done
-- Detecting CXX compile features
-- Detecting CXX compile features - done
-- Project will be installed to /home/roberto/Software/recipe-04
-- Build type set to Release
-- Installing LIB components to /home/roberto/Software/recipe-04/lib64
-- Installing BIN components to /home/roberto/Software/recipe-04/bin
-- Installing INCLUDE components to /home/roberto/Software/recipe-04/include
-- Installing CMAKE components to /home/roberto/Software/recipe-04/share/cmake/recipe-04
-- recipe-04 staged install: /home/roberto/Workspace/robertodr/cmake-cookbook/chapter-10/recipe-04/cxx-example/build/stage
-- Suitable message could not be located, Building message instead.
-- Configuring done
-- Generating done
-- Build files have been written to: /home/roberto/Workspace/robertodr/cmake-cookbook/chapter-10/recipe-04/cxx-example/build

如所指示,CMake 报告以下内容:

  • 安装将被分阶段到构建树中。分阶段安装是一种沙盒化实际安装过程的方法。作为开发者,这对于检查所有库、可执行文件和文件是否安装在正确位置之前运行安装命令很有用。对于用户来说,它提供了相同的最终结构,但在构建目录内。这样,即使没有运行适当的安装,我们的项目也可以立即使用。

  • 系统上没有找到合适的message库。然后,CMake 将在构建我们的项目之前运行提供用于构建库的命令,以满足这个依赖。

如果库已经在系统上的已知位置,我们可以传递

CMake 的-Dmessage_DIR选项:

$ cmake -DCMAKE_INSTALL_PREFIX=$HOME/Software/use_message -Dmessage_DIR=$HOME/Software/message/share/cmake/message ..

实际上,库已被找到并导入。只会执行我们自己项目的构建操作:

-- The CXX compiler identification is GNU 7.3.0
-- Check for working CXX compiler: /nix/store/gqg2vrcq7krqi9rrl6pphvsg81sb8pjw-gcc-wrapper-7.3.0/bin/g++
-- Check for working CXX compiler: /nix/store/gqg2vrcq7krqi9rrl6pphvsg81sb8pjw-gcc-wrapper-7.3.0/bin/g++ -- works
-- Detecting CXX compiler ABI info
-- Detecting CXX compiler ABI info - done
-- Detecting CXX compile features
-- Detecting CXX compile features - done
-- Project will be installed to /home/roberto/Software/recipe-04
-- Build type set to Release
-- Installing LIB components to /home/roberto/Software/recipe-04/lib64
-- Installing BIN components to /home/roberto/Software/recipe-04/bin
-- Installing INCLUDE components to /home/roberto/Software/recipe-04/include
-- Installing CMAKE components to /home/roberto/Software/recipe-04/share/cmake/recipe-04
-- recipe-04 staged install: /home/roberto/Workspace/robertodr/cmake-cookbook/chapter-10/recipe-04/cxx-example/build/stage
-- Checking for one of the modules 'uuid'
-- Found message: /home/roberto/Software/message/lib64/libmessage.so.1 (found version 1.0.0)
-- Configuring done
-- Generating done
-- Build files have been written to: /home/roberto/Workspace/robertodr/cmake-cookbook/chapter-10/recipe-04/cxx-example/build

项目的最终安装规则将复制分阶段安装前缀的内容到CMAKE_INSTALL_PREFIX

install(
  DIRECTORY
    ${STAGED_INSTALL_PREFIX}/
  DESTINATION
    .
  USE_SOURCE_PERMISSIONS
  )

注意使用.而不是${CMAKE_INSTALL_PREFIX}绝对路径,这样这个规则也可以被 CPack 工具正确理解。CPack 的使用将在第十一章,打包项目,第一部分,生成源代码和二进制包中展示。

recipe-04_core项目构建一个简单的可执行目标,该目标链接到message共享库。正如本章前面所讨论的,需要正确设置RPATH,以便可执行文件能够正确运行。本章的第一部分展示了如何使用 CMake 实现这一点,同样的模式在这里被用于处理创建use_message可执行文件的CMakeLists.txt

file(RELATIVE_PATH _rel ${CMAKE_INSTALL_PREFIX}/${CMAKE_INSTALL_BINDIR} ${CMAKE_INSTALL_PREFIX})
if(APPLE)
  set(_rpath "@loader_path/${_rel}")
else()
  set(_rpath "\$ORIGIN/${_rel}")
endif()
file(TO_NATIVE_PATH "${_rpath}/${CMAKE_INSTALL_LIBDIR}" use_message_RPATH)

set_target_properties(use_message
  PROPERTIES
    MACOSX_RPATH ON
    SKIP_BUILD_RPATH OFF
    BUILD_WITH_INSTALL_RPATH OFF
    INSTALL_RPATH "${use_message_RPATH}"
    INSTALL_RPATH_USE_LINK_PATH ON
  )

为了验证这确实足够,我们可以使用平台原生工具打印已安装可执行文件的RPATH。我们将对该工具的调用封装在一个 Python 脚本中,该脚本进一步封装在一个 CMake 脚本中。最终,CMake 脚本作为安装规则使用SCRIPT关键字被调用:

if(UNIX)
  set(PRINT_SCRIPT "${CMAKE_CURRENT_LIST_DIR}/cmake/print_rpath.py")
  configure_file(cmake/install_hook.cmake.in install_hook.cmake @ONLY)
  install(
    SCRIPT
      ${CMAKE_CURRENT_BINARY_DIR}/install_hook.cmake
    )
endif()

这个额外的脚本在安装过程的最后执行:

$ cmake --build build --target install

在 GNU/Linux 系统上,我们将看到以下输出:

Install the project...
-- Install configuration: "Release"
-- Installing: /home/roberto/Software/recipe-04/.
-- Installing: /home/roberto/Software/recipe-04/./lib64
-- Installing: /home/roberto/Software/recipe-04/./lib64/libmessage.so
-- Installing: /home/roberto/Software/recipe-04/./lib64/libmessage_s.a
-- Installing: /home/roberto/Software/recipe-04/./lib64/libmessage.so.1
-- Installing: /home/roberto/Software/recipe-04/./include
-- Installing: /home/roberto/Software/recipe-04/./include/message
-- Installing: /home/roberto/Software/recipe-04/./include/message/Message.hpp
-- Installing: /home/roberto/Software/recipe-04/./include/message/messageExport.h
-- Installing: /home/roberto/Software/recipe-04/./share
-- Installing: /home/roberto/Software/recipe-04/./share/cmake
-- Installing: /home/roberto/Software/recipe-04/./share/cmake/message
-- Installing: /home/roberto/Software/recipe-04/./share/cmake/message/messageTargets-release.cmake
-- Installing: /home/roberto/Software/recipe-04/./share/cmake/message/messageConfigVersion.cmake
-- Installing: /home/roberto/Software/recipe-04/./share/cmake/message/messageConfig.cmake
-- Installing: /home/roberto/Software/recipe-04/./share/cmake/message/messageTargets.cmake
-- Installing: /home/roberto/Software/recipe-04/./bin
-- Installing: /home/roberto/Software/recipe-04/./bin/hello-world_wAR
-- Installing: /home/roberto/Software/recipe-04/./bin/use_message
-- Installing: /home/roberto/Software/recipe-04/./bin/hello-world_wDSO
-- ELF patching tool chrpath FOUND
-- RPATH for /home/roberto/Software/recipe-04/bin/use_message is /home/roberto/Software/recipe-04/bin/use_message: RUNPATH=$ORIGIN/../lib64:/home/roberto/Workspace/robertodr/cmake-cookbook/chapter-10/recipe-04/cxx-example/build/stage/lib64:/nix/store/di389pfcw2krnmh8nmkn55d1rnzmba37-CMake-Cookbook/lib64:/nix/store/di389pfcw2krnmh8nmkn55d1rnzmba37-CMake-Cookbook/lib:/nix/store/mjs2b8mmid86lvbzibzdlz8w5yrjgcnf-util-linux-2.31.1/lib:/nix/store/2kcrj1ksd2a14bm5sky182fv2xwfhfap-glibc-2.26-131/lib:/nix/store/4zd34747fz0ggzzasy4icgn3lmy89pra-gcc-7.3.0-lib/lib
-- Running /home/roberto/Software/recipe-04/bin/use_message:
 This is my very nice message: 
Hello, World! From a client of yours!
...and here is its UUID: a8014bf7-5dfa-45e2-8408-12e9a5941825
This is my very nice message: 
Goodbye, World! From a client of yours!
...and here is its UUID: ac971ef4-7606-460f-9144-1ad96f713647

我们建议用于处理可执行和可链接格式(ELF)对象的工具包括 PatchELF(nixos.org/patchelf.html)、chrpath(linux.die.net/man/1/chrpath)和 otool(www.manpagez.com/man/1/otool/)。第一个工具适用于 GNU/Linux 和 macOS,而 chrpath 和 otool 分别适用于 GNU/Linux 和 macOS。

第十二章:打包项目

在本章中,我们将涵盖以下食谱:

  • 生成源代码和二进制包

  • 通过 PyPI 分发使用 CMake/pybind11 构建的 C++/Python 项目

  • 通过 PyPI 分发使用 CMake/CFFI 构建的 C/Fortran/Python 项目

  • 将简单项目作为 Conda 包分发

  • 将具有依赖项的项目作为 Conda 包分发

引言

到目前为止,我们已经从源代码编译并安装(示例)软件包——这意味着通过 Git 获取项目,并手动执行配置、构建、测试和安装步骤。然而,在实践中,软件包通常使用包管理器(如 Apt、DNF、Pacman、pip 和 Conda)进行安装。我们需要能够以各种格式分发我们的代码项目:作为源代码存档或作为二进制安装程序。

这就是我们在熟悉的 CMake 项目使用方案中提到的打包时间,显示了项目的各个阶段:

在本章中,我们将探讨不同的打包策略。我们将首先讨论使用 CMake 家族中的工具 CPack 进行打包。我们还将提供将 CMake 项目打包并上传到 Python Package Index(PyPI,[pypi.org](https://pypi.org))和 Anaconda Cloud(https://anaconda.org)的食谱——这些都是通过包管理器 pip 和 Conda([conda.io/docs/](https://conda.io/docs/))分发包的标准且流行的平台。对于 PyPI,我们将演示如何打包和分发混合 C++/Python 或 C/Fortran/Python 项目。对于 Conda,我们将展示如何打包依赖于其他库的 C++项目。

生成源代码和二进制包

本食谱的代码可在https://github.com/dev-cafe/cmake-cookbook/tree/v1.0/chapter-11/recipe-01找到。该食谱适用于 CMake 版本 3.6(及以上),并在 GNU/Linux、macOS 和 Windows 上进行了测试。

如果您的代码是开源的,用户将期望能够下载您项目的源代码,并使用您精心定制的 CMake 脚本自行构建。当然,打包操作可以用脚本完成,但 CPack 提供了更紧凑和便携的替代方案。本食谱将指导您创建多种打包替代方案:

准备工作

我们将使用第十章[72e949cc-6881-4be1-9710-9ac706c14a4d.xhtml]中介绍的message库的源代码,编写安装程序,第 3 个配方,导出目标。项目树由以下目录和文件组成:

.
├── cmake
│   ├── coffee.icns
│   ├── Info.plist.in
│   └── messageConfig.cmake.in
├── CMakeCPack.cmake
├── CMakeLists.txt
├── INSTALL.md
├── LICENSE
├── src
│   ├── CMakeLists.txt
│   ├── hello-world.cpp
│   ├── Message.cpp
│   └── Message.hpp
└── tests
    ├── CMakeLists.txt
    └── use_target
        ├── CMakeLists.txt
        └── use_message.cpp

由于本配方的重点将是有效使用 CPack,我们将不对源代码本身进行评论。我们只会在CMakeCPack.cmake中添加打包指令,我们将在稍后讨论。此外,我们添加了INSTALL.md和一个LICENSE文件:它们包含项目安装说明和许可证,并且是打包指令所必需的。

如何操作

让我们看看需要添加到此项目的打包指令。我们将它们收集在CMakeCPack.cmake中,该文件在CMakeLists.txt的末尾使用include(CMakeCPack.cmake)包含:

  1. 我们声明包的名称。这与项目名称相同,因此我们使用PROJECT_NAME CMake 变量:
set(CPACK_PACKAGE_NAME "${PROJECT_NAME}")
  1. 我们声明了包的供应商:
set(CPACK_PACKAGE_VENDOR "CMake Cookbook")
  1. 打包的源代码将包括一个描述文件。这是包含安装说明的纯文本文件:
set(CPACK_PACKAGE_DESCRIPTION_FILE "${PROJECT_SOURCE_DIR}/INSTALL.md")
  1. 我们还添加了包的简要概述:
set(CPACK_PACKAGE_DESCRIPTION_SUMMARY "message: a small messaging library")
  1. 许可证文件也将包含在包中:
set(CPACK_RESOURCE_FILE_LICENSE "${PROJECT_SOURCE_DIR}/LICENSE")
  1. 从分发的包中安装时,文件将被放置在/opt/recipe-01目录中:
set(CPACK_PACKAGING_INSTALL_PREFIX "/opt/${PROJECT_NAME}")
  1. 包的主版本、次版本和补丁版本设置为 CPack 的变量:
set(CPACK_PACKAGE_VERSION_MAJOR "${PROJECT_VERSION_MAJOR}")
set(CPACK_PACKAGE_VERSION_MINOR "${PROJECT_VERSION_MINOR}")
set(CPACK_PACKAGE_VERSION_PATCH "${PROJECT_VERSION_PATCH}")
  1. 我们设置了一组文件和目录,以在打包操作期间忽略:
set(CPACK_SOURCE_IGNORE_FILES "${PROJECT_BINARY_DIR};/.git/;.gitignore")
  1. 我们列出了源代码存档的打包生成器——在我们的例子中是ZIP,用于生成.zip存档,以及TGZ,用于.tar.gz存档。
set(CPACK_SOURCE_GENERATOR "ZIP;TGZ")
  1. 我们还列出了二进制存档生成器:
set(CPACK_GENERATOR "ZIP;TGZ")
  1. 我们现在还声明了平台原生的二进制安装程序,从 DEB 和 RPM 包生成器开始,仅适用于 GNU/Linux:
if(UNIX)
  if(CMAKE_SYSTEM_NAME MATCHES Linux)
    list(APPEND CPACK_GENERATOR "DEB")
    set(CPACK_DEBIAN_PACKAGE_MAINTAINER "robertodr")
    set(CPACK_DEBIAN_PACKAGE_SECTION "devel")
    set(CPACK_DEBIAN_PACKAGE_DEPENDS "uuid-dev")

    list(APPEND CPACK_GENERATOR "RPM")
    set(CPACK_RPM_PACKAGE_RELEASE "1")
    set(CPACK_RPM_PACKAGE_LICENSE "MIT")
    set(CPACK_RPM_PACKAGE_REQUIRES "uuid-devel")
  endif()
endif()
  1. 如果我们使用的是 Windows,我们将希望生成一个 NSIS 安装程序:
if(WIN32 OR MINGW)
  list(APPEND CPACK_GENERATOR "NSIS")
  set(CPACK_NSIS_PACKAGE_NAME "message")
  set(CPACK_NSIS_CONTACT "robertdr")
  set(CPACK_NSIS_ENABLE_UNINSTALL_BEFORE_INSTALL ON)
endif()
  1. 另一方面,在 macOS 上,捆绑包是我们的首选安装程序:
if(APPLE)
  list(APPEND CPACK_GENERATOR "Bundle")
  set(CPACK_BUNDLE_NAME "message")
  configure_file(${PROJECT_SOURCE_DIR}/cmake/Info.plist.in Info.plist @ONLY)
  set(CPACK_BUNDLE_PLIST ${CMAKE_CURRENT_BINARY_DIR}/Info.plist)
  set(CPACK_BUNDLE_ICON ${PROJECT_SOURCE_DIR}/cmake/coffee.icns)
endif()
  1. 我们向用户打印有关当前系统上可用的包装生成器的信息性消息:
message(STATUS "CPack generators: ${CPACK_GENERATOR}")
  1. 最后,我们包含了CPack.cmake标准模块。这将向构建系统添加一个package和一个package_source目标:
include(CPack)

我们现在可以像往常一样配置项目:

$ mkdir -p build
$ cd build
$ cmake ..

使用以下命令,我们可以列出可用的目标(示例输出是在使用 Unix Makefiles 作为生成器的 GNU/Linux 系统上获得的):

$ cmake --build . --target help

The following are some of the valid targets for this Makefile:
... all (the default if no target is provided)
... clean
... depend
... install/strip
... install
... package_source
... package
... install/local
... test
... list_install_components
... edit_cache
... rebuild_cache
... hello-world
... message

我们可以看到packagepackage_source目标可用。源包可以通过以下命令生成:

$ cmake --build . --target package_source

Run CPack packaging tool for source...
CPack: Create package using ZIP
CPack: Install projects
CPack: - Install directory: /home/user/cmake-cookbook/chapter-11/recipe-01/cxx-example
CPack: Create package
CPack: - package: /home/user/cmake-cookbook/chapter-11/recipe-01/cxx-example/build/recipe-01-1.0.0-Source.zip generated.
CPack: Create package using TGZ
CPack: Install projects
CPack: - Install directory: /home/user/cmake-cookbook/chapter-11/recipe-01/cxx-example
CPack: Create package
CPack: - package: /home/user/cmake-cookbook/chapter-11/recipe-01/cxx-example/build/recipe-01-1.0.0-Source.tar.gz generated.

同样,我们可以构建二进制包:

$ cmake --build . --target package

在我们的例子中,我们获得了以下二进制包列表:

message-1.0.0-Linux.deb
message-1.0.0-Linux.rpm
message-1.0.0-Linux.tar.gz
message-1.0.0-Linux.zip

工作原理

CPack 可以用来生成许多不同类型的包用于分发。在生成构建系统时,我们在CMakeCPack.cmake中列出的 CPack 指令用于在构建目录中生成一个CPackConfig.cmake文件。当运行 CMake 命令为packagepackage_source目标时,CPack 会自动使用自动生成的配置文件作为参数调用。确实,这两个新目标只是简单地包装了对 CPack 的调用。就像 CMake 一样,CPack 也有生成器的概念。在 CMake 的上下文中,生成器是用于生成原生构建脚本的工具,例如 Unix Makefiles 或 Visual Studio 项目文件,而在 CPack 的上下文中,这些是用于打包的工具。我们列出了这些,特别注意不同的平台,使用CPACK_SOURCE_GENERATORCPACK_GENERATOR变量为源和二进制包。因此,Debian 打包工具将被调用用于DEB包生成器,而在给定平台上适当的存档工具将被调用用于TGZ生成器。我们可以直接从build目录调用 CPack,并使用-G命令行选项选择要使用的生成器。RPM 包可以通过以下方式生成:

$ cd build
$ cpack -G RPM

CPack: Create package using RPM
CPack: Install projects
CPack: - Run preinstall target for: recipe-01
CPack: - Install project: recipe-01
CPack: Create package
CPackRPM: Will use GENERATED spec file: /home/user/cmake-cookbook/chapter-11/recipe-01/cxx-example/build/_CPack_Packages/Linux/RPM/SPECS/recipe-01.spec
CPack: - package: /home/user/cmake-cookbook/chapter-11/recipe-01/cxx-example/build/recipe-01-1.0.0-Linux.rpm generated.

对于任何分发,无论是源还是二进制,我们只需要打包最终用户严格需要的那些内容,因此整个构建目录和与版本控制相关的任何其他文件都必须从要打包的文件列表中排除。在我们的示例中,排除列表是通过以下命令声明的:

set(CPACK_SOURCE_IGNORE_FILES "${PROJECT_BINARY_DIR};/.git/;.gitignore")

我们还需要指定有关我们包的基本信息,例如名称、简短描述和版本。这些信息是通过 CMake 变量设置的,然后在包含相应的模块时传递给 CPack。

自 CMake 3.9 起,project()命令接受一个DESCRIPTION字段,其中包含对项目的简短描述。CMake 将设置一个PROJECT_DESCRIPTION,可以用来设置CPACK_PACKAGE_DESCRIPTION_SUMMARY

让我们详细看看我们为示例项目可以生成的不同类型的包的说明。

源代码存档

在我们的示例中,我们决定为源归档使用TGZZIP生成器。这将分别产生.tar.gz.zip归档文件。我们可以检查生成的.tar.gz文件的内容:

$ tar tzf recipe-01-1.0.0-Source.tar.gz

recipe-01-1.0.0-Source/opt/
recipe-01-1.0.0-Source/opt/recipe-01/
recipe-01-1.0.0-Source/opt/recipe-01/cmake/
recipe-01-1.0.0-Source/opt/recipe-01/cmake/coffee.icns
recipe-01-1.0.0-Source/opt/recipe-01/cmake/Info.plist.in
recipe-01-1.0.0-Source/opt/recipe-01/cmake/messageConfig.cmake.in
recipe-01-1.0.0-Source/opt/recipe-01/CMakeLists.txt
recipe-01-1.0.0-Source/opt/recipe-01/src/
recipe-01-1.0.0-Source/opt/recipe-01/src/Message.hpp
recipe-01-1.0.0-Source/opt/recipe-01/src/CMakeLists.txt
recipe-01-1.0.0-Source/opt/recipe-01/src/Message.cpp
recipe-01-1.0.0-Source/opt/recipe-01/src/hello-world.cpp
recipe-01-1.0.0-Source/opt/recipe-01/LICENSE
recipe-01-1.0.0-Source/opt/recipe-01/tests/
recipe-01-1.0.0-Source/opt/recipe-01/tests/CMakeLists.txt
recipe-01-1.0.0-Source/opt/recipe-01/tests/use_target/
recipe-01-1.0.0-Source/opt/recipe-01/tests/use_target/CMakeLists.txt
recipe-01-1.0.0-Source/opt/recipe-01/tests/use_target/use_message.cpp
recipe-01-1.0.0-Source/opt/recipe-01/INSTALL.md

正如预期的那样,只有源树的内容被包括在内。注意,INSTALL.mdLICENSE文件也被包括在内,这是通过CPACK_PACKAGE_DESCRIPTION_FILECPACK_RESOURCE_FILE_LICENSE变量指定的。

package_source目标不被 Visual Studio 系列的生成器理解:gitlab.kitware.com/cmake/cmake/issues/13058

二进制归档文件

在创建二进制归档文件时,CPack 将根据我们的CMakeCPack.cmake文件中描述的安装说明,将目标的内容打包。因此,在我们的示例中,hello-world 可执行文件、消息共享库以及相应的头文件都将被打包在.tar.gz.zip格式中。此外,CMake 配置文件也将被打包。这对于需要链接到我们库的其他项目非常有用。在包中使用的安装前缀可能与从构建树安装项目时使用的前缀不同。可以使用CPACK_PACKAGING_INSTALL_PREFIX变量来实现这一点。在我们的示例中,我们将其设置为系统上的特定位置:/opt/recipe-01

我们可以分析生成的.tar.gz归档文件的内容:

$ tar tzf recipe-01-1.0.0-Linux.tar.gz

recipe-01-1.0.0-Linux/opt/
recipe-01-1.0.0-Linux/opt/recipe-01/
recipe-01-1.0.0-Linux/opt/recipe-01/bin/
recipe-01-1.0.0-Linux/opt/recipe-01/bin/hello-world
recipe-01-1.0.0-Linux/opt/recipe-01/share/
recipe-01-1.0.0-Linux/opt/recipe-01/share/cmake/
recipe-01-1.0.0-Linux/opt/recipe-01/share/cmake/recipe-01/
recipe-01-1.0.0-Linux/opt/recipe-01/share/cmake/recipe-01/messageConfig.cmake
recipe-01-1.0.0-Linux/opt/recipe-01/share/cmake/recipe-01/messageTargets-hello-world.cmake
recipe-01-1.0.0-Linux/opt/recipe-01/share/cmake/recipe-01/messageConfigVersion.cmake
recipe-01-1.0.0-Linux/opt/recipe-01/share/cmake/recipe-01/messageTargets-hello-world-release.cmake
recipe-01-1.0.0-Linux/opt/recipe-01/share/cmake/recipe-01/messageTargets-release.cmake
recipe-01-1.0.0-Linux/opt/recipe-01/share/cmake/recipe-01/messageTargets.cmake
recipe-01-1.0.0-Linux/opt/recipe-01/include/
recipe-01-1.0.0-Linux/opt/recipe-01/include/message/
recipe-01-1.0.0-Linux/opt/recipe-01/include/message/Message.hpp
recipe-01-1.0.0-Linux/opt/recipe-01/include/message/messageExport.h
recipe-01-1.0.0-Linux/opt/recipe-01/lib64/
recipe-01-1.0.0-Linux/opt/recipe-01/lib64/libmessage.so
recipe-01-1.0.0-Linux/opt/recipe-01/lib64/libmessage.so.1

平台原生二进制安装程序

我们预计每个平台原生二进制安装程序的配置会有所不同。这些差异可以在一个CMakeCPack.cmake中通过 CPack 进行管理,正如我们在示例中所做的那样。

对于 GNU/Linux,该节配置了DEBRPM生成器:

if(UNIX)
  if(CMAKE_SYSTEM_NAME MATCHES Linux)
    list(APPEND CPACK_GENERATOR "DEB")
    set(CPACK_DEBIAN_PACKAGE_MAINTAINER "robertodr")
    set(CPACK_DEBIAN_PACKAGE_SECTION "devel")
    set(CPACK_DEBIAN_PACKAGE_DEPENDS "uuid-dev")

    list(APPEND CPACK_GENERATOR "RPM")
    set(CPACK_RPM_PACKAGE_RELEASE "1")
    set(CPACK_RPM_PACKAGE_LICENSE "MIT")
    set(CPACK_RPM_PACKAGE_REQUIRES "uuid-devel")
  endif()
endif()

我们的示例依赖于 UUID 库,CPACK_DEBIAN_PACKAGE_DEPENDSCPACK_RPM_PACKAGE_REQUIRES选项允许我们在我们的包和其他数据库中的包之间指定依赖关系。我们可以使用dpkgrpm程序分别分析生成的.deb.rpm包的内容。

请注意,CPACK_PACKAGING_INSTALL_PREFIX也会影响这些包生成器:我们的包将被安装到/opt/recipe-01

CMake 确实提供了对跨平台和便携式构建系统的支持。以下节将使用 Nullsoft Scriptable Install System(NSIS)创建一个安装程序:

if(WIN32 OR MINGW)
  list(APPEND CPACK_GENERATOR "NSIS")
  set(CPACK_NSIS_PACKAGE_NAME "message")
  set(CPACK_NSIS_CONTACT "robertdr")
  set(CPACK_NSIS_ENABLE_UNINSTALL_BEFORE_INSTALL ON)
endif()

最后,如果我们正在 macOS 上构建项目,以下节将启用 Bundle 打包器:

if(APPLE)
  list(APPEND CPACK_GENERATOR "Bundle")
  set(CPACK_BUNDLE_NAME "message")
  configure_file(${PROJECT_SOURCE_DIR}/cmake/Info.plist.in Info.plist @ONLY)
  set(CPACK_BUNDLE_PLIST ${CMAKE_CURRENT_BINARY_DIR}/Info.plist)
  set(CPACK_BUNDLE_ICON ${PROJECT_SOURCE_DIR}/cmake/coffee.icns)
endif()

在 macOS 示例中,我们首先需要为包配置一个属性列表文件,这可以通过configure_file命令实现。然后,Info.plist的位置和包的图标被设置为 CPack 的变量。

你可以在这里阅读更多关于属性列表格式的信息:en.wikipedia.org/wiki/Property_list

还有更多

我们没有像之前为了简化而将 CPack 配置设置列在CMakeCPack.cmake中,而是可以将CPACK_*变量的每个生成器设置放在一个单独的文件中,例如CMakeCPackOptions.cmake,并使用set(CPACK_PROJECT_CONFIG_FILE "${PROJECT_SOURCE_DIR}/CMakeCPackOptions.cmake")将这些设置包含到CMakeCPack.cmake中。这个文件也可以在 CMake 时配置,然后在 CPack 时包含,提供了一种干净的方式来配置多格式包生成器(另请参见:cmake.org/cmake/help/v3.6/module/CPack.html)。

与 CMake 家族中的所有工具一样,CPack 功能强大且多才多艺,提供了比本食谱中展示的更多的灵活性和选项。感兴趣的读者应阅读 CPack 的官方文档,了解命令行界面的详细信息(cmake.org/cmake/help/v3.6/manual/cpack.1.html)以及详细介绍 CPack 如何使用额外生成器打包项目的 man 页面(cmake.org/cmake/help/v3.6/module/CPack.html)。

通过 PyPI 分发使用 CMake/pybind11 构建的 C++/Python 项目

本食谱的代码可在github.com/dev-cafe/cmake-cookbook/tree/v1.0/chapter-11/recipe-02找到。该食谱适用于 CMake 版本 3.11(及以上),并在 GNU/Linux、macOS 和 Windows 上进行了测试。

在本食谱中,我们将以第九章,混合语言项目,第 5 个食谱,使用 pybind11 构建 C++和 Python 项目中的 pybind11 示例为起点,添加相关的安装目标和 pip 打包信息,并将项目上传到 PyPI。我们的目标将是得到一个可以使用 pip 安装的项目,并在幕后运行 CMake 并获取 pybind11 依赖项。

准备就绪

要通过 PyPI 分发包,您需要在pypi.org上注册一个用户账户,但也可以先从本地路径进行安装练习。

我们还普遍建议使用 pip 安装此包和其他 Python 包,使用 Pipenv(docs.pipenv.org)或虚拟环境(virtualenv.pypa.io/en/stable/)而不是安装到系统环境中。

我们的起点是来自第九章,混合语言项目,第 5 个食谱,使用 pybind11 构建 C++和 Python 项目的 pybind11 示例,其中包含一个顶级CMakeLists.txt文件和一个account/CMakeLists.txt文件,该文件配置了账户示例目标并使用以下项目树:

.
├── account
│   ├── account.cpp
│   ├── account.hpp
│   ├── CMakeLists.txt
│   └── test.py
└── CMakeLists.txt

在这个配方中,我们将保持account.cppaccount.hpptest.py脚本不变。我们将修改account/CMakeLists.txt并添加一些文件,以便 pip 能够构建和安装包。为此,我们需要在根目录中添加三个额外的文件:README.rstMANIFEST.insetup.py

README.rst包含有关项目的文档:

Example project
===============

Project description in here ...

MANIFEST.in列出了应与 Python 模块和包一起安装的文件:

include README.rst CMakeLists.txt
recursive-include account *.cpp *.hpp CMakeLists.txt

最后,setup.py包含构建和安装项目的指令:

import distutils.command.build as _build
import os
import sys
from distutils import spawn
from distutils.sysconfig import get_python_lib

from setuptools import setup

def extend_build():
    class build(_build.build):
        def run(self):
            cwd = os.getcwd()
            if spawn.find_executable('cmake') is None:
                sys.stderr.write("CMake is required to build this package.\n")
                sys.exit(-1)
            _source_dir = os.path.split(__file__)[0]
            _build_dir = os.path.join(_source_dir, 'build_setup_py')
            _prefix = get_python_lib()
            try:
                cmake_configure_command = [
                    'cmake',
                    '-H{0}'.format(_source_dir),
                    '-B{0}'.format(_build_dir),
                    '-DCMAKE_INSTALL_PREFIX={0}'.format(_prefix),
                ]
                _generator = os.getenv('CMAKE_GENERATOR')
                if _generator is not None:
                    cmake_configure_command.append('-
G{0}'.format(_generator))
                spawn.spawn(cmake_configure_command)
                spawn.spawn(
                    ['cmake', '--build', _build_dir, '--target', 'install'])
                os.chdir(cwd)
            except spawn.DistutilsExecError:
                sys.stderr.write("Error while building with CMake\n")
                sys.exit(-1)
            _build.build.run(self)

    return build

_here = os.path.abspath(os.path.dirname(__file__))

if sys.version_info[0] < 3:
    with open(os.path.join(_here, 'README.rst')) as f:
        long_description = f.read()
else:
    with open(os.path.join(_here, 'README.rst'), encoding='utf-8') as f:
        long_description = f.read()

_this_package = 'account'

version = {}
with open(os.path.join(_here, _this_package, 'version.py')) as f:
    exec(f.read(), version)

setup(
    name=_this_package,
    version=version['__version__'],
    description='Description in here.',
    long_description=long_description,
    author='Bruce Wayne',
    author_email='bruce.wayne@example.com',
    url='http://example.com',
    license='MIT',
    packages=[_this_package],
    include_package_data=True,
    classifiers=[
        'Development Status :: 3 - Alpha',
        'Intended Audience :: Science/Research',
        'Programming Language :: Python :: 2.7',
        'Programming Language :: Python :: 3.6'
    ],
    cmdclass={'build': extend_build()})

我们将把__init__.py放入account子目录中:

from .version import __version__
from .account import Account

__all__ = [
    '__version__',
    'Account',
]

我们还将把version.py放入account子目录中:

__version__ = '0.0.0'

这意味着我们的项目将具有以下文件结构:

.
├── account
│   ├── account.cpp
│   ├── account.hpp
│   ├── CMakeLists.txt
│   ├── __init__.py
│   ├── test.py
│   └── version.py
├── CMakeLists.txt
├── MANIFEST.in
├── README.rst
└── setup.py

如何做到这一点

这个配方建立在第九章,混合语言项目,配方 5,使用 pybind11 构建 C++和 Python 项目的基础上。让我们详细看看:

首先,我们扩展account/CMakeLists.txt。唯一的添加是最后一个指令,它指定了安装目标:

install(
  TARGETS
    account
  LIBRARY
    DESTINATION account
  )

就是这样!有了安装目标和README.rstMANIFEST.insetup.py__init__.pyversion.py文件,我们就可以测试使用 pybind11 接口的示例代码的安装了:

  1. 为此,在你的计算机上创建一个新的目录,我们将在那里测试安装。

  2. 在新创建的目录中,我们从本地路径运行pipenv install。调整本地路径以指向包含setup.py脚本的目录:

$ pipenv install /path/to/cxx-example
  1. 现在我们在 Pipenv 环境中启动一个 Python shell:
$ pipenv run python
  1. 在 Python shell 中,我们可以测试我们的 CMake 包:
>>> from account import Account
>>> account1 = Account()
>>> account1.deposit(100.0)
>>> account1.deposit(100.0)
>>> account1.withdraw(50.0)
>>> print(account1.get_balance())
150.0

它是如何工作的

${CMAKE_CURRENT_BINARY_DIR}目录包含使用 pybind11 编译的account.cpython-36m-x86_64-linux-gnu.soPython 模块,但请注意,其名称取决于操作系统(在这种情况下,64 位 Linux)和 Python 环境(在这种情况下,Python 3.6)。setup.py脚本将在后台运行 CMake,并将 Python 模块安装到正确的路径,具体取决于所选的 Python 环境(系统 Python 或 Pipenv 或虚拟环境)。但现在我们在安装模块时面临两个挑战:

  • 命名可能会改变。

  • 路径是在 CMake 之外设置的。

我们可以通过使用以下安装目标来解决这个问题,其中setup.py将定义安装目标位置:

install(
  TARGETS
    account
  LIBRARY
    DESTINATION account
  )

在这里,我们指导 CMake 将编译后的 Python 模块文件安装到相对于安装目标位置的account子目录中(第十章,编写安装程序,详细讨论了如何设置目标位置)。后者将由setup.py通过定义CMAKE_INSTALL_PREFIX指向正确的路径,这取决于 Python 环境。

现在让我们检查一下我们是如何在setup.py中实现这一点的;我们将从脚本的底部开始:

setup(
    name=_this_package,
    version=version['__version__'],
    description='Description in here.',
    long_description=long_description,
    author='Bruce Wayne',
    author_email='bruce.wayne@example.com',
    url='http://example.com',
    license='MIT',
    packages=[_this_package],
    include_package_data=True,
    classifiers=[
        'Development Status :: 3 - Alpha',
        'Intended Audience :: Science/Research',
        'Programming Language :: Python :: 2.7',
        'Programming Language :: Python :: 3.6'
    ],
    cmdclass={'build': extend_build()})

脚本包含多个占位符和希望自我解释的指令,但我们将重点关注最后一个指令cmdclass,在这里我们通过一个自定义函数扩展默认的构建步骤,我们称之为extend_build。这个函数是默认构建步骤的子类:

def extend_build():
    class build(_build.build):
        def run(self):
            cwd = os.getcwd()
            if spawn.find_executable('cmake') is None:
                sys.stderr.write("CMake is required to build this package.\n")
                sys.exit(-1)
            _source_dir = os.path.split(__file__)[0]
            _build_dir = os.path.join(_source_dir, 'build_setup_py')
            _prefix = get_python_lib()
            try:
                cmake_configure_command = [
                    'cmake',
                    '-H{0}'.format(_source_dir),
                    '-B{0}'.format(_build_dir),
                    '-DCMAKE_INSTALL_PREFIX={0}'.format(_prefix),
                ]
                _generator = os.getenv('CMAKE_GENERATOR')
                if _generator is not None:
                    cmake_configure_command.append('-G{0}'.format(_generator))
                spawn.spawn(cmake_configure_command)
                spawn.spawn(
                    ['cmake', '--build', _build_dir, '--target', 'install'])
                os.chdir(cwd)
            except spawn.DistutilsExecError:
                sys.stderr.write("Error while building with CMake\n")
                sys.exit(-1)
            _build.build.run(self)

    return build

首先,该函数检查系统上是否安装了 CMake。函数的核心执行两个 CMake 命令:

cmake_configure_command = [
    'cmake',
    '-H{0}'.format(_source_dir),
    '-B{0}'.format(_build_dir),
    '-DCMAKE_INSTALL_PREFIX={0}'.format(_prefix),
]
_generator = os.getenv('CMAKE_GENERATOR')
if _generator is not None:
    cmake_configure_command.append('-G{0}'.format(_generator))
spawn.spawn(cmake_configure_command)
spawn.spawn(
    ['cmake', '--build', _build_dir, '--target', 'install'])

在这里,我们可以通过设置CMAKE_GENERATOR环境变量来更改默认的生成器。安装前缀定义如下:

_prefix = get_python_lib()

distutils.sysconfig导入的get_python_lib函数提供了安装前缀的根目录。cmake --build _build_dir --target install命令以可移植的方式一步构建并安装我们的项目。我们使用名称_build_dir而不是简单的build的原因是,在测试本地安装时,您的项目可能已经包含一个build目录,这会与新安装发生冲突。对于已经上传到 PyPI 的包,构建目录的名称并不重要。

还有更多内容。

现在我们已经测试了本地安装,我们准备将包上传到 PyPI。但是,在这样做之前,请确保setup.py中的元数据(如项目名称、联系信息和许可证信息)是合理的,并且项目名称在 PyPI 上尚未被占用。在将包上传到pypi.org之前,先测试上传到 PyPI 测试实例test.pypi.org并下载,这是一个良好的实践。

在上传之前,我们需要在主目录中创建一个名为.pypirc的文件,其中包含(替换yourusernameyourpassword):

[distutils]account
index-servers=
    pypi
    pypitest

[pypi]
username = yourusername
password = yourpassword

[pypitest]
repository = https://test.pypi.org/legacy/
username = yourusername
password = yourpassword

我们将分两步进行。首先,我们在本地创建分发:

$ python setup.py sdist

在第二步中,我们使用 Twine(我们将其安装到本地 Pipenv 中)上传生成的分发数据:

$ pipenv run twine upload dist/* -r pypitest

Uploading distributions to https://test.pypi.org/legacy/
Uploading yourpackage-0.0.0.tar.gz

接下来,尝试从测试实例安装到一个隔离的环境中:

$ pipenv shell
$ pip install --index-url https://test.pypi.org/simple/ yourpackage

一旦这个工作正常,我们就可以准备上传到生产 PyPI 了:

$ pipenv run twine upload dist/* -r pypi

通过 PyPI 分发使用 CMake/CFFI 构建的 C/Fortran/Python 项目

本食谱的代码可在github.com/dev-cafe/cmake-cookbook/tree/v1.0/chapter-11/recipe-03找到,并包含 C++和 Fortran 示例。该食谱适用于 CMake 版本 3.5(及以上),并在 GNU/Linux、macOS 和 Windows 上进行了测试。

这个配方是之前的配方和第九章,混合语言项目,配方 6,使用 Python CFFI 混合 C、C++、Fortran 和 Python的混合体。我们将重用之前配方的许多构建块,但不是使用 pybind11,而是使用 Python CFFI 来提供 Python 接口。在这个配方中,我们的目标是通过 PyPI 共享一个 Fortran 项目,但它同样可以是 C 或 C++项目,或者任何暴露 C 接口的语言项目。

准备工作

我们将从以下文件树开始:

.
├── account
│   ├── account.h
│   ├── CMakeLists.txt
│   ├── implementation
│   │   └── fortran_implementation.f90
│   ├── __init__.py
│   ├── interface_file_names.cfg.in
│   ├── test.py
│   └── version.py
├── CMakeLists.txt
├── MANIFEST.in
├── README.rst
└── setup.py

顶级的CMakeLists.txt文件和account下的所有源文件,除了account/CMakeLists.txt,与第九章,混合语言项目,配方 6,使用 Python CFFI 混合 C、C++、Fortran 和 Python中出现的方式相同。我们很快会讨论需要应用到account/CMakeLists.txt的小改动。README.rst文件与之前的配方相同。setup.py脚本与之前的配方相比包含一条额外的行(包含install_requires=['cffi']的行):

# ... up to this line the script is unchanged

setup(
    name=_this_package,
    version=version['__version__'],
    description='Description in here.',
    long_description=long_description,
    author='Bruce Wayne',
    author_email='bruce.wayne@example.com',
    url='http://example.com',
    license='MIT',
    packages=[_this_package],
    install_requires=['cffi'],
    include_package_data=True,
    classifiers=[
        'Development Status :: 3 - Alpha',
        'Intended Audience :: Science/Research',
        'Programming Language :: Python :: 2.7',
        'Programming Language :: Python :: 3.6'
    ],
    cmdclass={'build': extend_build()})

MANIFEST.in列出了应与 Python 模块和包一起安装的文件,并包含以下内容:

include README.rst CMakeLists.txt
recursive-include account *.h *.f90 CMakeLists.txt

account子目录下,我们看到了两个新文件。同样,有一个version.py文件,它保存了setup.py的项目版本:

__version__ = '0.0.0'

子目录还包含interface_file_names.cfg.in文件,我们很快就会讨论它:

[configuration]
header_file_name = account.h
library_file_name = $<TARGET_FILE_NAME:account>

如何操作

让我们讨论实现打包所需的步骤:

  1. 我们扩展了第九章,混合语言项目,配方 6,使用 Python CFFI 混合 C、C++、Fortran 和 Python中的account/CMakeLists.txt。唯一的添加指令如下:
file(
  GENERATE OUTPUT ${CMAKE_CURRENT_BINARY_DIR}/interface_file_names.cfg
  INPUT ${CMAKE_CURRENT_SOURCE_DIR}/interface_file_names.cfg.in
  )

set_target_properties(account
  PROPERTIES
    PUBLIC_HEADER "account.h;${CMAKE_CURRENT_BINARY_DIR}/account_export.h"
    RESOURCE "${CMAKE_CURRENT_BINARY_DIR}/interface_file_names.cfg"
  )

install(
  TARGETS
    account
  LIBRARY
    DESTINATION account/lib
  RUNTIME
    DESTINATION account/lib
  PUBLIC_HEADER
    DESTINATION account/include
  RESOURCE
    DESTINATION account
  )

就这样!安装目标和附加文件就位后,我们就可以开始测试安装了。为此,在你的电脑上创建一个新的目录,我们将在那里进行安装测试。

  1. 在新创建的目录中,我们从本地路径运行pipenv install。调整本地路径以指向包含setup.py脚本的目录:
$ pipenv install /path/to/fortran-example
  1. 现在我们在 Pipenv 环境中启动一个 Python shell:
$ pipenv run python
  1. 在 Python shell 中,我们可以测试我们的 CMake 包:
>>> import account
>>> account1 = account.new()
>>> account.deposit(account1, 100.0)
>>> account.deposit(account1, 100.0)
>>> account.withdraw(account1, 50.0)
>>> print(account.get_balance(account1))
150.0

它是如何工作的

与第九章,混合语言项目,配方 6,使用 Python CFFI 混合 C、C++、Fortran 和 Python相比,使用 Python CFFI 和 CMake 安装混合语言项目的扩展包括两个额外步骤:

  1. 我们需要setup.py层。

  2. 我们安装目标,以便 CFFI 层所需的头文件和共享库文件根据所选 Python 环境安装在正确的路径中。

setup.py的结构与之前的食谱几乎相同,我们请您参考之前的食谱来讨论这个文件。唯一的增加是包含install_requires=['cffi']的行,以确保安装我们的示例包也会获取并安装所需的 Python CFFI。setup.py脚本将自动安装__init__.pyversion.py,因为这些是从setup.py脚本引用的。MANIFEST.in稍作修改,以打包不仅包括README.rst和 CMake 文件,还包括头文件和 Fortran 源文件:

include README.rst CMakeLists.txt
recursive-include account *.h *.f90 CMakeLists.txt

在本食谱中,我们将面临三个挑战,即打包使用 Python CFFI 和setup.py的 CMake 项目:

  • 我们需要将account.haccount_export.h头文件以及共享库复制到依赖于 Python 环境的 Python 模块位置。

  • 我们需要告诉__init__.py在哪里找到这些头文件和库。在第九章,混合语言项目,第 6 个食谱,使用 Python CFFI 混合 C、C++、Fortran 和 Python中,我们通过使用环境变量解决了这些问题,但每次我们计划使用 Python 模块时设置这些变量是不切实际的。

  • 在 Python 方面,我们不知道共享库文件的确切名称(后缀),因为它取决于操作系统。

让我们从最后一点开始:我们不知道确切的名称,但在生成构建系统时 CMake 知道,因此我们在interface_file_names.cfg.in中使用生成器表达式来扩展占位符:

[configuration]
header_file_name = account.h
library_file_name = $<TARGET_FILE_NAME:account>

此输入文件用于生成${CMAKE_CURRENT_BINARY_DIR}/interface_file_names.cfg

file(
  GENERATE OUTPUT ${CMAKE_CURRENT_BINARY_DIR}/interface_file_names.cfg
  INPUT ${CMAKE_CURRENT_SOURCE_DIR}/interface_file_names.cfg.in
  )

然后,我们将两个头文件定义为PUBLIC_HEADER(另请参见第十章,编写安装程序),并将配置文件定义为RESOURCE

set_target_properties(account
  PROPERTIES
    PUBLIC_HEADER "account.h;${CMAKE_CURRENT_BINARY_DIR}/account_export.h"
    RESOURCE "${CMAKE_CURRENT_BINARY_DIR}/interface_file_names.cfg"
  )

最后,我们将库、头文件和配置文件安装到一个相对于由setup.py定义的路径的结构中:

install(
  TARGETS
    account
  LIBRARY
    DESTINATION account/lib
  RUNTIME
    DESTINATION account/lib
  PUBLIC_HEADER
    DESTINATION account/include
  RESOURCE
    DESTINATION account
  )

请注意,我们将DESTINATION设置为LIBRARYRUNTIME,指向account/lib。这对于 Windows 来说很重要,因为共享库具有可执行入口点,因此我们必须同时指定两者。

Python 包将能够通过account/__init__.py中的这一部分找到这些文件:

# this interface requires the header file and library file
# and these can be either provided by interface_file_names.cfg
# in the same path as this file
# or if this is not found then using environment variables
_this_path = Path(os.path.dirname(os.path.realpath(__file__)))
_cfg_file = _this_path / 'interface_file_names.cfg'
if _cfg_file.exists():
    config = ConfigParser()
    config.read(_cfg_file)
    header_file_name = config.get('configuration', 'header_file_name')
    _header_file = _this_path / 'include' / header_file_name
    _header_file = str(_header_file)
    library_file_name = config.get('configuration', 'library_file_name')
    _library_file = _this_path / 'lib' / library_file_name
    _library_file = str(_library_file)
else:
    _header_file = os.getenv('ACCOUNT_HEADER_FILE')
    assert _header_file is not None
    _library_file = os.getenv('ACCOUNT_LIBRARY_FILE')
    assert _library_file is not None

在这种情况下,_cfg_file将被找到并解析,setup.py将在include下找到头文件,在lib下找到库,并将这些传递给 CFFI 以构造库对象。这也是我们使用lib作为安装目标DESTINATION而不是CMAKE_INSTALL_LIBDIR的原因,否则可能会让account/__init__.py感到困惑。

还有更多

对于将包上传到 PyPI 测试和生产实例的后续步骤,我们请读者参考之前的食谱,因为这些步骤是类似的。

将一个简单项目作为 Conda 包分发

本节的代码可在github.com/dev-cafe/cmake-cookbook/tree/v1.0/chapter-11/recipe-04找到。本节适用于 CMake 版本 3.5(及更高版本),并在 GNU/Linux、macOS 和 Windows 上进行了测试。

尽管 PyPI 是分发 Python 包的标准且流行的平台,但 Anaconda(anaconda.org)更为通用,因为它不仅允许分发带有 Python 接口的 Python 或混合语言项目,还允许为非 Python 项目进行打包和依赖管理。在本节中,我们将为使用 CMake 配置和构建的非常简单的 C++示例项目准备一个 Conda 包,该项目没有除 C++之外的其他依赖项。在下一节中,我们将准备并讨论一个更复杂的 Conda 包。

准备就绪

我们的目标将是打包以下简单的示例代码(example.cpp):

#include <iostream>

int main() {
  std::cout << "hello from your conda package!" << std::endl;

  return 0;
}

如何操作

以下是按步骤进行的方法:

  1. CMakeLists.txt文件以最小版本要求、项目名称和支持的语言开始:
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)

project(recipe-04 LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 11)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
  1. 我们希望构建hello-conda可执行文件,该文件由example.cpp构建:
add_executable(hello-conda "")

target_sources(hello-conda
  PRIVATE
    example.cpp
  )
  1. 我们通过定义安装目标来结束CMakeLists.txt
install(
  TARGETS
    hello-conda
  DESTINATION
    bin
  )
  1. 我们将在名为meta.yaml的文件中描述 Conda 包,我们将把它放在conda-recipe下,以达到以下文件结构:
.
├── CMakeLists.txt
├── conda-recipe
│   └── meta.yaml
└── example.cpp
  1. meta.yaml文件由以下内容组成:
package:
  name: conda-example-simple
  version: "0.0.0"

source:
  path: ../ # this can be changed to git-url

build:
  number: 0
  binary_relocation: true
  script:
    - cmake -H. -Bbuild_conda -G "${CMAKE_GENERATOR}" -DCMAKE_INSTALL_PREFIX=${PREFIX} # [not win]
    - cmake -H. -Bbuild_conda -G "%CMAKE_GENERATOR%" -DCMAKE_INSTALL_PREFIX="%LIBRARY_PREFIX%" # [win]
    - cmake --build build_conda --target install

requirements:
  build:
    - cmake >=3.5
    - {{ compiler('cxx') }}

about:
  home: http://www.example.com
  license: MIT
  summary: "Summary in here ..."
  1. 现在我们可以尝试构建包:
$ conda build conda-recipe
  1. 我们将在屏幕上看到大量输出,但一旦构建完成,我们就可以安装包。我们将首先进行本地安装:
$ conda install --use-local conda-example-simple
  1. 现在我们准备测试它——打开一个新的终端(假设已激活 Anaconda)并输入以下内容:
$ hello-conda 
hello from your conda package!
  1. 测试成功后,我们可以再次删除该包:
$ conda remove conda-example-simple

工作原理

CMakeLists.txt中的安装目标是本节的关键组件:

install(
  TARGETS
    hello-conda
  DESTINATION
    bin
  )

此目标确保二进制文件安装在${CMAKE_INSTALL_PREFIX}/bin中。前缀变量在meta.yaml的构建步骤中由 Conda 定义:

build:
  number: 0
  binary_relocation: true
  script:
    - cmake -H. -Bbuild_conda -G "${CMAKE_GENERATOR}" 
-DCMAKE_INSTALL_PREFIX=${PREFIX} # [not win]
    - cmake -H. -Bbuild_conda -G "%CMAKE_GENERATOR%" 
-DCMAKE_INSTALL_PREFIX="%LIBRARY_PREFIX%" # [win]
    - cmake --build build_conda --target install

构建步骤配置项目,将安装前缀设置为${PREFIX}(由 Conda 设置的内在变量),构建并安装项目。将构建目录命名为build_conda的动机与前面的节类似:特定的构建目录名称使得更容易基于可能已经包含名为build的目录的目录进行本地安装实验。

通过将包安装到 Anaconda 环境中,我们使可执行文件对系统可用。

还有更多

配置文件meta.yaml可用于指定项目的构建、测试和安装步骤,原则上任何复杂度的项目都可以使用。请参考官方文档进行深入讨论:conda.io/docs/user-guide/tasks/build-packages/define-metadata.html

要将 Conda 包上传到 Anaconda 云,请遵循 Anaconda 云官方文档:docs.anaconda.com/anaconda-cloud/user-guide/。同时,可以考虑使用 Miniconda 作为 Anaconda 的轻量级替代品:conda.io/miniconda.html

以 Conda 包形式分发具有依赖关系的项目

本食谱的代码可在github.com/dev-cafe/cmake-cookbook/tree/v1.0/chapter-11/recipe-05找到。该食谱适用于 CMake 版本 3.5(及以上),并在 GNU/Linux、macOS 和 Windows 上进行了测试。

在本食谱中,我们将基于前一个食谱的发现,为示例 CMake 项目准备一个更真实、更复杂的 Conda 包,该项目将依赖于并利用 Intel 数学内核库(MKL)中提供的 DGEMM 函数实现,用于矩阵乘法。Intel MKL 作为 Conda 包提供。本食谱将为我们提供准备和共享具有依赖关系的 Conda 包的工具集。

准备就绪

对于本食谱,我们将使用与前一个简单 Conda 食谱相同的文件命名和目录结构:

.
├── CMakeLists.txt
├── conda-recipe
│   └── meta.yaml
└── example.cpp

示例源文件(example.cpp)执行矩阵乘法,并将 MKL 库返回的结果与“noddy”实现进行比较:

#include "mkl.h"

#include <cassert>
#include <cmath>
#include <iostream>
#include <random>

int main() {
  // generate a uniform distribution of real number between -1.0 and 1.0
  std::random_device rd;
  std::mt19937 mt(rd());
  std::uniform_real_distribution<double> dist(-1.0, 1.0);

  int m = 500;
  int k = 1000;
  int n = 2000;

  double *A = (double *)mkl_malloc(m * k * sizeof(double), 64);
  double *B = (double *)mkl_malloc(k * n * sizeof(double), 64);
  double *C = (double *)mkl_malloc(m * n * sizeof(double), 64);
  double *D = new double[m * n];

  for (int i = 0; i < (m * k); i++) {
    A[i] = dist(mt);
  }

  for (int i = 0; i < (k * n); i++) {
    B[i] = dist(mt);
  }

  for (int i = 0; i < (m * n); i++) {
    C[i] = 0.0;
  }

  double alpha = 1.0;
  double beta = 0.0;
  cblas_dgemm(CblasRowMajor,
              CblasNoTrans,
              CblasNoTrans,
              m,
              n,
              k,
              alpha,
              A,
              k,
              B,
              n,
              beta,
              C,
              n);

  // D_mn = A_mk B_kn
  for (int r = 0; r < m; r++) {
    for (int c = 0; c < n; c++) {
      D[r * n + c] = 0.0;
      for (int i = 0; i < k; i++) {
        D[r * n + c] += A[r * k + i] * B[i * n + c];
      }
    }
  }

  // compare the two matrices
  double r = 0.0;
  for (int i = 0; i < (m * n); i++) {
    r += std::pow(C[i] - D[i], 2.0);
  }
  assert(r < 1.0e-12 && "ERROR: matrices C and D do not match");

  mkl_free(A);
  mkl_free(B);
  mkl_free(C);
  delete[] D;

  std::cout << "MKL DGEMM example worked!" << std::endl;

  return 0;
}

我们还需要一个修改过的meta.yaml。但是,与前一个食谱相比,唯一的更改是在需求下添加了mkl-devel依赖项的行:

package:
  name: conda-example-dgemm
  version: "0.0.0"

source:
  path: ../ # this can be changed to git-url

build:
  number: 0
  script:
    - cmake -H. -Bbuild_conda -G "${CMAKE_GENERATOR}" 
-DCMAKE_INSTALL_PREFIX=${PREFIX} # [not win]
    - cmake -H. -Bbuild_conda -G "%CMAKE_GENERATOR%" 
-DCMAKE_INSTALL_PREFIX="%LIBRARY_PREFIX%" # [win]
    - cmake --build build_conda --target install

requirements:
  build:
    - cmake >=3.5
    - {{ compiler('cxx') }}
  host:
    - mkl-devel 2018

about:
  home: http://www.example.com
  license: MIT
  summary: "Summary in here ..."

如何操作

以下是准备我们包的步骤:

  1. CMakeLists.txt文件以最小版本要求、项目名称和支持的语言开始:
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)

project(recipe-05 LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 11)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
  1. 我们希望构建dgemm-example可执行文件,它由example.cpp构建:
add_executable(dgemm-example "")

target_sources(dgemm-example
  PRIVATE
    example.cpp
  )
  1. 然后,我们需要定位通过mkl-devel安装的 MKL 库。我们准备了一个名为IntelMKLINTERFACE库。这可以像任何其他目标一样使用,并将为任何依赖目标设置包含目录、编译器选项和链接库。设置旨在模仿 Intel MKL 链接行顾问建议的内容(software.intel.com/en-us/articles/intel-mkl-link-line-advisor/)。首先,我们设置编译器选项:
add_library(IntelMKL INTERFACE)

target_compile_options(IntelMKL
  INTERFACE
    $<$<OR:$<CXX_COMPILER_ID:GNU>,$<CXX_COMPILER_ID:AppleClang>>:-m64>
  )
  1. 接下来,我们搜索mkl.h头文件,并为IntelMKL目标设置include目录:
find_path(_mkl_h
  NAMES
    mkl.h
  HINTS
    ${CMAKE_INSTALL_PREFIX}/include
  )
target_include_directories(IntelMKL
  INTERFACE
    ${_mkl_h}
  )
message(STATUS "MKL header file FOUND: ${_mkl_h}")
  1. 最后,我们定位库并设置IntelMKL目标的链接库:
find_library(_mkl_libs
  NAMES
    mkl_rt
  HINTS
    ${CMAKE_INSTALL_PREFIX}/lib
  )
message(STATUS "MKL single dynamic library FOUND: ${_mkl_libs}")

find_package(Threads QUIET)
target_link_libraries(IntelMKL
  INTERFACE
    ${_mkl_libs}
    $<$<OR:$<CXX_COMPILER_ID:GNU>,$<CXX_COMPILER_ID:AppleClang>>:Threads::Threads>
    $<$<OR:$<CXX_COMPILER_ID:GNU>,$<CXX_COMPILER_ID:AppleClang>>:m>
  )
  1. 我们使用cmake_print_properties函数打印有关IntelMKL目标的有用消息:
include(CMakePrintHelpers)
cmake_print_properties(
  TARGETS
    IntelMKL
  PROPERTIES
    INTERFACE_COMPILE_OPTIONS
    INTERFACE_INCLUDE_DIRECTORIES
    INTERFACE_LINK_LIBRARIES
  )
  1. 我们将dgemm-example目标与这些库链接:
target_link_libraries(dgemm-example
  PRIVATE
    IntelMKL
  )
  1. 我们通过定义安装目标来结束CMakeLists.txt
install(
  TARGETS
    dgemm-example
  DESTINATION
    bin
  )
  1. 现在我们可以尝试构建包:
$ conda build conda-recipe
  1. 我们将在屏幕上看到大量输出,但一旦构建完成,我们就可以安装该包。我们将首先在本地进行此操作:
$ conda install --use-local conda-example-dgemm
  1. 现在我们准备测试它 – 打开一个新的终端(假设 Anaconda 已激活)并输入:
$ dgemm-example 
MKL DGEMM example worked!
  1. 测试成功后,我们可以再次删除该包:
$ conda remove conda-example-dgemm

它是如何工作的

与之前的配方相比,meta.yaml中的唯一变化是mkl-devel依赖项。从 CMake 的角度来看,挑战在于定位 Anaconda 安装的 MKL 库。幸运的是,我们知道它们位于${CMAKE_INSTALL_PREFIX}。在线提供的 Intel MKL 链接行顾问(software.intel.com/en-us/articles/intel-mkl-link-line-advisor/)可以用来查找根据所选平台和编译器将 MKL 链接到我们项目的方法。我们决定将这些信息封装成一个INTERFACE库。对于 MKL 的情况,这种解决方案是理想的:该库不是我们项目或任何子项目创建的目标,但它仍然需要以可能非常复杂的方式处理;即:设置编译器标志、包含目录和链接库。CMake INTERFACE库是构建系统中的目标,但不直接创建任何构建输出。然而,由于它们是目标,我们可以在它们上面设置属性。就像“真实”目标一样,它们也可以被安装、导出和导入。

首先,我们声明一个名为IntelMKL的新库,并带有INTERFACE属性。然后我们需要根据需要设置属性,我们遵循在目标上使用INTERFACE属性调用适当的 CMake 命令的模式,使用以下命令:

  • target_compile_options,用于设置INTERFACE_COMPILE_OPTIONS。在我们的例子中,必须设置-m64,但仅在使用 GNU 或 AppleClang 编译器时。请注意,我们使用生成器表达式来执行此操作。

  • target_include_directories,用于设置INTERFACE_INCLUDE_DIRECTORIES。这些可以在系统中找到mkl.h头文件后设置。这是通过find_path CMake 命令完成的。

  • target_link_libraries,用于设置INTERFACE_LINK_LIBRARIES。我们决定链接到单个动态库libmkl_rt.so,并使用find_library CMake 命令搜索它。GNU 或 AppleClang 编译器还需要将可执行文件链接到本机线程和数学库。再次,这些情况都优雅地使用生成器表达式处理。

我们刚刚在 IntelMKL 目标上设置的属性可以通过cmake_print_properties命令打印出来供用户查看。最后,我们链接到IntelMKL目标。正如预期的那样,这将设置编译器标志、包含目录和链接库,以确保成功编译:

target_link_libraries(dgemm-example
  PRIVATE
    IntelMKL
  )

还有更多

Anaconda 云包含大量包。有了前面的配方,就有可能并且相对简单地为可能依赖于其他 Conda 包的 CMake 项目构建 Conda 包。探索这种可能性,并分享您的软件包,以便其他人可以在您的开发基础上构建!

第十三章:构建文档

在本章中,我们将涵盖以下食谱:

  • 使用 Doxygen 构建文档

  • 使用 Sphinx 构建文档

  • 结合 Doxygen 和 Sphinx

引言

文档在所有软件项目中都是必不可少的:对于用户,解释如何获取和构建代码,并说明如何有效地使用您的代码或库,对于开发者,描述库的内部细节,并帮助其他程序员参与并贡献于您的项目。本章将展示如何使用 CMake 构建代码文档,使用两个流行的框架:Doxygen 和 Sphinx。

使用 Doxygen 构建文档

本食谱的代码可在github.com/dev-cafe/cmake-cookbook/tree/v1.0/chapter-12/recipe-01找到,并包含一个 C++示例。该食谱适用于 CMake 版本 3.5(及以上),并在 GNU/Linux、macOS 和 Windows 上进行了测试。

Doxygen(www.doxygen.nl)是一个非常流行的源代码文档工具。您可以在代码中添加文档标签作为注释。运行 Doxygen 将提取这些注释并在 Doxyfile 配置文件中定义的格式中创建文档。Doxygen 可以输出 HTML、XML,甚至是 LaTeX 或 PDF。本食谱将向您展示如何使用 CMake 构建您的 Doxygen 文档。

准备就绪

我们将使用之前章节中介绍的message库的简化版本。源树组织如下:

.
├── cmake
│   └── UseDoxygenDoc.cmake
├── CMakeLists.txt
├── docs
│   ├── Doxyfile.in
│   └── front_page.md
└── src
    ├── CMakeLists.txt
    ├── hello-world.cpp
    ├── Message.cpp
    └── Message.hpp

我们的源代码仍然位于src子目录下,自定义 CMake 模块位于cmake子目录下。由于我们的重点是文档,我们删除了对 UUID 的依赖并简化了源代码。最显著的区别是头文件中的大量代码注释:

#pragma once

#include <iosfwd>
#include <string>

/*! \file Message.hpp */

/*! \class Message
 * \brief Forwards string to screen
 * \author Roberto Di Remigio
 * \date 2018
 */
class Message {
public:
  /*! \brief Constructor from a string
   * \param[in] m a message
   */
  Message(const std::string &m) : message_(m) {}
  /*! \brief Constructor from a character array
   * \param[in] m a message
   */
  Message(const char *m) : message_(std::string(m)) {}

  friend std::ostream &operator<<(std::ostream &os, Message &obj) {
    return obj.printObject(os);
  }

private:
  /*! The message to be forwarded to screen */
  std::string message_;
  /*! \brief Function to forward message to screen
   * \param[in, out] os output stream
   */
  std::ostream &printObject(std::ostream &os);
};

这些注释采用/*! */格式,并包含一些特殊标签,这些标签被 Doxygen 理解(参见www.stack.nl/~dimitri/doxygen/manual/docblocks.html)。

如何操作

首先,让我们讨论根目录中的CMakeLists.txt文件:

  1. 如您所熟悉,我们声明一个 C++11 项目,如下所示:
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)

project(recipe-01 LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 11)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
  1. 我们定义共享和静态库以及可执行文件的输出目录,如下所示:
include(GNUInstallDirs)
set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY
  ${CMAKE_BINARY_DIR}/${CMAKE_INSTALL_LIBDIR})
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY
  ${CMAKE_BINARY_DIR}/${CMAKE_INSTALL_LIBDIR})
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY
  ${CMAKE_BINARY_DIR}/${CMAKE_INSTALL_BINDIR})
  1. 我们将cmake子目录附加到CMAKE_MODULE_PATH。这是 CMake 找到我们的自定义模块所必需的:
list(APPEND CMAKE_MODULE_PATH "${CMAKE_SOURCE_DIR}/cmake")
  1. 包含自定义模块UseDoxygenDoc.cmake。我们将在后面讨论其内容:
include(UseDoxygenDoc)
  1. 然后我们添加src子目录:
add_subdirectory(src)

src子目录中的CMakeLists.txt文件包含以下构建块:

  1. 我们添加一个message静态库,如下所示:
add_library(message STATIC
  Message.hpp
  Message.cpp
  )
  1. 然后我们添加一个可执行目标,hello-world
add_executable(hello-world hello-world.cpp)
  1. 然后,hello-world可执行文件应该链接到消息库:
target_link_libraries(hello-world
  PUBLIC
    message
  )

在根CMakeLists.txt文件的最后一节中,我们调用了add_doxygen_doc函数。这添加了一个新的docs目标,该目标将调用 Doxygen 来构建我们的文档:

add_doxygen_doc(
  BUILD_DIR
    ${CMAKE_CURRENT_BINARY_DIR}/_build
  DOXY_FILE
    ${CMAKE_CURRENT_SOURCE_DIR}/docs/Doxyfile.in
  TARGET_NAME
    docs
  COMMENT
    "HTML documentation"
  )

最后,让我们看一下UseDoxygenDoc.cmake模块,其中定义了add_doxygen_doc函数:

  1. 我们找到DoxygenPerl可执行文件,如下所示:
find_package(Perl REQUIRED)
find_package(Doxygen REQUIRED)
  1. 然后,我们声明add_doxygen_doc函数。该函数理解单值参数:BUILD_DIRDOXY_FILETARGET_NAMECOMMENT。我们使用 CMake 的标准命令cmake_parse_arguments来解析这些参数:
function(add_doxygen_doc)
  set(options)
  set(oneValueArgs BUILD_DIR DOXY_FILE TARGET_NAME COMMENT)
  set(multiValueArgs)

  cmake_parse_arguments(DOXY_DOC
    "${options}"
    "${oneValueArgs}"
    "${multiValueArgs}"
    ${ARGN}
    )

  # ...

endfunction()
  1. Doxyfile包含构建文档所需的所有 Doxygen 设置。模板Doxyfile.in作为函数参数DOXY_FILE传递,并被解析到DOXY_DOC_DOXY_FILE变量中。我们按照以下方式配置模板文件Doxyfile.in
configure_file(
  ${DOXY_DOC_DOXY_FILE}
  ${DOXY_DOC_BUILD_DIR}/Doxyfile
  @ONLY
  )
  1. 然后,我们定义一个名为DOXY_DOC_TARGET_NAME的自定义目标,它将使用Doxyfile中的设置执行 Doxygen,并将结果输出到DOXY_DOC_BUILD_DIR
add_custom_target(${DOXY_DOC_TARGET_NAME}
  COMMAND
    ${DOXYGEN_EXECUTABLE} Doxyfile
  WORKING_DIRECTORY
    ${DOXY_DOC_BUILD_DIR}
  COMMENT
    "Building ${DOXY_DOC_COMMENT} with Doxygen"
  VERBATIM
  )
  1. 最终,会向用户打印一条状态消息:
message(STATUS "Added ${DOXY_DOC_TARGET_NAME} [Doxygen] target to build documentation")

我们可以像往常一样配置项目:

$ mkdir -p build
$ cd build
$ cmake ..
$ cmake --build .

通过调用我们的自定义docs目标,可以构建文档:

$ cmake --build . --target docs

你会注意到,在构建树中会出现一个_build子目录。这包含 Doxygen 从你的源文件生成的 HTML 文档。使用你喜欢的浏览器打开index.html将显示 Doxygen 欢迎页面。

如果你导航到类列表,你可以例如浏览Message类的文档:

工作原理

CMake 默认不支持文档构建。但是,我们可以使用add_custom_target来执行任意操作,这是我们在本食谱中利用的机制。需要注意的是,我们需要确保系统上存在构建文档所需的工具(在本例中为 Doxygen 和 Perl)。

此外,请注意UseDoxygenDoc.cmake自定义模块仅执行以下操作:

  • 执行对 Doxygen 和 Perl 可执行文件的搜索

  • 定义一个函数

实际创建docs目标的操作留给了稍后调用add_doxygen_doc函数。这是一种“显式优于隐式”的模式,我们认为这是良好的 CMake 实践:不要使用模块包含来执行类似宏(或函数)的操作。

我们通过使用函数而不是宏来实现add_doxygen_doc,以限制变量定义的作用域和可能的副作用。在这种情况下,函数和宏都可以工作(并且会产生相同的结果),但我们建议除非需要修改父作用域中的变量,否则应优先使用函数而不是宏。

CMake 3.9 中添加了一个新的改进的FindDoxygen.cmake模块。实现了便利函数doxygen_add_docs,它将作为我们在本食谱中介绍的宏。有关更多详细信息,请查看在线文档cmake.org/cmake/help/v3.9/module/FindDoxygen.html

使用 Sphinx 构建文档

本食谱的代码可在github.com/dev-cafe/cmake-cookbook/tree/v1.0/chapter-12/recipe-02找到,并包含一个 C++示例。该食谱适用于 CMake 版本 3.5(及以上),并在 GNU/Linux、macOS 和 Windows 上进行了测试。

Sphinx 是一个 Python 程序,也是一个非常流行的文档系统(www.sphinx-doc.org)。当与 Python 项目一起使用时,它可以解析源文件中的所谓 docstrings,并自动为函数和类生成文档页面。然而,Sphinx 不仅限于 Python,还可以解析 reStructuredText、Markdown 纯文本文件,并生成 HTML、ePUB 或 PDF 文档。与在线 Read the Docs 服务(readthedocs.org)结合使用,它提供了一种快速开始编写和部署文档的绝佳方式。本食谱将向您展示如何使用 CMake 基于 Sphinx 构建文档。

准备工作

我们希望构建一个简单的网站来记录我们的消息库。源树现在看起来如下:

.
├── cmake
│   ├── FindSphinx.cmake
│   └── UseSphinxDoc.cmake
├── CMakeLists.txt
├── docs
│   ├── conf.py.in
│   └── index.rst
└── src
    ├── CMakeLists.txt
    ├── hello-world.cpp
    ├── Message.cpp
    └── Message.hpp

我们在cmake子目录中有一些自定义模块,docs子目录包含我们网站的主页,以纯文本 reStructuredText 格式,index.rst,以及一个 Python 模板文件,conf.py.in,用于 Sphinx 的设置。此文件可以使用 Sphinx 安装的一部分sphinx-quickstart实用程序自动生成。

如何操作

与之前的食谱相比,我们将修改根CMakeLists.txt文件,并实现一个函数(add_sphinx_doc):

  1. 在将cmake文件夹附加到CMAKE_MODULE_PATH之后,我们如下包含UseSphinxDoc.cmake自定义模块:
list(APPEND CMAKE_MODULE_PATH "${CMAKE_SOURCE_DIR}/cmake")

include(UseSphinxDoc)
  1. UseSphinxDoc.cmake模块定义了add_sphinx_doc函数。我们使用关键字参数调用此函数,以设置我们的 Sphinx 文档构建。自定义文档目标将被称为docs
add_sphinx_doc(
  SOURCE_DIR
    ${CMAKE_CURRENT_SOURCE_DIR}/docs
  BUILD_DIR
    ${CMAKE_CURRENT_BINARY_DIR}/_build
  CACHE_DIR
    ${CMAKE_CURRENT_BINARY_DIR}/_doctrees
  HTML_DIR
    ${CMAKE_CURRENT_BINARY_DIR}/sphinx_html
  CONF_FILE
    ${CMAKE_CURRENT_SOURCE_DIR}/docs/conf.py.in
  TARGET_NAME
    docs
  COMMENT
    "HTML documentation"
  )

UseSphinxDoc.cmake模块遵循我们在前一个食谱中使用的相同“显式优于隐式”模式:

  1. 我们需要找到 Python 解释器和Sphinx可执行文件,如下所示:
find_package(PythonInterp REQUIRED)
find_package(Sphinx REQUIRED)
  1. 然后我们定义带有单值关键字参数的add_sphinx_doc函数。这些参数由cmake_parse_arguments命令解析:
function(add_sphinx_doc)
  set(options)
  set(oneValueArgs
    SOURCE_DIR
    BUILD_DIR
    CACHE_DIR
    HTML_DIR
    CONF_FILE
    TARGET_NAME
    COMMENT
    )
  set(multiValueArgs)

  cmake_parse_arguments(SPHINX_DOC
    "${options}"
    "${oneValueArgs}"
    "${multiValueArgs}"
    ${ARGN}
    )

  # ...

endfunction()
  1. 模板文件conf.py.in,作为CONF_FILE关键字参数传递,配置为在SPHINX_DOC_BUILD_DIR中的conf.py
configure_file(
  ${SPHINX_DOC_CONF_FILE}
  ${SPHINX_DOC_BUILD_DIR}/conf.py
  @ONLY
  )
  1. 我们添加了一个名为SPHINX_DOC_TARGET_NAME的自定义目标,以协调使用 Sphinx 构建文档:
add_custom_target(${SPHINX_DOC_TARGET_NAME}
  COMMAND
    ${SPHINX_EXECUTABLE}
       -q
       -b html
       -c ${SPHINX_DOC_BUILD_DIR}
       -d ${SPHINX_DOC_CACHE_DIR}
       ${SPHINX_DOC_SOURCE_DIR}
       ${SPHINX_DOC_HTML_DIR}
  COMMENT
    "Building ${SPHINX_DOC_COMMENT} with Sphinx"
  VERBATIM
  )
  1. 最后,我们向用户打印出一条状态消息:
message(STATUS "Added ${SPHINX_DOC_TARGET_NAME} [Sphinx] target to build documentation")
  1. 我们配置项目并构建docs目标:
$ mkdir -p build
$ cd build
$ cmake ..
$ cmake --build . --target docs

这将在构建树的SPHINX_DOC_HTML_DIR子目录中生成 HTML 文档。再次,您可以使用您喜欢的浏览器打开index.html并查看闪亮(但仍然稀疏)的文档:

它是如何工作的

再次,我们利用了add_custom_target的强大功能,向我们的构建系统添加了一个任意构建目标。在这种情况下,文档将使用 Sphinx 构建。由于 Sphinx 是一个可以与其他 Python 模块扩展的 Python 程序,因此docs目标将依赖于 Python 解释器。我们确保通过使用find_package来满足依赖关系。请注意,FindSphinx.cmake模块还不是标准的 CMake 模块;它的副本包含在项目源代码的cmake子目录下。

结合 Doxygen 和 Sphinx

本食谱的代码可在github.com/dev-cafe/cmake-cookbook/tree/v1.0/chapter-12/recipe-03找到,并包含一个 C++示例。该食谱适用于 CMake 版本 3.5(及以上),并在 GNU/Linux、macOS 和 Windows 上进行了测试。

我们有一个 C++项目,因此,Doxygen 是生成源代码文档的理想选择。然而,我们也希望发布面向用户的文档,例如解释我们的设计选择。我们更愿意使用 Sphinx 来实现这一点,因为生成的 HTML 也可以在移动设备上工作,而且我们可以将文档部署到 Read the Docs(readthedocs.org)。本食谱将说明如何使用 Breathe 插件(breathe.readthedocs.io)来桥接 Doxygen 和 Sphinx。

准备就绪

本食谱的源代码树与前两个食谱类似:

.
├── cmake
│   ├── FindPythonModule.cmake
│   ├── FindSphinx.cmake
│   └── UseBreathe.cmake
├── CMakeLists.txt
├── docs
│   ├── code-reference
│   │   ├── classes-and-functions.rst
│   │   └── message.rst
│   ├── conf.py.in
│   ├── Doxyfile.in
│   └── index.rst
└── src
    ├── CMakeLists.txt
    ├── hello-world.cpp
    ├── Message.cpp
    └── Message.hpp

现在,docs子目录中包含了Doxyfile.inconf.py.in模板文件,分别用于 Doxygen 和 Sphinx 的设置。此外,我们还有一个code-reference子目录。

紧随code-reference的文件包含 Breathe 指令,以在 Sphinx 中包含 Doxygen 生成的文档:

Messaging classes
=================

Message
-------
.. doxygenclass:: Message
   :project: recipe-03
   :members:
   :protected-members:
   :private-members:

这将输出Message类的文档。

如何操作

src目录中的CMakeLists.txt文件未更改。根目录中的CMakeLists.txt文件的唯一更改如下:

  1. 我们包含UseBreathe.cmake自定义模块:
list(APPEND CMAKE_MODULE_PATH "${CMAKE_SOURCE_DIR}/cmake")

include(UseBreathe)
  1. 我们调用了add_breathe_doc函数。该函数在自定义模块中定义,并接受关键字参数来设置结合 Doxygen 和 Sphinx 的构建:
add_breathe_doc(
  SOURCE_DIR
    ${CMAKE_CURRENT_SOURCE_DIR}/docs
  BUILD_DIR
    ${CMAKE_CURRENT_BINARY_DIR}/_build
  CACHE_DIR
    ${CMAKE_CURRENT_BINARY_DIR}/_doctrees
  HTML_DIR
    ${CMAKE_CURRENT_BINARY_DIR}/html
  DOXY_FILE
    ${CMAKE_CURRENT_SOURCE_DIR}/docs/Doxyfile.in
  CONF_FILE
    ${CMAKE_CURRENT_SOURCE_DIR}/docs/conf.py.in
  TARGET_NAME
    docs
  COMMENT
    "HTML documentation"
  )

让我们检查UseBreatheDoc.cmake模块。这遵循了我们之前两个配方中描述的明确优于隐式的相同模式。该模块详细描述如下:

  1. 文档生成依赖于 Doxygen:
find_package(Doxygen REQUIRED)
find_package(Perl REQUIRED)
  1. 我们还依赖于 Python 解释器和Sphinx
find_package(PythonInterp REQUIRED)
find_package(Sphinx REQUIRED)
  1. 此外,我们还必须找到breathe Python 模块。我们使用FindPythonModule.cmake模块:
include(FindPythonModule)
find_python_module(breathe REQUIRED)
  1. 我们定义了add_breathe_doc函数。该函数有一个单值关键字参数,我们将使用cmake_parse_arguments命令对其进行解析:
function(add_breathe_doc)
  set(options)
  set(oneValueArgs
    SOURCE_DIR
    BUILD_DIR
    CACHE_DIR
    HTML_DIR
    DOXY_FILE
    CONF_FILE
    TARGET_NAME
    COMMENT
    )
  set(multiValueArgs)

  cmake_parse_arguments(BREATHE_DOC
    "${options}"
    "${oneValueArgs}"
    "${multiValueArgs}"
    ${ARGN}
    )

  # ...

endfunction()
  1. BREATHE_DOC_CONF_FILE模板文件用于 Sphinx,配置为conf.pyBREATHE_DOC_BUILD_DIR中:
configure_file(
  ${BREATHE_DOC_CONF_FILE}
  ${BREATHE_DOC_BUILD_DIR}/conf.py
  @ONLY
  )
  1. 相应地,Doxygen 的BREATHE_DOC_DOXY_FILE模板文件配置为DoxyfileBREATHE_DOC_BUILD_DIR中:
configure_file(
  ${BREATHE_DOC_DOXY_FILE}
  ${BREATHE_DOC_BUILD_DIR}/Doxyfile
  @ONLY
  )
  1. 然后我们添加了自定义目标BREATHE_DOC_TARGET_NAME。请注意,只运行了 Sphinx;对 Doxygen 的必要调用在BREATHE_DOC_SPHINX_FILE内部发生:
add_custom_target(${BREATHE_DOC_TARGET_NAME}
  COMMAND
    ${SPHINX_EXECUTABLE}
       -q
       -b html
       -c ${BREATHE_DOC_BUILD_DIR}
       -d ${BREATHE_DOC_CACHE_DIR}
       ${BREATHE_DOC_SOURCE_DIR}
       ${BREATHE_DOC_HTML_DIR}
  COMMENT
    "Building ${BREATHE_DOC_TARGET_NAME} documentation with Breathe, Sphinx and Doxygen"
  VERBATIM
  )
  1. 最后,向用户打印一条状态消息:
message(STATUS "Added ${BREATHE_DOC_TARGET_NAME} [Breathe+Sphinx+Doxygen] target to build documentation")
  1. 配置完成后,我们可以像往常一样构建文档:
$ mkdir -p build
$ cd build
$ cmake ..
$ cmake --build . --target docs

文档将可在构建树的BREATHE_DOC_HTML_DIR子目录中找到。启动浏览器打开index.html文件后,您可以导航到Message类的文档:

工作原理

您会注意到,尽管在声明自定义BREATHE_DOC_TARGET_NAME目标时只给出了对 Sphinx 的调用,但 Doxygen 和 Sphinx 都运行了。这是由于 Sphinx 的conf.py文件中定义的以下设置:

def run_doxygen(folder):
    """Run the doxygen make command in the designated folder"""

    try:
        retcode = subprocess.call("cd {}; doxygen".format(folder), shell=True)
        if retcode < 0:
            sys.stderr.write(
                "doxygen terminated by signal {}".format(-retcode))
    except OSError as e:
        sys.stderr.write("doxygen execution failed: {}".format(e))

def setup(app):
    run_doxygen('@BREATHE_DOC_BUILD_DIR@')

Doxygen 将生成 XML 输出,Breathe 插件将能够以与所选 Sphinx 文档样式一致的形式呈现这些输出。

第十四章:替代生成器和跨编译

在本章中,我们将介绍以下内容:

  • 在 Visual Studio 中构建 CMake 项目

  • 跨编译一个 hello world 示例

  • 使用 OpenMP 并行化跨编译 Windows 二进制文件

引言

CMake 本身并不构建可执行文件和库。相反,CMake 配置一个项目并生成由另一个构建工具或框架用来构建项目的文件。在 GNU/Linux 和 macOS 上,CMake 通常生成 Unix Makefiles,但存在许多替代方案。在 Windows 上,这些通常是 Visual Studio 项目文件或 MinGW 或 MSYS Makefiles。CMake 包含了一系列针对本地命令行构建工具或集成开发环境(IDEs)的生成器。您可以在以下链接了解更多信息:cmake.org/cmake/help/latest/manual/cmake-generators.7.html

这些生成器可以使用cmake -G来选择,例如:

$ cmake -G "Visual Studio 15 2017"

并非所有生成器在每个平台上都可用,根据 CMake 运行的平台,通常只有一部分可用。要查看当前平台上所有可用的生成器列表,请输入以下内容:

$ cmake -G

在本章中,我们不会遍历所有可用的生成器,但我们注意到本书中的大多数配方都使用Unix MakefilesMSYS MakefilesNinjaVisual Studio 15 2017生成器进行了测试。在本章中,我们将专注于在 Windows 平台上进行开发。我们将演示如何直接使用 Visual Studio 15 2017 构建 CMake 项目,而不使用命令行。我们还将讨论如何在 Linux 或 macOS 系统上跨编译 Windows 可执行文件。

使用 Visual Studio 2017 构建 CMake 项目

本配方的代码可在github.com/dev-cafe/cmake-cookbook/tree/v1.0/chapter-13/recipe-01找到,并包含一个 C++示例。该配方适用于 CMake 版本 3.5(及以上),并在 Windows 上进行了测试。

虽然早期的 Visual Studio 版本要求开发者在不同的窗口中编辑源代码和运行 CMake 命令,但 Visual Studio 2017 引入了对 CMake 项目的内置支持(aka.ms/cmake),允许整个编码、配置、构建和测试工作流程在同一个 IDE 中发生。在本节中,我们将测试这一点,并直接使用 Visual Studio 2017 构建一个简单的“hello world”CMake 示例项目,而不求助于命令行。

准备工作

首先,我们将使用 Windows 平台,下载并安装 Visual Studio Community 2017(www.visualstudio.com/downloads/)。在撰写本文时,该版本可免费使用 30 天试用期。我们将遵循的步骤也在此视频中得到了很好的解释:www.youtube.com/watch?v=_lKxJjV8r3Y

在运行安装程序时,请确保在左侧面板中选择“使用 C++的桌面开发”,并验证“Visual C++工具用于 CMake”在右侧的摘要面板中被选中:

在 Visual Studio 2017 15.4 中,您还可以为 Linux 平台编译代码。为此,请在其他工具集中选择“Linux 开发与 C++”:

启用此选项后,您可以从 Visual Studio 内部为 Windows 和 Linux 机器编译代码,前提是您已配置了对 Linux 服务器的访问。但是,我们不会在本章中演示这种方法。

在本节中,我们将在 Windows 上构建 Windows 二进制文件,我们的目标是配置和构建以下示例代码(hello-world.cpp):

#include <cstdlib>
#include <iostream>
#include <string>

const std::string cmake_system_name = SYSTEM_NAME;

int main() {
  std::cout << "Hello from " << cmake_system_name << std::endl;

  return EXIT_SUCCESS;
}

操作方法

要创建相应的源代码,请按照以下步骤操作:

  1. 创建一个目录并将hello-world.cpp文件放入新创建的目录中。

  2. 在此目录中,创建一个CMakeLists.txt文件,其中包含以下内容:

# set minimum cmake version
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)

# project name and language
project(recipe-01 LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 11)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

include(GNUInstallDirs)
set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY
  ${CMAKE_BINARY_DIR}/${CMAKE_INSTALL_LIBDIR})
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY
  ${CMAKE_BINARY_DIR}/${CMAKE_INSTALL_LIBDIR})
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY
  ${CMAKE_BINARY_DIR}/${CMAKE_INSTALL_BINDIR})

# define executable and its source file
add_executable(hello-world hello-world.cpp)

# we will print the system name in the code
target_compile_definitions(hello-world
  PUBLIC
    "SYSTEM_NAME=\"${CMAKE_SYSTEM_NAME}\""
  )

install(
  TARGETS
    hello-world
  DESTINATION
    ${CMAKE_INSTALL_BINDIR}
  )
  1. 打开 Visual Studio 2017,然后导航到包含源文件和CMakeLists.txt的新建文件夹,通过以下方式:文件 | 打开 | 文件夹。

  2. 一旦文件夹打开,请注意 CMake 配置步骤是如何自动运行的(底部面板):

  1. 现在,我们可以右键单击CMakeLists.txt(右侧面板)并选择“构建”:

  1. 这构建了项目(请参见底部面板的输出):

  1. 这样就成功编译了可执行文件。在下一个子节中,我们将学习如何定位可执行文件,并可能更改构建和安装路径。

工作原理

我们已经看到,Visual Studio 2017 很好地与 CMake 接口,并且我们已经能够从 IDE 内部配置和构建代码。除了构建步骤,我们还可以运行安装或测试步骤。这些可以通过右键单击CMakeLists.txt(右侧面板)来访问。

然而,配置步骤是自动运行的,我们可能更倾向于修改配置选项。我们还希望知道实际的构建和安装路径,以便我们可以测试我们的可执行文件。为此,我们可以选择 CMake | 更改 CMake 设置,然后我们到达以下屏幕:

在左上角的面板中,我们现在可以检查和修改生成器(在本例中为 Ninja)、设置、参数以及路径。构建路径在上面的截图中突出显示。设置被分组到构建类型(x86-Debugx86-Release等)中,我们可以在顶部面板栏的中间在这些构建类型之间切换。

现在我们知道实际的构建路径,我们可以测试编译的可执行文件:

$ ./hello-world.exe

Hello from Windows

当然,构建和安装路径可以进行调整。

另请参阅

交叉编译一个“Hello World”示例

本配方的代码可在github.com/dev-cafe/cmake-cookbook/tree/v1.0/chapter-13/recipe-01找到,并包含一个 C++示例。本配方适用于 CMake 版本 3.5(及以上),并在 GNU/Linux 和 macOS 上进行了测试。

在本配方中,我们将重用上一个配方中的“Hello World”示例,并从 Linux 或 macOS 交叉编译到 Windows。换句话说,我们将在 Linux 或 macOS 上配置和编译代码,并获得一个 Windows 平台的可执行文件。

准备工作

我们从一个简单的“Hello World”示例开始(hello-world.cpp):

#include <cstdlib>
#include <iostream>
#include <string>

const std::string cmake_system_name = SYSTEM_NAME;

int main() {
  std::cout << "Hello from " << cmake_system_name << std::endl;

  return EXIT_SUCCESS;
}

我们还将使用上一个配方中未更改的CMakeLists.txt

# set minimum cmake version
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)

# project name and language
project(recipe-01 LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 11)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

include(GNUInstallDirs)
set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY
  ${CMAKE_BINARY_DIR}/${CMAKE_INSTALL_LIBDIR})
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY
  ${CMAKE_BINARY_DIR}/${CMAKE_INSTALL_LIBDIR})
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY
  ${CMAKE_BINARY_DIR}/${CMAKE_INSTALL_BINDIR})

# define executable and its source file
add_executable(hello-world hello-world.cpp)

# we will print the system name in the code
target_compile_definitions(hello-world
  PUBLIC
    "SYSTEM_NAME=\"${CMAKE_SYSTEM_NAME}\""
  )

install(
  TARGETS
    hello-world
  DESTINATION
    ${CMAKE_INSTALL_BINDIR}
  )

为了交叉编译源代码,我们需要安装一个 C++的交叉编译器,以及可选的 C 和 Fortran 编译器。一个选项是使用打包的 MinGW 编译器。作为打包的交叉编译器的替代方案,我们还可以使用 MXE(M 交叉环境)从源代码构建一套交叉编译器:mxe.cc

如何操作

我们将按照以下步骤在这个交叉编译的“Hello World”示例中创建三个文件:

  1. 创建一个目录,其中包含hello-world.cpp和前面列出的CMakeLists.txt

  2. 创建一个toolchain.cmake文件,其中包含以下内容:

# the name of the target operating system
set(CMAKE_SYSTEM_NAME Windows)

# which compilers to use
set(CMAKE_CXX_COMPILER i686-w64-mingw32-g++)

# adjust the default behaviour of the find commands:
# search headers and libraries in the target environment
set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
# search programs in the host environment
set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
  1. CMAKE_CXX_COMPILER调整为相应的编译器(路径)。

  2. 然后,通过指向CMAKE_TOOLCHAIN_FILE到工具链文件来配置代码(在本例中,使用了从源代码构建的 MXE 编译器):

$ mkdir -p build
$ cd build
$ cmake -D CMAKE_TOOLCHAIN_FILE=toolchain.cmake .. 
-- The CXX compiler identification is GNU 5.4.0
-- Check for working CXX compiler: /home/user/mxe/usr/bin/i686-w64-mingw32.static-g++
-- Check for working CXX compiler: /home/user/mxe/usr/bin/i686-w64-mingw32.static-g++ -- works
-- Detecting CXX compiler ABI info
-- Detecting CXX compiler ABI info - done
-- Detecting CXX compile features
-- Detecting CXX compile features - done
-- Configuring done
-- Generating done
-- Build files have been written to: /home/user/cmake-recipes/chapter-13/recipe-01/cxx-example/build
  1. 现在,让我们构建可执行文件:
$ cmake --build .

Scanning dependencies of target hello-world
[ 50%] Building CXX object CMakeFiles/hello-world.dir/hello-world.cpp.obj
[100%] Linking CXX executable bin/hello-world.exe
[100%] Built target hello-world
  1. 请注意,我们在 Linux 上获得了hello-world.exe。将二进制文件复制到 Windows 计算机。

  2. 在 Windows 计算机上,我们可以观察到以下输出:

Hello from Windows
  1. 如您所见,该二进制文件在 Windows 上运行!

它是如何工作的

由于我们在与目标环境(Windows)不同的宿主环境(在这种情况下,GNU/Linux 或 macOS)上配置和构建代码,我们需要向 CMake 提供有关目标环境的信息,我们已经在toolchain.cmake文件中对其进行了编码(cmake.org/cmake/help/latest/manual/cmake-toolchains.7.html#cross-compiling)。

首先,我们提供目标操作系统的名称:

set(CMAKE_SYSTEM_NAME Windows)

然后,我们指定编译器,例如:

set(CMAKE_C_COMPILER i686-w64-mingw32-gcc)
set(CMAKE_CXX_COMPILER i686-w64-mingw32-g++)
set(CMAKE_Fortran_COMPILER i686-w64-mingw32-gfortran)

在这个简单的例子中,我们不需要检测任何库或头文件,但如果需要,我们将使用以下方式指定根路径:

set(CMAKE_FIND_ROOT_PATH /path/to/target/environment)

目标环境可以是例如由 MXE 安装提供的环境。

最后,我们调整 find 命令的默认行为。我们指示 CMake 在目标环境中搜索头文件和库:

set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)

并在宿主环境中搜索程序:

set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)

另请参阅

有关各种选项的更详细讨论,请参阅cmake.org/cmake/help/latest/manual/cmake-toolchains.7.html#cross-compiling

使用 OpenMP 并行化交叉编译 Windows 二进制文件

本食谱的代码可在github.com/dev-cafe/cmake-cookbook/tree/v1.0/chapter-13/recipe-02找到,并包含 C++和 Fortran 示例。本食谱适用于 CMake 版本 3.9(及以上),并在 GNU/Linux 上进行了测试。

在本食谱中,我们将应用在前一个食谱中学到的知识,尽管是针对一个更有趣和更现实的例子:我们将交叉编译一个使用 OpenMP 并行化的 Windows 二进制文件。

准备工作

我们将使用第三章,检测外部库和程序,食谱 5,检测 OpenMP 并行环境中的未修改源代码。示例代码计算所有自然数到N的总和(example.cpp):

#include <iostream>
#include <omp.h>
#include <string>

int main(int argc, char *argv[]) {
  std::cout << "number of available processors: " << omp_get_num_procs()
            << std::endl;
  std::cout << "number of threads: " << omp_get_max_threads() << std::endl;

  auto n = std::stol(argv[1]);
  std::cout << "we will form sum of numbers from 1 to " << n << std::endl;

  // start timer
  auto t0 = omp_get_wtime();

  auto s = 0LL;
#pragma omp parallel for reduction(+ : s)
  for (auto i = 1; i <= n; i++) {
    s += i;
  }

  // stop timer
  auto t1 = omp_get_wtime();

  std::cout << "sum: " << s << std::endl;
  std::cout << "elapsed wall clock time: " << t1 - t0 << " seconds" << std::endl;

  return 0;
}

CMakeLists.txt文件与第三章,检测外部库和程序,食谱 5,检测 OpenMP 并行环境相比,基本上没有变化,除了增加了一个安装目标:

# set minimum cmake version
cmake_minimum_required(VERSION 3.9 FATAL_ERROR)

# project name and language
project(recipe-02 LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 11)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

include(GNUInstallDirs)
set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY
  ${CMAKE_BINARY_DIR}/${CMAKE_INSTALL_LIBDIR})
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY
  ${CMAKE_BINARY_DIR}/${CMAKE_INSTALL_LIBDIR})
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY
  ${CMAKE_BINARY_DIR}/${CMAKE_INSTALL_BINDIR})

find_package(OpenMP REQUIRED)

add_executable(example example.cpp)

target_link_libraries(example
  PUBLIC
    OpenMP::OpenMP_CXX
  )

install(
  TARGETS
    example
  DESTINATION
    ${CMAKE_INSTALL_BINDIR}
  )

如何操作

通过以下步骤,我们将能够交叉编译一个使用 OpenMP 并行化的 Windows 可执行文件:

  1. 创建一个目录,其中包含之前列出的example.cppCMakeLists.txt

  2. 我们将使用与前一个食谱相同的toolchain.cmake

# the name of the target operating system
set(CMAKE_SYSTEM_NAME Windows)

# which compilers to use
set(CMAKE_CXX_COMPILER i686-w64-mingw32-g++)

# adjust the default behaviour of the find commands:
# search headers and libraries in the target environment
set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
# search programs in the host environment
set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
  1. CMAKE_CXX_COMPILER调整为相应的编译器(路径)。

  2. 然后,通过指向CMAKE_TOOLCHAIN_FILE到工具链文件来配置代码(在本例中,使用了从源代码构建的 MXE 编译器):

$ mkdir -p build
$ cd build
$ cmake -D CMAKE_TOOLCHAIN_FILE=toolchain.cmake .. 
-- The CXX compiler identification is GNU 5.4.0
-- Check for working CXX compiler: /home/user/mxe/usr/bin/i686-w64-mingw32.static-g++
-- Check for working CXX compiler: /home/user/mxe/usr/bin/i686-w64-mingw32.static-g++ -- works
-- Detecting CXX compiler ABI info
-- Detecting CXX compiler ABI info - done
-- Detecting CXX compile features
-- Detecting CXX compile features - done
-- Found OpenMP_CXX: -fopenmp (found version "4.0")
-- Found OpenMP: TRUE (found version "4.0")
-- Configuring done
-- Generating done
-- Build files have been written to: /home/user/cmake-recipes/chapter-13/recipe-02/cxx-example/build
  1. 现在,让我们构建可执行文件:
$ cmake --build .

Scanning dependencies of target example
[ 50%] Building CXX object CMakeFiles/example.dir/example.cpp.obj
[100%] Linking CXX executable bin/example.exe
[100%] Built target example
  1. 将二进制文件example.exe复制到 Windows 计算机。

  2. 在 Windows 计算机上,我们可以看到以下示例输出:

$ set OMP_NUM_THREADS=1
$ example.exe 1000000000

number of available processors: 2
number of threads: 1
we will form sum of numbers from 1 to 1000000000
sum: 500000000500000000
elapsed wall clock time: 2.641 seconds

$ set OMP_NUM_THREADS=2
$ example.exe 1000000000

number of available processors: 2
number of threads: 2
we will form sum of numbers from 1 to 1000000000
sum: 500000000500000000
elapsed wall clock time: 1.328 seconds
  1. 正如我们所见,二进制文件在 Windows 上运行,并且我们可以观察到由于 OpenMP 并行化带来的速度提升!

它是如何工作的

我们已成功使用简单的工具链进行交叉编译,在 Windows 平台上构建了用于并行执行的可执行文件。我们能够通过设置OMP_NUM_THREADS来指定 OpenMP 线程的数量。从 1 个线程增加到 2 个线程,我们观察到运行时间从 2.6 秒减少到 1.3 秒。有关工具链文件的讨论,请参阅之前的配方。

还有更多

可以为一组目标平台进行交叉编译,例如 Android。有关示例,我们请读者参考cmake.org/cmake/help/latest/manual/cmake-toolchains.7.html

第十五章:测试仪表板

在本章中,我们将介绍以下内容:

  • 将测试部署到 CDash 仪表板

  • 向 CDash 仪表板报告测试覆盖率

  • 使用 AddressSanitizer 并向 CDash 报告内存缺陷

  • 使用 ThreadSanitizer 并向 CDash 报告数据竞争

引言

CDash 是一个 Web 服务,用于聚合 CTest 在测试运行、夜间测试或在持续集成设置中报告的测试结果。向仪表板报告就是我们所说的CDash 时间,如下图所示:

在本章中,我们将演示如何向 CDash 仪表板报告测试结果。我们将讨论报告测试覆盖率的策略,以及使用 AddressSanitizer 和 ThreadSanitizer 等工具收集的内存缺陷和数据竞争。

向 CDash 报告有两种方式:通过构建的测试目标或使用 CTest 脚本。我们将在前两个食谱中演示测试目标的方法,并在最后两个食谱中使用 CTest 脚本的方法。

设置 CDash 仪表板

CDash 的安装需要一个带有 PHP 和 SSL 启用的 Web 服务器(Apache、NGINX 或 IIS),以及访问 MySQL 或 PostgreSQL 数据库服务器的权限。本书不详细讨论 CDash Web 服务的设置;我们建议读者参考其官方文档,网址为public.kitware.com/Wiki/CDash:Installation

安装 CDash 实例不是本章食谱的必要条件,因为 Kitware 提供了两个公共仪表板(my.cdash.orgopen.cdash.org)。我们将在食谱中引用前者。

对于决定自行安装 CDash 实例的读者,我们建议使用 MySQL 后端,因为这似乎是my.cdash.orgopen.cdash.org所使用的配置,并且社区对其进行了更充分的测试。

也可以使用 Docker 来部署 CDash 实例,而无需太多努力。目前,在 CDash 问题跟踪器上有一个关于官方镜像的请求,网址为github.com/Kitware/CDash/issues/562

将测试部署到 CDash 仪表板

本食谱的代码可在github.com/dev-cafe/cmake-cookbook/tree/v1.0/chapter-14/recipe-01找到,并包含一个 C++示例。该食谱适用于 CMake 版本 3.5(及以上),并在 GNU/Linux、macOS 和 Windows 上进行了测试。

在本食谱中,我们将扩展第 1 个食谱,即“创建一个简单的单元测试”,来自第四章,“创建和运行测试”,并将测试结果部署到my.cdash.org/index.php?project=cmake-cookbook,这是我们在公共仪表板(my.cdash.org)上为本书创建的,由 Kitware 提供给社区。

准备工作

我们将从重用第 1 个食谱,即“创建一个简单的单元测试”,来自第四章,“创建和运行测试”的示例源代码开始,该示例对作为命令行参数给出的整数求和。示例由三个源文件组成:main.cppsum_integers.cppsum_integers.hpp。这些源文件保持不变。我们还将重用来自第四章,“创建和运行测试”的文件test.cpp,但将其重命名为test_short.cpp。我们将通过test_long.cpp扩展示例,其中包含以下代码:

#include "sum_integers.hpp"

#include <numeric>
#include <vector>

int main() {
  // creates vector {1, 2, 3, ..., 999, 1000}
  std::vector<int> integers(1000);
  std::iota(integers.begin(), integers.end(), 1);

  if (sum_integers(integers) == 500500) {
    return 0;
  } else {
    return 1;
  }
}

然后,我们将这些文件组织成以下文件树:

.
├── CMakeLists.txt
├── CTestConfig.cmake
├── src
│   ├── CMakeLists.txt
│   ├── main.cpp
│   ├── sum_integers.cpp
│   └── sum_integers.hpp
└── tests
    ├── CMakeLists.txt
    ├── test_long.cpp
    └── test_short.cpp

如何做到这一点

现在,我们将描述如何配置、构建、测试,最后,将我们示例项目的测试结果提交到仪表板:

  1. 源目标在src/CMakeLists.txt中定义,如下所示:
# example library
add_library(sum_integers "")

target_sources(sum_integers
  PRIVATE
    sum_integers.cpp
  PUBLIC
    ${CMAKE_CURRENT_LIST_DIR}/sum_integers.hpp
  )

target_include_directories(sum_integers
  PUBLIC
    ${CMAKE_CURRENT_LIST_DIR}
  )

# main code
add_executable(sum_up main.cpp)

target_link_libraries(sum_up sum_integers)
  1. 测试在tests/CMakeLists.txt中定义:
add_executable(test_short test_short.cpp)
target_link_libraries(test_short sum_integers)

add_executable(test_long test_long.cpp)
target_link_libraries(test_long sum_integers)

add_test(
  NAME
    test_short
  COMMAND
    $<TARGET_FILE:test_short>
  )

add_test(
  NAME
    test_long
  COMMAND
    $<TARGET_FILE:test_long>
  )
  1. 顶级CMakeLists.txt文件引用了前面两个文件,本食谱中的新元素是包含include(CTest)的行,它允许我们向 CDash 仪表板报告:
# set minimum cmake version
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)

# project name and language
project(recipe-01 LANGUAGES CXX)

# require C++11
set(CMAKE_CXX_STANDARD 11)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

# process src/CMakeLists.txt
add_subdirectory(src)

enable_testing()

# allow to report to a cdash dashboard
include(CTest)

# process tests/CMakeLists.txt
add_subdirectory(tests)
  1. 此外,我们在顶级CMakeLists.txt文件所在的同一目录中创建了文件CTestConfig.cmake。这个新文件包含以下行:
set(CTEST_DROP_METHOD "http")
set(CTEST_DROP_SITE "my.cdash.org")
set(CTEST_DROP_LOCATION "/submit.php?project=cmake-cookbook")
set(CTEST_DROP_SITE_CDASH TRUE)
  1. 我们现在准备配置并构建项目,如下所示:
$ mkdir -p build
$ cd build
$ cmake ..
$ cmake --build .
  1. 在构建代码之后,我们可以运行测试集并将测试结果报告给仪表板:
$ ctest --dashboard Experimental

 Site: larry
 Build name: Linux-c++
Create new tag: 20180408-1449 - Experimental
Configure project
 Each . represents 1024 bytes of output
 . Size of output: 0K
Build project
 Each symbol represents 1024 bytes of output.
 '!' represents an error and '*' a warning.
 . Size of output: 0K
 0 Compiler errors
 0 Compiler warnings
Test project /home/user/cmake-recipes/chapter-15/recipe-01/cxx-example/build
 Start 1: test_short
1/2 Test #1: test_short ....................... Passed 0.00 sec
 Start 2: test_long
2/2 Test #2: test_long ........................ Passed 0.00 sec

100% tests passed, 0 tests failed out of 2

Total Test time (real) = 0.01 sec
Performing coverage
 Cannot find any coverage files. Ignoring Coverage request.
Submit files (using http)
 Using HTTP submit method
 Drop site:http://my.cdash.org/submit.php?project=cmake-cookbook
 Uploaded: /home/user/cmake-recipes/chapter-14/recipe-01/cxx-example/build/Testing/20180408-1449/Build.xml
 Uploaded: /home/user/cmake-recipes/chapter-14/recipe-01/cxx-example/build/Testing/20180408-1449/Configure.xml
 Uploaded: /home/user/cmake-recipes/chapter-14/recipe-01/cxx-example/build/Testing/20180408-1449/Test.xml
 Submission successful
  1. 最后,我们可以在浏览器中浏览测试结果(在本例中,测试结果被报告给my.cdash.org/index.php?project=cmake-cookbook):)

它是如何工作的

工作流程的高层次概览显示,CTest 运行测试并将结果记录在本地 XML 文件中。这些 XML 文件随后被发送到 CDash 服务器,在那里可以进行浏览和分析。通过点击前面截图中显示的“通过”下的 2,我们可以获得关于通过或失败的测试的更多细节(在本例中,没有失败的测试)。如后续截图所示,详细记录了运行测试的机器信息以及时间信息。同样,个别测试的输出可以在网上浏览。

CTest 支持三种不同的运行提交模式:实验性、夜间和连续性。我们使用了ctest --dashboard Experimental,因此测试结果出现在实验性下。实验模式适用于测试代码的当前状态,用于调试新的仪表板脚本(参见本章的第 3 和第 4 个食谱),或用于调试 CDash 服务器或项目。夜间模式将更新(或降级)代码到最接近最新夜间开始时间的仓库快照,这可以在CTestConfig.cmake中设置;它为接收频繁更新的项目中的所有夜间测试提供了一个定义良好的参考点。例如,可以将夜间开始时间设置为协调世界时午夜,如下所示:

set(CTEST_NIGHTLY_START_TIME "00:00:00 UTC")

连续模式适用于持续集成工作流程,并将更新代码到最新版本。

使用单个命令即可完成构建、测试并提交到实验仪表板 - 即cmake --build . --target Experimental命令。

还有更多

在本食谱中,我们直接从测试目标部署到 CDash。也可以使用专门的 CTest 脚本,我们将在本章稍后的第 3 和第 4 个食谱中演示这种方法。

CDash 不仅允许您监控测试是否通过或失败,还允许您监控测试时间。您可以为测试时间配置边际:如果测试花费的时间超过分配的时间,它将被标记为失败。这对于基准测试很有用,可以自动检测在重构代码时测试时间性能下降的情况。

另请参见

有关 CDash 定义和配置设置的详细讨论,请参阅官方 CDash 文档,网址为public.kitware.com/Wiki/CDash:Documentation

向 CDash 仪表板报告测试覆盖率

本食谱的代码可在github.com/dev-cafe/cmake-cookbook/tree/v1.0/chapter-14/recipe-02获取,并包含一个 C++示例。该食谱适用于 CMake 版本 3.5(及以上),并在 GNU/Linux、macOS 和 Windows 上进行了测试。

在本食谱中,我们将测量测试覆盖率并将其报告给 CDash 仪表板,以便我们能够逐行浏览测试覆盖率分析,以识别未测试或未使用的代码。

准备就绪

我们将在前一个食谱的源代码中添加一个微小的变化,在src/sum_integers.cpp中,我们将添加一个函数 - sum_integers_unused

#include "sum_integers.hpp"

#include <vector>

int sum_integers(const std::vector<int> integers) {
  auto sum = 0;
  for (auto i : integers) {
    sum += i;
  }
  return sum;
}

int sum_integers_unused(const std::vector<int> integers) {
  auto sum = 0;
  for (auto i : integers) {
    sum += i;
  }
  return sum;
}

我们的目标是使用测试覆盖率分析来检测这段未使用的代码,方法是使用 gcov(gcc.gnu.org/onlinedocs/gcc/Gcov.html)。除了上述修改外,我们将使用前一个食谱的未修改源代码。

如何操作

通过以下步骤,我们将启用覆盖率分析并将结果上传到仪表板:

  1. 顶级CMakeLists.txttests/CMakeLists.txt文件与之前的配方保持不变。

  2. 我们将在src/CMakeLists.txt中扩展,添加一个选项以添加代码覆盖率的编译标志。此选项默认启用,如下所示:

option(ENABLE_COVERAGE "Enable coverage" ON)

if(ENABLE_COVERAGE)
  if(CMAKE_CXX_COMPILER_ID MATCHES GNU)
    message(STATUS "Coverage analysis with gcov enabled") 
    target_compile_options(sum_integers
      PUBLIC
        -fprofile-arcs -ftest-coverage -g
      )
    target_link_libraries(sum_integers
      PUBLIC
        gcov
      )
  else()
    message(WARNING "Coverage not supported for this compiler")
  endif()
endif()
  1. 然后,我们将配置、构建并部署到 CDash:
$ mkdir -p build
$ cd build
$ cmake ..
$ cmake --build . --target Experimental
  1. 这将产生与之前配方类似的输出,但最后一步将执行测试覆盖率分析:
Performing coverage
   Processing coverage (each . represents one file):
    ...
   Accumulating results (each . represents one file):
    ...
        Covered LOC: 14
        Not covered LOC: 7
        Total LOC: 21
        Percentage Coverage: 66.67%
Submit files (using http)
   Using HTTP submit method
   Drop site:http://my.cdash.org/submit.php?project=cmake-cookbook
   Uploaded: /home/user/cmake-recipes/chapter-14/recipe-02/cxx-example/build/Testing/20180408-1530/Build.xml
   Uploaded: /home/user/cmake-recipes/chapter-14/recipe-02/cxx-example/build/Testing/20180408-1530/Configure.xml
   Uploaded: /home/user/cmake-recipes/chapter-14/recipe-02/cxx-example/build/Testing/20180408-1530/Coverage.xml
   Uploaded: /home/user/cmake-recipes/chapter-14/recipe-02/cxx-example/build/Testing/20180408-1530/CoverageLog-0.xml
   Uploaded: /home/user/cmake-recipes/chapter-14/recipe-02/cxx-example/build/Testing/20180408-1530/Test.xml
   Submission successful
  1. 最后,我们可以在浏览器中验证测试结果(在本例中,测试结果报告给my.cdash.org/index.php?project=cmake-cookbook)。

工作原理

测试覆盖率分析以 66.67%的百分比进行总结。为了获得更深入的见解,我们可以点击该百分比,并获得两个子目录的覆盖率分析,如下所示:

通过浏览子目录链接,我们可以检查单个文件的测试覆盖率百分比,甚至可以浏览逐行的总结(例如,src/sum_integers.cpp):

绿色线条在运行测试套件时已被遍历,而红色线条则没有。通过这一点,我们不仅可以识别未使用/未测试的代码(使用sum_integers_unused函数),还可以看到每行代码被遍历的频率。例如,代码行sum += i已被访问 1,005 次(test_short期间 5 次,test_long期间 1,000 次)。测试覆盖率分析是自动化测试不可或缺的伴侣,CDash 为我们提供了一个在浏览器中浏览和图形化分析结果的界面。

另请参阅

如需进一步阅读,我们推荐以下博客文章,该文章讨论了 CDash 中的额外覆盖功能:blog.kitware.com/additional-coverage-features-in-cdash/

使用 AddressSanitizer 并将内存缺陷报告给 CDash

本配方的代码可在github.com/dev-cafe/cmake-cookbook/tree/v1.0/chapter-14/recipe-03找到,包括一个 C++和一个 Fortran 示例。本配方适用于 CMake 版本 3.5(及更高版本),并在 GNU/Linux 和 macOS 上进行了测试。

AddressSanitizer(ASan)是 C++、C 和 Fortran 的内存错误检测器。它可以发现内存缺陷,如使用后释放、使用后返回、使用后作用域、缓冲区溢出、初始化顺序错误和内存泄漏(参见github.com/google/sanitizers/wiki/AddressSanitizer)。AddressSanitizer 是 LLVM 的一部分,从版本 3.1 开始,也是 GCC 的一部分,从版本 4.8 开始。在本菜谱中,我们将在我们的代码中制造两个可能未在正常测试运行中检测到的错误。为了检测这些错误,我们将 CTest 与使用 AddressSanitizer 的动态分析相结合,并将缺陷报告给 CDash。

准备工作

在本例中,我们将使用两个源文件和两个测试,如下所示:

.
├── CMakeLists.txt
├── CTestConfig.cmake
├── dashboard.cmake
├── src
│   ├── buggy.cpp
│   ├── buggy.hpp
│   └── CMakeLists.txt
└── tests
    ├── CMakeLists.txt
    ├── leaky.cpp
    └── use_after_free.cpp

文件buggy.cpp包含两个有问题的函数,如下所示:

#include "buggy.hpp"

#include <iostream>

int function_leaky() {

  double *my_array = new double[1000];

  // do some work ...

  // we forget to deallocate the array
  // delete[] my_array;

  return 0;
}

int function_use_after_free() {

  double *another_array = new double[1000];

  // do some work ...

  // deallocate it, good!
  delete[] another_array;

  // however, we accidentally use the array
  // after it has been deallocated
  std::cout << "not sure what we get: " << another_array[123] << std::endl;

  return 0;
}

这些函数在相应的头文件(buggy.hpp)中公开:

#pragma once

int function_leaky();
int function_use_after_free();

测试源码leaky.cpp验证function_leaky的返回码:

#include "buggy.hpp"

int main() {
  int return_code = function_leaky();
  return return_code;
}

相应地,use_after_free.cpp检查function_use_after_free的返回值,如下所示:

#include "buggy.hpp"

int main() {
  int return_code = function_use_after_free();
  return return_code;
}

如何操作

我们需要使用特定的标志编译我们的代码以利用 ASan。然后,我们将运行测试并将它们提交到仪表板。让我们看看如何做到这一点:

  1. 有问题的库在src/CMakeLists.txt中定义:
add_library(buggy "")

target_sources(buggy
  PRIVATE
    buggy.cpp
  PUBLIC
    ${CMAKE_CURRENT_LIST_DIR}/buggy.hpp
  )

target_include_directories(buggy
  PUBLIC
    ${CMAKE_CURRENT_LIST_DIR}
  )
  1. 对于文件src/CMakeLists.txt,我们将添加一个选项和代码以使用 ASan 进行消毒:
option(ENABLE_ASAN "Enable AddressSanitizer" OFF)

if(ENABLE_ASAN)
  if(CMAKE_CXX_COMPILER_ID MATCHES GNU)
    message(STATUS "AddressSanitizer enabled")
    target_compile_options(buggy
      PUBLIC
        -g -O1 -fsanitize=address -fno-omit-frame-pointer
      )
    target_link_libraries(buggy
      PUBLIC
        asan
      )
  else()
    message(WARNING "AddressSanitizer not supported for this compiler")
  endif()
endif()
  1. 两个测试在tests/CMakeLists.txt中紧凑地定义,使用foreach循环:
foreach(_test IN ITEMS leaky use_after_free)
  add_executable(${_test} ${_test}.cpp)
  target_link_libraries(${_test} buggy)

  add_test(
    NAME
      ${_test}
    COMMAND
      $<TARGET_FILE:${_test}>
    )
endforeach()
  1. 顶级CMakeLists.txt基本上与之前的菜谱保持不变:
# set minimum cmake version
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)

# project name and language
project(recipe-03 LANGUAGES CXX)

# require C++11
set(CMAKE_CXX_STANDARD 11)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

# process src/CMakeLists.txt
add_subdirectory(src)

enable_testing()

# allow to report to a cdash dashboard
include(CTest)

# process tests/CMakeLists.txt
add_subdirectory(tests)
  1. 同样,CTestConfig.cmake文件保持不变:
set(CTEST_DROP_METHOD "http")
set(CTEST_DROP_SITE "my.cdash.org")
set(CTEST_DROP_LOCATION "/submit.php?project=cmake-cookbook")
set(CTEST_DROP_SITE_CDASH TRUE)
  1. 在本菜谱中,我们将使用 CTest 脚本向 CDash 报告;为此,我们将创建一个文件,dashboard.cmake(与主CMakeLists.txtCTestConfig.cmake在同一目录中),包含以下内容:
set(CTEST_PROJECT_NAME "example")
cmake_host_system_information(RESULT _site QUERY HOSTNAME)
set(CTEST_SITE ${_site})
set(CTEST_BUILD_NAME "${CMAKE_SYSTEM_NAME}-${CMAKE_HOST_SYSTEM_PROCESSOR}")

set(CTEST_SOURCE_DIRECTORY "${CTEST_SCRIPT_DIRECTORY}")
set(CTEST_BINARY_DIRECTORY "${CTEST_SCRIPT_DIRECTORY}/build")

include(ProcessorCount)
ProcessorCount(N)
if(NOT N EQUAL 0)
  set(CTEST_BUILD_FLAGS -j${N})
  set(ctest_test_args ${ctest_test_args} PARALLEL_LEVEL ${N})
endif()

ctest_start(Experimental)

ctest_configure(
  OPTIONS
    -DENABLE_ASAN:BOOL=ON
  )

ctest_build()
ctest_test()

set(CTEST_MEMORYCHECK_TYPE "AddressSanitizer")
ctest_memcheck()

ctest_submit()
  1. 我们将直接执行dashboard.cmake脚本。请注意我们如何使用CTEST_CMAKE_GENERATOR选项传递要使用的生成器,如下所示:
$ ctest -S dashboard.cmake -D CTEST_CMAKE_GENERATOR="Unix Makefiles"

   Each . represents 1024 bytes of output
    . Size of output: 0K
   Each symbol represents 1024 bytes of output.
   '!' represents an error and '*' a warning.
    . Size of output: 1K
  1. 结果将出现在 CDash 站点上,如下面的截图所示:

工作原理

在本菜谱中,我们成功地将内存错误报告到了仪表板的动态分析部分。我们可以通过浏览缺陷(在缺陷计数下)获得更深入的见解:

通过点击各个链接,可以浏览完整输出。

请注意,也可以在本地生成 AddressSanitizer 报告。在本例中,我们需要设置ENABLE_ASAN,如下所示:

$ mkdir -p build
$ cd build
$ cmake -DENABLE_ASAN=ON ..
$ cmake --build .
$ cmake --build . --target test

    Start 1: leaky
1/2 Test #1: leaky ............................***Failed 0.07 sec
    Start 2: use_after_free
2/2 Test #2: use_after_free ...................***Failed 0.04 sec

0% tests passed, 2 tests failed out of 2

直接运行leaky测试可执行文件产生以下结果:

$ ./build/tests/leaky

=================================================================
==18536==ERROR: LeakSanitizer: detected memory leaks

Direct leak of 8000 byte(s) in 1 object(s) allocated from:
    #0 0x7ff984da1669 in operator new[](unsigned long) /build/gcc/src/gcc/libsanitizer/asan/asan_new_delete.cc:82
    #1 0x564925c93fd2 in function_leaky() /home/user/cmake-recipes/chapter-14/recipe-03/cxx-example/src/buggy.cpp:7
    #2 0x564925c93fb2 in main /home/user/cmake-recipes/chapter-14/recipe-03/cxx-example/tests/leaky.cpp:4
    #3 0x7ff98403df49 in __libc_start_main (/usr/lib/libc.so.6+0x20f49)

SUMMARY: AddressSanitizer: 8000 byte(s) leaked in 1 allocation(s).

相应地,我们可以通过直接运行use_after_free可执行文件来获得详细的输出,如下所示:

$ ./build/tests/use_after_free

=================================================================
==18571==ERROR: AddressSanitizer: heap-use-after-free on address 0x6250000004d8 at pc 0x557ffa8b0102 bp 0x7ffe8c560200 sp 0x7ffe8c5601f0
READ of size 8 at 0x6250000004d8 thread T0
 #0 0x557ffa8b0101 in function_use_after_free() /home/user/cmake-recipes/chapter-14/recipe-03/cxx-example/src/buggy.cpp:28
 #1 0x557ffa8affb2 in main /home/user/cmake-recipes/chapter-14/recipe-03/cxx-example/tests/use_after_free.cpp:4
 #2 0x7ff1d6088f49 in __libc_start_main (/usr/lib/libc.so.6+0x20f49)
 #3 0x557ffa8afec9 in _start (/home/user/cmake-recipes/chapter-14/recipe-03/cxx-example/build/tests/use_after_free+0xec9)

0x6250000004d8 is located 984 bytes inside of 8000-byte region 0x625000000100,0x625000002040)
freed by thread T0 here:
 #0 0x7ff1d6ded5a9 in operator delete[ /build/gcc/src/gcc/libsanitizer/asan/asan_new_delete.cc:128
 #1 0x557ffa8afffa in function_use_after_free() /home/user/cmake-recipes/chapter-14/recipe-03/cxx-example/src/buggy.cpp:24
 #2 0x557ffa8affb2 in main /home/user/cmake-recipes/chapter-14/recipe-03/cxx-example/tests/use_after_free.cpp:4
 #3 0x7ff1d6088f49 in __libc_start_main (/usr/lib/libc.so.6+0x20f49)

previously allocated by thread T0 here:
 #0 0x7ff1d6dec669 in operator new[](unsigned long) /build/gcc/src/gcc/libsanitizer/asan/asan_new_delete.cc:82
 #1 0x557ffa8affea in function_use_after_free() /home/user/cmake-recipes/chapter-14/recipe-03/cxx-example/src/buggy.cpp:19
 #2 0x557ffa8affb2 in main /home/user/cmake-recipes/chapter-14/recipe-03/cxx-example/tests/use_after_free.cpp:4
 #3 0x7ff1d6088f49 in __libc_start_main (/usr/lib/libc.so.6+0x20f49)

SUMMARY: AddressSanitizer: heap-use-after-free /home/user/cmake-recipes/chapter-14/recipe-03/cxx-example/src/buggy.cpp:28 in function_use_after_free()
Shadow bytes around the buggy address:
 0x0c4a7fff8040: fd fd fd fd fd fd fd fd fd fd fd fd fd fd fd fd
 0x0c4a7fff8050: fd fd fd fd fd fd fd fd fd fd fd fd fd fd fd fd
 0x0c4a7fff8060: fd fd fd fd fd fd fd fd fd fd fd fd fd fd fd fd
 0x0c4a7fff8070: fd fd fd fd fd fd fd fd fd fd fd fd fd fd fd fd
 0x0c4a7fff8080: fd fd fd fd fd fd fd fd fd fd fd fd fd fd fd fd
=>0x0c4a7fff8090: fd fd fd fd fd fd fd fd fd fd fd[fd]fd fd fd fd
 0x0c4a7fff80a0: fd fd fd fd fd fd fd fd fd fd fd fd fd fd fd fd
 0x0c4a7fff80b0: fd fd fd fd fd fd fd fd fd fd fd fd fd fd fd fd
 0x0c4a7fff80c0: fd fd fd fd fd fd fd fd fd fd fd fd fd fd fd fd
 0x0c4a7fff80d0: fd fd fd fd fd fd fd fd fd fd fd fd fd fd fd fd
 0x0c4a7fff80e0: fd fd fd fd fd fd fd fd fd fd fd fd fd fd fd fd
Shadow byte legend (one shadow byte represents 8 application bytes):
 Addressable: 00
 Partially addressable: 01 02 03 04 05 06 07
 Heap left redzone: fa
 Freed heap region: fd
 Stack left redzone: f1
 Stack mid redzone: f2
 Stack right redzone: f3
 Stack after return: f5
 Stack use after scope: f8
 Global redzone: f9
 Global init order: f6
 Poisoned by user: f7
 Container overflow: fc
 Array cookie: ac
 Intra object redzone: bb
 ASan internal: fe
 Left alloca redzone: ca
 Right alloca redzone: cb
==18571==ABORTING

如果我们不使用 AddressSanitizer 进行测试(默认情况下ENABLE_ASANOFF),则以下示例不会报告任何错误:

$ mkdir -p build_no_asan
$ cd build_no_asan
$ cmake ..
$ cmake --build .
$ cmake --build . --target test

    Start 1: leaky
1/2 Test #1: leaky ............................ Passed 0.00 sec
    Start 2: use_after_free
2/2 Test #2: use_after_free ................... Passed 0.00 sec

100% tests passed, 0 tests failed out of 2

确实,leaky只会浪费内存,而use_after_free可能导致非确定性失败。调试这些失败的一种方法是使用 valgrind(valgrind.org)。

与前两个方案不同,我们使用了一个 CTest 脚本来配置、构建和测试代码,并将报告提交到仪表板。要了解这个方案的工作原理,请仔细查看dashboard.cmake脚本。首先,我们定义项目名称并设置主机报告和构建名称,如下所示:

set(CTEST_PROJECT_NAME "example")
cmake_host_system_information(RESULT _site QUERY HOSTNAME)
set(CTEST_SITE ${_site})
set(CTEST_BUILD_NAME "${CMAKE_SYSTEM_NAME}-${CMAKE_HOST_SYSTEM_PROCESSOR}")

在我们的例子中,CTEST_BUILD_NAME评估为Linux-x86_64。在您的例子中,您可能会观察到不同的结果,这取决于您的操作系统。

接下来,我们为源代码和构建目录指定路径:

set(CTEST_SOURCE_DIRECTORY "${CTEST_SCRIPT_DIRECTORY}")
set(CTEST_BINARY_DIRECTORY "${CTEST_SCRIPT_DIRECTORY}/build")

我们可以将生成器设置为Unix Makefiles

set(CTEST_CMAKE_GENERATOR "Unix Makefiles")

然而,为了编写更便携的测试脚本,我们更倾向于通过命令行提供生成器,如下所示:

$ ctest -S dashboard.cmake -D CTEST_CMAKE_GENERATOR="Unix Makefiles"

dashboard.cmake中的下一个代码片段计算出机器上可用的核心数,并将测试步骤的并行级别设置为可用核心数,以最小化总测试时间:

include(ProcessorCount)
ProcessorCount(N)
if(NOT N EQUAL 0)
  set(CTEST_BUILD_FLAGS -j${N})
  set(ctest_test_args ${ctest_test_args} PARALLEL_LEVEL ${N})
endif()

接下来,我们开始测试步骤并配置代码,设置ENABLE_ASANON

ctest_start(Experimental)

ctest_configure(
  OPTIONS
    -DENABLE_ASAN:BOOL=ON
  )

剩余的dashboard.cmake中的命令对应于构建、测试、内存检查和提交步骤:

ctest_build()
ctest_test()

set(CTEST_MEMORYCHECK_TYPE "AddressSanitizer")
ctest_memcheck()

ctest_submit()

还有更多

细心的读者会注意到,我们在链接目标之前并没有在我们的系统上搜索 AddressSanitizer。在现实世界的完整用例中,这样做是为了避免在链接阶段出现不愉快的意外。我们将提醒读者,我们在第 7 个方案中展示了一种方法来探测 sanitizers 的可用性,即“探测编译器标志”,在第五章“配置时间和构建时间操作”中。

更多关于 AddressSanitizer 的文档和示例,请参见github.com/google/sanitizers/wiki/AddressSanitizer。AddressSanitizer 不仅限于 C 和 C++。对于 Fortran 示例,我们建议读者参考位于github.com/dev-cafe/cmake-cookbook/tree/v1.0/chapter-14/recipe-03/fortran-example的代码仓库。

github.com/arsenm/sanitizers-cmake上可以找到用于发现 sanitizers 并调整编译器标志的 CMake 工具。

另请参阅

以下博客文章讨论了如何添加对动态分析工具的支持的示例,并启发了当前的方案:blog.kitware.com/ctest-cdash-add-support-for-new-dynamic-analysis-tools/

使用 ThreadSanitizer 并将数据竞争报告给 CDash

本食谱的代码可在github.com/dev-cafe/cmake-cookbook/tree/v1.0/chapter-14/recipe-04找到,并包含一个 C++示例。该食谱适用于 CMake 版本 3.5(及以上),并在 GNU/Linux 和 macOS 上进行了测试。

在本食谱中,我们将重用前一个示例的方法,但结合使用 ThreadSanitizer(或 TSan)与 CTest 和 CDash,以识别数据竞争并将这些信息报告给 CDash 仪表板。ThreadSanitizer 的文档可以在网上找到,网址为github.com/google/sanitizers/wiki/ThreadSanitizerCppManual

准备就绪

在本食谱中,我们将使用以下示例代码(example.cpp):

#include <chrono>
#include <iostream>
#include <thread>

static const int num_threads = 16;

void increase(int i, int &s) {
  std::this_thread::sleep_for(std::chrono::seconds(1));
  std::cout << "thread " << i << " increases " << s++ << std::endl;
}

int main() {
  std::thread t[num_threads];

  int s = 0;

  // start threads
  for (auto i = 0; i < num_threads; i++) {
    t[i] = std::thread(increase, i, std::ref(s));
  }

  // join threads with main thread
  for (auto i = 0; i < num_threads; i++) {
    t[i].join();
  }

  std::cout << "final s: " << s << std::endl;

  return 0;
}

在这个示例代码中,我们启动了 16 个线程,每个线程都调用了increase函数。increase函数休眠一秒钟,然后打印并递增一个整数s。我们预计这段代码会表现出数据竞争,因为所有线程都在没有明确同步或协调的情况下读取和修改同一地址。换句话说,我们预计最终的s,即代码末尾打印的s,可能会在每次运行中有所不同。这段代码存在缺陷,我们将尝试借助 ThreadSanitizer 来识别数据竞争。如果不运行 ThreadSanitizer,我们可能不会发现代码中的任何问题:

$ ./example

thread thread 0 increases 01 increases 1
thread 9 increases 2
thread 4 increases 3
thread 10 increases 4
thread 2 increases 5
thread 3 increases 6
thread 13 increases 7
thread thread 7 increases 8
thread 14 increases 9
thread 8 increases 10
thread 12 increases 11
thread 15 increases 12
thread 11 increases 13
5 increases 14
thread 6 increases 15

final s: 16

如何操作

让我们详细地逐一介绍必要的步骤:

  1. CMakeLists.txt文件首先定义了最低支持版本、项目名称、支持的语言,以及在这种情况下,对 C++11 标准的要求:
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)

project(recipe-04 LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 11)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
  1. 接下来,我们定位 Threads 库,定义可执行文件,并将其与 Threads 库链接:
find_package(Threads REQUIRED)

add_executable(example example.cpp)

target_link_libraries(example
  PUBLIC
    Threads::Threads
  )
  1. 然后,我们提供选项和代码以支持 ThreadSanitizer 的编译和链接:
option(ENABLE_TSAN "Enable ThreadSanitizer" OFF)

if(ENABLE_TSAN)
  if(CMAKE_CXX_COMPILER_ID MATCHES GNU)
    message(STATUS "ThreadSanitizer enabled")
    target_compile_options(example
      PUBLIC
        -g -O1 -fsanitize=thread -fno-omit-frame-pointer -fPIC
      )
    target_link_libraries(example
      PUBLIC
        tsan
      )
  else()
    message(WARNING "ThreadSanitizer not supported for this compiler")
  endif()
endif()
  1. 最后,作为测试,我们执行编译后的示例本身:
enable_testing()

# allow to report to a cdash dashboard
include(CTest)

add_test(
  NAME
    example
  COMMAND
    $<TARGET_FILE:example>
  )
  1. CTestConfig.cmake文件与前一个食谱相比没有变化:
set(CTEST_DROP_METHOD "http")
set(CTEST_DROP_SITE "my.cdash.org")
set(CTEST_DROP_LOCATION "/submit.php?project=cmake-cookbook")
set(CTEST_DROP_SITE_CDASH TRUE)
  1. 相应的dashboard.cmake脚本是对前一个食谱的简单改编,以适应 TSan:
set(CTEST_PROJECT_NAME "example")
cmake_host_system_information(RESULT _site QUERY HOSTNAME)
set(CTEST_SITE ${_site})
set(CTEST_BUILD_NAME "${CMAKE_SYSTEM_NAME}-${CMAKE_HOST_SYSTEM_PROCESSOR}")

set(CTEST_SOURCE_DIRECTORY "${CTEST_SCRIPT_DIRECTORY}")
set(CTEST_BINARY_DIRECTORY "${CTEST_SCRIPT_DIRECTORY}/build")

include(ProcessorCount)
ProcessorCount(N)
if(NOT N EQUAL 0)
  set(CTEST_BUILD_FLAGS -j${N})
  set(ctest_test_args ${ctest_test_args} PARALLEL_LEVEL ${N})
endif()

ctest_start(Experimental)

ctest_configure(
  OPTIONS
    -DENABLE_TSAN:BOOL=ON
  )

ctest_build()
ctest_test()

set(CTEST_MEMORYCHECK_TYPE "ThreadSanitizer")
ctest_memcheck()

ctest_submit()
  1. 让我们再次为这个示例设置生成器,通过传递CTEST_CMAKE_GENERATOR选项:
$ ctest -S dashboard.cmake -D CTEST_CMAKE_GENERATOR="Unix Makefiles"

   Each . represents 1024 bytes of output
    . Size of output: 0K
   Each symbol represents 1024 bytes of output.
   '!' represents an error and '*' a warning.
    . Size of output: 0K
  1. 在仪表板上,我们将看到以下内容:

  1. 我们可以更详细地看到动态分析如下:

它是如何工作的

本食谱的核心成分位于以下部分的CMakeLists.txt中:

option(ENABLE_TSAN "Enable ThreadSanitizer" OFF)

if(ENABLE_TSAN)
  if(CMAKE_CXX_COMPILER_ID MATCHES GNU)
    message(STATUS "ThreadSanitizer enabled")
    target_compile_options(example
      PUBLIC
        -g -O1 -fsanitize=thread -fno-omit-frame-pointer -fPIC
      )
    target_link_libraries(example
      PUBLIC
        tsan
      )
  else()
    message(WARNING "ThreadSanitizer not supported for this compiler")
  endif()
endif()

成分也包含在dashboard.cmake中更新的步骤中:

# ...

ctest_start(Experimental)

ctest_configure(
  OPTIONS
    -DENABLE_TSAN:BOOL=ON
  )

ctest_build()
ctest_test()

set(CTEST_MEMORYCHECK_TYPE "ThreadSanitizer")
ctest_memcheck()

ctest_submit()

与前一个食谱一样,我们也可以在本地检查 ThreadSanitizer 的输出:

$ mkdir -p build
$ cd build
$ cmake -DENABLE_TSAN=ON ..
$ cmake --build .
$ cmake --build . --target test

 Start 1: example
1/1 Test #1: example ..........................***Failed 1.07 sec

0% tests passed, 1 tests failed out of 1

$ ./build/example 

thread 0 increases 0
==================
WARNING: ThreadSanitizer: data race (pid=24563)

... lots of output ...

SUMMARY: ThreadSanitizer: data race /home/user/cmake-recipes/chapter-14/recipe-04/cxx-example/example.cpp:9 in increase(int, int&)

还有更多内容

对 OpenMP 代码应用 TSan 是一个自然的步骤,但请注意,在某些情况下,OpenMP 在 TSan 下会产生误报。对于 Clang 编译器,一个解决办法是重新编译编译器本身及其libomp,并使用-DLIBOMP_TSAN_SUPPORT=TRUE。通常,合理地使用检测器可能需要重新编译整个工具栈,以避免误报。对于使用 pybind11 的 C++项目,我们可能需要重新编译启用了检测器的 Python,以获得有意义的结果。或者,可以通过使用检测器抑制来将 Python 绑定排除在检测之外,如github.com/google/sanitizers/wiki/ThreadSanitizerSuppressions所述。如果例如一个共享库被一个启用了检测的二进制文件和一个 Python 插件同时调用,这可能是不可能的。

另请参阅

以下博客文章讨论了如何为动态分析工具添加支持的示例,并激发了当前的方案:blog.kitware.com/ctest-cdash-add-support-for-new-dynamic-analysis-tools/

第十六章:将项目移植到 CMake

在本书的最后一章中,我们将结合前面章节中讨论的多个不同的构建块,并将其应用于一个实际项目。我们的目标将是逐步展示如何将一个非平凡的项目移植到 CMake,并讨论这样的过程中的步骤。我们将为移植您自己的项目或为遗留代码添加 CMake 支持提供建议,无论是来自 Autotools,来自手工编写的配置脚本和 Makefile,还是来自 Visual Studio 项目文件。

为了有一个具体和现实的示例,我们将使用流行的编辑器 Vim(www.vim.org)背后的源代码,并尝试将配置和编译从 Autotools 移植到 CMake。

为了保持讨论和示例的相对简单性,我们将不尝试为整个 Vim 代码提供完整的 CMake 移植,包括所有选项。相反,我们将挑选并讨论最重要的方面,并且只构建一个核心版本的 Vim,不支持图形用户界面(GUI)。尽管如此,我们将得到一个使用 CMake 和本书中介绍的其他工具配置、构建和测试的 Vim 工作版本。

本章将涵盖以下主题:

  • 移植项目时的初始步骤

  • 生成文件和编写平台检查

  • 检测所需的依赖项并进行链接

  • 重现编译器标志

  • 移植测试

  • 移植安装目标

  • 将项目转换为 CMake 时常见的陷阱

从哪里开始

我们将首先展示在哪里可以在线找到我们的示例,然后逐步讨论移植示例。

重现移植示例

我们将从 Vim 源代码仓库的v8.1.0290发布标签(github.com/vim/vim)开始,并基于 Git 提交哈希b476cb7进行工作。以下步骤可以通过克隆 Vim 的源代码仓库并检出该特定版本的代码来重现:

$ git clone --single-branch -b v8.1.0290 https://github.com/vim/vim.git

或者,我们的解决方案可以在github.com/dev-cafe/vimcmake-support分支上找到,并使用以下命令克隆到您的计算机上:

$ git clone --single-branch -b cmake-support https://github.com/dev-cafe/vim

在本示例中,我们将模拟在 CMake 中使用 GNU 编译器集合构建的./configure --enable-gui=no配置。

为了与我们的解决方案进行比较,并获得额外的灵感,我们鼓励读者也研究 Neovim 项目(github.com/neovim/neovim),这是一个传统的 Vi 编辑器的分支,并提供了一个 CMake 构建系统。

创建顶层 CMakeLists.txt

作为开始,我们在源代码仓库的根目录中创建一个顶级的CMakeLists.txt,在其中设置最小 CMake 版本、项目名称和支持的语言,在本例中为 C:

cmake_minimum_required(VERSION 3.5 FATAL_ERROR)

project(vim LANGUAGES C)

在添加任何目标或源文件之前,我们可以设置默认的构建类型。在这种情况下,我们默认使用Release配置,这将启用某些编译器优化:

if(NOT CMAKE_BUILD_TYPE)
  set(CMAKE_BUILD_TYPE Release CACHE STRING "Build type" FORCE)
endif()

我们还使用便携式安装目录变量,如 GNU 软件所定义:

include(GNUInstallDirs)
set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY
  ${CMAKE_BINARY_DIR}/${CMAKE_INSTALL_LIBDIR})
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY
  ${CMAKE_BINARY_DIR}/${CMAKE_INSTALL_LIBDIR})
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY
  ${CMAKE_BINARY_DIR}/${CMAKE_INSTALL_BINDIR})

作为健全性检查,我们可以尝试配置和构建项目,但到目前为止还没有目标,因此构建步骤的输出将为空:

$ mkdir -p build
$ cd build
$ cmake ..
$ cmake --build .

我们很快将开始添加目标,以使构建更加充实。

如何同时允许传统配置和 CMake 配置

CMake 的一个非常好的特性是,我们可以在源代码目录之外构建,构建目录可以是任何目录,而不必是项目目录的子目录。这意味着我们可以在不干扰先前/当前配置和构建机制的情况下将项目迁移到 CMake。对于非平凡项目的迁移,CMake 文件可以与其他构建框架共存,以允许逐步迁移,无论是选项、功能和可移植性方面,还是允许开发人员社区适应新框架。为了允许传统和 CMake 配置在一段时间内共存,一个典型的策略是将所有 CMake 代码收集在CMakeLists.txt文件中,并将所有辅助 CMake 源文件放在cmake子目录下。在我们的示例中,我们不会引入cmake子目录,而是将辅助文件更靠近需要它们的目标和源文件,但我们会注意保持几乎所有用于传统 Autotools 构建的文件不变,只有一个例外:我们将对自动生成的文件进行少量修改,以便将它们放置在构建目录下,而不是源代码树中。

记录传统构建过程的记录

在我们向配置中添加任何目标之前,通常首先记录传统构建过程的内容,并将配置和构建步骤的输出保存到日志文件中,这通常很有用。对于我们的 Vim 示例,可以使用以下方法完成:

$ ./configure --enable-gui=no

... lot of output ...

$ make > build.log

在我们的情况下(build.log的完整内容未在此处显示),我们能够验证哪些源文件被编译以及使用了哪些编译标志(-I. -Iproto

-DHAVE_CONFIG_H -g -O2 -U_FORTIFY_SOURCE -D_FORTIFY_SOURCE=1。从日志文件中,我们可以推断出以下内容:

  • 所有对象都被链接成一个单一的二进制文件

  • 不生成库文件

  • 可执行目标链接了以下库:-lSM -lICE -lXpm -lXt -lX11 -lXdmcp -lSM -lICE -lm -ltinfo -lelf -lnsl -lacl -lattr -lgpm -ldl

调试迁移过程

在逐步将目标和命令迁移到 CMake 侧时,使用message命令打印变量值将非常有用:

message(STATUS "for debugging printing the value of ${some_variable}")

通过添加选项、目标、源文件和依赖项,同时使用message进行调试,我们将逐步构建起一个可用的构建系统。

实现选项

找出传统配置向用户提供的选项(例如,通过运行./configure --help)。Vim 项目提供了一个非常长的选项和标志列表,为了在本章中保持讨论的简单性,我们只会在 CMake 侧实现四个选项:

--disable-netbeans    Disable NetBeans integration support.
--disable-channel     Disable process communication support.
--enable-terminal     Enable terminal emulation support.
--with-features=TYPE  tiny, small, normal, big or huge (default: huge)

我们还将忽略任何 GUI 支持,并模拟--enable-gui=no,因为这会使示例复杂化,而对学习成果没有显著增加。

我们将在CMakeLists.txt中放置以下选项和默认值:

option(ENABLE_NETBEANS "Enable netbeans" ON)
option(ENABLE_CHANNEL "Enable channel" ON)
option(ENABLE_TERMINAL "Enable terminal" ON)

我们将使用一个变量FEATURES来模拟--with-features标志,该变量可以通过cmake -D FEATURES=value来定义。我们确保如果FEATURES未设置,它默认为"huge":

if(NOT FEATURES)
  set(FEATURES "huge" CACHE STRING
    "FEATURES chosen by the user at CMake configure time")
endif()

我们还要确保用户为FEATURES提供有效的值:

list(APPEND _available_features "tiny" "small" "normal" "big" "huge")
if(NOT FEATURES IN_LIST _available_features)
  message(FATAL_ERROR "Unknown features: \"${FEATURES}\". Allowed values are: ${_available_features}.")
endif()
set_property(CACHE FEATURES PROPERTY STRINGS ${_available_features})

最后一行set_property(CACHE FEATURES PROPERTY STRINGS ${_available_features})有一个很好的效果,即在使用cmake-gui配置项目时,用户会看到一个用于FEATURES的选择字段,列出了我们已定义的所有可用功能(另请参见blog.kitware.com/constraining-values-with-comboboxes-in-cmake-cmake-gui/)。

这些选项可以放在顶层的CMakeLists.txt中(正如我们在这里所做的),或者可以定义在查询ENABLE_NETBEANSENABLE_CHANNELENABLE_TERMINALFEATURES的目标附近。前一种策略的优势在于选项集中在一个地方,不需要遍历CMakeLists.txt文件树来查找选项的定义。由于我们还没有定义任何目标,我们可以从将选项保存在一个中心文件开始,但稍后我们可能会将选项定义移到更接近目标的位置,以限制范围并得到更可重用的 CMake 构建块。

从可执行文件和非常少的目标开始,稍后限制范围

让我们添加一些源文件。在 Vim 示例中,源文件位于src目录下,为了保持主CMakeLists.txt的可读性和可维护性,我们将创建一个新文件src/CMakeLists.txt,并通过在主CMakeLists.txt中添加以下内容来在它自己的目录范围内处理该文件:

add_subdirectory(src)

src/CMakeLists.txt内部,我们可以开始定义可执行目标并列出从build.log中提取的所有源文件:

add_executable(vim
  arabic.c beval.c buffer.c blowfish.c crypt.c crypt_zip.c dict.c diff.c digraph.c edit.c eval.c evalfunc.c ex_cmds.c ex_cmds2.c ex_docmd.c ex_eval.c ex_getln.c farsi.c fileio.c fold.c getchar.c hardcopy.c hashtab.c if_cscope.c if_xcmdsrv.c list.c mark.c memline.c menu.c misc1.c misc2.c move.c mbyte.c normal.c ops.c option.c os_unix.c auto/pathdef.c popupmnu.c pty.c quickfix.c regexp.c screen.c search.c sha256.c spell.c spellfile.c syntax.c tag.c term.c terminal.c ui.c undo.c userfunc.c window.c libvterm/src/encoding.c libvterm/src/keyboard.c libvterm/src/mouse.c libvterm/src/parser.c libvterm/src/pen.c libvterm/src/screen.c libvterm/src/state.c libvterm/src/unicode.c libvterm/src/vterm.c netbeans.c channel.c charset.c json.c main.c memfile.c message.c version.c
  )

这是一个开始。在这种情况下,代码甚至不会配置,因为源文件列表包含生成的文件。在我们讨论生成的文件和链接依赖之前,我们将把这个长列表分成几个部分,以限制目标依赖的范围,并使项目更易于管理。如果我们将它们分组到目标中,我们还将使 CMake 更容易扫描源文件依赖关系,并避免出现非常长的链接行。

对于 Vim 示例,我们可以从 src/Makefilesrc/configure.ac 中获得关于源文件分组的更多见解。从这些文件中,我们可以推断出大多数源文件是基本的和必需的。有些源文件是可选的(netbeans.c 应该只在 ENABLE_NETBEANSON 时构建,channel.c 应该只在 ENABLE_CHANNELON 时构建)。此外,我们可能可以将所有源文件归类在 src/libvterm/ 下,并使用 ENABLE_TERMINAL 使它们的编译成为可选。

通过这种方式,我们将 CMake 结构重新组织为以下树形结构:

.
├── CMakeLists.txt
└── src
    ├── CMakeLists.txt
    └── libvterm
        └── CMakeLists.txt

顶级文件添加了 src/CMakeLists.txt 并包含 add_subdirectory(src)src/CMakeLists.txt 文件现在包含三个目标(一个可执行文件和两个库),每个目标都带有编译定义和包含目录。我们首先定义可执行文件:

add_executable(vim
  main.c
  )

target_compile_definitions(vim
  PRIVATE
    "HAVE_CONFIG_H"
  )

然后,我们定义所需的源文件:

add_library(basic_sources "")

target_sources(basic_sources
  PRIVATE
    arabic.c beval.c blowfish.c buffer.c charset.c
    crypt.c crypt_zip.c dict.c diff.c digraph.c
    edit.c eval.c evalfunc.c ex_cmds.c ex_cmds2.c
    ex_docmd.c ex_eval.c ex_getln.c farsi.c fileio.c
    fold.c getchar.c hardcopy.c hashtab.c if_cscope.c
    if_xcmdsrv.c json.c list.c main.c mark.c
    memfile.c memline.c menu.c message.c misc1.c
    misc2.c move.c mbyte.c normal.c ops.c
    option.c os_unix.c auto/pathdef.c popupmnu.c pty.c
    quickfix.c regexp.c screen.c search.c sha256.c
    spell.c spellfile.c syntax.c tag.c term.c
    terminal.c ui.c undo.c userfunc.c version.c
    window.c
  )

target_include_directories(basic_sources
  PRIVATE
    ${CMAKE_CURRENT_LIST_DIR}/proto
    ${CMAKE_CURRENT_LIST_DIR}
    ${CMAKE_CURRENT_BINARY_DIR}
  )

target_compile_definitions(basic_sources
  PRIVATE
    "HAVE_CONFIG_H"
  )

target_link_libraries(vim
  PUBLIC
    basic_sources
  )

然后,我们定义可选的源文件:

add_library(extra_sources "")

if(ENABLE_NETBEANS)
  target_sources(extra_sources
    PRIVATE
      netbeans.c
    )
endif()

if(ENABLE_CHANNEL)
  target_sources(extra_sources
    PRIVATE
      channel.c
    )
endif()

target_include_directories(extra_sources
  PUBLIC
    ${CMAKE_CURRENT_LIST_DIR}/proto
    ${CMAKE_CURRENT_BINARY_DIR}
  )

target_compile_definitions(extra_sources
  PRIVATE
    "HAVE_CONFIG_H"
  )

target_link_libraries(vim
  PUBLIC
    extra_sources
  )

该文件还选择性地处理并链接 src/libvterm/,使用以下代码:

if(ENABLE_TERMINAL)
  add_subdirectory(libvterm)

  target_link_libraries(vim
    PUBLIC
      libvterm
    )
endif()

相应的 src/libvterm/CMakeLists.txt 包含以下内容:

add_library(libvterm "")

target_sources(libvterm
  PRIVATE
    src/encoding.c
    src/keyboard.c
    src/mouse.c
    src/parser.c
    src/pen.c
    src/screen.c
    src/state.c
    src/unicode.c
    src/vterm.c
  )

target_include_directories(libvterm
  PUBLIC
    ${CMAKE_CURRENT_LIST_DIR}/include
  )

target_compile_definitions(libvterm
  PRIVATE
    "HAVE_CONFIG_H"
    "INLINE="
    "VSNPRINTF=vim_vsnprintf"
    "IS_COMBINING_FUNCTION=utf_iscomposing_uint"
    "WCWIDTH_FUNCTION=utf_uint2cells"
  )

我们已经从记录的 build.log 中提取了编译定义。树形结构的优点是目标定义靠近源文件所在的位置。如果我们决定重构代码并重命名或移动目录,描述目标的 CMake 文件有机会随源文件一起移动。

我们的示例代码甚至还没有配置(除非在成功的 Autotools 构建之后尝试):

$ mkdir -p build
$ cd build
$ cmake ..

-- The C compiler identification is GNU 8.2.0
-- Check for working C compiler: /usr/bin/cc
-- Check for working C compiler: /usr/bin/cc -- works
-- Detecting C compiler ABI info
-- Detecting C compiler ABI info - done
-- Detecting C compile features
-- Detecting C compile features - done
-- Configuring done
CMake Error at src/CMakeLists.txt:12 (add_library):
  Cannot find source file:

    auto/pathdef.c

  Tried extensions .c .C .c++ .cc .cpp .cxx .cu .m .M .mm .h .hh .h++ .hm
  .hpp .hxx .in .txx

我们需要生成 auto/pathdef.c(以及其他文件),我们将在下一节中考虑这一点。

生成文件和编写平台检查

事实证明,对于 Vim 代码示例,我们需要在配置时生成三个文件:src/auto/pathdef.csrc/auto/config.hsrc/auto/osdef.h

  • pathdef.c 记录安装路径、编译和链接标志、编译代码的用户以及主机名

  • config.h 包含特定于系统环境的编译定义

  • osdef.h 是一个包含由 src/osdef.sh 生成的编译定义的文件。

这种情况相当常见。我们需要根据 CMake 变量配置一个文件,执行一系列平台检查以生成 config.h,并在配置时执行一个脚本。特别是,平台检查对于追求可移植性的项目来说非常常见,以适应操作系统之间的微妙差异。

在原始布局中,文件在 src 文件夹下生成。我们不喜欢这种方法,在我们的示例 CMake 移植中将采取不同的做法:这些文件将在构建目录中生成。这样做的原因是,生成的文件通常依赖于所选的选项、编译器或构建类型,我们希望保持能够配置多个具有相同源代码的构建的能力。为了在构建目录中启用生成,我们将不得不对之前列出的文件之一的生成脚本进行最小程度的更改。

如何组织文件

我们将收集生成这些文件的函数在src/autogenerate.cmake中,包含此模块,并在定义可执行目标之前在src/CMakeLists.txt中调用这些函数:

# generate config.h, pathdef.c, and osdef.h
include(autogenerate.cmake)
generate_config_h()
generate_pathdef_c()
generate_osdef_h()

add_executable(vim
  main.c
  )

# ...

包含的src/autogenerate.cmake包含其他包含功能,我们将需要这些功能来探测头文件,函数和库,以及三个函数:

include(CheckTypeSize)
include(CheckFunctionExists)
include(CheckIncludeFiles)
include(CheckLibraryExists)
include(CheckCSourceCompiles)

function(generate_config_h)
  # ... to be written
endfunction()

function(generate_pathdef_c)
  # ... to be written
endfunction()

function(generate_osdef_h)
  # ... to be written
endfunction()

我们选择使用函数生成文件,而不是宏或“裸”CMake 代码。正如我们在前几章中讨论的那样,这避免了许多陷阱:

  • 它使我们能够避免文件被多次生成,以防我们不小心多次包含该模块。如第五章中的重新定义函数和宏所述,在第七章,项目结构中,我们可以使用包含保护来防止不小心多次运行代码。

  • 它确保完全控制函数内部定义的变量的作用域。这避免了这些定义泄漏并污染主作用域。

根据系统环境配置预处理器定义

config.h文件是从src/config.h.in生成的,其中包含根据系统能力配置的预处理器标志:

/* Define if we have EBCDIC code */
#undef EBCDIC

/* Define unless no X support found */
#undef HAVE_X11

/* Define when terminfo support found */
#undef TERMINFO

/* Define when termcap.h contains ospeed */
#undef HAVE_OSPEED

/* ... */

src/config.h生成的示例可以像这个示例一样开始(定义可能因环境而异):

/* Define if we have EBCDIC code */
/* #undef EBCDIC */

/* Define unless no X support found */
#define HAVE_X11 1

/* Define when terminfo support found */
#define TERMINFO 1

/* Define when termcap.h contains ospeed */
/* #undef HAVE_OSPEED */

/* ... */

平台检查的一个很好的资源是这个页面:www.vtk.org/Wiki/CMake:How_To_Write_Platform_Checks

src/configure.ac中,我们可以检查需要执行哪些平台检查以设置相应的预处理器定义。

我们将使用#cmakedefinecmake.org/cmake/help/v3.5/command/configure_file.html?highlight=cmakedefine),并确保我们不会破坏现有的 Autotools 构建,我们将复制config.h.inconfig.h.cmake.in,并将所有#undef SOME_DEFINITION更改为#cmakedefine SOME_DEFINITION @SOME_DEFINITION@

generate_config_h函数中,我们首先定义一些变量:

set(TERMINFO 1)
set(UNIX 1)

# this is hardcoded to keep the discussion in the book chapter
# which describes the migration to CMake simpler
set(TIME_WITH_SYS_TIME 1)
set(RETSIGTYPE void)
set(SIGRETURN return)

find_package(X11)
set(HAVE_X11 ${X11_FOUND})

然后,我们执行一些类型大小检查:

check_type_size("int" VIM_SIZEOF_INT)
check_type_size("long" VIM_SIZEOF_LONG)
check_type_size("time_t" SIZEOF_TIME_T)
check_type_size("off_t" SIZEOF_OFF_T)

然后,我们遍历函数并检查系统是否能够解析它们:

foreach(
  _function IN ITEMS
  fchdir fchown fchmod fsync getcwd getpseudotty
  getpwent getpwnam getpwuid getrlimit gettimeofday getwd lstat
  memset mkdtemp nanosleep opendir putenv qsort readlink select setenv
  getpgid setpgid setsid sigaltstack sigstack sigset sigsetjmp sigaction
  sigprocmask sigvec strcasecmp strerror strftime stricmp strncasecmp
  strnicmp strpbrk strtol towlower towupper iswupper
  usleep utime utimes mblen ftruncate
  )

  string(TOUPPER "${_function}" _function_uppercase)
  check_function_exists(${_function} HAVE_${_function_uppercase})
endforeach()

我们验证特定的库是否包含特定的函数:

check_library_exists(tinfo tgetent "" HAVE_TGETENT)

if(NOT HAVE_TGETENT)
  message(FATAL_ERROR "Could not find the tgetent() function. You need to install a terminal library; for example ncurses.")
endif()

然后,我们遍历头文件并检查它们是否可用:

foreach(
  _header IN ITEMS
  setjmp.h dirent.h
  stdint.h stdlib.h string.h
  sys/select.h sys/utsname.h termcap.h fcntl.h
  sgtty.h sys/ioctl.h sys/time.h sys/types.h
  termio.h iconv.h inttypes.h langinfo.h math.h
  unistd.h stropts.h errno.h sys/resource.h
  sys/systeminfo.h locale.h sys/stream.h termios.h
  libc.h sys/statfs.h poll.h sys/poll.h pwd.h
  utime.h sys/param.h libintl.h libgen.h
  util/debug.h util/msg18n.h frame.h sys/acl.h
  sys/access.h sys/sysinfo.h wchar.h wctype.h
  )

  string(TOUPPER "${_header}" _header_uppercase)
  string(REPLACE "/" "_" _header_normalized "${_header_uppercase}")
  string(REPLACE "." "_" _header_normalized "${_header_normalized}")
  check_include_files(${_header} HAVE_${_header_normalized})
endforeach()

然后,我们将 CMake 选项从主CMakeLists.txt转换为预处理器定义:

string(TOUPPER "${FEATURES}" _features_upper)
set(FEAT_${_features_upper} 1)

set(FEAT_NETBEANS_INTG ${ENABLE_NETBEANS})
set(FEAT_JOB_CHANNEL ${ENABLE_CHANNEL})
set(FEAT_TERMINAL ${ENABLE_TERMINAL})

最后,我们检查是否能够编译特定的代码片段:

check_c_source_compiles(
  "
  #include <sys/types.h>
  #include <sys/stat.h>
  int
  main ()
  {
          struct stat st;
          int n;

          stat(\"/\", &st);
          n = (int)st.st_blksize;
    ;
    return 0;
  }
  "
  HAVE_ST_BLKSIZE
  )

然后使用定义的变量来配置src/config.h.cmake.inconfig.h,这完成了generate_config_h函数:

configure_file(
  ${CMAKE_CURRENT_LIST_DIR}/config.h.cmake.in
  ${CMAKE_CURRENT_BINARY_DIR}/auto/config.h
  @ONLY
  )

使用路径和编译器标志配置文件

我们生成pathdef.csrc/pathdef.c.in

#include "vim.h"

char_u *default_vim_dir = (char_u *)"@_default_vim_dir@";
char_u *default_vimruntime_dir = (char_u *)"@_default_vimruntime_dir@";
char_u *all_cflags = (char_u *)"@_all_cflags@";
char_u *all_lflags = (char_u *)"@_all_lflags@";
char_u *compiled_user = (char_u *)"@_compiled_user@";
char_u *compiled_sys = (char_u *)"@_compiled_sys@";

generate_pathdef_c函数配置src/pathdef.c.in,但我们省略了链接标志以简化:

function(generate_pathdef_c)
  set(_default_vim_dir ${CMAKE_INSTALL_PREFIX})
  set(_default_vimruntime_dir ${_default_vim_dir})

  set(_all_cflags "${CMAKE_C_COMPILER} ${CMAKE_C_FLAGS}")
  if(CMAKE_BUILD_TYPE STREQUAL "Release")
    set(_all_cflags "${_all_cflags} ${CMAKE_C_FLAGS_RELEASE}")
  else()
    set(_all_cflags "${_all_cflags} ${CMAKE_C_FLAGS_DEBUG}")
  endif()

  # it would require a bit more work and execute commands at build time
  # to get the link line into the binary
  set(_all_lflags "undefined")

  if(WIN32)
    set(_compiled_user $ENV{USERNAME})
  else()
    set(_compiled_user $ENV{USER})
  endif()

  cmake_host_system_information(RESULT _compiled_sys QUERY HOSTNAME)

  configure_file(
    ${CMAKE_CURRENT_LIST_DIR}/pathdef.c.in
    ${CMAKE_CURRENT_BINARY_DIR}/auto/pathdef.c
    @ONLY
    )
endfunction()

在配置时执行 shell 脚本

最后,我们使用以下函数生成osdef.h

function(generate_osdef_h)
  find_program(BASH_EXECUTABLE bash)

  execute_process(
    COMMAND
      ${BASH_EXECUTABLE} osdef.sh ${CMAKE_CURRENT_BINARY_DIR}
    WORKING_DIRECTORY
      ${CMAKE_CURRENT_LIST_DIR}
    )
endfunction()

为了在 ${CMAKE_CURRENT_BINARY_DIR}/src/auto 而不是 src/auto 中生成 osdef.h,我们不得不修改 osdef.sh 以接受 ${CMAKE_CURRENT_BINARY_DIR} 作为命令行参数。

osdef.sh内部,我们检查是否给出了这个参数:

if [ $# -eq 0 ]
  then
    # there are no arguments
    # assume the target directory is current directory
    target_directory=$PWD
  else
    # target directory is provided as argument
    target_directory=$1
fi

然后,我们生成 ${target_directory}/auto/osdef.h。为此,我们还需要调整osdef.sh内部的下述编译行:

$CC -I. -I$srcdir -I${target_directory} -E osdef0.c >osdef0.cc

检测所需依赖项和链接

现在我们已经将所有生成的文件放置到位,让我们重新尝试构建。我们应该能够配置和编译源代码,但我们无法链接:

$ mkdir -p build
$ cd build
$ cmake ..
$ cmake --build .

...
Scanning dependencies of target vim
[ 98%] Building C object src/CMakeFiles/vim.dir/main.c.o
[100%] Linking C executable ../bin/vim
../lib64/libbasic_sources.a(term.c.o): In function `set_shellsize.part.12':
term.c:(.text+0x2bd): undefined reference to `tputs'
../lib64/libbasic_sources.a(term.c.o): In function `getlinecol':
term.c:(.text+0x902): undefined reference to `tgetent'
term.c:(.text+0x915): undefined reference to `tgetent'
term.c:(.text+0x935): undefined reference to `tgetnum'
term.c:(.text+0x948): undefined reference to `tgetnum'

... many other undefined references ...

同样,我们可以从 Autotools 编译的日志文件中,特别是链接行中获得灵感,通过在src/CMakeLists.txt中添加以下代码来解决缺失的依赖:

# find X11 and link to it
find_package(X11 REQUIRED)
if(X11_FOUND)
  target_link_libraries(vim
    PUBLIC
      ${X11_LIBRARIES}
    )
endif()

# a couple of more system libraries that the code requires
foreach(_library IN ITEMS Xt SM m tinfo acl gpm dl)
  find_library(_${_library}_found ${_library} REQUIRED)
  if(_${_library}_found)
    target_link_libraries(vim
      PUBLIC
        ${_library}
      )
  endif()
endforeach()

注意我们是如何一次向目标添加一个库依赖,而不必构建和携带一个变量中的库列表,这会导致更脆弱的 CMake 代码,因为变量在过程中可能会被破坏,尤其是在大型项目中。

通过这个更改,代码编译并链接:

$ cmake --build .

...
Scanning dependencies of target vim
[ 98%] Building C object src/CMakeFiles/vim.dir/main.c.o
[100%] Linking C executable ../bin/vim
[100%] Built target vim

我们现在可以尝试执行编译后的二进制文件,并用我们新编译的 Vim 版本编辑一些文件!

重现编译器标志

现在让我们尝试调整编译器标志以反映参考构建。

定义编译器标志

到目前为止,我们还没有定义任何自定义编译器标志,但从参考 Autotools 构建中,我们记得代码是用-g -U_FORTIFY_SOURCE编译的

-D_FORTIFY_SOURCE=1 -O2 使用 GNU C 编译器。

我们的第一个方法可能是定义以下内容:

if(CMAKE_C_COMPILER_ID MATCHES GNU)
    set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -g -U_FORTIFY_SOURCE -D_FORTIFY_SOURCE=1 -O2")
endif()

而且,我们会将这段代码放在src/CMakeLists.txt的顶部,就在生成源文件之前(因为pathdef.c使用了${CMAKE_C_FLAGS}):

# <- we will define flags right here

include(autogenerate.cmake)
generate_config_h()
generate_pathdef_c()
generate_osdef_h()

对编译器标志定义的一个小改进是将-O2定义为Release配置标志,并为Debug配置关闭优化:

if(CMAKE_C_COMPILER_ID MATCHES GNU)
  set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -g -U_FORTIFY_SOURCE 
-D_FORTIFY_SOURCE=1")
  set(CMAKE_C_FLAGS_RELEASE "-O2")
  set(CMAKE_C_FLAGS_DEBUG "-O0")
endif()

请使用make VERBOSE=1验证构建是否使用了预期的标志。

编译器标志的范围

在这个特定的示例项目中,所有源文件使用相同的编译标志。对于其他项目,我们可能更倾向于不全局定义编译标志,而是使用target_compile_options为每个目标单独定义标志。这样做的好处是更灵活和更局部的范围。在我们这里的例子中,代价可能是不必要的代码重复。

移植测试

现在让我们讨论如何将测试从参考构建移植到我们的 CMake 构建。

开始

如果正在移植的项目包含测试目标或任何形式的自动化测试或测试脚本,第一步将再次是运行传统的测试步骤并记录使用的命令。对于 Vim 项目,起点是src/testdir/Makefile。在 CMake 侧定义测试可能是有意义的,接近src/testdir/Makefile和测试脚本,我们将选择在src/testdir/CMakeLists.txt中定义测试。为了处理这样的文件,我们必须在其src/CMakeLists.txt中引用它:

add_subdirectory(testdir)

我们还应该在顶层CMakeLists.txt中启用测试目标,就在处理src/CMakeLists.txt之前:

# enable the test target
enable_testing()

# process src/CMakeLists.txt in its own scope
add_subdirectory(src)

到目前为止,在我们向src/testdir/CMakeLists.txt填充add_test指令之前,测试目标还是空的。add_test中最少需要指定的是测试名称和一个运行命令。该命令可以是任何语言编写的任何脚本。对于 CMake 来说,关键的是如果测试成功,脚本返回零,如果测试失败,则返回非零。更多详情,我们请读者参考第四章,创建和运行测试。对于 Vim 的情况,我们需要更多来适应多步骤测试,我们将在下一节讨论。

实现多步骤测试

src/testdir/Makefile中的目标表明 Vim 代码以多步骤测试运行:首先,vim可执行文件处理一个脚本并生成一个输出文件,然后在第二步中,输出文件与参考文件进行比较,如果这些文件没有差异,则测试成功。临时文件随后在第三步中被删除。这可能无法以可移植的方式适应单个add_test命令,因为add_test只能执行一个命令。一个解决方案是将测试步骤定义在一个 Python 脚本中,并用一些参数执行该 Python 脚本。我们将在这里介绍的另一种替代方案也是跨平台的,即将测试步骤定义在一个单独的 CMake 脚本中,并从add_test执行该脚本。我们将在src/testdir/test.cmake中定义测试步骤:

function(execute_test _vim_executable _working_dir _test_script)
  # generates test.out
  execute_process(
    COMMAND ${_vim_executable} -f -u unix.vim -U NONE --noplugin --not-a-term -s dotest.in ${_test_script}.in
    WORKING_DIRECTORY ${_working_dir}
    )

  # compares test*.ok and test.out
  execute_process(
    COMMAND ${CMAKE_COMMAND} -E compare_files ${_test_script}.ok test.out
    WORKING_DIRECTORY ${_working_dir}
    RESULT_VARIABLE files_differ
    OUTPUT_QUIET
    ERROR_QUIET
    )

  # removes leftovers
  file(REMOVE ${_working_dir}/Xdotest)

  # we let the test fail if the files differ
  if(files_differ)
    message(SEND_ERROR "test ${_test_script} failed")
  endif()
endfunction()

execute_test(${VIM_EXECUTABLE} ${WORKING_DIR} ${TEST_SCRIPT})

再次,我们选择函数而非宏来确保变量不会逃逸函数作用域。我们将处理这个脚本,该脚本将调用execute_test函数。然而,我们必须确保从外部定义了${VIM_EXECUTABLE}${WORKING_DIR}${TEST_SCRIPT}。这些在src/testdir/CMakeLists.txt中定义:

add_test(
  NAME
    test1
  COMMAND
    ${CMAKE_COMMAND} -D VIM_EXECUTABLE=$<TARGET_FILE:vim>
                     -D WORKING_DIR=${CMAKE_CURRENT_LIST_DIR}
                     -D TEST_SCRIPT=test1
                     -P ${CMAKE_CURRENT_LIST_DIR}/test.cmake
  WORKING_DIRECTORY
    ${PROJECT_BINARY_DIR}
  )

Vim 项目有许多测试,但在本例中,我们只移植了一个(test1)作为概念验证。

测试建议

我们至少可以给出两个关于移植测试的建议。首先,确保测试不会总是报告成功,如果代码被破坏或参考数据被更改,请验证测试是否失败。其次,为测试添加COST估计,以便在并行运行时,较长的测试首先启动,以最小化总测试时间(参见第四章,创建和运行测试,第 8 个配方,并行运行测试)。

移植安装目标

我们现在可以配置、编译、链接和测试代码,但我们缺少安装目标,我们将在本节中添加它。

这是 Autotools 构建和安装代码的方法:

$ ./configure --prefix=/some/install/path
$ make
$ make install

这就是 CMake 的方式:

$ mkdir -p build
$ cd build
$ cmake -D CMAKE_INSTALL_PREFIX=/some/install/path ..
$ cmake --build .
$ cmake --build . --target install

要添加安装目标,我们需在src/CMakeLists.txt中添加以下代码片段:

install(
  TARGETS
    vim
  RUNTIME DESTINATION
    ${CMAKE_INSTALL_BINDIR}
  )

在本例中,我们只安装了可执行文件。Vim 项目在安装二进制文件的同时安装了大量文件(符号链接和文档文件)。为了使本节易于理解,我们没有在本例迁移中安装所有其他文件。对于你自己的项目,你应该验证安装步骤的结果是否与遗留构建框架的安装目标相匹配。

进一步的步骤

成功移植到 CMake 后,下一步应该是进一步限定目标和变量的范围:考虑将选项、目标和变量移动到它们被使用和修改的位置附近。避免全局变量,因为它们会强制 CMake 命令的顺序,而这个顺序可能不明显,会导致脆弱的 CMake 代码。一种强制分离变量范围的方法是将大型项目划分为 CMake 项目,这些项目使用超级构建模式(参见第八章,超级构建模式)。考虑将大型CMakeLists.txt文件拆分为较小的模块。

接下来的步骤可能是在其他平台和操作系统上测试配置和编译,以便使 CMake 代码更加通用和防弹,并使其更具可移植性。

最后,在将项目迁移到新的构建框架时,开发社区也需要适应它。通过培训、文档和代码审查帮助你的同事。在将代码移植到 CMake 时,最难的部分可能是改变人的习惯。

转换项目到 CMake 时的总结和常见陷阱

让我们总结一下本章我们取得了哪些成就以及我们学到了什么。

代码变更总结

在本章中,我们讨论了如何将项目移植到 CMake。我们以 Vim 项目为例,并添加了以下文件:

.
├── CMakeLists.txt
└── src
    ├── autogenerate.cmake
    ├── CMakeLists.txt
    ├── config.h.cmake.in
    ├── libvterm
    │   └── CMakeLists.txt
    ├── pathdef.c.in
    └── testdir
        ├── CMakeLists.txt
        └── test.cmake

可以在线浏览变更:github.com/dev-cafe/vim/compare/b476cb7...cmake-support

这是一个不完整的 CMake 移植概念证明,我们省略了许多选项和调整以简化,并试图专注于最突出的特性和步骤。

常见陷阱

我们希望通过指出转向 CMake 时的一些常见陷阱来结束这次讨论。

  • 全局变量是代码异味:这在任何编程语言中都是如此,CMake 也不例外。跨越 CMake 文件的变量,特别是从叶子到父级CMakeLists.txt文件“向上”传递的变量,表明代码存在问题。通常有更好的方式来传递依赖。理想情况下,依赖应该通过目标来导入。不要将一系列库组合成一个变量并在文件之间传递该变量,而是将库一个接一个地链接到它们定义的位置附近。不要将源文件组合成变量,而是使用target_sources添加源文件。在链接库时,如果可用,使用导入的目标而不是变量。

  • 最小化顺序影响:CMake 不是一种声明式语言,但我们也不应该用命令式范式来处理它。强制严格顺序的 CMake 源码往往比较脆弱。这也与变量的讨论有关(见前一段)。某些语句和模块的顺序是必要的,但为了得到稳健的 CMake 框架,我们应该避免不必要的顺序强制。使用target_sourcestarget_compile_definitionstarget_include_directoriestarget_link_libraries。避免全局范围的语句,如add_definitionsinclude_directorieslink_libraries。避免全局定义编译标志。如果可能,为每个目标定义编译标志。

  • 不要将生成的文件放置在构建目录之外:强烈建议永远不要将生成的文件放置在构建目录之外。这样做的原因是,生成的文件通常依赖于所选的选项、编译器或构建类型,而将文件写入源代码树中,我们放弃了维护多个具有相同源代码的构建的可能性,并且使构建步骤的可重复性变得复杂。

  • 优先使用函数而非宏:它们具有不同的作用域,函数作用域是有限的。所有变量修改都需要明确标记,这也向读者表明了变量重定义。当你必须使用宏时使用,但如果你能使用函数,则优先使用函数。

  • 避免 shell 命令:它们可能不兼容其他平台(如 Windows)。优先使用 CMake 的等效命令。如果没有可用的 CMake 等效命令,考虑调用 Python 脚本。

  • 在 Fortran 项目中,注意后缀大小写:需要预处理的 Fortran 源文件应具有大写的.F90后缀。不需要预处理的源文件应具有小写的.f90后缀。

  • 避免显式路径:无论是在定义目标时还是在引用文件时都是如此。使用CMAKE_CURRENT_LIST_DIR来引用当前路径。这样做的好处是,当你移动或重命名目录时,它仍然有效。

  • 模块包含不应是函数调用:将 CMake 代码模块化是一个好的策略,但包含模块理想情况下不应执行 CMake 代码。相反,应将 CMake 代码封装到函数和宏中,并在包含模块后显式调用这些函数和宏。这可以防止无意中多次包含模块时产生的不良后果,并使执行 CMake 代码模块的动作对读者更加明确。

posted @ 2024-05-15 16:42  绝不原创的飞龙  阅读(58)  评论(0编辑  收藏  举报