当深入研究模糊测试时,发现很多方案会用到LLVM,一种用于程序分析和转换的编译器框架,基于该框架进行代码插桩、污点追踪等操作。分享一篇发表于2004年CGO会议的LLVM经典论文,结合相关博客和教程从基本原理和入门实践中记录学习笔记。
论文摘要
这篇论文描述了一个名为LLVM(Low Level Virtual Machine)的编译器框架,英文标题中 lifelong 一词,表示该编译器框架支持在所有阶段,包括编译时、链接时、运行时、以及运行中的空闲时,可对任意程序进行分析和转换。LLVM定义了一个通用的、低级代码、且采用静态单赋值形式(Static Single Assignment,SSA)的中间表示,具备几个新颖的特点:一个语言无关的类型系统;一个地址算术化的指令设计;一个实现高级语言异常处理的简单机制。结合编译器框架和代码中间表示,LLVM可以提供实用的程序分析和转换。
1 背景介绍
现代应用程序的规模在不断地增加,执行过程中程序的行为也有各种变化,不但支持动态扩展和升级,而且还具有用多种不同语言编写的组件。为了最大限度地提高程序的效率,有必要在程序的整个生命周期进行分析和转换。这种 lifelong 代码优化技术包含链接时的过程优化、安装时的机器优化、运行时的动态优化以及空闲时的引导优化,复杂的分析和转换能够实现强制的程序安全。
LLVM提出了一个编译器框架,可以实现对任意程序在 lifelong 跨度上的分析和转换。它包含两个部分:(1)代码表示:实现了一套通用符号来表达程序的语法语义;(2)编译器设计:利用这种代码表示来提供各种工具进行程序的优化处理。
LLVM是一种低级虚拟机,LLVM为程序代码设计的表示语言描述了一个类似于RISC的指令集,又被称为LLVM IR(Intermediate Representation),有着更高层的关键信息用于程序分析,包括类型系统、控制流图、数据流表示等。基于这套中间表示,LLVM实现了多种工具帮助进行程序分析,使用LLVM Pass来完成程序的链式优化,其目标是作为高级虚拟机如JVM(Java语言提供的具有跨平台特性的虚拟机)等系统的补充。
2 程序表示
LLVM的指令集抓住了通用处理器的关键操作,并且避免了与底层硬件的约束关联,它提供了无限数量的虚拟寄存器,可用于处理多种原类型,如布尔型、整型、浮点型、指针等。
其中,虚拟寄存器采用静态单赋值形式,根据维基百科的解释:
在编译器设计中,在静态单赋值形式SSA是程序语言中间表示IR的一个属性,它要求每个变量只能被赋值一次并且在使用之前定义,它的好处在于可以通过简化变量的属性来改进编译器优化的结果。
以如下代码为例。
y := 1
y := 2
x := y
转换为SSA形式如下:
y1 := 1
y2 := 2
x1 := y2
LLVM是一种load/store
架构:程序仅通过使用类型化指针的加载和存储操作,在寄存器和内存之间传输值。其中,load
指令从内存中取值加载到寄存器,store
指令将寄存器的值存储到内存中。
完整的LLVM指令集仅包含为数不多的操作码,一般划分为终止符(terminator)指令、二进制(binary)指令、逻辑(logical)指令、内存(memory)指令和其他指令等。在1.0.0版本的LLVM项目源码Instruction.def文件里定义了34条指令,其指令的解释说明可参考对应文档的#instref标签。LLVM是一个不断演进的,由社区积极维护的项目,随着版本的更新,指令集也在不断的丰富和扩展。在16.0.0版本的LLVM项目源码Instruction.def文件里已经有了67条指令,其指令的解释说明可参考对应文档的#instruction-reference标签,其他版本以此类推。
LLVM采用较少的指令集一方面是避免多个操作码执行同样的操作,另一方面是大多数的操作码实际上是过载的,例如add
加法指令可以对任意整型或浮点型的操作数执行计算。大多数的指令,包括算术和逻辑操作,都是3地址形式的,即输入1或者2个操作数输出单个结果。因此,LLVM提供的基本指令集足以提供高级语言到机器语言之间编译和优化的需求了,下面根据论文内容举例介绍其中几个关键的指令。
2.1 语言无关的类型系统,cast,getelementptr指令
LLVM类型系统包含了预定义大小的原类型,例如void
、bool
、有符号或无符号的integer
、单精度或双精度的float
等,且只有4种派生类型:pointer
、array
、strut
和function
。在1.0.0版本的LLVM项目源码Type.def文件里给出了定义,其具体说明可参考对应文档的#typesystem标签。同样地,类型系统的定义在后续迭代的版本中也扩展了其内容,例如在原类型中加入了与系统架构有关的浮点类x86_fp80
,在派生类型中加入了向量类vector
等。在16.0.0版本的LLVM项目源码Type.h文件里给出了定义,其具体说明可参考对应文档的#type-system标签。
LLVM IR是一种强类型的语言,它要求所有变量都必须明确指定其类型,包括局部变量、全局变量,寄存器地址、内存地址等。cast
指令用于将某个值的类型转换成任意类型,且是唯一进行类型转换的方式,这使得所有类型的变化过程都是显式的且能够追踪的。一个没有cast
指令的语言一定是类型安全的,因为类型转换容易造成代码安全问题,例如数组溢出,显式地类型转换可缓解这一安全风险。LLVM项目在最新版本中对类型转换指令做了更多详细的划分。
getelementptr
指令将内存指针算术化,既保留了类型信息又具有与机器无关的语义。给定一个指向聚合类型对象的内存指针,该指令能保留计算该对象子元素地址的行为,可有效使用[]
来选取数组的指定项以及使用.
来选取结构体的指定元素。例如C语言中的X[i].a = 1
语句,可转换为如下LLVM指令:
%p = getelement %xty* %X, long %i, ubyte 3;
store int 1, int* %p;
其中,假设a
是结构体X[i]
的第3个元素,结构体的类型为%xty
,所有变量的类型都是显式的。上述的第一条指令获得X[i].a
元素的地址分配到虚拟寄存器%p
,第二条指令通过store
操作将int
类型的值1
存入到%p
指向的内存地址中。
2.2 显式的内存分配和统一的内存模型
在C语言中,malloc
函数会在堆上分配一个或多个指定类型的内存空间并返回相应类型的指针,而free
函数则搭配进行内存空间的释放。LLVM使用类似的指令alloca
完成类似的操作,不同的是该指令是在当前函数的栈上划分内容空间而不是堆,所有栈相关的数据都必须显式地通过该指令进行内存空间分配。另外,全局变量和函数定义都定义在一张符号表中,并且提供对象地址而非对象自身进行使用,从而LLVM实现了一个统一的内存模型。
2.3 函数调用和异常捕获
对于普通的函数调用LLVM提供了call
指令,输入函数类型的地址以及具体类型的参数,程序将进行相应的栈操作并跳转。同时,LLVM还提供了显式的、低级的、机器无关的指令以实现在高级语言中常见的异常捕获机制,类似于C语言支持的setjmp
和longjmp
操作(具体可参考setjmp是怎么工作的这篇文章)。
LLVM使用invoke
和unwind
两个指令共同实现了抽象的异常捕获模型,invoke
指令与call
指令类似但指定一个特殊的程序基本块,当程序执行到unwind
指令时它将堆栈恢复且从invoke
指令指定的标签处跳转,可以很清晰地看到异常操作的程序控制流。高级语言使用的try/catch
代码块从而可以很直接地用这两个指令来进行实现,任何函数内部遇到try
关键字时将被编译为invoke
指令,而当程序catch
到某个抛出的异常时将调用unwind
指令继续执行。在16.0.0版本的LLVM项目中,unwind
指令被替换成了resume
指令。
2.4 纯文本、二进制和内存表示
LLVM IR是一种对文本、二进制、内存进行平等表示的一阶语言,指令集采用可持久且离线的设计,使得其不需要在不同编译环节进行语义转换。中间表示在进行分析和转换时不会发生信息丢失,这使得程序调试变得更加简单,而且也降低了对内存表示的理解和学习门槛。
给定一个最简单的C语言代码,文件为test.c
如下:
int main() {
return 1;
}
将其编译为LLVM IR,执行命令如下:
$ clang-16 -S -emit-llvm test.c
会生成一个test.ll
文件,内容如下:
; ModuleID = 'test.c'
source_filename = "test.c"
target datalayout = "e-m:e-p270:32:32-p271:32:32-p272:64:64-i64:64-f80:128-n8:16:32:64-S128"
target triple = "x86_64-pc-linux-gnu"
; Function Attrs: noinline nounwind optnone uwtable
define dso_local i32 @main() #0 {
%1 = alloca i32, align 4
store i32 0, ptr %1, align 4
ret i32 1
}
attributes #0 = { noinline nounwind optnone uwtable "frame-pointer"="all" "min-legal-vector-width"="0" "no-trapping-math"="true" "stack-protector-buffer-size"="8" "target-cpu"="x86-64" "target-features"="+cx8,+fxsr,+mmx,+sse,+sse2,+x87" "tune-cpu"="generic" }
!llvm.module.flags = !{!0, !1, !2, !3, !4}
!llvm.ident = !{!5}
!0 = !{i32 1, !"wchar_size", i32 4}
!1 = !{i32 8, !"PIC Level", i32 2}
!2 = !{i32 7, !"PIE Level", i32 2}
!3 = !{i32 7, !"uwtable", i32 2}
!4 = !{i32 7, !"frame-pointer", i32 2}
!5 = !{!"Ubuntu clang version 16.0.6 (++20231112100510+7cbf1a259152-1~exp1~20231112100554.106)"}
其中,main
函数转换成LLVM IR的核心内容如下:
; Function Attrs: noinline nounwind optnone uwtable
define dso_local i32 @main() #0 {
%1 = alloca i32, align 4
store i32 0, ptr %1, align 4
ret i32 1
}
以上述为例,除指令集外LLVM IR常用的符号表示如下表所示:
特殊开头 | 符号说明 | 代码示例 |
---|---|---|
; | 注释 | ; ModuleID = ‘test.c’ |
@ | 全局变量 | @main |
# | 属性 | #0 |
% | 虚拟寄存器 | %1 |
! | 元数据 | !llvm.module.flags |
3 编译器架构
LLVM编译器架构的目标是,将程序在链接时、安装时、运行时、空闲时的所有复杂转换都采用相同的中间表示,从而构建高效且实用的程序分析优化系统。根据书籍《开源应用的架构第一卷之LLVM》的介绍,最受欢迎的传统静态编译器,例如C语言编译器gcc,采用三阶段设计,如图1所示主要包括前端(front end),优化器(optimizer),后端(back end)。
前端解析源代码,检查其中的错误,构建面向语言的抽象语法树(Abstract Syntax Tree,AST),并转换成某种中间表示输入给优化器。优化器进行分析将结果输入给后端,最终由后端翻译成机器码运行在目标机器上。
在LLVM编译器的设计中,前端负责解析、验证和诊断源代码中的错误,并将构建的抽象语法树转换成LLVM IR。该中间表示通过LLVM优化器的多轮分析之后依旧输出LLVM IR给后端,由后端负责生成运行在目标机器上的机器码。LLVM编译器设计图2所示。
3.1 LLVM IR实践
参考系列文章LLVM IR入门指南,记录LLVM实践使用过程,运行环境为Ubuntu 22.04操作系统,编译器采用16.0.x版本。
安装LLVM编译器框架
使用官方提供的脚本进行自动化安装:
$ wget https://apt.llvm.org/llvm.sh
$ chmod +x llvm.sh
$ sudo ./llvm.sh 16 all
针对Ubuntu22.04发行版,自动化脚本支持的安装版本可在https://apt.llvm.org/jammy/dists/目录下找到。
一个简单的hello world
创建一个文件main.ll
,最基本的程序内容如下:
; main.ll
define i32 @main() {
ret i32 1
}
执行以下命令对其进行编译,运行以测试代码的正确性。
$ clang-16 main.ll -o main
$ ./main
$ echo $?
运行程序后,程序自动退出,通过打印程序退出时的返回值,返回1
符合预期。
目标三元组
在使用clang编译LLVM IR代码时,会输出一个警告:
warning: overriding the module target triple with x86_64-pc-linux-gnu [-Woverride-module]
1 warning generated.
为了实现LLVM的可移植性,IR代码需要告诉编译器框架中的各个工具,其所运行的目标环境是什么。LLVM使用CPU-vendor-OS三元组决定了一个平台:CPU是指目标环境所使用的处理器,不同指令集的CPU能够运行的二进制指令、代码、格式都不相同;vendor是指供应商,对于linux环境往往不太重要;OS是指目标环境所安装的操作系统。
如果要消除编译时的警告,可以在main.ll
中加入一行
target triple = "x86_64-pc-linux-gnu"
3.2 常用IR操作命令
将源代码test.c
直接编译成可执行的文件test
,命令如下:
$ clang-16 test.c -o test
将源代码test.c
生成可读性形式的IR文件test.ll
,命令如下:
$ clang-16 -S -emit-llvm test.c
将可读形式的IR文件test.ll
转化为比特码形式的IR文件test.bc
,命令如下:
$ llvm-as-16 test.ll
将比特码形式的IR文件test.bc
转化为可读形式的IR文件test.ll
,命令如下:
$ llvm-dis-16 test.bc
利用opt
工具对循环内容的IR文件for.ll
生成程序控制流图,并利用dot
工具生成png图片,命令如下:
$ opt-16 -p dot-cfg for.ll
$ dot .main.dot -Tpng -o for.png
还可以对包含函数调用的IR文件cg.ll
生成函数调用关系图,并利用dot
工具生成png图片,命令如下:
$ opt-16 -p dot-callgraph cg.ll
$ dot cg.ll.callgraph.dot -Tpng -o cg.png
更多细节介绍可以阅读文章LLVM IR入门指南。
4 分析和优化
基于精心设计的中间表示和编译器框架,LLVM极大地降低了程序分析和优化的难度,许多新的工程都选择使用它来构建,比较著名的就是Rust、Swift等语言。但LLVM不只是用于实现新的编译器或程序语言,最重要的是它为安全研究人员提供了一套非常强大的工具,例如在代码审计中检查函数安全性,通过插桩技术进行软件模糊测试等。
在LLVM编译器架构中,程序语言的设计人员仅需关注如何将高级语言翻译成LLVM IR(前端),编译器开发工程师则主要关注如何将LLVM IR翻译成可执行的机器码(后端),而程序分析人员则通过LLVM提供的Pass对代码进行优化。根据LLVM的 lifelong 特性,Pass将IR作为输入执行相应操作并同样输出IR,图3展示了这一流程。
参考知乎文章写给入门者的LLVM介绍来展示LLVM是如何对IR进行分析和优化,原作者提供了一个模板,搭建了LLVM Pass的基本骨架。该模板项目使用C++语言编写,基于LLVM的新Pass管理框架,以插件的方式注册可直接使用clang进行链接编译。项目源码通过如下命令克隆到本地,该项目包含多个分支,不同的分支对应不同的Pass实例。
$ git clone https://github.com/sampsyo/llvm-pass-skeleton.git
进入到llvm-pass-skeleton目录下,新建build目录并编译。
$ cd llvm-pass-skeleton
$ mkdir build
$ cd build
$ cmake .. # 生成Makefile文件.
$ make # 自动构建pass动态链接库.
编译成功后会生成动态库文件build/skeleton/SkeletonPass.so
,下面将加载该动态库来执行编写的pass。
4.1 函数调用记录
使用master
分支,查看文件skeleton/Skeleton.cpp
,编写pass的关键代码如下:
struct SkeletonPass : public PassInfoMixin<SkeletonPass> {
PreservedAnalyses run(Module &M, ModuleAnalysisManager &AM) {
for (auto &F : M) {
errs() << "I saw a function called " << F.getName() << "!\n";
}
return PreservedAnalyses::all();
};
};
基于LLVM的新Pass管理框架,以插件的方式注册可直接使用clang进行链接编译。该pass的功能是打印函数的名称,编译运行结果如下:
$ clang-16 -fpass-plugin=`echo build/skeleton/SkeletonPass.*` test.c
I saw a function called main!
另外,在noauto
分支中使用了旧的Pass管理框架来编写核心代码,查看文件skeleton/Skeleton.cpp
的关键内容如下:
struct SkeletonPass : public FunctionPass {
static char ID;
SkeletonPass() : FunctionPass(ID) {}
virtual bool runOnFunction(Function &F) {
errs() << "I saw a function called " << F.getName() << "!\n";
return false;
}
};
这里使用了旧的Pass管理框架下的函数pass,其对应的动态链接库为build/skeleton/libSkeletonPass.so
,可以使用opt
工具来进行测试,命令如下:
$ opt-16 -enable-new-pm=0 -load build/skeleton/libSkeletonPass.so -skeleton main.ll -o /dev/null
I saw a function called main!
其中,选项-enable-new-pm
用来告诉工具使用旧的Pass管理框架,选项-skeleton
指定注册的pass名称。可以通过如下命令查看编写的pass是否正确注册到动态库中:
$ opt-16 -load build/skeleton/libSkeletonPass.so -help | grep skeleton
--skeleton - a useless pass
4.2 IR指令查看
为了更好的学习pass是如何编写的,需要了解LLVM核心类的层级关系,主要由4个类来操作和构建IR。
Module
类:模块类是最外层的,以文件为单位,包含一组函数、一组全局变量,和一个符号表;Function
类:函数类嵌套在模块里,以函数为单位,包含了一组程序基本块,一组参数,同样有一个符号表;BasicBlock
类:基本块类嵌套在函数里,以跳转指令为划分,其中包含了一组顺序执行的指令;Instruction
类:指令类嵌套在基本块中,是LLVM的公共基本类,每一条指令表示一个具体的操作。
此外,Type
类定义了所有类型的基础类,Value
类定义了所有值的基础类,其他关键的类定义可参考LLVM核心类文档说明,图4给出了如上4个重要的类的继承关系。
值得注意的是,BasicBlock
类继承Vaule
类,而Instruction
类并没有直接继承Vaule
类,而是继承其子类User
类。其原因在于LLVM使用静态单赋值形式SSA来描述IR指令,于是需要在值的基础上新增一些功能,以更好的操作IR指令并且让用户获得更多IR指令相关的信息。
切换containers
分支,查看文件skeleton/Skeleton.cpp
,编写pass的关键代码如下:
struct SkeletonPass : public PassInfoMixin<SkeletonPass> {
PreservedAnalyses run(Module &M, ModuleAnalysisManager &AM) {
for (auto &F : M.functions()) {
errs() << "In a function called " << F.getName() << "!\n";
errs() << "Function body:\n";
F.print(errs());
for (auto &B : F) {
errs() << "Basic block:\n";
B.print(errs());
for (auto &I : B) {
errs() << "Instruction: \n";
I.print(errs(), true);
errs() << "\n";
}
}
errs() << "I saw a function called " << F.getName() << "!\n";
}
return PreservedAnalyses::all();
};
};
由于pass被传递给函数,可以用它来遍历每个函数的基本块,然后遍历每个基本块的指令集,编译运行结果如下:
$ clang-16 -fpass-plugin=`echo build/skeleton/SkeletonPass.*` test.c
In a function called main!
Function body:
; Function Attrs: noinline nounwind optnone uwtable
define dso_local i32 @main() #0 {
%1 = alloca i32, align 4
store i32 0, ptr %1, align 4
ret i32 1
}
Basic block:
%1 = alloca i32, align 4
store i32 0, ptr %1, align 4
ret i32 1
Instruction:
%1 = alloca i32, align 4
Instruction:
store i32 0, ptr %1, align 4
Instruction:
ret i32 1
I saw a function called main!
4.3 操作符修改
当需要对程序进行模式寻找时,可以在检索到对应代码时对其进行修改。一个简单的示例在mutate
分支里给出,替换每个函数中的第一个二元运算符(+、-等),查看文件skeleton/Skeleton.cpp
,编写pass的关键代码如下:
struct SkeletonPass : public PassInfoMixin<SkeletonPass> {
PreservedAnalyses run(Module &M, ModuleAnalysisManager &AM) {
for (auto &F : M.functions()) {
for (auto &B : F) {
for (auto &I : B) {
if (auto *op = dyn_cast<BinaryOperator>(&I)) {
// 指定`op`出现的位置进行代码改写。
IRBuilder<> builder(op);
// 创建相同操作数的乘法`op`。
Value *lhs = op->getOperand(0);
Value *rhs = op->getOperand(1);
Value *mul = builder.CreateMul(lhs, rhs);
// 在op所有使用到的地方,插入新生成的乘法指令。
for (auto &U : op->uses()) {
// User类可用来控制操作数。
User *user = U.getUser();
user->setOperand(U.getOperandNo(), mul);
}
// 修改代码生效。
return PreservedAnalyses::none();
}
}
}
}
return PreservedAnalyses::all();
};
};
如果编译该分支代码下的example.c
文件:
#include <stdio.h>
int main(int argc, const char** argv) {
int num;
scanf("%i", &num);
printf("%i\n", num + 2);
return 0;
}
编译运行结果对比如下:
$ gcc example.c
$ ./a.out
10
12
$ clang-16 -fpass-plugin=`echo build/skeleton/SkeletonPass.*` example.c
$ ./a.out
10
20
4.4 运行时库调用
当需要对代码进行插桩做些什么事情的时候,直接用IRBuilder来生成LLVM指令太麻烦了,rtlib
分支给出了一个可以调用提前写好的运行时库的方法,查看文件skeleton/Skeleton.cpp
,编写pass的关键代码如下:
struct SkeletonPass : public PassInfoMixin<SkeletonPass> {
PreservedAnalyses run(Module &M, ModuleAnalysisManager &AM) {
for (auto &F : M.functions()) {
// 从运行时库中获取函数调用。
LLVMContext &Ctx = F.getContext();
std::vector<Type*> paramTypes = {Type::getInt32Ty(Ctx)};
Type *retType = Type::getVoidTy(Ctx);
FunctionType *logFuncType = FunctionType::get(retType, paramTypes, false);
FunctionCallee logFunc =
F.getParent()->getOrInsertFunction("logop", logFuncType);
for (auto &B : F) {
for (auto &I : B) {
if (auto *op = dyn_cast<BinaryOperator>(&I)) {
// 在操作符`op`之后插入。
IRBuilder<> builder(op);
builder.SetInsertPoint(&B, ++builder.GetInsertPoint());
// 插入一个自定义的函数调用。
Value* args[] = {op};
builder.CreateCall(logFunc, args);
return PreservedAnalyses::none();
}
}
}
}
return PreservedAnalyses::all();
}
};
其中,getOrInsertFunction
方法声明了一个运行时函数logop
,CreateCall
方法创建了该函数调用指令,最后结合库文件rtlib.c
的内容:
#include <stdio.h>
void logop(int i) {
printf("computed: %i\n", i);
}
编译链接后的运行结果如下:
$ gcc -c rtlib.c
$ clang-16 -fpass-plugin=`echo build/skeleton/SkeletonPass.*` -c example.c
$ gcc example.o rtlib.o
$ ./a.out
12
computed: 14
14
学习笔记
LLVM项目看似十分的庞大,但其核心原理和本质思想是很简单的,目的是构造一个精简且统一的IR指令集,再将编译器框架模块化。在它之前类似的工作也有,例如java语言的jvm虚拟机等,将优化器与后端绑定,向前端提供一个跨平台的环境。LLVM的设计则更进一步,干脆将优化器与后端也解耦合。任何一个研究方向,抓住其最基本的内容就能窥探全貌,其他的就是时间问题了。最后,附上文献引用和DOI链接:
Lattner C, Adve V. LLVM: A compilation framework for lifelong program analysis & transformation[C]//International symposium on code generation and optimization, 2004. CGO 2004. IEEE, 2004: 75-86.