LLVM12-学习手册-全-

LLVM12 学习手册(全)

原文:zh.annas-archive.org/md5/96A20F7680F39BBAA9B437BF26B65FE2

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

构建编译器是一项复杂而迷人的任务。LLVM 项目为您的编译器提供了可重用的组件。LLVM 核心库实现了世界一流的优化代码生成器,为所有流行的 CPU 架构转换了与源语言无关的中间表示的机器代码。许多编程语言的编译器已经利用了 LLVM 技术。

本书教会您如何实现自己的编译器,以及如何使用 LLVM 来实现。您将学习编译器的前端如何将源代码转换为抽象语法树,以及如何从中生成中间表示(IR)。通过向编译器添加优化管道,您可以将 IR 编译为高性能的机器代码。

LLVM 框架可以通过多种方式进行扩展,您将学习如何向 LLVM 添加新的 pass、新的机器指令,甚至是一个全新的后端。高级主题,如为不同的 CPU 架构进行编译,以及使用自己的插件和检查器扩展 clang 和 clang 静态分析器也会被涵盖。本书采用实用的方法,包含大量示例源代码,使得在自己的项目中应用所学知识变得容易。

本书适合对象

本书适用于编译器开发人员、爱好者和工程师,他们对 LLVM 还不熟悉,有兴趣了解 LLVM 框架。对于希望使用基于编译器的工具进行代码分析和改进的 C++软件工程师,以及希望更多了解 LLVM 基础知识的 LLVM 库的普通用户也很有用。理解本书所涵盖概念需要具备中级水平的 C++编程经验。

本书涵盖内容

[第一章],安装 LLVM,解释了如何设置和使用开发环境。在本章结束时,您将已经编译了 LLVM 库,并学会了如何自定义构建过程。

[第二章],LLVM 源码之旅,介绍了各种 LLVM 项目,并讨论了所有项目共享的常见目录布局。您将使用 LLVM 核心库创建您的第一个项目,并为不同的 CPU 架构进行编译。

[第三章],编译器的结构,为您概述了编译器的组件。在本章结束时,您将已经实现了生成 LLVM IR 的第一个编译器。

[第四章],将源文件转换为抽象语法树,详细教您如何实现编译器的前端。您将为一种小型编程语言创建自己的前端,最终构建一个抽象语法树。

[第五章],IR 生成基础,向您展示如何从抽象语法树生成 LLVM IR。在本章结束时,您将已经实现了一个示例语言的编译器,生成汇编文本或目标代码文件作为结果。

[第六章],高级语言结构的 IR 生成,说明了如何将高级编程语言中常见的源语言特性转换为 LLVM IR。您将学习如何翻译聚合数据类型,实现类继承和虚函数的各种选项,以及如何遵守系统的应用二进制接口。

[第七章],高级 IR 生成,向您展示如何为源语言中的异常处理语句生成 LLVM IR。您还将学习如何为基于类型的别名分析添加元数据,以及如何向生成的 LLVM IR 添加调试信息,并扩展您的编译器生成的元数据。

第八章优化 IR,解释了 LLVM pass 管理器。您将实现自己的 pass,作为 LLVM 的一部分和作为插件,并学习如何将新 pass 添加到优化 pass 管道中。

第九章指令选择,展示了 LLVM 如何将 IR 降低为机器指令。您将学习 LLVM 中如何定义指令,并向 LLVM 添加一个新的机器指令,以便指令选择考虑新指令。

第十章JIT 编译,讨论了如何使用 LLVM 实现即时JIT)编译器。在本章结束时,您将以两种不同的方式为 LLVM IR 实现自己的 JIT 编译器。

第十一章使用 LLVM 工具进行调试,探讨了 LLVM 的各种库和组件的细节,这有助于您识别应用程序中的错误。您将使用 sanitizer 来识别缓冲区溢出和其他错误。使用 libFuzzer 库,您将测试具有随机数据输入的函数,XRay 将帮助您找到性能瓶颈。您将使用 clang 静态分析器在源代码级别识别错误,并了解您可以向分析器添加自己的检查器。您还将学习如何使用自己的插件扩展 clang。

第十二章创建自己的后端,解释了如何向 LLVM 添加新的后端。您将实现所有必要的类,并在本章结束时将 LLVM IR 编译为另一种 CPU 架构。

为了充分利用本书

您需要一台运行 Linux、Windows、macOS 或 FreeBSD 的计算机,并为操作系统安装了开发工具链。请参阅所需工具的表格。所有工具都应该在您的 shell 的搜索路径中。

要查看第九章中的 DAG 可视化,指令选择,您必须安装来自graphviz.org/的 Graphviz 软件。默认情况下,生成的图像是 PDF 格式,您需要一个 PDF 查看器来显示它。

要创建第十一章中的火焰图,使用 LLVM 工具进行调试,您需要从github.com/brendangregg/FlameGraph安装脚本。要运行脚本,您还需要安装最新版本的 Perl,并且要查看图形,您需要一个能够显示 SVG 文件的 Web 浏览器,所有现代浏览器都可以。要查看同一章节中的 Chrome Trace Viewer 可视化,您需要安装 Chrome 浏览器。

如果您使用本书的数字版本,我们建议您自己输入代码或通过 GitHub 存储库(链接在下一节中提供)访问代码。这样做将有助于避免与复制和粘贴代码相关的任何潜在错误。

下载示例代码文件

您可以从 GitHub 上的github.com/PacktPublishing/Learn-LLVM-12下载本书的示例代码文件。如果代码有更新,将在现有的 GitHub 存储库上进行更新。

我们还有来自我们丰富书籍和视频目录的其他代码包,可在github.com/PacktPublishing/上找到。看一看吧!

代码实例

本书的代码实例视频可在bit.ly/3nllhED上观看

下载彩色图片

我们还提供了一个 PDF 文件,其中包含本书中使用的屏幕截图/图表的彩色图像。您可以在此处下载:static.packt-cdn.com/downloads/9781839213502_ColorImages.pdf

使用的约定

本书中使用了许多文本约定。

文本中的代码:表示文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 用户名。例如:“您可以在代码中观察到定义了一个量子电路操作,并定义了一个名为numOnes的变量。”

代码块设置如下:

#include "llvm/IR/IRPrintingPasses.h"
#include "llvm/IR/LegacyPassManager.h"
#include "llvm/Support/ToolOutputFile.h"

当我们希望引起您对代码块的特定部分的注意时,相关行或项目将以粗体显示:

  switch (Kind) {
// Many more cases
  case m88k:           return "m88k";
  }

粗体:表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词会以这种方式出现在文本中。例如:“从管理面板中选择系统信息。”

提示或重要说明

看起来像这样。

第一部分:使用 LLVM 进行编译器构建的基础

在本节中,您将学习如何自己编译 LLVM,以及如何根据自己的需求定制构建。您将了解 LLVM 项目的组织方式,并将创建您的第一个利用 LLVM 的项目。您还将学习如何为不同的 CPU 架构编译 LLVM 和使用 LLVM 进行应用程序的编译。最后,您将在创建一个小型编译器的过程中探索编译器的整体结构。

本节包括以下章节:

  • 第一章, 安装 LLVM

  • 第二章, 浏览 LLVM 源码

  • 第三章, 编译器的结构

第一章:安装 LLVM

要了解如何使用 LLVM,最好从源代码编译 LLVM 开始。LLVM 是一个综合项目,其 GitHub 存储库包含属于 LLVM 的所有项目的源代码。每个 LLVM 项目都在存储库的顶级目录中。除了克隆存储库外,您的系统还必须安装构建系统所需的所有工具。

在本章中,您将了解以下主题:

  • 准备先决条件,将向您展示如何设置构建系统。

  • 使用 CMake 构建,将介绍如何使用 CMake 和 Ninja 编译和安装 LLVM 核心库和 Clang。

  • 定制构建过程,将讨论我们可以影响构建过程的各种方式。

准备先决条件

要使用 LLVM,您的开发系统必须运行常见的操作系统,如 Linux,FreeBSD,macOS 或 Windows。启用调试符号构建 LLVM 和 Clang 很容易需要数十 GB 的磁盘空间,因此请确保您的系统有足够的磁盘空间可用-在这种情况下,您应该有 30GB 的可用空间。

所需的磁盘空间严重依赖于所选择的构建选项。例如,仅在发布模式下构建 LLVM 核心库,同时仅针对一个平台,大约需要 2GB 的可用磁盘空间,这是所需的最低限度。为了减少编译时间,快速的 CPU(例如 2.5GHz 时钟速度的四核 CPU)和快速的 SSD 也会有所帮助。

甚至可以在树莓派等小型设备上构建 LLVM-只是需要花费很长时间。我在一台配有 Intel 四核 CPU,时钟速度为 2.7GHz,40GB RAM 和 2.5TB SSD 磁盘空间的笔记本电脑上开发了本书中的示例。这个系统非常适合手头的开发任务。

您的开发系统必须安装一些先决条件软件。让我们回顾一下这些软件包的最低要求版本。

注意

Linux 发行版通常包含可以使用的更新版本。版本号适用于 LLVM 12。LLVM 的较新版本可能需要这里提到的软件包的更新版本。

要从GitHub检出源代码,您需要git (git-scm.com/)。没有特定版本的要求。GitHub 帮助页面建议至少使用版本 1.17.10。

LLVM 项目使用CMake (cmake.org/) 作为构建文件生成器。至少需要版本 3.13.4。CMake 可以为各种构建系统生成构建文件。在本书中,使用Ninja (ninja-build.org/),因为它快速且在所有平台上都可用。建议使用最新版本 1.9.0。

显然,您还需要一个C/C++编译器。LLVM 项目是用现代 C++编写的,基于 C++14 标准。需要符合的编译器和标准库。已知以下编译器与 LLVM 12 兼容:

  • gcc 5.1.0 或更高版本

  • Clang 3.5 或更高版本

  • Apple Clang 6.0 或更高版本

  • Visual Studio 2017 或更高版本

请注意,随着 LLVM 项目的进一步发展,编译器的要求很可能会发生变化。在撰写本文时,有讨论要使用 C++17 并放弃对 Visual Studio 2017 的支持。一般来说,您应该使用系统中可用的最新编译器版本。

Python (python.org/) 用于生成构建文件和运行测试套件。它应至少是 3.6 版本。

尽管本书未涉及,但您可能有理由需要使用 Make 而不是 Ninja。在这种情况下,您需要在每个命令中使用make和本书中描述的场景。

要安装先决条件软件,最简单的方法是使用操作系统的软件包管理器。在接下来的部分中,将显示安装最受欢迎操作系统的软件所需输入的命令。

Ubuntu

Ubuntu 20.04 使用 APT 软件包管理器。大多数基本实用程序已经安装好了;只有开发工具缺失。要一次安装所有软件包,请键入以下内容:

$ sudo apt install –y gcc g++ git cmake ninja-build

Fedora 和 RedHat

Fedora 33 和 RedHat Enterprise Linux 8.3 的软件包管理器称为DNF。与 Ubuntu 一样,大多数基本实用程序已经安装好了。要一次安装所有软件包,请键入以下内容:

$ sudo dnf install –y gcc gcc-c++ git cmake ninja-build

FreeBSD

在 FreeBSD 12 或更高版本上,必须使用 PKG 软件包管理器。FreeBSD 与基于 Linux 的系统不同,它更喜欢使用 Clang 编译器。要一次安装所有软件包,请键入以下内容:

$ sudo pkg install –y clang git cmake ninja

OS X

在 OS X 上进行开发时,最好从 Apple 商店安装Xcode。虽然本书中没有使用 XCode IDE,但它带有所需的 C/C++编译器和支持工具。要安装其他工具,可以使用 Homebrew 软件包管理器(https://brew.sh/)。要一次安装所有软件包,请键入以下内容:

$ brew install git cmake ninja

Windows

与 OS X 一样,Windows 没有软件包管理器。安装所有软件的最简单方法是使用Chocolatelychocolatey.org/)软件包管理器。要一次安装所有软件包,请键入以下内容:

$ choco install visualstudio2019buildtools cmake ninja git\
  gzip bzip2 gnuwin32-coreutils.install

请注意,这只安装了来自package visualstudio2019community而不是visualstudio2019buildtools的构建工具。Visual Studio 2019 安装的一部分是 x64 Native Tools Command Prompt for VS 2019。使用此命令提示时,编译器会自动添加到搜索路径中。

配置 Git

LLVM 项目使用 Git 进行版本控制。如果您以前没有使用过 Git,则应该在继续之前对 Git 进行一些基本配置;也就是说,设置用户名和电子邮件地址。如果您提交更改,这两个信息都会被使用。在以下命令中,将Jane替换为您的姓名,jane@email.org替换为您的电子邮件:

$ git config --global user.email "jane@email.org"
$ git config --global user.name "Jane"

默认情况下,Git 使用vi编辑器进行提交消息。如果您希望使用其他编辑器,则可以以类似的方式更改配置。要使用nano编辑器,请键入以下内容:

$ git config --global core.editor nano

有关 git 的更多信息,请参阅 Packt Publishing 的Git Version Control Cookbook - Second Editionwww.packtpub.com/product/git-version-control-cookbook/9781782168454)。

使用 CMake 构建

准备好构建工具后,您现在可以从 GitHub 检出所有 LLVM 项目。执行此操作的命令在所有平台上基本相同。但是,在 Windows 上,建议关闭行结束的自动翻译。

让我们分三部分回顾这个过程:克隆存储库,创建构建目录和生成构建系统文件。

克隆存储库

在所有非 Windows 平台上,键入以下命令以克隆存储库:

$ git clone https://github.com/llvm/llvm-project.git

在 Windows 上,您必须添加选项以禁用自动翻译行结束。在这里,键入以下内容:

$ git clone --config core.autocrlf=false\  https://github.com/llvm/llvm-project.git

这个git命令将最新的源代码从 GitHub 克隆到名为llvm-project的本地目录中。现在,使用以下命令将当前目录更改为新的llvm-project目录:

$ cd llvm-project

在目录中包含了所有 LLVM 项目,每个项目都在自己的目录中。值得注意的是,LLVM 核心库位于llvm子目录中。LLVM 项目使用分支进行后续发布的开发(“release/12.x”)和标记(“llvmorg-12.0.0”)来标记特定的发布。使用前面的clone命令,您可以获得当前的开发状态。本书使用 LLVM 12。要检出 LLVM 12 的第一个发布版本,请键入以下内容:

$ git checkout -b llvmorg-12.0.0

有了这个,你已经克隆了整个存储库并检出了一个标签。这是最灵活的方法。

Git 还允许你只克隆一个分支或一个标签(包括历史记录)。使用git clone --branch llvmorg-12.0.0 https://github.com/llvm/llvm-project,你检出了与之前相同的标签,但只克隆了该标签的历史记录。通过额外的--depth=1选项,你可以防止克隆历史记录。这样可以节省时间和空间,但显然会限制你在本地可以做什么。

下一步是创建一个构建目录。

创建一个构建目录

与许多其他项目不同,LLVM 不支持内联构建,需要一个单独的build目录。这可以很容易地在llvm-project目录内创建。使用以下命令切换到此目录:

$ cd llvm-project

然后,为简单起见,创建一个名为build的构建目录。在这里,Unix 和 Windows 系统的命令不同。在类 Unix 系统上,你应该使用以下命令:

$ mkdir build

在 Windows 上,你应该使用以下命令:

$ md build

然后,切换到build目录:

$ cd build

现在,你已经准备好在这个目录中使用 CMake 工具创建构建系统文件。

生成构建系统文件

要生成使用 Ninja 编译 LLVM 和 Clang 的构建系统文件,请运行以下命令:

$ cmake –G Ninja -DLLVM_ENABLE_PROJECTS=clang ../llvm

提示

在 Windows 上,反斜杠字符\是目录名称分隔符。在 Windows 上,CMake 会自动将 Unix 分隔符/转换为 Windows 分隔符。

-G选项告诉 CMake 为哪个系统生成构建文件。最常用的选项如下:

  • Ninja:对于 Ninja 构建系统

  • Unix Makefiles:对于 GNU Make

  • Visual Studio 15 VS2017Visual Studio 16 VS2019:对于 Visual Studio 和 MS Build

  • Xcode:对于 XCode 项目

生成过程可以通过使用-D选项设置各种变量来进行影响。通常,它们以CMAKE_(如果由 CMake 定义)或LLVM_(如果由 LLVM 定义)为前缀。通过设置LLVM_ENABLE_PROJECTS=clang变量,CMake 会生成 Clang 的构建文件,除了 LLVM。命令的最后一部分告诉 CMake 在哪里找到 LLVM 核心库源代码。关于这一点,我们将在下一节详细介绍。

一旦构建文件生成,LLVM 和 Clang 可以使用以下命令编译:

$ ninja

根据硬件资源的不同,这个命令需要花费 15 分钟(具有大量 CPU 核心和内存以及快速存储的服务器)到几个小时(双核 Windows 笔记本,内存有限)不等。默认情况下,Ninja 利用所有可用的 CPU 核心。这对于编译速度很好,但可能会阻止其他任务运行。例如,在基于 Windows 的笔记本上,几乎不可能在 Ninja 运行时上网冲浪。幸运的是,你可以使用-j选项限制资源使用。

假设你有四个 CPU 核心可用,而 Ninja 只应该使用两个(因为你有并行任务要运行)。在这里,你应该使用以下命令进行编译:

$ ninja –j2

一旦编译完成,最佳实践是运行测试套件,以检查一切是否按预期工作:

$ ninja check-all

这个命令的运行时间因可用的硬件资源而变化很大。Ninja check-all目标运行所有测试用例。为包含测试用例的每个目录生成目标。使用check-llvm而不是check-all运行 LLVM 测试但不运行 Clang 测试;check-llvm-codegen只运行 LLVM 的CodeGen目录中的测试(即llvm/test/CodeGen目录)。

你也可以进行快速手动检查。你将使用的 LLVM 应用程序之一是-version选项,它显示它的 LLVM 版本,它的主机 CPU 以及所有支持的架构:

$ bin/llc -version

如果您在编译 LLVM 时遇到问题,应该查阅Getting Started with the LLVM System文档的Common Problems部分(llvm.org/docs/GettingStarted.html#common-problems)以解决常见问题。

最后,安装二进制文件:

$ ninja install

在类 Unix 系统上,安装目录为/usr/local。在 Windows 上,使用C:\Program Files\LLVM。当然可以更改。下一节将解释如何更改。

自定义构建过程

CMake 系统使用CMakeLists.txt文件中的项目描述。顶层文件位于llvm目录中;即llvm/CMakeLists.txt。其他目录也包含CMakeLists.txt文件,在构建文件生成期间递归包含。

根据项目描述中提供的信息,CMake 检查已安装的编译器,检测库和符号,并创建构建系统文件,例如build.ninjaMakefile(取决于选择的生成器)。还可以定义可重用的模块,例如检测 LLVM 是否已安装的函数。这些脚本放置在特殊的cmake目录(llvm/cmake),在生成过程中会自动搜索。

构建过程可以通过定义 CMake 变量进行自定义。使用-D命令行选项设置变量的值。这些变量在 CMake 脚本中使用。CMake 本身定义的变量几乎总是以CMAKE_为前缀,并且这些变量可以在所有项目中使用。LLVM 定义的变量以LLVM_为前缀,但只能在项目定义中包括 LLVM 使用时使用。

CMake 定义的变量

一些变量使用环境变量的值进行初始化。最显著的是CCCXX,它们定义了用于构建的 C 和 C++编译器。CMake 会尝试自动定位 C 和 C++编译器,使用当前的 shell 搜索路径。它会选择找到的第一个编译器。如果安装了多个编译器,例如 gcc 和 Clang 或不同版本的 Clang,则这可能不是您要用于构建 LLVM 的编译器。

假设您想将clang9用作 C 编译器,将clang++9用作 C++编译器。在 Unix shell 中,可以按以下方式调用 CMake:

$ CC=clang9 CXX=clang++9 cmake ../llvm

这将设置cmake调用时环境变量的值。如果需要,您可以为编译器可执行文件指定绝对路径。

CCCMAKE_C_COMPILER CMake 变量的默认值,而CXXCMAKE_CXX_COMPILER CMake 变量的默认值。您可以直接设置 CMake 变量,而不是使用环境变量。这相当于前面的调用:

$ cmake –DCMAKE_C_COMPILER=clang9\
  -DCMAKE_CXX_COMPILER=clang++9 ../llvm

CMake 定义的其他有用变量如下:

  • CMAKE_INSTALL_PREFIX:安装期间添加到每个路径前面的路径前缀。Unix 上默认为/usr/local,Windows 上为C:\Program Files\<Project>。要在/opt/llvm目录中安装 LLVM,必须指定-DCMAKE_INSTALL_PREFIX=/opt/llvm。二进制文件将被复制到/opt/llvm/bin,库文件将被复制到/opt/llvm/lib,依此类推。

  • CMAKE_BUILD_TYPE:不同类型的构建需要不同的设置。例如,调试构建需要指定生成调试符号的选项,并且通常链接到系统库的调试版本。相比之下,发布构建使用优化标志,并链接到库的生产版本。此变量仅用于只能处理一种构建类型的构建系统,例如 Ninja 或 Make。对于 IDE 构建系统,会生成所有变体,您必须使用 IDE 的机制在构建类型之间切换。一些可能的值如下:

DEBUG:带有调试符号的构建

RELEASE:用于速度优化的构建

RELWITHDEBINFO:带有调试符号的发布版本

MINSIZEREL:针对大小进行优化的构建

默认的构建类型是DEBUG。要为发布构建生成构建文件,必须指定-DCMAKE_BUILD_TYPE=RELEASE

  • CMAKE_C_FLAGSCMAKE_CXX_FLAGS:这些是在编译 C 和 C++源文件时使用的额外标志。初始值取自CFLAGSCXXFLAGS环境变量,可以用作替代。

  • CMAKE_MODULE_PATH:指定要在 CMake 模块中搜索的附加目录。指定的目录将在默认目录之前搜索。该值是一个用分号分隔的目录列表。

  • PYTHON_EXECUTABLE:如果找不到 Python 解释器,或者如果安装了多个版本并选择了错误的版本,则可以将此变量设置为 Python 二进制文件的路径。只有在包含 CMake 的 Python 模块时,此变量才会生效(这是 LLVM 的情况)。

CMake 为变量提供了内置帮助。--help-variable var选项会打印var变量的帮助信息。例如,您可以输入以下内容以获取CMAKE_BUILD_TYPE的帮助:

$ cmake --help-variable CMAKE_BUILD_TYPE

您还可以使用以下命令列出所有变量:

$ cmake --help-variablelist

此列表非常长。您可能希望将输出导入more或类似的程序。

LLVM 定义的变量

LLVM 定义的变量与 CMake 定义的变量的工作方式相同,只是没有内置帮助。最有用的变量如下:

  • LLVM_TARGETS_TO_BUILD:LLVM 支持不同 CPU 架构的代码生成。默认情况下,会构建所有这些目标。使用此变量指定要构建的目标列表,用分号分隔。当前的目标有AArch64AMDGPUARMBPFHexagonLanaiMipsMSP430NVPTXPowerPCRISCVSparcSystemZWebAssemblyX86XCoreall可以用作所有目标的简写。名称区分大小写。要仅启用 PowerPC 和 System Z 目标,必须指定-DLLVM_TARGETS_TO_BUILD="PowerPC;SystemZ"

  • LLVM_ENABLE_PROJECTS:这是要构建的项目列表,用分号分隔。项目的源代码必须与llvm目录处于同一级别(并排布局)。当前列表包括clangclang-tools-extracompiler-rtdebuginfo-testsliblibclclibcxxlibcxxabilibunwindlldlldbllgomliropenmpparallel-libspollypstlall可以用作此列表中所有项目的简写。要与 LLVM 一起构建 Clang 和 llgo,必须指定-DLLVM_ENABLE_PROJECT="clang;llgo"

  • LLVM_ENABLE_ASSERTIONS:如果设置为ON,则启用断言检查。这些检查有助于发现错误,在开发过程中非常有用。对于DEBUG构建,默认值为ON,否则为OFF。要打开断言检查(例如,对于RELEASE构建),必须指定–DLLVM_ENABLE_ASSERTIONS=ON

  • LLVM_ENABLE_EXPENSIVE_CHECKS:这将启用一些可能会显著减慢编译速度或消耗大量内存的昂贵检查。默认值为OFF。要打开这些检查,必须指定-DLLVM_ENABLE_EXPENSIVE_CHECKS=ON

  • LLVM_APPEND_VC_REV:LLVM 工具(如llc)显示它们所基于的 LLVM 版本,以及其他信息(如果提供了--version命令行选项)。此版本信息基于LLVM_REVISION C 宏。默认情况下,版本信息不仅包括 LLVM 版本,还包括最新提交的 Git 哈希。如果您正在跟踪主分支的开发,这很方便,因为它清楚地指出了工具所基于的 Git 提交。如果不需要这个信息,则可以使用–DLLVM_APPEND_VC_REV=OFF关闭。

  • LLVM_ENABLE_THREADS:如果检测到线程库(通常是 pthread 库),LLVM 会自动包含线程支持。此外,在这种情况下,LLVM 假定编译器支持-DLLVM_ENABLE_THREADS=OFF

  • LLVM_ENABLE_EH:LLVM 项目不使用 C++异常处理,因此默认情况下关闭异常支持。此设置可能与您的项目链接的其他库不兼容。如果需要,可以通过指定–DLLVM_ENABLE_EH=ON来启用异常支持。

  • LLVM_ENABLE_RTTI:LVM 使用了一个轻量级的、自建的运行时类型信息系统。默认情况下,生成 C++ RTTI 是关闭的。与异常处理支持一样,这可能与其他库不兼容。要打开 C++ RTTI 的生成,必须指定–DLLVM_ENABLE_RTTI=ON

  • LLVM_ENABLE_WARNINGS:编译 LLVM 应尽可能不生成警告消息。因此,默认情况下打印警告消息的选项是打开的。要关闭它,必须指定–DLLVM_ENABLE_WARNINGS=OFF

  • LLVM_ENABLE_PEDANTIC:LLVM 源代码应符合 C/C++语言标准;因此,默认情况下启用源代码的严格检查。如果可能,还会禁用特定于编译器的扩展。要取消此设置,必须指定–DLLVM_ENABLE_PEDANTIC=OFF

  • LLVM_ENABLE_WERROR:如果设置为ON,则所有警告都被视为错误-一旦发现警告,编译就会中止。它有助于找到源代码中所有剩余的警告。默认情况下,它是关闭的。要打开它,必须指定–DLLVM_ENABLE_WERROR=ON

  • LLVM_OPTIMIZED_TABLEGEN:通常,tablegen 工具与 LLVM 的其他部分使用相同的选项构建。同时,tablegen 用于生成代码生成器的大部分代码。因此,在调试构建中,tablegen 的速度要慢得多,从而显著增加了编译时间。如果将此选项设置为ON,则即使在调试构建中,tablegen 也将使用优化进行编译,可能会减少编译时间。默认为OFF。要打开它,必须指定–DLLVM_OPTIMIZED_TABLEGEN=ON

  • LLVM_USE_SPLIT_DWARF:如果构建编译器是 gcc 或 Clang,则打开此选项将指示编译器将 DWARF 调试信息生成到单独的文件中。对象文件的减小尺寸显著减少了调试构建的链接时间。默认为OFF。要打开它,必须指定-LLVM_USE_SPLIT_DWARF=ON

LLVM 定义了许多更多的 CMake 变量。您可以在 LLVM CMake 文档中找到完整的列表(releases.llvm.org/12.0.0/docs/CMake.html#llvm-specific-variables)。前面的列表只包含您可能需要的变量。

总结

在本章中,您准备好了开发机器来编译 LLVM。您克隆了 LLVM GitHub 存储库,并编译了自己的 LLVM 和 Clang 版本。构建过程可以使用 CMake 变量进行自定义。您还了解了有用的变量以及如何更改它们。掌握了这些知识,您可以根据自己的需求调整 LLVM。

在下一章中,我们将更仔细地查看 LLVM 单一存储库的内容。您将了解其中包含哪些项目以及这些项目的结构。然后,您将使用这些信息来使用 LLVM 库创建自己的项目。最后,您将学习如何为不同的 CPU 架构编译 LLVM。

第二章:LLVM 源代码导览

LLVM 单一存储库包含llvm-project根目录下的所有项目。所有项目都遵循统一的源代码布局。要有效地使用 LLVM,了解可用内容以及其位置是很重要的。在本章中,您将了解以下内容:

  • LLVM 单一存储库的内容,涵盖了最重要的顶级项目

  • LLVM 项目的布局,展示了所有项目使用的通用源代码布局

  • 如何使用 LLVM 库创建自己的项目,涵盖了在自己的项目中使用 LLVM 的所有方式

  • 如何针对不同的 CPU 架构,展示交叉编译到另一个系统所需的步骤

技术要求

本章的代码文件可在github.com/PacktPublishing/Learn-LLVM-12/tree/master/Chapter02/tinylang找到

您可以在bit.ly/3nllhED找到代码演示视频

LLVM 单一存储库的内容

第一章中,安装 LLVM,您克隆了 LLVM 单一存储库。该存储库包含所有 LLVM 顶级项目。它们可以分为以下几类:

  • LLVM 核心库和附加内容

  • 编译器和工具

  • 运行时库

在接下来的章节中,我们将更详细地了解这些组。

LLVM 核心库和附加内容

LLVM 核心库位于llvm目录中。该项目提供了一组为知名 CPU 进行优化和代码生成的库。它还提供了基于这些库的工具。LLVM 静态编译器llc接受 LLVM llvm-objdumpllvm-dwarfdump文件,让您检查目标文件,以及像llvm-ar这样的工具让您从一组目标文件创建存档文件。它还包括帮助开发 LLVM 本身的工具。例如,bugpoint工具有助于找到 LLVM 内部崩溃的最小测试用例。llvm-mc是机器码播放器:该工具汇编和反汇编机器指令,并输出编码,这在添加新指令时非常有帮助。

LLVM 核心库是用 C++编写的。此外,还提供了 C 接口和 Go、Ocaml 和 Python 的绑定。

位于polly目录中的 Polly 项目为 LLVM 增加了另一组优化。它基于一种称为多面体模型的数学表示。采用这种方法,可以进行诸如为缓存局部性优化的循环等复杂优化。

mlir目录。

编译器和工具

LLVM 项目中包含一个名为 clang(clang.llvm.org/)的完整的 C/C++/Objective-C/Object-C++编译器。源代码位于clang目录中。它提供了一组库,用于从 C、C++、Objective-C 和 Objective-C++源文件中进行词法分析、语法分析、语义分析和生成 LLVM IR。小工具clang是基于这些库的编译器驱动程序。另一个有用的工具是clang-format,它可以根据用户提供的规则格式化 C/C++源文件和源代码片段。

Clang 旨在与 GCC(GNU C/C++编译器)和 CL(Microsoft C/C++编译器)兼容。

clang-tools-extra项目提供了 C/C++的其他工具,位于同名目录中。其中最值得注意的是clang-tidy,它是用于 C/C++的 Lint 风格检查器。clang-tidy使用 clang 库解析源代码并进行静态分析。该工具可以捕获比编译器更多的潜在错误,但运行时开销更大。

Llgo 是 Go 编程语言的编译器,位于llgo目录中。它是用 Go 编写的,并使用 LLVM 核心库的 Go 绑定与 LLVM 进行接口。Llgo 旨在与参考编译器(https://golang.org/)兼容,但目前唯一支持的目标是 64 位 x86 Linux。该项目似乎没有维护,并可能在将来被移除。

编译器创建的目标文件必须与运行时库链接在一起形成可执行文件。这是lldlld.llvm.org/)的工作,LLVM 链接器位于lld目录中。该链接器支持 ELF、COFF、Mach-O 和 WebAssembly 格式。

没有调试器的编译器工具集是不完整的!LLVM 调试器称为lldblldb.llvm.org/),位于同名目录中。其界面类似于 GDB,GNU 调试器,并且该工具可以直接支持 C、C++和 Objective-C。调试器是可扩展的,因此可以轻松添加对其他编程语言的支持。

运行时库

除了编译器,完整的编程语言支持还需要运行时库。所有列出的项目都位于顶级目录中,与同名目录中的目录相同:

  • compiler-rt项目提供了与编程语言无关的支持库。它包括通用函数,例如 32 位 i386 的 64 位除法,各种消毒剂,模糊库和分析库。

  • libunwind库基于 DWARF 标准提供了用于堆栈展开的辅助函数。这通常用于实现诸如 C++之类的语言的异常处理。该库是用 C 编写的,函数与特定的异常处理模型无关。

  • libcxxabi库在libunwind的基础上实现了 C++异常处理,并为其提供了标准的 C++函数。

  • 最后,libcxx是 C++标准库的实现,包括 iostreams 和 STL。此外,pstl项目提供了 STL 算法的并行版本。

  • libclc是 OpenCL 的运行时库。OpenCL 是用于异构并行计算的标准,有助于将计算任务移动到图形卡上。

  • libc旨在提供完整的 C 库。该项目仍处于早期阶段。

  • openmp项目提供了对 OpenMP API 的支持。OpenMP 有助于多线程编程,并且可以根据源代码中的注释来并行化循环。

尽管这是一个很长的项目列表,但好消息是所有项目的结构都类似。我们将在下一节中查看通用目录布局。

LLVM 项目的布局

所有 LLVM 项目都遵循相同的目录布局理念。为了理解这个理念,让我们将 LLVM 与GCCGNU 编译器集进行比较。几十年来,GCC 为几乎您能想象到的每个系统提供了成熟的编译器。但是,除了编译器,没有利用代码的工具。原因是它不是为重用而设计的。这与 LLVM 不同。

每个功能都有明确定义的 API,并放在自己的库中。clang 项目(除其他外)有一个库,用于将 C/C++源文件词法分析为标记流。解析器库将此标记流转换为抽象语法树(也由库支持)。语义分析、代码生成甚至编译器驱动程序都作为库提供。著名的clang工具只是针对这些库链接的一个小应用程序。

优势是显而易见的:当您想要构建一个需要 C++文件的抽象语法树AST)的工具时,您可以重用这些库的功能来构建 AST。不需要语义分析和代码生成,也不需要链接到这些库。这个原则被所有 LLVM 项目遵循,包括核心库!

每个项目都有类似的组织结构。因为 CMake 用于构建文件生成,每个项目都有一个CMakeLists.txt文件,描述了项目的构建过程。如果需要额外的 CMake 模块或支持文件,则它们存储在cmake子目录中,模块放置在cmake/modules中。

库和工具大多是用 C++编写的。源文件放在lib目录下,头文件放在include目录下。因为一个项目通常由多个库组成,在lib目录中为每个库都有一个目录。如果需要,这个过程会重复。例如,在llvm/lib目录中有Target目录,其中包含特定目标的降低代码。除了一些源文件外,每个目标都有一个子目录,这些子目录再次编译成库。每个目录都有一个CMakeLists.txt文件,描述了如何构建库以及哪些子目录还包含源代码。

include目录有一个额外的级别。为了使包含文件的名称唯一,路径名包括项目名称,这是include下的第一个子目录。只有在这个文件夹中,才会重复来自lib目录的结构。

应用程序的源代码位于toolsutils目录中。utils目录中是在编译或测试期间使用的内部应用程序。它们通常不是用户安装的一部分。tools目录包含面向最终用户的应用程序。在这两个目录中,每个应用程序都有自己的子目录。与lib目录一样,每个包含源代码的子目录都有一个CMakeLists.txt文件。

正确的代码生成对于编译器是必不可少的。这只能通过一个良好的测试套件来实现。unittest目录包含使用Google Test框架的单元测试。这主要用于单个函数和无法以其他方式进行测试的独立功能。test目录中是 LIT 测试。这些测试使用llvm-lit实用程序来执行测试。llvm-lit扫描文件以执行 shell 命令。文件包含用作测试输入的源代码,例如 LLVM IR。文件中嵌入了由llvm-lit执行的编译命令。然后验证此步骤的输出,通常借助FileCheck实用程序的帮助。这个实用程序从一个文件中读取检查语句,并将它们与另一个文件进行匹配。LIT 测试本身位于test目录下的子目录中,大致遵循lib目录的结构。

文档(通常作为docs目录。如果项目提供示例,则它们在examples目录中。

根据项目的需求,也可以有其他目录。最值得注意的是,一些提供运行时库的项目将源代码放在src目录中,并使用lib目录进行库导出定义。compiler-rt 和 libclc 项目包含与体系结构相关的代码。这总是放在以目标体系结构命名的子目录中(例如i386ptx)。

总之,提供示例库并具有驱动程序工具的项目的一般布局如下:

图 2.1-一般项目目录布局

图 2.1-一般项目目录布局

我们自己的项目也将遵循这种组织结构。

使用 LLVM 库创建您自己的项目

根据前一节的信息,现在可以使用 LLVM 库创建自己的项目。以下部分介绍了一个名为tinylang的小语言。在这里定义了这样一个项目的结构。尽管本节中的工具只是一个Hello, world应用程序,但其结构具有实现真实编译器所需的所有部分。

创建目录结构

第一个问题是是否应该将tinylang项目与 LLVM 一起构建(如 clang),还是应该是一个独立的项目,只是使用 LLVM 库。在前一种情况下,还需要决定在哪里创建项目。

首先假设tinylang应与 LLVM 一起构建。有不同的选项可供放置项目。第一个解决方案是在llvm-projects目录内创建项目的子目录。此目录中的所有项目都将作为构建 LLVM 的一部分进行捕获和构建。在创建并排项目布局之前,这是构建例如 clang 的标准方式。

第二个选项是将tinylang项目放在顶级目录中。因为它不是官方的 LLVM 项目,所以 CMake 脚本不知道它。在运行cmake时,您需要指定–DLLVM_ENABLE_PROJECTS=tinylang以将项目包含在构建中。

第三个选项是将项目目录放在llvm-project目录之外的其他位置。当然,您需要告诉 CMake 这个位置。例如,如果位置是/src/tinylang,则需要指定–DLLVM_ENABLE_PROJECTS=tinylang –DLLVM_EXTERNAL_TINYLANG_SOURCE_DIR=/src/tinylang

如果要将项目构建为独立项目,则需要找到 LLVM 库。这是在稍后讨论的CMakeLists.txt文件中完成的。

在了解可能的选项之后,哪一个是最好的?将您的项目作为 LLVM 源树的一部分是有点不灵活的,因为大小。只要您不打算将项目添加到顶级项目列表中,我建议使用单独的目录。您可以在 GitHub 或类似服务上维护您的项目,而不必担心如何与 LLVM 项目同步。并且如前所示,您仍然可以与其他 LLVM 项目一起构建。

让我们创建一个非常简单的库和应用程序的项目。第一步是创建目录布局。选择一个对您方便的位置。在接下来的步骤中,我假设它与您克隆llvm-project目录的相同目录中。使用mkdir(Unix)或md(Windows)创建以下目录:

图 2.2- 项目所需的目录

图 2.2- 项目所需的目录

接下来,我们将在这些目录中放置构建描述和源文件。

添加 CMake 文件

您应该从上一节中认识到基本结构。在tinylang目录中,创建一个名为CMakeLists.txt的文件,并执行以下步骤:

  1. 文件开始时调用cmake_minimum_required()来声明所需的 CMake 的最小版本。这与第一章中的版本相同,安装 LLVM
Cmake_minimum_required(VERSION 3.13.4)
  1. 下一个语句是if()。如果条件为真,则将构建项目,并且需要一些额外的设置。条件使用两个变量,CMAKE_SOURCE_DIRCMAKE_CURRENT_SOURCE_DIRCMAKE_SOURCE_DIR变量是在cmake命令行上给出的顶层源目录。正如我们在关于目录布局的讨论中看到的,每个具有源文件的目录都有一个CMakeLists.txt文件。CMake 当前处理的CMakeLists.txt文件的目录记录在CMAKE_CURRENT_SOURCE_DIR变量中。如果两个变量具有相同的字符串值,则将构建项目。否则,CMAKE_SOURCE_DIR将是llvm目录:
if(CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR)

独立设置很简单。每个 CMake 项目都需要一个名称。在这里,我们将其设置为Tinylang

  project(Tinylang)
  1. LLVM 软件包已被搜索,找到的 LLVM 目录被添加到 CMake 模块路径中:
  find_package(LLVM REQUIRED HINTS     "${LLVM_CMAKE_PATH}")
  list(APPEND CMAKE_MODULE_PATH ${LLVM_DIR})
  1. 然后,包含了 LLVM 提供的另外三个 CMake 模块。第一个仅在使用 Visual Studio 作为构建编译器时需要,并设置正确的运行时库以进行链接。另外两个模块添加了 LLVM 使用的宏,并根据提供的选项配置了构建:
  include(ChooseMSVCCRT)
  include(AddLLVM)
  include(HandleLLVMOptions)
  1. 接下来,LLVM 的头文件路径被添加到包含搜索路径中。添加了两个目录。从构建目录中添加了include目录,因为自动生成的文件保存在这里。另一个include目录是源目录内的目录:
  include_directories("${LLVM_BINARY_DIR}/include"                      "${LLVM_INCLUDE_DIR}")
  1. 使用link_directories(),将 LLVM 库的路径添加到链接器中:
  link_directories("${LLVM_LIBRARY_DIR}")
  1. 最后,设置一个标志以表示项目是独立构建的:
  set(TINYLANG_BUILT_STANDALONE 1)
endif()
  1. 现在进行常见的设置。将cmake/modules目录添加到 CMake 模块搜索路径中。这样可以稍后添加我们自己的 CMake 模块:
list(APPEND CMAKE_MODULE_PATH   "${CMAKE_CURRENT_SOURCE_DIR}/cmake/modules")
  1. 接下来,我们检查用户是否正在进行外部构建。与 LLVM 一样,我们要求用户为构建项目使用单独的目录:
if(CMAKE_SOURCE_DIR STREQUAL CMAKE_BINARY_DIR AND NOT     MSVC_IDE)
  message(FATAL_ERROR "In-source builds are not     allowed.")
endif()
  1. tinylang的版本号被写入一个生成的文件中,使用configure_file()命令。版本号取自TINYLANG_VERSION_STRING变量。configure_file()命令读取一个输入文件,用当前值替换 CMake 变量,并写入一个输出文件。请注意,输入文件是从源目录读取的,并写入构建目录:
set(TINYLANG_VERSION_STRING "0.1")
configure_file(${CMAKE_CURRENT_SOURCE_DIR}/include/tinylang/Basic/Version.inc.in
  ${CMAKE_CURRENT_BINARY_DIR}/include/tinylang/Basic/Version.inc)
  1. 接下来,包含另一个 CMake 模块。AddTinylang模块具有一些辅助功能:
include(AddTinylang)
  1. 接下来是另一个include_directories()语句。这将我们自己的include目录添加到搜索路径的开头。与独立构建一样,添加了两个目录:
include_directories(BEFORE
  ${CMAKE_CURRENT_BINARY_DIR}/include
  ${CMAKE_CURRENT_SOURCE_DIR}/include
  )
  1. 在文件末尾,将libtools目录声明为 CMake 查找CMakeLists.txt文件的其他目录。这是连接目录的基本机制。此示例应用程序只在libtools目录下有源文件,因此不需要其他内容。更复杂的项目将添加更多目录,例如用于单元测试的目录:
add_subdirectory(lib)
add_subdirectory(tools)

这是您项目的主要描述。

AddTinylang.cmake辅助模块放置在cmake/modules目录中。它具有以下内容:

macro(add_tinylang_subdirectory name)
  add_llvm_subdirectory(TINYLANG TOOL ${name})
endmacro()
macro(add_tinylang_library name)
  if(BUILD_SHARED_LIBS)
    set(LIBTYPE SHARED)
  else()
    set(LIBTYPE STATIC)
  endif()
  llvm_add_library(${name} ${LIBTYPE} ${ARGN})
  if(TARGET ${name})
    target_link_libraries(${name} INTERFACE 
      ${LLVM_COMMON_LIBS})
    install(TARGETS ${name}
      COMPONENT ${name}
      LIBRARY DESTINATION lib${LLVM_LIBDIR_SUFFIX}
      ARCHIVE DESTINATION lib${LLVM_LIBDIR_SUFFIX}
      RUNTIME DESTINATION bin)
  else()
    add_custom_target(${name})
  endif()
endmacro()
macro(add_tinylang_executable name)
  add_llvm_executable(${name} ${ARGN} )
endmacro()
macro(add_tinylang_tool name)
  add_tinylang_executable(${name} ${ARGN})
  install(TARGETS ${name}
    RUNTIME DESTINATION bin
    COMPONENT ${name})
endmacro()

随着模块的包含,add_tinylang_subdirectory()add_tinylang_library()add_tinylang_executable()add_tinylang_tool()函数可供使用。基本上,这些函数是 LLVM 提供的等效函数(在AddLLVM模块中)的包装器。add_tinylang_subdirectory()添加一个新的源目录以便在构建中包含。此外,还添加了一个新的 CMake 选项。通过此选项,用户可以控制是否应该编译该目录的内容。使用add_tinylang_library()定义一个也被安装的库。add_tinylang_executable()定义一个可执行文件,add_tinylang_tool()定义一个也被安装的可执行文件。

lib目录中,即使没有源文件,也需要一个CMakeLists.txt文件。它必须包括该项目库的源目录。打开您喜欢的文本编辑器,并将以下内容保存到文件中:

add_subdirectory(Basic)

一个大型项目会创建多个库,并且源文件会放在lib的子目录中。每个这些目录都必须在CMakeLists.txt文件中添加。我们的小项目只有一个名为Basic的库,所以只需要一行。

Basic库只有一个源文件Version.cpp。该目录中的CMakeLists.txt文件同样简单:

add_tinylang_library(tinylangBasic
  Version.cpp
  )

定义了一个名为tinylangBasic的新库,并将编译的Version.cpp添加到该库中。LLVM 选项控制这是一个共享库还是静态库。默认情况下,将创建一个静态库。

tools目录中重复相同的步骤。该文件夹中的CMakeLists.txt文件几乎与lib目录中的一样简单:

create_subdirectory_options(TINYLANG TOOL)
add_tinylang_subdirectory(driver)

首先,定义一个 CMake 选项,用于控制是否编译此目录的内容。然后添加唯一的子目录driver,这次使用我们自己模块的函数。同样,这使我们能够控制是否包括此目录在编译中。

driver目录包含应用程序Driver.cpp的源代码。此目录中的CMakeLists.txt文件包含编译和链接此应用程序的所有步骤:

set(LLVM_LINK_COMPONENTS
  Support
  )
add_tinylang_tool(tinylang
  Driver.cpp
  )
target_link_libraries(tinylang
  PRIVATE
  tinylangBasic
  )

首先,将LLVM_LINK_COMPONENTS变量设置为我们需要将我们的工具链接到的 LLVM 组件列表。LLVM 组件是一个或多个库的集合。显然,这取决于工具的实现功能。在这里,我们只需要Support组件。

使用add_tinylang_tool()定义一个新的可安装应用程序。名称为tinylang,唯一的源文件是Driver.cpp。要链接到我们自己的库,必须使用target_link_libraries()指定它们。这里只需要tinylangBasic

现在,CMake 系统所需的文件已经就位。接下来,我们将添加源文件。

添加 C++源文件

让我们从include/tinylang/Basic目录开始。首先,创建Version.inc.in模板文件,其中包含配置的版本号:

#define TINYLANG_VERSION_STRING "@TINYLANG_VERSION_STRING@"

@符号表示TINYLANG_VERSION_STRING是一个 CMake 变量,应该用其内容替换。

Version.h头文件只声明一个函数来检索版本字符串:

#ifndef TINYLANG_BASIC_VERSION_H
#define TINYLANG_BASIC_VERSION_H
#include "tinylang/Basic/Version.inc"
#include <string>
namespace tinylang {
std::string getTinylangVersion();
}
#endif

此函数的实现在lib/Basic/Version.cpp文件中。它同样简单:

#include "tinylang/Basic/Version.h"
std::string tinylang::getTinylangVersion() {
  return TINYLANG_VERSION_STRING;
}

最后,在tools/driver/Driver.cpp文件中有应用程序源代码:

#include "llvm/Support/InitLLVM.h"
#include "llvm/Support/raw_ostream.h"
#include "tinylang/Basic/Version.h"
int main(int argc_, const char **argv_) {
  llvm::InitLLVM X(argc_, argv_);
  llvm::outs() << "Hello, I am Tinylang "               << tinylang::getTinylangVersion()
               << "\n";
}

尽管只是一个友好的工具,但源代码使用了典型的 LLVM 功能。llvm::InitLLVM()调用进行了一些基本的初始化。在 Windows 上,参数被转换为 Unicode,以便统一处理命令行解析。并且在应用程序崩溃的情况下(希望不太可能发生),会安装一个漂亮的打印堆栈跟踪处理程序。它输出调用层次结构,从发生崩溃的函数开始。要查看真实的函数名称而不是十六进制地址,必须存在调试符号。

LLVM 不使用 C++标准库的iostream类。它带有自己的实现。llvm::outs()是输出流,在这里用于向用户发送友好的消息。

编译 tinylang 应用程序

现在,第一个应用程序的所有文件都就位,可以编译该应用程序。简而言之,您应该有以下目录和文件:

图 2.3 - tinylang 项目的所有目录和文件

图 2.3 - tinylang 项目的所有目录和文件

如前所述,有几种构建tinylang的方法。以下是如何将tinylang作为 LLVM 的一部分构建:

  1. 使用以下命令切换到构建目录:
$ cd build
  1. 然后,按以下方式运行 CMake:
-G Ninja). The build type is set to Release, thus producing optimized binaries (-DCMAKE_BUILD_TYPE=Release). Tinylang is built as an external project alongside LLVM (-DLLVM_EXTERNAL_PROJECTS=tinylang) and the source is found in a directory parallel to the build directory (-DLLVM_EXTERNAL_TINYLANG_SOURCE_DIR=../tinylang). A target directory for the build binaries is also given (-DCMAKE_INSTALL_PREFIX=../llvm-12). As the last parameter, the path of the LLVM project directory is specified (../llvm-project/llvm).
  1. 现在,构建并安装所有内容:
$ ninja
$ ninja install
  1. 构建和安装后,../llvm-12目录包含 LLVM 和tinylang二进制文件。请检查您是否可以运行该应用程序:
$ ../llvm-12/bin/tinylang
  1. 您应该看到友好的消息。还请检查是否安装了 Basic 库:
libtinylangBasic.a file.

与 LLVM 一起构建在您密切关注 LLVM 开发并希望尽快了解 API 更改时非常有用。在第一章中,安装 LLVM,我们检出了 LLVM 的特定版本。因此,我们看不到 LLVM 源代码的任何更改。

在这种情况下,构建 LLVM 一次并使用编译版本的 LLVM 编译tinylang作为独立项目是有意义的。以下是如何做到这一点:

  1. 重新开始,进入build目录:
$ cd build

这次,只使用 CMake 构建 LLVM:

$ cmake -G Ninja -DCMAKE_BUILD_TYPE=Release \
  -DCMAKE_INSTALL_PREFIX=../llvm-12 \
  ../llvm-project/llvm
  1. 将此与前面的 CMake 命令进行比较:缺少指向tinylang的参数;其他一切都是相同的。

  2. 使用 Ninja 构建和安装 LLVM:

$ ninja
$ ninja install
  1. 现在您在llvm-12目录中安装了 LLVM。接下来,将构建tinylang项目。由于它是一个独立的构建,需要一个新的build目录。保留 LLVM 构建目录如下:
$ cd ..
  1. 现在创建一个新的build-tinylang目录。在 Unix 上,您使用以下命令:
$ mkdir build-tinylang

在 Windows 上,您将使用以下命令:

$ md build-tinylang
  1. 使用以下命令进入新目录,无论是在哪个操作系统上:
$ cd build-tinylang
  1. 现在运行 CMake 为tinylang创建构建文件。唯一的特殊之处在于如何发现 LLVM,因为 CMake 不知道我们安装 LLVM 的位置。解决方案是使用LLVMConfig.cmake文件的路径来指定LLVM_DIR变量。命令如下:
$ cmake -G Ninja -DCMAKE_BUILD_TYPE=Release \
  -DLLVM_DIR=../llvm-12/lib/cmake/llvm \
  -DCMAKE_INSTALL_PREFIX=../tinylang ../tinylang/
  1. 安装目录现在也是分开的。像往常一样,使用以下命令构建和安装:
$ ninja
$ ninja install
  1. 命令完成后,您应该运行../tinylang/bin/tinylang应用程序,以检查应用程序是否正常工作。

包含 LLVM 的另一种方法

如果您不想为您的项目使用 CMake,那么您需要找出包含文件和库的位置,链接的库,使用了哪种构建模式等等。这些信息由llvm-config工具提供,该工具位于 LLVM 安装的bin目录中。假设该目录包含在您的 shell 搜索路径中,您运行$ llvm-config来查看所有选项。

例如,要使 LLVM 库链接到support组件(在前面的示例中使用),您运行以下命令:

$ llvm-config –libs support

输出是一行库名称,包括编译器的链接选项,例如-lLLVMSupport –lLLVMDemangle。显然,这个工具可以很容易地与您选择的构建系统集成。

使用本节中显示的项目布局,您拥有一个适用于大型项目(如编译器)的结构。下一节奠定了另一个基础:如何为不同的目标架构进行交叉编译。

针对不同的 CPU 架构

今天,许多小型计算机,如树莓派,正在使用,并且资源有限。在这样的计算机上运行编译器通常是不可能的,或者运行时间太长。因此,编译器的一个常见要求是为不同的 CPU 架构生成代码。创建可执行文件的整个过程称为交叉编译。在上一节中,您创建了一个基于 LLVM 库的小型示例应用程序。现在我们将采用这个应用程序,并为不同的目标进行编译。

在交叉编译中,涉及两个系统:编译器在主机系统上运行,并为目标系统生成代码。为了表示这些系统,所谓的x86_64-pc-win32用于运行在 64 位 X86 CPU 上的 Windows 系统。CPU 架构是x86_64pc是一个通用的供应商,win32是操作系统。这些部分由连字符连接。在 ARMv8 CPU 上运行 Linux 系统使用aarch64-unknown-linux-gnu作为三重。aarch64是 CPU 架构。操作系统是linux,运行gnu环境。对于基于 Linux 的系统,没有真正的供应商,因此这一部分是unknown。对于特定目的未知或不重要的部分通常被省略:三重aarch64-linux-gnu描述了相同的 Linux 系统。

假设您的开发机器在 X86 64 位 CPU 上运行 Linux,并且您希望交叉编译到运行 Linux 的 ARMv8 CPU 系统。主机三重是x86_64-linux-gnu,目标三重是aarch64-linux-gnu。不同的系统具有不同的特征。您的应用程序必须以可移植的方式编写,否则您将会受到失败的惊吓。常见的陷阱如下:

  • 字节序:存储在内存中的多字节值的顺序可能不同。

  • int可能不足以容纳指针。

  • long double可以使用 64 位(ARM)、80 位(X86)或 128 位(ARMv8)。PowerPC 系统可能使用long double的双倍精度算术,通过使用两个 64 位double值的组合来获得更高的精度。

如果你不注意这些要点,那么你的应用程序在目标平台上可能会表现出令人惊讶的行为,甚至在你的主机系统上运行完美。LLVM 库在不同平台上进行了测试,也包含了对上述问题的可移植解决方案。

进行交叉编译,你需要以下工具:

  • 为目标生成代码的编译器

  • 一个能够为目标生成二进制文件的链接器

  • 目标的头文件和库

Ubuntu 和 Debian 发行版有支持交叉编译的软件包。在下面的设置中,我们利用了这一点。gccg++编译器,ld链接器和库都可以作为预编译的二进制文件,生成 ARMv8 代码和可执行文件。要安装所有这些软件包,输入以下命令:

$ sudo apt install gcc-8-aarch64-linux-gnu \
  g++-8-aarch64-linux-gnu binutils-aarch64-linux-gnu \
  libstdc++-8-dev-arm64-cross

新文件安装在/usr/aarch64-linux-gnu目录下。这个目录是目标系统的(逻辑)根目录。它包含通常的binlibinclude目录。交叉编译器(aarch64-linux-gnu-gcc-8aarch64-linux-gnu-g++-8)知道这个目录。

在其他系统上进行交叉编译

如果你的发行版没有所需的工具链,那么你可以从源代码构建它。gcc 和 g++编译器必须配置为为目标系统生成代码,binutils 工具需要处理目标系统的文件。此外,C 和 C++库需要使用这个工具链进行编译。这些步骤因使用的操作系统和主机和目标架构而异。在网上,你可以找到指令,如果你搜索gcc 交叉编译<架构>

准备工作完成后,你几乎可以开始交叉编译示例应用程序(包括 LLVM 库),只是还有一个小细节。LLVM 使用第一章中构建的llvm-tblgen,或者你可以只编译这个工具。假设你在包含 GitHub 存储库克隆的目录中,输入以下命令:

$ mkdir build-host
$ cd build-host
$ cmake -G Ninja \
  -DLLVM_TARGETS_TO_BUILD="X86" \
  -DLLVM_ENABLE_ASSERTIONS=ON \
  -DCMAKE_BUILD_TYPE=Release \
  ../llvm-project/llvm
$ ninja llvm-tblgen
$ cd ..

这些步骤现在应该很熟悉了。创建一个构建目录并进入。CMake 命令只为 X86 目标创建 LLVM 构建文件。为了节省空间和时间,进行了一个发布构建,但启用了断言以捕获可能的错误。只有llvm-tblgen工具是用 Ninja 编译的。

有了llvm-tblgen工具,现在你可以开始交叉编译了。CMake 命令行非常长,所以你可能想把命令存储在一个脚本文件中。与以前的构建不同的是,需要提供更多的信息:

$ mkdir build-target
$ cd build-target
$ cmake -G Ninja \
  -DCMAKE_CROSSCOMPILING=True \
  -DLLVM_TABLEGEN=../build-host/bin/llvm-tblgen \
  -DLLVM_DEFAULT_TARGET_TRIPLE=aarch64-linux-gnu \
  -DLLVM_TARGET_ARCH=AArch64 \
  -DLLVM_TARGETS_TO_BUILD=AArch64 \
  -DLLVM_ENABLE_ASSERTIONS=ON \
  -DLLVM_EXTERNAL_PROJECTS=tinylang \
  -DLLVM_EXTERNAL_TINYLANG_SOURCE_DIR=../tinylang \
  -DCMAKE_INSTALL_PREFIX=../target-tinylang \
  -DCMAKE_BUILD_TYPE=Release \
  -DCMAKE_C_COMPILER=aarch64-linux-gnu-gcc-8 \
  -DCMAKE_CXX_COMPILER=aarch64-linux-gnu-g++-8 \
  ../llvm-project/llvm
$ ninja

再次创建一个构建目录并进入。一些 CMake 参数以前没有使用过,需要一些解释:

  • CMAKE_CROSSCOMPILING设置为ON告诉 CMake 我们正在进行交叉编译。

  • LLVM_TABLEGEN指定要使用的llvm-tblgen工具的路径。这是之前构建的那个。

  • LLVM_DEFAULT_TARGET_TRIPLE是目标架构的三元组。

  • LLVM_TARGET_ARCH用于即时JIT)代码生成。它默认为主机的架构。对于交叉编译,这必须设置为目标架构。

  • LLVM_TARGETS_TO_BUILD是 LLVM 应该包括代码生成器的目标列表。列表至少应该包括目标架构。

  • CMAKE_C_COMPILERCMAKE_CXX_COMPILER指定用于构建的 C 和 C++编译器。交叉编译器的二进制文件以目标三元组为前缀,并且 CMake 不会自动找到它们。

使用其他参数,请求启用断言的发布构建,并将我们的 tinylang 应用程序作为 LLVM 的一部分构建(如前一节所示)。编译过程完成后,您可以使用 file 命令检查您是否真的为 ARMv8 创建了一个二进制文件。运行 $ file bin/tinylang 并检查输出是否表明它是针对 ARM aarch64 架构的 ELF 64 位对象。

使用 clang 进行交叉编译

由于 LLVM 为不同的架构生成代码,使用 clang 进行交叉编译似乎是显而易见的。这里的障碍是 LLVM 并未提供所有所需的部分;例如,缺少 C 库。因此,您必须使用 LLVM 和 GNU 工具的混合,并且作为结果,您需要向 CMake 提供更多关于您正在使用的环境的信息。至少,您需要为 clang 和 clang++ 指定以下选项:--target=<target-triple>(启用为不同目标生成代码)、--sysroot=<path>(目标根目录的路径;参见前文)、I(头文件的搜索路径)和 –L(库的搜索路径)。在 CMake 运行期间,将编译一个小应用程序,如果您的设置有问题,CMake 将会报错。这一步足以检查您是否有一个可用的环境。常见问题包括选择错误的头文件、由于不同的库名称导致的链接失败,以及错误的搜索路径。

交叉编译非常复杂。有了本节的说明,您将能够为您选择的目标架构交叉编译您的应用程序。

总结

在本章中,您了解了 LLVM 仓库中的项目以及常用的布局。您为自己的小应用程序复制了这个结构,为更复杂的应用程序奠定了基础。作为编译器构建的至高学科,您还学会了如何为另一个目标架构交叉编译您的应用程序。

在下一章中,将概述示例语言 tinylang。您将了解编译器必须执行的任务以及 LLVM 库支持的位置。

第三章:编译器的结构

编译器技术是计算机科学中一个深入研究的领域。它的高级任务是将源语言翻译成机器码。通常,这个任务分为两部分:前端和后端。前端主要处理源语言,而后端负责生成机器码。

在本章中,我们将涵盖以下主题:

  • 编译器的构建模块,您将了解到编译器中通常找到的组件。

  • 算术表达式语言,将为您介绍一个示例语言。您将学习语法如何用于定义语言。

  • 词法分析,将讨论如何为语言实现词法分析器。

  • 语法分析,涵盖如何从语法构建解析器。

  • 语义分析,您将学习如何实现语义检查。

  • 使用 LLVM 后端进行代码生成,将讨论如何与 LLVM 后端进行接口,以及如何将所有阶段连接在一起创建完整的编译器。

技术要求

本章的代码文件可在以下链接找到:github.com/PacktPublishing/Learn-LLVM-12/tree/master/Chapter03/calc

您可以在以下链接找到代码的操作视频:bit.ly/3nllhED

编译器的构建模块

自从上个世纪中期计算机问世以来,很快就显而易见,比汇编语言更抽象的语言对编程是有用的。早在 1957 年,Fortran 就是第一种可用的高级编程语言。从那时起,成千上万种编程语言被开发出来。事实证明,所有编译器都必须解决相同的任务,并且编译器的实现最好根据这些任务进行结构化。

在最高级别上,编译器由两部分组成:前端和后端。前端负责特定于语言的任务。它读取源文件并计算其语义分析表示,通常是带注释的抽象语法树AST)。后端从前端的结果创建优化的机器码。前端和后端之间有区分的动机是可重用性。假设前端和后端之间的接口定义良好。在这里,您可以将 C 和 Modula-2 前端连接到相同的后端。或者,如果您有一个用于 X86 的后端和一个用于 Sparc 的后端,那么您可以将 C++前端连接到两者。

前端和后端有特定的结构。前端通常执行以下任务:

  1. 词法分析器读取源文件并生成标记流。

  2. 解析器从标记流创建 AST。

  3. 语义分析器向 AST 添加语义信息。

  4. 代码生成器从 AST 生成中间表示IR)。

中间表示是后端的接口。后端执行以下任务:

  1. 后端对 IR 进行与目标无关的优化。

  2. 然后,它为 IR 代码选择指令。

  3. 然后,它对指令执行与目标相关的优化。

  4. 最后,它会发出汇编代码或目标文件。

当然,这些说明仅在概念层面上。实现方式各不相同。LLVM 核心库定义了一个中间表示作为后端的标准接口。其他工具可以使用带注释的 AST。C 预处理器是一种独立的语言。它可以作为一个独立的应用程序实现,输出预处理的 C 源代码,或者作为词法分析器和解析器之间的附加组件。在某些情况下,AST 不必显式构造。如果要实现的语言不太复杂,那么将解析器和语义分析器结合起来,然后在解析过程中生成代码是一种常见的方法。即使程序设计语言的特定实现没有明确命名这些组件,也要记住这些任务仍然必须完成。

在接下来的章节中,我们将为一个表达式语言构建一个编译器,该编译器可以从输入中生成 LLVM IR。LLVM 静态编译器llc代表后端,然后可以用于将 IR 编译成目标代码。一切都始于定义语言。

算术表达式语言

算术表达式是每种编程语言的一部分。这里有一个名为calc的算术表达式计算语言的示例。calc 表达式被编译成一个应用程序,用于计算以下表达式:

with a, b: a * (4 + b)

表达式中使用的变量必须使用with关键字声明。这个程序被编译成一个应用程序,该应用程序要求用户输入ab变量的值,并打印结果。

示例总是受欢迎的,但作为编译器编写者,你需要比这更彻底的规范来进行实现和测试。编程语言的语法的载体是其语法。

用于指定编程语言语法的形式化方法

语言的元素,如关键字、标识符、字符串、数字和运算符,被称为标记。从这个意义上说,程序是一系列标记的序列,语法规定了哪些序列是有效的。

通常,语法是用扩展的巴科斯-瑙尔范式(EBNF)编写的。语法的一个规则是它有左侧和右侧。左侧只是一个称为非终结符的单个符号。规则的右侧由非终结符、标记和用于替代和重复的元符号组成。让我们来看看 calc 语言的语法:

calc : ("with" ident ("," ident)* ":")? expr ;
expr : term (( "+" | "-" ) term)* ;
term : factor (( "*" | "/") factor)* ;
factor : ident | number | "(" expr ")" ;
ident : ([a-zAZ])+ ;
number : ([0-9])+ ;

在第一行中,calc是一个非终结符。如果没有另外说明,那么语法的第一个非终结符是起始符号。冒号:是规则左侧和右侧的分隔符。"with",":"是代表这个字符串的标记。括号用于分组。一个组可以是可选的或重复的。括号后面的问号?表示一个可选组。星号*表示零次或多次重复,加号+表示一次或多次重复。identexpr是非终结符。对于每一个,都存在另一个规则。分号;标记了规则的结束。第二行中的竖线|表示一个替代。最后,最后两行中的方括号[]表示一个字符类。有效的字符写在方括号内。例如,[a-zA-Z]字符类匹配大写或小写字母,([a-zA-Z])+匹配一个或多个这些字母。这对应于一个正则表达式。

语法如何帮助编译器编写者

这样的语法可能看起来像一个理论上的玩具,但对于编译器编写者来说是有价值的。首先,定义了所有的标记,这是创建词法分析器所需的。语法的规则可以被转换成解析器。当然,如果对解析器是否正确工作有疑问,那么语法就是一个很好的规范。

然而,语法并没有定义编程语言的所有方面。语法的含义 - 语义 - 也必须被定义。为此目的开发了形式化方法,但通常是以纯文本的方式指定的,类似于语言首次引入时的情况。

掌握了这些知识,接下来的两节将向您展示词法分析如何将输入转换为标记序列,以及如何在 C++中对语法进行编码以进行语法分析。

词法分析

正如我们在上一节的示例中看到的,编程语言由许多元素组成,如关键字、标识符、数字、运算符等。词法分析的任务是接受文本输入并从中创建一个标记序列。calc 语言由with:+-*/()标记以及([a-zA-Z])+(标识符)和([0-9])+(数字)正则表达式组成。我们为每个标记分配一个唯一的数字,以便更容易地处理它们。

手写词法分析器

词法分析器的实现通常称为Lexer。让我们创建一个名为Lexer.h的头文件,并开始定义Token。它以通常的头文件保护和所需的头文件开始:

#ifndef LEXER_H
#define LEXER_H
#include "llvm/ADT/StringRef.h"
#include "llvm/Support/MemoryBuffer.h"

llvm::MemoryBuffer类提供对填充有文件内容的内存块的只读访问。在请求时,会在缓冲区的末尾添加一个尾随的零字符('\x00')。我们使用这个特性来在不检查每次访问时缓冲区的长度的情况下读取缓冲区。llvm::StringRef类封装了指向 C 字符串及其长度的指针。由于长度被存储,字符串不需要像普通的 C 字符串那样以零字符('\x00')结尾。这允许StringRef的实例指向由MemoryBuffer管理的内存。让我们更详细地看一下这个:

  1. 首先,Token类包含了先前提到的唯一标记数字的枚举定义:
class Lexer;
class Token {
  friend class Lexer;
public:
  enum TokenKind : unsigned short {
    eoi, unknown, ident, number, comma, colon, plus, 
    minus, star, slash, l_paren, r_paren, KW_with
  };

除了为每个标记定义一个成员之外,我们还添加了两个额外的值:eoiunknowneoi代表结束输入,unknown用于在词法级别出现错误的情况下;例如,#不是语言的标记,因此会被映射为unknown

  1. 除了枚举之外,该类还有一个成员Text,它指向标记文本的开头。它使用了之前提到的StringRef类:
private:
  TokenKind Kind;
  llvm::StringRef Text;
public:
  TokenKind getKind() const { return Kind; }
  llvm::StringRef getText() const { return Text; }

这对于语义处理很有用,因为知道标识符的名称是很有用的。

  1. is()isOneOf()方法用于测试标记是否属于某种类型。isOneOf()方法使用可变模板,允许可变数量的参数:
  bool is(TokenKind K) const { return Kind == K; }
  bool isOneOf(TokenKind K1, TokenKind K2) const {
    return is(K1) || is(K2);
  }
  template <typename... Ts>
  bool isOneOf(TokenKind K1, TokenKind K2, Ts... Ks) const {
    return is(K1) || isOneOf(K2, Ks...);
  }
};
  1. Lexer类本身具有类似的简单接口,并在头文件中紧随其后:
class Lexer {
  const char *BufferStart;
  const char *BufferPtr;
public:
  Lexer(const llvm::StringRef &Buffer) {
    BufferStart = Buffer.begin();
    BufferPtr = BufferStart;
  }
  void next(Token &token);
private:
  void formToken(Token &Result, const char *TokEnd,
                 Token::TokenKind Kind);
};
#endif

除了构造函数之外,公共接口只包含next()方法,它返回下一个标记。该方法的行为类似于迭代器,总是前进到下一个可用的标记。该类的唯一成员是指向输入开头和下一个未处理字符的指针。假定缓冲区以终止0(类似于 C 字符串)结束。

  1. 让我们在Lexer.cpp文件中实现Lexer类。它以一些辅助函数开始,以帮助对字符进行分类:
#include "Lexer.h"
namespace charinfo {
LLVM_READNONE inline bool isWhitespace(char c) {
  return c == ' ' || c == '\t' || c == '\f' ||         c == '\v' ||
         c == '\r' || c == '\n';
}
LLVM_READNONE inline bool isDigit(char c) {
  return c >= '0' && c <= '9';
}
LLVM_READNONE inline bool isLetter(char c) {
  return (c >= 'a' && c <= 'z') ||         (c >= 'A' && c <= 'Z');
}
}

这些函数用于使条件更易读。

注意

我们不使用<cctype>标准库头文件提供的函数有两个原因。首先,这些函数根据环境中定义的区域设置而改变行为。例如,如果区域设置是德语区域设置,则德语变音符可以被分类为字母。这通常不是编译器所希望的。其次,由于这些函数的参数类型为int,我们必须从char类型转换。这种转换的结果取决于char是作为有符号类型还是无符号类型处理,这会导致可移植性问题。

  1. 根据上一节中的语法,我们知道语言的所有标记。但是语法并没有定义应该忽略的字符。例如,空格或换行符只会添加空白并经常被忽略。next()方法首先忽略这些字符:
void Lexer::next(Token &token) {
  while (*BufferPtr &&         charinfo::isWhitespace(*BufferPtr)) {
    ++BufferPtr;
  }
  1. 接下来,确保仍有字符需要处理:
  if (!*BufferPtr) {
    token.Kind = Token::eoi;
    return;
  }

至少有一个字符需要处理。

  1. 因此,我们首先检查字符是小写还是大写。在这种情况下,标记要么是标识符,要么是with关键字,因为标识符的正则表达式也匹配关键字。常见的解决方案是收集正则表达式匹配的字符,并检查字符串是否恰好是关键字:
  if (charinfo::isLetter(*BufferPtr)) {
    const char *end = BufferPtr + 1;
    while (charinfo::isLetter(*end))
      ++end;
    llvm::StringRef Name(BufferPtr, end - BufferPtr);
    Token::TokenKind kind =
        Name == "with" ? Token::KW_with : Token::ident;
    formToken(token, end, kind);
    return;
  }

私有的formToken()方法用于填充标记。

  1. 接下来,我们检查是否为数字。以下代码与先前显示的代码非常相似:
  else if (charinfo::isDigit(*BufferPtr)) {
    const char *end = BufferPtr + 1;
    while (charinfo::isDigit(*end))
      ++end;
    formToken(token, end, Token::number);
    return;
  }
  1. 现在,只剩下由固定字符串定义的标记。这很容易用switch来实现。由于所有这些标记只有一个字符,所以使用CASE预处理宏来减少输入:
  else {
    switch (*BufferPtr) {
#define CASE(ch, tok) \
case ch: formToken(token, BufferPtr + 1, tok); break
CASE('+', Token::plus);
CASE('-', Token::minus);
CASE('*', Token::star);
CASE('/', Token::slash);
CASE('(', Token::Token::l_paren);
CASE(')', Token::Token::r_paren);
CASE(':', Token::Token::colon);
CASE(',', Token::Token::comma);
#undef CASE
  1. 最后,我们需要检查是否有意外的字符:
    default:
      formToken(token, BufferPtr + 1, Token::unknown);
    }
    return;
  }
}

只有私有的辅助方法formToken()还缺失。

  1. 这个私有的辅助方法填充了Token实例的成员并更新了指向下一个未处理字符的指针:
void Lexer::formToken(Token &Tok, const char *TokEnd,
                      Token::TokenKind Kind) {
  Tok.Kind = Kind;
  Tok.Text = llvm::StringRef(BufferPtr, TokEnd -                              BufferPtr);
  BufferPtr = TokEnd;
}

在下一节中,我们将看一下如何构建用于语法分析的解析器。

语法分析

语法分析由我们将在下一步实现的解析器完成。它的基础是前几节的语法和词法分析器。解析过程的结果是一种称为抽象语法树AST)的动态数据结构。AST 是输入的非常简洁的表示形式,并且非常适合语义分析。首先,我们将实现解析器。之后,我们将看一下 AST。

手写解析器

解析器的接口在Parser.h头文件中定义。它以一些include语句开始:

#ifndef PARSER_H
#define PARSER_H
#include "AST.h"
#include "Lexer.h"
#include "llvm/Support/raw_ostream.h"

AST.h头文件声明了 AST 的接口,并将在稍后显示。LLVM 的编码指南禁止使用<iostream>库,因此必须包含等效的 LLVM 功能的头文件。需要发出错误消息。让我们更详细地看一下这个:

  1. 首先,Parser类声明了一些私有成员:
class Parser {
  Lexer &Lex;
  Token Tok;
  bool HasError;

LexTok是前一节中的类的实例。Tok存储下一个标记(向前看),而Lex用于从输入中检索下一个标记。HasError标志指示是否检测到错误。

  1. 有几种方法处理标记:
  void error() {
    llvm::errs() << "Unexpected: " << Tok.getText()
                 << "\n";
    HasError = true;
  }
  void advance() { Lex.next(Tok); }
  bool expect(Token::TokenKind Kind) {
    if (Tok.getKind() != Kind) {
      error();
      return true;
    }
    return false;
  }
  bool consume(Token::TokenKind Kind) {
    if (expect(Kind))
      return true;
    advance();
    return false;
  }

advance()从词法分析器中检索下一个标记。expect()测试向前看是否是预期的类型,如果不是则发出错误消息。最后,consume()如果向前看是预期的类型,则检索下一个标记。如果发出错误消息,则将HasError标志设置为 true。

  1. 对于语法中的每个非终结符,声明了一个解析规则的方法:
  AST *parseCalc();
  Expr *parseExpr();
  Expr *parseTerm();
  Expr *parseFactor();

注意

identnumber没有方法。这些规则只返回标记,并由相应的标记替换。

  1. 以下是公共接口。构造函数初始化所有成员并从词法分析器中检索第一个标记:
public:
  Parser(Lexer &Lex) : Lex(Lex), HasError(false) {
    advance();
  }
  1. 需要一个函数来获取错误标志的值:
  bool hasError() { return HasError; }
  1. 最后,parse()方法是解析的主要入口点:
  AST *parse();
};
#endif

在下一节中,我们将学习如何实现解析器。

解析器实现

让我们深入了解解析器的实现:

  1. 解析器的实现可以在Parser.cpp文件中找到,并以parse()方法开始:
#include "Parser.h"
AST *Parser::parse() {
  AST *Res = parseCalc();
  expect(Token::eoi);
  return Res;
}

parse()方法的主要目的是整个输入已被消耗。您还记得第一节中解析示例添加了一个特殊符号来表示输入的结束吗?我们将在这里检查这一点。

  1. parseCalc()方法实现了相应的规则。让我们回顾一下第一节的规则:
calc : ("with" ident ("," ident)* ":")? expr ;
  1. 该方法开始声明一些局部变量:
AST *Parser::parseCalc() {
  Expr *E;
  llvm::SmallVector<llvm::StringRef, 8> Vars;
  1. 首先要做出的决定是是否必须解析可选组。该组以with标记开始,因此我们将标记与此值进行比较:
  if (Tok.is(Token::KW_with)) {
    advance();
  1. 接下来,我们期望一个标识符:
    if (expect(Token::ident))
      goto _error;
    Vars.push_back(Tok.getText());
    advance();

如果有一个标识符,那么我们将其保存在Vars向量中。否则,这是一个语法错误,需要单独处理。

  1. 语法中现在跟随一个重复组,它解析更多的标识符,用逗号分隔:
    while (Tok.is(Token::comma)) {
      advance();
      if (expect(Token::ident))
        goto _error;
      Vars.push_back(Tok.getText());
      advance();
    }

这一点现在对你来说应该不足为奇了。重复组以the标记开始。标记的测试成为while循环的条件,实现零次或多次重复。循环内的标识符被视为之前处理的方式。

  1. 最后,可选组需要在末尾加上冒号:
    if (consume(Token::colon))
      goto _error;
  }
  1. 现在,必须解析expr规则:
  E = parseExpr();
  1. 通过这个调用,规则已经成功解析。我们收集的信息现在用于创建这个规则的 AST 节点:
  if (Vars.empty()) return E;
  else return new WithDecl(Vars, E);

现在,只有错误处理代码还缺失。检测语法错误很容易,但从中恢复却令人惊讶地复杂。在这里,必须使用一种称为恐慌模式的简单方法。

在恐慌模式中,从标记流中删除标记,直到找到解析器可以继续工作的标记为止。大多数编程语言都有表示结束的符号;例如,在 C++中,我们可以使用;(语句的结束)或}(块的结束)。这些标记是寻找的好候选者。

另一方面,错误可能是我们正在寻找的符号丢失了。在这种情况下,可能会在解析器继续之前删除很多标记。这并不像听起来那么糟糕。今天,编译器的速度更重要。在出现错误时,开发人员查看第一个错误消息,修复它,然后重新启动编译器。这与使用穿孔卡完全不同,那时尽可能多地获得错误消息非常重要,因为下一次运行编译器只能在第二天进行。

错误处理

不是使用一些任意的标记,而是使用另一组标记。对于每个非终端,都有一组可以在规则中跟随这个非终端的标记。让我们来看一下:

  1. calc的情况下,只有输入的结尾跟随这个非终端。它的实现是微不足道的:
_error:
  while (!Tok.is(Token::eoi))
    advance();
  return nullptr;
}
  1. 其他解析方法的构造方式类似。parseExpr()是对expr规则的翻译:
Expr *Parser::parseExpr() {
  Expr *Left = parseTerm();
  while (Tok.isOneOf(Token::plus, Token::minus)) {
    BinaryOp::Operator Op =
       Tok.is(Token::plus) ? BinaryOp::Plus :
                             BinaryOp::Minus;
    advance();
    Expr *Right = parseTerm();
    Left = new BinaryOp(Op, Left, Right);
  }
  return Left;
}

规则内的重复组被翻译成了while循环。请注意isOneOf()方法的使用简化了对多个标记的检查。

  1. term规则的编码看起来是一样的:
Expr *Parser::parseTerm() {
  Expr *Left = parseFactor();
  while (Tok.isOneOf(Token::star, Token::slash)) {
    BinaryOp::Operator Op =
        Tok.is(Token::star) ? BinaryOp::Mul : 
                              BinaryOp::Div;
    advance();
    Expr *Right = parseFactor();
    Left = new BinaryOp(Op, Left, Right);
  }
  return Left;
}

这个方法与parseExpr()非常相似,你可能会想将它们合并成一个。在语法中,可以有一个处理乘法和加法运算符的规则。使用两个规则而不是一个的优势在于运算符的优先级与数学计算顺序很匹配。如果合并这两个规则,那么你需要在其他地方找出评估顺序。

  1. 最后,你需要实现factor规则:
Expr *Parser::parseFactor() {
  Expr *Res = nullptr;
  switch (Tok.getKind()) {
  case Token::number:
    Res = new Factor(Factor::Number, Tok.getText());
    advance(); break;

与使用一系列ifelse if语句不同,这里似乎更适合使用switch语句,因为每个备选方案都以一个标记开始。一般来说,你应该考虑使用哪种翻译模式。如果以后需要更改解析方法,那么如果不是每个方法都有不同的实现语法规则的方式,那就是一个优势。

  1. 如果使用switch语句,那么错误处理发生在default情况下:
  case Token::ident:
    Res = new Factor(Factor::Ident, Tok.getText());
    advance(); break;
  case Token::l_paren:
    advance();
    Res = parseExpr();
    if (!consume(Token::r_paren)) break;
  default:
    if (!Res) error();

我们在这里防止发出错误消息,因为会出现错误。

  1. 如果括号表达式中有语法错误,那么会发出错误消息。保护措施防止发出第二个错误消息:
    while (!Tok.isOneOf(Token::r_paren, Token::star,
                        Token::plus, Token::minus,
                        Token::slash, Token::eoi))
      advance();
  }
  return Res;
}

这很容易,不是吗?一旦你记住了使用的模式,根据语法规则编写解析器几乎是乏味的。这种类型的解析器称为递归下降解析器

递归下降解析器无法从所有语法构造出来

语法必须满足一定条件才能适合构造递归下降解析器。这类语法称为 LL(1)。事实上,大多数你可以在互联网上找到的语法都不属于这类语法。大多数关于编译器构造理论的书都解释了这个原因。这个主题的经典书籍是所谓的“龙书”,即 Aho、Lam、Sethi 和 Ullman 的编译器原理、技术和工具

抽象语法树

解析过程的结果是一个;,表示单个语句的结束。当然,这对解析器很重要。一旦我们将语句转换为内存表示,分号就不再重要,可以被丢弃。

如果你看一下例子表达式语言的第一个规则,那么很明显with关键字,逗号,和冒号:对程序的含义并不重要。重要的是声明的变量列表,这些变量可以在表达式中使用。结果是只需要几个类来记录信息:Factor保存数字或标识符,BinaryOp保存算术运算符和表达式的左右两侧,WithDecl保存声明的变量列表和表达式。ASTExpr仅用于创建一个公共类层次结构。

除了从解析输入中获得的信息外,还要在使用AST.h头文件时进行树遍历。让我们来看一下:

  1. 它以访问者接口开始:
#ifndef AST_H
#define AST_H
#include "llvm/ADT/SmallVector.h"
#include "llvm/ADT/StringRef.h"
class AST;
class Expr;
class Factor;
class BinaryOp;
class WithDecl;
class ASTVisitor {
public:
  virtual void visit(AST &){};
  virtual void visit(Expr &){};
  virtual void visit(Factor &) = 0;
  virtual void visit(BinaryOp &) = 0;
  virtual void visit(WithDecl &) = 0;
};

访问者模式需要知道它必须访问的每个类。因为每个类也引用了访问者,我们在文件顶部声明所有类。请注意,ASTExprvisit()方法具有默认实现,什么也不做。

  1. AST类是层次结构的根:
class AST {
public:
  virtual ~AST() {}
  virtual void accept(ASTVisitor &V) = 0;
};
  1. 同样,Expr是与表达式相关的AST类的根:
class Expr : public AST {
public:
  Expr() {}
};
  1. Factor类存储数字或变量的名称:
class Factor : public Expr {
public:
  enum ValueKind { Ident, Number };
private:
  ValueKind Kind;
  llvm::StringRef Val;
public:
  Factor(ValueKind Kind, llvm::StringRef Val)
      : Kind(Kind), Val(Val) {}
  ValueKind getKind() { return Kind; }
  llvm::StringRef getVal() { return Val; }
  virtual void accept(ASTVisitor &V) override {
    V.visit(*this);
  }
};

在这个例子中,数字和变量几乎被处理得一样,因此我们决定只创建一个 AST 节点类来表示它们。Kind成员告诉我们实例代表这两种情况中的哪一种。在更复杂的语言中,通常希望有不同的 AST 类,比如NumberLiteral类用于数字,VariableAccess类用于引用变量。

  1. BinaryOp类保存了评估表达式所需的数据:
class BinaryOp : public Expr {
public:
  enum Operator { Plus, Minus, Mul, Div };
private:
  Expr *Left;
  Expr *Right;
  Operator Op;
public:
  BinaryOp(Operator Op, Expr *L, Expr *R)
      : Op(Op), Left(L), Right(R) {}
  Expr *getLeft() { return Left; }
  Expr *getRight() { return Right; }
  Operator getOperator() { return Op; }
  virtual void accept(ASTVisitor &V) override {
    V.visit(*this);
  }
};

与解析器相比,BinaryOp类在乘法和加法运算符之间没有区别。运算符的优先级隐含在树结构中。

  1. 最后,WithDecl存储了声明的变量和表达式:
class WithDecl : public AST {
  using VarVector =                   llvm::SmallVector<llvm::StringRef, 8>;
  VarVector Vars;
  Expr *E;
public:
  WithDecl(llvm::SmallVector<llvm::StringRef, 8> Vars,
           Expr *E)
      : Vars(Vars), E(E) {}
  VarVector::const_iterator begin()                                 { return Vars.begin(); }
  VarVector::const_iterator end() { return Vars.end(); }
  Expr *getExpr() { return E; }
  virtual void accept(ASTVisitor &V) override {
    V.visit(*this);
  }
};
#endif

AST 在解析过程中构建。语义分析检查树是否符合语言的含义(例如,使用的变量是否已声明),并可能增强树。之后,树被用于代码生成。

语义分析

语义分析器遍历 AST 并检查语言的各种语义规则;例如,变量必须在使用前声明,或者表达式中的变量类型必须兼容。如果语义分析器发现可以改进的情况,还可以打印警告。对于示例表达语言,语义分析器必须检查每个使用的变量是否已声明,因为语言要求如此。可能的扩展(这里不会实现)是在未使用的情况下打印警告消息。

语义分析器实现在 Sema 类中,语义分析由 semantic() 方法执行。以下是完整的 Sema.h 头文件:

#ifndef SEMA_H
#define SEMA_H
#include "AST.h"
#include "Lexer.h"
class Sema {
public:
  bool semantic(AST *Tree);
};
#endif

实现在 Sema.cpp 文件中。有趣的部分是语义分析,它使用访问者来实现。基本思想是每个声明的变量名都存储在一个集合中。在创建集合时,我们可以检查每个名称是否唯一,然后稍后检查名称是否在集合中:

#include "Sema.h"
#include "llvm/ADT/StringSet.h"
namespace {
class DeclCheck : public ASTVisitor {
  llvm::StringSet<> Scope;
  bool HasError;
  enum ErrorType { Twice, Not };
  void error(ErrorType ET, llvm::StringRef V) {
    llvm::errs() << "Variable " << V << " "
                 << (ET == Twice ? "already" : "not")
                 << " declared\n";
    HasError = true;
  }
public:
  DeclCheck() : HasError(false) {}
  bool hasError() { return HasError; }

Parser 类一样,使用标志来指示是否发生错误。名称存储在名为 Scope 的集合中。在包含变量名的 Factor 节点中,我们检查变量名是否在集合中:

  virtual void visit(Factor &Node) override {
    if (Node.getKind() == Factor::Ident) {
      if (Scope.find(Node.getVal()) == Scope.end())
        error(Not, Node.getVal());
    }
  };

对于 BinaryOp 节点,我们只需要检查两侧是否存在并已被访问:

  virtual void visit(BinaryOp &Node) override {
    if (Node.getLeft())
      Node.getLeft()->accept(*this);
    else
      HasError = true;
    if (Node.getRight())
      Node.getRight()->accept(*this);
    else
      HasError = true;
  };

WithDecl 节点中,集合被填充,并开始对表达式的遍历:

  virtual void visit(WithDecl &Node) override {
    for (auto I = Node.begin(), E = Node.end(); I != E;
         ++I) {
      if (!Scope.insert(*I).second)
        error(Twice, *I);
    }
    if (Node.getExpr())
      Node.getExpr()->accept(*this);
    else
      HasError = true;
  };
};
}

semantic() 方法只是开始树遍历并返回错误标志:

bool Sema::semantic(AST *Tree) {
  if (!Tree)
    return false;
  DeclCheck Check;
  Tree->accept(Check);
  return Check.hasError();
}

如果需要,这里可以做更多的工作。还可以打印警告消息,如果声明的变量未被使用。我们留给您来实现。如果语义分析没有错误完成,那么我们可以从 AST 生成 LLVM IR。我们将在下一节中进行这个操作。

使用 LLVM 后端生成代码

后端的任务是从模块的 IR 创建优化的机器代码。IR 是后端的接口,可以使用 C++ 接口或文本形式创建。同样,IR 是从 AST 生成的。

LLVM IR 的文本表示

在尝试生成 LLVM IR 之前,我们需要了解我们想要生成什么。对于示例表达语言,高级计划如下:

  1. 询问用户每个变量的值。

  2. 计算表达式的值。

  3. 打印结果。

要求用户为变量提供一个值并打印结果,使用了两个库函数 calc_read()calc_write()。对于 with a: 3*a 表达式,生成的 IR 如下:

  1. 库函数必须像 C 语言一样声明。语法也类似于 C 语言。函数名前的类型是返回类型。括号中的类型是参数类型。声明可以出现在文件的任何位置:
declare i32 @calc_read(i8*)
declare void @calc_write(i32)
  1. calc_read() 函数以变量名作为参数。以下结构定义了一个常量,保存了 a 和在 C 语言中用作字符串终结符的空字节:
@a.str = private constant [2 x i8] c"a\00"
  1. 它跟在 main() 函数后面。参数的名称被省略,因为它们没有被使用。与 C 语言一样,函数的主体用大括号括起来:
define i32 @main(i32, i8**) {
  1. 每个基本块必须有一个标签。因为这是函数的第一个基本块,我们将其命名为 entry
entry:
  1. 调用 calc_read() 函数来读取 a 变量的值。嵌套的 getelemenptr 指令执行索引计算以计算字符串常量的第一个元素的指针。函数的结果被赋值给未命名的 %2 变量:
  %2 = call i32 @calc_read(i8* getelementptr inbounds
                 ([2 x i8], [2 x i8]* @a.str, i32 0, i32 0))
  1. 接下来,变量乘以 3
  %3 = mul nsw i32 3, %2
  1. 结果通过调用 calc_write() 函数打印到控制台:
  call void @calc_write(i32 %3)
  1. 最后,main() 函数返回 0 表示执行成功:
  ret i32 0
}

LLVM IR 中的每个值都是有类型的,i32表示 32 位整数类型,i8*表示指向字节的指针。IR 代码非常可读(也许除了getelementptr操作之外,在第五章**,IR 生成基础中将详细解释)。现在清楚了 IR 的样子,让我们从 AST 生成它。

从 AST 生成 IR。

CodeGen.h头文件中提供的接口非常小:

#ifndef CODEGEN_H
#define CODEGEN_H
#include "AST.h"
class CodeGen
{
public:
 void compile(AST *Tree);
};
#endif

因为 AST 包含了语义分析阶段的信息,基本思想是使用访问者遍历 AST。CodeGen.cpp文件的实现如下:

  1. 所需的包含在文件顶部:
#include "CodeGen.h"
#include "llvm/ADT/StringMap.h"
#include "llvm/IR/IRBuilder.h"
#include "llvm/IR/LLVMContext.h"
#include "llvm/Support/raw_ostream.h"
  1. LLVM 库的命名空间用于名称查找:
using namespace llvm;
  1. 首先,在访问者中声明了一些私有成员。LLVM 中,每个编译单元都由Module类表示,访问者有一个指向模块调用M的指针。为了方便生成 IR,使用了BuilderIRBuilder<>类型)。LLVM 有一个类层次结构来表示 IR 中的类型。您可以在 LLVM 上下文中查找基本类型的实例,比如i32。这些基本类型经常被使用。为了避免重复查找,我们缓存所需的类型实例,可以是VoidTyInt32TyInt8PtrTyInt8PtrPtrTyInt32ZeroV是当前计算的值,通过树遍历更新。最后,nameMap将变量名映射到calc_read()函数返回的值:
namespace {
class ToIRVisitor : public ASTVisitor {
  Module *M;
  IRBuilder<> Builder;
  Type *VoidTy;
  Type *Int32Ty;
  Type *Int8PtrTy;
  Type *Int8PtrPtrTy;
  Constant *Int32Zero;
  Value *V;
  StringMap<Value *> nameMap;
  1. 构造函数初始化了所有成员:
public:
  ToIRVisitor(Module *M) : M(M), Builder(M->getContext()) 
  {
    VoidTy = Type::getVoidTy(M->getContext());
    Int32Ty = Type::getInt32Ty(M->getContext());
    Int8PtrTy = Type::getInt8PtrTy(M->getContext());
    Int8PtrPtrTy = Int8PtrTy->getPointerTo();
    Int32Zero = ConstantInt::get(Int32Ty, 0, true);
  }
  1. 对于每个函数,必须创建一个FunctionType实例。在 C++术语中,这是一个函数原型。函数本身是用Function实例定义的。首先,run()方法在 LLVM IR 中定义了main()函数:
  void run(AST *Tree) {
    FunctionType *MainFty = FunctionType::get(
        Int32Ty, {Int32Ty, Int8PtrPtrTy}, false);
    Function *MainFn = Function::Create(
        MainFty, GlobalValue::ExternalLinkage,
        "main", M);
  1. 然后,使用entry标签创建BB基本块,并将其附加到 IR 构建器:
    BasicBlock *BB = BasicBlock::Create(M->getContext(),
                                        "entry", MainFn);
    Builder.SetInsertPoint(BB);
  1. 准备工作完成后,树遍历可以开始:
    Tree->accept(*this);
  1. 树遍历后,通过调用calc_write()函数打印计算出的值。再次,必须创建函数原型(FunctionType的实例)。唯一的参数是当前值V
    FunctionType *CalcWriteFnTy =
        FunctionType::get(VoidTy, {Int32Ty}, false);
    Function *CalcWriteFn = Function::Create(
        CalcWriteFnTy, GlobalValue::ExternalLinkage,
        "calc_write", M);
    Builder.CreateCall(CalcWriteFnTy, CalcWriteFn, {V});
  1. 生成完成后,从main()函数返回0
    Builder.CreateRet(Int32Zero);
  }
  1. WithDecl节点保存了声明变量的名称。首先,必须为calc_read()函数创建函数原型:
  virtual void visit(WithDecl &Node) override {
    FunctionType *ReadFty =
        FunctionType::get(Int32Ty, {Int8PtrTy}, false);
    Function *ReadFn = Function::Create(
        ReadFty, GlobalValue::ExternalLinkage, 
        "calc_read", M);
  1. 该方法循环遍历变量名:
    for (auto I = Node.begin(), E = Node.end(); I != E;
         ++I) {
  1. 为每个变量创建一个带有变量名的字符串:
      StringRef Var = *I;
      Constant *StrText = ConstantDataArray::getString(
          M->getContext(), Var);
      GlobalVariable *Str = new GlobalVariable(
          *M, StrText->getType(),
          /*isConstant=*/true, 
          GlobalValue::PrivateLinkage,
          StrText, Twine(Var).concat(".str"));
  1. 然后,创建调用calc_read()函数的 IR 代码。将在上一步中创建的字符串作为参数传递:
      Value *Ptr = Builder.CreateInBoundsGEP(
          Str, {Int32Zero, Int32Zero}, "ptr");
      CallInst *Call =
          Builder.CreateCall(ReadFty, ReadFn, {Ptr});
  1. 返回的值存储在mapNames映射中以供以后使用:
      nameMap[Var] = Call;
    }
  1. 树遍历继续进行,表达式如下:
    Node.getExpr()->accept(*this);
  };
  1. Factor节点可以是变量名或数字。对于变量名,在mapNames映射中查找值。对于数字,将值转换为整数并转换为常量值:
  virtual void visit(Factor &Node) override {
    if (Node.getKind() == Factor::Ident) {
      V = nameMap[Node.getVal()];
    } else {
      int intval;
      Node.getVal().getAsInteger(10, intval);
      V = ConstantInt::get(Int32Ty, intval, true);
    }
  };
  1. 最后,对于BinaryOp节点,必须使用正确的计算操作:
  virtual void visit(BinaryOp &Node) override {
    Node.getLeft()->accept(*this);
    Value *Left = V;
    Node.getRight()->accept(*this);
    Value *Right = V;
    switch (Node.getOperator()) {
    case BinaryOp::Plus:
      V = Builder.CreateNSWAdd(Left, Right); break;
    case BinaryOp::Minus:
      V = Builder.CreateNSWSub(Left, Right); break;
    case BinaryOp::Mul:
      V = Builder.CreateNSWMul(Left, Right); break;
    case BinaryOp::Div:
      V = Builder.CreateSDiv(Left, Right); break;
    }
  };
};
}
  1. 这样,访问者类就完成了。compile()方法创建全局上下文和模块,运行树遍历,并将生成的 IR 转储到控制台:
void CodeGen::compile(AST *Tree) {
  LLVMContext Ctx;
  Module *M = new Module("calc.expr", Ctx);
  ToIRVisitor ToIR(M);
  ToIR.run(Tree);
  M->print(outs(), nullptr);
}

通过这样,我们已经实现了编译器的前端,从读取源代码到生成 IR。当然,所有这些组件必须在用户输入上一起工作,这是编译器驱动程序的任务。我们还需要实现运行时所需的函数。我们将在下一节中涵盖这两个方面。

缺失的部分 - 驱动程序和运行时库

前几节的所有阶段都由Calc.cpp驱动程序连接在一起,我们将在这里实现。此时,声明了输入表达式的参数,初始化了 LLVM,并调用了前几节的所有阶段。让我们来看一下:

  1. 首先,必须包含所需的头文件:
#include "CodeGen.h"
#include "Parser.h"
#include "Sema.h"
#include "llvm/Support/CommandLine.h"
#include "llvm/Support/InitLLVM.h"
#include "llvm/Support/raw_ostream.h"
  1. LLVM 有自己的命令行选项声明系统。您只需要为每个需要的选项声明一个静态变量。这样做,选项就会在全局命令行解析器中注册。这种方法的优势在于每个组件都可以在需要时添加命令行选项。我们必须为输入表达式声明一个选项:
static llvm::cl::opt<std::string>
    Input(llvm::cl::Positional,
          llvm::cl::desc("<input expression>"),
          llvm::cl::init(""));
  1. main()函数内,初始化了 LLVM 库。您需要调用ParseCommandLineOptions来处理命令行上的选项。这也处理打印帮助信息。在出现错误的情况下,此方法会退出应用程序:
int main(int argc, const char **argv) {
  llvm::InitLLVM X(argc, argv);
  llvm::cl::ParseCommandLineOptions(
      argc, argv, "calc - the expression compiler\n");
  1. 接下来,我们调用词法分析器和语法分析器。在语法分析之后,我们检查是否发生了错误。如果是这种情况,那么我们以一个返回代码退出编译器,表示失败:
  Lexer Lex(Input);
  Parser Parser(Lex);
  AST *Tree = Parser.parse();
  if (!Tree || Parser.hasError()) {
    llvm::errs() << "Syntax errors occured\n";
    return 1;
  }
  1. 如果有语义错误,我们也会这样做。
  Sema Semantic;
  if (Semantic.semantic(Tree)) {
    llvm::errs() << "Semantic errors occured\n";
    return 1;
  }
  1. 最后,在驱动程序中,调用了代码生成器:
  CodeGen CodeGenerator;
  CodeGenerator.compile(Tree);
  return 0;
}

有了这个,我们已经成功地为用户输入创建了 IR 代码。我们将对象代码生成委托给 LLVM 静态编译器llc,因此这完成了我们的编译器的实现。我们必须将所有组件链接在一起,以创建calc应用程序。

运行时库由一个名为rtcalc.c的单个文件组成。它包含了用 C 编写的calc_read()calc_write()函数的实现:

#include <stdio.h>
#include <stdlib.h>
void calc_write(int v)
{
  printf("The result is: %d\n", v);
}

calc_write()只是将结果值写入终端:

int calc_read(char *s)
{
  char buf[64];
  int val;
  printf("Enter a value for %s: ", s);
  fgets(buf, sizeof(buf), stdin);
  if (EOF == sscanf(buf, "%d", &val))
  {
    printf("Value %s is invalid\n", buf);
    exit(1);
  }
  return val;
}

calc_read()从终端读取一个整数。没有任何限制阻止用户输入字母或其他字符,因此我们必须仔细检查输入。如果输入不是数字,我们就退出应用程序。一个更复杂的方法是让用户意识到问题,并再次要求输入一个数字。

现在,我们可以尝试我们的编译器。calc应用程序从表达式创建 IR。LLVM 静态编译器llc将 IR 编译为一个目标文件。然后,您可以使用您喜欢的 C 编译器链接到小型运行时库。在 Unix 上,您可以输入以下内容:

$ calc "with a: a*3" | llc –filetype=obj –o=expr.o
$ clang –o expr expr.o rtcalc.c
$ expr
Enter a value for a: 4
The result is: 12

在 Windows 上,您很可能会使用cl编译器:

$ calc "with a: a*3" | llc –filetype=obj –o=expr.obj
$ cl expr.obj rtcalc.c
$ expr
Enter a value for a: 4
The result is: 12

有了这个,您已经创建了您的第一个基于 LLVM 的编译器!请花一些时间玩弄各种表达式。还要检查乘法运算符在加法运算符之前进行评估,并且使用括号会改变评估顺序,这是我们从基本计算器中期望的。

总结

在本章中,您了解了编译器的典型组件。一个算术表达式语言被用来向您介绍编程语言的语法。然后,您学会了如何为这种语言开发典型的前端组件:词法分析器、语法分析器、语义分析器和代码生成器。代码生成器只产生了 LLVM IR,LLVM 静态编译器llc用它来创建目标文件。最后,您开发了您的第一个基于 LLVM 的编译器!

在下一章中,您将加深这些知识,以构建一个编程语言的前端。

第二部分:从源代码到机器码生成

在本节中,您将学习如何开发自己的编译器。您将首先构建前端,该前端读取源文件并创建其抽象语法树。然后,您将学习如何从源文件生成 LLVM IR。利用 LLVM 的优化能力,您将创建优化的机器码。您还将学习一些高级主题,包括为面向对象语言构造生成 LLVM IR,以及如何添加调试元数据。

本节包括以下章节:

  • 第四章, 将源文件转换为抽象语法树

  • 第五章, IR 生成的基础

  • 第六章, 高级语言构造的 IR 生成

  • 第七章, 高级 IR 生成

  • 第八章, IR 优化

第四章:将源文件转换为抽象语法树

编译器通常分为两部分:前端和后端。在本章中,我们将实现编程语言的前端;也就是处理源语言的部分。我们将学习真实世界编译器使用的技术,并将其应用到我们自己的编程语言中。

我们将从定义编程语言的语法开始,以 抽象语法树(AST) 结束,这将成为代码生成的基础。你可以使用这种方法来为你想要实现编译器的每种编程语言。

在本章中,你将学习以下主题:

  • 定义一个真正的编程语言将向你介绍 tinylang 语言,它是一个真正编程语言的子集,你必须为其实现一个编译器前端。

  • 创建项目布局,你将为编译器创建项目布局。

  • 管理源文件和用户消息,这将让你了解如何处理多个输入文件,并以愉快的方式通知用户有关问题。

  • 构建词法分析器,讨论词法分析器如何分解为模块化部分。

  • 构建一个递归下降解析器,将讨论从语法中导出解析器的规则,以执行语法分析。

  • 使用 bison 和 flex 生成解析器和词法分析器,你将使用工具舒适地从规范中生成解析器和词法分析器。

  • 执行语义分析,你将创建 AST 并评估其属性,这将与解析器交织在一起。

通过本章节你将获得的技能,你将能够为任何编程语言构建编译器前端。

技术要求

本章的代码文件可在github.com/PacktPublishing/Learn-LLVM-12/tree/master/Chapter04找到

你可以在bit.ly/3nllhED找到代码演示视频

定义一个真正的编程语言

一个真正的编程语言带来的挑战比简单的 tinylang 更多。

让我们快速浏览一下本章将使用的 tinylang 语法的子集。在接下来的章节中,我们将从这个语法中导出词法分析器和语法分析器:

compilationUnit
  : "MODULE" identifier ";" ( import )* block identifier "." ;
Import : ( "FROM" identifier )? "IMPORT" identList ";" ;
Block
  : ( declaration )* ( "BEGIN" statementSequence )? "END" ;

Modula-2 中的编译单元以 MODULE 关键字开始,后面跟着模块的名称。模块的内容可以是导入模块的列表、声明和包含在初始化时运行的语句块:

declaration
  : "CONST" ( constantDeclaration ";" )*
  | "VAR" ( variableDeclaration ";" )*
  | procedureDeclaration ";" ;

声明引入常量、变量和过程。已声明的常量以 CONST 关键字为前缀。同样,变量声明以 VAR 关键字开头。声明常量非常简单:

constantDeclaration : identifier "=" expression ;

标识符是常量的名称。值来自表达式,必须在编译时可计算。声明变量稍微复杂一些:

variableDeclaration : identList ":" qualident ;
qualident : identifier ( "." identifier )* ;
identList : identifier ( "," identifier)* ;

为了能够一次声明多个变量,必须使用标识符列表。类型的名称可能来自另一个模块,在这种情况下,前缀为模块名称。这称为限定标识符。过程需要最多的细节:

procedureDeclaration
  : "PROCEDURE" identifier ( formalParameters )? ";"
    block identifier ;
formalParameters
  : "(" ( formalParameterList )? ")" ( ":" qualident )? ;
formalParameterList
  : formalParameter (";" formalParameter )* ;
formalParameter : ( "VAR" )? identList ":" qualident ;

在前面的代码中,你可以看到如何声明常量、变量和过程。过程可以有参数和返回类型。普通参数按值传递,而 VAR 参数按引用传递。前面的 block 规则中缺少的另一部分是 statementSequence,它只是一个单个语句的列表:

statementSequence
  : statement ( ";" statement )* ;

一个语句如果后面跟着另一个语句,就用分号分隔。再次强调,只支持Modula-2语句的一个子集:

statement
  : qualident ( ":=" expression | ( "(" ( expList )? ")" )? )
  | ifStatement | whileStatement | "RETURN" ( expression )? ;

这条规则的第一部分描述了赋值或过程调用。跟着:=的限定符标识符是一个赋值。另一方面,如果它后面跟着(,那么它就是一个过程调用。其他语句是通常的控制语句:

ifStatement
  : "IF" expression "THEN" statementSequence
    ( "ELSE" statementSequence )? "END" ;

IF语句的语法也很简化,因为它只能有一个ELSE块。有了这个语句,我们可以有条件地保护一个语句:

whileStatement
  : "WHILE" expression "DO" statementSequence "END" ;

WHILE语句描述了一个由条件保护的循环。与IF语句一起,这使我们能够在tinylang中编写简单的算法。最后,缺少表达式的定义:

expList
  : expression ( "," expression )* ;
expression
  : simpleExpression ( relation simpleExpression )? ;
relation
  : "=" | "#" | "<" | "<=" | ">" | ">=" ;
simpleExpression
  : ( "+" | "-" )? term ( addOperator term )* ;
addOperator
  : "+" | "-" | "OR" ;
term
  : factor ( mulOperator factor )* ;
mulOperator
  : "*" | "/" | "DIV" | "MOD" | "AND" ;
factor
  : integer_literal | "(" expression ")" | "NOT" factor
  | qualident ( "(" ( expList )? ")" )? ; 

表达式语法与上一章中的 calc 非常相似。只支持INTEGERBOOLEAN数据类型。

此外,还使用了identifierinteger_literal标记。一个H

这已经是很多规则了,我们只覆盖了 Modula-2 的一部分!尽管如此,在这个子集中编写小型应用是可能的。让我们为tinylang实现一个编译器!

创建项目布局

tinylang的项目布局遵循我们在第二章中提出的方法,浏览 LLVM 源码。每个组件的源代码都在lib目录的子目录中,而头文件在include/tinylang的子目录中。子目录的名称取决于组件。在第二章中,浏览 LLVM 源码,我们只创建了Basic组件。

从上一章我们知道,我们需要实现词法分析器、解析器、AST 和语义分析器。每个都是自己的组件,称为LexerParserASTSema。在上一章中使用的目录布局如下:

图 4.1 - tinylang 项目的目录布局

图 4.1 - tinylang 项目的目录布局

这些组件有明确定义的依赖关系。在这里,Lexer只依赖于BasicParser依赖于BasicLexerASTSema。最后,Sema只依赖于BasicAST。这些明确定义的依赖关系有助于重用组件。

让我们更仔细地看看它们的实现!

管理源文件和用户消息

一个真正的编译器必须处理许多文件。通常,开发人员使用主编译单元的名称调用编译器。这个编译单元可以引用其他文件,例如,通过 C 中的#include指令或 Python 或 Modula-2 中的import语句。导入的模块可以导入其他模块,依此类推。所有这些文件必须加载到内存中,并通过编译器的分析阶段运行。在开发过程中,开发人员可能会出现语法或语义错误。一旦检测到,应该打印出包括源行和标记的错误消息。在这一点上,显然可以看出这个基本组件并不是简单的。

幸运的是,LLVM 带有一个解决方案:llvm::SourceMgr类。通过调用AddNewSourceBuffer()方法向SourceMgr添加新的源文件。或者,可以通过调用AddIncludeFile()方法加载文件。这两种方法都返回一个 ID 来标识缓冲区。您可以使用此 ID 来检索与关联文件的内存缓冲区的指针。要在文件中定义位置,必须使用llvm::SMLoc类。这个类封装了一个指向缓冲区的指针。各种PrintMessage()方法允许我们向用户发出错误和其他信息消息。

只缺少一个集中定义消息的方法。在大型软件(如编译器)中,您不希望在各个地方散布消息字符串。如果有要求更改消息或将其翻译成另一种语言,那么最好将它们放在一个中心位置!

一个简单的方法是每个消息都有一个 ID(一个enum成员),一个严重程度级别和包含消息的字符串。在你的代码中,你只引用消息 ID。当消息被打印时,严重程度级别和消息字符串才会被使用。这三个项目(ID、安全级别和消息)必须一致管理。LLVM 库使用预处理器来解决这个问题。数据存储在一个带有.def后缀的文件中,并且包装在一个宏名称中。该文件通常被多次包含,使用不同的宏定义。这个定义在include/tinylang/Basic/Diagnostic.def文件路径中,看起来如下:

#ifndef DIAG
#define DIAG(ID, Level, Msg)
#endif
DIAG(err_sym_declared, Error, "symbol {0} already declared")
#undef DIAG

第一个宏参数ID是枚举标签,第二个参数Level是严重程度,第三个参数Msg是消息文本。有了这个定义,我们可以定义一个DiagnosticsEngine类来发出错误消息。接口在include/tinylang/Basic/Diagnostic.h文件中:

#ifndef TINYLANG_BASIC_DIAGNOSTIC_H
#define TINYLANG_BASIC_DIAGNOSTIC_H
#include "tinylang/Basic/LLVM.h"
#include "llvm/ADT/StringRef.h"
#include "llvm/Support/FormatVariadic.h"
#include "llvm/Support/SMLoc.h"
#include "llvm/Support/SourceMgr.h"
#include "llvm/Support/raw_ostream.h"
#include <utility>
namespace tinylang {

在包含必要的头文件之后,现在使用Diagnostic.def来定义枚举。为了不污染全局命名空间,必须使用嵌套命名空间diag

namespace diag {
enum {
#define DIAG(ID, Level, Msg) ID,
#include "tinylang/Basic/Diagnostic.def"
};
} // namespace diag

DiagnosticsEngine类使用SourceMgr实例通过report()方法发出消息。消息可以有参数。为了实现这个功能,必须使用 LLVM 的可变格式支持。消息文本和严重程度级别是通过static方法获取的。作为奖励,发出的错误消息数量也被计算:

class DiagnosticsEngine {
  static const char *getDiagnosticText(unsigned DiagID);
  static SourceMgr::DiagKind
  getDiagnosticKind(unsigned DiagID);

消息字符串由getDiagnosticText()返回,而级别由getDiagnosticKind()返回。这两个方法将在.cpp文件中实现:

  SourceMgr &SrcMgr;
  unsigned NumErrors;
public:
  DiagnosticsEngine(SourceMgr &SrcMgr)
      : SrcMgr(SrcMgr), NumErrors(0) {}
  unsigned nunErrors() { return NumErrors; }

由于消息可以有可变数量的参数,C++中的解决方案是使用可变模板。当然,LLVM 提供的formatv()函数也使用了这个。为了获得格式化的消息,我们只需要转发模板参数:

  template <typename... Args>
  void report(SMLoc Loc, unsigned DiagID,
              Args &&... Arguments) {
    std::string Msg =
        llvm::formatv(getDiagnosticText(DiagID),
                      std::forward<Args>(Arguments)...)
            .str();
    SourceMgr::DiagKind Kind = getDiagnosticKind(DiagID);
    SrcMgr.PrintMessage(Loc, Kind, Msg);
    NumErrors += (Kind == SourceMgr::DK_Error);
  }
};
} // namespace tinylang
#endif

到目前为止,我们已经实现了大部分的类。只有getDiagnosticText()getDiagnosticKind()还没有。它们在lib/Basic/Diagnostic.cpp文件中定义,并且还使用了Diagnostic.def文件:

#include "tinylang/Basic/Diagnostic.h"
using namespace tinylang;
namespace {
const char *DiagnosticText[] = {
#define DIAG(ID, Level, Msg) Msg,
#include "tinylang/Basic/Diagnostic.def"
};

与头文件中一样,DIAG宏被定义为检索所需的部分。在这里,我们将定义一个数组来保存文本消息。因此,DIAG宏只返回Msg部分。我们将使用相同的方法来处理级别:

SourceMgr::DiagKind DiagnosticKind[] = {
#define DIAG(ID, Level, Msg) SourceMgr::DK_##Level,
#include "tinylang/Basic/Diagnostic.def"
};
} // namespace

毫不奇怪,这两个函数只是简单地索引数组以返回所需的数据:

const char *
DiagnosticsEngine::getDiagnosticText(unsigned DiagID) {
  return DiagnosticText[DiagID];
}
SourceMgr::DiagKind
DiagnosticsEngine::getDiagnosticKind(unsigned DiagID) {
  return DiagnosticKind[DiagID];
}

SourceMgrDiagnosticsEngine类的组合为其他组件提供了良好的基础。让我们先在词法分析器中使用它们!

构建词法分析器

正如我们从前一章所知,我们需要一个Token类和一个Lexer类。此外,还需要一个TokenKind枚举,以给每个标记类一个唯一的编号。拥有一个全能的头文件和一个实现文件并不可扩展,所以让我们重新构建一下。TokenKind枚举可以被普遍使用,并放在Basic组件中。TokenLexer类属于Lexer组件,但放在不同的头文件和实现文件中。

有三种不同的标记类:CONST关键字,;分隔符和ident标记,代表源代码中的标识符。每个标记都需要一个枚举的成员名称。关键字和标点符号有自然的显示名称,可以用于消息。

与许多编程语言一样,关键字是标识符的子集。要将标记分类为关键字,我们需要一个关键字过滤器,检查找到的标识符是否确实是关键字。这与 C 或 C++中的行为相同,其中关键字也是标识符的子集。编程语言随着时间的推移而发展,可能会引入新的关键字。例如,最初的 K&R C 语言没有使用enum关键字定义枚举。因此,应该存在一个指示关键字的语言级别的标志。

我们收集了几个信息片段,所有这些信息都属于TokenKind枚举的成员:枚举成员的标签,标点符号的拼写以及关键字的标志。至于诊断消息,我们将信息集中存储在名为include/tinylang/Basic/TokenKinds.def.def文件中,如下所示。需要注意的一点是,关键字以kw_为前缀:

#ifndef TOK
#define TOK(ID)
#endif
#ifndef PUNCTUATOR
#define PUNCTUATOR(ID, SP) TOK(ID)
#endif
#ifndef KEYWORD
#define KEYWORD(ID, FLAG) TOK(kw_ ## ID)
#endif
TOK(unknown)
TOK(eof)
TOK(identifier)
TOK(integer_literal)
PUNCTUATOR(plus,                "+")
PUNCTUATOR(minus,               "-")
// …
KEYWORD(BEGIN                       , KEYALL)
KEYWORD(CONST                       , KEYALL)
// …
#undef KEYWORD
#undef PUNCTUATOR
#undef TOK

有了这些集中定义,很容易在include/tinylang/Basic/TokenKinds.h文件中创建TokenKind枚举。同样,枚举被放入自己的命名空间中,称为tok

#ifndef TINYLANG_BASIC_TOKENKINDS_H
#define TINYLANG_BASIC_TOKENKINDS_H
namespace tinylang {
namespace tok {
enum TokenKind : unsigned short {
#define TOK(ID) ID,
#include "TokenKinds.def"
  NUM_TOKENS
};

现在,您应该熟悉用于填充数组的模式。TOK宏被定义为仅返回枚举标签的ID。作为有用的补充,我们还将NUM_TOKENS定义为枚举的最后一个成员,表示定义的标记数量:

    const char *getTokenName(TokenKind Kind);
    const char *getPunctuatorSpelling(TokenKind Kind);
    const char *getKeywordSpelling(TokenKind Kind);
  }
}
#endif

实现文件lib/Basic/TokenKinds.cpp也使用.def文件来检索名称:

#include "tinylang/Basic/TokenKinds.h"
#include "llvm/Support/ErrorHandling.h"
using namespace tinylang;
static const char * const TokNames[] = {
#define TOK(ID) #ID,
#define KEYWORD(ID, FLAG) #ID,
#include "tinylang/Basic/TokenKinds.def"
  nullptr
};

标记的文本名称是从其枚举标签的ID派生的。有两个特殊之处。首先,我们需要定义TOKKEYWORD宏,因为KEYWORD的默认定义不使用TOK宏。其次,在数组的末尾添加了一个nullptr值,考虑到了添加的NUM_TOKENS枚举成员:

const char *tok::getTokenName(TokenKind Kind) {
  return TokNames[Kind];
}

对于getPunctuatorSpelling()getKeywordSpelling()函数,我们采用了稍微不同的方法。这些函数仅对枚举的子集返回有意义的值。这可以通过switch语句实现,它默认返回nullptr值:

const char *tok::getPunctuatorSpelling(TokenKind Kind) {
  switch (Kind) {
#define PUNCTUATOR(ID, SP) case ID: return SP;
#include "tinylang/Basic/TokenKinds.def"
    default: break;
  }
  return nullptr;
}
const char *tok::getKeywordSpelling(TokenKind Kind) {
  switch (Kind) {
#define KEYWORD(ID, FLAG) case kw_ ## ID: return #ID;
#include "tinylang/Basic/TokenKinds.def"
    default: break;
  }
  return nullptr;
}

提示

请注意如何定义宏以从文件中检索所需的信息。

在上一章中,Token类是在与Lexer类相同的头文件中声明的。为了使其更模块化,我们将Token类放入include/Lexer/Token.h的头文件中。与之前一样,Token存储了指向标记开头的指针,长度和标记的种类,如之前定义的那样:

class Token {
  friend class Lexer;
  const char *Ptr;
  size_t Length;
  tok::TokenKind Kind;
public:
  tok::TokenKind getKind() const { return Kind; }
  size_t getLength() const { return Length; }

SMLoc实例,表示消息中源的位置,是从标记的指针创建的:

  SMLoc getLocation() const {
    return SMLoc::getFromPointer(Ptr);
  }

getIdentifier()getLiteralData()方法允许我们访问标识符和文字数据的文本。对于任何其他标记类型,不需要访问文本,因为这是标记类型所暗示的:

  StringRef getIdentifier() {
    assert(is(tok::identifier) &&
           "Cannot get identfier of non-identifier");
    return StringRef(Ptr, Length);
  }
  StringRef getLiteralData() {
    assert(isOneOf(tok::integer_literal,
                   tok::string_literal) &&
           "Cannot get literal data of non-literal");
    return StringRef(Ptr, Length);
  }
};

我们在include/Lexer/Lexer.h头文件中声明了Lexer类,并将实现放在lib/Lexer/lexer.cpp文件中。结构与上一章的 calc 语言相同。在这里,我们必须仔细看两个细节:

  • 首先,有些运算符共享相同的前缀;例如,<<=。当我们正在查看的当前字符是<时,我们必须先检查下一个字符,然后再决定我们找到了哪个标记。请记住,我们要求输入以空字节结尾。因此,如果当前字符有效,下一个字符总是可以使用的:
    case '<':
      if (*(CurPtr + 1) == '=')
        formTokenWithChars(token, CurPtr + 2, tok::lessequal);
      else
        formTokenWithChars(token, CurPtr + 1, tok::less);
      break;
  • 另一个细节是,在这一点上,关键字要多得多。我们该如何处理?一个简单而快速的解决方案是用关键字填充一个哈希表,这些关键字都存储在TokenKinds.def文件中。这可以在我们实例化Lexer类的同时完成。在这种方法中,也可以支持语言的不同级别,因为关键字可以根据附加的标志进行过滤。在这里,这种灵活性还不需要。在头文件中,关键字过滤器定义如下,使用llvm::StringMap的实例作为哈希表:
class KeywordFilter {
  llvm::StringMap<tok::TokenKind> HashTable;
  void addKeyword(StringRef Keyword,
                  tok::TokenKind TokenCode);
public:
  void addKeywords();

getKeyword()方法返回给定字符串的标记类型,如果字符串不表示关键字,则返回默认值:

  tok::TokenKind getKeyword(
      StringRef Name,
      tok::TokenKind DefaultTokenCode = tok::unknown) {
    auto Result = HashTable.find(Name);
    if (Result != HashTable.end())
      return Result->second;
    return DefaultTokenCode;
  }
};

在实现文件中,关键字表被填充:

void KeywordFilter::addKeyword(StringRef Keyword,
                               tok::TokenKind TokenCode) 
{
  HashTable.insert(std::make_pair(Keyword, TokenCode));
}
void KeywordFilter::addKeywords() {
#define KEYWORD(NAME, FLAGS)                                 
addKeyword(StringRef(#NAME), tok::kw_##NAME);
#include "tinylang/Basic/TokenKinds.def"
}

有了这些技巧,编写一个高效的词法分析器类并不难。由于编译速度很重要,许多编译器使用手写的词法分析器,Clang 就是一个例子。

构建递归下降解析器

正如前一章所示,解析器是从其语法派生出来的。让我们回顾一下所有的构造规则。对于语法的每个规则,你都要创建一个方法,该方法的名称与规则左侧的非终端相同,以便解析规则的右侧。根据右侧的定义,你必须做到以下几点:

  • 对于每个非终端,都会调用相应的方法。

  • 每个标记都被消耗。

  • 对于替代和可选或重复组,会检查先行标记(下一个未消耗的标记)以决定我们可以从哪里继续。

让我们将这些构造规则应用到语法的以下规则上:

ifStatement
  : "IF" expression "THEN" statementSequence
    ( "ELSE" statementSequence )? "END" ;

我们可以很容易地将这个转换成以下的 C++方法:

void Parser::parseIfStatement() {
  consume(tok::kw_IF);
  parseExpression();
  consume(tok::kw_THEN);
  parseStatementSequence();
  if (Tok.is(tok::kw_ELSE)) {
    advance();
    parseStatementSequence();
  }
  consume(tok::kw_END);
}

这样可以将tinylang的整个语法转换为 C++。一般来说,你必须小心并避免一些陷阱。

要注意的一个问题是左递归规则。如果右侧开始的终端与左侧相同,则规则是左递归。一个典型的例子可以在表达式的语法中找到:

expression : expression "+" term ;

如果从语法中还不清楚,那么将其翻译成 C++应该很明显,这会导致无限递归:

Void Parser::parseExpression() {
  parseExpression();
  consume(tok::plus);
  parseTerm();
}

左递归也可能间接发生,并涉及更多的规则,这更难以发现。这就是为什么存在一种算法,可以检测和消除左递归。

在每一步,解析器只需使用先行标记就可以决定如何继续。如果这个决定不能被确定性地做出,那么就说语法存在冲突。为了说明这一点,让我们来看看 C#中的using语句。就像在 C++中一样,using语句可以用来使一个符号在命名空间中可见,比如using Math;。还可以为导入的符号定义别名;也就是说,using M = Math;。在语法中,这可以表示如下:

usingStmt : "using" (ident "=")? ident ";"

显然,这里存在一个问题。在解析器消耗了using关键字之后,先行标记是ident。但这个信息对我们来说不足以决定是否必须跳过或解析可选组。如果可选组的开始标记集与可选组后面的标记集重叠,那么这种情况总是会出现。

让我们用一个替代而不是一个可选组来重写规则:

usingStmt : "using" ( ident "=" ident | ident ) ";" ;

现在,有一个不同的冲突:两种选择都以相同的标记开头。只看先行标记,解析器无法确定哪个选择是正确的。

这些冲突非常常见。因此,了解如何处理它们是很好的。一种方法是以这样的方式重写语法,使冲突消失。在前面的例子中,两种选择都以相同的标记开头。这可以被分解出来,得到以下规则:

usingStmt : "using" ident ("=" ident)? ";" ;

这种表述没有冲突。但是,也应该注意到它的表达力较弱。在另外两种表述中,很明显哪个ident是别名,哪个ident是命名空间名称。在这个无冲突的规则中,最左边的ident改变了它的角色。首先,它是命名空间名称,但如果后面跟着一个等号(=),那么它就变成了别名。

第二种方法是添加一个额外的谓词来区分两种情况。这个谓词通常被称为Token &peek(int n)方法,它返回当前向前看标记之后的第 n 个标记。在这里,等号的存在可以作为决定的一个额外谓词:

if (Tok.is(tok::ident) && Lex.peek(0).is(tok::equal)) {
  advance();
  consume(tok::equal);
}
consume(tok::ident);

现在,让我们加入错误恢复。在上一章中,我介绍了所谓的恐慌模式作为错误恢复的一种技术。基本思想是跳过标记,直到找到适合继续解析的标记。例如,在tinylang中,一个语句后面跟着一个分号(;)。

如果在IF语句中有语法问题,那么你会跳过所有标记,直到找到一个分号为止。然后,你继续下一个语句。不要使用特定的标记集的临时定义,最好使用系统化的方法。

对于每个非终结符,计算可以跟随非终结符的任何地方的标记集(称为ELSEEND标记可以跟随)。因此,在parseStatement()的错误恢复部分中使用这个集合。这种方法假设可以在本地处理语法错误。一般来说,这是不可能的。因为解析器跳过标记,可能会跳过太多标记,导致到达输入的末尾。在这一点上,本地恢复是不可能的。

为了防止无意义的错误消息,调用方法需要被告知错误恢复仍然没有完成。这可以通过bool返回值来实现:true表示错误恢复尚未完成,而false表示解析(包括可能的错误恢复)成功完成。

有许多方法可以扩展这种错误恢复方案。一个流行的方法是还使用活动调用者的 FOLLOW 集。举个简单的例子,假设parseStatement()parseStatementSequence()调用,而后者被parseBlock()调用,而后者又被parseModule()调用。

在这里,每个相应的非终结符都有一个 FOLLOW 集。如果解析器在parseStatement()中检测到语法错误,那么会跳过标记,直到标记至少在活动调用者的 FOLLOW 集中的一个中。如果标记在语句的 FOLLOW 集中,那么错误会在本地恢复,向调用者返回一个false值。否则,返回一个true值,表示错误恢复必须继续。这种扩展的可能实现策略是将一个std::bitsetstd::tuple传递给被调用者,表示当前 FOLLOW 集的并集。

还有一个问题尚未解决:我们如何调用错误恢复?在上一章中,使用了goto来跳转到错误恢复块。这样做虽然有效,但并不是一个令人满意的解决方案。根据之前的讨论,我们可以在一个单独的方法中跳过标记。Clang 有一个名为skipUntil()的方法,可以用于这个目的,我们也可以在tinylang中使用这个方法。

因为接下来要向解析器添加语义动作,如果有必要,最好有一个集中的地方放置清理代码。嵌套函数对此来说是理想的。C++没有嵌套函数。相反,lambda 函数可以起到类似的作用。完整的错误恢复的parseIfStatement()方法如下所示:

bool Parser::parseIfStatement() {
  auto _errorhandler = [this] {
    return SkipUntil(tok::semi, tok::kw_ELSE, tok::kw_END);
  };
  if (consume(tok::kw_IF))
    return _errorhandler();
  if (parseExpression(E))
    return _errorhandler();
  if (consume(tok::kw_THEN))
    return _errorhandler();
  if (parseStatementSequence(IfStmts))
    return _errorhandler();
  if (Tok.is(tok::kw_ELSE)) {
    advance();
    if (parseStatementSequence(ElseStmts))
      return _errorhandler();
  }
  if (expect(tok::kw_END))
    return _errorhandler();
  return false;
}

使用 bison 和 flex 生成解析器和词法分析器

手动构建词法分析器和解析器并不困难,通常会产生快速的组件。缺点是很难引入更改,特别是在解析器中。如果您正在原型设计一种新的编程语言,这可能很重要。使用专门的工具可以缓解这个问题。

有许多可用的工具可以从规范文件生成词法分析器或解析器。在 Linux 世界中,flex (https://github.com/westes/flex) 和 bison (www.gnu.org/software/bison/) 是最常用的工具。Flex 从一组正则表达式生成词法分析器,而 bison 从语法描述生成解析器。通常,这两个工具一起使用。

Bison 生成的tinylang,存储在tinylang.yy文件中,以以下序言开始:

%require "3.2"
%language "c++"
%defines "Parser.h"
%define api.namespace {tinylang}
%define api.parser.class {Parser}
%define api.token.prefix {T_}
%token
  identifier integer_literal string_literal
  PLUS MINUS STAR SLASH 

我们使用%language指令指示 bison 生成 C++代码。使用%define指令,我们覆盖了一些代码生成的默认值:生成的类应该命名为Parser,并位于tinylang命名空间中。此外,表示标记种类的枚举成员应以T_为前缀。我们要求版本为 3.2 或更高,因为这些变量中的一些是在此版本中引入的。为了能够与 flex 交互,我们告诉 bison 使用%defines指令写入一个Parser.h头文件。最后,我们必须使用%token指令声明所有使用的标记。语法规则在%%之后:

%%
compilationUnit
  : MODULE identifier SEMI imports block identifier PERIOD ;
imports : %empty | import imports ;
import
  : FROM identifier IMPORT identList SEMI
  | IMPORT identList SEMI ;

请将这些规则与本章第一节中显示的语法规范进行比较。Bison 不知道重复组,因此我们需要添加一个称为imports的新规则来模拟这种重复。在import规则中,我们必须引入一个替代方案来模拟可选组。

我们还需要以这种方式重写tinylang语法的其他规则。例如,IF语句的规则变成了以下内容:

ifStatement
  : IF expression THEN statementSequence
    elseStatement END ;
elseStatement : %empty | ELSE statementSequence ;

同样,我们必须引入一个新规则来模拟可选的ELSE语句。%empty指令可以省略,但使用它可以清楚地表明这是一个空的替代分支。

一旦我们以 bison 风格重写了所有语法规则,就可以使用以下命令生成解析器:

$ bison tinylang.yy

这就是创建一个类似于上一节手写的解析器所需的全部内容!

同样,flex 易于使用。Flex 的规范是一系列正则表达式和相关联的操作,如果正则表达式匹配,则执行该操作。tinylang.l文件指定了tinylang的词法分析器。与 bison 规范一样,它以序言开始:

%{
#include "Parser.h"
%}
%option noyywrap nounput noinput batch
id       [a-zA-Z_][a-zA-Z_0-9]*
digit    [0-9]
hexdigit [0-9A-F]
space    [ \t\r]

%{ }%内的文本被复制到 flex 生成的文件中。我们使用这种机制来包含 bison 生成的头文件。使用%option指令,我们控制生成的词法分析器应具有哪些特性。我们只读取一个文件,不希望在到达文件末尾后继续读取另一个文件,因此我们指定noyywrap以禁用此功能。我们也不需要访问底层文件流,并使用nounputnoinout禁用它。最后,因为我们不需要交互式词法分析器,我们要求生成一个batch扫描器。

在序言中,我们还可以定义字符模式以供以后使用。在%%之后是定义部分:

%%
{space}+
{digit}+      return
                   tinylang::Parser::token::T_integer_literal;

在定义部分,您指定一个正则表达式模式和一个要执行的操作,如果模式匹配输入。操作也可以为空。

{space}+模式使用序言中定义的space字符模式。它匹配一个或多个空白字符。我们没有定义操作,因此所有空白将被忽略。

为了匹配一个数字,我们使用{digit}+模式。作为操作,我们只返回相关的标记种类。对于所有标记都是这样做的。例如,我们对算术运算符做如下操作:

"+"             return tinylang::Parser::token::T_PLUS;
"-"             return tinylang::Parser::token::T_MINUS;
"*"             return tinylang::Parser::token::T_STAR;
"/"             return tinylang::Parser::token::T_SLASH;

如果有多个模式匹配输入,则选择最长匹配的模式。如果仍然有多个模式匹配输入,则选择规范文件中按字典顺序排列的第一个模式。这就是为什么首先定义关键字的模式,然后才定义标识符的模式很重要:

"VAR"           return tinylang::Parser::token::T_VAR;
"WHILE"         return tinylang::Parser::token::T_WHILE;
{id}            return tinylang::Parser::token::T_identifier;

这些操作不仅仅限于return语句。如果你的代码需要多于一行,那么你必须用大括号{ }括起你的代码。

扫描器是用以下命令生成的:

$ flex –c++ tinylang.l

你的语言项目应该使用哪种方法?解析器生成器通常生成 LALR(1)解析器。LALR(1)类比 LL(1)类更大,递归下降解析器可以构造。如果无法调整语法使其适合 LL(1)类,则应考虑使用解析器生成器。手动构造这样的自底向上解析器是不可行的。即使你的语法是 LL(1),解析器生成器提供了更多的舒适性,同时生成的代码与手动编写的代码相似。通常,这是受许多因素影响的选择。Clang 使用手写解析器,而 GCC 使用 bison 生成的解析器。

执行语义分析

在前一节中构造的解析器只检查输入的语法。下一步是添加执行语义分析的能力。在上一章的 calc 示例中,解析器构造了一个 AST。在单独的阶段,语义分析器对这棵树进行了处理。这种方法总是可以使用的。在本节中,我们将使用稍微不同的方法,更加交织解析器和语义分析器。

这些是语义分析器必须执行的一些任务:

  • 对于每个声明,语义分析器必须检查所使用的名称是否已经在其他地方声明过。

  • 对于表达式或语句中的每个名称出现,语义分析器必须检查名称是否已声明,并且所需的使用是否符合声明。

  • 对于每个表达式,语义分析器必须计算结果类型。还需要计算表达式是否是常量,如果是,还需要计算它的值。

  • 对于赋值和参数传递,语义分析器必须检查类型是否兼容。此外,我们必须检查IFWHILE语句中的条件是否为BOOLEAN类型。

对于编程语言的这么小的子集来说,这已经是很多要检查的了!

处理名称的作用域

让我们先来看看名称的作用域。名称的作用域是名称可见的范围。像 C 一样,tinylang使用先声明后使用的模型。例如,BX变量在模块级别声明,因此它们是INTEGER类型:

VAR B, X: INTEGER;

在声明之前,变量是未知的,不能使用。只有在声明之后才能使用。在过程内,可以声明更多的变量:

PROCEDURE Proc;
VAR B: BOOLEAN;
BEGIN
  (* Statements *)
END Proc;

在此过程内,在注释所在的位置,使用B指的是局部变量B,而使用X指的是全局变量X。局部变量B的作用域是Proc过程。如果在当前作用域中找不到名称,则在封闭作用域中继续搜索。因此,X变量可以在过程内使用。在tinylang中,只有模块和过程会打开新的作用域。其他语言构造,如structclass通常也会打开作用域。预定义实体,如INTEGER类型或TRUE文字,是在全局作用域中声明的,包围模块的作用域。

tinylang中,只有名称是关键的。因此,作用域可以实现为名称到其声明的映射。只有当名称不存在时才能插入新名称。对于查找,还必须知道封闭或父作用域。接口(在include/tinylang/Sema/Scope.h文件中)如下:

#ifndef TINYLANG_SEMA_SCOPE_H
#define TINYLANG_SEMA_SCOPE_H
#include "tinylang/Basic/LLVM.h"
#include "llvm/ADT/StringMap.h"
#include "llvm/ADT/StringRef.h"
namespace tinylang {
class Decl;
class Scope {
  Scope *Parent;
  StringMap<Decl *> Symbols;
public:
  Scope(Scope *Parent = nullptr) : Parent(Parent) {}
  bool insert(Decl *Declaration);
  Decl *lookup(StringRef Name);
  Scope *getParent() { return Parent; }
};
} // namespace tinylang
#endif

lib/Sema/Scope.cpp文件中的实现如下:

#include "tinylang/Sema/Scope.h"
#include "tinylang/AST/AST.h"
using namespace tinylang;
bool Scope::insert(Decl *Declaration) {
  return Symbols
      .insert(std::pair<StringRef, Decl *>(
          Declaration->getName(), Declaration))
      .second;
}

请注意,StringMap::insert()方法不会覆盖现有条目。结果std::pairsecond成员指示表是否已更新。此信息返回给调用者。

为了实现符号声明的搜索,lookup()方法搜索当前作用域;如果找不到任何内容,则搜索由parent成员链接的作用域:

Decl *Scope::lookup(StringRef Name) {
  Scope *S = this;
  while (S) {
    StringMap<Decl *>::const_iterator I =
        S->Symbols.find(Name);
    if (I != S->Symbols.end())
      return I->second;
    S = S->getParent();
  }
  return nullptr;
}

然后变量声明如下处理:

  • 当前作用域是模块作用域。

  • 查找INTEGER类型声明。如果找不到声明或者它不是类型声明,则会出错。

  • 实例化一个新的 AST 节点VariableDeclaration,重要属性是名称B和类型。

  • 名称B被插入到当前作用域中,映射到声明实例。如果名称已经存在于作用域中,则这是一个错误。在这种情况下,当前作用域的内容不会改变。

  • X变量也是同样的操作。

这里执行了两项任务。就像在 calc 示例中一样,构造了 AST 节点。同时,计算了节点的属性,例如其类型。为什么这是可能的?

语义分析器可以回退到两组不同的属性。作用域从调用者那里继承。类型声明可以通过评估类型声明的名称来计算(或合成)。语言设计成这样的方式,这两组属性足以计算 AST 节点的所有属性。

其中一个重要方面是先声明后使用模型。如果一种语言允许在声明之前使用名称,例如 C++中类内的成员,那么不可能一次计算 AST 节点的所有属性。在这种情况下,AST 节点必须只用部分计算的属性或纯粹的信息(例如在 calc 示例中)构造。

AST 必须被访问一次或多次以确定缺失的信息。在tinylang(和 Modula-2)的情况下,也可以不使用 AST 构造 - AST 是通过parseXXX()方法的调用层次结构间接表示的。从 AST 生成代码更为常见,因此我们也在这里构造了一个 AST。

在我们将所有部分放在一起之前,我们需要了解 LLVM 使用运行时类型信息(RTTI)的风格。

在 AST 中使用 LLVM 风格的 RTTI

当然,AST 节点是类层次结构的一部分。声明总是有一个名称。其他属性取决于正在声明的内容。如果声明了变量,则需要一个类型。常量声明需要一个类型和一个值,依此类推。当然,在运行时,您需要找出正在处理的声明的类型。可以使用dynamic_cast<> C++运算符来实现这一点。问题在于,如果 C++类附有虚表,即使用虚函数,则所需的 RTTI 才可用;另一个缺点是 C++ RTTI 过于臃肿。为了避免这些缺点,LLVM 开发人员引入了一种自制的 RTTI 风格,该风格在整个 LLVM 库中使用。

我们层次结构的(抽象)基类是Decl。为了实现 LLVM 风格的 RTTI,需要添加一个包含每个子类标签的公共枚举。此外,还需要一个私有成员和一个公共 getter 方法。私有成员通常称为Kind。在我们的情况下,看起来是这样的:

class Decl {
public:
  enum DeclKind { DK_Module, DK_Const, DK_Type,
                  DK_Var, DK_Param, DK_Proc };
private:
  const DeclKind Kind;
public:
  DeclKind getKind() const { return Kind; }
};

现在每个子类都需要一个名为classof的特殊函数成员。此函数的目的是确定给定实例是否是请求的类型。对于VariableDeclaration,它的实现如下:

static bool classof(const Decl *D) {
  return D->getKind() == DK_Var;
}

现在,您可以使用llvm::isa<>特殊模板来检查对象是否是请求类型的对象,并使用llvm::dyn_cast<>来动态转换对象。还有更多的模板存在,但这两个是最常用的。有关其他模板,请参见llvm.org/docs/ProgrammersManual.html#the-isa-cast-and-dyn-cast-templates,有关 LLVM 样式的更多信息,包括更高级的用法,请参见llvm.org/docs/HowToSetUpLLVMStyleRTTI.html

创建语义分析器

有了这些知识,我们现在可以实现语义分析器,它操作由解析器创建的 AST 节点。首先,我们将实现存储在include/llvm/tinylang/AST/AST.h文件中的变量的 AST 节点的定义。除了支持 LLVM 样式的 RTTI 外,基类还存储声明的名称、名称的位置和指向封闭声明的指针。后者是为了生成嵌套过程所必需的。Decl基类声明如下:

class Decl {
public:
  enum DeclKind { DK_Module, DK_Const, DK_Type,
                  DK_Var, DK_Param, DK_Proc };
private:
  const DeclKind Kind;
protected:
  Decl *EnclosingDecL;
  SMLoc Loc;
  StringRef Name;
public:
  Decl(DeclKind Kind, Decl *EnclosingDecL, SMLoc Loc,
       StringRef Name)
      : Kind(Kind), EnclosingDecL(EnclosingDecL), Loc(Loc),
        Name(Name) {}
  DeclKind getKind() const { return Kind; }
  SMLoc getLocation() { return Loc; }
  StringRef getName() { return Name; }
  Decl *getEnclosingDecl() { return EnclosingDecL; }
};

变量的声明只是添加了指向类型声明的指针:

class TypeDeclaration;
class VariableDeclaration : public Decl {
  TypeDeclaration *Ty;
public:
  VariableDeclaration(Decl *EnclosingDecL, SMLoc Loc,
                      StringRef Name, TypeDeclaration *Ty)
      : Decl(DK_Var, EnclosingDecL, Loc, Name), Ty(Ty) {}
  TypeDeclaration *getType() { return Ty; }
  static bool classof(const Decl *D) {
    return D->getKind() == DK_Var;
  }
};

解析器中的方法需要扩展语义动作和已收集信息的变量:

bool Parser::parseVariableDeclaration(DeclList &Decls) {
  auto _errorhandler = [this] {
    while (!Tok.is(tok::semi)) {
      advance();
      if (Tok.is(tok::eof)) return true;
    }
    return false;
  };
  Decl *D = nullptr; IdentList Ids;
  if (parseIdentList(Ids)) return _errorhandler();
  if (consume(tok::colon)) return _errorhandler();
  if (parseQualident(D)) return _errorhandler();
  Actions.actOnVariableDeclaration(Decls, Ids, D);
  return false;
}

DeclList是一个称为std::vector<Decl*>的声明列表,而IdentList是一个称为std::vector<std::pair<SMLoc, StringRef>>的位置和标识符列表。

parseQualident()方法返回一个声明,在这种情况下,预期是一个类型声明。

解析器类知道语义分析器类Sema的一个实例,它存储在Actions成员中。调用actOnVariableDeclaration()运行语义分析器和 AST 构造。实现在lib/Sema/Sema.cpp文件中:

void Sema::actOnVariableDeclaration(DeclList &Decls,
                                    IdentList &Ids,
                                    Decl *D) {
  if (TypeDeclaration *Ty = dyn_cast<TypeDeclaration>(D)) {
    for (auto I = Ids.begin(), E = Ids.end(); I != E; ++I) {
      SMLoc Loc = I->first;
      StringRef Name = I->second;
      VariableDeclaration *Decl = new VariableDeclaration(
          CurrentDecl, Loc, Name, Ty);
      if (CurrentScope->insert(Decl))
        Decls.push_back(Decl);
      else
        Diags.report(Loc, diag::err_symbold_declared, Name);
    }
  } else if (!Ids.empty()) {
    SMLoc Loc = Ids.front().first;
    Diags.report(Loc, diag::err_vardecl_requires_type);
  }
}

首先,使用llvm::dyn_cast<TypeDeclaration>检查类型声明。如果它不是类型声明,则打印错误消息。否则,对于Ids列表中的每个名称,实例化一个VariableDeclaration并将其添加到声明列表中。如果将变量添加到当前作用域失败,因为名称已经被声明,则打印错误消息。

大多数其他实体以相同的方式构建,它们的语义分析复杂性是唯一的区别。模块和过程需要更多的工作,因为它们打开了一个新的作用域。打开一个新的作用域很容易:只需实例化一个新的Scope对象。一旦模块或过程被解析,作用域必须被移除。

这必须以可靠的方式完成,因为我们不希望在语法错误的情况下将名称添加到错误的作用域中。这是 C++中资源获取即初始化RAII)习惯用法的经典用法。另一个复杂性来自于过程可以递归调用自身的事实。因此,必须在使用之前将过程的名称添加到当前作用域中。语义分析器有两种方法可以进入和离开作用域。作用域与声明相关联:

void Sema::enterScope(Decl *D) {
  CurrentScope = new Scope(CurrentScope);
  CurrentDecl = D;
}
void Sema::leaveScope() {
  Scope *Parent = CurrentScope->getParent();
  delete CurrentScope;
  CurrentScope = Parent;
  CurrentDecl = CurrentDecl->getEnclosingDecl();
}

一个简单的辅助类用于实现 RAII 习惯用法:

class EnterDeclScope {
  Sema &Semantics;
public:
  EnterDeclScope(Sema &Semantics, Decl *D)
      : Semantics(Semantics) {
    Semantics.enterScope(D);
  }
  ~EnterDeclScope() { Semantics.leaveScope(); }
};

在解析模块或过程时,现在有两种与语义分析器的交互。第一种是在解析名称之后。在这里,(几乎为空的)AST 节点被构造,并建立了一个新的作用域:

bool Parser::parseProcedureDeclaration(/* … */) {
  /* … */
  if (consume(tok::kw_PROCEDURE)) return _errorhandler();
  if (expect(tok::identifier)) return _errorhandler();
  ProcedureDeclaration *D =
      Actions.actOnProcedureDeclaration(
          Tok.getLocation(), Tok.getIdentifier());
  EnterDeclScope S(Actions, D);
  /* … */
}

语义分析器不仅仅是检查当前作用域中的名称并返回 AST 节点:

ProcedureDeclaration *
Sema::actOnProcedureDeclaration(SMLoc Loc, StringRef Name) {
  ProcedureDeclaration *P =
      new ProcedureDeclaration(CurrentDecl, Loc, Name);
  if (!CurrentScope->insert(P))
    Diags.report(Loc, diag::err_symbold_declared, Name);
  return P;
}

一旦所有声明和过程的主体被解析,真正的工作就开始了。基本上,语义分析器只需要检查过程声明末尾的名称是否等于过程的名称,并且用于返回类型的声明是否真的是一个类型声明:

void Sema::actOnProcedureDeclaration(
    ProcedureDeclaration *ProcDecl, SMLoc Loc,
    StringRef Name, FormalParamList &Params, Decl *RetType,
    DeclList &Decls, StmtList &Stmts) {
  if (Name != ProcDecl->getName()) {
    Diags.report(Loc, diag::err_proc_identifier_not_equal);
    Diags.report(ProcDecl->getLocation(),
                 diag::note_proc_identifier_declaration);
  }
  ProcDecl->setDecls(Decls);
  ProcDecl->setStmts(Stmts);
  auto RetTypeDecl =
      dyn_cast_or_null<TypeDeclaration>(RetType);
  if (!RetTypeDecl && RetType)
    Diags.report(Loc, diag::err_returntype_must_be_type,
                 Name);
  else
    ProcDecl->setRetType(RetTypeDecl);
}

一些声明是固有的,无法由开发人员定义。这包括BOOLEANINTEGER类型以及TRUEFALSE字面量。这些声明存在于全局范围,并且必须以编程方式添加。Modula-2 还预定义了一些过程,例如INCDEC,这些过程也应该添加到全局范围。鉴于我们的类,全局范围的初始化很简单:

void Sema::initialize() {
  CurrentScope = new Scope();
  CurrentDecl = nullptr;
  IntegerType =
      new TypeDeclaration(CurrentDecl, SMLoc(), "INTEGER");
  BooleanType =
      new TypeDeclaration(CurrentDecl, SMLoc(), "BOOLEAN");
  TrueLiteral = new BooleanLiteral(true, BooleanType);
  FalseLiteral = new BooleanLiteral(false, BooleanType);
  TrueConst = new ConstantDeclaration(CurrentDecl, SMLoc(),
                                      "TRUE", TrueLiteral);
  FalseConst = new ConstantDeclaration(
      CurrentDecl, SMLoc(), "FALSE", FalseLiteral);
  CurrentScope->insert(IntegerType);
  CurrentScope->insert(BooleanType);
  CurrentScope->insert(TrueConst);
  CurrentScope->insert(FalseConst);
}

有了这个方案,tinylang的所有必需计算都可以完成。例如,要计算表达式是否产生常量值,您必须确保发生以下情况:

  • 字面量或对常量声明的引用是常量。

  • 如果表达式的两侧都是常量,那么应用运算符也会产生一个常量。

这些规则很容易嵌入到语义分析器中,同时为表达式创建 AST 节点。同样,类型和常量值可以被计算。

应该指出,并非所有类型的计算都可以以这种方式完成。例如,要检测未初始化变量的使用,可以使用一种称为符号解释的方法。在其一般形式中,该方法需要通过 AST 的特殊遍历顺序,这在构建时是不可能的。好消息是,所提出的方法创建了一个完全装饰的 AST,可以用于代码生成。当然,可以根据需要打开或关闭昂贵的分析,这个 AST 当然可以用于进一步的分析。

要玩转前端,您还需要更新驱动程序。由于缺少代码生成,正确的tinylang程序不会产生任何输出。但是,它可以用来探索错误恢复并引发语义错误:

#include "tinylang/Basic/Diagnostic.h"
#include "tinylang/Basic/Version.h"
#include "tinylang/Parser/Parser.h"
#include "llvm/Support/InitLLVM.h"
#include "llvm/Support/raw_ostream.h"
using namespace tinylang;
int main(int argc_, const char **argv_) {
  llvm::InitLLVM X(argc_, argv_);
  llvm::SmallVector<const char *, 256> argv(argv_ + 1,
                                            argv_ + argc_);
  llvm::outs() << "Tinylang "
               << tinylang::getTinylangVersion() << "\n";
  for (const char *F : argv) {
    llvm::ErrorOr<std::unique_ptr<llvm::MemoryBuffer>>
        FileOrErr = llvm::MemoryBuffer::getFile(F);
    if (std::error_code BufferError =
            FileOrErr.getError()) {
      llvm::errs() << "Error reading " << F << ": "
                   << BufferError.message() << "\n";
      continue;
    }
    llvm::SourceMgr SrcMgr;
    DiagnosticsEngine Diags(SrcMgr);
    SrcMgr.AddNewSourceBuffer(std::move(*FileOrErr),
                              llvm::SMLoc());
    auto lexer = Lexer(SrcMgr, Diags);
    auto sema = Sema(Diags);
    auto parser = Parser(lexer, sema);
    parser.parse();
  }
}

恭喜!您已经完成了对tinylang前端的实现!

现在,让我们尝试一下我们到目前为止学到的东西。保存以下源代码,这是欧几里德最大公约数算法的实现,保存为Gcd.mod文件:

MODULE Gcd;
PROCEDURE GCD(a, b: INTEGER):INTEGER;
VAR t: INTEGER;
BEGIN
  IF b = 0 THEN RETURN a; END;
  WHILE b # 0 DO
    t := a MOD b;
    a := b;
    b := t;
  END;
  RETURN a;
END GCD;
END Gcd.

让我们用以下命令在这个文件上运行编译器:

$ tinylang Gcm.mod
Tinylang 0.1

除了打印版本号之外,没有其他输出。这是因为只实现了前端部分。但是,如果更改源代码以包含语法错误,那么将打印错误消息。

我们将在下一章讨论代码生成,继续这个有趣的话题。

总结

在本章中,您了解了现实世界编译器在前端使用的技术。从项目的布局开始,您为词法分析器、解析器和语义分析器创建了单独的库。为了向用户输出消息,您扩展了现有的 LLVM 类,这允许消息被集中存储。词法分析器现在已经分成了几个接口。

然后,您学会了如何从语法描述中构建递归下降解析器,要避免哪些陷阱,以及如何使用生成器来完成工作。您构建的语义分析器执行了语言所需的所有语义检查,同时与解析器和 AST 构造交织在一起。

您的编码工作的结果是一个完全装饰的 AST,将在下一章中用于生成 IR 代码和目标代码。

第五章:IR 代码生成基础

创建了装饰的抽象语法树AST)用于您的编程语言后,下一个任务是从中生成 LLVM IR 代码。LLVM IR 代码类似于三地址代码,具有人类可读的表示。因此,我们需要一个系统化的方法来将语言概念,如控制结构,转换为 LLVM IR 的较低级别。

在本章中,您将学习 LLVM IR 的基础知识,以及如何从 AST 中为控制流结构生成 IR。您还将学习如何使用现代算法以静态单赋值SSA形式为表达式生成 LLVM IR。最后,您将学习如何发出汇编文本和目标代码。

本章将涵盖以下主题:

  • 从 AST 生成 IR

  • 使用 AST 编号以 SSA 形式生成 IR 代码

  • 设置模块和驱动程序

在本章结束时,您将掌握创建自己的编程语言的代码生成器的知识,以及如何将其集成到自己的编译器中。

技术要求

本章的代码文件可在github.com/PacktPublishing/Learn-LLVM-12/tree/master/Chapter05/tinylang找到

您可以在bit.ly/3nllhED找到代码的实际操作视频

从 AST 生成 IR

LLVM 代码生成器将模块作为 IR 的描述输入,并将其转换为目标代码或汇编文本。我们需要将 AST 表示转换为 IR。为了实现 IR 代码生成器,我们将首先查看一个简单的示例,然后开发所需的类:CodeGeneratorCGModuleCGProcedure类。CodeGenerator类是编译器驱动程序使用的通用接口。CGModuleCGProcedure类保存了为编译单元和单个函数生成 IR 代码所需的状态。

我们将从下一节开始查看clang生成的 IR。

理解 IR 代码

在生成 IR 代码之前,了解 IR 语言的主要元素是很有用的。在[第三章](B15647_03_ePub_RK.xhtml#_idTextAnchor048)编译器的结构中,我们已经简要地看了 IR。了解 IR 的更多知识的简单方法是研究clang的输出。例如,保存这个 C 源代码,它实现了欧几里德算法来计算两个数的最大公约数,命名为gcd.c

unsigned gcd(unsigned a, unsigned b) {
  if (b == 0)
    return a;
  while (b != 0) {
    unsigned t = a % b;
    a = b;
    b = t;
  }
  return a;
}

然后,您可以使用以下命令创建 IR 文件gcd.ll

$ clang --target=aarch64-linux-gnu –O1 -S -emit-llvm gcd.c

IR 代码并非目标无关,即使它看起来经常是这样。前面的命令在 Linux 上为 ARM 64 位 CPU 编译源文件。-S选项指示clang输出一个汇编文件,并通过额外的-emit-llvm规范创建一个 IR 文件。优化级别-O1用于获得易于阅读的 IR 代码。让我们来看看生成的文件,并了解 C 源代码如何映射到 IR。在文件顶部,建立了一些基本属性:

; ModuleID = 'gcd.c'
source_filename = "gcd.c"
target datalayout = "e-m:e-i8:8:32-i16:16:32-i64:64-
                     i128:128-n32:64-S128"
target triple = "aarch64-unknown-linux-gnu"

第一行是一个注释,告诉您使用了哪个模块标识符。在下一行,命名了源文件的文件名。使用clang,两者是相同的。

target datalayout字符串建立了一些基本属性。它的部分由-分隔。包括以下信息:

  • 小写的e表示内存中的字节使用小端模式存储。要指定大端模式,使用大写的E

  • m:指定应用于符号的名称修饰。这里,m:e表示使用了 ELF 名称修饰。

  • iN:A:P形式的条目,例如i8:8:32,指定了以位为单位的数据对齐。第一个数字是 ABI 所需的对齐,第二个数字是首选对齐。对于字节(i8),ABI 对齐是 1 字节(8),首选对齐是 4 字节(32)。

  • n指定了可用的本机寄存器大小。n32:64表示本机支持 32 位和 64 位宽整数。

  • S指定了堆栈的对齐方式,同样是以位为单位。S128表示堆栈保持 16 字节对齐。

注意

目标数据布局可以提供更多的信息。您可以在参考手册中找到完整的信息,网址为llvm.org/docs/LangRef.html#data-layout

最后,target triple字符串指定了我们正在编译的架构。这对于我们在命令行上提供的信息至关重要。您将在第二章中找到对 triple 的更深入讨论,LLVM 源码之旅

接下来,在 IR 文件中定义了gcd函数。

define i32 @gcd(i32 %a, i32 %b) {

这类似于 C 文件中的函数签名。unsigned数据类型被翻译为 32 位整数类型i32。函数名以@为前缀,参数名以%为前缀。函数体用大括号括起来。函数体的代码如下:

entry:
  %cmp = icmp eq i32 %b, 0
  br i1 %cmp, label %return, label %while.body

IR 代码是以所谓的entry组织的。块中的代码很简单:第一条指令将参数%b0进行比较。如果条件为true,第二条指令将分支到标签return,如果条件为false,则分支到标签while.body

IR 代码的另一个特点是它在%cmp中。这个寄存器随后被使用,但再也没有被写入。像常量传播和公共子表达式消除这样的优化在 SSA 形式下工作得非常好,所有现代编译器都在使用它。

下一个基本块是while循环的主体:

while.body:
  %b.addr.010 = phi i32 [ %rem, %while.body ],
                        [ %b, %entry ]
  %a.addr.09 = phi i32 [ %b.addr.010, %while.body ],
                       [ %a, %entry ]
  %rem = urem i32 %a.addr.09, %b.addr.010
  %cmp1 = icmp eq i32 %rem, 0
  br i1 %cmp1, label %return, label %while.body

gcd的循环中,ab参数被分配了新的值。如果一个寄存器只能写一次,那么这是不可能的。解决方案是使用特殊的phi指令。phi指令有一个基本块和值的参数列表。基本块表示来自该基本块的入边,值是来自这些基本块的值。在运行时,phi指令将先前执行的基本块的标签与参数列表中的标签进行比较。

然后指令的值就是与标签相关联的值。对于第一个phi指令,如果先前执行的基本块是while.body,那么值就是寄存器%rem。如果entry是先前执行的基本块,那么值就是%b。这些值是基本块开始时的值。寄存器%b.addr.010从第一个phi指令中获得一个值。同一个寄存器在第二个phi指令的参数列表中使用,但在通过第一个phi指令更改之前,假定值是之前的值。

在循环主体之后,必须选择返回值:

return:
  %retval.0 = phi i32 [ %a, %entry ],
                      [ %b.addr.010, %while.body ]
  ret i32 %retval.0
}

再次,phi指令用于选择所需的值。ret指令不仅结束了这个基本块,还表示了运行时这个函数的结束。它将返回值作为参数。

关于使用phi指令有一些限制。它们必须是基本块的第一条指令。第一个基本块是特殊的:它没有先前执行的块。因此,它不能以phi指令开始。

IR 代码本身看起来很像 C 和汇编语言的混合体。尽管有这种熟悉的风格,但我们不清楚如何轻松地从 AST 生成 IR 代码。特别是phi指令看起来很难生成。但不要害怕。在下一节中,我们将实现一个简单的算法来做到这一点!

了解负载和存储方法

LLVM 中的所有局部优化都是基于这里显示的 SSA 形式。对于全局变量,使用内存引用。IR 语言知道加载和存储指令,用于获取和存储这些值。您也可以用于局部变量。这些指令不是 SSA 形式,LLVM 知道如何将它们转换为所需的 SSA 形式。因此,您可以为每个局部变量分配内存插槽,并使用加载和存储指令来更改它们的值。您只需要记住存储变量的内存插槽的指针。事实上,clang 编译器使用了这种方法。

让我们看一下带有加载和存储的 IR 代码。再次编译gcd.c,这次不启用优化:

$ clang --target=aarch64-linux-gnu -S -emit-llvm gcd.c

gcd函数现在看起来不同了。这是第一个基本块:

define i32 @gcd(i32, i32) {
  %3 = alloca i32, align 4
  %4 = alloca i32, align 4
  %5 = alloca i32, align 4
  %6 = alloca i32, align 4
  store i32 %0, i32* %4, align 4
  store i32 %1, i32* %5, align 4
  %7 = load i32, i32* %5, align 4
  %8 = icmp eq i32 %7, 0
  br i1 %8, label %9, label %11

IR 代码现在传递了寄存器和标签的自动编号。参数的名称没有指定。隐式地,它们是%0%1。基本块没有标签,所以它被分配为2。第一条指令为四个 32 位值分配了内存。之后,参数%0%1被存储在寄存器%4%5指向的内存插槽中。为了执行参数%10的比较,该值被显式地从内存插槽中加载。通过这种方法,您不需要使用phi指令!相反,您从内存插槽中加载一个值,对其进行计算,并将新值存储回内存插槽。下次读取内存插槽时,您将得到最后计算出的值。gcd函数的所有其他基本块都遵循这种模式。

以这种方式使用加载和存储指令的优势在于生成 IR 代码相当容易。缺点是您会生成大量 IR 指令,LLVM 会在将基本块转换为 SSA 形式后的第一个优化步骤中使用mem2reg pass 来删除这些指令。因此,我们直接生成 SSA 形式的 IR 代码。

我们开始通过将控制流映射到基本块来生成 IR 代码。

将控制流映射到基本块

如前一节所述,一个良好形成的基本块只是指令的线性序列。一个基本块可以以phi指令开始,并且必须以分支指令结束。在基本块内部,不允许有phi或分支指令。每个基本块都有一个标签,标记基本块的第一条指令。标签是分支指令的目标。您可以将分支视为两个基本块之间的有向边,从而得到控制流图CFG)。一个基本块可以有前任者后继者。函数的第一个基本块在没有前任者的意义上是特殊的。

由于这些限制,源语言的控制语句,如WHILEIF,会产生多个基本块。让我们看一下WHILE语句。WHILE语句的条件控制着循环体或下一条语句是否执行。条件必须在自己的基本块中生成,因为有两个前任者:

  • WHILE循环之前的语句产生的基本块

  • 从循环体末尾返回到条件的分支

还有两个后继者:

  • 循环体的开始

  • WHILE循环后面的语句产生的基本块

循环体本身至少有一个基本块:

图 5.1 - WHILE 语句的基本块

图 5.1 - WHILE 语句的基本块

IR 代码生成遵循这种结构。我们在CGProcedure类中存储当前基本块的指针,并使用llvm::IRBuilder<>的实例将指令插入基本块。首先,我们创建基本块:

void emitStmt(WhileStatement *Stmt) {
  llvm::BasicBlock *WhileCondBB = llvm::BasicBlock::Create(
      getLLVMCtx(), "while.cond", Fn);
  llvm::BasicBlock *WhileBodyBB = llvm::BasicBlock::Create(
      getLLVMCtx(), "while.body", Fn);
  llvm::BasicBlock *AfterWhileBB = 
    llvm::BasicBlock::Create(
      getLLVMCtx(), "after.while", Fn);

Fn变量表示当前函数,getLLVMCtx()返回 LLVM 上下文。这两者稍后设置。我们以一个分支结束当前基本块,该分支将保持条件:

  Builder.CreateBr(WhileCondBB);

条件的基本块成为新的当前基本块。我们生成条件并以条件分支结束块:

  setCurr(WhileCondBB);
  llvm::Value *Cond = emitExpr(Stmt->getCond());
  Builder.CreateCondBr(Cond, WhileBodyBB, AfterWhileBB);

接下来,我们生成循环体。作为最后一条指令,我们添加一个分支回到条件的基本块:

  setCurr(WhileBodyBB);
  emit(Stmt->getWhileStmts());
  Builder.CreateBr(WhileCondBB);

这结束了WHILE语句的生成。WHILE语句后的空基本块成为新的当前基本块:

  setCurr(AfterWhileBB);
}

按照这个模式,你可以为源语言的每个语句创建一个emit()方法。

使用 AST 编号生成 SSA 形式的 IR 代码

为了从 AST 中生成 SSA 形式的 IR 代码,我们使用一种称为AST 编号的方法。基本思想是对于每个基本块,我们存储在该基本块中写入的本地变量的当前值。

尽管它很简单,我们仍然需要几个步骤。首先我们将介绍所需的数据结构,然后我们将实现读写基本块内的本地值。然后我们将处理在几个基本块中使用的值,并最后优化创建的phi指令。

定义保存值的数据结构

我们使用struct BasicBlockDef来保存单个块的信息:

struct BasicBlockDef {
llvm::DenseMap<Decl *, llvm::TrackingVH<llvm::Value>> Defs;
// ...
};

LLVM 类llvm::Value表示 SSA 形式中的值。Value类就像计算结果的标签。它通常通过 IR 指令创建一次,然后被后续使用。在各种优化过程中可能会发生变化。例如,如果优化器检测到值%1%2始终相同,那么它可以用%1替换%2的使用。基本上,这改变了标签,但不改变计算。为了意识到这样的变化,我们不能直接使用Value类。相反,我们需要一个值句柄。有不同功能的值句柄。为了跟踪替换,我们使用llvm::TrackingVH<>类。因此,Defs成员将 AST 的声明(变量或形式参数)映射到其当前值。现在我们需要为每个基本块存储这些信息:

llvm::DenseMap<llvm::BasicBlock *, BasicBlockDef> 
  CurrentDef;

有了这种数据结构,我们现在能够处理本地值。

读写基本块内的本地值

要在基本块中存储本地变量的当前值,我们只需在映射中创建一个条目:

void writeLocalVariable(llvm::BasicBlock *BB, Decl *Decl,
                        llvm::Value *Val) {
  CurrentDef[BB].Defs[Decl] = Val;
}

查找变量的值有点复杂,因为该值可能不在基本块中。在这种情况下,我们需要扩展搜索到前驱,使用可能的递归搜索:

llvm::Value *
readLocalVariable(llvm::BasicBlock *BB, Decl *Decl) {
  auto Val = CurrentDef[BB].Defs.find(Decl);
  if (Val != CurrentDef[BB].Defs.end())
    return Val->second;
  return readLocalVariableRecursive(BB, Decl);
}

真正的工作是搜索前驱,这在下一节中实现。

搜索前驱块的值

如果我们正在查看的当前基本块只有一个前驱,那么我们在那里搜索变量的值。如果基本块有几个前驱,那么我们需要在所有这些块中搜索该值并组合结果。要说明这种情况,可以看看上一节中WHILE语句的条件的基本块。

这个基本块有两个前驱 - 一个是WHILE循环之前的语句产生的,另一个是WHILE循环体结束的分支产生的。在条件中使用的变量应该有一个初始值,并且很可能在循环体中被改变。因此,我们需要收集这些定义并从中创建一个phi指令。从WHILE语句创建的基本块包含一个循环。

因为我们递归搜索前驱块,我们必须打破这个循环。为此,我们使用一个简单的技巧。我们插入一个空的phi指令并记录这个作为变量的当前值。如果我们在搜索中再次看到这个基本块,那么我们会发现变量有一个值,我们会使用它。搜索在这一点停止。收集到所有的值后,我们必须更新phi指令。

我们仍然会面临一个问题。在查找时,基本块的所有前驱可能并不都已知。这是怎么发生的?看看WHILE语句的基本块的创建。循环条件的 IR 首先生成。但是从主体末尾返回到包含条件的基本块的分支只能在生成主体的 IR 之后添加,因为这个基本块在之前是未知的。如果我们需要在条件中读取变量的值,那么我们就陷入困境,因为并不是所有的前驱都是已知的。

为了解决这种情况,我们必须再做一点:

  1. 首先,我们给基本块附加一个标志。

  2. 然后,如果我们知道基本块的所有前驱,我们将定义基本块为已封装。如果基本块没有被封装,并且我们需要查找尚未在这个基本块中定义的变量的值,那么我们插入一个空的phi指令并将其用作值。

  3. 我们还需要记住这个指令。如果块后来被封装,那么我们需要用真实的值更新指令。为了实现这一点,我们向struct BasicBlockDef添加了两个成员:IncompletePhis 映射记录了我们需要稍后更新的phi指令,Sealed 标志指示基本块是否已封装:

llvm::DenseMap<llvm::PHINode *, Decl *> 
  IncompletePhis;
unsigned Sealed : 1;
  1. 然后,该方法可以按照描述实现:
llvm::Value *readLocalVariableRecursive(
                               llvm::BasicBlock *BB,
                               Decl *Decl) {
  llvm::Value *Val = nullptr;
  if (!CurrentDef[BB].Sealed) {
    llvm::PHINode *Phi = addEmptyPhi(BB, Decl);
    CurrentDef[BB].IncompletePhis[Phi] = Decl;
    Val = Phi;
  } else if (auto *PredBB = BB
                           ->getSinglePredecessor()) {
    Val = readLocalVariable(PredBB, Decl);
  } else {
    llvm::PHINode *Phi = addEmptyPhi(BB, Decl);
    Val = Phi;
    writeLocalVariable(BB, Decl, Val);
    addPhiOperands(BB, Decl, Phi);
  }
  writeLocalVariable(BB, Decl, Val);
  return Val;
}
  1. “addEmptyPhi()”方法在基本块的开头插入一个空的phi指令:
llvm::PHINode *addEmptyPhi(llvm::BasicBlock *BB, Decl *Decl) {
  return BB->empty()
             ? llvm::PHINode::Create(mapType(Decl), 0,
              "", BB)
             : llvm::PHINode::Create(mapType(Decl), 0, 
              "", &BB->front());
}
  1. 为了向phi指令添加缺失的操作数,我们首先搜索基本块的所有前驱,并将操作数对值和基本块添加到phi指令中。然后,我们尝试优化指令:
void addPhiOperands(llvm::BasicBlock *BB, Decl *Decl,
                    llvm::PHINode *Phi) {
  for (auto I = llvm::pred_begin(BB),
            E = llvm::pred_end(BB);
       I != E; ++I) {
    Phi->addIncoming(readLocalVariable(*I, Decl), *I);
  }
  optimizePhi(Phi);
}

这个算法可能会生成不需要的phi指令。优化这些的方法在下一节中实现。

优化生成的 phi 指令

我们如何优化phi指令,为什么要这样做?尽管 SSA 形式对许多优化有利,phi指令通常不被算法解释,从而一般阻碍了优化。因此,我们生成的phi指令越少越好:

  1. 如果指令只有一个操作数或所有操作数都具有相同的值,那么我们将用这个值替换指令。如果指令没有操作数,那么我们将用特殊值Undef替换指令。只有当指令有两个或更多不同的操作数时,我们才必须保留指令:
void optimizePhi(llvm::PHINode *Phi) {
  llvm::Value *Same = nullptr;
  for (llvm::Value *V : Phi->incoming_values()) {
    if (V == Same || V == Phi)
      continue;
    if (Same && V != Same)
      return;
    Same = V;
  }
  if (Same == nullptr)
    Same = llvm::UndefValue::get(Phi->getType());
  1. 删除一个phi指令可能会导致其他phi指令的优化机会。我们搜索其他phi指令中值的所有用法,然后尝试优化这些指令:
  llvm::SmallVector<llvm::PHINode *, 8> CandidatePhis;
  for (llvm::Use &U : Phi->uses()) {
    if (auto *P =
            llvm::dyn_cast<llvm::PHINode>(U.getUser()))
      CandidatePhis.push_back(P);
  }
  Phi->replaceAllUsesWith(Same);
  Phi->eraseFromParent();
  for (auto *P : CandidatePhis)
    optimizePhi(P);
}

如果需要,这个算法可以进一步改进。我们可以选择并记住两个不同的值,而不是总是迭代每个phi指令的值列表。在optimize函数中,我们可以检查这两个值是否仍然在phi指令的列表中。如果是,那么我们知道没有什么可以优化的。但即使没有这种优化,这个算法运行非常快,所以我们现在不打算实现这个。

我们几乎完成了。只有封装基本块的操作还没有实现,我们将在下一节中实现。

封装一个块

一旦我们知道一个块的所有前驱都已知,我们就可以封存该块。如果源语言只包含结构化语句,比如 tinylang,那么很容易确定块可以被封存的位置。再看一下为 WHILE 语句生成的基本块。包含条件的基本块可以在从主体末尾添加分支之后封存,因为这是最后一个缺失的前驱。要封存一个块,我们只需向不完整的 phi 指令添加缺失的操作数并设置标志:

void sealBlock(llvm::BasicBlock *BB) {
  for (auto PhiDecl : CurrentDef[BB].IncompletePhis) {
    addPhiOperands(BB, PhiDecl.second, PhiDecl.first);
  }
  CurrentDef[BB].IncompletePhis.clear();
  CurrentDef[BB].Sealed = true;
}

有了这些方法,我们现在可以准备生成表达式的 IR 代码了。

为表达式创建 IR 代码

一般来说,你可以像在第三章中已经展示的那样翻译表达式,编译器的结构。唯一有趣的部分是如何访问变量。前一节涵盖了局部变量,但还有其他类型的变量。让我们简要讨论一下我们需要做什么:

  • 对于过程的局部变量,我们使用了前一节中的 readLocalVariable()writeLocalVariable() 方法。

  • 对于封闭过程中的局部变量,我们需要一个指向封闭过程框架的指针。这将在后面的部分处理。

  • 对于全局变量,我们生成加载和存储指令。

  • 对于形式参数,我们必须区分按值传递和按引用传递(tinylang 中的 VAR 参数)。按值传递的参数被视为局部变量,按引用传递的参数被视为全局变量。

把所有这些放在一起,我们得到了读取变量或形式参数的以下代码:

llvm::Value *CGProcedure::readVariable(llvm::BasicBlock 
                                       *BB,
                                       Decl *D) {
  if (auto *V = llvm::dyn_cast<VariableDeclaration>(D)) {
    if (V->getEnclosingDecl() == Proc)
      return readLocalVariable(BB, D);
    else if (V->getEnclosingDecl() ==
             CGM.getModuleDeclaration()) {
      return Builder.CreateLoad(mapType(D),
                                CGM.getGlobal(D));
    } else
      llvm::report_fatal_error(
          "Nested procedures not yet supported");
  } else if (auto *FP =
                 llvm::dyn_cast<FormalParameterDeclaration>(
                     D)) {
    if (FP->isVar()) {
      return Builder.CreateLoad(
          mapType(FP)->getPointerElementType(),
          FormalParams[FP]);
    } else
      return readLocalVariable(BB, D);
  } else
    llvm::report_fatal_error("Unsupported declaration");
}

写入变量或形式参数是对称的;我们只需要用写入方法替换读取方法,并使用 store 指令代替 load 指令。

接下来,在生成函数的 IR 代码时应用这些函数,我们将在下一步实现。

发出函数的 IR 代码

大部分 IR 代码将存在于一个函数中。IR 代码中的函数类似于 C 中的函数。它指定了参数和返回值的名称和类型以及其他属性。要在不同的编译单元中调用函数,您需要声明该函数。这类似于 C 中的原型。如果您向函数添加基本块,那么您就定义了该函数。我们将在接下来的部分中完成所有这些工作,首先讨论符号名称的可见性。

使用链接和名称混淆来控制可见性

函数(以及全局变量)都有一个链接样式。通过链接样式,我们定义了符号名称的可见性以及如果有多个符号具有相同名称时应该发生什么。最基本的链接样式是 privateexternal。具有 private 链接的符号只在当前编译单元中可见,而具有 external 链接的符号是全局可用的。

对于没有适当模块概念的语言,比如 C,这当然是足够的。有了模块,我们需要做更多的工作。假设我们有一个名为 Square 的模块,提供一个 Root() 函数,还有一个名为 Cube 的模块,也提供一个 Root() 函数。如果函数是私有的,那么显然没有问题。函数得到名称 Root 和私有链接。如果函数被导出,以便在其他模块中调用,情况就不同了。仅使用函数名称是不够的,因为这个名称不是唯一的。

解决方案是调整名称以使其全局唯一。这称为名称混编。如何做取决于语言的要求和特性。在我们的情况下,基本思想是使用模块和函数名的组合来创建全局唯一的名称。使用Square.Root作为名称看起来是一个明显的解决方案,但可能会导致与汇编器的问题,因为点可能具有特殊含义。我们可以通过在名称组件前面加上它们的长度来获得类似的效果,而不是在名称组件之间使用分隔符:6Square4Root。这对于 LLVM 来说不是合法标识符,但我们可以通过在整个名称前面加上_tt代表tinylang)来解决这个问题:_t6Square4Root。通过这种方式,我们可以为导出的符号创建唯一的名称:

std::string CGModule::mangleName(Decl *D) {
  std::string Mangled;
  llvm::SmallString<16> Tmp;
  while (D) {
    llvm::StringRef Name = D->getName();
    Tmp.clear();
    Tmp.append(llvm::itostr(Name.size()));
    Tmp.append(Name);
    Mangled.insert(0, Tmp.c_str());
    D = D->getEnclosingDecl();
  }
  Mangled.insert(0, "_t");
  return Mangled;
}

如果您的源语言支持类型重载,那么您需要使用类型名称来扩展此方案。例如,为了区分 C++函数int root(int)double root(double),参数的类型和返回值被添加到函数名中。

您还需要考虑生成名称的长度,因为一些链接器对长度有限制。在 C++中有嵌套的命名空间和类,混编的名称可能会很长。在那里,C++定义了一种压缩方案,以避免一遍又一遍地重复名称组件。

接下来,我们将看看如何处理参数类型。

将 AST 描述中的类型转换为 LLVM 类型

函数的参数也需要一些考虑。首先,我们需要将源语言的类型映射到 LLVM 类型。由于tinylang目前只有两种类型,这很容易:

llvm::Type *convertType(TypeDeclaration *Ty) {
  if (Ty->getName() == "INTEGER")
    return Int64Ty;
  if (Ty->getName() == "BOOLEAN")
    return Int1Ty;
  llvm::report_fatal_error("Unsupported type");
}

Int64TyInt1Ty,以及后来的VoidTy是类成员,保存着 LLVM 类型i64i1void的类型表示。

对于通过引用传递的形式参数,这还不够。这个参数的 LLVM 类型是一个指针。我们概括函数并考虑形式参数:

llvm::Type *mapType(Decl *Decl) {
  if (auto *FP = llvm::
    dyn_cast<FormalParameterDeclaration>(
          Decl)) {
    llvm::Type *Ty = convertType(FP->getType());
    if (FP->isVar())
      Ty = Ty->getPointerTo();
    return Ty;
  }
  if (auto *V = llvm::dyn_cast<VariableDeclaration>(Decl))
    return convertType(V->getType());
  return convertType(llvm::cast<TypeDeclaration>(Decl));
}

有了这些帮助,我们接下来创建 LLVM IR 函数。

创建 LLVM IR 函数

要在 LLVM IR 中发出函数,需要一个函数类型,它类似于 C 中的原型。创建函数类型涉及映射类型,然后调用工厂方法创建函数类型:

llvm::FunctionType *createFunctionType(
    ProcedureDeclaration *Proc) {
  llvm::Type *ResultTy = VoidTy;
  if (Proc->getRetType()) {
    ResultTy = mapType(Proc->getRetType());
  }
  auto FormalParams = Proc->getFormalParams();
  llvm::SmallVector<llvm::Type *, 8> ParamTypes;
  for (auto FP : FormalParams) {
    llvm::Type *Ty = mapType(FP);
    ParamTypes.push_back(Ty);
  }
  return llvm::FunctionType::get(ResultTy, ParamTypes,
                                 /* IsVarArgs */ false);
}

根据函数类型,我们还创建 LLVM 函数。这将函数类型与链接和名称混合在一起:

llvm::Function *
createFunction(ProcedureDeclaration *Proc,
               llvm::FunctionType *FTy) {
  llvm::Function *Fn = llvm::Function::Create(
      Fty, llvm::GlobalValue::ExternalLinkage,
      mangleName(Proc), getModule());

getModule()方法返回当前的 LLVM 模块,稍后我们将对其进行设置。

创建函数后,我们可以为其添加更多信息。首先,我们可以给参数命名。这使得 IR 更易读。其次,我们可以向函数和参数添加属性以指定一些特性。例如,我们对通过引用传递的参数这样做。

在 LLVM 级别,这些参数是指针。但是根据源语言设计,这些是非常受限制的指针。类似于 C++中的引用,我们总是需要为VAR参数指定一个变量。因此,我们知道这个指针永远不会为空,并且它总是可以解引用的,这意味着我们可以读取指向的值而不会出现一般保护错误。同样根据设计,这个指针不能被传递。特别是,没有指针的副本会在函数调用之后存在。因此,该指针被称为不被捕获。

llvm::AttributeBuilder类用于构建形式参数的属性集。要获取参数类型的存储大小,我们可以简单地询问数据布局:

  size_t Idx = 0;
  for (auto I = Fn->arg_begin(), E = Fn->arg_end(); I != E;
       ++I, ++Idx) {
    llvm::Argument *Arg = I;
    FormalParameterDeclaration *FP =
        Proc->getFormalParams()[Idx];
    if (FP->isVar()) {
      llvm::AttrBuilder Attr;
      llvm::TypeSize Sz =
          CGM.getModule()
            ->getDataLayout().getTypeStoreSize(
              CGM.convertType(FP->getType()));
      Attr.addDereferenceableAttr(Sz);
      Attr.addAttribute(llvm::Attribute::NoCapture);
      Arg->addAttrs(Attr);
    }
    Arg->setName(FP->getName());
  }
  return Fn;
}

我们现在已经创建了 IR 函数。在下一节中,我们将向函数添加函数体的基本块。

发出函数体

我们几乎完成了为函数发出 IR 代码!我们只需要将各个部分组合在一起以发出函数,包括其函数体:

  1. 给定来自tinylang的过程声明,我们首先创建函数类型和函数:
void run(ProcedureDeclaration *Proc) {
  this->Proc = Proc;
  Fty = createFunctionType(Proc);
  Fn = createFunction(Proc, Fty);
  1. 接下来,我们创建函数的第一个基本块,并将其设置为当前基本块:
  llvm::BasicBlock *BB = llvm::BasicBlock::Create(
      CGM.getLLVMCtx(), "entry", Fn);
  setCurr(BB);
  1. 然后我们遍历所有的形式参数。为了正确处理 VAR 参数,我们需要初始化FormalParams成员(在readVariable()中使用)。与局部变量不同,形式参数在第一个基本块中有一个值,所以我们让这些值知道:
  size_t Idx = 0;
  auto &Defs = CurrentDef[BB];
  for (auto I = Fn->arg_begin(), E = Fn->arg_end(); I !=        E; ++I, ++Idx) {
    llvm::Argument *Arg = I;
    FormalParameterDeclaration *FP = Proc->
      getParams()[Idx];
    FormalParams[FP] = Arg;
    Defs.Defs.insert(
        std::pair<Decl *, llvm::Value *>(FP, Arg));
  }
  1. 在进行下一步之前,我们可以调用emit()方法开始生成语句的 IR 代码:
  auto Block = Proc->getStmts();
  emit(Proc->getStmts());
  1. 在生成 IR 代码之后,最后一个块可能还没有封闭,所以现在我们调用sealBlock()tinylang中的一个过程可能有一个隐式返回,所以我们还要检查最后一个基本块是否有适当的终结符,如果没有,就添加一个:
  sealBlock(Curr);
  if (!Curr->getTerminator()) {
    Builder.CreateRetVoid();
  }
}

这完成了函数的 IR 代码生成。我们仍然需要创建 LLVM 模块,其中包含所有的 IR 代码。我们将在下一节中完成这个工作。

设置模块和驱动程序

我们在 LLVM 模块中收集编译单元的所有函数和全局变量。为了简化 IR 生成,我们将前面章节中的所有函数封装在一个代码生成器类中。为了获得一个可工作的编译器,我们还需要定义要生成代码的目标架构,并添加生成代码的传递。我们将在接下来的章节中实现所有这些,从代码生成器开始。

将所有内容包装在代码生成器中

IR 模块是我们为编译单元生成的所有元素的大括号。在全局级别,我们遍历模块级别的声明,并创建全局变量,并调用过程的代码生成。在tinylang中,全局变量映射到llvm::GobalValue类的实例。这个映射保存在Globals中,并且可以在过程的代码生成中使用:

void CGModule::run(ModuleDeclaration *Mod) {
  for (auto *Decl : Mod->getDecls()) {
    if (auto *Var =
            llvm::dyn_cast<VariableDeclaration>(Decl)) {
      llvm::GlobalVariable *V = new llvm::GlobalVariable(
          *M, convertType(Var->getType()),
          /*isConstant=*/false,
          llvm::GlobalValue::PrivateLinkage, nullptr,
          mangleName(Var));
      Globals[Var] = V;
    } else if (auto *Proc =
                   llvm::dyn_cast<ProcedureDeclaration>(
                       Decl)) {
      CGProcedure CGP(*this);
      CGP.run(Proc);
    }
  }
}

模块还包含LLVMContext类,并缓存了最常用的 LLVM 类型。后者需要初始化,例如,64 位整数类型:

Int64Ty = llvm::Type::getInt64Ty(getLLVMCtx());

CodeGenerator类初始化 LLVM IR 模块,并调用模块的代码生成。最重要的是,这个类必须知道我们想要为哪个目标架构生成代码。这个信息传递给llvm::TargetMachine类,在驱动程序中设置:

void CodeGenerator::run(ModuleDeclaration *Mod, std::string FileName) {
  llvm::Module *M = new llvm::Module(FileName, Ctx);
  M->setTargetTriple(TM->getTargetTriple().getTriple());
  M->setDataLayout(TM->createDataLayout());
  CGModule CGM(M);
  CGM.run(Mod);
}

为了方便使用,我们还引入了一个代码生成器的工厂方法:

CodeGenerator *CodeGenerator::create(llvm::TargetMachine *TM) {
  return new CodeGenerator(TM);
}

CodeGenerator类提供了一个小接口来创建 IR 代码,这对于在编译器驱动程序中使用是理想的。在集成之前,我们需要实现对机器代码生成的支持。

初始化目标机器类

现在,只剩下创建目标机器了。有了目标机器,我们可以定义要生成代码的 CPU 架构。对于每个 CPU,还有一些可用的特性,可以用来影响代码生成。例如,CPU 架构系列的新 CPU 可以支持矢量指令。有了特性,我们可以切换矢量指令的使用。为了支持从命令行设置所有这些选项,LLVM 提供了一些支持代码。在Driver类中,我们添加了以下include变量:

#include "llvm/CodeGen/CommandFlags.h"

这个include变量将常见的命令行选项添加到我们的编译器驱动程序中。许多 LLVM 工具也使用这些命令行选项,这样做的好处是为用户提供了一个共同的接口。只是缺少指定目标三元组的选项。由于这非常有用,我们自己添加这个选项:

static cl::opt<std::string>
    MTriple("mtriple",
            cl::desc("Override target triple for module"));

让我们创建目标机器:

  1. 为了显示错误消息,应用程序的名称必须传递给函数:
llvm::TargetMachine *
createTargetMachine(const char *Argv0) {
  1. 首先收集命令行提供的所有信息。这些是代码生成器的选项,CPU 的名称,可能要激活或停用的特性,以及目标的三元组:
  llvm::Triple = llvm::Triple(
      !MTriple.empty()
          ? llvm::Triple::normalize(MTriple)
          : llvm::sys::getDefaultTargetTriple());
  llvm::TargetOptions =
      codegen::InitTargetOptionsFromCodeGenFlags(Triple);
  std::string CPUStr = codegen::getCPUStr();
  std::string FeatureStr = codegen::getFeaturesStr();
  1. 然后我们在目标注册表中查找目标。如果发生错误,我们会显示错误消息并退出。用户指定的可能错误是不支持的三元组:
  std::string Error;
  const llvm::Target *Target =
      llvm::TargetRegistry::lookupTarget(
                     codegen::getMArch(), Triple, 
                     Error);
  if (!Target) {
    llvm::WithColor::error(llvm::errs(), Argv0) << 
                     Error;
    return nullptr;
  }
  1. 借助 Target 类的帮助,我们使用用户请求的所有已知选项配置目标机器:
  llvm::TargetMachine *TM = Target->
    createTargetMachine(
      Triple.getTriple(), CPUStr, FeatureStr, 
      TargetOptions, 
      llvm::Optional<llvm::Reloc::Model>(
                           codegen::getRelocModel()));
  return TM;
}

有了目标机器实例,我们可以生成针对我们选择的 CPU 架构的 IR 代码。缺少的是将其转换为汇编文本或生成目标代码文件。我们将在下一节中添加这个支持。

发出汇编文本和目标代码

在 LLVM 中,IR 代码通过一系列 passes 运行。每个 pass 执行一个任务,例如删除死代码。我们将在 第八章 中了解更多关于 passes 的知识,优化 IR。输出汇编代码或目标文件也被实现为一个 pass。让我们为此添加基本支持!

我们需要包含更多的 LLVM 头文件。我们需要 llvm::legacy::PassManager 类来保存发出代码到文件的 passes。我们还希望能够输出 LLVM IR 代码,因此我们还需要一个 pass 来发出这个。最后,我们使用 llvm:: ToolOutputFile 类进行文件操作:

#include "llvm/IR/IRPrintingPasses.h"
#include "llvm/IR/LegacyPassManager.h"
#include "llvm/Support/ToolOutputFile.h"

还需要另一个用于输出 LLVM IR 的命令行选项:

static cl::opt<bool>
    EmitLLVM("emit-llvm",
             cl::desc("Emit IR code instead of assembler"),
             cl::init(false));

emit() 方法中的第一个任务是处理输出文件的名称。如果输入是从 stdin 读取的,表示为减号 -,那么我们将结果输出到 stdoutToolOutputFile 类知道如何处理特殊文件名 -

bool emit(StringRef Argv0, llvm::Module *M,
          llvm::TargetMachine *TM,
          StringRef InputFilename) {
  CodeGenFileType FileType = codegen::getFileType();
  std::string OutputFilename;
  if (InputFilename == "-") {
    OutputFilename = "-";
  }

否则,我们会删除输入文件名的可能扩展,并根据用户给出的命令行选项附加.ll.s.o作为扩展名。FileType 选项在 llvm/CodeGen/CommandFlags.inc 头文件中定义,我们之前已经包含了这个选项。这个选项不支持发出 IR 代码,所以我们添加了新选项 –emit-llvm,只有在与汇编文件类型一起使用时才会生效:

  else {
    if (InputFilename.endswith(".mod"))
      OutputFilename = InputFilename.drop_back(4).str();
    else
      OutputFilename = InputFilename.str();
    switch (FileType) {
    case CGFT_AssemblyFile:
      OutputFilename.append(EmitLLVM ? ".ll" : ".s");
      break;
    case CGFT_ObjectFile:
      OutputFilename.append(".o");
      break;
    case CGFT_Null:
      OutputFilename.append(".null");
      break;
    }
  }

一些平台区分文本和二进制文件,因此在打开输出文件时我们必须提供正确的打开标志:

  std::error_code EC;
  sys::fs::OpenFlags = sys::fs::OF_None;
  if (FileType == CGFT_AssemblyFile)
    OpenFlags |= sys::fs::OF_Text;
  auto Out = std::make_unique<llvm::ToolOutputFile>(
      OutputFilename, EC, OpenFlags);
  if (EC) {
    WithColor::error(errs(), Argv0) << EC.message() << 
      '\n';
    return false;
  }

现在我们可以向 PassManager 添加所需的 passes。TargetMachine 类有一个实用方法,用于添加请求的类。因此,我们只需要检查用户是否要求输出 LLVM IR 代码:

  legacy::PassManager PM;
  if (FileType == CGFT_AssemblyFile && EmitLLVM) {
    PM.add(createPrintModulePass(Out->os()));
  } else {
    if (TM->addPassesToEmitFile(PM, Out->os(), nullptr,
                                FileType)) {
      WithColor::error() << "No support for file type\n";
      return false;
    }
  }

准备工作都做好了,发出文件归结为一个函数调用:

  PM.run(*M);

ToolOutputFile 类会在我们没有明确要求保留文件时自动删除文件。这样做可以使错误处理更容易,因为可能有很多地方需要处理错误,但只有一个地方在一切顺利的情况下被调用。我们成功地发出了代码,所以我们想要保留这个文件:

  Out->keep();

最后,我们向调用者报告成功:

  return true;
}

使用我们创建的 llvm::Module 调用 CodeGenerator 类的 emit() 方法,按照请求发出代码。

假设您在 tinylang 中有最大公约数算法存储在 gcd.mod 文件中。要将其转换为 gcd.os 目标文件,您可以输入以下内容:

$ tinylang –filetype=obj gcd.mod

如果您想直接在屏幕上检查生成的 IR 代码,那么可以输入以下内容:

$ tinylang –filetype=asm –emit-llvm –o – gcd.mod

让我们庆祝一下!到目前为止,我们已经创建了一个完整的编译器,从读取源语言到发出汇编代码或目标文件。

总结

在本章中,您学习了如何为 LLVM IR 代码实现自己的代码生成器。基本块是一个重要的数据结构,包含所有指令并表示分支。您学习了如何为源语言的控制语句创建基本块,以及如何向基本块添加指令。您应用了一种现代算法来处理函数中的局部变量,从而减少了 IR 代码。编译器的目标是为输入生成汇编文本或目标文件,因此您还添加了一个简单的编译流水线。有了这些知识,您将能够为自己的语言编译器生成 LLVM IR,随后生成汇编文本或目标代码。

在下一章中,您将学习如何处理聚合数据结构,以及如何确保函数调用符合您平台的规则。

第六章:高级语言构造的 IR 生成

今天的高级语言通常使用聚合数据类型和面向对象编程OOP)构造。LLVM IR对聚合数据类型有一定支持,我们必须自行实现类似类的 OOP 构造。添加聚合类型引发了一个问题,即如何传递聚合类型的参数。不同的平台有不同的规则,这也反映在 IR 中。遵守调用约定可确保可以调用系统函数。

在本章中,您将学习如何将聚合数据类型和指针转换为 LLVM IR,以及如何以符合系统的方式传递函数的参数。您还将学习如何在 LLVM IR 中实现类和虚函数。

本章将涵盖以下主题:

  • 使用数组、结构和指针

  • 正确获取应用程序二进制接口

  • 为类和虚函数创建 IR 代码

通过本章结束时,您将掌握创建 LLVM IR 的聚合数据类型和 OOP 的知识。您还将了解如何根据平台规则传递聚合数据类型。

技术要求

本章的代码文件可在以下网址找到:github.com/PacktPublishing/Learn-LLVM-12/tree/master/Chapter06/tinylang

您可以在以下网址找到代码的操作视频:bit.ly/3nllhED

使用数组、结构和指针

对于几乎所有应用程序,诸如INTEGER之类的基本类型是不够的。例如,要表示数学对象,如矩阵或复数,必须基于现有数据类型构造新的数据类型。这些新数据类型通常称为聚合复合类型

tinylang类型为ARRAY [10] OF INTEGER,或 C 类型为long[10],在 IR 中表示如下:

[10 x i64]

结构是不同类型的组合。在编程语言中,它们通常用具有命名成员的方式表示。例如,在tinylang中,结构写为RECORD x, y: REAL; color: INTEGER; END;,在 C 中相同的结构为struct { float x, y; long color; };。在 LLVM IR 中,只列出类型名称:

{ float, float, i64 }

要访问成员,使用数字索引。与数组一样,第一个元素的索引号为0

该结构的成员根据数据布局字符串中的规范在内存中布局。如果需要,将插入未使用的填充字节。如果需要控制内存布局,则可以使用紧凑结构,其中所有元素具有 1 字节对齐。语法略有不同:

<{ float, float, i64 }>

加载到寄存器中,数组和结构被视为一个单元。例如,不可能将%x数组值寄存器的单个元素表示为%x[3]。这是因为%x[i]%x[j]是否引用相同的元素。相反,我们需要特殊指令来提取和插入单个元素值到数组中。要读取第二个元素,我们使用以下:

%el2 = extractvalue [10 x i64] %x, 1

我们还可以更新一个元素,例如第一个元素:

%xnew = insertvalue [10 x i64] %x, i64 %el2, 0

这两个指令也适用于结构。例如,要从%pt寄存器中访问color成员,可以编写以下内容:

%color = extractvalue { float, float, i64 } %pt, 2

这两个指令都有一个重要的限制:索引必须是一个常数。对于结构,这很容易解释。索引号只是名称的替代,诸如 C 的语言没有动态计算结构成员名称的概念。对于数组,这只是它无法有效实现。这两个指令在特定情况下具有价值,当元素数量较少且已知时。例如,复数可以建模为两个浮点数的数组。传递这个数组是合理的,并且在计算过程中始终清楚数组的哪一部分必须被访问。

为了在前端通用,我们必须使用指向内存的指针。LLVM 中的所有全局值都表示为指针。让我们声明一个全局变量 @arr,作为包含八个 i64 元素的数组,相当于 C 声明的 long arr[8]

@arr = common global [8 x i64] zeroinitializer

要访问数组的第二个元素,必须执行地址计算以确定索引元素的地址。然后,可以从该地址加载值。放入 @second 函数中,看起来像这样:

define i64 @second() {
  %1 = getelementptr [8 x i64], [8 x i64]* @arr, i64 0, i64 
       1
  %2 = load i64, i64* %1
  ret i64 %2
}

getelementptr 指令是地址计算的主要工具。因此,它需要更多的解释。第一个操作数 [8 x i64] 是指令操作的基本类型。第二个操作数 [8 x i64]* @arr 指定了基本指针。请注意这里的细微差别:我们声明了一个包含八个元素的数组,但因为所有的全局值都被视为指针,所以我们有一个指向数组的指针。在 C 语法中,我们使用 long (*arr)[8]!这意味着我们首先必须解引用指针,然后才能索引元素,比如在 C 中的 arr[0][1]。第三个操作数 i64 0 解引用指针,第四个操作数 i64 1 是元素索引。这个计算的结果是索引元素的地址。请注意,这个指令不会触及任何内存。

除了结构体,索引参数不需要是常量。因此,getelementptr 指令可以在循环中用于检索数组的元素。这里对待结构体的方式不同:只能使用常量,并且类型必须是 i32

有了这些知识,数组很容易从第五章IR 生成基础 中集成到代码生成器中。convertType() 方法必须扩展以创建类型。如果 Arr 变量保存了数组的类型标识符,那么我们可以在方法中添加以下内容:

llvm::Type *Component = convertType(Arr->getComponentType());
uint64_t NumElements = Arr->getNumElem();
return llvm::ArrayType::get(Component, NumElements);

这种类型可以用来声明全局变量。对于局部变量,我们需要为数组分配内存。我们在过程的第一个基本块中进行这个操作:

for (auto *D : Proc->getDecls()) {
  if (auto *Var =
          llvm::dyn_cast<VariableDeclaration>(D)) {
    llvm::Type *Ty = mapType(Var);
    if (Ty->isAggregateType()) {
      llvm::Value *Val = Builder.CreateAlloca(Ty);
      Defs.Defs.insert(
          std::pair<Decl *, llvm::Value *>(Var, Val));
    }
  }
}

要读取和写入一个元素,我们必须生成 getelemtptr 指令。这被添加到 emitExpr()(读取值)和 emitAssign()(写入值)方法中。要读取数组的元素,首先读取变量的值。然后处理变量的选择器。对于每个索引,计算表达式并存储值。基于这个列表,计算引用元素的地址并加载值:

auto &Selectors = Var->getSelectorList();
for (auto *I = Selectors.begin(),
          *E = Selectors.end();
     I != E;) {
  if (auto *Idx = llvm::dyn_cast<IndexSelector>(*I)) {
    llvm::SmallVector<llvm::Value *, 4> IdxList;
    IdxList.push_back(emitExpr(Idx->getIndex()));
    for (++I; I != E;) {
      if (auto *Idx2 =
              llvm::dyn_cast<IndexSelector>(*I)) {
        IdxList.push_back(emitExpr(Idx2->getIndex()));
        ++I;
      } else
        break;
    }
    Val = Builder.CreateGEP(Val, IdxList);
    Val = Builder.CreateLoad(
        Val->getType()->getPointerElementType(), Val);
  } else {
    llvm::report_fatal_error("Unsupported selector");
  }
}

写入数组元素使用相同的代码,唯一的区别是不生成 load 指令。而是使用指针作为 store 指令的目标。对于记录,使用类似的方法。记录成员的选择器包含常量字段索引,称为 Idx。将这个常量转换为常量 LLVM 值,如下所示:

llvm::Value *FieldIdx = llvm::ConstantInt::get(Int32Ty, Idx);

然后,你可以像数组一样在 Builder.CreateGEP() 方法中使用值。

现在你有了将聚合数据类型转换为 LLVM IR 的知识。以系统兼容的方式传递这些类型的值需要一些小心,你将在下一节中学习如何正确实现它。

正确理解应用二进制接口

随着数组和记录被添加到代码生成器中,你可能会注意到有时生成的代码并不按预期执行。原因是到目前为止我们忽略了平台的调用约定。每个平台都定义了如何一个函数可以调用同一程序或库中的另一个函数的规则。这些规则在应用二进制接口ABI)文档中进行了总结。典型的信息包括以下内容:

  • 机器寄存器用于参数传递吗?如果是,使用哪些?

  • 如何将数组和结构等聚合类型传递给函数?

  • 返回值是如何处理的?

使用的规则种类繁多。在某些平台上,聚合始终以间接方式传递,这意味着在堆栈上放置聚合的副本,然后只传递该副本的指针作为参数。在其他平台上,小型聚合(例如 128 位或 256 位宽)在寄存器中传递,只有超过该阈值才使用间接参数传递。一些平台还使用浮点和矢量寄存器进行参数传递,而其他平台要求浮点值在整数寄存器中传递。

当然,这都是有趣的低级内容。不幸的是,这些内容泄漏到了 LLVM IR 中。起初,这让人感到惊讶。毕竟,我们在 LLVM IR 中定义了函数所有参数的类型!事实证明这是不够的。为了理解这一点,让我们考虑复数。一些语言具有内置的复数数据类型;例如,C99 具有float _Complex(等等)。较早版本的 C 没有复数类型,但您可以轻松地定义struct Complex { float re, im; }并在此类型上创建算术运算。这两种类型都可以映射到{ float,float }LLVM IR 类型。如果 ABI 现在规定内置复数类型的值在两个浮点寄存器中传递,但用户定义的聚合始终以间接方式传递,那么函数提供的信息对于 LLVM 来说不足以决定如何传递此特定参数。不幸的后果是我们需要向 LLVM 提供更多信息,而这些信息是高度特定于 ABI 的。

有两种方法可以向 LLVM 指定此信息:参数属性和类型重写。您需要使用的方法取决于目标平台和代码生成器。最常用的参数属性如下:

  • inreg指定参数在寄存器中传递。

  • byval指定参数按值传递。参数必须是指针类型。将指向数据的隐藏副本制作,并将此指针传递给被调用的函数。

  • zeroextsignext指定传递的整数值应该是零扩展或符号扩展。

  • sret指定此参数保存一个指向用于从函数返回聚合类型的内存的指针。

虽然所有代码生成器都支持zeroextsignextsret属性,但只有一些支持inregbyval。可以使用addAttr()方法将属性添加到函数的参数中。例如,要在Arg参数上设置inreg属性,可以调用以下方法:

Arg->addAttr(llvm::Attribute::InReg);

要设置多个属性,可以使用llvm::AttrBuilder类。

提供额外信息的另一种方法是使用类型重写。通过这种方法,您可以伪装原始类型。您可以执行以下操作:

  • 拆分参数;例如,不要传递一个复数参数,而是传递两个浮点参数。

  • 将参数转换为不同的表示形式,例如,将大小为 64 位或更小的结构体转换为i64整数。

要在不改变值的位的情况下在类型之间转换,可以使用bitcast指令。bitcast指令不适用于聚合类型,但这并不是限制,因为您总是可以使用指针。如果将一个点建模为具有两个int成员的结构,在 LLVM 中表示为类型{ i32,i32 },那么可以以以下方式将其bitcasti64

%intpoint = bitcast { i32, i32}* %point to i64*

这将指针转换为结构体的指针,然后可以加载此值并将其作为参数传递。您只需确保两种类型的大小相同即可。

向参数添加属性或更改类型并不复杂。但是你怎么知道你需要实现什么?首先,你应该了解目标平台上使用的调用约定。例如,Linux 上的 ELF ABI 针对每个支持的 CPU 平台都有文档记录。只需查阅文档并熟悉它。有关 LLVM 代码生成器的要求也有文档记录。信息来源是 Clang 实现,在github.com/llvm/llvm-project/raw/main/clang/lib/CodeGen/TargetInfo.cpp文件中。这个单一文件包含了所有支持平台的 ABI 特定操作。这也是所有信息被收集的唯一地方。

在本节中,您学习了如何生成符合平台 ABI 的函数调用的 IR。下一节将介绍为类和虚函数创建 IR 的不同方法。

为类和虚函数创建 IR 代码

许多现代编程语言使用类支持面向对象编程。是一个高级语言构造,在本节中,我们将探讨如何将类构造映射到 LLVM IR 中。

实现单继承

类是数据和方法的集合。一个类可以继承自另一个类,可能添加更多的数据字段和方法,或者覆盖现有的虚拟方法。让我们用 Oberon-2 中的类来说明这一点,这也是tinylang的一个很好的模型。一个Shape类定义了一个带有颜色和面积的抽象形状:

TYPE Shape = RECORD
               color: INTEGER;
               PROCEDURE (VAR s: Shape) GetColor(): 
                   INTEGER;
               PROCEDURE (VAR s: Shape) Area(): REAL;
             END;

GetColor方法只返回颜色编号:

PROCEDURE (VAR s: Shape) GetColor(): INTEGER;
BEGIN RETURN s.color; END GetColor;

抽象形状的面积无法计算,因此这是一个抽象方法:

PROCEDURE (VAR s: Shape) Area(): REAL;
BEGIN HALT; END;

Shape类型可以扩展为表示Circle类:

TYPE Circle = RECORD (Shape)
                radius: REAL;
                PROCEDURE (VAR s: Circle) Area(): REAL;
              END;

对于一个圆,可以计算出面积:

PROCEDURE (VAR s: Circle) Area(): REAL;
BEGIN RETURN 2 * radius * radius; END;

类型也可以在运行时查询。如果shapeShape类型的变量,那么我们可以这样制定类型测试:

IF shape IS Circle THEN (* … *) END;

除了不同的语法之外,这与 C++中的工作方式非常相似。与 C++的一个显着区别是,Oberon-2 的语法使隐式的this指针变得显式,称之为方法的接收者。

要解决的基本问题是如何在内存中布局一个类,以及如何实现方法的动态调用和运行时类型检查。对于内存布局来说,这是相当容易的。Shape类只有一个数据成员,我们可以将它映射到相应的 LLVM 结构类型:

@Shape = type { i64 }

Circle类添加了另一个数据成员。解决方案是将新的数据成员追加到末尾:

@Circle = type { i64, float }

原因是一个类可以有许多子类。采用这种策略,共同基类的数据成员始终具有相同的内存偏移量,并且还使用相同的索引通过getelementptr指令访问字段。

要实现方法的动态调用,我们必须进一步扩展 LLVM 结构。如果在Shape对象上调用Area()函数,那么将调用抽象方法,导致应用程序停止。如果在Circle对象上调用它,那么将调用计算圆形面积的相应方法。GetColor()函数可以用于两个类的对象。实现这一点的基本思想是为每个对象关联一个带有函数指针的表。在这里,表将有两个条目:一个是GetColor()方法,另一个是Area()函数。Shape类和Circle类都有这样的表。这些表在Area()函数的条目上有所不同,根据对象的类型调用不同的代码。这个表被称为虚方法表,通常缩写为vtable

仅有 vtable 是没有用的。我们必须将其与对象连接起来。为此,我们将一个指向 vtable 的指针始终添加为结构的第一个数据成员。在 LLVM 级别上,@Shape类型然后变成了以下形式:

@Shape = type { [2 x i8*]*, i64 }

@Circle类型也类似扩展。结果的内存结构显示在图 6.1中:

图 6.1-类和虚拟方法表的内存布局

图 6.1-类和虚拟方法表的内存布局

LLVM 没有 void 指针,而是使用字节指针。引入隐藏的vtable字段后,现在也需要一种初始化它的方法。在 C++中,这是调用构造函数的一部分。在 Oberon-2 中,当分配内存时,该字段会自动初始化。

然后执行动态调用方法的步骤如下:

  1. 通过getelementptr指令计算 vtable 指针的偏移量。

  2. 加载 vtable 的指针。

  3. 计算函数在 vtable 中的偏移量。

  4. 加载函数指针。

  5. 通过call指令间接调用函数。

这听起来并不是很高效,但事实上,大多数 CPU 架构可以在只有两条指令的情况下执行这个动态调用。因此,这实际上是 LLVM 级别的冗长。

要将函数转换为方法,需要对象数据的引用。这是通过将数据指针作为方法的第一个参数来实现的。在 Oberon-2 中,这是显式的接收者。在类似于 C++的语言中,这是隐式的this指针。

有了 vtable,我们对每个类在内存中都有一个唯一的地址。这对运行时类型测试有帮助吗?答案是只有在有限的范围内有帮助。为了说明问题,让我们通过一个Ellipse类扩展类层次结构,它继承自Circle类。(这不是数学意义上的经典is-a关系。)如果我们有Shape类型的shape变量,那么我们可以将shape IS Circle类型测试实现为将存储在shape变量中的 vtable 指针与Circle类的 vtable 指针进行比较。只有当shape确实具有Circle类型时,比较才会返回true。但如果shape确实是Ellipse类型,那么比较会返回false,即使Ellipse类型的对象可以在只需要Circle类型的对象的所有地方使用。

显然,我们需要做更多的工作。解决方案是使用运行时类型信息扩展虚拟方法表。需要存储多少信息取决于源语言。为了支持运行时类型检查,只需存储指向基类 vtable 的指针,然后看起来像图 6.2

图 6.2-支持简单类型测试的类和 vtable 布局

图 6.2-支持简单类型测试的类和 vtable 布局

如果像之前描述的那样测试失败,那么就会用基类的 vtable 指针重复测试。这将重复进行,直到测试产生true,或者如果没有基类,则产生false。与调用动态函数不同,类型测试是一个昂贵的操作,因为在最坏的情况下,继承层次结构会一直向上走到根类。

如果你知道整个类层次结构,那么可以采用高效的方法:对类层次结构的每个成员进行深度优先编号。然后,类型测试变成了与数字或区间的比较,可以在常数时间内完成。事实上,这就是 LLVM 自己的运行时类型测试的方法,我们在上一章中了解到了。

将运行时类型信息与 vtable 耦合是一个设计决策,要么是源语言规定的,要么只是一个实现细节。例如,如果你需要详细的运行时类型信息,因为源语言支持运行时反射,并且你有没有 vtable 的数据类型,那么耦合两者并不是一个好主意。在 C++中,耦合导致一个具有虚函数(因此没有 vtable)的类没有附加的运行时类型数据。

通常,编程语言支持接口,它们是一组虚拟方法。接口很重要,因为它们增加了一个有用的抽象。我们将在下一节中看看接口的可能实现。

通过接口扩展单一继承

诸如Java之类的语言支持接口。接口是一组抽象方法,类似于没有数据成员且只定义了抽象方法的基类。接口提出了一个有趣的问题,因为实现接口的每个类可以在 vtable 中的不同位置具有相应的方法。原因很简单,vtable 中函数指针的顺序是从源语言中类定义中函数的顺序派生的。接口中的定义与此无关,不同的顺序是正常的。

因为接口中定义的方法可以有不同的顺序,我们将每个实现的接口附加到类上。对于接口的每个方法,此表可以指定 vtable 中方法的索引,或者可以是存储在 vtable 中的函数指针的副本。如果在接口上调用方法,那么将搜索接口的相应 vtable,然后获取函数指针并调用方法。将两个接口I1I2添加到Shape类中会得到以下布局:

图 6.3 – 接口 vtable 的布局

图 6.3 – 接口 vtable 的布局

警告在于我们必须找到正确的 vtable。我们可以使用类似于运行时类型测试的方法:我们可以通过接口 vtable 列表执行线性搜索。我们可以为每个接口分配一个唯一的数字(例如,内存地址),并使用此数字来识别 vtable。这种方案的缺点是显而易见的:通过接口调用方法比在类上调用相同的方法需要更多的时间。对于这个问题并没有简单的缓解方法。

一个好的方法是用哈希表替换线性搜索。在编译时,已知类实现的接口。因此,我们可以构造一个完美的哈希函数,将接口号映射到接口的 vtable。可能需要一个已知的唯一标识接口的数字来进行构造,以便内存不会有帮助。但是还有其他计算唯一数字的方法。如果源中的符号名称是唯一的,那么总是可以计算出诸如MD5之类的加密哈希,并将哈希用作数字。计算发生在编译时,因此没有运行时成本。

结果比线性搜索快得多,只需要常数时间。但是,它涉及对数字进行多次算术运算,比类类型的方法调用慢。

通常,接口也参与运行时类型测试,使得搜索列表变得更长。当然,如果实现了哈希表方法,那么它也可以用于运行时类型测试。

一些语言允许有多个父类。这对实现有一些有趣的挑战,我们将在下一节中掌握这些挑战。

添加对多重继承的支持

多重继承增加了另一个挑战。如果一个类从两个或更多的基类继承,那么我们需要以这样的方式组合数据成员,以便它们仍然可以从方法中访问。就像单一继承的情况一样,解决方案是追加所有数据成员,包括隐藏的 vtable 指针。Circle类不仅是一个几何形状,还是一个图形对象。为了模拟这一点,我们让Circle类继承自Shape类和GraphicObj类。在类布局中,Shape类的字段首先出现。然后,我们追加GraphicObj类的所有字段,包括隐藏的 vtable 指针。之后,我们添加Circle类的新数据成员,得到了图 6.4中显示的整体结构:

图 6.4 - 具有多重继承的类和 vtable 的布局

图 6.4 - 具有多重继承的类和 vtable 的布局

这种方法有几个影响。现在可以有几个指向对象的指针。指向ShapeCircle类的指针指向对象的顶部,而指向GraphicObj类的指针指向对象的内部,指向嵌入的GraphicObj对象的开头。在比较指针时必须考虑到这一点。

调用虚方法也会受到影响。如果一个方法在GraphicObj类中定义,那么这个方法期望GraphicObj类的类布局。如果这个方法在Circle类中没有被覆盖,那么有两种可能性。简单的情况是,如果方法调用是通过指向GraphicObj实例的指针完成的:在这种情况下,你在GraphicObj类的 vtable 中查找方法的地址并调用该函数。更复杂的情况是,如果你用指向Circle类的指针调用该方法。同样,你可以在Circle类的 vtable 中查找方法的地址。被调用的方法期望一个指向GraphicObj类实例的this指针,所以我们也必须调整该指针。我们可以做到这一点,因为我们知道GraphicObj类在Circle类内部的偏移量。

如果GrapicObj的方法在Circle类中被覆盖,那么如果通过指向Circle类的指针调用该方法,则不需要做任何特殊处理。然而,如果通过指向GraphicObj实例的指针调用该方法,那么我们需要进行另一个调整,因为该方法需要一个指向Circle实例的this指针。在编译时,我们无法计算这个调整,因为我们不知道这个GraphicObj实例是否是多重继承层次结构的一部分。为了解决这个问题,我们在 vtable 中的每个函数指针一起存储我们需要对this指针进行的调整,在图 6.5中显示。

图 6.5 - 具有对 this 指针的调整的 vtable

图 6.5 - 具有对 this 指针的调整的 vtable

现在方法调用变成了以下形式:

  1. 在 vtable 中查找函数指针。

  2. 调整this指针。

  3. 调用方法。

这种方法也可以用于实现接口。因为接口只有方法,每个实现的接口都会向对象添加一个新的 vtable 指针。这样做更容易实现,而且很可能更快,但它会给每个对象实例增加开销。在最坏的情况下,如果你的类有一个单独的 64 位数据字段,但实现了 10 个接口,那么你的对象在内存中需要 96 字节:8 字节用于类本身的 vtable 指针,8 字节用于数据成员,以及每个接口的 10 * 8 字节的 vtable 指针。

为了支持与对象的有意义比较并执行运行时类型测试,需要首先将指针规范化为对象。如果我们在 vtable 中添加一个额外的字段,其中包含对象顶部的偏移量,那么我们就可以始终调整指针指向真正的对象。在Circle类的 vtable 中,这个偏移量是0,但在嵌入的GraphicObj类的 vtable 中不是。当然,是否需要实现这一点取决于源语言的语义。

LLVM 本身不偏向于面向对象特性的特殊实现。正如在本节中所看到的,我们可以使用现有的 LLVM 数据类型来实现所有方法。如果你想尝试一种新的方法,那么一个好的方式是先在 C 中做一个原型。所需的指针操作很快就能转换为 LLVM IR,但在高级语言中推理功能更容易。

通过本节学到的知识,你可以在自己的代码生成器中将编程语言中常见的所有面向对象编程构造降低为 LLVM IR。你已经知道了如何表示内存中的单继承、带接口的单继承或多重继承,以及如何实现类型测试和查找虚拟函数,这些都是面向对象语言的核心概念。

总结

在本章中,你学会了如何将聚合数据类型和指针转换为 LLVM IR 代码。你还了解了 ABI 的复杂性。最后,你了解了将类和虚拟函数转换为 LLVM IR 的不同方法。有了本章的知识,你将能够为大多数真实编程语言创建一个 LLVM IR 代码生成器。

在下一章中,你将学习一些高级技术。异常处理在现代编程语言中非常常见,LLVM 对此提供了一些支持。将类型信息附加到指针可以帮助进行某些优化,所以我们也会添加这个功能。最后但同样重要的是,调试应用程序的能力对许多开发人员来说是至关重要的,因此我们将为我们的代码生成器添加调试元数据的生成。

第七章:高级 IR 生成

在前几章介绍的中间表示IR)生成中,您已经可以实现编译器中所需的大部分功能。在本章中,我们将研究一些通常在实际编译器中出现的高级主题。例如,许多现代语言使用异常处理,我们将看看如何将其转换为低级虚拟机LLVM)IR。

为了支持 LLVM 优化器在某些情况下生成更好的代码,我们向 IR 代码添加了额外的类型元数据,并附加调试元数据使编译器的用户能够利用源级调试工具。

在本章中,您将学习以下主题:

  • 抛出和捕获异常中,您将学习如何在编译器中实现异常处理。

  • 为基于类型的别名分析生成元数据中,您将向 LLVM IR 附加额外的元数据,这有助于 LLVM 更好地优化代码。

  • 添加调试元数据中,您将实现所需的支持类,以向生成的 IR 代码添加调试信息。

到本章结束时,您将了解有关异常处理和基于类型的别名分析和调试信息的元数据的知识。

技术要求

本章的代码文件可在github.com/PacktPublishing/Learn-LLVM-12/tree/master/Chapter07找到

您可以在bit.ly/3nllhED找到代码演示视频。

抛出和捕获异常

LLVM IR 中的异常处理与平台的支持密切相关。在这里,我们将看到使用libunwind进行最常见类型的异常处理。它的全部潜力由 C++使用,因此我们将首先看一个 C++的示例,在该示例中,bar()函数可以抛出intdouble值,如下所示:

int bar(int x) {
  if (x == 1) throw 1;
  if (x == 2) throw 42.0;
  return x;
}

foo()函数调用bar(),但只处理抛出的int值。它还声明它只抛出int值,如下所示:

int foo(int x) throw(int) {
  int y = 0;
  try {
    y = bar(x);
  }
  catch (int e) {
    y = e;
  }
  return y;
}

抛出异常需要两次调用运行时库。首先,使用__cxa_allocate_exception()调用分配异常的内存。此函数将要分配的字节数作为参数。然后将异常有效负载(例如示例中的intdouble值)复制到分配的内存中。然后使用__cxa_throw()调用引发异常。此函数需要三个参数:指向分配的异常的指针;有关有效负载的类型信息;以及指向析构函数的指针,如果异常有效负载有一个的话。__cxa_throw()函数启动堆栈展开过程并且永远不会返回。在 LLVM IR 中,这是针对int值完成的,如下所示:

%eh = tail call i8* @__cxa_allocate_exception(i64 4)
%payload = bitcast i8* %eh to i32*
store i32 1, i32* %payload
tail call void @__cxa_throw(i8* %eh,
                   i8* bitcast (i8** @_ZTIi to i8*), i8* 
                   null)
unreachable

_ZTIi是描述int类型的类型信息。对于 double 类型,它将是_ZTId。对__cxa_throw()的调用被标记为尾调用,因为它是该函数中的最终调用,可能使当前堆栈帧得以重用。

到目前为止,还没有做任何特定于 LLVM 的工作。这在foo()函数中发生了变化,因为对bar()的调用可能会引发异常。如果是int类型的异常,则必须将控制流转移到catch子句的 IR 代码。为了实现这一点,必须使用invoke指令而不是call指令,如下面的代码片段所示:

%y = invoke i32 @_Z3bari(i32 %x) to label %next
                                 unwind label %lpad

两个指令之间的区别在于invoke有两个关联的标签。第一个标签是如果被调用的函数正常结束,通常是使用ret指令。在前面的代码示例中,这个标签称为%next。如果发生异常,则执行将继续在所谓的着陆垫上,具有%lpad标签。

着陆坪是一个基本的块,必须以landingpad指令开始。landingpad指令为 LLVM 提供了有关处理的异常类型的信息。对于foo()函数,它提供了以下信息:

lpad:
%exc = landingpad { i8*, i32 }
          cleanup
          catch i8* bitcast (i8** @_ZTIi to i8*)
          filter [1 x i8*] [i8* bitcast (i8** @_ZTIi to 
              i8*)]

这里有三种可能的操作类型,如下所述:

  • cleanup:这表示存在用于清理当前状态的代码。通常,这用于调用局部对象的析构函数。如果存在此标记,则在堆栈展开期间始终调用着陆坪。

  • catch:这是一个类型-值对的列表,表示可以处理的异常类型。如果抛出的异常类型在此列表中找到,则调用着陆坪。对于foo()函数,该值是指向int类型的 C++运行时类型信息的指针,类似于__cxa_throw()函数的参数。

  • filter:这指定了一个异常类型数组。如果当前异常的异常类型在数组中找不到,则调用着陆坪。这用于实现throw()规范。对于foo()函数,该数组只有一个成员——int类型的类型信息。

landingpad指令的结果类型是一个{ i8*, i32 }结构。第一个元素是指向抛出的异常的指针,而第二个元素是类型选择器。让我们从结构中提取这两个元素,如下所示:

%exc.ptr = extractvalue { i8*, i32 } %exc, 0
%exc.sel = extractvalue { i8*, i32 } %exc, 1

类型选择器是一个数字,它帮助我们识别为什么调用着陆坪的原因。如果当前异常类型与landingpad指令的catch部分中给定的异常类型之一匹配,则它具有正值。如果当前异常类型与filter部分中给定的任何值都不匹配,则该值为负值,如果应调用清理代码,则为0

基本上,类型选择器是偏移量,指向从landingpad指令的catchfilter部分中给定的值构造的类型信息表。在优化期间,多个着陆坪可以合并为一个,这意味着在 IR 级别不知道此表的结构。要检索给定类型的类型选择器,我们需要调用@llvm.eh.typeid.for内部函数。我们需要这样做来检查类型选择器的值是否对应于int的类型信息,以便能够执行catch (int e) {}块中的代码,如下所示:

%tid.int = tail call i32 @llvm.eh.typeid.for(
                             i8* bitcast (i8** @_ZTIi to 
                             i8*))
%tst.int = icmp eq i32 %exc.sel, %tid.int
br i1 % tst.int, label %catchint, label %filterorcleanup

异常处理由对__cxa_begin_catch()__cxa_end_catch()的调用框定。__cxa_begin_catch()函数需要一个参数:当前异常。这是landingpad指令返回的值之一。它返回指向异常有效负载的指针——在我们的情况下是一个int值。__cxa_end_catch()函数标记异常处理的结束,并释放使用__cxa_allocate_exception()分配的内存。请注意,如果在catch块内抛出另一个异常,则运行时行为要复杂得多。处理异常的方式如下:

catchint:
%payload = tail call i8* @__cxa_begin_catch(i8* %exc.ptr)
%payload.int = bitcast i8* %payload to i32*
%retval = load i32, i32* %payload.int
tail call void @__cxa_end_catch()
br label %return

如果当前异常的类型与throws()声明中的列表不匹配,则调用意外异常处理程序。首先,我们需要再次检查类型选择器,如下所示:

filterorcleanup:
%tst.blzero = icmp slt i32 %exc.sel, 0
br i1 %tst.blzero, label %filter, label %cleanup

如果类型选择器的值小于0,则调用处理程序,如下所示:

filter:
tail call void @__cxa_call_unexpected(i8* %exc.ptr) #4
unreachable

同样,不希望处理程序返回。

在这种情况下不需要清理工作,因此所有清理代码所做的就是恢复堆栈展开器的执行,如下所示:

cleanup:
resume { i8*, i32 } %exc

还有一部分缺失:libunwind驱动堆栈展开,但它与单一语言无关。语言相关的处理在personality函数中完成。对于 Linux 上的 C++,personality函数称为__gxx_personality_v0()。根据平台或编译器的不同,这个名称可能会有所不同。每个需要参与堆栈展开的函数都附有一个personality函数。personality函数分析函数是否捕获异常,是否有不匹配的过滤列表,或者是否需要清理调用。它将这些信息返回给展开器,展开器会相应地进行操作。在 LLVM IR 中,personality函数的指针作为函数定义的一部分给出,如下面的代码片段所示:

define i32 @_Z3fooi(i32) personality i8* bitcast
                     (i32 (...)* @__gxx_personality_v0 to 
                      i8*)

有了这些,异常处理功能就完成了。

要在编译器中为您的编程语言使用异常处理,最简单的策略是依附于现有的 C++运行时函数。这样做的优势是您的异常与 C++是可互操作的。缺点是您将一些 C++运行时绑定到您的语言运行时中,尤其是内存管理。如果您想避免这一点,那么您需要创建自己的_cxa_函数的等价物。但是,您仍然需要使用提供堆栈展开机制的libunwind

  1. 让我们看看如何创建这个 IR。我们在第三章中创建了calc表达式编译器,编译器的结构。现在我们将扩展表达式编译器的代码生成器,以便在执行除以0时引发和处理异常。生成的 IR 将检查除法的除数是否为0。如果为true,则会引发异常。我们还将在函数中添加一个着陆块,用于捕获异常,将Divide by zero!打印到控制台,并结束计算。在这种简单情况下,使用异常处理并不是真正必要的,但它允许我们集中精力在代码生成上。我们将所有代码添加到CodeGenerator.cpp文件中。我们首先添加所需的新字段和一些辅助方法。我们需要存储__cxa_allocate_exception()__cxa_throw()函数的 LLVM 声明,包括函数类型和函数本身。需要一个GlobalVariable实例来保存类型信息。我们还需要引用包含着陆块的基本块和只包含unreachable指令的基本块,如下面的代码片段所示:
  GlobalVariable *TypeInfo = nullptr;
  FunctionType *AllocEHFty = nullptr;
  Function *AllocEHFn = nullptr;
  FunctionType *ThrowEHFty = nullptr;
  Function *ThrowEHFn = nullptr;
  BasicBlock *LPadBB = nullptr;
  BasicBlock *UnreachableBB = nullptr;
  1. 我们还添加了一个新的辅助函数来创建比较两个值的 IR。createICmpEq()函数以LeftRight值作为参数进行比较。它创建一个compare指令,测试值的相等性,并创建一个分支指令到两个基本块,用于相等和不相等的情况。两个基本块通过TrueDestFalseDest参数的引用返回。新基本块的标签可以在TrueLabelFalseLabel参数中给出。代码如下所示:
  void createICmpEq(Value *Left, Value *Right,
                    BasicBlock *&TrueDest,
                    BasicBlock *&FalseDest,
                    const Twine &TrueLabel = "",
                    const Twine &FalseLabel = "") {
    Function *Fn =        Builder.GetInsertBlock()->getParent();
    TrueDest = BasicBlock::Create(M->getContext(),                                  TrueLabel, Fn);
    FalseDest = BasicBlock::Create(M->getContext(),                                   FalseLabel, Fn);
    Value *Cmp = Builder.CreateCmp(CmpInst::ICMP_EQ,                                   Left, Right);
    Builder.CreateCondBr(Cmp, TrueDest, FalseDest);
  }
  1. 使用运行时的函数,我们需要创建几个函数声明。在 LLVM 中,必须构建给出签名的函数类型以及函数本身。我们使用createFunc()方法来创建这两个对象。函数需要引用FunctionTypeFunction指针,新声明函数的名称和结果类型。参数类型列表是可选的,并且用来指示可变参数列表的标志设置为false,表示参数列表中没有可变部分。代码可以在以下片段中看到:
  void createFunc(FunctionType *&Fty, Function *&Fn,
                  const Twine &N, Type *Result,
                  ArrayRef<Type *> Params = None,
                  bool IsVarArgs = false) {
    Fty = FunctionType::get(Result, Params, IsVarArgs);
    Fn = Function::Create(
        Fty, GlobalValue::ExternalLinkage, N, M);
  }

准备工作完成后,我们继续生成 IR 来引发异常。

引发异常

为了生成引发异常的 IR 代码,我们添加了一个addThrow()方法。这个新方法需要初始化新字段,然后通过__cxa_throw函数生成引发异常的 IR。引发的异常的有效载荷是int类型,并且可以设置为任意值。以下是我们需要编写的代码:

  1. 新的addThrow()方法首先检查TypeInfo字段是否已初始化。如果没有,则创建一个i8*类型和_ZTIi名称的全局外部常量。这代表描述 C++ int类型的 C++元数据。代码如下所示:
  void addThrow(int PayloadVal) {
    if (!TypeInfo) {
      TypeInfo = new GlobalVariable(
          *M, Int8PtrTy,
          /*isConstant=*/true,
          GlobalValue::ExternalLinkage,
          /*Initializer=*/nullptr, "_ZTIi");
  1. 初始化继续创建__cxa_allocate_exception()__cxa_throw函数的 IR 声明,使用我们的createFunc()辅助方法,如下所示:
      createFunc(AllocEHFty, AllocEHFn,
                 "__cxa_allocate_exception", 
                 Int8PtrTy,
                 {Int64Ty});
      createFunc(ThrowEHFty, ThrowEHFn, "__cxa_throw",
                 VoidTy,
                 {Int8PtrTy, Int8PtrTy, Int8PtrTy});
  1. 使用异常处理的函数需要一个personality函数,它有助于堆栈展开。我们添加 IR 代码声明来自 C++库的__gxx_personality_v0() personality函数,并将其设置为当前函数的personality例程。当前函数没有存储为字段,但我们可以使用Builder实例查询当前基本块,该基本块将函数存储为parent字段,如下面的代码片段所示:
      FunctionType *PersFty;
      Function *PersFn;
      createFunc(PersFty, PersFn,                 "__gxx_personality_v0", Int32Ty, None,                 true);
      Function *Fn =          Builder.GetInsertBlock()->getParent();
      Fn->setPersonalityFn(PersFn);
  1. 接下来,我们创建并填充着陆块的基本块。首先,我们需要保存当前基本块的指针。然后,我们创建一个新的基本块,将其设置在构建器内部用作插入指令的基本块,并调用addLandingPad()方法。此方法生成处理异常的 IR 代码,并在下一节“捕获异常”中进行描述。以下代码填充了着陆块的基本块:
      BasicBlock *SaveBB = Builder.GetInsertBlock();
      LPadBB = BasicBlock::Create(M->getContext(),                                  "lpad", Fn);
      Builder.SetInsertPoint(LPadBB);
      addLandingPad();
  1. 初始化部分已经完成,创建了一个包含unreachable指令的基本块。然后,我们创建一个基本块,并将其设置为构建器的插入点。然后,我们向其中添加一个unreachable指令。最后,我们将构建器的插入点设置回保存的SaveBB实例,以便后续的 IR 添加到正确的基本块。代码如下所示:
      UnreachableBB = BasicBlock::Create(
          M->getContext(), "unreachable", Fn);
      Builder.SetInsertPoint(UnreachableBB);
      Builder.CreateUnreachable();
      Builder.SetInsertPoint(SaveBB);
    }
  1. 要引发异常,我们需要通过调用__cxa_allocate_exception()函数为异常和有效载荷分配内存。我们的有效载荷是 C++ int类型,通常大小为 4 字节。我们为大小创建一个常量无符号值,并调用该函数作为参数。函数类型和函数声明已经初始化,所以我们只需要创建一个call指令,如下所示:
    Constant *PayloadSz =       ConstantInt::get(Int64Ty, 4, false);
    CallInst *EH = Builder.CreateCall(        AllocEHFty, AllocEHFn, {PayloadSz});
  1. 接下来,我们将PayloadVal值存储到分配的内存中。为此,我们需要使用ConstantInt::get()函数创建一个 LLVM IR 常量。分配的内存指针是i8*类型,但要存储i32类型的值,我们需要创建一个bitcast指令来转换类型,如下所示:
    Value *PayloadPtr =        Builder.CreateBitCast(EH, Int32PtrTy);
    Builder.CreateStore(        ConstantInt::get(Int32Ty, PayloadVal, true),
        PayloadPtr);
  1. 最后,我们通过调用__cxa_throw函数引发异常。因为这个函数实际上引发的异常也在同一个函数中处理,所以我们需要使用invoke指令而不是call指令。与call指令不同,invoke指令结束一个基本块,因为它有两个后继基本块。在这里,它们是UnreachableBBLPadBB基本块。如果函数没有引发异常,控制流将转移到UnreachableBB基本块。由于__cxa_throw()函数的设计,这永远不会发生。控制流将转移到LPadBB基本块以处理异常。这完成了addThrow()方法的实现,如下面的代码片段所示:
    Builder.CreateInvoke(
        ThrowEHFty, ThrowEHFn, UnreachableBB, LPadBB,
        {EH, ConstantExpr::getBitCast(TypeInfo, 
         Int8PtrTy),
         ConstantPointerNull::get(Int8PtrTy)});
  }

接下来,我们添加生成处理异常的 IR 代码。

捕获异常

为了生成捕获异常的 IR 代码,我们添加了一个addLandingPad()方法。生成的 IR 从异常中提取类型信息。如果匹配 C++的int类型,那么异常将通过向控制台打印Divide by zero!并从函数中返回来处理。如果类型不匹配,我们简单地执行一个resume指令,将控制转回运行时。因为在调用层次结构中没有其他函数来处理这个异常,运行时将终止应用程序。这些是我们需要采取的步骤来生成捕获异常的 IR:

  1. 在生成的 IR 中,我们需要从 C++运行时库中调用__cxa_begin_catch()_cxa_end_catch()函数。为了打印错误消息,我们将从 C 运行时库生成一个调用puts()函数的调用,并且为了从异常中获取类型信息,我们必须生成一个调用llvm.eh.typeid.for指令。我们需要为所有这些都创建FunctionTypeFunction实例,并且利用我们的createFunc()方法来创建它们,如下所示:
  void addLandingPad() {
    FunctionType *TypeIdFty; Function *TypeIdFn;
    createFunc(TypeIdFty, TypeIdFn,
               "llvm.eh.typeid.for", Int32Ty,
               {Int8PtrTy});
    FunctionType *BeginCatchFty; Function 
        *BeginCatchFn;
    createFunc(BeginCatchFty, BeginCatchFn,
               "__cxa_begin_catch", Int8PtrTy,
               {Int8PtrTy});
    FunctionType *EndCatchFty; Function *EndCatchFn;
    createFunc(EndCatchFty, EndCatchFn,
               "__cxa_end_catch", VoidTy);
    FunctionType *PutsFty; Function *PutsFn;
    createFunc(PutsFty, PutsFn, "puts", Int32Ty,
               {Int8PtrTy});
  1. landingpad指令是我们生成的第一条指令。结果类型是一个包含i8*i32类型字段的结构。通过调用StructType::get()函数生成这个结构。我们处理 C++ int类型的异常,必须将其作为landingpad指令的一个子句添加。子句必须是i8*类型的常量,因此我们需要生成一个bitcast指令将TypeInfo值转换为这种类型。我们将指令返回的值存储在Exc变量中,以备后用,如下所示:
    LandingPadInst *Exc = Builder.CreateLandingPad(
        StructType::get(Int8PtrTy, Int32Ty), 1, "exc");
    Exc->addClause(ConstantExpr::getBitCast(TypeInfo, 
                   Int8PtrTy));
  1. 接下来,我们从返回值中提取类型选择器。通过调用llvm.eh.typeid.for内部函数,我们检索TypeInfo字段的类型 ID,表示 C++的int类型。有了这个 IR,我们现在已经生成了我们需要比较的两个值,以决定是否可以处理异常,如下面的代码片段所示:
    Value *Sel = Builder.CreateExtractValue(Exc, {1},                  "exc.sel");
    CallInst *Id =
        Builder.CreateCall(TypeIdFty, TypeIdFn,
                           {ConstantExpr::getBitCast(
                               TypeInfo, Int8PtrTy)});
  1. 为了生成比较的 IR,我们调用我们的createICmpEq()函数。这个函数还生成了两个基本块,我们将它们存储在TrueDestFalseDest变量中,如下面的代码片段所示:
    BasicBlock *TrueDest, *FalseDest;
    createICmpEq(Sel, Id, TrueDest, FalseDest, 
                 "match",
                 "resume");
  1. 如果两个值不匹配,控制流将在FalseDest基本块继续。这个基本块只包含一个resume指令,将控制返回给 C++运行时。下面的代码片段中有示例:
    Builder.SetInsertPoint(FalseDest);
    Builder.CreateResume(Exc);
  1. 如果两个值相等,控制流将在TrueDest基本块继续。我们首先生成 IR 代码,从landingpad指令的返回值中提取指向异常的指针,存储在Exc变量中。然后,我们生成一个调用__cxa_begin_catch()函数的调用,将指向异常的指针作为参数传递。这表示异常开始被运行时处理,如下面的代码片段所示:
    Builder.SetInsertPoint(TrueDest);
    Value *Ptr =
        Builder.CreateExtractValue(Exc, {0}, 
            "exc.ptr");
    Builder.CreateCall(BeginCatchFty, BeginCatchFn,
                       {Ptr});
  1. 我们通过调用puts()函数来处理异常,向控制台打印一条消息。为此,我们首先通过调用CreateGlobalStringPtr()函数生成一个指向字符串的指针,然后将这个指针作为参数传递给生成的puts()函数调用,如下所示:
    Value *MsgPtr = Builder.CreateGlobalStringPtr(
        "Divide by zero!", "msg", 0, M);
    Builder.CreateCall(PutsFty, PutsFn, {MsgPtr});
  1. 这完成了异常处理,并生成了一个调用__cxa_end_catch()函数通知运行时的过程。最后,我们使用ret指令从函数中返回,如下所示:
    Builder.CreateCall(EndCatchFty, EndCatchFn);
    Builder.CreateRet(Int32Zero);
  }

通过addThrow()addLandingPad()函数,我们可以生成 IR 来引发异常和处理异常。我们仍然需要添加 IR 来检查除数是否为0,这是下一节的主题。

将异常处理代码集成到应用程序中

除法的 IR 是在visit(BinaryOp&)方法中生成的。我们首先生成 IR 来比较除数和0,而不仅仅是生成一个sdiv指令。如果除数是0,那么控制流将继续在一个基本块中引发异常。否则,控制流将在一个包含sdiv指令的基本块中继续。借助createICmpEq()addThrow()函数,我们可以很容易地编写这个代码。

    case BinaryOp::Div:
      BasicBlock *TrueDest, *FalseDest;
      createICmpEq(Right, Int32Zero, TrueDest,
                   FalseDest, "divbyzero", "notzero");
      Builder.SetInsertPoint(TrueDest);
      addThrow(42); // Arbitrary payload value.
      Builder.SetInsertPoint(FalseDest);
      V = Builder.CreateSDiv(Left, Right);
      break;

代码生成部分现在已经完成。要构建应用程序,您需要切换到build目录并运行ninja工具。

$ ninja

构建完成后,您可以检查生成的 IR,例如使用with a: 3/a表达式。

$ src/calc "with a: 3/a"

您将看到引发和捕获异常所需的额外 IR。

生成的 IR 现在依赖于 C++运行时。链接所需库的最简单方法是使用 clang++编译器。将用于表达式计算器的运行时函数的rtcalc.c文件重命名为rtcalc.cpp,并在文件中的每个函数前面添加extern "C"。然后我们可以使用llc工具将生成的 IR 转换为目标文件,并使用 clang++编译器创建可执行文件。

$ src/calc "with a: 3/a" | llc -filetype obj -o exp.o
$ clang++ -o exp exp.o ../rtcalc.cpp

然后,我们可以使用不同的值运行生成的应用程序,如下所示:

$ ./exp
Enter a value for a: 1
The result is: 3
$ ./exp
Enter a value for a: 0
Divide by zero!

在第二次运行中,输入为0,这引发了一个异常。这符合预期!

我们已经学会了如何引发和捕获异常。生成 IR 的代码可以用作其他编译器的蓝图。当然,所使用的类型信息和catch子句的数量取决于编译器的输入,但我们需要生成的 IR 仍然遵循本节中提出的模式。

添加元数据是向 LLVM 提供更多信息的一种方式。在下一节中,我们将添加类型元数据以支持 LLVM 优化器在某些情况下的使用。

为基于类型的别名分析生成元数据

两个指针可能指向同一内存单元,然后它们彼此别名。在 LLVM 模型中,内存没有类型,这使得优化器难以确定两个指针是否彼此别名。如果编译器可以证明两个指针不会别名,那么就有可能进行更多的优化。在下一节中,我们将更仔细地研究这个问题,并探讨如何添加额外的元数据将有所帮助,然后再实施这种方法。

理解需要额外元数据的原因

为了演示问题,让我们看一下以下函数:

void doSomething(int *p, float *q) {
  *p = 42;
  *q = 3.1425;
} 

优化器无法确定pq指针是否指向同一内存单元。在优化过程中,这是一个重要的分析,称为pq指向同一内存单元,那么它们是别名。如果优化器可以证明这两个指针永远不会别名,这将提供额外的优化机会。例如,在soSomething()函数中,存储可以重新排序而不改变结果。

这取决于源语言的定义,一个类型的变量是否可以是不同类型的另一个变量的别名。请注意,语言也可能包含打破基于类型的别名假设的表达式,例如不相关类型之间的类型转换。

LLVM 开发人员选择的解决方案是向loadstore指令添加元数据。元数据有两个目的,如下所述:

  • 首先,它基于类型层次结构定义了类型层次结构,其中一个类型可能是另一个类型的别名

  • 其次,它描述了loadstore指令中的内存访问

让我们来看看 C 中的类型层次结构。每种类型层次结构都以根节点开头,可以是命名匿名。LLVM 假设具有相同名称的根节点描述相同类型的层次结构。您可以在相同的 LLVM 模块中使用不同的类型层次结构,LLVM 会安全地假设这些类型可能会别名。在根节点下面,有标量类型的节点。聚合类型的节点不附加到根节点,但它们引用标量类型和其他聚合类型。Clang 为 C 定义了以下层次结构:

  • 根节点称为Simple C/C++ TBAA

  • 在根节点下面是char类型的节点。这是 C 中的特殊类型,因为所有指针都可以转换为指向char的指针。

  • char节点下面是其他标量类型的节点和一个名为any pointer的所有指针类型。

聚合类型被定义为一系列成员类型和偏移量。

这些元数据定义用于附加到loadstore指令的访问标签。访问标签由三部分组成:基本类型、访问类型和偏移量。根据基本类型,访问标签描述内存访问的方式有两种可能,如下所述:

  1. 如果基本类型是聚合类型,则访问标签描述了struct成员的内存访问,具有访问类型,并位于给定偏移量处。

  2. 如果基本类型是标量类型,则访问类型必须与基本类型相同,偏移量必须为0

有了这些定义,我们现在可以在访问标签上定义一个关系,用于评估两个指针是否可能别名。元组(基本类型,偏移量)的直接父节点由基本类型和偏移量确定,如下所示:

  • 如果基本类型是标量类型且偏移量为 0,则直接父节点是(父类型,0),其中父类型是在类型层次结构中定义的父节点的类型。如果偏移量不为 0,则直接父节点未定义。

  • 如果基本类型是聚合类型,则元组(基本类型,偏移量)的直接父节点是元组(新类型,新偏移量),其中新类型是在偏移量处的成员的类型。新偏移量是新类型的偏移量,调整为其新的起始位置。

这个关系的传递闭包是父关系。例如,(基本类型 1,访问类型 1,偏移 1)和(基本类型 2,访问类型 2,偏移 2)这两种内存访问类型可能会别名,如果(基本类型 1,偏移 1)和(基本类型 2,偏移 2)或者反之亦然在父关系中相关联。

让我们通过一个例子来说明:

struct Point { float x, y; }
void func(struct Point *p, float *x, int *i, char *c) {
  p->x = 0; p->y = 0; *x = 0.0; *i = 0; *c = 0; 
}

使用前面对标量类型的内存访问标签定义,参数i的访问标签是(intint0),参数c的访问标签是(charchar0)。在类型层次结构中,int类型的节点的父节点是char节点,因此(int0)的直接父节点是(char0),两个指针可能会别名。对于参数x和参数c也是如此。但是参数xi没有关联,因此它们不会别名。struct Pointy成员的访问是(Pointfloat4),4 是结构体中y成员的偏移量。因此(Point4)的直接父节点是(float0),因此p->yx的访问可能会别名,并且根据相同的推理,也会与参数c别名。

要创建元数据,我们使用llvm::MDBuilder类,该类在llvm/IR/MDBuilder.h头文件中声明。数据本身存储在llvm::MDNodellvm::MDString类的实例中。使用构建器类可以保护我们免受构造的内部细节的影响。

通过调用createTBAARoot()方法创建根节点,该方法需要类型层次结构的名称作为参数,并返回根节点。可以使用createAnonymousTBAARoot()方法创建匿名唯一根节点。

使用createTBAAScalarTypeNode()方法将标量类型添加到层次结构中,该方法以类型的名称和父节点作为参数。为聚合类型添加类型节点稍微复杂一些。createTBAAStructTypeNode()方法以类型的名称和字段列表作为参数。字段作为std::pair<llvm::MDNode*, uint64_t>实例给出。第一个元素表示成员的类型,第二个元素表示struct类型中的偏移量。

使用createTBAAStructTagNode()方法创建访问标签,该方法以基本类型、访问类型和偏移量作为参数。

最后,元数据必须附加到loadstore指令上。llvm::Instruction类有一个setMetadata()方法,用于添加各种元数据。第一个参数必须是llvm::LLVMContext::MD_tbaa,第二个参数必须是访问标签。

掌握了这些知识,我们将在下一节为tinylang添加元数据。

为 tinylang 添加 TBAA 元数据

为了支持 TBAA,我们添加了一个新的CGTBAA类。这个类负责生成元数据节点。我们将它作为CGModule类的成员,称之为TBAA。每个loadstore指令都可能被注释,我们也在CGModule类中放置了一个新的函数来实现这个目的。该函数尝试创建标签访问信息。如果成功,元数据将附加到指令上。这种设计还允许我们在不需要元数据的情况下关闭元数据生成,例如在关闭优化的构建中。代码如下所示:

void CGModule::decorateInst(llvm::Instruction *Inst,
                            TypeDenoter *TyDe) {
  if (auto *N = TBAA.getAccessTagInfo(TyDe))
    Inst->setMetadata(llvm::LLVMContext::MD_tbaa, N);
}

我们将新的CGTBAA类的声明放入include/tinylang/CodeGen/CGTBAA.h头文件中,并将定义放入lib/CodeGen/CGTBAA.cpp文件中。除了抽象语法树AST)定义之外,头文件还需要包括定义元数据节点和构建器的文件,如下面的代码片段所示:

#include "tinylang/AST/AST.h"
#include "llvm/IR/MDBuilder.h"
#include "llvm/IR/Metadata.h"

CGTBAA类需要存储一些数据成员。因此,让我们逐步看看如何做到这一点,如下所示:

  1. 首先,我们需要缓存类型层次结构的根,如下所示:
 class CGTBAA {
  llvm::MDNode *Root;
  1. 为了构造元数据节点,我们需要MDBuilder类的一个实例,如下所示:
  llvm::MDBuilder MDHelper;
  1. 最后,我们将为类型生成的元数据存储起来以便重用,如下所示:
  llvm::DenseMap<TypeDenoter *, llvm::MDNode *> 
    MetadataCache;
// …
};

在定义构造所需的变量之后,我们现在添加了创建元数据所需的方法,如下所示:

  1. 构造函数初始化数据成员,如下所示:
CGTBAA::CGTBAA(llvm::LLVMContext &Ctx)
      : MDHelper(llvm::MDBuilder(Ctx)), Root(nullptr) {}
  1. 我们懒惰地实例化类型层次结构的根,我们称之为Simple tinylang TBAA,如下面的代码片段所示:
llvm::MDNode *CGTBAA::getRoot() {
  if (!Root)
    Root = MDHelper.createTBAARoot("Simple tinylang                                    TBAA");
  return Root;
}
  1. 对于标量类型,我们使用MDBuilder类根据类型的名称创建元数据节点。新的元数据节点存储在缓存中,如下面的代码片段所示:
llvm::MDNode *
CGTBAA::createScalarTypeNode(TypeDeclaration *Ty,
                             StringRef Name,
                             llvm::MDNode *Parent) {
  llvm::MDNode *N =
      MDHelper.createTBAAScalarTypeNode(Name, Parent);
  return MetadataCache[Ty] = N;
}
  1. 创建记录的元数据的方法更加复杂,因为我们必须枚举记录的所有字段。代码如下所示:
llvm::MDNode *CGTBAA::createStructTypeNode(
    TypeDeclaration *Ty, StringRef Name,
    llvm::ArrayRef<std::pair<llvm::MDNode *, 
        uint64_t>>
        Fields) {
  llvm::MDNode *N =
      MDHelper.createTBAAStructTypeNode(Name, Fields);
  return MetadataCache[Ty] = N;
}
  1. 为了返回tinylang类型的元数据,我们需要创建类型层次结构。由于tinylang的类型系统非常受限,我们可以使用简单的方法。每个标量类型都映射到附加到根节点的唯一类型,我们将所有指针映射到单个类型。结构化类型然后引用这些节点。如果我们无法映射类型,我们将返回nullptr,如下所示:
llvm::MDNode *CGTBAA::getTypeInfo(TypeDeclaration *Ty) {
  if (llvm::MDNode *N = MetadataCache[Ty])
    return N;
  if (auto *Pervasive =
          llvm::dyn_cast<PervasiveTypeDeclaration>(Ty)) {
    StringRef Name = Pervasive->getName();
    return createScalarTypeNode(Pervasive, Name, 
        getRoot());
  }
  if (auto *Pointer =
          llvm::dyn_cast<PointerTypeDeclaration>(Ty)) {
    StringRef Name = "any pointer";
    return createScalarTypeNode(Pointer, Name, 
        getRoot());
  }
  if (auto *Record =
          llvm::dyn_cast<RecordTypeDeclaration>(Ty)) {
    llvm::SmallVector<std::pair<llvm::MDNode *, 
        uint64_t>,
                      4>
        Fields;
    auto *Rec =
        llvm::cast<llvm::StructType>(              CGM.convertType(Record));
    const llvm::StructLayout *Layout =
        CGM.getModule()->getDataLayout()
            .getStructLayout(Rec);
    unsigned Idx = 0;
    for (const auto &F : Record->getFields()) {
      uint64_t Offset = Layout->getElementOffset(Idx);
      Fields.emplace_back(getTypeInfo(F.getType()), 
          Offset);
      ++Idx;
    }
    StringRef Name = CGM.mangleName(Record);
    return createStructTypeNode(Record, Name, Fields);
  }
  return nullptr;
}
  1. 获取元数据的通用方法是getAccessTagInfo()。因为我们只需要查找指针类型,所以我们进行了检查。否则,我们返回nullptr,如下面的代码片段所示:
llvm::MDNode *CGTBAA::getAccessTagInfo(TypeDenoter *TyDe) 
{
  if (auto *Pointer = llvm::dyn_cast<PointerType>(TyDe)) 
  {
    return getTypeInfo(Pointer->getTyDen());
  }
  return nullptr;
}

为了启用 TBAA 元数据的生成,我们现在只需要将元数据附加到我们生成的loadstore指令上。例如,在CGProcedure::writeVariable()中,对全局变量的存储,使用store指令,如下所示:

      Builder.CreateStore(Val, CGM.getGlobal(D));

为了装饰指令,我们需要用以下行替换前一行:

      auto *Inst = Builder.CreateStore(Val,
                                       CGM.getGlobal(Decl));
      CGM.decorateInst(Inst, V->getTypeDenoter());

有了这些变化,我们已经完成了 TBAA 元数据的生成。

在下一节中,我们将讨论一个非常相似的主题:调试元数据的生成。

添加调试元数据

为了允许源级调试,我们必须添加调试信息。LLVM 中的调试信息支持使用调试元数据来描述源语言的类型和其他静态信息,并使用内在函数来跟踪变量值。LLVM 核心库在 Unix 系统上生成 DWARF 格式的调试信息,在 Windows 上生成蛋白质数据银行PDB)格式。我们将在下一节中看一下一般的结构。

理解调试元数据的一般结构

为了描述静态结构,LLVM 使用元数据类似于基于类型的分析的元数据。静态结构描述文件、编译单元、函数、词法块和使用的数据类型。

我们使用的主要类是llvm::DIBuilder,我们需要使用llvm/IR/DIBuilder包含文件来获取类声明。这个构建器类提供了一个易于使用的接口来创建调试元数据。稍后,元数据要么添加到 LLVM 对象,比如全局变量,要么在调试内部使用。构建器类可以创建的重要元数据在这里列出:

  • lvm::DIFile:使用文件名和包含文件的目录的绝对路径来描述文件。您可以使用createFile()方法来创建它。一个文件可以包含主编译单元,也可以包含导入的声明。

  • llvm::DICompileUnit:用于描述当前编译单元。除其他内容外,您需要指定源语言、特定于编译器的生产者字符串,是否启用优化,以及编译单元所在的DIFile。您可以通过调用createCompileUnit()来创建它。

  • llvm::DISubprogram:描述一个函数。重要信息是作用域(通常是DICompileUnit或嵌套函数的DISubprogram)、函数的名称、函数的重整名和函数类型。它是通过调用createFunction()来创建的。

  • llvm::DILexicalBlock:描述了许多高级语言中找到的块作用域的词法块。您可以通过调用createLexicalBlock()来创建它。

LLVM 不对编译器翻译的语言做任何假设。因此,它对语言的数据类型没有任何信息。为了支持源级调试,特别是在调试器中显示变量值,也必须添加类型信息。这里列出了重要的构造:

  • createBasicType()函数返回一个指向llvm::DIBasicType类的指针,用于创建描述tinylang中的INTEGER或 C++中的int等基本类型的元数据。除了类型的名称,所需的参数是位大小和编码,例如,它是有符号还是无符号类型。

  • 有几种方法可以构造复合数据类型的元数据,由llvm::DIComposite类表示。您可以使用createArrayType()createStructType()createUnionType()createVectorType()函数来实例化arraystructunionvector数据类型的元数据。这些函数需要您期望的参数,例如,数组类型的基本类型和订阅数量,或者struct类型的字段成员列表。

  • 还有支持枚举、模板、类等的方法。

函数列表显示您必须将源语言的每个细节添加到调试信息中。假设您的llvm::DIBuilder类的实例称为DBuilder。进一步假设您在名为File.mod的文件中有一些tinylang源码,位于/home/llvmuser文件夹中。文件中有一个在第 5 行包含在第 7 行包含一个VAR i:INTEGER本地声明的Func():INTEGER函数。让我们从文件的信息开始创建这些元数据。您需要指定文件名和文件所在文件夹的绝对路径,如下面的代码片段所示:

llvm::DIFile *DbgFile = DBuilder.createFile("File.mod",
                                            "/home/llvmuser"); 

文件是tinylang中的一个模块,因此是 LLVM 的编译单元。这携带了大量信息,如下面的代码片段所示:

bool IsOptimized = false;
llvm::StringRef CUFlags;
unsigned ObjCRunTimeVersion = 0;
llvm::StringRef SplitName;
llvm::DICompileUnit::DebugEmissionKind EmissionKind =
      llvm::DICompileUnit::DebugEmissionKind::FullDebug;
llvm::DICompileUnit *DbgCU = DBuilder.createCompileUnit(
      llvm::dwarf::DW_LANG_Modula2, DbgFile, „tinylang",
      IsOptimized, CUFlags, ObjCRunTimeVersion, SplitName,
      EmissionKind);

调试器需要知道源语言。DWARF 标准定义了一个包含所有常见值的枚举。一个缺点是您不能简单地添加一个新的源语言。要做到这一点,您必须通过 DWARF 委员会创建一个请求。请注意,调试器和其他调试工具也需要支持新语言,仅仅向枚举添加一个新成员是不够的。

在许多情况下,选择一个接近您源语言的语言就足够了。在tinylang的情况下,这是 Modula-2,我们使用DW_LANG_Modula2进行语言识别。编译单元位于一个文件中,由我们之前创建的DbgFile变量标识。调试信息可以携带有关生产者的信息。这可以是编译器的名称和版本信息。在这里,我们只传递一个tinylang字符串。如果您不想添加这些信息,那么您可以简单地将一个空字符串作为参数。

下一组信息包括一个IsOptimized标志,应指示编译器是否已经打开了优化。通常,此标志是从-O命令行开关派生的。您可以使用CUFlags参数向调试器传递附加的参数设置。这里没有使用,我们传递一个空字符串。我们不使用 Objective-C,所以我们将0作为 Objective-C 运行时版本传递。通常,调试信息嵌入在我们正在创建的目标文件中。如果我们想要将调试信息写入一个单独的文件中,那么SplitName参数必须包含此文件的名称;否则,只需传递一个空字符串。最后,您可以定义应该发出的调试信息级别。默认设置是完整的调试信息,通过使用FullDebug枚举值表示。如果您只想发出行号,则可以选择LineTablesOnly值,或者选择NoDebug值以完全不发出调试信息。对于后者,最好一开始就不创建调试信息。

我们的最小化源码只使用INTEGER数据类型,这是一个带符号的 32 位值。为此类型创建元数据是直接的,可以在以下代码片段中看到:

llvm::DIBasicType *DbgIntTy =
                       DBuilder.createBasicType("INTEGER", 32,
                                  llvm::dwarf::DW_ATE_signed);

要为函数创建调试元数据,我们首先必须为签名创建一个类型,然后为函数本身创建元数据。这类似于为函数创建 IR。函数的签名是一个数组,其中包含源顺序中所有参数的类型以及函数的返回类型作为索引0处的第一个元素。通常,此数组是动态构建的。在我们的情况下,我们也可以静态构建元数据。这对于内部函数(例如模块初始化)非常有用。通常,这些函数的参数是已知的,并且编译器编写者可以硬编码它们。代码如下所示:

llvm::Metadata *DbgSigTy = {DbgIntTy};
llvm::DITypeRefArray DbgParamsTy =
                      DBuilder.getOrCreateTypeArray(DbgSigTy);
llvm::DISubroutineType *DbgFuncTy =
                   DBuilder.createSubroutineType(DbgParamsTy);

我们的函数具有INTEGER返回类型和没有其他参数,因此DbgSigTy数组仅包含指向此类型元数据的指针。这个静态数组被转换成类型数组,然后用于创建函数的类型。

函数本身需要更多的数据,如下所示:

unsigned LineNo = 5;
unsigned ScopeLine = 5;
llvm::DISubprogram *DbgFunc = DBuilder.createFunction(
      DbgCU, "Func", "_t4File4Func", DbgFile, LineNo,
      DbgFuncTy, ScopeLine, 
      llvm::DISubprogram::FlagPrivate,
      llvm::DISubprogram::SPFlagLocalToUnit);

函数属于编译单元,在我们的案例中存储在DbgCU变量中。我们需要在源文件中指定函数的名称,即Func,并且搅乱的名称存储在目标文件中。这些信息帮助调试器在以后定位函数的机器代码。根据tinylang的规则,搅乱的名称是_t4File4Func。我们还需要指定包含函数的文件。

这一开始可能听起来令人惊讶,但想想 C 和 C++中的包含机制:一个函数可以存储在不同的文件中,然后在主编译单元中用#include包含。在这里,情况并非如此,我们使用与编译单元相同的文件。接下来,传递函数的行号和函数类型。函数的行号可能不是函数的词法范围开始的行号。在这种情况下,您可以指定不同的ScopeLine。函数还有保护,我们在这里用FlagPrivate值指定为私有函数。其他可能的值是FlagPublicFlagProtected,分别表示公共和受保护的函数。

除了保护级别,这里还可以指定其他标志。例如,FlagVirtual表示虚函数,FlagNoReturn表示函数不会返回给调用者。您可以在llvm/include/llvm/IR/DebugInfoFlags.def的 LLVM 包含文件中找到所有可能的值的完整列表。最后,还可以指定特定于函数的标志。最常用的是SPFlagLocalToUnit值,表示该函数是本编译单元的本地函数。还经常使用的是MainSubprogram值,表示该函数是应用程序的主函数。您还可以在前面提到的 LLVM 包含文件中找到所有可能的值。

到目前为止,我们只创建了引用静态数据的元数据。变量是动态的,我们将在下一节中探讨如何将静态元数据附加到 IR 代码以访问变量。

跟踪变量及其值

要有用,上一节中描述的类型元数据需要与源程序的变量关联起来。对于全局变量,这相当容易。llvm::DIBuilder类的createGlobalVariableExpression()函数创建了描述全局变量的元数据。这包括源中变量的名称、搅乱的名称、源文件等。LLVM IR 中的全局变量由GlobalVariable类的实例表示。该类有一个addDebugInfo()方法,它将从createGlobalVariableExpression()返回的元数据节点与全局变量关联起来。

对于局部变量,我们需要采取另一种方法。LLVM IR 不知道表示局部变量的类;它只知道值。LLVM 社区开发的解决方案是在函数的 IR 代码中插入对内部函数的调用。内部函数是 LLVM 知道的函数,因此可以对其进行一些魔术操作。在大多数情况下,内部函数不会导致机器级别的子例程调用。在这里,函数调用是一个方便的工具,用于将元数据与值关联起来。

调试元数据最重要的内部函数是llvm.dbg.declarellvm.dbg.value。前者用于声明局部变量的地址,而后者在将局部变量设置为新值时调用。

未来的 LLVM 版本将用 llvm.dbg.addr 内部函数替换 llvm.dbg.declare

llvm.dbg.declare内部函数做出了一个非常强烈的假设:调用中描述的变量的地址在函数的整个生命周期内都是有效的。这个假设使得在优化期间保留调试元数据变得非常困难,因为真实的存储地址可能会发生变化。为了解决这个问题,设计了一个名为llvm.dbg.addr的新内部函数。这个内部函数接受与llvm.dbg.declare相同的参数,但语义不那么严格。它仍然描述了局部变量的地址,前端应该生成对它的调用。

在优化期间,传递可以用(可能是多个)对llvm.dbg.value和/或llvm.dbg.addr的调用来替换这个内部函数,以保留调试信息。

llvm.dbg.addr的工作完成后,llvm.dbg.declare内部函数将被弃用并最终移除。

它是如何工作的?LLVM IR 表示和通过llvm::DIBuilder类进行编程创建有些不同,因此我们需要同时看两者。继续上一节的例子,我们使用alloca指令在Func函数内为i变量分配局部存储空间,如下所示:

@i = alloca i32

之后,我们添加一个对llvm.dbg.declare内部函数的调用,如下所示:

call void @llvm.dbg.declare(metadata i32* %i,
                        metadata !1, metadata 
                        !DIExpression())

第一个参数是局部变量的地址。第二个参数是描述局部变量的元数据,由llvm::DIBuilder类的createAutoVariable()createParameterVariable()调用创建。第三个参数描述一个地址表达式,稍后我会解释。

让我们实现 IR 创建。您可以使用llvm::IRBuilder<>类的CreateAlloca()方法为@i局部变量分配存储空间,如下所示:

llvm::Type *IntTy = llvm::Type::getInt32Ty(LLVMCtx);
llvm::Value *Val = Builder.CreateAlloca(IntTy, nullptr, "i");

LLVMCtx变量是使用的上下文类,Builderllvm::IRBuilder<>类的实例。

局部变量也需要用元数据描述,如下所示:

llvm::DILocalVariable *DbgLocalVar =
 DBuilder.createAutoVariable(DbgFunc, "i", DbgFile,
                             7, DbgIntTy);

使用上一节中的值,我们指定变量是DbgFunc函数的一部分,名称为i,在由DbgFile命名的文件中定义,位于第 7 行,类型为DbgIntTy

最后,我们使用llvm.dbg.declare内部函数将调试元数据与变量的地址关联起来。使用llvm::DIBuilder可以屏蔽掉添加调用的所有细节。代码如下所示:

llvm::DILocation *DbgLoc =
                llvm::DILocation::get(LLVMCtx, 7, 5, 
                                      DbgFunc);
DBuilder.insertDeclare(Val, DbgLocalVar,
                       DBuilder.createExpression(), DbgLoc,
                       Val.getParent());

同样,我们需要为变量指定源位置。llvm::DILocation的实例是一个容器,用于保存与作用域关联的位置的行和列。insertDeclare()方法向 LLVM IR 添加对内部函数的调用。作为参数,它需要变量的地址(存储在Val中)和变量的调试元数据(存储在DbgValVar中)。我们还传递了一个空地址表达式和之前创建的调试位置。与普通指令一样,我们需要指定将调用插入到哪个基本块中。如果我们指定了一个基本块,那么调用将插入到末尾。或者,我们可以指定一个指令,调用将插入到该指令之前。我们有指向alloca指令的指针,这是我们插入到基本块中的最后一个指令。因此,我们使用这个基本块,调用将在alloca指令之后追加。

如果局部变量的值发生变化,那么必须在 IR 中添加对llvm.dbg.value的调用。您可以使用llvm::DIBuilderinsertValue()方法来实现。对于llvm.dbg.addr也是类似的。不同之处在于,现在指定的是变量的新值,而不是变量的地址。

在我们为函数实现 IR 生成时,我们使用了一种先进的算法,主要使用值并避免为局部变量分配存储空间。为了添加调试信息,这意味着我们在 Clang 生成的 IR 中使用llvm.dbg.value的频率要比你看到的要高得多。

如果变量没有专用存储空间,而是属于较大的聚合类型,我们可以怎么办?可能出现这种情况的一种情况是使用嵌套函数。为了实现对调用者堆栈帧的访问,您需要将所有使用的变量收集到一个结构中,并将指向此记录的指针传递给被调用的函数。在被调用的函数内部,您可以将调用者的变量视为函数的本地变量。不同的是,这些变量现在是聚合的一部分。

在调用llvm.dbg.declare时,如果调试元数据描述了第一个参数指向的整个内存,则使用空表达式。如果它只描述内存的一部分,则需要添加一个表达式,指示元数据适用于内存的哪一部分。在嵌套帧的情况下,需要计算到帧的偏移量。您需要访问DataLayout实例,可以从您正在创建 IR 代码的 LLVM 模块中获取。如果llvm::Module实例命名为Mod,则包含嵌套帧结构的变量命名为Frame,类型为llvm::StructType,并且您可以访问帧的第三个成员。然后,您可以得到成员的偏移量,如下面的代码片段所示:

const llvm::DataLayout &DL = Mod->getDataLayout();
uint64_t Ofs = DL.getStructLayout(Frame)
               ->getElementOffset(3);

表达式是从一系列操作中创建的。为了访问帧的第三个成员,调试器需要将偏移量添加到基指针。您需要创建一个数组和这个信息,例如:

llvm::SmallVector<int64_t, 2> AddrOps;
AddrOps.push_back(llvm::dwarf::DW_OP_plus_uconst);
AddrOps.push_back(Offset);

从这个数组中,您可以创建一个表达式,然后将其传递给llvm.dbg.declare,而不是空表达式,如下所示:

llvm::DIExpression *Expr = DBuilder.createExpression(AddrOps);

您不仅限于此偏移操作。DWARF 知道许多不同的操作符,您可以创建相当复杂的表达式。您可以在llvm/include/llvm/BinaryFormat/Dwarf.def LLVM 包含文件中找到操作符的完整列表。

现在,您可以为变量创建调试信息。为了使调试器能够跟踪源代码中的控制流,您还需要提供行号信息,这是下一节的主题。

添加行号

调试器允许程序员逐行浏览应用程序。为此,调试器需要知道哪些机器指令属于源代码中的哪一行。LLVM 允许在每条指令中添加源位置。在上一节中,我们创建了llvm::DILocation类型的位置信息。调试位置具有比行、列和作用域更多的信息。如果需要,可以指定此行内联的作用域。还可以指示此调试位置属于隐式代码,即前端生成的但不在源代码中的代码。

在将调试位置附加到指令之前,我们必须将调试位置包装在llvm::DebugLoc对象中。为此,您只需将从llvm::DILocation类获得的位置信息传递给llvm::DebugLoc构造函数。通过这种包装,LLVM 可以跟踪位置信息。虽然源代码中的位置显然不会改变,但是源级语句或表达式的生成机器代码可能会在优化期间被丢弃。封装有助于处理这些可能的更改。

将行号信息添加到生成的指令中主要是从 AST 中检索行号信息,并将其添加到生成的指令中。llvm::Instruction类有setDebugLoc()方法,它将位置信息附加到指令上。

在下一节中,我们将向我们的tinylang编译器添加调试信息的生成。

为 tinylang 添加调试支持

我们将调试元数据的生成封装在新的CGDebugInfo类中。我们将声明放入tinylang/CodeGen/CGDebugInfo.h头文件中,将定义放入tinylang/CodeGen/CGDebugInfo.cpp文件中。

CGDebugInfo类有五个重要成员。我们需要模块的代码生成器CGM的引用,因为我们需要将 AST 表示的类型转换为 LLVM 类型。当然,我们还需要llvm::DIBuilder类的实例DBuilder,就像前面的部分一样。还需要编译单元的指针,并将其存储在名为CU的成员中。

为了避免重复创建类型的调试元数据,我们还添加了一个用于缓存这些信息的映射。成员称为TypeCache。最后,我们需要一种管理作用域信息的方法,为此我们基于llvm::SmallVector<>类创建了一个名为ScopeStack的堆栈。因此,我们有以下代码:

  CGModule &CGM;
  llvm::DIBuilder DBuilder;
  llvm::DICompileUnit *CU;
  llvm::DenseMap<TypeDeclaration *, llvm::DIType *>
      TypeCache;
  llvm::SmallVector<llvm::DIScope *, 4> ScopeStack;

CGDebugInfo类的以下方法都使用了这些成员:

  1. 首先,我们需要在构造函数中创建编译单元。我们还在这里创建包含编译单元的文件。稍后,我们可以通过CU成员引用该文件。构造函数的代码如下所示:
CGDebugInfo::CGDebugInfo(CGModule &CGM)
    : CGM(CGM), DBuilder(*CGM.getModule()) {
  llvm::SmallString<128> Path(
      CGM.getASTCtx().getFilename());
  llvm::sys::fs::make_absolute(Path);
  llvm::DIFile *File = DBuilder.createFile(
      llvm::sys::path::filename(Path),
      llvm::sys::path::parent_path(Path));
  bool IsOptimized = false;
  unsigned ObjCRunTimeVersion = 0;
  llvm::DICompileUnit::DebugEmissionKind EmissionKind =
      llvm::DICompileUnit::DebugEmissionKind::FullDebug;
  CU = DBuilder.createCompileUnit(
      llvm::dwarf::DW_LANG_Modula2, File, "tinylang",
      IsOptimized, StringRef(), ObjCRunTimeVersion,
      StringRef(), EmissionKind);
}
  1. 我们经常需要提供行号。这可以从源管理器位置派生,大多数 AST 节点都可以使用。源管理器可以将其转换为行号,如下所示:
unsigned CGDebugInfo::getLineNumber(SMLoc Loc) {
  return CGM.getASTCtx().getSourceMgr().FindLineNumber(
      Loc);
}
  1. 作用域的信息保存在堆栈上。我们需要方法来打开和关闭作用域,并检索当前作用域。编译单元是全局作用域,我们会自动添加它,如下所示:
llvm::DIScope *CGDebugInfo::getScope() {
  if (ScopeStack.empty())
    openScope(CU->getFile());
  return ScopeStack.back();
}
void CGDebugInfo::openScope(llvm::DIScope *Scope) {
  ScopeStack.push_back(Scope);
}
void CGDebugInfo::closeScope() {
  ScopeStack.pop_back();
}
  1. 我们为需要转换的类型的每个类别创建一个方法。getPervasiveType()方法为基本类型创建调试元数据。请注意以下代码片段中对编码参数的使用,声明INTEGER类型为有符号类型,BOOLEAN类型编码为布尔类型:
llvm::DIType *
CGDebugInfo::getPervasiveType(TypeDeclaration *Ty) {
  if (Ty->getName() == "INTEGER") {
    return DBuilder.createBasicType(
        Ty->getName(), 64, llvm::dwarf::DW_ATE_signed);
  }
  if (Ty->getName() == "BOOLEAN") {
    return DBuilder.createBasicType(
        Ty->getName(), 1, 
            llvm::dwarf::DW_ATE_boolean);
  }
  llvm::report_fatal_error(
      "Unsupported pervasive type");
}
  1. 如果类型名称只是重命名,那么我们将其映射到类型定义。在这里,我们需要首次使用作用域和行号信息,如下所示:
llvm::DIType *
CGDebugInfo::getAliasType(AliasTypeDeclaration *Ty) {
  return DBuilder.createTypedef(
      getType(Ty->getType()), Ty->getName(),
      CU->getFile(), getLineNumber(Ty->getLocation()),
      getScope());
}
  1. 为数组创建调试信息需要指定大小和对齐方式。我们从DataLayout类中检索这些数据。我们还需要指定数组的索引范围。我们可以使用以下代码来实现:
llvm::DIType *
CGDebugInfo::getArrayType(ArrayTypeDeclaration *Ty) {
  auto *ATy =
      llvm::cast<llvm::ArrayType>(CGM.convertType(Ty));
  const llvm::DataLayout &DL =
      CGM.getModule()->getDataLayout();
  uint64_t NumElements = Ty->getUpperIndex();
  llvm::SmallVector<llvm::Metadata *, 4> Subscripts;
  Subscripts.push_back(
      DBuilder.getOrCreateSubrange(0, NumElements));
  return DBuilder.createArrayType(
      DL.getTypeSizeInBits(ATy) * 8,
      DL.getABITypeAlignment(ATy),
      getType(Ty->getType()),
      DBuilder.getOrCreateArray(Subscripts));
}
  1. 使用所有这些单个方法,我们创建一个中心方法来为类型创建元数据。这个元数据还负责缓存数据。代码可以在以下代码片段中看到:
llvm::DIType *
CGDebugInfo::getType(TypeDeclaration *Ty) {
  if (llvm::DIType *T = TypeCache[Ty])
    return T;
  if (llvm::isa<PervasiveTypeDeclaration>(Ty))
    return TypeCache[Ty] = getPervasiveType(Ty);
  else if (auto *AliasTy =
               llvm::dyn_cast<AliasTypeDeclaration>(Ty))
    return TypeCache[Ty] = getAliasType(AliasTy);
  else if (auto *ArrayTy =
               llvm::dyn_cast<ArrayTypeDeclaration>(Ty))
    return TypeCache[Ty] = getArrayType(ArrayTy);
  else if (auto *RecordTy =
               llvm ::dyn_cast<RecordTypeDeclaration>(
                   Ty))
    return TypeCache[Ty] = getRecordType(RecordTy);
  llvm::report_fatal_error("Unsupported type");
  return nullptr;
}
  1. 我们还需要添加一个方法来发出全局变量的元数据,如下所示:
void CGDebugInfo::emitGlobalVariable(
    VariableDeclaration *Decl,
    llvm::GlobalVariable *V) {
  llvm::DIGlobalVariableExpression *GV =
      DBuilder.createGlobalVariableExpression(
          getScope(), Decl->getName(), V->getName(),
          CU->getFile(),
          getLineNumber(Decl->getLocation()),
          getType(Decl->getType()), false);
  V->addDebugInfo(GV);
}
  1. 要为过程发出调试信息,我们首先需要为过程类型创建元数据。为此,我们需要参数类型的列表,返回类型是第一个条目。如果过程没有返回类型,则使用一个称为void的未指定类型,就像 C 语言一样。如果参数是引用,则需要添加引用类型;否则,我们将类型添加到列表中。代码如下所示:
llvm::DISubroutineType *
CGDebugInfo::getType(ProcedureDeclaration *P) {
  llvm::SmallVector<llvm::Metadata *, 4> Types;
  const llvm::DataLayout &DL =
      CGM.getModule()->getDataLayout();
  // Return type at index 0
  if (P->getRetType())
    Types.push_back(getType(P->getRetType()));
  else
    Types.push_back(
        DBuilder.createUnspecifiedType("void"));
  for (const auto *FP : P->getFormalParams()) {
    llvm::DIType *PT = getType(FP->getType());
    if (FP->isVar()) {
      llvm::Type *PTy = CGM.convertType(FP->getType());
      PT = DBuilder.createReferenceType(
          llvm::dwarf::DW_TAG_reference_type, PT,
          DL.getTypeSizeInBits(PTy) * 8,
          DL.getABITypeAlignment(PTy));
    }
    Types.push_back(PT);
  }
  return DBuilder.createSubroutineType(
      DBuilder.getOrCreateTypeArray(Types));
}
  1. 对于过程本身,我们现在可以使用上一步创建的过程类型创建调试信息。过程还会打开一个新的作用域,因此我们将该过程推送到作用域堆栈上。我们还将 LLVM 函数对象与新的调试信息关联起来,如下所示:
void CGDebugInfo::emitProcedure(
    ProcedureDeclaration *Decl, llvm::Function *Fn) {
  llvm::DISubroutineType *SubT = getType(Decl);
  llvm::DISubprogram *Sub = DBuilder.createFunction(
      getScope(), Decl->getName(), Fn->getName(),
      CU->getFile(), getLineNumber(Decl->getLocation()),
      SubT, getLineNumber(Decl->getLocation()),
      llvm::DINode::FlagPrototyped,
      llvm::DISubprogram::SPFlagDefinition);
  openScope(Sub);
  Fn->setSubprogram(Sub);
}
  1. 当到达过程的结束时,我们必须通知构建器完成该过程的调试信息的构建。我们还需要从作用域堆栈中移除该过程。我们可以使用以下代码来实现:
void CGDebugInfo::emitProcedureEnd(
    ProcedureDeclaration *Decl, llvm::Function *Fn) {
  if (Fn && Fn->getSubprogram())
    DBuilder.finalizeSubprogram(Fn->getSubprogram());
  closeScope();
}
  1. 最后,当我们完成添加调试信息时,我们需要将finalize()方法添加到构建器上。然后验证生成的调试信息。这是开发过程中的重要步骤,因为它可以帮助您找到错误生成的元数据。代码可以在以下代码片段中看到:
void CGDebugInfo::finalize() { DBuilder.finalize(); }

只有在用户请求时才应生成调试信息。我们将需要一个新的命令行开关来实现这一点。我们将把这个开关添加到CGModule类的文件中,并且在这个类内部也会使用它,如下所示:

static llvm::cl::opt<bool>
    Debug("g", llvm::cl::desc("Generate debug information"),
          llvm::cl::init(false));

CGModule类持有std::unique_ptr<CGDebugInfo>类的实例。指针在构造函数中初始化,关于命令行开关的设置如下:

  if (Debug)
    DebugInfo.reset(new CGDebugInfo(*this));

在 getter 方法中,我们返回指针,就像这样:

CGDebugInfo *getDbgInfo() {
  return DebugInfo.get();
}

生成调试元数据时的常见模式是检索指针并检查其是否有效。例如,在创建全局变量后,我们以这种方式添加调试信息:

VariableDeclaration *Var = …;
llvm::GlobalVariable *V = …;
if (CGDebugInfo *Dbg = getDbgInfo())
  Dbg->emitGlobalVariable(Var, V);

为了添加行号信息,我们需要在CGDebugInfo类中添加一个getDebugLoc()转换方法,将 AST 中的位置信息转换为调试元数据,如下所示:

llvm::DebugLoc CGDebugInfo::getDebugLoc(SMLoc Loc) {
  std::pair<unsigned, unsigned> LineAndCol =
      CGM.getASTCtx().getSourceMgr().getLineAndColumn(Loc);
  llvm::DILocation *DILoc = llvm::DILocation::get(
      CGM.getLLVMCtx(), LineAndCol.first, LineAndCol.second,
      getCU());
  return llvm::DebugLoc(DILoc);
}

然后可以调用CGModule类中的实用函数来将行号信息添加到指令中,如下所示:

void CGModule::applyLocation(llvm::Instruction *Inst,
                             llvm::SMLoc Loc) {
  if (CGDebugInfo *Dbg = getDbgInfo())
    Inst->setDebugLoc(Dbg->getDebugLoc(Loc));
}

通过这种方式,您可以为自己的编译器添加调试信息。

总结

在本章中,您了解了在 LLVM 中如何抛出和捕获异常,以及需要生成哪些 IR 代码来利用此功能。为了增强 IR 的范围,您学习了如何将各种元数据附加到指令上。基于类型的别名的元数据为 LLVM 优化器提供了额外的信息,并有助于进行某些优化以生成更好的机器代码。用户总是欣赏使用源级调试器的可能性,通过向 IR 代码添加调试信息,您可以提供编译器的这一重要功能。

优化 IR 代码是 LLVM 的核心任务。在下一章中,我们将学习通道管理器的工作原理以及如何影响通道管理器管理的优化流水线。

第八章:优化 IR

LLVM 使用一系列 Passes 来优化中间表示IR)。Pass 对 IR 单元执行操作,可以是函数或模块。操作可以是转换,以定义的方式更改 IR,也可以是分析,收集依赖关系等信息。一系列 Passes 称为Pass 管道。Pass 管理器在我们的编译器生成的 IR 上执行 Pass 管道。因此,我们需要了解 Pass 管理器的作用以及如何构建 Pass 管道。编程语言的语义可能需要开发新的 Passes,并且我们必须将这些 Passes 添加到管道中。

在本章中,我们将涵盖以下主题:

  • 介绍 LLVM Pass 管理器

  • 使用新 Pass 管理器实现 Pass

  • 为旧 Pass 管理器使用 Pass

  • 向您的编译器添加优化管道

在本章结束时,您将了解如何开发新的 Pass 以及如何将其添加到 Pass 管道中。您还将获得设置自己编译器中 Pass 管道所需的知识。

技术要求

本章的源代码可在github.com/PacktPublishing/Learn-LLVM-12/tree/master/Chapter08找到

您可以在bit.ly/3nllhED找到代码的实际应用视频

介绍 LLVM Pass 管理器

LLVM 核心库优化编译器创建的 IR 并将其转换为目标代码。这项巨大的任务被分解为称为Passes的单独步骤。这些 Passes 需要按正确的顺序执行,这是 Pass 管理器的目标。

但是为什么不硬编码 Passes 的顺序呢?嗯,您的编译器的用户通常期望您的编译器提供不同级别的优化。开发人员更喜欢在开发时间内更快的编译速度而不是优化。最终应用程序应尽可能快地运行,您的编译器应能够执行复杂的优化,接受更长的编译时间。不同级别的优化意味着需要执行不同数量的优化 Passes。作为编译器编写者,您可能希望提供自己的 Passes,以利用您对源语言的了解。例如,您可能希望用内联 IR 或者可能的话用该函数的计算结果替换已知的库函数。对于 C,这样的 Pass 是 LLVM 核心库的一部分,但对于其他语言,您需要自己提供。并且引入自己的 Passes,您可能需要重新排序或添加一些 Passes。例如,如果您知道您的 Pass 的操作使一些 IR 代码不可达,则还应在您自己的 Pass 之后运行死代码删除 Pass。Pass 管理器帮助您组织这些要求。

Pass 通常根据其工作范围进行分类:

  • 函数 Pass接受单个函数作为输入,并仅对该函数执行其工作。

  • 模块 Pass接受整个模块作为输入。这样的 Pass 在给定模块上执行其工作,并且可以用于模块内的程序内操作。

  • 调用图 Pass 按自底向上的顺序遍历调用图的函数。

除了 IR 代码之外,Pass 还可能消耗、产生或使一些分析结果无效。进行了许多不同的分析;例如,别名分析或支配树的构建。支配树有助于将不变的代码移出循环,因此只有在支配树创建后才能运行执行此类转换的 Pass。另一个 Pass 可能执行一个转换,这可能会使现有的支配树无效。

在幕后,Pass 管理器确保以下内容:

  • 分析结果在 Passes 之间共享。这要求您跟踪哪个 Pass 需要哪个分析,以及每个分析的状态。目标是避免不必要的分析重新计算,并尽快释放分析结果所占用的内存。

  • Pass 以管道方式执行。例如,如果应该按顺序执行多个函数 Pass,那么 Pass 管理器将在第一个函数上运行每个函数 Pass。然后它将在第二个函数上运行所有函数 Pass,依此类推。这里的基本思想是改善缓存行为,因为编译器仅对有限的数据集(即一个 IR 函数)执行转换,然后转移到下一个有限的数据集。

LLVM 中有两个 Pass 管理器,如下:

  • 旧的(或传统的)Pass 管理器

  • 新的 Pass 管理器

未来属于新的 Pass 管理器,但过渡尚未完成。一些关键的 Pass,如目标代码发射,尚未迁移到新的 Pass 管理器,因此了解两个 Pass 管理器非常重要。

旧的 Pass 管理器需要一个 Pass 从一个基类继承,例如,从llvm::FunctionPass类继承一个函数 Pass。相比之下,新的 Pass 管理器依赖于基于概念的方法,只需要从特殊的llvm::PassInfo<> mixin 类继承。旧的 Pass 管理器中 Passes 之间的依赖关系没有明确表达。在新的 Pass 管理器中,需要明确编码。新的 Pass 管理器还采用了不同的分析处理方法,并允许通过命令行上的文本表示来指定优化管道。一些 LLVM 用户报告说,仅通过从旧的 Pass 管理器切换到新的 Pass 管理器,编译时间就可以减少高达 10%,这是使用新的 Pass 管理器的非常有说服力的论点。

首先,我们将为新的 Pass 管理器实现一个 Pass,并探索如何将其添加到优化管道中。稍后,我们将看看如何在旧的 Pass 管理器中使用 Pass。

使用新的 Pass 管理器实现 Pass

Pass 可以对 LLVM IR 执行任意复杂的转换。为了说明添加新 Pass 的机制,我们的新 Pass 只计算 IR 指令和基本块的数量。我们将 Pass 命名为countir。将 Pass 添加到 LLVM 源树或作为独立的 Pass 略有不同,因此我们将在以下部分都进行。

将 Pass 添加到 LLVM 源树

让我们从将新 Pass 添加到 LLVM 源开始。如果我们以后想要在 LLVM 树中发布新的 Pass,这是正确的方法。

对 LLVM IR 执行转换的 Pass 的源代码位于llvm-project/llvm/lib/Transforms文件夹中,头文件位于llvm-project/llvm/include/llvm/Transforms文件夹中。由于 Pass 太多,它们被分类到适合它们的类别的子文件夹中。

对于我们的新 Pass,在两个位置都创建一个名为CountIR的新文件夹。首先,让我们实现CountIR.h头文件:

  1. 像往常一样,我们需要确保文件可以被多次包含。此外,我们需要包含 Pass 管理器的定义:
#ifndef LLVM_TRANSFORMS_COUNTIR_COUNTIR_H
#define LLVM_TRANSFORMS_COUNTIR_COUNTIR_H
#include "llvm/IR/PassManager.h"
  1. 因为我们在 LLVM 源代码中,所以我们将新的CountIR类放入llvm命名空间中。该类继承自PassInfoMixin模板。该模板仅添加了一些样板代码,例如name()方法。它不用于确定 Pass 的类型。
namespace llvm {
class CountIRPass : public PassInfoMixin<CountIRPass> {
  1. 在运行时,将调用任务的run()方法。run()方法的签名确定 Pass 的类型。这里,第一个参数是对Function类型的引用,因此这是一个函数 Pass:
public:
  PreservedAnalyses run(Function &F,
                        FunctionAnalysisManager &AM);
  1. 最后,我们需要关闭类、命名空间和头文件保护:
};
} // namespace llvm
#endif

当然,我们的新 Pass 的定义是如此简单,因为我们只执行了一个微不足道的任务。

让我们继续在CountIIR.cpp文件中实现 Pass。LLVM 支持在调试模式下收集有关 Pass 的统计信息。对于我们的 Pass,我们将利用这个基础设施。

  1. 我们首先包含我们自己的头文件和所需的 LLVM 头文件:
#include "llvm/Transforms/CountIR/CountIR.h"
#include "llvm/ADT/Statistic.h"
#include "llvm/Support/Debug.h"
  1. 为了缩短源代码,我们告诉编译器我们正在使用llvm命名空间:
using namespace llvm;
  1. LLVM 的内置调试基础设施要求我们定义一个调试类型,即一个字符串。这个字符串稍后将显示在打印的统计信息中:
#define DEBUG_TYPE "countir"
  1. 我们使用STATISTIC宏定义了两个计数器变量。第一个参数是计数器变量的名称,第二个参数是将在统计中打印的文本:
STATISTIC(NumOfInst, "Number of instructions.");
STATISTIC(NumOfBB, "Number of basic blocks.");
  1. run()方法中,我们循环遍历函数的所有基本块,并递增相应的计数器。我们对基本块的所有指令也是一样的。为了防止编译器警告我们关于未使用的变量,我们插入了对I变量的无操作使用。因为我们只计数而不改变 IR,我们告诉调用者我们已经保留了所有现有的分析:
PreservedAnalyses
CountIRPass::run(Function &F,
                 FunctionAnalysisManager &AM) {
  for (BasicBlock &BB : F) {
    ++NumOfBB;
    for (Instruction &I : BB) {
      (void)I;
      ++NumOfInst;
    }
  }
  return PreservedAnalyses::all();
}

到目前为止,我们已经实现了新 Pass 的功能。我们稍后将重用这个实现来进行一个树外的 Pass。对于 LLVM 树内的解决方案,我们必须更改 LLVM 中的几个文件来宣布新 Pass 的存在:

  1. 首先,我们需要在源文件夹中添加一个CMakeLists.txt文件。这个文件包含了一个新的 LLVM 库名LLVMCountIR的构建指令。新库需要链接 LLVM 的Support组件,因为我们使用了调试和统计基础设施,以及 LLVM 的Core组件,其中包含了 LLVM IR 的定义:
add_llvm_component_library(LLVMCountIR
  CountIR.cpp
  LINK_COMPONENTS Core Support )
  1. 为了使这个新库成为构建的一部分,我们需要将该文件夹添加到父文件夹的CMakeLists.txt文件中,即llvm-project/llvm/lib/Transforms/CMakeList.txt文件。然后,添加以下行:
add_subdirectory(CountIR)
  1. PassBuilder类需要知道我们的新 Pass。为此,我们在llvm-project/llvm/lib/Passes/PassBuilder.cpp文件的include部分添加以下行:
#include "llvm/Transforms/CountIR/CountIR.h"
  1. 作为最后一步,我们需要更新 Pass 注册表,这在llvm-project/llvm/lib/Passes/PassRegistry.def文件中。查找定义函数 Pass 的部分,例如通过搜索FUNCTION_PASS宏。在这个部分中,添加以下行:
FUNCTION_PASS("countir", CountIRPass())
  1. 我们现在已经做出了所有必要的更改。按照第一章中的构建说明,使用 CMake 构建部分,重新编译 LLVM。要测试新的 Pass,我们将以下 IR 代码存储在我们的build文件夹中的demo.ll文件中。代码有两个函数,总共三条指令和两个基本块:
define internal i32 @func() {
  ret i32 0
}
define dso_local i32 @main() {
  %1 = call i32 @func()
  ret i32 %1
}
  1. 我们可以使用opt实用程序来使用新的 Pass。要运行新的 Pass,我们将利用--passes="countir"选项。要获得统计输出,我们需要添加--stats选项。因为我们不需要生成的位码,我们还指定了--disable-output选项:
$ bin/opt --disable-output --passes="countir" –-stats demo.ll
===--------------------------------------------------------===
                   ... Statistics Collected ...
===--------------------------------------------------------===
2 countir - Number of basic blocks.
3 countir - Number of instructions. 
  1. 我们运行我们的新 Pass,输出符合我们的期望。我们已经成功扩展了 LLVM!

运行单个 Pass 有助于调试。使用--passes选项,您不仅可以命名单个 Pass,还可以描述整个流水线。例如,优化级别 2 的默认流水线被命名为default<O2>。您可以在默认流水线之前使用--passes="module(countir),default<O2>"参数运行countir Pass。这样的流水线描述中的 Pass 名称必须是相同类型的。默认流水线是一个模块 Pass,我们的countir Pass 是一个函数 Pass。要从这两者创建一个模块流水线,首先我们必须创建一个包含countir Pass 的模块 Pass。这是通过module(countir)来完成的。您可以通过以逗号分隔的列表指定更多的函数 Passes 添加到这个模块 Pass 中。同样,模块 Passes 也可以组合。为了研究这一点的影响,您可以使用inlinecountir Passes:以不同的顺序运行它们,或者作为模块 Pass,将给出不同的统计输出。

将新的 Pass 添加到 LLVM 源代码树中是有意义的,如果您计划将您的 Pass 作为 LLVM 的一部分发布。如果您不打算这样做,或者希望独立于 LLVM 分发您的 Pass,那么您可以创建一个 Pass 插件。在下一节中,我们将查看如何执行这些步骤。

作为插件添加新的 Pass

为了将新的 Pass 作为插件提供,我们将创建一个使用 LLVM 的新项目:

  1. 让我们从在我们的源文件夹中创建一个名为countirpass的新文件夹开始。该文件夹将具有以下结构和文件:
|-- CMakeLists.txt
|-- include
|   `-- CountIR.h
|-- lib
    |-- CMakeLists.txt
    `-- CountIR.cpp
  1. 请注意,我们已经重用了上一节的功能,只是做了一些小的调整。CountIR.h头文件现在位于不同的位置,所以我们改变了用作守卫的符号的名称。我们也不再使用llvm命名空间,因为我们现在不在 LLVM 源代码之内。由于这个改变,头文件变成了以下内容:
#ifndef COUNTIR_H
#define COUNTIR_H
#include "llvm/IR/PassManager.h"
class CountIRPass
    : public llvm::PassInfoMixin<CountIRPass> {
public:
  llvm::PreservedAnalyses
  run(llvm::Function &F,
      llvm::FunctionAnalysisManager &AM);
};
#endif
  1. 我们可以从上一节复制CountIR.cpp实现文件。这里也需要做一些小的改动。因为我们的头文件路径已经改变,所以我们需要用以下内容替换include指令:
#include "CountIR.h"
  1. 我们还需要在 Pass builder 中注册新的 Pass。这是在插件加载时发生的。Pass 插件管理器调用特殊函数llvmGetPassPluginInfo(),进行注册。对于这个实现,我们需要两个额外的include文件:
#include "llvm/Passes/PassBuilder.h"
#include "llvm/Passes/PassPlugin.h"

用户可以使用--passes选项在命令行上指定要运行的 Passes。PassBuilder类从字符串中提取 Pass 名称。为了创建命名 Pass 的实例,PassBuilder类维护一个回调函数列表。基本上,回调函数会以 Pass 名称和 Pass 管理器作为参数进行调用。如果回调函数知道 Pass 名称,那么它会将这个 Pass 的实例添加到 Pass 管理器中。对于我们的 Pass,我们需要提供这样一个回调函数:

bool PipelineParsingCB(
    StringRef Name, FunctionPassManager &FPM,
    ArrayRef<PassBuilder::PipelineElement>) {
  if (Name == "countir") {
    FPM.addPass(CountIRPass());
    return true;
  }
  return false;
}
  1. 当然,我们需要将这个函数注册为PassBuilder实例。插件加载后,将为此目的调用注册回调。我们的注册函数如下:
void RegisterCB(PassBuilder &PB) {
  PB.registerPipelineParsingCallback(PipelineParsingCB);
}
  1. 最后,每个插件都需要提供上述llvmGetPassPluginInfo()函数。这个函数返回一个结构,包含四个元素:我们的插件使用的 LLVM 插件 API 版本、名称、插件的版本号和注册回调。插件 API 要求函数使用extern "C"约定。这是为了避免 C++名称混淆的问题。这个函数非常简单:
extern "C" ::llvm::PassPluginLibraryInfo LLVM_ATTRIBUTE_WEAK
llvmGetPassPluginInfo() {
  return {LLVM_PLUGIN_API_VERSION, "CountIR", "v0.1",
          RegisterCB};
}

为每个回调实现一个单独的函数有助于我们理解正在发生的事情。如果您的插件提供了多个 Passes,那么您可以扩展RegisterCB回调函数以注册所有 Passes。通常,您可以找到一个非常紧凑的方法。以下的llvmGetPassPluginInfo()函数将PipelineParsingCB()RegisterCB()和之前的llvmGetPassPluginInfo()合并为一个函数。它通过使用 lambda 函数来实现:

extern "C" ::llvm::PassPluginLibraryInfo LLVM_ATTRIBUTE_WEAK
llvmGetPassPluginInfo() {
  return {LLVM_PLUGIN_API_VERSION, "CountIR", "v0.1",
          [](PassBuilder &PB) {
            PB.registerPipelineParsingCallback(
                [](StringRef Name, FunctionPassManager                        &FPM,
                ArrayRef<PassBuilder::PipelineElement>)  
                {
                  if (Name == "countir") {
                    FPM.addPass(CountIRPass());
                    return true;
                  }
                  return false;
                });
          }};
}
  1. 现在,我们只需要添加构建文件。lib/CMakeLists.txt文件只包含一个命令来编译源文件。LLVM 特定的命令add_llvm_library()确保使用用于构建 LLVM 的相同编译器标志:
add_llvm_library(CountIR MODULE CountIR.cpp)

顶层的CMakeLists.txt文件更加复杂。

  1. 像往常一样,我们设置所需的 CMake 版本和项目名称。此外,我们将LLVM_EXPORTED_SYMBOL_FILE变量设置为ON。这对于使插件在 Windows 上工作是必要的:
cmake_minimum_required(VERSION 3.4.3)
project(countirpass)
set(LLVM_EXPORTED_SYMBOL_FILE ON)
  1. 接下来,我们寻找 LLVM 安装。我们还将在控制台上打印有关找到的版本的信息:
find_package(LLVM REQUIRED CONFIG)
message(STATUS "Found LLVM ${LLVM_PACKAGE_VERSION}")
message(STATUS "Using LLVMConfig.cmake in: ${LLVM_DIR}")
  1. 现在,我们可以将 LLVM 的cmake文件夹添加到搜索路径中。我们包括 LLVM 特定的文件ChooseMSVCCRTAddLLVM,它们提供了额外的命令:
list(APPEND CMAKE_MODULE_PATH ${LLVM_DIR})
include(ChooseMSVCCRT)
include(AddLLVM)
  1. 编译器需要了解所需的定义和 LLVM 路径:
include_directories("${LLVM_INCLUDE_DIR}")
add_definitions("${LLVM_DEFINITIONS}")
link_directories("${LLVM_LIBRARY_DIR}")
  1. 最后,我们添加自己的包含和源文件夹:
include_directories(BEFORE include)
add_subdirectory(lib)
  1. 在实现了所有必需的文件之后,我们现在可以在countirpass文件夹旁边创建build文件夹。首先,切换到构建目录并创建构建文件:
$ cmake –G Ninja ../countirpass
  1. 然后,您可以编译插件,如下所示:
$ ninja
  1. 您可以使用opt实用程序使用插件,opt实用程序会生成输入文件的优化版本。要使用插件,您需要指定一个额外的参数来加载插件:
$ opt --load-pass-plugin=lib/CountIR.so --passes="countir"\
  --disable-output –-stats demo.ll

输出与以前版本相同。恭喜,Pass 插件有效!

到目前为止,我们只为新 Pass 管理器创建了一个 Pass。在下一节中,我们还将扩展旧 Pass 管理器的 Pass。

调整 Pass 以与旧 Pass 管理器一起使用

未来属于新 Pass 管理器,为旧 Pass 管理器专门开发新 Pass 是没有意义的。然而,在进行过渡阶段期间,如果一个 Pass 可以与两个 Pass 管理器一起工作,那将是有用的,因为 LLVM 中的大多数 Pass 已经这样做了。

旧 Pass 管理器需要一个从特定基类派生的 Pass。例如,函数 Pass 必须从FunctionPass基类派生。还有更多的不同之处。Pass 管理器运行的方法被命名为runOnFunction(),还必须提供 Pass 的ID。我们在这里遵循的策略是创建一个单独的类,可以与旧 Pass 管理器一起使用,并以一种可以与两个 Pass 管理器一起使用的方式重构源代码。

我们将 Pass 插件用作基础。在include/CountIR.h头文件中,我们添加一个新的类定义,如下所示:

  1. 新类需要从FunctionPass类派生,因此我们包含一个额外的头文件来获取类定义:
#include "llvm/Pass.h"
  1. 我们将新类命名为CountIRLegacyPass。该类需要内部 LLVM 机制的 ID,并用其初始化父类:
class CountIRLegacyPass : public llvm::FunctionPass {
public:
  static char ID;
  CountIRLegacyPass() : llvm::FunctionPass(ID) {}
  1. 为了实现 Pass 功能,必须重写两个函数。runOnFunction()方法用于每个 LLVM IR 函数,并实现我们的计数功能。getAnalysisUsage()方法用于宣布所有分析结果都已保存:
  bool runOnFunction(llvm::Function &F) override;
  void getAnalysisUsage(llvm::AnalysisUsage &AU) const     override;
};
  1. 现在头文件的更改已经完成,我们可以增强lib/CountIR.cpp文件中的实现。为了重用计数功能,我们将源代码移入一个新的函数:
void runCounting(Function &F) {
  for (BasicBlock &BB : F) {
    ++NumOfBB;
    for (Instruction &I : BB) {
      (void)I;
      ++NumOfInst;
    }
  }
}
  1. 新 Pass 管理器的方法需要更新,以便使用新功能:
PreservedAnalyses
CountIRPass::run(Function &F, FunctionAnalysisManager &AM) {
  runCounting(F);
  return PreservedAnalyses::all();
}
  1. 以同样的方式,我们实现了旧 Pass 管理器的方法。通过返回false值,我们表明 IR 没有发生变化:
bool CountIRLegacyPass::runOnFunction(Function &F) {
  runCounting(F);
  return false;
}
  1. 为了保留现有的分析结果,必须以以下方式实现getAnalysisUsage()方法。这类似于新 Pass 管理器中PreservedAnalyses::all()的返回值。如果不实现此方法,则默认情况下会丢弃所有分析结果:
void CountIRLegacyPass::getAnalysisUsage(
    AnalysisUsage &AU) const {
  AU.setPreservesAll();
}
  1. ID字段可以用任意值初始化,因为 LLVM 使用字段的地址。通常值为0,所以我们也使用它:
char CountIRLegacyPass::ID = 0;
  1. 现在只缺少 Pass 注册。要注册新 Pass,我们需要提供RegisterPass<>模板的静态实例。第一个参数是调用新 Pass 的命令行选项的名称。第二个参数是 Pass 的名称,用于在调用-help选项时向用户提供信息等:
static RegisterPass<CountIRLegacyPass>
    X("countir", "CountIR Pass");
  1. 这些变化足以让我们在旧 Pass 管理器和新 Pass 管理器下调用我们的新 Pass。为了测试这个添加,切换回build文件夹并编译 Pass:
$ ninja
  1. 为了在旧 Pass 管理器中加载插件,我们需要使用--load选项。我们的新 Pass 是使用--countir选项调用的:
$ opt --load lib/CountIR.so --countir –-stats\
  --disable-output demo.ll

提示

请还要检查,在上一节的命令行中,使用新 Pass 管理器调用我们的 Pass 是否仍然正常工作!

能够使用 LLVM 提供的工具运行我们的新 Pass 是很好的,但最终,我们希望在我们的编译器内运行它。在下一节中,我们将探讨如何设置优化流水线以及如何自定义它。

向您的编译器添加优化流水线

我们的tinylang编译器,在前几章中开发,对创建的 IR 代码不进行任何优化。在接下来的章节中,我们将向编译器添加一个优化流水线,以实现这一点。

使用新 Pass 管理器创建优化流水线

优化流水线设置的核心是PassBuilder类。这个类知道所有注册的 Pass,并可以根据文本描述构建 Pass 流水线。我们使用这个类来从命令行给出的描述创建 Pass 流水线,或者使用基于请求的优化级别的默认流水线。我们还支持使用 Pass 插件,例如我们在上一节中讨论的countir Pass 插件。通过这样做,我们模仿了opt工具的部分功能,并且还使用了类似的命令行选项名称。

PassBuilder类填充了一个ModulePassManager类的实例,这是用于保存构建的 Pass 流水线并实际运行它的 Pass 管理器。代码生成 Pass 仍然使用旧 Pass 管理器;因此,我们必须保留旧 Pass 管理器以实现这一目的。

对于实现,我们扩展了我们的tinylang编译器中的tools/driver/Driver.cpp文件:

  1. 我们使用新的类,因此我们首先添加新的include文件。llvm/Passes/PassBuilder.h文件提供了PassBuilder类的定义。llvm/Passes/PassPlugin.h文件是插件支持所需的。最后,llvm/Analysis/TargetTransformInfo.h文件提供了一个将 IR 级别转换与特定目标信息连接起来的 Pass:
#include "llvm/Passes/PassBuilder.h"
#include "llvm/Passes/PassPlugin.h"
#include "llvm/Analysis/TargetTransformInfo.h"
  1. 为了使用新 Pass 管理器的某些功能,我们添加了三个命令行选项,使用与opt工具相同的名称。--passes选项允许 Pass 流水线的文本规范,--load-pass-plugin选项允许使用 Pass 插件。如果给出--debug-pass-manager选项,则 Pass 管理器会打印有关执行的 Pass 的信息:
static cl::opt<bool>
    DebugPM("debug-pass-manager", cl::Hidden,
            cl::desc("Print PM debugging 
                     information"));
static cl::opt<std::string> PassPipeline(
    "passes",
    cl::desc("A description of the pass pipeline"));
static cl::list<std::string> PassPlugins(
    "load-pass-plugin",
    cl::desc("Load passes from plugin library"));
  1. 用户通过优化级别影响 Pass 流水线的构建。PassBuilder类支持六个不同的优化级别:一个无优化级别,三个用于优化速度的级别,以及两个用于减小大小的级别。我们在一个命令行选项中捕获所有这些级别:
static cl::opt<signed char> OptLevel(
    cl::desc("Setting the optimization level:"),
    cl::ZeroOrMore,
    cl::values(
        clEnumValN(3, "O", "Equivalent to -O3"),
        clEnumValN(0, "O0", "Optimization level 0"),
        clEnumValN(1, "O1", "Optimization level 1"),
        clEnumValN(2, "O2", "Optimization level 2"),
        clEnumValN(3, "O3", "Optimization level 3"),
        clEnumValN(-1, "Os",
                   "Like -O2 with extra 
                    optimizations "
                   "for size"),
        clEnumValN(
            -2, "Oz",
            "Like -Os but reduces code size further")),
    cl::init(0));
  1. LLVM 的插件机制支持静态插件注册表,在项目配置期间创建。为了利用这个注册表,我们包括llvm/Support/Extension.def数据库文件来创建返回插件信息的函数的原型:
#define HANDLE_EXTENSION(Ext)                          \
  llvm::PassPluginLibraryInfo get##Ext##PluginInfo();
#include "llvm/Support/Extension.def"
  1. 我们用新版本替换现有的emit()函数。我们在函数顶部声明所需的PassBuilder实例:
bool emit(StringRef Argv0, llvm::Module *M,
          llvm::TargetMachine *TM,
          StringRef InputFilename) {
  PassBuilder PB(TM);
  1. 为了实现对命令行上给出的 Pass 插件的支持,我们循环遍历用户给出的插件库列表,并尝试加载插件。如果失败,我们会发出错误消息;否则,我们注册 Passes:
  for (auto &PluginFN : PassPlugins) {
    auto PassPlugin = PassPlugin::Load(PluginFN);
    if (!PassPlugin) {
      WithColor::error(errs(), Argv0)
          << "Failed to load passes from '" 
          << PluginFN
          << "'. Request ignored.\n";
      continue;
    }
    PassPlugin->registerPassBuilderCallbacks(PB);
  }
  1. 静态插件注册表中的信息类似地用于向我们的PassBuilder实例注册这些插件:
#define HANDLE_EXTENSION(Ext)                          \
  get##Ext##PluginInfo().RegisterPassBuilderCallbacks( \
      PB);
#include "llvm/Support/Extension.def"
  1. 我们需要声明不同分析管理器的变量。唯一的参数是调试标志:
  LoopAnalysisManager LAM(DebugPM);
  FunctionAnalysisManager FAM(DebugPM);
  CGSCCAnalysisManager CGAM(DebugPM);
  ModuleAnalysisManager MAM(DebugPM);
  1. 接下来,我们通过在PassBuilder实例上调用相应的register方法来填充分析管理器。通过这个调用,分析管理器填充了默认的分析 Passes,并且还运行注册回调。我们还确保函数分析管理器使用默认的别名分析管道,并且所有分析管理器都知道彼此:
  FAM.registerPass(
      [&] { return PB.buildDefaultAAPipeline(); });
  PB.registerModuleAnalyses(MAM);
  PB.registerCGSCCAnalyses(CGAM);
  PB.registerFunctionAnalyses(FAM);
  PB.registerLoopAnalyses(LAM);
  PB.crossRegisterProxies(LAM, FAM, CGAM, MAM);
  1. MPM模块 Pass 管理器保存我们构建的 Pass 管道。该实例使用调试标志进行初始化:
  ModulePassManager MPM(DebugPM);
  1. 我们实现了两种不同的方法来填充模块 Pass 管理器与 Pass 管道。如果用户在命令行上提供了 Pass 管道,也就是说,他们使用了--passes选项,那么我们将使用这个作为 Pass 管道:
  if (!PassPipeline.empty()) {
    if (auto Err = PB.parsePassPipeline(
            MPM, PassPipeline)) {
      WithColor::error(errs(), Argv0)
          << toString(std::move(Err)) << "\n";
      return false;
    }
  }
  1. 否则,我们使用选择的优化级别来确定要构建的 Pass 管道。默认 Pass 管道的名称是default,它将优化级别作为参数:
  else {
    StringRef DefaultPass;
    switch (OptLevel) {
    case 0: DefaultPass = "default<O0>"; break;
    case 1: DefaultPass = "default<O1>"; break;
    case 2: DefaultPass = "default<O2>"; break;
    case 3: DefaultPass = "default<O3>"; break;
    case -1: DefaultPass = "default<Os>"; break;
    case -2: DefaultPass = "default<Oz>"; break;
    }
    if (auto Err = PB.parsePassPipeline(
            MPM, DefaultPass)) {
      WithColor::error(errs(), Argv0)
          << toString(std::move(Err)) << "\n";
      return false;
    }
  }
  1. 现在设置了在 IR 代码上运行转换的 Pass 管道。我们需要打开一个文件来写入结果。系统汇编器和 LLVM IR 输出都是基于文本的,因此我们应该为它们都设置OF_Text标志:
  std::error_code EC;
  sys::fs::OpenFlags OpenFlags = sys::fs::OF_None;
  CodeGenFileType FileType = codegen::getFileType();
  if (FileType == CGFT_AssemblyFile)
    OpenFlags |= sys::fs::OF_Text;
  auto Out = std::make_unique<llvm::ToolOutputFile>(
      outputFilename(InputFilename), EC, OpenFlags);
  if (EC) {
    WithColor::error(errs(), Argv0)
        << EC.message() << '\n';
    return false;
  }
  1. 对于代码生成,我们必须使用旧的 Pass 管理器。我们只需声明CodeGenPM实例并添加使目标特定信息在 IR 转换级别可用的 Pass:
  legacy::PassManager CodeGenPM;
  CodeGenPM.add(createTargetTransformInfoWrapperPass(
      TM->getTargetIRAnalysis()));
  1. 为了输出 LLVM IR,我们添加了一个只打印 IR 到流中的 Pass:
  if (FileType == CGFT_AssemblyFile && EmitLLVM) {
    CodeGenPM.add(createPrintModulePass(Out->os()));
  }
  1. 否则,我们让TargetMachine实例添加所需的代码生成 Passes,由我们作为参数传递的FileType值指导:
  else {
    if (TM->addPassesToEmitFile(CodeGenPM, Out->os(),
                                nullptr, FileType)) {
      WithColor::error()
          << "No support for file type\n";
      return false;
    }
  }
  1. 经过所有这些准备,我们现在准备执行 Passes。首先,我们在 IR 模块上运行优化管道。接下来,运行代码生成 Passes。当然,在所有这些工作之后,我们希望保留输出文件:
  MPM.run(*M, MAM);
  CodeGenPM.run(*M);
  Out->keep();
  return true;
}
  1. 这是很多代码,但很简单。当然,我们还必须更新tools/driver/CMakeLists.txt构建文件中的依赖项。除了添加目标组件外,我们还从 LLVM 中添加所有转换和代码生成组件。名称大致类似于源代码所在的目录名称。在配置过程中,组件名称将被转换为链接库名称:
set(LLVM_LINK_COMPONENTS ${LLVM_TARGETS_TO_BUILD}
  AggressiveInstCombine Analysis AsmParser
  BitWriter CodeGen Core Coroutines IPO IRReader
  InstCombine Instrumentation MC ObjCARCOpts Remarks
  ScalarOpts Support Target TransformUtils Vectorize
  Passes)
  1. 我们的编译器驱动程序支持插件,并宣布以下支持:
add_tinylang_tool(tinylang Driver.cpp SUPPORT_PLUGINS)
  1. 与以前一样,我们必须链接到我们自己的库:
target_link_libraries(tinylang
  PRIVATE tinylangBasic tinylangCodeGen
  tinylangLexer tinylangParser tinylangSema)

这些是源代码和构建系统的必要补充。

  1. 要构建扩展的编译器,请进入您的build目录并输入以下内容:
$ ninja

构建系统的文件更改会自动检测到,并且在编译和链接我们更改的源代码之前运行cmake。如果您需要重新运行配置步骤,请按照第二章中的说明,LLVM 源代码漫游编译 tinylang 应用程序部分中的说明进行操作。

由于我们已经使用opt工具的选项作为蓝图,您应该尝试使用加载 Pass 插件并运行 Pass 的选项来运行tinylang,就像我们在前面的部分中所做的那样。

通过当前的实现,我们可以运行默认的 Pass 管道或自己构建一个。后者非常灵活,但在几乎所有情况下都是过度的。默认管道非常适用于类似 C 的语言。缺少的是扩展 Pass 管道的方法。在下一节中,我们将解释如何实现这一点。

扩展 Pass 管道

在上一节中,我们使用PassBuilder类从用户提供的描述或预定义名称创建 Pass 管道。现在,我们将看另一种自定义 Pass 管道的方法:使用扩展点

在构建 Pass 管道期间,Pass 构建器允许您添加用户贡献的 Passes。这些地方被称为扩展点。存在许多扩展点,例如以下:

  • 管道开始扩展点允许您在管道开始时添加 Passes。

  • 窥孔扩展点允许您在指令组合器 Pass 的每个实例之后添加 Passes。

还存在其他扩展点。要使用扩展点,您需要注册一个回调。在构建 Pass 管道期间,您的回调在定义的扩展点处运行,并可以向给定的 Pass 管理器添加 Pass。

要为管道开始扩展点注册回调,您需要调用PassBuilder类的registerPipelineStartEPCallback()方法。例如,要将我们的CountIRPass Pass 添加到管道的开头,您需要将 Pass 调整为使用createModuleToFunctionPassAdaptor()模板函数作为模块 Pass,并将 Pass 添加到模块 Pass 管理器中:

PB.registerPipelineStartEPCallback(
    [](ModulePassManager &MPM) {
        MPM.addPass(
             createModuleToFunctionPassAdaptor(
                 CountIRPass());
    });

您可以在创建管道之前的任何时间点将此片段添加到 Pass 管道设置代码中,也就是在调用parsePassPipeline()方法之前。

在上一节所做的工作的自然扩展是让用户通过命令行传递管道描述。opt工具也允许这样做。让我们为管道开始扩展点做这个。首先,我们将以下代码添加到tools/driver/Driver.cpp文件中:

  1. 我们为用户添加了一个新的命令行,用于指定管道描述。同样,我们从opt工具中获取选项名称:
static cl::opt<std::string> PipelineStartEPPipeline(
    "passes-ep-pipeline-start",
    cl::desc("Pipeline start extension point));
  1. 使用 lambda 函数作为回调是最方便的方式。为了解析管道描述,我们调用PassBuilder实例的parsePassPipeline()方法。Passes 被添加到PM Pass 管理器,并作为参数传递给 lambda 函数。如果出现错误,我们会打印错误消息而不会停止应用程序。您可以在调用crossRegisterProxies()方法之后添加此片段:
  PB.registerPipelineStartEPCallback(
      &PB, Argv0 {
        if (auto Err = PB.parsePassPipeline(
                PM, PipelineStartEPPipeline)) {
          WithColor::error(errs(), Argv0)
              << "Could not parse pipeline "
              << PipelineStartEPPipeline.ArgStr 
              << ": "
              << toString(std::move(Err)) << "\n";
        }
      });

提示

为了允许用户在每个扩展点添加 Passes,您需要为每个扩展点添加前面的代码片段。

  1. 现在是尝试不同pass manager选项的好时机。使用--debug-pass-manager选项,您可以跟踪执行 Passes 的顺序。您可以使用--print-before-all--print-after-all选项在每次调用 Pass 之前或之后打印 IR。如果您创建自己的 Pass 管道,那么您可以在感兴趣的点插入print Pass。例如,尝试--passes="print,inline,print"选项。您还可以使用print Pass 来探索各种扩展点。
    PassBuilder::OptimizationLevel Olevel = …;
    if (OLevel == PassBuilder::OptimizationLevel::O0)
      MPM.addPass(AlwaysInlinerPass());
    else
      MPM = PB.buildPerModuleDefaultPipeline(OLevel,           DebugPM);

当然,也可以以这种方式向 Pass 管理器添加多个 Pass。PassBuilder类在构建 Pass 管道期间还使用addPass()方法。

LLVM 12 中的新功能-运行扩展点回调

因为 Pass 管道在优化级别O0下没有填充,所以注册的扩展点不会被调用。如果您使用扩展点来注册应该在O0级别运行的 Passes,这将是有问题的。在 LLVM 12 中,可以调用新的runRegisteredEPCallbacks()方法来运行已注册的扩展点回调,从而使 Pass 管理器仅填充通过扩展点注册的 Passes。

通过将优化管道添加到tinylang中,您可以创建一个类似 clang 的优化编译器。LLVM 社区致力于在每个发布版本中改进优化和优化管道。因此,默认情况下很少不使用默认管道。通常情况下,会添加新的 Passes 来实现编程语言的某些语义。

总结

在本章中,您学会了如何为 LLVM 创建新的 Pass。您使用 Pass 管道描述和扩展点运行了 Pass。您通过构建和执行类似 clang 的 Pass 管道来扩展了您的编译器,将tinylang变成了一个优化编译器。Pass 管道允许您在扩展点添加 Passes,并且您学会了如何在这些点注册 Passes。这使您能够使用自己开发的 Passes 或现有 Passes 扩展优化管道。

在下一章中,我们将探讨 LLVM 如何从优化的 IR 生成机器指令。

第三部分:将 LLVM 提升到下一个级别

在本节中,您将学习 LLVM 中指令选择的实现方式,并通过添加对新机器指令的支持来应用这些知识。LLVM 具有即时编译器(JIT),您将学习如何使用它以及如何根据自己的需求进行定制。您还将尝试各种工具和库,以帮助识别应用程序中的错误。最后,您将使用新的后端扩展 LLVM,这将使您具备利用 LLVM 尚未支持的新架构所需的知识。

本节包括以下章节:

  • 第九章,指令选择

  • 第十章,JIT 编译

  • 第十一章,使用 LLVM 工具进行调试

  • 第十二章,创建自己的后端

第九章:指令选择

到目前为止使用的 LLVM IR 仍然需要转换为机器指令。这称为指令选择,通常缩写为ISel。指令选择是目标后端的重要部分,LLVM 有三种不同的选择指令的方法:选择 DAG,快速指令选择和全局指令选择。

在本章中,您将学习以下主题:

  • 了解 LLVM 目标后端结构,介绍了目标后端执行的任务,并检查了要运行的机器传递。

  • 使用机器 IRMIR)来测试和调试后端,这有助于在指定的传递后输出 MIR 并在 MIR 文件上运行传递。

  • 指令选择的工作方式,您将了解 LLVM 执行指令选择的不同方式。

  • 支持新的机器指令,其中您添加一个新的机器指令并使其可用于指令选择。

通过本章结束时,您将了解目标后端的结构以及指令选择的工作方式。您还将获得向汇编程序和指令选择中添加当前不受支持的机器指令的知识,以及如何测试您的添加。

技术要求

要查看图形可视化,您必须安装Graphviz软件,可从graphviz.org/下载。源代码可在gitlab.com/graphviz/graphviz/上找到。

本章的源代码可在github.com/PacktPublishing/Learn-LLVM-12/tree/master/Chapter09上找到

您可以在bit.ly/3nllhED上找到代码演示视频

了解 LLVM 目标后端结构

在优化了 LLVM IR 之后,选择的 LLVM 目标用于从中生成机器代码。在目标后端中执行以下任务,包括:

  1. 用于指令选择的有向无环图DAG),通常称为SelectionDAG,被构建。

  2. 选择与 IR 代码对应的机器指令。

  3. 选择的机器指令按最佳顺序排列。

  4. 虚拟寄存器被机器寄存器替换。

  5. 向函数添加序言和尾声代码。

  6. 基本块按最佳顺序排列。

  7. 运行特定于目标的传递。

  8. 发出目标代码或汇编。

所有这些步骤都被实现为机器函数传递,派生自MachineFunctionPass类。这是FunctionPass类的子类,是旧的 pass 管理器使用的基类之一。截至 LLVM 12,将机器函数传递转换为新的 pass 管理器仍然是一个正在进行中的工作。

在所有这些步骤中,LLVM 指令都会经历转换。在代码级别,LLVM IR 指令由Instruction类的实例表示。在指令选择阶段,它被转换为MachineInstr实例。这是一个更接近实际机器级别的表示。它已经包含了对目标有效的指令,但仍然在虚拟寄存器上运行(直到寄存器分配),并且还可以包含某些伪指令。指令选择后的传递会对此进行细化,最终创建一个MCInstr实例,这是真实机器指令的表示。MCInstr实例可以写入对象文件或打印为汇编代码。

要探索后端传递,您可以创建一个包含以下内容的小型 IR 文件:

define i16 @sum(i16 %a, i16 %b) {
  %res = add i16 %a, 3
  ret i16 %res
}

将此代码保存为sum.ll。使用 LLVM 静态编译器llc为 MIPS 架构编译它。这个工具将 LLVM IR 编译成汇编文本或目标文件。可以使用–mtriple选项在命令行上覆盖目标平台的编译。使用–debug-pass=Structure选项调用llc工具:

$ llc -mtriple=mips-linux-gnu -debug-pass=Structure < sum.ll

除了生成的汇编代码,你还会看到一个要运行的机器 pass 的长列表。其中,MIPS DAG->DAG Pattern Instruction Selection pass 执行指令选择,Mips Delay Slot Filler是一个特定于目标的 pass,而在清理之前的最后一个 pass,Mips Assembly Printer,负责打印汇编代码。在所有这些 pass 中,指令选择 pass 是最有趣的,我们将在下一节详细讨论。

使用 MIR 测试和调试后端

你在前面的部分看到目标后端运行了许多 pass。然而,这些 pass 中的大多数并不是在 LLVM IR 上运行的,而是在 MIR 上运行的。这是指令的一个与目标相关的表示,因此比 LLVM IR 更低级。它仍然可以包含对虚拟寄存器的引用,因此它还不是目标 CPU 的纯指令。

要查看 IR 级别的优化,例如,可以告诉llc在每个 pass 之后转储 IR。这在后端的机器 pass 中不起作用,因为它们不在 IR 上工作。相反,MIR 起到了类似的作用。

MIR 是当前模块中机器指令当前状态的文本表示。它利用了 YAML 格式,允许序列化和反序列化。基本思想是你可以在某个点停止 pass 管道并以 YAML 格式检查状态。你也可以修改 YAML 文件,或者创建你自己的文件,并传递它,并检查结果。这样可以方便地进行调试和测试。

让我们来看看 MIR。使用llc工具和--stop-after=finalize-isel选项以及之前使用的测试输入文件运行:

$ llc -mtriple=mips-linux-gnu \
        -stop-after=finalize-isel < sum.ll

这指示llc在指令选择完成后转储 MIR。缩短的输出看起来像这样:

---
name:                 sum
body:                  |
  bb.0 (%ir-block.0):
     liveins: $a0, $a1
     %1:gpr32 = COPY $a1
     %0:gpr32 = COPY $a0
     %2:gpr32 = ADDu %0, %1
     $v0 = COPY %2
     RetRA implicit $v0
... 

有几个属性你立即注意到。首先,有一些虚拟寄存器,比如%0和实际的机器寄存器,比如$a0。这是由 ABI 降级引起的。为了在不同的编译器和语言之间具有可移植性,函数遵循调用约定的一部分,这是$a0的一部分。因为 MIR 输出是在指令选择之后但是在寄存器分配之前生成的,所以你仍然可以看到虚拟寄存器的使用。

在 LLVM IR 中的add指令,MIR 文件中使用的是机器指令ADDu。你还可以看到虚拟寄存器有一个寄存器调用附加,这种情况下是gpr32。在 MIPS 架构上没有 16 位寄存器,因此必须使用 32 位寄存器。

bb.0标签指的是第一个基本块,标签后面的缩进内容是基本块的一部分。第一条语句指定了进入基本块时活跃的寄存器。之后是指令。在这种情况下,只有$a0$a1,两个参数,在进入时是活跃的。

MIR 文件中还有很多其他细节。你可以在 LLVM MIR 文档中阅读有关它们的内容llvm.org/docs/MIRLangRef.html

你遇到的一个问题是如何找出一个 pass 的名称,特别是如果你只需要检查该 pass 之后的输出而不是积极地在其上工作。当使用-debug-pass=Structure选项与llc一起时,激活 pass 的选项被打印在顶部。例如,如果你想在Mips Delay Slot Filler pass 之前停止,那么你需要查看打印出的列表,并希望找到-mips-delay-slot-filler选项,这也会给出 pass 的名称。

MIR 文件格式的主要应用是帮助测试目标后端中的机器传递。使用llc--stop-after选项,您可以在指定的传递之后获得 MIR。通常,您将使用这个作为您打算测试用例的基础。您首先注意到的是 MIR 输出非常冗长。例如,许多字段是空的。为了减少这种混乱,您可以在llc命令行中添加-simplify-mir选项。

您可以根据需要保存和更改 MIR 以进行测试。llc工具可以运行单个传递,这非常适合使用 MIR 文件进行测试。假设您想要测试MIPS Delay Slot Filler传递。延迟槽是 RISC 架构(如 MIPS 或 SPARC)的一个特殊属性:跳转后的下一条指令总是被执行。因此,编译器必须确保每次跳转后都有一个合适的指令,这个传递就是执行这个任务的。

我们在运行传递之前生成 MIR:

$ llc -mtriple=mips-linux-gnu \
        -stop-before=mips-delay-slot-filler -simplify-mir \
        < sum.ll  >delay.mir

输出要小得多,因为我们使用了-simplify-mir选项。函数的主体现在是以下内容:

body:                  |
  bb.0 (%ir-block.0):
     liveins: $a0, $a1
     renamable $v0 = ADDu killed renamable $a0,
                             killed renamable $a1
     PseudoReturn undef $ra, implicit $v0

最重要的是,您将看到ADDu指令,后面是返回的伪指令。

使用delay.ll文件作为输入,我们现在运行延迟槽填充器传递:

$ llc -mtriple=mips-linux-gnu \
        -run-pass=mips-delay-slot-filler -o - delay.mir

现在将输出中的函数与之前的函数进行比较:

body:                  |
  bb.0 (%ir-block.0):
     PseudoReturn undef $ra, implicit $v0 {
        renamable $v0 = ADDu killed renamable $a0,
                                killed renamable $a1
     }

您会看到ADDu和返回的伪指令的顺序已经改变,ADDu指令现在嵌套在返回内部:传递将ADDu指令标识为适合延迟槽的指令。

如果延迟槽的概念对您来说是新的,您还会想要查看生成的汇编代码,您可以使用llc轻松生成:

$ llc -mtriple=mips-linux-gnu < sum.ll

输出包含很多细节,但是通过基本块的bb.0名称的帮助,您可以轻松地定位生成的汇编代码:

# %bb.0:
           jr        $ra
           addu     $2, $4, $5

确实,指令的顺序改变了!

掌握了这些知识,我们来看一下目标后端的核心,并检查 LLVM 中如何执行机器指令选择。

指令选择的工作原理

LLVM 后端的任务是从 LLVM IR 创建机器指令。这个过程称为指令选择降低。受到尽可能自动化这项任务的想法的启发,LLVM 开发人员发明了 TableGen 语言来捕获目标描述的所有细节。我们首先看一下这种语言,然后再深入研究指令选择算法。

在 TableGen 语言中指定目标描述

机器指令有很多属性:汇编器和反汇编器使用的助记符、在内存中表示指令的位模式、输入和输出操作数等。LLVM 开发人员决定将所有这些信息都捕获在一个地方,即.td后缀。

原则上,TableGen 语言非常简单。您所能做的就是定义记录。Register类定义了寄存器的共同属性,您可以为寄存器R0定义一个具体的记录:

class Register {
  string name;
}
def R0 : Register {
  let name = "R0";
  string altName = "$0";
}

您可以使用let关键字来覆盖一个值。

TableGen 语言有很多语法糖,使处理记录变得更容易。例如,一个类可以有一个模板参数:

class Register<string n> {
  string name = n;
}
def R0 : Register<"R0"> {
  string altName = "$0";
}

TableGen 语言是静态类型的,您必须指定每个值的类型。一些支持的类型如下:

  • :一个单独的位

  • int:64 位整数值

  • bits<n>:由n位组成的整数类型

  • string:一个字符字符串

  • list<t>:类型为t的元素列表

  • dag有向无环图DAG;指令选择使用)

类的名称也可以用作类型。例如,list<Register>指定了Register类的元素列表。

该语言允许使用include关键字包含其他文件。对于条件编译,支持预处理指令#define#ifdef#ifndef

LLVM 中的 TableGen 库可以解析用 TableGen 语言编写的文件,并创建记录的内存表示。您可以使用这个库来创建自己的生成器。

LLVM 自带了一个名为llvm-tblgen的生成器工具和一些.td文件。后端的目标描述首先包括llvm/Target/Target.td文件。该文件定义了诸如RegisterTargetProcessor之类的类。llvm-tblgen工具了解这些类,并从定义的记录生成 C++代码。

让我们以 MIPS 后端为例来看一下。目标描述在llvm/lib/Target/Mips文件夹中的Mips.td文件中。该文件包括了最初提到的Target.td文件。它还定义了目标特性,例如:

def FeatureMips64r2
  : SubtargetFeature<"mips64r2", "MipsArchVersion", 
                     "Mips64r2", "Mips64r2 ISA Support",
                     [FeatureMips64, FeatureMips32r2]>;

这些特性后来被用来定义 CPU 模型,例如:

def : Proc<"mips64r2", [FeatureMips64r2]>;

其他定义寄存器、指令、调度模型等的文件也包括在内。

llvm-tblgen工具可以显示由目标描述定义的记录。如果你在build目录中,那么以下命令将在控制台上打印记录:

$ bin/llvm-tblgen \
  -I../llvm-project/llvm/lib/Target/Mips/ \
  -I../llvm-project/llvm/include \
  ../llvm-project/llvm/lib/Target/Mips/Mips.td

与 Clang 一样,-I选项会在包含文件时添加一个目录进行搜索。查看记录对于调试很有帮助。该工具的真正目的是从记录生成 C++代码。例如,使用-gen-subtarget选项,将向控制台发出解析llc-mcpu=-mtarget=选项所需的数据:

$ bin/llvm-tblgen \
  -I../llvm-project/llvm/lib/Target/Mips/ \
  -I../llvm-project/llvm/include \
  ../llvm-project/llvm/lib/Target/Mips/Mips.td \
  -gen-subtarget

将该命令生成的代码保存到一个文件中,并探索特性和 CPU 在生成的代码中的使用方式!

指令的编码通常遵循一些模式。因此,指令的定义被分成了定义位编码和指令具体定义的类。MIPS 指令的编码在文件llvm/Target/Mips/MipsInstrFormats.td中。让我们来看一下ADD_FM格式的定义:

class ADD_FM<bits<6> op, bits<6> funct> : StdArch {
  bits<5> rd;
  bits<5> rs;
  bits<5> rt;
  bits<32> Inst;
  let Inst{31-26} = op;
  let Inst{25-21} = rs;
  let Inst{20-16} = rt;
  let Inst{15-11} = rd;
  let Inst{10-6}  = 0;
  let Inst{5-0}   = funct;
}

在记录主体中,定义了几个新的位字段:rdrs等。它们用于覆盖Inst字段的部分内容,该字段保存指令的位模式。rdrsrt位字段编码了指令操作的寄存器,而opfunct参数表示操作码和函数编号。StdArch超类只添加了一个字段,说明该格式遵循标准编码。

MIPS 目标中的大多数指令编码不涉及 DAG 节点,也不指定汇编助记符。为此定义了一个单独的类。MIPS 架构中的一条指令是nor指令,它计算第一个和第二个输入寄存器的按位或,反转结果的位,并将结果赋给输出寄存器。这条指令有几个变体,以下的LogicNOR类有助于避免多次重复相同的定义:

class LogicNOR<string opstr, RegisterOperand RO>:
  InstSE<(outs RO:$rd), (ins RO:$rs, RO:$rt),
            !strconcat(opstr, "\t$rd, $rs, $rt"),
            [(set RO:$rd, (not (or RO:$rs, RO:$rt)))],
            II_NOR, FrmR, opstr> {
  let isCommutable = 1;
}

哇,记录这个简单的概念现在看起来很复杂。让我们剖析一下这个定义。这个类派生自InstSE类,这个类总是用于具有标准编码的指令。如果你继续跟踪超类层次结构,你会看到这个类派生自Instruction类,这是一个预定义的类,表示目标的指令。(outs RO:$rd)参数将最终指令的结果定义为 DAG 节点。RO部分是指LogicNOR类的同名参数,表示寄存器操作数。$rd是要使用的寄存器。这是稍后将放入指令编码中的值,在rd字段中。第二个参数定义了指令将操作的值。总之,这个类是用于操作三个寄存器的指令。!strconcat(opstr, "\t$rd, $rs, $rt")参数组装了指令的文本表示。!strconcat操作符是 TableGen 中预定义的功能,用于连接两个字符串。你可以在 TableGen 程序员指南中查找所有预定义的操作符:llvm.org/docs/TableGen/ProgRef.html

它遵循一个模式定义,类似于nor指令的文本描述,并描述了这个指令的计算。模式的第一个元素是操作,后面是一个逗号分隔的操作数列表。操作数指的是 DAG 参数中的寄存器名称,并且还指定了 LLVM IR 值类型。LLVM 有一组预定义的操作符,比如addand,可以在模式中使用。这些操作符属于SDNode类,也可以用作参数。你可以在文件llvm/Target/TargetSelectionDAG.td中查找预定义的操作符。

II_NOR参数指定了调度模型中使用的行程类别,FrmR参数是一个定义的值,用于识别此指令格式。最后,opstr助记符被传递给超类。这个类的主体非常简单:它只是指定nor操作是可交换的,这意味着操作数的顺序可以交换。

最后,这个类用于定义一个指令的记录,例如,用于 64 位模式下的nor指令:

def NOR64 : LogicNOR<"nor", GPR64Opnd>, ADD_FM<0, 0x27>,                                    
                              GPR_64;

这是最终的定义,可以从def关键字中识别出来。它使用LogicNOR类来定义 DAG 操作数和模式,使用ADD_FM类来指定二进制指令编码。额外的GPR_64谓词确保只有在 64 位寄存器可用时才使用这个指令。

开发人员努力避免多次重复定义,一个经常使用的方法是使用multiclass类。multiclass类可以一次定义多个记录。

例如,MIPS CPU 的浮点单元可以执行单精度或双精度浮点值的加法。这两个指令的定义非常相似,因此定义了一个multiclass类,一次创建两个指令:

multiclass ADDS_M<…> {
  def _D32 : ADDS_FT<…>, FGR_32;
  def _D64 : ADDS_FT<…>, FGR_64;
}

ADDS_FT类定义了指令格式,类似于LogicNOR类。FGR_32FGR_64谓词用于在编译时决定可以使用哪个指令。重要的部分是定义了_D32_D64记录。这些是记录的模板。然后使用defm关键字定义指令记录:

defm FADD : ADDS_M<…>;

这一次同时定义了多类中的两个记录,并为它们分配了名称FADD_D32FADD_D64。这是避免代码重复的一种非常强大的方式,它经常在目标描述中使用,但结合其他 TableGen 功能,可能会导致非常晦涩的定义。

有了目标描述组织的知识,我们现在可以在下一节中探索指令选择。

使用选择 DAG 进行指令选择

LLVM 将 IR 转换为机器指令的标准方式是通过 DAG。使用目标描述中提供的模式匹配和自定义代码,IR 指令被转换为机器指令。这种方法并不像听起来那么简单:IR 大多是与目标无关的,并且可能包含目标不支持的数据类型。例如,代表单个位的i1类型在大多数目标上都不是有效的类型。

selectionDAG 由SDNode类型的节点组成,在文件llvm/CodeGen/SelectionDAGNodes.h中定义。节点表示的操作称为OpCode,目标独立代码在文件llvm/CodeGen/ISDOpcodes.h中定义。除了操作,节点还存储操作数和它产生的值。

节点的值和操作数形成数据流依赖关系。控制流依赖由链边表示,具有特殊类型MVT::Other。这使得可以保持具有副作用的指令的顺序,例如,加载指令。

使用选择 DAG 进行指令选择的步骤如下:

  1. 如何跟踪指令选择过程

  2. DAG 被优化了。

  3. DAG 中的类型被合法化了。

  4. 指令被选择了。

  5. DAG 中的操作被合法化了。

  6. DAG 被优化了。

  7. 指令被排序了。

  8. 就像上一节的 MIR 输出中一样,您在这里看到CopyFromReg指令,它们将 ABI 使用的寄存器的内容传输到虚拟节点。由于示例使用 16 位值,但 MIPS 架构仅对 32 位值有本机支持,因此需要truncate节点。add操作是在 16 位虚拟寄存器上执行的,并且结果被扩展并返回给调用者。对于上述每个步骤,都会打印这样的部分。

让我们看看如何跟踪每个步骤对选择 DAG 的更改。

![图 9.1 - 为 sum.ll 文件构建的选择 DAG

您可以以两种不同的方式看到指令选择的工作。如果将-debug-only=isel选项传递给llc工具,则每个步骤的结果将以文本格式打印出来。如果您需要调查为什么选择了机器指令,这将是一个很大的帮助。例如,运行以下命令以查看“Understanding the LLVM target backend structure”部分的sum.ll文件的输出:

$ llc -mtriple=mips-linux-gnu -debug-only=isel < sum.ll

这打印了大量信息。在输出顶部,您可以看到输入的初始创建的 DAG 的描述:

Initial selection DAG: %bb.0 'sum:'
SelectionDAG has 12 nodes:
  t0: ch = EntryToken
              t2: i32,ch = CopyFromReg t0, Register:i32 %0
           t5: i16 = truncate t2
              t4: i32,ch = CopyFromReg t0, Register:i32 %1
           t6: i16 = truncate t4
        t7: i16 = add t5, t6
     t8: i32 = any_extend t7
  t10: ch,glue = CopyToReg t0, Register:i32 $v0, t8
  t11: ch = MipsISD::Ret t10, Register:i32 $v0, t10:1 

DAG 被构建了。

LLVM 还可以借助Graphviz软件生成选择 DAG 的可视化。如果将–view-dag-combine1-dags选项传递给llc工具,则会打开一个窗口显示构建的 DAG。例如,使用前面的小文件运行llc

$ llc -mtriple=mips-linux-gnu  –view-dag-combine1-dags sum.ll

DAG 被优化了。

在 Windows PC 上运行,您将看到 DAG:

在 Windows PC 上运行,您将看到 DAG:

图 9.1 - 为 sum.ll 文件构建的选择 DAG

确保文本表示和此图包含相同的信息。EntryToken是 DAG 的起点,GraphRoot是最终节点。控制流的链用蓝色虚线箭头标记。黑色箭头表示数据流。红色箭头将节点粘合在一起,防止重新排序。即使对于中等大小的函数,图可能会变得非常大。它不包含比带有-debug-only=isel选项的文本输出更多或其他信息,只是呈现更加舒适。您还可以在其他时间生成图,例如:

  • --view-legalize-types-dags选项添加到类型合法化之前查看 DAG。

  • 添加–view-isel-dags选项以查看选择指令。

您可以使用--help-hidden选项查看查看 DAG 的所有可用选项。由于 DAG 可能变得庞大和混乱,您可以使用-filter-view-dags选项将渲染限制为一个基本块。

检查指令选择

了解如何可视化 DAG 后,我们现在可以深入了解细节。选择 DAG 是从 IR 构建的。对于 IR 中的每个函数,SelectionDAGBuilder类通过SelectionDAGBuilder类填充SelectionDAG类的实例。在此步骤中没有进行特殊优化。尽管如此,目标需要提供一些函数来降低调用、参数处理、返回跳转等。为此,目标必须实现TargetLowering接口。在目标的文件夹中,源代码通常在XXXISelLowering.hXXXISelLowering.cpp文件中。TargetLowering接口的实现提供了指令过程所需的所有信息,例如目标上支持的数据类型和操作。

优化步骤会运行多次。优化器执行简单的优化,例如识别支持这些操作的目标上的旋转。这里的原理是产生一个清理过的 DAG,从而简化其他步骤。

在类型合法化步骤中,目标不支持的类型将被替换为支持的类型。例如,如果目标本机只支持 32 位宽整数,则较小的值必须通过符号或零扩展转换为 32 位。这称为TargetLowering接口。类型合法化后,选择 DAG 对sum.ll文件具有以下文本表示:

Optimized type-legalized selection DAG: %bb.0 'sum:'
SelectionDAG has 9 nodes:
  t0: ch = EntryToken
        t2: i32,ch = CopyFromReg t0, Register:i32 %0
        t4: i32,ch = CopyFromReg t0, Register:i32 %1
     t12: i32 = add t2, t4
  t10: ch,glue = CopyToReg t0, Register:i32 $v0, t12
  t11: ch = MipsISD::Ret t10, Register:i32 $v0, t10:1

如果将此与最初构建的 DAG 进行比较,那么这里只使用了 32 位寄存器。16 位值被提升,因为本机只支持 32 位值。

操作合法化类似于类型合法化。这一步是必要的,因为并非所有操作都可能被目标支持,或者即使目标本机支持某种类型,也可能并非所有操作都有效。例如,并非所有目标都有用于人口统计的本机指令。在这种情况下,该操作将被一系列操作替换以实现功能。如果类型不适合操作,则可以将类型提升为更大的类型。后端作者还可以提供自定义代码。如果合法化操作设置为Custom,则将为这些操作调用TargetLowering类中的LowerOperation()方法。该方法必须创建操作的合法版本。在sum.ll示例中,add操作已经是合法的,因为平台支持两个 23 位寄存器的加法,而且没有改变。

在类型和操作被合法化之后,指令选择就会发生。选择的大部分部分是自动化的。请记住前一节中,您在指令描述中提供了一个模式。从这些描述中,llvm-tblgen工具生成了一个模式匹配器。基本上,模式匹配器试图找到与当前 DAG 节点匹配的模式。然后选择与该模式相关联的指令。模式匹配器被实现为字节码解释器。解释器的可用代码在llvm/CodeGen/SelectionDAGISel.h头文件中定义。XXXISelDAGToDAG类实现了目标的指令选择。对于每个 DAG 节点,都会调用Select()方法。默认情况下会调用生成的匹配器,但您也可以为它未处理的情况添加代码。

值得注意的是,选择 DAG 节点与所选指令之间没有一对一的关系。DAG 节点可以扩展为多条指令,而多个 DAG 节点可以合并为单条指令。前者的一个例子是合成立即值。特别是在 RISC 架构上,立即值的位长度受限。32 位目标可能仅支持 16 位长度的嵌入式立即值。要执行需要 32 位常量值的操作,通常会将其拆分为两个 16 位值,然后生成使用这两个 16 位值的两个或更多指令。在 MIPS 目标中,您会发现这方面的模式。位域指令是后一种情况的常见例子:andorshift DAG 节点的组合通常可以匹配到特殊的位域指令,从而只需一条指令即可处理两个或更多 DAG 节点。

通常,您可以在目标描述中指定一个模式,以组合两个或多个 DAG 节点。对于更复杂的情况,这些情况不容易用模式处理,您可以标记顶部节点的操作,需要特殊的 DAG 组合处理。对于这些节点,在XXXISelLowering类中调用PerformDAGCombine()方法。然后,您可以检查任意复杂的模式,如果找到匹配,那么您可以返回表示组合 DAG 节点的操作。在运行 DAG 节点的生成匹配器之前调用此方法。

您可以在sum.ll文件的打印输出中跟踪指令选择过程。对于add操作,您会在那里找到以下行:

ISEL: Starting selection on root node: t12: i32 = add t2, t4
ISEL: Starting pattern match
  Initial Opcode index to 27835
  …
  Morphed node: t12: i32 = ADDu t2, t4
ISEL: Match complete!

索引号指向生成匹配器的数组。起始索引为27835(一个可以在发布版本之间更改的任意值),经过一些步骤后,选择了ADDu指令。

遵循模式匹配

如果遇到模式问题,您还可以通过阅读生成的字节码来追踪匹配过程。您可以在build目录中的lib/Target/XXX/XXXGenDAGIsel.inc文件中找到源代码。您可以在文本编辑器中打开文件,并在先前的输出中搜索索引。每行都以索引号为前缀,因此您可以轻松找到数组中的正确位置。使用的谓词也会以注释的形式打印出来,因此它们可以帮助您理解为什么某个特定的模式未被选择。

将 DAG 转换为指令序列

在指令选择之后,代码仍然是一个图。这种数据结构需要被展平,这意味着指令必须按顺序排列。图包含数据和控制流依赖关系,但总是有几种可能的方式来安排指令,以满足这些依赖关系。我们希望的是一种最大程度利用硬件的顺序。现代硬件可以并行发出多条指令,但总是有限制。这种限制的一个简单例子是一个指令需要另一个指令的结果。在这种情况下,硬件可能无法发出两条指令,而是按顺序执行指令。

您可以向目标描述添加调度模型,描述可用的单元及其属性。例如,如果 CPU 有两个整数算术单元,那么这些信息就被捕捉在模型中。对于每个指令,有必要知道模型的哪个部分被使用。有不同的方法来做到这一点。较新的、推荐的方法是使用所谓的机器指令调度器来定义调度模型。为此,您需要为目标描述中的每个子目标定义一个SchedMachineModel记录。基本上,模型由指令和处理器资源的输入和输出操作数的定义组成。然后,这两个定义与延迟值一起关联。您可以在llvm/Target/TargetSched.td文件中查找此模型的预定义类型。查看 Lanai 目标以获取一个非常简单的模型,并在 SystemZ 目标中获取一个复杂的调度模型。

还有一个基于所谓行程的较旧模型。使用这个模型,您将处理器单元定义为FuncUnit记录。使用这样一个单元的步骤被定义为InstrStage记录。每个指令都与一个行程类相关联。对于每个行程类,定义了使用的处理器流水线由InstrStage记录组成,以及执行所需的处理器周期数。您可以在llvm/Target/TargetItinerary.td文件中找到行程模型的预定义类型。

一些目标同时使用这两种模型。一个原因是由于开发历史。基于行程的模型是最早添加到 LLVM 中的,目标开始使用这个模型。当新的机器指令调度器在 5 年多以后添加时,没有人关心足够迁移已经存在的模型。另一个原因是,使用行程模型不仅可以对使用多个处理器单元的指令进行建模,还可以指定在哪些周期使用这些单元。然而,这种细节级别很少需要,如果需要,那么可以参考机器指令调度器模型来定义行程,基本上将这些信息也引入到新模型中。

如果存在,调度模型用于以最佳方式排序指令。在这一步之后,DAG 不再需要,并被销毁。

使用选择 DAG 进行指令选择几乎可以得到最佳结果,但在运行时和内存使用方面会付出代价。因此,开发了替代方法,我们将在下一节中进行讨论。在下一节中,我们将看一下快速指令选择方法。

快速指令选择 - FastISel

使用选择 DAG 进行指令选择会消耗编译时间。如果您正在开发一个应用程序,那么编译器的运行时很重要。您也不太关心生成的代码,因为更重要的是发出完整的调试信息。因此,LLVM 开发人员决定实现一个特殊的指令选择器,它具有快速的运行时,但生成的代码不太优化,并且仅用于-O0优化级别。这个组件称为快速指令选择,简称FastIsel

实现在XXXFastISel类中。并非每个目标都支持这种指令选择方法,如果是这种情况,选择 DAG 方法也用于-O0。实现很简单:从FastISel类派生一个特定于目标的类,并实现一些方法。TableGen 工具从目标描述中生成了大部分所需的代码。然而,需要一些工作来实现这个指令选择器。一个根本原因是你需要正确地获取调用约定,这通常是复杂的。

MIPS 目标具有快速指令选择的实现。您可以通过向llc工具传递-fast-isel选项来启用快速指令选择。使用第一节中的sum.ll示例文件,调用如下:

$ llc -mtriple=mips-linux-gnu -fast-isel –O0 sum.ll

快速指令选择运行非常快,但它是一条完全不同的代码路径。一些 LLVM 开发人员决定寻找一个既能快速运行又能产生良好代码的解决方案,目标是在未来替换选择dag和快速指令选择器。我们将在下一节讨论这种方法。

新的全局指令选择 - GlobalISel

使用选择 dag,我们可以生成相当不错的机器代码。缺点是它是一个非常复杂的软件。这意味着它很难开发、测试和维护。快速指令选择工作迅速,复杂性较低,但不能产生良好的代码。除了由 TableGen 生成的代码外,这两种方法几乎没有共享代码。

我们能否兼得两全?一种指令选择算法,既快速,易于实现,又能产生良好的代码?这就是向 LLVM 框架添加另一种指令选择算法 - 全局指令选择的动机。短期目标是首先替换 FastISel,长期目标是替换选择 dag。

全局指令选择采用的方法是建立在现有基础设施之上。整个任务被分解为一系列机器函数传递。另一个主要的设计决定是不引入另一种中间表示,而是使用现有的MachineInstr类。但是,会添加新的通用操作码。

当前的步骤顺序如下:

  1. IRTranslator pass 使用通用操作码构建初始机器指令。

  2. Legalizer pass 在一步中使类型和操作合法化。这与选择 dag 不同,后者需要两个不同的步骤。真实的 CPU 架构有时很奇怪,可能只支持某种数据类型的一条指令。选择 dag 处理这种情况不好,但在全局指令选择的组合步骤中很容易处理。

  3. 生成的机器指令仍然在虚拟寄存器上操作。在RegBankSelect pass 中,选择了一个寄存器组。寄存器组代表 CPU 上的寄存器类型,例如通用寄存器。这比目标描述中的寄存器定义更粗粒度。重要的是它将类型信息与指令关联起来。类型信息基于目标中可用的类型,因此这已经低于 LLVM IR 中的通用类型。

  4. 此时,已知类型和操作对于目标是合法的,并且每条指令都与类型信息相关联。接下来的InstructionSelect pass 可以轻松地用机器指令替换通用指令。

全局指令选择后,通常会运行后端传递,如指令调度、寄存器分配和基本块放置。

全局指令选择已编译到 LLVM 中,但默认情况下未启用。如果要使用它,需要给llc传递-global-isel选项,或者给clang传递-mllvm global-isel选项。您可以控制全局指令选择无法处理 IR 构造时的处理方式。当您给llc传递-global-isel-abort=0选项时,选择 dag 将作为后备。使用=1时,应用程序将终止。为了防止这种情况,您可以给llc传递-global-isel-abort=0选项。使用=2时,选择 dag 将作为后备,并打印诊断消息以通知您有关问题。

要将全局指令选择添加到目标,您只需要重写目标的TargetPassConfig类中的相应函数。这个类由XXXTargetMachine类实例化,并且实现通常可以在同一个文件中找到。例如,您可以重写addIRTranslator()方法,将IRTranslator pass 添加到目标的机器 pass 中。

开发主要发生在 AArch64 目标上,目前该目标对全局指令选择有最好的支持。许多其他目标,包括 x86 和 Power,也已经添加了对全局指令选择的支持。一个挑战是从表描述中生成的代码并不多,所以仍然有一定量的手动编码需要完成。另一个挑战是目前不支持大端目标,因此纯大端目标(如 SystemZ)目前无法使用全局指令选择。这两个问题肯定会随着时间的推移得到改善。

Mips 目标具有全局指令选择的实现,但有一个限制,即它只能用于小端目标。您可以通过向llc工具传递–global-isel选项来启用全局指令选择。使用第一节的sum.ll示例文件,调用如下:

$ llc -mtriple=mipsel-linux-gnu -global-isel sum.ll

请注意,目标mipsel-linux-gnu是小端目标。使用大端mips-linux-gnu目标会导致错误消息。

全局指令选择器比选择 DAG 快得多,并且已经产生了比快速指令选择更高的代码质量。

支持新的机器指令

您的目标 CPU 可能具有 LLVM 尚不支持的机器指令。例如,使用 MIPS 架构的制造商经常向核心 MIPS 指令集添加特殊指令。RISC-V 指令集的规范明确允许制造商添加新指令。或者您正在添加一个全新的后端,那么您必须添加 CPU 的指令。在下一节中,我们将为 LLVM 后端的单个新机器指令添加汇编器支持。

添加汇编和代码生成的新指令

新的机器指令通常与特定的 CPU 特性相关联。然后,只有在用户使用--mattr=选项选择了该特性时,新指令才会被识别。

例如,我们将在 MIPS 后端添加一个新的机器指令。这个虚构的新机器指令首先将两个输入寄存器$2$3的值平方,然后将两个平方的和赋给输出寄存器$1

sqsumu $1, $2, $3

指令的名称是sqsumu,源自平方和求和操作。名称中的最后一个u表示该指令适用于无符号整数。

我们首先要添加的 CPU 特性称为sqsum。这将允许我们使用--mattr=+sqsum选项调用llc来启用对新指令的识别。

我们将添加的大部分代码位于llvm/lib/Target/Mips文件夹中。顶层文件是Mips.td。查看该文件,并找到定义各种特性的部分。在这里,您添加我们新特性的定义:

def FeatureSQSum
     : SubtargetFeature<"sqsum", "HasSQSum", "true",
                                 "Use square-sum instruction">;

SubtargetFeature类有四个模板参数。第一个sqsum是特性的名称,用于命令行。第二个参数HasSQSumSubtarget类中表示此特性的属性的名称。接下来的参数是特性的默认值和描述,用于在命令行上提供帮助。TableGen 会为MipsSubtarget类生成基类,该类在MipsSubtarget.h文件中定义。在这个文件中,我们在类的私有部分添加新属性,其中定义了所有其他属性:

  // Has square-sum instruction.
  bool HasSQSum = false;

在公共部分,我们还添加了一个方法来检索属性的值。我们需要这个方法来进行下一个添加:

  bool hasSQSum() const { return HasSQSum; }

有了这些添加,我们已经能够在命令行上设置sqsum功能,尽管没有效果。

为了将新指令与sqsum功能关联起来,我们需要定义一个谓词,指示是否选择了该功能。我们将其添加到MipsInstrInfo.td文件中,可以是在定义所有其他谓词的部分,也可以简单地添加到末尾:

def HasSQSum : Predicate<"Subtarget->hasSQSum()">,
                     AssemblerPredicate<(all_of FeatureSQSum)>;

该谓词使用先前定义的hasSQSum()方法。此外,AssemblerPredicate模板指定了在为汇编器生成源代码时使用的条件。我们只需引用先前定义的功能。

我们还需要更新调度模型。MIPS 目标使用行程表和机器指令调度器。对于行程表模型,在MipsSchedule.td文件中为每条指令定义了一个InstrItinClass记录。只需在此文件的所有行程表都被定义的部分添加以下行:

def II_SQSUMU : InstrItinClass;

我们还需要提供有关指令成本的详细信息。通常,您可以在 CPU 的文档中找到这些信息。对于我们的指令,我们乐观地假设它只需要在 ALU 中一个周期。这些信息被添加到同一文件中的MipsGenericItineraries定义中:

InstrItinData<II_SQSUMU, [InstrStage<1, [ALU]>]>

有了这个,基于行程表的调度模型的更新就完成了。MIPS 目标还在MipsScheduleGeneric.td文件中定义了一个基于机器指令调度器模型的通用调度模型。因为这是一个涵盖所有指令的完整模型,我们还需要添加我们的指令。由于它是基于乘法的,我们只需扩展MULTMULTu指令的现有定义:

def : InstRW<[GenericWriteMul], (instrs MULT, MULTu, SQSUMu)>;

MIPS 目标还在MipsScheduleP5600.td文件中为 P5600 CPU 定义了一个调度模型。显然,我们的新指令在这个目标上不受支持,所以我们将其添加到不支持的功能列表中:

list<Predicate> UnsupportedFeatures = [HasSQSum, HasMips3, … 

现在我们准备在Mips64InstrInfo.td文件的末尾添加新指令。TableGen 定义总是简洁的,因此我们对其进行分解。该定义使用 MIPS 目标描述中的一些预定义类。我们的新指令是一个算术指令,并且按设计,它适用于ArithLogicR类。第一个参数"sqsumu"指定了指令的汇编助记符。下一个参数GPR64Opnd表示指令使用 64 位寄存器作为操作数,接下来的1参数表示操作数是可交换的。最后,为指令给出了一个行程表。ADD_FM类用于指定指令的二进制编码。对于真实的指令,必须根据文档选择参数。然后是ISA_MIPS64谓词,指示指令适用于哪个指令集。最后,我们的SQSUM谓词表示只有在启用我们的功能时指令才有效。完整的定义如下:

def SQSUMu  : ArithLogicR<"sqsumu", GPR64Opnd, 1, II_SQSUMU>,
                  ADD_FM<0x1c, 0x28>, ISA_MIPS64, SQSUM

如果您只想支持新指令,那么这个定义就足够了。在这种情况下,请确保用 ; 结束定义。通过添加选择 DAG 模式,您可以使指令可用于代码生成器。该指令使用两个操作寄存器 $rs$rt,以及目标寄存器 $rd,这三个寄存器都由 ADD_FM 二进制格式类定义。理论上,要匹配的模式很简单:使用 mul 乘法运算符对每个寄存器的值进行平方,然后使用 add 运算符将两个乘积相加,并赋值给目标寄存器 $rd。模式变得有点复杂,因为在 MIPS 指令集中,乘法的结果存储在一个特殊的寄存器对中。为了可用,结果必须移动到通用寄存器中。在操作合法化期间,通用的 mul 运算符被替换为 MIPS 特定的 MipsMult 操作进行乘法,以及 MipsMFLO 操作将结果的低位部分移动到通用寄存器中。在编写模式时,我们必须考虑到这一点,模式如下所示:

{
  let Pattern = [(set GPR64Opnd:$rd,
                              (add (MipsMFLO (MipsMult   
                                GPR64Opnd:$rs, 

                                GPR64Opnd:$rs)),
                                      (MipsMFLO (MipsMult 
                                        GPR64Opnd:$rt, 

                                        GPR64Opnd:$rt)))
                                )];
}

使用选择 DAG 进行指令选择部分所述,如果此模式与当前 DAG 节点匹配,则会选择我们的新指令。由于 SQSUM 谓词,只有在激活 sqsum 功能时才会发生这种情况。让我们用一个测试来检查一下!

测试新指令

如果您扩展了 LLVM,那么最好的做法是使用自动化测试来验证。特别是如果您想将您的扩展贡献给 LLVM 项目,那么就需要良好的测试。

在上一节中添加了一个新的机器指令后,我们必须检查两个不同的方面:

  • 首先,我们必须验证指令编码是否正确。

  • 其次,我们必须确保代码生成按预期工作。

LLVM 项目使用 llvm-mc 工具。除了其他任务,此工具可以显示指令的编码。为了进行临时检查,您可以运行以下命令来显示指令的编码:

$ echo "sqsumu \$1,\$2,\$3" | \
  llvm-mc --triple=mips64-linux-gnu -mattr=+sqsum \
              --show-encoding

这已经显示了部分输入和在自动化测试用例中运行的命令。为了验证结果,您可以使用 FileCheck 工具。llvm-mc 的输出被传送到这个工具中。此外,FileCheck 会读取测试用例文件。测试用例文件包含了以 CHECK: 关键字标记的行,之后是预期的输出。FileCheck 会尝试将这些行与传送到它的数据进行匹配。如果没有找到匹配项,则会显示错误。将以下内容的 sqsumu.s 测试用例文件放入 llvm/test/MC/Mips 目录中:

# RUN: llvm-mc %s -triple=mips64-linux-gnu -mattr=+sqsum \
# RUN:  --show-encoding | FileCheck %s
# CHECK: sqsumu  $1, $2, $3 # encoding: [0x70,0x43,0x08,0x28]
     sqsumu $1, $2, $3

如果您在 llvm/test/Mips/MC 文件夹中,可以使用以下命令运行测试,最后会报告成功:

$ llvm-lit sqsumu.s
-- Testing: 1 tests, 1 workers --
PASS: LLVM :: MC/Mips/sqsumu.s (1 of 1)
Testing Time: 0.11s
  Passed: 1

LIT 工具解释 RUN: 行,将 %s 替换为当前的文件名。FileCheck 工具读取文件,解析 CHECK: 行,并尝试匹配来自管道的输入。这是一种非常有效的测试方法。

如果您在 build 目录中,可以使用以下命令调用 LLVM 测试:

$ ninja check-llvm

您还可以运行一个文件夹中包含的测试,只需添加以破折号分隔的文件夹名称。要运行 llvm/test/Mips/MC 文件夹中的测试,可以输入以下命令:

$ ninja check-llvm-mips-mc

要为代码生成构建一个测试用例,您可以遵循相同的策略。以下的 sqsum.ll 文件包含了用于计算斜边平方的 LLVM IR 代码:

define i64 @hyposquare(i64 %a, i64 %b) {
  %asq = mul i64 %a, %a
  %bsq = mul i64 %b, %b
  %res = add i64 %asq, %bsq
  ret i64 %res
}

要查看生成的汇编代码,您可以使用 llc 工具:

$ llc –mtriple=mips64-linux-gnu –mattr=+sqsum < sqsum.ll

确保您在输出中看到我们的新 sqsum 指令。还请检查,如果删除 –mattr=+sqsum 选项,则不会生成该指令。

掌握了这些知识,您可以构建测试用例。这次,我们使用两个RUN:行:一个用于检查我们是否生成了新指令,另一个用于检查是否没有生成。我们可以在一个测试用例文件中执行这两个操作,因为我们可以告诉FileCheck工具查找的标签与CHECK:不同。将以下内容的测试用例文件sqsum.ll放入llvm/test/CodeGen/Mips文件夹中:

; RUN: llc -mtriple=mips64-linux-gnu -mattr=+sqsum < %s |\
; RUN:  FileCheck -check-prefix=SQSUM %s
; RUN: llc -mtriple=mips64-linux-gnu < %s |\
; RUN:  FileCheck --check-prefix=NOSQSUM %s
define i64 @hyposquare(i64 %a, i64 %b) {
; SQSUM-LABEL: hyposquare:
; SQSUM: sqsumu $2, $4, $5
; NOSQSUM-LABEL: hyposquare:
; NOSQSUM: dmult $5, $5
; NOSQSUM: mflo $1
; NOSQSUM: dmult $4, $4
; NOSQSUM: mflo $2
; NOSQSUM: addu $2, $2, $1
  %asq = mul i64 %a, %a
  %bsq = mul i64 %b, %b
  %res = add i64 %asq, %bsq
  ret i64 %res
}

与其他测试一样,您可以使用以下命令在文件夹中单独运行测试:

$ llvm-lit squm.ll

或者,您可以使用以下命令从构建目录运行它:

$ ninja check-llvm-mips-codegen

通过这些步骤,您增强了 LLVM 汇编器的功能,使其支持新指令,启用了指令选择以使用这个新指令,并验证了编码是否正确,代码生成是否按预期工作。

总结

在本章中,您学习了 LLVM 目标的后端结构。您使用 MIR 来检查通过后的状态,并使用机器 IR 来运行单个通过。有了这些知识,您可以调查后端通过中的问题。

您学习了 LLVM 中如何使用选择 DAG 来实现指令选择,并且还介绍了使用 FastISel 和 GlobalISel 进行指令选择的替代方法,这有助于决定如果您的平台提供所有这些算法,则选择哪种算法。

您扩展了 LLVM 以支持汇编器中的新机器指令和指令选择,帮助您添加对当前不支持的 CPU 功能的支持。为了验证扩展,您为其开发了自动化测试用例。

在下一章中,我们将研究 LLVM 的另一个独特特性:一步生成和执行代码,也称为即时JIT)编译。

第十章:JIT 编译

LLVM 核心库配备了ExecutionEngine组件,允许在内存中编译和执行 IR 代码。使用这个组件,我们可以构建即时JIT)编译器,允许直接执行 IR 代码。JIT 编译器更像解释器,因为不需要在辅助存储上存储目标代码。

在本章中,您将了解 JIT 编译器的应用程序,以及 LLVM JIT 编译器的工作原理。您将探索 LLVM 动态编译器和解释器,还将学习如何自己实现 JIT 编译器工具。您还将了解如何在静态编译器中使用 JIT 编译器,以及相关的挑战。

本章将涵盖以下主题:

  • 获取 LLVM 的 JIT 实现和用例概述

  • 使用 JIT 编译进行直接执行

  • 利用 JIT 编译器进行代码评估

在本章结束时,您将了解如何开发 JIT 编译器,无论是使用预配置的类还是符合您需求的定制版本。您还将获得使用静态编译器内部的 JIT 编译器的知识。

技术要求

本章的代码文件可以在github.com/PacktPublishing/Learn-LLVM-12/tree/master/Chapter10找到

您可以在bit.ly/3nllhED找到代码的实际操作视频

获取 LLVM 的 JIT 实现和用例概述

到目前为止,我们只看过提前AOT)编译器。这些编译器编译整个应用程序。只有在编译完成后,应用程序才能运行。如果在应用程序运行时进行编译,则编译器是 JIT 编译器。JIT 编译器有一些有趣的用例:

  • 虚拟机的实现:编程语言可以使用 AOT 编译器将其转换为字节码。在运行时,JIT 编译器用于将字节码编译为机器代码。这种方法的优势在于字节码是与硬件无关的,并且由于 JIT 编译器,与 AOT 编译器相比没有性能损失。如今,Java 和 C#使用这种模型,但这个想法实际上很古老:1977 年的 USCD Pascal 编译器已经使用了类似的方法。

  • 表达式评估:电子表格应用程序可以使用 JIT 编译器编译经常执行的表达式。例如,这可以加速财务模拟。LLVM 调试器 LLDB 使用这种方法在调试时评估源表达式。

  • 数据库查询:数据库从数据库查询创建执行计划。执行计划描述了对表和列的操作,这导致了查询执行时的结果。JIT 编译器可以用于将执行计划转换为机器代码,从而加速查询的执行。

LLVM 的静态编译模型并不像你想象的那样远离 JIT 模型。LLVM 静态编译器llc将 LLVM IR 编译成机器代码,并将结果保存为磁盘上的目标文件。如果目标文件不是存储在磁盘上而是存储在内存中,那么代码是否可以执行?不直接执行,因为对全局函数和全局数据的引用使用重定位而不是绝对地址。

概念上,重定位描述了如何计算地址,例如,作为已知地址的偏移量。如果我们解析重定位为地址,就像链接器和动态加载器所做的那样,那么我们就可以执行目标代码。运行静态编译器将 IR 代码编译成内存中的目标文件,对内存中的目标文件进行链接步骤,然后运行代码,这就给我们了一个 JIT 编译器。LLVM 核心库中的 JIT 实现就是基于这个想法的。

在 LLVM 的开发历史中,有几个不同功能集的 JIT 实现。最新的 JIT API 是按需编译ORC)引擎。如果你想知道这个首字母缩略词的含义:这是首席开发人员的意图,在托尔金的宇宙基础上发明另一个首字母缩略词,之前已经有了ELF可执行和链接格式)和DWARF调试标准)。

ORC 引擎建立在使用静态编译器和动态链接器在内存中的对象文件上的想法之上,并对其进行了扩展。实现采用了分层方法。两个基本级别如下:

  1. 编译层

  2. 链接层

在编译层之上可以放置一个提供对延迟编译的支持的层。转换层可以堆叠在延迟编译层的上方或下方,允许开发人员添加任意的转换,或者只是在某些事件发生时得到通知。这种分层方法的优势在于 JIT 引擎可以根据不同的需求进行定制。例如,高性能虚拟机可能会选择预先编译所有内容,并且不使用延迟编译层。其他虚拟机将强调启动时间和对用户的响应性,并通过延迟编译层的帮助来实现这一点。

较旧的 MCJIT 引擎仍然可用。API 源自一个更早的、已经删除的 JIT 引擎。随着时间的推移,API 变得有点臃肿,并且缺乏 ORC API 的灵活性。目标是删除这个实现,因为 ORC 引擎现在提供了 MCJIT 引擎的所有功能。新的开发应该使用 ORC API。

在下一节中,我们将先看看lli,LLVM 解释器和动态编译器,然后再深入实现 JIT 编译器。

使用 JIT 编译进行直接执行

直接运行 LLVM IR 是在考虑 JIT 编译器时首先想到的想法。这就是lli工具,LLVM 解释器和动态编译器所做的。我们将在下一节中探索lli工具,并随后自己实现类似的工具。

探索 lli 工具

让我们尝试使用lli工具进行一个非常简单的示例。将以下源代码存储为hello.ll文件。这相当于一个 C 语言的 hello world 应用程序。它声明了 C 库中printf()函数的原型。hellostr常量包含要打印的消息。在main()函数内部,通过getelementptr指令计算出消息的第一个字符的指针,并将该值传递给printf()函数。该应用程序始终返回0。完整的源代码如下:

declare i32 @printf(i8*, ...)
@hellostr = private unnamed_addr constant [13 x i8] c"Hello                                                   world\0A\00"
define i32 @main(i32 %argc, i8** %argv) {
  %res = call i32 (i8*, ...) @printf(                  i8* getelementptr inbounds ([13 x i8],                          [13 x i8]* @hellostr, i64 0, i64 0))
  ret i32 0
}

这个 LLVM IR 文件足够通用,适用于所有平台。我们可以直接使用以下命令在lli工具中执行 IR:

$ lli hello.ll
Hello world

这里有趣的一点是如何找到printf()函数。IR 代码被编译成机器代码,并触发了对printf符号的查找。在 IR 中找不到这个符号,所以当前进程会在其中搜索。lli工具动态链接到 C 库,并在那里找到了该符号。

当然,lli工具不会链接到您创建的库。为了启用这些函数的使用,lli工具支持加载共享库和对象。以下 C 源代码只是打印一个友好的消息:

#include <stdio.h>
void greetings() {
  puts("Hi!");
}

存储在greetings.c文件中,我们将用它来探索使用lli工具加载对象。将此源代码编译成共享库。-fPIC选项指示 clang 生成位置无关的代码,这对于共享库是必需的。给定-shared选项后,编译器将创建greetings.so共享库:

$ clang –fPIC –shared –o greetings.so greetings.c

我们还将文件编译成greetings.o对象文件:

$ clang –c –o greetings.o greetings.c

现在我们有两个文件,greetings.so共享库和greetings.o对象文件,我们将它们加载到lli工具中。

我们还需要一个 LLVM IR 文件,其中调用greetings()函数。为此,请创建包含对该函数的单个调用的main.ll文件:

declare void @greetings(...)
define dso_local i32 @main(i32 %argc, i8** %argv) {
  call void (...) @greetings()
  ret i32 0
}

如果尝试像以前一样执行 IR,则lli工具无法找到greetings符号,将简单崩溃:

$ lli main.ll
PLEASE submit a bug report to https://bugs.llvm.org/ and include the crash backtrace.

greetings()函数在外部文件中定义,为了修复崩溃,我们必须告诉lli工具需要加载哪个附加文件。为了使用共享库,您必须使用–load选项,该选项以共享库的路径作为参数:

$ lli –load ./greetings.so main.ll
Hi!

如果包含共享库的目录不在动态加载器的搜索路径中,则重要的是指定共享库的路径。如果省略,则将无法找到库。

或者,我们可以指示lli工具使用–extra-object选项加载对象文件:

$ lli –extra-object greetings.o main.ll
Hi!

其他支持的选项是–extra-archive,它加载存档,以及–extra-module,它加载另一个位代码文件。这两个选项都需要文件的路径作为参数。

现在您知道如何使用lli工具直接执行 LLVM IR。在下一节中,我们将实现自己的 JIT 工具。

使用 LLJIT 实现我们自己的 JIT 编译器

lli工具只是 LLVM API 周围的薄包装器。在第一节中,我们了解到 ORC 引擎使用分层方法。ExecutionSession类表示正在运行的 JIT 程序。除其他项目外,此类还保存了使用的JITDylib实例。JITDylib实例是一个符号表,将符号名称映射到地址。例如,这可以是 LLVM IR 文件中定义的符号,或者是加载的共享库的符号。

要执行 LLVM IR,我们不需要自己创建 JIT 堆栈。实用程序LLJIT类提供此功能。当从较旧的 MCJIT 实现迁移时,您也可以使用此类。该类基本上提供了相同的功能。我们将在下一小节中开始实现 JIT 引擎的初始化。

初始化用于编译 LLVM IR 的 JIT 引擎

我们首先实现设置 JIT 引擎,编译 LLVM IR 模块并在此模块中执行main()函数的函数。稍后,我们将使用此核心功能构建一个小型 JIT 工具。这是jitmain()函数:

  1. 该函数需要执行 LLVM 模块的 LLVM IR。还需要用于此模块的 LLVM 上下文类,因为上下文类保存重要的类型信息。目标是调用main()函数,因此我们还传递通常的argcargv参数:
Error jitmain(std::unique_ptr<Module> M,
              std::unique_ptr<LLVMContext> Ctx, int 
              argc,
              char *argv[]) {
  1. 我们使用LLJITBuilder类创建LLJIT实例。如果发生错误,则返回错误。错误的可能来源是平台尚不支持 JIT 编译:
  auto JIT = orc::LLJITBuilder().create();
  if (!JIT)
    return JIT.takeError();
  1. 然后我们将模块添加到主JITDylib实例中。如果配置,则 JIT 编译将利用多个线程。因此,我们需要将模块和上下文包装在ThreadSafeModule实例中。如果发生错误,则返回错误:
  if (auto Err = (*JIT)->addIRModule(
          orc::ThreadSafeModule(std::move(M),
                                std::move(Ctx))))
    return Err;
  1. lli工具一样,我们还支持 C 库中的符号。DefinitionGenerator类公开符号,DynamicLibrarySearchGenerator子类公开共享库中找到的名称。该类提供了两个工厂方法。Load()方法可用于加载共享库,而GetForCurrentProcess()方法公开当前进程的符号。我们使用后者功能。符号名称可以具有前缀,取决于平台。我们检索数据布局并将前缀传递给GetForCurrentprocess()函数。然后符号名称将以正确的方式处理,我们不需要关心它。通常情况下,如果发生错误,我们会从函数中返回:
  const DataLayout &DL = (*JIT)->getDataLayout();
  auto DLSG = orc::DynamicLibrarySearchGenerator::
      GetForCurrentProcess(DL.getGlobalPrefix());
  if (!DLSG)
    return DLSG.takeError();
  1. 然后我们将生成器添加到主JITDylib实例中。如果需要查找符号,则还会搜索加载的共享库中的符号:
  (*JIT)->getMainJITDylib().addGenerator(
      std::move(*DLSG));
  1. 接下来,我们查找main符号。该符号必须在命令行给出的 IR 模块中。查找触发了该 IR 模块的编译。如果 IR 模块内引用了其他符号,则使用前一步添加的生成器进行解析。结果是JITEvaluatedSymbol类的实例:
  auto MainSym = (*JIT)->lookup("main");
  if (!MainSym)
    return MainSym.takeError();
  1. 我们询问返回的 JIT 符号函数的地址。我们将此地址转换为 C main()函数的原型:
  auto *Main = (int (*)(
      int, char **))MainSym->getAddress();
  1. 现在我们可以在 IR 模块中调用main()函数,并传递函数期望的argcargv参数。我们忽略返回值:
  (void)Main(argc, argv);
  1. 函数执行后报告成功:
  return Error::success();
}

这演示了使用 JIT 编译是多么容易。除了暴露当前进程或共享库中的符号之外,还有许多其他可能性。StaticLibraryDefinitionGenerator类暴露了静态存档中找到的符号,并且可以像DynamicLibrarySearchGenerator类一样使用。LLJIT类还有一个addObjectFile()方法来暴露对象文件的符号。如果现有的实现不符合您的需求,您还可以提供自己的DefinitionGenerator实现。在下一小节中,您将把实现扩展为 JIT 编译器。

创建 JIT 编译器实用程序

jitmain()函数很容易扩展为一个小工具,我们接下来就这样做。源代码保存在JIT.cpp文件中,是一个简单的 JIT 编译器:

  1. 我们必须包含几个头文件。LLJIT.h头文件定义了LLJIT类和 ORC API 的核心类。我们包含IRReader.h头文件,因为它定义了一个用于读取 LLVM IR 文件的函数。CommandLine.h头文件允许我们以 LLVM 风格解析命令行选项。最后,InitLLVM.h头文件用于工具的基本初始化,TargetSelect.h头文件用于本机目标的初始化:
#include "llvm/ExecutionEngine/Orc/LLJIT.h"
#include "llvm/IRReader/IRReader.h"
#include "llvm/Support/CommandLine.h"
#include "llvm/Support/InitLLVM.h"
#include "llvm/Support/TargetSelect.h"
  1. 我们将llvm命名空间添加到当前作用域中:
using namespace llvm;
  1. 我们的 JIT 工具在命令行上期望有一个输入文件,我们使用cl::opt<>类声明这个文件:
static cl::opt<std::string>
    InputFile(cl::Positional, cl::Required,
              cl::desc("<input-file>"));
  1. 要读取 IR 文件,我们调用parseIRFile()函数。文件可以是文本 IR 表示,也可以是位码文件。该函数返回指向创建的模块的指针。错误处理有点不同,因为可以解析文本 IR 文件,这不一定是语法正确的。SMDiagnostic实例在语法错误时保存错误信息。错误消息被打印,应用程序退出:
std::unique_ptr<Module>
loadModule(StringRef Filename, LLVMContext &Ctx,
           const char *ProgName) {
  SMDiagnostic Err;
  std::unique_ptr<Module> Mod =
      parseIRFile(Filename, Err, Ctx);
  if (!Mod.get()) {
    Err.print(ProgName, errs());
    exit(-1);
  }
  return std::move(Mod);
}
  1. jitmain()函数放在这里:
Error jitmain(…) { … }
  1. 然后我们添加main()函数,该函数初始化工具和本机目标,并解析命令行:
int main(int argc, char *argv[]) {
  InitLLVM X(argc, argv);
  InitializeNativeTarget();
  InitializeNativeTargetAsmPrinter();
  InitializeNativeTargetAsmParser();
  cl::ParseCommandLineOptions(argc, argv,
                              "JIT\n");
  1. 接下来,初始化 LLVM 上下文类:
  auto Ctx = std::make_unique<LLVMContext>();
  1. 然后我们加载命令行上命名的 IR 模块:
  std::unique_ptr<Module> M =
      loadModule(InputFile, *Ctx, argv[0]);
  1. 然后我们可以调用jitmain()函数。为了处理错误,我们使用ExitOnError实用类。当发生错误时,该类打印错误消息并退出应用程序。我们还设置了一个横幅,显示应用程序的名称,该横幅会在错误消息之前打印:
  ExitOnError ExitOnErr(std::string(argv[0]) + ": ");
  ExitOnErr(jitmain(std::move(M), std::move(Ctx),
                    argc, argv));
  1. 如果控制流到达这一点,那么 IR 已成功执行。我们返回0表示成功:
  return 0;
}

这已经是完整的实现了!我们只需要添加构建描述,这是下一小节的主题。

添加 CMake 构建描述

为了编译这个源文件,我们还需要创建一个CMakeLists.txt文件,其中包含构建描述,保存在JIT.cpp文件旁边:

  1. 我们将最小要求的 CMake 版本设置为 LLVM 所需的版本号,并给项目命名为jit
cmake_minimum_required (VERSION 3.13.4)
project ("jit")
  1. LLVM 包需要被加载,我们将 LLVM 提供的 CMake 模块目录添加到搜索路径中。然后我们包含ChooseMSVCCRT模块,以确保与 LLVM 使用相同的 C 运行时:
find_package(LLVM REQUIRED CONFIG)
list(APPEND CMAKE_MODULE_PATH ${LLVM_DIR})
include(ChooseMSVCCRT)
  1. 我们还需要添加 LLVM 的定义和包含路径。使用的 LLVM 组件通过函数调用映射到库名称:
add_definitions(${LLVM_DEFINITIONS})
include_directories(SYSTEM ${LLVM_INCLUDE_DIRS})
llvm_map_components_to_libnames(llvm_libs Core OrcJIT
                                          Support 
                                          native)
  1. 最后,我们定义可执行文件的名称,要编译的源文件以及要链接的库:
add_executable(JIT JIT.cpp)
target_link_libraries(JIT ${llvm_libs})
  1. 这就是 JIT 工具所需的一切。创建并切换到构建目录,然后运行以下命令来创建和编译应用程序:
$ cmake –G Ninja <path to source directory>
$ ninja

这将编译JIT工具。您可以使用本章开头的hello.ll文件检查功能:

$ JIT hello.ll
Hello world

创建 JIT 编译器非常容易!

示例使用 LLVM IR 作为输入,但这不是必需的。LLJIT类使用IRCompileLayer类,负责将 IR 编译为机器代码。您可以定义自己的层,接受您需要的输入,例如 Java 字节码。

使用预定义的 LLJIT 类很方便,但限制了我们的灵活性。在下一节中,我们将看看如何使用 ORC API 提供的层来实现 JIT 编译器。

从头开始构建 JIT 编译器类

使用 ORC 的分层方法,非常容易构建符合要求的 JIT 编译器。没有一种通用的 JIT 编译器,本章的第一部分给出了一些例子。让我们看看如何设置 JIT 编译器。

ORC API 使用堆叠在一起的层。最低级别是对象链接层,由llvm::orc::RTDyldObjectLinkingLayer类表示。它负责链接内存对象并将其转换为可执行代码。此任务所需的内存由MemoryManager接口的实例管理。有一个默认实现,但如果需要,我们也可以使用自定义版本。

对象链接层上面是编译层,负责创建内存中的目标文件。llvm::orc::IRCompileLayer类以 IR 模块作为输入,并将其编译为目标文件。IRCompileLayer类是IRLayer类的子类,后者是接受 LLVM IR 的层实现的通用类。

这两个层已经构成了 JIT 编译器的核心。它们将 LLVM IR 模块作为输入,编译并链接到内存中。要添加更多功能,我们可以在这两个层之上添加更多层。例如,CompileOnDemandLayer类将模块拆分,以便仅编译请求的函数。这可以用于实现延迟编译。CompileOnDemandLayer类也是IRLayer类的子类。以非常通用的方式,IRTransformLayer类,也是IRLayer类的子类,允许我们对模块应用转换。

另一个重要的类是ExecutionSession类。这个类表示正在运行的 JIT 程序。基本上,这意味着该类管理JITDylib符号表,为符号提供查找功能,并跟踪使用的资源管理器。

JIT 编译器的通用配方如下:

  1. 初始化ExecutionSession类的一个实例。

  2. 初始化层,至少包括RTDyldObjectLinkingLayer类和IRCompileLayer类。

  3. 创建第一个JITDylib符号表,通常使用main或类似的名称。

使用方法与上一节的LLJIT类非常相似:

  1. 将 IR 模块添加到符号表中。

  2. 查找符号,触发相关函数的编译,可能是整个模块。

  3. 执行函数。

在下一小节中,我们将基于通用配方实现一个 JIT 编译器类。

创建一个 JIT 编译器类

为了保持 JIT 编译器类的实现简单,我们将所有内容放入JIT.h头文件中。类的初始化有点复杂。由于需要处理可能的错误,我们需要一个工厂方法在调用构造函数之前创建一些对象。创建类的步骤如下:

  1. 我们首先使用JIT_H预处理器定义保护头文件免受多次包含的影响:
#ifndef JIT_H
#define JIT_H
  1. 需要一堆包含文件。其中大多数提供与头文件同名的类。Core.h头文件提供了一些基本类,包括ExecutionSession类。ExecutionUtils.h头文件提供了DynamicLibrarySearchGenerator类来搜索库中的符号,我们已经在使用 LLJIT 实现我们自己的 JIT 编译器部分中使用过。CompileUtils.h头文件提供了ConcurrentIRCompiler类:
#include "llvm/Analysis/AliasAnalysis.h"
#include "llvm/ExecutionEngine/JITSymbol.h"
#include "llvm/ExecutionEngine/Orc/CompileUtils.h"
#include "llvm/ExecutionEngine/Orc/Core.h"
#include "llvm/ExecutionEngine/Orc/ExecutionUtils.h"
#include "llvm/ExecutionEngine/Orc/IRCompileLayer.h"
#include "llvm/ExecutionEngine/Orc/IRTransformLayer.h"
#include     "llvm/ExecutionEngine/Orc/JITTargetMachineBuilder.h"
#include "llvm/ExecutionEngine/Orc/Mangling.h"
#include     "llvm/ExecutionEngine/Orc/RTDyldObjectLinkingLayer.h"
#include     "llvm/ExecutionEngine/Orc/TargetProcessControl.h"
#include "llvm/ExecutionEngine/SectionMemoryManager.h"
#include "llvm/Passes/PassBuilder.h"
#include "llvm/Support/Error.h"
  1. 我们的新类是JIT类:
class JIT {
  1. 私有数据成员反映了 ORC 层和一个辅助类。 ExecutionSessionObjectLinkingLayerCompileLayerOptIRLayerMainJITDylib实例代表了运行中的 JIT 程序,层和符号表,如前所述。 TargetProcessControl实例用于与 JIT 目标进程进行交互。这可以是相同的进程,同一台机器上的另一个进程,或者是不同机器上的远程进程,可能具有不同的架构。 DataLayoutMangleAndInterner类需要以正确的方式操纵符号名称。符号名称是内部化的,这意味着所有相等的名称具有相同的地址。要检查两个符号名称是否相等,只需比较地址,这是一个非常快速的操作:
  std::unique_ptr<llvm::orc::TargetProcessControl> 
    TPC;
  std::unique_ptr<llvm::orc::ExecutionSession> ES;
  llvm::DataLayout DL;
  llvm::orc::MangleAndInterner Mangle;
  std::unique_ptr<llvm::orc::RTDyldObjectLinkingLayer>
      ObjectLinkingLayer;
  std::unique_ptr<llvm::orc::IRCompileLayer>
      CompileLayer;
  std::unique_ptr<llvm::orc::IRTransformLayer>
      OptIRLayer;
  llvm::orc::JITDylib &MainJITDylib;
  1. 初始化分为三个部分。在 C++中,构造函数不能返回错误。简单且推荐的解决方案是创建一个静态工厂方法,在构造对象之前进行错误处理。层的初始化更复杂,因此我们也为它们引入了工厂方法。

create()工厂方法中,我们首先创建一个SymbolStringPool实例,用于实现字符串内部化,并由几个类共享。为了控制当前进程,我们创建一个SelfTargetProcessControl实例。如果我们想要针对不同的进程,则需要更改此实例。

然后,我们构造了一个JITTargetMachineBuilder实例,我们需要知道 JIT 进程的目标三元组。接下来,我们查询目标机器生成器以获取数据布局。如果生成器无法根据提供的三元组实例化目标机器,例如,因为对此目标的支持未编译到 LLVM 库中,这一步可能会失败:

public:
  static llvm::Expected<std::unique_ptr<JIT>> create() {
    auto SSP =
        std::make_shared<llvm::orc::SymbolStringPool>();
    auto TPC =
        llvm::orc::SelfTargetProcessControl::Create(SSP);
    if (!TPC)
      return TPC.takeError();
    llvm::orc::JITTargetMachineBuilder JTMB(
        (*TPC)->getTargetTriple());
    auto DL = JTMB.getDefaultDataLayoutForTarget();
    if (!DL)
      return DL.takeError();
  1. 在这一点上,我们已经处理了所有可能失败的调用。我们现在能够初始化ExecutionSession实例。最后,调用JIT类的构造函数,并将结果返回给调用者:
    auto ES =
        std::make_unique<llvm::orc::ExecutionSession>(
            std::move(SSP));
    return std::make_unique<JIT>(
        std::move(*TPC), std::move(ES), 
        std::move(*DL),
        std::move(JTMB));
  }
  1. JIT类的构造函数将传递的参数移动到私有数据成员。通过调用带有create前缀的静态工厂名称构造层对象。每个layer工厂方法都需要引用ExecutionSession实例,将层连接到运行中的 JIT 会话。除了对象链接层位于层堆栈的底部之外,每个层还需要引用上一个层,说明了堆叠顺序:
  JIT(std::unique_ptr<llvm::orc::TargetProcessControl>
          TPCtrl,
      std::unique_ptr<llvm::orc::ExecutionSession> ExeS,
      llvm::DataLayout DataL,
      llvm::orc::JITTargetMachineBuilder JTMB)
      : TPC(std::move(TPCtrl)), ES(std::move(ExeS)),
        DL(std::move(DataL)), Mangle(*ES, DL),
        ObjectLinkingLayer(std::move(
            createObjectLinkingLayer(*ES, JTMB))),
        CompileLayer(std::move(createCompileLayer(
            *ES, *ObjectLinkingLayer, 
             std::move(JTMB)))),
        OptIRLayer(std::move(
            createOptIRLayer(*ES, *CompileLayer))),
        MainJITDylib(ES->createBareJITDylib("<main>")) {
  1. 在构造函数的主体中,我们添加了生成器来搜索当前进程的符号。GetForCurrentProcess()方法是特殊的,因为返回值包装在Expected<>模板中,表示也可以返回Error对象。但我们知道不会发生错误-当前进程最终会运行!因此,我们使用cantFail()函数解包结果,如果发生错误,它将终止应用程序:
    MainJITDylib.addGenerator(llvm::cantFail(
        llvm::orc::DynamicLibrarySearchGenerator::
            GetForCurrentProcess(DL.getGlobalPrefix())));
  }
  1. 要创建对象链接层,我们需要提供一个内存管理器。我们在这里坚持使用默认的SectionMemoryManager类,但如果需要,我们也可以提供不同的实现:
  static std::unique_ptr<
      llvm::orc::RTDyldObjectLinkingLayer>
  createObjectLinkingLayer(
      llvm::orc::ExecutionSession &ES,
      llvm::orc::JITTargetMachineBuilder &JTMB) {
    auto GetMemoryManager = []() {
      return std::make_unique<
          llvm::SectionMemoryManager>();
    };
    auto OLLayer = std::make_unique<
        llvm::orc::RTDyldObjectLinkingLayer>(
        ES, GetMemoryManager);
  1. 对于在 Windows 上使用的 COFF 目标文件格式存在一个小复杂性。这种文件格式不允许将函数标记为导出。这随后导致在对象链接层内部的检查失败:存储在符号中的标志与 IR 中的标志进行比较,由于缺少导出标记而导致不匹配。解决方案是仅针对这种文件格式覆盖标志。这完成了对象层的构建,并将对象返回给调用者:
    if (JTMB.getTargetTriple().isOSBinFormatCOFF()) {
      OLLayer
         ->setOverrideObjectFlagsWithResponsibilityFlags(
              true);
      OLLayer
         ->setAutoClaimResponsibilityForObjectSymbols(
              true);
    }
    return std::move(OLLayer);
  }
  1. 要初始化编译器层,需要一个IRCompiler实例。IRCompiler实例负责将 IR 模块编译成目标文件。如果我们的 JIT 编译器不使用线程,那么我们可以使用SimpleCompiler类,它使用给定的目标机器编译 IR 模块。TargetMachine类不是线程安全的,同样SimpleCompiler类也不是。为了支持多线程编译,我们使用ConcurrentIRCompiler类,它为每个要编译的模块创建一个新的TargetMachine实例。这种方法解决了多线程的问题:
  static std::unique_ptr<llvm::orc::IRCompileLayer>
  createCompileLayer(
      llvm::orc::ExecutionSession &ES,
      llvm::orc::RTDyldObjectLinkingLayer &OLLayer,
      llvm::orc::JITTargetMachineBuilder JTMB) {
    auto IRCompiler = std::make_unique<
        llvm::orc::ConcurrentIRCompiler>(
        std::move(JTMB));
    auto IRCLayer =
        std::make_unique<llvm::orc::IRCompileLayer>(
            ES, OLLayer, std::move(IRCompiler));
    return std::move(IRCLayer);
  }
  1. 我们不直接将 IR 模块编译成机器代码,而是安装一个优化 IR 的层。这是一个有意的设计决定:我们将我们的 JIT 编译器转变为一个优化的 JIT 编译器,它产生更快的代码,但需要更长的时间来生成,这对用户来说会有延迟。我们不添加延迟编译,所以当查找一个符号时,整个模块都会被编译。这可能会导致用户在看到代码执行之前花费相当长的时间。
  static std::unique_ptr<llvm::orc::IRTransformLayer>
  createOptIRLayer(
      llvm::orc::ExecutionSession &ES,
      llvm::orc::IRCompileLayer &CompileLayer) {
    auto OptIRLayer =
        std::make_unique<llvm::orc::IRTransformLayer>(
            ES, CompileLayer,
            optimizeModule);
    return std::move(OptIRLayer);
  }
  1. optimizeModule()函数是对 IR 模块进行转换的一个示例。该函数以要转换的模块作为参数,并返回转换后的模块。由于 JIT 可能会使用多个线程,IR 模块被包装在一个ThreadSafeModule实例中:
  static llvm::Expected<llvm::orc::ThreadSafeModule>
  optimizeModule(
      llvm::orc::ThreadSafeModule TSM,
      const llvm::orc::MaterializationResponsibility
          &R) {
  1. 为了优化 IR,我们回顾一些来自第八章的信息,优化 IR,在向编译器添加优化流水线部分。我们需要一个PassBuilder实例来创建一个优化流水线。首先,我们定义了一些分析管理器,并在通行构建器中注册它们。然后,我们使用默认的优化流水线填充了一个ModulePassManager实例,用于O2级别。这再次是一个设计决定:O2级别已经产生了快速的机器代码,但比O3级别更快。之后,我们在模块上运行流水线。最后,优化后的模块返回给调用者:
    TSM.withModuleDo([](llvm::Module &M) {
      bool DebugPM = false;
      llvm::PassBuilder PB(DebugPM);
      llvm::LoopAnalysisManager LAM(DebugPM);
      llvm::FunctionAnalysisManager FAM(DebugPM);
      llvm::CGSCCAnalysisManager CGAM(DebugPM);
      llvm::ModuleAnalysisManager MAM(DebugPM);
      FAM.registerPass(
          [&] { return PB.buildDefaultAAPipeline(); });
      PB.registerModuleAnalyses(MAM);
      PB.registerCGSCCAnalyses(CGAM);
      PB.registerFunctionAnalyses(FAM);
      PB.registerLoopAnalyses(LAM);
      PB.crossRegisterProxies(LAM, FAM, CGAM, MAM);
      llvm::ModulePassManager MPM =
          PB.buildPerModuleDefaultPipeline(
              llvm::PassBuilder::OptimizationLevel::O2,
              DebugPM);
      MPM.run(M, MAM);
    });
    return std::move(TSM);
  }
  1. JIT类的客户端需要一种添加 IR 模块的方法,我们使用addIRModule()函数提供这种方法。记住我们创建的层栈:我们必须将 IR 模块添加到顶层,否则我们可能会意外地绕过一些层。这将是一个不容易发现的编程错误:如果OptIRLayer成员被CompileLayer成员替换,那么我们的JIT类仍然可以工作,但不作为一个优化的 JIT,因为我们已经绕过了这一层。这在这个小实现中并不值得担心,但在一个大的 JIT 优化中,我们会引入一个函数来返回顶层层次:
  llvm::Error addIRModule(
      llvm::orc::ThreadSafeModule TSM,
      llvm::orc::ResourceTrackerSP RT = nullptr) {
    if (!RT)
      RT = MainJITDylib.getDefaultResourceTracker();
    return OptIRLayer->add(RT, std::move(TSM));
  }
  1. 同样,我们的 JIT 类的客户端需要一种查找符号的方法。我们将这个任务委托给ExecutionSession实例,传入主符号表的引用以及所请求符号的 mangled 和 internalized 名称:
  llvm::Expected<llvm::JITEvaluatedSymbol>
  lookup(llvm::StringRef Name) {
    return ES->lookup({&MainJITDylib},
                      Mangle(Name.str()));
  }

将 JIT 编译器组合在一起相当容易。初始化这个类有点棘手,因为它涉及到JIT类的一个工厂方法和构造函数调用,以及每个层的工厂方法。这种分布是由于 C++的限制,尽管代码本身很简单。

在下一小节中,我们将使用我们的新 JIT 编译器类来实现一个命令行实用程序。

使用我们的新 JIT 编译器类

我们的新 JIT 编译器类的接口类似于使用 LLJIT 实现我们自己的 JIT 编译器部分中使用的LLJIT类。为了测试我们的新实现,我们从上一节中复制LIT.cpp类,并进行以下更改:

  1. 为了能够使用我们的新类,我们包含JIT.h头文件。这取代了llvm/ExecutionEngine/Orc/LLJIT.h头文件,因为我们不再使用 LLJIT 类,所以它不再需要。

  2. jitmain()函数中,我们用对我们的新JIT::create()方法的调用替换了对orc::LLJITBuilder().create()的调用。

  3. 同样,在jitmain()函数中,我们删除了添加DynamicLibrarySearchGenerator类的代码。这个生成器已经集成在 JIT 类中。

这已经是需要改变的一切了!我们可以像在上一节中一样编译和运行更改后的应用程序,得到相同的结果。在底层,新类使用了固定的优化级别,因此对于足够大的模块,我们可以注意到启动和运行时的差异。

拥有 JIT 编译器可以激发新的想法。在下一节中,我们将看看如何将 JIT 编译器作为静态编译器的一部分来评估编译时的代码。

利用 JIT 编译器进行代码评估

编译器编写者付出了巨大的努力来生成最佳代码。一个简单而有效的优化是用两个常量替换算术运算的结果值。为了能够执行计算,嵌入了一个常量表达式的解释器。为了得到相同的结果,解释器必须实现与生成的机器代码相同的规则!当然,这可能是微妙错误的源泉。

另一种方法是使用相同的代码生成方法将常量表达式编译为 IR,然后让 JIT 编译和执行 IR。这个想法甚至可以进一步发展。在数学中,函数对于相同的输入总是产生相同的结果。对于计算机语言中的函数,这并不成立。一个很好的例子是rand()函数,它每次调用都返回一个随机值。在计算机语言中,具有与数学函数相同特性的函数称为纯函数。在表达式优化期间,我们可以 JIT 编译和执行只有常量参数的纯函数,并用 JIT 执行返回的结果替换对函数的调用。实际上,我们将函数的执行从运行时移到了编译时!

考虑交叉编译

在静态编译器中使用 JIT 编译器是一个有趣的选择。然而,如果编译器支持交叉编译,那么这种方法应该经过深思熟虑。通常会引起麻烦的候选者是浮点类型。C 语言中long double类型的精度通常取决于硬件和操作系统。一些系统使用 128 位浮点数,而其他系统只使用 64 位浮点数。80 位浮点类型仅在 x86 平台上可用,并且通常仅在 Windows 上使用。使用不同精度进行相同的浮点运算可能会导致巨大差异。在这种情况下,无法使用 JIT 编译进行评估。

很难确定一个函数是否是纯函数。常见的解决方案是应用一种启发式方法。如果一个函数既不通过指针也不通过聚合类型间接地读取或写入堆内存,并且只调用其他纯函数,那么它就是一个纯函数。开发人员可以帮助编译器,例如,用特殊的关键字或符号标记纯函数。在语义分析阶段,编译器可以检查违规情况。

在下一小节中,我们将更仔细地看一下在编译时尝试对函数进行 JIT 执行时对语言语义的影响。

识别语言语义

困难的部分确实是在语言语义层面决定哪些语言部分适合在编译时进行评估。排除对堆内存的访问是非常限制性的。一般来说,这排除了字符串处理,例如。当分配的内存的生存周期超过 JIT 执行的函数的生存周期时,使用堆内存就会变得棘手。这是一个程序状态,可能会影响其他结果,因此是危险的。另一方面,如果malloc()free()函数有匹配的调用,那么内存只用于内部计算。在这种情况下,使用堆内存是安全的。但要证明这种条件并不容易。

在类似的层面上,JIT 执行函数中的无限循环可能会使编译器冻结。艾伦·图灵在 1936 年表明,没有机器可以决定一个函数是否会产生结果,或者它是否陷入无休止的循环。必须采取一些预防措施来避免这种情况,例如,在 JIT 执行的函数被终止之前设置一个运行时限制。

最后,允许更多功能,就必须更多地考虑安全性,因为编译器现在执行的是其他人编写的代码。想象一下,这段代码从互联网下载并运行文件,或者试图擦除硬盘:如果允许 JIT 执行函数有太多状态,我们也需要考虑这样的情况。

这个想法并不新鲜。D 编程语言有一个名为编译时函数执行的功能。参考编译器dmd通过在 AST 级别解释函数来实现这一功能。基于 LLVM 的 LDC 编译器具有一个试验性的功能,可以使用 LLVM JIT 引擎。您可以在 https://dlang.org/了解更多关于该语言和编译器的信息。

忽略语义上的挑战,实现并不那么困难。在“从头开始构建 JIT 编译器类”部分,我们使用JIT类开发了一个 JIT 编译器。我们在类中输入一个 IR 模块,然后可以查找并执行该模块中的函数。通过查看tinylang编译器的实现,我们可以清楚地识别对常量的访问,因为 AST 中有一个ConstantAccess节点。例如,有如下代码:

  if (auto *Const = llvm::dyn_cast<ConstantAccess>(Expr)) {
    // Do something with the constant.
  }

与其解释表达式中的操作以推导常量的值,我们可以做如下操作:

  1. 创建一个新的 IR 模块。

  2. 在模块中创建一个 IR 函数,返回预期类型的值。

  3. 使用现有的emitExpr()函数为表达式创建 IR,并使用最后一条指令返回计算出的值。

  4. JIT 执行函数以计算值。

这值得实现吗?LLVM 在优化管道中执行常量传播和函数内联。例如,一个简单的表达式如 4 + 5 在 IR 构造过程中已经被替换为结果。像最大公约数的计算这样的小函数会被内联。如果所有参数都是常量值,那么内联的代码会通过常量传播的计算结果被替换。

基于这一观察,这种方法的实现只有在编译时有足够的语言特性可供执行时才有用。如果是这种情况,那么使用给定的草图实现起来是相当容易的。

了解如何使用 LLVM 的 JIT 编译器组件使您能够以全新的方式使用 LLVM。除了实现类似 Java 虚拟机的 JIT 编译器之外,JIT 编译器还可以嵌入到其他应用程序中。这允许创造性的方法,比如在本节中所看到的将其用于静态编译器。

总结

在本章中,您学习了如何开发 JIT 编译器。您从 JIT 编译器的可能应用开始,并探索了 LLVM 动态编译器和解释器lli。使用预定义的LLJIT类,您自己构建了类似于lli的工具。为了能够利用 ORC API 的分层结构,您实现了一个优化的JIT类。在获得了所有这些知识之后,您探讨了在静态编译器内部使用 JIT 编译器的可能性,这是一些语言可以受益的特性。

在下一章中,您将学习如何为新的 CPU 架构向 LLVM 添加后端。

第十一章:使用 LLVM 工具进行调试

LLVM 带有一套工具,可帮助您识别应用程序中的某些错误。所有这些工具都使用 LLVM 和Clang库。

在本章中,您将学习如何使用消毒剂为应用程序安装仪器,如何使用最常见的消毒剂来识别各种错误,并如何为应用程序实现模糊测试。这将帮助您识别通常无法通过单元测试找到的错误。您还将学习如何识别应用程序中的性能瓶颈,运行静态分析器以识别通常无法通过编译器找到的问题,并创建自己的基于 Clang 的工具,以便您可以扩展 Clang 的新功能。

本章将涵盖以下主题:

  • 使用消毒剂为应用程序安装仪器

  • 使用libFuzzer查找错误

  • 使用XRay进行性能分析

  • 使用Clang 静态分析器检查源代码

  • 创建自己的基于 Clang 的工具

在本章结束时,您将了解如何使用各种 LLVM 和 Clang 工具来识别应用程序中的大量错误类别。您还将获得扩展 Clang 的知识,例如强制执行命名约定或添加新的源分析功能。

技术要求

要在使用 XRay 进行性能分析部分创建火焰图,您需要从github.com/brendangregg/FlameGraph安装脚本。一些系统,如FedoraFreeBSD,提供了这些脚本的软件包,您也可以使用它们。

要在同一部分查看Chrome 可视化,您需要安装Chrome浏览器。您可以从www.google.com/chrome/下载浏览器,或者使用系统的软件包管理器安装 Chrome 浏览器。本章的代码文件可在github.com/PacktPublishing/Learn-LLVM-12/tree/master/Chapter11找到

您可以在bit.ly/3nllhED找到代码的实际操作视频

使用消毒剂为应用程序安装仪器

LLVM 带有一些compiler-rt项目。消毒剂可以在 Clang 中启用,这使它们非常方便使用。在接下来的章节中,我们将看一下可用的消毒剂,即“地址”,“内存”和“线程”。我们将首先看一下“地址”消毒剂。

使用地址消毒剂检测内存访问问题

您可以使用“地址”消毒剂来检测应用程序中的一些内存访问错误。这包括常见错误,如在释放动态分配的内存后继续使用它,或者在分配的内存边界之外写入动态分配的内存。

启用“地址”消毒剂后,它将使用自己的版本替换对malloc()free()函数的调用,并使用检查保护仪器化所有内存访问。当然,这会给应用程序增加很多开销,您只会在应用程序的测试阶段使用“地址”消毒剂。如果您对实现细节感兴趣,可以在llvm/lib/Transforms/Instrumentation/AddressSanitizer.cpp文件中找到该传递的源代码,以及在github.com/google/sanitizers/wiki/AddressSanitizerAlgorithm上找到的算法描述。

让我们运行一个简短的示例来演示“地址”消毒剂的功能。以下示例应用程序outofbounds.c分配了12字节的内存,但初始化了14字节:

#include <stdlib.h>
#include <string.h>
int main(int argc, char *argv[]) {
  char *p = malloc(12);
  memset(p, 0, 14);
  return (int)*p;
}

您可以编译并运行此应用程序,而不会注意到任何问题。这对于这种类型的错误是典型的。即使在更大的应用程序中,这种错误也可能长时间不被注意到。但是,如果您使用-fsanitize=address选项启用address检测器,那么应用程序在检测到错误后会停止。

启用-g选项的调试符号也很有用,因为它有助于确定源代码中错误的位置。以下代码是一个使用address检测器和启用调试符号编译源文件的示例:

$ clang -fsanitize=address -g outofbounds.c -o outofbounds

现在,当运行应用程序时,您会得到一个冗长的错误报告:

$ ./outofbounds
=================================================================
==1067==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x60200000001c at pc 0x00000023a6ef bp 0x7fffffffeb10 sp 0x7fffffffe2d8
WRITE of size 14 at 0x60200000001c thread T0
    #0 0x23a6ee in __asan_memset /usr/src/contrib/llvm-project/compiler-rt/lib/asan/asan_interceptors_memintrinsics.cpp:26:3
    #1 0x2b2a03 in main /home/kai/sanitizers/outofbounds.c:6:3
    #2 0x23331f in _start /usr/src/lib/csu/amd64/crt1.c:76:7

报告还包含有关内存内容的详细信息。重要信息是错误的类型-address检测器拦截应用程序的执行。它显示了outofbounds.c文件中的第 6 行,其中包含对memset()的调用-确实是发生缓冲区溢出的确切位置。

如果您将outofbounds.c文件中包含memset(p, 0, 14);的行替换为以下代码,则会在释放内存后访问内存。您需要将源代码保存在useafterfree.c文件中:

  memset(p, 0, 12);
  free(p);

再次,如果您编译并运行它,将检测到在释放内存后使用指针:

$ clang -fsanitize=address -g useafterfree.c -o useafterfree
$ ./useafterfree
=================================================================
==1118==ERROR: AddressSanitizer: heap-use-after-free on address 0x602000000010 at pc 0x0000002b2a5c bp 0x7fffffffeb00 sp 0x7fffffffeaf8
READ of size 1 at 0x602000000010 thread T0
    #0 0x2b2a5b in main /home/kai/sanitizers/useafterfree.c:8:15
    #1 0x23331f in _start /usr/src/lib/csu/amd64/crt1.c:76:7

这次,报告指向包含对p指针的解引用的第 8 行

在运行应用程序之前,将ASAN_OPTIONS环境变量设置为值detect_leaks=1,然后您还会收到有关内存泄漏的报告。在命令行上,您可以这样做:

$ ASAN_OPTIONS=detect_leaks=1 ./useafterfree

address检测器非常有用,因为它捕获了一类难以检测的错误。memory检测器执行类似的任务,我们将在下一节中看到。

使用memory检测器查找未初始化的内存访问

使用未初始化的内存是另一类难以发现的错误。在CC++中,一般的内存分配例程不会使用默认值初始化内存缓冲区。对于堆栈上的自动变量也是如此。

存在许多错误的机会,memory检测器有助于找到这些错误。如果您对实现细节感兴趣,可以在llvm/lib/Transforms/Instrumentation/MemorySanitizer.cpp文件中找到memory检测器传递的源代码。文件顶部的注释解释了实现背后的思想。

让我们运行一个小例子,并将以下源代码保存为memory.c文件。您应该注意到x变量没有初始化,但被用作return值:

int main(int argc, char *argv[]) {
  int x;
  return x;
}

如果没有检测器,应用程序将正常运行。但是,如果使用-fsanitize=memory选项,则会收到错误报告:

$ clang -fsanitize=memory -g memory.c -o memory
$ ./memory
==1206==WARNING: MemorySanitizer: use-of-uninitialized-value
    #0 0x10a8f49 in main /home/kai/sanitizers/memory.c:3:3
    #1 0x1053481 in _start /usr/src/lib/csu/amd64/crt1.c:76:7
SUMMARY: MemorySanitizer: use-of-uninitialized-value /home/kai/sanitizers/memory.c:3:3 in main
Exiting

address检测器一样,memory检测器会在发现第一个错误时停止应用程序。

在下一节中,我们将看看如何使用thread检测器来检测多线程应用程序中的数据竞争。

使用thread检测器指出数据竞争

为了利用现代 CPU 的强大功能,应用程序现在使用多个线程。这是一种强大的技术,但也引入了新的错误来源。多线程应用程序中的一个非常常见的问题是对全局数据的访问没有受到保护,例如,thread检测器可以在llvm/lib/Transforms/Instrumentation/ThreadSanitize.cpp文件中检测到数据竞争。

为了演示thread检测器的功能,我们将创建一个非常简单的生产者/消费者风格的应用程序。生产者线程增加全局变量,而消费者线程减少相同的变量。对全局变量的访问没有受到保护,因此这显然是数据竞争。您需要将以下源代码保存在thread.c文件中:

#include <pthread.h>
int data = 0;
void *producer(void *x) {
  for (int i = 0; i < 10000; ++i) ++data;
  return x;
}
void *consumer(void *x) {
  for (int i = 0; i < 10000; ++i) --data;
  return x;
}
int main() {
  pthread_t t1, t2;
  pthread_create(&t1, NULL, producer, NULL);
  pthread_create(&t2, NULL, consumer, NULL);
  pthread_join(t1, NULL);
  pthread_join(t2, NULL);
  return data;
}

从前面的代码中,data变量在两个线程之间共享。在这里,它是int类型,以使示例简单化。通常情况下,会使用诸如std::vector类或类似的数据结构。这两个线程运行producer()consumer()函数。

producer()函数只增加data变量,而consumer()函数减少它。未实现访问保护,因此这构成了数据竞争。main()函数使用pthread_create()函数启动两个线程,使用pthread_join()函数等待线程结束,并返回data变量的当前值。

如果您编译并运行此应用程序,那么您将注意到没有错误;也就是说,返回值始终为 0。在这种情况下,如果循环执行的次数增加了 100 倍,就会出现错误,即返回值不等于 0。然后,您会看到其他值出现。

您可以使用thread sanitizer 来识别数据竞争。要启用带有thread sanitizer 的编译,您需要向 Clang 传递-fsanitize=thread选项。使用-g选项添加调试符号可以在报告中给出行号,这非常有帮助。请注意,您还需要链接pthread库:

$ clang -fsanitize=thread -g thread.c -o thread -lpthread
$ ./thread
==================
WARNING: ThreadSanitizer: data race (pid=1474)
  Write of size 4 at 0x000000cdf8f8 by thread T2:
    #0 consumer /home/kai/sanitizers/thread.c:11:35 (thread+0x2b0fb2)
  Previous write of size 4 at 0x000000cdf8f8 by thread T1:
    #0 producer /home/kai/sanitizers/thread.c:6:35 (thread+0x2b0f22)
  Location is global 'data' of size 4 at 0x000000cdf8f8 (thread+0x000000cdf8f8)
  Thread T2 (tid=100437, running) created by main thread at:
    #0 pthread_create /usr/src/contrib/llvm-project/compiler-rt/lib/tsan/rtl/tsan_interceptors_posix.cpp:962:3 (thread+0x271703)
    #1 main /home/kai/sanitizers/thread.c:18:3 (thread+0x2b1040)
  Thread T1 (tid=100436, finished) created by main thread at:
    #0 pthread_create /usr/src/contrib/llvm-project/compiler-rt/lib/tsan/rtl/tsan_interceptors_posix.cpp:962:3 (thread+0x271703)
    #1 main /home/kai/sanitizers/thread.c:17:3 (thread+0x2b1021)
SUMMARY: ThreadSanitizer: data race /home/kai/sanitizers/thread.c:11:35 in consumer
==================
ThreadSanitizer: reported 1 warnings

报告指出了源文件的第 6 行第 11 行,在这里全局变量被访问。它还显示了两个名为T1T2的线程访问了该变量,以及各自调用pthread_create()函数的文件和行号。

在本节中,我们学习了如何使用三种 sanitizer 来识别应用程序中的常见问题。address sanitizer 帮助我们识别常见的内存访问错误,例如越界访问或在释放后使用内存。使用memory sanitizer,我们可以找到对未初始化内存的访问,而thread sanitizer 则帮助我们识别数据竞争。

在下一节中,我们将尝试通过在随机数据上运行我们的应用程序来触发 sanitizers,称为模糊测试

使用 libFuzzer 查找错误

要测试您的应用程序,您需要编写单元测试。这是确保软件行为正确的好方法。但是,由于可能输入的数量呈指数增长,您可能会错过某些奇怪的输入,以及一些错误。

模糊测试可以在这里帮助。其思想是向应用程序提供随机生成的数据,或者基于有效输入但带有随机更改的数据。这样一遍又一遍地进行,因此您的应用程序将被大量输入进行测试。这是一种非常强大的测试方法。几乎所有浏览器和其他软件中的数百个错误都是通过模糊测试发现的。

LLVM 自带其自己的模糊测试库。最初是 LLVM 核心库的一部分,名为compiler-rt。该库旨在测试小型和快速函数。

让我们运行一个小例子。您需要提供LLVMFuzzerTestOneInput()函数。该函数由fuzzer.c文件调用:

#include <stdint.h>
#include <stdlib.h>
int count(const uint8_t *Data, size_t Size) {
  int cnt = 0;
  if (Size)
    while (Data[cnt] >= '0' && Data[cnt] <= '9') ++cnt;
  return cnt;
}
int LLVMFuzzerTestOneInput(const uint8_t *Data, size_t 
                           Size) {
  count(Data, Size);
  return 0;
}

从前面的代码中,count()函数计算Data变量指向的内存中的数字数量。仅检查数据的大小以确定是否有任何可用字节。在while循环内,未检查大小。

使用正常的0字节。LLVMFuzzerTestOneInput()函数是所谓的0,目前是唯一允许的值。

要使用 libFuzzer 编译文件,您需要添加-fsanitize=fuzzer选项。建议还启用address sanitizer 和生成调试符号。使用以下命令编译文件:

$ clang -fsanitize=fuzzer,address -g fuzzer.c -o fuzzer

运行测试时,会生成一个冗长的报告。该报告包含的信息比堆栈跟踪更多,因此让我们仔细看一下:

  1. 第一行告诉您用于初始化随机数生成器的种子。您可以使用–seed=选项重复此执行:
INFO: Seed: 1297394926
  1. 默认情况下,libFuzzer 将输入限制为最多 4,096 字节。您可以使用–max_len=选项更改默认值:
INFO: -max_len is not provided; libFuzzer will not generate inputs larger than 4096 bytes
  1. 现在,我们在不提供样本输入的情况下运行测试。所有样本输入的集合称为语料库,在此运行中为空:
INFO: A corpus is not provided, starting from an empty corpus
  1. 随后将提供有关生成的测试数据的一些信息。它向您显示尝试了28个输入,找到了6个输入,总长度为19字节,这些输入一共覆盖了6个覆盖点或基本块:
#28     NEW    cov: 6 ft: 9 corp: 6/19b lim: 4 exec/s: 0 rss: 29Mb L: 4/4 MS: 4 CopyPart-PersAutoDict-CopyPart-ChangeByte- DE: "1\x00"-
  1. 之后,检测到缓冲区溢出,并且随后是来自address消毒剂的信息。最后,报告向您指出导致缓冲区溢出的输入的位置:
artifact_prefix='./'; Test unit written to ./crash-17ba0791499db908433b80f37c5fbc89b870084b

有了保存的输入,您可以再次执行带有崩溃输入的测试用例:

$ ./fuzzer crash-17ba0791499db908433b80f37c5fbc89b870084b

这显然对于识别问题非常有帮助。但是,使用随机数据通常并不是非常有用。如果尝试对tinylang词法分析器或解析器进行模糊测试,那么纯随机数据会导致立即拒绝输入,因为找不到有效的标记。

在这种情况下,提供一小组有效输入(称为语料库)更有用。然后,语料库的文件将被随机变异并用作输入。您可以将输入视为大多数有效,只是有一些位被翻转。这也适用于其他必须具有特定格式的输入。例如,对于处理JPEGPNG文件的库,您将提供一些小的JPEGPNG文件作为语料库。

您可以将语料库文件保存在一个或多个目录中,并且可以使用printf命令为模糊测试创建一个简单的语料库:

$ mkdir corpus
$ printf "012345\0" >corpus/12345.txt
$ printf "987\0" >corpus/987.txt

在运行测试时,您将在命令行上提供目录:

$ ./fuzzer corpus/

然后,语料库被用作生成随机输入的基础,正如报告所告诉您的那样:

INFO: seed corpus: files: 2 min: 4b max: 7b total: 11b rss: 29Mb

如果要测试的函数处理标记或其他魔术值,比如编程语言,那么可以通过提供包含标记的字典来加快该过程。对于编程语言,字典将包含语言中使用的所有关键字和特殊符号。字典定义遵循简单的键值样式。例如,要在字典中定义if关键字,可以添加以下内容:

kw1="if"

但是,密钥是可选的,可以省略。然后,您可以使用–dict=选项在命令行上指定字典文件。在下一节中,我们将了解 libFuzzer 实现的限制和替代方案。

限制和替代方案

libFuzzer 实现速度快,但对测试目标有一些限制。它们如下:

  • 测试函数必须将输入作为内存中的数组接受。一些库函数需要数据的文件路径,因此无法使用 libFuzzer 进行测试。

  • 不应调用exit()函数。

  • 不应更改全局状态。

  • 不应使用硬件随机数生成器。

从上述限制中,前两个限制是 libFuzzer 作为库的实现的含义。后两个限制是为了避免评估算法中的混淆。如果这些限制中的一个未满足,那么对模糊目标的两个相同调用可能会产生不同的结果。

模糊测试的最佳替代工具是AFL,位于github.com/google/AFL。AFL 需要一个被插装的二进制文件(提供了一个用于插装的 LLVM 插件),并要求应用程序在命令行上以文件路径形式接受输入。AFL 和 libFuzzer 可以共享相同的语料库和相同的字典文件。因此,可以使用这两种工具测试应用程序。在 libFuzzer 不适用的情况下,AFL 可能是一个很好的替代方案。

还有许多其他影响 libFuzzer 工作方式的方法。您可以阅读llvm.org/docs/LibFuzzer.html上的参考页面以获取更多详细信息。

在下一节中,我们将看一个应用程序可能遇到的完全不同的问题;我们尝试识别性能瓶颈。

使用 XRay 进行性能分析

如果你的应用程序似乎运行缓慢,那么你可能想知道代码中花费了多少时间。在这种情况下,使用llvm/lib/XRay/目录对代码进行仪器化。运行时部分是compiler-rt的一部分。

在下面的示例源代码中,通过调用usleep()函数来模拟真实工作。func1()函数休眠 10 微秒。func2()函数根据n参数是奇数还是偶数,要么调用func1(),要么休眠 100 微秒。在main()函数内,这两个函数都在一个循环中被调用。这已经足够获取有趣的信息了。你需要将以下源代码保存在xraydemo.c文件中:

#include <unistd.h>
void func1() { usleep(10); }
void func2(int n) {
  if (n % 2) func1();
  else usleep(100);
}
int main(int argc, char *argv[]) {
  for (int i = 0; i < 100; i++) { func1(); func2(i); }
  return 0;
}

要在编译期间启用 XRay 仪器化,你需要指定-fxray-instrument选项。少于 200 条指令的函数不会被仪器化。这是开发人员定义的一个任意阈值,在我们的情况下,这些函数不会被仪器化。阈值可以通过-fxray-instruction-threshold=选项指定。另外,我们可以添加一个函数属性来控制是否应该对函数进行仪器化。例如,添加以下原型将导致始终对函数进行仪器化:

void func1() __attribute__((xray_always_instrument));

同样地,通过使用xray_never_instrument属性,你可以关闭对函数的仪器化。

现在我们将使用命令行选项并按以下方式编译xraydemo.c文件:

$ clang -fxray-instrument -fxray-instruction-threshold=1 -g\
  xraydemo.c -o xraydemo

在生成的二进制文件中,默认情况下关闭了仪器。如果你运行这个二进制文件,你会注意到与未经仪器化的二进制文件没有任何区别。XRAY_OPTIONS环境变量用于控制运行时数据的记录。要启用数据收集,你需要按照以下方式运行应用程序:

$ XRAY_OPTIONS= "patch_premain=true xray_mode=xray-basic "\
  ./xraydemo

xray_mode=xray-basic选项告诉运行时我们要使用基本模式。在这种模式下,会收集所有运行时数据,这可能会导致巨大的日志文件。当给出patch_premain=true选项时,那么在main()函数之前运行的函数也会被仪器化。

运行这个命令后,你会在目录中看到一个新文件,其中存储了收集到的数据。你需要使用llvm-xray工具从这个文件中提取可读的信息。

llvm-xray工具支持各种子命令。你可以使用account子命令来提取一些基本统计信息。例如,要获取前 10 个最常调用的函数,你可以添加-top=10选项来限制输出,并使用-sort=count选项来指定函数调用计数作为排序标准。你可以使用-sortorder=选项来影响排序顺序。运行以下命令来获取统计信息:

$ llvm-xray account xray-log.xraydemo.xVsWiE -sort=count\
  -sortorder=dsc -instr_map ./xraydemo
Functions with latencies: 3
   funcid      count        sum  function
        1        150   0.166002  demo.c:4:0: func1
        2        100   0.543103  demo.c:9:0: func2
        3          1   0.655643  demo.c:17:0: main

你可以看到func1()函数被调用最频繁,以及在这个函数中累积的时间。这个示例只有三个函数,所以-top=选项在这里没有明显的效果,但对于真实的应用程序来说,它非常有用。

从收集到的数据中,可以重构出运行时发生的所有堆栈帧。你可以使用stack子命令来查看前 10 个堆栈。这里显示的输出已经为了简洁起见进行了缩减:

$ llvm-xray stack xray-log.xraydemo.xVsWiE -instr_map\
  ./xraydemo
Unique Stacks: 3
Top 10 Stacks by leaf sum:
Sum: 1325516912
lvl   function              count              sum
#0    main                      1       1777862705
#1    func2                    50       1325516912
Top 10 Stacks by leaf count:
Count: 100
lvl   function              count              sum
#0    main                      1       1777862705
#1    func1                   100        303596276

main()函数调用了func2()函数,这是累积时间最长的堆栈帧。深度取决于调用了多少函数,堆栈帧通常很大。

这个子命令也可以用来创建一个flamegraph.pl脚本,将数据转换成可伸缩矢量图形SVG)文件,你可以在浏览器中查看。

使用以下命令,您可以指示llvm-xray使用-all-stacks选项输出所有堆栈帧。使用-stack-format=flame选项,输出格式符合flamegraph.pl脚本的预期格式。使用-aggregation-type选项,您可以选择堆栈帧是按总时间还是按调用次数进行聚合。llvm-xray的输出被导入flamegraph.pl脚本,并将结果输出保存在flame.svg文件中:

$ llvm-xray stack xray-log.xraydemo.xVsWiE -all-stacks\
  -stack-format=flame --aggregation-type=time\
  -instr_map ./xraydemo | flamegraph.pl >flame.svg

在浏览器中打开生成的flame.svg文件。图形如下所示:

图 11.1 - 由 llvm-xray 生成的火焰图

图 11.1 - 由 llvm-xray 生成的火焰图

火焰图乍一看可能会令人困惑,因为x轴没有经过的时间的通常含义。相反,函数只是按名称排序。颜色选择是为了具有良好的对比度,并没有其他含义。从前面的图表中,您可以轻松确定调用层次结构和函数中所花费的时间。

关于堆栈帧的信息只有在将鼠标光标移动到表示该帧的矩形上方时才显示。单击帧后,您可以放大此堆栈帧。如果您想要识别值得优化的函数,火焰图非常有帮助。要了解更多关于火焰图的信息,请访问火焰图的发明者 Brendan Gregg 的网站www.brendangregg.com/flamegraphs.html

您可以使用convert子命令将数据转换为.yaml格式或xray.evt文件使用的格式,运行以下命令:

$ llvm-xray convert -output-format=trace_event\
  -output=xray.evt -symbolize –sort\
  -instr_map=./xraydemo xray-log.xraydemo.xVsWiE

如果不指定-symbolize选项,则结果图中不会显示函数名称。

完成后,打开 Chrome 浏览器,输入chrome:///tracing。然后,单击xray.evt文件。您将看到以下数据的可视化:

图 11.2 - 由 llvm-xray 生成的 Chrome 跟踪查看器可视化

图 11.2 - 由 llvm-xray 生成的 Chrome 跟踪查看器可视化

在此视图中,堆栈帧按函数调用发生的时间进行排序。要进一步解释可视化,请阅读www.chromium.org/developers/how-tos/trace-event-profiling-tool上的教程。

提示

llvm-xray工具具有更多功能。您可以在 LLVM 网站上阅读有关它的信息llvm.org/docs/XRay.htmlllvm.org/docs/XRayExample.html

在本节中,我们学习了如何使用 XRay 对应用程序进行工具化,如何收集运行时信息以及如何可视化这些数据。我们可以使用这些知识来找出应用程序中的性能瓶颈。

识别应用程序中的错误的另一种方法是分析源代码,这是由静态分析器完成的。

使用 Clang 静态分析器检查源代码

Clang 静态分析器是一种在 C、C++和Objective C源代码上执行额外检查的工具。静态分析器执行的检查比编译器执行的检查更彻底。它们在时间和所需资源方面也更昂贵。静态分析器具有一组检查器,用于检查特定的错误。

该工具对源代码进行符号解释,从中查看应用程序的所有代码路径,并从中推导出应用程序中使用的值的约束。符号解释是编译器中常用的技术,例如用于识别常量值。在静态分析器的上下文中,检查器适用于推导出的值。

例如,如果除法的除数为 0,则静态分析器会发出警告。我们可以通过div.c文件中的以下示例进行检查:

int divbyzero(int a, int b) { return a / b; }
int bug() { return divbyzero(5, 0); }

在示例中,静态分析器将警告除以0。但是,在编译时,使用clang -Wall -c div.c命令编译的文件不会显示警告。

有两种方法可以从命令行调用静态分析器。较旧的工具是scan-build工具是更简单的解决方案。您只需将compile命令传递给工具,其他所有操作都会自动完成:

$ scan-build clang -c div.c
scan-build: Using '/usr/local/llvm12/bin/clang-12' for static analysis
div.c:2:12: warning: Division by zero [core.DivideZero]
  return a / b;
         ~~^~~
1 warning generated.
scan-build: Analysis run complete.
scan-build: 1 bug found.
scan-build: Run 'scan-view /tmp/scan-build-2021-03-01-023401-8721-1' to examine bug reports.

屏幕上的输出已经告诉您发现了问题,即触发了名为core.DivideZero的检查器。但这还不是全部。您将在/tmp目录的提到的子目录中找到完整的 HTML 报告。您可以使用scan-view命令查看报告,或者在浏览器中打开子目录中找到的index.html文件。

报告的第一页显示了找到的错误的摘要:

图 11.3 - 摘要页面

图 11.3 - 摘要页面

对于每个找到的错误,摘要页面显示了错误的类型、源代码中的位置以及分析器发现错误后的路径长度。提供了指向错误详细报告的链接。

以下屏幕截图显示了错误的详细报告:

图 11.4 - 详细报告

图 11.4 - 详细报告

通过详细报告,您可以通过跟随编号的气泡来验证错误。在我们的简单示例中,它显示了通过将0作为参数值传递导致除以零错误的三个步骤。

确实需要通过人来验证。如果派生的约束对于某个检查器不够精确,则可能会出现误报,即对于完全正常的代码报告错误。根据报告,您可以识别出误报。

您不仅限于使用工具提供的检查器。您也可以添加新的检查器。下一节将介绍如何执行此操作。

向 Clang 静态分析器添加新的检查器

要向 Clang 静态分析器添加新的检查器,您需要创建Checker类的新子类。静态分析器尝试通过代码的所有可能路径。分析引擎在某些点生成事件,例如,在函数调用之前或之后。如果需要处理这些事件,您的类必须为这些事件提供回调。Checker类和事件的注册在clang/include/clang/StaticAnalyzer/Core/Checker.h头文件中提供。

通常,检查器需要跟踪一些符号。但是检查器无法管理状态,因为它不知道分析引擎当前尝试的代码路径。因此,跟踪的状态必须在引擎中注册,并且只能使用ProgramStateRef实例进行更改。

许多库提供必须成对使用的函数。例如,C 标准库提供了malloc()free()函数。malloc()函数分配的内存必须由free()函数精确释放一次。不调用free()函数或多次调用它都是编程错误。这种编码模式还有许多其他实例,静态分析器为其中一些提供了检查器。

iconv库提供了iconv_open()iconv_close()函数,这两个函数必须成对使用。您可以实现一个检查器来检查这一点。

为了检测错误,检查器需要跟踪从iconv_open()函数返回的描述符。分析引擎为iconv_open()函数的返回值返回一个SymbolRef实例。我们将此符号与状态关联起来,以反映是否调用了iconv_close()。对于状态,我们创建了IconvState类,它封装了一个bool值。

新的IconvChecker类需要处理四个事件:

  • PostCall,在函数调用之后发生。在调用iconv_open()函数之后,我们检索返回值的符号,并记住它处于打开状态。

  • PreCall,在函数调用之前发生。在调用iconv_close()函数之前,我们检查描述符的符号是否处于打开状态。如果不是,则说明已经为描述符调用了iconv_close()函数,我们已经检测到对该函数的双重调用。

  • DeadSymbols,当未使用的符号被清理时发生。我们检查未使用的符号是否仍处于打开状态。如果是,则我们已经检测到对iconv_close()的缺失调用,这是资源泄漏。

  • PointerEscape,当符号不再能被分析器跟踪时调用。在这种情况下,我们从状态中移除符号,因为我们无法再推断描述符是否已关闭。

新的检查器是在 Clang 项目内实现的。让我们从将新的检查器添加到所有检查器的集合开始,这是clang/include/clang/StaticAnalyzer/Checkers/Checkers.td文件。每个检查器都与软件包相关联。我们的新检查器正在开发中,因此它属于alpha软件包。iconv API 是一个 POSIX 标准化的 API,因此它也属于unix软件包。在Checkers.td文件中找到UnixAlpha部分,并添加以下代码以注册新的IconvChecker

def IconvChecker : Checker<"Iconv">,
  HelpText<"Check handling of iconv functions">,
  Documentation<NotDocumented>;

这将新的检查器添加到已知检查器集合中,为命令行选项设置帮助文本,并声明此检查器没有文档。

接下来,我们在clang/lib/StaticAnalyzer/Checkers/IconvChecker.cpp文件中实现检查器:

  1. 对于实现,我们需要包括几个头文件。BuiltinCheckerRegistration.h文件用于注册检查器。Checker.h文件提供了Checker类的声明和事件的回调。CallEvent.h文件声明了用于调用事件的类,CheckerContext.h文件用于声明CheckerContext类,它是提供对分析器状态访问的中心类:
#include "clang/StaticAnalyzer/Checkers/
BuiltinCheckerRegistration.h"
#include "clang/StaticAnalyzer/Core/Checker.h"
#include "clang/StaticAnalyzer/Core/
PathSensitive/CallEvent.h"
#include "clang/StaticAnalyzer/Core/PathSensitive/
CheckerContext.h"
  1. 为了避免输入命名空间名称,我们使用clangento命名空间:
using namespace clang;
using namespace ento;
  1. 我们将状态与表示 iconv 描述符的每个符号关联起来。状态可以是打开或关闭的,我们使用一个bool类型的变量,对于打开状态为true。状态值封装在IconvState结构中。该结构与FoldingSet数据结构一起使用,后者是一个过滤重复条目的哈希集。为了使该数据结构实现可用,这里添加了Profile()方法,该方法设置了该结构的唯一位。我们将该结构放入匿名命名空间中,以避免全局命名空间的污染。
namespace {
struct IconvState {
  const bool IsOpen;
public:
  IconvState(bool IsOpen) : IsOpen(IsOpen) {}
  bool isOpen() const { return IsOpen; }
  bool operator==(const IconvState &O) const {
    return IsOpen == O.IsOpen;
  }
  void Profile(llvm::FoldingSetNodeID &ID) const {
    ID.AddInteger(IsOpen);
  }
};
}
  1. IconvState结构表示 iconv 描述符的状态,由SymbolRef类的符号表示。这最好通过一个映射来完成,该映射将符号作为键,状态作为值。正如前面所解释的,检查器不能保存状态。相反,状态必须在全局程序状态中注册,这是通过REGISTER_MAP_WITH_PROGRAMSTATE宏完成的。此宏引入了IconvStateMap名称,我们稍后将使用它来访问映射:
REGISTER_MAP_WITH_PROGRAMSTATE(IconvStateMap, SymbolRef,
                               IconvState)
  1. 我们还在匿名命名空间中实现了IconvChecker类。请求的PostCallPreCallDeadSymbolsPointerEscape事件是Checker基类的模板参数:
namespace {
class IconvChecker
    : public Checker<check::PostCall, check::PreCall,
                     check::DeadSymbols,
                     check::PointerEscape> {
  1. IconvChecker类只有CallDescription类型的字段,用于识别程序中的iconv_open()iconv()iconv_close()函数调用:
  CallDescription IconvOpenFn, IconvFn, IconvCloseFn;
  1. report()方法生成错误报告。该方法的重要参数是符号数组、错误类型和错误描述。在方法内部,为每个符号创建一个错误报告,并将该符号标记为错误的有趣对象。如果提供了源范围作为参数,则也将其添加到报告中。最后,报告被发出:
  void
  report(ArrayRef<SymbolRef> Syms, const BugType &Bug,
         StringRef Desc, CheckerContext &C,
         ExplodedNode *ErrNode,
         Optional<SourceRange> Range = None) const {
    for (SymbolRef Sym : Syms) {
      auto R = std::make_unique
              <PathSensitiveBugReport>(
          Bug, Desc, ErrNode);
      R->markInteresting(Sym);
      if (Range)
        R->addRange(*Range);
      C.emitReport(std::move(R));
    }
  }
  1. IconvChecker类的构造函数只使用函数的名称初始化CallDescription字段:
public:
  IconvChecker()
      : IconvOpenFn("iconv_open"), IconvFn("iconv"),
        IconvCloseFn("iconv_close", 1) {}
  1. 当分析器执行函数调用后,会调用checkPostCall()方法。如果执行的函数不是全局 C 函数,也不是名为iconv_open,那么就没有什么要做的:
  void checkPostCall(const CallEvent &Call,
                     CheckerContext &C) const {
    if (!Call.isGlobalCFunction() ||
        !Call.isCalled(IconvOpenFn))
      return;
  1. 否则,我们尝试将函数的返回值作为符号获取。为了将具有打开状态的符号存储在全局程序状态中,我们需要从CheckerContext实例中获取ProgramStateRef实例。状态是不可变的,所以将符号添加到状态中会导致新的状态。通过调用addTransition()方法,分析器引擎被告知新的状态:
    if (SymbolRef Handle =
            Call.getReturnValue().getAsSymbol()) {
      ProgramStateRef State = C.getState();
      State = State->set<IconvStateMap>(
          Handle, IconvState(true));
      C.addTransition(State);
    }
  }
  1. 同样,当分析器执行函数之前,会调用checkPreCall()方法。我们只对名为iconv_close的全局 C 函数感兴趣:
  void checkPreCall(const CallEvent &Call,
                    CheckerContext &C) const {
    if (!Call.isGlobalCFunction() ||
        !Call.isCalled(IconvCloseFn))
      return;
  1. 如果函数的第一个参数的符号,也就是 iconv 描述符,是已知的,那么我们从程序状态中检索符号的状态:
    if (SymbolRef Handle =
            Call.getArgSVal(0).getAsSymbol()) {
      ProgramStateRef State = C.getState();
      if (const IconvState *St =
              State->get<IconvStateMap>(Handle)) {
  1. 如果状态表示关闭状态,那么我们已经检测到了双重关闭错误,并为此生成了一个错误报告。调用generateErrorNode()可能会返回nullptr值,如果已经为这条路径生成了错误报告,所以我们必须检查这种情况:
        if (!St->isOpen()) {
          if (ExplodedNode *N = C.generateErrorNode()) {
            BugType DoubleCloseBugType(
                this, "Double iconv_close",
                "iconv API Error");
            report({Handle}, DoubleCloseBugType,
                   "Closing a previous closed iconv "
                   "descriptor",
                   C, N, Call.getSourceRange());
          }
          return;
        }
      }
  1. 否则,我们将符号的状态设置为关闭:
      State = State->set<IconvStateMap>(
          Handle, IconvState(false));
      C.addTransition(State);
    }
  }
  1. 调用checkDeadSymbols()方法来清理未使用的符号。我们遍历我们跟踪的所有符号,并询问SymbolReaper实例当前的符号是否已经失效:
  void checkDeadSymbols(SymbolReaper &SymReaper,
                        CheckerContext &C) const {
    ProgramStateRef State = C.getState();
    SmallVector<SymbolRef, 8> LeakedSyms;
    for (auto SymbolState :
         State->get<IconvStateMap>()) {
      SymbolRef Sym = SymbolState.first;
      IconvState &St = SymbolState.second;
      if (SymReaper.isDead(Sym)) {
  1. 如果符号已经失效,那么我们需要检查状态。如果状态仍然是打开的,那么这是一个潜在的资源泄漏。有一个例外:iconv_open()在错误的情况下返回-1。如果分析器在处理此错误的代码路径中,那么假设存在资源泄漏是错误的,因为函数调用失败了。我们尝试从ConstraintManager实例中获取符号的值,如果这个值是-1,我们就不认为这个符号是资源泄漏。我们将一个泄漏的符号添加到SmallVector实例中,以便稍后生成错误报告。最后,我们从程序状态中删除死亡的符号:
        if (St.isOpen()) {
          bool IsLeaked = true;
          if (const llvm::APSInt *Val =
                  State->getConstraintManager()
                      .getSymVal(State, Sym))
            IsLeaked = Val->getExtValue() != -1;
          if (IsLeaked)
            LeakedSyms.push_back(Sym);
        }
        State = State->remove<IconvStateMap>(Sym);
      }
    }
  1. 循环结束后,我们调用generateNonFatalErrorNode()方法。这个方法转换到新的程序状态,并且如果这条路径上还没有错误节点,就返回一个错误节点。LeakedSyms容器保存了泄漏符号的(可能为空的)列表,我们调用report()方法生成错误报告:
    if (ExplodedNode *N =
            C.generateNonFatalErrorNode(State)) {
      BugType LeakBugType(this, "Resource Leak",
                          "iconv API Error", true);
      report(LeakedSyms, LeakBugType,
             "Opened iconv descriptor not closed", C,
             N);
    }
  }
  1. 当分析器检测到参数无法被跟踪的函数调用时,会调用checkPointerEscape()函数。在这种情况下,我们必须假设我们不知道 iconv 描述符是否在函数内部关闭。唯一的例外是对iconv()函数的调用,它执行转换并且已知不会调用iconv_close()函数。这完成了IconvChecker类的实现:
  ProgramStateRef
  checkPointerEscape(ProgramStateRef State,
                     const InvalidatedSymbols &Escaped,
                     const CallEvent *Call,
                     PointerEscapeKind Kind) const {
    if (Kind == PSK_DirectEscapeOnCall &&
        Call->isCalled(IconvFn))
      return State;
    for (SymbolRef Sym : Escaped)
      State = State->remove<IconvStateMap>(Sym);
    return State;
  }
};
}
  1. 最后,新的检查器需要在CheckerManager实例中注册。shouldRegisterIconvChecker()方法返回true,表示IconvChecker应该默认注册,registerIconvChecker()方法执行注册。这两个方法都是通过从Checkers.td文件生成的代码调用的。
void ento::registerIconvChecker(CheckerManager &Mgr) {
  Mgr.registerChecker<IconvChecker>();
}
bool ento::shouldRegisterIconvChecker(
    const CheckerManager &Mgr) {
  return true;
}

这完成了新检查器的实现。您只需要将文件名添加到clang/lib/StaticAnalyzer/Checkers/CmakeLists.txt文件中的源文件名列表中:

add_clang_library(clangStaticAnalyzerCheckers
…
  IconvChecker.cpp
…)

要编译新的检查器,您需要切换到构建目录并运行ninja命令:

$ ninja 

您可以使用以下保存在conv.c文件中的源代码来测试新的检查器,其中包含两个对iconv_close()函数的调用:

#include <iconv.h>
void doconv() {
  iconv_t id = iconv_open("Latin1", "UTF-16");
  iconv_close(id);
  iconv_close(id);
}

你学会了如何用自己的检查器扩展 Clang 静态分析器。你可以利用这些知识来创建新的通用检查器并贡献给社区,或者你可以创建专门为你的需求构建的检查器,提高产品的质量。

静态分析器是建立在 Clang 基础设施之上的,下一节将介绍如何构建自己的插件来扩展 Clang。

创建你自己的基于 Clang 的工具

静态分析器是 Clang 基础设施的一个令人印象深刻的例子。你也可以扩展 Clang 的功能,以便向 Clang 添加你自己的功能。这种技术与向 LLVM 添加一个 pass 插件非常相似。

让我们用一个简单的插件来探索功能。LLVM 编码标准要求函数名以小写字母开头。然而,编码标准随着时间的推移而发展,有许多情况下函数以大写字母开头。一个警告违反命名规则的插件可以帮助解决这个问题,所以让我们试一试。

因为你想在PluginASTAction类上运行一个用户定义的动作。如果你使用 Clang 库编写自己的工具,那么你为你的动作定义ASTFrontendAction类的子类。PluginASTAction类是ASTFrontendAction类的子类,还具有解析命令行选项的额外能力。

你还需要另一个ASTConsumer类的子类。AST 消费者是一个类,你可以在 AST 上运行一个动作,而不管 AST 的来源是什么。我们的第一个插件不需要更多的东西。你可以在NamingPlugin.cpp文件中创建实现,如下所示:

  1. 首先包括所需的头文件。除了提到的ASTConsumer类,你还需要一个编译器实例和插件注册表的实例:
#include "clang/AST/ASTConsumer.h"
#include "clang/Frontend/CompilerInstance.h"
#include "clang/Frontend/FrontendPluginRegistry.h"
  1. 使用clang命名空间,并将你的实现放入匿名命名空间中,以避免名称冲突:
using namespace clang;
namespace {
  1. 接下来,定义你的ASTConsumer类的子类。稍后,如果检测到命名规则的违反,你将希望发出警告。为此,你需要一个对DiagnosticsEngine实例的引用。

  2. 你需要在类中存储一个CompilerInstance实例,然后你可以要求一个DiagnosticsEngine实例:

class NamingASTConsumer : public ASTConsumer {
  CompilerInstance &CI;
public:
  NamingASTConsumer(CompilerInstance &CI) : CI(CI) {}
  1. ASTConsumer实例有几个入口方法。HandleTopLevelDecl()方法符合我们的目的。该方法对顶层的每个声明都会被调用。这包括函数以外的内容,例如变量。因此,你将使用 LLVM RTTI dyn_cast<>()函数来确定声明是否是函数声明。HandleTopLevelDecl()方法有一个声明组作为参数,它可以包含多个声明。这需要对声明进行循环。以下代码展示了HandleTopLevelDecl()方法:
  bool HandleTopLevelDecl(DeclGroupRef DG) override {
    for (DeclGroupRef::iterator I = DG.begin(),
                                E = DG.end();
         I != E; ++I) {
      const Decl *D = *I;
      if (const FunctionDecl *FD =
              dyn_cast<FunctionDecl>(D)) {
  1. 在找到函数声明后,你需要检索函数的名称。你还需要确保名称不为空:
        std::string Name =
            FD->getNameInfo().getName().getAsString();
        assert(Name.length() > 0 &&
               "Unexpected empty identifier");

如果函数名不以小写字母开头,那么你将发现一个违反命名规则的情况:

        char &First = Name.at(0);
        if (!(First >= 'a' && First <= 'z')) {
  1. 要发出警告,你需要一个DiagnosticsEngine实例。另外,你需要一个消息 ID。在 Clang 内部,消息 ID 被定义为一个枚举。因为你的插件不是 Clang 的一部分,你需要创建一个自定义 ID,然后用它来发出警告:
          DiagnosticsEngine &Diag = 
              CI.getDiagnostics();
          unsigned ID = Diag.getCustomDiagID(
              DiagnosticsEngine::Warning,
              "Function name should start with "
              "lowercase letter");
          Diag.Report(FD->getLocation(), ID);
  1. 除了关闭所有的大括号,你需要从这个函数中返回true来表示处理可以继续进行:
        }
      }
    }
    return true;
  }
};
  1. 接下来,你需要创建PluginASTAction子类,该子类实现了 Clang 调用的接口:
class PluginNamingAction : public PluginASTAction {
public:

你必须实现的第一个方法是CreateASTConsumer()方法,它返回你的NamingASTConsumer类的一个实例。这个方法是由 Clang 调用的,传递的CompilerInstance实例让你可以访问编译器的所有重要类。以下代码演示了这一点:

  std::unique_ptr<ASTConsumer>
  CreateASTConsumer(CompilerInstance &CI,
                    StringRef file) override {
    return std::make_unique<NamingASTConsumer>(CI);
  }
  1. 插件还可以访问命令行选项。您的插件没有命令行参数,只需返回true表示成功:
  bool ParseArgs(const CompilerInstance &CI,
                 const std::vector<std::string> &args)                                                override {
    return true;
  }
  1. 插件的操作类型描述了何时调用操作。默认值是Cmdline,这意味着必须在命令行上命名插件才能调用。您需要覆盖该方法并将值更改为AddAfterMainAction,这将自动运行操作:
  PluginASTAction::ActionType getActionType() override {
    return AddAfterMainAction;
  }
  1. 您的PluginNamingAction类的实现已经完成;只缺少类和匿名命名空间的闭合大括号。将它们添加到代码中如下:
};
}
  1. 最后,您需要注册插件。第一个参数是插件的名称,第二个参数是帮助文本:
static FrontendPluginRegistry::Add<PluginNamingAction>
    X("naming-plugin", "naming plugin");

这完成了插件的实现。要编译插件,在CMakeLists.txt文件中创建一个构建描述。插件位于 Clang 源树之外,因此您需要设置一个完整的项目。您可以按照以下步骤进行:

  1. 从定义所需的CMake版本和项目名称开始:
cmake_minimum_required(VERSION 3.13.4)
project(naminglugin)
  1. 接下来,包括 LLVM 文件。如果 CMake 无法自动找到文件,则必须将LLVM_DIR变量设置为指向包含 CMake 文件的 LLVM 目录:
find_package(LLVM REQUIRED CONFIG)
  1. 将包含一些必需模块的 LLVM 目录附加到搜索路径中:
list(APPEND CMAKE_MODULE_PATH ${LLVM_DIR})
include(ChooseMSVCCRT)
include(AddLLVM)
include(HandleLLVMOptions)
  1. 然后,加载 Clang 的 CMake 定义。如果 CMake 无法自动找到文件,则必须将Clang_DIR变量设置为指向包含 CMake 文件的 Clang 目录:
find_package(Clang REQUIRED)
  1. 接下来,定义头文件和库文件的位置,以及要使用的定义:
include_directories("${LLVM_INCLUDE_DIR}"
                    "${CLANG_INCLUDE_DIRS}")
add_definitions("${LLVM_DEFINITIONS}")
link_directories("${LLVM_LIBRARY_DIR}")
  1. 前面的定义设置了构建环境。插入以下命令,定义插件的名称、插件的源文件和它是一个 Clang 插件:
add_llvm_library(NamingPlugin MODULE NamingPlugin.cpp
                 PLUGIN_TOOL clang)

Windows上,插件支持与Unix平台不同,必须链接所需的 LLVM 和 Clang 库。以下代码确保了这一点:

if(LLVM_ENABLE_PLUGINS AND (WIN32 OR CYGWIN))
  set(LLVM_LINK_COMPONENTS Support)
  clang_target_link_libraries(NamingPlugin PRIVATE
    clangAST clangBasic clangFrontend clangLex)
endif()
  1. 将这两个文件保存在NamingPlugin目录中。在与NamingPlugin目录相同级别创建一个build-naming-plugin目录,并使用以下命令构建插件:
$ mkdir build-naming-plugin
$ cd build-naming-plugin
$ cmake –G Ninja ../NamingPlugin
$ ninja

这些步骤在build目录中创建了NamingPlugin.so共享库。

要测试插件,请将以下源代码保存为naming.c文件。Func1函数名称违反了命名规则,但main名称没有违反:

int Func1() { return 0; }
int main() { return Func1(); }

要调用插件,您需要指定-fplugin=选项:

$ clang -fplugin=./NamingPlugin.so  naming.c
naming.c:1:5: warning: Function name should start with lowercase letter
int Func1() { return 0; }
    ^
1 warning generated.

这种调用方式要求您覆盖PluginASTAction类的getActionType()方法,并返回与Cmdline默认值不同的值。

如果您没有这样做,例如,因为您希望更多地控制插件操作的调用,那么可以从编译器命令行运行插件:

$ clang -cc1 -load ./NamingPlugin.so -plugin naming-plugin\
  naming.c

恭喜,您已经构建了您的第一个 Clang 插件!

这种方法的缺点是它有一定的限制。ASTConsumer类有不同的入口方法,但它们都是粗粒度的。这可以通过使用RecursiveASTVisitor类来解决。这个类遍历所有 AST 节点,您可以重写您感兴趣的VisitXXX()方法。您可以按照以下步骤重写插件以使用访问者:

  1. 您需要为RecursiveASTVisitor类的定义添加额外的include。将其插入如下:
#include "clang/AST/RecursiveASTVisitor.h"
  1. 然后,在匿名命名空间中将访问者定义为第一个类。您只需存储对 AST 上下文的引用,这将使您能够访问所有重要的 AST 操作方法,包括发出警告所需的DiagnosticsEngine实例:
class NamingVisitor
    : public RecursiveASTVisitor<NamingVisitor> {
private:
  ASTContext &ASTCtx;
public:
  explicit NamingVisitor(CompilerInstance &CI)
      : ASTCtx(CI.getASTContext()) {}
  1. 在遍历期间,每当发现函数声明时,都会调用VisitFunctionDecl()方法。将内部循环的主体复制到HandleTopLevelDecl()函数中:
  virtual bool VisitFunctionDecl(FunctionDecl *FD) {
    std::string Name =
        FD->getNameInfo().getName().getAsString();
    assert(Name.length() > 0 &&
           "Unexpected empty identifier");
    char &First = Name.at(0);
    if (!(First >= 'a' && First <= 'z')) {
      DiagnosticsEngine &Diag = 
          ASTCtx.getDiagnostics();
      unsigned ID = Diag.getCustomDiagID(
          DiagnosticsEngine::Warning,
          "Function name should start with "
          "lowercase letter");
      Diag.Report(FD->getLocation(), ID);
    }
    return true;
  }
};
  1. 这完成了访问者模式的实现。在你的NamingASTConsumer类中,你现在只需要存储一个访问者实例:
  std::unique_ptr<NamingVisitor> Visitor;
public:
  NamingASTConsumer(CompilerInstance &CI)
      : Visitor(std::make_unique<NamingVisitor>(CI)) {}
  1. 你将删除HandleTopLevelDecl()方法,因为功能现在在访问者类中,所以你需要重写HandleTranslationUnit()方法。这个类对每个翻译单元调用一次,你将从这里开始 AST 遍历:
  void
  HandleTranslationUnit(ASTContext &ASTCtx) override {
    Visitor->TraverseDecl(
        ASTCtx.getTranslationUnitDecl());
  }

这个新的实现具有完全相同的功能。优点是更容易扩展。例如,如果你想检查变量声明,那么你实现VisitVarDecl()方法。或者如果你想处理语句,那么你实现VisitStmt()方法。基本上,你对 C、C++和 Objective C 语言的每个实体都有一个访问者方法。

访问 AST 允许你构建执行复杂任务的插件。强制命名约定,如本节所述,是 Clang 的一个有用补充。你可以实现的另一个有用的插件是计算软件度量,比如圈复杂度。你还可以添加或替换 AST 节点,允许你例如添加运行时仪表。添加插件允许你按照你的需要扩展 Clang。

总结

在本章中,你学会了如何应用各种消毒剂。你使用address消毒剂检测指针错误,使用memory消毒剂检测未初始化的内存访问,并使用thread消毒剂检测数据竞争。应用程序错误通常是由格式不正确的输入触发的,你实现了模糊测试来使用随机数据测试你的应用程序。

你使用 XRay 为你的应用程序添加了仪表,以识别性能瓶颈,并且你也学习了各种可视化数据的方法。在本章中,你还使用了 Clang 静态分析器通过对源代码的解释来查找可能的错误,并学习了如何构建自己的 Clang 插件。

这些技能将帮助你提高构建应用程序的质量。在应用程序用户抱怨之前找到运行时错误肯定是件好事。应用本章中所学的知识,你不仅可以找到各种常见错误,还可以扩展 Clang 的新功能。

在下一章中,你将学习如何向 LLVM 添加新的后端。

第十二章:创建您自己的后端

LLVM 具有非常灵活的架构。您也可以向其添加新的目标后端。后端的核心是目标描述,其中大部分代码都是由此生成的。但是,目前还无法生成完整的后端,并且实现调用约定需要手动编写代码。在本章中,我们将学习如何为历史 CPU 添加支持。

在本章中,我们将涵盖以下内容:

  • 为新后端做准备,让您了解 M88k CPU 架构,并指导您在何处找到所需的信息。

  • 将新架构添加到 Triple 类将教会您如何使 LLVM 意识到新的 CPU 架构。

  • 在 LLVM 中扩展 ELF 文件格式定义,您将为处理 ELD 对象文件的库和工具添加对 M88k 特定重定位的支持。

  • 在创建目标描述中,您将使用 TableGen 语言开发目标描述的所有部分。

  • 在实现 DAG 指令选择类中,您将创建所需的指令选择的传递和支持类。

  • 生成汇编指令教会您如何实现汇编打印机,负责生成文本汇编程序。

  • 在发出机器代码中,您将了解必须提供哪些额外的类来使机器代码MC)层能够向目标文件写入代码。

  • 在添加反汇编支持中,您将学习如何实现反汇编器的支持。

  • 在将所有内容组合在一起中,您将把新后端的源代码集成到构建系统中。

通过本章的学习,您将了解如何开发一个新的完整后端。您将了解后端由不同部分组成,从而更深入地了解 LLVM 架构。

技术要求

该章节的代码文件可在github.com/PacktPublishing/Learn-LLVM-12/tree/master/Chapter12找到

您可以在bit.ly/3nllhED找到代码演示视频

为新后端做准备

无论是出于商业需要支持新的 CPU,还是仅仅是为了为一些旧的架构添加支持而进行的爱好项目,向 LLVM 添加新的后端都是一项重大任务。以下各节概述了您需要开发新后端的内容。我们将为 20 世纪 80 年代的 RISC 架构 Motorola M88k 添加一个后端。

参考资料

您可以在维基百科上阅读更多关于该架构的信息:en.wikipedia.org/wiki/Motorola_88000。有关该架构的重要信息仍然可以在互联网上找到。您可以在www.bitsavers.org/components/motorola/88000/找到包含指令集和时序信息的 CPU 手册,以及包含 ELF 格式和调用约定定义的 System V ABI M88k 处理器补充的信息。

OpenBSD,可在www.openbsd.org/找到,仍然支持 LUNA-88k 系统。在 OpenBSD 系统上,很容易为 M88k 创建 GCC 交叉编译器。并且有一个名为 GXemul 的仿真器,可运行 M88k 架构的某些 OpenBSD 版本,该仿真器可在 http://gavare.se/gxemul/找到。

总的来说,M88k 架构已经停产很久了,但我们找到了足够的信息和工具,使其成为向 LLVM 添加后端的有趣目标。我们将从一个非常基本的任务开始,并扩展Triple类。

将新架构添加到 Triple 类

Triple类的一个实例代表 LLVM 正在为其生成代码的目标平台。为了支持新的架构,第一步是扩展Triple类。在llvm/include/llvm/ADT/Triple.h文件中,您需要向ArchType枚举添加一个成员和一个新的谓词:

class Triple {
public:
  enum ArchType {
  // Many more members
    m88k,           // M88000 (big endian): m88k
  };
  /// Tests whether the target is M88k.
  bool isM88k() const {
    return getArch() == Triple::m88k;
  }
// Many more methods
};

llvm/lib/Support/Triple.cpp文件中,有许多使用ArchType枚举的方法。您需要扩展所有这些方法;例如,在getArchTypeName()方法中,您需要添加一个新的 case 语句:

  switch (Kind) {
// Many more cases
  case m88k:           return "m88k";
  }

在大多数情况下,如果您忘记处理一个函数中的新的m88k枚举成员,编译器会警告您。接下来,我们将扩展可执行和可链接格式ELF)的定义。

扩展 LLVM 中的 ELF 文件格式定义

ELF 文件格式是 LLVM 支持读取和写入的二进制对象文件格式之一。ELF 本身为许多 CPU 架构定义了规范,也有 M88k 架构的定义。我们需要做的就是添加重定位的定义和一些标志。重定位在《第四章》,《对象文件》,《System V ABI M88k Processor》补充书中给出:

  1. 我们需要在llvm/include/llvm/BinaryFormat/ELFRelocs/M88k.def文件中输入以下内容:
#ifndef ELF_RELOC
#error "ELF_RELOC must be defined"
#endif
ELF_RELOC(R_88K_NONE, 0)
ELF_RELOC(R_88K_COPY, 1)
// Many more…
  1. 我们还需要向llvm/include/llvm/BinaryFormat/ELF.h文件添加一些标志,并包括重定位的定义:
// M88k Specific e_flags
enum : unsigned {
  EF_88K_NABI = 0x80000000,   // Not ABI compliant
  EF_88K_M88110 = 0x00000004  // File uses 88110-
                              // specific 
                              // features
};
// M88k relocations.
enum {
#include "ELFRelocs/M88k.def"
};

代码可以添加到文件的任何位置,但最好保持排序顺序,并在 MIPS 架构的代码之前插入。

  1. 我们还需要扩展一些其他方法。在llvm/include/llvm/Object/ELFObjectFile.h文件中有一些在枚举成员和字符串之间进行转换的方法。例如,我们必须向getFileFormatName()方法添加一个新的 case 语句:
  switch (EF.getHeader()->e_ident[ELF::EI_CLASS]) {
// Many more cases
    case ELF::EM_88K:
      return "elf32-m88k";
  }
  1. 同样地,我们扩展getArch()方法。

  2. 最后,在llvm/lib/Object/ELF.cpp文件中使用重定位定义,在getELFRelocationTypeName()方法中:

  switch (Machine) {
// Many more cases
  case ELF::EM_88K:
    switch (Type) {
#include "llvm/BinaryFormat/ELFRelocs/M88k.def"
    default:
      break;
    }
    break;
  }
  1. 要完成支持,您还可以在llvm/lib/ObjectYAML/ELFYAML.cpp文件中添加重定位,在映射ELFYAML::ELF_REL枚举的方法中。

  2. 在这一点上,我们已经完成了对 ELF 文件格式中 m88k 架构的支持。您可以使用llvm-readobj工具检查一个 ELF 目标文件,例如,在 OpenBSD 上由交叉编译器创建的。同样地,您可以使用yaml2obj工具为 m88k 架构创建一个 ELF 目标文件。

为对象文件格式添加支持是否是强制性的?

将对架构的支持集成到 ELF 文件格式实现中只需要几行代码。如果您为其创建 LLVM 后端的架构使用 ELF 格式,那么您应该采用这种方式。另一方面,为全新的二进制文件格式添加支持本身就是一项复杂的任务。在这种情况下,一个可能的方法是只输出汇编文件,并使用外部汇编器创建目标文件。

有了这些添加,ELF 文件格式的实现现在支持 M88k 架构。在下一节中,我们将为 M88k 架构创建目标描述,描述架构的指令、寄存器、调用约定和其他细节。

创建目标描述

目标描述是后端实现的核心。在理想的情况下,我们可以从目标描述生成整个后端。这个目标尚未实现,因此我们需要稍后扩展生成的代码。让我们从顶层文件开始剖析目标描述。

实现目标描述的顶层文件

我们将我们新后端的文件放入llvm/lib/Target/M88k目录。目标描述在M88k.td文件中:

  1. 在这个文件中,我们首先需要包括 LLVM 预定义的基本目标描述类,然后是我们将在下一节中创建的文件:
include "llvm/Target/Target.td"
include "M88kRegisterInfo.td"
include "M88kCallingConv.td"
include "M88kSchedule.td"
include "M88kInstrFormats.td"
include "M88kInstrInfo.td"
  1. 接下来,我们还定义了支持的处理器。除其他事项外,这还转换为-mcpu=选项的参数:
def : ProcessorModel<"mc88110", M88kSchedModel, []>;
  1. 所有这些定义都完成后,我们现在可以将我们的目标组合起来。我们定义这些子类,以防需要修改一些默认值。M88kInstrInfo类包含有关指令的所有信息:
def M88kInstrInfo : InstrInfo;
  1. 我们为.s汇编文件定义了一个解析器,并且我们还声明寄存器名称始终以%为前缀:
def M88kAsmParser : AsmParser;
def M88kAsmParserVariant : AsmParserVariant {
  let RegisterPrefix = "%";
}
  1. 接下来,我们为汇编写入器定义一个类,负责编写.s汇编文件:
def M88kAsmWriter : AsmWriter;
  1. 最后,所有这些记录都被放在一起来定义目标:
def M88k : Target {
  let InstructionSet = M88kInstrInfo;
  let AssemblyParsers  = [M88kAsmParser];
  let AssemblyParserVariants = [M88kAsmParserVariant];
  let AssemblyWriters = [M88kAsmWriter];
  let AllowRegisterRenaming = 1;
}

现在顶层文件已经实现,我们创建包含的文件,从下一节开始定义寄存器定义。

添加寄存器定义

CPU 架构通常定义一组寄存器。这些寄存器的特性可以有很大的变化。一些架构允许访问子寄存器。例如,x86 架构具有特殊的寄存器名称,用于仅访问寄存器值的一部分。其他架构则不实现这一点。除了通用寄存器、浮点寄存器和矢量寄存器外,架构还可以定义特殊寄存器,例如用于状态代码或浮点运算配置。您需要为 LLVM 定义所有这些信息。

M88k 架构定义了通用寄存器、浮点寄存器和控制寄存器。为了使示例简单,我们只定义通用寄存器。我们首先定义寄存器的超类。寄存器的编码仅使用5位,因此我们限制了保存编码的字段。我们还定义,所有生成的 C++代码应该驻留在M88k命名空间中:

class M88kReg<bits<5> Enc, string n> : Register<n> {
  let HWEncoding{15-5} = 0;
  let HWEncoding{4-0} = Enc;
  let Namespace = "M88k";
}

M88kReg类用于所有寄存器类型。我们为通用寄存器定义了一个特殊的类:

class GRi<bits<5> Enc, string n> : M88kReg<Enc, n>;

现在我们可以定义所有 32 个通用寄存器:

foreach I = 0-31 in {
  def R#I : GRi<I, "r"#I>;
}

单个寄存器需要分组在寄存器类中。寄存器的序列顺序还定义了寄存器分配器中的分配顺序。在这里,我们只是添加所有寄存器:

def GPR : RegisterClass<"M88k", [i32], 32,
                            (add (sequence "R%u", 0, 31))>;

最后,我们需要基于寄存器类定义一个操作数。该操作数用于选择 DAG 节点以匹配寄存器,并且还可以扩展以表示打印和匹配汇编代码中的方法名称:

def GPROpnd : RegisterOperand<GPR>;

这完成了我们对寄存器的定义。在下一节中,我们将使用这些定义来定义调用约定。

定义调用约定

调用约定定义了如何传递参数给函数。通常,第一个参数是通过寄存器传递的,其余的参数是通过堆栈传递的。还必须制定关于如何传递聚合和如何从函数返回值的规则。根据这里给出的定义,生成了分析器类,稍后在调用降级期间使用。

您可以在第三章中阅读 M88k 架构使用的调用约定,低级系统信息System V ABI M88k 处理器补充书。让我们将其翻译成 TableGen 语法:

  1. 我们为调用约定定义一个记录:
def CC_M88k : CallingConv<[
  1. M88k 架构只有 32 位寄存器,因此需要将较小数据类型的值提升为 32 位:
  CCIfType<[i1, i8, i16], CCPromoteToType<i32>>,
  1. 调用约定规定,对于聚合返回值,内存的指针将传递到r12寄存器中:
  CCIfSRet<CCIfType<[i32], CCAssignToReg<[R12]>>>,
  1. 寄存器r2r9用于传递参数:
  CCIfType<[i32,i64,f32,f64],
          CCAssignToReg<[R2, R3, R4, R5, R6, R7, R8, 
            R9]>>,
  1. 每个额外的参数都以 4 字节对齐的插槽传递到堆栈上:
  CCAssignToStack<4, 4>
]>;
  1. 另一个记录定义了如何将结果传递给调用函数。32 位值在r2寄存器中传递,64 位值使用r2r3寄存器:
def RetCC_M88k : CallingConv<[
  CCIfType<[i32,f32], CCAssignToReg<[R2]>>,
  CCIfType<[i64,f64], CCAssignToReg<[R2, R3]>>
]>;
  1. 最后,调用约定还说明了由被调用函数保留的寄存器:
def CSR_M88k :
         CalleeSavedRegs<(add (sequence "R%d", 14, 
           25), R30)>;

如果需要,您还可以定义多个调用约定。在下一节中,我们将简要介绍调度模型。

创建调度模型

调度模型被代码生成用来以最佳方式排序指令。定义调度模型可以提高生成代码的性能,但对于代码生成并不是必需的。因此,我们只为模型定义一个占位符。我们添加的信息是 CPU 最多可以同时发出两条指令,并且它是一个顺序 CPU:

def M88kSchedModel : SchedMachineModel {
  let IssueWidth = 2;
  let MicroOpBufferSize = 0;
  let CompleteModel = 0;
  let NoModel = 1;
}

您可以在 YouTube 上的www.youtube.com/watch?v=brpomKUynEA上找到有关如何创建完整调度模型的教程编写优秀的调度程序

接下来,我们将定义指令格式和指令。

定义指令格式和指令信息

我们已经在第九章**,指令选择中查看了指令格式和指令信息,在支持新机器指令部分。为了定义 M88k 架构的指令,我们遵循相同的方法。首先,我们为指令记录定义一个基类。这个类最重要的字段是Inst字段,它保存了指令的编码。这个类的大多数其他字段定义只是为Instruction超类中定义的字段赋值:

class InstM88k<dag outs, dag ins, string asmstr,
         list<dag> pattern, InstrItinClass itin = 
           NoItinerary>
   : Instruction {
  field bits<32> Inst;
  field bits<32> SoftFail = 0; 
  let Namespace = "M88k";
  let Size = 4;
  dag OutOperandList = outs;
  dag InOperandList = ins;
  let AsmString   = asmstr;
  let Pattern = pattern;
  let DecoderNamespace = "M88k";
  let Itinerary = itin;
}

这个基类用于所有指令格式,因此也用于F_JMP格式。您可以使用处理器的用户手册中的编码。该类有两个参数,必须是编码的一部分。func参数定义了编码的第 11 到 15 位,这些位定义了指令是带有或不带有保存返回地址的跳转指令。next参数是一个位,定义了下一条指令是否无条件执行。这类似于 MIPS 架构的延迟槽。

该类还定义了rs2字段,其中保存了保存目标地址的寄存器的编码。其他参数包括 DAG 输入和输出操作数,文本汇编器字符串,用于选择此指令的 DAG 模式,以及调度器模型的行程类:

class F_JMP<bits<5> func, bits<1> next,
            dag outs, dag ins, string asmstr,
            list<dag> pattern,
            InstrItinClass itin = NoItinerary>
   : InstM88k<outs, ins, asmstr, pattern, itin> {
  bits<5> rs2;
  let Inst{31-26} = 0b111101;
  let Inst{25-16} = 0b0000000000;
  let Inst{15-11} = func;
  let Inst{10}    = next;
  let Inst{9-5}   = 0b00000;
  let Inst{4-0}   = rs2;
}

有了这个,我们最终可以定义指令了。跳转指令是基本块中的最后一条指令,因此我们需要设置isTerminator标志。因为控制流不能通过此指令,我们还必须设置isBarrier标志。我们从处理器的用户手册中获取funcnext参数的值。

输入 DAG 操作数是一个通用寄存器,并指的是前一个寄存器信息中的操作数。编码存储在rs2字段中,来自前一个类定义。输出操作数为空。汇编字符串给出了指令的文本语法,也指的是寄存器操作数。DAG 模式使用预定义的brind操作符。如果 DAG 包含一个以寄存器中保存的目标地址为目标的间接跳转节点,则选择此指令:

let isTerminator = 1, isBarrier = 1 in
  def JMP : F_JMP<0b11000, 0, (outs), (ins GPROpnd:$rs2),
                  "jmp $rs2", [(brind GPROpnd:$rs2)]>;

我们需要以这种方式为所有指令定义记录。

在这个文件中,我们还实现了指令选择的其他必要模式。一个典型的应用是常量合成。M88k 架构有 32 位宽的寄存器,但是带有立即数操作数的指令只支持 16 位宽的常量。因此,诸如寄存器和 32 位常量之间的按位and等操作必须分成两条使用 16 位常量的指令。

幸运的是,and指令中的一个标志定义了操作是应用于寄存器的下半部分还是上半部分。使用 LO16 和 HI16 操作符来提取常量的下半部分或上半部分,我们可以为寄存器和 32 位宽常量之间的and操作制定一个 DAG 模式:

def : Pat<(and GPR:$rs1, uimm32:$imm),
          (ANDri (ANDriu GPR:$rs1, (HI16 i32:$imm)),
                                   (LO16 i32:$imm))>;

ANDri操作符是将常量应用于寄存器的低半部分的and指令,而ANDriu操作符使用寄存器的上半部分。当然,在模式中使用这些名称之前,我们必须像定义jmp指令一样定义指令。此模式解决了使用 32 位常量进行and操作的问题,在指令选择期间为其生成两条机器指令。

并非所有操作都可以由预定义的 DAG 节点表示。例如,M88k 架构定义了位字段操作,可以看作是普通and/or操作的泛化。对于这样的操作,可以引入新的节点类型,例如set指令:

def m88k_set : SDNode<"M88kISD::SET", SDTIntBinOp>;

这定义了SDNode类的新记录。第一个参数是表示新操作的 C++枚举成员。第二个参数是所谓的类型配置文件,定义了参数的类型和数量以及结果类型。预定义的SDTIntBinOp类定义了两个整数参数和一个整数结果类型,这对于此操作是合适的。您可以在llvm/include/llvm/Target/TargetSelectionDAG.td文件中查找预定义的类。如果没有合适的预定义类型配置文件,那么您可以定义一个新的。

对于调用函数,LLVM 需要某些不能预定义的定义,因为它们不完全是与目标无关的。例如,对于返回,我们需要指定retflag记录:

def retflag : SDNode<"M88kISD::RET_FLAG", SDTNone,
                 [SDNPHasChain, SDNPOptInGlue, SDNPVariadic]>;

将此与m88k_set记录进行比较,这也为 DAG 节点定义了一些标志:链和粘合序列被使用,并且操作符可以接受可变数量的参数。

逐步实现指令

现代 CPU 很容易有成千上万条指令。一次不要实现所有指令是有意义的。相反,您应该首先集中在基本指令上,例如逻辑操作和调用和返回指令。这足以使基本的后端工作。然后,您可以添加更多的指令定义和模式。

这完成了我们对目标描述的实现。从目标描述中,使用llvm-tblgen工具自动生成了大量代码。为了完成指令选择和后端的其他部分,我们仍然需要使用生成的代码开发 C++源代码。在下一节中,我们将实现 DAG 指令选择。

实现 DAG 指令选择类

DAG 指令选择器的大部分是由llvm-tblgen工具生成的。我们仍然需要使用生成的代码创建类,并将所有内容放在一起。让我们从初始化过程的一部分开始。

初始化目标机器

每个后端都必须提供至少一个TargetMachine类,通常是LLVMTargetMachine类的子类。M88kTargetMachine类包含了代码生成所需的许多细节,并且还充当其他后端类的工厂,尤其是Subtarget类和TargetPassConfig类。Subtarget类保存了代码生成的配置,例如启用了哪些特性。TargetPassConfig类配置了后端的机器传递。我们的M88kTargetMachine类的声明在M88ktargetMachine.h文件中,如下所示:

class M88kTargetMachine : public LLVMTargetMachine {
public:
  M88kTargetMachine(/* parameters */);
  ~M88kTargetMachine() override;
  const M88kSubtarget *getSubtargetImpl(const Function &)
                                        const override;
  const M88kSubtarget *getSubtargetImpl() const = delete;
  TargetPassConfig *createPassConfig(PassManagerBase &PM)
                                                     override;
};

请注意,每个函数可能有不同的子目标。

M88kTargetMachine.cpp文件中的实现是直接的。最有趣的是为此后端设置机器传递。这创建了与选择 DAG(如果需要,还有全局指令选择)的连接。类中创建的传递后来被添加到传递管道中,以从 IR 生成目标文件或汇编程序:

namespace {
class M88kPassConfig : public TargetPassConfig {
public:
  M88kPassConfig(M88kTargetMachine &TM, PassManagerBase 
    &PM)
      : TargetPassConfig(TM, PM) {}
  M88kTargetMachine &getM88kTargetMachine() const {
    return getTM<M88kTargetMachine>();
  }
  bool addInstSelector() override {
    addPass(createM88kISelDag(getM88kTargetMachine(), 
                              getOptLevel()));
    return false;
  }
};
} // namespace
TargetPassConfig *M88kTargetMachine::createPassConfig(
    PassManagerBase &PM) {
  return new M88kPassConfig(*this, PM);
}

SubTarget实现从M88kTargetMachine类返回,可以访问其他重要的类。M88kInstrInfo类返回有关指令的信息,包括寄存器。M88kTargetLowering类提供了与调用相关指令的降低,并允许添加自定义的 DAG 规则。大部分类是由llvm-tblgen工具生成的,我们需要包含生成的头文件。

M88kSubTarget.h文件中的定义如下:

#define GET_SUBTARGETINFO_HEADER
#include "M88kGenSubtargetInfo.inc"
namespace llvm {
class M88kSubtarget : public M88kGenSubtargetInfo {
  Triple TargetTriple;
  virtual void anchor();
  M88kInstrInfo InstrInfo;
  M88kTargetLowering TLInfo;
  M88kFrameLowering FrameLowering;
public:
  M88kSubtarget(const Triple &TT, const std::string &CPU,
                const std::string &FS,
                const TargetMachine &TM);
  void ParseSubtargetFeatures(StringRef CPU, StringRef FS);
  const TargetFrameLowering *getFrameLowering() const 
    override
  { return &FrameLowering; }
  const M88kInstrInfo *getInstrInfo() const override
  { return &InstrInfo; }
  const M88kRegisterInfo *getRegisterInfo() const override
  { return &InstrInfo.getRegisterInfo(); }
  const M88kTargetLowering *getTargetLowering() const 
    override 
  { return &TLInfo; }
};
} // end namespace llvm

接下来,我们实现选择 DAG。

添加选择 DAG 实现

选择 DAG 在同名文件中的M88kDAGtoDAGIsel类中实现。在这里,我们受益于已经创建了目标机器描述:大部分功能都是从这个描述中生成的。在最初的实现中,我们只需要重写Select()函数并将其转发到生成的SelectCode函数。还可以为特定情况重写更多函数,例如,如果我们需要扩展 DAG 的预处理,或者如果我们需要添加特殊的内联汇编约束。

因为这个类是一个机器函数传递,我们还为传递提供了一个名称。主要的实现部分来自生成的文件,我们在类的中间包含了这个文件:

class M88kDAGToDAGISel : public SelectionDAGISel {
  const M88kSubtarget *Subtarget;
public:
  M88kDAGToDAGISel(M88kTargetMachine &TM,
                   CodeGenOpt::Level OptLevel)
      : SelectionDAGISel(TM, OptLevel) {}
  StringRef getPassName() const override {
    return "M88k DAG->DAG Pattern Instruction Selection";
  }
#include "M88kGenDAGISel.inc"
  void Select(SDNode *Node) override {
    SelectCode(Node);
  }
};

我们还在这个文件中添加了创建传递的工厂函数:

FunctionPass *llvm::createM88kISelDag(M88kTargetMachine &TM,
                                 CodeGenOpt::Level                 
                                   OptLevel) {
  return new M88kDAGToDAGISel(TM, OptLevel);
}

现在我们可以实现目标特定的操作,这些操作无法在目标描述中表达。

支持目标特定操作

让我们转向M88kTargetLowering类,在M88kISelLowering.h文件中定义。这个类配置指令 DAG 选择过程,并增强了目标特定操作的降低。

在目标描述中,我们定义了新的 DAG 节点。与新类型一起使用的枚举也在这个文件中定义,继续使用上一个预定义数字的编号:

namespace M88kISD {
enum NodeType : unsigned {
  FIRST_NUMBER = ISD::BUILTIN_OP_END,
  RET_FLAG,
  SET,
};
} // end namespace M88kISD

该类需要为函数调用提供所需的降低方法。为了保持简单,我们只关注返回值。该类还可以为需要自定义处理的操作定义LowerOperation()挂钩方法。我们还可以启用自定义 DAG 组合方法,为此我们定义PerformDAGCombine()方法:

class M88kTargetLowering : public TargetLowering {
  const M88kSubtarget &Subtarget;
public:
  explicit M88kTargetLowering(const TargetMachine &TM,
                              const M88kSubtarget &STI);
  SDValue LowerOperation(SDValue Op, SelectionDAG &DAG) const 
                                                     override;
  SDValue PerformDAGCombine(SDNode *N, DAGCombinerInfo &DCI) 
                                               const override;
  SDValue LowerReturn(SDValue Chain, CallingConv::ID CallConv,
          bool IsVarArg,
          const SmallVectorImpl<ISD::OutputArg> &Outs,
          const SmallVectorImpl<SDValue> &OutVals,
          const SDLoc &DL,
          SelectionDAG &DAG) const override;
};

该类的实现在M88kISelLowering.cpp文件中。首先,我们看一下如何降低返回值:

  1. 需要调用约定的生成函数,因此我们包含了生成的文件:
#include "M88kGenCallingConv.inc"
  1. LowerReturn()方法有很多参数,所有这些参数都是由TargetLowering超类定义的。最重要的是Outs向量,它保存了返回参数的描述,以及OutVals向量,它保存了返回值的 DAG 节点:
SDValue M88kTargetLowering::LowerReturn(SDValue Chain,
            CallingConv::ID CallConv,
            bool IsVarArg,
            const SmallVectorImpl<ISD::OutputArg> 
              &Outs,
            const SmallVectorImpl<SDValue> &OutVals,
            const SDLoc &DL, SelectionDAG &DAG) const {
  1. 我们使用CCState类来分析返回参数,并传递一个对生成的RetCC_M88k函数的引用。结果,我们已经对所有的返回参数进行了分类:
  MachineFunction &MF = DAG.getMachineFunction();
  SmallVector<CCValAssign, 16> RetLocs;
  CCState RetCCInfo(CallConv, IsVarArg, MF, RetLocs,
                                      *DAG.getContext());
  RetCCInfo.AnalyzeReturn(Outs, RetCC_M88k);
  1. 如果是void函数,则无需操作并返回。请注意,返回节点的类型是RET_FLAG。我们在目标描述中定义了这个新的ret_flag节点:
  if (RetLocs.empty())
    return DAG.getNode(M88kISD::RET_FLAG, DL,
                       MVT::Other, Chain);
  1. 否则,我们需要循环遍历返回参数。对于每个返回参数,我们都有一个CCValAssign类的实例,告诉我们如何处理参数:
  SDValue Glue;
  SmallVector<SDValue, 4> RetOps;
  RetOps.push_back(Chain);
  for (unsigned I = 0, E = RetLocs.size(); I != E; 
       ++I) {
    CCValAssign &VA = RetLocs[I];
    SDValue RetValue = OutVals[I];
  1. 值可能需要提升。如果需要,我们添加一个带有所需扩展操作的 DAG 节点:
    switch (VA.getLocInfo()) {
    case CCValAssign::SExt:
      RetValue = DAG.getNode(ISD::SIGN_EXTEND, DL,
                             VA.getLocVT(), RetValue);
      break;
    case CCValAssign::ZExt:
      RetValue = DAG.getNode(ISD::ZERO_EXTEND, DL, 
                             VA.getLocVT(), RetValue);
      break;
    case CCValAssign::AExt:
      RetValue = DAG.getNode(ISD::ANY_EXTEND, DL,  
                             VA.getLocVT(), RetValue);
      break;
    case CCValAssign::Full:
      break;
    default:
      llvm_unreachable("Unhandled VA.getLocInfo()");
    }
  1. 当值具有正确的类型时,我们将该值复制到寄存器中返回,并将复制的链和粘合在一起。这完成了循环:
    Register Reg = VA.getLocReg();
    Chain = DAG.getCopyToReg(Chain, DL, Reg, RetValue, 
                             Glue);
    Glue = Chain.getValue(1);
    RetOps.push_back(DAG.getRegister(Reg, 
                                     VA.getLocVT()));
  }
  1. 最后,我们需要更新链和粘合:
  RetOps[0] = Chain;
  if (Glue.getNode())
    RetOps.push_back(Glue);
  1. 然后我们将返回ret_flag节点,连接降低的结果:
  return DAG.getNode(M88kISD::RET_FLAG, DL, 
    MVT::Other, 
                     RetOps);
}

为了能够调用函数,我们必须实现LowerFormalArguments()LowerCall()方法。这两种方法都遵循类似的方法,因此这里不再显示。

配置目标降低

必须始终实现降低函数调用和参数的方法,因为它们始终是与目标相关的。其他操作可能在目标架构中有或没有支持。为了使降低过程意识到这一点,我们在M88kTargetLowering类的构造函数中设置了配置:

  1. 构造函数以TargetMachineM88kSubtarget实例作为参数,并用它们初始化相应的字段:
M88kTargetLowering::M88kTargetLowering(
       const TargetMachine &TM, const M88kSubtarget &STI)
    : TargetLowering(TM), Subtarget(STI) {
  1. 首先添加所有寄存器类。我们只定义了通用寄存器,因此这只是一个简单的调用:
  addRegisterClass(MVT::i32, &M88k::GPRRegClass);
  1. 在添加所有寄存器类之后,我们计算寄存器的派生属性。例如,由于寄存器宽度为 32 位,因此此函数将 64 位数据类型标记为需要两个寄存器:
  computeRegisterProperties(Subtarget.getRegisterInfo());
  1. 我们还需要告诉哪个寄存器用于堆栈指针。在 M88k 架构中,使用r31寄存器:
  setStackPointerRegisterToSaveRestore(M88k::R31);
  1. 我们还需要定义boolean值的表示方式。基本上,我们在这里说使用值 0 和 1。其他可能的选项是仅查看值的第 0 位,忽略所有其他位,并将值的所有位设置为 0 或 1:
  setBooleanContents(ZeroOrOneBooleanContent);
  1. 对于每个需要特殊处理的操作,我们必须调用setOperationAction()方法。该方法以操作、值类型和要执行的操作作为输入。如果操作有效,则使用Legal操作值。如果类型应该提升,则使用Promote操作值,如果操作应该导致库调用,则使用LibCall操作值。

如果给出Expand操作值,则指令选择首先尝试将此操作扩展为其他操作。如果这不可能,则使用库调用。最后,如果使用Custom操作值,我们可以实现自己的操作。在这种情况下,将为具有此操作的节点调用LowerOperation()方法。例如,我们将CTTZ计数尾随零操作设置为Expand操作。此操作将被一系列原始位操作替换:

  setOperationAction(ISD::CTTZ, MVT::i32, Expand);
  1. M88k 架构具有位字段操作,对于该操作,很难在目标描述中定义模式。在这里,我们告诉指令选择器,我们希望在or DAG 节点上执行额外的匹配:
  setTargetDAGCombine(ISD::OR);
}

根据目标架构,设置构造函数中的配置可能会更长。我们只定义了最低限度,例如忽略了浮点运算。

我们已经标记了or操作以执行自定义组合。因此,指令选择器在调用生成的指令选择之前调用PerformDAGCombine()方法。此函数在指令选择的各个阶段调用,但通常,我们只在操作被合法化后执行匹配。通用实现是查看操作并跳转到处理匹配的函数。

SDValue M88kTargetLowering::PerformDAGCombine(SDNode *N,
                                 DAGCombinerInfo &DCI) const {
  if (DCI.isBeforeLegalizeOps())
    return SDValue();
  switch (N->getOpcode()) {
  default:
    break;
  case ISD::OR:
    return performORCombine(N, DCI);
  }
  return SDValue();
}

performORCombine()方法中,我们尝试检查是否可以为or操作生成set指令。set指令将一系列连续的位设置为 1,从指定的位偏移开始。这是or操作的特殊情况,第二个操作数是匹配此格式的常量。由于 M88k 架构的or指令仅适用于 16 位常量,因此此匹配是有益的,否则,我们将不得不合成常量,导致两个or指令。此方法使用isShiftedMask()辅助函数来确定常量值是否具有所需的形式。

如果第二个操作数是所需形式的常量,则此函数返回表示set指令的节点。否则,返回值SDValue()表示未找到匹配模式,应调用生成的 DAG 模式匹配器:

SDValue performORCombine(SDNode *N, 
    TargetLowering::DAGCombinerInfo &DCI) {
  SelectionDAG &DAG = DCI.DAG;
  uint64_t Width, Offset;
  ConstantSDNode *Mask =
                   dyn_cast<ConstantSDNode>(N->getOperand(
                     1));
  if (!Mask ||
      !isShiftedMask(Mask->getZExtValue(), Width, Offset))
    return SDValue();
  EVT ValTy = N->getValueType(0);
  SDLoc DL(N);
  return DAG.getNode(M88kISD::SET, DL, ValTy, 
          N->getOperand(0),
          DAG.getConstant(Width << 5 | Offset, DL, 
            MVT::i32));
}

为了完成整个降低过程的实现,我们需要实现M88kFrameLowering类。这个类负责处理堆栈帧。这包括生成序言和结语代码,处理寄存器溢出等。对于第一个实现,您可以只提供空函数。显然,为了完整的功能,这个类必须被实现。

这完成了我们对指令选择的实现。接下来,我们将看看最终的指令是如何发出的。

生成汇编指令

在前几节中实现的指令选择将 IR 指令降低为MachineInstr实例。这已经是指令的更低表示,但还不是机器码本身。后端管道中的最后一步是发出指令,可以是汇编文本,也可以是目标文件。M88kAsmPrinter机器传递负责这项任务。

基本上,这个传递将MachineInstr实例降低到MCInst实例,然后发出到一个流器。MCInst类表示真正的机器码指令。这种额外的降低是必需的,因为MachineInstr类仍然没有所有必需的细节。

对于第一种方法,我们可以将我们的实现限制在重写emitInstruction()方法。您需要重写更多的方法来支持几种操作数类型,主要是为了发出正确的重定位。这个类还负责处理内联汇编器,如果需要的话,您也需要实现它。

因为M88kAsmPrinter类再次是一个机器函数传递,我们还需要重写getPassName()方法。该类的声明如下:

class M88kAsmPrinter : public AsmPrinter {
public:
  explicit M88kAsmPrinter(TargetMachine &TM,
                         std::unique_ptr<MCStreamer> 
                           Streamer)
      : AsmPrinter(TM, std::move(Streamer)) {}
  StringRef getPassName() const override
  { return "M88k Assembly Printer"; }
  void emitInstruction(const MachineInstr *MI) override;
};

基本上,在emitInstruction()方法中我们必须处理两种不同的情况。MachineInstr实例仍然可以有操作数,这些操作数不是真正的机器指令。例如,对于返回ret_flag节点,具有RET操作码值。在 M88k 架构上,没有return指令。相反,会跳转到存储在r1寄存器中的地址。因此,当检测到RET操作码时,我们需要构造分支指令。在默认情况下,降低只需要MachineInstr实例的信息,我们将这个任务委托给M88kMCInstLower类:

void M88kAsmPrinter::emitInstruction(const MachineInstr *MI) {
  MCInst LoweredMI;
  switch (MI->getOpcode()) {
  case M88k::RET:
    LoweredMI = MCInstBuilder(M88k::JMP).addReg(M88k::R1);
    break;
  default:
    M88kMCInstLower Lower(MF->getContext(), *this);
    Lower.lower(MI, LoweredMI);
    break;
  }
  EmitToStreamer(*OutStreamer, LoweredMI);
}

M88kMCInstLower类没有预定义的超类。它的主要目的是处理各种操作数类型。由于目前我们只有一组非常有限的支持的操作数类型,我们可以将这个类简化为只有一个方法。lower()方法设置MCInst实例的操作码和操作数。只处理寄存器和立即操作数;其他操作数类型被忽略。对于完整的实现,我们还需要处理内存地址。

void M88kMCInstLower::lower(const MachineInstr *MI, MCInst &OutMI) const {
  OutMI.setOpcode(MI->getOpcode());
  for (unsigned I = 0, E = MI->getNumOperands(); I != E; ++I) 
  {
    const MachineOperand &MO = MI->getOperand(I);
    switch (MO.getType()) {
    case MachineOperand::MO_Register:
      if (MO.isImplicit())
        break;
      OutMI.addOperand(MCOperand::createReg(MO.getReg()));
      break;
    case MachineOperand::MO_Immediate:
      OutMI.addOperand(MCOperand::createImm(MO.getImm()));
      break;
    default:
      break;
    }
  }
}

汇编打印机需要一个工厂方法,在初始化期间调用,例如从InitializeAllAsmPrinters()方法:

extern "C" LLVM_EXTERNAL_VISIBILITY void 
LLVMInitializeM88kAsmPrinter() {
  RegisterAsmPrinter<M88kAsmPrinter> X(getTheM88kTarget());
}

最后,将指令降低到真实的机器码指令后,我们还没有完成。我们需要在 MC 层实现各种小的部分,我们将在下一节中讨论。

发出机器码

MC 层负责以文本或二进制形式发出机器码。大部分功能要么在各种 MC 类中实现并且只需要配置,要么从目标描述生成实现。

MC 层的初始化在MCTargetDesc/M88kMCTargetDesc.cpp文件中进行。以下类已在TargetRegistry单例中注册:

  • M88kMCAsmInfo:这个类提供基本信息,比如代码指针的大小,堆栈增长的方向,注释符号,或者汇编指令的名称。

  • M88MCInstrInfo:这个类保存有关指令的信息,例如指令的名称。

  • M88kRegInfo:此类提供有关寄存器的信息,例如寄存器的名称或哪个寄存器是堆栈指针。

  • M88kSubtargetInfo:此类保存调度模型的数据和解析和设置 CPU 特性的方法。

  • M88kMCAsmBackend:此类提供了获取与目标相关的修正数据的辅助方法。它还包含了用于对象编写器类的工厂方法。

  • M88kMCInstPrinter:此类包含一些辅助方法,用于以文本形式打印指令和操作数。如果操作数在目标描述中定义了自定义打印方法,则必须在此类中实现。

  • M88kMCCodeEmitter:此类将指令的编码写入流中。

根据后端实现的范围,我们不需要注册和实现所有这些类。如果不支持文本汇编器输出,则可以省略注册MCInstPrinter子类。如果不支持编写目标文件,则可以省略MCAsmBackendMCCodeEmitter子类。

我们首先包含生成的部分,并为其提供工厂方法:

#define GET_INSTRINFO_MC_DESC
#include "M88kGenInstrInfo.inc"
#define GET_SUBTARGETINFO_MC_DESC
#include "M88kGenSubtargetInfo.inc"
#define GET_REGINFO_MC_DESC
#include "M88kGenRegisterInfo.inc"
static MCInstrInfo *createM88kMCInstrInfo() {
  MCInstrInfo *X = new MCInstrInfo();
  InitM88kMCInstrInfo(X);
  return X;
}
static MCRegisterInfo *createM88kMCRegisterInfo(
                                           const Triple &TT) {
  MCRegisterInfo *X = new MCRegisterInfo();
  InitM88kMCRegisterInfo(X, M88k::R1);
  return X;
}
static MCSubtargetInfo *createM88kMCSubtargetInfo(
              const Triple &TT, StringRef CPU, StringRef 
                FS) {
  return createM88kMCSubtargetInfoImpl(TT, CPU, FS);
}

我们还为其他文件中实现的类提供了一些工厂方法:

static MCAsmInfo *createM88kMCAsmInfo(
                  const MCRegisterInfo &MRI, const Triple &TT,
                  const MCTargetOptions &Options) {
  return new M88kMCAsmInfo(TT);
}
static MCInstPrinter *createM88kMCInstPrinter(
                 const Triple &T, unsigned SyntaxVariant,
                 const MCAsmInfo &MAI, const MCInstrInfo &MII,
                 const MCRegisterInfo &MRI) {
  return new M88kInstPrinter(MAI, MII, MRI);
}

要初始化 MC 层,我们只需要使用TargetRegistry单例注册所有工厂方法:

extern "C" LLVM_EXTERNAL_VISIBILITY
void LLVMInitializeM88kTargetMC() {
  TargetRegistry::RegisterMCAsmInfo(getTheM88kTarget(), 
                                         createM88kMCAsmInfo);
  TargetRegistry::RegisterMCCodeEmitter(getTheM88kTarget(),

                                     createM88kMCCodeEmitter);
  TargetRegistry::RegisterMCInstrInfo(getTheM88kTarget(),
                                       createM88kMCInstrInfo);
  TargetRegistry::RegisterMCRegInfo(getTheM88kTarget(),
                                    createM88kMCRegisterInfo);
  TargetRegistry::RegisterMCSubtargetInfo(getTheM88kTarget(),
                                   createM88kMCSubtargetInfo);
  TargetRegistry::RegisterMCAsmBackend(getTheM88kTarget(),
                                      createM88kMCAsmBackend);
  TargetRegistry::RegisterMCInstPrinter(getTheM88kTarget(),
                                     createM88kMCInstPrinter);
}

此外,在MCTargetDesc/M88kTargetDesc.h头文件中,我们还需要包含生成源的头部部分,以便其他人也可以使用:

#define GET_REGINFO_ENUM
#include "M88kGenRegisterInfo.inc"
#define GET_INSTRINFO_ENUM
#include "M88kGenInstrInfo.inc"
#define GET_SUBTARGETINFO_ENUM
#include "M88kGenSubtargetInfo.inc"

我们将注册类的源文件都放在MCTargetDesc目录中。对于第一个实现,只需为这些类提供存根即可。例如,只要目标描述中没有添加对内存地址的支持,就不会生成修正。M88kMCAsmInfo类可以非常快速地实现,因为我们只需要在构造函数中设置一些属性:

M88kMCAsmInfo::M88kMCAsmInfo(const Triple &TT) {
  CodePointerSize = 4;
  IsLittleEndian = false;
  MinInstAlignment = 4;
  CommentString = "#";
}

在为 MC 层实现了支持类之后,我们现在能够将机器码输出到文件中。

在下一节中,我们实现了用于反汇编的类,这是相反的操作:将目标文件转换回汇编器文本。

添加反汇编支持

目标描述中指令的定义允许构建解码器表,用于将目标文件反汇编为文本汇编器。解码器表和解码器函数由llvm-tblgen工具生成。除了生成的代码,我们只需要提供注册和初始化M88kDisassembler类以及一些辅助函数来解码寄存器和操作数的代码。我们将实现放在Disassembler/M88kDisassembler.cpp文件中。

M88kDisassembler类的getInstruction()方法执行解码工作。它以字节数组作为输入,并将下一条指令解码为MCInst类的实例。类声明如下:

using DecodeStatus = MCDisassembler::DecodeStatus;
namespace {
class M88kDisassembler : public MCDisassembler {
public:
  M88kDisassembler(const MCSubtargetInfo &STI, MCContext &Ctx)
      : MCDisassembler(STI, Ctx) {}
  ~M88kDisassembler() override = default;
  DecodeStatus getInstruction(MCInst &instr, uint64_t &Size,
                              ArrayRef<uint8_t> Bytes, 
                              uint64_t Address,
                              raw_ostream &CStream) const 
                                                     override;
};
}

生成的类未经限定地引用DecodeStatus枚举,因此我们必须使此名称可见。

要初始化反汇编器,我们定义一个简单的工厂函数来实例化一个新对象:

static MCDisassembler *
createM88kDisassembler(const Target &T,
                       const MCSubtargetInfo &STI,
                       MCContext &Ctx) {
  return new M88kDisassembler(STI, Ctx);
}

LLVMInitializeM88kDisassembler()函数中,我们在目标注册表中注册工厂函数:

extern "C" LLVM_EXTERNAL_VISIBILITY void
LLVMInitializeM88kDisassembler() {
  TargetRegistry::RegisterMCDisassembler(
      getTheM88kTarget(), createM88kDisassembler);
}

当 LLVM 核心库初始化时,此函数将从InitializeAllDisassemblers()函数或InitializeNativeTargetDisassembler()函数中调用。

生成的解码器函数需要辅助函数来解码寄存器和操作数。原因是这些元素的编码通常涉及目标描述中未表达的特殊情况。例如,两条指令之间的距离总是偶数,因此可以忽略最低位,因为它总是零。

要解码寄存器,必须定义DecodeGPRRegisterClass()函数。32 个寄存器用 0 到 31 之间的数字进行编码,我们可以使用静态的GPRDecoderTable表来在编码和生成的寄存器枚举之间进行映射:

static const uint16_t GPRDecoderTable[] = {
    M88k::R0,  M88k::R1,  M88k::R2,  M88k::R3,
    M88k::R4,  M88k::R5,  M88k::R6,  M88k::R7,
    M88k::R8,  M88k::R9,  M88k::R10, M88k::R11,
    M88k::R12, M88k::R13, M88k::R14, M88k::R15,
    M88k::R16, M88k::R17, M88k::R18, M88k::R19,
    M88k::R20, M88k::R21, M88k::R22, M88k::R23,
    M88k::R24, M88k::R25, M88k::R26, M88k::R27,
    M88k::R28, M88k::R29, M88k::R30, M88k::R31,
};
static DecodeStatus
DecodeGPRRegisterClass(MCInst &Inst, uint64_t RegNo,
                       uint64_t Address,
                       const void *Decoder) {
  if (RegNo > 31)
    return MCDisassembler::Fail;
  unsigned Register = GPRDecoderTable[RegNo];
  Inst.addOperand(MCOperand::createReg(Register));
  return MCDisassembler::Success;
}

所有其他所需的解码器函数都遵循与DecodeGPRRegisterClass()函数相同的模式:

  1. 检查要解码的值是否符合所需的大小限制。如果不符合,则返回MCDisassembler::Fail值。

  2. 解码值并将其添加到MCInst实例中。

  3. 返回MCDisassembler::Success以指示成功。

然后,我们可以包含生成的解码器表和函数:

#include "M88kGenDisassemblerTables.inc"

最后,我们可以定义getInstruction()方法。该方法有两个结果值,解码指令和指令的大小。如果字节数组太小,则大小必须设置为0。这很重要,因为大小参数被调用者用来将指针推进到下一个内存位置,即使解码失败也是如此。

对于 M88k 架构,该方法很简单,因为所有指令都是 4 个字节长。因此,在从数组中提取 4 个字节后,可以调用生成的解码器函数:

DecodeStatus M88kDisassembler::getInstruction(
    MCInst &MI, uint64_t &Size, ArrayRef<uint8_t> Bytes,
    uint64_t Address, raw_ostream &CS) const {
  if (Bytes.size() < 4) {
    Size = 0;
    return MCDisassembler::Fail;
  }
  Size = 4;
  uint32_t Inst = 0;
  for (uint32_t I = 0; I < Size; ++I)
    Inst = (Inst << 8) | Bytes[I];
  return decodeInstruction(DecoderTableM88k32, MI, Inst,
                           Address, this, STI);
}

这完成了反汇编器的实现。

在实现了所有类之后,我们只需要设置构建系统以选择新的目标后端,这将在下一节中添加。

将所有部分组合在一起

我们的新目标位于llvm/lib/Target/M88k目录中,需要集成到构建系统中。为了方便开发,我们将其添加为llvm/CMakeLists.txt文件中的实验性目标。我们用我们的目标名称替换现有的空字符串:

set(LLVM_EXPERIMENTAL_TARGETS_TO_BUILD "M88k"  … )

我们还需要提供一个llvm/lib/Target/M88k/CMakeLists.txt文件来构建我们的目标。除了列出目标的 C++文件外,它还定义了从目标描述生成源代码。

从目标描述生成所有类型的源

llvm-tblgen工具的不同运行会生成不同部分的 C++代码。然而,我建议将所有部分的生成都添加到CMakeLists.txt文件中。原因是这样做提供了更好的检查。例如,如果您在指令编码中出错,那么只有在反汇编器代码生成期间才会捕获到错误。因此,即使您不打算支持反汇编器,生成其源代码仍然是值得的。

文件如下所示:

  1. 首先,我们定义一个名为M88k的新的 LLVM 组件:
add_llvm_component_group(M88k)
  1. 接下来,我们命名目标描述文件,添加语句以使用 TableGen 生成各种源代码片段,并为其定义一个公共目标:
set(LLVM_TARGET_DEFINITIONS M88k.tdtablegen(LLVM M88kGenAsmMatcher.inc -gen-asm-matcher)
tablegen(LLVM M88kGenAsmWriter.inc -gen-asm-writer)
tablegen(LLVM M88kGenCallingConv.inc -gen-callingconv)
tablegen(LLVM M88kGenDAGISel.inc -gen-dag-isel)
tablegen(LLVM M88kGenDisassemblerTables.inc 
                                       -gen-disassembler)
tablegen(LLVM M88kGenInstrInfo.inc -gen-instr-info)
tablegen(LLVM M88kGenMCCodeEmitter.inc -gen-emitter)
tablegen(LLVM M88kGenRegisterInfo.inc -gen-register-info)
tablegen(LLVM M88kGenSubtargetInfo.inc -gen-subtarget)
add_public_tablegen_target(M88kCommonTableGen)
  1. 我们必须列出新组件由哪些源文件组成:
add_llvm_target(M88kCodeGen
  M88kAsmPrinter.cpp M88kFrameLowering.cpp
  M88kISelDAGToDAG.cpp M88kISelLowering.cpp
  M88kRegisterInfo.cpp M88kSubtarget.cpp
  M88kTargetMachine.cpp )
  1. 最后,我们在构建中包含了 MC 和反汇编器类的目录:
add_subdirectory(MCTargetDesc)
add_subdirectory(Disassembler)

现在我们准备用新的后端目标编译 LLVM。在构建目录中,我们可以简单地运行这个命令:

$ ninja

这将检测到更改的CmakeLists.txt文件,再次运行配置步骤,并编译新的后端。要检查一切是否顺利,您可以运行这个命令:

$ bin/llc –version

输出应该在Registered Target部分包含以下行:

    m88k       - M88k

万岁!我们完成了后端的实现。让我们试试。LLVM IR 中的以下f1函数执行函数的两个参数之间的按位 AND 操作,并返回结果。将其保存在example.ll文件中:

target triple = "m88k-openbsd"
define i32 @f1(i32 %a, i32 %b) {
  %res = and i32 %a, %b
  ret i32 %res
}

运行llc工具如下以在控制台上查看生成的汇编文本:

$ llc < example.ll
        .text
        .file   "<stdin>"
        .globl  f1                              # -- Begin function f1
        .align  3
        .type   f1,@function
f1:                                     # @f1
        .cfi_startproc
# %bb.0:
        and %r2, %r2, %r3
        jmp %r1
.Lfunc_end0:
        .size   f1, .Lfunc_end0-f1
        .cfi_endproc
                                        # -- End function
        .section        ".note.GNU-stack","",@progbits

输出符合有效的 GNU 语法。对于f1函数,生成了andjmp指令。参数传递在%r2%r3寄存器中,这些寄存器在and指令中被使用。结果存储在%r2寄存器中,这也是返回 32 位值的寄存器。函数的返回通过跳转到%r1寄存器中保存的地址来实现,这也符合 ABI。一切看起来都很不错!

通过本章学到的知识,你现在可以实现自己的 LLVM 后端。对于许多相对简单的 CPU,比如数字信号处理器(DSP),你不需要实现更多的内容。当然,M88k CPU 架构的实现还不支持所有的特性,例如浮点寄存器。然而,你现在已经了解了 LLVM 后端开发中应用的所有重要概念,有了这些,你将能够添加任何缺失的部分!

总结

在本章中,你学会了如何为 LLVM 开发一个新的后端目标。你首先收集了所需的文档,并通过增强Triple类使 LLVM 意识到了新的架构。文档还包括 ELF 文件格式的重定位定义,你还为 LLVM 添加了对此的支持。

你了解了目标描述包含的不同部分,并使用从中生成的 C++源代码,学会了如何实现指令选择。为了输出生成的代码,你开发了一个汇编打印程序,并学会了需要哪些支持类来写入目标文件。你还学会了如何添加反汇编支持,用于将目标文件转换回汇编文本。最后,你扩展了构建系统,将新的目标包含在构建中。

现在你已经具备了在自己的项目中以创造性方式使用 LLVM 所需的一切。LLVM 生态系统非常活跃,不断添加新特性,所以一定要关注所有的发展!

作为一个编译器开发者,能够写关于 LLVM 并在过程中发现一些新特性对我来说是一种乐趣。享受 LLVM 的乐趣吧!

posted @   绝不原创的飞龙  阅读(546)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 一个费力不讨好的项目,让我损失了近一半的绩效!
· 清华大学推出第四讲使用 DeepSeek + DeepResearch 让科研像聊天一样简单!
· 实操Deepseek接入个人知识库
· CSnakes vs Python.NET:高效嵌入与灵活互通的跨语言方案对比
· Plotly.NET 一个为 .NET 打造的强大开源交互式图表库
历史上的今天:
2021-05-15 现在学生物出路真有那么不济吗?(@Mengjie Chen)
点击右上角即可分享
微信分享提示