LLVM学习笔记

本来说暑假就学完llvm + ollvm的,这下笑嘻了。

这篇blog是为了后续混淆学习打基础的。

reference: https://www.kanxue.com/book-section_list-88.htm,钱包-199

安装

LLVM是一种模块化的编译器框架,命名最早源自于底层虚拟机(Low Level Virtual Machine)的首字母缩写。

先安装再说

  • Ubuntu 20.04
  • LLVM 12.0.1
  • CMake 3.24.2

release界面下载源码

然后创建一个你想要放llvm的路径,我是/home/llvm/Programs/llvm-project,在这个文件夹里解压两个源码包,分别命名为llvm和clang,然后把两个文件夹放进build文件夹,大概是下面这个样子

然后在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 install

(我大概跑了3个多小时吧...

安装完成后,使用clang -help进行测试,若没有报错则说明安装成功

一些错误的解决方法

  • 如果在clang -help时报错:clang: error while loading shared libraries: libtinfo.so.5: cannot open shared object file: No such file or directory

    应该是系统路径的原因,建议使用sudo find / -name libtinfo.so.5,找到一个该文件后复制其路径,然后sudo ln -s [刚刚找到的文件路径] /usr/local/llvm/lib/libtinfo.so.5

  • 如果在clang -help时报错exec format error,那你应该去github上找找对应自己系统的llvm的下载链接,而不是使用本文的下载链接,然后删除现在的llvm并重新安装

简介

我主要是看IR和PASS

我们知道,传统的编译过程大概可分为两个步骤:把源代码翻译成中间表示(IR),把IR翻译成机器码(或者由解释器来解释执行)。这三个层次也就是前端、中端、后端。

GCC编译器在设计时没有做好层次划分,你不能知道它的IR是什么样子的,它也不会提供接口让你操作IR,这导致了其前端后端的大量数据耦合在了一起。这样一来,GCC就很难支持新的编程语言或者架构。

如果有一个大家都使用的开放的IR,那么每种新语言只需要一个新前端,每个新平台只需要一个新后端,这就比GCC方便的多。LLVM就是这一种项目。LLVM IR本质上一种与源编程语言和目标机器架构无关的通用中间表示,是LLVM项目的核心设计和最大的优势。

然后这里还出现了Clang。Clang是基于LLVM的编译器驱动,他可以把C/C++/OC等源码翻译成LLVM IR的前端,并经由LLVM的库成功由IR到后端。

更多介绍可以移步官方文档

LLVM的基本用法

LLVM有三种表示,分别是

  • .ll格式:给人看的版本,是一种可读的IR,类似于汇编代码,但其实它介于高等语言和汇编之间
  • .bc格式:不可读的二进制IR,被称作位码(bitcode),用来给机器存储
  • 内存表示,只保存在内存中,所以谈不上文件格式和文件后缀

可以通过llvm-as和llvm-dis两个指令来在前两种文件之间做转换(可以从指令的名字中看出来LLVM IR确实是把.ll看做汇编,.bc看做机器码)

这里给出了一次具体的编译过程。main.cpp和factorial.c各自经过前端Clang翻译变成中间代码.bc文件,随后.bc各自经过优化器进行优化,得到优化后的代码main_obt.bc和factorial_opt.bc,这两个文件经过LLVM IR层面的链接器(llvm-link)合并为一个中间代码文件linked.bc,最后经过静态编译器(llc)进行编译,得到目标平台的机器代码。

注意一下Linker,为了实现链接时优化,LLVM在前端(Clang)生成单个代码单元的IR后,将整个工程的IR都链接起来,同时做链接时优化。之后的compiler只编译LLVM IR。

手动编译一次试试

简单尝试一下编译这个hello.cpp

#include <cstdio>
void Hello()
{
	printf("Hello, World!\n");
	return;
}
int main()
{
	Hello();
	return 0;
}

先试试.ll格式的

clang -S -emit-llvm hello.cpp -o hello.ll

然后出来大概是这样的

; ModuleID = 'hello.cpp'
source_filename = "hello.cpp"
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-unknown-linux-gnu"

@.str = private unnamed_addr constant [15 x i8] c"Hello, World!\0A\00", align 1

; Function Attrs: mustprogress noinline optnone uwtable
define dso_local void @_Z5Hellov() #0 {
  %1 = call i32 (i8*, ...) @printf(i8* getelementptr inbounds ([15 x i8], [15 x i8]* @.str, i64 0, i64 0))
  ret void
}

declare dso_local i32 @printf(i8*, ...) #1

; Function Attrs: mustprogress noinline norecurse optnone uwtable
define dso_local i32 @main() #2 {
  %1 = alloca i32, align 4
  store i32 0, i32* %1, align 4
  call void @_Z5Hellov()
  ret i32 0
}

attributes #0 = { mustprogress noinline 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" }
attributes #1 = { "frame-pointer"="all" "no-trapping-math"="true" "stack-protector-buffer-size"="8" "target-cpu"="x86-64" "target-features"="+cx8,+fxsr,+mmx,+sse,+sse2,+x87" "tune-cpu"="generic" }
attributes #2 = { mustprogress noinline norecurse 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}
!llvm.ident = !{!3}

!0 = !{i32 1, !"wchar_size", i32 4}
!1 = !{i32 7, !"uwtable", i32 1}
!2 = !{i32 7, !"frame-pointer", i32 2}
!3 = !{!"clang version 13.0.1"}

然后试试.bc

clang -c -emit-llvm hello.cpp -o hello.bc

这下我反正看不懂了

然后应该用opt对LLVM IR进行优化了

opt -load LLVMObfuscator.so -hlw -S hello.ll -o hello_opt.ll

LLVMObfuscator.so在下面LLVM Pass的手操里面有,是通过cmake make编译出来的一个玩意

  • -load 加载特定的 LLVM Pass (集合)进行优化(通常为.so文件)
  • -hlw 是 LLVM Pass 中自定义的参数,用来指定使用哪个 Pass 进行优化

最终可以将LLVM IR编译为可执行文件了

clang hello_opt.ll -o hello,直接用clang就行

LLVM PASS

LLVM Pass 框架是整个LLVM提供给用户用来干预代码优化过程的框架,也是我们编写代码混淆工具的基础。

编译后的LLVM Pass(一般是.so文件)通过优化器opt进行加载,可以对LLVM IR中间代码进行分析和修改,生成新的中间代码。

同时,LLVM Pass也为开发者提供了丰富的API用于实现中间代码的分析与修改。

来了解一下llvm项目里面的目录文件夹

  • llvm/include/llvm: 存放了LLVM提供的公共头文件,也就是编程时使用的
  • llvm/lib: 存放了LLVM的大部分源码以及一些不公开的文件夹,在想要了解函数等运行原理时很有用
  • llvm/lib/Transform: 存放了所有LLVM的源代码,以及一些自带的PASS

尝试编写一个简单的PASS

目标是编写一个LLVM PASS,遍历程序中的所有函数,并输出"Hello, " + 函数名。我们考虑通过CMake对Pass进行单独编译。

理论准备

LLVM有多种类型的Pass可供选择,包括: ModulePass(基于模块)、FuncitonPass(基于函数)、CallGraphPass(基于调用图)、LoopPass(基于循环)等,我们集中于FunctionPass,因为后续学习的混淆基本都是基于这个玩意的。

FunctionPass是以函数为单位进行处理的,其子类必须实现runOnFunction这个函数,并且在Pass执行时会对每一个函数执行一次runOnFunction函数。这意味着我们可以在该函数内写一些关于IR的玩意。

在编写我们想要实现的简单pass时,大概步骤是:

  1. 创建一个类,继承FunctionPass父类
  2. 在创建的类中实现runOnFunction函数
  3. 向LLVM注册我们的Pass类

手操

首先需要在vscode上配置remote - ssh插件,并且在虚拟机上安装好ssh服务

可参考https://blog.csdn.net/weixin_44260459/article/details/121772911

在这个文件夹里创建一个OLLVM++文件夹,来保存这次的项目,然后在本机的vscode上使用remote - ssh插件连接到虚拟机,并且打开这个文件夹,在OLLVM++文件夹中创建Build和Test文件夹,后续分别用于储存编译后 LLVM Pass以及测试程序。

在Test文件夹内新建文件TestProgram.cpp,作为测试文件,代码如下

#include <cstdio>
#include <cstring>
#include <cstdlib>

char input[100] = {0};
char enc[100] = "\x86\x8a\x7d\x87\x93\x8b\x4d\x81\x80\x8a\
\x43\x7f\x49\x49\x86\x71\x7f\x62\x53\x69\x28\x9d"; 
void encrypt(unsigned char *dest, char *src){
    int len = strlen(src);
    for(int i = 0;i < len;i ++){
        dest[i] = (src[i] + (32 - i)) ^ i; 
    } 
}
//flag{s1mpl3_11vm_d3m0} 
int main(int argc, char *argv[]){ 
    printf("Please input your flag: ");
    scanf("%s", input);
    unsigned char dest[100] = {0}; 
    encrypt(dest, input); 
    bool result = strlen(input) == 22 && !memcmp(dest, enc, 22); 
    if(result){ 
        printf("Congratulations~\n"); 
    }else{
        printf("Sorry try again.\n"); 
    }
}

然后在OLLVM++下创建文件夹Transforms,其内容如下

  • include文件夹存放LLVM Pass项目的头文件,现在用不上
  • src文件夹存放LLVM Pass项目的源代码
  • CMakeLists.txt是CMake项目的配置文件

配置文件如下

project(OLLVM++) # 指定了项目的名称
cmake_minimum_required(VERSION 3.13.4) # llvm最低要求是3.13.4
find_package(LLVM REQUIRED CONFIG)

list(APPEND CMAKE_MODULE_PATH "${LLVM_CMAKE_DIR}")
include(AddLLVM) # 上面是和环境有关的一些配置
include_directories("./include") # 指定项目的包含文件夹,后续可以导入该文件夹的头文件

separate_arguments(LLVM_DEFINITIONS_LIST NATIVE_COMMAND ${LLVM_DEFINITIONS})
add_definitions(${LLVM_DEFINITIONS_LIST})
include_directories(${LLVM_INCLUDE_DIRS}) # 配置项

#link_libraries("libLLVMTransformUtils.so")

add_llvm_library( LLVMObfuscator MODULE # 向llvm中注册一个叫LLVMObfuscator的.so文件
  src/HelloWorld.cpp # 这个就是Pass的源代码
)

现在,在src文件夹内新建一个Hello World.cpp,这就是我们今天的主要目标

(以后也可以拿这玩意当一个模板来看,要写pass就在这个框架里面加东西就行)

#include "llvm/Pass.h"
#include "llvm/IR/Function.h"
#include "llvm/Support/raw_ostream.h"

using namespace llvm;

namespace {

    class HelloWorld : public FunctionPass{
        public:
            static char ID;
            HelloWorld() : FunctionPass(ID) {}

            bool runOnFunction(Function &F);
    };
    
}


bool HelloWorld::runOnFunction(Function &F){
	outs() << "Hello, " << F.getName() << "\n";
    // outs()获取整个输出流,和cout差不多
    // F.getName()获取函数名称 
}

char HelloWorld::ID = 0; // 初始化ID

static RegisterPass<HelloWorld> X("hlw", "My first line of LLVM Pass.");
// 向LLVM注册我们的Pass
// 前面一个变量是LLVM Pass的参数(opt的时候用他来指定),后面一个是对该参数的描述

然后写一个shell来编译(test.sh)

cd ./Build
cmake ../Transforms
make
cd ../Test
clang -S -emit-llvm TestProgram.cpp -o TestProgram.ll
opt -load ../Build/LLVMObfuscator.so -hlw -S TestProgram.ll -o TestProgram_hlw.ll
clang TestProgram_hlw.ll -o TestProgram_hlw
./TestProgram_hlw

运行一下看看效果

可以看到,在opt优化的过程中终端输出了两行Hello加上函数的名字,这说明我们的Hello World的Pass运行成功了。并且在最后执行了Test程序,这说明我们的编译也没有出问题。

LLVM IR

首先可以看看官方的一个入门引导,英语好的爹可以直接去听一下讲这个PPT的会议

我就是看这些玩意看吐了才去买的🌏👴的网课

概述

前面我们尝试了写一个pass,但是我们并没有真正的深入理解如何对中间代码进行优化。在LLVM的编译过程中,其优化是基于中间代码LLVM IR的,现在我们进行对IR的学习,为后续代码混淆的学习打好基础。

LLVM IR是一种低级的编程语言,其语法类似于汇编语言,任何高级的编程语言(如c++)都可以用LLVM IR进行优化。如果我们把这些语言都用LLVM IR表示,那么我们可以很方便地进行优化。

我们已经了解过,LLVM IR有两种表示方法:.ll和.bc,并且可以通过llvm-as和llvm-dis两个指令来在前两种文件之间做转换。

在源代码被编译为LLVM IR后,将会具有如下结构:

一个源码文件对应LLVM IR中的一个模块。其中,头部信息包含程序的目标平台,如x86,ARM等以及一些其他信息。头部信息由编译器自动生成。全局符号包含函数定义、函数声明以及全局变量,

LLVM IR中的函数表示源代码中的某个函数,这两个“函数”基本上是同一个概念。参数就是函数的参数,函数由若干个基本块组成,其中函数最先执行的基本块为函数的入口块。入口块可以跳转到其他的基本块进行执行。

关于基本块,一个基本块由若干个指令和标签组成,在正常情况下,基本块的最后一条指令为跳转指令(IR中的br或者switch指令),或者返回指令(retn,和c中的return差不多)。这一类指令也叫终结指令(Terminator Instruction)。

除了这些指令,基本块中还有其他的指令,比如PHI指令等。

我们用IDA来类比记忆一下。模块、函数块和基本块的概念可以类比到IDA的Graph View界面。把程序拖进IDA后就可以把他理解为一个模块,一个模块中有若干个函数,就对应IDA的函数列表界面。IDA的Graph View界面可以看到函数的控制流图,一个控制流图由若干个代码块和一些跳转关系组成,图中的代码块可以和LLVM IR中的基本块对应。

基于LLVM的混淆,通常是以函数或者比函数更小的单位为基本单位进行混淆的,我们通常更关心函数和基本块这两个结构。模块这个结构用到的较少。

LLVM IR是学代码混淆的基础,例如:

  • 以函数为基本单位的混淆:控制流平坦化
  • 以基本块为基本单位的混淆:虚假控制流
  • 以指令为基本单位的混淆:指令替代

指令详解

终结指令 Terminator Instructions

在前面提到过,终结指令一般是基本块中的最后一条指令,通常是跳转或返回指令,他们会跳转到下一个基本块继续执行,或者直接从当前执行的函数中返回。

ret

ret指令是函数的返回指令,对应c中的return。ret指令语法如下

ret <type> <value>    ; 返回特定类型返回值的return指令
ret void              ; 无返回值的return指令

第一类是携带返回值的ret指令,需要指定返回值的类型与值。第二类是无返回值的ret指令,他直接返回一个void,也就是空值。

举个例子

ret i32 5                       ; 返回整数 5
ret void                        ; 无返回值
ret { i32, i8 } { i32 4, i8 2 } ; 返回一个结构体

br

br是”分支”的英文branch的缩写,分为非条件分支和条件分支,对应C/C++的if语句。

无条件分支类似于x86汇编中的jmp指令,条件分支类似于jz/jnz, je/jne等条件跳转指令。

br指令语法如下

br i1 <cond>, label <iftrue>, <label> <iffalse> ; 条件分支
br label <dest>                                 ; 非条件分支

第一类是条件分支,条件分支中需要指定一个条件,其类型为i1,也就是一位整数, 可以理解为bool类型。若为true,则会跳转到第一个标签iftrue对应的基本块,否则跳转到第二个标签iffalse对应的基本块。

第二类是非条件跳转,他会直接跳转到dest对应的基本块。

举个例子

Test:
     %cond = icmp eq i32 %a, %b
     br i1 %cond, label %IfEqual, label IfUnequal
IfEqual:
	ret i32 1
IfUnequal:
	ret i32 0

首先是一个条件跳转。条件是icmp指令的运算结果cond。这里的意思是比较a和b是否相等,相等为true否则为false。br指令会根据cond的值进行跳转,cond为true则跳转到基本块IfEqual,该基本块会返回一个值1,cond为false则跳转到基本块IFUnequal,该基本块会返回一个值0。

另:比较指令

另外说一下比较指令,在x86汇编中条件跳转指令(jz,je等)通常与比较指令(cmp,test等)一起出现

在LLVM IR中也有这种指令:icmp等,他们通常与br一起出现

cmp指令也分两类,第一种是icmp指令,一般是整数或指针的比较指令,语法如下

<result> = icmp <cond> <ty> <op1>, <op2>   ; 比较整数op1和op2是否满足条件cond

icmp首先要指定条件的类型,这里的条件cond可以是eq(相等),ne(不相等),ugt(无符号大于)等

然后还需要指定比较数的类型ty,例如32位整数i32等。

举个例子

<result> = icmp eq i32 4, 5            ; yields: result=false eq=equal
<result> = icmp ne float* %X, %X	   ; yields: result=false ne=not equal
<result> = icmp ult i16 4, 5		   ; yields: result=true ult=unsigned less than
<result> = icmp sgt i16 4, 5		   ; yields: result=false sgt=signed greater than
<result> = icmp ule i16 -4, 5		   ; yields: result=false ule=unsigned less or equal
<result> = icmp sge i16 4, 5		   ; yields: result=false sge=signed greater or equal

float*就是浮点型指针,其他的很明显了

第二种是fcmp指令,这里的f是float,也就是浮点数的意思。其语法与icmp十分相似

<result> = fcmp <cond> <ty> <op1>, <op2>   ; 比较两个浮点数是否满足条件cond

不同点在于其条件cond与icmp不太一样。这里条件可以是oeq(ordered and equal),ueq(unordered or equal),false等。这里order是指两个操作数都不为NAN

举个例子

<result> = fcmp oeq float 4.0, 5.0  		; yields: result=false
<result> = fcmp one float 4.0, 5.0			; yields: result=true
<result> = fcmp olt float 4.0, 5.0 			; yields: result=true
<result> = fcmp ueq double 1.0, 2.0			; yields: result=false

switch

switch也是一种分支指令,他可以看做是br指令的升级版,支持的分支更多,但语法也更加复杂。他和c中的switch是差不多的。语法如下

switch <intty> <value>, label <defaultdest> [ <intty> <val>, label <dest> ... ]

首先需要指定一个switch变量,intty是变量类型,value是变量的值。后续的<intty>和<val>分别指定的是每个case的类型和值。如果switch变量和某个case的值相等,他就会跳转到这个case执行对应的分支dest。若都不相等,则会执行默认分支defaultdest。

举个例子

; 与条件跳转等效
%Val = zext i1 %value to i32
switch i32 %Val, label %truedest [ i32 0, label %falsedest ]

; 与非条件跳转等效
switch i32 0, label %dest [ ]

; 拥有三个分支的条件跳转
switch i32 %val, label %otherwise [ i32 0, label %onzero
								    i32 1, label %onone
								    i32 2, label %ontwo ]

二元运算指令 Binary Operations

add

add是整数加法指令,对应C中的+号,类似x86汇编中的add指令。语法如下

<result> = add <ty> <op1>, <op2>

十分易懂,ty是变量类型

举个例子

<result> = add i32 4, %var   ; yields i32:result = 4 + %var

sub

sub是整数减法指令,对应C中的-号,类似x86汇编中的sub指令。语法如下

<result> = sub <ty> <op1>, <op2>

和add差不多,举个例子

<result> = sub i32 4, %var 			; yields i32:result = 4 - %var
<result> = sub i32 0, %var 			; yields i32:result = -%var

mul

mul是整数乘法指令,对应C中的*号,类似x86汇编中的mul指令。语法如下

<result> = mul <ty> <op1>, <op2>

举个例子

<result> = mul i32 4, %var 			; yields i32:result = 4 * %var

udiv

udiv是无符号整数除法指令,对应C中的/号。如果存在exact关键字,且op1不是op2的倍数,就会出现错误。

语法如下

<result> = udiv <ty> <op1>, <op2>		 ; yields ty:result
<result> = udiv exact <ty> <op1>, <op2>  ; yields ty:result

有点不一样的是下面这一条。exact要求op1是op2的倍数才能正确执行,否则会报错。

举个例子

<result> = udiv i32 4, %var 			; yields i32:result = 4 / %var

sdiv

sdiv是有符号整数除法指令,对应C中的/号。语法如下

<result> = sdiv <ty> <op1>, <op2>		 ; yields ty:result
<result> = sdiv exact <ty> <op1>, <op2>  ; yields ty:result

和sdiv一样

举个例子

<result> = sdiv i32 4, %var 			; yields i32:result = 4 / %var

urem

urem是无符号整数取余指令,对应C中的%号。语法如下

<result> = urem <ty> <op1>, <op2>		 ; yields ty:result

举个例子

<result> = urem i32 4, %var 			; yields i32:result = 4 % %var

srem

srem是有符号整数取余指令,对应C中的%号。语法如下

<result> = srem <ty> <op1>, <op2>		 ; yields ty:result

举个例子

<result> = srem i32 4, %var 			; yields i32:result = 4 % %var

二元运算指令都算很简单的指令,用法都差不多

按位二元运算 Bitwise Binary Operations

shl

shl是整数左移指令,对应C中的<<号,类似x86汇编中的shl指令。语法如下

<result> = shl <ty> <op1>, <op2>

举个例子

<result> = shl i32 4, %var		; yields i32: 4 << %var
<result> = shl i32 4, 2		    ; yields i32: 16
<result> = shl i32 1, 10		; yields i32: 1024
<result> = shl i32 1, 32		; undefined
<result> = shl <2 x i32> < i32 1, i32 1>, < i32 1, i32 2>		; yields: result=<2 x i32> <i32 2,i32 4>

第四个指令爆了,所以undefined

lshr

lshr是整数逻辑右移指令,对应C中的>>号,右移后会在左侧补0。逻辑右移不管是有符号还是无符号数,一律当做无符号数处理。语法如下

<result> = lshr <ty> <op1>, <op2>

举个例子

<result> = lshr i324, 1				; yields i32:result = 2
<result> = lshr i32 4, 2 			; yields i32:result = 1
<result> = lshr i8 4, 3				; yields i8:result = 0
<result> = lshr i8 -2, 1 		    ; yields i8:result = 0x7F
<result> = lshr i32 1, 32 			; undefined
<result> = lshr <2 x i32> < i32 -2, i32 4>, <i32 1, i32 2>   ; yields: result=<2 x i32><i32 0x7FFFFFF, i32 1>

看看第四句,把-2逻辑右移了1位,这里会把-2当做无符号数处理,右移后会得到0x7f

ashr

ashr是整数算术右移指令,右移后会在左侧补符号位(负数符号位是1,正数是0),意思就是说他把有符号和无符号数是分开来看待的。语法如下

<result> = ashr <ty> <op1>, <op2>

举个例子

<result> = ashr i324, 1				; yields i32:result = 2
<result> = ashr i32 4, 2 			; yields i32:result = 1
<result> = ashr i8 4, 3				; yields i8:result = 0
<result> = ashr i8 -2, 1 		    ; yields i8:result = -1
<result> = ashr i32 1, 32 			; undefined
<result> = ashr <2 x i32> < i32 -2, i32 4>, <i32 1, i32 2>   ; yields: result=<2 x i32><i32 -1, i32 0>

可以发现,正数的情况下ashr与lshr一模一样,但是当负数的时候,ashr会按照负数进行运算

and

and是按位与运算指令,对应C中的&号。语法如下

<result> = and <ty> <op1>, <op2>

举个例子

<result> = and i32 4, %var 			; yields i32:result = 4 & %var
<result> = and i32 15, 40 			; yields i32:result = 8
<result> = and i32 4, 8 			; yields i32:result = 0

or

or是按位或运算指令,对应C中的|号。语法如下

<result> = or <ty> <op1>, <op2>

举个例子

<result> = or i32 4, %var 			; yields i32:result = 4 | %var
<result> = or i32 15, 40 			; yields i32:result = 47
<result> = or i32 4, 8 				; yields i32:result = 12

xor

xor是按位异或运算指令,对应C中的^号。语法如下

<result> = xor <ty> <op1>, <op2>

举个例子

<result> = xor i32 4, %var 			; yields i32:result = 4 ^ %var
<result> = xor i32 15, 40 			; yields i32:result = 39
<result> = xor i32 4, 8 			; yields i32:result = 12
<result> = xor i32 %V, -1 			; yields i32:result = ~%V

第四条,如果与-1异或相当于对变量取反。

内存访问和寻址操作 Memory Access and Addressing Operations

标题这么长一看就很重要

首先我们要了解什么是静态单赋值

在编译器设计中,静态单赋值(Static Single Assignment, SSA),是 IR 的一种属性。简单来说,SSA 的特点是:在程序中一个变量仅能有一条赋值语句。静态的意思就是在整个静态程序,而非动态执行中的程序里,一个变量只能出现在一条赋值语句里。

LLVM IR 正是基于静态单赋值原则设计的。

上面这个流程图内,x y w都被赋值了两次,他们都不满足SSA

我们把他改写成这样,现在他就满足SSA了,每个变量只出现在了一条赋值语句里。

SSA的特性会带来一些问题。

#include <cstdio>

int main()
{
    for (int i = 0; i < 100; ++i)
    {
        printf("Hello, %d\n", i);
    }
}

例如这个程序,如果C++也是遵守静态单赋值原则的话,这个程序中i被赋值了100次,显然违反了SSA。

这里给出了一种改写方案

#include <cstdio>

int main()
{
    int *i = (int *) malloc(4);
    for (*i = 0; *i < 100; ++(*i))
    {
        printf("Hello, %d\n", *i);
    }
}

i由int变量变成了int指针变量,我们在循环时直接通过指针i来操作内存。这样i就只被赋值了一次,也就是malloc处。后面的操作均是基于指针i对其指向的内存进行操作,而并没有修改变量i。这是符合SSA的。这种实现方法也是LLVM IR中所采用的。

alloca

alloca是内存分配指令,在栈中分配一块空间并获得指向该空间的指针,类似于C中的malloc()。不同的是,alloca是在栈中分配内存,而malloc()是在堆中分配内存。语法如下

<result> = alloca <type> [, <ty> <NumElements>] [, align <alignment>]		;分配sizeof(type)*NumElements字节的内存,分配的地址与alignment对齐

首先<type>要声明你分配的内存空间是什么类型的数据,比如是i32的数据,随后<NumElements>需要声明你要分配多少个这样的数据。align是对齐的意思,也就是说分配的内存空间会与alignment的对齐。例如alignment是1024,那么你分配的地址空间一定是1024的倍数。

举个例子

%ptr = alloca i32					  ;分配4字节的内存并返回i32类型的指针
%ptr = alloca i32, i32 4			  ;分配4*4字节的内存并返回i32类型的指针
%ptr = alloca i32, i32 4, align 1024  ;分配4*4字节的内存并返回i32类型的指针,分配的地址与1024对齐
%ptr = alloca i32, align 1024	  	  ;分配4字节的内存并返回i32类型的指针,分配的地址与1024对齐

sizeof(i32)是4,i32 4是指定了要分配4个i32类型数据大小的空间,align 1024是指定对齐为1024

store

store是内存存储指令,向指针指向的内存中存储数据,类似于C中的指针解引用后的赋值操作。语法如下

store <ty> <value>, <ty>* <pointer> ; 向特定类型指针指向的内存存储相同类型的数据

首先指定一个类型ty,然后向一个相同类型的指针存入变量

举个例子

%ptr = alloca i32
store i32 3, i32* %ptr

首先由alloca分配给指针一个4字节的空间,然后通过store指令向ptr指针指向的内存写入相同类型(i32)的数据(3)。

load

load是内存读取指令,从指针指向的内存中读取数据,类似于C中的指针解引用操作。语法如下

load <ty> <value>, <ty>* <pointer> ; 从特定类型指针指向的内存中读取特定类型的数据

举个例子

%ptr = alloca i32			; yields i32* :ptr
store i32 3, i32* ptr		; yields void
%val = load i32, i32* %ptr	; yields i32:val = i32 3

类型转换操作 Conversion Operations

trunc ... to

trunc...to是截断指令,将一种类型的变量截断为另一种类型的变量。对应C中大类型向小类型的强制转换(比如 long转int)。语法如下

<result> = trunc <ty> <value> to <ty2>  ; 将ty类型的变量截断为ty2类型的变量

先定义一个ty类型的变量value,然后将他强制转化为ty2类型。非常简单的操作。

举个例子

%X = trunc i32 257 to i8 						; yields i8:1
%Y = trunc i32 123 to i1 						; yields i1:true
%Z = trunc i32 122 to i1						; yields i1:false
%W = trunc <2 x i16> <i16 8, i16 7> to <2 x i8> ; yields <i8 8, i8 7>

第一条指令把i32类型的变量257截断为i8类型,那么只保留257的低8位,也就是1

后面都差不多

zext ... to

零拓展(Zero Extend)指令,将一种类型的变量拓展为另一种类型的变量,高位补0。对应C中小类型向大类型的强制转换(比如int转long)。语法如下

<result> = zext <ty> <value> to <ty2>  ; 将ty类型的变量拓展为ty2类型的变量

举个例子

%X = zext i32 257 to i64 						; yields i64:257
%Y = zext i1 true to i32 						; yields i32:1
%Z = zext <2 x i16> <i16 8, i16 7> to <2 x i32> ; yields <i32 8, i32 7>

zext是高位补0,所以结果不变。

sext ... to

符号位拓展(Sign Extend)指令,通过复制符号位(最高位)将一种类型的变量拓展为另一种类型的变量。正数的符号位是0,那么对正数进行sext ... to的结果和zext ... to是一样的,但负数符号位是1,结果就不一样了。

语法如下

<result> = sext <ty> <value> to <ty2>  ; 将ty类型的变量拓展为ty2类型的变量

举个例子

%X = sext i8 -1 to i16 							; yields i16:-1
%Y = sext i1 true to i32 						; yields i32:-1
%Z = sext <2 x i16> <i16 8, i16 7> to <2 x i32> ; yields <i32 8, i32 7>

看第一条指令,-1的二进制是10000001,然后把他转换为补码,除符号位之外取反再加1,变成11111111,然后符号位是1,sext ... to用符号位进行填充,i8到i16会变为11111111 11111111,这个补码转换为原码还是-1。

下面的跟着这个走一下就行,一个道理

其他操作 Other Operations

phi

我们在内存访问和寻址操作那里介绍了SSA,并且给出了两个流程图转换的例子。但是,我们注意到这种转换存在一个问题:转换后的最后一个基本块内,到底是用\(y_{1}\)还是\(y_{2}\)?如果程序是往左边走下来的那应该是\(y_{1}\),右边走下来的话就是\(y_{2}\)

为了解决这种变量引用混乱的问题,SSA引入了φ函数的概念。φ函数的运算结果由前驱块决定。

如果我们的程序从左边执行下来,那么φ的运算结果就是\(y_{1}\),右边执行下来结果就是\(y_{2}\)

这个φ函数就对应LLVM IR中的phi指令。phi 指令可以看做是为了解决SSA一个变量只能被赋值一次而引起的问题衍生出的指令,其计算结果由phi指令所在的基本块的前驱块确定。语法如下

<result> = phi <ty> [<val0>, <label0>], ... ;如果前驱块为label0,则result=val0 ...

首先得定义phi运算的结果的类型ty,然后定义了一系列的值以及对应的标签。如果phi指令对应的前驱块是label0,那phi的运算结果就是val0。

举个例子

Loop: ; Infinite loop that counts from 0 on up. . .
	%indvar = phi i32 [ 0, %LoopHeader ], [ %nextindvar, %Loop ]
	%nextindvar = add i32 %indvar, 1
	br label %Loop

这是用phi实现for循环的一个实例,我们之前用内存访问来实现过满足SSA的for循环,这里来看看phi实现的是什么样子的。

第一句话就是phi,这里有两个标签和对应的值,如果是从外面进入循环的,那么phi运算的结果是0,也就是赋初值的操作,如果前驱块是Loop,也就是在循环中跳转来的,那运算结果就是上一个循环中的nextindvar变量,上一个循环的nextindvar就是当前的indvar的值加1。这样就实现了++i的操作并且遵循了SSA原则。

select

select指令类似于C中的三元运算符... ? ... : ...,语法如下

<result> = select i1 <cond>, <ty> <val1>, <ty> <val2> ;如果条件cond 成立,result=val1,否则result=val2

先定义一个bool类型的变量cond,如果是true则select的结果是val1,否则是val2

举个例子

%X = select i1 true, i8 17, i8 42; yields i8:17

call

call指令用来调用某个函数,对应C中的函数调用,与x86汇编中的call指令类似。语法如下

<result> = call <ty>|<fnty> <fnptrval>(<function args>)	;调用函数

举个例子

%retval = call i32 @test( i32 %argc) 			; 调用test函数,参数为i32类型,返回值为i32类型
call i32 (i8*, ...)* @printf( i8* %msg, i32 12,i8 42) ; 调用printf函数,参数可变

实例分析

在LLVM PASS部分写了一个TestProgram.cpp,就以这个的.ll为实例

; ModuleID = 'TestProgram.cpp'
source_filename = "TestProgram.cpp"
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-unknown-linux-gnu"

@input = dso_local global [100 x i8] zeroinitializer, align 16
@enc = dso_local global <{ [22 x i8], [78 x i8] }> <{ [22 x i8] c"\86\8A}\87\93\8BM\81\80\8AC\7FII\86q\7FbSi(\9D", [78 x i8] zeroinitializer }>, align 16
@.str = private unnamed_addr constant [25 x i8] c"Please input your flag: \00", align 1
@.str.1 = private unnamed_addr constant [3 x i8] c"%s\00", align 1
@.str.2 = private unnamed_addr constant [18 x i8] c"Congratulations~\0A\00", align 1
@.str.3 = private unnamed_addr constant [18 x i8] c"Sorry try again.\0A\00", align 1

; Function Attrs: noinline nounwind optnone uwtable mustprogress
define dso_local void @_Z7encryptPhPc(i8* %0, i8* %1) #0 {
  %3 = alloca i8*, align 8
  %4 = alloca i8*, align 8
  %5 = alloca i32, align 4
  %6 = alloca i32, align 4
  store i8* %0, i8** %3, align 8
  store i8* %1, i8** %4, align 8
  %7 = load i8*, i8** %4, align 8
  %8 = call i64 @strlen(i8* %7) #5
  %9 = trunc i64 %8 to i32
  store i32 %9, i32* %5, align 4
  store i32 0, i32* %6, align 4
  br label %10

10:                                               ; preds = %31, %2
  %11 = load i32, i32* %6, align 4
  %12 = load i32, i32* %5, align 4
  %13 = icmp slt i32 %11, %12
  br i1 %13, label %14, label %34

14:                                               ; preds = %10
  %15 = load i8*, i8** %4, align 8
  %16 = load i32, i32* %6, align 4
  %17 = sext i32 %16 to i64
  %18 = getelementptr inbounds i8, i8* %15, i64 %17
  %19 = load i8, i8* %18, align 1
  %20 = sext i8 %19 to i32
  %21 = load i32, i32* %6, align 4
  %22 = sub nsw i32 32, %21
  %23 = add nsw i32 %20, %22
  %24 = load i32, i32* %6, align 4
  %25 = xor i32 %23, %24
  %26 = trunc i32 %25 to i8
  %27 = load i8*, i8** %3, align 8
  %28 = load i32, i32* %6, align 4
  %29 = sext i32 %28 to i64
  %30 = getelementptr inbounds i8, i8* %27, i64 %29
  store i8 %26, i8* %30, align 1
  br label %31

31:                                               ; preds = %14
  %32 = load i32, i32* %6, align 4
  %33 = add nsw i32 %32, 1
  store i32 %33, i32* %6, align 4
  br label %10, !llvm.loop !2

34:                                               ; preds = %10
  ret void
}

; Function Attrs: nounwind readonly willreturn
declare dso_local i64 @strlen(i8*) #1

; Function Attrs: noinline norecurse optnone uwtable mustprogress
define dso_local i32 @main(i32 %0, i8** %1) #2 {
  %3 = alloca i32, align 4
  %4 = alloca i32, align 4
  %5 = alloca i8**, align 8
  %6 = alloca [100 x i8], align 16
  %7 = alloca i8, align 1
  store i32 0, i32* %3, align 4
  store i32 %0, i32* %4, align 4
  store i8** %1, i8*** %5, align 8
  %8 = call i32 (i8*, ...) @printf(i8* getelementptr inbounds ([25 x i8], [25 x i8]* @.str, i64 0, i64 0))
  %9 = call i32 (i8*, ...) @__isoc99_scanf(i8* getelementptr inbounds ([3 x i8], [3 x i8]* @.str.1, i64 0, i64 0), i8* getelementptr inbounds ([100 x i8], [100 x i8]* @input, i64 0, i64 0))
  %10 = bitcast [100 x i8]* %6 to i8*
  call void @llvm.memset.p0i8.i64(i8* align 16 %10, i8 0, i64 100, i1 false)
  %11 = getelementptr inbounds [100 x i8], [100 x i8]* %6, i64 0, i64 0
  call void @_Z7encryptPhPc(i8* %11, i8* getelementptr inbounds ([100 x i8], [100 x i8]* @input, i64 0, i64 0))
  %12 = call i64 @strlen(i8* getelementptr inbounds ([100 x i8], [100 x i8]* @input, i64 0, i64 0)) #5
  %13 = icmp eq i64 %12, 22
  br i1 %13, label %14, label %19

14:                                               ; preds = %2
  %15 = getelementptr inbounds [100 x i8], [100 x i8]* %6, i64 0, i64 0
  %16 = call i32 @memcmp(i8* %15, i8* getelementptr inbounds ([100 x i8], [100 x i8]* bitcast (<{ [22 x i8], [78 x i8] }>* @enc to [100 x i8]*), i64 0, i64 0), i64 22) #5
  %17 = icmp ne i32 %16, 0
  %18 = xor i1 %17, true
  br label %19

19:                                               ; preds = %14, %2
  %20 = phi i1 [ false, %2 ], [ %18, %14 ]
  %21 = zext i1 %20 to i8
  store i8 %21, i8* %7, align 1
  %22 = load i8, i8* %7, align 1
  %23 = trunc i8 %22 to i1
  br i1 %23, label %24, label %26

24:                                               ; preds = %19
  %25 = call i32 (i8*, ...) @printf(i8* getelementptr inbounds ([18 x i8], [18 x i8]* @.str.2, i64 0, i64 0))
  br label %28

26:                                               ; preds = %19
  %27 = call i32 (i8*, ...) @printf(i8* getelementptr inbounds ([18 x i8], [18 x i8]* @.str.3, i64 0, i64 0))
  br label %28

28:                                               ; preds = %26, %24
  %29 = load i32, i32* %3, align 4
  ret i32 %29
}

declare dso_local i32 @printf(i8*, ...) #3

declare dso_local i32 @__isoc99_scanf(i8*, ...) #3

; Function Attrs: argmemonly nofree nosync nounwind willreturn writeonly
declare void @llvm.memset.p0i8.i64(i8* nocapture writeonly, i8, i64, i1 immarg) #4

; Function Attrs: nounwind readonly willreturn
declare dso_local i32 @memcmp(i8*, i8*, i64) #1

attributes #0 = { noinline nounwind optnone uwtable mustprogress "disable-tail-calls"="false" "frame-pointer"="all" "less-precise-fpmad"="false" "min-legal-vector-width"="0" "no-infs-fp-math"="false" "no-jump-tables"="false" "no-nans-fp-math"="false" "no-signed-zeros-fp-math"="false" "no-trapping-math"="true" "stack-protector-buffer-size"="8" "target-cpu"="x86-64" "target-features"="+cx8,+fxsr,+mmx,+sse,+sse2,+x87" "tune-cpu"="generic" "unsafe-fp-math"="false" "use-soft-float"="false" }
attributes #1 = { nounwind readonly willreturn "disable-tail-calls"="false" "frame-pointer"="all" "less-precise-fpmad"="false" "no-infs-fp-math"="false" "no-nans-fp-math"="false" "no-signed-zeros-fp-math"="false" "no-trapping-math"="true" "stack-protector-buffer-size"="8" "target-cpu"="x86-64" "target-features"="+cx8,+fxsr,+mmx,+sse,+sse2,+x87" "tune-cpu"="generic" "unsafe-fp-math"="false" "use-soft-float"="false" }
attributes #2 = { noinline norecurse optnone uwtable mustprogress "disable-tail-calls"="false" "frame-pointer"="all" "less-precise-fpmad"="false" "min-legal-vector-width"="0" "no-infs-fp-math"="false" "no-jump-tables"="false" "no-nans-fp-math"="false" "no-signed-zeros-fp-math"="false" "no-trapping-math"="true" "stack-protector-buffer-size"="8" "target-cpu"="x86-64" "target-features"="+cx8,+fxsr,+mmx,+sse,+sse2,+x87" "tune-cpu"="generic" "unsafe-fp-math"="false" "use-soft-float"="false" }
attributes #3 = { "disable-tail-calls"="false" "frame-pointer"="all" "less-precise-fpmad"="false" "no-infs-fp-math"="false" "no-nans-fp-math"="false" "no-signed-zeros-fp-math"="false" "no-trapping-math"="true" "stack-protector-buffer-size"="8" "target-cpu"="x86-64" "target-features"="+cx8,+fxsr,+mmx,+sse,+sse2,+x87" "tune-cpu"="generic" "unsafe-fp-math"="false" "use-soft-float"="false" }
attributes #4 = { argmemonly nofree nosync nounwind willreturn writeonly }
attributes #5 = { nounwind readonly willreturn }

!llvm.module.flags = !{!0}
!llvm.ident = !{!1}

!0 = !{i32 1, !"wchar_size", i32 4}
!1 = !{!"clang version 12.0.1"}
!2 = distinct !{!2, !3}
!3 = !{!"llvm.loop.mustprogress"}

这就是LLVM IR中的一个模块,头部给出了很多信息,比如source_filename表明了是从哪个文件编译来的。

下面有变量定义,比如叫input的大小为100的i8类型的数组。

再下面是函数定义,例如_Z7encryptPhPc,这个就是encrypt函数的定义。最开始的地方是入口块,是函数第一个执行的基本块,我们可以看到最后他有个终结指令 br label %10,跳转到10这个块。后面的块也差不多。34那里可以看到ret void,这里就是结束了。

我们还能看到函数的声明,引用库函数时LLVM IR会产生对应的声明。

; Function Attrs: nounwind readonly willreturn
declare dso_local i64 @strlen(i8*) #1

例如这里,我们调用了strlen,LLVM IR就产生了对于他的声明。

最后,attributes这里是LLVM IR模块的尾部信息,这是由编译器自动生成的,了解即可。

想要更进一步研究的话,我们可以自行阅读一下入口块、10和14这三个部分,对照源代码来理解一下每个语句在干什么。

LLVM PASS 常用API

LLVM PASS框架中最核心的三个类是Function, BasicBlock和Instruction,分别对应IR中的函数、基本块和指令。

Function

与Function有关的操作主要是获取函数的一些基本属性,例如函数名等,以及对函数中基本块的遍历

  • F.getName(): 获取函数名称
  • F.getEntryBlock(): 获取入口块
  • 对基本块进行遍历

对基本块进行遍历可以采用foreach

// 遍历函数F中的基本块BB
bool runOnFunction(Function &F)
{
    for (BasicBlock &BB: F)
    {
        // 对BB做点你想做的
    }
}

BasicBlock

与BasicBlock有关的操作主要是基本块的克隆、分裂、移动,以及对基本块中指令的遍历等。

  • 获取基本块的名称:BB.getName()

  • 获取基本块的终结指令:BB.getTerminator()

    遍历指令和上面那个差不多

    // 遍历函数F中的基本块BB的指令I
    bool runOnFunction(Function &F)
    {
        for (BasicBlock &BB: F)
        {
            for (Instruction &I : BB)
            {
                // 对I做点你想做的
            }
        }
    }
    
    

Instructions

他是可以有很多种子类的,我们前面写了一堆指令类型。与Instructions有关的操作主要是指令的创建、删除、修改以及操作数的遍历

遍历操作数就不能foreach了,我们通过I.getNumOperands()获取操作数的数量,通过I.getOperand(i)获取下标为i的操作数。

// 遍历函数F中的基本块BB的指令I的操作数V
bool runOnFunction(Function &F)
{
    for (BasicBlock &BB: F)
    {
        for (Instruction &I : BB)
        {
            for (int i = 0; i < I.getNumOperands(); ++i)
            {
                Value *V = I.getOperand(i);
                // 对V做点你想做的
            }
        }
    }
}

注意到操作数的类型为Value,这是LLVM中的一个基本类,所有可以被当做指令操作数的类型都是Value的子类。

Value的子类有Constant, Argument, Instruction(运算结果), Function(指针), BasicBlock五种。

输出流

C++中我们会用到cout, cerr, clog来打印输出流,但是LLVM中,我们一般采用outs(), errs()和dbgs()来获取输出流并进行打印,分别打印一般信息、错误信息和调试信息。

posted @ 2022-09-19 16:38  iPlayForSG  阅读(976)  评论(0编辑  收藏  举报