LLVM Pass入门导引

0. 前言

最近,我需要对C代码插桩,而听闻从 LLVM Pass 着手插桩很方便。 于是我花了一段时间来研究了 LLVM。

从官方文档着手研究 LLVM 并不是什么愉快的过程。 官方文档虽然足够丰富,但是分散得十分凌乱。 它并没有一个入口告诉你,想要写出自己的 LLVM Pass,需要按照什么顺序阅读什么内容。 我只能凭借自己的经验和直觉概览文档,配合搜索引擎逐步探索。 但最后,在亲自完成一个 LLVM Pass 后,我又不得不感慨 LLVM Pass 确实是一个方便且有趣的技术。

为了能让大家更愉快地体验 LLVM 的技术,又不至于浪费过多时间在探索应该按照什么顺序阅读什么文档上, 我决定撰写这篇“入门导引”。

本文之所以称为“入门导引”,而非是“入门”, 是因为我只是希望为读者指明一条可行的路线去入门 LLVM Pass, 希望通过自己的经验来减少各位读者踩坑的时间,而非是把每个技术细节都自成体系地写在文章里。 真正的入门,还需要读者结合实践读文档,根据具体的需求逐步深入。

虽然如此,但实际上只是作者我个人水平有限,接触 LLVM 时间也不长,所以自然不敢妄加更深的指导。如果我是 LLVM 领域的专家,我自然不吝真正写一篇担得上“入门”之称的文章。现在,我只足够把自己踩坑的经验分享出来。 如果我的分享给读者带来了帮助,读者可以给我点个赞。我当喜不自胜。 如果文章有问题存在,欢迎读者指出。

本文的主要内容如下:

  • LLVM 简介
  • Clang 简介
  • 安装 LLVM 与 Clang
  • LLVM IR
  • 编写 LLVM Pass
  • 参考项目

1. 准备工作

本文需要读者有编译原理的背景知识,以及对常见的编译过程、编译参数有一定了解。

本文基于Ubuntu 16.04。但或许类Unix平台都适用。 如果有行不通的地方,读者可能还是得根据自己的经验变通,或者亲自查阅官方文档。 欢迎在评论区分享解决方案。

2. LLVM 简介

LLVM是什么?

学过编译原理的人都知道,编译过程主要可以划分为前端与后端:

  • 前端把源代码翻译成中间表示 (IR)。
  • 后端把IR编译成目标平台的机器码。当然,IR也可以给解释器解释执行。

然而,经典的编译器如gcc在设计上都是提供一条龙服务的: 你不需要知道它使用的IR是什么样的,它也不会暴露中间接口来给你操作它的IR。 换句话说,从前端到后端,这些编译器的大量代码都是强耦合的。

这样做有好处也有坏处。好处是,因为不需要暴露中间过程的接口,它可以在内部做任何想做的平台相关的优化。 而坏处是,每当一个新的平台出现,这些编译器都要各自为政实现一个从自己的IR到新平台的后端。 甚至如果当一种新语言出现,且需要实现一个新的编译器,那么可能需要设计一个新的IR,以及针对大部分平台实现这个IR的后端。 不妨想一下,如果有M种语言、N种目标平台,那么最坏情况下要实现 M*N 个前后端。这是很低效的。

因此,我们很自然地会想,如果大家都共用一种IR呢? 那么每当新增加一种语言,我们就只要添加一个这个语言到IR的前端; 每当新增加一种目标平台,我们就只要添加一个IR到这个目标平台的后端。 如果有M种语言、N种目标平台,那么最优情况下我们只要实现 M+N 个前后端。

LLVM就是这样一个项目。LLVM的核心设计了一个叫 LLVM IR 的中间表示, 并以库(Library) 的方式提供一系列接口, 为你提供诸如操作IR、生成目标平台代码等等后端的功能。

那么 LLVM Pass 又是什么呢? Pass就是“遍历一遍IR,可以同时对它做一些操作”的意思。翻译成中文应该叫“趟”。 在实现上,LLVM的核心库中会给你一些 Pass类 去继承。你需要实现它的一些方法。 最后使用LLVM的编译器会把它翻译得到的IR传入Pass里,给你遍历和修改。

那LLVM Pass有什么用呢?

  1. 显然它的一个用处就是插桩,毕竟这是我本来想利用它做的事情: 在Pass遍历LLVM IR的同时,自然就可以往里面插入新的代码。
  2. 机器无关的代码优化:大家如果还记得编译原理的知识的话,应该知道IR在被翻译成机器码前会做一些机器无关的优化。 但是不同的优化方法之间需要解耦,所以自然要各自遍历一遍IR,实现成了一个个LLVM Pass。 最终,基于LLVM的编译器会在前端生成LLVM IR后调用一些LLVM Pass做机器无关优化, 然后再调用LLVM后端生成目标平台代码。
  3. 静态分析: 像VSCode的C/C++插件就会用LLVM Pass来分析代码,提示可能的错误 (无用的变量、无法到达的代码等等)。
  4. …… (自行发挥想象)

再次强调,LLVM的核心是一个库,而不是一个具体的二进制程序。 不过,LLVM这个项目本身也基于这个库实现了周边的工具, 下面列出了几个重要的命令行工具,光看名字就可以知道它们大概在做什么:

  • llvm-as:把LLVM IR从人类能看懂的文本格式汇编成二进制格式。注意:此处得到的不是目标平台的机器码。
  • llvm-disllvm-as的逆过程,即反汇编。 不过这里的反汇编的对象是LLVM IR的二进制格式,而不是机器码。
  • opt:优化LLVM IR。输出新的LLVM IR。
  • llc:把LLVM IR编译成汇编码。需要用as进一步得到机器码。
  • lli:解释执行LLVM IR。

如果现在无法想象什么“文本格式”“二进制格式”也没关系。 在后面的LLVM IR一节中,读者了解完LLVM IR后,我会再简单介绍一下这些指令的使用。

另外,此时在有了LLVM的概念以后,我们可以打开github上的LLVM的源代码

来更直观地了解一下这个项目(这里LLVM的源代码隶属于llvm project仓库。这个仓库我们会在后面4.2节中再解释)。

  1. 根目录下,最重要的就是include和lib这两个文件夹。include文件夹包含了其它项目在使用LLVM核心库时需要包含的头文件,而lib文件夹里放的就是LLVM核心库的实现。分别打开lib和include,可以看到很多文件与子文件夹。 有经验的读者应该能从名字大概猜到其实现的东西。比如,lib/IR子文件夹肯定是存放了与IR相关的代码,lib/ Target子文件夹肯定与生成目标平台机器码有关。又比如,include/llvm/Pass.h文件里面声明了Pass类用来给你继承去遍历、修改LLVM IR。 当然,我们现在不必知道每个模块是干什么的。 等有需要再去查看官方文档吧。
  2. 根目录下还有一个tools文件夹,这里面就存放了我上面所说的周边工具。 打开这个目录,就可以看到类似llvm-as这样的子目录。显然这就是llvm-as的实现。

关于LLVM的介绍就到此为止了。我想也应该足够了。 如果读者还想了解更多,可以去看看官方文档所推荐的介绍文章。 其中我看过的是Intro to LLVM,讲得挺不错的。

3. Clang 简介

因为接下来我们还会用到Clang,所以我们还是要在这里介绍一下 Clang。

那么Clang是什么呢?想必有了上一节的铺垫,读者应该很容易自己想到了。Clang是一个基于LLVM的编译器驱动。它提供了把C/C++/OC等语言翻译成LLVM IR的前端,并使用LLVM的库实现了LLVM IR到目标平台的后端。

为什么叫编译器驱动呢?因为在你使用clang main.c -o main (clang兼容gcc的语法)的时候, Clang帮你“驱动”C语言预处理器、C语言前端、LLVM后端、链接器等等。 其实GNU gcc也是编译器驱动。不过为了方便,我们还都是叫它们编译器吧。

可能有人会问,Clang怎么读?我一开始读的是 /see-lang/,不过网上搜索一番,很多人读的是 /klang/。

本文主要的目标是LLVM Pass。所以Clang的介绍到此为止。 我会在LLVM IR一节介绍一下clang的使用。

4. 安装 LLVM 与 Clang

既然我们的目标是写一个 LLVM Pass,那么我们自然就需要安装 LLVM 提供的库了。 另外,我们也需要用 Clang 编译得到 LLVM IR,所以 Clang 也是必须要安装的。

LLVM 和 Clang 都可以通过下载源代码编译安装,也可以直接安装预编译好的包, 比如在Ubuntu 上就可以通过apt安装。

这里讲一个坑。从理论上想想,我们也会觉得,只要安装好了 LLVM 的库,就可以编译 LLVM Pass 了。 然而,官方文档里给的示例 Writing an LLVM Pass 是需要把 Pass 的源代码放在LLVM项目中一起编译的。 所以我盲信官方研究了一天如何编译 LLVM 以及 Clang (手动微笑)。

更夸张的是,LLVM和Clang在默认配置参数 (Debug模式) 下会占据大量磁盘空间 (十多个GB)。 所以不建议大家走源码编译的这条路。但是我还是会把编译源码的方法放在下面。因为只要配置参数设置合理,最后编译出的结果只需要占据 500MB 左右 (基于LLVM 5.0版本)

那我们开始吧。

4.1. 预编译包的安装

在Ubuntu上,可以直接通过apt安装llvm和clang:

$ sudo apt install llvm
$ sudo apt install clang

Ubuntu 16.04的apt默认安装的版本是clang-3.8和llvm-3.8。 然而在我写这篇文章的时候,llvm的stable版本已经是10.0了。 所以读者如果需要安装更高的版本,有两种方法:

  1. 使用sudo apt install llvm-x.y clang-x.y来指定安装当前源可获取的版本。一般可以通过在输入完sudo apt install llvm-时按下tab键查看有哪些版本可以获取。这个方法的局限是:可安装的最高版本一般不会是最新版本。
  2. 想要安装最新版本,可以根据 apt.llvm.org这个网站列出的方法配置源并安装。

其它平台可能需要自己寻找如何安装预编译的LLVM和Clang的方法。当然也可能有的平台已经自带安装了。

后续我们会用到clangoptllvm-config等指令。请记得把它们加进PATH 环境变量中。此外,有一种可能是,读者在安装后仅会得到像 clang-5.0 这样带有版本后缀的可执行文件 (而不是clang这样不带后缀的),抑或是同时安装了好几个不同的版本,包含诸如 clang-6.0clang-10.0 这样多个版本的可执行文件。在这些情况下,读者可以通过 update-alternatives指令 (自行搜索使用方法) 或手动使用 ln 指令来创建一个软链接/usr/bin/clang 指向你所需要的版本的可执行文件。

4.2. 编译安装

如果需要编译安装,可以从releases.llvm.org下载你想要的版本的源代码。

如果打开10.0.0这个版本的下载链接, 你可能会看到如下好多源代码包:

Sources:

    llvm-project monorepo source code (.sig)
    LLVM source code (.sig)
    Clang source code (.sig)
    compiler-rt source code (.sig)
    ...
    clang-tools-extra (.sig)
    LLVM Test Suite (.sig)

不要慌。让我们首先来讲讲背景。

LLVM 项目本身和其它基于它实现的项目如 Clang、libc++ 都会作为子项目加入 LLVM Project。Github 上有 LLVM Project 的源代码,这个仓库正是我们之前在介绍LLVM源代码时提到过的。可以看到,LLVM Project 作为一个大合集,把所有子项目的代码都并排地放在了一起。此外,LLVM Project 还有一个需要注意的编译规则:在编译的时候,所有子项目必须平行地放在同一个目录中

现在回到上面的源代码包列表中,除开第一行“llvm-project monorepo source code”, 其它每一行都代表了一个加入到了 LLVM Project 的项目的源代码。 那么顾名思义,“llvm-project monorepo source code”也就代表了打包所有子项目源代码的 LLVM Project。 如果你解压下来看,它的目录结构是和 Github 仓库基本一致的。

如果只需要编译LLVM和依赖它的Clang,那么我们可以只下载 LLVM source code 和 Clang source code。 不过其它子项目倒也不大,所以空间富裕的也可以直接下载大礼包。

如果我们下载的不是大礼包,那么在llvm和clang的源代码下载好以后, 我们首先创建一个目录,取任何你想要的名字都可以,不过我们按照官方的结构就叫它llvm-project好了。

然后按照上面提到的LLVM Project的编译规则, 我们把下载好的LLVM和Clang源代码的tarball解压在这个目录下面,分别重命名为llvm和clang。 总之,只要和LLVM Project中的目录结构一致就不会出问题。

如果下载的是大礼包,那么解压开来会得到一个llvm-project-<version>的目录。 后面我们也用llvm-project来指代这个目录。

接下来正式编译。 读者可以参考官方的 Getting Started。 里面还提了一下编译工具链的版本要求。

之前我提到过,如果按照默认参数编译,那么生成的文件会很大,可能会超过10个G。 所以我在这里首先介绍一下重要的编译参数,以及如何减少生成文件的大小。

  • -DLLVM_ENABLE_PROJECTS='clang': 这个参数告诉llvm还要编译clang。如果有需要,可以加入其它子项目。
  • -DCMAKE_BUILD_TYPE=Release: 在cmake里,有四种编译模式:Debug, Release, RelWithDebInfo, 和MinSizeRel。 这里BUILD_TYPE的取值就可以是这四个中的一个。 Debug相当于-g,Release相当于-O3, RelWithDebInfo相当于-O2 -g,MinSizeRel相当与-Os。 我们这里使用Release模式编译会省很大的空间。因为少了很多Debug信息。
  • -DLLVM_TARGETS_TO_BUILD='X86': 这个参数指定编译的目标平台。所谓目标平台,就是编译出的clang能支持的后端。这个参数的默认值是所有平台,所以在默认值下编译会很占磁盘空间。在x86平台上,如果没有交叉编译的需求,我们把这个参数设置为X86就行了。如果想要知道clang还支持哪些目标平台,可以参考上面提到的官方文档。
  • -DBUILD_SHARED_LIBS=On: 这个参数指定使用动态链接来链接LLVM的库。 因为默认取值Off代表静态链接,这会导致LLVM库被重复复制到好多可执行文件中,所以可以设置为On节省空间。

现在,我们可以编译了:

$ cd llvm-project
$ mkdir build && cd build
$ cmake -G "Unix Makefiles" -DLLVM_ENABLE_PROJECTS="clang" \
    -DCMAKE_BUILD_TYPE=Release -DLLVM_TARGETS_TO_BUILD="X86" \
    -DBUILD_SHARED_LIBS=On ../llvm
$ make

如果读者对make有了解,应该知道可以使用make -j<N>并行编译来加速。 不过如果并行编译出了问题,那么可以去掉-j<N>试试。 根据主机的性能,编译时长会从几分钟到一个多小时不等。 接下来,可以喝口茶休息一会了。

5. LLVM IR

关于LLVM IR,我的了解也不是很深入,不敢班门弄斧。所以请读者首先阅读

。 它是来自于一个会议的presentation。 另外,喜欢练习听力的读者可以去Youtube看对应的视频

看完后,读者现在应该对LLVM IR有了基本了解了。我就来讲点琐碎的。

大家肯定知道,LLVM IR实际上有三种表示:

  1. .ll 格式:人类可以阅读的文本。
  2. .bc 格式:适合机器存储的二进制文件。
  3. 内存表示

首先,.ll格式和.bc格式是如何生成并相互转换的呢?下面我列了个常用的简单指令清单:

  • .c -> .ll:clang -emit-llvm -S a.c -o a.ll
  • .c -> .bc: clang -emit-llvm -c a.c -o a.bc
  • .ll -> .bc: llvm-as a.ll -o a.bc
  • .bc -> .ll: llvm-dis a.bc -o a.ll
  • .bc -> .s: llc a.bc -o a.s

可以看到,clang通过-emit-llvm参数, 使得原本要生成汇编以及机器码的指令生成了LLVM IR的ll格式和bc格式。 这可以理解为:对于LLVM IR来说,.ll文件就相当于汇编,.bc文件就相当于机器码。 这也是llvm-asllvm-dis指令为什么叫asdis的缘故。

如果想要更详细地了解llvm的相关工具,请查阅官方文档 LLVM CommandGuide。 对于clang,请查阅官方文档 User Manual

其次,LLVM IR的内存表示在写LLVM Pass的时候会用到。读者可以现在阅读官方文档 ProgrammersManual - The Core LLVM Class Hierarchy Reference 这一小节来学习。 如果看得不是很懂,可以先行跳过。后面我会在合适的时机提醒你再去看一遍。

6. 编写 LLVM Pass

6.1. 第一个 Pass:Hello Pass

现在万事俱备,我们可以开始学习编写一个经典的Hello Pass了。

请读者仔细阅读官方文档

IntroductionQuick Start 这两部分,然后略读 Pass classes and requirementsPass Statistics 这两部分。

在官方文档中,Hello Pass是基于源代码项目构建的。 如果你没有源代码,那么构建Pass的部分可以简单略读。 但无论如何,在读完这部分内容后,你手头至少需要有一份如下的Hello.cpp(为了排版紧凑稍有等价删改), 并且对它大概做了什么有一定了解:

#include "llvm/Pass.h"
#include "llvm/IR/Function.h"
#include "llvm/Support/raw_ostream.h"
#include "llvm/IR/LegacyPassManager.h"
#include "llvm/Transforms/IPO/PassManagerBuilder.h"

using namespace llvm;

namespace {
  struct Hello : public FunctionPass {
    static char ID;
    Hello() : FunctionPass(ID) {}
    bool runOnFunction(Function &F) override {
      errs() << "Hello: ";
      errs().write_escaped(F.getName()) << '\n';
      return false;
    }
  };
}

char Hello::ID = 0;

// Register for opt
static RegisterPass<Hello> X("hello", "Hello World Pass");

// Register for clang
static RegisterStandardPasses Y(PassManagerBuilder::EP_EarlyAsPossible,
  [](const PassManagerBuilder &Builder, legacy::PassManagerBase &PM) {
    PM.add(new Hello());
  });

接下里我们需要编译它,得到一个动态库:LLVMHello.so (名字可以随意取)。下面介绍三种方式。

源代码编译

对于有源代码的读者,刚才阅读的官方文档中有描述如何编译。此处不再赘述。

使用cmake编译

使用cmake编译是比较推荐的方法,具体请参考官方文档cmake out-of-source这一节。

这里简单说明一下使用cmake编译基于LLVM的项目的原理,以及为什么推荐这种方式。因为LLVM是使用cmake构建的项目,因此在安装的时候它会附上它自己使用cmake配置的包信息。包信息主要包含了头文件的位置、动态库的位置、必要的编译和链接的参数等等。对于那些要使用LLVM库的项目来说,这些包信息显然是必要指定的,因此我们就可以使用cmake提供的find_package(LLVM)来方便地引用这些包信息,否则就需要手动指定 (参考下面命令行编译)。具体原理请学习cmake。 cmake examples是一个很好的入门cmake的github项目。

另外,Ubuntu 16.04默认安装的llvm-3.8这个版本附带的cmake包信息有bug。请读者自行修复或选择其它版本,或者采用下面讲解的命令行编译。

命令行编译

命令行编译是最简单暴力的方法。以Hello Pass为例:

$ clang `llvm-config --cxxflags` -Wl,-znodelete -fno-rtti -fPIC -shared Hello.cpp -o LLVMHello.so `llvm-config --ldflags`

其中

  • llvm-config提供了CXXFLAGSLDFLAGS参数方便查找LLVM的头文件与库文件。 如果链接有问题,还可以用llvm-config --libs提供动态链接的LLVM库。 具体llvm-config打印了什么,请自行尝试或查找官方文档。
  • -fPIC -shared 显然是编译动态库的必要参数。
  • 因为LLVM没用到RTTI,所以用-fno-rtti 来让我们的Pass与之一致。
  • -Wl,-znodelete主要是为了应对LLVM 5.0+中加载ModulePass引起segmentation fault的bug。如果你的Pass继承了ModulePass,还请务必加上。

现在,你手中应该有一份编译好的LLVMHello.so了。根据刚才阅读过的官方文档的介绍,你知道可以通过命令

$ clang -c -emit-llvm main.c -o main.bc # 随意写一个C代码并编译到bc格式
$ opt -load path/to/LLVMHello.so -hello main.bc -o /dev/null

来使用它。

6.2. 结合 Clang 使用 Pass

当代码文件比较多的时候,你会觉得先把源代码编译成IR代码,然后用opt运行你的Pass实在麻烦且无趣。 恰好在你手头已有一些构建工具时,你可能会想,如果能把Pass集成到clang的参数中调用,那该多好啊。 因为这样你就可以做这样的事情 (假设你的构建工具是autotools):

$ CC=clang CFLAGS="-arg-to-load-my-pass mypass.so" ./configure
$ make

下面这篇文章就告诉了你该怎么做,请仔细阅读。当你读完后,你可能会觉得,这魔法参数也太丑陋了吧。我也觉得。“Maybe this is life”。

现在回头看看前面Hello.cpp,有留意到里面的两行注释吗? static RegisterPass<Hello> X是给opt加载Pass用的, static RegisterStandardPasses Y是给clang加载Pass用的, 有时候两者只要选一个就行了。希望在读完上面这篇文章后你能理解得更深入。

现在,你可以在clang中直接加载Hello Pass了

$ clang -Xclang -load -Xclang path/to/LLVMHello.so main.c -o main

当然,你还觉得这不够优雅的话,也可以编写一个clang的wrapper程序hello-clang。 它会读取命令行参数,然后加上-Xclang -load -Xclang path/to/LLVMHello.so构造成新的命令行参数。 最后调用execvp()执行clang

举例来说,如果输入hello-clang main.c -o main, 那么它会调整参数,最终执行clang -Xclang -load -Xclang path/to/LLVMHello.so main.c -o main

不用我说,你也能想到这个画面:

$ CC=hello-clang ./configure && make

6.3. 结合 Clang 插桩的注意点

一般来说,插桩代码的时候,我们往往会在源代码中插入一些call指令来调用我们实现的函数。 举个例子,你可能会想写一个MemTrace Pass来监控运行时内存的访问。所以它会在所有访问内存的指令前插入一个call my_memlog(mem_addr)指令来记录这次的内存访问。

假如MemTrace Pass编译在libmemtrace.so中,my_memlog()函数编译在libmemlog.a中, 那么我们不要忘记在编译的时候链接它:

$ clang -Xclang -load -Xclang libmemtrace.so main.c -o main libmemlog.a

你也可以和上面的hello-clang一样,把它封装到一个clang wrapper中。

6.4. 更难一些的Pass

现在是仔细瞧瞧

的时候了。 其中,结合

,读者可以再仔细去看看ProgrammersManual - The Core LLVM Class Hierarchy Reference这一小节,回顾一下LLVM IR在内存中的表示。 也记得看看Helpful Hints for Common Operations这一小节,学习一下怎么遍历IR、修改指令。 当你看完这些后,那个github项目你也肯定能看懂了。

7. 参考项目

最后,我们推荐一个经典项目作为读者的入门级参考:

AFL是一个fuzzing测试工具,有软工背景的读者应该都会知道。它为了收集C/C++程序中的运行时信息来支持fuzzing,使用了clang和LLVM Pass来把相关代码在编译时插入到待测试的程序中。

AFL采用命令行的方式编译LLVM Pass,并把clang封装成了编译器afl-clang。可以说,这个项目基本上覆盖了第6节的所有知识点。

不过,AFL现在已经不怎么维护了。取而代之的是新的AFL++:

由于目前有活跃的开源社区维护,AFL++会比较快地跟进LLVM的新版本。考虑到LLVM版本在不断迭代,这篇文章在未来也可能会逐步失效。这时候,AFL++应该会是读者一个不错的参考。当然,AFL++也更加复杂,所以我们前面推荐的AFL可以作为读者力有不逮时的垫脚石。

8. 小结

到此为止,本文就告一段落了。 我的学识有限,哪怕在此文中,技术细节也大多靠指引官方文档给读者学习, 所以此后要是再深入谈谈更多LLVM的细节,我也心有余而力不足。

希望我的“入门导引”能给读者带来一段跟LLVM愉快相处的经历。

编辑于 2022-05-05 16:57