cmk-bst-prac-2e-merge-2
CMake 最佳实践第二版(三)
原文:
zh.annas-archive.org/md5/FFE1C97095FAEE9D7B23FBC1FEE1C179译者:飞龙
第九章:创建可重现的构建环境
构建软件可能会很复杂,尤其是涉及依赖项或特殊工具时。在一台机器上编译通过的软件,在另一台机器上可能无法正常工作,因为缺少某个关键的软件。单单依赖软件项目文档的正确性来搞清楚所有构建要求通常是不够的,因此,程序员往往需要花费大量时间梳理各种错误信息,以找出构建失败的原因。
在构建或 持续集成 (CI) 环境中,许多人因为害怕任何更改都可能破坏软件的构建能力,而避免升级任何东西。情况甚至严重到公司因为担心无法再发布产品,而拒绝升级其使用的编译器工具链。创建关于构建环境的稳健且可移植的信息是彻底改变游戏规则的举措。通过预设,CMake 提供了定义配置项目的常见方式的可能性。当与工具链文件、Docker 容器和 系统根目录 (sysroots) 结合使用时,创建一个可在不同机器上重建的构建环境变得更加容易。
在本章中,你将学习如何定义 CMake 预设以配置、构建和测试 CMake 项目,以及如何定义和使用工具链文件。我们将简要介绍如何使用容器构建软件,并学习如何使用 sysroot 工具链文件创建隔离的构建环境。本章的主要内容如下:
-
使用 CMake 预设
-
组织预设的最佳实践
-
使用 CMake 构建容器
-
使用 sysroot 隔离构建环境
所以,让我们系好安全带,开始吧!
技术要求
和前几章一样,示例已在 CMake 3.25 版本下测试,并可在以下任一编译器上运行:
-
GCC 9 或更新版本
-
Clang 12 或更新版本
-
MSVC 19 或更新版本
对于使用构建容器的示例,需要 Docker。
所有示例和源代码都可以在本书的 GitHub 仓库中找到。对于本章,CMake 预设和构建容器的示例位于仓库的根文件夹中。如果缺少任何软件,相应的示例将从构建中排除。仓库地址:github.com/PacktPublishing/CMake-Best-Practices---2nd-Edition
使用 CMake 预设
在多个配置、编译器和平台上构建软件是 CMake 的最大优点,但这也是其最大的弱点之一,因为这通常让程序员难以弄清楚哪些构建设置实际上经过测试并能在特定软件上工作。自 3.19 版本以来,CMake 引入了一个叫做 预设(presets) 的功能。这个功能是处理这些场景的一个非常可靠且方便的工具。在引入预设之前,开发者需要依赖文档和模糊的约定来搞清楚 CMake 项目的首选配置。预设可以指定构建目录、生成器、目标架构、主机工具链、缓存变量和用于项目的环境变量。从 CMake 3.19 版本开始,预设经历了很大的发展,增加了构建、测试和打包预设,以及最新加入的:工作流预设。
要使用预设,项目的顶级目录必须包含一个名为 CMakePresets.json 或 CMakeUserPresets.json 的文件。如果两个文件都存在,它们会通过先解析 CMakePresets.json,然后再解析 CMakeUserPresets.json 来内部合并。两个文件的格式相同,但用途略有不同:
-
CMakePresets.json应由项目本身提供,并处理项目特定的任务,比如运行 CI 构建,或者在项目中提供的情况下,知道使用哪些工具链进行交叉编译。由于CMakePresets.json是项目特定的,它不应该引用项目结构外的任何文件或路径。由于这些预设与项目紧密相关,通常也会将其保存在版本控制中。 -
另一方面,
CMakeUserPresets.json通常由开发者为自己的机器或构建环境定义。CMakeUserPresets.json可以根据需要非常具体,可能包含项目外的路径或特定系统设置的路径。因此,项目不应该提供此文件,也不应该将其放入版本控制中。
预设是将缓存变量、编译器标志等从 CMakeLists.txt 文件中移出的好方法,同时还能以可以与 CMake 一起使用的方式保留这些信息,从而提高项目的可移植性。如果有预设,可以通过调用以下命令 cmake --list-presets 从源目录列出预设,这将生成如下输出:
Available configure presets:
"ninja-debug" - Ninja (Debug)
"ninja-release" - Ninja (Release)
这将列出带引号的预设名称,如果设置了 displayName 属性,也会显示该属性。要从命令行使用属性时,使用带引号的名称。
CMake GUI 将在源目录中显示所有可用的预设,类似如下:
图 9.1 – 在 CMake GUI 中列出可用的预设
从 CMake 3.21 版本开始,ccmake 命令行配置工具不支持预设。可以通过以下方式从顶级目录选择配置预设:
cmake --preset=name
CMakePresets.json 和 CMakeUserPresets.json 的整体结构如下:
{
"version": 6,
"cmakeMinimumRequired": {"major": 3,"minor": 25,"patch": 0 },
"configurePresets": [...],
"buildPresets": [...],
"testPresets": [...],
"packagePresets": [...],
"workflowPresets": [...],
"vendor": {
"microsoft.com/VisualStudioSettings/CMake/1.9": { "intelliSenseMode": "windows-msvc-x64"
} }
}
version 字段指定要使用的 JSON 架构。版本 1 是 CMake 3.19 的首次发布,仅支持 configurePresets。随后的版本添加了 buildPresets、testPresets、packagePresets 和 workflowPresets。在编写本书时,最新版本是版本 9,该版本随 CMake 3.30 发布。
可选的 cmakeMinimumRequired 字段可以用来定义构建此项目所需的最低 CMake 版本。由于最低要求通常也会在 CMakeLists.txt 文件中声明,因此此字段通常会被省略。
四个列表:configurePresets、buildPresets、testPresets 和 packagePresets,分别包含用于配置、构建、测试和打包项目的配置列表。构建、测试和打包的预设要求至少有一个配置预设,如我们将在本节后面看到的那样。workflowPresets 是一个特殊情况,它描述了由其他预设组成的典型工作流。
vendor 字段包含一个可选的映射,用于存储供应商或 IDE 特定的信息。CMake 不会解释此字段的内容,除非验证 JSON 格式。映射的键应该是由斜杠分隔的供应商特定域。在前面的示例中,供应商预设的键是 microsoft.com/VisualStudioSettings/CMake/1.9。供应商字段中的值可以是任何有效的 JSON 格式。所有预设文件必须至少包含一个配置预设,因此让我们更仔细地看一下。
配置预设
要使用预设,必须至少存在一个配置预设,用于为 CMake 配置构建系统的环境。它们至少应该指定构建路径以及配置时使用的生成器。通常,配置预设还会设置一些常见的缓存变量,比如用于单配置生成器的 CMAKE_BUILD_TYPE。一个包含配置预设的预设,用于在调试模式下使用 Ninja 生成器构建项目,可能像这样:
{
"version": 3,
"configurePresets": [
{
"name": "ninja",
"displayName": "Ninja Debug",
"description": "build in debug mode using Ninja generator",
"generator": "Ninja",
"binaryDir": "build",
"cacheVariables": { "CMAKE_BUILD_TYPE": "Debug" }
}
]
}
所有预设必须具有在预设块中唯一的名称。由于某些 GUI 应用程序仅显示已分配 displayName 字段的预设,因此强烈建议设置此字段。
预设命名约定
一个好的实践是,在 CMakePresets.json 文件中定义的预设名称应避免与开发者可能在 CMakeUserPresets.json 中定义的名称冲突。一个常见的约定是,将项目定义的预设以 ci- 为前缀,以标记它们用于 CI 环境。
在版本 1 和 2 的预设中,binaryDir和generator字段是必需的;在版本 3 中,它们变为可选。如果未设置任一字段,其行为与未使用预设的 CMake 相同。CMake 命令的命令行选项将在相关情况下覆盖预设中指定的值。因此,如果设置了binaryDir,在调用cmake --preset=时,它会自动创建,但如果传递了-B选项,则其值会被 CMake 覆盖。
缓存变量可以通过key:value对来定义,如前面的示例所示,或者作为 JSON 对象,这样可以指定变量类型。文件路径可以像这样指定:
"cacheVariables": {
"CMAKE_TOOLCHAIN_FILE": {
"type": "FILEPATH",
"value": "${sourceDir}/cmake/toolchain.cmake"
}
}
如果以key:value的形式使用,则类型将被视为STRING,除非它是true或false(没有引号),在这种情况下将被解释为BOOL。例如,$ {sourceDir}是一个宏,当使用预设时会展开。
已知的宏如下:
-
${sourceDir}:这指向项目的源目录,${sourceParentDir}指向源目录的父目录。可以通过${sourceDirName}获取源目录的目录名,不包括路径。例如,如果${sourceDir}是/home/sandy/MyProject,那么${sourceDirName}将是MyProject,而${sourceParentDir}将是/home/sandy/。 -
${generator}:这是当前使用的预设指定的生成器。对于构建和测试预设,它包含配置预设使用的生成器。 -
${hostSystemName}:这是主机操作系统的系统名称,与CMAKE_HOST_SYSTEM变量相同。该值要么是uname -s的结果,要么是 Linux、Windows 或 Darwin(用于 macOS)。 -
$env{<variable-name>}:这包含名为<variable-name>的环境变量。如果该变量在预设的环境字段中定义,则使用此值,而不是从父环境或系统环境中获取的值。使用$penv{<variable-name>}的作用类似,但值始终从父环境中获取,而不是从环境字段中获取,即使该变量已定义。这允许向现有环境变量添加前缀或后缀。由于$env{...}不允许循环引用,因此不能向变量添加前缀或后缀。需要注意的是,在 Windows 环境中,变量是不区分大小写的,但在预设中使用的变量仍然区分大小写。因此,建议保持环境变量的大小写一致。 -
$vendor{<macro-name>}:这是供 IDE 供应商插入其自定义宏的扩展点。由于 CMake 无法解释这些宏,使用$vendor{…}宏的预设将被忽略。 -
${dollar}:这是一个占位符,代表字面上的美元符号$。
修改预设的环境与设置缓存变量类似:通过设置包含key:value对的映射的environment字段。即使值为空或为null,环境变量始终会被设置。环境变量可以互相引用,只要不包含循环引用。考虑以下示例:
{
"version": 3,
"configurePresets": [
{
"name": "ci-ninja",
"generator": "Ninja",
"binaryDir": "build",
"environment": {
"PATH": "${sourceDir}/scripts:$penv{PATH}",
"LOCAL_PATH": "$env{PATH}",
"EMPTY" : null
}
]
}
在这个示例中,PATH环境变量通过在项目结构内部预先添加路径进行修改。使用$penv{PATH}宏确保值来自预设之外。然后,LOCAL_PATH变量通过使用$env{PATH}宏引用修改后的PATH环境变量。只要PATH环境变量不包含$env{LOCAL_PATH}(这将产生循环引用),这种引用是允许的。通过传递null,EMPTY环境变量被取消设置。注意,null不需要加引号。除非使用构建预设或测试预设,否则环境不会传递到相应的步骤。如果使用了构建预设或测试预设,但不希望应用来自配置预设的环境,可以通过将inheritConfigureEnvironment字段设置为false来明确声明。
从预设中继承
预设可以通过inherits字段继承其他相同类型的预设,该字段可以包含单个预设或预设列表。在继承父级字段时,预设可以被覆盖或添加额外的字段。这对于避免为常见构建块重复代码非常有用。结合hidden字段使用时,可以使CMakePreset.json文件更小。考虑以下示例:
{
"version": 3,
"configurePresets": [
{
"name": "ci-ninja",
"generator": "Ninja",
"hidden": true,
"binaryDir": "build"
},
{
"name": "ci-ninja-debug",
"inherits": "ci-ninja",
"cacheVariables": {
"CMAKE_BUILD_TYPE": "Debug"
}
},
{
"name": "ci-ninja-release",
"inherits": "ci-ninja",
"cacheVariables": {
"CMAKE_BUILD_TYPE": "Release"
}
}
]
}
在示例中,ci-ninja-debug和ci-ninja-release预设都从隐藏的ci-ninja build预设继承,并额外设置了CMAKE_BUILD_TYPE缓存变量,以对应的配置进行构建。隐藏预设仍然可以使用,但在执行cmake --list-presets时不会显示。CMakeUserPreset.json中定义的预设可以从CMakePreset.json继承,但反过来不行。
在前面的示例中,预设继承自单一父预设,但预设也可以从多个父预设继承。以下示例展示了CMakeUserPreset.json与前面示例中的CMakePreset.json如何协同工作:
{
"version": 6,
"configurePresets": [
{
"name": "gcc-11",
"hidden": true,
"binaryDir": "build",
"cacheVariables": {
"CMAKE_C_COMPILER": "gcc-11",
"CMAKE_CXX_COMPILER": "g++-11"
}
},
{
"name": "ninja-debug-gcc",
"inherits": ["ci-ninja-debug","gcc-11"],
"cacheVariables": {
"CMAKE_BUILD_TYPE": "Debug"
}
},
]
}
在这里,用户提供了一个预设,明确选择了 GCC 11 作为名为gcc-11的编译器。之后,ninja-debug-gcc预设从项目提供的CMakePreset.json中定义的ci-ninja-debug预设继承值,并与用户提供的gcc-11预设结合。如果两个父预设为相同字段定义了不同的值,则inherits列表中首先出现的那个预设的值优先。
预设条件
有时,预设仅在某些条件下才有意义,例如针对特定的构建平台。例如,使用 Visual Studio 生成器的配置预设仅在 Windows 环境中才有用。对于这些情况,如果条件不满足,可以使用condition选项禁用预设。任何在父预设中定义的条件都会被继承。条件可以是常量、字符串比较,或检查列表是否包含某个值。从预设版本 3 开始提供这些功能。以下配置预设仅在你在 Windows 环境下工作时启用:
{
"name": "ci-msvc-19",
"generator": "Visual Studio 16 2019",
"binaryDir": "build",
"condition": {
"type": "equals",
"lhs": "${hostSystemName}",
"rhs": "Windows"
}
}
在上述示例中,构建预设会在使用${hostSystemName}宏获取主机系统的名称并与Windows字符串进行比较后启用。如果${hostSystemName}匹配,则启用预设;否则,禁用该预设,尝试使用它会导致错误。在比较字符串时,大小写是重要的:对于不区分大小写的测试,可以使用matches或notMatches类型,它们接受正则表达式。
对于更复杂的条件,支持使用allOf、anyOf和not运算符进行布尔逻辑嵌套。例如,如果一个配置预设只应在 Windows 和 Linux 下启用,而在 macOS 下不启用,则预设和条件可以如下所示:
{
"name": "WindowsAndLinuxOnly",
"condition": {
"type": "anyOf",
"conditions": [
{
"type": "equals",
"lhs": "${hostSystemName}",
"rhs": "Windows"
},
{
"type": "equals",
"lhs": "${hostSystemName}",
"rhs": "Linux"
}
]
}
每个条件也可以包含进一步的嵌套条件(如果需要的话),尽管这样做会迅速增加预设的复杂性。
到目前为止,我们在示例中只见过配置预设,但正如本章开头所提到的,还有构建预设和测试预设。构建和测试预设的语法与配置预设非常相似,许多字段,如name、displayName和inherit,以及条件的工作方式与配置预设相同。一旦指定了配置预设,我们就可以开始指定构建、测试和打包预设。
构建、测试和打包预设
构建预设必须在configurePreset字段中指定一个配置预设,或从另一个指定了配置预设的构建预设中继承。构建目录由配置预设决定,除非inheritConfigureEnvironment字段设置为false,否则会继承配置预设的环境。构建预设主要用于多配置生成器,但如果需要,也可以用于单配置生成器。可选地,构建预设可以指定一个要构建的目标列表。一个构建预设的示例如下:
{
"version": 6,
"configurePresets": [
{
"name": "ci-msvc-19",
"displayName": "msvc 19",
"description": "Configuring for msvc 19",
"generator": "Visual Studio 16 2019",
"binaryDir" : "build"
}
],
"buildPresets": [
{
"name": "ci-msvc-debug",
"configurePreset": "ci-msvc-19",
"configuration": "Debug"
},
{
"name": "ci-msvc-release",
"configurePreset": "ci-msvc-19",
"configuration": "Release"
},
{
"name": "ci-documentation",
"configurePreset": "ci-msvc-19",
"targets": [
"api-doc",
"doc"
]
}
]
}
在上面的示例中,定义了三个构建预设。前两个分别叫做ci-msvc-debug和ci-msvc-release,用于指定 Visual Studio 的构建配置,并且不指定任何目标。第三个构建预设叫做ci-documentation,并将api-doc和doc目标列为文档构建的一部分。调用任何ci-msvc构建预设都会构建"all"目标,而ci-documentation则只会构建列出的目标。可用的构建预设列表可以通过cmake --build --list-presets命令获取。
测试预设与构建预设非常相似,不同之处在于它们与 CTest 一起使用。类似地,在项目根目录下运行ctest --list-presets命令将列出可用的测试预设。测试预设是一个非常有用的工具,可以用来选择或排除特定的测试、指定固定选项,或者控制测试的输出。大部分在第七章中描述的测试选项,无缝集成代码质量工具与 CMake,都可以通过测试预设来控制。一个测试预设的示例如下:
{
"version": 3,
"configurePresets": [
{
"name": "ci-ninja",
...
}
"testPresets": [
{
"name": "ci-feature-X",
"configurePreset": "ci-ninja",
"filter": {
"include": {
"name": "feature-X"
},
"exclude": {
"label": "integration"
}
}
}
]
}
在上面的示例中,添加了一个测试预设,用于过滤包含feature-X的测试,但排除了标记为integration的任何测试。这等同于从构建目录调用以下命令:
ctest --tests-regex feature-X --label-exclude integration
包预设与构建和测试预设非常相似,也需要设置一个配置预设。一个包预设的示例如下:
"packagePresets": [
{
"name": "ci-package-tgz",
"configurePreset": "ci-ninja-release",
"generators": [
"TGZ"
],
"packageDirectory": "${sourceDir}/dist"
}
]
这个包预设将使用 TGZ 生成器构建一个包,并将结果包放入dist目录中。由于打包依赖于生成器,并且 CPack 需要相当复杂的配置,因此预设通常用于将包放置在预定义位置,并与工作流预设一起使用,正如我们将在下一节看到的那样。
工作流预设
工作流预设顾名思义,是一种从配置到构建、测试和打包的完整工作流定义方式。如果其中的任何一步失败,工作流将中断,后续步骤不会被执行。工作流预设主要用于自动化构建系统,但当然也可以在本地使用。
列出工作流预设的语法如下:
cmake --workflow --list-presets
要调用工作流预设,使用以下命令:
cmake --workflow --preset=ci-ninja-debug-workflow
这将执行完整的ci-ninja-debug-workflow工作流。工作流定义为一系列的步骤,如下所示:
"workflowPresets": [
{
"name": "ci-ninja-debug-workflow",
"displayName": "CI",
"description": "Continuous Integration",
"steps": [
{
"type": "configure",
"name": "ci-ninja-debug"
},
{
"type": "build",
"name": "ci-ninja-debug-build"
},
{
"type": "test",
"name": "ci-unit-tests-debug"
},
{
"type": "package",
"name": "ci-package-tgz"
}
]
}
]
该命令定义了一个工作流,配置、构建、测试并打包项目。
所有工作流步骤必须使用相同的配置预设
有一点值得注意的是,所有步骤必须定义相同的配置步骤,否则工作流将不正确,CMake 会失败。
虽然它们缺乏像 GitHub Actions、Jenkins 和 Azure DevOps 那样定义完整构建流水线的复杂性,但它们的优势在于能够让开发人员轻松遵循定义好的工作流。工作流预设是应对 CMake 预设“组合爆炸”问题的一种尝试,尤其是当有很多预设时。虽然它们在一定程度上有助于解决这个问题,但它们也要求所有中间步骤都明确地定义,并显式使用相同的配置预设。这样一来,可能迫使开发人员添加更多的预设,这些预设在某种程度上是“差不多但不完全相同”的。
目前还没有一劳永逸的解决方案,但如果预设组织得当,并且遵循一定的纪律,仍然可以获得很多好处。
组织预设的最佳实践
随着项目的增长——特别是当它们面向多个平台时——CMake 预设的数量也可能迅速增加。这会使得跟踪预设并找到正确的预设变得困难。设置工作流预设可以有所帮助,但这仅仅是解决问题的一半,缺点往往是,使用工作流预设会为所有中间步骤创建更多的预设。
组织预设的第一步是找到一个好的命名方案。这样可以轻松地弄清楚某个预设的作用,也有助于让开发人员猜测所需的预设名称(如果它存在的话)。
一个好的方案应该包含以下构建信息:
-
ci或dev -
要使用的生成器
-
工具链,例如要使用的编译器和目标平台
-
构建类型,例如调试版或发布版
使用这种方式,我们最终得到一个类似于<env>-<generator>-<toolchain>-<buildType>的方案,因此用于构建 Linux x86_64 的预设可能被命名为ci-ninja-linux-x86_64-clang-debug。
命名方案适用于各种预设,但对于配置预设最为有用。这里的env可以是ci或dev,取决于预设是用于 CI 还是本地使用。一个好的经验法则是将所有ci预设放入CMakePresets.json并进行版本控制,而将dev预设放入CMakeUserPresets.json并且不进行检查。生成器部分可以是 CMake 支持的任何生成器,例如 Makefiles、Ninja 或 MSVC(用于 Microsoft Visual Studio)。工具链是一个由目标平台、编译器和操作系统组合而成的部分,通常称为目标三元组或目标四元组,例如linux-armv7-clang12。
构建类型是常见的构建类型之一,如调试版、发布版、带调试信息的发布版(relWithDebInfo)或最小大小发布版(minSizeRel),但如果项目配置了自定义构建类型,也可以使用它。对于像 ninja-multi 这样的多配置生成器,构建类型可以在配置预设中省略,并在构建预设中使用。为了聚合预设,可以使用继承和隐藏的预设。那么,哪个预设应该放入哪个部分呢?
图 9.2 – 按类型分组预设以组合可用预设
让我们更仔细地看看用于组合可用预设的不同类型的预设:
-
ci预设应使用相同或至少相似的一组预设。通常,此类别包含多个预设,针对不同方面,例如一个用于设置 Ccache,另一个用于指向 clang-tidy。 -
生成器预设:它们定义要使用的 CMake 生成器。对于包预设,这也可以是包生成器。默认情况下,它们是隐藏的。通常,只有在 CI 或本地实际使用的生成器应该存在。
-
hidden。 -
构建类型预设:这些定义构建类型,例如 release、debug、RelWithDebInfo 或 MinSizeRel。
-
组合预设:这些预设通过 inherit 关键字将所有先前的预设组合成可用预设。它们是可见的,并且指定了构建目录,通常是直接指定或通过其中一个先前的预设指定。
以这种方式设置预设有助于保持它们的组织性。CMake 预设支持包含不同的文件,因此将类别放入不同的文件中可能有助于保持它们的组织性。
CMake 预设可以说是自从引入目标以来,改变了 CMake 使用方式的少数几个特性之一。它们是一个很好的折衷方案,可以将常见的配置和构建选项与项目一起提供,同时保持 CMakeLists.txt 文件与平台无关。最终,无法避免 CMake 支持大量工具和平台,或者 C++ 软件可以通过多种方式构建这一问题。适应这种灵活性需要维护所有这些配置;预设显然是一个不错的选择。然而,有时候,提供必要的设置还不够,你还希望共享一个构建环境,确保软件能够编译。一种实现方法是通过定义一个包含 CMake 和必要库的构建容器。
使用 CMake 的构建容器
容器化带来的好处是开发人员可以在一定程度上控制构建环境。容器化的构建环境对于设置 CI 环境也非常有帮助。目前有许多容器运行时,其中 Docker 是最流行的。深入探讨容器化超出了本书的范围,因此我们将在本书的示例中使用 Docker。
构建容器包含一个完全定义的构建系统,包括 CMake 和构建某个软件所需的任何工具和库。通过提供容器定义(例如 Dockerfile),以及项目文件,或者通过公开可访问的容器注册表,任何人都可以使用该容器来构建软件。巨大的优势是开发者不需要安装额外的库或工具,从而避免污染主机机器,除了运行容器所需的软件。缺点是构建可能需要更长的时间,并且并非所有 IDE 和工具都支持以便捷的方式与容器协作。值得注意的是,Visual Studio Code 对在容器中工作提供了很好的支持。您可以访问code.visualstudio.com/docs/remote/containers了解更多细节。
从很高的层次来看,使用构建容器的工作流程如下:
-
定义容器并构建它。
-
将本地代码的副本挂载到构建容器中。
-
在容器内运行任何构建命令。
用于构建一个简单 C++ 应用程序的非常简单的 Docker 定义可能如下所示:
FROM alpine:3.20.2
RUN apk add --no-cache cmake ninja g++ bash make git
RUN <any command to install additional libraries etc.>
这将基于 Alpine Linux 3.15 定义一个小型容器,并安装cmake、ninja、bash、make和git。任何实际使用的容器可能会安装额外的工具和库以方便工作;然而,仅为了说明如何使用容器构建软件,拥有这样一个最小的容器就足够了。以下 Docker 命令将构建容器镜像,并使用builder_minimal名称进行标签:
docker build . -t builder_minimal
一旦容器是本地的克隆,源代码被挂载到容器中,所有的 CMake 命令都在容器内部执行。假设用户从source目录执行 Docker 命令,配置 CMake 构建项目的命令可能看起来像这样:
docker run --user 1000:1000 --rm -v $(pwd):/workspace
builder_minimal cmake -S /workspace -B /workspace/build
docker run --user 1000:1000 --rm -v $(pwd):/workspace
builder_minimal cmake --build /workspace/build
这将启动我们创建的容器并执行其中的 CMake 命令。通过-v选项,本地目录会挂载到容器中的/workspace。由于我们的 Docker 容器默认使用root作为用户,因此需要通过--user选项传递用户 ID 和组 ID。对于类 Unix 操作系统,用户 ID 应与主机上的用户 ID 匹配,这样创建的任何文件也可以在容器外部编辑。--rm标志告诉 Docker 在完成后删除镜像。
与容器交互的另一种方式是通过向docker run命令传递-ti标志来以交互模式运行它:
docker run --user 1000:1000 --rm -ti -v $(pwd):/workspace
builder_minimal
这将在容器内启动一个 shell,可以在其中调用build命令,而无需每次都重新启动容器。
编辑器或 IDE 和构建容器如何协同工作有几种策略。最便捷的方式当然是 IDE 本身原生支持,或者通过像 Visual Studio Code 这样的方便扩展支持。如果没有这种支持,将合适的编辑器打包进容器并从容器内启动也是一个可行的策略来方便地开发软件。另一种方法是在宿主系统上运行编辑器,并重新配置它,使其不直接调用 CMake,而是启动容器并在其中执行 CMake。
我们在这里展示的只是使用容器作为构建环境的最低要求,但我们希望它能作为使用容器的第一步。随着越来越多的集成开发环境(IDE)开始支持容器化构建环境,使用它们将变得更加容易。容器使得构建环境在不同机器之间变得非常可移植,并有助于确保项目中的所有开发者都使用相同的构建环境。将容器定义文件放在版本控制下也是一个好主意,这样对构建环境的必要更改就能与代码一起进行跟踪。
容器是创建隔离构建环境的一种好且可移植的方式。然而,如果出于某种原因无法使用容器,另一种创建隔离和可移植构建环境的方法是使用 sysroot。
使用 sysroot 来隔离构建环境
简而言之,sysroot 是构建系统认为的根目录,用来定位头文件和库文件。简而言之,它们包含为目标平台编译软件时所需的简化版根文件系统。它们通常在交叉编译软件到其他平台时使用,如 第十二章 中所述,跨平台编译与自定义工具链。如果容器无法用于传递整个构建环境,sysroot 可以作为提供已定义构建环境的替代方案。
要在 CMake 中使用 sysroot,需使用工具链文件。顾名思义,这些文件定义了编译和链接软件所使用的工具,并指明了查找任何库的路径。在正常的构建过程中,CMake 会通过系统自检自动检测工具链。工具链文件通过 CMAKE_TOOLCHAIN_FILE 变量传递给 CMake,方法如下:
cmake -S <source_dir> -B <binary_dir> -DCMAKE_TOOLCHAIN_FILE=
<path/to/toolchain.cmake>
从版本 3.21 开始,CMake 额外支持 --toolchain 选项来传递工具链文件,这相当于传递 CMAKE_TOOLCHAIN_FILE 缓存变量。
另外,工具链文件可以作为缓存变量通过 CMake 预设传递。最基本的情况下,使用 sysroot 的工具链文件会定义CMAKE_SYSROOT变量来指向 sysroot,CMAKE_<LANG>_COMPILER变量来指向与 sysroot 中的库兼容的编译器。为了避免混淆来自 sysroot 之外的依赖与安装在主机系统上的文件,通常还会设置控制find_命令查找位置的变量。一个最小的工具链文件可能如下所示:
set(CMAKE_SYSTEM_NAME Linux)
set(CMAKE_SYSROOT /path/to/sysroot/)
set(CMAKE_STAGING_PREFIX path/to/staging/directory)
set(CMAKE_C_COMPILER /path/to/sysroot/usr/bin/gcc-10)
set(CMAKE_CXX_COMPILER /path/to/sysroot/usr/bin/g++-10)
set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE BOTH)
让我们详细看看这里发生了什么:
-
首先,通过设置
CMAKE_SYSTEM_NAME变量,设置目标系统的系统名称。这是为其在 sysroot 内部编译文件的系统。 -
然后,通过设置
CMAKE_SYSROOT变量,设置 sysroot 本身的路径。CMAKE_STAGING_PREFIX是可选的,用于指定一个位置来安装项目的任何产物。指定一个暂存前缀有助于保持 sysroot 和主机文件系统的清洁,因为如果没有它,所有产物的安装都会发生在主机文件系统上。 -
接下来,通过设置
CMAKE_C_COMPILER和CMAKE_CXX_COMPILER变量,编译器会设置为与 sysroot 一起提供的编译器二进制文件。 -
最后,设置任何
find_命令在 CMake 中的搜索行为。CMAKE_FIND_ROOT_PATH_MODE_*变量可以取ONLY、NEVER和BOTH中的任何一个值。如果设置为ONLY,CMake 只会在 sysroot 内搜索该类型的文件;如果设置为NEVER,搜索将仅考虑主机文件结构。如果设置为BOTH,则会同时搜索主机系统路径和 sysroot 路径。需要注意的是,CMAKE_STAGING_PREFIX被视为系统路径,因此为了同时搜索 sysroot 和暂存目录,必须选择BOTH。在这个例子中,配置的方式是将所有头文件和库限制在 sysroot 中,而任何find_program的调用只会在主机系统中查找,find_package则会在两个地方查找。
设置CMAKE_SYSROOT变量不会自动设置构建产物的安装位置。在生成的二进制文件与主机系统兼容的情况下,这可能是预期的行为。在许多情况下,比如交叉编译时,这不是我们想要的行为,因此通常推荐设置CMAKE_STAGING_PREFIX。设置暂存目录有两个效果:首先,它会导致所有产物安装到暂存目录中;其次,暂存目录会被添加到find_命令的搜索前缀中。需要注意的是,暂存目录会被添加到CMAKE_SYSTEM_PREFIX_PATH,这带来的一个问题是,前面例子中的CMAKE_FIND_ROOT_PATH_MODE_XXX变量必须设置为BOTH,这样才能找到安装在暂存区域中的软件包、库和程序。
CMAKE_STAGING_PREFIX 和 CMAKE_INSTALL_PREFIX
如果同时设置了 CMAKE_STAGING_PREFIX 和 CMAKE_INSTALL_PREFIX,则 staging 前缀将具有优先权。因此,作为经验法则,只要工具链与主机系统兼容,通常可以省略 staging,或者倾向于定义它。
与容器相比,sysroots 的一个缺点是它们不能像容器那样启动并直接执行命令。因此,如果工具链和 sysroot 与主机平台不兼容,则生成的任何文件都无法执行,除非移至目标平台或使用仿真器。
总结
本章中,我们学习到 CMake 的主要优势之一是它在使用多种工具链为大量平台构建软件方面的多功能性。其缺点是,有时开发者难以找到适合的软件配置。不过,通过提供 CMake 预设、容器和 sysroots,通常可以更容易地开始 CMake 项目。
本章详细讲解了如何定义 CMake 预设来定义工作配置设置,并创建构建和测试定义。然后,我们简要介绍了如何创建 Docker 容器以及如何在容器内调用 CMake 命令,最后简要回顾了 sysroots 和工具链文件。有关工具链和 sysroots 的更多内容将在 第十二章 中讲解,跨平台编译和 自定义工具链。
在下一章中,您将学习如何将大型分布式项目作为超级构建进行处理。在那里,您将学习如何处理不同版本以及如何以可管理的方式从多个仓库组装项目。
问题
-
CMakePresets.json和CMakeUserPresets.json有何区别? -
预设如何在命令行中使用?
-
存在哪三种类型的预设,它们之间如何相互依赖?
-
配置预设应至少定义什么内容?
-
当从多个预设继承时,如果某个值被多次指定,哪个预设优先?
-
常见的构建容器工作策略有哪些?
-
用于与 sysroots 一起使用的工具链文件通常定义了什么?
答案
-
CMakePresets.json通常与项目一起维护和交付,而CMakeUserPresets.json则由用户维护。在语法和内容上,它们没有区别。 -
可以通过调用
cmake --preset=presetName、cmake --build --preset=presetName或ctest --preset=presetName来完成。 -
存在配置、构建、打包、测试和工作流预设。构建、测试和打包预设依赖于配置预设来确定
build目录。 -
配置预设应定义名称、生成器和要使用的构建目录。
-
第一个设置值的预设具有优先权。
-
这可以通过使用编辑器对构建容器的本地支持、从容器内运行编辑器,或者每次启动容器以在其中调用单个命令来完成。
-
它们定义了系统名称、sysroot 的位置、要使用的编译器以及
find_命令的行为方式。
第十章:在超级构建中处理分布式仓库和依赖项
正如我们现在应该已经了解的,每个大项目都有自己的依赖项。处理这些依赖项最简单的方法是使用包管理器,如Conan或vcpkg。但是,使用包管理器并不总是可行的,可能是由于公司政策、项目需求或资源不足。因此,项目作者可能会考虑使用传统的老式方式来处理依赖项。处理这些依赖项的常见方式可能包括将所有依赖项嵌入到仓库的构建代码中。或者,项目作者可能决定让最终用户从头开始处理依赖项。这两种方式都不太清晰,各有缺点。如果我告诉你有一个折衷方案呢?欢迎使用超级构建方法。
超级构建是一种可以用于将满足依赖关系所需的逻辑与项目代码解耦的方法,就像包管理器的工作原理一样。事实上,我们可以把这种方法称为穷人的包管理器。将依赖逻辑与项目代码分离,使我们能够拥有更灵活、更易于维护的项目结构。在本章中,我们将详细学习如何实现这一点。
为了理解本章分享的技巧,我们将涵盖以下主要内容:
-
超级构建的要求和前提条件
-
跨多个代码仓库构建
-
确保超级构建中的版本一致性
让我们从技术要求开始。
技术要求
在深入本章之前,你应该已经掌握了第五章的内容,整合第三方库 和依赖管理。本章将采用以实例教学的方式,因此建议从github.com/PacktPublishing/CMake-Best-Practices---2nd-Edition/tree/main/chapter10获取本章的示例内容。所有示例都假设你将使用项目中提供的容器,项目地址为github.com/PacktPublishing/CMake-Best-Practices---2nd-Edition。
让我们从检查超级构建的前提和要求开始学习。
超级构建的要求和前提条件
超级构建可以结构化为一个大型构建,构建多个项目,或者作为一个项目内部的子模块来处理依赖。因此,获取仓库的手段是必须的。幸运的是,CMake 提供了稳定且成熟的方式来实现这一点。举几个例子,ExternalProject 和 FetchContent 是处理外部依赖的最流行的 CMake 模块。在我们的示例中,我们将使用 FetchContent CMake 模块,因为它更简洁且易于处理。请注意,使用 CMake 提供的手段并不是强制要求,而是一种便捷方式。超级构建也可以通过使用版本控制系统工具来结构化,比如 git submodule 或 git subtree。由于本书的重点是 CMake,而 Git 对 FetchContent 的支持也相当不错,我们倾向于使用它。
现在就到这里。让我们继续学习如何构建跨多个代码仓库的项目。
跨多个代码仓库进行构建
软件项目,无论是直接的还是间接的,都涉及多个代码仓库。处理本地项目代码是最简单的,但软件项目很少是独立的。如果没有合适的依赖管理策略,事情可能会很快变得复杂。本章的第一个建议是如果可能的话,使用包管理器或依赖提供者。正如在 第五章 中所描述的,集成第三方库和依赖管理,包管理器大大减少了在依赖管理上花费的精力。如果你不能使用预构建的包管理器,你可能需要为你的项目创建一个专门的迷你包管理器,这就是所谓的 超级构建。
超级构建主要用于使项目在依赖方面自给自足,也就是说,项目能够在不需要用户干预的情况下满足自身的依赖。拥有这样的能力对所有使用者来说都非常方便。为了演示这种技术,我们将从一个这种场景的示例开始。让我们开始吧。
创建超级构建的推荐方式 – FetchContent
我们将按照 Chapter 10``, Example 01 来进行这一部分的学习。让我们像往常一样,首先检查 Chapter 10``, Example 01 的 CMakeLists.txt 文件。为了简便起见,前七行被省略了:
if(CH10_EX01_USE_SUPERBUILD)
include(superbuild.cmake)
else()
find_package(GTest 1.10.0 REQUIRED)
find_package(benchmark 1.6.1 REQUIRED)
endif()
add_executable(ch10_ex01_tests)
target_sources(ch10_ex01_tests PRIVATE src/tests.cpp)
target_link_libraries(ch10_ex01_tests PRIVATE GTest::Main)
add_executable(ch10_ex01_benchmarks)
target_sources(ch10_ex01_benchmarks PRIVATE src
/benchmarks.cpp)
target_link_libraries(ch10_ex01_benchmarks PRIVATE
benchmark::benchmark)
如我们所见,这是一个简单的 CMakeLists.txt 文件,定义了两个目标,分别命名为 ch10_ex01_tests 和 ch10_ex01_benchmarks。这两个目标分别依赖于 Google Test 和 Google Benchmark 库。这些库通过超级构建或 find_package(…) 调用来查找和定义,具体取决于 CH10_EX01_USE_SUPERBUILD 变量。find_package(…) 路径是我们到目前为止所采用的方式。让我们一起检查超级构建文件 superbuild.cmake:
include(FetchContent)
FetchContent_Declare(benchmark
GIT_REPOSITORY https://github.com/google/benchmark.git
GIT_TAG v1.6.1
)
FetchContent_Declare(GTest
GIT_REPOSITORY https://github.com/google/googletest.git
GIT_TAG release-1.10.0
)
FetchContent_MakeAvailable(GTest benchmark)
add_library(GTest::Main ALIAS gtest_main)
在第一行中,我们包含了FetchContent CMake 模块,因为我们将利用它来处理依赖关系。在接下来的六行中,使用FetchContent_Declare函数声明了两个外部目标,benchmark和GTest,并指示它们通过 Git 获取。因此,调用了FetchContent_MakeAvailable(…)函数以使声明的目标可用。最后,调用add_library(…)来定义一个名为GTest::Main的别名目标,指向gtest_main目标。这样做是为了保持find_package(…)和超级构建目标名称之间的兼容性。对于benchmark,没有定义别名目标,因为它的find_package(…)和超级构建目标名称已经兼容。
让我们通过调用以下命令来配置和构建示例:
cd chapter_10/ex01_external_deps
cmake -S ./ -B build -DCH10_EX01_USE_SUPERBUILD:BOOL=ON
cmake --build build/ --parallel $(nproc)
在前两行中,我们进入example10/ex01文件夹并配置项目。请注意,我们将CH10_EX01_USE_SUPERBUILD变量设置为ON,以启用超级构建代码。在最后一行,我们通过N个并行作业构建项目,其中N是nproc命令的结果。
由于替代的find_package(...)路径,构建在没有启用超级构建的情况下也能正常工作,前提是环境中有google test >= 1.10.0和google benchmark >= 1.6.1。这将允许包维护者在不修补项目的情况下更改依赖项版本。像这样的细小定制点对于可移植性和可重复性非常重要。
接下来,我们将查看一个使用ExternalProject模块而不是FetchContent模块的超级构建示例。
传统方法 – ExternalProject_Add
在FetchContent出现之前,大多数人通过使用ExternalProject_Add CMake 函数实现了超级构建方法。该函数由ExternalProject CMake 模块提供。在本节中,我们将通过ExternalProject_Add查看一个超级构建示例,以了解它与使用FetchContent模块的区别。
让我们一起看看Chapter 10, Example 02中的CMakeLists.txt`文件(注释和项目指令已省略):
# ...
include(superbuild.cmake)
add_executable(ch10_ex02_tests)
target_sources(ch10_ex02_tests PRIVATE src/tests.cpp)
target_link_libraries(ch10_ex02_tests PRIVATE catch2)
同样,这个项目是一个单元测试项目,包含一个 C++源文件,不过这次使用的是catch2而不是 Google Test。CMakeLists.txt文件直接包含了superbuild.cmake文件,定义了一个可执行目标,并将Catch2库链接到该目标。你可能已经注意到,这个示例没有使用FindPackage(...)来发现Catch2库。原因在于,与FetchContent不同,ExternalProject是在构建时获取并构建外部依赖项。由于Catch2库的内容在配置时不可用,我们无法在此使用FindPackage(...)。FindPackage(…)在配置时运行,并需要包文件存在。让我们也看看superbuild.cmake:
include(ExternalProject)
ExternalProject_Add(catch2_download
GIT_REPOSITORY https://github.com/catchorg/Catch2.git
GIT_TAG v2.13.9
INSTALL_COMMAND ""
# For disabling the warning that treated as an error
CMAKE_ARGS -DCMAKE_CXX_FLAGS="-Wno-error=pragmas"
)
SET(CATCH2_INCLUDE_DIR ${CMAKE_CURRENT_BINARY_DIR}
/catch2_download-
prefix/src/catch2_download/single_include)
file(MAKE_DIRECTORY ${CATCH2_INCLUDE_DIR})
add_library(catch2 IMPORTED INTERFACE GLOBAL)
add_dependencies(catch2 catch2_download)
set_target_properties(catch2 PROPERTIES "INTERFACE_INCLUDE_
DIRECTORIES" "${CATCH2_INCLUDE_DIR}")
superbuild.cmake 模块包含了 ExternalProject CMake 模块。代码调用了 ExternalProject_Add 函数来声明一个名为 catch2_download 的目标,并指定 GIT_REPOSITORY、GIT_TAG、INSTALL_COMMAND 和 CMAKE_ARGS 参数。如你从前面的章节中回忆的那样,ExternalProject_Add 函数可以从不同的源获取依赖项。我们的示例是尝试通过 Git 获取依赖项。GIT_REPOSITORY 和 GIT_TAG 参数分别用于指定目标 Git 仓库的 URL 和 git clone 后需要签出的标签。由于 Catch2 是一个 CMake 项目,因此我们需要提供给 ExternalProject_Add 函数的参数最少。ExternalProject_Add 函数默认知道如何配置、构建和安装一个 CMake 项目,因此无需 CONFIGURE_COMMAND 或 BUILD_COMMAND 参数。空的 INSTALL_COMMAND 参数用于禁用并安装构建后的依赖项。最后一个参数 CMAKE_ARGS 用于向外部项目的配置步骤传递 CMake 参数。我们用它来抑制一个关于 Catch2 编译中遗留 pragma 的 GCC 警告(将其视为错误)。
ExternalProject_Add 命令将所需的库拉取到一个前缀路径并进行构建。因此,要使用已获取的内容,我们首先需要将其导入到项目中。由于我们不能使用 FindPackage(...) 让 CMake 来处理库的导入工作,因此我们需要做一些手动操作。其中一项工作是定义 Catch2 目标的 include 目录。由于 Catch2 是一个仅包含头文件的库,定义一个包含头文件的接口目标就足够了。我们声明了 CATCH2_INCLUDE_DIR 变量来设置包含 Catch2 头文件的目录。我们使用该变量来设置在此示例中创建的导入目标的 INTERFACE_INCLUDE_DIRECTORIES 属性。接下来,调用文件(MAKE_DIRECTORY ${CATCH2_INCLUDE_DIR})的 CMake 命令来创建包含目录。之所以这么做,是因为 ExternalProject_Add 的工作方式,Catch2 的内容直到构建步骤执行时才会出现。设置目标的 INTERFACE_INCLUDE_DIRECTORIES 需要确保给定的目录已经存在,所以我们通过这种小技巧来解决这个问题。在最后三行中,我们为 Catch2 声明了一个 IMPORTED INTERFACE 库,使该库依赖于 catch2_download 目标,并设置了导入库的 INTERFACE_INCLUDE_DIRECTORIES。
让我们尝试配置并构建我们的示例,检查它是否能够正常工作:
cd chapter_10/ex02_external_deps_with_extproject
cmake -S ./ -B build
cmake --build build/ --parallel $(nproc)
如果一切顺利,你应该会看到类似于以下的输出:
[ 10%] Creating directories for 'catch2_download'
[ 20%] Performing download step (git clone) for
'catch2_download'
Cloning into 'catch2_download'...
HEAD is now at 62fd6605 v2.13.9
[ 30%] Performing update step for 'catch2_download'
[ 40%] No patch step for 'catch2_download'
[ 50%] Performing configure step for 'catch2_download'
/* ... */
[ 60%] Performing build step for 'catch2_download'
/* ... */
[ 70%] No install step for 'catch2_download'
[ 80%] Completed 'catch2_download'
[ 80%] Built target catch2_download
/* ... */
[100%] Built target ch10_ex02_tests
好的,看来我们已经成功构建了测试可执行文件。让我们运行它,检查它是否正常工作,通过运行 ./``build/ch10_ex02_tests 可执行文件:
===========================================================
All tests passed (4 assertions in 1 test case)
接下来,我们将看到一个简单的 Qt 应用程序,使用来自超构建的 Qt 框架。
奖励 – 使用 Qt 6 框架与超构建
到目前为止,我们已经处理了那些占用空间较小的库。现在让我们尝试一些更复杂的内容,比如在超级构建中使用像 Qt 这样的框架。在这一部分,我们将跟随第十章,示例03进行操作。
重要提示
如果你打算在提供的 Docker 容器外尝试这个示例,可能需要安装一些 Qt 运行时所需的附加依赖项。Debian 类系统所需的包如下:libgl1-mesa-dev libglu1-mesa-dev '^libxcb.*-dev' libx11-xcb-dev libglu1-mesa-dev libxrender-dev libxi-dev libxkbcommon-dev libxkbcommon-x11-dev。
该示例包含一个源文件main.cpp,它输出一个简单的 Qt 窗口应用程序,并带有一条消息。实现如下:
#include <qapplication.h>
#include <qpushbutton.h>
int main( int argc, char **argv )
{
QApplication a( argc, argv );
QPushButton hello( "Hello from CMake Best Practices!",
0 );
hello.resize( 250, 30 );
hello.show();
return a.exec();
}
我们的目标是能够编译这个 Qt 应用程序,而不需要用户自己安装 Qt 框架。超级构建应该自动安装 Qt 6 框架,并且应用程序应该能够使用它。接下来,让我们像往常一样看看这个示例的 CMakeLists.txt 文件:
if(CH10_EX03_USE_SUPERBUILD)
include(superbuild.cmake)
else()
set(CMAKE_AUTOMOC ON)
set(CMAKE_AUTORCC ON)
set(CMAKE_AUTOUIC ON)
find_package(Qt6 COMPONENTS Core Widgets REQUIRED)
endif()
add_executable(ch10_ex03_simple_qt_app main.cpp)
target_compile_features(ch10_ex03_simple_qt_app PRIVATE
cxx_std_11)
target_link_libraries(ch10_ex03_simple_qt_app Qt6::Core
Qt6::Widgets)
和第一个示例一样,CMakeLists.txt 文件根据一个 option 标志包含了 superbuild.cmake 文件。如果用户选择使用超级构建,例如,它将包含超级构建模块。否则,依赖项将通过 find_package(...) 尝试在系统中找到。在最后的三行中,定义了一个可执行目标,为该目标设置了 C++ 标准,并将该目标与 QT6::Core 和 QT6::Widgets 目标链接。这些目标要么由超级构建定义,要么通过 find_package(...) 调用找到,具体取决于用户是否选择使用超级构建。接下来,让我们继续看看 superbuild.cmake 文件:
include(FetchContent)
message(STATUS "Chapter 10, example 03 superbuild enabled.
Will try to satisfy dependencies for the example.")
set(FETCHCONTENT_QUIET FALSE) # Enable message output for
FetchContent commands
set(QT_BUILD_SUBMODULES "qtbase" CACHE STRING "Submodules
to build")
set(QT_WILL_BUILD_TOOLS on)
set(QT_FEATURE_sql off)
set(QT_FEATURE_network off)
set(QT_FEATURE_dbus off)
set(QT_FEATURE_opengl off)
set(QT_FEATURE_testlib off)
set(QT_BUILD_STANDALONE_TESTS off)
set(QT_BUILD_EXAMPLES off)
set(QT_BUILD_TESTS off)
FetchContent_Declare(qt6
GIT_REPOSITORY https://github.com/qt/qt5.git
GIT_TAG v6.3.0
GIT_SHALLOW TRUE
GIT_PROGRESS TRUE # Since the clone process is lengthy,
show progress of download
GIT_SUBMODULES qtbase # The only QT submodule we need
)
FetchContent_MakeAvailable(qt6)
superbuild.cmake 文件使用 FetchContent 模块来获取 Qt 依赖项。由于获取和准备 Qt 可能需要较长时间,因此禁用了某些未使用的 Qt 框架功能。启用了 FetchContent 消息输出,以便更好地跟踪进度。让我们通过运行以下命令来配置并编译示例:
cd chapter_10/ex03_simple_qt_app/
cmake -S ./ -B build -DCH10_EX03_USE_SUPERBUILD:BOOL=ON
cmake --build build/ --parallel $(nproc)
如果一切如预期那样进行,你应该看到类似于这里展示的输出:
/*...*/
[ 11%] Creating directories for 'qt6-populate'
[ 22%] Performing download step (git clone) for
'qt6-populate'
Cloning into 'qt6-src'...
/*...*/
[100%] Completed 'qt6-populate'
[100%] Built target qt6-populate
/*...*/
-- Configuring done
-- Generating done
/*...*/
[ 0%] Generating ../../mkspecs/modules
/qt_lib_widgets_private.pri
[ 0%] Generating ../../mkspecs/modules
/qt_lib_gui_private.pri
[ 0%] Generating ../../mkspecs/modules/qt_lib_core_private.pri
/* ... */
[ 98%] Linking CXX executable ch10_ex03_simple_qt_app
[ 98%] Built target ch10_ex03_simple_qt_app
/*...*/
如果一切顺利,你已经成功编译了示例。让我们通过运行以下命令检查它是否正常工作:
./build/ch10_ex03_simple_qt_app
如果一切正常,一个小的图形界面窗口应该会弹出。该窗口应类似于下图所示:
图 10.1 – 简单的 Qt 应用程序窗口
解决了这个问题后,我们已经完成了如何在 CMake 项目中使用超级构建的讲解。接下来,我们将探讨如何确保超级构建中的版本一致性。
确保超级构建中的版本一致性
版本一致性是所有软件项目中的一个重要方面。正如你现在应该已经了解到的,软件世界中的一切都不是一成不变的。软件随着时间的发展而演变和变化。这些变化往往需要提前得到确认,要么通过对新版本运行一系列测试,要么通过对消费代码本身进行修改。理想情况下,上游代码的变化不应该影响现有构建的复现,除非我们希望它们产生影响。如果软件验证和测试已经针对某一组合完成,那么一个项目的x.y版本应该始终与z.q依赖版本一起构建。原因在于,即便没有 API 或 ABI 的变化,上游依赖中的最小变动也可能影响你的软件行为。如果没有提供版本一致性,你的软件将没有明确定义的行为。因此,提供版本一致性的方法非常关键。
在超构建中确保版本一致性取决于超构建的组织方式。对于通过版本控制系统获取的代码库来说,相对容易。与其克隆项目并按原样使用,不如切换到特定的分支或标签。如果没有这些锚点,可以切换到特定的提交。这样可以确保你的超构建具备前瞻性。但即使这样也可能不足够。标签可能被覆盖,分支可能被强制推送,历史可能被重写。为了降低这种风险,你可以选择分叉项目并使用该分叉作为上游。这样,你就可以完全控制上游内容。但请记住,这种方法带来了维护的负担。
这个故事的寓意是,不要盲目跟踪上游。始终关注最新的变化。对于作为归档文件使用的第三方依赖,始终检查它们的哈希摘要。通过这种方式,你可以确保你确实使用了项目的目标版本,如果有任何变化,你必须手动确认它。
总结
本章简要介绍了超构建的概念,以及如何利用超构建进行依赖管理。超构建是一种非侵入性且强大的依赖管理方式,适用于缺少包管理器的情况。
在下一章中,我们将详细探讨如何为苹果生态系统构建软件。由于苹果的封闭性以及 macOS 和 iOS 的紧密集成,在针对这些平台时需要考虑一些事项。
问题
完成本章后,你应该能够回答以下问题:
-
什么是超构建(super-build)?
-
我们在哪些主要场景下可以使用超构建(super-build)?
-
为了在超构建中实现版本一致性,可以采取哪些措施?
答案
-
超构建是一种构建软件项目的方法,跨越多个代码库。
-
在没有包管理器的情况下,我们希望让项目能够满足自身的依赖关系。
-
使用锚点,如分支、标签或提交哈希值。
第十一章:为 Apple 系统创建软件
在软件开发领域,为 Apple 平台——macOS、iOS、watchOS 和 tvOS——构建应用程序具有一套独特的要求和最佳实践。由于封闭的生态系统,Apple 平台有一些独特的构建特点,尤其是对于具有图形用户界面和更复杂库框架的应用程序。针对这些情况,Apple 使用了名为应用程序包(app bundle)和框架(framework)的特定格式。本章深入探讨了如何有效地使用 CMake 这一强大的跨平台构建系统,针对 Apple 生态系统进行开发。无论你是希望简化工作流程的资深开发者,还是渴望探索 Apple 特定开发的新人,本章将为你提供掌握这一过程所需的知识和工具。
在本章中,我们将涵盖以下主要内容:
-
使用 XCode 作为 CMake 生成器
-
创建应用程序包
-
创建 Apple 库框架
-
为 Apple Store 使用的软件进行签名
技术要求
与前面章节相同,示例在 macOS Sonoma 14.2 上使用 CMake 3.24 进行了测试,并可在以下编译器中运行:
-
Apple Clang 15 或更新版本
-
Xcode 15.4
有些示例要求你已为 Xcode 设置了代码签名,这需要一个已加入开发者计划的 Apple ID。
所有示例和源代码可以在本书的 GitHub 仓库中找到:github.com/PacktPublishing/CMake-Best-Practices---2nd-Edition。
如果缺少任何软件,相应的示例将从构建中排除。
在 Apple 上使用 CMake 开发
在 macOS 上开发时,理解 Apple 的生态系统和开发期望非常重要。Apple 强烈鼓励开发者使用 Xcode 作为主要开发环境,CMake 可以为其生成构建指令。Xcode 是一个包含代码编辑器、调试器以及其他专为 macOS 和 iOS 开发设计的工具的综合工具套件。Apple 会频繁更新 Xcode,新增功能、修复 bug 以及提升性能。因此,开发者需要定期升级到最新版本的 Xcode,以确保兼容性并充分利用新功能。
以下命令为 Xcode 生成构建指令:
cmake -S . -B ./build -G Xcode
在运行前述命令后,CMake 将在构建目录中生成一个以 .xcodeproj 后缀的 Xcode 项目文件。该项目可以直接在 Xcode 中打开,或者通过调用以下命令,使用 CMake 构建项目:
cmake --build ./build
如果项目已由 CMake 更新,Xcode 将检测到更改并重新加载项目。
虽然 Xcode 是首选的 IDE,但并非唯一的选择。例如,Visual Studio Code(VS Code)是一个广受欢迎的替代方案,许多开发者使用它,因为它的多功能性和丰富的扩展生态系统。
虽然 Xcode 是推荐的 macOS 生成器,但也可以选择其他生成器,如 Ninja 或 Makefile。尽管它们缺乏一些 Apple 集成的特性,但它们轻量且也可以用于构建简单的应用程序和库。
CMake 中的 Xcode 生成器会创建可以直接在 Xcode 中打开和管理的项目文件。这确保了构建过程能够利用 Xcode 的功能,如优化的构建设置、资源管理以及与 macOS 特性的无缝集成。
Xcode 支持各种构建设置,这些设置可以通过 CMake 使用XCODE_ATTRIBUTE_<SETTING>属性进行配置。要获取所有可能设置的列表,可以参考 Apple 的开发者文档,或者运行以下构建目录调用:
xcodebuild -showBuildSettings
Xcode 属性可以全局设置,也可以针对每个目标进行设置,如下例所示:
set(CMAKE_XCODE_ATTRIBUTE_CODE_SIGN_IDENTITY "Apple Development")
set(CMAKE_XCODE_ATTRIBUTE_DEVELOPMENT_TEAM "12345ABC")
add_executable(ch11_hello_world_apple src/main.mm)
set_target_properties(ch11_hello_world_apple PROPERTIES
XCODE_ATTRIBUTE_CXX_LANGUAGE_STANDARD «c++17»
XCODE_ATTRIBUTE_ENABLE_HARDENED_RUNTIME[variant=Release] "YES"
)
如示例所示,Xcode 属性也可以仅为特定的构建变体设置,通过在[variant=ConfigName]后缀中指定,其中配置是常见的 CMake 构建变体,如Debug和Release。如果使用不同于 Xcode 的生成器来构建项目,这些属性将无效。要创建简单的应用程序,选择 Xcode 生成器并设置适当的属性就足够了。然而,要为 Apple 构建和分发更复杂的软件,就必须深入研究应用程序包和 Apple 框架。
Apple 应用程序包
Apple 应用程序包是 macOS 和 iOS 中用于打包和组织应用程序所需所有文件的目录结构。这些文件包括可执行二进制文件、资源文件(如图片和声音)、元数据(如应用程序的Info.plist文件)等。应用程序包使得分发和管理应用程序变得更加容易,因为它们将所有必要的组件封装在一个单独的目录中,用户可以像处理单个文件一样移动、复制或删除该目录。应用程序包在 Finder 中显示为单个文件,但实际上它们是具有特定结构的目录,如下所示:
MyApp.app
└── Contents
├── MacOS
│ └── MyApp (executable)
├── Resources
│ ├── ...
├── Info.plist
├── Frameworks
│ └── ...
└── PlugIns
└── ...
值得注意的是,这个结构被扁平化处理,适用于 iOS、tvOS 和 watchOS 目标平台,这由 CMake 本身处理。要将可执行文件标记为一个应用包,需要将MACOSX_BUNDLE关键字添加到目标中,如下所示:
add_executable(myApp MACOSX_BUNDLE)
设置关键字会告诉 CMake 创建应用程序包的目录结构并生成一个Info.plist文件。Info.plist文件是应用程序包中的关键文件,因为它包含了该包的必要配置。CMake 会生成一个默认的Info.plist文件,在许多情况下它已经很好用了。然而,你可以通过指定模板文件的路径来使用自定义的Info.plist模板,方法是使用MACOSX_BUNDLE_INFO_PLIST属性,像这样在目标上指定:
set_target_properties(hello_world PROPERTIES
MACOSX_BUNDLE_INFO_PLIST ${CMAKE_SOURCE_DIR}/Info.plist.in
)
模板文件使用与configure_file相同的语法,如第八章所述,使用 CMake 执行自定义任务。一些标识符会从 CMake 自动设置。下面是一个自定义Info.plist.in模板的示例:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleIdentifier</key>
<string>@MACOSX_BUNDLE_IDENTIFIER@</string>
<key>CFBundleName</key>
<string>@MACOSX_BUNDLE_NAME@</string>
<key>CFBundleVersion</key>
<string>@MACOSX_BUNDLE_VERSION@</string>
<key>CFBundleExecutable</key>
<string>@MACOSX_BUNDLE_EXECUTABLE@</string>
</dict>
</plist>
这样,一个基本的应用程序包就可以定义并构建了。虽然这在 Xcode 生成器下效果最好,但创建文件结构和Info.plist文件在其他生成器如 Ninja 或 Makefile 中也能工作。然而,如果应用程序包含界面构建器文件或故事板,Xcode 是唯一的选择。接下来我们来看看如何包含资源文件。
Apple 应用程序包的资源文件
Apple 提供了自己的 SDK 来构建应用程序,它支持故事板或界面构建器文件来定义用户界面。为了创建包,这些文件会被编译成 Apple 特有的资源格式,并放置在包中的适当位置。只有 Xcode 生成器支持自动处理这些文件,因此在这时,使用其他生成器几乎没有什么用处。
故事板和界面构建器源文件必须作为源文件包含在add_executable()或target_sources()命令中。为了确保它们被自动编译并复制到包内的正确位置,这些源文件应该设置MACOSX_PACKAGE_LOCATION文件属性。为了避免重新输入所有文件名,将它们放入变量中会很方便:
set(resource_files
storyboards/Main.storyboard
storyboards/My_Other.storyboard
)
add_executable(ch11_app_bundle_storyboard MACOSX_BUNDLE src/main.mm)
target_sources(ch11_app_bundle_storyboard PRIVATE ${resource_files})
set_source_files_properties(${resource_files} PROPERTIES MACOSX_PACKAGE_LOCATION Resources)
到此为止,你应该已经设置好创建 Apple 应用程序包来生成可执行文件了。如果你想为 Apple 构建易于重新分发的复杂库,那么使用框架是最佳选择。
Apple 框架
macOS 框架是用于简化 macOS 应用程序开发的可重用类、函数和资源的集合。它们提供了一种结构化的方式来访问系统功能,并与 macOS 无缝集成。
链接框架与链接普通库非常相似。首先,使用find_package或find_library找到它们,然后使用target_link_libraries,只是语法稍有不同。以下示例将Cocoa框架链接到ch11_hello_world_apple目标:
target_link_libraries(ch11_hello_world_apple PRIVATE
"-framework Cocoa")
请注意,–framework关键字是必要的,而且框架的名称应该加引号。如果链接多个框架,它们都需要分别加引号。
既然我们可以使用框架,接下来让我们来看看如何创建自己的框架。框架与应用程序包非常相似,但有一些区别。特别是,在 macOS 上可以安装同一框架的多个版本。除了资源之外,框架还包含使用它所需的头文件和库。让我们来看看框架的文件结构,以便理解框架如何支持多个版本,并填充头文件:
MyFramework.framework/
│
├── Versions/
│ ├── Current -> A/
│ ├── A/
│ │ ├── Headers
│ │ │ └── ...
│ │ ├── PrivateHeaders
│ │ │ └── ...
│ │ ├── Resources
│ │ │ ├── Info.plist
│ │ │ └── ...
│ │ ├── MyFramework
├── MyFramework -> Versions/Current/MyFramework
├── Resources -> Versions/Current/Resources
├── Headers -> Versions/Current/Headers
├── PrivateHeaders -> Versions/Current/PrivateHeaders
└── Info.plist -> Versions/Current/Resources/Info.plist
框架的顶层必须以 .framework 结尾,通常,除了 Versions 文件夹外,所有顶层文件都是指向 Versions 文件夹中文件或文件夹的符号链接。
Versions 文件夹包含不同版本的库。目录的名称可以是任意的,但通常,它们会被命名为 A、B、C 等,或者使用数字版本。
无论使用哪种约定,都需要有一个名为 Current 的符号链接,并且该链接应该指向最新版本。每个版本必须有一个名为 Resources 的子文件夹,该文件夹包含如在应用程序包部分所述的 Info.plist 文件。
CMake 支持通过设置目标的 FRAMEWORK 和 FRAMEWORK_VERSION 属性来创建 macOS 框架:
add_library(ch11_framework_example SHARED)
set_target_properties(
ch11_framework_example
PROPERTIES FRAMEWORK TRUE
FRAMEWORK_VERSION 1
PUBLIC_HEADER include/hello.hpp
PRIVATE_HEADER src/internal.hpp
)
由于框架通常包含头文件,因此可以使用 PUBLIC_HEADER 和 PRIVATE_HEADER 属性来指定它们,以便将它们复制到正确的位置。可以使用 MACOSX_FRAMEWORK_INFO_PLIST 属性为框架设置自定义的 Info.plist 文件。如果没有提供自定义的 Info.plist 文件,CMake 将生成一个默认文件,在大多数情况下这是足够的。
到目前为止,我们已经介绍了构建 macOS 软件的基础知识,但有一点是缺失的,那就是对代码进行签名,以便将其发布到 Mac 上。
macOS 的代码签名
对于许多使用场景,创建未签名的应用或框架可能已经足够;然而,如果应用程序需要通过 macOS 的官方渠道进行分发,则必须进行签名。签名可以通过 Xcode 本身进行;但是,也可以使用 CMake 来进行签名。
要进行签名,需要三个信息:包或框架的 ID、开发团队 ID 和 代码签名实体。可以使用 XCODE_ATTRIBUTE_DEVELOPMENT_TEAM 和 XCODE_ATTRIBUTE_CODE_SIGN_ENTITY Xcode 属性来设置这些值。通常,这些设置是在项目级别进行,而不是针对单独的目标:
set(CMAKE_XCODE_ATTRIBUTE_CODE_SIGN_IDENTITY "Apple Development" CACHE STRING "")
set(CMAKE_XCODE_ATTRIBUTE_DEVELOPMENT_TEAM "12345ABC" CACHE STRING "")
签名身份表示证书提供者,通常可以保持为 "Apple Development",这将使 Xcode 选择适当的签名身份。在 Xcode 11 之前,签名身份必须设置为 "Mac Developer"(用于 macOS)或 "iPhone Developer"(用于 iOS、tvOS 或 watchOS 应用程序)。团队 ID 是一个 10 位的代码,分配给 Apple 开发者帐户,可以在 Apple 的开发者门户网站上创建 (developer.apple.com)。可以通过 Xcode 下载签名证书。
总结
苹果生态系统在处理上有些特殊,因为其封闭的设计与 Linux 甚至 Windows 相比有很大不同,但尤其是在移动市场,如果不想失去一个重要市场,通常无法避免为 Apple 构建应用。通过本章中的信息,如创建应用程序包和框架以及签署软件,你应该能够开始为 Apple 部署应用程序。虽然为 Apple 构建应用需要使用 Xcode,并且可能还需要拥有 Apple 硬件,但这并不是所有其他平台的情况。CMake 擅长于平台独立性并能够跨不同平台构建软件,这正是我们将在下一章讨论的内容。
问题
-
哪种生成器最适合为 Apple 构建软件?
-
如何设置 Xcode 属性?
-
关于不同版本,应用程序包和框架之间有什么区别?
-
签署应用程序包或框架需要什么?
答案
-
尽管不同的生成器可以用于 Apple,推荐使用 Xcode。
-
通过设置
CMAKE_XCODE_ATTRIBUTE_<ATTRIBUTE>变量或XCODE_ATTRIBUTE_<ATTRIBUTE>属性来实现。 -
每次只能安装一个版本的应用程序包,而可以同时安装多个版本的框架。
-
要为 Apple 签署软件,你需要一个已注册开发者计划的 Apple ID 和 Xcode。
第三部分 – 掌握细节
在这一部分,你将能够在跨平台项目中使用 CMake,重用 CMake 代码,优化和维护现有项目,并将非 CMake 项目迁移到 CMake。你还将了解如何为 CMake 项目做出贡献以及推荐的进一步阅读材料:
-
第十二章**, 跨平台编译自定义工具链
-
第十三章**, 重用 CMake 代码
-
第十四章**,优化和维护 CMake 项目
-
第十五章**,迁移到 CMake
-
附录**,为 CMake 做出贡献和进一步阅读材料
第十二章:跨平台编译自定义工具链
CMake 的一个强大特性是它对跨平台软件构建的支持。简单来说,这意味着通过 CMake,可以将任何平台的项目构建为任何其他平台的软件,只要在运行 CMake 的系统上提供必要的工具。在构建软件时,我们通常谈论编译器和链接器,它们当然是构建软件的必需工具。然而,如果我们仔细看看,构建软件时通常还涉及一些其他工具、库和文件。统称这些工具、库和文件通常被称为 CMake 中的工具链。
到目前为止,本书中的所有示例都是针对 CMake 运行所在的系统构建的。在这些情况下,CMake 通常能很好地找到正确的工具链。然而,如果软件是为另一个平台构建的,通常必须由开发者指定工具链。工具链定义可能相对简单,仅指定目标平台,或者可能复杂到需要指定单个工具的路径,甚至是为了为特定芯片组创建二进制文件而指定特定的编译器标志。
在交叉编译的上下文中,工具链通常伴随有root文件夹,用于查找编译和链接软件所需的库和文件,以便将软件编译到预期的目标平台。
虽然交叉编译一开始可能让人感到害怕,但使用 CMake 正确配置时,它通常并不像看起来那样困难。本章将介绍如何使用工具链文件以及如何自己编写工具链文件。我们将详细探讨在软件构建的不同阶段涉及哪些工具。最后,我们将介绍如何设置 CMake,使其能够通过模拟器运行测试。
本章将涵盖以下主要内容:
-
使用现有的跨平台工具链文件
-
创建工具链文件
-
测试交叉编译的二进制文件
-
测试工具链的支持特性
本章结束时,你将熟练掌握如何处理现有的工具链,并了解如何使用 CMake 为不同平台构建和测试软件。我们将深入探讨如何测试编译器的某个特性,以确定它是否适合我们的用途。
技术要求
与前几章一样,示例是用 CMake 3.25 进行测试的,并在以下任一编译器上运行:
-
GNU 编译器集合 9(GCC 9)或更新版本,包括用于arm 硬浮动(armhf)架构的交叉编译器
-
Clang 12 或更新版本
-
Microsoft Visual Studio C++ 19(MSVC 19)或更新版本
-
对于 Android 示例,Android 原生开发工具包(Android NDK)23b 或更新版本是必需的。安装说明可以在官方的 Android 开发文档中找到:
developer.android.com/studio/projects/install-ndk。 -
对于 Apple 嵌入式示例,建议使用 Xcode 12 或更新版本,以及iOS 软件开发工具包 12.4(iOS SDK 12.4)。
本书的所有示例和源代码都可以在 GitHub 仓库中找到。如果缺少任何软件,相应的示例将从构建中排除。仓库地址在这里:github.com/PacktPublishing/CMake-Best-Practices---2nd-Edition/。
使用现有的跨平台工具链文件
当为多个平台构建软件时,最直接的方法是直接在目标系统上进行编译。其缺点是每个开发者必须有一个正在运行的目标系统来进行构建。如果这些是桌面系统,可能会相对顺利,但在不同的安装环境之间迁移以开发软件也会使开发者的工作流程变得非常繁琐。像嵌入式系统这样不太强大的设备,由于缺乏适当的开发工具,或者因为编译软件非常耗时,可能会非常不方便。
因此,从开发者的角度来看,更便捷的方式是使用交叉编译。这意味着软件工程师在自己的机器上编写代码并构建软件,但生成的二进制文件是为不同平台的。构建软件的机器和平台通常称为主机机器和主机平台,而软件应运行的平台称为目标平台。例如,开发者在运行 Linux 的x64桌面机器上编写代码,但生成的二进制文件是为运行在arm64处理器上的嵌入式 Linux 系统设计的。因此,主机平台是x64 Linux,目标平台是arm64 Linux。要进行交叉编译软件,以下两项是必需的:
-
一个能够生成正确格式二进制文件的工具链
-
为目标系统编译的项目依赖项
工具链是一组工具,如编译器、链接器和归档器,用于生成在主机系统上运行,但为目标系统生成输出的二进制文件。依赖项通常会收集在一个sysroot目录中。Sysroot 是包含根文件系统精简版的目录,所需的库会存储在其中。对于交叉编译,这些目录作为搜索依赖项的根目录。
一些工具,例如 CMAKE_TOOLCHAIN_FILE 变量,或者从 CMake 3.21 开始,使用 --toolchain 选项,像这样:
cmake -DCMAKE_TOOLCHAIN_FILE=toolchain.cmake -S <SourceDir> -B
<BuildDir>
cmake --toolchain arm64.toolchain.cmake -S <SourceDir> -B <BuildDir>
这些调用是等效的。如果 CMAKE_TOOLCHAIN_FILE 被设置为环境变量,CMake 也会进行解析。如果使用 CMake 预设,配置预设可能会通过 toolchainFile 选项配置工具链文件,像这样:
{
"name": "arm64-build-debug",
"generator" : "Ninja",
"displayName": "Arm 64 Debug",
"toolchainFile": "${sourceDir}/arm64.toolchain.cmake",
"cacheVariables": {
"CMAKE_BUILD_TYPE": "Debug"
}
},
toolchainFile选项支持宏扩展,具体描述请参见第九章,创建可复现的构建环境。如果工具链文件的路径是相对路径,CMake 会先在build目录下查找,如果在那里没有找到文件,它会从源目录开始查找。由于CMAKE_TOOLCHAIN_FILE是一个缓存变量,它只需要在第一次运行 CMake 时指定;之后的运行将使用缓存的值。
在第一次运行时,CMake 会执行一些内部查询来确定工具链支持哪些功能。这无论是否使用工具链文件指定工具链,或使用默认系统工具链时,都会发生。有关这些测试是如何执行的更深入的介绍,请参考测试工具链支持的功能部分。CMake 将在第一次运行时输出各种功能和属性的测试结果,类似如下:
-- The CXX compiler identification is GNU 9.3.0
-- The C compiler identification is GNU 9.3.0
-- Detecting CXX compiler ABI info
-- Detecting CXX compiler ABI info - done
-- Check for working CXX compiler: /usr/bin/arm-linux-gnueabihf-g++-
9 - skipped
-- Detecting CXX compile features
-- Detecting CXX compile features - done
-- Detecting C compiler ABI info
-- Detecting C compiler ABI info - done
-- Check for working C compiler: /usr/bin/arm-linux-gnueabi-gcc-9 -
skipped
-- Detecting C compile features
-- Detecting C compile features - done
功能检测通常发生在CMakeLists.txt文件中的第一次调用project()时。但是,任何启用先前禁用的语言的后续project()调用都会触发进一步的检测。如果在CMakeLists.txt文件中使用enable_language()来启用额外的编程语言,也会发生同样的情况。
由于工具链的功能和测试结果是被缓存的,因此无法更改已配置构建目录的工具链。CMake 可能会检测到工具链已经更改,但通常情况下,替换缓存变量是不完全的。因此,在更改工具链之前,应该完全删除构建目录。
配置后切换工具链
在切换工具链之前,请始终完全清空构建目录。仅删除CMakeCache.txt文件是不够的,因为与工具链相关的内容可能会被缓存到不同的位置。如果你经常为多个平台构建项目,使用为每个工具链分配的独立构建目录可以显著加快开发过程。
CMake 的工作方式是一个项目应该使用相同的工具链来进行所有操作。因此,直接支持使用多个工具链的方式并不存在。如果确实需要这样做,那么需要将需要不同工具链的项目部分配置为子构建,具体方法请参见第十章,在超构建中处理分布式仓库和依赖关系。
工具链应尽可能保持精简,并且与任何项目完全解耦。理想情况下,它们可以在不同的项目中复用。通常,工具链文件是与用于交叉编译的任何 SDK 或 sysroot 一起捆绑的。然而,有时它们需要手动编写。
创建工具链文件
工具链文件一开始可能看起来令人害怕,但仔细检查后,它们通常相对简单。定义跨编译工具链很难的误解源于互联网上存在许多过于复杂的工具链文件示例。许多示例是为早期版本的 CMake 编写的,因此实现了许多额外的测试和检查,而这些现在已经是 CMake 的一部分。CMake 工具链文件基本上做以下几件事:
-
定义目标系统和架构。
-
提供构建软件所需的任何工具的路径,这些工具通常只是编译器。
-
为编译器和链接器设置默认标志。
-
如果是跨编译,指向 sysroot 并可能指向任何暂存目录。
-
设置 CMake
find_命令的搜索顺序提示。更改搜索顺序是项目可能定义的内容,是否应将其放在工具链文件中或由项目处理是有争议的。有关find_命令的详细信息,请参见 第五章,集成第三方库和依赖管理。
一个执行所有这些操作的示例工具链可能如下所示:
set(CMAKE_SYSTEM_NAME Linux)
set(CMAKE_SYSTEM_PROCESSOR arm)
set(CMAKE_C_COMPILER /usr/bin/arm-linux-gnueabi-gcc-9)
set(CMAKE_CXX_COMPILER /usr/bin/arm-linux-gnueabihf-g++-9)
set(CMAKE_C_FLAGS_INIT -pedantic)
set(CMAKE_CXX_FLAGS_INIT -pedantic)
set(CMAKE_SYSROOT /home/builder/raspi-sysroot/)
set(CMAKE_STAGING_PREFIX /home/builder/raspi-sysroot-staging/)
set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE BOTH)
这个示例会定义一个工具链,目标是为在主机系统的 /usr/bin/ 文件夹上运行的 Linux 操作系统进行构建。接着,编译器标志设置为打印由严格的 -pedantic 标志要求的所有警告。然后,设置 sysroot 以查找任何所需的库,路径为 /home/builder/raspi-sysroot/,并设置跨编译时用于安装内容的暂存目录为 /home/builder/raspi-sysroot-staging/。最后,改变 CMake 的搜索行为,使得程序仅在主机系统上搜索,而库、include 文件和包仅在 sysroot 中搜索。关于工具链文件是否应该影响搜索行为,存在争议。通常,只有项目知道它正在尝试查找什么,因此在工具链文件中做假设可能会破坏这一点。然而,只有工具链知道应该使用哪个系统根目录以及其中包含哪些类型的文件,因此让工具链来定义这一点可能会更方便。一个好的折中方法是使用 CMake 预设来定义工具链和搜索行为,而不是将其放在项目文件或工具链文件中。
定义目标系统
跨编译的目标系统由以下三个变量定义 – CMAKE_SYSTEM_NAME、CMAKE_SYSTEM_PROCESSOR 和 CMAKE_SYSTEM_VERSION。它们分别对应 CMAKE_HOST_SYSTEM_NAME、CMAKE_HOST_SYSTEM_PROCESSOR 和 CMAKE_HOST_SYSTEM_VERSION 变量,这些变量描述了构建所在平台的系统信息。
CMAKE_SYSTEM_NAME 变量描述了要构建软件的目标操作系统。设置这个变量很重要,因为它会导致 CMake 将 CMAKE_CROSSCOMPILING 变量设置为 true。常见的值有 Linux、Windows、Darwin、Android 或 QNX,你也可以使用更具体的平台名称,例如 WindowsPhone、WindowsCE 或 WindowsStore。对于裸机嵌入式设备,CMAKE_SYSTEM_NAME 变量设置为 Generic。不幸的是,在写这篇文档时,CMake 文档中没有官方的支持系统列表。然而,如果需要,可以查看本地 CMake 安装中的 /Modules/Platform 文件夹中的文件。
CMAKE_SYSTEM_PROCESSOR 变量用于描述平台的硬件架构。如果未指定,将假定使用 CMAKE_HOST_SYSTEM_PROCESSOR 变量的值。在从 64 位平台交叉编译到 32 位平台时,即使处理器类型相同,也应该设置目标处理器架构。对于 Android 和 Apple 平台,通常不指定处理器。当为 Apple 目标交叉编译时,实际设备由使用的 SDK 定义,SDK 由 CMAKE_OSX_SYSROOT 变量指定。为 Android 交叉编译时,使用诸如 CMAKE_ANDROID_ARCH_ABI、CMAKE_ANDROID_ARM_MODE 和(可选的)CMAKE_ANDROID_ARM_NEON 等专用变量来控制目标架构。关于 Android 的构建会在 为 Android 交叉编译 部分中详细介绍。
定义目标系统的最后一个变量是 CMAKE_SYSTEM_VERSION。它的内容取决于构建的系统。对于 WindowsCE、WindowsStore 和 WindowsPhone,它用于定义使用哪个版本的 Windows SDK。在 Linux 上,通常省略此项,或者如果相关,可能包含目标系统的内核版本。
使用 CMAKE_SYSTEM_NAME、CMAKE_SYSTEM_PROCESSOR 和 CMAKE_SYSTEM_VERSION 变量,通常可以完全指定目标平台。然而,一些生成器,如 Visual Studio,直接支持其本地平台。对于这些平台,可以通过 CMake 的 -A 命令行选项来设置架构,方法如下:
cmake -G "Visual Studio 2019" -A Win32 -T host=x64
当使用预设时,architecture 设置可以在配置预设中使用,以达到相同的效果。一旦定义了目标系统,就可以定义用于实际构建软件的工具。
一些编译器,如 Clang 和 CMAKE_<LANG>_COMPILER_TARGET 变量也被使用。对于 Clang,值是目标三元组,如 arm-linux-gnueabihf,而对于 QNX GCC,编译器名称和目标的值如 gcc_ntoarmv7le。Clang 的支持三元组在其官方文档中有描述,网址为 clang.llvm.org/docs/CrossCompilation.html。
对于 QNX 可用的选项,应该参考 QNX 文档,网址为 www.qnx.com/developers/docs/。
所以,使用 Clang 的工具链文件可能如下所示:
set(CMAKE_SYSTEM_NAME Linux)
set(CMAKE_SYSTEM_PROCESSOR arm)
set(CMAKE_C_COMPILER /usr/bin/clang)
set(CMAKE_C_COMPILER_TARGET arm-linux-gnueabihf)
set(CMAKE_CXX_COMPILER /usr/bin/clang++)
set(CMAKE_CXX_COMPILER_TARGET arm-linux-gnueabihf)
在这个例子中,Clang 被用来编译运行在 ARM 处理器上的 Linux 系统的 C 和 C++ 代码,并且该系统支持硬件浮点运算。定义目标系统通常会直接影响将使用的构建工具。在下一节中,我们将探讨如何为交叉编译选择编译器及相关工具。
选择构建工具
在构建软件时,编译器通常是首先想到的工具,在大多数情况下,仅设置工具链文件中的编译器就足够了。编译器的路径由 CMAKE_<LANG>_COMPILER 缓存变量设置,可以在工具链文件中设置,也可以手动传递给 CMake。如果路径是绝对路径,则会直接使用;否则,将使用与 find_program() 相同的搜索顺序,这也是为什么在工具链文件中更改搜索行为时需要谨慎的原因之一。如果工具链文件和用户都没有指定编译器,CMake 将尝试根据指定的目标平台和生成器自动选择一个编译器。此外,编译器还可以通过与 <LANG> 对应的环境变量来设置。所以,C 用来设置 C 编译器,CXX 用来设置 C++ 编译器,ASM 用来设置汇编器,依此类推。
一些生成器,如 Visual Studio,可能支持其自定义的工具集定义,这些定义的工作方式不同。它们可以通过 -T 命令行选项进行设置。以下命令将告诉 CMake 为 Visual Studio 生成代码,以便为 32 位系统生成二进制文件,但使用 64 位编译器进行编译:
cmake -G "Visual Studio 2019" -A Win32 -T host=x64
这些值也可以通过工具链文件中的 CMAKE_GENERATOR_TOOLSET 变量进行设置。这个变量不应该在项目中设置,因为它显然不符合 CMake 项目文件与生成器和平台无关的原则。
对于 Visual Studio 用户,通过安装同一版本的预览版和正式版,可以在计算机上同时安装多个相同版本的 Visual Studio 实例。如果是这种情况,可以在工具链文件中将 CMAKE_GENERATOR_INSTANCE 变量设置为 Visual Studio 的绝对安装路径。
通过指定要使用的编译器,CMake 将为编译器和链接器选择默认标志,并通过设置CMAKE_<LANG>_FLAGS和CMAKE_<LANG>_FLAGS_<CONFIG>使其在项目中可用,其中 <LANG> 代表相应的编程语言,<CONFIG> 代表构建配置,如调试或发布。默认的链接器标志由 CMAKE_<TARGETTYPE>_LINKER_FLAGS 和 CMAKE_<TARGETTYPE>_LINKER_FLAGS_<CONFIG> 变量设置,其中 <TARGETTYPE> 可以是 EXE、STATIC、SHARED 或 MODULE。
要向默认标志添加自定义标志,可以使用带有 _INIT 后缀的变量—例如,CMAKE_<LANG>_FLAGS_INIT。在使用工具链文件时,_INIT 变量用于设置任何必要的标志。一个从 64 位主机为 32 位目标进行 GCC 编译的工具链文件可能如下所示:
set(CMAKE_SYSTEM_NAME Linux)
set(CMAKE_SYSTEM_PROCESSOR i686)
set(CMAKE_C_COMPILER gcc)
set(CMAKE_CXX_COMPILER g++)
set(CMAKE_C_FLAGS_INIT -m32)
set(CMAKE_CXX_FLAGS_INIT -m32)
set(CMAKE_EXE_LINKER_FLAGS_INIT -m32)
set(CMAKE_SHARED_LINKER_FLAGS_INIT -m32)
set(CMAKE_STATIC_LINKER_FLAGS_INIT -m32)
set(CMAKE_MODULE_LINKER_FLAGS_INIT -m32)
对于简单的项目,设置目标系统和工具链可能已经足够开始创建二进制文件,但对于更复杂的项目,它们可能需要访问目标系统的库和头文件。对于这种情况,可以在工具链文件中指定 sysroot。
设置 sysroot
在进行交叉编译时,所有链接的依赖项显然也必须与目标平台匹配,一种常见的处理方法是创建一个 sysroot,它是目标系统的根文件系统,存储在一个文件夹中。虽然 sysroot 可以包含完整的系统,但通常会被精简到仅提供所需内容。sysroot 的详细描述见于 第九章,创建可重现的 构建环境。
设置 sysroot 通过将 CMAKE_SYSROOT 设置为其路径来完成。如果设置了该值,CMake 默认会首先在 sysroot 中查找库和头文件,除非另有说明,正如在 第五章,集成第三方库和依赖管理 中所述。在大多数情况下,CMake 还会自动设置必要的编译器和链接器标志,以便工具与 sysroot 一起工作。
如果构建产物不应直接安装到 sysroot 中,可以设置 CMAKE_STAGING_PREFIX 变量以提供替代的安装路径。通常在以下情况时需要这样做:sysroot 应保持干净或当它被挂载为只读时。请注意,CMAKE_STAGING_PREFIX 设置不会将该目录添加到 CMAKE_SYSTEM_PREFIX_PATH,因此,只有当工具链中的 CMAKE_FIND_ROOT_PATH_MODE_PACKAGE 变量设置为 BOTH 或 NEVER 时,暂存目录中安装的内容才能通过 find_package() 找到。
定义目标系统并设置工具链配置、sysroot 和暂存目录通常是进行交叉编译所需的所有内容。两个例外是针对 Android 和 Apple 的 iOS、tvOS 或 watchOS 进行交叉编译。
针对 Android 进行交叉编译
过去,Android 的 NDK 与不同 CMake 版本之间的兼容性有时关系并不顺畅,因为 NDK 的新版本往往不再以与以前版本相同的方式与 CMake 协作。然而,从 r23 版本开始,这一情况得到了极大的改善,因为 Android NDK 现在使用 CMake 内部对工具链的支持。结合 CMake 3.21 或更高版本,为 Android 构建变得相对方便,因此推荐使用这些或更新的版本。关于 Android NDK 与 CMake 集成的官方文档可以在此处找到:developer.android.com/ndk/guides/cmake。
从 r23 版本开始,NDK 提供了自己的 CMake 工具链文件,位于<NDK_ROOT>/build/cmake/android.toolchain.cmake,可以像任何常规的工具链文件一样使用。NDK 还包括所有必要的工具,以支持基于 Clang 的工具链,因此通常不需要定义其他工具。要控制目标平台,应通过命令行或使用 CMake 预设传递以下 CMake 变量:
-
ANDROID_ABI:指定armeabi-v7a、arm64-v8a、x86和x86_64。在为 Android 进行交叉编译时,这个变量应该始终设置。 -
ANDROID_ARM_NEON:为armeabi-v7a启用 NEON 支持。该变量不会影响其他 ABI 版本。使用 r21 版本以上的 NDK 时,默认启用 NEON 支持,通常不需要禁用它。 -
ANDROID_ARM_MODE:指定是否为armeabi-v7a生成 ARM 或 Thumb 指令。有效值为thumb或arm。该变量不会影响其他 ABI 版本。 -
ANDROID_LD:决定使用默认的链接器还是来自llvm的实验性lld。有效的值为default或lld,但由于lld处于实验阶段,这个变量通常在生产构建中被省略。 -
ANDROID_PLATFORM:指定最低的$API_LEVEL,android-$API_LEVEL,或android-$API_LETTER格式,其中$API_LEVEL是一个数字,$API_LETTER是平台的版本代码。ANDROID_NATIVE_API_LEVEL是该变量的别名。虽然设置 API 级别并非严格必要,但通常会进行设置。 -
ANDROID_STL:指定使用哪种c++_static(默认值)、c++_shared、none或system。现代 C++支持需要使用c++_shared或c++_static。system库仅提供new和delete以及 C 库头文件的 C++封装,而none则完全不提供 STL 支持。
调用 CMake 来配置 Android 构建的命令可能如下所示:
cmake -S . -B build --toolchain <NDK_DIR>/build/cmake/android
.toolchain.cmake -DANDROID_ABI=armeabi-v7a -DANDROID_PLATFORM=23
这个调用将指定需要 API 级别 23 或更高的构建,这对应于 Android 6.0 或更高版本的 32 位 ARM 中央处理 单元 (CPU)。
使用 NDK 提供的工具链的替代方案是将 CMake 指向 Android NDK 的位置,这对于 r23 版本之后的 NDK 是推荐的方式。然后,目标平台的配置通过相应的 CMake 变量进行。通过将CMAKE_SYSTEM_NAME变量设置为android,并将CMAKE_ANDROID_NDK变量设置为 Android NDK 的位置,CMake 会被告知使用 NDK。这可以通过命令行或在工具链文件中完成。或者,如果设置了ANDROID_NDK_ROOT或ANDROID_NDK 环境变量,它们将被用作CMAKE_ANDROID_NDK的值。
当以这种方式使用 NDK 时,配置是通过定义变量来实现的,而不是直接调用 NDK 工具链文件时所用的CMAKE_等效变量,如下所示:
-
CMAKE_ANDROID_API或CMAKE_SYSTEM_VERSION用于指定要构建的最低 API 级别 -
CMAKE_ANDROID_ARCH_ABI用于指示要使用的 ABI 模式 -
CMAKE_ANDROID_STL_TYPE指定要使用的 STL
配置 CMake 与 Android NDK 的示例工具链文件可能如下所示:
set(CMAKE_SYSTEM_NAME Android)
set(CMAKE_SYSTEM_VERSION 21)
set(CMAKE_ANDROID_ARCH_ABI arm64-v8a)
set(CMAKE_ANDROID_NDK /path/to/the/android-ndk-r23b)
set(CMAKE_ANDROID_STL_TYPE c++_static)
当使用 Visual Studio 生成器为 Android 进行交叉编译时,CMake 要求使用NVIDIA Nsight Tegra Visual Studio Edition或Visual Studio for Android 工具,它们使用 Android NDK。使用 Visual Studio 构建 Android 二进制文件时,可以通过将CMAKE_ANDROID_NDK变量设置为 NDK 的位置,利用 CMake 的内置 Android NDK 支持。
随着 NDK 的最近版本和 3.20 及更高版本的 CMake,Android 的本地代码交叉编译变得更加简单。交叉编译的另一个特殊情况是当目标是 Apple 的 iOS、tvOS 或 watchOS 时。
为 iOS、tvOS 或 watchOS 进行交叉编译
推荐的为 Apple 的 iPhone、Apple TV 或 Apple 手表进行交叉编译的方式是使用 Xcode 生成器。苹果对用于这些设备构建应用的工具有相当严格的限制,因此需要使用 macOS 或运行 macOS 的虚拟机(VM)。虽然使用 Makefiles 或 Ninja 文件也是可能的,但它们需要更深入的苹果生态系统知识才能正确配置。
为这些设备进行交叉编译时,需要使用 Apple 设备的 SDK,并将CMAKE_SYSTEM_NAME变量设置为iOS、tvOS或watchOS,如下所示:
cmake -S <SourceDir> -B <BuildDir> -G Xcode -DCMAKE_SYSTEM_NAME=iOS
对于合理现代的 SDK 和 CMake 版本为 3.14 或更高版本时,通常这就是所需要的所有配置。默认情况下,系统上可用的最新设备 SDK 将被使用,但如果需要,可以通过将CMAKE_OSX_SYSROOT变量设置为 SDK 路径来选择不同的 SDK。如果需要,还可以通过CMAKE_OSX_DEPLOYMENT_TARGET变量指定最低目标平台版本。
在为 iPhone、Apple TV 或 Apple Watch 进行交叉编译时,目标可以是实际设备,也可以是随不同 SDK 提供的设备模拟器。然而,Xcode 内置支持在构建过程中切换目标,因此 CMake 不需要运行两次。如果选择了 Xcode 生成器,CMake 会内部使用 xcodebuild 命令行工具,该工具支持 -sdk 选项来选择所需的 SDK。在通过 CMake 构建时,可以像这样传递此选项:
cmake -build <BuildDir> -- -sdk <sdk>
这将把指定的 -sdk 选项传递给 xcodebuild。允许的值包括 iOS 的 iphoneos 或 iphonesimulator,Apple TV 设备的 appletvos 或 appletvsimulator,以及 Apple Watch 的 watchos 或 watchsimulator。
Apple 嵌入式平台要求对某些构建产物进行强制签名。对于 Xcode 生成器,开发团队 CMAKE_XCODE_ATTRIBUTE_DEVELOPMENT_TEAM 缓存变量。
在为 Apple 嵌入式设备构建时,模拟器非常有用,可以在无需每次都将代码部署到设备上的情况下进行测试。在这种情况下,测试最好通过 Xcode 或 xcodebuild 本身来完成,但对于其他平台,交叉编译的代码可以直接通过 CMake 和 CTest 进行测试。
测试交叉编译的二进制文件
能够轻松地为不同架构交叉编译二进制文件,为开发者的工作流程带来了极大的便利,但通常这些工作流程不仅仅局限于构建二进制文件,还包括运行测试。如果软件也可以在主机工具链上编译,并且测试足够通用,那么在主机上运行测试可能是测试软件的最简单方式,尽管这可能会在切换工具链和频繁重建时浪费一些时间。如果这不可行或过于耗时,当然可以选择在实际目标硬件上运行测试,但这取决于硬件的可用性和在硬件上设置测试的工作量,这可能会变得相当繁琐。因此,通常可行的折中方法是,如果有模拟器可用,在目标平台的模拟器中运行测试。
要定义用于运行测试的仿真器,使用CROSSCOMPILING_EMULATOR目标属性。它可以为单个目标设置,也可以通过设置CMAKE_CROSSCOMPILING_EMULATOR缓存变量来全局设置,该变量包含一个用分号分隔的命令和参数列表,用于运行仿真器。如果全局设置,则该命令将被添加到add_test()、add_custom_command()和add_custom_target()中指定的所有命令之前,并且它将用于运行任何由try_run()命令生成的可执行文件。这意味着所有用于构建的自定义命令也必须能够在仿真器中访问并运行。CROSSCOMPILING_EMULATOR属性不一定必须是一个实际的仿真器——它可以是任何任意程序,例如一个将二进制文件复制到目标机器并在那里执行的脚本。
设置CMAKE_CROSSCOMPILING_EMULATOR应该通过工具链文件、命令行或配置的前缀进行。一个用于交叉编译 C++代码到 ARM 的工具链文件示例如下,它使用流行的开源仿真器QEMU来运行测试:
set(CMAKE_SYSTEM_NAME Linux)
set(CMAKE_SYSTEM_PROCESSOR arm)
set(CMAKE_SYSROOT /path/to/arm/sysroot/)
set(CMAKE_CXX_COMPILER /usr/bin/clang++)
set(CMAKE_CXX_COMPILER_TARGET arm-linux-gnueabihf)
set(CMAKE_CROSSCOMPILING_EMULATOR "qemu-arm;-L;${CMAKE_SYSROOT}")
除了设置目标系统和工具链的交叉编译信息外,示例中的最后一行将emulator命令设置为qemu-arm -L /path/to/arm/sysroot。假设一个CMakeLists.txt文件中包含如下定义的测试:
add_test(NAME exampleTest COMMAND exampleExe)
当运行 CTest 时,不是直接运行exampleExe,而是将test命令转换为如下形式:
qemu-arm "-L" "/path/to/arm/sysroot/" "/path/to/build-dir/
exampleExe"
在仿真器中运行测试可以显著加速开发人员的工作流程,因为它可能消除了在主机工具链和目标工具链之间切换的需要,并且不需要将构建产物移动到目标硬件进行每个表面测试。像这样的仿真器也非常适合持续集成(CI)构建,因为在真实的目标硬件上构建可能会很困难。
有关CMAKE_CROSSCOMPILING_EMULATOR的一个技巧是,它也可以用来临时将测试包装在诊断工具中,例如valgrind或类似的诊断工具。由于运行指定的仿真器可执行文件并不依赖于CMAKE_CROSSCOMPILING变量(该变量指示一个项目是否是交叉编译的),因此使用这个变通方法的一个常见陷阱是,设置CMAKE_CROSSCOMPILING_EMULATOR变量会影响try_run()命令,该命令通常用于测试工具链或任何依赖项是否支持某些功能,并且由于诊断工具可能导致编译器测试失败,因此可能需要在已经缓存的构建上运行它,其中try_run()的任何结果已经被缓存。因此,使用CMAKE_CROSSCOMPILING_EMULATOR变量运行诊断工具不应永久进行,而应在特定的开发情况下使用,例如在寻找缺陷时。
在本节中,我们提到过 CMake 的 try_run() 命令,它与密切相关的 try_compile() 命令一起,用于检查编译器或工具链中某些功能的可用性。在下一节中,我们将更详细地探讨这两个命令以及功能测试工具链。
测试工具链支持的功能
当 CMake 在项目树上首次运行时,它会执行各种编译器和语言功能的测试。每次调用 project() 或 enable_language() 都会重新触发测试,但测试结果可能已经从之前的运行中缓存。缓存也是为什么在现有构建中切换工具链不推荐的原因。
正如我们将在本节中看到的,CMake 可以开箱即检查许多功能。大多数检查将内部使用 try_compile() 命令来执行这些测试。该命令本质上使用检测到的或由用户提供的工具链构建一个小的二进制文件。所有相关的全局变量,如 CMAKE_<LANG>_FLAGS,都将传递给 try_compile()。
与 try_complie() 密切相关的是 try_run() 命令,它内部调用 try_compile(),如果成功,它将尝试运行程序。对于常规的编译器检查,不使用 try_run(),任何调用它的地方通常都在项目中定义。
为了编写自定义检查,建议使用 CheckSourceCompiles 或 CheckSourceRuns 模块,而不是直接调用 try_compile() 和 try_run(),这两个模块和相应的函数 check_source_compiles() 和 check_source_runs() 命令自 CMake 3.19 版本以来就已可用。在大多数情况下,它们足以提供必要的信息,而无需更复杂地处理 try_compile() 或 try_run()。这两个命令的签名非常相似,如下所示:
check_source_compiles(<lang> <code> <resultVar>
[FAIL_REGEX <regex1> [<regex2>...]] [SRC_EXT <extension>])
check_source_runs(<lang> <code> <resultVar>
[SRC_EXT <extension>])
<lang> 参数指定 CMake 支持的语言之一,如 C 或 C++。<code> 是作为字符串链接为可执行文件的代码,因此它必须包含一个 main() 函数。编译的结果将作为布尔值存储在 <resultVar> 缓存变量中。如果为 check_source_compiles 提供了 FAIL_REGEX,则将检查编译输出是否符合提供的表达式。代码将保存在具有与所选语言匹配的扩展名的临时文件中;如果文件的扩展名与默认值不同,则可以通过 SRC_EXT 选项指定。
还有语言特定版本的模块,称为 Check<LANG>SourceCompiles 和 Check<LANG>SourceRuns,它们提供相应的命令,如以下示例所示:
include(CheckCSourceCompiles)
check_c_source_compiles(code resultVar
[FAIL_REGEX regexes...]
)
include(CheckCXXSourceCompiles)
check_cxx_source_compiles(code resultVar
[FAIL_REGEX regexes...]
)
假设有一个 C++ 项目,它可能使用标准库的原子功能,或者如果不支持该功能,则回退到其他实现。针对这个功能的编译器检查可能如下所示:
include(CheckSourceCompiles)
check_source_compiles(CXX "
#include <atomic>
int main(){
std::atomic<unsigned int> x;
x.fetch_add(1);
x.fetch_sub(1);
}" HAS_STD_ATOMIC)
在包含该模块后,check_source_compiles() 函数会与一个使用待检查功能的小程序一起调用。如果代码成功编译,HAS_STD_ATOMIC 将被设置为 true;否则,将被设置为 false。该测试会在项目配置期间执行,并打印类似如下的状态信息:
[cmake] -- Performing Test HAS_STD_ATOMIC
[cmake] -- Performing Test HAS_STD_ATOMIC - Success
结果会被缓存,以便后续运行 CMake 时不会再次执行该测试。在很多情况下,检查程序是否编译已经能提供足够的信息,表明工具链的某些特性,但有时需要运行底层程序以获取所需的信息。为此,check_source_runs() 类似于 check_source_compiles()。check_source_runs() 的一个注意事项是,如果设置了 CMAKE_CROSSCOMPILING 但未设置模拟器命令,那么测试将只编译测试程序,而不会运行,除非设置了 CMAKE_CROSSCOMPILING_EMULATOR。
有许多以 CMAKE_REQUIRED_ 开头的变量可以控制检查如何编译代码。请注意,这些变量缺少特定语言的部分,如果在进行跨语言测试时需要特别小心。以下是一些这些变量的解释:
-
CMAKE_REQUIRED_FLAGS用于在CMAKE_<LANG>_FLAGS或CMAKE_<LANG>_FLAGS_<CONFIG>变量中指定的任何标志之后,向编译器传递附加标志。 -
CMAKE_REQUIRED_DEFINITIONS指定了多个编译器定义,形式为-DFOO=bar。 -
CMAKE_REQUIRED_INCLUDES指定了一个目录列表,用于搜索额外的头文件。 -
CMAKE_REQUIRED_LIBRARIES指定在链接程序时要添加的库列表。这些可以是库的文件名或导入的 CMake 目标。 -
CMAKE_REQUIRED_LINK_OPTIONS是一个附加链接器标志的列表。 -
CMAKE_REQUIRED_QUIET可以设置为true,以抑制检查时的任何状态信息。
在需要将检查彼此隔离的情况下,CMakePushCheckState 模块提供了 cmake_push_check_state()、cmake_pop_check_state() 和 cmake_reset_check_state() 函数,用于存储配置、恢复先前的配置和重置配置,以下例子演示了这一点:
include(CMakePushCheckState)
cmake_push_check_state()
# Push the state and clean it to start with a clean check state
cmake_reset_check_state()
include(CheckCompilerFlag)
check_compiler_flag(CXX -Wall WALL_FLAG_SUPPORTED)
if(WALL_FLAG_SUPPORTED)
set(CMAKE_REQUIRED_FLAGS -Wall)
# Preserve -Wall and add more things for extra checks
cmake_push_check_state()
set(CMAKE_REQUIRED_INCLUDES ${CMAKE_CURRENT_SOURCE_DIR}/include)
include(CheckSymbolExists)
check_symbol_exists(hello "hello.hpp" HAVE_HELLO_SYMBOL)
cmake_pop_check_state()
endif()
# restore all CMAKE_REQUIRED_VARIABLEs to original state
cmake_pop_check_state()
用于检查编译或运行测试程序的命令是更复杂的 try_compile() 和 try_run() 命令。虽然它们可以使用,但主要用于内部,因此我们这里不做解释,而是参考命令的官方文档。
通过编译和运行程序来检查编译器特性是一种非常灵活的方法,用于检查工具链特性。有些检查非常常见,CMake 提供了专门的模块和函数来执行这些检查。
工具链和语言特性的常见检查
对于一些最常见的功能检查,例如检查编译器标志是否支持或头文件是否存在,CMake 提供了方便的模块。从 CMake 3.19 版本开始,提供了通用模块,可以将语言作为参数,但相应的Check<LANG>...特定语言模块仍然可以使用。
一个非常基础的测试,用于检查某个语言的编译器是否可用,可以通过CheckLanguage模块来完成。如果未设置CMAKE_<LANG>_COMPILER变量,它可以用来检查某个语言的编译器是否可用。例如,检查 Fortran 是否可用的示例如下:
include(CheckLanguage)
check_language(Fortran)
if(CMAKE_Fortran_COMPILER)
enable_language(Fortran)
else()
message(STATUS "No Fortran support")
endif()
如果检查成功,则会设置相应的CMAKE_<LANG>_COMPILER变量。如果在检查之前该变量已设置,则不会产生任何影响。
CheckCompilerFlag提供了check_compiler_flag()函数,用于检查当前编译器是否支持某个标志。在内部,会编译一个非常简单的程序,并解析输出以获取诊断信息。该检查假设CMAKE_<LANG>_FLAGS中已存在的任何编译器标志都能成功运行;否则,check_compiler_flag()函数将始终失败。以下示例检查 C++编译器是否支持-Wall标志:
include(CheckCompilerFlag)
check_compiler_flag(CXX -Wall WALL_FLAG_SUPPORTED)
如果-Wall标志被支持,则WALL_FLAG_SUPPORTED缓存变量将为true;否则为false。
用于检查链接器标志的相应模块叫做CheckLinkerFlag,其工作方式与检查编译器标志类似,但链接器标志不会直接传递给链接器。由于链接器通常是通过编译器调用的,因此传递给链接器的额外标志可以使用如-Wl或-Xlinker等前缀,告诉编译器将该标志传递过去。由于该标志是编译器特定的,CMake 提供了LINKER:前缀来自动替换命令。例如,要向链接器传递生成执行时间和内存消耗统计信息的标志,可以使用以下命令:
include(CheckLinkerFlag)
check_linker_flag(CXX LINKER:-stats LINKER_STATS_FLAG_SUPPORTED)
如果链接器支持-stats标志,则LINKER_STATS_FLAG_SUPPORTED变量将为true。
其他有用的模块用于检查各种内容,包括CheckLibraryExists、CheckIncludeFile和CheckIncludeFileCXX模块,用于检查某个库或包含文件是否存在于某些位置。
CMake 还提供了更多详细的检查,可能非常特定于某个项目——例如,CheckSymbolExists和CheckSymbolExistsCXX模块检查某个符号是否存在,无论它是作为预处理器定义、变量还是函数。CheckStructHasMember将检查结构体是否具有某个成员,而CheckTypeSize可以检查非用户类型的大小,并使用CheckPrototypeDefinition检查 C 和 C++函数原型的定义。
正如我们所见,CMake 提供了很多检查,随着 CMake 的发展,可用的检查列表可能会不断增加。虽然在某些情况下检查是有用的,但我们应该小心不要让测试的数量过多。检查的数量和复杂度将对配置步骤的速度产生很大影响,同时有时并不会带来太多好处。在一个项目中有很多检查,也可能意味着该项目存在不必要的复杂性。
总结
CMake 对交叉编译的广泛支持是其显著特点之一。在本章中,我们探讨了如何定义一个用于交叉编译的工具链文件,以及如何使用 sysroot 来使用不同目标平台的库。交叉编译的一个特殊案例是 Android 和苹果移动设备,它们依赖于各自的 SDK。通过简要介绍使用模拟器或仿真器测试其他平台,现在你已经掌握了所有必要的信息,可以开始为各种目标平台构建优质软件。
本章的最后部分讨论了测试工具链某些特性的高级话题。虽然大多数项目不需要关注这些细节,但了解这些内容依然很有用。
下一章将讨论如何让 CMake 代码在多个项目之间可重用,而不需要一遍又一遍地重写所有内容。
问题
-
工具链文件如何传递给 CMake?
-
通常在交叉编译的工具链文件中定义了什么?
-
在 sysroot 的上下文中,什么是中间目录?
-
如何将模拟器传递给 CMake 进行测试?
-
什么触发了编译器特性的检测?
-
如何存储和恢复编译器检查的配置上下文?
-
CMAKE_CROSSCOMPILING变量对编译器检查有什么影响? -
为什么在切换工具链时应该完全清除构建目录,而不仅仅是删除缓存?
答案
-
工具链文件可以通过
--toolchain命令行标志、CMAKE_TOOLCHAIN_FILE变量,或者通过 CMake 预设中的toolchainFile选项传递。 -
通常,交叉编译的工具链文件中会做以下几件事:
-
定义目标系统和架构
-
提供构建软件所需的任何工具的路径
-
为编译器和链接器设置默认标志
-
指定 sysroot 和可能的任何中间目录(如果是交叉编译的话)
-
为 CMake 的
find_命令设置搜索顺序的提示
-
-
中间目录通过
CMAKE_STAGING_PREFIX变量设置,作为安装任何已构建的工件的地方,如果 sysroot 不应被修改。 -
模拟器命令作为分号分隔的列表传递给
CMAKE_CROSSCOMPILING_EMULATOR变量。 -
在项目中对
project()或enable_language()的任何调用都会触发特性检测。 -
编译器检查的配置上下文可以通过
cmake_push_check_state()存储,并通过cmake_pop_check_state()恢复到先前的状态。 -
如果设置了
CMAKE_CROSSCOMPILING,任何对try_run()的调用将会编译测试但不会运行,除非设置了模拟器命令。 -
构建目录应该完全清理,因为仅删除缓存时,编译器检查的临时产物可能不会正确重建。
第十三章:重用 CMake 代码
为项目编写构建系统代码并非易事。项目维护者和开发人员在编写 CMake 代码时会花费大量精力来配置编译器标志、项目构建变体、第三方库和工具集成。从头开始编写用于配置项目无关细节的 CMake 代码,在处理多个 CMake 项目时可能会开始带来显著的负担。为项目编写的这些 CMake 代码中的大部分内容,都是可以在多个项目之间重用的。考虑到这一点,我们有必要制定一个策略,使我们的 CMake 代码易于重用。解决这个问题的直接方法是将 CMake 代码视为常规代码,并应用一些最基本的编码原则:不要重复自己(DRY)原则和单一责任(SRP)原则。
如果按照重用性的思路来结构化 CMake 代码,那么 CMake 代码是可以轻松重用的。实现基本的重用性非常简单:将 CMake 代码拆分成模块和函数。你可能已经意识到,使 CMake 代码可重用的方法与使软件代码可重用的方法是一样的。记住——毕竟 CMake 本身就是一种脚本语言。所以,将 CMake 代码视为常规代码并应用软件设计原则是完全自然的。像任何功能性脚本语言一样,CMake 拥有以下基本重用能力:
-
能够包含其他 CMake 文件
-
函数/宏
-
可移植性
在本章中,我们将学习如何编写具有重用性思想的 CMake 代码,并在 CMake 项目中重用 CMake 代码。我们还将讨论如何在项目之间进行版本管理和共享常用的 CMake 代码。
为了理解本章分享的技能,我们将涵盖以下主要主题:
-
什么是 CMake 模块?
-
模块的基本构建块——函数和宏
-
编写你的第一个 CMake 模块
我们从技术要求开始。
技术要求
在深入本章之前,建议先阅读 第一章,快速入门 CMake。本章采用以示例教学的方法,因此建议从这里获取本章的示例内容:github.com/PacktPublishing/CMake-Best-Practices---2nd-Edition/tree/main/chapter13。对于所有示例,假设你将使用该项目提供的容器:github.com/PacktPublishing/CMake-Best-Practices---2nd-Edition/。
让我们首先了解一些 CMake 中重用性的基本知识。
什么是 CMake 模块?
一个Find*.cmake模块)。CMake 默认提供的模块列表可以在cmake.org/cmake/help/latest/manual/cmake-modules.7.html查看。官方的 CMake 文档将模块分为以下两大类:
-
工具模块
-
查找模块
正如它们的名字所示,工具模块提供工具,而查找模块则旨在搜索系统中的第三方软件。如你所记得,我们在第四章《CMake 项目的打包、部署与安装》和第五章《集成第三方库及依赖管理》中详细讨论了查找模块。因此,在本章中我们将专注于工具模块。你会记得,在前几章中我们使用了一些 CMake 提供的工具模块。我们使用的一些模块有GNUInstallDirs、CPack、FetchContent和ExternalProject。这些模块位于CMake安装目录下。
为了更好地理解工具模块的概念,让我们从研究 CMake 提供的一个简单的工具模块开始。为此,我们将研究ProcessorCount工具模块。你可以在github.com/Kitware/CMake/blob/master/Modules/ProcessorCount.cmake找到该模块的源文件。ProcessorCount模块是一个允许在 CMake 代码中获取系统 CPU 核心数的模块。ProcessorCount.cmake文件定义了一个名为ProcessorCount的 CMake 函数,它接受一个名为var的参数。该函数的实现大致如下:
function(ProcessorCount var)
# Unknown:
set(count 0)
if(WIN32)
set(count "$ENV{NUMBER_OF_PROCESSORS}")
endif()
if(NOT count)
# Mac, FreeBSD, OpenBSD (systems with sysctl):
# … mac-specific approach … #
endif()
if(NOT count)
# Linux (systems with nproc):
# … linux-specific approach … #
endif()
# … Other platforms, alternative fallback methods … #
# Lastly:
set(${var} ${count} PARENT_SCOPE)
endfunction()
ProcessorCount函数尝试多种不同的方法来获取主机机器的 CPU 核心数。使用ProcessorCount模块非常简单,如下所示:
include(ProcessorCount)
ProcessorCount(CORE_COUNT)
message(STATUS "Core count: ${CORE_COUNT}")
如你在上面的示例中所看到的,使用 CMake 模块就像将模块包含到所需的 CMake 文件中一样简单。include()函数是递归的,因此include行之后的代码可以使用模块中包含的所有 CMake 定义。
我们现在大致了解了一个工具模块的样子。接下来,继续学习更多关于工具模块的基本构建块:函数和宏。
模块的基本构建块——函数和宏
很明显,我们需要一些基本的构建块来创建工具模块。工具模块的最基本构建块是函数和宏,因此掌握它们的工作原理至关重要。让我们从学习函数开始。
函数
让我们回顾一下在 第一章《启动 CMake》中学到的关于函数的内容。function(…) 拥有一个包含 CMake 命令的函数体,并以 endfunction() CMake 命令结束。function() 命令的第一个参数需要是函数名,其后可以有可选的函数参数名,如下所示:
function(<name> [<arg1> ...])
<commands>
endfunction()
函数定义了一个新的变量作用域,因此对 CMake 变量的修改仅在函数体内可见。独立作用域是函数的最重要特性。拥有新的作用域意味着我们无法意外地泄露变量给调用者或修改调用者的变量,除非我们愿意这么做。大多数情况下,我们希望将修改限制在函数的作用域内,并仅将函数的结果反映给调用者。由于 CMake 不支持返回值,我们将采取在调用者作用域中定义变量的方法来返回函数结果给调用者。
为了说明这种方法,我们定义一个简单的函数来一起获取当前的 Git 分支名称:
function(git_get_branch_name result_var_name)
execute_process(
COMMAND git symbolic-ref -q --short HEAD
WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}"
OUTPUT_VARIABLE git_current_branch_name
OUTPUT_STRIP_TRAILING_WHITESPACE
ERROR_QUIET
)
set(${result_var_name} ${git_current_branch_name}
PARENT_SCOPE)
endfunction()
git_get_branch_name 函数接受一个名为 result_var_name 的单一参数。该参数是将被定义在调用者作用域中的变量名,用于返回 Git 分支名称给调用者。或者,我们可以使用一个常量变量名,比如 GIT_CURRENT_BRANCH_NAME,并去掉 result_var_name 参数,但如果项目已经使用了 GIT_CURRENT_BRANCH_NAME 这个名字,这可能会导致问题。
这里的经验法则是将命名留给调用者,因为这可以提供最大的灵活性和可移植性。为了获取当前的 Git 分支名称,我们通过 execute_process() 调用了 git symbolic-ref -q --short HEAD 命令。命令的结果存储在函数作用域内的 git_current_branch_name 变量中。由于该变量处于函数作用域内,调用者无法看到 git_current_branch_name 变量。因此,我们使用 set(${result_var_name} ${git_current_branch_name} PARENT_SCOPE) 来在调用者的作用域内定义一个变量,使用的是本地 git_current_branch_name 变量的值。
PARENT_SCOPE 参数改变了 set(…) 命令的作用域,使得它在调用者的作用域中定义变量,而不是在函数作用域中定义。git_get_branch_name 函数的用法如下:
git_get_branch_name(branch_n)
message(STATUS "Current git branch name is: ${branch_n}")
接下来我们来看看宏。
宏
如果函数的作用域对你的使用场景是个难题,你可以考虑改用宏。宏以 macro(…) 开始,以 endmacro() 结束。函数和宏在各个方面表现得很相似,但有一个区别:宏不会定义新的变量作用域。回到我们的 Git 分支示例,考虑到 execute_process(…) 已经有了 OUTPUT_VARIABLE 参数,定义 git_get_branch_name 为宏而非函数会更方便,这样就可以避免在结尾使用 set(… PARENT_SCOPE):
macro(git_get_branch_name_m result_var_name)
execute_process(
COMMAND git symbolic-ref -q --short HEAD
WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}"
OUTPUT_VARIABLE ${result_var_name}
OUTPUT_STRIP_TRAILING_WHITESPACE
ERROR_QUIET
)
endmacro()
git_get_branch_name_m宏的使用与git_get_branch_name()函数相同:
git_get_branch_name_m(branch_nn)
message(STATUS "Current git branch name is: ${branch_nn}")
我们已经学习了如何在需要时定义函数或宏。接下来,我们将一起定义第一个 CMake 模块。
编写你自己的第一个 CMake 模块
在上一节中,我们学习了如何使用函数和宏为 CMake 项目提供有用的工具。现在,我们将学习如何将这些函数和宏移到一个单独的 CMake 模块中。
创建和使用一个基本的 CMake 模块文件非常简单:
-
在你的项目中创建一个
<module_name>.cmake文件。 -
在
<module_name>.cmake文件中定义任何宏/函数。 -
在需要的文件中包含
<module_name>.cmake。
好的——让我们按照这些步骤一起创建一个模块。作为我们之前 Git 分支名称示例的后续,我们将扩大范围,编写一个 CMake 模块,提供通过git命令获取分支名称、头部提交哈希值、当前作者名称和当前作者电子邮件信息的能力。对于这一部分,我们将参考chapter13/ex01_git_utility的示例。示例文件夹包含一个CMakeLists.txt文件和一个位于.cmake文件夹下的git.cmake文件。让我们首先来看一下.cmake/git.cmake文件,文件内容如下:
# …
include_guard(DIRECTORY)
macro(git_get_branch_name result_var_name)
execute_process(
COMMAND git symbolic-ref -q --short HEAD
WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}"
OUTPUT_VARIABLE ${result_var_name}
OUTPUT_STRIP_TRAILING_WHITESPACE
ERROR_QUIET
)
endmacro()
# … git_get_head_commit_hash(), git_get_config_value()
git.cmake文件是一个 CMake 实用模块文件,其中包含三个宏,分别为git_get_branch_name、git_get_head_commit_hash和git_get_config_value。此外,文件顶部有一个include_guard(DIRECTORY)行。这类似于 C/C++中的#pragma once预处理指令,防止该文件被多次包含。DIRECTORY参数表示include_guard在目录范围内定义,意味着该文件在当前目录及其下属目录中最多只能被包含一次。或者,也可以指定GLOBAL参数代替DIRECTORY,限制该文件无论作用域如何只被包含一次。
为了了解如何使用git.cmake模块文件,让我们一起查看chapter13/ex01_git_utility的CMakeLists.txt文件:
cmake_minimum_required(VERSION 3.21)
project(
ch13_ex01_git_module
VERSION 1.0
DESCRIPTION "Chapter 13 Example 01, git utility module
example"
LANGUAGES CXX)
# Include the git.cmake module.
# Full relative path is given, since .cmake/ is not in the
CMAKE_MODULE_PATH
include(.cmake/git.cmake)
git_get_branch_name(current_branch_name)
git_get_head_commit_hash(current_head)
git_get_config_value("user.name" current_user_name)
git_get_config_value("user.email" current_user_email)
message(STATUS "-----------------------------------------")
message(STATUS "VCS (git) info:")
message(STATUS "\tBranch: ${current_branch_name}")
message(STATUS "\tCommit hash: ${current_head}")
message(STATUS "\tAuthor name: ${current_user_name}")
message(STATUS "\tAuthor e-mail: ${current_user_email}")
message(STATUS "-----------------------------------------")
CMakeLists.txt文件通过指定模块文件的完整相对路径来包含git.cmake文件。该模块提供的git_get_branch_name、git_get_head_commit_hash和git_get_config_value宏分别用于获取分支名称、提交哈希值、作者名称和电子邮件地址,并将其存储到current_branch_name、current_head、current_user_name和current_user_email变量中。最后,通过message(…)命令将这些变量打印到屏幕上。让我们配置示例项目,看看我们刚刚编写的 Git 模块是否按预期工作:
cd chapter13/ex01_git_utility/
cmake -S ./ -B ./build
命令的输出应类似于以下内容:
-- The CXX compiler identification is GNU 9.4.0
-- Detecting CXX compiler ABI info
-- Detecting CXX compiler ABI info - done
-- Check for working CXX compiler: /usr/bin/c++ - skipped
-- Detecting CXX compile features
-- Detecting CXX compile features - done
-- -------------------------------------------
-- VCS (git) info:
-- Branch: chapter-development/chapter-13
-- Commit hash: 1d5a32649e74e4132e7b66292ab23aae
ed327fdc
-- Author name: Mustafa Kemal GILOR
-- Author e-mail: mustafagilor@gmail.com
-- -------------------------------------------
-- Configuring done
-- Generating done
-- Build files have been written to:
/home/toor/workspace/ CMake-Best-Practices---2nd-Edition/chapter13
/ex01_git_utility/build
如我们所见,我们成功地从git命令中获取了信息。我们的第一个 CMake 模块按预期工作。
案例研究——处理项目元数据文件
让我们继续另一个例子。假设我们有一个环境文件,其中每行包含一个键值对。在项目中包含外部文件以存储有关项目的一些元数据(例如项目版本和依赖关系)并不罕见。该文件可以有不同的格式,例如 JSON 格式或换行分隔的键值对,如我们在此示例中所见。当前任务是创建一个工具模块,读取环境变量文件并为文件中的每个键值对定义一个 CMake 变量。文件的内容将类似于以下内容:
KEY1="Value1"
KEY2="Value2"
对于本节,我们将参考chapter13/ex02_envfile_utility示例。让我们从检查.cmake/envfile-utils.cmake的内容开始:
include_guard(DIRECTORY)
function(read_environment_file ENVIRONMENT_FILE_NAME)
file(STRINGS ${ENVIRONMENT_FILE_NAME} KVP_LIST ENCODING
UTF-8)
foreach(ENV_VAR_DECL IN LISTS KVP_LIST)
string(STRIP ENV_VAR_DECL ${ENV_VAR_DECL})
string(LENGTH ENV_VAR_DECL ENV_VAR_DECL_LEN)
if(ENV_VAR_DECL_LEN EQUAL 0)
continue()
endif()
string(SUBSTRING ${ENV_VAR_DECL} 0 1
ENV_VAR_DECL_FC)
if(ENV_VAR_DECL_FC STREQUAL "#")
continue()
endif()
string(REPLACE "=" ";" ENV_VAR_SPLIT
${ENV_VAR_DECL})
list(GET ENV_VAR_SPLIT 0 ENV_VAR_NAME)
list(GET ENV_VAR_SPLIT 1 ENV_VAR_VALUE)
string(REPLACE "\"" "" ENV_VAR_VALUE
${ENV_VAR_VALUE})
set(${ENV_VAR_NAME} ${ENV_VAR_VALUE} PARENT_SCOPE)
endforeach()
endfunction()
envfile-utils.cmake工具模块包含一个函数read_environment_file,该函数读取一个键值对列表格式的环境文件。该函数将文件中的所有行读取到KVP_LIST变量中,然后遍历所有行。每一行都通过(=)等号符号进行分割,等号左边的部分作为变量名,右边的部分作为变量值,将每个键值对定义为一个 CMake 变量。空行和注释行会被跳过。至于模块的使用情况,让我们看看chapter13/ex02_envfile_utility/CMakeLists.txt文件:
# Add .cmake folder to the module path, so subsequent
include() calls
# can directly include modules under .cmake/ folder by
specifying the name only.
set(CMAKE_MODULE_PATH ${CMAKE_MODULE_PATH}
${PROJECT_SOURCE_DIR}/.cmake/)
add_subdirectory(test-executable)
你可能已经注意到,.cmake文件夹被添加到了CMAKE_MODULE_PATH变量中。CMAKE_MODULE_PATH变量是include(…)指令将在其中搜索的路径集合。默认情况下,它是空的。这允许我们直接按名称在当前和子CMakeLists.txt文件中包含envfile-utils模块。最后,让我们看一下chapter13/ex02_envfile_utility/test-executable/CMakeLists.txt文件:
# ....
# Include the module by name
include(envfile-utils)
read_environment_file("${PROJECT_SOURCE_DIR}/
variables.env")
add_executable(ch13_ex02_envfile_utility_test)
target_sources(ch13_ex02_envfile_utility_test PRIVATE
test.cpp)
target_compile_features(ch13_ex02_envfile_utility_test
PRIVATE cxx_std_11)
target_compile_definitions(ch13_ex02_envfile_utility_test
PRIVATE TEST_PROJECT_VERSION="${TEST_PROJECT_VERSION}"
TEST_PROJECT_AUTHOR="${TEST_PROJECT_AUTHOR}")
如你所见,envfile-utils环境文件读取模块按名称被包含。这是因为包含envfile-utils.cmake文件的文件夹之前已经添加到CMAKE_MODULE_PATH变量中。read_environment_file()函数被调用来读取同一文件夹中的variables.env文件。variables.env文件包含以下键值对:
# This file contains some metadata about the project
TEST_PROJECT_VERSION="1.0.2"
TEST_PROJECT_AUTHOR="CBP Authors"
因此,在调用read_environment_file()函数之后,我们期望TEST_PROJECT_VERSION和TEST_PROJECT_AUTHOR变量在当前 CMake 作用域中定义,并且它们的相应值在文件中指定。为了验证这一点,定义了一个名为ch13_ex02_envfile_utility_test的可执行目标,并将TEST_PROJECT_VERSION和TEST_PROJECT_AUTHOR变量作为宏定义传递给该目标。最后,目标的源文件test.cpp将TEST_PROJECT_VERSION和TEST_PROJECT_AUTHOR宏定义打印到控制台:
#include <cstdio>
int main(void) {
std::printf("Version `%s`, author `%s`\n",
TEST_PROJECT_VERSION, TEST_PROJECT_AUTHOR);
}
好的——让我们编译并运行应用程序,看看它是否有效:
cd chapter13/ex02_envfile_utility
cmake -S ./ -B ./build
cmake --build build
./build/test-executable/ch13_ex02_envfile_utility_test
# Will output: Version `1.0.2`, author `CBP Authors`
正如我们所看到的,我们已成功地从源代码树中读取了一个键值对格式的文件,并将每个键值对定义为 CMake 变量,然后将这些变量作为宏定义暴露给我们的应用程序。
虽然编写 CMake 模块非常直接,但还是有一些额外的建议需要考虑:
-
始终为函数/宏使用唯一的名称
-
为所有模块函数/宏使用一个共同的前缀
-
避免为非函数范围的变量使用常量名称
-
使用
include_guard()来保护您的模块 -
如果您的模块输出消息,请为模块提供静默模式
-
不要暴露模块的内部实现
-
对于简单的命令包装器使用宏,对于其他情况使用函数
说到这里,我们结束了本章这一部分的内容。接下来,我们将探讨如何在项目间共享 CMake 模块。
关于项目间共享 CMake 模块的建议
共享 CMake 模块的推荐方式是维护一个独立的 CMake 模块项目,然后将该项目作为外部资源引入,可以通过 Git 子模块/子树或 CMake 的FetchContent进行,如在第五章中所描述,集成第三方库和依赖管理。在使用 FetchContent 时,可以通过设置 SOURCE_DIR 属性,并将 CMAKE_MODULE_PATH 设置为指定路径,轻松集成外部模块。这样,所有可重用的 CMake 工具可以集中在一个项目中进行维护,并可以传播到所有下游项目。将 CMake 模块放入在线 Git 托管平台(如 GitHub 或 GitLab)中的仓库,将使大多数人方便使用该模块。由于 CMake 支持直接从 Git 拉取内容,使用共享模块将变得非常简单。
为了演示如何使用外部 CMake 模块项目,我们将使用一个名为 hadouken 的开源 CMake 工具模块项目(github.com/mustafakemalgilor/hadouken)。该项目包含用于工具集成、目标创建和特性检查的 CMake 工具模块。
对于这一部分,我们将按照chapter13/ex03_external_cmake_module示例进行操作。该示例将获取hadouken:
include(FetchContent)
FetchContent_Declare(hadouken
GIT_REPOSITORY https://github.com/mustafakemalgilor
/hadouken.git
GIT_TAG 7d0447fcadf8e93d25f242b9bb251ecbcf67f8cb
SOURCE_DIR "${CMAKE_CURRENT_LIST_DIR}/.hadouken"
)
FetchContent_MakeAvailable(hadouken)
list(APPEND CMAKE_MODULE_PATH ${CMAKE_CURRENT_LIST_DIR}/.hadouken/cmake/modules)
include(core/MakeTarget)
在前面的示例中,我们使用了 FetchContent_Declare 和 FetchContent_MakeAvailable 将 hadouken 拉取到我们的项目中,并将其放置在 .hadouken 文件夹中的构建目录里。然后,将 hadouken 项目的模块目录添加到 CMAKE_MODULE_PATH 中,通过 include(…) 指令使用 hadouken 项目的 CMake 工具模块。这样,我们就能访问由导入的 CMake 模块提供的 make_target() 宏。我们已经共同完成了这一章的内容。接下来,我们将总结本章所学的知识,并展望下一章的内容。
总结
在本章中,我们学习了如何构建 CMake 项目以支持可重用性。我们学习了如何实现 CMake 工具模块,如何共享它们,以及如何使用别人编写的工具模块。能够利用 CMake 模块使我们能够更好地组织项目,并更有效地与团队成员协作。掌握这些知识后,CMake 项目将变得更易于维护。CMake 项目中常用的可重用代码将发展成一个庞大的有用模块库,使得使用 CMake 编写项目变得更容易。
我想提醒您,CMake 是一种脚本语言,应该像对待脚本语言一样来使用。采用软件设计原则和模式,使 CMake 代码更具可维护性。将 CMake 代码组织成函数和模块。尽可能地重用和共享 CMake 代码。请不要忽视您的构建系统代码,否则您可能需要从头开始编写。
在下一章中,我们将学习如何优化和维护 CMake 项目。
下一章见!
问题
完成本章后,您应该能够回答以下问题:
-
在 CMake 中,可重用性的最基本构建模块是什么?
-
什么是 CMake 模块?
-
如何使用 CMake 模块?
-
CMAKE_MODULE_PATH变量的用途是什么? -
分享 CMake 模块给不同项目的一种方式是什么?
-
在 CMake 中,函数和宏的主要区别是什么?
答案
-
函数和宏。
-
CMake 模块是一个逻辑实体,包含 CMake 代码、函数和宏,用于特定目的。
-
通过将其包含在所需的作用域中。
-
要将额外的路径添加到
include(…)指令的搜索路径中。 -
通过使用 Git 子模块/子树或 CMake 的
FetchContent/ExternalProject模块。 -
函数定义了一个新的变量作用域;宏则没有。
第十四章:优化和维护 CMake 项目
软件项目通常会存在很长时间,对于某些项目来说,持续开发十年甚至更久并不罕见。但即使项目没有存在那么久,它们也会随着时间的推移而增长,并吸引某些杂乱和遗留的工件。通常,维护项目不仅仅是重构代码或偶尔添加功能,还包括保持构建信息和依赖项的最新状态。
随着项目复杂度的增加,构建时间往往会大幅增加,甚至到达开发变得乏味的程度,因为需要等待很长时间。长时间的构建不仅不方便,还可能促使开发人员采取捷径,因为它使得尝试新事物变得困难。如果每次构建需要数小时才能完成,而且每次推送到 CI/CD 管道需要数小时才能返回,这种情况就更糟糕了。
除了选择一个好的模块化项目结构来提高增量构建的有效性外,CMake 还提供了一些功能来帮助分析性能和优化构建时间。如果仅使用 CMake 还不够,使用编译器缓存 (ccache) 等技术来缓存构建结果或预编译头文件,进一步加速增量构建。
优化构建时间可以带来良好的效果,显著改善开发人员的日常工作,甚至可能成为节省成本的因素,因为 CI/CD 管道可能需要更少的资源来构建项目。然而,也有一些陷阱,过度优化的系统可能会变得脆弱,更容易崩溃,并且在某些情况下,为了优化构建时间,可能会牺牲项目的易维护性。
本章将介绍一些关于如何维护项目并结构化它们以便保持维护工作量的通用建议。然后,我们将深入分析构建性能,并看看如何加速构建。本章将涵盖以下主题:
-
保持 CMake 项目的可维护性
-
CMake 构建的性能分析
-
优化构建性能
技术要求
与前几章一样,所有示例都使用 CMake 3.21 进行了测试,并运行在以下任一编译器上:
-
GNU 编译器集合 (GCC) 9 或更新版本
-
Clang 12 或更新版本
-
Microsoft Visual C++ (MSVC) 19 或更新版本
要查看性能数据,需要第三方查看器来查看 Google 跟踪格式的数据;可以说,最广泛使用的是 Google Chrome。
使用ccache的示例已经在 Clang 和 GCC 上进行了测试,但没有在 MSVC 上测试过。要获得ccache,可以使用操作系统的包管理器,或从ccache.dev/获取。
所有示例可在github.com/PacktPublishing/CMake-Best-Practices---2nd-Edition/查看。
保持 CMake 项目的可维护性
在长时间维护 CMake 项目时,通常会出现一些经常性的任务。有一些常见的事情,比如新文件被添加到项目中,或者依赖版本的增加,这些通常通过 CMake 处理起来比较琐碎。接着,有些事情如添加新的工具链或跨平台编译,最后是 CMake 本身的更新,当新功能(如预设)可用时。
定期更新 CMake 并利用新功能有助于保持项目的可维护性。虽然通常不实际更新每一个新版本,但检查 CMake 的新功能并在其发布时使用它们,可能会使项目更易于维护。例如,CMake 3.19 版本引入的 CMake 预设就是这样一个具有潜力的功能,它可以使许多复杂的CMakeLists.txt文件变得更加简单。
保持依赖关系的更新和控制通常是维护人员忙碌的任务。在这方面,使用一致的依赖处理概念将使得维护项目变得更加容易。在这方面,我们建议使用包管理器,正如在第五章中所描述的,集成第三方库与依赖管理,对于任何项目(除非是非常小的项目)。由于包管理器旨在将管理依赖关系的复杂性转移到包管理器上,而不是暴露给维护人员,它们通常具有使维护人员的工作更轻松的巨大潜力。
保持项目可维护性的核心是选择有效的项目结构,使得各部分可以轻松找到,并且能够相互独立地改进。具体选择哪种结构,往往取决于项目的背景和规模,因此对一个项目有效的结构可能不适用于另一个项目。
保持大型项目可维护性的最大收益是使用适合需求的项目结构。虽然项目组织的细节取决于项目开发的实际情况,但有一些良好的实践可以帮助保持项目的概览。保持项目可维护性从项目的CMakeLists.txt根目录开始。对于大型项目,CMakeLists.txt根目录应处理以下内容:
-
整个项目的基本设置,例如处理
project()调用、获取工具链、支持程序和帮助库。这还包括设置语言标准、搜索行为以及项目范围的编译器标志和搜索路径。 -
处理横向依赖,特别是像 Boost 和 Qt 这样的大型框架,应当放在项目的顶层。根据依赖关系的复杂性,创建并包含一个独立的
CMakeLists.txt文件来处理获取这些依赖关系,可能有助于保持项目的可维护性。建议使用add_subdirectory来包含依赖项,而不是使用include,因为这样,任何用于查找依赖项的临时变量的作用域仅限于该子目录,除非它们显式标记为缓存变量。如果构建目标不止几个,将它们移到自己的子目录并使用add_subdirectory()来包含它们,将有助于保持单个文件小且自包含。追求松耦合和高内聚性的设计原则,将使得库和可执行文件更容易独立维护。文件和项目结构应反映这一点,这可能意味着项目中的每个库和可执行文件都需要拥有自己的CMakeLists.txt文件。 -
单元测试是保持在与其测试对象接近的位置,还是作为根目录下
tests文件夹的子文件夹,这取决于个人偏好。将测试保持在独立的子目录中,并为其设置自己的CMakeLists.txt文件,可以更容易地处理与测试相关的依赖项和编译器设置。 -
项目的打包和安装说明应集中在项目的顶层。如果安装说明和打包说明过于庞大,可以将它们放入单独的
CMakeLists.txt文件中,并从根目录的CMakeLists.txt文件中引用。
以这种方式组织项目结构,将简化项目内部的导航,并有助于避免在 CMake 文件中不必要的代码重复,特别是当项目随着时间推移变得更大时。
良好的项目设置可能决定了是否每天与构建系统作斗争,或者能够顺利运行。使用本书中的技术和实践将有助于使 CMake 项目具有可维护性。通过使用 CMake 预设和构建容器或系统根目录(sysroots),如在第九章中所述,创建可重现的构建 环境,以及第十二章中所述,跨平台编译和自定义工具链,将有助于使构建在开发者和 CI 系统之间更具可移植性。最后但同样重要的是,按照第十三章中所述,重用 CMake 代码,将自定义 CMake 代码组织成宏和函数,将有助于避免冗余和重复。
除了 CMake 文件的复杂性之外,随着项目规模的增长,较长的配置和构建时间往往是另一个问题。为了管理这些日益增长的构建和配置时间,CMake 提供了一些功能来优化它们。
CMake 构建的性能分析
当 CMake 项目变得庞大时,配置它们可能需要相当长的时间,尤其是如果加载了外部内容或进行了大量的工具链特性检查。优化的第一步是检查配置过程中哪些部分花费了多少时间。从版本 3.18 开始,CMake 包含了命令行选项来生成精美的分析图,以便调查配置过程中时间的分布。通过添加 --profiling-output 和 --profiling-format 分析标志,CMake 将生成分析输出。写这本书时,仅支持 Google 跟踪格式作为输出格式。尽管如此,仍然需要指定格式和文件来生成分析信息。生成分析图的 CMake 调用可以像这样:
cmake -S <sourceDir> -B <buildDir> --profiling-output
./profiling.json --profiling-format=google-trace
这将把分析输出写入当前目录中的 profiling.json 文件。可以通过在地址栏中输入 about://tracing,使用 Google Chrome 查看输出文件。针对本书中的 GitHub 项目的缓存构建,跟踪输出可能如下所示:
图 14.1 – 在 Google Chrome 中显示的 CMake 项目示例分析图
在前面的图中,很明显有一个 add_subdirectory 调用占用了大部分配置项目时的时间。在这种情况下,这是 chapter5 子目录,花费了超过 3 秒的时间来完成。通过进一步深入分析,很明显这些是使用 Conan 包管理器的示例,特别是两个 conan_cmake_install 调用,使得配置过程相对较慢。在这种情况下,将对 Conan 的调用集中在更上层的目录中,将使得 CMake 配置运行的时间缩短一半。
为了正确解释分析输出,将不同的 CMake 运行进行比较是很有帮助的,特别是将清理缓存的 CMake 运行与利用缓存信息的运行进行比较。如果只有清理缓存的 CMake 运行花费了较长时间,但增量运行足够快,对于开发人员来说这可能仍然是可以接受的。然而,如果增量的 CMake 运行也花费了较长时间,这可能会更成问题。对其进行分析可能有助于找出每次配置运行中是否有不必要的步骤。
修复慢构建步骤将取决于具体情况,但长时间配置的常见原因是每次都会下载的文件,因为没有检查文件是否存在。分析分析调用通常会显示像execute_process或try_compile这样的调用占用了大量执行时间。最明显的修复方法是尝试去除这些调用,但通常这些调用是有原因的。更常见的是,跟踪导致这些命令的调用堆栈可能会揭示减少这些函数调用频率的机会。也许结果可以缓存,或者使用execute_process创建的文件不需要每次都生成。
尤其在交叉编译时,find_命令可能也会占用大量时间。通过更改不同的CMAKE_FIND_ROOT_PATH_MODE_变量来改变搜索顺序,正如在第五章《集成第三方库与依赖管理》中所述,可能在这里有一点帮助。为了更深入地分析为何find_调用占用过多时间,可以通过将CMAKE_FIND_DEBUG_MODE变量设置为true来启用调试输出。由于这会输出大量信息,因此最好只为特定的调用启用此功能,如下所示:
set(CMAKE_FIND_DEBUG_MODE TRUE)
find_package(...)
set(CMAKE_FIND_DEBUG_MODE FALSE)
CMake 的分析选项允许对构建过程的配置阶段进行分析;实际的编译和时间分析必须使用相应的生成器来完成。大多数生成器都支持某些分析选项或记录所需的信息。对于 Visual Studio 生成器,vcperf工具(github.com/microsoft/vcperf)将提供大量见解。在使用 Ninja 时,可以使用ninjatracing工具(github.com/nico/ninjatracing)将.ninja_log文件转换为 Google 跟踪格式。虽然 CMake 不提供分析实际编译和链接的软件的支持,但它确实提供了改进构建时间的方法,这将在下一节中看到。
优化构建性能
除了纯编译时间外,C++项目中构建时间长的主要原因通常是目标或文件之间不必要的依赖。如果目标之间存在不必要的链接要求,构建系统在执行构建任务时将无法并行化,一些目标将被频繁地重新链接。如在第六章《自动生成文档》中所述,创建目标的依赖图将有助于识别这些依赖关系。如果生成的图看起来更像是绳结而不是树形结构,那么优化和重构项目结构可能会带来大量性能提升。如在第七章《无缝集成代码质量工具与 CMake》中所述,使用include what you use和link what you use等工具,可能进一步帮助识别不必要的依赖关系。另一个常见的问题是 C 或 C++项目在公共头文件中暴露过多的私有信息,这通常导致频繁重建,降低增量构建的效率。
一个相对安全的选项来提升性能是将CMAKE_OPTIMIZE_DEPENDENCIES缓存变量设置为true。这将导致 CMake 在生成时移除一些静态或目标库的依赖,如果这些依赖不再需要。如果你处理大量静态或目标库,并且依赖关系图很深,这可能会在编译时间上带来一些收益。
一般来说,优化项目结构并将代码模块化,往往比代码优化对构建性能的影响更大。平均而言,编译和链接由许多小文件组成的项目,比由少数大文件组成的项目所需时间更长。CMake 可以通过所谓的统一构建来帮助提高构建性能,统一构建将多个文件合并成一个更大的文件。
使用统一构建
CMake 支持的统一构建可以通过将多个文件合并成较大的文件来帮助提高构建性能,从而减少需要编译的文件数量。这可能会减少构建时间,因为include文件只会处理一次,而不是每个小文件都处理一次。因此,如果许多文件包含相同的头文件,且这些头文件对编译器来说比较重(例如包含大量宏或模板元编程),这一做法会产生最大的效果。创建统一构建可能会显著提高构建时间,尤其是在使用大型头文件库(如 Eigen 数学库)时。另一方面,统一构建的缺点是增量构建可能会变得更慢,因为通常需要重新编译和链接更大的项目部分,即使只是单个文件发生了变化。
通过将CMAKE_UNITY_BUILD缓存变量设置为true,CMake 会将源文件合并成一个或多个 unity 源文件并进行构建,而不是使用原始文件。生成的文件遵循unity_<lang>_<Nr>.<lang>的命名模式,并位于构建目录中的Unity文件夹内。例如,C++的 unity 文件会命名为unity_0_cxx.cxx、unity_1_cxx.cxx等,C 语言文件则命名为unity_0_c.c等。这个变量不应该在CMakeLists.txt文件中设置,而是通过命令行或预设传递,因为是否需要 unity 构建可能取决于上下文。CMake 会根据需要和可能性决定项目的语言。如果需要合并文件,CMake 会判断是否可以合并。比如,头文件不会被编译,因此不会被添加到 unity 源文件中。对于 C 和 C++来说,这种方法效果良好,但对于其他语言,可能无法正常工作。
Unity 构建最适用于由许多小文件组成的项目。如果源文件本身已经很大,unity 构建可能会面临编译时内存不足的风险。如果只有少数文件在这方面有问题,可以通过在源文件上设置SKIP_UNITY_BUILD_INCLUSION属性来将它们从 unity 构建中排除,像这样:
target_sources(ch14_unity_build PRIVATE
src/main.cpp
src/fibonacci.cpp
src/eratosthenes.cpp
)
set_source_files_properties(src/eratosthenes.cpp PROPERTIES
SKIP_UNITY_BUILD_INCLUSION YES)
在示例中,eratosthenes.cpp文件将被排除在 unity 构建之外,而main.cpp和fibonacci.cpp将包含在一个编译单元中。如果之前的项目已配置,unit_0_cxx.cxx文件将包含类似以下内容:
/* generated by CMake */
#include "/chapter14/unity_build/src/main.cpp"
#include "/chapter14/unity_build/src/fibonacci.cpp"
请注意,原始源文件只会包含在 unity 文件中,而不会被复制到文件中。
从 CMake 3.18 开始,unity 构建支持两种模式,可以通过CMAKE_UNITY_BUILD_MODE变量或UNITY_BUILD_MODE目标属性来控制。模式可以是BATCH或GROUP,如果未指定,则默认使用BATCH模式。在BATCH模式下,CMake 会默认按文件添加到目标的顺序来决定哪些文件组合在一起。除非显式排除,否则所有目标文件都会被分配到批处理中。在GROUP模式下,每个目标必须明确指定文件如何分组。未分配到任何组的文件将单独编译。虽然GROUP模式提供了更精确的控制,但通常推荐使用BATCH模式,因为它的维护开销要小得多。
默认情况下,当 UNITY_BUILD_MODE 属性设置为 BATCH 时,CMake 会将文件按每批八个文件进行收集。通过设置目标的 UNITY_BUILD_BATCH_SIZE 属性,可以更改这一点。要全局设置批大小,可以使用 CMAKE_UNITY_BUILD_BATCH_SIZE 缓存变量。批大小应谨慎选择,因为设置得太小对性能提升有限,而设置得过大会导致编译器使用过多内存或编译单元达到其他大小限制。如果批大小设置为 0,则所有目标文件将合并为一个批次,但由于之前提到的原因,不推荐这样做。
在 GROUP 模式下,不会应用批大小,但必须通过设置源文件的 UNITY_GROUP 属性将文件分配到组中,以下是一个示例:
add_executable(ch14_unity_build_group)
target_sources(ch14_unity_build_group PRIVATE
src/main.cpp
src/fibonacci.cpp
src/eratosthenes.cpp
src/pythagoras.cpp
)
set_target_properties(ch14_unity_build_group PROPERTIES
UNITY_BUILD_MODE GROUP)
set_source_files_properties(src/main.cpp src/fibonacci.cpp
PROPERTIES UNITY_GROUP group1)
set_source_files_properties(src/erathostenes.cpp
src/pythagoras.cpp PROPERTIES UNITY_GROUP group2)
在这个示例中,main.cpp 和 fibonacci.cpp 文件会被归为一组,而 erathostenes.cpp 和 pythagoras.cpp 则会在另一个组中编译。在 GROUP 模式下,生成的文件会命名为 unity_<groupName>_<lang>.<lang>。因此,在这个示例中,文件将被命名为 unity_group1_cxx.cxx 和 unity_group2_cxx.cxx。
根据项目的结构,使用统一构建可能会显著提高构建性能。另一种常用于提高构建速度的技术是使用预编译头文件。
预编译头文件
预编译头文件通常对编译时间有显著提升,尤其是在处理头文件是编译时间的重要组成部分,或当头文件在多个编译单元中被包含时。简而言之,预编译头文件通过将一些头文件编译成二进制格式,从而使编译器更容易处理。自 CMake 3.16 起,已经直接支持预编译头文件,大多数主要编译器也支持某种形式的预编译头文件。
预编译头文件通过 target_precompile_headers 命令添加到目标中,其语法如下:
target_precompile_headers(<target>
<INTERFACE|PUBLIC|PRIVATE> [header1...]
[<INTERFACE|PUBLIC|PRIVATE> [header2...] ...])
PRIVATE、PUBLIC 和 INTERFACE 关键字具有常见含义。在大多数情况下,应使用 PRIVATE。命令中指定的头文件将会被收集到 cmake_pch.h 或 cmake_pch.hxx 文件中,该文件会通过相应的编译器标志强制包含到所有源文件中,因此源文件中无需添加 #include "cmake_pch.h" 指令。
头文件可以指定为普通文件名,带尖括号,或带双引号,在这种情况下,它们必须使用双中括号进行转义:
target_precompile_headers(SomeTarget PRIVATE myHeader.h
[["external_header.h"]]
<unordered_map>
)
在这个示例中,myHeader.h 会从当前的源代码目录中搜索,而 external_header.h 和 unordered_map 则会在 include 目录中搜索。
在大型项目中,多个目标之间使用相同的预编译头文件是相对常见的。为了避免每次都重新定义它们,可以使用target_precompile_headers的REUSE_FROM选项:
target_precompile_headers(<target> REUSE_FROM
<other_target>)
重用预编译头文件会引入目标与other_target之间的自动依赖关系。两个目标将启用相同的编译器选项、标志和定义。一些编译器会在这种情况不符合时发出警告,但有些编译器则不会。
另一个目标的预编译头文件只有在当前目标没有定义自己的预编译头文件集时才能使用。如果目标已经定义了预编译头文件,CMake 将会报错并停止。
当包含的头文件很少更改时,预编译头文件在提高构建时间方面最为有效。由编译器、系统或外部依赖项提供的任何头文件通常都是合适的预编译头文件候选。哪些头文件确实带来最多的好处,需要通过试验和测量来确定。
与统一构建一起,预编译头文件可以显著提高编译时间,尤其是对于频繁重用头文件的项目。优化增量构建编译时间的第三种方式是使用编译器缓存,即ccache。
使用编译器缓存(ccache)来加速重建
缓存通过缓存编译结果并检测相同编译是否再次进行来工作。在编写本书时,最受欢迎的编译缓存程序是ccache,它是开源的,并且在ccache程序下进行分发,ccache不仅影响增量构建,还会影响全新构建,只要缓存没有在两次运行之间被删除。创建的缓存可以在运行相同编译器的系统之间移植,并且可以存储在远程数据库中,以便多个开发人员可以访问相同的缓存。官方支持ccache与 GCC、Clang 以及ccache与 CMake 一起使用,最佳的配合方式是使用 Makefile 和 Ninja 生成器。写本书时,Visual Studio 尚不支持。
要在 CMake 中使用ccache,可以使用CMAKE_<LANG>_COMPILER_LAUNCHER缓存变量,其中<LANG>替换为相应的编程语言。推荐的做法是通过预设传递这个变量,但为了在CMakeLists.txt文件中启用 C 和 C++的ccache,可以使用以下代码:
find_program(CCACHE_PROGRAM ccache)
if(CCACHE_PROGRAM)
set(CMAKE_C_COMPILER_LAUNCHER ${CCACHE_PROGRAM})
set(CMAKE_CXX_COMPILER_LAUNCHER ${CCACHE_PROGRAM})
endif()
从预设或命令行传递变量也是一个不错的替代方案,特别是因为ccache的配置最容易通过使用环境变量来完成。
使用默认配置的ccache可能已经在构建时间上带来相当大的改进,但如果构建稍微复杂一些,可能需要进一步的配置。要配置ccache,可以使用一些以CCACHE_开头的环境变量;有关所有配置选项的完整文档,请参阅ccache文档。需要特别注意的常见场景包括将ccache与预编译头文件结合使用,管理通过FetchContent包含的依赖项,以及将ccache与其他编译器包装器结合使用,如distcc或icecc用于分布式构建。对于这些场景,将使用以下环境变量:
-
为了高效地使用预编译头文件,设置
CCACHE_SLOPPINESS为pch_defines,time_macros。原因在于ccache无法检测到预编译头文件中#defines的变化,也无法判断在创建预编译头文件时是否使用了__TIME__、__DATE__或__TIMESTAMP__。可选地,将include_file_mtime设置为CCACHE_SLOPPINESS可能会进一步提高缓存命中性能,但它带有一个非常小的竞态条件风险。 -
当包含从源代码构建的大型依赖项时(例如,使用
FetchContent),将CCACHE_BASEDIR设置为CMAKE_BINARY_DIR可能会提高缓存命中率;特别是当有多个(子)项目获取相同的依赖项时,这可能会带来性能提升。另一方面,如果项目本身的源代码需要更多时间编译,将其设置为CMAKE_SOURCE_DIR可能会带来更好的结果。需要通过试验来确定哪种设置能带来更好的效果。 -
要与其他编译器包装器一起使用,
CCACHE_PREFIX环境变量用于为这些包装器添加命令。建议在链式调用多个包装器时首先使用ccache,以便其他包装器的结果也可以被缓存。
通过使用配置预设将环境变量传递给 CMake,如在第九章中所述,创建可重现的构建环境,是推荐的方法;这可以与在CMakeLists.txt文件中检测ccache相结合,或者也可以通过以下预设将ccache命令传递:
{
"name" : "ccache-env",
...
"environment": {
"CCACHE_BASEDIR" : "${sourceDir}",
"CCACHE_SLOPPINESS" : "pch_defines,time_macros"
}
},
使用这些配置,使用ccache可以大大提高编译时间的效率,但缓存编译器结果是一个复杂的问题,因此为了获得最大的好处,应该查阅ccache文档。在大多数情况下,使用ccache可能通过相对简单的设置带来最大的性能提升。其他工具,如用于分布式构建的distcc,从 CMake 的角度看工作非常相似,但需要更多的配置工作。
分布式构建
分布式构建通过将部分编译任务分配给网络上的不同机器来工作。这要求设置能够接受连接的服务器,并配置客户端以便能够连接这些服务器。为distcc设置服务器的命令如下:
distccd --daemon --allow client1 client2
在这里,client1和client2是各自构建服务器的主机名或 IP 地址。在客户端,配置 CMake 使用distcc的方式类似于通过将CMAKE_<LANG>_COMPILER_LAUNCHER设置为distcc命令来使用ccache。潜在服务器的列表可以通过配置文件或DISTCC_HOSTS环境变量进行配置。与ccache配置不同,这种配置非常依赖主机,因此配置应放在用户预设中,而不是项目特定的预设中。相应的预设可能如下所示:
{
"name" : "distcc-env",
...
"environment": {
"DISTCC_HOSTS" : "localhost buildsrvr1,cpp,lzo
host123,cpp,lzo"
}
},
注意buildsrvr1主机后的cpp后缀。这将distcc置于所谓的泵模式,通过将预处理也分发到服务器来进一步提高编译速度。lzo后缀告诉distcc压缩通信内容。
分布式构建的缺点在于,为了获得速度提升,网络必须足够快速,否则传输编译信息的成本可能会高于减少的构建时间。然而,在大多数本地网络中,这通常是可以满足的。如果机器在处理器架构、编译器和操作系统方面相似,分布式构建效果很好。虽然使用distcc进行交叉编译是可能的,但设置起来可能需要相当多的工作。通过结合良好的编码实践、预编译头文件和编译器缓存,大型项目仍然能够正常工作,而无需等待每次构建花费几分钟时间。
总结
在本章中,我们讨论了一些关于构建和维护 CMake 项目的常见技巧,尤其是大型项目。随着项目规模的增大,配置和构建时间通常会增加,这可能会妨碍开发人员的工作流。我们探讨了 CMake 的性能分析功能,这可能是找出配置过程中的性能瓶颈的有用工具,尽管它不能用于分析编译本身的性能。
为了帮助解决较长的编译时间,我们展示了如何使用 CMake 中的统一构建和预编译头文件来改善编译时间。如果这些方法还不能达到预期效果,可以通过在编译器命令前加上编译器缓存(如ccache)或分布式编译器(如distcc)来使用这些工具。
优化构建性能是一个非常令人满意的过程,即使找到合适的工具和方法组合以最大化 CMake 的效率可能有些繁琐。然而,经过高度优化的构建也有其缺点,那就是构建可能更容易失败,而且构建过程中增加的复杂性可能需要更深入的理解和更多的专业知识来长期维护。
在下一章,我们将概述从任何构建系统迁移到 CMake 项目的一些高层策略。
问题
-
哪些命令行标志用于从 CMake 生成性能分析信息?
-
从一个非常高层次来看,统一构建如何优化编译时间?
-
BATCH模式和GROUP模式在统一构建中有什么区别? -
如何将预编译头文件添加到目标中?
-
CMake 如何处理编译器缓存?
答案
-
使用
--profiling-output <filename>和--profiling-format=google-trace标志。 -
通过将多个编译单元合并为一个,减少了重新链接的需求。
-
在
BATCH模式下,CMake 会自动将源文件分组,而在GROUP模式下,分组需要由用户指定。默认情况下,BATCH模式会将所有源文件分组为统一构建,而GROUP模式只会将显式标记的文件添加到统一构建中。 -
通过使用
target_precompile_headers函数,预编译头文件会自动包含,无需在文件中使用#include指令。 -
通过在编译器命令前加上
CMAKE_<LANG>_COMPILER_LAUNCHER中指定的命令。
第十五章:迁移到 CMake
虽然 CMake 正在发展成为 C++ 和 C 项目的事实上的行业标准,但仍然有一些项目—有时甚至是大型项目—使用不同的构建系统。当然,只要它满足你的需求,这并没有问题。然而,在某些时候,出于某种原因,你可能希望切换到 CMake。例如,也许软件应该能够通过不同的 IDE 或在不同的平台上进行构建,或者依赖管理变得繁琐。另一个常见的情况是,当代码库结构从一个包含所有库的大型单一仓库,变更为每个库项目都有独立仓库时。无论什么原因,迁移到 CMake 可能是一个挑战,尤其是对于大型项目,但结果可能是值得的。
虽然一次性转换整个项目是首选方式,但通常会有一些非技术性要求使得这种方式不可行。例如,在迁移过程中,某些部分的开发可能仍然需要继续进行,或者因为一些超出团队控制的需求,项目的某些部分无法一开始就进行迁移。
因此,通常需要逐步的方法。更改构建系统很可能会影响任何 CI/CD 过程,因此这也应该考虑在内。本章将探讨一些高级策略,讲解如何逐步将项目迁移到 CMake。然而,注意具体的迁移路径很大程度上依赖于各自的具体情况。例如,从基于单一仓库的 Makefile 项目迁移,和从跨多个仓库的 Gradle 构建迁移,操作方式是不同的。
改变构建系统,可能还包括项目结构的变化,可能会对所有相关人员造成很大的干扰,因为他们已经习惯了现有的结构和构建系统。因此,决定切换构建系统不应轻率做出,只有在收益显著的情况下才能进行更改。
本章虽然侧重于迁移项目到 CMake 的过程,但通常迁移的目的并不是为了切换构建系统,而是为了实现其他主要目标,比如简化项目结构或减少项目各部分之间的耦合,从而使它们能够更容易地独立维护。在谈论迁移的好处时,记住这些好处不一定非得是纯粹的技术性好处,比如通过更好地并行化构建来提升构建速度。好处也可以来自“社会”层面,例如,拥有一种标准化且广为人知的构建软件方式,可以减少新开发者的入门时间。
本章将涵盖以下主题:
-
高级迁移策略
-
迁移小型项目
-
将大型项目迁移到 CMake
在本章中,我们将介绍一些从任何构建系统迁移到 CMake 的高级概念。正如你所看到的,迁移小型项目可能相当简单,而大型复杂项目则需要更多的前期规划。在本章结束时,你将对将不同规模的项目迁移到 CMake 的不同策略有一个清晰的了解。此外,我们还会提供一些迁移时需要检查的提示,并附上一份大致的迁移步骤指南,以及如何与遗留构建系统互动。
技术要求
本章没有具体的技术要求,因为它展示的是概念,而不是具体示例。然而,建议在迁移到 CMake 时使用最新版本的 CMake。本章中的示例假设使用的是 CMake 3.21 或更高版本。
高级迁移策略
在将软件项目迁移到 CMake 之前,首先需要回答一些关于现有项目的问题,并定义最终目标应该是什么。从非常抽象的层面来看,通常软件项目会定义如何处理以下事项:
-
软件的各个部分——即库和可执行文件——如何编译,以及它们如何被链接在一起
-
使用哪些外部依赖,它们是如何被找到的,如何在项目中使用
-
构建哪些测试以及如何运行它们
-
软件如何安装或打包
-
提供额外的信息,如许可证信息、文档、更新日志等
一些项目可能只定义前述点的一个子集。但通常来说,这些是我们作为开发人员希望在项目设置中处理的任务。这些任务通常以结构化的方式定义,例如使用 Makefile 或特定于 IDE 的项目定义。项目的组织和结构方式有无数种,而一种设置有效的方式可能不适用于另一种设置。因此,在任何情况下,都需要对具体情况进行个别评估。
有一些工具可以自动将一些构建系统(如qmake、Autotools 或 Visual Studio)转换为 CMake,但生成的 CMake 文件质量至多堪忧,并且它们通常假设某些约定。因此,不建议使用这些工具。
此外,一个项目可能会定义它如何在 CI/CD 流水线中构建、测试和部署,虽然这与项目描述密切相关,但 CI/CD 流水线的定义通常不会被视为项目描述的一部分,而是作为使用项目定义的内容。更换构建系统往往会影响 CI/CD 流水线,且通常,想要现代化或更改 CI/CD 基础设施的需求可能是更改构建系统的触发因素。
需要意识到,迁移工作只有在不再使用旧的构建方式时才算完成。因此,我们建议在项目迁移到 CMake 后,删除所有旧的构建指令,以消除维护与旧构建方式的向后兼容性所需的工作。
在理想的情况下,项目的所有部分都应该迁移到 CMake。然而,也有一些情况是无法做到的,或者如果某个部分是否应该迁移,从经济角度来看也值得怀疑。例如,项目可能依赖于一个不再积极维护并且即将淘汰的库。最佳情况是,可以利用迁移工作作为一个契机来完全移除这个依赖;然而,更常见的是,这并不可行。在无法完全移除遗留依赖的情况下,可能的好做法是将其从项目中移除,使其不再被视为内部依赖,而是作为一个外部依赖,并且拥有自己的发布周期。此外,如果无法移除或移除工作量过大,可以对这个特定的库做出例外处理,使用遗留的构建系统和 ExternalProject 解决方案,作为临时措施。对于本章讨论的迁移策略,我们将依赖分为内部依赖和外部依赖。内部依赖是由与要迁移的项目相同的组织或人员积极开发的依赖,以便开发人员可以有可能修改构建过程。而外部依赖是开发人员对构建过程或代码几乎没有控制权的依赖。
迁移项目时需要考虑的一件事是,在迁移过程中有多少人会被阻止继续在项目上工作,以及需要同时维护旧的构建方式和 CMake 的时间长短。改变构建系统对开发者的工作流程有很大干扰。很可能会有一些时候,项目的某些部分在完全迁移之前无法继续开发。解决这一问题的最简单方法是暂时停止功能开发,让每个人都参与迁移工作。然而,如果这不可行,良好的沟通和有效的工作分配往往是所需要的。话虽如此,避免在迁移过程中半途而废:将一个大项目的某些部分迁移了,而其他部分仍然使用旧的构建方式,这很可能会带来两者构建方式的缺点,却无法享受到任何一种的好处。
那么,迁移项目时该如何推进呢?对于那些主要依赖外部库的小型项目,这可能会相对简单。
迁移小型项目
我们将小型项目定义为仅包含少数目标的项目,这些目标通常是一起部署的。小型项目是自包含在单一仓库中的,通常你可以很快地了解它们。这些项目可能构建一个单一的库或带有少量外部依赖项的可执行文件。在这些情况下,迁移到 CMake 通常相对简单。对于小型项目,在第一次迁移时,将所有内容放在一个文件中可能是最简单的方式,以便快速获得早期结果。如果项目已经正确构建,将文件重新排列并将 CMakeLists.txt 文件拆分为多个部分并使用 add_subdirectory() 会更加容易。
向 CMake 迁移的一般方法如下:
-
在项目的根目录下创建一个空的
CMakeLists.txt文件。 -
确定项目中的目标和相关文件,并在
CMakeLists.txt文件中创建适当的目标。 -
查找所有外部依赖项和
include路径,并在必要时将它们添加到 CMake 目标中。 -
确定必要的编译器特性、标志和编译器定义(如果有的话),并将它们提供给 CMake。
-
通过创建必要的目标并调用
add_test(),将所有测试迁移到 CTest。 -
确定与 CMake 相关的任何安装或打包说明,包括需要安装的资源文件等。
-
清理并优化项目,使其更整洁。如果需要,可以创建预设,重新排列文件和文件夹,并拆分
CMakeLists.txt文件。
自然地,每个步骤到底需要做什么,很大程度上取决于原始项目的组织方式和使用的技术。通常,迁移过程需要多次迭代 CMakeLists.txt 文件,直到一切正常工作。如果第一次实现的 CMake 项目看起来还不太完美,这通常是很正常的。
对于小型项目,处理依赖关系是比较困难的任务之一,因为存在一些隐含的假设,关于依赖关系的存放位置及其在项目内部的结构或隐藏方式。使用包管理器,如在第五章中所述,集成第三方库和依赖管理,可以显著减少处理依赖关系的复杂性。
通常,迁移小型、主要是自包含的项目相对直接,尽管根据原始设置的混乱程度,重新整理和使一切重新正常工作可能需要相当多的工作。在大型组织中,多个此类小型项目可能会一起用于软件组合,再次可以被描述为一个项目。它们的迁移需要更多的规划才能顺利进行。
将大型项目迁移到 CMake
迁移包含多个库和多个可执行文件的大型项目可能是一项挑战。仔细分析,这些项目实际上可能是多个层次嵌套的项目,包含一个或多个根项目,这些根项目整合了多个子项目,而这些子项目又包含或需要多个子项目。根据组织软件组合的大小和复杂性,可能会并存多个共享公共子项目的根项目,这可能会使迁移变得更加复杂。创建项目和子项目的依赖图(如以下图示)通常能帮助我们确定迁移顺序。每个项目可能包含多个项目或目标,它们有自己的依赖关系:
图 15.1 – 一个示例项目层次结构,展示了各种依赖关系
在迁移之前,首先需要彻底分析项目之间的依赖关系以及它们需要按照什么顺序构建。根据项目的状态,生成的图可能相当庞大,因此确定从哪里开始可能会是一个挑战。实际上,依赖图通常不像本书中展示的那样整洁。是否先理清项目的结构然后迁移到 CMake,还是先迁移到 CMake 再理清项目结构,取决于实际情况。如果项目非常大且复杂,首先从图中找到尽可能自包含的“岛屿”,然后从那里开始。
对于复杂的层次化项目,有两种主要的迁移策略需要考虑。一种是自上而下的方法,其中根项目首先进行迁移和替换,然后子项目按最少传入依赖关系的顺序排列。第二种是自下而上的方法,逐个迁移各个项目,从依赖关系最多的项目开始。
自上而下的方法有一个好处,那就是可以确保整个项目能够尽早使用 CMake 构建、测试和打包,但这需要将现有的构建系统集成到 CMake 中,使用ExternalProject。自上而下方法的缺点可能是在早期阶段,生成的 CMake 项目包含了大量用于处理由旧系统构建的包的自定义代码。实际上,使用一些临时的解决方法将现有项目包含到构建中,通常是实现快速且良好结果的最务实方法,并且可以在一定程度上减轻为相同子项目维护两个构建系统的工作量。
自下而上的方法有一个好处,即每个迁移到 CMake 的库可以使用已经迁移的依赖项。缺点是根项目只能在所有子项目都能用 CMake 构建后才可以替换。尽管项目是从下往上迁移的,但一个好的做法是在早期就创建根 CMake 项目。它与原始构建系统中的根项目并存。这样可以在新的 CMake 项目中提前放入外部依赖项并安装配置和打包指令。
下图展示了自上而下和自下而上的策略并排展示的情况。框旁的数字代表迁移顺序:
图 15.2 – 迁移顺序示例
除了整体迁移策略,另一个需要考虑的因素是项目是设置为超级构建(superbuild)还是常规项目。当采用自上而下的策略时,超级构建结构可能更容易迁移,因为它的一个优点是设计上更容易集成非 CMake 项目。关于超级构建结构的更多信息,请参考 第十章,在超级构建中处理分布式代码库和依赖项。
无论选择自上而下还是自下而上的方法来迁移单个项目,迁移大型项目的总体策略看起来都将是以下的方式:
-
分析依赖关系、项目层级和部署单元。
-
决定迁移策略,并确定是采用常规项目结构还是超级构建。
-
创建或迁移根项目,并通过
ExternalProject、FetchContent或中间find模块将所有尚未转换的项目拉入,如果使用的是二进制包。 -
使用 CMake 处理项目范围的依赖关系。
-
将子项目逐一转换为 CMake,如本章最后一节所述。如果使用中间查找模块,逐一替换它们:
-
如果需要,可以在此时将依赖关系处理更改为包管理器。
-
查找常见选项,将它们传播到根项目,并创建预设。
-
-
在迁移子项目时,如果尚未完成,请在 CMake 中组织打包。
-
清理、重新组织文件和项目,提升性能,等等。
通过分析现有项目层次结构和依赖关系来开始迁移,有助于你制定迁移计划,以便与所有相关人员进行沟通。创建类似之前的可视化图通常是一个很好的工具,尽管对于非常大的项目,这本身可能成为一个相当大的挑战。制定迁移计划时,另一个重要的点是识别哪些是常常一起部署的,以及哪个子项目的发布频率是多少。那些很少变动和发布的项目,可能没有那些频繁更新和发布的项目那样迫切需要迁移。识别部署单元与项目的打包方式密切相关。根据打包的组织方式,可能需要等到所有项目都迁移完成后,才能将打包迁移到 CMake。
到目前为止,我们主要讨论了子项目,但在分析现有结构时,重要的是要识别哪些子项目实际上是应该作为独立项目构建的,既可以在完整项目的上下文中构建,也可以作为常规 CMake 目标处理,后者很少在项目外部构建。
创建一个根 CMakeLists.txt 文件,将涵盖基本的项目设置,并包含必要的模块,如 FetchContent、CTest、CPack 等。虽然不直接在 CMakeLists.txt 文件中,但交叉编译所需的工具链文件、构建容器或 sysroots 也将在此设置。对于大型项目,根 CMakeLists.txt 文件通常不直接包含目标。相反,它通过 add_subdirectory 或 FetchContent 来包含,或者在超构建的情况下,使用 ExternalProject。根 CMakeLists.txt 文件应具有以下结构:
-
项目定义以及 CMake 的最低版本要求。
-
全局属性和默认变量,例如最低语言标准、自定义构建类型、搜索路径和模块路径。
-
项目范围内使用的任何 模块和助手函数。
-
项目范围内的
find_package()。 -
add_subdirectory、FetchContent或在超构建的情况下,ExternalProject。 -
整个项目的测试。通常,每个子项目都会有自己的单元测试,但集成测试或系统测试可能会位于顶层。
-
打包指令,用于 CPack。
根据定义的复杂性,将外部依赖、测试和打包的处理分到自己的子目录中,可能有助于保持 CMake 文件简短且简洁。
项目范围内使用的外部依赖项可能是大型软件框架,如 Qt 或 Boost,或是小型但常用的实用库,使用频繁。
对于自上而下的方法,子项目将在开始时导入,然后逐一迁移。而在使用自下而上的策略时,构建目标和子项目一开始很可能是空的,随着项目的迁移逐渐被填充。在迁移子项目时,注意寻找可以传播到根项目或移动到预设选项中的常见依赖关系或构建选项。
一旦所有子项目迁移完成,通常还有一些维护任务待完成,例如整理打包文件、协调和归类测试等。此外,迁移完成后,CMake 文件中仍然可能会有一些杂乱的内容,因此进行一次额外的清理,集中整理功能,将确保迁移后的项目能够顺利使用。
通常,迁移大型项目是一项挑战,尤其是在构建过程复杂且(遗憾的是,通常是这样)缺乏适当文档的情况下。软件有许多不同的构建方式,本节描述的策略试图提供一种通用的方法。然而,最终每个迁移过程都是独一无二的。有些情况下,构建系统复杂到描述的迁移策略反而成为障碍;例如,包含尚未迁移的项目到 CMake 中的难度如此之大,以至于逐步迁移可能比从头开始构建还要费力。让我们更详细地看看,当采用自上而下的方式时,如何将使用原始构建系统的子项目纳入其中。
在进行自上而下迁移时整合遗留项目
对于自上而下的迁移策略,现有的项目在开始时就会提供给 CMake。最简单的方法是使用ExternalProject,无论是否计划使用 superbuild。导入的目标可以直接定义,也可以通过 find 模块定义。对于常规项目,这只是一个中间步骤,目的是能够相对快速地构建完整项目,并将配置和构建顺序的控制交给 CMake。生成的 CMake 代码可能看起来不太好看,但首要目标是让根项目能够通过 CMake 构建。不过,在迁移子项目时,确保一步步清理它。对于由单一代码库组成或通过 Git 子模块或类似方式拉取依赖的常规项目,ExternalProject_Add 可以通过指定 SOURCE_DIR 属性来省略下载。包含 Autotools 项目的 CMake 代码可能如下所示:
include(ExternalProject)
set(ExtInstallDir ${CMAKE_CURRENT_BINARY_DIR}/install)
ExternalProject_Add(SubProjectXYZ_ext
SOURCE_DIR ${CMAKE_CURRENT_LIST_DIR}/SubProjectXYZ/
INSTALL_DIR ${ExtInstallDir}
CONFIGURE_COMMAND <SOURCE_DIR>/configure --prefix <INSTALL_DIR>
INSTALL_COMMAND make install
BUILD_COMMAND make
)
add_library(SubProjectXYZ::SubProjectXYZ IMPORTED SHARED)
set_target_properties(SubProjectXYZ::SubProjectXYZ
PROPERTIES IMPORTED_LOCATION " ${CMAKE_CURRENT_LIST_DIR}
/SubProjectXYZ/lib/libSubProjectXYZ.so"
INTERFACE_INCLUDE_DIRECTORIES "${CMAKE_CURRENT_LIST_DIR}
/SubProjectXYZ/include"
IMPORTED_LINK_INTERFACE_LANGUAGES "CXX"
)
...
add_dependencies(SomeTarget SubProjectXZY_ext)
target_link_libraries(SomeTarget SubProjectXYZ:: SubProjectXYZ)
由于ExternalProject只在构建时提供内容,因此这种方法仅适用于已经存在于本地文件夹中的子项目。因为它们包括一个在配置时必须存在的导入目标目录,而在使用target_link_libraries时,这些目录必须在配置时就已经存在,因此导出的路径应该指向源目录,而不是外部项目的安装位置。
这些做法是临时的权宜之计。
这里描述的使用ExternalProject和FetchContent的做法,旨在为迁移过程中能够将遗留项目包含到 CMake 构建中提供临时的解决方案。这些做法并不适合在生产环境中使用。此模式允许使用原始构建系统,并提供一个导入的目标,用于链接已经迁移的项目。是否通过创建这样的中间项目结构来实现早期用 CMake 构建整个项目的努力是值得的,需要根据每个案例单独考虑。
如果从 Microsoft Visual Studio 迁移而不是使用ExternalProject,则可以使用include_external_msproject()函数直接包含项目文件。
通过这些内容,你应该掌握了从其他构建系统迁移到 CMake 所需的所有概念。
总结
在本章中,你学习了关于将各种规模的项目迁移到 CMake 的一些概念和策略。迁移项目到 CMake 所需的努力和实际工作将很大程度上依赖于项目的具体设置。然而,采用这里描述的方法后,选择合适的策略应该会更容易。改变构建过程和开发者工作流程通常是破坏性的,因此你必须仔细考虑这种努力是否值得。尽管如此,将项目迁移到 CMake 将开启所有用于构建高质量软件的功能和做法的可能性,正如本书所述。此外,拥有一个清晰且维护良好的构建系统将使开发者能够专注于他们的主要任务,即编写代码和发布软件。
这将带我们进入本书的最后一章,内容是如何访问 CMake 社区、寻找进一步的阅读材料并为 CMake 本身做贡献。
问题
-
迁移大型项目的两种主要策略是什么?
-
在选择自下而上的方法迁移项目时,应该首先迁移哪些子项目或目标?
-
在选择自上而下的方法时,应该首先迁移哪些项目?
-
自上而下的方法有哪些优缺点?
-
使用自下而上的方法进行迁移有哪些优缺点?
答案
-
大型项目可以选择自上而下或自下而上的方式进行迁移。
-
在使用自下而上的方法时,应该首先迁移具有最多传入依赖项的项目或目标。
-
选择自上而下方法时,应优先迁移那些依赖最少的项目。
-
自上而下的方法可以快速使用 CMake 作为入口点构建整个项目。此外,每个已迁移的项目完成后,旧的构建系统可以被丢弃。缺点是,自上而下的方法需要一些中间代码。
-
自下而上的方法比自上而下的方法需要更少的中间代码,并且可以从一开始就写出干净的 CMake 代码。缺点是,只有在所有子项目都迁移完成后,才能构建整个项目。


浙公网安备 33010602011771号