mdn-cmk-cpp-2e-merge-0
面向 C++ 的现代 CMake 第二版(一)
原文:
zh.annas-archive.org/md5/4abd6886e8722cebdc63cd42f86a9282
译者:飞龙
序言
创建一流的软件并非易事。开发人员在网上研究这个话题时,经常难以判断哪些建议是最新的,哪些方法已经被更新、更好的实践所取代。此外,大多数资源以混乱的方式解释过程,缺乏适当的背景、上下文和结构。
现代 CMake for C++ 提供了一个端到端的指南,通过全面处理 C++ 解决方案的构建,提供了更简便的体验。它不仅教你如何在项目中使用 CMake,还突出展示了使项目保持可维护、优雅和清晰的要素。该指南将帮助你自动化许多项目中常见的复杂任务,包括构建、测试和打包。
本书指导你如何组织源目录、构建目标以及创建包。在进阶过程中,你将学会如何编译和链接可执行文件和库,详细了解这些过程,并优化每个步骤以获得最佳结果。此外,你将学会如何将外部依赖项(如第三方库、测试框架、程序分析工具和文档生成工具)集成到项目中。最后,你将学习如何导出、安装和打包你的解决方案,以供内部和外部使用。
完成本书后,你将能够在专业层面上自信地使用 CMake。
本书适合的读者
当你学会了 C++ 后,你会很快发现,仅仅精通语言本身并不足以使你准备好交付最高标准的项目。本书弥补了这一空白:它面向任何希望成为更好的软件开发人员,甚至是专业构建工程师的人!
阅读本书,如果你想从零开始学习现代 CMake,或提升和刷新你当前的 CMake 技能。它将帮助你理解如何制作一流的 C++ 项目,并从其他构建环境过渡过来。
本书内容
第一章,CMake 入门,涵盖了 CMake 的安装、命令行界面的使用,并介绍了 CMake 项目所需的基本构建块。
第二章,CMake 语言,涵盖了 CMake 语言的基本概念,包括命令调用、参数、变量、控制结构和注释。
第三章,在流行 IDE 中使用 CMake,强调了集成开发环境(IDE)的重要性,指导你选择合适的 IDE,并提供 Clion、Visual Studio Code 和 Visual Studio IDE 的设置说明。
第四章,设置你的第一个 CMake 项目,将教你如何在 CMake 项目的顶级文件中进行配置,结构化文件树,并为开发准备必要的工具链。
第五章,与目标一起工作,探讨了逻辑构建目标的概念,理解它们的属性和不同类型,并学习如何为 CMake 项目定义自定义命令。
第六章,使用生成器表达式,解释了生成器表达式的目的和语法,包括如何用于条件扩展、查询和转换。
第七章,使用 CMake 编译 C++ 源代码,深入探讨编译过程,配置预处理器和优化器,并发现减少构建时间和改善调试的技巧。
第八章,链接可执行文件和库,了解链接机制、不同类型的库、唯一定义规则、链接顺序,以及如何为测试准备项目。
第九章,在 CMake 中管理依赖关系,将教你如何管理第三方库,如何为那些缺乏支持的库添加 CMake 支持,并从互联网获取外部依赖项。
第十章,使用 C++20 模块,介绍了 C++20 模块,展示如何在 CMake 中启用其支持,并相应地配置工具链。
第十一章,测试框架,将帮助你理解自动化测试的重要性,利用 CMake 中内置的测试支持,并使用流行框架开始单元测试。
第十二章,程序分析工具,将展示如何在构建时和运行时自动格式化源代码并检测软件错误。
第十三章,生成文档,介绍了如何使用 Doxygen 从源代码自动生成文档,并添加样式以增强文档的外观。
第十四章,安装和打包,为你的项目准备发布,无论是否进行安装,创建可重用的包,并指定单个组件进行打包。
第十五章,创建你的专业项目,将本书中获得的所有知识应用于开发一个全面的、专业级别的项目。
第十六章,编写 CMake 预设,将高级项目配置封装到使用 CMake 预设文件的工作流中,使项目设置和管理更高效。
附录 - 杂项命令,作为参考,提供与字符串、列表、文件和数学运算相关的各种 CMake 命令。
为了充分利用本书的内容
本书假设读者对 C++ 和类 Unix 系统有基本的了解。尽管 Unix 知识不是严格要求,但它会对充分理解本书中的示例有所帮助。
本书针对 CMake 3.26,但大多数技术应该在 CMake 3.15 版本及以上都能使用(新增加的功能通常会突出显示)。一些章节已更新至 CMake 3.28,以涵盖最新的功能。
示例运行环境的准备在第 1-3 章中涵盖,但我们特别建议,如果你熟悉 Docker 工具,可以使用本书提供的 Docker 镜像。
下载示例代码文件
本书的代码包托管在 GitHub 上,地址为github.com/PacktPublishing/Modern-CMake-for-Cpp-2E
。我们还提供了其他来自我们丰富书籍和视频目录的代码包,您可以在github.com/PacktPublishing/
查看。快来看看吧!
下载彩色图像
我们还提供了一个 PDF 文件,其中包含本书中使用的截图/图表的彩色图像。您可以在此下载:packt.link/gbp/9781805121800
。
使用的约定
本书中使用了多种文本约定。
CodeInText
:表示文本中的代码字、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 账户。例如:“将下载的WebStorm-10*.dmg
磁盘镜像文件挂载为系统中的另一个磁盘。”
代码块设置如下:
cmake_minimum_required(VERSION 3.26)
project(Hello)
add_executable(Hello hello.cpp)
当我们希望特别提醒您关注代码块中的某部分时,相关的行或项目将设置为粗体:
cmake_minimum_required(VERSION 3.26)
project(Hello)
add_executable(Hello hello.cpp)
**add_subdirectory****(api)**
任何命令行输入或输出以以下方式编写:
cmake --build <dir> --parallel [<number-of-jobs>]
cmake --build <dir> -j [<number-of-jobs>]
粗体:表示新术语、重要单词或屏幕上显示的单词。例如:“从管理面板中选择系统信息。”
警告或重要注释以如下方式显示。
提示和技巧以如下方式显示。
联系我们
我们始终欢迎读者的反馈。
一般反馈:请发送电子邮件至feedback@packtpub.com
,并在邮件主题中注明书名。如果您有关于本书的任何问题,请通过questions@packtpub.com
与我们联系。
勘误表:虽然我们已尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在本书中发现任何错误,我们将非常感激您能向我们报告。请访问,www.packtpub.com/submit-errata
,选择您的书籍,点击“勘误提交表单”链接,并输入详细信息。
盗版:如果你在互联网上发现任何我们作品的非法复制品,任何形式的,我们将非常感激您能提供该位置地址或网站名称。请通过电子邮件copyright@packtpub.com
与我们联系,并附上相关材料的链接。
如果您有兴趣成为作者:如果您在某个领域具有专业知识,并且有兴趣写作或为书籍贡献内容,请访问authors.packtpub.com
。
分享您的想法
一旦您阅读了现代 CMake for C++,第二版,我们很想听听您的想法!请点击这里直接访问亚马逊的书评页面并分享您的反馈。
您的评论对我们和技术社区非常重要,帮助我们确保提供高质量的内容。
下载本书的免费 PDF 副本
感谢您购买本书!
你喜欢随时随地阅读,但又不能把印刷版书籍带在身边吗?
你的电子书购买是否无法在你选择的设备上使用?
不用担心,现在购买每本 Packt 图书,你都可以免费获得该图书的 DRM-free PDF 版本。
在任何地方、任何设备上阅读。直接从你最喜欢的技术书籍中搜索、复制并粘贴代码到你的应用程序中。
优惠不止这些,你还可以获得独家折扣、新闻通讯以及每日发送到邮箱的精彩免费内容。
按照以下简单步骤获取这些福利:
- 扫描二维码或访问下面的链接:
https://packt.link/free-ebook/9781805121800
-
提交你的购买凭证。
-
就这样!我们会将你的免费 PDF 和其他福利直接发送到你的电子邮件。
第一章:CMake 入门
软件创建有一种神奇的魅力。我们不仅仅是在创建一个能够被激活的工作机制,而且我们还常常在构思解决方案功能背后的想法。
为了将我们的想法付诸实践,我们在以下循环中工作:设计、编码和测试。我们发明变更,用编译器理解的语言表达这些变更,并检查它们是否按预期工作。为了从源代码中创建合适的高质量软件,我们需要仔细执行重复且容易出错的任务:调用正确的命令,检查语法,链接二进制文件,运行测试,报告问题等等。
每次都记住每个步骤是非常费劲的。相反,我们希望专注于实际编码,并将其他所有工作委托给自动化工具。理想情况下,这个过程应该在我们修改代码后,点击一个按钮就开始。它应该是智能的、快速的、可扩展的,并且在不同操作系统和环境中以相同方式工作。它应该得到多个集成开发环境(IDE)的支持。进一步地,我们可以将这个过程流畅地整合进持续集成(CI)流水线,每当提交更改到共享仓库时,自动构建和测试我们的软件。
CMake 是许多此类需求的答案;然而,它需要一些工作来正确配置和使用。CMake 不是复杂性的根源;复杂性来自于我们在这里所处理的主题。别担心,我们会非常有条理地逐步学习这一过程。很快,你就会成为一个软件构建高手。
我知道你迫不及待地想开始编写自己的 CMake 项目,而这正是我们在本书的大部分内容中要做的事情。但因为你将主要为用户(包括你自己)创建项目,所以首先理解用户的视角是非常重要的。
那么,让我们从这里开始:成为一个CMake 高级用户。我们将了解一些基础知识:这个工具是什么,原理上它如何工作,以及如何安装它。然后,我们将深入探讨命令行和操作模式。最后,我们将总结项目中不同文件的用途,并解释如何在不创建项目的情况下使用 CMake。
本章将涵盖以下主要内容:
-
理解基础知识
-
在不同平台上安装 CMake
-
精通命令行
-
导航项目文件
-
发现脚本和模块
技术要求
你可以在 GitHub 上找到本章中提供的代码文件,地址是 github.com/PacktPublishing/Modern-CMake-for-Cpp-2E/tree/main/examples/ch01
。
要构建本书提供的示例,请始终执行所有推荐的命令:
cmake -B <build tree> -S <source tree>
cmake --build <build tree>
一定要将占位符<build tree>
和<source tree>
替换为适当的路径。正如本章所述,build tree是输出目录的路径,而source tree是源代码所在的路径。
为了构建 C++程序,你还需要一个适合你平台的编译器。如果你熟悉 Docker,你可以使用在不同平台上安装 CMake一节中介绍的完全集成的镜像。如果你更倾向于手动设置 CMake,我们将在同一节中解释安装过程。
理解基础知识
C++源代码的编译似乎是一个相当简单的过程。让我们从经典的 Hello World 示例开始。
以下代码位于ch01/01-hello/hello.cpp
中,C++语言中的 Hello World:
#include <iostream>
int main() {
std::cout << "Hello World!" << std::endl;
return 0;
}
为了生成可执行文件,我们当然需要一个 C++编译器。CMake 本身不附带编译器,因此你需要自行选择并安装一个。常见的选择包括:
-
Microsoft Visual C++ 编译器
-
GNU 编译器集合
-
Clang/LLVM
大多数读者都对编译器非常熟悉,因为它是学习 C++时不可或缺的部分,所以我们不会详细介绍如何选择和安装编译器。本书中的示例将使用 GNU GCC,因为它是一个成熟的、开源的、可免费在多个平台上使用的软件编译器。
假设我们已经安装了编译器,对于大多数供应商和系统,运行它的方式类似。我们应该将文件名作为参数传递给它:
$ g++ hello.cpp -o hello
代码是正确的,因此编译器将默默地生成一个可执行的二进制文件,供我们的机器理解。我们可以通过调用文件名来运行它:
$ ./hello
Hello World!
运行一个命令来构建程序很简单;然而,随着项目的增长,你会很快明白,所有东西都保存在一个文件中是不可能的。清洁代码实践建议源代码文件应保持简短,并且结构要井井有条。手动编译每个文件可能是一个乏味且容易出错的过程。一定有更好的方法。
什么是 CMake?
假设我们通过编写一个脚本来自动化构建,该脚本遍历我们的项目树并编译所有内容。为了避免不必要的编译,脚本将检测自上次运行以来源代码是否已被修改。现在,我们希望有一种方便的方式来管理每个文件传递给编译器的参数——最好是基于可配置的标准来处理。此外,我们的脚本应当知道如何将所有已编译的文件链接成一个单一的二进制文件,或者更好的是,构建可以重用的完整解决方案,并将其作为模块集成到更大的项目中。
构建软件是一个非常多样化的过程,涵盖了多个不同的方面:
-
编译可执行文件和库
-
管理依赖关系
-
测试
-
安装
-
打包
-
生成文档
-
再做一些测试
创建一个真正模块化且强大的 C++ 构建工具以适应所有需求将需要非常长的时间。事实证明,它做到了。Bill Hoffman 在 Kitware 实现了 CMake 的第一个版本,已经有 20 多年的历史了。正如你可能已经猜到的,它非常成功。如今,它拥有众多功能并得到了社区的广泛支持。CMake 正在积极开发,并已成为 C 和 C++ 程序员的行业标准。
自动化构建代码的问题早于 CMake 的诞生,所以自然,市面上有很多选择:GNU 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 首先检查项目是否已配置过,并从 CMakeCache.txt
文件中读取缓存的配置变量。在第一次运行时,情况并非如此,因此它会创建一个空的构建树,并收集关于它所处环境的所有细节:例如,架构是什么,哪些编译器可用,已安装了哪些链接器和归档工具。此外,它还会检查是否能正确编译一个简单的测试程序。
接下来,解析并执行 CMakeLists.txt
项目配置文件(是的,CMake 项目是用 CMake 的编程语言配置的)。这个文件是一个 CMake 项目的最基本形式(源文件可以稍后添加)。它告诉 CMake 项目的结构、目标及其依赖项(库和其他 CMake 包)。
在此过程中,CMake 将收集的信息存储在构建树中,例如系统细节、项目配置、日志和临时文件,这些信息将在下一步骤中使用。具体来说,CMake 会创建一个 CMakeCache.txt
文件,用于存储更稳定的信息(例如编译器和其他工具的路径),这样当整个构建过程重新执行时,可以节省时间。
生成阶段
在读取项目配置后,CMake 将为其所处的具体环境生成一个 构建系统。构建系统实际上就是为其他构建工具(例如,GNU Make 或 Ninja 的 Makefile,以及 Visual Studio 的 IDE 项目文件)量身定制的配置文件。在这个阶段,CMake 还可以通过评估 生成器表达式 对构建配置进行一些最后的调整。
生成阶段会在配置阶段之后自动执行。因此,本书及其他资源有时在提到“构建系统的配置”或“生成”时会将这两个阶段互换使用。要明确仅运行配置阶段,可以使用 cmake-gui
工具。
构建阶段
为了生成项目中指定的最终产物(如可执行文件和库),CMake 需要运行适当的 构建工具。这可以通过直接调用、通过 IDE 或使用适当的 CMake 命令来实现。这些构建工具将执行步骤,通过编译器、链接器、静态和动态分析工具、测试框架、报告工具以及你能想到的其他任何工具来生成 目标产物。
这个解决方案的优势在于,它能够通过单一配置(即相同的项目文件)按需为每个平台生成构建系统:
图 1.1:CMake 的各个阶段
你还记得我们在 理解基础 部分提到的 hello.cpp
应用程序吗?用 CMake 构建它非常简单。我们只需要在与源文件相同的目录中放置以下 CMakeLists.txt
文件。
ch01/01-hello/CMakeLists.txt
cmake_minimum_required(VERSION 3.26)
project(Hello)
add_executable(Hello hello.cpp)
创建该文件后,在相同目录下执行以下命令:
cmake -B <build tree>
cmake --build <build tree>
请注意,<build tree>
是一个占位符,应该替换为存放生成文件的临时目录的路径。
这是在 Docker 中运行的 Ubuntu 系统的输出(Docker 是一种可以在其他系统内运行的虚拟机;我们将在在不同平台上安装 CMake一节中讨论它)。第一个命令生成一个构建系统:
~/examples/ch01/01-hello# cmake -B ~/build_tree
-- The C compiler identification is GNU 11.3.0
-- The CXX compiler identification is GNU 11.3.0
-- Detecting C compiler ABI info
-- Detecting C compiler ABI info - done
-- Check for working C compiler: /usr/bin/cc - skipped
-- Detecting C compile features
-- Detecting C compile features - done
-- 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
-- Configuring done (1.0s)
-- Generating done (0.1s)
-- Build files have been written to: /root/build_tree
第二个命令实际上是构建项目:
~/examples/ch01/01-hello# cmake --build ~/build_tree
Scanning dependencies of target Hello
[ 50%] Building CXX object CMakeFiles/Hello.dir/hello.cpp.o
[100%] Linking CXX executable Hello
[100%] Built target Hello
剩下的就是运行已编译的程序:
~/examples/ch01/01-hello# ~/build_tree/Hello
Hello World!
在这里,我们已经生成了一个存储在构建树目录中的构建系统。接下来,我们执行了构建阶段并生成了一个可以运行的最终二进制文件。
现在你知道结果是什么样子了,我相信你一定有很多问题:这个过程的前提条件是什么?这些命令是什么意思?为什么需要两个命令?我该如何编写自己的项目文件?别担心——这些问题将在接下来的章节中得到解答。
本书将为您提供与当前版本的 CMake 相关的最重要信息(截至写作时,版本为 3.26)。为了给您提供最佳建议,我特别避免了任何已弃用或不再推荐的功能,并且强烈建议至少使用 CMake 版本 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/cmake2:base
:一个基于 Ubuntu 的镜像,包含构建 CMake 所需的工具和依赖项 -
swidzinski/cmake2:examples
:基于前述工具链的镜像,包含本书中的所有项目和示例
第一个选项适用于那些仅仅想要一个干净的镜像,准备构建自己项目的读者,第二个选项适用于在我们通过章节时进行动手实践并使用示例的读者。
你可以按照其官方文档中的说明安装 Docker(请参阅 docs.docker.com/get-docker)。然后,在终端中执行以下命令来下载镜像并启动容器:
$ docker pull swidzinski/cmake2:examples
$ docker run -it swidzinski/cmake2:examples
root@b55e271a85b2:root@b55e271a85b2:#
请注意,示例位于匹配此格式的目录中:
devuser/examples/examples/ch<N>/<M>-<title>
在这里,<N>
和 <M>
分别是零填充的章节和示例编号(如 01
、08
和 12
)。
Windows
在 Windows 上安装非常简单——只需从官方网站下载 32 位或 64 位的版本。你也可以选择便携式 ZIP 或 MSI 包,使用 Windows 安装程序,它将把 CMake 的 bin
目录添加到 PATH
环境变量中(图 1.2),这样你就可以在任何目录中使用它,而不会出现类似的错误:
cmake
未被识别为内部或外部命令、可操作程序或批处理文件。
如果你选择 ZIP 包,你需要手动完成安装。MSI 安装程序带有一个方便的图形用户界面:
图 1.2:安装向导可以为你设置 PATH 环境变量
正如我之前提到的,这是开源软件,因此你可以自行构建 CMake。然而,在 Windows 上,你首先需要在系统上获取 CMake 的二进制版本。这种情况是 CMake 贡献者用来生成新版本的方式。
Windows 平台与其他平台没有区别,也需要一个可以完成 CMake 启动的构建工具。一个流行的选择是 Visual Studio IDE,它捆绑了一个 C++编译器。社区版可以从 Microsoft 官网免费下载:visualstudio.microsoft.com/downloads/
。
Linux
在 Linux 上安装 CMake 与其他流行软件包的安装过程相同:从命令行调用包管理器。包仓库通常会保持更新,提供相对较新的 CMake 版本,但通常不会是最新版本。如果您对此没有异议,并且使用的是 Debian 或 Ubuntu 等发行版,那么最简单的做法就是直接安装适当的包:
$ sudo apt-get install cmake
对于 Red Hat 发行版,请使用以下命令:
$ yum install cmake
请注意,在安装包时,包管理器将从为您的操作系统配置的仓库中获取最新版本。在许多情况下,包仓库并不提供最新版本,而是提供一个经过时间验证的稳定版本,这些版本通常能够可靠地运行。根据您的需求选择,但请注意,旧版本不会具备本书中描述的所有功能。
要获取最新版本,请参考 CMake 官方网站的下载部分。如果您知道当前版本号,可以使用以下命令之一。
Linux x86_64 的命令是:
$ VER=3.26.0 && wget https://github.com/Kitware/CMake/releases/download/v$VER/cmake-$VER-linux-x86_64.sh && chmod +x cmake-$VER-linux-x86_64.sh && ./cmake-$VER-linux-x86_64.sh
Linux AArch64 的命令是:
$ VER=3.26.0 && wget https://github.com/Kitware/CMake/releases/download/v$VER/cmake-$VER-Linux-aarch64.sh && chmod +x cmake-$VER-Linux-aarch64.sh && ./cmake-$VER-Linux-aarch64.sh
或者,查看从源代码构建部分,学习如何在您的平台上自行编译 CMake。
macOS
这个平台也得到了 CMake 开发者的强力支持。最常见的安装选择是通过 MacPorts,使用以下命令:
$ sudo port install cmake
请注意,在写作时,MacPorts 中提供的最新版本是 3.24.4。要获取最新版本,请安装cmake-devel
包:
$ sudo port install cmake-devel
或者,您可以使用 Homebrew 包管理器:
$ brew install cmake
macOS 的包管理器将涵盖所有必要步骤,但请注意,除非您从源代码构建,否则您可能无法获得最新版本。
从源代码构建
如果您使用的是其他平台,或者只是想体验尚未发布的最新版本(或未被您喜欢的包仓库采用),请从官方网站下载源代码并自行编译:
$ wget https://github.com/Kitware/CMake/releases/download/v3.26.0/cmake-3.26.0.tar.gz
$ tar xzf cmake-3.26.0.tar.gz
$ cd cmake-3.26.0
$ ./bootstrap
$ make
$ make install
从源代码构建相对较慢,并且需要更多步骤。然而,只有通过这种方式,您才能自由选择任何版本的 CMake。这对于操作系统仓库中提供的包过时的情况尤其有用:系统版本越老,更新的频率越低。
现在我们已经安装了 CMake,接下来让我们学习如何使用它!
精通命令行
本书的大部分内容将教你如何为用户准备 CMake 项目。为了满足用户的需求,我们需要深入了解用户在不同场景下如何与 CMake 进行交互。这样,你可以测试你的项目文件,并确保它们正常工作。
CMake 是一套工具,包含五个可执行文件:
-
cmake
:配置、生成和构建项目的主要可执行文件 -
ctest
:用于运行并报告测试结果的测试驱动程序 -
cpack
:用于生成安装程序和源代码包的打包程序 -
cmake-gui
:cmake
的图形界面包装器 -
ccmake
:cmake
的基于控制台的 GUI 包装器
此外,CMake 的背后公司 Kitware 还提供了一个名为 CDash 的独立工具,用于对我们项目的构建健康状态进行高级监控。
CMake 命令行
cmake
是 CMake 套件的主要二进制文件,并提供几种操作模式(有时也称为动作):
-
生成项目构建系统
-
构建项目
-
安装项目
-
运行脚本
-
运行命令行工具
-
运行工作流预设
-
获取帮助
让我们看看它们是如何工作的。
生成项目构建系统
构建我们项目所需的第一步是生成构建系统。以下是三种执行 CMake 生成项目构建系统 操作的命令形式:
cmake [<options>] -S <source tree> -B <build tree>
cmake [<options>] <source tree>
cmake [<options>] <build tree>
我们将在接下来的章节中讨论可用的 <options>
。现在,让我们专注于选择正确的命令形式。CMake 的一个重要特点是支持 源代码外构建 或支持将 构建产物 存储在与源代码树不同的目录中。这是一种推荐的做法,可以保持源代码目录的干净,避免将意外的文件或忽略指令污染 版本控制系统(VCSs)。
这就是为什么第一种命令形式是最实用的原因。它允许我们分别使用 -S
和 -B
来指定源代码树的路径和生成的构建系统路径:
cmake -S ./project -B ./build
CMake 将从 ./project
目录读取项目文件,并在 ./build
目录中生成构建系统(如有需要,事先创建该目录)。
我们可以省略一个参数,cmake
会“猜测”我们打算使用当前目录。注意,省略两个参数将产生 源代码内构建,并将 构建产物 与源代码文件一起存储,这是我们不希望发生的。
运行 CMAKE 时要明确
不要使用 cmake <directory>
命令的第二种或第三种形式,因为它们可能会产生一个杂乱的 源代码内构建。在 第四章,设置你的第一个 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.
示例
使用上一级目录中的源代码在当前目录中生成构建树:
cmake -S ..
使用当前目录中的源代码在 ./build
目录中生成构建树:
cmake -B build
选择生成器
如前所述,在生成阶段,你可以指定一些选项。选择和配置生成器决定了在后续的构建项目部分中,系统将使用哪个构建工具,构建文件的样子,以及构建树的结构。
那么,你应该在意吗?幸运的是,答案通常是“否”。CMake 确实支持多个本地构建系统在许多平台上的使用;然而,除非你同时安装了几个生成器,否则 CMake 会为你正确地选择一个。这个选择可以通过CMAKE_GENERATOR
环境变量或直接在命令行中指定生成器来覆盖,例如:
cmake -G <generator name> -S <source tree> -B <build tree>
一些生成器(例如 Visual Studio)支持对工具集(编译器)和平台(编译器或 SDK)进行更深入的指定。此外,CMake 会扫描那些覆盖默认值的环境变量:CMAKE_GENERATOR_TOOLSET
和CMAKE_GENERATOR_PLATFORM
。另外,这些值也可以在命令行中直接指定:
cmake -G <generator name>
-T <toolset spec>
-A <platform name>
-S <source tree> -B <build tree>
Windows 用户通常希望为他们喜欢的 IDE 生成构建系统。在 Linux 和 macOS 上,使用Unix Makefiles或Ninja生成器非常常见。
要检查你的系统上可用的生成器,请使用以下命令:
cmake --help
在help
输出的末尾,你将得到一个完整的生成器列表,例如在 Windows 10 上生成的列表(部分输出已被截断以提高可读性):
此平台上可用的生成器如下:
Visual Studio 17 2022
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 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 支持许多不同的生成器和 IDE。
管理项目缓存
CMake 在配置阶段会查询系统的各种信息。由于这些操作可能需要一些时间,因此收集到的信息会缓存到构建树目录中的CMakeCache.txt
文件中。有一些命令行选项可以更方便地管理缓存的行为。
我们可以使用的第一个选项是能够预填充缓存信息:
cmake -C <initial cache script> -S <source tree> -B <build tree>
我们可以提供一个 CMake 列表文件的路径,该文件(仅)包含一个set()
命令列表,用于指定将用于初始化一个空构建树的变量。我们将在下一章讨论编写列表文件。
初始化和修改现有的缓存变量可以通过另一种方式进行(例如,当创建一个文件仅仅是为了设置几个变量时,可能有些过于繁琐)。你可以在命令行中直接设置它们,如下所示:
cmake -D <var>[:<type>]=<value> -S <source tree> -B <build tree>
:<type>
部分是可选的(它由 GUI 使用),并接受以下类型:BOOL
、FILEPATH
、PATH
、STRING
或INTERNAL
。如果你省略类型,CMake 会检查变量是否存在于CMakeCache.txt
文件中并使用其类型;否则,它将被设置为UNINITIALIZED
。
一个特别重要的变量是我们通常通过命令行设置的,它指定了构建类型(CMAKE_BUILD_TYPE
)。大多数 CMake 项目将在多个场合使用它来决定诊断信息的详细程度、调试信息的存在与否,以及创建的工件的优化级别。
对于单配置生成器(如 GNU Make 和 Ninja),你应该在配置阶段指定构建类型,并为每种配置类型生成一个单独的构建树。这里使用的值有Debug
、Release
、MinSizeRel
或RelWithDebInfo
。如果缺少此信息,可能会对依赖它进行配置的项目产生未定义的影响。
这是一个例子:
cmake -S . -B ../build -D CMAKE_BUILD_TYPE=Release
请注意,多配置生成器是在构建阶段配置的。
出于诊断目的,我们还可以使用-L
选项列出缓存变量:
cmake -L -S <source tree> -B <build tree>
有时,项目作者可能会提供有用的帮助信息与变量一起显示——要打印它们,请添加H
修饰符:
cmake -LH -S <source tree> -B <build tree>
cmake -LAH -S <source tree> -B <build tree>
令人惊讶的是,手动使用-D
选项添加的自定义变量,除非你指定支持的类型之一,否则在此打印输出中不可见。
可以使用以下选项来移除一个或多个变量:
cmake -U <globbing_expr> -S <source tree> -B <build tree>
在这里,通配符表达式支持*
(通配符)和?
(任意字符)符号。使用这些符号时要小心,因为很容易删除比预期更多的变量。
-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 <source tree>
我们将在第二章,《CMake 语言》中更详细地讨论命名上下文和日志命令。
如果其他方法都失败了,我们需要使用“重磅武器”,那么总有追踪模式,它会打印出每个执行的命令,包含文件名、调用所在的行号以及传递的参数列表。你可以通过以下方式启用它:
cmake --trace
正如你所想象的那样,它不推荐用于日常使用,因为输出内容非常长。
配置预设
用户可以指定许多选项来生成项目的构建树。在处理构建树路径、生成器、缓存和环境变量时,很容易感到困惑或遗漏某些内容。开发者可以简化用户与项目的交互,并提供一个 CMakePresets.json
文件,指定一些默认设置。
要列出所有可用的预设,执行以下命令:
cmake --list-presets
你可以使用以下提供的预设之一:
cmake --preset=<preset> -S <source> -B <build tree>
要了解更多,请参阅本章的导航项目文件部分和第十六章,编写 CMake 预设。
清理构建树
有时,我们可能需要删除生成的文件。这可能是由于构建之间环境发生了变化,或者仅仅是为了确保我们在干净的状态下工作。我们可以手动删除构建树目录,或者仅将 --fresh
参数添加到命令行中:
cmake --fresh -S <source tree> -B <build tree>
CMake 然后会以系统无关的方式删除 CMakeCache.txt
和 CMakeFiles/
,并从头开始生成构建系统。
构建一个项目
在生成构建树后,我们可以开始构建项目。CMake 不仅知道如何为许多不同的构建器生成输入文件,还可以根据项目的需求为它们运行,并提供适当的参数。
避免直接调用 MAKE
许多在线资源推荐在生成阶段之后直接通过调用 make
命令来运行 GNU Make。因为 GNU Make 是 Linux 和 macOS 的默认生成器,所以这个建议是有效的。然而,建议使用本节中描述的方法,因为它与生成器无关,并且在所有平台上都得到官方支持。因此,你不需要担心每个应用程序用户的确切环境。
构建模式的语法是:
cmake --build <build tree> [<options>] [-- <build-tool-options>]
在大多数情况下,只需要提供最低限度的设置即可成功构建:
cmake --build <build tree>
唯一必需的参数是生成的构建树的路径。这与在生成阶段通过 -B
参数传递的路径相同。
CMake 允许你指定适用于所有构建器的关键构建参数。如果你需要向所选的本地构建器传递特殊参数,可以将它们添加到命令末尾,在 --
标记之后:
cmake --build <build tree> -- <build tool options>
让我们看看还有哪些其他选项可用。
运行并行构建
默认情况下,许多构建工具会使用多个并发进程来利用现代处理器并并行编译源代码。构建工具了解项目依赖关系的结构,因此它们可以同时处理满足依赖关系的步骤,从而节省用户时间。
如果你希望在多核机器上更快地构建(或为了调试强制进行单线程构建),你可能想要覆盖该设置。
只需通过以下任一选项指定作业的数量:
cmake --build <build tree> --parallel [<number of jobs>]
cmake --build <build tree> -j [<number of jobs>]
另一种方法是通过CMAKE_BUILD_PARALLEL_LEVEL
环境变量进行设置。命令行选项将覆盖这个变量。
选择构建和清理的目标
每个项目由一个或多个部分组成,这些部分被称为目标(我们将在本书的第二部分讨论这些)。通常,我们希望构建所有可用的目标;然而,有时我们可能希望跳过某些目标,或者明确构建一个故意排除在正常构建之外的目标。我们可以通过以下方式做到这一点:
cmake --build <build tree> --target <target1> --target <target2> …
我们可以通过重复使用–target
参数来指定多个构建目标。此外,还有一个简写版本-t <target>
,可以用来代替。
清理构建树
一个特殊的目标是clean
,通常不会被构建。构建它的特殊效果是从构建目录中删除所有产物,这样以后可以重新从头开始创建。你可以通过以下方式开始这个过程:
cmake --build <build tree> -t clean
此外,如果你希望先清理然后再执行正常构建,CMake 还提供了一个便捷的别名:
cmake --build <build tree> --clean-first
这个操作与清理构建树部分中提到的清理不同,它只影响目标产物,而不会影响其他内容(如缓存)。
为多配置生成器配置构建类型
所以,我们已经了解了一些关于生成器的信息:它们有不同的形状和大小。其中一些生成器支持在一个构建树中构建Debug
和Release
这两种构建类型。支持这一功能的生成器包括 Ninja Multi-Config、Xcode 和 Visual Studio。其他生成器都是单配置生成器,它们需要为每个想要构建的配置类型提供单独的构建树。
选择Debug
、Release
、MinSizeRel
或RelWithDebInfo
并按以下方式指定:
cmake --build <build tree> --config <cfg>
否则,CMake 将使用Debug
作为默认设置。
调试构建过程
当出现问题时,我们首先应该检查输出信息。然而,经验丰富的开发者知道,始终打印所有细节会让人困惑,所以它们通常默认隐藏这些信息。当我们需要深入查看时,可以通过告诉 CMake 启用详细模式来获得更详细的日志:
cmake --build <build tree> --verbose
cmake --build <build tree> -v
同样的效果可以通过设置CMAKE_VERBOSE_MAKEFILE
缓存变量来实现。
安装项目
当构建产物生成后,用户可以将它们安装到系统中。通常,这意味着将文件复制到正确的目录,安装库,或执行某些来自 CMake 脚本的自定义安装逻辑。
安装模式的语法是:
cmake --install <build tree> [<options>]
与其他操作模式一样,CMake 需要一个生成的构建树的路径:
cmake --install <build tree>
安装操作还有很多其他附加选项。让我们看看它们能做些什么。
选择安装目录
我们可以通过添加我们选择的前缀来预先添加安装路径(例如,当我们对某些目录的写入权限有限时)。以/home/user
为前缀的/usr/local
路径变成/home/user/usr/local
。
该选项的签名如下:
cmake --install <build tree> --install-prefix <prefix>
如果你使用的是 CMake 3.21 或更早版本,你将需要使用一个不太明确的选项:
cmake --install <build tree> --prefix <prefix>
请注意,这在 Windows 上无法使用,因为该平台上的路径通常以驱动器字母开头。
针对多配置生成器的安装
就像在构建阶段一样,我们可以指定要用于安装的构建类型(更多细节请参见构建项目部分)。可用的类型包括Debug
、Release
、MinSizeRel
和RelWithDebInfo
。其签名如下:
cmake --install <build tree> --config <cfg>
选择要安装的组件
作为开发人员,你可能选择将项目拆分为可以独立安装的组件。我们将在第十四章 安装和打包中进一步讨论组件的概念。现在,我们假设它们表示一些不需要在每种情况下都使用的工件集。这可能是像application
、docs
和extra-tools
之类的东西。
要安装单个组件,使用以下选项:
cmake --install <build tree> --component <component>
设置文件权限
如果安装是在类似 Unix 的平台上进行的,你可以使用以下选项指定已安装目录的默认权限,格式为u=rwx,g=rx,o=rx
:
cmake --install <build tree>
--default-directory-permissions <permissions>
调试安装过程
类似于构建阶段,我们还可以选择查看安装阶段的详细输出。为此,请使用以下任一方法:
cmake --install <build tree> --verbose
cmake --install <build tree> -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.26 支持以下命令:capabilities
,cat
,chdir
,compare_files
,copy
,copy_directory
,copy_directory_if_different
,copy_if_different
,echo
,echo_append
,env
,environment
,make_directory
,md5sum
,sha1sum
,sha224sum
,sha256sum
,sha384sum
,sha512sum
,remove
,remove_directory
,rename
,rm
,sleep
,tar
,time
,touch
,touch_nocreate
,create_symlink
,create_hardlink
,true
和 false
。
如果你想使用的命令缺失,或者你需要更复杂的行为,可以考虑将其包装在脚本中并以 -P
模式运行。
运行预设工作流
我们在 它是如何工作的? 部分中提到,使用 CMake 构建有三个阶段:配置、生成和构建。此外,我们还可以通过 CMake 运行自动化测试,甚至创建可重新分发的包。通常,用户需要通过命令行手动执行每个步骤,通过调用适当的 cmake
操作。然而,高级项目可以指定 工作流预设,将多个步骤捆绑为一个操作,只需一个命令就能执行。现在,我们只提到,用户可以通过运行以下命令来获取可用预设的列表:
cmake ––workflow --list-presets
它们可以通过以下方式执行预设的工作流:
cmake --workflow --preset <name>
这一部分将在 第十六章,编写 CMake 预设 中深入讲解。
获取帮助
不足为奇的是,CMake 提供了大量的帮助,可以通过其命令行访问。帮助模式的语法如下:
cmake --help
这将打印出可供深入探索的主题列表,并解释需要添加哪些参数才能获得更多帮助。
CTest 命令行
自动化测试对于生成和维护高质量代码非常重要。CMake 套件带有一个专门的命令行工具,名为 CTest,专门用于此目的。它旨在标准化测试的运行和报告方式。作为 CMake 用户,你不需要了解测试这个特定项目的细节:使用了什么框架或如何运行它。CTest 提供了一个便捷的界面,可以列出、筛选、打乱、重试和时间限制测试运行。
要运行已构建项目的测试,我们只需在生成的构建树中调用 ctest
:
$ ctest
Test project /tmp/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 包、RPM 等。
CPack 的工作方式与 CMake 非常相似:它使用 CMake 语言进行配置,并且有许多 包生成器 可供选择(不要与 CMake 构建系统生成器混淆)。我们将在 第十四章《安装与打包》中详细介绍它,因为这个工具是为成熟的 CMake 项目使用的。
CMake GUI
CMake for Windows 带有一个 GUI 版本,用于配置之前准备好的项目的构建过程。对于类 Unix 平台,它有一个使用 Qt 库构建的版本。Ubuntu 在 cmake-qt-gui
包中提供了它。
要访问 CMake GUI,请运行 cmake-gui
可执行文件:
图 1.3:CMake GUI——使用 Visual Studio 2019 生成器的构建系统的配置阶段
GUI 应用程序是为你的应用程序用户提供的便利:对于那些不熟悉命令行并且更喜欢图形界面的用户来说,它非常有用。
使用命令行工具代替
我绝对推荐终端用户使用 GUI,但对于像你这样的程序员,我建议避免任何手动阻塞步骤,因为每次构建程序时都需要点击表单。这在成熟的项目中尤其有利,其中整个构建过程可以在没有任何用户交互的情况下完全执行。
CCMake 命令行
ccmake
可执行文件是 CMake 在类 Unix 平台上的交互式文本用户界面(如果没有明确构建,它在 Windows 上不可用)。我在这里提到它是为了让你知道当你看到它时会是什么(图 1.4),但和 GUI 一样,开发人员直接编辑 CMakeCache.txt
文件会更有利。
图 1.4:ccmake 中的配置阶段
说完这些,我们已经完成了 CMake 套件命令行的基本介绍。现在是时候了解一个典型的 CMake 项目的结构了。
导航项目目录和文件
CMake 项目由相当多的文件和目录组成。让我们大致了解每个文件的作用,以便我们可以开始修改它们。它们大致可以分为几类:
-
当然,我们会有一些项目文件,这些文件是我们作为开发人员在项目发展过程中准备和修改的。
-
还有一些文件是 CMake 为自己生成的,尽管它们包含 CMake 语言命令,但并不是供开发人员编辑的。任何手动更改都会被 CMake 覆盖。
-
有些文件是为高级用户(即:非项目开发人员)设计的,用来根据个人需求定制 CMake 构建项目的方式。
-
最后,还有一些临时文件,在特定上下文中提供有价值的信息。
本节还将建议你可以将哪些文件放入版本控制系统(VCS)的忽略文件中。
源代码树
这是你的项目所在的目录(也称为项目根目录)。它包含所有 C++源代码和 CMake 项目文件。
以下是该目录中的最重要要点:
-
它需要一个
CMakeLists.txt
配置文件。 -
这个目录的路径由用户在使用
cmake
命令生成构建系统时通过-S
参数指定。 -
避免在 CMake 代码中硬编码任何指向源代码树的绝对路径——你的软件用户会将项目存储在不同的路径中。
在这个目录中初始化一个版本库是个好主意,可以使用像Git
这样的 VCS。
构建树
CMake 在用户指定的路径中创建此目录。它将存储构建系统和构建过程中创建的所有内容:项目的构建产物、临时配置、缓存、构建日志以及本地构建工具(如 GNU Make)的输出。此目录的其他名称包括构建根目录和二进制树。
记住的关键点:
-
你的构建配置(构建系统)和构建产物将被创建在这里(例如二进制文件、可执行文件、库文件,以及用于最终链接的目标文件和归档文件)。
-
CMake 建议将该目录放置在源代码树目录之外(这种做法称为源外构建)。这样,我们可以防止项目的污染(源内构建)。
-
它通过
-B
参数在生成构建系统时指定给cmake
命令。 -
这个目录并不是生成文件的最终目的地。相反,建议你的项目包含一个安装阶段,将最终的构件复制到系统中应有的位置,并删除所有用于构建的临时文件。
不要将这个目录添加到版本控制系统(VCS)中——每个用户会为自己选择一个目录。如果你有充分的理由进行源代码内构建,请确保将这个目录添加到 VCS 忽略文件中(例如.gitignore
)。
列表文件
包含 CMake 语言的文件称为列表文件,可以通过调用include()
和find_package()
来互相包含,或者通过add_subdirectory()
间接包含。CMake 并不强制规定这些文件的命名规则,但根据惯例,它们的扩展名是.cmake
。
项目文件
CMake 项目使用CMakeLists.txt
列表文件进行配置(注意,由于历史原因,这个文件的扩展名不常见)。该文件是每个项目源代码树顶端必须存在的文件,并且是配置阶段首先执行的文件。
顶级的CMakeLists.txt
应该包含至少两个命令:
-
cmake_minimum_required(VERSION <x.xx>)
:设置 CMake 的期望版本,并告诉 CMake 如何处理与策略相关的遗留行为。 -
project(<name> <OPTIONS>)
:命名项目(提供的名称将存储在PROJECT_NAME
变量中),并指定配置项目的选项(更多内容请参见第二章,CMake 语言)。
随着软件的成长,你可能希望将其划分为可以单独配置和推理的小单元。CMake 通过引入具有独立CMakeLists.txt
文件的子目录来支持这一点。你的项目结构可能类似于以下示例:
myProject/CMakeLists.txt
myProject/api/CMakeLists.txt
myProject/api/api.h
myProject/api/api.cpp
一个非常简单的顶层CMakeLists.txt
文件可以将所有内容整合在一起:
cmake_minimum_required(VERSION 3.26)
project(app)
message("Top level CMakeLists.txt")
add_subdirectory(api)
项目的主要方面在顶层文件中得到涵盖:管理依赖项、声明需求以及检测环境。我们还有一个add_subdirectory(api)
命令,用来包含api
子目录中的另一个CMakeLists.txt
文件,以执行特定于应用程序 API 部分的步骤。
缓存文件
缓存变量将在配置阶段第一次运行时从列表文件生成,并存储在CMakeCache.txt
中。该文件位于构建树的根目录,格式非常简单(为了简洁,省略了一些行):
# This is the CMakeCache file.
# For build in directory: /root/build tree
# It was generated by CMake: /usr/local/bin/cmake
# 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 宏和函数。
-
配置文件命名为
<PackageName>-config.cmake
或<PackageName>Config.cmake
。 -
使用
find_package()
命令来包含包。
如果需要特定版本的包,CMake 将会检查关联的<PackageName>-config-version.cmake
或<PackageName>ConfigVersion.cmake
。
如果供应商没有为包提供配置文件,有时配置会与 CMake 本身捆绑,或者可以通过项目中的Find-module(原始拼写)提供。
生成的文件
许多文件是由cmake
可执行文件在生成阶段生成的。因此,它们不应手动编辑。CMake 将它们用作cmake
安装操作、CTest 和 CPack 的配置。
可能会遇到的文件包括:
-
cmake_install.cmake
-
CTestTestfile.cmake
-
CPackConfig.cmake
如果你正在实现源代码构建,最好将它们添加到 VCS 忽略文件中。
JSON 和 YAML 文件
CMake 使用的其他格式包括JavaScript 对象表示法(JSON)和另一种标记语言(YAML)。这些文件作为与外部工具(如 IDE)进行通信的接口,或者提供可以轻松生成和解析的配置。
预设文件
项目的高级配置在需要指定诸如缓存变量、选择的生成器、构建树的路径等事项时,可能变得相对繁琐,特别是当我们有多种方式构建项目时。这时预设(presets)就派上用场了——我们可以通过提供一个存储所有详细信息的文件,而不是通过命令行手动配置这些值,并将其与项目一起发布。自 CMake 3.25 起,预设还允许我们配置工作流,将各个阶段(配置、构建、测试和打包)绑定为一个命名的执行步骤列表。
如本章的掌握命令行部分所提到的,用户可以通过 GUI 选择预设,或者使用命令--list-presets
并使用--preset=<preset>
选项为构建系统选择一个预设。
预设存储在两个文件中:
-
CMakePresets.json
:这是供项目作者提供官方预设的文件。 -
CMakeUserPresets.json
:这是为那些希望根据自己的喜好自定义项目配置的用户设计的(可以将其添加到 VCS 忽略文件中)。
预设在项目中并非必需,只有在高级场景下才会变得有用。详情请参见第十六章,编写 CMake 预设。
基于文件的 API
CMake 3.14 引入了一个 API,允许外部工具查询构建系统信息:生成文件的路径、缓存条目、工具链等。我们提到这个非常高级的话题,是为了避免在文档中看到基于文件的 API时产生困惑。该名称暗示了它的工作原理:必须将带有查询的 JSON 文件放置在构建树中的特定路径下。CMake 将在构建系统生成期间读取该文件,并将响应写入另一个文件,以便外部应用程序解析。
基于文件的 API 是为了替代一个已被弃用的机制——服务器模式(或 cmake-server
),该机制最终在 CMake 3.26 中被移除。
配置日志
从 3.26 版本开始,CMake 将提供一个结构化的日志文件,用于高级调试 配置阶段,文件位于:
<build tree>/CMakeFiles/CMakeConfigureLog.yaml
这是一个你通常不需要关注的特性——直到你需要的时候。
在 Git 中忽略文件
有许多版本控制系统(VCS);其中最流行的之一是 Git。每当我们开始一个新项目时,确保只将必要的文件添加到仓库中是很重要的。如果我们在 .gitignore
文件中指定不需要的文件,项目的清洁度会更容易维护。例如,我们可以排除一些生成的文件、用户特定的文件或临时文件。
Git 在创建新的提交时会自动跳过这些文件。以下是我在项目中使用的文件:
ch01/01-hello/.gitignore
CMakeUserPresets.json
# If in-source builds are used, exclude their output 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 脚本,依此类推。而 CMake 将这一切抽象化,你可以拥有一个在所有平台上都能正常工作的文件。当然,你也可以使用外部工具,如 Python、Perl 或 Ruby 脚本,但这增加了依赖项,会增加 C/C++ 项目的复杂性。那么,为什么要引入另一种语言呢?大多数情况下,你可以使用更简单的方式来完成任务。使用 CMake!
我们已经从 掌握命令行 部分学到,我们可以使用 -P
选项来执行脚本:cmake -P script.cmake
。
但是我们希望使用的脚本文件的实际要求是什么呢?其实并不复杂:脚本可以根据需要复杂,也可以只是一个空文件。不过,仍然建议在每个脚本的开头调用 cmake_minimum_required()
命令。该命令告诉 CMake 应该对项目中的后续命令应用哪些策略(详细内容请参见 第四章,设置你的第一个 CMake 项目)。
这是一个简单脚本的示例:
ch01/02-script/script.cmake
# An example of a script
cmake_minimum_required(VERSION 3.26.0)
message("Hello world")
file(WRITE Hello.txt "I am writing to a file")
在运行脚本时,CMake 不会执行任何常规阶段(例如配置或生成),也不会使用缓存,因为在脚本中没有 源树 或 构建树 的概念。这意味着项目特定的 CMake 命令在脚本模式下不可用/不可使用。就这些了,祝你脚本编写愉快!
实用模块
CMake 项目可以使用外部模块来增强其功能。模块是用 CMake 语言编写的,包含宏定义、变量和执行各种功能的命令。它们的复杂程度从非常复杂的脚本(如 CPack
和 CTest
提供的脚本)到相对简单的脚本,例如 AddFileDependencies
或 TestBigEndian
。
CMake 分发版包含了超过 80 个不同的实用模块。如果这些还不够,你可以通过浏览一些策划的列表,如 github.com/onqtam/awesome-cmake
,从互联网上下载更多,或者从头编写你自己的模块。
要使用实用模块,我们需要调用 include(<MODULE>)
命令。以下是一个简单的项目,展示了这一过程:
ch01/03-module/CMakeLists.txt
cmake_minimum_required(VERSION 3.26.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()
命令并提供相关包的名称来使用它们。这样,查找模块会进行一场捉迷藏的游戏,检查所有已知的软件位置。如果找到文件,将定义包含其路径的变量(如该模块手册中所指定)。现在,CMake 可以基于该依赖关系进行构建。
例如,FindCURL
模块搜索一个流行的 Client URL 库,并定义以下变量:CURL_FOUND
、CURL_INCLUDE_DIRS
、CURL_LIBRARIES
和 CURL_VERSION_STRING
。
我们将在 第九章《在 CMake 中管理依赖》中更深入地讨论查找模块。
总结
现在您了解了 CMake 是什么以及它是如何工作的;您学习了 CMake 工具家族的关键组成部分,并了解了它如何在各种系统上安装。作为一名真正的高级用户,您知道如何通过命令行运行 CMake 的各种方式:构建系统生成、构建项目、安装、运行脚本、命令行工具和打印帮助信息。您还了解了 CTest、CPack 和 GUI 应用程序。这将帮助您以正确的视角为用户和其他开发者创建项目。此外,您还了解了项目的组成:目录、列表文件、配置、预设和辅助文件,以及如何在版本控制系统中忽略哪些内容。最后,您快速浏览了其他非项目文件:独立脚本和两种类型的模块——实用模块和查找模块。
在下一章中,我们将学习如何使用 CMake 编程语言。这将使您能够编写自己的列表文件,并为您的第一个脚本、项目和模块打开大门。
深入阅读
获取更多信息,您可以参考以下资源:
-
官方 CMake 网页和文档:
cmake.org/
-
单配置生成器:
cgold.readthedocs.io/en/latest/glossary/single-config.html
-
CMake GUI 中阶段的划分:
stackoverflow.com/questions/39401003/
留下评论!
喜欢这本书吗?通过留下亚马逊评论来帮助像您一样的读者。扫描下面的二维码,获取您选择的免费电子书。
第二章:CMake 语言
用 CMake 语言 编写代码比想象的要复杂。当你第一次阅读 CMake 列表文件时,可能会觉得它的语言如此简单,以至于可以直接实践,而不需要任何理论基础。你可能会尝试做出修改,并在没有充分理解其工作原理的情况下实验代码。我不会责怪你。我们程序员通常都很忙,构建相关的问题通常也不是一个值得投入大量时间的有趣话题。为了快速推进,我们往往凭直觉做出修改,希望它们能奏效。这种解决技术问题的方式被称为巫术编程。
CMake 语言看起来很简单:在我们引入了小扩展、修复、黑客技巧或一行代码后,我们突然发现有些东西不起作用。通常,调试所花费的时间超过了理解该主题本身所需的时间。幸运的是,这不会成为我们的命运,因为本章涵盖了使用 CMake 语言实践所需的大部分关键知识。
在本章中,我们不仅将学习 CMake 语言的构成模块——注释、命令、变量和控制结构——还将理解相关背景知识,并根据最新的实践进行示例操作。
CMake 让你处于一个独特的地位。一方面,你扮演着构建工程师的角色,必须全面了解编译器、平台及相关方面;另一方面,你是一个开发者,编写生成构建系统的代码。编写高质量代码是一项具有挑战性的任务,需要多方面的能力。代码不仅要能正常工作、易于阅读,还应该易于分析、扩展和维护。
总结时,我们将展示一些在 CMake 中最实用和最常用的命令。那些也常用但使用频率较低的命令将被放入附录,杂项命令中(包括 string
、list
、file
和 math
命令的参考指南)。
在本章中,我们将覆盖以下主要内容:
-
CMake 语言语法基础
-
处理变量
-
使用列表
-
理解 CMake 中的控制结构
-
探索常用的命令
技术要求
你可以在 GitHub 上找到本章中出现的代码文件,链接地址是:github.com/PacktPublishing/Modern-CMake-for-Cpp-2E/tree/main/examples/ch02
。
为了构建本书提供的示例,始终使用推荐的命令:
cmake -B <build tree> -S <source tree>
cmake --build <build tree>
请确保将 <build tree>
和 <source tree>
占位符替换为适当的路径。提醒一下:build tree 是目标/输出目录的路径,source tree 是源代码所在的路径。
CMake 语言语法基础
编写 CMake 代码与编写其他命令式语言非常相似:代码行从上到下、从左到右执行,偶尔会进入一个包含文件或调用的函数。执行的起点由模式决定(参见第一章中掌握命令行部分,与 CMake 初步接触),即从源代码树的根文件(CMakeLists.txt
)或作为参数传递给 cmake
的 .cmake
脚本文件开始。
由于 CMake 脚本广泛支持 CMake 语言,除了项目相关功能外,我们将在本章中利用它们来练习 CMake 语法。一旦我们熟练掌握了编写简单列表文件的技巧,就可以进阶到创建实际的项目文件,这部分内容将在第四章中讲解,设置你的第一个 CMake 项目。
提醒一下,可以使用以下命令运行脚本:cmake -P script.cmake
。
CMake 支持 7 位 ASCII 文本文件,以实现跨平台的可移植性。你可以使用\n
或\r\n
行结束符。CMake 版本高于 3.2 支持 UTF-8 和 UTF-16,并且可以选择使用 字节顺序标记 (BOM)。
在 CMake 列表文件中,所有内容要么是注释,要么是命令调用。
注释
就像在 C++ 中一样,注释有两种类型:单行注释和括号(多行)注释。但与 C++ 不同的是,括号注释可以嵌套。单行注释以井号#
开始:
# they can be placed on an empty line
message("Hi"); # or after a command like here.
多行括号注释得名于其符号——它们以#
开头,后跟一个左方括号[
,接着是任意数量的等号=
(可以包括 0 个),然后是另一个方括号[
。要关闭一个括号注释,请使用相同数量的等号并反向括号]
:
#[=[
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
知道如何使用注释绝对是有用的,但这又引出了另一个问题:我们应该在何时使用它们?因为编写列表文件本质上是编程,所以将最佳编码实践应用于它们也是一个好主意。
遵循这种做法的代码通常被称为干净代码——这是软件开发大师如罗伯特·C·马丁、马丁·福勒等许多作者多年来使用的术语。
关于哪些做法是有益的,哪些是有害的,常常会有很多争议,正如你所料,注释也未能免于这些争论。所有事情都应该根据具体情况进行判断,但普遍认可的指南认为好的注释至少应该具备以下之一:
-
信息:它们可以解开复杂性,比如正则表达式模式或格式化字符串。
-
意图:它们可以解释代码的意图,当代码的实现或接口并不明显时。
-
澄清:它们可以解释一些无法轻易重构或更改的概念。
-
后果警告:它们可以提供警告,特别是关于可能破坏其他部分的代码。
-
加强:它们可以强调某个在代码中难以表达的概念的重要性。
-
合法条款:它们可以添加这个必要的麻烦,通常这并不是程序员的领域。
最好通过更好的命名、重构或修正代码来避免注释。省略以下类型的注释:
-
强制性:这些是为了完整性而添加的,但它们并不真正重要。
-
冗余:这些内容重复了代码中已清晰表达的内容。
-
误导性:如果这些没有跟随代码变化,它们可能会变得过时或不准确。
-
日志:这些记录了已更改的内容及其时间(可以使用版本控制系统(VCS)来替代)。
-
分隔符:这些标记出不同的部分。
如果可以,避免添加注释,采用更好的命名实践,并重构或修正代码。编写优雅的代码是一个具有挑战性的任务,但它提升了读者的体验。由于我们花在阅读代码上的时间通常比编写代码的时间更多,我们应该始终努力编写易于阅读的代码,而不仅仅是追求快速完成。我建议你查看本章末尾的进一步阅读部分,那里有一些关于清洁代码的好参考。如果你对注释感兴趣,你可以找到我 YouTube 视频《你代码中的哪些注释是好的?》的链接,深入探讨这个话题。
命令调用
该采取行动啦!调用命令是 CMake 列表文件的核心。在运行命令时,必须指定命令名称,后面跟上括号,括号内可以包含以空格分隔的命令参数。
图 2.1:命令示例
命令名称不区分大小写,但 CMake 社区有一个约定,使用 snake_case
(即用下划线连接的小写单词)。你还可以定义自己的命令,我们将在本章的理解 CMake 中的控制结构部分讲解这一点。
CMake 与 C++ 之间一个显著的区别是,CMake 中的命令调用不是表达式。这意味着你不能将另一个命令作为参数传递给已调用的命令,因为括号内的所有内容都被视为该特定命令的参数。
CMake 命令后面也不需要加分号。这是因为每行源代码只能包含一个命令。
命令后面可以选择性地加上注释:
command(argument1 "argument2" argument3) # comment
command2() #[[ multiline comment
但不是反过来:
#[[ bracket
]] command()
如我们之前所说,CMake 列表文件中的所有内容要么是注释,要么是命令 调用。CMake 语法确实如此简单,通常来说,这是件好事。虽然有些限制(例如,你不能通过表达式来递增计数器变量),但大部分情况下,这些限制是可以接受的,因为 CMake 并非旨在成为一种通用编程语言。
CMake 提供了命令来操作变量、控制执行流、修改文件等等。为了简化操作,我们将在不同示例中逐步介绍相关命令。这些命令可以分为两组:
-
脚本命令:这些命令始终可用,它们改变命令处理器的状态,访问变量,并影响其他命令和环境。
-
项目命令:这些命令在项目中可用,它们用于操作项目状态和构建目标。
几乎所有命令都依赖于语言中的其他元素才能运行:变量、条件语句,最重要的是命令行参数。现在,让我们探讨一下如何利用它们。
命令参数
CMake 中的许多命令需要空格分隔的参数来配置其行为。如图 2.1所示,围绕参数使用的引号可能相当特殊。某些参数需要引号,而其他参数则不需要。为什么会有这样的区别呢?
在底层,CMake 识别的唯一数据类型是 string
。因此,每个命令都期望它的参数是零个或多个字符串。CMake 会评估每个参数为静态字符串,然后将它们传递给命令。评估意味着字符串插值,或者用另一个值替换字符串的一部分。这可能意味着替换转义序列,扩展变量引用(也叫做变量插值),以及解包列表。
根据上下文的不同,我们可能需要根据需要启用这样的评估。因此,CMake 提供了三种类型的参数:
-
括号参数
-
加引号的参数
-
未加引号的参数
CMake 中的每种参数类型都有其特殊性,并提供不同级别的评估。
括号参数
括号参数不会被评估,因为它们用于将多行字符串逐字传递给命令作为单一参数。这意味着这样的参数会包括制表符和换行符形式的空白字符。
括号参数的格式与注释完全相同。它们以 [=[
开始,并以 ]=]
结束,开头和结尾的等号数量必须匹配(只要匹配,省略等号也是允许的)。与注释的唯一不同是,括号参数不能嵌套。
下面是使用这种参数的示例,结合 message()
命令,它会将所有传递的参数打印到屏幕上:
ch02/01-arguments/bracket.cmake
message([[multiline
bracket
argument
]])
message([==[
because we used two equal-signs "=="
this command receives only a single argument
even if it includes two square brackets in a row
{ "petsArray" = [["mouse","cat"],["dog"]] }
]==])
在前面的示例中,我们可以看到不同形式的括号参数。注意在第一次调用中将闭合标签放在单独一行,会导致输出中出现空行:
$ cmake -P ch02/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++ 字符串相似之处的终结。带引号的参数可以跨越多行,并且它们会插入变量引用。可以将它们视为内置的printf
函数来自C,或来自C++20的std::format
函数。要在参数中插入变量引用,只需将变量名用 ${name}
这样的标记括起来。我们将在本章的处理变量部分进一步讨论变量引用。
你能猜到下面的脚本输出会有多少行吗?
ch02/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 ch02/01-arguments/quoted.cmake
1\. escape sequence: "
in a quoted argument
2\. multi...
line
3\. and a variable reference: 3.26.0
没错——我们有一个转义的引号字符,一个换行转义序列和一个字面上的换行符。我们还访问了一个内置的 CMAKE_VERSION
变量,可以看到它在最后一行被插入。让我们来看 CMake 是如何处理没有引号的参数的。
未加引号的参数
在编程领域,我们已经习惯了字符串必须以某种方式进行定界,例如使用单引号、双引号或反斜杠。CMake 偏离了这一惯例,提出了未加引号的参数。我们或许可以争论去掉定界符会让代码更易读。这个说法是否成立?我让你自己去判断。
未加引号的参数会同时处理转义序列和变量引用。但是,要小心分号 (;)
,因为在 CMake 中,分号会被视为列表的定界符。如果参数中包含分号,CMake 会将其拆分成多个参数。如果需要使用分号,必须使用反斜杠进行转义 \;
。我们将在本章的使用列表部分进一步讨论分号。
你可能会发现这些参数最难处理,因此这里有一个例子可以帮助说明这些参数是如何被划分的:
图 2.2:转义序列导致多个标记被解释为一个单一的参数
处理未加引号的参数时总是需要小心。一些 CMake 命令需要特定数量的参数,并会忽略任何附加内容。如果你的参数被不小心分开了,你可能会遇到难以调试的错误。
未加引号的参数不能包含未转义的引号(“)、井号(#)和反斜杠(\)。如果这些还不够记住,括号()
仅在它们形成正确的配对时才允许使用。也就是说,你必须从一个左括号开始,并在关闭命令参数列表之前关闭它。
下面是一些展示我们讨论过的规则的例子:
ch02/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 ch02/01-arguments/unquoted.cmake
a single argument
twoarguments
threeseparatedarguments
3.16.3
()()()
即使是像message()
这样简单的命令,对于未加引号的参数分隔也有严格要求。当a single argument
中的空格被正确转义时,它被正确打印出来。然而,twoarguments
和threeseparatearguments
却被粘在了一起,因为message()
不会自动添加空格。
鉴于所有这些复杂性,何时使用未加引号的参数会更有利呢?一些 CMake 命令允许使用由关键字引导的可选参数,表示将提供一个可选参数。在这种情况下,使用未加引号的关键字参数可以使代码更加易读。例如:
project(myProject VERSION 1.2.3)
在这个命令中,VERSION
关键字和后面的参数1.2.3
是可选的。正如你所看到的,两个部分都没有加引号,以提高可读性。注意,关键字是区分大小写的。
现在我们已经了解了如何处理 CMake 参数的复杂性和怪癖,接下来就可以处理 CMake 中各种变量的操作了。
操作变量
CMake 中的变量是一个令人惊讶的复杂话题。变量不仅有三类——普通变量、缓存变量和环境变量——它们还存在于不同的变量作用域中,并有特定的规则规定一个作用域如何影响另一个作用域。通常,对于这些规则理解不够清晰会成为 bug 和头痛的源头。我建议你认真学习这一部分,确保在继续之前完全理解所有的概念。
让我们从一些关于 CMake 变量的关键事实开始:
-
变量名是区分大小写的,并且几乎可以包含任何字符。
-
所有变量都以字符串形式存储,即使一些命令可以将它们解释为其他数据类型的值(甚至是列表!)。
基本的变量操作命令是set()
和unset()
,但还有其他命令可以改变变量的值,例如string()
和list()
。
要声明一个普通变量,我们只需要调用set()
,提供其名称和值:
ch02/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 命令的名称。
要取消设置一个变量,我们可以使用unset()
,方法如下:unset(MyString1)
。
set()
命令接受一个普通文本变量名作为第一个参数,但message()
命令使用用${}
语法包裹的变量引用。
如果我们将一个变量用${}
语法传递给set()
命令,会发生什么情况?
要回答这个问题,我们需要更好地理解变量引用。
变量引用
我已经在命令参数部分简要提到了引用,因为它们会在带引号和不带引号的参数中进行求值。我们了解到,要创建对已定义变量的引用,我们需要使用${}
语法,像这样:message(${MyString1})
。
在求值时,CMake 将从最内层的作用域遍历到最外层作用域,并将${MyString1}
替换为一个值,或者如果没有找到变量,则替换为空字符串(CMake 不会产生任何错误信息)。这个过程也被称为变量求值、扩展或插值。
插值是从内向外进行的,从最内层的花括号对开始,逐步向外推进。例如,如果遇到${MyOuter${MyInner}}
引用:
-
CMake 将首先尝试评估
MyInner
,而不是查找名为MyOuter${MyInner}
的变量。 -
如果
MyInner
变量成功展开,CMake 将使用新形成的引用重复扩展过程,直到无法继续扩展为止。
为了避免出现意外的结果,建议不要将变量扩展标记存储在变量值中。
CMake 会执行变量扩展,直到完全展开,之后才会将得到的结果作为参数传递给命令。这就是为什么当我们调用set(${MyInner} "Hi")
时;我们实际上不会改变MyInner
变量,而是会修改一个以MyInner
存储的值命名的变量。通常,这并不是我们想要的结果。
变量引用在处理变量类别时有些特别,但一般来说,以下内容适用:
-
${}
语法用于引用普通或缓存变量。 -
$ENV{}
语法用于引用环境变量。 -
$CACHE{}
语法用于引用缓存变量。
没错,通过${}
,你可能从某个类别获取到一个值:如果在当前作用域内设置了普通变量,则会使用该变量;但如果没有设置,或者被取消设置,CMake 将使用具有相同名称的缓存变量。如果没有这样的变量,引用将评估为空字符串。
CMake 预定义了许多内置的普通变量,它们有不同的用途。例如,你可以在--
标记之后将命令行参数传递给脚本,这些参数将被存储在CMAKE_ARGV<n>
变量中(CMAKE_ARGC
变量将包含计数)。
让我们介绍其他类别的变量,以便更清楚地理解它们是什么。
使用环境变量
这是最简单的一种变量类型。CMake 会复制用于启动cmake
进程的环境中的变量,并将它们提供给单一的全局作用域。要引用这些变量,可以使用$ENV{<name>}
语法。
CMake 会更改这些变量,但更改只会影响正在运行的cmake
进程中的本地副本,而不会影响实际的系统环境;此外,这些更改不会对后续的构建或测试运行产生影响,因此不推荐这样做。
请注意,有一些环境变量会影响 CMake 行为的不同方面。例如,CXX
变量指定了用于编译 C++文件的可执行文件。我们将在本书中讲解环境变量,它们将变得非常相关。完整的列表可以在文档中找到:cmake.org/cmake/help/latest/manual/cmake-env-variables.7.html
。
需要注意的是,如果你将ENV
变量作为命令的参数,值将在生成构建系统时进行插值。这意味着它们将永久地嵌入到构建树中,改变构建阶段的环境将没有任何效果。
例如,考虑以下项目文件:
ch02/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 脚本来测试在配置阶段使用一个值、在构建阶段使用另一个值的效果:
ch02/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
文件里的持久变量。它们包含在项目的配置阶段收集的信息。它们来源于系统(编译器、链接器、工具等的路径)和用户,通过 GUI 或通过命令行的 -D
选项提供。再强调一次,缓存变量在脚本中不可用;它们只存在于项目中。
如果 ${<name>}
引用在当前作用域内找不到普通变量,而存在同名的缓存变量,则将使用缓存变量。然而,它们也可以通过 $CACHE{<name>}
语法显式引用,并通过 set()
命令的特殊形式进行定义:
set(<variable> <value> CACHE <type> <docstring> [FORCE])
与用于普通变量的 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 执行中被覆盖。然而,如果变量在缓存文件中不存在,或者指定了可选的 FORCE
参数,则该值将被持久保存:
set(FOO "BAR" CACHE STRING "interesting value" FORCE)
类似于 C++,CMake 支持变量作用域,尽管它的实现方式比较特定。
如何在 CMake 中正确使用变量作用域
变量作用域 可能是 CMake 语言中最奇怪的概念。这也许是因为我们习惯了在通用编程语言中实现变量作用域的方式。我们早期解释这个概念,是因为对作用域的错误理解通常是导致难以发现和修复的错误的根源。
为了澄清,作为一个通用概念,变量作用域旨在通过代码表达不同层次的抽象。作用域以树形结构相互嵌套。最外层的作用域(根)被称为全局作用域。任何作用域都可以称为局部作用域,表示当前执行或讨论的作用域。作用域在变量之间创建了边界,使得嵌套作用域可以访问外部作用域中定义的变量,但反过来则不行。
CMake 有两种变量作用域:
-
文件:在文件内执行块和自定义函数时使用
-
目录:当调用
add_subdirectory()
命令在嵌套目录中执行另一个CMakeLists.txt
列表文件时使用条件块、循环块和宏不会创建独立的作用域。
那么,CMake 中变量作用域的实现有什么不同呢?当创建一个嵌套作用域时,CMake 会简单地用外部作用域中的所有变量的副本填充它。后续命令将影响这些副本。但一旦嵌套作用域的执行完成,所有副本将被删除,外部作用域中的原始变量将被恢复。
CMake 中作用域的概念有一些有趣的影响,这在其他语言中并不常见。在嵌套作用域中执行时,如果你取消设置(unset()
)一个在外部作用域中创建的变量,它会消失,但仅在当前的嵌套作用域内,因为该变量是局部副本。如果你现在引用这个变量,CMake 会认为没有定义这样的变量,它将忽略外部作用域,并继续在缓存变量中查找(这些被视为独立的)。这是一个可能的陷阱。
文件变量作用域是通过block()
和function()
命令打开的(但不是macro()
),并分别通过endblock()
和endfunction()
命令关闭。我们将在本章的命令定义部分讨论函数。现在,让我们看看如何通过更简单的block()
命令(在 CMake 3.25 中引入)来实际使用变量作用域。
考虑以下示例:
ch02/04-scope/scope.cmake
cmake_minimum_required(VERSION 3.26)
set(V 1)
message("> Global: ${V}")
block() # outer block
message(" > Outer: ${V}")
set(V 2)
block() # inner block
message(" > Inner: ${V}")
set(V 3)
message(" < Inner: ${V}")
endblock()
message(" < Outer: ${V}")
endblock()
message("< Global: ${V}")
我们最初将变量V
设置为1
,在全局作用域中。进入外部和内部块后,我们分别将它们改为2
和3
。我们还会在进入和退出每个作用域时打印变量:
> Global: 1
> Outer: 1
> Inner: 2
< Inner: 3
< Outer: 2
< Global: 1
如前所述,当我们进入每个嵌套作用域时,变量值会从外部作用域临时复制,但在退出时会恢复其原始值。这反映在输出的最后两行中。
block()
命令还可以将值传播到外部作用域(就像 C++ 默认的行为一样),但必须通过PROPAGATE
关键字显式启用。如果我们使用block(PROPAGATE V)
来启用内部块的传播,输出将如下所示:
> Global: 1
> Outer: 1
> Inner: 2
< Inner: 3
< Outer: 3
< Global: 1
再次,我们只影响了外部块的作用域,而没有影响全局作用域。
修改外部作用域变量的另一种方法是为 set()
和 unset()
命令设置 PARENT_SCOPE
标志:
set(MyVariable "New Value" PARENT_SCOPE)
unset(MyVariable PARENT_SCOPE)
这种解决方法有些局限,因为它不允许访问比当前层级更高的变量。另一个值得注意的事实是,使用 PARENT_SCOPE
不会改变当前作用域中的变量。
现在我们已经知道如何处理基本变量,让我们来看看一个特殊的情况:由于所有变量都以字符串形式存储,CMake 必须采取更有创意的方法来处理更复杂的数据结构,如列表。
使用列表
要存储一个列表,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 语言如果没有控制结构就不完整了!像其他所有功能一样,控制结构以命令的形式提供,分为三类:条件块、循环和命令定义。控制结构在脚本和项目的构建系统生成过程中执行。
条件块
CMake 中唯一支持的条件块是简单的 if()
命令。所有条件块必须以 endif()
命令结束,并且可以包含任意数量的 elseif()
命令和一个可选的 else()
命令,顺序如下:
if(<condition>)
<commands>
elseif(<condition>) # optional block, can be repeated
<commands>
else() # optional block
<commands>
endif()
如同许多其他命令式语言,if()
-endif()
块控制哪些命令集合将被执行:
-
如果
if()
命令中指定的<condition>
表达式满足条件,第一部分将被执行。 -
否则,CMake 会在本块中满足条件的第一个
elseif()
命令所属的部分执行命令。 -
如果没有这样的命令,CMake 会检查是否提供了
else()
命令,并执行该代码段中的任何命令。 -
如果前面的条件都不满足,执行将在
endif()
命令之后继续。
请注意,在任何条件块中都不会创建本地变量作用域。
提供的<condition>
表达式根据非常简单的语法进行评估——让我们进一步了解它。
条件命令的语法
相同的语法适用于if()
,elseif()
和while()
命令。
逻辑运算符
if()
条件支持NOT
,AND
和OR
逻辑运算符:
-
NOT <condition>
-
<condition> AND <condition>
-
<condition> OR <condition>
此外,可以使用匹配的括号对(()
)来嵌套条件。像所有优秀的编程语言一样,CMake 语言遵循评估顺序,从最内层的括号开始:
(<condition>) AND (<condition> OR (<condition>))
字符串和变量的评估
出于兼容性原因(因为变量引用(${}
)语法并非一直存在),CMake 会尝试将未加引号的参数评估为变量引用。换句话说,在条件中使用一个简单的变量名(例如QUX
)等同于写${QUX}
。这里有一个示例供你考虑,还有一个陷阱:
set(BAZ FALSE)
set(QUX "BAZ")
if(${QUX})
if()
条件在这里有点复杂——首先,它会将${QUX}
评估为BAZ
,这是一个已识别的变量,然后它将被评估为一个包含五个字符的字符串FALSE
。字符串只有在等于以下常量之一时才被视为布尔真(这些比较是不区分大小写的):ON
,Y
,YES
,TRUE
,或者是一个非零数字。
这使我们得出结论,前面示例中的条件将评估为布尔假。
然而,这里有一个陷阱——如果条件中有一个未加引号的参数,且该参数是一个包含值的变量名,例如BAR
,会如何评估呢?考虑以下代码示例:
set(FOO BAR)
if(FOO)
根据我们到目前为止所说的内容,它应该是false
,因为BAR
字符串不满足评估为布尔真值的条件。遗憾的是,并非如此,因为 CMake 在未加引号的变量引用时会做出例外处理。与加引号的参数不同,FOO
不会被评估为BAR
,从而生成if("BAR")
语句(这将是false
)。相反,只有当FOO
等于以下常量之一时,CMake 才会将if(FOO)
评估为false
(这些比较是不区分大小写的):
-
OFF
,NO
,FALSE
,N
,IGNORE
,或NOTFOUND
-
以
-NOTFOUND
结尾的字符串 -
一个空字符串
-
零
因此,简单地请求一个未定义的变量将被评估为false
:
if (CORGE)
当一个变量在之前定义时,情况就会发生变化,条件评估为true
:
set(CORGE "A VALUE")
if (CORGE)
如果你认为递归求值未加引号的if()
参数很令人困惑,可以将变量引用放在引号参数中:if("${
CORGE}")
。这会使得在将参数传递到if()
命令之前,首先对参数进行求值,行为将与字符串求值一致。
换句话说,CMake 假定传递变量名给if()
命令的用户是想检查该变量是否已定义且其值不等于布尔假。要明确检查变量是否已定义(忽略其值),我们可以使用以下方法:
if(DEFINED <name>)
if(DEFINED CACHE{<name>})
if(DEFINED ENV{<name>})
比较值
比较操作支持以下运算符:
EQUAL
、LESS
、LESS_EQUAL
、GREATER
、GREATER_EQUAL
其他语言中常见的比较运算符在 CMake 中并不适用:==
、>
、<
、!=
等等。
它们可以用来比较数值,例如:
if (1 LESS 2)
你可以通过在任何运算符前添加VERSION_
前缀来比较软件版本,格式为major[.minor[.patch[.tweak]]]
:
if (1.3.4 VERSION_LESS_EQUAL 1.4)
被省略的组件会被视为零,且非整数版本组件会在该点截断比较的字符串。
对于字典序字符串比较,我们需要在操作符前加上STR
前缀(注意没有下划线):
if ("A" STREQUAL "${B}")
我们通常需要比简单的相等比较更高级的机制。幸运的是,CMake 还支持POSIX 正则表达式匹配(CMake 文档提示支持扩展正则表达式(ERE)类型,但未提到支持特定的正则表达式字符类)。我们可以使用MATCHES
运算符,如下所示:
<VARIABLE|STRING> MATCHES <regex>
任何匹配的组都会被捕获到CMAKE_MATCH_<n>
变量中。
简单检查
我们已经提到过一个简单的检查方法DEFINED
,但还有其他方法,如果条件满足,直接返回true
。
我们可以检查以下内容:
-
判断一个值是否在列表中:
<VARIABLE|STRING> IN_LIST <VARIABLE>
-
判断在此版本的 CMake 中是否可以调用某个命令:
COMMAND <command-name>
-
判断是否存在 CMake 策略:
POLICY <policy-id>
(在第四章《设置你的第一个 CMake 项目》中讲解过) -
判断 CTest 测试是否通过
add_test()
添加:TEST <test-name>
-
判断构建目标是否已定义:
TARGET <target-name>
我们将在第五章《与目标一起工作》中深入探讨构建目标,但现在我们只需要知道,目标是通过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
:检查路径是否为绝对路径。
此外,从 3.24 版本开始,CMake 支持简单的路径比较检查,它会压缩多个路径分隔符,但不会进行其他规范化操作:
if ("/a////b/c" PATH_EQUAL "/a/b/c") # returns true
若要进行更高级的路径操作,请参考 cmake_path()
命令的文档。
这完成了条件命令的语法;接下来我们将讨论的控制结构是循环。
循环
CMake 中的循环非常简单——我们可以使用 while()
循环或 foreach()
循环来反复执行相同的一组命令。这两个命令都支持循环控制机制:
-
break()
循环会停止剩余代码块的执行,并跳出外部循环。 -
continue()
循环会停止当前迭代的执行,并从下一次迭代的顶部重新开始。
请注意,任何循环块中都不会创建局部的 变量作用域。
while()
循环块通过 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>
(包括 <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>
中。然后,它会为列表中的每个项执行所有命令。你可以选择仅提供列表、仅提供值,或者两者同时提供:
ch02/06-loops/foreach.cmake
set(MyList 1 2 3)
foreach(VAR IN LISTS MyList 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>)
压缩列表的过程涉及遍历多个列表,并对具有相同索引的对应项进行操作。让我们来看一个例子:
ch02/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("word=${num_0}, num=${num_1}")
endforeach()
CMake 会为每个提供的列表创建一个 num_<N>
变量,并用每个列表中的项填充它。
你可以传递多个变量名(每个列表一个),每个列表将使用一个单独的变量来存储其项:
foreach(word num IN ZIP_LISTS L1 L2)
message("word=${word}, num=${num}")
ZIP_LISTS
中的两个示例将产生相同的输出:
word=one, num=1
word=two, num=2
word=three, num=3
word=four, num=4
如果列表之间的项数不同,较短列表的变量将为空。
值得注意的是,从版本 3.21 起,foreach()
中的循环变量被限制在循环的局部作用域内。这结束了我们对循环的讨论。
命令定义
有两种方法可以定义自己的命令:可以使用 macro()
命令或 function()
命令。解释这两个命令之间的区别最简单的方式是将它们与 C 风格的预处理器宏 和实际的 C++ 函数进行比较:
macro()
命令更像是一个查找替换指令,而不是像 function()
这样的实际子程序调用。与函数不同,宏不会在调用栈上创建单独的条目。这意味着在宏中调用 return()
会返回到比函数更高一级的调用语句(如果我们已经在最顶层作用域,可能会终止执行)。
function()
命令为其变量创建 局部作用域,与 macro()
命令不同,后者在调用者的 变量作用域 中工作。这可能会导致混淆的结果。我们将在下一节讨论这些细节。
定义命令的两种方法都允许定义可以在命令的局部作用域中引用的命名参数。此外,CMake 提供了以下变量用于访问与调用相关的值:
-
${ARGC}
:参数的数量 -
${ARGV}
:所有参数作为列表 -
${ARGV<index>}
:特定索引(从 0 开始)处的参数值,无论该参数是否为预期参数 -
${ARGN}
:由调用者在最后一个预期参数后传递的匿名参数列表
访问超出 ARGC
范围的数字参数是未定义行为。为了处理高级场景(通常是参数个数未知的情况),你可能会对官方文档中的 cmake_parse_arguments()
感兴趣。如果你决定定义一个带命名参数的命令,那么每次调用必须传递所有参数,否则会无效。
宏
定义宏类似于定义任何其他块:
macro(<name> [<argument>…])
<commands>
endmacro()
在此声明之后,我们可以通过调用宏的名称来执行宏(函数调用不区分大小写)。
如我们所知,宏不会在调用栈上创建单独的条目或 变量作用域。以下示例突出了与宏行为相关的一些问题:
ch02/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 ch02/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()
函数需要一个名称,并可以选择性地接受一组期望的参数名称。如前所述,函数创建它们自己的 变量作用域。你可以调用 set()
,提供函数的某个命名参数,任何更改都将仅在函数内有效(除非指定了 PARENT_SCOPE
,正如我们在 如何正确使用 CMake 中的变量作用域 部分讨论过的那样)。
函数遵循调用栈规则,可以使用 return()
命令返回到调用范围。从 CMake 3.25 开始,return()
命令允许使用可选的 PROPAGATE
关键字,后面跟着一个变量名列表。其目的是类似于 block()
命令 —— 将指定变量的值从 局部范围 传递到调用范围。
CMake 为每个函数设置了以下变量(这些变量自版本 3.17 起可用):
-
CMAKE_CURRENT_FUNCTION
-
CMAKE_CURRENT_FUNCTION_LIST_DIR
-
CMAKE_CURRENT_FUNCTION_LIST_FILE
-
CMAKE_CURRENT_FUNCTION_LIST_LINE
让我们实际看看这些函数变量:
ch02/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}")
使用 cmake -P function.cmake
运行此脚本将打印以下输出:
Function: MyFunction
File: /root/examples/ch02/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
文件,调用三个已定义的命令,这些命令可能会调用它们自己定义的命令。图 2.3 展示了这一点:
图 2.3:过程调用图
在 CMake 中,采用过程式风格编程可能会遇到问题,因为你必须在调用命令之前提供命令定义。CMake 的解析器不会接受其他方式。你的代码可能看起来像这样:
cmake_minimum_required(...)
project(Procedural)
# Definitions
function(pull_shared_protobuf)
function(setup_first_target)
function(calculate_version)
function(setup_second_target)
function(setup_tests)
# Calls
setup_first_target()
setup_second_target()
setup_tests()
多么糟糕!一切都被颠倒了!因为最低抽象级别的代码出现在文件的开头,所以很难理解。正确结构化的代码应该在第一个子程序中列出最一般的步骤,然后提供稍微更详细的子程序,并将最详细的步骤放在文件的末尾。
这个问题有解决方案,比如将命令定义移到其他文件中,并在不同目录之间划分作用域(作用域目录将在第四章,设置你的第一个 CMake 项目中详细解释)。但也有一种简单而优雅的方法——在文件顶部声明一个入口点宏,并在文件末尾调用它:
macro(main)
first_step()
second_step()
third_step()
endmacro()
function(first_step)
function(second_step)
function(third_step)
main()
使用这种方法,我们的代码是按照逐渐缩小的范围编写的,并且由于我们实际上是在最后才调用main()
宏,CMake 不会因为执行未定义的命令而报错。
为什么在这种情况下使用宏而不是函数?因为宏能够不受限制地访问全局变量,而且由于我们没有向main()
传递任何参数,因此不需要担心通常的注意事项。
你可以在本书 GitHub 仓库的ch02/09-procedural/CMakeLists.txt
列表文件中找到一个简单的示例。
关于命名约定的几点说明
命名在软件开发中一向被认为是难题,但尽管如此,保持易读易懂的解决方案仍然非常重要。对于 CMake 脚本和项目,我们应该像处理任何软件开发解决方案一样,遵循清晰代码的方法:
-
遵循一致的命名风格(
snake_case
是 CMake 社区公认的标准)。 -
使用简短但有意义的名称(例如,避免使用
func()
、f()
等类似名称)。 -
避免在命名中使用双关语或聪明的做法。
-
使用可以发音、易于搜索且无需进行思维映射的名称。
现在我们已经知道如何正确地使用正确的语法调用命令,让我们首先探索哪些命令对我们最有益。
探索常用命令
CMake 提供了许多脚本命令,允许你处理变量和环境。部分命令在附录中有详细介绍,例如,list()
、string()
和file()
。其他一些命令,如find_file()
、find_package()
和find_path()
,更适合放在讨论它们各自主题的章节中。在本节中,我们将简要概述在大多数情况下都很有用的常见命令:
-
message()
-
include()
-
include_guard()
-
file()
-
execute_process()
让我们开始吧。
message()命令
我们已经知道并喜爱我们可靠的message()
命令,它将文本打印到标准输出。但是,它的功能远不止表面那么简单。通过提供一个MODE
参数,你可以像这样定制命令的行为:message(<MODE> "要打印的文本")
。
识别的模式如下:
-
FATAL_ERROR
:这会停止处理和生成。 -
SEND_ERROR
:这会继续处理,但跳过生成。 -
WARNING
:这会继续处理。 -
AUTHOR_WARNING
:一个 CMake 警告。此警告会继续处理。 -
DEPRECATION
:如果启用了CMAKE_ERROR_DEPRECATED
或CMAKE_WARN_DEPRECATED
变量,则此命令按相应方式工作。 -
NOTICE
或省略模式(默认):这将向stderr
打印一条信息,以引起用户的注意。 -
STATUS
:这继续处理,推荐用于向用户传递主要信息。 -
VERBOSE
:这继续处理,通常用于包含更详细的信息,这些信息通常不太必要。 -
DEBUG
:这继续处理,并应包含在项目出现问题时可能有帮助的任何细节。 -
TRACE
:这继续处理,推荐在项目开发过程中打印消息。通常,这些类型的消息在发布项目之前会被删除。
选择正确的模式需要额外的工作,但通过根据严重性为输出文本着色(自 3.21 起)或在声明不可恢复错误后停止执行,它可以节省调试时间:
ch02/10-useful/message_error.cmake
message(FATAL_ERROR "Stop processing")
message("This won't be printed.")
消息将根据当前的日志级别进行打印(默认情况下是STATUS
)。我们在上一章的调试和追踪选项部分讨论了如何更改这一点。
在第一章,CMake 的第一步中,我提到了使用CMAKE_MESSAGE_CONTEXT
进行调试,现在是时候深入研究它了。在此期间,我们已经深入了解了该主题的三个关键内容:列表、作用域和函数。
在复杂的调试场景中,指示消息发生在哪个上下文中可能非常有用。考虑以下输出,其中在foo
函数中打印的消息具有适当的前缀:
$ cmake -P message_context.cmake --log-context
[top] Before `foo`
[top.foo] foo message
[top] After `foo`
其工作原理如下:
ch02/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`")
让我们分解一下:
-
首先,我们将
top
添加到上下文跟踪变量CMAKE_MESSAGE_CONTEXT
中,然后我们打印初始的Before `foo`
消息,并且匹配的前缀[top]
将被添加到输出中。 -
接下来,进入
foo()
函数时,我们会在该函数所属的函数名后,将一个名为foo
的新上下文添加到列表中,并输出另一条信息,该信息在输出中将以扩展的[top.foo]
前缀出现。 -
最后,在函数执行完成后,我们打印
After `foo`
消息。该消息将以原始的[foo]
作用域打印。为什么?因为变量作用域规则:更改的CMAKE_MESSAGE_CONTEXT
变量仅存在于函数作用域结束之前,然后会恢复为原始未更改的版本。
使用message()
的另一个酷技巧是将缩进添加到CMAKE_MESSAGE_INDENT
列表中(与CMAKE_MESSAGE_CONTEXT
完全相同的方式):
list(APPEND CMAKE_MESSAGE_INDENT " ")
message("Before `foo`")
foo()
message("After `foo`")
我们脚本的输出看起来可能更简单:
Before `foo`
foo message
After `foo`
由于 CMake 没有提供任何真正的调试器或断点等工具,因此在事情没有按计划进行时,生成干净的日志消息的能力变得非常方便。
include()命令
将代码分割到不同的文件中以保持秩序并且,嗯,分开,是非常有用的。然后,我们可以通过调用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 模块目录中查找名为<module>.cmake
的文件。
当 CMake 遍历源树并包含不同的列表文件时,以下变量会被设置:CMAKE_CURRENT_LIST_DIR
、CMAKE_CURRENT_LIST_FILE
、CMAKE_PARENT_LIST_FILE
和CMAKE_CURRENT_LIST_LINE
。
include_guard()
命令
当我们包含具有副作用的文件时,我们可能希望限制它们只能被包含一次。这时,include_guard([DIRECTORY|GLOBAL])
就派上用场了。
将include_guard()
放在包含文件的顶部。当 CMake 第一次遇到它时,它会在当前作用域中记录这一事实。如果该文件再次被包含(可能因为我们无法控制项目中的所有文件),则不会再进行处理。
如果我们想要防止在不相关的函数作用域中包含那些不会共享变量的文件,我们应该提供DIRECTORY
或GLOBAL
参数。正如名字所示,DIRECTORY
关键字会在当前目录及其子目录中应用保护,而GLOBAL
关键字则会将保护应用到整个构建中。
file()
命令
为了让你了解如何使用 CMake 脚本,我们快速看看file()
命令的一些常用变体:
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 <cmd> <arguments>
参数来链式执行命令并将一个命令的输出传递给另一个命令。
可选地,你可以使用TIMEOUT <seconds>
参数来终止任务,如果任务在规定时间内未完成,还可以根据需要设置WORKING_DIRECTORY <directory>
。
所有任务的退出代码可以通过提供RESULTS_VARIABLE <variable>
参数收集到一个列表中。如果你只对最后执行命令的结果感兴趣,可以使用单数形式:RESULT_VARIABLE <variable>
。
为了收集输出,CMake 提供了两个参数:OUTPUT_VARIABLE
和ERROR_VARIABLE
(它们的使用方式相似)。如果你想合并stdout
和stderr
,可以对两个参数使用相同的变量。
请记住,当为其他用户编写项目时,确保你计划使用的命令在你声称支持的平台上是可用的。
总结
本章为 CMake 的实际编程打开了大门——你现在可以编写出色的、有信息量的注释,并利用内置命令,还理解了如何正确地为它们提供各种参数。仅凭这些知识,你就能理解你可能在其他人创建的项目中看到的 CMake 列表文件中的不寻常语法。我们已经涵盖了 CMake 中的变量——具体来说,如何引用、设置和取消设置普通、缓存和环境变量。我们深入探讨了文件和目录变量作用域的工作原理,如何创建它们,以及可能遇到的问题和解决方法。我们还涵盖了列表和控制结构。我们研究了条件语法、逻辑运算、无引号参数的评估,以及字符串和变量的操作。我们学习了如何比较值、进行简单检查并检查系统中文件的状态。这使我们能够编写条件块和while
循环;在讨论循环时,我们也掌握了foreach
循环的语法。
了解如何使用宏和函数语句定义自定义命令无疑会帮助你写出更清晰、更具程序化的代码。我们还讨论了改善代码结构和创建更具可读性名称的策略。
最后,我们正式介绍了message()
命令及其多个日志级别。我们还学习了如何分区和包含列表文件,并发现了一些其他有用的命令。凭借这些信息,我们已经为迎接下一章,第三章,在流行的 IDE 中使用 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
加入我们的社区,加入 Discord 讨论群
加入我们社区的 Discord 空间,与作者和其他读者进行讨论:
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 分享一个免费、快速、无限量使用的满血 DeepSeek R1 模型,支持深度思考和联网搜索!
· 使用C#创建一个MCP客户端
· 基于 Docker 搭建 FRP 内网穿透开源项目(很简单哒)
· ollama系列1:轻松3步本地部署deepseek,普通电脑可用
· 按钮权限的设计及实现
2024-03-03 OpenDocCN 20240303 更新
2024-03-03 笨办法学 Python3 第五版(预览)(三)
2024-03-03 笨办法学 Python3 第五版(预览)(二)
2024-03-03 笨办法学 Python3 第五版(预览)(一)
2023-03-03 PyTorch 1.0 中文官方教程:用 numpy 和 scipy 创建扩展