CMake-最佳实践第二版-全-

CMake 最佳实践第二版(全)

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

译者:飞龙

协议:CC BY-NC-SA 4.0

序言

软件世界和我们用来创建软件的工具每天都在不断发展。CMake 也不例外;在过去的二十年里,它不断发展,现在已被认为是构建 C++ 应用程序的行业标准。尽管 CMake 拥有丰富的功能和全面的文档,但关于如何有效地将这些功能结合使用的真实世界示例和指南却很少。这正是《CMake 最佳实践》所填补的空白。

本书并未深入探讨 CMake 的每个细节和功能,而是通过示例说明如何有效地使用 CMake 完成各种软件构建任务,而不会覆盖每个极端案例——其他书籍可以满足这一需求。本书的目的是尽可能简化,同时覆盖完成任务的推荐最佳实践。这样做的原因是,您不需要了解 CMake 的所有功能就能完成日常任务。

我们尝试通过具体的示例来说明所有概念,您可以亲自尝试。由于本书的读者主要是工程师,我们已经根据这一点量身定制了内容。在编写本书时,我们首先是软件工程师,其次是作者。因此,内容更注重实用性而非理论性。这本书是经过精心挑选的、已被验证的技术的汇编,您可以在日常的 CMake 工作流中使用它们。

从工程师到工程师,我们希望您喜欢这本书。

第二版有什么新内容?

在本第二版中,我们基于读者反馈和 CMake 最新的进展做了几项重要的补充和改进:

  • 关于为 Apple 产品构建软件的新章节:我们增加了关于如何为 Apple 平台构建软件的全面内容,解决了在 Apple 封闭生态系统中软件处理的独特方式。

  • 深入讨论 CMake 预设:本版包含了比上一版更详细的关于 CMake 预设的讨论。这将帮助您简化开发流程。

  • 重做的依赖处理章节:关于依赖处理的章节已大幅重做,包含了如何使用新的 CMake 依赖提供者以及使用 Conan 2 版本的实用指南,使得在项目中管理依赖变得更加容易。

  • 更正勘误和读者反馈:我们已经彻底修订了内容,纠正了任何错误,并纳入了读者的宝贵反馈,以增强本书的清晰度和实用性。

我们相信这些更新将使《CMake 最佳实践》第二版对您更有价值,提供最新的技巧和见解,以改善您使用 CMake 构建软件的体验。

本书适合谁阅读

本书适用于那些经常使用 C 或 C++ 的软件工程师和构建系统维护人员,帮助他们利用 CMake 改善日常任务。基本的 C++ 和编程知识将帮助你更好地理解书中所涵盖的示例。

本书内容涵盖

第一章启动 CMake,简要介绍了 CMake 的基本概念,然后直接讲解如何安装 CMake 并使用 CMake 构建项目。你将学到如何手动安装最新的稳定版本,即使你的包管理器没有提供该版本。你还将了解 CMake 的基本概念,以及它为什么是一个构建系统生成器,而不是一个构建系统本身。学习它如何与现代 C++(以及 C)的软件开发结合。

第二章以最佳方式访问 CMake,展示了如何通过命令行、GUI 最有效地使用 CMake,以及 CMake 如何与一些常见的 IDE 和编辑器集成。

第三章创建一个 CMake 项目,带你完成为构建可执行文件、库并将两者链接在一起的项目设置。

第四章打包、部署和安装 CMake 项目,将教你如何创建一个可分发版本的软件项目。你将学到如何添加安装说明,并使用 CMake 和 CPack(CMake 的打包程序)来打包项目。

第五章集成第三方库和依赖管理,解释了如何将现有的第三方库集成到你的项目中。它还展示了如何添加已安装在系统中的库、外部 CMake 项目和非 CMake 项目。本章涵盖了 CMake 的依赖项提供者,并简要介绍了如何使用外部包管理器。

第六章自动生成文档,探索了如何在构建过程中使用 CMake 和 doxygen、dot(graphviz)、plantuml 从代码中生成文档。

第七章无缝集成代码质量工具与 CMake,将展示如何将单元测试、代码清理工具、静态代码分析和代码覆盖工具集成到你的项目中。你将学到如何使用 CMake 发现并执行测试。

第八章使用 CMake 执行自定义任务,将解释如何将几乎任何工具集成到构建过程中。你将学习如何将外部程序包装成自定义目标,或如何将它们挂钩到构建过程中以执行。我们将介绍如何使用自定义任务生成文件,以及如何让它们消耗其他目标生成的文件。你还将学习如何在 CMake 构建的配置过程中执行系统命令,以及如何使用 CMake 的 cscript 模式创建跨平台的命令。

第九章创建可重现的构建环境,展示了如何在各种机器之间(包括 CI/CD 流水线)构建便携环境。如何使用 Docker、sysroots 和 CMake 预设,让你的构建能在任何地方“开箱即用”。

第十章处理超构建中的分布式仓库和依赖项,简化了使用 CMake 管理分布在多个 Git 仓库中的项目。你将学习如何创建一个超构建,允许你构建特定版本以及最新的夜间构建。探索它所需的先决条件,以及如何将它们结合起来。

第十一章为 Apple 系统创建软件,由于封闭的生态系统,Apple 平台有一些独特的构建特点,尤其是对于具有图形用户界面和更复杂库框架的应用程序。对于这些情况,Apple 使用称为 app bundles 和 frameworks 的特定格式。在本章中,你将学习如何创建这些 Apple 特有的构建产物,并且如何对它们进行签名,以便在 App Store 中使用。

第十二章跨平台编译和自定义工具链,展示了如何使用预定义的跨平台工具链。你还将学习如何编写自己的工具链定义,并方便地在 CMake 中使用不同的工具链。

第十三章复用 CMake 代码,解释了 CMake 模块以及如何将你的 CMake 文件进行通用化。你将学习如何编写广泛使用的模块,并将它们从你的项目中单独分发。

第十四章优化和维护 CMake 项目,提供关于如何加快构建时间的提示,以及如何在长期使用中保持 CMake 项目整洁有序的技巧。

第十五章迁移到 CMake,解释了如何在不完全停止开发的情况下,将一个大型现有代码库迁移到 CMake 的高层策略。

附录, 贡献 CMake 和进一步阅读材料,提供有关如何贡献的提示、需要注意的事项以及基本的贡献指南。它还会指导您在哪里找到额外的深入信息或更具体的文献。

如何最大限度地利用本书

书中涉及的软件/硬件 操作系统要求
CMake 3.25 或更新版本 Linux, Windows, macOS
GCC, Clang 或 MSVC Linux, Windows, macOS
git Linux, Windows, macOS

如果您使用的是本书的数字版,我们建议您自己输入代码,或者从本书的 GitHub 仓库(下节中会提供链接)访问代码。这样可以帮助您避免因复制粘贴代码而可能出现的错误

下载示例代码文件

您可以从 GitHub 下载本书的示例代码文件,网址是 github.com/PacktPublishing/CMake-Best-Practices---2nd-Edition。如果代码有更新,它将在 GitHub 仓库中更新。

我们还有其他代码包,来自我们丰富的书籍和视频目录,您可以在 github.com/PacktPublishing/ 查看!

使用的约定

本书中使用了一些文本约定。

文本中的代码:表示文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 账号。例如:“将下载的 WebStorm-10*.dmg 磁盘镜像文件挂载为您系统中的另一个磁盘。”

代码块如下所示:

project( 
"chapter1" 
VERSION 1.0 
DESCRIPTION "A simple C++ project to demonstrate basic CMake usage" 
LANGUAGES CXX 
)

当我们希望特别指出代码块中的某一部分时,相关的行或项目会用粗体显示:

include(GenerateExportHeader) 
generate_export_header(hello 
EXPORT_FILE_NAME export/hello/ 
export_hello.hpp)
target_include_directories(hello PUBLIC "${CMAKE_CURRENT_BINARY_DIR} 
/export")

任何命令行输入或输出都会如下所示:

cmake -G "Unix Makefiles" -DCMAKE_CXX_COMPILER=/usr/bin/g++-12 -S . 
-B ./build 

粗体:表示新术语、重要单词或您在屏幕上看到的单词。例如,菜单或对话框中的单词会以粗体显示。以下是一个示例:“尽管 CMake 已与许多 IDE 和编辑器很好地集成,但它本质上是一个命令行工具,因此学习如何在命令行界面CLI)中使用 CMake 对于充分发挥其潜力至关重要。”

提示或重要注释

以这种方式显示。

联系我们

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

一般反馈:如果您对本书的任何方面有疑问,请通过电子邮件联系我们 customercare@packtpub.com,并在邮件主题中注明书名。

勘误:虽然我们已尽力确保内容的准确性,但错误仍然可能发生。如果您在本书中发现错误,我们将非常感激您能向我们报告。请访问 www.packtpub.com/support/errata 并填写表单。

盗版:如果你在互联网上发现我们作品的任何非法复制品,请提供相关地址或网站名称。请通过版权@packt.com 与我们联系,并附上相关链接。

如果你有兴趣成为作者:如果你在某个领域有专长,并且有兴趣写书或为书籍做贡献,请访问authors.packtpub.com

分享你的想法

一旦你读完了《CMake 最佳实践》,我们很想听听你的想法!请点击这里直接访问亚马逊书评页面,分享你的反馈。

你的评论对我们以及技术社区非常重要,将帮助我们确保提供优质的内容。

下载本书的免费 PDF 版本

感谢你购买本书!

你喜欢随时随地阅读,但又无法随身携带纸质书籍吗?

你的电子书购买与设备不兼容吗?

不用担心,现在每本 Packt 书籍都附赠一份免费的无 DRM PDF 版本。

随时随地、在任何设备上阅读。从你最喜欢的技术书籍中直接复制、粘贴代码到你的应用程序中。

福利不止这些,你还可以独享折扣、电子报以及每日发送的精彩免费内容。

按照以下简单步骤享受相关福利:

  1. 扫描二维码或访问下面的链接

packt.link/free-ebook/978-1-83588-064-7

  1. 提交你的购买凭证

  2. 就是这样!我们会将免费的 PDF 及其他福利直接发送到你的电子邮箱。

第一部分 – 基础知识

在第一章,你将学习如何调用 CMake,并获得关于 CMake 基本概念的高层次概述,以及 CMake 语言的简要介绍。

第二章将介绍如何从命令行、GUI 或不同的 IDE 和编辑器中使用 CMake。它将演示如何更改各种配置选项以及如何选择不同的编译器。

在第三章,我们将介绍如何创建一个简单的 CMake 项目来构建可执行文件和库。

这一部分包含以下章节:

  • 第一章启动 CMake

  • 第二章以最佳方式访问 CMake

  • 第三章创建 CMake 项目

第一章:启动 CMake

如果你正在使用 C++ 或 C 开发软件,可能已经听说过 CMake。过去 20 年,CMake 已经发展成为构建 C++ 应用程序的行业标准。但 CMake 不仅仅是一个构建系统——它是一个构建系统生成器,这意味着它为其他构建系统(如 Makefile、Ninja、Visual Studio、QtCreator、Android Studio 和 XCode)生成指令。CMake 不止于构建软件——它还包括支持安装、打包和测试软件的功能。

作为事实上的行业标准,CMake 是每个 C++ 程序员必须了解的技术。

本章将为你提供 CMake 的高层次概述,并介绍构建你的第一个程序所需的基础知识。我们将了解 CMake 的构建过程,并概述如何使用 CMake 语言来配置构建过程。

本章我们将涵盖以下主题:

  • CMake 简介

  • 安装 CMake

  • CMake 构建过程

  • 编写 CMake 文件

  • 不同的工具链和构建配置

  • 单配置和多配置生成器

让我们开始吧!

技术要求

为了运行本章中的示例,你需要一个支持 C++17 的最新 C++ 编译器。尽管这些示例并不复杂到需要新标准的功能,但它们已经相应地设置好了。

我们建议使用这里列出的任何编译器来运行示例:

  • Linux: GCC 9 或更新版本,Clang 10 或更新版本

  • Windows: MSVC 19 或更新版本,或 MinGW 9.0.0 或更新版本

  • macOS: AppleClang 10 或更新版本

本章中使用的完整示例可以在 github.com/PacktPublishing/CMake-Best-Practices---2nd-Edition/tree/main/chapter01 找到

注意

为了尝试本书中的任何示例,我们提供了一个现成的 Docker 容器,包含所有必要的依赖。

你可以在 github.com/PacktPublishing/CMake-Best-Practices---2nd-Edition 找到它。

CMake 简介

CMake 是开源的,且可在多个平台上使用。它也是与编译器无关的,这使得它在构建和分发跨平台软件时非常强大。所有这些功能使它成为以现代方式构建软件的宝贵工具——即通过依赖构建自动化和内置质量门控。

CMake 包含三个命令行工具:

  • cmake: CMake 本身,用于生成构建指令

  • ctest: CMake 的测试工具,用于检测和运行测试

  • cpack: CMake 的打包工具,用于将软件打包成方便的安装程序,如 DEB、RPM 和自解压安装程序

还有两个互动工具:

  • cmake-gui: 一个图形界面前端,帮助配置项目

  • ccmake:用于配置 CMake 的交互式终端 UI

cmake-gui工具可以方便地配置 CMake 构建并选择要使用的编译器:

图 1.1 – 配置项目后的 cmake-gui 界面

图 1.1 – 配置项目后的 cmake-gui 界面

如果你在控制台工作,但仍希望拥有交互式配置 CMake 的功能,那么ccmake是合适的工具。虽然它没有cmake-gui那么方便,但提供了相同的功能。当你必须通过ssh shell 或类似方式远程配置 CMake 时,这尤其有用:

图 1.2 – 使用 ccmake 配置项目

图 1.2 – 使用 ccmake 配置项目

CMake 相比于常规构建系统有许多优势。首先是跨平台特性。使用 CMake,你可以更容易地为各种编译器和平台创建构建指令,而无需深入了解各自构建系统的具体细节。

另外,CMake 能够发现系统库和依赖,这大大减少了寻找正确库文件来构建软件的麻烦。额外的好处是,CMake 与包管理器如 Conan 和 vcpkg 的集成非常顺畅。

CMake 不仅具备为多个平台构建软件的能力,还原生支持软件的测试、安装和打包,这使得 CMake 在构建软件时比单一构建系统更具优势。能够在一个统一的地方定义从构建、过度测试到打包的所有内容,对于长期维护项目极为有帮助。

CMake 本身对系统的依赖非常少,且可以在命令行上无须用户交互地运行,这使得它非常适合用于 CI/CD 流水线中的构建系统自动化。

现在我们已经简要介绍了 CMake 的功能,接下来让我们学习如何安装 CMake。

安装 CMake

CMake 可以从cmake.org/download/免费下载。它提供了预编译的二进制文件或源代码。对于大多数使用场景,预编译的二进制文件已经完全足够,但由于 CMake 本身依赖很少,构建一个版本也是可能的。

任何主要的 Linux 发行版都提供了 CMake 的安装包。虽然预打包的 CMake 版本通常不是最新发布版本,但如果系统经常更新,这些安装包通常足以使用。另一种方便的安装方式是使用 Python 包管理器pip

注意

本书中示例所需使用的最低 CMake 版本为3.23。我们建议你手动下载适当版本的 CMake,以确保获得正确的版本。

从源代码构建 CMake

CMake 是用 C++ 编写的,并使用 Make 构建自身。从零开始构建 CMake 是可能的,但对于大多数使用场景,使用二进制下载版本就足够了。

cmake.org/download/ 下载源代码包后,将其解压到一个文件夹,并运行以下命令:

./configure make

如果你还想构建 cmake-gui,可以使用 --qt-gui 选项进行配置。这要求你安装 Qt。配置过程可能会花些时间,但完成后,你可以使用以下命令安装 CMake:

make install

为了测试安装是否成功,你可以执行以下命令:

cmake --version

这将打印出 CMake 的版本,类似于这样:

cmake version 3.23.2
CMake suite is maintained and supported by Kitware (kitware.com/cmake).

现在,CMake 已经安装在你的机器上,你可以开始构建你的第一个项目了。让我们开始吧!

构建你的第一个项目

现在,是时候动手看看你的安装是否成功了。我们提供了一个简单的 hello world 项目的示例,你可以立即下载并构建。打开一个控制台,输入以下命令,你就可以开始了:

git clone   https://github.com/PacktPublishing/CMake-Best-Practices---2nd-Edition.git
cd CMake-Best-Practices---2nd-Edition/chapter01/simple_executable
cmake -S . -B build
cmake -–build ./build

这将生成一个名为 ch_simple_executable 的可执行文件,在控制台上输出 Welcome to CMake Best Practices

让我们详细看看发生了什么:

首先,使用 Git 检出示例仓库。示例 CMake 项目位于 chapter01/simple_executable 子文件夹中,构建前的文件结构如下所示:

.
├── CMakeLists.txt
└── src
    └── main.cpp

除了包含源代码的文件夹外,还有一个名为 CMakeLists.txt 的文件。该文件包含了 CMake 如何为项目创建构建指令及如何构建它的指令。每个 CMake 项目在项目根目录下都有一个 CMakeLists.txt 文件,但在各个子文件夹中可能还有多个同名的文件。

  1. 克隆完仓库后,构建过程通过 cmake –S . -B build 命令启动。这告诉 CMake 使用当前目录作为源目录,并使用名为 build 的目录来存放构建产物。我们将在本章稍后详细讨论源目录和构建目录的概念。CMake 的构建过程是一个两阶段的过程。第一步,通常称为 配置,读取 CMakeLists.txt 文件并生成本地构建工具链的指令。第二步,执行这些构建指令,构建出可执行文件或库。

在配置步骤中,检查构建要求,解决依赖关系,并生成构建指令。

  1. 配置项目时还会创建一个名为 CMakeCache.txt 的文件,包含创建构建指令所需的所有信息。接下来执行 cmake --build ./build 命令时,会通过内部调用 CMake 来执行构建;如果你使用的是 Windows,它会通过调用 Visual Studio 编译器来完成。这个步骤就是实际的二进制文件编译过程。如果一切顺利,build 文件夹中应该会有一个名为 ch1_simple_executable 的可执行文件。

在前面的示例中,我们通过传递 -S-B 命令行选项显式指定了源代码和构建文件夹。这通常是与 CMake 一起工作的推荐方法。还有一种更简短的方法,使用相对路径工作,这种方法在在线教程中也经常见到:

mkdir build
cd build
cmake ..
cmake --build

在这里,我们首先创建了构建文件夹,然后cd 进入该文件夹,并使用带有相对路径的 cmake。默认情况下,CMake 会假设它在要创建二进制文件和构建工件的文件夹中启动。

显式传递构建目录和源目录在使用 CMake 进行持续集成时通常很有用,因为明确指定有助于维护。如果你想为不同的配置创建不同的构建目录(例如在构建跨平台软件时),这也很有帮助。

那么,CMake 如何知道编译哪些文件以及创建哪些二进制文件呢?为此,它使用包含构建指令的文本文件,通常称为CMakeLists.txt

一个最小的 CMakeLists.txt 文件

对于一个非常简单的 hello world 示例,CMakeLists.txt 文件只包含几行指令:

cmake_minimum_required(VERSION 3.23)
project(
  "chapter1"
  VERSION 1.0
  DESCRIPTION "A simple project to demonstrate basic CMake usage"
  LANGUAGES CXX)
add_executable(ch1_simple_executable)
target_sources(ch1_simple_executable PRIVATE src/main.cpp)

让我们更详细地理解这些指令:

第一行定义了构建此项目所需的 CMake 最低版本。每个 CMakeLists.txt 文件都以此指令开始。该指令用于提醒用户,如果项目使用了仅在某个版本及以上的 CMake 特性,这时就会显示警告。一般来说,我们建议将版本设置为支持项目中使用特性所需的最低版本。

下一个指令是要构建项目的名称、版本和描述,之后是项目中使用的编程语言。这里,我们使用 CXX 来标记这是一个 C++ 项目。

add_executable 指令告诉 CMake 我们要构建一个可执行文件(与库或自定义工件不同,后者我们将在本书稍后介绍)。

target_sources 语句告诉 CMake 在哪里查找名为 ch1_simple_executable 的可执行文件的源代码,并且源代码的可见性仅限于该可执行文件。我们将在本书稍后部分详细介绍单个命令的具体内容。

恭喜——你现在可以使用 CMake 创建软件程序了。但是,要了解命令背后发生了什么,我们接下来将详细了解 CMake 构建过程。

理解 CMake 构建过程

CMake 的构建过程分为两个步骤,如下图所示。首先,如果没有使用任何特殊标志调用,CMake 会在配置过程中扫描系统,查找可用的工具链,然后决定输出结果应该是什么。第二步是在调用 cmake --build 时,实际的编译和构建过程。

图 1.3 – CMake 的两阶段构建过程

图 1.3 – CMake 的两阶段构建过程

标准输出是 Unix Makefiles,除非唯一检测到的编译器是 Microsoft Visual Studio,在这种情况下将创建一个 Visual Studio 解决方案(.sln)。

要更改生成器,可以将 -G 选项传递给 CMake,像这样:

cmake .. -G Ninja

这将生成供 Ninja 使用的文件(ninja-build.org/),Ninja 是一种替代的构建生成器。CMake 有许多可用的生成器。可以在 CMake 的官方网站上找到支持的生成器列表:cmake.org/cmake/help/latest/manual/cmake-generators.7.html

生成器主要分为两类——一种是有多种 Makefile 版本和 Ninja 生成器,通常从命令行使用,另一种是为 Visual Studio 或 Xcode 等 IDE 创建构建文件。

CMake 区分 单配置生成器多配置生成器。使用单配置生成器时,每次更改配置时必须重写构建文件;而多配置构建系统可以在不需要重新生成的情况下管理不同的配置。尽管本书中的示例使用单配置生成器,但它们也适用于多配置生成器。对于大多数示例,选择的生成器无关紧要;如果有区别,会特别提到:

生成器 多配置
Makefiles(所有版本)
Ninja
Ninja-Multi
Xcode
Visual Studio

此外,还有一些额外的生成器,它们使用普通的生成器,但还会为编辑器或 IDE 生成项目信息,例如 Sublime Text 2、Kate 编辑器、CodeBlocks 或 Eclipse。对于每个生成器,你可以选择编辑器是否应该使用 Make 或 Ninja 来内部构建应用程序。

调用后,CMake 会在 build 文件夹中创建许多文件,其中最显著的是 CMakeCache.txt 文件。这里存储了所有检测到的配置。缓存配置的主要好处是,后续的 CMake 运行速度更快。请注意,当使用 cmake-gui 时,第一步被拆分为配置项目和生成构建文件。然而,当从命令行运行时,这些步骤合并为一个。一旦配置完成,所有构建命令都会从 build 文件夹执行。

源文件夹和构建文件夹

在 CMake 中,存在两个逻辑文件夹。一是 source 文件夹,包含一个分层的项目集合;另一个是 build 文件夹,包含构建指令、缓存以及所有生成的二进制文件和产物。

source 文件夹的根目录是顶级 CMakeLists.txt 文件所在的位置。build 文件夹可以放在 source 文件夹内,但有些人喜欢将其放在其他位置。两者都可以,值得注意的是,本书中的示例决定将 build 文件夹放在 source 文件夹内。build 文件夹通常仅称为 build,但它可以有任何名称,包括针对不同平台的前后缀。在源代码树中使用 build 文件夹时,建议将其添加到 .gitignore 中,以避免意外提交。

配置 CMake 项目时,source 文件夹的项目和文件夹结构会在 build 文件夹中重新创建,从而使所有构建产物都位于相同的位置。在每个映射的文件夹中,会有一个名为 CMakeFiles 的子文件夹,其中包含 CMake 配置步骤生成的所有信息。

以下代码展示了一个 CMake 项目的示例结构:

├── chapter_1
│   ├── CMakeLists.txt
│   └── src
│       └── main.cpp
├── CMakeLists.txt

执行 CMake 配置时,CMake 项目的文件结构会映射到 build 文件夹中。每个包含 CMakeLists.txt 文件的文件夹都会被映射,并会创建一个名为 CMakeFiles 的子文件夹,其中包含 CMake 用于构建的内部信息:

├── build
│   ├── chapter_1
│   │   └── CMakeFiles
│   │   ├── CMakeCache.txt
│   └── CMakeFiles

在处理构建文件夹和构建配置时,一个重要的区分是单配置生成器与多配置生成器之间的区别。

单配置生成器和多配置生成器

在 CMake 中,生成器负责根据项目构建的平台以及开发者的偏好,创建本地构建系统(例如 Makefile、Visual Studio 解决方案)。CMake 中的两种主要生成器类型是单配置生成器和多配置生成器。

主要区别在于单配置生成器生成构建系统信息,其中每个构建配置(例如调试或发布)对应一个单独的构建目录。关于不同构建类型的更多细节将在本章后面介绍。如果切换构建配置(例如从调试构建切换到发布构建),用户必须选择一个不同的构建目录,否则之前的信息将被覆盖。

对于多配置生成器,一个构建目录可以包含多个配置,选择哪个配置仅在构建步骤中指定。

是否选择单配置生成器或多配置生成器,通常取决于个人偏好或某个操作系统中工具的便捷性。作为一个经验法则,可以说单配置生成器在命令行和 CI 环境中稍微更容易使用,而多配置生成器可能在 IDE 中有更好的集成。目前,我们已经使用现有的CMakeLists.txt来了解 CMake 的构建过程。我们学习了配置和构建步骤,以及生成器,并了解到我们需要CMakeLists.txt文件将必要的信息传递给 CMake。那么,接下来让我们更进一步,看看CMakeLists.txt文件的样子以及 CMake 语言是如何工作的。

编写 CMake 文件

当你编写 CMake 文件时,有一些核心概念和语言特性是你需要了解的。我们在这里不会涵盖语言的每个细节,因为 CMake 的文档已经做得相当不错,尤其是在全面性方面。接下来的章节将提供核心概念和语言特性的概述,后续章节会深入探讨不同方面的细节。

语言的完整文档可以在cmake.org/cmake/help/latest/manual/cmake-language.7.html找到。

CMake 语言 – 一个 10000 英尺的概览

CMake 使用名为CMakeLists.txt的配置文件来确定构建规范。这些文件是用一种脚本语言编写的,通常也叫做 CMake。该语言本身简单,支持变量、字符串函数、宏、函数定义和导入其他 CMake 文件。

除了列表外,没有对结构体或类等数据结构的支持。但正是这种相对简单性,使得 CMake 项目在正确执行时本质上更容易维护。

语法基于关键字和空格分隔的参数。例如,以下命令告诉 CMake 哪些文件需要添加到库中:

target_sources(MyLibrary
                PUBLIC include/api.h
                PRIVATE src/internals.cpp src/foo.cpp)

PUBLICPRIVATE关键字表示文件在与此库链接时的可见性,并且充当文件列表之间的分隔符。

此外,CMake 语言还支持所谓的“生成器表达式”,这些表达式在构建系统生成时进行评估。它们通常用于在项目的配置阶段,为每个生成的构建配置指定特定信息。它们将在本章后面的生成器 表达式部分进行详细介绍。

项目

CMake 将各种构建产物(如库、可执行文件、测试和文档)组织成项目。总是有一个根项目,尽管这些项目可以彼此封装。原则上,每个 CMakeLists.txt 文件中应该只有一个项目,这意味着每个项目必须在源目录中有一个单独的文件夹。

项目描述如下:

 project(
"chapter1"
VERSION 1.0
DESCRIPTION "A simple C++ project to demonstrate basic CMake usage"
LANGUAGES CXX
)

当前正在解析的项目存储在 PROJECT_NAME 变量中。对于根项目,这个信息也存储在 CMAKE_PROJECT_NAME 变量中,这对于判断一个项目是独立的还是被封装在另一个项目中非常有用。自版本 3.21 起,还引入了 PROJECT_IS_TOP_LEVEL 变量,用于直接判断当前项目是否为顶级项目。此外,使用 <PROJECT-NAME>_IS_TOP_LEVEL,可以检测某个特定项目是否为顶级项目。

以下是一些与项目相关的附加变量。对于根项目,所有这些变量都可以以 CMAKE_ 为前缀。如果在 project() 指令中没有定义它们,则这些字符串为空:

  • PROJECT_DESCRIPTION:项目的描述字符串

  • PROJECT_HOMEPAGE_URL:项目的 URL 字符串

  • PROJECT_VERSION:赋予项目的完整版本号

  • PROJECT_VERSION_MAJOR:版本字符串中的第一个数字

  • PROJECT_VERSION_MINOR:版本字符串中的第二个数字

  • PROJECT_VERSION_PATCH:版本字符串中的第三个数字

  • PROJECT_VERSION_TWEAK:版本字符串中的第四个数字

每个项目都有一个源目录和一个二进制目录,它们可能会彼此封装。假设以下示例中的每个 CMakeFiles.txt 文件都定义了一个项目:

.
├── CMakeLists.txt #defines project("CMakeBestPractices
"...)
├── chapter_1
│   ├── CMakeLists.txt # defines project("Chapter 1"...)

当解析根文件夹中的 CMakeLists.txt 文件时,PROJECT_NAMECMAKE_PROJECT_NAME 都将是 CMakeBestPractices。当你解析 chapter_1/CMakeLists.txt 时,PROJECT_NAME 变量将更改为 "Chapter_1",但 CMAKE_PROJECT_NAME 仍然保持为 CMakeBestPractices,这是根文件夹中的设置。

尽管项目可以嵌套,但最好以独立的方式编写它们,使其能够单独工作。虽然它们可能依赖于文件层次结构中较低的其他项目,但不应将某个项目作为另一个项目的子项目。可以在同一个 CMakeLists.txt 文件中多次调用 project(),但我们不推荐这种做法,因为它往往会使项目变得混乱,难以维护。通常,更好的做法是为每个项目创建一个单独的 CMakeLists.txt 文件,并通过子文件夹组织结构。

本书的 GitHub 仓库,包含了本书中的示例,采用层次化的方式组织,其中每个章节都是一个独立的项目,可能包含更多的项目来处理不同的部分和示例。

虽然每个示例都可以单独构建,但你也可以从仓库的根目录构建整个书籍项目。

变量

变量是 CMake 语言的核心部分。可以使用set命令设置变量,使用unset命令删除变量。变量名是区分大小写的。下面的示例展示了如何设置一个名为MYVAR的变量并将值1234赋给它:

set(MYVAR "1234")

要删除MYVAR变量,可以使用unset

unset(MYVAR)

一般的代码约定是将变量写成全大写字母。内部变量始终表示为字符串。

你可以通过$符号和花括号访问变量的值:

message(STATUS "The content of MYVAR are ${MYVAR}")

变量引用甚至可以嵌套,并按内外顺序进行评估:

${outer_${inner_variable}_variable}

变量可能具有以下作用域:

  • 函数作用域:在函数内部设置的变量仅在该函数内可见。

  • 目录作用域:源树中的每个子目录都会绑定变量,并包括父目录中的任何变量绑定。

  • 持久缓存:缓存变量可以是系统定义的或用户定义的。这些变量会在多次运行中保持其值。

PARENT_SCOPE选项传递给set()会使变量在父作用域中可见。

CMake 提供了多种预定义变量。这些变量以CMAKE_为前缀。完整列表可在cmake.org/cmake/help/latest/manual/cmake-variables.7.html查看。

列表

尽管 CMake 内部将变量存储为字符串,但可以通过用分号分隔值来处理 CMake 中的列表。列表可以通过将多个未加引号的变量传递给set(),或者直接传递一个分号分隔的字符串来创建:

set(MYLIST abc def ghi)
 set(MYLIST "abc;def;ghi")

可以使用list命令来操作列表,修改其内容、重新排序或查找项。以下代码将查询MYLISTabc值的索引,并检索该值并将其存储在名为ABC的变量中:

list(FIND MYLIST abc ABC_INDEX)
list(GET MYLIST ${ABC_INDEX} ABC)

要向列表中添加一个值,可以使用APPEND关键字。这里,xyz值被追加到MYLIST中:

list(APPEND MYLIST "xyz")

缓存变量和选项

CMake 会缓存某些变量,以便在随后的构建中运行得更快。这些变量存储在CMakeCache.txt文件中。通常,你不需要手动编辑它们,但它们在调试行为异常的构建时非常有用。

所有用于配置构建的变量都会被缓存。要缓存一个名为ch1_MYVAR、值为foo的自定义变量,可以使用set命令,如下所示:

 set(ch1_MYVAR foo CACHE STRING "Variable foo that configures bar")

请注意,缓存变量必须具有类型和文档字符串,以提供其简要总结。

大多数自动生成的缓存变量都标记为高级,这意味着它们在cmake-guiccmake中默认是隐藏的。要使它们可见,必须显式切换它们。如果CMakeLists.txt文件生成了其他缓存变量,它们也可以通过调用mark_as_advanced(MYVAR)命令来隐藏:

图 1.4 – 左侧 – cmake-gui 不显示标记为高级的变量。右侧 – 标记“高级”复选框会显示所有标记为高级的变量

图 1.4 – 左侧 – cmake-gui 不显示标记为高级的变量。右侧 – 标记“高级”复选框会显示所有标记为高级的变量

一条经验法则是,任何用户不应更改的选项或变量应标记为高级。标记变量为高级的常见情况是在编写 CMake 模块或查找依赖项时,正如我们在第五章《集成第三方库和依赖管理》和第十三章《重用 CMake 代码》中所见。

对于简单的布尔缓存变量,CMake 还提供了 option 关键字,默认值为 OFF,除非另行指定。这些变量也可以通过 CMakeDependentOption 模块相互依赖:

option(CHAPTER1_PRINT_LANGUAGE_EXAMPLES "Print examples for each
  language" OFF)
include(CMakeDependentOption)
cmake_dependent_option(CHAPTER1_PRINT_HELLO_WORLD "print a greeting
  from chapter1 " ON CHAPTER1_PRINT_LANGUAGE_EXAMPLES ON)

选项通常是指定简单项目配置的便捷方式。它们是 bool 类型的缓存变量。如果已经存在与选项同名的变量,则调用 option 不会执行任何操作。

属性

CMake 中的属性是附加到特定对象或 CMake 范围的值,如文件、目标、目录或测试用例。可以通过使用 set_property 函数来设置或更改属性。要读取属性的值,可以使用 get_property 函数,它遵循类似的模式。默认情况下,set_property 会覆盖已经存储在属性中的值。可以通过将 APPENDAPPEND_STRING 传递给 set_property 来将值添加到当前值中。

完整的函数签名如下:

set_property(<Scope> <EntityName>
              [APPEND] [APPEND_STRING]
              PROPERTY <propertyName> [<values>])

范围说明符可以具有以下值:

  • GLOBAL:影响整个构建过程的全局属性。

  • DIRECTORY <dir>:绑定到当前目录或 <dir> 中指定目录的属性。也可以通过使用 set_directory_properties 命令直接设置。

  • TARGET <targets>:特定目标的属性。也可以通过使用 set_target_properties 函数来设置。

  • SOURCE <files>:将一个属性应用于源文件列表。也可以通过使用 set_source_files_properties 直接设置。此外,还有 SOURCE DIRECTORYSOURCE TARGET_DIRECTORY 扩展选项:

    • DIRECTORY <dirs>:为目录范围内的源文件设置属性。该目录必须已经由 CMake 解析,或者是当前目录,或者是通过 add_subdirectory 添加的。

    • TARGET_DIRECTORY <targets>:将属性设置为指定目标所在的目录。同样,目标必须在设置属性时已经存在。

  • INSTALL <files>:为已安装的文件设置属性。这些可以用于控制 cpack 的行为。

  • TEST <tests>:设置测试的属性。也可以通过set_test_properties直接设置。

  • CACHE <entry>:设置缓存变量的属性。最常见的包括将变量设置为高级选项或为其添加文档字符串。

支持的属性完整列表,按其不同实体排序,可以在cmake.org/cmake/help/latest/manual/cmake-properties.7.html找到。

在修改属性时,使用诸如set_target_propertiesset_test_properties等直接函数比使用更通用的set_property命令更好。使用显式命令可以避免错误和属性名称混淆,通常更具可读性。还有define_property函数,它创建一个没有设置值的属性。我们建议你不要使用它,因为属性应该始终有一个合理的默认值。

循环和条件

像任何编程语言一样,CMake 支持条件和循环块。条件块位于if()elseif()else()endif()语句之间。条件使用各种关键字表达。

一元关键字位于值之前,如下所示:

 if(DEFINED MY_VAR)

用于条件的一元关键字如下:

  • COMMAND:如果提供的值是命令,则为true

  • DEFINED:如果值是已定义的变量,则为true

此外,还有一元文件系统条件:

  • EXISTS:如果传递的文件或目录存在,则为true

  • IS_DIRECTORY:检查提供的路径是否是一个目录

  • IS_SYMLINK:如果提供的路径是符号链接,则为true

  • IS_ABSOLUTE:检查提供的路径是否是绝对路径

二元测试比较两个值,并将它们放在需要比较的值之间,如下所示:

if(MYVAR STREQUAL "FOO")

二元运算符如下:

  • LESSGREATEREQUALLESS_EQUALGREATER_EQUAL:这些比较数值。

  • STRLESSSTREQUALSTRGREATERSTRLESS_EQUALSTRGREATER_EQUAL:这些按字典顺序比较字符串。

  • VERSION_LESSVERSION_EQUALVERSION_GREATERVERSION_LESS_EQUALVERSION_GREATER_EQUAL:这些比较版本字符串。

  • MATCHES:这些与正则表达式进行比较。

  • IS_NEWER_THAN:检查传递的两个文件中哪个文件最近被修改。不幸的是,这并不十分精确,因为如果两个文件有相同的时间戳,它也会返回true。还有更多的混淆,因为如果任一文件缺失,结果也会是true

最后,还有布尔运算符ORANDNOT

循环可以通过while()endwhile()foreach()endforeach()实现。循环可以通过break()终止;continue()会中止当前的迭代并立即开始下一次迭代。

while循环的条件与if语句相同。下面的例子在MYVAR小于5时循环。请注意,为了增加变量值,我们使用了math()函数:

 set(MYVAR 0)
while(MYVAR LESS "5")
  message(STATUS "Chapter1: MYVAR is '${MYVAR}'")
  math(EXPR MYVAR "${MYVAR}+1")
endwhile()

除了while循环,CMake 还提供了用于遍历列表或范围的循环:

foreach(ITEM IN LISTS MYLIST)
# do something with ${ITEM}
endforeach()

for循环可以通过使用RANGE关键字在特定范围内创建:

foreach(ITEM RANGE 0 10)
# do something with ${ITEM}
endforeach()

尽管foreach()RANGE版本只需要一个stop变量就能工作,但最好还是始终指定起始和结束值。

函数

函数由function()/endfunction()定义。函数为变量开启了新的作用域,因此在函数内部定义的所有变量在外部不可访问,除非将PARENT_SCOPE选项传递给set()

函数不区分大小写,通过调用function的名称并加上圆括号来调用:

function(foo ARG1)
# do something
endfunction()
# invoke foo with parameter bar
foo("bar")

函数是让你的 CMake 部分可重用的好方法,通常在处理较大项目时非常有用。

CMake 宏通过macro()/endmacro()命令定义。它们有点像函数,不同之处在于函数中的参数是真正的变量,而宏中的参数是字符串替换。这意味着宏的所有参数必须使用花括号访问。

另一个区别是通过调用函数,控制权会转移到函数中。宏的执行方式像是将宏的主体粘贴到调用位置一样。这意味着宏不会创建与变量和控制流相关的作用域。因此,强烈建议避免在宏中调用return(),因为这会阻止宏调用位置的作用域执行。

目标

CMake 的构建系统是作为一组逻辑目标组织的,这些目标对应于可执行文件、库或自定义命令或工件,例如文档或类似的内容。

在 CMake 中有三种主要方式来创建目标——add_executableadd_libraryadd_custom_target。前两个用于创建可执行文件和静态或共享库,而第三个则可以包含几乎任何自定义命令来执行。

目标可以相互依赖,以确保一个目标在另一个目标之前构建。

在设置构建配置或编译器选项的属性时,最好使用目标而不是全局变量。一些目标属性有可见性修饰符,如PRIVATEPUBLICINTERFACE,用以表示哪些要求是传递性的——也就是说,哪些属性必须被依赖的目标“继承”。

生成器表达式

生成器表达式是构建配置阶段评估的小语句。大多数函数允许使用生成器表达式,但也有少数例外。生成器表达式的形式为$<OPERATOR:VALUE>,其中OPERATOR应用或比较VALUE。你可以将生成器表达式看作是小型的内联if语句。

在下面的示例中,使用生成器表达式根据构建配置是调试还是发布,设置my_target的不同编译定义:

target_compile_definitions(my_target PRIVATE
    $<$<CONFIG:Debug>:MY_DEBUG_FLAG>
    $<$<CONFIG:Release>:MY_RELEASE_FLAG>
)

这个示例告诉 CMake 评估CONFIG变量,值可以是DebugRelease,如果匹配其中一个,则为my_target目标定义MY_DEBUG_FLAGMY_RELEASE_FLAG。生成器表达式在编写平台和编译器独立的 CMake 文件时非常有用。除了查询值之外,生成器表达式还可以用来转换字符串和列表:

$<LOWER_CASE:CMake>

这将输出cmake

你可以在cmake.org/cmake/help/latest/manual/cmake-generator-expressions.7.html了解更多关于生成器表达式的信息。

大多数 CMake 命令都能处理生成器表达式,但有一些显著的例外,如用于文件操作的file()命令和在配置步骤中调用第三方程序的execute_process()命令。

另一个需要注意的事项是,在配置或生成步骤的哪个阶段可以使用哪些生成器表达式。例如,对于多配置生成器,$<CONFIG:...>在配置步骤期间可能未设置,因为构建配置通常只在构建步骤期间传递。在下一节中,我们将学习如何告诉 CMake 使用哪个工具链,以及如何配置不同的构建类型,如调试或发布。

CMake 策略

对于顶层的CMakeLists.txt文件,cmake_minimum_required必须在任何项目调用之前调用,因为它还设置了用于构建项目的 CMake 内部策略。

策略用于在多个 CMake 版本之间保持向后兼容性。它们可以配置为使用OLD行为,这意味着cmake表现出向后兼容性,或者使用NEW,这意味着新策略生效。由于每个新版本都会引入新规则和新特性,策略将用于警告你可能存在的向后兼容性问题。策略可以通过cmake_policy调用进行禁用或启用。

在下面的示例中,CMP0121策略已设置为向后兼容的值。CMP0121是在 CMake 3.21 版本中引入的,它检查list()命令的索引变量是否符合有效格式——即它们是否为整数:

cmake_minimum_required(VERSION 3.21)
cmake_policy(SET CMP0121 OLD)
list(APPEND MYLIST "abc;def;ghi")
list(GET MYLIST "any" OUT_VAR)

通过设置cmake_policy(SET CMP0121 OLD),启用了向后兼容性,尽管前面的代码访问了MYLIST"any"索引(不是整数),但不会产生警告。

将策略设置为NEW将在 CMake 配置步骤中抛出一个错误——[build] list index: any is not a valid index

除非你正在包含遗留项目,否则避免设置单一策略。

通常,应该通过设置 cmake_minimum_required 命令来控制策略,而不是通过更改单个策略。更改单个策略的最常见使用场景是将遗留项目作为子文件夹包含时。

到目前为止,我们已经介绍了 CMake 语言背后的基本概念,它用于配置构建系统。CMake 用于为不同类型的构建和语言生成构建指令。在下一节中,我们将学习如何指定要使用的编译器,以及如何配置构建。

不同的工具链和构建类型

CMake 的强大之处在于,你可以使用相同的构建规范——即 CMakeLists.txt——来适配不同的编译器工具链,而无需重写任何内容。一个工具链通常由一系列程序组成,能够编译和链接二进制文件,创建归档文件等。

CMake 支持多种语言,可以为其配置工具链。本书将重点讲解 C++。为不同编程语言配置工具链的方法是,将以下变量中的 CXX 部分替换为相应的语言标签:

  • C

  • CXX – C++

  • CUDA

  • OBJC – Objective C

  • OBJCXX – Objective C++

  • Fortran

  • HIP – NVIDIA 和 AMD GPU 的 HIP C++ 运行时 API

  • ISPC – 基于 C 的 SPMD 编程语言

  • ASM – 汇编器

如果项目没有指定语言,则默认假设使用 C 和 CXX。

CMake 会通过检查系统自动检测要使用的工具链,但如果需要,可以通过环境变量进行配置,或者在交叉编译的情况下,通过提供工具链文件来配置。这个工具链会存储在缓存中,因此如果工具链发生变化,必须删除并重新构建缓存。如果安装了多个编译器,可以通过在调用 CMake 前设置环境变量(如 CC 用于 C 编译器,CXX 用于 C++ 编译器)来指定非默认的编译器。在这里,我们使用 CXX 环境变量来覆盖 CMake 使用的默认编译器:

CXX=g++-7 cmake /path/to/the/source

或者,你可以通过传递相应的 cmake 变量并使用 -D 来覆盖使用的 C++ 编译器,如下所示:

cmake -D CMAKE_CXX_COMPILER=g++-7 /path/to/source

这两种方法都确保 CMake 使用 GCC 版本 7 来构建,而不是系统中可用的任何默认编译器。避免在 CMakeLists.txt 文件中设置编译器工具链,因为这与 CMake 文件应该是平台和编译器无关的理念相冲突。

默认情况下,链接器会由所选的编译器自动选择,但也可以通过传递链接器可执行文件的路径,并使用 CMAKE_CXX_LINKER 变量来选择不同的链接器。

构建类型

在构建 C++ 应用程序时,通常会有多种构建类型,例如包含所有调试符号的调试构建和经过优化的发布构建。

CMake 原生提供四种构建类型:

  • Debug:未优化,包含所有调试符号,并启用所有断言。与在 GCC 和 Clang 中设置 -O0 -g 相同。

  • Release:针对速度进行了优化,不包含调试符号,且禁用断言。通常,这就是发布时使用的构建类型。等同于 -O3 -DNDEBUG

  • RelWithDebInfo:提供优化后的代码,并包含调试符号,但禁用断言,等同于 -O2 -g -DNDEBUG

  • MinSizeRel:与 Release 相同,但针对小二进制文件大小进行了优化,而不是优化速度,这通常对应 -Os -DNDEBUG。注意,并非所有生成器在所有平台上都支持此配置。

请注意,构建类型必须在配置阶段传递给单配置生成器,例如 CMake 或 Ninja。对于多目标生成器,如 MSVC,它们不会在配置步骤中使用,而是在构建步骤中指定。也可以创建自定义构建类型,这可以方便地用于指定生成代码覆盖的构建,但通常并非所有编译器都能支持,因此需要一些谨慎。有关自定义构建类型的示例,请参见 第七章无缝集成代码质量工具与 CMake。由于 CMake 支持各种各样的工具链、生成器和语言,一个常见的问题是如何找到并维护这些选项的有效组合。在这里,预设可以提供帮助。

使用预设保持良好的构建配置

在使用 CMake 构建软件时,一个常见的问题是如何共享良好的或可用的配置来构建项目。通常,团队和个人都有偏好的构建产物存放位置、使用哪种生成器在某个平台上构建,或者希望 CI 环境使用与本地相同的设置进行构建。自 2020 年 12 月 CMake 3.19 发布以来,这些信息可以存储在 CMakePresets.json 文件中,该文件位于项目的根目录。此外,每个用户还可以通过 CMakeUserPresets.json 文件覆盖自己的配置。基本的预设通常会放在版本控制下,但用户的预设不会被提交到版本系统中。两个文件都遵循相同的 JSON 格式,顶层结构如下:

{
"version": 6,
"cmakeMinimumRequired": {
"major": 3,
"minor": 23,
"patch": 0
},
"configurePresets": [...],
"buildPresets": [...],
"testPresets": [...],
"packagePresets ": [...],
"workflowPresets": [...],}

第一行,"version": 6,表示 JSON 文件的架构版本。CMake 3.23 支持最多版本六,但预计新的版本发布将带来新的架构版本。

接下来,cmakeMinimumRequired{...} 指定了需要使用的 CMake 版本。虽然这是可选的,但最好将其包含在内,并确保版本与 CMakeLists.txt 文件中指定的版本匹配。

此后,可以通过 configurePresetsbuildPresetstestPresets 添加不同构建阶段的各种预设。如其名所示,configurePresets 适用于 CMake 构建过程的配置阶段,而其他两个则用于构建和测试阶段。构建和测试预设可以继承一个或多个配置预设。如果没有指定继承关系,它们将应用于所有先前的步骤。

要查看项目中已配置的预设,可以运行 cmake --list-presets 查看可用预设的列表。要使用预设进行构建,运行 cmake --build --``preset name

要查看 JSON 模式的完整规格,请访问 cmake.org/cmake/help/v3.21/manual/cmake-presets.7.html

预设是共享如何以非常明确的方式构建项目知识的好方法。撰写本文时,越来越多的 IDE 和编辑器原生支持 CMake 预设,尤其是在处理跨编译和工具链时。在这里,我们只为你提供了 CMake 预设的简要概述;它们将在 第九章 中更深入地介绍,创建可重复的 构建 环境

总结

在本章中,我们为你提供了 CMake 的简要概述。首先,你学习了如何安装并运行一个简单的构建。接着,你了解了 CMake 的两阶段构建过程以及单配置生成器和多配置生成器。

到现在为止,你应该能够构建本书 GitHub 仓库中提供的示例:github.com/PacktPublishing/CMake-Best-Practices---2nd-Edition。你学习了 CMake 语言的核心功能,如变量、目标和策略。我们简要介绍了函数和宏,以及用于流程控制的条件语句和循环。随着你继续阅读本书,你将运用到目前为止所学的内容,探索更多好的实践和技巧,将从简单的单目标项目过渡到复杂的软件项目,并通过良好的 CMake 配置保持其可维护性。

在下一章中,我们将学习如何执行 CMake 中一些最常见的任务,以及 CMake 如何与各种 IDE 配合工作。

进一步阅读

要了解更多关于本章中讨论的主题,请参考以下资源:

问题

回答以下问题,以测试你对本章内容的理解:

  1. 如何启动 CMake 的配置步骤?

  2. 单配置生成器和多配置生成器之间有什么区别?

  3. 如何启动 CMake 的构建步骤?

  4. 哪个 CMake 可执行文件可以用于运行测试?

  5. 哪个 CMake 可执行文件用于打包?

  6. CMake 中的目标是什么?

  7. 属性和变量有什么区别?

  8. CMake 预设的用途是什么?

答案

以下是前面问题的答案:

  1. 你可以通过以下命令开始 CMake 的配置步骤:

    cmake -S /path/to/source -``B /path/to/build

  2. 单配置生成器只会生成一个类型构建的构建文件,例如调试或发布构建。多配置生成器会一次性生成所有可用构建类型的构建指令。

  3. 你可以使用以下命令开始 CMake 的构建步骤:

    cmake -build /path/to/build
    
  4. 以下是可以用于运行测试的 CMake 可执行文件:

    ctest
    
  5. 以下是可以用于打包的 CMake 可执行文件:

    cpack
    
  6. CMake 中的目标是围绕构建组织的逻辑单元。它们可以是可执行文件、库,或包含自定义命令。

  7. 与变量不同,属性是附加到特定对象或作用域上的。

  8. CMake 预设用于共享构建的工作配置。

第二章:以最佳方式访问 CMake

在上一章中,我们已经了解了 CMake 并学习了它的基本概念。现在,我们将学习如何与它交互。学习如何与 CMake 交互非常重要。在开始使用 CMake 构建你的软件项目之前,你必须先学会如何配置、构建和安装现有项目。这将使你能够与 CMake 项目进行交互。

本章将探讨 CMake 作为一个界面所提供的功能,并检查一些流行的 IDE 和编辑器集成。本章将涵盖以下内容:

  • 通过命令行界面使用 CMake

  • 使用 cmake-guiccmake 界面

  • IDE 和编辑器集成(Visual Studio、Visual Studio CodeVSCode)和 Qt Creator)

由于我们有很多内容要讲解,因此不要浪费时间,直接开始技术要求。

技术要求

在深入详细内容之前,有一些要求需要满足,才能跟上示例的步伐:

  • CMake 最佳实践库:这是包含本书所有示例内容的主要库。可以在线访问:github.com/PacktPublishing/CMake-Best-Practices---2nd-Edition/

  • 后续章节中关于打包和依赖管理的一些示例使用了 OpenSSL 来说明如何与第三方库一起工作。要安装它,可以使用操作系统提供的包管理器,例如 apt-getchocolateybrew,或者从 OpenSSL wiki 中提供的任何链接下载:wiki.openssl.org/index.php/Binaries。选择 OpenSSL 是因为它可以在多种平台上免费使用,并且易于安装。

通过命令行界面使用 CMake

尽管 CMake 已经很好地集成到许多 IDE 和编辑器中,但它本质上是一个 命令行 工具,因此学习如何在 命令行界面 (CLI) 中使用 CMake 是充分发挥其潜力的关键。通过命令行使用 CMake 还可以帮助理解 CMake 的内部工作原理和概念。在本节中,我们将学习如何使用 CLI 执行最基本的 CMake 操作。

与 CMake CLI 的交互可以通过在操作系统终端中输入 cmake 命令来完成,前提是已安装 CMake 且 cmake 可执行文件已包含在系统的 PATH 变量(或等效项)中。你可以通过在终端中输入 cmake 而不带任何参数来验证这一点,如下图所示:

图 2.1 – 调用 cmake 命令

图 2.1 – 调用 cmake 命令

如果您的终端提示缺少命令,您应该安装 CMake(在 第一章启动 CMake 中有详细说明),或通过将其添加到系统的 PATH 变量中使其可被发现。请参考您的操作系统指南,了解如何将路径添加到系统的 PATH 变量。

安装 CMake 并将其添加到 PATH 变量中(如果需要),之后您应该测试 CMake 是否可用。您可以在命令行中执行的最基本命令是 cmake --version,该命令可以让您检查 CMake 的版本:

图 2.2 – 在终端中检查 CMake 版本

图 2.2 – 在终端中检查 CMake 版本

CMake 将以 cmake version <maj.min.rev> 的形式输出版本字符串。您应该看到一个包含您安装的 CMake 版本号的输出。

注意

如果版本与已安装的版本不匹配,可能是您的系统上安装了多个 CMake 版本。由于本书中的示例是为 CMake 版本 3.23 及以上编写的,建议在继续之前先解决该问题。

安装 CMake 后,您还应该安装构建系统和编译器。对于 Debian 类操作系统(例如 Debian 和 Ubuntu),可以通过执行 sudo apt install build-essential 命令轻松完成。此软件包本质上包含 gccg++make

CLI 的使用将在 Ubuntu 22.04 环境中进行演示。除了少数边缘情况外,其他环境中的使用方法相同。那些边缘情况将在后续中提到。

学习 CMake CLI 基础知识

你应该学习的关于使用 CMake CLI 的三个基本知识点如下:

  • 配置 CMake 项目

  • 构建 CMake 项目

  • 安装 CMake 项目

学习基础知识后,您将能够构建并安装任何您选择的 CMake 项目。让我们从配置开始。

通过 CLI 配置项目

要通过命令行配置 CMake 项目,您可以使用 cmake -G "Unix Makefiles" -S <project_root> -B <output_directory> 结构。-S 参数用于指定要配置的 CMake 项目,而 -B 指定 配置 输出目录。最后,-G 参数允许我们指定用于生成构建系统的生成器。配置过程的结果将写入 <output_directory>

作为示例,让我们将本书的示例项目配置到项目根目录 build 目录中:

图 2.3 – 克隆示例代码库

图 2.3 – 克隆示例代码库

重要提示

项目必须已经存在于您的环境中。如果没有,请在终端中执行 git clone https://github.com/PacktPublishing/CMake-Best-Practices---2nd-Edition.git 通过 Git 克隆该项目。

现在进入 CMake-Best-Practices---2nd-Edition/chapter02/simple_example 目录并执行 cmake -G "Unix Makefiles" -S . -B ./build,如以下截图所示:

图 2.4 – 使用 CMake 配置示例代码

图 2.4 – 使用 CMake 配置示例代码

这个命令就像是对 CMake 说,使用“Unix Makefiles”(-G "Unix Makefiles") 生成器在当前目录(-S .)为 CMake 项目生成构建系统,并将其输出到构建(-B ./build)目录

CMake 将把当前文件夹中的项目配置到 build 文件夹中。由于我们省略了构建类型,CMake 使用了 Debug 构建类型(这是项目的默认 CMAKE_BUILD_TYPE 值)。

在接下来的部分中,我们将了解在配置步骤中使用的基本设置。

更改构建类型

CMake 默认情况下不会假定任何构建类型。要设置构建类型,必须向 configure 命令提供一个名为 CMAKE_BUILD_TYPE 的额外变量。要提供额外的变量,变量必须以 -D 为前缀。

要获取 Release 构建而不是 Debug,请在 configure 命令中添加 CMAKE_BUILD_TYPE 变量,如前所述:cmake -G "Unix Makefiles" -S . -B ./build

注意

CMAKE_BUILD_TYPE 变量仅适用于单配置生成器,例如 Unix Makefiles 和 Ninja。在多配置生成器中,如 Visual Studio,构建类型是一个构建时参数,而不是配置时参数。因此,不能通过使用 CMAKE_BUILD_TYPE 参数来配置。请参见 为多配置生成器安装特定配置 部分,了解如何在这些生成器中更改构建类型。

更改生成器类型

根据环境,CMake 默认尝试选择合适的生成器。要显式指定生成器,必须提供 -G 参数,并指定一个有效的生成器名称。例如,如果您想使用 Ninja 作为构建系统而不是 make,可以按如下方式更改:

cmake -G "Ninja" -DCMAKE_BUILD_TYPE=Debug -S . -B ./build

输出应与以下图中所示的命令输出相似:

图 2.5 – 检查 CMake 的 Ninja 生成器输出

图 2.5 – 检查 CMake 的 Ninja 生成器输出

这将导致 CMake 生成 Ninja 构建文件,而不是 Makefiles。

为了查看您环境中所有可用的生成器类型,可以执行 cmake --help 命令。可用的生成器将在 Help text generators 部分的末尾列出,如下所示:

图 2.6 – 帮助中可用生成器的列表

图 2.6 – 帮助中可用生成器的列表

带有星号的生成器是您当前环境的默认生成器。

更改编译器

在 CMake 中,要使用的编译器是通过每种语言的 CMAKE_<LANG>_COMPILER 变量来指定的。为了更改某种语言的编译器,必须将 CMAKE_<LANG>_COMPILER 参数传递给 Configure 命令。对于 C/C++ 项目,通常被覆盖的变量是 CMAKE_C_COMPILER(C 编译器)和 CMAKE_CXX_COMPILER(C++ 编译器)。编译器标志同样由 CMAKE_<LANG>_FLAGS 变量控制。此变量可用于存储与配置无关的编译器标志。

作为示例,让我们尝试在一个 g++-12 不是默认编译器的环境中使用它作为 C++ 编译器:

cmake -G "Unix Makefiles" -DCMAKE_CXX_COMPILER=/usr/bin/g++-12 -S .
   -B ./build

在这里,我们可以看到使用的是 g++-12,而不是系统默认的编译器 g++-11

图 2.7 – 使用不同编译器(g++-10)配置项目

图 2.7 – 使用不同编译器(g++-10)配置项目

如果没有指定编译器,CMake 会优先在此环境中使用 g++-9

图 2.8 – 无编译器偏好配置行为

图 2.8 – 无编译器偏好配置行为

将标志传递给编译器

为了说明如何指定编译器标志,假设你想启用所有警告并将其视为错误。这些行为分别通过 gcc 工具链中的 -Wall-Werror 编译器标志进行控制;因此,我们需要将这些标志传递给 C++ 编译器。以下代码说明了如何实现:

cmake -G "Unix Makefiles" -DCMAKE_CXX_FLAGS="-Wall -Werror"
-S . -B ./build

我们可以看到,在下面的示例中,命令中指定的标志(-Wall-Werror)被传递给了编译器:

图 2.9 – 将标志传递给 C++ 编译器

图 2.9 – 将标志传递给 C++ 编译器

构建标志可以通过在其后添加大写的构建类型字符串来为每种构建类型定制。以下列出了四个不同构建类型的四个变量。它们在根据编译器标志指定构建类型时非常有用。仅当配置的构建类型匹配时,指定在这些变量中的标志才有效:

  • CMAKE_<LANG>_FLAGS_DEBUG

  • CMAKE_<LANG>_FLAGS_RELEASE

  • CMAKE_<LANG>_FLAGS_RELWITHDEBINFO

  • CMAKE_<LANG>_FLAGS_MINSIZEREL

除了前面的示例,如果你只想在 Release 构建中将警告视为错误,构建类型特定的编译器标志可以让你做到这一点。

这是一个说明如何使用构建类型特定编译器标志的示例:

cmake -G "Unix Makefiles" -DCMAKE_CXX_FLAGS="-Wall -Werror" -DCMAKE_CXX_FLAGS_RELEASE="-fpermissive" -DCMAKE_BUILD_TYPE=Debug -S . -B ./build

请注意,在前面的命令中存在一个额外的 CMAKE_CXX_FLAGS_RELEASE 参数。只有在构建类型为 Release 时,这个变量中的内容才会被传递给编译器。由于构建类型被指定为 Debug,我们可以看到传递给编译器的标志中没有 -fpermissive 标志,如下图所示:

图 2.10 – 根据构建类型指定标志;在 Debug 构建中缺少 –fpermissive 标志

图 2.10 – 基于构建类型指定标志;在 Debug 构建中缺少 -fpermissive 标志

图 2.10 中,注意到 -fpermissive 标志在构建命令中没有出现,而且 grep 的结果为空。这证实了 CMAKE_CXX_FLAGS_RELEASE 变量在 Debug 构建类型中没有被使用。当构建类型指定为 Release 时,我们可以看到 -O3 标志存在:

cmake -G "Unix Makefiles" -DCMAKE_CXX_FLAGS="-Wall -Werror" -
  DCMAKE_CXX_FLAGS_RELEASE="-fpermissive" -DCMAKE_BUILD_TYPE=
    Release -S . -B ./build

在这一行中,你告诉 CMake,在当前目录中配置 CMake 项目并将其构建到 build/ 文件夹,使用 “Unix Makefiles” 生成器。对于所有构建类型,毫不犹豫地将 -Wall 和 –Werror 标志传递给编译器。如果构建类型是 Release,还需要传递 -fpermissive 标志。

这是当构建类型设置为 Release 时命令的输出:

图 2.11 – 基于构建类型指定标志;在 Release 构建中存在 -fpermissive 标志

图 2.11 – 基于构建类型指定标志;在 Release 构建中存在 -fpermissive 标志

图 2.11 中,我们可以确认 -fpermissive 标志也传递给了编译器。请注意,尽管 RelWithDebInfoMinSizeRel 也是 Release 构建,但它们与 Release 构建类型是不同的,因此在 CMAKE_<LANG>_FLAGS_RELEASE 变量中指定的标志不会应用到它们。

快捷方式 – 使用 CMake 预设

在命令行中使用 CMake 提供了大量的配置选项,这给予了对构建过程的很多控制。然而,它也可能变得相当困难,因为需要跟踪项目的各种配置所需的标志和参数组合。在 CMake 3.21 引入 CMake 预设 之前,跟踪构建项目所需的所有不同标志可能是一个相当大的挑战。但幸运的是,CMake 预设简化了很多繁琐的工作,因为几乎所有通过命令行传递给 CMake 的选项都可以在预设中表示。这就是为什么它们成为预先配置各种 CMake 选项组合的好方法。我们将在 第九章 中深入探讨 CMake 预设,创建可复现的构建环境,但如今越来越多的项目已经预先提供了预设。

要列出所有可用的预设,请使用以下命令:

cmake --list-presets

要使用预设调用来配置项目,请使用以下命令:

cmake --preset my-preset-name

提示

一旦你熟悉了如何配置 CMake 的基本选项,我们强烈建议使用 CMake 预设,以便轻松管理所有不同的构建配置、编译器标志等。

列出缓存变量

你可以通过执行 cmake -L ./build/ 命令列出所有缓存变量(见 图 2.12)。默认情况下,这不会显示与每个变量相关的高级变量和帮助字符串。如果你想同时显示它们,请改用 cmake -LAH ./build/ 命令。

图 2.12 – CMake 导出的缓存变量列表

图 2.12 – CMake 导出的缓存变量列表

通过 CLI 构建配置好的项目

要构建配置好的项目,执行 cmake --build ./build 命令。

此命令告诉 CMake 构建已经在 构建文件夹中配置好的 CMake 项目

您也可以等效地执行 cd build && make。使用 cmake --build 的好处是它使您无需调用特定于构建系统的命令。当构建 CI 流水线或构建脚本时,它尤其有用。通过这种方式,您可以更改构建系统生成器,而不必更改构建命令。

您可以在以下示例中看到 cmake --build ./build 命令的输出示例:

图 2.13 – 构建配置好的项目

图 2.13 – 构建配置好的项目

并行构建

在执行构建命令时,您还可以自定义构建时间的细节。最显著的构建时间配置是用于构建项目的作业数量。要指定作业数,可以将 --parallel <job_count> 添加到您的 cmake --build 命令中。

要进行并行构建,执行 cmake --build ./build --parallel 2,其中数字 2 表示作业数。构建系统推荐的作业数量是最多每个硬件线程一个作业。在多核系统中,还建议使用比可用硬件线程数少至少一个作业数,以避免在构建过程中影响系统的响应能力。

注意

通常,您可以在每个硬件线程上使用多个作业并获得更快的构建时间,因为构建过程大多数是 I/O 限制的,但效果可能因人而异。请进行实验并观察。

此外,一些构建系统,如 Ninja,将尽量利用系统中可用的所有硬件线程,因此,如果您的目标是使用系统中的所有硬件线程,则为这些构建系统指定作业数是多余的。您可以通过在 Linux 环境中执行 nproc 命令来获取硬件线程数。

在期望在不同环境中调用的命令中,最好不要为依赖环境的变量使用固定值,例如 CI/CD 脚本和构建脚本。下面是一个示例 build 命令,利用 nproc 动态确定并行作业的数量:

cmake --build ./build/ --parallel $(($(nproc)-1))

让我们观察不同的作业数量如何影响构建时间。我们将使用 time 工具来测量每次命令执行的时间。环境详情如下:

  • 操作系统: Ubuntu 22.04

  • CPU: 第 11 代 Intel i9-11900H @2.5GHz

  • 内存: 32 GB

使用一个作业(--parallel 1),构建时间结果如下:

图 2.14 – 使用一个作业的并行构建时间结果

图 2.14 – 使用一个作业的并行构建时间结果

使用两个作业(--parallel 4)的构建时间结果如下:

图 2.15 – 使用两个任务的并行构建时间结果

图 2.15 – 使用两个任务的并行构建时间结果

即使是在一个非常简单的项目中,我们也能清楚地看到额外的任务如何帮助加快构建时间。

仅构建特定目标

默认情况下,CMake 会构建所有已配置的可用目标。由于构建所有目标并不总是理想的,CMake 允许通过 --target 子选项来构建目标的子集。该子选项可以多次指定,如下所示:

cmake --build ./build/ --target "ch2_framework_component1" --target
  "ch2_framework_component2"

此命令将构建范围限制为仅包括 ch2_framework_component1ch2_framework_component2 目标。如果这些目标还依赖于其他目标,它们也将被构建。

在构建之前删除之前的构建产物

如果你想执行一次干净的构建,可能需要先删除之前运行时生成的产物。为此,可以使用 --clean-first 子选项。这个子选项会调用一个特殊的目标,清除构建过程中生成的所有产物(执行 make clean)。

这里是一个如何为名为 build 的构建文件夹执行此操作的示例:

cmake --build ./build/ --clean-first

调试你的构建过程

正如我们在前面 传递标志给编译器 部分所做的那样,你可能希望检查在构建过程中哪些命令被调用,以及它们使用了哪些参数。--verbose 子命令指示 CMake 在支持详细模式的命令下,以详细模式调用所有构建命令。这使我们能够轻松调查棘手的编译和链接错误。

要以详细模式构建名为 build 的文件夹,请按如下示例调用 --build

cmake --build ./build/ --verbose

向构建工具传递命令行参数

如果你需要将参数传递给底层构建工具,可以在命令末尾添加 -- 并写下将要传递的参数:

cmake --build ./build/ -- --trace

在前述的情况下,--trace 将直接传递给构建工具,在我们例子中是 make。这将使 make 打印每个构建配方的追踪信息。

通过命令行接口安装项目

如果需要,CMake 本身允许将产物安装到环境中。为了做到这一点,CMake 代码必须已经使用 CMake install() 指令指定在调用 cmake --install(或构建系统等效命令)时要安装的内容。chapter_2 的内容已经以这种方式配置,以展示该命令。

我们将在 第四章中学习如何使 CMake 目标可安装,打包、部署和安装 CMake 项目

cmake --install 命令需要一个已经配置并构建过的项目。如果你还没有配置并构建 CMake 项目,请先配置并构建它。然后,发出 cmake --install <project_binary_dir> 命令来安装 CMake 项目。由于在我们的示例中 build 用作项目的二进制目录,<project_binary_dir> 将被替换为 build

以下图展示了 install 命令的示例:

图 2.16 – 安装项目

图 2.16 – 安装项目

默认安装目录在不同环境之间有所不同。在类 Unix 环境中,默认安装目录为 /usr/local,而在 Windows 环境中,默认安装目录为 C:/Program Files

提示

请记住,在尝试安装项目之前,项目必须已经构建完成。

为了能够成功安装项目,您必须具有适当的权限/许可,以便写入安装目标目录。

更改默认安装路径

要更改默认安装目录,您可以指定额外的 --prefix 参数,如此处所示,以更改安装目录:

cmake --install build --prefix /tmp/example

以下图展示了在调用 cmake --install 并使用 /tmp/example 前缀后,/tmp/example 文件夹的内容:

图 2.17 – 将项目安装到不同路径

图 2.17 – 将项目安装到不同路径

如此处所示,安装根目录已成功更改为 /tmp/example

安装时剥离二进制文件

在软件世界中,构建工件通常会捆绑一些额外的信息,例如调试所需的符号表。这些信息对于执行最终产品可能并不必要,并且可能大幅增加二进制文件的大小。如果您希望减少最终产品的存储占用,剥离二进制文件可能是一个不错的选择。剥离的另一个额外好处是,它使得逆向工程二进制文件变得更加困难,因为二进制文件中的关键信息符号被剥离掉了。

CMake 的 --install 命令允许在安装操作时剥离二进制文件。可以通过在 --install 命令中指定额外的 --strip 选项来启用此功能,如下所示:

cmake --install build --strip

在下面的示例中,您可以观察到未剥离和剥离二进制文件之间的大小差异。请注意,剥离静态库有其自身的限制,并且 CMake 默认情况下不会执行此操作。您可以在此图中看到未剥离二进制文件的大小:

图 2.18 – 工件大小(未剥离)

图 2.18 – 工件大小(未剥离)

使用剥离过的 (cmake –install build --strip) 二进制文件,大小差异如下图所示:

图 2.19 – 工件大小(剥离)

图 2.19 – 工件大小(剥离)

仅安装特定组件(基于组件的安装)

如果项目在 install() 命令中使用了 CMake 的 COMPONENT 功能,您可以通过指定组件名称来安装特定组件。COMPONENT 功能允许将安装过程分为多个子部分。为了说明这个功能,chapter_2 示例被结构化为两个组件,分别命名为 librariesexecutables

要安装特定组件,需要在 cmake --install 命令中添加一个额外的 --component 参数:

cmake --install build --component ch2.executables

这是一个示例调用:

图 2.20 – 仅安装特定组件

图 2.20 – 仅安装特定组件

安装特定配置(仅适用于多配置生成器)

一些生成器支持相同构建配置的多种配置(例如,Visual Studio)。对于这种生成器,--install 选项提供了一个额外的 --config 参数,用于指定要安装的二进制文件的配置。

这是一个示例:

cmake --install build --config Debug

注意

正如你可能注意到的,示例中使用的命令参数非常长且明确。这是故意的。明确指定参数可以确保我们每次执行时都能得到一致的结果,无论在哪个环境下运行我们的命令。例如,如果没有 -G 参数,CMake 会默认使用环境中的首选构建系统生成器,这可能不是我们想要的结果。我们的座右铭是,明确总比隐含好。明确指定参数可以使我们的意图更清晰,并自然地使得在 CI 系统/脚本中编写更具未来兼容性和可维护性的 CMake 代码。

我们已经讲解了 CMake 命令行用法的基础知识。接下来让我们继续学习 CMake 的另一种可用界面形式——CMake 的图形界面。

使用 CMake-GUI 和 ccmake 进行高级配置

虽然它们看起来不同,但大多数界面做的事情基本相同;因此,我们在上一部分已经覆盖的内容在这里同样有效。记住,我们将改变的是交互的形式,而不是我们实际交互的工具。

注意

在继续之前,请检查你的终端中是否可以使用 ccmake 命令。如果不能,请确认你的 PATH 变量是否设置正确,并检查你的安装情况。

学习如何使用 ccmake(CMake curses GUI)

ccmake 是基于终端的 ncurses

由于 ccmake 并不是默认安装的 CMake 包的一部分,它需要单独安装,可以通过操作系统的包管理器安装,或者从 CMake 官方网站下载并安装。使用 ccmake 和在 CLI 中使用 CMake 完全相同,只是它无法调用构建和安装步骤。主要的区别是,ccmake 会显示一个基于终端的图形界面,便于交互式地编辑缓存的 CMake 变量。当你在尝试设置时,这是一个非常方便的工具。ccmake 的状态栏会显示每个设置项的描述及其可能的值。

要开始使用 ccmake,在项目配置步骤中使用 ccmake 代替 cmake。在我们的示例中,我们将完全复制之前在 通过 CLI 配置项目 部分中讲解的命令行示例:

ccmake -S . -B ./build

以下是前面命令的示例输出:

图 2.21 – ccmake 主屏幕

图 2.21 – ccmake 主屏幕

执行命令后,将出现基于终端的 UI。初始页面是主页面,可以在其中编辑 CMake 变量。EMPTY CACHE表示没有进行过先前的配置,CMake 缓存文件(CMakeCache.txt)目前为空。要开始编辑变量,必须首先进行项目配置。按键盘上的C键即可进行配置,如Keys:部分所示。

按下C键后,将执行 CMake 配置步骤,并显示带有配置输出的日志输出屏幕:

图 2.22 – 配置后 ccmake 日志屏幕

图 2.22 – 配置后 ccmake 日志屏幕

要关闭日志输出屏幕并返回主屏幕,请按E键。返回后,你会发现EMPTY CACHE已被CMakeCache.txt文件中的变量名替换。要选择一个变量,使用键盘上的上下箭头键。当前选中的变量会以白色高亮显示,如下图所示:

图 2.23 – 配置后 ccmake 主屏幕

图 2.23 – 配置后 ccmake 主屏幕

在前面的截图中,选择了CMAKE_BUILD_TYPE变量。在右侧,显示了 CMake 变量的当前值。对于CMAKE_BUILD_TYPE,目前它是空的。变量值旁边的星号表示该变量的值在先前的配置中刚刚发生了变化。你可以按Enter键编辑它,或者按键盘上的D键删除它。下图展示了更改变量后的ccmake主屏幕:

图 2.24 – 变量更改后的 ccmake 主屏幕

图 2.24 – 变量更改后的 ccmake 主屏幕

让我们将CMAKE_BUILD_TYPE设置为Release并重新配置:

图 2.25 – ccmake 配置输出(Release)

图 2.25 – ccmake 配置输出(Release)

我们可以观察到,构建类型现在已设置为Release。返回上一屏幕,按下g(生成)按钮以保存更改。按下q(不生成,退出)按钮可以丢弃更改。

要编辑其他变量,例如CMAKE_CXX_COMPILERCMAKE_CXX_FLAGS,需要启用高级模式。通过调用mark_as_advanced() CMake 函数,这些变量默认被标记为高级标志,因此它们在图形界面中默认是隐藏的。在主屏幕上,按t键切换到高级模式:

图 2.26 – 高级模式下的 ccmake

图 2.26 – 高级模式下的 ccmake

启用高级模式后,一整套新选项会变得可见。你可以像普通变量一样观察和修改它们的值。你可能已经注意到,之前隐藏的变量CHAPTER2_BUILD_DRIVER_APPLICATION现在出现了。这是一个用户定义的 CMake 变量。该变量定义如下:

# Option to exclude driver application from build.
set(CHAPTER2_BUILD_DRIVER_APPLICATION TRUE CACHE BOOL "Whether to
ccmak  include driver application in build. Default: True")
# Hide this option from GUI's by default.
mark_as_advanced(CHAPTER2_BUILD_DRIVER_APPLICATION)

CHAPTER2_BUILD_DRIVER_APPLICATION变量被定义为布尔类型的缓存变量,默认值为true。它被标记为高级选项,因此在非高级模式下不会显示。

通过 cmake-gui 使用 CMake

如果你是那种觉得命令行界面(CLI)不直观,或者你更喜欢 GUI 而不是 CLI 的人,CMake 也提供了一个跨平台的 GUI。与ccmake相比,cmake-gui提供了更多功能,如环境编辑器正则表达式资源管理器

CMake GUI 并不总是默认包含在 CMake 安装中;根据使用的操作系统,它可能需要单独安装。它的主要目的是允许用户配置 CMake 项目。要启动cmake-gui,可以在终端中输入cmake-gui命令。在 Windows 上,它也可以从开始菜单找到。如果这些方法都无法工作,请进入 CMake 安装路径,它应该位于bin\目录中。

注意

如果你在 Windows 环境下启动cmake-gui,并打算使用 Visual Studio 提供的工具链,请从 IDE 的相应“本地工具命令提示符”启动cmake-gui。如果你有多个版本的 IDE,请确保使用正确的本地工具命令提示符。否则,CMake 可能无法找到所需的工具(如编译器),或者可能会找到错误的工具。有关详细信息,请参考docs.microsoft.com/en-us/visualstudio/ide/reference/command-prompt-powershell?view=vs-2019

这是 CMake GUI 的主窗口:

图 2.27 – CMake GUI 主窗口

图 2.27 – CMake GUI 主窗口

CMake GUI 的主屏幕基本包含以下内容:

  • 源代码路径字段

  • 输出路径字段

  • 预设选择列表

  • 配置生成按钮

  • 缓存变量列表

这些是我们将要交互的四个基本内容。要开始配置项目,请通过点击浏览源代码...按钮选择项目的根目录。然后,通过点击浏览构建...按钮选择项目的输出目录。此路径将是通过所选生成器生成的输出文件的路径。

如果项目包含 CMake 预设,可以从预设列表中选择预设。任何由预设修改的缓存变量将显示在缓存变量列表中。在以下示例中,选择了一个配置 Clang 13 作为编译器,并将调试作为构建类型的预设:

图 2.28 – 从 CMake GUI 选择预设

图 2.28 – 从 CMake GUI 选择预设

设置源路径和输出路径后,点击配置以开始配置选定的项目。CMake GUI 将允许你选择生成器、平台选择(如果生成器支持)、工具集和编译器等详细信息,如下图所示:

图 2.29 – CMake GUI 生成器选择界面

图 2.29 – CMake GUI 生成器选择界面

根据你的环境填写这些详细信息后,点击完成继续。CMake GUI 将开始使用给定的详细信息配置你的项目,并在日志区域报告输出。成功配置后,你还应该能在缓存变量列表区域看到缓存变量:

图 2.30 – 配置后的 CMake GUI

图 2.30 – 配置后的 CMake GUI

如果一切正常,点击.sln.cxxproj以及其他文件。生成项目后,makefiles),然后会显示生成的文件。之后,你可以使用 IDE 来构建项目。

重要提示

请注意,生成的项目只是生成器的产物,对生成的项目文件(.sln.cxxproj)所做的更改不会被保存,并将在下次生成时丢失。修改CMakeLists.txt文件或编辑CMakeCache.txt文件(无论是直接还是间接)时,别忘了重新生成项目文件。对于版本控制,应该将生成的项目文件视为构建产物,不应将其添加到版本控制中。你可以通过适当的生成器在 CMake 中重新生成项目,随时从头开始获取它们。

有时,项目可能需要调整某些缓存变量,或者你可能决定使用不同的构建类型。例如,要更改任何缓存变量,点击所需缓存变量的值;它应变为可编辑。根据变量类型,可能会显示复选框而不是字符串。如果所需变量未在列表中显示,它可能是高级变量,只有在cmake-gui处于高级模式时才能看到。

图 2.31 – cmake-gui 高级模式

图 2.31 – cmake-gui 高级模式

调整任何缓存值后,点击配置,然后点击生成以应用更改。

提示

另一个有用的功能是分组功能,它允许将缓存变量根据其公共前缀进行分组(如果存在)。组名由变量名的第一部分决定,直到第一个下划线为止。

我们已经涵盖了 cmake-gui 的最基本功能。在继续学习其他杂项内容之前,如果你需要重新加载缓存值或删除缓存并从头开始,你可以在文件菜单中找到重新加载缓存删除缓存菜单项。

调整环境变量

CMake GUI 提供了一个便捷的环境变量编辑器,允许对环境变量执行增、删、改、查操作。要访问它,只需点击主屏幕上的环境变量…按钮。点击后,环境变量编辑器窗口将弹出,如下图所示:

图 2.32 – CMake GUI 环境变量编辑器

图 2.32 – CMake GUI 环境变量编辑器

环境变量编辑器窗口包含当前环境中存在的环境变量列表。要编辑环境变量,只需双击表格中所需环境变量的值字段。该窗口还允许使用添加条目删除条目按钮来添加和删除信息。

使用 CMake 评估正则表达式

你是否曾经想过,CMake 是如何评估正则表达式的,它到底会给出什么结果?如果是的话,你可能以前通过 message() 手动调试它,打印正则表达式匹配结果变量。那如果我告诉你有一种更好的方法呢?让我向你介绍 CMake GUI 中的正则表达式浏览器工具:

图 2.33 – CMake GUI 正则表达式浏览器

图 2.33 – CMake GUI 正则表达式浏览器

这个隐藏的宝藏让你可以使用 CMake 的正则表达式引擎调试正则表达式。它位于工具菜单中,名称为正则表达式浏览器…。使用起来非常简单:

  1. 将表达式输入到正则表达式字段中。

    该工具将检查表达式是否有效。如果有效,屏幕上的有效文本将显示为绿色。如果 CMake 的正则表达式引擎不喜欢你给出的表达式,它将变为红色。

  2. 将测试字符串输入到输入文本字段中。正则表达式将与此文本进行匹配。

  3. 如果有任何匹配,窗口上的匹配字样将从红色变为绿色。匹配的字符串将显示在完全匹配字段中。

  4. 匹配时,捕获组将分别分配给匹配 1匹配 2、… 匹配 N,如果有的话。

在本节中,我们学习了如何使用 CMake 的本地图形界面。接下来,我们将继续通过了解一些 CMake 的 IDE 和编辑器集成来学习如何使用 CMake。

在 Visual Studio、VSCode 和 Qt Creator 中使用 CMake

作为软件开发中的常用工具,CMake 与各种 IDE 和源代码编辑器都有集成。在使用 IDE 或编辑器时,利用这些集成可能对用户来说更加方便。本节将介绍 CMake 如何与一些流行的 IDE 和编辑器集成。

如果你期待的是如何使用 IDE 或编辑器的指南,那么这一部分不涉及这方面的内容。本节的重点是探索并了解 CMake 与这些工具的集成。假设你已经具备与将要交互的 IDE/编辑器的使用经验。

让我们从 Visual Studio 开始。

Visual Studio

Visual Studio是支持 CMake 的后来的参与者之一。与其他流行的 IDE 不同,Visual Studio 直到 2017 年才开始原生支持 CMake。在那一年,微软决定行动,推出了内置支持 CMake 项目的功能,并随 Visual Studio 2017 一起发布。从那时起,这成为了 Visual Studio IDE 的一个重要功能。

要开始使用,请获取 Visual Studio 2017 或更高版本的副本。对于旧版本的 Visual Studio,这个功能完全不可用。在我们的示例中,我们将使用 Visual Studio 2022 社区版。

从头开始创建 CMake 项目

Visual Studio 的项目创建功能基于项目模板。从 Visual Studio 2017 及以后版本,项目模板中也包含了 CMake 项目模板。我们将学习如何使用这个模板来创建新的 CMake 项目。

要使用 Visual Studio 创建一个新的 CMake 项目,请点击欢迎页面上的创建新项目按钮。或者,你也可以通过点击文件 | 新建 | 项目来访问,或者使用Ctrl + Shift + N新建项目)快捷键。Visual Studio 2022 的欢迎屏幕如下所示:

图 2.34 – Visual Studio 2022 欢迎屏幕

图 2.34 – Visual Studio 2022 欢迎屏幕

创建新项目屏幕上,双击项目模板列表中的CMake 项目。你可以通过使用位于列表顶部的搜索栏来筛选项目模板:

图 2.35 – Visual Studio 2022 创建新项目屏幕

图 2.35 – Visual Studio 2022 创建新项目屏幕

点击CMakeProject1之后。

图 2.36 – Visual Studio 2022 新项目配置屏幕

图 2.36 – Visual Studio 2022 新项目配置屏幕

填写完详细信息后,点击CMakeLists.txt文件、C++源文件和一个 C++头文件,文件名与选择的项目名称相同。新创建的项目布局如下图所示:

图 2.37 – 使用 Visual Studio 创建新 CMake 项目后的第一印象

图 2.37 – 使用 Visual Studio 创建新 CMake 项目后的第一印象

打开现有 CMake 项目

要打开一个现有的 CMake 项目,请转到项目的 CMakeLists.txt 文件。下图显示了 Open 菜单的样子:

图 2.38 – CMake 项目打开菜单

图 2.38 – CMake 项目打开菜单

接下来,让我们看看如何配置和构建 CMake 项目。

配置和构建 CMake 项目

要在 Visual Studio 中构建 CMake 项目,请进入 configure 步骤并生成所需的构建系统文件。配置完成后,点击 Build | Build All 来构建项目。你也可以通过使用 F7 快捷键来触发 Build All

请注意,每当你保存 CMakeLists.txt 文件时,Visual Studio 会自动调用 configure,该文件是项目的一部分。

执行 CMake 目标上的常见操作

Visual Studio 使用 启动目标 概念来进行需要目标的操作,如构建、调试和启动。要将 CMake 目标设置为启动目标,请使用工具栏上的 Select Startup Target 下拉框。Visual Studio 会在配置时自动将 CMake 目标填充到这个下拉框中:

图 2.39 – 启动目标选择下拉菜单

图 2.39 – 启动目标选择下拉菜单

设置启动目标后,你可以像在 Visual Studio 中一样调用调试、构建或启动等操作:

  1. 要进行调试,首先点击 Debug | Startup Target,然后点击 Debug | Start Debugging 或使用 F5 快捷键。

  2. 要在不调试的情况下启动,请点击 Start without debug 或使用 Ctrl + F5 快捷键。

  3. 要进行构建,点击 Build,点击 Build | Build ,或使用 Ctrl + B 快捷键。

    按钮位置如下面的图所示:

图 2.40 – 工具栏按钮位置

图 2.40 – 工具栏按钮位置

本节我们已经介绍了 Visual Studio CMake 集成的基础知识。在下一节中,我们将继续学习另一个 Microsoft 产品——VSCode。

Visual Studio Code

VSCode 是微软开发的开源代码编辑器。它不是一个 IDE,但通过扩展可以变得强大并拥有类似 IDE 的功能。扩展市场有各种各样的附加内容,从主题到语言服务器。你几乎可以找到任何东西的扩展,这使得 VSCode 既强大又受到广泛用户的喜爱。毫不奇怪,VSCode 也有官方的 CMake 扩展。该扩展最初由 Colby Pike(也被称为 vector-of-bool)开发,但现在由 Microsoft 官方维护。

本节我们将学习如何安装扩展并使用它执行基本的 CMake 任务。

在继续之前,VSCode 必须已经安装在你的环境中。如果没有,请访问 code.visualstudio.com/learn/get-started/basics 获取下载和安装的详细信息。

同时,我们将频繁访问命令面板。强烈建议经常使用它,以便熟悉它。对于那些问“命令面板到底是什么?”的人,下面是一个截图:

图 2.41 – VSCode 命令面板

图 2.41 – VSCode 命令面板

是的,就是那个东西。说实话,直到现在我才知道它有个名字。访问命令面板的快捷键是F1Ctrl + Shift + P。命令面板是 VSCode 的核心,它能加速 VSCode 的工作流程。

安装扩展

安装扩展是相当简单的。你可以通过 CLI 安装,使用以下命令(如果你使用的是 Insiders 版本,请将code替换为code-insiders):

code --install-extension ms-vscode.cmake-tools

另外,你也可以通过 VSCode 的图形界面做同样的操作。打开 VSCode 并在扩展搜索框中输入CMake Tools,然后选择CMake Tools(由Microsoft提供)。要小心不要与 CMake 扩展混淆。点击安装按钮来安装:

图 2.42 – VSCode 扩展市场

图 2.42 – VSCode 扩展市场

安装完成后,扩展就可以使用了。

快速开始项目

VSCode CMake Tools 扩展提供了一个cmake quick start。选择CMake: Quick Start并按下键盘上的Enter键。

图 2.43 – 命令面板 – 定位 CMake: Quick Start

图 2.43 – 命令面板 – 定位 CMake: Quick Start

首先,扩展会询问使用哪个工具链。选择适合你新项目的工具链。关于工具链的更多信息将在处理工具链部分中讨论。

在选择好工具链后,系统会提示你输入项目名称。这将是你顶级 CMake 项目的名称。输入你选择的名称。

最后,将显示一个示例应用程序代码的选择。在此选择中,你将被要求创建一个可执行应用程序项目或一个库项目。选择其中之一,瞧!你就拥有了一个工作中的 CMake 项目。选择后,CMakeLists.txtmain.cpp文件将被生成。这些文件的内容在可执行文件和库的选择之间稍有不同。

打开现有项目

在 VSCode 中打开 CMake 项目并没有什么特别的。只需打开包含项目顶级CMakeLists.txt文件的文件夹。CMake Tools 扩展将自动识别该文件夹为 CMake 项目,所有与 CMake 相关的命令将会在 VSCode 的命令面板上可用。打开现有项目时,系统会询问是否配置该项目。

图 2.44 – VSCode 询问是否配置现有项目

图 2.44 – VSCode 询问是否配置现有项目

如果项目支持 CMake 预设,你将自动被询问选择哪个预设。如果项目不支持预设,那么你将被要求选择一个编译器工具链,稍后在本章的处理 kits 部分将对此进行解释。

图 2.45 – 在 VSCode 中选择 CMake 预设

图 2.45 – 在 VSCode 中选择 CMake 预设

至此,我们已经准备好配置项目。

配置、构建和清理项目

要配置一个 CMake 项目,从命令面板中选择CMake: Configure菜单项。要构建项目,选择构建目标,点击CMake: Set Build Target菜单项。这将让你选择在触发构建时将构建哪个目标。最后,选择CMake: Build以构建选定的构建目标。如果要在不将其设置为构建目标的情况下构建特定目标,可以使用CMake: Build Target菜单项。

要清理构建产物,请使用clean目标并删除所有构建产物。

调试目标

要调试一个目标,选择CMake: Set Debug Target菜单项,从命令面板中选择调试目标。你将看到列出所有可调试的目标:

图 2.46 – 调试目标选择

图 2.46 – 调试目标选择

选择目标并从命令面板中选择CMake: DebugCtrl + F5)。选定的目标将在调试器下启动。

如果你想在不使用调试器的情况下运行选定的目标,请选择CMake: Run Without DebuggingShift + F5)。

图 2.47 – 正在调试的可执行 Chapter1 目标

图 2.47 – 正在调试的可执行 Chapter1 目标

在下一节中,我们将讨论如何为调试目标提供参数。

向调试目标传递参数

你要调试的目标可能需要命令行参数。要向调试目标传递命令行参数,请打开 VSCode 的 settings.json 并追加以下行:

"cmake.debugConfig": {
        "args": [
            "<argument1>",
            "<argument2>"
        ]
    }

args JSON 数组中,你可以放置目标所需的任何数量的参数。这些参数将无条件地传递给所有未来的调试目标。如果你想对参数进行精细控制,最好还是定义一个 launch.json 文件。

处理 kits

CMake Tools 扩展中的 kit 代表了一组可用于构建项目的工具组合;因此,kit 这个术语几乎可以视为工具链的同义词。Kit 使得在多编译器环境中工作变得更加简便,允许用户选择使用哪种编译器。Kit 可以通过扩展自动发现,或者通过工具链文件读取,或由用户手动定义。

要查看项目的可用 kits,请从命令面板中选择CMake: Select a Kit菜单项(F1Ctrl + Shift + P)。

图 2.48 – Kit 选择列表

图 2.48 – Kit 选择列表

选择的工具包将用于配置 CMake 项目,这意味着工具包中定义的工具将用于编译该项目。选择工具包将自动触发 CMake 配置。

默认情况下,扩展会自动扫描工具包。因此,发现的工具链会作为选项列在工具包选择菜单中。如果您的工具链没有显示在这里,这意味着 CMake Tools 未能发现它。在这种情况下,首先尝试重新扫描工具包。如果仍然没有显示,您可以手动将其添加到用户本地的 cmake-tools-kits.json (1) 文件中来定义额外的工具包。

通常不需要添加新的工具包,因为扩展可以很好地自动发现工具链。如果遇到异常情况失败,这里有一个工具包模板,您可以自定义并将其附加到用户本地的 cmake-tools-kits.json 文件中,以定义一个新的工具包。要打开用户本地的工具包文件,请从命令面板中选择 CMake: 编辑用户本地 CMake 工具包 菜单项:

  {
    "name":"<name of the kit>",
    "compilers" {
      "CXX":"<absolute-path-to-c++-compiler>",
      "C": "<absolute-path-to-c-compiler>"
    }
  }

注意

在较旧版本的 CMake Tools 扩展中,cmake-tools-kits.json 文件可能被命名为 cmake-kits.json

请记住,如果您的工具包名称与 CMake Tools 自动生成的名称冲突,CMake Tools 在扫描时会覆盖您的条目。因此,请始终为您的工具包定义提供唯一的名称。

有关工具包的更多信息,请参阅 github.com/microsoft/vscode-cmake-tools/blob/dev/gcampbell/KitCmakePath/docs/kits.md

Qt Creator

Qt Creator 是另一个支持 CMake 项目的 IDE。CMake 支持相当不错,并且默认提供,无需额外的插件。在本节中,我们将快速了解 Qt Creator 对 CMake 的支持。

和往常一样,确保您的 IDE 已正确安装并在环境中配置好。

本示例使用的是 Qt Creator 版本 5.0.1。

添加您的 CMake 安装

为了在 Qt Creator 中使用 CMake,必须在 Qt Creator 中定义 CMake 的路径。要查看和定义 CMake 路径,请导航至 编辑 | 首选项 | CMake

图 2.49 – Qt Creator CMake 路径设置

图 2.49 – Qt Creator CMake 路径设置

根据 CMake 的安装方式,Qt Creator 可能能够自动检测到正确的版本。如果没有,您可以手动配置它。要选择在 Qt Creator 中运行的 CMake 可执行文件,请选择所需的条目并点击 设置为默认 按钮。

要添加新的 CMake 可执行文件,点击 添加。这将把一个新的条目添加到 手动 部分,并弹出一个窗口,您可以在其中填写新条目的详细信息:

图 2.50 – 添加新的 CMake 可执行文件

图 2.50 – 添加新的 CMake 可执行文件

该窗口中的字段在这里有详细描述:

  • 名称:用于区分新 CMake 可执行文件条目的唯一名称。

  • cmake/cmake.exe

  • 版本:CMake 的版本(由 Qt Creator 推测)。

  • 帮助文件:可选的 Qt Creator 帮助文件,用于该可执行文件。这样在按下 F1 时,CMake 帮助文件会显示出来。

  • CMakeLists.txt 文件的更改。

填写完详细信息后,点击 应用 将新的 CMake 可执行文件添加到 Qt Creator 中。如果你希望 Qt Creator 使用它,别忘了将其设置为默认。

创建 CMake 项目

在 Qt Creator 中创建 CMake 项目遵循与创建常规项目相同的步骤。Qt Creator 不将 CMake 视为外部构建系统生成器。相反,它允许用户在三种构建系统生成器之间进行选择,分别是 qmakecmakeqbs。任何类型的 Qt 项目都可以通过这些构建系统生成器中的任意一种从头开始创建。

要在 Qt Creator 中创建 CMake 项目,请点击 文件 | 新建文件或项目... (Ctrl + N),然后在 新建文件或项目 窗口中选择项目类型。我们以 Qt Widgets 应用程序 作为示例。

图 2.51 – Qt Creator 新建文件或项目窗口

图 2.51 – Qt Creator 新建文件或项目窗口

选择后,项目创建向导将出现。根据需要填写详细信息。在 定义构建系统 步骤中选择 CMake,如以下截图所示:

图 2.52 – Qt Creator 新建项目向导中的构建系统选择

图 2.52 – Qt Creator 新建项目向导中的构建系统选择

就是这样!你已经创建了一个带有 CMake 构建系统的 Qt 应用程序。

下图展示了一个新创建的 CMake 项目:

图 2.53 – 生成的基于 CMake 的 Qt 小部件应用程序项目

图 2.53 – 生成的基于 CMake 的 Qt 小部件应用程序项目

打开现有的 CMake 项目

要在 Qt Creator 中打开现有的 CMake 项目,请点击 文件 | 打开文件或项目... (Ctrl + O) 菜单项。选择项目的顶层 CMakeLists.txt 文件,然后点击 打开。Qt Creator 会提示你选择一个工具链(kit)来构建项目。选择你首选的工具链后,点击 配置项目 按钮。项目将被打开,并且 CMake 配置步骤会使用所选工具链执行。

例如,以下图所示的是使用 Qt Creator 打开的 CMake Best Practices 项目:

图 2.54 – 在 Qt Creator 中查看 CMake Best Practices 示例项目

图 2.54 – 在 Qt Creator 中查看 CMake Best Practices 示例项目

第一次打开 CMake 项目时,Qt Creator 会在项目的根目录中创建一个名为 CMakeLists.txt.user 的文件。该文件包含一些 Qt 特有的细节,这些细节不能存储在 CMakeLists.txt 文件中,例如工具链信息和编辑器设置。

配置和构建

在大多数情况下(例如,打开项目并保存对 CMakeLists.txt 的更改),Qt Creator 会自动运行 CMake 配置,而无需手动执行。若要手动运行 CMake 配置,请点击 Build | Run CMake 菜单项。

配置完成后,点击最左侧的锤子图标以构建项目。或者,可以使用 Ctrl + B 快捷键。这将构建整个 CMake 项目。若要仅构建特定的 CMake 目标,请使用位于 cm 旁边的定位器,然后按下空格键。

图 2.55 – Qt Creator 定位器建议

图 2.55 – Qt Creator 定位器建议

定位器将显示可构建的 CMake 目标。可以通过高亮选择目标并按 Enter 键,或直接用鼠标点击目标来选择。

图 2.56 – 定位器中显示的可用 CMake 构建目标

图 2.56 – 定位器中显示的可用 CMake 构建目标

选择的 CMake 目标(以及其依赖项)将被构建。

运行和调试

要运行或调试一个 CMake 目标,请按 Kit 选择器按钮(左侧导航栏中的计算机图标),并选择 CMake 目标。然后,点击运行按钮(Kit 选择器下方的 播放图标)来运行,或者点击调试按钮(带有错误的 播放图标)来调试。

下图显示了 Kit 选择器菜单的内容:

图 2.57 – 显示 CMake 目标的 Kit 选择器

图 2.57 – 显示 CMake 目标的 Kit 选择器

在这里,我们结束了使用 CMake 和 Qt Creator 的基础内容。有关更高级的主题,您可以参考 进一步阅读 部分提供的资源。

总结

在本章中,我们介绍了与 CMake 交互的基本方法,包括 CLI 和 GUI。我们还讨论了各种 IDE 和编辑器的集成,它们对于日常工作流程至关重要。使用任何工具都需要了解如何与其交互。学习交互方式使我们能够更好地利用工具本身,也能帮助我们更轻松地达成目标。

在下一章中,我们将讨论 CMake 项目的构建块,这将使你能够从零开始创建一个结构良好、适合生产的 CMake 项目。

问题

为了巩固你在本章学到的内容,试着回答以下问题。如果你在回答时遇到困难,请返回相关章节并重新阅读该主题:

  1. 描述如何通过命令行接口(CLI)将 CMake 项目配置到项目根目录下的构建文件夹,涉及以下各项:

    1. 另一个 C++ 编译器,位于 /usr/bin/clang++

    2. Ninja 生成器

    3. -Wall 编译器标志,用于 Debug 构建类型

  2. 描述如何使用命令行和 CMake 构建之前在 Q1 中配置的项目,涉及以下各项:

    1. 八个并行任务

    2. Unix Makefiles 生成器中的 --trace 选项

  3. 描述如何使用 directory/opt/project 命令行通过 CMake 安装之前在 Q1 中构建的项目?

  4. 假设 CMake-Best-Practices 项目已经配置并构建完成,必须执行哪个命令来仅安装 ch2.libraries 组件?

  5. CMake 中的高级变量是什么?

答案

  1. 下面是答案:

    1. cmake –S . -B ./build -DCMAKE_CXX_COMPILER:STRING= "/``usr/bin/clang++ "

    2. cmake –S . -B ./build -``G "Ninja"

    3. cmake –S . -B ./build -``DCMAKE_BUILD_FLAGS_DEBUG:STRING= "-Wall"

  2. 在 Q1 中之前配置的项目可以通过以下命令使用 CMake 在命令行中构建:

    1. cmake --build ./build --``parallel 8

    2. cmake --build ./build -- VERBOSE=1

  3. cmake --install ./``build --prefix=/opt/project

  4. cmake --install ./build --``component ch2.libraries

  5. 它是一个 CMake 缓存变量,标记为 高级,通过 mark_as_advanced() 函数使其在图形界面中隐藏。

进一步阅读

本章讨论的主题有很多相关的指南和文档。你可以在这里找到一份不完全的推荐阅读材料清单:

第三章:创建一个 CMake 项目

到现在为止,你应该已经熟悉了如何使用 CMake 及其基本概念,如两阶段构建。目前,我们只讨论了如何使用 CMake 与现有代码配合,但更有趣的部分是如何使用 CMake 构建应用程序。在本章中,你将学习如何构建可执行文件和库,并学习如何将它们一起使用。我们将深入探讨创建不同类型的库,并展示一些关于如何构建 CMake 项目的好实践。由于库通常伴随着多种编译器设置,我们将学习如何设置它们,并在必要时将这些设置传递给依赖库。由于项目中的依赖关系可能变得相当复杂,我们还将学习如何可视化不同目标之间的依赖关系。

本章将涵盖以下主题:

  • 设置项目

  • 创建一个“hello world”可执行文件

  • 创建一个简单的库

  • 整合它们

技术要求

和之前的章节一样,所有示例都已使用 CMake 3.21 测试,并在以下编译器之一上运行:

  • GCC 9 或更新版本

  • Clang 12 或更新版本

  • MSVC 19 或更新版本

本章的所有示例和源代码可以在本书的 GitHub 仓库中找到,github.com/PacktPublishing/CMake-Best-Practices---2nd-Edition,在 chapter03 子文件夹中。

设置项目

虽然 CMake 可以处理几乎任何项目文件结构,但有一些关于如何组织文件的好实践。本书中的示例遵循以下常见模式:

├── CMakeLists.txt
├── build
├── include/project_name
└── src

在一个最小化项目结构中,包含三个文件夹和一个文件。它们如下:

  • build:存放build文件和二进制文件的文件夹。当克隆一个新项目时,通常不会看到 build 文件夹,因为它将由 CMake 生成。它通常被命名为 build,但也可以有任何名称。

  • include/project_name:该文件夹包含所有外部可访问的公共头文件。添加一个包含项目名称的子文件夹是有帮助的,因为头文件的引用通常是通过 <project_name/somefile.h> 完成的,这样更容易判断头文件来自哪个库。

  • src:这个文件夹包含所有私有的源文件和头文件。

  • CMakeLists.txt:这是根 CMake 文件。build 文件夹几乎可以放在任何位置。将其放在项目根目录下是非常方便的。然而,我们强烈建议避免选择任何非空文件夹作为 build 文件夹。特别是,将构建文件放入 includesrc 中被认为是一个不好的做法。通常会有 testdoc 等附加文件夹,用于组织测试和文档页面。

使用嵌套项目

当你将项目嵌套在彼此内部时,每个项目应当映射上面的文件结构,并且每个CMakeLists.txt文件应编写成使子项目能够独立构建。这意味着每个子项目的CMakeLists.txt文件应该指定cmake_minimum_required,并可选择性地定义项目。我们将在第十章中深入讨论大型项目和超级构建,处理分布式仓库和依赖关系的超级构建

嵌套项目看起来像这样:

├── CMakeLists.txt
├── build
├── include/project_name
├── src
└── subproject
    ├── CMakeLists.txt
    ├── include
    │   └── subproject
    └── src

在这里,文件夹结构在subproject文件夹中得到了重复。坚持这种文件夹结构并使子项目能够独立构建,可以更容易地移动项目。这也允许开发人员只构建项目的一部分,这在大型项目中尤为有用,因为在这些项目中,构建时间可能会相当长。

现在我们已经完成了文件结构的设置,接下来让我们从创建一个简单的独立可执行文件开始,不涉及任何特殊的依赖项。本章后面,我们将创建各种类型的库并将它们组合在一起。

创建一个“hello world”可执行文件

首先,我们将从一个简单的 hello world C++程序创建一个简单的可执行文件。下面的 C++程序将打印出Welcome to CMakeBest Practices

#include <iostream>
int main(int, char **) {
  std::cout << "Welcome to CMake Best Practices\n";
  return 0;
}

要构建这个,我们需要编译它并给可执行文件命名。让我们看看用来构建这个可执行文件的CMakeLists.txt文件长什么样:

cmake_minimum_required(VERSION 3.21)
project(
    hello_world_standalone
    VERSION 1.0
    DESCRIPTION "A simple C++ project"
    HOMEPAGE_URL  https://github.com/PacktPublishing/CMake-Best-Practices---2nd-Edition
    LANGUAGES CXX
)
add_executable(hello_world)
target_sources(hello_world PRIVATE src/main.cpp)

在第一行cmake_minimum_required(VERSION 3.21)中,我们告诉 CMake 预期使用的 CMake 版本以及 CMake 将启用哪些功能。如果尝试使用低于指定版本的 CMake 构建这个项目,会导致错误。在本书中,我们使用 CMake 3.21 进行所有示例,但为了兼容性,你可以选择较低的版本。

对于这个例子,版本 3.1 将是绝对的最低要求,因为在此之前,target_sources命令是不可用的。一个好的做法是将cmake_minimum_required命令放在每个CMakeLists.txt文件的顶部。

接下来,使用project()命令设置项目。第一个参数是项目的名称——在我们的例子中是"hello_world_standalone"

接下来,项目的版本设置为 1.0。接下来是简短的描述和主页的 URL。最后,LANGUAGES CXX属性指定我们正在构建一个 C++项目。除了项目名称,所有其他参数都是可选的。

调用add_executable(hello_world)命令会创建一个名为hello_world的目标。这也将是由这个目标创建的可执行文件的名称。

现在目标已经创建,使用target_sources将 C++源文件添加到目标中。在这种情况下,hello_world是目标名称,如add_executable中所指定。PRIVATE定义指定源文件仅用于构建此目标,并且对任何依赖的目标不可见。在作用域说明符之后,是一组相对于当前CMakeLists.txt文件路径的源文件列表。如果需要,可以通过CMAKE_CURRENT_SOURCE_DIR变量访问当前正在处理的CMakeLists.txt文件的位置。

源文件可以直接添加到add_executable函数中,或者使用target_sources函数单独添加。通过target_sources添加可以显式地定义源文件的使用范围,使用PRIVATEPUBLICINTERFACE。然而,除PRIVATE外的任何指定仅对库目标有意义。当源文件直接添加到add_executable命令时,它们默认是PRIVATE的。

一个常见的模式是将项目的主可执行文件命名为项目的名称,可以通过使用PROJECT_NAME变量来实现,例如:

project(hello_world
...
)
add_executable(${PROJECT_NAME})

尽管乍一看这似乎很方便,但这不是一个好的做法。项目的名称和目标承载着不同的语义,因此应该视为两个独立的事物,因此应避免使用PROJECT_NAME作为目标的名称。

可执行文件很重要,而且相对容易创建,但除非你正在构建一个巨大的整体应用,否则库是模块化和分发代码的好方式。在下一节中,我们将学习如何构建库以及如何处理不同的链接方法。

创建一个简单的库

创建库的过程与创建可执行文件相似,尽管由于库目标通常会被其他目标使用(无论是同一项目中的目标,还是其他项目中的目标),因此需要考虑一些额外的因素。由于库通常有一个内部部分和一个公开的 API,我们在将文件添加到项目时必须考虑这一点。

一个简单的库项目将是这样的:

cmake_minimum_required(VERSION 3.21)
project(
  ch3_hello
  VERSION 1.0
  DESCRIPTION
    "A simple C++ project to demonstrate creating executables and
      libraries in CMake"
  LANGUAGES CXX)
add_library(hello)
add_library(ch3_hello::hello ALIAS hello)
target_sources(
  hello
  PRIVATE src/hello.cpp src/internal.cpp)
target_compile_features(hello PUBLIC cxx_std_17)
target_include_directories(
  hello
  PRIVATE src/hello
  PUBLIC include)

同样,文件以设置cmake_minimum_required和项目信息开始,你现在应该已经很熟悉这些内容了。

接下来,使用add_library创建库的目标——在这种情况下,库的类型未被确定。我们可以传递STATICSHARED来显式地确定库的链接类型。如果省略这一部分,我们允许库的任何下游使用者选择如何构建和链接它。通常,静态库最容易处理,但在编译时间和模块化分发方面有一些缺点。有关构建共享库的更多信息,请参见共享库中的符号可见性子部分。

如果省略库的类型,BUILD_SHARED_LIBS变量决定默认情况下库是作为共享库还是静态库构建。此变量不应在项目的CMakeLists.txt文件中无条件设置;它应始终由用户传递。

除了定义库目标外,良好的实践是还定义一个库别名,可以通过以下代码实现:

add_library(ch3_hello::hello ALIAS hello)

这会创建一个别名,名为ch3_hello::hello,它指代hello目标。

接下来,使用target_sources添加库的源文件。第一个参数是目标名称,后面是由PRIVATEPUBLICINTERFACE关键字分隔的源文件。在实际操作中,源文件几乎总是使用PRIVATE修饰符添加。PRIVATEPUBLIC关键字指定了源文件在哪些地方用于编译。指定PRIVATE意味着源文件仅会在hello目标本身中使用。如果使用PUBLIC,则源文件会添加到hello以及任何链接到hello的目标中。如前所述,这通常不是期望的行为。INTERFACE关键字意味着源文件不会添加到hello中,但会添加到任何与hello链接的目标中。这通常只适用于头文件,而不适用于源文件。一般来说,任何指定为PRIVATE的目标都可以视为该目标的构建要求。标记为PUBLIC的源文件是构建和接口要求,而标记为INTERFACE的源文件仅为接口要求。最后,使用target_include_directories设置库的include目录。通过此命令指定的文件夹中的所有文件可以通过#include <file.hpp>(使用尖括号)而非#include ""来访问,尽管使用引号的版本仍然有效。include目录在PRIVATEPUBLICINTERFACE的语义上与源文件类似。

PRIVATE包含的路径不会被包括在目标属性INTERFACE_INCLUDE_DIRECTORIES中。当目标依赖于库时,CMake 会读取此属性,以确定哪些include目录对被依赖目标可见。

由于库的 C++代码使用了与现代 C++版本相关的特性,例如 C++11/14/17/20 或即将发布的 C++23,我们必须设置cxx_std_17属性。由于此标准对于编译库本身以及与库的接口都是必要的,因此它设置为PUBLIC。只有当头文件中包含需要特定标准的代码时,才有必要将其设置为PUBLICINTERFACE。如果仅内部代码依赖于某个标准,则更倾向于将其设置为PRIVATE。通常,尽量将公共 C++标准设置为能正常工作的最低版本。也可以只启用某个现代 C++标准的特定特性,但这相对较少见。

可用的编译特性完整列表可以在cmake.org/cmake/help/latest/prop_gbl/CMAKE_CXX_KNOWN_FEATURES.html找到。

库别名

库别名是一种在不创建新构建目标的情况下引用库的方式,有时也被称为命名空间。一个常见的模式是为从项目安装的每个库创建一个形如MyProject::Library的库别名。

它们可以用于语义上将多个目标分组。它们还可以帮助避免命名冲突,特别是当项目中包含常见的目标(例如名为utilshelpers等的库)时。一个好的做法是将同一项目的所有目标放在同一个命名空间下。当你从其他项目链接库时,包含命名空间可以防止你不小心链接错误的库。被认为是好习惯的是,为所有库创建一个带有命名空间的别名,将它们分组,以便可以通过命名空间引用它们:

add_library(ch3_hello::hello ALIAS hello)
...
target_link_libraries(SomeLibrary PRIVATE ch3_hello::hello)

除了帮助确定目标的来源外,CMake 还使用命名空间来识别导入的目标,并创建更好的诊断消息,正如我们在安装和打包部分中看到的,在第四章中,CMake 项目的打包、部署和安装,以及在第五章中,集成第三方库和依赖管理,我们将讲解依赖管理时也会涉及此内容。

始终使用命名空间

作为好习惯,始终使用命名空间别名目标,并通过namespace::前缀引用它们。

通常,当你从项目外部引用目标时,使用包含命名空间的完整名称并通过target_link_library添加它们。虽然别名是语义化命名 CMake 构建目标的一种方式,但它们对构建后实际生成的库文件名称的影响有限。不过,CMake 提供了方便的函数来控制命名并确保库符合不同操作系统的命名约定。

命名库

当你使用add_library(<name>)创建库时,库的名称必须在项目内全局唯一,因为名称冲突会导致错误。默认情况下,库的实际文件名是根据平台的约定构造的,例如 Linux 上的lib<name>.so,以及 Windows 上的<name>.lib<name>.dll。通过设置目标的OUTPUT_NAME属性,可以更改文件名的默认行为。以下示例中,输出文件的名称已从ch3_hello更改为hello

add_library(ch3_hello)
set_target_properties(
   ch3_hello
   PROPERTIES OUTPUT_NAME hello
)

避免使用以lib为前缀或后缀的库名,因为 CMake 可能会根据平台自动在文件名的前面或后面附加适当的字符串。

共享库常用的命名约定是将版本添加到文件名中,以指定构建版本和 API 版本。通过为库目标指定VERSIONSOVERSION属性,CMake 将在构建和安装库时创建必要的文件名和符号链接:

set_target_properties(
    hello
    PROPERTIES VERSION ${PROJECT_VERSION} # Contains 1.2.3
    SOVERSION ${PROJECT_VERSION_MAJOR} # Contains only 1
)

在 Linux 上,以上示例将生成名为libhello.so.1.0.0的文件,并且从libhello.solibhello.so.1到实际库文件的符号链接也会创建。以下截图显示了生成的文件和指向它的符号链接:

图 3.1 – 构建时带有 SOVERSION 属性的库文件和生成的符号链接

图 3.1 – 构建时带有 SOVERSION 属性的库文件和生成的符号链接

在项目中常见的一种约定是为不同的构建配置添加不同的文件名后缀。CMake 通过设置CMAKE_<CONFIG>_POSTFIX全局变量或者添加<CONFIG>_POSTFIX属性到目标来处理这一点。如果设置了这个变量,后缀会自动添加到非可执行目标上。与大多数全局变量一样,它们应通过命令行或预设传递给 CMake,而不是硬编码在CMakeLists.txt文件中。

调试库的后缀也可以明确地为单个目标设置,如下例所示:

set_target_properties(
hello
PROPERTIES DEBUG_POSTFIX d)

这样,在调试配置下构建时,库文件和符号链接将被命名为libhellod.so。由于在 CMake 中,库链接是通过目标而非文件名进行的,选择正确的文件名会自动完成,因此我们无需手动跟踪。然而,在链接共享库时需要注意的一点是符号的可见性。我们将在下一节中讨论这个问题。

共享库中的符号可见性

要链接共享库,链接器必须知道哪些符号可以从库外部使用。这些符号可以是类、函数、类型等,公开它们的过程称为导出。

编译器在指定符号可见性时有不同的方式和默认行为,这使得以平台无关的方式指定这一点变得有些麻烦。首先是编译器的默认可见性;GCC 和 Clang 默认假定所有符号都是可见的,而 Visual Studio 编译器默认隐藏所有符号,除非显式导出。通过设置CMAKE_WINDOWS_EXPORT_ALL_SYMBOLS,可以改变 MSVC 的默认行为,但这是一个粗暴的解决方法,只能在库的所有符号都应该导出的情况下使用。

虽然将所有符号设置为公开可见是确保链接顺利的简单方法,但它也有一些缺点。

通过导出所有内容,无法防止依赖目标使用内部代码。

由于每个符号都可以被外部代码使用,链接器无法丢弃死代码,因此生成的库往往会变得臃肿。如果库包含模板,这一点尤其明显,因为模板会显著增加符号的数量。

由于每个符号都被导出,唯一可以判断哪些符号应该被视为隐藏或内部的线索只能来自文档。

暴露库的内部符号可能会暴露应该保持隐藏的内容。

设置所有符号为可见

当你设置共享库中的所有符号都可见时,尤其是在关注安全问题或二进制大小很重要的情况下,要小心。

更改默认可见性

要更改符号的默认可见性,将<LANG>_VISIBILITY_PRESET属性设置为HIDDEN。此属性可以全局设置,也可以针对单个库目标进行设置。<LANG>会替换为库所使用的编程语言,例如 C++使用CXX,C 语言使用C。如果所有符号都应该是隐藏的并且要导出,它们必须在代码中特别标记。最常见的做法是指定一个预处理器定义来决定一个符号是否可见:

class HELLO_EXPORT Hello {
…
};

HELLO_EXPORT定义将包含关于符号在库编译时是否会导出的信息,或者在链接库时是否应当导入。GCC 和 Clang 使用__attribute__(…)关键字来确定此行为,而在 Windows 上使用的是_declspec(…)。编写能够跨平台处理的头文件并不是一件容易的事,特别是当你还需要考虑库可能被构建为静态库和对象库时。幸运的是,CMake 提供了generate_export_header宏,它由GenerateExportHeader模块导入,以简化这一过程。

在以下示例中,hello库的符号默认设置为隐藏。然后,使用GenerateExportHeader模块导入的generate_export_header宏单独启用它们。此外,示例还将VISIBILITY_INLINES_HIDDEN属性设置为TRUE,以通过隐藏内联类成员函数来进一步减少导出符号表。设置内联符号的可见性并非严格必要,但通常在设置默认可见性时会这样做:

add_library(hello SHARED)
set_property(TARGET hello PROPERTY CXX_VISIBILITY_PRESET "hidden")
set_property(TARGET hello PROPERTY VISIBILITY_INLINES_HIDDEN TRUE)
include(GenerateExportHeader)
generate_export_header(hello EXPORT_FILE_NAME export/hello/
  export_hello.hpp)
target_include_directories(hello PUBLIC "${CMAKE_CURRENT_BINARY_DIR}
  /export")

调用generate_export_header会在CMAKE_CURRENT_BINARY_DIR/export/hello目录下创建一个名为export_hello.hpp的文件,该文件可以在库的其他文件中包含。将这些生成的文件放在构建目录的子文件夹中是一种好的做法,这样只有部分目录会被添加到include路径中。生成的文件的include结构应该与库其他部分的include结构保持一致。所以,在这个例子中,如果所有公共头文件都是通过#include <hello/a_public_header.h>方式包含的,那么导出头文件也应该放在名为hello的文件夹中。生成的文件还必须添加到安装指令中,正如在第四章中所解释的那样,打包、部署和安装 CMake 项目。此外,为了生成导出文件,必须为目标设置必要的编译器标志来导出符号。

由于生成的头文件必须包含在声明要导出的类、函数和类型的文件中,因此CMAKE_CURRENT_BINARY_DIR/export/被添加到target_include_directories中。请注意,这必须是PUBLIC,以便依赖的库也能够找到该文件。

关于generate_export_header宏还有许多其他选项,但我们在本节中所看到的已经涵盖了大部分常见用例。有关设置符号可见性的更多信息,请参阅官方 CMake 文档:cmake.org/cmake/help/latest/module/GenerateExportHeader.html

接口库或头文件库

头文件库有点特殊,因为它们不需要编译;相反,它们导出自己的头文件,以便可以直接在其他库中包含。在大多数方面,头文件库的工作方式与普通库相似,但它们的头文件是通过INTERFACE关键字公开的,而不是通过PUBLIC关键字。

由于头文件库不需要编译,它们不会将源代码添加到目标中。以下代码创建了一个最小的头文件库:

project(
  ch3_hello_header_only
  VERSION 1.0
  DESCRIPTION "Chapter 3 header-only example"
  LANGUAGES CXX)
add_library(hello_header_only INTERFACE)
target_include_directories(hello_header_only INTERFACE include/)
target_compile_features( hello_header_only INTERFACE cxx_std_17)

还值得注意的是,在 CMake 版本3.19之前,INTERFACE库不能添加任何target_sources。现在,头文件库可以列出源代码,但这种用法很少见。

对象库——仅供内部使用

有时,你可能想要拆分代码,以便某些部分可以被重用,而无需创建完整的库。一个常见的做法是,当你想在可执行文件和单元测试中使用一些代码,而无需重新编译所有内容两次时。

为此,CMake 提供了对象库,其中源代码会被编译,但不会被归档或链接。通过调用add_library(MyLibrary OBJECT)来创建一个对象库。

自 CMake 3.12 以来,这些目标可以像普通库一样通过将它们添加到target_link_libraries函数中来使用。在 3.12 版本之前,目标库需要通过生成表达式添加,即$<TARGET_OBJECTS:MyLibrary>。这将在构建系统生成期间扩展为一个对象列表。虽然这仍然可以做到,但不再推荐这样做,因为它会迅速变得难以维护,尤其是在项目中有多个目标库的情况下。

何时使用目标库

目标库有助于加速构建和模块化代码,而无需将模块公开。

使用目标库时,所有不同类型的库都被涵盖。库本身编写和维护都很有趣,但除非它们集成到更大的项目中,否则它们什么也做不了。所以,让我们看看到目前为止我们定义的所有库如何在可执行文件中使用。

汇总 - 使用你的库

到目前为止,我们已经创建了三种不同的库——一个二进制库,可以静态或动态链接,一个接口或仅头文件库,以及一个已预编译但未链接的目标库。

让我们学习如何在共享项目中将它们用于可执行文件。将它们作为系统库安装或作为外部依赖项使用将在第五章集成第三方库依赖管理中讨论。

所以,我们可以将add_library调用放在同一个CMakeLists.txt文件中,或者通过使用add_subdirectory将它们集成在一起。两者都是有效的选项,具体取决于项目的设置,如本章的设置项目处理嵌套项目部分所述。

在下面的示例中,我们假设在hello_libhello_header_onlyhello_object目录中已定义了三个带有CMakeLists.txt文件的库。这些库可以通过add_subdirectory命令包含进来。在这里,我们创建了一个名为chapter3的新目标,即我们的可执行文件。然后,通过target_link_libraries将这些库添加到可执行文件中:

add_subdirectory(hello_lib)
add_subdirectory(hello_header_only)
add_subdirectory(hello_object)
add_executable(chapter3)
target_sources(chapter3 PRIVATE src/main.cpp)
target_link_libraries(chapter3 PRIVATE hello_header_only hello_lib
  hello_object)

target_link_libraries的目标可以是一个可执行文件,也可以是另一个库。同样,库是通过访问说明符进行链接的,访问说明符可以是以下之一:

  • PRIVATE:该库用于链接,但它不是公共接口的一部分。链接的库只有在构建目标时才是必需的。

  • INTERFACE:该库不会被链接,但它是公共接口的一部分。当你在其他地方使用该目标时,链接的库是必需的。这通常只在你链接其他只包含头文件的库时使用。

  • PUBLIC:该库被链接,并且它是公共接口的一部分。因此,该库既是构建依赖项,也是使用依赖项。

注意 – 不良做法

本书的作者强烈不推荐以下做法,因为它们往往会创建难以维护的项目,并使得在不同的构建环境之间移植变得困难。不过,我们将其包括在内以确保内容的完整性。

PUBLICPRIVATEINTERFACE 后面传递另一个目标时,您还可以传递库的完整路径或库的文件名,例如 /usr/share/lib/mylib.so 或仅 mylib.so。这些做法是可以实现的,但不推荐使用,因为它们会使 CMake 项目变得不易移植。此外,您还可以通过传递类似 -nolibc 这样的内容来传递链接器标志,尽管同样不推荐这样做。如果所有目标都需要特殊的链接器标志,最好通过命令行传递它们。如果单个库需要特殊的标志,则使用 target_link_options 是推荐的做法,最好与命令行上设置的选项结合使用。

在下一节中,我们将讨论如何设置编译器和链接器选项。

设置编译器和链接器选项

C++ 编译器有很多选项,涉及一些常见的标志设置,同时从外部设置预处理器定义也是一种常见做法。在 CMake 中,这些选项是通过 target_compile_options 命令传递的。更改链接器行为则通过 target_link_options 命令实现。不幸的是,编译器和链接器可能在设置标志的方式上有所不同。例如,在 GCC 和 Clang 中,选项是通过连字符(-)传递的,而 Microsoft 编译器则使用斜杠(/)作为选项的前缀。但通过使用生成器表达式(在第一章《启动 CMake》中介绍过),可以方便地在 CMake 中处理这些差异,以下是一个示例:

target_compile_options(
  hello
  PRIVATE $<$<CXX_COMPILER_ID:MSVC>:/SomeOption>
          $<$<CXX_COMPILER_ID:GNU,Clang,AppleClang>:-someOption>
)

让我们详细看看这个生成器表达式。

$<$<CXX_COMPILER_ID:MSVC>:/SomeOption> 是一个嵌套的生成器表达式,按从内到外的顺序进行求值。生成器表达式在构建系统生成期间进行求值。首先,$<CXX_COMPILER_ID:MSVC> 如果 C++ 编译器为 MSVC,则求值为 true。如果是这种情况,那么外部表达式将返回 /SomeOption,然后传递给编译器。如果内部表达式求值为 false,则什么都不会传递。

$<$<CXX_COMPILER_ID:GNU,Clang,AppleClang>:-fopenmp> 类似地工作,但它不仅检查单一的值,而是传递一个包含 GNU,Clang,AppleClang 的列表。如果 CXX_COMPILER_ID 与这些值中的任何一个匹配,则内部表达式求值为 true,并将 someOption 传递给编译器。

将编译器或链接器选项传递为 PRIVATE 标记它们是此目标的构建要求,而不需要在库接口中使用。如果将 PRIVATE 替换为 PUBLIC,则编译选项也会成为一个使用要求,所有依赖于原始目标的目标将使用相同的编译选项。将编译器选项暴露给依赖目标需要谨慎处理。如果某个编译选项仅用于使用目标,但不用于构建目标,则可以使用 keyword INTERFACE。这通常出现在构建仅包含头文件的库时。

编译选项的特殊情况是预处理器或编译定义,这些定义会传递给底层程序。它们是通过 target_compile_definitions 函数传递的。

使用编译数据库调试编译选项

要查看所有编译选项,可以查看生成的构建文件,例如 Makefile 或 Visual Studio 项目。更方便的方法是让 CMake 将所有编译命令导出为 JSON 编译数据库。

通过启用 CMAKE_EXPORT_COMPILE_COMMANDS 变量,CMake 会在 build 文件夹中创建一个名为 compile_commands.json 的文件,里面包含完整的编译命令。

启用此选项并运行 CMake 后,将生成类似以下内容的结果:

{
  "directory": "/workspaces/CMake-Best-Pracitces/build",
  "command": "/usr/bin/g++ -I/workspaces/CMake-Best-Practices/
  chapter_3/hello_header_only/include -I/workspaces/CMake-Tips-and-
  Tricks/chapter_3/hello_lib/include -I/workspaces/CMake-Tips-and-
  Tricks/chapter_3/hello_object_lib/include -g -fopenmp -o
  chapter_3/CMakeFiles/chapter3.dir/src/main.cpp.o -c /workspaces
  /CMake-Best-Practices/chapter_3/src/main.cpp",
  "file": "/workspaces/CMake-Best-Practices/chapter_3/src/main.cpp"
},

注意从之前示例中手动指定的 -fopenMP 标志的添加。compile_commands.json 可以作为一种与构建系统无关的方式来加载命令。一些 IDE,如 VS Code 和 Clion,能够解析该 JSON 文件并自动生成项目信息。

编译命令数据库的完整规范可以在 clang.llvm.org/docs/JSONCompilationDatabase.html 找到。

目前,越来越多的工具使用 compile_commands.json 数据库来确定传递的确切编译选项,因此许多项目默认启用此功能。特别是,大多数来自 LLVM 的工具,例如 clang-tidy 静态分析工具或用于代码补全的 clangd,都能从访问编译数据库中获益匪浅。如果编译选项出现问题,也可以通过该数据库方便地调试编译选项:clang.llvm.org/docs/JSONCompilationDatabase.html

总结

现在你已经完成了本章的内容,准备好使用 CMake 创建应用程序和库,并开始构建比“hello world”更复杂的项目。你已经学会了如何将不同的目标链接在一起,以及如何将编译器和链接器选项传递给目标。我们还讨论了仅供内部使用的对象库,并讲解了共享库的符号可见性。最后,你学会了如何自动化文档化这些依赖关系,以便对大型项目有一个概览。

在下一章中,你将学习如何在不同平台上打包和安装你的应用程序和库。

问题

请回答以下问题,测试你对本章的理解:

  1. 创建可执行目标的 CMake 命令是什么?

  2. 创建库目标的 CMake 命令是什么?

  3. 如何指定一个库是静态链接还是动态链接?

  4. 对象库有什么特别之处,在哪里使用它们最为方便?

  5. 如何指定共享库的默认符号可见性?

  6. 如何为目标指定编译器选项,如何查看编译命令?

答案

  1. 创建可执行目标的 CMake 命令是:

    add_executable

  2. 创建库目标的 CMake 命令是:

    add_library

  3. 通过添加 SHAREDSTATIC 关键字,或者设置 BUILD_SHARED_LIBS 全局变量

  4. 对象库是已编译但未链接的库。它们用于在内部分离代码,并减少编译时间。

  5. 通过全局设置 <LANG>_VISIBILITY_PRESET 属性

  6. 通过调用 target_compile_options 函数。编译选项可以在 compile_commands.json 文件中查看,该文件会在将 CMAKE_EXPORT_COMPILE_COMMANDS 变量设置为 true 时生成。

第二部分 – 实用 CMake – 亲自动手使用 CMake

在这一部分中,你将能够以适应大多数用例的方式使用 CMake 设置软件项目。第 45 章将涵盖项目安装和打包、依赖管理以及包管理器的使用。

第 6、78 章将介绍如何将外部工具集成到 CMake 项目中,以生成文档、确保代码质量或执行几乎任何构建软件所需的任务。第 910 章将讲解如何为构建项目创建可重用的环境,并处理来自分布式仓库的大型项目。

最后,第十一章将稍微介绍一下 macOS 生态系统,并覆盖一些特定的构建和部署 Apple 系统软件的要求。

本部分包含以下章节:

  • 第四章**,打包、部署和 安装 CMake 项目

  • 第五章**, 集成第三方库和依赖管理

  • 第六章**,自动生成文档

  • 第七章**,无缝集成代码质量工具与 CMake

  • 第八章**,使用 CMake 执行自定义任务

  • 第九章**,创建可重现的构建环境

  • 第十章**, 在超级构建中处理分布式仓库和依赖关系

  • 第十一章**,为 Apple 系统创建软件

第四章:打包、部署和安装 CMake 项目

正确地打包软件往往被编写和构建软件的过程所掩盖,然而它通常是确保任何软件项目成功和持久性的一个重要因素。打包是开发者创作与最终用户体验之间的桥梁,涵盖了从分发到安装和维护的方方面面。打包得当的软件不仅简化了部署过程,还增强了用户满意度、可靠性,并且便于无缝更新和修复漏洞。

确保软件以与这些不同环境兼容的方式打包,对其可用性和可访问性至关重要。此外,用户的技术能力跨度广泛,从经验丰富的专业人士到新手不等。因此,打包必须迎合这一范围,为经验较少的用户提供直观的安装过程,同时为技术熟练的用户提供高级选项。此外,遵守安装标准对于用户的便利性和系统完整性都至关重要。通过遵循既定的打包规范,开发者可以减少在目标系统中堆积不必要的文件或冲突的依赖关系,从而促进系统的稳定性和整洁性。归根结底,软件打包是将原始代码转化为精致、可访问产品的关键最后一步,和开发过程本身一样至关重要。

CMake 内部有良好的支持和工具,使得安装和打包变得简单。这一点的好处在于,CMake 利用现有的项目代码来实现这些功能。因此,使项目可安装或打包项目不会带来沉重的维护成本。本章中,我们将学习如何利用 CMake 在安装和打包方面的现有能力,来支持部署工作。

本章将涵盖以下主题:

  • 使 CMake 目标可安装

  • 使用你的项目为他人提供配置信息

  • 使用 CPack 创建可安装包

技术要求

在深入本章之前,你应该对 CMake 中的目标有一个良好的理解(在第一章《启动 CMake》和第三章《创建 CMake 项目》中简要介绍,详细内容见其中)。本章将基于这些知识进行扩展。

请从本书的 GitHub 仓库获取本章的示例,地址为 github.com/PacktPublishing/CMake-Best-Practices---2nd-Edition。本章的示例内容位于 chapter04/ 子文件夹中。

使 CMake 目标可安装

在 CMake 的上下文中,安装打包软件是两个不同的概念。安装软件涉及将可执行文件、库和其他必要资源复制到预定的位置。而打包则是将所有必要的文件和依赖项捆绑成一个可分发格式(例如 tarball、ZIP 压缩包或安装程序包),以便于在其他系统上进行分发和安装。CMake 的打包机制是先将项目安装到临时位置,然后将安装的文件打包成适当的格式。

支持项目部署的最原始方式是将其设置为可安装。相反,最终用户仍然必须获取项目的源代码并从头开始构建它。一个可安装的项目会有额外的构建系统代码,用于在系统上安装运行时或开发工件。如果有适当的安装指令,构建系统将在这里执行安装操作。由于我们使用 CMake 生成构建系统文件,因此 CMake 必须生成相关的安装代码。在这一节中,我们将深入探讨如何指导 CMake 为 CMake 目标生成此类代码的基础知识。

install()命令

install(...)命令是一个内置的 CMake 命令,允许您生成安装目标、文件、目录等的构建系统指令。CMake 不会生成安装指令,除非明确告诉它这么做。因此,什么内容被安装始终在您的控制之下。让我们来看一下它的基本用法。

安装 CMake 目标

要使 CMake 目标可安装,必须指定TARGETS参数,并提供至少一个参数。该用法的命令签名如下:

install(TARGETS <target>... [...])

TARGETS参数表示install将接受一组 CMake 目标,生成安装代码。在这种形式下,只有目标的输出工件会被安装。目标的最常见输出工件定义如下:

  • ARCHIVE(静态库、DLL 导入库和链接器导入文件):

    • 除了在 macOS 中标记为FRAMEWORK的目标
  • LIBRARY(共享库):

    • 除了在 macOS 中标记为FRAMEWORK的目标

    • 除了 DLL(在 Windows 中)

  • RUNTIME(可执行文件和 DLL):

    • 除了在 macOS 中标记为MACOSX_BUNDLE的目标

在将目标设置为可安装后,CMake 会生成必要的安装代码,以便安装为该目标生成的输出工件。为了说明这一点,让我们一起将一个基本的可执行目标设置为可安装。要查看install(...)命令的实际操作,我们可以查看位于chapter04/ex01_executable文件夹中的Chapter 4example 1CMakeLists.txt文件:

add_executable(ch4_ex01_executable)
target_sources(ch4_ex01_executable src/main.cpp)
target_compile_features(ch4_ex01_executable PRIVATE cxx_std_11)
install(TARGETS ch4_ex01_executable)

在前面的代码中,定义了一个名为ch4_ex01_executable的可执行目标,并在接下来的两行中填充了它的属性。最后一行install(...)是我们感兴趣的部分,它告诉 CMake 为ch4_ex01_executable创建所需的安装代码。

为了检查ch4_ex01_executable是否可以被安装,让我们在chapter 4的根文件夹中通过 CLI 构建并安装该项目:

cmake -S . -B ./build -DCMAKE_BUILD_TYPE="Release"
cmake --build ./build
cmake --install ./build --prefix /tmp/install-test

注意

与其为cmake --install指定--prefix参数,你也可以使用CMAKE_INSTALL_PREFIX变量来提供非默认的install前缀。

在使用 CMake 与多配置生成器(如 Ninja 多配置和 Visual Studio)时,请为cmake --buildcmake --install命令指定--config参数:

# For multi-config generators:
cmake --build ./build --config Release
cmake --install ./build --prefix /tmp/install-test --config Debug

让我们检查一下cmake --install命令的作用:

-- Install configuration: "Release"
-- Installing: /tmp/install-test/lib/libch2.framework.component1.a
-- Installing: /tmp/install-test/lib/libch2.framework.component2.so
-- Installing: /tmp/install-test/bin/ch2.driver_application
-- Set runtime path of "/tmp/install-test/bin/
    ch2.driver_application" to ""
-- Installing: /tmp/install-test/bin/ch4_ex01_executable

在前面输出的最后一行中,我们可以看到ch4_ex01_executable目标的输出工件——也就是说,ch4_ex01_executable二进制文件已经被安装。由于这是ch4_ex01_executable目标的唯一输出工件,我们可以得出结论,目标确实已经变得可以安装了。

请注意,ch4_ex01_executable并没有直接安装到/tmp/install-test(前缀)目录中。相反,install命令将它放入了bin/子目录。这是因为 CMake 智能地判断了应该将什么类型的工件放到哪里。在传统的 UNIX 系统中,二进制文件通常放在/usr/bin,而库文件放在/usr/lib。CMake 知道add_executable()命令会生成一个可执行的二进制工件,并将其放入/bin子目录。这些目录是 CMake 默认提供的,具体取决于目标类型。提供默认安装路径信息的 CMake 模块被称为GNUInstallDirs模块。GNUInstallDirs模块在被包含时定义了各种CMAKE_INSTALL_路径。下表显示了各个目标的默认安装目录:

目标类型 GNUInstallDirs 变量 内置默认值
RUNTIME $ bin
LIBRARY $ lib
ARCHIVE $ lib
PRIVATE_HEADER $ include
PUBLIC_HEADER $ include

为了覆盖内置的默认值,install(...)命令中需要一个额外的<TARGET_TYPE> DESTINATION参数。为了说明这一点,假设我们要将默认的RUNTIME安装目录更改为qbin,而不是bin。这样做只需要对原始的install(...)命令做一个小的修改:

# …
install(TARGETS ch4_ex01_executable
        RUNTIME DESTINATION qbin
)

做出此更改后,我们可以重新运行 configurebuildinstall 命令。我们可以通过检查 cmake --install 命令的输出确认 RUNTIME 目标已经更改。与第一次不同,我们可以观察到 ch4_ex01_executable 二进制文件被放入 qbin 而不是默认的 (bin) 目录:

# ...
-- Installing: /tmp/install-test/qbin/ch4_ex01_executable

现在,让我们看另一个示例。这次我们将安装一个 STATIC 库。让我们看看 第四章 中的 CMakeLists.txt 文件,示例 2,它位于 chapter04/ex02_static 文件夹中。由于篇幅原因,注释和 project(...) 命令已被省略。让我们开始检查文件:

add_library(ch4_ex02_static STATIC)
target_sources(ch4_ex02_static PRIVATE src/lib.cpp)
target_include_directories(ch4_ex02_static PUBLIC include)
target_compile_features(ch4_ex02_static PRIVATE cxx_std_11)
include(GNUInstallDirs)
install(TARGETS ch4_ex02_static)
install (
     DIRECTORY include/
     DESTINATION "${CMAKE_INSTALL_INCLUDEDIR}"
)

如你所见,它与我们之前的示例稍有不同。首先,新增了一个带有 DIRECTORY 参数的 install(...) 命令。这是为了使静态库的头文件可以被安装。原因是 CMake 不会安装任何不是 输出产物 的文件,而 STATIC 库目标只会生成一个二进制文件作为 输出产物。头文件不是 输出产物,应单独安装。

注意

DIRECTORY 参数中的尾随斜杠会导致 CMake 复制文件夹内容,而不是按名称复制文件夹。CMake 处理尾随斜杠的方式与 Linux 的 rsync 命令相同。

安装文件和目录

如我们在前一节中看到的,我们打算安装的内容并不总是目标的 输出产物。它们可能是目标的运行时依赖项,例如图像、资源、配置文件、脚本和资源文件。CMake 提供了 install(FILES...)install(DIRECTORY...) 命令,用于安装任何特定的文件或目录。让我们从安装文件开始。

安装文件

install(FILES...) 命令接受一个或多个文件作为参数。它还需要一个额外的 TYPEDESTINATION 参数。这两个参数用于确定指定文件的目标目录。TYPE 参数用于指示哪些文件将使用该文件类型的默认路径作为安装目录。通过设置相关的 GNUInstallDirs 变量可以覆盖默认值。以下表格显示了有效的 TYPE 值及其目录映射:

类型 GNUInstallDirs 变量 内置默认值
BIN $ bin
SBIN $ sbin
LIB $ lib
INCLUDE $ include
SYSCONF $ etc
SHAREDSTATE $ com
LOCALSTATE $ var
RUNSTATE $ /run
DATA $
INFO $ /info
LOCALE $ /locale
MAN $ /man
DOC $ /doc

如果你不想使用 TYPE 参数,可以改用 DESTINATION 参数。它允许你为 install(...) 命令中指定的文件提供自定义的目标位置。

install(FILES...) 的一种替代形式是 install(PROGRAMS...),它与 install(FILES...) 相同,区别在于它还为已安装的文件设置了 OWNER_EXECUTEGROUP_EXECUTEWORLD_EXECUTE 权限。对于必须由最终用户执行的二进制文件或脚本文件来说,这样做是有意义的。

要理解 install(FILES|PROGRAMS...),让我们看一个例子。我们将要查看的例子是 第四章**,示例 3chapter04/ex03_file)。它实际上包含了三个文件:chapter04_greeter_contentchapter04_greeter.pyCMakeLists.txt。首先,让我们看看它的 CMakeLists.txt 文件:

install(FILES "${CMAKE_CURRENT_LIST_DIR}/chapter04_greeter_content"
  DESTINATION "${CMAKE_INSTALL_BINDIR}")
install(PROGRAMS "${CMAKE_CURRENT_LIST_DIR}/chapter04_greeter.py"
  DESTINATION "${CMAKE_INSTALL_BINDIR}" RENAME chapter04_greeter)

让我们消化一下我们所看到的内容;在第一个 install(...) 命令中,我们告诉 CMake 将 chapter04_greeter_content 文件安装到当前 CMakeLists.txt 目录(chapter04/ex03_file)的系统默认 BIN 目录中。在第二个 install(...) 命令中,我们告诉 CMake 将 chapter04_greeter.py 文件安装到默认的 BIN 目录,并且文件名为 chapter04_greeter

注意

RENAME 参数仅在单文件 install(...) 调用时有效。

使用这些 install(...) 指令,CMake 应该会将 chapter04_greeter.pychapter04_greeter_content 文件安装到 ${CMAKE_INSTALL_PREFIX}/bin 目录。让我们通过 CLI 构建并安装项目:

cmake -S . -B ./build
cmake --build ./build
cmake --install ./build --prefix /tmp/install-test

让我们看看 cmake --install 命令做了什么:

/* … */
-- Installing: /tmp/install-test/bin/chapter04_greeter_content
-- Installing: /tmp/install-test/bin/chapter04_greeter

上面的输出确认了 CMake 为 chapter04_greeter_contentchapter04_greeter.py 文件生成了所需的安装代码。最后,让我们检查一下 chapter04_greeter 文件是否能够执行,因为我们使用了 PROGRAMS 参数来安装它:

15:01 $ /tmp/install-test/bin/chapter04_greeter
['Hello from installed file!']

这样,我们就完成了 install(FILES|PROGRAMS...) 部分的内容。接下来,让我们继续安装目录。

安装目录

install(DIRECTORY...) 命令对于安装目录非常有用。目录的结构将会被原样复制到目标位置。目录可以作为整体安装,也可以选择性地安装。让我们先从最基本的目录安装示例开始:

install(DIRECTORY dir1 dir2 dir3 TYPE LOCALSTATE)

上面的例子将会把 dir1dir2 目录安装到 ${CMAKE_INSTALL_PREFIX}/var 目录中,并且连同它们的所有子文件夹和文件一起原样安装。有时候,安装文件夹的全部内容并不理想。幸运的是,CMake 允许 install 命令根据通配符模式和正则表达式来包含或排除目录内容。让我们这次选择性地安装 dir1dir2dir3

include(GNUInstallDirs)
install(DIRECTORY dir1 DESTINATION ${CMAKE_INSTALL_LOCALSTATEDIR}
  FILES_MATCHING PATTERN "*.x")
install(DIRECTORY dir2 DESTINATION ${CMAKE_INSTALL_LOCALSTATEDIR}
  FILES_MATCHING PATTERN "*.hpp" EXCLUDE PATTERN "*")
install(DIRECTORY dir3 DESTINATION ${CMAKE_INSTALL_LOCALSTATEDIR}
  PATTERN "bin" EXCLUDE)

在前面的示例中,我们使用了FILES_MATCHING参数来定义文件选择的标准。FILES_MATCHING后面可以跟PATTERNREGEX参数。PATTERN允许您定义一个通配符模式,而REGEX允许您定义一个正则表达式。默认情况下,这些表达式用于包含文件。如果要排除符合标准的文件,可以在模式后添加EXCLUDE参数。请注意,这些过滤器不会应用于子目录名称,因为FILES_MATCHING参数的限制。我们还在最后一个install(...)命令中使用了PATTERN而没有加上FILES_MATCHING,这使得我们可以过滤子目录而非文件。这一次,只有dir1中扩展名为.x的文件、dir2中没有.hpp扩展名的文件以及dir3中除bin文件夹外的所有内容将被安装。这个示例可以在chapter04/ex04_directory文件夹中的Chapter 4**,示例 4中找到。让我们编译并安装它,看看它是否执行了正确的操作:

cmake -S . -B ./build
cmake -- build ./build
cmake -- install ./build –prefix /tmp/install-test

cmake --install的输出应该如下所示:

-- Installing: /tmp/install-test/var/dir1
-- Installing: /tmp/install-test/var/dir1/subdir
-- Installing: /tmp/install-test/var/dir1/subdir/asset5.x
-- Installing: /tmp/install-test/var/dir1/asset1.x
-- Installing: /tmp/install-test/var/dir2
-- Installing: /tmp/install-test/var/dir2/chapter04_hello.dat
-- Installing: /tmp/install-test/var/dir3
-- Installing: /tmp/install-test/var/dir3/asset4

注意

FILES_MATCHING不能在PATTERNREGEX之后使用,但可以反过来使用。

在输出中,我们可以看到只有扩展名为.x的文件被从dir1中选取。这是因为在第一个install(...)命令中使用了FILES_MATCHING PATTERN "*.x"参数,导致asset2文件没有被安装。同时,注意到dir2/chapter04_hello.dat文件被安装,而dir2/chapter04_hello.hpp文件被跳过。这是因为第二个install(…)命令中的FILES_MATCHING PATTERN "*.hpp" EXCLUDE PATTERN "*"参数所致。最后,我们看到dir3/asset4文件被安装,而dir3/bin目录被完全跳过,因为在最后一个install(...)命令中指定了PATTERN "bin" EXCLUDE参数。

使用install(DIRECTORY...)时,我们已经涵盖了install(...)命令的基础知识。接下来,让我们继续了解install(…)命令的其他常见参数。

install()命令的其他常见参数

如我们所见,install()命令的第一个参数指示要安装的内容。还有一些额外的参数可以让我们定制安装过程。让我们一起查看一些常见的参数。

DESTINATION 参数

该参数允许你为 install(...) 命令中指定的文件指定目标目录。目录路径可以是相对路径或绝对路径。相对路径将相对于 CMAKE_INSTALL_PREFIX 变量。建议使用相对路径以使安装可重定位。此外,为了打包,使用相对路径也很重要,因为 cpack 要求安装路径必须是相对的。最好使用以相关的 GNUInstallDirs 变量开头的路径,这样包维护者可以根据需要覆盖安装目标位置。DESTINATION 参数可以与 TARGETSFILESIMPORTED_RUNTIME_ARTIFACTSEXPORTDIRECTORY 安装类型一起使用。

PERMISSIONS 参数

该参数允许你在支持的平台上更改已安装文件的权限。可用的权限有 OWNER_READOWNER_WRITEOWNER_EXECUTEGROUP_READGROUP_WRITEGROUP_EXECUTEWORLD_READWORLD_WRITEWORLD_EXECUTESETUIDSETGIDPERMISSIONS 参数可以与 TARGETSFILESIMPORTED_RUNTIME_ARTIFACTSEXPORTDIRECTORY 安装类型一起使用。

CONFIGURATIONS 参数

这允许你在指定特定构建配置时限制应用的参数集。

OPTIONAL 参数

该参数使得文件的安装变为可选,这样当文件不存在时,安装不会失败。OPTIONAL 参数可以与 TARGETSFILESIMPORTED_RUNTIME_ARTIFACTSDIRECTORY 安装类型一起使用。

在本节中,我们学习了如何使目标、文件和目录可安装。在下一节中,我们将学习如何生成配置信息,以便可以直接将 CMake 项目导入到另一个 CMake 项目中。

为他人提供项目的配置信息

在上一节中,我们学习了如何使我们的项目可安装,以便他人可以通过安装它到他们的系统中来使用我们的项目。但有时候,仅仅交付制品并不足够。例如,如果你交付的是一个库,它必须也能方便地导入到另一个项目中——尤其是 CMake 项目中。在本节中,我们将学习如何让其他 CMake 项目更容易导入你的项目。

如果被导入的项目具有适当的配置文件,则有一些便捷的方法可以导入库。一个突出的方式是利用 find_package() 方法(我们将在第五章中讲解,集成第三方库依赖管理)。如果你的消费者在工作流程中使用 CMake,他们会很高兴能够直接写 find_package(your_project_name),并开始使用你的代码。在本节中,我们将学习如何生成所需的配置文件,以使 find_package() 能在你的项目中正常工作。

CMake 推荐的依赖管理方式是通过包(packages)。包用于传递 CMake 基于构建系统的依赖信息。包可以是 Config-file 包、Find-module 包或 pkg-config 包的形式。所有这些包类型都可以通过 find_package() 查找并使用。为了提高效率并遵循最佳实践,本节将仅关注 Config-file 包。其他方法,如 find-modulespkg-config 包,通常被视为过时的变通方法,主要在没有配置文件的情况下使用,通常不推荐使用。让我们深入了解 Config-file 包,理解它们的优点和实现方式。

进入 CMake 包的世界 —— Config-file

Config-file 包基于包含包内容信息的配置文件。这些信息指示包的内容位置,因此 CMake 会读取此文件并使用该包。因此,仅发现包的配置文件就足够使用该包了。

配置文件有两种类型 —— 包配置文件和可选的包版本文件。两个文件都必须遵循特定的命名约定。包配置文件可以命名为 <ProjectName>Config.cmake<projectname>-config.cmake,具体取决于个人偏好。在 find_package(ProjectName)/find_package(projectname) 调用时,CMake 会自动识别这两种命名方式。包配置文件的内容大致如下:

set(Foo_INCLUDE_DIRS ${PREFIX}/include/foo-1.2)
set(Foo_LIBRARIES ${PREFIX}/lib/foo-1.2/libfoo.a)

在这里,${PREFIX} 是项目的安装前缀。它是一个变量,因为安装前缀可以根据系统类型进行更改,也可以由用户更改。

和包配置文件一样,包版本文件也可以命名为 <ProjectName>ConfigVersion.cmake<projectname>-config-version.cmake。CMake 期望在 find_package(...) 搜索路径中找到包配置文件和包版本文件。你可以在 CMake 的帮助下创建这些文件。find_package(...) 在查找包时会检查多个位置,其中之一就是 <CMAKE_PREFIX_PATH>/cmake 目录。在我们的例子中,我们将把 config-file 包配置文件放到这个文件夹中。

为了创建 config-file 包,我们需要了解一些额外的内容,例如 CmakePackageConfigHelpers 模块。为了了解这些内容,让我们开始深入探讨一个实际的例子。我们将跟随 第四章**,示例 5 来学习如何构建一个 CMake 项目,将其组织成 chapter04/ex05_config_file_package 文件夹。首先,让我们检查 chapter04/ex05_config_file_package 目录中的 CMakeLists.txt 文件(注释和项目命令已省略以节省空间;另外,请注意,所有与主题无关的行将不被提及):

include(GNUInstallDirs)
set(ch4_ex05_lib_INSTALL_CMAKEDIR cmake CACHE PATH "Installation
  directory for config-file package cmake files")Is

CMakeLists.txt 文件与 chapter04/ex02_static 非常相似。这是因为它是同一个示例,只是它支持 config-file 包。第一行 include(GNUInstallDirs) 用于包含 GNUInstallDirs 模块。这个模块提供了 CMAKE_INSTALL_INCLUDEDIR 变量,稍后会用到。set(ch4_ex05_lib_INSTALL_CMAKEDIR...) 是一个用户定义的变量,用于设置 config-file 打包配置文件的目标安装目录。它是一个相对路径,应在 install(…) 指令中使用,因此它隐式地是相对于 CMAKE_INSTALL_PREFIX 的:

target_include_directories(ch4_ex05_lib PUBLIC
      $<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
)
target_compile_features(ch4_ex05_lib PUBLIC cxx_std_11)

target_include_directories(...) 调用与通常的调用非常不同。它使用了 generator expressions 来区分构建时的 include 目录和安装时的 include 目录,因为构建时的 include 路径在目标被导入到另一个项目时将不存在。以下一组命令将使目标可安装:

install(TARGETS ch4_ex05_lib
        EXPORT ch4_ex05_lib_export
        INCLUDES DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}
)
install (
      DIRECTORY ${PROJECT_SOURCE_DIR}/include/
      DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}
)

install(TARGETS...) 与常规调用稍有不同。它包含了一个额外的 EXPORT 参数。这个 EXPORT 参数用于从给定的 install(…) 目标创建一个导出名称。然后可以使用这个导出名称来导出这些目标。通过 INCLUDES DESTINATION 参数指定的路径将用于填充导出目标的 INTERFACE_INCLUDE_DIRECTORIES 属性,并会自动加上安装前缀路径。在这里,install(DIRECTORY...) 命令用于安装目标的头文件,这些文件位于 ${PROJECT_SOURCE_DIR}/include/,并安装到 ${CMAKE_INSTALL_PREFIX}/${CMAKE_INSTALL_INCLUDEDIR} 目录下。${CMAKE_INSTALL_INCLUDEDIR} 变量用于为用户提供覆盖此安装的 include 目录的能力。现在,让我们从之前示例中创建一个导出文件:

install(EXPORT ch4_ex05_lib_export
        FILE ch4_ex05_lib-config.cmake
        NAMESPACE ch4_ex05_lib::
        DESTINATION ${ch4_ex05_lib_INSTALL_CMAKEDIR}
)

install(EXPORT...) 可能是这个文件中最重要的代码部分。它执行实际的目标导出。它生成一个包含所有导出目标的 CMake 文件,并使用给定的导出名称。EXPORT 参数接受一个现有的导出名称来执行导出。它引用了我们之前通过 install(TARGETS...) 调用创建的 ch4_ex05_lib_export 导出名称。FILE 参数用于确定导出的文件名,并设置为 ch4_ex05_lib-config.cmakeNAMESPACE 参数用于给所有导出的目标添加前缀命名空间。这使得你可以将所有导出的目标放在一个公共的命名空间下,避免与其他有相似目标名称的包发生冲突。最后,DESTINATION 参数确定了生成的导出文件的安装路径。它设置为 ${ch4_ex05_lib_INSTALL_CMAKEDIR},以便 find_package() 可以找到它。

注意

由于我们除了导出的目标之外不提供任何额外内容,因此导出文件的名称是ch4_ex05_lib-config.cmake。这是此包所需的包配置文件名称。我们这样做是因为示例项目不需要先满足任何额外的依赖关系,可以直接按原样导入。如果需要任何额外的操作,建议先创建一个中间包配置文件,以满足这些依赖关系,然后再包含导出的文件。

使用install(EXPORT...)命令,我们获得了ch4_ex05_lib-config.cmake文件。这意味着我们的目标可以通过find_package(..)来使用。为了实现对find_package(…)的完全支持,还需要执行一个额外步骤,即获取ch4_ex05_lib-config-version.cmake文件:

/*…*/
include(CMakePackageConfigHelpers)
write_basic_package_version_file(
  "ch4_ex05_lib-config-version.cmake"
  # Package compatibility strategy. SameMajorVersion is essentially
    `semantic versioning`.
  COMPATIBILITY SameMajorVersion
)
install(FILES
  "${CMAKE_CURRENT_BINARY_DIR}/ch4_ex05_lib-config-version.cmake"
  DESTINATION «${ch4_ex05_lib_INSTALL_CMAKEDIR}»
)
/* end of the file */

在最后几行中,您可以找到生成并安装ch4_ex05_lib-config-version.cmake文件所需的代码。通过include(CMakePackageConfigHelpers)这一行,导入了CMakePackageConfigHelpers模块。该模块提供了write_basic_package_version_file(…)函数。write_basic_package_version_file(…)函数用于根据给定的参数自动生成包版本文件。第一个位置参数是输出文件的文件名。VERSION参数用于指定我们正在生成的包的版本,格式为major.minor.patch。我们选择不显式指定版本,以允许write_basic_package_version_file自动从项目版本中获取。COMPATIBILITY参数允许根据版本值指定兼容性策略。SameMajorVersion表示该包与任何具有相同主版本号的版本兼容。其他可能的值包括AnyNewerVersionSameMinorVersionExactVersion

现在,让我们测试一下这个是否有效。为了测试包配置,我们必须以常规方式安装项目:

cmake -S . -B ./build
cmake --build ./build
cmake --install ./build --prefix /tmp/install-test

cmake --install命令的输出应如下所示:

-- Installing: /tmp/install-test/cmake/ch4_ex05_lib-config.cmake
-- Installing: /tmp/install-test/cmake/ch4_ex05_lib-config-
  noconfig.cmake
-- Installing: /tmp/install-test/cmake/ch4_ex05_lib-config-
  version.cmake

在这里,我们可以看到我们的包配置文件已成功安装到/tmp/install-test/cmake目录中。检查这些文件的内容作为练习留给您自己。所以,现在我们手头有一个可消费的包。让我们换个角度,尝试消费我们新创建的包。为此,我们将查看chapter04/ex05_consumer示例。让我们一起检查CMakeLists.txt文件:

if(NOT PROJECT_IS_TOP_LEVEL)
  message(FATAL_ERROR "The chapter-4, ex05_consumer project is
    intended to be a standalone, top-level project. Do not include
      this directory.")
endif()
find_package(ch4_ex05_lib 1 CONFIG REQUIRED)
add_executable(ch4_ex05_consumer src/main.cpp)
target_compile_features(ch4_ex05_consumer PRIVATE cxx_std_11)
target_link_libraries(ch4_ex05_consumer ch4_ex05_lib::ch4_ex05_lib)

在前几行中,我们可以看到关于该项目是否是顶级项目的验证。由于这个示例旨在作为外部应用程序,它不应成为根示例项目的一部分。因此,我们可以保证使用由软件包导出的目标,而不是根项目的目标。根项目也不包括ex05_consumer文件夹。接下来,有一个find_package(…)调用,其中ch4_ex05_lib作为软件包名称给出。还明确要求该软件包的主版本为 1;find_package(…)只能考虑CONFIG软件包,并且此find_package(…)调用中指定的软件包是必需的。在接下来的几行中,定义了一个常规可执行文件ch4_ex05_consumer,它在ch4_ex05_lib命名空间下链接到ch4_ex05_libch4_ex05_lib::ch4_ex05_lib)。ch4_ex05_lib::ch4_ex05_lib就是我们在软件包中定义的实际目标。让我们来看一下源文件src/main.cpp

#include <chapter04/ex05/lib.hpp>
int main(void){
    chapter04::ex05::greeter g;
    g.greet();
}

这是一个简单的应用程序,它包括chapter04/ex05/lib.hpp,创建一个greeter类的实例,并调用greet()函数。让我们尝试编译并运行该应用程序:

cd chapter04/ex05_consumer
cmake -S . -B build/ -DCMAKE_PREFIX_PATH:STRING=/tmp/install-test
cmake --build build/
./build/ch4_ex05_consumer

由于我们已经使用自定义前缀(/tmp/install-test)安装了软件包,我们可以通过设置CMAKE_PREFIX_PATH变量来指示这一点。这将使得find_package(…)/tmp/install-test中也查找软件包。对于默认前缀安装,此参数设置是不可选的。如果一切顺利,我们应该看到臭名昭著的Hello, world!消息:

 ./build/ch4_ex05_consumer
Hello, world!

在这里,我们的消费者可以使用我们的小欢迎程序,每个人都很高兴。现在,让我们通过学习如何使用CPack打包来结束这一部分。

使用 CPack 创建可安装软件包

到目前为止,我们已经看到了 CMake 如何构建软件项目。尽管 CMake 是这场演出的主角,但它也有一些强大的朋友。现在是时候向你介绍 CPack——CMake 的打包工具了。它默认与 CMake 一起安装。它允许你利用现有的 CMake 代码生成特定平台的安装包。CPack 的概念类似于 CMake。它基于生成器,这些生成器生成的是软件包而非构建系统文件。下表展示了截至版本 3.21.3 的可用 CPack 生成器类型:

生成器名称 描述
7Z 7-zip 压缩档案
DEB Debian 软件包
External CPack 外部软件包
IFW Qt 安装程序框架
NSIS Null Soft 安装程序
NSIS64 Null Soft 安装程序(64 位)
NuGet NuGet 软件包
RPM RPM 软件包
STGZ 自解压 TAR gzip 压缩档案
TBZ2 Tar BZip2 压缩档案
TGZ Tar GZip 压缩档案
TXZ Tar XZ 压缩档案
TZ Tar 压缩档案
TZST Tar Zstandard 压缩档案
ZIP Zip 压缩档案

CPack 使用 CMake 的安装机制来填充包的内容。CPack 使用位于CPackConfig.cmakeCPackSourceConfig.cmake文件中的配置详情来生成包。这些文件可以手动填充,也可以通过 CMake 配合 CPack 模块自动生成。对于一个已有的 CMake 项目,使用 CPack 非常简单,只需要包含 CPack 模块,前提是项目已经有正确的install(…)命令。包含 CPack 模块会使 CMake 生成CPackConfig.cmakeCPackSourceConfig.cmake文件,这些文件是打包项目所需的 CPack 配置。此外,还会生成一个额外的package目标,用于构建步骤。这个步骤会构建项目并运行 CPack,从而开始打包。当 CPack 配置文件已经正确填充时,无论是通过 CMake 还是用户,CPack 都可以使用。CPack 模块允许你自定义打包过程。可以设置大量的 CPack 变量,这些变量分为两类——通用变量和生成器特定变量。通用变量影响所有包生成器,而生成器特定变量仅影响特定类型的生成器。我们将检查最基本和最显著的变量,主要处理通用变量。以下表格展示了我们将在示例中使用的最常见的 CPack 变量:

变量名 描述 默认值
CPACK_PACKAGE_NAME 包名 项目名
CPACK_PACKAGE_VENDOR 包的供应商名称 “Humanity”
CPACK_PACKAGE_VERSION_MAJOR 包的主版本 项目的主版本
CPACK_PACKAGE_VERSION_MINOR 包的次版本 项目的次版本
CPACK_PACKAGE_VERSION_PATCH 包的补丁版本 项目的补丁版本
CPACK_GENERATOR 使用的 CPack 生成器列表
CPACK_THREADS 支持并行时使用的线程数 1

必须在包含 CPack 模块之前修改变量,否则将使用默认值。让我们通过一个例子来深入了解 CPack 的实际操作。我们将跟随第四章示例 6chapter04/ex06_pack)进行。这个示例是一个独立的项目,不是根项目的一部分。它是一个常规项目,包含名为executablelibrary的两个子目录。executable目录的CMakeLists.txt文件如下所示:

add_executable(ch4_ex06_executable src/main.cpp)
target_compile_features(ch4_ex06_executable PRIVATE cxx_std_11)
target_link_libraries(ch4_ex06_executable PRIVATE ch4_ex06_library)
install(TARGETS ch4_ex06_executable)

library目录的CMakeLists.txt文件如下所示:

add_library(ch4_ex06_library STATIC src/lib.cpp)
target_compile_features(ch4_ex06_library PRIVATE cxx_std_11)
target_include_directories(ch4_ex06_library PUBLIC include)
set_target_properties(ch4_ex06_library PROPERTIES PUBLIC_HEADER
  include/chapter04/ex06/lib.hpp)
include(GNUInstallDirs) # Defines the ${CMAKE_INSTALL_INCLUDEDIR}
  variable.
install(TARGETS ch4_ex06_library)
install (
    DIRECTORY ${PROJECT_SOURCE_DIR}/include/
    DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}
)

这些文件夹的CMakeLists.txt文件并没有什么特别之处。它们包含常规的可安装 CMake 目标,并且没有声明关于 CPack 的任何内容。让我们也看一下顶级CMakeLists.txt文件:

cmake_minimum_required(VERSION 3.21)
project(
  ch4_ex06_pack
  VERSION 1.0
  DESCRIPTION "Chapter 4 Example 06, Packaging with CPack"
  LANGUAGES CXX)
if(NOT PROJECT_IS_TOP_LEVEL)
  message(FATAL_ERROR "The chapter04, ex06_pack project is intended
    to be a standalone, top-level project. Do not include this
      directory.")
endif()
add_subdirectory(executable)
add_subdirectory(library)
set(CPACK_PACKAGE_VENDOR "CBP Authors")
set(CPACK_GENERATOR "DEB;RPM;TBZ2")
set(CPACK_THREADS 0)
set(CPACK_DEBIAN_PACKAGE_MAINTAINER "CBP Authors")
include(CPack)

顶层的 CMakeLists.txt 文件几乎是一个常规的顶层 CMakeLists.txt 文件,唯一不同的是最后四行。它设置了三个与 CPack 相关的变量,并引入了 CPack 模块。这四行足以提供基本的 CPack 支持。CPACK_PACKAGE_NAMECPACK_PACKAGE_VERSION_* 变量没有被设置,让 CPack 从顶层项目的名称和版本参数中推导出来。让我们配置一下项目,看看是否有效:

cd chapter04/ex06_pack
cmake –S . -B build/

配置项目后,CpackConfig.cmakeCpackConfigSource.cmake 文件应该由 CPack 模块生成,并存放在 build/CPack* 目录下。我们来检查一下它们是否存在:

$ ls build/CPack*
build/CPackConfig.cmake  build/CPackSourceConfig.cmake

在这里,我们可以看到 CPack 配置文件已自动生成。让我们构建一下,并尝试使用 CPack 打包项目:

cmake --build build/
cpack --config build/CPackConfig.cmake -B build/

--config 参数是 CPack 命令的主要输入。-B 参数覆盖了 CPack 默认的包目录,指定了它将写入工件的路径。我们来看看 CPack 的输出:

CPack: Create package using DEB
/*…*/
CPack: - package: /home/user/workspace/personal/CMake-Best-Practices/chapter04/ex06_pack/build/ch4_ex06_pack-1.0-Linux.deb
generated.
CPack: Create package using RPM
/*…*/
CPack: - package: /home/user/workspace/personal/CMake-Best-Practices/chapter04/ex06_pack/build/ch4_ex06_pack-1.0-Linux.rpm
generated.
CPack: Create package using TBZ2
/*…*/
CPack: - package: /home/user/workspace/personal/CMake-Best-Practices/chapter04/ex06_pack/build/ch4_ex06_pack-1.0-Linux.tar.bz2
generated.

在这里,我们可以看到 CPack 使用了 DEBRPMTBZ2 生成器分别生成了 ch4_ex06_pack-1.0-Linux.debch4_ex06_pack-1.0-Linux.rpmch4_ex06_pack-1.0-Linux.tar.bz2 包。我们来尝试在 Debian 环境中安装生成的 Debian 包:

sudo dpkg -i build/ch4_ex06_pack-1.0-Linux.deb

如果打包正确,我们应该能够在命令行中直接调用 ch4_ex06_executable

13:38 $ ch4_ex06_executable
Hello, world!

成功了!作为练习,试着安装 RPMtar.bz2 包。处理包文件通常有两种方式。一种是创建小型包,依赖其他包来安装所需的依赖项;另一种方式是创建包含所有必要库的独立安装包,以便独立运行。通常,Linux 发行版自带包管理器来处理这些依赖项,而 Windows 和 macOS 默认依赖独立的安装程序。虽然近年来,Windows 上的 Chocolatey 和 macOS 上的 Homebrew 已成为支持依赖包的流行包管理器,但 CPack 目前(尚未)支持它们。到目前为止,我们只看过需要用户自行安装所有依赖项的简单包。接下来,我们来看一下如何为 Windows 构建一个便于分发的独立包。

为 Windows 创建独立安装程序

由于 Windows 并没有自带标准的包管理器,软件的安装程序通常需要包含所有必要的库。一种做法是将预制的安装程序打包成 NSIS 或 WIX 安装包,但这并非总是可行的,所以我们来看一下如何查找依赖文件。为此,CMake 提供了 install 命令的可选 RUNTIME_DEPENDENCIES 标志和 InstallRequiredSystemLibraries 模块,用于查找打包所需的依赖项。

它们的使用方式如下:

if(WIN32)
  if(CMAKE_BUILD_TYPE STREQUAL "Debug")
    set(CMAKE_INSTALL_DEBUG_LIBRARIES TRUE)
  endif()
  include(InstallRequiredSystemLibraries)
endif()

在前面的示例中,包含了 InstallRequiredSystemLibraries 模块。该模块是针对 Windows 进行定制的。包含该模块会创建安装编译器提供的库的指令,例如 MSVC 提供的 Visual Studio C++ 运行时库。通过将 CMAKE_INSTALL_DEBUG_LIBRARIES 变量设置为 true(如前面示例中所做),可以配置为包括库的调试版本。还有更多选项可以指示 CMake 安装额外的库,例如 Windows MFC 库、OpenMP 或用于在 Windows XP 或更早版本的 Windows 上进行应用本地部署的 Microsoft Universal CRT 库。

模块的完整文档可以在这里找到:cmake.org/cmake/help/latest/module/InstallRequiredSystemLibraries.html

包括编译器提供的库是一回事,但通常软件项目还会依赖其他库。如果这些库需要与项目一起打包,可以通过 install() 命令的 RUNTIME_DEPENDENCIES 选项来包含它们,如下所示:

# this includes the runtime directories of the executable and the library
install(TARGETS ch4_ex07_executable
        RUNTIME_DEPENDENCIES
        PRE_EXCLUDE_REGEXES "api-ms-.*" "ext-ms-.*"
        POST_EXCLUDE_REGEXES ".*system32/.*\\.dll"
        )

这将尝试找出目标指定依赖的任何共享库。由于 Windows 处理 DLL 解析的方式,这很可能会找到比实际需要更多的库。具体来说,它很可能会找到以api-msext-ms开头的库,这些库是为了兼容性原因存在的,并且并不需要。可以通过 PRE_EXCLUDE_REGEXES 选项将这些库过滤掉,该选项会在包含库之前进行过滤。任何与这些正则表达式匹配的文件路径都将在确定运行时依赖时被排除在考虑范围之外。或者,也可以使用 POST_EXCLUDE_REGEXES 选项,在找到文件之后对其进行过滤。如果你想排除来自某个特定位置的文件,这个选项很有用。在前面的示例中,它被用来排除来自 32 位 system32 文件夹的 DLL 文件。

在本节中,我们学习了如何使用 CPack 打包我们的项目。这不是一本详尽的指南。有关完整指南,官方的 CPack 文档提供了大量的信息。至此,我们成功完成了本章内容。

总结

在本章中,我们学习了如何使目标可安装的基础知识,以及如何为开发和消费者环境打包项目。部署是专业软件项目中的一个重要方面,借助我们在本章中覆盖的内容,你可以轻松处理这些部署需求。

在下一章中,我们将学习如何将第三方库集成到 CMake 项目中。

问题

请回答以下问题,测试你对本章内容的理解:

  1. 我们如何指示 CMake 使 CMake 目标可安装?

  2. 通过 install(TARGETS) 命令安装时,哪些文件会被安装?

  3. 对于库目标,install(TARGETS) 命令是否会安装头文件?为什么?如果没有,如何安装头文件?

  4. GNUInstallDirs CMake 模块提供了什么?

  5. 如何选择性地将一个目录的内容安装到目标目录中?

  6. 为什么在指定安装目标目录时应该使用相对路径?

  7. config-file 包所需的基本文件是什么?

  8. 导出一个目标是什么意思?

  9. 如何使 CMake 项目能够通过 CPack 打包?

答案

以下是上述问题的答案:

  1. 这可以通过 install(TARGETS <target_name>) 命令实现。

  2. 指定目标的输出工件。

  3. 不会,因为头文件不被视为目标的输出工件。它们必须通过 install(DIRECTORY) 命令单独安装。

  4. GNUInstallDirs CMake 模块提供了系统特定的默认安装路径,例如 binlibinclude

  5. 通过 install(DIRECTORY) 命令的 PATTERNFILES_MATCHING 参数的帮助。

  6. 为了使安装可迁移,用户可以通过指定安装前缀来更改安装目录。

  7. <package-name>-config.cmake<package-name>Config.cmake 文件,另可选择包含 <package-name>-config-version.cmake<package-name>ConfigVersion.cmake 文件。

  8. 导出一个目标意味着创建所需的 CMake 代码,以便将其导入到另一个 CMake 项目中。

  9. 通过包含 CPack 模块可以实现。

第五章:集成第三方库和依赖管理

迄今为止,在本书中,我们已经介绍了如何使用CMake构建和安装我们自己的代码。在本章中,我们将探讨如何使用那些不是 CMake 项目一部分的文件、库和程序。本章的第一部分将讲解如何一般性地查找这些内容,而后半部分将专注于如何管理依赖关系,以便构建你的 CMake 项目。

使用 CMake 的一个最大优势是,它内置了依赖管理功能,用于发现许多第三方库。在本章中,我们将探讨如何集成已安装在系统上的库和本地下载的依赖项。此外,你还将学习如何将第三方库作为二进制文件下载并使用,或者如何从源代码直接在 CMake 项目中构建它们。

我们将探讨如何为 CMake 编写指令,以便可靠地查找系统上的几乎任何库。最后,我们将看看如何在 CMake 中使用 Conan 和 vcpkg 等包管理器。依赖管理的实践,如本章所述,将帮助你创建稳定和可移植的 CMake 构建。不管你是使用预编译的二进制文件,还是从头开始编译它们,设置 CMake 以结构化且一致的方式处理依赖关系,将减少未来修复损坏构建时所花费的时间。以下是我们将在本章中讨论的主要主题:

  • 使用 CMake 查找文件、程序和路径

  • 在 CMake 项目中使用第三方库

  • 在 CMake 中使用包管理器

  • 获取依赖项作为源代码

  • 依赖提供者 – 获取依赖的新方式

技术要求

与前几章一样,所有示例都使用 CMake 3.24 进行测试,并能在以下任何编译器上运行:

  • GCC 9 或更新版本

  • Clang 12 或更新版本

  • MSVC 19 或更新版本

此外,一些示例需要安装 OpenSSL 3 才能编译。某些示例从各种在线位置拉取依赖项,因此还需要互联网连接。所有示例和源代码都可以从本书的 GitHub 仓库获得,地址是 github.com/PacktPublishing/CMake-Best-Practices---2nd-Edition/

外部包管理器的示例需要在系统上安装 Conan(版本 1.40 或更新)和 vcpkg 才能运行。你可以在这里获取这些软件:

使用 CMake 查找文件、程序和路径

大多数项目很快会增长到一个规模和复杂性,依赖于项目外部管理的文件、库,甚至可能是程序。CMake 提供了内置命令来查找这些内容。乍一看,搜索和查找内容的过程似乎非常简单。然而,经过仔细分析,实际上需要考虑很多因素。首先,我们必须处理查找文件或程序时的搜索顺序。然后,我们可能需要添加更多可能包含文件的位置,最后,还必须考虑不同操作系统之间的差异。

在比单个文件更高的抽象层级上,CMake 可以查找定义了目标、包含路径和特定于包的变量的整个包。更多细节请参见在 CMake 项目中使用第三方库一节。

有五个 find_... 命令,它们共享非常相似的选项和行为:

  • find_file:用于定位单个文件

  • find_path:用于查找包含特定文件的目录

  • find_library:用于查找库文件

  • find_program:用于查找可执行程序

  • find_package:用于查找完整的包集合

这些命令的工作方式类似,但在查找位置方面有一些小但重要的差异。特别是,find_package 不仅仅是定位文件;它不仅查找包,还将文件内容提供给 CMake 项目,方便使用。在本章中,我们将首先介绍较简单的 find 函数,然后再讲解如何查找复杂的包。

查找文件和路径

查找最底层和最基本的内容是文件和路径。find_filefind_path 函数具有相同的签名。它们的唯一区别是,find_path 将文件找到的目录存储在结果中,而 find_file 会存储包括文件名在内的完整路径。find_file 命令的签名如下所示:

find_file (
          <VAR>
          name | NAMES name1 [name2 ...]
          [HINTS [path | ENV var]... ]
          [PATHS [path | ENV var]... ]
          [PATH_SUFFIXES suffix1 [suffix2 ...]]
          [DOC "cache documentation string"]
          [NO_CACHE]
          [REQUIRED]
          [NO_DEFAULT_PATH]
          [NO_PACKAGE_ROOT_PATH]
          [NO_CMAKE_PATH]
          [NO_CMAKE_ENVIRONMENT_PATH]
          [NO_SYSTEM_ENVIRONMENT_PATH]
          [NO_CMAKE_SYSTEM_PATH]
          [CMAKE_FIND_ROOT_PATH_BOTH |
           ONLY_CMAKE_FIND_ROOT_PATH |
           NO_CMAKE_FIND_ROOT_PATH]
         )

上述命令要么直接搜索单个文件(如果名称已直接传递),要么搜索可能的名称列表(如果使用了 NAMES 选项)。结果路径会存储在传递的 <VAR> 变量中。如果文件无法找到,变量将包含 <VARIABLENAME>-NOTFOUND

传递一个名称列表在搜索文件时非常有用,特别是当文件名存在变体时,例如大小写不同或命名约定不同,可能包含或不包含版本号等。传递名称列表时,名称应按首选顺序排序,因为一旦找到第一个文件,搜索就会停止。

搜索包含版本号的文件

推荐在搜索包含版本号的文件之前,先搜索没有版本号的文件名。这是为了确保本地构建的文件优先于操作系统安装的文件。

HINTSPATHS 选项包含附加的位置,文件将在这些位置下进行搜索。PATH_SUFFIXES 可以包含几个子目录,这些子目录将在其他位置下进行搜索。

find_… 命令在定义的地方并按定义的顺序搜索内容。命令的 NO_..._PATH 参数可用于跳过相应的路径。下表显示了搜索位置的顺序以及跳过位置的选项:

位置 命令中的跳过选项
包根变量 NO_PACKAGE_ROOT_PATH
CMake 特定的缓存变量 NO_CMAKE_PATH
CMake 特定的环境变量 NO_CMAKE_ENVIRONMENT_PATH
来自 HINTS 选项的路径
系统特定的环境变量 NO_SYSTEM_ENVIRONMENT_PATH
系统特定的缓存变量 NO_CMAKE_SYSTEM_PATH
来自 PATHS 选项的路径

让我们更仔细地看一下搜索顺序以及不同位置的含义:

  • find_filefind_package 命令的一部分。有关详细讨论,请参考 在 CMake 项目中使用第三方库 部分。

  • CMAKE_PREFIX_PATHCMAKE_INCLUDE_PATHCMAKE_FRAMEWORK_PATH 是 macOS 的缓存变量。通常,设置 CMAKE_PREFIX_PATH 缓存变量比其他两种类型更为优选,因为它用于所有的 find_ 命令。前缀路径是进行搜索的基准点,常见的文件结构如 binlibinclude 等都位于该路径下。CMAKE_PREFIX_PATH 是路径列表,对于每个条目,find_file 将在 <prefix>/include<prefix>/include/${CMAKE_LIBRARY_ARCHITECTURE} 下搜索(如果相应的变量已被设置)。通常,CMake 会自动设置这些变量,开发人员不应更改它们。特定架构的路径优先于通用路径。

  • 如果标准目录结构不适用,则应仅使用 CMAKE_INCLUDE_PATHCMAKE_FRAMEWORK_PATH 缓存变量。它们不会在路径中添加额外的 include 后缀。

  • 通过将 NO_CMAKE_PATH 选项传递给命令,或者通过全局设置 CMAKE_FIND_USE_PATH 变量为 false,可以跳过这些路径的搜索。

  • CMAKE_PREFIX_PATHCMAKE_INCLUDE_PATHCMAKE_FRAMEWORK_PATH 是系统环境变量。这些变量的工作方式与缓存变量相同,但通常是从 CMake 调用外部设置的。

  • 请注意,在 Unix 平台上,列表是通过冒号 (:) 分隔的,而不是分号 (;),以符合平台特定的环境变量。

  • 来自 HINTS 选项的路径是手动指定的附加搜索位置。它们可以从其他值(如属性值)构造,或者可能依赖于先前找到的文件或路径。

  • INCLUDEPATH 环境变量每个都可以包含一个目录列表供查找。再者,在 Unix 平台上,列表用冒号 (:) 分隔,而不是分号 (;)。

    • 在 Windows 上,PATHS 条目的处理方式更为复杂。对于每个条目,通过删除任何尾部的 binsbin 目录来提取基础路径。如果设置了 CMAKE_LIBRARY_ARCHITECTURE,则会将 include/${CMAKE_LIBRARY_ARCHITECTURE} 子目录作为每个路径的优先级进行添加。之后,会搜索 include(不带后缀)。然后,再搜索原始路径,这个路径可能以 binsbin 结尾,也可能不以其结尾。如果传递了 NO_SYSTEM_ENVIRONMENT_PATH 变量或将 CMAKE_FIND_USE_CMAKE_SYSTEM_PATH 变量设置为 false,将跳过环境变量中的位置。

    • 假设 PATH 选项包含 C:\myfolder\bin;C:\yourfolder,并且设置了 CMAKE_LIBRARY_ARCHITECTUREx86_64,则搜索顺序如下:

      1. C:\myfolder\include\x86_64

      2. C:\myfolder\include\

      3. C:\myfolder\bin

      4. C:\yourfolder\include\x86_64

      5. C:\yourfolder\include\

      6. C:\yourfolder\

  • CMAKE_SYSTEM_PREFIX_PATHCMAKE_SYSTEM_FRAMEWORK_PATH 变量的作用类似于 CMake 特定的缓存变量。这些变量不应由开发者修改,而是当 CMake 设置平台工具链时进行配置。唯一的例外是当提供了工具链文件时,例如使用 sysroot 或进行交叉编译时,如在 第十二章 中解释的,跨平台编译与 自定义工具链

  • 除了 NO_CMAKE_SYSTEM_PATH 选项,CMAKE_FIND_USE_CMAKE_SYSTEM_PATH 变量可以设置为 false,以跳过系统特定缓存变量提供的位置。

  • HINTS 选项一样,PATHS 选项中指定的路径是手动提供的附加搜索位置。虽然技术上没有禁止,按照惯例,PATHS 变量应是固定路径,不应依赖于其他值。

如果只希望搜索由 HINTSPATHS 提供的位置,添加 NO_DEFAULT_PATH 选项将跳过所有其他位置。

有时,你可能希望忽略某些特定的搜索路径。在这种情况下,可以在 CMAKE_IGNORE_PATHCMAKE_SYSTEM_IGNORE_PATH 中指定路径列表。这两个变量是为交叉编译场景设计的,其他情况下很少使用。

查找交叉编译时的文件

进行交叉编译时,查找文件的过程通常有所不同,因为交叉编译工具链被收集在其自身的独立目录结构下,这与系统工具链不混合。通常,首先你会想要在工具链的目录中查找文件。通过设置 CMAKE_FIND_ROOT 变量,可以将所有查找的源更改为新位置。

此外,CMAKE_SYSROOTCMAKE_SYSROOT_COMPILECMAKE_SYSROOT_LINK变量会影响搜索位置,但它们只应在工具链文件中设置,而不是由项目本身设置。如果任何常规搜索位置已经在 sysroot 中或由CMAKE_FIND_ROOT指定的位置下,它们将不会被更改。任何以波浪号(~)开头的路径,并传递给find_命令时,不会被更改,以避免跳过位于用户主目录下的目录。

默认情况下,CMake 首先在前述段落中提供的任何变量指定的位置进行搜索,然后继续搜索主机系统。通过将CMAKE_FIND_ROOT_PATH_MODE_INCLUDE变量设置为BOTHNEVERONLY,可以全局更改此行为。或者,您可以将find_fileCMAKE_FIND_ROOT_PATH_BOTH选项、ONLY_CMAKE_FIND_ROOT_PATH选项或NO_CMAKE_FIND_ROOT_PATH选项进行设置。

下表显示了在不同搜索模式下设置任何选项或变量时的搜索顺序:

模式 选项 搜索顺序
BOTH CMAKE_FIND_ROOT_PATH_BOTH
  • CMAKE_FIND_ROOT_PATH

  • CMAKE_SYSROOT_COMPILE

  • CMAKE_SYSROOT_LINK

  • CMAKE_SYSROOT

  • 所有常规搜索位置

|

NEVER NO_CMAKE_FIND_ROOT_PATH
  • 所有常规搜索位置

|

| ONLY | ONLY_CMAKE_FIND_ROOT_PATH | CMAKE_FIND_ROOT_PATH

  • CMAKE_SYSROOT_COMPILE

  • CMAKE_SYSROOT_LINK

  • CMAKE_SYSROOT

  • 任何常规路径,其他位置之一,或CMAKE_STAGING_PREFIX

|

CMAKE_STAGING_PREFIX变量用于为交叉编译提供安装路径。通过安装内容到其中,CMAKE_SYSROOT不应被更改。关于交叉编译工具链的设置,我们将在第十二章,“跨平台编译与自定义工具链”中详细讨论。

查找程序

查找可执行文件与查找文件和路径非常相似,find_program命令的签名几乎与find_file相同。此外,find_programNAMES_PER_DIR选项,指示命令一次只搜索一个目录,并在每个目录中搜索所有提供的文件名,而不是在每个文件中搜索每个目录。

在 Windows 上,.exe.com文件扩展名会自动添加到提供的文件名中,但.bat.cmd不会。

find_program使用的缓存变量与find_file使用的缓存变量略有不同:

  • find_program会自动将binsbin添加到由CMAKE_PREFIX_PATH提供的搜索位置中

  • CMAKE_LIBRARY_ARCHITECTURE中的值会被忽略,并且没有任何效果

  • CMAKE_PROGRAM_PATH替代了CMAKE_INCLUDE_PATH

  • CMAKE_APPBUNDLE_PATH替代了CMAKE_FRAMEWORK_PATH

  • CMAKE_FIND_ROOT_PATH_MODE_PROGRAM用于更改查找程序的模式

与其他find命令一样,find_program会在 CMake 无法找到程序时设置<varname>-NOTFOUND变量。这通常对于判断是否启用某个依赖特定外部程序的自定义构建步骤非常有用。

查找库

查找库是查找文件的一种特殊情况,因此find_library命令支持与find_file相同的选项集。此外,与find_program命令类似,它还有额外的NAMES_PER_DIR选项,该选项会首先检查所有文件名,然后再进入下一个目录。查找常规文件和查找库之间的区别在于,find_library会自动根据平台特定的命名约定来处理文件名。在 Unix 平台上,文件名前会加上lib,而在 Windows 上,会添加.dll.lib扩展名。

同样,缓存变量与find_filefind_program中使用的变量略有不同:

  • find_library通过CMAKE_PREFIX_PATHlib添加到搜索路径中,并使用CMAKE_LIBRARY_PATH代替CMAKE_INCLUDE_PATH来查找库。CMAKE_FRAMEWORK_PATH变量的使用方式类似于find_fileCMAKE_LIBRARY_ARCHITECTURE变量与find_file中的用法相同。

  • 通过将相应的文件夹附加到搜索路径来实现此操作。find_library以与find_file相同的方式搜索PATH环境变量中的位置,但会在每个前缀中附加lib。另外,如果已设置LIB环境变量,它将使用该变量,而不是使用INCLUDE变量。

  • CMAKE_FIND_ROOT_PATH_MODE_LIBRARY用于更改搜索库的模式。

CMake 通常会识别 32 位和 64 位搜索位置的命名约定,例如某些平台使用lib32lib64文件夹来存放同名的不同库。此行为由FIND_LIBRARY_USE_LIB[32|64|X32]_PATHS变量控制,该变量决定了应先搜索什么。项目还可以使用CMAKE_FIND_LIBRARY_CUSTOM_LIB_SUFFIX变量定义自己的后缀,从而覆盖其他变量的行为。然而,通常情况下不需要这样做,修改CMakeLists.txt文件中的搜索顺序会迅速使项目变得难以维护,并且对不同系统之间的可移植性产生重大影响。

查找静态库或共享库

在大多数情况下,直接将库的基本名称传递给 CMake 就足够了,但有时需要覆盖默认行为。这样做的原因之一是,在某些平台上,应该优先使用库的静态版本而不是共享版本,或者反之。最好的方法是将find_library调用拆分为两个调用,而不是试图在一个调用中实现此目标。如果静态库和动态库位于不同的目录中,这样的做法更为稳健:

find_library(MYSTUFF_LIBRARY libmystuff.a)
find_library(MYSTUFF_LIBRARY mystuff)

在 Windows 上,这种方法无法使用,因为静态库和 DLL 的导入库具有相同的 .lib 后缀,因此无法通过名称区分它们。find_filefind_pathfind_programfind_library 命令在查找特定内容时非常有用。另一方面,查找依赖项发生在更高的层次。这正是 CMake 擅长的地方,通过提供 find_package 方法。使用 find_package,我们无需首先查找所有的 include 文件,再查找所有的库文件,然后手动将它们添加到每个目标中,最后还要考虑所有平台特有的行为。接下来,让我们深入了解如何查找依赖项的过程。

在 CMake 项目中使用第三方库

如果你在认真实践软件开发,迟早你会遇到项目依赖外部库的情况。与其寻找单独的库文件或头文件,推荐的将第三方代码集成到 CMake 项目的方式是使用 find_package 命令来使用 CMake 包。包为 CMake 和生成的构建系统提供了有关依赖项的一系列信息。它们可以以两种形式集成到项目中,分别是通过它们的配置详情(也称为 *config 包)或所谓的 find 模块包。配置包通常由上游项目提供,而使用 find 模块的包通常由 CMake 本身或使用该包的项目定义。两种类型的包都可以通过 find_package 查找,结果是一组导入的目标和/或一组包含与构建系统相关的信息的变量。

findPkgConfig 模块使用 freedesktop.org 提供的 pkg-config 工具查找依赖项的相关元信息,也间接地支持包。

通常,find 模块用于定位依赖项,例如当上游没有提供包配置所需的信息时。它们不应与 CMake 工具模块混淆,后者是与 include() 一起使用的。

尽量使用上游提供的包,而不是使用 find 模块。

如果可能,使用上游来源提供的包,而不是创建 find 模块。如果上游项目缺少必要的信息,尽量在源头上修复,而不是编写一个新的 find 模块。

请注意,find_package 命令有两种签名:基本的或简短的签名和完整的或长的签名。在几乎所有场景中,使用简短的签名就足够找到了我们需要的包,并且它应该更受青睐,因为它更容易维护。简短形式支持模块包和配置包,而长形式仅支持配置模式。

简短模式的签名如下:

find_package(<PackageName> [version] [EXACT] [QUIET] [MODULE]
             [REQUIRED] [[COMPONENTS] [components...]]
             [OPTIONAL_COMPONENTS components...]
             [NO_POLICY_SCOPE])

假设我们想编写一个程序,通过使用 OpenSSL 库的适当功能将字符串转换为 SHA-256 哈希。为了编译和链接这个例子,我们必须告诉 CMake 项目需要 OpenSSL 库,然后将其附加到目标上。暂时假设所需的库已经通过默认位置安装在您的系统上;例如,通过使用 Linux 的常规包管理器如 apt、RPM 或类似的工具,Windows 的 Chocolatey,或 macOS 的 brew。

一个样例 CMakeLists.txt 文件可能如下所示:

find_package(OpenSSL REQUIRED COMPONENTS SSL)
add_executable(find_package_example)
target_link_libraries(find_package_example PRIVATE OpenSSL::SSL)

前述示例执行以下操作:

  1. 在示例的第一行中,有一个 find_package(OpenSSL REQUIRED COMPONENTS SSL) 调用。这告诉 CMake 我们正在寻找 OpenSSL 的一组库和头文件。具体来说,我们正在寻找 SSL 组件,并忽略 OpenSSL 包提供的任何其他组件。REQUIRED 关键字告诉 CMake 找到此包对于构建此项目是必需的。如果找不到该包,CMake 将失败并显示错误。

  2. 一旦找到了包,我们告诉 CMake 使用 target_link_libary 将库链接到目标。具体地,我们告诉 CMake 链接由 OpenSSL 包提供的 OpenSSL::SSL 目标。

如果一个依赖项必须是特定版本,则可以指定为 major[.minor[.patch[.tweak]]] 格式的单个版本,或者作为 versionMin..[<]versionMax 格式的版本范围。对于版本范围,versionMinversionMax 应具有相同的格式,通过指定 <,将排除上限版本。在这种情况下,find_package 调用将看起来像这样:

find_package(OpenSSL 3.0 REQUIRED)

这将告诉 CMake 查找任何版本为 3.0.x 的 OpenSSL。在这种情况下,补丁级别的数字将被忽略以匹配版本号。如果版本需要精确匹配,可以指定 EXACT 关键字,但这很少使用。

不幸的是,截至 2024 年 5 月,CMake 无法查询模块以获取可用的组件。因此,我们必须依赖于模块或库提供者的文档来查找可用的组件。可以使用以下命令查询可用的模块:

cmake --help-module-list #< lists all available modules
cmake --help-module <mod> #< prints the documentation for module
  <mod>
cmake --help-modules #< lists all modules and their documentation

可以在 cmake.org/cmake/help/latest/manual/cmake-modules.7.html 找到一系列与 CMake 一起提供的模块。

查找单独的库和文件

可以查找单独的库和文件,但首选方式是使用包。在 编写您自己的查找模块 部分将介绍如何查找单独的文件并使其可用于 CMake。

在模块模式下运行时,find_package 命令会查找名为 Find<PackageName>.cmake 的文件;首先在由 CMAKE_MODULE_PATH 指定的路径中查找,然后在 CMake 安装提供的 find 模块中查找。如果你想了解如何创建 CMake 包,可以查看 第四章CMake 项目的打包、部署与安装

在配置模式下运行时,find_package 会按照以下模式查找文件:

  • <``小写包名>-config.cmake

  • <``PackageName>Config.cmake

  • <小写包名>-config-version.cmake(如果指定了版本详情)

  • <PackageName>ConfigVersion.cmake(如果指定了版本详情)

所有搜索将按照一组明确定义的顺序进行;如果需要,某些位置可以通过将相应的选项传递给 CMake 来跳过。find_package 比其他 find_ 命令包含更多选项。下表显示了搜索顺序的高级概述:

位置 命令中的跳过选项
包根变量 NO_PACKAGE_ROOT_PATH
CMake 特定缓存变量 NO_CMAKE_PATH
CMake 特定环境变量 NO_CMAKE_ENVIRONMENT_PATH
HINTS 选项中指定的路径
系统特定环境变量 NO_SYSTEM_ENVIRONMENT_PATH
用户包注册表 NO_CMAKE_PACKAGE_REGISTRY
系统特定缓存变量 NO_CMAKE_SYSTEM_PATH
系统包注册表 NO_CMAKE_SYSTEM_PACKAGE_REGISTRY
PATHS 选项中指定的路径

让我们更仔细地看看搜索顺序和搜索位置:

  • find_package 调用会存储在名为 <PackageName>_ROOT 的变量中。它们是搜索属于某个包的文件的优先级。包根变量的作用与 CMAKE_PREFIX_PATH 相同,不仅适用于 find_package 的调用,也适用于在该包的 find 模块中可能发生的所有其他 find_ 调用。

  • CMAKE_PREFIX_PATH。对于 macOS,还会考虑将 CMAKE_FRAMEWORK_PATH 变量作为搜索位置。

  • 通过将 CMAKE_FIND_USE_CMAKE_PATH 变量设置为 false,可以跳过 CMake 特定缓存变量中的位置。

  • CMAKE_PREFIX_PATHCMAKE_FRAMEWORK_PATH 作为缓存变量时,CMake 也会考虑它们是否被设置为环境变量。

  • CMAKE_FIND_USE_ENVIRONMENT_PATH 变量设置为 false 会禁用此行为。

  • find_package 中的 HINTS 是一个可选路径,传递给 find_package

  • PATH 环境变量用于查找包和文件,并且会去除末尾的 binsbin 目录。此时,系统的默认位置,如 /usr/lib 等,通常会被搜索。

  • CMAKE_PREFIX_PATH选项。包注册表是告诉 CMake 在哪里查找依赖项的另一种方式。包注册表是包含一组包的特殊位置。用户注册表对当前用户账户有效,而系统包注册表在系统范围内有效。在 Windows 上,用户包注册表的位置存储在 Windows 注册表中,路径如下:

    • HKEY_CURRENT_USER\Software\Kitware\CMake\Packages\<packageName>\
  • 在 Unix 平台上,它被存储在用户的主目录中,路径如下:

    • ~/.``cmake/packages/<PackageName>
  • find_package、平台特定的CMAKE_SYSTEM_PREFIX_PATHCMAKE_SYSTEM_FRAMEWORK_PATHCMAKE_SYSTEM_APPBUNDLE_PATH缓存变量的工作方式与其他查找调用类似。它们由 CMake 本身设置,不应由项目修改。

  • HKEY_LOCAL_MACHINE\Software\Kitware\CMake\Packages\<packageName>\

  • Unix 系统不提供系统包注册表。* PATHS来自find_package,是传递给find_package的可选路径。通常,HINTS选项是根据其他值计算的,或者依赖于变量,而PATHS选项是固定路径。

具体来说,在配置模式下查找包时,CMake 将会在各种前缀下查找以下文件结构:

<prefix>/
<prefix>/(cmake|CMake)/
<prefix>/<packageName>*/
<prefix>/<packageName>*/(cmake|CMake)/
<prefix>/(lib/<arch>|lib*|share)/cmake/<packageName>*/
<prefix>/(lib/<arch>|lib*|share)/<packageName>*/
<prefix>/(lib/<arch>|lib*|share)/<packageName>*/(cmake|CMake)/
<prefix>/<packageName>*/(lib/<arch>|lib*|share)/cmake/
  <packageName>*/
<prefix>/<packageName>*/(lib/<arch>|lib*|share)/<packageName>*/
<prefix>/<packageName>*/(lib/<arch>|lib*|share)/<packageName>*/
  (cmake|CMake)/

在 macOS 平台上,还会搜索以下文件夹:

<prefix>/<packageName>.framework/Resources/
<prefix>/<packageName>.framework/Resources/CMake/
<prefix>/<packageName>.framework/Versions/*/Resources/
<prefix>/<packageName>.framework/Versions/*/Resources/CMake/
<prefix>/<packageName>.app/Contents/Resources/
<prefix>/<packageName>.app/Contents/Resources/CMake/

您可以在官方 CMake 文档中了解有关包的更多信息,链接:cmake.org/cmake/help/latest/manual/cmake-packages.7.html

就模块而言,到目前为止,我们只讨论了如何查找现有的模块。但是如果我们想查找那些既没有集成到 CMake 中,也不在标准位置,或者没有为 CMake 提供配置说明的依赖项怎么办呢?好吧,让我们在下一节中了解一下。

编写您自己的查找模块

尽管 CMake 几乎已成为行业标准,但仍然有许多库没有使用 CMake 进行管理,或者虽然使用 CMake 管理,但没有导出 CMake 包。如果它们能够安装到系统的默认位置或使用包管理器时,这些库通常不成问题。不幸的是,这并非总是可行。一个常见的情况是使用专有的第三方库,该库只为某个特定项目所需,或者使用与系统包管理器安装的版本不同的库进行构建,或者该包在包管理器中不可用。

如果你正在同时开发多个项目,可能希望为每个项目在本地处理依赖项。无论哪种方式,最好将项目设置成这样:依赖项在本地管理,而不是过度依赖系统中已安装的内容。因此,使用包管理工具(如 Conan 或 vcpkg),如在 CMake 中使用包管理工具章节中所述,优于自己编写find模块。

创建完全可重现的构建在第十二章中有描述,跨平台编译与自定义工具链;不过,了解如何编写自己的find模块很有用,并且能帮助我们深入了解 CMake 的包如何工作。如果没有模块或配置文件用于某个依赖项,通常编写自己的所谓find模块是最快的解决方法。目标是提供足够的信息,以便稍后我们可以通过find_package使用任何包。

find模块是 CMake 的指令,告诉它如何找到库所需的头文件和二进制文件,并创建供 CMake 使用的导入目标。如本章前面所述,在模块模式下调用find_package时,CMake 会在CMAKE_MODULE_PATH中搜索名为Find<PackageName>.cmake的文件。

假设我们正在构建一个项目,其中依赖项已经被下载或构建,并已放入一个名为dep的文件夹中,然后再使用它们。在这个示例中,假设我们使用一个名为obscure的库;在这种情况下,find模块将被命名为FindObscure.cmake。因此,项目结构可能如下所示:

./chapter05/find_module
├── cmake
│   └── FindObscure.cmake <- This what we need to write
├── CMakeLists.txt
├── dep <- The folder where we locally keep depdendencies
└── src
    └── main.cpp

我们首先要做的就是将cmake文件夹添加到CMAKE_MODULE_PATH中,这其实是一个列表。因此,首先我们在CMakeLists.txt文件中添加以下行:

list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/cmake")

这告诉 CMake,它应该在cmake文件夹中查找find模块。通常,find模块按以下顺序执行:

  1. 它查找属于该包的文件。

  2. 它为包设置包含目录和库目录的变量。

  3. 它为导入的包设置目标。

  4. 它为目标设置属性。

一个简单的FindModules.cmake文件,用于名为obscure的库,可能如下所示:

cmake_minimum_required(VERSION 3.21)
find_library(
    OBSCURE_LIBRARY
    NAMES obscure
    HINTS ${PROJECT_SOURCE_DIR}/dep/
    PATH_SUFFIXES  lib  bin  build/Release  build/Debug
)
find_path(
    OBSCURE_INCLUDE_DIR
    NAMES obscure/obscure.hpp
    HINTS ${PROJECT_SOURCE_DIR}/dep/include/
)
include(FindPackageHandleStandardArgs)
find_package_handle_standard_args(
    Obscure
    DEFAULT_MSG
    OBSCURE_LIBRARY
    OBSCURE_INCLUDE_DIR
)
mark_as_advanced(OBSCURE_LIBRARY OBSCURE_INCLUDE_DIR)
if(NOT TARGET Obscure::Obscure)
    add_library(Obscure::Obscure UNKNOWN IMPORTED )
    set_target_properties(Obscure::Obscure  PROPERTIES
               IMPORTED_LOCATION "${OBSCURE_LIBRARY}"
               INTERFACE_INCLUDE_DIRECTORIES
                 "${OBSCURE_INCLUDE_DIR}"
               IMPORTED_LINK_INTERFACE_LANGUAGES "CXX"
)
endif()

看这个示例时,我们可以观察到以下几件事情:

  1. 首先,使用find_library命令搜索属于依赖项的实际library文件。如果找到,文件的路径(包括实际文件名)将存储在OBSCURE_LIBRARY变量中。通常做法是将变量命名为<PACKAGENAME>_LIBRARYNAMES参数是一个可能的库名称列表。这些名称会自动扩展为常见的前缀和扩展名。因此,尽管在前面的示例中我们寻找的是名为obscure的文件,但实际上会找到一个名为libobscure.soobscure.dll的文件。关于搜索顺序、提示和路径的更多细节将在本节后面讲解。

  2. 接下来,find模块尝试定位include路径。这是通过找到库的已知路径模式来完成的,通常是公共头文件之一。结果存储在OBSCURE_INCLUDE_DIR变量中。同样的,常见做法是将该变量命名为<PACKAGENAME>_INCLUDE_DIR

  3. 由于处理find模块的所有要求可能会非常繁琐且重复,CMake 提供了FindPackageHandleStandardArgs模块,它提供了一个便捷的函数来处理所有常见情况。它提供了find_package_handle_standard_args函数,处理REQUIREDQUIET以及find_package的版本相关参数。find_package_handle_standard_args有简短签名和长签名两种形式。在这个例子中,使用了简短签名:

    find_package_handle_standard_args(<PackageName>
    
      (DEFAULT_MSG|<custom-failure-message>)
    
      <required-var>...
    
      )
    
  4. 对于大多数情况,find_package_handle_standard_args的简写形式已足够使用。在简写形式中,find_package_handle_standard_args函数将包名作为第一个参数,并传递该包所需的变量列表。DEFAULT_MSG参数指定在成功或失败时打印默认消息,这取决于find_package是否使用REQUIREDQUIET选项被调用。消息可以自定义,但我们建议尽可能使用默认消息。这样,所有find_package命令的消息保持一致。在前面的示例中,find_package_handle_standard_args检查传入的OBSCURE_LIBRARYOBSCURE_INCLUDE_DIR变量是否有效。如果有效,<PACKAGENAME>_FOUND变量会被设置。

  5. 如果一切顺利,find模块定义了目标。在此之前,最好检查一下我们尝试创建的目标是否已经存在(以避免在多次调用find_package查找相同依赖时覆盖已有目标)。创建目标是通过add_library完成的。由于我们无法确定它是静态库还是动态库,因此类型设为UNKNOWN并设置IMPORTED标志。

  6. 最后,库的属性被设置。我们推荐的最小设置是IMPORTED_LOCATION属性和INTERFACE_INCLUDE_DIRinclude文件的位置。

如果一切按预期工作,那么可以像这样使用库:

find_package(Obscure REQUIRED)
...
target_link_libraries(find_module_example PRIVATE  Obscure::Obscure)

现在,我们了解了如何将其他库添加到项目中,如果它们已经可用。那么,我们如何将这些库首先引入到系统中呢?我们将在下一节中解决这个问题。

使用 CMake 的包管理器

将依赖项加入项目的最简单方法是通过 apt-get、brew 或 Chocolatey 定期安装它们。安装所有内容的缺点是,你可能会污染系统,导致存在多个不同版本的库,并且你需要的版本可能根本无法找到。特别是在你同时处理多个具有不同依赖要求的项目时,这种情况尤为严重。开发者通常会为每个项目本地下载依赖项,以确保每个项目能够独立工作。处理依赖项的一个非常好的方法是使用像 Conan 或 vcpkg 这样的包管理器。

使用专门的包管理器在依赖管理方面有许多优点。处理 C++ 依赖项时,两个更受欢迎的包管理器是 Conan 和 vcpkg。它们都能处理复杂的构建系统,要掌握它们需要单独写一本书,因此我们这里只介绍入门所需的基本内容。在本书中,我们将重点介绍如何使用 CMake 项目中已有的包,而不是如何创建自己的包。

从版本 3.24 开始,CMake 支持一个叫做 find_packageFetchContent_MakeAvailable 的概念,用于调用外部程序或脚本以定位或安装依赖项。依赖提供者必须在第一次调用项目函数时通过 CMAKE_PROJECT_TOP_LEVEL_INCLUDES 进行设置,这通常应该通过命令行或使用 CMake 预设来完成。

尽管这个概念在写作时对 CMake 来说相对较新,但它看起来非常有前景。Conan 2.0 对依赖提供者提供了实验性的支持,而 vcpkg 目前还没有此功能。

使用 Conan 获取依赖项

在过去的几年里,Conan 包管理器获得了很高的关注度,并且与 CMake 的集成非常好。Conan 是一个去中心化的包管理器,基于客户端/服务器架构。这意味着本地客户端从一个或多个远程服务器获取或上传包。2023 年 2 月,Conan 团队发布了 Conan 2,它与 Conan 1 不再兼容。如果你还在使用 Conan 1,我们建议你迁移到 Conan 2,因为它在多个方面对 Conan 1.x 进行了改进和变化,包括与 CMake 的更好集成、改进的包创建和管理功能,以及提升的用户体验。

Conan 最强大的功能之一是它可以为多个平台、配置和版本创建并管理二进制包。在创建包时,这些包会通过一个 conanfile.py 文件进行描述,文件列出了所有依赖项、源代码和构建指令。

这些包通过 Conan 客户端构建并上传到远程服务器。这还有一个额外的好处,即如果没有适合你本地配置的二进制包,包可以从源代码本地构建。

使用 Conan 与 CMake 的基本工作流程如下:

  1. 在你的项目中创建一个 conanfile.txt,其中包含依赖项和设置的列表。

  2. 使用 Conan 安装依赖项,并使用 Conan 提供的生成器来创建 CMake 可以使用的文件,以便查找和链接依赖项。

  3. 运行 CMake 并整合 Conan 生成的信息来构建项目。

Conan 有两个重要的核心概念。第一个是 conanfile。配置文件通常位于用户的主目录中,可以有多个不同的配置文件,但最常使用的是默认配置文件。

要创建新的配置文件,可以使用 conan profile new 命令:

conan profile detect --name myprofile

这将从系统上检测到的标准编译器创建一个新的配置文件。如果省略 --name 参数,将创建默认的配置文件。生成的配置文件可能如下所示:

[settings]
arch=x86_64
build_type=Release
compiler=gcc
compiler.cppstd=gnu20
compiler.libcxx=libstdc++11
compiler.version=12
os=Linux

对于不同的编译器设置,配置文件可以根据需要进行自定义。

Conan 的另一个核心概念是 CMakeDepsCMakeToolchain

CMakeDeps 生成供 find_package 使用的信息,并提供更多灵活性,而 CMakeToolchain 更方便使用,但在配置方面有一些限制。

CMakeToolchain 生成器适用于小型独立项目。对于较大的项目,或者涉及交叉编译工具链时,建议使用 CMakeDeps 生成器。

使用 Conan 与 CMake 集成有两种主要方式。一种是单独调用 Conan,让它生成 CMake 消耗的包信息,另一种是将其作为 CMake 的依赖项提供者插入。哪种方式最适合取决于个人偏好和项目设置。虽然单独调用 Conan 可以在配置 Conan 时提供最大的自由度,但它可能会限制 CMake 方面工具链和预设选项的选择。将 Conan 作为依赖项提供者更加方便,并且可以访问 CMake 的全部功能,但它限制了可用的 Conan 配置数量。从 CMake 的角度来看,使用 Conan 作为依赖项提供者的一个优点是,不需要为所有不同的构建配置预先指定所有 Conan 配置文件,而是可以使用正常的 CMake 定义动态创建它们。首先,让我们看看如何将 Conan 作为依赖项提供者使用。

将 Conan 用作依赖项提供者

要将 Conan 作为依赖项提供者,我们需要两件事:

  • conanfile.txt 用于列出依赖项

  • Conan 的依赖项提供者定义

首先,conanfile.txt 被放置在 CMakeLists.txt 旁边,结果项目结构类似于以下内容:

my_project/
├── src/
│   └── main.cpp
├── CMakeLists.txt
└── conanfile.txt

conanfile.txt 中,我们列出了要使用的依赖项,并告诉 Conan 使用 CMakeDeps 生成器来生成依赖项的包信息:

[requires]
fmt/10.2.1
[generators]
CMakeDeps

首先,[requires] 部分描述了要导入哪些包;在这种情况下,导入了 fmt 版本 10.2.1 包。

[generators] 部分描述了可以使用的生成器。对于将 Conan 作为依赖项提供程序,使用 CMakeDeps 生成器。

要在 CMake 中使用该依赖项,可以使用前面所示的 find_package 命令,因此 CMakeLists.txt 文件可能如下所示:

find_package(fmt 10.2.1 REQUIRED)
add_executable(conan_example src/main.cpp)
target_link_libraries(conan_example PRIVATE fmt::fmt)

请注意,使用 Conan 包与使用“常规” CMake 包没有区别,不需要在 CMakeLists.txt 中添加任何特定于 Conan 的代码,这有助于在不同系统之间保持可移植性。

到这里,我们差不多准备好了。接下来需要告诉 CMake 使用 Conan 作为依赖项提供程序。为此,CMake 依赖项提供程序的定义文件可以从这里获取:github.com/conan-io/cmake-conan

在这个仓库中,有一个 conan_provider.cmake 文件,可以手动下载该文件,或者可以将 Git 仓库用作子模块,选择最适合你的方式。在包含示例的 GitHub 仓库中,文件作为子模块包含。你可以通过克隆该仓库并调用 git submodule update --init --recursive 来获取它。

有了这个文件,我们就拥有了开始构建所需的一切。通过将此文件作为 CMAKE_PROJECT_TOP_LEVEL_INCLUDES 传递给 CMake,依赖项提供程序会自动安装,任何对 find_package() 的调用都会首先通过 Conan,看看依赖项是否列在 conanfile.txt 中。最终调用 CMake 可能如下所示:

cmake -S . -B build -DCMAKE_PROJECT_TOP_LEVEL_INCLUDES=./cmake-conan/conan_provider.cmake -DCMAKE_BUILD_TYPE=Release

默认情况下,Conan 依赖项提供程序会自动检测任何配置文件信息并将其传递给 Conan。如果没有默认配置文件,它将创建一个。如果所选的构建配置或编译器在 Conan 仓库中没有可用的二进制包,Conan 会尝试在本地构建该包,这可能会花费一些时间,具体取决于包的大小。

尽管推荐将 Conan 用作依赖项提供程序,但有些人可能希望对 Conan 有更多的控制权,独立使用 Conan 并与 CMake 配合使用。让我们来看看这如何实现。

在 CMake 中使用 Conan

为了有效地与 CMake 一起使用 Conan,我们可以利用 CMakeDepsCMakeToolchain 生成器。这些生成器帮助弥合 Conan 的依赖项管理和 CMake 的构建系统配置之间的差距。

如果单独使用 Conan,则必须为每个可以与 CMake 配合使用的构建配置创建一个 Conan 配置文件。

首先,让我们安装依赖项,并让 Conan 创建用于与 CMake 配合使用的必要文件。库和头文件将安装到用户主目录中的 Conan 缓存中,但我们可以告诉 Conan 将 CMake 的包定义安装到哪里。可以是任何目录,但实际上通常将其生成到 CMake 构建目录中会更方便:

conan install . --output-folder ./build --build=missing --settings=build_type=Debug

这将安装 conanfile.txt 中列出的所有依赖项,并在构建文件夹中创建一堆文件。我们还传递了 build=missing 标志。如果 Conan 仓库中没有可用的二进制包,Conan 将尝试在本地构建该包。我们还传递了我们希望安装该包的构建类型。如果省略此选项,则仅会安装默认配置文件中的配置,这可能会导致其他配置的包检测失败。通常,最好显式指定构建类型,使用 -DCMAKE_BUILD_TYPE=Debug,但在使用 Conan 时,这变得是强制性的,以避免因找不到依赖项而带来的麻烦。

如果我们只使用 Conan 的 CMakeDeps 生成器,这将创建必要的文件,供在构建文件夹中使用 find_package()。首选方法是通过命令行将构建文件夹传递给 CMAKE_PREFIX_PATH,或者使用类似以下的前缀:

cmake -S . -B build/ -DCMAKE_PREFIX_PATH=./build -DCMAKE_BUILD_TYPE=Debug

或者,它也可以像这样追加到 CMakeLists.txt 文件中:

list(APPEND CMAKE_PREFIX_PATH ${CMAKE_CURRENT_BINARY_DIR})

这样,CMake 项目就可以构建,并且可以使用依赖项。在大多数情况下,CMakeDeps 生成器是您希望使用的,因为它在交叉编译时利用了 CMake 的优势,同时又能享受 Conan 管理包的便利。

如果您希望将构建配置完全交给 Conan 处理,可以使用 Conan 的 CMakeToolchain 生成器。这个生成器不仅会创建包信息,还会生成一个 CMake 工具链定义和用于使用它们的预设。CMake 工具链的详细内容可以参考 第九章创建可重复的构建环境

同样,安装 Conan 包的命令如下:

conan install . --output-folder ./build --build=missing --settings=build_type=Debug –g CMakeToolchain

使用 CMakeToolchain 生成器将在构建文件夹中创建一个工具链文件,其中包含解析依赖项的所有信息。此外,还将创建 CMakeUserPresets.json,其中包含用于使用生成的工具链的预设。运行 Conan 后,可以使用以下命令使用该预设:

cmake --preset conan-debug

这将使用从 Conan 生成的工具链配置 CMake 项目。虽然这看起来非常方便,但它的缺点是所有构建环境的配置都必须通过 Conan 配置文件或 conan install 命令的标志来完成。使用现有的 CMake 配置选项变得更加困难。特别是,如果项目或开发人员已经定义了自己的预设,这些预设会被 Conan 生成的预设覆盖。因此,建议仅在具有相对简单构建环境要求的小型独立项目中使用 CMakeToolchain 生成器。

尽管 Conan 是一个非常强大的包管理器,并且与 CMake 的集成非常好,但它并不是唯一的选择。另一个常用的包管理器是来自微软的 vcpkg。让我们更详细地了解一下它。

使用 vcpkg 进行依赖管理

另一个流行的开源包管理器是vcpkg,来自微软。它的工作方式类似于 Conan,都是以客户端/服务器架构的形式进行设置。最初,它是为与 Visual Studio 编译器环境一起使用而构建的,后来才添加了 CMake 支持。包可以通过手动安装,调用 vcpkg 的所谓经典模式,或者直接通过 CMake 以清单模式安装。使用 vcpkg 经典模式安装包的命令如下:

vcpkg install [packages]

当以清单模式运行时,项目的依赖项在项目根目录下的vcpkg.json文件中定义。清单模式有一个很大的优势,就是它与 CMake 的集成更加顺畅,因此在可能的情况下,建议使用清单模式。一个 vcpkg 清单可能如下所示:

{
      "name" : "vcpkg-example",
      "version-semver" : "0.0.1",
      "dependencies" :
      [
      "someLibrary",
      "anotherLibrary",
]
}

为了让 CMake 找到包,必须将 vcpkg 工具链文件传递给 CMake,因此调用 CMake 的命令如下所示:

cmake -S . -B ./build --toolchain=~/.local/opt/vcpkg/scripts/buildsystems/vcpkg.cmake

如果以清单模式运行,vcpkg.json文件中指定的包将会自动下载并本地安装。如果以经典模式运行,则必须在运行 CMake 之前手动安装这些包。当传递 vcpkg 工具链文件时,已安装的包可以像往常一样使用,方法是使用find_packagetarget_link_libraries

微软建议将 vcpkg 安装为与 CMake 根项目处于同一级别的子模块,但它几乎可以安装在任何地方。

设置工具链文件可能会在交叉编译时导致问题,因为CMAKE_TOOLCHAIN_FILE可能已经指向了另一个文件。在这种情况下,可以通过VCPKG_CHAINLOAD_TOOLCHAIN_FILE变量传递第二个工具链文件。然后,调用 CMake 的命令可能如下所示:

cmake -S <source_dir> -D <binary_dir> -DCMAKE_TOOLCHAIN_FILE=[vcpkg
root]/scripts/buildsystems/vcpkg.cmake -DVCPKG_CHAINLOAD_TOOLCHAIN_
FILE=/path/to/other/toolchain.cmake

Conan 和 vcpkg 只是 C++和 CMake 中流行的两个包管理器。当然,还有许多其他包管理器,但要描述它们所有,恐怕需要一本专门的书。特别是当项目变得更加复杂时,我们强烈建议使用包管理器。

选择哪个包管理器取决于项目开发的背景和个人偏好。Conan 相比 vcpkg 有一个轻微的优势,因为它在更多平台上受支持,因为它可以在所有 Python 支持的地方运行。在功能和跨编译能力方面,两者大致相等。总体来说,Conan 提供了更多的高级配置选项和对包的控制,但代价是需要更复杂的处理。另一种处理本地依赖的方法是通过使用容器、sysroot 等创建完全隔离的环境。这将在第九章中讨论,创建可重现的构建 环境。暂时我们假设我们正在使用标准系统安装运行 CMake。

在处理项目特定的依赖时,使用包管理器进行依赖管理是推荐的做法。然而,有时包管理器不可用,这可能是因为一些神秘的公司政策或其他原因。在这种情况下,CMake 也支持将依赖项作为源代码下载并将其集成到项目中作为外部目标。

获取依赖作为源代码

有几种方法可以将依赖项作为源代码添加到项目中。一种相对简单但危险的方法是手动下载或克隆它们到项目中的子文件夹,然后使用add_subdirectory将此文件夹添加进来。虽然这种方法有效且速度较快,但很快会变得乏味且难以维护。因此,应尽早将其自动化。

直接将第三方软件的副本下载并集成到产品中的做法称为供应商集成。虽然这种方式的优点是通常使构建软件变得简单,但它会在打包库时产生问题。通过使用包管理器或将第三方软件安装到系统中的某个位置,可以避免供应商集成。

通过纯 CMake 下载依赖作为源代码

获取外部内容的基础是 CMake 的ExternalProject模块和更复杂的FetchContent模块,后者是建立在ExternalProject基础上的。虽然ExternalProject提供了更多的灵活性,但FetchContent通常更方便使用,尤其是当下载的项目本身也使用 CMake 构建时。它们都可以将项目作为源文件下载并用于构建。

使用 FetchContent

对于使用 CMake 构建的外部项目,使用FetchContent模块是添加源依赖的最佳方式。对于二进制依赖,仍然首选使用find_packagefind模块。ExternalProjectFetchContent的主要区别之一是,FetchContent在配置时下载并配置外部项目,而ExternalProject则在构建步骤中完成所有操作。这个缺点是,在配置时无法使用源代码及其配置。

在使用 FetchContent 之前,你会使用 Git 子模块来手动下载依赖项,然后通过 add_subdirectory 将其添加。这在某些情况下有效,但维护起来可能会显得不方便且繁琐。

FetchContent 提供了一系列函数,用于拉取源代码依赖,主要是 FetchContent_Declare,它定义了下载和构建的参数,以及 FetchContent_MakeAvailable,它将依赖的目标填充并使其可用于构建。在以下示例中,bertrand 这个用于契约设计的库通过 GitHub 从 Git 拉取并使其可供使用:

include(FetchContent)
FetchContent_Declare(
  bertrand
  GIT_REPOSITORY https://github.com/bernedom/bertrand.git
  GIT_TAG 0.0.17)
FetchContent_MakeAvailable(bertrand)
add_executable(fetch_content_example)
target_link_libraries(
    fetch_content_example
    PRIVATE bertrand::bertrand
)

在获取依赖源时,应该尽可能使用 FetchContent_Declare 紧跟着 FetchContent_MakeAvailable,因为它使代码库更加易于维护,且其简洁性带来更多的可维护性。FetchContent 可以从 HTTP/S、Git、SVN、Mercurial 和 CVS 下载源代码,使用这些方法时,最好遵循最佳实践,如为下载的内容指定 MD5 校验和或使用 Git 哈希值等。

FetchContent_MakeAvailable 是使外部基于 CMake 的项目可用的推荐方式,但如果你希望对外部项目有更多控制,也可以手动填充项目。以下示例与前面的示例效果相同,但方法更加冗长:

FetchContent_Declare(
  bertrand
  GIT_REPOSITORY https://github.com/bernedom/bertrand.git
  GIT_TAG 0.0.17)
if(NOT bertrand_POPULATED)
FetchContent_Populate(bertrand)
add_subdirectory(${bertrand_SOURCE_DIR} ${bertrand_BINARY_DIR})
endif()

FetchContent_Populate 具有其他选项,可以更精细地控制构建。其签名如下:

FetchContent_Populate( <name>
  [QUIET]
  [SUBBUILD_DIR <subBuildDir>]
  [SOURCE_DIR <srcDir>]
  [BINARY_DIR <binDir>]
  ...
)

让我们来看一下 FetchContent_Populate 的选项:

  • QUIET:如果指定了此选项,成功时将抑制填充输出。如果命令失败,输出将会显示,即使指定了该选项也会显示出来,以便进行调试。

  • SUBBUILD_DIR:此选项指定外部项目的位置。默认值为 ${CMAKE_CURRENT_BINARY_DIR}/<name>-subbuild。通常,这个选项应保持默认设置。

  • SOURCE_DIRBINARY_DIR 改变了外部项目源代码和构建目录的位置。默认设置为 ${CMAKE_CURRENT_BINARY_DIR}/<lcName>-src 作为 SOURCE_DIR,以及 ${CMAKE_CURRENT_BINARY_DIR}/<lcName>-build 作为 BINARY_DIR

  • 在后台,FetchContent 使用的是较早的 ExternalProject 模块,下一节将介绍该模块。任何额外添加的参数都将传递给底层的 ExternalProject_Add。然而,FetchContent 禁止你编辑不同步骤的命令,因此如果试图修改 CONFIGURE_COMMANDBUILD_COMMANDINSTALL_COMMANDTEST_COMMAND,将导致 FetchContent_Populate 失败并报错。

注意

如果你发现自己需要向底层的 ExternalProject_Add 传递选项,考虑直接使用 ExternalProject,而不是先通过 FetchContent。有关如何使用 ExternalProject 的更多细节,请参阅下一节。

关于源目录和构建目录的信息,以及项目是否已被填充,可以通过读取 <name>_SOURCE_DIR<name>_BINARY_DIR<name>_POPULATED 变量,或者调用 FetchContent_GetProperties 获取。请注意,<name> 将始终以大写字母和小写字母的形式提供。这样,CMake 即使面对不同的大小写形式,也能识别这些包。

FetchContent 的另一个重要优点是,它能够处理外部项目共享公共依赖项的情况,避免它们被多次下载和构建。第一次通过 FetchContent 定义依赖项时,相关信息会被缓存,任何进一步的定义都会被默默忽略。这样做的好处是,父项目可以覆盖子项目的依赖关系。

假设我们有一个顶层项目叫做 MyProject,它获取了两个外部项目 Project_AProject_B,每个项目都依赖于一个第三方外部项目 AwesomeLib,但依赖的是不同的小版本。在大多数情况下,我们不希望下载并使用两个版本的 AwesomeLib,而是只使用一个版本以避免冲突。下面的图示展示了依赖关系图可能的样子:

图 5.1 – 项目 Project_A 和项目 Project_B 依赖于不同版本的 AwesomeLib

图 5.1 – 项目 Project_A 和项目 Project_B 依赖于不同版本的 AwesomeLib

为了解决这个问题,我们可以通过在顶层的 CMakeLists.txt 文件中添加 FetchContent_Declare 调用来指定拉取哪个版本的 AwesomeLib。声明的顺序在 CMakeLists.txt 文件中并不重要,重要的是它的声明级别。由于 Project_AProject_B 都包含填充 AwesomeLib 的代码,顶层项目不需要使用 FetchContent_MakeAvailableFetchContent_Populate。最终生成的顶层 CMakeLists.txt 文件可能如下所示:

include(FetchContent)
FetchContent_Declare(Project_A GIT_REPOSITORY ... GIT_TAG ...)
FetchContent_Declare(Project_B GIT_REPOSITORY ... GIT_TAG ...)
# Force AwesomeLib dependency to a certain version
FetchContent_Declare(AwesomeLib
GIT_REPOSITORY … GIT_TAG 1.2 )
FetchContent_MakeAvailable(Project_A)
FetchContent_MakeAvailable(Project_B)

这将强制所有项目将 AwesomeLib 锁定为版本 1.2。当然,这仅在 Project_AProject_B 所需的版本接口兼容的情况下有效,从而生成如下所示的依赖关系图:

图 5.2 – MyProject 声明 AwesomeLib 版本后的修正依赖关系图

图 5.2 – MyProject 声明 AwesomeLib 版本后的修正依赖关系图

将依赖项作为源代码添加有一些优点,但也有一个主要缺点,那就是它会显著增加配置和构建时间。在 第十章,《处理超构建中的分布式仓库和依赖关系》中,我们将讨论超构建和分布式仓库,并提供有关如何处理源代码依赖项的更多信息。

在本章开始时,我们查看了find_package,它可以用来包含二进制依赖项,但我们没有讨论如何方便地使用 CMake 下载本地二进制依赖项。虽然可以使用FetchContent来完成此操作,但这并不是它的目的。相反,像 Conan 和 vcpkg 这样的专用包管理器更为适合。FetchContent在内部使用的是较旧且更复杂的ExternalProject模块。虽然ExternalProject提供了更多控制权,但使用起来也更复杂。接下来,我们来看一下如何使用它。

使用 ExternalProject

ExternalProject模块用于下载并构建那些没有完全集成到主项目中的外部项目。在构建外部项目时,构建是完全隔离的,这意味着它不会自动继承任何与架构或平台相关的设置。这种隔离可以避免目标或组件命名冲突。外部项目会创建一个主要目标和几个子目标,包含以下隔离的构建步骤:

  1. ExternalProject可以通过多种方式下载内容,比如纯 HTTPS 下载,或通过访问版本控制系统,如 Git、Subversion、Mercurial 和 CVS。如果内容是归档文件,下载步骤也会将其解压。

  2. 更新和修补:如果内容是从源代码管理SCM)中拉取的,下载的源代码可以被修补或更新到最新版本。

  3. 配置:如果下载的源代码使用 CMake,则执行配置步骤。对于非 CMake 项目,可以提供一个自定义命令来进行配置。

  4. 构建:默认情况下,依赖项使用与主项目相同的构建工具进行构建,但如果不希望如此,可以提供自定义命令。如果提供了自定义构建命令,则用户需要确保传递必要的编译器标志,以确保结果与 ABI 兼容。

  5. 安装:可以将隔离的构建安装到本地,通常是主项目的构建树中的某个位置。

  6. 测试:如果外部内容附带了一组测试,主项目可以选择运行这些测试。默认情况下,不会运行测试。

所有步骤,包括下载,都在构建时执行。因此,根据外部项目的不同,这可能会显著增加构建时间。CMake 会缓存下载和构建内容,因此除非外部项目已更改,否则额外的开销主要是第一次运行时的开销。虽然可以为外部构建添加更多步骤,但对于大多数项目,默认步骤已经足够。稍后我们将看到,步骤可以根据需要进行自定义或省略。

在以下示例中,使用契约设计的bertrand库通过 HTTPS 下载并在当前的build目录中本地安装:

include(ExternalProject)
ExternalProject_Add(
  bertrand
  URL https://github.com/bernedom/bertrand/archive
    /refs/tags/0.0.17.tar.gz
  URL_HASH MD5=354141c50b8707f2574b69f30cef0238
  INSTALL_DIR ${CMAKE_CURRENT_BINARY_DIR}/bertrand_install
   CMAKE_CACHE_ARGS -DBERTRAND_BUILD_TESTING:BOOL=OFF
-DCMAKE_INSTALL_PREFIX:PATH=<INSTALL_DIR>
)

请注意,ExternalProject 模块默认不可用,必须在第一行使用 include(ExternalProject) 引入。由于外部库安装在本地构建目录中,因此指定了 INSTALL_DIR 选项。由于 bertrand 本身是一个 CMake 项目,安装目录通过使用 CMAKE_INSTALL_PREFIX 变量传递给 <INSTALL_DIR> 以构建该项目。<INSTALL_DIR> 是一个占位符,指向 INSTALL_DIR 选项。ExternalProject 知道各种目录的占位符,如 <SOURCE_DIR><BINARY_DIR><DOWNLOAD_DIR>。有关完整列表,请查阅模块文档 cmake.org/cmake/help/latest/module/ExternalProject.html

验证你的下载

强烈建议你在任何 URL 中添加下载哈希值,因为这样如果文件内容发生变化,你会收到通知。

为了使其生效,任何依赖于 bertrand 的目标必须在外部依赖项构建之后构建。由于 bertrand 是一个仅包含头文件的库,我们希望将 include 路径添加到目标中。在 CMake 中为另一个目标使用外部项目可能类似如下所示:

ExternalProject_Get_Property(bertrand INSTALL_DIR)
set(BERTRAND_DOWNLOADED_INSTALL_DIR "${INSTALL_DIR}")
# Create a target to build an executable
add_executable(external_project_example)
# make the executable to be built depend on the external project
# to force downloading first
add_dependencies(external_project_example bertrand)
# make the header file for bertrand available
target_include_directories(external_project_example PRIVATE
  ${BERTRAND_DOWNLOADED_INSTALL_DIR}/include)

在第一行中,通过 ExternalProject_Get_Property 获取安装目录并存储在 INSTALL_DIR 变量中。不幸的是,变量名总是与属性名称相同,因此建议你在获取后立即将其存储在一个唯一名称的变量中,以便更好地表达其用途。

接下来,我们创建要构建的目标,并使其依赖于 ExternalProject_Add 创建的目标。这是强制正确构建顺序所必需的。

最后,使用 target_include_directories 将本地安装路径添加到目标中。此外,我们还可以导入外部库提供的 CMake 目标,但本示例的目的是说明当外部项目不是由 CMake 构建时,如何实现这一操作。

从 SCM 系统下载时会使用相应的选项。对于 Git,这通常如下所示:

ExternalProject_Add(MyProject GIT_REPOSITORY
  https://github.com/PacktPublishing/SomeRandomProject.git
    GIT_TAG 56cc1aaf50918f208e2ff2ef5e8ec0111097fb8d )

请注意,GIT_TAG 可以是 Git 的任何有效修订号,包括标签名称和长短哈希。如果省略 GIT_TAG,将下载默认分支的最新版本—通常叫做 main 或 master。我们强烈建议你始终指定要下载的版本。最稳健的方法是定义提交哈希,因为标签有可能被移动,尽管在实践中这种情况很少发生。从 SVN 下载与从 Git 下载类似。有关更多详情,请查阅 ExternalProject 的官方文档。

使用非 CMake 项目和交叉编译

ExternalProject 的常见用例是构建那些不是由 CMake 处理而是由 Autotools 或 Automake 处理的依赖项。在这种情况下,你需要指定配置和构建命令,如下所示:

find_program(MAKE_EXECUTABLE NAMES nmake gmake make)
ExternalProject_Add(MyAutotoolsProject
   URL    someUrl
   INSTALL_DIR ${CMAKE_CURRENT_BINARY_DIR}/myProject_install
   CONFIGURE_COMMAND <SOURCE_DIR>/configure --prefix=<INSTALL_DIR>
    BUILD_COMMAND ${MAKE_EXECUTABLE}
)

请注意,第一个 find_program 命令用于查找 make 的版本并将其存储在 MAKE_EXECUTABLE 变量中。外部项目的一个常见问题是,你必须仔细控制依赖项的安装位置。大多数项目希望将其安装到默认的系统位置,这通常需要管理员权限,并且可能会不小心污染系统。因此,通常需要将必要的选项传递给配置或构建步骤。另一种处理方式是完全避免安装过程,通过将 INSTALL_COMMAND 替换为空字符串,如下所示:

ExternalProject_Add(MyAutotoolsProject
   URL    someUrl
   CONFIGURE_COMMAND <SOURCE_DIR>/configure
    BUILD_COMMAND ${MAKE_EXECUTABLE}
    INSTALL_COMMAND ""
)

使用非 CMake 项目(例如这个项目)的一大问题是,它们没有定义直接使用依赖项所需的目标。因此,要在另一个目标中使用外部构建的库,通常需要将完整的库名称添加到 target_link_libraries 调用中。这个方法的主要缺点是,你需要手动维护不同平台上文件的名称和位置。find_libraryfind_file 调用几乎没什么用处,因为它们发生在配置时,而 ExternalProject 只会在构建时创建所需的文件。

另一个常见的用例是使用 ExternalProject 为不同的目标平台构建现有源目录的内容。在这种情况下,处理下载的参数可以直接省略。如果外部项目使用 CMake 构建,可以将工具链文件作为 CMake 选项传递给外部项目。关于工具链文件的更多信息,请参阅 第十二章跨平台编译和自定义工具链。一个常见的陷阱是,ExternalProject 不会识别外部项目源代码的任何变化,因此 CMake 可能不会重新构建它们。为了避免这种情况,应该传递 BUILD_ALWAYS 选项,但这会导致构建时间显著增加:

ExternalProject_Add(ProjectForADifferentPlatform
SOURCE_DIR $
    {CMAKE_CURRENT_LIST_DIR}/ProjectForADifferentPlatform
INSTALL_DIR ${CMAKE_CURRENT_BINARY_DIR}/
  ProjectForADifferentPlatform-install
CMAKE_ARGS
-D CMAKE_TOOLCHAIN_FILE=${CMAKE_CURRENT_LIST_DIR}/fwtoolchain.cmake
-D CMAKE_BUILD_TYPE=Release
-D CMAKE_INSTALL_PREFIX=<INSTALL_DIR>
BUILD_ALWAYS YES
)

管理 ExternalProject 中的步骤

如前一节所述,ExternalProject 的步骤可以进一步配置,并以更细粒度的方式使用。可以通过传递 STEP_TARGETS 选项或调用 ExternalProject_Add_StepsTargets 来告诉 ExternalProject 为每个步骤创建常规目标。以下调用将外部项目的配置步骤和构建步骤暴露为目标:

ExternalProject_Add(MyProject
   # various options
   STEP_TARGETS configure build
)
ExternalProject_Add_StepTargets(MyProject configure build)

目标命名为 <mainName>-step。在上述示例中,将创建两个额外的目标:MyProject-configureMyProject-build。创建步骤目标有两个主要用途:你可以创建按下载、配置、构建、安装和测试顺序排序的自定义步骤,或者可以使步骤依赖于其他目标。这些目标可以是常规目标,由 add_executableadd_libraryadd_custom_target 创建,或者是来自其他可执行文件的目标。一个常见的情况是外部项目相互依赖,因此一个项目的配置步骤必须依赖于另一个项目。在下一个示例中,ProjectB 的配置步骤将依赖于 ProjectA 的完成:

ExternalProject_Add(ProjectA
... # various options
        STEP_TARGETS install
)
ExternalProject_Add(ProjectB
... # various options
)
ExternalProject_Add_StepDependencies(ProjectB configure ProjectA)

最后,我们还可以创建自定义步骤并插入到外部项目中。添加步骤的过程通过 ExternalProject_Add_Step 命令完成。自定义步骤不能与任何预定义的步骤同名(如 mkdirdownloadupdatepatchconfigurebuildinstalltest)。以下示例将在构建后创建一个步骤,将外部项目的许可证信息添加到特定的 tar 文件中:

ExternalProject_Add_Step(bertrand_downloaded copy_license
     COMMAND ${CMAKE_COMMAND} -E tar "cvzf" ${CMAKE_CURRENT_
       BINARY_DIR}/licenses.tar.gz <SOURCE_DIR>/LICENSE
         DEPENDEES build
)

总的来说,ExternalProject 是一个非常强大的工具;然而,它的管理可能会变得非常复杂。通常,正是这种灵活性使得 ExternalProject 难以使用。虽然它可以帮助隔离构建,但它常常迫使项目维护者手动将外部项目内部工作的信息暴露给 CMake,这与 CMake 原本应当解决的问题相矛盾。

总结

在本章中,我们介绍了查找文件、库和程序的一般方法,以及更复杂的 CMake 包查找。你学会了如何在 CMake 无法自动找到包时,创建一个导入的包定义,方法是提供你自己的 find 模块。我们还探讨了基于源代码的依赖关系,使用 ExternalProjectFetchContent,以及即使是非 CMake 项目也可以通过 CMake 构建。

此外,如果你希望在依赖管理方面更加复杂,我们介绍了 Conan 和 vcpkg 作为两种与 CMake 集成非常好的包管理工具。

依赖管理是一个难以涵盖的复杂话题,有时可能令人感到繁琐。然而,花时间按照本章所述的技巧正确设置依赖管理是值得的。CMake 的多功能性以及它寻找依赖的多种方式是其最大优点,也是其最大弱点。通过使用各种 find_ 命令、FetchContentExternalProject 或将任何可用的包管理器与 CMake 集成,几乎可以将任何依赖集成到项目中。然而,选择合适的方法可能会非常困难。尽管如此,我们还是建议尽可能使用 find_package。随着 CMake 越来越受欢迎,其他项目无缝集成的机会也会增大。

在下一章中,你将学习如何自动生成并打包你的代码文档。

问题

  1. CMake 中存在哪些 find_ 程序?

  2. 应为由 find 模块导入的目标设置哪些属性?

  3. 在查找内容时,HINTSPATHS 哪个选项优先?

  4. 依赖管理技术的优先顺序是什么?

  5. ExternalProject 在哪个阶段下载外部内容?

  6. FetchContent 在哪个阶段下载外部内容?

答案

  1. 答案是 find_filefind_pathfind_libraryfind_programfind_package

  2. IMPORTED_LOCATIONINTERFACE_INCLUDE_DIRECTORIES 属性。

  3. HINTS 优先于 PATHS

  4. 包管理器作为依赖提供者是最推荐的处理依赖的方式,其次是使用独立的包管理器,然后是 FetchContent,最后,只有在其他方法都失败时,才应使用 ExternalProject

  5. ExternalProject 在构建时下载外部内容。

  6. FetchContent 在配置时下载外部内容。

第六章:自动生成文档

文档无疑是所有项目中不可或缺的一部分。文档传递的是用户无法直接获得的信息,它是一种分享项目意图、功能、能力和限制的方式,使技术人员和非技术人员都能参与项目的工作。然而,编写文档确实是一个费时的过程。因此,利用现有的工具来生成文档非常重要。

本章将展示如何将 Doxygen、dot 和 PlantUML 集成到 CMake 中,以加速文档生成过程。这些工具将帮助我们减少代码和文档之间的上下文切换,并减轻文档维护的负担。

为了理解本章所介绍的技巧,我们将涵盖以下主要内容:

  • 从代码生成文档

  • 使用 CPack 打包和分发文档

  • 创建 CMake 目标的依赖图

让我们从技术要求开始。

技术要求

在深入本章内容之前,你应该对第四章《打包、部署和安装 CMake 项目》和第五章《集成第三方库和依赖管理》有一个清晰的了解。本章将使用的技术都已在这两章中涵盖。此外,建议从github.com/PacktPublishing/CMake-Best-Practices---2nd-Edition/tree/main/chapter06获取本章的示例内容。所有示例都假设你将使用项目提供的开发环境容器,相关链接为:github.com/PacktPublishing/CMake-Best-Practices---2nd-Edition。这是一个类似于 Debian 的环境,已经预先安装了所有必要的依赖。如果使用不同的环境,命令和输出可能会有所不同。如果你没有使用提供的 Docker 容器,请确保你已经在环境中安装了 Doxygen、PlantUML 和 Graphviz。有关安装详细信息,请查阅你的包管理器索引。

让我们通过学习如何从现有代码生成文档,来深入了解文档的领域。

从代码生成文档

大多数人,无论是有意识的还是无意识的,都以有组织的方式构建他们的软件项目。这种组织结构是面向对象OO)设计、编程语言规则、个人偏好、习惯或项目规则等方法论和程序的积极副作用。尽管规则和约定往往显得枯燥,但遵守它们会导致一个更易理解的项目结构。当程序、规则、秩序和组织存在时,计算机就能理解其中的内容。文档生成软件利用这一点来为我们带来好处。

生成文档的最显著工具之一是 Doxygen。作为 C++ 中代码文档的事实标准,它与 CMake 的集成非常顺畅。我们将学习如何将 Doxygen 与 CMake 集成,以自动为 CMake 项目生成文档。

了解 Doxygen 是什么

Doxygen 是一个非常流行的 C++ 项目文档生成软件,它可以从代码中生成文档。Doxygen 理解 C 和 C++ 语法,并能够像编译器一样查看代码结构。这使得 Doxygen 可以深入了解软件项目的结构,查看所有类定义、命名空间、匿名函数、封装、变量、继承关系等内容。Doxygen 将这些信息与程序员编写的内联代码文档结合起来。最终的结果是兼容在线和离线阅读的各种格式的人类可读文档。

为了能够理解代码注释,Doxygen 要求注释必须符合一组预定义的格式。如何创建 Doxygen 可以理解的代码注释的完整文档可以在这里找到:www.doxygen.nl/manual/docblocks.html。在我们的示例中,我们将使用Javadoc风格的注释,这种注释方式常见,但你可以根据个人偏好选择适合的方式。下面是一个 C++ 函数的 Javadoc 注释示例:

/**
* Does foo with @p bar and @p baz
*
* @param [in] bar Level of awesomeness
* @param [in] baz Reason of awesomeness
*/
void foo(int bar, const char* baz){}

Doxygen 还需要一个Doxyfile,它本质上包含了所有文档生成的参数,例如输出格式、排除的文件模式、项目名称等。由于配置参数的数量庞大,初始配置 Doxygen 可能会让人感到畏惧,但不用担心——CMake 也会为你生成一个 Doxyfile。

随着我们进一步深入本章,你将开始看到为你的项目使用文档生成软件的好处。通过这样做,保持文档与代码的一致性变得更加容易,而且能够查看代码结构也使得绘制图表变得更加简单。

让我们看看 Doxygen 和 CMake 如何协同工作。

使用 Doxygen 与 CMake

CMake 作为一个面向 C++ 的构建系统生成器,能够很好地支持集成 C++ 项目中常用的外部工具。正如你所期待的,将 Doxygen 与 CMake 集成非常简单。我们将使用 CMake 的 FindDoxygen.cmake 模块将 Doxygen 集成到我们的项目中。该模块默认由 CMake 安装提供,无需额外的设置。

FindDoxygen.cmake,顾名思义,是一个模块包文件,专门供 find_package() CMake 函数使用。它的主要作用是定位环境中的 Doxygen,并提供一些额外的工具函数以启用 CMake 项目中的文档生成。为了展示 Doxygen 的功能,我们将遵循 第六章 - 示例 01 的示例。本节的目标是为一个简单的计算器库及其 README 文件生成文档。这个库的接口定义如下:

class calculator : private calculator_interface {
public:
  /**
    * Calculate the sum of two numbers, @p augend lhs and
      @p addend
    *
    * @param [in] augend The number to which @p addend is
      added
    * @param [in] addend The number which is added to
      @p augend
    *
    * @return double Sum of two numbers, @p lhs and @p rhs
    */
  virtual double sum(double augend, double addend)
    override;
   /**
    * Calculate the difference of @p rhs from @p lhs
    *
    * @param [in] minuend    The number to which @p
      subtrahend is subtracted
    * @param [in] subtrahend The number which is to be
      subtracted from @p minuend
   *
    * @return double Difference of two numbers, @p minuend
      and @p subtrahend
   */
  virtual double sub(double minuend, double subtrahend)
    override;
  /*...*/}; // class calculator

calculator 类实现了在 calculator_interface 类中定义的类接口。它已按照 Javadoc 格式进行了适当的文档编写。我们期望 Doxygen 生成 calculatorcalculator_interface 类,并附带继承关系图。类定义位于 calculator.hpp 文件中,位于 chapter6/ex01_doxdocgen 目录下的 include/chapter6/ex01 子目录中。此外,我们在 chapter6/ex01_doxdocgen 目录下还有一个名为 README.md 的 Markdown 文件,其中包含关于示例项目布局的基本信息。我们期望这个文件成为文档的主页。由于我们的输入材料已准备好,让我们像往常一样,继续深入查看示例的 CMakeLists.txt 文件,即 chapter6/ex01_doxdocgen/CMakeLists.txt 文件。该 CMakeLists.txt 文件首先查找 Doxygen 包,如下所示:

find_package(Doxygen)
set(DOXYGEN_OUTPUT_DIRECTORY"${CMAKE_CURRENT_BINARY_DIR}
  /docs")
set(DOXYGEN_GENERATE_HTML YES)
set(DOXYGEN_GENERATE_MAN YES)
set(DOXYGEN_MARKDOWN_SUPPORT YES)
set(DOXYGEN_AUTOLINK_SUPPORT YES)
set(DOXYGEN_HAVE_DOT YES)
set(DOXYGEN_COLLABORATION_GRAPH YES)
set(DOXYGEN_CLASS_GRAPH YES)
set(DOXYGEN_UML_LOOK YES)
set(DOXYGEN_DOT_UML_DETAILS YES)
set(DOXYGEN_DOT_WRAP_THRESHOLD 100)
set(DOXYGEN_CALL_GRAPH YES)
set(DOXYGEN_QUIET YES)

find_package(...) 调用将利用 CMake 安装提供的 FindDoxygen.cmake 模块来查找环境中是否存在 Doxygen。省略 REQUIRED 参数,目的是允许包维护者打包项目时,无需事先安装 Doxygen,确保在继续之前能够检测到 Doxygen。后续的几行代码设置了几个 Doxygen 配置。这些配置将被写入由 CMake 生成的 Doxyfile 文件中。每个选项的详细描述如下:

  • DOXYGEN_OUTPUT_DIRECTORY:设置 Doxygen 的输出目录。

  • DOXYGEN_GENERATE_HTML:指示 Doxygen 生成 超文本标记语言 (HTML) 输出。

  • DOXYGEN_GENERATE_MAN:指示 Doxygen 生成 MAN 页面的输出。

  • DOXYGEN_AUTOLINK_SUPPORT:允许 Doxygen 自动将语言符号和文件名链接到相关的文档页面(如果可用)。

  • DOXYGEN_HAVE_DOT:告诉 Doxygen 环境中有 dot 命令可用,可以用来生成图表。这将使 Doxygen 能够通过图表(如依赖图、继承图和协作图)来丰富生成的文档。

  • DOXYGEN_COLLABORATION_GRAPH:告诉 Doxygen 为类生成协作图。

  • DOXYGEN_CLASS_GRAPH:告诉 Doxygen 为类生成类图。

  • DOXYGEN_UML_LOOK:指示 Doxygen 生成类似于统一建模语言UML)的图表。

  • DOXYGEN_DOT_UML_DETAILS:向 UML 图表中添加类型和参数信息。

  • DOXYGEN_DOT_WRAP_THRESHOLD:设置 UML 图表的换行阈值。

  • DOXYGEN_CALL_GRAPH:指示 Doxygen 为函数文档生成调用图。

  • DOXYGEN_QUIET:抑制生成的 Doxygen 输出到标准输出stdout)。

Doxygen 的选项集合非常广泛,提供了比我们所覆盖的选项更多的功能。如果你想进一步自定义文档生成,请查看在 www.doxygen.nl/manual/config.html 上列出的完整参数列表。要在 CMake 中设置任何 Doxygen 选项,请在变量名之前添加 DOXYGEN_ 前缀,并使用 set() 设置所需的值。说完这些附注后,让我们回到示例代码。前面显示的 CMake 代码后面是目标声明。以下代码行定义了一个常规的静态库,包含我们示例代码的文档:

add_library(ch6_ex01_doxdocgen_lib STATIC)
target_sources(ch6_ex01_doxdocgen_lib PRIVATE
  src/calculator.cpp)
target_include_directories(ch6_ex01_doxdocgen_lib PUBLIC
  include)
target_compile_features(ch6_ex01_doxdocgen_lib PRIVATE
  cxx_std_11)

随后,以下代码行定义了一个可执行文件,它使用之前定义的静态库目标:

add_executable(ch6_ex01_doxdocgen_exe src/main.cpp)
target_compile_features(ch6_ex01_doxdocgen_exe PRIVATE
  cxx_std_11)
target_link_libraries(ch6_ex01_doxdocgen_exe PRIVATE
  ch6_ex01_doxdocgen_lib)

最后,调用 doxygen_add_docs(...) 函数来指定我们希望生成文档的代码,如下所示:

doxygen_add_docs(
  ch6_ex01_doxdocgen_generate_docs
  "${CMAKE_CURRENT_LIST_DIR}"
  ALL
  COMMENT "Generating documentation for Chapter 6 - Example
    01 with Doxygen"
)

doxygen_add_docs(...) 函数是 FindDoxygen.cmake 模块提供的一个函数。它的唯一目的是提供一种便捷的方式来创建用于文档生成的 CMake 目标,而无需显式处理 Doxygen。doxygen_add_docs(...) 函数的函数签名如下(非相关参数已省略):

doxygen_add_docs(targetName
    [filesOrDirs...]
    [ALL]
    [COMMENT comment])

targetName 函数的第一个参数是文档目标的名称。该函数将生成一个名为 targetName 的自定义目标。此目标将在构建时触发 Doxygen,并从代码生成文档。接下来的参数列表 filesOrDirs 是包含我们要生成文档的代码的文件或目录列表。ALL 参数用于使 CMake 的 ALL 元目标依赖于 doxygen_add_docs(...) 函数创建的文档目标,因此当构建 ALL 元目标时,文档生成会自动触发。最后,COMMENT 参数用于在构建目标时让 CMake 将一条消息打印到输出中。COMMENT 主要用于诊断目的,帮助我们快速了解文档是否正在生成。

在简要介绍了 doxygen_add_docs(...) 之后,让我们回到示例代码,并解释一下在我们的场景中 doxygen_add_docs(...) 函数调用的作用。它创建了一个名为 ch6_ex01_doxdocgen_generate_docs 的目标,将 ${CMAKE_CURRENT_LIST_DIR} 添加到文档生成目录,请求 ALL 元目标依赖于它,并指定了一个在目标构建时打印的 COMMENT 参数。

好的——现在是时候测试这是否有效了。进入 chapter06/ 目录并使用以下命令在 build/ 目录中配置项目:

cd chapter06/
cmake -S . -B build/

检查 CMake 输出,以查看配置是否成功。如果配置成功,那就意味着 CMake 成功地在环境中找到了 Doxygen。你应该能在 CMake 输出中看到这一点,具体如下所示:

Found Doxygen: /usr/bin/doxygen (found version „1.9.1")
  found components: doxygen dot

配置成功后,我们尝试使用以下命令构建它:

cmake --build build/

在构建输出中,你应该能够看到我们在 COMMENT 参数中输入的文本被打印到 CMake 输出中。这意味着文档目标正在构建,并且 Doxygen 正在运行。注意,我们没有为 CMake 的 build 命令指定 --target 参数,这实际上导致 CMake 构建了 ALL 元目标。由于我们为 doxygen_add_docs(...) 函数提供了 ALL 参数,ch6_ex01_doxdocgen_generate_docs 目标也会被构建。build 命令的输出应该类似于这里给出的输出:

 [ 62%] Generating documentation for Chapter 6 - Example 01 with Doxygen
[ 62%] Built target ch6_ex01_doxdocgen_generate_docs
[ 75%] Building CXX object ex02_doxplantuml/CMakeFiles/ch6_ex02_doxplantuml.dir/src/main.cpp.o
[ 87%] Linking CXX executable ch6_ex02_doxplantuml
[ 87%] Built target ch6_ex02_doxplantuml
[100%] Generating documentation for Chapter 6 - Example 02 with Doxygen
[100%] Built target ch6_ex02_doxdocgen_generate_docs

看起来我们已经成功构建了项目和文档。让我们检查生成的文档,位于 ${CMAKE_CURRENT_BINARY_DIR}/docs 输出文件夹中,如下所示:

14:27 $ ls build/ex01_doxdocgen/docs/
html  man

在这里,我们可以看到 Doxygen 已经将 HTMLMAN 页面输出到 html/man/ 目录中。让我们检查每种类型的结果。要检查生成的 MAN 页面,只需输入以下内容:

man build/ex01_doxdocgen/docs/man/man3
  /chapter6_ex01_calculator.3

要在仓库提供的开发容器中使用 man,首先运行 sudo unminimize,因为该容器镜像已针对大小进行了优化。该命令将打开 Man 页面并显示类似于此截图的内容:

图 6.1 使用 Doxygen 生成的示例手册页

很好!我们的代码注释变成了手册页。同样,让我们检查 HTML 输出。使用你喜欢的浏览器打开 build/ex01_doxdocgen/docs/html/index.html 文件,如下所示:

google-chrome build/ex01_doxdocgen/docs/html/index.html

这将显示文档的主页,如下所示的截图所示:

图 6.2 – 文档的主页

在上面的截图中,我们可以看到 Doxygen 已经将 README.md Markdown 文件的内容渲染到主页中。请注意,主页仅作为示例提供。Doxygen 可以将任意数量的 Markdown 文件嵌入到生成的文档中。它甚至会将文件名、类名和函数名替换为指向相关文档的链接。这是通过 Doxygen 的 AUTOLINK 功能和 @ref Doxygen 命令实现的。点击 calculator 类下的 calculator 链接。calculator 类的文档页面应该如下所示:

图 6.3 – 生成的计算器类 HTML 文档(基础布局)

在上面的截图中,我们可以看到 Doxygen 知道 calculator 类继承自 calculator_interface,并为 calculator 类绘制了继承图。

注意

Doxygen 需要 dot 工具来渲染图表。dot 工具包含在 Graphviz 软件包中。

此外,生成的图表包含 UML 风格的函数名称和封装符号。让我们看看下面截图中显示的详细成员函数文档:

图 6.4 – 生成的计算器类 div() 函数文档

如我们在 图 6.3 中所见,Doxygen 在将内容排版成清晰、可读的布局方面做得相当不错。如果你在阅读这个 API 文档,你应该会很高兴。最后,让我们导航到 main.cpp,查看 main.cpp 的文档,以说明什么是依赖图。你可以在以下截图中看到文档页面的表示:

图 6.5 – main.cpp 文档页面

在上面的截图中显示的依赖图表明,main.cpp 文件直接依赖于 iostreamchapter6/ex06/calculator.hpp 文件,并间接依赖于 chapter6/ex06/calculator_interface.hpp 文件。将依赖信息包含在文档中是非常有用的。使用者将准确知道文件依赖关系,而无需深入代码。如果你再往下滚动一点,你会看到 main() 函数的调用图,如下所示的截图所示:

图 6.6 – main() 函数调用图

太棒了!我们只用了不到 20 行额外的 CMake 代码,就生成了带有图表的文档,支持两种不同的格式。多酷啊!现在,凭借这个功能,要找借口避免文档化就变得很难了。不过,本章的旅程还没有结束。接下来的部分将通过教我们如何将自定义 UML 图表嵌入文档,进一步丰富我们的知识。让我们继续!

将自定义 UML 图表嵌入文档

在上一部分,我们学习了如何利用 Doxygen 为我们的 CMake 项目生成图表和文档,但并非每个图表都能从代码中推断出来。我们可能想要绘制自定义图表,以说明一个实体与外部系统之间的复杂关系,而这些关系在代码上下文中是无法体现的。为了解决这个问题,显而易见的选择是将该上下文以某种方式引入到代码或注释中,再次利用文档生成。那么,正如预期的那样,这也是 Doxygen 可以做到的。Doxygen 允许在注释中嵌入 PlantUML 图表,这将使我们能够绘制任何 PlantUML 支持的图表。但在开始将 PlantUML 代码放入 Doxygen 注释之前,有一件小事需要处理:在 Doxygen 中启用 PlantUML 支持。我们已经有了一个起点。让我们开始吧!

在 Doxygen 中启用 PlantUML 支持非常简单。Doxygen 需要一个 PLANTUML_JAR_PATH 变量,该变量必须设置为 plantuml.jar 文件在环境中的位置。因此,我们需要找出该文件的位置。为此,我们将使用 find_path(...) CMake 函数。find_path(...) 函数与 find_program(...) 类似,不同之处在于它用于定位文件路径,而不是程序位置。也就是说,我们应该能够通过 find_path(...) 找到 plantuml.jar 的路径,然后将该路径提供给 Doxygen,接下来就可以……获利了!让我们来验证这个理论。我们将按照 第六章 - 示例 02 来进行这一部分的操作。照例,我们先来查看示例代码的 CMakeLists.txt 文件,文件位于 chapter06/ex02_doxplantuml/CMakeLists.txt 路径下。从 find_path(...) 调用开始,具体如下:

find_path(PLANTUML_JAR_PATH NAMES plantuml.jar HINTS
  "/usr/share/plantuml" REQUIRED)
find_package(Doxygen REQUIRED)
set(DOXYGEN_OUTPUT_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}
  /docs")
set(DOXYGEN_GENERATE_HTML YES)
set(DOXYGEN_AUTOLINK_SUPPORT YES)
set(DOXYGEN_PLANTUML_JAR_PATH "${PLANTUML_JAR_PATH}")
set(DOXYGEN_QUIET YES)

在这里的find_path(...)调用中,PLANTUML_JAR_PATH是输出变量的名称。NAMES是将在搜索位置中查找的文件名。HINTS是除默认搜索位置之外的额外路径。这些路径对于在非标准位置发现文件非常有用。最后,REQUIRED参数用于将plantuml.jar作为一个必需项,因此当找不到plantuml.jar时,CMake 会失败并退出。以下的 Doxygen 配置部分与我们之前的示例《第六章 示例 01》完全相同,只不过我们将DOXYGEN_PLANTUML_JAR_PATH设置为通过find_path(...)调用找到的 PlantUML 目录路径。此外,未在此示例中需要的变量也被省略了。此时,Doxygen 应该能够使用 PlantUML。让我们通过一个示例 PlantUML 图表来测试一下,它被嵌入到了 src/main.cpp 源文件中,如下所示:

 /**
* @brief Main entry point of the application
  @startuml{system_interaction.png} "System Interaction Diagram"
  user -> executable : main()
  user -> stdin : input text
  executable -> stdin: read_stdin()
  stdin -> executable
  executable -> stdout: print_to_stdout()
  stdout -> user : visual feedback
  @enduml
*
* @return int Exit code
*/
int main(void) {
...
}

@startuml@enduml Doxygen 注释关键词分别用于标示 PlantUML 图表的开始和结束。常规的 PlantUML 代码可以放在@startuml - @enduml块中。在我们的示例中,我们展示了一个应用程序的简单系统交互图。如果一切顺利,我们应该能在main()函数的文档中看到嵌入的 PlantUML 图表。让我们通过构建包含以下代码的示例来生成文档:

cd chapter06/
cmake -S ./ -B build/
cmake --build build/

第二个示例的文档现在应该已经生成。通过运行以下命令,使用你选择的浏览器打开生成的 build/ex02_doxplantuml/docs/html/index.html HTML 文档:

google-chrome build/ex02_doxplantuml/docs/html/main_8cpp.html

如果你向下滚动到 main 函数,你会看到我们的 UML 时序图,如下所示:

图 6.7 – 嵌入在 main() 函数文档中的 PlantUML 图表

图 6.6中,我们可以看到 Doxygen 生成了一个 PlantUML 图表并将其嵌入到了文档中。通过这个功能,我们现在能够将自定义图表嵌入到我们生成的文档中了。这将帮助我们在不需要使用外部绘图工具的情况下,解释复杂的系统和关系。

现在我们已经拥有了生成文档的正确工具,接下来是学习如何打包和交付这些文档。在接下来的部分,我们将学习文档交付的方式,以及涉及的相关软件。

使用 CPack 打包和分发文档

打包文档与打包软件及其构件并没有什么不同——毕竟,文档本身就是项目的一个构件。因此,我们将使用在第四章《打包、部署和安装 CMake 项目》中学到的技术来打包我们的文档。

注意

如果你还没有阅读第四章《打包、部署和安装 CMake 项目》,强烈建议在阅读本节之前先阅读该章节。

为了说明这一部分,我们将回到第六章 - 示例 01。我们将使我们在这个示例中生成的文档可安装并可打包。让我们重新回到位于chapter06/ex01_doxdocgen/文件夹中的CMakeLists.txt文件。通过以下代码,我们将使htmlman文档可安装:

include(GNUInstallDirs)
install(DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/docs/html/"
  DESTINATION "${CMAKE_INSTALL_DOCDIR}" COMPONENT
    ch6_ex01_html)
install(DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/docs/man/"
  DESTINATION "${CMAKE_INSTALL_MANDIR}" COMPONENT
    ch6_ex01_man)

记得我们之前在第四章中使用install(DIRECTORY...)来安装任何类型的文件夹,同时保持其结构吗?打包、部署和安装 CMake 项目?这正是我们在这里所做的。我们通过安装docs/htmldocs/manGNUInstallDirs模块提供的默认文档和 man 页面目录中,使生成的文档可安装。此外,还要记住,如果某个内容是可安装的,那么它也意味着可以打包,因为 CMake 可以从install(...)调用生成所需的打包代码。所以,让我们也包括CPack模块,以便为这个示例启用打包功能。代码在以下片段中展示:

set(CPACK_PACKAGE_NAME cbp_chapter6_example01)
set(CPACK_PACKAGE_VENDOR "CBP Authors")
set(CPACK_GENERATOR "DEB;RPM;TBZ2")
set(CPACK_DEBIAN_PACKAGE_MAINTAINER "CBP Authors")
include(CPack)

就这样!就这么简单。让我们尝试通过调用以下命令来构建并打包示例项目:

cd chapter06/
cmake -S . -B build/
cmake --build build/
cpack --config  build/CPackConfig.cmake -B build/pak

好的,让我们总结一下这里发生了什么。我们已经配置并构建了chapter06/代码,并通过调用 CPack 将项目打包到build/pak文件夹中,使用了生成的CPackConfig.cmake文件。为了检查一切是否正常,我们通过调用以下命令将生成的包的内容提取到/tmp/ch6-ex01路径下:

dpkg -x build/pak/cbp_chapter6_example01-1.0-Linux.deb
  /tmp/ch6-ex01
export MANPATH=/tmp/ch6-ex01/usr/share/man/

提取完成后,文档应能在/tmp/ch6-ex01/usr/share路径下访问。由于我们使用了非默认路径,因此我们使用MANPATH环境变量让man命令知道我们的文档路径。让我们先检查是否可以通过调用man命令访问 man 页面,如下所示:

man chapter6_ex01_calculator

chapter6_ex01_calculator的名称是由 Doxygen 根据chapter6::ex01::calculator类名自动推断出来的。你应该能够看到我们在上一节中讨论的 man 页面输出。

到目前为止,我们已经学习了很多关于生成和打包文档的内容。接下来,我们将学习如何生成 CMake 目标的依赖图。

创建 CMake 目标的依赖图

在前面的部分中,我们已经涵盖了软件代码的文档编写和图形化,但在一个大型项目中,我们可能还需要对 CMake 代码进行文档化和可视化。CMake 目标之间的关系可能非常复杂,这可能使得追踪所有依赖关系变得困难。幸运的是,CMake 通过提供一个显示所有目标之间依赖关系的图表来帮助我们。通过调用cmake --graphviz=my-project.dot /path/to/build/dir,CMake 将生成包含目标相互依赖关系的 DOT 语言文件。DOT 语言是一种描述图的语言,可以被多种程序解释,其中最著名的是免费的 Graphviz。DOT 文件可以转换为图像,甚至可以使用 Graphviz 中的dot命令行工具,如下所示:dot -Tpng filename.dot -o out.png

第三章所示,提供了更多的目标,让我们在该章节的build文件夹中运行此命令。这将生成类似于以下的输出:

图 6.8 – 使用 DOT 语言可视化的第三章项目结构

行为和选项可以通过CMakeGraphVizOptions中提供的变量进行控制。在创建 DOT 图时,CMake 会在PROJECT_SOURCE_DIRPROJECT_BINARY_DIR目录中查找名为CMakeGraphVizOptions.cmake的文件,如果找到,将使用其中提供的值。这样的配置文件示例如下:

set(GRAPHVIZ_GRAPH_NAME "CMake Best Practices")
set(GRAPHVIZ_GENERATE_PER_TARGET FALSE)
set(GRAPHVIZ_GENERATE_DEPENDERS FALSE)

默认情况下,CMake 会为所有目标创建依赖图。将GRAPHVIZ_GENERATE_PER_TARGETGRAPHVIZ_GENERATE_DEPENDERS设置为FALSE将减少生成的文件数量。所有可用选项的完整列表可以在 CMake 文档中的cmake.org/cmake/help/latest/module/CMakeGraphVizOptions.html找到。

总结

本章我们简要介绍了 Doxygen,并学习了如何从代码生成文档,以及如何打包生成的文档以便部署。在任何软件项目中,掌握这些技能都是至关重要的。从代码生成文档大大减少了技术文档的工作量,并几乎没有维护成本。作为一名软件专业人士,自动化确定性的任务,并生成可推导的信息以不同的表现形式呈现,是最理想的。这种方法为其他需要更多人工解决问题技能的工程任务创造了时间和空间。自动化任务减少了维护成本,使产品更加稳定,并减少了对人工资源的整体需求。它是一种通过让机器完成相同的工作,将纯粹的人力劳动转化为消耗电力的方式。机器在执行确定性任务方面比人类更优秀。它们永远不会生病,几乎不会损坏,易于扩展且永不疲倦。自动化是一种利用这种未经驯服的力量的方法。

本书的主要方向不是教你如何做事情,而是教你如何让机器为某个特定任务工作。这种方法确实需要先学习,但请记住,如果你在做一个可以由机器手动完成多次的昂贵操作,那就是在浪费你宝贵的时间。投资于自动化——它是一个快速回报的有利投资。

在下章中,我们将学习如何通过将单元测试、代码清理工具、静态代码分析、微基准测试和代码覆盖率工具集成到 CMake 项目中,来提高代码质量,当然,我们也会自动化这些任务。

下章见!

问题

完成本章后,你应该能够回答以下问题:

  1. 什么是 Doxygen?

  2. 将 Doxygen 集成到 CMake 项目中的最简单方法是什么?

  3. Doxygen 能绘制图表和图形吗?如果能,我们如何启用这个功能?

  4. 应该使用哪些 Doxygen 标签将 PlantUML 图表嵌入 Doxygen 文档中?

  5. 应该采取哪些配置步骤来启用 Doxygen 使用 PlantUML?

  6. 由于 build/ 文件夹下已有 man/page 输出,如何使这些文档可以安装?

答案

  1. Doxygen 是 C 和 C++ 项目中文档生成工具的事实标准。

  2. 由于 CMake 已经提供了一个 find 模块来查找 Doxygen,可以通过使用 find_package(...) CMake 命令来实现这一点。

  3. 是的——只要环境中有 dot、Graphviz 和 PlantUML 等绘图软件,Doxygen 就可以绘制图形。要启用 DOT 绘图,只需将 HAVE_DOT 设置为 TRUE。对于 PlantUML,则需要将 PLANTUML_JAR_PATH 设置为包含 plantuml.jar 文件的路径。

  4. @startuml@enduml

  5. PLANTUML_JAR_PATH 需要设置为包含 plantuml.jar 文件的路径。

  6. 通过install(DIRECTORY)命令的帮助。

第七章:与 CMake 无缝集成代码质量工具

到目前为止,我们集中于构建和安装项目,生成文档以及处理外部依赖项。编写高质量软件的另一个重要任务是测试,并通过其他手段确保代码质量达到预期水平。为了实现高代码质量,仅仅编写单元测试并偶尔执行是不够的。如果你想开发高质量的软件,拥有与构建系统轻松集成的合适测试工具不仅不是奢侈,而是必须的。只有构建和测试能够无缝配合,程序员才能专注于编写良好的测试,而不是花时间确保这些测试能够运行。像测试驱动开发这样的方式为软件质量带来了巨大价值。

然而,提高质量的不仅仅是编写普通的测试。编写良好的测试是一回事;通过覆盖率报告检查测试的有效性,并通过静态代码分析确保整体代码质量是另一回事。

虽然测试、覆盖率和静态代码分析有助于确定代码是否按预期运行,但一个常见的问题是,一些工具仅与特定编译器兼容,或需要特殊的编译器设置。为了利用这些工具,可能需要使用不同的编译器以不同的方式编译相同的源代码。幸运的是,CMake 正是擅长这一点,这也是为什么 CMake 能够帮助提升代码质量,使得这些质量工具易于访问的原因。

很多确保高代码质量的工具有一个好处,那就是它们通常可以自动化。随着如今 CI/CD 系统的普及,创建高程度的自动化检查以确保软件质量变得相当容易,尤其是使用 CMake 时,这些工具通常可以在定义软件构建方式的地方进行配置和执行。本章将教你如何使用 CMake 定义和协调测试,并创建代码覆盖率报告,以查看哪些部分的代码已被测试。我们还将探讨如何集成各种代码清理工具和静态代码分析器,在编译时就检查代码质量。我们会展示如何将所有这些工具集成在一起,以及如何创建一个专门的构建类型来运行静态代码质量工具。最后,我们还会看看如何设置微基准测试,检查代码的运行时性能。

本章涵盖以下主要内容:

  • 定义、发现和运行测试

  • 生成代码覆盖率报告

  • 清理代码

  • 使用 CMake 进行静态代码分析

  • 为质量工具创建自定义构建类型

技术要求

与之前的章节一样,示例在 CMake 3.24 下进行测试,并可以在以下任意编译器上运行:

  • GCC 9 或更高版本

  • Clang 12 或更高版本

  • MSVC 19 或更高版本

一些关于代码覆盖率、清理工具和静态代码分析的示例需要 GCC 或 Clang 才能运行,无法在 MSVC 上运行。要在 Windows 上运行 Clang,请查看 第九章创建可重现的构建 环境,该章节介绍了工具链文件的使用。一些示例需要安装 Catch2 单元测试套件才能编译。某些示例会从不同的在线位置拉取依赖项,因此也需要连接互联网。

除了一个有效的编译器外,以下软件也用于示例中:

  • GcovGcovrlcov 用于 Linux 示例中的代码覆盖率

  • Opencppcoverage 用于 Windows 示例中的代码覆盖率

  • Clang-tidyCppcheckCpplintinclude-what-you-use 用于静态代码分析器的示例

本书的所有示例和源代码都可以在 github.com/PacktPublishing/CMake-Best-Practices---2nd-Edition 的 GitHub 仓库中找到。

如果缺少某些软件,相关示例将从构建中排除。

定义、发现和运行测试

测试是任何注重软件质量的工程师的主食。各种语言中用于编写单元测试的框架数量庞大,尤其是在 C++ 中,CMake 包含了与大多数流行框架一起使用的模块。

在非常抽象的层面上,所有单元测试框架都执行以下操作:

  • 允许对测试用例进行定义和分组

  • 包含某种形式的断言来检查不同的测试条件

  • 发现并运行测试用例,可以是全部或其中的某些测试

  • 以多种格式(如纯文本、JSON 和 XML)生成测试结果

使用 CTest 工具,CMake 提供了一种内置的执行几乎任何测试的方法。任何已设置 enable_testing() 并通过 add_test() 添加了至少一个测试的 CMake 项目,都启用了测试支持。任何对 enable_testing() 的调用都会启用当前目录及其子目录中的测试发现,因此通常建议在顶层 CMakeLists.txt 文件中设置它,且在任何调用 add_subdirectory 之前进行设置。CMake 的 CTest 模块会自动设置 enable_testing,如果与 include(CTest) 一起使用,除非将 BUILD_TESTING 选项设置为 OFF

不建议构建和运行依赖于 BUILD_TESTING 选项的测试。一个常见的做法是将所有与测试相关的部分放入自己的子文件夹中,并且只有在 BUILD_TESTING 设置为 ON 时才包含该子文件夹。

CTest 模块通常应该仅在项目的顶级 CMakeLists.txt 文件中包含。自 CMake 版本 3.21 起,可以使用 PROJECT_IS_TOP_LEVEL 变量来测试当前的 CMakeLists.txt 文件是否为顶级文件。对于项目的顶级目录及通过 ExternalProject 添加的项目的顶级目录,该变量的值为 true。对于通过 add_subdirectoryFetchContent 添加的目录,值为 false。因此,CTest 应该像这样被包含:

project(CMakeBestPractice)
...
if(PROJECT_IS_TOP_LEVEL)
   include(CTest)
endif()

通常,项目应该依赖 BUILD_TESTING 标志来确定是否应该构建和包含测试。然而,特别是对于开源项目或具有复杂测试需求的项目,提供一个额外的 option() 来禁用仅特定项目的测试对于开发者来说是非常方便的。生成的 CMake 代码可能如下所示:

option(MYPROJECT_BUILD_TESTING "enable testing for MyProject" ${BUILD_TESTING})
...
If(MYPROJECT_BUILD_TESTING AND BUILD_TESTING)
   add_subdirectory(test)
endif()

单元测试本质上是小型程序,它们在内部运行一系列断言,如果任何断言失败,它们将返回一个非零的返回值。有许多框架和库可以帮助组织测试和编写断言,但从外部来看,检查断言并返回相应的值是核心功能。

测试可以通过 add_test 函数添加到任何 CMakeLists.txt 文件中:

add_test(NAME <name> COMMAND <command> [<arg>...]
         [CONFIGURATIONS <config>...]
         [WORKING_DIRECTORY <dir>]
         [COMMAND_EXPAND_LISTS])

COMMAND 可以是项目中定义的可执行目标的名称,也可以是任意可执行文件的完整路径。任何测试所需的参数也包括在内。使用目标名称是首选的方式,因为这样 CMake 会自动替换可执行文件的路径。CONFIGURATION 选项用于告诉 CMake 测试适用于哪些构建配置。对于大多数测试用例,这一点无关紧要,但对于微基准测试等,这可能非常有用。WORKING_DIRECTORY 应该是绝对路径。默认情况下,测试在 CMAKE_CURRENT_BINARY_DIR 中执行。COMMAND_EXPAND_LISTS 选项确保作为 COMMAND 选项一部分传递的任何列表都会被展开。

包含一个测试的简单项目可能如下所示:

cmake_minimum_required(VERSION 3.21)
project("simple_test" VERSION 1.0)
enable_testing()
add_executable(simple_test)
target_sources(simple_test PRIVATE src/main.cpp)
add_test(NAME example_test COMMAND simple_test)

在这个示例中,使用一个名为 simple_test 的可执行目标作为一个名为 example_test 的测试。

CTest 将消耗有关测试的信息并执行它们。测试通过单独运行 ctest 命令或作为 CMake 构建步骤的一部分执行的特殊目标来执行。无论哪种执行方式,都要求项目在执行之前已经构建。以下两条命令都将执行测试:

ctest --test-dir <build_dir>
cmake --build <build_dir> --target test

将 CTest 作为构建的目标来调用的优点是,CMake 会首先检查所有需要的目标是否已构建并且是最新版本,但直接运行 ctest 则提供了更多对执行测试的控制。

ctest 的输出可能类似于以下内容:

Test project /workspaces/ CMake-Best-Practices---2nd-Edition/build
    Start 1: example_test
1/3 Test #1: example_test .....................***Failed    0.00 sec
    Start 2: pass_fail_test
2/3 Test #2: pass_fail_test ...................   Passed    0.00 sec
    Start 3: timeout_test
3/3 Test #3: timeout_test .....................   Passed    0.50 sec
67% tests passed, 1 tests failed out of 3
Total Test time (real) =   0.51 sec
The following tests FAILED:
          1 - example_test (Failed)
Errors while running CTest
Output from these tests are in: /workspaces/CMake-Tips-and-Tricks
  /build/Testing/Temporary/LastTest.log
Use "--rerun-failed --output-on-failure" to re-run the failed cases
  verbosely.

通常,测试会抑制所有输出到 stdout。通过传递 -V--verbose 命令行参数,输出将始终打印出来。然而,通常情况下,你只对失败的测试的输出感兴趣。因此,--output-on-failure 参数通常是更好的选择。这样,只有失败的测试会产生输出。对于非常详细的测试,可以使用 --test-output-size-passed <size>--test-output-size-failed <size> 选项来限制输出的大小,其中 size 为字节数。

在构建树中有一个或多个对 add_test 的调用,会导致 CMake 在 CMAKE_CURRENT_BINARY_DIR 中为 CTest 写出一个输入文件。CTest 的输入文件不一定位于项目的顶层,而是位于定义它们的位置。要列出所有测试但不执行它们,可以使用 CTest 的 -N 选项。

CTest 的一个非常有用的功能是,它在每次运行之间缓存测试的状态。这使得你只需运行上次运行中失败的测试。要做到这一点,可以运行 ctest --rerun-failed。如果没有测试失败或之前没有运行过任何测试,所有测试都会被执行。

有时,你可能不想执行完整的测试集——例如,如果需要修复某个单一失败的测试。-E-R 命令行选项分别表示 -E 选项排除匹配模式的测试,而 -R 选项选择需要包含的测试。这些选项可以组合使用。以下命令将执行所有以 FeatureX 开头的测试,但排除名为 FeatureX_Test_1 的测试:

ctest -R ^FeatureX -E FeatureX_Test_1

另一种有选择性地执行测试的方法是使用 LABELS 属性为测试标记标签,然后使用 CTest 的 -L 选项选择要执行的标签。一个测试可以分配多个标签,标签之间用分号分隔,如以下示例所示:

add_test(NAME labeled_test_1 COMMAND someTest)
set_tests_properties(labeled_test PROPERTIES LABELS "example")
add_test(NAME labeled_test_2 COMMAND anotherTest)
set_tests_properties(labeled_test_2 PROPERTIES LABELS "will_fail" )
add_test(NAME labeled_test_3 COMMAND YetAnotherText)
set_tests_properties(labeled_test_3 PROPERTIES LABELS "example;will_fail")

-L 命令行选项接受一个正则表达式,用于过滤标签:

ctest -L example

这将只执行 labeled_test_1labeled_test_3,因为它们都被分配了 example 标签,但不会执行 labeled_test_2 或任何其他没有分配标签的测试。

通过相应地制定正则表达式,可以将多个标签组合在一起:

ctest -L "example|will_fail"

这将执行所有来自示例的测试,但不会执行其他没有分配标签的测试。

使用标签特别有助于标记设计上会失败的测试或类似的测试,或者仅在某些执行上下文中相关的测试。

正则表达式或标签基础的测试选择的最后一个替代方法是使用 -I 选项,它接受分配的测试编号。-I 选项的参数有些复杂:

ctest -I [Start,End,Stride,test#,test#,...|Test file]

使用 StartEndStride,可以指定要执行的测试范围。三个数字表示与显式测试编号 test# 结合的范围。或者,也可以传递包含参数的文件。

以下调用将执行从 110 的所有奇数测试:

ctest -I 1,10,2

因此,测试 13579 将被执行。以下命令将只执行测试 8

ctest -I ,0,,6,7

请注意,在此调用中,End被设置为0,因此没有执行测试范围。要结合范围和显式的测试编号,以下命令将执行从110的所有奇数测试,并另外测试68

ctest -I 1,10,2,6,8

处理-I选项的繁琐以及添加新测试可能会重新分配编号,是这种方法在实践中很少使用的两个原因。通常,更倾向于通过标签或测试名称进行过滤。

编写测试时的另一个常见陷阱是测试不够独立。因此,测试2可能意外地依赖于测试1的先前执行。为了防止这种意外依赖,CTest 可以通过--schedule-random命令行参数随机化测试执行顺序。这将确保测试以任意顺序执行。

自动发现测试

使用add_test定义测试是将测试暴露给 CTest 的一种方式。一个缺点是,这将把整个可执行文件注册为一个单独的测试。然而,在大多数情况下,一个可执行文件将包含许多单元测试,而不仅仅是一个,因此当其中一个测试失败时,可能很难确定究竟是哪个测试失败了。

考虑一个包含以下测试代码的 C++文件,假设 Fibonacci 函数包含一个 bug,因此Fibonacci(0)不会返回1,而是返回其他值:

TEST_CASE("Fibonacci(0) returns 1"){ REQUIRE(Fibonacci(0) == 1);}
TEST_CASE("Fibonacci(1) returns 1"){ REQUIRE(Fibonacci(1) == 1); }
TEST_CASE("Fibonacci(2) returns 2"){ REQUIRE(Fibonacci(2) == 2); }
TEST_CASE("Fibonacci(5) returns 8"){ REQUIRE(Fibonacci(5) == 8); }

如果将所有这些测试编译到同一个可执行文件中,名为Fibonacci,那么通过add_test将它们添加进去时,只会显示可执行文件失败,但不会告诉我们它在前面代码块中的哪个场景下失败了。

测试的结果将类似于以下内容:

Test project /workspaces/CMake-Tips-and-Tricks/build
    Start 5: Fibonacci
1/1 Test #5: Fibonacci ........................***Failed    0.00 sec
0% tests passed, 1 tests failed out of 1
Total Test time (real) =   0.01 sec
The following tests FAILED:
          5 - Fibonacci (Failed)

这样做对确定哪个测试用例失败并没有太大帮助。幸运的是,使用 Catch2 和 GoogleTest 时,可以通过将内部测试暴露给 CTest 来使它们作为常规测试执行。对于 GoogleTest,CMake 本身提供了执行此操作的模块;Catch2 则在其自己的 CMake 集成中提供了这一功能。使用 Catch2 发现测试是通过catch_discover_tests,而对于 GoogleTest,则使用gtest_discover_tests。以下示例将把在 Catch2 框架中编写的测试暴露给 CTest:

find_package(Catch2)
include(Catch)
add_executable(Fibonacci)
catch_discover_tests(Fibonacci)

请注意,为了使该函数可用,必须包含Catch模块。对于 GoogleTest,它的工作方式非常相似:

include(GoogleTest)
add_executable(Fibonacci)
gtest_discover_tests(Fibonacci)

当使用发现功能时,在测试可执行文件中定义的每个测试用例将被 CTest 视为其自己的测试。如果像这样暴露测试,则调用 CTest 的结果可能如下所示:

    Start 5: Fibonacci(0) returns 1
1/4 Test #5: Fibonacci(0) returns 1 .........***Failed    0.00 sec
    Start 6: Fibonacci(1) returns 1
2/4 Test #6: Fibonacci(1) returns 1 .........   Passed    0.00 sec
    Start 7: Fibonacci(2) returns 2
3/4 Test #7: Fibonacci(2) returns 2 .........   Passed    0.00 sec
    Start 8: Fibonacci(5) returns 8
4/4 Test #8: Fibonacci(5) returns 8 .........   Passed    0.00 sec
75% tests passed, 1 tests failed out of 4
Total Test time (real) =   0.02 sec
The following tests FAILED:
          5 - Fibonacci(0) returns 1 (Failed)

现在,我们可以清楚地看到哪些已定义的测试用例失败了。在这种情况下,Fibonacci(0) returns 1 测试用例没有按预期行为工作。当使用具有集成测试功能的编辑器或 IDE 时,这尤其有用。发现功能通过运行指定的可执行文件来工作,可以选择仅打印测试名称并将其内部注册到 CTest,因此每个构建步骤会有一些额外的开销。更细粒度地发现测试也有一个优点,即其执行可以更好地由 CMake 并行化,如本章的并行运行测试和管理测试资源部分所述。

gtest_discover_testscatch_discover_tests 都有多种选项,例如为测试名称添加前缀或后缀,或将属性列表添加到生成的测试中。有关这些函数的完整文档可以在这里找到:

Catch2 和 GoogleTest 只是众多测试框架中的两个;可能还有其他未广为人知的测试套件也具备相同功能。

现在,我们从寻找测试转向更深入地了解如何控制测试行为。

确定测试成功或失败的高级方法

默认情况下,CTest 会根据命令的返回值来判断测试是否失败或通过。0 表示所有测试成功;任何非 0 的返回值都被视为失败。

有时,仅凭返回值不足以判断测试是否通过。如果需要检查程序输出中是否包含某个字符串,可以使用 FAIL_REGULAR_EXPRESSIONPASS_REGULAR_EXPRESSION 测试属性,如下例所示:

set_tests_properties(some_test PROPERTIES
                   FAIL_REGULAR_EXPRESSION "[W|w]arning|[E|e]rror"
                   PASS_REGULAR_EXPRESSION "[S|s]uccess")

这些属性会导致 some_test 测试失败,如果输出中包含 "Warning""Error"。如果发现 "Success" 字符串,则认为测试通过。如果设置了 PASS_REGULAR_EXPRESSION,则仅当字符串存在时,测试才会被认为通过。在这两种情况下,返回值将被忽略。如果需要忽略某个测试的特定返回值,可以使用 SKIP_RETURN_CODE 选项。

有时,测试预期会失败。在这种情况下,将 WILL_FAIL 设置为 true 会导致测试结果反转:

add_test(NAME SomeFailingTerst COMMAND SomeFailingTest)
set_tests_properties(SomeFailingTest PROPERTIES WILL_FAIL True)

这通常比禁用测试更好,因为它仍然会在每次测试运行时执行,如果测试意外开始通过,开发人员会立即知道。一个特殊的测试失败情况是测试未返回或完成需要太长时间。对于这种情况,CTest 提供了添加测试超时的功能,甚至可以在失败时重试测试。

处理超时和重试测试

有时,我们不仅仅关注测试的成功或失败,还关注测试完成所需的时间。TIMEOUT测试属性使用一个秒数来确定测试的最大运行时间。如果测试超出了设定的时间,它将被终止并视为失败。以下命令将测试的执行时间限制为 10 秒:

set_tests_properties(timeout_test PROPERTIES TIMEOUT 10)

TIMEOUT属性通常对于那些有可能因某些原因进入无限循环或永远挂起的测试非常有用。

另外,CTest 还接受--timeout参数,设置一个全局超时,这个超时适用于所有没有指定TIMEOUT属性的测试。对于那些已定义TIMEOUT的测试,CmakeLists.txt中定义的超时会优先于命令行传递的超时设置。

为了避免长时间的测试执行,CTest 命令行接受--stop-time参数,该参数以当天的实时时间作为完整测试集的时间限制。以下命令会为每个测试设置一个默认的超时为 30 秒,且所有测试必须在 23:59 之前完成:

ctest --timeout 30 --stop-time 23:59

有时,我们可能会遇到由于一些不可控因素而导致的测试超时。常见的情况是需要进行某种网络通信或依赖某种带宽有限资源的测试。有时,唯一能让测试继续运行的方法是重新尝试。为此,可以将--repeat after-timeout:n命令行参数传递给 CTest,其中n是一个数字。

--repeat参数实际上有三个选项:

  • after-timeout:如果发生超时,这会重新尝试测试若干次。通常,若发生重复超时,应将--timeout选项传递给 CTest。

  • until-pass:这个选项会一直重新运行测试,直到通过或者达到重试次数为止。在 CI 环境中,作为一般规则设置此选项是不推荐的,因为测试通常应该总是通过的。

  • until-fail:测试会重新运行若干次,直到失败为止。这通常用于测试偶尔失败的情况,目的是找出这种失败发生的频率。--repeat-until-fail参数与--repeat:until-fail:n的作用完全相同。

如前所述,测试失败的原因可能是测试依赖的资源不可用。外部资源不可用的常见情况是它们被测试请求淹没。并行运行测试和管理测试资源部分探讨了避免此类问题的一些方法。另一种常见的超时原因是,当测试运行时,外部资源尚未可用。

在下一部分中,我们将看到如何编写测试夹具,确保在测试运行之前资源已经启动。

编写测试夹具

测试通常应该彼此独立。也有一些情况,测试可能依赖于一个前提条件,而这个前提条件并不由测试本身控制。例如,某个测试可能要求一个服务器正在运行,才能测试客户端。这些依赖关系可以通过在 CMake 中定义测试固定装置(test fixtures)来表达,使用FIXTURE_SETUPFIXTURE_CLEANUPFIXTURE_REQUIRED测试属性。所有三个属性都接受一个字符串列表来标识一个固定装置。一个测试可以通过定义FIXTURE_REQUIRED属性来表明它需要某个特定的固定装置。这将确保在执行该测试之前,名为fixture的测试已经成功完成。同样,一个测试可以在FIXTURE_CLEANUP中声明,表示它必须在依赖该固定装置的测试完成后执行。清理部分中定义的固定装置无论测试成功与否,都会被执行。考虑以下示例,它可以在代码库的chapter07/fixture_example目录中找到:

 add_test(NAME start_server COMMAND ch7_fixture_server)
set_tests_properties(start_server PROPERTIES FIXTURES_SETUP ch7_server)
add_test(NAME stop_server COMMAND ch7_fixture_server --stop)
set_tests_properties(stop_server PROPERTIES FIXTURES_CLEANUP ch7_server)
add_test(NAME ch7_fixture_test COMMAND ch7_fixture_sample)
set_tests_properties(ch7_fixture_test PROPERTIES FIXTURES_REQUIRED ch7_server)

在这个例子中,名为echo_server的程序作为固定装置使用,以便另一个名为echo_client的程序可以使用它。echo_server的执行通过--start--stop参数被制定为两个测试,分别命名为start_serverstop_serverstart_server测试被标记为固定装置的设置部分,命名为serverstop_server测试同样被设置,但标记为固定装置的清理部分。最后,实际的测试client_test被设置,并作为必要的前提条件传递给server固定装置。

如果现在使用 CTest 运行client_test,固定装置会自动与测试一起调用。固定装置测试会作为常规测试出现在 CTest 的输出中,如下所示的示例输出所示:

ctest -R ch7_fixture_test
Test project CMake-Best-Practices:
    Start  9: start_server
1/3 Test  #9: start_server ..............   Passed    0.00 sec
    Start 11: client_test
2/3 Test #11: client_test................   Passed    0.00 sec
    Start 10: stop_server
3/3 Test #10: stop_server ...............   Passed    0.00 sec

请注意,CTest 是通过正则表达式过滤器调用的,仅匹配客户端测试,但 CTest 仍然启动了固定装置。为了避免在并行执行测试时过度加载测试固定装置,可以将它们定义为资源,如下一节所示。

并行运行测试并管理测试资源

如果一个项目有很多测试,并行执行它们会加速测试过程。默认情况下,CTest 按序列运行测试;通过向 CTest 调用传递-j选项,可以并行运行测试。或者,也可以在CTEST_PARALLEL_LEVEL环境变量中定义并行线程的数量。默认情况下,CTest 假设每个测试只会在单个 CPU 上运行。如果一个测试需要多个处理器才能成功运行,则可以为该测试设置PROCESSORS属性,以定义所需的处理器数量:

add_test(NAME concurrency_test COMMAND concurrency_tests)
set_tests_properties(concurrency_test PROPERTIES PROCESSORS 2)

这将告诉 CTest,concurrency_test 测试需要两个 CPU 才能运行。当使用 -j 8 并行运行测试时,concurrency_test 将占用八个并行执行“插槽”中的两个。如果此时,PROCESSORS 属性被设置为 8,则意味着没有其他测试可以与 concurrency_test 并行运行。当为 PROCESSORS 设置一个大于系统上可用的并行插槽或 CPU 数量的值时,测试将在整个池可用时运行。有时候,一些测试不仅需要特定数量的处理器,还需要独占运行,而不与任何其他测试一起运行。为了实现这一点,可以为测试设置 RUN_SERIAL 属性为 true。这可能会严重影响整体测试性能,因此使用时需要谨慎。一个更细粒度的控制方式是使用 RESOURCE_LOCK 属性,它包含一个字符串列表。这些字符串没有特别的含义,除了 CTest 会阻止两个测试并行运行,如果它们列出了相同的字符串。通过这种方式,可以实现部分序列化,而不会中止整个测试执行。这也是指定测试是否需要某个特定唯一资源(如某个文件、数据库等)的好方法。考虑以下示例:

set_tests_properties(database_test_1 database_test_2 database_test_3
  PROPERTIES RESOURCE_LOCK database)
set_tests_properties(some_other_test PROPERTIES RESOURCE_LOCK fileX)
set_tests_properties(yet_another_test PROPERTIES RESOURCE_LOCK
  "database;fileX ")

在这个例子中,database_test_1database_test_2database_test_3 测试被阻止并行运行。some_other_test 测试不受数据库测试的影响,但 yet_another_test 将不会与任何数据库测试和 some_other_test 一起运行。

作为资源的夹具

虽然技术上不是必需的,但如果 RESOURCE_LOCKFIXTURE_SETUPFIXTURE_CLEANUPFIXTURE_REQUIRED 一起使用,最好为相同的资源使用相同的标识符。

使用 RESOURCE_LOCK 管理测试的并行性在测试需要独占访问某些资源时非常方便。在大多数情况下,这完全足够管理并行性。从 CMake 3.16 开始,这可以通过 RESOURCE_GROUPS 属性在更细粒度的级别上进行控制。资源组不仅允许你指定哪些资源被使用,还允许你指定使用多少资源。常见的场景是定义特定贪婪操作可能需要的内存量,或者避免超过某个服务的连接限制。资源组通常在使用 GPU 进行通用计算的项目中出现,定义每个测试需要多少 GPU 插槽。

与简单的资源锁相比,资源组在复杂性上迈出了很大一步。要使用它们,CTest 必须执行以下操作:

  • 了解一个测试需要哪些资源才能运行:这通过在项目中设置测试属性来定义

  • 了解系统中可用的资源:这是在运行测试时在项目之外完成的

  • 传递关于测试使用哪些资源的信息:这是通过使用环境变量来完成的。

类似于资源锁,资源组是用来标识资源的任意字符串。实际与标签绑定的资源定义由用户来指定。资源组定义为 name:value 对,如果有多个组,则通过逗号分隔。测试可以通过 RESOURCE_GROUPS 属性定义要使用的资源,如下所示:

set_property(TEST SomeTest PROPERTY RESOURCE_GROUPS
    cpus:2,mem_mb:500
    servers:1,clients:1
    servers:1,clients:2
    4,servers:1,clients:1
 )

在前面的示例中,SomeTest 表示它使用了两个 CPU 和 500 MB 的内存。它总共使用了六个客户端-服务器对实例,每对包含多个服务器和客户端实例。第一对由一个服务器实例和一个客户端实例组成;第二对需要一个服务器,但有两个客户端实例。

最后一行,4, servers:1,clients:1,是用来告诉 CTest 使用四个相同的实例对,由一个 servers 资源和一个 clients 资源组成。这意味着这个测试不会运行,除非总共可以提供六个服务器和七个客户端,以及所需的 CPU 和内存。

可用的系统资源在一个 JSON 文件中指定,该文件传递给 CTest,方法是通过 ctest --resource-spec-file 命令行参数,或在调用 CMake 时设置 CTEST_RESOURCE_SPEC_FILE 变量。设置变量应该通过使用 cmake -D 来完成,而不是在 CMakeLists.txt 中进行,因为指定系统资源应该在项目外部完成。

前面示例的一个资源规格文件可能如下所示:

{
    "version": {
        "major": 1,
        "minor": 0
    },
    "local": [
        {
            "mem_mb": [
                {
                    "id": "memory_pool_0",
                    "slots": 4096
                }
            ],
            "cpus" :
            [
                {
                    "id": "cpu_0",
                    "slots": 8
                }
            ],
            "servers": [
                {
                    "id": "0",
                    "slots": 4
                },
                {
                    "id": "1",
                    "slots": 4
                }
            ],
            "clients": [
                {
                    "id": "0",
                    "slots": 8
                },
                {
                    "id": "1",
                    "slots": 8
                }
            ]
        }
    ]
}

该文件指定了一个具有 4096 MB 内存、8 个 CPU、2x4 服务器实例和 2x8 客户端实例的系统,总共 8 个服务器和 16 个客户端。如果无法用可用的系统资源满足测试的资源请求,则测试无法运行,并抛出类似如下的错误:

ctest -j $(nproc) --resource-spec-file ../resources.json
Test project /workspaces/CMake-Tips-and-Tricks/chapter_7
  /resource_group_example/build
    Start 2: resource_test_2
                  Start 3: resource_test_3
Insufficient resources for test resource_test_3:
  Test requested resources of type 'mem_mb' in the following
    amounts:
    8096 slots
  but only the following units were available:
    'memory_pool_0': 4096 slots
Resource spec file:
  ../resources.json

当前的示例可以在这个规格下运行,因为它需要总共六个服务器和七个客户端。CTest 无法确保指定的资源是否真正可用;这项任务由用户或 CI 系统负责。例如,一个资源文件可能指定有八个 CPU 可用,但硬件实际上只有四个核心。

关于分配的资源组的信息通过环境变量传递给测试。CTEST_RESOURCE_GROUP_COUNT 环境变量指定分配给测试的资源组的总数。如果未设置该变量,则表示 CTest 是在没有环境文件的情况下调用的。测试应该检查这一点并相应地采取行动。如果一个测试在没有资源的情况下无法运行,它应该失败,或者通过返回在 SKIP_RETURN_CODESKIP_REGULAR_EXPRESSION 属性中定义的相应返回代码或字符串来表明它未运行。分配给测试的资源组通过一对环境变量传递:

  • CTEST_RESOURCE_GROUP_<ID>,它将包含资源组的类型。在之前的示例中,这将是 "mem_mb""cpus""clients""servers"

  • CTEST_RESOURCE_GROUP_<ID>_<TYPE>,它将包含一个 id:slots 的类型对。

如何使用资源组以及如何在内部分配资源,取决于测试的实现。到目前为止,我们已经看到了如何使用 CTest 执行测试,但定义和发现测试同样重要。

编写和运行测试显然是提升代码质量的主要推动力之一。然而,另一个有趣的指标往往是你的代码实际被多少测试覆盖。调查和报告代码覆盖率可以提供有趣的线索,不仅可以了解软件的测试广度,还能发现代码中的空白区域。

生成代码覆盖率报告

了解你的代码被测试覆盖的程度是一个很大的好处,通常可以给人留下软件测试充分的良好印象。它还可以为开发者提供有关未被测试覆盖的执行路径和边缘情况的提示。

获取 C++ 代码覆盖率

有一些工具可以帮助你获取 C++ 代码覆盖率。可以说,最流行的工具是来自 GNU 的Gcov。它已经存在多年,并且与 GCC 和 Clang 配合得很好。尽管它与微软的 Visual Studio 不兼容,但使用 Clang 来构建和运行软件为 Windows 提供了可行的替代方案。或者,OpenCppCoverage 工具可以在 Windows 上获取 MSVC 构建的覆盖率数据。

Gcov 生成的覆盖率信息可以通过 Gcovr 或 LCOV 工具汇总为报告。

使用 Clang 或 GCC 生成覆盖率报告

在这一部分,我们将看看如何使用 Gcovr 创建代码覆盖率报告。生成这些报告的大致过程如下:

  1. 被测试的程序和库需要使用特殊的标志进行编译,以便它们暴露出覆盖率信息。

  2. 程序运行后,覆盖率信息会存储在一个文件中。

  3. 覆盖率分析工具,如 Gcovr 或 LCOV,会分析覆盖率文件并生成报告。

  4. 可选地,报告可以存储或进一步分析,以显示覆盖率的趋势。

代码覆盖率的常见设置是,你想要了解项目代码中有多少部分被单元测试覆盖。为了做到这一点,代码必须使用必要的标志进行编译,以便信息能够被暴露出来。

<LANG>_COMPILER_FLAGS 缓存变量应通过命令行传递给 CMake。使用 GCC 或 Clang 时,这可能看起来像这样:

cmake -S <sourceDir> -B <BuildDir> -DCMAKE_CXX_FLAGS=--coverage

另一种方法是定义各自的预设,如在第九章中所述,创建可复现的构建 环境。在构建代码覆盖率时,通常一个好的做法是启用调试信息并使用-Og标志禁用任何优化。此外,指定-fkeep-inline-functions-fkeep-static-consts编译器标志将防止在函数未被使用的情况下优化掉静态和内联函数。这将确保所有可能的执行分支都被编译到代码中;否则,覆盖率报告可能会产生误导,特别是对于内联函数。

覆盖率报告不仅适用于单个可执行文件,还适用于库。但是,库必须在启用覆盖率标志的情况下编译。

由于覆盖率的编译器标志是全局设置的,这些选项将传递给通过FetchContentExternalProject添加的项目,这可能会显著增加编译时间。

使用启用覆盖率标志的 GCC 或 Clang 编译源代码将会在每个目标文件和可执行文件的构建目录中创建.gcno文件。这些文件包含关于各自编译单元中可用的调用和执行路径的 Gcov 元信息。为了找出哪些路径被使用,必须运行程序。

查看 Gcov 和 GCC 的版本

提取代码覆盖率信息时常见的失败和挫折原因是 GCC 和 Gcov 的版本不匹配。请始终通过g++ --versiongcov --version检查它们是否相同。

在我们想要了解测试代码覆盖率的场景中,运行 CTest 将生成覆盖率结果。或者,直接运行可执行文件也会产生相同的结果。启用覆盖率的可执行文件将会在构建目录中生成.gcda文件,其中包含关于各自目标文件中调用的信息。

一旦这些文件生成,运行 Gcovr 将会生成有关覆盖率的信息。默认情况下,Gcovr 将信息输出到stdout,但它也可以生成 HTML 页面、JSON 文件或 SonarQube 报告。

一个常见的陷阱是,Gcovr 期望所有源代码和目标文件都位于同一目录中,但这在 CMake 中并非如此。因此,我们必须通过-r选项将相应的目录传递给 Gcov,如下所示:

gcovr -r <SOURCE_DIR> <BINARY_DIR> -html

这样的调用可能会生成一个看起来像这样的 HTML 文件:

图 7.1 – 覆盖率运行示例输出

图 7.1 – 覆盖率运行示例输出

Gcovr 的另一个替代方案是 LCOV,它的工作方式非常相似。与 Gcovr 不同,LCOV 不能直接生成 HTML 或 XML 输出,而是将任何覆盖率信息组装成一个中间格式,然后可以通过各种转换器进行处理。为了生成 HTML 输出,通常使用genhtml工具。使用 LCOV 生成报告的命令可能如下所示:

lcov -c -d <BINARY_DIR> -o <OUTPUT_FILE>
genhtml -o <HTML_OUTPUT_PATH> <LCOV_OUTPUT>

使用 LCOV 生成的覆盖率报告可能如下所示:

图 7.2 – 使用 LCOV 生成的示例覆盖率报告

图 7.2 – 使用 LCOV 生成的示例覆盖率报告

请注意,这些调用只会为最后一次运行创建覆盖率报告。如果你想将它们汇总成一个时间序列,以查看代码覆盖率是增加还是减少,有许多 CI 工具可供使用,如 Codecov 和 Cobertura,来完成这项工作。这些工具通常可以解析 Gcovr 或 LCOV 的输出,并将其汇总为精美的图形,展示覆盖率的趋势。有关 Gcovr 的详细文档可以在gcovr.com/en/stable/找到。

为 MSVC 创建覆盖率报告

在使用 MSVC 构建软件时,OpenCppCoverage工具是 Gcov 的替代方案。它通过分析 MSVC 编译器生成的程序数据库(.pdb),而不是通过使用不同的标志重新编译源代码来工作。生成单个可执行文件的 HTML 覆盖率报告的命令可能如下所示:

OpenCppCoverage.exe --export_type html:coverage.html --
  MyProgram.exe arg1 arg2

由于这将仅生成单个可执行文件的覆盖率报告,OpenCppCoverage允许你读取先前回合的输入,并将其合并成如下报告:

OpenCppCoverage.exe --export_type binary:program1.cov --
  program1.exe
OpenCppCoverage.exe --export_type binary:program2.cov --
  program2.exe
OpenCppCoverage.exe --input_coverage=program1.cov --input_coverage=
  program2.cov --export_type html:coverage.html

这将把前两次运行的输入合并成一个公共报告。为了处理覆盖率信息,export_type选项必须设置为binary

覆盖率报告的一个常见用途是找出项目中由测试定义所覆盖的代码量。在这种情况下,使用 CTest 作为测试驱动程序是很方便的。由于 CTest 将实际测试作为子进程运行,因此必须将--cover_children选项传递给OpenCppCoverage。为了避免生成系统库的覆盖率报告,可能需要添加模块和源过滤器。命令可能如下所示:

OpenCppCoverage.exe  --cover_children --modules <build_dir> --
  sources <source_dir> -- ctest.exe --build-config Debug

这种方法的一个小缺点是,覆盖率报告会包括 CTest 本身的覆盖率报告。生成的 HTML 报告可能如下所示:

图 7.3 – 使用 OpenCppCoverage 生成的覆盖率报告

图 7.3 – 使用 OpenCppCoverage 生成的覆盖率报告

如果你使用 Visual Studio,命令行的替代方案是使用插件。插件可以在 Visual Studio 市场中找到:marketplace.visualstudio.com/items?itemName=OpenCppCoverage.OpenCppCoveragePlugin

要查看完整的文档,请参考 OpenCppCoverage 的 GitHub 页面:github.com/OpenCppCoverage/OpenCppCoverage

知道代码测试覆盖了多少内容是了解代码质量的非常有价值的信息。事实上,在许多受监管的行业中,比如医疗、航空和汽车行业,监管机构可能要求提供代码覆盖率报告。然而,仅仅知道代码执行了多少是不够的;底层代码的质量更为重要。一些编译器提供了有用的工具,通过所谓的 sanitizer 来检测代码中的常见错误。在下一节中,您将学习如何使用 CMake 来应用这些 sanitizer。

清理代码

当今的编译器不仅仅是将文本转换为二进制代码的程序。它们是复杂的软件套件,内置了确保代码质量的功能。编译器对代码质量问题的关注程度急剧增加,尤其是随着 LLVM 和 Clang 的出现。这些质量工具通常被称为 sanitizers,并通过向编译器和链接器传递特定标志来启用。

代码 sanitizer 是通过使用编译器为二进制代码添加注释和钩子,从而带来额外的质量检查,能够检测各种运行时问题。代码执行时,系统会检查这些注释,若发现任何违规行为,会进行报告。Sanitizer 相对较快,但显然会对程序的运行时行为产生影响。如果 sanitizer 检测到任何问题,程序会调用 abort() 终止,并返回非零值。这在测试中尤为有用,因为这意味着任何违反 sanitizer 的测试都会被标记为失败。

以下是最常见的几种 sanitizer 类型:

  • 地址 sanitizerASan)用于检测内存访问错误,如越界访问和使用后释放错误。在某些平台上,ASan 甚至可以使用硬件辅助来运行。

  • 泄漏 sanitizerLSan)是 ASan 的一部分,可用于检测内存泄漏。

  • 在 GCC 和 Clang 中,存在一些 ASan 的专用版本,比如 内核地址 sanitizerKASAN),用于检测 Linux 内核中的内存错误。

  • 内存 sanitizerMSan)用于检测未初始化的内存读取。

  • 线程 sanitizerTSan)会报告数据竞争问题。由于 TSan 的工作方式,它不能与 ASan 和 LSan 一起运行。

  • 未定义行为 sanitizerUBSan)用于检测并报告代码导致未定义行为的情况。使用未初始化的变量或操作符优先级的歧义是常见的例子。

Clang 套件在 sanitizers 的可用性方面处于领先地位,其次是 GCC。微软在采用这些特性上稍慢,但从 MSVC 版本 16.9(随 Visual Studio 19 一起发布)开始,微软的编译器至少支持 ASan。有关各个 sanitizer 的详细功能以及如何详细配置它们,请参考各编译器的有用文档。

通过传递各种编译器标志启用 sanitizers,这些标志会使编译器将额外的调试信息添加到二进制文件中。当执行二进制文件时,sanitizer 代码会执行检查,并将任何错误打印到stderr。由于代码需要执行才能让 sanitizers 找到潜在的 bug,因此,高代码覆盖率对于提高 sanitizers 的可靠性至关重要。

要在 GCC 或 Clang 中启用 ASan,必须传递-fsanitize=<sanitizer>编译器标志。对于 MSVC,相应的选项是/fsanitize=<sanitizer>

编译器标志通过CMAKE_CXX_FLAGS缓存变量传递给 CMake。因此,从命令行调用启用 sanitizers 的 CMake 命令应该如下所示:

cmake -S <sourceDir> -B <BuildDir> -DCMAKE_CXX_FLAGS=-fsanitize=
  <sanitizer>

在使用 CMake 预设时,也可以在其中定义包含编译器标志的缓存变量。预设在第九章《创建可重现的构建环境》中有详细介绍。全局设置sanitizer选项也会影响在标志设置后使用FetchContentExternalProject的任何包含项目,因此在这里请小心操作。对于 ASan,使用在 GCC 和 Clang 中的-fsanitizer=address,在 MSVC 中使用/fsanitizer=address。MSan 通过-fsanitize=memory启用,LSan 通过-fsanitize=leak启用,TSan 通过-fsanitize=thread启用,UBSan 在撰写本文时仅在 GCC 和 Clang 中通过-fsanitize=undefined启用。为了获得 ASan、LSan 和 MSan 更简洁的输出,可以告诉编译器显式保留帧指针。通过在 GCC 和 Clang 中设置-fno-omit-framepointer来实现。MSVC 仅在 x86 构建中通过/Oy-选项支持此功能。

注意

不推荐在CMakeLists.txt中设置CMAKE_CXX_FLAGS变量来启用 sanitizers,因为 sanitizers 既不构建也没有任何使用要求来使用项目定义的目标。此外,在CMakeLists.txt中设置CMAKE_CXX_FLAGS变量可能与用户从命令行传递的标志发生冲突。

Sanitizers 是提高代码质量的非常强大的工具。结合单元测试和覆盖率报告,它们提供了确保代码质量的四个主要概念中的三个。第四种自动确保代码质量的方式是使用静态代码分析器。

使用 CMake 进行静态代码分析

单元测试、清理器和覆盖率报告都依赖于实际运行代码来检测可能的错误。静态代码分析则是在不运行代码的情况下进行分析。其优点是所有编译的代码都可以进行分析,而不仅仅是测试覆盖的部分。当然,这也意味着可以发现不同类型的故障。静态代码分析的一个缺点是,它可能需要很长时间才能运行完所有测试。

CMake 支持多种静态代码分析工具,这些工具通过设置属性或全局变量来启用。除link what you use外,所有工具都是外部程序,需要安装并在系统路径中找到。Link what you use 使用系统的链接器,因此不需要进一步安装。CMake 支持的工具如下:

  • LANG>_CLANG_TIDY 属性或 CMAKE_<LANG>_CLANG_TIDY 变量。

  • <LANG>_CPPCHECK 属性或 CMAKE_<LANG>_CPPCHECK 变量。

  • <LANG>_CPPLINT 属性或 CMAKE_<LANG>_CPPLINT 变量。Cpplint 最初由 Google 开发,因此它内置了 Google C++ 风格。

  • <LANG>_INCLUDE_WHAT_YOU_USE 属性或 CMAKE_<LANG>_INCLUDE_WHAT_YOU_USE 变量。

  • LINK_WHAT_YOU_USE 属性或 CMAKE_LINK_WHAT_YOU_USE 变量。请注意,这与所选择的语言无关。

对于所有工具,<LANG>CCXX。这些属性包含一个以分号分隔的列表,其中包含各自的可执行文件和命令行参数。从 CMake 3.21 开始,静态代码分析器的自动执行仅支持 Ninja 和 Makefile 生成器。Visual Studio 通过 IDE 设置处理静态代码分析器,而 CMake 无法控制这些设置。lwyu 是一个特殊的情况,因为它使用特定的标志来处理 lddld 链接器,并且不是一个特殊工具。因此,LINK_WHAT_YOU_USE 属性仅是一个布尔值,而不是一个命令行。这也意味着 lwyu 仅在 ELF 平台上受支持。

与本章前面的覆盖率报告和清理器类似,静态代码分析工具通过将命令传递到相应变量中来启用 CMake,可以通过命令行或使用预设来实现。如果设置了该变量,静态代码分析器将在编译源文件时自动执行。启用 clang-tidy 构建可能如下所示:

cmake -S <sourceDir> -B <buildDir>-DCMAKE_CXX_CLANG_TIDY="clang-
  tidy;-checks=*;-header-filter=<sourceDir>/*"

命令和参数以分号分隔的列表形式格式化。在上述示例中,通过 -checks=* 启用所有 clang-tidy 检查,并添加了一个过滤器,仅将 clang-tidy 应用于当前项目的 include 文件,使用 -header-filter=<sourceDir/*>

使用 Cppcheck、Cpplint 和 iwyu 时,相同的模式也适用,以下示例展示了这一点:

cmake -S <sourceDir> -B <buildDir> -DCMAKE_CXX_CPPCYHECK="cppcheck;-
  -enable=warning;--inconclusive;--force;--inline-support"
cmake -S <sourceDir> -B <buildDir> -DCMAKE_CXX_CPPLINT="cpplint"
cmake -S <sourceDir> -B <buildDir> -CMAKE_CXX_INCLUDE_WHAT_YOU_USE=
  "iwyu;-Xiwyu;any;-Xiwyu;iwyu;-Xiwyu;args;--verbose=5"

静态代码分析器将在编译项目中的文件时运行。任何发现的输出将与通常的编译器警告或错误一起打印出来。默认情况下,分析器的所有非关键问题不会导致构建失败。对于零容忍警告的高质量软件,可以传递适当的标志给 Cppcheck 和 Clang-Tidy:

  • 对于 Clang-Tidy,传递 --warnings-as-errors=* 将导致在发现任何问题时编译失败

  • 对于 Cppcheck,传递 --error-exitcode=1 参数将在发现问题时使 Cppcheck 以 1 而非 0 退出,导致构建失败

iwyucpplint 不幸的是,缺少类似的标志。

Clang-Tidy 的一个非常棒的功能是它可以自动对源文件应用修复。这可以通过向 Clang-Tidy 额外传递 --fix--fix-error 标志来实现。

构建增量时的注意事项

所有静态代码分析器仅在文件实际编译时工作。为了确保静态代码分析器捕捉到所有错误,必须在干净的构建上运行它们。

除 lwyu 外,所有静态代码分析器都会查看源文件以发现任何问题;相反,lwyu 会查看二进制文件以查找未使用的依赖项。

lwyu 分析器旨在帮助加速构建并减少依赖树的复杂性。lwyu 的命令在 CMAKE_LINK_WHAT_YOU_USE_CHECK 中定义。这个变量只是一个布尔选项,而不是像其他工具那样的外部命令。如果设置,它会将相应的标志传递给链接器,以输出任何未使用的直接依赖项。从 CMake 版本 3.21 开始,这被定义为 ldd –u -r 命令。使用 ldd 意味着此分析器仅适用于 ELF 平台。可以通过传递一个简单的选项来启用 lwyu,像这样:

cmake -S <sourceDir> -B <buildDir> -DCMAKE_LINK_WHAT_YOU_USE=TRUE

lwyu 的输出可能如下所示:

[100%] Linking CXX executable ch7_lwyu_example
Warning: Unused direct dependencies:
        /lib/x86_64-linux-gnu/libssl.so.1.1
        /lib/x86_64-linux-gnu/libcrypto.so.1.1

这个例子显示了 libssl.so 已经链接但没有被使用,甚至是那些通过任何依赖项间接链接的。

各种静态代码分析器与 iwyu 和 lwyu 的组合有助于保持代码库简洁并避免常见的代码异味。到目前为止,本章我们已经讨论了如何定义测试、使用清理工具和静态代码分析,它们主要用于检查代码是否正确工作。我们看到的一个问题是,如果必须为所有单独的目标启用这些组合,CMakeLists.txt 可能会变得杂乱无章,尤其是对于大型项目。一个干净的替代方案是提供一个自定义构建类型,全球启用编译时代码分析。

为质量工具创建自定义构建类型

到目前为止,我们讨论了 CMake 默认提供的构建类型,例如DebugReleaseRelWithDebInfoMinSizeRel。这些构建类型可以通过自定义构建类型进行扩展,从而将全局标志传递给所有目标。对于依赖特定编译器标志的代码质量工具,提供自定义构建类型可以显著简化CMakeLists.txt,特别是对于大型项目。创建自定义构建类型通常比直接修改全局的CMAKE_<LANG>_FLAGS更为推荐。

不要覆盖CMAKE_<LANG>_FLAGS

设置全局编译器选项时,应使用通用的CMAKE_<LANG>_FLAGS,并将其写入CMakeLists.txt文件中。这些标志应该在项目外部设置,可以通过命令行传递或通过工具链文件提供。如果在项目内部修改这些标志,容易与外部传递的设置发生冲突。

对于像 MSVC 或 Ninja Multi-Config 这样的多配置生成器,可用的构建类型会存储在CMAKE_CONFIGURATION_TYPES缓存变量中。对于像 Make 或 Ninja 这样的单配置生成器,当前的构建类型会存储在CMAKE_BUILD_TYPE变量中。自定义构建类型应在顶级项目中定义。

可以在CMakeLists.txt中添加一个名为Coverage的自定义构建类型,示例如下:

 if(isMultiConfig)
    if(NOT "Coverage" IN_LIST CMAKE_CONFIGURATION_TYPES)
        list(APPEND CMAKE_CONFIGURATION_TYPES Coverage)
    endif()
else()
    set(allowedBuildTypes Debug Release Coverage RelWithDebugInfo MinSizeRel)
    set_property(
        CACHE CMAKE_BUILD_TYPE
        PROPERTY STRINGS "${allowedBuildTypes}"
    )
    if(NOT CMAKE_BUILD_TYPE)
        set(CMAKE_BUILD_TYPE Debug CACHE STRING "" FORCE)
    elseif(NOT CMAKE_BUILD_TYPE IN_LIST allowedBuildTypes)
        message(FATAL_ERROR "Unknown build type: ${CMAKE_BUILD_TYPE}")
    endif()
endif()

让我们来分析一下前面示例中的操作:

  • 首先,需要确定当前的Generator是多配置生成器还是单配置生成器。这一信息会存储在GENERATOR_IS_MULTI_CONFIG全局属性中。由于该属性不能直接用于if语句,因此需要检索该属性并将其存储在IS_MULTI_CONFIG变量中。

  • 如果当前的生成器确实是一个多配置生成器,那么名为Coverage的自定义构建配置将被添加到CMAKE_CONFIGURATION_TYPES中,并在生成器中可用,但前提是它尚不存在。

  • 如果生成器是单配置生成器,则通过设置CMAKE_BUILD_TYPE缓存变量的STRINGS属性来添加Coverage构建类型的提示。这将在 CMake GUI 中创建一个下拉菜单,显示有效选项。为了方便起见,支持的构建类型存储在KNOWN_BUILD_TYPES变量中。

  • 由于当前构建类型通常是由外部为单配置生成器提供的,因此最好检查未知的构建类型,并在指定了未知的构建类型时中止配置。将消息打印为FATAL_ERROR将导致 CMake 停止构建。

通过这种方式,Coverage构建类型被添加到 CMake 中,但此时构建类型尚未配置为向构建中添加自定义编译器和链接器标志。要定义这些标志,使用了两组缓存变量:

  • CMAKE_<LANG>_FLAGS_<CONFIGURATION>

  • CMAKE_<TARGET_TYPE>_LINKER_FLAGS_<CONFIGURATION>

<CONFIGURATION> 是自定义构建类型的名称,<LANG> 是编程语言,<TARGET_TYPE> 的链接器标志可以是可执行文件或各种类型的库。基于现有构建类型来创建自定义构建配置非常有用,这样可以重用任何配置选项。以下示例设置了一个基于 Debug 构建类型标志的 Coverage 构建类型,适用于 Clang 或 GCC 兼容的编译器:

set(CMAKE_C_FLAGS_COVERAGE
    "${CMAKE_C_FLAGS_DEBUG} --coverage" CACHE STRING ""
)
set(CMAKE_CXX_FLAGS_COVERAGE
    "${CMAKE_CXX_FLAGS_DEBUG} --coverage" CACHE STRING ""
)
set(CMAKE_EXE_LINKER_FLAGS_COVERAGE
    "${CMAKE_EXE_LINKER_FLAGS_DEBUG} --coverage" CACHE STRING ""
)
set(CMAKE_SHARED_LINKER_FLAGS_COVERAGE
    "${CMAKE_SHARED_LINKER_FLAGS_DEBUG} --coverage"
     CACHE STRING ""
)

标志也可以包含生成器表达式,以便在设置标志时考虑不同的编译器。将标志标记为 advanced 将有助于防止用户意外更改这些变量:

mark_as_advanced(CMAKE_C_FLAGS_COVERAGE
                 CMAKE_CXX_FLAGS_COVERAGE
                 CMAKE_EXE_LINKER_FLAGS_COVERAGE
                 CMAKE_SHARED_LINKER_FLAGS_COVERAGE
                 CMAKE_STATIC_LINKGER_FLAGS_COVERAGE
                 CMAKE_MODULE_LINKER_FLAGS_COVERAGE
)

有时候,库的文件名应当反映它们是通过特殊构建类型创建的。为自定义构建类型设置 CMAKE_<CONFIGURATION>_POSTFIX 可以实现这一点。这在调试构建中已经是常见做法,这样文件在打包时可以与发布构建区分开来。与此相关的是 DEBUG_CONFIGURATIONS 全局属性,它包含被认为是非优化的配置,用于调试。如果自定义构建被认为是非发布构建,应该考虑如以下方式将其添加到该属性中:

set_property(GLOBAL APPEND PROPERTY DEBUG_CONFIGURATIONS Coverage)

DEBUG_CONFIGURATION 属性应该在顶层项目中设置,在任何 target_link_libraries 调用之前。DEBUG_CONFIGURATIONS 属性目前仅由 target_link_libraries 使用,出于历史原因,库可能会以 debug 前缀标记或优化,表明它们只应在相应的构建配置下链接。如今,这种做法很少使用,因为生成器表达式提供了更细粒度的控制。

本章到此结束。我们已经涵盖了测试和质量工具的最常见方面,并希望我们能为你在追求卓越软件质量的道路上做出贡献。

总结

维护高质量的软件是一个庞大而复杂的任务,今天,有如此多的工具和技术可供测试软件,以至于可能难以掌握。通过本章描述的技术和工具,我们希望简要概述了现代 C++ 开发中最常见的任务和工具。CTest 和 CMake 可以帮助协调各种测试类型,从而最大化工具的效能。在本章中,你已经看到如何定义和运行测试,如何并行执行测试,以及如何管理测试资源。我们还探讨了如何定义测试夹具,以及如何定义更高级的方法来根据测试输出确定测试是否成功或失败。

我们展示了如何使用 Gcov 设置代码覆盖率报告,以及如何定义自定义构建类型来传递必要的编译器标志。我们还介绍了如何将各种静态代码分析工具包含到 CMake 项目中,以及如何使用各种编译器的 sanitizer。

在下一章中,我们将学习如何在 CMake 中使用外部程序,以及如何执行平台无关的任务。

问题

  1. 在 CMake 中,测试是如何定义的?

  2. 如何告诉 CTest 执行特定的测试?

  3. 如何将一个不稳定的测试重复执行,直到它成功或失败?

  4. 如何以并行和随机顺序运行测试?

  5. 如何防止多个测试同时使用唯一的测试资源?

  6. 如何为目标启用静态代码分析工具?

  7. 如何定义自定义构建类型?

答案

  1. 测试是通过使用add_test函数定义的。

  2. 通过使用ctest -R对测试名称应用正则表达式,或者通过使用测试编号,使用ctest -I

  3. 通过调用ctest --repeat:until-pass:nctest --repeat:until-fail:n

  4. 通过运行ctest -j <num_of_jobs> --schedule-random

  5. 通过为相应的测试设置RESOURCE_LOCKRESOURCE_GROUP属性。

  6. 静态代码分析工具是通过将命令行(包括任何参数)传递给相应的目标属性来启用的。

  7. 通过将其添加到多配置类型生成器的CMAKE_CONFIGURATION_TYPES属性中,或通过将其添加到CMAKE_BUILD_TYPE属性中。

第八章:使用 CMake 执行自定义任务

构建和发布软件可能是一个复杂的任务,任何工具都无法完成构建和发布项目所需的所有不同任务。在某些时候,您可能需要执行一个编译器或 CMake 功能没有涵盖的任务。常见任务包括归档构建成果、创建哈希以验证下载,或生成或自定义构建的输入文件。还有许多其他依赖于特定软件构建环境的专门任务。

在本章中,我们将学习如何将自定义任务包含到 CMake 项目中,以及如何创建自定义构建目标和自定义命令。我们将讨论如何创建和管理目标之间的依赖关系,以及如何将它们包含或排除在标准构建之外。

在项目的构建步骤中包含这样的外部程序可以帮助确保代码的一致性,即使有很多人参与其中。由于 CMake 构建非常容易自动化,使用 CMake 调用必要的命令使得将这些工具应用到不同的机器或 CI 环境变得简单。

在本章中,我们将学习如何定义自定义任务,以及如何控制它们的执行时机。特别地,我们将专注于管理自定义任务和常规目标之间的依赖关系。由于 CMake 通常用于在多个平台上提供构建信息,您还将学习如何定义通用任务,以便它们能在任何运行 CMake 的地方执行。

本章将涵盖以下主要内容:

  • 在 CMake 中使用外部程序

  • 在构建时执行自定义任务

  • 在配置时执行自定义任务

  • 复制和修改文件

  • 使用 CMake 执行平台独立的命令

那么,让我们开始吧!

技术要求

与前几章一样,本章中的示例已在 CMake 3.21 上进行过测试,并且可以在以下编译器上运行:

  • GCC 9 或更高版本

  • Clang 12 或更高版本

  • MSVC 19 或更高版本

本章的所有示例和源代码可以在本书的 GitHub 仓库中找到,地址是 github.com/PacktPublishing/CMake-Best-Practices---2nd-Edition。如果缺少任何软件,相应的示例将从构建中排除。

在 CMake 中使用外部程序

CMake 功能非常广泛,因此它可以覆盖许多构建软件时的任务。然而,也有一些情况,开发者需要执行一些 CMake 功能没有涵盖的任务。常见的例子包括运行特殊工具,对目标的文件进行预处理或后处理,使用源代码生成器为编译器生成输入,以及压缩和归档不由 CPack 处理的构建成果。必须在构建步骤中完成的此类特殊任务的列表可能是几乎无尽的。CMake 支持三种执行自定义任务的方式:

  • 通过定义一个使用 add_custom_target 执行命令的目标

  • 通过使用add_custom_command将自定义命令附加到现有目标,或者通过使目标依赖于由自定义命令生成的文件

  • 通过使用execute_process函数,在配置步骤中执行命令

如果可能,应在构建步骤中调用外部程序,因为配置步骤用户控制性较低,应尽可能快速地运行。

让我们学习如何定义在构建时运行的任务。

在构建时执行自定义任务

添加自定义任务的最通用方法是通过创建一个自定义目标,该目标以命令序列执行外部任务。自定义目标像任何其他库或可执行目标一样处理,不同之处在于它们不调用编译器和链接器,而是执行用户定义的操作。自定义目标使用add_custom_target命令定义:

add_custom_target(Name [ALL] [command1 [args1...]]
                  [COMMAND command2 [args2...] ...]
                  [DEPENDS depend depend depend ... ]
                  [BYPRODUCTS [files...]]
                  [WORKING_DIRECTORY dir]
                  [COMMENT comment]
                  [JOB_POOL job_pool]
                  [VERBATIM] [USES_TERMINAL]
                  [COMMAND_EXPAND_LISTS]
                  [SOURCES src1 [src2...]])

add_custom_target命令的核心是通过COMMAND选项传递的命令列表。虽然第一个命令可以在没有此选项的情况下传递,但最好在任何add_custom_target调用中始终添加COMMAND选项。默认情况下,只有在明确请求时,自定义目标才会执行,除非指定了ALL选项。自定义目标始终被认为是过时的,因此指定的命令会始终运行,无论它们是否重复产生相同的结果。通过DEPENDS关键字,可以使自定义目标依赖于通过add_custom_command函数定义的自定义命令的文件和输出,或依赖于其他目标。若要使自定义目标依赖于另一个目标,请使用add_dependencies函数。反过来也适用——任何目标都可以依赖于自定义目标。如果自定义目标创建了文件,可以在BYPRODUCTS选项下列出这些文件。列在其中的任何文件都会标记为GENERATED属性,CMake 会用这个属性来判断构建是否过时,并找出需要清理的文件。然而,使用add_custom_command创建文件的任务可能更适合,如本节后续所述。

默认情况下,这些命令在当前二进制目录中执行,该目录存储在CMAKE_CURRENT_BINARY_DIRECTORY缓存变量中。如有必要,可以通过WORKING_DIRECTORY选项更改此目录。此选项可以是绝对路径,也可以是相对路径,若为相对路径,则相对于当前二进制目录。

COMMENT选项用于指定在命令运行之前打印的消息,这在命令默默运行时非常有用。不幸的是,并非所有生成器都显示这些消息,因此将其用于显示关键信息可能不太可靠。

VERBATIM 标志会将所有命令直接传递给平台,而不经过底层 shell 的转义或变量替换。CMake 本身仍会替换传递给命令或参数的变量。当转义可能成为问题时,建议传递 VERBATIM 标志。编写自定义任务时,使其与底层平台独立也是一种良好的实践。在本章稍后,在 使用 CMake 创建平台独立的 命令 部分,你可以找到更多有关如何创建平台独立命令的技巧。

USES_TERMINAL 选项指示 CMake 如果可能的话让命令访问终端。如果使用的是 Ninja 生成器,这意味着它将在 terminal 作业池中运行。该池中的所有命令是串行执行的。

JOB_POOL 选项可在使用 Ninja 生成时控制作业的并发性。它很少使用,并且不能与 USES_TERMINAL 标志一起使用。你很少需要干预 Ninja 的作业池,且处理起来并不简单。如果你想了解更多信息,可以参考 CMake 官方文档中的 JOB_POOLS 属性部分。

SOURCES 属性接受与自定义目标关联的源文件列表。该属性不会影响源文件,但可以帮助在某些 IDE 中显示文件。如果一个命令依赖于例如与项目一起交付的脚本等文件,这些文件应该在这里添加。

COMMAND_EXPAND_LISTS 选项告诉 CMake 在将列表传递给命令之前展开它们。这在某些情况下是必要的,因为在 CMake 中,列表只是由分号分隔的字符串,这可能导致语法错误。当传递 COMMAND_EXPAND_LISTS 选项时,分号会根据平台被替换为合适的空白字符。展开操作包括使用 $<JOIN: 生成器表达式生成的列表。

以下是一个示例,展示了一个使用名为 CreateHash 的外部程序来为另一个目标的输出创建哈希值的自定义目标:

add_executable(SomeExe)
add_custom_target(CreateHash ALL
                  COMMAND Somehasher $<TARGET_FILE:SomeExe>
)

本例创建了一个名为 CreateHash 的自定义目标,它调用外部的 SomeHasher 程序,并将 SomeExe 目标的二进制文件作为参数。请注意,二进制文件是通过 $<TARGET_FILE:SomeExe> 生成器表达式获取的。这有两个目的——它消除了用户需要跟踪目标二进制文件名的需求,并且在两个目标之间建立了一个隐式依赖关系。CMake 会识别这些隐式依赖并按正确的顺序执行目标。如果生成所需文件的目标尚未构建,CMake 将自动构建它。你还可以使用 $<TARGET_FILE: 生成器来直接执行由另一个目标创建的可执行文件。以下生成器表达式会在目标之间引发隐式依赖:

  • $<TARGET_FILE:target>:这包含了目标的主二进制文件的完整路径,如.exe.so.dll

  • $<TARGET_LINKER_FILE: target>:这包含了用于与目标进行链接的文件的完整路径。通常是库文件本身,但在 Windows 上,.lib文件会与 DLL 相关联。

  • $<TARGET_SONAME_FILE: target>:这包含了库文件及其完整名称,包括由SOVERSION属性设置的任何数字,如.so.3

  • $<TARGET_PDB_FILE: target>:这包含了用于调试的生成的程序数据库文件的完整路径。

创建自定义目标是一种在构建时执行外部任务的方法。另一种方法是定义自定义命令。自定义命令可以用来将自定义任务添加到现有目标中,包括自定义目标。

将自定义任务添加到现有目标

有时,在构建目标时,你可能需要执行一个额外的外部任务。在 CMake 中,你可以使用add_custom_command来实现这一点,它有两种签名。一种用于将命令钩入现有的目标,另一种用于生成文件。我们将在本节后续部分讲解这一点。将命令添加到现有目标的签名如下所示:

add_custom_command(TARGET <target>
                   PRE_BUILD | PRE_LINK | POST_BUILD
                   COMMAND command1 [ARGS] [args1...]
                   [COMMAND command2 [ARGS] [args2...] ...]
                   [BYPRODUCTS [files...]]
                   [WORKING_DIRECTORY dir]
                   [COMMENT comment]
                   [VERBATIM] [USES_TERMINAL]
                   [COMMAND_EXPAND_LISTS])

大多数选项的工作方式与之前提到的add_custom_target类似。TARGET属性可以是当前目录中定义的任何目标,这是该命令的一个限制,尽管这很少成为问题。命令可以在以下时间钩入构建过程:

  • PRE_BUILD:在 Visual Studio 中,此命令会在任何其他构建步骤之前执行。当你使用其他生成器时,它将在PRE_LINK命令之前执行。

  • PRE_LINK:此命令将在源代码编译完成后执行,但在可执行文件或归档工具链接到静态库之前执行。

  • POST_BUILD:在所有其他构建规则执行完毕后运行此命令。

执行自定义步骤的最常见方法是使用POST_BUILD;另外两种选项很少使用,可能是因为支持有限,或者因为它们既不能影响链接,也不能影响构建。

将自定义命令添加到现有目标相对简单。以下代码在每次编译后添加一个命令,用于生成并存储已构建文件的哈希值:

add_executable(MyExecutable)
add_custom_command(TARGET MyExecutable
   POST_BUILD
  COMMAND hasher $<TARGET_FILE:ch8_custom_command_example>
    ${CMAKE_CURRENT_BINARY_DIR}/MyExecutable.sha256
COMMENT "Creating hash for MyExecutable"
)

在这个例子中,使用一个名为hasher的自定义可执行文件来生成MyExecutable目标的输出文件的哈希值。

通常,在构建之前你可能需要执行某些操作,以更改文件或生成额外的信息。对于这种情况,第二种签名通常是更好的选择。让我们仔细看看。

生成文件与自定义任务

通常,我们希望自定义任务能生成特定的输出文件。这可以通过定义自定义目标并设置目标之间的必要依赖关系来完成,或者通过挂钩构建步骤来实现,正如前面所述。不幸的是,PRE_BUILD 钩子并不可靠,因为只有 Visual Studio 生成器能正确支持它。因此,一个更好的方法是创建一个自定义命令来生成文件,通过使用 add_custom_command 函数的第二种签名:

add_custom_command(OUTPUT output1 [output2 ...]
                   COMMAND command1 [ARGS] [args1...]
                   [COMMAND command2 [ARGS] [args2...] ...]
                   [MAIN_DEPENDENCY depend]
                   [DEPENDS [depends...]]
                   [BYPRODUCTS [files...]]
                   [IMPLICIT_DEPENDS <lang1> depend1
                                    [<lang2> depend2] ...]
                   [WORKING_DIRECTORY dir]
                   [COMMENT comment]
                   [DEPFILE depfile]
                   [JOB_POOL job_pool]
                   [VERBATIM] [APPEND] [USES_TERMINAL]
                   [COMMAND_EXPAND_LISTS])

这种签名的 add_custom_command 定义了一个生成 OUTPUT 中指定文件的命令。该命令的大多数选项与 add_custom_target 和挂钩自定义任务到构建步骤的签名非常相似。DEPENDS 选项可以用来手动指定文件或目标的依赖关系。需要注意的是,与此相比,自定义目标的 DEPENDS 选项只能指向文件。如果任何依赖关系在构建或 CMake 更新时发生变化,自定义命令将再次运行。MAIN_DEPENDENCY 选项密切相关,指定命令的主要输入文件。它的作用类似于 DEPENDS 选项,只是它只接受一个文件。MAIN_DEPENDENCY 主要用于告诉 Visual Studio 添加自定义命令的位置。

注意

如果源文件列为 MAIN_DEPENDENCY,则自定义命令会替代正常的文件编译,这可能导致链接错误。

另外两个与依赖相关的选项,IMPLICIT_DEPENDSDEPFILE,很少使用,因为它们的支持仅限于 Makefile 生成器。IMPLICIT_DEPENDS 告诉 CMake 使用 C 或 C++ 扫描器来检测列出文件的任何编译时依赖关系,并基于此创建依赖关系。另一个选项 DEPFILE 可以用来指向 .d 依赖文件,该文件由 Makefile 项目生成。.d 文件最初来自 GNU Make 项目,虽然它们非常强大,但也比较复杂,大多数项目不应手动管理这些文件。以下示例展示了如何使用自定义命令,在常规目标运行之前,根据用于输入的另一个文件生成源文件:

add_custom_command(OUTPUT ${CMAKE_CURRENT_BINARY_DIR}/main.cpp
COMMAND sourceFileGenerator ${CMAKE_CURRENT_SOURCE_DIR}/message.txt
  ${CMAKE_CURRENT_BINARY_DIR}/main.cpp
COMMENT "Creating main.cpp frommessage.txt"
DEPENDS message.txt
VERBATIM
)
add_executable(
ch8_create_source_file_example
${CMAKE_CURRENT_BINARY_DIR}/main.cpp
)

在这个示例中发生了几件事。首先,自定义命令将当前二进制目录中的 main.cpp 文件定义为 OUTPUT 文件。然后,定义了生成该文件的命令——这里使用了一个名为 sourceFileGenerator 的假设程序——它将消息文件转换为 .cpp 文件。DEPENDS 部分指出,每次 message.txt 文件发生变化时,都应重新运行该命令。

后续创建了可执行文件的目标。由于可执行文件引用了在自定义命令的OUTPUT部分指定的main.cpp文件,CMake 会隐式添加命令和目标之间的必要依赖关系。以这种方式使用自定义命令比使用PRE_BUILD指令更可靠且具有更好的移植性,因为它适用于所有生成器。

有时,为了创建所需的输出,可能需要多个命令。如果存在一个生成相同输出的先前命令,可以通过使用APPEND选项将命令链接起来。使用APPEND的自定义命令只能定义额外的COMMANDDEPENDS选项;其他选项会被忽略。如果两个命令生成相同的输出文件,除非指定APPEND,CMake 会打印出错误。这个功能主要用于当一个命令是可选执行时。考虑以下示例:

add_custom_command(OUTPUT archive.tar.gz
COMMAND cmake -E tar czf ${CMAKE_CURRENT_BINARY_DIR}/archive.tar.gz
  $<TARGET_FILE:MyTarget>
COMMENT "Creating Archive for MyTarget"
VERBATIM
)
add_custom_command(OUTPUT archive.tar.gz
COMMAND cmake -E tar czf ${CMAKE_CURRENT_BINARY_DIR}/archive.tar.gz
  ${CMAKE_CURRENT_SOURCE_DIR}/SomeFile.txt
APPEND
)

在这个示例中,目标MyTarget的输出文件已经被添加到一个tar.gz归档中;之后,另一个文件被添加到相同的归档中。注意,第一个命令自动依赖于MyTarget,因为它使用了在命令中创建的二进制文件。然而,它不会通过构建自动执行。第二个自定义命令列出了与第一个命令相同的输出文件,但将压缩文件作为第二个输出添加。通过指定APPEND,第二个命令会在每次执行第一个命令时自动执行。如果缺少APPEND关键字,CMake 会打印出类似如下的错误:

CMake Error at CMakeLists.txt:30 (add_custom_command):
  Attempt to add a custom rule to output
     /create_hash_example/build/hash_example.md5.rule
   which already has a custom rule.

如前所述,本示例中的自定义命令隐式依赖于MyTarget,但它们不会自动执行。为了执行这些命令,推荐的做法是创建一个依赖于输出文件的自定义目标,可以像这样生成:

add_custom_target(create_archive ALL DEPENDS
    ${CMAKE_CURRENT_BINARY_DIR}/archive.tar.gz
)

在这里,创建了一个名为create_archive的自定义目标,该目标作为All构建的一部分执行。由于它依赖于自定义命令的输出,因此构建该目标会调用自定义命令。自定义命令反过来依赖于MyTarget,因此如果MyTarget尚未是最新的,构建create_archive也会触发MyTarget的构建。

add_custom_commandadd_custom_target自定义任务都会在 CMake 的构建步骤中执行。如果需要,也可以在配置时添加任务。我们将在下一节中讨论这个问题。

在配置时执行自定义任务

要在配置时执行自定义任务,可以使用execute_process函数。常见的需求是,如果构建在开始之前需要额外的信息,或者需要更新文件以便重新运行 CMake。另一个常见的情况是,当CMakeLists.txt文件或其他输入文件在配置步骤中生成时,尽管这也可以通过专用的configure_file命令实现,正如本章稍后所展示的那样。

execute_process 函数的工作方式与我们之前看到的 add_custom_targetadd_custom_command 函数非常相似。然而,有一个区别是,execute_process 可以将输出捕获到变量或文件中的 stdoutstderrexecute_process 的函数签名如下:

execute_process(COMMAND <cmd1> [<arguments>]
                [COMMAND <cmd2> [<arguments>]]...
                [WORKING_DIRECTORY <directory>]
                [TIMEOUT <seconds>]
                [RESULT_VARIABLE <variable>]
                [RESULTS_VARIABLE <variable>]
                [OUTPUT_VARIABLE <variable>]
                [ERROR_VARIABLE <variable>]
                [INPUT_FILE <file>]
                [OUTPUT_FILE <file>]
                [ERROR_FILE <file>]
                [OUTPUT_QUIET]
                [ERROR_QUIET]
                [COMMAND_ECHO <where>]
                [OUTPUT_STRIP_TRAILING_WHITESPACE]
                [ERROR_STRIP_TRAILING_WHITESPACE]
                [ENCODING <name>]
                [ECHO_OUTPUT_VARIABLE]
                [ECHO_ERROR_VARIABLE]
                [COMMAND_ERROR_IS_FATAL <ANY|LAST>])

execute_process 函数接受一系列要在 WORKING_DIRECTORY 中执行的 COMMAND 属性。最后执行命令的返回代码可以存储在使用 RESULT_VARIABLE 定义的变量中。或者,可以将以分号分隔的变量列表传递给 RESULTS_VARIABLE。如果使用 list 版本,命令会按照定义的变量顺序存储命令的返回码。如果定义的变量少于命令,任何多余的返回码将被忽略。如果定义了 TIMEOUT 且任何子进程未能返回,结果变量将包含 timeout。从 CMake 3.19 版本开始,提供了方便的 COMMAND_ERROR_IS_FATAL 选项,它告诉 CMake 如果任何(或仅最后一个)进程失败,则中止执行。这比在执行后获取所有返回码并逐个检查要方便得多。在以下示例中,如果任何命令返回非零值,CMake 的配置步骤将失败并报错:

execute_process(
   COMMAND SomeExecutable
   COMMAND AnotherExecutable
   COMMAND_ERROR_IS_FATAL_ANY
)

任何输出到 stdoutstderr 的内容可以分别通过 OUTPUT_VARIABLEERROR_VARIABLE 变量进行捕获。作为替代方法,它们可以通过使用 OUTPUT_FILEERROR_FILE 重定向到文件,或者通过传递 OUTPUT_QUIETERROR_QUIET 完全忽略。不能同时将输出捕获到变量和文件中,这会导致其中一个为空。保留哪个输出,丢弃哪个,取决于平台。如果没有其他设置,OUTPUT_* 选项表示输出将发送到 CMake 进程本身。

如果输出被捕获到变量中但仍然可以显示,可以添加 ECHO_<STREAM>_VARIABLE。也可以通过传递 STDOUTSTDERRNONECOMMAND_ECHO 选项来让 CMake 输出命令本身。然而,如果输出被捕获到文件中,这将没有任何效果。如果为 stdoutstderr 指定相同的变量或文件,结果将被合并。如果需要,可以通过传递文件给 INPUT_FILE 选项来控制第一个命令的输入流。

输出到变量的行为可以通过使用 <STREAM>_STRIP_TRAILING_WHITESPACE 选项进行有限控制,该选项会去除输出末尾的空白字符。当输出被重定向到文件时,此选项无效。在 Windows 上,可以使用 ENCODING 选项来控制输出编码。它支持以下几种值:

  • NONE:不进行重新编码。这将保持 CMake 内部的编码格式,即 UTF-8。

  • AUTO:使用当前控制台的编码。如果不可用,则使用 ANSI 编码。

  • ANSI:使用 ANSI 代码页进行编码。

  • OEM:使用平台定义的代码页。

  • UTF8UTF-8:强制使用 UTF-8 编码。

使用 execute_process 的常见原因之一是收集构建所需的信息,然后将其传递给项目。考虑一个示例,我们想要将 git 修订版编译到可执行文件中,通过将其作为预处理器定义传递。这样做的缺点是,为了执行自定义任务,必须调用 CMake,而不仅仅是构建系统。因此,使用带有 OUTPUT 参数的 add_custom_command 可能是更实际的解决方案,但为了说明目的,这个示例应该已经足够。以下是一个示例,其中在配置时读取 git 哈希并作为编译定义传递给目标:

find_package(Git REQUIRED)
execute_process(COMMAND ${GIT_EXECUTABLE} "rev-parse" "--short"
  "HEAD"
OUTPUT_VARIABLE GIT_REVISION
OUTPUT_STRIP_TRAILING_WHITESPACE
COMMAND_ERROR_IS_FATAL ANY
WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR})
add_executable(SomeExe src/main.cpp)
target_compile_definitions(SomeExe PRIVATE VERSION=
  \"${GIT_REVISION}\")

在这个示例中,传递给 execute_processgit 命令是在包含当前正在执行的 CMakeLists.txt 文件的目录中执行的。生成的哈希值存储在 GIT_REVISION 变量中,如果命令由于任何原因失败,配置过程将会停止并报错。

通过使用预处理器定义将 execute_process 的信息传递给编译器的做法远非最佳。更好的解决方案是,如果我们能够生成一个包含这些信息的头文件,并将其包含进来。CMake 还有一个名为 configure_file 的功能可以用来实现这一目的,正如我们将在下一节中看到的那样。

复制和修改文件

在构建软件时,一个相对常见的任务是必须在构建前将某些文件复制到特定位置。大多数文件操作可以在配置时通过 file() 命令来完成。例如,复制文件可以通过以下方式调用:

file(COPY_FILE old_file new_file)

有几种文件操作可用,例如 file(REMOVE)file(REMOVE_RECURSE) 用于删除文件或目录树,file(RENAME) 用于移动文件,file(CHMOD) 用于更改支持该操作的系统上的权限。file 命令的完整文档请参见:cmake.org/cmake/help/latest/command/file.html

但如果我们想要同时复制和修改一个文件该怎么办呢?在配置时执行自定义任务一节中,我们看到了一个示例,其中获取了 git 修订版本并作为预处理器定义传递给编译器。更好的做法是生成一个包含必要信息的头文件。虽然直接回显代码片段并将其写入文件是可行的,但这样做是危险的,因为它可能会导致平台特定的代码。CMake 的解决方案是 configure_file 命令,它可以将文件从一个位置复制到另一个位置并在此过程中修改其内容。configure_file 的函数签名如下:

configure_file(<input> <output>
               [NO_SOURCE_PERMISSIONS | USE_SOURCE_PERMISSIONS |
                FILE_PERMISSIONS <permissions>...]
               [COPYONLY] [ESCAPE_QUOTES] [@ONLY]
               [NEWLINE_STYLE [UNIX|DOS|WIN32|LF|CRLF] ])

configure_file函数会将<input>文件复制到<output>文件。如果需要,输出文件的路径将被创建,路径可以是相对路径或绝对路径。如果使用相对路径,输入文件将从当前源目录中查找,但输出文件的路径将相对于当前构建目录。如果无法写入输出文件,命令将失败,配置将被停止。默认情况下,输出文件与目标文件具有相同的权限,尽管如果当前用户与输入文件所属的用户不同,所有权可能会发生变化。如果添加NO_SOURCE_PERMISSION,则不会传递权限,输出文件将获得默认的rw-r--r--权限。或者,可以通过FILE_PERMISSIONS选项手动指定权限,该选项需要一个三位数字作为参数。USE_SOURCE_PERMISSION已经是默认值,该选项仅用于更明确地表达意图。

如前所述,configure_file在复制到输出路径时也会替换输入文件的部分内容,除非传递了COPYONLY。默认情况下,configure_file会将所有引用的变量${SOME_VARIABLE}@SOME_VARIABLE@替换为相同名称的变量的值。如果在CMakeLists.txt中定义了变量,当调用configure_file时,相应的值会写入输出文件。如果未指定变量,输出文件中的相应位置将包含空字符串。考虑一个包含以下信息的hello.txt.in文件:

Hello ${GUEST} from @GREETER@

CMakeLists.txt文件中,configure_file函数用于配置hello.txt.in文件:

set(GUEST "World")
set(GREETER "The Universe")
configure_file(hello.txt.in hello.txt)

在这个示例中,生成的hello.txt文件将包含Hello World from The Universe。如果将@ONLY选项传递给configure_file,只有@GREETER@会被替换,生成的内容将是Hello ${GUEST} from The Universe。使用@ONLY在你转换可能包含大括号括起来的变量的 CMake 文件时非常有用,这些变量不应该被替换。ESCAPE_QUOTES会在目标文件中用反斜杠转义任何引号。默认情况下,configure_file会转换换行符,以便目标文件与当前平台匹配。默认行为可以通过设置NEWLINE_STYLE来改变。UNIXLF将使用\n作为换行符,而DOSWIN32CRLF将使用\r\n。同时设置NEWLINE_STYLECOPYONLY选项将导致错误。请注意,设置COPYONLY不会影响换行符样式。

让我们回到我们希望将 git 修订版编译到可执行文件中的示例。在这里,我们将编写一个头文件作为输入。它可能包含如下内容:

#define CMAKE_BEST_PRACTICES_VERSION "@GIT_REVISION@"
The CMakeLists.txt could look something like this:
execute_process(
    COMMAND ${GIT_EXECUTABLE} rev-parse --short HEAD
    OUTPUT_VARIABLE GIT_REVISION
    OUTPUT_STRIP_TRAILING_WHITESPACE
    COMMAND_ERROR_IS_FATAL ANY
    WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR})
configure_file(version.h.in ${CMAKE_CURRENT_SOURCE_DIR}/src
  /version.h @ONLY)

如前一节中的示例所示,版本信息是作为编译定义传递的,git 修订版首先通过execute_process获取。随后,文件通过configure_file进行复制,@GIT_REVISION@被替换为当前提交的短哈希值。

当你使用预处理器定义时,configure_file会将所有形如#cmakedefine VAR ...的行替换为#define VAR/* undef VAR */,具体取决于VAR是否包含 CMake 解释为truefalse的值。

假设有一个名为version.in.h的文件,其中包含以下两行:

#cmakedefine GIT_VERSION_ENABLE
#cmakedefine GIT_VERSION "@GIT_REVISION@"

附带的CMakeLists.txt文件可能如下所示:

option(GIT_VERSION_ENABLE "Define revision in a header file" ON)
if(GIT_VERSION_ENABLE)
  execute_process(
    COMMAND ${GIT_EXECUTABLE} rev-parse --short HEAD
    OUTPUT_VARIABLE GIT_REVISION
    WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}
)
endif()
configure_file(version.h.in ${CMAKE_CURRENT_SOURCE_DIR}/src/version.h @ONLY)

一旦配置已运行,如果GIT_REVISION_ENABLE被启用,生成的文件将包含以下输出:

#define GIT_VERSION_ENABLE
#define CMAKE_BEST_PRACTICES_VERSION "c030d83"

如果GIT_REVISION_ENABLE被禁用,生成的文件将包含以下输出:

/* #undef GIT_VERSION_ENABLE */
/* #undef GIT_REVISION */

总而言之,configure_file命令非常有用,可以为构建准备输入。除了生成源文件外,它常用于生成 CMake 文件,这些文件随后会被包含在CMakeLists.txt文件中。其优点之一是它允许你独立于平台复制和修改文件,这在跨平台工作时是一个重要的优势。由于configure_fileexecute_process常常一起使用,因此确保执行的命令也是平台无关的。

在下一节中,你将学习如何使用 CMake 来定义平台无关的命令和脚本。

使用 CMake 进行平台无关命令

CMake 成功的一个关键因素是它允许你在多种平台上构建相同的软件。相反,这意味着CMakeLists.txt必须以不假设某个平台或编译器必须使用的方式编写。这可能会很具挑战性,特别是当你在处理自定义任务时。在这种情况下,cmake命令行工具提供的-E标志非常有帮助,它可以用于执行常见任务,例如文件操作和创建哈希。大多数cmake -E命令用于与文件相关的操作,如创建、复制、重命名和删除文件,以及创建目录。在支持文件系统链接的系统上,CMake 还可以在文件之间创建符号链接或硬链接。自 CMake 版本 3.21 以来,大多数操作也可以通过使用file()命令来实现,但并非所有操作都可以。值得注意的是,创建哈希值时,可以使用cmake –``E <algorithm>以平台无关的方式进行。

此外,CMake 可以使用tar命令创建文件归档,并使用cat命令连接文本文件。它还可以用于为文件创建各种哈希值。

还有一些操作可以提供关于当前系统信息的信息。capabilities操作将打印出 CMake 的能力,例如了解支持的生成器和当前正在运行的 CMake 版本。environment命令将打印出已设置的环境变量列表。

可以通过运行cmake -E而不带任何其他参数来获取命令行选项的完整参考。CMake 的在线文档可以在cmake.org/cmake/help/latest/manual/cmake.1.html#run-a-command-line-tool找到。

平台无关的文件操作

每当需要通过自定义任务执行文件操作时,请使用cmake –``E

使用cmake -E,在大多数情况下可以做得相当远。但是,有时需要执行更复杂的操作。为此,CMake 可以在脚本模式下运行,执行 CMake 文件。

执行 CMake 文件作为脚本

CMake 的脚本模式在创建跨平台脚本时非常强大。这是因为它允许您创建完全与平台无关的脚本。通过调用cmake -P <script>.cmake,执行指定的 CMake 文件。脚本文件可能不包含定义构建目标的任何命令。可以使用-D标志将参数作为变量传递,但必须在-P选项之前执行此操作。或者,参数仅可以在脚本名称之后追加,以便可以使用CMAKE_ARGV[n]变量检索它们。参数的数量存储在CMAKE_ARGC变量中。以下脚本演示了如何使用位置参数生成文件的哈希并将其存储在另一个文件中:

cmake_minimum_required(VERSION 3.21)
if(CMAKE_ARGC LESS 5)
    message(FATAL_ERROR "Usage: cmake -P CreateSha256.cmake
      file_to_hash target_file")
endif()
set(FILE_TO_HASH ${CMAKE_ARGV3})
set(TARGET_FILE ${CMAKE_ARGV4})
# Read the source file and generate the hash for it
file(SHA256 "${FILE_TO_HASH}" GENERATED_HASH)
# write the hash to a new file
file(WRITE "${TARGET_FILE}" "${GENERATED_HASH}")

可以使用cmake -P CreateSha256.cmake <input file> <output_file>来调用此脚本。请注意,前三个参数被cmake-P和脚本名称(CreateSha256.cmake)占用。虽然不是严格要求,但脚本文件应始终在开头包含cmake_minimum_required语句。定义脚本的另一种方式,而不使用位置参数,如下所示:

cmake_minimum_required(VERSION 3.21)
if(NOT FILE_TO_HASH OR NOT TARGET_FILE)
   message(FATAL_ERROR "Usage: cmake –DFILE_TO_HASH=<intput_file> \
-DTARGET_FILE=<target file> -P CreateSha256.cmake")
endif()
# Read the source file and generate the hash for it
file(SHA256 "${FILE_TO_HASH}" GENERATED_HASH)
# write the hash to a new file
file(WRITE "${TARGET_FILE}" "${GENERATED_HASH}")

在这种情况下,脚本必须通过显式传递变量来调用,如下所示:

cmake –DFILE_TO_HASH=<input>
      -DTARGET_FILE=<target> -P CreateSha256.cmake

这两种方法也可以结合使用。一个常见的模式是将所有简单的强制参数作为位置参数来期望,并将任何可选或更复杂的参数作为定义的变量。将脚本模式与add_custom_commandadd_custom_targetexecute_process结合使用是创建跨平台无关的构建指令的好方法。从前面章节生成哈希的示例可能如下所示:

add_custom_target(Create_hash_target ALL
COMMAND cmake -P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/
  CreateSha256.cmake $<TARGET_FILE:SomeTarget>
   ${CMAKE_CURRENT_BINARY_DIR}/hash_example.sha256
)
add_custom_command(TARGET SomeTarget
POST_BUILD
COMMAND cmake -P ${CMAKE_CURRENT_SOURCE_DIR}/cmake
  /CreateSha256.cmake $<TARGET_FILE:SomeTarget>
    ${CMAKE_CURRENT_BINARY_DIR}/hash_example.sha256
)

将 CMake 的脚本模式与在项目的配置或构建阶段执行自定义命令的各种方式结合使用,为您在定义构建过程时提供了很大的自由,甚至适用于不同的平台。然而,需注意的是,向构建过程中添加过多的逻辑可能会使其维护变得比预期更加困难。每当您需要编写脚本或向CMakeLists.txt文件中添加自定义命令时,最好先休息一下,考虑一下这一步是否属于构建过程,还是应该留给用户在设置开发环境时处理。

总结

在本章中,您学习了如何通过执行外部任务和程序来定制构建。我们介绍了如何将自定义构建操作作为目标添加,如何将它们添加到现有目标中,以及如何在配置步骤期间执行它们。我们探讨了如何通过命令生成文件,以及如何使用configure_file命令复制和修改文件。最后,我们学习了如何使用 CMake 命令行工具以平台无关的方式执行任务。

定制 CMake 构建的能力是一个非常强大的资产,但它也往往会使构建变得更加脆弱,因为当执行任何自定义任务时,构建的复杂性通常会增加。虽然有时不可避免,但依赖于除编译器和链接器之外的外部程序的安装可能意味着某些软件无法在未安装或不可用这些程序的平台上构建。这意味着,必须特别小心,确保自定义任务在可能的情况下不会假设使用 CMake 的系统有什么特定的配置。最后,执行自定义任务可能会对构建系统带来性能负担,尤其是当它们在每次构建时进行大量工作时。

然而,如果您小心处理自定义构建步骤,它们是增加构建凝聚力的一个很好的方式,因为许多与构建相关的任务可以在构建定义的位置进行定义。这可以使自动化任务(如创建构建产物的哈希值或将所有文档打包成一个公共档案)变得更加容易。

在下一章中,您将学习如何使构建环境在不同系统之间具有可移植性。您将学习如何使用预设来定义配置 CMake 项目的常见方式,如何将您的构建环境打包到容器中,以及如何使用sysroots来定义工具链和库,以便它们在不同系统之间具有可移植性。

问题

请回答以下问题,测试您对本章内容的掌握情况:

  1. add_custom_commandexecute_process之间的主要区别是什么?

  2. add_custom_command的两种签名分别用于什么?

  3. add_custom_commandPRE_BUILDPRE_LINKPOST_BUILD选项有什么问题?

  4. 有哪两种方式可以定义变量,以便它们可以通过configure_file进行替换?

  5. 如何控制configure_file的替换行为?

  6. CMake 命令行工具执行任务的两个标志是什么?

答案

以下是本章节问题的答案:

  1. 使用add_custom_command添加的命令在构建时执行,而使用execute_process添加的命令在配置时执行。

  2. 一个签名用于创建自定义构建步骤,而另一个用于生成文件。

  3. 只有POST_BUILD在所有生成器中都可靠地得到支持。

  4. 变量可以定义为${VAR}@VAR@

  5. 变量替换可以通过传递@ONLY来控制,这样只替换定义为@VAR@的变量,或者通过指定COPYONLY选项来控制,这样完全不执行任何替换。

  6. 使用cmake -E可以直接执行常见任务。使用cmake -P.cmake文件可以作为脚本执行。

第九章:创建可重现的构建环境

构建软件可能会很复杂,尤其是涉及依赖项或特殊工具时。在一台机器上编译通过的软件,在另一台机器上可能无法正常工作,因为缺少某个关键的软件。单单依赖软件项目文档的正确性来搞清楚所有构建要求通常是不够的,因此,程序员往往需要花费大量时间梳理各种错误信息,以找出构建失败的原因。

在构建或 持续集成 (CI) 环境中,许多人因为害怕任何更改都可能破坏软件的构建能力,而避免升级任何东西。情况甚至严重到公司因为担心无法再发布产品,而拒绝升级其使用的编译器工具链。创建关于构建环境的稳健且可移植的信息是彻底改变游戏规则的举措。通过预设,CMake 提供了定义配置项目的常见方式的可能性。当与工具链文件、Docker 容器和 系统根目录 (sysroots) 结合使用时,创建一个可在不同机器上重建的构建环境变得更加容易。

在本章中,你将学习如何定义 CMake 预设以配置、构建和测试 CMake 项目,以及如何定义和使用工具链文件。我们将简要介绍如何使用容器构建软件,并学习如何使用 sysroot 工具链文件创建隔离的构建环境。本章的主要内容如下:

  • 使用 CMake 预设

  • 组织预设的最佳实践

  • 使用 CMake 构建容器

  • 使用 sysroot 隔离构建环境

所以,让我们系好安全带,开始吧!

技术要求

和前几章一样,示例已在 CMake 3.25 版本下测试,并可在以下任一编译器上运行:

  • GCC 9 或更新版本

  • Clang 12 或更新版本

  • MSVC 19 或更新版本

对于使用构建容器的示例,需要 Docker。

所有示例和源代码都可以在本书的 GitHub 仓库中找到。对于本章,CMake 预设和构建容器的示例位于仓库的根文件夹中。如果缺少任何软件,相应的示例将从构建中排除。仓库地址:github.com/PacktPublishing/CMake-Best-Practices---2nd-Edition

使用 CMake 预设

在多个配置、编译器和平台上构建软件是 CMake 的最大优点,但这也是其最大的弱点之一,因为这通常让程序员难以弄清楚哪些构建设置实际上经过测试并能在特定软件上工作。自 3.19 版本以来,CMake 引入了一个叫做 预设(presets) 的功能。这个功能是处理这些场景的一个非常可靠且方便的工具。在引入预设之前,开发者需要依赖文档和模糊的约定来搞清楚 CMake 项目的首选配置。预设可以指定构建目录、生成器、目标架构、主机工具链、缓存变量和用于项目的环境变量。从 CMake 3.19 版本开始,预设经历了很大的发展,增加了构建、测试和打包预设,以及最新加入的:工作流预设。

要使用预设,项目的顶级目录必须包含一个名为 CMakePresets.jsonCMakeUserPresets.json 的文件。如果两个文件都存在,它们会通过先解析 CMakePresets.json,然后再解析 CMakeUserPresets.json 来内部合并。两个文件的格式相同,但用途略有不同:

  • CMakePresets.json 应由项目本身提供,并处理项目特定的任务,比如运行 CI 构建,或者在项目中提供的情况下,知道使用哪些工具链进行交叉编译。由于 CMakePresets.json 是项目特定的,它不应该引用项目结构外的任何文件或路径。由于这些预设与项目紧密相关,通常也会将其保存在版本控制中。

  • 另一方面,CMakeUserPresets.json 通常由开发者为自己的机器或构建环境定义。CMakeUserPresets.json 可以根据需要非常具体,可能包含项目外的路径或特定系统设置的路径。因此,项目不应该提供此文件,也不应该将其放入版本控制中。

预设是将缓存变量、编译器标志等从 CMakeLists.txt 文件中移出的好方法,同时还能以可以与 CMake 一起使用的方式保留这些信息,从而提高项目的可移植性。如果有预设,可以通过调用以下命令 cmake --list-presets 从源目录列出预设,这将生成如下输出:

Available configure presets:
  "ninja-debug"   - Ninja (Debug)
  "ninja-release" - Ninja (Release)

这将列出带引号的预设名称,如果设置了 displayName 属性,也会显示该属性。要从命令行使用属性时,使用带引号的名称。

CMake GUI 将在源目录中显示所有可用的预设,类似如下:

图 9.1 – 在 CMake GUI 中列出可用的预设

图 9.1 – 在 CMake GUI 中列出可用的预设

从 CMake 3.21 版本开始,ccmake 命令行配置工具不支持预设。可以通过以下方式从顶级目录选择配置预设:

cmake --preset=name

CMakePresets.jsonCMakeUserPresets.json 的整体结构如下:

{
   "version": 6,
   "cmakeMinimumRequired": {"major": 3,"minor": 25,"patch": 0  },
   "configurePresets": [...],
   "buildPresets": [...],
   "testPresets": [...],
  "packagePresets": [...],
  "workflowPresets": [...],
   "vendor": {
      "microsoft.com/VisualStudioSettings/CMake/1.9": {                      "intelliSenseMode": "windows-msvc-x64"
      }  }
}

version 字段指定要使用的 JSON 架构。版本 1 是 CMake 3.19 的首次发布,仅支持 configurePresets。随后的版本添加了 buildPresetstestPresetspackagePresetsworkflowPresets。在编写本书时,最新版本是版本 9,该版本随 CMake 3.30 发布。

可选的 cmakeMinimumRequired 字段可以用来定义构建此项目所需的最低 CMake 版本。由于最低要求通常也会在 CMakeLists.txt 文件中声明,因此此字段通常会被省略。

四个列表:configurePresetsbuildPresetstestPresetspackagePresets,分别包含用于配置、构建、测试和打包项目的配置列表。构建、测试和打包的预设要求至少有一个配置预设,如我们将在本节后面看到的那样。workflowPresets 是一个特殊情况,它描述了由其他预设组成的典型工作流。

vendor 字段包含一个可选的映射,用于存储供应商或 IDE 特定的信息。CMake 不会解释此字段的内容,除非验证 JSON 格式。映射的键应该是由斜杠分隔的供应商特定域。在前面的示例中,供应商预设的键是 microsoft.com/VisualStudioSettings/CMake/1.9。供应商字段中的值可以是任何有效的 JSON 格式。所有预设文件必须至少包含一个配置预设,因此让我们更仔细地看一下。

配置预设

要使用预设,必须至少存在一个配置预设,用于为 CMake 配置构建系统的环境。它们至少应该指定构建路径以及配置时使用的生成器。通常,配置预设还会设置一些常见的缓存变量,比如用于单配置生成器的 CMAKE_BUILD_TYPE。一个包含配置预设的预设,用于在调试模式下使用 Ninja 生成器构建项目,可能像这样:

{
  "version": 3,
  "configurePresets": [
    {
      "name": "ninja",
      "displayName": "Ninja Debug",
      "description": "build in debug mode using Ninja generator",
      "generator": "Ninja",
      "binaryDir": "build",
      "cacheVariables": { "CMAKE_BUILD_TYPE": "Debug" }
    }
  ]
}

所有预设必须具有在预设块中唯一的名称。由于某些 GUI 应用程序仅显示已分配 displayName 字段的预设,因此强烈建议设置此字段。

预设命名约定

一个好的实践是,在 CMakePresets.json 文件中定义的预设名称应避免与开发者可能在 CMakeUserPresets.json 中定义的名称冲突。一个常见的约定是,将项目定义的预设以 ci- 为前缀,以标记它们用于 CI 环境。

在版本 1 和 2 的预设中,binaryDirgenerator字段是必需的;在版本 3 中,它们变为可选。如果未设置任一字段,其行为与未使用预设的 CMake 相同。CMake 命令的命令行选项将在相关情况下覆盖预设中指定的值。因此,如果设置了binaryDir,在调用cmake --preset=时,它会自动创建,但如果传递了-B选项,则其值会被 CMake 覆盖。

缓存变量可以通过key:value对来定义,如前面的示例所示,或者作为 JSON 对象,这样可以指定变量类型。文件路径可以像这样指定:

"cacheVariables": {
  "CMAKE_TOOLCHAIN_FILE": {
    "type": "FILEPATH",
    "value": "${sourceDir}/cmake/toolchain.cmake"
  }
}

如果以key:value的形式使用,则类型将被视为STRING,除非它是truefalse(没有引号),在这种情况下将被解释为BOOL。例如,$ {sourceDir}是一个宏,当使用预设时会展开。

已知的宏如下:

  • ${sourceDir}:这指向项目的源目录,${sourceParentDir}指向源目录的父目录。可以通过${sourceDirName}获取源目录的目录名,不包括路径。例如,如果${sourceDir}/home/sandy/MyProject,那么${sourceDirName}将是MyProject,而${sourceParentDir}将是/home/sandy/

  • ${generator}:这是当前使用的预设指定的生成器。对于构建和测试预设,它包含配置预设使用的生成器。

  • ${hostSystemName}:这是主机操作系统的系统名称,与CMAKE_HOST_SYSTEM变量相同。该值要么是uname -s的结果,要么是 Linux、Windows 或 Darwin(用于 macOS)。

  • $env{<variable-name>}:这包含名为<variable-name>的环境变量。如果该变量在预设的环境字段中定义,则使用此值,而不是从父环境或系统环境中获取的值。使用$penv{<variable-name>}的作用类似,但值始终从父环境中获取,而不是从环境字段中获取,即使该变量已定义。这允许向现有环境变量添加前缀或后缀。由于$env{...}不允许循环引用,因此不能向变量添加前缀或后缀。需要注意的是,在 Windows 环境中,变量是不区分大小写的,但在预设中使用的变量仍然区分大小写。因此,建议保持环境变量的大小写一致。

  • $vendor{<macro-name>}:这是供 IDE 供应商插入其自定义宏的扩展点。由于 CMake 无法解释这些宏,使用$vendor{…}宏的预设将被忽略。

  • ${dollar}:这是一个占位符,代表字面上的美元符号$

修改预设的环境与设置缓存变量类似:通过设置包含key:value对的映射的environment字段。即使值为空或为null,环境变量始终会被设置。环境变量可以互相引用,只要不包含循环引用。考虑以下示例:

{
  "version": 3,
  "configurePresets": [
    {
      "name": "ci-ninja",
      "generator": "Ninja",
      "binaryDir": "build",
      "environment": {
        "PATH": "${sourceDir}/scripts:$penv{PATH}",
        "LOCAL_PATH": "$env{PATH}",
        "EMPTY" : null
    }
  ]
}

在这个示例中,PATH环境变量通过在项目结构内部预先添加路径进行修改。使用$penv{PATH}宏确保值来自预设之外。然后,LOCAL_PATH变量通过使用$env{PATH}宏引用修改后的PATH环境变量。只要PATH环境变量不包含$env{LOCAL_PATH}(这将产生循环引用),这种引用是允许的。通过传递nullEMPTY环境变量被取消设置。注意,null不需要加引号。除非使用构建预设或测试预设,否则环境不会传递到相应的步骤。如果使用了构建预设或测试预设,但不希望应用来自配置预设的环境,可以通过将inheritConfigureEnvironment字段设置为false来明确声明。

从预设中继承

预设可以通过inherits字段继承其他相同类型的预设,该字段可以包含单个预设或预设列表。在继承父级字段时,预设可以被覆盖或添加额外的字段。这对于避免为常见构建块重复代码非常有用。结合hidden字段使用时,可以使CMakePreset.json文件更小。考虑以下示例:

{
   "version": 3,
   "configurePresets": [
      {
         "name": "ci-ninja",
         "generator": "Ninja",
         "hidden": true,
         "binaryDir": "build"
      },
      {
         "name": "ci-ninja-debug",
         "inherits": "ci-ninja",
         "cacheVariables": {
            "CMAKE_BUILD_TYPE": "Debug"
         }
      },
      {
          "name": "ci-ninja-release",
          "inherits": "ci-ninja",
          "cacheVariables": {
             "CMAKE_BUILD_TYPE": "Release"
         }
      }
   ]
}

在示例中,ci-ninja-debugci-ninja-release预设都从隐藏的ci-ninja build预设继承,并额外设置了CMAKE_BUILD_TYPE缓存变量,以对应的配置进行构建。隐藏预设仍然可以使用,但在执行cmake --list-presets时不会显示。CMakeUserPreset.json中定义的预设可以从CMakePreset.json继承,但反过来不行。

在前面的示例中,预设继承自单一父预设,但预设也可以从多个父预设继承。以下示例展示了CMakeUserPreset.json与前面示例中的CMakePreset.json如何协同工作:

{
    "version": 6,
    "configurePresets": [
    {
      "name": "gcc-11",
      "hidden": true,
      "binaryDir": "build",
      "cacheVariables": {
          "CMAKE_C_COMPILER": "gcc-11",
          "CMAKE_CXX_COMPILER": "g++-11"
       }
     },
     {
       "name": "ninja-debug-gcc",
       "inherits": ["ci-ninja-debug","gcc-11"],
       "cacheVariables": {
            "CMAKE_BUILD_TYPE": "Debug"
         }
     },
   ]
}

在这里,用户提供了一个预设,明确选择了 GCC 11 作为名为gcc-11的编译器。之后,ninja-debug-gcc预设从项目提供的CMakePreset.json中定义的ci-ninja-debug预设继承值,并与用户提供的gcc-11预设结合。如果两个父预设为相同字段定义了不同的值,则inherits列表中首先出现的那个预设的值优先。

预设条件

有时,预设仅在某些条件下才有意义,例如针对特定的构建平台。例如,使用 Visual Studio 生成器的配置预设仅在 Windows 环境中才有用。对于这些情况,如果条件不满足,可以使用condition选项禁用预设。任何在父预设中定义的条件都会被继承。条件可以是常量、字符串比较,或检查列表是否包含某个值。从预设版本 3 开始提供这些功能。以下配置预设仅在你在 Windows 环境下工作时启用:

{
  "name": "ci-msvc-19",
  "generator": "Visual Studio 16 2019",
  "binaryDir": "build",
  "condition": {
    "type": "equals",
    "lhs": "${hostSystemName}",
    "rhs": "Windows"
  }
}

在上述示例中,构建预设会在使用${hostSystemName}宏获取主机系统的名称并与Windows字符串进行比较后启用。如果${hostSystemName}匹配,则启用预设;否则,禁用该预设,尝试使用它会导致错误。在比较字符串时,大小写是重要的:对于不区分大小写的测试,可以使用matchesnotMatches类型,它们接受正则表达式。

对于更复杂的条件,支持使用allOfanyOfnot运算符进行布尔逻辑嵌套。例如,如果一个配置预设只应在 Windows 和 Linux 下启用,而在 macOS 下不启用,则预设和条件可以如下所示:

{
"name": "WindowsAndLinuxOnly",
"condition": {
    "type": "anyOf",
    "conditions": [
        {
            "type": "equals",
            "lhs": "${hostSystemName}",
            "rhs": "Windows"
        },
        {
            "type": "equals",
            "lhs": "${hostSystemName}",
            "rhs": "Linux"
        }
    ]
}

每个条件也可以包含进一步的嵌套条件(如果需要的话),尽管这样做会迅速增加预设的复杂性。

到目前为止,我们在示例中只见过配置预设,但正如本章开头所提到的,还有构建预设和测试预设。构建和测试预设的语法与配置预设非常相似,许多字段,如namedisplayNameinherit,以及条件的工作方式与配置预设相同。一旦指定了配置预设,我们就可以开始指定构建、测试和打包预设。

构建、测试和打包预设

构建预设必须在configurePreset字段中指定一个配置预设,或从另一个指定了配置预设的构建预设中继承。构建目录由配置预设决定,除非inheritConfigureEnvironment字段设置为false,否则会继承配置预设的环境。构建预设主要用于多配置生成器,但如果需要,也可以用于单配置生成器。可选地,构建预设可以指定一个要构建的目标列表。一个构建预设的示例如下:

{
    "version": 6,
    "configurePresets": [
        {
            "name": "ci-msvc-19",
            "displayName": "msvc 19",
            "description": "Configuring for msvc 19",
            "generator": "Visual Studio 16 2019",
            "binaryDir" : "build"
        }
    ],
    "buildPresets": [
        {
            "name": "ci-msvc-debug",
            "configurePreset": "ci-msvc-19",
            "configuration": "Debug"
        },
        {
            "name": "ci-msvc-release",
            "configurePreset": "ci-msvc-19",
            "configuration": "Release"
        },
        {
            "name": "ci-documentation",
            "configurePreset": "ci-msvc-19",
            "targets": [
                "api-doc",
                "doc"
            ]
        }
    ]
}

在上面的示例中,定义了三个构建预设。前两个分别叫做ci-msvc-debugci-msvc-release,用于指定 Visual Studio 的构建配置,并且不指定任何目标。第三个构建预设叫做ci-documentation,并将api-docdoc目标列为文档构建的一部分。调用任何ci-msvc构建预设都会构建"all"目标,而ci-documentation则只会构建列出的目标。可用的构建预设列表可以通过cmake --build --list-presets命令获取。

测试预设与构建预设非常相似,不同之处在于它们与 CTest 一起使用。类似地,在项目根目录下运行ctest --list-presets命令将列出可用的测试预设。测试预设是一个非常有用的工具,可以用来选择或排除特定的测试、指定固定选项,或者控制测试的输出。大部分在第七章中描述的测试选项,无缝集成代码质量工具与 CMake,都可以通过测试预设来控制。一个测试预设的示例如下:

{
    "version": 3,
    "configurePresets": [
        {
            "name": "ci-ninja",
            ...
        }
    "testPresets": [
{
            "name": "ci-feature-X",
            "configurePreset": "ci-ninja",
            "filter": {
                "include": {
                    "name": "feature-X"
                },
                "exclude": {
                    "label": "integration"
                }
}
        }
    ]
}

在上面的示例中,添加了一个测试预设,用于过滤包含feature-X的测试,但排除了标记为integration的任何测试。这等同于从构建目录调用以下命令:

ctest --tests-regex feature-X --label-exclude integration

包预设与构建和测试预设非常相似,也需要设置一个配置预设。一个包预设的示例如下:

"packagePresets": [
    {
        "name": "ci-package-tgz",
        "configurePreset": "ci-ninja-release",
        "generators": [
            "TGZ"
        ],
        "packageDirectory": "${sourceDir}/dist"
    }
]

这个包预设将使用 TGZ 生成器构建一个包,并将结果包放入dist目录中。由于打包依赖于生成器,并且 CPack 需要相当复杂的配置,因此预设通常用于将包放置在预定义位置,并与工作流预设一起使用,正如我们将在下一节看到的那样。

工作流预设

工作流预设顾名思义,是一种从配置到构建、测试和打包的完整工作流定义方式。如果其中的任何一步失败,工作流将中断,后续步骤不会被执行。工作流预设主要用于自动化构建系统,但当然也可以在本地使用。

列出工作流预设的语法如下:

cmake --workflow --list-presets

要调用工作流预设,使用以下命令:

cmake --workflow --preset=ci-ninja-debug-workflow

这将执行完整的ci-ninja-debug-workflow工作流。工作流定义为一系列的步骤,如下所示:

"workflowPresets": [
    {
        "name": "ci-ninja-debug-workflow",
        "displayName": "CI",
        "description": "Continuous Integration",
        "steps": [
            {
                "type": "configure",
                "name": "ci-ninja-debug"
            },
            {
                "type": "build",
                "name": "ci-ninja-debug-build"
            },
            {
                "type": "test",
                "name": "ci-unit-tests-debug"
            },
            {
                "type": "package",
                "name": "ci-package-tgz"
            }
        ]
    }
]

该命令定义了一个工作流,配置、构建、测试并打包项目。

所有工作流步骤必须使用相同的配置预设

有一点值得注意的是,所有步骤必须定义相同的配置步骤,否则工作流将不正确,CMake 会失败。

虽然它们缺乏像 GitHub Actions、Jenkins 和 Azure DevOps 那样定义完整构建流水线的复杂性,但它们的优势在于能够让开发人员轻松遵循定义好的工作流。工作流预设是应对 CMake 预设“组合爆炸”问题的一种尝试,尤其是当有很多预设时。虽然它们在一定程度上有助于解决这个问题,但它们也要求所有中间步骤都明确地定义,并显式使用相同的配置预设。这样一来,可能迫使开发人员添加更多的预设,这些预设在某种程度上是“差不多但不完全相同”的。

目前还没有一劳永逸的解决方案,但如果预设组织得当,并且遵循一定的纪律,仍然可以获得很多好处。

组织预设的最佳实践

随着项目的增长——特别是当它们面向多个平台时——CMake 预设的数量也可能迅速增加。这会使得跟踪预设并找到正确的预设变得困难。设置工作流预设可以有所帮助,但这仅仅是解决问题的一半,缺点往往是,使用工作流预设会为所有中间步骤创建更多的预设。

组织预设的第一步是找到一个好的命名方案。这样可以轻松地弄清楚某个预设的作用,也有助于让开发人员猜测所需的预设名称(如果它存在的话)。

一个好的方案应该包含以下构建信息:

  • cidev

  • 要使用的生成器

  • 工具链,例如要使用的编译器和目标平台

  • 构建类型,例如调试版或发布版

使用这种方式,我们最终得到一个类似于<env>-<generator>-<toolchain>-<buildType>的方案,因此用于构建 Linux x86_64 的预设可能被命名为ci-ninja-linux-x86_64-clang-debug

命名方案适用于各种预设,但对于配置预设最为有用。这里的env可以是cidev,取决于预设是用于 CI 还是本地使用。一个好的经验法则是将所有ci预设放入CMakePresets.json并进行版本控制,而将dev预设放入CMakeUserPresets.json并且不进行检查。生成器部分可以是 CMake 支持的任何生成器,例如 Makefiles、Ninja 或 MSVC(用于 Microsoft Visual Studio)。工具链是一个由目标平台、编译器和操作系统组合而成的部分,通常称为目标三元组或目标四元组,例如linux-armv7-clang12

构建类型是常见的构建类型之一,如调试版、发布版、带调试信息的发布版(relWithDebInfo)或最小大小发布版(minSizeRel),但如果项目配置了自定义构建类型,也可以使用它。对于像 ninja-multi 这样的多配置生成器,构建类型可以在配置预设中省略,并在构建预设中使用。为了聚合预设,可以使用继承和隐藏的预设。那么,哪个预设应该放入哪个部分呢?

图 9.2 – 按类型分组预设以组合可用预设

图 9.2 – 按类型分组预设以组合可用预设

让我们更仔细地看看用于组合可用预设的不同类型的预设:

  • ci 预设应使用相同或至少相似的一组预设。通常,此类别包含多个预设,针对不同方面,例如一个用于设置 Ccache,另一个用于指向 clang-tidy。

  • 生成器预设:它们定义要使用的 CMake 生成器。对于包预设,这也可以是包生成器。默认情况下,它们是隐藏的。通常,只有在 CI 或本地实际使用的生成器应该存在。

  • hidden

  • 构建类型预设:这些定义构建类型,例如 release、debug、RelWithDebInfo 或 MinSizeRel。

  • 组合预设:这些预设通过 inherit 关键字将所有先前的预设组合成可用预设。它们是可见的,并且指定了构建目录,通常是直接指定或通过其中一个先前的预设指定。

以这种方式设置预设有助于保持它们的组织性。CMake 预设支持包含不同的文件,因此将类别放入不同的文件中可能有助于保持它们的组织性。

CMake 预设可以说是自从引入目标以来,改变了 CMake 使用方式的少数几个特性之一。它们是一个很好的折衷方案,可以将常见的配置和构建选项与项目一起提供,同时保持 CMakeLists.txt 文件与平台无关。最终,无法避免 CMake 支持大量工具和平台,或者 C++ 软件可以通过多种方式构建这一问题。适应这种灵活性需要维护所有这些配置;预设显然是一个不错的选择。然而,有时候,提供必要的设置还不够,你还希望共享一个构建环境,确保软件能够编译。一种实现方法是通过定义一个包含 CMake 和必要库的构建容器。

使用 CMake 的构建容器

容器化带来的好处是开发人员可以在一定程度上控制构建环境。容器化的构建环境对于设置 CI 环境也非常有帮助。目前有许多容器运行时,其中 Docker 是最流行的。深入探讨容器化超出了本书的范围,因此我们将在本书的示例中使用 Docker。

构建容器包含一个完全定义的构建系统,包括 CMake 和构建某个软件所需的任何工具和库。通过提供容器定义(例如 Dockerfile),以及项目文件,或者通过公开可访问的容器注册表,任何人都可以使用该容器来构建软件。巨大的优势是开发者不需要安装额外的库或工具,从而避免污染主机机器,除了运行容器所需的软件。缺点是构建可能需要更长的时间,并且并非所有 IDE 和工具都支持以便捷的方式与容器协作。值得注意的是,Visual Studio Code 对在容器中工作提供了很好的支持。您可以访问code.visualstudio.com/docs/remote/containers了解更多细节。

从很高的层次来看,使用构建容器的工作流程如下:

  1. 定义容器并构建它。

  2. 将本地代码的副本挂载到构建容器中。

  3. 在容器内运行任何构建命令。

用于构建一个简单 C++ 应用程序的非常简单的 Docker 定义可能如下所示:

FROM alpine:3.20.2
RUN apk add --no-cache cmake ninja g++ bash make git
RUN <any command to install additional libraries etc.>

这将基于 Alpine Linux 3.15 定义一个小型容器,并安装cmakeninjabashmakegit。任何实际使用的容器可能会安装额外的工具和库以方便工作;然而,仅为了说明如何使用容器构建软件,拥有这样一个最小的容器就足够了。以下 Docker 命令将构建容器镜像,并使用builder_minimal名称进行标签:

docker build . -t builder_minimal

一旦容器是本地的克隆,源代码被挂载到容器中,所有的 CMake 命令都在容器内部执行。假设用户从source目录执行 Docker 命令,配置 CMake 构建项目的命令可能看起来像这样:

docker run --user 1000:1000 --rm -v $(pwd):/workspace
  builder_minimal cmake -S /workspace -B /workspace/build
docker run --user 1000:1000 --rm -v $(pwd):/workspace
  builder_minimal cmake --build /workspace/build

这将启动我们创建的容器并执行其中的 CMake 命令。通过-v选项,本地目录会挂载到容器中的/workspace。由于我们的 Docker 容器默认使用root作为用户,因此需要通过--user选项传递用户 ID 和组 ID。对于类 Unix 操作系统,用户 ID 应与主机上的用户 ID 匹配,这样创建的任何文件也可以在容器外部编辑。--rm标志告诉 Docker 在完成后删除镜像。

与容器交互的另一种方式是通过向docker run命令传递-ti标志来以交互模式运行它:

docker run --user 1000:1000 --rm -ti -v $(pwd):/workspace
  builder_minimal

这将在容器内启动一个 shell,可以在其中调用build命令,而无需每次都重新启动容器。

编辑器或 IDE 和构建容器如何协同工作有几种策略。最便捷的方式当然是 IDE 本身原生支持,或者通过像 Visual Studio Code 这样的方便扩展支持。如果没有这种支持,将合适的编辑器打包进容器并从容器内启动也是一个可行的策略来方便地开发软件。另一种方法是在宿主系统上运行编辑器,并重新配置它,使其不直接调用 CMake,而是启动容器并在其中执行 CMake。

我们在这里展示的只是使用容器作为构建环境的最低要求,但我们希望它能作为使用容器的第一步。随着越来越多的集成开发环境(IDE)开始支持容器化构建环境,使用它们将变得更加容易。容器使得构建环境在不同机器之间变得非常可移植,并有助于确保项目中的所有开发者都使用相同的构建环境。将容器定义文件放在版本控制下也是一个好主意,这样对构建环境的必要更改就能与代码一起进行跟踪。

容器是创建隔离构建环境的一种好且可移植的方式。然而,如果出于某种原因无法使用容器,另一种创建隔离和可移植构建环境的方法是使用 sysroot。

使用 sysroot 来隔离构建环境

简而言之,sysroot 是构建系统认为的根目录,用来定位头文件和库文件。简而言之,它们包含为目标平台编译软件时所需的简化版根文件系统。它们通常在交叉编译软件到其他平台时使用,如 第十二章 中所述,跨平台编译与自定义工具链。如果容器无法用于传递整个构建环境,sysroot 可以作为提供已定义构建环境的替代方案。

要在 CMake 中使用 sysroot,需使用工具链文件。顾名思义,这些文件定义了编译和链接软件所使用的工具,并指明了查找任何库的路径。在正常的构建过程中,CMake 会通过系统自检自动检测工具链。工具链文件通过 CMAKE_TOOLCHAIN_FILE 变量传递给 CMake,方法如下:

cmake -S <source_dir> -B <binary_dir> -DCMAKE_TOOLCHAIN_FILE=
  <path/to/toolchain.cmake>

从版本 3.21 开始,CMake 额外支持 --toolchain 选项来传递工具链文件,这相当于传递 CMAKE_TOOLCHAIN_FILE 缓存变量。

另外,工具链文件可以作为缓存变量通过 CMake 预设传递。最基本的情况下,使用 sysroot 的工具链文件会定义CMAKE_SYSROOT变量来指向 sysroot,CMAKE_<LANG>_COMPILER变量来指向与 sysroot 中的库兼容的编译器。为了避免混淆来自 sysroot 之外的依赖与安装在主机系统上的文件,通常还会设置控制find_命令查找位置的变量。一个最小的工具链文件可能如下所示:

set(CMAKE_SYSTEM_NAME Linux)
set(CMAKE_SYSROOT /path/to/sysroot/)
set(CMAKE_STAGING_PREFIX path/to/staging/directory)
set(CMAKE_C_COMPILER /path/to/sysroot/usr/bin/gcc-10)
set(CMAKE_CXX_COMPILER /path/to/sysroot/usr/bin/g++-10)
set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE BOTH)

让我们详细看看这里发生了什么:

  1. 首先,通过设置CMAKE_SYSTEM_NAME变量,设置目标系统的系统名称。这是为其在 sysroot 内部编译文件的系统。

  2. 然后,通过设置CMAKE_SYSROOT变量,设置 sysroot 本身的路径。CMAKE_STAGING_PREFIX是可选的,用于指定一个位置来安装项目的任何产物。指定一个暂存前缀有助于保持 sysroot 和主机文件系统的清洁,因为如果没有它,所有产物的安装都会发生在主机文件系统上。

  3. 接下来,通过设置CMAKE_C_COMPILERCMAKE_CXX_COMPILER变量,编译器会设置为与 sysroot 一起提供的编译器二进制文件。

  4. 最后,设置任何find_命令在 CMake 中的搜索行为。CMAKE_FIND_ROOT_PATH_MODE_*变量可以取ONLYNEVERBOTH中的任何一个值。如果设置为ONLY,CMake 只会在 sysroot 内搜索该类型的文件;如果设置为NEVER,搜索将仅考虑主机文件结构。如果设置为BOTH,则会同时搜索主机系统路径和 sysroot 路径。需要注意的是,CMAKE_STAGING_PREFIX被视为系统路径,因此为了同时搜索 sysroot 和暂存目录,必须选择BOTH。在这个例子中,配置的方式是将所有头文件和库限制在 sysroot 中,而任何find_program的调用只会在主机系统中查找,find_package则会在两个地方查找。

设置CMAKE_SYSROOT变量不会自动设置构建产物的安装位置。在生成的二进制文件与主机系统兼容的情况下,这可能是预期的行为。在许多情况下,比如交叉编译时,这不是我们想要的行为,因此通常推荐设置CMAKE_STAGING_PREFIX。设置暂存目录有两个效果:首先,它会导致所有产物安装到暂存目录中;其次,暂存目录会被添加到find_命令的搜索前缀中。需要注意的是,暂存目录会被添加到CMAKE_SYSTEM_PREFIX_PATH,这带来的一个问题是,前面例子中的CMAKE_FIND_ROOT_PATH_MODE_XXX变量必须设置为BOTH,这样才能找到安装在暂存区域中的软件包、库和程序。

CMAKE_STAGING_PREFIX 和 CMAKE_INSTALL_PREFIX

如果同时设置了 CMAKE_STAGING_PREFIXCMAKE_INSTALL_PREFIX,则 staging 前缀将具有优先权。因此,作为经验法则,只要工具链与主机系统兼容,通常可以省略 staging,或者倾向于定义它。

与容器相比,sysroots 的一个缺点是它们不能像容器那样启动并直接执行命令。因此,如果工具链和 sysroot 与主机平台不兼容,则生成的任何文件都无法执行,除非移至目标平台或使用仿真器。

总结

本章中,我们学习到 CMake 的主要优势之一是它在使用多种工具链为大量平台构建软件方面的多功能性。其缺点是,有时开发者难以找到适合的软件配置。不过,通过提供 CMake 预设、容器和 sysroots,通常可以更容易地开始 CMake 项目。

本章详细讲解了如何定义 CMake 预设来定义工作配置设置,并创建构建和测试定义。然后,我们简要介绍了如何创建 Docker 容器以及如何在容器内调用 CMake 命令,最后简要回顾了 sysroots 和工具链文件。有关工具链和 sysroots 的更多内容将在 第十二章 中讲解,跨平台编译和 自定义工具链

在下一章中,您将学习如何将大型分布式项目作为超级构建进行处理。在那里,您将学习如何处理不同版本以及如何以可管理的方式从多个仓库组装项目。

问题

  1. CMakePresets.jsonCMakeUserPresets.json 有何区别?

  2. 预设如何在命令行中使用?

  3. 存在哪三种类型的预设,它们之间如何相互依赖?

  4. 配置预设应至少定义什么内容?

  5. 当从多个预设继承时,如果某个值被多次指定,哪个预设优先?

  6. 常见的构建容器工作策略有哪些?

  7. 用于与 sysroots 一起使用的工具链文件通常定义了什么?

答案

  1. CMakePresets.json 通常与项目一起维护和交付,而 CMakeUserPresets.json 则由用户维护。在语法和内容上,它们没有区别。

  2. 可以通过调用 cmake --preset=presetNamecmake --build --preset=presetNamectest --preset=presetName 来完成。

  3. 存在配置、构建、打包、测试和工作流预设。构建、测试和打包预设依赖于配置预设来确定 build 目录。

  4. 配置预设应定义名称、生成器和要使用的构建目录。

  5. 第一个设置值的预设具有优先权。

  6. 这可以通过使用编辑器对构建容器的本地支持、从容器内运行编辑器,或者每次启动容器以在其中调用单个命令来完成。

  7. 它们定义了系统名称、sysroot 的位置、要使用的编译器以及find_命令的行为方式。

第十章:在超级构建中处理分布式仓库和依赖项

正如我们现在应该已经了解的,每个大项目都有自己的依赖项。处理这些依赖项最简单的方法是使用包管理器,如Conanvcpkg。但是,使用包管理器并不总是可行的,可能是由于公司政策、项目需求或资源不足。因此,项目作者可能会考虑使用传统的老式方式来处理依赖项。处理这些依赖项的常见方式可能包括将所有依赖项嵌入到仓库的构建代码中。或者,项目作者可能决定让最终用户从头开始处理依赖项。这两种方式都不太清晰,各有缺点。如果我告诉你有一个折衷方案呢?欢迎使用超级构建方法。

超级构建是一种可以用于将满足依赖关系所需的逻辑与项目代码解耦的方法,就像包管理器的工作原理一样。事实上,我们可以把这种方法称为穷人的包管理器。将依赖逻辑与项目代码分离,使我们能够拥有更灵活、更易于维护的项目结构。在本章中,我们将详细学习如何实现这一点。

为了理解本章分享的技巧,我们将涵盖以下主要内容:

  • 超级构建的要求和前提条件

  • 跨多个代码仓库构建

  • 确保超级构建中的版本一致性

让我们从技术要求开始。

技术要求

在深入本章之前,你应该已经掌握了第五章的内容,整合第三方库 和依赖管理。本章将采用以实例教学的方式,因此建议从github.com/PacktPublishing/CMake-Best-Practices---2nd-Edition/tree/main/chapter10获取本章的示例内容。所有示例都假设你将使用项目中提供的容器,项目地址为github.com/PacktPublishing/CMake-Best-Practices---2nd-Edition

让我们从检查超级构建的前提和要求开始学习。

超级构建的要求和前提条件

超级构建可以结构化为一个大型构建,构建多个项目,或者作为一个项目内部的子模块来处理依赖。因此,获取仓库的手段是必须的。幸运的是,CMake 提供了稳定且成熟的方式来实现这一点。举几个例子,ExternalProjectFetchContent 是处理外部依赖的最流行的 CMake 模块。在我们的示例中,我们将使用 FetchContent CMake 模块,因为它更简洁且易于处理。请注意,使用 CMake 提供的手段并不是强制要求,而是一种便捷方式。超级构建也可以通过使用版本控制系统工具来结构化,比如 git submodulegit subtree。由于本书的重点是 CMake,而 Git 对 FetchContent 的支持也相当不错,我们倾向于使用它。

现在就到这里。让我们继续学习如何构建跨多个代码仓库的项目。

跨多个代码仓库进行构建

软件项目,无论是直接的还是间接的,都涉及多个代码仓库。处理本地项目代码是最简单的,但软件项目很少是独立的。如果没有合适的依赖管理策略,事情可能会很快变得复杂。本章的第一个建议是如果可能的话,使用包管理器或依赖提供者。正如在 第五章 中所描述的,集成第三方库和依赖管理,包管理器大大减少了在依赖管理上花费的精力。如果你不能使用预构建的包管理器,你可能需要为你的项目创建一个专门的迷你包管理器,这就是所谓的 超级构建

超级构建主要用于使项目在依赖方面自给自足,也就是说,项目能够在不需要用户干预的情况下满足自身的依赖。拥有这样的能力对所有使用者来说都非常方便。为了演示这种技术,我们将从一个这种场景的示例开始。让我们开始吧。

创建超级构建的推荐方式 – FetchContent

我们将按照 Chapter 10``, Example 01 来进行这一部分的学习。让我们像往常一样,首先检查 Chapter 10``, Example 01CMakeLists.txt 文件。为了简便起见,前七行被省略了:

if(CH10_EX01_USE_SUPERBUILD)
  include(superbuild.cmake)
else()
  find_package(GTest 1.10.0 REQUIRED)
  find_package(benchmark 1.6.1 REQUIRED)
endif()
add_executable(ch10_ex01_tests)
target_sources(ch10_ex01_tests PRIVATE src/tests.cpp)
target_link_libraries(ch10_ex01_tests PRIVATE GTest::Main)
add_executable(ch10_ex01_benchmarks)
target_sources(ch10_ex01_benchmarks PRIVATE src
  /benchmarks.cpp)
target_link_libraries(ch10_ex01_benchmarks PRIVATE
  benchmark::benchmark)

如我们所见,这是一个简单的 CMakeLists.txt 文件,定义了两个目标,分别命名为 ch10_ex01_testsch10_ex01_benchmarks。这两个目标分别依赖于 Google Test 和 Google Benchmark 库。这些库通过超级构建或 find_package(…) 调用来查找和定义,具体取决于 CH10_EX01_USE_SUPERBUILD 变量。find_package(…) 路径是我们到目前为止所采用的方式。让我们一起检查超级构建文件 superbuild.cmake

include(FetchContent)
FetchContent_Declare(benchmark
    GIT_REPOSITORY https://github.com/google/benchmark.git
    GIT_TAG        v1.6.1
)
FetchContent_Declare(GTest
    GIT_REPOSITORY https://github.com/google/googletest.git
    GIT_TAG        release-1.10.0
)
FetchContent_MakeAvailable(GTest benchmark)
add_library(GTest::Main ALIAS gtest_main)

在第一行中,我们包含了FetchContent CMake 模块,因为我们将利用它来处理依赖关系。在接下来的六行中,使用FetchContent_Declare函数声明了两个外部目标,benchmarkGTest,并指示它们通过 Git 获取。因此,调用了FetchContent_MakeAvailable(…)函数以使声明的目标可用。最后,调用add_library(…)来定义一个名为GTest::Main的别名目标,指向gtest_main目标。这样做是为了保持find_package(…)和超级构建目标名称之间的兼容性。对于benchmark,没有定义别名目标,因为它的find_package(…)和超级构建目标名称已经兼容。

让我们通过调用以下命令来配置和构建示例:

cd chapter_10/ex01_external_deps
cmake -S ./ -B build -DCH10_EX01_USE_SUPERBUILD:BOOL=ON
cmake --build build/ --parallel $(nproc)

在前两行中,我们进入example10/ex01文件夹并配置项目。请注意,我们将CH10_EX01_USE_SUPERBUILD变量设置为ON,以启用超级构建代码。在最后一行,我们通过N个并行作业构建项目,其中Nnproc命令的结果。

由于替代的find_package(...)路径,构建在没有启用超级构建的情况下也能正常工作,前提是环境中有google test >= 1.10.0google benchmark >= 1.6.1。这将允许包维护者在不修补项目的情况下更改依赖项版本。像这样的细小定制点对于可移植性和可重复性非常重要。

接下来,我们将查看一个使用ExternalProject模块而不是FetchContent模块的超级构建示例。

传统方法 – ExternalProject_Add

FetchContent出现之前,大多数人通过使用ExternalProject_Add CMake 函数实现了超级构建方法。该函数由ExternalProject CMake 模块提供。在本节中,我们将通过ExternalProject_Add查看一个超级构建示例,以了解它与使用FetchContent模块的区别。

让我们一起看看Chapter 10, Example 02中的CMakeLists.txt`文件(注释和项目指令已省略):

# ...
include(superbuild.cmake)
add_executable(ch10_ex02_tests)
target_sources(ch10_ex02_tests PRIVATE src/tests.cpp)
target_link_libraries(ch10_ex02_tests PRIVATE catch2)

同样,这个项目是一个单元测试项目,包含一个 C++源文件,不过这次使用的是catch2而不是 Google Test。CMakeLists.txt文件直接包含了superbuild.cmake文件,定义了一个可执行目标,并将Catch2库链接到该目标。你可能已经注意到,这个示例没有使用FindPackage(...)来发现Catch2库。原因在于,与FetchContent不同,ExternalProject是在构建时获取并构建外部依赖项。由于Catch2库的内容在配置时不可用,我们无法在此使用FindPackage(...)FindPackage(…)在配置时运行,并需要包文件存在。让我们也看看superbuild.cmake

include(ExternalProject)
ExternalProject_Add(catch2_download
    GIT_REPOSITORY https://github.com/catchorg/Catch2.git
    GIT_TAG v2.13.9
    INSTALL_COMMAND ""
    # For disabling the warning that treated as an error
    CMAKE_ARGS -DCMAKE_CXX_FLAGS="-Wno-error=pragmas"
)
SET(CATCH2_INCLUDE_DIR ${CMAKE_CURRENT_BINARY_DIR}
  /catch2_download-
  prefix/src/catch2_download/single_include)
file(MAKE_DIRECTORY ${CATCH2_INCLUDE_DIR})
add_library(catch2 IMPORTED INTERFACE GLOBAL)
add_dependencies(catch2 catch2_download)
set_target_properties(catch2 PROPERTIES "INTERFACE_INCLUDE_
  DIRECTORIES" "${CATCH2_INCLUDE_DIR}")

superbuild.cmake 模块包含了 ExternalProject CMake 模块。代码调用了 ExternalProject_Add 函数来声明一个名为 catch2_download 的目标,并指定 GIT_REPOSITORYGIT_TAGINSTALL_COMMANDCMAKE_ARGS 参数。如你从前面的章节中回忆的那样,ExternalProject_Add 函数可以从不同的源获取依赖项。我们的示例是尝试通过 Git 获取依赖项。GIT_REPOSITORYGIT_TAG 参数分别用于指定目标 Git 仓库的 URL 和 git clone 后需要签出的标签。由于 Catch2 是一个 CMake 项目,因此我们需要提供给 ExternalProject_Add 函数的参数最少。ExternalProject_Add 函数默认知道如何配置、构建和安装一个 CMake 项目,因此无需 CONFIGURE_COMMANDBUILD_COMMAND 参数。空的 INSTALL_COMMAND 参数用于禁用并安装构建后的依赖项。最后一个参数 CMAKE_ARGS 用于向外部项目的配置步骤传递 CMake 参数。我们用它来抑制一个关于 Catch2 编译中遗留 pragma 的 GCC 警告(将其视为错误)。

ExternalProject_Add 命令将所需的库拉取到一个前缀路径并进行构建。因此,要使用已获取的内容,我们首先需要将其导入到项目中。由于我们不能使用 FindPackage(...) 让 CMake 来处理库的导入工作,因此我们需要做一些手动操作。其中一项工作是定义 Catch2 目标的 include 目录。由于 Catch2 是一个仅包含头文件的库,定义一个包含头文件的接口目标就足够了。我们声明了 CATCH2_INCLUDE_DIR 变量来设置包含 Catch2 头文件的目录。我们使用该变量来设置在此示例中创建的导入目标的 INTERFACE_INCLUDE_DIRECTORIES 属性。接下来,调用文件(MAKE_DIRECTORY ${CATCH2_INCLUDE_DIR})的 CMake 命令来创建包含目录。之所以这么做,是因为 ExternalProject_Add 的工作方式,Catch2 的内容直到构建步骤执行时才会出现。设置目标的 INTERFACE_INCLUDE_DIRECTORIES 需要确保给定的目录已经存在,所以我们通过这种小技巧来解决这个问题。在最后三行中,我们为 Catch2 声明了一个 IMPORTED INTERFACE 库,使该库依赖于 catch2_download 目标,并设置了导入库的 INTERFACE_INCLUDE_DIRECTORIES

让我们尝试配置并构建我们的示例,检查它是否能够正常工作:

cd chapter_10/ex02_external_deps_with_extproject
cmake -S ./ -B build
cmake --build build/ --parallel $(nproc)

如果一切顺利,你应该会看到类似于以下的输出:

[ 10%] Creating directories for 'catch2_download'
[ 20%] Performing download step (git clone) for
  'catch2_download'
Cloning into 'catch2_download'...
HEAD is now at 62fd6605 v2.13.9
[ 30%] Performing update step for 'catch2_download'
[ 40%] No patch step for 'catch2_download'
[ 50%] Performing configure step for 'catch2_download'
/* ... */
[ 60%] Performing build step for 'catch2_download'
/* ... */
[ 70%] No install step for 'catch2_download'
[ 80%] Completed 'catch2_download'
[ 80%] Built target catch2_download
/* ... */
[100%] Built target ch10_ex02_tests

好的,看来我们已经成功构建了测试可执行文件。让我们运行它,检查它是否正常工作,通过运行 ./``build/ch10_ex02_tests 可执行文件:

===========================================================
All tests passed (4 assertions in 1 test case)

接下来,我们将看到一个简单的 Qt 应用程序,使用来自超构建的 Qt 框架。

奖励 – 使用 Qt 6 框架与超构建

到目前为止,我们已经处理了那些占用空间较小的库。现在让我们尝试一些更复杂的内容,比如在超级构建中使用像 Qt 这样的框架。在这一部分,我们将跟随第十章,示例03进行操作。

重要提示

如果你打算在提供的 Docker 容器外尝试这个示例,可能需要安装一些 Qt 运行时所需的附加依赖项。Debian 类系统所需的包如下:libgl1-mesa-dev libglu1-mesa-dev '^libxcb.*-dev' libx11-xcb-dev libglu1-mesa-dev libxrender-dev libxi-dev libxkbcommon-dev libxkbcommon-x11-dev

该示例包含一个源文件main.cpp,它输出一个简单的 Qt 窗口应用程序,并带有一条消息。实现如下:

#include <qapplication.h>
#include <qpushbutton.h>
int main( int argc, char **argv )
{
    QApplication a( argc, argv );
    QPushButton hello( "Hello from CMake Best Practices!",
      0 );
    hello.resize( 250, 30 );
    hello.show();
    return a.exec();
}

我们的目标是能够编译这个 Qt 应用程序,而不需要用户自己安装 Qt 框架。超级构建应该自动安装 Qt 6 框架,并且应用程序应该能够使用它。接下来,让我们像往常一样看看这个示例的 CMakeLists.txt 文件:

if(CH10_EX03_USE_SUPERBUILD)
  include(superbuild.cmake)
else()
    set(CMAKE_AUTOMOC ON)
    set(CMAKE_AUTORCC ON)
    set(CMAKE_AUTOUIC ON)
    find_package(Qt6 COMPONENTS Core Widgets REQUIRED)
endif()
add_executable(ch10_ex03_simple_qt_app main.cpp)
target_compile_features(ch10_ex03_simple_qt_app PRIVATE
  cxx_std_11)
target_link_libraries(ch10_ex03_simple_qt_app Qt6::Core
  Qt6::Widgets)

和第一个示例一样,CMakeLists.txt 文件根据一个 option 标志包含了 superbuild.cmake 文件。如果用户选择使用超级构建,例如,它将包含超级构建模块。否则,依赖项将通过 find_package(...) 尝试在系统中找到。在最后的三行中,定义了一个可执行目标,为该目标设置了 C++ 标准,并将该目标与 QT6::CoreQT6::Widgets 目标链接。这些目标要么由超级构建定义,要么通过 find_package(...) 调用找到,具体取决于用户是否选择使用超级构建。接下来,让我们继续看看 superbuild.cmake 文件:

include(FetchContent)
message(STATUS "Chapter 10, example 03 superbuild enabled.
  Will try to satisfy dependencies for the example.")
set(FETCHCONTENT_QUIET FALSE) # Enable message output for
  FetchContent commands
set(QT_BUILD_SUBMODULES "qtbase" CACHE STRING "Submodules
  to build")
set(QT_WILL_BUILD_TOOLS on)
set(QT_FEATURE_sql off)
set(QT_FEATURE_network off)
set(QT_FEATURE_dbus off)
set(QT_FEATURE_opengl off)
set(QT_FEATURE_testlib off)
set(QT_BUILD_STANDALONE_TESTS off)
set(QT_BUILD_EXAMPLES off)
set(QT_BUILD_TESTS off)
FetchContent_Declare(qt6
    GIT_REPOSITORY https://github.com/qt/qt5.git
    GIT_TAG        v6.3.0
    GIT_SHALLOW TRUE
    GIT_PROGRESS TRUE # Since the clone process is lengthy,
      show progress of download
    GIT_SUBMODULES qtbase # The only QT submodule we need
)
FetchContent_MakeAvailable(qt6)

superbuild.cmake 文件使用 FetchContent 模块来获取 Qt 依赖项。由于获取和准备 Qt 可能需要较长时间,因此禁用了某些未使用的 Qt 框架功能。启用了 FetchContent 消息输出,以便更好地跟踪进度。让我们通过运行以下命令来配置并编译示例:

cd chapter_10/ex03_simple_qt_app/
cmake -S ./ -B build -DCH10_EX03_USE_SUPERBUILD:BOOL=ON
cmake --build build/ --parallel $(nproc)

如果一切如预期那样进行,你应该看到类似于这里展示的输出:

/*...*/
[ 11%] Creating directories for 'qt6-populate'
[ 22%] Performing download step (git clone) for
  'qt6-populate'
Cloning into 'qt6-src'...
/*...*/
[100%] Completed 'qt6-populate'
[100%] Built target qt6-populate
/*...*/
-- Configuring done
-- Generating done
/*...*/
[  0%] Generating ../../mkspecs/modules
  /qt_lib_widgets_private.pri
[  0%] Generating ../../mkspecs/modules
  /qt_lib_gui_private.pri
[  0%] Generating ../../mkspecs/modules/qt_lib_core_private.pri
/* ... */
[ 98%] Linking CXX executable ch10_ex03_simple_qt_app
[ 98%] Built target ch10_ex03_simple_qt_app
/*...*/

如果一切顺利,你已经成功编译了示例。让我们通过运行以下命令检查它是否正常工作:

./build/ch10_ex03_simple_qt_app

如果一切正常,一个小的图形界面窗口应该会弹出。该窗口应类似于下图所示:

图 10.1 – 简单的 Qt 应用程序窗口

图 10.1 – 简单的 Qt 应用程序窗口

解决了这个问题后,我们已经完成了如何在 CMake 项目中使用超级构建的讲解。接下来,我们将探讨如何确保超级构建中的版本一致性。

确保超级构建中的版本一致性

版本一致性是所有软件项目中的一个重要方面。正如你现在应该已经了解到的,软件世界中的一切都不是一成不变的。软件随着时间的发展而演变和变化。这些变化往往需要提前得到确认,要么通过对新版本运行一系列测试,要么通过对消费代码本身进行修改。理想情况下,上游代码的变化不应该影响现有构建的复现,除非我们希望它们产生影响。如果软件验证和测试已经针对某一组合完成,那么一个项目的x.y版本应该始终与z.q依赖版本一起构建。原因在于,即便没有 API 或 ABI 的变化,上游依赖中的最小变动也可能影响你的软件行为。如果没有提供版本一致性,你的软件将没有明确定义的行为。因此,提供版本一致性的方法非常关键。

在超构建中确保版本一致性取决于超构建的组织方式。对于通过版本控制系统获取的代码库来说,相对容易。与其克隆项目并按原样使用,不如切换到特定的分支或标签。如果没有这些锚点,可以切换到特定的提交。这样可以确保你的超构建具备前瞻性。但即使这样也可能不足够。标签可能被覆盖,分支可能被强制推送,历史可能被重写。为了降低这种风险,你可以选择分叉项目并使用该分叉作为上游。这样,你就可以完全控制上游内容。但请记住,这种方法带来了维护的负担。

这个故事的寓意是,不要盲目跟踪上游。始终关注最新的变化。对于作为归档文件使用的第三方依赖,始终检查它们的哈希摘要。通过这种方式,你可以确保你确实使用了项目的目标版本,如果有任何变化,你必须手动确认它。

总结

本章简要介绍了超构建的概念,以及如何利用超构建进行依赖管理。超构建是一种非侵入性且强大的依赖管理方式,适用于缺少包管理器的情况。

在下一章中,我们将详细探讨如何为苹果生态系统构建软件。由于苹果的封闭性以及 macOS 和 iOS 的紧密集成,在针对这些平台时需要考虑一些事项。

问题

完成本章后,你应该能够回答以下问题:

  1. 什么是超构建(super-build)?

  2. 我们在哪些主要场景下可以使用超构建(super-build)?

  3. 为了在超构建中实现版本一致性,可以采取哪些措施?

答案

  1. 超构建是一种构建软件项目的方法,跨越多个代码库。

  2. 在没有包管理器的情况下,我们希望让项目能够满足自身的依赖关系。

  3. 使用锚点,如分支、标签或提交哈希值。

第十一章:为 Apple 系统创建软件

在软件开发领域,为 Apple 平台——macOS、iOS、watchOS 和 tvOS——构建应用程序具有一套独特的要求和最佳实践。由于封闭的生态系统,Apple 平台有一些独特的构建特点,尤其是对于具有图形用户界面和更复杂库框架的应用程序。针对这些情况,Apple 使用了名为应用程序包(app bundle)和框架(framework)的特定格式。本章深入探讨了如何有效地使用 CMake 这一强大的跨平台构建系统,针对 Apple 生态系统进行开发。无论你是希望简化工作流程的资深开发者,还是渴望探索 Apple 特定开发的新人,本章将为你提供掌握这一过程所需的知识和工具。

在本章中,我们将涵盖以下主要内容:

  • 使用 XCode 作为 CMake 生成器

  • 创建应用程序包

  • 创建 Apple 库框架

  • 为 Apple Store 使用的软件进行签名

技术要求

与前面章节相同,示例在 macOS Sonoma 14.2 上使用 CMake 3.24 进行了测试,并可在以下编译器中运行:

  • Apple Clang 15 或更新版本

  • Xcode 15.4

有些示例要求你已为 Xcode 设置了代码签名,这需要一个已加入开发者计划的 Apple ID。

所有示例和源代码可以在本书的 GitHub 仓库中找到:github.com/PacktPublishing/CMake-Best-Practices---2nd-Edition

如果缺少任何软件,相应的示例将从构建中排除。

在 Apple 上使用 CMake 开发

在 macOS 上开发时,理解 Apple 的生态系统和开发期望非常重要。Apple 强烈鼓励开发者使用 Xcode 作为主要开发环境,CMake 可以为其生成构建指令。Xcode 是一个包含代码编辑器、调试器以及其他专为 macOS 和 iOS 开发设计的工具的综合工具套件。Apple 会频繁更新 Xcode,新增功能、修复 bug 以及提升性能。因此,开发者需要定期升级到最新版本的 Xcode,以确保兼容性并充分利用新功能。

以下命令为 Xcode 生成构建指令:

cmake -S . -B ./build -G Xcode

在运行前述命令后,CMake 将在构建目录中生成一个以 .xcodeproj 后缀的 Xcode 项目文件。该项目可以直接在 Xcode 中打开,或者通过调用以下命令,使用 CMake 构建项目:

cmake --build ./build

如果项目已由 CMake 更新,Xcode 将检测到更改并重新加载项目。

虽然 Xcode 是首选的 IDE,但并非唯一的选择。例如,Visual Studio CodeVS Code)是一个广受欢迎的替代方案,许多开发者使用它,因为它的多功能性和丰富的扩展生态系统。

虽然 Xcode 是推荐的 macOS 生成器,但也可以选择其他生成器,如 Ninja 或 Makefile。尽管它们缺乏一些 Apple 集成的特性,但它们轻量且也可以用于构建简单的应用程序和库。

CMake 中的 Xcode 生成器会创建可以直接在 Xcode 中打开和管理的项目文件。这确保了构建过程能够利用 Xcode 的功能,如优化的构建设置、资源管理以及与 macOS 特性的无缝集成。

Xcode 支持各种构建设置,这些设置可以通过 CMake 使用XCODE_ATTRIBUTE_<SETTING>属性进行配置。要获取所有可能设置的列表,可以参考 Apple 的开发者文档,或者运行以下构建目录调用:

xcodebuild -showBuildSettings

Xcode 属性可以全局设置,也可以针对每个目标进行设置,如下例所示:

set(CMAKE_XCODE_ATTRIBUTE_CODE_SIGN_IDENTITY "Apple Development")
set(CMAKE_XCODE_ATTRIBUTE_DEVELOPMENT_TEAM "12345ABC")
add_executable(ch11_hello_world_apple src/main.mm)
set_target_properties(ch11_hello_world_apple PROPERTIES
    XCODE_ATTRIBUTE_CXX_LANGUAGE_STANDARD «c++17»
    XCODE_ATTRIBUTE_ENABLE_HARDENED_RUNTIME[variant=Release] "YES"
)

如示例所示,Xcode 属性也可以仅为特定的构建变体设置,通过在[variant=ConfigName]后缀中指定,其中配置是常见的 CMake 构建变体,如DebugRelease。如果使用不同于 Xcode 的生成器来构建项目,这些属性将无效。要创建简单的应用程序,选择 Xcode 生成器并设置适当的属性就足够了。然而,要为 Apple 构建和分发更复杂的软件,就必须深入研究应用程序包和 Apple 框架。

Apple 应用程序包

Apple 应用程序包是 macOS 和 iOS 中用于打包和组织应用程序所需所有文件的目录结构。这些文件包括可执行二进制文件、资源文件(如图片和声音)、元数据(如应用程序的Info.plist文件)等。应用程序包使得分发和管理应用程序变得更加容易,因为它们将所有必要的组件封装在一个单独的目录中,用户可以像处理单个文件一样移动、复制或删除该目录。应用程序包在 Finder 中显示为单个文件,但实际上它们是具有特定结构的目录,如下所示:

MyApp.app
└── Contents
    ├── MacOS
    │   └── MyApp (executable)
    ├── Resources
    │   ├── ...
    ├── Info.plist
    ├── Frameworks
    │   └── ...
    └── PlugIns
        └── ...

值得注意的是,这个结构被扁平化处理,适用于 iOS、tvOS 和 watchOS 目标平台,这由 CMake 本身处理。要将可执行文件标记为一个应用包,需要将MACOSX_BUNDLE关键字添加到目标中,如下所示:

add_executable(myApp MACOSX_BUNDLE)

设置关键字会告诉 CMake 创建应用程序包的目录结构并生成一个Info.plist文件。Info.plist文件是应用程序包中的关键文件,因为它包含了该包的必要配置。CMake 会生成一个默认的Info.plist文件,在许多情况下它已经很好用了。然而,你可以通过指定模板文件的路径来使用自定义的Info.plist模板,方法是使用MACOSX_BUNDLE_INFO_PLIST属性,像这样在目标上指定:

set_target_properties(hello_world PROPERTIES
    MACOSX_BUNDLE_INFO_PLIST ${CMAKE_SOURCE_DIR}/Info.plist.in
)

模板文件使用与configure_file相同的语法,如第八章所述,使用 CMake 执行自定义任务。一些标识符会从 CMake 自动设置。下面是一个自定义Info.plist.in模板的示例:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>CFBundleIdentifier</key>
    <string>@MACOSX_BUNDLE_IDENTIFIER@</string>
    <key>CFBundleName</key>
    <string>@MACOSX_BUNDLE_NAME@</string>
    <key>CFBundleVersion</key>
    <string>@MACOSX_BUNDLE_VERSION@</string>
    <key>CFBundleExecutable</key>
    <string>@MACOSX_BUNDLE_EXECUTABLE@</string>
</dict>
</plist>

这样,一个基本的应用程序包就可以定义并构建了。虽然这在 Xcode 生成器下效果最好,但创建文件结构和Info.plist文件在其他生成器如 Ninja 或 Makefile 中也能工作。然而,如果应用程序包含界面构建器文件或故事板,Xcode 是唯一的选择。接下来我们来看看如何包含资源文件。

Apple 应用程序包的资源文件

Apple 提供了自己的 SDK 来构建应用程序,它支持故事板或界面构建器文件来定义用户界面。为了创建包,这些文件会被编译成 Apple 特有的资源格式,并放置在包中的适当位置。只有 Xcode 生成器支持自动处理这些文件,因此在这时,使用其他生成器几乎没有什么用处。

故事板和界面构建器源文件必须作为源文件包含在add_executable()target_sources()命令中。为了确保它们被自动编译并复制到包内的正确位置,这些源文件应该设置MACOSX_PACKAGE_LOCATION文件属性。为了避免重新输入所有文件名,将它们放入变量中会很方便:

set(resource_files
storyboards/Main.storyboard
storyboards/My_Other.storyboard
)
add_executable(ch11_app_bundle_storyboard MACOSX_BUNDLE src/main.mm)
target_sources(ch11_app_bundle_storyboard PRIVATE ${resource_files})
set_source_files_properties(${resource_files} PROPERTIES MACOSX_PACKAGE_LOCATION Resources)

到此为止,你应该已经设置好创建 Apple 应用程序包来生成可执行文件了。如果你想为 Apple 构建易于重新分发的复杂库,那么使用框架是最佳选择。

Apple 框架

macOS 框架是用于简化 macOS 应用程序开发的可重用类、函数和资源的集合。它们提供了一种结构化的方式来访问系统功能,并与 macOS 无缝集成。

链接框架与链接普通库非常相似。首先,使用find_packagefind_library找到它们,然后使用target_link_libraries,只是语法稍有不同。以下示例将Cocoa框架链接到ch11_hello_world_apple目标:

target_link_libraries(ch11_hello_world_apple PRIVATE
"-framework Cocoa")

请注意,–framework关键字是必要的,而且框架的名称应该加引号。如果链接多个框架,它们都需要分别加引号。

既然我们可以使用框架,接下来让我们来看看如何创建自己的框架。框架与应用程序包非常相似,但有一些区别。特别是,在 macOS 上可以安装同一框架的多个版本。除了资源之外,框架还包含使用它所需的头文件和库。让我们来看看框架的文件结构,以便理解框架如何支持多个版本,并填充头文件:

MyFramework.framework/
│
├── Versions/
│   ├── Current -> A/
│   ├── A/
│   │   ├── Headers
│   │   │    └── ...
│   │   ├── PrivateHeaders
│   │   │    └── ...
│   │   ├── Resources
│   │   │    ├── Info.plist
│   │   │    └── ...
│   │   ├── MyFramework
├── MyFramework -> Versions/Current/MyFramework
├── Resources -> Versions/Current/Resources
├── Headers -> Versions/Current/Headers
├── PrivateHeaders -> Versions/Current/PrivateHeaders
└── Info.plist -> Versions/Current/Resources/Info.plist

框架的顶层必须以 .framework 结尾,通常,除了 Versions 文件夹外,所有顶层文件都是指向 Versions 文件夹中文件或文件夹的符号链接。

Versions 文件夹包含不同版本的库。目录的名称可以是任意的,但通常,它们会被命名为 ABC 等,或者使用数字版本。

无论使用哪种约定,都需要有一个名为 Current 的符号链接,并且该链接应该指向最新版本。每个版本必须有一个名为 Resources 的子文件夹,该文件夹包含如在应用程序包部分所述的 Info.plist 文件。

CMake 支持通过设置目标的 FRAMEWORKFRAMEWORK_VERSION 属性来创建 macOS 框架:

add_library(ch11_framework_example SHARED)
set_target_properties(
    ch11_framework_example
    PROPERTIES FRAMEWORK TRUE
               FRAMEWORK_VERSION 1
               PUBLIC_HEADER include/hello.hpp
               PRIVATE_HEADER src/internal.hpp
)

由于框架通常包含头文件,因此可以使用 PUBLIC_HEADERPRIVATE_HEADER 属性来指定它们,以便将它们复制到正确的位置。可以使用 MACOSX_FRAMEWORK_INFO_PLIST 属性为框架设置自定义的 Info.plist 文件。如果没有提供自定义的 Info.plist 文件,CMake 将生成一个默认文件,在大多数情况下这是足够的。

到目前为止,我们已经介绍了构建 macOS 软件的基础知识,但有一点是缺失的,那就是对代码进行签名,以便将其发布到 Mac 上。

macOS 的代码签名

对于许多使用场景,创建未签名的应用或框架可能已经足够;然而,如果应用程序需要通过 macOS 的官方渠道进行分发,则必须进行签名。签名可以通过 Xcode 本身进行;但是,也可以使用 CMake 来进行签名。

要进行签名,需要三个信息:包或框架的 ID开发团队 ID代码签名实体。可以使用 XCODE_ATTRIBUTE_DEVELOPMENT_TEAMXCODE_ATTRIBUTE_CODE_SIGN_ENTITY Xcode 属性来设置这些值。通常,这些设置是在项目级别进行,而不是针对单独的目标:

set(CMAKE_XCODE_ATTRIBUTE_CODE_SIGN_IDENTITY "Apple Development" CACHE STRING "")
set(CMAKE_XCODE_ATTRIBUTE_DEVELOPMENT_TEAM "12345ABC" CACHE STRING "")

签名身份表示证书提供者,通常可以保持为 "Apple Development",这将使 Xcode 选择适当的签名身份。在 Xcode 11 之前,签名身份必须设置为 "Mac Developer"(用于 macOS)或 "iPhone Developer"(用于 iOS、tvOS 或 watchOS 应用程序)。团队 ID 是一个 10 位的代码,分配给 Apple 开发者帐户,可以在 Apple 的开发者门户网站上创建 (developer.apple.com)。可以通过 Xcode 下载签名证书。

总结

苹果生态系统在处理上有些特殊,因为其封闭的设计与 Linux 甚至 Windows 相比有很大不同,但尤其是在移动市场,如果不想失去一个重要市场,通常无法避免为 Apple 构建应用。通过本章中的信息,如创建应用程序包和框架以及签署软件,你应该能够开始为 Apple 部署应用程序。虽然为 Apple 构建应用需要使用 Xcode,并且可能还需要拥有 Apple 硬件,但这并不是所有其他平台的情况。CMake 擅长于平台独立性并能够跨不同平台构建软件,这正是我们将在下一章讨论的内容。

问题

  1. 哪种生成器最适合为 Apple 构建软件?

  2. 如何设置 Xcode 属性?

  3. 关于不同版本,应用程序包和框架之间有什么区别?

  4. 签署应用程序包或框架需要什么?

答案

  1. 尽管不同的生成器可以用于 Apple,推荐使用 Xcode。

  2. 通过设置CMAKE_XCODE_ATTRIBUTE_<ATTRIBUTE>变量或XCODE_ATTRIBUTE_<ATTRIBUTE>属性来实现。

  3. 每次只能安装一个版本的应用程序包,而可以同时安装多个版本的框架。

  4. 要为 Apple 签署软件,你需要一个已注册开发者计划的 Apple ID 和 Xcode。

第三部分 – 掌握细节

在这一部分,你将能够在跨平台项目中使用 CMake,重用 CMake 代码,优化和维护现有项目,并将非 CMake 项目迁移到 CMake。你还将了解如何为 CMake 项目做出贡献以及推荐的进一步阅读材料:

  • 第十二章**, 跨平台编译自定义工具链

  • 第十三章**, 重用 CMake 代码

  • 第十四章**,优化和维护 CMake 项目

  • 第十五章**,迁移到 CMake

  • 附录**,为 CMake 做出贡献和进一步阅读材料

第十二章:跨平台编译自定义工具链

CMake 的一个强大特性是它对跨平台软件构建的支持。简单来说,这意味着通过 CMake,可以将任何平台的项目构建为任何其他平台的软件,只要在运行 CMake 的系统上提供必要的工具。在构建软件时,我们通常谈论编译器和链接器,它们当然是构建软件的必需工具。然而,如果我们仔细看看,构建软件时通常还涉及一些其他工具、库和文件。统称这些工具、库和文件通常被称为 CMake 中的工具链。

到目前为止,本书中的所有示例都是针对 CMake 运行所在的系统构建的。在这些情况下,CMake 通常能很好地找到正确的工具链。然而,如果软件是为另一个平台构建的,通常必须由开发者指定工具链。工具链定义可能相对简单,仅指定目标平台,或者可能复杂到需要指定单个工具的路径,甚至是为了为特定芯片组创建二进制文件而指定特定的编译器标志。

在交叉编译的上下文中,工具链通常伴随有root文件夹,用于查找编译和链接软件所需的库和文件,以便将软件编译到预期的目标平台。

虽然交叉编译一开始可能让人感到害怕,但使用 CMake 正确配置时,它通常并不像看起来那样困难。本章将介绍如何使用工具链文件以及如何自己编写工具链文件。我们将详细探讨在软件构建的不同阶段涉及哪些工具。最后,我们将介绍如何设置 CMake,使其能够通过模拟器运行测试。

本章将涵盖以下主要内容:

  • 使用现有的跨平台工具链文件

  • 创建工具链文件

  • 测试交叉编译的二进制文件

  • 测试工具链的支持特性

本章结束时,你将熟练掌握如何处理现有的工具链,并了解如何使用 CMake 为不同平台构建和测试软件。我们将深入探讨如何测试编译器的某个特性,以确定它是否适合我们的用途。

技术要求

与前几章一样,示例是用 CMake 3.25 进行测试的,并在以下任一编译器上运行:

  • GNU 编译器集合 9GCC 9)或更新版本,包括用于arm 硬浮动armhf)架构的交叉编译器

  • Clang 12 或更新版本

  • Microsoft Visual Studio C++ 19MSVC 19)或更新版本

  • 对于 Android 示例,Android 原生开发工具包Android NDK)23b 或更新版本是必需的。安装说明可以在官方的 Android 开发文档中找到:developer.android.com/studio/projects/install-ndk

  • 对于 Apple 嵌入式示例,建议使用 Xcode 12 或更新版本,以及iOS 软件开发工具包 12.4iOS SDK 12.4)。

本书的所有示例和源代码都可以在 GitHub 仓库中找到。如果缺少任何软件,相应的示例将从构建中排除。仓库地址在这里:github.com/PacktPublishing/CMake-Best-Practices---2nd-Edition/

使用现有的跨平台工具链文件

当为多个平台构建软件时,最直接的方法是直接在目标系统上进行编译。其缺点是每个开发者必须有一个正在运行的目标系统来进行构建。如果这些是桌面系统,可能会相对顺利,但在不同的安装环境之间迁移以开发软件也会使开发者的工作流程变得非常繁琐。像嵌入式系统这样不太强大的设备,由于缺乏适当的开发工具,或者因为编译软件非常耗时,可能会非常不方便。

因此,从开发者的角度来看,更便捷的方式是使用交叉编译。这意味着软件工程师在自己的机器上编写代码并构建软件,但生成的二进制文件是为不同平台的。构建软件的机器和平台通常称为主机机器主机平台,而软件应运行的平台称为目标平台。例如,开发者在运行 Linux 的x64桌面机器上编写代码,但生成的二进制文件是为运行在arm64处理器上的嵌入式 Linux 系统设计的。因此,主机平台是x64 Linux,目标平台是arm64 Linux。要进行交叉编译软件,以下两项是必需的:

  • 一个能够生成正确格式二进制文件的工具链

  • 为目标系统编译的项目依赖项

工具链是一组工具,如编译器、链接器和归档器,用于生成在主机系统上运行,但为目标系统生成输出的二进制文件。依赖项通常会收集在一个sysroot目录中。Sysroot 是包含根文件系统精简版的目录,所需的库会存储在其中。对于交叉编译,这些目录作为搜索依赖项的根目录。

一些工具,例如 CMAKE_TOOLCHAIN_FILE 变量,或者从 CMake 3.21 开始,使用 --toolchain 选项,像这样:

cmake -DCMAKE_TOOLCHAIN_FILE=toolchain.cmake -S <SourceDir> -B
  <BuildDir>
cmake  --toolchain arm64.toolchain.cmake -S <SourceDir> -B <BuildDir>

这些调用是等效的。如果 CMAKE_TOOLCHAIN_FILE 被设置为环境变量,CMake 也会进行解析。如果使用 CMake 预设,配置预设可能会通过 toolchainFile 选项配置工具链文件,像这样:

{
    "name": "arm64-build-debug",
    "generator" : "Ninja",
    "displayName": "Arm 64 Debug",
    "toolchainFile": "${sourceDir}/arm64.toolchain.cmake",
    "cacheVariables": {
        "CMAKE_BUILD_TYPE": "Debug"
    }
},

toolchainFile选项支持宏扩展,具体描述请参见第九章创建可复现的构建环境。如果工具链文件的路径是相对路径,CMake 会先在build目录下查找,如果在那里没有找到文件,它会从源目录开始查找。由于CMAKE_TOOLCHAIN_FILE是一个缓存变量,它只需要在第一次运行 CMake 时指定;之后的运行将使用缓存的值。

在第一次运行时,CMake 会执行一些内部查询来确定工具链支持哪些功能。这无论是否使用工具链文件指定工具链,或使用默认系统工具链时,都会发生。有关这些测试是如何执行的更深入的介绍,请参考测试工具链支持的功能部分。CMake 将在第一次运行时输出各种功能和属性的测试结果,类似如下:

-- The CXX compiler identification is GNU 9.3.0
-- The C compiler identification is GNU 9.3.0
-- Detecting CXX compiler ABI info
-- Detecting CXX compiler ABI info - done
-- Check for working CXX compiler: /usr/bin/arm-linux-gnueabihf-g++-
  9 - skipped
-- Detecting CXX compile features
-- Detecting CXX compile features - done
-- Detecting C compiler ABI info
-- Detecting C compiler ABI info - done
-- Check for working C compiler: /usr/bin/arm-linux-gnueabi-gcc-9 -
  skipped
-- Detecting C compile features
-- Detecting C compile features - done

功能检测通常发生在CMakeLists.txt文件中的第一次调用project()时。但是,任何启用先前禁用的语言的后续project()调用都会触发进一步的检测。如果在CMakeLists.txt文件中使用enable_language()来启用额外的编程语言,也会发生同样的情况。

由于工具链的功能和测试结果是被缓存的,因此无法更改已配置构建目录的工具链。CMake 可能会检测到工具链已经更改,但通常情况下,替换缓存变量是不完全的。因此,在更改工具链之前,应该完全删除构建目录。

配置后切换工具链

在切换工具链之前,请始终完全清空构建目录。仅删除CMakeCache.txt文件是不够的,因为与工具链相关的内容可能会被缓存到不同的位置。如果你经常为多个平台构建项目,使用为每个工具链分配的独立构建目录可以显著加快开发过程。

CMake 的工作方式是一个项目应该使用相同的工具链来进行所有操作。因此,直接支持使用多个工具链的方式并不存在。如果确实需要这样做,那么需要将需要不同工具链的项目部分配置为子构建,具体方法请参见第十章在超构建中处理分布式仓库和依赖关系

工具链应尽可能保持精简,并且与任何项目完全解耦。理想情况下,它们可以在不同的项目中复用。通常,工具链文件是与用于交叉编译的任何 SDK 或 sysroot 一起捆绑的。然而,有时它们需要手动编写。

创建工具链文件

工具链文件一开始可能看起来令人害怕,但仔细检查后,它们通常相对简单。定义跨编译工具链很难的误解源于互联网上存在许多过于复杂的工具链文件示例。许多示例是为早期版本的 CMake 编写的,因此实现了许多额外的测试和检查,而这些现在已经是 CMake 的一部分。CMake 工具链文件基本上做以下几件事:

  • 定义目标系统和架构。

  • 提供构建软件所需的任何工具的路径,这些工具通常只是编译器。

  • 为编译器和链接器设置默认标志。

  • 如果是跨编译,指向 sysroot 并可能指向任何暂存目录。

  • 设置 CMake find_ 命令的搜索顺序提示。更改搜索顺序是项目可能定义的内容,是否应将其放在工具链文件中或由项目处理是有争议的。有关 find_ 命令的详细信息,请参见 第五章集成第三方库和依赖管理

一个执行所有这些操作的示例工具链可能如下所示:

set(CMAKE_SYSTEM_NAME Linux)
set(CMAKE_SYSTEM_PROCESSOR arm)
set(CMAKE_C_COMPILER /usr/bin/arm-linux-gnueabi-gcc-9)
set(CMAKE_CXX_COMPILER /usr/bin/arm-linux-gnueabihf-g++-9)
set(CMAKE_C_FLAGS_INIT -pedantic)
set(CMAKE_CXX_FLAGS_INIT -pedantic)
set(CMAKE_SYSROOT /home/builder/raspi-sysroot/)
set(CMAKE_STAGING_PREFIX /home/builder/raspi-sysroot-staging/)
set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE BOTH)

这个示例会定义一个工具链,目标是为在主机系统的 /usr/bin/ 文件夹上运行的 Linux 操作系统进行构建。接着,编译器标志设置为打印由严格的 -pedantic 标志要求的所有警告。然后,设置 sysroot 以查找任何所需的库,路径为 /home/builder/raspi-sysroot/,并设置跨编译时用于安装内容的暂存目录为 /home/builder/raspi-sysroot-staging/。最后,改变 CMake 的搜索行为,使得程序仅在主机系统上搜索,而库、include 文件和包仅在 sysroot 中搜索。关于工具链文件是否应该影响搜索行为,存在争议。通常,只有项目知道它正在尝试查找什么,因此在工具链文件中做假设可能会破坏这一点。然而,只有工具链知道应该使用哪个系统根目录以及其中包含哪些类型的文件,因此让工具链来定义这一点可能会更方便。一个好的折中方法是使用 CMake 预设来定义工具链和搜索行为,而不是将其放在项目文件或工具链文件中。

定义目标系统

跨编译的目标系统由以下三个变量定义 – CMAKE_SYSTEM_NAMECMAKE_SYSTEM_PROCESSORCMAKE_SYSTEM_VERSION。它们分别对应 CMAKE_HOST_SYSTEM_NAMECMAKE_HOST_SYSTEM_PROCESSORCMAKE_HOST_SYSTEM_VERSION 变量,这些变量描述了构建所在平台的系统信息。

CMAKE_SYSTEM_NAME 变量描述了要构建软件的目标操作系统。设置这个变量很重要,因为它会导致 CMake 将 CMAKE_CROSSCOMPILING 变量设置为 true。常见的值有 LinuxWindowsDarwinAndroidQNX,你也可以使用更具体的平台名称,例如 WindowsPhoneWindowsCEWindowsStore。对于裸机嵌入式设备,CMAKE_SYSTEM_NAME 变量设置为 Generic。不幸的是,在写这篇文档时,CMake 文档中没有官方的支持系统列表。然而,如果需要,可以查看本地 CMake 安装中的 /Modules/Platform 文件夹中的文件。

CMAKE_SYSTEM_PROCESSOR 变量用于描述平台的硬件架构。如果未指定,将假定使用 CMAKE_HOST_SYSTEM_PROCESSOR 变量的值。在从 64 位平台交叉编译到 32 位平台时,即使处理器类型相同,也应该设置目标处理器架构。对于 Android 和 Apple 平台,通常不指定处理器。当为 Apple 目标交叉编译时,实际设备由使用的 SDK 定义,SDK 由 CMAKE_OSX_SYSROOT 变量指定。为 Android 交叉编译时,使用诸如 CMAKE_ANDROID_ARCH_ABICMAKE_ANDROID_ARM_MODE 和(可选的)CMAKE_ANDROID_ARM_NEON 等专用变量来控制目标架构。关于 Android 的构建会在 为 Android 交叉编译 部分中详细介绍。

定义目标系统的最后一个变量是 CMAKE_SYSTEM_VERSION。它的内容取决于构建的系统。对于 WindowsCEWindowsStoreWindowsPhone,它用于定义使用哪个版本的 Windows SDK。在 Linux 上,通常省略此项,或者如果相关,可能包含目标系统的内核版本。

使用 CMAKE_SYSTEM_NAMECMAKE_SYSTEM_PROCESSORCMAKE_SYSTEM_VERSION 变量,通常可以完全指定目标平台。然而,一些生成器,如 Visual Studio,直接支持其本地平台。对于这些平台,可以通过 CMake 的 -A 命令行选项来设置架构,方法如下:

cmake -G "Visual Studio 2019" -A Win32 -T host=x64

当使用预设时,architecture 设置可以在配置预设中使用,以达到相同的效果。一旦定义了目标系统,就可以定义用于实际构建软件的工具。

一些编译器,如 Clang 和 CMAKE_<LANG>_COMPILER_TARGET 变量也被使用。对于 Clang,值是目标三元组,如 arm-linux-gnueabihf,而对于 QNX GCC,编译器名称和目标的值如 gcc_ntoarmv7le。Clang 的支持三元组在其官方文档中有描述,网址为 clang.llvm.org/docs/CrossCompilation.html

对于 QNX 可用的选项,应该参考 QNX 文档,网址为 www.qnx.com/developers/docs/

所以,使用 Clang 的工具链文件可能如下所示:

set(CMAKE_SYSTEM_NAME Linux)
set(CMAKE_SYSTEM_PROCESSOR arm)
set(CMAKE_C_COMPILER /usr/bin/clang)
set(CMAKE_C_COMPILER_TARGET arm-linux-gnueabihf)
set(CMAKE_CXX_COMPILER /usr/bin/clang++)
set(CMAKE_CXX_COMPILER_TARGET arm-linux-gnueabihf)

在这个例子中,Clang 被用来编译运行在 ARM 处理器上的 Linux 系统的 C 和 C++ 代码,并且该系统支持硬件浮点运算。定义目标系统通常会直接影响将使用的构建工具。在下一节中,我们将探讨如何为交叉编译选择编译器及相关工具。

选择构建工具

在构建软件时,编译器通常是首先想到的工具,在大多数情况下,仅设置工具链文件中的编译器就足够了。编译器的路径由 CMAKE_<LANG>_COMPILER 缓存变量设置,可以在工具链文件中设置,也可以手动传递给 CMake。如果路径是绝对路径,则会直接使用;否则,将使用与 find_program() 相同的搜索顺序,这也是为什么在工具链文件中更改搜索行为时需要谨慎的原因之一。如果工具链文件和用户都没有指定编译器,CMake 将尝试根据指定的目标平台和生成器自动选择一个编译器。此外,编译器还可以通过与 <LANG> 对应的环境变量来设置。所以,C 用来设置 C 编译器,CXX 用来设置 C++ 编译器,ASM 用来设置汇编器,依此类推。

一些生成器,如 Visual Studio,可能支持其自定义的工具集定义,这些定义的工作方式不同。它们可以通过 -T 命令行选项进行设置。以下命令将告诉 CMake 为 Visual Studio 生成代码,以便为 32 位系统生成二进制文件,但使用 64 位编译器进行编译:

cmake -G "Visual Studio 2019" -A Win32 -T host=x64

这些值也可以通过工具链文件中的 CMAKE_GENERATOR_TOOLSET 变量进行设置。这个变量不应该在项目中设置,因为它显然不符合 CMake 项目文件与生成器和平台无关的原则。

对于 Visual Studio 用户,通过安装同一版本的预览版和正式版,可以在计算机上同时安装多个相同版本的 Visual Studio 实例。如果是这种情况,可以在工具链文件中将 CMAKE_GENERATOR_INSTANCE 变量设置为 Visual Studio 的绝对安装路径。

通过指定要使用的编译器,CMake 将为编译器和链接器选择默认标志,并通过设置CMAKE_<LANG>_FLAGSCMAKE_<LANG>_FLAGS_<CONFIG>使其在项目中可用,其中 <LANG> 代表相应的编程语言,<CONFIG> 代表构建配置,如调试或发布。默认的链接器标志由 CMAKE_<TARGETTYPE>_LINKER_FLAGSCMAKE_<TARGETTYPE>_LINKER_FLAGS_<CONFIG> 变量设置,其中 <TARGETTYPE> 可以是 EXESTATICSHAREDMODULE

要向默认标志添加自定义标志,可以使用带有 _INIT 后缀的变量—例如,CMAKE_<LANG>_FLAGS_INIT。在使用工具链文件时,_INIT 变量用于设置任何必要的标志。一个从 64 位主机为 32 位目标进行 GCC 编译的工具链文件可能如下所示:

set(CMAKE_SYSTEM_NAME Linux)
set(CMAKE_SYSTEM_PROCESSOR i686)
set(CMAKE_C_COMPILER  gcc)
set(CMAKE_CXX_COMPILER g++)
set(CMAKE_C_FLAGS_INIT -m32)
set(CMAKE_CXX_FLAGS_INIT -m32)
set(CMAKE_EXE_LINKER_FLAGS_INIT -m32)
set(CMAKE_SHARED_LINKER_FLAGS_INIT -m32)
set(CMAKE_STATIC_LINKER_FLAGS_INIT -m32)
set(CMAKE_MODULE_LINKER_FLAGS_INIT -m32)

对于简单的项目,设置目标系统和工具链可能已经足够开始创建二进制文件,但对于更复杂的项目,它们可能需要访问目标系统的库和头文件。对于这种情况,可以在工具链文件中指定 sysroot。

设置 sysroot

在进行交叉编译时,所有链接的依赖项显然也必须与目标平台匹配,一种常见的处理方法是创建一个 sysroot,它是目标系统的根文件系统,存储在一个文件夹中。虽然 sysroot 可以包含完整的系统,但通常会被精简到仅提供所需内容。sysroot 的详细描述见于 第九章创建可重现的 构建环境

设置 sysroot 通过将 CMAKE_SYSROOT 设置为其路径来完成。如果设置了该值,CMake 默认会首先在 sysroot 中查找库和头文件,除非另有说明,正如在 第五章集成第三方库和依赖管理 中所述。在大多数情况下,CMake 还会自动设置必要的编译器和链接器标志,以便工具与 sysroot 一起工作。

如果构建产物不应直接安装到 sysroot 中,可以设置 CMAKE_STAGING_PREFIX 变量以提供替代的安装路径。通常在以下情况时需要这样做:sysroot 应保持干净或当它被挂载为只读时。请注意,CMAKE_STAGING_PREFIX 设置不会将该目录添加到 CMAKE_SYSTEM_PREFIX_PATH,因此,只有当工具链中的 CMAKE_FIND_ROOT_PATH_MODE_PACKAGE 变量设置为 BOTHNEVER 时,暂存目录中安装的内容才能通过 find_package() 找到。

定义目标系统并设置工具链配置、sysroot 和暂存目录通常是进行交叉编译所需的所有内容。两个例外是针对 Android 和 Apple 的 iOS、tvOS 或 watchOS 进行交叉编译。

针对 Android 进行交叉编译

过去,Android 的 NDK 与不同 CMake 版本之间的兼容性有时关系并不顺畅,因为 NDK 的新版本往往不再以与以前版本相同的方式与 CMake 协作。然而,从 r23 版本开始,这一情况得到了极大的改善,因为 Android NDK 现在使用 CMake 内部对工具链的支持。结合 CMake 3.21 或更高版本,为 Android 构建变得相对方便,因此推荐使用这些或更新的版本。关于 Android NDK 与 CMake 集成的官方文档可以在此处找到:developer.android.com/ndk/guides/cmake

从 r23 版本开始,NDK 提供了自己的 CMake 工具链文件,位于<NDK_ROOT>/build/cmake/android.toolchain.cmake,可以像任何常规的工具链文件一样使用。NDK 还包括所有必要的工具,以支持基于 Clang 的工具链,因此通常不需要定义其他工具。要控制目标平台,应通过命令行或使用 CMake 预设传递以下 CMake 变量:

  • ANDROID_ABI:指定armeabi-v7aarm64-v8ax86x86_64。在为 Android 进行交叉编译时,这个变量应该始终设置。

  • ANDROID_ARM_NEON:为armeabi-v7a启用 NEON 支持。该变量不会影响其他 ABI 版本。使用 r21 版本以上的 NDK 时,默认启用 NEON 支持,通常不需要禁用它。

  • ANDROID_ARM_MODE:指定是否为armeabi-v7a生成 ARM 或 Thumb 指令。有效值为thumbarm。该变量不会影响其他 ABI 版本。

  • ANDROID_LD:决定使用默认的链接器还是来自llvm的实验性lld。有效的值为defaultlld,但由于lld处于实验阶段,这个变量通常在生产构建中被省略。

  • ANDROID_PLATFORM:指定最低的$API_LEVELandroid-$API_LEVEL,或android-$API_LETTER格式,其中$API_LEVEL是一个数字,$API_LETTER是平台的版本代码。ANDROID_NATIVE_API_LEVEL是该变量的别名。虽然设置 API 级别并非严格必要,但通常会进行设置。

  • ANDROID_STL:指定使用哪种c++_static(默认值)、c++_sharednonesystem。现代 C++支持需要使用c++_sharedc++_staticsystem库仅提供newdelete以及 C 库头文件的 C++封装,而none则完全不提供 STL 支持。

调用 CMake 来配置 Android 构建的命令可能如下所示:

cmake -S . -B build --toolchain <NDK_DIR>/build/cmake/android
  .toolchain.cmake -DANDROID_ABI=armeabi-v7a -DANDROID_PLATFORM=23

这个调用将指定需要 API 级别 23 或更高的构建,这对应于 Android 6.0 或更高版本的 32 位 ARM 中央处理 单元 (CPU)。

使用 NDK 提供的工具链的替代方案是将 CMake 指向 Android NDK 的位置,这对于 r23 版本之后的 NDK 是推荐的方式。然后,目标平台的配置通过相应的 CMake 变量进行。通过将CMAKE_SYSTEM_NAME变量设置为android,并将CMAKE_ANDROID_NDK变量设置为 Android NDK 的位置,CMake 会被告知使用 NDK。这可以通过命令行或在工具链文件中完成。或者,如果设置了ANDROID_NDK_ROOTANDROID_NDK 环境变量,它们将被用作CMAKE_ANDROID_NDK的值。

当以这种方式使用 NDK 时,配置是通过定义变量来实现的,而不是直接调用 NDK 工具链文件时所用的CMAKE_等效变量,如下所示:

  • CMAKE_ANDROID_APICMAKE_SYSTEM_VERSION用于指定要构建的最低 API 级别

  • CMAKE_ANDROID_ARCH_ABI用于指示要使用的 ABI 模式

  • CMAKE_ANDROID_STL_TYPE指定要使用的 STL

配置 CMake 与 Android NDK 的示例工具链文件可能如下所示:

set(CMAKE_SYSTEM_NAME Android)
set(CMAKE_SYSTEM_VERSION 21)
set(CMAKE_ANDROID_ARCH_ABI arm64-v8a)
set(CMAKE_ANDROID_NDK /path/to/the/android-ndk-r23b)
set(CMAKE_ANDROID_STL_TYPE c++_static)

当使用 Visual Studio 生成器为 Android 进行交叉编译时,CMake 要求使用NVIDIA Nsight Tegra Visual Studio EditionVisual Studio for Android 工具,它们使用 Android NDK。使用 Visual Studio 构建 Android 二进制文件时,可以通过将CMAKE_ANDROID_NDK变量设置为 NDK 的位置,利用 CMake 的内置 Android NDK 支持。

随着 NDK 的最近版本和 3.20 及更高版本的 CMake,Android 的本地代码交叉编译变得更加简单。交叉编译的另一个特殊情况是当目标是 Apple 的 iOS、tvOS 或 watchOS 时。

为 iOS、tvOS 或 watchOS 进行交叉编译

推荐的为 Apple 的 iPhone、Apple TV 或 Apple 手表进行交叉编译的方式是使用 Xcode 生成器。苹果对用于这些设备构建应用的工具有相当严格的限制,因此需要使用 macOS 或运行 macOS 的虚拟机VM)。虽然使用 Makefiles 或 Ninja 文件也是可能的,但它们需要更深入的苹果生态系统知识才能正确配置。

为这些设备进行交叉编译时,需要使用 Apple 设备的 SDK,并将CMAKE_SYSTEM_NAME变量设置为iOStvOSwatchOS,如下所示:

cmake -S <SourceDir> -B <BuildDir> -G Xcode -DCMAKE_SYSTEM_NAME=iOS

对于合理现代的 SDK 和 CMake 版本为 3.14 或更高版本时,通常这就是所需要的所有配置。默认情况下,系统上可用的最新设备 SDK 将被使用,但如果需要,可以通过将CMAKE_OSX_SYSROOT变量设置为 SDK 路径来选择不同的 SDK。如果需要,还可以通过CMAKE_OSX_DEPLOYMENT_TARGET变量指定最低目标平台版本。

在为 iPhone、Apple TV 或 Apple Watch 进行交叉编译时,目标可以是实际设备,也可以是随不同 SDK 提供的设备模拟器。然而,Xcode 内置支持在构建过程中切换目标,因此 CMake 不需要运行两次。如果选择了 Xcode 生成器,CMake 会内部使用 xcodebuild 命令行工具,该工具支持 -sdk 选项来选择所需的 SDK。在通过 CMake 构建时,可以像这样传递此选项:

cmake -build <BuildDir> -- -sdk <sdk>

这将把指定的 -sdk 选项传递给 xcodebuild。允许的值包括 iOS 的 iphoneosiphonesimulator,Apple TV 设备的 appletvosappletvsimulator,以及 Apple Watch 的 watchoswatchsimulator

Apple 嵌入式平台要求对某些构建产物进行强制签名。对于 Xcode 生成器,开发团队 CMAKE_XCODE_ATTRIBUTE_DEVELOPMENT_TEAM 缓存变量。

在为 Apple 嵌入式设备构建时,模拟器非常有用,可以在无需每次都将代码部署到设备上的情况下进行测试。在这种情况下,测试最好通过 Xcode 或 xcodebuild 本身来完成,但对于其他平台,交叉编译的代码可以直接通过 CMake 和 CTest 进行测试。

测试交叉编译的二进制文件

能够轻松地为不同架构交叉编译二进制文件,为开发者的工作流程带来了极大的便利,但通常这些工作流程不仅仅局限于构建二进制文件,还包括运行测试。如果软件也可以在主机工具链上编译,并且测试足够通用,那么在主机上运行测试可能是测试软件的最简单方式,尽管这可能会在切换工具链和频繁重建时浪费一些时间。如果这不可行或过于耗时,当然可以选择在实际目标硬件上运行测试,但这取决于硬件的可用性和在硬件上设置测试的工作量,这可能会变得相当繁琐。因此,通常可行的折中方法是,如果有模拟器可用,在目标平台的模拟器中运行测试。

要定义用于运行测试的仿真器,使用CROSSCOMPILING_EMULATOR目标属性。它可以为单个目标设置,也可以通过设置CMAKE_CROSSCOMPILING_EMULATOR缓存变量来全局设置,该变量包含一个用分号分隔的命令和参数列表,用于运行仿真器。如果全局设置,则该命令将被添加到add_test()add_custom_command()add_custom_target()中指定的所有命令之前,并且它将用于运行任何由try_run()命令生成的可执行文件。这意味着所有用于构建的自定义命令也必须能够在仿真器中访问并运行。CROSSCOMPILING_EMULATOR属性不一定必须是一个实际的仿真器——它可以是任何任意程序,例如一个将二进制文件复制到目标机器并在那里执行的脚本。

设置CMAKE_CROSSCOMPILING_EMULATOR应该通过工具链文件、命令行或配置的前缀进行。一个用于交叉编译 C++代码到 ARM 的工具链文件示例如下,它使用流行的开源仿真器QEMU来运行测试:

set(CMAKE_SYSTEM_NAME Linux)
set(CMAKE_SYSTEM_PROCESSOR arm)
set(CMAKE_SYSROOT /path/to/arm/sysroot/)
set(CMAKE_CXX_COMPILER /usr/bin/clang++)
set(CMAKE_CXX_COMPILER_TARGET arm-linux-gnueabihf)
set(CMAKE_CROSSCOMPILING_EMULATOR "qemu-arm;-L;${CMAKE_SYSROOT}")

除了设置目标系统和工具链的交叉编译信息外,示例中的最后一行将emulator命令设置为qemu-arm -L /path/to/arm/sysroot。假设一个CMakeLists.txt文件中包含如下定义的测试:

add_test(NAME exampleTest COMMAND exampleExe)

当运行 CTest 时,不是直接运行exampleExe,而是将test命令转换为如下形式:

qemu-arm "-L" "/path/to/arm/sysroot/" "/path/to/build-dir/
  exampleExe"

在仿真器中运行测试可以显著加速开发人员的工作流程,因为它可能消除了在主机工具链和目标工具链之间切换的需要,并且不需要将构建产物移动到目标硬件进行每个表面测试。像这样的仿真器也非常适合持续集成CI)构建,因为在真实的目标硬件上构建可能会很困难。

有关CMAKE_CROSSCOMPILING_EMULATOR的一个技巧是,它也可以用来临时将测试包装在诊断工具中,例如valgrind或类似的诊断工具。由于运行指定的仿真器可执行文件并不依赖于CMAKE_CROSSCOMPILING变量(该变量指示一个项目是否是交叉编译的),因此使用这个变通方法的一个常见陷阱是,设置CMAKE_CROSSCOMPILING_EMULATOR变量会影响try_run()命令,该命令通常用于测试工具链或任何依赖项是否支持某些功能,并且由于诊断工具可能导致编译器测试失败,因此可能需要在已经缓存的构建上运行它,其中try_run()的任何结果已经被缓存。因此,使用CMAKE_CROSSCOMPILING_EMULATOR变量运行诊断工具不应永久进行,而应在特定的开发情况下使用,例如在寻找缺陷时。

在本节中,我们提到过 CMake 的 try_run() 命令,它与密切相关的 try_compile() 命令一起,用于检查编译器或工具链中某些功能的可用性。在下一节中,我们将更详细地探讨这两个命令以及功能测试工具链。

测试工具链支持的功能

当 CMake 在项目树上首次运行时,它会执行各种编译器和语言功能的测试。每次调用 project()enable_language() 都会重新触发测试,但测试结果可能已经从之前的运行中缓存。缓存也是为什么在现有构建中切换工具链不推荐的原因。

正如我们将在本节中看到的,CMake 可以开箱即检查许多功能。大多数检查将内部使用 try_compile() 命令来执行这些测试。该命令本质上使用检测到的或由用户提供的工具链构建一个小的二进制文件。所有相关的全局变量,如 CMAKE_<LANG>_FLAGS,都将传递给 try_compile()

try_complie() 密切相关的是 try_run() 命令,它内部调用 try_compile(),如果成功,它将尝试运行程序。对于常规的编译器检查,不使用 try_run(),任何调用它的地方通常都在项目中定义。

为了编写自定义检查,建议使用 CheckSourceCompilesCheckSourceRuns 模块,而不是直接调用 try_compile()try_run(),这两个模块和相应的函数 check_source_compiles()check_source_runs() 命令自 CMake 3.19 版本以来就已可用。在大多数情况下,它们足以提供必要的信息,而无需更复杂地处理 try_compile()try_run()。这两个命令的签名非常相似,如下所示:

check_source_compiles(<lang> <code> <resultVar>
    [FAIL_REGEX <regex1> [<regex2>...]]  [SRC_EXT <extension>])
check_source_runs(<lang> <code> <resultVar>
[SRC_EXT <extension>])

<lang> 参数指定 CMake 支持的语言之一,如 C 或 C++。<code> 是作为字符串链接为可执行文件的代码,因此它必须包含一个 main() 函数。编译的结果将作为布尔值存储在 <resultVar> 缓存变量中。如果为 check_source_compiles 提供了 FAIL_REGEX,则将检查编译输出是否符合提供的表达式。代码将保存在具有与所选语言匹配的扩展名的临时文件中;如果文件的扩展名与默认值不同,则可以通过 SRC_EXT 选项指定。

还有语言特定版本的模块,称为 Check<LANG>SourceCompilesCheck<LANG>SourceRuns,它们提供相应的命令,如以下示例所示:

include(CheckCSourceCompiles)
check_c_source_compiles(code resultVar
  [FAIL_REGEX regexes...]
)
include(CheckCXXSourceCompiles)
check_cxx_source_compiles(code resultVar
  [FAIL_REGEX regexes...]
)

假设有一个 C++ 项目,它可能使用标准库的原子功能,或者如果不支持该功能,则回退到其他实现。针对这个功能的编译器检查可能如下所示:

include(CheckSourceCompiles)
check_source_compiles(CXX "
#include <atomic>
int main(){
    std::atomic<unsigned int> x;
    x.fetch_add(1);
    x.fetch_sub(1);
}" HAS_STD_ATOMIC)

在包含该模块后,check_source_compiles() 函数会与一个使用待检查功能的小程序一起调用。如果代码成功编译,HAS_STD_ATOMIC 将被设置为 true;否则,将被设置为 false。该测试会在项目配置期间执行,并打印类似如下的状态信息:

[cmake] -- Performing Test HAS_STD_ATOMIC
[cmake] -- Performing Test HAS_STD_ATOMIC - Success

结果会被缓存,以便后续运行 CMake 时不会再次执行该测试。在很多情况下,检查程序是否编译已经能提供足够的信息,表明工具链的某些特性,但有时需要运行底层程序以获取所需的信息。为此,check_source_runs() 类似于 check_source_compiles()check_source_runs() 的一个注意事项是,如果设置了 CMAKE_CROSSCOMPILING 但未设置模拟器命令,那么测试将只编译测试程序,而不会运行,除非设置了 CMAKE_CROSSCOMPILING_EMULATOR

有许多以 CMAKE_REQUIRED_ 开头的变量可以控制检查如何编译代码。请注意,这些变量缺少特定语言的部分,如果在进行跨语言测试时需要特别小心。以下是一些这些变量的解释:

  • CMAKE_REQUIRED_FLAGS 用于在 CMAKE_<LANG>_FLAGSCMAKE_<LANG>_FLAGS_<CONFIG> 变量中指定的任何标志之后,向编译器传递附加标志。

  • CMAKE_REQUIRED_DEFINITIONS 指定了多个编译器定义,形式为 -DFOO=bar

  • CMAKE_REQUIRED_INCLUDES 指定了一个目录列表,用于搜索额外的头文件。

  • CMAKE_REQUIRED_LIBRARIES 指定在链接程序时要添加的库列表。这些可以是库的文件名或导入的 CMake 目标。

  • CMAKE_REQUIRED_LINK_OPTIONS 是一个附加链接器标志的列表。

  • CMAKE_REQUIRED_QUIET 可以设置为 true,以抑制检查时的任何状态信息。

在需要将检查彼此隔离的情况下,CMakePushCheckState 模块提供了 cmake_push_check_state()cmake_pop_check_state()cmake_reset_check_state() 函数,用于存储配置、恢复先前的配置和重置配置,以下例子演示了这一点:

include(CMakePushCheckState)
cmake_push_check_state()
# Push the state and clean it to start with a clean check state
cmake_reset_check_state()
include(CheckCompilerFlag)
check_compiler_flag(CXX -Wall WALL_FLAG_SUPPORTED)
if(WALL_FLAG_SUPPORTED)
    set(CMAKE_REQUIRED_FLAGS -Wall)
    # Preserve -Wall and add more things for extra checks
    cmake_push_check_state()
        set(CMAKE_REQUIRED_INCLUDES ${CMAKE_CURRENT_SOURCE_DIR}/include)
        include(CheckSymbolExists)
        check_symbol_exists(hello "hello.hpp" HAVE_HELLO_SYMBOL)
    cmake_pop_check_state()
endif()
# restore all CMAKE_REQUIRED_VARIABLEs to original state
cmake_pop_check_state()

用于检查编译或运行测试程序的命令是更复杂的 try_compile()try_run() 命令。虽然它们可以使用,但主要用于内部,因此我们这里不做解释,而是参考命令的官方文档。

通过编译和运行程序来检查编译器特性是一种非常灵活的方法,用于检查工具链特性。有些检查非常常见,CMake 提供了专门的模块和函数来执行这些检查。

工具链和语言特性的常见检查

对于一些最常见的功能检查,例如检查编译器标志是否支持或头文件是否存在,CMake 提供了方便的模块。从 CMake 3.19 版本开始,提供了通用模块,可以将语言作为参数,但相应的Check<LANG>...特定语言模块仍然可以使用。

一个非常基础的测试,用于检查某个语言的编译器是否可用,可以通过CheckLanguage模块来完成。如果未设置CMAKE_<LANG>_COMPILER变量,它可以用来检查某个语言的编译器是否可用。例如,检查 Fortran 是否可用的示例如下:

include(CheckLanguage)
check_language(Fortran)
if(CMAKE_Fortran_COMPILER)
   enable_language(Fortran)
else()
   message(STATUS "No Fortran support")
endif()

如果检查成功,则会设置相应的CMAKE_<LANG>_COMPILER变量。如果在检查之前该变量已设置,则不会产生任何影响。

CheckCompilerFlag提供了check_compiler_flag()函数,用于检查当前编译器是否支持某个标志。在内部,会编译一个非常简单的程序,并解析输出以获取诊断信息。该检查假设CMAKE_<LANG>_FLAGS中已存在的任何编译器标志都能成功运行;否则,check_compiler_flag()函数将始终失败。以下示例检查 C++编译器是否支持-Wall标志:

include(CheckCompilerFlag)
check_compiler_flag(CXX -Wall WALL_FLAG_SUPPORTED)

如果-Wall标志被支持,则WALL_FLAG_SUPPORTED缓存变量将为true;否则为false

用于检查链接器标志的相应模块叫做CheckLinkerFlag,其工作方式与检查编译器标志类似,但链接器标志不会直接传递给链接器。由于链接器通常是通过编译器调用的,因此传递给链接器的额外标志可以使用如-Wl-Xlinker等前缀,告诉编译器将该标志传递过去。由于该标志是编译器特定的,CMake 提供了LINKER:前缀来自动替换命令。例如,要向链接器传递生成执行时间和内存消耗统计信息的标志,可以使用以下命令:

include(CheckLinkerFlag)
check_linker_flag(CXX LINKER:-stats LINKER_STATS_FLAG_SUPPORTED)

如果链接器支持-stats标志,则LINKER_STATS_FLAG_SUPPORTED变量将为true

其他有用的模块用于检查各种内容,包括CheckLibraryExistsCheckIncludeFileCheckIncludeFileCXX模块,用于检查某个库或包含文件是否存在于某些位置。

CMake 还提供了更多详细的检查,可能非常特定于某个项目——例如,CheckSymbolExistsCheckSymbolExistsCXX模块检查某个符号是否存在,无论它是作为预处理器定义、变量还是函数。CheckStructHasMember将检查结构体是否具有某个成员,而CheckTypeSize可以检查非用户类型的大小,并使用CheckPrototypeDefinition检查 C 和 C++函数原型的定义。

正如我们所见,CMake 提供了很多检查,随着 CMake 的发展,可用的检查列表可能会不断增加。虽然在某些情况下检查是有用的,但我们应该小心不要让测试的数量过多。检查的数量和复杂度将对配置步骤的速度产生很大影响,同时有时并不会带来太多好处。在一个项目中有很多检查,也可能意味着该项目存在不必要的复杂性。

总结

CMake 对交叉编译的广泛支持是其显著特点之一。在本章中,我们探讨了如何定义一个用于交叉编译的工具链文件,以及如何使用 sysroot 来使用不同目标平台的库。交叉编译的一个特殊案例是 Android 和苹果移动设备,它们依赖于各自的 SDK。通过简要介绍使用模拟器或仿真器测试其他平台,现在你已经掌握了所有必要的信息,可以开始为各种目标平台构建优质软件。

本章的最后部分讨论了测试工具链某些特性的高级话题。虽然大多数项目不需要关注这些细节,但了解这些内容依然很有用。

下一章将讨论如何让 CMake 代码在多个项目之间可重用,而不需要一遍又一遍地重写所有内容。

问题

  1. 工具链文件如何传递给 CMake?

  2. 通常在交叉编译的工具链文件中定义了什么?

  3. 在 sysroot 的上下文中,什么是中间目录?

  4. 如何将模拟器传递给 CMake 进行测试?

  5. 什么触发了编译器特性的检测?

  6. 如何存储和恢复编译器检查的配置上下文?

  7. CMAKE_CROSSCOMPILING 变量对编译器检查有什么影响?

  8. 为什么在切换工具链时应该完全清除构建目录,而不仅仅是删除缓存?

答案

  1. 工具链文件可以通过 --toolchain 命令行标志、CMAKE_TOOLCHAIN_FILE 变量,或者通过 CMake 预设中的 toolchainFile 选项传递。

  2. 通常,交叉编译的工具链文件中会做以下几件事:

    1. 定义目标系统和架构

    2. 提供构建软件所需的任何工具的路径

    3. 为编译器和链接器设置默认标志

    4. 指定 sysroot 和可能的任何中间目录(如果是交叉编译的话)

    5. 为 CMake 的 find_ 命令设置搜索顺序的提示

  3. 中间目录通过 CMAKE_STAGING_PREFIX 变量设置,作为安装任何已构建的工件的地方,如果 sysroot 不应被修改。

  4. 模拟器命令作为分号分隔的列表传递给 CMAKE_CROSSCOMPILING_EMULATOR 变量。

  5. 在项目中对 project()enable_language() 的任何调用都会触发特性检测。

  6. 编译器检查的配置上下文可以通过cmake_push_check_state()存储,并通过cmake_pop_check_state()恢复到先前的状态。

  7. 如果设置了CMAKE_CROSSCOMPILING,任何对try_run()的调用将会编译测试但不会运行,除非设置了模拟器命令。

  8. 构建目录应该完全清理,因为仅删除缓存时,编译器检查的临时产物可能不会正确重建。

第十三章:重用 CMake 代码

为项目编写构建系统代码并非易事。项目维护者和开发人员在编写 CMake 代码时会花费大量精力来配置编译器标志、项目构建变体、第三方库和工具集成。从头开始编写用于配置项目无关细节的 CMake 代码,在处理多个 CMake 项目时可能会开始带来显著的负担。为项目编写的这些 CMake 代码中的大部分内容,都是可以在多个项目之间重用的。考虑到这一点,我们有必要制定一个策略,使我们的 CMake 代码易于重用。解决这个问题的直接方法是将 CMake 代码视为常规代码,并应用一些最基本的编码原则:不要重复自己DRY)原则和单一责任SRP)原则。

如果按照重用性的思路来结构化 CMake 代码,那么 CMake 代码是可以轻松重用的。实现基本的重用性非常简单:将 CMake 代码拆分成模块和函数。你可能已经意识到,使 CMake 代码可重用的方法与使软件代码可重用的方法是一样的。记住——毕竟 CMake 本身就是一种脚本语言。所以,将 CMake 代码视为常规代码并应用软件设计原则是完全自然的。像任何功能性脚本语言一样,CMake 拥有以下基本重用能力:

  • 能够包含其他 CMake 文件

  • 函数/宏

  • 可移植性

在本章中,我们将学习如何编写具有重用性思想的 CMake 代码,并在 CMake 项目中重用 CMake 代码。我们还将讨论如何在项目之间进行版本管理和共享常用的 CMake 代码。

为了理解本章分享的技能,我们将涵盖以下主要主题:

  • 什么是 CMake 模块?

  • 模块的基本构建块——函数和宏

  • 编写你的第一个 CMake 模块

我们从技术要求开始。

技术要求

在深入本章之前,建议先阅读 第一章快速入门 CMake。本章采用以示例教学的方法,因此建议从这里获取本章的示例内容:github.com/PacktPublishing/CMake-Best-Practices---2nd-Edition/tree/main/chapter13。对于所有示例,假设你将使用该项目提供的容器:github.com/PacktPublishing/CMake-Best-Practices---2nd-Edition/

让我们首先了解一些 CMake 中重用性的基本知识。

什么是 CMake 模块?

一个Find*.cmake模块)。CMake 默认提供的模块列表可以在cmake.org/cmake/help/latest/manual/cmake-modules.7.html查看。官方的 CMake 文档将模块分为以下两大类:

  • 工具模块

  • 查找模块

正如它们的名字所示,工具模块提供工具,而查找模块则旨在搜索系统中的第三方软件。如你所记得,我们在第四章CMake 项目的打包、部署与安装》和第五章集成第三方库及依赖管理》中详细讨论了查找模块。因此,在本章中我们将专注于工具模块。你会记得,在前几章中我们使用了一些 CMake 提供的工具模块。我们使用的一些模块有GNUInstallDirsCPackFetchContentExternalProject。这些模块位于CMake安装目录下。

为了更好地理解工具模块的概念,让我们从研究 CMake 提供的一个简单的工具模块开始。为此,我们将研究ProcessorCount工具模块。你可以在github.com/Kitware/CMake/blob/master/Modules/ProcessorCount.cmake找到该模块的源文件。ProcessorCount模块是一个允许在 CMake 代码中获取系统 CPU 核心数的模块。ProcessorCount.cmake文件定义了一个名为ProcessorCount的 CMake 函数,它接受一个名为var的参数。该函数的实现大致如下:

function(ProcessorCount var)
  # Unknown:
  set(count 0)
  if(WIN32)
    set(count "$ENV{NUMBER_OF_PROCESSORS}")
  endif()
  if(NOT count)
    # Mac, FreeBSD, OpenBSD (systems with sysctl):
    # … mac-specific approach … #
  endif()
  if(NOT count)
    # Linux (systems with nproc):
    # … linux-specific approach … #
  endif()
# … Other platforms, alternative fallback methods … #
# Lastly:
set(${var} ${count} PARENT_SCOPE)
endfunction()

ProcessorCount函数尝试多种不同的方法来获取主机机器的 CPU 核心数。使用ProcessorCount模块非常简单,如下所示:

   include(ProcessorCount)
   ProcessorCount(CORE_COUNT)
   message(STATUS "Core count: ${CORE_COUNT}")

如你在上面的示例中所看到的,使用 CMake 模块就像将模块包含到所需的 CMake 文件中一样简单。include()函数是递归的,因此include行之后的代码可以使用模块中包含的所有 CMake 定义。

我们现在大致了解了一个工具模块的样子。接下来,继续学习更多关于工具模块的基本构建块:函数和宏。

模块的基本构建块——函数和宏

很明显,我们需要一些基本的构建块来创建工具模块。工具模块的最基本构建块是函数和宏,因此掌握它们的工作原理至关重要。让我们从学习函数开始。

函数

让我们回顾一下在 第一章《启动 CMake》中学到的关于函数的内容。function(…) 拥有一个包含 CMake 命令的函数体,并以 endfunction() CMake 命令结束。function() 命令的第一个参数需要是函数名,其后可以有可选的函数参数名,如下所示:

function(<name> [<arg1> ...])
  <commands>
endfunction()

函数定义了一个新的变量作用域,因此对 CMake 变量的修改仅在函数体内可见。独立作用域是函数的最重要特性。拥有新的作用域意味着我们无法意外地泄露变量给调用者或修改调用者的变量,除非我们愿意这么做。大多数情况下,我们希望将修改限制在函数的作用域内,并仅将函数的结果反映给调用者。由于 CMake 不支持返回值,我们将采取在调用者作用域中定义变量的方法来返回函数结果给调用者。

为了说明这种方法,我们定义一个简单的函数来一起获取当前的 Git 分支名称:

function(git_get_branch_name result_var_name)
  execute_process(
        COMMAND git symbolic-ref -q --short HEAD
        WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}"
        OUTPUT_VARIABLE git_current_branch_name
        OUTPUT_STRIP_TRAILING_WHITESPACE
        ERROR_QUIET
    )
    set(${result_var_name} ${git_current_branch_name}
      PARENT_SCOPE)
endfunction()

git_get_branch_name 函数接受一个名为 result_var_name 的单一参数。该参数是将被定义在调用者作用域中的变量名,用于返回 Git 分支名称给调用者。或者,我们可以使用一个常量变量名,比如 GIT_CURRENT_BRANCH_NAME,并去掉 result_var_name 参数,但如果项目已经使用了 GIT_CURRENT_BRANCH_NAME 这个名字,这可能会导致问题。

这里的经验法则是将命名留给调用者,因为这可以提供最大的灵活性和可移植性。为了获取当前的 Git 分支名称,我们通过 execute_process() 调用了 git symbolic-ref -q --short HEAD 命令。命令的结果存储在函数作用域内的 git_current_branch_name 变量中。由于该变量处于函数作用域内,调用者无法看到 git_current_branch_name 变量。因此,我们使用 set(${result_var_name} ${git_current_branch_name} PARENT_SCOPE) 来在调用者的作用域内定义一个变量,使用的是本地 git_current_branch_name 变量的值。

PARENT_SCOPE 参数改变了 set(…) 命令的作用域,使得它在调用者的作用域中定义变量,而不是在函数作用域中定义。git_get_branch_name 函数的用法如下:

git_get_branch_name(branch_n)
message(STATUS "Current git branch name is: ${branch_n}")

接下来我们来看看宏。

如果函数的作用域对你的使用场景是个难题,你可以考虑改用宏。宏以 macro(…) 开始,以 endmacro() 结束。函数和宏在各个方面表现得很相似,但有一个区别:宏不会定义新的变量作用域。回到我们的 Git 分支示例,考虑到 execute_process(…) 已经有了 OUTPUT_VARIABLE 参数,定义 git_get_branch_name 为宏而非函数会更方便,这样就可以避免在结尾使用 set(… PARENT_SCOPE)

macro(git_get_branch_name_m result_var_name)
  execute_process(
        COMMAND git symbolic-ref -q --short HEAD
        WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}"
        OUTPUT_VARIABLE ${result_var_name}
        OUTPUT_STRIP_TRAILING_WHITESPACE
        ERROR_QUIET
    )
endmacro()

git_get_branch_name_m宏的使用与git_get_branch_name()函数相同:

git_get_branch_name_m(branch_nn)
message(STATUS "Current git branch name is: ${branch_nn}")

我们已经学习了如何在需要时定义函数或宏。接下来,我们将一起定义第一个 CMake 模块。

编写你自己的第一个 CMake 模块

在上一节中,我们学习了如何使用函数和宏为 CMake 项目提供有用的工具。现在,我们将学习如何将这些函数和宏移到一个单独的 CMake 模块中。

创建和使用一个基本的 CMake 模块文件非常简单:

  1. 在你的项目中创建一个<module_name>.cmake文件。

  2. <module_name>.cmake文件中定义任何宏/函数。

  3. 在需要的文件中包含<module_name>.cmake

好的——让我们按照这些步骤一起创建一个模块。作为我们之前 Git 分支名称示例的后续,我们将扩大范围,编写一个 CMake 模块,提供通过git命令获取分支名称、头部提交哈希值、当前作者名称和当前作者电子邮件信息的能力。对于这一部分,我们将参考chapter13/ex01_git_utility的示例。示例文件夹包含一个CMakeLists.txt文件和一个位于.cmake文件夹下的git.cmake文件。让我们首先来看一下.cmake/git.cmake文件,文件内容如下:

# …
include_guard(DIRECTORY)
macro(git_get_branch_name result_var_name)
    execute_process(
        COMMAND git symbolic-ref -q --short HEAD
        WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}"
        OUTPUT_VARIABLE ${result_var_name}
        OUTPUT_STRIP_TRAILING_WHITESPACE
        ERROR_QUIET
    )
endmacro()
# … git_get_head_commit_hash(), git_get_config_value()

git.cmake文件是一个 CMake 实用模块文件,其中包含三个宏,分别为git_get_branch_namegit_get_head_commit_hashgit_get_config_value。此外,文件顶部有一个include_guard(DIRECTORY)行。这类似于 C/C++中的#pragma once预处理指令,防止该文件被多次包含。DIRECTORY参数表示include_guard在目录范围内定义,意味着该文件在当前目录及其下属目录中最多只能被包含一次。或者,也可以指定GLOBAL参数代替DIRECTORY,限制该文件无论作用域如何只被包含一次。

为了了解如何使用git.cmake模块文件,让我们一起查看chapter13/ex01_git_utilityCMakeLists.txt文件:

cmake_minimum_required(VERSION 3.21)
project(
  ch13_ex01_git_module
  VERSION 1.0
  DESCRIPTION "Chapter 13 Example 01, git utility module
    example"
  LANGUAGES CXX)
# Include the git.cmake module.
# Full relative path is given, since .cmake/ is not in the
  CMAKE_MODULE_PATH
include(.cmake/git.cmake)
git_get_branch_name(current_branch_name)
git_get_head_commit_hash(current_head)
git_get_config_value("user.name" current_user_name)
git_get_config_value("user.email" current_user_email)
message(STATUS "-----------------------------------------")
message(STATUS "VCS (git) info:")
message(STATUS "\tBranch: ${current_branch_name}")
message(STATUS "\tCommit hash: ${current_head}")
message(STATUS "\tAuthor name: ${current_user_name}")
message(STATUS "\tAuthor e-mail: ${current_user_email}")
message(STATUS "-----------------------------------------")

CMakeLists.txt文件通过指定模块文件的完整相对路径来包含git.cmake文件。该模块提供的git_get_branch_namegit_get_head_commit_hashgit_get_config_value宏分别用于获取分支名称、提交哈希值、作者名称和电子邮件地址,并将其存储到current_branch_namecurrent_headcurrent_user_namecurrent_user_email变量中。最后,通过message(…)命令将这些变量打印到屏幕上。让我们配置示例项目,看看我们刚刚编写的 Git 模块是否按预期工作:

cd chapter13/ex01_git_utility/
cmake -S ./ -B ./build

命令的输出应类似于以下内容:

-- The CXX compiler identification is GNU 9.4.0
-- Detecting CXX compiler ABI info
-- Detecting CXX compiler ABI info - done
-- Check for working CXX compiler: /usr/bin/c++ - skipped
-- Detecting CXX compile features
-- Detecting CXX compile features - done
-- -------------------------------------------
-- VCS (git) info:
--      Branch: chapter-development/chapter-13
--      Commit hash: 1d5a32649e74e4132e7b66292ab23aae
          ed327fdc
--      Author name: Mustafa Kemal GILOR
--      Author e-mail: mustafagilor@gmail.com
-- -------------------------------------------
-- Configuring done
-- Generating done
-- Build files have been written to:
/home/toor/workspace/ CMake-Best-Practices---2nd-Edition/chapter13
/ex01_git_utility/build

如我们所见,我们成功地从git命令中获取了信息。我们的第一个 CMake 模块按预期工作。

案例研究——处理项目元数据文件

让我们继续另一个例子。假设我们有一个环境文件,其中每行包含一个键值对。在项目中包含外部文件以存储有关项目的一些元数据(例如项目版本和依赖关系)并不罕见。该文件可以有不同的格式,例如 JSON 格式或换行分隔的键值对,如我们在此示例中所见。当前任务是创建一个工具模块,读取环境变量文件并为文件中的每个键值对定义一个 CMake 变量。文件的内容将类似于以下内容:

KEY1="Value1"
KEY2="Value2"

对于本节,我们将参考chapter13/ex02_envfile_utility示例。让我们从检查.cmake/envfile-utils.cmake的内容开始:

include_guard(DIRECTORY)
function(read_environment_file ENVIRONMENT_FILE_NAME)
    file(STRINGS ${ENVIRONMENT_FILE_NAME} KVP_LIST ENCODING
      UTF-8)
    foreach(ENV_VAR_DECL IN LISTS KVP_LIST)
        string(STRIP ENV_VAR_DECL ${ENV_VAR_DECL})
        string(LENGTH ENV_VAR_DECL ENV_VAR_DECL_LEN)
        if(ENV_VAR_DECL_LEN EQUAL 0)
            continue()
        endif()
        string(SUBSTRING ${ENV_VAR_DECL} 0 1
          ENV_VAR_DECL_FC)
        if(ENV_VAR_DECL_FC STREQUAL "#")
            continue()
        endif()
        string(REPLACE "=" ";" ENV_VAR_SPLIT
          ${ENV_VAR_DECL})
        list(GET ENV_VAR_SPLIT 0 ENV_VAR_NAME)
        list(GET ENV_VAR_SPLIT 1 ENV_VAR_VALUE)
        string(REPLACE "\"" "" ENV_VAR_VALUE
          ${ENV_VAR_VALUE})
        set(${ENV_VAR_NAME} ${ENV_VAR_VALUE} PARENT_SCOPE)
    endforeach()
endfunction()

envfile-utils.cmake工具模块包含一个函数read_environment_file,该函数读取一个键值对列表格式的环境文件。该函数将文件中的所有行读取到KVP_LIST变量中,然后遍历所有行。每一行都通过(=)等号符号进行分割,等号左边的部分作为变量名,右边的部分作为变量值,将每个键值对定义为一个 CMake 变量。空行和注释行会被跳过。至于模块的使用情况,让我们看看chapter13/ex02_envfile_utility/CMakeLists.txt文件:

# Add .cmake folder to the module path, so subsequent
  include() calls
# can directly include modules under .cmake/ folder by
  specifying the name only.
set(CMAKE_MODULE_PATH ${CMAKE_MODULE_PATH}
  ${PROJECT_SOURCE_DIR}/.cmake/)
add_subdirectory(test-executable)

你可能已经注意到,.cmake文件夹被添加到了CMAKE_MODULE_PATH变量中。CMAKE_MODULE_PATH变量是include(…)指令将在其中搜索的路径集合。默认情况下,它是空的。这允许我们直接按名称在当前和子CMakeLists.txt文件中包含envfile-utils模块。最后,让我们看一下chapter13/ex02_envfile_utility/test-executable/CMakeLists.txt文件:

# ....
# Include the module by name
include(envfile-utils)
read_environment_file("${PROJECT_SOURCE_DIR}/
  variables.env")
add_executable(ch13_ex02_envfile_utility_test)
target_sources(ch13_ex02_envfile_utility_test PRIVATE
  test.cpp)
target_compile_features(ch13_ex02_envfile_utility_test
  PRIVATE cxx_std_11)
target_compile_definitions(ch13_ex02_envfile_utility_test
  PRIVATE TEST_PROJECT_VERSION="${TEST_PROJECT_VERSION}"
    TEST_PROJECT_AUTHOR="${TEST_PROJECT_AUTHOR}")

如你所见,envfile-utils环境文件读取模块按名称被包含。这是因为包含envfile-utils.cmake文件的文件夹之前已经添加到CMAKE_MODULE_PATH变量中。read_environment_file()函数被调用来读取同一文件夹中的variables.env文件。variables.env文件包含以下键值对:

# This file contains some metadata about the project
TEST_PROJECT_VERSION="1.0.2"
TEST_PROJECT_AUTHOR="CBP Authors"

因此,在调用read_environment_file()函数之后,我们期望TEST_PROJECT_VERSIONTEST_PROJECT_AUTHOR变量在当前 CMake 作用域中定义,并且它们的相应值在文件中指定。为了验证这一点,定义了一个名为ch13_ex02_envfile_utility_test的可执行目标,并将TEST_PROJECT_VERSIONTEST_PROJECT_AUTHOR变量作为宏定义传递给该目标。最后,目标的源文件test.cppTEST_PROJECT_VERSIONTEST_PROJECT_AUTHOR宏定义打印到控制台:

#include <cstdio>
int main(void) {
    std::printf("Version `%s`, author `%s`\n",
      TEST_PROJECT_VERSION, TEST_PROJECT_AUTHOR);
}

好的——让我们编译并运行应用程序,看看它是否有效:

cd chapter13/ex02_envfile_utility
cmake -S ./ -B ./build
cmake --build build
./build/test-executable/ch13_ex02_envfile_utility_test
# Will output: Version `1.0.2`, author `CBP Authors`

正如我们所看到的,我们已成功地从源代码树中读取了一个键值对格式的文件,并将每个键值对定义为 CMake 变量,然后将这些变量作为宏定义暴露给我们的应用程序。

虽然编写 CMake 模块非常直接,但还是有一些额外的建议需要考虑:

  • 始终为函数/宏使用唯一的名称

  • 为所有模块函数/宏使用一个共同的前缀

  • 避免为非函数范围的变量使用常量名称

  • 使用 include_guard() 来保护您的模块

  • 如果您的模块输出消息,请为模块提供静默模式

  • 不要暴露模块的内部实现

  • 对于简单的命令包装器使用宏,对于其他情况使用函数

说到这里,我们结束了本章这一部分的内容。接下来,我们将探讨如何在项目间共享 CMake 模块。

关于项目间共享 CMake 模块的建议

共享 CMake 模块的推荐方式是维护一个独立的 CMake 模块项目,然后将该项目作为外部资源引入,可以通过 Git 子模块/子树或 CMake 的FetchContent进行,如在第五章中所描述,集成第三方库和依赖管理。在使用 FetchContent 时,可以通过设置 SOURCE_DIR 属性,并将 CMAKE_MODULE_PATH 设置为指定路径,轻松集成外部模块。这样,所有可重用的 CMake 工具可以集中在一个项目中进行维护,并可以传播到所有下游项目。将 CMake 模块放入在线 Git 托管平台(如 GitHub 或 GitLab)中的仓库,将使大多数人方便使用该模块。由于 CMake 支持直接从 Git 拉取内容,使用共享模块将变得非常简单。

为了演示如何使用外部 CMake 模块项目,我们将使用一个名为 hadouken 的开源 CMake 工具模块项目(github.com/mustafakemalgilor/hadouken)。该项目包含用于工具集成、目标创建和特性检查的 CMake 工具模块。

对于这一部分,我们将按照chapter13/ex03_external_cmake_module示例进行操作。该示例将获取hadouken

include(FetchContent)
FetchContent_Declare(hadouken
    GIT_REPOSITORY https://github.com/mustafakemalgilor
      /hadouken.git
    GIT_TAG        7d0447fcadf8e93d25f242b9bb251ecbcf67f8cb
    SOURCE_DIR "${CMAKE_CURRENT_LIST_DIR}/.hadouken"
)
FetchContent_MakeAvailable(hadouken)
list(APPEND CMAKE_MODULE_PATH ${CMAKE_CURRENT_LIST_DIR}/.hadouken/cmake/modules)
include(core/MakeTarget)

在前面的示例中,我们使用了 FetchContent_DeclareFetchContent_MakeAvailablehadouken 拉取到我们的项目中,并将其放置在 .hadouken 文件夹中的构建目录里。然后,将 hadouken 项目的模块目录添加到 CMAKE_MODULE_PATH 中,通过 include(…) 指令使用 hadouken 项目的 CMake 工具模块。这样,我们就能访问由导入的 CMake 模块提供的 make_target() 宏。我们已经共同完成了这一章的内容。接下来,我们将总结本章所学的知识,并展望下一章的内容。

总结

在本章中,我们学习了如何构建 CMake 项目以支持可重用性。我们学习了如何实现 CMake 工具模块,如何共享它们,以及如何使用别人编写的工具模块。能够利用 CMake 模块使我们能够更好地组织项目,并更有效地与团队成员协作。掌握这些知识后,CMake 项目将变得更易于维护。CMake 项目中常用的可重用代码将发展成一个庞大的有用模块库,使得使用 CMake 编写项目变得更容易。

我想提醒您,CMake 是一种脚本语言,应该像对待脚本语言一样来使用。采用软件设计原则和模式,使 CMake 代码更具可维护性。将 CMake 代码组织成函数和模块。尽可能地重用和共享 CMake 代码。请不要忽视您的构建系统代码,否则您可能需要从头开始编写。

在下一章中,我们将学习如何优化和维护 CMake 项目。

下一章见!

问题

完成本章后,您应该能够回答以下问题:

  1. 在 CMake 中,可重用性的最基本构建模块是什么?

  2. 什么是 CMake 模块?

  3. 如何使用 CMake 模块?

  4. CMAKE_MODULE_PATH变量的用途是什么?

  5. 分享 CMake 模块给不同项目的一种方式是什么?

  6. 在 CMake 中,函数和宏的主要区别是什么?

答案

  1. 函数和宏。

  2. CMake 模块是一个逻辑实体,包含 CMake 代码、函数和宏,用于特定目的。

  3. 通过将其包含在所需的作用域中。

  4. 要将额外的路径添加到include(…)指令的搜索路径中。

  5. 通过使用 Git 子模块/子树或 CMake 的FetchContent/ExternalProject模块。

  6. 函数定义了一个新的变量作用域;宏则没有。

第十四章:优化和维护 CMake 项目

软件项目通常会存在很长时间,对于某些项目来说,持续开发十年甚至更久并不罕见。但即使项目没有存在那么久,它们也会随着时间的推移而增长,并吸引某些杂乱和遗留的工件。通常,维护项目不仅仅是重构代码或偶尔添加功能,还包括保持构建信息和依赖项的最新状态。

随着项目复杂度的增加,构建时间往往会大幅增加,甚至到达开发变得乏味的程度,因为需要等待很长时间。长时间的构建不仅不方便,还可能促使开发人员采取捷径,因为它使得尝试新事物变得困难。如果每次构建需要数小时才能完成,而且每次推送到 CI/CD 管道需要数小时才能返回,这种情况就更糟糕了。

除了选择一个好的模块化项目结构来提高增量构建的有效性外,CMake 还提供了一些功能来帮助分析性能和优化构建时间。如果仅使用 CMake 还不够,使用编译器缓存 (ccache) 等技术来缓存构建结果或预编译头文件,进一步加速增量构建。

优化构建时间可以带来良好的效果,显著改善开发人员的日常工作,甚至可能成为节省成本的因素,因为 CI/CD 管道可能需要更少的资源来构建项目。然而,也有一些陷阱,过度优化的系统可能会变得脆弱,更容易崩溃,并且在某些情况下,为了优化构建时间,可能会牺牲项目的易维护性。

本章将介绍一些关于如何维护项目并结构化它们以便保持维护工作量的通用建议。然后,我们将深入分析构建性能,并看看如何加速构建。本章将涵盖以下主题:

  • 保持 CMake 项目的可维护性

  • CMake 构建的性能分析

  • 优化构建性能

技术要求

与前几章一样,所有示例都使用 CMake 3.21 进行了测试,并运行在以下任一编译器上:

  • GNU 编译器集合 (GCC) 9 或更新版本

  • Clang 12 或更新版本

  • Microsoft Visual C++ (MSVC) 19 或更新版本

要查看性能数据,需要第三方查看器来查看 Google 跟踪格式的数据;可以说,最广泛使用的是 Google Chrome。

使用ccache的示例已经在 Clang 和 GCC 上进行了测试,但没有在 MSVC 上测试过。要获得ccache,可以使用操作系统的包管理器,或从ccache.dev/获取。

所有示例可在github.com/PacktPublishing/CMake-Best-Practices---2nd-Edition/查看。

保持 CMake 项目的可维护性

在长时间维护 CMake 项目时,通常会出现一些经常性的任务。有一些常见的事情,比如新文件被添加到项目中,或者依赖版本的增加,这些通常通过 CMake 处理起来比较琐碎。接着,有些事情如添加新的工具链或跨平台编译,最后是 CMake 本身的更新,当新功能(如预设)可用时。

定期更新 CMake 并利用新功能有助于保持项目的可维护性。虽然通常不实际更新每一个新版本,但检查 CMake 的新功能并在其发布时使用它们,可能会使项目更易于维护。例如,CMake 3.19 版本引入的 CMake 预设就是这样一个具有潜力的功能,它可以使许多复杂的CMakeLists.txt文件变得更加简单。

保持依赖关系的更新和控制通常是维护人员忙碌的任务。在这方面,使用一致的依赖处理概念将使得维护项目变得更加容易。在这方面,我们建议使用包管理器,正如在第五章中所描述的,集成第三方库与依赖管理,对于任何项目(除非是非常小的项目)。由于包管理器旨在将管理依赖关系的复杂性转移到包管理器上,而不是暴露给维护人员,它们通常具有使维护人员的工作更轻松的巨大潜力。

保持项目可维护性的核心是选择有效的项目结构,使得各部分可以轻松找到,并且能够相互独立地改进。具体选择哪种结构,往往取决于项目的背景和规模,因此对一个项目有效的结构可能不适用于另一个项目。

保持大型项目可维护性的最大收益是使用适合需求的项目结构。虽然项目组织的细节取决于项目开发的实际情况,但有一些良好的实践可以帮助保持项目的概览。保持项目可维护性从项目的CMakeLists.txt根目录开始。对于大型项目,CMakeLists.txt根目录应处理以下内容:

  • 整个项目的基本设置,例如处理project()调用、获取工具链、支持程序和帮助库。这还包括设置语言标准、搜索行为以及项目范围的编译器标志和搜索路径。

  • 处理横向依赖,特别是像 Boost 和 Qt 这样的大型框架,应当放在项目的顶层。根据依赖关系的复杂性,创建并包含一个独立的CMakeLists.txt文件来处理获取这些依赖关系,可能有助于保持项目的可维护性。建议使用add_subdirectory来包含依赖项,而不是使用include,因为这样,任何用于查找依赖项的临时变量的作用域仅限于该子目录,除非它们显式标记为缓存变量。如果构建目标不止几个,将它们移到自己的子目录并使用add_subdirectory()来包含它们,将有助于保持单个文件小且自包含。追求松耦合和高内聚性的设计原则,将使得库和可执行文件更容易独立维护。文件和项目结构应反映这一点,这可能意味着项目中的每个库和可执行文件都需要拥有自己的CMakeLists.txt文件。

  • 单元测试是保持在与其测试对象接近的位置,还是作为根目录下tests文件夹的子文件夹,这取决于个人偏好。将测试保持在独立的子目录中,并为其设置自己的CMakeLists.txt文件,可以更容易地处理与测试相关的依赖项和编译器设置。

  • 项目的打包和安装说明应集中在项目的顶层。如果安装说明和打包说明过于庞大,可以将它们放入单独的CMakeLists.txt文件中,并从根目录的CMakeLists.txt文件中引用。

以这种方式组织项目结构,将简化项目内部的导航,并有助于避免在 CMake 文件中不必要的代码重复,特别是当项目随着时间推移变得更大时。

良好的项目设置可能决定了是否每天与构建系统作斗争,或者能够顺利运行。使用本书中的技术和实践将有助于使 CMake 项目具有可维护性。通过使用 CMake 预设和构建容器或系统根目录(sysroots),如在第九章中所述,创建可重现的构建 环境,以及第十二章中所述,跨平台编译和自定义工具链,将有助于使构建在开发者和 CI 系统之间更具可移植性。最后但同样重要的是,按照第十三章中所述,重用 CMake 代码,将自定义 CMake 代码组织成宏和函数,将有助于避免冗余和重复。

除了 CMake 文件的复杂性之外,随着项目规模的增长,较长的配置和构建时间往往是另一个问题。为了管理这些日益增长的构建和配置时间,CMake 提供了一些功能来优化它们。

CMake 构建的性能分析

当 CMake 项目变得庞大时,配置它们可能需要相当长的时间,尤其是如果加载了外部内容或进行了大量的工具链特性检查。优化的第一步是检查配置过程中哪些部分花费了多少时间。从版本 3.18 开始,CMake 包含了命令行选项来生成精美的分析图,以便调查配置过程中时间的分布。通过添加 --profiling-output--profiling-format 分析标志,CMake 将生成分析输出。写这本书时,仅支持 Google 跟踪格式作为输出格式。尽管如此,仍然需要指定格式和文件来生成分析信息。生成分析图的 CMake 调用可以像这样:

cmake -S <sourceDir> -B <buildDir> --profiling-output
./profiling.json --profiling-format=google-trace

这将把分析输出写入当前目录中的 profiling.json 文件。可以通过在地址栏中输入 about://tracing,使用 Google Chrome 查看输出文件。针对本书中的 GitHub 项目的缓存构建,跟踪输出可能如下所示:

图 14.1 – 在 Google Chrome 中显示的 CMake 项目示例分析图

图 14.1 – 在 Google Chrome 中显示的 CMake 项目示例分析图

在前面的图中,很明显有一个 add_subdirectory 调用占用了大部分配置项目时的时间。在这种情况下,这是 chapter5 子目录,花费了超过 3 秒的时间来完成。通过进一步深入分析,很明显这些是使用 Conan 包管理器的示例,特别是两个 conan_cmake_install 调用,使得配置过程相对较慢。在这种情况下,将对 Conan 的调用集中在更上层的目录中,将使得 CMake 配置运行的时间缩短一半。

为了正确解释分析输出,将不同的 CMake 运行进行比较是很有帮助的,特别是将清理缓存的 CMake 运行与利用缓存信息的运行进行比较。如果只有清理缓存的 CMake 运行花费了较长时间,但增量运行足够快,对于开发人员来说这可能仍然是可以接受的。然而,如果增量的 CMake 运行也花费了较长时间,这可能会更成问题。对其进行分析可能有助于找出每次配置运行中是否有不必要的步骤。

修复慢构建步骤将取决于具体情况,但长时间配置的常见原因是每次都会下载的文件,因为没有检查文件是否存在。分析分析调用通常会显示像execute_processtry_compile这样的调用占用了大量执行时间。最明显的修复方法是尝试去除这些调用,但通常这些调用是有原因的。更常见的是,跟踪导致这些命令的调用堆栈可能会揭示减少这些函数调用频率的机会。也许结果可以缓存,或者使用execute_process创建的文件不需要每次都生成。

尤其在交叉编译时,find_命令可能也会占用大量时间。通过更改不同的CMAKE_FIND_ROOT_PATH_MODE_变量来改变搜索顺序,正如在第五章《集成第三方库与依赖管理》中所述,可能在这里有一点帮助。为了更深入地分析为何find_调用占用过多时间,可以通过将CMAKE_FIND_DEBUG_MODE变量设置为true来启用调试输出。由于这会输出大量信息,因此最好只为特定的调用启用此功能,如下所示:

set(CMAKE_FIND_DEBUG_MODE TRUE)
find_package(...)
set(CMAKE_FIND_DEBUG_MODE FALSE)

CMake 的分析选项允许对构建过程的配置阶段进行分析;实际的编译和时间分析必须使用相应的生成器来完成。大多数生成器都支持某些分析选项或记录所需的信息。对于 Visual Studio 生成器,vcperf工具(github.com/microsoft/vcperf)将提供大量见解。在使用 Ninja 时,可以使用ninjatracing工具(github.com/nico/ninjatracing)将.ninja_log文件转换为 Google 跟踪格式。虽然 CMake 不提供分析实际编译和链接的软件的支持,但它确实提供了改进构建时间的方法,这将在下一节中看到。

优化构建性能

除了纯编译时间外,C++项目中构建时间长的主要原因通常是目标或文件之间不必要的依赖。如果目标之间存在不必要的链接要求,构建系统在执行构建任务时将无法并行化,一些目标将被频繁地重新链接。如在第六章《自动生成文档》中所述,创建目标的依赖图将有助于识别这些依赖关系。如果生成的图看起来更像是绳结而不是树形结构,那么优化和重构项目结构可能会带来大量性能提升。如在第七章《无缝集成代码质量工具与 CMake》中所述,使用include what you uselink what you use等工具,可能进一步帮助识别不必要的依赖关系。另一个常见的问题是 C 或 C++项目在公共头文件中暴露过多的私有信息,这通常导致频繁重建,降低增量构建的效率。

一个相对安全的选项来提升性能是将CMAKE_OPTIMIZE_DEPENDENCIES缓存变量设置为true。这将导致 CMake 在生成时移除一些静态或目标库的依赖,如果这些依赖不再需要。如果你处理大量静态或目标库,并且依赖关系图很深,这可能会在编译时间上带来一些收益。

一般来说,优化项目结构并将代码模块化,往往比代码优化对构建性能的影响更大。平均而言,编译和链接由许多小文件组成的项目,比由少数大文件组成的项目所需时间更长。CMake 可以通过所谓的统一构建来帮助提高构建性能,统一构建将多个文件合并成一个更大的文件。

使用统一构建

CMake 支持的统一构建可以通过将多个文件合并成较大的文件来帮助提高构建性能,从而减少需要编译的文件数量。这可能会减少构建时间,因为include文件只会处理一次,而不是每个小文件都处理一次。因此,如果许多文件包含相同的头文件,且这些头文件对编译器来说比较重(例如包含大量宏或模板元编程),这一做法会产生最大的效果。创建统一构建可能会显著提高构建时间,尤其是在使用大型头文件库(如 Eigen 数学库)时。另一方面,统一构建的缺点是增量构建可能会变得更慢,因为通常需要重新编译和链接更大的项目部分,即使只是单个文件发生了变化。

通过将CMAKE_UNITY_BUILD缓存变量设置为true,CMake 会将源文件合并成一个或多个 unity 源文件并进行构建,而不是使用原始文件。生成的文件遵循unity_<lang>_<Nr>.<lang>的命名模式,并位于构建目录中的Unity文件夹内。例如,C++的 unity 文件会命名为unity_0_cxx.cxxunity_1_cxx.cxx等,C 语言文件则命名为unity_0_c.c等。这个变量不应该在CMakeLists.txt文件中设置,而是通过命令行或预设传递,因为是否需要 unity 构建可能取决于上下文。CMake 会根据需要和可能性决定项目的语言。如果需要合并文件,CMake 会判断是否可以合并。比如,头文件不会被编译,因此不会被添加到 unity 源文件中。对于 C 和 C++来说,这种方法效果良好,但对于其他语言,可能无法正常工作。

Unity 构建最适用于由许多小文件组成的项目。如果源文件本身已经很大,unity 构建可能会面临编译时内存不足的风险。如果只有少数文件在这方面有问题,可以通过在源文件上设置SKIP_UNITY_BUILD_INCLUSION属性来将它们从 unity 构建中排除,像这样:

target_sources(ch14_unity_build PRIVATE
  src/main.cpp
  src/fibonacci.cpp
  src/eratosthenes.cpp
)
set_source_files_properties(src/eratosthenes.cpp PROPERTIES
SKIP_UNITY_BUILD_INCLUSION YES)

在示例中,eratosthenes.cpp文件将被排除在 unity 构建之外,而main.cppfibonacci.cpp将包含在一个编译单元中。如果之前的项目已配置,unit_0_cxx.cxx文件将包含类似以下内容:

/* generated by CMake */
#include "/chapter14/unity_build/src/main.cpp"
#include "/chapter14/unity_build/src/fibonacci.cpp"

请注意,原始源文件只会包含在 unity 文件中,而不会被复制到文件中。

从 CMake 3.18 开始,unity 构建支持两种模式,可以通过CMAKE_UNITY_BUILD_MODE变量或UNITY_BUILD_MODE目标属性来控制。模式可以是BATCHGROUP,如果未指定,则默认使用BATCH模式。在BATCH模式下,CMake 会默认按文件添加到目标的顺序来决定哪些文件组合在一起。除非显式排除,否则所有目标文件都会被分配到批处理中。在GROUP模式下,每个目标必须明确指定文件如何分组。未分配到任何组的文件将单独编译。虽然GROUP模式提供了更精确的控制,但通常推荐使用BATCH模式,因为它的维护开销要小得多。

默认情况下,当 UNITY_BUILD_MODE 属性设置为 BATCH 时,CMake 会将文件按每批八个文件进行收集。通过设置目标的 UNITY_BUILD_BATCH_SIZE 属性,可以更改这一点。要全局设置批大小,可以使用 CMAKE_UNITY_BUILD_BATCH_SIZE 缓存变量。批大小应谨慎选择,因为设置得太小对性能提升有限,而设置得过大会导致编译器使用过多内存或编译单元达到其他大小限制。如果批大小设置为 0,则所有目标文件将合并为一个批次,但由于之前提到的原因,不推荐这样做。

GROUP 模式下,不会应用批大小,但必须通过设置源文件的 UNITY_GROUP 属性将文件分配到组中,以下是一个示例:

add_executable(ch14_unity_build_group)
target_sources(ch14_unity_build_group PRIVATE
  src/main.cpp
  src/fibonacci.cpp
  src/eratosthenes.cpp
  src/pythagoras.cpp
)
set_target_properties(ch14_unity_build_group PROPERTIES
  UNITY_BUILD_MODE GROUP)
set_source_files_properties(src/main.cpp src/fibonacci.cpp
  PROPERTIES UNITY_GROUP group1)
set_source_files_properties(src/erathostenes.cpp
  src/pythagoras.cpp PROPERTIES UNITY_GROUP group2)

在这个示例中,main.cppfibonacci.cpp 文件会被归为一组,而 erathostenes.cpppythagoras.cpp 则会在另一个组中编译。在 GROUP 模式下,生成的文件会命名为 unity_<groupName>_<lang>.<lang>。因此,在这个示例中,文件将被命名为 unity_group1_cxx.cxxunity_group2_cxx.cxx

根据项目的结构,使用统一构建可能会显著提高构建性能。另一种常用于提高构建速度的技术是使用预编译头文件。

预编译头文件

预编译头文件通常对编译时间有显著提升,尤其是在处理头文件是编译时间的重要组成部分,或当头文件在多个编译单元中被包含时。简而言之,预编译头文件通过将一些头文件编译成二进制格式,从而使编译器更容易处理。自 CMake 3.16 起,已经直接支持预编译头文件,大多数主要编译器也支持某种形式的预编译头文件。

预编译头文件通过 target_precompile_headers 命令添加到目标中,其语法如下:

target_precompile_headers(<target>
  <INTERFACE|PUBLIC|PRIVATE> [header1...]
  [<INTERFACE|PUBLIC|PRIVATE> [header2...] ...])

PRIVATEPUBLICINTERFACE 关键字具有常见含义。在大多数情况下,应使用 PRIVATE。命令中指定的头文件将会被收集到 cmake_pch.hcmake_pch.hxx 文件中,该文件会通过相应的编译器标志强制包含到所有源文件中,因此源文件中无需添加 #include "cmake_pch.h" 指令。

头文件可以指定为普通文件名,带尖括号,或带双引号,在这种情况下,它们必须使用双中括号进行转义:

target_precompile_headers(SomeTarget PRIVATE myHeader.h
  [["external_header.h"]]
    <unordered_map>
)

在这个示例中,myHeader.h 会从当前的源代码目录中搜索,而 external_header.hunordered_map 则会在 include 目录中搜索。

在大型项目中,多个目标之间使用相同的预编译头文件是相对常见的。为了避免每次都重新定义它们,可以使用target_precompile_headersREUSE_FROM选项:

target_precompile_headers(<target> REUSE_FROM
  <other_target>)

重用预编译头文件会引入目标与other_target之间的自动依赖关系。两个目标将启用相同的编译器选项、标志和定义。一些编译器会在这种情况不符合时发出警告,但有些编译器则不会。

另一个目标的预编译头文件只有在当前目标没有定义自己的预编译头文件集时才能使用。如果目标已经定义了预编译头文件,CMake 将会报错并停止。

当包含的头文件很少更改时,预编译头文件在提高构建时间方面最为有效。由编译器、系统或外部依赖项提供的任何头文件通常都是合适的预编译头文件候选。哪些头文件确实带来最多的好处,需要通过试验和测量来确定。

与统一构建一起,预编译头文件可以显著提高编译时间,尤其是对于频繁重用头文件的项目。优化增量构建编译时间的第三种方式是使用编译器缓存,即ccache

使用编译器缓存(ccache)来加速重建

缓存通过缓存编译结果并检测相同编译是否再次进行来工作。在编写本书时,最受欢迎的编译缓存程序是ccache,它是开源的,并且在ccache程序下进行分发,ccache不仅影响增量构建,还会影响全新构建,只要缓存没有在两次运行之间被删除。创建的缓存可以在运行相同编译器的系统之间移植,并且可以存储在远程数据库中,以便多个开发人员可以访问相同的缓存。官方支持ccache与 GCC、Clang 以及ccache与 CMake 一起使用,最佳的配合方式是使用 Makefile 和 Ninja 生成器。写本书时,Visual Studio 尚不支持。

要在 CMake 中使用ccache,可以使用CMAKE_<LANG>_COMPILER_LAUNCHER缓存变量,其中<LANG>替换为相应的编程语言。推荐的做法是通过预设传递这个变量,但为了在CMakeLists.txt文件中启用 C 和 C++的ccache,可以使用以下代码:

find_program(CCACHE_PROGRAM ccache)
if(CCACHE_PROGRAM)
     set(CMAKE_C_COMPILER_LAUNCHER ${CCACHE_PROGRAM})
     set(CMAKE_CXX_COMPILER_LAUNCHER ${CCACHE_PROGRAM})
endif()

从预设或命令行传递变量也是一个不错的替代方案,特别是因为ccache的配置最容易通过使用环境变量来完成。

使用默认配置的ccache可能已经在构建时间上带来相当大的改进,但如果构建稍微复杂一些,可能需要进一步的配置。要配置ccache,可以使用一些以CCACHE_开头的环境变量;有关所有配置选项的完整文档,请参阅ccache文档。需要特别注意的常见场景包括将ccache与预编译头文件结合使用,管理通过FetchContent包含的依赖项,以及将ccache与其他编译器包装器结合使用,如distccicecc用于分布式构建。对于这些场景,将使用以下环境变量:

  • 为了高效地使用预编译头文件,设置CCACHE_SLOPPINESSpch_defines,time_macros。原因在于ccache无法检测到预编译头文件中#defines的变化,也无法判断在创建预编译头文件时是否使用了__TIME____DATE____TIMESTAMP__。可选地,将include_file_mtime设置为CCACHE_SLOPPINESS可能会进一步提高缓存命中性能,但它带有一个非常小的竞态条件风险。

  • 当包含从源代码构建的大型依赖项时(例如,使用FetchContent),将CCACHE_BASEDIR设置为CMAKE_BINARY_DIR可能会提高缓存命中率;特别是当有多个(子)项目获取相同的依赖项时,这可能会带来性能提升。另一方面,如果项目本身的源代码需要更多时间编译,将其设置为CMAKE_SOURCE_DIR可能会带来更好的结果。需要通过试验来确定哪种设置能带来更好的效果。

  • 要与其他编译器包装器一起使用,CCACHE_PREFIX 环境变量用于为这些包装器添加命令。建议在链式调用多个包装器时首先使用ccache,以便其他包装器的结果也可以被缓存。

通过使用配置预设将环境变量传递给 CMake,如在第九章中所述,创建可重现的构建环境,是推荐的方法;这可以与在CMakeLists.txt文件中检测ccache相结合,或者也可以通过以下预设将ccache命令传递:

{
"name" : "ccache-env",
...
  "environment": {
    "CCACHE_BASEDIR" : "${sourceDir}",
    "CCACHE_SLOPPINESS" : "pch_defines,time_macros"
  }
},

使用这些配置,使用ccache可以大大提高编译时间的效率,但缓存编译器结果是一个复杂的问题,因此为了获得最大的好处,应该查阅ccache文档。在大多数情况下,使用ccache可能通过相对简单的设置带来最大的性能提升。其他工具,如用于分布式构建的distcc,从 CMake 的角度看工作非常相似,但需要更多的配置工作。

分布式构建

分布式构建通过将部分编译任务分配给网络上的不同机器来工作。这要求设置能够接受连接的服务器,并配置客户端以便能够连接这些服务器。为distcc设置服务器的命令如下:

distccd --daemon --allow client1 client2

在这里,client1client2是各自构建服务器的主机名或 IP 地址。在客户端,配置 CMake 使用distcc的方式类似于通过将CMAKE_<LANG>_COMPILER_LAUNCHER设置为distcc命令来使用ccache。潜在服务器的列表可以通过配置文件或DISTCC_HOSTS环境变量进行配置。与ccache配置不同,这种配置非常依赖主机,因此配置应放在用户预设中,而不是项目特定的预设中。相应的预设可能如下所示:

{
"name" : "distcc-env",
...
  "environment": {
    "DISTCC_HOSTS" : "localhost buildsrvr1,cpp,lzo
      host123,cpp,lzo"
  }
},

注意buildsrvr1主机后的cpp后缀。这将distcc置于所谓的泵模式,通过将预处理也分发到服务器来进一步提高编译速度。lzo后缀告诉distcc压缩通信内容。

分布式构建的缺点在于,为了获得速度提升,网络必须足够快速,否则传输编译信息的成本可能会高于减少的构建时间。然而,在大多数本地网络中,这通常是可以满足的。如果机器在处理器架构、编译器和操作系统方面相似,分布式构建效果很好。虽然使用distcc进行交叉编译是可能的,但设置起来可能需要相当多的工作。通过结合良好的编码实践、预编译头文件和编译器缓存,大型项目仍然能够正常工作,而无需等待每次构建花费几分钟时间。

总结

在本章中,我们讨论了一些关于构建和维护 CMake 项目的常见技巧,尤其是大型项目。随着项目规模的增大,配置和构建时间通常会增加,这可能会妨碍开发人员的工作流。我们探讨了 CMake 的性能分析功能,这可能是找出配置过程中的性能瓶颈的有用工具,尽管它不能用于分析编译本身的性能。

为了帮助解决较长的编译时间,我们展示了如何使用 CMake 中的统一构建和预编译头文件来改善编译时间。如果这些方法还不能达到预期效果,可以通过在编译器命令前加上编译器缓存(如ccache)或分布式编译器(如distcc)来使用这些工具。

优化构建性能是一个非常令人满意的过程,即使找到合适的工具和方法组合以最大化 CMake 的效率可能有些繁琐。然而,经过高度优化的构建也有其缺点,那就是构建可能更容易失败,而且构建过程中增加的复杂性可能需要更深入的理解和更多的专业知识来长期维护。

在下一章,我们将概述从任何构建系统迁移到 CMake 项目的一些高层策略。

问题

  1. 哪些命令行标志用于从 CMake 生成性能分析信息?

  2. 从一个非常高层次来看,统一构建如何优化编译时间?

  3. BATCH模式和GROUP模式在统一构建中有什么区别?

  4. 如何将预编译头文件添加到目标中?

  5. CMake 如何处理编译器缓存?

答案

  1. 使用--profiling-output <filename>--profiling-format=google-trace标志。

  2. 通过将多个编译单元合并为一个,减少了重新链接的需求。

  3. BATCH模式下,CMake 会自动将源文件分组,而在GROUP模式下,分组需要由用户指定。默认情况下,BATCH模式会将所有源文件分组为统一构建,而GROUP模式只会将显式标记的文件添加到统一构建中。

  4. 通过使用target_precompile_headers函数,预编译头文件会自动包含,无需在文件中使用#include指令。

  5. 通过在编译器命令前加上CMAKE_<LANG>_COMPILER_LAUNCHER中指定的命令。

第十五章:迁移到 CMake

虽然 CMake 正在发展成为 C++ 和 C 项目的事实上的行业标准,但仍然有一些项目—有时甚至是大型项目—使用不同的构建系统。当然,只要它满足你的需求,这并没有问题。然而,在某些时候,出于某种原因,你可能希望切换到 CMake。例如,也许软件应该能够通过不同的 IDE 或在不同的平台上进行构建,或者依赖管理变得繁琐。另一个常见的情况是,当代码库结构从一个包含所有库的大型单一仓库,变更为每个库项目都有独立仓库时。无论什么原因,迁移到 CMake 可能是一个挑战,尤其是对于大型项目,但结果可能是值得的。

虽然一次性转换整个项目是首选方式,但通常会有一些非技术性要求使得这种方式不可行。例如,在迁移过程中,某些部分的开发可能仍然需要继续进行,或者因为一些超出团队控制的需求,项目的某些部分无法一开始就进行迁移。

因此,通常需要逐步的方法。更改构建系统很可能会影响任何 CI/CD 过程,因此这也应该考虑在内。本章将探讨一些高级策略,讲解如何逐步将项目迁移到 CMake。然而,注意具体的迁移路径很大程度上依赖于各自的具体情况。例如,从基于单一仓库的 Makefile 项目迁移,和从跨多个仓库的 Gradle 构建迁移,操作方式是不同的。

改变构建系统,可能还包括项目结构的变化,可能会对所有相关人员造成很大的干扰,因为他们已经习惯了现有的结构和构建系统。因此,决定切换构建系统不应轻率做出,只有在收益显著的情况下才能进行更改。

本章虽然侧重于迁移项目到 CMake 的过程,但通常迁移的目的并不是为了切换构建系统,而是为了实现其他主要目标,比如简化项目结构或减少项目各部分之间的耦合,从而使它们能够更容易地独立维护。在谈论迁移的好处时,记住这些好处不一定非得是纯粹的技术性好处,比如通过更好地并行化构建来提升构建速度。好处也可以来自“社会”层面,例如,拥有一种标准化且广为人知的构建软件方式,可以减少新开发者的入门时间。

本章将涵盖以下主题:

  • 高级迁移策略

  • 迁移小型项目

  • 将大型项目迁移到 CMake

在本章中,我们将介绍一些从任何构建系统迁移到 CMake 的高级概念。正如你所看到的,迁移小型项目可能相当简单,而大型复杂项目则需要更多的前期规划。在本章结束时,你将对将不同规模的项目迁移到 CMake 的不同策略有一个清晰的了解。此外,我们还会提供一些迁移时需要检查的提示,并附上一份大致的迁移步骤指南,以及如何与遗留构建系统互动。

技术要求

本章没有具体的技术要求,因为它展示的是概念,而不是具体示例。然而,建议在迁移到 CMake 时使用最新版本的 CMake。本章中的示例假设使用的是 CMake 3.21 或更高版本。

高级迁移策略

在将软件项目迁移到 CMake 之前,首先需要回答一些关于现有项目的问题,并定义最终目标应该是什么。从非常抽象的层面来看,通常软件项目会定义如何处理以下事项:

  • 软件的各个部分——即库和可执行文件——如何编译,以及它们如何被链接在一起

  • 使用哪些外部依赖,它们是如何被找到的,如何在项目中使用

  • 构建哪些测试以及如何运行它们

  • 软件如何安装或打包

  • 提供额外的信息,如许可证信息、文档、更新日志等

一些项目可能只定义前述点的一个子集。但通常来说,这些是我们作为开发人员希望在项目设置中处理的任务。这些任务通常以结构化的方式定义,例如使用 Makefile 或特定于 IDE 的项目定义。项目的组织和结构方式有无数种,而一种设置有效的方式可能不适用于另一种设置。因此,在任何情况下,都需要对具体情况进行个别评估。

有一些工具可以自动将一些构建系统(如qmake、Autotools 或 Visual Studio)转换为 CMake,但生成的 CMake 文件质量至多堪忧,并且它们通常假设某些约定。因此,不建议使用这些工具。

此外,一个项目可能会定义它如何在 CI/CD 流水线中构建、测试和部署,虽然这与项目描述密切相关,但 CI/CD 流水线的定义通常不会被视为项目描述的一部分,而是作为使用项目定义的内容。更换构建系统往往会影响 CI/CD 流水线,且通常,想要现代化或更改 CI/CD 基础设施的需求可能是更改构建系统的触发因素。

需要意识到,迁移工作只有在不再使用旧的构建方式时才算完成。因此,我们建议在项目迁移到 CMake 后,删除所有旧的构建指令,以消除维护与旧构建方式的向后兼容性所需的工作。

在理想的情况下,项目的所有部分都应该迁移到 CMake。然而,也有一些情况是无法做到的,或者如果某个部分是否应该迁移,从经济角度来看也值得怀疑。例如,项目可能依赖于一个不再积极维护并且即将淘汰的库。最佳情况是,可以利用迁移工作作为一个契机来完全移除这个依赖;然而,更常见的是,这并不可行。在无法完全移除遗留依赖的情况下,可能的好做法是将其从项目中移除,使其不再被视为内部依赖,而是作为一个外部依赖,并且拥有自己的发布周期。此外,如果无法移除或移除工作量过大,可以对这个特定的库做出例外处理,使用遗留的构建系统和 ExternalProject 解决方案,作为临时措施。对于本章讨论的迁移策略,我们将依赖分为内部依赖和外部依赖。内部依赖是由与要迁移的项目相同的组织或人员积极开发的依赖,以便开发人员可以有可能修改构建过程。而外部依赖是开发人员对构建过程或代码几乎没有控制权的依赖。

迁移项目时需要考虑的一件事是,在迁移过程中有多少人会被阻止继续在项目上工作,以及需要同时维护旧的构建方式和 CMake 的时间长短。改变构建系统对开发者的工作流程有很大干扰。很可能会有一些时候,项目的某些部分在完全迁移之前无法继续开发。解决这一问题的最简单方法是暂时停止功能开发,让每个人都参与迁移工作。然而,如果这不可行,良好的沟通和有效的工作分配往往是所需要的。话虽如此,避免在迁移过程中半途而废:将一个大项目的某些部分迁移了,而其他部分仍然使用旧的构建方式,这很可能会带来两者构建方式的缺点,却无法享受到任何一种的好处。

那么,迁移项目时该如何推进呢?对于那些主要依赖外部库的小型项目,这可能会相对简单。

迁移小型项目

我们将小型项目定义为仅包含少数目标的项目,这些目标通常是一起部署的。小型项目是自包含在单一仓库中的,通常你可以很快地了解它们。这些项目可能构建一个单一的库或带有少量外部依赖项的可执行文件。在这些情况下,迁移到 CMake 通常相对简单。对于小型项目,在第一次迁移时,将所有内容放在一个文件中可能是最简单的方式,以便快速获得早期结果。如果项目已经正确构建,将文件重新排列并将 CMakeLists.txt 文件拆分为多个部分并使用 add_subdirectory() 会更加容易。

向 CMake 迁移的一般方法如下:

  1. 在项目的根目录下创建一个空的 CMakeLists.txt 文件。

  2. 确定项目中的目标和相关文件,并在 CMakeLists.txt 文件中创建适当的目标。

  3. 查找所有外部依赖项和include路径,并在必要时将它们添加到 CMake 目标中。

  4. 确定必要的编译器特性、标志和编译器定义(如果有的话),并将它们提供给 CMake。

  5. 通过创建必要的目标并调用 add_test(),将所有测试迁移到 CTest。

  6. 确定与 CMake 相关的任何安装或打包说明,包括需要安装的资源文件等。

  7. 清理并优化项目,使其更整洁。如果需要,可以创建预设,重新排列文件和文件夹,并拆分 CMakeLists.txt 文件。

自然地,每个步骤到底需要做什么,很大程度上取决于原始项目的组织方式和使用的技术。通常,迁移过程需要多次迭代 CMakeLists.txt 文件,直到一切正常工作。如果第一次实现的 CMake 项目看起来还不太完美,这通常是很正常的。

对于小型项目,处理依赖关系是比较困难的任务之一,因为存在一些隐含的假设,关于依赖关系的存放位置及其在项目内部的结构或隐藏方式。使用包管理器,如在第五章中所述,集成第三方库和依赖管理,可以显著减少处理依赖关系的复杂性。

通常,迁移小型、主要是自包含的项目相对直接,尽管根据原始设置的混乱程度,重新整理和使一切重新正常工作可能需要相当多的工作。在大型组织中,多个此类小型项目可能会一起用于软件组合,再次可以被描述为一个项目。它们的迁移需要更多的规划才能顺利进行。

将大型项目迁移到 CMake

迁移包含多个库和多个可执行文件的大型项目可能是一项挑战。仔细分析,这些项目实际上可能是多个层次嵌套的项目,包含一个或多个根项目,这些根项目整合了多个子项目,而这些子项目又包含或需要多个子项目。根据组织软件组合的大小和复杂性,可能会并存多个共享公共子项目的根项目,这可能会使迁移变得更加复杂。创建项目和子项目的依赖图(如以下图示)通常能帮助我们确定迁移顺序。每个项目可能包含多个项目或目标,它们有自己的依赖关系:

图 15.1 – 一个示例项目层次结构,展示了各种依赖关系

图 15.1 – 一个示例项目层次结构,展示了各种依赖关系

在迁移之前,首先需要彻底分析项目之间的依赖关系以及它们需要按照什么顺序构建。根据项目的状态,生成的图可能相当庞大,因此确定从哪里开始可能会是一个挑战。实际上,依赖图通常不像本书中展示的那样整洁。是否先理清项目的结构然后迁移到 CMake,还是先迁移到 CMake 再理清项目结构,取决于实际情况。如果项目非常大且复杂,首先从图中找到尽可能自包含的“岛屿”,然后从那里开始。

对于复杂的层次化项目,有两种主要的迁移策略需要考虑。一种是自上而下的方法,其中根项目首先进行迁移和替换,然后子项目按最少传入依赖关系的顺序排列。第二种是自下而上的方法,逐个迁移各个项目,从依赖关系最多的项目开始。

自上而下的方法有一个好处,那就是可以确保整个项目能够尽早使用 CMake 构建、测试和打包,但这需要将现有的构建系统集成到 CMake 中,使用ExternalProject。自上而下方法的缺点可能是在早期阶段,生成的 CMake 项目包含了大量用于处理由旧系统构建的包的自定义代码。实际上,使用一些临时的解决方法将现有项目包含到构建中,通常是实现快速且良好结果的最务实方法,并且可以在一定程度上减轻为相同子项目维护两个构建系统的工作量。

自下而上的方法有一个好处,即每个迁移到 CMake 的库可以使用已经迁移的依赖项。缺点是根项目只能在所有子项目都能用 CMake 构建后才可以替换。尽管项目是从下往上迁移的,但一个好的做法是在早期就创建根 CMake 项目。它与原始构建系统中的根项目并存。这样可以在新的 CMake 项目中提前放入外部依赖项并安装配置和打包指令。

下图展示了自上而下和自下而上的策略并排展示的情况。框旁的数字代表迁移顺序:

图 15.2 – 迁移顺序示例

图 15.2 – 迁移顺序示例

除了整体迁移策略,另一个需要考虑的因素是项目是设置为超级构建(superbuild)还是常规项目。当采用自上而下的策略时,超级构建结构可能更容易迁移,因为它的一个优点是设计上更容易集成非 CMake 项目。关于超级构建结构的更多信息,请参考 第十章在超级构建中处理分布式代码库和依赖项

无论选择自上而下还是自下而上的方法来迁移单个项目,迁移大型项目的总体策略看起来都将是以下的方式:

  1. 分析依赖关系、项目层级和部署单元。

  2. 决定迁移策略,并确定是采用常规项目结构还是超级构建。

  3. 创建或迁移根项目,并通过 ExternalProjectFetchContent 或中间 find 模块将所有尚未转换的项目拉入,如果使用的是二进制包。

  4. 使用 CMake 处理项目范围的依赖关系。

  5. 将子项目逐一转换为 CMake,如本章最后一节所述。如果使用中间查找模块,逐一替换它们:

    1. 如果需要,可以在此时将依赖关系处理更改为包管理器。

    2. 查找常见选项,将它们传播到根项目,并创建预设。

  6. 在迁移子项目时,如果尚未完成,请在 CMake 中组织打包。

  7. 清理、重新组织文件和项目,提升性能,等等。

通过分析现有项目层次结构和依赖关系来开始迁移,有助于你制定迁移计划,以便与所有相关人员进行沟通。创建类似之前的可视化图通常是一个很好的工具,尽管对于非常大的项目,这本身可能成为一个相当大的挑战。制定迁移计划时,另一个重要的点是识别哪些是常常一起部署的,以及哪个子项目的发布频率是多少。那些很少变动和发布的项目,可能没有那些频繁更新和发布的项目那样迫切需要迁移。识别部署单元与项目的打包方式密切相关。根据打包的组织方式,可能需要等到所有项目都迁移完成后,才能将打包迁移到 CMake。

到目前为止,我们主要讨论了子项目,但在分析现有结构时,重要的是要识别哪些子项目实际上是应该作为独立项目构建的,既可以在完整项目的上下文中构建,也可以作为常规 CMake 目标处理,后者很少在项目外部构建。

创建一个根 CMakeLists.txt 文件,将涵盖基本的项目设置,并包含必要的模块,如 FetchContentCTestCPack 等。虽然不直接在 CMakeLists.txt 文件中,但交叉编译所需的工具链文件、构建容器或 sysroots 也将在此设置。对于大型项目,根 CMakeLists.txt 文件通常不直接包含目标。相反,它通过 add_subdirectoryFetchContent 来包含,或者在超构建的情况下,使用 ExternalProject。根 CMakeLists.txt 文件应具有以下结构:

  1. 项目定义以及 CMake 的最低版本要求。

  2. 全局属性和默认变量,例如最低语言标准、自定义构建类型、搜索路径和模块路径。

  3. 项目范围内使用的任何 模块和助手函数

  4. 项目范围内的 find_package()

  5. add_subdirectoryFetchContent 或在超构建的情况下,ExternalProject

  6. 整个项目的测试。通常,每个子项目都会有自己的单元测试,但集成测试或系统测试可能会位于顶层。

  7. 打包指令,用于 CPack。

根据定义的复杂性,将外部依赖、测试和打包的处理分到自己的子目录中,可能有助于保持 CMake 文件简短且简洁。

项目范围内使用的外部依赖项可能是大型软件框架,如 Qt 或 Boost,或是小型但常用的实用库,使用频繁。

对于自上而下的方法,子项目将在开始时导入,然后逐一迁移。而在使用自下而上的策略时,构建目标和子项目一开始很可能是空的,随着项目的迁移逐渐被填充。在迁移子项目时,注意寻找可以传播到根项目或移动到预设选项中的常见依赖关系或构建选项。

一旦所有子项目迁移完成,通常还有一些维护任务待完成,例如整理打包文件、协调和归类测试等。此外,迁移完成后,CMake 文件中仍然可能会有一些杂乱的内容,因此进行一次额外的清理,集中整理功能,将确保迁移后的项目能够顺利使用。

通常,迁移大型项目是一项挑战,尤其是在构建过程复杂且(遗憾的是,通常是这样)缺乏适当文档的情况下。软件有许多不同的构建方式,本节描述的策略试图提供一种通用的方法。然而,最终每个迁移过程都是独一无二的。有些情况下,构建系统复杂到描述的迁移策略反而成为障碍;例如,包含尚未迁移的项目到 CMake 中的难度如此之大,以至于逐步迁移可能比从头开始构建还要费力。让我们更详细地看看,当采用自上而下的方式时,如何将使用原始构建系统的子项目纳入其中。

在进行自上而下迁移时整合遗留项目

对于自上而下的迁移策略,现有的项目在开始时就会提供给 CMake。最简单的方法是使用ExternalProject,无论是否计划使用 superbuild。导入的目标可以直接定义,也可以通过 find 模块定义。对于常规项目,这只是一个中间步骤,目的是能够相对快速地构建完整项目,并将配置和构建顺序的控制交给 CMake。生成的 CMake 代码可能看起来不太好看,但首要目标是让根项目能够通过 CMake 构建。不过,在迁移子项目时,确保一步步清理它。对于由单一代码库组成或通过 Git 子模块或类似方式拉取依赖的常规项目,ExternalProject_Add 可以通过指定 SOURCE_DIR 属性来省略下载。包含 Autotools 项目的 CMake 代码可能如下所示:

include(ExternalProject)
set(ExtInstallDir ${CMAKE_CURRENT_BINARY_DIR}/install)
ExternalProject_Add(SubProjectXYZ_ext
    SOURCE_DIR ${CMAKE_CURRENT_LIST_DIR}/SubProjectXYZ/
    INSTALL_DIR ${ExtInstallDir}
    CONFIGURE_COMMAND <SOURCE_DIR>/configure --prefix <INSTALL_DIR>
    INSTALL_COMMAND make install
    BUILD_COMMAND make
)
add_library(SubProjectXYZ::SubProjectXYZ IMPORTED SHARED)
set_target_properties(SubProjectXYZ::SubProjectXYZ
    PROPERTIES IMPORTED_LOCATION " ${CMAKE_CURRENT_LIST_DIR}
      /SubProjectXYZ/lib/libSubProjectXYZ.so"
    INTERFACE_INCLUDE_DIRECTORIES "${CMAKE_CURRENT_LIST_DIR}
      /SubProjectXYZ/include"
   IMPORTED_LINK_INTERFACE_LANGUAGES "CXX"
)
...
add_dependencies(SomeTarget SubProjectXZY_ext)
target_link_libraries(SomeTarget SubProjectXYZ:: SubProjectXYZ)

由于ExternalProject只在构建时提供内容,因此这种方法仅适用于已经存在于本地文件夹中的子项目。因为它们包括一个在配置时必须存在的导入目标目录,而在使用target_link_libraries时,这些目录必须在配置时就已经存在,因此导出的路径应该指向源目录,而不是外部项目的安装位置。

这些做法是临时的权宜之计。

这里描述的使用ExternalProjectFetchContent的做法,旨在为迁移过程中能够将遗留项目包含到 CMake 构建中提供临时的解决方案。这些做法并不适合在生产环境中使用。此模式允许使用原始构建系统,并提供一个导入的目标,用于链接已经迁移的项目。是否通过创建这样的中间项目结构来实现早期用 CMake 构建整个项目的努力是值得的,需要根据每个案例单独考虑。

如果从 Microsoft Visual Studio 迁移而不是使用ExternalProject,则可以使用include_external_msproject()函数直接包含项目文件。

通过这些内容,你应该掌握了从其他构建系统迁移到 CMake 所需的所有概念。

总结

在本章中,你学习了关于将各种规模的项目迁移到 CMake 的一些概念和策略。迁移项目到 CMake 所需的努力和实际工作将很大程度上依赖于项目的具体设置。然而,采用这里描述的方法后,选择合适的策略应该会更容易。改变构建过程和开发者工作流程通常是破坏性的,因此你必须仔细考虑这种努力是否值得。尽管如此,将项目迁移到 CMake 将开启所有用于构建高质量软件的功能和做法的可能性,正如本书所述。此外,拥有一个清晰且维护良好的构建系统将使开发者能够专注于他们的主要任务,即编写代码和发布软件。

这将带我们进入本书的最后一章,内容是如何访问 CMake 社区、寻找进一步的阅读材料并为 CMake 本身做贡献。

问题

  1. 迁移大型项目的两种主要策略是什么?

  2. 在选择自下而上的方法迁移项目时,应该首先迁移哪些子项目或目标?

  3. 在选择自上而下的方法时,应该首先迁移哪些项目?

  4. 自上而下的方法有哪些优缺点?

  5. 使用自下而上的方法进行迁移有哪些优缺点?

答案

  1. 大型项目可以选择自上而下或自下而上的方式进行迁移。

  2. 在使用自下而上的方法时,应该首先迁移具有最多传入依赖项的项目或目标。

  3. 选择自上而下方法时,应优先迁移那些依赖最少的项目。

  4. 自上而下的方法可以快速使用 CMake 作为入口点构建整个项目。此外,每个已迁移的项目完成后,旧的构建系统可以被丢弃。缺点是,自上而下的方法需要一些中间代码。

  5. 自下而上的方法比自上而下的方法需要更少的中间代码,并且可以从一开始就写出干净的 CMake 代码。缺点是,只有在所有子项目都迁移完成后,才能构建整个项目。

附录:为 CMake 做贡献与进一步阅读资料

这是一个漫长的旅程,我们已经学到了很多关于 CMake 的知识。然而,正如你现在可能已经意识到的,CMake 是一个庞大的生态系统,一本书并不足以涵盖所有可以讨论的主题。因此,在本章中,我们将看一下那些能帮助你更好理解 CMake 的资源,以及参与 CMake 项目的方式。

CMake 是一个灵活的工具,广泛应用于软件行业中的许多项目。因此,CMake 拥有一个不断壮大的支持社区。网上有大量资源可供学习和解决你可能遇到的 CMake 问题。

为了理解本章中分享的技能,我们将涵盖以下主要内容:

  • 寻找 CMake 社区的途径

  • 为 CMake 做贡献

  • 推荐的书籍和博客

让我们开始吧!

前提条件

这是一个通读章节,没有实践或示例。所以,唯一的要求就是一台兼容的设备、一个安静的地方,当然,还有你的时间。

寻找 CMake 社区的途径

在深入 CMake 之后,你可能会有与他人交流想法的需求,或者寻找一个平台,向可能知道答案的人提问。为此,我为你提供了一些在线平台的推荐。

Stack Overflow

Stack Overflow 是一个受欢迎的问答平台,也是大多数开发者的首选。如果你遇到 CMake 问题或有任何疑问,可以先在 Stack Overflow 上搜索问题的答案。很有可能有人遇到过相同的问题,或者以前问过类似的问题。你还可以查看热门问题列表,发现一些使用 CMake 的新方法。

提问时,确保给你的问题加上 cmake 标签。这样,感兴趣回答 CMake 相关问题的人就能更容易找到你的问题。你可以访问 Stack Overflow 的主页:stackoverflow.com/

Reddit (r/cmake)

r/cmake 子版块,其中包含 CMake 相关的问题、公告和分享。你可以发现许多有用的 GitHub 仓库,获取 CMake 最新版本的通知,发现博客文章和资料,帮助你解决问题。你可以访问 r/cmake 子版块:www.reddit.com/r/cmake/

CMake 讨论论坛

CMake 讨论论坛 是 CMake 开发者和用户交流的主要平台。它完全专注于 CMake 相关的内容。论坛包含公告、如何使用 CMake 的指南、社区空间、CMake 开发空间,以及许多你可能感兴趣的其他内容。你可以访问该论坛:discourse.cmake.org/

Kitware CMake GitLab 仓库

Kitware 的 CMake 仓库也是一个很好的资源,可以帮助你解决可能遇到的问题。尝试在gitlab.kitware.com/cmake/cmake/-/issues上搜索你遇到的问题。很有可能其他人已经报告了类似的问题。如果没有,你可以遵循 CMake 的贡献规则创建一个新的问题。

上述列表并不全面,网上还有许多其他论坛。以下四个平台已经足够让你入门。接下来,我们将讨论如何为 CMake 项目本身做贡献。

为 CMake 做贡献

如你所知,CMake 是由 Kitware 开发的开源软件。Kitware 在gitlab.kitware.com/cmake的专用 GitLab 实例中维护 CMake 的开发活动。所有内容都以开源且透明的形式提供,意味着参与 CMake 的开发相对容易。你可以查看问题、合并请求,并参与 CMake 的开发。如果你认为你发现了 CMake 中的 bug,或者想提出功能请求,可以在gitlab.kitware.com/cmake/cmake/-/issues上创建一个新问题。如果你有改进 CMake 的想法,可以先通过创建一个问题来讨论这个想法。你还可以查看gitlab.kitware.com/cmake/cmake/-/merge_requests上的开放合并请求,帮助审查正在开发的代码。

为开源软件做贡献对开源世界的可持续发展至关重要。请不要犹豫,以任何方便的方式帮助开源社区。你提供的帮助可能很小,但小小的贡献会迅速积累成更大的成就。接下来,我们将查看一些你可能会觉得有用的阅读材料。

推荐的书籍和博客

关于 CMake 有许多书籍、博客和资源。以下是一些精选的你可能会觉得有用的资源列表。这些资源将帮助你进一步了解 CMake,拓宽你的视野:

话虽如此,我们已经到达了另一个章节的结尾。接下来,我们将总结本章所学内容。

摘要

在本章中,我们简要讨论了你可以在网上找到的 CMake 社区、贡献 CMake 以及一些很好的阅读和观看推荐。关于 CMake 的材料和演讲数量庞大,内容也在日益增长。时刻关注 CMake 的更新,并定期访问你选择的论坛,保持信息的同步。

话虽如此,如果你已经来到这里并阅读这段文字,那么恭喜你!你已经完成了我们在本书中希望涵盖的所有主题。这是最后一章内容。不要忘记将你从本书中学到的知识应用并实践到日常工作流中。我们很享受一起走过的这段旅程,希望你从本书中获得的知识能够为你带来帮助。

posted @ 2025-10-02 09:35  绝不原创的飞龙  阅读(85)  评论(0)    收藏  举报