cmk-bst-prac-2e-merge-1

CMake 最佳实践第二版(二)

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

译者:飞龙

协议:CC BY-NC-SA 4.0

第五章:集成第三方库和依赖管理

迄今为止,在本书中,我们已经介绍了如何使用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,而是只使用一个版本以避免冲突。下面的图示展示了依赖关系图可能的样子:

https://github.com/OpenDocCN/freelearn-c-cpp-pt2-zh/raw/master/docs/cmk-bst-prac-2e/img/B30947_05_01.jpg

图 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 所需的版本接口兼容的情况下有效,从而生成如下所示的依赖关系图:

https://github.com/OpenDocCN/freelearn-c-cpp-pt2-zh/raw/master/docs/cmk-bst-prac-2e/img/B30947_05_02.jpg

图 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 页面并显示类似于此截图的内容:

https://github.com/OpenDocCN/freelearn-c-cpp-pt2-zh/raw/master/docs/cmk-bst-prac-2e/img/B30947_06_01.jpg

图 6.1 使用 Doxygen 生成的示例手册页

很好!我们的代码注释变成了手册页。同样,让我们检查 HTML 输出。使用你喜欢的浏览器打开 build/ex01_doxdocgen/docs/html/index.html 文件,如下所示:

google-chrome build/ex01_doxdocgen/docs/html/index.html

这将显示文档的主页,如下所示的截图所示:

https://github.com/OpenDocCN/freelearn-c-cpp-pt2-zh/raw/master/docs/cmk-bst-prac-2e/img/B30947_06_02.jpg

图 6.2 – 文档的主页

在上面的截图中,我们可以看到 Doxygen 已经将 README.md Markdown 文件的内容渲染到主页中。请注意,主页仅作为示例提供。Doxygen 可以将任意数量的 Markdown 文件嵌入到生成的文档中。它甚至会将文件名、类名和函数名替换为指向相关文档的链接。这是通过 Doxygen 的 AUTOLINK 功能和 @ref Doxygen 命令实现的。点击 calculator 类下的 calculator 链接。calculator 类的文档页面应该如下所示:

https://github.com/OpenDocCN/freelearn-c-cpp-pt2-zh/raw/master/docs/cmk-bst-prac-2e/img/B30947_06_03.jpg

图 6.3 – 生成的计算器类 HTML 文档(基础布局)

在上面的截图中,我们可以看到 Doxygen 知道 calculator 类继承自 calculator_interface,并为 calculator 类绘制了继承图。

注意

Doxygen 需要 dot 工具来渲染图表。dot 工具包含在 Graphviz 软件包中。

此外,生成的图表包含 UML 风格的函数名称和封装符号。让我们看看下面截图中显示的详细成员函数文档:

https://github.com/OpenDocCN/freelearn-c-cpp-pt2-zh/raw/master/docs/cmk-bst-prac-2e/img/B30947_06_04.jpg

图 6.4 – 生成的计算器类 div() 函数文档

如我们在 图 6.3 中所见,Doxygen 在将内容排版成清晰、可读的布局方面做得相当不错。如果你在阅读这个 API 文档,你应该会很高兴。最后,让我们导航到 main.cpp,查看 main.cpp 的文档,以说明什么是依赖图。你可以在以下截图中看到文档页面的表示:

https://github.com/OpenDocCN/freelearn-c-cpp-pt2-zh/raw/master/docs/cmk-bst-prac-2e/img/B30947_06_05.jpg

图 6.5 – main.cpp 文档页面

在上面的截图中显示的依赖图表明,main.cpp 文件直接依赖于 iostreamchapter6/ex06/calculator.hpp 文件,并间接依赖于 chapter6/ex06/calculator_interface.hpp 文件。将依赖信息包含在文档中是非常有用的。使用者将准确知道文件依赖关系,而无需深入代码。如果你再往下滚动一点,你会看到 main() 函数的调用图,如下所示的截图所示:

https://github.com/OpenDocCN/freelearn-c-cpp-pt2-zh/raw/master/docs/cmk-bst-prac-2e/img/B30947_06_06.jpg

图 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 时序图,如下所示:

https://github.com/OpenDocCN/freelearn-c-cpp-pt2-zh/raw/master/docs/cmk-bst-prac-2e/img/B30947_06_07.jpg

图 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文件夹中运行此命令。这将生成类似于以下的输出:

https://github.com/OpenDocCN/freelearn-c-cpp-pt2-zh/raw/master/docs/cmk-bst-prac-2e/img/B30947_06_08.jpg

图 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 文件:

https://github.com/OpenDocCN/freelearn-c-cpp-pt2-zh/raw/master/docs/cmk-bst-prac-2e/img/B30947_07_01.jpg

图 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 生成的覆盖率报告可能如下所示:

https://github.com/OpenDocCN/freelearn-c-cpp-pt2-zh/raw/master/docs/cmk-bst-prac-2e/img/B30947_07_02.jpg

图 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 报告可能如下所示:

https://github.com/OpenDocCN/freelearn-c-cpp-pt2-zh/raw/master/docs/cmk-bst-prac-2e/img/B30947_07_03.jpg

图 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文件可以作为脚本执行。

posted @ 2025-03-03 17:58  绝不原创的飞龙  阅读(166)  评论(0)    收藏  举报