原文:
zh.annas-archive.org/md5/125f0c03ca93490db2ba97b08bc69e99
译者:飞龙
序言
打造顶级软件并非易事。开发者在网上研究这一主题时,常常遇到的问题是如何判断哪些建议是更新的,哪些方法已经被更 fresh、更好的实践所超越。与此同时,大多数资源对这个过程的解释是混乱的,没有适当的背景、上下文和结构。
现代 CMake for C++ 是一本端到端的指南,提供了更简单的体验,因为它全面地处理了 C++解决方案的构建。它教会您如何在 CMake 项目中使用 CMake,并展示使其可维护、优雅和干净的原因。它引导您通过许多项目中出现的复杂任务的自动化,包括构建、测试和打包。
本书将指导您如何形成源目录,以及如何构建目标和包。随着您的进展,您将学习如何编译和链接可执行文件和库,这些过程是如何详细工作的,以及如何优化所有步骤以获得最佳结果。您还将了解如何将外部依赖项添加到项目中:第三方库、测试框架、程序分析工具和文档生成器。最后,您将探索如何为内部和外部目的导出、安装和打包您的解决方案。
完成本书后,您将能够在专业水平上自信地使用 CMake。
本书适合谁阅读
学习 C++语言往往不足以让你为交付项目到最高标准做好准备。如果你有兴趣成为一名专业的构建工程师,一个更好的软件开发者,或者简单地说,想精通 CMake,如果你想了解项目是如何组合在一起的以及为什么这样,如果你正在从不同的构建环境中过渡,或者如果你对从零开始学习现代 CMake 感兴趣,那么这本书适合你。
本书涵盖内容
第一章,CMake 的初步步骤,涵盖了如何安装和使用 CMake 的命令行,以及构成项目的文件。
第二章,CMake 语言,提供了关键代码信息:注释、命令调用和参数、变量、列表和控制结构。
第三章,设置您的第一个 CMake 项目,介绍了项目的基本配置、所需的 CMake 版本、项目元数据和文件结构,以及工具链设置。
第四章,与目标工作,介绍了逻辑构建目标,这些目标为可执行文件和库生成工件。
第五章,使用 CMake 编译 C++源代码,解释了编译过程的细节以及如何在 CMake 项目中控制编译过程。
第六章,使用 CMake 链接,提供了关于链接、静态和共享库的一般信息。本章还解释了如何结构化一个项目,以便它可以被测试。
第七章,使用 CMake 管理依赖项,解释了现代 CMake 中可用的依赖管理方法。
第八章,测试框架,描述了如何将最流行的测试框架添加到您的项目中,以及如何使用 CMake 工具集中的 CTest 工具。
第九章,程序分析工具,介绍了如何在您的项目中执行自动格式化,以及进行静态和动态分析。
第十章,生成文档,解释了如何使用 Doxygen 直接从 C++源代码生成用户手册。
第十一章,安装和打包,展示了如何将您的项目准备成在其他项目中使用或在系统上安装。我们还将看到 CPack 工具的解释。
第十二章,创建你的专业项目,介绍了如何将迄今为止你所获得的所有知识整合成一个完全形成的项目。
附录:杂项命令,提供最流行命令的快速参考:string()
、list()
、file()
和 math()
。
为了充分利用本书
本书假设您对 C++和类 Unix 系统有基本的熟悉度。虽然这不是一个严格的要求,但熟悉这些内容将有助于您完全理解本书中的示例。
本书针对 CMake 3.20,但描述的大多数技术应该从 CMake 3.15 起就可以工作(在之后添加的功能通常会 highlighted)。
所有示例已在安装以下包的 Debian 上进行测试:
clang-format clang-tidy cppcheck doxygen g++ gawk git graphviz lcov libpqxx-dev libprotobuf-dev make pkg-config protobuf-compiler tree valgrind vim wget
为了体验相同的环境,建议使用如第一章所解释的 Docker 镜像。
如果你使用的是本书的数字版,我们建议你亲自输入代码或从本书的 GitHub 仓库(下一节中有链接)访问代码。这样做可以帮助你避免因复制和粘贴代码而可能出现的任何潜在错误。
下载示例代码文件
您可以从 GitHub 下载本书的示例代码文件,地址为github.com/PacktPublishing/Modern-CMake-for-Cpp
。如果代码有更新,它将在 GitHub 仓库中更新。
我们还有其他丰富的书籍和视频目录中的代码包,可在github.com/PacktPublishing/
找到。去看看吧!
下载彩色图像
我们还提供了一个 PDF 文件,其中包含本书中使用的屏幕截图和图表的彩色图像。您可以通过此链接下载: static.packt-cdn.com/downloads/9781801070058_ColorImages.pdf
。
本书中使用了以下约定:
本书中使用了多种文本约定。
文本中的代码
:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、假 URL、用户输入和 Twitter 处理。以下是一个示例:“选择Debug
、Release
、MinSizeRel
或RelWithDebInfo
并按如下方式指定。”
代码块如下所示:
cmake_minimum_required(VERSION 3.20)
project(Hello)
add_executable(Hello hello.cpp)
当我们要引起您对代码块中特定部分的关注时,相关行或项目以粗体显示:
cmake_minimum_required(VERSION 3.20)
project(app)
message("Top level CMakeLists.txt")
add_subdirectory(api)
任何命令行输入或输出如下所示:
cmake --build <dir> --parallel [<number-of-jobs>]
cmake --build <dir> -j [<number-of-jobs>]
粗体:表示新术语、重要单词或您在屏幕上看到的单词。例如,菜单或对话框中的单词以粗体显示。以下是一个示例:“如果其他方法都失败了,我们需要使用大炮,总有跟踪模式。”
提示或重要说明
文本中代码如下所示。
第一部分:介绍 CMake
打好基础对于理解更高级的内容和避免愚蠢的错误至关重要。大多数 CMake 用户在这里会遇到麻烦:没有坚实的基础,很难达到正确的结果。难怪会这样。跳过入门材料,直接进入实际行动并快速完成任务是很有诱惑力的。我们在本节中解释了 CMake 的核心主题,并拼凑了几行代码来展示最简单的项目看起来是什么样子,以解决这两个问题。
为了构建恰当的思维背景,我们将解释 CMake 究竟是什么以及它是如何工作的,还会介绍命令行的样子。我们会讨论不同的构建阶段,并学习用于生成构建系统的语言。我们还将讨论 CMake 项目:它们包含哪些文件,如何处理它们的目录结构,以及我们将探索它们的主要配置。
本部分包括以下章节:
-
第一章,《CMake 的初步步骤》
-
第二章,《CMake 语言》
-
第三章,《设置你的第一个 CMake 项目》
第一章:CMake 的初步步骤
将源代码转换成运行中的应用程序有一种神奇的感觉。这种神奇不仅仅在于效果本身,即我们设计并使其实际运行的机制,而且在于将想法付诸实践的过程或行为。
作为程序员,我们按照以下循环工作:设计、编码和测试。我们发明变更,我们用编译器能理解的言语来阐述它们,然后我们检查它们是否如预期那样工作。为了从我们的源代码创建一个适当、高质量的程序,我们需要精心执行重复性、易出错的任务:调用正确的命令,检查语法,链接二进制文件,运行测试,报告问题,等等。
每次都记住每个步骤需要付出很大的努力。相反,我们希望专注于实际的编程,并将其他所有任务委派给自动化工具。理想情况下,这个过程将从我们更改代码后立即开始,只需按一个按钮。它会智能、快速、可扩展,并且在不同的操作系统和环境中以相同的方式工作。它会被多种集成开发环境(IDEs)支持,同时也会被持续集成(CI)管道支持,在我们向共享仓库提交更改后测试我们的软件。
CMake 是许多需求的答案;然而,要正确配置和使用它需要一些工作。这不是因为 CMake 不必要的复杂,而是因为我们这里处理的主题就是这样。不要担心。我们将非常有条理地进行整个学习过程;在您意识到之前,您将已经成为一个建筑大师。
我知道您渴望开始编写自己的 CMake 项目,我赞赏您的热情。由于您的项目主要面向用户(包括您自己),您理解这一观点也很重要。
那么,让我们从这一点开始:成为 CMake 的高级用户。我们会先了解一些基础知识:这个工具是什么,它的工作原理是什么,以及如何安装它。然后,我们将深入探讨命令行和操作模式。最后,我们将总结项目中不同文件的目的,并解释如何在不下创建项目的情况下使用 CMake。
在本章中,我们将涵盖以下主要主题:
-
理解基础知识
-
在不同平台上安装 CMake
-
掌握命令行
-
导航项目文件
-
发现脚本和模块
技术要求
您可以在 GitHub 上找到本章中存在的代码文件:github.com/PacktPublishing/Modern-CMake-for-Cpp/tree/main/examples/chapter01
。
为了构建本书中提供的示例,始终使用建议的命令:
cmake -B <build tree> -S <source tree>
cmake --build <build tree>
请确保将占位符<build tree>
和<source tree>
替换为适当的路径。作为提醒:build tree是目标/输出目录的路径,source tree是源代码所在的位置。
理解基础知识
编译 C++源代码似乎是一个相当直接的过程。让我们以一个小型程序为例,比如经典的hello.cpp
应用:
chapter-01/01-hello/hello.cpp
#include <iostream>
int main() {
std::cout << "Hello World!" << std::endl;
return 0;
}
现在,要获得可执行文件,我们只需要运行一个命令。我们通过将文件名作为参数传递给编译器来调用它:
$ g++ hello.cpp -o a.out
我们的代码是正确的,所以编译器将默默生成一个可执行的二进制文件,我们的机器可以理解。我们可以通过调用它的名字来运行它:
$ ./a.out
Hello World!
$
然而,随着我们的项目增长,你会很快理解将所有内容放在一个文件中 simply not possible。良好的代码实践建议文件应保持小且结构良好。手动编译每个文件可能是一个繁琐且脆弱的过程。一定有更好的方法。
什么是 CMake?
假设我们通过编写一个脚本来自动化构建,该脚本遍历我们的项目树并编译所有内容。为了避免不必要的编译,我们的脚本将检测源代码是否自上次运行(脚本)以来已被修改。现在,我们想要一种方便地管理传递给每个文件编译器的参数的方法——最好是基于可配置标准来完成。此外,我们的脚本应知道如何链接所有编译文件到一个二进制文件中,或者更好,构建整个解决方案,可以被复用并作为更大项目的模块。
我们添加的功能越多,我们得到一个完整的解决方案的机会就越大。软件构建是一个非常多样化的过程,可以涵盖多个不同的方面:
-
编译可执行文件和库
-
管理依赖项
-
测试
-
安装
-
打包
-
生成文档
-
再测试一下
要创建一个真正模块化且强大的 C++构建应用程序需要很长时间,而且确实做到了。Bill Hoffman 在 Kitware 实现了 CMake 的第一个版本,至今已有 20 多年。正如你可能已经猜到的,它非常成功。它现在有很多功能和社区支持。今天,CMake 正在积极开发中,并已成为 C 和 C++程序员的行业标准。
自动化构建代码的问题比 CMake 要早得多,所以自然而然,有很多选择:Make、Autotools、SCons、Ninja、Premake 等等。但为什么 CMake 能占据上风呢?
关于 CMake,有几件事情我觉得(主观地)很重要:
-
它专注于支持现代编译器和工具链。
-
CMake 确实是跨平台的——它支持为 Windows、Linux、macOS 和 Cygwin 构建。
-
它为流行的 IDE 生成项目文件:Microsoft Visual Studio、Xcode 和 Eclipse CDT。此外,它还是其他项目的模型,如 CLion。
-
CMake 在恰到好处的抽象级别上操作——它允许你将文件分组到可重用的目标和项目中。
-
有大量使用 CMake 构建的项目,它们提供了一种轻松将它们包含在你项目中的方法。
-
CMake 将测试、打包和安装视为构建过程的固有部分。
-
旧的、未使用的特性会被弃用,以保持 CMake 的轻量级。
CMake 为整个平台提供了一致、简化的体验。无论你是使用 IDE 构建软件,还是直接从命令行构建,重要的是它还负责后构建阶段。你的持续集成/持续部署(CI/CD)流水线可以轻松使用相同的 CMake 配置和构建项目,即使所有前面的环境都不同。
它是如何工作的?
你可能还以为 CMake 是一个工具,在一端读取源代码,在另一端生成二进制文件——虽然从原则上讲这是正确的,但这并不是全部。
CMake 本身不能构建任何东西——它依赖于系统中的其他工具来执行实际的编译、链接等任务。你可以把它看作是你构建过程的协调者:它知道需要做哪些步骤,最终目标是什么,以及如何找到合适的工人和材料。
这个过程分为三个阶段:
-
配置阶段
-
生成
-
构建
配置阶段
这个阶段是关于读取存储在称为源树的目录中的项目详细信息,并为生成阶段准备输出目录或构建树。
CMake 首先创建一个空的构建树,并收集有关它正在工作的环境的详细信息,例如架构、可用的编译器、链接器和归档器。此外,它检查一个简单的测试程序是否可以正确编译。
接下来,解析并执行CMakeLists.txt
项目配置文件(是的,CMake 项目是用 CMake 的编程语言配置的)。这个文件是 CMake 项目的最小配置(源文件可以在以后添加)。它告诉 CMake 关于项目结构、目标和依赖项(库和其他 CMake 包)。在这个过程中,CMake 将在构建树中存储收集的信息,如系统详细信息、项目配置、日志和临时文件,供下一步使用。特别是,创建了一个CMakeCache.txt
文件来存储更稳定的变量(如编译器和其他工具的路径),以节省下一次配置的时间。
生成阶段
在阅读项目配置后,CMake 将为它正在工作的确切环境生成一个构建系统。构建系统只是为其他构建工具(例如,为 GNU Make 的 Makefile 或为 Ninja 和 Visual Studio 的 IDE 项目文件)定制的配置文件。在这个阶段,CMake 仍然可以通过评估生成器表达式来对构建配置进行一些最后的润色。
注意
生成阶段在配置阶段自动执行后执行。因此,本书和其他资源在提到“配置”或“生成”构建系统时,通常会提到这两个阶段。要显式运行 just the 配置阶段,你可以使用cmake-gui
工具。
构建阶段
为了生成我们项目中所指定的最终工件,我们必须运行适当的构建工具。这可以直接通过 IDE 调用,或者使用 CMake 命令。反过来,这些构建工具将执行步骤,使用编译器、链接器、静态和动态分析工具、测试框架、报告工具以及你能想到的任何其他工具来生成目标。
这个解决方案的优点在于能够根据需要为每个平台生成构建系统,只需一个配置(即,相同的项目文件):
[外链图片转存中…(img-gYqA6UYM-1716544491727)]
](https://gitee.com/OpenDocCN/freelearn-c-cpp-pt2-zh/raw/master/docs/mdn-cmk-cpp/img/Figure_1.1_B17205.jpg)
图 1.1 – CMake 的阶段
你们还记得我们在理解基本内容部分提到的hello.cpp
应用程序吗?CMake 让构建它变得非常简单。我们需要的只是一个位于源代码旁边的CMakeLists.txt
文件和两个简单的命令cmake -B buildtree
和cmake --build buildtree
,如下所示:
chapter01/01-hello/CMakeLists.txt:用 CMake 语言编写的世界
cmake_minimum_required(VERSION 3.20)
project(Hello)
add_executable(Hello hello.cpp)
这是来自 Dockerized Linux 系统的输出(请注意,我们将在在不同的平台上安装 CMake部分讨论 Docker):
root@5f81fe44c9bd:/root/examples/chapter01/01-hello# cmake -B buildtree.
-- The C compiler identification is GNU 9.3.0
-- The CXX compiler identification is GNU 9.3.0
-- Check for working C compiler: /usr/bin/cc
-- Check for working C compiler: /usr/bin/cc -- works
-- Detecting C compiler ABI info
-- Detecting C compiler ABI info - done
-- Detecting C compile features
-- Detecting C compile features - done
-- Check for working CXX compiler: /usr/bin/c++
-- Check for working CXX compiler: /usr/bin/c++ -- works
-- Detecting CXX compiler ABI info
-- Detecting CXX compiler ABI info - done
-- Detecting CXX compile features
-- Detecting CXX compile features - done
-- Configuring done
-- Generating done
-- Build files have been written to: /root/examples/chapter01/01-hello/buildtree
root@5f81fe44c9bd:/root/examples/chapter01/01-hello# cmake --build buildtree/
Scanning dependencies of target Hello
[ 50%] Building CXX object CMakeFiles/Hello.dir/hello.cpp.o
[100%] Linking CXX executable Hello
[100%] Built target Hello
现在只剩下运行它了:
root@68c249f65ce2:~# ./buildtree/Hello
Hello World!
在这里,我们生成了一个存储在buildtree
目录中的构建系统。接着,我们执行了构建阶段,生成了一个我们能够运行的最终二进制文件。
现在你知道最终结果长什么样了,我相信你会有很多问题:这个过程的前提条件是什么?这些命令意味着什么?为什么我们需要两个?我如何编写自己的项目文件?不要担心——这些问题将在接下来的章节中得到解答。
获取帮助
本书将为您提供与当前版本的 CMake(撰写本书时为 3.20 版)最相关的最重要的信息。为了给您提供最好的建议,我明确避免使用任何已弃用和不推荐使用的功能。我强烈建议至少使用版本 3.15,这个版本被认为是“现代 CMake”。如果您需要更多信息,您可以在cmake.org/cmake/help/
找到最新的完整文档。
在不同的平台上安装 CMake
CMake 是一个用 C++编写的跨平台、开源软件。这意味着你当然可以自己编译它;然而,最有可能的情况是,你不需要这么做。这是因为预编译的二进制文件可以在官方网页cmake.org/download/
上供你下载。
基于 Unix 的系统可以直接从命令行提供准备安装的包。
注意
请记住,CMake 不附带编译器。如果你的系统还没有安装它们,你需要在使用 CMake 之前提供它们。确保将它们的执行文件路径添加到PATH
环境变量中,这样 CMake 才能找到它们。
为了避免在阅读本书时解决工具和依赖问题,我建议选择第一种安装方法:Docker。
让我们来看看可以在哪些环境中使用 CMake。
Docker
Docker (www.docker.com/
) 是一个跨平台的工具,提供操作系统级别的虚拟化,允许应用程序以完整的包形式运输,这些包被称为容器。这些都是自给自足的捆绑包,包含了一个软件以及它所有的库、依赖项和运行它所需的工具。Docker 在其轻量级环境中执行其容器,彼此之间是隔离的。
这个概念使得分享整个工具链变得极其方便,这对于给定的过程是必要的,已经配置好并准备好使用。我无法强调当你不需要担心微小的环境差异时事情变得有多简单。
Docker 平台有一个公共容器镜像仓库,registry.hub.docker.com/
,提供了数百万个准备使用的镜像。
为了方便起见,我发布了两个 Docker 仓库:
-
swidzinski/cmake:toolchain
:这个包含了构建 CMake 所需的精心挑选的工具和依赖项。 -
swidzinski/cmake:examples
:这个包含了前面的工具链以及本书中的所有项目和示例。
第一个选项是为那些只想使用一个空白镜像来构建自己项目的读者准备的,第二个选项是为在章节中进行实例实践准备的。
你可以通过遵循 Docker 官方文档中的说明来安装 Docker(请参考docs.docker.com/get-docker)。然后,在你的终端中执行以下命令来下载镜像并启动容器:
$ docker pull swidzinski/cmake:examples
$ docker run -it swidzinski/cmake:examples
root@b55e271a85b2:root@b55e271a85b2:#
请注意,所有示例都可以在匹配此格式的目录中找到:/root/examples/``examples/chapter-<N>/<M>-<title>
。
Windows
在 Windows 上的安装很简单——只需下载 32 位或 64 位的版本。你可以选择一个便携式 ZIP 或 MSI 包用于 Windows 安装程序。
使用 ZIP 包,你将不得不将 CMake 二进制目录添加到PATH
环境变量中,这样你才能在任何目录中使用它,而不会出现错误:
'cmake' is not recognized as an internal or external command, operable program or batch file.
如果你喜欢方便,可以直接使用 MSI 安装程序:
[外链图片转存中…(img-H18778bs-1716544491728)]
图 1.2 – 安装向导可以为你设置 PATH 环境变量
如我前面提到的,这是开源软件,所以自己构建 CMake 是可能的。然而,首先,你需要在你的系统上获取 CMake 的二进制副本。那么,如果你有自己的构建工具,为什么还要使用其他的呢?这种场景是 CMake 贡献者用来生成新版本的。
在 Windows 上,我们还需要一个构建工具来完成由 CMake 启动的构建过程。这里的一个流行选择是 Visual Studio,其社区版可从微软网站免费获得:visualstudio.microsoft.com/downloads/
。
Linux
在 Linux 上获取 CMake 与获取任何其他流行软件包相同。只需使用命令行的包管理器即可。软件包通常会保持更新,包含相对较新的版本。然而,如果你想要最新版本,你可以从网站上下载安装脚本:
适用于 Linux x86_64 的脚本
$ wget -O - https://github.com/Kitware/CMake/releases/download/v3.20.0/cmake-3.20.0-linux-x86_64.sh | bash
适用于 Linux aarch64 的脚本
$ wget -O - https://github.com/Kitware/CMake/releases/download/v3.20.0/cmake-3.20.0-Linux-aarch64.sh | bash
适用于 Debian/Ubuntu 的软件包
$ sudo apt-get install cmake
适用于 Red Hat 的软件包
$ yum install cmake
macOS
这个平台也得到了 CMake 开发者的强烈支持。最流行的安装方式是通过 MacPorts:
$ sudo port install cmake
另外,你可以使用 Homebrew:
$ brew install cmake
从源代码构建
如果其他方法都失败了——或者如果你在一个特殊的平台上——从官方网站下载源代码并自己编译它:
$ wget https://github.com/Kitware/CMake/releases/download/v3.20.0/cmake-3.20.0.tar.gz
$ tar xzf cmake-3.20.0.tar.gz
$ cd cmake-3.20.0
$ ./bootstrap
$ make
$ make install
从源代码构建相对较慢,需要更多步骤。然而,通过这种方式,你可以保证使用最新版本的 CMake。这在与 Linux 上可用的软件包相比较时尤为明显:系统版本越旧,更新越少。
既然我们已经轻松安装了 CMake,那就让我们学习如何使用它吧!
精通命令行
本书的大部分内容将教你如何为你的用户准备 CMake 项目。为了满足他们的需求,我们需要彻底了解用户在不同场景中如何与 CMake 互动。这将允许你测试项目文件,并确保它们正确运行。
CMake 是一组工具的家族,包括五个可执行文件:
-
cmake
:这是主要的可执行文件,用于配置、生成和构建项目。 -
ctest
:这个程序用于运行和报告测试结果。 -
cpack
:这个程序用于生成安装程序和源代码包。 -
cmake-gui
:这是围绕cmake
的图形界面。 -
ccmake
:这是基于控制台的图形界面围绕cmake
。
CMake
这个二进制文件提供了一些操作模式(也称为动作):
-
生成项目构建系统
-
构建项目
-
安装项目
-
运行脚本
-
运行命令行工具
-
获取帮助
生成项目构建系统
安装项目是我们构建项目所需的第一步。以下是执行 CMake 构建操作的几种方式:
生成模式的语法
cmake [<options>] -S <path-to-source> -B <path-to-build>
cmake [<options>] <path-to-source>
cmake [<options>] <path-to-existing-build>
我们将在接下来的部分讨论这些选项。现在,让我们专注于选择正确的命令形式。CMake 的一个重要特性是支持离线构建或在单独的目录中生成工件。与 GNU Make 等工具相比,这确保了源代码目录中不会包含任何与构建相关的文件,并避免了使用-S
选项后跟生成构建系统的目录路径的污染:
cmake -S ./project -B ./build
前面命令将在./build
目录中(如果缺失则创建)生成一个构建系统,该构建系统来源于./project
目录中的源代码。
我们可以省略一个参数,cmake
会“猜测”我们打算使用当前目录。但是,要小心。省略两个参数将会得到一个源代码构建,这将非常混乱。
不推荐
<directory>: it will use the cached path to the sources and rebuild from there. Since we often invoke the same commands from the Terminal command history, we might get into trouble here: before using this form, always check whether your shell is currently working in the right directory.
示例
在当前目录中构建,但源代码从上一级目录获取(注意-S
是可选的):
cmake -S ..
在./build
目录中构建,并使用当前目录中的源代码:
cmake -B build
生成器的选项
如前所述,在生成阶段,你可以指定一些选项。选择和配置生成器决定了我们将使用我们系统中的哪个构建工具来构建,构建文件将呈现什么样子,以及构建树的结构将如何。
那么,你应该关心吗?幸运的是,答案通常是“不”。CMake 在许多平台上支持多种本地构建系统;然而,除非你同时安装了几个生成器,否则 CMake 会正确地为你选择一个。这可以通过设置CMAKE_GENERATOR
环境变量或直接在命令行上指定生成器来覆盖,如下所示:
cmake -G <generator-name> <path-to-source>
一些生成器(如 Visual Studio)支持对工具集(编译器)和平面(编译器或 SDK)进行更详细的指定。另外,这些生成器还有相应的环境变量,这些环境变量会覆盖默认值:CMAKE_GENERATOR_TOOLSET
和CMAKE_GENERATOR_PLATFORM
。我们可以像下面这样直接指定它们:
cmake -G <generator-name>
-T <toolset-spec> -A <platform-name>
<path-to-source>
Windows 用户通常希望为他们的首选 IDE 生成一个构建系统。在 Linux 和 macOS 上,使用 Unix Makefiles 或 Ninja 生成器非常普遍。
为了检查你的系统上可用的生成器,请使用以下命令:
cmake --help
在help
打印输出结束时,你应该看到一个完整的列表,如下所示:
Windows 10 上有许多生成器可供选择:
The following generators are available on this platform:
Visual Studio 16 2019
Visual Studio 15 2017 [arch]
Visual Studio 14 2015 [arch]
Visual Studio 12 2013 [arch]
Visual Studio 11 2012 [arch]
Visual Studio 10 2010 [arch]
Visual Studio 9 2008 [arch]
Borland Makefiles
NMake Makefiles
NMake Makefiles JOM
MSYS Makefiles
MinGW Makefiles
Green Hills MULTI
Unix Makefiles
Ninja
Ninja Multi-Config
Watcom Wmake
CodeBlocks - MinGW Makefiles
CodeBlocks - NMake Makefiles
CodeBlocks - NMake Makefiles JOM
CodeBlocks - Ninja
CodeBlocks - Unix Makefiles
CodeLite - MinGW Makefiles
CodeLite - NMake Makefiles
CodeLite - Ninja
CodeLite - Unix Makefiles
Eclipse CDT4 - NMake Makefiles
Eclipse CDT4 - MinGW Makefiles
Eclipse CDT4 - Ninja
Eclipse CDT4 - Unix Makefiles
Kate - MinGW Makefiles
Kate - NMake Makefiles
Kate - Ninja
Kate - Unix Makefiles
Sublime Text 2 - MinGW Makefiles
Sublime Text 2 - NMake Makefiles
Sublime Text 2 - Ninja
Sublime Text 2 - Unix Makefiles
缓存选项
CMake 在配置阶段查询系统获取各种信息。这些信息存储在构建树目录中的CMakeCache.txt
文件中。有一些选项可以让你更方便地管理该文件。
我们首先可以使用的功能是预填充缓存信息的能力:
cmake -C <initial-cache-script> <path-to-source>
我们可以提供 CMake 脚本的路径,该脚本(仅)包含一个set()
命令列表,用于指定将用于初始化空构建树的变量。
现有缓存变量的初始化和修改可以通过另一种方式完成(例如,仅设置几个变量而创建一个文件似乎有些过于繁琐)。你只需在命令行中简单地设置它们,如下所示:
cmake -D <var>[:<type>]=<value> <path-to-source>
:<type>
部分是可选的(GUIs 使用它);你可以使用BOOL
、FILEPATH
、PATH
、STRING
或INTERNAL
。如果你省略了类型,它将设置为已有变量的类型;否则,它将设置为UNINITIALIZED
。
一个特别重要的变量包含构建类型:例如,调试和发布。许多 CMake 项目会在多个场合读取它,以决定诸如消息的冗余度、调试信息的的存在以及创建的艺术品的优化级别等事情。
对于单配置生成器(如 Make 和 Ninja),你需要在配置阶段指定CMAKE_BUILD_TYPE
变量,并为每种类型的配置生成一个单独的构建树:Debug
、Release
、MinSizeRel
或RelWithDebInfo
。
以下是一个示例:
cmake -S . -B build -D CMAKE_BUILD_TYPE=Release
请注意,多配置生成器在构建阶段进行配置。
我们可以使用-L
选项:
cmake -L[A][H] <path-to-source>
这样的列表将包含未标记为ADVANCED
的缓存变量。我们可以通过添加A
修饰符来改变这一点。要打印带有变量的帮助信息 - 添加H
修饰符。
令人惊讶的是,使用-D
选项手动添加的自定义变量如果不指定支持的一种类型,将不可见。
删除一个或多个变量的操作可以通过以下选项完成:
cmake -U <globbing_expr> <path-to-source>
在此,通配符表达式支持*
通配符和任何?
字符符号。使用时要小心,以免破坏东西。
-U
和-D
选项都可以重复多次。
调试和跟踪选项
CMake 可以运行多种选项,让你窥视其内部。要获取有关变量、命令、宏和其他设置的一般信息,请运行以下操作:
cmake --system-information [file]
可选的文件参数允许你将输出存储在文件中。在构建树目录中运行它将打印有关缓存变量和日志文件中的构建信息的额外信息。
在我们的项目中,我们将使用message()
命令来报告构建过程的详细信息。CMake 根据当前日志级别(默认情况下是STATUS
)过滤这些日志输出。以下行指定了我们感兴趣的日志级别:
cmake --log-level=<level>
在这里,level
可以是以下任意一个:ERROR
、WARNING
、NOTICE
、STATUS
、VERBOSE
、DEBUG
或 TRACE
。你可以在 CMAKE_MESSAGE_LOG_LEVEL
缓存变量中永久指定这个设置。
另一个有趣的选项允许你使用message()
调用。为了调试非常复杂的工程,CMAKE_MESSAGE_CONTEXT
变量可以像栈一样使用。每当你代码进入一个特定的上下文时,你可以向栈中添加一个描述性的名称,并在离开时移除它。通过这样做,我们的消息将被当前CMAKE_MESSAGE_CONTEXT
变量装饰如下:
[some.context.example] Debug message.
启用这种日志输出的选项如下:
cmake --log-context <path-to-source>
我们将在第二章 CMake 语言 中更详细地讨论日志记录。
如果其他方法都失败了——我们必须使用大杀器——总是有跟踪模式。这将打印出每个命令以及它来自的文件名和确切的行号及其参数。你可以按照如下方式启用它:
cmake --trace
预设选项
正如你可能已经猜到的,用户可以指定很多选项来从你的项目中生成一个构建树。当处理构建树路径、生成器、缓存和环境变量时,很容易感到困惑或遗漏某些内容。开发者可以简化用户与他们项目交互的方式,并提供一个指定一些默认值的CMakePresets.json
文件。要了解更多,请参考导航项目文件部分。
要列出所有可用的预设,执行以下操作:
cmake --list-presets
你可以按照如下方式使用其中一个预设:
cmake --preset=<preset>
这些值覆盖了系统默认值和环境。然而,同时,它们也可以被命令行上明确传递的任何参数覆盖:
[外链图片转存中…(img-DV4rlnY3-1716544491728)]
图 1.3 – 预设如何覆盖 CMakeCache.txt 和系统环境变量
构建项目
在生成我们的构建树之后,我们准备进入下一阶段:运行构建工具。CMake 不仅知道如何为许多不同的构建器生成输入文件,而且还知道如何为你提供特定于项目的参数来运行它们。
不推荐
许多在线资源建议在生成阶段之后直接运行 GNU Make:make
。这是 Linux 和 macOS 的默认生成器,通常可以工作。然而,我们更喜欢本节描述的方法,因为它与生成器无关,并且支持所有平台。因此,我们不需要担心我们应用程序每个用户的准确环境。
构建模式的语法
cmake --build <dir> [<options>] [-- <build-tool-options>]
在这些大多数情况下,提供最少量的东西以获得成功的构建就足够了:
cmake --build <dir>
CMake 需要知道我们生成的构建树的位置。这是我们在生成阶段传递给 -B
参数的相同路径。
通过提供一些选项,CMake 允许您指定对每个构建器都有效的关键构建参数。如果您需要向您选择的本地构建器提供特殊参数,请在--
标记之后,在命令的末尾传递它们:
cmake --build <dir> -- <build-tool-options>
并行构建选项
默认情况下,许多构建工具会使用多个并发进程来利用现代处理器并并行编译您的源代码。构建器知道项目依赖的结构,因此它们可以同时处理满足其依赖的步骤,以节省用户的时间。
如果您在强大的机器上构建(或者为了调试而强制进行单线程构建),您可能想要覆盖那个设置。只需使用以下任一选项指定作业数量:
cmake --build <dir> --parallel [<number-of-jobs>]
cmake --build <dir> -j [<number-of-jobs>]
另一种方法是使用CMAKE_BUILD_PARALLEL_LEVEL
环境变量来设置。像往常一样,我们总是可以使用前面的选项来覆盖变量。
目标选项
我们将在书的第二部分讨论目标。现在,我们只需说每个项目都由一个或多个称为目标的部分组成。通常,我们想要构建它们所有;然而,在某些情况下,我们可能想要跳过一些或者显式构建被正常构建中故意排除的目标。我们可以这样做:
cmake --build <dir> --target <target1> -t <target2> ...
正如您将观察到的,我们可以通过重复-t
参数来指定多个目标。
通常不构建的一个目标是clean
。这将从构建目录中删除所有工件。您可以这样调用它:
cmake --build <dir> -t clean
此外,CMake 还提供了一个方便的别名,如果你想要先清理然后再进行正常构建的话:
cmake --build <dir> --clean-first
多配置生成器的选项
所以,我们已经对生成器有了一些了解:它们有不同的形状和大小。其中一些提供的功能比其他的多,而这些功能之一就是能够在单个构建树中构建Debug
和Release
构建类型。
支持此功能的生成器包括 Ninja 多配置、Xcode 和 Visual Studio。其余的生成器都是单配置生成器,为此目的需要一个独立的构建树。
选择Debug
、Release
、MinSizeRel
或RelWithDebInfo
,并按照以下方式指定:
cmake --build <dir> --config <cfg>
否则,CMake 将使用Debug
作为默认值。
调试选项
当事情出错时,我们首先应该做的是检查输出信息。然而,经验丰富的开发者知道,一直打印所有细节是令人困惑的,所以它们通常默认隐藏它们。当我们需要揭开盖子时,我们可以通过告诉 CMake 要详细输出日志来请求更详细的日志:
cmake --build <dir> --verbose
cmake --build <dir> -v
通过设置CMAKE_VERBOSE_MAKEFILE
缓存变量也可以达到同样的效果。
安装项目
当构建工件时,用户可以将它们安装到系统中。通常,这意味着将文件复制到正确的目录中,安装库,或者从 CMake 脚本中运行一些自定义安装逻辑。
安装模式的语法
cmake --install <dir> [<options>]
与其他操作模式一样,CMake 需要一个生成构建树的路径:
cmake --install <dir>
多配置生成器选项
与构建阶段类似,我们可以指定我们想要用于安装的构建类型(有关详细信息,请参阅构建项目部分)。可用的类型包括Debug
、Release
、MinSizeRel
和RelWithDebInfo
。签名如下:
cmake --install <dir> --config <cfg>
组件选项
作为开发者,您可能会选择将项目拆分为可以独立安装的组件。我们将在第十一章进一步讨论组件的概念,安装与打包。现在,我们假设它们代表解决方案的不同部分。这可能类似于application
、docs
和extra-tools
。
要安装单个组件,请使用以下选项:
cmake --install <dir> --component <comp>
权限选项
如果在类 Unix 平台上进行安装,您可以使用以下选项指定安装目录的默认权限,格式为u=rwx,g=rx,o=rx
:
cmake --install <dir>
--default-directory-permissions <permissions>
安装目录选项
我们可以为项目配置中指定的安装路径添加一个自定义前缀(例如,当我们对某些目录的写入权限有限时)。原本的/usr/local
路径通过添加/home/user
前缀后变为/home/user/usr/local
。此选项的签名如下:
cmake --install <dir> --prefix <prefix>
请注意,这在 Windows 上不起作用,因为该平台上的路径通常以驱动器字母开头。
调试选项
同样地,我们也可以选择在构建阶段查看安装阶段的详细输出。为此,可以使用以下任意一个选项:
cmake --build <dir> --verbose
cmake --build <dir> -v
如果设置了VERBOSE
环境变量,也可以达到同样的效果。
运行脚本
CMake 项目使用 CMake 的自定义语言进行配置。它是跨平台的,相当强大,并且已经存在。那么为什么不将其用于其他任务呢?确实,你可以编写独立的脚本(我们将在本章末尾讨论到这一点)。
CMake 可以像这样运行这些脚本:
脚本模式的语法
cmake [{-D <var>=<value>}...] -P <cmake-script-file>
[-- <unparsed-options>...]
运行此类脚本不会运行任何配置或生成阶段。此外,它不会影响缓存。你可以通过以下两种方式将值传递给此脚本:
-
通过使用
-D
选项定义的变量。 -
通过在
--
标记后传递的参数。CMake 将为传递给脚本的的所有参数(包括--
标记)创建CMAKE_ARGV<n>
变量。
运行命令行工具
在少数情况下,我们可能需要以平台无关的方式运行单个命令——可能是复制文件或计算校验和。并非所有的平台都是平等的,因此并非所有的命令在每一个系统中都是可用的,或者它们有不同的名称。
CMake 提供了一种模式,可以在不同平台上一致地执行最常用的命令:
命令行工具模式的语法
cmake -E <command> [<options>]
由于这种特定模式的使用相当有限,我们不会深入讨论。然而,如果你对细节感兴趣,我建议调用cmake -E
来列出所有可用的命令。为了简单地了解提供的功能,CMake 3.20 支持以下命令:
capabilities
, cat
, chdir
, compare_files
, copy
, copy_directory
, copy_if_different
, echo
, echo_append
, env
, environment
, make_directory
, md5sum
, sha1sum
, sha224sum
, sha256sum
, sha384sum
, sha512sum
, remove
, remove_directory
, rename
, rm
, server
, sleep
, tar
, time
, touch
, touch_nocreate
, create_symlink
, create_hardlink
, true
, 和 false
。
如果你想要使用的命令缺失,或者你需要更复杂的行为,考虑将其封装在脚本中,并以-P
模式运行它。
获取帮助
毫不奇怪,CMake 通过其命令行提供了广泛的帮助。
帮助模式的语法
cmake ––help[-<topic>]
CTest
自动化测试对于生成和维护高质量代码非常重要。这就是为什么我们专门用了一整章来讨论这个主题(请参考第八章,测试框架),其中我们深入探讨了 CTest 的使用。它是可用的命令行工具之一,所以我们现在简要介绍一下。
CTest 是在更高层次的抽象中封装 CMake,其中构建阶段成为开发我们软件过程中的一个垫脚石。CMake 还可以为我们执行其他任务,包括更新、运行各种测试、将项目状态报告给外部仪表板以及运行编写在 CMake 语言中的脚本。
更重要的是,CTest 标准化了使用 CMake 构建的解决方案的测试运行和报告。这意味着作为用户,你不需要知道项目使用的是哪个测试框架或如何运行它。CTest 提供了一个方便的界面来列出、筛选、洗牌、重试和计时测试运行。此外,如果需要构建,它还可以调用 CMake。
运行构建项目测试的最简单方法是在生成的构建树中调用ctest
:
$ ctest
Test project C:/Users/rapha/Desktop/CMake/build
Guessing configuration Debug
Start 1: SystemInformationNew
1/1 Test #1: SystemInformationNew ......... Passed 3.19 sec
100% tests passed, 0 tests failed out of 1
Total Test time (real) = 3.24 sec
CPack
在我们构建并测试了我们的神奇软件之后,我们准备与世界分享它。在少数情况下,高级用户完全可以使用源代码,这就是他们想要的。然而,世界上绝大多数人使用预编译的二进制文件,因为这样方便且能节省时间。
CMake 不会让你在这里陷入困境;它自带了电池。CPack 正是为了创建不同平台的包而设计的:压缩归档、可执行安装程序、向导、NuGet 包、macOS 捆绑包、DMG 包、RPMs 等。
CPack 的工作方式与 CMake 非常相似:它使用 CMake 语言进行配置,并有许多可供选择的包生成器(只是不要将它们与 CMake 构建系统生成器混淆)。我们将在第十一章中详细介绍,该章节讨论安装和打包,因为这是一个用于 CMake 项目最后阶段的相当庞大的工具。
CMake GUI
对于 Windows,CMake 附带一个 GUI 版本,用于配置先前准备好的项目的构建过程。对于 Unix-like 平台,有一个用 QT 库构建的版本。Ubuntu 在cmake-qt-gui
包中提供。
要访问 CMake GUI,运行cmake-gui
可执行文件:
[外链图片转存中…(img-PFdAFGRN-1716544491729)]
图 1.4 – CMake GUI –使用 Visual Studio 2019 的生成器配置构建系统的配置阶段
图形用户界面(GUI)应用程序是方便的工具,因为那里的选项相当有限。对于不熟悉命令行并且更喜欢基于窗口的界面的用户来说,这可能很有用。
不推荐
我肯定会推荐 GUI 给那些追求方便的最终用户;然而,作为一名程序员,我避免引入任何需要每次构建程序时点击表单的手动、阻塞步骤。这对于 CI 管道中的构建自动化尤为重要。这些工具需要无头应用程序,以便在没有用户交互的情况下完全执行构建。
CCMake
ccmake
可执行文件是 CMake 的 Unix-like 平台的curses
界面(Windows 上不可用)。它不是 CMake 包的一部分,因此用户必须单独安装。
Debian/Ubuntu 系统的命令如下:
$ sudo apt-get install cmake-curses-gui
请注意,可以通过这个 GUI 交互式地指定项目配置设置。程序运行时,终端底部会提供简短的说明:
CCMake 命令的语法
ccmake [<options>]
ccmake {<path-to-source> | <path-to-existing-build>}
CCMake 使用与cmake
相同的选项集:
[外链图片转存中…(img-Gjg9HYGG-1716544491729)]
图 1.5 – ccmake 中的配置阶段
与图形用户界面(GUI)一样,这种模式相当有限,旨在供经验较少的用户使用。如果您在 Unix 机器上工作,我建议您快速查看并更快地继续。
这结束了关于 CMake 命令行的基本介绍。是时候探索一下典型 CMake 项目的结构了。
浏览项目文件
CMake 使用很多文件来管理其项目。在修改内容之前,让我们试图了解每个文件的作用。重要的是要意识到,尽管一个文件包含 CMake 语言命令,但这并不意味着它一定是为了让开发者编辑的。有些文件是为了被后续工具使用而生成的,对这些文件所做的任何更改都将在某个阶段被覆盖。其他文件是为了让高级用户根据个人需求调整项目。最后,还有一些在特定上下文中提供宝贵信息的临时文件。本节还将指定哪些应该放在您版本控制系统的忽略文件中。
源代码树
这是您的项目将所在的目录(也称为项目根)。它包含所有的 C++源代码和 CMake 项目文件。
此目录的关键收获如下:
-
您必须在它的顶部目录中提供一个
CMakeLists.txt
配置文件。 -
它应该使用如
git
这样的 VCS 进行管理。 -
此目录的路径由用户通过
cmake
命令的-S
参数给出。 -
避免在您的 CMake 代码中硬编码任何绝对路径到源代码树——您的软件的用户可以将项目存储在不同的路径下。
构建树
CMake 使用此目录来存储构建过程中生成的所有内容:项目的工件、短暂配置、缓存、构建日志以及您的本地构建工具将创建的任何内容。这个目录的别名还包括构建根和二进制树。
此目录的关键收获如下:
-
您的二进制文件将在此处创建,例如可执行文件和库文件,以及用于最终链接的对象文件和归档文件。
-
不要将此目录添加到您的 VCS 中——它是特定于您的系统的。如果您决定将其放在源代码树内,请确保将其添加到 VCS 忽略文件中。
-
CMake 建议进行源外构建,或生成工件的目录与所有源文件分离的构建。这样,我们可以避免用临时、系统特定的文件污染项目的源代码树(或者进行源内构建)。
-
如果提供了源代码的路径,例如
cmake -S ../project ./
,则使用-B
或作为cmake
命令的最后一个参数来指定此目录。 -
建议您的项目包括一个安装阶段,允许您将最终工件放在系统中的正确位置,以便可以删除用于构建的所有临时文件。
列表文件
包含 CMake 语言的文件称为列表文件,可以通过调用include()
和find_package()
,或者间接地通过add_subdirectory()
来相互包含:
-
CMake 不强制这些文件的一致命名,但通常它们具有
.cmake
扩展名。 -
一个非常重要的命名异常是一个名为
CMakeLists.txt
的文件,这是在配置阶段第一个被执行的文件。它需要位于源树的顶部。 -
当 CMake 遍历源树并包含不同的列表文件时,以下变量将被设置:
CMAKE_CURRENT_LIST_DIR
,CMAKE_CURRENT_LIST_FILE
,CMAKE_PARENT_LIST_FILE
和CMAKE_CURRENT_LIST_LINE
。
CMakeLists.txt
CMakeLists.txt
列表文件用于配置 CMake 项目。你必须在源树根目录中提供至少一个。这样的顶级文件在配置阶段是第一个被执行的,它至少应该包含两个命令:
-
cmake_minimum_required(VERSION <x.xx>)
:设置 CMake 的预期版本(隐含地告诉 CMake 如何应用与遗留行为相关的策略)。 -
project(<name> <OPTIONS>)
:用于命名项目(提供的名称将存储在PROJECT_NAME
变量中)并指定配置选项(我们将在第二章 CMake 语言中进一步讨论)。
随着你的软件的增长,你可能会希望将其划分为更小的单元,可以单独配置和推理。CMake 通过子目录及其自己的CMakeLists.txt
文件支持这一点。你的项目结构可能类似于以下示例:
CMakeLists.txt
api/CMakeLists.txt
api/api.h
api/api.cpp
然后可以使用一个非常简单的CMakeLists.txt
文件将其全部整合在一起:
CMakeLists.txt
cmake_minimum_required(VERSION 3.20)
project(app)
message("Top level CMakeLists.txt")
add_subdirectory(api)
项目的主要方面在顶级文件中涵盖:管理依赖项,声明要求,以及检测环境。在此文件中,我们还有一个add_subdirectory(api)
命令,以从api
目录中包含另一个CMakeListst.txt
文件,执行与应用程序的 API 部分相关的特定步骤。
CMakeCache.txt
缓存变量将在第一次运行配置阶段时从listfiles
生成,并存储在CMakeCache.txt
中。此文件位于构建树的根目录中,格式相当简单:
# This is the CMakeCache file.
# For build in directory:
c:/Users/rapha/Desktop/CMake/empty_project/build
# It was generated by CMake: C:/Program
Files/CMake/bin/cmake.exe
# You can edit this file to change values found and used by
cmake.
# If you do want to change a value, simply edit, save, and
exit the editor.
# The syntax for the file is as follows:
# KEY:TYPE=VALUE
# KEY is the name of a variable in the cache.
# TYPE is a hint to GUIs for the type of VALUE, DO NOT EDIT
TYPE!.
# VALUE is the current value for the KEY.
########################
# EXTERNAL cache entries
########################
//Flags used by the CXX compiler during DEBUG builds.
CMAKE_CXX_FLAGS_DEBUG:STRING=/MDd /Zi /Ob0 /Od /RTC1
// ... more variables here ...
########################
# INTERNAL cache entries
########################
//Minor version of cmake used to create the current loaded
cache
CMAKE_CACHE_MINOR_VERSION:INTERNAL=19
// ... more variables here ...
正如你在标题中的注释所观察到的,这个格式相当简单易懂。EXTERNAL
部分中的缓存条目是为了让用户修改,而INTERNAL
部分由 CMake 管理。请注意,不建议您手动更改它们。
以下是一些要点:
-
你可以通过调用
cmake
手动管理此文件(请参阅缓存选项在精通命令行部分),或者通过ccmake
/cmake-gui
。 -
通过删除此文件,你可以将项目重置为其默认配置;它将从列表文件中重新生成。
-
缓存变量可以从列表文件中读写。有时,变量引用评估有点复杂;然而,我们将在第二章 CMake 语言中更详细地介绍。
包的配置文件
CMake 生态系统的大部分包括项目可以依赖的外部包。它们允许开发人员以无缝、跨平台的方式使用库和工具。支持 CMake 的包应提供配置文件,以便 CMake 了解如何使用它们。
我们将在第十一章中学习如何编写这些文件,安装和打包。同时,这里有一些有趣的细节要注意:
-
配置文件(原名)包含有关如何使用库二进制文件、头文件和辅助工具的信息。有时,它们暴露出 CMake 宏,可以在您的项目中使用。
-
使用
find_package()
命令来包含包。 -
描述包的 CMake 文件名为
<PackageName>-config.cmake
和<PackageName>Config.cmake
。 -
使用包时,您可以指定需要的包的哪个版本。CMake 会在关联的
<Config>Version.cmake
文件中检查这个版本。 -
配置文件由支持 CMake 生态系统的包供应商提供。如果一个供应商没有提供这样的配置文件,可以用 Find-module(原名)来替换。
-
CMake 提供了一个包注册表,用于在系统范围内和每个用户处存储包。
cmake_install.cmake、CTestTestfile.cmake 和 CPackConfig.cmake 文件
这些文件由 cmake
可执行文件在生成阶段在构建树中生成。因此,不建议手动编辑它们。CMake 使用它们作为 cmake
安装操作、CTest 和 CPack 的配置。如果您实现源代码构建(不建议),添加到 VCS 忽略文件中可能是个不错的主意。
CMakePresets.json 和 CMakeUserPresets.json
当我们需要具体设置诸如缓存变量、选择生成器、构建树路径等事物时,项目的配置可能会变得相对繁琐——尤其是当我们有多种构建项目的方式时。这时预设就派上用场了。
用户可以通过 GUI 选择预设,或者使用命令行 --list-presets
并使用 --preset=<preset>
选项为构建系统选择一个预设。您可以在本章的 精通命令行 部分找到更多详细信息。
预设以相同的 JSON 格式存储在两个文件中:
-
CMakePresets.json
:这是为了让项目作者提供官方预设。 -
CMakeUserPresets.json
:这是专为希望按自己的喜好自定义项目配置的用户准备的(您可以在 VCS 忽略文件中添加它)。
预设是项目文件,所以它们的解释属于这里。然而,在项目中它们并非必需,只有在完成初始设置后它们才变得有用。所以,如果您愿意,可以跳到下一节,需要时再回来:
chapter-01/02-presets/CMakePresets.json
{
"version": 1,
"cmakeMinimumRequired": {
"major": 3, "minor": 19, "patch": 3
},
"configurePresets": [ ],
"vendor": {
"vendor-one.com/ExampleIDE/1.0": {
"buildQuickly": false
}
}
}
CMakePresets.json
指定以下根字段:
-
Version
:这是必须的,总是1
。 -
cmakeMinimumRequired
:这是可选的。它以散列形式指定 CMake 版本,包含三个字段:major
、minor
和patch
。 -
vendor
:IDE 可以使用这个可选字段来存储其元数据。它是一个以供应商域和斜杠分隔的路径为键的映射。CMake 实际上忽略这个字段。 -
configurePresets
:这是一个可选的可用预设数组。
让我们向我们的configurePresets
数组中添加两个预设:
chapter-01/02-presets/CMakePresets.json:my-preset
{
"name": "my-preset",
"displayName": "Custom Preset",
"description": "Custom build - Ninja",
"generator": "Ninja",
"binaryDir": "${sourceDir}/build/ninja",
"cacheVariables": {
"FIRST_CACHE_VARIABLE": {
"type": "BOOL", "value": "OFF"
},
"SECOND_CACHE_VARIABLE": "Ninjas rock"
},
"environment": {
"MY_ENVIRONMENT_VARIABLE": "Test",
"PATH": "$env{HOME}/ninja/bin:$penv{PATH}"
},
"vendor": {
"vendor-one.com/ExampleIDE/1.0": {
"buildQuickly": true
}
}
},
此文件支持树状结构,其中子预设从多个父预设继承属性。这意味着我们可以创建先前预设的副本,并只覆盖我们需要的字段。以下是一个子预设可能的样子:
chapter-01/02-presets/CMakePresets.json:my-preset-multi
{
"name": "my-preset-multi",
"inherits": "my-preset",
"displayName": "Custom Ninja Multi-Config",
"description": "Custom build - Ninja Multi",
"generator": "Ninja Multi-Config"
}
注意
CMake 文档只将一些字段明确标记为必需的。然而,还有一些其他字段被标记为可选的,这些字段必须在预设中提供,或者从其父预设继承。
预设被定义为具有以下字段的映射:
-
name
:这是一个必需的字符串,用于标识预设。它必须对机器友好,并且在两个文件中唯一。 -
Hidden
:这是一个可选的布尔值,用于隐藏预设,使其不在 GUI 和命令行列表中显示。这样的预设可以是另一个预设的父预设,并且不需要提供除其名称以外的任何内容。 -
displayName
:这是一个可选的字符串,有一个人类可读的名字。 -
description
:这是一个可选的字符串,用于描述预设。 -
Inherits
:这是一个可选的字符串或预设名称数组,用于从其中继承。在冲突的情况下,早期预设的值将被优先考虑,每个预设都可以覆盖任何继承的字段。此外,CMakeUserPresets.json
可以继承项目预设,但反之则不行。 -
Vendor
:这是一个可选的供应商特定值的映射。它遵循与根级vendor
字段相同的约定。 -
Generator
:这是一个必需或继承的字符串,用于指定预设要使用的生成器。 -
architecture
和toolset
:这些是用于配置支持这些选项的生成器的可选字段(在生成项目构建系统部分提到)。每个字段可以是一个简单的字符串,或者一个带有value
和strategy
字段的哈希表,其中strategy
是set
或external
。当strategy
字段配置为set
时,将设置字段值,如果生成器不支持此字段,则会产生错误。配置为external
意味着字段值是为外部 IDE 设置的,CMake 应该忽略它。 -
binaryDir
:这是一个必需或继承的字符串,提供了构建树目录的路径(相对于源树是绝对路径或相对路径)。它支持宏扩展。 -
cacheVariables
:这是一个可选的缓存变量映射,其中键表示变量名。接受的值包括null
、"TRUE"
、"FALSE"
、字符串值,或具有可选type
字段和必需value
字段的哈希。value
可以是"TRUE"
或"FALSE"
的字符串值。除非明确指定为null
,否则缓存变量会通过并集操作继承——在这种情况下,它将保持未设置。字符串值支持宏扩展。 -
Environment
: 这是一个可选的环境变量映射,其中键表示变量名。接受的值包括null
或字符串值。除非明确指定为null
,否则环境变量会通过并集操作继承——在这种情况下,它将保持未设置。字符串值支持宏扩展,变量可以以任何顺序相互引用,只要没有循环引用即可。
以下宏将被识别和评估:
-
${sourceDir}
:这是源树的位置。 -
${sourceParentDir}
:这是源树父目录的位置。 -
${sourceDirName}
: 这是${sourceDir}
的最后一个文件名组件。例如,对于/home/rafal/project
,它就是project
。 -
${presetName}
: 这是预设的名称字段的值。 -
${generator}
:这是预设的生成器字段的值。 -
${dollar}
: 这是一个字面意义上的美元符号($)。 -
$env{<variable-name>}
:这是一个环境变量宏。如果预设中定义了该变量,它将返回预设中的变量值;否则,它将从父环境返回值。请注意,预设中的变量名是区分大小写的(与 Windows 环境不同)。 -
$penv{<variable-name>}
:这个选项与$env
类似,但总是从父环境返回值。这允许您解决预设环境变量中不允许的循环引用问题。 -
$vendor{<macro-name>}
:这使得供应商能够插入自己的宏。
在 Git 中忽略文件
有很多版本控制系统;其中最流行的一种是 Git。每当我们开始一个新项目时,确保我们只将需要存在于仓库中的文件提交到仓库中是很重要的。如果我们只是将生成的、用户或临时文件添加到.gitignore
文件中,项目卫生更容易维护。这样,Git 就知道在构建新提交时自动跳过它们。这是我在我项目中使用的文件:
chapter-01/01-hello/.gitignore
# If you put build tree in the source tree add it like so:
build_debug/
build_release/
# Generated and user files
**/CMakeCache.txt
**/CMakeUserPresets.json
**/CTestTestfile.cmake
**/CPackConfig.cmake
**/cmake_install.cmake
**/install_manifest.txt
**/compile_commands.json
在您的项目中使用前面的文件将为您和其他贡献者和用户带来更多的灵活性。
项目文件的未知领域现在已经绘制成图。有了这张地图,你很快就能编写自己的列表文件,配置缓存,准备预设,等等。在你扬帆远航项目编写之前,让我们来看看您可以使用 CMake 创建的其他类型的自包含单元。
发现脚本和模块
与 CMake 一起工作的主要焦点是构建的项目以及生产供其他系统(如 CI/CD 管道和测试平台)消费的工件,或者部署到机器或工件仓库。然而,CMake 还有两个其他概念可以用其语言创建:脚本和模块。让我们仔细看看。
脚本
为了配置项目构建,CMake 提供了一种与平台无关的编程语言。这带有许多有用命令。你可以使用这个工具来编写随项目提供或完全独立的脚本。
把它当作一种一致的跨平台工作方式:不用在 Linux 上使用 bash 脚本,在 Windows 上使用批处理或 PowerShell 脚本,你可以有一个版本。当然,你可以引入外部工具,如 Python、Perl 或 Ruby 脚本,但这又是另一个依赖,将增加 C/C++项目的复杂性。是的,有时这将是唯一能完成工作的事情,但更多的时候,我们可以用一些更简单的东西应付过去。
我们已经从掌握命令行部分了解到,我们可以使用-P
选项执行脚本:cmake -P script.cmake
。但是提供的脚本文件的实际要求是什么?并不多:脚本可以像你喜欢的那么复杂,也可以是一个空文件。然而,建议你在脚本的开始处调用cmake_minimum_required()
命令。这个命令告诉 CMake 应该对项目中的后续命令应用哪些策略(更多详情请参阅第三章,设置你的第一个 CMake 项目)。
chapter-01/03-script/script.cmake
# An example of a script
cmake_minimum_required(VERSION 3.20.0)
message("Hello world")
file(WRITE Hello.txt "I am writing to a file")
当运行脚本时,CMake 不会执行任何常规阶段(如配置或生成),也不会使用缓存。由于脚本中没有源/构建树的概念,通常持有这些路径引用的变量将包含当前工作目录:CMAKE_BINARY_DIR
、CMAKE_SOURCE_DIR
、CMAKE_CURRENT_BINARY_DIR
和CMAKE_CURRENT_SOURCE_DIR
。
快乐脚本编程!
实用模块
CMake 项目可以利用外部模块来增强其功能。模块是用 CMake 语言编写的,包含宏定义、变量和执行各种功能的命令。它们从相当复杂的脚本(CPack
和CTest
也提供模块!)到相对简单的脚本,如AddFileDependencies
或TestBigEndian
。
CMake 分发版包含了几乎 90 个不同的实用模块。如果这还不够,你可以在浏览精选列表,如在github.com/onqtam/awesome-cmake
找到的列表上互联网下载更多,或者从头开始编写一个模块。
要使用一个实用模块,我们需要调用一个include(<MODULE>)
命令。下面是一个简单项目展示了这个动作:
chapter-01/04-module/CMakeLists.txt
cmake_minimum_required(VERSION 3.20.0)
project(ModuleExample)
include (TestBigEndian)
TEST_BIG_ENDIAN(IS_BIG_ENDIAN)
if(IS_BIG_ENDIAN)
message("BIG_ENDIAN")
else()
message("LITTLE_ENDIAN")
endif()
我们将在它们与主题相关时学习有哪些模块可供使用。如果你好奇,可以找到包含模块的完整列表在cmake.org/cmake/help/latest/manual/cmake-modules.7.html
。
查找模块
在包的配置文件部分,我提到 CMake 有一个机制,允许它找到属于外部依赖项的文件,这些依赖项不支持 CMake 并且没有提供 CMake 配置文件(或者还没有)。查找模块就是为了这个目的。CMake 提供了 150 多个模块,能够定位系统中的不同包。和实用模块一样,网络上还有更多的查找模块可供选择,另一种选择是编写自己的模块,作为最后的手段。
你可以通过调用find_package()
命令并提供相关包的名称来使用它们。这样的查找模块将然后玩一场捉迷藏游戏,并检查它所寻找的软件的所有已知位置。在此之后,它定义了变量(如该模块手册中所指定的)允许你针对该依赖项进行构建。
例如,FindCURL
模块搜索一个流行的客户端 URL库,并定义了以下变量:CURL_FOUND
、CURL_INCLUDE_DIRS
、CURL_LIBRARIES
和CURL_VERSION_STRING
。
我们将在第七章更深入地讨论查找模块,使用 CMake 管理依赖项。
总结
现在你已经了解了 CMake 是什么以及它是如何工作的;你学习了 CMake 工具家族的关键组成部分以及如何在各种系统中安装它们。像真正的功率用户一样,你知道通过命令行运行 CMake 的所有方式:生成构建系统、构建项目、安装、运行脚本、命令行工具和打印帮助。你知道 CTest、CPack 和 GUI 应用程序。这将帮助你为用户和其他开发者创建项目,并从正确的角度出发。此外,你还学会了组成一个项目的内容:目录、列表文件、配置文件、预设和帮助文件,以及在 VCS 中应该忽略哪些内容。最后,你简要地查看了其他非项目文件:独立的脚本和模块。
在下一章中,我们将深入探讨 CMake 的编程语言。这将使你能够编写自己的列表文件并打开编写第一个脚本、项目和模块的大门。
进一步阅读
更多信息,你可以参考以下资源:
-
官方 CMake 网页和文档:
cmake.org/
-
单配置生成器:
cgold.readthedocs.io/en/latest/glossary/single-config.html
-
CMake GUI 中阶段的分离:
stackoverflow.com/questions/39401003/why-there-are-two-buttons-in-gui-configure-and-generate-when-cli-does-all-in-one
第二章:CMake 语言
在CMake 语言中写作有点棘手。当你第一次阅读 CMake 列表文件时,你可能会觉得其中的语言如此简单,以至于不需要任何特殊培训或准备。接下来的内容经常是尝试引入变化和实验代码的实际尝试,而没有彻底理解它是如何工作的。我们程序员通常非常忙碌,并且过于热衷于用最小的投入解决任何与构建相关的问题。我们倾向于基于直觉进行更改,希望它们可能管用。解决技术问题的这种方法称为巫术编程。
CMake 语言看起来很简单:在我们完成小的添加、修复或黑客攻击,或者添加了一行代码之后,我们意识到有些事情不对劲。调试时间通常比实际研究主题的时间还要长。幸运的是,这不会是我们的命运——因为本章涵盖了实践中使用 CMake 语言所需的大部分关键知识。
在本章中,我们不仅将学习 CMake 语言的构建块——注释、命令、变量和控制结构,还将提供必要的背景知识,并在一个干净现代的 CMake 示例中尝试它们。CMake 让你处于一个独特的位置。一方面,你扮演着构建工程师的角色;你需要理解编译器、平台以及中间所有事物的复杂性。另一方面,你是一名开发者;你在编写生成构建系统的代码。编写好的代码是困难的,并要求同时思考多个层面——它应该能工作且容易阅读,但它也应该容易分析、扩展和维护。这正是我们将在这里讨论的内容。
最后,我们将介绍 CMake 中一些最有用和常见的命令。不经常使用的命令将放在附录部分(这将包括字符串、列表和文件操作命令的完整参考指南)。
在本章中,我们将涵盖以下主要主题:
-
CMake 语言基础语法
-
使用变量
-
使用列表
-
理解 CMake 中的控制结构
-
有用命令
技术要求
你可以在 GitHub 上找到本章中存在的代码文件:github.com/PacktPublishing/Modern-CMake-for-Cpp/tree/main/examples/chapter02
。
为了构建本书中提供的示例,总是使用推荐的命令:
cmake -B <build tree> -S <source tree>
cmake --build <build tree>
请确保将占位符<build tree>
和<source tree>
替换为适当的路径。作为提醒:构建树是目标/输出目录的路径,源树是您的源代码所在的路径。
CMake 语言基础语法
编写 CMake 代码与编写其他命令式语言的代码非常相似:代码从上到下、从左到右执行,偶尔会进入一个被包含的文件或一个被调用的函数。根据模式(参见第一章中的掌握命令行部分,CMake 的初学者指南),执行从源树根文件(CMakeLists.txt
)或作为一个参数传递给cmake
的.cmake
脚本文件开始。
正如我们在上一章中讨论的,脚本支持 CMake 语言的大部分(排除任何与项目相关的功能)。因此,它们是开始练习 CMake 语法的好方法,这就是为什么我们会在这里使用它们。在熟练编写基本列表文件之后,我们将在下一章开始准备实际的项目文件。如果你还记得,脚本可以用以下命令运行:
cmake -P script.cmake
注意
CMake 支持\n
或\r\n
行结束。UTF-8支持可选的字节顺序标记(BOMs)的 CMake 版本 above 3.0,并且UTF-16在 CMake 版本 above 3.2 中得到支持。
CMake 列表文件中的所有内容要么是命令调用,要么是注释。
注释
就像在**C++**中一样,有两种注释——单行注释和方括号 (多行)注释。但与 C++不同,方括号注释可以嵌套。让我给你展示一下语法:
# single-line comments start with a hash sign "#"
# they can be placed on an empty line
message("Hi"); # or after a command like here.
#[=[
bracket comment
#[[
nested bracket comment
#]]
#]=]
多行注释因其符号而得名——它们以一个开口方括号([
)开始,后面跟着任意数量的等号(=
)和一个另一个方括号:[=[
。要关闭方括号注释,请使用相同数量的等号,并像这样反转方括号:]=]
。
在方括号标记前面加上#
是可选的,允许你通过在方括号注释的第一行添加另一个#
来快速禁用多行注释,像这样:
##[=[ this is a single-line comment now
no longer commented
#[[
still, a nested comment
#]]
#]=] this is a single-line comment now
那是个巧妙的技巧,但我们在 CMake 文件中什么时候以及如何使用注释呢?由于编写列表文件本质上是一种编程,因此最好也将我们最好的编程实践应用到它们上。遵循此类实践的代码通常被称为干净——这个术语多年来被软件开发大师如 Robert C. Martin, Martin Fowler,以及其他许多作者使用。认为有帮助和有害的东西常常激烈争论,正如你所猜的,注释也没有被排除在这些争论之外。
一切都应该根据具体情况来判断,但通常公认的指导原则说,好的注释至少提供以下之一:
-
信息:它们可以解释像正则表达式模式或格式化字符串这样的复杂性。
-
意图:它们可以在代码的实现或接口不明显时解释代码的意图。
-
阐明:它们可以解释难以重构或更改的概念。
-
警告后果:它们可以提供警告,尤其是关于可能破坏其他内容的代码。
-
放大:它们可以强调难以用代码表达的想法。
-
法律条款:它们可以添加这个必要的恶棍,这通常不是程序员的领域。
如果你可以,避免添加注释并采用更好的命名约定,或者重构或修正你的代码。如果你可以,避免添加以下类型的注释:
-
强制:这些是为了完整性而添加的,但它们并不是非常重要。
-
冗余:这些重复了代码中已经清晰写明的内容。
-
误导:如果它们没有跟随代码更改,它们可能是过时的或不正确的。
-
日志:这些记录了更改的内容和时间(使用版本控制系统代替)。
-
分隔符:这些用于标记章节。
不带注释编写优雅的代码很难,但它可以提高读者的体验。由于我们花在阅读代码上的时间比编写代码的时间多,我们总是应该努力编写可读的代码,而不仅仅是尝试快速编写它。我建议在本章末尾查看进一步阅读部分,那里有一些关于整洁代码的好参考资料。如果你对注释特别感兴趣,你会在其中一个 YouTube 视频中找到一个深入讨论这个主题的链接,这个视频是我关于这个主题的众多视频之一。
命令调用
是时候动手了!调用命令是 CMake 列表文件的核心。要执行一个命令,你必须提供其名称,后面跟着括号,其中你可以包含一个空格分隔的命令参数列表。
[外链图片转存中…(img-J5wSbjjz-1716544491730)]
图 2.1 – 命令示例
命令名称不区分大小写,但 CMake 社区有一个约定,在命令名称中使用蛇形大小写(即,使用下划线连接的小写字母单词)。你也可以定义自己的命令,这部分将在本章的理解 CMake 中的控制结构部分进行介绍。
与 C++ 相比,特别引人注目的是 CMake 中的命令调用不是表达式。你不能将另一个命令作为参数传递给被调用的命令,因为所有括号内的内容都被解释为该命令的参数。
更加令人沮丧的是,CMake 命令在调用结束时不需要分号。这可能是因为源代码的每一行可以包含多达一个命令调用,后面可以跟一个可选的单行注释。或者,整个行必须是括号注释的一部分。所以,这些是唯一允许的格式:
command(argument1 "argument2" argument3) # comment
[[ multiline comment ]]
在括号注释后放置命令是不允许的:
[[ bracket
]] command()
在删除任何注释、空格和空行之后,我们得到一个命令调用的列表。这创造了一个有趣的视角——CMake 语法真的很简单,但这是一件好事吗?我们是如何处理变量的?或者,我们是如何指导执行流程的?
CMake 提供了这些操作以及更多命令。为了简化事情,我们将随着不同示例的推进介绍相关命令,并将它们分为三个类别:
-
脚本命令:这些命令始终可用,用于改变命令处理器的状态、访问变量,并影响其他命令和环境。
-
项目命令:这些命令在项目中可用,用于操作项目状态和构建目标。
-
CTest 命令:这些命令在 CTest 脚本中可用,用于管理测试。
在本章中,我们将介绍最有用的脚本命令(因为它们在项目中也非常有用)。项目和 CTest 命令将在我们引入与构建目标相关的概念(第三章,设置你的第一个 CMake 项目)和测试框架(第八章,测试框架)时讨论。
几乎每个命令都依赖于语言的其他元素才能正常工作:变量、条件语句,以及最重要的,命令行参数。让我们看看应该如何使用这些元素。
命令参数
许多命令需要用空格分隔的参数来指定它们的行为方式。正如你在图 2.1中看到的,引号周围的参数有些奇怪。一些参数有引号,而其他参数没有——这是怎么回事?
在底层,CMake 唯一能识别的数据类型是一个字符串。这就是为什么每个命令都期望其参数为零个或多个字符串。但是,普通的、静态的字符串并不非常有用,尤其是当我们不能嵌套命令调用时。参数就派上用场了——CMake 将评估每个参数为静态字符串,然后将它们传递给命令。评估意味着字符串插值,或将字符串的一部分替换为另一个值。这可以意味着替换转义序列,展开变量引用(也称为变量插值),以及解包列表。
根据上下文,我们可能需要启用这种评估。为此,CMake 提供了三种参数类型:
-
方括号参数
-
引号参数
-
未引用的参数
每种参数类型提供不同级别的评估,并且有一些小怪癖。
方括号参数
方括号参数不进行评估,因为它们用于将多行字符串作为单个参数传递给命令,而不做任何更改。这意味着它会包括制表符和换行符形式的空白。
这些参数的结构与注释完全一样——也就是说,它们以[=[
开头,以]=]
结尾,其中开头和结尾标记中的等号数量必须匹配(省略等号也是可以的,但它们仍然必须匹配)。与注释的区别在于,你不能嵌套方括号参数。
下面是使用此类参数与message()
命令的一个例子,该命令将所有传递的参数打印到屏幕上:
chapter02/01-arguments/bracket.cmake
message([[multiline
bracket
argument
]])
message([==[
because we used two equal-signs "=="
following is still a single argument:
{ "petsArray" = [["mouse","cat"],["dog"]] }
]==])
在上面的例子中,我们可以看到不同形式的括号参数。第一个省略了等号。注意把闭合标签放在单独一行上,在输出中会显示为一个空行:
$ cmake -P chapter02/01-arguments/bracket.cmake
multiline
bracket
argument
because we used two equal-signs "=="
following is still a single argument:
{ "petsArray" = [["mouse","cat"],["dog"]] }
第二种形式在传递包含双括号(]]
)的文本时很有用(在代码片段中突出显示),因为它们不会被解释为参数的结束标记。
这类括号参数的用途有限——通常,用来包含较长的文本块。在大多数情况下,我们需要一些更动态的内容,比如引号参数。
引号参数
引号参数类似于普通的 C++字符串——这些参数将多个字符(包括空格)组合在一起,并将展开转义序列。和 C++字符串一样,它们用双引号字符("
)打开和关闭,所以为了在输出字符串中包含一个引号字符,你必须用反斜杠(\"
)进行转义。也支持其他一些著名的转义序列:\\
表示一个字面反斜杠,\t
是一个制表符,\n
是一个换行符,\r
是一个回车符。
这就是 C++字符串相似之处的结束。引号参数可以跨越多行,并且它们将插值变量引用。可以认为它们内置了sprintf
函数从std::format
中${name}
。我们将在使用变量部分更多地讨论变量引用。
让我们尝试这些参数的实际应用:
chapter02/01-arguments/quoted.cmake
message("1\. escape sequence: \" \n in a quoted argument")
message("2\. multi...
line")
message("3\. and a variable reference: ${CMAKE_VERSION}")
你能猜到前面脚本的输出将有多少行吗?
$ cmake -P chapter02/01-arguments/quoted.cmake
1\. escape sequence: "
in a quoted argument
2\. multi...
line
3\. and a variable reference: 3.16.3
没错——我们有一个转义的引号字符,一个转义的换行符,和一个字面的换行符。它们都将被打印在输出中。我们还访问了内置的CMAKE_VERSION
变量,我们可以在最后一行正确地看到它被插值。
未引用的参数
在编程世界中,最后一种参数确实比较少见。我们习惯了字符串要以某种方式进行分隔,例如,使用单引号、双引号或反斜杠。CMake 与这个约定不符,并引入了未引用的参数。我们可能会认为省略分隔符可以使代码更容易阅读,就像省略分号一样。这是真的吗?我会让你自己形成自己的看法。
未引用的参数评估转义序列和变量引用。然而,要小心分号(;
),因为在 CMake 中,这被视为分隔符。CMake 会将包含它的参数拆分为多个参数。如果你需要使用它,用反斜杠(\;
)转义它。这就是 CMake 如何管理列表的方式。我将在使用列表部分详细解释。
你可能会发现这些参数是最让人困惑的,所以这里有一个说明来帮助澄清这些参数是如何划分的:
[外链图片转存中…(img-hNS5KcPP-1716544491730)]
](https://gitee.com/OpenDocCN/freelearn-c-cpp-pt2-zh/raw/master/docs/mdn-cmk-cpp/img/Figure_2.2_B17205.jpg)
图 2.2 – 转义序列导致分离的标记被解释为一个参数
问题
为什么一个值作为单个参数传递或多个参数传递会有区别?一些 CMake 命令需要特定数量的参数,并忽略任何开销。如果你的参数不小心被分开了,你会得到难以调试的错误。
未引用的参数不能包含未转义的引号("
)、散列(#
)和反斜杠(\
)。如果这些规则还不够难以记忆,圆括号(()
)只能在它们形成正确、匹配的对时使用。也就是说,你将以一个开放圆括号开始,并在关闭命令参数列表之前关闭它。
让我们看看上面所有规则的一些例子:
chapter02/01-arguments/unquoted.cmake
message(a\ single\ argument)
message(two arguments)
message(three;separated;arguments)
message(${CMAKE_VERSION}) # a variable reference
message(()()()) # matching parentheses
上述代码的输出会是什么?让我们来看看:
$ cmake -P chapter02/01-arguments/unquoted.cmake
a single argument
twoarguments
threeseparatedarguments
3.16.3
()()()
即使是像 message()
这样的简单命令也非常在意分离的未引用参数:
-
在
a single argument
中的空格在显式转义时被正确打印。 -
然而,
twoarguments
和threeseparatearguments
被粘在一起,因为message()
本身不会添加任何空格。
现在我们已经理解了如何处理 CMake 参数的复杂性和怪癖,我们准备迎接下一个有趣的话题——在 CMake 中处理各种变量。
在 CMake 中处理变量
CMake 中的变量是一个相当复杂的话题。不仅变量分为三种类别——普通、缓存和环境变量,而且它们还存在于不同的作用域中,有着特定的一套规则,一个作用域如何影响另一个作用域。在大多数情况下,对这些规则的误解成为错误和头痛的来源。我建议你仔细学习这一部分,并确保在继续之前理解了所有概念。
让我们先了解一些关于 CMake 变量的关键事实:
-
变量名是区分大小写的,并且几乎可以包含任何字符。
-
所有的变量内部都是以字符串的形式存储的,即使有些命令可以将它们解释为其他数据类型(甚至是列表!)。
-
CMake 的基本变量操作命令是
set()
和unset()
,但还有其他可以影响变量的命令,如string()
和list()
。
要设置一个变量,我们只需调用 set()
,提供其名称和值:
chapter02/02-variables/set.cmake
set(MyString1 "Text1")
set([[My String2]] "Text2")
set("My String 3" "Text3")
message(${MyString1})
message(${My\ String2})
message(${My\ String\ 3})
正如你所看到的,括号和引号参数的使用允许在变量名中包含空格。然而,在稍后引用时,我们必须用反斜杠(\
)转义空格。因此,建议变量名只使用字母数字字符、连字符(-
)和下划线(_
)。
还应避免以以下任何内容开头的保留名称(全部大写、全部小写或混合大小写):CMAKE_
、_CMAKE_
或下划线(_
),后跟任何 CMake 命令的名称。
注意
set()
命令接受一个普通文本变量名作为其第一个参数,但message()
命令使用的是用${}
语法包裹的变量引用。如果我们向set()
命令提供一个用${}
语法包裹的变量,会发生什么?为了回答这个问题,我们需要更好地理解变量引用。
要取消设置变量,我们可以使用以下方式:unset(MyString1)
。
变量引用
我已经在命令参数部分简要提到了引用,因为它们对带引号和不带引号的参数进行评估。我们还了解到,要创建一个对已定义变量的引用,我们需要使用${}
语法,如下所示:message(${MyString1})
。
在评估时,CMake 将遍历作用域堆栈(我稍后会解释)并将${MyString1}
替换为一个值,如果没有找到变量,则替换为一个空字符串(CMake 不会生成任何错误消息)。这个过程被称为变量评估、展开或插值。
这样的插值是逆向进行的。这意味着两件事:
-
如果遇到以下引用——
${MyOuter${MyInner}}
——CMake 将首先尝试评估MyInner
,而不是搜索名为MyOuter${MyInner}
的变量。 -
如果
MyInner
变量成功展开,CMake 将重复展开过程,直到无法进一步展开为止。
让我们考虑以下变量的示例:
-
MyInner
带有Hello
值 -
MyOuter
带有${My
值
如果我们调用message("${MyOuter}Inner} World")
命令,我们将收到Hello World
的输出,这是因为${MyOuter}
被替换为字面值${My
,与顶级值Inner}
结合,创建了另一个变量引用——${MyInner}
。
CMake 将进行这种展开到最大限度,然后才将结果值作为参数传递给命令。这就是为什么我们调用set(${MyInner} "Hi")
时,我们实际上并没有改变MyInner
变量,而是改变了Hello
变量。CMake 展开${MyInner}
为Hello
,并将该字符串作为set()
命令的第一个参数,并传递一个新的值,Hi
。通常,这并不是我们想要的结果。
变量引用在变量类别方面的工作方式有些奇特,但总的来说,以下内容适用:
-
${}
语法用于引用普通或缓存变量。 -
$ENV{}
语法用于引用环境变量。 -
$CACHE{}
语法用于引用缓存变量。
没错,使用${}
,你可能会从一个类别或另一个类别中获取一个值。我将在如何在 CMake 中正确使用变量作用域部分解释这一点,但首先,让我们介绍一下其他类别的变量,以便我们清楚地了解它们是什么。
注意
请记住,您可以在--
标记之后通过命令行向脚本传递参数。值将存储在CMAKE_ARGV<n>
变量中,传递的参数数量将在CMAKE_ARGC
变量中。
使用环境变量
这是最简单的变量类型。CMake 会复制启动cmake
过程时使用的环境中的变量,并使它们在一个单独的全局作用域中可用。要引用这些变量,请使用$ENV{<name>}
语法。
CMake 还允许您设置(set()
)和取消设置(unset()
)这些变量,但更改只会对运行中的cmake
过程中的本地副本进行修改,而不会对实际系统环境进行修改;此外,这些更改不会对后续的构建或测试运行可见。
要修改或创建一个变量,请使用set(ENV{<variable>} <value>)
命令,如下所示:
set(ENV{CXX} "clang++")
要清除一个环境变量,请使用unset(ENV{<variable>})
,如下所示:
unset(ENV{VERBOSE})
请注意,有几个环境变量会影响 CMake 行为的不同方面。CXX
变量是其中的一个——它指定了用于编译 C++文件的执行文件。我们将在本书中覆盖其他环境变量,因为它们将变得相关。完整的列表可以在文档中找到:
cmake.org/cmake/help/latest/manual/cmake-env-variables.7.html
如果您将ENV
变量作为命令的参数,它们的值将在构建系统的生成过程中进行插值。这意味着它们将被编织进构建树中,更改构建阶段的环境将没有任何效果。
例如,考虑以下项目文件:
chapter02/03-environment/CMakeLists.txt
cmake_minimum_required(VERSION 3.20.0)
project(Environment)
message("generated with " $ENV{myenv})
add_custom_target(EchoEnv ALL COMMAND echo "myenv in build
is" $ENV{myenv})
前面的示例有两个步骤:在配置阶段打印myenv
环境变量,并通过add_custom_target()
添加一个构建阶段,在构建过程中输出相同的变量。我们可以用一个 bash 脚本测试会发生什么,该脚本在配置阶段使用一个值,在构建阶段使用另一个值:
chapter02/03-environment/build.sh
#!/bin/bash
export myenv=first
echo myenv is now $myenv
cmake -B build .
cd build
export myenv=second
echo myenv is now $myenv
cmake --build .
运行前面的代码清楚地显示,在配置阶段设置的值被持久到了生成的构建系统中:
$ ./build.sh | grep -v "\-\-"
myenv is now first
generated with first
myenv is now second
Scanning dependencies of target EchoEnv
myenv in build is first
Built target EchoEnv
使用缓存变量
我们首先在第一章 CMake 的初步步骤中提到了缓存变量,当时是在讨论cmake
的命令行选项。本质上,它们是存储在构建树中的CMakeCache.txt
文件中的持久变量。它们包含在项目配置阶段收集的信息,既有来自系统的(编译器、链接器、工具等的路径),也有通过CMakeCache.txt
文件来自用户的——它们只存在于项目中。
缓存变量可以用$CACHE{<name>}
语法引用。
要设置一个缓存变量,请使用以下语法使用set()
:
set(<variable> <value> CACHE <type> <docstring> [FORCE])
正如你所见,有一些新的必需参数(与正常变量的 set()
命令相比),它还引入了第一个关键字:CACHE
和 FORCE
。
将 CACHE
指定为 set()
参数意味着我们打算改变在配置阶段提供的内容,并强制提供变量 <type>
和 <docstring>
值。这是因为这些变量可以由用户配置,GUI 需要知道如何显示它。以下类型被接受:
-
BOOL
: 是/否的布尔值。GUI 将显示一个复选框。 -
FILEPATH
: 磁盘上一个文件的路径。GUI 将打开一个文件对话框。 -
PATH
: 磁盘上一个目录的路径。GUI 将打开一个目录对话框。 -
STRING
: 一行文本。GUI 提供一个文本字段来填写。它可以被调用set_property(CACHE <variable> STRINGS <values>)
的下拉控件替换。 -
INTERNAL
: 一行文本。GUI 跳过内部条目。内部条目可用于在多次运行之间持久化存储变量。使用此类型隐式添加FORCE
关键字。
<docstring>
值只是一个标签,它将由 GUI 在字段旁边显示,为用户提供关于此设置的更多详细信息。即使是 INTERNAL
类型也需要它。
设置缓存变量遵循与环境变量相同的规则,在某种程度上——值只在 CMake 的当前执行中覆盖。看这个例子:
set(FOO "BAR" CACHE STRING "interesting value")
如果变量在缓存中存在,上述调用将没有永久效果。然而,如果值不在缓存中或者指定了可选的 FORCE
参数,该值将被持久化:
set(FOO "BAR" CACHE STRING "interesting value" FORCE)
设置缓存变量有一些不明显的含义。也就是说,任何同名的正常变量都会被移除。我们在下一节中找出原因。
作为提醒,缓存变量也可以从命令行管理(查看 第一章,CMake 的第一步 中的适当部分)。
如何在 CMake 中正确使用变量作用域
变量作用域 可能是整个 CMake 语言概念中最难的部分。这可能是因为我们习惯于在支持命名空间和作用域操作符的更高级语言中是如何处理事情的。CMake 没有这些机制,所以它以自己 somewhat unusual 的方式处理这个问题。
为了澄清,作用域作为一个一般概念是为了将不同层次的抽象分离,以便当调用一个用户定义的函数时,函数中设置的变量是局部的。这些局部变量即使与全局变量的名称完全相同,也不会影响全局作用域。如果明确需要,函数应该对全局变量也有读/写权限。这种变量的分离(或作用域)必须在多个层面上工作——当一个函数调用另一个函数时,相同的分离规则适用。
CMake 有两个作用域:
-
执行
function()
-
在嵌套目录中执行的
CMakeLists.txt
清单文件从add_subdirectory()
命令开始
我们将在本书稍后介绍前面的命令,但首先,我们需要了解作用域概念是如何实现的。当创建一个嵌套作用域时,CMake 简单地用当前作用域的所有变量的副本填充它。随后的命令将影响这些副本。但一旦嵌套作用域的执行完成,所有的副本都被删除,并恢复原始的父作用域。
让我们考虑以下场景:
-
父作用域将
VAR
变量设置为ONE
。 -
嵌套作用域开始,并将
VAR
打印到控制台。 -
VAR
变量被设置为TWO
,并将VAR
打印到控制台。 -
嵌套作用域结束,并将
VAR
打印到控制台。
控制台的输出将如下所示:ONE
,TWO
,ONE
。这是因为嵌套作用域结束后,复制的VAR
变量被丢弃。
在 CMake 中作用域的概念如何工作有着有趣的暗示,这在其他语言中并不常见。如果你在一个嵌套作用域中执行时取消设置(unset()
)了在父作用域中创建的变量,它将消失,但仅在嵌套作用域中。当嵌套作用域完成后,变量会恢复到其原来的值。
这让我们想到了变量引用和${}
语法的的行为。无论何时我们尝试访问普通变量,CMake 都会从当前作用域搜索变量,如果定义了这样一个名字的变量,它会返回它的值。到目前为止,一切都好。然而,当 CMake 找不到这个名字的变量(例如,如果它不存在或被取消设置(unset()
)),它将搜索缓存变量,并在找到匹配项时返回那里的值。
如果在嵌套作用域中调用unset()
,这可能是一个潜在的陷阱。取决于我们引用那个变量的位置——在内部还是外部作用域——我们将访问缓存或原始值。
但如果我们真的需要在调用(父)作用域中更改变量,该怎么办呢?CMake 有一个PARENT_SCOPE
标志,你可以在set()
和unset()
命令的末尾添加:
set(MyVariable "New Value" PARENT_SCOPE)
unset(MyVariable PARENT_SCOPE)
这个变通方法有点局限,因为它不允许访问超过一级的变量。值得注意的是,使用PARENT_SCOPE
并不会改变当前作用域中的变量。
让我们看看实际中变量作用域是如何工作的,并考虑以下示例:
chapter02/04-scope/CMakeLists.txt
function(Inner)
message(" > Inner: ${V}")
set(V 3)
message(" < Inner: ${V}")
endfunction()
function(Outer)
message(" > Outer: ${V}")
set(V 2)
Inner()
message(" < Outer: ${V}")
endfunction()
set(V 1)
message("> Global: ${V}")
Outer()
message("< Global: ${V}")
我们将全局变量V
设置为1
,然后调用Outer
函数;然后将V
设置为2
并调用Inner
函数,然后将V
设置为3
。在每一步之后,我们都将变量打印到控制台:
> Global: 1
> Outer: 1
> Inner: 2
< Inner: 3
< Outer: 2
< Global: 1
如我们之前解释的,当我们深入函数时,变量值会被复制到嵌套作用域中,但当我们退出作用域时,它们的原始值会被恢复。
如果我们更改Inner
函数的set()
命令以在父级作用域中操作:set(V 3 PARENT_SCOPE)
,输出会是什么?
> Global: 1
> Outer: 1
> Inner: 2
< Inner: 2
< Outer: 3
< Global: 1
我们影响了Outer
函数的作用域,但没有影响Inner
函数或全局作用域!
正如 CMake 文档中提到的,CMake 脚本在单个目录作用域中绑定变量(这有点冗余,因为唯一有效地创建目录作用域的命令,add_subdirectory()
,在脚本中不允许)。
由于所有变量都存储为字符串,CMake 不得不采取更具创意的方法来处理更复杂的数据结构,如列表。
使用列表
要存储;
)作为分隔符:a;list;of;5;elements
。您可以在元素中用反斜杠转义分号:a\;single\;element
。
要创建一个列表,我们可以使用set()
命令:set(myList a list of five elements)
。由于列表的存储方式,以下命令将具有完全相同的效果:
-
set(myList "a;list;of;five;elements")
-
set(myList a list "of;five;elements")
CMake 会自动在未引用的参数中解包列表。通过传递一个未引用的myList
引用,我们实际上向命令发送了更多参数:
message("the list is:" ${myList})
message()
命令将接收六个参数:“the list is:
”,“a
”,“list
”,“of
”,“five
”,“elements
”。这将导致意想不到的后果,因为输出将不带任何额外空格打印参数之间:
the list is:alistoffiveelements
正如你所看到的,这是一个非常简单的机制,应该谨慎使用。
CMake 提供了一个list()
命令,该命令提供了许多子命令来读取、搜索、修改和排序列表。以下是一个简要总结:
list(LENGTH <list> <out-var>)
list(GET <list> <element index> [<index> ...] <out-var>)
list(JOIN <list> <glue> <out-var>)
list(SUBLIST <list> <begin> <length> <out-var>)
list(FIND <list> <value> <out-var>)
list(APPEND <list> [<element>...])
list(FILTER <list> {INCLUDE | EXCLUDE} REGEX <regex>)
list(INSERT <list> <index> [<element>...])
list(POP_BACK <list> [<out-var>...])
list(POP_FRONT <list> [<out-var>...])
list(PREPEND <list> [<element>...])
list(REMOVE_ITEM <list> <value>...)
list(REMOVE_AT <list> <index>...)
list(REMOVE_DUPLICATES <list>)
list(TRANSFORM <list> <ACTION> [...])
list(REVERSE <list>)
list(SORT <list> [...])
大多数时候,我们实际上并不需要在项目中使用列表。然而,如果你发现自己处于这种方便的概念的罕见情况,你会在附录部分找到list()
命令的更深入的参考。
现在我们已经知道如何处理各种变量和列表,让我们将重点转移到控制执行流程上,并了解 CMake 中可用的控制结构。
了解 CMake 中的控制结构
在 CMake 语言中,控制结构是不可或缺的!与 everything else 一样,它们以命令的形式提供,并分为三个类别:条件块、循环和命令定义。控制结构在脚本中执行,在项目构建系统生成期间也执行。
条件块
CMake 支持的唯一条件块是谦逊的if()
命令。所有条件块都必须用endif()
命令关闭,它们可能有任意数量的elseif()
命令和一个可选的else()
命令,顺序如下:
if(<condition>)
<commands>
elseif(<condition>) # optional block, can be repeated
<commands>
else() # optional block
<commands>
endif()
像许多其他命令式语言一样,if()
-endif()
块控制哪些命令集将被执行:
-
如果
if()
命令中指定的<条件>
表达式满足,将执行第一个部分。 -
否则,CMake 将执行本块中第一个满足其条件的
elseif()
命令所属的部分中的命令。 -
如果没有这样的命令,CMake 将检查是否提供了
else()
命令并执行该代码部分的任何命令。 -
如果上述条件都不满足,执行将继续
endif()
命令之后进行。
提供的<条件>
表达式根据一个非常简单的语法进行评估。
条件命令的语法
对于if()
、elseif()
和while()
命令,相同的语法是有效的。
逻辑运算符
if()
条件支持NOT
、AND
和OR
逻辑运算符:
-
NOT <条件>
-
<条件> AND <条件>
-
<条件> OR <条件>
此外,条件的嵌套是可能的,匹配的括号对(()
)。像所有体面的语言一样,CMake 语言尊重评估的顺序,并从最内层的括号开始:
(<条件>) AND (<条件> OR (<条件>))
字符串和变量的评估
由于历史原因(因为变量引用(${}
)语法并非一直存在),CMake 会尝试将未引用的参数评估为变量引用。换句话说,在条件中使用普通的变量名(例如,VAR
)等同于写${VAR}
。下面是一个供你考虑的示例和一个陷阱:
set(VAR1 FALSE)
set(VAR2 "VAR1")
if(${VAR2})
if()
条件在这里有点复杂——首先,它会评估${VAR2}
为VAR1
,这是一个已知的变量,进而将其评估为FALSE
字符串。字符串只有等于以下常量之一时才被认为是布尔真(这些比较不区分大小写):
-
ON
、Y
、YES
或TRUE
-
非零数字
这使我们得出结论,前一个示例中的条件将评估为假。
然而,这里还有一个陷阱——一个未引用的参数的条件评估会怎样,这个参数的名称包含一个值为BAR
的变量呢?考虑以下代码示例:
set(FOO BAR)
if(FOO)
根据我们迄今为止的说法,这将是false
,因为BAR
字符串不符合评估为布尔true
值的准则。不幸的是,这不是事实,因为 CMake 在未引用的变量引用方面做出了例外。与引号引用的参数不同,FOO
不会被评估为BAR
以产生if("BAR")
语句(这将是false
)。相反,CMake 只会评估if(FOO)
为false
,如果它是以下常量之一(这些比较不区分大小写):
-
OFF
,NO
,FALSE
,N
,IGNORE
,NOTFOUND
-
以
-NOTFOUND
结尾的字符串 -
空字符串
-
零
所以,简单地询问一个未定义的变量将被评估为false
:
if (FOO)
然而,事先定义一个变量会改变情况,条件被评估为true
:
set(FOO "FOO")
if (FOO)
注意
如果你认为未引用的参数的行为令人困惑,请将变量引用用引号引起来:if ("${FOO}")
。这将导致在提供的参数传递到if()
命令之前先评估参数,行为将与字符串的评估一致。
换句话说,CMake 假设用户询问变量是否定义(并且不是显式false
)。幸运的是,我们可以明确地检查这一点(而不必担心内部值):
if(DEFINED <name>)
if(DEFINED CACHE{<name>})
if(DEFINED ENV{<name>})
比较值
比较操作支持以下操作符:
EQUAL
,LESS
,LESS_EQUAL
,GREATER
,和GREATER_EQUAL
它们可以用来比较数字值,如下所示:
if (1 LESS 2)
注意
根据 CMake 文档,如果操作数之一不是数字,值将是false
。但实际实验表明,以数字开头的字符串比较工作正确:if (20 EQUALS "20 GB")
。
你可以通过给任何操作符添加VERSION_
前缀,按照major[.minor[.patch[.tweak]]]
格式比较软件版本:
if (1.3.4 VERSION_LESS_EQUAL 1.4)
省略的组件被视为零,非整数版本组件在比较字符串时截断。
对于字典顺序的字符串比较,我们需要在操作符前加上STR
前缀(注意没有下划线):
if ("A" STREQUAL "${B}")
我们经常需要比简单相等比较更高级的机制。幸运的是,CMake 也支持MATCHES
操作符,如下所示:
<变量名|字符串> MATCHES <正则表达式>
任何匹配的组都捕获在CMAKE_MATCH_<n>
变量中。
简单检查
我们已经提到了一个简单的检查,DEFINED
,但还有其他简单的返回true
如果条件满足的检查。
我们可以检查以下内容:
-
如果值在列表中:
<变量名|字符串> IN_LIST <变量名>
-
如果一个命令可以被调用:
COMMAND <命令名>
-
如果存在 CMake 策略:
POLICY <策略 ID>
(这在第三章中有介绍,设置你的第一个 CMake 项目) -
如果一个 CTest 测试是用
add_test()
添加的:TEST <测试名称>
-
如果定义了一个构建目标:
TARGET <目标名称>
我们将在第四章,《使用目标》中探索构建目标,但现在,让我们说目标是为项目创建的逻辑构建过程中的单位,该项目已经调用了add_executable()
、add_library()
或add_custom_target()
命令。
检查文件系统
CMake 提供了许多处理文件的方法。我们很少需要直接操作它们,通常我们更愿意使用高层次的方法。为了参考,本书将在附录部分提供一个简短的与文件相关的命令列表。但大多数时候,只需要以下操作符(对于绝对路径的行为定义得很清楚):
EXISTS <path-to-file-or-directory>
:检查文件或目录是否存在
这解决了符号链接(如果符号链接的目标存在,则返回true
)。
<file1> IS_NEWER_THAN <file2>
:检查哪个文件更新
如果file1
比(或等于)file2
新,或者这两个文件中的一个不存在,则返回true
。
-
IS_DIRECTORY path-to-directory
:检查一个路径是否是目录 -
IS_SYMLINK file-name
:检查一个路径是否是符号链接 -
IS_ABSOLUTE path
:检查一个路径是否是绝对的
循环
CMake 中的循环相当直接——我们可以使用while()
或foreach()
来反复执行相同的命令集。这两个命令都支持循环控制机制:
-
break()
循环停止剩余块的执行,并从外层循环中断。 -
continue()
循环停止当前迭代的执行,并从下一个开始。
当
循环块用while()
命令打开,用endwhile()
命令关闭。只要while()
中提供的<condition>
表达式为true
,任何包含的命令都将执行。表述条件的语法与if()
命令相同:
while(<condition>)
<commands>
endwhile()
你可能猜到了——通过一些额外的变量——while
循环可以替代for
循环。实际上,使用foreach()
循环要容易得多——让我们来看看。
foreach 循环
foreach 块有几个变体,为每个值执行包含的命令。与其他块一样,它有foreach()
和endforeach()
命令。
foreach()
的最简单形式旨在提供 C++风格的for
循环:
foreach(<loop_var> RANGE <max>)
<commands>
endforeach()
CMake 将从0
迭代到<max>
(包括)。如果我们需要更多的控制,可以使用第二种变体,提供<min>
、<max>
,可选地提供<step>
。所有参数必须是非负整数。此外,<min>
必须小于<max>
:
foreach(<loop_var> RANGE <min> <max> [<step>])
然而,当它处理列表时,foreach()
显示了它的真正颜色:
foreach(<loop_variable> IN [LISTS <lists>] [ITEMS <items>])
CMake 将取所有提供的<lists>
列表变量的元素,然后是所有明确声明的<items>
值,并将它们存储在<loop variable>
中,一次执行<commands>
中的每一个项目。你可以选择只提供列表、只提供值,或者两者都提供:
chapter02/06-loops/foreach.cmake
set(MY_LIST 1 2 3)
foreach(VAR IN LISTS MY_LIST ITEMS e f)
message(${VAR})
endforeach()
上述代码将输出以下内容:
1
2
3
e
f
或者,我们可以使用简短版本(省略 IN
关键字)得到相同的结果:
foreach(VAR 1 2 3 e f)
从版本 3.17 开始,foreach()
学会了如何压缩列表(ZIP_LISTS
):
foreach(<loop_var>... IN ZIP_LISTS <lists>)
压缩列表意味着简单地遍历多个列表,并对具有相同索引的相应项目进行操作。让我们看一个例子:
chapter02/06-loops/foreach.cmake
set(L1 "one;two;three;four")
set(L2 "1;2;3;4;5")
foreach(num IN ZIP_LISTS L1 L2)
message("num_0=${num_0}, num_1=${num_1}")
endforeach()
CMake 将为每个提供的列表创建一个 num_<N>
变量,并填充每个列表的项目。您可以传递多个 <loop_var>
变量名(每个列表一个),每个列表将使用单独的变量存储其项目:
foreach(word num IN ZIP_LISTS L1 L2)
message("word=${word}, num=${num}")
如果列表之间的项数不同,CMake 不会为较短的列表定义变量。
所以,关于循环的内容就讲到这里。
命令定义
定义自己的命令有两种方法:可以使用 macro()
命令或 function()
命令。解释这些命令之间的区别最简单的方式是通过将它们与 C 语言风格的预处理器宏和实际的 C++函数进行比较:
-
macro()
命令更像是一个查找和替换指令,而不是实际的子程序调用,如function()
。与函数相反,宏不会在调用栈上创建一个单独的条目。这意味着在宏中调用return()
将会返回到比函数中的return()
高一个级别的调用语句(如果我们在最外层作用域中,可能会导致执行终止)。 -
function()
命令为局部变量创建了一个单独的作用域,与macro()
命令在工作域中的调用者不同。这可能会导致混淆的结果。我们在下一节讨论这些细节。
这两种方法都接受可以在命令块内部命名和引用的参数。此外,CMake 允许您使用以下引用访问在命令调用中传递的参数:
-
${ARGC}
:参数的数量 -
${ARGV}
:所有参数的列表 -
${ARG0}
、${ARG1}
、${ARG2}
:特定索引处的参数值 -
${ARGN}
: 传递给调用者的一些匿名参数,在最后一个预期参数之后
使用索引访问超出 ARGC
范围的数字参数是未定义行为。
如果您决定定义一个带有命名参数的命令,每个调用都必须传递它们全部,否则它将是无效的。
宏
定义宏与其他块类似:
macro(<name> [<argument>…])
<commands>
endmacro()
在此声明之后,我们可以通过调用其名称来执行我们的宏(函数调用不区分大小写)。
以下示例突出了宏中变量作用域的所有问题:
chapter02/08-definitions/macro.cmake
macro(MyMacro myVar)
set(myVar "new value")
message("argument: ${myVar}")
endmacro()
set(myVar "first value")
message("myVar is now: ${myVar}")
MyMacro("called value")
message("myVar is now: ${myVar}")
以下是此脚本的输出:
$ cmake -P chapter02/08-definitions/macro.cmake
myVar is now: first value
argument: called value
myVar is now: new value
发生了什么事?尽管明确将 myVar
设置为 new value
,但它并没有影响 message("argument: ${myVar}")
的输出!这是因为传递给宏的参数不是作为真正的变量处理,而是作为常数的查找和替换指令。
另一方面,全局作用域中的 myVar
变量从 first value
变为了 new value
。这种行为被称为 副作用,并且被认为是坏实践,因为不阅读宏就很难知道哪个变量可能会受到此类宏的影响。
我建议尽可能多地使用函数,因为这可能会节省你很多头疼的问题。
函数
要声明一个命令作为一个函数,请使用以下语法:
function(<name> [<argument>…])
<commands>
endfunction()
一个函数需要一个名称,可选地接受一个预期参数的名称列表。如果函数调用传递的参数比声明的参数多,多余的参数将被解释为匿名参数并存储在 ARGN
变量中。
如前所述,函数打开它们自己的作用域。你可以调用 set()
,提供函数的一个命名参数,任何更改都仅限于函数内部(除非指定了 PARENT_SCOPE
,正如我们在 如何在 CMake 中正确使用变量作用域 部分讨论的那样)。
函数遵循调用栈的规则,通过 return()
命令返回调用作用域。
CMake 为每个函数设置了以下变量(这些变量自 3.17 版本以来一直可用):
-
CMAKE_CURRENT_FUNCTION
-
CMAKE_CURRENT_FUNCTION_LIST_DIR
-
CMAKE_CURRENT_FUNCTION_LIST_FILE
-
CMAKE_CURRENT_FUNCTION_LIST_LINE
让我们在实际中看看这些函数变量:
chapter02/08-definitions/function.cmake
function(MyFunction FirstArg)
message("Function: ${CMAKE_CURRENT_FUNCTION}")
message("File: ${CMAKE_CURRENT_FUNCTION_LIST_FILE}")
message("FirstArg: ${FirstArg}")
set(FirstArg "new value")
message("FirstArg again: ${FirstArg}")
message("ARGV0: ${ARGV0} ARGV1: ${ARGV1} ARGC: ${ARGC}")
endfunction()
set(FirstArg "first value")
MyFunction("Value1" "Value2")
message("FirstArg in global scope: ${FirstArg}")
这会打印出以下输出:
Function: MyFunction
File: /root/examples/chapter02/08-definitions/function.cmake
FirstArg: Value1
FirstArg again: new value
ARGV0: Value1 ARGV1: Value2 ARGC: 2
FirstArg in global scope: first value
正如你所看到的,函数的一般语法和概念与宏非常相似,但这次——它实际上起作用了。
CMake 中的过程式范例
让我们假设一下,我们想要以与在 C++ 中编写程序相同的方式编写一些 CMake 代码。我们将创建一个 CMakeLists.txt
列表文件,它将调用三个定义的命令,这些命令可能还会调用它们自己的定义命令:
[外链图片转存中…(img-w79Yel29-1716544491731)]
](https://gitee.com/OpenDocCN/freelearn-c-cpp-pt2-zh/raw/master/docs/mdn-cmk-cpp/img/Figure_2.3_B17205.jpg)
图 2.3 – 过程式调用图
在 CMake 中以这种过程式风格编写代码有点问题——你被迫提供你计划使用的命令定义。CMake 解析器别无选择。你的代码可能看起来像这样:
cmake_minimum_required(...)
project(Procedural)
function(pull_shared_protobuf)
function(setup_first_target)
function(calculate_version)
function(setup_second_target)
function(setup_tests)
setup_first_target()
setup_second_target()
setup_tests()
多么噩梦般的场景!一切都被颠覆了!这段代码非常难以阅读,因为最微小的细节都放在了文件的最顶部。一段正确结构的代码首先在第一个子程序中列出最一般的步骤,然后提供稍微详细一些的子程序,并将最详细的步骤推到最后一个文件。
这个问题有解决方案:将命令定义移动到其他文件并将作用域分区到目录之间(将在 第三章 中详细解释,设置你的第一个 CMake 项目)。但还有一个简单而优雅的解决方案:在文件顶部声明一个入口点宏,并在文件的最后调用它:
macro(main)
function(...) # key steps
function(...) # details
function(...) # fine details
main()
采用这种方法,我们的代码是以逐渐缩小的范围编写的,并且因为我们实际上直到最后才调用main()
宏,所以 CMake 不会抱怨未定义命令的执行!
最后一个问题依然存在——为什么要在宏上而不是推荐函数上使用?在这种情况下,无限制访问全局变量是好的,由于我们没有向main()
传递任何参数,所以我们不需要担心常见的警告。
你可以在本书 GitHub 仓库中的chapter-02/09-procedural/CMakeLists.txt
清单文件中找到这个概念的一个简单示例。
关于命名约定的一点说明
在软件开发中,命名是以著称困难的,尽管如此,维持一个易于阅读和理解解决方案仍然非常重要。在 CMake 脚本和项目方面,我们应该遵循干净代码方法的规则,就像在任何一个软件开发解决方案方面一样:
-
遵循一致的命名风格(在 CMake 社区中,
snake_case
是被接受的标准化风格)。 -
使用简短而有意义的名称(例如,避免
func()
、f()
等)。 -
避免在你的命名中使用双关语和机智。
-
使用可以发音、可搜索的名称,不需要进行心智映射。
既然我们已经知道如何正确地使用正确的语法调用命令,那么让我们来探讨哪些命令最初对我们最有益。
有用命令
CMake 提供了许多脚本命令,允许你与变量和环境交互。其中一些在附录部分有广泛的覆盖,例如list()
、string()
和file()
(我们在这里解释这些命令,并在主章节集中精力于项目)。其他的,如find_...()
,更适合在讨论管理依赖的章节中。在本节中,我们将简要介绍对脚本最有用的命令。
message()
命令
我们已经知道并喜欢我们可靠的message()
命令,它将文本打印到标准输出。然而,它的内涵远不止所见。通过提供一个MODE
参数,你可以自定义输出的样式,在出错的情况下,你可以停止代码的执行:message(<MODE> "text")
。
已识别的模式如下:
-
FATAL_ERROR
:这会停止处理和生成。 -
SEND_ERROR
:这会继续处理,但跳过生成。 -
WARNING
:这会继续处理。 -
AUTHOR_WARNING
:CMake 警告。这会继续处理。 -
DEPRECATION
:如果启用了CMAKE_ERROR_DEPRECATED
或CMAKE_WARN_DEPRECATED
变量,这将相应地工作。 -
NOTICE
或省略模式(默认):这会在stderr
上打印一条消息,以吸引用户的注意。 -
STATUS
:这会继续处理,并且建议用于主要用户信息。 -
VERBOSE
:这会继续处理,通常用于不必要太详细的更多信息。 -
DEBUG
:这会继续处理,并且应该包含在项目出现问题时可能有助于解决问题的任何详细信息。 -
TRACE
:这会继续处理,并且在项目开发期间推荐打印消息。通常,这类消息在发布项目之前会被删除。
以下示例在第一条消息后停止执行:
chapter02/10-useful/message_error.cmake
message(FATAL_ERROR "Stop processing")
message("Won't print this.")
这意味着将根据当前日志级别(默认是STATUS
)打印消息。我们在上一章的调试和跟踪选项部分讨论了如何更改此设置。在那部分,我承诺要谈论使用CMAKE_MESSAGE_CONTEXT
进行调试,所以让我们开始吧。从那时起,我们已经了解了这个谜题的三个重要部分:列表、作用域和函数。
当我们启用一个命令行标志,cmake --log-context
,我们的消息将被点分隔的上下文装饰,并存储在CMAKE_MESSAGE_CONTEXT
列表中。考虑以下示例:
chapter02/10-useful/message_context.cmake
function(foo)
list(APPEND CMAKE_MESSAGE_CONTEXT "foo")
message("foo message")
endfunction()
list(APPEND CMAKE_MESSAGE_CONTEXT "top")
message("Before `foo`")
foo()
message("After `foo`")
前面脚本的输出将如下所示:
$ cmake -P message_context.cmake --log-context
[top] Before `foo`
[top.foo] foo message
[top] After `foo`
函数的初始作用域是从父作用域中复制的(父作用域中已经有一个列表项:top
)。foo
中的第一条命令向CMAKE_MESSAGE_CONTEXT
中添加了一个新项,该项带有foo
函数名称。打印消息,函数作用域结束,丢弃本地复制的变量,并恢复之前的范围(不包含foo
)。
这种方法在非常复杂的项目中有很多嵌套函数时非常有用。希望您永远不需要它,但我觉得这是一个非常好的例子,展示了函数作用域在实际中是如何工作的。
message()
的另一个酷炫技巧是向CMAKE_MESSAGE_INDENT
列表中添加缩进(与CMAKE_MESSAGE_CONTEXT
完全相同的方式):
list(APPEND CMAKE_MESSAGE_INDENT " ")
我们脚本的输出可以变得更加整洁:
Before `foo`
foo message
After `foo`
由于 CMake 没有提供任何真正的带有断点或其他工具的调试器,因此在事情并不完全按计划进行时,生成干净的日志消息功能非常方便。
include()
命令
我们可以将 CMake 代码分割到单独的文件中,以保持事物有序,嗯,分离。然后,我们可以通过调用include()
从父列表文件中引用它们,如下例所示:
include(<file|module> [OPTIONAL] [RESULT_VARIABLE <var>])
如果我们提供一个文件名(带有.cmake
扩展名的路径),CMake 将尝试打开并执行它。请注意,不会创建嵌套的独立作用域,因此在该文件中对变量的任何更改都将影响调用作用域。
CMake 如果文件不存在将抛出一个错误,除非我们使用OPTIONAL
关键字指定它是可选的。如果我们需要知道include()
是否成功,我们可以提供带有变量名称的RESULT_VARIABLE
关键字。在成功时,它将填充包含成功包含的文件的完整路径,或在失败时(NOTFOUND
)不包含。
当以脚本模式运行时,任何相对路径都将从当前工作目录解析。要强制在脚本本身的关系中搜索,请提供一个绝对路径:
include("${CMAKE_CURRENT_LIST_DIR}/<filename>.cmake")
如果我们没有提供路径,但是提供了模块的名称(不带.cmake
或其他),CMake 将尝试查找模块并将其包含进来。CMake 将在CMAKE_MODULE_PATH
中搜索名为<模块>.cmake
的文件,然后在其模块目录中搜索。
include_guard()
命令
当我们包含具有副作用的文件时,我们可能希望限制它们,使它们只被包含一次。这就是include_guard([DIRECTORY|GLOBAL])
发挥作用的地方。
将include_guard()
放在被包含文件的顶部。当 CMake 首次遇到它时,它将在当前作用域中记录这一事实。如果文件再次被包含(也许是因为我们无法控制我们项目中的所有文件),它将不再被进一步处理。
如果我们想要防止在不相关的函数作用域中包含,这些作用域不会共享变量,我们应该提供DIRECTORY
或GLOBAL
参数。正如这些名称所暗示的,DIRECTORY
关键字将在当前目录及其子目录内应用保护,而GLOBAL
关键字将对整个构建过程应用保护。
file()
命令
为了让您了解您可以用 CMake 脚本做什么,让我们快速浏览一下文件操作命令的最有用变体:
file(READ <filename> <out-var> [...])
file({WRITE | APPEND} <filename> <content>...)
file(DOWNLOAD <url> [<file>] [...])
简而言之,file()
命令将让您以系统无关的方式读取、写入和传输文件,以及与文件系统、文件锁、路径和存档进行交互。请参阅附录部分以获取更多详细信息。
execute_process()
命令
时不时地,您需要使用系统可用的工具(毕竟,CMake 主要是构建系统生成器)。CMake 为此提供了一个命令:您可以使用execute_process()
来运行其他进程并收集它们的输出。这个命令非常适合脚本,也可以在配置阶段项目中使用。以下是该命令的一般形式:
execute_process(COMMAND <cmd1> [<arguments>]… [OPTIONS])
CMake 将使用操作系统的 API 来创建子进程(因此,像&&
,||
和>
这样的 shell 操作符将不起作用)。然而,您仍然可以通过多次提供COMMAND <命令> <参数>
参数来链接命令,并将一个的输出传递给另一个。
可选地,您可以使用TIMEOUT <秒>
参数来终止进程,如果它没有在规定的时间内完成任务,并且您可以根据需要设置WORKING_DIRECTORY <目录>
。
所有任务的退出代码可以通过提供RESULTS_VARIABLE <变量>
参数来收集在列表中。如果您只对最后执行的命令的结果感兴趣,请使用单数形式:RESULT_VARIABLE <变量>
。
为了收集输出,CMake 提供了两个参数:OUTPUT_VARIABLE
和ERROR_VARIABLE
(这两个参数用法相似)。如果您想合并stdout
和stderr
,请为这两个参数使用同一个变量。
记住,当为其他用户编写项目时,您应该确保您打算使用的命令在您声称支持的平台上是可用的。
总结
本章打开了使用 CMake 进行实际编程的大门——你现在能够编写伟大的、富有信息性的注释和调用内置命令,并理解如何正确地为它们提供各种参数。这个知识本身将帮助您理解 CMake 列表文件中您可能在其他项目中看到的异常语法。
接下来,我们讲解了 CMake 中的变量——具体来说,是如何引用、设置和取消设置普通、缓存和环境变量。我们深入探讨了目录和函数作用域是如何工作的,并讨论了与嵌套作用域相关的问题(及其解决方法)。
我们还讲解了列表和控制结构。我们讨论了条件的语法、它们的逻辑操作、未引用参数的评估以及字符串和变量。我们学习了如何比较值、进行简单检查以及查看系统文件的状态。这使我们能够编写条件块和 while 循环。在谈论循环的时候,我们也掌握了 foreach 循环的语法。
我相信了解如何使用宏和函数语句定义自己的命令将帮助您以更程序化的风格编写更干净的代码。我们还分享了一些关于如何更好地组织代码和提出更易读名称的想法。
最后,我们正式介绍了message()
命令及其多个日志级别。我们还学习了如何分割和包含列表文件,发现了几种其他有用的命令。我相信有了这些材料,我们准备好迎接下一章,并在 CMake 中编写我们的第一个项目。
进一步阅读
关于本章涵盖的主题,您可以参考以下内容:
-
整洁的代码:敏捷软件工艺手册(Robert C. Martin):
amzn.to/3cm69DD
-
重构:改善现有代码的设计(Martin Fowler):
amzn.to/3cmWk8o
-
你的代码中哪些注释是好的?(Rafał Świdzinski):
youtu.be/4t9bpo0THb8
-
CMake 中设置和使用变量的语法是什么?(StackOverflow):
stackoverflow.com/questions/31037882/whats-the-cmake-syntax-to-set-and-use-variables
_DIR}/.cmake")
如果我们没有提供路径,但是提供了模块的名称(不带`.cmake`或其他),CMake 将尝试查找模块并将其包含进来。CMake 将在`CMAKE_MODULE_PATH`中搜索名为`<模块>.cmake`的文件,然后在其模块目录中搜索。
## `include_guard()`命令
当我们包含具有副作用的文件时,我们可能希望限制它们,使它们只被包含一次。这就是`include_guard([DIRECTORY|GLOBAL])`发挥作用的地方。
将`include_guard()`放在被包含文件的顶部。当 CMake 首次遇到它时,它将在当前作用域中记录这一事实。如果文件再次被包含(也许是因为我们无法控制我们项目中的所有文件),它将不再被进一步处理。
如果我们想要防止在不相关的函数作用域中包含,这些作用域不会共享变量,我们应该提供`DIRECTORY`或`GLOBAL`参数。正如这些名称所暗示的,`DIRECTORY`关键字将在当前目录及其子目录内应用保护,而`GLOBAL`关键字将对整个构建过程应用保护。
## `file()`命令
为了让您了解您可以用 CMake 脚本做什么,让我们快速浏览一下文件操作命令的最有用变体:
```cpp
file(READ <filename> <out-var> [...])
file({WRITE | APPEND} <filename> <content>...)
file(DOWNLOAD <url> [<file>] [...])
简而言之,file()
命令将让您以系统无关的方式读取、写入和传输文件,以及与文件系统、文件锁、路径和存档进行交互。请参阅附录部分以获取更多详细信息。
execute_process()
命令
时不时地,您需要使用系统可用的工具(毕竟,CMake 主要是构建系统生成器)。CMake 为此提供了一个命令:您可以使用execute_process()
来运行其他进程并收集它们的输出。这个命令非常适合脚本,也可以在配置阶段项目中使用。以下是该命令的一般形式:
execute_process(COMMAND <cmd1> [<arguments>]… [OPTIONS])
CMake 将使用操作系统的 API 来创建子进程(因此,像&&
,||
和>
这样的 shell 操作符将不起作用)。然而,您仍然可以通过多次提供COMMAND <命令> <参数>
参数来链接命令,并将一个的输出传递给另一个。
可选地,您可以使用TIMEOUT <秒>
参数来终止进程,如果它没有在规定的时间内完成任务,并且您可以根据需要设置WORKING_DIRECTORY <目录>
。
所有任务的退出代码可以通过提供RESULTS_VARIABLE <变量>
参数来收集在列表中。如果您只对最后执行的命令的结果感兴趣,请使用单数形式:RESULT_VARIABLE <变量>
。
为了收集输出,CMake 提供了两个参数:OUTPUT_VARIABLE
和ERROR_VARIABLE
(这两个参数用法相似)。如果您想合并stdout
和stderr
,请为这两个参数使用同一个变量。
记住,当为其他用户编写项目时,您应该确保您打算使用的命令在您声称支持的平台上是可用的。
总结
本章打开了使用 CMake 进行实际编程的大门——你现在能够编写伟大的、富有信息性的注释和调用内置命令,并理解如何正确地为它们提供各种参数。这个知识本身将帮助您理解 CMake 列表文件中您可能在其他项目中看到的异常语法。
接下来,我们讲解了 CMake 中的变量——具体来说,是如何引用、设置和取消设置普通、缓存和环境变量。我们深入探讨了目录和函数作用域是如何工作的,并讨论了与嵌套作用域相关的问题(及其解决方法)。
我们还讲解了列表和控制结构。我们讨论了条件的语法、它们的逻辑操作、未引用参数的评估以及字符串和变量。我们学习了如何比较值、进行简单检查以及查看系统文件的状态。这使我们能够编写条件块和 while 循环。在谈论循环的时候,我们也掌握了 foreach 循环的语法。
我相信了解如何使用宏和函数语句定义自己的命令将帮助您以更程序化的风格编写更干净的代码。我们还分享了一些关于如何更好地组织代码和提出更易读名称的想法。
最后,我们正式介绍了message()
命令及其多个日志级别。我们还学习了如何分割和包含列表文件,发现了几种其他有用的命令。我相信有了这些材料,我们准备好迎接下一章,并在 CMake 中编写我们的第一个项目。
进一步阅读
关于本章涵盖的主题,您可以参考以下内容:
-
整洁的代码:敏捷软件工艺手册(Robert C. Martin):
amzn.to/3cm69DD
-
重构:改善现有代码的设计(Martin Fowler):
amzn.to/3cmWk8o
-
你的代码中哪些注释是好的?(Rafał Świdzinski):
youtu.be/4t9bpo0THb8
-
CMake 中设置和使用变量的语法是什么?(StackOverflow):
stackoverflow.com/questions/31037882/whats-the-cmake-syntax-to-set-and-use-variables