类型系统
我们知道,汇编语言是弱类型的,我们操作汇编语言的时候,实际上考虑的是一些二进制序列。但是,LLVM IR却是强类型的,在LLVM IR中所有变量都必须有类型。这是因为,我们在使用高级语言编程的时候,往往都会使用强类型的语言,弱类型的语言无必要性,也不利于维护。因此,使用强类型语言,LLVM IR可以更好地进行优化。
基本的数据类型
LLVM IR中比较基本的数据类型包括:
- 空类型(
void
) - 整型(
iN
) - 浮点型(
float
、double
等)
空类型一般是作为不返回值的函数的返回类型,没有特别的含义,就代表「什么都没有」。
整型是指i1
, i8
, i16
, i32
, i64
这类的数据类型。这里iN
的N
可以是任意正整数,可以是i3
,i1942652
。但最常用,最符合常理的就是i1
以及8的整数倍。i1
有两个值:true
和false
。也就是说,下面的代码可以正确编译:
%boolean_variable = alloca i1
store i1 true, ptr %boolean_variable
对于大于1位的整型,也就是如i8
, i16
等类型,我们可以直接用数字字面量赋值:
%integer_variable = alloca i32
store i32 128, ptr %integer_variable
store i32 -128, ptr %integer_variable
符号
有一点需要注意的是,在LLVM IR中,整型默认是有符号整型,也就是说我们可以直接将-128
以补码形式赋值给i32
类型的变量。在LLVM IR中,整型的有无符号是体现在操作指令而非类型上的,比方说,对于两个整型变量的除法,LLVM IR分别提供了udiv
和sdiv
指令分别适用于无符号整型除法和有符号整型除法:
%1 = udiv i8 -6, 2 ; Get (256 - 6) / 2 = 125
%2 = sdiv i8 -6, 2 ; Get (-6) / 2 = -3
我们可以用这样一个简单的程序验证:
; div_test.ll
define i8 @main() {
%1 = udiv i8 -6, 2
%2 = sdiv i8 -6, 2
ret i8 %1
}
分别将ret
语句的参数换成%1
和%2
以后,将代码编译成可执行文件,在终端下运行并查看返回值即可。
总结一下就是,LLVM IR中的整型默认按有符号补码存储,但一个变量究竟是否要被看作有无符号数需要看其参与的指令。
转换指令
与整型密切相关的就是转换指令,比如说,将i8
类型的数-127
转换成i32
类型的数,将i32
类型的数257
转换成i8
类型的数等。总的来说,LLVM IR中提供三种指令:trunc
.. to
指令,zext
.. to
指令和sext
.. to
指令。
将长的整型转换成短的整型很简单,直接把多余的高位去掉就行,LLVM IR提供的是trunc
.. to
指令:
%trunc_integer = trunc i32 257 to i8 ; Trunc 32 bit 100000001 to 8 bit, get 1
将短的整型变成长的整型则相对比较复杂。这是因为,在补码中最高位是符号位,并不表示实际的数值。因此,如果单纯地在更高位补0
,那么i8
类型的-1
(补码为11111111
)就会变成i32
的255
。这虽然符合道理,但有时候我们需要i8
类型的-1
扩展到i32
时仍然是-1
。LLVM IR为我们提供了两种指令:零扩展的zext
.. to
指令和符号扩展的sext
.. to
指令。
零扩展就是最简单的,直接在高位补0
,而符号扩展则是用原数的符号位来填充。也就是说我们如下的代码:
%zext_integer = zext i8 -1 to i32 ; Extend 8 bit 0xFF to 32 bit 0x000000FF, get 255
%sext_integer = sext i8 -1 to i32 ; Extend 8 bit 0xFF to 32 bit 0xFFFFFFFF, get -1
类似地,浮点型的数和整型的数也可以相互转换,使用fptoui
.. to
, fptosi
.. to
, uitofp
.. to
, sitofp
.. to
可以分别将浮点数转换为无符号、有符号整型,将无符号、有符号整型转换为浮点数。不过有一点要注意的是,如果将大数转换为小的数,那么并不保证截断,如将浮点型的257.1
转换成i8
(上限为128
),那么就会产生未定义行为。所以,在浮点型和整型相互转换的时候,需要在高级语言层面做一些调整,如使用饱和转换等,具体方案可以看Rust最近1.45.0的更新Announcing Rust 1.45.0和GitHub上的PR:Out of range float to int conversions using as
has been defined as a saturating conversion.。
指针类型
LLVM IR中的指针类型就是ptr
。与C语言不同,LLVM IR中的指针不含有其指向内容的类型,也就是说,类似于C语言中的void *
。我们之前提到,LLVM IR中的全局变量和栈上分配的变量都是指针,所以其类型都是指针类型。
在高级语言中,直接操作裸指针的机会都比较少,除非在性能极其敏感的场景下,由最厉害的大佬才能操作裸指针。这是因为,裸指针极其危险,稍有不慎就会出现段错误等致命错误,所以我们使用指针时应该慎之又慎。
LLVM IR为大佬们提供了操作裸指针的一些指令。在C语言中,我们会遇到这种场景:
int x, y;
size_t address_of_x = (size_t)&x;
size_t address_of_y = address_of_x - sizeof(int);
int also_y = *(int *)address_of_y;
这种场景比较无脑,但确实是合理的,需要将指针看作一个具体的数值进行加减。到x86_64的汇编语言层次,取地址就变成了lea
命令,解引用倒是比较正常,就是一个简单的mov
。
在LLVM IR层次,为了使指针能像整型一样加减,提供了ptrtoint
.. to
指令和inttoptr
.. to
指令,分别解决将指针转换为整型,和将整型转换为指针的功能。也就是说,我们可以粗略地将上面的程序转写为
%x = alloca i32 ; %x is of type ptr, which is the address of variable x
%y = alloca i32 ; %y is of type ptr, which is the address of variable y
%address_of_x = ptrtoint ptr %x to i64
%address_of_y = sub i64 %address_of_x, 4
%also_y = inttoptr i64 %address_of_y to ptr ; %also_y is of type ptr, which is the address of variable y
聚合类型
比起指针类型而言,更重要的是聚合类型。我们在C语言中常见的聚合类型有数组和结构体,LLVM IR也为我们提供了相应的支持。
数组类型很简单,我们要声明一个类似C语言中的int a[4]
,只需要
%a = alloca [4 x i32]
也就是说,C语言中的int[4]
类型在LLVM IR中可以写成[4 x i32]
。注意,这里面是个x
不是*
。
我们也可以使用类似地语法进行初始化:
@global_array = global [4 x i32] [i32 0, i32 1, i32 2, i32 3]
特别地,我们知道,字符串在底层可以看作字符组成的数组,所以LLVM IR为我们提供了语法糖:
@global_string = global [12 x i8] c"Hello world\00"
在字符串中,转义字符必须以\xy
的形式出现,其中xy
是这个转义字符的ASCII码。比如说,字符串的结尾,C语言中的\0
,在LLVM IR中就表现为\00
。
结构体的类型也相对比较简单,在C语言中的结构体
struct MyStruct {
int x;
char y;
};
在LLVM IR中就成了
%MyStruct = type {
i32,
i8
}
我们初始化一个结构体也很简单:
@global_structure = global %MyStruct { i32 1, i8 0 }
; or
@global_structure = global { i32, i8 } { i32 1, i8 0 }
值得注意的是,无论是数组还是结构体,其作为全局变量或栈上变量,依然是指针,也就是说,@global_array
的类型是ptr
, @global_structure
的类型也是ptr
。接下来的问题就是,我们如何对聚合类型进行操作呢?
在LLVM IR中,如果我们想对一个聚合类型的某些字段进行操作,需要区分这个聚合类型是指针形式的,也就是以全局变量或者栈形式存储,还是值形式的,也就是以寄存器形式存储。
getelementptr
首先,我们将介绍以指针形式存储的聚合类型,该如何访问其字段。
访问数组元素字段
我们先来看一个最直观的例子:
struct MyStruct {
int x;
int y;
};
void foo(struct MyStruct *my_structs_ptr) {
int my_y = my_structs_ptr[2].y;
}
我们有一个foo
函数,其接收了一个参数my_structs_ptr
。从函数体的语义可知,这里这个参数,实际上指向了一个数组,我们要取这个数组的第三个元素的y
字段。
我们先直接看结论,用LLVM IR来表示为
%MyStruct = type { i32, i32 }
define void @foo(ptr %my_structs_ptr) {
%my_y_in_stack = alloca i32
%my_y_ptr = getelementptr %MyStruct, ptr %my_structs_ptr, i64 2, i32 1
%my_y_val = load i32, ptr %my_y_ptr
store i32 %my_y_val, ptr %my_y_in_stack
ret void
}
我们可以注意到,最核心的就是getelementptr
指令了。它的四个参数的语义分别为
-
%MyStruct
我们要取地址的指针,它指向区域的类型为
%MyStruct
-
ptr %my_structs_ptr
我们要操作的指针,是
ptr %my_structs_ptr
-
i64 2
取偏移量为2的那个元素,也就是
my_structs_ptr[2]
-
i32 1
对于获得到的那个元素,取索引为1的字段,也就是
my_structs_ptr[2].y
通过这个指令,我们获得了my_structs_ptr[2].y
的地址,随后的LLVM IR指令就是将这个地址的值放到了局部变量中。
访问指针字段
接下来,我们看这样一个例子:
struct MyStruct {
int x;
int y;
};
void foo(struct MyStruct *my_structs_ptr) {
int my_y = my_structs_ptr->y;
}
其对应的LLVM IR为
%MyStruct = type { i32, i32 }
define void @foo(ptr %my_structs_ptr) {
%my_y_in_stack = alloca i32
%my_y_ptr = getelementptr %MyStruct, ptr %my_structs_ptr, i64 0, i32 1
%my_y_val = load i32, ptr %my_y_ptr
store i32 %my_y_val, ptr %my_y_in_stack
ret void
}
唯一的改动,就是将之前的偏移量i64 2
改为i64 0
。
这看上去挺符合直觉的。等等,符合直觉吗?
我们发现,即使是将my_structs_ptr
看作是指向结构体的指针,而非指向数组的指针,仍然要加一个偏移量0。这是因为,C语言中,对于一个数组array
,&array[0]
和指向首元素的array_ptr
是同一个东西。为了兼容C语言这个特性,LLVM IR在getelementptr
中,将所有的指针都看作一个指向数组首地址的指针。因此,我们需要额外加一个i64 0
的偏移量来解决这个问题。
级联访问
此外,getelementptr
还可以接多个参数,类似于级联调用。我们有C程序:
struct MyStruct {
int x;
int y[5];
};
struct MyStruct my_structs[4];
那么如果我们想获得my_structs[2].y[3]
的地址,只需要
%MyStruct = type {
i32,
[5 x i32]
}
%my_structs = alloca [4 x %MyStruct]
%1 = getelementptr %MyStruct, ptr %my_structs, i64 2, i32 1, i64 3
我们可以查看官方提供的The Often Misunderstood GEP Instruction指南更多地了解getelementptr
的机理。
extractvalue
和insertvalue
除了我们上面讲的这种情况,也就是把结构体分配在栈或者全局变量,然后操作其指针以外,还有什么情况呢?我们考虑这种情况:
; extract_insert_value.ll
%MyStruct = type {
i32,
i32
}
@my_struct = global %MyStruct { i32 1, i32 2 }
define i32 @main() {
%1 = load %MyStruct, ptr @my_struct
ret i32 0
}
这时,我们的结构体是直接放在虚拟寄存器%1
里,%1
并不是存储@my_struct
的指针,而是直接存储这个结构体的值。这时,我们并不能用getelementptr
来操作%1
,因为这个指令需要的是一个指针。因此,LLVM IR提供了extractvalue
和insertvalue
指令。
因此,如果要获得@my_struct
第二个字段的值,我们需要
%2 = extractvalue %MyStruct %1, 1
这里的1
就代表第二个字段(从0
开始)。
类似地,如果要将%1
的第二个字段赋值为233
,只需要
%3 = insertvalue %MyStruct %1, i32 233, 1
然后%3
就会是%1
将第二个字段赋值为233
后的值。
extractvalue
和insertvalue
并不只适用于结构体,也同样适用于存储在虚拟寄存器中的数组,这里不再赘述。
标签类型
在汇编语言中,一切的控制语句、函数调用都是由标签来控制的,在LLVM IR中,控制语句也是需要标签来完成。其具体的内容我会在之后专门有一篇控制语句的文章来解释。
元数据类型
在我们使用Clang将C语言程序输出成LLVM IR时,会发现代码的最后几行有
!llvm.module.flags = !{!0, !1, !2, !3}
!llvm.ident = !{!4}
!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 = !{!"Homebrew clang version 16.0.6"}
类似于这样的东西。
在LLVM IR中,以!
开头的标识符为元数据。元数据是为了将额外的信息附加在程序中传递给LLVM后端,使后端能够好地优化或生成代码。用于Debug的信息就是通过元数据形式传递的。我们可以使用-g
选项:
clang -S -emit-llvm -g test.c
来在LLVM IR中附加额外的Debug信息。关于元数据,在后续的章节里会有更具体的介绍。
属性
最后,还有一种叫做属性的概念。属性并不是类型,其一般用于函数。比如说,告诉编译器这个函数不会抛出错误,不需要某些优化等等。我们可以看到
define void @foo() nounwind {
; ...
}
这里nounwind
就是一个属性。
有时候,一个函数的属性会特别特别多,并且有多个函数都有相同的属性。那么,就会有大量重复的篇幅用来给每一个函数说明属性。因此,LLVM IR引入了属性组的概念,我们在将一个简单的C程序编译成LLVM IR时,会发现代码中有
attributes #0 = { noinline nounwind optnone ssp uwtable "correctly-rounded-divide-sqrt-fp-math"="false" "darwin-stkchk-strong-link" "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"="false" "probe-stack"="___chkstk_darwin" "stack-protector-buffer-size"="8" "target-cpu"="penryn" "target-features"="+cx16,+cx8,+fxsr,+mmx,+sahf,+sse,+sse2,+sse3,+sse4.1,+ssse3,+x87" "unsafe-fp-math"="false" "use-soft-float"="false" }
这种一大长串的,就是属性组。属性组总是以#
开头。当我们函数需要它的时候,只需要
define void @foo #0 {
; ...
}
直接使用#0
即可。关于属性,后续也会有专门的章节进行介绍。