LLVM17-学习指南-全-
LLVM17 学习指南(全)
原文:
zh.annas-archive.org/md5/4eb785ee29cbcd619aa463737309bc4d
译者:飞龙
前言
构建编译器是一项复杂而有趣的任务。LLVM 项目为您提供了可重用的组件,LLVM 核心库实现了一个世界级的优化代码生成器,它将所有流行 CPU 架构的机器代码的源语言无关的中间表示翻译成源代码。许多编程语言的编译器已经利用了 LLVM 技术。
本书教您如何实现自己的编译器以及如何使用 LLVM 来实现它。您将学习编译器的前端如何将源代码转换为抽象语法树,以及如何从中生成中间表示(IR)。此外,您还将探索向编译器添加优化管道,这允许您将 IR 编译成高效的机器代码。
LLVM 框架可以通过多种方式扩展,您将学习如何添加新的传递,甚至为 LLVM 添加一个全新的后端。本书还涵盖了高级主题,例如为不同的 CPU 架构编译以及使用您自己的插件和检查器扩展 clang 和 clang 静态分析器。本书采用实用方法,并包含大量示例源代码,这使得将所学知识应用于自己的项目变得容易。
本版新增内容
学习 LLVM 17 现在新增了一章,专门介绍在 LLVM 中使用的 TableGen 语言的概要和语法,读者可以利用它来定义类、记录以及整个 LLVM 后端。此外,本书还强调了后端开发,讨论了可以用于 LLVM 后端实现的各种新后端概念,例如实现 GlobalISel 指令框架和开发机器函数传递。
本书面向的对象
本书面向编译器开发者、爱好者以及有兴趣了解 LLVM 框架的工程师。对于希望使用基于编译器的工具进行代码分析和改进的 C++ 软件工程师,以及希望深入了解 LLVM 核心知识的 LLVM 库的普通用户来说,本书也非常有用。为了更有效地理解本书中涵盖的概念,需要具备中级 C++ 编程经验。
本书涵盖的内容
第一章,安装 LLVM,解释了如何设置和使用您的开发环境。在章节末尾,您将编译 LLVM 库并学习如何自定义构建过程。
第二章,编译器的结构,为您提供了编译器组件的概述。在章节末尾,您将实现您的第一个编译器,生成 LLVM IR。
第三章, 将源文件转换为抽象语法树,详细介绍了如何实现编译器的前端。你将为一个小型编程语言创建自己的前端,最终构建一个抽象语法树。
第四章, IR 代码生成基础,展示了如何从抽象语法树生成 LLVM IR。本章结束时,你将实现一个示例语言的编译器,生成汇编文本或目标代码文件作为结果。
第五章, 高级语言构造的 IR 生成,说明了如何将源语言中常见于高级编程语言的功能翻译成 LLVM IR。你将了解聚合数据类型的翻译,实现类继承和虚函数的各种选项,以及如何遵守系统的应用程序二进制接口。
第六章, 高级 IR 生成,展示了如何生成源语言中异常处理语句的 LLVM IR。你还将学习如何为基于类型的别名分析添加元数据,以及如何将调试信息添加到生成的 LLVM IR 中,并扩展你的编译器生成的元数据。
第七章, 优化 IR,解释了 LLVM 的 Pass 管理器。你将实现自己的 Pass,既作为 LLVM 的一部分,也作为插件,并学习如何将你的新 Pass 添加到优化 Pass 管道中。
第八章, TableGen 语言,介绍了 LLVM 自己的领域特定语言 TableGen。这种语言用于减少开发者的编码工作量,你将了解在 TableGen 语言中定义数据的不同方式,以及它如何在后端得到利用。
第九章, 即时编译,讨论了如何使用 LLVM 实现即时(JIT)编译器。到本章结束时,你将以两种不同的方式实现了自己的 LLVM IR 即时编译器。
第十章, 使用 LLVM 工具进行调试,探讨了 LLVM 的各种库和组件的细节,这有助于你识别应用程序中的错误。你将使用 sanitizers 来识别缓冲区溢出和其他错误。使用 libFuzzer 库,你可以用随机数据作为输入测试函数,XRay 将帮助你找到性能瓶颈。你将使用 clang 静态分析器在源级别识别错误,并了解你可以向分析器添加自己的检查器。你还将学习如何通过自己的插件扩展 clang。
第十一章**, 目标描述,解释了您如何添加对新 CPU 架构的支持。本章讨论了必要的和可选的步骤,如定义寄存器和指令、开发指令选择以及支持汇编器和反汇编器。
第十二章,指令选择,展示了两种不同的指令选择方法,具体解释了 SelectionDAG 和 GlobalISel 的工作原理,并展示了如何根据上一章的示例在目标中实现这些功能。此外,您还将学习如何调试和测试指令选择。
第十三章,超越指令选择,解释了您如何通过探索指令选择以外的概念来完成后端实现。这包括添加新的机器遍历来实现特定目标任务,并指导您了解一些高级主题,这些主题对于简单的后端不是必需的,但对于高度优化的后端可能很有趣,例如跨编译到另一个 CPU 架构。
要充分利用本书
您需要一个运行 Linux、Windows、Mac OS X 或 FreeBSD 的计算机,并已安装操作系统所需的开发工具链。请参阅表格了解所需的工具。所有工具都应位于您的 shell 的搜索路径中。
本书涵盖的软件/硬件 | 操作系统要求 |
---|---|
一个 C/C++ 编译器:gcc 7.1.0 或更高版本,clang 5.0 或更高版本,Apple clang 10.0 或更高版本,Visual Studio 2019 16.7 或更高版本 | Linux(任何版本),Windows,Mac OS X 或 FreeBSD |
CMake 3.20.0 或更高版本 | |
Ninja 1.11.1 | |
Python 3.6 或更高版本 | |
Git 2.39.1 或更高版本 |
要在 第十章,使用 LLVM 工具进行调试 中创建火焰图,您需要安装来自 github.com/brendangregg/FlameGraph
的脚本。要运行脚本,您还需要安装最新的 Perl 版本,并且要查看图表,您需要一个能够显示 SVG 文件的网页浏览器,所有现代浏览器都可以做到。要查看同一章中的 Chrome 跟踪查看器可视化,您需要安装 Chrome 浏览器。
如果您正在使用本书的数字版,我们建议您亲自输入代码或通过 GitHub 仓库(下一节中提供链接)访问代码。这样做将帮助您避免与代码的复制和粘贴相关的任何潜在错误。
下载示例代码文件
您可以从 GitHub 下载本书的示例代码文件:github.com/PacktPublishing/Learn-LLVM-17
。如果代码有更新,它将在现有的 GitHub 仓库中更新。
我们还有其他来自我们丰富的图书和视频目录的代码包可供下载,请访问github.com/PacktPublishing/
。查看它们!
使用的约定
本书使用了多种文本约定。
文本中的代码
:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 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";
}
粗体:表示新术语、重要单词或您在屏幕上看到的单词。例如,菜单或对话框中的单词在文本中显示如下。以下是一个示例:“在 OS X 上的开发中,最好从 Apple 商店安装Xcode。”
小贴士或重要注意事项
看起来像这样。
联系我们
我们欢迎读者的反馈。
一般反馈:如果您对本书的任何方面有疑问,请在邮件主题中提及书名,并通过 mailto:customercare@packtpub.com 与我们联系。
勘误:尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,我们将不胜感激,如果您能向我们报告,我们将不胜感激。请访问www.packtpub.com/support/errata,选择您的书籍,点击勘误提交表单链接,并输入详细信息。
盗版:如果您在互联网上以任何形式发现我们作品的非法副本,如果您能提供位置地址或网站名称,我们将不胜感激。请通过 mailto:copyright@packt.com 与我们联系,并提供材料的链接。
如果您有兴趣成为作者:如果您在某个主题上具有专业知识,并且您有兴趣撰写或为本书做出贡献,请访问authors.packtpub.com。
分享您的想法
一旦您阅读了Learn LLVM 17,我们很乐意听到您的想法!请点击此处直接转到本书的亚马逊评论页面并分享您的反馈。
您的评论对我们和科技社区都很重要,并将帮助我们确保我们提供高质量的内容。
下载本书的免费 PDF 副本
感谢您购买本书!
您喜欢在路上阅读,但无法携带您的印刷书籍到处走?
您的电子书购买是否与您选择的设备不兼容?
别担心,现在每购买一本 Packt 图书,您都可以免费获得该书的 DRM 免费 PDF 版本。
在任何地方、任何设备上阅读。直接从您最喜欢的技术书籍中搜索、复制和粘贴代码到您的应用程序中。
优惠远不止这些,您还可以获得独家折扣、时事通讯和每日邮箱中的优质免费内容
按照以下简单步骤获取好处:
- 扫描下面的二维码或访问以下链接
https://packt.link/free-ebook/9781837631346
-
提交您的购买证明
-
就这么简单!我们将直接将您的免费 PDF 和其他好处发送到您的邮箱
第一部分:使用 LLVM 的编译器构建基础
在本节中,你将学习如何自己编译 LLVM 并根据你的需求定制构建。你将了解 LLVM 项目的组织方式,并创建你的第一个使用 LLVM 的项目。最后,你将探索编译器的整体结构,同时创建一个小型编译器。
本节包含以下章节:
-
第一章, 安装 LLVM
-
第二章, 编译器的结构
第一章:安装 LLVM
为了学习如何与 LLVM 一起工作,最好从源代码编译 LLVM 开始。LLVM 是一个伞形项目,GitHub 存储库包含 LLVM 所属所有项目的源代码。每个 LLVM 项目都在存储库的顶级目录中。除了克隆存储库外,您的系统还必须安装构建系统所需的全部工具。在本章中,您将学习以下主题:
-
准备先决条件,这将向您展示如何设置您的构建系统
-
克隆存储库并从源代码构建,这将涵盖如何获取 LLVM 源代码,以及如何使用 CMake 和 Ninja 编译和安装 LLVM 核心库和 clang
-
自定义构建过程,这将讨论影响构建过程的多种可能性
编译 LLVM 与安装二进制文件
您可以从各种来源安装 LLVM 二进制文件。如果您使用 Linux,那么您的发行版包含 LLVM 库。为什么还要自己编译 LLVM 呢?
首先,并非所有安装包都包含开发 LLVM 所需的所有文件。自己编译和安装 LLVM 可以防止这个问题。另一个原因源于 LLVM 的高度可定制性。通过构建 LLVM,您将学习如何自定义 LLVM,这将使您能够诊断在将您的 LLVM 应用程序带到另一个平台时可能出现的任何问题。最后,在本书的第三部分,您将扩展 LLVM 本身,为此,您需要自己构建 LLVM 的技能。
然而,在第一步避免编译 LLVM 是完全可以接受的。如果您想走这条路,那么您只需要安装下一节中描述的先决条件。
注意
许多 Linux 发行版将 LLVM 分割成几个包。请确保您安装了开发包。例如,在 Ubuntu 上,您需要安装 llvm-dev
包。请确保您安装了 LLVM 17。对于其他版本,本书中的示例可能需要更改。
准备先决条件
要使用 LLVM,您的开发系统应运行常见的操作系统,例如 Linux、FreeBSD、macOS 或 Windows。您可以在不同的模式下构建 LLVM 和 clang。启用调试符号的构建可能需要高达 30 GB 的空间。所需的磁盘空间很大程度上取决于选择的构建选项。例如,仅以发布模式构建 LLVM 核心库,针对单一平台,需要大约 2 GB 的空闲磁盘空间,这是最低需求。
为了减少编译时间,一个快速的 CPU(例如,2.5 GHz 时钟速度的四核 CPU)和快速的 SSD 也是很有帮助的。甚至可以在像 Raspberry Pi 这样的小型设备上构建 LLVM – 它只需要很多时间。本书中的示例是在一个配备英特尔四核 CPU,运行在 2.7 GHz 时钟速度,40 GB RAM 和 2.5 TB SSD 磁盘空间的笔记本电脑上开发的。这个系统非常适合开发任务。
您的开发系统必须安装一些先决软件。让我们回顾这些软件包的最小所需版本。
要从 GitHub 检出源代码,你需要 Git (git-scm.com/
)。没有特定版本的要求。GitHub 帮助页面建议使用至少版本 1.17.10。由于过去发现的安全问题,建议使用最新的可用版本,即写作时的 2.39.1。
LLVM 项目使用 CMake (cmake.org/
) 作为构建文件生成器。至少需要 3.20.0 版本。CMake 可以为各种构建系统生成构建文件。本书中使用 Ninja (ninja-build.org/
),因为它速度快且适用于所有平台。建议使用最新版本,1.11.1。
显然,你还需要一个 C/C++ 编译器。LLVM 项目是用现代 C++ 编写的,基于 C++17 标准。需要一个符合标准的编译器和标准库。以下编译器已知与 LLVM 17 兼容:
-
gcc 7.1.0 或更高版本
-
clang 5.0 或更高版本
-
Apple clang 10.0 或更高版本
-
Visual Studio 2019 16.7 或更高版本
小贴士
请注意,随着 LLVM 项目的进一步发展,编译器的需求很可能会发生变化。一般来说,你应该使用适用于您系统的最新编译器版本。
Python (python.org/
) 在生成构建文件和运行测试套件时使用。它至少应该是 3.8 版本。
虽然本书没有涉及,但可能有原因需要使用 Make 而不是 Ninja。在这种情况下,对于以下描述的场景,需要在每个命令中使用 make
和 ninja
。
LLVM 还依赖于 zlib
库 (www.zlib.net/
)。你应该至少安装了 1.2.3.4 版本。像往常一样,我们建议使用最新版本,1.2.13。
要安装先决软件,最简单的方法是使用操作系统的包管理器。在以下章节中,将展示为最流行的操作系统安装软件所需的命令。
Ubuntu
Ubuntu 22.04 使用 apt
包管理器。大多数基本实用工具已经安装;只有开发工具缺失。要一次性安装所有包,请输入以下命令:
$ sudo apt -y install gcc g++ git cmake ninja-build zlib1g-dev
Fedora 和 RedHat
Fedora 37 和 RedHat Enterprise Linux 9 的包管理器称为 dnf
。和 Ubuntu 一样,大多数基本工具已经安装。要一次性安装所有包,你可以输入以下命令:
$ sudo dnf –y install gcc gcc-c++ git cmake ninja-build \
zlib-devel
FreeBSD
在 FreeBSD 13 或更高版本上,你必须使用 pkg
包管理器。FreeBSD 与基于 Linux 的系统不同,因为 clang 编译器已经安装。要一次性安装所有其他包,你可以输入以下命令:
$ sudo pkg install –y git cmake ninja zlib-ng
OS X
对于 OS X 上的开发,最好从 Apple Store 安装 Xcode。虽然本书中没有使用 Xcode IDE,但它包含了所需的 C/C++ 编译器和支持工具。对于其他工具的安装,可以使用包管理器 Homebrew (brew.sh/
)。要一次性安装所有包,你可以输入以下命令:
$ brew install git cmake ninja zlib
Windows
和 OS X 一样,Windows 没有自带包管理器。对于 C/C++ 编译器,你需要下载 Visual Studio Community 2022 (visualstudio.microsoft.com/vs/community/
),这是个人使用的免费软件。请确保你安装了名为 Desktop Development with C++ 的工作负载。你可以使用包管理器 Scoop (scoop.sh/
) 来安装其他包。按照网站上的说明安装 Scoop 后,从你的 Windows 菜单中打开 x64 Native Tools 命令提示符 for VS 2022。要安装所需的包,你可以输入以下命令:
$ scoop install git cmake ninja python gzip bzip2 coreutils
$ scoop bucket add extras
$ scoop install zlib
请密切关注 Scoop 的输出。对于 Python 和 zlib
包,它会建议添加一些注册表键。这些条目是必需的,以便其他软件可以找到这些包。要添加注册表键,你最好复制并粘贴 Scoop 的输出,如下所示:
$ %HOMEPATH%\scoop\apps\python\current\install-pep-514.reg
$ %HOMEPATH%\scoop\apps\zlib\current\register.reg
每个命令之后,注册表编辑器会弹出一个消息窗口询问你是否真的想要导入那些注册表键。你需要点击 是 来完成导入。现在所有先决条件都已安装。
对于本书中的所有示例,你必须使用 VS 2022 的 x64 Native Tools 命令提示符。使用此命令提示符,编译器会自动添加到搜索路径。
小贴士
LLVM 代码库非常大。为了舒适地导航源代码,我们建议使用一个允许你跳转到类定义并搜索源代码的 IDE。我们发现 Visual Studio Code (code.visualstudio.com/download
),这是一个可扩展的跨平台 IDE,非常易于使用。然而,这并不是遵循本书中示例的必要条件。
克隆仓库并从源代码构建。
准备好构建工具后,你现在可以从 GitHub 检出所有 LLVM 项目并构建 LLVM。这个过程在所有平台上基本上是相同的:
-
配置 Git。
-
克隆仓库。
-
创建构建目录。
-
生成构建系统文件。
-
最后,构建并安装 LLVM。
让我们从配置 Git 开始。
配置 Git
LLVM 项目使用 Git 进行版本控制。如果您之前没有使用过 Git,那么在继续之前,您应该先进行一些基本的 Git 配置:设置用户名和电子邮件地址。这两项信息在提交更改时都会使用。
您可以使用以下命令检查是否已经在 Git 中配置了之前的电子邮件和用户名:
$ git config user.email
$ git config user.name
前面的命令将输出您在使用 Git 时已经设置的相应电子邮件和用户名。然而,如果您是第一次设置用户名和电子邮件,可以输入以下命令进行首次配置。在以下命令中,您可以将Jane
替换为您自己的名字,将jane@email.org
替换为您自己的电子邮件:
$ git config --global user.email "jane@email.org"
$ git config --global user.name "Jane"
这些命令会更改全局 Git 配置。在 Git 仓库内部,您可以通过不指定--global
选项来本地覆盖这些值。
默认情况下,Git 使用vi编辑器来编辑提交信息。如果您更喜欢其他编辑器,那么您可以通过类似的方式更改配置。要使用nano编辑器,您需要输入以下命令:
$ git config --global core.editor nano
关于 Git 的更多信息,请参阅Git 版本控制 食谱 (www.packtpub.com/product/git-version-control-cookbook-second-edition/9781789137545
)。
现在您已经准备好从 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/17.x”)和标签(“llvmorg-17.0.1”)来标记特定的发布。使用前面的克隆命令,您将获得当前的开发状态。本书使用 LLVM 17。要将 LLVM 17 的第一个发布版本检出到一个名为llvm-17
的分支,您需要输入以下命令:
$ git checkout -b llvm-17 llvmorg-17.0.1
通过前面的步骤,您已经克隆了整个仓库并从标签创建了一个分支。这是最灵活的方法。
Git 还允许你仅克隆一个分支或一个标签(包括历史记录)。使用 git clone --branch release/17.x https://github.com/llvm/llvm-project
,你只克隆 release/17.x
分支及其历史记录。这样,你就拥有了 LLVM 17 发布分支的最新状态,因此如果你需要确切的发布版本,你只需像以前一样从发布标签创建一个分支即可。使用额外的 –-depth=1
选项,这被称为 Git 的浅克隆,你还可以防止克隆历史记录。这节省了时间和空间,但显然限制了你在本地可以做的事情,包括基于发布标签检出分支。
创建构建目录
与许多其他项目不同,LLVM 不支持内联构建并需要一个单独的构建目录。最简单的方法是在 llvm-project
目录内创建,这是你的当前目录。为了简单起见,让我们将构建目录命名为 build
。在这里,Unix 和 Windows 系统的命令不同。在类 Unix 系统上,你使用以下命令:
$ mkdir build
在 Windows 上,使用以下命令:
$ md build
现在,你已准备好在这个目录内使用 CMake 工具创建构建系统文件。
生成构建系统文件
为了生成使用 Ninja 编译 LLVM 和 clang 的构建系统文件,你运行以下命令:
$ cmake -G Ninja -DCMAKE_BUILD_TYPE=Release \
-DLLVM_ENABLE_PROJECTS=clang -B build -S llvm
-G
选项告诉 CMake 为哪个系统生成构建文件。该选项常用的值如下:
-
Ninja
– 用于 Ninja 构建系统 -
Unix Makefiles
– 用于 GNU Make -
Visual Studio 17 VS2022
– 用于 Visual Studio 和 MS Build -
Xcode
– 用于 Xcode 项目
使用 –B
选项,你告诉 CMake 构建目录的路径。同样,使用 –S
选项指定源目录。生成过程可以通过设置 –D
选项中的各种变量来影响。通常,它们以 CMAKE_
(如果由 CMake 定义)或 LLVM_
(如果由 LLVM 定义)为前缀。
如前所述,我们还对在 LLVM 旁边编译 clang 感兴趣。通过设置 LLVM_ENABLE_PROJECTS=clang
变量,这允许 CMake 生成 clang 的构建文件,除了 LLVM。此外,CMAKE_BUILD_TYPE=Release
变量告诉 CMake 应该生成发布构建的构建文件。
–G
选项的默认值取决于你的平台,构建类型的默认值取决于工具链。然而,你可以使用环境变量定义自己的偏好。CMAKE_GENERATOR
变量控制生成器,而 CMAKE_BUILD_TYPE
变量指定构建类型。如果你使用 bash 或类似的 shell,那么你可以使用以下方式设置变量:
$ export CMAKE_GENERATOR=Ninja
$ export CMAKE_BUILD_TYPE=Release
如果你使用 Windows 命令提示符,那么你可以使用以下方式设置变量:
$ set CMAKE_GENERATOR=Ninja
$ set CMAKE_BUILD_TYPE=Release
使用这些设置,创建构建系统文件的命令变为以下内容,这更容易输入:
$ cmake -DLLVM_ENABLE_PROJECTS=clang -B build -S llvm
你可以在 自定义构建过程 部分找到更多关于 CMake 变量的信息。
编译和安装 LLVM
在生成构建文件后,可以使用以下方式编译 LLVM 和 clang:
$ cmake –-build build
此命令在底层运行 Ninja,因为我们告诉 CMake 在配置步骤中生成 Ninja 文件。然而,如果您为支持多个构建配置的系统(如 Visual Studio)生成构建文件,则需要使用 --config
选项指定用于构建的配置。根据硬件资源,此命令的运行时间在 15 分钟(具有大量 CPU 核心、内存和快速存储的服务器)到数小时(双核 Windows 笔记本,内存有限)之间。
默认情况下,Ninja 会利用所有可用的 CPU 核心。这对于编译速度是有好处的,但可能会阻止其他任务运行;例如,在基于 Windows 的笔记本电脑上,当 Ninja 运行时几乎无法上网。幸运的是,您可以使用 --j
选项限制资源使用。
假设您有四个 CPU 核心可用,而 Ninja 应仅使用两个(因为您有并行任务要运行);然后您使用此命令进行编译:
$ cmake --build build –j2
编译完成后,一个最佳实践是运行测试套件以检查是否一切按预期工作:
$ cmake --build build --target check-all
再次强调,此命令的运行时间会因可用硬件资源而大不相同。check-all
Ninja 目标会运行所有测试用例。为包含测试用例的每个目录生成目标。使用 check-llvm
而不是 check-all
将运行 LLVM 测试但不运行 clang 测试;check-llvm-codegen
仅运行 LLVM 的 CodeGen
目录中的测试(即 llvm/test/CodeGen
目录)。
您还可以进行快速的手动检查。LLVM 应用程序中的一个选项是 -version
,它显示 LLVM 版本、主机 CPU 和所有支持的架构:
$ build/bin/llc --version
如果您在编译 LLVM 时遇到问题,那么您应该查阅 Getting Started with the LLVM System 文档中的 常见问题 部分 https://releases.llvm.org/17.0.1/docs/GettingStarted.html#common-problems) 以获取典型问题的解决方案。
作为最后一步,您可以安装二进制文件:
$ cmake --install build
在类 Unix 系统上,安装目录是 /usr/local
。在 Windows 上,使用 C:\Program Files\LLVM
。当然,这也可以更改。下一节将解释如何更改。
自定义构建过程
CMake 系统使用 CMakeLists.txt
文件中的项目描述。顶级文件位于 llvm
目录中,llvm/CMakeLists.txt
。其他目录也有 CMakeLists.txt
文件,在生成过程中递归包含。
根据项目描述中提供的信息,CMake 会检查已安装的编译器,检测库和符号,并创建构建系统文件,例如build.ninja
或Makefile
(取决于选择的生成器)。还可能定义可重用的模块,例如检测 LLVM 是否已安装的函数。这些脚本放置在特殊的cmake
目录(llvm/cmake
)中,在生成过程中会自动搜索。
构建过程可以通过 CMake 变量的定义进行自定义。命令行选项–D
用于将变量设置为一个值。变量在 CMake 脚本中使用。由 CMake 本身定义的变量几乎总是以CMAKE_
为前缀,并且这些变量可以在所有项目中使用。由 LLVM 定义的变量以LLVM_
为前缀,但只有在项目定义中包含了对 LLVM 的使用时才能使用。
由 CMake 定义的变量
一些变量使用环境变量的值进行初始化。最显著的是CC
和CXX
,它们定义了用于构建的 C 和 C++编译器。CMake 会尝试自动定位 C 和 C++编译器,使用当前 shell 搜索路径。它会选择找到的第一个编译器。如果你安装了多个编译器,例如 gcc 和 clang 或不同版本的 clang,那么这可能不是你用于构建 LLVM 的编译器。
假设你希望使用 clang17 作为 C 编译器,clang++17 作为 C++编译器。那么,你可以在 Unix shell 中以以下方式调用 CMake:
$ CC=clang17 CXX=clang++17 cmake –B build –S llvm
这只为cmake
的调用设置环境变量的值。如果需要,你可以指定编译器可执行文件的绝对路径。
CC
是CMAKE_C_COMPILER
CMake 变量的默认值,CXX
是CMAKE_CXX_COMPILER
CMake 变量的默认值。而不是使用环境变量,你可以直接设置 CMake 变量。这相当于前面的调用:
$ cmake –DCMAKE_C_COMPILER=clang17 \
-DCMAKE_CXX_COMPILER=clang++17 –B build –S 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 :优化大小的构建默认的构建类型是从 CMAKE_BUILD_TYPE 环境变量中获取的。如果此变量未设置,则默认值取决于使用的工具链,通常为空。为了生成发布构建的构建文件,您指定 -DCMAKE_BUILD_TYPE=RELEASE 。 |
CMAKE_C_FLAGS |
CMAKE_CXX_FLAGS |
CMAKE_MODULE_PATH |
这指定了搜索 CMake 模块的附加目录。指定的目录在默认目录之前被搜索。该值是一个以分号分隔的目录列表。 |
PYTHON_EXECUTABLE |
如果未找到 Python 解释器或您安装了多个版本而选择了错误的版本,您可以设置此变量为 Python 二进制的路径。此变量仅在包含 CMake Python 模块(对于 LLVM 是这种情况)时才有效。 |
表 1.1 - CMake 提供的附加有用变量
CMake 为变量提供了内置的帮助。--help-variable var
选项打印 var
变量的帮助。例如,您可以输入以下内容以获取 CMAKE_BUILD_TYPE
的帮助:
$ cmake --help-variable CMAKE_BUILD_TYPE
您也可以使用以下命令列出所有变量:
$ cmake --help-variable-list
此列表非常长。您可能希望将输出通过 more
或类似程序管道输出。
使用由 LLVM 定义的构建配置变量
由 LLVM 定义的构建配置变量与由 CMake 定义的变量工作方式相同,只是没有内置的帮助。最有用的变量可以在以下表中找到,其中它们被分为对首次安装 LLVM 的用户有用的变量,以及更高级的 LLVM 用户使用的变量。
对首次安装 LLVM 的用户有用的变量
变量名称 | 用途 |
---|---|
LLVM_TARGETS_TO_BUILD |
LLVM 支持为不同的 CPU 架构生成代码。默认情况下,构建所有这些目标。使用此变量来指定要构建的目标列表,由分号分隔。当前的目标包括 AArch64 、AMDGPU 、ARM 、AVR 、BPF 、Hexagon 、Lanai 、LoongArch 、Mips 、MSP430 、NVPTX 、PowerPC 、RISCV 、Sparc 、SystemZ 、VE 、WebAssembly 、X86 和 XCore 。all 可以用作所有目标的简称。名称是区分大小写的。要仅启用 PowerPC 和 System Z 目标,你指定 -DLLVM_TARGETS_TO_BUILD="PowerPC;SystemZ" 。 |
LLVM_EXPERIMENTAL_TARGETS_TO_BUILD |
除了官方的目标之外,LLVM 源代码树还包含实验性目标。这些目标处于开发中,通常还不支持后端的所有功能。当前实验性目标的列表包括 ARC 、CSKY 、DirectX 、M68k 、SPIRV 和 Xtensa 。要构建 M68k 目标,你指定 -D LLVM_EXPERIMENTAL_TARGETS_TO_BUILD=M68k 。 |
LLVM_ENABLE_PROJECTS |
这是你要构建的项目列表,由分号分隔。项目的源必须在 llvm 目录同一级别(并排布局)。当前的列表包括 bolt 、clang 、clang-tools-extra 、compiler-rt 、cross-project-tests 、libc 、libclc 、lld 、lldb 、mlir 、openmp 、polly 和 pstl 。all 可以用作此列表中所有项目的简称。此外,你还可以在此处指定 flang 项目。由于一些特殊的构建要求,它目前还不是 all 列表的一部分。要一起构建 clang 和 bolt 与 LLVM,你指定 -DLLVM_ENABLE_PROJECT="clang;bolt" 。 |
表 1.2 - 首次使用 LLVM 用户的有用变量
LLVM 的高级用户变量
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 |
如果提供了 -version 命令行选项,LLVM 工具(如 llc )除了显示其他信息外,还会显示它们基于的 LLVM 版本。这个版本信息基于 LLVM_REVISION C 宏。默认情况下,LLVM 版本以及当前的 Git 哈希值都是版本信息的一部分。如果你正在跟踪 master 分支的开发,这很有用,因为它清楚地表明工具基于哪个 Git 提交。如果不需要,则可以使用 –DLLVM_APPEND_VC_REV=OFF 来关闭它。 |
LLVM_ENABLE_THREADS |
如果检测到线程库(通常是pthreads 库),LLVM 会自动包含线程支持。此外,在这种情况下,LLVM 假设编译器支持-DLLVM_ENABLE_THREADS=OFF 。 |
LLVM_ENABLE_EH |
LLVM 项目不使用 C++异常处理,因此默认关闭异常支持。此设置可能与项目链接的其他库不兼容。如果需要,可以通过指定–DLLVM_ENABLE_EH=ON 来启用异常支持。 |
LLVM_ENABLE_RTTI |
LLVM 使用一个轻量级、自建的运行时类型信息系统。默认情况下关闭 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 。 |
表 1.3 - 高级 LLVM 用户的有用变量
注意
LLVM 定义了许多其他 CMake 变量。您可以在 LLVM 关于 CMake 的文档中找到完整的列表releases.llvm.org/17.0.1/docs/CMake.html#llvm-specific-variables
。上述列表仅包含您最可能需要的变量。
摘要
在本章中,你已准备好你的开发机器以编译 LLVM。你已克隆了 GitHub 仓库并编译了你自己的 LLVM 和 clang 版本。构建过程可以通过 CMake 变量进行自定义。你了解了有用的变量以及如何更改它们。掌握了这些知识,你可以根据需要调整 LLVM。
在下一节中,我们将更深入地探讨编译器的结构。我们将探讨编译器内部的不同组件,以及在其中发生的不同类型的分析——特别是词法、语法和语义分析。最后,我们还将简要介绍与用于代码生成的 LLVM 后端进行接口连接。
第二章:编译器的结构
编译技术是计算机科学中一个研究得很好的领域。高级任务是将源语言翻译成机器代码。通常,这个任务被分为三个部分,前端、中间端和后端。前端主要处理源语言,而中间端执行转换以改进代码,后端负责生成机器代码。由于 LLVM 核心库提供了中间端和后端,因此在本章中我们将重点关注前端。
在本章中,你将涵盖以下部分和主题:
-
编译器的构建块,其中你将了解在编译器中通常可以找到的组件
-
算术表达式语言,将介绍一个示例语言并展示如何使用语法来定义语言
-
词法分析,讨论如何为语言实现一个词法分析器
-
语法分析,涵盖了从语法构建解析器的构造
-
语义分析,其中你将了解如何实现语义检查
-
使用 LLVM 后端进行代码生成,讨论如何与 LLVM 后端接口并将所有前面的阶段粘合在一起以创建一个完整的编译器
编译器的构建块
自从计算机变得可用以来,已经开发了数千种编程语言。结果证明,所有编译器都必须解决相同的问题,并且编译器的实现最好是根据这些任务来结构化。在高级别上,有三个组件。前端将源代码转换为中间表示(IR)。然后中间端对 IR 进行转换,目的是提高性能或减少代码的大小。最后,后端从 IR 生成机器代码。LLVM 核心库提供了由非常复杂的转换和所有流行平台的后端组成的中间端。此外,LLVM 核心库还定义了一个中间表示,用作中间端和后端的输入。这种设计的好处是,你只需要关注你想要实现的编程语言的前端。
前端输入是源代码,通常是文本文件。为了理解它,前端首先识别语言的单词,如数字和标识符,通常称为标记。这一步由词法分析器执行。接下来,分析由标记形成的句法结构。所谓的解析器执行这一步,结果是抽象语法树(AST)。最后,前端需要检查编程语言的规则是否被遵守,这是通过语义分析器完成的。如果没有检测到错误,那么 AST 将转换为 IR 并传递给中间端。
在接下来的章节中,我们将构建一个表达式语言的编译器,它将输入转换为 LLVM IR。然后,代表后端的 LLVM llc
静态编译器可以用来将 IR 编译成目标代码。一切始于定义语言。请记住,本章中所有文件的 C++ 实现都将包含在一个名为 src/
的目录中。
算术表达式语言
算术表达式是每种编程语言的一部分。以下是一个名为 calc 的算术表达式计算语言的示例。calc 表达式被编译成一个应用程序,该应用程序评估以下表达式:
with a, b: a * (4 + b)
表达式中所使用的变量必须用关键字 with
声明。这个程序被编译成一个应用程序,该应用程序会询问用户 a
和 b
变量的值,并打印结果。
示例总是受欢迎的,但作为一个编译器编写者,你需要比这更详尽的规范来进行实现和测试。编程语言语法的载体是语法。
编程语言语法的形式化规范
语言元素,例如,关键字、标识符、字符串、数字和运算符,被称为标记。在这个意义上,程序是一系列标记的序列,而语法指定了哪些序列是有效的。
通常,语法是用扩展的巴科斯-诺尔范式(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"
、,
和 ":"
是代表这个字符串的标记。括号用于分组。一个分组可以是可选的或可重复的。在闭括号后面的问号 (?
) 表示一个可选分组。星号 *
表示零个或多个重复,而加号 +
表示一个或多个重复。Ident
和 expr
是非终结符。对于它们中的每一个,都存在另一个规则。分号 (;
) 标记规则的结束。在第二行中,竖线 |
表示选择。最后,在最后两行中,方括号 [ ]
表示字符类。有效的字符写在方括号内。例如,字符类 [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 字符串及其长度的指针。因为长度被存储,字符串不需要以零字符('\x00'
)结尾,就像正常的 C 字符串一样。这允许 StringRef
实例指向由 MemoryBuffer
管理的内存。
在此基础上,我们开始实现 Lexer
类:
-
首先,
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 };
除了为每个标记定义一个成员外,我们还添加了两个额外的值:
eoi
和unknown
。eoi
代表 输入结束,当处理完输入的所有字符时返回。unknown
用于词法层面的错误事件,例如,#
不是语言的标记,因此会被映射到unknown
。 -
除了枚举之外,该类还有一个
Text
成员,它指向标记文本的开始。它使用之前提到的StringRef
类:private: TokenKind Kind; llvm::StringRef Text; public: TokenKind getKind() const { return Kind; } llvm::StringRef getText() const { return Text; }
这对于语义处理很有用,例如,对于一个标识符,知道其名称是有用的。
-
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...); } };
-
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 字符串一样)。 -
让我们在
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
是否被视为有符号或无符号类型,这会导致可移植性问题。
-
从上一节中的语法,我们知道该语言的所有标记。但是语法并没有定义应该忽略的字符。例如,空格或换行符仅添加空白,通常会被忽略。
next()
方法开始时就会忽略这些字符:void Lexer::next(Token &token) { while (*BufferPtr && charinfo::isWhitespace(*BufferPtr)) { ++BufferPtr; }
-
接下来,确保还有字符需要处理:
if (!*BufferPtr) { token.Kind = Token::eoi; return; }
至少有一个字符需要处理。
-
我们首先检查字符是否为小写或大写。在这种情况下,标记要么是一个标识符,要么是
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()
私有方法用于填充标记。 -
接下来,我们检查一个数字。这段代码与前面的代码非常相似:
else if (charinfo::isDigit(*BufferPtr)) { const char *end = BufferPtr + 1; while (charinfo::isDigit(*end)) ++end; formToken(token, end, Token::number); return; }
现在只剩下由固定字符串定义的标记。
-
这可以通过
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
-
最后,我们需要检查意外的字符:
default: formToken(token, BufferPtr + 1, Token::unknown); } return; } }
只缺少
formToken()
私有辅助方法。 -
它填充了
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 功能的头文件。这是发出错误消息所需的:
-
Parser
类首先声明了一些私有成员:class Parser { Lexer &Lex; Token Tok; bool HasError;
Lex
和Tok
是前几节中类的实例。Tok
存储下一个标记(前瞻),Lex
用于从输入中检索下一个标记。HasError
标志指示是否检测到错误。 -
几个方法处理标记:
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。 -
对于语法中的每个非终结符,声明了一个解析规则的方法:
AST *parseCalc(); Expr *parseExpr(); Expr *parseTerm(); Expr *parseFactor();
注意:
对于 ident
和 number
没有方法。这些规则只返回标记,并被相应的标记替换。
-
公共接口如下。构造函数初始化所有成员并从词法分析器中检索第一个标记:
public: Parser(Lexer &Lex) : Lex(Lex), HasError(false) { advance(); }
-
需要一个函数来获取错误标志的值:
bool hasError() { return HasError; }
-
最后,
parse()
方法是解析的主要入口点:AST *parse(); }; #endif
解析器实现
让我们深入了解解析器的实现!
-
我们在
Parser.cpp
文件中的实现以parse()
方法开始:#include "Parser.h" AST *Parser::parse() { AST *Res = parseCalc(); expect(Token::eoi); return Res; }
parse()
方法的要点是整个输入已经被消耗。你还记得第一部分的解析示例添加了一个特殊符号来表示输入的结束吗?我们在这里检查它。 -
parseCalc()
方法实现了相应的规则。值得仔细看看这个方法,因为其他解析方法遵循相同的模式。让我们回忆一下第一部分的规则:calc : ("with" ident ("," ident)* ":")? expr ;
-
方法以声明一些局部变量开始:
AST *Parser::parseCalc() { Expr *E; llvm::SmallVector<llvm::StringRef, 8> Vars;
-
首先要做的决定是是否必须解析可选组。组以
with
标记开始,所以我们比较标记与此值:if (Tok.is(Token::KW_with)) { advance();
-
接下来,我们期望一个标识符:
if (expect(Token::ident)) goto _error; Vars.push_back(Tok.getText()); advance();
如果有一个标识符,则将其保存到
Vars
向量中。否则,它是语法错误,将单独处理。 -
在语法中接下来是一个重复组,它解析更多的标识符,用逗号分隔:
while (Tok.is(Token::comma)) { advance(); if (expect(Token::ident)) goto _error; Vars.push_back(Tok.getText()); advance(); }
到现在为止,这应该不会令人惊讶。重复组以标记(
,
)开始。对标记的测试成为while
循环的条件,实现零次或多次重复。循环内的标识符处理方式与之前相同。 -
最后,可选组需要在末尾有一个冒号:
if (consume(Token::colon)) goto _error; }
-
最后,必须解析
expr
的规则:E = parseExpr();
-
通过这个调用,规则的解析成功完成。现在收集到的信息被用来创建这个规则的 AST 节点:
if (Vars.empty()) return E; else return new WithDecl(Vars, E);
现在只缺少错误处理。检测语法错误很容易,但从中恢复却出人意料地复杂。在这里,使用了一种称为恐慌模式的简单方法。
在恐慌模式下,从标记流中删除标记,直到找到一个解析器可以用来继续其工作的标记。大多数编程语言都有表示结束的符号,例如,在 C++中,有;
(语句结束)或}
(块结束)。这样的标记是寻找的好候选。
另一方面,错误可能是因为我们正在寻找的符号缺失。在这种情况下,解析器在继续之前可能已经删除了大量的标记。这并不像听起来那么糟糕。如今,编译器速度快更重要。一旦发生错误,开发者会查看第一条错误信息,修复它,然后重新启动编译器。这与使用穿孔卡片的情况截然不同,当时尽可能多地获取错误信息很重要,因为下一次编译器的运行可能只有第二天。
错误处理
而不是使用一些任意的标记来查找,这里使用另一组标记。对于每个非终结符,都有一个可以跟随该非终结符的标记集合:
-
在
calc
的情况下,只有输入的结束符跟随这个非终结符。实现很简单:_error: while (!Tok.is(Token::eoi)) advance(); return nullptr; }
-
其他解析方法的结构类似。
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()
方法的使用如何简化了对多个标记的检查。 -
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()
非常相似,你可能想将它们合并为一个。在语法中,可以有一个规则处理乘法和加法运算符。使用两个规则的优势在于,这样运算符的优先级与数学评估顺序很好地匹配。如果你将两个规则合并,那么你需要在其他地方确定评估顺序。 -
最后,你需要实现
factor
的规则:Expr *Parser::parseFactor() { Expr *Res = nullptr; switch (Tok.getKind()) { case Token::number: Res = new Factor(Factor::Number, Tok.getText()); advance(); break;
与使用一系列
if
和else if
语句相比,这里使用switch
语句似乎更合适,因为每个备选方案都只从单个标记开始。一般来说,你应该考虑你喜欢的翻译模式。如果你以后需要更改解析方法,那么如果每个方法没有不同的语法规则实现方式,这将是一个优点。 -
如果你使用
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();
我们在这里保护发出错误信息,因为存在跌落。
-
如果括号表达式中有语法错误,那么已经发出了错误信息。保护防止第二个错误信息:
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 合著的《编译器:原理、技术和工具》。
抽象语法树
解析过程的结果是 AST。AST 是输入程序的另一种紧凑表示。它捕获了关键信息。许多编程语言都有作为分隔符但不含进一步意义的符号。例如,在 C++中,分号;
表示单个语句的结束。当然,这个信息对于解析器来说很重要。一旦我们将语句转换为内存表示,分号就不再重要了,可以省略。
如果你查看示例表达式语言的第一条规则,那么很明显,with
关键字、逗号(,
)和冒号(:
)对于程序的意义并不重要。重要的是声明的变量列表,这些变量可以用在表达式中。结果是,只需要几个类来记录信息:Factor
保存数字或标识符,BinaryOp
保存算术运算符和表达式的左右两侧,WithDecl
存储声明的变量列表和表达式。AST
和Expr
仅用于创建一个公共类层次结构。
除了解析输入的信息外,使用AST.h
头文件进行树遍历:
-
它从访问者接口开始:
#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; };
访问者模式需要知道要访问的每个类。因为每个类也引用了访问者,所以我们将在文件顶部声明所有类。请注意,
AST
和Expr
的visit()
方法有一个默认实现,它什么都不做。 -
AST
类是层次结构的根:class AST { public: virtual ~AST() {} virtual void accept(ASTVisitor &V) = 0; };
-
类似地,
Expr
是AST
相关表达式类的根:class Expr : public AST { public: Expr() {} };
-
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
类。 -
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
类在乘法和加法运算符之间没有区别。运算符的优先级在树结构中隐式可用。 -
最后,
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 后端生成代码
后端的任务是从模块的 LLVM IR 生成优化的机器代码。IR 是后端接口,可以使用 C++ 接口或文本形式创建。同样,IR 是从 AST 生成的。
LLVM IR 的文本表示
在尝试生成 LLVM IR 之前,应该清楚我们想要生成什么。对于我们的示例表达式语言,高级计划如下:
-
询问用户每个变量的值。
-
计算表达式的值。
-
打印结果。
要让用户提供变量的值并打印结果,使用了两个库函数:calc_read()
和 calc_write()
。对于 with a: 3*a
表达式,生成的 IR 如下:
-
库函数必须像在 C 中一样声明。语法也类似于 C。函数名前的类型是返回类型。括号内的类型名是参数类型。声明可以出现在文件的任何位置:
declare i32 @calc_read(ptr) declare void @calc_write(i32)
-
calc_read()
函数接受变量名作为参数。以下构造定义了一个常量,包含a
和用作 C 中字符串终止符的空字节:@a.str = private constant [2 x i8] c"a\00"
-
它跟随
main()
函数。省略了参数名称,因为它们没有被使用。就像在 C 中一样,函数体被括号包围:define i32 @main(i32, ptr) {
-
每个基本块都必须有一个标签。因为这个是函数的第一个基本块,我们将其命名为
entry
:entry:
-
调用
calc_read()
函数读取a
变量的值。嵌套的getelemenptr
指令执行索引计算以计算字符串常量第一个元素的指针。函数结果被赋值给未命名的%2
变量。%2 = call i32 @calc_read(ptr @a.str)
-
接下来,变量被乘以
3
:%3 = mul nsw i32 3, %2
-
通过调用
calc_write()
函数将结果打印到控制台:call void @calc_write(i32 %3)
-
最后,
main()
函数返回0
以指示执行成功:ret i32 0 }
LLVM IR 中的每个值都有类型,其中 i32
表示 32 位整数类型,ptr
表示指针。
注意
LLVM 的早期版本使用有类型的指针。例如,在 LLVM 中,字节的指针表示为 i8*。自 LLVM 16 以来,使用 ptr
。
由于现在已经很清楚 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
文件实现如下:
-
所需的包含在文件顶部:
#include "CodeGen.h" #include "llvm/ADT/StringMap.h" #include "llvm/IR/IRBuilder.h" #include "llvm/IR/LLVMContext.h" #include "llvm/Support/raw_ostream.h"
-
使用 LLVM 库的命名空间进行名称查找:
using namespace llvm;
-
首先,在访问者中声明一些私有成员。每个编译单元在 LLVM 中由
Module
类表示,访问者有一个指向模块的指针,称为M
。为了方便 IR 生成,使用Builder
(类型为IRBuilder<>)
。LLVM 有一个类层次结构来表示 IR 中的类型。您可以从 LLVM 上下文中查找基本类型,如i32
的实例。这些基本类型使用非常频繁。为了避免重复查找,我们缓存所需类型实例:
VoidTy
、Int32Ty
、PtrTy
和Int32Zero
。V
成员是当前计算值,它通过树遍历更新。最后,nameMap
将变量名映射到calc_read()
函数返回的值:namespace { class ToIRVisitor : public ASTVisitor { Module *M; IRBuilder<> Builder; Type *VoidTy; Type *Int32Ty; PointerType *PtrTy; Constant *Int32Zero; Value *V; StringMap<Value *> nameMap;
-
构造函数初始化所有成员:
public: ToIRVisitor(Module *M) : M(M), Builder(M->getContext()) { VoidTy = Type::getVoidTy(M->getContext()); Int32Ty = Type::getInt32Ty(M->getContext()); PtrTy = PointerType::getUnqual(M->getContext()); Int32Zero = ConstantInt::get(Int32Ty, 0, true); }
-
对于每个函数,必须创建一个
FunctionType
实例。在 C++术语中,这被称为函数原型。函数本身是通过Function
实例定义的。run()
方法首先在 LLVM IR 中定义main()
函数:void run(AST *Tree) { FunctionType *MainFty = FunctionType::get( Int32Ty, {Int32Ty, PtrTy}, false); Function *MainFn = Function::Create( MainFty, GlobalValue::ExternalLinkage, "main", M);
-
然后我们创建带有
entry
标签的BB
基本块,并将其附加到 IR 构建器:BasicBlock *BB = BasicBlock::Create(M->getContext(), "entry", MainFn); Builder.SetInsertPoint(BB);
-
准备工作完成后,可以开始树遍历:
Tree->accept(*this);
-
树遍历完成后,通过调用
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});
-
生成过程通过从
main()
函数返回0
结束:Builder.CreateRet(Int32Zero); }
-
WithDecl
节点包含声明的变量名。首先,我们为calc_read()
函数创建一个函数原型:virtual void visit(WithDecl &Node) override { FunctionType *ReadFty = FunctionType::get(Int32Ty, {PtrTy}, false); Function *ReadFn = Function::Create( ReadFty, GlobalValue::ExternalLinkage, "calc_read", M);
-
方法遍历变量名:
for (auto I = Node.begin(), E = Node.end(); I != E; ++I) {
-
对于每个变量,创建一个包含变量名的字符串:
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"));
-
然后创建调用
calc_read()
函数的 IR 代码。上一步创建的字符串作为参数传递:CallInst *Call = Builder.CreateCall(ReadFty, ReadFn, {Str});
-
返回值存储在
mapNames
映射中,以供以后使用:nameMap[Var] = Call; }
-
树遍历继续进行到表达式:
Node.getExpr()->accept(*this); };
-
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); } };
-
最后,对于
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; } }; }; }
-
这样,访问者类就完成了。
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,并调用前几节的所有阶段:
-
首先,我们包含所需的头文件:
#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"
-
LLVM 自带一套用于声明命令行选项的系统。你只需要为每个需要的选项声明一个静态变量。这样做时,选项会通过全局命令行解析器进行注册。这种方法的优点是每个组件可以在需要时添加命令行选项。我们声明了一个用于输入表达式的选项:
static llvm::cl::opt<std::string> Input(llvm::cl::Positional, llvm::cl::desc("<input expression>"), llvm::cl::init(""));
-
在
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");
-
接下来,我们调用词法分析和语法分析器。在语法分析之后,我们检查是否发生了任何错误。如果是这种情况,则通过返回代码指示失败退出编译器:
Lexer Lex(Input); Parser Parser(Lex); AST *Tree = Parser.parse(); if (!Tree || Parser.hasError()) { llvm::errs() << "Syntax errors occured\n"; return 1; }
-
如果存在语义错误,我们也会这样做:
Sema Semantic; if (Semantic.semantic(Tree)) { llvm::errs() << "Semantic errors occured\n"; return 1; }
-
在驱动程序的最后一个步骤中,调用代码生成器:
CodeGen CodeGenerator; CodeGenerator.compile(Tree); return 0; }
现在我们已经成功为用户输入创建了一些 IR 代码。我们将目标代码生成委托给 LLVM 的 llc
静态编译器,这样我们就完成了编译器的实现。我们将所有组件链接在一起以创建 calc
应用程序。
运行时库由一个文件 rtcalc.c
组成。它包含了 calc_read()
和 calc_write()
函数的实现,这些函数是用 C 编写的:
#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 的应用程序。
构建并测试 calc 应用程序
为了构建 calc
,我们首先需要在原始 src
目录之外创建一个新的 CMakeLists.txt
文件,该文件包含所有源文件实现:
-
首先,我们将所需的最低 CMake 版本设置为 LLVM 所需的版本,并将项目命名为
calc
:cmake_minimum_required (VERSION 3.20.0) project ("calc")
-
接下来,需要加载 LLVM 包,并将 LLVM 提供的 CMake 模块目录添加到搜索路径:
find_package(LLVM REQUIRED CONFIG) message("Found LLVM ${LLVM_PACKAGE_VERSION}, build type ${LLVM_BUILD_TYPE}") list(APPEND CMAKE_MODULE_PATH ${LLVM_DIR})
-
我们还需要添加来自 LLVM 的定义和包含路径。使用的 LLVM 组件通过函数调用映射到库名称:
separate_arguments(LLVM_DEFINITIONS_LIST NATIVE_COMMAND ${LLVM_DEFINITIONS}) add_definitions(${LLVM_DEFINITIONS_LIST}) include_directories(SYSTEM ${LLVM_INCLUDE_DIRS}) llvm_map_components_to_libnames(llvm_libs Core)
-
最后,我们指出需要将
src
子目录包含在我们的构建中,因为这个目录包含了本章内完成的全部 C++ 实现:add_subdirectory ("src")
在 src
子目录内还需要有一个新的 CMakeLists.txt
文件。这个位于 src
目录中的 CMake 描述如下。我们简单地定义了可执行文件的名字,称为 calc
,然后列出要编译的源文件和要链接的库:
add_executable (calc
Calc.cpp CodeGen.cpp Lexer.cpp Parser.cpp Sema.cpp)
target_link_libraries(calc PRIVATE ${llvm_libs})
最后,我们可以开始构建 calc
应用程序。在 src
目录之外,我们创建一个新的构建目录并切换到该目录。之后,我们可以按照以下方式运行 CMake 和构建调用:
$ cmake -GNinja -DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++ -DLLVM_DIR=<path to llvm installation configuration> ../
$ ninja
现在我们应该有一个新构建的、功能齐全的 calc
应用程序,它可以生成 LLVM IR 代码。这可以进一步与 llc
一起使用,llc
是 LLVM 静态后端编译器,用于将 IR 代码编译成目标文件。
然后,你可以使用你喜欢的 C 编译器来链接到小的运行时库。在 Unix 的 X86 上,你可以输入以下内容:
$ calc "with a: a*3" | llc –filetype=obj \
-relocation-model=pic –o=expr.o
$ clang –o expr expr.o rtcalc.c
$ expr
Enter a value for a: 4
The result is: 12
在其他 Unix 平台,如 AArch64 或 PowerPC 上,你必须移除 -relocation-model=pic
选项。
在 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
语言的知识,它是真实编程语言的一个子集,并且你将为它实现编译器前端 -
组织编译器项目的目录结构
-
了解如何处理编译器的多个输入文件
-
处理用户消息并以愉快的方式通知他们问题的技巧
-
使用模块化组件构建词法分析器
-
从语法规则中构建递归下降解析器以执行语法分析
-
通过创建 AST 并分析其特征来执行语义分析
通过本章你将获得的技能,你将能够为任何编程语言构建编译器前端。
定义一个真正的编程语言
真实编程带来的挑战比上一章的简单 calc 语言要多。为了查看细节,我们将在这章和下一章中使用Modula-2的一个小子集。Modula-2 设计良好,并可选择支持tinylang
。
让我们从tinylang
程序的一个例子开始。以下函数使用欧几里得算法计算最大公约数:
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
子集的语法。在接下来的几节中,我们将使用这个语法从中推导出词法分析和解析器:
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 非常相似。仅支持 INTEGER
和 BOOLEAN
数据类型。
此外,还使用了 identifier
和 integer_literal
标记。一个 H
。
这些规则已经很多了,而我们只覆盖了 Modula-2 的一部分!尽管如此,在这个子集中仍然可以编写小型应用程序。让我们来实现一个 tinylang
编译器!
创建项目布局
tinylang
的项目布局遵循我们在 第一章 中概述的方法,即 安装 LLVM。每个组件的源代码位于 lib
目录的子目录中,头文件位于 include/tinylang
目录的子目录中。子目录以组件命名。在 第一章 中,安装 LLVM,我们只创建了 Basic
组件。
从上一章,我们知道我们需要实现一个词法分析器、一个解析器、一个抽象语法树(AST)和一个语义分析器。每个都是其自身的组件,分别称为 Lexer
、Parser
、AST
和 Sema
。本章将使用的目录结构如下所示:
图 3.1 – tinylang
项目的目录结构
组件有明确定义的依赖关系。Lexer
只依赖于 Basic
。Parser
依赖于 Basic
、Lexer
、AST
和 Sema
。Sema
只依赖于 Basic
和 AST
。明确的依赖关系有助于我们重用组件。
让我们更详细地看看实现过程!
管理编译器的输入文件
一个真正的编译器必须处理许多文件。通常,开发者使用主编译单元的名称调用编译器。这个编译单元可以引用其他文件——例如,通过 C 中的#include
指令或 Python 或 Modula-2 中的import
语句。一个导入的模块可以导入其他模块,依此类推。所有这些文件都必须加载到内存中,并经过编译器的分析阶段。在开发过程中,开发者可能会犯语法或语义错误。当检测到错误时,应该打印出包括源行和标记的错误消息。这个基本组件并不简单。
幸运的是,LLVM 提供了一个解决方案:llvm::SourceMgr
类。通过调用AddNewSourceBuffer()
方法,可以向SourceMgr
添加一个新的源文件。或者,可以通过调用AddIncludeFile()
方法来加载一个文件。这两种方法都返回一个 ID 来标识缓冲区。你可以使用这个 ID 来检索关联文件的内存缓冲区的指针。为了在文件中定义一个位置,你可以使用llvm::SMLoc
类。这个类封装了一个指向缓冲区的指针。各种PrintMessage()
方法允许你向用户发出错误和其他信息性消息。
处理用户消息
只缺少消息的集中定义。在一个大型软件(如编译器)中,你不想将消息字符串散布在各个地方。如果有更改消息或将其翻译成其他语言的需求,那么最好将它们放在一个中心位置!
一种简单的方法是,每条消息都有一个 ID(一个enum
成员),一个严重程度级别,如Error
或Warning
,以及包含消息的字符串。在你的代码中,你只引用消息 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];
}
SourceMgr
类和 DiagnosticsEngine
类的组合为其他组件提供了一个良好的基础。我们首先将在词法分析器中使用它们!
结构化词法分析器
正如我们从上一章所知,我们需要一个 Token
类和一个 Lexer
类。此外,还需要一个 TokenKind
枚举来为每个令牌类分配一个唯一的数字。将所有内容放在一个头文件和实现文件中并不易于扩展,因此让我们移动这些项。TokenKind
可以被普遍使用,并放置在 Basic
组件中。Token
和 Lexer
类属于 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
派生的。有两个特殊情况:
-
首先,我们需要定义
TOK
和KEYWORD
宏,因为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++。一般来说,你必须小心避免一些陷阱,因为你在互联网上找到的大多数语法都不适合这种构建。
语法和解析器。
有两种不同的解析器类型:自顶向下的解析器和自底向上的解析器。它们的名称来源于解析过程中处理规则顺序。解析器的输入是由词法分析器生成的标记序列。
自顶向下的解析器会扩展规则中的最左边的符号,直到匹配到一个标记。如果所有标记都被消耗并且所有符号都被扩展,解析就成功了。这正是 tinylang 解析器的工作方式。
自底向上的解析器做的是相反的事情:它查看标记序列,并尝试用语法的符号替换标记。例如,如果下一个标记是IF
、3
、+
和4
,那么自底向上的解析器将3 + 4
标记替换为expression
符号,从而得到IF
expression
序列。当看到属于IF
语句的所有标记时,这个标记和符号序列就被替换为ifStatement
符号。
解析成功是指所有标记都被消耗,并且剩下的唯一符号是起始符号。虽然自顶向下解析器可以很容易地手工构建,但对于自底向上解析器来说并非如此。
通过首先扩展哪些符号来描述这两种类型的解析器是另一种方法。两者都是从左到右读取输入,但自顶向下解析器首先扩展最左边的符号,而自底向上解析器首先扩展最右边的符号。因此,自顶向下解析器也被称为 LL 解析器,而自底向上解析器被称为 LR 解析器。
语法必须具有某些属性,以便从中导出 LL 或 LR 解析器。这些语法相应地命名:你需要一个 LL 语法来构建一个 LL 解析器。
你可以在关于编译器构造的大学教科书中找到更多详细信息,例如 Wilhelm, Seidl, 和 Hack 的 Compiler Design. Syntactic and Semantic Analysis,Springer 2013,以及 Grune 和 Jacobs 的 Parsing Techniques, A practical guide,Springer 2008。
一个需要关注的问题是左递归规则。如果一个规则的右侧以左侧相同的终结符开始,则该规则被称为左递归。一个典型的例子可以在表达式语法中找到:
expression : expression "+" term ;
如果语法本身没有明确,那么将其翻译成 C++ 就会使这种无限递归的结果变得明显:
Void Parser::parseExpression() {
parseExpression();
consume(tok::plus);
parseTerm();
}
左递归也可以间接发生并涉及更多规则,这要难于发现得多。这就是为什么存在一个算法可以检测并消除左递归。
注意
左递归规则仅是 LL 解析器的问题,例如 tinylang
的递归下降解析器。原因是这些解析器首先扩展最左边的符号。相比之下,如果你使用解析器生成器生成一个 LR 解析器,它首先扩展最右边的符号,那么你应该避免右递归规则。
在每一步中,解析器通过仅使用前瞻标记来决定如何继续。如果这个决定不能确定性地做出,则语法存在冲突。为了说明这一点,看看 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
语句中存在语法问题,那么你会跳过所有令牌,直到找到一个分号。然后,你继续执行下一个语句。而不是使用针对令牌集的特定定义,使用系统性的方法会更好。
对于每个非终结符,你计算可以跟随非终结符的令牌集合(称为;
、ELSE
和END
令牌可以跟随。因此,你必须在parseStatement()
的错误恢复部分使用这个集合。这种方法假设语法错误可以在本地处理。通常情况下,这是不可能的。因为解析器会跳过令牌,所以可能会跳过很多令牌,直到达到输入的末尾。在这个点上,局部恢复是不可能的。
为了防止出现无意义的错误信息,调用方法需要被告知错误恢复尚未完成。这可以通过bool
来实现。如果它返回true
,这意味着错误恢复尚未完成,而false
表示解析(包括可能的错误恢复)已成功。
有许多方法可以扩展这个错误恢复方案。使用活动调用者的FOLLOW
集合是一种流行的方法。作为一个简单的例子,假设parseStatement()
被parseStatementSequence()
调用,而parseStatementSequence()
本身又被parseBlock()
和parseModule()
调用。
在这里,每个相应的非终结符都有一个FOLLOW
集合。如果解析器在parseStatement()
中检测到语法错误,则跳过标记直到标记至少属于活动调用者的一个FOLLOW
集合。如果标记在语句的FOLLOW
集合中,则错误被局部恢复,并返回一个false
值给调用者。否则,返回一个true
值,意味着错误恢复必须继续。为此扩展的一个可能的实现策略是将std::bitset
或std::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;
}
解析器和词法分析器生成器
手动构建解析器和词法分析器可能是一项繁琐的任务,尤其是当你试图发明一种新的编程语言并且经常更改语法时。幸运的是,一些工具可以自动化这项任务。
经典的 Linux 工具是flex(github.com/westes/flex
)和bison(www.gnu.org/software/bison/
)。flex 从一组正则表达式生成词法分析器,而 bison 从语法描述生成LALR(1)解析器。这两个工具都生成 C/C+源代码,并且可以一起使用。
另一个流行的工具是AntLR(https://www.antlr.org/)。AntLR 可以从语法描述中生成一个词法分析器、一个解析器和 AST。生成的解析器属于LL(*)类别,这意味着它是一个自顶向下的解析器,使用可变数量的前瞻来解决冲突。这个工具是用 Java 编写的,但可以生成许多流行语言的源代码,包括 C/C++。
所有这些工具都需要一些库支持。如果你正在寻找一个可以生成自包含的词法分析器和解析器的工具,那么Coco/R(ssw.jku.at/Research/Projects/Coco/
)可能就是你要找的工具。Coco/R 可以从LL(1)语法描述生成一个词法分析器和递归下降解析器,类似于本书中使用的那个。生成的文件基于一个模板文件,如果需要可以更改。这个工具是用 C#编写的,但可以移植到 C++、Java 和其他语言。
有许多其他工具可供选择,它们在支持的特性和输出语言方面差异很大。当然,在选择工具时,也需要考虑权衡。例如,bison 这样的 LALR(1)解析器生成器可以消费广泛的语法,你可以在互联网上找到的免费语法通常都是 LALR(1)语法。
作为缺点,这些生成器生成的状态机需要在运行时进行解释,这可能会比递归下降解析器慢。错误处理也更复杂。bison 有处理语法错误的基本支持,但正确使用需要深入理解解析器的工作原理。相比之下,AntLR 消耗的语法类略小,但可以自动生成错误处理,还可以生成 AST。因此,重写语法以便与 AntLR 一起使用可能会加快后续的开发速度。
执行语义分析
我们在上一节中构建的解析器只检查输入的语法。下一步是添加执行语义分析的能力。在上一章的 calc 示例中,解析器构建了一个 AST。在单独的阶段,语义分析器处理这个树。这种方法始终可以使用。在本节中,我们将使用一种稍微不同的方法,并将解析器和语义分析器更紧密地交织在一起。
语义分析器需要做什么?让我们先看看:
-
对于每一次声明,必须检查变量、对象等的名称,以确保它们没有在其他地方声明过。
-
对于表达式或语句中名称的每一次出现,都必须检查该名称是否已声明,以及所需的使用是否符合声明。
-
对于每个表达式,必须计算其结果类型。还必须计算表达式是否为常量,如果是,它具有哪个值。
-
对于赋值和参数传递,我们必须检查类型是否兼容。此外,我们还必须检查
IF
和WHILE
语句中的条件是否为BOOLEAN
类型。
对于这样一个小子集的编程语言来说,已经有很多需要检查的内容了!
处理名称的作用域
首先看看名称的作用域。名称的作用域是名称可见的范围。像 C 语言一样,tinylang
使用声明先于使用模型。例如,B
和X
变量在模块级别声明为INTEGER
类型:
VAR B, X: INTEGER;
在声明之前,变量是未知的,不能使用。只有在声明之后才能这样做。在过程内部,可以声明更多变量:
PROCEDURE Proc;
VAR B: BOOLEAN;
BEGIN
(* Statements *)
END Proc;
在程序内部,在注释所在的位置,对B
的使用指的是局部变量B
,而对X
的使用指的是全局变量X
。局部变量B
的作用域是Proc
。如果当前作用域中找不到名称,则搜索将继续在封装作用域中进行。因此,可以在程序内部使用X
变量。在tinylang
中,只有模块和程序会打开新的作用域。其他语言结构,如结构体和类,通常也会打开作用域。预定义实体,如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::pair
的second
成员指示是否更新了表。此信息返回给调用者。
为了实现符号声明的搜索,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 节点的所有属性。在这种情况下,必须使用仅部分计算属性或仅使用普通信息(如 calc 示例)来构建 AST 节点。
然后,AST 必须被访问一次或多次以确定缺失的信息。在tinylang
(和 Modula-2)的情况下,可能不需要 AST 构造——AST 是通过parseXXX()
方法的调用层次间接表示的。从 AST 生成代码更为常见,所以我们在这里也构建了一个 AST。
在我们将这些部分组合在一起之前,我们需要了解 LLVM 使用运行时类型信息(RTTI)的风格。
使用 LLVM 风格的 RTTI 对 AST 进行操作
自然地,AST 节点是类层次结构的一部分。声明总是有一个名称。其他属性取决于正在声明的对象。如果一个变量被声明,则需要一个类型。常量声明需要一个类型、一个值等等。当然,在运行时,你需要找出你正在处理哪种类型的声明。可以使用dynamic_cast<>
C++运算符来完成这个任务。问题是,所需的 RTTI 仅在 C++类附加了虚拟表时才可用——也就是说,它使用了虚拟函数。另一个缺点是 C++ RTTI 很庞大。为了避免这些缺点,LLVM 开发者引入了一种自制的 RTTI 风格,该风格被用于整个 LLVM 库中。
我们层次结构的(抽象)基类是Decl
。为了实现 LLVM 风格的 RTTI,必须添加一个包含每个子类标签的公共枚举。还需要这个类型的私有成员和一个公共获取器。私有成员通常称为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
。
创建语义分析器
带着这些知识,我们现在可以实施所有部分。首先,我们必须在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 &[Loc, Name] : Ids) {
auto *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);
}
一些声明固有的存在,不能由开发者定义。这包括 BOOLEAN
和 INTEGER
类型以及 TRUE
和 FALSE
文本。这些声明存在于全局作用域中,必须通过程序添加。Modula-2 还预定义了一些过程,如 INC
或 DEC
,可以添加到全局作用域中。考虑到我们的类,初始化全局作用域很简单:
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 TheLexer = Lexer(SrcMgr, Diags);
auto TheSema = Sema(Diags);
auto TheParser = Parser(TheLexer, TheSema);
TheParser.parse();
}
}
恭喜!你已经完成了tinylang
的前端实现!你可以使用定义真实编程语言部分提供的示例程序Gcd.mod
来运行前端:
$ tinylang Gcd.mod
当然,这是一个有效的程序,看起来好像没有发生任何事情。务必修改文件并引发一些错误消息。我们将在下一章中继续添加代码生成,继续这项有趣的旅程。
摘要
在本章中,你学习了现实世界编译器在前端使用的技巧。从项目布局开始,你创建了用于词法分析器、解析器和语义分析器的单独库。为了向用户输出消息,你扩展了一个现有的 LLVM 类,允许消息集中存储。词法分析器现在被分割成几个接口。
然后,你学习了如何从语法描述中构建递归下降解析器,了解了要避免的陷阱,并学习了如何使用生成器来完成这项工作。你构建的语义分析器在解析器和 AST 构建过程中执行了语言所需的所有语义检查。
你的编码努力的结果是一个完全装饰的 AST。你将在下一章中使用它来生成 IR 代码,最终生成目标代码。
第四章:IR 代码生成基础
在为您的编程语言创建了一个装饰过的抽象语法树(AST)之后,接下来的任务是从它生成 LLVM IR 代码。LLVM IR 代码类似于具有人类可读表示的三地址代码。因此,我们需要一种系统性的方法来将诸如控制结构等语言概念转换为 LLVM IR 的低级形式。
在本章中,您将了解 LLVM IR 的基础知识以及如何从 AST 生成控制流结构的 IR。您还将学习如何使用现代算法生成 静态单赋值(SSA)形式的 LLVM IR。最后,您将学习如何生成汇编文本和目标代码。
本章将涵盖以下主题:
-
从 AST 生成 IR
-
使用 AST 编号生成 SSA 形式的 IR 代码
-
设置模块和驱动程序
到本章结束时,您将了解如何为您的编程语言创建代码生成器以及如何将其集成到您的编译器中。
从 AST 生成 IR
LLVM 代码生成器接受一个以 LLVM IR 表示的模块作为输入,并将其转换为目标代码或汇编文本。我们需要将 AST 表示转换为 IR。为了实现 IR 代码生成器,我们首先将查看一个简单的示例,然后开发代码生成器所需的类。完整的实现将分为三个类:
-
CodeGenerator
-
CGModule
-
CGProcedure
CodeGenerator
类是编译器驱动程序使用的通用接口。CGModule
和 CGProcedure
类包含生成编译单元和单个函数的 IR 代码所需的状态。
我们将首先查看 Clang 生成的 IR。
理解 IR 代码
在生成 IR 代码之前,了解 IR 语言的主要元素是很有帮助的。在 第二章,编译器的结构中,我们简要地了解了 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;
}
然后,您可以使用 clang
和以下命令创建 gcd.ll
IR 文件:
$ clang --target=aarch64-linux-gnu -O1 -S -emit-llvm gcd.c
IR 代码不是目标无关的,尽管它通常看起来是这样。前面的命令编译了 Linux 上 ARM 64 位 CPU 的源文件。-S
选项指示 clang
输出一个汇编文件,通过添加 -emit-llvm
的额外指定,创建了一个 IR 文件。使用 -O1
优化级别可以得到易于阅读的 IR 代码。Clang 有许多其他选项,所有这些都在 clang.llvm.org/docs/ClangCommandLineReference.html
的命令行参数参考中有记录。让我们看一下生成的文件,并了解 C 源代码如何映射到 LLVM IR。
C 文件被翻译成 i
,后面跟着位数。例如,64 位整数类型写作 i64
。最基本的浮点类型是 float
和 double
,分别表示 32 位和 64 位的 IEEE 浮点类型。还可以创建聚合类型,如向量、数组和结构体。
这里是 LLVM 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
字符串指定了我们正在为其编译的架构。这反映了我们在命令行上给出的信息。三元组是一个配置字符串,通常由 CPU 架构、供应商和操作系统组成。经常还会添加更多关于环境的信息。例如,x86_64-pc-win32
三元组用于在 64 位 X86 CPU 上运行的 Windows 系统。x86_64
是 CPU 架构,pc
是一个通用的供应商,win32
是操作系统。这些部分由连字符连接。在 ARMv8 CPU 上运行的 Linux 系统使用 aarch64-unknown-linux-gnu
作为其三元组。aarch64
是 CPU 架构,操作系统是运行在 gnu
环境下的 linux
。对于基于 Linux 的系统,实际上没有真正的供应商,所以这部分是 unknown
。对于特定目的不重要或不为人知的部分通常会被省略:aarch64-linux-gnu
三元组描述了相同的 Linux 系统。
接下来,在 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
。该块中的代码很简单:第一条指令比较 %b
参数与 0
。第二条指令在条件为 true
时跳转到 return
标签,在条件为 false
时跳转到 while.body
标签。
IR 代码的另一个特点是它位于 %cmp
中。然后使用这个寄存器,但从未再次写入。常量传播和公共子表达式消除等优化与 SSA 形式配合得很好,并且所有现代编译器都在使用它。
SSA
SSA 形式是在 1980 年代末开发的。从那时起,它被广泛用于编译器,因为它简化了数据流分析和优化。例如,如果 IR 是 SSA 形式,那么在循环内识别公共子表达式会变得容易得多。SSA 的一个基本特性是它建立了 def-use
和 use-def
链:对于单个定义,你知道所有的使用(def-use
),对于每个使用,你知道唯一的定义(use-def
)。这种知识被大量使用,例如在常量传播中:如果一个定义被确定为常量,那么这个值的所有使用都可以轻松地替换为那个常量值。
为了构建 SSA 形式,Cytron 等人于 1989 年提出的算法非常流行,并且也被用于 LLVM 实现。也开发出了其他算法。一个早期的观察是,如果源语言没有 goto
语句,这些算法会变得更简单。
在 F. Rastello 和 F. B. Tichadou 所著的《基于 SSA 的编译器设计》一书中可以找到对 SSA 的深入探讨,Springer 2022 年出版。
下一个基本块是 while
循环的主体:
while.body:
%b.loop = phi i32 [ %rem, %while.body ],
[ %b, %entry ]
%a.loop = phi i32 [ %b.loop, %while.body ],
[ %a, %entry ]
%rem = urem i32 %a.loop, %b.loop
%cmp1 = icmp eq i32 %rem, 0
br i1 %cmp1, label %return, label %while.body
在 gcd
循环内部,a
和 b
参数被分配了新的值。如果寄存器只能写入一次,那么这是不可能的。解决方案是使用特殊的 phi
指令。phi
指令有一个基本块列表和值作为参数。基本块表示从该基本块进入的边,值是来自该基本块的值。在运行时,phi
指令将之前执行的基本块的标签与参数列表中的标签进行比较。
指令的值是与标签关联的值。对于第一个 phi
指令,如果之前执行的基本块是 while.body
,则值是 %rem
寄存器。如果 entry
是之前执行的基本块,则值是 %b
。值是基本块开始时的值。%b.loop
寄存器从第一个 phi
指令获取值。在第二个 phi
指令的参数列表中使用相同的寄存器,但假设的值是它通过第一个 phi
指令更改之前的值。
在循环主体之后,必须选择返回值:
return:
%retval = phi i32 [ %a, %entry ],
[ %b.loop, %while.body ]
ret i32 %retval
}
再次,使用 phi
指令来选择所需的值。ret
指令不仅结束这个基本块,而且在运行时也标志着这个函数的结束。它将返回值作为参数。
对 phi
指令的使用有一些限制。它们必须是基本块的第一条指令。第一个基本块是特殊的:它没有之前执行过的块。因此,它不能以 phi
指令开始。
LLVM IR 参考
我们只接触了 LLVM IR 的基础知识。请访问 llvm.org/docs/LangRef.html
中的 LLVM 语言参考手册以查找所有详细信息。
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, ptr %4, align 4
store i32 %1, ptr %5, align 4
%7 = load i32, ptr %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
指向的内存槽中。为了比较 %1
与 0
,显式地从内存槽中加载了值。使用这种方法,你不需要使用 phi
指令!相反,你从内存槽中加载一个值,对其进行计算,然后将新值存储回内存槽。下次你读取内存槽时,你会得到最后计算出的值。gcd
函数的所有其他基本块都遵循这个模式。
以这种方式使用加载和存储指令的优点是生成 IR 代码相对容易。缺点是你会生成大量的 IR 指令,LLVM 将在将基本块转换为 SSA 形式后的第一个优化步骤中通过 mem2reg
过滤器删除这些指令。因此,我们直接以 SSA 形式生成 IR 代码。
我们将开始通过将控制流映射到基本块来开发 IR 代码生成。
将控制流映射到基本块
基本块的概念是它是一个 按顺序执行的线性指令序列。基本块在开始处恰好有一个入口,并以一个终止指令结束,这是一个将控制流转移到另一个基本块的指令,例如分支指令、切换指令或返回指令。有关终止指令的完整列表,请参阅 llvm.org/docs/LangRef.html#terminator-instructions
。基本块可以以 phi
指令开始,但在基本块内部不允许 phi
或分支指令。换句话说,你只能从第一个指令进入基本块,你只能从最后一个指令离开基本块,即终止指令。不可能从基本块内部的指令分支,也不可能从基本块中间分支到另一个基本块。请注意,使用 call
指令的简单函数调用可以在基本块内部发生。每个基本块恰好有一个标签,标记基本块的第一条指令。标签是分支指令的目标。你可以将分支视为两个基本块之间的有向边,从而形成 控制流图(CFG)。基本块可以有 前驱 和 后继。函数的第一个基本块在意义上是特殊的,不允许有前驱。
由于这些限制,源语言的控制语句,如 WHILE
和 IF
,会产生多个基块。让我们看看 WHILE
语句。WHILE
语句的条件控制循环体或下一个语句是否执行。条件必须在它自己的基块中生成,因为它有两个前驱:
-
由
WHILE
语句前的语句产生的基块 -
从循环体末尾返回到条件的分支
同时也有两个后继:
-
循环体的开始
-
由
WHILE
语句后的语句产生的基块
循环体本身至少有一个基块:
图 4.1 – WHILE 语句的基块
IR 代码生成遵循这个结构。我们在 CGProcedure
类中存储当前基块的指针,并使用 llvm::IRBuilder<>
实例将指令插入到基块中。首先,我们创建基本块:
void emitStmt(WhileStatement *Stmt) {
llvm::BasicBlock *WhileCondBB = llvm::BasicBlock::Create(
CGM.getLLVMCtx(), "while.cond", Fn);
llvm::BasicBlock *WhileBodyBB = llvm::BasicBlock::Create(
CGM.getLLVMCtx(), "while.body", Fn);
llvm::BasicBlock *AfterWhileBB = llvm::BasicBlock::Create(
CGM.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
语句。现在我们已经生成了 WhileCondBB
和 Curr
基块,我们可以将它们封闭:
sealBlock(WhileCondBB);
sealBlock(Curr);
WHILE
语句后的语句的空基块变为新的当前基块:
setCurr(AfterWhileBB);
}
按照这个模式,你可以为源语言中的每个语句创建一个 emit()
方法。
使用 AST 编号生成 SSA 形式的 IR 代码
要从 AST 生成 SSA 形式的 IR 代码,我们可以使用一种称为 AST 编号 的方法。基本思想是,对于每个基本块,我们存储在此基本块中写入的局部变量的当前值。
注意
实现基于 Braun 等人撰写的论文《简单且高效的静态单赋值形式构建》,发表于 2013 年国际编译器构造会议(CC 2013),Springer(见 http://individual.utoronto.ca/dfr/ece467/braun13.pdf)。在其呈现的形式中,它仅适用于具有结构化控制流的 IR 代码。该论文还描述了如果需要支持任意控制流(例如,goto
语句)所需的必要扩展。
虽然它很简单,但我们仍然需要几个步骤。我们首先介绍所需的数据结构,然后我们将学习如何读取和写入基本块本地的值。然后,我们将处理在多个基本块中使用的值,并通过优化创建的 phi
指令来结束。
定义用于存储值的数结构
我们使用 BasicBlockDef
结构体来保存单个块的信息:
struct BasicBlockDef {
llvm::DenseMap<Decl *, llvm::TrackingVH<llvm::Value>> Defs;
// ...
};
llvm::Value
类表示 SSA 形式中的值。Value
类类似于计算结果的标签。它一旦创建,通常通过 IR 指令创建,然后使用。在优化过程中可能会发生各种变化。例如,如果优化器检测到 %1
和 %2
值始终相同,那么它可以替换 %2
的使用为 %1
。这改变了标签,但没有改变计算。
为了意识到这样的变化,我们不能直接使用 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 生成后添加。这是因为这个基本块在之前是未知的。如果我们需要读取条件中变量的值,那么我们就陷入了困境,因为并非所有前驱都是已知的。
为了解决这个问题,我们必须做更多一点:
-
首先,我们必须给基本块附加一个
Sealed
标志。 -
然后,如果我们知道基本块的所有前驱,我们必须定义基本块为密封的。如果基本块未密封并且我们需要查找在此基本块中尚未定义的变量的值,那么我们必须插入一个空的
phi
指令并使用它作为值。 -
我们还需要记住这个指令。如果块后来被密封,那么我们需要更新指令以使用真实值。为了实现这一点,我们必须向
struct BasicBlockDef
添加两个额外的成员:IncompletePhis
映射,它记录了我们稍后需要更新的phi
指令,以及Sealed
标志,它表示基本块是否被密封:llvm::DenseMap<llvm::PHINode *, Decl *> IncompletePhis; unsigned Sealed : 1;
-
然后,可以按照本节开头讨论的方法实现该方法:
llvm::Value *CGProcedure::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); writeLocalVariable(BB, Decl, Phi); Val = addPhiOperands(BB, Decl, Phi); } writeLocalVariable(BB, Decl, Val); return Val; }
-
addEmptyPhi()
方法在基本块的开头插入一个空的phi
指令:llvm::PHINode * CGProcedure::addEmptyPhi(llvm::BasicBlock *BB, Decl *Decl) { return BB->empty() ? llvm::PHINode::Create(mapType(Decl), 0, "", BB) : llvm::PHINode::Create(mapType(Decl), 0, "", &BB->front()); }
-
要向
phi
指令添加缺失的操作数,首先,我们必须搜索基本块的所有前驱,并将操作数对值和基本块添加到phi
指令中。然后,我们必须尝试优化指令:llvm::Value * CGProcedure::addPhiOperands(llvm::BasicBlock *BB, Decl *Decl, llvm::PHINode *Phi) { for (auto *PredBB : llvm::predecessors(BB)) Phi->addIncoming(readLocalVariable(PredBB, Decl), PredBB); return optimizePhi(Phi); }
这个算法可能会生成不必要的phi
指令。在下一节中,我们将实现一种优化这些指令的方法。
优化生成的phi
指令
我们如何优化phi
指令,为什么我们应该这样做?尽管 SSA 形式对许多优化有利,但phi
指令通常不被算法解释,从而阻碍了整体的优化。因此,我们生成的phi
指令越少,越好。让我们更仔细地看看:
-
如果指令只有一个操作数或者所有操作数都有相同的值,那么我们用这个值替换指令。如果指令没有操作数,那么我们用特殊的
Undef
值替换指令。只有当指令有两个或更多不同的操作数时,我们才必须保留指令:llvm::Value * CGProcedure::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 Phi; Same = V; } if (Same == nullptr) Same = llvm::UndefValue::get(Phi->getType());
-
移除
phi
指令可能会导致其他phi
指令中的优化机会。幸运的是,LLVM 跟踪用户和值的用法(这是在 SSA 定义中提到的use-def
链)。我们必须搜索其他phi
指令中值的所有用法,并尝试优化这些指令:llvm::SmallVector<llvm::PHINode *, 8> CandidatePhis; for (llvm::Use &U : Phi->uses()) { if (auto *P = llvm::dyn_cast<llvm::PHINode>(U.getUser())) if (P != Phi) CandidatePhis.push_back(P); } Phi->replaceAllUsesWith(Same); Phi->eraseFromParent(); for (auto *P : CandidatePhis) optimizePhi(P); return Same; }
如果我们愿意,我们可以进一步改进这个算法。我们不必总是迭代每个 phi
指令的值列表,我们可以选择并记住两个不同的值。然后,在 optimizePhi
函数中,我们可以检查这两个值是否仍然在 phi
指令的列表中。如果是这样,那么我们就知道没有什么可以优化的。但即使没有这个优化,这个算法运行得非常快,所以我们现在不会实现它。
我们几乎完成了。我们还没有做的事情是实现封闭基本块的运算。我们将在下一节中这样做。
封闭一个块
一旦我们知道一个块的所有前驱都已知,我们就可以封闭这个块。如果源语言只包含结构化语句,如 tinylang
,那么确定可以封闭块的位置很容易。再次看看为 WHILE
语句生成的基块。
包含条件的基块在从主体末尾添加分支后可以封闭,因为这是最后一个缺失的前驱。要封闭一个块,我们可以简单地向不完整的 phi
指令添加缺失的操作数并设置标志:
void CGProcedure::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, false),
FormalParams[FP]);
} else
return readLocalVariable(BB, D);
} else
llvm::report_fatal_error("Unsupported declaration");
}
向变量或形式参数写入是对称的——我们只需要交换读取方法和写入方法,并使用 store
指令而不是 load
指令。
接下来,在为函数生成 IR 代码时应用这些函数。
生成函数的 IR 代码
大多数 IR 代码将存在于一个函数中。IR 代码中的函数类似于 C 语言中的函数。它在名称中指定了参数类型、返回值和其他属性。要调用不同编译单元中的函数,你需要声明该函数。这类似于 C 语言中的原型。如果你向函数中添加基本块,那么你就定义了该函数。我们将在接下来的几节中做所有这些,但首先,我们将讨论符号名称的可见性。
使用链接和名称混淆控制可见性
函数(以及全局变量)附有一个链接样式。通过链接样式,我们定义了符号名称的可见性以及当多个符号具有相同名称时应该发生什么。最基本的链接样式是 private
和 external
。具有 private
链接的符号仅在当前编译单元中可见,而具有 external
链接的符号在全局范围内可用。
对于没有适当模块概念的编程语言,如 C,这已经足够了。有了模块,我们需要做更多。假设我们有一个名为 Square
的模块,它提供了一个 Root()
函数和一个 Cube
模块,它也提供了一个 Root()
函数。如果函数是私有的,那么就没有问题。函数获得名称 Root
和私有链接。如果函数是导出的,以便可以从其他模块调用它,那么情况就不同了。仅使用函数名称是不够的,因为这个名称不是唯一的。
解决方案是调整名称以使其全局唯一。这被称为名称 Square.Root
,因为名称看起来像是一个明显的解决方案,但它可能导致汇编器出现问题,因为点号可能有特殊含义。我们可以在名称组件之间使用分隔符,而不是使用名称组件的长度作为前缀:6Square4Root
。这不是 LLVM 的有效标识符,但我们可以通过在名称前加上 _t
(代表 tinylang
)来修复这个问题:_t6Square4Root
。这样,我们可以为导出符号创建唯一的名称:
std::string CGModule::mangleName(Decl *D) {
std::string Mangled("_t");
llvm::SmallVector<llvm::StringRef, 4> List;
for (; D; D = D->getEnclosingDecl())
List.push_back(D->getName());
while (!List.empty()) {
llvm::StringRef Name = List.pop_back_val();
Mangled.append(
llvm::Twine(Name.size()).concat(Name).str());
}
return Mangled;
}
如果你的源语言支持类型重载,那么你需要通过类型名称扩展此方案。例如,为了区分 int root(int)
和 double root(double)
C++ 函数,必须将参数类型和返回值类型添加到函数名称中。
你还需要考虑生成的名称长度,因为一些链接器对长度有限制。在 C++中,嵌套命名空间和类可能导致名称变得相当长。在那里,C++定义了一种压缩方案,以避免重复名称组件。
接下来,我们将探讨如何处理参数。
将类型从 AST 描述转换为 LLVM 类型
函数的参数也需要考虑。首先,我们需要将源语言的类型映射到 LLVM 类型。由于 tinylang
目前只有两种类型,这很容易:
llvm::Type *CGModule::convertType(TypeDeclaration *Ty) {
if (Ty->getName() == "INTEGER")
return Int64Ty;
if (Ty->getName() == "BOOLEAN")
return Int1Ty;
llvm::report_fatal_error("Unsupported type");
}
Int64Ty
、Int1Ty
和VoidTy
是类成员,它们持有i64
、i1
和void
LLVM 类型的类型表示。
对于按引用传递的形式参数,这还不够。此参数的 LLVM 类型是指针。然而,当我们想要使用形式参数的值时,我们需要知道其底层类型。这由HonorReference
标志控制,其默认值为true
。我们泛化函数并考虑形式参数:
llvm::Type *CGProcedure::mapType(Decl *Decl,
bool HonorReference) {
if (auto *FP = llvm::dyn_cast<FormalParameterDeclaration>(
Decl)) {
if (FP->isVar() && HonorReference)
return llvm::PointerType::get(CGM.getLLVMCtx(),
/*AddressSpace=*/0);
return CGM.convertType(FP->getType());
}
if (auto *V = llvm::dyn_cast<VariableDeclaration>(Decl))
return CGM.convertType(V->getType());
return CGM.convertType(llvm::cast<TypeDeclaration>(Decl));
}
使用这些辅助工具,我们可以创建 LLVM IR 函数。
创建 LLVM IR 函数
要在 LLVM IR 中发射一个函数,需要一个函数类型,这类似于 C 中的原型。创建函数类型涉及映射类型,然后调用工厂方法创建函数类型:
llvm::FunctionType *CGProcedure::createFunctionType(
ProcedureDeclaration *Proc) {
llvm::Type *ResultTy = CGM.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 *
CGProcedure::createFunction(ProcedureDeclaration *Proc,
llvm::FunctionType *FTy) {
llvm::Function *Fn = llvm::Function::Create(
Fty, llvm::GlobalValue::ExternalLinkage,
CGM.mangleName(Proc), CGM.getModule());
getModule()
方法返回当前 LLVM 模块,我们将在稍后设置它。
函数创建后,我们可以添加一些关于它的更多信息:
-
首先,我们可以给出参数的名称。这使得 IR 更易于阅读。
-
其次,我们可以向函数及其参数添加属性以指定一些特性。作为一个例子,我们将为按引用传递的参数执行此操作。
在 LLVM 级别,这些参数是指针。但从源语言设计来看,这些是非常受限的指针。类似于 C++中的引用,我们始终需要为VAR
参数指定一个变量。因此,按照设计,我们知道这个指针永远不会为空,并且总是可解引用的,这意味着我们可以读取被指向的值而不会冒着引发一般保护故障的风险。此外,按照设计,这个指针不能被传递——特别是没有超出函数调用的指针副本。因此,这个指针被认为是未被捕获的。
llvm::AttributeBuilder
类用于构建形式参数的属性集。要获取参数类型的存储大小,我们可以简单地查询数据布局对象:
for (auto [Idx, Arg] : llvm::enumerate(Fn->args())) {
FormalParameterDeclaration *FP =
Proc->getFormalParams()[Idx];
if (FP->isVar()) {
llvm::AttrBuilder Attr(CGM.getLLVMCtx());
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 代码的发射!我们只需要将这些部分组合起来以发射一个函数,包括其主体:
-
给定
tinylang
中的过程声明,首先,我们将创建函数类型和函数:void CGProcedure::run(ProcedureDeclaration *Proc) { this->Proc = Proc; Fty = createFunctionType(Proc); Fn = createFunction(Proc, Fty);
-
接下来,我们将创建函数的第一个基本块并将其设置为当前块:
llvm::BasicBlock *BB = llvm::BasicBlock::Create( CGM.getLLVMCtx(), "entry", Fn); setCurr(BB);
-
然后,我们必须遍历所有形式参数。为了正确处理 VAR 参数,我们需要初始化
FormalParams
成员(在readVariable()
中使用)。与局部变量不同,形式参数在第一个基本块中有一个值,因此我们必须使这些值已知:for (auto [Idx, Arg] : llvm::enumerate(Fn->args())) { FormalParameterDeclaration *FP = Proc->getFormalParams()[Idx]; FormalParams[FP] = &Arg; writeLocalVariable(Curr, FP, &Arg); }
-
在此设置之后,我们可以调用
emit()
方法来开始生成语句的 IR 代码:auto Block = Proc->getStmts(); emit(Proc->getStmts());
-
在生成 IR 代码后的最后一个块可能尚未密封,因此我们现在必须调用
sealBlock()
。tinylang
中的过程可能有一个隐式的返回值,因此我们还需要检查最后一个基本块是否有适当的终止符,如果没有,则添加一个:if (!Curr->getTerminator()) { Builder.CreateRetVoid(); } sealBlock(Curr); }
这样,我们就完成了函数的 IR 代码生成。然而,我们仍然需要创建一个 LLVM 模块,它将所有 IR 代码组合在一起。我们将在下一节中这样做。
设置模块和驱动程序
我们在一个 LLVM 模块中收集编译单元的所有函数和全局变量。为了简化 IR 生成过程,我们可以将前几节中的所有函数包装到一个代码生成器类中。为了得到一个可工作的编译器,我们还需要定义我们想要为其生成代码的目标架构,并添加生成代码的传递。我们将在本章和接下来的几章中实现这一点,从代码生成器开始。
将所有内容包装在代码生成器中
IR 模块是我们为编译单元生成的所有元素的括号。在全局级别,我们遍历模块级别的声明,创建全局变量,并调用过程的代码生成。tinylang
中的全局变量映射到 llvm::GlobalValue
类的实例。这种映射保存在 Globals
中,并可供过程的代码生成使用:
void CGModule::run(ModuleDeclaration *Mod) {
for (auto *Decl : Mod->getDecls()) {
if (auto *Var =
llvm::dyn_cast<VariableDeclaration>(Decl)) {
// Create global variables
auto *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
类传递,该类在驱动程序中设置:
std::unique_ptr<llvm::Module>
CodeGenerator::run(ModuleDeclaration *Mod,
std::string FileName) {
std::unique_ptr<llvm::Module> M =
std::make_unique<llvm::Module>(FileName, Ctx);
M->setTargetTriple(TM->getTargetTriple().getTriple());
M->setDataLayout(TM->createDataLayout());
CGModule CGM(M.get());
CGM.run(Mod);
return M;
}
为了便于使用,我们还必须引入一个代码生成器的工厂方法:
CodeGenerator *
CodeGenerator::create(llvm::LLVMContext &Ctx,
llvm::TargetMachine *TM) {
return new CodeGenerator(Ctx, TM);
}
CodeGenerator
类提供了一个小的接口来创建 IR 代码,这对于在编译器驱动程序中使用是理想的。在我们将其集成之前,我们需要实现机器代码生成的支持。
初始化目标机器类
现在,只缺少目标机器。有了目标机器,我们定义了我们想要为其生成代码的 CPU 架构。对于每个 CPU,都有可用于影响代码生成过程的功能。例如,一个 CPU 架构家族的新 CPU 可以支持向量指令。通过功能,我们可以打开或关闭向量指令的使用。为了支持从命令行设置所有这些选项,LLVM 提供了一些支持代码。在 Driver
类中,我们可以添加以下 include
变量:
#include "llvm/CodeGen/CommandFlags.h"
这个include
变量将常见的命令行选项添加到我们的编译器驱动程序中。许多 LLVM 工具也使用这些命令行选项,这有利于为用户提供一个统一的接口。唯一缺少的是指定目标三元组的选项。由于这非常有用,我们将自己添加它:
static llvm::cl::opt<std::string> MTriple(
"mtriple",
llvm::cl::desc("Override target triple for module"));
让我们创建目标机器:
-
要显示错误消息,必须将应用程序的名称传递给函数:
llvm::TargetMachine * createTargetMachine(const char *Argv0) {
-
首先,我们必须收集命令行提供的所有信息。这些是代码生成器的选项——即 CPU 的名称以及应该激活或禁用的可能特性,以及目标的三元组:
llvm::Triple Triple = llvm::Triple( !MTriple.empty() ? llvm::Triple::normalize(MTriple) : llvm::sys::getDefaultTargetTriple()); llvm::TargetOptions TargetOptions = codegen::InitTargetOptionsFromCodeGenFlags(Triple); std::string CPUStr = codegen::getCPUStr(); std::string FeatureStr = codegen::getFeaturesStr();
-
然后,我们必须在目标注册表中查找目标。如果发生错误,我们将显示错误消息并退出。一个可能错误是用户指定的不支持的三元组:
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; }
-
在
Target
类的帮助下,我们可以使用用户请求的所有已知选项来配置目标机器:llvm::TargetMachine *TM = Target->createTargetMachine( Triple.getTriple(), CPUStr, FeatureStr, TargetOptions, std::optional<llvm::Reloc::Model>( codegen::getRelocModel())); return TM; }
使用目标机器实例,我们可以生成针对我们选择的 CPU 架构的 IR 代码。缺少的是将其翻译成汇编文本或生成目标代码文件。我们将在下一节中添加此支持。
输出汇编文本和目标代码
在 LLVM 中,IR 代码通过一系列的遍历运行。每个遍历执行单个任务,例如删除死代码。我们将在第七章,优化 IR中了解更多关于遍历的内容。输出汇编代码或目标文件也被实现为一个遍历。让我们添加基本支持它!
我们需要包含更多的 LLVM 头文件。首先,我们需要llvm::legacy::PassManager
类来保存要输出到文件的代码的遍历。我们还想能够输出 LLVM IR 代码,因此我们还需要一个遍历来输出这些代码。最后,我们将使用llvm:: ToolOutputFile
类来进行文件操作:
#include "llvm/IR/IRPrintingPasses.h"
#include "llvm/IR/LegacyPassManager.h"
#include "llvm/MC/TargetRegistry.h"
#include "llvm/Pass.h"
#include "llvm/Support/ToolOutputFile.h"
还需要另一个命令行选项来输出 LLVM IR:
static llvm::cl::opt<bool> EmitLLVM(
"emit-llvm",
llvm::cl::desc("Emit IR code instead of assembler"),
llvm::cl::init(false));
最后,我们希望能够给输出文件命名:
static llvm::cl::opt<std::string>
OutputFilename("o",
llvm::cl::desc("Output filename"),
llvm::cl::value_desc("filename"));
新的emit()
方法中的第一个任务是处理输出文件名,如果用户没有在命令行中提供。如果输入是从stdin
读取的,表示使用减号-
,则我们将结果输出到stdout
。ToolOutputFile
类知道如何处理特殊文件名-
:
bool emit(StringRef Argv0, llvm::Module *M,
llvm::TargetMachine *TM,
StringRef InputFilename) {
CodeGenFileType FileType = codegen::getFileType();
if (OutputFilename.empty()) {
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 OpenFlags = sys::fs::OF_None;
if (FileType == CGFT_AssemblyFile)
OpenFlags |= sys::fs::OF_TextWithCRLF;
auto Out = std::make_unique<llvm::ToolOutputFile>(
OutputFilename, EC, OpenFlags);
if (EC) {
WithColor::error(llvm::errs(), Argv0)
<< EC.message() << '\n';
return false;
}
现在,我们可以向 PassManager
添加所需的传递。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(llvm::errs(), Argv0)
<< "No support for file type\n";
return false;
}
}
在完成所有这些准备工作后,生成文件简化为单个函数调用:
PM.run(*M);
ToolOutputFile
类会自动删除文件,除非我们明确请求保留它。这使得错误处理变得更容易,因为可能有很多地方需要处理错误,而如果一切顺利,只有一个地方会被访问。我们成功生成了代码,因此我们希望保留文件:
Out->keep();
最后,我们必须向调用者报告成功:
return true;
}
使用 llvm::Module
调用 emit()
方法,这是我们通过调用 CodeGenerator
类创建的,按照要求生成代码。
假设你将 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.
要将此转换为 Gcd.o
目标文件,请输入以下内容:
$ tinylang --filetype=obj Gcd.mod
如果你想要直接在屏幕上检查生成的 IR 代码,请输入以下内容:
$ tinylang --filetype=asm --emit-llvm -o - Gcd.mod
根据当前实现的状况,在 tinylang
中创建一个完整的程序是不可能的。然而,你可以使用一个名为 callgcd.c
的小型 C 程序来测试生成的目标文件。注意使用混淆名称来调用 GCD
函数:
#include <stdio.h>
extern long _t3Gcd3GCD(long, long);
int main(int argc, char *argv[]) {
printf(„gcd(25, 20) = %ld\n", _t3Gcd3GCD(25, 20));
printf(„gcd(3, 5) = %ld\n", _t3Gcd3GCD(3, 5));
printf(„gcd(21, 28) = %ld\n", _t3Gcd3GCD(21, 28));
return 0;
}
要使用 clang
编译和运行整个应用程序,请输入以下内容:
$ tinylang --filetype=obj Gcd.mod
$ clang callgcd.c Gcd.o -o gcd
$ gcd
让我们庆祝!在这个阶段,我们已经通过读取源语言并生成汇编代码或目标文件创建了一个完整的编译器。
摘要
在本章中,你学习了如何实现 LLVM IR 代码的代码生成器。基本块是重要的数据结构,它包含所有指令并表达分支。你学习了如何为源语言的控制语句创建基本块以及如何向基本块添加指令。你应用了一种现代算法来处理函数中的局部变量,从而减少了 IR 代码。编译器的目标是为目标生成汇编文本或目标文件,因此你也添加了一个简单的编译管道。有了这些知识,你将能够为你自己的语言编译器生成 LLVM IR 代码、汇编文本或目标代码。
在下一章中,你将学习如何处理聚合数据结构以及如何确保函数调用符合平台规则。
第五章:高级语言构造的 IR 生成
当前的高级语言通常使用聚合数据类型和 面向对象编程 (OOP) 构造。LLVM IR 对聚合数据类型有一些支持,并且必须自行实现类等 OOP 构造。添加聚合类型会引发聚合类型参数如何传递的问题。不同的平台有不同的规则,这也在 IR 中得到体现。遵守调用约定也确保了可以调用系统函数。
在本章中,您将学习如何将聚合数据类型和指针转换为 LLVM IR,以及如何以系统兼容的方式向函数传递参数。您还将学习如何在 LLVM IR 中实现类和虚函数。
本章将涵盖以下主题:
-
使用数组、结构和指针
-
正确获取 应用程序二进制接口 (ABI)
-
为类和虚函数创建 IR 代码
到本章结束时,您将掌握创建 LLVM IR 用于聚合数据类型和面向对象(OOP)构造的知识。您还将了解如何根据平台的规则传递聚合数据类型。
技术要求
本章中使用的代码可以在 github.com/PacktPublishing/Learn-LLVM-17/tree/main/Chapter05
找到。
使用数组、结构和指针
对于几乎所有应用程序,基本类型如 INTEGER
是不够的。例如,为了表示数学对象,如矩阵或复数,您必须基于现有类型构造新的数据类型。这些新数据类型通常被称为 聚合 或 复合。
tinylang
类型 ARRAY [10] OF INTEGER
或 C 类型 long[10]
在 IR 中的表示如下:
[10 x i64]
结构是不同类型的组合。在编程语言中,它们通常使用命名成员表示。例如,在 tinylang
中,结构被写成 RECORD x: REAL; color: INTEGER; y: REAL; END;
,而在 C 中相同的结构是 struct { float x; long color; float y; };
。在 LLVM IR 中,只列出类型名称:
{ float, i64, float }
要访问成员,使用数值索引。就像数组一样,第一个元素的索引号为 0
。
该结构的成员根据数据布局字符串中的规范在内存中排列。有关 LLVM 中数据布局字符串的更多信息,请参阅 第四章,IR 代码生成基础,其中描述了这些细节。
此外,如果需要,可以插入未使用的填充字节。如果您需要控制内存布局,则可以使用所有元素具有 1 字节对齐的打包结构。在 C 中,我们使用以下方式在结构中利用 __packed__
属性:
struct __attribute__((__packed__)) { float x; long long color; float y; }
同样,LLVM IR 中的语法略有不同,如下所示:
<{ float, i64, float }>
将数组、结构和寄存器加载为单元。不可能像%x[3]
那样引用数组值寄存器%x
的单个元素。这是由于 SSA 形式,因为无法判断%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 = load i64, ptr getelementptr inbounds ([8 x i64], ptr @arr, i64 0, i64 1)
ret i64 %1
}
getelementptr
指令是地址计算的工作马。因此,它需要更多的解释。第一个操作数[8 x i64]
是指令操作的基类型。第二个操作数ptr @arr
指定了基指针。请注意这里的微妙区别:我们声明了一个包含八个元素的数组,但由于所有全局值都被视为指针,所以我们有一个指向数组的指针。在 C 语法中,我们实际上与long (*arr)[8]
一起工作!结果是,我们必须先取消引用指针,然后才能索引元素,例如 C 中的arr[0][1]
。第三个操作数i64 0
取消引用指针,第四个操作数i64 1
是元素索引。这个计算的结果是索引元素的地址。请注意,此指令不会触及任何内存。
除了结构体之外,索引参数不需要是常量。因此,可以使用getelementptr
指令在循环中检索数组的元素。在这里,结构体的处理方式不同:只能使用常量,并且类型必须是i32
。
带着这些知识,数组可以很容易地从第四章,IR 代码生成基础中集成到代码生成器中。必须扩展convertType()
方法来创建类型。如果Arr
变量持有数组的类型表示符,并且假设数组中的元素数量是一个整数字面量,那么我们就可以在convertType()
方法中添加以下内容来处理数组:
if (auto *ArrayTy =
llvm::dyn_cast<ArrayTypeDeclaration>(Ty)) {
llvm::Type *Component =
convertType(ArrayTy->getType());
Expr *Nums = ArrayTy->getNums();
uint64_t NumElements =
llvm::cast<IntegerLiteral>(Nums)
->getValue()
.getZExtValue();
llvm::Type *T =
llvm::ArrayType::get(Component, NumElements);
// TypeCache is a mapping between the original
// TypeDeclaration (Ty) and the current Type (T).
return TypeCache[Ty] = T;
}
这种类型可以用来声明全局变量。对于局部变量,我们需要为数组分配内存。我们在过程的第一个基本块中这样做:
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);
// The following method requires a BasicBlock (Curr),
// a VariableDeclation (Var), and an llvm::Value (Val)
writeLocalVariable(Curr, Var, Val);
}
}
}
要读取和写入一个元素,我们必须生成getelementptr
指令。这个指令被添加到emitExpr()
(读取值)和emitStmt()
(写入值)方法中。为了读取数组中的一个元素,首先读取变量的值。然后,处理变量的选择器。对于每个索引,计算表达式并存储值。基于这个列表,计算引用元素的地址并加载值:
auto &Selectors = Var->getSelectors();
for (auto I = Selectors.begin(), E = Selectors.end();
I != E; ) {
if (auto *IdxSel =
llvm::dyn_cast<IndexSelector>(*I)) {
llvm::SmallVector<llvm::Value *, 4> IdxList;
while (I != E) {
if (auto *Sel =
llvm::dyn_cast<IndexSelector>(*I)) {
IdxList.push_back(emitExpr(Sel->getIndex()));
++I;
} else
break;
}
Val = Builder.CreateInBoundsGEP(Val->getType(), Val, IdxList);
Val = Builder.CreateLoad(
Val->getType(), Val);
}
// . . . Check for additional selectors and handle
// appropriately by generating getelementptr and load.
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
指定参数是通过值传递的。参数必须是指针类型。对指向的数据创建一个隐藏的副本,并将这个指针传递给被调用的函数。 -
zeroext
和signext
指定传递的整数值应该是零扩展或符号扩展。 -
sret
指定此参数包含指向内存的指针,该内存用于从函数返回聚合类型。
虽然所有代码生成器都支持 zeroext
、signext
和 sret
属性,但只有一些支持 inreg
和 byval
。可以使用 addAttr()
方法将属性添加到函数的参数上。例如,要将 inreg
属性设置在参数 Arg
上,你可以调用以下代码:
Arg->addAttr(llvm::Attribute::InReg);
要设置多个属性,可以使用 llvm::AttrBuilder
类。
提供额外信息的另一种方式是使用类型重写。使用这种方法,你可以伪装原始类型。你可以做以下操作:
-
分割参数。例如,你不必传递一个复数参数,而是可以传递两个浮点参数。
-
将参数转换为不同的表示,例如通过整数寄存器传递浮点值。
要在不改变值位的情况下在类型之间进行转换,你使用 bitcast
指令。bitcast
指令可以操作简单数据类型,如整数和浮点值。当浮点值通过整数寄存器传递时,浮点值必须转换为整数。在 LLVM 中,32 位浮点值表示为 float
,32 位位整数表示为 i32
。浮点值可以按以下方式转换为整数:
%intconv = bitcast float %fp to i32
此外,bitcast
指令要求两种类型具有相同的大小。
向参数添加属性或更改类型并不复杂。但你怎么知道你需要实现什么?首先,你应该了解目标平台使用的调用约定。例如,Linux 上的 ELF ABI 为每个支持的 CPU 平台进行了文档记录,因此你可以查阅文档并让自己熟悉它。
还有一些关于 LLVM 代码生成器要求的文档。信息来源是 clang 实现,你可以在 github.com/llvm/llvm-project/blob/main/clang/lib/CodeGen/TargetInfo.cpp
找到。这个单独的文件包含了所有支持平台的所有 ABI 特定操作,并且也是收集所有信息的地方。
在本节中,你学习了如何生成符合平台 ABI 的函数调用 IR。下一节将介绍创建类和虚拟函数 IR 的不同方法。
为类和虚拟函数创建 IR 代码
许多现代编程语言通过类支持面向对象。类是一种高级语言构造,在本节中,我们将探讨如何将类构造映射到 LLVM IR。
实现单继承
类是数据和方法的集合。一个类可以继承自另一个类,可能添加更多的数据字段和方法,或者覆盖现有的虚拟方法。让我们用 Oberon-2 中的类来举例说明,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;
类型也可以在运行时查询。如果形状是类型为 Shape
的变量,那么我们可以这样进行类型测试:
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 { ptr, i64 }
@Circle
类型也被相应地扩展。
结果的内存结构如图 5.1所示。1*:
图 5.1 – 类和虚方法表的内存布局
在 LLVM IR 方面,Shape
类的 vtable 可以表示如下,其中两个指针分别对应于图 5.1中表示的GetColor()
和GetArea()
方法。1*:
@ShapeVTable = constant { ptr, ptr } { GetColor(), Area() }
此外,LLVM 没有空指针。而是使用字节指针。随着隐藏的vtable
字段的引入,现在还需要有一种初始化它的方法。在 C++中,这是调用构造函数的一部分。在 Oberon-2 中,字段在内存分配时自动初始化。
方法动态调用的执行步骤如下:
-
通过
getelementptr
指令计算 vtable 指针的偏移量。 -
加载 vtable 的指针。
-
计算 vtable 中函数的偏移量。
-
加载函数指针。
-
通过
call
指令通过指针间接调用函数。
我们也可以在 LLVM IR 中可视化对虚拟方法的动态调用,例如Area()
。首先,我们从Shape
类的指定位置加载一个指针。下面的加载表示加载Shape
的实际 vtable 的指针:
// Load a pointer from the corresponding location.
%ptrToShapeObj = load ptr, ...
// Load the first element of the Shape class.
%vtable = load ptr, ptr %ptrToShapeObj, align 8
然后,使用getelementptr
获取调用Area()
方法的偏移量:
%offsetToArea = getelementptr inbounds ptr, ptr %vtable, i64 1
然后,我们加载Area()
函数的指针:
%ptrToAreaFunction = load ptr, ptr %offsetToArea, align 8
最后,通过指针调用Area()
函数,与之前突出显示的一般步骤相似:
%funcCall = call noundef float %ptrToAreaFunction(ptr noundef nonnull align 8 dereferenceable(12) %ptrToShapeObj)
如我们所见,即使在单继承的情况下,生成的 LLVM IR 看起来也可能非常冗长。尽管生成动态方法调用的通用过程听起来不是很高效,但大多数 CPU 架构只需两条指令就可以执行这个动态调用。
此外,要将函数转换为方法,需要对象的引用。这是通过将数据指针作为方法的第一个参数传递来实现的。在 Oberon-2 中,这是显式的接收者。在类似于 C++的语言中,这是隐式的this
指针。
使用 vtable,我们为每个类在内存中有一个唯一的地址。这也有助于运行时类型测试吗?答案是,它只以有限的方式有帮助。为了说明问题,让我们通过一个继承自Circle
类的Ellipse
类扩展类层次结构。这在数学意义上不是经典的概念。
如果我们有一个shape
变量,其类型为Shape
,那么我们可以将shape IS Circle
类型测试实现为比较存储在shape
变量中的 vtable 指针与Circle
类的 vtable 指针。这种比较只有在shape
具有确切的Circle
类型时才会返回 true。然而,如果shape
确实是Ellipse
类型,那么即使Ellipse
类型的对象可以在只需要Circle
类型对象的所有地方使用,比较也会返回 false。
显然,我们需要做更多。解决方案是扩展虚拟方法表以包含运行时类型信息。你需要存储多少信息取决于源语言。为了支持运行时类型检查,存储基类 vtable 的指针就足够了,它看起来就像图 5.2所示:
图 5.2 – 支持简单类型测试的类和 vtable 布局
如果测试如前所述失败,则测试会使用基类的 vtable 指针重复进行。这会一直重复,直到测试返回 true,或者如果没有基类,则返回 false。与调用动态函数相比,类型测试是一个昂贵的操作,因为在最坏的情况下,继承层次结构会遍历到根类。
如果你知道整个类层次结构,那么可以采取一种有效的方法:以深度优先的顺序给类层次结构中的每个成员编号。然后,类型测试变成与数字或区间的比较,这可以在常数时间内完成。实际上,这正是我们在上一章中学到的 LLVM 自己的运行时类型测试的方法。
将运行时类型信息与虚表结合是一个设计决策,要么是由源语言强制规定的,要么只是作为一个实现细节。例如,如果你需要详细的运行时类型信息,因为源语言支持运行时反射,并且你有没有虚表的数据类型,那么将两者结合不是一个好主意。在 C++中,这种结合导致了一个事实,即具有虚函数(因此没有虚表)的类没有附加的运行时类型数据。
通常,编程语言支持接口,这些接口是一系列虚拟方法。接口很重要,因为它们增加了一个有用的抽象。我们将在下一节中探讨接口的可能实现。
使用接口扩展单继承
类似于Java这样的语言支持接口。接口是一系列抽象方法的集合,相当于一个没有数据成员且只定义了抽象方法的基本类。接口提出了一个有趣的问题,因为每个实现接口的类都可以在虚表中的不同位置有相应的方法。原因很简单,虚表中的函数指针顺序是从源语言中类定义中函数的顺序派生出来的。接口的定义独立于这一点,不同的顺序是常态。
由于接口中定义的方法可以有不同的顺序,我们将每个实现的接口的表附加到类上。对于接口的每个方法,这个表可以指定方法在虚表中的索引或虚表中存储的函数指针的副本。如果在接口上调用一个方法,那么将搜索接口的相应虚表,获取函数指针,并调用该方法。将两个I1
和I2
接口添加到Shape
类中会导致以下布局:
图 5.3 – 接口虚表的布局
注意之处在于我们必须找到正确的虚表。我们可以使用类似于运行时类型测试的方法:我们可以通过接口虚表的列表进行线性搜索。我们可以给每个接口分配一个唯一的数字(例如,一个内存地址),并使用这个数字来识别这个虚表。这种方案的缺点很明显:通过接口调用方法比在类上调用相同的方法花费更多的时间。这个问题没有简单的缓解方法。
一个好的方法是使用哈希表来替换线性搜索。在编译时,一个类实现的接口是已知的。因此,我们可以构造一个完美的哈希函数,将接口编号映射到接口的虚函数表。构建接口的唯一标识符可能需要一个已知的唯一数字,因此内存无法帮助,但还有其他方法来计算一个唯一数字。如果源代码中的符号名称是唯一的,那么总是可以计算符号的加密哈希,如MD5
,并使用哈希作为数字。这个计算发生在编译时,因此没有运行时成本。
结果比线性搜索快得多,并且只需要常数时间。尽管如此,它涉及到对数字的几个算术运算,并且比类类型的方法调用慢。
通常,接口也参与运行时类型测试,这使得列表搜索更长。当然,如果实现了哈希表方法,那么它也可以用于运行时类型测试。
一些语言允许有多个父类。这给实现带来了一些有趣的挑战,我们将在下一节中掌握这一点。
添加多重继承支持
多重继承增加了另一个挑战。如果一个类从两个或更多基类继承,那么我们需要以这种方式组合数据成员,使得它们仍然可以从方法中访问。就像在单继承的情况下,解决方案是附加所有数据成员,包括隐藏的虚函数表指针。
Circle
类不仅是一个几何形状,也是一个图形对象。为了建模这一点,我们让Circle
类继承自Shape
类和GraphicObj
类。在类布局中,Shape
类的字段首先出现。然后,我们附加GraphicObj
类的所有字段,包括隐藏的虚函数表指针。之后,我们添加Circle
类的新数据成员,从而形成如图 5.4所示的总体结构。4*:
图 5.4 - 多重继承的类和虚函数表布局
这种方法有几个影响。现在可以有指向对象的多个指针。指向Shape
或Circle
类的指针指向对象的顶部,而指向GraphicObj
类的指针指向这个对象的内部,指向嵌入的GraphicObj
对象的开始。在比较指针时必须考虑到这一点。
调用虚方法也会受到影响。如果方法在 GraphicObj
类中定义,那么这个方法期望 GraphicObj
类的类布局。如果这个方法在 Circle
类中没有重写,那么有两种可能性。简单的情况是如果使用指向 GraphicObj
实例的指针进行方法调用:在这种情况下,你在 GraphicObj
类的 vtable 中查找方法的地址并调用该函数。更复杂的情况是如果你使用指向 Circle
类的指针调用该方法。同样,你可以在 Circle
类的 vtable 中查找方法的地址。被调用的方法期望 this
指针是一个 GraphicObj
类的实例,因此我们也必须调整该指针。我们可以这样做,因为我们知道 GraphicObj
类在 Circle
类中的偏移量。
如果在 Circle
类中重写了 GrapicObj
方法,那么如果通过 Circle
类的指针调用该方法,则不需要做任何特殊处理。然而,如果通过 GraphicObj
实例的指针调用该方法,那么我们需要进行另一个调整,因为该方法需要一个指向 Circle
实例的 this
指针。在编译时,我们无法计算这个调整,因为我们不知道这个 GraphicObj
实例是否是多重继承层次结构的一部分。为了解决这个问题,我们在调用方法之前,将需要调整的 this
指针与 vtable 中的每个函数指针一起存储,如图 5**.5 所示:
图 5.5 – 调整 this 指针的 vtable
现在的方法调用变为以下:
-
在 vtable 中查找函数指针。
-
调整
this
指针。 -
调用方法。
这种方法也可以用于实现接口。由于接口只有方法,每个实现的接口都会为对象添加一个新的 vtable 指针。这更容易实现,并且可能更快,但它为每个对象实例增加了开销。
在最坏的情况下,如果你的类有一个 64 位数据字段,但实现了 10 个接口,那么你的对象在内存中需要 96 字节:8 字节用于类本身的 vtable 指针,8 字节用于数据成员,以及 10 * 8 字节用于每个接口的 vtable 指针。
为了支持对对象的有意义比较以及执行运行时类型测试,我们首先需要将对象指针规范化。如果我们向 vtable 添加一个额外的字段,包含对象顶部的偏移量,那么我们总能调整指针以指向实际的对象。在 Circle
类的 vtable 中,这个偏移量是 0,但在内嵌的 GraphicObj
类的 vtable 中不是。当然,是否需要实现这取决于源语言的语义。
LLVM 本身并不倾向于特殊实现面向对象特性。正如本节所示,我们可以使用可用的 LLVM 数据类型实现所有方法。此外,正如我们已经看到的单继承的 LLVM IR 示例,当涉及多重继承时,IR 可能会变得更加冗长。如果你想尝试一种新的方法,那么一个好的方式是首先用 C 语言做一个原型。所需的指针操作可以快速转换为 LLVM IR,但在高级语言中推理功能更容易。
通过本节获得的知识,你可以在自己的代码生成器中实现将编程语言中常见的所有面向对象构造转换为 LLVM IR。你有了如何表示单继承、具有接口的单继承或内存中的多重继承的食谱,以及如何实现类型测试和查找虚函数的方法,这些都是面向对象语言的核心概念。
摘要
在本章中,你学习了如何将聚合数据类型和指针转换为 LLVM IR 代码。你还了解了应用程序二进制接口的复杂性。最后,你学习了将类和虚函数转换为 LLVM IR 的不同方法。通过本章的知识,你将能够为大多数真实编程语言创建一个 LLVM IR 代码生成器。
在下一章中,你将学习一些关于 IR 生成的先进技术。异常处理在现代编程语言中相当常见,LLVM 对此也有一些支持。将类型信息附加到指针可以帮助进行某些优化,因此我们也会添加这一点。最后但同样重要的是,调试应用程序的能力对于许多开发者来说至关重要,因此我们还将添加调试元数据的生成到我们的代码生成器中。
第六章:高级 IR 生成
在前几章中介绍了 IR 生成后,你就可以实现编译器所需的大部分功能。在本章中,我们将探讨一些在现实世界编译器中经常出现的高级主题。例如,许多现代语言都使用了异常处理,因此我们将探讨如何将其转换为 LLVM IR。
为了支持 LLVM 优化器,以便它在某些情况下产生更好的代码,我们必须向 IR 代码中添加额外的类型元数据。此外,附加调试元数据使编译器的用户能够利用源级调试工具。
在本章中,我们将涵盖以下主题:
-
抛出和捕获异常:在这里,你将学习如何在你的编译器中实现异常处理
-
为基于类型的别名分析生成元数据:在这里,你将为 LLVM IR 附加额外的元数据,这有助于 LLVM 更好地优化代码
-
添加调试元数据:在这里,你将实现添加到生成的 IR 代码中的调试信息所需的支持类
到本章结束时,你将了解异常处理,以及基于类型的别名分析和调试信息的元数据。
抛出和捕获异常
LLVM IR 中的异常处理与平台支持紧密相关。在这里,我们将探讨使用libunwind
的最常见的异常处理类型。C++使用了它的全部潜力,因此我们将首先查看一个 C++的例子,其中bar()
函数可以抛出int
或double
值:
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) {
int y = 0;
try {
y = bar(x);
}
catch (int e) {
y = e;
}
return y;
}
抛出异常需要调用运行时库两次;这可以在bar()
函数中看到。首先,通过调用__cxa_allocate_exception()
为异常分配内存。这个函数接受要分配的字节数作为参数。异常负载(在这个例子中的int
或double
值)被复制到分配的内存中。然后,通过调用__cxa_throw()
来引发异常。这个函数接受三个参数:分配的异常的指针、负载的类型信息以及指向析构函数的指针,以防异常负载有一个。__cxa_throw()
函数启动堆栈回溯过程,并且永远不会返回。在 LLVM IR 中,这是对int
值进行的,如下所示:
%eh = call ptr @__cxa_allocate_exception(i64 4)
store i32 1, ptr %eh
call void @__cxa_throw(ptr %eh, ptr @_ZTIi, ptr null)
unreachable
_ZTIi
是描述int
类型的类型信息。对于double
类型,它将是_ZTId
。
到目前为止,还没有进行任何特定于 LLVM 的操作。这在前面的foo()
函数中发生了变化,因为对bar()
的调用可能会抛出异常。如果它是一个int
类型的异常,那么控制流必须转移到捕获子句的 IR 代码。为了完成这个任务,必须使用invoke
指令而不是call
指令:
%y = invoke i32 @_Z3bari(i32 %x) to label %next
unwind label %lpad
这两个指令之间的区别在于invoke
有两个标签相关联。第一个标签是在被调用函数正常结束(通常使用ret
指令)时继续执行的地方。在示例代码中,这个标签被称为%next
。如果发生异常,则执行继续在所谓的着陆点,标签为%lpad
。
着陆点是一个必须以landingpad
指令开始的代码块。landingpad
指令向 LLVM 提供有关处理异常类型的信息。例如,一个可能的着陆点可能看起来像这样:
lpad:
%exc = landingpad { ptr, i32 }
cleanup
catch ptr @_ZTIi
filter [1 x ptr] [ptr @_ZTIi]
这里可能有三种操作类型:
-
cleanup
:这表示存在清理当前状态的代码。通常,这用于调用局部对象的析构函数。如果存在此标记,则在栈回溯期间始终调用着陆点。 -
catch
:这是一个类型-值对的列表,表示可以处理的异常类型。如果抛出的异常类型在此列表中,则调用着陆点。在foo()
函数的情况下,值是 C++运行时类型信息指针,类似于__cxa_throw()
函数的参数。 -
filter
:这指定了一个异常类型数组。如果当前异常的类型不在数组中,则调用着陆点。这用于实现throw()
规范。对于foo()
函数,数组只有一个成员——int
类型的类型信息。
landingpad
指令的结果类型是{ ptr, i32 }
结构。第一个元素是指向抛出异常的指针,而第二个是类型选择器。让我们从结构中提取这两个值:
%exc.ptr = extractvalue { ptr, i32 } %exc, 0
%exc.sel = extractvalue { ptr, i32 } %exc, 1
类型选择器是一个帮助我们识别为什么着陆点被调用的原因的数字。如果当前异常类型与landingpad
指令的catch
部分中给出的异常类型之一匹配,则该值是正数。如果当前异常类型与filter
部分中给出的任何值都不匹配,则该值为负数。如果应该调用清理代码,则该值为0
。
类型选择器是一个类型信息表的偏移量,该表由landingpad
指令的catch
和filter
部分给出的值构建而成。在优化过程中,多个着陆点可以合并为一个,这意味着该表的结构在 IR 级别上是未知的。为了检索给定类型的类型选择器,我们需要调用内建的@llvm.eh.typeid.for
函数。我们需要这个函数来检查类型选择器值是否对应于int
类型的类型信息,以便我们可以在catch (int e) {}
块中执行代码:
%tid.int = call i32 @llvm.eh.typeid.for(ptr @_ZTIi)
%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 = call ptr @__cxa_begin_catch(ptr %exc.ptr)
%retval = load i32, ptr %payload
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:
call void @__cxa_call_unexpected(ptr %exc.ptr) #4
unreachable
再次,处理器不应该返回。
在这种情况下不需要进行清理工作,因此所有清理代码所做的只是恢复堆栈回溯器的执行:
cleanup:
resume { ptr, i32 } %exc
还缺少一部分:libunwind
驱动堆栈回溯过程,但它并不绑定到单一的语言。语言相关的处理在个性函数中完成。对于 Linux 上的 C++,个性函数被调用为 __gxx_personality_v0()
。根据平台或编译器,这个名称可能会有所不同。每个需要参与堆栈回溯的函数都有一个个性函数附加。这个个性函数分析函数是否捕获了异常,是否有不匹配的过滤器列表,或者是否需要清理调用。它将此信息返回给回溯器,回溯器据此采取行动。在 LLVM IR 中,个性函数的指针作为函数定义的一部分给出:
define i32 @_Z3fooi(i32) personality ptr @__gxx_personality_v0
这样,异常处理功能就完成了。
在编译器中为您的编程语言使用异常处理的最简单策略是利用现有的 C++ 运行时函数。这也具有优势,即您的异常可以与 C++ 兼容。缺点是您将一些 C++ 运行时绑定到您语言的运行时中,最显著的是内存管理。如果您想避免这种情况,那么您需要创建自己的 _cxa_
函数等效物。尽管如此,您仍然会想使用 libunwind
,它提供了堆栈回溯机制:
-
让我们看看如何创建这个 IR。我们在 第二章 中创建了
calc
表达式编译器,编译器的结构。现在,我们将扩展表达式编译器的代码生成器,以便在执行除以零操作时抛出和处理异常。生成的 IR 将检查除法的除数是否为0
。如果是,则抛出异常。我们还将向函数中添加一个 landing pad,它捕获异常并将Divide by zero!
打印到控制台并结束计算。在这个简单的情况下,使用异常处理不是必需的,但它允许我们专注于代码生成过程。我们必须将所有代码添加到CodeGen.cpp
文件中。我们首先添加所需的新字段和一些辅助方法。首先,我们需要存储__cxa_allocate_exception()
和__cxa_throw()
函数的 LLVM 声明,这些声明包括函数类型和函数本身。需要一个GlobalVariable
实例来存储类型信息。我们还需要引用包含 landing pad 的基本块和一个只包含unreachable
指令的基本块:GlobalVariable *TypeInfo = nullptr; FunctionType *AllocEHFty = nullptr; Function *AllocEHFn = nullptr; FunctionType *ThrowEHFty = nullptr; Function *ThrowEHFn = nullptr; BasicBlock *LPadBB = nullptr; BasicBlock *UnreachableBB = nullptr;
-
我们还将添加一个新的辅助函数来创建比较两个值的 IR。
createICmpEq()
函数接受要比较的Left
和Right
值作为参数。它创建一个比较指令来测试值的相等性,并为相等和不相等的情况创建一个分支指令到两个基本块。这两个基本块通过TrueDest
和FalseDest
参数返回。此外,可以在TrueLabel
和FalseLabel
参数中给出新基本块的标签。代码如下: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); }
-
要使用运行时函数,我们需要创建几个函数声明。在 LLVM 中,函数类型给出签名,而函数本身必须构造。我们使用
createFunc()
方法创建这两个对象。函数需要FunctionType
和Function
指针的引用,新声明的函数的名称,以及结果类型。参数类型列表是可选的,表示变量参数列表的标志设置为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
类型,可以设置为任意值。以下是我们需要编写的代码:
-
新的
addThrow()
方法首先检查TypeInfo
字段是否已初始化。如果没有初始化,则创建一个名为_ZTIi
的i8
指针类型的全局外部常量。这代表了描述 C++int
类型的 C++ 元数据:void addThrow(int PayloadVal) { if (!TypeInfo) { TypeInfo = new GlobalVariable( *M, Int8PtrTy, /*isConstant=*/true, GlobalValue::ExternalLinkage, /*Initializer=*/nullptr, "_ZTIi");
-
初始化继续通过使用我们的辅助
createFunc()
方法创建__cxa_allocate_exception()
和__cxa_throw()
函数的 IR 声明:createFunc(AllocEHFty, AllocEHFn, "__cxa_allocate_exception", Int8PtrTy, {Int64Ty}); createFunc(ThrowEHFty, ThrowEHFn, "__cxa_throw", VoidTy, {Int8PtrTy, Int8PtrTy, Int8PtrTy});
-
使用异常处理的函数需要一个个人函数,它有助于栈回溯。我们添加 IR 代码来声明来自 C++库的
__gxx_personality_v0()
个人函数,并将其设置为当前函数的个人例程。当前函数不是作为字段存储的,但我们可以使用Builder
实例来查询当前基本块,该基本块将函数存储为Parent
字段:FunctionType *PersFty; Function *PersFn; createFunc(PersFty, PersFn, "__gxx_personality_v0", Int32Ty, std::nulopt, true); Function *Fn = Builder.GetInsertBlock()->getParent(); Fn->setPersonalityFn(PersFn);
-
接下来,我们必须创建并填充着陆地基本块。首先,我们需要保存当前基本块的指针。然后,我们必须创建一个新的基本块,将其设置在构建器中以便可以使用它来插入指令,并调用
addLandingPad()
方法。这个方法生成处理异常的 IR 代码,并在下一节捕获异常中描述。这段代码填充了着陆地基本块:BasicBlock *SaveBB = Builder.GetInsertBlock(); LPadBB = BasicBlock::Create(M->getContext(), "lpad", Fn); Builder.SetInsertPoint(LPadBB); addLandingPad();
-
通过创建包含
unreachable
指令的基本块来完成初始化部分。再次,我们创建基本块并将其设置为构建器的插入点。然后,我们可以向其中添加unreachable
指令。最后,我们可以将构建器的插入点设置回保存的SaveBB
实例,以便以下 IR 添加到正确的基本块:UnreachableBB = BasicBlock::Create( M->getContext(), "unreachable", Fn); Builder.SetInsertPoint(UnreachableBB); Builder.CreateUnreachable(); Builder.SetInsertPoint(SaveBB); }
-
要抛出异常,我们需要通过调用
__cxa_allocate_exception()
函数为异常和有效负载分配内存。我们的有效负载是 C++的int
类型,通常大小为 4 字节。我们创建一个常量无符号值作为大小,并用它作为参数调用该函数。函数类型和函数声明已经初始化,所以我们只需要创建call
指令:Constant *PayloadSz = ConstantInt::get(Int64Ty, 4, false); CallInst *EH = Builder.CreateCall( AllocEHFty, AllocEHFn, {PayloadSz});
-
接下来,我们将
PayloadVal
值存储在分配的内存中。为此,我们需要创建一个调用ConstantInt::get()
函数的 LLVM IR 常量。分配的内存的指针是i8
指针类型;为了存储i32
类型的值,我们需要创建一个bitcast
指令来转换类型:Value *PayloadPtr = Builder.CreateBitCast(EH, Int32PtrTy); Builder.CreateStore( ConstantInt::get(Int32Ty, PayloadVal, true), PayloadPtr);
-
最后,我们必须通过调用
__cxa_throw()
函数来抛出异常。由于这个函数会抛出异常,而这个异常也在同一个函数中处理,因此我们需要使用invoke
指令而不是call
指令。与call
指令不同,invoke
指令会结束一个基本块,因为它有两个后续的基本块。在这里,这些是UnreachableBB
和LPadBB
基本块。如果函数没有抛出异常,控制流将转移到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 中,我们需要从 C++运行时库中调用
__cxa_begin_catch()
和__cxa_end_catch()
函数。为了打印错误消息,我们将生成对 C 运行时库中的puts()
函数的调用。此外,为了从异常中获取类型信息,我们必须生成对llvm.eh.typeid.for
内建函数的调用。我们还需要所有这些的FunctionType
和Function
实例;我们将利用我们的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});
-
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));
-
接下来,我们从返回值中提取类型选择器。通过调用
llvm.eh.typeid.for
内建函数,我们检索代表 C++int
类型的TypeInfo
字段的类型 ID。有了这个 IR,我们已经生成了我们需要比较的两个值,以决定我们是否可以处理这个异常:Value *Sel = Builder.CreateExtractValue(Exc, {1}, "exc.sel"); CallInst *Id = Builder.CreateCall(TypeIdFty, TypeIdFn, {ConstantExpr::getBitCast( TypeInfo, Int8PtrTy)});
-
要生成比较的 IR,我们必须调用我们的
createICmpEq()
函数。此函数还生成两个基本块,我们将它们存储在TrueDest
和FalseDest
变量中:BasicBlock *TrueDest, *FalseDest; createICmpEq(Sel, Id, TrueDest, FalseDest, "match", "resume");
-
如果两个值不匹配,控制流将继续在
FalseDest
基本块中。此基本块仅包含一个resume
指令,以将控制权交还给 C++运行时:Builder.SetInsertPoint(FalseDest); Builder.CreateResume(Exc);
-
如果两个值相等,控制流将继续在
TrueDest
基本块中。首先,我们生成 IR 代码以从landingpad
指令的返回值中提取指向异常的指针,该返回值存储在Exc
变量中。然后,我们生成对__cxa_begin_catch ()
函数的调用,并将指向异常的指针作为参数传递。这表示运行时开始处理异常:Builder.SetInsertPoint(TrueDest); Value *Ptr = Builder.CreateExtractValue(Exc, {0}, "exc.ptr"); Builder.CreateCall(BeginCatchFty, BeginCatchFn, {Ptr});
-
然后通过调用
puts()
函数来处理异常,打印一条消息到控制台。为此,我们通过调用CreateGlobalStringPtr()
函数生成一个指向字符串的指针,然后将此指针作为参数传递给生成的puts()
函数调用:Value *MsgPtr = Builder.CreateGlobalStringPtr( "Divide by zero!", "msg", 0, M); Builder.CreateCall(PutsFty, PutsFn, {MsgPtr});
-
现在我们已经处理了异常,我们必须生成对
__cxa_end_catch()
函数的调用,以通知运行时。最后,我们使用ret
指令从函数返回:Builder.CreateCall(EndCatchFty, EndCatchFn); Builder.CreateRet(Int32Zero); }
使用addThrow()
和addLandingPad()
函数,我们可以生成 IR 来抛出异常和处理异常。然而,我们仍然需要添加 IR 来检查除数是否为0
。我们将在下一节中介绍这一点。
将异常处理代码集成到应用程序中
除法的 IR 是在visit(BinaryOp &)
方法中生成的。我们不仅需要生成一个sdiv
指令,还必须生成一个 IR 来比较除数与0
。如果除数为 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;
代码生成部分现在已完成。要构建应用程序,我们必须切换到构建目录并运行ninja
工具:
$ ninja
构建完成后,你可以使用with a:
3/a
表达式来检查生成的 IR:
$ 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 的代码可以用作其他编译器的蓝图。当然,使用的类型信息和捕获子句的数量取决于编译器的输入,但我们需要生成的 IR 仍然遵循本节中展示的模式。
添加元数据是向 LLVM 提供更多信息的另一种方式。在下一节中,我们将添加类型元数据以支持 LLVM 优化器在特定情况下的工作。
为基于类型的别名分析生成元数据
两个指针可能指向同一个内存单元,此时它们相互别名。在 LLVM 模型中,内存没有类型,这使得优化器难以决定两个指针是否相互别名。如果编译器可以证明两个指针不会相互别名,那么可以执行更多的优化。在下一节中,我们将更详细地研究这个问题,并在实现此方法之前探讨添加额外元数据如何有所帮助。
理解额外元数据的需求
为了展示问题,让我们看看以下函数:
void doSomething(int *p, float *q) {
*p = 42;
*q = 3.1425;
}
优化器无法决定指针 p
和 q
是否指向同一个内存单元。在优化过程中,可以进行一个重要的分析,称为 p
和 q
指向同一个内存单元,那么它们是别名。此外,如果优化器可以证明这两个指针永远不会相互别名,这将使额外的优化机会成为可能。例如,在 doSomething()
函数中,存储操作可以被重新排序,而不会改变结果。
此外,一个类型的变量是否可以是另一个不同类型变量的别名,这取决于源语言的定义。请注意,语言也可能包含破坏基于类型的别名假设的表达式——例如,不同类型之间的类型转换。
LLVM 开发者选择的解决方案是在 load
和 store
指令中添加元数据。添加的元数据有两个目的:
-
首先,它基于哪种类型可以别名另一种类型来定义类型层次结构
-
其次,它描述了
load
或store
指令中的内存访问
让我们看看 C 中的类型层次结构。每个类型层次结构都以一个根节点开始,无论是命名的还是匿名的。LLVM 假设具有相同名称的根节点描述了相同类型的层次结构。你可以在同一个 LLVM 模块中使用不同的类型层次结构,LLVM 假设这些类型可能存在别名。在根节点之下,有标量类型的节点。聚合类型的节点不直接连接到根节点,但它们引用标量类型和其他聚合类型。Clang 将 C 的层次结构定义为如下:
-
根节点被称为
Simple C/C++ TBAA
。 -
在根节点之下是
char
类型的节点。这在 C 中是一个特殊类型,因为所有指针都可以转换为指向char
的指针。 -
在
char
节点之下是其他标量类型的节点以及所有指针的类型,称为any pointer
。
此外,聚合类型被定义为成员类型和偏移量的序列。
这些元数据定义用于附加到 load
和 store
指令的访问标签上。一个访问标签由三部分组成:一个基类型、一个访问类型和一个偏移量。根据基类型的不同,访问标签描述内存访问的方式有两种可能:
-
如果基类型是聚合类型,则访问标签描述了具有必要访问类型的
struct
成员的内存访问,并位于给定的偏移量处。 -
如果基类型是标量类型,则访问类型必须与基类型相同,偏移量必须为
0
。
使用这些定义,我们现在可以在访问标签上定义一个关系,该关系用于评估两个指针是否可能相互别名。让我们更仔细地看看 (base type, offset)
元组的直接父节点的选项:
-
如果基类型是标量类型且偏移量为 0,则直接父节点是
(父类型,0)
,其中父类型是父节点在类型层次结构中定义的类型。如果偏移量不为 0,则直接父节点未定义。 -
如果基类型是聚合类型,则
(base type, offset)
元组的直接父节点是(new type, new offset)
元组,其中新类型是偏移量处的成员的类型。新偏移量是新类型的偏移量,调整到其新的起始位置。
这个关系的传递闭包是父关系。两个内存访问,(基类型 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
参数的访问标签是 (int
,int
,0),而 c
参数的访问标签是 (char
,char
,0)。在类型层次结构中,int
类型节点的父节点是 char
节点。因此,(int
,0) 的直接父节点是 (char
,0),并且两个指针可以别名。对于 x
和 c
参数也是如此。然而,x
和 i
参数不相关,因此它们不会相互别名。struct Point
的 y
成员的访问是 (Point
,float
,4),其中 4 是 y
成员在结构体中的偏移量。(Point
,4) 的直接父节点是 (float
,0),因此对 p->y
和 x
的访问可能别名,同样地,根据相同的推理,也与 c
参数相关。
在 LLVM 中创建 TBAA 元数据
要创建元数据,我们必须使用 llvm::MDBuilder
类,该类在 llvm/IR/MDBuilder.h
头文件中声明。数据本身存储在 llvm::MDNode
和 llvm::MDString
类的实例中。使用构建器类可以保护我们免受构建内部细节的影响。
通过调用 createTBAARoot()
方法创建一个根节点,该方法期望类型层次结构的名称作为参数并返回根节点。可以使用 createAnonymousTBAARoot()
方法创建一个匿名、唯一的根节点。
使用 createTBAAScalarTypeNode()
方法将标量类型添加到层次结构中,该方法接受类型名称和父节点作为参数。
另一方面,为聚合类型添加类型节点稍微复杂一些。createTBAAStructTypeNode()
方法接受类型名称和字段列表作为参数。具体来说,字段以std::pair<llvm::MDNode*, uint64_t>
实例给出,其中第一个元素表示成员的类型,第二个元素表示在struct
中的偏移量。
使用createTBAAStructTagNode()
方法创建一个访问标签,该方法接受基类型、访问类型和偏移量作为参数。
最后,元数据必须附加到load
或store
指令。llvm::Instruction
类包含一个名为setMetadata()
的方法,用于添加基于类型的各种别名分析元数据。第一个参数必须是llvm::LLVMContext::MD_tbaa
类型,第二个参数必须是访问标签。
带着这些知识,我们必须为tinylang
添加元数据。
添加 TBAA 元数据到 tinylang
为了支持 TBAA,我们必须添加一个新的CGTBAA
类。这个类负责生成元数据节点。此外,我们将CGTBAA
类作为CGModule
类的成员,命名为TBAA
。
每个加载和存储指令都必须进行注释。在CGModule
类中为此目的创建了一个新函数,称为decorateInst()
。此函数尝试创建标签访问信息。如果成功,则将元数据附加到相应的加载或存储指令。此外,这种设计还允许我们在不需要时关闭元数据生成过程,例如在关闭优化的构建中:
void CGModule::decorateInst(llvm::Instruction *Inst,
TypeDeclaration *Type) {
if (auto *N = TBAA.getAccessTagInfo(Type))
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
类需要存储一些数据成员。那么,让我们一步一步地看看如何做这个步骤:
-
首先,我们需要缓存类型层次结构的根:
class CGTBAA { llvm::MDNode *Root;
-
为了构建元数据节点,我们需要
MDBuilder
类的一个实例:llvm::MDBuilder MDHelper;
-
最后,我们必须存储为类型生成的元数据以供重用:
llvm::DenseMap<TypeDenoter *, llvm::MDNode *> MetadataCache; // … };
现在我们已经定义了构建所需变量,我们必须添加创建元数据所需的方法:
-
构造函数初始化数据成员:
CGTBAA::CGTBAA(CGModule &CGM) : CGM(CGM), MDHelper(llvm::MDBuilder(CGM.getLLVMCtx())), Root(nullptr) {}
-
我们必须延迟实例化类型层次结构的根,我们将其命名为
Simple
tinylang TBAA
:llvm::MDNode *CGTBAA::getRoot() { if (!Root) Root = MDHelper.createTBAARoot("Simple tinylang TBAA"); return Root; }
-
对于标量类型,我们必须使用基于类型的名称通过
MDBuilder
类创建一个元数据节点。新的元数据节点存储在缓存中:llvm::MDNode * CGTBAA::createScalarTypeNode(TypeDeclaration *Ty, StringRef Name, llvm::MDNode *Parent) { llvm::MDNode *N = MDHelper.createTBAAScalarTypeNode(Name, Parent); return MetadataCache[Ty] = N; }
-
创建记录元数据的方法更为复杂,因为我们必须枚举记录的所有字段。类似于标量类型,新的元数据节点存储在缓存中:
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; }
-
为了返回
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 *Array = llvm::dyn_cast<ArrayTypeDeclaration>(Ty)) { StringRef Name = Array->getType()->getName(); return createScalarTypeNode(Array, 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; }
-
获取元数据的一般方法是
getAccessTagInfo()
。要获取 TBAA 访问标签信息,必须添加对getTypeInfo()
函数的调用。该函数期望TypeDeclaration
作为其参数,该参数是从我们想要为生成元数据的指令中检索到的:llvm::MDNode *CGTBAA::getAccessTagInfo(TypeDeclaration *Ty) { return getTypeInfo(Ty); }
最后,为了启用 TBAA 元数据的生成,我们只需将元数据附加到我们在 tinylang
中生成的所有加载和存储指令上。
例如,在 CGProcedure::writeVariable()
中,对一个全局变量的存储使用了一个存储指令:
Builder.CreateStore(Val, CGM.getGlobal(D));
为了装饰这个特定的指令,我们需要将这一行替换为以下行,其中 decorateInst()
将 TBAA 元数据添加到这个存储指令中:
auto *Inst = Builder.CreateStore(Val, CGM.getGlobal(D));
// NOTE: V is of the VariableDeclaration class, and
// the getType() method in this class retrieves the
// TypeDeclaration that is needed for decorateInst().
CGM.decorateInst(Inst, V->getType());
在这些更改到位后,我们已经完成了 TBAA 元数据的生成。
现在,我们可以将一个示例 tinylang
文件编译成 LLVM 中间表示,以查看我们新实现的 TBAA 元数据。例如,考虑以下文件,Person.mod
:
MODULE Person;
TYPE
Person = RECORD
Height: INTEGER;
Age: INTEGER
END;
PROCEDURE Set(VAR p: Person);
BEGIN
p.Age := 18;
END Set;
END Person.
本章构建目录中构建的 tinylang
编译器可以用来为该文件生成中间表示:
$ tools/driver/tinylang -emit-llvm ../examples/Person.mod
在新生成的 Person.ll
文件中,我们可以看到存储指令被装饰了我们本章内生成的 TBAA 元数据,其中元数据反映了最初声明的记录类型的字段:
; ModuleID = '../examples/Person.mod'
source_filename = "../examples/Person.mod"
target datalayout = "e-m:o-i64:64-i128:128-n32:64-S128"
target triple = "arm64-apple-darwin22.6.0"
define void @_t6Person3Set(ptr nocapture dereferenceable(16) %p) {
entry:
%0 = getelementptr inbounds ptr, ptr %p, i32 0, i32 1
store i64 18, ptr %0, align 8, !tbaa !0
ret void
}
!0 = !{!"_t6Person6Person", !1, i64 0, !1, i64 8}
!1 = !{!"INTEGER", !2, i64 0}
!2 = !{!"Simple tinylang TBAA"}
现在我们已经学会了如何生成 TBAA 元数据,我们将在下一节中探索一个非常类似的主题:生成调试元数据。
添加调试元数据
为了允许源级调试,我们必须添加调试信息。LLVM 对调试信息支持使用调试元数据来描述源语言类型和其他静态信息,以及内联函数来跟踪变量值。LLVM 核心库在 Unix 系统上使用 DWARF 格式 生成调试信息,在 Windows 上使用 PDB 格式。我们将在下一节中查看一般结构。
理解调试元数据的一般结构
为了描述一般结构,LLVM 使用与基于类型的分析元数据相似的元数据。静态结构描述了文件、编译单元、函数和词法块,以及使用的数据类型。
我们主要使用的是 llvm::DIBuilder
类,我们需要使用 llvm/IR/DIBuilder
头文件来获取类声明。这个构建器类提供了一个易于使用的接口来创建调试元数据。稍后,这些元数据要么被添加到 LLVM 对象(如全局变量)中,要么用于调用调试内嵌函数。以下是构建器类可以创建的一些重要元数据:
-
llvm::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()
函数分别实例化数组、结构体、联合和向量数据类型的元数据。这些函数需要您预期的参数,例如数组类型的基类型和订阅数,或结构体类型的字段成员列表。 -
同样存在支持枚举、模板、类等方法。
函数列表显示你必须将源语言的每一个细节都添加到调试信息中。假设你的llvm::DIBuilder
类实例称为DBuilder
。假设你有一些tinylang
源代码位于/home/llvmuser
文件夹中的File.mod
文件中。在这个文件中,第 5 行有一个Func():INTEGER
函数,其中包含在第 7 行的局部VAR i: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
值来指定私有函数。函数保护的其它可能值是FlagPublic
和FlagProtected
,分别表示公共和受保护的函数。
除了保护级别之外,还可以在此处指定其他标志。例如,FlagVirtual
表示虚函数,而FlagNoReturn
表示该函数不会返回给调用者。你可以在 LLVM 包含文件中找到可能的完整值列表——即llvm/include/llvm/IR/DebugInfoFlags.def
。
最后,可以指定特定于函数的标志。最常用的标志是SPFlagLocalToUnit
值,它表示该函数是此编译单元的局部函数。MainSubprogram
值也经常使用,表示该函数是应用程序的主函数。前面提到的 LLVM 包含文件还列出了所有与特定于函数的标志相关的可能值。
到目前为止,我们只创建了指向静态数据的元数据。变量是动态的,所以我们将探讨如何在下一节中将静态元数据附加到 IR 代码以访问变量。
跟踪变量及其值
为了变得有用,上一节中描述的类型元数据需要与源程序中的变量相关联。对于全局变量,这很简单。llvm::DIBuilder
类的createGlobalVariableExpression()
函数创建描述全局变量的元数据。这包括源变量名、混淆名、源文件等。在 LLVM IR 中,全局变量由GlobalVariable
类的实例表示。这个类有一个名为addDebugInfo()
的方法,它将createGlobalVariableExpression()
返回的元数据节点与全局变量关联。
对于局部变量,我们需要采取另一种方法。LLVM IR 不知道表示局部变量的类,因为它只知道值。LLVM 社区开发的解决方案是在函数的 IR 代码中插入内建函数的调用。一个llvm.dbg.declare
和一个llvm.dbg.value
。
llvm.dbg.declare
内建函数提供信息,并且由前端生成一次,用于声明局部变量。本质上,这个内建函数描述了局部变量的地址。在优化过程中,传递可以替换这个内建函数为(可能多个)对llvm.dbg.value
的调用,以保留调试信息并跟踪局部源变量。优化后,可能存在多个llvm.dbg.declare
调用,因为它用于描述局部变量在内存中存在的程序点。
另一方面,每当局部变量被设置为新的值时,都会调用llvm.dbg.value
内建函数。这个内建函数描述了局部变量的值,而不是其地址。
这一切都是如何工作的?LLVM IR 表示和通过llvm::DIBuilder
类的程序性创建略有不同,所以我们将查看两者。
继续我们上一节中的例子,我们将在Func
函数内部使用alloca
指令为I
变量分配局部存储:
@i = alloca i32
之后,我们必须添加对llvm.dbg.declare
内建函数的调用:
call void @llvm.dbg.declare(metadata ptr %i,
metadata !1, metadata !DIExpression())
第一个参数是局部变量的地址。第二个参数是描述局部变量的元数据,它通过调用createAutoVariable()
为局部变量或createParameterVariable()
为llvm::DIBuilder
类的参数创建。最后,第三个参数描述了一个地址表达式,稍后将会解释。
让我们实现 IR 的创建。你可以通过调用llvm::IRBuilder<>
类的CreateAlloca()
方法为局部@i
变量分配存储:
llvm::Type *IntTy = llvm::Type::getInt32Ty(LLVMCtx);
llvm::Value *Val = Builder.CreateAlloca(IntTy, nullptr, "i");
LLVMCtx
变量是使用的上下文类,Builder
是llvm::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::DIBuilder
类的insertValue()
方法来实现这一点。
当我们实现函数的 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 的包含文件中找到运算符的完整列表,该文件名为 llvm/include/llvm/BinaryFormat/Dwarf.def
。
在这一点上,你可以为变量创建调试信息。为了使调试器能够跟踪源代码中的控制流,你还需要提供行号信息。这是下一节的主题。
添加行号
调试器允许程序员逐行执行应用程序。为此,调试器需要知道哪些机器指令属于源代码中的哪一行。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 类型。当然,我们还需要一个名为Dbuilder
的llvm::DIBuilder
类的实例,就像我们在前面的章节中所做的那样。还需要一个指向编译单元实例的指针;我们将其存储在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
类的以下方法使用了这些成员:
-
首先,我们需要创建编译单元,这在我们构造函数中完成。我们在这里也创建了包含编译单元的文件。稍后,我们可以通过
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; llvm::StringRef CUFlags; unsigned ObjCRunTimeVersion = 0; llvm::StringRef SplitName; llvm::DICompileUnit::DebugEmissionKind EmissionKind = llvm::DICompileUnit::DebugEmissionKind::FullDebug; CU = DBuilder.createCompileUnit( llvm::dwarf::DW_LANG_Modula2, File, "tinylang", IsOptimized, CUFlags, ObjCRunTimeVersion, SplitName, EmissionKind); }
-
通常,我们需要提供一个行号。行号可以从源管理器的位置推导出来,这在大多数 AST 节点中都是可用的。源管理器可以将此转换为行号:
unsigned CGDebugInfo::getLineNumber(SMLoc Loc) { return CGM.getASTCtx().getSourceMgr().FindLineNumber( Loc); }
-
范围的信息存储在栈上。我们需要方法来打开和关闭范围以及检索当前范围。编译单元是全局范围,我们自动添加:
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(); }
-
接下来,我们必须为每个需要转换的类型类别创建一个方法。
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"); }
-
如果类型名称只是重命名,那么我们必须将其映射到类型定义。在这里,我们需要使用范围和行号信息:
llvm::DIType * CGDebugInfo::getAliasType(AliasTypeDeclaration *Ty) { return DBuilder.createTypedef( getType(Ty->getType()), Ty->getName(), CU->getFile(), getLineNumber(Ty->getLocation()), getScope()); }
-
创建数组的调试信息需要指定大小和对齐。我们可以从
DataLayout
类中检索这些数据。我们还需要指定数组的索引范围:llvm::DIType * CGDebugInfo::getArrayType(ArrayTypeDeclaration *Ty) { auto *ATy = llvm::cast<llvm::ArrayType>(CGM.convertType(Ty)); const llvm::DataLayout &DL = CGM.getModule()->getDataLayout(); Expr *Nums = Ty->getNums(); uint64_t NumElements = llvm::cast<IntegerLiteral>(Nums) ->getValue() .getZExtValue(); llvm::SmallVector<llvm::Metadata *, 4> Subscripts; Subscripts.push_back( DBuilder.getOrCreateSubrange(0, NumElements)); return DBuilder.createArrayType( DL.getTypeSizeInBits(ATy) * 8, 1 << Log2(DL.getABITypeAlign(ATy)), getType(Ty->getType()), DBuilder.getOrCreateArray(Subscripts)); }
-
使用所有这些单个方法,我们可以创建一个中心方法来创建类型的元数据。这个元数据也负责缓存数据:
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; }
-
我们还需要添加一个方法来生成全局变量的元数据:
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); }
-
为了生成过程的调试信息,我们需要为过程类型创建元数据。为此,我们需要一个参数类型的列表,其中返回类型是第一个条目。如果过程没有返回类型,那么我们必须使用未指定的类型;这被称为
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, 1 << Log2(DL.getABITypeAlign(PTy))); } Types.push_back(PT); } return DBuilder.createSubroutineType( DBuilder.getOrCreateTypeArray(Types)); }
-
对于实际的过程本身,我们现在可以使用在上一步骤中创建的过程类型来创建调试信息。过程也打开了一个新的作用域,因此我们必须将过程推入作用域栈。我们还必须将 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); }
-
当达到过程的末尾时,我们必须通知构建器完成此过程的调试信息构建。我们还需要从作用域栈中移除过程:
void CGDebugInfo::emitProcedureEnd( ProcedureDeclaration *Decl, llvm::Function *Fn) { if (Fn && Fn->getSubprogram()) DBuilder.finalizeSubprogram(Fn->getSubprogram()); closeScope(); }
-
最后,当我们完成添加调试信息后,我们需要在构建器上实现
finalize()
方法。生成的调试信息随后被验证。这是开发过程中的一个重要步骤,因为它有助于你找到错误生成的元数据:void CGDebugInfo::finalize() { DBuilder.finalize(); }
调试信息只有在用户请求时才应生成。这意味着我们需要一个新的命令行开关来实现这一点。我们将将其添加到 CGModule
类的文件中,并且我们还将在这个类内部使用它:
static llvm::cl::opt<bool>
Debug("g", llvm::cl::desc("Generate debug information"),
llvm::cl::init(false));
-g
选项可以与 tinylang
编译器一起使用来生成调试元数据。
此外,CGModule
类持有 std::unique_ptr<CGDebugInfo>
类的一个实例。该指针在构造函数中初始化,用于设置命令行开关:
if (Debug)
DebugInfo.reset(new CGDebugInfo(*this));
在 CGModule.h
中定义的获取方法中,我们简单地返回指针:
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,
getScope());
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 pipeline。pass 管理器在 IR 上执行 pass pipeline,这是我们的编译器产生的。因此,你需要了解 pass 管理器做什么以及如何构建一个 pass pipeline。编程语言的语义可能需要开发新的 passes,我们必须将这些 passes 添加到管道中。
在本章中,你将学习以下内容:
-
如何利用 LLVM pass 管理器在 LLVM 中实现 passes
-
如何在 LLVM 项目中实现一个作为示例的 instrumentation pass,以及一个独立的插件
-
在使用 LLVM 工具中的 pprofiler pass 时,你将学习如何使用
opt
和clang
与 pass 插件一起使用 -
在向你的编译器添加优化 pipeline 时,你将使用基于新 pass 管理器的优化 pipeline 扩展
tinylang
编译器。
到本章结束时,你将了解如何开发一个新的 pass,以及如何将其添加到 pass 管道中。你还将能够在你的编译器中设置 pass 管道。
技术要求
本章的源代码可在github.com/PacktPublishing/Learn-LLVM-17/tree/main/Chapter07
找到。
LLVM 的 Pass 管理器
LLVM 核心库优化编译器创建的 IR,并将其转换为对象代码。这个巨大的任务被分解为称为passes的单独步骤。这些 pass 需要按正确的顺序执行,这是 pass 管理器的目标。
为什么不直接硬编码 pass 的顺序呢?你的编译器的用户通常期望编译器提供不同级别的优化。开发者更倾向于在开发时优先考虑快速的编译速度,而不是优化。最终的应用程序应该尽可能快地运行,并且你的编译器应该能够执行复杂的优化,接受更长的编译时间。不同级别的优化意味着需要执行不同数量的优化 pass。因此,作为一个编译器编写者,你可能想利用你对源语言的知识来提供自己的 pass。例如,你可能想用内联 IR 或预计算的結果来替换已知的库函数。对于 C 语言,这样的 pass 是 LLVM 库的一部分,但对于其他语言,你可能需要自己提供。在引入自己的 pass 之后,你可能需要重新排序或添加一些 pass。例如,如果你知道你的 pass 操作会留下一些不可达的 IR 代码,那么你希望在你的 pass 之后额外运行死代码删除 pass。pass 管理器帮助组织这些需求。
流程通常根据其工作的范围进行分类:
-
一个 模块流程 以整个模块作为输入。此类流程在其给定的模块上执行其工作,并可用于此模块内的过程内操作。
-
一个 调用图流程 在调用图的 强连通分量(SCCs)上操作。它按自下而上的顺序遍历这些组件。
-
一个 函数流程 以单个函数作为输入,并且只在此函数上执行其工作。
-
一个 循环流程 在函数内的循环上工作。
除了 IR 代码之外,流程还可能需要、更新或使某些分析结果无效。执行了许多不同的分析,例如别名分析或构建支配树。如果流程需要此类分析,则可以从分析管理器请求它。如果信息已经计算,则将返回缓存的結果。否则,将计算信息。如果流程更改 IR 代码,则需要宣布哪些分析结果是保留的,以便在必要时使缓存的分析信息无效。
在底层,流程管理器确保以下内容:
-
分析结果在流程之间共享。这需要跟踪哪个流程需要哪种分析以及每个分析的状态。目标是避免不必要的分析预计算,并尽快释放分析结果占用的内存。
-
流程以管道方式执行。例如,如果应该按顺序执行多个函数流程,则流程管理器将每个这些函数流程运行在第一个函数上。然后,它将运行所有函数流程在第二个函数上,依此类推。这里的底层思想是改善缓存行为,因为编译器只对有限的数据集(一个 IR 函数)执行转换,然后转到下一个有限的数据集。
让我们实现一个新的 IR 转换流程,并探索如何将其添加到优化流程中。
实现一个新的流程
流程可以对 LLVM IR 执行任意复杂的转换。为了说明添加新流程的机制,我们添加了一个执行简单仪表化的流程。
为了调查程序的性能,了解函数被调用的频率以及它们运行的时间很有趣。收集这些数据的一种方法是在每个函数中插入计数器。这个过程被称为 ppprofiler
。我们将开发新的流程,使其可以作为独立插件使用,或者作为插件添加到 LLVM 源树中。之后,我们将探讨随 LLVM 一起提供的流程如何集成到框架中。
将 ppprofiler 流程作为插件开发
在本节中,我们将探讨如何从 LLVM 树中创建一个新的作为插件的pass
。新pass
的目标是在函数的入口处插入对__ppp_enter()
函数的调用,并在每个返回指令之前插入对__ppp_exit()
函数的调用。仅传递当前函数的名称作为参数。这些函数的实现可以计算调用次数并测量经过的时间。我们将在本章末尾实现这个运行时支持。我们将检查如何开发这个pass
。
我们将源代码存储在PPProfiler.cpp
文件中。按照以下步骤操作:
-
首先,让我们包含一些文件:
#include "llvm/ADT/Statistic.h" #include "llvm/IR/Function.h" #include "llvm/IR/PassManager.h" #include "llvm/Passes/PassBuilder.h" #include "llvm/Passes/PassPlugin.h" #include "llvm/Support/Debug.h"
-
为了缩短源代码,我们将告诉编译器我们正在使用
llvm
命名空间:using namespace llvm;
-
LLVM 的内置调试基础设施要求我们定义一个调试类型,这是一个字符串。这个字符串稍后会在打印的统计信息中显示:
#define DEBUG_TYPE "ppprofiler"
-
接下来,我们将定义一个带有
ALWAYS_ENABLED_STATISTIC
宏的计数器变量。第一个参数是计数器变量的名称,而第二个参数是将在统计中打印的文本:ALWAYS_ENABLED_STATISTIC( NumOfFunc, "Number of instrumented functions.");
注意
可以使用两个宏来定义计数器变量。如果你使用STATISTIC
宏,那么统计值仅在调试构建中收集,如果启用了断言,或者在 CMake 命令行上设置了LLVM_FORCE_ENABLE_STATS
为ON
。如果你使用ALWAYS_ENABLED_STATISTIC
宏,那么统计值总是被收集。然而,使用–stats
命令行选项打印统计信息仅适用于前一种方法。如果需要,你可以通过调用llvm::PrintStatistics(llvm::raw_ostream)
函数来打印收集到的统计信息。
-
接下来,我们必须在匿名命名空间中声明
pass
类。该类继承自PassInfoMixin
模板。这个模板仅添加了一些样板代码,例如name()
方法。它不用于确定pass
的类型。当pass
执行时,run()
方法会被 LLVM 调用。我们还需要一个名为instrument()
的辅助方法:namespace { class PPProfilerIRPass : public llvm::PassInfoMixin<PPProfilerIRPass> { public: llvm::PreservedAnalyses run(llvm::Module &M, llvm::ModuleAnalysisManager &AM); private: void instrument(llvm::Function &F, llvm::Function *EnterFn, llvm::Function *ExitFn); }; }
-
现在,让我们定义如何对函数进行仪器化。除了要仪器化的函数外,还需要传递要调用的函数:
void PPProfilerIRPass::instrument(llvm::Function &F, Function *EnterFn, Function *ExitFn) {
-
在函数内部,我们更新统计计数器:
++NumOfFunc;
-
为了方便插入 IR 代码,我们需要
IRBuilder
类的实例。我们将它设置到第一个基本块,即函数的入口块:IRBuilder<> Builder(&*F.getEntryBlock().begin());
-
现在我们有了构建器,我们可以插入一个全局常量,该常量包含我们希望进行仪器化的函数的名称:
GlobalVariable *FnName = Builder.CreateGlobalString(F.getName());
-
接下来,我们将插入对
__ppp_enter()
函数的调用,并将名称作为参数传递:Builder.CreateCall(EnterFn->getFunctionType(), EnterFn, {FnName});
-
要调用
__ppp_exit()
函数,我们必须定位所有返回指令。方便的是,由调用SetInsertionPoint()
函数设置的插入点在传递的指令之前,因此我们只需在那个点插入调用即可:for (BasicBlock &BB : F) { for (Instruction &Inst : BB) { if (Inst.getOpcode() == Instruction::Ret) { Builder.SetInsertPoint(&Inst); Builder.CreateCall(ExitFn->getFunctionType(), ExitFn, {FnName}); } } } }
-
接下来,我们将实现
run()
方法。LLVM 通过模块传递我们的通过,以及一个分析管理器,如果需要,我们可以从中请求分析结果:PreservedAnalyses PPProfilerIRPass::run(Module &M, ModuleAnalysisManager &AM) {
-
这里有一点小麻烦:如果包含
__ppp_enter()
和__ppp_exit()
函数实现的运行时模块被仪器化,那么我们会遇到麻烦,因为我们创建了一个无限递归。为了避免这种情况,如果这些函数中的任何一个被定义,我们必须简单地什么也不做:if (M.getFunction("__ppp_enter") || M.getFunction("__ppp_exit")) { return PreservedAnalyses::all(); }
-
现在,我们准备声明函数。这里没有什么不寻常的:首先创建函数类型,然后是函数:
Type *VoidTy = Type::getVoidTy(M.getContext()); PointerType *PtrTy = PointerType::getUnqual(M.getContext()); FunctionType *EnterExitFty = FunctionType::get(VoidTy, {PtrTy}, false); Function *EnterFn = Function::Create( EnterExitFty, GlobalValue::ExternalLinkage, "__ppp_enter", M); Function *ExitFn = Function::Create( EnterExitFty, GlobalValue::ExternalLinkage, "__ppp_exit", M);
-
现在我们需要做的就是遍历模块中的所有函数,并通过调用我们的
instrument()
方法来对找到的函数进行仪器化。当然,我们需要忽略函数声明,因为它们只是原型。也可能存在没有名称的函数,这不适合我们的方法。我们也会过滤掉这些函数:for (auto &F : M.functions()) { if (!F.isDeclaration() && F.hasName()) instrument(F, EnterFn, ExitFn); }
-
最后,我们必须声明我们没有保留任何分析。这可能是过于悲观了,但通过这样做我们可以确保安全:
return PreservedAnalyses::none(); }
我们新通过的功能现在已实现。为了能够使用我们的通过,我们需要将其注册到
PassBuilder
对象中。这可以通过两种方式实现:静态或动态。如果插件是静态链接的,那么它需要提供一个名为get<Plugin-Name>PluginInfo()
的函数。要使用动态链接,需要提供llvmGetPassPluginInfo()
函数。在两种情况下,都会返回一个PassPluginLibraryInfo
结构体的实例,该结构体提供有关插件的一些基本信息。最重要的是,这个结构体包含一个指向注册通过函数的指针。让我们将其添加到我们的源代码中。 -
在
RegisterCB()
函数中,我们注册了一个 Lambda 函数,该函数在解析通过管道字符串时被调用。如果通过的名称是ppprofiler
,那么我们将我们的通过添加到模块通过管理器中。这些回调将在下一节中进一步说明:void RegisterCB(PassBuilder &PB) { PB.registerPipelineParsingCallback( [](StringRef Name, ModulePassManager &MPM, ArrayRef<PassBuilder::PipelineElement>) { if (Name == "ppprofiler") { MPM.addPass(PPProfilerIRPass()); return true; } return false; }); }
-
当插件静态链接时,会调用
getPPProfilerPluginInfo()
函数。它返回有关插件的一些基本信息:llvm::PassPluginLibraryInfo getPPProfilerPluginInfo() { return {LLVM_PLUGIN_API_VERSION, "PPProfiler", "v0.1", RegisterCB}; }
-
最后,如果插件是动态链接的,那么当插件被加载时将调用
llvmGetPassPluginInfo()
函数。然而,当将此代码静态链接到工具中时,可能会遇到链接器错误,因为该函数可能在多个源文件中定义。解决方案是使用宏来保护该函数:#ifndef LLVM_PPPROFILER_LINK_INTO_TOOLS extern "C" LLVM_ATTRIBUTE_WEAK ::llvm::PassPluginLibraryInfo llvmGetPassPluginInfo() { return getPPProfilerPluginInfo(); } #endif
这样,我们就实现了通过插件。在我们查看如何使用新插件之前,让我们看看如果我们想将通过插件添加到 LLVM 源树中,需要做哪些更改。
将通过添加到 LLVM 源树中
如果你计划使用预编译的 clang 等工具,将新转换作为插件实现是有用的。另一方面,如果你编写自己的编译器,那么将你的新转换直接添加到 LLVM 源树中可能有很好的理由。你可以以两种不同的方式这样做——作为插件和作为一个完全集成的转换。插件方法需要的更改较少。
利用 LLVM 源树内部的插件机制
执行对 LLVM IR 进行转换的转换函数的源代码位于 llvm-project/llvm/lib/Transforms
目录中。在此目录内,创建一个名为 PPProfiler
的新目录,并将源文件 PPProfiler.cpp
复制到其中。你不需要对源代码进行任何修改!
要将新插件集成到构建系统中,创建一个名为 CMakeLists.txt
的文件,并包含以下内容:
add_llvm_pass_plugin(PPProfiler PPProfiler.cpp)
最后,在父目录中的 CmakeLists.txt
文件中,你需要通过添加以下行来包含新的源目录:
add_subdirectory(PPProfiler)
你现在可以准备使用添加了 PPProfiler
的 LLVM 进行构建了。切换到 LLVM 的构建目录,并手动运行 Ninja:
$ ninja install
CMake 会检测构建描述的变化并重新运行配置步骤。你将看到额外的行:
-- Registering PPProfiler as a pass plugin (static build: OFF)
这表明插件已被检测到,并已作为共享库构建。在安装步骤之后,你将在 <install directory>/lib
目录中找到该共享库,PPProfiler.so
。
到目前为止,与上一节中的转换插件相比,唯一的区别是共享库作为 LLVM 的一部分被安装。但你也可以将新的插件静态链接到 LLVM 工具。为此,你需要重新运行 CMake 配置,并在命令行上添加 -DLLVM_PPPROFILER_LINK_INTO_TOOLS=ON
选项。从 CMake 中查找此信息以确认更改后的构建选项:
-- Registering PPProfiler as a pass plugin (static build: ON)
再次编译和安装 LLVM 后,以下内容发生了变化:
-
插件被编译到静态库
libPPProfiler.a
中,并且该库被安装到<install directory>/lib
目录中。 -
LLVM 工具,如 opt,都与该库链接。
-
插件被注册为扩展。你可以检查
<install directory>/include/llvm/Support/Extension.def
文件现在是否包含以下行:HANDLE_EXTENSION(PPProfiler)
此外,所有支持此扩展机制的工具都会获取新的转换。在 创建优化管道 部分中,你将学习如何在你的编译器中实现这一点。
这种方法效果很好,因为新的源文件位于一个单独的目录中,并且只更改了一个现有文件。这最大限度地减少了如果你尝试保持修改后的 LLVM 源树与主仓库同步时的合并冲突概率。
也有情况,将新的 pass 作为插件添加并不是最佳方式。LLVM 提供的 pass 使用不同的注册方式。如果您开发了一个新的 pass 并提议将其添加到 LLVM 中,并且 LLVM 社区接受了您的贡献,那么您将希望使用相同的注册机制。
完全集成 pass 到 pass 注册表中
要完全集成新的 pass 到 LLVM 中,插件的源代码需要稍微不同的结构。这样做的主要原因是因为 pass 类的构造函数是从 pass 注册表中调用的,这要求类接口被放入头文件中。
与之前一样,您必须将新的 pass 放入 LLVM 的Transforms
组件中。通过创建llvm-project/llvm/include/llvm/Transforms/PPProfiler/PPProfiler.h
头文件开始实现;该文件的内容是类定义;将其放入llvm
命名空间。不需要其他更改:
#ifndef LLVM_TRANSFORMS_PPPROFILER_PPPROFILER_H
#define LLVM_TRANSFORMS_PPPROFILER_PPPROFILER_H
#include "llvm/IR/PassManager.h"
namespace llvm {
class PPProfilerIRPass
: public llvm::PassInfoMixin<PPProfilerIRPass> {
public:
llvm::PreservedAnalyses
run(llvm::Module &M, llvm::ModuleAnalysisManager &AM);
private:
void instrument(llvm::Function &F,
llvm::Function *EnterFn,
llvm::Function *ExitFn);
};
} // namespace llvm
#endif
接下来,将 pass 插件的源文件PPProfiler.cpp
复制到新目录llvm-project/llvm/lib/Transforms/PPProfiler
中。此文件需要按以下方式更新:
-
由于类定义现在在头文件中,您必须从该文件中移除类定义。在顶部,添加对头文件的
#include
指令:#include "llvm/Transforms/PPProfiler/PPProfiler.h"
-
必须删除
llvmGetPassPluginInfo()
函数,因为 pass 没有构建成它自己的共享库。
与之前一样,您还需要提供一个CMakeLists.txt
文件用于构建。您必须声明新的 pass 作为一个新组件:
add_llvm_component_library(LLVMPPProfiler
PPProfiler.cpp
LINK_COMPONENTS
Core
Support
)
然后,就像在上一节中一样,您需要通过在父目录的CMakeLists.txt
文件中添加以下行来包含新的源目录:
add_subdirectory(PPProfiler)
在 LLVM 内部,可用的 passes 被保存在llvm/lib/Passes/PassRegistry.def
数据库文件中。您需要更新此文件。新的 pass 是一个模块 pass,因此我们需要在文件中搜索定义模块 passes 的部分,例如,通过搜索MODULE_PASS
宏。在此部分中,添加以下行:
MODULE_PASS("ppprofiler", PPProfilerIRPass())
此数据库文件用于llvm/lib/Passes/PassBuilder.cpp
类。此文件需要包含您的新头文件:
#include "llvm/Transforms/PPProfiler/PPProfiler.h"
这些都是基于新 pass 插件版本所需的所有源代码更改。
由于您创建了一个新的 LLVM 组件,因此还需要在llvm/lib/Passes/CMakeLists.txt
文件中添加一个链接依赖项。在LINK_COMPONENTS
关键字下,您需要添加一行,包含新组件的名称:
PPProfiler
Et voilà – 您现在可以构建和安装 LLVM。新的 pass,ppprofiler
,现在对所有 LLVM 工具都可用。它已被编译进libLLVMPPProfiler.a
库,并在构建系统中作为PPProfiler
组件可用。
到目前为止,我们已经讨论了如何创建一个新的 pass。在下一节中,我们将探讨如何使用ppprofiler
pass。
使用 LLVM 工具的 pprofiler pass
回想一下我们在 Developing the ppprofiler pass as a plugin 部分中从 LLVM 树中开发出来的 ppprofiler 传递函数。在这里,我们将学习如何使用这个传递函数与 LLVM 工具一起使用,如 opt
和 clang
,因为它们可以加载插件。
让我们先看看 opt
。
在 opt 中运行传递插件
要尝试新的插件,你需要一个包含 LLVM IR 的文件。最简单的方法是将一个 C 程序翻译过来,例如一个基本的“Hello World”风格程序:
#include <stdio.h>
int main(int argc, char *argv[]) {
puts("Hello");
return 0;
}
使用 clang
编译此文件,hello.c
:
$ clang -S -emit-llvm -O1 hello.c
你将得到一个非常简单的 IR 文件,名为 hello.ll
,其中包含以下代码:
$ cat hello.ll
@.str = private unnamed_addr constant [6 x i8] c"Hello\00",
align 1
define dso_local i32 @main(
i32 noundef %0, ptr nocapture noundef readnone %1) {
%3 = tail call i32 @puts(
ptr noundef nonnull dereferenceable(1) @.str)
ret i32 0
}
这足以测试传递函数。
要运行此传递函数,你必须提供一些参数。首先,你需要告诉 opt
通过 --load-pass-plugin
选项加载共享库。要运行单个传递函数,你必须指定 --passes
选项。使用 hello.ll
文件作为输入,你可以运行以下命令:
$ opt --load-pass-plugin=./PPProfile.so \
--passes="ppprofiler" --stats hello.ll -o hello_inst.bc
如果启用了统计生成,你将看到以下输出:
===--------------------------------------------------------===
... Statistics Collected ...
===--------------------------------------------------------===
1 ppprofiler - Number of instrumented functions.
否则,你将被告知统计收集未启用:
Statistics are disabled. Build with asserts or with
-DLLVM_FORCE_ENABLE_STATS
位码文件 hello_inst.bc
是结果。你可以使用 llvm-dis
工具将此文件转换为可读的 IR。正如预期的那样,你会看到对 __ppp_enter()
和 __ppp_exit()
函数的调用以及一个用于函数名称的新常量:
$ llvm-dis hello_inst.bc -o –
@.str = private unnamed_addr constant [6 x i8] c"Hello\00",
align 1
@0 = private unnamed_addr constant [5 x i8] c"main\00",
align 1
define dso_local i32 @main(i32 noundef %0,
ptr nocapture noundef readnone %1) {
call void @__ppp_enter(ptr @0)
%3 = tail call i32 @puts(
ptr noundef nonnull dereferenceable(1) @.str)
call void @__ppp_exit(ptr @0)
ret i32 0
}
这已经看起来不错了!如果我们可以将这个 IR 转换为可执行文件并运行它,那就更好了。为此,你需要为被调用的函数提供实现。
通常,对某个功能的运行时支持比将其添加到编译器本身更复杂。这种情况也是如此。当调用 __ppp_enter()
和 __ppp_exit()
函数时,你可以将其视为一个事件。为了稍后分析数据,有必要保存这些事件。你希望获取的基本数据是事件类型、函数名称及其地址,以及时间戳。没有技巧,这并不像看起来那么简单。让我们试试看。
创建一个名为 runtime.c
的文件,内容如下:
-
你需要文件 I/O、标准函数和时间支持。这由以下包含提供:
#include <stdio.h> #include <stdlib.h> #include <time.h>
-
对于文件,需要一个文件描述符。此外,当程序结束时,应该正确关闭该文件描述符:
static FILE *FileFD = NULL; static void cleanup() { if (FileFD == NULL) { fclose(FileFD); FileFD = NULL; } }
-
为了简化运行时,只使用一个固定的输出名称。如果文件未打开,则打开文件并注册
cleanup
函数:static void init() { if (FileFD == NULL) { FileFD = fopen("ppprofile.csv", "w"); atexit(&cleanup); } }
-
你可以使用
clock_gettime()
函数来获取时间戳。CLOCK_PROCESS_CPUTIME_ID
参数返回此进程消耗的时间。请注意,并非所有系统都支持此参数。如果需要,你可以使用其他时钟,例如CLOCK_REALTIME
:typedef unsigned long long Time; static Time get_time() { struct timespec ts; clock_gettime(CLOCK_PROCESS_CPUTIME_ID, &ts); return 1000000000L * ts.tv_sec + ts.tv_nsec; }
-
现在,定义
__ppp_enter()
函数很容易。只需确保文件已打开,获取时间戳,并写入事件:void __ppp_enter(const char *FnName) { init(); Time T = get_time(); void *Frame = __builtin_frame_address(1); fprintf(FileFD, // "enter|name|clock|frame" „enter|%s|%llu|%p\n", FnName, T, Frame); }
-
__ppp_exit()
函数仅在事件类型方面有所不同:void __ppp_exit(const char *FnName) { init(); Time T = get_time(); void *Frame = __builtin_frame_address(1); fprintf(FileFD, // "exit|name|clock|frame" „exit|%s|%llu|%p\n", FnName, T, Frame); }
这就完成了对运行时支持的简单实现。在我们尝试之前,应该对实现进行一些说明,因为很明显,这里有几个问题部分。
首先,由于只有一个文件描述符,并且对其访问没有保护,因此实现不是线程安全的。尝试使用此运行时实现与多线程程序一起使用,很可能会导致输出文件中的数据混乱。
此外,我们忽略了检查与 I/O 相关的函数的返回值,这可能导致数据丢失。
但最重要的是,事件的戳记并不精确。调用函数已经增加了开销,但在该函数中执行 I/O 操作使其变得更糟。原则上,你可以匹配函数的进入和退出事件并计算函数的运行时间。然而,这个值本身是有缺陷的,因为它可能包括 I/O 所需的时间。总之,不要相信这里记录的时间。
尽管存在所有这些缺陷,这个小运行时文件仍允许我们生成一些输出。将带有运行时代码的文件的 bitcode 与编译器文件一起编译,并运行生成的可执行文件:
$ clang hello_inst.bc runtime.c
$ ./a.out
这在包含以下内容的目录中生成一个名为ppprofile.csv
的新文件:
$ cat ppprofile.csv
enter|main|3300868|0x1
exit|main|3760638|0x1
太棒了——新的 pass 和运行时似乎都工作得很好!
指定 pass pipeline
使用–-passes
选项,你不仅可以命名单个 pass,还可以描述整个 pipeline。例如,优化级别 2 的默认 pipeline 命名为default<O2>
。你可以使用–-passes="ppprofile,default<O2>"
参数在默认 pipeline 之前运行ppprofile
pass。请注意,此类 pipeline 描述中的 pass 名称必须是同一类型。
现在,让我们转向使用新的 pass 与clang
。
将新的 pass 插入到 clang 中
在上一节中,你学习了如何使用opt
运行单个 pass。如果你需要调试一个 pass,这很有用,但对于真正的编译器,步骤不应该那么复杂。
为了达到最佳效果,编译器需要按照一定的顺序运行优化通行。LLVM 通行管理器为通行执行提供了默认顺序。这也被称为 opt
,您可以使用 –passes
选项指定不同的通行管道。这很灵活,但对于用户来说也很复杂。实际上,大多数时候,您只想在非常具体的位置添加新的通行,例如在运行优化通行之前或循环优化过程结束时。这些位置被称为 PassBuilder
类允许您在扩展点注册通行。例如,您可以通过调用 registerPipelineStartEPCallback()
方法将通行添加到优化管道的开始处。这正是我们需要的 ppprofiler
通行的地方。在优化过程中,函数可能会被内联,而通行可能会错过这些内联函数。相反,在优化通行之前运行通行可以保证所有函数都被仪器化。
要使用这种方法,您需要扩展通行插件中的 RegisterCB()
函数。将以下代码添加到函数中:
PB.registerPipelineStartEPCallback(
[](ModulePassManager &PM, OptimizationLevel Level) {
PM.addPass(PPProfilerIRPass());
});
当通行管理器填充默认的通行管道时,它会调用所有扩展点的回调。我们只需在这里添加新的通行。
要将插件加载到 clang
中,您可以使用 -fpass-plugin
选项。现在创建 hello.c
文件的仪器化可执行文件几乎变得微不足道:
$ clang -fpass-plugin=./PPProfiler.so hello.c runtime.c
请运行可执行文件并验证运行是否创建了 ppprofiler.csv
文件。
注意
由于通行检查特殊函数尚未在模块中声明,因此 runtime.c
文件没有被仪器化。
这已经看起来更好了,但它是否适用于更大的程序?让我们假设您想为 第五章 构建 tinylang
编译器的仪器化二进制文件。您将如何做?
您可以在 CMake 命令行上传递编译器和链接器标志,这正是我们所需要的。C++ 编译器的标志在 CMAKE_CXX_FLAGS
变量中给出。因此,在 CMake 命令行上指定以下内容会将新的通行添加到所有编译器运行中:
-DCMAKE_CXX_FLAGS="-fpass-plugin=<PluginPath>/PPProfiler.so"
请将 <PluginPath>
替换为共享库的绝对路径。
类似地,指定以下内容会将 runtime.o
文件添加到每个链接调用中。再次提醒,请将 <RuntimePath>
替换为 runtime.c
编译版本的绝对路径:
-DCMAKE_EXE_LINKER_FLAGS="<RuntimePath>/runtime.o"
当然,这需要使用 clang
作为构建编译器。确保 clang
作为构建编译器使用的最快方法是相应地设置 CC
和 CXX
环境变量:
export CC=clang
export CXX=clang++
使用这些附加选项,第五章 中的 CMake 配置应正常运行。
在构建 tinylang
可执行文件后,您可以使用示例 Gcd.mod
文件运行它。这次 ppprofile.csv
文件也将被写入,这次有超过 44,000 行!
当然,拥有这样的数据集会引发一个问题:你是否能从中获得有用的信息。例如,获取最常调用的 10 个函数的列表,包括函数的调用次数和在该函数中花费的时间,将是有用的信息。幸运的是,在 Unix 系统中,你有一些工具可以帮助你。让我们构建一个简短的管道,匹配进入事件和退出事件,计算函数,并显示前 10 个函数。awk
Unix 工具帮助完成这些步骤中的大多数。
为了匹配进入事件和退出事件,进入事件必须存储在 record
关联映射中。当匹配到退出事件时,会查找存储的进入事件,并写入新的记录。发出的行包含进入事件的戳记,退出事件的戳记,以及两者之间的差异。我们必须将此放入 join.awk
文件中:
BEGIN { FS = "|"; OFS = "|" }
/enter/ { record[$2] = $0 }
/exit/ { split(record[$2],val,"|")
print val[2], val[3], $3, $3-val[3], val[4] }
为了计算函数调用和执行,使用了两个关联映射,count
和 sum
。在 count
中,计算函数调用,而在 sum
中,添加执行时间。最后,映射被导出。你可以将此放入 avg.awk
文件中:
BEGIN { FS = "|"; count[""] = 0; sum[""] = 0 }
{ count[$1]++; sum[$1] += $4 }
END { for (i in count) {
if (i != "") {
print count[i], sum[i], sum[i]/count[i], I }
} }
运行这两个脚本后,结果可以按降序排序,然后可以从文件中取出前 10 行。然而,我们仍然可以改进函数名,__ppp_enter()
和 __ppp_exit()
,它们是混淆的,因此难以阅读。使用 llvm-cxxfilt
工具,可以取消混淆。以下是一个 demangle.awk
脚本:
{ cmd = "llvm-cxxfilt " $4
(cmd) | getline name
close(cmd); $4 = name; print }
要获取前 10 个函数调用,你可以运行以下命令:
$ cat ppprofile.csv | awk -f join.awk | awk -f avg.awk |\
sort -nr | head -15 | awk -f demangle.awk
这里是输出的一些示例行:
446 1545581 3465.43 charinfo::isASCII(char)
409 826261 2020.2 llvm::StringRef::StringRef()
382 899471 2354.64
tinylang::Token::is(tinylang::tok::TokenKind) const
171 1561532 9131.77 charinfo::isIdentifierHead(char)
第一个数字是函数的调用次数,第二个是累计执行时间,第三个数字是平均执行时间。正如之前所解释的,尽管调用次数应该是准确的,但不要相信时间值。
到目前为止,我们已经实现了一个新的仪器传递,要么作为插件,要么作为 LLVM 的补充,并在一些实际场景中使用了它。在下一节中,我们将探讨如何在我们的编译器中设置优化管道。
向你的编译器添加优化管道
在前几章中我们开发的 tinylang
编译器对 IR 代码不进行优化。在接下来的几个小节中,我们将向编译器添加一个优化管道来实现这一点。
创建优化管道
PassBuilder
类对于设置优化管道至关重要。这个类了解所有已注册的 pass,并可以从文本描述中构建 pass 管道。我们可以使用这个类从命令行上的描述创建 pass 管道,或者使用基于请求的优化级别的默认管道。我们还支持使用 pass 插件,例如我们在上一节中讨论的ppprofiler
pass 插件。有了这个,我们可以模拟opt工具的部分功能,并且也可以为命令行选项使用类似的名字。
PassBuilder
类填充了一个ModulePassManager
类的实例,这是持有构建的 pass 管道并运行它的 pass 管理器。代码生成 pass 仍然使用旧的 pass 管理器。因此,我们必须保留旧的 pass 管理器用于此目的。
对于实现,我们将从我们的tinylang
编译器扩展tools/driver/Driver.cpp
文件:
-
我们将使用新的类,因此我们将从添加新的包含文件开始。
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"
-
为了使用新 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"));
-
用户通过优化级别影响 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));
-
LLVM 的插件机制支持静态链接插件的插件注册表,该注册表在项目配置期间创建。为了使用此注册表,我们必须包含
llvm/Support/Extension.def
数据库文件以创建返回插件信息的函数的原型:#define HANDLE_EXTENSION(Ext) \ llvm::PassPluginLibraryInfo get##Ext##PluginInfo(); #include "llvm/Support/Extension.def"
-
现在,我们必须用新版本替换现有的
emit()
函数。此外,我们必须在函数顶部声明所需的PassBuilder
实例:bool emit(StringRef Argv0, llvm::Module *M, llvm::TargetMachine *TM, StringRef InputFilename) { PassBuilder PB(TM);
-
为了实现命令行上提供的 pass 插件的支持,我们必须遍历用户提供的插件库列表,并尝试加载插件。如果失败,我们将发出错误消息;否则,我们将注册 pass:
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); }
-
与静态插件注册表中的信息以类似的方式使用,将那些插件注册到我们的
PassBuilder
实例:#define HANDLE_EXTENSION(Ext) \ get##Ext##PluginInfo().RegisterPassBuilderCallbacks( \ PB); #include "llvm/Support/Extension.def"
-
现在,我们需要声明不同分析管理器的变量。唯一的参数是调试标志:
LoopAnalysisManager LAM(DebugPM); FunctionAnalysisManager FAM(DebugPM); CGSCCAnalysisManager CGAM(DebugPM); ModuleAnalysisManager MAM(DebugPM);
-
接下来,我们必须通过在
PassBuilder
实例上调用相应的register
方法来填充分析管理器。通过这个调用,分析管理器被填充了默认的分析传递,并且也运行了注册回调。我们还必须确保函数分析管理器使用默认的别名分析管道,并且所有分析管理器都知道彼此:FAM.registerPass( [&] { return PB.buildDefaultAAPipeline(); }); PB.registerModuleAnalyses(MAM); PB.registerCGSCCAnalyses(CGAM); PB.registerFunctionAnalyses(FAM); PB.registerLoopAnalyses(LAM); PB.crossRegisterProxies(LAM, FAM, CGAM, MAM);
-
MPM
模块传递管理器持有我们构建的传递管道。实例使用调试标志初始化:ModulePassManager MPM(DebugPM);
-
现在,我们需要实现两种不同的方法来用传递管道填充模块传递管理器。如果用户在命令行上提供了传递管道——也就是说,他们使用了
--passes
选项——那么我们就使用这个作为传递管道:if (!PassPipeline.empty()) { if (auto Err = PB.parsePassPipeline( MPM, PassPipeline)) { WithColor::error(errs(), Argv0) << toString(std::move(Err)) << "\n"; return false; } }
-
否则,我们使用选择的优化级别来确定构建传递管道。默认传递管道的名称是
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; } }
-
这样,对 IR 代码运行转换的传递管道已经设置好了。在此步骤之后,我们需要一个打开的文件来写入结果。系统汇编器和 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; }
-
对于代码生成过程,我们必须使用旧的传递管理器。我们只需声明
CodeGenPM
实例并添加传递,这样就可以在 IR 转换级别提供目标特定信息:legacy::PassManager CodeGenPM; CodeGenPM.add(createTargetTransformInfoWrapperPass( TM->getTargetIRAnalysis()));
-
要输出 LLVM IR,我们必须添加一个传递,该传递将 IR 打印到流中:
if (FileType == CGFT_AssemblyFile && EmitLLVM) { CodeGenPM.add(createPrintModulePass(Out->os())); }
-
否则,我们必须让
TargetMachine
实例添加所需的代码生成传递,这些传递由我们传递的FileType
值指导:else { if (TM->addPassesToEmitFile(CodeGenPM, Out->os(), nullptr, FileType)) { WithColor::error() << "No support for file type\n"; return false; } }
-
在所有这些准备工作之后,我们现在可以执行传递了。首先,我们必须在 IR 模块上运行优化管道。接下来,运行代码生成传递。当然,在所有这些工作之后,我们希望保留输出文件:
MPM.run(*M, MAM); CodeGenPM.run(*M); Out->keep(); return true; }
-
这段代码很多,但过程很简单。当然,我们还需要更新
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)
-
我们的编译器驱动程序支持插件,我们必须宣布这种支持:
add_tinylang_tool(tinylang Driver.cpp SUPPORT_PLUGINS)
-
如前所述,我们必须链接到我们自己的库:
target_link_libraries(tinylang PRIVATE tinylangBasic tinylangCodeGen tinylangLexer tinylangParser tinylangSema)
这些是对源代码和构建系统的必要补充。
-
要构建扩展编译器,你必须切换到你的
build
目录并输入以下命令:$ ninja
构建系统文件的更改将被自动检测,并且在编译和链接我们的更改源之前将运行 cmake
。如果您需要重新运行配置步骤,请按照 第一章 中 安装 LLVM 的 编译 tinylang 应用程序 部分的说明操作。
由于我们已经将opt
工具的选项作为蓝图使用,你应该尝试使用选项来运行tinylang
,以加载一个 pass 插件并运行该插件,就像我们在前面的章节中所做的那样。
根据当前实现,我们可以运行默认的 pass 管道,或者我们可以自己构建一个。后者非常灵活,但在几乎所有情况下,这都会过于冗余。默认管道对 C-like 语言运行得非常好。然而,缺少的是扩展 pass 管道的方法。我们将在下一节中看看如何实现这一点。
扩展 pass 管道
在前面的章节中,我们使用了PassBuilder
类来创建一个 pass 管道,无论是从用户提供的描述还是预定义的名称。现在,让我们看看另一种自定义 pass 管道的方法:使用扩展点。
在构建 pass 管道的过程中,pass 构建器允许添加用户贡献的 pass。这些位置被称为扩展点。存在几个扩展点,如下所示:
-
允许我们在管道开始处添加 pass 的管道开始扩展点
-
允许我们在指令组合器 pass 的每个实例之后添加 pass 的 peephole 扩展点
还存在其他扩展点。要使用扩展点,你必须注册一个回调。在构建 pass 管道的过程中,你的回调将在定义的扩展点处运行,并可以向给定的 pass 管理器添加 pass。
要为管道开始扩展点注册一个回调,你必须调用PassBuilder
类的registerPipelineStartEPCallback()
方法。例如,要将我们的PPProfiler
pass 添加到管道的开始处,你需要将 pass 适配为模块 pass,通过调用createModuleToFunctionPassAdaptor()
模板函数,然后将 pass 添加到模块 pass 管理器:
PB.registerPipelineStartEPCallback(
[](ModulePassManager &MPM) {
MPM.addPass(PPProfilerIRPass());
});
你可以在创建管道之前,在任何位置添加此代码片段,即在调用parsePassPipeline()
方法之前。
对我们在上一节中所做的事情的一个非常自然的扩展是让用户在命令行上传递一个扩展点的管道描述。opt
工具也允许这样做。让我们为管道开始扩展点添加以下代码到tools/driver/Driver.cpp
文件:
-
首先,我们必须为用户提供一个新的命令行来指定管道描述。再次,我们从
opt
工具中获取选项名称:static cl::opt<std::string> PipelineStartEPPipeline( "passes-ep-pipeline-start", cl::desc("Pipeline start extension point));
-
使用 Lambda 函数作为回调是最方便的方法。为了解析管道描述,我们必须调用
PassBuilder
实例的parsePassPipeline()
方法。将 pass 添加到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,您需要为每个扩展点添加前面的代码片段。
-
现在是尝试不同的
pass manager
选项的好时机。使用--debug-pass-manager
选项,您可以跟踪执行顺序中哪些 passes 被执行。您还可以在每个 pass 之前或之后打印 IR,这可以通过--print-before-all
和--print-after-all
选项来实现。如果您创建了您自己的 pass 管道,那么您可以在感兴趣的位置插入print
pass。例如,尝试--passes="print,inline,print"
选项。此外,为了确定哪个 pass 改变了 IR 代码,您可以使用--print-changed
选项,该选项仅在 IR 代码与上一个 pass 的结果相比有变化时打印 IR 代码。大大减少的输出使得跟踪 IR 转换变得容易得多。PassBuilder
类有一个嵌套的OptimizationLevel
类来表示六个不同的优化级别。我们不仅可以将"default<O?>"
管道描述作为parsePassPipeline()
方法的参数,还可以调用buildPerModuleDefaultPipeline()
方法,该方法为请求级别构建默认的优化管道——除了级别O0
。这个优化级别意味着不执行任何优化。因此,没有 passes 被添加到 pass manager 中。如果我们仍然想运行某个 pass,那么我们可以手动将其添加到 pass manager 中。在这个级别上运行的一个简单 pass 是
AlwaysInliner
pass,它将带有always_inline
属性的函数内联到调用者中。在将优化级别的命令行选项值转换为OptimizationLevel
类的相应成员之后,我们可以这样实现:PassBuilder::OptimizationLevel Olevel = …; if (OLevel == PassBuilder::OptimizationLevel::O0) MPM.addPass(AlwaysInlinerPass()); else MPM = PB.buildPerModuleDefaultPipeline(OLevel, DebugPM);
当然,以这种方式可以向 pass manager 添加多个 passes。
PassBuilder
在构建 pass 管道时也会使用addPass()
方法。
运行扩展点回调
由于优化级别 O0
的 pass 管道没有被填充,因此注册的扩展点没有被调用。如果您使用扩展点注册应在 O0
级别运行的 passes,这会存在问题。您可以通过调用 runRegisteredEPCallbacks()
方法来运行注册的扩展点回调,这将导致只包含通过扩展点注册的 passes 的 pass manager。
通过将优化管道添加到 tinylang
,您创建了一个类似于 clang
的优化编译器。LLVM 社区在每个版本中都致力于改进优化和优化管道。因此,默认管道很少不被使用。通常,新 passes 被添加来实现编程语言的某些语义。
摘要
在本章中,你学习了如何为 LLVM 创建一个新的 Pass。你使用 Pass 管道描述和扩展点运行了该 Pass。你通过构建和执行类似于clang
的 Pass 管道,扩展了你的编译器,将tinylang
转换成了一个优化编译器。Pass 管道允许在扩展点添加 Pass,你学习了如何在这些点上注册 Pass。这允许你通过你开发的 Pass 或现有的 Pass 来扩展优化管道。
在下一章中,你将学习clang
的基础知识,以显著减少手动编程。
第三部分:将 LLVM 提升到更高水平
在本节中,你将深入了解 LLVM 的各种底层细节。首先,你将探索 TableGen 语言,这是 LLVM 的领域特定语言,并了解它如何在后端使用。LLVM 还有一个即时(JIT)编译器,你将探索如何使用它并调整以满足你的需求。此外,你还将尝试各种旨在识别应用程序中错误的工具和库。考虑到所有这些,这些知识将使你能够利用 LLVM 尚未支持的新的架构。
本节包含以下章节:
-
第八章, TableGen 语言
-
第九章, JIT 编译
-
第十章, 使用 LLVM 工具进行调试
第八章:TableGen 语言
LLVM 后端的大部分内容是用 TableGen 语言编写的,这是一种用于生成 C++源代码片段的特殊语言,以避免为每个后端实现相似代码并缩短源代码量。因此,了解 TableGen 是很重要的。
在本章中,你将学习以下内容:
-
在理解 TableGen 语言中,你将了解 TableGen 背后的主要思想
-
在实验 TableGen 语言中,你将定义自己的 TableGen 类和记录,并学习 TableGen 语言的语法
-
在从 TableGen 文件生成 C++代码中,你将开发自己的 TableGen 后端
-
TableGen 的缺点
到本章结束时,你将能够使用现有的 TableGen 类来定义你自己的记录。你还将获得如何从头创建 TableGen 类和记录的知识,以及如何开发一个 TableGen 后端以生成源代码。
技术要求
你可以在 GitHub 上找到本章使用的源代码:github.com/PacktPublishing/Learn-LLVM-17/tree/main/Chapter08
。
理解 TableGen 语言
LLVM 自带一种名为TableGen的领域特定语言(DSL)。它被用于生成适用于广泛用例的 C++代码,从而减少了开发者需要编写的代码量。TableGen 语言不是一个完整的编程语言。它仅用于定义记录,这是一个指代名称和值集合的术语。为了理解为什么这种受限的语言是有用的,让我们考察两个例子。
定义 CPU 的一个机器指令通常需要以下典型数据:
-
指令的助记符
-
位模式
-
操作数数量和类型
-
可能的限制或副作用
很容易看出这些数据可以表示为一个记录。例如,一个名为asmstring
的字段可以保存助记符的值;比如说,"add"
。还有一个名为opcode
的字段可以保存指令的二进制表示。这些字段共同描述了一个额外的指令。每个 LLVM 后端都以这种方式描述指令集。
记录是一个如此通用的概念,以至于你可以用它们描述各种各样的数据。另一个例子是命令行选项的定义。一个命令行选项:
-
有一个名称
-
可能有一个可选参数
-
有帮助文本
-
可能属于一组选项
再次,这些数据可以很容易地被视为一个记录。Clang 使用这种方法为 Clang 驱动器的命令行选项。
TableGen 语言
在 LLVM 中,TableGen 语言被用于各种任务。后端的大部分内容是用 TableGen 语言编写的;例如,寄存器文件的定义,所有带有助记符和二进制编码的指令,调用约定,指令选择的模式,以及指令调度的调度模型。LLVM 的其他用途包括内建函数的定义,属性的定义,以及命令行选项的定义。
你可以在llvm.org/docs/TableGen/ProgRef.html
找到《程序员参考》,在llvm.org/docs/TableGen/BackGuide.html
找到《后端开发者指南》。
为了实现这种灵活性,TableGen 语言的解析和语义是在一个库中实现的。要从记录生成 C++代码,你需要创建一个工具,该工具接受解析后的记录并从中生成 C++代码。在 LLVM 中,这个工具被称为llvm-tblgen
,在 Clang 中被称为clang-tblgen
。这些工具包含项目所需的代码生成器。但它们也可以用来学习更多关于 TableGen 语言的知识,这就是我们在下一节将要做的。
尝试使用 TableGen 语言
初学者往往觉得 TableGen 语言令人不知所措。但一旦你开始尝试使用这种语言,它就会变得容易得多。
定义记录和类
让我们定义一个简单的指令记录:
def ADD {
string Mnemonic = "add";
int Opcode = 0xA0;
}
def
关键字表示定义一个记录。其后跟随记录的名称。记录体被大括号包围,体由字段定义组成,类似于 C++中的结构体。
你可以使用llvm-tblgen
工具查看生成的记录。将前面的源代码保存为inst.td
文件,然后运行以下命令:
$ llvm-tblgen --print-records inst.td
------------- Classes -----------------
------------- Defs -----------------
def ADD {
string Mnemonic = "add";
int Opcode = 160;
}
这还不是特别令人兴奋;它只表明定义的记录被正确解析。
使用单个记录定义指令并不太方便。现代 CPU 有数百条指令,这么多记录,很容易在字段名称中引入打字错误。如果你决定重命名一个字段或添加一个新字段,那么需要更改的记录数量就成为一个挑战。因此,需要一个蓝图。在 C++中,类有类似的作用,在 TableGen 中,它也被称为Inst
类和基于该类的两个记录:
class Inst<string mnemonic, int opcode> {
string Mnemonic = mnemonic;
int Opcode = opcode;
}
def ADD : Inst<"add", 0xA0>;
def SUB : Inst<"sub", 0xB0>;
类的语法与记录类似。class
关键字表示定义了一个类,其后跟随类的名称。类可以有一个参数列表。在这里,Inst
类有两个参数,mnemonic
和opcode
,它们用于初始化记录的字段。这些字段的值在类实例化时给出。ADD
和SUB
记录展示了类的两个实例。再次使用llvm-tblgen
来查看记录:
$ llvm-tblgen --print-records inst.td
------------- Classes -----------------
class Inst<string Inst:mnemonic = ?, int Inst:opcode = ?> {
string Mnemonic = Inst:mnemonic;
int Opcode = Inst:opcode;
}
------------- Defs -----------------
def ADD { // Inst
string Mnemonic = "add";
int Opcode = 160;
}
def SUB { // Inst
string Mnemonic = "sub";
int Opcode = 176;
}
现在,你有一个类定义和两个记录。用于定义记录的类的名称显示为注释。请注意,类的参数默认值为 ?
,表示 int
未初始化。
调试技巧
要获取记录的更详细输出,可以使用 –-print-detailed-records
选项。输出包括记录和类定义的行号,以及记录字段初始化的位置。如果你试图追踪为什么记录字段被赋予某个特定的值,它们可能非常有帮助。
通常,ADD
和 SUB
指令有很多共同之处,但也有区别:加法是交换律操作,而减法不是。我们也要在记录中捕捉这一事实。一个小挑战是 TableGen 只支持有限的数据类型。你已经在示例中使用了 string
和 int
。其他可用的数据类型有 bit
、bits<n>
、list<type>
和 dag
。bit
类型表示单个位;即 0
或 1
。如果你需要一个固定数量的位,那么你将使用 bits<n>
类型。例如,bits<5>
是一个 5 位宽的整型。要基于其他类型定义列表,你将使用 list<type>
类型。例如,list<int>
是一个整数列表,而 list<Inst>
是从示例中 Inst
类的记录列表。dag
类型表示 有向无环图(DAG)节点。这种类型对于定义模式和操作非常有用,并且在 LLVM 后端中被广泛使用。
为了表示一个标志,一个单独的位就足够了,所以你可以使用一个来标记指令为可交换的。大多数指令都不是可交换的,所以你可以利用默认值:
class Inst<string mnemonic, int opcode, bit commutable = 0> {
string Mnemonic = mnemonic;
int Opcode = opcode;
bit Commutable = commutable;
}
def ADD : Inst<"add", 0xA0, 1>;
def SUB : Inst<"sub", 0xB0>;
你应该运行 llvm-tblgen
来验证记录是否按预期定义。
类不需要有参数。也可以稍后分配值。例如,你可以定义所有指令都不是可交换的:
class Inst<string mnemonic, int opcode> {
string Mnemonic = mnemonic;
int Opcode = opcode;
bit Commutable = 0;
}
def SUB : Inst<"sub", 0xB0>;
使用 let
语句,你可以覆盖该值:
let Commutable = 1 in
def ADD : Inst<"add", 0xA0>;
或者,你可以打开记录体来覆盖该值:
def ADD : Inst<"add", 0xA0> {
let Commutable = 1;
}
再次提醒,请使用 llvm-tblgen
验证在两种情况下 Commutable
标志是否设置为 1
。
类和记录可以从多个类继承,并且总是可以添加新字段或覆盖现有字段的值。你可以使用继承来引入一个新的 CommutableInst
类:
class Inst<string mnemonic, int opcode> {
string Mnemonic = mnemonic;
int Opcode = opcode;
bit Commutable = 0;
}
class CommutableInst<string mnemonic, int opcode>
: Inst<mnemonic, opcode> {
let Commutable = 1;
}
def SUB : Inst<"sub", 0xB0>;
def ADD : CommutableInst<"add", 0xA0>;
结果记录始终相同,但语言允许你以不同的方式定义记录。请注意,在后一个示例中,Commutable
标志可能是多余的:代码生成器可以查询记录所基于的类,如果该列表包含 CommutableInst
类,则它可以内部设置该标志。
使用多类一次创建多个记录
另一个经常使用的语句是 multiclass
。多类允许你一次定义多个记录。让我们扩展示例来展示这为什么有用。
add
指令的定义非常简单。在现实中,CPU 通常有几个add
指令。一个常见的变体是,一个指令有两个寄存器操作数,而另一个指令有一个寄存器操作数和一个立即数操作数,这是一个小的数字。假设对于具有立即数操作的指令,指令集的设计者决定用i
作为后缀来标记它们。因此,我们最终得到add
和addi
指令。进一步假设操作码相差1
。许多算术和逻辑指令遵循此方案;因此,您希望定义尽可能紧凑。
第一个挑战是您需要操作值。您可以使用有限数量的运算符来修改一个值。例如,要生成1
和字段 opcode 值的和,您可以这样写:
!add(opcode, 1)
这样的表达式最好用作类的参数。测试字段值并根据找到的值进行更改通常是不可能的,因为这需要动态语句,而这些语句是不可用的。始终记住,所有计算都是在记录构建时完成的!
以类似的方式,字符串可以连接:
!strconcat(mnemonic,"i")
因为所有运算符都以感叹号(!
)开头,它们也被称为感叹号运算符。您可以在程序员参考中找到完整的感叹号运算符列表:llvm.org/docs/TableGen/ProgRef.html#appendix-a-bang-operators
。
现在,您可以定义一个多类。Inst
类再次作为基类:
class Inst<string mnemonic, int opcode> {
string Mnemonic = mnemonic;
int Opcode = opcode;
}
多类的定义稍微复杂一些,所以让我们分步骤来做:
-
多类定义使用的语法与类类似。新的多类名为
InstWithImm
,有两个参数,mnemonic
和opcode
:multiclass InstWithImm<string mnemonic, int opcode> {
-
首先,您需要使用两个寄存器操作数定义一个指令。就像在正常的记录定义中一样,您使用
def
关键字来定义记录,并使用Inst
类来创建记录内容。您还需要定义一个空名称。我们稍后会解释为什么这是必要的:def "": Inst<mnemonic, opcode>;
-
接下来,您使用立即数操作数定义一个指令。您使用感叹号运算符从多类的参数中推导出助记符和操作码的值。记录命名为
I
:def I: Inst<!strconcat(mnemonic,"i"), !add(opcode, 1)>;
-
那就是全部了;类体可以像这样关闭:
}
要实例化记录,您必须使用defm
关键字:
defm ADD : InstWithImm<"add", 0xA0>;
这些语句的结果如下:
-
Inst<"add", 0xA0>
记录被实例化。记录的名称是defm
关键字后面的名称和多层语句中def
后面的名称的连接,结果为名称ADD
。 -
Inst<"addi", 0xA1>
记录被实例化,并按照相同的方案,被赋予名称ADDI
。
让我们用llvm-tblgen
验证这个说法:
$ llvm-tblgen –print-records inst.td
------------- Classes -----------------
class Inst<string Inst:mnemonic = ?, int Inst:opcode = ?> {
string Mnemonic = Inst:mnemonic;
int Opcode = Inst:opcode;
}
------------- Defs -----------------
def ADD { // Inst
string Mnemonic = "add";
int Opcode = 160;
}
def ADDI { // Inst
string Mnemonic = "addi";
int Opcode = 161;
}
使用多类,一次生成多个记录非常容易。这个特性被非常频繁地使用!
记录不需要有名称。匿名记录完全可以接受。要定义一个匿名记录,只需省略名称即可。由多类生成的记录名称由两个名称组成,创建一个命名记录时必须提供这两个名称。如果在defm
之后省略名称,则只会创建匿名记录。同样,如果多类内部的def
后面没有跟名称,也会创建一个匿名记录。这就是为什么在多类示例中的第一个定义使用了空名称""
:没有它,记录将是匿名的。
模拟函数调用
在某些情况下,使用类似于前例中的多类可能会导致重复。假设 CPU 还支持内存操作数,方式与立即操作数类似。你可以通过向多类中添加一个新的记录定义来支持这一点:
multiclass InstWithOps<string mnemonic, int opcode> {
def "": Inst<mnemonic, opcode>;
def "I": Inst<!strconcat(mnemonic,"i"), !add(opcode, 1)>;
def "M": Inst<!strconcat(mnemonic,"m"), !add(opcode, 2)>;
}
这完全没问题。但现在,想象一下你不需要定义 3 个记录,而是需要定义 16 个记录,并且需要多次这样做。这种情况可能出现的典型场景是当 CPU 支持许多向量类型,并且向量指令根据使用的类型略有不同。
请注意,所有带有def
语句的三行具有相同的结构。变化仅在于名称和助记符的后缀,以及将 delta 值添加到操作码中。在 C 语言中,你可以将数据放入一个数组中,并实现一个基于索引值返回数据的函数。然后,你可以创建一个循环来遍历数据,而不是手动重复语句。
令人惊讶的是,你可以在 TableGen 语言中做类似的事情!以下是转换示例的方式:
-
为了存储数据,你定义一个包含所有必需字段的类。这个类被称为
InstDesc
,因为它描述了指令的一些属性:class InstDesc<string name, string suffix, int delta> { string Name = name; string Suffix = suffix; int Delta = delta; }
-
现在,你可以为每种操作数类型定义记录。请注意,它精确地捕捉到观察到的数据中的差异:
def RegOp : InstDesc<"", "", 0>; def ImmOp : InstDesc<"I", """, 1>; def MemOp : InstDesc"""","""", 2>;
-
假设你有一个枚举数字
0
、1
和2
的循环,并且你想根据索引选择之前定义的其中一个记录。你该如何做?解决方案是创建一个getDesc
类,它接受索引作为参数。它有一个单一的字段ret
,你可以将其解释为返回值。为了将正确的值分配给此字段,使用了!cond
运算符:class getDesc<int n> { InstDesc ret = !cond(!eq(n, 0) : RegOp, !eq(n, 1) : ImmOp, !eq(n, 2) : MemOp); }
此运算符的工作方式与 C 语言中的
switch
/case
语句类似。 -
现在,你准备好定义多类。TableGen 语言有一个
loop
语句,它还允许我们定义变量。但请记住,没有动态执行!因此,循环范围是静态定义的,你可以给变量赋值,但之后不能改变这个值。然而,这足以检索数据。请注意,使用getDesc
类的方式类似于函数调用。但没有函数调用!相反,创建了一个匿名记录,值是从该记录中取出的。最后,过去操作符(#
)执行字符串连接,类似于之前使用的!strconcat
操作符:multiclass InstWithOps<string mnemonic, int opcode> { foreach I = 0-2 in { defvar Name = getDesc<I>.ret.Name; defvar Suffix = getDesc<I>.ret.Suffix; defvar Delta = getDesc<I>.ret.Delta; def Name: Inst<mnemonic # Suffix, !add(opcode, Delta)>; } }
-
现在,你使用多类定义记录,就像之前一样:
defm ADD : InstWithOps<"add", 0xA0>;
请运行llvm-tblgen
并检查记录。除了各种ADD
记录外,你还会看到一些由getDesc
类使用生成的匿名记录。
这种技术被用于几个 LLVM 后端的指令定义中。凭借你获得的知识,你应该没有问题理解这些文件。
foreach
语句使用0-2
语法来表示范围的界限。这被称为0...3
,如果数字是负数时很有用。最后,你不仅限于数值范围;你还可以遍历元素列表,这允许你使用字符串或先前定义的记录。例如,你可能喜欢使用foreach
语句,但认为使用getDesc
类太复杂。在这种情况下,遍历InstDesc
记录是解决方案:
multiclass InstWithOps<string mnemonic, int opcode> {
foreach I = [RegOp, ImmOp, MemOp] in {
defvar Name = I.Name;
defvar Suffix = I.Suffix;
defvar Delta = I.Delta;
def Name: Inst<mnemonic # Suffix, !add(opcode, Delta)>;
}
}
到目前为止,你只使用 TableGen 语言定义了记录,使用了最常用的语句。在下一节中,你将学习如何从 TableGen 语言中定义的记录生成 C++源代码。
从 TableGen 文件生成 C++代码
在上一节中,你使用 TableGen 语言定义了记录。为了使用这些记录,你需要编写自己的 TableGen 后端,该后端可以生成 C++源代码或使用记录作为输入执行其他操作。
在第三章,“将源文件转换为抽象语法树”,Lexer
类的实现使用数据库文件来定义标记和关键字。各种查询函数都利用了那个数据库文件。除此之外,数据库文件还用于实现关键字过滤器。关键字过滤器是一个哈希表,使用llvm::StringMap
类实现。每当找到一个标识符时,都会调用关键字过滤器来检查该标识符是否实际上是一个关键字。如果你仔细查看第六章“高级 IR 生成”中使用的ppprofiler
传递的实现,你会发现这个函数被调用得相当频繁。因此,尝试不同的实现来使该功能尽可能快可能是有用的。
然而,这并不像看起来那么简单。例如,你可以尝试用二分搜索替换哈希表中的查找。这要求数据库文件中的关键字是有序的。目前这似乎是正确的,但在开发过程中,可能会在不被发现的情况下在错误的位置添加一个新的关键字。确保关键字顺序正确的方法是添加一些在运行时检查顺序的代码。
你可以通过改变内存布局来加速标准的二分搜索。例如,你不必对关键字进行排序,可以使用 Eytzinger 布局,该布局按广度优先顺序枚举搜索树。这种布局增加了数据的缓存局部性,因此加快了搜索速度。就个人而言,在数据库文件中手动以广度优先顺序维护关键字是不可能的。
另一种流行的搜索方法是生成最小完美哈希函数。如果你将一个新的键插入到像 llvm::StringMap
这样的动态哈希表中,那么这个键可能会映射到一个已经占用的槽位。这被称为 gperf
GNU 工具。
总结来说,有一些动力能够从关键字生成查找函数。因此,让我们将数据库文件移动到 TableGen!
在 TableGen 语言中定义数据
TokenKinds.def
数据库文件定义了三个不同的宏。TOK
宏用于没有固定拼写的标记,例如用于整型字面量。PUNCTUATOR
宏用于所有类型的标点符号,并包含一个首选拼写。最后,KEYWORD
宏定义了一个由字面量和标志组成的关键字,该标志用于指示这个字面量在哪个语言级别上是关键字。例如,thread_local
关键字被添加到 C++11 中。
在 TableGen 语言中表达这一点的办法是创建一个 Token
类来保存所有数据。然后你可以添加该类的子类以使使用更加方便。你还需要一个 Flag
类来定义与关键字一起定义的标志。最后,你需要一个类来定义关键字过滤器。这些类定义了基本的数据结构,并且可以在其他项目中潜在地重用。因此,你为它创建了一个 Keyword.td
文件。以下是步骤:
-
标志被建模为一个名称和一个相关联的值。这使得从这个数据生成枚举变得容易:
class Flag<string name, int val> { string Name = name; int Val = val; }
-
Token
类用作基类。它只携带一个名称。请注意,这个类没有参数:class Token { string Name; }
-
Tok
类与数据库文件中相应的TOK
宏具有相同的功能。它表示一个没有固定拼写的标记。它从基类Token
继承,并仅添加了名称的初始化:class Tok<string name> : Token { let Name = name; }
-
同样地,
Punctuator
类类似于PUNCTUATOR
宏。它为标记的拼写添加了一个字段:class Punctuator<string name, string spelling> : Token { let Name = name; string Spelling = spelling; }
-
最后,
Keyword
类需要一个标志列表:class Keyword<string name, list<Flag> flags> : Token { let Name = name; list<Flag> Flags = flags; }
-
在这些定义到位后,你现在可以定义一个名为
TokenFilter
的关键字过滤器类。它接受一个标记列表作为参数:class TokenFilter<list<Token> tokens> { string FunctionName; list<Token> Tokens = tokens; }
使用这些类定义,你当然能够从TokenKinds.def
数据库文件中捕获所有数据。TinyLang 语言不利用标志,因为只有这个语言版本。现实世界的语言,如 C 和 C++,已经经历了几次修订,并且通常需要标志。因此,我们以 C 和 C++的关键字为例。让我们创建一个KeywordC.td
文件,如下所示:
-
首先,你包含之前创建的类定义:
Include "Keyword.td"
-
接下来,你定义标志。标志的值是标志的二进制值。注意
!or
运算符是如何用来为KEYALL
标志创建值的:def KEYC99 : Flag<"KEYC99", 0x1>; def KEYCXX : Flag<"KEYCXX", 0x2>; def KEYCXX11: Flag<"KEYCXX11", 0x4>; def KEYGNU : Flag<"KEYGNU", 0x8>; def KEYALL : Flag<"KEYALL", !or(KEYC99.Val, KEYCXX.Val, KEYCXX11.Val , KEYGNU.Val)>;
-
有些标记没有固定的拼写——例如,一个注释:
def : Tok<"comment">;
-
运算符使用
Punctuator
类定义,就像这个例子一样:def : Punctuator<"plus", "+">; def : Punctuator<"minus", "-">;
-
关键字需要使用不同的标志:
def kw_auto: Keyword<"auto", [KEYALL]>; def kw_inline: Keyword<"inline", [KEYC99,KEYCXX,KEYGNU]>; def kw_restrict: Keyword<"restrict", [KEYC99]>;
-
最后,这是关键字过滤器的定义:
def : TokenFilter<[kw_auto, kw_inline, kw_restrict]>;
当然,这个文件并没有包含 C 和 C++中所有的标记。然而,它展示了定义的 TableGen 类所有可能的用法。
基于这些 TableGen 文件,你将在下一节实现 TableGen 后端。
实现 TableGen 后端
由于解析和记录的创建是通过 LLVM 库完成的,你只需要关注后端实现,这主要是由基于记录信息生成 C++源代码片段组成的。首先,你需要明确要生成什么源代码,然后才能将其放入后端。
绘制要生成的源代码草图
TableGen 工具的输出是一个包含 C++片段的单个文件。这些片段由宏保护。目标是替换TokenKinds.def
数据库文件。根据 TableGen 文件中的信息,你可以生成以下内容:
-
用于定义标志的枚举成员。开发者可以自由命名类型;然而,它应该基于
unsigned
类型。如果生成的文件命名为TokenKinds.inc
,那么预期的用途如下:enum Flags : unsigned { #define GET_TOKEN_FLAGS #include "TokenKinds.inc" }
-
TokenKind
枚举,以及getTokenName()
、getPunctuatorSpelling()
和getKeywordSpelling()
函数的原型和定义。这段代码替换了TokenKinds.def
数据库文件,大多数TokenKinds.h
包含文件和TokenKinds.cpp
源文件。 -
一个新的
lookupKeyword()
函数,它可以用来替代当前使用llvm::StringMap
类型的实现。这是你想要优化的函数。
了解你想要生成的内容后,你现在可以转向实现后端。
创建一个新的 TableGen 工具
为您的新工具创建一个简单的结构,可以有一个驱动程序来评估命令行选项,并在不同的文件中调用生成函数和实际的生成函数。让我们将驱动程序文件命名为 TableGen.cpp
,将包含生成器的文件命名为 TokenEmitter.cpp
。您还需要一个 TableGenBackends.h
头文件。让我们从在 TokenEmitter.cpp
文件中生成 C++ 代码开始实现:
-
如同往常,文件以包含所需的头文件开始。其中最重要的是
llvm/TableGen/Record.h
,它定义了一个Record
类,用于存储由解析.td
文件生成的记录:#include "TableGenBackends.h" #include "llvm/Support/Format.h" #include "llvm/TableGen/Record.h" #include "llvm/TableGen/TableGenBackend.h" #include <algorithm>
-
为了简化编码,导入了
llvm
命名空间:using namespace llvm;
-
TokenAndKeywordFilterEmitter
类负责生成 C++ 源代码。emitFlagsFragment()
、emitTokenKind()
和emitKeywordFilter()
方法发出源代码,正如上一节中所述的 绘制要生成的源代码。唯一的公共方法是run()
,它调用所有代码发出方法。记录存储在RecordKeeper
实例中,该实例作为参数传递给构造函数。该类位于匿名命名空间内:namespace { class TokenAndKeywordFilterEmitter { RecordKeeper &Records; public: explicit TokenAndKeywordFilterEmitter(RecordKeeper &R) : Records(R) {} void run(raw_ostream &OS); private: void emitFlagsFragment(raw_ostream &OS); void emitTokenKind(raw_ostream &OS); void emitKeywordFilter(raw_ostream &OS); }; } // End anonymous namespace
-
run()
方法调用所有发出方法。它还记录了每个阶段的长度。您指定--time-phases
选项,然后所有代码生成完成后会显示计时:void TokenAndKeywordFilterEmitter::run(raw_ostream &OS) { // Emit Flag fragments. Records.startTimer("Emit flags"); emitFlagsFragment(OS); // Emit token kind enum and functions. Records.startTimer("Emit token kind"); emitTokenKind(OS); // Emit keyword filter code. Records.startTimer("Emit keyword filter"); emitKeywordFilter(OS); Records.stopTimer(); }
-
emitFlagsFragment()
方法展示了函数发出 C++ 源代码的典型结构。生成的代码由GET_TOKEN_FLAGS
宏保护。要发出 C++ 源代码片段,您需要遍历 TableGen 文件中从Flag
类派生的所有记录。拥有这样的记录后,查询记录的名称和值就变得很容易。请注意,名称Flag
、Name
和Val
必须与 TableGen 文件中的完全一致。如果您在 TableGen 文件中将Val
重命名为Value
,那么您也需要更改此函数中的字符串。所有生成的源代码都写入提供的流OS
中:void TokenAndKeywordFilterEmitter::emitFlagsFragment( raw_ostream &OS) { OS << "#ifdef GET_TOKEN_FLAGS\n"; OS << "#undef GET_TOKEN_FLAGS\n"; for (Record *CC : Records.getAllDerivedDefinitions("Flag")) { StringRef Name = CC->getValueAsString("Name"); int64_t Val = CC->getValueAsInt("Val"); OS << Name << " = " << format_hex(Val, 2) << ",\n"; } OS << "#endif\n"; }
-
emitTokenKind()
方法发出标记分类函数的声明和定义。让我们先看看如何发出声明。整体结构与上一个方法相同——只是发出的 C++ 源代码更多。生成的源代码片段由GET_TOKEN_KIND_DECLARATION
宏保护。请注意,此方法试图生成格式良好的 C++ 代码,使用换行和缩进来模拟人类开发者。如果发出的源代码不正确,并且您需要检查它以找到错误,这将非常有帮助。这样的错误也很容易犯:毕竟,您正在编写一个发出 C++ 源代码的 C++ 函数。首先,发出
TokenKind
枚举。关键字的名称应该以kw_
字符串为前缀。循环遍历Token
类的所有记录,您可以查询记录是否也是Keyword
类的子类,这使您能够发出前缀:OS << "#ifdef GET_TOKEN_KIND_DECLARATION\n" << "#undef GET_TOKEN_KIND_DECLARATION\n" << "namespace tok {\n" << " enum TokenKind : unsigned short {\n"; for (Record *CC : Records.getAllDerivedDefinitions("Token")) { StringRef Name = CC->getValueAsString("Name"); OS << " "; if (CC->isSubClassOf("Keyword")) OS << "kw_"; OS << Name << ",\n"; } OS << „ NUM_TOKENS\n" << „ };\n";
-
接下来,发出函数声明。这只是一个常量字符串,所以没有发生什么激动人心的事情。这完成了声明的发出:
OS << " const char *getTokenName(TokenKind Kind) " "LLVM_READNONE;\n" << " const char *getPunctuatorSpelling(TokenKind " "Kind) LLVM_READNONE;\n" << " const char *getKeywordSpelling(TokenKind " "Kind) " "LLVM_READNONE;\n" << "}\n" << "#endif\n";
-
现在,让我们转向发出定义。同样,生成的代码由一个名为
GET_TOKEN_KIND_DEFINITION
的宏保护。首先,令牌名称被发出到TokNames
数组中,getTokenName()
函数使用该数组来检索名称。请注意,当在字符串内部使用时,引号符号必须转义为\"
:OS << "#ifdef GET_TOKEN_KIND_DEFINITION\n"; OS << "#undef GET_TOKEN_KIND_DEFINITION\n"; OS << "static const char * const TokNames[] = {\n"; for (Record *CC : Records.getAllDerivedDefinitions("Token")) { OS << " \"" << CC->getValueAsString("Name") << "\",\n"; } OS << "};\n\n"; OS << "const char *tok::getTokenName(TokenKind Kind) " "{\n" << " if (Kind <= tok::NUM_TOKENS)\n" << " return TokNames[Kind];\n" << " llvm_unreachable(\"unknown TokenKind\");\n" << " return nullptr;\n" << "};\n\n";
-
接下来,发出
getPunctuatorSpelling()
函数。与其他部分相比,唯一的显著区别是循环遍历从Punctuator
类派生的所有记录。此外,生成一个switch
语句而不是数组:OS << "const char " "*tok::getPunctuatorSpelling(TokenKind " "Kind) {\n" << " switch (Kind) {\n"; for (Record *CC : Records.getAllDerivedDefinitions("Punctuator")) { OS << " " << CC->getValueAsString("Name") << ": return \"" << CC->getValueAsString("Spelling") << "\";\n"; } OS << " default: break;\n" << " }\n" << " return nullptr;\n" << "};\n\n";
-
最后,发出
getKeywordSpelling()
函数。编码与发出getPunctuatorSpelling()
类似。这次,循环遍历Keyword
类的所有记录,并且名称再次以kw_
前缀:OS << "const char *tok::getKeywordSpelling(TokenKind " "Kind) {\n" << " switch (Kind) {\n"; for (Record *CC : Records.getAllDerivedDefinitions("Keyword")) { OS << " kw_" << CC->getValueAsString("Name") << ": return \"" << CC->getValueAsString("Name") << "\";\n"; } OS << " default: break;\n" << " }\n" << " return nullptr;\n" << «};\n\n»; OS << «#endif\n»; }
-
emitKeywordFilter()
方法比之前的方法更复杂,因为发出过滤器需要从记录中收集一些数据。生成的源代码使用std::lower_bound()
函数,从而实现二分搜索。现在,让我们简化一下。在 TableGen 文件中可以定义多个
TokenFilter
类的记录。为了演示目的,只需发出最多一个令牌过滤器方法:std::vector<Record *> AllTokenFilter = Records.getAllDerivedDefinitionsIfDefined( "TokenFilter"); if (AllTokenFilter.empty()) return;
-
用于过滤的关键字位于名为
Tokens
的列表中。为了访问该列表,您首先需要查找记录中的Tokens
字段。这返回一个指向RecordVal
类实例的指针,您可以通过调用方法getValue()
从该实例中检索Initializer
实例。Tokens
字段定义为列表,因此您将初始化器实例转换为ListInit
。如果失败,则退出函数:ListInit *TokenFilter = dyn_cast_or_null<ListInit>( AllTokenFilter[0] ->getValue("Tokens") ->getValue()); if (!TokenFilter) return;
-
现在,您已经准备好构建一个过滤器表。对于存储在
TokenFilter
列表中的每个关键字,您需要Flag
字段的名称和值。该字段再次定义为列表,因此您需要遍历这些元素来计算最终值。结果名称/标志值对存储在Table
向量中:using KeyFlag = std::pair<StringRef, uint64_t>; std::vector<KeyFlag> Table; for (size_t I = 0, E = TokenFilter->size(); I < E; ++I) { Record *CC = TokenFilter->getElementAsRecord(I); StringRef Name = CC->getValueAsString("Name"); uint64_t Val = 0; ListInit *Flags = nullptr; if (RecordVal *F = CC->getValue("Flags")) Flags = dyn_cast_or_null<ListInit>(F->getValue()); if (Flags) { for (size_t I = 0, E = Flags->size(); I < E; ++I) { Val |= Flags->getElementAsRecord(I)->getValueAsInt( "Val"); } } Table.emplace_back(Name, Val); }
-
为了能够执行二分搜索,该表需要排序。比较函数由一个 lambda 函数提供:
llvm::sort(Table.begin(), Table.end(), [](const KeyFlag A, const KeyFlag B) { return A.first < B.first; });
-
现在,您可以发出 C++ 源代码。首先,您需要发出包含关键字名称和相关标志值的排序表:
OS << "#ifdef GET_KEYWORD_FILTER\n" << "#undef GET_KEYWORD_FILTER\n"; OS << "bool lookupKeyword(llvm::StringRef Keyword, " "unsigned &Value) {\n"; OS << " struct Entry {\n" << " unsigned Value;\n" << " llvm::StringRef Keyword;\n" << " };\n" << "static const Entry Table[" << Table.size() << "] = {\n"; for (const auto &[Keyword, Value] : Table) { OS << " { " << Value << ", llvm::StringRef(\"" << Keyword << "\", " << Keyword.size() << ") },\n"; } OS << " };\n\n";
-
接下来,你使用
std::lower_bound()
标准 C++函数在排序表中查找关键字。如果关键字在表中,则Value
参数接收与关键字关联的标志值,函数返回true
。否则,函数简单地返回false
:OS << " const Entry *E = " "std::lower_bound(&Table[0], " "&Table[" << Table.size() << "], Keyword, [](const Entry &A, const " "StringRef " "&B) {\n"; OS << " return A.Keyword < B;\n"; OS << " });\n"; OS << " if (E != &Table[" << Table.size() << "]) {\n"; OS << " Value = E->Value;\n"; OS << " return true;\n"; OS << " }\n"; OS << " return false;\n"; OS << "}\n"; OS << "#endif\n"; }
-
现在唯一缺少的部分是调用此实现的方法,为此你定义了一个全局函数
EmitTokensAndKeywordFilter()
。在llvm/TableGen/TableGenBackend.h
头文件中声明的emitSourceFileHeader()
函数在生成的文件顶部输出一个注释:void EmitTokensAndKeywordFilter(RecordKeeper &RK, raw_ostream &OS) { emitSourceFileHeader("Token Kind and Keyword Filter " "Implementation Fragment", OS); TokenAndKeywordFilterEmitter(RK).run(OS); }
有了这些,你就在TokenEmitter.cpp
文件中完成了源发射器的实现。总体来说,代码并不复杂。
TableGenBackends.h
头文件只包含EmitTokensAndKeywordFilter()
函数的声明。为了避免包含其他文件,你使用前向声明为raw_ostream
和RecordKeeper
类:
#ifndef TABLEGENBACKENDS_H
#define TABLEGENBACKENDS_H
namespace llvm {
class raw_ostream;
class RecordKeeper;
} // namespace llvm
void EmitTokensAndKeywordFilter(llvm::RecordKeeper &RK,
llvm::raw_ostream &OS);
#endif
缺失的部分是驱动程序的实现。其任务是解析 TableGen 文件并根据命令行选项输出记录。实现位于TableGen.cpp
文件中:
-
如同往常,实现从包含所需的头文件开始。最重要的是
llvm/TableGen/Main.h
,因为这个头文件声明了 TableGen 的前端:#include "TableGenBackends.h" #include "llvm/Support/CommandLine.h" #include "llvm/Support/PrettyStackTrace.h" #include "llvm/Support/Signals.h" #include "llvm/TableGen/Main.h" #include "llvm/TableGen/Record.h"
-
为了简化编码,导入了
llvm
命名空间:using namespace llvm;
-
用户可以选择一个操作。
ActionType
枚举包含所有可能的操作:enum ActionType { PrintRecords, DumpJSON, GenTokens, };
-
使用一个名为
Action
的单个命令行选项对象。用户需要指定--gen-tokens
选项来输出你实现的令牌过滤器。其他两个选项--print-records
和--dump-json
是用于输出读取记录的标准选项。注意,该对象位于匿名命名空间中:namespace { cl::opt<ActionType> Action( cl::desc("Action to perform:"), cl::values( clEnumValN( PrintRecords, "print-records", "Print all records to stdout (default)"), clEnumValN(DumpJSON, "dump-json", "Dump all records as " "machine-readable JSON"), clEnumValN(GenTokens, "gen-tokens", "Generate token kinds and keyword " "filter")));
-
Main()
函数根据Action
的值执行请求的操作。最重要的是,如果命令行中指定了--gen-tokens
,则会调用你的EmitTokensAndKeywordFilter()
函数。函数结束后,匿名命名空间关闭:bool Main(raw_ostream &OS, RecordKeeper &Records) { switch (Action) { case PrintRecords: OS << Records; // No argument, dump all contents break; case DumpJSON: EmitJSON(Records, OS); break; case GenTokens: EmitTokensAndKeywordFilter(Records, OS); break; } return false; } } // namespace
-
最后,你定义了一个
main()
函数。在设置堆栈跟踪处理程序和解析命令行选项后,调用TableGenMain()
函数来解析 TableGen 文件并创建记录。如果没有任何错误,该函数还会调用你的Main()
函数:int main(int argc, char **argv) { sys::PrintStackTraceOnErrorSignal(argv[0]); PrettyStackTraceProgram X(argc, argv); cl::ParseCommandLineOptions(argc, argv); llvm_shutdown_obj Y; return TableGenMain(argv[0], &Main); }
你自己的 TableGen 工具现在已经实现。编译后,你可以使用KeywordC.td
样本输入文件运行它,如下所示:
$ tinylang-tblgen --gen-tokens –o TokenFilter.inc KeywordC.td
生成的 C++源代码被写入TokenFilter.inc
文件。
令牌过滤器的性能
使用简单的二分搜索进行关键字过滤器搜索并不比基于llvm::StringMap
类型的实现有更好的性能。要超越当前实现的性能,你需要生成一个完美的哈希函数。
来自捷克共和国的 Havas 和 Majewski 的经典算法可以轻松实现,并且提供了非常好的性能。它描述在生成最小完美哈希函数的最优算法,信息处理信件,第 43 卷,第 5 期,1992 年。见 https://www.sciencedirect.com/science/article/abs/pii/002001909290220P。
最先进的算法是 Pibiri 和 Trani 的 PTHash,在PTHash:重新审视 FCH 最小完美哈希,SIGIR’21中描述。见arxiv.org/pdf/2104.10402.pdf
。
这两种算法都是生成一个比llvm::StringMap
实际更快的标记过滤器的好候选。
TableGen 的缺点
这里有一些 TableGen 的缺点:
-
TableGen 语言建立在简单概念之上。因此,它不具备其他 DSLs 相同的计算能力。显然,一些程序员希望用一种不同、更强大的语言来替换 TableGen,这个话题在 LLVM 讨论论坛上时不时会出现。
-
有可能实现自己的后端,TableGen 语言非常灵活。然而,这也意味着给定定义的语义隐藏在后台中。因此,你可以创建其他开发者基本上无法理解的 TableGen 文件。
-
最后,如果你尝试解决一个非平凡的任务,后端实现可能会非常复杂。如果 TableGen 语言更加强大,预期这种努力会降低,这是合理的。
即使不是所有开发者都对 TableGen 的功能感到满意,这个工具在 LLVM 中仍然被广泛使用,对于开发者来说,理解它是很重要的。
摘要
在本章中,你首先学习了 TableGen 背后的主要思想。然后,你在 TableGen 语言中定义了你的第一个类和记录,并获得了 TableGen 语法的知识。最后,你基于定义的 TableGen 类开发了一个生成 C++源代码片段的 TableGen 后端。
在下一章中,我们将探讨 LLVM 的另一个独特特性:一步生成和执行代码,也称为即时编译(JIT)。
第九章:JIT 编译
LLVM 核心库包含一个名为 ExecutionEngine 的组件,该组件允许在内存中编译和执行 中间表示(IR)代码。使用此组件,我们可以构建 即时(JIT)编译器,这允许直接执行 IR 代码。即时编译器更像是一个解释器,因为不需要在辅助存储上存储目标代码。
在本章中,你将了解即时编译器的应用,以及 LLVM 即时编译器在原理上是如何工作的。你将探索 LLVM 动态编译器和解释器,并学习如何自己实现即时编译器工具。此外,你还将学习如何将即时编译器作为静态编译器的一部分使用,以及相关的挑战。
本章将涵盖以下主题:
-
了解 LLVM 的 JIT 实现和使用案例概述
-
使用 JIT 编译进行直接执行
-
从现有类实现自己的 JIT 编译器
-
从零开始实现自己的 JIT 编译器
到本章结束时,你将理解并知道如何开发一个即时编译器,无论是使用预配置的类还是定制版本以满足你的需求。
技术要求
你可以在github.com/PacktPublishing/Learn-LLVM-17/tree/main/Chapter09
找到本章使用的代码。
LLVM 的整体 JIT 实现和使用案例
到目前为止,我们只看了 预编译(AOT)编译器。这些编译器编译整个应用程序。应用程序只能在编译完成后运行。如果编译是在应用程序的运行时执行的,那么编译器就是一个 JIT 编译器。JIT 编译器有一些有趣的用例:
-
虚拟机的实现:一种编程语言可以使用 AOT 编译器转换为字节码。在运行时,使用 JIT 编译器将字节码编译为机器代码。这种方法的优点是字节码是硬件无关的,而且由于 JIT 编译器的存在,与 AOT 编译器相比没有性能损失。Java 和 C# 目前使用这种模型,但这并不是一个新想法:1977 年的 USCD Pascal 编译器已经使用了类似的方法。
-
lldb
LLVM 调试器使用这种方法在调试时评估源表达式。 -
数据库查询:数据库从数据库查询中创建一个执行计划。执行计划描述了对表和列的操作,当执行时,这些操作导致查询结果。可以使用即时编译器将执行计划转换为机器代码,从而加快查询的执行速度。
LLVM 的静态编译模型并不像人们想象的那样远离 JIT 模型。llc
LLVM 静态编译器将 LLVM IR 编译成机器代码,并将结果保存为磁盘上的目标文件。如果目标文件不是存储在磁盘上而是在内存中,代码是否可执行?不是直接可执行,因为对全局函数和全局数据的引用使用重定位而不是绝对地址。从概念上讲,重定位描述了如何计算地址——例如,作为已知地址的偏移量。如果我们像链接器和动态加载器那样解析重定位到地址,那么我们可以执行目标代码。运行静态编译器将 IR 代码编译成内存中的目标文件,对内存中的目标文件执行链接步骤,然后运行代码,我们就得到了一个 JIT 编译器。LLVM 核心库中的 JIT 实现基于这个想法。
在 LLVM 的发展历史中,有几种 JIT 实现,具有不同的功能集。最新的 JIT API 是 按需编译(ORC)引擎。如果你对缩写词感兴趣,这是主要开发者的意图,在 可执行和链接格式(ELF)和 调试标准(DWARF)已经存在之后,再次发明一个基于托尔金的宇宙的缩写词。
ORC 引擎建立在并扩展了在内存中的目标文件上使用静态编译器和动态链接器的想法。该实现使用分层方法。两个基本级别是编译层和链接层。在这之上是一个提供懒编译支持的层。可以在懒编译层之上或之下堆叠一个转换层,允许开发者添加任意的转换或简单地通知某些事件。此外,这种分层方法的优势在于 JIT 引擎可以根据不同的需求进行定制。例如,高性能虚拟机可能会选择预先编译所有内容,并且不使用懒编译层。另一方面,其他虚拟机可能会强调启动时间和对用户的响应性,并借助懒编译层来实现这一点。
较旧的 MCJIT 引擎仍然可用,其 API 来自一个更早、已经删除的 JIT 引擎。随着时间的推移,这个 API 逐渐变得臃肿,并且缺乏 ORC API 的灵活性。目标是移除这个实现,因为 ORC 引擎现在提供了 MCJIT 引擎的所有功能,新的开发应该使用 ORC API。
在我们深入实现 JIT 编译器之前,下一节我们将探讨 lli
,LLVM 解释器和动态编译器。
使用 JIT 编译进行直接执行
直接运行 LLVM IR 是想到即时编译器时的第一个想法。这正是 lli
工具、LLVM 解释器和动态编译器所做的事情。我们将在下一节中探讨 lli
工具。
探索 lli 工具
让我们用一个非常简单的例子来尝试 lli
工具。下面的 LLVM IR 可以存储在一个名为 hello.ll
的文件中,这相当于一个 C 的 hello world 应用程序。此文件声明了来自 C 库的 printf()
函数的原型。hellostr
常量包含要打印的消息。在 main()
函数内部,会生成对 printf()
函数的调用,并且这个函数包含一个将要打印的 hellostr
消息。应用程序总是返回 0
。
完整的源代码如下:
declare i32 @printf(ptr, ...)
@hellostr = private unnamed_addr constant [13 x i8] c"Hello world\0A\00"
define dso_local i32 @main(i32 %argc, ptr %argv) {
%res = call i32 (ptr, ...) @printf(ptr @hellostr)
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 greetings.c -fPIC -shared -o greetings.so
我们还将文件编译成 greetings.o
对象文件:
$ clang greetings.c -c -o greetings.o
现在我们有两个文件,即 greetings.so
共享库和 greetings.o
对象文件,我们将它们加载到 lli
工具中。
我们还需要一个调用 greetings()
函数的 LLVM IR 文件。为此,创建一个包含对函数的单个调用的 main.ll
文件:
declare void @greetings(...)
define dso_local i32 @main(i32 %argc, i8** %argv) {
call void (...) @greetings()
ret i32 0
}
注意,在执行时,之前的 IR 会崩溃,因为 lli
无法定位到问候符号:
$ lli main.ll
JIT session error: Symbols not found: [ _greetings ]
lli: Failed to materialize symbols: { (main, { _main }) }
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 立即编译代码文件中的符号,或者加载的共享库中的符号。
对于执行 LLVM 立即编译代码,我们不需要自己创建 JIT 栈,因为 LLJIT
类提供了这一功能。在从较老的 MCJIT 实现迁移时,您也可以使用这个类,因为这个类本质上提供了相同的功能。
为了说明 LLJIT
工具的功能,我们将创建一个包含 JIT 功能的交互式计算器应用程序。我们的 JIT 计算器的主要源代码将扩展自 第二章,《编译器结构》中的 calc
示例。
我们交互式即时编译计算器的核心思想如下:
-
允许用户输入一个函数定义,例如
def f(x) =
x*2
。 -
用户输入的函数随后将由
LLJIT
工具编译成一个函数——在这种情况下,是f
函数。 -
允许用户使用数值调用他们定义的函数:
f(3)
。 -
使用提供的参数评估函数,并将结果打印到控制台:
6
。
在我们讨论将 JIT 功能集成到计算器源代码之前,有一些主要差异需要指出,与原始计算器示例相比:
-
首先,我们之前只输入和解析以
with
关键字开头的函数,而不是之前描述的def
关键字。对于本章,我们只接受以def
关键字开头的函数定义,并在我们的DefDecl
中表示为特定的节点。DefDecl
类知道它所定义的参数及其名称,并且函数名也存储在这个类中。 -
其次,我们还需要我们的抽象语法树(AST)能够识别函数调用,以表示
LLJIT
工具所消耗或即时编译(JIT)的函数。每当用户输入一个函数名,后面跟着括号内的参数时,AST 会将这些识别为FuncCallFromDef
节点。这个类本质上知道与DefDecl
类相同的信息。
由于增加了这两个 AST 类,可以明显预期语义分析、解析器和代码生成类将相应地调整以处理我们 AST 中的变化。需要注意的是,还增加了一个新的数据结构,称为JITtedFunctions
,这些类都了解这个数据结构。这个数据结构是一个映射,其中定义的函数名作为键,函数定义中存储的参数数量作为映射中的值。我们将在稍后看到这个数据结构如何在我们的 JIT 计算器中利用。
关于我们对calc
示例所做的更改的更多细节,包含从calc
和本节 JIT 实现的更改的完整源代码可以在lljit
源目录中找到。
将 LLJIT 引擎集成到计算器中
首先,让我们讨论如何在交互式计算器中设置 JIT 引擎。与 JIT 引擎相关的所有实现都存在于Calc.cpp
文件中,该文件有一个main()
循环用于程序的执行:
-
除了包括我们的代码生成、语义分析器和解析器实现的头文件外,我们还必须包含几个头文件。《LLJIT.h》头文件定义了
LLJIT
类和 ORC API 的核心类。接下来,需要InitLLVM.h
头文件来进行工具的基本初始化,以及需要TargetSelect.h
头文件来进行本地目标的初始化。最后,我们还包含了<iostream>
C++头文件,以便允许用户在我们的计算器应用程序中输入:#include "CodeGen.h" #include "Parser.h" #include "Sema.h" #include "llvm/ExecutionEngine/Orc/LLJIT.h" #include "llvm/Support/InitLLVM.h" #include "llvm/Support/TargetSelect.h" #include <iostream>
-
接下来,我们将
llvm
和llvm::orc
命名空间添加到当前作用域:using namespace llvm; using namespace llvm::orc;
-
我们将要创建的
LLJIT
实例中的许多调用都会返回一个错误类型,Error
。ExitOnError
类允许我们在记录到stderr
并退出应用程序的同时丢弃由LLJIT
实例返回的Error
值。我们声明一个全局的ExitOnError
变量如下:ExitOnError ExitOnErr;
-
然后,我们添加
main()
函数,该函数初始化工具和本地目标:int main(int argc, const char **argv{ InitLLVM X(argc, argv); InitializeNativeTarget(); InitializeNativeTargetAsmPrinter(); InitializeNativeTargetAsmParser();
-
我们使用
LLJITBuilder
类创建一个LLJIT
实例,并将其封装在之前声明的ExitOnErr
变量中,以防出现错误。一个可能出错的原因是平台尚未支持 JIT 编译:auto JIT = ExitOnErr(LLJITBuilder().create());
-
接下来,我们声明我们的
JITtedFunctions
映射,该映射跟踪函数定义,正如我们之前所描述的:StringMap<size_t> JITtedFunctions;
-
为了方便等待用户输入的环境,我们添加了一个
while()
循环,并允许用户输入一个表达式,将用户输入的行保存到一个名为calcExp
的字符串中:while (true) { outs() << "JIT calc > "; std::string calcExp; std::getline(std::cin, calcExp);
-
之后,初始化 LLVM 上下文类和新的 LLVM 模块。模块的数据布局也相应设置,我们还声明了一个代码生成器,该生成器将用于为用户在命令行上定义的函数生成 IR:
std::unique_ptr<LLVMContext> Ctx = std::make_unique<LLVMContext>(); std::unique_ptr<Module> M = std::make_unique<Module>("JIT calc.expr", *Ctx); M->setDataLayout(JIT->getDataLayout()); CodeGen CodeGenerator;
-
我们必须解释用户输入的行,以确定用户是定义一个新函数还是调用他们之前定义并带有参数的函数。在接收用户输入的行时定义了一个
Lexer
类。我们将看到词法分析器主要关注两个主要情况:Lexer Lex(calcExp); Token::TokenKind CalcTok = Lex.peek();
-
词法分析器可以检查用户输入的第一个标记。如果用户正在定义一个新的函数(由
def
关键字或Token::KW_def
标记表示),那么我们将解析它并检查其语义。如果解析器或语义分析器检测到用户定义的函数有任何问题,将相应地发出错误,计算器程序将停止。如果没有检测到解析器或语义分析器的错误,这意味着我们有一个有效的 AST 数据结构,即DefDecl
:if (CalcTok == Token::KW_def) { Parser Parser(Lex); AST *Tree = Parser.parse(); if (!Tree || Parser.hasError()) { llvm::errs() << "Syntax errors occured\n"; return 1; } Sema Semantic; if (Semantic.semantic(Tree, JITtedFunctions)) { llvm::errs() << "Semantic errors occured\n"; return 1; }
-
然后,我们可以将新构建的 AST 传递给我们的代码生成器,编译用户定义函数的中间表示(IR)。IR 生成的具体细节将在之后讨论,但这个编译为 IR 的函数需要知道模块和我们的
JITtedFunctions
映射。在生成 IR 之后,我们可以通过调用addIRModule()
并将模块和上下文包装在ThreadSafeModule
类中来将此信息添加到我们的LLJIT
实例中,以防止这些信息被其他并发线程访问:CodeGenerator.compileToIR(Tree, M.get(), JITtedFunctions); ExitOnErr( JIT->addIRModule(ThreadSafeModule(std::move(M), std::move(Ctx))));
-
相反,如果用户正在调用带有参数的函数,这由
Token::ident
标记表示,我们还需要在将输入转换为有效的 AST 之前解析和语义检查用户输入是否有效。这里的解析和检查与之前略有不同,因为它可能包括确保用户提供给函数调用的参数数量与函数最初定义的参数数量相匹配的检查:} else if (CalcTok == Token::ident) { outs() << "Attempting to evaluate expression:\n"; Parser Parser(Lex); AST *Tree = Parser.parse(); if (!Tree || Parser.hasError()) { llvm::errs() << "Syntax errors occured\n"; return 1; } Sema Semantic; if (Semantic.semantic(Tree, JITtedFunctions)) { llvm::errs() << "Semantic errors occured\n"; return 1; }
-
一旦为函数调用构建了一个有效的抽象语法树(AST),即
FuncCallFromDef
,我们就从 AST 中获取函数名称,然后代码生成器准备生成对之前添加到LLJIT
实例中的函数的调用。在幕后发生的是,用户定义的函数被重新生成为一个 LLVM 调用,在一个将执行原始函数实际评估的单独函数中。这一步需要 AST、模块、函数调用名称以及我们的函数定义映射:llvm::StringRef FuncCallName = Tree->getFnName(); CodeGenerator.prepareCalculationCallFunc(Tree, M.get(), FuncCallName, JITtedFunctions);
-
在代码生成器完成重新生成原始函数和创建单独评估函数的工作后,我们必须将此信息添加到
LLJIT
实例中。我们创建一个ResourceTracker
实例来跟踪分配给添加到LLJIT
的函数的内存,以及另一个模块和上下文的ThreadSafeModule
实例。然后,这两个实例被添加到 JIT 作为一个 IR 模块:auto RT = JIT->getMainJITDylib().createResourceTracker(); auto TSM = ThreadSafeModule(std::move(M), std::move(Ctx)); ExitOnErr(JIT->addIRModule(RT, std::move(TSM)));
-
然后,通过
lookup()
方法在我们的LLJIT
实例中查询单独的评估函数,通过将我们的评估函数名称calc_expr_func
提供给函数。如果查询成功,calc_expr_func
函数的地址被转换为适当类型,这是一个不接受任何参数并返回单个整数的函数。一旦获得函数的地址,我们就调用该函数以生成用户定义函数的参数所提供的参数的结果,然后将结果打印到控制台:auto CalcExprCall = ExitOnErr(JIT->lookup("calc_expr_func")); int (*UserFnCall)() = CalcExprCall.toPtr<int (*)()>(); outs() << "User defined function evaluated to: " << UserFnCall() << "\n";
-
函数调用完成后,之前与我们的函数关联的内存随后通过
ResourceTracker
释放:ExitOnErr(RT->remove());
代码生成更改以支持通过 LLJIT 进行 JIT 编译
现在,让我们简要地看一下我们在CodeGen.cpp
中做出的某些更改,以支持我们的基于 JIT 的计算器:
-
如前所述,代码生成类有两个重要方法:一个是将用户定义的函数编译成 LLVM IR 并将 IR 打印到控制台,另一个是准备计算评估函数
calc_expr_func
,它包含对原始用户定义函数的评估调用。第二个函数也将生成的 IR 打印给用户:void CodeGen::compileToIR(AST *Tree, Module *M, StringMap<size_t> &JITtedFunctions) { ToIRVisitor ToIR(M, JITtedFunctions); ToIR.run(Tree); M->print(outs(), nullptr); } void CodeGen::prepareCalculationCallFunc(AST *FuncCall, Module *M, llvm::StringRef FnName, StringMap<size_t> &JITtedFunctions) { ToIRVisitor ToIR(M, JITtedFunctions); ToIR.genFuncEvaluationCall(FuncCall); M->print(outs(), nullptr); }
-
如前所述的源代码所示,这些代码生成函数定义了一个
ToIRVisitor
实例,它接受我们的模块和一个JITtedFunctions
映射,在初始化时用于其构造函数:class ToIRVisitor : public ASTVisitor { Module *M; IRBuilder<> Builder; StringMap<size_t> &JITtedFunctionsMap; . . . public: ToIRVisitor(Module *M, StringMap<size_t> &JITtedFunctions) : M(M), Builder(M->getContext()), JITtedFunctionsMap(JITtedFunctions) {
-
最终,这些信息被用来生成 IR 或评估之前为 IR 生成的函数。当生成 IR 时,代码生成器期望看到一个
DefDecl
节点,它代表定义一个新函数。函数名称及其定义的参数数量存储在函数定义映射中:virtual void visit(DefDecl &Node) override { llvm::StringRef FnName = Node.getFnName(); llvm::SmallVector<llvm::StringRef, 8> FunctionVars = Node.getVars(); (JITtedFunctionsMap)[FnName] = FunctionVars.size();
-
之后,通过
genUserDefinedFunction()
调用创建实际函数定义:Function *DefFunc = genUserDefinedFunction(FnName);
-
在
genUserDefinedFunction()
中,第一步是检查函数是否在模块中存在。如果不存在,我们确保函数原型存在于我们的映射数据结构中。然后,我们使用名称和参数数量来构建一个具有用户定义的参数数量的函数,并使该函数返回一个单一整数值:Function *genUserDefinedFunction(llvm::StringRef Name) { if (Function *F = M->getFunction(Name)) return F; Function *UserDefinedFunction = nullptr; auto FnNameToArgCount = JITtedFunctionsMap.find(Name); if (FnNameToArgCount != JITtedFunctionsMap.end()) { std::vector<Type *> IntArgs(FnNameToArgCount->second, Int32Ty); FunctionType *FuncType = FunctionType::get(Int32Ty, IntArgs, false); UserDefinedFunction = Function::Create(FuncType, GlobalValue::ExternalLinkage, Name, M); } return UserDefinedFunction; }
-
在生成用户定义的函数之后,创建一个新的基本块,并将我们的函数插入到基本块中。每个函数参数也与用户定义的名称相关联,因此我们也为所有函数参数设置了相应的名称,以及生成在函数内部操作的数学运算:
BasicBlock *BB = BasicBlock::Create(M->getContext(), "entry", DefFunc); Builder.SetInsertPoint(BB); unsigned FIdx = 0; for (auto &FArg : DefFunc->args()) { nameMap[FunctionVars[FIdx]] = &FArg; FArg.setName(FunctionVars[FIdx++]); } Node.getExpr()->accept(*this); };
-
当评估用户定义的函数时,在我们的示例中期望的 AST 被称为
FuncCallFromDef
节点。首先,我们定义评估函数并将其命名为calc_expr_func
(接受零个参数并返回一个结果):virtual void visit(FuncCallFromDef &Node) override { llvm::StringRef CalcExprFunName = "calc_expr_func"; FunctionType *CalcExprFunTy = FunctionType::get(Int32Ty, {}, false); Function *CalcExprFun = Function::Create( CalcExprFunTy, GlobalValue::ExternalLinkage, CalcExprFunName, M);
-
接下来,我们创建一个新的基本块以插入
calc_expr_func
:BasicBlock *BB = BasicBlock::Create(M->getContext(), "entry", CalcExprFun); Builder.SetInsertPoint(BB);
-
与之前类似,用户定义的函数是通过
genUserDefinedFunction()
检索的,我们将函数调用的数值参数传递给刚刚重新生成的原始函数:llvm::StringRef CalleeFnName = Node.getFnName(); Function *CalleeFn = genUserDefinedFunction(CalleeFnName);
-
一旦我们有了实际的
llvm::Function
实例,我们就利用IRBuilder
创建对定义的函数的调用,并返回结果,以便在最终将结果打印给用户时可以访问:auto CalleeFnVars = Node.getArgs(); llvm::SmallVector<Value *> IntParams; for (unsigned i = 0, end = CalleeFnVars.size(); i != end; ++i) { int ArgsToIntType; CalleeFnVars[i].getAsInteger(10, ArgsToIntType); Value *IntParam = ConstantInt::get(Int32Ty, ArgsToIntType, true); IntParams.push_back(IntParam); } Builder.CreateRet(Builder.CreateCall(CalleeFn, IntParams, "calc_expr_res")); };
构建基于 LLJIT 的计算器
最后,为了编译我们的 JIT 计算器源代码,我们还需要创建一个包含构建描述的CMakeLists.txt
文件,并将其保存到Calc.cpp
和我们的其他源文件旁边:
-
我们将所需的最低 CMake 版本设置为 LLVM 所需的版本,并为项目命名:
cmake_minimum_required (VERSION 3.20.0) project ("jit")
-
需要加载 LLVM 包,并将 LLVM 提供的 CMake 模块目录添加到搜索路径中。然后,我们包含
DetermineGCCCompatible
和ChooseMSVCCRT
模块,这些模块检查编译器是否具有 GCC 兼容的命令行语法,并确保使用与 LLVM 相同的 C 运行时:find_package(LLVM REQUIRED CONFIG) list(APPEND CMAKE_MODULE_PATH ${LLVM_DIR}) include(DetermineGCCCompatible) include(ChooseMSVCCRT)
-
我们还需要添加来自 LLVM 的定义和
include
路径。使用的 LLVM 组件通过函数调用映射到库名称:add_definitions(${LLVM_DEFINITIONS}) include_directories(SYSTEM ${LLVM_INCLUDE_DIRS}) llvm_map_components_to_libnames(llvm_libs Core OrcJIT Support native)
-
之后,如果确定编译器具有 GCC 兼容的命令行语法,我们还会检查是否启用了运行时类型信息和异常处理。如果没有启用,则相应地添加 C++标志以关闭这些功能:
if(LLVM_COMPILER_IS_GCC_COMPATIBLE) if(NOT LLVM_ENABLE_RTTI) set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -fno-rtti") endif() if(NOT LLVM_ENABLE_EH) set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -fno-exceptions") endif() endif()
-
最后,我们定义了可执行文件名称、要编译的源文件以及要链接的库:
add_executable (calc Calc.cpp CodeGen.cpp Lexer.cpp Parser.cpp Sema.cpp) target_link_libraries(calc PRIVATE ${llvm_libs})
上述步骤就是我们的基于 JIT 的交互式计算器工具所需的所有步骤。接下来,创建并切换到构建目录,然后运行以下命令以创建和编译应用程序:
$ cmake –G Ninja <path to source directory>
$ ninja
这会编译calc
工具。然后我们可以启动计算器,开始定义函数,并查看我们的计算器如何评估我们定义的函数。
以下示例调用显示了首先定义的函数的 IR,然后是创建的calc_expr_func
函数,该函数用于生成对最初定义的函数的调用,以便使用传递给它的任何参数评估该函数:
$ ./calc
JIT calc > def f(x) = x*2
define i32 @f(i32 %x) {
entry:
%0 = mul nsw i32 %x, 2
ret i32 %0
}
JIT calc > f(20)
Attempting to evaluate expression:
define i32 @calc_expr_func() {
entry:
%calc_expr_res = call i32 @f(i32 20)
ret i32 %calc_expr_res
}
declare i32 @f(i32)
User defined function evaluated to: 40
JIT calc > def g(x,y) = x*y+100
define i32 @g(i32 %x, i32 %y) {
entry:
%0 = mul nsw i32 %x, %y
%1 = add nsw i32 %0, 100
ret i32 %1
}
JIT calc > g(8,9)
Attempting to evaluate expression:
define i32 @calc_expr_func() {
entry:
%calc_expr_res = call i32 @g(i32 8, i32 9)
ret i32 %calc_expr_res
}
declare i32 @g(i32, i32)
User defined function evaluated to: 172
就这样!我们刚刚创建了一个基于 JIT 的计算器应用程序!
由于我们的 JIT 计算器旨在作为一个简单的示例,说明如何将LLJIT
集成到我们的项目中,因此值得注意的是存在一些限制:
-
此计算器不接受十进制值的负数
-
我们不能重新定义同一个函数超过一次
对于第二个限制,这是按设计进行的,因此由 ORC API 本身预期并强制执行:
$ ./calc
JIT calc > def f(x) = x*2
define i32 @f(i32 %x) {
entry:
%0 = mul nsw i32 %x, 2
ret i32 %0
}
JIT calc > def f(x,y) = x+y
define i32 @f(i32 %x, i32 %y) {
entry:
%0 = add nsw i32 %x, %y
ret i32 %0
}
Duplicate definition of symbol '_f'
请记住,除了暴露当前进程或共享库中的符号之外,还有许多其他方法可以暴露名称。例如,StaticLibraryDefinitionGenerator
类暴露了静态归档中找到的符号,并可用于DynamicLibrarySearchGenerator
类。
此外,LLJIT
类还有一个addObjectFile()
方法来暴露对象文件的符号。如果现有的实现不符合您的需求,您也可以提供自己的DefinitionGenerator
实现。
如我们所见,使用预定义的LLJIT
类很方便,但它可能会限制我们的灵活性。在下一节中,我们将探讨如何使用 ORC API 提供的层来实现 JIT 编译器。
从头开始构建 JIT 编译器类
使用 ORC 的分层方法,构建针对特定需求的 JIT 编译器非常容易。没有一种适合所有情况的 JIT 编译器,本章的第一部分给出了一些示例。让我们看看如何从头开始设置 JIT 编译器。
ORC API 使用堆叠在一起的层。最低层是对象链接层,由llvm::orc::RTDyldObjectLinkingLayer
类表示。它负责将内存中的对象链接起来,并将它们转换为可执行代码。这个任务所需的内存由MemoryManager
接口的一个实例管理。有一个默认实现,但如果我们需要,也可以使用自定义版本。
在对象链接层之上是编译层,它负责创建内存中的对象文件。llvm::orc::IRCompileLayer
类接受 IR 模块作为输入,并将其编译为对象文件。IRCompileLayer
类是IRLayer
类的子类,IRLayer
是一个用于接受 LLVM IR 的层实现的通用类。
这两个层已经构成了 JIT 编译器的核心:它们添加一个 LLVM IR 模块作为输入,该模块在内存中编译和链接。为了添加额外的功能,我们可以在两个层之上添加更多的层。
例如,CompileOnDemandLayer
类将模块分割,以便只编译请求的函数。这可以用于实现懒编译。此外,CompileOnDemandLayer
类也是IRLayer
类的子类。以非常通用的方式,IRTransformLayer
类,也是IRLayer
类的子类,允许我们对模块应用转换。
另一个重要的类是ExecutionSession
类。这个类代表一个正在运行的 JIT 程序。本质上,这意味着该类管理JITDylib
符号表,提供符号的查找功能,并跟踪使用的资源管理器。
JIT 编译器的通用配方如下:
-
初始化
ExecutionSession
类的一个实例。 -
初始化层,至少包括
RTDyldObjectLinkingLayer
类和IRCompileLayer
类。 -
创建第一个
JITDylib
符号表,通常使用main
或类似名称。
JIT 编译器的一般用法也非常简单:
-
将 IR 模块添加到符号表中。
-
查找符号,触发相关函数的编译,以及可能整个模块的编译。
-
执行函数。
在下一小节中,我们将按照通用配方实现一个 JIT 编译器类。
创建 JIT 编译器类
为了保持 JIT 编译器类的实现简单,所有内容都放置在JIT.h
中,在一个可以创建的源目录jit
内。然而,与使用LLJIT
相比,类的初始化要复杂一些。由于处理可能的错误,我们需要一个工厂方法在调用构造函数之前预先创建一些对象。创建类的步骤如下:
-
我们首先使用
JIT_H
预处理器定义来保护头文件,防止多次包含:#ifndef JIT_H #define JIT_H
-
首先,需要一些
include
文件。其中大部分提供与头文件同名的类。Core.h
头文件提供了一些基本类,包括ExecutionSession
类。此外,ExecutionUtils.h
头文件提供了DynamicLibrarySearchGenerator
类来搜索库中的符号。此外,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"
-
声明一个新的类。我们的新类将被称为
JIT
:class JIT {
-
私有数据成员反映了 ORC 层和一些辅助类。
ExecutionSession
、ObjectLinkingLayer
、CompileLayer
、OptIRLayer
和MainJITDylib
实例代表正在运行的 JIT 程序、层和符号表,如前所述。此外,TargetProcessControl
实例用于与 JIT 目标进程交互。这可以是同一个进程,同一台机器上的另一个进程,或者不同机器上的远程进程,可能具有不同的架构。DataLayout
和MangleAndInterner
类用于以正确的方式混淆符号名称。此外,符号名称被内部化,这意味着所有相同名称的地址相同。这意味着要检查两个符号名称是否相等,只需比较地址即可,这是一个非常快速的操作: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;
-
初始化被分为三个部分。在 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();
-
在这一点上,我们已经处理了所有可能失败的调用。现在我们可以初始化
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)); }
-
JIT
类的构造函数将传入的参数移动到私有数据成员中。层对象通过调用具有create
前缀的静态工厂名称来构建。此外,每个层工厂方法都需要对ExecutionSession
实例的引用,这将层连接到正在运行的 JIT 会话。除了位于层堆栈底部的对象链接层之外,每个层都需要对前一个层的引用,说明了堆叠顺序:JIT(std::unique_ptr<llvm::orc::ExecutorProcessControl> EPCtrl, std::unique_ptr<llvm::orc::ExecutionSession> ExeS, llvm::DataLayout DataL, llvm::orc::JITTargetMachineBuilder JTMB) : EPC(std::move(EPCtrl)), 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>")) {
-
在构造函数的主体中,我们添加了一个生成器来搜索当前进程中的符号。
GetForCurrentProcess()
方法很特殊,因为返回值被包裹在一个Expected<>
模板中,表示也可以返回一个Error
对象。然而,由于我们知道不会发生错误,当前进程最终会运行!因此,我们使用cantFail()
函数解包结果,如果确实发生了错误,则终止应用程序:MainJITDylib.addGenerator(llvm::cantFail( llvm::orc::DynamicLibrarySearchGenerator:: GetForCurrentProcess(DL.getGlobalPrefix()))); }
-
要创建一个对象链接层,我们需要提供一个内存管理器。在这里,我们坚持使用默认的
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);
-
对于在 Windows 上使用的通用对象文件格式(COFF)对象文件格式,存在一个轻微的复杂性。此文件格式不允许将函数标记为导出。这随后导致对象链接层内部的检查失败:存储在符号中的标志与 IR 中的标志进行比较,由于缺少导出标记,导致不匹配。解决方案是为此文件格式覆盖标志。这完成了对象层的构建,并将对象返回给调用者:
if (JTMB.getTargetTriple().isOSBinFormatCOFF()) { OLLayer ->setOverrideObjectFlagsWithResponsibilityFlags( true); OLLayer ->setAutoClaimResponsibilityForObjectSymbols( true); } return OLLayer; }
-
要初始化编译器层,需要一个
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 IRCLayer; }
-
我们不是直接将 IR 模块编译成机器代码,而是安装一个先优化 IR 的层。这是一个故意的决策:我们将我们的 JIT 编译器转变为一个优化 JIT 编译器,它产生的代码更快,但生成代码所需的时间更长,这意味着对用户来说会有延迟。我们没有添加懒编译,所以当查找符号时,整个模块都会被编译。这可能会在用户看到代码执行之前增加相当长的时间。
注意
在所有情况下引入懒编译都不是一个合适的解决方案。懒编译是通过将每个函数移动到它自己的模块中实现的,当查找函数名时进行编译。这防止了诸如 内联 这样的跨程序优化,因为内联器需要访问被调用函数的体来内联它们。因此,用户会看到懒编译时的启动速度更快,但产生的代码并不像可能的那样优化。这些设计决策取决于预期的用途。在这里,我们决定要快速代码,接受较慢的启动时间。此外,这意味着优化层本质上是一个转换层。
IRTransformLayer
类将转换委托给一个函数——在我们的例子中,是 optimizeModule
函数:
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 OptIRLayer;
}
-
optimizeModule()
函数是一个对 IR 模块进行转换的例子。该函数获取一个模块作为参数,并返回 IR 模块的转换版本。由于 JIT 编译器可能以多线程方式运行,IR 模块被包装在一个ThreadSafeModule
实例中:static llvm::Expected<llvm::orc::ThreadSafeModule> optimizeModule( llvm::orc::ThreadSafeModule TSM, const llvm::orc::MaterializationResponsibility &R) {
-
为了优化 IR,我们回顾了在 添加优化管道到您的编译器 部分的 第七章 中的一些信息,优化 IR。我们需要一个
PassBuilder
实例来创建一个优化管道。首先,我们定义了一对分析管理器,并在之后在管道构建器中注册它们。之后,我们使用O2
级别的默认优化管道填充一个ModulePassManager
实例。这又是一个设计决策: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 TSM; }
-
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)); }
-
同样,我们的 JIT 类的客户端需要一个查找符号的方法。我们将此委托给
ExecutionSession
实例,传递对主符号表的引用以及请求的符号的混淆和内部化名称:llvm::Expected<llvm::orc::ExecutorSymbolDef> lookup(llvm::StringRef Name) { return ES->lookup({&MainJITDylib}, Mangle(Name.str())); }
如我们所见,此 JIT 类的初始化可能很棘手,因为它涉及 JIT
类的工厂方法和构造函数调用,以及每一层的工厂方法。尽管这种分布是由 C++ 的限制造成的,但代码本身是直接的。
接下来,我们将使用新的 JIT 编译器类来实现一个简单的命令行实用程序,该实用程序接受 LLVM IR 文件作为输入。
使用我们新的 JIT 编译器类
我们首先创建一个名为 JIT.cpp
的文件,与 JIT.h
文件位于同一目录下,并将以下内容添加到这个源文件中:
-
首先,包含几个头文件。我们必须包含
JIT.h
以使用我们的新类,以及IRReader.h
头文件,因为它定义了一个用于读取 LLVM IR 文件的功能。CommandLine.h
头文件允许我们以 LLVM 风格解析命令行选项。接下来,需要InitLLVM.h
以进行工具的基本初始化。最后,需要TargetSelect.h
以进行本地目标的初始化:#include "JIT.h" #include "llvm/IRReader/IRReader.h" #include "llvm/Support/CommandLine.h" #include "llvm/Support/InitLLVM.h" #include "llvm/Support/TargetSelect.h"
-
接下来,我们将
llvm
命名空间添加到当前作用域中:using namespace llvm;
-
我们的 JIT 工具期望命令行上恰好有一个输入文件,我们使用
cl::opt<>
类声明它:static cl::opt<std::string> InputFile(cl::Positional, cl::Required, cl::desc("<input-file>"));
-
要读取 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 Mod; }
-
jitmain()
函数放置在loadModule()
方法之后。此函数设置我们的 JIT 引擎并编译一个 LLVM IR 模块。该函数需要执行所需的 LLVM 模块和 IR。此模块还需要 LLVM 上下文类,因为上下文类包含重要的类型信息。目标是调用main()
函数,因此我们还传递了常用的argc
和argv
参数:Error jitmain(std::unique_ptr<Module> M, std::unique_ptr<LLVMContext> Ctx, int argc, char *argv[]) {
-
接下来,我们创建我们之前构建的 JIT 类的实例。如果发生错误,则相应地返回错误信息:
auto JIT = JIT::create(); if (!JIT) return JIT.takeError();
-
然后,我们将模块添加到主
JITDylib
实例中,再次将模块和上下文包装在ThreadSafeModule
实例中。如果发生错误,则返回错误信息:if (auto Err = (*JIT)->addIRModule( orc::ThreadSafeModule(std::move(M), std::move(Ctx)))) return Err;
-
此后,我们查找
main
符号。此符号必须在命令行上给出的 IR 模块中。查找触发该 IR 模块的编译。如果 IR 模块内部引用了其他符号,则它们将使用之前步骤中添加的生成器进行解析。结果是ExecutorAddr
类,它表示执行进程的地址:llvm::orc::ExecutorAddr MainExecutorAddr = MainSym->getAddress(); auto *Main = MainExecutorAddr.toPtr<int(int, char**)>();
-
现在,我们可以在 IR 模块中调用
main()
函数,并传递函数期望的argc
和argv
参数。我们忽略返回值:(void)Main(argc, argv);
-
函数执行后,我们报告成功:
return Error::success(); }
-
在实现了一个
jitmain()
函数之后,我们添加一个main()
函数,该函数初始化工具和本地目标,并解析命令行:int main(int argc, char *argv[]) { InitLLVM X(argc, argv); InitializeNativeTarget(); InitializeNativeTargetAsmPrinter(); InitializeNativeTargetAsmParser(); cl::ParseCommandLineOptions(argc, argv, "JIT\n");
-
之后,初始化了 LLVM 上下文类,并加载了命令行上指定的 IR 模块:
auto Ctx = std::make_unique<LLVMContext>(); std::unique_ptr<Module> M = loadModule(InputFile, *Ctx, argv[0]);
-
在加载 IR 模块后,我们可以调用
jitmain()
函数。为了处理错误,我们使用ExitOnError
实用类在遇到错误时打印错误消息并退出应用程序。我们还设置了一个带有应用程序名称的横幅,该横幅在错误消息之前打印:ExitOnError ExitOnErr(std::string(argv[0]) + ": "); ExitOnErr(jitmain(std::move(M), std::move(Ctx), argc, argv));
-
如果控制流到达这一点,则表示 IR 已成功执行。我们返回
0
以指示成功:return 0; }
现在,我们可以通过编译一个简单的示例来测试我们新实现的 JIT 编译器,该示例将 Hello World!
打印到控制台。在底层,新类使用固定的优化级别,因此对于足够大的模块,我们可以注意到启动和运行时的差异。
要构建我们的 JIT 编译器,我们可以遵循与在 使用 LLJIT 实现自己的 JIT 编译器 部分接近结尾时相同的 CMake 步骤,我们只需确保 JIT.cpp
源文件正在使用正确的库进行编译以进行链接:
add_executable(JIT JIT.cpp)
include_directories(${CMAKE_SOURCE_DIR})
target_link_libraries(JIT ${llvm_libs})
然后,我们切换到 build
目录并编译应用程序:
$ cmake –G Ninja <path to jit source directory>
$ ninja
我们的 JIT
工具现在可以使用了。可以像以下这样用 C 编写一个简单的 Hello World!
程序:
$ cat main.c
#include <stdio.h>
int main(int argc, char** argv) {
printf("Hello world!\n");
return 0;
}
接下来,我们可以使用以下命令将 Hello World C 源代码编译成 LLVM IR:
$ clang -S -emit-llvm main.c
记住 – 我们将 C 源代码编译成 LLVM IR,因为我们的 JIT 编译器接受 IR 文件作为输入。最后,我们可以使用以下方式调用我们的 JIT 编译器:
$ JIT main.ll
Hello world!
摘要
在本章中,你学习了如何开发 JIT 编译器。你从了解 JIT 编译器的可能应用开始,并探索了 lli
,LLVM 的动态编译器和解释器。使用预定义的 LLJIT
类,你构建了一个基于 JIT 的交互式计算器工具,并学习了查找符号和将 IR 模块添加到 LLJIT
中。为了能够利用 ORC API 的分层结构,你还实现了一个优化的 JIT
类。
在下一章中,你将学习如何利用 LLVM 工具进行调试。
第十章:使用 LLVM 工具进行调试
LLVM 附带了一套工具,可以帮助您识别应用程序中的某些错误。所有这些工具都使用了 LLVM 和clang库。
在本章中,您将学习如何使用清理器对应用程序进行检测,以及如何使用最常用的清理器来识别广泛的各种错误,之后您将为应用程序实现模糊测试。这将帮助您识别通常在单元测试中找不到的错误。您还将学习如何识别应用程序中的性能瓶颈,运行静态分析器来识别编译器通常找不到的问题,并创建自己的基于 clang 的工具,在其中您可以扩展 clang 以添加新功能。
本章将涵盖以下主题:
-
使用清理器对应用程序进行检测
-
使用libFuzzer查找错误
-
使用XRay进行性能分析
-
使用Clang 静态分析器检查源代码
-
创建自己的 clang 工具
到本章结束时,您将了解如何使用各种 LLVM 和 clang 工具来识别应用程序中的大量错误。您还将获得扩展 clang 以添加新功能的知识,例如强制命名约定或添加新的源分析。
技术要求
要在使用 XRay 进行性能分析部分创建火焰图,您需要安装来自github.com/brendangregg/FlameGraph
的脚本。某些系统,如Fedora和FreeBSD,提供了这些脚本的软件包,您也可以使用。
要在同一部分查看Chrome 可视化,您需要安装Chrome浏览器。您可以从www.google.com/chrome/
下载浏览器或使用系统的包管理器安装Chrome浏览器。
此外,为了通过scan-build
脚本来运行静态分析器,您需要在Fedora和Ubuntu上安装perl-core
软件包。
使用清理器对应用程序进行检测
LLVM 附带了一些compiler-rt
项目。可以在 clang 中启用清理器,这使得它们非常易于使用。要构建compiler-rt
项目,我们可以在构建 LLVM 时,简单地将-DLLVM_ENABLE_RUNTIMES=compiler-rt
CMake 变量添加到初始 CMake 配置步骤。
在以下章节中,我们将查看address
、memory
和thread
清理器。首先,我们将查看address
清理器。
使用地址清理器检测内存访问问题
您可以使用address
清理器来检测应用程序中不同类型的内存访问错误。这包括常见的错误,例如在释放动态分配的内存后使用它或在分配内存边界之外写入动态分配的内存。
当启用时,address
sanitizers 会替换对malloc()
和free()
函数的调用,并使用自己的版本,并使用检查保护器对所有的内存访问进行仪器化。当然,这会给应用程序添加很多开销,你将在应用程序的测试阶段使用address
sanitizers。如果你对实现细节感兴趣,你可以在llvm/lib/Transforms/Instrumentation/AddressSanitizer.cpp
文件中找到 pass 的源代码,并在github.com/google/sanitizers/wiki/AddressSanitizerAlgorithm
找到实现的算法描述。
让我们运行一个简短的示例来展示address
sanitizers 的能力!
以下示例应用程序,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
sanitizers,那么应用程序在检测到错误后会停止。
也有必要使用-g
选项启用调试符号,因为它有助于识别源代码中错误的地点。以下代码是使用address
sanitizers 和启用调试符号编译源文件的示例:
$ 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
报告还包含了关于内存内容的详细信息。重要信息是错误的类型 – outofbounds.c
文件,这是包含对memset()
调用的行。这正是缓冲区溢出的确切位置。
如果你在outofbounds.c
文件中将包含memset(p, 0, 14);
的行替换为以下代码,那么一旦释放了内存,你就可以引入对内存的访问。你需要将源代码存储在useafterfree.c
文件中:
memset(p, 0, 12);
free(p);
再次强调,如果你编译并运行它,sanitizer 会检测到在内存释放后使用指针的情况:
$ 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
这次,报告指向第 8 行,其中包含对p
指针的解引用。
在运行应用程序之前,将ASAN_OPTIONS
环境变量设置为detect_leaks=1
,那么你也会得到关于内存泄漏的报告。
在命令行上,你可以这样做:
$ ASAN_OPTIONS=detect_leaks=1 ./useafterfree
address
sanitizers 非常有用,因为它可以捕获一类其他情况下难以检测到的错误。memory
sanitizers 执行类似的任务。我们将在下一节中检查其用例。
使用内存 sanitizer 查找未初始化的内存访问
使用未初始化的内存是另一类难以找到的错误。在C和C++中,一般的内存分配例程不会用默认值初始化内存缓冲区。对于堆栈上的自动变量也是如此。
存在许多错误的机会,内存 sanitizer 有助于找到这些错误。如果你对实现细节感兴趣,可以在llvm/lib/Transforms/Instrumentation/MemorySanitizer.cpp
文件中找到内存 sanitizer pass 的源代码。文件顶部的注释解释了实现背后的思想。
让我们运行一个小示例,并将以下源代码保存为memory.c
文件。注意,x
变量未初始化,并被用作return
值:
int main(int argc, char *argv[]) {
int x;
return x;
}
没有使用 sanitizer 时,应用程序将正常运行。然而,如果你使用-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
sanitizer 类似,内存 sanitizer 会在找到的第一个错误处停止应用程序。如这里所示,内存 sanitizer 提供了已初始化的值警告。
最后,在下一节中,我们将探讨如何使用thread
sanitizer 来检测多线程应用程序中的数据竞争。
使用 thread sanitizer 指出数据竞争
为了利用现代 CPU 的强大功能,应用程序现在使用多个线程。这是一个强大的技术,但也引入了新的错误来源。在多线程应用程序中一个非常常见的问题是全局数据的访问没有得到保护,例如,使用thread
sanitizer 可以在llvm/lib/Transforms/Instrumentation/ThreadSanitizer.cpp
文件中检测数据竞争。
为了演示thread
sanitizer 的功能,我们将创建一个非常简单的生产者-消费者风格的程序。生产者线程增加一个全局变量,而消费者线程减少相同的变量。对全局变量的访问没有得到保护,因此这是一个数据竞争。
你需要将以下源代码保存到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
变量的当前值。
如果你编译并运行这个应用程序,你将不会注意到任何错误——也就是说,返回值总是零。如果执行的循环数量增加 100 倍,则会显示错误——在这种情况下,返回值不等于零。此时,你将开始注意到其他值出现。
我们可以使用thread
检查器来识别程序中的数据竞争。要启用thread
检查器进行编译,你需要将-fsanitize=thread
选项传递给 clang。使用–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 行,其中访问了全局变量。它还显示两个名为T1和T2的线程访问了该变量,以及pthread_create()
函数相应调用的文件和行号。
通过这样,我们已经学会了如何使用三种不同类型的检查器来识别应用程序中的常见问题。address
检查器帮助我们识别常见的内存访问错误,例如越界访问或使用已释放的内存。使用memory
检查器,我们可以找到对未初始化内存的访问,而thread
检查器帮助我们识别数据竞争。
在下一节中,我们将尝试通过在随机数据上运行我们的应用程序来触发检查器,这个过程被称为模糊测试。
使用 libFuzzer 查找错误
为了测试你的应用程序,你需要编写单元测试。这是一种确保你的软件按预期正确运行的好方法。然而,由于可能的输入数量呈指数级增长,你可能会错过某些奇怪的输入,也可能会有一些错误。
模糊测试可以在这里提供帮助。其思路是向你的应用程序提供随机生成数据,或者基于有效输入但带有随机变化的数据。这个过程会重复进行,因此你的应用程序会使用大量输入进行测试,这就是为什么模糊测试可以是一种强大的测试方法。据记录,模糊测试已帮助在网页浏览器和其他软件中找到数百个错误。
有趣的是,LLVM 自带了自己的模糊测试库。最初是 LLVM 核心库的一部分,即compiler-rt
。该库旨在测试小型且快速的函数。
让我们运行一个小示例来看看 libFuzzer 是如何工作的。首先,你需要提供LLVMFuzzerTestOneInput()
函数。这个函数由模糊测试驱动程序调用,并提供一些输入。以下函数计算输入中的连续 ASCII 数字。完成这个操作后,我们将随机输入喂给它。
你需要将示例保存到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
检查器和调试符号的生成。我们可以使用以下命令来编译fuzzer.c
文件:
$ clang -fsanitize=fuzzer,address -g fuzzer.c -o fuzzer
当你运行测试时,它会生成一个冗长的报告。报告包含的信息比堆栈跟踪更多,所以让我们更仔细地看看它:
-
第一行告诉你用于初始化随机数生成器的种子。你可以使用
–seed=
选项来重复此执行:INFO: Seed: 1297394926
-
默认情况下,libFuzzer 将输入限制在最多
4096
字节。你可以通过使用–max_len=
选项来更改默认值:INFO: -max_len is not provided; libFuzzer will not generate inputs larger than 4096 bytes
-
现在,我们可以不提供样本输入来运行测试。所有样本输入的集合称为
corpus
,对于这次运行它是空的:INFO: A corpus is not provided, starting from an empty corpus
-
以下将展示关于生成测试数据的详细信息。它显示你尝试了
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"-
-
之后,检测到缓冲区溢出,并跟随
address
检查器提供的信息。最后,报告告诉你导致缓冲区溢出的输入被保存的位置:artifact_prefix='./'; Test unit written to ./crash-17ba0791499db908433b80f37c5fbc89b870084b
使用保存的输入,可以再次使用相同的崩溃输入来执行测试用例:
$ ./fuzzer crash-17ba0791499db908433b80f37c5fbc89b870084b
这有助于识别问题,因为我们可以使用保存的输入作为直接重现器来修复可能出现的任何问题。然而,仅使用随机数据在每种情况下通常并不很有帮助。如果你尝试模糊测试tinylang
词法分析器或解析器,那么纯随机数据会导致立即拒绝输入,因为找不到任何有效令牌。
在这种情况下,提供一小组有效输入,称为 corpus,更有用。在这种情况下,corpus 的文件会被随机变异并用作输入。你可以认为输入主要是有效的,只有少数位被翻转。这也非常适合其他必须具有特定格式的输入。例如,对于一个处理corpus
的库:
提供 corpus 的一个例子如下。你可以将 corpus 文件保存到一个或多个目录中,并且你可以使用printf
命令帮助我们创建一个简单的 corpus 用于我们的模糊测试:
$ mkdir corpus
$ printf "012345\0" >corpus/12345.txt
$ printf "987\0" >corpus/987.txt
在运行测试时,必须在命令行上提供目录:
$ ./fuzzer corpus/
然后 corpus 被用作生成随机输入的基础,正如报告所告知的:
INFO: seed corpus: files: 2 min: 4b max: 7b total: 11b rss: 29Mb
此外,如果你正在测试一个处理令牌或其他魔法值(如编程语言)的函数,那么通过提供一个包含令牌的字典可以加快这个过程。对于编程语言,字典将包含语言中使用的所有关键字和特殊符号。此外,字典定义遵循简单的键值风格。例如,要在字典中定义if
关键字,你可以添加以下内容:
kw1="if"
然而,密钥是可选的,您可以省略它。现在,您可以使用 –dict=
选项在命令行上指定字典文件。
现在我们已经介绍了如何使用 libFuzzer 来查找错误,让我们来看看 libFuzzer 实现的局限性和替代方案。
局限性和替代方案
libFuzzer 的实现速度快,但对测试目标施加了几个限制。它们如下:
-
在
test
下的函数必须接受内存中的数组作为输入。一些库函数需要数据的文件路径,并且不能使用 libFuzzer 进行测试。 -
不应调用
exit()
函数。 -
全局状态不应被修改。
-
不应使用硬件随机数生成器。
前两个限制是 libFuzzer 作为库实现的推论。后两个限制是为了避免评估算法中的混淆。如果这些限制中的任何一个没有得到满足,那么对模糊目标进行两次相同的调用可能会得到不同的结果。
最著名的模糊测试替代工具是 AFL,可以在 github.com/google/AFL
找到。AFL 需要一个已插入的二进制文件(提供了一个用于插入的 LLVM 插件)并且需要应用程序将输入作为命令行上的文件路径。AFL 和 libFuzzer 可以共享相同的语料库和相同的字典文件。因此,可以使用这两个工具测试应用程序。此外,当 libFuzzer 不适用时,AFL 可能是一个不错的选择。
有许多其他方法可以影响 libFuzzer 的工作方式。您可以阅读 llvm.org/docs/LibFuzzer.html
上的参考页面以获取更多详细信息。
在下一节中,我们将探讨应用程序可能遇到的不同问题——我们将尝试使用 XRay 工具来识别性能瓶颈。
使用 XRay 进行性能分析
如果您的应用程序似乎运行缓慢,那么您可能想知道代码中的时间是如何花费的。在这里,使用 llvm/lib/XRay/
目录对代码进行插入。运行时部分是 compiler-rt
的一部分。
在以下示例源代码中,通过调用 usleep()
函数来模拟实际工作。func1()
函数休眠 10 µs。func2()
函数根据 n
参数是奇数还是偶数来调用 func1()
或休眠 100 µs。在 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
文件。图形如下所示:
图 10.1 – 由 llvm-xray 生成的火焰图
火焰图一开始可能会让人感到困惑,因为 X 轴并没有表示经过时间的通常意义。相反,函数只是按名称字母顺序排序。此外,火焰图的 Y 轴显示堆栈深度,底部从零开始计数。颜色选择是为了有良好的对比度,没有其他含义。从前面的图中,您可以轻松地确定调用层次结构和函数中花费的时间。
只有在将鼠标光标移至表示帧的矩形上时,才会显示堆栈帧的信息。通过单击帧,您可以放大此堆栈帧。如果您想识别值得优化的函数,火焰图非常有帮助。要了解更多关于火焰图的信息,请访问火焰图的发明者布伦丹·格雷格的网站: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
文件。您将看到以下数据可视化:
图 10.2 – 由 llvm-xray 生成的 Chrome 跟踪查看器可视化
在此视图中,堆栈帧按函数调用发生的时间排序。为了进一步解释可视化结果,请阅读位于 www.chromium.org/developers/how-tos/trace-event-profiling-tool
的教程。
小贴士
llvm-xray 工具有更多适用于性能分析的功能。您可以在 LLVM 网站上阅读有关信息:llvm.org/docs/XRay.html
和 llvm.org/docs/XRayExample.html
。
在本节中,我们学习了如何使用 XRay 对应用程序进行仪器化,如何收集运行时信息,以及如何可视化这些数据。我们可以使用这些知识来查找应用程序中的性能瓶颈。
识别应用程序中的错误的另一种方法是分析源代码,这可以通过 clang 静态分析器来完成。
使用 clang 静态分析器检查源代码
clang 静态分析器是一个对 C、C++和Objective C源代码执行额外检查的工具。静态分析器执行的检查比编译器执行的检查更彻底。它们在时间和所需资源方面也更具成本。静态分析器有一套检查器,用于检查特定的错误。
工具对源代码进行符号解释,它检查应用程序的所有代码路径,并从中推导出应用于应用程序的值的约束。符号解释是一种在编译器中常用的技术,例如,用于识别常量值。在静态分析器的上下文中,检查器应用于推导出的值。
例如,如果除法的除数为零,则静态分析器会警告我们。我们可以通过以下存储在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/home/kai/LLVM/llvm-17/bin/clang-17' 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
文件。
报告的第一页显示了发现的错误摘要:
图 10.3 – 摘要页面
对于发现的每个错误,摘要页面都会显示错误的类型、源代码中的位置以及分析器找到错误后的路径长度。还会提供一个错误详细报告的链接。
以下截图显示了错误的详细报告:
图 10.4 – 详细报告
通过这份详细的报告,你可以通过跟随编号的气泡来验证错误。我们的简单示例展示了将0
作为参数值传递会导致除以零错误。
因此,需要人工验证。如果派生的约束对于某个检查器来说不够精确,则可能出现假阳性 – 即,对于完全正常的代码报告了错误。根据报告,您可以使用它们来识别假阳性。
您不仅限于使用工具提供的检查器 – 您还可以添加新的检查器。下一节将演示如何做到这一点。
将新的检查器添加到 clang 静态分析器
许多 C 库提供了必须成对使用的函数。例如,C 标准库提供了 malloc()
和 free()
函数。由 malloc()
函数分配的内存必须由 free()
函数精确释放一次。不调用 free()
函数,或者多次调用它,是编程错误。这种编码模式还有很多实例,静态分析器为其中一些提供了检查器。
iconv_open()
和 iconv_close()
函数,必须成对使用,类似于内存管理函数。尚未为这些函数实现检查器,所以让我们来实现一个。
要将新的检查器添加到 clang 静态分析器,您必须创建 Checker
类的新子类。静态分析器尝试代码的所有可能路径。分析器引擎在特定点生成事件 – 例如,在函数调用之前或之后。此外,如果需要处理这些事件,您的类必须提供回调。Checker
类和事件的注册在 clang/include/clang/StaticAnalyzer/Core/Checker.h
头文件中提供。
通常,检查器需要跟踪一些符号。然而,检查器无法管理状态,因为它不知道分析器引擎当前尝试哪个代码路径。因此,跟踪的状态必须注册到引擎中,并且只能使用 ProgramStateRef
实例来更改。
为了检测错误,检查器需要跟踪从 iconv_open()
函数返回的描述符。分析器引擎为 iconv_open()
函数的返回值返回一个 SymbolRef
实例。我们将此符号与一个状态关联,以反映是否调用了 iconv_close()
。对于状态,我们创建了 IconvState
类,它封装了一个 bool
值。
新的 IconvChecker
类需要处理四种类型的事件:
-
PostCall
,发生在函数调用之后。在调用iconv_open()
函数之后,我们检索了返回值的符号,并将其记住为处于“打开”状态。 -
PreCall
,发生在函数调用之前。在调用iconv_close()
函数之前,我们检查描述符的符号是否处于“打开”状态。如果不是,那么对于该描述符已经调用了iconv_close()
函数,并且我们已经检测到该函数的双重调用。 -
当未使用的符号被清理时,会发生
DeadSymbols
。我们检查描述符的未使用符号是否仍然处于“打开”状态。如果是,那么我们就检测到了对iconv_close()
的缺失调用,这是一个资源泄露。 -
当符号无法被分析器跟踪时,会调用
PointerEscape
。在这种情况下,我们从状态中删除符号,因为我们无法再推断描述符是否已关闭。
我们可以创建一个新的目录来实现新的检查器作为 clang 插件,并在 IconvChecker.cpp
文件中添加实现:
-
对于实现,我们需要包含几个头文件。
include
文件BugType.h
是用于生成报告所必需的。头文件Checker.h
提供了Checker
类的声明以及事件的回调,这些事件在CallEvent
文件中声明。此外,CallDescription.h
文件有助于匹配函数和方法。最后,CheckerContext.h
文件是声明CheckerContext
类所必需的,这是一个提供对分析器状态访问的中心类:#include "clang/StaticAnalyzer/Core/BugReporter/BugType.h" #include "clang/StaticAnalyzer/Core/Checker.h" #include "clang/StaticAnalyzer/Core/PathSensitive/CallDescription.h" #include "clang/StaticAnalyzer/Core/PathSensitive/CallEvent.h" #include "clang/StaticAnalyzer/Core/PathSensitive/CheckerContext.h" #include "clang/StaticAnalyzer/Frontend/CheckerRegistry.h" #include <optional>
-
为了避免输入命名空间名称,我们可以使用
clang
和ento
命名空间:using namespace clang; using namespace ento;
-
我们将一个状态与表示 iconv 描述符的每个符号关联起来。状态可以是打开或关闭的,我们使用一个
bool
类型的变量,对于打开状态使用true
值。状态值封装在IconvState
结构体中。这个结构体与FoldingSet
数据结构一起使用,这是一个过滤重复条目的哈希集合。为了与这个数据结构实现兼容,这里添加了Profile()
方法,它设置这个结构体的唯一位。我们将结构体放入匿名命名空间中,以避免污染全局命名空间。而不是暴露bool
值,类提供了getOpened()
和getClosed()
工厂方法以及isOpen()
查询方法:namespace { class IconvState { const bool IsOpen; IconvState(bool IsOpen) : IsOpen(IsOpen) {} public: bool isOpen() const { return IsOpen; } static IconvState getOpened() { return IconvState(true); } static IconvState getClosed() { return IconvState(false); } bool operator==(const IconvState &O) const { return IsOpen == O.IsOpen; } void Profile(llvm::FoldingSetNodeID &ID) const { ID.AddInteger(IsOpen); } }; } // namespace
-
IconvState
结构体表示 iconv 描述符的状态,描述符由SymbolRef
类的符号表示。这最好通过一个映射来实现,其中符号作为键,状态作为值。如前所述,检查器无法保持状态。相反,状态必须与全局程序状态注册,这通过REGISTER_MAP_WITH_PROGRAMSTATE
宏来完成。此宏引入了IconvStateMap
名称,我们将在稍后使用它来访问映射:REGISTER_MAP_WITH_PROGRAMSTATE(IconvStateMap, SymbolRef, IconvState)
-
我们还在匿名命名空间中实现了
IconvChecker
类。请求的PostCall
、PreCall
、DeadSymbols
和PointerEscape
事件是Checker
基类模板参数:namespace { class IconvChecker : public Checker<check::PostCall, check::PreCall, check::DeadSymbols, check::PointerEscape> {
-
IconvChecker
类具有CallDescription
类型的字段,这些字段用于识别程序中对iconv_open()
、iconv()
和iconv_close()
的函数调用:CallDescription IconvOpenFn, IconvFn, IconvCloseFn;
-
类还持有检测到的错误类型的引用:
std::unique_ptr<BugType> DoubleCloseBugType; std::unique_ptr<BugType> LeakBugType;
-
最后,这个类有几个方法。除了构造函数和调用事件的方法之外,我们还需要一个方法来发出错误报告:
void report(ArrayRef<SymbolRef> Syms, const BugType &Bug, StringRef Desc, CheckerContext &C, ExplodedNode *ErrNode, std::optional<SourceRange> Range = std::nullopt) const; public: IconvChecker(); void checkPostCall(const CallEvent &Call, CheckerContext &C) const; void checkPreCall(const CallEvent &Call, CheckerContext &C) const; void checkDeadSymbols(SymbolReaper &SymReaper, CheckerContext &C) const; ProgramStateRef checkPointerEscape(ProgramStateRef State, const InvalidatedSymbols &Escaped, const CallEvent *Call, PointerEscapeKind Kind) const; }; } // namespace
-
IconvChecker
类的构造函数的实现使用函数的名称初始化CallDescription
字段,并创建代表错误类型的对象:IconvChecker::IconvChecker() : IconvOpenFn({"iconv_open"}), IconvFn({"iconv"}), IconvCloseFn({"iconv_close"}, 1) { DoubleCloseBugType.reset(new BugType( this, "Double iconv_close", "Iconv API Error")); LeakBugType.reset(new BugType( this, "Resource Leak", "Iconv API Error", /*SuppressOnSink=*/true)); }
-
现在,我们可以实现第一个调用事件方法,
checkPostCall()
。这个方法在分析器执行函数调用后被调用。如果执行的函数不是全局 C 函数也不是名为iconv_open
的函数,那么就没有什么要做的:void IconvChecker::checkPostCall( const CallEvent &Call, CheckerContext &C) const { if (!Call.isGlobalCFunction()) return; if (!IconvOpenFn.matches(Call)) return;
-
否则,我们可以尝试将函数的返回值作为一个符号。为了将符号以打开状态存储在全局程序状态中,我们需要从
CheckerContext
实例中获取一个ProgramStateRef
实例。状态是不可变的,因此将符号添加到状态会导致一个新的状态。最后,通过调用addTransition()
方法通知分析器引擎新的状态:if (SymbolRef Handle = Call.getReturnValue().getAsSymbol()) { ProgramStateRef State = C.getState(); State = State->set<IconvStateMap>( Handle, IconvState::getOpened()); C.addTransition(State); } }
-
同样,在分析器执行函数之前调用
checkPreCall()
方法。只有名为iconv_close
的全局 C 函数对我们感兴趣:void IconvChecker::checkPreCall( const CallEvent &Call, CheckerContext &C) const { if (!Call.isGlobalCFunction()) { return; } if (!IconvCloseFn.matches(Call)) { return; }
-
如果函数的第一个参数的符号(即 iconv 描述符)是已知的,那么我们可以从程序状态中检索符号的状态:
if (SymbolRef Handle = Call.getArgSVal(0).getAsSymbol()) { ProgramStateRef State = C.getState(); if (const IconvState *St = State->get<IconvStateMap>(Handle)) {
-
如果状态表示关闭状态,那么我们检测到了双重关闭错误,并且可以为此生成一个错误报告。如果已经为该路径生成了错误报告,
generateErrorNode()
的调用可以返回一个nullptr
值,因此我们必须检查这种情况:if (!St->isOpen()) { if (ExplodedNode *N = C.generateErrorNode()) { report(Handle, *DoubleCloseBugType, "Closing a previous closed iconv " "descriptor", C, N, Call.getSourceRange()); } return; } }
-
否则,我们必须将符号的状态设置为“关闭”状态:
State = State->set<IconvStateMap>( Handle, IconvState::getClosed()); C.addTransition(State); } }
-
调用
checkDeadSymbols()
方法来清理未使用的符号。我们遍历所有我们跟踪的符号,并询问SymbolReaper
实例当前符号是否无效:void IconvChecker::checkDeadSymbols( SymbolReaper &SymReaper, CheckerContext &C) const { ProgramStateRef State = C.getState(); SmallVector<SymbolRef, 8> LeakedSyms; for (auto [Sym, St] : State->get<IconvStateMap>()) { if (SymReaper.isDead(Sym)) {
-
如果符号是无效的,那么我们需要检查状态。如果状态仍然是打开的,那么这是一个潜在的资源泄露。有一个例外:
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); } }
-
循环结束后,我们调用
generateNonFatalErrorNode()
方法。这个方法过渡到新的程序状态,如果没有为该路径生成错误节点,则返回一个错误节点。LeakedSyms
容器持有(可能为空)的泄露符号列表,我们调用report()
方法来生成错误报告:if (ExplodedNode *N = C.generateNonFatalErrorNode(State)) { report(LeakedSyms, *LeakBugType, "Opened iconv descriptor not closed", C, N); } }
-
当分析器检测到一个无法跟踪参数的函数调用时,会调用
checkPointerEscape()
函数。在这种情况下,我们必须假设我们不知道 iconv 描述符是否会在函数内部关闭。例外情况是调用iconv()
,它执行转换并且已知不会调用iconv_close()
函数,以及iconv_close()
函数本身,我们在checkPreCall()
方法中处理它。我们也不会改变调用在系统头文件内部的状态,如果我们知道参数不会在调用函数中逃逸。在所有其他情况下,我们从状态中删除符号:ProgramStateRef IconvChecker::checkPointerEscape( ProgramStateRef State, const InvalidatedSymbols &Escaped, const CallEvent *Call, PointerEscapeKind Kind) const { if (Kind == PSK_DirectEscapeOnCall) { if (IconvFn.matches(*Call) || IconvCloseFn.matches(*Call)) return State; if (Call->isInSystemHeader() || !Call->argumentsMayEscape()) return State; } for (SymbolRef Sym : Escaped) State = State->remove<IconvStateMap>(Sym); return State; }
-
report()
方法生成错误报告。该方法的重要参数是一个符号数组、错误类型和错误描述。在方法内部,为每个符号创建一个错误报告,并将符号标记为对错误感兴趣的。如果提供了源范围作为参数,那么它也会添加到报告中。最后,报告被发出:void IconvChecker::report( ArrayRef<SymbolRef> Syms, const BugType &Bug, StringRef Desc, CheckerContext &C, ExplodedNode *ErrNode, std::optional<SourceRange> Range) 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)); } }
-
现在,新的检查器需要在
CheckerRegistry
实例中进行注册。当我们的插件被加载时,使用clang_registerCheckers()
函数,在其中进行注册。每个检查器都有一个名称,并属于一个包。我们调用IconvChecker
检查器,并将其放入unix
包中,因为 iconv 库是一个标准的 POSIX 接口。这是addChecker()
方法的第一个参数。第二个参数是对功能功能的简要说明,第三个参数可以是一个指向提供有关检查器更多信息文档的 URI:extern "C" void clang_registerCheckers(CheckerRegistry ®istry) { registry.addChecker<IconvChecker>( "unix.IconvChecker", "Check handling of iconv functions", ""); }
-
最后,我们需要声明我们使用的静态分析器 API 的版本,这使系统能够确定插件是否兼容:
extern "C" const char clang_analyzerAPIVersionString[] = CLANG_ANALYZER_API_VERSION_STRING;
这完成了新检查器的实现。为了构建插件,我们还需要在
CMakeLists.txt
文件中创建一个构建描述,该文件位于IconvChecker.cpp
相同的目录中: -
首先定义所需的CMake版本和项目名称:
cmake_minimum_required(VERSION 3.20.0) project(iconvchecker)
-
接下来,包含 LLVM 文件。如果 CMake 无法自动找到文件,那么你必须设置
LLVM_DIR
变量,使其指向包含 CMake 文件的 LLVM 目录:find_package(LLVM REQUIRED CONFIG)
-
将包含 CMake 文件的 LLVM 目录添加到搜索路径中,并从 LLVM 包含所需的模块:
list(APPEND CMAKE_MODULE_PATH ${LLVM_DIR}) include(AddLLVM) include(HandleLLVMOptions)
-
然后,加载 clang 的 CMake 定义。如果 CMake 无法自动找到文件,那么你必须设置
Clang_DIR
变量,使其指向包含 CMake 文件的 clang 目录:find_package(Clang REQUIRED)
-
接下来,将包含 CMake 文件的 Clang 目录添加到搜索路径中,并从 Clang 包含所需的模块:
list(APPEND CMAKE_MODULE_PATH ${Clang_DIR}) include(AddClang)
-
然后,定义头文件和库文件的位置,以及要使用的定义:
include_directories("${LLVM_INCLUDE_DIR}" "${CLANG_INCLUDE_DIRS}") add_definitions("${LLVM_DEFINITIONS}") link_directories("${LLVM_LIBRARY_DIR}")
-
之前的定义设置了构建环境。插入以下命令,该命令定义了你的插件名称、插件的源文件以及它是一个 clang 插件:
add_llvm_library(IconvChecker MODULE IconvChecker.cpp PLUGIN_TOOL clang)
-
在 Windows 上,插件支持与 Unix 不同,必须链接所需的 LLVM 和 clang 库。以下代码确保了这一点:
if(WIN32 OR CYGWIN) set(LLVM_LINK_COMPONENTS Support) clang_target_link_libraries(IconvChecker PRIVATE clangAnalysis clangAST clangStaticAnalyzerCore clangStaticAnalyzerFrontend) endif()
现在,我们可以配置和构建插件,假设 CMAKE_GENERATOR
和 CMAKE_BUILD_TYPE
环境变量已设置:
$ cmake -DLLVM_DIR=~/LLVM/llvm-17/lib/cmake/llvm \
-DClang_DIR=~/LLVM/llvm-17/lib/cmake/clang \
-B build
$ cmake --build build
你可以使用以下源代码在 conv.c
文件中测试新的检查器,该文件有两个对 iconv_close()
函数的调用:
#include <iconv.h>
void doconv() {
iconv_t id = iconv_open("Latin1", "UTF-16");
iconv_close(id);
iconv_close(id);
}
要使用 scan-build
脚本与插件一起使用,你需要通过 -load-plugin
选项指定插件的路径。使用 conv.c
文件的一个运行示例如下:
$ scan-build -load-plugin build/IconvChecker.so clang-17 \
-c conv.c
scan-build: Using '/home/kai/LLVM/llvm-17/bin/clang-17' for static analysis
conv.c:6:3: warning: Closing a previous closed iconv descriptor [unix.IconvChecker]
6 | iconv_close(id);
| ^~~~~~~~~~~~~~~
1 warning generated.
scan-build: Analysis run complete.
scan-build: 1 bug found.
scan-build: Run 'scan-view /tmp/scan-build-2023-08-08-114154-12451-1' to examine bug reports.
通过这样,你已经学会了如何使用自己的检查器扩展 clang 静态分析器。你可以使用这些知识来创建新的通用检查器并将其贡献给社区,或者创建专门为你的需求定制的检查器,以提高你产品的质量。
静态分析器是通过利用 clang 基础设施构建的。下一节将介绍如何构建自己的插件以扩展 clang。
创建自己的基于 clang 的工具
静态分析器是利用 clang 基础设施所能做到的令人印象深刻的例子。也有可能通过插件扩展 clang,以便你可以在 clang 中添加自己的功能。这种技术非常类似于向 LLVM 添加一个 pass 插件。
让我们通过一个简单的插件来探索功能。LLVM 编码标准要求函数名以小写字母开头。然而,编码标准已经发展,有许多函数以大写字母开头的情况。一个警告关于命名规则违规的插件可以帮助解决这个问题,所以让我们试试看。
因为你想在 AST 上运行用户定义的操作,你需要定义 PluginASTAction
类的子类。如果你使用 clang 库编写自己的工具,那么你可以为你的操作定义 ASTFrontendAction
类的子类。PluginASTAction
类是 ASTFrontendAction
类的子类,具有解析命令行选项的附加功能。
你还需要一个 ASTConsumer
类的子类。AST 消费者是一个你可以使用它来在 AST 上运行操作的类,无论 AST 的来源如何。对于我们的第一个插件,不需要更多。你可以在 NamingPlugin.cpp
文件中创建实现,如下所示:
-
开始时,包括所需的头文件。除了提到的
ASTConsumer
类之外,你还需要编译器和插件注册表的实例:#include "clang/AST/ASTConsumer.h" #include "clang/Frontend/CompilerInstance.h" #include "clang/Frontend/FrontendPluginRegistry.h"
-
使用
clang
命名空间,并将你的实现放入一个匿名namespace
中,以避免名称冲突:using namespace clang; namespace {
-
接下来,定义你的
ASTConsumer
类的子类。稍后,如果你检测到命名规则的违规,你将想要发出警告。为此,你需要一个DiagnosticsEngine
实例的引用。 -
你需要在类中存储一个
CompilerInstance
实例,之后你可以请求一个DiagnosticsEngine
实例:class NamingASTConsumer : public ASTConsumer { CompilerInstance &CI; public: NamingASTConsumer(CompilerInstance &CI) : CI(CI) {}
-
ASTConsumer
实例有几个入口方法。HandleTopLevelDecl()
方法符合我们的目的。该方法为顶级声明调用。这包括不仅仅是函数——例如,变量。因此,你必须使用 LLVM RTTIdyn_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)) {
-
在找到函数声明后,你需要检索函数名称。同时,你还需要确保名称不为空:
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
实例。此外,你还需要一个消息 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);
-
除了关闭所有未闭合的大括号外,你需要从这个函数返回
true
以指示处理可以继续:} } } return true; } };
-
接下来,你需要创建实现 clang 调用的
PluginASTAction
子类: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); }
-
插件还可以访问命令行选项。你的插件没有命令行参数,你将只返回
true
以指示成功:bool ParseArgs(const CompilerInstance &CI, const std::vector<std::string> &args) override { return true; }
-
插件的动作类型描述了动作何时被调用。默认值是
Cmdline
,这意味着插件必须在命令行上指定才能被调用。你需要重写该方法并将值更改为AddAfterMainAction
,这将自动运行动作:PluginASTAction::ActionType getActionType() override { return AddAfterMainAction; }
-
你的
PluginNamingAction
类的实现已完成;只缺少类和匿名命名空间的闭合大括号。将它们添加到代码中,如下所示:}; }
-
最后,你需要注册插件。第一个参数是插件名称,第二个参数是帮助文本:
static FrontendPluginRegistry::Add<PluginNamingAction> X("naming-plugin", "naming plugin");
这完成了插件的实现。要编译插件,请在CMakeLists.txt
文件中创建一个构建描述。由于插件位于 clang 源树之外,因此你需要设置一个完整的项目。你可以通过以下步骤来完成:
-
开始时,定义所需的CMake版本和项目名称:
cmake_minimum_required(VERSION 3.20.0) project(naminglugin)
-
接下来,包含 LLVM 文件。如果 CMake 无法自动找到文件,那么你必须设置
LLVM_DIR
变量,使其指向包含 CMake 文件的 LLVM 目录:find_package(LLVM REQUIRED CONFIG)
-
将 LLVM 目录和 CMake 文件夹添加到搜索路径中,并包含一些必需的模块:
list(APPEND CMAKE_MODULE_PATH ${LLVM_DIR}) include(AddLLVM) include(HandleLLVMOptions)
-
然后,加载 clang 的 CMake 定义。如果 CMake 无法自动找到文件,那么你必须设置
Clang_DIR
变量,使其指向包含 CMake 文件的 clang 目录:find_package(Clang REQUIRED)
-
接下来,定义头文件和库文件的位置以及要使用的定义:
include_directories("${LLVM_INCLUDE_DIR}" "${CLANG_INCLUDE_DIRS}") add_definitions("${LLVM_DEFINITIONS}") link_directories("${LLVM_LIBRARY_DIR}")
-
之前的定义设置了构建环境。插入以下命令,它定义了你的插件名称、插件源文件以及它是一个 clang 插件:
add_llvm_library(NamingPlugin MODULE NamingPlugin.cpp PLUGIN_TOOL clang)
在 Windows 上,插件支持与 Unix 不同,必须链接所需的 LLVM 和 clang 库。以下代码确保了这一点:
if(WIN32 OR CYGWIN) set(LLVM_LINK_COMPONENTS Support) clang_target_link_libraries(NamingPlugin PRIVATE clangAST clangBasic clangFrontend clangLex) endif()
现在,我们可以配置和构建插件,假设 CMAKE_GENERATOR
和 CMAKE_BUILD_TYPE
环境变量已设置:
$ cmake -DLLVM_DIR=~/LLVM/llvm-17/lib/cmake/llvm \
-DClang_DIR=~/LLVM/llvm-17/lib/cmake/clang \
-B build
$ cmake --build build
这些步骤在 build
目录中创建了 NamingPlugin.so
共享库。
要测试插件,将以下源代码保存为 naming.c
文件。函数名 Func1
违反了命名规则,但 main
名称没有:
int Func1() { return 0; }
int main() { return Func1(); }
要调用插件,你需要指定 –fplugin=
选项:
$ clang -fplugin=build/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()
方法。你可以按照以下步骤重写插件,使其使用访问者:
-
你需要额外的
include
来定义RecursiveASTVisitor
类。按照以下方式插入它:#include "clang/AST/RecursiveASTVisitor.h"
-
然后,将访问者定义为匿名命名空间中的第一个类。你将只存储对 AST 上下文的引用,这将为你提供访问所有重要的 AST 操作方法,包括用于发出警告的
DiagnosticsEngine
实例:class NamingVisitor : public RecursiveASTVisitor<NamingVisitor> { private: ASTContext &ASTCtx; public: explicit NamingVisitor(CompilerInstance &CI) : ASTCtx(CI.getASTContext()) {}
-
在遍历过程中,每当发现函数声明时,都会调用
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; } };
-
这完成了访问者的实现。在你的
NamingASTConsumer
类中,你现在将只存储一个访问者实例:std::unique_ptr<NamingVisitor> Visitor; public: NamingASTConsumer(CompilerInstance &CI) : Visitor(std::make_unique<NamingVisitor>(CI)) {}
-
删除
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 架构添加新的后端目标,使用 TableGen 语言。此外,你还将探索 LLVM 内部的各个指令选择框架,并了解如何实现它们。最后,你还将深入研究 LLVM 指令选择框架之外的某些概念,这些概念对于高度优化后端非常有价值。
本节包括以下章节:
-
第十一章, 目标描述
-
第十二章, 指令选择
-
第十三章, 超越指令选择
第十一章:目标描述
LLVM 具有非常灵活的架构。您也可以向其中添加新的目标后端。后端的核心是目标描述,大部分代码都是从那里生成的。在本章中,您将学习如何添加对历史 CPU 的支持。
在本章中,您将涵盖以下内容:
-
为新后端做准备 介绍了 M88k CPU 架构,并展示了如何找到所需信息
-
将新架构添加到 Triple 类中 教您如何让 LLVM 识别新的 CPU 架构
-
在 LLVM 中扩展 ELF 文件格式定义 展示了如何向处理 ELF 对象文件的库和工具添加对 M88k 特定重定位的支持
-
创建目标描述 将您对 TableGen 语言的了解应用于在目标描述中建模寄存器文件和指令
-
将 M88k 后端添加到 LLVM 解释了为 LLVM 后端所需的最低基础设施
-
实现汇编器解析器 展示了如何开发汇编器
-
创建反汇编器 教您如何创建反汇编器
到本章结束时,您将了解如何将新后端添加到 LLVM。您将获得开发目标描述中的寄存器文件定义和指令定义的知识,并且您将知道如何从该描述中创建汇编器和反汇编器。
为新后端做准备
无论是否需要商业上支持新的 CPU,还是仅仅作为一个爱好项目来添加对某些旧架构的支持,将新后端添加到 LLVM 都是一项重大任务。本节和接下来的两章概述了您需要为新后端开发的内容。我们将添加一个用于摩托罗拉 M88k 架构的后端,这是一个 80 年代的 RISC 架构。
参考文献
您可以在维基百科上了解更多关于这种摩托罗拉架构的信息:en.wikipedia.org/wiki/Motorola_88000
。关于该架构的最重要信息仍然可以在互联网上找到。您可以在 www.bitsavers.org/components/motorola/88000/
找到 CPU 手册以及指令集和时序信息,在 archive.org/details/bitsavers_attunixSysa0138776555SystemVRelease488000ABI1990_8011463
可以找到 System V ABI M88k 处理器补充,其中包含了 ELF 格式和调用约定的定义。
OpenBSD,可在 www.openbsd.org/
获取,仍然支持 LUNA-88k 系统。在 OpenBSD 系统上,创建 M88k 的 GCC 跨编译器很容易。而且,使用可在 gavare.se/gxemul/
获取的 GXemul,我们可以得到一个能够运行某些 OpenBSD 版本的 M88k 架构的仿真器。
M88k 架构已经很久没有生产了,但我们找到了足够的信息和工具,使其成为添加 LLVM 后端的一个有趣目标。我们将从扩展 Triple
类的非常基础的任务开始。
将新的架构添加到 Triple 类
Triple
类的实例表示 LLVM 为其生成代码的目标平台。为了支持新的架构,第一个任务是扩展 Triple
类。在 llvm/include/llvm/TargetParser/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/TargetParser/Triple.cpp
文件内部,有许多使用 ArchType
枚举的方法。你需要扩展它们所有;例如,在 getArchTypeName()
方法中,你需要添加一个新的 case
语句,如下所示:
switch (Kind) {
// Many more cases
case m88k: return "m88k";
}
大多数情况下,编译器会警告你如果在某个函数中忘记处理新的 m88k
枚举成员。接下来,我们将扩展 可执行和链接 格式 (ELF)。
扩展 LLVM 中的 ELF 文件格式定义
ELF 文件格式是 LLVM 支持的几种二进制对象文件格式之一。ELF 本身是为许多 CPU 架构定义的,也有 M88k 架构的定义。我们只需要添加重定位的定义和一些标志。重定位在 系统 V ABI M88k 处理器 补充书的 IR 代码生成基础 章节中给出(见章节开头 为新的后端设置舞台 部分的链接):第四章
-
我们需要在
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…
-
我们还在
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 架构的代码之前插入它。
-
我们还需要扩展一些其他方法。在
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"; }
-
同样,我们扩展
getArch()
方法:switch (EF.getHeader().e_machine) { // Many more cases case ELF::EM_88K: return Triple::m88k;
-
最后,我们在
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; }
-
为了完成支持,你也可以扩展
llvm/lib/ObjectYAML/ELFYAML.cpp
文件。此文件由yaml2obj
和obj2yaml
工具使用,它们根据 YAML 描述创建 ELF 文件,反之亦然。第一个添加需要在ScalarEnumerationTraits<ELFYAML::ELF_EM>::enumeration()
方法中完成,该方法列出 ELF 架构的所有值:ECase(EM_88K);
-
同样,在
ScalarEnumerationTraits<ELFYAML::ELF_REL>::enumeration()
方法中,你需要再次包含重定位的定义:case ELF::EM_88K: #include "llvm/BinaryFormat/ELFRelocs/M88k.def" break;
在这个阶段,我们已经完成了对 ELF 文件格式中 m88k 架构的支持。您可以使用 llvm-readobj
工具来检查 ELF 对象文件,例如,由 OpenBSD 上的交叉编译器创建的文件。同样,您可以使用 yaml2obj
工具为 m88k 架构创建 ELF 对象文件。
添加对对象文件格式的支持是强制性的吗?
将对架构的支持集成到 ELF 文件格式实现中只需要几行代码。如果您正在创建的 LLVM 后端使用的架构是 ELF 格式,那么您应该选择这条路径。另一方面,添加对完全新的二进制文件格式的支持是一个复杂的过程。如果这是必需的,那么常用的方法是将汇编文件输出,并使用外部汇编器创建对象文件。
通过这些添加,LLVM 对 ELF 文件格式的实现现在支持了 M88k 架构。在下一节中,我们将为 M88k 架构创建目标描述,它描述了指令、寄存器以及许多关于架构的细节。
创建目标描述
llvm/include/llvm/Target/Target.td
文件,可以在 github.com/llvm/llvm-project/blob/main/llvm/include/llvm/Target/Target.td
上找到。这个文件注释很多,是关于定义使用的有用信息来源。
在理想的世界里,我们会从目标描述中生成整个后端。这个目标尚未实现,因此,我们以后还需要扩展生成的代码。由于文件大小,目标描述被拆分成了几个文件。顶层文件将是 M88k.td
,位于 llvm/lib/Target/M88k
目录中,该目录还包括其他文件。让我们看看一些文件,从寄存器定义开始。
添加寄存器定义
CPU 架构通常定义一组寄存器。这些寄存器的特性可能不同。一些架构允许访问子寄存器。例如,x86 架构有特殊的寄存器名称来访问寄存器值的一部分。其他架构不实现这一点。除了通用、浮点数和向量寄存器外,架构还可能有用于状态代码或浮点操作配置的特殊寄存器。我们需要为 LLVM 定义所有这些信息。寄存器定义存储在 M88kRegisterInfo.td
文件中,也可以在 llvm/lib/Target/M88k
目录中找到。
M88k 架构定义了通用寄存器、用于浮点操作的扩展寄存器和控制寄存器。为了使示例保持简洁,我们只定义通用寄存器。我们首先定义寄存器的超类。寄存器有一个名称和一个编码。名称用于指令的文本表示。同样,编码用作指令二进制表示的一部分。该架构定义了 32 个寄存器,因此寄存器的编码使用 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";
}
接下来,我们可以定义所有 32 个通用寄存器。r0
寄存器是特殊的,因为它在读取时总是返回常数0
,因此我们将该寄存器的isConstant
标志设置为true
:
foreach I = 0-31 in {
let isConstant = !eq(I, 0) in
def R#I : M88kReg<I, "r"#I>;
}
对于寄存器分配器,单个寄存器需要被分组到寄存器类中。寄存器的顺序定义了分配顺序。寄存器分配器还需要其他关于寄存器的信息,例如,例如,可以存储在寄存器中的值类型、寄存器的位溢出大小以及内存中所需的对齐方式。我们不是直接使用RegisterClass
基类,而是创建了一个新的M88kRegisterClass
类。这允许我们根据需要更改参数列表。它还避免了重复使用生成代码中使用的 C++命名空间名称,这是RegisterClass
类的第一个参数:
class M88kRegisterClass<list<ValueType> types, int size,
int alignment, dag regList,
int copycost = 1>
: RegisterClass<"M88k", types, alignment, regList> {
let Size = size;
let CopyCost = copycost;
}
此外,我们定义了一个用于寄存器操作数的类。操作数描述了指令的输入和输出。它们在指令的汇编和反汇编过程中以及指令选择阶段使用的模式中都被使用。使用我们自己的类,我们可以给用于解码寄存器操作数的生成函数一个符合 LLVM 编码指南的名称:
class M88kRegisterOperand<RegisterClass RC>
: RegisterOperand<RC> {
let DecoderMethod = "decode"#RC#"RegisterClass";
}
基于这些定义,我们现在定义通用寄存器。请注意,m88k 架构的通用寄存器是 32 位宽的,可以存储整数和浮点值。为了避免编写所有寄存器名称,我们使用sequence
生成器,它根据模板字符串生成字符串列表:
def GPR : M88kRegisterClass<[i32, f32], 32, 32,
(add (sequence "R%u", 0, 31))>;
同样,我们定义了寄存器操作数。r0
寄存器是特殊的,因为它包含常数0
。这个事实可以被全局指令选择框架使用,因此我们将此信息附加到寄存器操作数上:
def GPROpnd : M88kRegisterOperand<GPR> {
let GIZeroRegister = R0;
}
m88k 架构有一个扩展,它定义了一个仅用于浮点值的扩展寄存器文件。您将按照与通用寄存器相同的方式定义这些寄存器。
通用寄存器也成对使用,主要用于 64 位浮点运算,我们需要对它们进行建模。我们使用sub_hi
和sub_lo
子寄存器索引来描述高 32 位和低 32 位。我们还需要设置生成的代码的 C++命名空间:
let Namespace = "M88k" in {
def sub_hi : SubRegIndex<32, 0>;
def sub_lo : SubRegIndex<32, 32>;
}
然后使用RegisterTuples
类定义寄存器对。该类将子寄存器索引列表作为第一个参数,将寄存器列表作为第二个参数。我们只需要偶数/奇数对,我们通过序列的可选第四个参数(生成序列时使用的步长)来实现这一点:
def GRPair : RegisterTuples<[sub_hi, sub_lo],
[(add (sequence "R%u", 0, 30, 2)),
(add (sequence "R%u", 1, 31, 2))]>;
要使用寄存器对,我们定义了一个寄存器类和一个寄存器操作数:
def GPR64 : M88kRegisterClass<[i64, f64], 64, 32,
(add GRPair), /*copycost=*/ 2>;
def GPR64Opnd : M88kRegisterOperand<GPR64>;
请注意,我们将copycost
参数设置为2
,因为我们需要两个指令而不是一个来复制寄存器对到另一个寄存器对。
这完成了我们对寄存器的定义。在下一节中,我们将定义指令格式。
定义指令格式和指令信息
指令使用 TableGen 的Instruction
类定义。定义一个指令是一个复杂任务,因为我们必须考虑许多细节。指令有一个文本表示,用于汇编器和反汇编器。它有一个名称,例如and
,并且它可能有操作数。汇编器将文本表示转换为二进制格式,因此我们必须定义该格式的布局。为了指令选择,我们需要将一个模式附加到指令上。为了管理这种复杂性,我们定义了一个类层次结构。基类将描述各种指令格式,并存储在M88kIntrFormats.td
文件中。指令本身和其他用于指令选择的定义存储在M88kInstrInfo.td
文件中。
让我们从定义一个名为M88kInst
的 m88k 架构指令类开始。我们从这个预定义的Instruction
类中派生这个类。我们的新类有几个参数。outs
和ins
参数描述了输出和输入操作数,作为一个使用特殊dag
类型的列表。指令的文本表示被分为asm
参数中给出的助记符和操作数。最后,pattern
参数可以存储用于指令选择的模式。
我们还需要定义两个新字段:
-
Inst
字段用于存储指令的位模式。由于指令的大小取决于平台,因此该字段不能预先定义。m88k 架构的所有指令都是 32 位宽,因此该字段具有bits<32>
类型。 -
另一个字段称为
SoftFail
,其类型与Inst
相同。它包含一个位掩码,用于与指令一起使用,其实际编码可以与Inst
字段中的位不同,但仍有效。唯一需要这个的平台是 ARM,因此我们可以简单地将此字段设置为0
。
其他字段在超类中定义,我们只需设置值。TableGen 语言中可以进行简单的计算,我们使用它来创建 AsmString
字段的值,该字段持有完整的汇编表示。如果 operands
操作数字符串为空,则 AsmString
字段将只包含 asm
参数的值;否则,它将是两个字符串的连接,它们之间有一个空格:
class InstM88k<dag outs, dag ins, string asm, string operands,
list<dag> pattern = []>
: Instruction {
bits<32> Inst;
bits<32> SoftFail = 0;
let Namespace = "M88k";
let Size = 4;
dag OutOperandList = outs;
dag InOperandList = ins;
let AsmString = !if(!eq(operands, ""), asm,
!strconcat(asm, " ", operands));
let Pattern = pattern;
let DecoderNamespace = "M88k";
}
对于指令编码,制造商通常将指令分组,同一组中的指令具有相似的编码。我们可以使用这些组来系统地创建定义指令格式的类。例如,m88k 架构的所有逻辑操作将目标寄存器编码在位 21 到 25,第一个源寄存器编码在位 16 到 20。请注意这里的实现模式:我们声明 rd
和 rs1
字段用于值,并将这些值分配给 Inst
字段中正确的位位置,这是我们之前在超类中定义的:
class F_L<dag outs, dag ins, string asm, string operands,
list<dag> pattern = []>
: InstM88k<outs, ins, asm, operands, pattern> {
bits<5> rd;
bits<5> rs1;
let Inst{25-21} = rd;
let Inst{20-16} = rs1;
}
基于这种格式的逻辑操作有几组。其中之一是使用三个寄存器的指令组,在手册中称为 三地址模式:
class F_LR<bits<5> func, bits<1> comp, string asm,
list<dag> pattern = []>
: F_L<(outs GPROpnd:$rd), (ins GPROpnd:$rs1, GPROpnd:$rs2),
!if(comp, !strconcat(asm, ".c"), asm),
"$rd, $rs1, $rs2", pattern> {
bits<5> rs2;
let Inst{31-26} = 0b111101;
let Inst{15-11} = func;
let Inst{10} = comp;
let Inst{9-5} = 0b00000;
let Inst{4-0} = rs2;
}
让我们更详细地检查这个类提供的功能。func
参数指定操作。作为一个特殊功能,第二个操作数可以在操作之前取补码,这通过设置 1
来指示。助记符在 asm
参数中给出,并且可以传递一个指令选择模式。
通过初始化超类,我们可以提供更多信息。and
指令的完整汇编文本模板是 and $rd, $rs1, $rs2
。这个操作数字符串对于这个组的所有指令都是固定的,因此我们可以在这里定义它。助记符由这个类的用户给出,但我们可以在这里附加 .c
后缀,表示第二个操作数应该首先取补码。最后,我们可以定义输出和输入操作数。这些操作数表示为 (``outs GPROpnd:$rd)
。
outs
操作符表示这个有向图(dag)为输出操作数列表。唯一的参数 GPROpnd:$rd
包含一个类型和一个名称。它连接了我们之前已经看到的一些部分。类型是 GPROnd
,这是我们在上一节中定义的寄存器操作数的名称。名称 $rd
指的是目标寄存器。我们之前在操作数字符串中使用过这个名称,也在 F_L
超类中作为字段名称。输入操作数以类似的方式定义。类的其余部分初始化 Inst
字段的其余位。请花点时间检查一下,现在是否确实已经分配了所有 32 位。
我们将最终的指令定义放在M88kInstrInfo.td
文件中。由于我们为每个逻辑指令有两个变体,我们使用多类同时定义这两个指令。我们还在这里定义了指令选择的模式,作为一个有向无环图。模式中的操作是set
,第一个参数是目标寄存器。第二个参数是一个嵌套图,这是实际的模式。再次强调,操作名称是第一个OpNode
元素。LLVM 有许多预定义的操作,你可以在llvm/include/llvm/Target/TargetSelectionDAG.td
文件中找到它们(github.com/llvm/llvm-project/blob/main/llvm/include/llvm/Target/TargetSelectionDAG.td
)。例如,有and
操作,表示位与操作。参数是两个源寄存器,$rs1
和$rs2
。你可以大致这样阅读这个模式:如果指令选择的输入包含使用两个寄存器的 OpNode 操作,则将这个操作的结果分配给$rd
寄存器并生成这个指令。利用图结构,你可以定义更复杂的模式。例如,第二个模式使用not
操作数将补码集成到模式中。
需要指出的小细节是逻辑运算具有交换律。这有助于指令选择,因此我们将这些指令的isCommutable
标志设置为1
:
multiclass Logic<bits<5> Fun, string OpcStr, SDNode OpNode> {
let isCommutable = 1 in
def rr : F_LR<Fun, /*comp=*/0b0, OpcStr,
[(set i32:$rd,
(OpNode GPROpnd:$rs1, GPROpnd:$rs2))]>;
def rrc : F_LR<Fun, /*comp=*/0b1, OpcStr,
[(set i32:$rd,
(OpNode GPROpnd:$rs1, (not GPROpnd:$rs2)))]>;
}
最后,我们定义了指令的记录:
defm AND : Logic<0b01000, "and", and>;
defm XOR : Logic<0b01010, "xor", xor>;
defm OR : Logic<0b01011, "or", or>;
第一个参数是功能的位模式,第二个参数是助记符,第三个参数是模式中使用的 dag 操作。
要完全理解类层次结构,请重新查看类定义。指导设计原则是避免信息重复。例如,0b01000
功能位模式只使用了一次。如果没有Logic
多类,你需要输入这个位模式两次,并多次重复模式,这容易出错。
请注意,为指令建立命名方案是很好的做法。例如,and
指令的记录命名为ANDrr
,而带有补码寄存器的变体命名为ANDrrc
。这些名称最终会出现在生成的 C++源代码中,使用命名方案有助于理解名称指的是哪个汇编指令。
到目前为止,我们已经对 m88k 架构的寄存器文件进行了建模,并定义了一些指令。在下一节中,我们将创建顶层文件。
为目标描述创建顶层文件
到目前为止,我们已经创建了M88kRegisterInfo.td
、M88kInstrFormats.td
和M88kInstrInfo.td
文件。目标描述是一个单独的文件,称为M88k.td
。该文件首先包含 LLVM 定义,然后是我们已实现的文件:
include "llvm/Target/Target.td"
include "M88kRegisterInfo.td"
include "M88kInstrFormats.td"
include "M88kInstrInfo.td"
我们将在添加更多后端功能时扩展这个 include
部分。
顶层文件还定义了一些全局实例。第一个名为 M88kInstrInfo
的记录包含了所有指令的信息:
def M88kInstrInfo : InstrInfo;
我们将汇编器类命名为 M88kAsmParser
。为了使 TableGen 能够识别硬编码的寄存器,我们指定寄存器名称以百分号开头,并且需要定义一个汇编器解析器变体来指定这一点:
def M88kAsmParser : AsmParser;
def M88kAsmParserVariant : AsmParserVariant {
let RegisterPrefix = "%";
}
最后,我们需要定义目标:
def M88k : Target {
let InstructionSet = M88kInstrInfo;
let AssemblyParsers = [M88kAsmParser];
let AssemblyParserVariants = [M88kAsmParserVariant];
}
现在我们已经定义了足够的目标信息,可以编写第一个实用工具。在下一节中,我们将添加 M88k 后端到 LLVM。
将 M88k 后端添加到 LLVM
我们尚未讨论目标描述文件放置的位置。LLVM 中的每个后端都在 llvm/lib/Target
下的一个子目录中。我们在这里创建 M88k
目录,并将目标描述文件复制到其中。
当然,仅仅添加 TableGen 文件是不够的。LLVM 使用一个注册表来查找目标实现的实例,并期望某些全局函数注册这些实例。由于某些部分是生成的,我们已提供实现。
关于每个目标的所有信息,如目标三元组、目标机器的工厂函数、汇编器、反汇编器等,都存储在 Target
类的一个实例中。每个目标都持有该类的静态实例,并且该实例在中央注册表中注册:
-
实现在我们目标中的
TargetInfo
子目录下的M88kTargetInfo.cpp
文件中。Target
类的单个实例被保留在getTheM88kTarget()
函数中:using namespace llvm; Target &llvm::getTheM88kTarget() { static Target TheM88kTarget; return TheM88kTarget; }
-
LLVM 要求每个目标提供一个
LLVMInitialize<Target Name>TargetInfo()
函数来注册目标实例。该函数必须具有 C 链接,因为它也用于 LLVM C API:extern "C" LLVM_EXTERNAL_VISIBILITY void LLVMInitializeM88kTargetInfo() { RegisterTarget<Triple::m88k, /*HasJIT=*/false> X( getTheM88kTarget(), "m88k", "M88k", "M88k"); }
-
我们还需要在同一个目录中创建一个
M88kTargetInfo.h
头文件,它只包含一个声明:namespace llvm { class Target; Target &getTheM88kTarget(); }
-
最后,我们添加一个
CMakeLists.txt
文件用于构建:add_llvm_component_library(LLVMM88kInfo M88kTargetInfo.cpp LINK_COMPONENTS Support ADD_TO_COMPONENT M88k)
接下来,我们在目标实例中部分填充了在机器代码(MC)级别使用的相关信息。让我们开始吧:
-
实现在我们目标中的
MCTargetDesc
子目录下的M88kMCTargetDesc.cpp
文件中。TableGen 将我们在上一节中创建的目标描述转换为 C++ 源代码片段。在这里,我们包含了寄存器信息、指令信息和子目标信息的部分:using namespace llvm; #define GET_INSTRINFO_MC_DESC #include "M88kGenInstrInfo.inc" #define GET_SUBTARGETINFO_MC_DESC #include "M88kGenSubtargetInfo.inc" #define GET_REGINFO_MC_DESC #include "M88kGenRegisterInfo.inc"
-
目标注册表期望为这里的每个类提供一个工厂方法。让我们从指令信息开始。我们分配一个
MCInstrInfo
类的实例,并调用生成的InitM88kMCInstrInfo()
函数来填充对象:static MCInstrInfo *createM88kMCInstrInfo() { MCInstrInfo *X = new MCInstrInfo(); InitM88kMCInstrInfo(X); return X; }
-
接下来,我们分配一个
MCRegisterInfo
类的对象,并调用一个生成的函数来填充它。额外的M88k::R1
参数值告诉 LLVM,r1
寄存器持有返回地址:static MCRegisterInfo * createM88kMCRegisterInfo(const Triple &TT) { MCRegisterInfo *X = new MCRegisterInfo(); InitM88kMCRegisterInfo(X, M88k::R1); return X; }
-
最后,我们需要一个子目标信息的工厂方法。该方法接受一个目标三元组、一个 CPU 名称和一个特性字符串作为参数,并将它们转发到生成的函数:
static MCSubtargetInfo * createM88kMCSubtargetInfo(const Triple &TT, StringRef CPU, StringRef FS) { return createM88kMCSubtargetInfoImpl(TT, CPU, /*TuneCPU*/ CPU, FS); }
-
定义了工厂方法之后,我们现在可以注册它们。类似于目标注册,LLVM 预期一个全局函数名为
LLVMInitialize<Target Name>TargetMC()
:extern "C" LLVM_EXTERNAL_VISIBILITY void LLVMInitializeM88kTargetMC() { TargetRegistry::RegisterMCInstrInfo( getTheM88kTarget(), createM88kMCInstrInfo); TargetRegistry::RegisterMCRegInfo( getTheM88kTarget(), createM88kMCRegisterInfo); TargetRegistry::RegisterMCSubtargetInfo( getTheM88kTarget(), createM88kMCSubtargetInfo); }
-
M88kMCTargetDesc.h
头文件只是使一些生成的代码可用:#define GET_REGINFO_ENUM #include "M88kGenRegisterInfo.inc" #define GET_INSTRINFO_ENUM #include "M88kGenInstrInfo.inc" #define GET_SUBTARGETINFO_ENUM #include "M88kGenSubtargetInfo.inc"
实现几乎完成。为了防止链接器错误,我们需要提供另一个函数,该函数注册一个 TargetMachine
类对象的工厂方法。这个类对于代码生成是必需的,我们在 第十二章 指令选择 中实现它,接下来。在这里,我们只是在 M88kTargetMachine.cpp
文件中定义了一个空函数:
#include "TargetInfo/M88kTargetInfo.h"
#include "llvm/MC/TargetRegistry.h"
extern "C" LLVM_EXTERNAL_VISIBILITY void
LLVMInitializeM88kTarget() {
// TODO Register the target machine. See chapter 12.
}
这标志着我们第一次实现的结束。然而,LLVM 还不知道我们的新后端。要集成它,打开 llvm/CMakeLists.txt
文件,找到定义所有实验性目标的章节,并将 M88k 目标添加到列表中:
set(LLVM_ALL_EXPERIMENTAL_TARGETS ARC … M88k …)
假设我们的新后端源代码在目录中,你可以通过输入以下内容来配置构建:
$ mkdir build
$ cd build
$ cmake -DLLVM_EXPERIMENTAL_TARGETS_TO_BUILD=M88k \
../llvm-m88k/llvm
…
-- Targeting M88k
…
在构建 LLVM 之后,你可以验证工具已经知道我们的新目标:
$ bin/llc –version
LLVM (http://llvm.org/):
LLVM version 17.0.2
Registered Targets:
m88k - M88k
到达这个阶段的过程很艰难,所以花点时间庆祝一下吧!
修复可能的编译错误
在 LLVM 17.0.2 中存在一个小疏忽,导致编译错误。在代码的一个地方,子目标信息的 TableGen 发射器使用了已删除的值 llvm::None
而不是 std::nullopt
,导致在编译 M88kMCTargetDesc.cpp
时出错。修复此问题的最简单方法是 cherry-pick 从 LLVM 18 开发分支的修复:git cherry-pick -x a587f429
。
在下一节中,我们实现汇编器解析器,这将给我们第一个工作的 LLVM 工具。
实现汇编器解析器
汇编器解析器很容易实现,因为 LLVM 为此提供了一个框架,大部分代码都是从目标描述中生成的。
我们类中的 ParseInstruction()
方法在框架检测到需要解析指令时被调用。该方法通过提供的词法分析器解析输入,并构建一个所谓的操作数向量。操作数可以是一个指令助记符、寄存器名称或立即数,或者它可以是针对目标特定的类别。例如,从 jmp %r2
输入中构建了两个操作数:一个用于助记符的标记操作数和一个寄存器操作数。
然后,一个生成的匹配器尝试将操作数向量与指令进行匹配。如果找到匹配项,则创建 MCInst
类的一个实例,该实例包含解析后的指令。否则,会发出错误消息。这种方法的优势是它自动从目标描述中推导出匹配器,而无需处理所有语法上的怪癖。
然而,我们需要添加几个额外的支持类来使汇编解析器工作。这些额外的类都存储在MCTargetDesc
目录中。
实现 M88k 目标的 MCAsmInfo 支持类
在本节中,我们探讨实现汇编解析器配置的第一个必需类:MCAsmInfo
类:
-
我们需要为汇编解析器设置一些定制参数。
MCAsmInfo
基类(github.com/llvm/llvm-project/blob/main/llvm/include/llvm/MC/MCAsmInfo.h
)包含了通用参数。此外,为每个支持的文件格式创建了一个子类;例如,MCAsmInfoELF
类(github.com/llvm/llvm-project/blob/main/llvm/include/llvm/MC/MCAsmInfoELF.h
)。这样做的原因是,使用相同文件格式的系统汇编器具有共同的特征,因为它们必须支持类似的功能。我们的目标操作系统是 OpenBSD,它使用 ELF 文件格式,因此我们从MCAsmInfoELF
类派生出自定义的M88kMCAsmInfo
类。M88kMCAsmInfo.h
文件中的声明如下:namespace llvm { class Triple; class M88kMCAsmInfo : public MCAsmInfoELF { public: explicit M88kMCAsmInfo(const Triple &TT); };
-
M88kMCAsmInfo.cpp
文件中的实现仅设置了一些默认值。目前有两个关键设置:使用大端模式以及使用|
符号作为注释。其他设置用于后续的代码生成:using namespace llvm; M88kMCAsmInfo::M88kMCAsmInfo(const Triple &TT) { IsLittleEndian = false; UseDotAlignForAlignment = true; MinInstAlignment = 4; CommentString = "|"; // # as comment delimiter is only // allowed at first column ZeroDirective = "\t.space\t"; Data64bitsDirective = "\t.quad\t"; UsesELFSectionDirectiveForBSS = true; SupportsDebugInformation = false; ExceptionsType = ExceptionHandling::SjLj; }
现在我们已经完成了MCAsmInfo
类的实现。接下来我们将学习实现下一个类,这个类帮助我们创建 LLVM 中指令的二进制表示。
实现 M88k 目标的 MCCodeEmitter 支持类
在 LLVM 内部,指令通过MCInst
类的实例来表示。指令可以被输出为汇编文本或二进制形式到目标文件中。M88kMCCodeEmitter
类创建指令的二进制表示,而M88kInstPrinter
类则输出其文本表示。
首先,我们将实现M88kMCCodeEmitter
类,该类存储在M88kMCCodeEmitter.cpp
文件中:
-
大多数类都是由 TableGen 生成的。因此,我们只需要添加一些样板代码。请注意,没有相应的头文件;工厂函数的原型将被添加到
M88kMCTargetDesc.h
文件中。它从设置输出指令数量的统计计数器开始:using namespace llvm; #define DEBUG_TYPE "mccodeemitter" STATISTIC(MCNumEmitted, "Number of MC instructions emitted");
-
M88kMCCodeEmitter
类位于匿名命名空间中。我们只需要实现基类中声明的encodeInstruction()
方法以及getMachineOpValue()
辅助方法。其他getBinaryCodeForInstr()
方法由 TableGen 从目标描述中生成:namespace { class M88kMCCodeEmitter : public MCCodeEmitter { const MCInstrInfo &MCII; MCContext &Ctx; public: M88kMCCodeEmitter(const MCInstrInfo &MCII, MCContext &Ctx) : MCII(MCII), Ctx(Ctx) {} ~M88kMCCodeEmitter() override = default; void encodeInstruction( const MCInst &MI, raw_ostream &OS, SmallVectorImpl<MCFixup> &Fixups, const MCSubtargetInfo &STI) const override; uint64_t getBinaryCodeForInstr( const MCInst &MI, SmallVectorImpl<MCFixup> &Fixups, const MCSubtargetInfo &STI) const; unsigned getMachineOpValue(const MCInst &MI, const MCOperand &MO, SmallVectorImpl<MCFixup> &Fixups, const MCSubtargetInfo &STI) const; }; } // end anonymous namespace
-
encodeInstruction()
方法仅查找指令的二进制表示,增加统计计数器,并以大端格式写入字节。记住,指令具有固定的 4 字节大小,因此我们在端序流上使用uint32_t
类型:void M88kMCCodeEmitter::encodeInstruction( const MCInst &MI, raw_ostream &OS, SmallVectorImpl<MCFixup> &Fixups, const MCSubtargetInfo &STI) const { uint64_t Bits = getBinaryCodeForInstr(MI, Fixups, STI); ++MCNumEmitted; support::endian::write<uint32_t>(OS, Bits, support::big); }
-
getMachineOpValue()
方法的任务是返回操作数的二进制表示。在目标描述中,我们定义了寄存器在指令中存储的位范围。在这里,我们计算存储在这些位置的值。该方法由生成的代码调用。我们只支持两种情况。对于寄存器,返回我们在目标描述中定义的寄存器编码。对于立即数,返回立即数值:unsigned M88kMCCodeEmitter::getMachineOpValue( const MCInst &MI, const MCOperand &MO, SmallVectorImpl<MCFixup> &Fixups, const MCSubtargetInfo &STI) const { if (MO.isReg()) return Ctx.getRegisterInfo()->getEncodingValue( MO.getReg()); if (MO.isImm()) return static_cast<uint64_t>(MO.getImm()); return 0; }
-
最后,我们包含生成的文件并为该类创建一个工厂方法:
#include "M88kGenMCCodeEmitter.inc" MCCodeEmitter * llvm::createM88kMCCodeEmitter(const MCInstrInfo &MCII, MCContext &Ctx) { return new M88kMCCodeEmitter(MCII, Ctx); }
为 M88k 目标实现指令打印支持类
M88kInstPrinter
类的结构与 M88kMCCodeEmitter
类类似。如前所述,InstPrinter
类负责输出 LLVM 指令的文本表示。类的大部分是由 TableGen 生成的,但我们必须添加打印操作数的支持。类在 M88kInstPrinter.h
头文件中声明。实现位于 M88kInstPrinter.cpp
文件中:
-
让我们从头文件开始。在包含所需的头文件并声明
llvm
命名空间之后,声明了两个前向引用以减少所需的包含数量:namespace llvm { class MCAsmInfo; class MCOperand;
-
除了构造函数之外,我们只需要实现
printOperand()
和printInst()
方法。其他方法由 TableGen 生成:class M88kInstPrinter : public MCInstPrinter { public: M88kInstPrinter(const MCAsmInfo &MAI, const MCInstrInfo &MII, const MCRegisterInfo &MRI) : MCInstPrinter(MAI, MII, MRI) {} std::pair<const char *, uint64_t> getMnemonic(const MCInst *MI) override; void printInstruction(const MCInst *MI, uint64_t Address, const MCSubtargetInfo &STI, raw_ostream &O); static const char *getRegisterName(MCRegister RegNo); void printOperand(const MCInst *MI, int OpNum, const MCSubtargetInfo &STI, raw_ostream &O); void printInst(const MCInst *MI, uint64_t Address, StringRef Annot, const MCSubtargetInfo &STI, raw_ostream &O) override; }; } // end namespace llvm
-
实现位于
M88kInstPrint.cpp
文件中。在包含所需的头文件并使用llvm
命名空间之后,包含生成了 C++ 片段的文件:using namespace llvm; #define DEBUG_TYPE "asm-printer" #include "M88kGenAsmWriter.inc"
-
printOperand()
方法检查操作数的类型,并输出一个寄存器名称或一个立即数。寄存器名称是通过getRegisterName()
生成的函数查找的:void M88kInstPrinter::printOperand( const MCInst *MI, int OpNum, const MCSubtargetInfo &STI, raw_ostream &O) { const MCOperand &MO = MI->getOperand(OpNum); if (MO.isReg()) { if (!MO.getReg()) O << '0'; else O << '%' << getRegisterName(MO.getReg()); } else if (MO.isImm()) O << MO.getImm(); else llvm_unreachable("Invalid operand"); }
-
printInst()
方法仅调用生成的printInstruction()
方法来打印指令,然后调用printAnnotation()
方法来打印可能的注释:void M88kInstPrinter::printInst( const MCInst *MI, uint64_t Address, StringRef Annot, const MCSubtargetInfo &STI, raw_ostream &O) { printInstruction(MI, Address, STI, O); printAnnotation(O, Annot); }
实现 M88k 特定的目标描述
在 M88kMCTargetDesc.cpp
文件中,我们需要做一些添加:
-
首先,我们需要为
MCInstPrinter
类和MCAsmInfo
类创建一个新的工厂方法:static MCInstPrinter *createM88kMCInstPrinter( const Triple &T, unsigned SyntaxVariant, const MCAsmInfo &MAI, const MCInstrInfo &MII, const MCRegisterInfo &MRI) { return new M88kInstPrinter(MAI, MII, MRI); } static MCAsmInfo * createM88kMCAsmInfo(const MCRegisterInfo &MRI, const Triple &TT, const MCTargetOptions &Options) { return new M88kMCAsmInfo(TT); }
-
最后,在
LLVMInitializeM88kTargetMC()
函数中,我们需要添加工厂方法的注册:extern "C" LLVM_EXTERNAL_VISIBILITY void LLVMInitializeM88kTargetMC() { // … TargetRegistry::RegisterMCAsmInfo( getTheM88kTarget(), createM88kMCAsmInfo); TargetRegistry::RegisterMCCodeEmitter( getTheM88kTarget(), createM88kMCCodeEmitter); TargetRegistry::RegisterMCInstPrinter( getTheM88kTarget(), createM88kMCInstPrinter); }
现在我们已经实现了所有必需的支持类,我们最终可以添加汇编解析器。
创建 M88k 汇编解析器类
在AsmParser
目录中只有一个M88kAsmParser.cpp
实现文件。M88kOperand
类表示一个解析后的操作数,并由生成的源代码和我们的汇编器解析器实现中的M88kAssembler
类使用。这两个类都在匿名命名空间中,只有工厂方法是全局可见的。让我们首先看看M88kOperand
类:
-
操作数可以是标记、寄存器或立即数。我们定义了
OperandKind
枚举来区分这些情况。当前类型存储在Kind
成员中。我们还存储操作数的起始和结束位置,这对于打印错误信息是必需的:class M88kOperand : public MCParsedAsmOperand { enum OperandKind { OpKind_Token, OpKind_Reg, OpKind_Imm }; OperandKind Kind; SMLoc StartLoc, EndLoc;
-
为了存储值,我们定义了一个联合。标记存储为
StringRef
,寄存器通过其编号来标识。立即数由MCExpr
类表示:union { StringRef Token; unsigned RegNo; const MCExpr *Imm; };
-
构造函数初始化所有字段,除了联合。此外,我们定义了返回起始和结束位置值的方法:
public: M88kOperand(OperandKind Kind, SMLoc StartLoc, SMLoc EndLoc) : Kind(Kind), StartLoc(StartLoc), EndLoc(EndLoc) {} SMLoc getStartLoc() const override { return StartLoc; } SMLoc getEndLoc() const override { return EndLoc; }
-
对于每种操作数类型,我们必须定义四个方法。对于寄存器,方法包括
isReg()
来检查操作数是否为寄存器,getReg()
来返回值,createReg()
来创建寄存器操作数,以及addRegOperands()
来将操作数添加到指令中。后一个函数在构建指令时由生成的源代码调用。标记和立即数的方法类似:bool isReg() const override { return Kind == OpKind_Reg; } unsigned getReg() const override { return RegNo; } static std::unique_ptr<M88kOperand> createReg(unsigned Num, SMLoc StartLoc, SMLoc EndLoc) { auto Op = std::make_unique<M88kOperand>( OpKind_Reg, StartLoc, EndLoc); Op->RegNo = Num; return Op; } void addRegOperands(MCInst &Inst, unsigned N) const { assert(N == 1 && "Invalid number of operands"); Inst.addOperand(MCOperand::createReg(getReg())); }
-
最后,超类定义了一个抽象的
print()
虚方法,我们需要实现它。这仅用于调试目的:void print(raw_ostream &OS) const override { switch (Kind) { case OpKind_Imm: OS << "Imm: " << getImm() << "\n"; break; case OpKind_Token: OS << "Token: " << getToken() << "\n"; break; case OpKind_Reg: OS << "Reg: " << M88kInstPrinter::getRegisterName(getReg()) << „\n"; break; } } };
接下来,我们声明M88kAsmParser
类。在声明之后,匿名命名空间将结束:
-
在类的开头,我们包含生成的片段:
class M88kAsmParser : public MCTargetAsmParser { #define GET_ASSEMBLER_HEADER #include "M88kGenAsmMatcher.inc"
-
接下来,我们定义所需的字段。我们需要对实际解析器的引用,它属于
MCAsmParser
类,以及一个对子目标信息的引用:MCAsmParser &Parser; const MCSubtargetInfo &SubtargetInfo;
-
为了实现汇编器,我们覆盖了
MCTargetAsmParser
超类中定义的一些方法。MatchAndEmitInstruction()
方法尝试匹配一个指令并发出由MCInst
类实例表示的指令。解析指令是在ParseInstruction()
方法中完成的,而parseRegister()
和tryParseRegister()
方法负责解析寄存器。其他方法内部需要:bool ParseInstruction(ParseInstructionInfo &Info, StringRef Name, SMLoc NameLoc, OperandVector &Operands) override; bool parseRegister(MCRegister &RegNo, SMLoc &StartLoc, SMLoc &EndLoc) override; OperandMatchResultTy tryParseRegister(MCRegister &RegNo, SMLoc &StartLoc, SMLoc &EndLoc) override; bool parseRegister(MCRegister &RegNo, SMLoc &StartLoc, SMLoc &EndLoc, bool RestoreOnFailure); bool parseOperand(OperandVector &Operands, StringRef Mnemonic); bool MatchAndEmitInstruction( SMLoc IdLoc, unsigned &Opcode, OperandVector &Operands, MCStreamer &Out, uint64_t &ErrorInfo, bool MatchingInlineAsm) override;
-
构造函数是内联定义的。它主要初始化所有字段。这完成了类的声明,之后匿名命名空间结束:
public: M88kAsmParser(const MCSubtargetInfo &STI, MCAsmParser &Parser, const MCInstrInfo &MII, const MCTargetOptions &Options) : MCTargetAsmParser(Options, STI, MII), Parser(Parser), SubtargetInfo(STI) { setAvailableFeatures(ComputeAvailableFeatures( SubtargetInfo.getFeatureBits())); } };
-
现在我们包含汇编器生成的部分:
#define GET_REGISTER_MATCHER #define GET_MATCHER_IMPLEMENTATION #include "M88kGenAsmMatcher.inc"
-
当期望指令时,会调用
ParseInstruction()
方法。它必须能够解析指令的所有语法形式。目前,我们只有接受三个操作数的指令,这些操作数由逗号分隔,因此解析很简单。请注意,在出现错误的情况下,返回值是true
!bool M88kAsmParser::ParseInstruction( ParseInstructionInfo &Info, StringRef Name, SMLoc NameLoc, OperandVector &Operands) { Operands.push_back( M88kOperand::createToken(Name, NameLoc)); if (getLexer().isNot(AsmToken::EndOfStatement)) { if (parseOperand(Operands, Name)) { return Error(getLexer().getLoc(), "expected operand"); } while (getLexer().is(AsmToken::Comma)) { Parser.Lex(); if (parseOperand(Operands, Name)) { return Error(getLexer().getLoc(), "expected operand"); } } if (getLexer().isNot(AsmToken::EndOfStatement)) return Error(getLexer().getLoc(), "unexpected token in argument list"); } Parser.Lex(); return false; }
-
操作数可以是寄存器或立即数。我们稍微泛化一下,解析一个表达式而不是仅仅一个整数。这有助于以后添加地址模式。如果解析成功,解析的操作数将被添加到
Operands
列表中:bool M88kAsmParser::parseOperand( OperandVector &Operands, StringRef Mnemonic) { if (Parser.getTok().is(AsmToken::Percent)) { MCRegister RegNo; SMLoc StartLoc, EndLoc; if (parseRegister(RegNo, StartLoc, EndLoc, /*RestoreOnFailure=*/false)) return true; Operands.push_back(M88kOperand::createReg( RegNo, StartLoc, EndLoc)); return false; } if (Parser.getTok().is(AsmToken::Integer)) { SMLoc StartLoc = Parser.getTok().getLoc(); const MCExpr *Expr; if (Parser.parseExpression(Expr)) return true; SMLoc EndLoc = Parser.getTok().getLoc(); Operands.push_back( M88kOperand::createImm(Expr, StartLoc, EndLoc)); return false; } return true; }
-
parseRegister()
方法尝试解析一个寄存器。首先,它检查是否存在百分号%
。如果其后跟一个与寄存器名称匹配的标识符,那么我们成功解析了一个寄存器,并在RegNo
参数中返回寄存器编号。然而,如果我们无法识别寄存器,那么如果RestoreOnFailure
参数为true
,我们可能需要撤销词法分析:bool M88kAsmParser::parseRegister( MCRegister &RegNo, SMLoc &StartLoc, SMLoc &EndLoc, bool RestoreOnFailure) { StartLoc = Parser.getTok().getLoc(); if (Parser.getTok().isNot(AsmToken::Percent)) return true; const AsmToken &PercentTok = Parser.getTok(); Parser.Lex(); if (Parser.getTok().isNot(AsmToken::Identifier) || (RegNo = MatchRegisterName( Parser.getTok().getIdentifier())) == 0) { if (RestoreOnFailure) Parser.getLexer().UnLex(PercentTok); return Error(StartLoc, "invalid register"); } Parser.Lex(); EndLoc = Parser.getTok().getLoc(); return false; }
-
覆盖的
parseRegister()
和tryparseRegister()
方法只是对先前定义的方法的包装。后者方法还将布尔返回值转换为OperandMatchResultTy
枚举的枚举成员:bool M88kAsmParser::parseRegister(MCRegister &RegNo, SMLoc &StartLoc, SMLoc &EndLoc) { return parseRegister(RegNo, StartLoc, EndLoc, /*RestoreOnFailure=*/false); } OperandMatchResultTy M88kAsmParser::tryParseRegister( MCRegister &RegNo, SMLoc &StartLoc, SMLoc &EndLoc) { bool Result = parseRegister(RegNo, StartLoc, EndLoc, /*RestoreOnFailure=*/true); bool PendingErrors = getParser().hasPendingError(); getParser().clearPendingErrors(); if (PendingErrors) return MatchOperand_ParseFail; if (Result) return MatchOperand_NoMatch; return MatchOperand_Success; }
-
最后,
MatchAndEmitInstruction()
方法驱动解析。该方法的大部分内容都是用于发出错误信息。为了识别指令,调用生成的MatchInstructionImpl()
方法:bool M88kAsmParser::MatchAndEmitInstruction( SMLoc IdLoc, unsigned &Opcode, OperandVector &Operands, MCStreamer &Out, uint64_t &ErrorInfo, bool MatchingInlineAsm) { MCInst Inst; SMLoc ErrorLoc; switch (MatchInstructionImpl( Operands, Inst, ErrorInfo, MatchingInlineAsm)) { case Match_Success: Out.emitInstruction(Inst, SubtargetInfo); Opcode = Inst.getOpcode(); return false; case Match_MissingFeature: return Error(IdLoc, "Instruction use requires " "option to be enabled"); case Match_MnemonicFail: return Error(IdLoc, "Unrecognized instruction mnemonic"); case Match_InvalidOperand: { ErrorLoc = IdLoc; if (ErrorInfo != ~0U) { if (ErrorInfo >= Operands.size()) return Error( IdLoc, "Too few operands for instruction"); ErrorLoc = ((M88kOperand &)*Operands[ErrorInfo]) .getStartLoc(); if (ErrorLoc == SMLoc()) ErrorLoc = IdLoc; } return Error(ErrorLoc, "Invalid operand for instruction"); } default: break; } llvm_unreachable("Unknown match type detected!"); }
-
并且像一些其他类一样,汇编器解析器有自己的工厂方法:
extern "C" LLVM_EXTERNAL_VISIBILITY void LLVMInitializeM88kAsmParser() { RegisterMCAsmParser<M88kAsmParser> X( getTheM88kTarget()); }
这完成了汇编器解析器的实现。在构建 LLVM 之后,我们可以使用 llvm-mc 机器代码游乐场工具来汇编一条指令:
$ echo 'and %r1,%r2,%r3' | \
bin/llvm-mc --triple m88k-openbsd --show-encoding
.text
and %r1, %r2, %r3 | encoding: [0xf4,0x22,0x40,0x03]
注意使用垂直线 |
作为注释符号。这是我们配置在 M88kMCAsmInfo
类中的值。
调试汇编器匹配器
要调试汇编器匹配器,你指定 --debug-only=asm-matcher
命令行选项。这有助于理解为什么解析的指令无法匹配目标描述中定义的指令。
在下一节中,我们将向 llvm-mc 工具添加反汇编器功能。
创建反汇编器
实现反汇编器是可选的。然而,实现不需要太多的努力,并且生成反汇编器表可能会捕获其他生成器未检查的编码错误。反汇编器位于 Disassembler
子目录中的 M88kDisassembler.cpp
文件中:
-
我们开始实现的过程是定义一个调试类型和
DecodeStatus
类型。这两个都是生成代码所必需的:using namespace llvm; #define DEBUG_TYPE "m88k-disassembler" using DecodeStatus = MCDisassembler::DecodeStatus;
-
M88kDisassmbler
类位于一个匿名命名空间中。我们只需要实现getInstruction()
方法: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; }; } // end anonymous namespace
-
我们还需要提供一个工厂方法,它将被注册在目标注册表中:
static MCDisassembler * createM88kDisassembler(const Target &T, const MCSubtargetInfo &STI, MCContext &Ctx) { return new M88kDisassembler(STI, Ctx); } extern "C" LLVM_EXTERNAL_VISIBILITY void LLVMInitializeM88kDisassembler() { TargetRegistry::RegisterMCDisassembler( getTheM88kTarget(), createM88kDisassembler); }
-
decodeGPRRegisterClass()
函数将寄存器编号转换为 TableGen 生成的寄存器枚举成员。这是M88kInstPrinter:: getMachineOpValue()
方法的逆操作。注意我们在M88kRegisterOperand
类的DecoderMethod
字段中指定了这个函数的名称:static const uint16_t GPRDecoderTable[] = { M88k::R0, M88k::R1, M88k::R2, M88k::R3, // … }; 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; }
-
然后我们包含生成的反汇编器表:
#include "M88kGenDisassemblerTables.inc"
-
最后,我们解码指令。为此,我们需要从
Bytes
数组的下一个四个字节开始,从这些字节中创建指令编码,并调用生成的decodeInstruction()
函数: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]; if (decodeInstruction(DecoderTableM88k32, MI, Inst, Address, this, STI) != MCDisassembler::Success) { return MCDisassembler::Fail; } return MCDisassembler::Success; }
对于反汇编器来说,以上就是需要完成的所有工作。在编译 LLVM 之后,你可以使用 llvm-mc
工具再次测试其功能:
$ echo "0xf4,0x22,0x40,0x03" | \
bin/llvm-mc --triple m88k-openbsd –disassemble
.text
and %r1, %r2, %r3
此外,我们现在可以使用 llvm-objdump
工具来反汇编 ELF 文件。然而,为了使其真正有用,我们需要将所有指令添加到目标描述中。
摘要
在本章中,你学习了如何创建一个 LLVM 目标描述,并且开发了一个简单的后端目标,该目标支持为 LLVM 指令进行汇编和反汇编。你首先收集了所需的文档,并通过增强 Triple
类使 LLVM 意识到新的架构。文档还包括 ELF 文件格式的重定位定义,并且你为 LLVM 添加了对这些定义的支持。
然后,你学习了目标描述中的寄存器定义和指令定义,并使用生成的 C++ 源代码实现了指令汇编器和反汇编器。
在下一章中,我们将向后端添加代码生成功能。
第十二章:指令选择
任何后端的核心都是指令选择。LLVM 实现了几种方法;在本章中,我们将通过选择有向无环图(DAG)和全局指令选择来实现指令选择。
在本章中,你将学习以下主题:
-
定义调用约定规则:本节展示了如何在目标描述中描述调用约定的规则
-
通过选择 DAG 进行指令选择:本节教你如何使用图数据结构实现指令选择
-
添加寄存器和指令信息:本节解释了如何访问目标描述中的信息,以及你需要提供哪些附加信息
-
实施空帧降低:本节介绍了函数的栈布局和前导部分
-
生成机器指令:本节告诉你机器指令是如何最终写入目标文件或汇编文本的
-
创建目标机器和子目标:本节展示了后端是如何配置的
-
全局指令选择:本节演示了指令选择的不同方法
-
如何进一步发展后端:本节为你提供了一些关于可能下一步的指导
到本章结束时,你将了解如何创建一个能够翻译简单指令的 LLVM 后端。你还将获得通过选择 DAG 和全局指令选择开发指令选择的知识,并且将熟悉你必须实现以使指令选择工作的重要支持类。
定义调用约定规则
实施调用约定规则是将 LLVM中间表示(IR)降低到机器代码的重要部分。基本规则可以在目标描述中定义。让我们看看。
大多数调用约定遵循一个基本模式:它们定义了一组寄存器用于参数传递。如果这个子集没有用完,下一个参数将传递到下一个空闲寄存器。如果没有空闲寄存器,则值将传递到栈上。这可以通过遍历参数并决定如何将每个参数传递给被调用函数,同时跟踪使用的寄存器来实现。在 LLVM 中,这个循环是在框架内部实现的,状态保存在一个名为CCState
的类中。此外,规则也在目标描述中定义。
规则是以条件序列的形式给出的。如果条件成立,则执行操作。根据该操作的结果,要么找到参数的位置,要么评估下一个条件。例如,32 位整数在寄存器中传递。条件是类型检查,操作是将寄存器分配给该参数。在目标描述中,这被写成如下:
CCIfType<[i32],
CCAssignToReg<[R2, R3, R4, R5, R6, R7, R8, R9]>>,
当然,如果被调用的函数有超过八个参数,那么寄存器列表将会耗尽,操作将失败。剩余的参数将通过栈传递,我们可以将此指定为下一个操作:
CCAssignToStack<4, 4>
第一个参数是栈槽的字节数大小,而第二个是对齐。由于这是一个通用的规则,没有使用条件。
实现调用约定规则
对于调用约定,还有一些需要注意的更多预定义条件和操作。例如,CCIfInReg
检查参数是否带有inreg
属性,如果函数有一个可变参数列表,则CCIfVarArg
评估为true
。CCPromoteToType
操作将参数的类型提升到更大的类型,而CCPassIndirect
操作表示参数值应该存储在栈上,并且将对该存储的指针作为普通参数传递。所有预定义的条件和操作都可以在llvm/include/llvm/Target/TargetCallingConv.td
中引用。
参数和返回值都是这样定义的。我们将定义放入M88kCallingConv.td
文件中:
-
首先,我们必须定义参数的规则。为了简化编码,我们将只考虑 32 位值:
def CC_M88k : CallingConv<[ CCIfType<[i8, i16], CCPromoteToType<i32>>, CCIfType<[i32,f32], CCAssignToReg<[R2, R3, R4, R5, R6, R7, R8, R9]>>, CCAssignToStack<4, 4> ]>;
-
之后,我们必须定义返回值的规则:
def RetCC_M88k : CallingConv<[ CCIfType<[i32], CCAssignToReg<[R2]>> ]>;
-
最后,必须定义调用者保存寄存器的序列。请注意,我们使用
sequence
运算符生成寄存器序列,而不是逐个写出:def CSR_M88k : CalleeSavedRegs<(add R1, R30, (sequence "R%d", 25, 14))>;
在目标描述中定义调用约定规则的好处是它们可以用于各种指令选择方法。接下来,我们将查看通过选择 DAG 进行指令选择。
通过选择 DAG 进行指令选择
从中间表示(IR)创建机器指令是后端中一个非常重要的任务。实现它的一个常见方法是通过使用 DAG:
-
首先,我们必须从中间表示(IR)创建一个 DAG。DAG 的一个节点代表一个操作,边表示控制和数据流依赖关系。
-
接下来,我们必须遍历 DAG 并合法化类型和操作。合法化意味着我们只使用硬件支持的类型和操作。这需要我们创建一个配置,告诉框架如何处理非法类型和操作。例如,一个 64 位值可以被拆分为两个 32 位值,两个 64 位值的乘法可以改为库调用,一个复杂的操作,如计数人口,可以扩展为一系列更简单的操作来计算这个值。
-
之后,使用模式匹配来匹配 DAG 中的节点,并用机器指令替换它们。我们在上一章中遇到了这样的模式。
-
最后,指令调度器重新排列机器指令,以获得更高效的顺序。
这只是通过选择 DAG 进行指令选择过程的高级描述。如果您想了解更多细节,可以在llvm.org/docs/CodeGenerator.html#selectiondag-instruction-selection-process
的The LLVM Target-Independent Code Generator用户指南中找到。
此外,LLVM 中的所有后端都实现了选择 DAG。主要优势是它生成高效的代码。然而,这也有代价:创建 DAG 代价高昂,并且会减慢编译速度。因此,这促使 LLVM 开发者寻找替代的、更理想的方法。一些目标通过 FastISel 实现指令选择,它仅用于非优化代码。它可以快速生成代码,但生成的代码不如选择 DAG 方法生成的代码。此外,它增加了一个全新的指令选择方法,这使测试工作翻倍。另一种用于指令选择的方法称为全局指令选择,我们将在后面的全局指令选择部分进行探讨。
在本章中,我们的目标是实现足够的后端功能,以便降低一个简单的 IR 函数,如下所示:
define i32 @f1(i32 %a, i32 %b) {
%res = and i32 %a, %b
ret i32 %res
}
此外,对于真正的后端,还需要更多的代码,我们必须指出需要添加什么以实现更多功能。
要通过选择 DAG 实现指令选择,我们需要创建两个新的类:M88kISelLowering
和M88kDAGToDAGISel
。前者类用于定制 DAG,例如,通过定义哪些类型是合法的。它还包含支持函数和函数调用的代码。后者类执行 DAG 转换,其实现主要来自目标描述。
在后端中,我们将添加几个类的实现,图 12**.1展示了我们将进一步开发的主要类之间的高级关系:
图 12.1 – 主要类之间的关系
实现 DAG 降低 – 处理合法类型和设置操作
让我们首先实现M88kISelLowering
类,该类存储在M88kISelLowering.cpp
文件中。构造函数配置合法类型和操作:
-
构造函数接受
TargetMachine
和M88kSubtarget
类的引用作为参数。TargetMachine
类负责目标的一般配置,例如,哪些传递需要运行。LLVM 后端通常针对一个 CPU 系列,而M88kSubtarget
类描述了所选 CPU 的特性。我们将在本章后面讨论这两个类:M88kTargetLowering::M88kTargetLowering( const TargetMachine &TM, const M88kSubtarget &STI) : TargetLowering(TM), Subtarget(STI) {
-
第一步是声明哪种机器值类型使用哪种寄存器类。记住,寄存器类是从目标描述生成的。在这里,我们只处理 32 位值:
addRegisterClass(MVT::i32, &M88k::GPRRegClass);
-
在添加所有寄存器类之后,我们必须计算这些寄存器类的派生属性。我们需要查询子目标以获取寄存器信息,这些信息主要来自目标描述:
computeRegisterProperties(Subtarget.getRegisterInfo());
-
接下来,我们必须声明哪个寄存器包含栈指针:
setStackPointerRegisterToSaveRestore(M88k::R31);
-
布尔值在不同的平台上表示方式不同。对于我们的目标,我们将声明布尔值存储在位
0
;其他位被清除:setBooleanContents(ZeroOrOneBooleanContent);
-
然后,我们设置函数的对齐方式。最小函数对齐是正确执行所需的对齐方式。此外,我们给出首选对齐方式:
setMinFunctionAlignment(Align(4)); setPrefFunctionAlignment(Align(4));
-
最后,我们声明哪些操作是合法的。在前一章中,我们只定义了三个逻辑指令,并且它们对于 32 位值是合法的:
setOperationAction(ISD::AND, MVT::i32, Legal); setOperationAction(ISD::OR, MVT::i32, Legal); setOperationAction(ISD::XOR, MVT::i32, Legal);
-
除了
Legal
之外,我们还可以使用其他一些操作。Promote
扩展类型,Expand
用其他操作替换操作,LibCall
将操作降低到库调用,而Custom
调用LowerOperation()
钩子方法,这允许你实现自己的自定义处理。例如,在 M88k 架构中,没有计数人口指令,因此我们请求将此操作扩展到其他操作:setOperationAction(ISD::CTPOP, MVT::i32, Expand); }
现在,让我们回顾一些要点,以强调我们迄今为止所做定义之间的联系。在M88kInstrInfo.td
文件中提到的目标描述中,我们使用and
助记符定义了一个机器指令,并且我们还为其附加了一个模式。如果我们展开AND
多类记录,并且只查看使用三个寄存器的指令,我们得到 TableGen 定义:
let isCommutable = 1 in
def ANDrr : F_LR<0b01000, Func, /*comp=*/0b0, "and",
[(set i32:$rd,
(and GPROpnd:$rs1, GPROpnd:$rs2))]>;
"and"
字符串是指令的助记符。在 C++源代码中,我们使用M88k::ANDrr
来引用这个指令。在模式中,使用 DAG and
节点类型。在 C++中,它命名为ISD::AND
,我们在调用setOperationAction()
方法时使用了它。在指令选择期间,如果模式匹配(包括输入操作数),则将and
类型的 DAG 节点替换为M88k::ANDrr
指令。因此,当我们开发指令选择时,最重要的任务是定义正确的合法化操作并将模式附加到指令定义上。
实现 DAG 降低 - 降低形式参数
让我们转向M88kISelLowering
类执行的另一个重要任务。我们在上一节中定义了调用约定的规则,但我们也需要将物理寄存器和内存位置映射到 DAG 中使用的虚拟寄存器。对于参数,这是在LowerFormalArguments()
方法中完成的;返回值在LowerReturn()
方法中处理。首先,我们必须处理参数:
-
我们首先包括生成的源代码:
#include "M88kGenCallingConv.inc"
-
LowerFormalArguments()
方法接受多个参数。SDValue
类表示与 DAG 节点关联的值,在处理 DAG 时经常使用。第一个参数Chain
代表控制流,可能的更新Chain
也是该方法的返回值。CallConv
参数标识使用的调用约定,如果参数列表中包含可变参数,则IsVarArg
设置为true
。需要处理的参数通过Ins
参数传递,同时带上它们在DL
参数中的位置。DAG
参数使我们能够访问SelectionDAG
类。最后,映射的结果将存储在InVals
向量参数中:SDValue M88kTargetLowering::LowerFormalArguments( SDValue Chain, CallingConv::ID CallConv, bool IsVarArg, const SmallVectorImpl<ISD::InputArg> &Ins, const SDLoc &DL, SelectionDAG &DAG, SmallVectorImpl<SDValue> &InVals) const {
-
我们的第一步是检索机器函数和机器寄存器信息的引用:
MachineFunction &MF = DAG.getMachineFunction(); MachineRegisterInfo &MRI = MF.getRegInfo();
-
接下来,我们必须调用生成的代码。我们需要实例化
CCState
类的一个对象。在调用AnalyzeFormalArguments()
方法时使用的CC_M88k
参数值是在目标描述中使用的调用约定名称。结果存储在ArgLocs
向量中:SmallVector<CCValAssign, 16> ArgLocs; CCState CCInfo(CallConv, IsVarArg, MF, ArgLocs, *DAG.getContext()); CCInfo.AnalyzeFormalArguments(Ins, CC_M88k);
-
一旦确定了参数的位置,我们需要将它们映射到 DAG。因此,我们必须遍历所有位置:
for (unsigned I = 0, E = ArgLocs.size(); I != E; ++I) { SDValue ArgValue; CCValAssign &VA = ArgLocs[I]; EVT LocVT = VA.getLocVT();
-
映射取决于确定的地址。首先,我们处理分配给寄存器的参数。目标是把物理寄存器复制到虚拟寄存器。要做到这一点,我们需要确定正确的寄存器类别。由于我们只处理 32 位值,这很容易做到:
if (VA.isRegLoc()) { const TargetRegisterClass *RC; switch (LocVT.getSimpleVT().SimpleTy) { default: llvm_unreachable("Unexpected argument type"); case MVT::i32: RC = &M88k::GPRRegClass; break; }
-
由于寄存器类存储在
RC
变量中,我们可以创建虚拟寄存器并复制值。我们还需要将物理寄存器声明为输入活期:Register VReg = MRI.createVirtualRegister(RC); MRI.addLiveIn(VA.getLocReg(), VReg); ArgValue = DAG.getCopyFromReg(Chain, DL, VReg, LocVT);
-
在调用约定定义中,我们添加了规则,即 8 位和 16 位值应提升为 32 位,我们需要确保这里的提升。为此,必须插入一个 DAG 节点,以确保值被提升。之后,值被截断到正确的大小。注意,我们将
ArgValue
的值作为操作数传递给 DAG 节点,并将结果存储在同一个变量中:if (VA.getLocInfo() == CCValAssign::SExt) ArgValue = DAG.getNode( ISD::AssertSext, DL, LocVT, ArgValue, DAG.getValueType(VA.getValVT())); else if (VA.getLocInfo() == CCValAssign::ZExt) ArgValue = DAG.getNode( ISD::AssertZext, DL, LocVT, ArgValue, DAG.getValueType(VA.getValVT())); if (VA.getLocInfo() != CCValAssign::Full) ArgValue = DAG.getNode(ISD::TRUNCATE, DL, VA.getValVT(), ArgValue);
-
最后,我们通过将 DAG 节点添加到结果向量中完成对寄存器参数的处理:
InVals.push_back(ArgValue); }
-
参数的另一个可能位置是在栈上。然而,我们没有定义任何加载和存储指令,所以我们还不能处理这种情况。这标志着对所有参数位置的遍历结束:
} else { llvm_unreachable("Not implemented"); } }
-
之后,我们可能需要添加代码来处理可变参数列表。同样,我们已经添加了一些代码来提醒我们尚未实现它:
assert(!IsVarArg && "Not implemented");
-
最后,我们必须返回
Chain
参数:return Chain; }
实现 DAG 降低 – 降低返回值
返回值处理方式类似。然而,我们必须扩展它们的目标描述。首先,我们需要定义一个新的 DAG 节点类型,称为RET_GLUE
。这种 DAG 节点类型用于将返回值粘合在一起,防止它们被重新排列,例如,由指令调度器。M88kInstrInfo.td
中的定义如下:
def retglue : SDNode<"M88kISD::RET_GLUE", SDTNone,
[SDNPHasChain, SDNPOptInGlue, SDNPVariadic]>;
在同一文件中,我们还定义了一个伪指令来表示函数调用的返回,它将被选择为RET_GLUE
节点:
let isReturn = 1, isTerminator = 1, isBarrier = 1,
AsmString = "RET" in
def RET : Pseudo<(outs), (ins), [(retglue)]>;
当我们生成输出时,我们将扩展这个伪指令。
在这些定义到位后,我们可以实现LowerReturn()
方法:
-
参数与
LowerFormalArguments()
相同,只是顺序略有不同: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 {
-
首先,我们称生成的代码为
RetCC_M88k
调用约定:SmallVector<CCValAssign, 16> RetLocs; CCState RetCCInfo(CallConv, IsVarArg, DAG.getMachineFunction(), RetLocs, *DAG.getContext()); RetCCInfo.AnalyzeReturn(Outs, RetCC_M88k);
-
然后,我们再次遍历位置。根据我们目前拥有的简单调用约定定义,这个循环最多执行一次。然而,如果我们添加对返回 64 位值的支持,这需要用两个寄存器返回,这将会改变:
SDValue Glue; SmallVector<SDValue, 4> RetOps(1, Chain); for (unsigned I = 0, E = RetLocs.size(); I != E; ++I) { CCValAssign &VA = RetLocs[I];
-
之后,我们将返回值复制到分配给返回值的物理寄存器中。这主要与处理参数类似,但有一个例外,即使用
Glue
变量将值粘合在一起:Register Reg = VA.getLocReg(); Chain = DAG.getCopyToReg(Chain, DL, Reg, OutVals[I], Glue); Glue = Chain.getValue(1); RetOps.push_back( DAG.getRegister(Reg, VA.getLocVT())); }
-
返回值是链和粘合的寄存器复制操作。后者仅在存在返回值时返回:
RetOps[0] = Chain; if (Glue.getNode()) RetOps.push_back(Glue);
-
最后,我们构建一个
RET_GLUE
类型的 DAG 节点,传递必要的值:return DAG.getNode(M88kISD::RET_GLUE, DL, MVT::Other, RetOps); }
恭喜!有了这些定义,指令选择的基石已经奠定。
在指令选择中实现 DAG 到 DAG 转换
还有一个关键部分缺失:我们需要定义执行目标描述中定义的 DAG 转换的 pass。类名为M88kDAGToDAGISel
,存储在M88kISelDAGToDAG.cpp
文件中。类的大部分是自动生成的,但我们仍然需要添加一些代码:
-
我们首先定义调试类型并为传递提供描述性名称:
#define DEBUG_TYPE "m88k-isel" #define PASS_NAME "M88k DAG->DAG Pattern Instruction Selection"
-
然后,我们必须在匿名命名空间内声明类。我们只重写
Select()
方法;其他代码在类的主体中生成并包含:class M88kDAGToDAGISel : public SelectionDAGISel { public: static char ID; M88kDAGToDAGISel(M88kTargetMachine &TM, CodeGenOpt::Level OptLevel) : SelectionDAGISel(ID, TM, OptLevel) {} void Select(SDNode *Node) override; #include "M88kGenDAGISel.inc" }; } // end anonymous namespace
-
然后,我们必须添加初始化传递的代码。LLVM 后端仍然使用传统的传递管理器,其设置与用于 IR 转换的传递管理器不同。使用静态成员
ID
值来识别传递。可以通过使用INITIALIZE_PASS
宏来实现传递的初始化,该宏展开为 C++代码。我们还必须添加一个工厂方法来创建传递的实例:char M88kDAGToDAGISel::ID = 0; INITIALIZE_PASS(M88kDAGToDAGISel, DEBUG_TYPE, PASS_NAME, false, false) FunctionPass * llvm::createM88kISelDag(M88kTargetMachine &TM, CodeGenOpt::Level OptLevel) { return new M88kDAGToDAGISel(TM, OptLevel); }
-
最后,我们必须实现
Select()
方法。目前,我们只调用生成的代码。然而,如果我们遇到无法表示为 DAG 模式的复杂转换,那么我们可以在调用生成的代码之前添加我们自己的代码来执行转换:void M88kDAGToDAGISel::Select(SDNode *Node) { SelectCode(Node); }
这样,我们就实现了指令选择。然而,在我们进行第一次测试之前,我们还需要添加一些支持类。我们将在接下来的几节中查看这些类。
添加寄存器和指令信息
目标描述捕获了关于寄存器和指令的大部分信息。要访问这些信息,我们必须实现M88kRegisterInfo
和M88kInstrInfo
类。这些类还包含我们可以重写的钩子,以完成在目标描述中难以表达的任务。让我们从在M88kRegisterInfo.h
文件中声明的M88kRegisterInfo
类开始:
-
头文件首先包含来自目标描述生成的代码:
#define GET_REGINFO_HEADER #include "M88kGenRegisterInfo.inc"
-
之后,我们必须在
llvm
命名空间中声明M88kRegisterInfo
类。我们只重写了几个方法:namespace llvm { struct M88kRegisterInfo : public M88kGenRegisterInfo { M88kRegisterInfo(); const MCPhysReg *getCalleeSavedRegs( const MachineFunction *MF) const override; BitVector getReservedRegs( const MachineFunction &MF) const override; bool eliminateFrameIndex( MachineBasicBlock::iterator II, int SPAdj, unsigned FIOperandNum, RegScavenger *RS = nullptr) const override; Register getFrameRegister( const MachineFunction &MF) const override; }; } // end namespace llvm
类的定义存储在M88kRegisterInfo.cpp
文件中:
-
再次,电影从包含来自目标描述生成的代码开始:
#define GET_REGINFO_TARGET_DESC #include "M88kGenRegisterInfo.inc"
-
构造函数初始化超类,将包含返回地址的寄存器作为参数传递:
M88kRegisterInfo::M88kRegisterInfo() : M88kGenRegisterInfo(M88k::R1) {}
-
然后,我们实现返回调用者保留寄存器列表的方法。我们在目标描述中定义了该列表,并且只返回该列表:
const MCPhysReg *M88kRegisterInfo::getCalleeSavedRegs( const MachineFunction *MF) const { return CSR_M88k_SaveList; }
-
然后,我们处理保留寄存器。保留寄存器取决于平台和硬件。
r0
寄存器包含常量值0
,因此我们将其视为保留寄存器。r28
和r29
寄存器始终保留供链接器使用。最后,r31
寄存器用作栈指针。此列表可能取决于函数,并且由于这种动态行为,它不能生成:BitVector M88kRegisterInfo::getReservedRegs( const MachineFunction &MF) const { BitVector Reserved(getNumRegs()); Reserved.set(M88k::R0); Reserved.set(M88k::R28); Reserved.set(M88k::R29); Reserved.set(M88k::R31); return Reserved; }
-
如果需要帧寄存器,则使用
r30
。请注意,我们的代码目前还不支持创建帧。如果函数需要帧,那么r30
也必须在getReservedRegs()
方法中标记为保留。然而,我们必须实现此方法,因为它在超类中被声明为纯虚函数:Register M88kRegisterInfo::getFrameRegister( const MachineFunction &MF) const { return M88k::R30; }
-
同样,我们需要实现
eliminateFrameIndex()
方法,因为它被声明为纯虚。它被调用以将操作数中的框架索引替换为用于在栈上寻址值的正确值:bool M88kRegisterInfo::eliminateFrameIndex( MachineBasicBlock::iterator MI, int SPAdj, unsigned FIOperandNum, RegScavenger *RS) const { return false; }
M88kInstrInfo
类有许多我们可以重写的钩子方法来完成特殊任务,例如,用于分支分析和重载。现在,我们只重写expandPostRAPseudo()
方法,在这个方法中,我们扩展伪指令 RET。让我们从头文件M88kInstrInfo.h
开始:
-
头文件以包含生成的代码开始:
#define GET_INSTRINFO_HEADER #include "M88kGenInstrInfo.inc"
-
M88kInstrInfo
类从生成的M88kGenInstrInfo
类派生。除了重写expandPostRAPseudo()
方法外,唯一的其他添加是,这个类拥有先前定义的类M88kRegisterInfo
的一个实例:namespace llvm { class M88kInstrInfo : public M88kGenInstrInfo { const M88kRegisterInfo RI; [[maybe_unused]] M88kSubtarget &STI; virtual void anchor(); public: explicit M88kInstrInfo(M88kSubtarget &STI); const M88kRegisterInfo &getRegisterInfo() const { return RI; } bool expandPostRAPseudo(MachineInstr &MI) const override; } // end namespace llvm
实现存储在M88kInstrInfo.cpp
类中:
-
与头文件一样,实现以包含生成的代码开始:
#define GET_INSTRINFO_CTOR_DTOR #define GET_INSTRMAP_INFO #include "M88kGenInstrInfo.inc"
-
然后,我们定义
anchor()
方法,它用于将 vtable 固定到这个文件:void M88kInstrInfo::anchor() {}
-
最后,我们在
expandPostRAPseudo()
方法中扩展RET
。正如其名称所暗示的,这个方法在寄存器分配器运行之后被调用,目的是扩展伪指令,该伪指令可能仍然与机器代码混合。如果机器指令的指令码MI
是伪指令RET
,我们必须插入jmp %r1
跳转指令,这是退出函数的指令。然后,我们复制所有表示要返回值的隐式操作数,并删除伪指令。如果在代码生成过程中需要其他伪指令,我们也可以扩展这个函数来在这里扩展它们:bool M88kInstrInfo::expandPostRAPseudo( MachineInstr &MI) const { MachineBasicBlock &MBB = *MI.getParent(); switch (MI.getOpcode()) { default: return false; case M88k::RET: { MachineInstrBuilder MIB = BuildMI(MBB, &MI, MI.getDebugLoc(), get(M88k::JMP)) .addReg(M88k::R1, RegState::Undef); for (auto &MO : MI.operands()) { if (MO.isImplicit()) MIB.add(MO); } break; } } MBB.erase(MI); return true; }
两个类都有最小实现。如果你继续开发目标,那么还需要重写更多方法。阅读TargetInstrInfo
和TargetRegisterInfo
基类的注释是值得的,这些基类可以在llvm/include/llvm/CodeGen
目录中找到。
我们仍然需要更多类来使指令选择运行。接下来,我们将查看框架降低。
将一个空的框架降低框架放置到位
平台的二进制接口不仅定义了参数的传递方式。它还包括如何布局栈帧:局部变量存储在哪些位置,寄存器溢出到哪里等等。通常,在函数的开始和结束需要一段特殊的指令序列,称为TargetFrameLowering
可用。简单的解决方案是为M88kFrameLowering
类提供一个空的实现。
类的声明在M88kFrameLowering.h
文件中。我们在这里必须重写纯虚函数:
namespace llvm {
class M88kFrameLowering : public TargetFrameLowering {
public:
M88kFrameLowering();
void
emitPrologue(MachineFunction &MF,
MachineBasicBlock &MBB) const override;
void
emitEpilogue(MachineFunction &MF,
MachineBasicBlock &MBB) const override;
bool hasFP(const MachineFunction &MF) const override;
};
}
存储在M88kFrameLowering.cpp
文件中的实现,在构造函数中提供了一些关于栈帧的基本细节。栈向下增长,到较小的地址,并且对齐在 8 字节边界上。当一个函数被调用时,局部变量直接存储在调用函数的栈指针下方,因此局部区域的偏移量是0
。即使在函数调用期间,栈也应该保持在 8 字节边界上对齐。最后一个参数意味着栈不能重新对齐。其他函数只是有一个空的实现:
M88kFrameLowering::M88kFrameLowering()
: TargetFrameLowering(
TargetFrameLowering::StackGrowsDown, Align(8),
0, Align(8), false /* StackRealignable */) {}
void M88kFrameLowering::emitPrologue(
MachineFunction &MF, MachineBasicBlock &MBB) const {}
void M88kFrameLowering::emitEpilogue(
MachineFunction &MF, MachineBasicBlock &MBB) const {}
bool M88kFrameLowering::hasFP(
const MachineFunction &MF) const { return false; }
当然,一旦我们的实现增长,这个类将是首先需要完全实现的第一批之一。
在我们可以将所有这些部分组合在一起之前,我们需要实现汇编打印机,它用于发出机器指令。
发出机器指令
指令选择从 LLVM IR 创建机器指令,表示为MachineInstr
类。但这并不是结束。MachineInstr
类的一个实例仍然携带额外的信息,例如标签或标志。要通过机器代码组件发出指令,我们需要将MachineInstr
实例降低到MCInst
实例。通过这样做,机器代码组件提供了将指令写入对象文件或将它们作为汇编文本打印出来的功能。M88kAsmPrinter
类负责发出整个编译单元。指令降低被委托给M88kMCInstLower
类。
汇编打印机是在后端运行的最后一个阶段。它的实现存储在M88kAsmPrinter.cpp
文件中:
-
M88kAsmPrinter
类的声明在一个匿名命名空间中。除了构造函数外,我们只重写了getPassName()
函数,它返回一个人类可读的字符串作为阶段的名称,以及emitInstruction()
函数:namespace { 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; }; } // end of anonymous namespace
-
像许多其他类一样,我们必须在我们的目标注册表中注册我们的汇编打印机:
extern "C" LLVM_EXTERNAL_VISIBILITY void LLVMInitializeM88kAsmPrinter() { RegisterAsmPrinter<M88kAsmPrinter> X( getTheM88kTarget()); }
-
emitInstruction()
方法负责将机器指令MI
发出到输出流。在我们的实现中,我们将指令降低委托给M88kMCInstLower
类:void M88kAsmPrinter::emitInstruction( const MachineInstr *MI) { MCInst LoweredMI; M88kMCInstLower Lower(MF->getContext(), *this); Lower.lower(MI, LoweredMI); EmitToStreamer(*OutStreamer, LoweredMI); }
这已经是完整的实现。基类AsmPrinter
提供了许多你可以重写的有用钩子。例如,emitStartOfAsmFile()
方法在发出任何内容之前被调用,而emitEndOfAsmFile()
方法在发出所有内容之后被调用。这些方法可以在文件的开始和结束时发出特定目标的数据或代码。同样,emitFunctionBodyStart()
和emitFunctionBodyEnd()
方法在函数体发出之前和之后被调用。阅读llvm/include/llvm/CodeGen/AsmPrinter.h
文件中的注释,以了解可以自定义的内容。
M88kMCInstLower
类降低操作数和指令,我们的实现包含两个用于此目的的方法。声明在M88kMCInstLower.h
文件中:
class LLVM_LIBRARY_VISIBILITY M88kMCInstLower {
public:
void lower(const MachineInstr *MI, MCInst &OutMI) const;
MCOperand lowerOperand(const MachineOperand &MO) const;
};
定义被放入到 M88kMCInstLower.cpp
文件中:
-
为了将
MachineOperand
降低到MCOperand
,我们需要检查操作数类型。在这里,我们只通过创建与MCOperand
等效的寄存器和立即数值来处理寄存器和立即数,这些值由原始的MachineOperand
值提供。一旦表达式作为操作数引入,这种方法就需要增强:MCOperand M88kMCInstLower::lowerOperand( const MachineOperand &MO) const { switch (MO.getType()) { case MachineOperand::MO_Register: return MCOperand::createReg(MO.getReg()); case MachineOperand::MO_Immediate: return MCOperand::createImm(MO.getImm()); default: llvm_unreachable("Operand type not handled"); } }
-
指令的降低过程类似。首先,将操作码复制,然后处理操作数。
MachineInstr
实例可以附加隐式操作数,这些操作数不会被降低,我们需要过滤它们:void M88kMCInstLower::lower(const MachineInstr *MI, MCInst &OutMI) const { OutMI.setOpcode(MI->getOpcode()); for (auto &MO : MI->operands()) { if (!MO.isReg() || !MO.isImplicit()) OutMI.addOperand(lowerOperand(MO)); } }
这样,我们就实现了汇编打印机。现在,我们需要将所有这些部分组合在一起。我们将在下一节中这样做。
创建目标机器和子目标
到目前为止,我们已经实现了指令选择类和一些其他类。现在,我们需要设置我们的后端如何工作。就像优化管道一样,后端被分为多个阶段。配置这些阶段是 M88kTargetMachine
类的主要任务。此外,我们还需要指定哪些功能可用于指令选择。通常,一个平台是一系列 CPU,它们都有一个共同的指令集,但具有特定的扩展不同。例如,一些 CPU 有向量指令,而另一些则没有。在 LLVM IR 中,一个函数可以附加属性来指定该函数应该为哪个 CPU 编译,或者哪些功能可用。换句话说,每个函数可能有一个不同的配置,这被 M88kSubTarget
类捕获。
实现 M88kSubtarget
让我们先实现 M88kSubtarget
类。声明存储在 M88kSubtarget.h
类中:
-
子目标的某些部分是从目标描述中生成的,我们首先包含这些代码:
#define GET_SUBTARGETINFO_HEADER #include "M88kGenSubtargetInfo.inc"
-
然后,我们声明该类,从生成的
M88kGenSubtargetInfo
类派生。该类拥有几个先前定义的类——指令信息、目标降低类和帧降低类:namespace llvm { class StringRef; class TargetMachine; class M88kSubtarget : public M88kGenSubtargetInfo { virtual void anchor(); Triple TargetTriple; M88kInstrInfo InstrInfo; M88kTargetLowering TLInfo; M88kFrameLowering FrameLowering;
-
子目标使用目标三元组、CPU 名称、特征字符串以及目标机器进行初始化,所有这些参数描述了我们的后端将为其生成代码的硬件:
public: M88kSubtarget(const Triple &TT, const std::string &CPU, const std::string &FS, const TargetMachine &TM);
-
接下来,我们再次包含生成的文件,这次是为了自动定义在目标描述中定义的功能的获取方法:
#define GET_SUBTARGETINFO_MACRO(ATTRIBUTE, DEFAULT, \ GETTER) \ bool GETTER() const { return ATTRIBUTE; } #include "M88kGenSubtargetInfo.inc"
-
此外,我们需要声明
ParseSubtargetFeatures()
方法。该方法本身是从目标描述中生成的:void ParseSubtargetFeatures(StringRef CPU, StringRef TuneCPU, StringRef FS);
-
接下来,我们必须为成员变量添加获取方法:
const TargetFrameLowering * getFrameLowering() const override { return &FrameLowering; } const M88kInstrInfo *getInstrInfo() const override { return &InstrInfo; } const M88kTargetLowering * getTargetLowering() const override { return &TLInfo; }
-
最后,我们必须为属于指令信息类的寄存器信息添加一个获取方法,这完成了声明:
const M88kRegisterInfo * getRegisterInfo() const override { return &InstrInfo.getRegisterInfo(); } }; } // end namespace llvm
接下来,我们必须实现实际的子目标类。实现存储在 M88kSubtarget.cpp
文件中:
-
再次,我们首先通过包含生成的源文件来开始文件:
#define GET_SUBTARGETINFO_TARGET_DESC #define GET_SUBTARGETINFO_CTOR #include "M88kGenSubtargetInfo.inc"
-
然后,我们定义锚定方法,它将 vtable 固定到这个文件:
void M88kSubtarget::anchor() {}
-
最后,我们定义构造函数。请注意,生成的类期望两个 CPU 参数:第一个用于指令集,第二个用于调度。这里的用例是,你想要优化最新 CPU 的代码,但仍然能够在旧 CPU 上运行代码。我们不支持此功能,并为两个参数使用相同的 CPU 名称:
M88kSubtarget::M88kSubtarget(const Triple &TT, const std::string &CPU, const std::string &FS, const TargetMachine &TM) : M88kGenSubtargetInfo(TT, CPU, /*TuneCPU*/ CPU, FS), TargetTriple(TT), InstrInfo(*this), TLInfo(TM, *this), FrameLowering() {}
实现 M88kTargetMachine – 定义定义
最后,我们可以实现M88kTargetMachine
类。这个类持有所有使用的子目标实例。它还拥有一个TargetLoweringObjectFile
的子类,为降低过程提供如段名称等详细信息。最后,它创建在这个后端运行的传递配置。
M88kTargetMachine.h
文件中的声明如下:
-
M88kTargetMachine
类从LLVMTargetMachine
类派生。唯一的成员是一个TargetLoweringObjectFile
实例和子目标映射:namespace llvm { class M88kTargetMachine : public LLVMTargetMachine { std::unique_ptr<TargetLoweringObjectFile> TLOF; mutable StringMap<std::unique_ptr<M88kSubtarget>> SubtargetMap;
-
构造函数的参数完全描述了我们将为其生成代码的目标配置。使用
TargetOptions
类,可以控制代码生成的许多细节 – 例如,是否可以使用浮点乘加指令。此外,重定位模型、代码模型和优化级别都传递给构造函数。值得注意的是,如果目标机器用于即时编译,则JIT
参数设置为 true。public: M88kTargetMachine(const Target &T, const Triple &TT, StringRef CPU, StringRef FS, const TargetOptions &Options, std::optional<Reloc::Model> RM, std::optional<CodeModel::Model> CM, CodeGenOpt::Level OL, bool JIT);
-
我们还需要重写一些方法。
getSubtargetImpl()
方法返回用于给定函数的子目标实例,而getObjFileLowering()
方法仅返回成员变量。此外,我们重写createPassConfig()
方法,它返回后端传递的配置:~M88kTargetMachine() override; const M88kSubtarget * getSubtargetImpl(const Function &) const override; TargetPassConfig * createPassConfig(PassManagerBase &PM) override; TargetLoweringObjectFile * getObjFileLowering() const override { return TLOF.get(); } }; } // end namespace llvm
实现 M88kTargetMachine – 添加实现
类的实现存储在M88kTargetMachine.cpp
文件中。请注意,我们在第十一章中创建了此文件。现在,我们将用完整的实现替换此文件:
-
首先,我们必须注册目标机器。此外,我们必须通过我们之前定义的初始化函数初始化 DAG-to-DAG 传递:
extern "C" LLVM_EXTERNAL_VISIBILITY void LLVMInitializeM88kTarget() { RegisterTargetMachine<M88kTargetMachine> X( getTheM88kTarget()); auto &PR = *PassRegistry::getPassRegistry(); initializeM88kDAGToDAGISelPass(PR); }
-
接下来,我们必须定义支持函数,
computeDataLayout()
。我们曾在第四章中讨论了数据布局字符串,IR 代码生成基础。在这个函数中,数据布局作为后端,期望它被定义。由于数据布局依赖于硬件特性,因此将三元组、CPU 名称和特性集字符串传递给此函数。我们使用以下组件创建数据布局字符串。目标是大端(E
)并使用ELF
符号混淆。指针宽度为 32 位,且 32 位对齐。所有标量类型都是自然对齐的。
MC88110
CPU 有一个扩展的寄存器集,并支持 80 位宽的浮点数。如果我们支持这个特殊特性,那么我们需要在这里添加对 CPU 名称的检查,并相应地扩展字符串以包含浮点值。接下来,我们必须声明所有全局变量都有一个首选的 16 位对齐,并且硬件只有 32 位寄存器:namespace { std::string computeDataLayout(const Triple &TT, StringRef CPU, StringRef FS) { std::string Ret; Ret += "E"; Ret += DataLayout::getManglingComponent(TT); Ret += "-p:32:32:32"; Ret += "-i1:8:8-i8:8:8-i16:16:16-i32:32:32-i64:64:64"; Ret += "-f32:32:32-f64:64:64"; Ret += "-a:8:16"; Ret += "-n32"; return Ret; } } // namespace
-
现在,我们可以定义构造函数和析构函数。许多参数只是传递给超类构造函数。请注意,我们的
computeDataLayout()
函数在这里被调用。此外,TLOF
成员使用TargetLoweringObjectFileELF
的实例初始化,因为我们使用的是 ELF 文件格式。在构造函数的主体中,我们必须调用initAsmInfo()
方法,该方法初始化超类中的许多数据成员:M88kTargetMachine::M88kTargetMachine( const Target &T, const Triple &TT, StringRef CPU, StringRef FS, const TargetOptions &Options, std::optional<Reloc::Model> RM, std::optional<CodeModel::Model> CM, CodeGenOpt::Level OL, bool JIT) : LLVMTargetMachine( T, computeDataLayout(TT, CPU, FS), TT, CPU, FS, Options, !RM ? Reloc::Static : *RM, getEffectiveCodeModel(CM, CodeModel::Medium), OL), TLOF(std::make_unique< TargetLoweringObjectFileELF>()) { initAsmInfo(); } M88kTargetMachine::~M88kTargetMachine() {}
-
之后,我们定义了
getSubtargetImpl()
方法。要使用的子目标实例取决于target-cpu
和target-features
函数属性。例如,target-cpu
属性可以设置为MC88110
,从而针对第二代 CPU。然而,目标特性属性可能描述我们不应该使用该 CPU 的图形指令。我们尚未在目标描述中定义 CPU 及其特性,因此我们在这里做了比必要的更多的工作。然而,实现足够简单:我们查询函数属性,并使用返回的字符串或默认值。有了这些信息,我们可以查询SubtargetMap
成员,如果找不到,我们创建子目标:const M88kSubtarget * M88kTargetMachine::getSubtargetImpl( const Function &F) const { Attribute CPUAttr = F.getFnAttribute("target-cpu"); Attribute FSAttr = F.getFnAttribute("target-features"); std::string CPU = !CPUAttr.hasAttribute(Attribute::None) ? CPUAttr.getValueAsString().str() : TargetCPU; std::string FS = !FSAttr.hasAttribute(Attribute::None) ? FSAttr.getValueAsString().str() : TargetFS; auto &I = SubtargetMap[CPU + FS]; if (!I) { resetTargetOptions(F); I = std::make_unique<M88kSubtarget>(TargetTriple, CPU, FS, *this); } return I.get(); }
-
最后,我们创建传递配置。为此,我们需要我们自己的类,
M88kPassConfig
,它从TargetPassConfig
类派生。我们只重写了addInstSelector
方法:namespace { class M88kPassConfig : public TargetPassConfig { public: M88kPassConfig(M88kTargetMachine &TM, PassManagerBase &PM) : TargetPassConfig(TM, PM) {} bool addInstSelector() override; }; } // namespace
-
通过这个定义,我们可以实现
createPassConfig
工厂方法:TargetPassConfig *M88kTargetMachine::createPassConfig( PassManagerBase &PM) { return new M88kPassConfig(*this, PM); }
-
最后,我们必须在
addInstSelector()
方法中将我们的指令选择类添加到传递管道中。返回值false
表示我们已经添加了一个将 LLVM IR 转换为机器指令的传递:bool M88kPassConfig::addInstSelector() { addPass(createM88kISelDag(getTM<M88kTargetMachine>(), getOptLevel())); return false; }
完成实现是一个漫长的旅程!现在我们已经构建了llc
工具,我们可以运行一个示例。将以下简单的 IR 保存到and.ll
文件中:
define i32 @f1(i32 %a, i32 %b) {
%res = and i32 %a, %b
ret i32 %res
}
现在,我们可以运行llc
并验证生成的汇编代码是否合理:
$ llc -mtriple m88k-openbsd < and.ll
.text
.file "<stdin>"
.globl f1 | -- Begin function f1
.align 2
.type f1,@function
f1: | @f1
| %bb.0:
and %r2, %r2, %r3
jmp %r1
.Lfunc_end0:
.size f1, .Lfunc_end0-f1
| -- End function
.section ".note.GNU-stack","",@progbits
要为m88k
目标编译,我们必须在命令行上指定三元组,就像这个例子中一样,或者在 IR 文件中。
在我们查看全局指令选择之前,先享受一下成功的喜悦。
全局指令选择
通过选择 DAG 进行指令选择可以生成快速代码,但这需要时间。编译器的速度对于开发者来说往往至关重要,他们希望快速尝试他们所做的更改。通常,编译器在优化级别0
时应该非常快,但随着优化级别的提高,它可能需要更多的时间。然而,构建选择 DAG 需要花费大量时间,因此这种方法无法按需扩展。第一个解决方案是创建另一个名为FastISel
的指令选择算法,它速度快但生成的代码质量不佳。它也没有与选择 DAG 实现共享代码,这是一个明显的问题。正因为如此,并非所有目标都支持FastISel
。
选择 DAG 方法无法扩展,因为它是一个庞大且单一化的算法。如果我们能避免创建像选择 DAG 这样的新数据结构,那么我们应该能够使用小型组件执行指令选择。后端已经有一个传递管道,因此使用传递是一个自然的选择。基于这些想法,GlobalISel 执行以下步骤:
-
首先,将 LLVM IR 降低到通用机器指令。通用机器指令代表了在真实硬件中最常见的操作。请注意,这种转换使用机器函数和机器基本块,这意味着它直接转换为后端其他部分使用的数据结构。
-
然后,将通用机器指令合法化。
-
之后,将通用机器指令的操作数映射到寄存器银行。
-
最后,使用目标描述中定义的模式将通用指令替换为真实机器指令。
由于这些都是传递,我们可以在其中插入任意多的传递。例如,一个组合传递可以用来将一系列通用机器指令替换为另一个通用机器指令,或者是一个真实机器指令。关闭这些额外的传递可以提高编译速度,而开启它们则能提升生成代码的质量。因此,我们可以根据需要调整规模。
这种方法还有另一个优点。选择 DAG 按基本块逐个翻译,但机器传递是在机器函数上工作的,这使得我们能够在指令选择时考虑函数的所有基本块。因此,这种指令选择方法被称为全局指令选择(GlobalISel)。让我们看看这种方法是如何工作的,从调用转换开始。
降低参数和返回值
对于将 LLVM IR 转换为通用机器指令,我们只需要实现如何处理参数和返回值。同样,可以通过使用目标描述生成的代码来简化实现。我们将创建的类称为M88kCallLowering
,其声明在GISel/M88kCallLowering.h
头文件中:
class M88kCallLowering : public CallLowering {
public:
M88kCallLowering(const M88kTargetLowering &TLI);
bool
lowerReturn(MachineIRBuilder &MIRBuilder,
const Value *Val,
ArrayRef<Register> VRegs,
FunctionLoweringInfo &FLI,
Register SwiftErrorVReg) const override;
bool lowerFormalArguments(
MachineIRBuilder &MIRBuilder, const Function &F,
ArrayRef<ArrayRef<Register>> VRegs,
FunctionLoweringInfo &FLI) const override;
bool enableBigEndian() const override { return true; }
};
当函数被转换时,GlobalISel 框架将调用 lowerReturn()
和 lowerFormalArguments()
方法。为了转换函数调用,你还需要覆盖并实现 lowerCall()
方法。请注意,我们还需要覆盖 enableBigEndian()
。如果没有它,将会生成错误的机器代码。
对于 GISel/M88kCallLowering.cpp
文件中的实现,我们需要定义支持类。从目标描述生成的代码告诉我们参数是如何传递的——例如,在寄存器中。我们需要创建一个 ValueHandler
的子类来生成它的机器指令。对于传入的参数,我们需要从 IncomingValueHandler
派生我们的类,以及对于返回值从 OutgoingValueHandler
派生。这两个都非常相似,所以我们只看传入参数的处理器:
namespace {
struct FormalArgHandler
: public CallLowering::IncomingValueHandler {
FormalArgHandler(MachineIRBuilder &MIRBuilder,
MachineRegisterInfo &MRI)
: CallLowering::IncomingValueHandler(MIRBuilder,
MRI) {}
void assignValueToReg(Register ValVReg,
Register PhysReg,
CCValAssign VA) override;
void assignValueToAddress(Register ValVReg,
Register Addr, LLT MemTy,
MachinePointerInfo &MPO,
CCValAssign &VA) override{};
Register
getStackAddress(uint64_t Size, int64_t Offset,
MachinePointerInfo &MPO,
ISD::ArgFlagsTy Flags) override {
return Register();
};
};
} // namespace
到目前为止,我们只能处理通过寄存器传递的参数,因此我们必须为其他方法提供虚拟实现。assignValueToReg()
方法将传入的物理寄存器的值复制到虚拟寄存器,如果需要则进行截断。我们在这里要做的只是将物理寄存器标记为函数的 live-in,并调用超类实现:
void FormalArgHandler::assignValueToReg(
Register ValVReg, Register PhysReg,
CCValAssign VA) {
MIRBuilder.getMRI()->addLiveIn(PhysReg);
MIRBuilder.getMBB().addLiveIn(PhysReg);
CallLowering::IncomingValueHandler::assignValueToReg(
ValVReg, PhysReg, VA);
}
现在,我们可以实现 lowerFormalArgument()
方法:
-
首先,
IR
函数的参数被转换成ArgInfo
类的实例。setArgFlags()
和splitToValueTypes()
框架方法帮助复制参数属性,并在传入的参数需要多个虚拟寄存器时分割值类型:bool M88kCallLowering::lowerFormalArguments( MachineIRBuilder &MIRBuilder, const Function &F, ArrayRef<ArrayRef<Register>> VRegs, FunctionLoweringInfo &FLI) const { MachineFunction &MF = MIRBuilder.getMF(); MachineRegisterInfo &MRI = MF.getRegInfo(); const auto &DL = F.getParent()->getDataLayout(); SmallVector<ArgInfo, 8> SplitArgs; for (const auto &[I, Arg] : llvm::enumerate(F.args())) { ArgInfo OrigArg{VRegs[I], Arg.getType(), static_cast<unsigned>(I)}; setArgFlags(OrigArg, I + AttributeList::FirstArgIndex, DL, F); splitToValueTypes(OrigArg, SplitArgs, DL, F.getCallingConv()); }
-
在
SplitArgs
变量中准备好参数后,我们就准备好生成机器代码了。这一切都是通过框架代码完成的,在生成的调用约定CC_M88k
和我们的辅助类FormalArghandler
的帮助下:IncomingValueAssigner ArgAssigner(CC_M88k); FormalArgHandler ArgHandler(MIRBuilder, MRI); return determineAndHandleAssignments( ArgHandler, ArgAssigner, SplitArgs, MIRBuilder, F.getCallingConv(), F.isVarArg()); }
返回值以类似的方式处理,主要区别是最多返回一个值。下一个任务是合法化通用机器指令。
将通用机器指令合法化
从 LLVM IR 到通用机器代码的转换主要是固定的。因此,可以生成使用不支持的数据类型的指令,以及其他挑战。合法化传递的任务是定义哪些操作和指令是合法的。有了这些信息,GlobalISel 框架试图将指令转换成合法形式。例如,m88k 架构只有 32 位寄存器,所以对 64 位值的位运算 and
是不合法的。然而,如果我们把 64 位值分成两个 32 位值,并使用两个位运算 and
代替,那么我们就有了合法的代码。这可以转换成一个合法化规则:
getActionDefinitionsBuilder({G_AND, G_OR, G_XOR})
.legalFor({S32})
.clampScalar(0, S32, S32);
当合法化传递处理G_AND
指令时,如果所有操作数都是 32 位宽,则该指令是合法的。否则,操作数会被限制为 32 位宽,实际上是将更大的值分割成多个 32 位值,然后再次应用该规则。如果指令无法合法化,后端将终止并显示错误信息。
所有合法化规则都在M88kLegalizerInfo
类的构造函数中定义,这使得该类非常简单。
“合法”是什么意思?
在 GlobalISel 中,如果一条通用指令可以被指令选择器翻译,则该指令是合法的。这给了我们在实现上更多的自由度。例如,我们可以声明一条指令作用于位值,即使硬件只操作 32 位值,只要指令选择器可以正确处理该类型即可。
我们接下来需要查看的是寄存器组选择器。
为操作数选择寄存器组
许多架构定义了多个寄存器组。寄存器组是一组寄存器。典型的寄存器组包括通用寄存器组和浮点寄存器组。为什么这个信息很重要?在寄存器组内部从一个寄存器移动到另一个寄存器的值通常成本很低,但将值复制到另一个寄存器组可能会很昂贵或不可能。因此,我们必须为每个操作数选择一个好的寄存器组。
这个类的实现涉及到对目标描述的补充。在GISel/M88lRegisterbanks.td
文件中,我们定义了我们唯一的寄存器组,引用了我们定义的寄存器类:
def GRRegBank : RegisterBank<"GRRB", [GPR, GPR64]>;
从这一行开始,生成了一些支持代码。然而,我们仍然需要添加一些可能生成的代码。首先,我们需要定义部分映射。这告诉框架值从哪个位索引开始,它的宽度是多少,以及它映射到哪个寄存器组。我们有两条条目,每个寄存器类一个:
RegisterBankInfo::PartialMapping
M88kGenRegisterBankInfo::PartMappings[]{
{0, 32, M88k::GRRegBank},
{0, 64, M88k::GRRegBank},
};
要索引这个数组,我们必须定义一个枚举:
enum PartialMappingIdx { PMI_GR32 = 0, PMI_GR64, };
由于我们只有三个地址指令,我们需要三个部分映射,每个操作数一个。我们必须创建一个包含所有这些指针的数组,第一个条目表示一个无效映射:
RegisterBankInfo::ValueMapping
M88kGenRegisterBankInfo::ValMappings[]{
{nullptr, 0},
{&M88kGenRegisterBankInfo::PartMappings[PMI_GR32], 1},
{&M88kGenRegisterBankInfo::PartMappings[PMI_GR32], 1},
{&M88kGenRegisterBankInfo::PartMappings[PMI_GR32], 1},
{&M88kGenRegisterBankInfo::PartMappings[PMI_GR64], 1},
{&M88kGenRegisterBankInfo::PartMappings[PMI_GR64], 1},
{&M88kGenRegisterBankInfo::PartMappings[PMI_GR64], 1},
};
要访问该数组,我们必须定义一个函数:
const RegisterBankInfo::ValueMapping *
M88kGenRegisterBankInfo::getValueMapping(
PartialMappingIdx RBIdx) {
return &ValMappings[1 + 3*RBIdx];
}
在创建这些表格时,很容易出错。乍一看,所有这些信息都可以从目标描述中推导出来,源代码中的注释指出,这段代码应由 TableGen 生成!然而,这尚未实现,因此我们必须手动创建代码。
我们在M88kRegisterBankInfo
类中必须实现的最重要函数是getInstrMapping()
,它返回指令每个操作数的映射寄存器组。现在这变得简单,因为我们可以查找部分映射数组,然后将其传递给getInstructionMapping()
方法,该方法构建完整的指令映射:
const RegisterBankInfo::InstructionMapping &
M88kRegisterBankInfo::getInstrMapping(
const MachineInstr &MI) const {
const ValueMapping *OperandsMapping = nullptr;
switch (MI.getOpcode()) {
case TargetOpcode::G_AND:
case TargetOpcode::G_OR:
case TargetOpcode::G_XOR:
OperandsMapping = getValueMapping(PMI_GR32);
break;
default:
#if !defined(NDEBUG) || defined(LLVM_ENABLE_DUMP)
MI.dump();
#endif
return getInvalidInstructionMapping();
}
return getInstructionMapping(DefaultMappingID, /*Cost=*/1,
OperandsMapping,
MI.getNumOperands());
}
在开发过程中,忘记通用指令的寄存器组映射是很常见的。不幸的是,在运行时生成的错误信息并没有提到映射失败的指令是哪一个。简单的修复方法是返回无效映射之前先转储指令。然而,我们需要在这里小心,因为dump()
方法并不适用于所有构建类型。
在映射寄存器组之后,我们必须将通用机器指令翻译成实际的机器指令。
翻译通用机器指令
对于通过选择 DAG 进行指令选择,我们在目标描述中添加了模式,这些模式使用 DAG 操作和操作数。为了重用这些模式,引入了从 DAG 节点类型到通用机器指令的映射。例如,DAG 的and
操作映射到通用的G_AND
机器指令。并非所有 DAG 操作都有等效的通用机器指令;然而,最常见的情况都得到了覆盖。因此,在目标描述中定义所有代码选择模式是有益的。
M88kInstructionSelector
类的实现大部分,该类可以在GISel/M88kInstructionSelector.cpp
文件中找到,是从目标描述生成的。然而,我们需要重写select()
方法,这允许我们将目标描述中模式未覆盖的通用机器指令进行翻译。由于我们只支持非常小的通用指令子集,我们可以简单地调用生成的模式匹配器:
bool M88kInstructionSelector::select(MachineInstr &I) {
if (selectImpl(I, *CoverageInfo))
return true;
return false;
}
指令选择实现后,我们可以使用 GlobalISel 来翻译 LLVM IR!
运行示例
要使用 GlobalISel 翻译 LLVM IR,我们需要在llc
的命令行中添加-global-isel
选项。例如,你可以使用之前定义的 IR 文件and.ll
:
$ llc -mtriple m88k-openbsd -global-isel < and.ll
打印的汇编文本是相同的。为了让我们确信翻译使用了 GlobalISel,我们必须利用这样一个事实:我们可以使用-stop-after=
选项在指定 pass 运行后停止翻译。例如,要查看合法化后的通用指令,你会运行以下命令:
$ llc -mtriple m88k-openbsd -global-isel < and.ll \
-stop-after=legalizer
在运行一个 pass 之后(或之前)停止的能力是 GlobalISel 的另一个优点,因为它使得调试和测试实现变得容易。
到目前为止,我们已经有一个可以翻译一些 LLVM IR 到 m88k 架构机器代码的工作后端。让我们思考如何从这里过渡到一个更完整的后端。
如何进一步发展后端
通过本章和上一章的代码,我们已经创建了一个可以将一些 LLVM IR 翻译成机器代码的后端。看到后端工作是非常令人满意的,但它离用于严肃任务还远。还需要更多的编码。以下是如何进一步发展后端的步骤:
-
你应该做的第一个决定是是否要使用 GlobalISel 或选择 DAG。根据我们的经验,GlobalISel 更容易理解和开发,但 LLVM 源树中的所有目标都实现了选择 DAG,你可能已经对它有了一些使用经验。
-
接下来,你应该定义添加和减去整数值的指令,这可以与位运算
and
指令类似地完成。 -
之后,你应该实现加载和存储指令。这更为复杂,因为你需要翻译不同的寻址模式。很可能会遇到索引,例如,为了访问数组的一个元素,这很可能需要之前定义的加法指令。
-
最后,你可以完全实现帧降低和调用降低。在这个阶段,你可以将一个简单的“Hello, world!”风格的应用程序翻译成可运行的程序。
-
下一个逻辑步骤是实现分支指令,这可以使循环的翻译成为可能。为了生成最优代码,你需要在指令信息类中实现分支分析方法。
当你达到这个阶段时,你的后端已经可以翻译简单的算法。你也应该积累了足够多的经验,可以根据你的优先级开发缺失的部分。
摘要
在本章中,你向你的后端添加了两种不同的指令选择:通过选择 DAG 进行指令选择和全局指令选择。为此,你需要在目标描述中定义调用约定。此外,你需要实现寄存器和指令信息类,这些类让你能够访问从目标描述生成的信息,但你还需要用额外的信息来增强它们。你了解到栈帧布局和前导代码生成在之后是必需的。为了翻译一个示例,你添加了一个用于发出机器指令的类,并创建了后端的配置。你还学习了全局指令选择的工作原理。最后,你获得了一些关于如何独立开发后端的指导。
在下一章中,我们将探讨在指令选择之后可以执行的一些任务——我们将在后端管道中添加一个新的传递,查看如何将后端集成到 clang 编译器中,以及如何交叉编译到不同的架构。
第十三章:超出指令选择
现在我们已经在前几章学习了使用 SelectionDAG 和 GlobalISel 基于 LLVM 的框架进行指令选择,我们可以探索指令选择之外的其它有趣概念。本章包含了一些对于高度优化编译器来说可能很有趣的后端之外的高级主题。例如,一些遍历操作会超出指令选择,并且可以对各种指令执行不同的优化,这意味着开发者有足够的自由在这个编译器的这个阶段引入他们自己的遍历操作来执行有意义的特定目标任务。
最终,在本章中,我们将深入研究以下概念:
-
将新的机器函数遍历操作添加到 LLVM 中
-
将新的目标集成到 clang 前端
-
如何针对不同的 CPU 架构
将新的机器函数遍历操作添加到 LLVM 中
在本节中,我们将探讨如何在 LLVM 中实现一个新的机器函数遍历操作,该操作在指令选择之后运行。具体来说,将创建一个 MachineFunctionPass
类,它是 LLVM 中原始 FunctionPass
类的一个子集,可以通过 opt
运行。这个类通过 llc
适配原始基础设施,允许实现操作在 MachineFunction
表示形式上运行的遍历操作。
需要注意的是,后端中遍历的实现使用了旧遍历管理器的接口,而不是新遍历管理器。这是因为 LLVM 目前在后端没有完整的可工作的新遍历管理器实现。因此,本章将遵循在旧遍历管理器管道中添加新遍历的方法。
在实际实现方面,例如函数遍历操作,机器函数遍历操作一次优化一个(机器)函数,但不是覆盖 runOnFunction()
方法,而是覆盖 runOnMachineFunction()
方法。本节将要实现的机器函数遍历操作是一个检查除零发生的遍历操作,具体来说,是在后端中插入陷阱代码。这种类型的遍历操作对于 M88k 目标很重要,因为 MC88100 硬件在检测除零情况上存在限制。
从上一章的后端实现继续,让我们看看后端机器函数遍历操作是如何实现的!
实现 M88k 目标的顶层接口
首先,在 llvm/lib/Target/M88k/M88k.h
中,让我们在 llvm
命名空间声明内添加两个原型,这些原型将在以后使用:
-
将要实现的机器函数遍历操作将被命名为
M88kDivInstrPass
。我们将添加一个函数声明来初始化这个遍历操作,并接收遍历注册表,这是一个管理所有遍历注册和初始化的类:void initializeM88kDivInstrPass(PassRegistry &);
-
接下来,声明实际创建
M88kDivInstr
遍历的函数,其参数为 M88k 目标机信息:FunctionPass *createM88kDivInstr(const M88kTargetMachine &);
添加机器函数遍历的 TargetMachine 实现
接下来,我们将分析在 llvm/lib/Target/M88k/M88kTargetMachine.cpp
中需要进行的某些更改:
-
在 LLVM 中,通常会给用户提供切换遍历开/关的选项。因此,让我们为我们的机器函数遍历提供相同的灵活性。我们首先声明一个名为
m88k-no-check-zero-division
的命令行选项,并将其初始化为false
,这意味着除非用户明确将其关闭,否则总会进行零除检查。我们将在llvm
命名空间声明下添加此选项,并且它是llc
的一个选项:using namespace llvm; static cl::opt<bool> NoZeroDivCheck("m88k-no-check-zero-division", cl::Hidden, cl::desc("M88k: Don't trap on integer division by zero."), cl::init(false));
-
还有一个惯例是创建一个正式的方法来返回命令行值,这样我们就可以查询它以确定是否运行遍历。我们的原始命令行选项将被
noZeroDivCheck()
方法包装起来,这样我们就可以在以后利用命令行结果:M88kTargetMachine::~M88kTargetMachine() {} bool M88kTargetMachine::noZeroDivCheck() const { return NoZeroDivCheck; }
-
接下来,在
LLVMInitializeM88kTarget()
中,我们将注册和初始化 M88k 目标和遍历的地方,插入对之前在llvm/lib/Target/M88k/M88k.h
中声明的initializeM88kDivInstrPass()
方法的调用:extern "C" LLVM_EXTERNAL_VISIBILITY void LLVMInitializeM88kTarget() { RegisterTargetMachine<M88kTargetMachine> X(getTheM88kTarget()); auto &PR = *PassRegistry::getPassRegistry(); initializeM88kDAGToDAGISelPass(PR); initializeM88kDivInstrPass(PR); }
-
M88k 目标还需要重写
addMachineSSAOptimization()
方法,这是一个在指令处于 SSA 形式时添加优化指令的遍历的方法。本质上,我们的机器函数遍历被添加为一种机器 SSA 优化。该方法被声明为一个需要重写的方法。我们将在M88kTargetMachine.cpp
的末尾添加完整的实现:bool addInstSelector() override; void addPreEmitPass() override; void addMachineSSAOptimization() override; . . . void M88kPassConfig::addMachineSSAOptimization() { addPass(createM88kDivInstr(getTM<M88kTargetMachine>())); TargetPassConfig::addMachineSSAOptimization(); }
-
我们返回用于切换机器函数遍历开/关的命令行选项的方法(
noZeroDivCheck()
方法)也声明在M88kTargetMachine.h
中:~M88kTargetMachine() override; bool noZeroDivCheck() const;
开发机器函数遍历的细节
现在,M88k 目标机的实现已经完成,下一步将是开发机器函数遍历本身。实现包含在新文件 llvm/lib/Target/M88k/M88kDivInstr.cpp
中:
-
为我们的机器函数遍历添加必要的头文件。这包括提供访问 M88k 目标信息的头文件,以及允许我们对机器函数和机器指令进行操作的头文件:
#include "M88k.h" #include "M88kInstrInfo.h" #include "M88kTargetMachine.h" #include "MCTargetDesc/M88kMCTargetDesc.h" #include "llvm/ADT/Statistic.h" #include "llvm/CodeGen/MachineFunction.h" #include "llvm/CodeGen/MachineFunctionPass.h" #include "llvm/CodeGen/MachineInstrBuilder.h" #include "llvm/CodeGen/MachineRegisterInfo.h" #include "llvm/IR/Instructions.h" #include "llvm/Support/Debug.h"
-
之后,我们将添加一些代码来为我们的机器函数遍历做准备。首先是
DEBUG_TYPE
定义,命名为m88k-div-instr
,用于调试时的细粒度控制。具体来说,定义这个DEBUG_TYPE
允许用户指定机器函数遍历的名称,并在启用调试信息时查看与遍历相关的任何调试信息:#define DEBUG_TYPE "m88k-div-instr"
-
我们还指定了正在使用
llvm
命名空间,并为我们的机器函数声明了一个STATISTIC
值。这个统计值称为InsertedChecks
,它跟踪编译器插入的除以零检查的数量。最后,声明了一个匿名命名空间来封装随后的机器函数传递实现:using namespace llvm; STATISTIC(InsertedChecks, "Number of inserted checks for division by zero"); namespace {
-
如前所述,这个机器函数传递旨在检查除以零的情况,并插入会导致 CPU 陷阱的指令。这些指令需要条件码,因此我们定义了一个名为
CC0
的enum
值,其中包含了适用于 M88k 目标的条件码及其编码:enum class CC0 : unsigned { EQ0 = 0x2, NE0 = 0xd, GT0 = 0x1, LT0 = 0xc, GE0 = 0x3, LE0 = 0xe };
-
让我们创建我们的机器函数传递的实际类,称为
M88kDivInstr
。首先,我们创建它作为一个继承并属于MachineFunctionPass
类型的实例。接下来,我们声明了M88kDivInstr
传递所需的各个必要实例。这包括我们将在稍后创建和详细说明的M88kBuilder
,以及包含目标指令和寄存器信息的M88kTargetMachine
。此外,我们在发出指令时还需要寄存器银行信息和机器寄存器信息。还添加了一个AddZeroDivCheck
布尔值来表示之前的命令行选项,它打开或关闭我们的传递:class M88kDivInstr : public MachineFunctionPass { friend class M88kBuilder; const M88kTargetMachine *TM; const TargetInstrInfo *TII; const TargetRegisterInfo *TRI; const RegisterBankInfo *RBI; MachineRegisterInfo *MRI; bool AddZeroDivCheck;
-
对于
M88kDivInstr
类的公共变量和方法,我们声明了一个识别号,LLVM 将使用它来识别我们的传递,以及M88kDivInstr
构造函数,它接受M88kTargetMachine
。接下来,我们重写了getRequiredProperties()
方法,它代表了MachineFunction
在优化过程中可能拥有的属性,我们还重写了runOnMachineFunction()
方法,这将是我们的传递在检查任何除以零时运行的主要方法之一。公开声明的第二个重要函数是runOnMachineBasicBlock()
函数,它将在runOnMachineFunction()
内部执行:public: static char ID; M88kDivInstr(const M88kTargetMachine *TM = nullptr); MachineFunctionProperties getRequiredProperties() const override; bool runOnMachineFunction(MachineFunction &MF) override; bool runOnMachineBasicBlock(MachineBasicBlock &MBB);
-
最后,最后一部分是声明私有方法和关闭类。在
M88kDivInstr
类中,我们声明的唯一私有方法是addZeroDivCheck()
方法,它会在任何除法指令之后插入除以零的检查。正如我们稍后将会看到的,MachineInstr
需要在 M88k 目标上指向特定的除法指令:private: void addZeroDivCheck(MachineBasicBlock &MBB, MachineInstr *DivInst); };
-
接下来创建了一个
M88kBuilder
类,这是一个专门化的构建实例,用于创建 M88k 特定的指令。这个类保持了一个MachineBasicBlock
实例(以及相应的迭代器)和DebugLoc
,以跟踪这个构建类的调试位置。其他必要的实例包括目标指令信息、目标寄存器信息和 M88k 目标寄存器银行信息:class M88kBuilder { MachineBasicBlock *MBB; MachineBasicBlock::iterator I; const DebugLoc &DL; const TargetInstrInfo &TII; const TargetRegisterInfo &TRI; const RegisterBankInfo &RBI;
-
对于
M88kBuilder
类的公共方法,我们必须实现这个构建器的构造函数。在初始化时,我们的专用构建器需要一个M88kDivInstr
传递的实例来初始化目标指令、寄存器信息以及寄存器银行信息,以及MachineBasicBlock
和一个调试位置:public: M88kBuilder(M88kDivInstr &Pass, MachineBasicBlock *MBB, const DebugLoc &DL) : MBB(MBB), I(MBB->end()), DL(DL), TII(*Pass.TII), TRI(*Pass.TRI), RBI(*Pass.RBI) {}
-
接下来,创建了一个在 M88k 构建器内部设置
MachineBasicBlock
的方法,并且相应地设置了MachineBasicBlock
迭代器:void setMBB(MachineBasicBlock *NewMBB) { MBB = NewMBB; I = MBB->end(); }
-
接下来需要实现
constrainInst()
函数,它是在处理MachineInstr
实例时需要的。对于一个给定的MachineInstr
,我们检查MachineInstr
实例的操作数的寄存器类是否可以通过现有的函数constrainSelectedInstRegOperands()
进行约束。如图所示,这个机器函数传递要求机器指令的寄存器操作数可以约束:void constrainInst(MachineInstr *MI) { if (!constrainSelectedInstRegOperands(*MI, TII, TRI, RBI)) llvm_unreachable("Could not constrain register operands"); }
-
这个传递插入的指令之一是一个
BCND
指令,它在M88kInstrInfo.td
中定义,是 M88k 目标上的条件分支。为了创建这个指令,我们需要一个条件码,即CC0
枚举,这些枚举在M88kDivInstr.cpp
的开头实现——即一个寄存器和MachineBasicBlock
。创建BCND
指令后,简单地返回,并在检查新创建的指令是否可以约束之后返回。此外,这完成了M88kBuilder
类的类实现并完成了之前声明的匿名命名空间:MachineInstr *bcnd(CC0 Cc, Register Reg, MachineBasicBlock *TargetMBB) { MachineInstr *MI = BuildMI(*MBB, I, DL, TII.get(M88k::BCND)) .addImm(static_cast<int64_t>(Cc)) .addReg(Reg) .addMBB(TargetMBB); constrainInst(MI); return MI; }
-
对于机器函数传递,我们还需要一个陷阱指令,这是一个
TRAP503
指令。这个指令需要一个寄存器,如果寄存器的 0 位没有被设置,则会引发一个带有向量 503 的陷阱,这将在零除之后发生。在创建TRAP503
指令后,在返回之前检查TRAP503
的约束。此外,这也完成了M88kBuilder
类的实现和之前声明的匿名命名空间:MachineInstr *trap503(Register Reg) { MachineInstr *MI = BuildMI(*MBB, I, DL, TII.get(M88k::TRAP503)).addReg(Reg); constrainInst(MI); return MI; } }; } // end anonymous namespace
-
现在我们可以开始实现机器函数传递中执行实际检查的函数了。首先,让我们探索一下
addZeroDivCheck()
函数是如何实现的。这个函数简单地在一个当前机器指令(预期指向DIVSrr
或DIVUrr
)之间插入一个除以零的检查;这些是分别表示有符号和无符号除法的助记符。插入BCND
和TRAP503
指令,并将InsertedChecks
统计量增加以指示两个指令的添加:void M88kDivInstr::addZeroDivCheck(MachineBasicBlock &MBB, MachineInstr *DivInst) { assert(DivInst->getOpcode() == M88k::DIVSrr || DivInst->getOpcode() == M88k::DIVUrr && "Unexpected opcode"); MachineBasicBlock *TailBB = MBB.splitAt(*DivInst); M88kBuilder B(*this, &MBB, DivInst->getDebugLoc()); B.bcnd(CC0::NE0, DivInst->getOperand(2).getReg(), TailBB); B.trap503(DivInst->getOperand(2).getReg()); ++InsertedChecks; }
-
runOnMachineFunction()
函数接下来被实现,并且是创建 LLVM 中的一种函数传递类型时需要重写的重要函数之一。这个函数返回 true 或 false,取决于在机器函数传递期间是否进行了任何更改。此外,对于给定的机器函数,我们收集所有相关的 M88k 子目标信息,包括目标指令、目标寄存器、寄存器银行和机器寄存器信息。是否启用或禁用M88kDivInstr
机器函数传递的详细信息也被查询并存储在AddZeroDivCheck
变量中。此外,对机器函数中的所有机器基本块进行分析,以检查除以零的情况。执行机器基本块分析的功能是runOnMachineBasicBlock()
;我们将在接下来实现这个功能。最后,如果机器函数已更改,这通过返回的Changed
变量来指示:bool M88kDivInstr::runOnMachineFunction(MachineFunction &MF) { const M88kSubtarget &Subtarget = MF.getSubtarget<M88kSubtarget>(); TII = Subtarget.getInstrInfo(); TRI = Subtarget.getRegisterInfo(); RBI = Subtarget.getRegBankInfo(); MRI = &MF.getRegInfo(); AddZeroDivCheck = !TM->noZeroDivCheck(); bool Changed = false; for (MachineBasicBlock &MBB : reverse(MF)) Changed |= runOnMachineBasicBlock(MBB); return Changed; }
-
对于
runOnMachineBasicBlock()
函数,也返回一个Changed
布尔标志,以指示机器基本块是否已更改;然而,它最初被设置为false
。此外,在机器基本块内,我们需要分析所有机器指令并检查指令是否是DIVUrr
或DIVSrr
操作码。除了检查操作码是否是除法指令外,我们还需要检查用户是否已启用或禁用我们的机器函数传递。如果所有这些条件都满足,将通过之前实现的addZeroDivCheck()
函数相应地添加带有条件分支和陷阱指令的除以零检查。bool M88kDivInstr::runOnMachineBasicBlock(MachineBasicBlock &MBB) { bool Changed = false; for (MachineBasicBlock::reverse_instr_iterator I = MBB.instr_rbegin(); I != MBB.instr_rend(); ++I) { unsigned Opc = I->getOpcode(); if ((Opc == M88k::DIVUrr || Opc == M88k::DIVSrr) && AddZeroDivCheck) { addZeroDivCheck(MBB, &*I); Changed = true; } } return Changed; }
-
之后,我们需要实现构造函数以初始化我们的函数传递并设置适当的机器函数属性。这可以通过在
M88kDivInstr
类的构造函数中调用initializeM88kDivInstrPass()
函数并设置机器函数属性以指示我们的传递需要机器函数处于 SSA 形式来实现:M88kDivInstr::M88kDivInstr(const M88kTargetMachine *TM) : MachineFunctionPass(ID), TM(TM) { initializeM88kDivInstrPass(*PassRegistry::getPassRegistry()); } MachineFunctionProperties M88kDivInstr::getRequiredProperties() const { return MachineFunctionProperties().set( MachineFunctionProperties::Property::IsSSA); }
-
下一步是初始化我们的机器函数传递的 ID,并使用我们的机器函数传递的详细信息实例化
INITIALIZE_PASS
宏。这需要传递实例、命名信息以及两个布尔参数,指示传递是否仅检查 CFG 以及传递是否是分析传递。由于M88kDivInstr
不执行这些操作,因此将两个false
参数指定给传递初始化宏:char M88kDivInstr::ID = 0; INITIALIZE_PASS(M88kDivInstr, DEBUG_TYPE, "Handle div instructions", false, false)
-
最后,
createM88kDivInstr()
函数创建M88kDivInstr
传递的新实例,并带有M88kTargetMachine
实例。这被封装在llvm
命名空间中,并在完成此函数后结束命名空间:namespace llvm { FunctionPass *createM88kDivInstr(const M88kTargetMachine &TM) { return new M88kDivInstr(&TM); } } // end namespace llvm
构建新实现的机器函数传递
我们几乎完成了我们新的机器函数传递的实现!现在,我们需要确保 CMake 意识到 M88kDivinstr.cpp
中的新机器函数传递。然后,此文件被添加到 llvm/lib/Target/M88k/CMakeLists.txt
:
add_llvm_target(M88kCodeGen
M88kAsmPrinter.cpp
M88kDivInstr.cpp
M88kFrameLowering.cpp
M88kInstrInfo.cpp
M88kISelDAGToDAG.cpp
最后一步是使用以下命令构建带有我们新的机器函数传递实现的 LLVM。我们需要 -DLLVM_EXPERIMENTAL_TARGETS_TO_BUILD=M88k
CMake 选项来构建 M88k 目标:
$ cmake -G Ninja ../llvm-project/llvm -DLLVM_EXPERIMENTAL_TARGETS_TO_BUILD=M88k -DCMAKE_BUILD_TYPE=Release -DLLVM_ENABLE_PROJECTS="llvm"
$ ninja
通过这样,我们已经实现了机器函数传递,但不是很有趣吗?我们可以通过通过 llc
传递 LLVM IR 来演示此传递的结果。
使用 llc 运行机器函数传递的快照
我们有以下 IR,其中包含除以零的操作:
$ cat m88k-divzero.ll
target datalayout = "E-m:e-p:32:32:32-i1:8:8-i8:8:8-i16:16:16-i32:32:32-i64:64:64-f32:32:32-f64:64:64-a:8:16-n32"
target triple = "m88k-unknown-openbsd"
@dividend = dso_local global i32 5, align 4
define dso_local i32 @testDivZero() #0 {
%1 = load i32, ptr @dividend, align 4
%2 = sdiv i32 %1, 0
ret i32 %2
}
让我们将其输入到 llc 中:
$ llc m88k-divzero.ll
通过这样做,我们会看到,在生成的汇编中,默认情况下,除以零检查(由 bcnd.n
(BCND
) 和 tb0
(TRAP503
) 表示)是由我们新的机器函数传递插入的:
| %bb.1:
subu %r2, %r0, %r2
bcnd.n ne0, %r0, .LBB0_2
divu %r2, %r2, 0
tb0 0, %r3, 503
. . .
.LBB0_3:
bcnd.n ne0, %r0, .LBB0_4
divu %r2, %r2, 0
tb0 0, %r3, 503
然而,让我们看看当我们指定 --m88k-no-check-zero-division
给 llc
时会发生什么:
$ llc m88k-divzero.ll –m88k-no-check-zero-division
此选项通知后端 llc
不要运行检查除以零的传递。生成的汇编将不包含任何 BCND
或 TRAP503
指令。以下是一个示例:
| %bb.1:
subu %r2, %r0, %r2
divu %r2, %r2, 0
jmp.n %r1
subu %r2, %r0, %r2
如我们所见,实现机器函数传递需要几个步骤,但这些程序可以作为你实现任何适合你需求的机器函数传递的指南。由于我们已经在本节中广泛探讨了后端,让我们转换方向,看看我们如何让前端了解 M88k 目标。
将新目标集成到 clang 前端
在前面的章节中,我们在 LLVM 中开发了 M88k 目标的后端实现。为了完成 M88k 目标的编译器实现,我们将研究通过添加 clang 的 M88k 目标实现来将我们的新目标连接到前端。
在 clang 中实现驱动集成
让我们从将驱动集成添加到 M88k 的 clang 开始:
-
我们将要做的第一个更改是在
clang/include/clang/Basic/TargetInfo.h
文件内部。BuiltinVaListKind
枚举列出了每个目标的不同类型的__builtin_va_list
,这用于变长函数支持,因此为 M88k 添加了一个相应的类型:enum BuiltinVaListKind { . . . // typedef struct __va_list_tag { // int __va_arg; // int *__va_stk; // int *__va_reg; //} va_list; M88kBuiltinVaList };
-
接下来,我们必须添加一个新的头文件,
clang/lib/Basic/Targets/M88k.h
。此文件是前端 M88k 目标功能支持的头部文件。第一步是定义一个新的宏,以防止多次包含相同的头文件、类型、变量等。我们还需要包含实现所需的各个头文件:#ifndef LLVM_CLANG_LIB_BASIC_TARGETS_M88K_H #define LLVM_CLANG_LIB_BASIC_TARGETS_M88K_H #include "OSTargets.h" #include "clang/Basic/TargetInfo.h" #include "clang/Basic/TargetOptions.h" #include "llvm/Support/Compiler.h" #include "llvm/TargetParser/Triple.h"
-
我们将要声明的函数将被添加到
clang
和targets
命名空间中,就像llvm-project
内的其他目标一样:namespace clang { namespace targets {
-
现在让我们声明实际的
M88kTargetInfo
类,并让它扩展原始的TargetInfo
类。这个类被标记为LLVM_LIBRARY_VISIBILITY
,因为如果这个类链接到共享库,这个属性允许M88kTargetInfo
类仅在库内部可见,外部不可访问:class LLVM_LIBRARY_VISIBILITY M88kTargetInfo: public TargetInfo {
-
此外,我们必须声明两个变量——一个字符数组来表示寄存器名称,以及一个
enum
值,包含 M88k 目标中可选择的 CPU 类型。我们设置的默认 CPU 是CK_Unknown
CPU。稍后,我们将看到这可以被用户选项覆盖:static const char *const GCCRegNames[]; enum CPUKind { CK_Unknown, CK_88000, CK_88100, CK_88110 } CPU = CK_Unknown;
-
然后,我们开始声明在我们的类实现中需要的公共方法。除了我们类的构造函数外,我们还定义了各种 getter 方法。这包括获取特定目标
#define
值的函数,获取目标支持的内置函数列表的函数,返回 GCC 寄存器名称及其别名的函数,以及最终返回我们之前添加到clang/include/clang/Basic/TargetInfo.h
中的 M88kBuiltinVaListKind
的函数:public: M88kTargetInfo(const llvm::Triple &Triple, const TargetOptions &); void getTargetDefines(const LangOptions &Opts, MacroBuilder &Builder) const override; ArrayRef<Builtin::Info> getTargetBuiltins() const override; ArrayRef<const char *> getGCCRegNames() const override; ArrayRef<TargetInfo::GCCRegAlias> getGCCRegAliases() const override; BuiltinVaListKind getBuiltinVaListKind() const override { return TargetInfo::M88kBuiltinVaList; }
-
在 getter 方法之后,我们还必须定义执行对 M88k 目标进行各种检查的方法。第一个方法检查 M88k 目标是否具有特定的目标特性,以字符串的形式提供。其次,我们添加了一个函数来验证内联汇编时使用的约束条件。最后,我们有一个函数检查特定的 CPU 是否适用于 M88k 目标,也以字符串的形式提供:
bool hasFeature(StringRef Feature) const override; bool validateAsmConstraint(const char *&Name, TargetInfo::ConstraintInfo &info) const override; bool isValidCPUName(StringRef Name) const override;
-
接下来,让我们声明
M88kTargetInfo
类的 setter 方法。第一个方法简单地设置我们想要针对的特定 M88k CPU,而第二个方法设置一个向量,包含所有有效的支持 M88k 的 CPU:bool setCPU(const std::string &Name) override; void fillValidCPUList(SmallVectorImpl<StringRef> &Values) const override; };
-
为了完成驱动程序的头部实现,让我们总结一下我们在开始时添加的命名空间和宏定义:
} // namespace targets } // namespace clang #endif // LLVM_CLANG_LIB_BASIC_TARGETS_M88K_H
-
现在我们已经完成了
clang/lib/Basic/Targets
中的 M88k 头文件,我们必须在clang/lib/Basic/Targets/M88k.cpp
中添加相应的TargetInfo
C++实现。我们将首先包含所需的头文件,特别是我们刚刚创建的新M88k.h
头文件:#include "M88k.h" #include "clang/Basic/Builtins.h" #include "clang/Basic/Diagnostic.h" #include "clang/Basic/TargetBuiltins.h" #include "llvm/ADT/StringExtras.h" #include "llvm/ADT/StringRef.h" #include "llvm/ADT/StringSwitch.h" #include "llvm/TargetParser/TargetParser.h" #include <cstring>
-
就像我们在标题中之前做的那样,我们从
clang
和targets
命名空间开始,然后也开始实现M88kTargetInfo
类的构造函数:namespace clang { namespace targets { M88kTargetInfo::M88kTargetInfo(const llvm::Triple &Triple, const TargetOptions &) : TargetInfo(Triple) {
-
在构造函数中,我们为 M88k 目标设置数据布局字符串。正如你可能之前看到的,这个数据布局字符串出现在生成的 LLVM IR 文件顶部。数据布局字符串每个部分的解释在这里描述:
std::string Layout = ""; Layout += "E"; // M68k is Big Endian Layout += "-m:e"; Layout += "-p:32:32:32"; // Pointers are 32 bit. // All scalar types are naturally aligned. Layout += "-i1:8:8-i8:8:8-i16:16:16-i32:32:32-i64:64:64"; // Floats and doubles are also naturally aligned. Layout += "-f32:32:32-f64:64:64"; // We prefer 16 bits of aligned for all globals; see above. Layout += "-a:8:16"; Layout += "-n32"; // Integer registers are 32bits. resetDataLayout(Layout);
-
M88kTargetInfo
类的构造函数通过设置各种变量类型为signed long long
、unsigned long
或signed int
来结束:IntMaxType = SignedLongLong; Int64Type = SignedLongLong; SizeType = UnsignedLong; PtrDiffType = SignedInt; IntPtrType = SignedInt; }
-
之后,实现了设置目标 CPU 的函数。这个函数接受一个字符串,并将 CPU 设置为用户在
llvm::StringSwitch
中提供的特定 CPU 字符串,这实际上只是一个常规的 switch 语句,但专门用于 LLVM 中的字符串。我们可以看到,在 M88k 目标上有三种支持的 CPU 类型,还有一个CK_Unknown
类型,用于如果提供的字符串与任何预期的类型都不匹配:bool M88kTargetInfo::setCPU(const std::string &Name) { StringRef N = Name; CPU = llvm::StringSwitch<CPUKind>(N) .Case("generic", CK_88000) .Case("mc88000", CK_88000) .Case("mc88100", CK_88100) .Case("mc88110", CK_88110) .Default(CK_Unknown); return CPU != CK_Unknown; }
-
之前已经提到,在 M88k 目标上支持并有效的 CPU 类型有三种:
mc88000
、mc88100
和mc88110
,其中generic
类型简单地就是mc88000
CPU。我们必须实现以下函数来在 clang 中强制执行这些有效的 CPU:首先,我们必须声明一个字符串数组ValidCPUNames[]
,以表示 M88k 上的有效 CPU 名称。其次,fillValidCPUList()
方法将有效 CPU 名称数组填充到一个向量中。然后,这个向量在isValidCPUName()
方法中使用,以检查提供的特定 CPU 名称是否确实适用于我们的 M88k 目标:static constexpr llvm::StringLiteral ValidCPUNames[] = { {"generic"}, {"mc88000"}, {"mc88100"}, {"mc88110"}}; void M88kTargetInfo::fillValidCPUList( SmallVectorImpl<StringRef> &Values) const { Values.append(std::begin(ValidCPUNames), std::end(ValidCPUNames)); } bool M88kTargetInfo::isValidCPUName(StringRef Name) const { return llvm::is_contained(ValidCPUNames, Name); }
-
接下来,实现
getTargetDefines()
方法。这个函数定义了前端必需的宏,例如有效 CPU 类型。除了__m88k__
和__m88k
宏之外,我们还必须为有效 CPU 定义相应的 CPU 宏:void M88kTargetInfo::getTargetDefines(const LangOptions &Opts, MacroBuilder &Builder) const { using llvm::Twine; Builder.defineMacro("__m88k__"); Builder.defineMacro("__m88k"); switch (CPU) { // For sub-architecture case CK_88000: Builder.defineMacro("__mc88000__"); break; case CK_88100: Builder.defineMacro("__mc88100__"); break; case CK_88110: Builder.defineMacro("__mc88110__"); break; default: break; } }
-
接下来的几个函数是存根函数,但它们对于前端的基本支持是必需的。这包括从目标获取内置函数的函数以及查询目标是否支持特定功能的函数。目前,我们将它们留空实现,并为这些函数设置默认返回值,以便以后实现:
ArrayRef<Builtin::Info> M88kTargetInfo::getTargetBuiltins() const { return std::nullopt; } bool M88kTargetInfo::hasFeature(StringRef Feature) const { return Feature == "M88000"; }
-
在这些函数之后,我们将为 M88k 上的寄存器名称添加一个实现。通常,支持的寄存器名称列表及其用途可以在感兴趣的具体平台的 ABI 中找到。在这个实现中,我们将实现 0-31 号的主要通用寄存器,并创建一个数组来存储这些信息。至于寄存器别名,请注意,我们目前实现的寄存器没有别名:
const char *const M88kTargetInfo::GCCRegNames[] = { "r0", "r1", "r2", "r3", "r4", "r5", "r6", "r7", "r8", "r9", "r10", "r11", "r12", "r13", "r14", "r15", "r16", "r17", "r18", "r19", "r20", "r21", "r22", "r23", "r24", "r25", "r26", "r27", "r28", "r29", "r39", "r31"}; ArrayRef<const char *> M88kTargetInfo::getGCCRegNames() const { return llvm::makeArrayRef(GCCRegNames); } ArrayRef<TargetInfo::GCCRegAlias> M88kTargetInfo::getGCCRegAliases() const { return std::nullopt; // No aliases. }
-
我们将实现的最后一个函数是验证目标内联汇编约束的函数。这个函数简单地接受一个字符,它代表内联汇编约束,并相应地处理这个约束。实现了一些内联汇编寄存器约束,例如地址、数据和浮点寄存器,以及一些常数的约束:
bool M88kTargetInfo::validateAsmConstraint( const char *&Name, TargetInfo::ConstraintInfo &info) const { switch (*Name) { case 'a': // address register case 'd': // data register case 'f': // floating point register info.setAllowsRegister(); return true; case 'K': // the constant 1 case 'L': // constant -1²⁰ .. 1¹⁹ case 'M': // constant 1-4: return true; } return false; }
-
我们通过关闭文件开始时启动的
clang
和targets
命名空间来结束文件:} // namespace targets } // namespace clang
在完成clang/lib/Basic/Targets/M88k.cpp
的实现后,需要在clang/include/clang/Driver/Options.td
中添加 M88k 功能组和有效 CPU 类型的实现:
回想一下,我们之前为我们的 M88k 目标定义了三种有效的 CPU 类型:mc88000
、mc88100
和 mc88110
。这些 CPU 类型也需要在 Options.td
中定义,因为该文件是定义所有将被 clang 接受的选项和标志的中心位置:
-
首先,我们必须添加
m_m88k_Features_Group
,它代表一组将可用于 M88k 目标的特性:def m_m88k_Features_Group: OptionGroup<"<m88k features group>">, Group<m_Group>, DocName<"M88k">;
-
然后,我们必须在 M88k 特性组中定义三种有效的 M88k CPU 类型作为一个特性:
def m88000 : Flag<["-"], "m88000">, Group<m_m88k_Features_Group>; def m88100 : Flag<["-"], "m88100">, Group<m_m88k_Features_Group>; def m88110 : Flag<["-"], "m88110">, Group<m_m88k_Features_Group>;
这样,我们就实现了将 M88k 目标与 clang 连接的驱动程序集成部分。
在 clang 中实现 M88k 的 ABI 支持
现在,我们需要在 clang 的前端添加 ABI 支持,这允许我们从前端生成针对 M88k 目标的特定代码:
-
让我们从添加以下
clang/lib/CodeGen/TargetInfo.h
开始。这是一个原型,用于为 M88k 目标创建代码生成信息:std::unique_ptr<TargetCodeGenInfo> createM88kTargetCodeGenInfo(CodeGenModule &CGM);
-
我们还需要将以下代码添加到
clang/lib/Basic/Targets.cpp
中,这将帮助 clang 学习 M88k 可接受的目标三元组。正如我们所见,对于 M88k 目标,可接受的操作系统是 OpenBSD。这意味着 clang 接受m88k-openbsd
作为目标三元组:#include "Targets/M88k.h" #include "Targets/MSP430.h" . . . case llvm::Triple::m88k: switch (os) { case llvm::Triple::OpenBSD: return std::make_unique<OpenBSDTargetInfo<M88kTargetInfo>>(Triple, Opts); default: return std::make_unique<M88kTargetInfo>(Triple, Opts); } case llvm::Triple::le32: . . .
现在,我们需要创建一个名为
clang/lib/CodeGen/Targets/M88k.cpp
的文件,这样我们就可以继续为 M88k 进行代码生成信息和 ABI 实现了。 -
在
clang/lib/CodeGen/Targets/M88k.cpp
中,我们必须添加以下必要的头文件,其中之一是我们刚刚修改的TargetInfo.h
头文件。然后,我们必须指定我们正在使用clang
和clang::codegen
命名空间:#include "ABIInfoImpl.h" #include "TargetInfo.h" using namespace clang; using namespace clang::CodeGen;
-
然后,我们必须声明一个新的匿名命名空间,并将我们的
M88kABIInfo
放入其中。M88kABIInfo
从 clang 的现有ABIInfo
继承,并在其中包含DefaultABIInfo
。对于我们的目标,我们严重依赖现有的ABIInfo
和DefaultABIInfo
,这显著简化了M88kABIInfo
类:namespace { class M88kABIInfo final : public ABIInfo { DefaultABIInfo defaultInfo;
-
此外,除了添加
M88kABIInfo
类的构造函数之外,还添加了一些方法。computeInfo()
实现了默认的clang::CodeGen::ABIInfo
类。还有一个EmitVAArg()
函数,它生成从传入的指针中检索参数的代码;稍后更新。这主要用于变长函数支持:public: explicit M88kABIInfo(CodeGen::CodeGenTypes &CGT) : ABIInfo(CGT), defaultInfo(CGT) {} void computeInfo(CodeGen::CGFunctionInfo &FI) const override {} CodeGen::Address EmitVAArg(CodeGen::CodeGenFunction &CGF, CodeGen::Address VAListAddr, QualType Ty) const override { return VAListAddr; } };
-
接下来,我们添加
M88kTargetCodeGenInfo
类的构造函数,它扩展了原始的TargetCodeGenInfo
。之后,我们必须关闭最初创建的匿名命名空间:class M88kTargetCodeGenInfo final : public TargetCodeGenInfo { public: explicit M88kTargetCodeGenInfo(CodeGen::CodeGenTypes &CGT) : TargetCodeGenInfo(std::make_unique<DefaultABIInfo>(CGT)) {} }; }
-
最后,我们必须添加实现来创建实际的
M88kTargetCodeGenInfo
类,并将其作为std::unique_ptr
使用,它接受一个生成 LLVM IR 代码的CodeGenModule
。这直接对应于最初添加到TargetInfo.h
中的内容:std::unique_ptr<TargetCodeGenInfo> CodeGen::createM88kTargetCodeGenInfo(CodeGenModule &CGM) { return std::make_unique<M88kTargetCodeGenInfo>(CGM.getTypes()); }
这就完成了前端对 M88k 的 ABI 支持。
在 clang 中实现 M88k 的工具链支持
在 clang 中 M88k 目标集成的最后部分是实现针对我们的目标的工具链支持。像之前一样,我们需要为工具链支持创建一个头文件。我们称这个头文件为 clang/lib/Driver/ToolChains/Arch/M88k.h
:
-
首先,我们必须定义
LLVM_CLANG_LIB_DRIVER_TOOLCHAINS_ARCH_M88K_H
以防止以后多次包含,并添加任何必要的头文件以供以后使用。在此之后,我们必须声明clang
、driver
、tools
和m88k
命名空间,每个嵌套在另一个内部:#ifndef LLVM_CLANG_LIB_DRIVER_TOOLCHAINS_ARCH_M88K_H #define LLVM_CLANG_LIB_DRIVER_TOOLCHAINS_ARCH_M88K_H #include "clang/Driver/Driver.h" #include "llvm/ADT/StringRef.h" #include "llvm/Option/Option.h" #include <string> #include <vector> namespace clang { namespace driver { namespace tools { namespace m88k {
-
接下来,我们必须声明一个
enum
值来描述浮点 ABI,这是用于软浮点和硬浮点的。这意味着浮点计算可以由浮点硬件本身完成,这很快,或者通过软件仿真,这会慢一些:enum class FloatABI { Invalid, Soft, Hard, };
-
在此之后,我们必须添加定义以通过驱动程序获取浮点 ABI,并通过 clang 的
-mcpu=
和-mtune=
选项获取 CPU。我们还必须声明一个从驱动程序检索目标功能的函数:FloatABI getM88kFloatABI(const Driver &D, const llvm::opt::ArgList &Args); StringRef getM88kTargetCPU(const llvm::opt::ArgList &Args); StringRef getM88kTuneCPU(const llvm::opt::ArgList &Args); void getM88kTargetFeatures(const Driver &D, const llvm::Triple &Triple, const llvm::opt::ArgList &Args, std::vector<llvm::StringRef> &Features);
-
最后,我们通过结束命名空间和我们最初定义的宏来结束头文件:
} // end namespace m88k } // end namespace tools } // end namespace driver } // end namespace clang #endif // LLVM_CLANG_LIB_DRIVER_TOOLCHAINS_ARCH_M88K_H
我们将要实现的最后一个文件是工具链支持的 C++ 实现,位于 clang/lib/Driver/ToolChains/Arch/M88k.cpp
:
-
我们将再次从包括我们稍后将要使用的必要头文件和命名空间开始实现。我们还必须包括我们之前创建的
M88k.h
头文件:#include "M88k.h" #include "ToolChains/CommonArgs.h" #include "clang/Driver/Driver.h" #include "clang/Driver/DriverDiagnostic.h" #include "clang/Driver/Options.h" #include "llvm/ADT/SmallVector.h" #include "llvm/ADT/StringSwitch.h" #include "llvm/Option/ArgList.h" #include "llvm/Support/Host.h" #include "llvm/Support/Regex.h" #include <sstream> using namespace clang::driver; using namespace clang::driver::tools; using namespace clang; using namespace llvm::opt;
-
接下来实现的是
normalizeCPU()
函数,该函数将 CPU 名称处理为 clang 中的-mcpu=
选项。正如我们所见,每个 CPU 名称都有几个可接受的变体。此外,当用户指定-mcpu=native
时,它允许他们为当前主机的 CPU 类型进行编译:static StringRef normalizeCPU(StringRef CPUName) { if (CPUName == "native") { StringRef CPU = std::string(llvm::sys::getHostCPUName()); if (!CPU.empty() && CPU != "generic") return CPU; } return llvm::StringSwitch<StringRef>(CPUName) .Cases("mc88000", "m88000", "88000", "generic", "mc88000") .Cases("mc88100", "m88100", "88100", "mc88100") .Cases("mc88110", "m88110", "88110", "mc88110") .Default(CPUName); }
-
接下来,我们必须实现
getM88kTargetCPU()
函数,其中,给定我们在clang/include/clang/Driver/Options.td
中之前实现的 clang CPU 名称,我们获取我们针对的 M88k CPU 的相应 LLVM 名称:StringRef m88k::getM88kTargetCPU(const ArgList &Args) { Arg *A = Args.getLastArg(options::OPT_m88000, options::OPT_m88100, options::OPT_m88110, options::OPT_mcpu_EQ); if (!A) return StringRef(); switch (A->getOption().getID()) { case options::OPT_m88000: return "mc88000"; case options::OPT_m88100: return "mc88100"; case options::OPT_m88110: return "mc88110"; case options::OPT_mcpu_EQ: return normalizeCPU(A->getValue()); default: llvm_unreachable("Impossible option ID"); } }
-
在之后实现的是
getM88kTuneCPU()
函数。这是 clang-mtune=
选项的行为,它将指令调度模型更改为使用给定 CPU 的数据来针对 M88k。我们简单地针对我们当前正在针对的任何 CPU 进行调整:StringRef m88k::getM88kTuneCPU(const ArgList &Args) { if (const Arg *A = Args.getLastArg(options::OPT_mtune_EQ)) return normalizeCPU(A->getValue()); return StringRef(); }
-
我们还将实现
getM88kFloatABI()
方法,该方法获取浮点 ABI。最初,我们将 ABI 设置为m88k::FloatABI::Invalid
作为默认值。接下来,我们必须检查命令行是否传递了任何-msoft-float
或-mhard-float
选项。如果指定了-msoft-float
,则相应地将 ABI 设置为m88k::FloatABI::Soft
。同样,当指定-mhard-float
时,我们将m88k::FloatABI::Hard
设置为 clang。最后,如果没有指定这些选项中的任何一个,我们将选择当前平台上的默认值,对于 M88k 来说这将是一个硬浮点值:m88k::FloatABI m88k::getM88kFloatABI(const Driver &D, const ArgList &Args) { m88k::FloatABI ABI = m88k::FloatABI::Invalid; if (Arg *A = Args.getLastArg(options::OPT_msoft_float, options::OPT_mhard_float)) { if (A->getOption().matches(options::OPT_msoft_float)) ABI = m88k::FloatABI::Soft; else if (A->getOption().matches(options::OPT_mhard_float)) ABI = m88k::FloatABI::Hard; } if (ABI == m88k::FloatABI::Invalid) ABI = m88k::FloatABI::Hard; return ABI; }
-
我们接下来将添加
getM88kTargetFeatures()
的实现。这个函数的重要部分是作为参数传递的Features
向量。正如我们所见,唯一处理的目标特性是浮点 ABI。从驱动程序及其传递的参数中,我们将从之前步骤中实现的浮点 ABI 中获取适当的浮点 ABI。请注意,我们还将-hard-float
目标特性添加到Features
向量中,以支持软浮点 ABI,这意味着目前 M88k 只支持硬浮点:void m88k::getM88kTargetFeatures(const Driver &D, const llvm::Triple &Triple, const ArgList &Args, std::vector<StringRef> &Features) { m88k::FloatABI FloatABI = m88k::getM88kFloatABI(D, Args); if (FloatABI == m88k::FloatABI::Soft) Features.push_back("-hard-float"); }
构建具有 clang 集成的 M88k 目标
我们几乎完成了将 M88k 集成到 clang 中的实现。最后一步是将我们添加的新 clang 文件添加到相应的CMakeLists.txt
文件中,这样我们就可以使用我们的 M88k 目标实现来构建 clang 项目:
-
首先,将
Targets/M88k.cpp
行添加到clang/lib/Basic/CMakeLists.txt
。 -
接下来,将
Targets/M88k.cpp
添加到clang/lib/CodeGen/CMakeLists.txt
。 -
最后,将
ToolChains/Arch/M88k.cpp
添加到clang/lib/Driver/CMakeLists.txt
。
就这样!这标志着我们为 M88k 目标工具链支持的工具链实现完成,这也意味着我们已经完成了 M88k 对 clang 的集成!
我们需要做的最后一步是用 M88k 目标构建 clang。以下命令将构建 clang 和 LLVM 项目。对于 clang,请注意 M88k 目标。在这里,必须添加与上一节相同的 CMake 选项-DLLVM_EXPERIMENTAL_TARGETS_TO_BUILD=M88k
:
$ cmake -G Ninja ../llvm-project/llvm -DLLVM_EXPERIMENTAL_TARGETS_TO_BUILD=M88k -DCMAKE_BUILD_TYPE=Release -DLLVM_ENABLE_PROJECTS="clang;llvm"
$ ninja
现在我们应该有一个可以识别 M88k 目标的 clang 版本!我们可以通过检查 clang 支持的目标列表来确认这一点,通过--print-targets
选项:
$ clang --print-targets | grep M88k
m88k - M88k
在本节中,我们深入探讨了将新的后端目标集成到 clang 中并使其被识别的技术细节。在下一节中,我们将探讨交叉编译的概念,我们将详细说明从当前主机针对不同 CPU 架构的流程。
针对不同的 CPU 架构
今天,尽管资源有限,许多小型计算机,如树莓派(Raspberry Pi),仍在使用中。在这样的计算机上运行编译器通常是不可能的,或者需要花费太多时间。因此,编译器的一个常见要求是为不同的 CPU 架构生成代码。为主机编译不同目标可执行文件的全过程被称为交叉编译。
在交叉编译中,涉及两个系统:主机系统和目标系统。编译器在主机系统上运行,为目标系统生成代码。为了表示系统,使用所谓的三元组。这是一个配置字符串,通常由 CPU 架构、供应商和操作系统组成。此外,通常还会将有关环境的附加信息添加到配置字符串中。例如,x86_64-pc-win32
三元组用于在 64 位 X86 CPU 上运行的 Windows 系统。CPU 架构是 x86_64
,pc
是一个通用的供应商,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
。不同的系统有不同的特性。因此,你的应用程序必须以可移植的方式编写;否则,可能会出现复杂情况。一些常见的问题如下:
-
字节序(Endianness):多字节值在内存中存储的顺序可能不同。
-
int
类型可能不足以容纳指针。 -
long double
类型可以使用 64 位(ARM)、80 位(X86)或 128 位(ARMv8)。PowerPC 系统可能使用双双精度算术来表示long double
,通过组合两个 64 位的double
值来提供更高的精度。
如果你没有注意这些要点,那么你的应用程序在目标平台上可能会表现出意外的行为或崩溃,即使它在主机系统上运行得很好。LLVM 库在不同的平台上进行了测试,并且还包含对上述问题的可移植解决方案。
对于交叉编译,需要以下工具:
-
能够为目标生成代码的编译器
-
能够为目标生成二进制文件的可链接器
-
目标系统的头文件和库
幸运的是,Ubuntu 和 Debian 发行版有支持交叉编译的软件包。我们在以下设置中利用了这一点。gcc
和 g++
编译器、链接器 ld
以及库都作为预编译的二进制文件提供,这些二进制文件生成 ARMv8 代码和可执行文件。以下命令安装了所有这些软件包:
$ sudo apt –y install gcc-12-aarch64-linux-gnu \
g++-12-aarch64-linux-gnu binutils-aarch64-linux-gnu \
libstdc++-12-dev-arm64-cross
新文件安装于 /usr/aarch64-linux-gnu
目录下。此目录是目标系统的(逻辑)根目录。它包含通常的 bin
、lib
和 include
目录。交叉编译器(aarch64-linux-gnu-gcc-8
和 aarch64-linux-gnu-g++-8
)了解此目录。
在其他系统上交叉编译
一些发行版,如 Fedora,只为裸机目标(如 Linux 内核)提供交叉编译支持,但未提供用户空间应用程序所需的头文件和库文件。在这种情况下,你可以简单地从目标系统复制缺少的文件。
如果你的发行版没有包含所需的工具链,则可以从源代码构建它。对于编译器,你可以使用 clang 或 gcc/g++。gcc 和 g++ 编译器必须配置为为目标系统生成代码,而 binutils 工具需要处理目标系统的文件。此外,C 和 C++ 库需要使用此工具链进行编译。步骤因操作系统、主机和目标架构而异。在网上,如果你搜索 gcc
cross-compile <架构>
,你可以找到说明。
准备就绪后,你几乎可以开始交叉编译示例应用程序(包括 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-12 \
-DCMAKE_CXX_COMPILER=aarch64-linux-gnu-g++-12 \
../llvm-project/llvm
$ ninja
再次,在运行 CMake 命令之前,创建一个构建目录并进入它。其中一些这些 CMake 参数之前未使用过,需要一些解释:
-
将
CMAKE_CROSSCOMPILING
设置为ON
告诉 CMake 我们正在进行交叉编译。 -
LLVM_TABLEGEN
指定了要使用的llvm-tblgen
工具的路径。这是之前构建中使用的版本。 -
LLVM_DEFAULT_TARGET_TRIPLE
是目标架构的三元组。 -
LLVM_TARGET_ARCH
用于 JIT 代码生成。它默认为主机架构。对于交叉编译,这必须设置为目标架构。 -
LLVM_TARGETS_TO_BUILD
是 LLVM 应该包含代码生成器的目标列表。该列表至少应包括目标架构。 -
CMAKE_C_COMPILER
和CMAKE_CXX_COMPILER
分别指定用于构建的 C 和 C++ 编译器。交叉编译器的二进制文件以目标三元组为前缀,并且 CMake 不会自动找到它们。
使用其他参数,请求启用断言的发布构建,并且我们的 tinylang 应用程序作为 LLVM 的一部分构建。一旦编译过程完成,file
命令可以证明我们已经为 ARMv8 创建了一个二进制文件。具体来说,我们可以运行 $ file bin/tinylang
并检查输出是否显示 ELF 64-bit object for the ARM
aarch64 architecture
。
使用 clang 进行交叉编译
由于 LLVM 为不同的架构生成代码,使用 clang 进行交叉编译似乎是显而易见的。这里的障碍是 LLVM 并不提供所有必需的部分——例如,C 库缺失。因此,您必须混合使用 LLVM 和 GNU 工具,结果您需要告诉 CMake 更多关于您使用环境的细节。至少,您需要为 clang 和 clang++ 指定以下选项:--target=<target-triple>
(启用针对不同目标的代码生成),--sysroot=<path>
(目标根目录的路径),I
(头文件的搜索路径),和 --L
(库的搜索路径)。在 CMake 运行期间,一个小型应用程序被编译,如果您的设置有问题,CMake 会抱怨。这一步足以检查您是否有正常工作的环境。常见问题包括选择错误的头文件或由于库名称不同或搜索路径错误导致的链接失败。
跨平台编译令人惊讶地复杂。通过本节中的说明,您将能够为所选的目标架构交叉编译您的应用程序。
摘要
在本章中,您学习了创建运行在指令选择之外的传递,特别是探索了后端中机器函数传递背后的创建!您还发现了如何将一个新的实验性目标添加到 clang 中,以及一些所需的驱动程序、ABI 和工具链更改。最后,在考虑编译器构建的最高准则时,您学习了如何为另一个目标架构交叉编译您的应用程序。
现在,我们已经到达了《学习 LLVM 17》的尾声,您已经具备了在项目中以创新方式使用 LLVM 的知识,并探索了许多有趣的主题。LLVM 生态系统非常活跃,新功能不断添加,因此请务必关注其发展!
作为编译器开发者,我们很高兴能撰写关于 LLVM 的文章,并在过程中发现了一些新功能。享受使用 LLVM 的乐趣吧!