LLVM-技巧-提示和最佳实践-全-
LLVM 技巧、提示和最佳实践(全)
原文:
zh.annas-archive.org/md5/697f0414efca22355c65fee92ee35f12
译者:飞龙
前言
编译器是程序员最常用的工具之一。大多数程序员在其开发流程中都有编译器——或某种形式的编译技术。现代编译器不仅将高级编程语言转换为低级机器代码,而且在优化编译的程序的速度、大小甚至内存占用方面也发挥着关键作用。具有这些特性,构建一个生产就绪的编译器一直是一项具有挑战性的任务。
LLVM 是一个用于编译器优化和代码生成的框架。它提供了构建块,可以显著减少开发者创建高质量优化编译器和编程语言工具的努力。它最著名的产物之一是 Clang——一个 C 系列语言的编译器,构建了包括 Google Chrome 浏览器和 iOS 应用在内的数千个广泛使用的软件。LLVM 也被用于许多不同编程语言的编译器中,如著名的 Swift 编程语言。当谈到创建新的编程语言时,说 LLVM 是最热门的话题之一并不过分。
随着数百个库和数千种不同的 API,LLVM 提供了广泛的功能,从优化程序的关键功能到更通用的实用工具。在这本书中,我们为 LLVM 中最重要两个子系统——Clang 和中间端——提供了全面而详尽的开发者指南。我们首先介绍了一些组件和开发最佳实践,这些都可以提高您使用 LLVM 的总体开发体验。然后,我们将向您展示如何使用 Clang 进行开发。更具体地说,我们将重点关注帮助您增强和定制 Clang 功能的话题。本书的最后部分,您将学习关于 LLVM IR 开发的关键知识。这包括如何使用最新语法编写 LLVM Pass,以及掌握处理不同的 IR 结构。我们还向您展示了几个可以极大地提高您在 LLVM 开发中生产力的实用工具。最后但同样重要的是,本书不假设任何特定的 LLVM 版本——我们努力保持最新,并包括 LLVM 源树中的最新功能。
本书在每一章都提供了一些代码片段和示例项目。我们鼓励您从本书的 GitHub 仓库下载它们,并尝试您自己的定制。
本书面向的对象
本书面向所有 LLVM 经验水平的人,需要具备对编译器的基本理解。如果您是使用 LLVM 进行日常工作的编译器工程师,本书提供了简洁的开发指南和参考。如果您是学术研究人员,本书将帮助您快速学习有用的 LLVM 技能,并构建原型和项目。编程语言爱好者在利用 LLVM 构建新的编程语言时,也会发现本书很有用。
本书涵盖的内容
第一章, 构建 LLVM 时节省资源,简要介绍了 LLVM 项目,然后向您展示如何构建 LLVM 而不会耗尽您的 CPU、内存资源和磁盘空间。这为后续章节的更短的开发周期和更流畅的体验铺平了道路。
第二章, 探索 LLVM 的构建系统功能,展示了如何编写用于树内和树外 LLVM 开发的 CMake 构建脚本。您将学习到利用 LLVM 的自定义构建系统功能编写更表达性和健壮的构建脚本的关键技能。
第三章, 使用 LLVM LIT 进行测试,展示了如何使用 LLVM 的 LIT 基础设施运行测试。本章不仅使您更好地理解测试在 LLVM 源树中的工作方式,还使您能够将这种直观、可扩展的测试基础设施集成到任何项目中。
第四章, TableGen 开发,展示了如何编写 TableGen – 由 LLVM 发明的特殊领域特定语言 (DSL)。我们特别关注将 TableGen 作为处理结构数据的通用工具,为您提供灵活的技能,以便在 LLVM 之外使用 TableGen。
第五章, 探索 Clang 的架构,标志着我们对 Clang 主题的探讨的开始。本章为您提供了 Clang 的概述,特别是其编译流程,并展示了 Clang 编译流程中各个组件的作用。
第六章, 扩展预处理器,展示了 Clang 中预处理器的架构,更重要的是,展示了如何开发一个插件来扩展其功能,而无需修改 LLVM 源树中的任何代码。
第七章, 处理 AST,展示了如何在 Clang 中使用抽象语法树 (AST) 进行开发。内容包括学习与 AST 的内存表示一起工作的重要主题,以及一个教程,介绍如何创建一个插件,将自定义 AST 处理逻辑插入到编译流程中。
第八章, 使用编译器标志和工具链,介绍了向 Clang 添加自定义编译器标志和工具链的步骤。如果您想在 Clang 中支持新功能或新平台,这两项技能尤其关键。
第九章, 使用 PassManager 和 AnalysisManager,标志着我们对 LLVM 中间端的讨论的开始。本章专注于编写 LLVM pass – 使用最新的新 PassManager 语法 – 以及如何通过 AnalysisManager 访问程序分析数据。
第十章,处理 LLVM IR,是一个包含关于 LLVM IR 的多种核心知识的章节,包括 LLVM IR 内存表示的结构以及与不同的 IR 单元(如函数、指令和循环)一起工作的有用技巧。
第十一章,使用支持工具提高效率,介绍了一些可以提高您与 LLVM IR 一起工作时生产力的工具——例如,获得更好的调试体验。
第十二章,学习 LLVM IR 工具化,展示了在 LLVM IR 上工具化的工作方式。它涵盖了两个主要用例:Sanitizer 和基于配置文件优化(PGO)。对于前者,您将学习如何创建自定义 Sanitizer。对于后者,您将学习如何在 LLVM Pass 中利用 PGO 数据。
要充分利用本书
本书旨在向您介绍 LLVM 的最新功能,因此我们鼓励您在本书中使用 LLVM 12.0 版本之后的版本,或者甚至整个开发分支——即主分支。
我们假设您正在使用 Linux 或 Unix 系统(包括 macOS)。本书中的工具和示例命令大多在命令行界面中运行,但您可以使用任何代码编辑器或 IDE 来编写您的代码。
在第一章**,在构建 LLVM 中节省资源*,我们将提供如何从源代码构建 LLVM 的详细信息。
如果您正在使用本书的数字版,我们建议您亲自输入代码或通过 GitHub 仓库(下一节中提供链接)访问代码。这样做将帮助您避免与代码复制粘贴相关的任何潜在错误。
下载示例代码文件
您可以从 GitHub 在github.com/PacktPublishing/LLVM-Techniques-Tips-and-Best-Practices-Clang-and-Middle-End-Libraries
上下载本书的示例代码文件。如果代码有更新,它将在现有的 GitHub 仓库中更新。
我们还有其他来自我们丰富的图书和视频目录的代码包,可在github.com/PacktPublishing/
找到。查看它们吧!
下载彩色图像
我们还提供了一份包含本书中使用的截图/图表彩色图像的 PDF 文件。您可以从这里下载:static.packt-cdn.com/downloads/9781838824952_ColorImages.pdf
。
使用的约定
本书使用了多种文本约定。
文本中的代码
:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“要将 Clang 包含在构建列表中,请编辑分配给LLVM_ENABLE_PROJECTS
CMake 变量的值。”
代码块应如下设置:
TranslationUnitDecl 0x560f3929f5a8 <<invalid sloc>> <invalid sloc>
|…
`-FunctionDecl 0x560f392e1350 <./test.c:2:1, col:30> col:5 foo 'int (int)'
当我们希望将您的注意力引到代码块中的特定部分时,相关的行或项目将以粗体显示:
|-ParmVarDecl 0x560f392e1280 <col:9, col:13> col:13 used c 'int'
`-CompoundStmt 0x560f392e14c8 <col:16, col:30>
`-ReturnStmt 0x560f392e14b8 <col:17, col:28>
`-BinaryOperator 0x560f392e1498 <col:24, col:28> 'int' '+'
|-ImplicitCastExpr 0x560f392e1480 <col:24> 'int' <LValueToRValue>
| `-DeclRefExpr 0x560f392e1440 <col:24> 'int' lvalue ParmVar 0x560f392e1280 'c' 'int'
`-IntegerLiteral 0x560f392e1460 <col:28> 'int' 1
任何命令行输入或输出都应如下所示:
$ clang -fplugin=/path/to/MyPlugin.so … foo.cpp
粗体:表示新术语、重要单词或您在屏幕上看到的单词。例如,菜单或对话框中的单词在文本中显示如下。以下是一个示例:“从管理面板中选择系统信息。”
小贴士或重要注意事项
看起来像这样。
联系我们
我们欢迎读者的反馈。
一般反馈:如果您对本书的任何方面有疑问,请在邮件主题中提及书名,并将邮件发送至 customercare@packtpub.com。
勘误表:尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,我们将非常感激您能向我们报告。请访问www.packtpub.com/support/errata,选择您的书籍,点击勘误表提交表单链接,并输入详细信息。
盗版:如果您在互联网上以任何形式发现我们作品的非法副本,我们将非常感激您能提供位置地址或网站名称。请通过 copyright@packt.com 与我们联系,并提供材料的链接。
如果您有兴趣成为作者:如果您在某个领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问authors.packtpub.com。
评论
请留下评论。一旦您阅读并使用了这本书,为何不在购买它的网站上留下评论呢?潜在的读者可以查看并使用您的客观意见来做出购买决定,我们 Packt 可以了解您对我们产品的看法,我们的作者也可以看到他们对书籍的反馈。谢谢!
如需更多关于 Packt 的信息,请访问packt.com。
第一部分:构建系统和 LLVM 特定工具
你将学习在树内和树外场景下开发 LLVM 构建系统的高级技能。本节包括以下章节:
-
第一章, 构建 LLVM 时节省资源
-
第二章, 探索 LLVM 的构建系统功能
-
第三章, 使用 LLVM LIT 进行测试
-
第四章, TableGen 开发
第一章:第一章:构建 LLVM 时节省资源
LLVM 是许多令人惊叹的工业和学术项目采用的先进编译器优化和代码生成框架,例如 JavaScript 引擎中的 即时编译器(JIT)和 机器学习(ML)框架。它是构建编程语言和二进制文件工具的有用工具箱。然而,尽管该项目非常稳健,但其学习资源分散,而且文档也不是最好的。正因为如此,即使是有些 LLVM 经验的开发者,其学习曲线也相当陡峭。本书旨在通过以实用方式向您提供 LLVM 中常见和重要领域知识来解决这些问题——向您展示一些有用的工程技巧,指出一些不太为人所知但实用的功能,并举例说明有用的示例。
作为 LLVM 开发者,从源代码构建 LLVM 总是您应该做的第一件事。鉴于 LLVM 当前的规模,这项任务可能需要数小时才能完成。更糟糕的是,重建项目以反映更改也可能需要很长时间,从而阻碍您的生产力。因此,了解如何使用正确的工具以及如何为您的项目找到最佳的构建配置,以节省各种资源,尤其是您宝贵的时间,这一点至关重要。
在本章中,我们将涵盖以下主题:
-
通过更好的工具减少构建资源
-
通过调整 CMake 参数节省构建资源
-
学习如何使用 GN,一个替代的 LLVM 构建系统,以及其优缺点
技术要求
在撰写本书时,LLVM 只有一些软件要求:
-
支持 C++14 的 C/C++ 编译器
-
CMake
-
CMake 支持的构建系统之一,例如 GNU Make 或 Ninja
-
Python(2.7 也行,但我强烈建议使用 3.x)
-
zlib
这些项目的确切版本会不时发生变化。有关更多详细信息,请参阅 llvm.org/docs/GettingStarted.html#software
。
本章假设您之前已经构建过 LLVM。如果不是这样,请执行以下步骤:
-
从 GitHub 获取 LLVM 源代码树副本:
$ git clone https://github.com/llvm/llvm-project
-
通常,默认分支应该无错误地构建。如果您想使用更稳定的发布版本,例如 10.x 版本的发布版本,请使用以下命令:
$ git clone -b release/10.x https://github.com/llvm/llvm-project
-
最后,您应该创建一个构建文件夹,您将在其中调用 CMake 命令。所有构建工件也将放置在这个文件夹中。可以使用以下命令完成此操作:
$ mkdir .my_build $ cd .my_build
通过更好的工具减少构建资源
如本章开头所述,如果您使用默认(CMake)配置构建 LLVM,通过以下方式调用 CMake 并构建项目,整个过程可能需要 数小时 才能完成:
$ cmake ../llvm
$ make all
这可以通过简单地使用更好的工具和更改一些环境来避免。在本节中,我们将介绍一些指导原则,以帮助您选择正确的工具和配置,这些工具和配置既可以加快您的构建时间,又可以改善内存占用。
用 Ninja 替换 GNU Make
我们可以做的第一个改进是使用 Ninja 构建工具 (ninja-build.org
) 而不是 GNU Make,这是 CMake 在主要 Linux/Unix 平台上生成的默认构建系统。
这里有一些步骤可以帮助你在系统上设置 Ninja:
-
例如,在 Ubuntu 上,你可以使用以下命令安装 Ninja:
$ sudo apt install ninja-build
Ninja 也适用于大多数 Linux 发行版。
-
然后,当你在构建 LLVM 时调用 CMake,请添加一个额外的参数:
$ cmake -G "Ninja" ../llvm
-
最后,使用以下构建命令代替:
$ ninja all
在大型代码库如 LLVM 上,Ninja 比 GNU Make 运行得显著更快。Ninja 运行速度极快的一个秘密是,尽管大多数构建脚本如 Makefile
都是设计为手动编写的,但 Ninja 的构建脚本 build.ninja
的语法更类似于汇编代码,这应该不应该由开发者编辑,而应该由其他高级构建系统如 CMake 生成。Ninja 使用类似汇编的构建脚本的事实使得它能够在幕后进行许多优化,并消除许多冗余,例如在调用构建时的较慢解析速度。Ninja 在生成构建目标之间的依赖关系方面也有很好的声誉。
Ninja 在其并行化程度方面做出了聪明的决策;也就是说,你想要并行执行多少个作业。所以,通常你不需要担心这一点。如果你想显式地分配工作线程的数量,GNU Make 使用的相同命令行选项在这里仍然有效:
$ ninja -j8 all
现在我们来看看如何避免使用 BFD 链接器。
避免使用 BFD 链接器
我们可以做的第二个改进是使用除了 BFD 链接器之外的链接器,这是大多数 Linux 系统中使用的默认链接器。尽管 BFD 链接器是 Unix/Linux 系统上最成熟的链接器,但它并不是针对速度或内存消耗进行优化的。这会创建一个性能瓶颈,尤其是在像 LLVM 这样的大型项目中。这是因为,与编译阶段不同,链接阶段很难在文件级别上进行并行化。更不用说 BFD 链接器在构建 LLVM 时的峰值内存消耗通常约为 20 GB,这会给内存较少的计算机带来负担。幸运的是,至少有两种链接器在野外提供良好的单线程性能和低内存消耗:GNU gold 链接器和 LLVM 自带的链接器 LLD。
金链接器最初由谷歌开发,捐赠给了 GNU 的binutils
。在现代 Linux 发行版中,您应该默认在binutils
软件包中找到它。LLD 是 LLVM 的子项目之一,具有更快的链接速度和实验性的并行链接技术。一些 Linux 发行版(例如较新的 Ubuntu 版本)已经在其软件仓库中包含了 LLD。您也可以从 LLVM 的官方网站下载预构建版本。
要使用 gold 链接器或 LLD 构建您的 LLVM 源代码树,请添加一个额外的 CMake 参数,指定您想要使用的链接器名称。
对于 gold 链接器,使用以下命令:
$ cmake -G "Ninja" -DLLVM_USE_LINKER=gold ../llvm
类似地,对于 LLD,使用以下命令:
$ cmake -G "Ninja" -DLLVM_USE_LINKER=lld ../llvm
限制链接的并行线程数量
限制链接的并行线程数量是减少(峰值)内存消耗的另一种方法。您可以通过分配LLVM_PARALLEL_LINK_JOBS=<N>
CMake 变量来实现这一点,其中N
是期望的工作线程数。
通过使用不同的工具,我们可以显著减少构建时间。在下一节中,我们将通过调整 LLVM 的 CMake 参数来提高构建速度。
调整 CMake 参数
本节将向您展示 LLVM 构建系统中的一些最常见 CMake 参数,这些参数可以帮助您自定义构建并实现最大效率。
在我们开始之前,您应该有一个已经通过 CMake 配置的构建文件夹。以下大部分子部分将修改构建文件夹中的一个文件;即CMakeCache.txt
文件。
选择正确的构建类型
LLVM 使用 CMake 提供的几个预定义的构建类型。其中最常见的是以下几种:
-
Release
:如果您没有指定任何构建类型,这是默认的构建类型。它将采用最高的优化级别(通常是-O3)并消除大部分调试信息。通常,这种构建类型会使构建速度略微变慢。 -
Debug
:这种构建类型将不应用任何优化(即-O0)。它保留所有调试信息。请注意,这将生成大量的工件,通常需要占用约 20GB 的空间,因此在使用此构建类型时,请确保您有足够的存储空间。由于没有进行优化,这通常会使构建速度略微加快。 -
RelWithDebInfo
:这种构建类型尽可能多地应用编译器优化(通常是-O2)并保留所有调试信息。这是一个在空间消耗、运行时速度和可调试性之间取得平衡的选项。
您可以使用CMAKE_BUILD_TYPE
CMake 变量选择其中之一。例如,要使用RelWithDebInfo
类型,可以使用以下命令:
$ cmake -DCMAKE_BUILD_TYPE=RelWithDebInfo …
建议首先使用RelWithDebInfo
(如果你打算稍后调试 LLVM)。现代编译器在优化程序二进制中的调试信息质量方面已经取得了长足的进步。因此,始终先尝试它以避免不必要的存储浪费;如果事情没有按预期进行,你始终可以回到Debug
类型。
除了配置构建类型外,LLVM_ENABLE_ASSERTIONS
是另一个控制是否启用断言(即assert(bool predicate)
函数,如果谓词参数不为真,则终止程序)的 CMake(布尔)参数。默认情况下,此标志仅在构建类型为Debug
时为真,但你始终可以手动将其打开以强制执行更严格的检查,即使在其他构建类型中也是如此。
避免构建所有目标
在过去几年中,LLVM 支持的硬件目标数量迅速增长。在撰写本书时,有近 20 个官方支持的目标。每个目标都处理非平凡的任务,例如原生代码生成,因此构建需要花费相当多的时间。然而,你同时处理所有这些目标的几率很低。因此,你可以使用LLVM_TARGETS_TO_BUILD
CMake 参数选择构建目标的一个子集。例如,要仅构建 X86 目标,我们可以使用以下命令:
$ cmake -DLLVM_TARGETS_TO_BUILD="X86" …
你还可以使用分号分隔的列表指定多个目标,如下所示:
$ cmake -DLLVM_TARGETS_TO_BUILD="X86;AArch64;AMDGPU" …
用双引号括起目标列表!
在某些 shell 中,例如BASH
,分号是命令的结束符号。所以,如果你不用双引号括起目标列表,CMake 命令的其余部分将被截断。
让我们看看构建共享库如何帮助调整 CMake 参数。
构建为共享库
LLVM 最标志性的特性之一是其 Unix/Linux 中的*.a
和 Windows 中的*.lib
。然而,在这种情况下,静态库有以下缺点:
-
静态库的链接通常比动态库(Unix/Linux 中的
*.so
和 Windows 中的*.dll
)的链接花费更多时间。 -
如果多个可执行文件链接到同一组库,例如许多 LLVM 工具所做的那样,当你采用静态库方法时,与动态库对应方法相比,这些可执行文件的总大小将显著更大。这是因为每个可执行文件都有这些库的副本。
-
当你使用调试器(例如 GDB)调试 LLVM 程序时,它们通常会在开始时花费相当多的时间加载静态链接的可执行文件,这会阻碍调试体验。
因此,建议在开发阶段使用BUILD_SHARED_LIBS
CMake 参数将每个 LLVM 组件构建为动态库:
$ cmake -DBUILD_SHARED_LIBS=ON …
这将为您节省大量的存储空间并加快构建过程。
分离调试信息
当你在调试模式下构建程序时——例如,使用 GCC 和 Clang 时添加-g
标志——默认情况下,生成的二进制文件包含一个存储cm``AKE_BUILD_TYPE=Debug
变量的部分——编译的库和可执行文件附带大量调试信息,这些信息占据了大量的磁盘空间。这导致以下问题:
-
由于 C/C++的设计,相同的调试信息可能会嵌入到不同的对象文件中(例如,头文件的调试信息可能嵌入到包含它的每个库中),这浪费了大量的磁盘空间。
-
链接器需要在链接阶段将对象文件及其相关的调试信息加载到内存中,这意味着如果对象文件包含非平凡的调试信息量,内存压力将会增加。
为了解决这些问题,LLVM 的构建系统提供了一种方法,允许我们将调试信息从原始对象文件中分割到单独的文件中。通过将调试信息从对象文件中分离出来,同一源文件的调试信息被压缩到一个地方,从而避免了不必要的重复创建并节省了大量磁盘空间。此外,由于调试信息不再是对象文件的一部分,链接器不再需要将它们加载到内存中,从而节省了大量内存资源。最后但同样重要的是,这个特性还可以提高我们的增量构建速度——即,在(小的)代码更改后重新构建项目——因为我们只需要更新单个地方的修改后的调试信息。
要使用此功能,请使用LLVM_USE_SPLIT_DWARF
CMake 变量:
$ cmake -DcmAKE_BUILD_TYPE=Debug -DLLVM_USE_SPLIT_DWARF=ON …
注意,这个 CMake 变量仅适用于使用 DWARF 调试格式的编译器,包括 GCC 和 Clang。
构建优化版本的llvm-tblgen
llvm-tblgen
。换句话说,llvm-tblgen
的运行时间将影响 LLVM 本身的构建时间。因此,如果你没有开发 TableGen 部分,无论全局构建类型(即CMAKE_BUILD_TYPE
)如何,始终构建一个优化版本的llvm-tblgen
都是一个好主意,这样可以使llvm-tblgen
运行得更快,并缩短整体构建时间。
例如,以下 CMake 命令将创建构建配置,构建除llvm-tblgen
可执行文件外的所有内容的调试版本,该可执行文件将作为优化版本构建:
$ cmake -DLLVM_OPTIMIZED_TABLEGEN=ON -DCMAKE_BUILD_TYPE=Debug …
最后,你将看到如何使用 Clang 和新的 PassManager。
使用新的 PassManager 和 Clang
Clang是 LLVM 的官方 C 族前端(包括 C、C++和 Objective-C)。它使用 LLVM 的库生成机器代码,这些代码由 LLVM 中最重要的子系统之一——PassManager组织。PassManager 将所有优化和代码生成所需的任务(即 Passes)组合在一起。
在第九章 与 PassManager 和 AnalysisManager 一起工作中,将介绍 LLVM 的新 PassManager,它从头开始构建,以在未来某个时候替换现有的 PassManager。与传统的 PassManager 相比,新的 PassManager 具有更快的运行速度。这种优势间接地为 Clang 带来了更好的运行性能。因此,这里的想法非常简单:如果我们使用 Clang 并启用新的 PassManager 来构建 LLVM 的源代码树,编译速度将会更快。大多数主流 Linux 发行版的软件包仓库已经包含了 Clang。如果您想获得更稳定的 PassManager 实现,建议使用 Clang 6.0 或更高版本。使用LLVM_USE_NEWPM
CMake 变量来使用新的 PassManager 构建 LLVM,如下所示:
$ env CC=`which clang` CXX=`which clang++` \
cmake -DLLVM_USE_NEWPM=ON …
LLVM 是一个庞大的项目,构建它需要花费很多时间。前两节介绍了一些提高其构建速度的有用技巧和提示。在下一节中,我们将介绍一个替代的构建系统来构建 LLVM。它相对于默认的 CMake 构建系统有一些优势,这意味着在某些场景下它将更加适合。
使用 GN 以获得更快的周转时间
CMake 是可移植和灵活的,并且已经被许多工业项目所实战检验。然而,在重新配置方面,它有一些严重的问题。正如我们在前几节中看到的,一旦构建文件生成,您可以通过编辑构建文件夹中的CMakeCache.txt
文件来修改一些 CMake 参数。当您再次调用build
命令时,CMake 将重新配置构建文件。如果您编辑源文件夹中的CMakeLists.txt
文件,相同的重新配置也会启动。CMake 的重新配置过程主要有两个缺点:
-
在某些系统中,CMake 配置过程相当慢。即使是重新配置,理论上只运行部分过程,有时仍然需要很长时间。
-
有时,CMake 将无法解决不同变量和构建目标之间的依赖关系,因此您的更改将不会反映出来。在最坏的情况下,它将默默地失败,让您花费很长时间来找出问题。
生成 Ninja,也称为GN,是 Google 许多项目(如 Chromium)使用的构建文件生成器。GN 从其自己的描述语言生成 Ninja 文件。它因其快速的配置时间和可靠的参数管理而享有良好的声誉。自 2018 年底(大约版本 8.0.0)以来,LLVM 已经引入了 GN 支持,作为一种(实验性的)替代构建方法。如果您的开发更改了构建文件,或者您想在短时间内尝试不同的构建选项,GN 特别有用。
使用 GN 构建 LLVM 的步骤如下:
-
LLVM 的 GN 支持位于
llvm/utils/gn
文件夹中。切换到该文件夹后,运行以下get.py
脚本来本地下载 GN 的可执行文件:get.py, simply put your version into the system's PATH. If you are wondering what other GN versions are available, you might want to check out the instructions for installing depot_tools at https://dev.chromium.org/developers/how-tos/install-depot-tools.
-
在同一文件夹中使用
gn.py
生成构建文件(本地的gn.py
只是真实gn
的包装,用于设置基本环境):out/x64.release is the name of the build folder. Usually, GN users will name the build folder in <architecture>.<build type>.<other features> format.
-
最后,您可以切换到构建文件夹并启动 Ninja:
$ cd out/x64.release $ ninja <build target>
-
或者,您可以使用
-C
Ninja 选项:$ ninja -C out/x64.release <build target>
您可能已经知道,初始构建文件生成过程非常快。现在,如果您想更改一些构建参数,请导航到构建文件夹下的 args.gn
文件(在这个例子中是 out/x64.release/args.gn
);例如,如果您想将构建类型更改为 debug
并将目标构建(即 LLVM_TARGETS_TO_BUILD
CMake 参数)改为 X86
和 AArch64
。建议使用以下命令来启动编辑器编辑 args.gn
:
$ ./gn.py args out/x64.release
在 args.gn
编辑器中输入以下内容:
# Inside args.gn
is_debug = true
llvm_targets_to_build = ["X86", "AArch64"]
保存并退出编辑器后,GN 将进行一些语法检查并重新生成构建文件(当然,您可以在不使用 gn
命令的情况下编辑 args.gn
,并且构建文件不会重新生成,直到您调用 ninja
命令)。这种重新生成/重新配置也将很快。最重要的是,不会有任何不一致的行为。多亏了 GN 的语言设计,不同构建参数之间的关系可以很容易地分析,几乎没有歧义。
通过运行此命令可以找到 GN 的构建参数列表:
$ ./gn.py args --list out/x64.release
不幸的是,在撰写本书时,仍有大量 CMake 参数尚未移植到 GN。GN 并非 LLVM 现有 CMake 构建系统的替代品,而是一个替代方案。尽管如此,如果您在涉及许多构建配置更改的开发中希望快速迭代,GN 仍然是一个不错的构建方法。
摘要
当涉及到构建用于代码优化和代码生成的工具时,LLVM 是一个有用的框架。然而,其代码库的大小和复杂性导致构建时间相当可观。本章提供了一些加快 LLVM 源树构建时间的技巧,包括使用不同的构建工具、选择正确的 CMake 参数,甚至采用除 CMake 之外的构建系统。这些技能减少了不必要的资源浪费,并在使用 LLVM 进行开发时提高了您的生产力。
在下一章中,我们将深入探讨基于 CMake 的 LLVM 构建基础设施,并展示如何构建在许多不同开发环境中至关重要的系统特性和指南。
进一步阅读
-
您可以在
llvm.org/docs/CMake.html#frequently-used-CMake-variables
查看由 LLVM 使用的完整 CMake 变量列表。你可以在
gn.googlesource.com/gn
了解更多关于 GN 的信息。gn.googlesource.com/gn/+/master/docs/quick_start.md
上的快速入门指南也非常有帮助。
第二章:第二章:探索 LLVM 的构建系统功能
在上一章中,我们了解到 LLVM 的构建系统是一个庞然大物:它包含数百个构建文件,有成千上万的交错构建依赖。更不用说,它还包含需要为异构源文件定制构建指令的目标。这些复杂性驱使 LLVM 采用了一些高级构建系统特性,更重要的是,一个更结构化的构建系统设计。在本章中,我们的目标将是了解一些重要的指令,以便在树内和树外进行 LLVM 开发时编写更简洁和更具表现力的构建文件。
在本章中,我们将涵盖以下主要主题:
-
探索 LLVM 重要 CMake 指令的词汇表
-
在树外项目中通过 CMake 集成 LLVM
技术要求
与第一章的构建 LLVM 时的资源节约类似,你可能想要有一个从源代码构建的 LLVM 副本。可选地,由于本章将涉及大量的 CMake 构建文件,你可能希望为CMakeLists.txt
准备一个语法高亮插件(例如,VSCode 的CMake Tools插件)。所有主流 IDE 和编辑器都应该有现成的。此外,熟悉基本的CMakeLists.txt
语法是首选的。
本章中所有的代码示例都可以在这个书的 GitHub 仓库中找到:github.com/PacktPublishing/LLVM-Techniques-Tips-and-Best-Practices/tree/main/Chapter02
.
探索 LLVM 重要 CMake 指令的词汇表
由于在选择底层构建系统方面的更高灵活性,LLVM 已经从GNU autoconf切换到CMake。从那时起,LLVM 已经提出了许多自定义的 CMake 函数、宏和规则来优化其自身的使用。本节将为您概述其中最重要的和最常用的几个。我们将学习如何以及何时使用它们。
使用 CMake 函数添加新库
库是 LLVM 框架的构建块。然而,在为新的库编写CMakeLists.txt
时,你不应该使用在正常的CMakeLists.txt
文件中出现的普通add_library
指令,如下所示:
# In an in-tree CMakeLists.txt file…
add_library(MyLLVMPass SHARED
MyPass.cpp) # Do NOT do this to add a new LLVM library
在这里使用普通的add_library
有几个缺点,如下所示:
-
如第一章所示,构建 LLVM 时的资源节约,LLVM 更倾向于使用全局 CMake 参数(即
BUILD_SHARED_LIBS
)来控制其所有组件库是否应该静态或动态构建。使用内置指令来做这一点相当困难。 -
与前一点类似,LLVM 更倾向于使用全局 CMake 参数来控制一些编译标志,例如是否在代码库中启用运行时类型信息(RTTI)和C++异常处理。
-
通过使用自定义 CMake 函数/宏,LLVM 可以创建自己的组件系统,这为开发者提供了更高层次的抽象,以便以更简单的方式指定构建目标依赖项。
因此,你应该始终使用这里所示的 add_llvm_component_library
CMake 函数:
# In a CMakeLists.txt
add_llvm_component_library(LLVMFancyOpt
FancyOpt.cpp)
这里,LLVMFancyOpt
是最终的库名称,而 FancyOpt.cpp
是源文件。
在常规的 CMake 脚本中,你可以使用 target_link_libraries
来指定给定目标的库依赖项,然后使用 add_dependencies
来在不同构建目标之间分配依赖关系,以创建明确的构建顺序。当你使用 LLVM 的自定义 CMake 函数创建库目标时,有更简单的方法来完成这些任务。
通过在 add_llvm_component_library
(或 add_llvm_library
,这是前者的底层实现)中使用 LINK_COMPONENTS
参数,你可以指定目标的链接组件:
add_llvm_component_library(LLVMFancyOpt
FancyOpt.cpp
LINK_COMPONENTS
Analysis ScalarOpts)
或者,你可以使用在函数调用之前定义的 LLVM_LINK_COMPONENTS
变量来完成相同的事情:
set(LLVM_LINK_COMPONENTS
Analysis ScalarOpts)
add_llvm_component_library(LLVMFancyOpt
FancyOpt.cpp)
组件库只是具有特殊意义的普通库,当涉及到你可以使用的 LLVM 构建块 时。如果你选择构建它,它们也包含在庞大的 libLLVM
库中。组件名称与真实库名称略有不同。如果你需要从组件名称到库名称的映射,你可以使用以下 CMake 函数:
llvm_map_components_to_libnames(output_lib_names
<list of component names>)
如果你想要直接链接到一个 普通 库(非 LLVM 组件的库),你可以使用 LINK_LIBS
参数:
add_llvm_component_library(LLVMFancyOpt
FancyOpt.cpp
LINK_LIBS
${BOOST_LIBRARY})
要将一般构建目标依赖项分配给库目标(相当于 add_dependencies
),你可以使用 DEPENDS
参数:
add_llvm_component_library(LLVMFancyOpt
FancyOpt.cpp
DEPENDS
intrinsics_gen)
intrinsics_gen
是一个表示生成包含 LLVM 内置定义的头文件过程的通用目标。
每个文件夹添加一个构建目标
许多 LLVM 自定义 CMake 函数存在一个涉及源文件检测的陷阱。假设你有一个如下的目录结构:
/FancyOpt
|___ FancyOpt.cpp
|___ AggressiveFancyOpt.cpp
|___ CMakeLists.txt
这里,你有两个源文件,FancyOpt.cpp
和 AggressiveFancyOpt.cpp
。正如它们的名称所暗示的,FancyOpt.cpp
是这种优化的基本版本,而 AggressiveFancyOpt.cpp
是相同功能的替代、更激进的版本。自然地,你将希望将它们分成单独的库,以便用户可以选择是否在他们的正常工作量中包含更激进的版本。因此,你可能编写一个 CMakeLists.txt
文件如下:
# In /FancyOpt/CMakeLists.txt
add_llvm_component_library(LLVMFancyOpt
FancyOpt.cpp)
add_llvm_component_library(LLVMAggressiveFancyOpt
AggressiveFancyOpt.cpp)
不幸的是,这会在处理第一个 add_llvm_component_library
语句时生成错误消息,告诉你类似 Found unknown source AggressiveFancyOpt.cpp …
的事情。
LLVM 的构建系统强制执行更严格的规则,以确保同一文件夹中的所有 C/C++ 源文件都被添加到同一个库、可执行文件或插件中。为了解决这个问题,有必要将其中一个文件拆分到一个单独的文件夹中,如下所示:
/FancyOpt
|___ FancyOpt.cpp
|___ CMakeLists.txt
|___ /AggressiveFancyOpt
|___ AggressiveFancyOpt.cpp
|___ CMakeLists.txt
在 /FancyOpt/CMakeLists.txt
中,我们有以下内容:
add_llvm_component_library(LLVMFancyOpt
FancyOpt.cpp)
add_subdirectory(AggressiveFancyOpt)
最后,在 /FancyOpt/AggressiveFancyOpt/CMakeLists.txt
文件中,我们有以下内容:
add_llvm_component_library(LLVMAggressiveFancyOpt
AggressiveFancyOpt.cpp)
这些是使用 LLVM 的自定义 CMake 指令添加(组件)库构建目标的基本要素。在接下来的两个部分中,我们将向您展示如何使用一组不同的 LLVM 特定 CMake 指令添加可执行文件和 Pass 插件构建目标。
使用 CMake 函数添加可执行文件和工具
与 add_llvm_component_library
类似,要添加新的可执行目标,我们可以使用 add_llvm_executable
或 add_llvm_tool
:
add_llvm_tool(myLittleTool
MyLittleTool.cpp)
这两个函数具有相同的语法。然而,只有由 add_llvm_tool
创建的目标才会包含在安装中。还有一个全局 CMake 变量 LLVM_BUILD_TOOLS
,它启用/禁用这些 LLVM 工具目标。
这两个函数也可以使用 DEPENDS
参数来指定依赖项,类似于我们之前介绍的 add_llvm_library
。然而,您只能使用 LLVM_LINK_COMPONENTS
变量来指定要链接的组件。
使用 CMake 函数添加 Pass 插件
尽管我们将在本书的后面部分介绍 Pass 插件开发,但添加 Pass 插件的构建目标现在(与早期仍使用带有一些特殊参数的 add_llvm_library
的 LLVM 版本相比)不可能更简单了。我们可以简单地使用以下命令:
add_llvm_pass_plugin(MyPass
HelloWorldPass.cpp)
LINK_COMPONENTS
、LINK_LIBS
和 DEPENDS
参数也在这里可用,其用法和功能与 add_llvm_component_library
中相同。
这些是一些最常见且最重要的 LLVM 特定 CMake 指令。使用这些指令不仅可以使您的 CMake 代码更加简洁,还可以帮助它与 LLVM 的自身构建系统同步,以防您想进行一些树内开发。在下一节中,我们将向您展示如何将 LLVM 集成到树外 CMake 项目中,并利用我们在本章中学到的知识。
树内与树外开发
在本书中,树内 开发意味着直接向 LLVM 项目贡献代码,例如修复 LLVM 缺陷或向现有的 LLVM 库添加新功能。另一方面,树外 开发可能代表为 LLVM 创建扩展(例如编写 LLVM Pass)或在其他项目中使用 LLVM 库(例如使用 LLVM 的代码生成库来实现您自己的编程语言)。
理解树外项目的 CMake 集成
在树内项目中实现你的功能对于原型设计是有益的,因为大部分基础设施已经存在。然而,与创建一个 树外项目 并将其链接到 LLVM 库相比,有许多场景将整个 LLVM 源代码树拉入你的代码库并不是最佳选择。例如,你可能只想创建一个使用 LLVM 功能的小型代码重构工具并在 GitHub 上开源,那么让 GitHub 上的开发者下载与你小巧的工具一起的多吉字节 LLVM 源代码树可能不会是一个愉快的体验。
配置树外项目以链接到 LLVM 至少有两种方式:
-
使用
llvm-config
工具 -
使用 LLVM 的 CMake 模块
这两种方法都有助于你整理所有细节,包括头文件和库路径。然而,后者创建的 CMake 脚本更简洁、更易读,这对于已经使用 CMake 的项目来说更可取。本节将展示使用 LLVM 的 CMake 模块将 LLVM 集成到树外 CMake 项目的必要步骤。
首先,我们需要准备一个树外(C/C++)CMake 项目。我们在上一节中讨论的核心 CMake 函数/宏将帮助我们完成这项工作。让我们看看我们的步骤:
-
我们假设你已经为需要链接到 LLVM 库的项目准备好了以下
CMakeLists.txt
框架:project(MagicCLITool) set(SOURCE_FILES main.cpp) add_executable(magic-cli ${SOURCE_FILES})
无论你是在尝试创建一个生成可执行文件的项目,就像我们在前面的代码块中看到的那样,还是其他如库或甚至 LLVM Pass 插件等工件,现在最大的问题是如何获取
包含路径
以及库路径
。 -
为了解决
包含路径
和库路径
,LLVM 为你提供了标准的 CMake 包接口,你可以使用find_package
CMake 指令导入各种配置,如下所示:project(MagicCLITool) find_package trick work, you need to supply the LLVM_DIR CMake variable while invoking the CMake command for this project:
LLVM 安装路径下的
lib/cmake/llvm
子目录。 -
在解决包含路径和库之后,是时候将主可执行文件链接到 LLVM 的库上了。LLVM 的自定义 CMake 函数(例如,
add_llvm_executable
)在这里将非常有用。但首先,CMake 需要能够 找到 这些函数。以下片段导入了 LLVM 的 CMake 模块(更具体地说,是
AddLLVM
CMake 模块),其中包含我们在上一节中介绍过的那些 LLVM 特定函数/宏:find_package(LLVM REQUIRED CONFIG) … list(APPEND CMAKE_MODULE_PATH ${LLVM_CMAKE_DIR}) include(AddLLVM)
-
以下片段使用我们在上一节中介绍过的 CMake 函数添加了可执行文件的构建目标:
find_package(LLVM REQUIRED CONFIG) … include(AddLLVM) set(LLVM_LINK_COMPONENTS Support Analysis) add_llvm_executable(magic-cli main.cpp)
-
添加库目标没有区别:
find_package(LLVM REQUIRED CONFIG) … include(AddLLVM) add_llvm_library(MyMagicLibrary lib.cpp LINK_COMPONENTS Support Analysis)
-
最后,添加 LLVM Pass 插件:
find_package(LLVM REQUIRED CONFIG) … include(AddLLVM) add_llvm_pass_plugin(MyMagicPass ThePass.cpp)
-
在实践中,你还需要注意 LLVM 特定定义 和 RTTI 设置:
find_package(LLVM REQUIRED CONFIG) … add_definitions(${LLVM_DEFINITIONS}) if(NOT ${LLVM_ENABLE_RTTI}) # For non-MSVC compilers set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -fno-rtti") endif() add_llvm_xxx(source.cpp)
这对于 RTTI 部分尤其如此,因为默认情况下,LLVM 并未构建 RTTI 支持,而正常的 C++ 应用程序是支持的。如果你的代码和 LLVM 库之间存在 RTTI 不匹配,将会抛出编译错误。
尽管在 LLVM 源树内开发很方便,但有时将整个 LLVM 源代码包含在你的项目中可能并不可行。因此,我们必须创建一个树外项目,并将 LLVM 作为库进行集成。本节展示了如何将 LLVM 集成到基于 CMake 的树外项目中,并充分利用我们在“探索 LLVM 重要 CMake 指令词汇表”部分学到的 LLVM 特定 CMake 指令。
摘要
本章深入探讨了 LLVM 的 CMake 构建系统。我们看到了如何使用 LLVM 自己的 CMake 指令来编写简洁有效的构建脚本,无论是树内还是树外开发。掌握这些 CMake 技能可以使你的 LLVM 开发更加高效,并为你提供更多与现有代码库或自定义逻辑交互 LLVM 功能的选择。
在下一章中,我们将介绍 LLVM 项目中另一个重要的基础设施,称为 LLVM LIT,这是一个易于使用且通用的框架,用于运行各种类型的测试。
第三章:第三章:使用 LLVM LIT 进行测试
在上一章中,我们学习了如何利用 LLVM 自己的 CMake 工具来提高我们的开发体验。我们还学习了如何无缝地将 LLVM 集成到其他树外项目中。在本章中,我们将讨论如何亲身体验 LLVM 自己的测试基础设施 LIT。
LIT是一个最初为运行 LLVM 回归测试而开发的测试基础设施。现在,它不仅是运行 LLVM 中所有测试的框架(包括单元和回归测试),而且还是一个可以在 LLVM 之外使用的通用测试框架。它还提供了一系列测试格式来应对不同的场景。本章将为您详细介绍该框架的组件,并帮助您掌握 LIT。
在本章中,我们将涵盖以下主题:
-
在树外项目中使用 LIT
-
了解高级 FileCheck 技巧
-
探索 TestSuite 框架
技术要求
LIT 的核心是用Python编写的,所以请确保您已安装 Python 2.7 或 Python 3.x(Python 3.x 更佳,因为 LLVM 现在正在逐步淘汰 Python 2.7)。
此外,还有一些支持工具,例如FileCheck
,稍后将使用。不幸的是,构建这些工具最快的方法是构建任何check-XXX
(伪)目标。例如,我们可以构建check-llvm-support
,如下面的代码所示:
$ ninja check-llvm-support
最后,最后一节要求已构建llvm-test-suite
,这是一个与llvm-project
分开的仓库。我们可以使用以下命令克隆它:
$ git clone https://github.com/llvm/llvm-test-suite
配置构建的最简单方法之一是使用缓存的 CMake 配置文件。例如,为了以优化(O3
)构建测试套件,我们将使用以下代码:
$ mkdir .O3_build
$ cd .O3_build
$ cmake -G Ninja -DCMAKE_C_COMPILER=<desired Clang binary \ path> -C ../cmake/caches/O3.cmake ../
然后,我们可以使用以下命令正常构建它:
$ ninja all
在树外项目中使用 LIT
编写树内 LLVM IR 回归测试非常简单:您只需在 IR 文件上添加测试指令即可。例如,看看以下脚本:
; RUN: opt < %s -instcombine -S -o - | FileCheck %s
target triple = "x86_64-unknown-linux"
define i32 @foo(i32 %c) {
entry:
; CHECK: [[RET:%.+]] = add nsw i32 %c, 3
; CHECK: ret i32 [[RET]]
%add1 = add nsw i32 %c, 1
%add2 = add nsw i32 %add1, 2
ret i32 %add2
}
此脚本检查InstCombine
(由前面片段中显示的-instcombine
命令行选项触发)是否将两个连续的算术加法简化为一个。将此文件放入llvm/test
下的任意文件夹后,当您执行llvm-lit
命令行工具时,脚本将自动被选中并作为回归测试的一部分运行。
尽管它很方便,但这几乎不能帮助您在树外项目中使用 LIT。在树外使用 LIT 特别有用,当您的项目需要一些端到端测试功能时,例如格式转换器、文本处理器、代码检查器和,当然,编译器。本节将向您展示如何将 LIT 带入您的树外项目,并提供 LIT 运行流程的完整概述。
为我们的示例项目做准备
在本节中,我们将使用树外 CMake 项目。此示例项目构建一个命令行工具,js-minifier
,它可以压缩任意 JavaScript 代码。我们将转换以下 JavaScript 代码:
const foo = (a, b) => {
let c = a + b;
console.log(`This is ${c}`);
}
这将被转换成尽可能短的语义等效代码:
const foo = (a,b) => {let c = a + b; console.log(`This is ${c}`);}
而不是教您如何编写这个 js-minifier
,本节的目标是向您展示如何创建一个 LIT 测试环境来测试这个工具。
此示例项目具有以下文件夹结构:
/JSMinifier
|__ CMakeLists.txt
|__ /src
|__ js-minifier.cpp
|__ /test
|__ test.js
|__ CMakeLists.txt
|__ /build
/src
文件夹下的文件包含 js-minifier
的源代码(我们在这里不会涉及)。我们将关注的是用于测试 js-minifier
的文件,这些文件位于 /test
文件夹下(目前只有一个文件,test.js
)。
在本节中,我们将设置一个测试环境,以便当我们运行 CMake /build
文件夹下的 llvm-lit
(本节的测试驱动和主要角色)时,它会打印测试结果,如下所示:
$ cd build
$ llvm-lit -sv .
-- Testing: 1 tests, 1 workers –
PASS: JSMinifier Test :: test.js (1 of 1)
Testing Time: 0.03s
Expected Passes : 1
这显示了通过了多少个以及哪些测试用例。
这是测试脚本,test.js
:
// RUN: %jsm %s -o - | FileCheck
// CHECK: const foo = (a,b) =>
// CHECK-SAME: {let c = a + b; console.log(`This is ${c}`);}
const foo = (a, b) => {
let c = a + b;
console.log(`This is ${c}`);
}
如您所见,这是一个简单的测试过程,它运行 js-minifier
工具(由 %jsm
指令表示,稍后将替换为 js-minifier
可执行文件的实际路径)并使用 FileCheck
的 CHECK
和 CHECK-SAME
指令检查运行结果。
通过这样,我们已经设置了我们的示例项目。在我们完成准备工作之前,我们还需要创建一个最终的工具。
由于我们试图减少对 LLVM 源树的依赖,我们将使用 PyPi 仓库(即 pip
命令行工具)中可用的 LIT
包重新创建 llvm-lit
命令行工具。您需要做的只是安装该包:
$ pip install --user lit
最后,用以下脚本包装该包:
#!/usr/bin/env python
from lit.main import main
if __name__ == '__main__':
main()
现在,我们可以使用 LIT 而不必构建 LLVM 树!接下来,我们将创建一些 LIT 配置脚本,这些脚本将驱动整个测试流程。
编写 LIT 配置
在本小节中,我们将向您展示如何编写 LIT 配置脚本。这些脚本描述了测试过程——文件将在哪里被测试,测试环境(如果我们需要导入任何工具,例如),失败时的策略等等。学习这些技能可以极大地提高您在 LLVM 树外使用 LIT 的效率。让我们开始吧:
-
在
/JSMinifier/test
文件夹内,创建一个名为lit.cfg.py
的文件,其中包含以下内容:import lit.formats config.name = 'JSMinifier Test' config.test_format = config variable here is a Python object that will be populated later when this script is loaded into LIT's runtime. It's basically a registry with predefined fields that carry configuration values, along with custom fields that can be added by lit.*.py scripts at any time.The `config.test_format` field suggests that LIT will run every test inside a shell environment (in the `ShTest` format), while the `config.suffixes` field suggests that only files with `.js` in their filename suffix will be treated as test cases (that is, all the JavaScript files).
-
在上一步的代码片段之后,LIT 现在需要另外两块信息:测试文件的根路径和工作目录:
… config.suffixes = ['.js'] config.test_source_root = os.path.dirname(__file__) config.test_exec_root = os.path.join(config.test_source_root, it's simply pointing to /JSMinifier/test. On the other hand, config.test_exec_root, which is the working directory, is pointing to a place whose parent folder is the value of a custom configuration field, my_obj_root. While it will be introduced later, simply put, it points to the build folder path. In other words, config.test_exec_root will eventually have a value of /JSMinifier/build/test.
-
我们在
test.js
中之前看到的%jsm
指令用作占位符,最终将被替换为js-minifier
可执行文件的实际/绝对路径。以下行将设置替换:… config.test_exec_root = os.path.join(config.my_obj_root, 'test') config.config.substitutions field, which makes LIT replace every %jsm occurrence in the test files with the /JSMinifier/build/js-minifier value. This wraps up all the content in lit.cfg.py.
-
现在,创建一个名为
lit.site.cfg.py.in
的新文件,并将其放置在/JSMinifier/test
文件夹下。该文件的前部分看起来如下:import os config.my_src_root = r'@CMAKE_SOURCE_DIR@' config.my_obj_root = r'@CMAKE_BINARY_DIR@'
这个神秘的
config.my_obj_root
字段最终在这里得到了解决,但它不是指向一个普通字符串,而是被分配了一个叫做@CMAKE_BINARY_DIR@
的奇怪值。同样,这将在稍后由 CMake 替换为真实路径。对于config.my_src_root
字段也是如此。 -
最后,
lit.site.cfg.py.in
由以下行封装:… lit_config.@ being resolved and copied into the build folder. From there, it will *call back* the lit.cfg.py script we saw in the earlier steps. This will be explained later in this section.
-
最后,是时候使用 CMake 的
configure_file
函数替换那些奇怪的 @-夹住的字符串为真实值了。在/JSMinifier/test/CMakeLists.txt
中,在文件中某处添加以下行:configure_file function will replace all the @-clamped string occurrences in the input file (lit.site.cfg.py.in, in this case) with their CMake variable counterparts in the current CMake context. For example, let's say there is a file called `demo.txt.in` that contains the following content:
name = "@FOO@"
age = @AGE@
Now, let's use `configure_file` in `CMakeLists.txt`:
set(FOO "John Smith")
set(AGE 87)
configure_file(demo.txt.in
demo.txt @ONLY)
Here, the aforementioned replacement will kick in and generate an output file, `demo.txt`, that contains the following content:
name = "John Smith"
age = 87
-
回到
lit.site.cfg.py.in
片段,由于CMAKE_SOURCE_DIR
和CMAKE_BINARY_DIR
总是可用的,它们分别指向根源文件夹和build
文件夹。生成的/JSMinifier/build/test/lit.site.cfg.py
将包含以下内容:import os config.my_src_root = r'/absolute/path/to/JSMinifier' config.my_obj_root = r'/absolute/path/to/JSMinifier/build' lit_config.load_config( config, os.path.join(config.my_src_root, 'test/ lit.cfg.py'))
通过这样,我们已经学会了如何为我们的示例项目编写 LIT 配置脚本。现在,是时候解释一些 LIT 内部工作细节以及为什么我们需要这么多文件(lit.cfg.py
、lit.site.cfg.py.in
和 lit.site.cfg.py
)了。
LIT 内部机制
让我们看看以下图表,它说明了在我们刚刚创建的演示项目中运行 LIT 测试的工作流程:
图 3.1 – LIT 在我们的示例项目中的分支流程
让我们更详细地看看这个图表:
-
lit.site.cfg.py.in
被复制到/JSMinifier/build/lit.site.cfg.py
,它携带一些 CMake 变量值。 -
llvm-lit
命令在/JSMinifier/build
内部启动。它将首先执行lit.site.cfg.py
。 -
lit.site.cfg.py
然后使用load_configure
Python 函数加载主 LIT 配置(lit.cfg.py
)并运行所有测试用例。
这张图表中最关键的部分是解释 lit.site.cfg.py
和 lit.site.cfg.py.in
的作用:许多参数,如 build
文件夹的绝对路径,将在 CMake 配置过程完成之前保持未知。因此,放置了一个 跳板 脚本——即 lit.site.cfg.py
——在 build
文件夹中,以将信息传递给真正的测试运行器。
在本节中,我们学习了如何为我们的树外示例项目编写 LIT 配置脚本。我们还学习了 LIT 在底层是如何工作的。了解这一点可以帮助你在除了 LLVM 以外的各种项目中使用 LIT。在下一节中,我们将重点关注 FileCheck
,这是一个关键且常用的 LIT 工具,它执行高级模式检查。
学习有用的 FileCheck 技巧
grep
命令行工具在 Unix/Linux 系统中可用,但提供了更强大且直观的基于行的上下文语法。此外,你可以在测试目标旁边放置 FileCheck
指令,这使得测试用例自包含且易于理解。
虽然基本的 FileCheck
语法很容易上手,但还有许多其他 FileCheck
功能真正释放了 FileCheck
的力量,并大大提高了你的测试体验——创建更简洁的测试脚本和解析更复杂的程序输出,仅举几例。本节将向你展示其中的一些技巧。
准备我们的示例项目
首先需要构建 FileCheck
命令行工具。类似于前面的章节,在 LLVM 树中构建一个 check-XXX
(伪)目标是这样做最容易的方法。以下是一个示例:
$ ninja check-llvm-support
在本节中,我们将使用一个虚构的命令行工具 js-obfuscator
,一个 JavaScript 混淆器,作为我们的示例。混淆是一种常用的技术,用于隐藏知识产权或实施安全保护。例如,我们可以在以下 JavaScript 代码上使用一个真实的 JavaScript 混淆器:
const onLoginPOST = (req, resp) => {
if(req.name == 'admin')
resp.send('OK');
else
resp.sendError(403);
}
myReset.post('/console', onLoginPOST);
这将转换成以下代码:
const t = "nikfmnsdzaO";
const aaa = (a, b) => {
if(a.z[0] == t[9] && a.z[1] == t[7] &&…)
b.f0(t[10] + t[2].toUpperCase());
else
b.f1(0x193);
}
G.f4(YYY, aaa);
此工具将尝试使原始脚本尽可能难以阅读。测试部分的挑战是在保留足够随机空间的同时验证其正确性。简单来说,js-obfuscator
将仅应用四种混淆规则:
-
仅混淆局部变量名,包括形式参数。形式参数名应始终以 <小写单词><参数索引号> 格式混淆。局部变量名将始终被混淆成小写和大写字母的组合。
-
如果我们使用箭头语法声明函数——例如,
let foo = (arg1, arg2) => {…}
——箭头和左花括号(=> {
)需要放在下一行。 -
将一个字面数字替换为相同值但不同表示形式;例如,将 87 替换为 0x57 或 87.000。
-
当你向工具提供
--shuffle-funcs
命令行选项时,会打乱顶层函数的声明/出现顺序。
最后,以下 JavaScript 代码是用于 js-obfuscator
工具的示例:
const square = x => x * x;
const cube = x => x * x * x;
const my_func1 = (input1, input2, input3) => {
// TODO: Check if the arrow and curly brace are in the second // line
// TODO: Check if local variable and parameter names are // obfuscated
let intermediate = square(input3);
let output = input1 + intermediate - input2;
return output;
}
const my_func2 = (factor1, factor2) => {
// TODO: Check if local variable and parameter names are // obfuscated
let term2 = cube(factor1);
// TODO: Check if literal numbers are obfuscated
return my_func1(94,
term2, factor2);
}
console.log(my_func2(1,2));
编写 FileCheck 指令
以下步骤将填充前面代码中出现的所有 TODO
注释:
-
根据行号,第一个任务是检查局部变量和参数是否被正确混淆。根据规范,形式参数有特殊的重命名规则(即,<小写单词><参数索引号>),因此使用正常的
CHECK
指令与 FileCheck 的正则表达式语法将是此处最合适的解决方案:// CHECK: my_func1 = ({{[a-z]+0}}, {{[a-z]+1}}, // {{[a-z]+2}}) const my_func1 = (input1, input2, input3) => { …
FileCheck 使用正则表达式的一个子集进行模式匹配,这些正则表达式被
{{…}}
或[[…]]
符号包围。我们将在稍后介绍后者。 -
这段代码看起来相当简单。然而,一旦执行了混淆,代码的语义也需要是正确的。所以,除了检查格式外,对那些参数的后续引用也需要重构,这就是 FileCheck 的模式绑定发挥作用的地方:
// CHECK: my_func1 = ([[A0:[a-z]+0]], // [[A1:[a-z]+1]], [[A2:[a-z]+2]]) const my_func1 = (input1, input2, input3) => { // CHECK: square(A0 ~ A2 using the [[…]] syntax, in which the binding variable name and the pattern are divided by a colon: [[<binding variable>:<pattern>]]. On the reference sites of the binding variable, the same [[…]] syntax is used, but without the pattern part.NoteA binding variable can have multiple definition sites. Its reference sites will read the last defined value.
-
我们不要忘记第二条规则——函数头部的箭头和左花括号需要放在第二行。为了实现“下一行”的概念,我们可以使用
CHECK-NEXT
指令:// CHECK: my_func1 = ([[A0:[a-z]+0]], // [[A1:[a-z]+1]], [[A2:[a-z]+2]]) const my_func1 = (input1, input2, input3) => { // CHECK directive, CHECK-NEXT will not only check if the pattern exists but also ensure that the pattern is in the line that follows the line matched by the previous directive.
-
接下来,在
my_func1
中检查所有局部变量和形式参数:// CHECK: my_func1 = ([[A0:[a-z]+0]], // [[A1:[a-z]+1]], [[A2:[a-z]+2]]) const my_func1 = (input1, input2, input3) => { // CHECK: let [[IM:[a-zA-Z]+]] = square([[A2]]); let intermediate = square(input3); // CHECK: let [[OUT:[a-zA-Z]+]] = // CHECK-SAME directive was used to match the succeeding pattern in the exact same line. The rationale behind this is that FileCheck expected different CHECK directives to be matched in different *lines*. So, let's say part of the snippet was written like this:
// CHECK: let [[OUT:[a-zA-Z]+]] =
// CHECK: [[A0]] + [[IM]] - [[A1]];
It will *only* match code that spread across two lines or more, as shown here:
let BGHr =
r0 + jkF + r1;
It will throw an error otherwise. This directive is especially useful if you wish to avoid writing a super long line of checking statements, thus making the testing scripts more concise and readable.
-
现在,进入
my_func2
,是时候检查数字是否被正确混淆了。这里的检查语句被设计为接受任何实例/模式除了原始数字。因此,CHECK-NOT
指令在这里就足够了:… // CHECK: return my_func1( // CHECK-NOT: 94 return my_func1(94, term2, factor2); … // CHECK: return my_func1 // CHECK-NOT: 94, // CHECK-SAME: {{0x5[eE]}} return my_func1(94, term2, factor2);
-
现在,只需要验证一条混淆规则:当
js-obfuscator
工具提供额外的命令行选项--shuffle-funcs
,它实际上会打乱所有顶级函数的顺序时,我们需要检查顶级函数在打乱顺序后是否保持了一定的顺序。在 JavaScript 中,函数在调用时会被解析。这意味着cube
、square
、my_func1
和my_func2
可以有一个任意的顺序,只要它们放在console.log(…)
语句之前。为了表达这种灵活性,CHECK-DAG
指令非常有用。相邻的
CHECK-DAG
指令将按任意顺序匹配文本。例如,假设我们有以下指令:// CHECK-DAG: 123 // CHECK-DAG: 456
这些指令将匹配以下内容:
123 456
它们也会匹配以下内容:
456 123
然而,这种排序的自由度在
CHECK
或CHECK-NOT
指令中都不会保持。例如,假设我们有以下指令:// CHECK-DAG: 123 // CHECK-DAG: 456 // CHECK: 789 // CHECK-DAG: abc // CHECK-DAG: def
这些指令将匹配以下文本:
456 123 789 def abc
然而,它们不会匹配以下文本:
456 789 123 def abc
-
回到我们的动机示例,可以通过以下代码检查混淆规则:
… // FileCheck provides a way to multiplex different *check suites* into a single file, where each suite can define how it runs and separates the checks from other suites.
-
FileCheck
中检查前缀的想法相当简单:你可以创建一个独立运行的检查套件,与其他套件分开。而不是使用CHECK
字符串,每个套件都会在前面提到的所有指令(包括CHECK-NOT
和CHECK-SAME
等)中替换成另一个字符串,包括CHECK
本身,以便区分同一文件中的其他套件。例如,你可以创建一个带有YOLO
前缀的套件,这样示例的这部分现在看起来如下所示:// --check-prefix command-line option. Here, the FileCheck command invocation will look like this:
$ cat test.out.js | FileCheck --check-prefix=YOLO test.js
-
最后,让我们回到我们的示例。最后一个混淆规则可以通过为那些
CHECK-DAG
指令使用一个替代前缀来解决:… // CHECK-SHUFFLE-DAG: const square = // CHECK-SHUFFLE-DAG: const cube = // CHECK-SHUFFLE-DAG: const my_func1 = // CHECK-SHUFFLE-DAG: const my_func2 = // CHECK-SHUFFLE: console.log console.log(my_func2(1,2));
这必须与默认的检查套件结合使用。本节中提到的所有检查都可以通过两个单独的命令运行,如下所示:
# Running the default check suite
$ js-obfuscator test.js | FileCheck test.js
# Running check suite for the function shuffling option
$ js-obfuscator --shuffle-funcs test.js | \
FileCheck --check-prefix=CHECK-SHUFFLE test.js
在本节中,我们通过我们的示例项目展示了某些高级且有用的 FileCheck
技巧。这些技巧为你提供了不同的方式来编写验证模式,并使你的 LIT 测试脚本更加简洁。
到目前为止,我们一直在讨论测试方法,该方法在类似 shell 的环境中运行测试(即在 ShTest
LIT 格式中)。在下一节中,我们将介绍一个替代的 LIT 框架——源自 llvm-test-suite
项目的 TestSuite 框架和测试格式——它为 LIT 提供了一种 不同类型 的有用测试方法。
探索 TestSuite 框架
在前面的章节中,我们学习了如何在 LLVM 中执行回归测试。更具体地说,我们看了 ShTest
测试格式(回忆一下 config.test_format = lit.formats.ShTest(…)
这一行),它基本上以 shell 脚本的方式运行端到端测试。ShTest
格式在验证结果方面提供了更多的灵活性,因为它可以使用我们在上一节中介绍的 FileCheck
工具,例如。
本节将要介绍另一种测试格式:llvm-test-suite
项目——为测试和基准测试 LLVM 而创建的测试套件和基准测试集合。类似于 ShTest
,这种 LIT 格式也是为了运行端到端测试而设计的。然而,TestSuite 的目标是让开发者在使用基于现有可执行文件的测试套件或基准测试代码库时更加方便。例如,如果你想将著名的 SPEC 基准测试 作为你的测试套件之一,你所需要做的只是添加一个构建描述和预期的纯文本输出。这在你的测试逻辑无法使用 文本测试脚本 表达时也很有用,正如我们在前面的章节中看到的。
在本节中,我们将学习如何将现有的测试套件或基准测试代码库导入到 llvm-test-suite
项目中。
为我们的示例项目做准备
首先,请按照本章开头提供的说明构建 llvm-test-suite
。
本节的其余部分将使用一个名为 GeoDistance
的伪测试套件项目。该项目使用 C++ 和 GNU Makefile
构建一个命令行工具 geo-distance
,该工具计算并打印出由输入文件提供的经纬度对列表构建的路径的总距离。
它应该具有以下文件夹结构:
GeoDistance
|___ helper.cpp
|___ main.cpp
|___ sample_input.txt
|___ Makefile
在这里,Makefile
的样子如下:
FLAGS := -DSMALL_INPUT -ffast-math
EXE := geo-distance
OBJS := helper.o main.o
%.o: %.cpp
$(CXX) $(FLAGS) -c $^
$(EXE): $(OBJS)
$(CXX) $(FLAGS) $< -o $@
要运行 geo-distance
命令行工具,请使用以下命令:
$ geo-distance ./sample_input.txt
这会在 stdout
上打印出浮点距离:
$ geo-distance ./sample_input.txt
94.873467
这里的浮点精度要求是 0.001
。
将代码导入到 llvm-test-suite
基本上,我们只需要做两件事就可以将现有的测试套件或基准测试导入到llvm-test-suite
中:
-
使用 CMake 作为构建系统
-
编写验证规则
要使用 CMake 作为构建系统,项目文件夹需要放在llvm-test-suite
源树中的MultiSource/Applications
子目录下。然后,我们需要相应地更新外部的CMakeLists.txt
文件:
# Inside MultiSource/Applications/CMakeLists.txt
…
add_subdirectory(GeoDistance)
要从我们的 GNU Makefile
迁移到CMakeLists.txt
,而不是使用内置的 CMake 指令(如add_executable
)重写它,LLVM 为您提供了一些方便的函数和宏:
# Inside MultiSource/Applications/GeoDistance/CMakeLists.txt
# (Unfinished)
llvm_multisource(geo-distance)
llvm_test_data(geo-distance sample_input.txt)
这里有一些新的 CMake 指令。llvm_multisource
及其兄弟指令llvm_singlesource
分别从多个源文件或单个源文件添加一个新的可执行文件构建目标。它们基本上是add_executable
,但如前所述的代码所示,您可以选择留空源文件列表,它将使用当前目录中显示的所有 C/C++源文件作为输入。
注意
如果有多个源文件但您使用的是llvm_singlesource
,每个源文件都将被视为一个独立的可执行文件。
llvm_test_data
将您希望在运行时使用的任何资源/数据文件复制到正确的工作目录。在这种情况下,是sample_input.txt
文件。
现在骨架已经设置好了,是时候使用以下代码配置编译标志了:
# Inside MultiSource/Applications/GeoDistance/CMakeLists.txt
# (Continue)
list(APPEND CPPFLAGS -DSMALL_INPUT)
list(APPEND CFLAGS -ffast-math)
llvm_multisource(geo-distance)
llvm_test_data(geo-distance sample_input.txt)
最后,TestSuite 需要知道如何运行测试以及如何验证结果:
# Inside MultiSource/Applications/GeoDistance/CMakeLists.txt
# (Continue)
…
set(RUN_OPTIONS sample_input.txt)
set(FP_TOLERANCE 0.001)
llvm_multisource(geo-distance)
…
RUN_OPTIONS
CMake 变量非常直观——它提供了测试可执行文件的命令行选项。
对于验证部分,默认情况下,TestSuite 将使用增强的 diff 来比较stdout
的输出和退出代码与以.reference_output
结尾的文件。
例如,在我们的案例中,创建了一个GeoDistance/geo-distance.reference_output
文件,其中包含预期的答案和退出状态代码:
94.873
exit 0
您可能会发现这里的预期答案与本节开头(94.873467
)的输出略有不同,这是因为比较工具允许您指定所需的浮点精度,这由之前显示的FP_TOLERANCE
CMake 变量控制。
在本节中,我们学习了如何利用llvm-test-suite
项目及其 TestSuite 框架来测试来自现有代码库或无法使用文本脚本表达测试逻辑的可执行文件。这将帮助您在使用 LIT 测试不同类型的项目中变得更加高效。
摘要
LIT 是一个通用测试框架,不仅可以用于 LLVM 内部,还可以轻松地用于任意项目。本章试图通过向你展示如何将 LIT 集成到树外项目中,甚至无需构建 LLVM 来证明这一点。其次,我们看到了 FileCheck——一个被许多 LIT 测试脚本使用的强大模式检查器。这些技能可以增强你测试脚本的表达能力。最后,我们向你介绍了 TestSuite 框架,它适用于测试不同类型的程序,并补充了默认的 LIT 测试格式。
在下一章中,我们将探讨 LLVM 项目中的另一个支持框架:TableGen。我们将向你展示 TableGen 也是一个 通用工具箱,可以解决树外项目的各种问题,尽管如今它几乎仅被用于 LLVM 的后端开发。
进一步阅读
目前,FileCheck 的源代码——用 C++ 编写——仍然位于 LLVM 的源树中。尝试使用 Python (github.com/mull-project/FileCheck.py
) 来复制其功能,这将有效地帮助你使用 FileCheck 而无需构建 LLVM,就像 LIT 一样!
第四章:第四章:TableGen 开发
TableGen 是一种最初在 低级虚拟机(LLVM)中开发的 领域特定语言(DSL),用于表达处理器的 指令集架构(ISA)和其他硬件特定细节,类似于 GNU 编译器集合(GCC)的 机器描述(MD)。因此,许多人在处理 LLVM 的后端开发时会学习 TableGen。然而,TableGen 不仅用于描述硬件规范:它是一种 通用 DSL,适用于任何涉及非平凡 静态和结构化数据 的任务。LLVM 也已经在后端之外的部分使用了 TableGen。例如,Clang 一直在使用 TableGen 来管理其命令行选项。社区中的人们也在探索在 TableGen 语法中实现 InstCombine 规则(LLVM 的 窥孔优化)的可能性。
尽管 TableGen 具有通用性,但该语言的核心语法从未被许多新开发者广泛理解,因此在 LLVM 的代码库中产生了大量的复制粘贴的样板 TableGen 代码,因为他们对语言本身不熟悉。本章试图为这种状况提供一些启示,并展示如何将这项惊人的技术应用于广泛的领域。
本章从介绍常见的和重要的 TableGen 语法开始,为你准备在 TableGen 中编写美味的甜甜圈配方作为实践,最终在第二部分展示 TableGen 的通用性。最后,本章将以一个教程结束,介绍如何开发自定义 发射器 或 TableGen 后端,将 TableGen 配方中的那些古怪句子转换为可以放入厨房的正常纯文本描述。
这里是我们将要涵盖的部分列表:
-
TableGen 语法介绍
-
在 TableGen 中编写甜甜圈配方
-
通过 TableGen 后端打印配方
技术要求
本章重点介绍 utils
文件夹中的一个工具:llvm-tblgen
。要构建它,请运行以下命令:
$ ninja llvm-tblgen
注意
如果你选择了在第一章中引入的 LLVM_OPTIMIZED_TABLEGEN
CMake 变量中构建 llvm-tblgen
,你可能想要更改这个设置,因为在这个章节中始终拥有 llvm-tblgen
的调试版本会更好。
本章中所有的源代码都可以在这个 GitHub 仓库中找到:github.com/PacktPublishing/LLVM-Techniques-Tips-and-Best-Practices-Clang-and-Middle-End-Libraries/tree/main/Chapter04
。
TableGen 语法介绍
这一节提供了一个对所有重要和常见 TableGen 语法的快速浏览,提供了所有必要的知识,以便在下一节中通过 TableGen 编写甜甜圈配方。
TableGen 是一种用于建模自定义数据布局的特定领域编程语言。尽管它是一种编程语言,但它所做的与传统的语言大不相同。传统编程语言通常描述对(输入)数据执行的操作、它们与环境如何交互以及它们如何生成结果,无论你采用哪种编程范式(命令式、函数式、事件驱动…)。相比之下,TableGen 几乎不描述任何操作。
TableGen 仅设计用于描述结构化的 静态数据。首先,开发者定义他们所需数据结构的布局——本质上只是一个包含许多字段的表格。然后,他们需要立即将这些数据填充到布局中,因为大多数字段都是填充/初始化的。后者可能是 TableGen 独特之处:许多编程语言或框架提供设计特定领域数据结构的方法(例如,Google 的 Protocol Buffers),但在那些场景中,数据通常是在消耗 DSL 部分的代码中 动态 填充的。
TABLE
; 在 TableGen 中则是 class
,这部分将在本节稍后介绍。然而,SQL 提供了比仅仅构建布局多得多的功能。它还可以动态地查询(实际上,这也是其名称的由来:结构化查询语言)和更新数据,这是 TableGen 所不具备的。然而,在本章的后面部分,你将看到 TableGen 提供了一个灵活处理和 解释 这些 TableGen 定义的框架。
我们现在将介绍四个重要的 TableGen 构造,如下所示:
-
布局和记录
-
破折号运算符
-
多类
-
有向无环图(DAG)数据类型
布局和记录
由于 TableGen 只是一种更花哨、更易于表达的方式来描述结构化数据,因此很容易想到存在一种原始的数据 class
语法表示,如下面的代码片段所示:
class Person {
string Name = "John Smith";
int Age;
}
如此所示,一个类类似于 C 语言和其他许多编程语言中的结构体,它只包含一组数据字段。每个字段都有一个类型,可以是任何原始类型(int
、string
、bit
等)或另一个用户定义的 class
类型。字段还可以分配一个默认值,例如 John Smith
。
在查看布局之后,是时候创建一个实例(或 TableGen 术语中的 记录)了,如下所示:
def john_smith : Person;
在这里,john_smith
是一个使用 Person
作为模板的记录,因此它也有两个字段——Name
和 Age
——其中 Name
字段填充了值 John Smith
。这看起来相当直接,但请记住 TableGen 应该定义静态数据,并且 大多数 字段应该填充值。此外,在这种情况下,Age
字段仍然未被初始化。你可以通过以下方式用括号闭合和其中的语句来 覆盖 它的值:
def john_smith : Person {
let Age = 87;
}
您甚至可以专门为john_smith
记录定义新字段,如下所示:
def john_smith : Person {
let Age = 87;
string Job = "Teacher";
}
请注意,您只能覆盖已声明的字段(使用let
关键字),就像在许多其他编程语言中一样。
Bang 操作符
Bang 操作符是一组执行基本任务(如基本算术或值转换)的函数。以下是一个将千克转换为克的简单示例:
class Weight<int kilogram> {
int Gram = !mul(kilogram, 1000);
}
常见操作符包括算术和位运算符(仅举几例),其中一些在此概述:
-
!add(a, b)
: 用于算术加法 -
!sub(a, b)
: 用于算术减法 -
!mul(a, b)
: 用于算术乘法 -
!and(a, b)
: 用于逻辑AND
运算 -
!or(a, b)
: 用于逻辑OR
运算 -
!xor(a, b)
: 用于逻辑XOR
运算
我们还使用条件操作符,这里概述了一些:
-
!ge(a, b)
: 如果a >= b
,则返回 1,否则返回 0 -
!gt(a, b)
: 如果a > b
,则返回 1,否则返回 0 -
!le(a, b)
: 如果a <= b
,则返回 1,否则返回 0 -
!lt(a, b)
: 如果a < b
,则返回 1,否则返回 0 -
!eq(a, b)
: 如果a == b
,则返回 1,否则返回 0
其他有趣的操作符包括以下内容:
-
!cast<type>(x)
: 此操作符根据type
参数对操作数x
进行类型转换。在类型是数值类型的情况下,例如int
或bits
,这会执行正常的算术类型转换。在某些特殊情况下,我们有以下场景:如果
type
是字符串且x
是记录,则返回记录的名称。如果
x
是字符串,它被视为记录的名称。TableGen 将查找迄今为止的所有记录定义,并返回具有x
名称的记录,其类型与type
参数匹配。 -
!if(pred, then, else)
: 如果pred
为 1,则此操作符返回then
表达式,否则返回else
表达式。 -
!cond(cond1 : val1, cond2 : val2, …, condN : valN)
: 这个操作符是!if
操作符的增强版本。它将连续评估cond1…condN
,直到其中一个表达式返回 1,然后返回其关联的val
表达式。注意
与函数不同,函数是在运行时评估的,而 Bang 操作符更像宏,它们是在构建时评估的——或者用 TableGen 的术语来说,当这些语法被 TableGen 后端处理时。
多类
在许多情况下,我们希望一次性定义多个记录。例如,以下代码片段尝试为多辆汽车创建自动部件记录:
class AutoPart<int quantity> {…}
def car1_fuel_tank : AutoPart<1>;
def car1_engine : AutoPart<1>;
def car1_wheels : AutoPart<4>;
…
def car2_fuel_tank : AutoPart<1>;
def car2_engine : AutoPart<1>;
def car2_wheels : AutoPart<4>;
…
我们可以通过使用multiclass
语法进一步简化这些操作,如下所示:
class AutoPart<int quantity> {…}
multiclass Car<int quantity> {
def _fuel_tank : AutoPart<quantity>;
def _engine : AutoPart<quantity>;
def _wheels : AutoPart<!mul(quantity, 4)>;
…
}
在创建记录实例时,使用defm
语法而不是def
,如下所示:
defm car1 : Car<1>;
defm car2 : Car<1>;
因此,最终它仍然会生成具有car1_fuel_tank
、car1_engine
、car2_fuel_tank
等名称的记录。
尽管其名称中包含 class
,但 multiclass
与类无关。multiclass
不是描述记录布局的,而是作为一个模板来 生成 记录。在 multiclass
模板内部是预期创建的记录以及模板展开后的记录名称 后缀。例如,前述片段中的 defm car1 : Car<1>
指令最终将被展开成三个 def
指令,如下所示:
-
def car1_fuel_tank : AutoPart<1>;
-
def car1_engine : AutoPart<1>;
-
def car1_wheels : AutoPart<!mul(1, 4)>;
如前所述的列表所示,我们在 multiclass
中找到的名称后缀(例如,_fuel_tank
)与 defm
后出现的名称连接在一起—在本例中是 car1
。此外,multiclass
的 quantity
模板参数也被实例化到每个展开的记录中。
简而言之,multiclass
尝试从多个记录实例中提取公共参数,并使其能够一次性创建它们。
DAG 数据类型
除了传统数据类型外,TableGen 还有一个相当独特的第一类类型:用于表示 DAG 实例的 dag
类型。要创建 DAG 实例,你可以使用以下语法:
(operator operand1, operand2,…, operandN)
虽然 operator
只能是记录实例,但操作数(operand1
…operandN
)可以具有任意类型。以下是一个尝试建模算术表达式 x * 2 + y + 8 * z
的示例:
class Variable {…}
class Operator {…}
class Expression<dag expr> {…}
// define variables
def x : Variable;
def y : Variable;
def z : Variable;
// define operators
def mul : Operator;
def plus : Operator;
// define expression
def tmp1 : Expression<(mul x, 2)>;
def tmp2 : Expression<(mul 8, z)>;
def result : Expression<(plus tmp1, tmp2, y)>;
可选地,你可以将 operator
和/或每个操作数与一个 tag 关联,如下所示:
…
def tmp1 : Expression<(mul:$op x, 2)>;
def tmp2 : Expression<(mul:$op 8, z)>;
def result : Expression<(plus tmp1:$term1, tmp2:$term2, y:$term3)>;
标签始终以美元符号 $
开头,后跟用户定义的标签名称。这些标签为每个 dag
组件提供了一种 逻辑 描述,并在 TableGen 后端处理 DAG 时可能很有用。
在本节中,我们介绍了 TableGen 语言的主体组件并介绍了一些基本语法。在下一节中,我们将亲自动手,使用 TableGen 编写美味的甜甜圈配方。
在 TableGen 中编写甜甜圈配方
在前几节的知识基础上,现在是时候编写我们自己的甜甜圈配方了!我们将按以下步骤进行:
-
首先要创建的文件是
Kitchen.td
。它定义了烹饪环境,包括测量单位、设备和程序等。我们将从测量单位开始,如下所示:class Unit { string Text; bit Imperial; }
在这里,
Text
字段是显示在配方上的文本格式,而Imperial
只是一个布尔标志,表示此单位是英制还是公制。每个重量或体积单位都将是一个继承自该类的记录—请查看以下代码片段以了解此例:def gram_unit : Unit { let Imperial = false; let Text = "g"; } def tbsp_unit : Unit { let Imperial = true; let Text = "tbsp"; }
我们想要创建许多测量单位,但代码已经相当长。一种简化并使其更易于阅读的方法是使用
class
模板参数,如下所示:class Unit<bit imperial, string text> { string Text = text; bit Imperial = imperial; } def gram_unit : Unit<false, "g">; def tbsp_unit : Unit<true, "tbsp">;
与 C++ 的模板参数不同,TableGen 中的模板参数仅接受具体值。它们只是为字段赋值的一种替代方式。
-
由于 TableGen 不支持浮点数,我们需要定义一种方法来表示编号,例如提到的
Integral
和DecimalPoint
字段,这个FixedPoint
类表示的值等于以下公式:*Integral * 10^(-DecimalPoint)*
由于 ¼、½ 和 ¾ 在测量中显然是常用的(尤其是对于像美国杯这样的英制单位),使用一个辅助类来创建它们可能是个好主意,如下所示:
class NplusQuarter<class is simply just integrating its fields.
-
要实现
NplusQuarter
,特别是将NplusQuarter
类模板参数转换为FixedPoint
的转换,我们需要进行一些简单的算术计算,这正是 TableGen 的感叹号运算符发挥作用的地方,如下所示:class NplusQuarter<num_quarter variable. By writing num_quarter{1…0}, this gives you a bits value that is equal to the 0th and first bit of num_quarter. There are some other variants of this technique. For example, it can slice a non-continuous range of bits, as follows:
num_quarter{8…6,4,2…0}
Or, it can extract bits in reversed ordering, as follows:
num_quarter{1…7}
NoteYou might wonder why the code needs to extract the smallest 2 bits *explicitly* even it has declared that `num_quarter` has a width of 2 bits (the `bits<2>` type). It turned out that for some reason, TableGen will not stop anyone from assigning values greater than `3` into `num_quarter`, like this: `def x : NplusQuarter<1,999>`.
-
使用测量单位和数字格式,我们最终可以处理这个食谱所需的成分。首先,让我们使用一个单独的文件,
Ingredients.td
,来存储所有成分记录。要使用前面提到的所有内容,我们可以使用include
语法导入Kitchen.td
,如下所示:// In Ingredients.td… include "Kitchen.td"
然后,创建一个所有成分的基类来携带一些常用字段,如下所示:
class IngredientBase<Unit unit> { IngredientBase, with parameters to specify the quantity needed by a recipe, and the unit used to measure this ingredient. Take milk, for example, as shown in the following code snippet:
class Milk<cup_unit put at the template argument for IngredientBase tells us that milk is measured by a US cup unit, and its quantity is to be determined later by the Milk class template arguments. When writing a recipe, each required ingredient is represented by a record created from one of these ingredient
classtypes:
def ingredient_milk : Milk<1,2>; // Need 1.5 cup of milk
-
一些成分总是同时出现——例如,柠檬皮和柠檬汁、蛋黄和蛋白。也就是说,如果你有两个蛋黄,那么就必须有两个蛋白的份量。然而,如果我们需要逐个创建记录并为每种成分分配数量,将会产生大量的重复代码。解决这个问题的更优雅的方法是使用 TableGen 的
multiclass
语法。以以下蛋黄为例,假设我们想一次性创建
WholeEgg
、EggWhite
和EggYolk
记录,并使用相同的数量,首先定义multiclass
:defm syntax to create multiclass records, as follows:
defm egg_ingredient : Egg<3>;
After using `defm`, three records will actually be created: `egg_ingredient_whole`, `egg_ingredient_yolk`, and `egg_ingredient_white`, inheriting from `WholeEgg`, `EggYolk`, and `EggWhite`, respectively.
-
最后,我们需要一种方法来描述制作甜甜圈的过程。许多食谱都有一些不需要按特定顺序完成的准备步骤。以这里的甜甜圈食谱为例:在甜甜圈准备好油炸之前,可以随时预热油。因此,用
dag
类型表达烘焙步骤可能是个好主意。让我们首先创建一个
class
来表示烘焙步骤,如下所示:class Step<Action field carries the baking instructions and information about the ingredients used. Here is an example:
def Action is just a class used for describing movements. The following snippet represents the fact that step_mixing2 is using the outcome from step_mixing (maybe a raw dough) and mixing it with butter:
… def step_mixing : Step<(mix milk, flour), …>; def step_mixing2 : Step<Step records will form a DAG, in which a vertex will either be a step or an ingredient record.We're also annotating our `dag` operator and operand with tags, as follows:
def step_mixing2 : Step<(mix:dag tags have no immediate effect in TableGen code, except affecting how TableGen backends handle the current record—for example, if we have a string type field, CustomFormat, in the Step class, as follows:
def step_prep : Step<(heat:$action, $oil, and $temp in the string with the textual representation of those records, generating a string such as *heat the peanut oil until it reaches 300 F*.
这样就结束了本章的这一部分。在下一节中,目标是开发一个自定义的 TableGen 后端,以这里作为输入的 TableGen 版本配方,并打印出正常的纯文本配方。
通过 TableGen 后端打印配方
在上一节的最后部分之后,在 TableGen 语法中组成甜甜圈配方后,就到了通过自定义构建的 TableGen 后端打印出一个正常配方的时候了。
注意
请不要将TableGen 后端与LLVM 后端混淆:前者将(或转换)TableGen 文件转换为任意文本内容,C/C++头文件是最常见的形式。另一方面,LLVM 后端将 LLVM中间表示(IR)转换为低级汇编代码。
在本节中,我们正在开发 TableGen 后端,将我们在上一节中组成的甜甜圈配方打印成内容,如下所示:
=======Ingredients=======
1\. oil 500 ml
2\. flour 300 g
3\. milk 1.25 cup
4\. whole egg 1
5\. yeast 1.50 tsp
6\. butter 3.50 tbsp
7\. sugar 2.0 tbsp
8\. salt 0.50 tsp
9\. vanilla extract 1.0 tsp
=======Instructions=======
1\. use deep fryer to heat oil until 160 C
2\. use mixer to mix flour, milk, whole egg, yeast, butter, sugar, salt, and vanilla extract. stir in low speed.
3\. use mixer to mix outcome from (step 2). stir in medium speed.
4\. use bowl to ferment outcome from (step 3).
5\. use rolling pin to flatten outcome from (step 4).
6\. use cutter to cut outcome from (step 5).
7\. use deep fryer to fry outcome from (step 1) and outcome from (step 6).
首先,我们将概述llvm-tblgen
,这是驱动 TableGen 翻译过程的程序。然后,我们将向您展示如何开发我们的配方打印 TableGen 后端。最后,我们将向您展示如何将我们的后端集成到llvm-tblgen
可执行文件中。
TableGen 的高级工作流程
TableGen 后端接受我们刚刚学到的 TableGen 代码的内存表示(以 C++对象的形式),并将其转换成任意的llvm-tblgen
可执行文件,其工作流程可以用这张图来表示:
图 4.1 – llvm-tblgen 的工作流程
TableGen 代码的内存表示(由 C++类型和 API 组成)在 TableGen 后端开发中起着重要作用。类似于 LLVM IR,它是层次化组织的。从顶层开始,以下是它的层次结构列表,其中每个项目都是一个 C++类:
-
RecordKeeper
:当前翻译单元中所有Record
对象的集合(和所有者)。 -
Record
:表示一个记录或一个class
。封装的字段由RecordVal
表示。如果它是一个class
,你也可以访问它的模板参数。 -
RecordVal
:表示记录字段及其初始化值的一个对,以及补充信息,如字段类型和源位置。 -
Init
:表示字段的初始化值。它是many
的父类,代表不同类型的初始化值——例如,IntInit
用于整数值和DagInit
用于 DAG 值。
为了让您在 TableGen 后端的实际方面有一个小任务,以下是它的框架:
class SampleEmitter {
RecordKeeper &Records;
public:
SampleEmitter(RecordKeeper &RK) : Records(RK) {}
void run(raw_ostream &OS);
};
这个发射器基本上将一个RecordKeeper
对象(由构造函数传入)作为输入,并将输出打印到raw_ostream
流中——SampleEmitter::run
函数的参数。
在下一节中,我们将向您展示如何设置开发环境并动手编写一个 TableGen 后端。
编写 TableGen 后端
在本节中,我们将向您展示如何编写后端以打印出用 TableGen 编写的食谱的步骤。让我们从设置开始。
项目设置
要开始,LLVM 已经为编写 TableGen 后端提供了一个骨架。因此,请将 llvm/lib/TableGen/TableGenBackendSkeleton.cpp
文件从 LLVM 项目的源树复制到 llvm/utils/TableGen
文件夹中,如下所示:
$ cd llvm
$ cp lib/TableGen/TableGenBackendSkeleton.cpp \
utils/TableGen/RecipePrinter.cpp
然后,将 SkeletonEmitter
类重构为 RecipePrinter
。
RecipePrinter
有以下工作流程:
-
收集所有烘焙步骤和成分记录。
-
使用单独的函数以文本格式打印单个成分,使用单独的函数以文本格式打印测量单位、温度、设备等。
-
线性化所有烘焙步骤的 DAG。
-
使用一个用于打印自定义格式的函数来打印每个线性化的烘焙步骤。
我们不会涵盖所有实现细节,因为许多后端代码实际上与 TableGen 并不直接相关(例如文本格式化和字符串处理)。因此,以下子节仅关注如何从 TableGen 的内存对象中检索信息。
获取所有烘焙步骤
在 TableGen 后端中,TableGen 记录由 Record
C++ 类表示。当我们想要检索从特定 TableGen class
派生的所有记录时,我们可以使用 RecordKeeper
的一个函数:getAllDerivedDefinitions
。例如,假设我们想要获取从本例中 Step
TableGen 类派生的所有烘焙步骤记录。以下是使用 getAllDerivedDefinitions
的方法:
// In RecipePrinter::run method…
std::vector<Record*> Steps = Records.getAllDerivedDefinitions("Step");
这为我们提供了一个表示所有 Step
记录的 Record
指针列表。
注意
在本节的其余部分,我们将使用(带有 Courier 字体外观)的 Record
格式来引用 TableGen 记录的 C++ 对应物。
检索字段值
从 Record
中检索字段值可能是最基本操作。假设我们正在编写一个用于打印之前引入的 Unit
记录对象的方法,如下所示:
void RecipePrinter::printUnit(raw_ostream& OS, Record* UnitRecord) {
OS << UnitRecord->getValueAsString("Text");
}
Record
类提供了一些方便的函数,例如 getValueAsString
,用于检索字段的值并尝试将其转换为特定类型,这样您就不需要在获取实际值之前检索特定字段(在这种情况下,是 Text
字段)的 RecordVal
值。类似函数包括以下内容:
-
Record* getValueAsDef(StringRef FieldName)
-
bool getValueAsBit(StringRef FieldName)
-
int64_t getValueAsInt(StringRef FieldName)
-
DagInit* getValueAsDag(StringRef FieldName)
除了这些实用函数之外,我们有时只想检查记录中是否存在特定字段。在这种情况下,调用 Record::getValue(StringRef FieldName)
并检查返回的值是否为 null。但请注意,并非每个字段都需要初始化;您可能仍然需要检查字段是否存在,但未初始化。当这种情况发生时,让 Record::isValueUnset
帮助您。
注意
TableGen 实际上使用一个特殊的Init
类,UnsetInit
来表示一个未初始化的值。
类型转换
Init
代表初始化值,但大多数时候我们并不是直接与它工作,而是与它的子类之一工作。
例如,StepOrIngredient
是一个代表Step
记录或成分记录的Init
类型对象。由于DefInit
提供了更丰富的功能,将其转换为底层的DefInit
对象会更容易。我们可以使用以下代码将Init
类型的StepOrIngredient
转换为DefInit
类型对象:
const auto* SIDef = cast<const DefInit>(StepOrIngredient);
您也可以使用isa<…>(…)
来首先检查其底层类型,或者如果您不希望在转换失败时接收异常,可以使用dyn_cast<…>(…)
。
Record
代表一个 TableGen 记录,但如果我们能找到它的父类,这将进一步告诉我们字段的信息会更好。
例如,在获取SIDef
的底层Record
对象之后,我们可以使用isSubClassOf
函数来判断该Record
是否是一个烘焙步骤或成分,如下所示:
Record* SIRecord = SIDef->getDef();
if (SIRecord->isSubClassOf("Step")) {
// This Record is a baking step!
} else if (SIRecord->isSubClassOf("IngredientBase")){
// This Record is an ingredient!
}
了解底层 TableGen 类实际上是什么可以帮助我们以它自己的方式打印出该记录。
处理 DAG 值
现在,我们将打印出Step
记录。回想一下,我们使用dag
类型来表示烘焙步骤的动作和所需的成分。看看下面的代码示例:
def step_prep : Step<(heat:$action fry_oil:$oil, oil_temp:$temp)> {
let CustomFormat = "$action $oil until $temp";
}
这里,高亮的dag
存储在Step
TableGen 类的Action
字段中。因此,我们使用getValueAsDag
来检索该字段作为DagInit
对象,如下所示:
DagInit* DAG = StepRecord->getValueAsDag("Action");
DagInit
是另一个从Init
派生出来的类,它是在之前引入的。它包含一些 DAG 特定的 API。例如,我们可以通过getArg
函数遍历所有的操作数并获取它们关联的Init
对象,如下所示:
for(i = 0; i < DAG->arg_size; ++i) {
Init* Arg = DAG->getArg(i);
}
此外,我们可以使用getArgNameStr
函数来检索令牌(如果有的话),在 TableGen 后端中,令牌总是以字符串类型表示,与特定的操作数相关联,如下面的代码片段所示:
for(i = 0; i < DAG->arg_size; ++i) {
StringRef ArgTok = DAG->getArgNameStr(i);
}
如果ArgTok
为空,这意味着没有与该操作数关联的令牌。要获取与操作符关联的令牌,我们可以使用getNameStr
API。
注意
DagInit::getArgNameStr
和DagInit::getNameStr
都返回不带前导美元符号的令牌字符串。
本节向您展示了使用 TableGen 指令内存中 C++表示的一些最重要的方面,这是编写 TableGen 后端的基本构建块。在下一节中,我们将向您展示将所有东西组合在一起并运行自定义 TableGen 后端的最后一步。
集成 RecipePrinter TableGen 后端
在完成utils/TableGen/RecipePrinter.cpp
文件后,是时候将所有东西组合在一起了。
如前所述,一个 TableGen 后端总是与 llvm-tblgen
工具相关联,这也是使用后端的唯一接口。llvm-tblgen
使用简单的命令行选项来选择要使用的后端。
这里是一个选择其中一个后端,IntrInfoEmitter
,从包含 X86
指令集信息的 TableGen
文件生成 C/C++ 头文件的例子:
$ llvm-tblgen -gen-instr-info /path/to/X86.td -o GenX86InstrInfo.inc
现在我们来看看如何将 RecipePrinter
源文件集成到 TableGen
后端:
-
要将
RecipePrinter
源文件链接到llvm-tblgen
并添加一个命令行选项来选择它,我们首先使用utils/TableGen/TableGenBackends.h
。此文件仅包含一个 TableGen 后端入口函数列表,这些函数接受一个raw_ostream
输出流和RecordKeeper
对象作为参数。我们还把我们的EmitRecipe
函数放入列表中,如下所示:… void EmitX86FoldTables(RecordKeeper &RK, raw_ostream &OS); void EmitRecipe(RecordKeeper &RK, raw_ostream &OS); void EmitRegisterBank(RecordKeeper &RK, raw_ostream &OS); …
-
接下来,在
llvm/utils/TableGen/TableGen.cpp
中,我们首先添加一个新的ActionType
枚举元素和选定的命令行选项,如下所示:enum Action Type { … GenRecipe, … } … cl::opt<ActionType> Action( cl::desc("Action to perform:"), cl::values( … clEnumValN(GenRecipe, "gen-recipe", "Print delicious recipes"), … ));
-
之后,转到
LLVMTableGenMain
函数并插入对EmitRecipe
函数的调用,如下所示:bool LLVMTableGenMain(raw_ostream &OS, RecordKeeper &Records) { switch (Action) { … case GenRecipe: EmitRecipe(Records, OS); break; } }
-
最后,别忘了更新
utils/TableGen/CMakeLists.txt
,如下所示:add_tablegen(llvm-tblgen LLVM … RecipePrinter.cpp …)
-
就这些了!你现在可以运行以下命令:
-o option.)The preceding command will print out a (mostly) normal donut recipe, just like this:
=成分=
1. 油 500 毫升
2. 面粉 300 克
3. 牛奶 1.25 杯
4. 全蛋 1 个
5. 酵母 1.50 茶匙
6. 黄油 3.50 汤匙
7. 糖 2.0 汤匙
8. 盐 0.50 茶匙
9. 香草提取物 1.0 茶匙
=说明=
1. 使用深 fryer 将油加热至 160 C
2. 使用搅拌器将面粉、牛奶、全蛋、酵母、黄油、糖、盐和香草提取物混合。以低速搅拌。
3. 使用搅拌器将步骤 2 的结果混合均匀,以中速搅拌。
4. 使用碗将步骤 3 的结果发酵。
5. 使用擀面杖将步骤 4 的结果擀平。
6. 使用刀将步骤 5 的结果切割。
7. 使用深 fryer 炸步骤 1 和步骤 6 的结果。
在本节中,我们学习了如何构建一个定制的 TableGen 后端,将用 TableGen 编写的配方转换为普通纯文本格式。我们学到的内容包括 llvm-tblgen
,TableGen 代码的驱动程序,是如何工作的;如何使用 TableGen 后端的 C++ API 操作 TableGen 指令的内存表示;以及如何将我们的自定义后端集成到 llvm-tblgen
中以运行它。结合本章和上一章中学到的技能,你可以创建一个完整且独立的工具链,实现你的自定义逻辑,使用 TableGen 作为解决方案。
摘要
在本章中,我们介绍了 TableGen,这是一种用于表达结构化数据的强大 DSL。我们展示了它在解决各种任务中的通用性,尽管它最初是为编译器开发而创建的。通过在 TableGen 中编写甜甜圈食谱的例子,我们学习了其核心语法。接下来的关于开发自定义 TableGen 后端的部分教了你如何使用 C++ API 与从源输入解析的内存中 TableGen 指令交互,这赋予了你创建一个完整且独立的 TableGen 工具链以实现你自己的自定义逻辑的能力。学习如何掌握 TableGen 不仅可以帮助你在 LLVM 相关项目中进行开发,还为你提供了更多在任意项目中解决结构化数据问题的选择。
本节标志着第一部分的结束——介绍了 LLVM 项目中各种有用的支持组件。从下一章开始,我们将进入 LLVM 的核心编译管道。我们将首先覆盖的第一个重要主题是 Clang,它是 LLVM 为 C 家族编程语言提供的官方前端。
进一步阅读
-
这个 LLVM 页面提供了关于 TableGen 语法的好参考:
llvm.org/docs/TableGen/ProgRef.html
-
这个 LLVM 页面提供了关于开发 TableGen 后端的好参考:
llvm.org/docs/TableGen/BackGuide.html
第二部分:前端开发
在本节中,你将了解与前端相关的话题,包括 Clang 及其工具(例如,语义推理)基础设施。我们将专门关注插件开发和如何编写自定义工具链。本节包括以下章节:
-
第五章, 探索 Clang 的架构
-
第六章, 扩展预处理器
-
第七章, 处理抽象语法树
-
第八章, 使用编译器标志和工具链
第五章:第五章:探索 Clang 的架构
Clang 是 LLVM 的官方前端,用于 C 家族 编程语言,包括 C、C++ 和 Objective-C。它处理输入源代码(例如解析、类型检查和语义推理等)并生成等效的 LLVM IR 代码,然后由其他 LLVM 子系统接管以执行优化和本地代码生成。许多 类似 C 的方言或语言扩展也发现 Clang 很容易托管它们的实现。例如,Clang 提供了对 OpenCL、OpenMP 和 CUDA C/C++ 的官方支持。除了正常的前端工作外,Clang 一直在发展,将其功能划分为库和模块,以便开发者可以使用它们创建各种与 源代码处理 相关的工具;例如,代码重构、代码格式化和语法高亮。学习 Clang 开发不仅可以让你更深入地参与到 LLVM 项目中,还可以为创建强大的应用程序和工具开辟广泛的可能性。
与将大多数任务安排在单个管道(即 PassManager)中并按顺序运行的 LLVM 不同,Clang 组织其子组件的方式更加多样化。在本章中,我们将向您展示一个清晰的图像,说明 Clang 的重要子系统是如何组织的,它们的作用是什么,以及你应该查找代码库的哪个部分。
术语
从本章开始到本书的其余部分,我们将使用 Clang(以大写 C 开头,并使用 Minion Pro 字体)来指代整个 项目 和其 技术。当我们使用 clang
(全部小写,并使用 Courier 字体)时,我们指的是 可执行程序。
在本章中,我们将涵盖以下主要主题:
-
学习 Clang 的子系统及其角色
-
探索 Clang 的工具功能和扩展选项
到本章结束时,你将拥有这个系统的路线图,以便你可以启动自己的项目,并为后续章节中关于 Clang 开发的相关内容奠定基础。
技术要求
在 第一章 中,构建 LLVM 时的资源节约,我们向您展示了如何构建 LLVM。然而,这些说明并没有构建 Clang。要包括 Clang 在构建列表中,请编辑分配给 LLVM_ENABLE_PROJECTS
CMake 变量的值,如下所示:
$ cmake -G Ninja -DLLVM_ENABLE_PROJECTS="clang;clang-tools-extra" …
该变量的值应该是一个分号分隔的列表,其中每个项目都是 LLVM 的子项目之一。在这种情况下,我们包括 Clang 和 clang-tools-extra
,它包含基于 Clang 技术的一组有用工具。例如,clang-format
工具被无数开源项目使用,特别是大型项目,以在其代码库中强制实施统一的编码风格。
将 Clang 添加到现有构建中
如果您已经有一个 Clang 未启用的 LLVM 构建,您可以在不再次调用原始 CMake 命令的情况下编辑 CMakeCache.txt
中的 LLVM_ENABLE_PROJECTS
CMake 参数的值。编辑文件后,CMake 应该会重新配置自己,然后再次运行 Ninja(或您选择的构建系统)。
您可以使用以下命令构建 clang
、Clang 的驱动程序和主程序:
$ ninja clang
您可以使用以下命令运行所有 Clang 测试:
$ ninja check-clang
现在,您应该在 /<您的构建目录>/bin
文件夹中拥有 clang
可执行文件。
学习 Clang 的子系统及其作用
在本节中,我们将为您概述 Clang 的结构和组织。在本书的后续部分,我们将通过专门的章节或章节进一步介绍一些重要的组件或子系统。我们希望这能给您一些关于 Clang 内部结构和它们如何对您的开发有益的想法。
首先,让我们看看整体情况。以下图表显示了 Clang 的高级结构:
图 5.1 – Clang 的高级结构
如图例所述,圆角矩形代表可能由具有相似功能的多个组件组成的子系统。例如,前端 可以进一步细分为预处理器、解析器和代码生成逻辑等组件。此外,还有中间结果,如图中所示为椭圆形。我们特别关注其中的两个 – Clang AST 和 LLVM IR。前者将在 第七章 中深入讨论,处理 AST,而后者是 第三部分,中间端开发 的主角,将讨论可以应用于 LLVM IR 的优化和分析。
让我们从查看驱动程序的概述开始。以下小节将简要介绍这些驱动程序组件的每个组件。
驱动程序
一个常见的误解是 clang
可执行文件是编译器前端。虽然 clang
确实使用了 Clang 的前端组件,但可执行文件本身实际上是一种称为 编译器驱动程序 或 驱动程序 的程序。
编译源代码是一个复杂的过程。首先,它包括多个阶段,如下所示:
-
前端:解析和语义检查
-
中间端:程序分析和优化
-
后端:本地代码生成
-
汇编:运行汇编器
-
链接:运行链接器
在这些阶段及其包含的组件中,有无数选项/参数和标志,例如告诉编译器在哪里搜索包含文件的选项(即 GCC 和 Clang 中的 -I
命令行选项)。此外,我们希望编译器能够确定这些选项中的某些值。例如,如果编译器能够默认将一些 C/C++ 标准库文件夹(例如 Linux 系统中的 /include
和 /usr/include
)包含在头文件搜索路径中,那就太好了,这样我们就不需要在命令行中手动指定每个文件夹。继续这个例子,很明显,我们希望我们的编译器能够在不同的操作系统和平台上通用,但许多操作系统使用不同的 C/C++ 标准库路径。那么,编译器是如何相应地选择正确的路径的呢?
在这种情况下,一个驱动程序被设计出来以提供帮助。它是一段软件,充当核心编译组件的管家,为他们提供必要的信息(例如,我们之前提到的特定于操作系统的系统包含路径)并安排它们的执行,以便用户只需提供重要的命令行参数。观察驱动程序辛勤工作的一个好方法是使用 clang
调用中的 -###
命令行标志。例如,你可以尝试使用该标志编译一个简单的 hello world 程序:
$ clang++ -### -std=c++11 -Wall ./hello_world.cpp -o hello_world
以下是在 macOS 计算机上运行先前命令后的输出的一部分:
"/path/to/clang" "-cc1" "-triple" "x86_64-apple-macosx11.0.0" "-Wdeprecated-objc-isa-usage" "-Werror=deprecated-objc-isa-usage" "-Werror=implicit-function-declaration" "-emit-obj" "-mrelax-all" "-disable-free" "-disable-llvm-verifier" … "-fno-strict-return" "-masm-verbose" "-munwind-tables" "-target-sdk-version=11.0" … "-resource-dir" "/Library/Developer/CommandLineTools/usr/lib/clang/12.0.0" "-isysroot" "/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk" "-I/usr/local/include" "-stdlib=libc++" … "-Wall" "-Wno-reorder-init-list" "-Wno-implicit-int-float-conversion" "-Wno-c99-designator" … "-std=c++11" "-fdeprecated-macro" "-fdebug-compilation-dir" "/Users/Rem" "-ferror-limit" "19" "-fmessage-length" "87" "-stack-protector" "1" "-fstack-check" "-mdarwin-stkchk-strong-link" … "-fexceptions" … "-fdiagnostics-show-option" "-fcolor-diagnostics" "-o" "/path/to/temp/hello_world-dEadBeEf.o" "-x" "c++" "hello_world.cpp"…
这些实际上是驱动程序翻译后传递给真实 Clang 前端的标志。虽然你不需要理解所有这些标志,但确实,即使是简单的程序,编译流程也包含大量的编译器选项和许多子组件。
驱动程序的源代码可以在 clang/lib/Driver
下找到。在第八章 与编译器标志和工具链一起工作中,我们将更详细地探讨这一点。
前端
一本典型的编译器教科书可能会告诉你,编译器前端由一个词法分析器和一个解析器组成,它们生成一个抽象语法树(AST)。Clang 的前端也使用这个框架,但有一些主要区别。首先,词法分析器通常与预处理器结合使用,对源代码进行的语义分析被分离到一个单独的子系统,称为Sema。这构建了一个 AST 并执行各种语义检查。
词法分析和预处理器
由于编程语言标准的复杂性和现实世界源代码的规模,预处理变得非同寻常。例如,当你有 10+层的头文件层次结构时,解决包含的文件变得复杂,这在大型项目中很常见。在 OpenMP 使用#pragma
并行化 for 循环的情况下,高级指令如#pragma
可能会受到挑战。解决这些挑战需要预处理程序和词法分析器之间的紧密合作,它们为所有预处理动作提供原语。它们的源代码可以在clang/lib/Lex
下找到。在第六章 扩展预处理程序中,你将熟悉预处理程序和词法分析器开发,并学习如何使用强大的扩展系统实现自定义逻辑。
解析器和 Sema
Clang 的解析器从预处理程序和词法分析器消耗标记流,并试图实现它们的语义结构。在这里,Sema 子系统在生成 AST 之前,从解析器的结果中进行更多的语义检查和分析。历史上,还有一个抽象层,你可以创建自己的解析器动作回调,以指定在解析某些语言指令(例如,变量名等标识符)时想要执行的操作。
在那时,Sema 是这些解析器动作之一。然而,后来人们发现这一额外的抽象层并不是必要的,所以解析器现在只与 Sema 交互。尽管如此,Sema 仍然保留了这种回调式设计。例如,当解析到 for 循环结构时,会调用clang::Sema::ActOnForStmt(…)
函数(在clang/lib/Sema/SemaStmt.cpp
中定义)。然后它会进行各种检查以确保语法正确,并为 for 循环生成 AST 节点;即,一个ForStmt
对象。
AST
AST(抽象语法树)是当你想要用自定义逻辑扩展 Clang 时最重要的基本元素。我们将要介绍的所有的常见 Clang 扩展/插件都是基于 AST 操作的。为了体验 AST,你可以使用以下命令从源代码中打印出 AST:
$ clang -Xclang -ast-dump -fsyntax-only foo.c
例如,在我的电脑上,我使用了以下简单的代码,它只包含一个函数:
int foo(int c) { return c + 1; }
这将产生以下输出:
TranslationUnitDecl 0x560f3929f5a8 <<invalid sloc>> <invalid sloc>
|…
`-FunctionDecl 0x560f392e1350 <./test.c:2:1, col:30> col:5 foo 'int (int)'
|-ParmVarDecl 0x560f392e1280 <col:9, col:13> col:13 used c 'int'
`-CompoundStmt 0x560f392e14c8 <col:16, col:30>
`-ReturnStmt 0x560f392e14b8 <col:17, col:28>
`-BinaryOperator 0x560f392e1498 <col:24, col:28> 'int' '+'
|-ImplicitCastExpr 0x560f392e1480 <col:24> 'int' <LValueToRValue>
| `-DeclRefExpr 0x560f392e1440 <col:24> 'int' lvalue ParmVar 0x560f392e1280 'c' 'int'
`-IntegerLiteral 0x560f392e1460 <col:28> 'int' 1
这个命令非常有用,因为它告诉你代表某些语言指令的 C++ AST 类,这对于编写 AST 回调——许多 Clang 插件的核心至关重要。例如,从前面的行中,我们可以知道变量引用位置(在c + 1
表达式中的c
)由DeclRefExpr
类表示。
与解析器的组织方式类似,你可以注册不同类型的ASTConsumer
实例来访问或操作 AST。CodeGen,我们将在稍后介绍,是其中之一。在第七章 处理 AST中,我们将向您展示如何使用插件实现自定义 AST 处理逻辑。
CodeGen
虽然没有关于如何处理 AST(例如,如果你使用前面显示的-ast-dump
命令行选项,前端将打印文本 AST 表示)的规定,但 CodeGen 子系统执行的最常见任务是生成 LLVM IR 代码,该代码随后将被 LLVM 编译成本地汇编或目标代码。
LLVM、汇编器和链接器
一旦代码生成子系统生成了 LLVM IR 代码,它将被 LLVM 编译管道处理以生成本地代码,无论是汇编代码还是目标代码。LLVM 提供了一个名为MC 层的框架,其中架构可以选择实现直接集成到 LLVM 管道中的汇编器。主要架构如 x86 和 ARM 都采用这种方法。如果你不这样做,LLVM 管道末尾生成的任何文本汇编代码都需要由驱动程序调用的外部汇编程序处理。
尽管 LLVM 已经拥有自己的链接器,即被称为LLD项目,但一个集成链接器仍然不是一个成熟的选项。因此,外部链接器程序总是由驱动程序调用以链接目标文件并生成最终的二进制工件。
外部与集成
使用外部汇编器或链接器意味着调用一个独立进程来运行程序。例如,要运行外部汇编器,前端需要将汇编代码放入一个临时文件中,然后使用该文件路径作为其命令行参数之一来启动汇编器。另一方面,使用集成汇编器/链接器意味着汇编或链接的功能被打包到库中,而不是可执行文件。因此,在编译管道的末尾,LLVM 将调用 API 来处理汇编代码的内存中实例以生成目标代码。这种集成方法的优点当然是节省许多间接操作(写入临时文件并立即读取)。这在一定程度上也使代码更加简洁。
有了这些,你已经对正常的编译流程有了概述,从源代码到本地代码。在下一节中,我们将超越clang
可执行文件,并提供 Clang 提供的工具和扩展选项的概述。这不仅增强了clang
的功能,还提供了一种在树外项目中使用 Clang 惊人技术的方法。
探索 Clang 的工具功能和扩展选项
Clang 项目不仅包含 clang
可执行文件。它还为开发者提供了扩展其工具的接口,以及将其功能作为库导出的接口。在本节中,我们将为您概述所有这些选项。其中一些将在后面的章节中介绍。
目前在 Clang 中有三种工具和扩展选项可用:clang::FrontendAction
类。
FrontendAction
类
在 学习 Clang 的子系统及其角色 部分,我们探讨了 Clang 的各种前端组件,例如预处理器和 Sema,仅举几个例子。许多这些重要的组件都被一个单一的数据类型封装,称为 FrontendAction
。一个 FrontendAction
实例可以被视为在前端运行的单个任务。它提供了一个统一的接口,以便任务可以消费和与各种资源进行交互,例如输入源文件和 AST,从这个角度来看,它类似于 LLVM Pass 的角色(LLVM Pass 提供了一个统一的接口来处理 LLVM IR)。然而,与 LLVM Pass 也有一些显著的不同:
-
并非所有前端组件都被封装到
FrontendAction
中,例如解析器和 Sema。它们是独立的组件,为其他 FrontendAction 运行生成材料(例如,AST)。 -
除了少数场景(Clang 插件就是其中之一)外,Clang 编译实例很少运行多个 FrontendAction。通常情况下,只有一个
FrontendAction
将被执行。
一般而言,一个 FrontendAction
描述了在前端一个或两个重要位置要执行的任务。这也解释了为什么它对于工具或扩展开发如此重要——我们基本上是将我们的逻辑构建到一个 FrontendAction
(更精确地说,是 FrontendAction
的一个派生类)实例中,以控制和定制正常 Clang 编译的行为。
为了让你对 FrontendAction
模块有一个感觉,这里列出了一些它的重要 API:
-
FrontendAction::BeginSourceFileAction(…)/EndSourceFileAction(…)
: 这些是派生类可以覆盖的回调,分别在处理源文件之前和之后执行操作。 -
FrontendAction::ExecuteAction(…)
: 这个回调描述了为这个FrontendAction
需要执行的主要操作。请注意,虽然没有人阻止你直接覆盖这个方法,但许多FrontendAction
的派生类已经提供了更简单的接口来描述一些常见任务。例如,如果你想处理一个 AST,你应该从ASTFrontendAction
继承并利用其基础设施。 -
FrontendAction::CreateASTConsumer(…)
: 这是一个工厂函数,用于创建ASTConsumer
实例,它是一组回调,当前端遍历 AST 的不同部分时会被调用(例如,当前端遇到一组声明时会被调用的回调)。请注意,尽管大多数 FrontendAction 的工作是在 AST 生成之后,但 AST 可能根本不会生成。这可能发生在用户只想运行预处理器的情况下(例如,使用 Clang 的-E
命令行选项来转储预处理器的内容)。因此,你不必总是实现你自定义的FrontendAction
中的这个函数。
再次强调,通常你不会直接从 FrontendAction
派生你的类,但了解 FrontendAction 在 Clang 中的内部角色及其接口,可以在进行工具或插件开发时为你提供更多的材料。
Clang 插件
Clang 插件允许你动态注册一个新的 FrontendAction
(更具体地说,是一个 ASTFrontendAction
),它可以在 clang
的主要动作之前、之后,甚至替换主要动作来处理 AST。一个现实世界的例子是将 virtual
关键字放置在应该为虚拟的方法上。
可以使用简单的命令行选项将插件轻松加载到普通的 clang
中:
$ clang -fplugin=/path/to/MyPlugin.so … foo.cpp
如果你想要自定义编译但没有控制 clang
可执行文件(即你不能使用修改过的 clang
版本),这非常有用。此外,使用 Clang 插件允许你更紧密地集成到构建系统中;例如,如果你想在源文件或任意构建依赖项被修改后重新运行你的逻辑。由于 Clang 插件仍然使用 clang
作为驱动程序,而现代构建系统在解析常规编译命令依赖项方面相当出色,这可以通过对编译标志进行一些调整来实现。
然而,使用 Clang 插件的最大缺点是其 clang
可执行文件,但这仅当你的插件使用了 C++ API(以及 ABI)并且 clang
可执行文件与之匹配时。不幸的是,目前 Clang(以及整个 LLVM 项目)没有意向使其任何 C++ API 稳定。换句话说,为了走最安全的路线,你需要确保你的插件和 clang
都使用完全相同的(主要)版本的 LLVM。这个问题使得 Clang 插件很难独立发布。
我们将在 第七章 中更详细地探讨这个问题,处理 AST。
LibTooling 和 Clang Tools
clang
可执行文件。此外,API 被设计得更加高级,这样你就不需要处理许多 Clang 的内部细节,使其对非 Clang 开发者更加友好。
语言服务器是 libTooling 最著名的用例之一。语言服务器作为一个守护进程启动,并接受来自编辑器或 IDE 的请求。这些请求可能非常简单,如检查代码片段的语法,或者非常复杂,如代码补全。虽然语言服务器不需要像普通编译器那样将传入的源代码编译成本地代码,但它需要一种方式来解析和分析该代码,这从头开始构建是非平凡的。libTooling 通过采用 Clang 的技术并为语言服务器开发者提供一个更简单的接口,避免了在这种情况下需要“重新造轮子”的需求。
为了让您更具体地了解 libTooling 与 Clang 插件的区别,这里有一个(简化的)代码片段,用于执行一个名为MyCustomAction
的自定义ASTFrontendAction
:
int main(int argc, char** argv) {
CommonOptionsParser OptionsParser(argc, argv,…);
ClangTool Tool(OptionsParser.getCompilations(), {"foo.cpp"});
return Tool.run(newFrontendActionFactory<MyCustomAction>(). get());
}
如前述代码所示,你不能随意将此代码嵌入到任何代码库中。libTooling 还提供了许多有用的工具,例如CommonOptionsParser
,它可以解析文本命令行选项并将它们转换为 Clang 选项。
libTooling 的 API 稳定性
不幸的是,libTooling 也没有提供稳定的 C++ API。然而,这并不是问题,因为你完全控制着使用的 LLVM 版本。
最后但同样重要的是,clang-refactor
用于重构代码。这包括重命名变量,如下面的代码所示:
// In foo.cpp…
struct Location {
float Lat, Lng;
};
float foo(Location *loc) {
auto Lat = loc->Lat + 1.0;
return Lat;
}
如果我们想要重命名Location
结构体中的Lat
成员变量Latitude
,我们可以使用以下命令:
$ clang-refactor --selection="foo.cpp:1:1-10:2" \
--old-qualified-name="Location::Lat" \
--new-qualified-name="Location::Latitude" \
foo.cpp
构建 clang-refactor
请务必遵循本章开头的说明,将clang-tools-extra
包含在LLVM_ENABLE_PROJECTS
CMake 变量的列表中。通过这样做,您将能够使用ninja clang-refactor
命令构建clang-refactor
。
你将得到以下输出:
// In foo.cpp…
struct Location {
float Latitude, Lng;
};
float foo(Location *loc) {
auto Lat = loc->Latitude + 1.0;
return Lat;
}
这是由 libTooling 内部构建的重构框架完成的;clang-refactor
仅仅为其提供了一个命令行接口。
摘要
在本章中,我们探讨了 Clang 的组织结构以及一些重要子系统组件的功能。然后,我们了解了 Clang 的主要扩展和工具选项之间的区别——Clang 插件、libTooling 和 Clang Tools——包括它们的外观以及它们的优缺点。Clang 插件通过动态加载的插件提供了一种简单的方法将自定义逻辑插入到 Clang 的编译管道中,但存在 API 稳定性问题;libTooling 与 Clang 插件的关注点不同,它旨在为开发者提供一个工具箱以创建独立工具;Clang Tools 提供了各种应用。
在下一章中,我们将讨论预处理器开发。我们将学习预处理器和词法分析器在 Clang 中的工作方式,并展示如何编写插件以定制预处理逻辑。
进一步阅读
-
这里是 Chromium 的 Clang 插件执行的检查列表:
chromium.googlesource.com/chromium/src/tools/clang/+/refs/heads/master/plugins/FindBadConstructsAction.h
. -
你可以在这里了解更多关于选择正确的 Clang 扩展接口的信息:
clang.llvm.org/docs/Tooling.html
. -
LLVM 还有一个基于 libTooling 的自己的语言服务器,称为
clangd
:clangd.llvm.org
.
第六章:第六章:扩展预处理器
在上一章中,我们探讨了 Clang 的结构——C 家族语言的官方前端 低级虚拟机 (LLVM),以及其中一些最重要的组件。我们还介绍了 Clang 的各种工具和扩展选项。在这一章中,我们将深入 Clang 前端管道的第一个阶段:预处理器。
对于 C 家族编程语言,#
) 字符——例如 #include
和 #define
——与一些其他文本内容(或某些罕见情况下的非文本 令牌)。例如,预处理器基本上会 复制和粘贴 由 #include
指令指定的头文件的内容,在解析它之前将其放入当前编译单元中。这种技术的好处是提取常用代码并重用它。
在本章中,我们将简要解释 Clang 的 #pragma
语法——例如 OpenMP 中使用的语法(例如 #pragma omp loop
)——以更简单的方式。学习这些技术将在解决不同抽象层次的问题时为您提供更多选项。以下是本章各节的内容列表:
-
与
SourceLocation
和SourceManager
一起工作 -
学习预处理器和词法分析器的基础知识
-
开发自定义预处理器插件和回调
技术要求
本章要求您有一个 Clang 可执行文件的构建。您可以通过运行以下命令来获取:
$ ninja clang
这是一个有用的命令,可以在预处理后打印文本内容:
clang
的 -E
命令行选项在打印预处理后的文本内容方面非常有用。例如,foo.c
包含以下内容:
#define HELLO 4
int foo(int x) {
return x + HELLO;
}
使用以下命令:
$ clang -E foo.c
上述命令将给出以下输出:
…
int foo(int x) {
return x + 4;
}
如您所见,代码中的 HELLO
被替换为 4
。您可能可以使用这个技巧在后续章节开发自定义扩展时进行调试。
本章使用的代码可以在以下链接找到:github.com/PacktPublishing/LLVM-Techniques-Tips-and-Best-Practices-Clang-and-Middle-End-Libraries/tree/main/Chapter06
。
与 SourceLocation 和 SourceManager 一起工作
当与源文件紧密工作时,最基本的问题之一是编译器前端如何能够 定位 文件中的一段字符串。一方面,打印格式消息(例如编译错误和警告消息)是一项至关重要的工作,其中必须显示准确的行和列号。另一方面,前端可能需要同时管理多个文件,并以高效的方式访问它们的内存内容。在 Clang 中,这些问题主要是由两个类 SourceLocation
和 SourceManager
处理的。我们将简要介绍它们,并在本节余下的部分展示如何在实践中使用它们。
介绍 SourceLocation
SourceLocation
类用于表示代码片段在其文件中的位置。在其实施方面,SourceLocation
实例在 Clang 的代码库中被广泛使用,并且基本上贯穿整个前端编译管道。因此,使用简洁的方式存储其信息而不是两个 32 位整数(这甚至可能不够,因为我们还想知道原始文件!)是很重要的,这可以轻易地增加 Clang 的运行时内存占用。
Clang 通过优雅设计的 SourceLocation
解决了这个问题,因为 SourceLocation
只在底层使用单个无符号整数,这也意味着其实例仅仅是 SourceLocation
的指针,它只有在与我们所提到的 数据缓冲区 放在一起时才有意义和有用,该缓冲区由故事中的第二位主要角色 SourceManager
管理。
其他有用的工具
SourceRange
是一对表示源代码范围起始和结束的 SourceLocation
对象;FullSourceLocation
将正常的 SourceLocation
类及其关联的 SourceManager
类封装到一个类中,这样你只需要携带一个 FullSourceLocation
实例而不是两个对象(一个 SourceLocation
对象和一个 SourceManager
对象)。
简单可复制
我们通常被教导,除非有充分的理由,否则在编写 C++ 时,你应该避免在正常情况下通过值传递对象(例如作为函数调用参数)。因为这涉及到底层数据成员的大量 复制,你应该通过指针或引用来传递。然而,如果设计得很好,一个类类型实例可以轻松地来回复制,例如,没有成员变量或只有少量成员变量的类,加上默认的复制构造函数。如果一个实例是简单可复制的,你被鼓励通过其值来传递它。
介绍 SourceManager
SourceManager
类管理存储在内存中的所有源文件,并提供访问它们的接口。它还提供了通过我们刚刚介绍的 SourceLocation
实例处理源代码位置的 API。例如,要从 SourceLocation
实例获取行号和列号,请运行以下代码:
void foo(SourceManager &SM, SourceLocation SLoc) {
auto Line = SM.getSpellingLineNumber(SLoc),
Column = SM.getSpellingColumnNumber(SLoc);
…
}
上一段代码中的 Line
和 Column
变量分别是 SLoc
所指向的源位置行号和列号。
你可能会想知道为什么我们在上一段代码片段中使用 spellingLineNumber
而不是简单的 LineNumber
。结果是,在宏展开(或在预处理期间发生的任何展开)的情况下,Clang 在展开前后跟踪宏内容的 SourceLocation
实例。拼写位置表示源代码最初被 编写 的位置,而展开位置是宏被展开的位置。
您也可以使用以下 API 创建新的拼写和扩展关联:
SourceLocation NewSLoc = SM.createExpansionLoc(
SpellingLoc, // The original macro spelling location
ExpansionStart, // Start of the location where macro is //expanded
ExpansionEnd, // End of the location where macro is // expanded
Len // Length of the content you want to expand
);
返回的 NewSLoc
现在关联了拼写和扩展位置,可以使用 SourceManager
进行查询。
这些是帮助您处理源代码位置的重要概念和 API,尤其是在后续章节中与预处理器一起工作时。下一节将为您介绍 Clang 中预处理器和词法分析器开发的一些背景知识,这对于在后续的“开发自定义预处理器插件和回调”部分工作将很有用。
学习预处理器和词法分析器的基础知识
在前面的“使用 SourceLocation 和 SourceManager”部分,我们学习了源位置,它是预处理器的一个重要部分,在 Clang 中的表示方式。在本节中,我们将首先解释 Clang 预处理器和词法分析器的原理,以及它们的工作流程。然后,我们将深入探讨这个流程中的一些重要组件,并简要说明它们在代码中的使用。这些内容也将为您在本书后面的“开发自定义预处理器插件和回调”部分的项目做好准备。
理解预处理器和词法分析器在 Clang 中的作用
Clang 的预处理器和词法分析器(分别由 Preprocessor
和 Lexer
类表示)所扮演的角色和执行的主要操作,如下面的图所示:
![图 6.1 – Clang 预处理器和词法分析器的角色
图 6.1 – Clang 预处理器和词法分析器的角色
我们相信大多数读者都会熟悉在词法分析器上下文中“标记”的概念——原始源代码的一个子串,它作为语义推理的最小构建块。在一些传统的编译器中,词法分析器负责将输入源代码切割成一系列标记或标记流,如前图所示。这个标记流随后将被送入解析器以构建语义结构。
在实现方面,Clang 与传统的编译器(或教科书中的编译器)采取了一条略有不同的路径:Preprocessor
使用的 Lexer
仍然是将源代码切割成标记的主要执行者。然而,当遇到预处理器指令(即以 #
开头的任何内容)或符号时,Lexer
会停止操作,并将该任务转交给 Preprocessor
组织的宏扩展、头文件解析器或预处理指令处理器。这些辅助组件在需要时向主标记流中注入额外的标记,这些标记最终会返回给 Preprocessor
的用户。
换句话说,大多数标记流消费者并不直接与 Lexer
交互,而是与 Preprocessor
实例交互。这使得人们将 Lexer
类称为 原始 lexer(如前图所示),因为 Lexer
本身只生成未经预处理的标记流。为了给您一个更具体的使用 Preprocessor
来检索标记(流)的例子,以下简单的代码片段已经提供。这显示了从当前正在处理的源代码中获取下一个标记的方法:
Token GetNextToken(Preprocessor &PP) {
Token Tok;
PP.Lex(Tok);
return Tok;
}
如您所猜测的,Token
是 Clang 中表示单个标记的类,我们将在下一段落中简要介绍。
理解标记
Token
类是单个标记的表示,无论是来自源代码的还是具有特殊用途的 虚拟 标记。它也被预处理/词法分析框架广泛使用,就像我们之前介绍的 SourceLocation
一样。因此,它被设计得在内存中非常简洁,并且可以轻易地复制。
对于 Token
类,这里有两点我们想要强调,如下所述:
-
标记类型告诉您这个标记是什么。
-
IdentifierInfo
类用于携带额外的标识符信息,这部分内容我们将在本节稍后进行介绍。
标记类型
标记类型告诉您这个 Token
是什么。Clang 的 Token
被设计用来表示不仅仅是具体的、物理语言结构,如关键字和符号,还包括解析器插入的虚拟概念,以便使用单个 Token
尽可能地编码尽可能多的信息。为了可视化标记流中标记的类型,您可以使用以下命令行选项:
$ clang -fsyntax-only -Xclang -dump-tokens foo.cc
foo.cc
包含以下内容:
namespace foo {
class MyClass {};
}
foo::MyClass Obj;
这是前面命令的输出:
namespace 'namespace' [StartOfLine] Loc=<foo.cc:1:1>
identifier 'foo' [LeadingSpace] Loc=<foo.cc:1:11>
l_brace '{' [LeadingSpace] Loc=<foo.cc:1:15>
class 'class' [StartOfLine] [LeadingSpace] Loc=<foo.cc:2:3>
identifier 'MyClass' [LeadingSpace] Loc=<foo.cc:2:9>
l_brace '{' [LeadingSpace] Loc=<foo.cc:2:17>
r_brace '}' Loc=<foo.cc:2:18>
semi ';' Loc=<foo.cc:2:19>
r_brace '}' [StartOfLine] Loc=<foo.cc:3:1>
identifier 'foo' [StartOfLine] Loc=<foo.cc:5:1>
coloncolon '::' Loc=<foo.cc:5:4>
identifier 'MyClass' Loc=<foo.cc:5:6>
identifier 'Obj' [LeadingSpace] Loc=<foo.cc:5:14>
semi ';' Loc=<foo.cc:5:17>
eof '' Loc=<foo.cc:5:18>
突出的部分是每个标记的标记类型。完整的标记类型列表可以在 clang/include/clang/Basic/TokenKinds.def
文件中找到。这个文件是了解任何语言结构(例如,return
关键字)与其标记类型对应(kw_return
)之间映射的有用参考。
尽管我们无法可视化虚拟标记——或者 ::
(在前面指令中称为 coloncolon
的标记类型)有几种不同的用法。例如,它可以用于命名空间解析(在 C++ 中更正式地称为 作用域解析),如前面代码片段所示,或者它可以(可选地)与 new
和 delete
操作符一起使用,如下面的代码片段所示:
int* foo(int N) {
return ::new int[N]; // Equivalent to 'new int[N]'
}
为了使解析处理更高效,解析器将首先尝试解决 coloncolon
标记是否是作用域解析。如果是,则标记将被替换为 annot_cxxscope
注解标记。
现在,让我们看看检索标记类型的 API。Token
类提供了一个 getKind
函数来检索其标记类型,如下面的代码片段所示:
bool IsReturn(Token Tok) {
return Tok.getKind() == tok::kw_return;
}
然而,如果您只是进行检查,就像前面的代码片段一样,有一个更简洁的函数可用,如下所示:
bool IsReturn(Token Tok) {
return Tok.is(tok::kw_return);
}
尽管很多时候,知道Token
的类型就足以进行处理,但某些语言结构需要更多的证据来判断(例如,代表函数名的标记,在这种情况下,标记类型identifier
并不像名称字符串那样重要)。Clang 使用一个专门的类IdentifierInfo
来携带有关任何标识符的额外信息,我们将在下一段中介绍。
标识符
标准 C/C++使用Token
这个词,它符合语言对标识符的标准定义,并辅以IdentifierInfo
对象。此对象包含诸如底层字符串内容或此标识符是否与宏函数相关联等属性。以下是从Token
类型变量Tok
检索IdentifierInfo
实例的方法:
IdentifierInfo *II = Tok.getIdentifierInfo();
前面的getIdentifierInfo
函数如果Tok
不是按照语言标准的定义表示标识符时将返回 null。请注意,如果两个标识符具有相同的文本内容,它们将由相同的IdentifierInfo
对象表示。这在您想要比较不同的标识符标记是否具有相同的文本内容时非常有用。
在各种标记类型之上使用专门的IdentifierInfo
类型有以下优点:
-
对于具有
identifier
标记类型的Token
,我们有时想知道它是否与宏相关联。您可以使用IdentifierInfo::hasMacroDefinition
函数来找出这一点。 -
对于具有
identifier
标记类型的标记,将底层字符串内容存储在辅助存储(即IdentifierInfo
对象)中可以节省Token
对象的内存占用,这在前端的热路径上。您可以使用IdentifierInfo::getName
函数检索底层字符串内容。 -
对于代表语言关键字的
Token
,尽管框架已经为这些类型的标记提供了专门的标记类型(例如,kw_return
用于return
关键字),但其中一些标记只有在后来的语言标准中才成为语言关键字。例如,以下代码片段在 C++11 之前的标准中是合法的:void foo(int auto) {}
-
您可以使用以下命令进行编译:
$ clang++ -std=c++03 standard into -std=c++11 or a later standard. The error message in the latter case will say that auto, a language keyword since C++11, can't be used there. To give the frontend have an easier time judging if a given token is a keyword in any case, the IdentifierInfo object attached on keyword tokens is designed to answer if an identifier is a keyword under a certain language standard (or language feature), using the IdentifierInfo::isKeyword(…) function, for example, whereby you pass a LangOptions class object (a class carrying information such as the language standard and features currently being used) as the argument to that function.
在下一小节中,我们将介绍本节最后一个重要的Preprocessor
概念:Preprocessor
如何处理 C 系列语言的macros
。
处理宏
C 系列语言的宏实现非同寻常。除了我们之前介绍过的源位置挑战——如何携带宏定义及其展开位置的双重源位置——能够重新定义和取消定义宏名称的能力使得整个问题更加复杂。以下是一个示例代码片段:
#define FOO(X) (X + 1)
return FOO(3); // Equivalent to "return (3 + 1);"
#define FOO(X) (X - 100)
return FOO(3); // Now this is equivalent to "return (3 - 100);"
#undef FOO
return FOO(3); // "FOO(3)" here will not be expanded in //preprocessor
前面的 C 代码显示,FOO
的定义(如果已定义)在不同词法位置(不同行)上有所不同。
本地宏与模块宏的区别
C++20 引入了一个新的语言概念,称为 export
。本书中我们只涵盖局部宏。
为了模拟这个概念,Clang 构建了一个系统来记录定义和取消定义的链。在解释其工作原理之前,以下是该系统最重要的三个组件:
-
MacroDirective
:此类是给定宏标识符的#define
或#undef
语句的逻辑表示。如前代码示例所示,同一个宏标识符上可以有多个#define
(和#undef
)语句,因此最终这些MacroDirective
对象将形成一个按其词法出现顺序排列的链。更具体地说,#define
和#undef
指令实际上分别由MacroDirective
的子类DefMacroDirective
和UndefMacroDirective
表示。 -
MacroDefinition
:此类代表当前时间点宏标识符的定义。而不是包含完整的宏定义体,此实例更像是一个指针,指向不同的宏体,这些宏体将由稍后介绍的MacroInfo
类表示,在解析不同的MacroDirective
类时。此类还可以告诉你定义此MacroDefinition
类的(最新)DefMacroDirective
类。 -
MacroInfo
:此类包含宏定义的体,包括体中的标记和宏参数(如果有)。
下面是一个图解,说明了这些类与前面示例代码之间的关系:
图 6.2 – 不同 C++ 宏类与前面代码示例的关系
要检索 MacroInfo
类及其 MacroDefinition
类,我们可以使用以下 Preprocessor
API,如下所示:
void printMacroBody(IdentifierInfo *MacroII, Preprocessor &PP) {
MacroDefinition Def = PP.getMacroDefinition(MacroII);
MacroInfo *Info = Def.getMacroInfo();
…
}
IdentifierInfo
类型参数 MacroII
,如前代码片段所示,代表宏名称。要进一步检查宏体,请运行以下代码:
void printMacroBody(IdentifierInfo *MacroII, Preprocessor &PP) {
…
MacroInfo *Info = Def.getMacroInfo();
for(Token Tok : Info->tokens()) {
std::cout << Tok.getName() << "\n";
}
}
从本节中,你已经了解了 Preprocessor
的工作流程,以及两个重要组件:Token
类和负责宏的子系统。学习这两个组件将帮助你更好地理解 Clang 的预处理工作方式,并为下一节中 Preprocessor
插件和自定义回调函数的开发做好准备。
开发自定义预处理器插件和回调函数
与 LLVM 和 Clang 的其他部分一样灵活,Clang 的预处理框架也提供了一种通过插件插入自定义逻辑的方法。更具体地说,它允许开发者编写插件来处理自定义 #pragma my_awesome_feature
)。此外,Preprocessor
类还提供了一个更通用的方法来定义在任意 #include
指令被解析时触发的自定义回调函数,仅举几个例子。在本节中,我们将使用一个简单项目来利用这两种技术来演示它们的用法。
项目目标和准备
C/C++ 中的宏一直因其糟糕的 设计卫生 而臭名昭著,如果不小心使用,很容易导致编码错误。看看以下代码片段,看看这个例子:
#define PRINT(val) \
printf("%d\n", val * 2)
void main() {
PRINT(1 + 3);
}
前一个代码片段中的 PRINT
看起来就像一个普通函数,因此很容易相信这个程序将打印出 8
。然而,PRINT
是一个宏函数而不是普通函数,所以当它展开时,main
函数相当于以下内容:
void main() {
printf("%d\n", 1 + 3 * 2);
}
因此,实际上程序打印的是 7
。当然,可以通过将宏体中 val
宏参数的每个出现都用括号括起来来解决这个问题,如下面的代码片段所示:
#define PRINT(val) \
printf("%d\n", (val) * 2)
因此,在宏展开后,main
函数将看起来像这样:
void main() {
printf("%d\n", (1 + 3) * 2);
}
我们将要做的项目是开发一个自定义的 #pragma
语法,以警告开发者如果某个由程序员指定的宏参数没有被正确地用括号包围,从而防止前面提到的 卫生 问题发生。以下是这个新语法的示例:
#pragma macro_arg_guard val
#define PRINT(val) \
printf("%d\n", val * 94 + (val) * 87);
void main() {
PRINT(1 + 3);
}
与前面的例子类似,如果前面 val
参数的出现没有被括号包围,这可能会引入潜在的错误。
在新的 macro_arg_guard
预处理指令语法中,紧跟在指令名称后面的标记是下一个宏函数中要检查的宏参数名称。由于前一个代码片段中的 val * 94
表达式中的 val
没有被括号包围,它将打印以下警告信息:
$ clang … foo.c
[WARNING] In foo.c:3:18: macro argument 'val' is not enclosed by parenthesis
这个项目,尽管是一个 玩具示例,但在宏函数变得相当大或复杂时实际上非常有用,在这种情况下,手动在每个宏参数出现处添加括号可能是一个容易出错的任务。一个能够捕捉这种错误的工具肯定会很有帮助。
在我们深入编码部分之前,让我们设置项目文件夹。以下是文件夹结构:
MacroGuard
|___ CMakeLists.txt
|___ MacroGuardPragma.cpp
|___ MacroGuardValidator.h
|___ MacroGuardValidator.cpp
MacroGuardPragama.cpp
文件包含一个自定义的 PragmaHandler
函数,我们将在下一节中介绍,实现自定义预处理指令处理器。对于 MacroGuardValidator.h/.cpp
,这包括一个自定义的 PPCallbacks
函数,用于检查指定的宏体和参数是否符合我们这里的规则。我们将在后面的 实现自定义预处理器回调函数 部分介绍。
由于我们在这里设置的是一个树外项目,如果你不知道如何导入 LLVM 自身的 CMake 指令(例如 add_llvm_library
和 add_llvm_executable
CMake 函数),请参阅 第二章 的 理解树外项目的 CMake 集成 部分,探索 LLVM 的构建系统功能。并且因为我们在这里也处理 Clang,我们需要使用类似的方法来导入 Clang 的构建配置,如下面的代码片段中所示的 include
文件夹路径:
# In MacroGuard/CmakeLists.txt
…
# (after importing LLVM's CMake directives)
find_package(Clang REQUIRED CONFIG)
include_directories(${CLANG_INCLUDE_DIRS})
我们在这里不需要设置 Clang 的库路径,是因为通常情况下,插件会动态链接到由加载程序(在我们的例子中是 clang
可执行文件)提供的库实现,而不是在构建时显式链接这些库。
最后,我们添加了插件的构建目标,如下所示:
set(_SOURCE_FILES
MacroGuardPragma.cpp
MacroGuardValidator.cpp
)
add_llvm_library(MacroGuardPlugin MODULE
${_SOURCE_FILES}
PLUGIN_TOOL clang)
PLUGIN_TOOL 参数
Windows platforms, since PLUGIN_TOOL is also used for specifying this plugin loader executable's name.
在设置好 CMake
脚本并构建插件后,你可以使用以下命令来运行插件:
$ clang … -fplugin=/path/to/MacroGuardPlugin.so foo.c
当然,我们目前还没有编写任何代码,所以没有输出任何内容。在下一节中,我们将首先开发一个自定义的 PragmaHandler
实例来实现我们的新 #pragma macro_arg_guard
语法。
实现自定义的预处理指令处理器
实现上述功能的第一步是创建一个自定义的 #pragma
处理器。为此,我们首先在 MacroGuardPragma.cpp
文件中创建一个从 PragmaHandler
类派生的 MacroGuardHandler
类,如下所示:
struct MacroGuardHandler : public PragmaHandler {
MacroGuardHandler() : PragmaHandler("macro_arg_guard"){}
void HandlePragma(Preprocessor &PP, PragmaIntroducer Introducer, Token &PragmaTok) override;
};
当 Preprocessor
遇到非标准预处理指令时,将调用 HandlePragma
回调函数。在这个函数中,我们将做两件事,如下所示:
-
获取任何补充标记——被视为
macro_arg_guard
)。 -
注册一个
PPCallbacks
实例,该实例将扫描下一个宏函数定义的主体,以查看特定的宏参数是否被正确地用括号封装。我们将在下一节中概述这个任务的细节。
对于第一个任务,我们正在利用 Preprocessor
来帮助我们解析要封装的宏参数,即宏参数名称。当调用 HandlePragma
时,Preprocessor
将在宏名称标记之后立即停止,如下面的代码片段所示:
#pragma macro_arg_guard val
^--Stop at here
因此,我们所需做的只是继续解析和存储这些标记,直到遇到这一行的末尾:
void MacroGuardHandler::HandlePragma(Preprocessor &PP,…) {
Token Tok;
PP.Lex(Tok);
while (Tok.isNot(tok::eod)) {
ArgsToEnclosed.push_back(Tok.getIdentifierInfo());
PP.Lex(Tok);
}
}
前面的代码片段中的 eod
标记类型表示 指令结束。它专门用于标记预处理器指令的结束。
对于 ArgsToEscped
变量,以下全局数组存储了指定的宏参数的 IdentifierInfo
对象:
SmallVector<const IdentifierInfo*, 2> ArgsToEnclosed;
struct MacroGuardHandler: public PragmaHandler {
…
};
我们在全局范围内声明 ArgsToEnclosed
的原因是,我们稍后需要用它来与我们的 PPCallbacks
实例进行通信,该实例将使用该数组内容来执行验证。
尽管我们的PPCallbacks
实例,即MacroGuardValidator
类的实现细节将在下一节中介绍,但它需要在第一次调用HandlePragma
函数时与Preprocessor
注册,如下所示:
struct MacroGuardHandler : public PragmaHandler {
bool IsValidatorRegistered;
MacroGuardHandler() : PragmaHandler("macro_arg_guard"),
IsValidatorRegistered(false) {}
…
};
void MacroGuardHandler::HandlePragma(Preprocessor &PP,…) {
…
if (!IsValidatorRegistered) {
auto Validator = std::make_unique<MacroGuardValidator>(…);
PP.addCallbackPPCallbacks(std::move(Validator));
IsValidatorRegistered = true;
}
}
我们还使用一个标志来确保它只注册一次。在此之后,每当发生预处理事件时,我们的MacroGuardValidator
类将被调用以处理它。在我们的情况下,我们只对宏定义
事件感兴趣,这会向MacroGuardValidator
发出信号以验证它刚刚定义的宏体。
在结束PragmaHandler
之前,我们需要一些额外的代码将处理程序转换为插件,如下所示:
struct MacroGuardHandler : public PragmaHandler {
…
};
static PragmaHandlerRegistry::Add<MacroGuardHandler>
X("macro_arg_guard", "Verify if designated macro args are enclosed");
在声明这个变量之后,当这个插件被加载到clang
中时,一个MacroGuardHandler
实例被插入到一个全局的PragmaHandler
注册表中,该注册表将在Preprocessor
遇到非标准的#pragma
指令时被查询。现在,当插件加载时,Clang 能够识别我们的自定义macro_arg_guard
指令。
实现自定义预处理器回调
Preprocessor
提供了一组回调,PPCallbacks
类,当某些预处理器事件(例如宏展开)发生时将被触发。前面的实现自定义指令处理程序部分展示了如何将你自己的PPCallbacks
实现,即MacroGuardValidator
,注册到Preprocessor
。在这里,我们将展示MacroGuardValidator
如何验证宏函数中的宏参数逃逸规则。
首先,在MacroGuardValidator.h/.cpp
中,我们放入以下骨架:
// In MacroGuardValidator.h
extern SmallVector<const IdentifierInfo*, 2> ArgsToEnclosed;
class MacroGuardValidator : public PPCallbacks {
SourceManager &SM;
public:
explicit MacroGuardValidator(SourceManager &SM) : SM(SM) {}
void MacroDefined(const Token &MacroNameToke,
const MacroDirective *MD) override;
};
// In MacroGuardValidator.cpp
void MacroGuardValidator::MacroDefined(const Token &MacroNameTok, const MacroDirective *MD) {
}
在PPCallbacks
中的所有回调函数中,我们只对MacroDefined
感兴趣,当处理宏定义时将被调用,由MacroDirective
类型函数参数(MD
)表示。SourceManager
类型成员变量(SM
)用于在需要显示一些警告消息时打印SourceLocation
。
专注于MacroGuardValidator::MacroDefined
,这里的逻辑相当简单:对于ArgsToEnclosed
数组中的每个标识符,我们正在扫描宏体标记以检查其出现是否有括号作为其前驱和后继标记。首先,让我们放入循环的骨架,如下所示:
void MacroGuardValidator::MacroDefined(const Token &MacroNameTok, const MacroDirective *MD) {
const MacroInfo *MI = MD->getMacroInfo();
// For each argument to be checked…
for (const IdentifierInfo *ArgII : ArgsToEnclosed) {
// Scanning the macro body
for (auto TokIdx = 0U, TokSize = MI->getNumTokens();
TokIdx < TokSize; ++TokIdx) {
…
}
}
}
如果宏体标记的IdentifierInfo
参数匹配ArgII
,这意味着存在一个宏参数出现,我们将检查该标记的前一个和下一个标记,如下所示:
for (const IdentifierInfo *ArgII : ArgsToEnclosed) {
for (auto TokIdx = 0U, TokSize = MI->getNumTokens();
TokIdx < TokSize; ++TokIdx) {
Token CurTok = *(MI->tokens_begin() + TokIdx);
if (CurTok.getIdentifierInfo() == ArgII) {
if (TokIdx > 0 && TokIdx < TokSize - 1) {
auto PrevTok = *(MI->tokens_begin() + TokIdx - 1),
NextTok = *(MI->tokens_begin() + TokIdx + 1);
if (PrevTok.is(tok::l_paren) && NextTok.is (tok::r_paren))
continue;
}
…
}
}
}
IdentifierInfo
实例的唯一性
回想一下,相同的标识符字符串总是由相同的IdentifierInfo
对象表示。这就是我们为什么可以在这里简单地使用指针比较的原因。
MacroInfo::tokens_begin
函数返回一个迭代器,指向包含所有宏体标记的数组的开始。
最后,如果宏参数标记没有被括号包围,我们打印一条警告消息,如下所示:
for (const IdentifierInfo *ArgII : ArgsToEnclosed) {
for (auto TokIdx = 0U, TokSize = MI->getNumTokens();
TokIdx < TokSize; ++TokIdx) {
…
if (CurTok.getIdentifierInfo() == ArgII) {
if (TokIdx > 0 && TokIdx < TokSize - 1) {
…
if (PrevTok.is(tok::l_paren) && NextTok.is (tok::r_paren))
continue;
}
SourceLocation TokLoc = CurTok.getLocation();
errs() << "[WARNING] In " << TokLoc.printToString(SM) << ": ";
errs() << "macro argument '" << ArgII->getName()
<< "' is not enclosed by parenthesis\n";
}
}
}
本节内容到此结束。你现在可以开发一个可以动态加载到 Clang 中以处理自定义 #pragma
指令的 PragmaHandler
插件。你还学会了如何实现 PPCallbacks
以便在预处理程序事件发生时插入自定义逻辑。
摘要
预处理程序和词法分析器标志着前端的开端。前者将预处理程序指令替换为其他文本内容,而后者将源代码切割成更有意义的标记。在本章中,我们学习了这两个组件如何相互协作以提供单个标记流视图,以便在后续阶段进行工作。此外,我们还了解了各种重要的 API,例如 Preprocessor
类、Token
类以及宏在 Clang 中的表示方式,这些 API 可以用于该部分的开发,特别是用于创建支持自定义 #pragma
指令的处理程序插件以及创建用于与预处理事件更深入集成的自定义预处理程序回调。
按照 Clang 的编译阶段顺序,下一章将向你展示如何处理抽象语法树(AST)以及如何开发一个可以将其逻辑插入其中的 AST 插件。
练习
这里有一些简单的问题和练习,你可能想自己尝试一下:
-
尽管大多数时候
Tokens
都是从提供的源代码中收集的,但在某些情况下,Tokens
可能会在Preprocessor
内部动态生成。例如,内置宏__LINE__
被展开为当前行号,而宏__DATE__
被展开为当前的日历日期。Clang 如何将这些生成的文本内容放入SourceManager
的源代码缓冲区中?Clang 如何将这些SourceLocation
分配给这些标记? -
当我们谈论实现自定义
PragmaHandler
时,我们正在利用Preprocessor::Lex
来获取Tokens
,直到遇到eod
标记类型。我们能否在eod
标记之后继续进行词法分析?如果你可以在#pragma
指令之后消费任意标记,你会做些什么有趣的事情? -
在“开发自定义预处理程序插件和回调”部分的
macro guard
项目中,警告信息的格式为[WARNING] In <source location>: ….
。显然,这并不是我们从clang
看到的典型编译器警告,它看起来像<source location>: warning: …
,如下代码片段所示:./simple_warn.c:2:7: warning: unused variable 'y'… int y = x + 1; ^ 1 warning generated.
warning
字符串甚至在支持的终端中被着色。我们如何打印这样的警告信息?Clang 中是否有基础设施来做这件事?
第七章:第七章:处理 AST
在上一章中,我们学习了 Clang 预处理器如何处理 C 家族语言的预处理指令。我们还学习了如何编写不同类型的预处理器插件,例如 pragma 处理器,以扩展 Clang 的功能。这些技能在实现特定领域的逻辑甚至自定义语言特性时特别有用。
在本章中,我们将讨论原始源代码文件解析后的语义感知表示,称为抽象语法树 (AST)。AST 是一种包含丰富语义信息的格式,包括类型、表达式树和符号等。它不仅用作生成后续编译阶段的 LLVM IR 的蓝图,也是执行静态分析的首选格式。除此之外,Clang 还为开发者提供了一个很好的框架,通过简单的插件接口在前端管道的中间拦截和操作 AST。
在本章中,我们将介绍如何在 Clang 中处理 AST,内存中 AST 表示的重要 API,以及如何编写 AST 插件以轻松实现自定义逻辑。我们将涵盖以下主题:
-
了解 Clang 中的 AST
-
编写 AST 插件
到本章结束时,你将了解如何使用 Clang 处理 AST 以在源代码级别分析程序。此外,你将了解如何通过 AST 插件以简单的方式将自定义 AST 处理逻辑注入 Clang。
技术要求
本章假设你已经构建了 clang
可执行文件。如果你还没有,请使用以下命令构建它:
$ ninja clang
此外,你可以使用以下命令行标志来打印出 AST 的文本表示:
$ clang -Xclang -ast-dump foo.c
例如,假设 foo.c
包含以下内容:
int foo(int c) { return c + 1; }
通过使用 -Xclang -ast-dump
命令行标志,我们可以打印出 foo.c
的 AST:
TranslationUnitDecl 0x560f3929f5a8 <<invalid sloc>> <invalid sloc>
|…
`-FunctionDecl 0x560f392e1350 <foo.c:2:1, col:30> col:5 foo 'int (int)'
|-ParmVarDecl 0x560f392e1280 <col:9, col:13> col:13 used c 'int'
`-CompoundStmt 0x560f392e14c8 <col:16, col:30>
`-ReturnStmt 0x560f392e14b8 <col:17, col:28>
`-BinaryOperator 0x560f392e1498 <col:24, col:28> 'int' '+'
|-ImplicitCastExpr 0x560f392e1480 <col:24> 'int' <LValueToRValue>
| `-DeclRefExpr 0x560f392e1440 <col:24> 'int' lvalue ParmVar 0x560f392e1280 'c' 'int'
`-IntegerLiteral 0x560f392e1460 <col:28> 'int' 1
此标志有助于找出用于表示代码特定部分的 C++ 类。例如,形式函数参数/参数由 ParmVarDecl
类表示,这在之前的代码中已突出显示。
本章的代码示例可以在以下位置找到:github.com/PacktPublishing/LLVM-Techniques-Tips-and-Best-Practices-Clang-and-Middle-End-Libraries/tree/main/Chapter07
。
了解 Clang 中的 AST
在本节中,我们将了解 Clang 的 AST 内存表示及其基本 API 使用。本节的第一部分将为你提供一个 Clang AST 层次结构的高级概述;第二部分将关注 Clang AST 中类型表示的更具体主题;最后一部分将展示 AST 匹配器的基本用法,这在编写 AST 插件时非常有用。
Clang AST 的内存结构
Clang 中 AST 的内存表示组织成一个类似于 C 家族语言程序语法结构的层次结构。从最高层开始,有两个类值得提及:
-
TranslationUnitDecl
:此类表示一个输入源文件,也称为翻译单元(大多数情况下)。它包含所有顶级声明——全局变量、类和函数等,作为其子节点,其中每个顶级声明都有自己的子树,递归地定义了 AST 的其余部分。 -
ASTContext
:正如其名称所示,此类跟踪来自输入源文件的所有 AST 节点和其他元数据。如果有多个输入源文件,每个文件都获得自己的TranslationUnitDecl
,但它们都共享相同的ASTContext
。
除了结构之外,AST 的主体——AST 节点——还可以进一步分为三个主要类别:Decl
、Expr
和 Stmt
类。在以下各节中,我们将介绍这些内存中的 AST 表示。
声明
变量声明(全局和局部)、函数和结构/类声明等语言结构由 Decl
的子类表示。虽然我们不会在这里深入探讨每个子类,但以下图表显示了 C/C++ 中常见的声明结构及其对应的 AST 类:
图 7.1 – C/C++ 中常见的声明及其 AST 类
在更具体的子类,如 FunctionDecl
和 Decl
之间,有几个重要的 抽象 类代表某些语言概念:
-
NamedDecl
:对于每个具有名称的声明。 -
ValueDecl
:对于声明的实例可以是值的声明,因此与类型信息相关联。 -
DeclaratorDecl
:对于每个使用声明符(基本上是<类型和限定符> <标识符名称>
形式的语句)。它们提供了关于标识符之外部分的其他信息。例如,它们提供了对具有命名空间解析的内存对象的访问,这充当声明符中的限定符。
要了解更多关于其他类型声明的 AST 类,你始终可以通过在 LLVM 的官方 API 参考网站上导航 Decl
的子类来获取。
语句
程序中表示 动作 概念的大多数指令都可以归类为语句,并由 Stmt
的子类表示,包括即将讨论的 表达式。除了函数调用或返回点等命令式语句之外,Stmt
还涵盖了结构概念,如 for
循环和 if
语句。以下是显示 C/C++ 中由 Stmt
(除表达式外)表示的常见语言结构及其对应 AST 类的图表:
图 7.2 – C/C++ 中常见的语句(不包括表达式)及其 AST 类
关于先前的图,有两点值得提及:
-
CompoundStmt
,它是一个包含多个语句的容器,不仅代表了函数体,基本上还代表了任何由花括号('{', '}')
包围的代码块。因此,尽管由于空间不足,在先前的图中没有显示,但IfStmt
、ForStmt
、WhileStmt
和SwitchStmt
都有一个CompoundStmt
子节点来表示它们的主体。 -
在
CompoundStmt
中的声明将被DeclStmt
节点包裹,其中实际的Decl
实例是其子节点。这创建了一个更简单的 AST 设计。
语句是典型 C/C++ 程序中最普遍的指令之一。然而,值得注意的是,许多语句是有层次结构的(例如,ForStmt
及其循环体),因此你可能需要额外的步骤才能找到所需的 Stmt
节点。
表达式
Clang AST 中的表达式是一种特殊的语句。与其他语句不同,表达式总是生成 值。例如,一个简单的算术表达式,3 + 4,预期生成一个整数值。Clang AST 中的所有表达式都由 Expr
的子类表示。以下是一个图,展示了 C/C++ 中由 Expr
表示的常见语言结构及其对应的 AST 类:
图 7.3 – C/C++ 中常见的表达式及其 AST 类
一个重要的 Expr
类是 DeclRefExpr
。它代表了符号引用的概念。你可以使用它的一个 API,DeclRefExpr::getDecl()
,来检索被引用符号的 Decl
对象。这种方便的符号信息仅在 AST 生成后才会出现,因此这也是人们总是推荐在 AST 而不是更原始的形式(例如在解析器内部)上实现静态分析逻辑的原因之一。
另一个有趣的 Expr
类——由于空间不足,在先前的图中没有突出显示——是 ParenExpr
,它代表了围绕表达式包裹的括号。例如,在先前的图中,ParenExpr
以一个表示 x + 1 的 BinaryOperator
作为其子节点。
Clang AST 中的类型
类型系统是现代编译器中最重要的组件之一,特别是对于如 C/C++ 这样的静态类型语言。类型检查确保输入源代码具有良好的格式(在一定程度上)并在编译时尽可能捕获错误。虽然我们不需要在 Clang 中自行进行类型检查,但它是由我们之前在 第五章 中介绍的 Sema
子系统完成的,探索 Clang 的架构。当你处理 AST 时,你可能会需要利用这些信息。让我们学习如何在 Clang AST 中建模类型。
核心类
Clang AST 类型系统的核心是 clang::Type
类。输入代码中的每个类型——包括如 int
这样的原始类型和如结构体/类这样的用户定义类型——都由一个 Type
对象表示。
术语
在本章的其余部分,我们将把输入源代码中的类型称为 源代码类型。
为这些类型中的每一个创建一个 Type
对象。这种设计最大的优点之一是,你有一个更简单的方式来比较两个 Type
对象。假设你有两个 Type
指针。通过在它们上执行简单的指针比较(这非常快),你可以判断它们是否表示相同的源代码类型。
单例设计的一个反例
如果 Clang AST 中的 Type
不使用单例设计,要比较两个 Type
指针是否表示相同的源代码类型,你需要检查它们所指向的对象的内容,这并不高效。
如我们之前提到的,每个源代码类型实际上都由 Type
的一个子类表示。以下是一些常见的 Type
子类:
-
BuiltinType
: 对于如int
、char
和float
这样的原始类型。 -
PointerType
: 对于所有指针类型。它有一个名为PointerType::getPointee()
的函数,用于检索它所指向的源代码类型。 -
ArrayType
: 对于所有数组类型。请注意,它还有其他子类,用于表示具有固定或可变长度的更特定数组。 -
RecordType
: 对于结构体/类/联合体类型。它有一个名为RecordType::getDecl()
的函数,用于检索底层的RecordDecl
。 -
FunctionType
: 用于表示函数的签名;即函数的参数类型和返回类型(以及其他属性,如其调用约定)。
让我们现在继续学习有资格的类型。
有资格的类型
对于 Clang 代码库的新手来说,最令人困惑的事情之一是许多地方使用 QualType
类而不是 Type
的子类来表示源代码类型。QualType
代表 Type
,用于表示如 const <type>
、volatile <type>
和 restrict <type>*
这样的概念。
要从 Type
指针创建 QualType
,可以使用以下代码:
// If `T` is representing 'int'…
QualType toConstVolatileTy(Type *T) {
return QualType(T, Qualifier::Const | Qualifier::Volatile);
} // Then the returned QualType represents `volatile const int`
在本节中,我们学习了 Clang AST 中的类型系统。现在让我们继续学习 ASTMatcher,这是一种匹配模式的语法。
ASTMatcher
当我们处理程序的 AST 时——例如,我们正在检查是否存在任何子优化语法——搜索特定的 AST 节点 模式 通常是最先采取的步骤,也是人们最常做的事情之一。根据我们在上一节中学到的知识,我们知道这种模式匹配可以通过遍历 AST 节点及其内存类 API 来完成。例如,给定一个 FunctionDecl
(函数的 AST 类)——你可以使用以下代码来找出其体中是否存在 while
循环,以及该循环的退出条件是否始终是一个字面量布尔值;即 true
:
// `FD` has the type of `const FunctionDecl&`
const auto* Body = dyn_cast<CompoundStmt>(FD.getBody());
for(const auto* S : Body->body()) {
if(const auto* L = dyn_cast<WhileStmt>(S)) {
if(const auto* Cond = dyn_cast<CXXBoolLiteralExpr> (L->getCond()))
if(Cond->getValue()) {
// The exit condition is `true`!!
}
}
}
如你所见,它创建了超过三层(缩进)的 if
语句来完成这样一个简单的检查。更不用说在实际情况下,我们还需要在这些行之间插入更多的合理性检查!虽然 Clang 的 AST 设计不难理解,但我们需要一个更 简洁 的语法来完成模式匹配任务。幸运的是,Clang 已经提供了一种——那就是 ASTMatcher。
ASTMatcher 是一个实用工具,它通过一个干净、简洁且高效的 领域特定语言(DSL)帮助您编写 AST 模式匹配逻辑。使用 ASTMatcher,执行与之前代码片段中相同的匹配操作只需几行代码:
functionDecl(compountStmt(hasAnySubstatement(
whileStmt(
hasCondition(cxxBoolLiteral(equals(true)))))));
之前代码片段中的大多数指令都很直观:例如 compoundStmt(…)
和 whileStmt(…)
函数调用检查当前节点是否匹配特定节点类型。在这里,这些函数调用中的参数要么代表其子树的模式匹配器,要么检查当前节点的额外属性。还有一些其他指令用于表达限定概念(例如,在这个循环体中的所有子语句中,都存在一个返回值),如 hasAnySubstatement(…)
,以及用于表达数据类型和常量值的指令,例如 cxxBoolLiteral(equals(true))
的组合。
简而言之,使用 ASTMatcher 可以使你的模式匹配逻辑更加 表达性。在本节中,我们展示了这个优雅 DSL 的基本用法。
遍历 AST
在我们深入核心语法之前,让我们了解 ASTMatcher 如何遍历 AST 以及在匹配过程完成后如何将结果返回给用户。
MatchFinder
是模式匹配过程的一个常用驱动器。其基本用法相当简单:
using namespace ast_matchers;
…
MatchFinder Finder;
// Add AST matching patterns to `MatchFinder`
Finder.addMatch(traverse(TK_AsIs, pattern1), Callback1);
Finder.addMatch(traverse(TK_AsIs, pattern2), Callback2);
…
// Match a given AST. `Tree` has the type of `ASTContext&`
// If there is a match in either of the above patterns,
// functions in Callback1 or Callback2 will be invoked // accordingly
Finder.matchAST(Tree);
// …Or match a specific AST node. `FD` has the type of // `FunctionDecl&`
Finder.match(FD, Tree);
pattern1
和 pattern2
是由 DSL 构建的模式对象,如之前所示。更有趣的是 traverse
函数和 TK_AsIs
参数。traverse
函数是模式匹配 DSL 的一部分,但它不是表达模式,而是描述遍历 AST 节点的动作。此外,TK_AsIs
参数代表 遍历模式。
当我们在本章前面展示了用于以文本格式转储 AST 的命令行标志(-Xclang -ast-dump
)时,您可能已经发现许多隐藏的 AST 节点被插入到树中,以帮助程序语义而不是表示程序员编写的真实代码。例如,ImplicitCastExpr
在许多地方被插入以确保程序的类型正确性。在编写模式匹配逻辑时处理这些节点可能是一种痛苦的经历。因此,traverse
函数提供了一个替代的、简化的遍历树的方法。假设我们有以下输入源代码:
struct B {
B(int);
};
B foo() { return 87; }
当您将TK_AsIs
作为traverse
的第一个参数传递时,它观察树,类似于-ast-dump
的行为:
FunctionDecl
`-CompoundStmt
`-ReturnStmt
`-ExprWithCleanups
`-CXXConstructExpr
`-MaterializeTemporaryExpr
`-ImplicitCastExpr
`-ImplicitCastExpr
`-CXXConstructExpr
`-IntegerLiteral 'int' 87
然而,通过使用TK_IgnoreUnlessSpelledInSource
作为第一个参数,观察到的树等于以下树:
FunctionDecl
`-CompoundStmt
`-ReturnStmt
`-IntegerLiteral 'int' 87
如其名称所示,TK_IgnoreUnlessSpelledInSource
仅访问实际在源代码中显示的节点。这极大地简化了编写匹配模式的过程,因为我们不再需要担心 AST 的细节。
另一方面,第一个片段中的Callback1
和Callback2
是MatchFinder::MatchCallback
对象,它们描述了在匹配成功时执行的操作。以下是MatchCallback
实现的骨架:
struct MyMatchCallback : public MatchFinder::MatchCallback {
void run(const MatchFinder::MatchResult &Result) override {
// Reach here if there is a match on the corresponding // pattern
// Handling "bound" result from `Result`, if there is any
}
};
在下一节中,我们将向您展示如何将模式的一部分与一个标签绑定,并在MatchCallback
中检索它。
最后但同样重要的是,尽管我们在第一个片段中使用了MatchFinder::match
和MatchFinder::matchAST
来启动匹配过程,但还有其他方法可以做到这一点。例如,您可以使用MatchFinder::newASTConsumer
创建一个ASTConsumer
实例,该实例将运行所描述的模式匹配活动。或者,您可以使用ast_matchers::match(…)
(不是MatchFinder
下的成员函数,而是一个独立的函数)在单次运行中对提供的模式和ASTContext
进行匹配,然后在返回匹配的节点之前。
ASTMatcher DSL
ASTMatcher 提供了一个易于使用且简洁的 C++ DSL,以帮助进行 AST 匹配。正如我们之前所看到的,所需模式的结构通过嵌套函数调用表示,其中每个这样的函数代表要匹配的 AST 节点的类型。
使用这个 DSL 表达简单的模式简直不能再简单了。然而,当你试图用多个条件/谓词组合模式时,事情会变得稍微复杂一些。例如,虽然我们知道 for 循环(例如,for(I = 0; I < 10; ++I){…}
)可以通过 forStmt(…)
指令进行匹配,但我们如何向其初始化语句(I = 0
)和退出条件(I < 10
)或其循环体添加条件?不仅官方 API 参考网站(我们通常使用的 doxygen 网站)在这方面缺乏清晰的文档,而且这些 DSL 函数在如何接受广泛的参数作为其子模式方面也非常灵活。例如,在回答匹配 for
循环的问题之后,你可以使用以下代码来仅检查循环体:
forStmt(hasBody(…));
或者,你可以检查其循环体和退出条件,如下所示:
forStmt(hasBody(…),
hasCondition(…));
这个问题的广义版本将是,给定一个任意的 DSL 指令,我们如何知道可以与之组合的 可用 指令?
为了回答这个问题,我们将利用专门为 ASTMatcher 创建的 LLVM 文档网站:clang.llvm.org/docs/LibASTMatchersReference.html
。这个网站包含一个巨大的三列表格,显示了每个 DSL 指令返回的类型和参数类型:
图 7.4 – ASTMatcher DSL 参考的一部分
尽管这个表格只是正常 API 参考的简化版本,但它已经展示了如何搜索候选指令。例如,现在你知道 forStmt(…)
可以接受零个或多个 Matcher<ForStmt>
,我们可以在表中搜索返回 Matcher<ForStmt>
或 Matcher<ForStmt> 的父类>
的指令,例如 Matcher<Stmt>
。在这种情况下,我们可以快速找到 hasCondition
、hasBody
、hasIncrement
或 hasLoopInit
作为候选(当然,许多其他返回 Matcher<Stmt>
的指令也可以使用)。
当你进行模式匹配时,有许多情况你不仅想知道模式是否匹配,还想获取匹配的 AST 节点。在 ASTMatcher 的上下文中,其 DSL 指令仅检查 AST 节点的 类型。如果你想检索(部分)正在匹配的 concrete AST 节点,你可以使用 bind(…)
API。以下是一个示例:
forStmt(
hasCondition(
expr().bind("exit_condition")));
在这里,我们使用 expr()
作为通配符模式来匹配任何 Expr
节点。此指令还调用 bind(…)
将匹配的 Expr
AST 节点与名称 exit_condition
关联。
然后,在之前介绍的 MatchCallback
中,我们可以使用以下代码检索绑定的节点:
…
void run(const MatchFinder::MatchResult &Result) override {
cons auto& Nodes = Result.Nodes;
const Expr* CondExpr = Nodes.getNodeAs<Expr> ("exit_condition");
// Use `CondExpr`…
}
getNodeAs<…>(…)
函数试图获取给定名称下的绑定 AST 节点并将其转换为模板参数建议的类型。
注意,你可以将不同的 AST 节点绑定到同一个名称下,在这种情况下,只有最后绑定的节点会在MatchCallback::run
中显示。
将一切整合
现在你已经了解了模式匹配 DSL 语法以及如何使用 ASTMatcher 遍历 AST,让我们将这两者结合起来。
假设我们想知道一个简单的for
循环(循环索引从零开始,每次迭代增加一,由一个字面量整数限制)在函数中的迭代次数——也称为遍历次数):
-
首先,我们必须编写以下代码来进行匹配和遍历:
auto PatExitCondition = binaryOperator( hasOperatorName("<"), hasRHS(integerLiteral() .bind("trip_count"))); auto Pattern = functionDecl( compountStmt(hasAnySubstatement( forStmt(hasCondition(PatExitCondition))))); MatchFinder Finder; auto* Callback = new MyMatchCallback(); Finder.addMatcher(traverse(TK_IgnoreUnlessSpelledInSource, Pattern), Callback);
前面的代码片段也展示了模块化 DSL 模式的样子。你可以根据需要创建单个模式片段并将它们组合起来,只要它们是兼容的。
最后,这是
MyMatchCallback::run
的样子:void run(const MatchFinder::MatchResult &Result) override { const auto& Nodes = Result.Nodes; const auto* TripCount = Nodes.getNodeAs<IntegerLiteral>("trip_count"); if (TripCount) TripCount->dump(); // print to llvm::errs() }
-
之后,你可以使用
Finder
在 AST 上匹配所需的模式(通过调用MatchFinder::match
或MatchFinder::matchAST
,或者通过使用MatchFinder::newASTConsumer
创建ASTConsumer
)来匹配。匹配的遍历次数将被打印到stderr
。例如,如果输入源代码是for(int i = 0; i < 10; ++i) {…}
,输出将简单地是10
。
在本节中,我们学习了 Clang 如何结构其 AST,Clang AST 如何在内存中表示,以及如何使用 ASTMatcher 帮助开发者进行 AST 模式匹配。有了这些知识,在下一节中,我们将向你展示如何创建 AST 插件,这是将自定义逻辑注入 Clang 编译管道的最简单方法之一。
编写 AST 插件
在上一节中,我们学习了如何在 Clang 中表示 AST 以及它的内存中类的样子。我们还学习了可以使用的一些技巧来在 Clang AST 上执行模式匹配。在本节中,我们将学习如何编写插件,允许你将自定义的 AST 处理逻辑插入到 Clang 的编译管道中。
本节将分为三个部分:
-
项目概述:本节将要创建的演示项目的目标和概述。
-
DiagnosticsEngine
,一个强大的子系统,可以帮助你打印出格式良好且具有意义的诊断信息。这将使我们的演示项目更适用于现实世界场景。 -
创建 AST 插件:本节将展示如何从头开始创建 AST 插件,填写所有实现细节,以及如何使用 Clang 运行它。
项目概述
在本节中,我们将创建一个插件,当输入代码中有可以转换为三元运算符的if
-else
语句时,它会提示用户警告信息。
快速回顾 – 三元运算符
当x
条件为真时,三元运算符x? val_1 : val_2
被评估为val_1
。否则,它被评估为val_2
。
例如,让我们看看以下 C/C++代码片段:
int foo(int c) {
if (c > 10) {
return c + 100;
} else {
return 94;
}
}
void bar(int x) {
int a;
if (x > 10) {
a = 87;
} else {
a = x – 100;
}
}
两个函数中的if
-else
语句可以转换为三元运算符,如下所示:
int foo(int c) {
return c > 10? c + 100 : 94;
}
void bar(int x) {
int a;
a = x > 10? 87 : x – 100;
}
在这个项目中,我们将只关注寻找两种潜在的三元运算符机会:
-
then
块(真分支)和else
块(假分支)都包含一个return
语句。在这种情况下,我们可以合并它们的返回值和分支条件为一个三元运算符(作为新的返回值)。 -
then
块(真分支)和else
块(假分支)都只包含一个赋值语句。这两个语句都使用单个DeclRefExpr
– 即符号引用 – 作为 LHS,并且这两个DeclRefExpr
对象都指向同一个Decl
(符号)。换句话说,我们正在涵盖前面代码片段中显示的bar
函数的情况。请注意,我们不包括 LHS 更复杂的情况;例如,当使用数组索引a[i]
作为 LHS 时。
在识别这些模式后,我们必须向用户提示警告信息,并提供额外信息以帮助用户修复此问题:
$ clang …(flags to run the plugin) ./test.c
./test.c:2:3: warning: this if statement can be converted to ternary operator:
if (c > 10) {
^
./test.c:3:12: note: with true expression being this:
return c + 100;
^
./test.c:5:12: note: with false expression being this:
return 94;
^
./test.c:11:3: warning: this if statement can be converted to ternary operator:
if (x > 10) {
^
./test.c:12:9: note: with true expression being this:
a = 87;
^
./test.c:14:9: note: with false expression being this:
a = x - 100;
^
2 warnings generated.
每个警告信息 – 告诉你哪个if
-else
语句可以转换为三元运算符 – 后面跟着两个注释,指出为运算符构造的潜在表达式。
与我们在第六章的扩展预处理器部分中做的手动制作编译器信息相比,这里我们使用 Clang 的诊断基础设施来打印包含更丰富信息的消息,例如消息所引用的代码快照。我们将在下一节中向你展示如何使用这个诊断基础设施。
打印诊断信息
在第六章中,扩展预处理器,我们询问你是否可以改进示例项目中在开发自定义预处理器插件和回调部分显示的警告信息格式,使其更接近你从 Clang 看到的编译器信息。针对这个问题的解决方案之一是使用 Clang 的诊断框架。我们将在本节中探讨这一点。
Clang 的诊断框架由三个主要部分组成:
-
诊断 ID
-
诊断引擎
-
诊断消费者(客户端)
它们之间的关系可以在以下图中看到:
图 7.5 – Clang 诊断框架的高级组织结构
诊断信息
从前面图的左侧开始,大多数情况下,一个诊断信息 – 例如,使用未声明的标识符" x" – 与一个具有自己的诊断 ID 的消息模板相关联。例如,使用未声明的标识符消息,其消息模板看起来像这样:
"use of undeclared identifier %0"
%0
是一个 x
,在先前的示例消息中)。跟随 %
的数字也暗示了它将使用哪些补充数据。我们将在稍后详细介绍这种格式。
模板通过 TableGen 语法注册到诊断引擎中。例如,我们正在讨论的消息被放入 clang/include/clang/Basic/DiagnosticSemaKinds.td
:
def err_undeclared_var_use : Error<"use of undeclared identifier %0">;
我们在前面的片段中突出了两个部分。首先,此消息模板的名称 err_undeclared_var_use
将在以后作为唯一的诊断 ID 使用。其次,Error
TableGen 类建议这是一个错误消息,或者更正式地说,它的 诊断级别 错误。
总结来说,一个诊断消息由一个唯一的诊断 ID 组成——它与一个消息模板及其诊断级别相关联——以及如果有的话,需要放入模板占位符中的补充数据。
诊断消费者
在将诊断消息发送到诊断引擎(由 DiagnosticsEngine
类表示)之后,引擎将消息格式化为文本内容,并将它们发送到 诊断消费者(在代码库中也称为 客户端;在本节的其余部分我们将使用术语 消费者)之一。
诊断消费者——DiagnosticConsumer
类的实现——对从 DiagnosticsEngine
发送的文本消息进行后处理,并通过不同的介质导出它们。例如,默认的 TextDiagnosticPrinter
将消息打印到命令行界面;另一方面,LogDiagnosticPrinter
在打印到日志文件之前,会用简单的 XML 标签装饰传入的消息。理论上,你甚至可以创建一个自定义的 DiagnosticConsumer
,将诊断消息发送到远程主机!
报告诊断消息
现在你已经了解了 Clang 的诊断框架是如何工作的,让我们学习如何向 DiagnosticEngine
发送(报告)诊断消息:
-
首先,我们需要获取对
DiagnosticEngine
的引用。该引擎本身位于 Clang 编译管道的核心,因此您可以从各种主要组件中获取它,例如ASTContext
和SourceManager
。以下是一个示例:// `Ctx` has the type of `ASTContext&` DiagnosticsEngine& Diag = Ctx.getDiagnostics();
-
接下来,我们需要使用
DiagnosticsEngine::Report
函数。此函数始终将诊断 ID 作为其参数之一。例如,要报告我们之前介绍的err_undeclared_var_use
,请使用以下代码:Diag.Report(err_undeclared_var_use takes one placeholder argument – namely, the identifier name – which is supplied through concatenating the Report function call with << operators:
Diag.Report(diag::err_undeclared_var_use) << ident_name_str;
-
回想一下,
err_undeclared_var_use
只有一个占位符%0
,因此它选择了<<
流中的第一个值。让我们假设我们有一个诊断消息err_invalid_placement
,其模板如下:"you cannot put %1 into %0"
-
您可以使用以下代码来报告此内容:
Diag.Report(diag::err_invalid_placement) << "boiling oil" << "water";
-
除了简单的占位符之外,另一个有用的特性是
%select
指令。例如,我们有一个诊断消息warn_exceed_limit
,其模板如下:"you exceed the daily %select directive consists of curly braces in which different message options are separated by |. Outside the curly braces, a number – 0, in the preceding code – indicates which supplement data is used to select the option within the braces. The following is an example of this:
Diag.Report(diag::warn_exceed_limit) << 0 作为流操作符(<<)之后的参数:
Diag.Report(diag::warn_exceed_limit) << 0;
这将导致一条消息指出你超过了每日的 WiFi 限制。
-
现在,假设你使用另一个版本的
Report
函数,它接受一个额外的SourceLocation
参数:// `SLoc` has the type of `SourceLocation` Diag.Report(SLoc:
test.cc:2:10: 错误:使用了未声明的标识符 'x'
return x + 1;
^
-
最后但同样重要的是,尽管大多数诊断消息是通过 TableGen 代码在 Clang 的源树内部注册到
DiagnosticsEngine
的,但这并不意味着开发者不能在不修改 Clang 的源树的情况下创建他们自己的新诊断消息。让我们介绍DiagnosticsEngine::getCustomDiagID(…)
,这是一个 API,它可以从开发者提供的消息模板和诊断级别创建一个新的诊断 ID:auto MyDiagID = Diag.MyDiagID, that has a message template of Today's weather is %0 at its note diagnostic level. You can use this diagnostic ID just like any other ID:
Diag.Report(MyDiagID) << "cloudy";
在本节中,你学习了如何利用 Clang 的诊断框架打印出类似于正常编译器消息的消息。
接下来,我们将结合本章学到的所有技能来创建一个自定义的 AST 插件。
创建 AST 插件
在本章的前几节中,我们探索了 Clang 的 AST 并学习了如何在内存 API 中使用它。在本节中,我们将学习如何编写一个插件,该插件可以帮助你以简单的方式将自定义的 AST 处理逻辑插入到 Clang 的编译管道中。
在 第五章 探索 Clang 的架构 中,我们学习了使用 Clang (AST) 插件的优势:即使你使用预构建的 clang
可执行文件,也可以开发它们;它们易于编写,并且与现有的工具链和构建系统有很好的集成,仅举几例。在 第六章 扩展预处理器 中,我们开发了一个用于预处理器中自定义预处理指令处理的插件。在本章中,我们也将编写一个插件,但这个插件将设计用于自定义 AST 处理。这两个插件的代码框架也相当不同。
我们在本节的 项目概述 部分介绍了我们将使用此节的示例项目。此插件将在输入代码中的某些 if
-else
语句可以转换为三元运算符时向用户显示警告消息。此外,它还提供了关于构建三元运算符的候选表达式的额外提示。
下面是构建插件的详细步骤:
-
与我们在 第六章 扩展预处理器 中看到的预处理指令插件类似,在 Clang 中创建插件基本上就像实现一个类。在 AST 插件的情况下,这将是一个
PluginASTAction
类。PluginASTAction
是ASTFrontendAction
的子类——一个专门用于处理 AST 的FrontendAction
(如果你不熟悉FrontendAction
,可以自由阅读第五章,探索 Clang 的架构)因此,我们需要实现CreateASTConsumer
成员函数:struct TernaryConverterAction : public PluginASTAction { std::unique_ptr<ASTConsumer> CreateASTConsumer(CompilerInstance &CI, StringRef InFile) override; };
我们稍后会填充这个函数。
-
除了
CreateASTConsumer
之外,还有两个其他成员函数我们可以覆盖以改变一些功能:getActionType
和ParseArgs
。前者通过返回这里显示的枚举值之一来告诉 Clang如何执行此插件:a.
Cmdline
:如果用户提供了-plugin <plugin name>
(前端)命令行标志,插件将在主动作之后执行。b.
ReplaceAction
:这会替换 Clang 原本要执行的动作。例如,如果 Clang 原本应该将输入代码编译成目标文件(-c
标志),一旦插件被加载,它将执行插件的动作。c.
AddBefore/AfterMainAction
:原始的 Clang 动作仍然会被执行,插件动作将被添加到其前面或后面。在这里,我们将使用
Cmdline
动作类型:struct TernaryConverterAction : public PluginASTAction { … ActionType getActionType() override { return ParseArgs member function, on the other hand, handles (frontend) command-line options specific to this plugin. In other words, you can create custom command-line flags for your plugin. In our case, we are going to create two flags: -no-detect-return and -no-detect-assignment. This allows us to decide whether we wish to detect potential ternary conversions regarding return statements or assignment statements, respectively:
struct TernaryConverterAction : public PluginASTAction {
…
NoReturn
和NoAssignment
,用于携带命令行选项的值。需要知道的一个重要的事情是ParseArgs
的返回值。ParseArgs
实际上返回的是插件是否应该继续其执行,而不是是否解析了任何自定义标志。因此,在大多数情况下,你应该始终返回 true。 -
现在,我们将讨论
CreateASTConsumer
的内容。这个函数将返回一个ASTConsumer
对象,这是我们将在其中放置自定义逻辑的主要部分。尽管如此,我们不会直接实现ASTConsumer
。相反,我们将使用由ASTMatcher生成的ASTConsumer
对象,我们在本章前面已经介绍过。回想一下,构建
MatchFinder
实例需要两个东西——ASTMatcher(模式匹配驱动程序)的主要模式匹配器和MatchCallback
的实现。让我们将我们的模式和匹配器回调分为两类:基于return
语句检测潜在三元运算符机会的模式,以及基于赋值语句机会的模式。下面是
CreateASTConsumer
的框架:using namespace ast_matchers; struct TernaryConverterAction : public PluginASTAction { … private: std::unique_ptr<MatchFinder> unique_ptr type member variables: one for holding MatchFinder and two MatchCallback ones for return-based and assignment-based patterns.Why Use unique_ptr?The rationale behind using `unique_ptr` to store those three objects – or storing those objects *persistently* – is because the `ASTConsumer` instance we created at the end of `CreateASTConsumer` (`ASTFinder->newASTConsumer()`) keeps references to those three objects. Thus, we need a way to keep them alive during the lifetime of the frontend.In addition to that, we registered the pattern for traversal with MatchFinder by using `MatchFinder::addMatcher`, the `traverse` function, and `MatchCallback` instances. If you're not familiar with these APIs, feel free to check out the *ASTMatcher* section.Now, we only need to compose the matching patterns and implement some callbacks to print out warning messages if there is a match – as the `TODO` comments suggested in the preceding snippet.
-
首先,我们来处理模式。我们正在寻找的模式——无论是基于返回值还是基于赋值的模式——在其最外层布局中都被一个函数(对于整个函数是
FunctionDecl
,对于函数体是CompoundStmt
)包围的if
-else
语句(IfStmt
)。在这两者内部,无论是IfStmt
的真分支还是假分支,都只能存在一个语句。这个结构可以这样表示:FunctionDecl |_CompoundStmt |_(Other AST nodes we don't care) |_IfStmt |_(true branch: contain only one return/assign statement) |_(false branch: contain only one return/assign statement)
要将这个概念转换为 ASTMatcher 的 DSL,以下是返回型和基于赋值型模式之间共享的 DSL 代码:
functionDecl( compoundStmt(hasAnySubstatement IfStmt( hasThen(/*CompoundStmt, you should always use quantifier directives such as hasAnySubstatement to match its body statements.We are going to use the previous `TODO` comments to customize for either return-based or assignment-based situations. Let's use subpattern variables to replace those `TODO` comments and put the preceding code into another function:
StatementMatcher
buildIfStmtMatcher(StatementMatcher truePattern,
StatementMatcher falsePattern) {
return functionDecl(
compoundStmt(hasAnySubstatement
IfStmt(
hasThen(truePattern)
hasElse(falsePattern))));
}
-
对于基于返回的模式,上一步提到的
if
-else
分支的子模式是相同的且简单的。我们还使用一个单独的函数来创建这个模式:StatementMatcher buildReturnMatcher() { return compoundStmt(statementCountIs directive to match the code blocks with only one statement. Also, we specified that we don't want an empty return via hasReturnValue(…). The argument for hasReturnValue is necessary since the latter takes at least one argument, but since we don't care what type of node it is, we are using expr() as some sort of wildcard pattern.For assignment-based patterns, things get a little bit complicated: we don't just want to match a single assignment statement (modeled by the `BinaryOperator` class) in both branches – the LHS of those assignments need to be `DeclRefExpr` expressions that point to the same `Decl` instance. Unfortunately, we are not able to express all these predicates using ASTMatch's DSL. What we can do, however, is push off some of those checks into `MatchCallback` later, and only use DSL directives to check the *shape* of our desired patterns:
StatementMatcher buildAssignmentMatcher() {
return compoundStmt(statementCountIs(1),
hasAnySubstatement(
binaryOperator(
hasOperatorName("="),
hasLHS(declRefExpr())
)));
}
-
现在我们已经完成了模式框架,是时候实现
MatchCallback
了。在MatchCallback::run
中,我们将做两件事。首先,对于基于赋值的模式,我们需要检查匹配的赋值候选的 LHS 的DeclRefExpr
是否指向相同的Decl
。其次,我们想要打印出帮助用户将if
-else
分支重写为三元运算符的消息。换句话说,我们需要从一些匹配的 AST 节点中获取位置信息。让我们使用AST 节点绑定技术来解决第一个任务。计划是将候选赋值的 LHS
DeclRefExpr
节点绑定,以便我们可以在稍后的MatchCallback::run
中检索它们,并对它们的Decl
节点进行进一步检查。让我们将buildAssignmentMatch
改成这样:StatementMatcher buildAssignmentMatcher() { return compoundStmt(statementCountIs(1), hasAnySubstatement( binaryOperator( hasOperatorName("="), hasLHS(DeclRefExpr is bound to the same name, meaning that the AST node that occurred later will overwrite the previously bound node. So, eventually, we won't get DeclRefExpr nodes from both branches as we previously planned.Therefore, let's use a different tags for `DeclRefExpr` that match from both branches: `dest.true` for the true branch and `dest.false` for the false branch. Let's tweak the preceding code to reflect this strategy:
StatementMatcher buildAssignmentMatcher(StringRef buildAssignmentMatcher, we will pass different suffixes for the different branches – either .true or .false.Finally, we must retrieve the bound nodes in
MatchCallback::run
. Here, we are creating differentMatchCallback
subclasses for return-based and assignment-based scenarios –MatchReturnCallback
andMatchAssignmentCallback
, respectively. Here is a part of the code inMatchAssignmentCallback::run
:void MatchAssignmentCallback::run(const MatchResult &Result) override { const auto& Nodes = Result.Nodes; // Check if destination of both assignments are the // same const auto *DestTrue = Nodes.getNodeAs<DeclRefExpr>("dest.true"), *DestFalse = Nodes.getNodeAs<DeclRefExpr>("dest.false"); if (DestTrue->getDecl() == DestFalse->getDecl()) { // Can be converted into ternary operator! } }
我们将在下一步解决第二个任务——向用户打印有用的信息。
-
为了打印有用的信息——包括哪些代码部分可以转换为三元运算符,以及如何构建那个三元运算符——我们需要在获取它们的源位置信息之前从匹配的模式中检索一些 AST 节点。为此,我们将使用一些节点绑定技巧,就像我们在上一步所做的那样。这次,我们将修改所有模式构建函数;即
buildIfStmtMatcher
、buildReturnMatcher
和buildAssignmentMatcher
:StatementMatcher buildIfStmtMatcher(StatementMatcher truePattern, StatementMatcher falsePattern) { return functionDecl( compoundStmt(hasAnySubstatement IfStmt( hasThen(truePattern) hasElse(falsePattern)).IfStmt since we want to tell our users where the potential places that can be converted into ternary operators are:
StatementMatcher buildReturnMatcher(StringRef MatchCallback::run)
和print out the message using the SourceLocation information that's attached to those nodes
。我们将使用 Clang 的诊断框架来打印这些消息(如果你不熟悉它,请随时再次阅读 打印诊断消息 部分)。由于预期的消息格式不是 Clang 代码库中的现有格式,我们将通过DiagnosticsEngine::getCustomDiagID(…)
创建我们自己的诊断 ID。以下是我们在MatchAssignmentCallback::run
中将要做的事情(我们只会演示MatchAssignmentCallback
,因为MatchReturnCallback
类似):void MatchAssignmentCallback::run(const MatchResult &Result) override { … auto& Diag = Result.Context->getDiagnostics(); auto DiagWarnMain = Diag.getCustomDiagID( DiagnosticsEngine::Warning, "this if statement can be converted to ternary operator:"); auto DiagNoteTrueExpr = Diag.getCustomDiagID( DiagnosticsEngine::Note, "with true expression being this:"); auto DiagNoteFalseExpr = Diag.getCustomDiagID( DiagnosticsEngine::Note, "with false expression being this:"); … }
将此与节点检索结合使用,以下是我们将如何打印消息的方法:
void MatchAssignmentCallback::run(const MatchResult &Result) override { … if (DestTrue && DestFalse) { if (DestTrue->getDecl() == DestFalse->getDecl()) { // Can be converted to ternary! const auto* If = Nodes.getNodeAs<IfStmt> ("if_stmt"); Diag.Report(If->getBeginLoc(), DiagWarnMain); const auto* TrueValExpr = Nodes.getNodeAs<Expr>("val.true"); const auto* FalseValExpr = Nodes.getNodeAs<Expr>("val.false"); Diag.Report(TrueValExpr->getBeginLoc(), DiagNoteTrueExpr); Diag.Report(FalseValExpr->getBeginLoc(), DiagNoteFalseExpr); } } }
-
最后,回到
CreateASTConsumer
。以下是我们将如何将所有这些内容拼接在一起:std::unique_ptr<ASTConsumer> TernaryConverterAction::CreateASTConsumer(CompilerInstance &CI, StringRef InFile) { … // Return matcher if (!NoReturn) { ReturnMatchCB = std::make_unique<MatchReturnCallback>(); ASTFinder->addMatcher( traverse(TK_IgnoreUnlessSpelledInSource, buildIfStmtMatcher( buildReturnMatcher(".true"), buildReturnMatcher(".false"))), ReturnMatchCB.get() ); } // Assignment matcher if (!NoAssignment) { AssignMatchCB = std::make_ unique<MatchAssignmentCallback>(); ASTFinder->addMatcher( traverse(TK_IgnoreUnlessSpelledInSource, buildIfStmtMatcher( buildAssignmentMatcher(".true"), buildAssignmentMatcher(".false"))), AssignMatchCB.get() ); } return std::move(ASTFinder->newASTConsumer()); }
这就完成了我们所需做的所有事情!
-
最后但同样重要的是,这是运行我们的插件的命令:
-no-detect-return and -no-detect-assignment in this project, please add the command-line options highlighted here:
-plugin-arg-<插件名称>
格式。
在本节中,你学习了如何编写一个 AST 插件,每当有可以转换为三元运算符的 if
-else
语句时,它就会向用户发送消息。你是通过利用本章中涵盖的所有技术来做到这一点的;即,Clang AST 的内存表示、ASTMatcher 和诊断框架,仅举几例。
摘要
当涉及到程序分析时,AST 通常是推荐的介质,因为它包含丰富的语义信息和高级结构。在本章中,我们了解了 Clang 中使用的强大内存 AST 表示,包括其 C++类和 API。这为你提供了分析源代码的清晰图景。
此外,我们学习和实践了一种简洁的方法来进行 AST 上的模式匹配——这是程序分析的一个关键过程——通过 Clang 的 ASTMatcher。熟悉这项技术可以大大提高你在从输入源代码中过滤出有趣区域时的效率。最后但同样重要的是,我们学习了如何编写一个 AST 插件,这使得你更容易将自定义逻辑集成到默认的 Clang 编译管道中。
在下一章中,我们将探讨 Clang 中的 驱动程序 和 工具链。我们将向您展示它们是如何工作的以及如何自定义它们。
第八章:第八章:使用编译器标志和工具链
在上一章中,我们学习了如何处理 Clang 的 AST – 分析程序中最常见的格式之一。此外,我们还学习了如何开发 AST 插件,这是一种将自定义逻辑插入 Clang 编译管道的简单方法。这些知识将帮助你在源代码检查或寻找潜在的安全漏洞等任务中增强你的技能集。
在本章中,我们正在从特定的子系统向上攀升,着眼于更大的图景 – 编译器驱动程序和工具链,根据用户的需求协调、配置和运行单个 LLVM 和 Clang 组件。更具体地说,我们将关注如何添加新的编译器标志以及如何创建自定义工具链。正如我们在第五章中提到的,即[探索 Clang 的架构],编译器驱动程序和工具链通常被低估,并且长期以来一直被忽视。然而,没有这两个重要的软件组件,编译器将变得极其难以使用。例如,由于缺乏标志转换,用户需要传递超过 10 个不同的编译器标志才能构建一个简单的 hello world 程序。用户还需要运行至少三种不同类型的工具,以便创建一个可执行的程序来运行,因为没有驱动程序或工具链帮助我们调用 汇编器 和 链接器。在本章中,你将学习编译器驱动程序和工具链在 Clang 中的工作方式以及如何自定义它们,这对于你想要在新的操作系统或架构上支持 Clang 来说非常有用。
在本节中,我们将涵盖以下主题:
-
理解 Clang 中的驱动程序和工具链
-
添加自定义驱动程序标志
-
添加自定义工具链
技术要求
在本章中,我们仍然依赖于 clang
可执行文件,所以请确保你已构建它,如下所示:
$ ninja clang
正如我们在第五章中提到的,即[探索 Clang 的架构],我们正在使用驱动程序,你可以使用 -###
命令行选项来打印出已从驱动程序转换的前端标志,如下所示:
$ clang++ -### -std=c++11 -Wall hello_world.cpp -o hello_world
"/path/to/clang" "-cc1" "-triple" "x86_64-apple-macosx11.0.0" "-Wdeprecated-objc-isa-usage" "-Werror=deprecated-objc-isa-usage" "-Werror=implicit-function-declaration" "-emit-obj" "-mrelax-all" "-disable-free" "-disable-llvm-verifier" … "-fno-strict-return" "-masm-verbose" "-munwind-tables" "-target-sdk-version=11.0" … "-resource-dir" "/Library/Developer/CommandLineTools/usr/lib/clang/12.0.0" "-isysroot" "/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk" "-I/usr/local/include" "-stdlib=libc++" … "-Wall" "-Wno-reorder-init-list" "-Wno-implicit-int-float-conversion" "-Wno-c99-designator" … "-std=c++11" "-fdeprecated-macro" "-fdebug-compilation-dir" "/Users/Rem" "-ferror-limit" "19" "-fmessage-length" "87" "-stack-protector" "1" "-fstack-check" "-mdarwin-stkchk-strong-link" … "-fexceptions" … "-fdiagnostics-show-option" "-fcolor-diagnostics" "-o" "/path/to/temp/hello_world-dEadBeEf.o" "-x" "c++" "hello_world.cpp"…
使用此标志将不会运行编译的其余部分,而只是执行驱动程序和工具链。这使得它成为验证和调试特定标志以及检查它们是否正确从驱动程序传播到前端的好方法。
最后但同样重要的是,在本章的最后部分,添加自定义工具链,我们将处理一个只能在 Linux 系统上运行的项目。此外,请事先安装 OpenSSL。在大多数 Linux 系统中,它通常作为软件包提供。例如,在 Ubuntu 上,你可以使用以下命令来安装它:
$ sudo apt install openssl
我们只使用命令行工具,因此不需要安装通常用于开发的任何 OpenSSL 库。
本章将使用的代码可以在以下位置找到:github.com/PacktPublishing/LLVM-Techniques-Tips-and-Best-Practices-Clang-and-Middle-End-Libraries/tree/main/Chapter08
.
在本章的第一部分,我们将简要介绍 Clang 的驱动程序和工具链基础设施。
理解 Clang 中的驱动程序和工具链
在我们讨论 Clang 的编译器驱动程序之前,有必要强调一点:编译一段代码永远不是一个单一的任务(也不是一个简单的任务)。在学校,我们被教导说编译器由一个词法分析器、一个解析器组成,有时还包含一个优化器,并以汇编代码生成器结束。虽然你仍然可以在现实世界的编译器中看到这些阶段,但它们只能给你文本形式的汇编代码,而不是我们通常期望的可执行文件或库。此外,这个简单的编译器只提供了有限的灵活性——它不能移植到任何其他操作系统或平台。
为了使这个玩具编译器更加真实和可用,还需要将许多其他管道工具组合起来,包括核心编译器:一个汇编器,将汇编代码转换为(二进制格式)目标文件,一个链接器,将多个目标文件放入可执行文件或库中,以及许多其他例程来解决特定平台的配置,例如数据宽度、默认头文件路径或应用程序二进制接口(ABIs)。只有借助这些管道,我们才能通过输入几个单词来使用编译器:
$ clang hello_world.c -o hello_world
编译器驱动程序是一种组织这些管道工作的软件。尽管在编译过程中有多个不同的任务需要完成,但本章我们将只关注两个最重要的任务——处理编译器标志和在不同平台上调用正确的工具——这正是工具链设计的目的。
以下图表显示了驱动程序、工具链和编译器其他部分的交互:
图 8.1 – Clang 驱动程序、工具链和编译器其他部分的典型工作流程
如前图所示,Clang 的驱动程序充当 调度器 并将标志和工作负载分配给编译的每个阶段,即前端/后端、汇编器和链接器。为了给您一个更具体的概念,了解每个这些阶段的标志看起来像什么,回想一下我们在本章开头介绍的 -###
编译器选项。该选项打印的(大量)内容是前端(-internal-isystem
包含有关系统头文件路径的信息,包括 C/C++ 标准库头文件存储的路径。显然,Clang 的前端需要知道标准库头文件存储在哪里,但根据您过去使用 clang
(或 gcc
)的经验,您很少需要明确地告诉它们这些头文件的路径 – 驱动程序会为您完成这项工作。同样的逻辑也适用于链接阶段。链接器通常需要不仅仅是对象文件才能正确生成可执行文件或库。例如,它们需要知道 C/C++ 标准库的库文件(在 Unix/Linux 系统上是 *.a
或 *.so
)在哪里。在这种情况下,Clang 的驱动程序将通过链接器标志向链接器提供这些信息。
提供给各个编译阶段的标志和工作负载(简称为 配置)是从两个来源 转换 的:驱动标志(-c
、-Wall
和 -std=c++11
。在下一节,添加自定义驱动标志 中,我们将展示一些 Clang 如何将驱动标志转换为前端标志或甚至汇编器/链接器标志的示例。
另一方面,还有 /usr/include
和 /usr/lib
。此外,macOS X 使用一种名为 Mach-O 的可执行格式,这与 Linux 的 ELF 格式不同。这极大地影响了编译器(Clang)构建代码的方式。
为了让 Clang 为各种平台编译代码,它使用工具链(在内部通过 ToolChain
C++ 类有效地表示)来封装平台特定的信息和配置。在编译的早期阶段,Clang 的驱动程序根据当前运行的系统(称为 -target=
驱动标志,用于请求 Clang 为与主机系统不同的特定平台构建程序,目前实际上是在做 ld64
和 lld
链接器,而 Linux 可以使用 ld
(BFD 链接器)、ld.gold
和 lld
作为链接器。因此,工具链还应指定要使用哪个汇编器和链接器。在本章的最后部分,添加自定义工具链,我们将通过一个示例项目来了解 Clang 的工具链是如何工作的。让我们从学习 Clang 中驱动标志的工作原理开始我们的旅程。
添加自定义驱动标志
在上一节中,我们解释了驱动和工具链在 Clang 中的作用。在本节中,我们将学习如何通过向 Clang 添加自定义驱动标志来实现 Clang 的这种转换。同样,我们将在单独的部分中演示详细步骤之前,首先概述这个示例项目。
项目概述
我们将在本节中使用的示例项目将添加一个新的驱动标志,以便当用户提供该标志时,头文件将 隐式地 包含在输入代码中。
更具体地说,这里我们有一个头文件 – simple_log.h
– 如下代码所示,它定义了一些简单的 API 来打印日志消息:
#ifndef SIMPLE_LOG_H
#define SIMPLE_LOG_H
#include <iostream>
#include <string>
#ifdef SLG_ENABLE_DEBUG
inline void print_debug(const std::string &M) {
std::cout << "[DEBUG] " << M << std::endl;
}
#endif
#ifdef SLG_ENABLE_ERROR
inline void print_error(const std::string &M) {
std::cout << "[ERROR] " << M << std::endl;
}
#endif
#ifdef SLG_ENABLE_INFO
inline void print_info(const std::string &M) {
std::cout << "[INFO] " << M << std::endl;
}
#endif
#endif
这里的目标是使用这些 API 在我们的代码中 不 写入 #include "simple_log.h"
行来导入头文件。并且这个特性只有在提供自定义驱动标志 -fuse-simple-log
给 clang
时才会启用。例如,让我们写下以下代码,test.cc
:
int main() {
print_info("Hello world!!");
return 0;
}
尽管它没有任何 #include
指令,但它仍然可以编译(使用 -fuse-simple-log
标志)并运行而不会出现任何问题:
$ clang++ -fuse-simple-log test.cc -o test
$ ./test
[INFO] Hello world!!
$
此外,我们可以使用 -fuse-<log level>-simple-log
/-fno-use-<log level>-simple-log
来包含或排除特定日志级别的函数。例如,让我们使用相同的先前代码片段,但在编译代码时添加 -fno-use-info-simple-log
:
$ clang++ -fuse-simple-log -fno-use-info-simple-log test.cc -o test
test.cc:2:3: error: use of undeclared identifier 'print_info'
print_info("Hello World!!");
^
1 error generated
$
每个日志打印功能的开关简单地由其周围的 simple_log.h
中的 #ifdef
语句控制。例如,print_info
只有在 SLG_ENABLE_INFO
被定义时才会被包含。在 翻译自定义驱动标志 部分,我们将向您展示这些宏定义是如何通过驱动标志切换的。
最后但同样重要的是,您可以指定 simple_log.h
文件的自定义路径。默认情况下,我们的特性将在源代码的当前文件夹中包含 simple_log.h
。您可以通过提供 -fsimple-log-path=<file path>
或 -fuse-simple-log=<file path>
来更改此设置。例如,我们想要使用 simple_log.h
的一个替代版本 – advanced_log.h
,它存储在 /home/user
,提供了相同接口但不同实现的函数。现在,我们可以使用以下命令:
$ clang++ -fuse-simple-log=/home/user/advanced_log.h test.cc -o test
[01/28/2021 20:51 PST][INFO] Hello World!!
$
以下部分将向您展示如何更改 Clang 驱动中的代码,以便您可以实现这些功能。
声明自定义驱动标志
首先,我们将引导您通过步骤来 声明 自定义驱动标志,例如 -fuse-simple-log
和 -fno-use-info-simple-log
。然后,我们将 连接 这些标志到真正的前端功能。
Clang 使用 TableGen 语法来声明所有类型的编译器标志 – 包括驱动标志和前端标志。
TableGen
TableGen 是一种 领域特定语言 (DSL),用于声明结构和关系数据。要了解更多信息,请参阅 第四章,TableGen 开发。
所有这些标志声明都放在clang/include/clang/Driver/Options.td
中。以常见的-g
标志为例,它告诉你你想要生成源级调试信息。例如,它有一个这样的声明:
def g_Flag : Flag<["-"], "g">, Group<g_Group>,
HelpText<"Generate source-level debug information">;
TableGen 记录g_Flag
是由几个 TableGen 类创建的:Flag
、Group
和HelpText
。其中,我们最感兴趣的是Flag
,其模板值(["-"]
和"g"
)描述了实际的命令行标志格式。注意,当我们声明一个布尔标志时——这个标志的值由其存在决定,没有其他值跟随——就像在这个例子中,我们继承自Flag
类。
在我们想要声明一个具有等于号("=")后跟值的标志的情况下,我们继承自Joined
类。例如,-std=<C++ standard name>
的 TableGen 声明看起来像这样:
def std_EQ : Joined<["-", "--"], "std=">, Flags<[CC1Option]>, …;
通常,这类标志的记录名称(如本例中的std_EQ
)以_EQ
作为后缀。
最后但同样重要的是,Flags
(复数)类可以用来指定一些属性。例如,前面的代码片段中的CC1Options
告诉我们这个标志也可以是一个前端标志。
现在我们已经了解了如何通常声明驱动器标志,是时候创建我们自己的了:
-
首先,我们将处理
-fuse-simple-log
标志。以下是我们的声明方式:def fuse_simple_log : Flag<["-"], "fuse-simple-log">, Group<f_Group>, Flags<[NoXarchOption]>;
这个代码片段基本上与我们之前使用的示例没有区别,只是多了
Group
类和NoXarchOption
。前者指定了这个标志所属的逻辑组——例如,f_Group
是为以-f
开头的标志。后者告诉我们这个标志只能在驱动器中使用。例如,你不能将它传递给前端(但我们如何直接将标志传递给前端?我们将在本节的最后回答这个问题)。注意,我们在这里只声明了
-fuse-simple-log
,而没有声明-fuse-simple-log=<file path>
——这将在稍后介绍的另一个标志中完成。 -
接下来,我们处理
-fuse-<log level>-simple-log
和-fno-use-<log level>-simple-log
。在 GCC 和 Clang 中,看到成对标志(如-f<flag name>
/-fno-<flag name>
)以启用或禁用某个功能是很常见的。因此,Clang 提供了一个方便的 TableGen 实用工具——BooleanFFlag
——以简化成对标志的创建。请参见以下代码中-fuse-error-simple-log
/-fno-use-error-simple-log
的声明:defm use_error_simple_log : BooleanFFlag<"use-error-simple-log">, Group<f_Group>, Flags<[NoXarchOption]>;
BooleanFFlag
是一个多类(所以请确保你使用defm
而不是def
来创建 TableGen 记录)。在底层,它同时为-f<flag name>
和-fno-<flag name>
创建 TableGen 记录。现在我们已经了解了如何创建
use_error_simple_log
,我们可以使用同样的技巧来为其他日志级别创建 TableGen 记录:defm use_debug_simple_log : BooleanFFlag<"use-debug-simple-log">, Group<f_Group>, Flags<[NoXarchOption]>; defm use_info_simple_log : BooleanFFlag<"use-info-simple-log">, Group<f_Group>, Flags<[NoXarchOption]>;
-
最后,我们声明了
-fuse-simple-log=<文件路径>
和-fsimple-log-path=<文件路径>
标志。在前面的步骤中,我们只处理布尔标志,但在这里,我们正在创建具有等于号后跟值的标志,因此我们使用了我们之前引入的Joined
类:def fsimple_log_path_EQ : Joined<["-"], "fsimple-log-path=">, Group<f_Group>, Flags<[NoXarchOption]>; def fuse_simple_log_EQ : Joined<["-"], "fuse-simple-log=">, Group<f_Group>, Flags<[NoXarchOption]>;
再次,具有值的标志通常在其 TableGen 记录名称后缀中使用
_EQ
。
这就完成了声明我们自定义驱动程序标志所需的所有必要步骤。在 Clang 的构建过程中,这些 TableGen 指令将被翻译成 C++枚举和其他由驱动程序使用的实用程序。例如,-fuse-simple-log=<文件路径>
将被表示为一个枚举;即options::OPT_fuse_simple_log_EQ
。下一节将向您展示如何查询用户给出的所有命令行标志,以及最重要的是,如何将我们的自定义标志翻译成其前端对应物。
翻译自定义驱动程序标志
请记住,编译器驱动程序在幕后为用户做了很多事情。例如,它们根据编译目标确定正确的工具链,并将用户指定的驱动程序标志翻译成驱动程序标志,这正是我们接下来要做的。在我们的例子中,当我们的新创建的-fuse-simple-log
被给出时,我们希望为用户包含simple_log.h
头文件,并定义宏变量,如SLG_ENABLE_ERROR
,以根据-fuse-<日志级别>-simple-log
/-fno-use-<日志级别>-simple-log
标志包含或排除某些日志打印函数。更具体地说,这些任务可以分为两部分:
-
如果给出
-fuse-simple-log
,我们将将其翻译成前端标志:-include frontend flag, as its name suggests, *implicitly* includes the designated file in the compiling source code.Using the same logic, if `-fuse-simple-log=/other/file.h` or `-fuse-simple-log -fsimple-log-path=/other/file.h` are given, they will be translated into the following:
`-include "/other/file.h"
-
如果给出
-fuse-<日志级别>-simple-log
或-fno-use-<日志级别>-simple-log
中的任何一个——例如,-fuse-error-simple-log
——它将被翻译成以下:-D flag implicitly defines a macro variable for the compiling source code.However, if only `-fuse-simple-only` is given, the flag will implicitly include all the log printing functions. In other words, `-fuse-simple-only` will not only be translated into the `-include` flag, as introduced in previous bullet point, but also the following flags:
-D -fuse-simple-log
和-fno-use-<日志级别>-simple-log
一起使用,例如:-fuse-simple-log -fno-use-error-simple-log
它们将被翻译成以下代码:
-include "simple_log.h" -D SLG_ENABLE_DEBUG -D SLG_ENABLE_INFO
最后但同样重要的是,我们还允许以下组合:
-fuse-info-simple-log -fsimple-log-path="my_log.h"
也就是说,我们只启用单个日志打印函数,而不使用
-fuse-simple-log
(而不是使用该标志并减去两个其他日志打印函数),并使用自定义简单日志头文件。这些驱动程序标志将被翻译成以下代码:-include "my_log.h" -D SLG_ENABLE_INFO
上述规则和标志的组合实际上可以以一种相当优雅的方式处理,尽管乍一看可能很复杂。我们很快就会向您展示如何做到这一点。
现在我们已经了解了将要翻译的前端标志是什么,是时候学习如何进行这些翻译了。
许多驱动程序标志翻译发生的地方是在driver::tools::Clang
C++类内部。更具体地说,这发生在其Clang::ConstructJob
方法中,该方法位于clang/lib/Driver/ToolChains/Clang.cpp
文件中。
关于 driver::tools::Clang
对于这个 C++ 类,最突出的问题可能包括:它代表什么 概念?为什么它被放在名为 ToolChains 的文件夹下?这意味着它也是一个工具链吗?虽然我们将在下一节详细回答这些问题,添加自定义工具链,但到目前为止,你只需将其视为 Clang 前端的代表。这(某种程度上)解释了为什么它负责将驱动标志转换为前端标志。
下面是将我们的自定义驱动标志翻译的步骤。以下代码可以插入到 Clang::ConstructJob
方法中的任何位置,在调用 addDashXForInput
函数之前,该函数开始封装翻译过程:
-
首先,我们定义了一个帮助类 –
SimpleLogOpts
– 来携带我们的自定义标志信息:struct SimpleLogOpts { // If a certain log level is enabled bool Error = false, Info = false, Debug = false; static inline SimpleLogOpts All() { return {true, true, true}; } // If any of the log level is enabled inline operator bool() const { return Error || Info || Debug; } }; // The object we are going to work on later SimpleLogOpts SLG;
SimpleLogOpts
中的bool
字段 –Error
、Info
和Debug
– 代表由我们的自定义标志启用的日志级别。我们还定义了一个辅助函数SimpleLogOpts::All()
,用于创建一个所有日志级别都启用的SimpleLogOpts
,以及一个bool
类型转换运算符,这样我们就可以使用更简洁的语法,如下所示,来告诉我们是否启用了任何级别:if (SLG) { // At least one log level is enabled! }
-
让我们先处理最简单的情况 –
-fuse-simple-log
标志。在这个步骤中,当我们看到-fuse-simple-log
标志时,我们只会在SLG
中打开所有日志级别。在
Clang::ConstructJob
方法内部,用户提供的驱动标志存储在Args
变量中(ConstructJob
的一个参数),该变量是ArgList
类型。查询Args
有许多方法,但在这里,因为我们只关心-fuse-simple-log
的 存在性,所以hasArg
是最合适的选择:if (Args.hasArg(options::OPT_fuse_simple_log)) { SLG = SimpleLogOpts::All(); }
在之前的代码中,我们通过 TableGen 语法声明的每个标志都将由
options
命名空间下的一个唯一的 enum 来表示。在这种情况下,枚举值是OPT_fuse_simple_log
。枚举值的名称通常是OPT_
,后面跟着我们在声明标志时使用的def
或defm
)。如果ArgList::hasArg
函数返回 true,则表示给定的标志标识符存在于输入驱动标志中。除了
-fuse-simple-log
之外,当提供-fuse-simple-log=<文件路径>
时,我们还需要打开所有日志级别,尽管我们稍后只处理跟随的文件路径。因此,我们将前面的代码片段更改为以下内容:if (Args.hasArg(options::OPT_fuse_simple_log, options::OPT_fuse_simple_log_EQ)) { SLG = SimpleLogOpts::All(); }
ArgList::hasArg
实际上可以接受多个标志标识符,如果 任何 一个存在于输入驱动标志中,则返回 true。再次强调,-fuse-simple-log=<…>
标志由OPT_fuse_simple_log_EQ
表示,因为它的 TableGen 记录名称是fuse_simple_log_EQ
。 -
接下来,我们将处理
-fuse-<日志级别>-simple-log
/-fno-use-<日志级别>-simple-log
。以错误级别为例(其他级别的标志使用完全相同的方式,所以这里不展示),在这里,我们利用了ArgList::hasFlag
函数:SLG.Error = Args.hasFlag(options::OPT_fuse_error_simple_log, options::OPT_fno_use_error_simple_log, SLG.Error);
hasFlag
函数将根据第一个(此处为OPT_fuse_error_simple_log
)或第二个(此处为OPT_fno_use_error_simple_log
)参数是否存在于输入驱动标志中返回 true 或 false。如果两个标志都不存在,
hasFlag
将返回由其第三个参数指定的默认值(在这种情况下为SLG.Error
)。使用这种机制,我们已经在本节中提到的(复杂的)规则和标志组合中实现了一些:
a)
-fno-use-<log level>-simple-log
标志可以禁用某些日志打印功能,当-fuse-simple-log
(最初实际上包括所有日志打印功能)存在时。b) 即使没有
-fuse-simple-log
的存在,我们也可以通过使用-fuse-<log level>-simple-log
标志来启用单个日志打印功能。 -
目前,我们只是在玩弄
SimpleLogOpts
数据结构。从下一步开始,我们将根据我们迄今为止构建的SimpleLogOpts
实例开始生成前端标志。我们在这里生成的前端第一个标志是-include <file path>
。首先,如果至少已经启用了一个日志级别,那么继续才有意义。因此,我们将通过检查SLG
(正如我们之前解释的那样)用if
语句包装-include
的生成:if (SLG) { CmdArgs.push_back("-include"); … }
CmdArgs
(Clang::ConstructJob
内的一个局部变量,具有类似向量的类型)是我们将放置CmdArgs
的地方,它将被视为argv
,我们可以在 C/C++ 的main
函数中看到,任何单个参数内的空白都会在参数实现时造成失败。相反,我们正在单独推送简单日志头文件的路径,如下所示:
if (SLG) { CmdArgs.push_back("-include"); if (Arg *A = Args.getLastArg(options::OPT_fuse_simple_ log_EQ, options::OPT_fsimple_log_path_EQ)) CmdArgs.push_back(A->getValue()); else CmdArgs.push_back("simple_log.h"); … }
ArgList::getLastArg
函数将检索值(如果有多个相同的标志,则为最后一个值),跟随给定的标志,如果这些标志都不存在,则返回 null。例如,在这种情况下,标志是-fuse-simple-log=
(第二个参数中的-fsimple-log-path=
只是第一个标志的别名标志)。 -
最后,我们正在生成前端标志,以控制哪些日志打印功能应该被启用。同样,我们在这里只展示了其中一个日志级别的代码,因为其他级别使用的是相同的方法:
if (SLG) { … if (SLG.Error) { CmdArgs.push_back("-D"); CmdArgs.push_back("SLG_ENABLE_ERROR"); } … }
这些基本上是我们项目所需的全部修改。在我们继续之前,我们必须验证我们的工作。回想一下 -###
命令行标志,它用于打印传递给前端的所有标志。我们在这里使用它来查看我们的自定义驱动标志是否被正确转换。
首先,让我们尝试这个命令:
$ clang++ -### -fuse-simple-log -c test.cc
输出应包含这些字符串:
"-include" "simple_log.h" "-D" "SLG_ENABLE_ERROR" "-D" "SLG_ENABLE_INFO" "-D" "SLG_ENABLE_DEBUG"
现在,让我们尝试以下命令:
$ clang++ -### -fuse-simple-log=my_log.h -fno-use-error-simple-log -c test.cc
输出应包含这些字符串:
"-include" "my_log.h" "-D" "SLG_ENABLE_INFO" "-D" "SLG_ENABLE_DEBUG"
最后,让我们使用以下命令:
$ clang++ -### -fuse-info-simple-log -fsimple-log-path=my_log.h -c test.cc
输出应包含以下字符串:
"-include" "my_log.h" "-D" "SLG_ENABLE_INFO"
在本节的最后一个小节中,我们将讨论一些向前端传递标志的杂项方法。
向前端传递标志
在前几节中,我们展示了驱动程序标志和前端标志之间的区别,它们是如何相关的,以及 Clang 的驱动程序如何将前者转换为后者。在这个时候,你可能想知道,我们能否跳过驱动程序,直接将标志传递给前端?我们允许传递哪些标志?
对于第一个问题的简短答案是是的,实际上你已经在之前的章节中这样做了好几次。回想一下,在第七章,处理抽象语法树中,我们开发了一个插件——更具体地说,是一个抽象语法树插件。我们使用像这里显示的命令行参数来在 Clang 中加载和运行我们的插件:
$ clang++ -fplugin=MyPlugin.so \
-Xclang -plugin -Xclang ternary-converter \
-fsyntax-only test.cc
你可能已经发现,我们需要在 -plugin
和 ternary-converter
参数之前先使用 -Xclang
标志。答案是简单的:这是因为 -plugin
(及其值 ternary-converter
)是一个仅前端标志。
要直接将标志传递给前端,我们可以在其前面加上 -Xclang
。但是使用 -Xclang
有一个注意事项:单个 -Xclang
只会转达一个随后的命令行参数(一个没有空白的字符串)到前端。换句话说,你不能像这样重写前面的插件加载示例:
# Error: `ternary-converter` will not be recognized
$ clang++ -fplugin=MyPlugin.so \
-Xclang -plugin ternary-converter \
-fsyntax-only test.cc
这是因为 -Xclang
只会将 -plugin
传递到前端,而将 ternary-converter
留在后面,在这种情况下,Clang 将无法知道要运行哪个插件。
向前端直接传递标志的另一种方法是将 -cc1
使用。回想一下,当我们使用 -###
在前几节打印由驱动程序翻译的前端标志时,在这些前端标志中,第一个跟随 clang
可执行文件路径的总是 -cc1
。这个标志有效地收集所有命令行参数并将它们发送到前端。虽然这看起来很方便——我们不再需要将每个想要传递给前端标志的前缀为 -Xclang
——但请注意,你不允许在该标志列表中混合任何仅驱动程序标志。例如,在本节早期,当我们使用 TableGen 语法声明我们的 -fuse-simple-log
标志时,我们用 NoXarchOption
注释了该标志,这表示它只能由驱动程序使用。在这种情况下,-fuse-simple-log
不能出现在 -cc1
之后。
这引出了我们的最终问题:驱动程序或前端可以使用哪些标志,以及哪些标志被两者都接受?答案实际上可以通过刚刚提到的 NoXarchOption
来看到。在 TableGen 语法中声明标志时——无论是为驱动程序还是前端——你可以使用 Flags<…>
TableGen 类及其模板参数来强制执行一些约束。例如,使用以下指令,你可以阻止驱动程序使用 -foo
标志:
def foo : Flag<["-"], "foo">, Flags<[NoDriverOption]>;
除了 NoXarchOption
和 NoDriverOption
之外,这里还有一些你可以在 Flags<…>
中使用的其他常见注释:
-
CoreOption
:表示此标志可以被clang
和clang-cl
共享。clang-cl
是一个有趣的驱动程序,它与 Microsoft Visual Studio 使用的命令行界面(包括命令行参数)兼容。 -
CC1Option
:表示此标志可以被前端接受。但这并不意味着它是一个仅限前端使用的标志。 -
Ignored
:表示此标志将被 Clang 驱动程序忽略(但继续编译过程)。GCC 有许多标志在 Clang 中不受支持(要么已过时,要么根本不适用)。然而,Clang 实际上试图识别这些标志,但除了显示关于缺少实现的警告消息外,不做任何操作。背后的理由是我们希望 Clang 可以在不修改许多项目中现有的构建脚本的情况下(没有这个兼容层,当 Clang 看到未知标志时将终止编译)。
在本节中,我们学习了如何为 Clang 的驱动程序添加自定义标志,并实现了将它们转换为前端标志的逻辑。当你想要以更直接和简洁的方式切换自定义功能时,这项技能非常有用。
在下一节中,我们将通过创建自己的自定义工具链来学习工具链的作用以及它在 Clang 中的工作原理。
添加自定义工具链
在前一节中,我们学习了如何在 Clang 中为驱动程序添加自定义标志,并了解了驱动程序如何将它们转换为前端接受的标志。在本节中,我们将讨论工具链——驱动程序内部的一个重要模块,它帮助驱动程序适应不同的平台。
回想一下,在本章的第一节“理解 Clang 中的驱动程序和工具链”中,我们展示了驱动程序和工具链之间的关系——图 8.1:驱动程序根据目标平台选择合适的工具链,然后利用其知识执行以下操作:
-
执行正确的汇编器、链接器或任何用于生成目标代码所需的工具。
-
将平台特定的标志传递给编译器、汇编器或链接器。
这些信息对于构建源代码至关重要,因为每个平台可能都有其独特的特性,例如系统库路径和支持的汇编器/链接器变体。没有它们,甚至无法生成正确的可执行文件或库。
本节旨在教会你如何在将来为定制平台创建 Clang 工具链。Clang 的工具链框架足够强大,可以适应各种用例。例如,你可以创建一个类似于 Linux 上传统编译器的工具链——包括使用 GNU AS 进行汇编和 GNU LD 进行链接——而无需对默认库路径或编译器标志进行许多自定义。另一方面,你可以有一个异国风情的工具链,甚至不使用 Clang 编译源代码,并使用专有汇编器和链接器以及不常见的命令行标志。本节将尝试使用一个示例,以涵盖最常见的用例,同时不遗漏这个框架的灵活方面。
本节组织如下:通常,我们将从将要工作的项目概述开始。之后,我们将把项目工作量分解为三个部分——添加自定义编译器选项、设置自定义汇编器和设置自定义链接器——在我们把它们组合起来完成本节之前。
系统要求
作为另一个友好的提醒,以下项目只能在 Linux 系统上运行。请确保已安装 OpenSSL。
项目概述
我们将在链接阶段创建一个名为 .tarbell
文件的工具链。
Base64
Base64 是一种常用的编码方案,用于将二进制转换为纯文本。它可以在不支持二进制格式的环境中轻松传输(例如,HTTP 头部)。你还可以将 Base64 应用于普通文本文件,就像在我们的案例中一样。
这个工具链在生产环境中基本上是没用的。它仅仅是一个演示,模拟了开发者在为定制平台创建新工具链时可能遇到的一些常见情况。
此工具链通过自定义驱动器标志 -zipline
/--zipline
启用。当提供此标志时,首先,编译器会隐式地将 my_include
文件夹添加到你的主目录中,作为搜索头文件的路径之一。例如,回想一下在上一节中,添加自定义驱动器标志,我们的自定义 -fuse-simple-log
标志会隐式地包含一个头文件,simple_log.h
,在输入源代码中:
$ ls
main.cc simple_log.h
$ clang++ -fuse-simple-log -fsyntax-only main.cc
$ # OK
然而,如果 simple_log.h
不在当前目录中,就像前面的片段中那样,我们需要通过另一个标志指定其完整路径:
$ ls .
# No simple_log.h in current folder
main.cc
$ clang++ -fuse-simple-log=/path/to/simple_log.h -fsyntax-only main.cc
$ # OK
在 Zipline 的帮助下,你可以将 simple_log.h
放在 /home/<用户名>/my_include
中,编译器将找到它:
$ ls .
# No simple_log.h in current folder
main.cc
$ ls ~/my_include
simple_log.h
$ clang++ -zipline -fuse-simple-log -fsyntax-only main.cc
$ # OK
Zipline 的第二个特性是,在-c
标志下,clang
可执行文件会将源代码编译成由 Base64 编码的汇编代码,这个标志原本是用来将汇编文件(来自编译器)组装成目标文件的。以下是一个示例命令:
$ clang -zipline -c test.c
$ file test.o
test.o: ASCII text # Not (binary) object file anymore
$ cat test.o
CS50ZXh0CgkuZmlsZQkidGVzdC5jYyIKCS 5nbG9ibAlfWjNmb29pCgkucDJhbGln
bgk0LCAweDkwCgkudHlwZQlfWjNmb29p LEBmdW5jdGlvbgpfWjNmb29pOgoJLmNm
… # Base64 encoded contents
$
前面的file
命令显示,来自之前clang
调用的生成文件test.o
不再是二进制格式对象文件。现在这个文件的内容是编译器后端生成的汇编代码的 Base64 编码版本。
最后,Zipline 用自定义的链接阶段替换了原始的链接阶段,将上述 Base64 编码的汇编文件打包并压缩成.zip
文件。以下是一个示例:
$ clang -zipline test.c -o test.zip
$ file test.zip
test.zip: Zip archive, at least v2.0 to extract
$
如果你解压test.zip
,你会发现那些提取的文件是 Base64 编码的汇编文件,正如我们之前提到的。
或者,我们可以在 Zipline 中使用 Linux 的tar
和gzip
实用工具来打包和压缩它们。让我们看看一个示例:
$ clang -zipline -fuse-ld=tar test.c -o test.tar.gz
$ file test.tar.gz
test.tar.gz: gzip compressed data, from Unix, original size…
$
通过使用现有的-fuse-ld=<linker name>
标志,我们可以在自定义链接阶段之间选择使用zip
或tar
和gzip
。
在下一节中,我们将为这个工具链创建骨架代码,并展示如何将额外的文件夹添加到头文件搜索路径中。
创建工具链并添加自定义包含路径
在本节中,我们将为我们的 Zipline 工具链创建骨架,并展示如何将额外的包含文件夹路径——更具体地说,一个额外的系统包含路径——添加到 Zipline 的编译阶段。以下是详细步骤:
-
在我们添加实际的工具链实现之前,别忘了我们将使用自定义驱动程序标志
-zipline
/--zipline
来启用我们的工具链。让我们使用在上一节中学到的相同技能,添加自定义驱动程序标志,来完成这个任务。在clang/include/clang/Driver/Options.td
内部,我们将添加以下行:// zipline toolchain def zipline : Flag<["-", "--"], "zipline">, Flags<[NoXarchOption]>;
再次强调,
Flag
告诉我们这是一个布尔标志,而NoXarchOption
告诉我们这个标志仅适用于驱动程序。我们将在不久的将来使用这个驱动程序标志。 -
在 Clang 中,工具链由
clang::driver::ToolChain
类表示。Clang 支持的每个工具链都从它派生出来,它们的源文件都放在clang/lib/Driver/ToolChains
文件夹下。我们将在那里创建两个新文件:Zipline.h
和Zipline.cpp
。 -
对于
Zipline.h
,我们首先添加以下骨架代码:namespace clang { namespace driver { namespace toolchains { struct LLVM_LIBRARY_VISIBILITY ZiplineToolChain : public Generic_ELF { ZiplineToolChain(const Driver &D, const llvm::Triple &Triple, const llvm::opt::ArgList &Args) : Generic_ELF(D, Triple, Args) {} ~ZiplineToolChain() override {} // Disable the integrated assembler bool IsIntegratedAssemblerDefault() const override { return false; } bool useIntegratedAs() const override { return false; } void AddClangSystemIncludeArgs(const llvm::opt::ArgList &DriverArgs, llvm::opt::ArgStringList &CC1Args) const override; protected: Tool *buildAssembler() const override; Tool *buildLinker() const override; }; } // end namespace toolchains } // end namespace driver } // end namespace clang
我们在这里创建的类
ZiplineToolChain
是从Generic_ELF
派生出来的,Generic_ELF
是ToolChain
的一个子类,专门用于使用 ELF 作为其执行格式的系统——包括 Linux。除了父类之外,我们将在本节或后续章节中实现三个重要方法:AddClangSystemIncludeArgs
、buildAssembler
和buildLinker
。 -
buildAssembler
和buildLinker
方法生成代表AddClangSystemIncludeArgs
方法的Tool
实例。在Zipline.cpp
中,我们将添加其方法体:void ZiplineToolChain::AddClangSystemIncludeArgs( const ArgList &DriverArgs, ArgStringList &CC1Args) const { using namespace llvm; SmallString<16> CustomIncludePath; sys::fs::expand_tilde("~/my_include", CustomIncludePath); addSystemInclude(DriverArgs, CC1Args, CustomIncludePath.c_str()); }
我们在这里所做的唯一事情是调用
addSystemInclude
函数,并传入位于主目录中的my_include
文件夹的完整路径。由于每个用户的家目录都不同,我们使用sys::fs::expand_tilde
辅助函数来展开~/my_include
– 其中~
代表 Linux 和 Unix 系统中的主目录 – 到绝对路径。另一方面,addSystemInclude
函数可以帮助你将"-internal-isystem" "/path/to/my_include"
标志添加到所有前端标志的列表中。-internal-isystem
标志用于指定系统头文件文件夹,包括标准库头文件和一些特定平台的头文件。 -
最后但同样重要的是,我们需要教会驱动程序在看到我们新创建的
-zipline
/--zipline
驱动标志时使用 Zipline 工具链。我们将修改clang/lib/Driver/Driver.cpp
中的Driver::getToolChain
方法来实现这一点。Driver::getToolChain
方法包含一个巨大的 switch case,用于根据目标操作系统和硬件架构选择不同的工具链。请导航到处理 Linux 系统的代码;我们将在那里添加一个额外的分支条件:const ToolChain &Driver::getToolChain(const ArgList &Args, const llvm::Triple &Target) const { … switch (Target.getOS()) { case llvm::Triple::Linux: … else if (Args.hasArg(options::OPT_zipline)) TC = std::make_unique<toolchains::ZiplineToolChain> (*this, Target, Args); … break; case … case … } }
这个额外的
else-if
语句基本上表示,如果目标操作系统是 Linux,那么当给出-zipline
/--zipline
标志时,我们将使用 Zipline。
通过这样,你已经添加了 Zipline 的骨架,并成功告诉驱动程序在给出自定义驱动标志时使用 Zipline。除此之外,你还学习了如何将额外的系统库文件夹添加到头文件搜索路径中。
在下一节中,我们将创建一个自定义汇编阶段并将其连接到我们在这里创建的工具链。
创建自定义汇编阶段
正如我们在 项目概述 部分中提到的,在 Zipline 的汇编阶段,我们不是进行常规的汇编来将汇编代码转换为对象文件,而是调用一个程序将我们从 Clang 生成的汇编文件转换为它的 Base64 编码的对应文件。在我们深入其实现之前,让我们先了解工具链中每个 阶段 的表示方法。
在上一节中,我们了解到在 Clang 中,工具链由 ToolChain
类表示。每个这些 ToolChain
实例都负责告诉驱动程序在每个编译阶段运行哪个 工具 —— 也就是编译、汇编和链接。这些信息封装在一个 clang::driver::Tool
类型的对象中。回想一下上一节中的 buildAssembler
和 buildLinker
方法;它们返回的正是描述汇编和链接阶段要执行的操作以及要运行的工具的 Tool
类型对象。在本节中,我们将向您展示如何实现由 buildAssembler
返回的 Tool
对象。让我们开始吧:
-
让我们先回到
Zipline.h
。在这里,我们在clang::driver::tools::zipline
命名空间内添加了一个额外的类,Assembler
:namespace clang { namespace driver { namespace tools { namespace zipline { struct LLVM_LIBRARY_VISIBILITY Assembler : public Tool { Assembler(const ToolChain &TC) : Tool("zipeline::toBase64", "toBase64", TC) {} bool hasIntegratedCPP() const override { return false; } void ConstructJob(Compilation &C, const JobAction &JA, const InputInfo &Output, const InputInfoList &Inputs, const llvm::opt::ArgList &TCArgs, const char *LinkingOutput) const override; }; } // end namespace zipline } // end namespace tools namespace toolchains { struct LLVM_LIBRARY_VISIBILITY ZiplineToolChain … { … }; } // end namespace toolchains } // end namespace driver } // end namespace clang
请注意,新创建的
Assembler
位于clang::driver::tools::zipline
命名空间中,而我们在上一节中创建的ZiplineToolChain
位于clang::driver::toolchains
中。Assembler::ConstructJob
方法是我们将放置调用 Base64 编码工具逻辑的地方。 -
在
Zipline.cpp
内部,我们将实现Assembler::ConstructJob
方法的主体:void tools::zipline::Assembler::ConstructJob(Compilation &C, const JobAction &JA, const InputInfo &Output, const InputInfoList &Inputs, const ArgList &Args, const char *LinkingOutput) const { ArgStringList CmdArgs; const InputInfo &II = Inputs[0]; std::string Exec = Args.MakeArgString(getToolChain(). GetProgramPath("openssl")); // opeenssl base64 arguments CmdArgs.push_back("base64"); CmdArgs.push_back("-in"); CmdArgs.push_back(II.getFilename()); CmdArgs.push_back("-out"); CmdArgs.push_back(Output.getFilename()); C.addCommand( std::make_unique<Command>( JA, *this, ResponseFileSupport::None(), Args.MakeArgString(Exec), CmdArgs, Inputs, Output)); }
我们使用 OpenSSL 进行 Base64 编码,我们希望运行的命令如下:
$ openssl base64 -in <input file> -out <output file>
ConstructJob
方法的任务是构建一个 程序调用 来运行之前的命令。这是通过在ConstructJob
的最后调用C.addCommand(…)
函数实现的。传递给addCommand
调用的Command
实例代表在汇编阶段要运行的具体命令。它包含必要的信息,例如程序可执行文件的路径(Exec
变量)及其参数(CmdArgs
变量)。对于
Exec
变量,工具链提供了一个方便的实用工具,即GetProgramPath
函数,以为您解析可执行文件的绝对路径。与我们在 添加自定义驱动程序标志 部分所做的事情类似,我们构建
openssl
(CmdArgs
变量)的参数方式非常相似:将驱动程序标志(Args
参数)和输入/输出文件信息(Output
和Inputs
参数)转换成一组新的命令行参数,并将它们存储在CmdArgs
中。 -
最后,我们通过实现
ZiplineToolChain::buildAssembler
方法将这个Assembler
类与ZiplineToolChain
连接起来:Tool *ZiplineToolChain::buildAssembler() const { return new tools::zipline::Assembler(*this); }
这些就是我们创建代表 Zipline 工具链链接阶段运行命令的 Tool
实例所需遵循的所有步骤。
创建自定义链接阶段
现在我们已经完成了汇编阶段,是时候进入下一个阶段——链接阶段了。我们将使用与上一节相同的方法;也就是说,我们将创建一个自定义的 Tool
类来表示链接器。以下是步骤:
-
在
Zipline.h
内部,创建一个从Tool
派生的Linker
类:namespace zipline { struct LLVM_LIBRARY_VISIBILITY Assembler : public Tool { … }; struct LLVM_LIBRARY_VISIBILITY Linker : public Tool { Linker(const ToolChain &TC) : Tool("zipeline::zipper", "zipper", TC) {} bool hasIntegratedCPP() const override { return false; } bool isLinkJob() const override { return true; } void ConstructJob(Compilation &C, const JobAction &JA, const InputInfo &Output, const InputInfoList &Inputs, const llvm::opt::ArgList &TCArgs, const char *LinkingOutput) const override; private: void buildZipArgs(const JobAction&, const InputInfo&, const InputInfoList&, const llvm::opt::ArgList&, llvm::opt::ArgStringList&) const; void buildTarArgs(const JobAction&, const InputInfo&, const InputInfoList&, const llvm::opt::ArgList&, llvm::opt::ArgStringList&) const; }; } // end namespace zipline
在这个
Linker
类中,我们还需要实现ConstructJob
方法,以告诉驱动器在链接阶段要执行什么。与Assembler
不同,由于我们需要支持zip
和tar
+gzip
打包/压缩方案,我们将添加两个额外的方法,buildZipArgs
和buildTarArgs
,以处理每个的参数构建。 -
在
Zipline.cpp
中,我们将首先关注Linker::ConstructJob
的实现:void tools::zipline::Linker::ConstructJob(Compilation &C, const JobAction &JA, const InputInfo &Output, const InputInfoList &Inputs, const ArgList &Args, const char *LinkingOutput) const { ArgStringList CmdArgs; std::string Compressor = "zip"; if (Arg *A = Args.getLastArg(options::OPT_fuse_ld_EQ)) Compressor = A->getValue(); std::string Exec = Args.MakeArgString( getToolChain().GetProgramPath(Compressor.c_str())); if (Compressor == "zip") buildZipArgs(JA, Output, Inputs, Args, CmdArgs); if (Compressor == "tar" || Compressor == "gzip") buildTarArgs(JA, Output, Inputs, Args, CmdArgs); else llvm_unreachable("Unsupported compressor name"); C.addCommand( std::make_unique<Command>( JA, *this, ResponseFileSupport::None(), Args.MakeArgString(Exec), CmdArgs, Inputs, Output)); }
在这个自定义链接阶段,我们希望使用
zip
命令或tar
命令——根据用户指定的-fuse-ld
标志——来打包所有由我们的自定义Assembler
生成的(Base64 编码的)文件。zip
和tar
的详细命令格式将很快解释。从前面的代码片段中,我们可以看到我们在这里所做的是与Assembler::ConstructJob
类似的。Exec
变量携带到zip
或tar
程序的绝对路径;CmdArgs
变量,由buildZipArgs
或buildTarArgs
(稍后解释)填充,携带工具的命令行参数(zip
或tar
)。与
Assembler::ConstructJob
相比,最大的不同之处在于可以通过用户提供的-fuse-ld
标志指定要执行的命令。因此,我们正在使用我们在 添加自定义驱动器标志 部分学到的技能来读取该驱动器标志并设置命令。 -
如果您的用户决定将文件打包成 ZIP 文件(这是默认方案,或者您可以通过
-fuse-ld=zip
明确指定它),我们将运行以下命令:$ zip <output zip file> <input file 1> <input file 2>…
因此,我们将构建我们的
Linker::buildZipArgs
方法,该方法构建前面命令的参数列表,如下所示:void tools::zipline::Linker::buildZipArgs(const JobAction &JA, const InputInfo &Output, const InputInfoList &Inputs, const ArgList &Args, ArgStringList &CmdArgs) const { // output file CmdArgs.push_back(Output.getFilename()); // input files AddLinkerInputs(getToolChain(), Inputs, Args, CmdArgs, JA); }
Linker::buildZipArgs
的CmdArgs
参数将是我们将导出结果的地方。虽然我们仍然使用相同的方式获取输出文件名(通过Output.getFilename()
),但由于链接器可能一次接受多个输入,我们正在利用另一个辅助函数AddLinkerInputs
来为我们添加所有输入文件名到CmdArgs
。 -
如果您的用户决定使用
tar
+gzip
打包方案(通过-fuse-ld=tar
或-fuse-ld=gzip
标志),我们将运行以下命令:$ tar -czf <output tar.gz file> <input file 1> <input file 2>…
因此,我们将构建我们的
Linker::buildTarArgs
方法,该方法构建前面命令的参数列表,如下所示:void tools::zipline::Linker::buildTarArgs(const JobAction &JA, const InputInfo &Output, const InputInfoList &Inputs, const ArgList &Args, ArgStringList &CmdArgs) const { // arguments and output file CmdArgs.push_back("-czf"); CmdArgs.push_back(Output.getFilename()); // input files AddLinkerInputs(getToolChain(), Inputs, Args, CmdArgs, JA); }
就像
buildZipArgs
一样,我们通过Output.getFilename()
获取输出文件名,并使用AddLinkerInput
将所有输入文件名添加到CmdArgs
中。 -
最后但同样重要的是,让我们将我们的
Linker
连接到ZiplineToolChain
:Tool *ZiplineToolChain::buildLinker() const { return new tools::zipline::Linker(*this); }
实现我们 Zipline 工具链的自定义链接阶段的步骤就到这里了。
现在我们已经为 Zipline 工具链创建了必要的组件,当用户选择此工具链时,我们可以执行我们的自定义功能——编码源文件并将它们打包成归档文件。在下一节中,我们将学习如何验证这些功能。
验证自定义工具链
要测试本章中实现的功能,我们可以运行项目概述中描述的示例命令,或者我们可以再次利用-###
驱动程序标志来转储所有预期的编译器、汇编器和链接器命令详情。
到目前为止,我们已经了解到-###
标志将显示由驱动程序翻译的所有前端标志。但实际上,它还会显示已安排运行的汇编器和链接器命令。例如,让我们调用以下命令:
$ clang -### -zipline -c test.c
由于-c
标志总是尝试在 Clang 生成的汇编文件上运行汇编器,因此我们的自定义汇编器(即 Base64 编码器)将在 Zipline 中被触发。因此,您将看到以下类似的输出:
$ clang -### -zipline -c test.c
"/path/to/clang" "-cc1" …
"/usr/bin/openssl" "base64" "-in" "/tmp/test_ae4f5b.s" "-out" "test.o"
$
以/path/to/clang -cc1
开头的行包含我们之前了解的前端标志。接下来的行是汇编器调用命令。在这种情况下,运行openssl
以执行 Base64 编码。
注意,奇特的/tmp/test_ae4f5b.s
文件名是由驱动程序创建的临时文件,用于容纳编译器生成的汇编代码。
使用相同的技巧,我们可以验证我们的自定义链接阶段,如下所示:
$ clang -### -zipline test.c -o test.zip
"/path/to/clang" "-cc1" …
"/usr/bin/openssl" "base64" "-in" "/tmp/test_ae4f5b.s" "-out" "/tmp/test_ae4f5b.o"
"/usr/bin/zip" "test.zip" "/tmp/test_ae4f5b.o"
$
由于前一个命令使用了-o
标志,Clang 将构建一个完整的可执行文件,从test.c
开始,涉及汇编器和链接器。因此,由于zip
命令从上一个汇编阶段获取结果(/tmp/test_ae4f5b.o
文件),我们的自定义链接阶段在这里。您可以随意添加-fuse-ld=tar
标志来查看zip
命令用完全不同的参数列表替换tar
命令。
在本节中,我们向您展示了如何创建 Clang 驱动程序的工具链。这是在自定义或新平台上支持 Clang 的关键技能。我们还了解到,Clang 的工具链框架是灵活的,可以处理目标平台所需的各种任务。
摘要
在本章中,我们首先介绍了 Clang 的驱动程序和工具链的作用——提供特定平台信息的模块,例如支持的汇编器和链接器——它协助了它。然后,我们向您展示了定制驱动程序最常见的方法之一——添加新的驱动程序标志。之后,我们讨论了工具链,最重要的是,如何创建一个自定义工具链。当您想在 Clang(甚至 LLVM)中创建新功能并需要自定义编译器标志来启用它时,这些技能非常有用。此外,开发自定义工具链的能力对于在新的操作系统或新的硬件架构上支持 Clang 至关重要。
这是本书第二部分的最后一章。从下一章开始,我们将讨论 LLVM 的中端——平台无关的程序分析和优化框架。
练习
-
覆盖汇编和链接阶段是很常见的,因为不同的平台往往支持不同的汇编器和链接器。然而,是否有可能覆盖 编译 阶段(即 Clang)?如果可能,我们该如何做?为什么人们可能希望这样做?
-
当我们正在处理
tools::zipline::Linker::ConstructJob
时,我们简单地使用llvm_unreachable
来退出编译过程,如果用户通过-fuse-ld
标志提供了一个不支持的压缩器名称。我们能否用我们在 第七章 中学到的 Clang 的 诊断 框架来替换它,以打印出更好的消息? -
就像我们可以使用
-Xclang
直接将标志传递给前端一样,我们也可以通过驱动器标志,如-Wa
(用于汇编器)或-Wl
(用于链接器),直接将汇编器特定的或链接器特定的标志传递给汇编器或链接器。我们如何在 Zipline 的自定义汇编器和链接器阶段消耗这些标志?
第三部分:“中端开发”
目标无关的转换和分析,也称为 LLVM 中的“中端”,是整个框架的核心。尽管你可以在网上找到大部分关于这部分资源的资料,但仍有许多隐藏的功能可以提升你的开发体验,以及许多可能导致你突然跌入深渊的陷阱。在本节中,我们将为你准备 LLVM 中端开发的旅程。本节包括以下章节:
-
第九章,与 PassManager 和 AnalysisManager 协同工作
-
第十章,处理 LLVM IR
-
第十一章,使用支持工具准备就绪
-
第十二章,学习 LLVM IR 工具
第九章:第九章:使用 PassManager 和 AnalysisManager
在本书的前一节前端开发中,我们开始介绍了 Clang 的内部结构,它是 LLVM 为 C 系列编程语言提供的官方前端。我们探讨了各种项目,涉及技能和知识,这些可以帮助你处理与源代码紧密相关的问题。
在本书的这一部分,我们将使用LLVM IR – 一种针对编译优化和代码生成的目标无关的中间表示(IR)。与 Clang 的抽象语法树(AST)相比,LLVM IR 通过封装额外的执行细节提供了不同层次的抽象,从而能够实现更强大的程序分析和转换。除了 LLVM IR 的设计,围绕这种 IR 格式还有一个成熟的生态系统,它提供了无数的资源,如库、工具和算法实现。我们将涵盖 LLVM IR 的多个主题,包括最常见的 LLVM Pass 开发、使用和编写程序分析,以及与 LLVM IR API 一起工作的最佳实践和技巧。此外,我们还将回顾更高级的技能,如程序引导优化(PGO)和 sanitizer 开发。
在本章中,我们将讨论编写用于新PassManager的转换Pass和程序分析。LLVM Pass 是整个项目中最为基础和关键的概念之一。它允许开发者将程序处理逻辑封装成一个模块化的单元,该单元可以通过PassManager根据情况自由组合其他 Pass。在 Pass 基础设施的设计方面,LLVM 实际上对 PassManager 和 AnalysisManager 都进行了彻底的改造,以提高它们的运行时性能和优化质量。新的 PassManager 为其封装的 Pass 使用了相当不同的接口。然而,这个新接口与旧接口不兼容,这意味着你无法在新 PassManager 中运行旧 Pass,反之亦然。更糟糕的是,网上关于这个新接口的学习资源并不多,尽管现在它们在 LLVM 和 Clang 中默认启用。本章的内容将填补这一空白,并提供关于 LLVM 中这一关键子系统的最新指南。
在本章中,我们将涵盖以下主题:
-
为新的 PassManager 编写 LLVM Pass
-
使用新的 AnalysisManager
-
在新的 PassManager 中学习 instrumentations
通过本章学习到的知识,你应该能够编写一个 LLVM Pass,使用新的 Pass 基础设施,来转换甚至优化你的输入代码。你也可以通过利用 LLVM 程序分析框架提供的分析数据来进一步提高你 Pass 的质量。
技术要求
在本章中,我们将主要使用一个名为opt
的命令行工具来测试我们的 Pass。您可以使用以下命令构建它:
$ ninja opt
本章的代码示例可以在github.com/PacktPublishing/LLVM-Techniques-Tips-and-Best-Practices-Clang-and-Middle-End-Libraries/tree/main/Chapter09
找到。
为新的 PassManager 编写 LLVM Pass
LLVM 中的 Pass是执行针对 LLVM IR 的某些操作所需的基本单元。它类似于工厂中的一个单一生产步骤,其中需要处理的产品是 LLVM IR,而工厂工人是 Pass。同样,一个正常的工厂通常有多个制造步骤,LLVM 也由多个按顺序执行的 Pass 组成,称为Pass 流水线。图 9.1显示了 Pass 流水线的一个示例:
![图 9.1 – LLVM Pass 流水线和其中间结果的示例
![img/Figure_9.1_B14590.jpg]
图 9.1 – LLVM Pass 流水线和其中间结果的示例
在前面的图中,多个 Pass 按直线排列。foo
函数的 LLVM IR 被一个 Pass 接着另一个 Pass 处理。foo
和将一个乘以 2 的算术乘法(mul
)替换为左移(shl
)1,这在大多数硬件架构中被认为比乘法更容易。此外,此图还说明了代码生成步骤被建模为 Pass。在 LLVM 中,代码生成将目标无关的 LLVM IR 转换为特定硬件架构的汇编代码(例如,图 9.1中的x86_64)。每个详细过程,如寄存器分配、指令选择或指令调度,都被封装到一个单独的 Pass 中,并按一定顺序执行。
代码生成 Pass
代码生成 Pass 具有与正常 LLVM IR Pass 不同的 API。此外,在代码生成阶段,LLVM IR 实际上被转换为另一种类型的 IR,称为机器 IR(MIR)。然而,在本章中,我们只将涵盖 LLVM IR 及其 Pass。
这个 Pass 流水线在概念上由一个名为PassManager的基础设施管理。PassManager 拥有计划 – 例如,它们的执行顺序 – 来运行这些 Pass。传统上,我们实际上使用Pass 流水线和PassManager这两个术语互换,因为它们几乎有相同的任务。在新 PassManager 中的学习工具部分,我们将更详细地介绍流水线本身,并讨论如何自定义这些封装 Pass 的执行顺序。
现代编译器中的代码转换可能很复杂。正因为如此,多个转换 Pass 可能需要相同的一组程序信息,这在 LLVM 中被称为分析,以便完成它们的工作。此外,为了达到最大效率,LLVM 还会缓存这些分析数据,以便在可能的情况下重用。然而,由于转换 Pass 可能会更改 IR,一些之前收集的缓存分析数据在运行该 Pass 后可能会过时。为了解决这些挑战,除了 PassManager 之外,LLVM 还创建了AnalysisManager来管理与程序分析相关的所有内容。我们将在使用新的 AnalysisManager部分中深入了解 AnalysisManager。
如本章引言中所述,LLVM 对其 Pass 和 PassManager(以及 AnalysisManager)基础设施进行了一系列的重大改造。新的基础设施运行速度更快,生成的结果质量更好。尽管如此,新的 Pass 与旧的一个有很多不同之处;我们将在途中简要解释这些差异。然而,除了这一点之外,我们将在本章的其余部分默认只讨论新的 Pass 基础设施。
在本节中,我们将向您展示如何为新的 PassManager 开发一个简单的 Pass。像往常一样,我们将从描述我们即将使用的示例项目开始。然后,我们将向您展示使用opt
实用程序创建一个 Pass 的步骤,该 Pass 可以从插件动态加载到之前提到的 Pass 管道中。
项目概述
在本节中,我们使用的示例项目被称为noalias
属性,将其应用于所有具有指针类型的函数参数。实际上,它向 C 代码中的函数参数添加了restrict
关键字。首先,让我们解释一下restrict
关键字的作用。
C 和 C++中的restrict
关键字
restrict
关键字是在 C99 中引入的。然而,它在 C++中没有对应的关键字。不过,主流编译器如 Clang、GCC 和 MSVS 都在 C++中支持相同的功能。例如,在 Clang 和 GCC 中,您可以在 C++代码中使用__restrict__
或__restrict
,它具有与 C 中restrict
相同的效果。
restrict
关键字也可以与 C 中的指针类型变量一起使用。在最常见的案例中,它与指针类型函数参数一起使用。以下是一个示例:
int foo(int* restrict x, int* restrict y) {
*x = *y + 1;
return *y;
}
实际上,这个额外的属性告诉编译器,参数 x
永远不会指向与参数 y
相同的内存区域。换句话说,程序员可以使用这个关键字来说服编译器他们永远不会调用 foo
函数,如下所示:
…
// Programmers will NEVER write the following code
int main() {
int V = 1;
return foo(&V, &V);
}
这背后的原因是,如果编译器知道两个指针——在这种情况下,两个指针参数——永远不会指向相同的内存区域,它可以进行更激进的优化。为了给您一个更具体的理解,如果您比较带有和没有 restrict
关键字的 foo
函数的汇编代码,后者版本在 x86_64 上执行需要五条指令:
foo:
mov eax, dword ptr [rsi]
add eax, 1
mov dword ptr [rdi], eax
mov eax, dword ptr [rsi]
ret
添加了 restrict
关键字的版本仅需要四条指令:
foo:
mov eax, dword ptr [rsi]
lea ecx, [rax + 1]
mov dword ptr [rdi], ecx
ret
虽然这里的差异看起来很微妙,但在没有 restrict
的版本中,编译器需要插入一个额外的内存加载操作来确保最后一个参数 *y
(在原始 C 代码中)总是读取最新的值。这种额外的开销可能会在更复杂的代码库中逐渐累积,并最终成为性能瓶颈。
现在,您已经了解了 restrict
的工作原理及其在确保良好性能方面的重要性。在 LLVM IR 中,也有一个相应的指令来模拟 restrict
关键字:noalias
属性。如果程序员在原始源代码中给出了如 restrict
之类的提示,则此属性附加到指针函数参数上。例如,带有 restrict
关键字的 foo
函数可以转换为以下 LLVM IR:
define i32 @foo(i32* noalias %0, i32* noalias %1) {
%3 = load i32, i32* %1
%4 = add i32 %3, 1
store i32 %4, i32* %0
ret i32 %3
}
此外,我们还可以在 C 代码中生成 foo
函数的 LLVM IR 代码,而不使用 restrict
,如下所示:
define i32 @foo(i32* %0, i32* %1) {
%3 = load i32, i32* %1
%4 = add i32 %3, 1
store i32 %4, i32* %0
%5 = load i32, i32* %1
ret i32 %5
}
在这里,您会发现有一个额外的内存加载(如前一个片段中突出显示的指令所示),这与之前汇编示例中发生的情况类似。也就是说,LLVM 无法执行更激进的优化来删除该内存加载,因为它不确定这些指针是否重叠。
在本节中,我们将编写一个 Pass,将 noalias
属性添加到函数的每个指针参数。该 Pass 将作为插件构建,一旦加载到 opt
中,用户可以使用 --passes
参数显式触发 StrictOpt
,如下所示:
$ opt --load-pass-plugin=StrictOpt.so \
--passes="function(strict-opt)" \
-S -o – test.ll
或者,如果优化级别大于或等于 -O3
,我们可以在其他优化之前运行 StrictOpt
。以下是一个示例:
$ opt -O3 --enable-new-pm \
--load-pass-plugin=StrictOpt.so \
-S -o – test.ll
我们将很快向您展示如何在这两种模式之间切换。
仅用于演示的 Pass
注意,StrictOpt
仅仅是一个仅用于演示的 Pass,并且将 noalias
添加到每个指针函数参数绝对不是您在现实世界用例中应该做的事情。这是因为这可能会破坏目标程序的正确性。
在下一节中,我们将向您展示创建此 Pass 的详细步骤。
编写 StrictOpt Pass
以下说明将引导您完成开发核心 Pass 逻辑的过程,然后再介绍如何动态地将 StrictOpt
注册到 Pass 管道中:
-
这次我们只有两个源文件:
StrictOpt.h
和StrictOpt.cpp
。在前者文件中,我们放置了StrictOpt
Pass 的框架:#include "llvm/IR/PassManager.h" struct StrictOpt : public Function IR unit. The run method is the primary entry point for this Pass, which we are going to fill in later. It takes two arguments: a Function class that we will work on and a FunctionAnalysisManager class that can give you analysis data. It returns a PreservedAnalyses instance, which tells PassManager (and AnalysisManager) what analysis data was *invalidated* by this Pass.If you have prior experience in writing LLVM Pass for the *legacy* PassManager, you might find several differences between the legacy Pass and the new Pass:a) The Pass class no longer derives from one of the `FunctionPass`, `ModulePass`, or `LoopPass`. Instead, the Passes running on different IR units are all deriving from `PassInfoMixin<YOUR_PASS>`. In fact, deriving from `PassInfoMixin` is *not* even a requirement for a functional Pass anymore – we will leave this as an exercise for you.b) Instead of *overriding* methods, such as `runOnFunction` or `runOnModule`, you will define a normal class member method, `run` (be aware that `run` does *not* have an `override` keyword that follows), which operates on the desired IR unit.Overall, the new Pass has a cleaner interface compared to the legacy one. This difference also allows the new PassManager to have less overhead runtime.
-
为了实现上一步骤中的框架,我们正在前往
StrictOpt.cpp
文件。在这个文件中,首先,我们创建以下方法定义:#include "StrictOpt.h" using namespace llvm; PreservedAnalyses StrictOpt::run(Function &F, FunctionAnalysisManager &FAM) { return PreservedAnalyses::all(); // Just a placeholder }
返回的
PreservedAnalyses::all()
实例只是一个占位符,稍后将被移除。 -
现在,我们最终正在创建代码来向指针函数参数添加
noalias
属性。逻辑很简单:对于Function
类中的每个Argument
实例,如果它满足条件,则附加noalias
:// Inside StrictOpt::run… bool Modified = false; for (auto &Arg : F.args() method of the Function class will return a range of Argument instances representing all of the formal parameters. We check each of their types to make sure there isn't an existing noalias attribute (which is represented by the Attribute::NoAlias enum). If everything looks good, we use addAttr to attach noalias. Here, the `Modified` flag here records whether any of the arguments were modified in this function. We will use this flag shortly.
-
由于转换 Pass 可能会改变程序的 IR,某些分析数据在转换后可能会过时。因此,在编写 Pass 时,我们需要返回一个
PreservedAnalyses
实例来显示哪些分析受到了影响,并且应该进行重新计算。虽然 LLVM 中有大量的分析可用,我们不需要逐一列举它们。相反,有一些方便的实用函数可以创建代表 所有分析 或 没有分析 的PreservedAnalyses
实例,这样我们只需要从其中减去或添加(未)受影响的分析即可。以下是我们在StrictOpt
中所做的工作:#include "llvm/Analysis/AliasAnalysis.h" … // Inside StrictOpt::run… auto PA = PreservedAnalyses instance, PA, which represents *all analyses*. Then, if the Function class we are working on here has been modified, we *discard* the AAManager analysis via the abandon method. AAManager represents the noalias attribute we are discussing here has strong relations with this analysis since they're working on a nearly identical problem. Therefore, if any new noalias attribute was generated, all the cached alias analysis data would be outdated. This is why we invalidate it using abandon.Note that you can always return a `PreservedAnalyses::none()` instance, which tells AnalysisManager to mark *every* analysis as outdated if you are not sure what analyses have been affected. This comes at a cost, of course, since AnalysisManager then needs to spend extra effort to recalculate the analyses that might contain expensive computations.
-
StrictOpt
的核心逻辑基本上已经完成。现在,我们将向您展示如何动态地将 Pass 注册到管道中。在StrictOpt.cpp
中,我们创建了一个特殊的全局函数,称为llvmGetPassPluginInfo
,其轮廓如下:extern "C" ::llvm::PassPluginLibraryInfo instance, which contains various piecesLLVM_PLUGIN_API_VERSION) and the Pass name (StrictOpt). One of its most important fields is a lambda function that takes a single PassBuilder& argument. In that particular function, we are going to insert our StrictOpt into a proper position within the Pass pipeline.`PassBuilder`, as its name suggests, is an entity LLVM that is used to build the Pass pipeline. In addition to its primary job, which involves configuring the pipeline according to the optimization level, it also allows developers to insert Passes into some of the places in the pipeline. Furthermore, to increase its flexibility, `PassBuilder` allows you to specify a *textual* description of the pipeline you want to run by using the `--passes` argument on `opt`, as we have seen previously. For instance, the following command will run `InstCombine`, `PromoteMemToReg`, and `SROA` (`opt` will run our Pass if `strict-opt` appears in the `--passes` argument, as follows:
$ opt registerPipelineParsingCallback 方法在 PassBuilder 中:
… [](PassBuilder &PB) { using PipelineElement = typename PassBuilder::PipelineElement; PB.registerPipelineParsingCallback method takes another lambda callback as the argument. This callback is invoked whenever PassBuilder encounters an unrecognized Pass name while parsing the textual pipeline representation. Therefore, in our implementation, we simply insert our StrictOpt pass into the pipeline via FunctionPassManager::addPass when the unrecognized Pass name, that is, the Name parameter, is strict-opt.
-
或者,我们还想在 Pass 管道开始时触发我们的
StrictOpt
,而不使用文本管道描述,正如我们在 项目概述 部分中描述的那样。这意味着在将 Pass 加载到opt
中使用以下命令后,Pass 将在其他的 Pass 之前运行:$ opt -O2 --enable-new-pm \ --enable-new-pm flag in the preceding command forced opt to use the new PassManager since it's still using the legacy one by default. We haven't used this flag before because --passes implicitly enables the new PassManager under the hood.)To do this, instead of using `PassBuilder::registerPipelineParsingCallback` to register a custom (pipeline) parser callback, we are going to use `registerPipelineStartEPCallback` to handle this. Here is the alternative version of the code snippet from the previous step:
…
[](PassBuilder &PB) {
using OptimizationLevel
= typename PassBuilder::OptimizationLevel;
PB.registerPipelineStartEPCallback(
[](ModulePassManager &MPM, OptimizationLevel OL) {
if (OL.getSpeedupLevel() >= 2) {
MPM.addPass(
createModuleToFunctionPassAdaptor(StrictOpt()));
}
});
}
在前面的代码片段中有几个值得注意的点:
-
我们在这里使用的
registerPipelineStartEPCallback
方法注册了一个回调,该回调可以自定义 Pass 管道中的某些位置,称为 扩展点(EPs)。我们在这里将要定制的 EP 是管道中最早的位置之一。 -
与我们在
registerPipelineParsingCallback
中看到的 lambda 回调相比,registerPipelineStartEPCallback
的 lambda 回调只提供ModulePassManager
,而不是FunctionPassManager
,以插入我们的StrictOpt
Pass,这是一个函数 Pass。我们使用ModuleToFunctionPassAdapter
来解决这个问题。ModuleToFunctionPassAdapter
是一个模块 Pass,可以在模块的封装函数上运行给定的函数 Pass。它适用于仅在ModulePassManager
可用的上下文中运行函数 Pass,例如在这个场景中。前面代码中突出显示的createModuleToFunctionPassAdaptor
函数用于从一个特定的函数 Pass 创建一个新的ModuleToFunctionPassAdapter
实例。 -
最后,在这个版本中,我们只有在优化级别大于或等于
-O2
时才启用StrictOpt
。因此,我们利用传递给 lambda 回调的OptimizationLevel
参数来决定是否将StrictOpt
插入到管道中。通过这些 Pass 注册步骤,我们还学习了如何触发我们的
StrictOpt
,而无需显式指定文本 Pass 管道。
总结来说,在本节中,我们学习了 LLVM Pass 和 Pass 管道的要点。通过 StrictOpt
项目,我们学习了如何开发一个 Pass——它也被封装为插件——用于新的 PassManager,以及如何以两种不同的方式在 opt
中动态注册它:首先,通过通过文本描述显式触发 Pass,其次,在管道中的某个时间点(EP)运行它。我们还学习了如何根据 Pass 中所做的更改使分析无效。这些技能可以帮助您开发高质量和现代的 LLVM Pass,以最大灵活性以可组合的方式处理 IR。在下一节中,我们将深入了解 LLVM 的程序分析基础设施。这大大提高了普通 LLVM 转换 Pass 的能力。
使用新的 AnalysisManager
现代编译器的优化可能很复杂。它们通常需要从目标程序中获取大量信息,以便做出正确的决策和最优的转换。例如,在 编写用于新 PassManager 的 LLVM Pass 部分中,LLVM 使用了 noalias
属性来计算内存别名信息,这些信息最终可能被用来删除冗余的内存加载。
其中一些信息——在 LLVM 中称为 analysis——评估成本很高。此外,单个分析也可能依赖于其他分析。因此,LLVM 创建了一个 AnalysisManager 组件来处理与 LLVM 程序分析相关的所有任务。在本节中,我们将向您展示如何在自己的 Pass 中使用 AnalysisManager,以便编写更强大和复杂的程序转换或分析。我们还将使用一个示例项目,HaltAnalyzer,来驱动本教程。下一节将在详细介绍开发步骤之前,为您提供 HaltAnalyzer 的概述。
项目概述
HaltAnalyzer 是在一个场景中设置的,其中目标程序使用一个特殊函数my_halt
,当它被调用时终止程序执行。my_halt
函数类似于std::terminate
函数,或者当其健全性检查失败时的assert
函数。
HaltAnalyzer 的任务是分析程序,以找到由于my_halt
函数而保证无法到达的基本块。更具体地说,让我们以下面的 C 代码为例:
int foo(int x, int y) {
if (x < 43) {
my_halt();
if (y > 45)
return x + 1;
else {
bar();
return x;
}
} else {
return y;
}
}
因为my_halt
在if (x < 43)
语句的真块开始时被调用,所以前面代码片段中高亮显示的代码永远不会被执行(即my_halt
在到达这些行之前就停止了所有程序执行)。
HaltAnalyzer 应该识别这些基本块,并向stderr
打印出警告信息。就像上一节中的示例项目一样,HaltAnalyzer 也是一个封装在插件中的函数 Pass。因此,如果我们使用前面的代码片段作为 HaltAnalyzer Pass 的输入,它应该打印出以下信息:
$ opt --enable-new-pm --load-pass-plugin ./HaltAnalyzer.so \
--disable-output ./test.ll
[WARNING] Unreachable BB: label %if.else
[WARNING] Unreachable BB: label %if.then2
$
%if.else
和%if.then2
字符串只是if (y > 45)
语句中基本块的名称(你可能会看到不同的名称)。另一个值得注意的事情是--disable-output
命令行标志。默认情况下,opt
实用程序会打印出 LLVM IR 的二进制形式(即 LLVM 位码),除非用户通过-o
标志将输出重定向到其他地方。使用上述标志只是为了告诉opt
不要这样做,因为我们这次对 LLVM IR 的最终内容不感兴趣(因为我们不会对其进行修改)。
虽然 HaltAnalyzer 的算法看起来相当简单,但从零开始编写它可能是个头疼的问题。这就是为什么我们正在利用 LLVM 提供的一项分析:支配树(DT)。控制流图(CFG)支配的概念在大多数入门级编译器课程中都有讲解,所以我们在这里就不深入解释了。简单来说,如果我们说一个基本块支配另一个块,那么到达后者的每个执行流程都保证首先经过前者。DT 是 LLVM 中最重要且最常用的分析之一;大多数与控制流相关的转换都离不开它。
将这个想法应用到 HaltAnalyzer 中,我们只是在寻找所有被包含my_halt
函数调用的基本块支配的基本块(我们在警告信息中排除了包含my_halt
调用站点的基本块)。在下一节中,我们将向您展示如何编写 HaltAnalyzer 的详细说明。
编写 HaltAnalyzer Pass
在这个项目中,我们只创建一个源文件,HaltAnalyzer.cpp
。大部分基础设施,包括CMakeListst.txt
,都可以从上一节中的StrictOpt
项目重用:
-
在
HaltAnalyzer.cpp
内部,首先,我们创建以下 Pass 框架:class HaltAnalyzer : public PassInfoMixin<HaltAnalyzer> { static constexpr const char* HaltFuncName = "my_halt"; // All the call sites to "my_halt" SmallVector<Instruction*, 2> run method that we saw in the previous section, we are creating an additional method, findHaltCalls, which will collect all of the Instruction calls to my_halt in the current function and store them inside the Calls vector.
-
让我们先实现
findHaltCalls
:void HaltAnalyzer::findHaltCalls(Function &F) { Calls.clear(); for (auto &I : llvm::instructions to iterate through every Instruction call in the current function and check them one by one. If the Instruction call is a CallInst – representing a typical function call site – and the callee name is my_halt, we will push it into the Calls vector for later use.Function name manglingBe aware that when a line of C++ code is compiled into LLVM IR or native code, the name of any symbol – including the function name – will be different from what you saw in the original source code. For example, a simple function that has the name of *foo* and takes no argument might have *_Z3foov* as its name in LLVM IR. We call such a transformation in C++ **name mangling**. Different platforms also adopt different name mangling schemes. For example, in Visual Studio, the same function name becomes *?foo@@YAHH@Z* in LLVM IR.
-
现在,让我们回到
HaltAnalyzer::run
方法。我们将做两件事。我们将通过findHaltCalls
收集对my_halt
的调用位置,这是我们刚刚编写的,然后检索 DT 分析数据:#include "llvm/IR/Dominators.h" … PreservedAnalyses HaltAnalyzer::run(Function &F, FunctionAnalysisManager type argument to retrieve specific analysis data (in this case, DominatorTree) for a specific Function class.Although, so far, we have (kind of) used the words *analysis* and *analysis data* interchangeably, in a real LLVM implementation, they are actually two different entities. Take the DT that we are using here as an example:a) `Function`. In other words, it is the one that *performs* the analysis.b) `DominatorTreeAnalysis`. This is just static data that will be cached by AnalysisManager until it is invalidated.Furthermore, LLVM asks every analysis to clarify its affiliated result type via the `Result` member type. For example, `DominatorTreeAnalysis::Result` is equal to `DominatorTree`.To make this even more formal, to associate the analysis data of an analysis class, `T`, with a `Function` variable, `F`, we can use the following snippet:
//
FAM
是 FunctionAnalysisManagertypename T::Result &Data = FAM.getResult
(F); -
在我们检索到
DominatorTree
之后,是时候找到我们之前收集的所有由Instruction
调用位置支配的基本块了:PreservedAnalyses HaltAnalyzer::run(Function &F, FunctionAnalysisManager &FAM) { … SmallVector<BasicBlock*, 4> DomBBs; for (auto *I : Calls) { auto *BB = I->getParent(); DomBBs.clear(); DT.DominatorTree::getDescendants method, we can retrieve all of the basic blocks dominated by a my_halt call site. Note that the results from getDescendants will also contain the block you put into the query (in this case, the block containing the my_halt call sites), so we need to exclude it before printing the basic block name using the BasicBlock::printAsOperand method.With the ending of the returning `PreservedAnalyses::all()`, which tells AnalysisManager that this Pass does not invalidate any analysis since we don't modify the IR at all, we will wrap up the `HaltAnalyzer::run` method here.
-
最后,我们需要将我们的 HaltAnalyzer Pass 动态地插入到 Pass pipeline 中。我们使用与上一节相同的方法,通过实现
llvmGetPassPluginInfo
函数并使用PassBuilder
将我们的 Pass 放置在 pipeline 中的某个 EP(扩展点):extern "C" ::llvm::PassPluginLibraryInfo LLVM_ATTRIBUTE_WEAK llvmGetPassPluginInfo() { return { LLVM_PLUGIN_API_VERSION, "HaltAnalyzer", "v0.1", [](PassBuilder &PB) { using OptimizationLevel = typename PassBuilder::OptimizationLevel; PB.StrictOpt in the previous section, we are using registerOptimizerLastEPCallback to insert HaltAnalyzer *after* all of the other optimization Passes. The rationale behind this is that some optimizations might move basic blocks around, so prompting warnings too early might not be very useful. Nevertheless, we are still leveraging ModuletoFunctionPassAdaptor to wrap around our Pass; this is because registerOptimizerLastEPCallback only provides ModulePassManager for us to add our Pass, which is a function Pass.
这些都是实现我们的 HaltAnalyzer 所必需的步骤。现在你已经学会了如何使用 LLVM 的程序分析基础设施在 LLVM Pass 中获取有关目标程序更多信息。这些技能可以让你在开发 Pass 时对 IR 有更深入的了解。此外,这个基础设施允许你重用 LLVM 提供的优质、现成的程序分析算法,而不是自己重新造轮子。要浏览 LLVM 提供的所有分析,源树中的llvm/include/llvm/Analysis
文件夹是一个很好的起点。这个文件夹中的大多数头文件都是独立的分析数据文件,你可以使用它们。
在本章的最后部分,我们将向您展示一些有用的诊断技术,这些技术对于调试 LLVM Pass 非常有用。
在新的 PassManager 中学习 instrumentations
LLVM 中的 PassManager 和 AnalysisManager 是复杂的软件组件。它们管理着数百个 Pass 和分析之间的交互,当我们试图诊断由它们引起的问题时,这可能是一个挑战。此外,编译器工程师修复编译器中的崩溃或Miscompilation bugs 是非常常见的。在这些情况下,有用的 instrumentation 工具可以为 Pass 和 Pass pipeline 提供洞察力,从而大大提高修复这些问题的效率。幸运的是,LLVM 已经提供了许多这样的工具。
Miscompilation
Miscompilation bugs usually refer to logical issues in the compiled program, which were introduced by compilers. For example, an overly aggressive compiler optimization removes certain loops that shouldn't be removed, causing the compiled software to malfunction, or mistakenly reorder memory barriers and create race conditions in the generated code.
我们将在接下来的每个部分中一次介绍一个工具。以下是它们的列表:
-
打印 Pass pipeline 详细信息
-
在每个 Pass 之后打印 IR 的变化
-
分割 Pass pipeline
这些工具可以在 opt
的命令行界面中交互。实际上,你还可以创建 自己的 仪器工具(甚至不需要更改 LLVM 源树!);我们将把这个留给你作为练习。
打印 Pass 管道详细信息
当使用 clang
(或 opt
)时,我们熟悉许多不同的 -O1
、-O2
或 -Oz
标志。每个优化级别都在运行 不同集合的 Pass 并以 不同顺序 安排它们。在某些情况下,这可能会极大地影响生成的代码,从性能或正确性方面来看。因此,有时了解这些配置对于获得我们将要处理的问题的清晰理解至关重要。
要打印出 opt
中所有 Pass 及其当前运行顺序,我们可以使用 --debug-pass-manager
标志。例如,给定以下 C 代码,test.c
,我们将看到以下内容:
int bar(int x) {
int y = x;
return y * 4;
}
int foo(int z) {
return z + z * 2;
}
我们首先使用以下命令为其生成 IR:
$ clang -O0 -Xclang -disable-O0-optnone -emit-llvm -S test.c
-disable-O0-optnone
标志
默认情况下,clang
将在 -O0
优化级别下将特殊属性 optnone
附接到每个函数上。此属性将防止对附加函数进行任何进一步优化。在这里,-disable-O0-optnone
(前端)标志阻止 clang
附上此属性。
然后,我们使用以下命令来打印出在 -O2
优化级别下运行的所有 Pass:
$ opt -O2 --disable-output --debug-pass-manager test.ll
Starting llvm::Module pass manager run.
…
Running pass: Annotation2MetadataPass on ./test.ll
Running pass: ForceFunctionAttrsPass on ./test.ll
…
Starting llvm::Function pass manager run.
Running pass: SimplifyCFGPass on bar
Running pass: SROA on bar
Running analysis: DominatorTreeAnalysis on bar
Running pass: EarlyCSEPass on bar
…
Finished llvm::Function pass manager run.
…
Starting llvm::Function pass manager run.
Running pass: SimplifyCFGPass on foo
…
Finished llvm::Function pass manager run.
Invalidating analysis: VerifierAnalysis on ./test.ll
…
$
上述命令行输出告诉我们 opt
首先运行一组 模块级 优化;这些 Pass 的顺序(例如,Annotation2MetadataPass
和 ForceFunctionAttrsPass
)也被列出。之后,对 bar
函数(例如,SROA
)执行一系列 函数级 优化,然后再对这些优化应用于 foo
函数。此外,它还显示了管道中使用的分析(例如,DominatorTreeAnalysis
),并提示我们有关它们因某个 Pass 而失效的消息。
总结来说,--debug-pass-manager
是一个有用的工具,可以窥探在特定优化级别下 Pass 管道运行的 Pass 及其顺序。了解这些信息可以帮助你获得 Pass 和分析如何与输入 IR 交互的整体图景。
打印每个 Pass 后的 IR 变化
要了解特定转换 Pass 对目标程序的影响,最直接的方法之一是比较该 Pass 处理前后的 IR。更具体地说,在大多数情况下,我们感兴趣的是特定转换 Pass 所做的 更改。例如,如果 LLVM 错误地删除了它不应该删除的循环,我们想知道是哪个 Pass 做了这件事,以及 Pass 管道中删除发生的时间。
通过使用--print-changed
标志(以及我们将很快介绍的某些其他支持的标志)与opt
结合,我们可以在每次 Pass 修改 IR 的情况下打印出 IR。使用上一段中的test.c
(及其 IR 文件test.ll
)示例代码,我们可以使用以下命令来打印变化,如果有任何变化的话:
$ opt -O2 --disable-output --print-changed ./test.ll
*** IR Dump At Start: ***
...
define dso_local i32 @bar(i32 %x) #0 {
entry:
%x.addr = alloca i32, align 4
%y = alloca i32, align 4
…
%1 = load i32, i32* %y, align 4
%mul = mul nsw i32 %1, 4
ret i32 %mul
}
...
*** IR Dump After VerifierPass (module) omitted because no change ***
…
...
*** IR Dump After SROA *** (function: bar)
; Function Attrs: noinline nounwind uwtable
define dso_local i32 @bar(i32 %x) #0 {
entry:
%mul = mul nsw i32 %x, 4
ret i32 %mul
}
...
$
在这里,我们只展示了少量输出。然而,在代码片段的高亮部分,我们可以看到这个工具将首先打印出原始 IR(IR Dump At Start
),然后显示每个 Pass 处理后的 IR。例如,前面的代码片段显示,经过 SROA Pass 后,bar
函数变得短得多。如果一个 Pass 根本未修改 IR,它将省略 IR 转储以减少噪声。
有时候,我们只对特定函数集上的变化感兴趣,比如在这个例子中的foo
函数。而不是打印整个模块的变更日志,我们可以添加--filter-print-funcs=<function names>
标志来仅打印函数子集的 IR 变化。例如,要仅打印foo
函数的 IR 变化,可以使用以下命令:
$ opt -O2 --disable-output \
--print-changed --filter-print-funcs=foo ./test.ll
就像--filter-print-funcs
一样,有时候我们只想看到特定 Pass 集所做的变化,比如 SROA 和InstCombine
Pass。在这种情况下,我们可以添加--filter-passes=<Pass names>
标志。例如,要仅查看与 SROA 和InstCombine
相关的内容,可以使用以下命令:
$ opt -O2 --disable-output \
--print-changed \
--filter-passes=SROA,InstCombinePass ./test.ll
现在你已经学会了如何打印管道中所有 Pass 的 IR 差异,并使用额外的过滤器进一步关注特定的函数或 Pass。换句话说,这个工具可以帮助你轻松观察 Pass 管道中变化的进展,并快速找到你可能感兴趣的任何痕迹。在下一节中,我们将学习如何通过二分法Pass 管道来调试代码优化中提出的问题。
二分 Pass 管道
在前几节中,我们介绍了--print-changed
标志,该标志在整个 Pass 管道中打印出IR 变更日志。我们还提到,调用我们感兴趣的变化是有用的;例如,一个导致误编译错误的无效代码转换。或者,我们也可以在opt
中使用--opt-bisect-limit=<N>
标志,通过禁用除了前 N 个之外的所有 Pass 来二分 Pass 管道。以下命令展示了这个示例:
$ opt -O2 --opt-bisect-limit=5 -S -o – test.ll
BISECT: running pass (1) Annotation2MetadataPass on module (./test.ll)
BISECT: running pass (2) ForceFunctionAttrsPass on module (./test.ll)
BISECT: running pass (3) InferFunctionAttrsPass on module (./test.ll)
BISECT: running pass (4) SimplifyCFGPass on function (bar)
BISECT: running pass (5) SROA on function (bar)
BISECT: NOT running pass (6) EarlyCSEPass on function (bar)
BISECT: NOT running pass (7) LowerExpectIntrinsicPass on function (bar)
BISECT: NOT running pass (8) SimplifyCFGPass on function (foo)
BISECT: NOT running pass (9) SROA on function (foo)
BISECT: NOT running pass (10) EarlyCSEPass on function (foo)
...
define dso_local i32 @bar(i32 %x) #0 {
entry:
%mul = mul nsw i32 %x, 4
ret i32 %mul
}
define dso_local i32 @foo(i32 %y) #0 {
entry:
%y.addr = alloca i32, align 4
store i32 %y, i32* %y.addr, align 4
%0 = load i32, i32* %y.addr, align 4
%1 = load i32, i32* %y.addr, align 4
%mul = mul nsw i32 %1, 2
%add = add nsw i32 %0, %mul
ret i32 %add
}
$
(请注意,这与前几节中显示的示例不同;前面的命令已打印出--opt-bisect-limit
和最终文本 IR 的消息。)
由于我们实现了--opt-bisect-limit=5
标志,Pass 管道只运行了前五个 Pass。正如诊断消息所示,SROA 应用于bar
函数,但没有应用于foo
函数,导致foo
函数的最终 IR 不太优化。
通过更改 --opt-bisect-limit
后面的数字,我们可以调整截止点,直到出现某些代码更改或触发某个特定错误(例如,崩溃)。这特别有用,可以作为 早期过滤步骤 来缩小原始问题在管道中 Pass 的范围。此外,由于它使用数值作为参数,这个特性非常适合自动化环境,例如自动崩溃报告工具或性能回归跟踪工具。
在本节中,我们介绍了 opt
中的一些有用的仪器工具,用于调试和诊断 Pass 管道。这些工具可以大大提高您在修复问题时的生产力,例如编译器崩溃、性能回归(在目标程序上)和误编译错误。
摘要
在本章中,我们学习了如何为新的 PassManager 编写 LLVM Pass,以及如何通过 AnalysisManager 在 Pass 中使用程序分析数据。我们还学习了如何利用各种仪器工具来改善与 Pass 管道一起工作的开发体验。通过本章获得的知识,您现在可以编写一个处理 LLVM IR 的 Pass,这可以用来转换甚至优化程序。
这些主题是在开始任何 IR 级别的转换或分析任务之前需要学习的最基本和最重要的技能之一。如果您一直在使用传统的 PassManager,这些技能也可以帮助您将代码迁移到新的 PassManager 系统,该系统现在已被默认启用。
在下一章中,我们将向您展示在使用 LLVM IR 的 API 时,您应该知道的各项技巧和最佳实践。
问题
-
在“为新的 PassManager 编写 LLVM Pass”部分的
StrictOpt
示例中,您如何在不需要派生PassInfoMixin
类的情况下编写一个 Pass? -
您如何为新的 PassManager 开发自定义的仪器?此外,您如何在不修改 LLVM 源树的情况下做到这一点?(提示:想想我们在本章中学到的 Pass 插件。)
第十章:第十章:处理 LLVM IR
在上一章中,我们学习了 LLVM 中的 PassManager 和 AnalysisManager。我们回顾了一些关于开发 LLVM pass 以及如何通过 AnalysisManager 获取程序分析数据的教程。我们获得的知识和技能有助于为开发者构建代码转换和程序分析的可组合构建块的基础。
在本章中,我们将重点关注处理 LLVM IR 的方法。LLVM IR 是一个针对程序分析和编译转换的独立目标 中间表示。你可以将 LLVM IR 视为你想要优化和编译的代码的 替代 形式。然而,与您熟悉的 C/C++ 代码不同,LLVM IR 以不同的方式描述程序——我们稍后会给你一个更具体的概念。LLVM 在编译过程中对 LLVM IR 执行的操作中,大部分 魔法 都是为了使输入程序在编译后更快或更小。回想一下,在上一章中,第九章,与 PassManager 和 AnalysisManager 一起工作,我们描述了如何以管道方式组织不同的 pass——那是 LLVM 转换输入代码的高级结构。在本章中,我们将向您展示如何以高效的方式修改 LLVM IR 的详细细节。
尽管通过文本表示查看 LLVM IR 是最直接和直观的方式,但 LLVM 提供了包含一组强大现代 C++ API 的库,用于与 IR 接口。这些 API 可以检查 LLVM IR 的内存表示,并帮助我们操作它,这实际上改变了我们要编译的目标程序。这些 LLVM IR 库可以嵌入到各种应用中,使开发者能够轻松地转换和分析他们的目标源代码。
不同编程语言的 LLVM API
正式来说,LLVM 只支持两种语言的 API:C 和 C++。在这两者之间,C++ 是功能最完整且更新到最新的,但它也拥有最 不稳定 的接口——它可能在任何时候更改,而不具备向后兼容性。另一方面,C API 拥有稳定的接口,但代价是落后于新功能更新,甚至某些功能可能缺失。OCaml、Go 和 Python 的 API 绑定作为社区驱动的项目存在于源代码树中。
我们将尝试通过通常适用的学习块,由常见主题和任务驱动,这些主题和任务由许多现实世界的示例支持来引导您。以下是本章我们将涵盖的主题列表:
-
学习 LLVM IR 基础
-
与值和指令一起工作
-
与循环一起工作
我们将首先介绍 LLVM IR。然后,我们将学习 LLVM IR 中最基本的两个元素——值和指令。最后,我们将通过查看 LLVM 中的循环来结束本章,这是一个更高级的话题,对于处理性能敏感的应用至关重要。
技术要求
本章中我们需要使用的工具是opt
命令行实用程序和clang
。请使用以下命令构建它们:
$ ninja opt clang
本章中的大部分代码都可以实现在 LLVM pass 和 pass 插件中,如第九章中介绍的第九章,与 PassManager 和 AnalysisManager 一起工作。
此外,请安装Graphviz工具。您可以通过以下页面了解您系统的安装指南:graphviz.org/download
。例如,在 Ubuntu 上,您可以使用以下命令安装该软件包:
$ sudo apt install graphviz
我们将使用 Graphviz 提供的命令行工具dot
来可视化函数的控制流。
本章中提到的代码示例可以实现在 LLVM pass 中,除非另有说明。
学习 LLVM IR 基础知识
LLVM IR 是您想要优化和编译的程序的一种替代形式。然而,它与 C/C++等常规编程语言的结构不同。LLVM IR 以分层的方式组织。这个层次结构的级别(从顶部开始计数)是模块、函数、基本块和指令。以下图表显示了它们的结构:
图 10.1 – LLVM IR 的层次结构
模块代表一个翻译单元——通常是一个源文件。每个模块可以包含多个函数(或全局变量)。每个函数包含一个基本块列表,其中每个基本块包含一个指令列表。
快速回顾——基本块
基本块代表一系列指令,只有一个入口和一个出口点。换句话说,如果一个基本块被执行,控制流将保证遍历该块中的每个指令。
了解 LLVM IR 的高级结构后,让我们看看一个 LLVM IR 的示例。假设我们有以下 C 代码,foo.c
:
int foo(int a, int b) {
return a > 0? a – b : a + b;
}
我们可以使用以下clang
命令生成其文本形式的 LLVM IR 对应物:
$ clang -emit-llvm -S foo.c
结果将被放入foo.ll
文件中。以下图表显示了其内容的一部分,并带有对相应 IR 单元的注释:
图 10.2 – foo.ll 中的部分内容,带有相应的 IR 单元注释
在文本形式中,指令通常以以下格式呈现:
<result> = <operator / op-code> <type>, [operand1, operand2, …]
例如,假设我们有以下指令:
%12 = load i32, i32* %3
在这里,%12
是结果值,load
是操作码,i32
是此指令的数据类型,而 %3
是唯一的操作数。
除了文本表示之外,LLVM IR 中几乎每个组件都有一个与同名字符相同的 C++ 类对应。例如,函数和基本块分别简单地表示为 Function
和 BasicBlock
C++ 类。
不同的指令类型由从 Instruction
类派生出的类表示。例如,BinaryOperator
类表示二元运算指令,而 ReturnInst
类表示返回语句。我们将在稍后更详细地探讨 Instruction
及其子类。
图 10.1 中所示的结构是 LLVM IR 的具体结构。也就是说,这就是它们在内存中的存储方式。除此之外,LLVM 还提供了其他逻辑结构来查看不同 IR 单元之间的关系。它们通常从具体结构评估并存储为辅助数据结构或作为分析结果。以下是 LLVM 中一些最重要的结构:
-
控制流图(CFG):这是一种组织成基本块的图结构,用于显示它们的控制流关系。该图中的顶点代表基本块,而边代表单个控制流转移。
-
循环:这代表我们熟悉的循环,由至少有一个回边(控制流边回到其父节点或祖先节点)的多个基本块组成。我们将在本章的最后部分,即“与循环一起工作”部分中更详细地探讨这一点。
-
调用图:与 CFG 类似,调用图也显示了控制流转移,但顶点变为单个函数,边变为函数调用关系。
在下一节中,我们将学习如何在具体和逻辑结构中遍历不同的 IR 单元。
遍历不同的 IR 单元
遍历 IR 单元(如基本块或指令)对于 LLVM IR 开发至关重要。这通常是我们在许多转换或分析算法中必须首先完成的步骤之一——扫描整个代码并找到一个有趣区域,以便应用某些测量。在本节中,我们将学习遍历不同 IR 单元的实际方面。我们将涵盖以下主题:
-
遍历指令
-
遍历基本块
-
遍历调用图
-
学习 GraphTraits
让我们从讨论如何遍历指令开始。
遍历指令
指令是 LLVM IR 中最基本元素之一。它通常代表程序中的单个操作,例如算术运算或函数调用。遍历单个基本块或函数中的所有指令是大多数程序分析和编译器优化的基石。
要遍历基本块中的所有指令,你只需要使用一个简单的 for-each 循环即可:
// `BB` has the type of `BasicBlock&`
for (Instruction &I : BB) {
// Work on `I`
}
我们可以通过两种方式遍历函数中的所有指令。首先,我们可以在访问指令之前遍历函数中的所有基本块。以下是一个例子:
// `F` has the type of `Function&`
for (BasicBlock &BB : F) {
for (Instruction &I : BB) {
// Work on `I`
}
}
第二种方式是利用一个名为inst_iterator
的实用工具。以下是一个例子:
#include "llvm/IR/InstIterator.h"
…
// `F` has the type of `Function&`
for (Instruction &I : instructions(F)) {
// Work on `I`
}
使用前面的代码,你可以检索此函数中的所有指令。
指令访问者
在许多情况下,我们希望对一个基本块或函数中的不同类型的指令应用不同的处理方式。例如,假设我们有以下代码:
for (Instruction &I : instructions(F)) {
switch (I.getOpcode()) {
case Instruction::BinaryOperator:
// this instruction is a binary operator like `add` or `sub`
break;
case Instruction::Return:
// this is a return instruction
break;
…
}
}
回想一下,不同类型的指令是通过从Instruction
派生出的(不同的)类来建模的。因此,一个Instruction
实例可以代表它们中的任何一个。前面代码片段中显示的getOpcode
方法可以给你一个唯一的令牌——即给定代码中的Instruction::BinaryOperator
和Instruction::Return
——这告诉你关于底层类的信息。然而,如果我们想对派生类(在这种情况下是ReturnInst
)实例而不是“原始”的Instruction
进行操作,我们需要进行一些类型转换。
LLVM 提供了一种更好的方式来实现这种访问模式——InstVisitor
。InstVisitor
是一个类,其中每个成员方法都是特定指令类型的回调函数。你可以在从InstVisitor
类继承后定义自己的回调。例如,查看以下代码片段:
#include "llvm/IR/InstVisitor.h"
class MyInstVisitor : public InstVisitor<MyInstVisitor> {
void visitBinaryOperator(BinaryOperator &BOp) {
// Work on binary operator instruction
…
}
void visitReturnInst(ReturnInst &RI) {
// Work on return instruction
…
}
};
这里显示的每个visitXXX
方法都是特定指令类型的回调函数。请注意,我们并没有覆盖这些方法(方法上没有附加override
关键字)。此外,InstVisitor
允许你只定义我们感兴趣的回调,而不是为所有指令类型定义回调。
一旦定义了MyInstVisitor
,我们就可以简单地创建其实例并调用visit
方法来启动访问过程。以下代码作为例子:
// `F` has the type of `Function&`
MyInstVisitor Visitor;
Visitor.visit(F);
对于Instruction
、BasicBlock
和Module
也有visit
方法。
基本块和指令的排序
本节中我们介绍的所有技能都假设Function
不会以特定的线性顺序存储或迭代其封装的BasicBlock
实例。我们将在不久的将来向你展示如何以各种有意义的顺序遍历所有基本块。
通过这样,你已经学会了从基本块或函数中迭代指令的几种方法。现在,让我们学习如何在函数中迭代基本块。
迭代基本块
在上一节中,我们学习了如何使用简单的 for 循环迭代函数的基本块。然而,开发者只能以任意顺序接收基本块,这种方式并没有给你提供块的执行顺序或块之间的控制流信息。在本节中,我们将向您展示如何以更有意义的方式迭代基本块。
基本块是表达函数控制流的重要元素,可以用有向图表示,即opt
工具。假设你有一个 LLVM IR 文件,foo.ll
,你可以使用以下命令以 Graphviz 格式打印出每个函数的 CFG:
$ opt -dot-cfg -disable-output foo.ll
此命令将为foo.ll
中的每个函数生成一个.dot
文件。
.dot
文件可能被隐藏
每个函数的控制流图(CFG).dot
文件的文件名通常以点字符('.'
)开头。在 Linux/Unix 系统中,这实际上隐藏了文件,使其不被正常的ls
命令所显示。因此,请使用ls -a
命令来显示这些文件。
每个.dot
文件都包含该函数 CFG 的 Graphviz 表示。Graphviz 是一种用于表示图的通用文本格式。人们在研究之前通常将.dot
文件转换为其他(图像)格式。例如,使用以下命令,你可以将.dot
文件转换为 PNG 图像文件,该文件可以直观地显示图形:
$ dot -Tpng foo.cfg.dot > foo.cfg.png
以下图表显示了两个示例:
图 10.3 – 左:包含分支的函数的 CFG;右:包含循环的函数的 CFG
上述图表的左侧显示了包含多个分支的函数的 CFG;右侧显示了包含单个循环的函数的 CFG。
现在,我们知道基本块是以有向图的形式组织的,即 CFG。我们能否迭代这个 CFG,使其遵循边和节点?LLVM 通过提供四种不同方式迭代图的功能来回答这个问题:拓扑排序、深度优先(本质上做DFS)、广度优先(本质上做BFS)和强连通分量(SCCs)。我们将在以下小节中学习如何使用这些实用工具中的每一个。
让我们从拓扑排序遍历开始。
拓扑排序遍历
拓扑排序是一种简单的线性排序,它保证了对于图中的每个节点,我们只有在访问了所有父节点(前驱节点)之后才会访问它。LLVM 提供了po_iterator
和其他一些实用函数,以在控制流图(CFG)上实现逆序拓扑排序(逆序拓扑排序更容易实现)。以下代码片段给出了使用po_iterator
的示例:
#include "llvm/ADT/PostOrderIterator.h"
#include "llvm/IR/CFG.h"
// `F` has the type of `Function*`
for (BasicBlock *BB : post_order(F)) {
BB->printAsOperand(errs());
errs() << "\n";
}
post_order
函数只是一个辅助函数,用于创建 po_iterator
的迭代范围。请注意,llvm/IR/CFG.h
头文件对于使 po_iterator
在 Function
和 BasicBlock
上工作是必要的。
如果我们将前面的代码应用于前面图中包含分支的函数,我们将得到以下命令行输出:
label %12
label %9
label %5
label %7
label %3
label %10
label %1
或者,你可以使用几乎相同的语法从特定的基本块开始遍历;例如:
// `F` has the type of `Function*`
BasicBlock &EntryBB = F->getEntryBlock();
for (BasicBlock *BB : post_order(&EntryBB)) {
BB->printAsOperand(errs());
errs() << "\n";
}
前面的代码片段将给出与上一个相同的结果,因为它是从入口块开始的。尽管如此,你也可以从任意块开始遍历。
深度优先和广度优先遍历
DFS 和 BFS 是访问拓扑结构(如图或树)中最著名和标志性的算法之二。对于树或图中的每个节点,DFS 总是会尝试在访问具有相同父节点的其他节点(即 兄弟 节点)之前访问其子节点。另一方面,BFS 会遍历所有兄弟节点,然后再移动到其子节点。
LLVM 提供了 df_iterator
和 bf_iterator
(以及一些其他实用函数)来实现深度优先和广度优先排序。由于它们的用法几乎相同,我们在这里只演示 df_iterator
:
#include "llvm/ADT/DepthFirstIterator.h"
#include "llvm/IR/CFG.h"
// `F` has the type of `Function*`
for (BasicBlock *BB : depth_first(F)) {
BB->printAsOperand(errs());
errs() << "\n";
}
与 po_iterator
和 post_order
类似,depth_first
只是一个用于创建 df_iterator
迭代范围的实用函数。要使用 bf_iterator
,只需将 depth_first
替换为 breadth_first
。如果你将前面的代码应用于前面图中的包含分支,它将给出以下命令行输出:
label %1
label %3
label %5
label %9
label %12
label %7
label %10
当使用 bf_iterator
/breadth_first
时,对于相同的示例,我们将得到以下命令行输出:
label %1
label %3
label %10
label %5
label %7
label %12
label %9
df_iterator
和 bf_iterator
也可以与 BasicBlock
一起使用,就像前面展示的 po_iterator
一样。
SSC 遍历
SCC 表示一个子图,其中每个包围节点都可以从其他任何节点到达。在 CFG 的上下文中,使用循环遍历 CFG 是很有用的。
我们之前介绍的基本块遍历方法是有用的工具,可以用来推理函数中的控制流。对于一个无循环的函数,这些方法为你提供了一个线性视图,该视图与包围基本块的执行顺序紧密相关。然而,对于一个包含循环的函数,这些(线性)遍历方法无法显示由循环创建的循环执行流。
重复控制流
循环并不是在函数内部创建重复控制流的唯一编程结构。一些其他指令——例如 C/C++ 中的 goto
语法——也会引入重复控制流。然而,这些特殊情况会使分析控制流变得更加困难(这也是你不应该在代码中使用 goto
的原因之一),因此当我们谈论重复控制流时,我们仅指代循环。
在 LLVM 中使用scc_iterator
,我们可以遍历 CFG 中的强连通基本块。有了这些信息,我们可以快速找到重复的控制流,这对于某些分析和程序转换任务至关重要。例如,为了准确地在控制流边沿传播分支概率数据,我们需要知道回边和重复的基本块。
这里是一个使用scc_iterator
的示例:
#include "llvm/ADT/SCCIterator.h"
#include "llvm/IR/CFG.h"
// `F` has the type of `Function*`
for (auto SCCI = scc_begin(&F); !SCCI.isAtEnd(); ++SCCI) {
const std::vector<BasicBlock*> &SCC = *SCCI;
for (auto *BB : SCC) {
BB->printAsOperand(errs());
errs() << "\n";
}
errs() << "====\n";
}
与之前的遍历方法不同,scc_iterator
不提供方便的范围式迭代。相反,你需要使用scc_begin
创建一个scc_iterator
实例,并进行手动递增。更重要的是,你应该使用isAtEnd
方法来检查退出条件,而不是像我们通常与 C++ STL 容器进行比较那样与“end”迭代器进行比较。可以从单个scc_iterator
解引用一个BasicBlock
向量。这些BasicBlock
实例是 SCC 内的基本块。这些 SCC 实例的顺序大致与反转拓扑顺序相同——即我们之前看到的后序。
如果你运行前面的代码在包含前面图中循环的函数上,它将给出以下命令行输出:
label %6
====
label %4
label %2
====
label %1
====
这表明基本块%4
和%2
位于同一个强连通分量(SCC)中。
通过这样,你已经学会了以不同的方式遍历函数中的基本块。在下一节中,我们将学习如何通过遵循调用图来遍历模块内的函数。
遍历调用图
调用图是一个直接图,它表示模块中函数之间的调用关系。它在跨过程代码转换和分析中扮演着重要角色,即分析或优化跨多个函数的代码。一个著名的优化称为函数内联,就是这种情况的一个例子。
在我们深入到调用图中节点迭代的细节之前,让我们看看如何构建一个调用图。LLVM 使用CallGraph
类来表示单个Module
的调用图。以下示例代码使用一个 pass 模块来构建CallGraph
:
#include "llvm/Analysis/CallGraph.h"
struct SimpleIPO : public PassInfoMixin<SimpleIPO> {
PreservedAnalyses run(Module &M, ModuleAnalysisManager &MAM) {
CallGraph CG(M);
for (auto &Node : CG) {
// Print function name
if (Node.first)
errs() << Node.first->getName() << "\n";
}
return PreservedAnalysis::all();
}
};
这个片段在遍历所有封装函数并打印它们的名称之前构建了一个CallGraph
实例。
就像Module
和Function
一样,CallGraph
只提供了一种最基本的方式来枚举其所有封装组件。那么,我们如何以不同的方式遍历CallGraph
——例如,使用 SCC——就像我们在上一节中看到的那样?这个答案出人意料地简单:确实是以相同的方式——使用相同的 API 集合和用法。
这背后的秘密是一个叫做GraphTraits
的东西。
学习关于 GraphTraits
GraphTraits
是一个旨在为 LLVM 中各种不同的图提供抽象接口的类——例如 CFG 和调用图。它允许其他 LLVM 组件——分析、转换或迭代器实用工具,正如我们在上一节中看到的——独立于底层图构建它们的工作。GraphTraits
不是要求 LLVM 中的每个图都从GraphTraits
继承并实现所需函数,而是采取了一种相当不同的方法,即使用模板特化。
假设你已经编写了一个简单的 C++类,它有一个接受任意类型的模板参数,如下所示:
template <typename T>
struct Distance {
static T compute(T &PointA, T &PointB) {
return PointA – PointB;
}
};
这个 C++类在调用Distance::compute
方法时会计算两点之间的距离。这些点的类型由T
模板参数参数化。
如果T
是如int
或float
这样的数值类型,一切都会正常。然而,如果T
是一个结构体或类,如这里所示,那么默认的compute
方法实现将无法编译:
Distance<int>::compute(94, 87); // Success
…
struct SimplePoint {
float X, Y;
};
SimplePoint A, B;
Distance<SimplePoint>::compute(A, B); // Compilation Error
为了解决这个问题,你可以为SimplePoint
实现一个减法运算符,或者你可以使用模板特化,如下所示:
// After the original declaration of struct Distance…
template<>
struct Distance<SimplePoint> {
SimplePoint compute(SimplePoint &A, SimplePoint &B) {
return std::sqrt(std::pow(A.X – B.X, 2),…);
}
};
…
SimplePoint A, B;
Distance<SimplePoint>::compute(A, B); // Success
之前代码中的Distance<SimplePoint>
描述了当T
等于SimplePoint
时Distance<T>
的样子。你可以将原始的Distance<T>
视为某种Distance<SimplePoint>
,其中Distance<SimplePoint>
中的compute
方法不是原始Distance<T>
中compute
的覆盖方法。这与正常的类继承(和虚方法)不同。
LLVM 中的GraphTraits
是一个模板类,它为各种图算法提供了一个接口,例如我们之前看到的df_iterator
和scc_iterator
。在 LLVM 中的每个图都会通过模板特化来实现这个接口。例如,以下GraphTraits
特化用于模拟函数的CFG:
template<>
struct GraphTraits<Function*> {…}
在GraphTraits<Function*>
的主体中,有几个(静态)方法和typedef
语句实现了所需接口。例如,nodes_iterator
是用于遍历 CFG 中所有顶点的类型,而nodes_begin
为你提供了这个 CFG 的入口/起始节点:
template<>
struct GraphTraits<Function*> {
typedef pointer_iterator<Function::iterator> nodes_iterator;
static node_iterator nodes_begin(Function *F) {
return nodes_iterator(F->begin());
}
…
};
在这种情况下,nodes_iterator
基本上是Function::iterator
。nodes_begin
简单地返回函数中的第一个基本块(通过一个迭代器)。如果我们查看CallGraph
的GraphTraits
,它对nodes_iterator
和nodes_begin
有完全不同的实现:
template<>
struct GraphTraits<CallGraph*> {
typedef mapped_iterator<CallGraph::iterator,
decltype(&CGGetValuePtr)> nodes_iterator;
static node_iterator nodes_begin(CallGraph *CG) {
return nodes_iterator(CG->begin(), &CGGetValuePtr);
}
};
当开发者实现一个新的图算法时,他们不必为 LLVM 中的每种图硬编码它,而是可以通过使用GraphTraits
作为接口来访问任意图的关键属性来构建他们的算法。
例如,假设我们想要创建一个新的图算法,find_tail
,它找到图中没有子节点的第一个节点。以下是find_tail
的框架:
template<class GraphTy,
typename GT = GraphTraits<GraphTy>>
auto find_tail(GraphTy G) {
for(auto NI = GT::nodes_begin(G); NI != GT::nodes_end(G); ++NI) {
// A node in this graph
auto Node = *NI;
// Child iterator for this particular node
auto ChildIt = GT::child_begin(Node);
auto ChildItEnd = GT::child_end(Node);
if (ChildIt == ChildItEnd)
// No child nodes
return Node;
}
…
}
在这个模板和GraphTraits
的帮助下,我们可以在Function
、CallGraph
或 LLVM 中的任何类型的图上重用这个函数;例如:
// `F` has the type of `Function*`
BasicBlock *TailBB = find_tail(F);
// `CG` has the type of `CallGraph*`
CallGraphNode *TailCGN = find_tail(CG);
简而言之,GraphTraits
通过模板特化技术将 LLVM 中的算法(如我们之前看到的df_iterator
和scc_iterator
)泛化到任意图中。这是一种干净且高效的方式来定义可重用组件的接口。
在本节中,我们学习了 LLVM IR 的层次结构以及如何迭代不同的 IR 单元——无论是具体的还是逻辑单元,如 CFGs。我们还学习了GraphTraits
在封装不同图(例如 CFGs 和调用图)中的重要作用,并暴露了 LLVM 中各种算法的通用接口,从而使这些算法更加简洁和可重用。
在下一节中,我们将学习如何在 LLVM 中表示值,这描述了不同 LLVM IR 组件之间关联的图景。此外,我们还将学习在 LLVM 中正确且高效地操作和更新值的方法。
与值和指令一起工作
在 LLVM 中,一个值是一个独特的结构——它不仅代表存储在变量中的值,还模拟了从常量、全局变量、单个指令甚至基本块等广泛的概念。换句话说,它是 LLVM IR 的基础之一。
价值的概念对于指令来说尤为重要,因为它直接与 IR 中的值进行交互。因此,在本节中,我们将它们纳入相同的讨论。我们将探讨在 LLVM IR 中值是如何工作的,以及值是如何与指令关联的。在此基础上,我们还将学习如何创建和插入新的指令,以及如何更新它们。
要了解如何在 LLVM IR 中使用值,我们必须理解支撑这个系统的关键理论,它决定了 LLVM 指令的行为和格式——单静态赋值(SSA)形式。
理解 SSA
SSA 是一种对 IR 进行结构和设计的方式,使得程序分析和编译器转换更容易执行。在 SSA 中,一个变量(在 IR 中)将只被赋值一次。这意味着我们不能像这样操作变量:
// the following code is NOT in SSA form
x = 94;
x = 87; // `x` is assigned the second time, not SSA!
尽管一个变量只能被赋值一次,但它可以在任意指令中被使用多次。例如,看看以下代码:
x = 94;
y = x + 4; // first time `x` is used
z = x + 2; // second time `x` is used
你可能想知道普通的 C/C++代码——显然不是 SSA 形式——是如何转换成 LLVM 这样的 SSA 形式 IR 的。虽然有一类不同的算法和研究论文回答了这个问题,我们在这里不会涉及,但大多数简单的 C/C++代码可以通过诸如重命名等简单技术进行转换。例如,假设我们有以下(非 SSA)的 C 代码:
x = 94;
x = x * y; // `x` is assigned more than once, not SSA!
x = x + 5;
在这里,我们可以将第一个赋值中的 x
重命名为 x0
,并将第二个和第三个赋值左侧的 x
分别用 x1
和 x2
这样的替代名称替换:
x0 = 94;
x1 = x0 * y;
x2 = x1 + 5;
通过这些简单的测量,我们可以获得具有相同行为的原始代码的 SSA 形式。
为了更全面地理解 SSA,我们必须改变我们对程序中指令外观的思考方式。在 x
"中,第二行表示“使用 x
和 y
进行一些乘法运算,然后将结果存储在 x
变量中”:
![图 10.4 – 将指令视为“动作”]
![img/B14590_Figure_10.4.jpg]
图 10.4 – 将指令视为“动作”
这些解释听起来直观。然而,当我们对这些指令进行一些转换——这当然是编译器中常见的事情——时,事情就变得复杂了。在前面的图表中,在右侧,当第一个指令变为 x = 87
时,我们不知道这个修改是否会影响右侧的 x
变量。在这里,我们别无选择,只能列出所有左侧有 x
的指令(即使用 x
作为目标),这相当低效。
我们可以不关注指令的动作方面,而是关注由指令生成的数据,从而清楚地了解每个指令的来源——即其结果值可以到达的区域。此外,我们还可以轻松地找出任意变量/值的来源。以下图表说明了这一优势:
![图 10.5 – SSA 突出显示指令之间的数据流]
![img/B14590_Figure_10.5.jpg]
图 10.5 – SSA 突出显示指令之间的数据流
换句话说,SSA 突出了程序中的数据流,以便编译器更容易跟踪、分析和修改指令。
LLVM 中的指令以 SSA 形式组织。这意味着我们更感兴趣的是指令产生的值,或数据流,而不是它将结果存储在哪个变量中。由于 LLVM IR 中的每个指令只能产生单个结果值,因此 Instruction
对象——回想一下 Instruction
是表示 LLVM IR 中指令的 C++ 类——也代表其 Value
。Instruction
是其子类之一。这意味着给定一个 Instruction
对象,我们当然可以将它转换为 Value
对象。那个特定的 Value
对象实际上是那个 Instruction
的结果:
// let's say `I` represents an instruction `x = a + b`
Instruction *I = …;
Value *V = I; // `V` effectively represents the value `x`
这是了解如何使用 LLVM IR 的重要事项之一,尤其是要使用其大多数 API。
虽然 Instruction
对象代表其自身的结果值,但它也有作为指令输入的操作数。猜猜看?我们也在使用 Value
对象作为操作数。例如,假设我们有以下代码:
Instruction *BinI = BinaryOperator::Create(Instruction::Add,…);
Instruction *RetI = ReturnInst::Create(…, BinI, …);
前面的代码片段基本上创建了一个算术加法指令(由 BinaryOperator
表示),其 结果 值将成为另一个返回指令的 操作数。生成的 IR 等价于以下 C/C++ 代码:
x = a + b;
return x;
除了 Instruction
、Constant
(表示不同类型常量的 C++ 类)、GlobalVariable
(表示全局变量的 C++ 类)和 BasicBlock
之外,它们都是 Value
的子类。这意味着它们也以 SSA 形式组织,并且你可以将它们用作 Instruction
的操作数。
现在,你已经了解了 SSA 是什么以及它对 LLVM IR 设计的影响。在下一节中,我们将讨论如何修改和更新 LLVM IR 中的值。
与值一起工作
SSA 让我们关注指令之间的 数据流。由于我们对值如何从一个指令流向另一个指令有清晰的了解,因此很容易替换指令中某些值的用法。但在 LLVM 中,“值使用”的概念是如何表示的呢?以下图表显示了两个重要的 C++ 类,它们回答了这个问题 – User
和 Use
:
![图 10.6 – Value、User 和 Use 之间的关系
图 10.6 – Value、User 和 Use 之间的关系
如我们所见,User
代表了 IR 实例(例如,一个 Instruction
)使用某个 Value
的概念。此外,LLVM 使用另一个类 Use
来模拟 Value
和 User
之间的边。回想一下,Instruction
是 Value
的子类 – 它代表由该指令生成的结果。实际上,Instruction
也是从 User
继承而来的,因为几乎所有的指令至少需要一个操作数。
一个 User
可能会被多个 Use
实例指向,这意味着它使用了多个 Value
实例。你可以使用 User
提供的 value_op_iterator
来检查这些 Value
实例中的每一个;例如:
// `Usr` has the type of `User*`
for (Value *V : Usr->operand_values()) {
// Working with `V`
}
再次强调,operand_values
只是一个生成 value_op_iterator
范围的实用函数。
这里是为什么要遍历一个 Value
的所有 User
实例的一个例子:想象我们正在分析一个程序,其中它的一个 Function
实例将返回敏感信息 – 假设是一个 get_password
函数。我们的目标是确保在 Function
中调用 get_password
时,其返回值(敏感信息)不会通过另一个函数调用泄露。例如,我们希望检测以下模式并发出警报:
void vulnerable() {
v = get_password();
…
bar(v); // WARNING: sensitive information leak to `bar`!
}
实现这种分析的最简单方法之一是检查敏感 Value
的所有 User
实例。以下是一些示例代码:
User *find_leakage(CallInst *GetPWDCall) {
for (auto *Usr : GetPWDCall->users()) {
if (isa<CallInst>(Usr)) {
return Usr;
}
}
…
}
find_leackage
函数接受一个 CallInst
参数 – 它代表一个 get_password
函数调用 – 并返回任何使用从该 get_password
调用返回的 Value
实例的 User
实例。
一个 Value
实例可以被多个不同的 User
实例使用。因此,同样地,我们可以使用以下代码片段遍历它们:
// `V` has the type of `Value*`
for (User *Usr : V->users()) {
// Working with `Usr`
}
通过这样,你已经学会了如何检查 Value
的 User
实例,或者当前 User
使用的 Value
实例。此外,在开发编译器转换时,改变 User
使用的 Value
实例为另一个实例是非常常见的。LLVM 提供了一些方便的实用工具来完成这项工作。
首先,Value::replaceAllUsesWith
方法,正如其名称所暗示的,可以告诉其所有 User
实例使用另一个 Value
而不是它。以下图表说明了其影响:
![图 10.7 – Value::replaceAllUsesWith 的影响
图 10.7 – Value::replaceAllUsesWith 的影响
当你需要用另一个 Instruction
替换 Instruction
时,这个方法非常有用。使用前面的图表来解释这一点,V1
是原始的 Instruction
,而 V2
是新的。
另一个执行类似操作的实用函数是 User::replaceUsesOfWith(From,To)
。此方法有效地扫描此 User
中的所有操作数,并将特定 Value
(From 参数)的使用替换为另一个 Value
(To 参数)。
在本节中你学到的技能是开发 LLVM 程序转换的最基本工具之一。在下一节中,我们将讨论如何创建和修改指令。
与指令一起工作
之前,我们学习了 Value
的基础知识——包括它与 Instruction
的关系——以及如何在 SSA 框架下更新 Value
实例。在本节中,我们将学习一些更基本的知识和技能,这些知识和技能将帮助你更好地理解 Instruction
,并帮助你以正确和高效的方式修改 Instruction
实例,这是开发成功的编译器优化的关键。
在本节中,我们将介绍以下主题列表:
-
不同指令类型之间的转换
-
插入新的指令
-
替换指令
-
批量处理指令
让我们先看看不同的指令类型。
不同指令类型之间的转换
在前一节中,我们学习了一个有用的实用工具 InstVisitor
。InstVisitor
类帮助你确定 Instruction
实例的底层类。它还节省了你进行不同指令类型之间转换的努力。然而,我们并不能总是依赖 InstVisitor
来完成涉及 Instruction
及其派生类之间类型转换的每一项任务。更普遍地说,我们希望有一个更简单的解决方案来进行父类和子类之间的类型转换。
现在,你可能想知道,但 C++ 已经通过 dynamic_cast
指令提供了这种机制,对吧?以下是一个 dynamic_cast
的例子:
class Parent {…};
class Child1 : public Parent {…};
class Child2 : public Parent {…};
void foo() {
Parent *P = new Child1();
Child1 *C = dynamic_cast<Child1*>(P); // OK
Child2 *O = dynamic_cast<Child2*>(P); // Error: bails out at // runtime
}
在前面代码中使用的 foo
函数中,我们可以看到在其第二行,我们可以将 P
转换为 Child1
实例,因为那是它的底层类型。另一方面,我们不能将 P
转换为 Child2
– 如果我们这样做,程序将在运行时崩溃。
事实上,dynamic_cast
具有我们需要的精确功能 – 更正式地说,是 运行时类型信息 (RTTI)功能 – 但它也带来了运行时性能方面的高开销。更糟糕的是,C++ 中 RTTI 的默认实现相当复杂,使得生成的程序难以优化。因此,LLVM 默认 禁用 RTTI。由于这个原因,LLVM 提出了一种自己的运行时类型转换系统,它更加简单和高效。在本节中,我们将讨论如何使用它。
LLVM 的类型转换框架提供了三个用于动态类型转换的函数:
-
isa<T>(val)
-
cast<T>(val)
-
dyn_cast<T>(val)
第一个函数,isa<T>
– 发音为 "is-a" – 检查 val
指针类型是否可以转换成 T
类型的指针。以下是一个例子:
// `I` has the type of `Instruction*`
if (isa<BinaryOperator>(I)) {
// `I` can be casted to `BinaryOperator*`
}
注意,与 dynamic_cast
不同,在这种情况下,你不需要将 BinaryOperator*
作为模板参数 – 只需一个没有指针修饰符的类型。
cast<T>
函数执行从(指针类型)val
到 T
类型指针的实际类型转换。以下是一个例子:
// `I` has the type of `Instruction*`
if (isa<BinaryOperator>(I)) {
BinaryOperator *BinOp = cast<BinaryOperator>(I);
}
同样,你不需要将 BinaryOperator*
作为模板参数。注意,如果你在调用 cast<T>
之前没有使用 isa<T>
进行类型检查,程序将在运行时崩溃。
最后一个函数,dyn_cast<T>
,是 isa<T>
和 cast<T>
的组合;也就是说,如果适用,你将执行类型转换。否则,它返回一个空值。以下是一个例子:
// `I` has the type of `Instruction*`
if (BinaryOperator *BinOp = dyn_cast<BinaryOperator>(I)) {
// Work with `BinOp`
}
在这里,我们可以看到一些将变量声明(BinOp
)与 if
语句结合的整洁语法。
注意,这些 API 都不能接受空值作为参数。相反,dyn_cast_or_null<T>
没有这个限制。它基本上是一个接受空值作为输入的 dyn_cast<T>
API。
现在,你已经知道了如何从一个任意的 Instruction
实例检查和转换到其底层指令类型。从下一节开始,我们终于要创建和修改一些指令了。
插入一个新的指令
在上一节 理解 SSA 的代码示例中,我们看到了一个类似的片段:
Instruction *BinI = BinaryOperator::Create(…);
Instruction *RetI = ReturnInst::Create(…, BinI, …);
如方法名所暗示的 – Create
– 我们可以推断出这两行代码创建了一个 BinaryOperator
和一个 ReturnInst
指令。
大多数的 LLVM 指令类都提供了工厂方法 – 例如这里的 Create
– 来构建一个新的实例。人们被鼓励使用这些工厂方法,而不是通过 new
关键字或 malloc
函数手动分配指令对象。LLVM 将为你管理指令对象的内存 – 一旦它被插入到 BasicBlock
中。有几种方法可以将新的指令插入到 BasicBlock
中:
-
一些指令类中的工厂方法提供了一种在创建后立即插入指令的选项。例如,
BinaryOperator
中的Create
方法变体之一允许你在创建后将其插入到另一个指令之前。以下是一个示例:Instruction *BinOp will be placed before the one represented by BeforeI. This method, however, can't be ported across different instruction classes. Not every instruction class has factory methods that provide this feature and even if they do provide them, the API might not be the same.
-
我们可以使用
Instruction
类提供的insertBefore
/insertAfter
方法来插入一个新的指令。由于所有指令类都是Instruction
的子类,我们可以使用insertBefore
或insertAfter
来在另一个Instruction
之前或之后插入新创建的指令实例。 -
我们还可以使用
IRBuilder
类。IRBuilder
是一个强大的工具,可以自动化一些指令创建和插入步骤。它实现了构建者设计模式,可以在开发者调用其创建方法之一时依次插入新指令。以下是一个示例:// `BB` has the type of `BasicBlock*` IRBuilder instance, we need to designate an *insertion point* as one of the constructor arguments. This insertion point argument can be a BasicBlock, which means we want to insert a new instruction at the end of BasicBlock; it can also be an Instruction instance, which means that new instructions are going to be inserted *before* that specific Instruction. You are encouraged to use `IRBuilder` over other mechanisms if possible whenever you need to create and insert new instructions in sequential order.
通过这样,你已经学会了如何创建和插入新的指令。现在,让我们看看如何用其他指令来替换现有的指令。
替换指令
在许多情况下,我们可能需要替换现有的指令。例如,一个简单的优化器可能会在乘法的一个操作数是 2 的幂次整数常量时,将算术乘法指令替换为左移指令。在这种情况下,似乎简单直接的方法是通过简单地更改运算符(操作码)和原始Instruction
中的一个操作数来实现这一点。然而,这并不是推荐的做法。
要在 LLVM 中替换Instruction
,你需要创建一个新的Instruction
(作为替换)并将所有从原始Instruction
到替换实例的 SSA 定义和引用重新路由。让我们以我们刚才看到的 2 的幂次乘法为例:
-
我们将要实现的函数名为
replacePow2Mul
,其参数是要处理的乘法指令(假设我们已经确保乘法有一个常数,2 的幂次整数操作数)。首先,我们将检索常数整数操作数——由ConstantInt
类表示——并将其转换为它的 2 的底数对数值(通过getLog2
实用函数;getLog2
的确切实现留作你的练习):void replacePow2Mul(BinaryOperator &Mul) { // Find the operand that is a power-of-2 integer // constant int ConstIdx = isa<ConstantInt>(Mul.getOperand(0))? 0 : 1; ConstantInt *ShiftAmount = getLog2(Mul. getOperand(ConstIdx)); }
-
接下来,我们将创建一个新的左移指令——由
ShlOperator
类表示:void replacePow2Mul(BinaryOperator &Mul) { … // Get the other operand from the original instruction auto *Base = Mul.getOperand(ConstIdx? 0 : 1); // Create an instruction representing left-shifting IRBuilder<> Builder(&Mul); auto *Shl = Builder.CreateShl(Base, ShiftAmount); }
-
最后,在我们删除
Mul
指令之前,我们需要通知所有原始Mul
的使用者使用我们新创建的Shl
:void replacePow2Mul(BinaryOperator &Mul) { … // Using `replaceAllUsesWith` to update users of `Mul` Mul.Mul are using Shl instead. Thus, we can safely remove Mul from the program.
通过这样,你已经学会了如何正确地替换现有的Instruction
。在最后一小节中,我们将讨论在BasicBlock
或Function
中处理多个指令的一些技巧。
批量处理指令的技巧
到目前为止,我们一直在学习如何插入、删除和替换单个 Instruction
。然而,在现实世界的案例中,我们通常会对一系列 Instruction
实例(例如在 BasicBlock
中)执行此类操作。让我们通过将所学知识应用到遍历 BasicBlock
中所有指令的 for
循环中来尝试这样做;例如:
// `BB` has the type of `BasicBlock&`
for (Instruction &I : BB) {
if (auto *BinOp = dyn_cast<BinaryOperator>(&I)) {
if (isMulWithPowerOf2(BinOp))
replacePow2Mul(BinOp);
}
}
之前使用的代码使用了我们在上一节中看到的 replacePow2Mul
函数来替换 BasicBlock
中的乘法运算,如果乘法满足某些条件,则用左移指令替换。 (这是通过 isMulWithPowerOf2
函数来检查的。同样,这个函数的细节已经留作你的练习。)
这段代码看起来相当简单,但不幸的是,在运行这个转换时会发生崩溃。这里发生的事情是,在运行我们的 replacePow2Mul
之后,BasicBlock
中的 Instruction
实例变得过时。Instruction
迭代器无法跟上对 BasicBlock
中 Instruction
实例应用的变化。换句话说,在迭代的同时更改 Instruction
实例真的很困难。
解决这个问题的最简单方法是将更改推迟:
// `BB` has the type of `BasicBlock&`
std::vector<BinaryOperator*> Worklist;
// Only perform the feasibility check
for (auto &I : BB) {
if (auto *BinOp = dyn_cast<BinaryOperator>(&I)) {
if (isMulWithPowerOf2(BinOp)) Worklist.push_back(BinOp);
}
}
// Replace the target instructions at once
for (auto *BinOp : Worklist) {
replacePow2Mul(BinOp);
}
之前的代码将之前的代码示例分为两部分(作为两个单独的 for
循环)。第一个 for
循环仍然遍历 BasicBlock
中的所有 Instruction
实例。但这次,它只执行检查(即调用 isMulWithPowerOf2
),如果通过检查则不会立即替换 Instruction
实例。相反,这个 for
循环将候选 Instruction
推送到数组存储中 - 第二个 for
循环检查工作列表并通过对每个工作列表项调用 replacePow2Mul
来执行实际的替换。由于第二个 for
循环中的替换不会使任何迭代器失效,我们最终可以无崩溃地转换代码。
当然,还有其他方法可以绕过上述迭代器问题,但它们通常比较复杂且可读性较差。使用工作列表是批量修改指令最安全且最表达性的方式。
Value
是 LLVM 中的第一级构造,概述了不同实体(如指令)之间的数据流。在本节中,我们介绍了在 LLVM IR 中如何表示值以及使分析和管理它更容易的 SSA 模型。我们还学习了如何以高效的方式更新值以及一些操作指令的有用技巧。这将帮助你建立使用 LLVM 构建更复杂和高级编译器优化的基础。
在下一节中,我们将查看一个稍微复杂一点的 IR 单元 - 循环。我们将学习如何在 LLVM IR 中表示循环以及如何与它们一起工作。
处理循环
到目前为止,我们已经学习了几个 IR 单元,如模块、函数、基本块和指令。我们还学习了关于一些 逻辑 单元,如 CFG 和调用图。在本节中,我们将探讨一个更逻辑的 IR 单元:循环。
循环是程序员广泛使用的构造,几乎所有编程语言都包含这个概念。循环会重复执行一定数量的指令多次,这当然可以节省程序员自己重复代码的大量工作。然而,如果循环中包含任何 低效 的代码——例如,总是返回相同值的耗时内存加载——性能下降也将被迭代次数 放大。
因此,编译器的任务是尽可能多地消除循环中的缺陷。除了从循环中移除次优代码外,由于循环是运行时性能的关键路径,人们一直在尝试通过特殊的基于硬件的加速来进一步优化它们;例如,用向量指令替换循环,这些指令可以在几个周期内处理多个标量值。简而言之,循环优化是生成更快、更有效程序的关键。这在高性能和科学计算社区尤为重要。
在本节中,我们将学习如何使用 LLVM 处理循环。我们将尝试将这个主题分为两部分:
-
学习关于 LLVM 中循环表示的知识
-
学习关于 LLVM 中循环基础设施的知识
在 LLVM 中,循环比其他(逻辑)IR 单元稍微复杂一些。因此,我们首先将学习 LLVM 中循环的高级概念及其 术语。然后,在第二部分,我们将了解用于在 LLVM 中处理循环的基础设施和工具。
让我们从第一部分开始。
学习关于 LLVM 中循环表示的知识
在 LLVM 中,循环是通过 Loop
类来表示的。这个类捕捉了任何从其前驱块中的一个基本块到包围基本块的 回边 的控制流结构。在我们深入其细节之前,让我们学习如何检索一个 Loop
实例。
如我们之前提到的,循环是 LLVM IR 中的一个逻辑 IR 单元。也就是说,它是从物理 IR 单元派生(或计算)出来的。在这种情况下,我们需要从 AnalysisManager
中检索计算出的 Loop
实例——该实例首次在 第九章,与 PassManager 和 AnalysisManager 一起工作 中介绍。以下是一个示例,展示了如何在 Pass
函数中检索它:
#include "llvm/Analysis/LoopInfo.h"
…
PreservedAnalyses run(Function &F, FunctionAnalysisManager &FAM) {
LoopInfo &LI = FAM.getResult<LoopAnalysis>(F);
// `LI` contains ALL `Loop` instances in `F`
for (Loop *LP : LI) {
// Working with one of the loops, `LP`
}
…
}
LoopAnalysis
是一个 LLVM 分析类,它为我们提供了一个 LoopInfo
实例,该实例包含 Function
中所有的 Loop
实例。我们可以遍历一个 LoopInfo
实例来获取一个单独的 Loop
实例,如前述代码所示。
现在,让我们看看一个 Loop
实例。
学习循环术语
Loop
实例包含特定循环的多个BasicBlock
实例。LLVM 为其中一些块以及它们之间的(控制流)边赋予了特殊含义/名称。以下图表显示了这些术语:
图 10.8 – 循环中使用的结构和术语
在这里,每个矩形都是一个BasicBlock
实例。然而,只有位于虚线区域内的块才包含在Loop
实例中。前面的图表还显示了两个重要的控制流边。让我们详细解释每个术语:
-
Loop
,它表示具有头块作为唯一后继者的块。换句话说,它是头块的唯一前驱。存在预头块使得编写一些循环转换变得更容易。例如,当我们想要将指令提升到循环外部,以便在进入循环之前只执行一次时,预头块可以是一个放置该指令的好地方。如果我们没有预头块,我们需要为头块每个前驱重复此指令。
-
回边:这是从循环中的某个块到头块的控制流边。一个循环可能包含多个回边。
-
** latch 块**:这是位于回边源处的块。
-
退出块和出口块:这两个名称有些令人困惑:退出块是具有控制流边界的块——即出口边——该边界离开循环。出口边的另一端,即不属于循环的部分,是出口块。一个循环可以包含多个出口块(和退出块)。
这些是Loop
实例中块的重要术语。除了控制流结构之外,编译器工程师还对循环中可能存在的一个特殊值感兴趣:i
变量是归纳变量:
for (int i = 0; i < 87; ++i){…}
一个循环可能不包含归纳变量——例如,许多 C/C++中的while
循环都没有。此外,找到归纳变量及其边界——起始值、结束值和停止值——并不总是容易。我们将在下一节中展示一些工具,以帮助您完成这项任务。但在那之前,我们将讨论一个关于循环规范形式的有趣话题。
理解规范循环
在上一节中,我们学习了 LLVM 中循环的几个术语,包括预头块。回想一下,预头块的存在使得开发循环转换更容易,因为它创建了一个更简单的循环结构。在此讨论之后,还有其他一些属性使得我们更容易编写循环转换。如果一个 Loop
实例具有这些良好的属性,我们通常称它为规范循环。LLVM 中的优化管道将尝试在将其发送到任何循环转换之前将循环“按摩”成这种规范形式。
目前,LLVM 为 Loop
有两种规范形式:一种简化形式和一种旋转形式。简化形式具有以下属性:
-
预头块。
-
单个后继边(因此只有一个锁存块)。
-
退出块的前驱来自循环。换句话说,头块支配所有退出块。
要获得简化的循环,您可以在原始循环上运行 LoopSimplfyPass
。此外,您还可以使用 Loop::isLoopSimplifyForm
方法来检查一个 Loop
是否处于这种形式。
单个后继边的好处在于我们可以更容易地分析递归数据流——例如,归纳变量。对于最后一个属性,如果每个退出块都受循环支配,我们可以在没有其他控制流路径干扰的情况下更容易地将指令“下沉”到循环下面。
让我们看看旋转规范形式。最初,旋转形式在 LLVM 的循环优化管道中不是一个正式的规范形式。但随着越来越多的循环传递依赖于它,它已经成为了“事实上的”规范形式。以下图表显示了这种形式的外观:
图 10.9 – 旋转循环的结构和术语
要获得旋转循环,您可以在原始循环上运行 LoopRotationPass
。要检查循环是否旋转,您可以使用 Loop::isRotatedForm
方法。
这种旋转形式基本上是将任意循环转换为一个带有一些额外检查的 do{…}while(…)
循环(在 C/C++ 中)。更具体地说,假设我们有一个以下的 for
循环:
// `N` is not a constant
for (int i = 0; i < N; ++i){…}
循环旋转有效地将其转换为以下代码:
if (i < N) {
do {
…
++i;
} while(i < N);
}
在前面的代码中突出显示的边界检查用于确保如果 i
变量在最开始就超出范围,则循环不会执行。我们也将这个检查称为循环守卫,如前图所示。
除了循环守卫之外,我们还发现旋转循环有一个组合的头、锁存和退出块。背后的理由是确保这个块中的每条指令都有相同的执行计数。这对于编译器优化,如循环向量化,是一个有用的属性。
通过这些,我们已经了解了各种循环术语和 LLVM 中规范循环的定义。在下一节中,我们将了解一些可以帮助你检查这些属性并以高效方式处理循环的 API。
在 LLVM 中了解循环基础设施
在 在 LLVM 中了解循环表示 部分,我们学习了 LLVM IR 中循环的高级构造和重要属性。在本节中,我们将了解可用于检查这些属性和进一步转换循环的 API。让我们从循环传递开始讨论——这是应用于 Loop
实例的 LLVM 传递。
在 第九章,与 PassManager 和 AnalysisManager 一起工作 中,我们了解到有不同种类的 LLVM 传递,它们在不同的 IR 单元上工作——例如,我们看到了针对 Function
和 Module
的传递。这两种类型的传递具有类似的 run
方法签名——LLVM 传递的主要入口点,如下所示:
PreservedAnalyses run(<IR unit class> &Unit,
<IR unit>AnalysisManager &AM);
它们的 run
方法都接受两个参数——IR 单元实例的引用和 AnalysisManager
实例。
相比之下,循环传递的 run
方法签名稍微复杂一些,如下所示:
PreservedAnalyses run(Loop &LP, LoopAnalysisManager &LAM,
LoopStandardAnalysisResults &LAR,
LPMUpdater &U);
run
方法接受四个参数,但我们已经了解了前两个。以下是其他两个参数的描述:
-
第三个参数,
LoopStandardAnalysisResults
,为你提供一些分析数据实例,例如AAResults
(别名分析数据)、DominatorTree
和LoopInfo
。这些分析被许多循环优化广泛使用。然而,大多数分析都由FunctionAnalysisManager
或ModuleAnalysisManager
管理。这意味着,最初,开发者需要实现更复杂的方法——例如,使用OuterAnalysisManagerProxy
类——来检索它们。LoopStandardAnalysisResults
实例基本上帮助你提前检索这些分析数据。 -
最后一个参数用于通知
PassManager
任何新添加的循环,以便它可以在稍后处理之前将这些新循环放入队列中。它还可以告诉PassManager
再次将当前循环放入队列中。
当我们编写传递时,我们希望使用 AnalysisManager 提供的分析数据——在这种情况下,它是 LoopAnalysisManager
实例。LoopAnalysisManager
的用法与其他版本的 AnalysisManager(例如,上一章中我们了解的 FunctionAnalysisManager
)类似。唯一的区别是我们需要向 getResult
方法提供一个额外的参数。以下是一个示例:
PreservedAnalyses run(Loop &LP, LoopAnalysisManager &LAM,
LoopStandardAnalysisResults &LAR,
LPMUpdater &U) {
…
LoopNest &LN = LAM.getResult<LoopNestAnalysis>(LP, LAR);
…
}
LoopNest
是由 LoopNestAnalysis
生成的分析数据。(我们将在 处理嵌套循环 部分简要讨论这两个内容。)
如前所述,LoopAnalysisManager::getResult
除了接受一个 LoopStandarAnalysisResults
类型的参数外,还接受一个 Loop
实例。
除了具有不同的 run
方法签名和 LoopAnalysisManager
的略微不同用法外,开发者可以以与其他类型相同的方式构建他们的循环传递。现在我们已经了解了循环传递和 AnalysisManager 提供的基础,是时候看看一些专门的循环了。我们将首先介绍的是嵌套循环。
处理嵌套循环
到目前为止,我们一直在谈论只有一层的循环。然而,嵌套循环——包含其他循环的循环——在现实场景中也很常见。例如,大多数矩阵乘法实现至少需要两层循环。
嵌套循环通常被表示为树——称为循环树。在循环树中,每个节点代表一个循环。如果一个节点有父节点,这意味着相应的循环被父节点所建模的循环包含。以下图展示了这一例子:
![Figure 10.10 – 一个循环树示例
![img/B14590_Figure_10.10.jpg]
图 10.10 – 一个循环树示例
在前面的图中,循环 j
和 g
被包含在循环 i
内,因此它们都是循环树中循环 i
的子节点。同样,循环 k
—— 最内层的循环 —— 被建模为树中循环 j
的子节点。
循环树的根也代表 Function
中的顶层循环。回想一下,之前我们学习了如何通过迭代 LoopInfo
对象来检索 Function
中的所有 Loop
实例——以这种方式检索到的每个 Loop
实例都是顶层循环。对于给定的 Loop
实例,我们可以以类似的方式检索其下一层的子循环。以下是一个示例:
// `LP` has the type of `Loop&`
for (Loop *SubLP : LP) {
// `SubLP` is one of the sub-loops at the next layer
}
注意,前面的代码片段仅遍历了下一级的子循环,而不是所有后代子循环。要遍历树中的所有后代子循环,你有两种选择:
-
通过使用
Loop::getLoopsInPreorder()
方法,你可以以预排序的方式遍历Loop
实例的所有后代循环。 -
在 迭代不同的 IR 单元 部分,我们学习了
GraphTraits
是什么以及 LLVM 如何使用它进行图遍历。事实证明,LLVM 还为循环树提供了一个默认的GraphTraits
实现。因此,你可以使用 LLVM 中现有的图迭代器遍历循环树,例如后序和深度优先,仅举几例。例如,以下代码尝试以深度优先的方式遍历以RootL
为根的循环树:#include "llvm/Analysis/LoopInfo.h" #include "llvm/ADT/DepthFirstIterator.h" … // `RootL` has the type of `Loop*` for (GraphTraits, we can have more flexibility when it comes to traversing a loop tree.
除了处理循环树中的单个循环外,LLVM 还提供了一个表示整个结构的包装类——LoopNest
。
LoopNest
是由LoopNestAnalysis
生成的分析数据。它封装了给定Loop
实例中的所有子循环,并为常用功能提供了几个“快捷”API。以下是一些重要的 API:
-
getOutermostLoop()
/getInnermostLoop()
: 这些实用工具检索最外层/最内层的Loop
实例。这些工具非常实用,因为许多循环优化仅适用于内层或最外层循环。 -
areAllLoopsSimplifyForm()
/areAllLoopsRotatedForm()
: 这些有用的实用工具会告诉您所有包围的循环是否处于我们之前章节中提到的某种规范形式。 -
getPerfectLoops(…)
: 您可以使用此方法获取当前循环层次结构中的所有完美循环。所谓完美循环,是指彼此嵌套在一起且之间没有“间隙”的循环。以下是一个完美循环和非完美循环的示例:// Perfect loops for(int i=…) { for(int j=…){…} } // Non-perfect loops for(int x=…) { foo call site is the gap between the upper and lower loops.Perfect loops are preferrable in many loop optimizations. For example, it's easier to *unroll* perfectly nested loops – ideally, we only need to duplicate the body of the innermost loop.
通过这些,您已经学会了如何处理嵌套循环。在下一节中,我们将学习关于循环优化另一个重要主题:归纳变量。
获取归纳变量及其范围
归纳变量是在每次循环迭代中按照一定模式进步的变量。它是许多循环优化的关键。例如,为了将循环向量化,我们需要知道归纳变量在数组中是如何使用的 – 即我们想要放入向量的数据 – 在循环内部。归纳变量还可以帮助我们解决循环的迭代次数 – 总迭代次数。在我们深入细节之前,以下图表显示了与归纳变量及其在循环中的位置相关的术语:
![图 10.11 – 归纳变量的术语
图 10.11 – 归纳变量的术语
现在,让我们介绍一些可以帮助您检索前面图表中显示的组件的 API。
首先,让我们谈谈归纳变量。Loop
类已经提供了两个方便的方法来检索归纳变量:getCanonicalInductionVariable
和getInductionVariable
。这两个方法都返回一个PHINode
实例作为归纳变量(如果有)。第一个方法只能在归纳变量从零开始且每次迭代只增加一的情况下使用。另一方面,第二个方法可以处理更复杂的情况,但需要一个ScalarEvolution
实例作为参数。
ScalarEvolution
是 LLVM 中一个有趣且强大的框架。简单来说,它试图跟踪值如何变化 – 例如,通过算术运算 – 在程序路径上。将这个概念应用到循环优化中,它用于捕获循环中的递归值变化行为,这与归纳变量有很强的关联。
要了解循环中归纳变量的行为,你可以通过 Loop::getInductionDescriptor
获取一个 InductionDescriptor
实例。一个 InductionDescriptor
实例提供了诸如初始值、步长值以及在每个迭代中更新归纳变量的指令等信息。Loop
类还提供了一个类似的数据结构来实现归纳变量的边界:Loop::LoopBounds
类。LoopBounds
不仅提供了归纳变量的初始值和步长值,还提供了预期的结束值以及用于检查退出条件的谓词。你可以通过 Loop::getBounds
方法获取一个 LoopBounds
实例。
循环对于程序的运行时性能至关重要。在本节中,我们学习了循环在 LLVM IR 中的表示方式以及如何与之交互。我们还探讨了它们的高级概念和用于检索所需循环属性的多种实用 API。有了这些知识,你离创建更有效、更具侵略性的循环优化,并从目标应用程序中获得更高的性能又近了一步。
摘要
在本节中,我们学习了关于 LLVM IR 的内容——这是一个位于整个 LLVM 框架核心的目标无关中间表示。我们介绍了 LLVM IR 的高层结构,并提供了如何在它的层次结构中遍历不同单元的实用指南。我们还关注了指令、值和 SSA 形式,这些对于高效地使用 LLVM IR 至关重要。我们还就同一主题提供了几个实用的技能、技巧和示例。最后但同样重要的是,我们学习了如何在 LLVM IR 中处理循环——这是一个优化性能敏感型应用程序的重要技术。有了这些能力,你可以在 LLVM IR 上执行更广泛的程序分析和代码优化任务。
在下一章中,我们将学习关于一组 LLVM 工具 API 的集合,这些 API 可以在开发、诊断和调试使用 LLVM 时提高你的生产力。
第十一章:第十一章:使用支持实用工具准备就绪
在上一章中,我们学习了低级虚拟机(LLVM)中间表示(IR)的基础知识——LLVM 中的目标无关中间表示,以及如何使用 C++ 应用程序编程接口(API)来检查和操作它。这些是进行程序分析和转换在 LLVM 中的核心技术。除了这些技能集之外,LLVM 还提供了许多支持实用工具,以提高编译器开发者在处理 LLVM IR 时的生产力。我们将在本章中涵盖这些主题。
编译器是一块复杂的软件。它不仅需要处理数千种不同的案例——包括不同形状的输入程序和广泛的目标架构——而且编译器的正确性也是一个重要的话题:也就是说,编译后的代码需要与原始代码具有相同的行为。LLVM,一个大规模的编译器框架(可能是最大的之一),也不例外。
为了应对这些复杂性,LLVM 提供了一系列的实用工具来改善开发体验。在本章中,我们将向您展示如何准备使用这些工具。这里涵盖的实用工具可以帮助您诊断您正在开发的 LLVM 代码中出现的问题。这包括更高效的调试、错误处理和性能分析能力;例如,其中一个工具可以收集关键组件(如特定 Pass 处理的基本块数量)的统计数据,并自动生成总结报告。另一个例子是 LLVM 自身的错误处理框架,它可以尽可能防止未处理的错误(一种常见的编程错误)。
在本章中,我们将讨论以下主题:
-
打印诊断信息
-
收集统计数据
-
添加时间测量
-
LLVM 中的错误处理实用工具
-
了解
Expected
和ErrorOr
类
通过这些实用工具的帮助,您将能够更好地调试和诊断 LLVM 代码,让您能够专注于使用 LLVM 实现的核心逻辑。
技术要求
在本节中,我们还将使用 LLVM Pass 作为平台来展示不同的 API 使用方法。因此,请确保您已经构建了opt
命令行工具,如下所示:
$ ninja opt
注意,本章中的一些内容仅适用于 LLVM 的调试构建版本。请查阅第一章节,第一章,构建 LLVM 时的资源节约,以回顾如何在调试模式下构建 LLVM。
如果您不确定如何创建新的 LLVM Pass,也可以回到第九章,使用 PassManager 和 AnalysisManager 工作时。
本章的示例代码可以在以下位置找到:
打印诊断消息
在软件开发中,有许多方法可以诊断错误——例如,使用调试器,将清理器插入到你的程序中(例如,捕获无效的内存访问),或者简单地使用最简单但最有效的方法之一:添加 打印语句。虽然最后一个选项听起来并不十分聪明,但实际上在许多情况下非常有用,在其他选项无法发挥全部潜力的情况下(例如,调试信息质量较差的发布模式二进制文件或多线程程序)。
LLVM 提供了一个小工具,它不仅可以帮助你打印调试信息,还可以过滤要显示的消息。假设我们有一个 LLVM Pass,名为 SimpleMulOpt
,它将乘以 2 的幂的常数替换为左移操作(这正是我们在上一章的最后一节中做的,处理 LLVM IR)。以下是它的 run
方法的一部分:
PreservedAnalyses
SimpleMulOpt::run(Function &F, FunctionAnalysisManager &FAM) {
for (auto &I : instructions(F)) {
if (auto *BinOp = dyn_cast<BinaryOperator>(&I) &&
BinOp->getOpcode() == Instruction::Mul) {
auto *LHS = BinOp->getOperand(0),
*RHS = BinOp->getOperand(1);
// `BinOp` is a multiplication, `LHS` and `RHS` are its
// operands, now trying to optimize this instruction…
…
}
}
…
}
之前的代码在寻找表示算术乘法的指令之前,会遍历给定函数中的所有指令。如果存在这样的指令,Pass 将会与 LHS
和 RHS
操作数(这些操作数出现在代码的其余部分中——这里没有展示)一起工作。
假设我们想在开发过程中打印出操作数变量。最简单的方法就是使用我们老朋友 errs()
,它将任意消息流到 stderr
,如下面的代码片段所示:
// (extracted from the previous snippet)
…
auto *LHS = BinOp->getOperand(0),
*RHS = BinOp->getOperand(1);
errs() << "Found a multiplication with operands ";
LHS->printAsOperand(errs());
errs() << " and ";
RHS->printAsOperand(errs());
…
在之前的代码片段中使用的 printAsOperand
将 Value
的文本表示打印到给定的流中(在这个例子中是 errs()
)。
除了这些消息即使在生产环境中也会被打印出来之外,一切看起来都很正常,这不是我们想要的。我们可以在发布产品之前删除这些代码,在这些代码周围添加一些宏保护(例如,#ifndef NDEBUG
),或者我们可以使用 LLVM 提供的调试工具。以下是一个例子:
#include "llvm/Support/Debug.h"
#define DEBUG_TYPE "simple-mul-opt"
…
auto *LHS = BinOp->getOperand(0),
*RHS = BinOp->getOperand(1);
LLVM_DEBUG(dbgs() << "Found a multiplication with operands ");
LLVM_DEBUG(LHS->printAsOperand(dbgs()));
LLVM_DEBUG(dbgs() << " and ");
LLVM_DEBUG(RHS->printAsOperand(dbgs()));
…
之前的代码基本上做了以下三件事:
-
将
errs()
的任何使用替换为dbgs()
。这两个流基本上做的是同一件事,但后者会在输出消息中添加一个漂亮的横幅(Debug Log Output
)。 -
使用
LLVM_DEBUG(…)
宏函数将所有与调试打印相关的行包装起来。使用此宏确保包含的行仅在开发模式下编译。它还编码了调试消息类别,我们将在稍后介绍。 -
在使用任何
LLVM_DEBUG(…)
宏函数之前,请确保将DEBUG_TYPE
定义为所需的调试类别字符串(在这个例子中是simple-mul-opt
)。
除了上述代码修改之外,我们还需要使用额外的命令行标志-debug
与opt
一起打印这些调试信息。以下是一个示例:
$ opt -O3 -debug -load-pass-plugin=… …
但然后,你会发现输出相当嘈杂。有来自其他 LLVM Pass 的大量调试信息。在这种情况下,我们只对来自我们 Pass 的消息感兴趣。
为了过滤掉无关的消息,我们可以使用-debug-only
命令行标志。以下是一个示例:
$ opt -O3 -debug-only=simple-mul-opt -load-pass-plugin=… …
-debug-only
后面的值是我们之前代码片段中定义的DEBUG_TYPE
值。换句话说,我们可以使用每个 Pass 定义的DEBUG_TYPE
来过滤所需的调试信息。我们还可以选择多个调试类别来打印。例如,查看以下命令:
$ opt -O3 -debug-only=sroa,simple-mul-opt -load-pass-plugin=… …
此命令不仅打印来自我们的SimpleMulOpt
Pass 的调试信息,还打印来自SROA
Pass 的调试信息——这是包含在O3
优化管道中的 LLVM Pass。
除了为 LLVM Pass 定义单个调试类别(DEBUG_TYPE
)之外,实际上你可以在 Pass 内部使用尽可能多的类别。这在例如你想为 Pass 的不同部分使用不同的调试类别时很有用。例如,我们可以在我们的SimpleMulOpt
Pass 的每个操作数上使用不同的类别。以下是我们可以这样做的方法:
…
#define DEBUG_TYPE "simple-mul-opt"
auto *LHS = BinOp->getOperand(0),
*RHS = BinOp->getOperand(1);
LLVM_DEBUG(dbgs() << "Found a multiplication instruction");
DEBUG_WITH_TYPE("simple-mul-opt-lhs",
LHS->printAsOperand(dbgs() << "LHS operand: "));
DEBUG_WITH_TYPE("simple-mul-opt-rhs",
RHS->printAsOperand(dbgs() << "RHS operand: "));
…
DEBUG_WITH_TYPE
是LLVM_DEBUG
的一个特殊版本。它使用第二个参数执行代码,第一个参数作为调试类别,可以不同于当前定义的DEBUG_TYPE
值。在前面的代码片段中,除了使用原始的simple-mul-opt
类别打印Found a multiplication instruction
之外,我们还使用simple-mul-opt-lhs
来打印与simple-mul-opt-rhs
相关的消息,以打印其他操作数的消息。有了这个特性,我们可以通过opt
命令更精细地选择调试信息类别。
你现在已经学会了如何使用 LLVM 提供的工具在开发环境中打印调试信息,以及如何在需要时过滤它们。在下一节中,我们将学习如何在运行 LLVM Pass 时收集关键统计数据。
收集统计数据
如前文所述,编译器是一块复杂的软件。收集统计数字——例如,特定优化处理的基本块数量——是快速了解编译器运行时行为的最简单和最有效的方法之一。
在 LLVM 中有几种收集统计数据的方法。在本节中,我们将学习三种最常见和有用的选项,这些方法在此概述:
-
使用
Statistic
类 -
使用优化注释
-
添加时间测量
第一个选项是一个通用工具,通过简单的计数器收集统计信息;第二个选项专门设计用于分析编译器优化;最后一个选项用于在编译器中收集时间信息。
让我们从第一个选项开始。
使用 Statistic 类
在本节中,我们将通过将新功能添加到上一节中的 SimpleMulOpt
LLVM Pass 来展示新功能。首先,让我们假设我们不仅想要打印出乘法指令的运算符 Value
,而且还想计数我们的 Pass 处理了多少条乘法指令。首先,让我们尝试使用我们刚刚学到的 LLVM_DEBUG
基础设施来实现这个功能,如下所示:
#define DEBUG_TYPE "simple-mul-opt"
PreservedAnalyses
SimpleMulOpt::run(Function &F, FunctionAnalysisManager &FAM) {
unsigned NumMul = 0;
for (auto &I : instructions(F)) {
if (auto *BinOp = dyn_cast<BinaryOperator>(&I) &&
BinOp->getOpcode() == Instruction::Mul) {
++NumMul;
…
}
}
LLVM_DEBUG(dbgs() << "Number of multiplication: " << NumMul);
…
}
这种方法看起来相当直接。但它的缺点是我们感兴趣的统计数字与其他调试信息混合在一起。我们需要采取额外的措施来解析或过滤我们想要的值,因为尽管你可以争辩说这些问题可以通过为每个计数器变量使用单独的 DEBUG_TYPE
标签来解决,但当计数器变量的数量增加时,你可能会发现自己创建了大量的冗余代码。
一个优雅的解决方案是使用 LLVM 提供的 Statistic
类(和相关工具)。以下是使用此解决方案重写的版本:
#include "llvm/ADT/Statistic.h"
#define DEBUG_TYPE "simple-mul-opt"
STATISTIC(NumMul, "Number of multiplications processed");
PreservedAnalyses
SimpleMulOpt::run(Function &F, FunctionAnalysisManager &FAM) {
for (auto &I : instructions(F)) {
if (auto *BinOp = dyn_cast<BinaryOperator>(&I) &&
BinOp->getOpcode() == Instruction::Mul) {
++NumMul;
…
}
}
…
}
上述代码片段显示了 Statistic
的用法,通过调用 STATISTIC
宏函数创建一个 Statistic
类型变量(带有文本描述)并像正常整数计数器变量一样使用它。
这个解决方案只需要修改原始代码中的几行,并且它收集所有计数器值,并在优化的最后以表格视图的形式打印出来。例如,如果你使用带有 -stats
标志的 opt
运行 SimpleMulOpt
Pass,你会得到以下输出:
$ opt -stats –load-pass-plugin=… …
===-------------------------------===
… Statistics Collected …
===-------------------------------===
87 simple-mul-opt - Number of multiplications processed
$
87
是在 SimpleMulOpt
中处理的乘法指令的数量。当然,你可以自由地添加尽可能多的 Statistic
计数器来收集不同的统计信息。如果你在管道中运行多个 Pass,所有统计数字都会在同一张表中展示。例如,如果我们向 SimpleMulOpt
中添加另一个 Statistic
计数器来收集乘法指令中的 none-power-of-two constant operands
的数量,并使用 聚合替换标量(SROA)运行 Pass,我们可以得到类似于下面所示的输出:
$ opt -stats –load-pass-plugin=… --passes="sroa,simple-mult-opt" …
===-------------------------------===
… Statistics Collected …
===-------------------------------===
94 simple-mul-opt - Number of multiplications processed
87 simple-mul-opt - Number of none-power-of-two constant operands
100 sroa - Number of alloca partition uses rewritten
34 sroa - Number of instructions deleted
…
$
上述代码片段的第二列是原始 Pass 的名称,该名称由在调用 STATISTIC
之前定义的 DEBUG_TYPE
值指定。
或者,你可以将结果输出到 -stats-json
标志的 opt
中。例如,看看下面的代码片段:
$ opt -stats -stats-json –load-pass-plugin=… …
{
"simple-mul-opt.NumMul": 87
}
$
在这种 JSON 格式下,我们不是用文本描述来打印统计值,而是使用统计条目字段的名称,其格式为:"<Pass name>.<Statistic variable name>"
(这里的 Pass name 同时也是 DEBUG_TYPE
的值)。此外,您可以使用 -info-output-file=<文件名>
命令行选项将统计结果(无论是默认格式还是 JSON 格式)打印到文件中。以下代码片段展示了这一示例:
$ opt -stats -stats-json -info-output-file=my_stats.json …
$ cat my_stats.json
{
"simple-mul-opt.NumMul": 87
}
$
现在,你已经学会了如何使用 Statistic
类收集简单的统计值。在下一节中,我们将学习一种独特的编译器优化统计收集方法。
使用优化注释
典型的编译器优化通常包括两个阶段:搜索从输入代码中寻找所需的模式,然后是修改代码。以我们的 SimpleMulOpt
Pass 为例:第一阶段是寻找乘法指令(BinaryOperator
与 Instruction::Mul
和 IRBuilder::CreateShl(…)
),并将所有旧的乘法指令用法替换为这些。
然而,在许多情况下,优化算法在第一阶段由于不可行的输入代码而简单地“退出”。例如,在 SimpleMulOpt
中,我们正在寻找乘法指令,但如果传入的指令不是 BinaryOperator
,Pass 将不会进入第二阶段(并继续到下一个指令)。有时,我们想知道这种退出的原因,这有助于我们改进优化算法或诊断不正确/次优的编译器优化。LLVM 提供了一个很好的工具,称为优化注释,用于收集和报告在优化 Pass 中发生的这种退出(或任何其他信息)。
例如,假设我们有以下输入代码:
int foo(int *a, int N) {
int x = a[5];
for (int i = 0; i < N; i += 3) {
a[i] += 2;
x = a[5];
}
return x;
}
理论上,我们可以使用 循环不变代码移动(LICM)来优化这段代码到一个等效的代码库,如下所示:
int foo(int *a, int N) {
for (int i = 0; i < N; i += 3) {
a[i] += 2;
}
return a[5];
}
我们可以将这个作为第五个数组元素 a[5]
,在循环内部从未改变其值。然而,如果我们运行 LLVM 的 LICM Pass 对原始代码进行优化,它将无法执行预期的优化。
为了诊断这个问题,我们可以使用带有附加选项的 opt
命令:--pass-remarks-output=<filename>
。该文件名将是一个 YAML Ain't Markup Language(YAML)文件,其中优化注释将打印出 LICM 未能优化的可能原因。以下是一个示例:
$ opt -licm input.ll –pass-remarks-output=licm_remarks.yaml …
$ cat licm_remarks.yaml
…
--- !Missed
Pass: licm
Name: LoadWithLoopInvariantAddressInvalidated
Function: foo
Args:
- String: failed to move load with loop-invariant address because the loop may invalidate its value
...
$
前面的输出中的cat
命令显示了licm_remarks.yaml
中的优化注释条目之一。这个条目告诉我们,在处理foo
函数时,LICM Pass 中发生了一个遗漏的优化。它还告诉我们原因:LICM 不确定特定的内存地址是否被循环无效化。虽然这个消息没有提供详细的细节,但我们仍然可以推断出,与 LICM 相关的有问题内存地址可能是a[5]
。LICM 不确定a[i] += 2
语句是否修改了a[5]
的内容。
借助这些知识,编译器开发者可以亲自动手改进 LICM——例如,教 LICM 识别步长值大于 1 的归纳变量(即,这个循环中的i
变量,在这个例子中是 3,因为i += 3
)。
要生成如前输出中所示的那种优化注释,编译器开发者需要将特定的实用 API 集成到他们的优化 Pass 中。为了向您展示如何在您的 Pass 中实现这一点,我们将重用我们的SimpleMulOpt
Pass 作为示例。以下是SimpleMulOpt
中执行第一阶段——搜索具有 2 的幂次常量操作数的乘法——的部分代码:
…
for (auto &I : instructions(F)) {
if (auto *BinOp = dyn_cast<BinaryOperator>(&I))
if (BinOp->getOpcode() == Instruction::Mul) {
auto *LHS = BinOp->getOperand(0),
*RHS = BinOp->getOperand(1);
// Has no constant operand
if (!isa<Constant>(RHS)) continue;
const APInt &Const = cast<ConstantInt>(RHS)->getValue();
// Constant operand is not power of two
if (!Const.isPowerOf2()) continue;
…
}
}
上述代码在确保操作数是 2 的幂次操作数之前,会检查操作数是否为常量。如果这两个检查中的任何一个失败,算法将通过继续执行函数中的下一个指令来退出。
我们故意在这个代码中插入了一个小缺陷,使其功能减弱,我们将通过使用优化注释向您展示如何找到这个问题。以下是执行此操作的步骤:
-
首先,我们需要有一个
OptimizationRemarkEmitter
实例,它可以帮助您发出注释消息。这可以通过其父分析器OptimizationRemarkEmitterAnalysis
获得。以下是如何在SimpleMulOpt::run
方法开始时包含它的方法:#include "llvm/Analysis/OptimizationRemarkEmitter.h" PreservedAnalyses SimpleMulOpt::run(Function &F, FunctionAnalysisManager &FAM) { OptimizationRemarkEmitter &ORE = FAM.getResult<OptimizationRemarkEmitterAnalysis>(F); … }
-
然后,我们将使用这个
OptimizationRemarkEmitter
实例来发出一个优化注释,如果乘法指令缺少常量操作数,如下所示:#include "OptimizationRemarkEmitter::emit method takes a lambda function as the argument. This lambda function will be invoked to emit an optimization remark object if the optimization remark feature is turned on (via the –pass-remarks-output command-line option we've seen previously, for example).
-
OptimizationRemarkMissed
类(请注意,它没有在OptimizationRemarkEmitter.h
中声明,而是在DiagnosticInfo.h
头文件中)表示一个遗漏的I
没有任何常量操作数的注释。OptimizationRemarkMissed
的构造函数接受三个参数:Pass 的名称、遗漏的优化机会的名称以及封装的 IR 单元(在这种情况下,我们使用封装的Function
)。除了构建OptimizationRemarkMissed
对象外,我们还通过流操作符(<<
)在末尾连接几个对象。这些对象最终将被放入我们之前看到的 YAML 文件中每个优化注释条目的Args
部分。除了使用
OptimizationRemarkMissed
来通知您错过优化机会外,您还可以使用从DiagnosticInfoOptimizationBase
派生的其他类来呈现不同类型的信息——例如,使用OptimizationRemark
来找出哪些优化已被成功应用,并使用OptimizationRemarkAnalysis
来记录分析数据/事实。 -
在流操作符连接的对象中,
ore::NV(…)
似乎是一个特殊情况。回想一下,在优化备注 YAML 文件中,Args
部分下的每一行都是一个键值对(例如,String: failed to move load with….
,其中String
是键)。ore::NV
对象允许您自定义键值对。在这种情况下,我们使用Inst
作为键,SS.str()
作为值。此功能为解析优化备注 YAML 文件提供了更多灵活性——例如,如果您想编写一个小工具来可视化优化备注,自定义Args
键可以在解析阶段更容易地区分关键数据和其他字符串。 -
现在您已经插入了生成优化备注的代码,是时候对其进行测试了。这次,我们将使用以下
IR
函数作为输入代码:define i32 @bar(i32 %0) { %2 = mul nsw i32 %0, 3 %3 = mul nsw i32 8, %3 ret %3 }
您可以重新构建
SimpleMulOpt
Pass,并使用如下命令运行它:$ opt –load-pass-plugin=… –passes="simple-mul-opt" \ SimpleMulOpt bailed out because it couldn't find a constant operand on one of the (multiplication) instructions. The Args section shows a detailed reason for this.With this information, we realize that `SimpleMulOpt` is unable to optimize a multiplication whose *first* operand (LHS operand) is a power-of-two constant, albeit a proper optimization opportunity. Thus, we can now fix the implementation of `SimpleMulOpt` to check if *either* of the operands is constant, as follows:
…
if (BinOp->getOpcode() == Instruction::Mul) {
auto *LHS = BinOp->getOperand(0),
*RHS = BinOp->getOperand(1);
// 没有常数操作数
if (!isa
(RHS) && !isa (LHS)) { ORE.emit(& {
return …
});
continue;
}
…
}
…
You have now learned how to emit optimization remarks in an LLVM Pass and how to use the generated report to discover potential optimization opportunities.
到目前为止,我们只研究了生成的优化备注 YAML 文件。虽然它提供了有价值的诊断信息,但如果我们能获得更多细粒度和直观的位置信息,以了解这些备注的确切发生位置,那就太好了。幸运的是,Clang 和 LLVM 提供了一种实现这一目标的方法。
在 Clang 的帮助下,我们实际上可以生成带有源位置(即原始源文件中的行和列号)的优化备注。此外,LLVM 为您提供了一个小型实用工具,可以将优化备注与其对应源位置关联,并在网页上可视化结果。以下是这样做的方法:
-
让我们重用以下代码作为输入:
int foo(int *a, int N) { for (int i = 0; i < N; i += 3) { a[i] += 2; } return a[5]; }
首先,让我们使用以下
clang
命令生成优化备注:$ clang -O3 -foptimization-record-file is the command-line option used to generate an optimization remark file with the given filename.
-
在生成
licm.remark.yaml
之后,让我们使用一个名为opt-viewer.py
的工具来可视化备注。opt-viewer.py
脚本默认不是安装在典型位置——它不是放在<install path>/bin
(例如/usr/bin
)中,而是安装在<install path>/share/opt-viewer
(/usr/share/opt-viewer
)。我们将使用以下命令行选项调用此脚本:$ opt-viewer.py --source-dir=$PWD \ --target-dir=licm_remark licm.remark.yaml
(请注意,
opt-viewer.py
依赖于几个 Python 包,如pyyaml
和pygments
。请在使用opt-viewer.py
之前安装它们。) -
在
licm_remark
文件夹内将生成一个 HTML 文件——index.html
。在您打开网页之前,请将原始源代码——opt_remark_licm.c
——也复制到该文件夹中。之后,您将能够看到如下网页:![图 11.1 – 优化注释与源文件结合的网页
图 11.1 – 优化注释与源文件结合的网页
我们特别关注其中的两列:分别以红色、绿色和白色渲染的Missed
、Passed
或Analyzed
——分别附加在“源位置”列中显示的给定行上。
如果我们点击“源位置”列中的链接,这将带您到一个看起来像这样的页面:
图 11.2 – 优化注释的详细信息
这个页面为您提供了一个优化注释细节的清晰视图,与原始源代码行交织在一起。例如,在第 3 行,loop-vectorize
Pass 表示它无法向量化这个循环,因为它的成本模型认为这样做没有好处。
你现在已经学会了如何使用优化注释来深入了解优化 Pass,这在调试缺失的优化机会或修复误编译错误时特别有用。
在下一节中,我们将学习一些有用的技能来分析 LLVM 的执行时间。
添加时间测量
LLVM 是一个庞大的软件,有成百上千的组件紧密协作。其不断增长的运行时间正逐渐成为一个问题。这影响了众多对编译时间敏感的使用场景——例如,即时编译器(JIT)。为了系统地诊断这个问题,LLVM 提供了一些有用的工具来分析执行时间。
时间分析一直是软件开发中的重要主题。通过从单个软件组件收集运行时间,我们可以更容易地发现性能瓶颈。在本节中,我们将学习 LLVM 提供的两个工具:Timer
类和TimeTraceScope
类。让我们首先从Timer
类开始。
使用 Timer 类
如其名所示,Timer
类可以测量代码区域的执行时间。以下是一个例子:
#include "llvm/Support/Timer.h"
…
Timer T("MyTimer", "A simple timer");
T.startTimer();
// Do some time-consuming works…
T.stopTimer();
在前面的代码片段中,Timer
实例T
通过startTimer
和stopTimer
方法调用测量的区域时间。
现在我们已经收集了时间数据,让我们尝试将其打印出来。以下是一个例子:
Timer T(…);
…
TimeRecord TR = T.getTotalTime();
TR.print(TR, errs());
在前面的代码片段中,一个TimeRecord
实例封装了Timer
类收集的数据。然后我们可以使用TimeRecord::print
将其打印到一个流中——在这种情况下,是errs()
流。此外,我们还通过print
的第一个参数分配了另一个TimeRecord
实例——作为我们想要比较的总时间间隔。让我们看看此代码的输出,如下所示:
===---------------------------------------------------------===
Miscellaneous Ungrouped Timers
===---------------------------------------------------------===
---User Time--- --User+System-- ---Wall Time--- --- Name ---
0.0002 (100.0%) 0.0002 (100.0%) 0.0002 (100.0%) A simple timer
0.0002 (100.0%) 0.0002 (100.0%) 0.0002 (100.0%) Total
0.0002 (100.0%) 0.0002 (100.0%) 0.0002 (100.0%)
在前面的输出中,第一行显示了从我们之前的Timer
实例收集的TimeRecord
实例,而第二行显示了总时间——TimeRecord::print
的第一个参数。
现在我们知道了如何打印单个Timer
实例收集的时间数据,但多个计时器怎么办?LLVM 为Timer
类提供了另一个支持工具:TimerGroup
类。以下是一个TimerGroup
类的使用示例:
TimerGroup TG("MyTimerGroup", "My collection of timers");
Timer T("MyTimer", "A simple timer", TG);
T.startTimer();
// Do some time-consuming works…
T.stopTimer();
Timer T2("MyTimer2", "Yet another simple timer", TG);
T2.startTimer();
// Do some time-consuming works…
T2.stopTimer();
TG.print(errs());
在前面的代码片段中,我们声明了一个TimerGroup
实例,TG
,并将其用作我们创建的每个Timer
实例的第三个构造函数参数。最后,我们使用TimerGroup::print
来打印它们。以下是此代码的输出:
===---------------------------------------------------------===
My collection of timers
===---------------------------------------------------------===
Total Execution Time: 0.0004 seconds (0.0004 wall clock)
---User Time--- --User+System-- ---Wall Time--- --- Name ---
0.0002 ( 62.8%) 0.0002 ( 62.8%) 0.0002 ( 62.8%) A simple timer
0.0001 ( 37.2%) 0.0001 ( 37.2%) 0.0001 ( 37.2%) Yet another simple timer
0.0004 (100.0%) 0.0004 (100.0%) 0.0004 (100.0%) Total
输出中的每一行(除了最后一行)都是该组中每个Timer
实例的TimeRecord
实例。
到目前为止,我们一直在使用Timer::startTimer
和Timer::stopTimer
来切换计时器。为了在不需要手动调用这两个方法的情况下,更容易地测量代码块内的时间间隔——即花括号{}
包围的区域——LLVM 提供了一个自动在进入代码块时启动计时器并在退出时关闭计时器的工具。让我们看看如何使用TimeRegion
类,以下是一个示例代码:
TimerGroup TG("MyTimerGroup", "My collection of timers");
{
Timer T("MyTimer", "A simple timer", TG);
TimeRegion TR(T);
// Do some time-consuming works…
}
{
Timer T("MyTimer2", "Yet another simple timer", TG);
TimeRegion TR(T);
// Do some time-consuming works…
}
TG.print(errs());
如前述代码片段所示,我们不是调用startTimer
/stopTimer
,而是将待测代码放入一个单独的代码块中,并使用TimeRegion
变量自动切换计时器。此代码将打印出与上一个示例相同的内容。借助TimeRegion
,我们可以拥有更简洁的语法,并避免任何由于忘记关闭计时器而犯的错误。
你现在已经学会了如何使用Timer
及其支持工具来测量特定代码区域的执行时间。在下一节中,我们将学习一种更高级的时间测量形式,它可以捕获程序的层次结构。
收集时间跟踪
在上一节中,我们学习了如何使用Timer
来收集一小段代码区域的执行时间。尽管这给了我们编译器运行时性能的轮廓,但我们有时需要更结构化的时间统计信息,以便完全理解任何系统性问题。
TimeTraceScope
是 LLVM 提供的一个用于执行全局范围时间分析的类。它的用法很简单:类似于我们在上一节中看到的TimeRegion
,TimeTraceScope
实例在进入和退出代码块时会自动打开和关闭时间分析器。以下是一个示例:
TimeTraceScope OuterTimeScope("TheOuterScope");
for (int i = 0; i < 50; ++i) {
{
TimeTraceScope InnerTimeScope("TheInnerScope");
foo();
}
bar();
}
在前面的代码片段中,我们创建了两个 TimeTraceScope
实例:OuterTimeScope
和 InnerTimeScope
。这些尝试分析整个区域的执行时间和函数 foo
上的时间。
通常,如果我们使用 Timer
而不是 TimeTraceScope
,它只能给我们每个计时器收集的聚合持续时间。然而,在这种情况下,我们更感兴趣的是代码的不同部分如何在 时间线 上分配自己。例如,foo
函数是否在每次循环迭代中花费相同的时间?如果不是这样,哪些迭代花费的时间比其他迭代多?
要查看结果,我们需要在运行 Pass 时向 opt
命令添加额外的命令行选项(假设您在 Pass 中使用 TimeTraceScope
)。以下是一个例子:
$ opt –passes="…" -time-trace -time-trace-file=my_trace.json …
额外的 -time-trace
标志是要求 opt
将 TimeTraceScope
收集的所有跟踪导出到由 -time-trace-file
选项指定的文件中。
运行此命令后,您将得到一个新的文件,my_trace.json
。该文件的内容基本上是不可读的,但你知道吗?您可以使用 Chrome 网络浏览器来可视化它。以下是操作的步骤:
-
打开您的 Chrome 网络浏览器,并在 统一资源定位符 (URL) 栏中输入
chrome://tracing
。您将看到一个类似这样的界面:图 11.3 – Chrome 中的跟踪可视化器
-
点击
my_trace.json
文件。您将看到一个类似这样的页面:图 11.4 – 打开 my_trace.json 后的视图
每个颜色块代表一个由
TimeTraceScope
实例收集的时间间隔。 -
让我们更仔细地看看:请按数字键 3 切换到缩放模式。之后,您应该可以通过点击并上下拖动鼠标来放大或缩小。同时,您可以使用箭头键左右滚动时间线。以下是缩放后的时间线的一部分:
图 11.5 – 跟踪时间线的一部分
如我们从 图 11.5 中所见,有多个层叠在一起。这种布局反映了不同的
TimeTraceScope
实例在opt
(以及在我们的 Pass 中)是如何组织的。例如,我们的TimeTraceScope
实例名为TheOuterScope
是堆叠在多个TheInnerScope
块之上的。每个TheInnerScope
块代表我们在之前看到的每个循环迭代中foo
函数所花费的时间。 -
我们可以通过点击来进一步检查一个块的性质。例如,如果我们点击一个
TheInnerScope
块,其时间属性将显示在屏幕的下半部分。以下是一个例子:
图 11.6 – 时间间隔块的详细信息
这为我们提供了诸如时间间隔和此时间线中的起始时间等信息。
通过这种可视化,我们可以将时间信息与编译器的结构相结合,这将帮助我们更快地找出性能瓶颈。
除了opt
之外,clang
还可以生成相同的跟踪 JSON 文件。请考虑添加-ftime-trace
标志。以下是一个示例:
$ clang -O3 -ftime-trace -c foo.c
这将生成一个与输入文件同名的 JSON 跟踪文件。在这种情况下,它将是foo.json
。您可以使用我们刚刚学到的技能来可视化它。
在本节中,我们学习了一些有用的技能来收集 LLVM 的统计数据。Statistic
类可以用作整数计数器来记录优化中发生的事件数量。另一方面,优化注释可以让我们深入了解优化 Pass 内部的一些决策过程,这使得编译器开发者更容易诊断遗漏的优化机会。通过Timer
和TimeTraceScope
,开发者可以以更可控的方式监控 LLVM 的执行时间,并自信地处理编译速度回归。这些技术可以提高 LLVM 开发者创建新发明或解决难题时的生产力。
在本章的下一节中,我们将学习如何使用 LLVM 提供的工具以高效的方式编写错误处理代码。
LLVM 的错误处理工具
错误处理一直是软件开发中广泛讨论的话题。它可以像返回一个错误代码那样简单——例如,在许多 Linux API 中(例如,open
函数)——或者使用像抛出异常这样的高级机制,这种机制已被许多现代编程语言(如 Java 和 C++)广泛采用。
尽管 C++具有内置的异常处理支持,但 LLVM 在其代码库中并不采用它。这一决策背后的理由是,尽管它方便且语法表达能力强,但 C++中的异常处理在性能方面代价高昂。简单来说,异常处理使原始代码更加复杂,并阻碍了编译器优化它的能力。此外,在运行时,程序通常需要花费更多时间从异常中恢复。因此,LLVM 默认禁用了其代码库中的异常处理,并回退到其他错误处理方式——例如,通过返回值携带错误或使用本节将要学习的工具。
在本节的上半部分,我们将讨论Error
类,正如其名称所暗示的那样,它表示一个错误。这与传统的错误表示不同——例如,当使用整数作为错误代码时,如果不处理它,就无法忽略生成的Error
实例。我们很快就会解释这一点。
除了Error
类之外,开发者发现 LLVM 代码库中的错误处理代码存在一个共同的模式:一个 API 可能返回一个结果或一个错误,但不能同时返回两者。例如,当我们调用一个文件读取 API 时,我们期望得到该文件的内容(结果)或者当出现错误时(例如,没有这样的文件)返回一个错误。在本节的第二部分,我们将学习两个实现此模式的实用类。
让我们先从Error
类的介绍开始。
介绍Error
类
Error
类所表示的概念相当简单:它是一个带有补充描述的错误,如错误消息或错误代码。它被设计为可以通过值(作为函数参数)或从函数返回。开发者也可以自由地创建他们自己的自定义Error
实例。例如,如果我们想创建一个FileNotFoundError
实例来告诉用户某个文件不存在,我们可以编写以下代码:
#include "llvm/Support/Error.h"
#include <system_error>
// In the header file…
struct FileNotFoundError : public ErrorInfo<FileNoteFoundError> {
StringRef FileName;
explicit FileNotFoundError(StringRef Name) : FileName(Name) {}
static char ID;
std::error_code convertToErrorCode() const override {
return std::errc::no_such_file_or_directory;
}
void log(raw_ostream &OS) const override {
OS << FileName << ": No such file";
}
};
// In the CPP file…
char FileNotFoundError::ID = 0;
实现自定义Error
实例有几个要求。以下列出了这些要求:
-
从
ErrorInfo<T>
类派生,其中T
是您的自定义类。 -
声明一个唯一的
ID
变量。在这种情况下,我们使用一个静态类成员变量。 -
实现一个
convertToErrorCode
方法。此方法为这个Error
实例指定一个std::error_code
实例。std::error_code
是 C++标准库中使用的错误类型(自 C++11 起)。请参阅 C++参考文档以获取可用的(预定义的)std::error_code
实例。 -
实现一个
log
方法来打印错误信息。
要创建一个Error
实例,我们可以利用一个make_error
实用函数。以下是一个示例用法:
Error NoSuchFileErr = make_error<FileNotFoundError>("foo.txt");
make_error
函数接受一个错误类作为模板参数和函数参数(在这种情况下,foo.txt
),如果有任何参数。然后这些参数将被传递给其构造函数。
如果您尝试在没有对NoSuchFileErr
变量进行任何操作的情况下运行前面的代码(在调试构建中),程序将简单地崩溃并显示如下错误信息:
Program aborted due to an unhandled Error:
foo.txt: No such file
结果表明,每个Error
实例在其生命周期结束之前(即其析构方法被调用时)都必须检查和处理。
让我先解释一下什么是检查一个Error
实例。除了表示一个真实错误之外,Error
类还可以表示一个成功状态——即没有错误。为了给您一个更具体的概念,许多 LLVM API 都有以下错误处理结构:
Error readFile(StringRef FileName) {
if (openFile(FileName)) {
// Success
// Read the file content…
return ErrorSuccess();
} else
return make_error<FileNotFoundError>(FileName);
}
换句话说,它们在成功的情况下返回一个ErrorSuccess
实例,否则返回一个ErrorInfo
实例。当程序从readFile
返回时,我们需要通过将其视为布尔变量来检查返回的Error
实例是否表示成功结果,如下所示:
Error E = readFile(…);
if (E) {
// TODO: Handle the error
} else {
// Success!
}
注意,即使你 100%确信它处于Success
状态,你也需要检查Error
实例,否则程序仍然会中断。
前面的代码片段很好地引出了处理Error
实例的话题。如果一个Error
实例代表一个真正的错误,我们需要使用一个特殊的 API 来处理它:handleErrors
。下面是如何使用它的示例:
Error E = readFile(…);
if (E) {
Error UnhandledErr = handleErrors(
std::move(E),
& {
NotFound.log(errs() << "Error occurred: ");
errs() << "\n";
});
…
}
handleErrors
函数通过std::move(E)
接管Error
实例,并使用提供的 lambda 函数来处理错误。你可能注意到handleErrors
返回另一个Error
实例,它代表未处理的错误。这意味着什么?
在readFile
函数的先前的例子中,返回的Error
实例可以代表Success
状态或FileNotFoundError
状态。我们可以稍微修改这个函数,以便在打开的文件为空时返回FileEmptyError
实例,如下所示:
Error readFile(StringRef FileName) {
if (openFile(FileName)) {
// Success
…
if (Buffer.empty())
return make_error<FileEmptyError>();
else
return ErrorSuccess();
} else
return make_error<FileNotFoundError>(FileName);
}
现在,从readFile
返回的Error
实例可以是Success
状态,FileNotFoundError
实例,或者 FileEmptyError
实例。然而,我们之前编写的handleErrors
代码只处理了FileNotFoundError
的情况。
因此,我们需要使用以下代码来处理FileEmptyError
的情况:
Error E = readFile(…);
if (E) {
Error UnhandledErr = handleErrors(
std::move(E),
& {…});
UnhandledErr = handleErrors(
std::move(UnhandledErr),
& {…});
…
}
注意,在使用handleErrors
时,你始终需要接管Error
实例的所有权。
或者,你可以通过为每种错误类型使用多个 lambda 函数参数,将两个handleErrors
函数调用合并为一个,如下所示:
Error E = readFile(…);
if (E) {
Error UnhandledErr = handleErrors(
std::move(E),
& {…},
& {…});
…
}
换句话说,handleErrors
函数就像一个Error
实例的 switch-case 语句。它实际上工作如下面的伪代码所示:
Error E = readFile(…);
if (E) {
switch (E) {
case FileNotFoundError: …
case FileEmptyError: …
default:
// generate the UnhandledError
}
}
现在,你可能想知道:由于 handleErrors
总是返回一个代表未处理的错误的Error
,我不能简单地忽略返回的实例,否则程序将中断,我们应该如何结束这个“错误处理链”呢?* 有两种方法可以做到这一点,让我们看看每种方法,如下所示:
-
如果你 100%确信你已经处理了所有可能的错误类型——这意味着未处理的
Error
变量处于Success
状态——你可以调用cantFail
函数来进行断言,如下面的代码片段所示:if (E) { Error UnhandledErr = handleErrors( std::move(E), & {…}, & {…}); UnhandledErr still contains an error, the cantFail function will abort the program execution and print an error message.
-
一个更优雅的解决方案是使用
handleAllErrors
函数,如下所示:if (E) { handleAllErrors will still abort the program execution, just like what we have seen previously.
现在,你已经学会了如何使用Error
类以及如何正确处理错误。尽管Error
的设计一开始看起来有点令人烦恼(也就是说,我们需要处理所有可能错误类型,否则执行将半途而废),但这些限制可以减少程序员犯的错误数量,并创建一个更健壮的程序。
接下来,我们将介绍另外两个可以进一步改进 LLVM 中错误处理表达式的实用类。
了解 Expected 和 ErrorOr 类
正如我们在本节引言中简要提到的,在 LLVM 的代码库中,看到 API 想要返回结果或错误(如果出现错误)的编码模式相当常见。LLVM 通过创建将结果和错误多路复用到单个对象中的实用工具来尝试使这种模式更容易访问——它们是 Expected
和 ErrorOr
类。让我们从第一个开始。
Expected 类
Expected
类携带一个 Success
结果或一个错误——例如,LLVM 中的 JSON 库使用它来表示解析传入字符串的结果,如下所示:
#include "llvm/Support/JSON.h"
using namespace llvm;
…
// `InputStr` has the type of `StringRef`
Expected<json::Value> JsonOrErr = json::parse(InputStr);
if (JsonOrErr) {
// Success!
json::Value &Json = *JsonOrErr;
…
} else {
// Something goes wrong…
Error Err = JsonOrErr.takeError();
// Start to handle `Err`…
}
前面的 JsonOrErr
类具有 Expected<json::Value>
类型。这意味着这个 Expected
变量要么携带一个 json::Value
类型的 Success
结果,要么是一个错误,由我们在上一节中刚刚学习到的 Error
类表示。
正如与 Error
类一样,每个 Expected
实例都需要被检查。如果它表示一个错误,那么这个 Error
实例也需要被处理。为了检查 Expected
实例的状态,我们也可以将其转换为布尔类型。然而,与 Error
不同,如果 Expected
实例包含一个 Success
结果,在转换为布尔类型后它将是 true
。
如果 Expected
实例表示一个 Success
结果,你可以使用 *
操作符(如前述代码片段所示)、->
操作符或 get
方法来获取结果。否则,在处理 Error
实例之前,你可以通过调用 takeError
方法来检索错误,使用我们在上一节中学到的技能。
可选地,如果你确定一个 Expected
实例处于 Error
状态,你可以通过调用 errorIsA
方法来检查底层错误类型,而无需首先检索底层的 Error
实例。例如,以下代码检查一个错误是否是 FileNotFoundError
实例,这是我们上一节中创建的:
if (JsonOrErr) {
// Success!
…
} else {
// Something goes wrong…
if (JsonOrErr.errorIsA<FileNotFoundError>()) {
…
}
}
这些是消费 Expected
变量的技巧。要创建一个 Expected
实例,最常见的方式是利用到 Expected
的隐式类型转换。以下是一个例子:
Expected<std::string> readFile(StringRef FileName) {
if (openFile(FileName)) {
std::string Content;
// Reading the file…
return Content;
} else
return make_error<FileNotFoundError>(FileName);
}
上述代码显示,在出现错误的情况下,我们可以简单地返回一个 Error
实例,它将被隐式转换为表示该错误的 Expected
实例。同样地,如果一切进行得相当顺利,Success
结果——在这个例子中,是 std::string
类型的变量 Content
——也将被隐式转换为具有 Success
状态的 Expected
实例。
你现在已经学会了如何使用 Expected
类。本节的最后一部分将向你展示如何使用其兄弟类之一:ErrorOr
。
ErrorOr 类
ErrorOr
类使用与Expected
类几乎相同的模型——它要么是Success
结果,要么是错误。与Expected
类不同,ErrorOr
使用std::error_code
来表示错误。以下是一个使用MemoryBuffer
API 读取文件——foo.txt
——并将其内容存储到MemoryBuffer
对象中的示例:
#include "llvm/Support/MemoryBuffer.h"
…
ErrorOr<std::unique_ptr<MemoryBuffer>> ErrOrBuffer
= MemoryBuffer::getFile("foo.txt");
if (ErrOrBuffer) {
// Success!
std::unique_ptr<MemoryBuffer> &MB = *ErrOrBuffer;
} else {
// Something goes wrong…
std::error_code EC = ErrOrBuffer.getError();
…
}
之前的代码片段显示了类似的结构,其中包含了之前看到的Expected
的示例代码:这里的std::unique_ptr<MemoryBuffer>
实例是成功结果的类型。我们也可以在检查ErrOrBuffer
的状态后使用*
运算符来检索它。
这里的唯一区别是,如果ErrOrBuffer
处于Error
状态,错误将由一个std::error_code
实例表示,而不是Error
。开发者不是必须处理std::error_code
实例——换句话说,他们可以忽略该错误,这可能会增加其他开发者犯错的几率。尽管如此,使用ErrorOr
类可以为您提供更好的与 C++标准库 API 的互操作性,因为其中许多使用std::error_code
来表示错误。有关如何使用std::error_code
的详细信息,请参阅 C++参考文档。
最后,为了创建一个ErrorOr
实例,我们使用了与Expected
类相同的技巧——利用隐式转换,如下面的代码片段所示:
#include <system_error>
ErrorOr<std::string> readFile(StringRef FileName) {
if (openFile(FileName)) {
std::string Content;
// Reading the file…
return Content;
} else
return std::errc::no_such_file_or_directory;
}
std::errc::no_such_file_or_directory
对象是来自system_error
头文件中预定义的std::error_code
对象之一。
在本节中,我们学习了如何使用 LLVM 提供的某些错误处理实用工具——重要的Error
类,它对未处理的错误施加严格规则,以及Expected
和ErrorOr
类,它们为您提供了在单个对象中多路复用程序结果和错误状态的手边工具。这些工具可以帮助您在 LLVM 开发中编写表达性强且健壮的错误处理代码。
摘要
在本章中,我们学习了大量的实用工具,这些工具可以提高我们使用 LLVM 进行开发时的生产力。其中一些——例如优化注释或计时器——有助于诊断 LLVM 提出的问题,而其他一些——例如Error
类——则帮助您构建更健壮的代码,这些代码可以很好地适应您自己的编译器的复杂性。
在本书的最后一章中,我们将学习关于基于配置文件优化(PGO)和sanitizer开发的内容,这些都是您不容错过的先进主题。
第十二章:第十二章:学习 LLVM IR 仪器
在上一章中,我们学习了如何利用各种工具来提高使用 LLVM 进行开发时的生产力。这些技能可以在诊断由 LLVM 引发的问题时给我们带来更流畅的体验。其中一些工具甚至可以减少编译工程师可能犯的错误数量。在本章中,我们将学习 LLVM IR 中的仪器是如何工作的。
我们在这里提到的仪器是一种技术,它将一些探针插入我们正在编译的代码中,以便收集运行时信息。例如,我们可以收集有关某个函数被调用多少次的信息——这只有在目标程序执行后才能获得。这种技术的优点是它提供了关于目标程序行为的极其准确的信息。这些信息可以用几种不同的方式使用。例如,我们可以使用收集到的值再次编译和优化相同的代码——但这次,由于我们有准确的数据,我们可以执行之前无法进行的更激进的优化。这种技术也称为基于配置文件指导的优化(PGO)。在另一个例子中,我们将使用插入的探针来捕捉运行时发生的不希望的事件——缓冲区溢出、竞态条件和双重释放内存,仅举几例。用于此目的的探针也称为清理器。
要在 LLVM 中实现仪器,我们不仅需要 LLVM 传递的帮助,还需要 LLVM 中多个子项目之间的协同作用——Clang、LLVM IR 转换和Compiler-RT。我们已经从前面的章节中了解了前两个。在本章中,我们将介绍 Compiler-RT,更重要的是,我们将介绍如何结合这些子系统以实现仪器的目的。
下面是我们将要涵盖的主题列表:
-
开发一个清理器
-
与 PGO 一起工作
在本章的第一部分,我们将看到清理器如何在 Clang 和 LLVM 中实现,然后我们将自己创建一个简单的清理器。本章的后半部分将向您展示如何使用 LLVM 中的 PGO 框架,以及我们如何扩展它。
技术要求
在本章中,我们将处理多个子项目。其中之一——Compiler-RT——需要通过我们修改 CMake 配置来包含在你的构建中。请打开构建文件夹中的CMakeCache.txt
文件,并将compiler-rt
字符串添加到LLVM_ENABLE_PROJECTS
变量的值中。以下是一个示例:
//Semicolon-separated list of projects to build…
LLVM_ENABLE_PROJECTS:STRING="clang;compiler-rt"
编辑文件后,使用任何构建目标启动构建。CMake 将尝试重新配置自己。
一切准备就绪后,我们可以构建本章所需的组件。以下是一个示例命令:
$ ninja clang compiler-rt opt llvm-profdata
这将构建我们所有人都熟悉的clang
工具和一组 Compiler-RT 库,我们将在稍后介绍。
你可以在同一个 GitHub 仓库中找到本章的示例代码:github.com/PacktPublishing/LLVM-Techniques-Tips-and-Best-Practices-Clang-and-Middle-End-Libraries/tree/main/Chapter12
。
开发清理器
清理器是一种检查由编译器插入的代码(probe
)的某些运行时属性的技巧。人们通常使用清理器来确保程序的正确性或强制执行安全策略。为了让你了解清理器是如何工作的,让我们以 Clang 中最受欢迎的清理器之一为例——地址清理器。
使用地址清理器的示例
假设我们有一些简单的 C 代码,如下所示:
int main(int argc, char **argv) {
int buffer[3];
for (int i = 1; i < argc; ++i)
buffer[i-1] = atoi(argv[i]);
for (int i = 1; i < argc; ++i)
printf("%d ", buffer[i-1]);
printf("\n");
return 0;
}
前面的代码将命令行参数转换为整数并将它们存储在大小为 3 的缓冲区中。然后,我们打印它们。
你应该能够轻松地发现一个突出的问题:当argc
的值大于buffer
的大小(即 3)时,它可以任意大。在这里,我们将值存储在一个*invalid*
内存位置。然而,当我们编译这段代码时,编译器不会说任何话。以下是一个例子:
$ clang -Wall buffer_overflow.c -o buffer_overflow
$ # No error or warning
在前面的命令中,即使我们通过-Wall
标志启用了所有编译器警告,clang
也不会对潜在的错误提出异议。
如果我们尝试执行buffer_overflow
程序,程序将在我们传递超过三个命令行参数给它后的某个时间点崩溃;例如:
$ ./buffer_overflow 1 2 3
1 2 3
$ ./buffer_overflow 1 2 3 4
Segmentation fault (core dumped)
$
更糟糕的是,导致buffer_overflow
崩溃的命令行参数数量实际上在每台机器上都是不同的。如果这里展示的例子是一个现实世界的错误,那么这将使得调试变得更加困难。总结一下,我们在这里遇到的问题是由buffer_overflow
仅在*some*
输入上变得异常,而编译器未能捕捉到这个问题所引起的。
现在,让我们尝试使用地址清理器来捕捉这个错误。以下命令要求clang
使用地址清理器编译相同的代码:
$ clang -fsanitize=address buffer_overflow.c -o san_buffer_overflow
让我们再次执行程序。以下是输出:
$ ./san_buffer_overflow 1 2 3
1 2 3
$ ./san_buffer_overflow 1 2 3 4
=================================================================
==137791==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x7ffea06bccac at pc 0x0000004f96df bp 0x7ffea06bcc70…
WRITE of size 4 at 0x7ffea06bccac thread T0
…
This frame has 1 object(s):
32, 44) 'buffer' <== Memory access at offset 44 overflows this variable
…
==137791==ABORTING
$
而不是仅仅崩溃,地址清理器给我们提供了关于在运行时引发的问题的许多详细信息:清理器告诉我们它检测到堆上的*buffer overflow*
,这可能是buffer
变量。
这些信息非常有用。想象一下,你正在处理一个更复杂的软件项目。当出现奇怪的内存错误时,地址清理器可以立即指出问题区域——具有高精度——而不是仅仅崩溃或静默地改变程序的逻辑。
要更深入地了解其机制,以下图表说明了地址清理器如何检测缓冲区溢出:
![图 12.1 – 地址清理器插入的仪器代码
![图片
图 12.1 – 地址清理器插入的仪器代码
在这里,我们可以看到地址清理器有效地在用于访问buffer
的数组索引中插入了一个边界检查。有了这个额外的检查——它将在运行时执行——目标程序可以在违反内存访问之前退出并带有错误详情。更普遍地说,在编译期间,清理器会插入一些仪器代码(到目标程序中),这些代码最终将在运行时执行以检查或保护某些属性。
使用地址清理器检测溢出
上述图表显示了地址清理器工作原理的简化版本。实际上,地址清理器将利用多种策略来监控程序中的内存访问。例如,地址清理器可以使用一个特殊的内存分配器,该分配器在无效内存区域放置陷阱
来分配内存。
虽然地址清理器擅长捕获非法内存访问,但ThreadSanitizer可以用来捕获数据竞争条件;即,对同一数据块的多线程无效访问。Clang 中其他清理器的例子还包括LeakSanitizer,它用于检测敏感数据(如密码)泄露,以及MemorySanitizer,它用于检测对未初始化内存的读取。
当然,使用清理器也有一些缺点。最突出的问题是性能影响:以 Clang 中的线程清理器为例,使用它的程序比原始版本慢5~15 倍。此外,由于清理器将额外的代码插入到程序中,它可能会阻碍一些优化机会,甚至影响原始程序的逻辑!换句话说,这是目标程序健壮性和性能之间的权衡。
通过这样,你已经了解了清理器的高级概念。让我们尝试自己创建一个,以了解 Clang 和 LLVM 如何实现清理器。接下来的部分包含比前几章中任何示例都多的代码,更不用说这些更改分散在 LLVM 的不同子项目中。为了专注于最重要的知识,我们不会深入一些支持性代码的细节——例如,对 CMake 构建脚本的更改。相反,我们将通过提供简要介绍并指出在哪里可以找到这本书的 GitHub 仓库中的相关内容来简要介绍它们。
让我们先概述一下我们将要创建的项目。
创建循环计数器清理器
为了(稍微)简化我们的任务,我们将创建的清理器——一个循环计数器清理器,简称LPCSan——看起来就像一个清理器,只不过它不会检查任何严重的程序属性。相反,我们希望用它来打印出实际的、具体的迭代计数——循环的迭代次数,这在运行时才可用。
例如,让我们假设我们有以下输入代码:
void foo(int S, int E, int ST, int *a) {
for (int i = S; i < E; i += ST) {
a[i] = a[i + 1];
}
}
int main(int argc, char **argv) {
int start = atoi(argv[1]),
end = atoi(argv[2]),
step = atoi(argv[3]);
int a[100];
foo(start, end, step, a);
return 0;
}
我们可以使用以下命令使用 LPCSan 进行编译:
$ clang -O1 -fsanitize=loop-counter test_lpcsan.c -o test_lpcsan
注意,使用大于 -O0
的优化编译是必要的;我们将在稍后解释原因。
当我们执行 test_lpcsan
(带有一些命令行参数)时,我们可以在 foo
函数中打印出循环的确切遍历次数。例如,看看以下代码:
$ ./test_lpcsan 0 100 1
==143813==INFO: Found a loop with trip count 100
$ ./test_lpcsan 0 50 2
==143814==INFO: Found a loop with trip count 25
$
上述代码中高亮显示的消息是由我们的检查器代码打印的。
现在,让我们深入了解创建 LPCSan 的步骤。我们将把这个教程分为三个部分:
-
开发 IR 转换
-
添加 Compiler-RT 组件
-
将 LPCSan 添加到 Clang
我们将从这个检查器的 IR 转换部分开始。
开发 IR 转换
之前,我们了解到地址检查器——或者更一般地说——通常会在目标程序中插入代码来检查某些运行时属性或收集数据。在 第九章 与 PassManager 和 AnalysisManager 一起工作 和 第十章 处理 LLVM IR 中,我们学习了如何修改/转换 LLVM IR,包括向其中插入新代码,因此这似乎是构建我们的 LPCSan 的好起点。
在本节中,我们将开发一个名为 LoopCounterSanitizer
的 LLVM 传递,该传递会插入特殊函数调用来收集 Module
中每个循环的确切遍历次数。以下是详细步骤:
-
首先,让我们创建两个文件:
LoopCounterSanitizer.cpp
位于llvm/lib/Transforms/Instrumentation
文件夹下,以及其对应的头文件位于llvm/include/llvm/Transforms/Instrumentation
文件夹内。在头文件中,我们将放置此传递的声明,如下所示:struct LoopCounterSanitizer : public PassInfoMixin<LoopCounterSanitizer> { PreservedAnalyses run(Loop&, LoopAnalysisManager&, LoopStandardAnalysisResults&, LPMUpdater&); private: // Sanitizer functions LPCSetStartFn and LPCAtEndFn memory variables – they will store the Function instances that collect loop trip counts (FunctionCallee is a thin wrapper around Function that provides additional function signature information).
-
最后,在
LoopCounterSanitizer.cpp
中,我们放置了我们传递的骨架代码,如下所示:PreservedAnalyses LoopCounterSanitizer::run(Loop &LP, LoopAnalysisManager &LAM, LoopStandardAnalysisResults &LSR, LPMUpdater &U) { initializeSanitizerFuncs method in the preceding code will populate LPCSetStartFn and LPCAtEndFn. Before we go into the details of initializeSanitizerFuncs, let's talk more about LPCSetStartFn and LPCAtEndFn.
-
为了确定确切的遍历次数,将使用存储在
LPCSetStartFn
中的Function
实例来收集循环的 初始 归纳变量值。另一方面,存储在LPCAtEndFn
中的Function
实例将用于收集循环的 最终 归纳变量值和步长值。为了给您一个具体的概念,了解这两个Function
实例是如何一起工作的,让我们假设以下伪代码作为我们的输入程序:void foo(int S, int E, int ST) { for (int i = S; i < E; i += ST) { … } }
在前面的代码中,
S
、E
和ST
变量分别代表循环的初始、最终和步长值。LoopCounterSanitizer
传递的目标是以以下方式插入LPCSetStartFn
和LPCAtEndFn
:void foo(int S, int E, int ST) { for (int i = S; i < E; i += ST) { lpc_set_start and lpc_at_end in the preceding code are Function instances that are stored in LPCSetStartFn and LPCAtEndFn, respectively. Here is one of the possible (pseudo) implementations of these two functions:
static int CurrentStartVal = 0;
void lpc_set_start(int start) {
LPCSetStartFn 和 LPCAtEndFn,现在是时候看看
initializeSanitizerFuncs
是如何初始化它们的了。 -
这里是
initializeSanitizerFuncs
中的代码:void LoopCounterSanitizer::initializeSanitizerFuncs(Loop &LP) { Module &M = *LP.getHeader()->getModule(); auto &Ctx = M.getContext(); Type *VoidTy = Type::__lpcsan_set_loop_start and __lpcsan_at_loop_end, from the module and storing their Function instances in LPCSetStartFn and LPCAtEndFn, respectively.The `Module::getOrInsertFunction` method either grabs the `Function` instance of the given function name from the module or creates one if it doesn't exist. If it's a newly created instance, it has an empty function body; in other words, it only has a function *declaration*.It is also worth noting that the second argument of `Module::getOrInsertFunction` is the return type of the `Function` inquiry. The rest (the arguments for `getOrInsertFunction`) represent the argument types of that `Function`.With `LPCSetStartFn` and `LPCAtEndFn` set up, let's see how we can insert them into the right place in IR.
-
回想一下,在 第十章 中,我们学习了关于与
Loop
一起工作的几个实用类,即 处理 LLVM IR。其中之一 –LoopBounds
– 可以给我们提供Loop
的边界。我们可以通过包含一个归纳变量的起始、结束和步长值来实现这一点,这正是我们所寻找的信息。以下是尝试检索LoopBounds
实例的代码:PreservedAnalyses LoopCounterSanitizer::run(Loop &LP, LoopAnalysisManager &LAM, LoopStandardAnalysisResults &LSR, LPMUpdater &U) { initializeSanitizerFuncs(LP); Loop::getBounds from the preceding code returned an Optional<LoopBounds> instance. The Optional<T> class is a useful container that either stores an instance of the T type or is *empty*. You can think of it as a replacement for the T* to represent a computation result where a null pointer means an empty value. However, this has the risk of dereferencing a null pointer if the programmer forgets to check the pointer first. The Optional<T> class doesn't have this problem.With a `LoopBounds` instance, we can retrieve the induction variable's range and store it in the `StartVal`, `EndVal`, and `StepVal` variables.
-
StartVal
是Value
实例,它将被__lpcsan_set_loop_start
收集,而__lpcsan_at_loop_end
将在运行时收集EndVal
和StepVal
。现在,问题是,我们应该在哪里插入__lpcsan_set_loop_start
和__lpcsan_at_loop_end
的函数调用,以正确收集这些值?通常的规则是,我们需要在那些值的 定义 之后插入那些函数调用。虽然我们可以找到那些值被定义的确切位置,但让我们通过在某个固定位置插入仪器化函数调用来简化问题 – 这些位置是我们目标值 总是 可用的位置。
对于
__lpcsan_set_loop_start
,我们在getTerminator
的末尾插入它,以获取头块中的最后一个Instruction
。然后,我们使用IRBuilder<>
– 以最后一个指令作为插入点 – 来插入新的Instruction
实例。在我们能够将
StartVal
作为参数传递给新的__lpcsan_set_loop_start
函数调用之前,我们需要将其 IR 类型(由Type
类表示)转换为兼容的类型。IRBuilder::CreateInstCast
是一个方便的实用工具,它可以自动生成一个指令来 扩展 整数位宽或生成一个指令来 截断 位宽,具体取决于给定的Value
和Type
实例。最后,我们可以通过
IRBuilder::CreateCall
创建一个__lpcsan_set_loop_start
的函数调用,并将StartVal
作为函数调用参数。 -
对于
__lpcsan_at_loop_end
,我们使用相同的技巧来收集EndVal
和StepVal
的运行时值。以下是代码:BasicBlock *ExitBlock = LP.__lpcsan_at_loop_end at the beginning of the *exit block*. This is because we can always expect the end value and the step value of the induction variable being defined before we leave the loop.These are all the implementation details for the `LoopCounterSanitizer` pass.
-
在我们结束这一节之前,我们需要编辑几个更多文件以确保一切正常。请查看本章示例代码文件夹中的
Changes-LLVM.diff
文件。以下是其他支持文件中进行的更改的摘要:i. 在
llvm/lib/Transforms/Instrumentation/CMakeLists.txt
中的更改:将我们的新过滤器源文件添加到构建中。ii. 在
llvm/lib/Passes/PassRegistry.def
中的更改:将我们的过滤器添加到可用过滤器的列表中,这样我们就可以使用我们的老朋友opt
来测试它。
这样,我们终于完成了对 LLVM 部分的所有必要修改。
在我们进入下一节之前,让我们测试我们新创建的 LoopCounterSanitizer
过滤器。我们将使用本节前面看到的相同 C 代码。以下是包含我们想要进行仪器化的循环的函数:
void foo(int S, int E, int ST, int *a) {
for (int i = S; i < E; i += ST) {
a[i] = a[i + 1];
}
}
注意,尽管我们没有在我们的过滤器中明确检查循环形式,但过滤器中使用的某些 API 实际上要求循环要被 旋转,所以请使用 O1 优化级别生成 LLVM IR 代码,以确保循环旋转的过滤器已经启动:
这里是 foo
函数的简化后的 LLVM IR:
define void @foo(i32 %S, i32 %E, i32 %ST, i32* %a) {
%cmp9 = icmp slt i32 %S, %E
br i1 %cmp9, label %for.body.preheader, label %for.cond. cleanup
for.body.preheader:
%0 = sext i32 %S to i64
%1 = sext i32 %ST to i64
%2 = sext i32 %E to i64
br label %for.body
…
for.body:
%indvars.iv = phi i64 [ %0, %for.body.preheader ], [ %indvars.iv.next, %for.body ]
…
%indvars.iv.next = add i64 %indvars.iv, %1
%cmp = icmp slt i64 %indvars.iv.next, %2
br i1 %cmp, label %for.body, label %for.cond.cleanup
}
突出的标签是此循环的预头块和循环体块。由于这个循环已经被旋转,for.body
块既是此循环的头块,也是 latch 和退出块。
现在,让我们使用以下命令使用 opt
转换这个 IR:
$ opt -S –passes="loop(lpcsan)" input.ll -o -
在 –passes
命令行选项中,我们要求 opt
运行我们的 LoopCounterSanitizer
过滤器(名称为 lpcsan
,已在 PassRegistry.def
文件中注册)。包围的 loop(…)
字符串只是简单地告诉 opt
,lpcsan
是一个循环过滤器(实际上你可以省略这个装饰,因为 opt
大多数时候都能找到正确的过滤器)。
这里是简化后的结果:
declare void @__lpcsan_set_loop_start(i32)
declare void @__lpcsan_at_loop_end(i32, i32)
define void @foo(i32 %S, i32 %E, i32* %a) {
%cmp8 = icmp slt i32 %S, %E
br i1 %cmp8, label %for.body.preheader, label %for.cond.cleanup
for.body.preheader:
%0 = sext i32 %S to i64
%wide.trip.count = sext i32 %E to i64
br label %for.body
for.cond.cleanup.loopexit:
%1 = trunc i64 %wide.trip.count to i32
call void @__lpcsan_at_loop_end(i32 %1, i32 1)
br label %for.cond.cleanup
for.body:
…
%3 = trunc i64 %0 to i32
call void @__lpcsan_set_loop_start(i32 %3)
br i1 %exitcond.not, label %for.cond.cleanup.loopexit, label %for.body
}
如您所见,__lpcsan_set_loop_start
和 __lpcsan_at_loop_end
已经分别正确地插入到头块和退出块中。它们也在收集与循环迭代次数相关的所需值。
现在,最大的问题是:__lpcsan_set_loop_start
和 __lpcsan_at_loop_end
的函数体在哪里?这两个函数在之前的 IR 代码中只有声明。
在下一节中,我们将使用 Compiler-RT 来回答这个问题。
添加 Compiler-RT 组件
Compiler-RT 的名称代表 Compiler RunTime。在这里,runtime 的使用有一点模糊,因为在正常的编译管道中,太多东西都可以被称为 runtime。但事实是,Compiler-RT 确实包含了一组用于完全不同任务的库。这些库的共同之处在于,它们为目标程序提供 补充 代码,以实现增强功能或原本缺失的功能。重要的是要记住,Compiler-RT 库不是用于构建编译器或相关工具的——它们应该与我们要编译的程序链接。
Compiler-RT 中最常用的功能之一是 内建函数。正如您可能听说的,现在越来越多的计算机架构原生支持 向量操作。也就是说,您可以在硬件的支持下同时处理多个数据元素。以下是一些使用向量操作的 C 语言示例代码:
typedef int v4si __attribute__((__vector_size__(16)));
v4si v1 = (v4si){1, 2, 3, 4};
v4si v2 = (v4si){5, 6, 7, 8};
v4si v3 = v1 + v2; // = {6, 8, 10, 12}
之前的代码使用了非标准化的(目前,你只能在 Clang 和 GCC 中使用这种语法)C/C++ 向量扩展来声明两个向量,v1
和 v2
,然后在将它们相加以生成第三个向量之前。
在 X86-64 平台上,此代码将被编译为使用向量指令集之一,例如使用for-loop
来替换此例中的向量求和。更具体地说,每当我们在编译时看到向量求和,我们就用包含使用for-loop
的合成实现的函数调用来替换它。函数体可以放在任何地方,只要它最终与程序链接即可。以下图表说明了这个过程:
![图 12.2 – Compiler-RT 内置的流程]
图 12.2 – Compiler-RT 内置的流程
如您所注意到的,这里显示的流程与我们在 LPCSan 中的要求相似:在前一节中,我们开发了一个 LLVM pass,它插入额外的函数调用来收集循环迭代次数,但我们仍然需要实现这些收集函数。如果我们利用前面图表中显示的流程,我们可以提出一个设计,如下面的图表所示:
![图 12.3 – Compiler-RT LPCSan 组件的流程]
![图 12.3 – Compiler-RT LPCSan 组件的流程]
之前的图表显示,__lpcsan_set_loop_start
和__lpcsan_at_loop_end
函数的函数体被放入一个最终将与最终二进制文件链接的 Compiler-RT 库中。在这两个函数内部,我们使用输入参数计算迭代次数并打印结果。在本节的其余部分,我们将向您展示如何为 LPCSan 创建这样的 Compiler-RT 库。让我们开始吧:
-
首先,将文件夹切换到
llvm-project/compiler-rt
,这是 Compiler-RT 的根目录。在这个子项目中,我们必须在将新的lpcsan.cpp
文件放入其中之前,先创建一个名为lib/lpcsan
的新文件夹。在这个文件中,让我们为我们的仪器函数创建一个骨架。以下是代码:#include "sanitizer_common/sanitizer_common.h" #include "sanitizer_common/sanitizer_internal_defs.h" using namespace __sanitizer; extern "C" SANITIZER_INTERFACE_ATTRIBUTE void s32 – available under the __sanitizer namespace – for a signed 32-bit integer rather than the normal int. The rationale behind this is that we might need to build Compiler-RT libraries for different hardware architectures or platforms, and the width of int might not be 32 bits on some of them.Second, although we are using C++ to implement our instrumentation functions, we need to expose them as C functions because C functions have a more stable `extern "C"` to functions you want to export. The `SANITIZER_INTERFACE_ATTRIBUTE` macro also ensures that the function will be exposed at the library interface correctly, so please add this as well.
-
接下来,我们将向这两个函数添加必要的代码。以下是我们的做法:
static CurLoopStart is a global variable that memorizes the *initial* induction variable value of the current loop. This is updated by __lpcsan_set_loop_start.Recall that when a loop is complete, `__lpcsan_at_loop_end` will be invoked. When that happens, we use the value stored in `CurLoopStart` and the `end` and `step` arguments to calculate the exact trip count of the current loop, before printing the result.
-
现在我们已经实现了核心逻辑,是时候构建这个库了。在
lib/lpcsan
文件夹内,创建一个新的CMakeLists.txt
文件并插入以下代码:… set(LPCSAN_RTL_SOURCES lpcsan.cpp) CMakeLists.txt. Here are some highlights:i. Compiler-RT creates its own set of CMake macros/functions. Here, we are using two of them, `add_compiler_rt_component` and `add_compiler_rt_runtime`, to create a pseudo build target for the entire LPCSan and the real library build target, respectively.ii. Different from a conventional build target, if a sanitizer wants to use supporting/utility libraries in Compiler-RT – for example, `RTSanitizerCommon` in the preceding code – we usually link against their *object files* rather than their library files. More specifically, we can use the `$<TARGET_OBJECTS:…>` directive to import supporting/utility components as one of the input sources.iii. A sanitizer library can support multiple architectures and platforms. In Compiler-RT, we are enumerating all the supported architectures and creating a sanitizer library for each of them.Again, the preceding snippet is just a small part of our build script. Please refer to our sample code folder for the complete `CMakeLists.txt` file.
-
要成功构建 LPCSan,我们仍然需要在 Compiler-RT 中进行一些更改。同一代码文件夹中的
Base-CompilerRT.diff
补丁提供了构建我们的 sanitizer 所需的其余更改。将其应用到 Compiler-RT 的源代码树上。以下是此补丁的摘要:i.
compiler-rt/cmake/config-ix.cmake
中的更改基本上指定了 LPCSan 支持的架构和操作系统。我们之前在代码片段中看到的LPCSAN_SUPPORTED_ARCH
CMake 变量就来自这里。ii. 整个
compiler-rt/test/lpcsan
文件夹实际上是一个占位符。由于某种原因,在 Compiler-RT 中,每个 sanitizer 都需要测试 – 这与 LLVM 不同。因此,我们在这里放置一个空的测试文件夹以满足由构建基础设施强加的要求。
这些都是为我们的 LPCSan 生成 Compiler-RT 组件的步骤。
要仅构建我们的 LPCSan 库,请执行以下命令:
$ ninja lpcsan
不幸的是,在我们修改 Clang 的编译管道之前,我们无法测试这个 LPCSan 库。在本节的最后部分,我们将学习如何完成这个任务。
将 LPCSan 添加到 Clang
在上一节中,我们学习了 Compiler-RT 库如何为目标程序提供补充功能或协助特殊工具,例如我们刚刚创建的清理器。在本节中,我们将把所有这些内容整合起来,这样我们就可以通过向 clang
传递 -fsanitize=loop-counter
标志来简单地使用我们的 LPCSan。
回想一下,在 图 12.3 中,Compiler-RT 库需要与我们要编译的程序链接。同样,回想一下,为了将工具代码插入到目标程序中,我们必须运行我们的 LoopCounterSanitizer
过滤器。在本节中,我们将修改 Clang 的编译管道,以便在特定时间运行我们的 LLVM 过滤器,并为我们的 Compiler-RT 库设置正确的配置。更具体地说,以下图显示了每个组件需要完成的任务以运行我们的 LPCSan:
![图 12.4 – 管道中每个组件的任务]
![img/B14590_12.4.jpg]
图 12.4 – 管道中每个组件的任务
以下是前图中每个数字(用圆圈圈出)的描述:
-
驱动程序需要识别
-fsanitize=loop-counter
标志。 -
当前端即将从
LoopCounterSanitizer
过滤器生成 LLVM IR 时。 -
LLVM 过滤器管道需要运行我们的
LoopCounterSanitizer
(如果前面的任务完成正确,我们不需要担心这个任务)。 -
链接器需要将我们的 Compiler-RT 库链接到目标程序。
虽然这个工作流程看起来有点吓人,但不要被预期的任务量所压倒——只要提供足够的信息,Clang 实际上可以为你完成大部分这些任务。在本节的其余部分,我们将向您展示如何实现前图中显示的任务,以将我们的 LPCSan 完全集成到 Clang 编译管道中(以下教程在 llvm-project/clang
文件夹内进行)。让我们开始吧:
-
首先,我们必须修改
include/clang/Basic/Sanitizers.def
以添加我们的清理器:… // Shadow Call Stack SANITIZER("shadow-call-stack", ShadowCallStack) // Loop Counter Sanitizer LoopCounter, to the SanitizerKind class.It turns out that the driver will parse the `-fsanitize` command-line option and *automatically* translate `loop-counter` into `SanitizerKind::LoopCounter` based on the information we provided in `Sanitizers.def`.
-
接下来,让我们处理驱动程序部分。打开
include/clang/Driver/SanitizerArgs.h
并向SanitizerArgs
类中添加一个新的实用方法,needsLpcsanRt
。以下是代码:bool needsLsanRt() const {…} bool needsLpcsanRt() const { return Sanitizers.has(SanitizerKind::LoopCounter); }
我们在这里创建的实用方法可以在驱动程序的其它地方使用,以检查我们的清理器是否需要 Compiler-RT 组件。
-
现在,让我们导航到
lib/Driver/ToolChains/CommonArgs.cpp
文件。在这里,我们向collectSanitizerRuntimes
函数中添加了几行代码。以下是代码:… if (SanArgs.needsLsanRt() && SanArgs.linkRuntimes()) StaticRuntimes.push_back("lsan"); if (SanArgs.needsLpcsanRt() && SanArgs.linkRuntimes()) StaticRuntimes.push_back("lpcsan"); …
前面的代码片段有效地使链接器将正确的 Compiler-RT 库链接到目标二进制文件。
-
我们将对驱动程序进行的最后一个修改是在
lib/Driver/ToolChains/Linux.cpp
文件中。在这里,我们将以下行添加到Linux::getSupportedSanitizers
方法中:SanitizerMask Res = ToolChain::getSupportedSanitizers(); … Res |= SanitizerKind::LoopCounter; …
之前的代码基本上是在告诉驱动程序,我们支持当前工具链中的 LPCSan——Linux 的工具链。请注意,为了简化我们的示例,我们只支持 Linux 中的 LPCSan。如果您想在其他平台和架构上支持这个自定义清理器,请修改其他工具链实现。如果需要,请参阅第八章,使用编译器标志和工具链,以获取更多详细信息。
-
最后,我们将把我们的
LoopCounterSanitizer
传递插入到 LLVM 传递管道中。打开lib/CodeGen/BackendUtil.cpp
文件,并将以下行添加到addSanitizers
函数中:… // `PB` has the type of `CodeGen, is a place where the Clang and LLVM libraries meet. Therefore, we will see several LLVM APIs appear in this place. There are primarily two tasks for this CodeGen component:a. Converting the Clang AST into its equivalent LLVM IR `module`b. Constructing an LLVM pass pipeline to optimize the IR and generate machine codeThe previous snippet was trying to customize the second task – that is, customizing the LLVM Pass pipeline. The specific function – `addSanitizers` – we are modifying here is responsible for putting sanitizer passes into the pass pipeline. To have a better understanding of this code, let's focus on two of its components:i. `PassBuilder`: This class provides predefined pass pipeline configurations for each optimization level – that is, the O0 ~ O3 notations (as well as Os and Oz for size optimization) we are familiar with. In addition to these predefined layouts, developers are free to customize the pipeline by leveraging the `PassBuilder` supports several EPs, such as at the *beginning* of the pipeline, at the *end* of the pipeline, or at the end of the vectorization process, to name a few. An example of using EP can be found in the preceding code, where we used the `PassBuilder::registerOptimizerLastEPCallback` method and a lambda function to customize the EP located at the *end* of the Pass pipeline. The lambda function has two arguments: `ModulePassManager` – which represents the pass pipeline – and the current optimization level. Developers can use `ModulePassManager::addPass` to insert arbitrary LLVM passes into this EP.ii. `ModulePassManager`: This class represents a Pass pipeline – or, more specifically, the pipeline for `Module`. There are, of course, other PassManager classes for different IR units, such as `FunctionPassManager` for `Function`. In the preceding code, we were trying to use the `ModulePassManager` instance to insert our `LoopCounterSanitizer` pass whenever `SanitizerKind::LoopCounter` was one of the sanitizers that had been designated by the user. Since `LoopCounterSanitizer` is a loop pass rather than a module pass, we need to add some *adaptors* between the pass and PassManager. The `createFunctionToLoopPassAdaptor` and `createModuleToFunctionPassAdaptor` functions we were using here created a special instance that adapts a pass to a PassManager of a different IR unit.This is all the program logic that supports our LPCSan in the Clang compilation pipeline.
-
最后但同样重要的是,我们必须对构建系统进行一些小的修改。打开
runtime/CMakeLists.txt
文件,并更改以下 CMake 变量:… set(COMPILER_RT_RUNTIMES effectively imports our LPCSan Compiler-RT libraries into the build.
这些都是在 Clang 中支持 LPCSan 所需的所有步骤。现在,我们最终可以使用 LPCSan,就像我们在本节开始时向您展示的那样:
$ clang -O1 -fsanitize=loop-counter input.c -o input
在本节中,我们学习了如何创建清理器。清理器是一个有用的工具,可以在不修改原始程序代码的情况下捕获运行时行为。创建清理器的能力增加了编译器开发者创建针对他们自己用例定制的诊断工具的灵活性。开发清理器需要全面了解 Clang、LLVM 和 Compiler-RT:创建一个新的 LLVM 传递、创建一个新的 Compiler-RT 组件,并自定义 Clang 的编译管道。您可以使用本节的内容,来巩固您在本本书的前几章中学到的知识。
在本章的最后部分,我们将探讨另一种仪器技术:PGO。
使用 PGO
在上一节中,我们学习了如何使用仅在运行时才可用的数据,通过清理器帮助开发者进行更精确的合理性检查。我们还学习了如何创建一个自定义清理器。在本节中,我们将继续探讨利用运行时数据的思想。我们将学习这种信息的另一种用途——用于编译器优化。
PGO 是一种使用在运行时收集的统计信息来启用更激进的编译器优化的技术。其名称中的profile指的是收集到的运行时数据。为了给您一个这样的数据如何增强优化的概念,让我们假设我们有以下 C 代码:
void foo(int N) {
if (N > 100)
bar();
else
zoo();
}
在此代码中,我们有三个函数:foo
、bar
和zoo
。第一个函数有条件地调用后两个函数。
当我们尝试优化此代码时,优化器通常会尝试将调用者函数内联到被调用者中。在这种情况下,bar
或zoo
可能会被内联到foo
中。然而,如果bar
或zoo
有一个大的函数体,内联两者可能会膨胀最终二进制文件的大小。理想情况下,如果我们只能内联执行频率最高的那个将是非常好的。遗憾的是,从统计学的角度来看,我们没有关于哪个函数有最高执行频率的线索,因为foo
函数根据一个(非常量)变量有条件地调用它们中的任何一个。
使用 PGO,我们可以在运行时收集bar
和zoo
的执行频率,并使用这些数据再次编译(和优化)相同的代码。以下图表展示了这一想法的高级概述:
![图 12.5 – PGO 工作流程
图 12.5 – PGO 工作流程
在这里,第一次编译阶段正常编译和优化了代码。在我们执行编译后的程序(任意次数)之后,我们能够收集到分析数据文件。在第二次编译阶段,我们不仅像之前那样优化了代码,而且还把分析数据集成到优化中,使它们更加积极。
PGO 收集运行时分析数据主要有两种方式:插入仪器代码或利用采样数据。让我们来介绍这两种方法。
基于仪器的 PGO 简介
基于仪器的 PGO 在第一次编译阶段将仪器代码插入到目标程序中。此代码测量我们感兴趣的程序结构的执行频率——例如,基本块和函数——并将结果写入文件。这与清理器的工作方式类似。
基于仪器的 PGO 通常生成的分析数据精度更高。这是因为编译器可以以提供最大优化利益的方式插入仪器代码。然而,就像清理器一样,基于仪器的 PGO 改变了目标程序的执行流程,这增加了性能退化的风险(对于从第一次编译阶段生成的二进制文件)。
基于采样的 PGO 简介
基于采样的 PGO 使用外部工具来收集分析数据。开发者使用perf
或valgrind
等分析器来诊断性能问题。这些工具通常利用高级系统功能甚至硬件功能来收集程序的运行时行为。例如,perf
可以让你了解分支预测和缓存行缺失。
由于我们正在利用其他工具的数据,因此无需修改原始代码来收集配置文件。因此,基于采样的 PGO 通常具有极低的运行时开销(通常,这不到 1%)。此外,我们不需要重新编译代码以进行配置文件收集。然而,以这种方式生成的配置文件通常不太精确。在第二个编译阶段,将配置文件映射回原始代码也更加困难。
在本节的其余部分,我们将重点关注基于插装的 PGO。我们将学习如何利用 LLVM IR。然而,正如我们很快将看到的,LLVM 中的这两种 PGO 策略共享许多共同的基础设施,因此代码是可移植的。以下是我们要涵盖的主题列表:
-
处理配置文件数据
-
了解访问配置文件数据的 API
第一部分将向我们展示如何使用 Clang 创建和使用基于插装的 PGO 配置文件,以及一些可以帮助我们检查和修改配置文件数据的工具。第二部分将更详细地介绍如何使用 LLVM API 访问配置文件数据。如果你想要创建自己的 PGO 传递,这将很有用。
处理配置文件数据
在本节中,我们将学习如何使用生成、检查甚至修改基于插装的配置文件数据。让我们从以下示例开始:
__attribute__((noinline))
void foo(int x) {
if (get_random() > 5)
printf("Hello %d\n", x * 3);
}
int main(int argc, char **argv) {
for (int i = 0; i < argc + 10; ++i) {
foo(i);
}
return 0;
}
在前面的代码中,get_random
是一个生成 1 到 10 之间随机数的函数,具有均匀分布。换句话说,foo
函数中高亮的 if
语句应该有 50% 的机会被执行。除了 foo
函数外,main
函数中 for
循环的遍历次数取决于命令行参数的数量。
现在,让我们尝试使用基于插装的 PGO 构建此代码。以下是步骤:
-
我们将要做的第一件事是生成一个用于 PGO 分析的可执行文件。以下是命令:
$ clang -O1 -fprofile-generate option enables instrumentation-based PGO. The path that we added after this flag is the directory where profiling data will be stored.
-
接下来,我们必须使用三个命令行参数运行
pgo
程序:$ ./pgo `seq 1 3` Hello 0 Hello 6 … Hello 36 Hello 39 $
你可能会得到完全不同的输出,因为打印字符串的概率只有 50%。
之后,
pgo_prof.dir
文件夹应该包含如这里所示的default_<hash>_<n>.profraw
文件:$ ls pgo_prof.dir default_10799426541722168222_0.profraw
文件名中的 hash 是基于你的代码计算得出的哈希值。
-
我们不能直接使用
*.profraw
文件进行我们的第二个编译阶段。相反,我们必须使用llvm-profdata
工具将其转换为另一种二进制形式。以下是命令:$ llvm-profdata llvm-profdata is a powerful tool for inspecting, converting, and merging profiling data files. We will look at it in more detail later. In the preceding command, we are merging and converting all the data files under pgo_prof.dir into a *single* *.profdata file.
-
最后,我们可以使用我们刚刚合并的文件进行编译的第二阶段。以下是命令:
$ clang -O1 -fprofile-use=pgo_prof.profdata pgo.cpp \ -emit-llvm -S -o pgo.after.ll
这里,-fprofile-use
选项告诉 clang
使用存储在 pgo_prof.profdata
中的配置文件数据来优化代码。我们将在完成此优化后查看 LLVM IR 代码。
打开 pgo.after.ll
并导航到 foo
函数。以下是 foo
的简化版本:
define void @foo(i32 %x) !prof !71 {
entry:
%call = call i32 @get_random()
%cmp = icmp sgt i32 %call, 5
br i1 %cmp, label %if.then, label %if.end, !prof !72
if.then:
%mul = mul nsw i32 %x, 3
…
}
在前面的 LLVM IR 代码中,有两个地方与原始 IR 不同;那就是在函数头部和分支指令之后跟随的!prof
标签,这对应于我们之前看到的if(get_random() > 5)
代码。
在 LLVM IR 中,我们可以在文本 LLVM IR 中附加'!'
,如前面的代码中的!prof
、!71
和!72
是表示我们收集到的分析数据的元数据标签。更具体地说,如果我们有一个与 IR 单元关联的分析数据,它总是以!prof
开头,后面跟着另一个包含所需值的元数据标签。这些元数据值放在 IR 文件的底部。如果我们导航到那里,我们将看到!71
和!72
的内容。以下是代码:
!71 = !{!"function_entry_count", i64 110}
!72 = !{!"branch_weights", i32 57, i32 54}
这两个元数据是包含两个和三个元素的元组。!71
,如其第一个元素所暗示的,表示foo
函数被调用的次数(在这种情况下,它被调用了 110 次)。
另一方面,!72
标记了if(get_random() > 5)
语句中每个分支被取的次数。在这种情况下,真分支被取了 57 次,假分支被取了 54 次。我们得到这些数字是因为我们使用了均匀分布的随机数生成(即每个分支有 50%的机会)。
在本节的第二部分,我们将学习如何访问这些值,以便开发更激进的编译器优化。在我们这样做之前,让我们更深入地看看我们刚刚收集到的分析数据文件。
我们刚刚使用的llvm-profdata
工具不仅可以帮助我们转换分析数据的格式,还可以快速预览其内容。以下命令打印出pgo_prof.profdata
的摘要,包括从每个函数收集到的分析值:
$ llvm-profdata show –-all-functions –-counts pgo_prof.profdata
…
foo:
Hash: 0x0ae15a44542b0f02
Counters: 2
Block counts: [54, 57]
main:
Hash: 0x0209aa3e1d398548
Counters: 2
Block counts: [110, 1]
…
Instrumentation level: IR entry_first = 0
Functions shown: 9
Total functions: 9
Maximum function count: …
Maximum internal block count: …
在这里,我们可以看到每个函数的分析数据条目。每个条目都有一个数字列表,表示所有包围的基本块的执行频率。
或者,您可以通过首先将其转换为文本文件来检查相同的数据文件。以下是命令:
$ llvm-profdata merge –-text pgo_prof.profdata -o pgo_prof.proftext
$ cat pgo_prof.proftext
# IR level Instrumentation Flag
:ir
…
foo
# Func Hash:
784007059655560962
# Num Counters:
2
# Counter Values:
54
57
…
*.proftext
文件是一种人类可读的文本格式,其中所有分析数据都简单地放在自己的行上。
这种文本表示实际上可以使用类似的命令转换回*.profdata
格式。以下是一个示例:
$ llvm-profdata merge –-binary pgo_prof.proftext -o pgo_prof.profdata
因此,*.proftext
在您想手动编辑分析数据时特别有用。
在我们深入探讨 PGO 的 API 之前,还有一个概念我们需要了解:即插装级别。
理解插装级别
到目前为止,我们已经了解到基于仪器的 PGO 可以插入用于收集运行时分析数据的仪器代码。在此基础上,我们插入此仪器代码的位置及其粒度也很重要。这个特性被称为基于仪器的 PGO 中的仪器级别。LLVM 目前支持三种不同的仪器级别。以下是每个级别的描述:
-
我们之前介绍的
-fprofile-generate
命令行选项将使用此仪器级别生成分析数据。例如,假设我们有以下 C 代码:void foo(int x) { if (x > 10) puts("hello"); else puts("world"); }
相应的 IR – 不启用基于仪器的 PGO – 如下所示:
define void @foo(i32 %0) { … %4 = icmp sgt i32 %3, 10 %5 or %7. Now, let's generate the IR with instrumentation-based PGO enabled with the following command:
$ clang @__profc_foo.0
或$ clang @__profc_foo.1
– 单独使用。这两个变量中的值最终将被导出为分支的分析数据,表示每个分支被取用的次数。这种仪器级别提供了相当高的精度,但受编译器变化的影响。更具体地说,如果 Clang 改变了它生成 LLVM IR 的方式,仪器代码将被插入的位置也会不同。这实际上意味着对于相同的输入代码,使用较旧版本的 LLVM 生成的分析数据可能与使用较新 LLVM 生成的分析数据不兼容。 -
Stmt
AST 节点在IfStmt
(一个 AST 节点)内部。使用这种方法,仪器代码几乎不受编译器变化的影响,并且我们可以跨不同编译器版本拥有更稳定的分析数据格式。这种仪器级别的缺点是它的精度低于 IR 仪器级别。您可以通过在调用
clang
进行第一次编译时用-fprofile-instr-generate
命令行选项替换-fprofile-generate
来采用这种仪器级别。尽管如此,您不需要更改第二次编译的命令。 -
使用两个 PGO 命令行选项
clang
,-fprofile-use
和-fcs-profile-generate
,分别指定上一步骤中的分析文件路径和预期的输出路径。当我们使用llvm-profdata
进行后处理时,我们正在合并我们拥有的所有分析数据文件:$ clang -fprofile-use=combined_prof.profdata \ foo.c -o optimized_foo
最后,将合并后的分析文件输入到 Clang 中,以便它可以使用这种上下文相关的分析数据来获取程序运行行为的更准确描述。
注意,不同的仪器级别只会影响分析数据的准确性;它们不会影响我们如何检索这些数据,我们将在下一节中讨论这一点。
在本节的最后部分,我们将学习如何通过 LLVM 提供的 API 在 LLVM 传递中访问此分析数据。
了解访问分析数据的 API
在上一节中,我们学习了如何使用 Clang 运行基于插桩的 PGO 并使用llvm-profdata
查看分析数据文件。在本节中,我们将学习如何在 LLVM pass 中访问这些数据,以帮助我们开发自己的 PGO。
在我们深入开发细节之前,让我们学习如何将那些分析数据文件导入opt
,因为这样更容易使用它来测试单个 LLVM pass。以下是一个示例命令:
$ opt -pgo-test-profile-file=pgo_prof.profdata \
--passes="pgo-instr-use,my-pass…" pgo.ll …
在前面的命令中,有两个关键点:
-
使用
-pgo-test-profile-file
来指定您想要放入的配置文件。 -
字符串"
pgo-instr-use
"代表PGOInstrumentaitonUse
pass,它读取(基于插桩的)分析文件并在 LLVM IR 上对数据进行注释。然而,它默认情况下并不运行,即使在预定义的优化级别(即 O0 ~ O3,Os 和 Oz)中也是如此。如果没有这个 pass 在 Pass 管道中提前运行,我们就无法访问任何分析数据。因此,我们需要明确将其添加到优化管道中。前面的示例命令演示了如何在管道中在自定义 LLVM passmy-pass
之前运行它。如果您想在任何预定义的优化管道之前运行它——例如,O1——您必须指定--passes="pgo-instr-use,default<O1>"
命令行选项。
现在,你可能想知道,在将分析数据读入opt
之后会发生什么?事实证明,由第二个编译阶段生成的 LLVM IR 文件——pgo.after.ll
——为我们提供了对这个问题的答案。
在pgo.after.ll
中,我们看到一些分支被元数据装饰,指定了它们被取的次数。类似的元数据也出现在函数中,表示这些函数被调用的总次数。
更普遍地说,LLVM 直接通过元数据将分析数据(从文件中读取)与其相关的 IR 构造结合起来。这种策略的最大优点是我们不需要在整个优化管道中携带原始分析数据——IR 本身包含这些分析信息。
现在,问题变成了,我们如何访问附加到 IR 上的元数据?LLVM 的元数据可以附加到许多种 IR 单元上。让我们首先看看最常见的一种:访问附加到Instruction
上的元数据。以下代码展示了我们如何读取之前看到的附加到分支指令上的分析元数据!prof !71
:
// `BB` has the type of `BasicBlock&`
Instruction *BranchInst = BB.getTerminator();
MDNode *BrWeightMD = BranchInst->getMetadata(LLVMContext::MD_prof);
在前面的代码片段中,我们使用BasicBlock::getTerminator
来获取基本块中的最后一个指令,这通常是分支指令。然后,我们尝试使用MD_prof
元数据检索分析元数据。BrWeightMD
是我们正在寻找的结果。
BrWeightMD
、MDNode
类型的表示单个元数据节点。不同的MDNode
实例可以组合在一起。更具体地说,一个MDNode
实例可以使用其他MDNode
实例作为其操作数——类似于我们在第十章中看到的Value
和User
实例,处理 LLVM IR。复合MDNode
可以表达更复杂的概念。
例如,在这种情况下,BrWeightMD
中的每个操作数代表每个分支被取用的次数。以下是访问它们的代码:
if (BrWeightMD->getNumOperands() > 2) {
// Taken counts for true branch
MDNode *TrueBranchMD = BrWeightMD->getOperand(1);
// Taken counts for false branch
MDNode *FalseBranchMD = BrWeightMD->getOperand(2);
}
如您所见,取用计数也以MDNode
的形式表示。
两个分支的操作数索引
注意,两个分支的数据都放置在索引从 1 开始的操作数中,而不是索引 0。
如果我们要将这些分支MDNode
实例转换为常量,我们可以利用mdconst
命名空间提供的小工具。以下是一个示例:
if (BrWeightMD->getNumOperands() > 2) {
// Taken counts for true branch
MDNode *TrueBranchMD = BrWeightMD->getOperand(1);
ConstantInt *NumTrueBrTaken
= mdconst::dyn_extract<ConstantInt>(TrueBranchMD);
…
}
之前的代码展开了一个MDNode
实例并提取了底层的ConstantInt
实例。
对于Function
,我们可以以更简单的方式获取其被调用的次数。以下是代码:
// `F` has the type of `Function&`
Function::ProfileCount EntryCount = F.getEntryCount();
uint64_t EntryCountVal = EntryCount.getCount();
Function
使用一种稍微不同的方式来表示其调用频率。但是,检索数值配置文件值仍然非常简单,如前所述。
值得注意的是,尽管我们在这里只关注基于采样的 PGO,但对于基于采样的 PGO,LLVM 也使用相同的编程接口来公开其数据。换句话说,即使你使用的是通过不同的opt
命令收集的采样工具的配置文件数据,配置文件数据也会标注在 IR 单元上,你仍然可以使用上述方法访问它。实际上,我们将在本节后面介绍的工具和 API 大多与配置文件数据源无关。
到目前为止,我们一直在处理从配置文件数据中检索到的实数值。然而,这些低级值不能帮助我们深入开发编译器优化或程序分析算法——通常,我们更感兴趣的是高级概念,如“执行最频繁的函数”或“最少被取用的分支”。为了满足这些需求,LLVM 在配置文件数据之上构建了几个分析,以提供此类高级、结构化的信息。
在下一节中,我们将介绍其中一些分析及其在 LLVM Pass 中的用法。
使用配置文件数据分析
在本节中,我们将学习三个分析类,它们可以帮助我们在运行时推理基本块和函数的执行频率。它们如下:
-
BranchProbabilityInfo
-
BlockFrequencyInfo
-
ProfileSummaryInfo
此列表按其在 IR 中的分析范围排序——从局部到全局。让我们从前两个开始。
使用 BranchProbabilityInfo 和 BlockFrequencyInfo
在上一节中,我们学习了如何访问附加到每个分支指令的配置元数据——BranchProbabilityInfo
类。以下是一些示例代码,展示了如何在(函数)Pass 中使用它:
#include "llvm/Analysis/BranchProbabilityInfo.h"
PreservedAnalyses run(Function &F, FunctionAnalysisManager &FAM) {
BranchProbabilityInfo &BPI
= FAM.getResult<BranchProbabilityAnalysis>(F);
BasicBlock *Entry = F.getEntryBlock();
BranchProbability BP = BPI.getEdgeProbability(Entry, 0);
…
}
之前的代码检索了一个BranchProbabilityInfo
实例,这是BranchProbabilityAnalysis
的结果,并试图从入口块获取到其第一个后继块的权重。
返回值,一个BranchProbability
实例,以百分比的形式给出分支的概率。你可以使用BranchProbability::getNumerator
来获取值(默认情况下,“分母”是 100)。BranchProbability
类还提供了一些方便的实用方法,用于执行两个分支概率之间的算术运算或按特定因子缩放概率。尽管我们可以很容易地通过BranchProbabilityInfo
来判断哪个分支更有可能被选中,但没有额外的数据,我们无法知道整个函数中分支的概率(被选中的概率)。例如,假设我们有一个以下的 CFG:
![图 12.6 – 嵌套分支
![img/B14590_12.6.jpg]
图 12.6 – 嵌套分支的 CFG
对于前面的图,我们有以下基本块的配置计数器值:
-
if.then4: 2
-
if.else: 10
-
if.else7: 20
如果我们只查看指向if.then4
和if.else
块的分支权重元数据——即if.then
的真分支和假分支——我们可能会产生一种错觉,认为if.else
块有大约 83%的概率被选中。但事实是,它只有大约 31%的概率,因为控制流在进入if.then
区域之前更有可能进入if.else7
。当然,在这种情况下,我们可以通过简单的数学计算来找出正确答案,但当 CFG 变得更大、更复杂时,我们可能很难自己完成这项工作。
BlockFrequencyInfo
类为这个问题提供了一个快捷方式。它可以在其封装函数的上下文中告诉我们每个基本块被选中的频率。以下是在 Pass 中使用它的一个示例:
#include "llvm/Analysis/BlockFrequencyInfo.h"
PreservedAnalyses run(Function &F, FunctionAnalysisManager &FAM) {
BlockFrequencyInfo &BFI
= FAM.getResult<BlockFrequencyAnalysis>(F);
for (BasicBlock *BB : F) {
BlockFrequency BF = BFI.getBlockFreq(BB);
}
…
}
之前的代码检索了一个BlockFrequencyInfo
实例,这是BlockFrequencyAnalysis
的结果,并试图评估函数中每个基本块的块频率。
与BranchProbability
类类似,BlockFrequency
也提供了计算其他BlockFrequency
实例的便捷实用方法。但与BranchProbability
不同,从BlockFrequency
检索的数值不是以百分比的形式呈现的。更具体地说,BlockFrequency::getFrequency
返回一个整数,它是相对于当前函数入口块的频率。换句话说,要获取基于百分比的频率,我们可以使用以下代码片段:
// `BB` has the type of `BasicBlock*`
// `Entry` has the type of `BasicBlock*` and represents entry // block
BlockFrequency BBFreq = BFI.getBlockFreq(BB),
EntryFreq = BFI.getBlockFreq(Entry);
auto FreqInPercent
= (BBFreq.getFrequency() / EntryFreq.getFrequency()) * 100;
突出的FreqInPercent
是BB
的块频率,以百分比表示。
BlockFrequencyInfo
计算在函数上下文中特定基本块的频率——但整个 模块 呢?更具体地说,如果我们引入一个 ProfileSummaryInfo
。
使用 ProfileSummaryInfo
ProfileSummaryInfo
类为你提供了一个全局视图,展示了 Module
中所有分析数据。以下是在模块 Pass 中检索其实例的一个示例:
#include "llvm/Analysis/ProfileSummaryInfo.h"
PreservedAnalyses run(Module &M, ModuleAnalysisManager &MAM) {
ProfileSummaryInfo &PSI = MAM. getResult<ProfileSummaryAnalysis>(M);
…
}
ProfileSummaryInfo
提供了各种各样的功能。让我们看看它最有趣的三个方法:
-
isFunctionEntryCold/Hot(Function*)
: 这两个方法比较一个Function
的入口计数——这实际上反映了函数被调用的次数——与同一模块中其他函数的入口计数,并告诉我们查询函数在这个指标中是排名靠前还是靠后。 -
isHot/ColdBlock(BasicBlock*, BlockFrequencyInfo&)
: 这两个方法与前面的要点类似,但比较BasicBlock
的执行频率与模块中 所有 其他块的执行频率。 -
isFunctionCold/HotInCallGraph(Function*, BlockFrequencyInfo&)
: 这两个方法结合了前面两个要点,它们可以告诉你一个函数是否被认为是“热”或“冷”,基于其入口计数或其封装基本块的执行频率。这在函数入口计数较低——即它没有被经常调用——但包含一个执行频率极高的 循环 时非常有用。在这种情况下,isFunctionHotInCallGraph
方法可以给我们一个更准确的评估。
这些 API 也有变体,你可以指定截止点为“热”或“冷”。请参阅 API 文档以获取更多信息。
很长一段时间以来,编译器只能以静态视图分析并优化源代码。对于程序内部的动态因素——例如,分支计数——编译器只能做出近似。PGO 开辟了一条替代路径,为编译器提供额外的信息,以便编译器可以窥视目标程序的运行时行为,从而做出更明确、更激进的决策。在本节中,我们学习了如何使用 LLVM 收集和使用运行时分析信息——PGO 的关键——我们学习了如何使用 LLVM 中的相关基础设施来收集和生成此类分析数据。我们还了解了我们可以使用的编程接口来访问这些数据——以及在此基础上构建的一些高级分析——以协助我们在 LLVM Pass 中的开发。有了这些能力,LLVM 开发者可以将这些运行时信息插入,以进一步提高他们现有优化 Pass 的质量和精度。
摘要
在本章中,我们通过处理静态源代码和捕获程序的运行时行为来增强了编译器的工作空间。在本章的第一部分,我们学习了如何使用 LLVM 提供的框架来创建一个检查器——一种将检测代码插入目标程序的技术,目的是检查某些运行时属性。通过使用检查器,软件工程师可以轻松且精确地提高他们的开发质量。在本章的第二部分,我们将此类运行时数据的用途扩展到了编译器优化的领域;PGO 是一种使用动态信息(如基本块或函数的执行频率)来做出更激进决策的技术,以优化代码。最后,我们学习了如何使用 LLVM Pass 访问此类数据,这使得我们能够将 PGO 增强功能添加到现有的优化中。
恭喜你,你已经完成了最后一章!非常感谢你阅读这本书。编译器开发在计算机科学中从来都不是一个容易的主题——如果不是一个晦涩难懂的主题。在过去十年中,LLVM 通过提供强大而灵活的模块,从根本上改变了人们对于编译器的看法,从而显著降低了这一主题的难度。编译器不再只是一个单一的执行文件,如gcc
或clang
,而是一系列构建块,为开发者提供了无数种创建工具的方法,以处理编程语言领域的难题。
然而,选择如此之多,当我刚开始接触 LLVM 时,我常常感到迷茫和困惑。这个项目中每个 API 都有文档,但我不知道如何将它们组合起来。我希望能有一本书能指出 LLVM 中每个重要组件的大致方向,告诉我它是什么,以及我如何利用它。现在这本书就在这里,就是我一直在梦想的,在我开始 LLVM 生涯之初就能拥有的那本书——你刚刚读完的那本书——现在变成了现实。我希望你在读完这本书后不会停止你对 LLVM 的探索。为了进一步提高你的技能并巩固你从这本书中学到的知识,我建议你查看官方文档页面(llvm.org/docs
),以补充这本书的内容。更重要的是,我鼓励你通过他们的邮件列表(lists.llvm.org/cgi-bin/mailman/listinfo/llvm-dev
)或 Discourse 论坛(https://llvm.discourse.group/)参与 LLVM 社区,尤其是第一个——虽然邮件列表可能听起来有些过时,但那里有很多愿意回答你的问题并提供有用学习资源的才华横溢的人。最后但同样重要的是,年度 LLVM 开发者会议(llvm.org/devmtg/
),在美国和欧洲举行,是一些你可以学习新的 LLVM 技能并与实际构建 LLVM 的人面对面交流的绝佳活动。
我希望这本书能照亮你在掌握 LLVM 的道路上,并帮助你从编译器的构建中找到乐趣。
第十三章:评估
本节包含所有章节的问题答案。
第六章,扩展预处理器
-
大多数情况下,标记是从提供的源代码中收集的,但在某些情况下,标记可能会在
Preprocessor
内部动态生成。例如,内置宏__LINE__
被展开为当前行号,而宏__DATE__
被展开为当前的日历日期。Clang 如何将这些生成的文本内容放入SourceManager
的源代码缓冲区?Clang 如何将这些SourceLocation
分配给这些标记?- 开发者可以利用
clang::ScratchBuffer
类来插入动态的Token
实例。
- 开发者可以利用
-
当我们讨论实现自定义的
PragmaHandler
时,我们使用Preprocessor::Lex
来获取紧随pragma
名称之后的标记,直到遇到eod
标记类型。我们能否在eod
标记之后继续进行词法分析?如果你可以消费#pragma
指令之后任意跟随的标记,你将做些什么有趣的事情?-
是的,我们可以在
eod
标记之后继续进行词法分析。它只是消费了#pragma
行之后的内 容。这样,你可以创建一个自定义的#pragma
,允许你写入 任意 内容(在其下方)——例如,编写 Clang 不支持的编程语言。以下是一个示例:#pragma that allows you to define a JavaScript function below it.
-
-
在 开发自定义预处理器插件和回调 部分的
macro guard
项目中,警告消息的格式为[WARNING] In <source location>: ….
。显然,这不是我们从Clang
看到的典型编译器警告,其格式看起来像<source location>: warning: …
:./simple_warn.c:2:7: warning: unused variable 'y'… int y = x + 1; ^ 1 warning generated.
warning
字符串甚至在支持的终端中着色。我们如何打印这样的警告消息?Clang 中是否有用于此目的的基础设施?- 开发者可以使用 Clang 中的诊断框架来打印此类消息。在 第七章 的 打印诊断消息 部分,即 处理 AST,我们将向您展示该框架的一些用法。
第八章,与编译器标志和工具链一起工作
-
覆盖汇编和链接阶段是很常见的,因为不同的平台通常支持不同的汇编器和链接器。但是,是否可以覆盖 编译 阶段(即 Clang)?如果可以,我们该如何做?人们可能出于什么原因这样做?
-
你可以覆盖
ToolChain::SelectTool
方法并提供一个替代的Tool
实例(它代表编译阶段),根据参数提供。以下是一个示例:Tool* MyCompiler – which is a class derived from Tool, if we are trying to compile the code for a certain hardware architecture.Providing an alternative compiler instance is useful when your target platform (for example, the `CUSTOM_HARDWARE` in the preceding snippet) or input file is not supported by Clang, but you still want to use the *same* `clang` command-line interface for all the build jobs. For example, suppose you are trying to cross-compile the same projects to *multiple* different architectures, but some of them are not supported by Clang yet. Therefore, you can create a custom Clang toolchain and redirect the compilation job to an external compiler (for example, `gcc`) when the `clang` command-line tool is asked to build the project for those architectures.
-
-
当我们处理
tools::zipline::Linker::ConstructJob
时,我们简单地使用llvm_unreachable
来通过-fuse-ld
标志退出编译过程,如果用户提供了不支持的压缩器名称。我们可以用 Clang 的Driver
类提供的快捷方式来替换它,访问诊断框架。在一个Tool
的派生类中,您可以使用getToolChain().getDriver()
来获取一个Driver
实例,然后使用Driver::Diag
方法打印出诊断信息。 -
就像我们可以使用
-Xclang
将标志直接传递给前端一样,我们也可以通过驱动器标志(如-Wa
用于汇编器,-Wl
用于链接器)将汇编器特定或链接器特定的标志直接传递给汇编器或链接器。我们如何在 Zipline 的自定义汇编器和链接器阶段消耗这些标志?-
在
ConstructJob
方法内部,您可以读取options::OPT_Wa_COMMA
和options::OPT_Wl_COMMA
的值,分别检索汇编器和链接器特定的命令行标志。以下是一个示例:void MyAssembler::ConstructJob(Compilation &C, const JobAction &JA, const InputInfo &Output, const InputInfoList &Inputs, const ArgList &Args, const char *LinkingOutput) const { if (Arg *A = Args.getLastArg(options::OPT_Wl_COMMA)) { // `A` contains linker-specific flags … } … }
-
第九章,使用 PassManager 和 AnalysisManager
-
在 Writing a LLVM Pass for the new PassManager 部分的 StrictOpt 示例中,我们如何编写一个不继承
PassInfoMixin
类的 Pass?-
PassInfoMixin
类仅为您定义了一个实用函数name
,该函数返回此 Pass 的名称。因此,您可以轻松地自己创建一个。以下是一个示例:struct MyPass { static StringRef name() { return "MyPass"; } PreservedAnalyses run(Function&, FunctionAnalysisManager&); };
-
-
我们如何为新的 PassManager 开发自定义的仪器?我们如何在不修改 LLVM 源树的情况下完成它?(提示:使用本章中我们学习过的 Pass 插件。)
- Pass 仪器是一段在 LLVM Pass 之前和/或之后运行的代码。这篇博客文章展示了通过 Pass 插件开发自定义 Pass 仪器的一个示例:
medium.com/@mshockwave/writing-pass-instrument-for-llvm-newpm-f17c57d3369f
。
- Pass 仪器是一段在 LLVM Pass 之前和/或之后运行的代码。这篇博客文章展示了通过 Pass 插件开发自定义 Pass 仪器的一个示例:
订阅我们的在线数字图书馆,全面访问超过 7,000 本书籍和视频,以及行业领先的工具,帮助您规划个人发展并推进职业生涯。更多信息,请访问我们的网站。
第十四章:为什么订阅?
-
通过来自 4,000 多名行业专业人士的实用电子书和视频,节省学习时间,多花时间编码
-
通过为您量身定制的技能计划提高您的学习效果
-
每月免费获得一本电子书或视频
-
完全可搜索,便于快速访问关键信息
-
复制粘贴、打印和收藏内容
您知道 Packt 为每本书都提供电子书版本,包括 PDF 和 ePub 文件吗?您可以在packt.com升级到电子书版本,并且作为印刷书客户,您有权获得电子书副本的折扣。有关更多信息,请联系我们 customercare@packtpub.com。
在www.packt.com,您还可以阅读一系列免费的技术文章,订阅各种免费通讯,并享受 Packt 书籍和电子书的独家折扣和优惠。
您可能还会喜欢以下书籍
如果您喜欢这本书,您可能会对 Packt 出版的以下其他书籍感兴趣:
LLVM 核心库入门
布鲁诺·卡多索·洛佩斯,拉斐尔·奥勒尔
ISBN: 9781782166924
-
配置、构建和安装额外的 LLVM 开源项目,包括 Clang 工具、静态分析器、Compiler-RT、LLDB、DragonEgg、libc++和 LLVM 测试套件
-
理解 LLVM 库的设计以及库和独立工具之间的交互
-
通过学习 Clang 前端如何使用词法分析器、解析器和语法分析来增加您对源代码处理阶段的了解
-
在编写自定义 IR 分析和转换传递时,操作、生成和玩 LLVM IR 文件
-
编写使用 LLVM 即时(JIT)编译功能的工具
-
通过使用静态分析器查找错误并改进您的代码
-
使用 LibClang、LibTooling 和 Clang 插件接口设计源代码分析和转换工具
学习 LLVM 12
凯·纳克
ISBN: 9781839213502
-
配置、编译和安装 LLVM 框架
-
理解 LLVM 源代码的组织结构
-
发现您在自己的项目中使用 LLVM 需要做什么
-
探索编译器的结构,并实现一个微型编译器
-
为常见的源语言构造生成 LLVM IR
-
设置优化管道并针对您的需求进行定制
-
通过转换传递和 Clang 工具扩展 LLVM
-
添加新的机器指令和完整的后端
Packt 正在寻找像您这样的作者
如果你有兴趣成为 Packt 的作者,请访问authors.packtpub.com并今天申请。我们与成千上万的开发者和技术专业人士合作,就像你一样,帮助他们将见解分享给全球技术社区。你可以提交一个一般性申请,申请我们正在招募作者的特定热门话题,或者提交你自己的想法。
留下评论 - 让其他读者了解你的想法
请通过在购买书籍的网站上留下评论的方式,与大家分享你对这本书的看法。如果你是从亚马逊购买的书籍,请在本书的亚马逊页面上留下一个诚实的评论。这对其他潜在读者来说至关重要,他们可以通过你的客观意见来做出购买决定,我们也可以了解客户对我们产品的看法,我们的作者也可以看到他们对与我们合作创作的书籍的反馈。这只需你几分钟的时间,但对其他潜在客户、我们的作者以及 Packt 来说都非常有价值。谢谢!