类型系统

我们知道,汇编语言是弱类型的,我们操作汇编语言的时候,实际上考虑的是一些二进制序列。但是,LLVM IR却是强类型的,在LLVM IR中所有变量都必须有类型。这是因为,我们在使用高级语言编程的时候,往往都会使用强类型的语言,弱类型的语言无必要性,也不利于维护。因此,使用强类型语言,LLVM IR可以更好地进行优化。

基本的数据类型

LLVM IR中比较基本的数据类型包括:

  • 空类型(void
  • 整型(iN
  • 浮点型(floatdouble等)

空类型一般是作为不返回值的函数的返回类型,没有特别的含义,就代表「什么都没有」。

整型是指i1, i8, i16, i32, i64这类的数据类型。这里iNN可以是任意正整数,可以是i3i1942652。但最常用,最符合常理的就是i1以及8的整数倍。i1有两个值:truefalse。也就是说,下面的代码可以正确编译:

%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分别提供了udivsdiv指令分别适用于无符号整型除法和有符号整型除法:

%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)就会变成i32255。这虽然符合道理,但有时候我们需要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的机理。

extractvalueinsertvalue

除了我们上面讲的这种情况,也就是把结构体分配在栈或者全局变量,然后操作其指针以外,还有什么情况呢?我们考虑这种情况:

; 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提供了extractvalueinsertvalue指令。

因此,如果要获得@my_struct第二个字段的值,我们需要

%2 = extractvalue %MyStruct %1, 1

这里的1就代表第二个字段(从0开始)。

类似地,如果要将%1的第二个字段赋值为233,只需要

%3 = insertvalue %MyStruct %1, i32 233, 1

然后%3就会是%1将第二个字段赋值为233后的值。

extractvalueinsertvalue并不只适用于结构体,也同样适用于存储在虚拟寄存器中的数组,这里不再赘述。

标签类型

在汇编语言中,一切的控制语句、函数调用都是由标签来控制的,在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即可。关于属性,后续也会有专门的章节进行介绍。