基础概念
LLVM数据结构
在LLVM中,基本数据结构包括:Module,Function,BasicBlock,Instruction之间的关系可以用以下的层次结构来描述:
Module
└── Function
└── BasicBlock
└── Instruction
以下是它们之间的关系的详细说明:
Module
:是LLVM IR的最顶层结构,它包含了整个程序的所有函数、全局变量和其他模块级别的定义。一个模块可以有多个函数。Function
:是模块中的一个成员,它定义了一个可以调用的代码块。一个函数属于一个模块,并且包含多个基本块(BasicBlock)。函数还包括了参数列表和返回类型。BasicBlock
:是函数中的一个组件,它代表了一个连续的指令序列,这些指令在执行时不会被其他指令中断。每个基本块以一个标签开始,并且以一个控制流指令(例如分支、跳转或返回)结束。一个函数可以有多个基本块,这些基本块按照控制流指令的指向相互连接。Instruction
:是基本块中的元素,它代表了一个具体的操作或计算。每个指令都属于一个基本块,并且可以有零个或多个操作数(这些操作数可以是常数、其他指令的输出或者函数的参数)。指令的执行顺序由它们在基本块中的位置决定。
在这个层次结构中,模块包含了函数,函数包含了基本块,而基本块包含了指令。这种结构允许LLVM IR以树状形式表示程序的逻辑结构,同时也方便了编译器在各个层次上对代码进行优化和分析。
常量表达式
Constant Expressions,是一种特殊的表达式,它在编译时就可以确定其值,并且永远不会改变。这些表达式通常用于表示程序中的常量值,例如字面量、数组初始化器、结构体初始化器等。
; 整数字面量
%const_int = constant i32 42
; 浮点字面量
%const_float = constant float 3.14159
; 字符串字面量(以数组形式)
%const_string = constant [13 x i8] c"Hello, World!"
; 结构体初始化器
%const_struct = constant { i32, float } { i32 42, float 3.14159 }
; 数组初始化器
%const_array = constant [2 x i32] [i32 1, i32 2]
; 常量表达式计算(例如,加法)
%const_add = add i32 %const_int, 1
常用指令类型
1.算数运算
算数运算指令,包括add, sub, mul, sdiv, udiv, fadd, fsub, fmul, fdiv
等。用于对两个数值进行算数运算,包括整型/浮点数的加减乘除。在LLVM中,该类指令的语法如下所示:
%result = opcode <type> <value1>, <value2>
其中,opcode表示运算符,包括add,sub,mul等。<type>
表示要进行加法运算的值的数据类型,可以是整数、浮点数等;<value1>
和<value2>
分别表示相加的两个数,可以是常量、寄存器或者其他指令的结果。
在LLVM中,add指令的<type>
参数指定了<value1>
和<value2>
的类型,同时也指定了<result>
的类型。支持的类型包括:
- 整数类型:
i1, i8, i16, i32, i64, i128
等; - 浮点类型:
half, float, double, fp128
等; - 向量类型:
<n x i8>
,<n x i16>
,<n x i32>
等; - 指针类型:
i8*
,i32*
,float*
等; - 标签类型:
metadata
;
例如,如果我们想将两个整数相加并得到一个整数结果,可以使用以下指令:
%x = add i32 2, 3
%z = mul i32 %x, %y
这个指令将常量2
和3
相加,结果保存到寄存器%x
中。随后将寄存器%x
和%y
中的值相乘,结果保存到寄存器%z
中。
2.位运算指令
位运算指令,包括and, or, xor, shl, lshr, ashr
等。IR有多种位运算指令,包括位与(and)、位或(or)、位异或(xor)、位取反(not)等。这些指令可以对整数类型进行按位操作,并将结果存储到一个新的寄存器中。以下是 IR 中常见的位运算指令及其作用:
- 位与(and):将两个整数的二进制表示进行按位与操作。
- 位或(or):将两个整数的二进制表示进行按位或操作。
- 位异或(xor):将两个整数的二进制表示进行按位异或操作。
- 位取反(not):将一个整数的二进制表示进行按位取反操作。
在LLVM中,该类指令与算数指令的语法类似,如下所示:
%result = opcode <type> <value1>, <value2>
其中<type>
表示要进行位运算的整数的数据类型,可以是i1、i8、i16、i32、i64
等;<value1>
和<value2>
分别表示要进行位运算的整数,可以是常量、寄存器或其他指令的结果。例如:
%result = and i32 %x, %y
%result = or i32 %x, %y
%result = xor i32 %x, %y
%result = xor i32 %x, -1
第一个指令将%x
和%y
进行按位与操作,并将结果保存到%result
中;第二个指令将%x
和 %y
进行按位或操作,并将结果保存到%result
中;第三个指令将%x
和%y
进行按位异或操作,并将结果保存到%result
中;最后一个指令将%x
和二进制全为1
的数进行按位异或操作,即将%x
的每一位取反,结果同样保存到%result
中。
3.转换指令
该类型指令用于将一种数据类型转换成另一种数据类型,包括如下:
trunc
: 将一个整数或浮点数截断为比原来小的位数,即去掉高位的一些二进制位。zext
: 将一个整数或布尔值的位数增加,新位数的高位都填充为零,即进行零扩展。sext
: 将一个整数的位数增加,新位数的高位都填充为原有的最高位,即进行符号扩展。fptrunc
: 将一个浮点数截断为比原来小的位数,即去掉高位的一些二进制位。这是一种舍入操作,可能会丢失一些精度。fpext
: 将一个浮点数的位数增加,新位数的高位都填充为零,即进行浮点零扩展。fptoui
: 将一个浮点数转换为一个无符号整数。如果浮点数是负数,则结果为零。fptosi
: 将一个浮点数转换为一个带符号整数。如果浮点数是负数,则结果为负的最小整数。uitofp
: 将一个无符号整数转换为一个浮点数。sitofp
: 将一个带符号整数转换为一个浮点数。ptrtoint
: 将一个指针类型转换为一个整数类型。该指令通常用于将指针转换为整数进行计算。inttoptr
: 将一个整数类型转换为一个指针类型。该指令通常用于将整数转换为指针进行内存地址计算。bitcast
: 将一个值从一种类型转换为另一种类型,但是这些类型必须具有相同的位数。这个指令可以用来实现底层内存操作,例如将浮点数转换为整数以进行位运算。
该类指令的语法如下所示:
%result = zext <source type> <value> to <destination type>
其中,<source type>
和<destination type>
分别表示源类型和目标类型,表示要转换的值。例如,下面的代码将一个64位整数截断为32位整数:
%a = add i64 1, 2
%b = trunc i64 %a to i32
在这个例子中,%a
是一个64
位整数,它的值是3(1+2)
。%b
是一个32
位整数,它的值是3
。由于%a
被截断为32
位整数,因此只有低32
位的值保留下来。
4.内存指令
内存指令,包括:alloca、load、store,getelementptr
等,这些指令可以用于内存分配、初始化和复制操作。
alloca
指令,用于在栈上分配内存,并返回一个指向新分配的内存的指针。alloca指令的使用格式如下:
%ptr = alloca <type>
其中,<type>
是要分配的内存块的类型。例如,下面的代码分配一个包含5
个整数的数组:
%array = alloca [5 x i32]
load
指令,用于从内存中读取数据,并将其加载到寄存器中。load指令的使用格式如下:
%val = load <type>* <ptr>
其中,<type>
是要读取的数据的类型,<ptr>
是指向要读取数据的内存块的指针。例如,下面的代码将一个整数数组的第一个元素加载到寄存器中:
%array = alloca [5 x i32]
%ptr = getelementptr [5 x i32], [5 x i32]* %array, i32 0, i32 0
%val = load i32, i32* %ptr
在这个例子中,%array
是一个整数数组,%ptr
是指向数组第一个元素的指针,load
指令将%ptr
指向的内存块中的数据加载到%val
寄存器中。
store
指令,用于将数据从寄存器中写入内存。store
指令的使用格式如下:
store <type> <val>, <type>* <ptr>
其中,<type>
是要写入的数据的类型,<val>
是要写入的数据的值,<ptr>
是指向要写入数据的内存块的指针。例如,下面的代码将一个整数存储到一个整数数组的第一个元素中:
%array = alloca [5 x i32]
%ptr = getelementptr [5 x i32], [5 x i32]* %array, i32 0, i32 0
store i32 42, i32* %ptr
在这个例子中,%array
是一个整数数组,%ptr
是指向数组第一个元素的指针,store
指令将整数值42存储到%ptr
指向的内存块中。
getelementptr
指令,用于计算指针的偏移量,以便访问内存中的数据。getelementptr
指令的使用格式如下:
%ptr = getelementptr <type>, <type>* <ptr>, <index type> <idx>, ...
其中,<type>
是指针指向的数据类型,<ptr>
是指向数据的指针,<index type>
是索引的类型,<idx>
是索引的值。
getelementptr
指令可以接受多个索引,每个索引都可以是任意类型的。索引类型必须是整数类型,用于计算偏移量。例如,下面的代码计算一个二维数组中的一个元素的指针:
%array = alloca [3 x [4 x i32]]
%ptr = getelementptr [3 x [4 x i32]], [3 x [4 x i32]]* %array, i32 1, i32 2
在这个例子中,%array
是一个二维数组,%ptr是指向第二行第三列元素的指针。
5.比较指令
比较指令,包括:icmp、fcmp等,这两个指令根据比较规则,比较两个操作数的关系。
- icmp指令的操作数类型是整型或整型向量、指针或指针向量。对于指针或指针向量,在做比较运算的时候,都会将其指向的地址值作为整型数值去比较,所以归根结底也还是整型。
- fcmp指令要求操作数是浮点值或者浮点向量,这个没有指针类型。
该类指令语法格式如下:
<result> = icmp <cond> <ty> <op1>, <op2> ; yields i1 or <N x i1>:result
<result> = fcmp [fast-math flags]* <cond> <ty> <op1>, <op2> ; yields i1 or <N x i1>:result
-
<op1>
,<op2>
这两个是操作数。 -
<cond>
这是比较规则,icmp
和fcmp
指令的比较规则不一样,包括:eq,ne,ult,sgt
等。
相关例子如下:
icmp:
<result> = icmp eq i32 4, 5 ; yields: result=false
<result> = icmp ne float* %X, %X ; yields: result=false
<result> = icmp ult i16 4, 5 ; yields: result=true
<result> = icmp sgt i16 4, 5 ; yields: result=false
<result> = icmp ule i16 -4, 5 ; yields: result=false
<result> = icmp sge i16 4, 5 ; yields: result=false
fcmp:
<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
6.条件跳转指令
条件跳转指令,br指令用于执行条件分支,根据条件跳转到不同的基本块。它的语法如下:
br i1 <cond>, label <iftrue>, label <iffalse>
其中<cond>
是条件值,如果其值为真,则跳转到标记为<iftrue>
的基本块;否则跳转到标记为<iffalse>
的基本块。下面是一个简单的示例:
define i32 @test(i32 %a, i32 %b) {
%cmp = icmp eq i32 %a, %b
br i1 %cmp, label %equal, label %notequal
equal:
ret i32 1
notequal:
ret i32 0
}
在这个示例中,我们定义了一个函数test
,它接受两个整数参数%a
和%b
。首先,我们使用icmp
指令比较这两个值是否相等,并将结果保存在%cmp
中。然后,我们使用br
指令根据%cmp
的值跳转到不同的基本块,如果它们相等,则返回1
;否则返回0
。
7.函数返回指令
函数返回指令,ret
用于从函数中返回一个值。它的语法如下:
ret <type> <value>
其中,<type>
是返回值的类型,<value>
是返回的值。如果函数没有返回值,则<type>
应该是void
。下面是一个示例:
define i32 @test(i32 %a, i32 %b) {
%sum = add i32 %a, %b
ret i32 %sum
}
在这个示例中,我们定义了一个函数test
,它接受两个整数参数%a
和%b
。首先,我们使用add
指令将它们相加并将结果保存在%sum
中。然后,我们使用ret
指令返回%sum
的值。
8.函数调用指令
函数调用指令,call用于调用函数。它的语法如下:
%result = call <type> <function>(<argument list>)
其中,<type>
是函数返回值的类型,<function>
是要调用的函数的名称,<argument list>
是函数参数的列表。下面是一个示例:
declare i32 @printf(i8*, ...)
define i32 @test(i32 %a, i32 %b) {
%sum = add i32 %a, %b
%format_str = getelementptr inbounds [4 x i8], [4 x i8]* @.str, i64 0, i64 0
call i32 (i8*, ...) @printf(i8* %format_str, i32 %sum)
ret i32 %sum
}
在这个示例中,我们首先使用add指令计算a+b
的值,然后使用getelementptr
指令获取全局字符串@.str
的指针,该字符串包含格式化字符串%d\n
。最后,我们使用call
指令调用函数printf
,将%format_str
和%sum
作为参数传递给它。
备注
详情见官方文档,链接。
xVMP介绍
开源代码仓库:github.sheincorp.cn/GANGE666/xV…
函数虚拟化
xVMP对某个特定函数进行指令虚拟化时,会将函数指令全部抽除掉,生成对应虚拟化后的指令集,存放到变量gv_code_seg_$funcname
中。如果当前函数有返回值,则将其存放到gv_code_seg_$funcname
数组的第一个元素中,从第二个元素开始用于存放函数输入参数;如果当前函数没有返回值,则将输入参数从数组第一个元素开始存放。虚拟化前后函数实例如下:
- 原函数
__attribute__((annotate(("xvmp, nosplit"))))
__attribute__((noinline))
long f1(long a, long b, long c) {
long d = 100;
if (a + b > 10){
c = c + d;
}else{
c = d - c;
}
a = c;
return a;
}
- 虚拟化后函数
__int64 __fastcall f1(__int64 a1, __int64 a2, __int64 a3)
{
data_seg_addr_f1 = (__int64)&gv_data_seg_f1;
data_seg_addr_f1[1] = a1;
data_seg_addr_f1[2] = a2;
data_seg_addr_f1[3] = a3;
code_seg_addr_f1 = (__int64)&gv_code_seg_f1;
vm_interpreter_f1();
return data_seg_addr_f1[0];
}
数据结构图
xVMP
解析器输入参数主要有两个,分别为:虚拟后代码指令集gv_code_seg
,数据段gv_data_seg
。
gv_code_seg_xx
,存放函数虚拟化后的指令集,并且对所有指令进行加密,每个代码块使用独立的密钥。
gv_data_seg
,存放函数执行过程中所需用到的各种变量,包括函数返回值,函数输入参数,临时变量,全局变量等。
指令虚拟化
1. Alloca指令虚拟化
alloca指令,用于分配某种数据类型的存储空间,如下:
%ptr = alloca <type> //例子: %3 = alloca i64, align 8 ; 分配一个int64位的数据,变量名命名为%3。
虚拟化后指令格式:
var_size
:代表当前alloca
开辟的空间的指针ptr
大小(小于等于8)。var_type
:代表当前变量数据类型。var_offset
:代表当前alloca
开辟的空间的指针在gv_data_seg
中的偏移位置。area_offset
:代表当前alloca
开辟的空间的在gv_data_seg
中的偏移位置。ptr
:虚拟机解析到alloca
指令时,会将area_offset
的值赋值到var_offset
所指的gv_data_seg
所在位置。
uint8_t * ptr = (uint8_t*)(data_seg_addr+var_offset);
uint64_t = data_seg_addr+area_offset;
for (int i = 0; i < var_size; i++) {
*ptr = value & 0xFF;
ptr ++;
value = value >> 8;
}
alloac value
:生成虚拟指令时,会在gv_data_seg
中预留所开辟空间的大小。
2. Load指令虚拟化
load指令,用于从内存中读取数据,并将其加载到寄存器中。load指令的使用格式如下:
%val = load <type>* <ptr> //例子:%val = load i32, i32* %ptr ;将%ptr指向的内存块中的数据加载到%val寄存器中。
虚拟化后指令格式:
val_size
:存放被赋值变量的长度,如上图所示,代表变量在gv_data_seg
中值的长度。val_type
:存放被赋值变量的类型(未被使用到)。val_offset
:存放被赋值变量在gv_data_seg中的偏移。ptr_size
:存放被加载变量的长度(未被使用到)。ptr_size
:存放被加载变量的类型(未被使用到)。ptr_offset
:存放被加载变量指针在gv_data_seg
中的偏移。
该指令最终将ptr value赋值到load value中。
3.Store指令虚拟化
store指令,用于将数据从寄存器中写入内存。store指令的使用格式如下:
store <type> <val>, <type>* <ptr> //例子:store i32 42, i32* %ptr ;store指令将整数值42存储到%ptr指向的内存块中。
虚拟化后指令格式:
val_size
:存放要存储值val
的大小。val_type
:存放要存储值val
的类型,0
表示其是个存放于gv_data_seg
中的变量,非0
表示其是个常量数值,该值存放于指令中。value/val_offset
:如果val_type
值为0
,则此处存放要存储值在gv_data_seg
的偏移;否则,此处存放具体的常量数值。ptr_size
:存放被赋值的内容大小(未被使用到)。ptr_type
:存放被赋值的内容类型(未被使用到)。ptr_offset
:存放被赋值的指针在gv_data_seg的偏移。
该指令最终将value
赋值到ptr value
中。
4.数值/位运算指令虚拟化
算数运算指令,包括add, sub, mul, sdiv, udiv, fadd, fsub, fmul, fdiv, and, or, xor, shl, lshr, ashr
等。用于对两个数值进行算数运算,包括整型/浮点数的加减乘除,异或移位等。该类指令的语法如下所示:
%result = opcode <type> <value1>, <value2> //例子:%z = mul i32 %x, %y
虚拟化后指令格式:
code
:表示数值运算符类型,包括:add, sub, mul, sdiv, udiv, fadd, fsub, fmul, fdiv, and, or, xor, shl, lshr, ashr
。res
:用于存放计算后结果。op1_value
:表示第一个操作数,如果type
类型为0
,则该数值存放于gv_data_seg
中,通过op1_offset
偏移获取到对应的值;否则,该值存放于指令段中,大小为op1_size
。op2_value
:表示第二个操作数,与op1_value
一致。
5.比较运算指令虚拟化
比较指令,包括:icmp
、fcmp
等,这两个指令根据比较规则,比较两个操作数的关系。该类指令语法格式如下:
<result> = icmp <cond> <ty> <op1>, <op2> ; yields i1 or <N x i1>:result
<result> = fcmp [fast-math flags]* <cond> <ty> <op1>, <op2> ; yields i1 or <N x i1>:result
//例子:
// <result> = icmp eq i32 4, 5 ; yields: result=false
//<result> = icmp ne float* %X, %X
虚拟化后指令格式:
指令格式与数值/位运算指令类似。
6.getelementptr指令虚拟化
getelementptr
指令,用于计算指针的偏移量,以便访问内存中的数据。getelementptr
指令的使用格式如下:
%ptr = getelementptr <type>, <type>* <ptr>, <index type> <idx>, ...
//例子:%ptr = getelementptr [3 x [4 x i32]], [3 x [4 x i32]]* %array, i32 1, i32 2
虚拟化后指令格式:
- 如果
gep_size !=0 && gep_type != 0
,则表示为数组类型,此时:res_value = ptr_value + gep_size * idx_value
; - 否则,表示为结构体类型,此时:
res_value = ptr_value + gep_size
;
7.Cast转换指令虚拟化
该类型指令用于将一种数据类型转换成另一种数据类型,该类指令的语法如下所示:
%result = zext <source type> <value> to <destination type>
//例子:
//%b = trunc i64 %a to i32
虚拟化后指令格式:
将value
值赋值到result
中。
8.条件跳转指令虚拟化
条件跳转指令,br
指令用于执行条件分支,根据条件跳转到不同的基本块。它的语法如下:
br i1 <cond>, label <iftrue>, label <iffalse>
//例子:
//define i32 @test(i32 %a, i32 %b) {
// %cmp = icmp eq i32 %a, %b
// br i1 %cmp, label %equal, label %notequal
//equal:
// ret i32 1
//notequal:
// ret i32 0
//}
虚拟化后指令格式:
ip
:当前代码段指令集gv_code_seg
执行到的指令位置,通过修改该值,可以实现指令跳转。br_type
:表示跳转指令类型,分为有条件跳转和无条件跳转,分别入上图所示。
9.返回指令虚拟化
函数返回指令,ret用于从函数中返回一个值。它的语法如下:
ret <type> <value>
//例子:
//define i32 @test(i32 %a, i32 %b) {
// %sum = add i32 %a, %b
// ret i32 %sum
//}
虚拟化后指令格式:
- 执行到
ret
指令后,直接return
退出虚拟函数,回到原函数出口。 res_value
:位于gv_data_seg
的第一个元素,用于存放函数返回值,虚拟机中将ret
指令中的value
赋值到res_value
中。
10.函数调用指令虚拟化
函数调用指令,call
用于调用函数。它的语法如下:
%result = call <type> <function>(<argument list>)
//例子:
//declare i32 @printf(i8*, ...)
//define i32 @test(i32 %a, i32 %b) {
// %sum = add i32 %a, %b
// %format_str = getelementptr inbounds [4 x i8], [4 x i8]* @.str, i64 0, i64 0
// call i32 (i8*, ...) @printf(i8* %format_str, i32 %sum)
// ret i32 %sum
//}
xVMP
会为每个函数$funcname
,生成对应的用于执行call
指令调用的虚拟函数"vm_interpreter_callinst_dispatch_$funcname(uint64_t targetfunc_id)"
,该函数有一个uint64_t
类型参数targetfunc_id
,用于表示$funcname
中第几个call
指令。
以printf函数调用为例,如下,f1
函数中有两处调用到printf
函数。
__attribute__((annotate(("xvmp, nosplit"))))
__attribute__((noinline))
long f1(long a, long b, long c) {
long d = 100;
char* format0 = "f1 d: %ld\n";
printf(format0, d);
if (a + b > 10){
c = c + d;
}else{
c = d - c;
}
char* format = "f1 c: %ld\n";
printf(format, c);
a = c;
return a;
}
因此对应生成后的vm_interpreter_callinst_dispatch_f1
函数中会根据targetfunc_id
调用到不同位置的printf
,虚拟化后代码如下。
void __fastcall vm_interpreter_callinst_dispatch_f1(int64 targetfunc_id)
{
if ( result )
{
if ( result == 1 )
{
long arg1 = data_seg_addr_f1[136];
long arg2 = data_seg_addr_f1[144];
result = printf(arg1, arg2);
data_seg_addr_f1[152] = result;
}
}
else
{
long arg1 = data_seg_addr_f1[237];
long arg2 = data_seg_addr_f1[245];
result = printf(arg1, arg2);
data_seg_addr_f1[253] = result;
}
return;
}
虚拟化后指令格式:
- 调用
vm_interpreter_callinst_dispatch_$funcname
函数,并以targetfunc_id
作为参数,完成函数调用。如果被调用的函数有返回值,则将其存储到ret_value
中。