面向 C++ 的现代 CMake 教程(二)(4)

简介: 面向 C++ 的现代 CMake 教程(二)

面向 C++ 的现代 CMake 教程(二)(3)https://developer.aliyun.com/article/1525490

条件表达式与 BOOL 运算符评估之间的区别

生成器表达式在评估布尔类型到字符串时可能会有些令人困惑。理解它们与普通的条件表达式有何不同是很重要的,尤其是从一个显式的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

让我们检查每行的输出:

  1. 这是一个布尔展开,其中BOOL0;因此,没有写入TRUE字符串。
  2. 这是一个典型的错误——作者本意是想根据BOOL值的TRUEFALSE打印,但由于它也是一个布尔的false展开,两个参数被视为一个,因此没有打印。
  3. 这是一个反转值的相同错误——它是一个布尔true展开,在单行中写入两个参数。
  4. 这是一个从IF开始的正确条件表达式——它打印FALSE,因为第一个参数是0
  5. 这是条件表达式的错误用法——当我们需要为布尔false不写值时,我们应该使用第一种形式。

生成器表达式以其复杂的语法而闻名。本例中提到的区别即使是经验丰富的构建者也会感到困惑。如果有疑问,将这样的表达式复制到另一个文件中,通过增加缩进和空格来拆分它,以便更好地理解。

总结

理解目标对于编写干净、现代的 CMake 项目至关重要。在本章中,我们不仅讨论了构成目标以及目标如何相互依赖,还学习了如何使用 Graphviz 模块在图表中呈现这些信息。有了这个基本的了解,我们能够学习目标的关键特性——属性(各种各样的属性)。我们不仅介绍了几个设置目标常规属性的命令,还解决了传递属性或传播属性的谜团。解决这个问题很困难,因为我们不仅需要控制哪些属性被传播,还需要可靠地将它们传播到选定的、更远的靶子。此外,我们还发现了如何确保当属性来自多个来源时,它们传播后仍然是兼容的。

我们随后简要讨论了伪目标——导入的目标、别名目标和接口库。它们都将会在我们的项目中派上用场,特别是当我们知道如何将它们与传播属性结合起来以造福我们的项目时。然后,我们谈到了生成的构建目标和它们在配置阶段我们行动的直接结果。之后,我们重点关注自定义命令(它们如何生成可以被其他目标消费、编译、翻译等的文件)以及它们的钩子函数——在目标构建时执行额外步骤。

本章的最后部分致力于生成表达式(genex)的概念。我们解释了其语法、嵌套以及条件表达式的工作原理。然后,我们介绍了两种类型的评估——布尔值和字符串。每种都有它自己的一套表达式,我们详细探索并评论了这些表达式。此外,我们还提供了一些使用示例,并澄清了它们在实际中是如何工作的。

有了这样一个坚实的基础,我们准备好进入下一个主题——将 C++源代码编译成可执行文件和库。

进一步阅读

更多信息,请访问以下网站:

  • Graphviz 模块文档:

CMake 社区文档中的 Graphviz 部分

CMakeGraphVizOptions 模块文档

  • Graphviz 软件:

Graphviz.org

  • CMake 目标属性:

CMake 目标属性

  • 传递性使用要求:

CMake 构建系统的传递性使用要求

第五章:使用 CMake 编译 C++源代码

简单的编译场景通常由工具链的默认配置或直接由 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是您的源代码所在的路径。

编译的基础

编译可以大致描述为将用高级编程语言编写的指令翻译成低级机器代码的过程。这允许我们使用类和对象等抽象概念来创建应用程序,而无需关心处理器特定汇编语言的繁琐细节。我们不需要直接与 CPU 寄存器打交道,考虑短跳或长跳,以及管理堆栈帧。编译语言更有表现力、可读性、更安全,并促进更易维护的代码(但性能尽可能)。

在 C++中,我们依赖于静态编译——整个程序必须在执行之前翻译成本地代码。这是 Java 或 Python 等语言的替代方法,这些语言每次用户运行时都使用特殊的、独立的解释器编译程序。每种方法都有其优点。C++的政策是为尽可能多的提供高级工具,同时仍能以完整的、自包含的应用程序的形式,为几乎所有的架构提供本地性能。

创建并运行一个 C++程序需要几个步骤:

  1. 设计你的应用程序并仔细编写源代码。
  2. 将单个.cpp实现文件(称为翻译单元)编译成目标文件
  3. 目标文件链接成单个可执行文件,并添加所有其他依赖项——动态和静态库。
  4. 要运行程序,操作系统将使用一个名为加载器的工具将它的机器代码和所有必需的动态库映射到虚拟内存。加载器然后读取头文件以检查程序从哪里开始,并将控制权交给代码。
  5. 启动 C++运行时;执行特殊的_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_98cxx_std_11cxx_std_14cxx_std_17cxx_std_20cxx_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.cppgui_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 : 尖括号形式
  • #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)。

使用BEFOREAFTER确定路径应该附加到目标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
}

如它所示,这个例子什么也不做;在这个例子中ABCDEF都没有定义(在这个例子中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_;
}

虽然这种用例非常方便(它允许测试直接访问私有成员),但这不是非常整洁的代码。单元测试应该只测试公共接口中方法是否如预期工作,并将底层实现视为黑盒机制。我建议你只在万不得已时使用这个方法。

使用 git 提交跟踪编译版本

让我们考虑一下在了解环境或文件系统详情方面有益的用例。一个在专业环境中可能很好的例子是传递用于构建二进制的修订版或提交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( )命令,我们可以从模板生成新的文件,就像这个一样:

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//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 命令,因为它们在所有支持的编译器上都是一致的。

是讨论细节的时候了。接下来的章节将介绍您可以在大多数编译器中启用的各种优化方法。

通用级别

优化器的所有不同行为都可以通过我们作为编译选项传递的具体标志进行深度配置。了解它们需要花费大量时间,并需要深入了解编译器、处理器和内存的内部工作原理。如果我们只想在大多数情况下都能良好工作的最佳可能场景怎么办?我们可以寻求一个通用解决方案——一个优化级别指定符。

大多数编译器提供四个基本级别的优化,从03。我们使用-O选项指定它们。-O0意味着没有优化,通常,这是编译器的默认级别。另一方面,-O2被认为是完全优化,它生成高度优化的代码,但编译时间最慢。

有一个中间的-O1级别,根据您的需求,它可以是一个很好的折中方案——它启用了适量的优化机制,而不会使编译速度变得太慢。

最后,我们可以使用-O3,这是完全优化,类似于-O2,但它在子程序内联和循环向量化方面采取了更为激进的方法。

还有一些优化变体,它们将优化生成文件的大小(不一定是速度)——-Os。还有一个超级激进的优化,-Ofast,它是不严格符合 C++标准的-O3优化。最明显的区别是使用-ffast-math-ffinite-math标志,这意味着如果你的程序是关于精确计算(像大多数程序一样),你可能想避免使用它。

CMake 知道并非所有的编译器都平等,因此,为了提供一致的开发体验,它为编译器提供了一些默认标志。这些标志存储在系统级(非目标特定)变量中,用于指定使用的语言(CXX用于 C++)和构建配置(DEBUGRELEASE):

  • CMAKE_CXX_FLAGS_DEBUG等于-g
  • CMAKE_CXX_FLAGS_RELEASE等于-O3 -DNDEBUG

正如你所看到的,调试配置没有启用任何优化,而发布配置直接选择了O3。如果你愿意,你可以直接使用set()命令更改它们,或者只是添加一个目标编译选项,这将覆盖这个默认行为。另外两个标志(-g, -DNDEBUG)与调试有关——我们将在为调试器提供信息部分讨论它们。

诸如CMAKE__FLAGS_之类的变量是全局的——它们适用于所有目标。建议通过target_compile_options()等属性和命令来配置目标,而不是依赖全局变量。这样,你可以更精细地控制你的目标。

通过使用-O选择优化级别,我们间接设置了一系列标志,每个标志控制一个特定的优化行为。然后,我们可以通过添加更多标志来微调优化:

  • 使用-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-...显式禁用内联。无论如何,对于详细信息,请参阅您编译器的特定版本的文档。

面向 C++ 的现代 CMake 教程(二)(5)https://developer.aliyun.com/article/1525492

相关文章
|
4月前
|
C++
Clion CMake C/C++程序输出乱码
Clion CMake C/C++程序输出乱码
|
4月前
|
存储 算法 编译器
C++ 函数式编程教程
C++ 函数式编程学习
|
4月前
|
存储 编译器 开发工具
C++语言教程分享
C++语言教程分享
|
4月前
|
存储 编译器 C++
|
4月前
|
C++ 存储 索引
面向 C++ 的现代 CMake 教程(一)(5)
面向 C++ 的现代 CMake 教程(一)
69 0
|
4月前
|
缓存 存储 C++
面向 C++ 的现代 CMake 教程(一)(4)
面向 C++ 的现代 CMake 教程(一)
66 0
|
4月前
|
C++ 缓存 存储
面向 C++ 的现代 CMake 教程(一)(3)
面向 C++ 的现代 CMake 教程(一)
83 0
|
4月前
|
缓存 C++ Windows
面向 C++ 的现代 CMake 教程(一)(2)
面向 C++ 的现代 CMake 教程(一)
95 0
|
4月前
|
C++ 容器 Docker
面向 C++ 的现代 CMake 教程(一)(1)
面向 C++ 的现代 CMake 教程(一)
107 0
|
Java 编译器 Linux
【CMake】CMake 引入 ( Android Studio 创建 Native C++ 工程 | C/C++ 源码编译过程 | Makefile 工具 | CMake 引入 )(二)
【CMake】CMake 引入 ( Android Studio 创建 Native C++ 工程 | C/C++ 源码编译过程 | Makefile 工具 | CMake 引入 )(二)
306 0
【CMake】CMake 引入 ( Android Studio 创建 Native C++ 工程 | C/C++ 源码编译过程 | Makefile 工具 | CMake 引入 )(二)

相关实验场景

更多
下一篇
无影云桌面