原文:
zh.annas-archive.org/md5/125f0c03ca93490db2ba97b08bc69e99
译者:飞龙 协议:CC BY-NC-SA 4.0
现在我们已经收集了足够的信息,可以开始讨论 CMake 的核心功能:构建项目。在 CMake 中,一个项目包含管理将我们的解决方案带入生活的所有源文件和配置。配置从执行所有检查开始:目标平台是否受支持,是否拥有所有必要的依赖项和工具,以及提供的编译器是否工作并支持所需功能。
完成后,CMake 将为我们的选择构建工具生成一个构建系统并运行它。源文件将与彼此以及它们的依赖项编译和链接,以产生输出工件。
项目可以由一组开发人员内部使用,产生用户可以通过包管理器在其系统上安装的包,或者它们可以用来提供单执行文件安装器。项目还可以在开源存储库中分享,以便用户可以使用 CMake 在他们的机器上编译项目并直接安装它们。
充分利用 CMake 项目将改善开发体验和生成代码的质量,因为我们可以自动化许多单调的任务,例如在构建后运行测试,检查代码覆盖率,格式化代码,以及使用 linters 和其他工具检查源代码。
为了充分发挥 CMake 项目的力量,我们首先要了解一些关键决策——这些决策是如何正确配置整个项目以及如何划分项目和设置源代码树,以便所有文件都整齐地组织在正确的目录中。
然后,我们将学习如何查询项目构建的环境——例如,它的架构是什么?有哪些工具可用?它们支持哪些功能?并使用的是哪种语言标准?最后,我们将学习如何编译一个测试**C++**文件,以验证所选编译器是否满足我们项目中设定的标准要求。
在本章中,我们将涵盖以下主要主题:
你可以在 GitHub 上找到本章中出现的代码文件:github.com/PacktPublishing/Modern-CMake-for-Cpp/tree/main/examples/chapter03
。
要构建本书提供的示例,始终使用推荐命令:
cmake -B <build tree> -S <source tree>
cmake --build <build tree>
务必将占位符<build tree>
和<source tree>
替换为合适的路径。作为提醒:build tree是目标/输出目录的路径,source tree是源代码所在的路径。
在*第一章**,CMake 的初步步骤中,我们已经看了一个简单的项目定义。让我们回顾一下。这是一个包含CMakeLists.txt
文件的目录,其中包含了几条配置语言处理器的命令:
chapter01/01-hello/CMakeLists.txt:CMake 语言中的 Hello world
cmake_minimum_required(VERSION 3.20)
project(Hello)
add_executable(Hello hello.cpp)
在同一章节中,在项目文件部分,我们了解了一些基本命令。让我们深入解释一下。
这并不是一个严格的项目特定命令,因为它也应该用于脚本,但我们在这里重复它是因为它非常重要。正如你所知,cmake_minimum_required()
将检查系统是否有正确的 CMake 版本,但隐式地,它还会调用另一个命令,cmake_policy(VERSION)
,这将告诉 CMake 对于这个项目应该使用哪些正确的策略。这些策略是什么?
在 CMake 发展的过去 20 年中,随着 CMake 及其支持的语言的发展,命令的行为发生了许多变化。为了保持语法简洁明了,CMake 团队决定引入策略来反映这些变化。每当引入一个向后不兼容的更改时,它都会附带一个策略,启用新的行为。
通过调用cmake_minimum_required()
,我们告诉 CMake 需要应用到提供参数中的版本的策略。当 CMake 通过新的策略升级时,我们不需要担心它们会破坏我们的项目,因为新策略不会被启用。如果我们用最新版本测试项目并且结果令我们满意,我们可以把更新后的项目发送给我们的用户。
策略可以影响 CMake 的每一个方面,包括其他重要命令如project()
。因此,很重要的一点是,你要在CMakeLists.txt
文件开始时设定你正在使用的版本。否则,你会收到警告和错误。
每个版本都引入了许多策略——除非你正在将旧项目升级到最新的 CMake 版本遇到问题,否则描述它们并没有真正的价值。在这种情况下,请参考官方文档中的策略:cmake.org/cmake/help/latest/manual/cmake-policies.7.html
。
从技术上讲,CMake 不需要project()
命令。任何包含CMakeLists.txt
文件的目录都会以项目模式被解析。CMake 隐式地在文件顶部添加了这个命令。但我们已经知道我们需要首先指定最小版本,所以最好不要忘记调用project()
。我们可以使用它的两种形式之一:
project(<PROJECT-NAME> [<language-name>...])
project(<PROJECT-NAME>
[VERSION <major>[.<minor>[.<patch>[.<tweak>]]]]
[DESCRIPTION <project-description-string>]
[HOMEPAGE_URL <url-string>]
[LANGUAGES <language-name>...])
我们需要指定<PROJECT-NAME>
,但其他参数是可选的。调用这个命令将隐式地设置以下变量:
PROJECT_NAME
CMAKE_PROJECT_NAME
(仅在最顶层的CMakeLists.txt
中)
PROJECT_SOURCE_DIR
、<PROJECT-NAME>_SOURCE_DIR
PROJECT_BINARY_DIR
、<PROJECT-NAME>_BINARY_DIR
支持哪些语言?很多。以下是您可以用来配置项目的语言关键词列表:C
、CXX
(C++)、CUDA
、OBJC
(Objective-C)、OBJCXX
(Objective C++)、Fortran
、ISPC
、ASM
,以及CSharp
(C#)和Java
。
CMake 默认支持 C 和 C++,所以您可能只想明确指定CXX
用于您的 C++项目。为什么?project()
命令将检测和测试您选择的可用编译器,所以选择正确的编译器将使您在配置阶段节省时间,通过跳过任何对未使用语言的检查。
指定VERSION
将使以下变量可用:
PROJECT_VERSION
、<PROJECT-NAME>_VERSION
CMAKE_PROJECT_VERSION
(仅在顶级CMakeLists.txt
中)
PROJECT_VERSION_MAJOR
、<PROJECT-NAME>_VERSION_MAJOR
PROJECT_VERSION_MINOR
、<PROJECT-NAME>_VERSION_MINOR
PROJECT_VERSION_PATCH
、<PROJECT-NAME>_VERSION_PATCH
PROJECT_VERSION_TWEAK
、<PROJECT-NAME>_VERSION_TWEAK
这些变量将有助于配置软件包,或将版本传递给编译文件,以便在最终可执行文件中可用。
遵循这一原则,我们可以设置DESCRIPTION
和HOMEPAGE_URL
,这将以相同的方式设置变量。
CMake 还允许通过enable_language(<lang>)
指定使用的语言,这将不会创建任何元数据变量。
这些命令将允许我们创建一个基本的列表文件并初始化一个空项目。现在,我们可以开始添加东西来构建。对于迄今为止我们所用的例子中的微小单文件项目,结构确实不太重要。但是当代码量增加时会发生什么?
随着我们的解决方案在行数和文件数量上的增长,我们逐渐意识到不可避免的事情即将发生:要么我们开始分区项目,要么我们淹没在代码行和众多文件中。我们可以用两种方法来解决这个问题:通过分区 CMake 代码,或将源文件移动到子目录中。在这两种情况下,我们都旨在遵循一个称为关注点分离的设计原则。简单来说,就是将代码分成块,将具有紧密相关功能的代码分组,同时将其他代码片段解耦,以创建强大的边界。
在第一章《CMake 的初步步骤》中讨论列表文件时,我们稍微提到了分区 CMake 代码。我们讨论了include()
命令,该命令允许 CMake 执行来自外部文件的代码。调用include()
不会引入任何未在文件中定义的作用域或隔离(如果包含的文件包含函数,那么在调用时它们的作用域将会被正确处理)。
这种方法有助于关注点的分离,但效果有限——专用代码被提取到单独的文件中,甚至可以跨不相关的项目共享,但如果作者不小心,它仍然可能会用其内部逻辑污染全局变量作用域。编程中的一个古老真理是,即使是最糟糕的机制也比最好的意图好。我们将在后面学习如何解决这个问题,但现在,让我们将重点转移到源代码上。
让我们考虑一个支持小型汽车租赁公司的软件示例——它将有很多源文件,定义软件的不同方面:管理客户、车辆、停车位、长期合同、维护记录、员工记录等等。如果我们把这些文件都放在一个单一的目录中,找到任何东西都将是一场噩梦。因此,我们在项目的主目录中创建多个目录,并将相关文件移入其中。我们的CMakeLists.txt
文件可能看起来像这样:
第三章/01-partition/CMakeLists.txt
cmake_minimum_required(VERSION 3.20.0)
project(Rental CXX)
add_executable(Rental
main.cpp
cars/car.cpp
# more files in other directories
)
这很好,但正如您所看到的,我们仍然在顶层文件中包含了嵌套目录的源文件列表!为了增加关注点的分离,我们可以将源文件列表放在另一个列表文件中,并使用前述的include()
命令和cars_sources
变量,像这样:
第三章/02-include/CMakeLists.txt
cmake_minimum_required(VERSION 3.20.0)
project(Rental CXX)
include(cars/cars.cmake)
add_executable(Rental
main.cpp
${cars_sources}
# ${more variables}
)
新的嵌套列表文件将包含以下源文件:
第三章/02-include/cars/cars.cmake
set(cars_sources
cars/car.cpp
# cars/car_maintenance.cpp
)
CMake 将有效地在add_executable
相同的范围内设置cars_sources
,用所有文件填充该变量。这个解决方案可行,但它有几个缺点:
在简单的示例中这不是问题,但在更复杂的多级树结构中,存在多个变量在过程中使用,它可能很快变得难以调试。
这个问题在项目随时间成熟的过程中显示了其真实面目。由于没有任何粒度,我们必须对每个翻译单元一视同仁,无法指定不同的编译标志,为代码的某些部分选择更新的语言版本,以及在代码的特定区域静默警告。一切都是全局的,这意味着我们需要同时对所有源文件引入更改。
配置的任何更改都意味着所有文件都将需要重新编译,即使更改对其中一些文件来说毫无意义。
请注意,在cars.cmake
中,我们不得不提供cars/car.cpp
文件的全路径。这导致很多重复的文本破坏了可读性,违反了不要重复自己(DRY)的清洁编码原则。重命名目录将是一场斗争。
另一种方法是使用add_subdirectory()
命令,它引入了变量作用域等。让我们来看看。
常见的做法是按照文件系统的自然结构来组织项目,其中嵌套目录表示应用程序的离散元素:业务逻辑、GUI、API 和报告,最后,单独的目录包含测试、外部依赖、脚本和文档。为了支持这个概念,CMake 提供了以下命令:
add_subdirectory(source_dir [binary_dir]
[EXCLUDE_FROM_ALL])
如前所述,这将为我们的构建添加一个源目录。可选地,我们可以提供一个路径,其中将生成文件(binary_dir
)。EXCLUDE_FROM_ALL
关键字将禁用子目录中定义的目标的默认构建(我们将在下一章讨论目标)。这对于分离不需要核心功能的项目的部分(例如示例和扩展)可能很有用。
此命令将在source_dir
路径(相对于当前目录评估)中寻找一个CMakeLists.txt
文件。然后在该目录作用域中解析该文件,意味着前面方法中提到的所有缺陷都不存在:
CMakeLists.txt
文件不需要构建无关的目标。
让我们来看一个使用add_subdirectory()
的项目:
chapter03/03-add_subdirectory# tree -A
.
├── CMakeLists.txt
├── cars
│ ├── CMakeLists.txt
│ ├── car.cpp
│ └── car.h
└── main.cpp
这里,我们有两个CMakeLists.txt
文件。顶层文件将使用嵌套目录cars
:
chapter03/02-add_subdirectory/CMakeLists.txt
cmake_minimum_required(VERSION 3.20.0)
project(Rental CXX)
add_executable(Rental main.cpp)
add_subdirectory(cars)
target_link_libraries(Rental PRIVATE cars)
最后一行用于将来自cars
目录的艺术品链接到Rental
可执行文件。这是一个目标特定的命令,我们将在下一章深入讨论。让我们看看嵌套列表文件看起来如何:
chapter03/02-add_subdirectory/cars/CMakeLists.txt
add_library(cars OBJECT
car.cpp
# car_maintenance.cpp
)
target_include_directories(cars PUBLIC .)
正如你所看到的,我使用add_library()
来生成一个全局可见的目标cars
,并使用target_include_directories()
将其添加到其公共包含目录中。这允许main.cpp
不提供相对路径即可包含cars.h
文件:
#include "car.h"
我们可以在嵌套列表文件中看到add_library()
命令,所以我们是在这个例子中开始使用库了吗?实际上,不是。由于我们使用了OBJECT
关键字,我们表明我们只对生成对象文件感兴趣(与上一个例子完全一样)。我们只是将它们分组在一个逻辑目标(cars
)下。您可能已经对目标有一个大致的了解。暂时保留那个想法——我们马上就会讨论到。
在上一节中,我们简要提到了 add_subdirectory()
命令中使用的 EXCLUDE_FROM_ALL
参数。CMake 文档建议,如果我们有这样的部分存在于源树中,它们在自己的 CMakeLists.txt
文件中应该有自己的 project()
命令,这样它们就可以生成自己的构建系统,并且可以独立构建。
还有其他哪些场景会用到这个功能呢?当然。例如,一个场景是当你在一个 CI/CD 管道中构建多个 C++ 项目(也许是在构建框架或一系列库时)。另一种情况可能是,你可能正在从遗留解决方案(如 GNU Make)中移植构建系统,该解决方案使用普通的 makefiles。在这种情况下,你可能需要一个选项,逐步将事物分解成更独立的单元——可能要把它们放在单独的构建管道中,或者只是为了在一个更小的范围内工作,这可以被如 CLion 这样的 IDE 加载。
你可以通过在嵌套目录中的 listfile 添加 project()
命令来实现。只是不要忘记用 cmake_minimum_required()
它前缀。
由于支持项目嵌套,我们能否 somehow 连接并排构建的相关项目?
技术上可以从一个项目到达另一个项目,CMake 也在一定程度上支持这一点。甚至还有一个 load_cache()
命令,允许你从另一个项目的缓存中加载值。话说回来,这并不是一个常规或推荐的使用场景,它会导致循环依赖和项目耦合的问题。最好避免使用这个命令,并做出决定:我们的相关项目应该嵌套、通过库连接,还是合并成一个项目?
这些是我们可用的分区工具:包括 listfiles、添加子目录和嵌套项目。但我们应该如何使用它们,使我们的项目保持可维护性、易于导航和扩展?为了实现这一点,我们需要一个定义良好的项目结构。
众所周知,随着项目增长,在 listfiles 和源代码中找到东西变得越来越难。因此,从一开始就保持项目卫生非常重要。
想象一个场景,你需要交付一些重要、时间敏感的更改,它们不适合你的项目中的两个目录之一。现在,你需要快速推送一个 cleanup commit ,引入更多的目录和另一层文件层次结构,以便你的更改有一个好的地方放置。或者(更糟糕的是),你决定只是把它们推到任何地方,并创建一个票证稍后处理问题。
在整个一年中,这些工单积累,技术债务增长,维护代码的成本也在增加。当需要快速修复现场系统的严重错误,且不熟悉代码库的人需要引入他们的更改时,这变得极其麻烦。
所以,我们需要一个好的项目结构。但这意味着什么?我们可以从软件开发的其他领域(例如,系统设计)借鉴几条规则。项目应该具有以下特征:
没有一种单一公认的解决方案,但在网上可用的众多项目结构模板中,我建议遵循这个模板,因为它简单且非常可扩展:
图 3.1 – 项目结构示例
这个项目概述了以下组件的目录结构:
cmake
:包括宏和函数、find_modules 以及一次性脚本
src
:将存储我们的二进制文件和库的源代码
doc
:用于构建文档
extern
:我们从中源代码构建的外部项目的配置
test
:包含自动化测试的代码
在这种结构中,CMakeLists.txt
文件应该存在于以下目录中:顶级项目目录、src
、doc
、extern
和 test
。主列表文件不应该声明任何自身的构建步骤,而是应该使用 add_subdirectory()
命令来执行嵌套目录中的所有列表文件。如果有需要,这些还可以将这项工作委托给更深层次的目录。
注意
一些开发者建议将可执行文件与库分开,创建两个顶级目录(src
和 lib
),而不是一个。CMake 将这两种工件同等对待,在这种层次上进行分离并不真正重要。
在 src
目录中有多个目录对于大型项目来说非常有用。但如果你只是构建一个可执行文件或库,你可以跳过它们,并将源文件直接存储在 src
中。无论如何,记得在那里添加一个 CMakeLists.txt
文件,并执行任何嵌套的列表文件。
你的目标文件树可能看起来是这样的:
图 3.2 – 可执行文件的目录结构
我们在app1
目录的根目录中看到一个CMakeLists.txt
文件——它将配置关键的项目设置并包括嵌套目录中的所有列表文件。src
目录包含另一个CMakeLists.txt
文件以及.cpp
实现文件:两个类和带有可执行程序入口点的主文件。CMakeLists.txt
文件应该定义一个目标,使用这些源文件构建一个可执行文件——我们将在下一章学习如何做到这一点。
我们的头文件放在include
目录中——这些文件被.cpp
实现文件用来声明来自其他 C++翻译单元的符号。
我们有一个test
目录来存储自动化测试的源代码,我们还有lib3
,它包含了一个只针对这个可执行文件的库(项目其他地方使用的库或导出到项目外的库应该放在src
目录中)。
这个结构非常具有表现力,并允许项目的许多扩展。随着我们不断添加更多的类,我们可以很容易地将它们分组到库中,以加快编译过程。让我们看看库的样子:
](https://gitee.com/OpenDocCN/freelearn-c-cpp-pt2-zh/raw/master/docs/mdn-cmk-cpp/img/Figure_3.4.jpg)
图 3.3 – 库的目录结构
结果证明,库遵循与可执行文件相同的结构,只有一个小的区别:在include
目录中有一个可选的lib3
目录。只有当我们从项目中外部使用库时,这个目录才应该存在。它提供了其他项目在编译时将消耗的公共头文件。我们将在第五章*,使用 CMake 编译 C++源代码中回到这个主题,构建我们自己的库。
所以,我们已经讨论了文件是如何布局在目录结构中的。现在,是时候看看单独的CMakeFiles.txt
文件是如何组合成一个项目的,以及它们在大场景中的作用。
图 3.4 – CMake 如何将列表文件合并到一个项目中
在图 3.4中,每个框代表了一个位于特定目录中的CMakeLists.txt
文件列表,而草体字中的标签代表了每个文件执行的动作(从上到下)。让我们从 CMake 的角度再次分析这个项目:
cmake
目录的文件,以便它们的内容在全局范围内可用。
src
目录的作用域,通过调用add_subdirectory(src bin)
命令(我们想将编译后的工件放在<binary_tree>/bin
中,而不是<binary_tree>/src
)。
src/CMakeLists.txt
文件并发现它的唯一目的是添加四个嵌套子目录:app1
、app2
、lib1
和 lib2
。
app1
的变量作用域,并了解了一个嵌套库 lib3
,该库拥有自己的 CMakeLists.txt
文件;然后进入了 lib3
的作用域。
lib3
库添加了一个与名称相同的静态库目标。CMake 返回 app1
的父作用域。
app1
子目录添加了一个依赖于 lib3
的可执行文件。CMake 返回 src
的父作用域。
add_subdirectory()
调用完成。
add_subdirectory(doc)
、add_subdirectory(extern)
和 add_subdirectory(test)
。每次,CMake 进入新的作用域并从相应的列表文件中执行命令。
我们需要记住,前面的步骤是按照我们编写命令的准确顺序发生的。有时这很重要,而其他时候,则不那么重要。我们在下一章解决这个问题。
所以,创建包含项目所有元素的目录的正确时机是什么时候呢?我们应该从一开始就创建未来所需的一切并保持目录空空如也,还是等到我们实际上需要放入其自己类别的文件时再做呢?这是一个选择——我们可以遵循极端编程规则 YAGNI(你不需要它),或者我们可以尝试使我们的项目具有未来性,并为即将到来的新开发者打下良好的基础。
尝试在這些方法之间寻求良好的平衡——如果你怀疑你的项目可能有一天需要一个 extern
目录,那么添加它(你可能需要创建一个空白的 .keep
文件以将目录检入仓库)。为了帮助其他人知道将他们的外部依赖项放在哪里,创建一个 readme
文件,为未来踏上这条道路的 less 经验丰富的程序员铺平道路。你自己可能已经注意到了这一点:开发者不愿意创建目录,尤其是在项目的根目录中。如果我们提供一个好的项目结构,人们倾向于遵循它。
一些项目可以在几乎所有的环境中构建,而其他项目则非常关注它们的特定环境。顶层列表文件是评估如何进行项目的最佳位置,取决于有什么可用。让我们来看看如何做到这一点。
CMake 提供了多种查询环境的方法,使用CMAKE_
变量、ENV
变量和特殊命令。例如,收集的信息可以用来支持跨平台脚本。这些机制允许我们避免使用可能不易移植或在不同环境中命名不同的平台特定 shell 命令。
对于性能关键的应用程序,了解目标平台的所有特性(例如,指令集、CPU 核心数等)将很有用。然后可以将这些信息传递给编译后的二进制文件,以便它们可以被完美地调整(我们将在下一章学习如何做到这一点)。看看 CMake 中 native 提供了哪些信息。
有许多场合知道目标操作系统是有用的。即使是像文件系统这样平凡的东西,在 Windows 和 Unix 之间也有很大的不同,比如大小写敏感性、文件路径结构、扩展名的存在、权限等。在一个系统上大多数命令在另一个系统上可能不可用,或者它们可能命名不同(即使只是一个字母——例如,ifconfig
和ipconfig
命令)。
如果你需要用一个 CMake 脚本支持多个目标操作系统,只需检查CMAKE_SYSTEM_NAME
变量,以便你可以相应地采取行动。这是一个简单的例子:
if(CMAKE_SYSTEM_NAME STREQUAL "Linux")
message(STATUS "Doing things the usual way")
elseif(CMAKE_SYSTEM_NAME STREQUAL "Darwin")
message(STATUS "Thinking differently")
elseif(CMAKE_SYSTEM_NAME STREQUAL "Windows")
message(STATUS "I'm supported here too.")
elseif(CMAKE_SYSTEM_NAME STREQUAL "AIX")
message(STATUS "I buy mainframes.")
else()
message(STATUS "This is ${CMAKE_SYSTEM_NAME} speaking.")
endif()
如果需要,有一个包含操作系统版本的变量:CMAKE_SYSTEM_VERSION
。然而,我的建议是尽量使你的解决方案尽可能系统无关,并使用内置的 CMake 跨平台功能。特别是在操作文件系统时,你应该使用附录部分描述的file()
命令。
在一台机器上编译代码,然后在另一台机器上运行,这被称为交叉编译。你可以(使用正确的工具集)在 Windows 机器上运行 CMake 来为 Android 编译应用程序。交叉编译不在本书的范围内,但了解它如何影响 CMake 的某些部分是非常重要的。
允许交叉编译的必要步骤之一是将CMAKE_SYSTEM_NAME
和CMAKE_SYSTEM_VERSION
变量设置为目标操作系统(CMake 文档中将其称为目标系统)的适当值。用于执行构建的操作系统称为宿主系统。
无论配置如何,宿主系统上的信息总是可以通过带有HOST
关键词的变量访问:CMAKE_HOST_SYSTEM
、CMAKE_HOST_SYSTEM_NAME
、CMAKE_HOST_SYSTEM_PROCESSOR
和CMAKE_HOST_SYSTEM_VERSION
。
还有一些变量在其名称中带有HOST
关键字,所以只需记住它们明确地引用了宿主系统。否则,所有变量都引用目标系统(通常是宿主系统,除非我们进行交叉编译)。
如果你对交叉编译感兴趣,我建议参考 CMake 文档在cmake.org/cmake/help/latest/manual/cmake-toolchains.7.html
。
CMake 将预定义一些变量,提供关于宿主和目标系统的信息。如果使用特定的系统,相应的变量将被设置为非假值(即1
或true
):
ANDROID
, APPLE
, CYGWIN
, UNIX
, IOS
, WIN32
, WINCE
, WINDOWS_PHONE
CMAKE_HOST_APPLE
, CMAKE_HOST_SOLARIS
, CMAKE_HOST_UNIX
, CMAKE_HOST_WIN32
WIN32
和CMAKE_HOST_WIN32
变量对于 32 位和 64 位的 Windows 和 MSYS 版本以及为了遗留原因而保持为真。另外,UNIX
对于 Linux、macOS 和 Cygwin 也为真。
CMake 可以提供更多的变量,但为了节省时间,它不查询环境中的罕见信息,例如处理器是否支持 MMX或总物理内存是多少。这并不意味着这些信息不可用——你只需要通过以下命令显式地请求它:
cmake_host_system_information(RESULT <VARIABLE> QUERY <KEY>…)
我们需要提供一个目标变量和我们要关注的键列表。如果我们只提供一个键,变量将包含一个值;否则,它将是一组值。我们可以询问许多关于环境和操作系统的详细信息:
如果需要,我们甚至可以查询处理器特定信息:
在 64 位架构中,内存地址、处理器寄存器、处理器指令、地址总线和数据总线都是 64 位的。虽然这是一个简化的定义,但它给出了 64 位平台与 32 位平台有何不同的粗略概念。
在 C++中,不同的架构意味着一些基本数据类型(int
和long
)和指针有不同的位宽。CMake 利用指针大小来收集目标机器的信息。通过CMAKE_SIZEOF_VOID_P
变量可获得此信息,对于 64 位该值为8
(因为指针是 8 字节宽)和对于 32 位该值为4
(4 字节):
if(CMAKE_SIZEOF_VOID_P EQUAL 8)
message(STATUS "Target is 64 bits")
endif()
架构可以是大端或小端。字节序是数据字中的字节顺序或处理器的自然数据单位。一个大端系统将最高有效字节存储在最低的内存地址,最低有效字节存储在最高的内存地址。一个小端系统与此相反。
在大多数情况下,字节顺序不影响,但当你编写需要可移植的位操作代码时,CMake 将提供 BIG_ENDIAN
或 LITTLE_ENDIAN
值,存储在 CMAKE_<LANG>_BYTE_ORDER
变量中,其中 <LANG>
是 C
、CXX
、OBJC
或 CUDA
。
现在我们已经知道如何查询环境,让我们将重点转移到项目的主要设置上。
对于 CMake 项目,工具链包括构建和运行应用程序的所有工具——例如,工作环境、生成器、CMake 执行文件本身以及编译器。
想象一下一个经验较少的使用者在构建过程中遇到一些神秘的编译和语法错误时会感到怎样。他们不得不深入源代码试图了解发生了什么。经过一个小时的调试后,他们发现正确的解决方案是更新他们的编译器。我们能否为用户提供更好的体验,并在开始构建前检查编译器中是否包含了所有必需的功能?
当然!有方法可以指定这些要求。如果工具链不支持所有必需的功能,CMake 将提前停止并显示发生了什么清晰的消息,要求用户介入。
我们可能首先想要做的是设置编译器需要支持的 C++ 标准,如果用户想要构建我们的项目的话。对于新项目,这至少应该是 C++14,但最好是 C++17 或 C++20。CMake 还支持将标准设置为实验性的 C++23,但那只是一个草案版本。
注意
自 C++11 正式发布以来已经过去了 10 年,它不再被认为是现代 C++ 标准。除非你的目标环境非常老旧,否则不建议用这个版本开始项目。
坚持旧标准的原因之一是因为你在构建太难升级的遗留目标。然而,C++ 委员会非常努力地保持 C++ 的向后兼容性,在大多数情况下,你将不会有任何问题将标准提升到更高版本。
CMake 支持基于每个目标单独设置标准,这意味着你可以拥有任何粒度。我相信最好让整个项目统一到一个标准上。这可以通过将 CMAKE_CXX_STANDARD
变量设置为以下值之一来实现:98
、11
、14
、17
、20
或 23
(自 CMake 3.20 起)。这将作为所有随后定义的目标的默认值(所以最好在根列表文件的顶部附近设置它)。如果需要,你可以按每个目标单独覆盖它,像这样:
set_property(TARGET <target> PROPERTY CXX_STANDARD <standard>)
上文提到的CXX_STANDARD
属性即使编译器不支持期望的版本——它也被视为一个偏好。CMake 不知道我们的代码实际上是否使用了在之前的编译器中不可用的全新特性,并且它会尝试利用可用的所有内容。
如果我们确信这将不会成功,我们可以设置另一个默认标志(它可以通过与前一个相同的方式覆盖)并明确要求我们目标的标准:
set(CMAKE_CXX_STANDARD_REQUIRED ON)
在这种情况下,如果最新的编译器不在系统当中(在这个例子中,GNU GCC 11
),用户将只看到以下消息,并且构建将停止:
Target "Standard" requires the language dialect "CXX23" (with compiler extensions), but CMake does not know the compile flags to use to enable it.
要求 C++23 可能有点过分,即使在一个现代环境中。但 C++14 应该完全没问题,因为它自 2015 年以来已经在GCC/Clang中得到全面支持。
根据你在组织中实施的政策,你可能对允许或禁用供应商特定的扩展感兴趣。这些是什么?嗯,我们可以说 C++标准对于一些编译器生产商来说进展得太慢,所以他们决定向语言添加他们自己的增强——如果你喜欢的话,就是插件。为了实现这一点,CMake 将把-std=gnu++14
添加到编译命令行中,而不是-std=c++14
。
一方面,这可能是想要的,因为它允许一些方便的功能。但另一方面,如果你的代码切换到不同的编译器(或者你的用户这样做!)构建将失败!
这也是一个针对每个目标的属性,其有一个默认变量,CMAKE_CXX_EXTENSIONS
。CMake 在这里更加宽松,除非我们明确告诉它不要这样做,否则允许扩展:
set(CMAKE_CXX_EXTENSIONS OFF)
如果可能的话,我建议这样做,因为此选项将坚持拥有与供应商无关的代码。此类代码不会对用户施加任何不必要的要求。类似地,你可以使用set_property()
按每个目标的基础更改此值。
通常,编译器在单个翻译单元的层面上优化代码,这意味着你的.cpp
文件将被预处理、编译,然后优化。后来,这些文件将被链接器用来构建单一的二进制文件。现代编译器可以在链接后进行优化(这称为链接时优化),以便所有编译单元可以作为一个单一模块进行优化。
如果你的编译器支持跨过程优化,使用它可能是个好主意。我们将采用与之前相同的方法。此设置的默认变量称为CMAKE_INTERPROCEDURAL_OPTIMIZATION
。但在设置之前,我们需要确保它被支持以避免错误:
include(CheckIPOSupported)
check_ipo_supported(RESULT ipo_supported)
if(ipo_supported)
set(CMAKE_INTERPROCEDURAL_OPTIMIZATION True)
endif()
正如你所见,我们不得不包含一个内置模块来获取check_ipo_supported()
命令的访问权限。
如我们之前讨论的,如果我们的构建失败,最好是早点失败,这样我们就可以向用户提供一个清晰的反馈信息。我们特别感兴趣的是衡量哪些 C++特性被支持(以及哪些不被支持)。CMake 将在配置阶段询问编译器,并将可用特性的列表存储在CMAKE_CXX_COMPILE_FEATURES
变量中。我们可以编写一个非常具体的检查,询问某个特性是否可用:
chapter03/07-features/CMakeLists.txt
list(FIND CMAKE_CXX_COMPILE_FEATURES
cxx_variable_templates result)
if(result EQUAL -1)
message(FATAL_ERROR "I really need variable templates.")
endif()
正如您可能猜到的,为每个使用特性编写一个测试文件是一项艰巨的任务。即使是 CMake 的作者也建议只检查某些高级元特性是否存在:cxx_std_98
、cxx_std_11
、cxx_std_14
、cxx_std_17
、cxx_std_20
和cxx_std_23
。每个元特性都表明编译器支持特定的 C++标准。如果您愿意,您可以像前一个示例中那样使用它们。
已知于 CMake 的所有特性的完整列表可以在文档中找到:
cmake.org/cmake/help/latest/prop_gbl/CMAKE_CXX_KNOWN_FEATURES.html
当我用 GCC 4.7.x 编译一个应用程序时,有一个特别有趣的场景出现在我面前。我已手动在编译器的参考资料中确认了我们使用的所有 C++11 特性都被支持。然而,解决方案仍然无法正确工作。代码默默地忽略了标准<regex>
头文件的调用。结果证明,GCC 4.7.x 有一个 bug,正则表达式库没有被实现。
没有一个单一的检查能保护你免受此类 bug 的影响,但通过创建一个测试文件,你可以填入所有你想检查的特性,从而有机会减少这种行为。CMake 提供了两个配置时间命令,try_compile()
和try_run()
,以验证您需要的所有内容在目标平台上是否支持。
第二个命令给您更多的自由,因为您可以确保代码不仅编译成功,而且执行也正确(您可以潜在地测试regex
是否工作)。当然,这对于交叉编译场景不起作用(因为主机无法运行为不同目标构建的可执行文件)。只需记住,这个检查的目的是在编译成功时向用户提供一个快速的反馈,所以它并不是用来运行任何单元测试或其他复杂内容的——尽量保持文件尽可能简单。例如,像这样:
chapter03/08-test_run/main.cpp
#include <iostream>
int main()
{
std::cout << "Quick check if things work." << std::endl;
}
调用test_run()
其实并不复杂。我们首先设置所需的标准,然后调用test_run()
,并将收集的信息打印给用户:
chapter03/08-test_run/CMakeLists.txt
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
try_run(run_result compile_result
${CMAKE_BINARY_DIR}/test_output
${CMAKE_SOURCE_DIR}/main.cpp
RUN_OUTPUT_VARIABLE output)
message("run_result: ${run_result}")
message("compile_result: ${compile_result}")
message("output:\n" ${output})
这个命令有很多可选字段需要设置,一开始可能会觉得有点复杂,但当我们阅读并与示例中的调用进行比较时,一切都会变得明朗起来:
try_run(<runResultVar> <compileResultVar>
<bindir> <srcfile> [CMAKE_FLAGS <flags>...]
[COMPILE_DEFINITIONS <defs>...]
[LINK_OPTIONS <options>...]
[LINK_LIBRARIES <libs>...]
[COMPILE_OUTPUT_VARIABLE <var>]
[RUN_OUTPUT_VARIABLE <var>]
[OUTPUT_VARIABLE <var>]
[WORKING_DIRECTORY <var>]
[ARGS <args>...])
只有几个字段是编译和运行一个非常基础的测试文件所必需的。我还使用了可选的RUN_OUTPUT_VARIABLE
关键字来收集stdout
的输出。
下一步是使用我们在实际项目中将要使用的更现代的 C++特性来扩展这个简单的文件——也许通过添加一个可变模板来看目标机器上的编译器是否能消化它。每次我们在实际项目中引入一个新特性,我们可以在测试文件中放入这个特性的微小样本。但请记住——保持简洁。我们希望在最短的时间内检查编译是否成功。
最后,我们可以在条件块中检查收集的输出是否符合我们的期望,当有些不对劲时会打印message(SEND_ERROR)
。记住SEND_ERROR
会在配置阶段继续,但不会启动生成。这有助于在放弃构建之前显示所有遇到的错误。
在第1章,《CMake 的初步步骤》中,我们讨论了源内构建,以及建议始终指定为源外构建路径。这不仅允许更干净的构建树和更简单的.gitignore
文件,而且还减少了你意外覆盖或删除任何源文件的可能性。
在网上搜索解决方案时,你可能会偶然发现一个 StackOverflow 帖子,提出了同样的问题:stackoverflow.com/q/1208681/6659218
。在这里,作者注意到不管你做什么,似乎 CMake 仍然会创建一个CMakeFiles/
目录和一个CMakeCache.txt
文件。一些答案建议使用未记录的变量,以确保用户在任何情况下都不能在源目录中写入:
# add this options before PROJECT keyword
set(CMAKE_DISABLE_SOURCE_CHANGES ON)
set(CMAKE_DISABLE_IN_SOURCE_BUILD ON)
我认为在使用任何软件的未记录功能时要小心,因为它们可能会在没有警告的情况下消失。在 CMake 3.20 中设置前面的变量会导致构建失败,并出现相当丑陋的错误:
CMake Error at /opt/cmake/share/cmake-3.20/Modules/CMakeDetermineSystem.cmake:203 (file):
file attempted to write a file:
/root/examples/chapter03/09-in-source/CMakeFiles/CMakeOutput.log into a source
directory.
然而,它仍然创建了提到的文件!因此,我的建议是使用更旧的——但完全支持——机制:
chapter03/09-in-source/CMakeLists.txt
cmake_minimum_required(VERSION 3.20.0)
project(NoInSource CXX)
if(PROJECT_SOURCE_DIR STREQUAL PROJECT_BINARY_DIR)
message(FATAL_ERROR "In-source builds are not allowed")
endif()
message("Build successful!")
如果 Kitware(CMake 背后的公司)正式支持CMAKE_DISABLE_SOURCE_CHANGES
或CMAKE_DISABLE_IN_SOURCE_BUILD
,那么当然,切换到那个解决方案。
我们在本章引入了许多有价值的概念,它们将为我们打下坚实的基础,以便我们向前发展并构建坚固、未来 proof 的项目。我们讨论了如何设置最低的 CMake 版本以及如何配置项目的关键方面,即名称、语言和元数据字段。
打下良好的基础将有助于确保我们的项目能够快速成长。这就是我们讨论项目分区的理由。我们分析了使用include()
的简单代码分区,并将其与add_subdirectory()
进行了比较。在此过程中,我们了解了管理变量目录作用域的好处,并探讨了使用更简单的路径和增加的模块性的好处。当需要逐步将代码分解为更独立的单元时,创建嵌套项目和分别构建它是非常有用的。
在概览了我们可用的分区机制之后,我们探讨了如何使用它们——例如,如何创建透明、有弹性且可扩展的项目结构。具体来说,我们分析了 CMake 如何遍历列表文件以及不同配置步骤的正确顺序。
接下来,我们研究了如何作用域化我们目标和宿主机器的环境,它们之间的区别是什么,以及通过不同的查询可以获取关于平台和系统的哪些信息。
最后,我们发现了如何配置工具链——例如,如何指定所需的 C++版本,如何解决特定编译器扩展的问题,以及如何启用重要的优化。我们最后了解了如何测试我们的编译器所需的特性以及编译测试文件。
虽然从技术上讲,项目所有这些就足够了,但它仍然不是一个非常有用的项目。为了改变这一点,我们需要引入目标。到目前为止,我们在这里那里提到了它们,但我试图在我们先了解一些基本概念之前避免这个话题。现在我们已经做到了,我们将详细查看它们。
关于本章涵盖的议题的更多信息,你可以参考以下内容:
nalexn.github.io/separation-of-concerns/
cmake.org/cmake/help/latest/manual/cmake-variables.7.html
现在我们已经掌握了最基本的技能,是时候更深入一点学习了。下一部分将使你能够解决在使用 CMake 构建项目时遇到的大多数情况。
我们故意关注现代、优雅的实践,避免引入太多的遗留问题。具体来说,我们将处理逻辑构建目标,而不是操纵单个文件。
接下来,我们将详细解释工具链执行的二进制工件构建步骤。这是许多关于 C++的书籍所缺少的部分:如何配置和使用预处理器、编译器和链接器,以及如何优化它们的行为。
最后,本部分将涵盖 CMake 提供管理依赖关系的所有不同方式,并解释如何为您的特定用例选择最佳方法。
本部分包括以下章节:
在 CMake 中,我们可以构建的最基本目标是一个单一的二进制可执行文件,它包含了一个完整的应用程序。它可以由单一片源代码组成,如经典的helloworld.cpp
。或者它可以更复杂——由数百个甚至数千个文件构建而成。这就是许多初学者项目的外观——用一个源文件创建一个二进制文件,再添加另一个,在不知不觉中,一切都被链接到一个没有结构可言的二进制文件中。
作为软件开发者,我们故意划设定界线,并将组件指定为将一个或多个翻译单元(.cpp
文件)分组在一起。我们这样做有多个原因:增加代码的可读性,管理耦合和 connascence,加快构建过程,最后,提取可重用的组件。
每一个足够大的项目都会推动你引入某种形式的分区。CMake 中的目标正是为了解决这个问题——一个高级逻辑单元,为 CMake 形成一个单一目标。一个目标可能依赖于其他目标,它们以声明性方式生成。CMake 将负责确定目标需要以什么顺序构建,然后逐个执行必要的步骤。作为一个一般规则,构建一个目标将生成一个 artifact,这个 artifact 将被输送到其他目标中,或作为构建的最终产品交付。
我故意使用不确切的词汇artifact,因为 CMake 并没有限制你只能生成可执行文件或库。实际上,我们可以使用生成的构建系统来创建许多类型的输出:更多的源文件、头文件、对象文件、归档文件和配置文件——任何真正需要的。我们需要的只是一个命令行工具(如编译器)、可选的输入文件和一个输出路径。
目标是一个非常强大的概念,极大地简化了项目的构建。理解它们如何工作以及如何以最优雅、最清洁的方式配置它们是关键。
在本章中,我们将涵盖以下主要主题:
您可以在 GitHub 上找到本章中存在的代码文件,地址为github.com/PacktPublishing/Modern-CMake-for-Cpp/tree/main/examples/chapter04
。
要构建本书中提供的示例,始终使用推荐的命令:
cmake -B <build tree> -S <source tree>
cmake --build <build tree>
请确保将占位符<build tree>
和<source tree>
替换为适当的路径。作为提醒:build tree是目标/输出目录的路径,source tree是源代码所在的位置路径。
如果你曾经使用过 GNU Make,你已经看到了目标的概念。本质上,它是一个构建系统用来将一组文件编译成另一个文件的食谱。它可以是一个.cpp
实现文件编译成一个.o
对象文件,一组.o
文件打包成一个.a
静态库,还有许多其他组合。
CMake 允许你节省时间并跳过那些食谱的中间步骤;它在更高的抽象级别上工作。它理解如何直接从源文件构建可执行文件。所以,你不需要编写显式的食谱来编译任何对象文件。所需的就是一个add_executable()
命令,带有可执行目标的名字和要作为其元素的文件列表:
add_executable(app1 a.cpp b.cpp c.cpp)
我们在之前的章节中已经使用了这个命令,并且我们已经知道如何在实践中使用可执行目标——在生成步骤中,CMake 将创建一个构建系统并为其填充编译每个源文件并将它们链接在一起成一个单一的二进制可执行文件的食谱。
在 CMake 中,我们可以使用三个命令之一创建一个目标:
add_executable()
add_library()
add_custom_target()
前两个相对容易解释;我们已经在之前的章节中简要使用过它们来构建可执行文件和库(我们将在第五章深入讨论它们,使用 CMake 编译 C++源代码)。但那些自定义目标是什么?
它们允许你指定自己的命令行,该命令行将被执行而不检查产生的输出是否是最新的,例如:
以下是add_custom_target()
命令的完整签名:
add_custom_target(Name [ALL] [command1 [args1...]]
[COMMAND command2 [args2...] ...]
[DEPENDS depend depend depend ... ]
[BYPRODUCTS [files...]]
[WORKING_DIRECTORY dir]
[COMMENT comment]
[JOB_POOL job_pool]
[VERBATIM] [USES_TERMINAL]
[COMMAND_EXPAND_LISTS]
[SOURCES src1 [src2...]])
我们不会在这里讨论每一个选项,因为我们想快速继续其他目标,但可以说自定义目标不必一定以文件形式产生有形工件。
自定义目标的一个好用例可能是需要在每次构建时删除特定文件的需求——例如,确保代码覆盖报告不包含过时数据。我们只需要像这样定义一个自定义目标:
add_custom_target(clean_stale_coverage_files
COMMAND find . -name "*.gcda" -type f -delete)
之前的命令将搜索所有具有.gcda
扩展名的文件并将它们删除。但是有一个问题;与可执行文件和库目标不同,自定义目标只有在它们被添加到依赖图时才会构建。我们来了解一下那是怎么回事。
成熟的应用程序通常由许多组件组成,我这里不是指外部依赖。具体来说,我指的是内部库。从结构上讲,将它们添加到项目中是有用的,因为相关的事物被包装在单一的逻辑实体中。并且它们可以与其他目标链接——另一个库或一个可执行文件。当多个目标使用同一个库时,这尤其方便。看看图 4.1,它描述了一个示例依赖关系图:
图 4.1 – BankApp 项目中依赖关系的构建顺序
在这个项目中,我们有两个库,两个可执行文件和一个自定义目标。我们的用例是提供一个带有漂亮 GUI 的用户银行应用程序(GuiApp),以及一个作为自动化脚本一部分的命令行版本(TerminalApp)。两个可执行文件都依赖于同一个Calculations库,但只有其中一个需要Drawing库。为了确保我们的应用程序在用户从互联网下载时没有被修改,我们将计算一个校验和,将其存储在文件中,并通过单独的安全渠道分发它。CMake 在编写此类解决方案的列表文件方面相当灵活:
chapter04/01-targets/CMakeLists.txt
cmake_minimum_required(VERSION 3.19.2)
project(BankApp CXX)
add_executable(terminal_app terminal_app.cpp)
add_executable(gui_app gui_app.cpp)
target_link_libraries(terminal_app calculations)
target_link_libraries(gui_app calculations drawing)
add_library(calculations calculations.cpp)
add_library(drawing drawing.cpp)
add_custom_target(checksum ALL
COMMAND sh -c "cksum terminal_app>terminal.ck"
COMMAND sh -c "cksum gui_app>gui.ck"
BYPRODUCTS terminal.ck gui.ck
COMMENT "Checking the sums..."
)
我们使用target_link_libraries()
命令将库和可执行文件连接起来。没有它,可执行文件的编译会失败,因为存在未定义的符号。你注意到我们在这个命令在实际上声明了任何库之前就调用了吗?当 CMake 配置项目时,它会收集有关目标和它们属性的信息——它们的名称、依赖关系、源文件和其他详细信息。
在解析完所有文件后,CMake 将尝试构建一个依赖关系图。和所有有效的依赖关系图一样,它们都是有向无环图。这意味着有一个明确的方向,即哪个目标依赖于哪个目标,并且这样的依赖关系不能形成循环。
当我们以构建模式执行cmake
时,生成的构建系统将检查我们定义了哪些顶层目标,并递归地构建它们的依赖关系。让我们考虑一下来自图 4.1的例子:
cksum
是一个 Unix 校验和工具)。
不过有一个小问题——前面的解决方案并不能保证校验和目标在可执行文件之后构建。CMake 不知道校验和依赖于可执行二进制文件的存在,所以它可以先开始构建它。为了解决这个问题,我们可以把add_dependencies()
命令放在文件的末尾:
add_dependencies(checksum terminal_app gui_app)
这将确保 CMake 理解 Checksum 目标与可执行文件之间的关系。
很好,但target_link_libraries()
和add_dependencies()
之间有什么区别?第一个是用来与实际库一起使用,并允许你控制属性传播。第二个仅适用于顶级目标以设置它们的构建顺序。
随着项目复杂性的增加,依赖树变得越来越难以理解。我们如何简化这个过程?
即使小型项目也难以推理和与其他开发人员共享。最简单的方法之一是通过一个好的图表。毕竟,一张图片胜过千言万语。我们可以自己动手绘制图表,就像我在图 4.1中做的那样。但这很繁琐,并且需要不断更新。幸运的是,CMake 有一个很好的模块,可以在dot/graphviz
格式中生成依赖图。而且它支持内部和外部依赖!
要使用它,我们可以简单地执行这个命令:
cmake --graphviz=test.dot .
该模块将生成一个文本文件,我们可以将其导入到 Graphviz 可视化软件中,该软件可以渲染图像或生成 PDF 或 SVG 文件,作为软件文档的一部分。每个人都喜欢伟大的文档,但几乎没有人喜欢创建它——现在,你不需要!
如果你急于求成,甚至可以直接从你的浏览器中运行 Graphviz,地址如下:
dreampuf.github.io/GraphvizOnline/
重要说明
自定义目标默认是不可见的,我们需要创建一个特殊的配置文件CMakeGraphVizOptions.cmake
,它将允许我们自定义图表。一个方便的自定义命令是set(GRAPHVIZ_CUSTOM_TARGETS TRUE)
;将其添加到特殊配置文件中以在您的图表中启用报告自定义目标。您可以在模块的文档中找到更多选项。
你只需要将test.dot
文件的内容复制并粘贴到左侧窗口中,你的项目就会被可视化。非常方便,不是吗?
图 4.2 —— 使用 Graphviz 可视化的 BankApp 示例
为了清晰起见,我已经从前面的图中移除了自动生成的图例部分。
使用这种方法,我们可以快速查看所有明确定义的目标。现在我们有了这个全局视角,让我们深入了解一下如何配置它们。
目标具有类似于 C++对象字段的工作方式属性。我们可以修改其中的一些属性,而其他属性是只读的。CMake 定义了一个大量的“已知属性”(参见进一步阅读部分),这些属性取决于目标类型(可执行文件、库或自定义)。如果你愿意,你也可以添加你自己的属性。使用以下命令来操作目标属性:
get_target_property(<var> <target> <property-name>)
set_target_properties(<target1> <target2> ...
PROPERTIES <prop1-name> <value1>
<prop2-name> <value2> ...)
为了在屏幕上打印目标属性,我们首先需要将其存储在<var>
变量中,然后将其传递给用户;我们必须一个一个地读取它们。另一方面,为目标设置属性允许我们同时指定多个属性,在多个目标上。
属性概念不仅限于目标;CMake 也支持设置其他范围属性的:GLOBAL
、DIRECTORY
、SOURCE
、INSTALL
、TEST
和CACHE
。为了操作各种各样的属性,有通用的get_property()
和set_property()
命令。你可以使用这些底层命令来做与set_target_properties()
命令完全相同的事情,只是需要更多的工作:
set_property(TARGET <target> PROPERTY <name> <value>)
通常,尽可能使用许多高级命令是更好的。CMake 提供更多这些,甚至范围更窄,例如为目标设置特定属性。例如,add_dependencies(<target> <dep>)
是在MANUALLY_ADDED_DEPENDENCIES
目标属性上添加依赖项。在这种情况下,我们可以用get_target_property()
查询它,就像查询任何其他属性一样。然而,我们不能用set_target_property()
来更改它(它是只读的),因为 CMake 坚持使用add_dependencies()
命令来限制操作只是添加。
在接下来的章节中讨论编译和链接时,我们将介绍更多的属性设置命令。同时,让我们关注一个目标的属性如何传递到另一个目标。
我们姑且同意命名是困难的,有时最终得到的结果很难理解。“传递使用要求”不幸之一,那些你在在线 CMake 文档中遇到的神秘标题。让我们解开这个奇怪的名字,也许提出一个更容易理解的术语。
我将先澄清这个谜题的中间部分。正如我们之前讨论的,一个目标可能依赖于另一个目标。CMake 文档有时将这种依赖性称为使用,即一个目标使用另一个目标。这很简单,所以继续下一个。
有时,这样的使用目标必须满足一些特定的要求:链接一些库,包含一个目录,或者需要特定的编译特性。所有这些都是要求,所以在某种意义上文档是正确的。问题是,在文档的其他任何上下文中,它们都不被称为要求。当你为一个单一目标指定同样的要求时,你设置属性或依赖项。因此,名称的最后部分也许应该简单地称为“属性”。
最后一个是–传递的。我相信这是正确的(也许有点太聪明了)。CMake 将在使用它们的目标的某些属性/要求附加到使用它们的目标的属性上。你可以说是有些属性可以隐式地传递(或简单地传播)跨目标,所以更容易表达依赖关系。
简化这个概念,我认为这就像是传播属性,在源目标(被使用的目标)和目标目标(使用其他目标的目标)之间。
让我们通过一个具体的例子来了解它为什么存在以及它是如何工作的:
target_compile_definitions(<source> <INTERFACE|PUBLIC|PRIVATE> [items1...])
这个目标命令将填充<source>
目标的COMPILE_DEFINITIONS
属性。编译定义仅仅是传递给编译器的-Dname=definition
标志,用于配置 C++预处理器定义(我们将在第五章,使用 CMake 编译 C++源代码中讨论这个)。这里有趣的部分是第二个参数。我们需要指定三个值之一,INTERFACE
,PUBLIC
或PRIVATE
,以控制属性应该传递给哪些目标。现在,不要将这些与 C++访问修饰符混淆——这完全是另一回事。
传播关键字按如下方式工作:
PRIVATE
用于设置源目标属性。
INTERFACE
用于设置目标目标属性。
PUBLIC
用于设置源目标和目标目标属性。
当一个属性不应该传递给任何目标目标时,将其设置为PRIVATE
。当需要这样的传递时,选择PUBLIC
。如果你处于这样的情况,源目标在其实现中(.cpp
文件)不使用属性,只在头文件中使用,并且这些被传递给消费者目标,那么INTERFACE
就是答案。
这个机制是如何工作的呢?为了管理这些属性,CMake 提供了一些命令,比如前面提到的target_compile_definitions()
。当你指定一个PRIVATE
或PUBLIC
关键字时,CMake 将在与命令匹配的目标属性中存储提供的值——在这个例子中,是COMPILE_DEFINITIONS
。另外,如果关键字是INTERFACE
或PUBLIC
,它将在带有INTERFACE_
前缀的属性中存储值——INTERFACE_COMPILE_DEFINITIONS
。在配置阶段,CMake 将读取源目标的对接口属性并将它们的内容附加到目标目标。就这样——传播属性,或者按 CMake 的说法,传递使用要求。
在 CMake 3.20 中,有 12 个这样的属性通过适当的命令(如target_link_options()
)或直接通过set_target_properties()
命令进行管理:
AUTOUIC_OPTIONS
COMPILE_DEFINITIONS
COMPILE_FEATURES
COMPILE_OPTIONS
INCLUDE_DIRECTORIES
LINK_DEPENDS
LINK_DIRECTORIES
LINK_LIBRARIES
LINK_OPTIONS
POSITION_INDEPENDENT_CODE
PRECOMPILE_HEADERS
SOURCES
我们将在接下来的页面中讨论这些选项的大部分,但请记住,所有这些选项当然都在 CMake 手册中有描述。在以下 URL 格式(将<PROPERTY>
替换为你感兴趣的属性)的页面中找到它们:
https://cmake.org/cmake/help/latest/prop_tgt/<PROPERTY>.html
接下来的问题是这种传播到底有多远。属性只设置在第一个目的地目标上,还是发送到依赖图的顶部?实际上,您可以决定。
为了创建目标之间的依赖关系,我们使用target_link_libraries()
命令。这个命令的完整签名需要一个传播关键词:
target_link_libraries(<target>
<PRIVATE|PUBLIC|INTERFACE> <item>...
[<PRIVATE|PUBLIC|INTERFACE> <item>...]...)
正如你所看到的,这个签名也指定了传播关键词,但这个关键词控制着源目标中的属性在目的目标中的存储位置。图 4.3 展示了在生成阶段(在配置阶段完成后)传播属性会发生什么:
图 4.3 – 属性如何传播到目标目标
传播关键词是这样工作的:
PRIVATE
将源值附加到目的地的私有属性。
INTERFACE
将源值追加到目的地的接口属性。
PUBLIC
追加到目的地的两个属性。
正如我们之前讨论的,接口属性只用于将属性传播到链的更低层,目的目标在构建过程中不会使用它们。
我们之前使用的基本的target_link_libraries(<target> <item>...)
命令隐式地指定了PUBLIC
关键词。
如果您正确地为您的源目标设置了传播关键词,属性将自动放置在目的目标上——除非有冲突…
当一个目标依赖于多个其他目标时,可能会出现传播属性彼此之间直接冲突的情况。比如说,一个使用目标将POSITION_INDEPENDENT_CODE
属性设置为true
,而另一个设置为false
。CMake 理解这是一个冲突,并将打印一个类似于这样的错误:
CMake Error: The INTERFACE_POSITION_INDEPENDENT_CODE property of "source_target2" does not agree with the value of POSITION_INDEPENDENT_CODE already determined for "destination_target".
收到这样的消息很有用,因为我们明确知道我们引入了这场冲突,我们需要解决它。CMake 有自己的属性,这些属性必须在源目标和目标目标之间“一致”。
在少数情况下,这可能变得很重要——例如,如果您在构建使用相同库的多个目标,然后将它们链接到一个单一的可执行文件。如果这些源目标使用不同版本的同一库,您可能会遇到问题。
为了确保我们只使用同一个具体版本,我们可以创建一个自定义接口属性INTERFACE_LIB_VERSION
,并将版本存储在那里。这不足以解决问题,因为 CMake 默认不会传播自定义属性。我们必须明确地将自定义属性添加到“兼容”属性的列表中。
每个目标都有四个这样的列表:
COMPATIBLE_INTERFACE_BOOL
COMPATIBLE_INTERFACE_STRING
COMPATIBLE_INTERFACE_NUMBER_MAX
COMPATIBLE_INTERFACE_NUMBER_MIN
将你的属性添加到其中的一个,将触发传播和兼容性检查。BOOL
列表将检查传播到目标目标的所有属性是否评估为相同的布尔值。类似地,STRING
将评估为字符串。NUMBER_MAX
和NUMBER_MIN
有点不同——传播的值不必匹配,但目标目标将只是接收最高或最低的值。
这个示例将帮助我们理解如何在实践中应用这一点:
chapter04/02-propagated/CMakeLists.txt
cmake_minimum_required(VERSION 3.20.0)
project(PropagatedProperties CXX)
add_library(source1 empty.cpp)
set_property(TARGET source1 PROPERTY INTERFACE_LIB_VERSION
4)
set_property(TARGET source1 APPEND PROPERTY
COMPATIBLE_INTERFACE_STRING LIB_VERSION
)
add_library(source2 empty.cpp)
set_property(TARGET source2 PROPERTY INTERFACE_LIB_VERSION
4)
add_library(destination empty.cpp)
target_link_libraries(destination source1 source2)
在这里我们创建了三个目标;为了简单起见,所有这些都使用了同一个空源文件。在两个源目标上,我们用INTERFACE_
前缀指定了我们的自定义属性。并将它们设置为相同的匹配库版本。两个源目标都被链接到目标目标。最后,我们为source1
指定了一个STRING
兼容性要求作为属性(这里我们没有添加INTERFACE_
前缀)。
CMake 会将这个自定义属性传播到目标目标,并检查所有源目标的版本是否完全匹配(兼容性属性可以设置在一个目标上)。
既然我们已经了解了目标是什么,那么让我们来看看其他看起来像目标、闻起来像目标、有时表现得像目标的东西,但最终发现,它们并不是真正的目标。
目标的概念如此有用,以至于如果能够将其一些行为借鉴到其他事物上也很好。具体来说,这些事物不是构建系统的输出,而是输入——外部依赖项、别名等等。这些都是伪目标,或者没有包含在生成的构建系统中的目标。
如果你浏览了目录,你知道我们将讨论 CMake 如何管理外部依赖项——其他项目、库等等。IMPORTED
目标本质上就是这一过程的产物。CMake 可以通过find_package()
命令定义它们。
你可以调整此类目标的目标属性:编译定义、编译 选项、包含目录等——并且它们甚至支持传递性使用要求。然而,你应该将它们视为不可变的;不要更改它们的源代码或依赖关系。
IMPORTED
目标定义的范围可以是全局的或局部的(在定义它的目录中可见,但在父目录中不可见)。
别名目标正好做了你应该期望的事情——它们以不同的名称创建了另一个目标引用。您可以使用以下命令为可执行文件和库创建别名目标:
add_executable(<name> ALIAS <target>)
add_library(<name> ALIAS <target>)
别名目标的属性是只读的,并且你不能安装或导出别名(它们在生成的构建系统中不可见)。
那么,为什么要有别名呢?它们在某些场景中非常有用,比如一个项目的某个部分(如子目录)需要一个具有特定名称的目标,而实际实现可能根据情况不同而有不同的名称。例如,你可能希望构建一个随解决方案一起分发的库,或者根据用户的选择来导入它。
这是一个有趣的构造——一个不编译任何内容而是作为工具目标的库。它的整个概念都是围绕传播属性(传递使用要求)构建的。
接口库有两个主要用途——代表仅头文件的库,以及将一堆传播属性捆绑到单个逻辑单元中。
使用add_library(INTERFACE)
可以相对容易地创建仅头文件库:
add_library(Eigen INTERFACE
src/eigen.h src/vector.h src/matrix.h
)
target_include_directories(Eigen INTERFACE
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/src>
$<INSTALL_INTERFACE:include/Eigen>
)
在前面的片段中,我们创建了一个包含三个头文件的 Eigen 接口库。接下来,使用生成表达式(在本章最后部分解释),我们将其包含目录设置为当目标导出时为${CMAKE_CURRENT_SOURCE_DIR}/src
,安装时为include/Eigen
(这将在本章最后解释)。
要使用这样的库,我们只需要链接它:
target_link_libraries(executable Eigen)
这里实际上并不发生链接,但 CMake 会理解这个命令为一个请求,将所有的INTERFACE
属性传播到executable
目标。
第二个用例正好利用了相同的机制,但目的不同——它创建了一个逻辑目标,可以作为传播属性的占位符。我们随后可以用这个目标作为其他目标依赖,并以一种清晰、方便的方式设置属性。这是一个例子:
add_library(warning_props INTERFACE)
target_compile_options(warning_props INTERFACE
-Wall -Wextra -Wpedantic
)
target_link_libraries(executable warning_props)
add_library(INTERFACE)
命令创建了一个逻辑warning_props
目标,用于在第二个命令中设置编译选项在executable
目标上。我建议使用这些INTERFACE
目标,因为它们可以提高代码的可读性和可重用性。把它看作是将一串魔法值重构为命名良好的变量。我还建议使用_props
后缀以便于区分接口库和常规库。
伪目标是否穷尽了目标的概念?当然不是!那将会太简单了。我们还需要理解这些目标如何翻译成生成的构建系统。
目标这个词有点含义丰富。在项目上下文和生成的构建系统上下文中,它的意思不同。当 CMake 生成一个构建系统时,它将 CMake 语言的列表文件“编译”成所选构建工具的语言;也许它为 GNU Make 创建了一个 Makefile。这样的 Makefile 有自己的目标——其中一些是列表文件目标的直接转换,其他的是隐式创建的。
其中一个构建系统目标是ALL
,CMake 默认生成的包含所有顶层列表文件目标的构建系统,如可执行文件和库(不一定是自定义目标)。当我们运行cmake --build <build tree>
命令而没有选择一个具体的目标时,会构建ALL
。正如您可能还记得第一章的内容,您可以通过向 preceding command 添加--target <name>
参数来选择一个。
有些可执行文件或库可能不是每个构建都需要,但我们希望将它们作为项目的一部分,以便在那些罕见的情况下它们有用。为了优化我们的默认构建,我们可以像这样将它们从ALL
目标中排除:
add_executable(<name> EXCLUDE_FROM_ALL [<source>...])
add_library(<name> EXCLUDE_FROM_ALL [<source>...])
自定义目标的工作方式与默认方式相反——默认情况下,它们被排除在ALL
目标之外,除非你明确用ALL
关键字定义它们,就像我们在 BankApp 示例中做的那样。
另一个隐式定义的构建目标是clean
,它简单地从构建树中删除生成的工件。我们用它来清除所有旧文件,从头开始构建一切。不过,重要的是要理解它并不是简单地删除构建目录中的所有内容。这意味着,为了让clean
正确工作,你需要手动指定任何自定义目标可能创建的文件作为BYPRODUCTS
(参见 BankApp 示例)。
还有一种有趣的自定义非目标机制可以创建可以在所有实际目标中使用的自定义工件——自定义命令。
使用自定义目标有一个缺点——一旦你把它们添加到ALL
目标中,或者开始让它们为其他目标提供依赖,它们将会在每次构建时都被构建(你仍然可以在if
块中启用它们以限制这种情况)。有时,这正是你所需要的,但在某些情况下,需要自定义行为来生成不应该在没有理由的情况下重新创建的文件:
自定义命令有两个签名。第一个是add_custom_target()
的扩展版本:
add_custom_command(OUTPUT output1 [output2 ...]
COMMAND command1 [ARGS] [args1...]
[COMMAND command2 [ARGS] [args2...] ...]
[MAIN_DEPENDENCY depend]
[DEPENDS [depends...]]
[BYPRODUCTS [files...]]
[IMPLICIT_DEPENDS <lang1> depend1
[<lang2> depend2] ...]
[WORKING_DIRECTORY dir]
[COMMENT comment]
[DEPFILE depfile]
[JOB_POOL job_pool]
[VERBATIM] [APPEND] [USES_TERMINAL]
[COMMAND_EXPAND_LISTS])
正如你可能猜到的那样,自定义命令不会创建一个逻辑目标,但与自定义目标一样,它也必须添加到依赖图中。这样做有两种方式——使用其输出工件作为可执行文件(或库)的源,或者明确将其添加到自定义目标的一个DEPENDS
列表中。
诚然,不是每个项目都需要从其他文件生成 C++代码。一个这样的场合可能是.proto
文件的编译。如果你不熟悉这个库,protobuf 是一个适用于结构化数据的平台中立二进制序列化器。为了同时保持跨平台和快速,谷歌的工程师们发明了他们自己的 protobuf 格式,该格式在.proto
文件中定义模型,例如这个:
message Person {
required string name = 1;
required int32 id = 2;
optional string email = 3;
}
这样的文件可以跨多种语言共享——C++、Ruby、Go、Python、Java 等等。Google 提供了编译器,用于读取.proto
文件并输出适用于所选语言的结构和序列化代码。明智的工程师不会将编译后的文件提交到仓库,而是会使用原始的 protobuf 格式,并将其添加到构建链中。
我们尚不知道如何检测目标主机上是否可用的 protobuf 编译器(我们将在第七章中学习,使用 CMake 管理依赖)。所以,现在,让我们假设编译器的protoc
命令位于系统知道的某个位置。我们已经准备了一个person.proto
文件,并且知道 protobuf 编译器将输出person.pb.h
和person.pb.cc
文件。下面是我们定义一个自定义命令来编译它们的示例:
add_custom_command(OUTPUT person.pb.h person.pb.cc
COMMAND protoc ARGS person.proto
DEPENDS person.proto
)
然后,为了允许我们的可执行文件进行序列化,我们只需将输出文件添加到源文件中:
add_executable(serializer serializer.cpp person.pb.cc)
假设我们正确处理了头文件的包含和 protobuf 库的链接,当我们对.proto
文件进行更改时,一切都会自动编译和更新。
一个简化(且实用性远小于)的例子可能是通过从另一个位置复制来创建所需的头文件:
chapter04/03-command/CMakeLists.txt
add_executable(main main.cpp constants.h)
target_include_directories(main PRIVATE
${CMAKE_BINARY_DIR})
add_custom_command(OUTPUT constants.h
COMMAND cp
ARGS "${CMAKE_SOURCE_DIR}/template.xyz" constants.h)
在这个例子中,我们的“编译器”是cp
命令。它通过从源树复制到构建树根目录创建一个constants.h
文件,从而满足main
目标的依赖关系。
add_custom_command()
命令的第二版引入了一种在构建目标之前或之后执行命令的机制:
add_custom_command(TARGET <target>
PRE_BUILD | PRE_LINK | POST_BUILD
COMMAND command1 [ARGS] [args1...]
[COMMAND command2 [ARGS] [args2...] ...]
[BYPRODUCTS [files...]]
[WORKING_DIRECTORY dir]
[COMMENT comment]
[VERBATIM] [USES_TERMINAL]
[COMMAND_EXPAND_LISTS])
我们可以在第一个参数中指定我们希望用新行为“增强”的目标,并在以下条件下进行:
PRE_BUILD
会在执行此目标的其他规则之前运行(仅限 Visual Studio 生成器;对于其他生成器,它表现得像PRE_LINK
)。
PRE_LINK
在所有源文件编译完成后,但在链接(或归档)目标之前运行命令。它不适用于自定义目标。
POST_BUILD
将在执行完此目标的的所有其他规则后运行。
使用这个版本的add_custom_command()
,我们可以复制之前 BankApp 示例中的校验和生成:
chapter04/04-command/CMakeLists.txt
cmake_minimum_required(VERSION 3.20.0)
project(Command CXX)
add_executable(main main.cpp)
add_custom_command(TARGET main POST_BUILD
COMMAND cksum
ARGS "$<TARGET_FILE:main>" > "main.ck")
在main可执行文件的构建完成后,CMake 将执行cksum命令,并提供参数。但第一个参数中发生了什么?它不是一个变量,因为它会被大括号({})包围,而不是尖括号(<>)。它是一个生成表达式,评估目标二进制文件的完整路径。这种机制在许多目标属性上下文中很有用。
CMake 解决方案的构建分为三个阶段——配置、生成和运行构建工具。通常,我们在配置阶段拥有所有必需的数据。但是偶尔,我们会遇到鸡和蛋的问题。拿前面小节的一个例子来说——一个目标需要知道另一个目标的可执行文件路径。但是这些信息在所有列表文件被解析和配置阶段完成后才可用。
我们如何处理这类问题呢?我们可以为这些信息创建一个占位符,并将其评估推迟到下一阶段——生成阶段。
这就是生成器表达式(有时被称为 genexes)所做的。它们围绕目标属性(如LINK_LIBRARIES
、INCLUDE_DIRECTORIES
、COMPILE_DEFINITIONS
)构建,传播属性和许多其他内容,但并非全部。它们遵循与条件语句和变量评估类似的规则。
需要注意的是,表达式通常是在使用表达式的目标上下文中评估的(除非有明确的说明否则)。
重要提示
生成器表达式将在生成阶段评估(当配置完成且构建系统被创建时),这意味着你无法很容易地将它们的输出捕获到一个变量中并打印到控制台。要调试它们,你可以使用以下任一方法:
• 将其写入文件(这个特定的file()
命令支持生成器表达式):file(GENERATE OUTPUT filename CONTENT "$<...>")
• 从命令行显式添加一个自定义目标并构建它:add_custom_target(gendbg COMMAND {CMAKE_COMMAND} -E echo "
让我们拿最简单的例子来说:
target_compile_definitions(foo PUBLIC
BAR=$<TARGET_FILE:foo>)
前面的命令向编译器参数添加了一个-D
定义标志(现在忽略PUBLIC
)来设置BAR
预处理器定义为foo 目标的可执行文件路径。
生成器表达式是如何形成的?
图 4.4 – 生成器表达式的语法
正如你在图 4.4中看到的,结构似乎相当简单和易读:
$<
)打开。
EXPRESSION
名称。
:
)并提供arg1
、arg2
和arg3
的值,用逗号(,
)分隔。
>
关闭表达式。
还有一些不需要任何参数的表达式,例如$<PLATFORM_ID>
。然而,当使用它们的更高级功能时,生成器表达式可能会迅速变得非常令人困惑和复杂。
让我们从能够将一般表达式作为另一个表达式的参数开始,或者换句话说,一般表达式的嵌套:
$<UPPER_CASE:$<PLATFORM_ID>>
这个例子并不复杂,但很容易想象当我们增加嵌套级别并使用多个参数的命令时会发生什么。
更甚者,你技术上可以在这个混合中添加一个变量展开:
$<UPPER_CASE:${my_variable}>
变量将在配置阶段展开,生成表达式在生成阶段展开。这个特性有些少见的使用场景,但我强烈建议避免使用。
生成表达式中支持布尔逻辑。这是一个很好的特性,但由于历史原因,其语法不一致且可能很难阅读。它有两种形式。第一种形式支持快乐路径和悲伤路径:
$<IF:condition,true_string,false_string>
这里的语法与其他所有表达式对齐,像所有表达式一样,嵌套是允许的。所以,你可以用另一个表达式替换任何一个参数,产生一些非常复杂的评估——你甚至可以将一个条件嵌套在另一个条件中。这种形式需要恰好三个参数,所以我们不能省略任何东西。在条件未满足的情况下跳过值的最好方法是:
$<IF:condition,true_string,>
第二种形式是对前一种的简写;只有当条件满足时,它才会展开成一个字符串:
$<condition:true_string >
正如你所见,它打破了将 EXPRESSION
名称作为第一个标记提供的一致性约定。我假设这里的意图是为了缩短表达式,省去那宝贵的三个字符,但结果可能真的很难找到合理的理由。以下是从 CMake 文档中举的一个例子:
$<$<AND:$<COMPILE_LANGUAGE:CXX>,$<CXX_COMPILER_ID:AppleClan
g,Clang>>:COMPILING_CXX_WITH_CLANG>
我希望语法能与常规 IF
命令的条件对齐,但遗憾的是并非如此。
生成表达式评估为两种类型之一——布尔值或字符串。布尔值用 1
(真)和 0
(假)表示。其他所有都是字符串。
重要的是要记住,嵌套表达式作为条件在条件表达式中是明确要求评估为布尔值的。
有一个显式的逻辑运算符将字符串转换为布尔值,但布尔类型可以隐式地转换为字符串。
既然我们已经了解了基本语法,那么让我们来看看我们能用它做些什么。
我们在上一节开始讨论条件表达式。我想一开始就彻底覆盖这个概念,这样就不用以后再回来了。有三种表达式评估为布尔值。
有四个逻辑运算符:
$<NOT:arg>
否定布尔参数。
$<AND:arg1,arg2,arg3...>
如果所有参数都是 1
,则返回 1
。
$<OR:arg1,arg2,arg3...>
如果任意一个参数是 1
,则返回 1
。
$<BOOL:string_arg>
将字符串参数转换为布尔类型。
字符串转换将评估为 1
,如果这些条件没有满足:
0
、FALSE
、OFF
、N
、NO
、IGNORE
或 NOTFOUND
的不区分大小写等价物。
-NOTFOUND
后缀结尾(区分大小写)。
比较如果满足其条件则评估为 1
,否则为 0
:
$<STREQUAL:arg1,arg2>
是一个区分大小写的字符串比较。
$<EQUAL:arg1,arg2>
将字符串转换为数字并比较相等性。
$<IN_LIST:arg,list>
检查arg
元素是否在list
列表中(区分大小写)。
有很多包含布尔值变量。如果它们满足条件,它们也将评估为1
,否则为0
。
有一个简单的查询:
$<TARGET_EXISTS:arg>
- arg
目标存在吗?有多个查询扫描传递的参数以查找特定值:
$<CONFIG:args>
是args
中的当前配置(Debug
、Release
等)(不区分大小写)。
$<PLATFORM_ID:args>
是args
中的当前平台 ID。
$<LANG_COMPILER_ID:args>
是args
中的 CMakeLANG
编译器 ID,其中LANG
是C
、CXX
、CUDA
、OBJC
、OBJCXX
、Fortran
或ISPC
之一。
$<LANG_COMPILER_VERSION:args>
是args
中的 CMakeLANG
编译器版本,其中LANG
是C
、CXX
、CUDA
、OBJC
、OBJCXX
、Fortran
或ISPC
之一。
$<COMPILE_FEATURES:features>
如果features
被编译器支持,将返回true
。
$<COMPILE_LANG_AND_ID:lang,compiler_id1,compiler_id2...>
是这个lang
目标的lang
和在这个目标中使用的编译器是否存在于compiler_ids
列表中。这个表达式用于指定特定编译器的配置细节:
target_compile_definitions(myapp PRIVATE
$<$<COMPILE_LANG_AND_ID:CXX,AppleClang,Clang>:CXX_CLAN
G>
$<$<COMPILE_LANG_AND_ID:CXX,Intel>:CXX_INTEL>
$<$<COMPILE_LANG_AND_ID:C,Clang>:C_CLANG>
)
在前一个示例中,如果我们用AppleClang
或Clang
编译CXX
编译器,将设置-DCXX_CLANG
定义。对于来自 Intel 的CXX
编译器,将设置-DCXX_INTEL
定义标志。最后,对于C
和Clang
编译器,我们将得到一个-DC_CLANG
定义。
$<COMPILE_LANGUAGE:args>
如果在这个目标中使用args
编译指定语言。这可以用来为编译器提供语言特定的标志:
target_compile_options(myapp
PRIVATE $<$<COMPILE_LANGUAGE:CXX>:-fno-exceptions>
)
如果我们编译CXX
,编译器将使用-fno-exceptions
标志。
$<LINK_LANG_AND_ID:lang,compiler_id1,compiler_id2...>
与COMPILE_LANG_AND_ID
类似,但检查链接步骤使用的语言。使用此表达式指定特定语言和链接器组合的目标的链接库、链接选项、链接目录和链接依赖项。
$<LINK_LANGUAGE:args>
是args
中链接步骤使用的语言。
有很多表达式被评估为字符串。我们可以直接将它们输出到目标的占位符中,或者作为另一个表达式的参数。我们已经学习了其中一个 - 条件表达式评估为字符串。还有哪些可用?
这些表达式在生成阶段将评估为一个特定的值:
$<CONFIG>
- 配置(Debug
和Release
)名称。
$<PLATFORM_ID>
– 当前系统的 CMake 平台 ID(Linux
、Windows
或Darwin
)。我们在上一章的环境范围部分讨论了平台。
$<LANG_COMPILER_ID>
– 这是用于LANG
编译器的 CMake 编译器 ID,其中LANG
是C
、CXX
、CUDA
、OBJC
、OBJCXX
、Fortran
或ISPC
中的一个。
$<LANG_COMPILER_VERSION>
– 这是用于LANG
编译器的 CMake 编译器版本,其中LANG
是C
、CXX
、CUDA
、OBJC
、OBJCXX
、Fortran
或ISPC
中的一个。
$<LINK_LANGUAGE>
– 在评估链接选项时,目标的语言。
使用以下查询,您可以评估可执行文件或库目标属性。请注意,自 CMake 3.19 以来,对于在另一个目标上下文中查询大多数目标表达式,不再创建这些目标之间的自动依赖关系(如 3.19 之前所发生的那样):
$<TARGET_NAME_IF_EXISTS:target>
– 如果存在,则是target
的目标名称;否则为空字符串。
$<TARGET_FILE:target>
– target
二进制文件的完整路径。
$<TARGET_FILE_NAME:target>
– target
文件名。
$<TARGET_FILE_PREFIX:target>
– target
文件名的前缀(lib
)。
$<TARGET_FILE_SUFFIX:target>
– target
文件名的后缀(或扩展名)(.so
、.exe
)。
$<TARGET_FILE_DIR:target>
– target
二进制文件的目录。
$<TARGET_LINKER_FILE:target>
– 链接到target
目标时使用的文件。通常,它是target
表示的库(在具有.lib
导入库的平台上的.a
、.lib
、.so
)。
TARGET_LINKER_FILE
提供了与常规TARGET_FILE
表达式相同的系列表达式:
<TARGET_LINKER_FILE_NAME:target>、<TARGET_LINKER_FILE_PREFIX:target>、
$<TARGET_SONAME_FILE:target>
– 具有 soname 的文件的完整路径(.so.3
)。
$<TARGET_SONAME_FILE_NAME:target>
– 具有 soname 的文件名称。
$<TARGET_SONAME_FILE_DIR:target>
– 具有 soname 的文件的目录。
$<TARGET_PDB_FILE:target>
– 链接器生成的程序数据库文件(.pdb
)的完整路径,用于target
。
PDB 文件提供了与常规TARGET_FILE相同的表达式:<TARGET_PDB_FILE_BASE_NAME:target>、
$<TARGET_BUNDLE_DIR:target>
– 目标(target)的全路径到捆绑包(Apple 特定的包)目录(my.app
、my.framework
或my.bundle
)。
$<TARGET_BUNDLE_CONTENT_DIR:target>
– target
的全路径的捆绑内容目录。在 macOS 上,它是my.app/Contents
,my.framework
或my.bundle/Contents
。其他的my.app
,my.framework
或my.bundle
。
$<TARGET_PROPERTY:target,prop>
– target
的prop
值。
$<TARGET_PROPERTY:prop>
– 正在评估的表达式的target
的prop
值。
$<INSTALL_PREFIX>
– 当目标用install(EXPORT)
导出时或在INSTALL_NAME_DIR
中评估时,安装前缀为;否则,为空。
在很少的情况下,您可能需要向具有特殊意义的生成器表达式传递一个字符。为了逃避这种行为,请使用以下表达式:
$<ANGLE-R>
– 字面上的>
符号(比较包含>
的字符串)
$<COMMA>
– 字面上的,
符号(比较包含,
的字符串)
$<SEMICOLON>
– 字面上的;
符号(防止在带有;
的参数上进行列表展开)
在生成器阶段处理字符串是可能的,使用以下表达式:
$<JOIN:list,d>
– 使用d
分隔符将分号分隔的list
连接起来。
$<REMOVE_DUPLICATES:list>
– 不排序地删除list
中的重复项。
$<FILTER:list,INCLUDE|EXCLUDE,regex>
– 使用regex
正则表达式从列表中包含/排除项。
$<GENEX_EVAL:expr>
– 以当前目标的嵌套表达式的上下文评估expr
字符串。当嵌套表达式的评估返回另一个表达式时(它们不是递归评估的),这很有用。
$<TARGET_GENEX_EVAL:target,expr>
– 以与GENEX_EVAL
转换类似的方式评估expr
,但在target
的上下文中。
CMake 文档未能提供对“输出相关表达式”的好解释。这让我们有些迷茫;它们与输出有什么关系?
根据 v3.13 文档(在较新的版本中被删除),“这些表达式生成输出,在某些情况下取决于输入。”
结果它们真的是点点滴滴。一些是缩写条件表达式的遗留版本。其他的只是尚未进入其他部分的字符串转换表达式。
以下表达式如果满足特定条件将返回其第一个参数,否则返回空字符串:
$<LINK_ONLY:deps>
– 在target_link_libraries()
中隐式设置以存储PRIVATE
deps
链接依赖项,这些依赖项不会作为使用要求传播。
$<INSTALL_INTERFACE:content>
– 如果用于install(EXPORT)
,则返回content
。
$<BUILD_INTERFACE:content>
– 如果与export()
命令一起使用或在与同一构建系统中的另一个目标一起使用时,返回content
。
以下输出表达式将对其参数执行字符串转换:
$<MAKE_C_IDENTIFIER:input>
– 转换为遵循与string(MAKE_C_IDENTIFIER)
相同行为的 C 标识符。
$<SHELL_PATH:input>
– 将绝对路径(或路径列表)转换为与目标操作系统匹配的壳路径样式。在 Windows 壳中,反斜杠被转换为斜杠,在 MSYS 壳中,驱动器字母被转换为 POSIX 路径。
最后,我们有一个游离变量查询表达式:
$<TARGET_OBJECTS:target>
– 从target
对象库返回一个对象文件列表当有一个好的实际例子来支持理论时,一切都会更容易理解。以下是生成器表达式的一些用途:
在第一章中,我们讨论了指定我们要构建的配置的构建类型 – Debug
、Release
等。可能会有这样的情况,基于你正在进行的构建类型,你希望采取不同的行动。实现这一目标的一种简单方法是使用$<CONFIG>
生成器表达式:
target_compile_options(tgt $<$<CONFIG:DEBUG>:-ginline-
points>)
前一个示例检查config
是否等于DEBUG
;如果是这样,嵌套表达式被评估为1
。外部的简写if
表达式 then 变为true
,我们的-ginline-points
调试标志被添加到选项中。
生成器表达式还可以用来将冗长的if
命令压缩成整洁的一行。假设我们有以下代码:
if (${CMAKE_SYSTEM_NAME} STREQUAL "Linux")
target_compile_definitions(myProject PRIVATE LINUX=1)
endif()
它告诉编译器如果这是目标系统,则将-DLINUX=1
添加到参数中。虽然这并不是特别长,但它可以很容易地用一个优雅的表达式替换:
target_compile_definitions(myProject PRIVATE
$<$<CMAKE_SYSTEM_NAME:LINUX>:LINUX=1>)
这样的代码工作得很好,但是你能在生成器表达式中放入多少内容,直到它变得难以阅读,有一个限度。在这种情况下,最好还是使用长的条件块。
正如我们在这章开头讨论的,接口库可以用来提供与编译器匹配的标志:
add_library(enable_rtti INTERFACE)
target_compile_options(enable_rtti INTERFACE
$<$<OR:$<COMPILER_ID:GNU>,$<COMPILER_ID:Clang>>:-rtti>
)
即使在这个简单的例子中,我们也可以看到当我们嵌套太多生成器表达式时会发生什么。不幸的是,有时这是实现所需效果的唯一方法。这里发生了什么:
COMPILER_ID
是否为GNU
;如果是这样,我们将OR
评估为1
。
COMPILER_ID
是否为Clang
,并将OR
评估为1
。否则,将OR
评估为0
。
OR
被评估为1
,则将-rtti
添加到enable_rtti
编译选项。否则,什么都不做。
接下来,我们可以用enable_rtti
接口库链接我们的库和可执行文件。如果编译器支持,CMake 将添加-rtti
标志。
有时,在尝试在生成器表达式中嵌套元素时,不清楚会发生什么。我们可以通过生成测试输出到调试文件来调试这些表达式。
让我们尝试一些东西,看看会发生什么:
chapter04/04-genex/CMakeLists.txt(片段)
set(myvar "small text")
set(myvar2 "small > text")
file(GENERATE OUTPUT nesting CONTENT
"1 $<PLATFORM_ID>
2 $<UPPER_CASE:$<PLATFORM_ID>>
3 $<UPPER_CASE:hello world>
4 $<UPPER_CASE:${myvar}>
5 $<UPPER_CASE:${myvar2}>
")
输出如下:
# cat nesting
1 Linux
2 LINUX
3 HELLO WORLD
4 SMALL TEXT
5 SMALL text>
以下是每行的工作方式:
PLATFORM_ID
输出值是常规大小写Linux
。
LINUX
。
>
)将被解释为生成器表达式的一部分,在这种情况下,只有字符串的一部分将被大写。
换句话说,要意识到变量的内容可能会影响您的生成器表达式的行为。如果您需要在变量中使用尖括号,请使用$<ANGLE-R>
。
生成器表达式在评估布尔类型到字符串时可能会有些令人困惑。理解它们与普通的条件表达式有何不同是很重要的,尤其是从一个显式的IF
关键字开始:
chapter04/04-genex/CMakeLists.txt(片段)
file(GENERATE OUTPUT boolean CONTENT
"1 $<0:TRUE>
2 $<0:TRUE,FALSE> (won't work)
3 $<1:TRUE,FALSE>
4 $<IF:0,TRUE,FALSE>
5 $<IF:0,TRUE,>
")
这将产生一个文件,像这样:
# cat boolean
1
2 (won't work)
3 TRUE,FALSE
4 FALSE
5
让我们检查每行的输出:
BOOL
是0
;因此,没有写入TRUE
字符串。
BOOL
值的TRUE
或FALSE
打印,但由于它也是一个布尔的false
展开,两个参数被视为一个,因此没有打印。
true
展开,在单行中写入两个参数。
IF
开始的正确条件表达式——它打印FALSE
,因为第一个参数是0
。
false
不写值时,我们应该使用第一种形式。
生成器表达式以其复杂的语法而闻名。本例中提到的区别即使是经验丰富的构建者也会感到困惑。如果有疑问,将这样的表达式复制到另一个文件中,通过增加缩进和空格来拆分它,以便更好地理解。
理解目标对于编写干净、现代的 CMake 项目至关重要。在本章中,我们不仅讨论了构成目标以及目标如何相互依赖,还学习了如何使用 Graphviz 模块在图表中呈现这些信息。有了这个基本的了解,我们能够学习目标的关键特性——属性(各种各样的属性)。我们不仅介绍了几个设置目标常规属性的命令,还解决了传递属性或传播属性的谜团。解决这个问题很困难,因为我们不仅需要控制哪些属性被传播,还需要可靠地将它们传播到选定的、更远的靶子。此外,我们还发现了如何确保当属性来自多个来源时,它们传播后仍然是兼容的。
我们随后简要讨论了伪目标——导入的目标、别名目标和接口库。它们都将会在我们的项目中派上用场,特别是当我们知道如何将它们与传播属性结合起来以造福我们的项目时。然后,我们谈到了生成的构建目标和它们在配置阶段我们行动的直接结果。之后,我们重点关注自定义命令(它们如何生成可以被其他目标消费、编译、翻译等的文件)以及它们的钩子函数——在目标构建时执行额外步骤。
本章的最后部分致力于生成表达式(genex)的概念。我们解释了其语法、嵌套以及条件表达式的工作原理。然后,我们介绍了两种类型的评估——布尔值和字符串。每种都有它自己的一套表达式,我们详细探索并评论了这些表达式。此外,我们还提供了一些使用示例,并澄清了它们在实际中是如何工作的。
有了这样一个坚实的基础,我们准备好进入下一个主题——将 C++源代码编译成可执行文件和库。
更多信息,请访问以下网站:
CMake 构建系统的传递性使用要求
简单的编译场景通常由工具链的默认配置或直接由 IDE 提供。然而,在专业环境中,业务需求往往需要更高级的东西。可能是对更高性能、更小二进制文件、更可移植性、测试支持或广泛的调试功能的需求——您说得都对。以一种连贯、未来无忧的方式管理所有这些,很快就会变得复杂、纠缠不清(尤其是在需要支持多个平台的情况下)。
编译过程在 C++书籍中往往没有解释得足够清楚(像虚拟基类这样的深入主题似乎更有趣)。在本章中,我们将回顾基础知识,以确保事情不如预期时能取得成功。我们将发现编译是如何工作的,它的内部阶段是什么,以及它们如何影响二进制输出。
之后,我们将重点关注先决条件——我们将讨论我们可以使用哪些命令来调整编译,如何从编译器那里要求特定的功能,以及如何向编译器提供必须处理的输入文件。
然后,我们将重点关注编译的第一阶段——预处理器。我们将提供包含头文件的路径,并研究如何插入 CMake 和环境预处理器定义。我们将涵盖一些有趣的用例,并学习如何大量暴露 CMake 变量给 C++代码。
紧接着,我们将讨论优化器以及不同标志如何影响性能。我们还将痛苦地意识到优化的代价——调试被破坏的代码有多困难。
最后,我们将解释如何通过使用预编译头和单元编译来减少编译时间,为发现错误做准备,调试构建,以及在最终二进制文件中存储调试信息。
在本章中,我们将涵盖以下主要主题:
您可以在 GitHub 上找到本章中存在的代码文件,地址为github.com/PacktPublishing/Modern-CMake-for-Cpp/tree/main/examples/chapter05
。
构建本书提供的示例时,始终使用建议的命令:
cmake -B <build tree> -S <source tree>
cmake --build <build tree>
请确保将占位符<build tree>
和<source tree>
替换为适当的路径。作为提醒:build tree是目标/输出目录的路径,source tree是您的源代码所在的路径。
编译可以大致描述为将用高级编程语言编写的指令翻译成低级机器代码的过程。这允许我们使用类和对象等抽象概念来创建应用程序,而无需关心处理器特定汇编语言的繁琐细节。我们不需要直接与 CPU 寄存器打交道,考虑短跳或长跳,以及管理堆栈帧。编译语言更有表现力、可读性、更安全,并促进更易维护的代码(但性能尽可能)。
在 C++中,我们依赖于静态编译——整个程序必须在执行之前翻译成本地代码。这是 Java 或 Python 等语言的替代方法,这些语言每次用户运行时都使用特殊的、独立的解释器编译程序。每种方法都有其优点。C++的政策是为尽可能多的提供高级工具,同时仍能以完整的、自包含的应用程序的形式,为几乎所有的架构提供本地性能。
创建并运行一个 C++程序需要几个步骤:
.cpp
实现文件(称为翻译单元)编译成目标文件。
_start
函数来收集命令行参数和环境变量。它开始线程,初始化静态符号,并注册清理回调。然后它调用由程序员编写的main()
函数。
正如你所见,幕后发生了相当多的工作。本章讨论的是前述列表中的第二步。从整体的角度考虑,我们可以更好地理解一些可能问题的来源。毕竟,软件中没有黑魔法(即使难以理解的复杂性让它看起来像是那样)。一切都有解释和原因。程序运行时可能会失败,是因为我们如何编译它(即使编译步骤本身已经成功完成)。编译器在其工作中检查所有边缘情况是不可能的。
如前所述,编译是将高级语言翻译成低级语言的过程——具体来说,是通过产生特定处理器可以直接执行的机器代码,以二进制对象文件格式生成,该格式特定于给定平台。在 Linux 上,最流行的格式是可执行和可链接格式(ELF)。Windows 使用 PE/COFF 格式规范。在 macOS 上,我们会找到 Mach 对象(Mach-O 格式)。
对象文件**是单个源文件的直接翻译。每一个对象文件都需要单独编译,之后链接器将它们合并成一个可执行文件或库。正因为如此,当你修改了代码,只需重新编译受影响的文件,就能节省时间。
编译器必须执行以下阶段来创建一个对象文件:
#include
指令,用定义的值替换标识符(#define
指令和-D
标志),调用简单的宏,并根据#if
、#elif
和#endif
指令有条件地包含或排除代码的一部分。预处理器对实际的 C++代码一无所知,通常只是一个更高级的查找和替换工具。然而,它在构建高级程序中的工作至关重要;将代码分成部分并在多个翻译单元之间共享声明是代码可重用的基础。
接下来是语言分析。在这里,更有趣的事情会发生。编译器将逐字符扫描文件(包含预处理器包含的所有头文件),并进行词法分析,将它们分组成有意义的标记——关键字、操作符、变量名等。然后,标记被分组成标记链,并检查它们的顺序和存在是否遵循 C++的规则——这个过程称为语法分析或解析(通常,在打印错误方面,它是声音最大的部分)。最后,进行语义分析——编译器尝试检测文件中的语句是否真的有意义。例如,它们必须满足类型正确性检查(你不能将整数赋值给字符串变量)。
汇编不过是将这些标记翻译成基于平台可用指令集的 CPU 特定指令。一些编译器实际上会创建一个汇编输出文件,之后再传递给专门的汇编器程序,以产生 CPU 可执行的机器代码。其他的编译器直接从内存中产生相同的机器代码。通常,这类编译器包括一个选项,以产生人类可读的汇编代码文本输出(尽管,仅仅因为你能读它,并不意味着它值得这么做)。
优化在整个编译过程中逐步进行,一点一点地,在每个阶段。在生成第一个汇编版本之后有一个明确的阶段,负责最小化寄存器的使用和删除未使用的代码。一个有趣且重要的优化是在线扩展或内联。编译器将“剪切”函数的主体并“粘贴”代替其调用(标准未定义这种情况发生在哪些情况下——这取决于编译器的实现)。这个过程加快了执行速度并减少了内存使用,但对调试有重大缺点(执行的代码不再在原始行上)。
代码发射包括根据目标平台指定的格式将优化后的机器代码写入对象文件。这个对象文件不能直接执行——它必须传递给下一个工具,链接器,它将适当移动我们对象文件的各个部分并解决对外部符号的引用。这是从 ASCII 源代码到可被处理器处理的二进制对象文件的转换。
每个阶段都具有重要意义,可以根据我们的特定需求进行配置。让我们看看如何使用 CMake 管理这个过程。
CMake 提供了多个命令来影响每个阶段:
target_compile_features()
:要求具有特定特性的编译器编译此目标。
target_sources()
:向已定义的目标添加源文件。
target_include_directories()
:设置预处理器包含路径。
target_compile_definitions()
:设置预处理器定义。
target_compile_options()
:命令行上的编译器特定选项。
target_precompile_headers()
:优化外部头的编译。
所有上述命令都接受类似的参数:
target_...(<target name> <INTERFACE|PUBLIC|PRIVATE>
<value>)
这意味着它们支持属性传播,如前章所讨论的,既可以用于可执行文件也可以用于库。顺便提一下——所有这些命令都支持生成器表达式。
如第三章“设置你的第一个 CMake 项目”中讨论的,检查支持的编译器特性,为使用你的软件的用户准备可能出错的事情,并努力提供清晰的消息——可用的编译器 X 没有提供所需的特性 Y。这比用户可能拥有的不兼容的工具链产生的任何错误都要好。我们不希望用户假设是你的代码出了问题,而不是他们过时的环境。
以下命令允许你指定构建目标所需的所有特性:
target_compile_features(<target> <PRIVATE|PUBLIC|INTERFACE>
<feature> [...])
CMake 理解 C++标准和这些compiler_ids
所支持的编译器特性:
AppleClang
:Xcode 版本 4.4+的 Apple Clang
Clang
:Clang 编译器版本 2.9+
GNU
: GNU 编译器 4.4+版本
MSVC
: Microsoft Visual Studio 2010+版本
SunPro
: Oracle Solaris Studio 12.4+版本
Intel
: Intel 编译器 12.1+版本
重要提示
当然,您可以使用任何CMAKE_CXX_KNOWN_FEATURES
变量,但我建议坚持使用通用 C++标准——cxx_std_98
、cxx_std_11
、cxx_std_14
、cxx_std_17
、cxx_std_20
或cxx_std_23
。查看进阶阅读部分以获取更多详细信息。
我们已经知道如何告诉 CMake 哪些源文件组成一个目标——一个可执行文件或一个库。我们在使用add_executable()
或add_library()
时提供文件列表。
随着解决方案的增长,每个目标的文件列表也在增长。我们可能会得到一些非常长的add_...()
命令。我们如何处理呢?一种诱惑可能是使用GLOB
模式的file()
命令——它可以收集子目录中的所有文件并将它们存储在一个变量中。我们将其作为目标声明的参数传递,并不再担心列表文件:
file(GLOB helloworld_SRC "*.h" "*.cpp")
add_executable(helloworld ${helloworld_SRC})
然而,前面提到的方法并不推荐。让我们找出原因。CMake 根据列表文件的变化生成构建系统,因此如果没有进行任何更改,构建可能会在没有警告的情况下失败(我们知道,在花费了长时间进行调试后,这种类型的失败是最糟糕的)。除此之外,不在目标声明中列出所有源代码将导致代码审查在 IDE(如 CLion)中失败(CLion 只解析一些命令以理解您的项目)。
如果不建议在目标声明中使用变量,我们如何才能在例如处理特定平台的实现文件(如gui_linux.cpp
和gui_windows.cpp
)时条件性地添加源文件呢?
我们可以使用target_sources()
命令将文件追加到先前创建的目标:
chapter05/01-sources/CMakeLists.txt
add_executable(main main.cpp)
if(CMAKE_SYSTEM_NAME STREQUAL "Linux")
target_sources(main PRIVATE gui_linux.cpp)
elseif(CMAKE_SYSTEM_NAME STREQUAL "Windows")
target_sources(main PRIVATE gui_windows.cpp)
endif()
这样,每个平台都可以获得自己的兼容文件集合。很好,但是长文件列表怎么办呢?嗯,我们只能接受有些事情目前还不完美,并继续手动添加它们。
既然我们已经确立了编译的关键事实,让我们更仔细地看看第一步——预处理。与计算机科学中的所有事情一样,细节是魔鬼。
预处理器在构建过程中的作用非常大。这可能有点令人惊讶,因为它的功能多么简单和有限。在接下来的部分,我们将介绍为包含文件提供路径和使用预处理器定义。我们还将解释如何使用 CMake 配置包含的头文件。
预处理器最基本的功能是使用#include
指令包含.h
/.hpp
头文件。它有两种形式:
#include <path-spec>
: 尖括号形式
#include "path-spec"
: 引号形式
正如我们所知,预处理器将这些指令替换为path-spec
中指定的文件的正文。找到这些文件可能是个问题。我们搜索哪些目录以及按什么顺序?不幸的是,C++标准并没有确切指定;我们需要查看我们使用的编译器的手册。
通常,尖括号形式将检查标准包含目录,包括系统中存储标准 C++库和标准 C 库头文件的目录。
引号形式将开始在当前文件的目录中搜索包含的文件,然后检查尖括号形式的目录。
CMake 提供了一个命令,用于操作搜索包含文件所需的路径:
target_include_directories(<target> [SYSTEM] [AFTER|BEFORE]
<INTERFACE|PUBLIC|PRIVATE> [item1...]
[<INTERFACE|PUBLIC|PRIVATE> [item2...] ...])
我们可以添加自定义路径,我们希望编译器检查。CMake 将在生成的构建系统中为编译器调用添加它们。它们将用适合特定编译器的标志提供(通常是-I
)。
使用BEFORE
或AFTER
确定路径应该附加到目标INCLUDE_DIRECTORIES
属性之前还是之后。是否检查这里提供的目录还是默认目录之前还是之后(通常,是之前)仍然由编译器决定。
SYSTEM
关键字通知编译器,提供的目录是作为标准系统目录(与尖括号形式一起使用)。对于许多编译器,这个值将作为-isystem
标志提供。
记得我提到预处理器的#define
和#if
、#elif
、#endif
指令时描述编译阶段吗?让我们考虑以下示例:
chapter05/02-definitions/definitions.cpp
#include <iostream>
int main() {
#if defined(ABC)
std::cout << "ABC is defined!" << std::endl;
#endif
#if (DEF < 2*4-3)
std::cout << "DEF is greater than 5!" << std::endl;
#endif
}
如它所示,这个例子什么也不做;在这个例子中ABC
和DEF
都没有定义(在这个例子中DEF
将默认为0
)。我们可以在这个代码顶部添加两行轻松地改变这一点:
#define ABC
#define DEF 8
编译并执行此代码后,我们可以在控制台看到两条消息:
ABC is defined!
DEF is greater than 5!
这看起来很简单,但如果我们想根据外部因素(如操作系统、体系结构或其他内容)来条件这些部分,会发生什么情况呢?好消息!您可以将值从 CMake 传递给 C++编译器,而且一点也不复杂。
target_compile_definitions()
命令将解决这个问题:
chapter05/02-definitions/CMakeLists.txt
set(VAR 8)
add_executable(defined definitions.cpp)
target_compile_definitions(defined PRIVATE ABC
"DEF=${VAR}")
前面的代码将与两个#define
声明完全一样,但我们有自由使用 CMake 的变量和生成表达式,并且可以将命令放在条件块中。
重要提示
这些定义传统上通过-D
标志传递给编译器——-DFOO=1
——一些程序员仍然在这个命令中使用这个标志:
target_compile_definitions(hello PRIVATE -DFOO)
CMake 识别这一点,并将移除任何前面的-D
标志。它还会忽略空字符串,所以即使写如下内容也是可以的:
target_compile_definitions(hello PRIVATE -D FOO)
-D
是一个独立的参数;移除后它将变成一个空字符串,然后正确地被忽略。
一些在线资源建议在单元测试中使用特定的-D
定义与#ifdef/ifndef
指令的组合。最简单的可能方法是将访问修饰符包裹在条件包含中,并在定义UNIT_TEST
时忽略它们:
class X {
#ifndef UNIT_TEST
private:
#endif
int x_;
}
虽然这种用例非常方便(它允许测试直接访问私有成员),但这不是非常整洁的代码。单元测试应该只测试公共接口中方法是否如预期工作,并将底层实现视为黑盒机制。我建议你只在万不得已时使用这个方法。
让我们考虑一下在了解环境或文件系统详情方面有益的用例。一个在专业环境中可能很好的例子是传递用于构建二进制的修订版或提交SHA
:
chapter05/03-git/CMakeLists.txt
add_executable(print_commit print_commit.cpp)
execute_process(COMMAND git log -1 --pretty=format:%h
OUTPUT_VARIABLE SHA)
target_compile_definitions(print_commit PRIVATE
"SHA=${SHA}")
我们可以在应用程序中如此使用它:
chapter05/03-git/print_commit.cpp
#include <iostream>
// special macros to convert definitions into c-strings:
#define str(s) #s
#define xstr(s) str(s)
int main()
{
#if defined(SHA)
std::cout << "GIT commit: " << xstr(SHA) << std::endl;
#endif
}
当然,上述代码需要用户在他们的PATH
中安装并可访问git
。这对于运行在我们生产主机上的程序来自持续集成/部署管道很有用。如果我们的软件有问题时,我们可以快速检查用于构建有缺陷产品的确切 Git 提交。
跟踪确切的提交对调试非常有用。对于一个变量来说,这不是很多工作,但是当我们想要将数十个变量传递给我们的头文件时会发生什么?
如果我们有多个变量,通过target_compile_definitions()
传递定义可能会有些繁琐。我们不能提供一个带有引用各种变量的占位符的头文件,并让 CMake 填充它们吗?
当然我们可以!使用configure_file(<input> <output>)
命令,我们可以从模板生成新的文件,就像这个一样:
chapter05/04-configure/configure.h.in
#cmakedefine FOO_ENABLE
#cmakedefine FOO_STRING1 "@FOO_STRING@"
#cmakedefine FOO_STRING2 "${FOO_STRING}"
#cmakedefine FOO_UNDEFINED "@FOO_UNDEFINED@"
我们可以使用命令,像这样:
chapter05/04-configure/CMakeLists.txt
add_executable(configure configure.cpp)
set(FOO_ENABLE ON)
set(FOO_STRING1 "abc")
set(FOO_STRING2 "def")
configure_file(configure.h.in configured/configure.h)
target_include_directories(configure PRIVATE
${CMAKE_CURRENT_BINARY_DIR})
我们可以让 CMake 生成一个输出文件,像这样:
chapter05/04-configure/<build_tree>/configure.h
#define FOO_ENABLE
#define FOO_STRING1 "abc"
#define FOO_STRING2 "def"
/* #undef FOO_UNDEFINED "@FOO_UNDEFINED@" */
正如你所见,@VAR@
和${VAR}
变量占位符被替换成了 CMake 列表文件中的值。此外,#cmakedefine
被替换成了#define
给已定义的变量,对于未定义的变量则替换成/* #undef VAR */
。
如果你需要为#if
块提供显式的#define 1
或#define 0
,请使用#cmakedefine01
。
我们如何在应用程序中使用这样的配置头文件?我们可以在实现文件中简单地包含它:
chapter05/04-configure/configure.cpp
#include <iostream>
#include "configured/configure.h"
// special macros to convert definitions into c-strings:
#define str(s) #s
#define xstr(s) str(s)
using namespace std;
int main()
{
#ifdef FOO_ENABLE
cout << "FOO_ENABLE: ON" << endl;
#endif
cout << "FOO_ENABLE1: " << xstr(FOO_ENABLE1) << endl;
cout << "FOO_ENABLE2: " << xstr(FOO_ENABLE2) << endl;
cout << "FOO_UNDEFINED: " << xstr(FOO_UNDEFINED) << endl;
}
由于我们已使用target_include_directories()
命令将二叉树添加到了我们的包含路径中,因此我们可以编译示例并从 CMake 接收填充好的输出:
FOO_ENABLE: ON
FOO_ENABLE1: FOO_ENABLE1
FOO_ENABLE2: FOO_ENABLE2
FOO_UNDEFINED: FOO_UNDEFINED
configure_file()
命令还具有许多格式化和文件权限选项。在这里描述它们可能会稍显冗长。如果你有兴趣,可以查看在线文档以获取详细信息(链接在进一步阅读部分)。
在准备好我们头文件和源文件的完整组合后,我们可以讨论在下一步中输出代码是如何形成的。由于我们无法直接影响语言分析或汇编(这些步骤遵循严格的标准),我们肯定可以访问优化器的配置。让我们了解它如何影响最终结果。
优化器将分析前阶段的结果,并使用多种程序员认为不整洁的技巧,因为它们不符合整洁代码原则。没关系——优化器的关键作用是使代码具有高性能(即,使用较少的 CPU 周期、较少的寄存器和较少的内存)。当优化器遍历源代码时,它会对其进行大量转换,以至于它几乎变得无法辨认。它变成了针对目标 CPU 的特殊准备版本。
优化器不仅会决定哪些函数可以被删除或压缩;它还会移动代码或甚至显著地重复它!如果它可以完全确定某些代码行是没有意义的,它就会从重要函数的中间抹去它们(你甚至都注意不到)。它会重复利用内存,所以众多变量在不同时间段可以占据同一个槽位。如果这意味着它可以节省一些周期,它还会将你的控制结构转换成完全不同的结构。
这里描述的技术,如果由程序员手动应用到源代码中,将会使其变得可怕、难以阅读。编写和推理将会困难。另一方面,如果由编译器应用,那就非常棒了,因为编译器将严格遵循所写的内容。优化器是一种无情的野兽,只服务于一个目的:使执行快速,无论输出会变得多么糟糕。如果我们在测试环境中运行它,输出可能包含一些调试信息,或者它可能不包含,以便让未授权的人难以篡改。
每个编译器都有自己的技巧,与它所遵循的平台和哲学相一致。我们将查看 GNU GCC 和 LLVM Clang 中可用的最常见的一些,以便我们可以了解什么是有用和可能的。
问题是——许多编译器默认不会启用任何优化(包括 GCC)。这在某些情况下是可以的,但在其他情况下则不然。为什么要慢慢来,当你可以快速前进时呢?要改变事物,我们可以使用target_compile_options()
命令,并精确指定我们想从编译器那里得到什么。
这个命令的语法与本章中的其他命令相似:
target_compile_options(<target> [BEFORE]
<INTERFACE|PUBLIC|PRIVATE> [items1...]
[<INTERFACE|PUBLIC|PRIVATE> [items2...] ...])
我们提供target
命令行选项以添加,并指定传播关键字。当执行此命令时,CMake 将在目标相应的COMPILE_OPTIONS
变量中附加给定选项。可选的BEFORE
关键字可用于指定我们想要在它们之前添加它们。在某些情况下,顺序很重要,因此能够选择是件好事。
重要提示
target_compile_options()
是一个通用命令。它也可以用来为类似编译器的-D
定义提供其他参数,对于这些参数,CMake 还提供了target_compile_definition()
命令。始终建议尽可能使用 CMake 命令,因为它们在所有支持的编译器上都是一致的。
是讨论细节的时候了。接下来的章节将介绍您可以在大多数编译器中启用的各种优化方法。
优化器的所有不同行为都可以通过我们作为编译选项传递的具体标志进行深度配置。了解它们需要花费大量时间,并需要深入了解编译器、处理器和内存的内部工作原理。如果我们只想在大多数情况下都能良好工作的最佳可能场景怎么办?我们可以寻求一个通用解决方案——一个优化级别指定符。
大多数编译器提供四个基本级别的优化,从0
到3
。我们使用-O<level>
选项指定它们。-O0
意味着没有优化,通常,这是编译器的默认级别。另一方面,-O2
被认为是完全优化,它生成高度优化的代码,但编译时间最慢。
有一个中间的-O1
级别,根据您的需求,它可以是一个很好的折中方案——它启用了适量的优化机制,而不会使编译速度变得太慢。
最后,我们可以使用-O3
,这是完全优化,类似于-O2
,但它在子程序内联和循环向量化方面采取了更为激进的方法。
还有一些优化变体,它们将优化生成文件的大小(不一定是速度)——-Os
。还有一个超级激进的优化,-Ofast
,它是不严格符合 C++标准的-O3
优化。最明显的区别是使用-ffast-math
和-ffinite-math
标志,这意味着如果你的程序是关于精确计算(像大多数程序一样),你可能想避免使用它。
CMake 知道并非所有的编译器都平等,因此,为了提供一致的开发体验,它为编译器提供了一些默认标志。这些标志存储在系统级(非目标特定)变量中,用于指定使用的语言(CXX
用于 C++)和构建配置(DEBUG
或RELEASE
):
CMAKE_CXX_FLAGS_DEBUG
等于-g
。
CMAKE_CXX_FLAGS_RELEASE
等于-O3 -DNDEBUG
。
正如你所看到的,调试配置没有启用任何优化,而发布配置直接选择了O3
。如果你愿意,你可以直接使用set()
命令更改它们,或者只是添加一个目标编译选项,这将覆盖这个默认行为。另外两个标志(-g,
-DNDEBUG
)与调试有关——我们将在为调试器提供信息部分讨论它们。
诸如CMAKE_<LANG>_FLAGS_<CONFIG>
之类的变量是全局的——它们适用于所有目标。建议通过target_compile_options()
等属性和命令来配置目标,而不是依赖全局变量。这样,你可以更精细地控制你的目标。
通过使用-O<level>
选择优化级别,我们间接设置了一系列标志,每个标志控制一个特定的优化行为。然后,我们可以通过添加更多标志来微调优化:
-f
选项启用它们:-finline-functions
。
-fno
选项禁用它们:-fno-inline-functions
。
其中一些标志值得更深入地了解,因为它们通常会影响你的程序如何运行以及你如何可以调试它。让我们来看看。
正如你所回忆的,编译器可以被鼓励内联某些函数,要么在类声明块内定义一个函数,要么明确使用inline
关键字:
struct X {
void im_inlined(){ cout << "hi\n"; };
void me_too();
};
inline void X::me_too() { cout << "bye\n"; };
是否内联函数由编译器决定。如果启用了内联并且函数在一个地方使用(或者是一个在几个地方使用的小函数),那么很可能会发生内联。
这是一种非常有趣的优化技术。它通过从所述函数中提取代码,并将它放在函数被调用的所有地方,替换原始调用并节省宝贵的 CPU 周期来工作。
让我们考虑一下我们刚刚定义的类以下示例:
int main() {
X x;
x.im_inlined();
x.me_too();
return 0;
}
如果没有内联,代码将在main()
帧中执行,直到一个方法调用。然后,它会为im_inlined()
创建一个新帧,在一个单独的作用域中执行,并返回到main()
帧。对me_too()
方法也会发生同样的事情。
然而,当内联发生时,编译器将替换这些调用,如下所示:
int main() {
X x;
cout << "hi\n";
cout << "bye\n";
return 0;
}
这不是一个精确的表示,因为内联是在汇编语言或机器代码级别(而不是源代码级别)发生的,但它传达了一个大致的画面。
编译器这样做是为了节省时间;它不必经历新调用帧的创建和销毁,不必查找下一条要执行(并返回)的指令地址,而且因为它们彼此相邻,编译器可以更好地缓存这些指令。
当然,内联有一些重要的副作用;如果函数使用不止一次,它必须被复制到所有地方(意味着文件大小更大,使用的内存更多)。如今,这可能不像过去那么关键,但仍然相关,因为我们不断开发必须在内存有限的高端设备上运行的软件。
除此之外,当我们调试自己编写的代码时,它对我们的影响尤为关键。内联代码不再位于其最初编写的行号,因此跟踪起来不再那么容易(有时甚至不可能),这就是为什么在调试器中放置的断点永远不会被击中(尽管代码以某种方式被执行)。为了避免这个问题,我们只能禁用调试构建中的内联功能(代价是不再测试与发布构建完全相同的版本)。
我们可以通过为目标指定-O0
级别或直接针对负责的标志:
-finline-functions-called-once
:仅 GCC 支持
-finline-functions
:Clang 和 GCC
-finline-hint-functions
:仅 Clang 支持
-finline-functions-called-once
:仅 GCC 支持
你可以使用-fno-inline-...
显式禁用内联。无论如何,对于详细信息,请参阅您编译器的特定版本的文档。
循环展开是一种优化技术,也被称为循环展开。通用方法是将循环转换为一组实现相同效果的语句。这样做,我们将用程序的大小换取执行速度,因为我们减少了或消除了控制循环的指令——指针算术或循环末端测试。
请考虑以下示例:
void func() {
for(int i = 0; i < 3; i++)
cout << "hello\n";
}
之前的代码将被转换为类似这样的内容:
void func() {
cout << "hello\n";
cout << "hello\n";
cout << "hello\n";
}
结果将相同,但我们不再需要分配i
变量,增加它,或三次将其与3
进行比较。如果我们程序运行期间调用func()
足够多次,即使是对这样一个简短且小的函数进行展开,也会产生显著的差异。
然而,理解两个限制因素很重要。循环展开只有在编译器知道或可以有效估计迭代次数时才能工作。其次,循环展开可能会对现代 CPU 产生不希望的效果,因为代码尺寸的增加可能会阻止有效缓存。
每个编译器提供这个标志的略有不同的版本:
-floop-unroll
:GCC
-funroll-loops
:Clang
如果你有疑问,广泛测试这个标志是否影响你的特定程序,并显式启用或禁用它。请注意,在 GCC 上,-O3
作为隐式启用的-floop-unroll-and-jam
标志的一部分隐式启用。
单指令多数据(SIMD)是 20 世纪 60 年代初为实现并行化而开发的一种机制。它的工作方式正如其名称所暗示的那样;它可以同时对多块信息执行相同的操作。实际意味着什么?让我们考虑以下示例:
int a[128];
int b[128];
// initialize b
for (i = 0; i<128; i++)
a[i] = b[i] + 5;
通常,前面的代码会循环 128 次,但是有了性能强大的 CPU,我们可以通过同时计算数组中的两个或更多元素来大大加快代码的执行速度。这之所以可行,是因为连续元素之间没有依赖性,数组之间的数据也没有重叠。智能编译器可以将前面的循环转换成类似于此的东西(这发生在汇编语言级别):
for (i = 0; i<32; i+=4) {
a[ i ] = b[ i ] + 5;
a[i+1] = b[i+1] + 5;
a[i+2] = b[i+2] + 5;
a[i+3] = b[i+3] + 5;
}
GCC 会在-O3
时启用循环的自动向量化。Clang 默认启用。这两个编译器提供了不同的标志来启用/禁用向量化:
-ftree-vectorize -ftree-slp-vectorize
在 GCC 中启用
-fno-vectorize -fno-slp-vectorize
在 Clang 中禁用(如果东西坏了)
向量化性能的提升来自于利用 CPU 制造商提供的特殊指令,而不仅仅是简单地将循环的原始形式替换为展开版本。因此,手动操作是无法达到相同性能水平的(而且代码也不太整洁)。
优化器在提高程序运行时的性能方面起着重要作用。通过有效地运用其策略,我们可以物有所值。效率的重要性不仅在于编码完成后,还在于我们开发软件的过程中。如果编译时间过长,我们可以通过更好地管理编译过程来改进它们。
作为程序员和构建工程师,我们需要考虑编译的其他方面——完成所需的时间,以及如何容易地发现和修复在构建解决方案过程中犯的错误。
在需要每天(或每小时)进行许多十几个重新编译的繁忙项目中,编译速度尽可能快是至关重要的。这不仅影响了你的代码-编译-测试循环的紧密程度,还影响了你的注意力和工作流程。幸运的是,C++在管理编译时间方面已经相当出色,这要归功于独立的翻译单元。CMake 会处理仅重新编译最近更改影响的源代码。然而,如果我们需要进一步改进,我们可以使用一些技术——头文件预编译和单元构建:
头文件(.h
)在实际编译开始前由预处理器包含在翻译单元中。这意味着每当.cpp
实现文件发生变化时,它们都必须重新编译。此外,如果多个翻译文件使用相同的共享头文件,每次包含时都必须重新编译。这是浪费,但长期以来一直是这样。*
幸运的是,自从版本 3.16 以来,CMake 提供了一个命令来启用头文件预编译。这使得编译器可以单独处理头文件和实现文件,从而加快编译速度。提供命令的语法如下:*
target_precompile_headers(<target>
<INTERFACE|PUBLIC|PRIVATE> [header1...]
[<INTERFACE|PUBLIC|PRIVATE> [header2...] ...])
添加的头文件列表存储在PRECOMPILE_HEADERS
目标属性中。正如你在第四章,《使用目标》中了解到的,我们可以使用传播属性通过使用PUBLIC
或INTERFACE
关键字将头文件与任何依赖的目标共享;然而,对于使用install()
命令导出的目标,不应该这样做。其他项目不应当被迫消耗我们的预编译头文件(因为这不符合常规)。
重要提示:*
如果你需要内部预编译头文件但仍然希望安装导出目标,那么第四章,《使用目标》中描述的$<BUILD_INTERFACE:...>
生成器表达式将防止头文件出现在使用要求中。然而,它们仍然会被添加到使用export()
命令从构建树导出的目标中。*
CMake 会将所有头文件的名称放入一个cmake_pch.h|xx
文件中,然后预编译为具有.pch
、.gch
或.pchi
扩展名的特定于编译器的二进制文件。*
我们可以像这样使用它:*
chapter05/06-precompile/CMakeLists.txt*
add_executable(precompiled hello.cpp)
target_precompile_headers(precompiled PRIVATE <iostream>)
chapter05/06-precompile/hello.cpp*
int main() {
std::cout << "hello world" << std::endl;
}
请注意,在我们的main.cpp
文件中,我们不需要包含cmake_pch.h
或其他任何头文件——CMake 会使用特定的命令行选项强制包含它们。*
在前一个示例中,我使用了一个内置的头文件;然而,你可以很容易地添加自己的头文件,带有类或函数定义:*
header.h
被视为相对于当前源目录的,并将使用绝对路径包含进来。*
[["header.h"]]
根据编译器的实现来解释,通常可以在INCLUDE_DIRECTORIES
变量中找到。使用target_include_directiories()
来配置它。*
一些在线参考资料将不鼓励预编译不属于标准库的头文件,如<iostream>
,或使用预编译头文件。这是因为更改列表或编辑自定义头文件会导致目标中所有翻译单元的重新编译。使用 CMake,你不需要担心这么多,尤其是如果你正确地组织你的项目(具有相对较小的目标,专注于狭窄的领域)。每个目标都有一个单独的预编译头文件,限制了头文件更改的扩散。*
另一方面,如果你的头文件被认为相当稳定,你可能会决定从一个小目标中重复使用预编译的头文件到另一个目标中。CMake 为此目的提供了一个方便的命令:
target_precompile_headers(<target> REUSE_FROM
<other_target>)
这设置了使用头文件的目标的PRECOMPILE_HEADERS_REUSE_FROM
属性,并在这些目标之间创建了一个依赖关系。使用这种方法,消费目标无法再指定自己的预编译头文件。另外,所有编译选项、编译标志和编译定义必须在目标之间匹配。注意要求,特别是如果你有任何使用双括号格式的头文件([["header.h"]]
)。两个目标都需要适当地设置它们的包含路径,以确保编译器能够找到这些头文件。
CMake 3.16 还引入了另一个编译时间优化功能——统一构建,也称为统一构建或巨构建。统一构建将多个实现源文件与#include
指令结合在一起(毕竟,编译器不知道它是在包含头文件还是实现)。这带来了一些有趣的含义——有些是非常有用的,而其他的是潜在有害的。
让我们从最明显的一个开始——避免在 CMake 创建统一构建文件时在不同翻译单元中重新编译头文件:
#include "source_a.cpp"
#include "source_b.cpp"
当这两个源中都包含#include "header.h"
行时,多亏了包含守卫(假设我们没有忘记添加那些),它只会被解析一次。这不如预编译头文件优雅,但这是一个选项。
这种构建方式的第二个好处是,优化器现在可以更大规模地作用,并优化所有捆绑源之间的跨过程调用。这类似于我们在第二章《CMake 语言》中讨论的链接时间优化。
然而,这些好处是有代价的。因为我们减少了对象文件的数量和处理步骤,我们也增加了处理更大文件所需的内存量。此外,我们减少了并行化工作量。编译器并不是真正那么擅长多线程编译,因为它们不需要——构建系统通常会启动许多编译任务,以便在不同的线程上同时执行所有文件。当我们把所有文件放在一起时,我们会使它变得困难得多,因为 CMake 现在会在我们创建的多个巨构建之间安排并行构建。
在使用统一构建时,你还需要考虑一些可能不是那么明显捕捉到的 C++语义含义——匿名命名空间跨文件隐藏符号现在被分组到一组中。静态全局变量、函数和宏定义也是如此。这可能会导致名称冲突,或者执行不正确的函数重载。
在重新编译时,巨构构建不受欢迎,因为它们会编译比所需更多的文件。当代码旨在尽可能快地整体编译所有文件时,它们效果最佳。在 Qt Creator 上进行的测试表明,您可以期待性能提升在 20%到 50%之间(取决于所使用的编译器)。
启用统一构建,我们有两个选项:
CMAKE_UNITY_BUILD
变量设置为true
——它将在定义后的每个目标上初始化UNITY_BUILD
属性。
UNITY_BUILD
设置为每个应使用统一构建的目标的true
。
第二个选项是通过以下方式实现的:
set_target_properties(<target1> <target2> ...
PROPERTIES UNITY_BUILD true)
默认情况下,CMake 将创建包含八个源文件的构建,这是由目标的UNITY_BUILD_BATCH_SIZE
属性指定的(在创建目标时从CMAKE_UNITY_BUILD_BATCH_SIZE
变量复制)。您可以更改目标属性或默认变量。
自版本 3.18 起,你可以选择明确地定义文件如何与命名组一起打包。为此,将目标的UNITY_BUILD_MODE
属性更改为GROUP
(默认值始终为BATCH
)。然后,你需要通过将他们的UNITY_GROUP
属性设置为你选择的名称来为源文件分配组:
set_property(SOURCE <src1> <src2>...
PROPERTY UNITY_GROUP "GroupA")
然后,CMake 将忽略UNITY_BUILD_BATCH_SIZE
,并将组中的所有文件添加到单个巨构构建中。
CMake 的文档建议不要默认启用公共项目的统一构建。建议您的应用程序的最终用户能够通过提供DCMAKE_UNITY_BUILD
命令行参数来决定他们是否需要巨构构建。更重要的是,如果由于您的代码编写方式而引起问题,您应该明确将目标属性设置为false
。然而,这并不妨碍您为内部使用的代码启用此功能,例如在公司内部或为您私人项目使用。
如果你密切关注 C++标准的发布,你会知道 C++20 引入了一个新特性——模块。这是一个重大的变革。它允许你避免使用头文件时的许多烦恼,减少构建时间,并使得代码更简洁、更易于导航和推理。
本质上,我们可以创建一个带有模块声明的单文件,而不是创建一个单独的头部和实现文件:
export module hello_world;
import <iostream>;
export void hello() {
std::cout << "Hello world!\n";
}
然后,你可以在代码中简单地导入它:
import hello_world;
int main() {
hello();
}
注意我们不再依赖预处理器;模块有自己的关键字——import
、export
和module
。最受欢迎的编译器最新版本已经可以执行所有必要的任务,以支持模块作为编写和构建 C++解决方案的新方法。我原本希望在本章开始时,CMake 已经提供了对模块的早期支持。不幸的是,这一点尚未实现。
然而,到你购买这本书的时候(或不久之后)可能就有了。有一些非常好的指标;Kitware 开发者已经创建(并在 3.20 中发布)了一个新的实验性特性,以支持 C++20 模块依赖项扫描对 Ninja 生成器的支持。现在,它只打算供编译器编写者使用,这样他们就可以在开发过程中测试他们的依赖项扫描工具。
当这个备受期待的特性完成并在一个稳定的版本中可用时,我建议彻底研究它。我预计它将简化并大大加快编译速度,超过今天可用的任何东西。
作为程序员,我们花了很多时间寻找 bug。这是一个悲哀的事实。查找并解决错误常常会让我们感到不舒服,尤其是如果它需要长时间的话。如果我们没有仪器帮助我们导航暴风雨,盲目飞行会更困难。这就是为什么我们应该非常小心地设置我们的环境,使这个过程尽可能容易和可忍受。我们通过使用target_compile_options()
配置编译器来实现这一点。那么编译选项能帮助我们什么呢?
软件开发中有许多令人压力很大的事情——比如在半夜修复关键错误、在高知名度的大型系统中处理昂贵的失败、以及处理那些令人烦恼的编译错误,尤其是那些难以理解或修复起来极其繁琐的错误。当研究一个主题以简化你的工作并减少失败的可能性时,你会发现有很多关于如何配置编译器警告的建议。
一条这样的好建议就是为所有构建启用-Werror
标志作为默认设置。这个标志做的简单而无辜的事情是——所有警告都被视为错误,除非你解决所有问题,否则代码不会编译。虽然这可能看起来是个好主意,但几乎从来不是。
你看,警告之所以不是错误,是有原因的。它们是用来警告你的。决定如何处理这是你的事。拥有忽视警告的自由,尤其是在你实验和原型化解决方案时,通常是一种祝福。
另一方面,如果你有一个完美无瑕、没有警告、闪闪发光的代码,允许未来的更改破坏这种情况真是太可惜了。启用它并只是保持在那里会有什么害处呢?表面上看起来没有。至少在你升级编译器之前是这样。编译器的新版本往往对弃用的特性更加严格,或者更好地提出改进建议。当你不将所有警告视为错误时,这很好,但当你这样做时,有一天你会发现你的构建开始在没有代码更改的情况下失败,或者更令人沮丧的是,当你需要快速修复一个与新警告完全无关的问题时。
那么,“几乎不”是什么意思,当你实际上应该启用所有可能的警告时?快速答案是当你编写一个公共库时。这时,你真的想避免因为你的代码在一个比你的环境更严格的编译器中编译而产生问题报告。如果你决定启用它,请确保你对编译器的新版本和它引入的警告了如指掌。
否则,让警告就是警告,专注于错误。如果你觉得自己有必要吹毛求疵,可以使用-Wpedantic
标志。这是一个有趣的选择——它启用了所有严格遵循 ISO C 和 ISO C++所要求的警告。请注意,使用此标志并不能检查代码是否符合标准——它只能找到需要诊断信息的非 ISO 实践。
更加宽容和脚踏实地的程序员会对-Wall
感到满意,可选地加上-Wextra
,以获得那种额外的华丽感觉。这些被认为是有实际用处和意义的警告,当你有空时应该修复你的代码中的这些问题。
还有许多其他的警告标志,这取决于项目的类型可能会有所帮助。我建议你阅读你选择的编译器的手册,看看有什么可用。
偶尔,编译会失败。这通常发生在我们试图重构一堆代码或清理我们的构建系统时。有时,事情很容易解决,但随后会有更复杂的问题,需要深入分析配置的每个步骤。我们已经知道如何打印更详细的 CMake 输出(如在第一章中讨论的,CMake 的初步步骤),但我们如何分析在每个阶段实际发生的情况呢?
我们可以向编译器传递-save-temps
标志(GCC 和 Clang 都有这个标志),它将强制将每个阶段的输出存储在文件中,而不是内存中:
chapter05/07-debug/CMakeLists.txt
add_executable(debug hello.cpp)
target_compile_options(debug PRIVATE -save-temps=obj)
前面的片段通常会产生两个额外的文件:
<build-tree>/CMakeFiles/<target>.dir/<source>.ii
:存储预处理阶段的输出,带有注释解释源代码的每一部分来自哪里:
# 1 "/root/examples/chapter05/06-debug/hello.cpp"
# 1 "<built-in>"
# 1 "<command-line>"
# 1 "/usr/include/stdc-predef.h" 1 3 4
# / / / ... removed for brevity ... / / /
# 252 "/usr/include/x86_64-linux-
gnu/c++/9/bits/c++config.h" 3
namespace std
{
typedef long unsigned int size_t;
typedef long int ptrdiff_t;
typedef decltype(nullptr) nullptr_t;
}
...
<build-tree>/CMakeFiles/<target>.dir/<source>.s
:语言分析阶段的输出,准备进入汇编阶段:
.file "hello.cpp"
.text
.section .rodata
.type _ZStL19piecewise_construct, @object
.size _ZStL19piecewise_construct, 1
_ZStL19piecewise_construct:
.zero 1
.local _ZStL8__ioinit
.comm _ZStL8__ioinit,1,1
.LC0:
.string "hello world"
.text
.globl main
.type main, @function
main:
( ... )
根据问题的性质,我们通常可以发现实际的问题所在。预处理器的输出对于发现诸如不正确的include 路径(提供错误版本的库)以及导致错误#ifdef
评估的定义错误等 bug 很有帮助。
语言分析阶段的输出对于针对特定处理器和解决关键优化问题很有用。
错误地包含的文件可能是一个真正难以调试的问题。我应该知道——我的第一份企业工作就是将整个代码库从一种构建系统移植到另一种。如果你发现自己需要精确了解正在使用哪些路径来包含请求的头文件,可以使用-H
:
chapter05/07-debug/CMakeLists.txt
add_executable(debug hello.cpp)
target_compile_options(debug PRIVATE -H)
打印出的输出将类似于这样:
[ 25%] Building CXX object
CMakeFiles/inclusion.dir/hello.cpp.o
. /usr/include/c++/9/iostream
.. /usr/include/x86_64-linux-gnu/c++/9/bits/c++config.h
... /usr/include/x86_64-linux-gnu/c++/9/bits/os_defines.h
.... /usr/include/features.h
-- removed for brevity --
.. /usr/include/c++/9/ostream
在object file
的名称之后,输出中的每一行都包含一个头文件的路径。行首的一个点表示顶级包含(#include
指令在hello.cpp
中)。两个点意味着这个文件被<iostream>
包含。进一步的点表示嵌套的又一层。
在这个输出的末尾,你也许还会找到对代码可能的改进建议:
Multiple include guards may be useful for:
/usr/include/c++/9/clocale
/usr/include/c++/9/cstdio
/usr/include/c++/9/cstdlib
你不必修复标准库,但可能会看到一些自己的头文件。你可能想修正它们。
机器代码是一系列用二进制格式编码的指令和数据,它不传达任何意义或目标。这是因为 CPU 不关心程序的目标是什么,或者所有指令的含义是什么。唯一的要求是代码的正确性。编译器会将所有内容转换成 CPU 指令的数值标识符、一些用于初始化内存的数据以及成千上万的内存地址。换句话说,最终的二进制文件不需要包含实际的源代码、变量名、函数签名或程序员关心的任何其他细节。这就是编译器的默认输出——原始且干燥。
这样做主要是为了节省空间并在执行时尽量减少开销。巧合的是,我们也在一定程度上(somewhat)保护了我们的应用程序免受逆向工程。是的,即使没有源代码,你也可以理解每个 CPU 指令做什么(例如,将这个整数复制到那个寄存器)。但最终,即使是基本程序也包含太多这样的指令,很难思考大局。
如果你是一个特别有驱动力的人,你可以使用一个名为反汇编器的工具,并且凭借大量的知识(还有一点运气),你将能够理解可能发生了什么。这种方法并不非常实用,因为反汇编代码没有原始符号,所以很难且缓慢地弄清楚哪些部分应该放在哪里。
相反,我们可以要求编译器将源代码存储在生成的二进制文件中,并与包含编译后和原始代码之间引用关系的映射一起存储。然后,我们可以将调试器连接到运行中的程序,并查看任何给定时刻正在执行哪一行源代码。当我们编写代码时,例如编写新功能或修正错误,这是不可或缺的。
这两个用例是两个配置文件(Debug
和Release
)的原因。正如我们之前看到的,CMake 会默认提供一些标志给编译器来管理这个过程,首先将它们存储在全局变量中:
CMAKE_CXX_FLAGS_DEBUG
包含了-g
。
CMAKE_CXX_FLAGS_RELEASE
包含了-DNDEBUG
。
-g
标志的意思是添加调试信息。它以操作系统的本地格式提供——stabs、COFF、XCOFF 或 DWARF。这些格式随后可以被诸如gdb
(GNU 调试器)之类的调试器访问。通常,这对于像 CLion 这样的 IDE 来说已经足够好了(因为它们在底层使用gdb
)。在其他情况下,请参考提供的调试器的手册,并检查对于您选择的编译器,适当的标志是什么。
对于RELEASE
配置,CMake 将添加-DNDEBUG
标志。这是一个预处理器定义,简单意味着不是调试构建。当启用此选项时,一些面向调试的宏可能不会工作。其中之一就是assert
,它在<assert.h>
头文件中可用。如果你决定在你的生产代码中使用断言,它们将根本不会工作:
int main(void)
{
bool my_boolean = false;
assert(my_boolean);
std::cout << "This shouldn't run. \n";
return 0;
}
在Release
配置中,assert(my_boolean)
调用将不会产生任何效果,但在Debug
模式下它会正常工作。如果你在实践断言性编程的同时还需要在发布构建中使用assert()
,你会怎么做?要么更改 CMake 提供的默认设置(从CMAKE_CXX_FLAGS_RELEASE
中移除NDEBUG
),要么通过在包含头文件前取消定义宏来实现硬编码覆盖:
#undef NDEBUG
#include <assert.h>
有关assert
的更多信息,请参考:en.cppreference.com/w/c/error/assert
。
我们已经完成了又一章!毫无疑问,编译是一个复杂的过程。有了所有的边缘情况和特定要求,如果没有一个好工具,管理起来可能会很困难。幸运的是,CMake 在这方面做得很好。
到目前为止,我们学到了什么?我们首先讨论了编译是什么以及它在操作系统中构建和运行应用程序的更广泛故事中的位置。然后,我们研究了编译的阶段以及管理它们的内部工具。这对于解决我们将来可能会遇到的更高级别案例中的所有问题非常有用。
然后,我们探讨了如何让 CMake 验证宿主上可用的编译器是否满足我们代码构建的所有必要要求。正如我们之前所确定的,对于我们的解决方案的用户来说,看到一个友好的消息要求他们升级,而不是由一个混淆于语言新特性的旧编译器打印出的某些神秘错误,会是一个更好的体验。
我们简要讨论了如何向已定义的目标添加源代码,然后转向预处理器配置。这是一个相当大的主题,因为这一阶段将所有的代码片段汇集在一起,决定哪些将被忽略。我们谈论了提供文件路径以及作为单个参数和批量(还有一些用例)添加自定义定义。
然后,我们讨论了优化器;我们探索了所有通用优化级别的优化以及它们隐含的标志,但我们也详细讨论了其中的一些——finline
、floop-unroll
和ftree-vectorize
。
最后,是再次研究整体编译流程和如何管理编译可行性的时候了。在这里我们解决了两个主要问题——减少编译时间(从而加强程序员的注意力集中)和查找错误。后者对于发现什么坏了和如何坏是非常重要的。正确设置工具并了解事情为何如此发生,在确保代码质量(以及我们的心理健康)方面起着很长的作用。
在下一章中,我们将学习链接知识,以及我们需要考虑的所有构建库和使用它们的项目中的事情。
cmake.org/cmake/help/latest/manual/cmake-compile-features.7.html#supported-compilers
interrupt.memfault.com/blog/best-and-worst-gcc-clang-compiler-flags
stackoverflow.com/questions/23615282/object-files-vs-library-files-and-why
有关`assert`的更多信息,请参考:[`en.cppreference.com/w/c/error/assert`](https://en.cppreference.com/w/c/error/assert)。
# 总结
我们已经完成了又一章!毫无疑问,编译是一个复杂的过程。有了所有的边缘情况和特定要求,如果没有一个好工具,管理起来可能会很困难。幸运的是,CMake 在这方面做得很好。
到目前为止,我们学到了什么?我们首先讨论了编译是什么以及它在操作系统中构建和运行应用程序的更广泛故事中的位置。然后,我们研究了编译的阶段以及管理它们的内部工具。这对于解决我们将来可能会遇到的更高级别案例中的所有问题非常有用。
然后,我们探讨了如何让 CMake 验证宿主上可用的编译器是否满足我们代码构建的所有必要要求。正如我们之前所确定的,对于我们的解决方案的用户来说,看到一个友好的消息要求他们升级,而不是由一个混淆于语言新特性的旧编译器打印出的某些神秘错误,会是一个更好的体验。
我们简要讨论了如何向已定义的目标添加源代码,然后转向预处理器配置。这是一个相当大的主题,因为这一阶段将所有的代码片段汇集在一起,决定哪些将被忽略。我们谈论了提供文件路径以及作为单个参数和批量(还有一些用例)添加自定义定义。
然后,我们讨论了优化器;我们探索了所有通用优化级别的优化以及它们隐含的标志,但我们也详细讨论了其中的一些——`finline`、`floop-unroll`和`ftree-vectorize`。
最后,是再次研究整体编译流程和如何管理编译可行性的时候了。在这里我们解决了两个主要问题——减少编译时间(从而加强程序员的注意力集中)和查找错误。后者对于发现什么坏了和如何坏是非常重要的。正确设置工具并了解事情为何如此发生,在确保代码质量(以及我们的心理健康)方面起着很长的作用。
在下一章中,我们将学习链接知识,以及我们需要考虑的所有构建库和使用它们的项目中的事情。
## 进一步阅读
+ 关于本章涵盖的更多信息,你可以参考以下内容:*CMake 支持的编译特性和编译器:* [`cmake.org/cmake/help/latest/manual/cmake-compile-features.7.html#supported-compilers`](https://cmake.org/cmake/help/latest/manual/cmake-compile-features.7.html#supported-compilers)
+ *管理目标源文件:*
+ [Stack Overflow 讨论:为什么 CMake 的文件匹配功能这么“邪恶”?](https://stackoverflow.com/questions/32411963/why-is-cmake-file-glob-evil)
+ [CMake 官方文档:target_sources 命令](https://cmake.org/cmake/help/latest/command/target_sources.html)
+ *提供包含文件的路径:*
+ [C++参考:预处理器中的#include 指令](https://en.cppreference.com/w/cpp/preprocessor/include)
+ [CMake 官方文档:target_include_directories 命令](https://cmake.org/cmake/help/latest/command/target_include_directories.html)
+ *配置头文件:* [CMake 官方文档:configure_file 命令](https://cmake.org/cmake/help/latest/command/configure_file.html)
+ *预编译头文件:* [CMake 官方文档:target_precompile_headers 命令](https://cmake.org/cmake/help/latest/command/target_precompile_headers.html)
+ *统一构建:*
+ [CMake 官方文档:UNITY_BUILD 属性](https://cmake.org/cmake/help/latest/prop_tgt/UNITY_BUILD.html)
+ [Qt 官方博客:关于即将到来的 CMake 中的预编译头文件和大型构建](https://www.qt.io/blog/2019/08/01/precompiled-headers-and-unity-jumbo-builds-in-upcoming-cmake)
+ *查找错误——编译器标志:* [`interrupt.memfault.com/blog/best-and-worst-gcc-clang-compiler-flags`](https://interrupt.memfault.com/blog/best-and-worst-gcc-clang-compiler-flags)
+ *为什么使用库而不是对象文件:* [`stackoverflow.com/questions/23615282/object-files-vs-library-files-and-why`](https://stackoverflow.com/questions/23615282/object-files-vs-library-files-and-why)
+ 分离关注点:*[https://nalexn.github.io/separation-of-concerns/](https://nalexn.github.io/separation-of-concerns/*