LLVM-入门笔记-全-

LLVM 入门笔记(全)

002:安装LLVM 🛠️

在本节课中,我们将学习如何安装LLVM以及如何使用其发行版中包含的一些工具。我们将介绍两种主要的安装方法:二进制发行版安装和从源代码编译安装。

安装方式选择

关于在本地系统安装LLVM,有几种不同的方法。本节我们将探讨两种主要途径。

首先,你需要决定是安装二进制发行版还是源代码。安装LLVM的二进制版本速度更快,因为你无需下载源代码并在本地编译。如果你的硬盘空间有限,这种方式占用的空间也更少。

然而,如果你安装并编译源代码,之后可以对其进行修改。只要遵守许可证(Apache 2.0,这是一种非常宽松的许可证),你甚至可以分发修改后的版本。

安装二进制发行版

让我们先看看如何安装二进制发行版。

在macOS上,你可以使用Homebrew来安装Brew仓库中可用的最新LLVM发行版。这将安装LLVM及其所有依赖项。

brew install llvm

在Linux上,你可以使用包管理器,例如apt,来安装。你甚至可以安装像Clang这样的编译工具以及随LLVM一起分发的其他项目。

sudo apt-get install llvm clang

从源代码安装LLVM

另一种获取LLVM的方式是从源代码安装。

在这种情况下,我们必须下载其源代码然后进行编译。这是本课程将采用的选项。如果条件允许,你应该优先选择这种方式,因为它为你提供了完整的LLVM源代码,我们可以随时查阅。

要从源代码安装LLVM,我们需要执行两个步骤:下载和编译。通常这是两个简单的步骤,但需要输入一些命令。

下载源代码

首先,从主要的LLVM Git仓库下载源代码。

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

编译源代码

现在开始编译。我们首先需要决定一个合适的位置来存放编译生成的二进制文件。

请注意,这通常会占用大量磁盘空间。在本例中,我将LLVM编译到一个名为build的目录中,该目录位于下载的llvm-project文件夹内。

cd llvm-project
mkdir build
cd build

如今,LLVM使用CMake来管理构建过程。CMake需要一个名为CMakeLists.txt的特殊文件。这个文件位于llvm文件夹中,该文件夹比我们的build目录高一级。这就是为什么我们在输入以下命令时需要指定该路径。

cmake -G "Unix Makefiles" -DLLVM_ENABLE_PROJECTS="clang" -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=/path/to/install ../llvm

我们可以向CMake传递更多选项。例如,这里我指定使用Makefile来构建LLVM。另一个选项是使用Ninja或Xcode。

我们需要指定构建LLVM的位置。在我的例子中,我构建在图中所示的文件夹中。

另一个我喜欢的选项是使用共享库,而不是静态构建,这通常可以节省大量硬盘空间。

最后,你应该启用Clang。这个选项将构建Clang。有了Clang,你可以为C语言开发插件,我们将在本课程后面看到。

-DLLVM_ENABLE_PROJECTS="clang" -DBUILD_SHARED_LIBS=ON

无论如何,这些是我在运行Ubuntu的x86_64机器上设置LLVM构建系统时使用的选项。这些选项可能也适合你。

但如果你愿意,可以使用gold插件。这个插件的主要优点是它启用了链接时优化。如果你想了解更多关于gold链接器和链接时优化的信息,请查看LLVM的官方文档。

请注意,这是一个可选步骤。

一旦你使用CMake创建了正确的Makefile,就可以通过输入make来构建LLVM。

make -j$(nproc)

请注意,这里我使用了-j选项来并行化构建过程。编译LLVM需要相当长的时间,在这种情况下,拥有多个核心会很有帮助。

编译完成后的工具

编译完LLVM后,你会发现现在可以访问许多不同的工具。要查看它们,请进入构建目录中生成的bin文件夹,然后输入ls

cd /path/to/llvm-project/build/bin
ls

这些是我在准备这门课程时系统上的工具。在本课程的其余部分,我们将了解其中的一些工具,但别担心,我们不会全部用到。

总结

本节课我们一起学习了LLVM的安装过程。我们介绍了两种主要方法:安装预编译的二进制发行版和从源代码编译安装。对于初学者,二进制安装更快捷方便;而对于希望深入研究和定制LLVM的开发者,从源代码编译是更好的选择。我们还了解了编译过程中的一些关键配置选项。

在下一节课中,我们将讨论如何运行刚刚看到的这些工具。

003:运行LLVM工具 🔧

在本节课中,我们将学习LLVM发行版中自带的三个核心工具:clangoptllc。我们将了解它们各自在编译器流水线中的角色,并通过简单的例子演示如何使用它们将C源代码转换为机器码。


编译器流水线回顾 🚀

上一节我们介绍了LLVM作为一个框架和工具集的整体概念。本节中我们来看看三个具体的工具。

一个编译器通常由三部分组成:

  1. 前端:负责解析源代码。
  2. 中端:负责代码分析和优化。
  3. 后端:负责生成目标机器代码。

LLVM的这三个工具分别对应这三个部分:

  • clang 是C语言前端。
  • opt 是中端优化器。
  • llc 是后端代码生成器。

Clang:C语言前端 ⚙️

clang 是LLVM的C语言前端。它的主要作用是将C语言源代码文件解析并转换为LLVM中间表示。

以下是一个简单的C程序示例,它实现了一个低效的前缀和算法:

// prefix_sum.c
int prefix_sum(int *arr, int n, int i) {
    int sum = 0;
    for (int j = 0; j <= i; j++) {
        sum += arr[j];
    }
    return sum;
}

我们可以使用 clang 将这个C程序转换为LLVM IR。转换命令如下:

clang -S -emit-llvm prefix_sum.c -o prefix_sum.ll

在这个命令中:

  • -S 标志指示生成汇编输出。
  • -emit-llvm 标志指示生成LLVM IR格式的“汇编”,而不是机器架构的汇编。
  • -o prefix_sum.ll 指定了输出文件的名称。

生成的 prefix_sum.ll 文件包含了LLVM IR代码,这是一种包含指令、变量和函数名的中间表示格式。


多样化的LLVM前端 🌈

需要理解的重要一点是,clang 只是LLVM众多前端中的一个。LLVM的设计优势在于,任何语言只要有一个前端将其转换为LLVM IR,就能获得整个LLVM框架的支持。

以下是其他一些语言的LLVM前端示例:

  • Rust:使用 rustc
  • Julia:使用Julia自带的编译器。
  • Swift:使用Swift编译器。

这种设计意味着,无论使用哪种编程语言,一旦代码被转换为LLVM IR,它就能受益于LLVM强大的中端分析和优化能力。


Opt:中端优化器 🛠️

上一节我们介绍了clang如何将源代码转换为LLVM IR。本节中我们来看看中端工具opt

opt 是LLVM的中端优化和转换工具。它接收LLVM IR,并可以对其应用各种分析和转换过程,例如优化性能、检测漏洞或进行性能剖析插桩。

例如,我们可以使用 opt 将LLVM IR文件转换为一种名为DOT的图形描述格式:

opt -dot-cfg prefix_sum.ll

这个命令会生成一个 .dot 文件。DOT是一种用于描述图形的文本格式,可以使用像Graphviz这样的工具将其可视化。生成的图形代表了程序的控制流图,这是一种编译器用于分析和优化程序的数据结构。它展示了程序中的所有指令以及指令之间可能的执行顺序。

虽然现在不需要深入理解这个图,但知道它代表了程序的一种关键内部表示形式就足够了。经过中端处理后的这种表示,将被传递给后端。


Llc:后端代码生成器 💻

经过前端和中端处理后,我们得到了优化过的LLVM IR。接下来,后端工具 llc 负责将这个与机器无关的IR映射到具体的目标架构

LLVM拥有众多后端,每个后端针对一种不同的处理器架构。以下是一些例子:

  • x86 / x86-64
  • ARM
  • PowerPC
  • MIPS

要查看你的LLVM发行版支持哪些架构,可以运行:

llc --version

输出会列出一个很长的目标架构列表。要为特定目标生成汇编代码,只需使用 -march 标志指定它。

例如,为x86-64架构生成汇编文件:

llc -march=x86-64 prefix_sum.ll -o prefix_sum_x86.s

或者,为ARM架构生成汇编文件:

llc -march=arm prefix_sum.ll -o prefix_sum_arm.s

这样,我们就得到了可以在相应目标处理器上汇编和执行的机器级汇编代码。


总结 📚

本节课我们一起学习了LLVM工具链中的三个核心独立工具:

  1. clang:作为C语言前端,将源代码转换为LLVM IR。
  2. opt:作为中端优化器,对LLVM IR进行各种转换和优化,并可以生成程序内部表示(如控制流图)以供分析。
  3. llc:作为后端代码生成器,将优化后的LLVM IR转换为特定目标处理器架构的汇编代码。

通过这三个工具的串联使用,我们完成了一个从C源代码到目标机器码的完整编译流程。在接下来的课程中,我们将更深入地探讨LLVM中间表示的细节。

004:LLVM中间表示(IR)🔧

在本节课中,我们将要学习LLVM中间表示(IR)。这是一种低级的程序表示语言,是LLVM编译器框架的核心。我们将了解IR是什么样子,它能做什么,以及如何通过工具链来操作它。

什么是LLVM IR?🤔

我们已经知道,一个编译器通常由三个模块组成:前端、中端和后端。在上一节中,我们了解到LLVM的一个可能前端是Clang,中端由名为opt的工具代表,后端则由名为llc的工具代表。

中端操作的对象就是用LLVM中间表示(IR)编写的程序。通常,当这种中间表示以可读的汇编格式给出时,我们给文件添加.ll扩展名。

这些.ll文件是从某些高级编程语言(例如C语言)翻译而来的。它们可以通过某个后端工具,被翻译成特定目标架构的代码。

LLVM的许多魅力和优雅之处都来自于它的中间表示。那么,这种编程语言到底是什么?它看起来怎么样?我们能用它做什么?这就是本节课的主题。

IR的基本特性 📝

首先,中间表示是一种编程语言,这意味着我们可以用它来编写程序。IR主要是为了由编译器自动生成而设计的,但这并不妨碍我们直接在IR中编写程序。

因为它旨在由编译器生成,所以它是低级的。因此,IR看起来很像一个汇编程序。我们可以使用LLVM基础设施中的工具来分析它,更重要的是,我们可以使用LLVM来优化它。

一个IR示例 🔍

让我们看一个例子。我将展示一个程序,并检查它在LLVM中间表示中的样子。这个程序取自Gennadiy Peytman在网上公开的演示文稿,该演示文稿还讨论了LLVM IR之外的许多其他内容,我强烈推荐大家阅读。

Gennadiy的程序包含一个通过指针接收参数并更新该内存位置的函数。为了使内容更完整,这里是调用该函数的代码。

为了生成IR格式的等效程序,我们可以使用以下命令行,我们在上一节课中已经见过它:

clang -S -emit-llvm code.c -o code.ll

注意,我们的示例存储在名为code.c的文件中,我们正在生成一个名为code.ll的文件。我们使用-S标志表示要生成汇编文件,否则会生成二进制程序。我们使用-emit-llvm来指定我们想要的是LLVM中间表示的汇编,否则会生成目标架构(本例中是x86)的汇编。

让我们看一下IR文件。首先,让我缩小源代码的显示尺寸,以便所有内容都能在屏幕上显示。

你会发现,与原始程序相比,IR文件在某种程度上显得更大。这是很自然的,因为我们对同一个程序使用了更低级的表示。请注意,我们甚至没有显示整个文件,为了专注于最有趣的部分,我们将省略其余内容。

注意IR文件中有两个函数,这是因为源文件中有两个函数。

深入分析IR代码 🧐

让我们看看LLVM为foo函数生成的代码。它就在这里。

这里有很多需要注意的地方。首先,程序是SSA(静态单赋值)格式。这意味着函数由一系列线性指令组成,每条指令都有一个操作码。操作码是指令所执行操作的名称。例如,这个程序使用了六条指令和五个不同的操作码。

另一个有趣的点是,LLVM IR是强类型的。这意味着程序操作的值具有类型。在这个例子中,我们有三种不同的类型:

  • 我们有32位整数,比如函数的返回值。
  • 我们有指向32位整数的指针,比如函数foo的参数x的类型。
  • 我们还有指向指针的指针。在这种情况下,指向指针的指针作为编译器实现程序所需的辅助变量出现。

优化IR代码 ⚡

注意,我们可以优化IR。例如,我们可能希望将变量从内存移动到寄存器中。为此,我们可以使用名为mem2regopt测试。

但要做到这一点,我们需要通过-Xclang参数向Clang表明,IR将被opt进一步转换。

如果你不使用这个标志,那么LLVM会在IR中的函数上添加optnone限定符,这将阻止程序被优化。

无论如何,在禁用这个标记(optnone限定符)之后,你就可以优化函数了。在这个例子中,我们使用了一个名为mem2reg的优化。你能通过思考它的名字来想象这个优化是做什么的吗?

基本上,这个优化会将栈上分配的变量移动到寄存器中。我们可以看到,我们目标函数中的变量是在栈上的,因为我们看到了操作内存的指令,比如allocastoreloadalloca通常意味着我们在栈上分配空间。

这是我们优化后的函数。注意,变量的加载现在是在寄存器中进行的。这些不是架构寄存器,而是虚拟寄存器。一旦生成机器代码,这些值就可以被放置在物理寄存器中。

这些变量的名称以百分号%开头,例如%0%1

直接操作IR ✍️

除了使用LLVM工具来操作IR,我们也可以直接对它进行编程。毕竟,它只是一个文本文件。

例如,假设我们想改变这个函数。我们不想通过指针传递参数,而是想直接传递值。我们当然可以用C语言重写程序,然后用LLVM生成优化版本。但我们也可以简单地通过编辑其文本文件来改变IR。

这就是我们修改后得到的等效代码。如你所见,这只是我们自己编写函数的问题。当然,我们还需要更改调用我们优化后函数的代码。我们将不再传递地址,而是直接传递值本身。

这就是我们手动优化后整个程序的样子。

转换到其他表示形式 🔄

我们可以将程序转发到其他表示形式。例如,假设这个包含所有LLVM IR文件所需信息的程序叫做callptr.ll

我们可以使用llvm-as将它编译成LLVM字节码。LLVM字节码是LLVM IR程序的二进制表示形式。

然后,我们可以使用llc(LLVM基础设施中的一个工具)将字节码翻译成汇编,例如x86汇编。

我们可以使用Linux汇编器as将x86汇编翻译成依赖于架构的二进制代码。

这样,我们就得到了同一个程序的四种不同表示形式。其中两种是二进制格式,另外两种是用汇编语言编写的,所以你可以阅读这些文件。这没有问题,但请注意,这四种表示形式具有相同的语义,它们代表同一个程序。

总结 📚

本节课中,我们一起学习了LLVM中间表示(IR)。我们了解到IR是一种低级的、强类型的、SSA格式的编程语言,它是LLVM编译器框架的核心。我们看到了一个IR代码的示例,学习了它的基本结构和类型系统。我们还探讨了如何使用opt工具(特别是mem2reg优化)来优化IR代码,以及如何直接编辑IR文本文件。最后,我们了解了如何将IR转换为其他表示形式,如字节码、汇编代码和最终的可执行二进制文件。

关于LLVM IR的更多信息,可以在语言手册中找到,你可以在参考资料中看到。此外,我向那些想更深入研究IR的人推荐Philip Devedo和Vince Bridges的YouTube教程。

005:使用LLVM进行程序可视化 📊

在本节课中,我们将学习如何使用LLVM编译基础设施来可视化程序。我们将重点介绍一个名为opt的工具,它是LLVM中间端优化器,可以用来生成程序的各种图形化表示,例如控制流图、支配树和调用图等。

使用OPT工具

上一节我们介绍了LLVM的整体架构。本节中我们来看看用于程序可视化的核心工具:opt

opt是LLVM发行版中自带的一个命令行工具,属于LLVM中间端(Middle End)的一部分。它接收LLVM中间表示(LLVM IR)格式的文件,并输出同样为LLVM IR格式的新文件。

你可以通过以下命令查看opt的版本信息:

opt --version

opt主要有三个用途:

  1. 可视化程序:生成程序结构的图形表示。
  2. 分析代码:例如,统计指令类型、循环数量或分析安全漏洞。
  3. 转换程序:运行各种优化或插入插桩代码。

本教程将首先聚焦于如何使用opt进行程序可视化。

生成控制流图(CFG)

控制流图是理解程序执行路径的基础。以下是生成CFG的步骤。

首先,我们需要一个C语言源文件(例如dag.c),并使用clang将其编译为LLVM IR格式:

clang -S -emit-llvm dag.c -o dag.ll

接着,使用opt-dot-cfg标志来生成DOT格式的控制流图文件:

opt -dot-cfg dag.ll

此命令会生成一个名为.dot的隐藏文件。DOT是一种通用的图形描述语言。

为了查看这个图形,我们需要使用dot工具(通常包含在Graphviz软件包中)将其转换为图像格式,如PNG:

dot .dot -Tpng -o cfg.png

在控制流图中:

  • 节点(基本块):代表一组总是顺序执行的指令序列。
  • :代表程序执行的可能路径。

其他可视化格式

除了详细的控制流图,opt还支持生成多种简化或不同侧重点的图形表示。

以下是opt支持的一些其他可视化选项:

  • 仅显示CFG结构:使用-dot-cfg-only标志,可以生成只包含基本块节点和边的简化图,有助于快速理解程序整体结构,例如识别循环。
    opt -dot-cfg-only dag.ll
    

  • 单入口单出口区域(SESE):程序区域是控制流图的子图,只有一个入口点和一个出口点。使用-dot-regions-dot-regions-only标志可以可视化这些区域及其嵌套关系。

    opt -dot-regions-only dag.ll
    
  • 支配树(Dominator Tree):支配树是一种重要的数据结构,用于计算程序区域。节点A支配节点B意味着从程序入口到B的任何执行路径都必须经过A。使用-dot-dom-dot-dom-only标志可以生成支配树。

    opt -dot-dom-only dag.ll
    
  • 调用图(Call Graph):调用图展示了函数之间的调用关系。节点代表函数,边表示调用关系(从调用者指向被调用者)。使用-dot-callgraph标志可以生成调用图。

    opt -dot-callgraph dag.ll
    

    例如,如果main函数调用了fact函数,图中就会有一条从main指向fact的边。如果fact是递归函数,图中则会有一个从fact指向自身的环。

你可以通过运行opt --help来查看你的LLVM版本所支持的所有可视化选项。

总结

本节课中我们一起学习了如何使用LLVM的opt工具进行程序可视化。我们介绍了如何生成控制流图来查看基本块和程序路径,以及如何生成简化结构图、程序区域图、支配树和调用图等多种表示形式。这些图形化工具是分析和理解程序内部结构的有力助手,为后续学习代码分析和优化奠定了基础。

关于LLVM IR和可视化更详细的信息,可以参考LLVM语言手册。

006:LLVM Passes简介 🧩

在本节课中,我们将要学习LLVM编译器的核心组成部分——Passes。我们将了解Passes是什么,它们有哪些不同类型,并通过具体例子来理解分析型Pass和转换型Pass是如何工作的。

编译器结构回顾

上一节我们介绍了编译器的基本结构。一个编译器通常由三个主要模块组成:前端、中端和后端。

所有Passes都位于中端。它们是编译器用来优化代码的主要工具。

在LLVM中,驱动Pass管道的工具叫做opt,它代表优化器。

Passes的类型

Passes主要分为两种类型。第一种叫做转换型Pass

顾名思义,这种Pass会以某种方式修改代码,并生成一个与原始代码语义相同但更快、更小或具有其他有用特性的新版本代码。

第二种Pass叫做分析型Pass。这种Pass完全不改变代码,而是生成分析信息,供其他Pass使用以简化它们的工作。

Passes的作用域

Passes有许多不同的类型,每种类型处理不同的作用域。以下是主要的几种:

  • 循环Pass:顾名思义,这种Pass分析或修改循环。例如,右侧示例代码中sum函数的循环就可能被循环Pass分析。
  • 函数Pass:这些Pass在程序的单个函数上运行。
  • 模块Pass:这些Pass一次处理整个模块。
  • 还有其他类型的Pass,例如调用图Pass、区域Pass、机器函数Pass等。

分析型Pass示例

现在,我们来看几个现有Pass的例子,以便了解它们能做什么。让我们从分析型Pass开始。

范围分析收集关于变量可能取值范围的信息。例如,在右侧代码中,i的取值范围是从1到100。

标量演化是一种非常强大的分析,可以描述变量在循环内部是如何演变的。这种分析可用于,例如,完全用计算total值的代码替换循环代码,即使你在编译时不知道循环边界。

支配树分析是编译器中一个非常有用的数据结构。基本上,这个数据结构告诉你代码的哪些部分肯定会在其他部分之前运行。

让我们看一个例子来让概念更具体。我们从一个代表函数入口的节点开始,这部分代码总是会执行。

然后我们为if块添加一个节点。由于它总是跟在入口代码之后,我们从入口节点向它添加一条边。

我们对else块做同样的事情。现在,我们在if-else块之后添加一个节点。

注意,无论是if块还是else块都不一定会在return语句之前执行。然而,入口块肯定会,所以我们从入口节点向这个节点添加一条边。

转换型Pass示例

现在,让我们看一些转换型Pass的例子。对于这些例子,请想象你正在编写Java编译器,你会如何自己优化这些代码示例?

我们将看到的第一个转换叫做死代码消除

如果你是一个编译器,你会如何优化这段代码?因为1总是小于2,所以我们可以移除比较。同时,我们也可以完全移除if块。这被称为死代码消除。如果一段代码永远不会被执行,它就被称为死代码。

接下来,让我们谈谈常量传播。想象一下我们的程序中有这段代码。

你会如何优化它?例如,a的值是10ba加上20,但我们知道a总是10,所以结果总是30。同样,cb乘以5,但我们已经知道这个表达式在编译时的结果,所以我们也可以用结果替换它。

接下来,让我们看看循环不变代码外提。看一下示例代码。你能做什么来优化它?

注意,x的计算在循环的每次迭代中总是相同的。所以我们可以把它从循环内部移除,并移到循环本身之前。

总结

本节课中我们一起学习了LLVM Passes的基础知识。我们了解到Passes是LLVM中端进行代码优化的核心单元,主要分为分析型Pass和转换型Pass。分析型Pass(如范围分析、标量演化、支配树分析)负责收集代码信息而不修改它;转换型Pass(如死代码消除、常量传播、循环不变代码外提)则利用这些信息来优化和重构代码,使其更高效。我们还了解了Passes可以作用于不同作用域,如循环、函数或整个模块。下一节,我们将学习如何编写一个非常基础的分析型Pass。

007:编写LLVM分析(第一部分)📚

概述

在本节课中,我们将学习如何编写一个自定义的LLVM分析(Analysis)。我们将从项目结构开始,创建分析所需的头文件,并定义其入口点。本教程分为两部分,这是第一部分,主要关注分析的结构搭建。


项目结构 🗂️

上一节我们介绍了分析与转换的区别,本节我们来看看如何构建一个分析项目。编写LLVM分析时,有两种主要方式:在LLVM源代码树内工作,或在外部构建独立的工具。本课程选择第二种方式,以便后续使用LLVM命令行库构建我们自己的工具。

以下是项目的基本目录结构:

  • include/:存放头文件的文件夹。
  • lib/:存放源代码文件的文件夹。
    • CMakeLists.txt:用于创建库的CMake文件。
  • build/:用于存放编译生成的库和二进制文件的文件夹。

项目源代码已公开在GitHub上,您可以随时查阅。


主CMake文件配置 ⚙️

让我们从主CMakeLists.txt文件开始。我将聚焦于核心部分,完整代码可在GitHub查看。

首先,我们定义CMake所需的最低版本和项目名称。

cmake_minimum_required(VERSION 3.13.4)
project(AddConst)

接着,定义一个变量LLVM_INSTALL_DIR,它指向LLVM的安装路径。这个变量将通过命令行传递给CMake,用于定位LLVM的CMake配置文件。

set(LLVM_INSTALL_DIR "" CACHE PATH "Path to LLVM installation")

然后,包含LLVM的头文件和库路径。

list(APPEND CMAKE_PREFIX_PATH "${LLVM_INSTALL_DIR}")
find_package(LLVM REQUIRED CONFIG)

接下来,设置使用的C++版本。LLVM通常使用C++17构建,我们保持一致性。

set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

您也可以定义所需的编译器标志。

最后,设置库文件的输出路径,并包含我们的lib目录。

set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR})
add_subdirectory(lib)

库CMake文件配置 📦

现在,让我们看看lib/目录下的CMakeLists.txt文件。它比主文件简单得多。

我们只需要设置库的源文件,并让include目录可见,以便使用我们的头文件。

set(SOURCES AddConst.cpp)
add_library(AddConst MODULE ${SOURCES})
target_include_directories(AddConst PUBLIC ${CMAKE_SOURCE_DIR}/include)

分析头文件设计 🧠

目前,LLVM有两个Pass管理器:旧版(Legacy)和新版(New Pass Manager)。新版Pass管理器的文档虽然尚未完全覆盖所有场景,但正在不断完善。由于它是LLVM的一个特性,本教程将使用新版Pass管理器。

新版Pass管理器的实现使用了一些清晰的设计模式。虽然编写Pass不一定需要深入了解它们,但LLVM文档中有提及。以下是两个您可能想了解的模式:奇异递归模板模式(CRTP)和混入(Mixin)模式。您可以从维基百科等资源开始查阅。

我们的LLVM分析是一个结构体(或类),它以AnalysisInfoMixin作为基类。这里可以很容易地看到上述两个设计模式的应用。

为了节省空间,我将省略一些代码。首先,我们需要定义分析将返回什么结果。在我们的案例中,将返回一个指令列表。这里我使用了SmallVector,它是LLVM提供的一个高效数据结构。LLVM有许多优秀的数据结构,我们可以在文档中详细了解。

// 分析结果:一个包含常量加法指令的列表
using Result = llvm::SmallVector<llvm::BinaryOperator *, 4>;

在我们的案例中,我们将收集加法指令(add),它们属于二元运算符(BinaryOperator)。

在新版Pass管理器中,每个Pass(无论是分析还是转换)都必须实现一个名为run的方法。该方法接收两个参数,其类型取决于Pass的工作范围。我们的分析将在函数(Function)级别工作,因此第一个参数的类型是Function,第二个是FunctionAnalysisManager,用于请求我们所需的其他分析的结果。

// 核心分析方法
Result run(llvm::Function &F, llvm::FunctionAnalysisManager &FAM);

最后,一个分析还必须有一个AnalysisKey,它提供了一个基于地址的标识符,用于唯一识别该分析。


定义打印Pass 🖨️

除了分析Pass,我们还将定义一个非常简单的转换Pass(实际上仅用于打印)。这个Pass要做的事情就是请求并打印分析的结果,主要用于调试目的。本课程稍后将会看到一个真正的转换Pass示例。

一个转换Pass也必须有一个run方法,接收两个参数,与分析Pass类似。唯一的区别是返回类型:转换Pass总是返回一个PreservedAnalyses集合,表明哪些分析结果在转换后仍然有效。在我们的案例中,所有分析都将被保留,因为我们不进行任何实际转换。

// 打印Pass的run方法
llvm::PreservedAnalyses run(llvm::Function &F,
                            llvm::FunctionAnalysisManager &FAM);

同时,我们定义一个私有字段作为输出流,以及一个初始化该输出流的构造函数。

private:
  llvm::raw_ostream &OS;
public:
  explicit PrintAddConstPass(llvm::raw_ostream &OS) : OS(OS) {}

这样,我们就完成了头文件的骨架设计。


实现OPT插件入口点 🔌

现在,我们将为LLVM OPT工具实现插件的入口点。

首先,使用LLVM的命名空间。然后,定义一个返回插件信息的函数。这些信息包括插件版本、插件名称、LLVM版本以及用于正确注册Pass的回调函数。

extern "C" LLVM_ATTRIBUTE_WEAK ::llvm::PassPluginLibraryInfo
llvmGetPassPluginInfo() {
  return {
      .APIVersion = LLVM_PLUGIN_API_VERSION,
      .PluginName = "AddConst",
      .PluginVersion = "v0.1",
      .RegisterPassBuilderCallbacks = &registerPassBuilderCallbacks,
  };
}

首先,注册我们的分析Pass,以便其他Pass可以请求它。然后,注册一个打印Pass,以便可以通过OPT使用它。这两个注册方法都需要回调函数。

为了注册一个分析,我们需要一个分析管理器(在我们的案例中是FunctionAnalysisManager),并用它来注册我们的AddConstAnalysis实例。

// 注册分析Pass的回调
void registerAnalysisCallback(llvm::FunctionAnalysisManager &FAM) {
  FAM.registerPass([&] { return AddConstAnalysis(); });
}

我们还需要实现将插件注册到LLVM OPT管道的回调函数。它接收三个参数:Pass名称、Pass管理器和一个管道元素数组(目前暂不使用)。

// 注册到OPT管道的回调
bool registerPassBuilderCallbacks(llvm::PassBuilder &PB,
                                  llvm::ArrayRef<llvm::PipelineElement>) {
  PB.registerAnalysisRegistrationCallback(registerAnalysisCallback);
  // ... 其他注册
  return true;
}

我们将打印Pass引用为print-add-const。如果传入的Pass名称是这个值,那么我们就将一个打印Pass的实例添加到Pass管理器中,并返回true

if (Name == "print-add-const") {
  FPM.addPass(PrintAddConstPass(llvm::errs()));
  return true;
}

请注意,我们的打印Pass构造函数接收一个输出流。这里我们使用了llvm::errs(),它是一个标准错误输出流。否则,返回false,因为没有匹配的Pass。

还有最后一件事需要做,那就是实现LLVM OPT工具的实际入口点。这非常简单,我们只需让llvmGetPassPluginInfo函数返回插件信息即可。

// OPT插件入口点
extern "C" LLVM_ATTRIBUTE_WEAK ::llvm::PassPluginLibraryInfo
llvmGetPassPluginInfo() {
  // ... 返回插件信息结构体
}

总结

本节课中,我们一起学习了如何为编写自定义LLVM分析搭建项目骨架。我们配置了CMake项目结构,设计了分析Pass和打印Pass的头文件,并实现了LLVM OPT插件的注册入口点。在下一部分,我们将完成分析Pass的具体实现,并看一个实际运行的例子。如有任何疑问,欢迎提出。

008:编写LLVM分析(第二部分)🔍

在本节课中,我们将完成一个LLVM分析(Analysis)的实现,并运行它以验证结果。我们将学习如何遍历函数中的指令,筛选出仅使用常量整数的加法操作,并最终输出分析结果。

上一节我们介绍了分析项目的结构和插件入口点。本节中,我们将深入实现分析逻辑的核心部分。

实现分析逻辑

首先,在代码中包含必要的头文件,并使用之前定义的命名空间。为了简化,我们也使用LLVM的命名空间。

#include "AddConst.h"
using namespace llvm;
namespace addconst {

分析的第一步是初始化分析键(Analysis Key)。请注意,此处的值并不重要,因为LLVM内部使用该变量的地址作为唯一标识符。

AnalysisKey AddConstAnalysis::Key;

接下来,我们需要实现 run 方法。此方法接收一个函数 F 和一个函数分析管理器 FAM,并返回我们收集的指令列表。

AddConstAnalysis::Result AddConstAnalysis::run(Function &F, FunctionAnalysisManager &FAM) {
    std::vector<BinaryOperator*> CollectedInsts;

以下是分析逻辑的主要步骤:

  1. 遍历基本块和指令:我们首先遍历函数 F 中的所有基本块(Basic Block),然后遍历每个基本块中的所有指令。
  2. 筛选二元操作符:检查当前指令是否为二元操作符(Binary Operator)。如果不是,则跳过。
  3. 筛选加法操作:检查该二元操作符的操作码(Opcode)是否为加法(Instruction::Add)。
  4. 检查常量操作数:调用一个辅助函数 isConstantOnly,检查该加法的所有操作数是否都是常量整数(ConstantInt)。
  5. 收集指令:如果满足以上所有条件,将该指令转换为 BinaryOperator 类型,并添加到结果列表中。
    for (auto &BB : F) {
        for (auto &I : BB) {
            // 检查是否为二元操作符
            if (!I.isBinaryOp()) continue;
            // 检查是否为加法操作
            if (I.getOpcode() != Instruction::Add) continue;
            // 检查操作数是否均为常量整数
            if (!isConstantOnly(&I)) continue;
            // 收集该指令
            CollectedInsts.push_back(cast<BinaryOperator>(&I));
        }
    }
    return CollectedInsts;
}

实现辅助函数

现在,我们需要实现用于检查指令操作数是否全为常量整数的辅助函数 isConstantOnly

bool isConstantOnly(Instruction *I) {
    // 遍历指令的所有操作数
    for (auto &Op : I->operands()) {
        // 检查操作数是否为 ConstantInt 类型
        if (!isa<ConstantInt>(Op)) {
            return false; // 如果有一个不是,则返回 false
        }
    }
    return true; // 所有操作数都是常量整数
}

实现结果打印器(Printer Pass)

为了查看分析结果,我们需要实现一个打印器(Printer Pass)。它也是一个LLVM Pass,负责获取分析结果并格式化输出。

PreservedAnalyses AddConstPrinterPass::run(Function &F, FunctionAnalysisManager &FAM) {
    // 从分析管理器获取 AddConstAnalysis 的结果
    auto &CollectedInsts = FAM.getResult<AddConstAnalysis>(F);
    
    // 打印函数名
    auto &OS = llvm::errs();
    OS << "Function: " << F.getName() << "\n";
    
    // 遍历并打印收集到的指令
    for (auto *BI : CollectedInsts) {
        OS << "  " << *BI << "\n";
    }
    
    // 声明此Pass保留了所有其他分析结果
    return PreservedAnalyses::all();
}

编译与运行

实现完成后,我们需要编译整个项目并运行分析。

  1. 确保位于项目根目录
  2. 使用CMake配置项目:在配置时,通过命令行传入LLVM的安装路径。
    cmake -B build -DLLVM_DIR=/path/to/llvm/installation/lib/cmake/llvm -G Ninja
    
  3. 编译项目:进入构建目录并使用 makeninja 进行编译。
    cd build
    ninja
    

创建测试用例

在运行分析之前,我们创建一个LLVM IR(.ll)文件作为测试用例。

; examples/test.ll
define i32 @foo(i32 %a, i32 %b) {
  %c = add i32 1, 2        ; 常量加法
  %d = add i32 3, 4        ; 常量加法
  %e = add i32 %a, %b      ; 非常量加法
  %f = add i32 %c, %e      ; 混合操作数
  ret i32 %f
}

运行分析

使用 opt 工具加载我们编译好的插件,并运行打印器Pass。

opt -load-pass-plugin ./build/libAddConstPlugin.so -passes="print<add-const>" -disable-output ./examples/test.ll

运行后,输出应类似于:

Function: foo
  %c = add i32 1, 2
  %d = add i32 3, 4

这表明我们的分析成功识别并收集了函数 foo 中仅使用常量整数的两个加法指令。

总结

本节课中我们一起学习了如何完整实现一个LLVM分析(Analysis)。我们完成了以下工作:

  1. 实现了核心的 run 方法,用于遍历指令并筛选出目标操作。
  2. 编写了辅助函数来检查指令的操作数属性。
  3. 实现了一个打印器Pass,用于直观地展示分析结果。
  4. 编译了项目,并创建测试用例验证了分析的正确性。

我们成功构建了一个可以收集函数中“常量整数加法指令”的分析工具。在下一节课中,我们将学习如何基于此分析结果,实现一个转换(Transformation)Pass来优化代码。

009:编写LLVM转换Pass 🛠️

在本节课中,我们将学习如何基于之前编写的分析Pass,实现一个实际的LLVM转换Pass。我们将把仅使用常量的加法指令在编译时计算出来,并从程序中移除。

概述

上一节我们介绍了如何编写一个LLVM分析Pass,用于收集函数中仅使用常量的加法指令。本节中,我们来看看如何利用这个分析结果,实现一个转换Pass来优化程序。

实现转换Pass

首先,我们需要更新头文件,添加一个新的转换Pass结构体。这与我们上次定义的AddConstPrinterPass非常相似,但这个结构体不需要任何私有变量。

struct AddConstTransformPass : public PassInfoMixin<AddConstTransformPass> {
  PreservedAnalyses run(Function &F, FunctionAnalysisManager &FAM);
};

接下来,我们创建一个新文件来实现我们的转换Pass。记得同时更新你的CMakeLists.txt文件以包含它。

与我们的分析Pass类似,转换Pass也需要一个run函数。这个函数返回一个PreservedAnalyses对象,该对象表示在转换Pass运行后,哪些分析结果仍然有效。我们稍后会详细解释这一点。

以下是run函数的主要步骤:

  1. 获取我们之前编写的分析Pass的结果,即收集到的指令向量。
  2. 遍历收集到的加法指令,并对每条指令调用一个我们将要定义的replaceAddInstWithConstant函数。

PreservedAnalyses AddConstTransformPass::run(Function &F, FunctionAnalysisManager &FAM) {
  auto &AddInsts = FAM.getResult<AddConstAnalysis>(F);
  for (auto *AddInst : AddInsts) {
    replaceAddInstWithConstant(AddInst);
  }
  // ... 处理PreservedAnalyses
}

处理保留的分析

现在,我们来处理PreservedAnalyses对象。我们首先创建一个表示所有分析都被保留的对象。

PreservedAnalyses PA = PreservedAnalyses::all();

然后,我们明确放弃AddConstAnalysis,因为我们从程序中删除了收集到的加法指令。这意味着任何其他需要AddConstAnalysis结果的Pass都会导致该分析重新运行。

PA.abandon<AddConstAnalysis>();

最后,我们返回这个PreservedAnalyses对象。

核心转换函数

现在,让我们看看replaceAddInstWithConstant函数,这是真正完成工作的地方。

该函数执行以下操作:

  1. 提取加法指令的第一个操作数,并将其转换为常量整数类型。
  2. 对第二个操作数执行相同的操作。
  3. 计算它们的和,并存储在一个变量中。
  4. 用我们刚刚计算出的和,替换该加法指令结果的所有使用处。
  5. 从程序中擦除这条加法指令。
void replaceAddInstWithConstant(Instruction *AddInst) {
  ConstantInt *Op1 = dyn_cast<ConstantInt>(AddInst->getOperand(0));
  ConstantInt *Op2 = dyn_cast<ConstantInt>(AddInst->getOperand(1));
  APInt Sum = Op1->getValue() + Op2->getValue();
  AddInst->replaceAllUsesWith(ConstantInt::get(AddInst->getContext(), Sum));
  AddInst->eraseFromParent();
}

注册转换Pass

最后一步是注册我们的Pass。以下是我们上次简化版的注册管道函数。我们插入一个检查,如果Pass名称是add-const-transform,就将我们的转换Pass加入管道。

llvm::PassPluginLibraryInfo getPassPluginInfo() {
  return {LLVM_PLUGIN_API_VERSION, "MyPasses", "1.0",
          [](PassBuilder &PB) {
            PB.registerPipelineParsingCallback(
                [](StringRef Name, FunctionPassManager &FPM,
                   ArrayRef<PassBuilder::PipelineElement>) {
                  if (Name == "add-const-printer") {
                    FPM.addPass(AddConstPrinterPass(errs()));
                    return true;
                  }
                  if (Name == "add-const-transform") { // 新增
                    FPM.addPass(AddConstTransformPass());
                    return true;
                  }
                  return false;
                });
          }};
}

运行与验证

让我们在之前的示例文件上运行我们的转换Pass。我们使用opt工具来运行Pass。

首先,加载我们的插件,然后指定要运行的Pass名称。-S标志表示我们希望输出为LLVM IR文本格式。我们指定输入文件和输出文件。

运行第一次转换后,程序中的cd被移除,定义f的加法指令的操作数也发生了变化。但我们可以更进一步,注意到此时f也可以在编译时计算并移除。

让我们重新运行Pass,但这次指定第一次转换的输出作为输入。最终,我们得到了一个完全优化后的版本,所有仅涉及常量的加法都被预先计算了。

虽然我们可以设计我们的转换Pass来迭代函数直到没有更多的加法需要移除,但这个例子演示了在编译程序时,一个真实的转换Pass可能会被多次调用。

总结

本节课中我们一起学习了如何在LLVM上编写一个转换Pass。我们基于之前的分析Pass,实现了一个常量传播优化,将编译时可确定的加法指令替换为计算结果。这种常量传播实际上由LLVM自动完成,即使没有启用任何优化选项。但我们认为这是一个很好的例子,展示了如何使用LLVM的基础设施来编写你自己的Pass。

在下一节视频中,我们将学习如何编写LLVM后端。欢迎提出任何评论或问题。

010:编写基于LLVM的工具 🛠️

概述

在本节课中,我们将学习如何利用LLVM的命令行API,编写一个独立的、基于LLVM的工具。这个工具将应用我们之前课程中开发的优化Pass,以替代使用opt工具的方式。

在之前的课程中,我们学习了LLVM Pass的基础知识,包括如何编写一个简单的分析Pass,以及如何实现一个转换Pass来使用该分析的结果。这为你开始编写自己的分析和优化提供了基础。本节中,我们将探索LLVM的其他功能,特别是其命令行接口。

回顾:常量加法优化

首先,简要回顾一下我们的分析和转换Pass。我们的分析Pass会收集程序中所有仅使用常量操作数的加法指令。例如,在下面的代码片段中,前两个加法指令只使用了常量操作数。

%1 = add i32 5, 3
%2 = add i32 %1, 10
%3 = add i32 %x, %y

因此,分析Pass的结果将至少包含前两条指令。随后,我们的转换Pass会利用这个列表,在编译时计算这些加法的结果,并用其最终值替换原来的指令。

项目结构

当前我们的项目结构如下:

  • 一个 CMakeLists.txt 文件。
  • 一个 include 文件夹,用于存放头文件。
  • 一个 lib 文件夹,用于存放源代码文件。
  • 一个 build 文件夹,用于存放构建生成的文件。

为了创建新工具,我们将在项目根目录下创建一个名为 tools 的新文件夹,并在其中添加源代码文件。

编写CMake构建文件

首先,我们创建工具的CMake构建文件。这个文件相当简单。

以下是需要完成的步骤:

  1. 添加一个可执行文件目标,我们将其命名为 add-const,并列出其所需的源文件。
  2. 将我们的可执行文件与所有必需的LLVM库和我们的Pass库进行链接。
  3. 包含我们的头文件目录,以便工具能够使用我们编写的Pass。

add_executable(add-const add-const.cpp)
target_link_libraries(add-const LLVMSupport LLVMCore LLVMBitReader LLVMBitWriter LLVMIRReader LLVMAsmParser LLVMTransformUtils MyPasses)
target_include_directories(add-const PRIVATE ${CMAKE_SOURCE_DIR}/include)

实现工具代码

现在,我们来看实际的C++实现。代码从定义命令行选项开始。

我们首先创建一个选项类别,并定义两个选项:

  • 第一个是输入文件选项,它是必需的。
  • 第二个是输出文件选项,我们将其设为可选,并提供一个默认值 output.ll

定义完选项后,我们进入 main 函数。在 main 函数中,我们首先隐藏不相关的命令行选项,只向用户显示我们定义的选项。接着,我们解析命令行参数并尝试读取输入文件。如果出现错误,我们打印错误信息并返回。

成功读取输入后,我们开始应用Pass。这个过程类似于我们在 opt 工具中实现的功能。

以下是核心步骤:

  1. 创建我们的转换Pass实例和一个函数Pass管理器。
  2. 将转换Pass添加到函数Pass管理器中。
  3. 创建一个函数分析管理器,并在其中注册我们的分析Pass,以便转换Pass后续能够使用它。
  4. 创建一个Pass构建器,用于注册默认的函数分析Pass以及我们的自定义分析Pass。
  5. 遍历模块中的所有函数,并运行Pass管理器来应用我们的优化。

最后,我们将转换后的模块写入指定的输出文件。如果一切顺利,我们将得到一个优化后的LLVM IR文件,其中常量加法已被消除。

构建与测试

现在让我们构建这个项目。本节使用的是LLVM 11版本。生成构建文件后,我们进入 build 目录编译项目。

编译完成后,在 build/tools 文件夹中会生成我们的可执行文件 add-const。我们可以用它来转换我们的示例代码。

使用工具后,对比转换前后的代码可以看到,左侧是原始文件,右侧是转换后的文件。工具成功地将前两个仅使用常量的加法指令替换为了它们的计算结果。

总结

本节课中,我们一起学习了如何利用LLVM的命令行API编写一个独立的工具。我们回顾了常量加法优化的原理,设置了项目的CMake构建系统,实现了工具的核心代码逻辑,并最终成功构建和测试了该工具。通过这个过程,你掌握了将自定义LLVM Pass封装成易用工具的方法,这为集成更复杂的优化到你的工作流中奠定了基础。

011:LLVM循环分析 🔄

在本节课中,我们将学习如何使用LLVM中一个现成的、功能强大的分析工具——循环分析。我们将通过一个具体的代码示例,了解如何获取循环信息并利用这些信息进行程序转换。

概述

在之前的视频中,我们讨论了相关Pass的概念、使用方法以及如何编写它们。为了结束这个入门系列,现在我们将展示一个使用现成LLVM分析功能的完整示例。

循环分析简介

我们已经知道,LLVM中间表示(IR)是由一系列Pass构成的。这些Pass可以是分析(Analysis)或转换(Transformation)。尽管分析Pass和转换Pass的代码结构非常相似,但它们存在一些关键区别。例如,转换Pass可以修改代码,而分析Pass则不能。它们的结果也不同:分析Pass返回收集到的信息,而转换Pass则返回一组不受代码更改影响的分析结果。

LoopInfo分析Pass

LLVM自带了一个现成的分析Pass,称为LoopInfo分析。这个循环分析返回一个名为LoopInfo的结构,它存储了一个函数中的所有循环信息。一旦我们获得了对循环的访问权限,就可以获取大量有用的信息,例如:

  • 循环头(Loop Header)
  • 循环锁存块(Loop Latch),即有一条指向循环头的回边的块
  • 退出中块(Exiting Blocks),即拥有指向循环外边的块
  • 退出块(Exit Blocks),即退出中块的目标块
  • 以及其他更多信息

接下来,我将展示我参与开发的一个项目中的一段代码,它使用了与循环相关的信息。首先,让我简要介绍一下这个项目的背景。

项目背景:LAF

LAF是“Low La Zran”的缩写。这里的“Zran”指的是程序的运行时间。如果一个程序无论输入什么,总是以相同的顺序执行相同的操作和内存访问,我们就说它是“Zran”的。这保证了其运行时间不依赖于输入。这种恒定时间特性对于加密实现尤其重要,因为它们必须免受侧信道攻击。侧信道攻击是一种可以被对手利用来获取敏感信息的漏洞。

如果你想了解更多关于这个项目的信息,可以查看我的网页,我也会在视频描述中留下链接。你也可以阅读我们在CGO 2021上发表的论文,其中详细解释了侧信道攻击。在发表那篇论文时,我们的原型完全无法处理循环,要求所有循环都必须被展开。

但从那时起,我们一直在扩展这个原型,以处理具有恒定边界的循环。这就是我今天要展示的代码片段所涉及的内容。

代码示例:prepareLoop函数

我将展示的这个函数的目标是对循环进行预处理,以便我们后续能够处理它们。

假设我们有一个循环,包含一个循环头和一个循环体。循环体内有一个条件语句,如果条件为真则退出循环,否则继续执行循环体的第二部分。循环末尾有一个循环条件,它有一条回边和一条指向退出块的边。我们希望消除循环内的这个条件语句,因为我们需要保证循环总是执行相同次数的迭代。

为了实现这一点,我们需要在循环头中添加一个Phi函数,来跟踪条件语句是否被触发。这就是我将展示的函数主要做的事情。此外,它还会收集一些信息供后续使用。

Phi函数存在于SSA形式中,用于合并变量。在本例中,Phi函数的结果在程序首次到达循环头时为false,之后每当从循环锁存块到达循环头时,其值将取决于条件语句的结果。

函数详解

这个函数名为prepareLoop,它接收一个LoopInfo和一个上下文(Context),并返回一个LoopWrapper。这个LoopWrapper是一个自定义结构,我们将在其中存储循环信息以及一些额外数据。如前所述,LoopInfo是循环分析的结果。因此,在代码库的某个地方,我们必须有类似下面的代码来请求LoopInfo的结果:

LoopInfo &LI = getAnalysis<LoopInfoWrapperPass>().getLoopInfo();

首先,我们初始化LoopWrapper。然后,定义一些常量,用于插入那些Phi函数。这些常量包括Phi函数的类型和初始值。

现在,我们将遍历所有循环,为此我们将使用LoopInfo

for (Loop *L : LI) {
    // 处理每个循环
}

接着,我们获取循环头和循环锁存块。我们假设锁存块是唯一的,因此循环只有一条回边。这主要是为了简化。有一个名为loop-simplify的算法Pass可以保证这一点。我们还假设循环是旋转形式(rotated form),这可以通过运行loop-rotate Pass来实现。一个旋转后的循环基本上是一个do-while风格的循环,条件在末尾。

我们保存循环边以供后续使用。接下来,我们获取退出中块(exiting blocks),以及循环头中我们将要插入那些Phi函数的位置。

然后,我们访问每个退出中块(除了循环边,因为一旦它的条件语句为真,循环就结束了)。我们获取退出中块的终止指令,它是一个条件语句。接着,我们获取条件值,并创建一个Phi函数。然后,我们遍历循环头的前驱块,用正确的传入值填充这个Phi函数。我们还将这个函数保存在我们的LoopWrapper结构中。

之后,我们获取退出块(exit blocks),即循环外的目标块,并将它们也保存起来。最后,我们返回LoopWrapper

代码概览

这就是我们代码的样子。如你所见,整个prepareLoop函数并不大,只有29行代码。无论如何,你可以在GitHub仓库中找到它,我会在视频描述中留下链接。

示例说明

为了说明,考虑以下函数。它接收一个数组A和一个整数K,并遍历数组A,尝试搜索这个整数K。它被翻译成如下的LLVM IR表示:

  • 入口块,跳转到循环头。
  • 循环头,获取循环归纳变量i的值。
  • 循环体,从数组A的位置i加载值,并检查它是否等于k
  • 如果比较结果为真,控制流转到循环退出并返回1。
  • 否则,程序移动到循环锁存块,在那里更新归纳变量并评估循环条件。

在这个循环中,我们有一个条件语句。我们需要在循环头中添加一个Phi函数,它与循环体中的条件语句相关。它被初始化为false,因为一旦它变为true,就意味着循环应该结束。

总结

本节课中,我们一起学习了如何使用LoopInfo分析来获取一个函数中的所有循环信息,包括嵌套循环。一旦我们能够访问每个循环,就可以获取各种信息,如循环头、锁存块、退出块和退出中块,以及循环是否处于旋转形式等等。

本视频的目标是向你展示,LLVM已经内置了许多现成的、非常有用的工具。这个视频结束了入门系列。相关的链接、GitHub仓库和论文都在视频描述中。感谢观看,如果你有任何问题,请随时在下方留言。

012:区域分析 🔍

在本节课中,我们将要学习LLVM执行的一种特定静态分析,称为区域分析。该分析将构成程序的基本块分组为所谓的“单入口单出口区域”。我们将了解其基本概念,并学习如何调用一个LLVM Pass来遍历和分析这些区域。

概述

区域分析将程序的控制流图(CFG)中的基本块分组为嵌套的、单入口单出口的区域。这种结构对于许多编译器优化和分析任务非常有用,例如自动并行化、快速构建程序依赖图以及检测时序侧信道。

什么是区域? 🧩

首先,我们需要理解什么是区域。一个区域是控制流图中的一个子图,它满足以下条件:

  • 单入口:只有一个基本块可以从区域外部进入。
  • 单出口:只有一个基本块可以退出到区域外部。

例如,如果一个图是程序的控制流图,我们可以识别出多个这样的区域。整个CFG本身就是一个区域,其入口是函数的入口节点,出口是函数的出口节点。在这个大区域内,可以嵌套更小的子区域,例如一个循环结构。子区域内部还可以包含其他子区域。

这些就是非平凡的单入口单出口区域。我们简称它们为“C区域”。未展示的其他C区域是平凡的,因为它们只包含一个基本块(即一个节点)。你可以自行验证,每个C区域确实都满足单入口和单出口的条件,即使对于内部包含循环的区域也是如此。

区域的用途 🛠️

上一节我们介绍了区域的定义,本节中我们来看看区域分析的实际用途。虽然本课程的目标是展示如何调用LLVM Pass,但了解其用途有助于理解其重要性。

将基本块分组为区域对许多任务非常有用:

  • 自动并行化:识别可以并行执行的代码区域。
  • 快速构建程序依赖图:帮助构建程序的依赖关系。
  • 函数提取:利用控制依赖关系从函数内部提取出子函数。
  • 侧信道检测:一个非常酷的应用是,我们可以利用这些概念来检测程序中与时间相关的侧信道漏洞。

如果你想了解如何使用C区域来检测侧信道,我推荐一篇论文,其中包含了基于LLVM构建的实现。

程序结构树 🌳

LLVM为我们提供了一个数据结构,将区域组织成一棵树。这棵树被称为程序结构树。计算程序结构树的算法可在一篇1994年的论文中找到。

为了说明LLVM提供了什么,请考虑下面这个程序。程序具体做什么并不重要,我们只关心其结构。

这是程序的控制流图。我们并不太关心每个基本块内的指令,只关注由基本块构成的图结构。

LLVM会将基本块分组为嵌套的C区域。你可以在右侧看到这些区域。

这种将块分组为区域的操作是由称为区域分析的Pass完成的。

实现一个区域遍历Pass 💻

现在,让我们来实现一个遍历LLVM提供给我们的区域的Pass。这是我们的分析Pass的头文件。整个头文件并不大。顺便说一下,我将使用传统的Pass管理器。

对于这个例子,我们只需要实现一个类,我称之为RegionsEX。它只包含少数几个方法。

我们需要实现其中的两个方法:runOnFunctiongetAnalysisUsage。让我们从更简单的后者开始。

实现 getAnalysisUsage

getAnalysisUsage 方法本身只有两行。第一行向LLVM说明我们将使用区域分析,这个分析由 RegionInfoPass 类提供给我们。另一行只是说明我们保留了LLVM迄今为止收集的所有信息,即我们的Pass不会以任何方式转换代码。

记住,我们必须导入 RegionInfo 以便能够访问 RegionInfoPass。我们可以在CPP文件中完成这个操作。

实现 runOnFunction

现在,让我们回到头文件。还需要为这个分析实现 runOnFunction 方法。我们想要做的就是打印出构成函数的每个区域的信息。

以下是 runOnFunction 方法的实现。它包含四行代码,我们将逐一讲解。

bool runOnFunction(Function &F) override {
    auto &RI = getAnalysis<RegionInfoPass>().getRegionInfo();
    Region *TopRegion = RI.getTopLevelRegion();
    visitRegion(TopRegion);
    return false; // 我们没有修改代码
}
  1. 首先,我们获取一个对 RegionInfo 类的引用。这是第一行代码的作用。
  2. 然后,我们获取一个指向区域树根节点的指针。我们通过捕获区域树根的引用来做到这一点,使用 RegionInfogetTopLevelRegion 方法。
  3. 接着,我们需要访问这些区域。我们将递归地访问它们,就像遍历一棵树一样。
  4. 最后,方法返回 false,表示此Pass没有修改函数。

实现 visitRegion 方法

以下是 visitRegion 方法。它也比较简短,让我们看看它的各个部分。

void visitRegion(Region *R) {
    printLog(R);
    for (auto &SubRegion : *R) {
        visitRegion(SubRegion.get());
    }
}

首先,它打印关于区域 R 的信息。我将在后面展示这个 printLog 方法。请注意,在我们处理完区域 R(即打印区域信息)之后,我们将遍历其子区域。换句话说,我们遍历区域中的子区域列表,并对所有这些子区域递归调用 visit

实现 printLog 方法

printLog 方法打印关于区域的信息,正如我之前所说。以下是我写的实现。这个实现的目标只是向你展示操作区域的LLVM API的不同部分。

void printLog(Region *R) {
    errs() << "Region Name: " << R->getNameStr() << "\n";
    errs() << "Is Top Level? " << (R->isTopLevelRegion() ? "Yes" : "No") << "\n";
    errs() << "Region Nesting Level: " << R->getDepth() << "\n";

    if (BasicBlock *Entry = R->getEntry())
        errs() << "Entry Block: " << Entry->getName() << "\n";

    errs() << "Contains Blocks: ";
    for (auto *BB : R->blocks())
        errs() << BB->getName() << " ";
    errs() << "\n";

    if (BasicBlock *Exit = R->getExit())
        errs() << "Exit Block: " << Exit->getName() << "\n";
    else
        errs() << "No Exit Block (e.g., outermost region)\n";

    errs() << "---\n";
}
  • 每个区域都有一个名称,我们可以获取它。
  • 我们还可以检查区域是否是最外层的(即不包含在任何其他区域内)。
  • 我们可以检查区域的嵌套级别,即任何给定区域距离根区域有多远。顶级区域的深度为0。
  • 我们可以读取区域的入口基本块。在这里,我读取入口基本块并打印其字符串名称。
  • 接下来,我遍历构成当前正在访问的区域的每个基本块,并打印这些基本块的名称。
  • 然后我打印区域的出口。请注意,并非每个区域都有出口块。有些区域没有,例如函数的最外层区域就没有出口块。这就是为什么我们需要检查出口是否可用。

哦,记得导入实现此方法所需的接口,即 RegionIterator 和LLVM的输出流实现。

这个分析因此实现在两个文件中:一个头文件和一个CPP实现文件。

编译与运行Pass 🚀

一旦分析准备就绪并编译完成,我们就可以调用它。你需要准备一个字节码文件,然后使用 opt 工具在这个文件上调用我们的Pass。

首先,让我用 clang 编译一个程序以产生 .bc 字节码文件。

clang -c -emit-llvm example.c -o example.bc

然后,为了便于阅读,让我用 opt 优化并重命名变量。

opt -mem2reg example.bc -o example.opt.bc

最后,这就是我们如何在新产生的字节码文件上调用刚刚创建的Pass。

opt -load ./libRegionsEX.so -regions-ex < example.opt.bc

命令行中 -load ./libRegionsEX.so 这部分定位了我们的Pass库。我称这个Pass为 regions-ex。不确定你是否记得,我们在头文件中有这样一行代码来注册Pass及其命令行标志 -regions-ex。哦,记得在命令行中传递存储Pass库的完整路径。

我们之前已经展示过如何编译LLVM Pass。这是我们的区域分析的输出。我向你展示了访问程序中两个不同区域的结果。

我想你可以将我们在 printLog 方法中使用的不同命令与我们在输出中得到的结果联系起来。比如,这里是我们访问的两个区域的名称,这里是基本块的列表。

无论如何,我将留给你暂停视频,并检查右侧的每个方法在左侧的输出中产生了什么。如果你遇到困难,可以写信问我问题。你也可以查看区域分析的实现,我在这里放了一个链接。

总结

本节课中我们一起学习了LLVM中的区域分析。我们了解了什么是单入口单出口区域,以及它们如何被组织成程序结构树。我们实现了一个简单的LLVM Pass来遍历和打印函数中的区域信息,包括区域的名称、嵌套级别、入口/出口块以及包含的基本块。通过这个实践,你掌握了调用和利用LLVM区域分析基础设施的基本方法,这为进行更复杂的程序分析和转换奠定了基础。

013:什么是LLVM元数据? 🔍

在本节课中,我们将要学习LLVM元数据(Metadata)机制。我们将了解其定义、格式以及一些主要的类型。

概述

LLVM提供元数据来存储关于程序的信息。这些信息可以被优化器和代码生成器使用。其主要用途是调试信息,但并非唯一用途。元数据也可用于不同的程序分析和转换。例如,C语言的类型别名分析会使用元数据,因为它必须依赖于源代码语言的类型系统,而不是IR的类型系统。

那么为什么要使用元数据呢?通过元数据,可以在程序进行转换时存储并保留相关信息。它同样有助于开发调试程序,并在源代码早已不存在时,仍能引用源代码信息。

在本课程中,我们将重点介绍如何使用元数据来开发调试程序。

生成元数据

为了生成用于调试目的的元数据,我们需要在编译程序时添加 -g 标志。这个标志告诉编译器在生成IR时附带源代码调试信息。这些元数据随后会被代码生成器翻译成平台特定的调试信息。

在Clang中,有不同种类和级别的调试信息生成选项。但对于本课程,仅使用 -g 就足够了。默认情况下,它会生成完整的调试信息。

元数据格式

以下是一个函数被翻译成LLVM IR后的示例。其中,红色字体部分是与该函数关联的元数据。如省略号所示,元数据出现在IR文件的末尾。

元数据可以附加到IR的容器上,例如全局变量、指令和函数。元数据附加在容器的末尾。所有元数据都以感叹号开头,并拥有索引。例如,附加到名为 delta 的函数上的元数据索引为 !11。可以使用这个索引来检索特定的元数据。

元数据类型

本质上,元数据有三种类型:元数据字符串、元数据节点和命名元数据。

元数据字符串可以存储字面量字符串,可以包含任何字符,并且总是由双引号包围的字符串。例如,以下元数据字符串用于显示当前的DWARF版本:

!0 = !{!"Dwarf Version", i32 4}

元数据节点类似于元数据的元组,它们能更精确地描述源代码对象。节点具有操作数,我们可以轻松访问它们,这允许用户遍历元数据。这些操作数可以是元数据字符串、常量,甚至是其他元数据节点。

例如,以下节点描述了一个名为 a_c 的局部变量:

!1 = !DILocalVariable(name: "a_c", arg: 1, scope: !2, file: !3, line: 11, type: !4)

仅通过查看这个节点,我们就可以看到该变量是一个参数,其声明位于源代码的第11行。但这并不是我们能从这个节点获取的唯一信息。通过遍历其操作数,我们可以访问其他元数据节点。使用操作数 !2,我们可以检索变量的作用域,即函数 main。我们还可以获取源文件代码和变量的类型。可以看到,所有这些信息都存储在其他元数据节点中。

在课程中,我们将更详细地介绍元数据节点的主要类型。

最后,存在命名元数据,它是元数据节点的集合。在示例中,named 就是一个命名元数据。它可以在模块符号表中查找。命名元数据表示为一个带有元数据前缀(即感叹号)的字符串。

需要知道的是,元数据不是LLVM值,并且元数据没有类型。但是,元数据可以作为用户参数用于函数调用,例如LLVM的 dbg.declare 指令。然而,当被函数调用引用时,我们使用的不是元数据本身,而是元数据类型。

主要专用节点

专用节点与源代码对象直接相关。

  • DICompileUnit:表示一个编译单元。使用此节点可以访问诸如全局变量、宏和程序导入等信息。
  • DIFile:表示源文件。
  • 描述作用域的节点:如描述词法块的 DILexicalBlock、描述局部作用域的 DILocalScope 以及描述函数的 DISubprogram
  • 描述变量的节点:如局部变量和全局变量。
  • 描述类型的节点
    • 基本类型,如 int、浮点数和 bool
    • 派生类型,如指针和结构体成员。
    • 复合类型,如数组、结构和联合。
    • 字符串类型,用于表示字面量字符串。

总结

本节课我们一起学习了LLVM元数据的入门知识。我们了解了它是什么、如何生成以及其基本格式和类型。在下一节课中,我们将学习如何访问元数据并从中检索信息。

如果你有任何问题或评论,欢迎随时联系我。你可以在下面的描述中找到参考链接。

014:使用元数据恢复源码信息 🔍

在本节课中,我们将学习如何在LLVM Pass中利用元数据来获取程序源代码的相关信息。

上一节我们介绍了元数据的概念、作用及其主要类型。本节中,我们将动手实践,看看如何在LLVM Pass中具体使用元数据。

访问元数据的方法

DIR(调试信息表示)的某些组件提供了方法来获取附加在其上的元数据。

以下是指令(Instruction)组件访问元数据的方法示例:

// 通过索引访问
instruction.getMetadata(20);
// 通过名称访问
instruction.getMetadata("dbg");

这两种方式都会返回相同的结果,即一个源代码位置信息。

类似地,其他组件(如全局变量、函数)也提供了获取其附加子程序(Subprogram)元数据的方法。这些方法都返回元数据节点。

使用DebugInfoFinder类

由于我们专注于使用元数据进行调试,接下来介绍DebugInfoFinder类。这是一个LLVM工具类,用于查找模块中的所有调试信息。

DebugInfoFinder内部维护了以下信息的迭代器:

  • 所有编译单元(Compile Units)
  • 所有子程序(Subprograms)
  • 所有全局变量表达式(Global Variable Expressions)
  • 所有类型(Types)
  • 所有作用域(Scopes)

因此,一旦你处理了一个LLVM模块,在你的Pass中就可以访问所有这些信息。

构建一个函数签名恢复Pass

现在,我们来构建一个利用元数据重建函数签名的LLVM Pass。我们的最佳选择是创建一个模块Pass(Module Pass)。如果你对LLVM中Pass项目的结构有疑问,可以回顾我们关于编写LLVM Pass的课程。

我们的项目名为“Sign Re”。我们将结合模块的调试信息来使用DebugInfoFinder。

首先,我们需要处理模块。处理完成后,我们可以遍历所有子程序并逐个分析。

以下是获取每个子程序的两种等效方法:

  1. 使用DebugInfoFinder遍历所有子程序。
  2. 遍历IR函数,然后获取每个函数的子程序。

分析子程序信息

我们可以直接获取子程序的名称和声明行号。我们还可以获取子程序的类型,这是一个子程序类型(SubroutineType)。

SubroutineType类包含一个类型数组,构成了函数的签名。我们可以遍历这个数组来访问每个类型。

  • 数组的第一个位置是函数的返回类型。如果第一个位置的元素是null,则表示这是一个无返回类型的void函数。
  • 数组的其他元素是函数的参数类型。

接下来,我们的Pass将分析每个参数的类型,以及返回类型(如果函数有返回值的话)。

处理调试类型

请注意,我们正在处理的是调试类型(Debug Type),而不是上节课提到的IR类型。

  • 调试基本类型(Debug Basic Type):代表语言中的原始类型。我们定义一个函数,根据DWARF标签(Dwarf Tag)来识别每种类型,并返回包含类型名称的字符串用于打印。
  • 调试派生类型(Debug Derived Type):代表诸如指针或引用之类的限定类型。我们处理三种最常见的派生类型:指针(Pointers)、常量修饰符(Const Modifiers)和引用(References)。然后,我们递归调用相同的analyzeDebugType函数,但这次传递派生类型所基于的类型作为参数。
  • 调试复合类型(Debug Composite Type):代表由其他类型组成的类型,如结构体或联合体。我们处理这两种类型以及数组类型。同样,我们基于DWARF标签来识别该节点代表哪种复合类型。

示例程序与输出

以一个在链表中插入元素并打印的程序为例,我们的Pass输出将如下所示:

Function `create` at line 10 returns `node*`.
  Argument 1 is `const int`.
  Argument 2 is `node*`.
Function `main` at line 18 returns `int`.
  Argument 1 is `int`.
  Argument 2 is `char**`.

我们可以看到程序中有一个用户定义类型node,但输出没有提供关于此类型的更多信息。

扩展功能:详述用户定义类型

因此,让我们在Pass中添加一个额外的功能,利用元数据来详述用户定义类型。

我们像之前一样,通过DWARF标签来识别调试类型。当我们找到一个复合类型时,可以详述其每个组成元素(即构成它的类型)。在这种情况下,每个类型都是一个代表复合类型中字段的派生类型。我们可以获取字段的名称,并使用我们已经定义的函数来分析其类型。我们同样处理typedef关键字重定义的类型。

在我们的主方法中,我们使用DebugInfoFinder来访问模块中找到的每个类型。如果找到一个用户定义类型,我们现在就可以详述它。

现在,输出也包含了用户定义类型的信息,使得输出更加完整:

User defined type `node` at line 1.
  Field `data` has type `int`.
  Field `next` has type `node*`.

总结

本节课中,我们一起学习了如何在LLVM Pass中使用元数据,以及如何检索程序源代码的相关信息。

在下一节课中,我们将了解如何在经过优化的IR代码中跟踪变量。如果你有任何问题或评论,欢迎随时联系我。你可以在描述中找到参考资料、链接,以及本视频中使用的Pass实现链接。此外,还有一个链接指向LLVM开发者大会上更详细介绍元数据的视频。

015:在LLVM IR中追踪变量 🔍

在本节课中,我们将学习如何使用元数据作为调试信息,在LLVM IR程序中追踪变量。

正如我们在之前的课程中所见,调试信息是LLVM元数据的主要应用场景。元数据是LLVM的一种机制,用于存储程序的某些信息,并确保这些信息在程序经过各种转换时得以保留。LLVM提供调试信息,旨在帮助开发者识别高级语言中的元素如何映射到LLVM代码,它保留了通常在编译过程中被剥离的信息。

调试信息必须仅服务于其自身目的。换句话说,它应该对编译器的其他部分影响甚微。任何转换、分析或代码生成都不应因为调试信息的存在而需要修改。同时,调试信息无需了解源语言级别的语义,因为LLVM被设计为支持多种语言。它应该能与任何语言协同工作,而不依赖于源代码语言施加任何限制。此外,它还需要与传统的机器码级别调试器(如GDB)兼容。

调试信息与元数据

LLVM使用了许多内联函数来在代码优化和生成过程中追踪变量。这些内联函数以 llvm.dbg 为前缀,并使用元数据作为参数。在本节中,我们将重点关注两个:llvm.dbg.declarellvm.dbg.value

llvm.dbg.declare 内联函数用于描述局部变量的地址。其第一个参数是变量本身的地址,第二个参数是一个 DILocalVariable 元数据节点,第三个参数是一个 DIExpression,用于描述引用的LLVM变量如何与源语言变量相关联。

以下是一个描述变量 c2 地址的 dbg.declare 示例:

call void @llvm.dbg.declare(metadata i32* %c2, metadata !31, metadata !DIExpression()), !dbg !35

请注意,调试内联函数也附加了元数据(本例中是 !dbg !35)。需要重点注意的是,每个局部变量只能有一个 dbg.declare 调用,并且 dbg.declare 通常与 alloca 指令直接相关。

llvm.dbg.value 内联函数则提供关于用户源变量何时被赋予新值的信息。其第一个参数是新值(在本例中包装为元数据),第二个参数是包含变量描述的 DILocalVariable,第三个参数同样是一个 DIExpression。请注意,llvm.dbg.value 描述的是分配给源变量的,而不是其地址。

以下是一个变量(由元数据 !31 描述)被赋予新值 %3 的示例:

call void @llvm.dbg.value(metadata i32 %3, metadata !31, metadata !DIExpression()), !dbg !35

处理调试内联函数的LLVM Pass示例

上一节我们介绍了调试内联函数的基本概念,本节中我们来看看一个处理这些内联函数并打印变量赋值信息的LLVM Pass代码示例。

我们将遍历函数中的每条指令,并基于调试内联函数构建赋值追踪。以下是该函数的核心逻辑:

首先,我们获取与内联函数关联的变量。然后,需要识别我们正在处理的是 dbg.declare 还是 dbg.value 内联函数。这可以通过 isAddressOfVariable() 方法来完成,该方法对于 dbg.declare 返回 true,对于 dbg.value 返回 false

对于 dbg.declare,我们可以获取该内联函数描述的地址并打印它。我们还可以获取该变量被声明的行号。为此,我们可以使用附加到调试内联函数的元数据,在本例中,该元数据是一个调试位置(DebugLoc)。

接下来,我们处理 dbg.value 内联函数。我们不处理行号节点,因为它们没有与源代码直接对应的严格映射。但是,我们会检查变量是否是一个常量值,并检查变量是否被赋予了一个新值。以类似的方式,我们可以检查新值是最终值还是某个变量的地址。最后,如果某个值是由另一条指令计算得出的,我们可以使用与之前讨论的相同方法获取相应源代码行的信息,即获取附加到它的元数据并打印行号。

示例程序分析

为了说明我们的分析如何工作,让我们使用这个程序示例。我们使用调试标志进行编译,并且唯一的优化是 mem2reg,它将栈上的值提升到虚拟寄存器中。

请注意,两个变量 c2c3 保留在栈上。我们打印了这些信息,包括声明行。您可以暂停视频以更仔细地查看输出。

我们还可以检索到告诉我们 p1 被赋值为一个变量地址的信息,因为 p1 是一个指针。通过这种方式,我们能够为此程序中的每次赋值显示这些信息。

总结

本节课中,我们一起学习了如何使用调试信息在LLVM中间表示中追踪变量。在下一节课中,我们将了解如何向IR添加自定义元数据。如果您有任何问题或评论,请随时联系我。您可以在描述中找到参考和最佳实现的链接。还有一个链接指向LLVM开发者会议的视频,该视频深入探讨了LLVM中调试的一般用途。

感谢观看。

016:TBAA元数据

在本节课中,我们将学习LLVM中的TBAA(基于类型的别名分析)元数据系统。我们将了解其核心概念、组成部分,以及如何在LLVM IR中表示和访问这些信息。

概述:TBAA元数据的作用

LLVM IR拥有自己的类型系统,这个系统与程序源代码语言的类型系统不同。此外,内存本身没有类型。这使得LLVM的类型系统不适用于进行基于类型的别名分析,这正是TBAA所代表的功能。因此,LLVM使用元数据机制来存储关于高级语言类型系统的信息。通过使用TBAA,可以实现C或C++的别名规则,以及依赖于编程语言类型系统的自定义分析。

TBAA系统的核心组成部分

TBAA系统主要包含两个部分:语义和表示。语义部分描述了访问标签和类型描述符,而表示部分则解释了这些信息如何被编码为元数据节点。

类型描述符

类型描述符用于表达高级语言的类型系统。

  • 标量描述符:描述不包含其他类型的类型。每个标量类型都有一个父类型,在TBAA系统中,父类型也必须是一个标量类型。
  • 结构体描述符:表示包含一系列其他类型描述符的类型,同时包含这些成员的偏移量信息。

父关系在TBAA系统中形成了一棵树,我们称之为类型描述符图

让我们通过一个例子来看看TBAA图的样子。

在TBAA图中,每个节点代表源代码中一个不同类型的描述符。图中存在一个根节点。有些节点可能位于不同的根节点下,但在那种情况下,它们之间的别名关系是未知的,LLVM会假设它们可能互为别名。我们这里讨论的所有内容都适用于同一根节点下的节点。

  • 存在一个char类型的节点,因为在C/C++中,char可以用来访问任意类型。
  • 然后我们有程序中包含的其他类型的节点。
  • 注意,每个描述符都有一个直接父节点。例如,int的父节点是char,而inner结构体的直接父节点是int

访问标签

访问标签是附加在加载和存储指令上的元数据。它们根据高级语言的类型系统来描述被访问的内存位置。

一个访问标签包含三个部分:一个基类型、一个访问类型和一个偏移量。访问标签可以描述两种情况:

  1. 如果基类型是一个结构体类型,那么该标签描述的是:在基类型结构体中,位于指定偏移量处的、访问类型成员的内存访问。
  2. 如果基类型是一个标量类型,那么偏移量必须为0,并且基类型和访问类型必须相同,因为你正在访问一个单一的标量。

继续我们的例子,在函数F中,我们可以看到四个不同的访问及其对应的标签。

例如,第一个标签告诉我们,我们正在访问一个outer结构体类型变量中,偏移量为0处的float类型成员。

最后一个访问是针对标量类型int的,因此基类型和访问类型相同,且偏移量必须为0。

TBAA元数据的表示

如前所述,所有的TBAA信息都被编码为元数据。因此,我们刚刚看到的组件在IR中都被表示为元数据节点。

以下是各种元数据节点的表示方式:

  • TBAA根节点:一个具有零个或一个操作数的节点。如果有一个操作数,它必须是一个元数据字符串。
  • 标量类型描述符:一个具有两个操作数的节点。第一个是元数据字符串(类型名称),第二个是一个指向该描述符父节点的节点(父节点可以是另一个标量类型或TBAA根节点)。标量类型描述符可以有一个可选的第三个参数,但它必须是常量整数0
  • 结构体类型描述符:一个具有大于1的奇数个操作数的节点。第一个操作数是元数据字符串(结构体类型名称)。之后,结构体类型描述符包含一系列交替出现的元数据节点和常量整数,分别表示成员类型和其偏移量。
  • 访问标签:一个具有三个或四个操作数的节点。第一个操作数是指向基类型表示节点的指针,第二个操作数是指向访问类型表示节点的指针,第三个操作数是一个常量整数,表示访问的偏移量。如果存在第四个字段,它必须是一个值为01的常量整数。如果它是1,则表示该访问标签所描述的内存位置在别名分析中被认为是常量。

在IR中访问TBAA元数据

我们可以通过!tbaa访问标志在IR中访问TBAA元数据。如前所述,访问标签附加在加载和存储指令上。

以下代码展示了如何访问元数据节点及其组件:

; 假设 %inst 是一条加载或存储指令
%tbaa_metadata = !{!0} ; 示例元数据节点
!0 = !{!1, !2, i64 0} ; 访问标签节点
!1 = !{!"outer"} ; 基类型描述符
!2 = !{!"float"} ; 访问类型描述符

然后,我们可以像在前面的课程中一样,通过迭代元数据节点的操作数来遍历整个TBAA图。

回到我们的例子,让我们看看TBAA元数据在IR中是什么样子。为了让Clang生成TBAA元数据,我们必须启用优化,因此需要使用-O1标志进行编译。

我们将省略部分IR,专注于与我们目的相关的部分。如你所见,函数F中的每个赋值在IR中都有一个对应的存储指令。所有的存储指令都附加了TBAA元数据。

在这里,我们可以看到该模块中的所有TBAA元数据,它们形成了我们之前看到的图。例如,对于这个存储指令,节点47表示这是一个赋值。节点38描述了变量的类型,节点44描述了被赋值的结构体成员f的类型。从节点38,我们知道outer类型在偏移量0处由一个float(节点41)、在偏移量8处由一个double(节点42)和在偏移量16处由一个inner结构体(节点43)组成。

总结

本节课中,我们一起学习了TBAA元数据是什么,它的组成部分(类型描述符和访问标签),以及如何在LLVM IR中表示和获取这些信息。TBAA元数据是LLVM实现高级语言别名规则和进行精确别名分析的关键工具。

017:基于性能剖析的优化 - 第一部分

在本节课中,我们将要学习基于性能剖析的优化的一些基本概念。

概述

基于性能剖析的优化,也常被称为 PGO

PGO 是一种编译器优化技术,它收集程序运行时的行为信息,并将这些收集到的信息作为反馈提供给编译器。因此,编译器可以做出更优的优化决策。

PGO 背后的思路是:在剖析应用的同时运行它,以收集该应用行为的样本。然后,利用剖析器提供的信息作为反馈,重新编译该应用,使编译器能够更好地优化程序。

请注意,PGO 也被称为其他名称,例如 FDO(反馈驱动优化)、PBO(基于性能剖析的优化)等。这里分享的是最常见的名称。

编译流程回顾

在介绍可以利用剖析信息的具体优化技术之前,我们先回顾一下编译流程。一个典型编译器的编译过程如下:

  1. 从程序的源代码开始。
  2. 解析器(也称为编译器前端)解析输入的源代码,并最终将其转换为中间表示。这种中间表示可以被编译器的其他阶段理解。
  3. 然后,编译器优化这段代码,并输出目标文件(二进制形式)。
  4. 最后,链接器将这些目标文件与外部库链接在一起,输出一个可执行的二进制文件。

现在我们已经了解了编译流程,接下来看看我们可以在哪些步骤中注入剖析信息。

我们可以在解析步骤、代码生成步骤、链接步骤中注入剖析信息。甚至在获得可执行二进制文件之后也可以,不过在这种情况下,我们不会使用编译器,而是会使用二进制优化工具。

利用剖析信息的优化技术

此时,你可能会问,有哪些技术可以利用剖析信息来优化代码呢?

以下是几种主要的优化技术:

  • 基本块布局:代码生成器可以根据执行频率来布局基本块,优先排列执行更频繁的基本块。这可以改善指令缓存行为,并减少分支开销。
  • 函数内联:内联器可以优先内联调用更频繁的函数。
  • 热点代码识别:编译器可以将地址空间分隔为热点代码(运行更频繁的部分)和冷点代码。这可以减少工作集大小并降低页面错误。
  • 溢出代码放置:寄存器分配器可以将溢出代码放置在较少执行的代码区域。

这些以及其他优化可以带来显著的性能收益,例如提高运行速度、减少内存占用。

剖析信息的内容

那么,剖析信息里包含什么呢?它可以包含很多内容。大多数人开始收集的是关于控制流的信息。

这类信息通常是执行计数,但也可能包括分支预测失误、分支未命中等其他信息。

我们还可以收集与不同程序结构相关联的剖析信息。例如:

  • 我们可以收集特定基本块或该基本块内特定指令的执行计数。为此,我们可以利用控制流图边
  • 我们也可以收集整个函数的执行计数,并利用调用图来收集关于调用图边和函数间依赖关系的信息。

请记住,收集的信息将完全取决于所执行优化的最终目标。

剖析信息的类型

基本上有两种主要的剖析信息类型:

  • 动态剖析:这是最常用的类型,在程序执行期间收集剖析信息。此类别中的技术包括:
    • 插桩:向程序中注入额外的代码,以跟踪程序行为信息。
    • 采样:通常使用像 Linux perf 这样的工具,通过采样程序指令的一个子集来从硬件计数器收集信息。
  • 静态剖析:在编译流程中收集关于程序的剖析信息,试图基于其结构特征来预测其运行时行为。此类别中也有两种主要技术:
    • 基于启发式规则:使用规则进行预测。
    • 基于机器学习:使用基于从先前获得的知识中学习的更高级搜索技术。

注意事项与挑战

然而,凡事总有挑战,PGO 也不例外。PGO 基于一个假设:你可以预测程序的行为。在某些情况下,这可能是正确的,但并非对所有程序都成立。

例如,如果程序行为对输入非常敏感,针对一组输入进行优化可能会使应用在运行另一组输入时变慢。因此,在使用 PGO 时,必须非常小心,确保通过收集能代表程序通常采取的所有不同执行路径的剖析信息来“训练”优化。

另一个挑战(虽然通常不那么显著)是,根据应用 PGO 的方式,它可能会在构建过程中引入额外的步骤。这些额外步骤可能会显著影响部署周期,有时还会增加开销。

总结

本节课中,我们一起学习了基于性能剖析优化的基本概念。我们回顾了编译流程,探讨了PGO可以应用的环节,并介绍了几种利用剖析信息的关键优化技术,如基本块布局和函数内联。我们还了解了剖析信息的内容和主要类型(动态与静态剖析),最后讨论了使用PGO时需要注意的挑战,例如输入敏感性和构建流程的复杂性。

希望你喜欢这节课。如有任何疑问,可以给我发送邮件。也期待你观看本课程的第二部分,内容将是 LLVM 中的 PGO。我们下次再见。

018:Clang AST简介 🧠

在本节课中,我们将学习Clang AST(抽象语法树)的基本概念。Clang是LLVM基础设施中的C语言前端,其主要目标是将C源代码转换为LLVM IR代码。理解AST是使用Clang库构建自定义工具(如插件)的基础。

什么是Clang AST?

AST代表抽象语法树。它是一种用于以树形结构表示源代码的数据结构。从编译器的角度来看,将程序视为树形结构比原始文本更容易理解和处理。好消息是,Clang通过其库将这些结构提供给我们使用。

AST的结构示例

为了理解AST的形态,让我们看一个表达式的例子。假设我们想将表达式 2 + 1 * 4 表示为树。

首先,我们需要计算右侧的乘法。因此,我们先为这部分构建一个树。树顶部的节点是我们想要执行的操作,即乘法。从该操作节点分出两个分支,代表操作数 14。这部分的计算结果为 4

接下来,我们将表达式左侧的加法引入。同样,加法操作位于顶部节点,操作数从它分支出来。在本例中,操作数是左侧的 2 和我们乘法结果的 4。最终,正如预期,答案是 42

从C代码生成AST

现在,让我们看一个用C语言编写的简单函数,它只是返回两个整数的和,并保存在名为 simple.c 的文件中。

我们可以使用以下命令来生成该函数的AST的文本表示:

clang -Xclang -ast-dump -fsyntax-only simple.c

生成的输出中,围绕我们求和函数的部分有一些编译器引入的默认类型声明。让我们聚焦于实际代表我们函数的AST部分。

我们函数的根节点是一个名为 FunctionDecl 的节点。这是Clang表示函数声明的方式,它保存了诸如函数名、返回类型、参数类型,甚至其在源代码中位置等信息。

构成此函数的所有内容都作为 FunctionDecl 节点的子节点保存。其参数可以在顶部的 ParmVarDecl 节点中找到。紧接着下方,有一个 CompoundStmt 节点,它代表语句组。通常,你可以将其视为条件语句、循环和函数中大括号内的语句。在我们的例子中,它代表函数体,其中只包含一个 ReturnStmt 节点。

再深入一层,有一个 BinaryOperator 节点,代表表达式 a + b。熟悉这种格式很重要,因为正如我们将看到的,在编写插件时,我们将遍历AST的节点并收集有关它们的信息。

AST的组织方式

既然我们已经看到了AST的样子,接下来讨论一下它是如何组织的。首先,Clang没有实现单一的基类。这意味着我们无法拥有一个能够遍历树中任何类型节点的单一方法。

相反,有三个核心类:

  • Decl:代表代码中的声明。我们在示例中已经见过几个,如 FunctionDeclParmVarDecl。当然,C语言中还有许多其他类型的声明,例如变量声明或结构体声明,每种都有对应的Decl类。
  • Stmt:代表代码中的语句。例如,我们已经见过求和函数中的 CompoundStmtBinaryOperator 节点也属于此类。你可能会期望此节点继承自表达式类,它确实如此。然而,表达式继承自语句,因此该节点继承的核心类实际上是 Stmt
  • Type:代表类型对象。在我们的示例中没有明确看到代表类型对象的节点,但类型信息确实出现了。例如,我们看到函数的参数具有 int 类型。使用Clang库,我们将能够从这些节点中提取类型对象。例如,BuiltinTypePointerType 是用于表示整数和指针的类型类示例。

这些只是属于每个核心类的几个示例类,但正如你可能想象的,还有很多其他类。我鼓励你自己编写几个C程序,并查看Clang为它们构建的AST。这将帮助你熟悉构成Clang的庞大库。

总结

本节课中,我们一起初步了解了Clang AST库。本视频使用的主要参考资料是官方的Clang文档。当你探索AST并发现新内容时,可以查阅该文档以获取更多信息。

在下一个视频中,我将讨论可用于构建我们工具的接口。在那之前,你可以通过电子邮件向我提出任何问题、建议或意见。

019:Clang Tooling接口概览 🛠️

在本节课中,我们将要学习如何将Clang作为库来构建工具。我们将介绍Clang官方文档中描述的三种主要接口:LibClang、LibTooling和Clang插件,并重点了解它们各自的特点和适用场景。

概述

上一节我们介绍了抽象语法树(AST)以及Clang如何使用它来表示代码。本节中,我们来看看利用Clang库构建工具的不同方式。

根据Clang官方文档(版本12),有三种主要方式来编写我们的工具:

  1. LibClang
  2. LibTooling
  3. Clang插件

本课程的重点是Clang插件,但也会对其他两种接口进行简要概述。本次概述的目的不是让你能够使用每一种接口编写工具,而是让你了解在何种情况下应该选择使用哪一种。

LibClang接口

首先介绍LibClang。文档将其描述为“一个稳定的、高层次的C接口”。

这意味着,你使用该库旧版本编写的程序,可以预期在未来的版本中继续工作。这与某些其他接口形成对比,那些接口的版本间变更可能非常剧烈,迫使你重写工具才能使其继续运行。

LibClang允许你使用所谓的“游标”来遍历AST。根据文档,游标允许你在无需了解Clang AST细节的情况下遍历AST,但缺点是,你无法获得对AST的完全控制。

以下是LibClang接口的核心概念:

  • 稳定性:API保持向后兼容。
  • 抽象层:使用CXCursor等高级对象,而非直接操作Clang的C++ AST节点。

LibClang不会暴露AST中保存的所有信息。其背后的理由很简单,如果我们记得LibClang的目标是“稳定”的话:它在API中暴露的AST细节越少,受到Clang内部变更影响的可能性就越小。因此,如果你需要更深入地使用AST,可能需要考虑Clang提供的其他接口。

LibTooling接口

接下来我们介绍LibTooling。LibTooling的API允许你完全控制AST。

LibTooling在代码上运行前端操作。前端操作充当我们工具的入口点,也就是说,你在这里编写希望Clang在编译期间为你运行的代码。

它们被实现为一个抽象类FrontendAction,其他类会实现它,例如ASTFrontendAction。这个类允许我们编写依赖于遍历Clang AST的操作。

当我们深入研究Clang插件时,会看到它使用了一种称为PluginASTAction的操作,而它实际上实现了ASTFrontendAction

需要说明的是,LibTooling不像LibClang那样稳定。

Clang插件接口

最后,我们来到本课程的主题:Clang插件。

如前所述,Clang插件在代码上运行PluginASTAction。这意味着Clang插件实际上是你可以使用LibTooling构建的一种工具。

我们也已经看到,PluginASTAction实现了另一个名为ASTFrontendAction的类,而ASTFrontendAction又实现了FrontendAction

在下一节视频中,我们将最终实现我们的第一个Clang插件,届时会确切地看到如何实现这些类。

现在,重要的是要知道,当我们编写Clang插件时,我们编写的是在代码上运行PluginASTAction的程序。这意味着Clang将读取你的代码,将其解析成AST,然后将这个AST交给你的PluginASTAction类。

Clang插件作为LibTooling的一部分,也让你能够完全控制Clang AST。

Clang中还有另一个巧妙的功能,即注册你的插件。不必担心细节,这将在下一节视频中详细讲解。基本上,这个功能允许你将插件编译成一个共享库,然后只需在命令行传递一个参数,就能让Clang运行它。

例如,像这样的命令:

clang -fsyntax-only -Xclang -load -Xclang path/to/your/plugin.so -Xclang -plugin -Xclang your-plugin-name example.c

我们将在下一节视频中详细讲解这行命令的每一部分,但基本上,这行命令调用clang,并要求它运行你注册为your-plugin-name的插件,对文件example.c中的程序进行分析。那个plugin.so文件就是你的插件被编译后生成的所谓共享库。

最后,Clang插件也不像LibClang那样稳定。

总结与下节预告

本节课中我们一起学习了如果想编写Clang工具,我们有哪些选项以及它们各自的优缺点。

总结一下今天的内容:

  • LibClang是一个非常稳定的库,允许你构建一些不依赖于获取AST每个细节的有趣工具。
  • LibTooling虽然不像LibClang那样稳定,但允许你访问Clang在解析代码时保存在AST中的几乎所有内容。
  • Clang插件是使用LibTooling的一种方式,并具有一些特定功能,例如注册机制,我们将在接下来的视频中更详细地了解。

在下一节课中,我们将终于开始构建我们的第一个Clang插件。在深入更有趣的内容之前,我将介绍让我们的插件启动和运行所需的最基本要素。在那之前,你可以通过电子邮件向我提出任何问题、建议或评论。

谢谢。

020:Clang插件基础模板

在本节课中,我们将学习如何构建一个最基础的Clang插件。我们将了解创建一个可运行的Clang插件所需的核心组件和基本模板代码。

概述

上一节我们介绍了使用Clang作为库来构建工具的一些接口。本节中,我们来看看如何构建一个最小的Clang插件,并解释你需要设置哪些基础模板代码,以便在其之上构建你的工具。

构建Clang插件所需组件

要构建并运行一个Clang插件,需要实现哪些部分呢?

正如我们在之前的视频中所见,Clang插件在代码上运行前端操作。前端操作是一种接口,允许我们编写我们想要的代码,并让Clang作为编译过程的一部分来执行。这是我们在使用LibTooling和Clang插件编写Clang工具时的入口点。

对于插件,有一个更具体的接口,它继承自前端操作,称为PluginASTAction,这也是我们将要介绍的内容。PluginASTAction是一种特定类型的前端操作。

我们将编写一个继承自PluginASTAction的操作类。当我们运行插件时,Clang会处理代码并生成一个AST对象,然后将这个AST对象传递给我们的操作类,这就是我们编写的插件开始执行的地方。

由于我们编写的是一个AST操作,我们需要为它提供一个AST消费者。

AST消费者接口

消费者为我们提供了一些访问AST对象的方法。

以下是AST消费者提供的一些关键方法:

  • HandleTopLevelDecl:这个方法允许我们访问AST中顶层声明的条目,即程序文件中最外层作用域内的任何声明。当编译器处理每个顶层声明时,会调用此方法。
  • HandleTranslationUnit:这个方法为我们提供对AST中代表整个C程序文件实体的访问。它在编译器完成对整个文件的AST处理后调用,这也是我们今天示例中将使用的方法。

此外,还有一种访问AST节点的模式,即通过实现AST访问器来完成。

最后,剩下的就是注册插件。这将允许我们在运行时从共享库中加载我们的插件到Clang中。

开始实现

我们今天要构建的插件非常简单。我们希望能够在程序中打印每个函数声明的AST。

首先,我们来看插件AST操作。我们的插件将被称为hello,因此我们的操作类将被称为HelloAction。它的主要目的是实现CreateASTConsumer方法,该方法返回一个AST消费者的实例,我们稍后会实现它。

在此之前,我们需要谈谈ParseArgs。根据官方的Clang文档,这是PluginASTAction与常规前端操作的主要区别——处理命令行参数的能力。我们的示例不会使用这个,但当你需要将命令行选项整合到你的工具中时,你很可能会用到它。

即使我们不解析任何参数,ParseArgsHelloAction继承的一个纯虚方法,因此我们需要在这里定义它,否则我们将无法编译我们的插件。

如果你决定使用命令行参数,可以在这个参数Args中找到它们。

接下来,我们需要实现这里的AST消费者,让我们开始吧。

实现AST消费者

同样,我们的插件叫做Hello。让我们将消费者命名为HelloConsumer。它内部定义了一个HelloVisitor的实例。别担心,我们稍后会介绍HelloVisitor。它将是本节的主角,但到目前为止,我们正在为它施展魔法奠定基础。

HandleTranslationUnit是我们访问AST的方式。如果你还记得视频前面提到的,HandleTranslationUnit在编译器完成处理整个程序文件后被调用,并接收这个ASTContext对象。

它包含了我们程序中AST收集的信息。然后我们将其交给我们的访问器对象,以便我们可以访问AST。

最后,让我们实现我们的访问器。

实现AST访问器

一个简短的说明:我们即将实现的访问器类遵循访问器模式。你可以在《设计模式:可复用面向对象软件的基础》这本书中了解更多。它是书中描述的行为模式之一。当你有一个对象结构,并且希望能够在这些对象上执行各种操作时,它非常有用。

这正是我们想要的。我们拥有AST,并且希望使用其节点做很多事情。

使用这个类的一种方法是实现它向我们公开的Visit方法。让我们看看其中的几个。

例如,我们可以定义VisitFunctionDecl方法。在Clang构建的AST上,有许多代表函数的节点。对于每一个这样的节点,VisitFunctionDecl方法将被调用,我们将能够以我们想要的任何方式使用这个节点。注意,该方法接收一个FunctionDecl类型的参数。

这就是所指的AST节点。我们继续调用dump()函数。它所做的就是打印我们对象的AST表示。我们将在视频末尾看到它的实际效果。

我们同样为参数声明(即函数的参数)实现VisitParmVarDecl方法。

甚至还有一个用于所有声明的访问器VisitDecl。这个访问器将访问你程序中的每一个声明。

注册插件

编写完访问器后,剩下的就是在插件中注册它。

在这里,我们将我们的操作类HelloAction添加到注册表中。然后我们将其命名为hello

这是当我们需要告诉Clang我们希望它为我们运行哪个插件时使用的名称。

我们还给它一个简短的描述。这就是运行一个基础Clang插件所需的全部内容。

然后,你需要将其编译成一个共享库,例如libhello.so

运行插件

我们将运行以下命令来调用插件。这里,我们加载libhello库中的所有插件,并将它们传递给Clang的CC1进程。

-cc1标志表示我们只想运行编译器的前端。这里,我们告诉它我们想要运行我们命名为hello的插件。

并且我们希望它在这个示例C程序上运行。一如既往,让我们看一个示例程序来了解这将如何工作。

示例C文件只有一个函数,类型为intsum

如果我们实现了VisitFunctionDecl方法,我们将得到这个输出。

如果你回到我们的第一个视频,你会看到这就像我们当时做的AST转储一样。现在你可以使用自己的代码来做到这一点。

类似地,如果我们使用VisitParmVarDecl来运行这个,我们将得到这个输出。注意,每一行代表我们函数的一个参数。

总结

本节课中,我们一起学习了如何编写构建第一个Clang插件所需的所有基础模板代码。在下一个视频中,我们将尝试使用目前学到的知识构建一些更有趣的东西。

请记住,我们今天看到的代码在课程仓库中是公开可用的。欢迎随意尝试。如果你有任何问题,可以给我发邮件。同时,也非常欢迎建议和评论。

021:AST匹配器 🧩

在本节课中,我们将学习Clang AST匹配器库。这是一个强大的工具,它提供了一种简洁的领域特定语言(DSL),用于描述和匹配抽象语法树(AST)中的特定模式。我们将了解其核心概念,并通过一个实际示例演示如何利用Clang Tooling库来构建一个使用AST匹配器的独立工具。

什么是AST?

上一节我们介绍了Clang AST的基础。AST代表抽象语法树,它是一种以树形结构表示源代码的数据结构。与左侧的原始文本相比,右侧的树形结构使编译器更容易理解和处理程序。

Clang将这些结构提供给我们使用。正如前一视频所提到的,遍历AST的一种方法是使用递归AST访问器。但Clang引入了另一种探索AST的有趣方式——AST匹配器库。该库为我们提供了一种简单而简洁的方法来描述和匹配AST中的特定模式。

AST匹配器示例

以下是一个来自AST匹配器文档的示例。假设你想匹配一个for循环,该循环有一个初始化语句,且该初始化语句包含一个声明,这个声明是一个变量声明,并且该变量被初始化为整数0

for (int i = 0; i < 10; ++i) {} // 匹配此模式
for (int i = 5; i < 10; ++i) {} // 不匹配,因为初始化值不是0

基本上,这个库提供了一个领域特定语言,允许你在AST级别提取信息,例如源代码位置、属性、类型等。其约定是通过构建一个匹配器树,并使用内部匹配器使你的匹配更加具体。

在上例中,我们只对变量初始化为0的情况感兴趣。如果我们不那么具体,接受任何整数初始化,但仍想知道变量被初始化为什么数字,可以使用bind方法从树中提取此信息。我们给它一个标签,以便在找到匹配项后稍后检索该节点。

编写匹配器的步骤

以下是编写匹配器时可以遵循的五个步骤。

第一步是找到你想要匹配的AST中最外层的节点类。因此,在编写匹配器时,需要理解你想要匹配的代码的AST是什么样子。一个很好的技巧是编写一些你想要匹配的代码,并查看它在AST中的表示。你可以使用以下命令将AST转储为可读格式,这有助于你创建匹配器,因为现在你可以看到节点及其在树中的相对位置。

clang -Xclang -ast-dump -fsyntax-only your_file.cpp

第二步,也是最重要的一步,是查阅AST匹配器参考文档。文档中列出了许多匹配器,可以帮助你匹配节点并细化树上的属性。匹配器主要分为三类:节点匹配器、细化匹配器和遍历匹配器。

  • 节点匹配器:匹配特定类型的AST节点。它们是匹配表达式的核心,指明了期望的节点类型。每个匹配表达式都以一个节点匹配器开始。它们是唯一支持bind调用的匹配器,bind调用将节点绑定到给定的字符串,以便稍后检索。

    • 示例:functionDecl(), varDecl(), forStmt()
  • 细化匹配器:匹配AST节点上的属性。它们用于细化节点,限制需要匹配的特定类型节点的集合。

    • 示例:hasName("foo"), hasInitializer(integerLiteral(equals(0)))
  • 遍历匹配器:允许在AST节点之间遍历,它们定义了从当前节点必须可达的其他节点之间的关系。遍历匹配器将节点匹配器作为其参数,因为它们处理这些节点之间的关系。

    • 示例:hasDescendant(), hasParent()

在我们的示例中更容易可视化:forStmt(hasLoopInit(varDecl(hasInitializer(integerLiteral(equals(0))))))

  • forStmt() 是一个节点匹配器
  • equals(0) 是一个细化匹配器
  • hasLoopInit()hasInitializer()遍历匹配器,它们允许你从一个节点遍历到另一个节点。

在研究了想要匹配的AST并查阅了官方文档后,第三步是创建你的匹配器表达式并验证其是否按预期工作。接下来,你应该寻找想要匹配的下一个内部节点,然后重复此过程直到完成。

使用Clang Tooling构建工具

由于已有另一个视频展示了如何制作Clang插件,这次我们将复制我们的示例,使用Clang Tooling库。该库支持创建独立工具,并让我们完全控制AST。

从我们的工具开始,我们需要包含以下库:

#include "clang/ASTMatchers/ASTMatchers.h"
#include "clang/ASTMatchers/ASTMatchFinder.h"
#include "clang/Tooling/CommonOptionsParser.h"
#include "clang/Tooling/Tooling.h"
#include "llvm/Support/CommandLine.h"

我们还需要这一行来为所有命令行选项应用自定义类别:

using namespace clang::tooling;
static llvm::cl::OptionCategory MyToolCategory("My tool options");

现在,我们可以添加我们的匹配器。我使用之前示例中的匹配器。接下来,我们给它一个名称,并可以选择绑定一个或多个节点以便稍后检索。在本例中,我选择绑定varDecl节点,这将允许我在获得匹配时获取所声明变量的名称。

bind调用允许我们定义一个标识符。这里我称之为"init_var",它将映射到我们匹配的节点,并让我们稍后在匹配回调中恢复它们。

using namespace clang::ast_matchers;
StatementMatcher LoopMatcher =
    forStmt(hasLoopInit(varDecl(hasInitializer(integerLiteral(equals(0))))
                .bind("init_var")));

这就引出了匹配回调,它与我们的匹配器配对。当找到匹配项时,它将告诉我们执行什么操作。因此,在AST中找到正确的节点时,将调用相应的匹配回调并传入匹配结果。

在我们的匹配回调(我称之为MatchPrinter)中,我将重写虚函数run,接收匹配结果,并决定在获得具有相应标识符的匹配时执行什么操作。这里,我只是打印变量名。

class MatchPrinter : public MatchFinder::MatchCallback {
public:
  virtual void run(const MatchFinder::MatchResult &Result) {
    if (const auto *VD = Result.Nodes.getNodeAs<VarDecl>("init_var")) {
      llvm::outs() << "Found variable initialized to 0: " << VD->getName() << "\n";
    }
  }
};

现在,转到我们的main函数。我们已经定义了匹配器和匹配回调(即打印机),但我们仍然需要添加一些东西。首先,我们需要使用匹配查找器对象编写一个AST匹配器,然后使用ClangTool运行它。

首先,我们创建匹配查找器,然后需要注册我们的匹配器。一个注意事项是我们设置的选项,用于忽略非源拼写的节点。AST匹配器的默认操作模式(称为ASTMatchFinder::TK_AsIs)可以访问源树中未明确拼写的节点,因此它们不明显匹配。选择使用此模式忽略非源拼写的AST节点,可以更轻松地创建匹配器。

int main(int argc, const char **argv) {
  auto ExpectedParser = CommonOptionsParser::create(argc, argv, MyToolCategory);
  // 错误处理...
  CommonOptionsParser &OptionsParser = *ExpectedParser;
  ClangTool Tool(OptionsParser.getCompilations(),
                 OptionsParser.getSourcePathList());

  MatchFinder Finder;
  MatchPrinter Printer;
  Finder.addMatcher(LoopMatcher, &Printer);

  return Tool.run(newFrontendActionFactory(&Finder).get());
}

最后,我们需要从ClangTool运行匹配器。为了运行它,我们需要CommonOptionsParser结构来解析参数并创建编译数据库。我们使用getCompilationsgetSourcePathList来检索编译数据库和输入文件路径列表。一旦有了这些,我们就可以围绕我们的前端操作在一些代码上创建一个ClangTool。

构建与测试

现在你已准备好测试它。我将使用Ninja作为生成器,用CMake构建该工具。我不会详述细节,但会展示一切是如何设置的。

lib文件夹中,有我们创建的matcher_finder.cpp。然后我们需要添加一个CMakeLists.txt文件。

# 查找Clang及其库
find_package(Clang REQUIRED)
# 为源文件创建可执行文件
add_executable(matcher_finder matcher_finder.cpp)
# 链接Clang Tooling库
target_link_libraries(matcher_finder PRIVATE clangTooling)

在我们的根目录中,有另一个CMakeLists.txt,用于设置最低CMake版本、本项目使用的C++标准、项目名称、构建类型,并准备将我们的可执行文件放入lib目录。

cmake_minimum_required(VERSION 3.20)
project(llvm_matcher_tutorial)
set(CMAKE_CXX_STANDARD 17)
add_subdirectory(lib)

最后,运行CMake生成Ninja构建文件并启动构建过程。这是唯一需要更改的部分,你应该添加你的LLVM构建目录的路径。

mkdir build && cd build
cmake -G Ninja -DLLVM_DIR=/path/to/your/llvm/build/lib/cmake/llvm ..
ninja

运行脚本后,我们的二进制文件应位于build/bin目录内。现在,你只需传入你的文件并运行它。

./build/bin/matcher_finder your_source_file.cpp

总结

本节课中,我们一起学习了Clang AST匹配器库。我们了解了AST匹配器的核心概念,包括节点匹配器、细化匹配器和遍历匹配器。我们通过一个查找被初始化为0的for循环变量的示例,逐步演示了如何设计匹配器表达式、使用bind方法提取节点信息、编写匹配回调函数,并最终利用Clang Tooling库构建和运行一个独立的AST分析工具。掌握AST匹配器能极大地简化在Clang AST中查找特定模式的过程,是进行源代码分析和重构的强大工具。

022:LLVM测试套件 🧪

在本节课中,我们将要学习如何安装和使用LLVM测试套件。LLVM测试套件是一组用于测试LLVM编译器正确性和性能的程序集合,它也可以作为辅助工具,在开发过程中测试你的自定义工具或优化。

什么是LLVM测试套件?

LLVM测试套件是一组用C、C++或Fortran编写的程序集合。通过这些程序,我们可以测试LLVM编译器的正确性或性能。同时,我们也可以使用测试套件作为开发过程的辅助工具,用它来测试你的工具或优化过程。

安装LLVM测试套件

安装LLVM测试套件的过程可以分为三个步骤:满足要求、下载源码、编译构建。

步骤一:安装要求

以下是安装前需要满足的要求:

  • CMake:用于生成编译文件。
  • LLVM集成测试器(lit):这是我们将用来运行测试的测试运行器。获取此工具有两种方式:
    • 使用 pip 安装。
    • 如果你已经有一个LLVM构建版本,可以在其二进制文件目录中找到它。

步骤二:下载源码

有两种方式可以下载测试套件源码:

  1. 从GitHub克隆仓库。
  2. 使用 wget 下载 .tgz 压缩包并解压。

重要提示:请确保下载与你使用的LLVM编译器版本兼容的测试套件版本。例如,如果你使用LLVM 17版,请克隆或检出 release/17.x 分支的测试套件,或者下载17版的 .tgz 压缩包。

步骤三:编译源码

现在我们已经有了源码文件,让我们学习如何编译它们。

首先,创建一个构建目录,例如 test-suite-build。然后,在该目录中使用 cmake 命令配置编译文件。命令示例如下:

cmake -DCMAKE_C_COMPILER=/path/to/your/llvm-build/bin/clang -C../test-suite/cmake/caches/O3.cmake ../test-suite

在这个命令中:

  • -DCMAKE_C_COMPILER 指定了你的LLVM编译器(clang)的路径。
  • -C 指定了要使用的CMake缓存配置文件(例如 O3.cmake)。
  • 最后一个参数 ../test-suite 是你之前下载的测试套件源码目录的路径。

一个关键注意事项:如果你使用自己构建的LLVM,需要在构建LLVM时启用 LLVM_INSTALL_UTILS 标志。这是因为测试套件中的微基准测试需要此标志才能完成编译过程。没有它,你只能编译大约一半的基准测试。

配置完成后,只需运行 make 命令并等待编译过程结束。

测试套件结构解析

现在我们已经构建好了LLVM测试套件,让我们了解一下它的目录结构。

  • SingleSource:此目录包含仅由单个源文件组成的简单程序。
  • MultiSource:此目录包含由多个源文件编写的大型基准测试和完整应用程序。
  • MicroBenchmarks:这是之前提到的需要启用 LLVM_INSTALL_UTILS 标志才能编译的基准测试目录。其中的程序使用了Google Benchmark库,需要运行多次以获得具有统计意义的结果。
  • External:如果你想添加新的基准测试套件,可以将它们放在这个文件夹中。

使用LLVM集成测试器(lit)

正如之前提到的,lit 工具是运行测试的必要条件。它作为LLVM测试套件的测试运行器,可以运行单个测试或整个目录的测试。

lit 的一些常用选项包括:

  • --output:指定输出文件,通常是一个JSON文件。
  • --num-workers:指定运行测试时要使用的CPU核心数。
  • -v:启用详细输出模式。当测试失败时,输出信息会更详细。

运行测试示例

为了简化说明,我们来看几个使用 lit 运行测试的例子。

示例1:运行整个测试套件

llvm-lit -v --num-workers=1 --output=results.json .

这个命令在 test-suite-build 目录内运行。lit 将搜索整个构建目录树中的测试文件,并为每个文件运行测试。

示例2:运行特定基准测试集

llvm-lit -v --num-workers=4 ./SingleSource

这个命令将只运行 SingleSource 目录下的所有基准测试。

示例3:运行单个基准测试

llvm-lit -v --num-workers=4 ./SingleSource/Benchmarks/Polybench

这个命令将仅运行 Polybench 这一个基准测试。

运行结果通常如下所示:第一行显示在目录中找到的测试文件数量和使用的核心数。对于每个测试,会显示是通过还是失败,并可以找到一些执行指标,如编译时间、执行时间等。

添加外部测试套件

最后,让我们学习如何添加外部测试套件。首先需要知道,你要添加的测试或套件必须与LLVM测试套件兼容。

这意味着它们必须遵循与测试套件中基准测试相同的结构,以便 lit 能够运行它们。我们可以将套件添加到 External 文件夹中。基本上,你需要将想要运行的套件的源码文件放到 External 目录下。

此外,还有一个链接套件的选项。LLVM支持链接像 SPEC 这样的外部套件。在编译过程中,我们可以使用一个标志来指定 SPEC 构建的路径,这样LLVM测试套件就会集成 SPEC 而无需额外步骤。

总结

本节课中,我们一起学习了LLVM测试套件的安装与使用。我们了解了测试套件的用途和结构,掌握了通过三个步骤(满足要求、下载、编译)来安装它。我们还学习了如何使用 lit 工具来运行整个套件、特定目录或单个程序的测试,并了解了添加外部兼容测试套件的基本方法。测试套件是验证编译器行为和性能的强大工具,希望本教程能帮助你开始使用它。

023:LLVM测试套件 - 第二部分 🧪

在本节课中,我们将学习如何使用LLVM集成测试器(Lit)来创建我们自己的测试。我们将从零开始构建一个测试套件,并了解如何配置和运行测试。


概述

上一节我们介绍了LLVM测试套件的基本概念。本节中,我们将深入探讨如何使用Lit工具来创建和管理自定义测试。Lit是一个简单的Python脚本,负责运行LLVM编译器文件中的所有测试。我们可以在LLVM环境之外使用它,甚至可以通过pip单独安装。

为什么使用测试运行器?

如果你正在为LLVM开发工具,那么使用测试结构是必要的,因为LLVM编译器的主要测试运行器就是Lit。即使在LLVM结构之外,Lit也提供了一些有趣的功能。

以下是使用Lit的几个主要原因:

  • 测试发现:无需手动传递每个测试文件,你可以将整个目录或测试套件传递给工具,让它自动搜索并运行测试文件。
  • 易于使用:只需三行代码即可配置整个测试。
  • 脚本简单:测试文件或测试脚本是一小段Shell代码,Lit会自动为你运行。

安装Lit

安装Lit工具有两种方法:

  1. 使用pip安装:pip install lit
  2. 如果你已经构建了LLVM,可以在其二进制文件中找到它。

在本视频中,我们将从零开始创建一个测试套件,演示如何创建测试和配置测试结构。需要注意的是,我们的测试结构应遵循LLVM测试套件的结构,但这并不复杂,我们稍后会看到。

创建测试套件结构

为了简化演示,我创建了一个简单的测试套件。该套件具有以下结构:

mytest/
├── lit.cfg.py
└── test/
    ├── palindrome/
    │   ├── palindrome.cpp
    │   ├── palindrome.test
    │   ├── input.txt
    │   └── output.txt
    └── square/
        ├── square.cpp
        ├── square.test
        ├── input.txt
        └── output.txt
  • mytest/ 是主目录,即项目根目录。
  • lit.cfg.py 是Lit配置文件,用于加载测试套件的所有配置。
  • test/ 目录下包含各个程序的测试目录。

配置文件详解

lit.cfg.py 是一个简单的Python脚本,我们需要将配置传递给工具。首先需要导入lit模块。

该文件中有许多可用配置,但只有两个是必需的:

  1. name:测试套件的名称,例如 config.name = 'My Test Suite Example'
  2. test_format:需要告知我们将要运行的测试类型,这里我们使用Shell测试:config.test_format = lit.formats.ShTest()

第三个配置 config.suffix 是可选的,我在这里告知Lit测试文件将以 .test 结尾:config.suffix = '.test'

测试程序示例

test/ 目录下,我们有一些程序目录。第一个是 palindrome/(回文检查程序)。

  • palindrome.cpp 是一个程序源文件。这是一个非常简单的程序,用于验证一个单词是否是回文。它从外部输入读取一个单词,如果单词是回文则输出1,否则输出0。
  • input.txt 是输入文件,我传入了单词 "Reviver"。
  • output.txt 是预期输出文件,内容就是 1,因为 "Reviver" 是一个回文。
  • palindrome.test 是用于测试程序是否正确的Shell脚本。

测试脚本解析

以下是测试文件的内容。第一行,我编译了程序。第二行,我调用程序并将实际输出与预期输出进行比较。

# RUN: clang++ %s -o %t
# RUN: %t < %S/input.txt | diff %S/output.txt -

Lit为我们提供了一些在Shell脚本中使用的宏,最常见的宏是:

  • %s:这个宏表示当前测试文件。
  • %S:这个宏表示测试目录,即当前测试文件所在的目录。
  • %t:这是一个临时文件。

如你所见,在Shell脚本的第一行,我通过给程序命名 %t 来调用程序。第二行,我通过 %t 调用程序,并将其输出与预期输出进行比较。

最后需要知道的是,最后一行代码的退出代码将指示测试是否通过。如果退出代码是0,测试将通过;如果退出代码是1或任何非零数字,测试将不会通过。

对于 square/ 程序,我也在做同样的事情。

运行测试

配置好所有文件后,运行测试的过程就很简单了。

如果你想为整个测试套件(套件内的所有程序)运行测试,可以调用Lit并指向整个测试套件目录。

如果你只想为特定程序运行测试,可以调用它并指向所需的目录。例如,以下命令仅对 square 程序运行测试:

lit test/square/

输出将类似以下内容:第一行指示工具在你传递的目录中找到的测试数量(如果对整个目录运行,会找到两个测试)。之后的每一行将指示找到的测试名称(例如 palindrome.testsquare.test)以及测试是否失败。

总结

本节课中,我们一起学习了如何使用LLVM集成测试器(Lit)来创建和运行自定义测试。我们了解了测试套件的基本结构,如何编写配置文件(lit.cfg.py),以及如何编写包含Lit宏的Shell测试脚本。最后,我们掌握了运行整个测试套件或单个程序测试的方法。通过遵循这些步骤,你可以为自己的项目构建一个简单而强大的测试框架。

024:如何为任意LLVM Pass构建测试套件 - 第一部分

在本节课中,我们将学习如何将一个自定义的LLVM Pass应用到整个LLVM测试套件中,并编译生成可执行文件。这个过程对于评估Pass在真实程序上的效果至关重要。

大家好,我是Hafaovaangga,是UFMG大学编译器实验室的一名研究生研究员。我目前的工作是在LLVM中实现代码压缩技术。

问题与目标

上一节我们介绍了LLVM Pass的基本概念。本节中我们来看看一个实际问题:如何评估自定义Pass在整个测试套件上的效果?我们的目标是使用特定的Pass来编译测试套件中的二进制文件。

为了实现这个目标,我们需要完成以下步骤:

  1. 首先构建项目,并使用lit运行测试以收集基线性能指标。
  2. 然后,重新构建测试套件,但这次要应用我们的自定义Pass。
  3. 最后,收集应用Pass后的指标,并与基线进行比较。

核心挑战与解决方案

但是,在开始之前,我们需要对cmake项目进行补丁修改。这是必要的,因为标准的LLVM测试套件没有提供直接使用指定Pass来编译二进制文件的方法。

以下是解决方案的核心流程:

  1. 首先,使用测试套件默认的CI配置选项进行构建。
  2. 接着,从生成的可执行文件中提取出完全链接后的Bitcode文件(.bc)。
  3. 然后,使用opt工具对我们的Bitcode文件应用自定义Pass。
  4. 最后,使用clang将处理后的Bitcode文件重新编译为可执行文件。

这个过程可以用以下代码流程描述:

# 1. 构建测试套件(基线)
cmake ... -DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++ ...
make

# 2. 提取Bitcode (假设通过某种方式获得 .bc 文件)
extract_bc_from_executable ${EXECUTABLE} -o ${EXECUTABLE}.bc

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/llvm-intro/img/523fb2a566da04e318a2ea31a5c4e5ef_1.png)

# 3. 应用自定义Pass
opt -load=MyPass.so -mypass ${EXECUTABLE}.bc -o ${EXECUTABLE}.optimized.bc

# 4. 重新编译为可执行文件
clang ${EXECUTABLE}.optimized.bc -o ${EXECUTABLE}.optimized

感谢观看。在下一个视频中,我将向大家展示如何具体应用这个补丁并使用它。

本节总结

本节课我们一起学习了为自定义LLVM Pass构建测试套件的整体思路和高级解决方案。我们明确了目标是通过对比基线指标和优化后指标来评估Pass效果,并指出了需要修改构建系统的核心挑战。关键步骤包括:构建基线、提取Bitcode、应用Pass、重新编译。下一节我们将进入实践环节。

025:如何为任意LLVM Pass构建测试套件 - 第二部分 🛠️

在本节课中,我们将学习如何应用上一节讨论的补丁,并利用它来配置环境、运行测试套件以及收集和分析程序指标数据。


大家好,我是Hafao Vaningga,佛罗里达大学编译器实验室的Gwi研究员,主要研究LLVM中的代码压缩技术。本视频将展示如何应用上一节讨论的补丁,以及如何使用它。

上一节我们介绍了补丁的基本概念,本节中我们来看看具体的应用步骤。

环境与前提假设

我们假设LLVM测试套件已经配置完成,目标是收集特定的程序指标。我将指导你设置环境以运行测试并收集结果。

安装必要的Python库

测试套件包含多个Python脚本,用于帮助分析代码指标。要使用它们,需要先安装一些依赖库。

以下是需要安装的库:

  • pandas
  • scipy
  • psutil

你可以使用以下命令进行安装:

pip install pandas scipy psutil

应用补丁与配置构建

我们将使用Ninja作为构建系统。要应用补丁,你可以选择克隆已打好补丁的测试套件版本,或者根据幻灯片中的提交链接手动应用补丁。

应用补丁后,进入测试套件目录并创建一个构建文件夹。我们将使用CMake进行配置,具体设置如下:

  • 使用Ninja作为构建系统。
  • 指定clang为编译器。
  • 启用-flto标志以生成Bitcode文件。
  • 强制链接器创建未优化的链接后Bitcode。
  • 启用对Bitcode文件应用Pass的选项。
  • 指定要运行的Pass。

我们将使用单源和多源测试套件,并对所有二进制文件使用-O1优化标志。

编译与运行测试(第一轮)

现在可以编译测试套件了。编译完成后,我们将运行lit来收集-O1优化级别下的指标数据。

之后,我们需要清理构建缓存,为第二轮运行做准备。

编译与运行测试(第二轮)

对于第二轮运行,我们重复使用相同的CMake命令,但将优化标志设置为-O2。重新编译后,再次运行lit以收集-O2级别的指标数据。

结果比较与分析

最后,我们将比较两轮运行的结果。测试套件提供了一个Python脚本,可以生成包含差异计算的详细报告,比较指令计数和代码大小等指标。

你需要指定要比较的文件以及保存结果的位置。如果需要,你还可以使用tail命令在终端中显示结果。


本节课中我们一起学习了如何为自定义的LLVM Pass应用补丁、配置并运行LLVM测试套件,以及如何收集和比较不同优化级别下的程序性能指标。这为分析和验证Pass的效果提供了完整的工作流程。

posted @ 2026-03-29 09:19  绝不原创的飞龙  阅读(5)  评论(0)    收藏  举报