控制语句

在控制流中,最基础的就是控制语句,也就是与汇编中的跳转相关的。

汇编层面的控制语句

在大多数语言中,常见的控制语句主要有四种:

  • if .. else
  • for
  • while
  • switch

在汇编语言层面,控制语句则被分解为两种核心的指令:条件跳转与无条件跳转(switch其实还有一些工作,之后会提到)。我们下面分别来看看在汇编层面是怎样实现控制语句的。

if .. else

我们有以下C代码:

if (a > b) {
    // Do something A
} else {
    // Do something B
}
// Do something C

为了将这个指令改写成汇编指令,我们同时需要条件跳转与无条件跳转。我们用伪代码表示其汇编指令为:

    Compare a and b
    Jump to label B if comparison is a is not greater than b // conditional jump
label A:
    Do something A
    Jump to label C // unconditional jump
label B:
    Do something B
label C:
    Do something C

汇编语言通过条件跳转、无条件跳转和三个标签(label A标签实际上没有作用,只不过让代码更加清晰)实现了高级语言层面的if .. else语句。

for

我们有以下C代码:

for (int i = 0; i < 4; i++) {
    // Do something A
}
// Do something B

为了将这个指令改写为汇编指令,我们同样地需要条件跳转与无条件跳转:

    int i = 0
label start:
    Compare i and 4
    Jump to label B if comparison is i is not less than 4 // conditional jump
label A:
    Do something A
    i++
    Jump to label start // unconditional jump
label B:
    Do something B

whilefor则极其类似,只不过少了初始化与自增的操作,这里不再赘述。

根据我们在汇编语言中积累的经验,我们得出,要实现大多数高级语言的控制语句,我们需要四个东西:

  • 标签
  • 无条件跳转
  • 比较大小的指令
  • 条件跳转

LLVM IR层面的控制语句

下面就以我们上面的for循环的C语言版本为例,解释如何写其对应的LLVM IR语句。

首先,我们对应的LLVM IR的基本框架为

%i = alloca i32                       ; int i = ...
store i32 0, ptr %i                   ; ... = 0
%i_value = load i32, ptr %i
; Do something A
%1 = add i32 %i_value, 1              ; ... = i + 1
store i32 %1, ptr %i                  ; i = ...
; Do something B

这个程序缺少了一些必要的步骤,而我们之后会将其慢慢补上。

标签

在LLVM IR中,标签与汇编语言的标签一致,也是以:结尾作标记。我们依照之前写的汇编语言的伪代码,给这个程序加上标签:

    %i = alloca i32                  ; int i = ...
    store i32 0, ptr %i              ; ... = 0
start:
    %i_value = load i32, ptr %i
A:
    ; Do something A
    %1 = add i32 %i_value, 1         ; ... = i + 1
    store i32 %1, ptr %i             ; i = ...
B:
    ; Do something B

比较指令

LLVM IR提供的比较指令为icmp。其接受三个参数:比较方案以及两个比较参数。这样讲比较抽象,我们就来看一下一个最简单的比较指令的例子:

%comparison_result = icmp uge i32 %a, %b

这个例子转化为C++语言就是

bool comparison_result = ((unsigned int)a >= (unsigned int)b);

这里,uge是比较方案,%a%b就是用来比较的两个数,而icmp则返回一个i1类型的值,也就是C++中的bool值,用来表示结果是否为真。

icmp支持的比较方案很广泛:

  • 首先,最简单的是eqne,分别代表相等或不相等。
  • 然后,是无符号的比较ugt, uge, ult, ule,分别代表大于、大于等于、小于、小于等于。我们之前在数的表示中提到,LLVM IR中一个整型变量本身的符号是没有意义的,而是需要看在其参与的指令中被看作是什么符号。这里每个方案的u就代表以无符号的形式进行比较。
  • 最后,是有符号的比较sgt, sge, slt, sle,分别是其无符号版本的有符号对应。

我们来看加上比较指令之后,我们的例子就变成了:

    %i = alloca i32                               ; int i = ...
    store i32 0, ptr %i                           ; ... = 0
start:
    %i_value = load i32, ptr %i
    %comparison_result = icmp slt i32 %i_value, 4 ; Test if i < 4
A:
    ; Do something A
    %1 = add i32 %i_value, 1                      ; ... = i + 1
    store i32 %1, ptr %i                          ; i = ...
B:
    ; Do something B

条件跳转

在比较完之后,我们需要条件跳转。我们来看一下我们此刻的目的:若%comparison_resulttrue,那么跳转到A,否则跳转到B

LLVM IR为我们提供的条件跳转指令是br,其接受三个参数,第一个参数是i1类型的值,用于作判断;第二和第三个参数分别是值为truefalse时需要跳转到的标签。比方说,在我们的例子中,就应该是

br i1 %comparison_result, label %A, label %B

我们把它加入我们的例子:

    %i = alloca i32                               ; int i = ...
    store i32 0, ptr %i                           ; ... = 0
start:
    %i_value = load i32, ptr %i
    %comparison_result = icmp slt i32 %i_value, 4 ; Test if i < 4
    br i1 %comparison_result, label %A, label %B
A:
    ; Do something A
    %1 = add i32 %i_value, 1                      ; ... = i + 1
    store i32 %1, ptr %i                          ; i = ...
B:
    ; Do something B

无条件跳转

无条件跳转更好理解,直接跳转到某一标签处。在LLVM IR中,我们同样可以使用br进行无条件跳转。如,如果要直接跳转到start标签处,则可以

br label %start

我们也把这加入我们的例子:

    %i = alloca i32                               ; int i = ...
    store i32 0, ptr %i                           ; ... = 0
start:
    %i_value = load i32, ptr %i
    %comparison_result = icmp slt i32 %i_value, 4 ; Test if i < 4
    br i1 %comparison_result, label %A, label %B
A:
    ; Do something A
    %1 = add i32 %i_value, 1                      ; ... = i + 1
    store i32 %1, ptr %i                          ; i = ...
    br label %start
B:
    ; Do something B

这样看上去就结束了,然而如果大家把这个代码交给llc的话,并不能编译通过,这是为什么呢?

Basic block

首先,我们来摘录一下LLVM IR的参考指南中Functions节的一段话:

A function definition contains a list of basic blocks, forming the CFG (Control Flow Graph) for the function. Each basic block may optionally start with a label (giving the basic block a symbol table entry), contains a list of instructions, and ends with a terminator instruction (such as a branch or function return). If an explicit label name is not provided, a block is assigned an implicit numbered label, using the next value from the same counter as used for unnamed temporaries (see above).

这段话的大意有几个:

  • 一个函数由许多基本块(Basic block)组成
  • 每个基本块包含:
    • 开头的标签(可省略)
    • 一系列指令
    • 结尾是终结指令
  • 一个基本块没有标签时,会自动赋给它一个标签

所谓终结指令,就是指改变执行顺序的指令,如跳转、返回等。

我们来看看我们之前写好的程序是不是符合这个规定。start开头的基本块,在一系列指令后,以

br i1 %comparison_result, label %A, label %B

结尾,是一个终结指令。A开头的基本块,在一系列指令后,以

br label %start

结尾,也是一个终结指令。B开头的基本块,在最后总归是需要函数返回的(这里为了篇幅省略了),所以也一定会带有一个终结指令。

看上去都很符合呀,那为什么编译不通过呢?我们来仔细想一下,我们考虑了所有基本块了吗?要注意到,一个基本块是可以没有名字的,所以,实际上还有一个基本块没有考虑到,就是函数开头的:

%i = alloca i32          ; int i = ...
store i32 0, ptr %i      ; ... = 0

这个基本块。它并没有以终结指令结尾!

所以,我们把一个终结指令补充在这个基本块的结尾:

    %i = alloca i32                               ; int i = ...
    store i32 0, ptr %i                           ; ... = 0
    br label %start
start:
    %i_value = load i32, ptr %i
    %comparison_result = icmp slt i32 %i_value, 4 ; Test if i < 4
    br i1 %comparison_result, label %A, label %B
A:
    ; Do something A
    %1 = add i32 %i_value, 1                      ; ... = i + 1
    store i32 %1, ptr %i                          ; i = ...
    br label %start
B:
    ; Do something B

这样就完成了我们的例子,大家可以在本系列的GitHub的仓库中查看对应的代码for.ll

可视化

LLVM的工具链甚至为我们提供了可视化控制语句的方法。我们使用之前提到的LLVM工具链中用于优化的opt工具:

opt -p dot-cfg for.ll

然后会生成一个.main.dot的文件。如果我们在计算机上装有Graphviz,那么就可以用

dot .main.dot -Tpng -o for.png

生成其可视化的控制流图(CFG):

for

switch

下面我们来讲讲switch语句。我们有以下C语言程序:

int x;
switch (x) {
    case 0:
        // do something A
        break;
    case 1:
        // do something B
        break;
    default:
        // do something C
        break;
}
// do something else

我们先直接来看其转换成LLVM IR是什么样子的:

switch i32 %x, label %C [
    i32 0, label %A
    i32 1, label %B
]
A:
    ; Do something A
    br label %end
B:
    ; Do something B
    br label %end
C:
    ; Do something C
    br label %end
end:
    ; Do something else

其核心就是第一行的switch指令。其第一个参数i32 %x是用来判断的,也就是我们C语言中的x。第二个参数label %C是C语言中的default分支,这是必须要有的参数。也就是说,我们的switch必须要有default来处理。接下来是一个数组,其意义已经很显然了,如果%x值是0,就去label %A,如果值是1,就去label %B

LLVM后端对switch语句具体到汇编层面的实现则通常有两种方案:用一系列条件语句或跳转表。

一系列条件语句的实现方式最简单,用伪代码来表示的话就是

if (x == 0) {
    Jump to label %A
} else if (x == 1) {
    Jump to label %B
} else {
    Jump to label %C
}

这是十分符合常理的。然而,我们需要注意到,如果这个switch语句一共有n个分支,那么其查找效率实际上是O(n)。那么,这种实现方案下的switch语句仅仅是if .. else的语法糖,除了增加可维护性,并不会优化什么性能。

跳转表则是一个可以优化性能的switch语句实现方案,其伪代码为:

labels = [label %A, label %B]
if (x < 0 || x > 1) {
    Jump to label %C
} else {
    Jump to labels[x]
}

这只是一个极其粗糙的近似的实现,我们需要的是理解其基本思想。跳转表的思想就是利用内存中数组的索引是O(1)复杂度的,所以我们可以根据目前的x值去查找应该跳转到哪一个地址,这就是跳转表的基本思想。

根据目标平台和switch语句的分支数,LLVM后端会自动选择不同的实现方式去实现switch语句。

select

我们经常会遇到一种情况,某一变量的值需要根据条件进行赋值,比如说以下C语言的函数:

void foo(int x) {
    int y;
    if (x > 0) {
        y = 1;
    } else {
        y = 2;
    }
    // Do something with y
}

如果x大于0,则y1,否则y2。这一情况很常见,然而在C语言中,如果要实现这种功能,y需要被实现为可变变量,但实际上无论x如何取值,y只会被赋值一次,并不应该是可变的。

我们知道,LLVM IR中,由于SSA的限制,局部可变变量都必须分配在栈上,虽然LLVM后端最终会进行一定的优化,但写起代码来还需要冗长的alloca, load, store等语句。如果我们按照C语言的思路来写LLVM IR,那么就会是:

define void @foo(i32 %x) {
    %y = alloca i32
    %1 = icmp sgt i32 %x, 0
    br i1 %1, label %btrue, label %bfalse
btrue:
    store i32 1, ptr %y
    br label %end
bfalse:
    store i32 2, ptr %y
    br label %end
end:
    ; do something with %y
    ret void
}

我们来看看其编译出的汇编语言是怎样的:

foo:
# %bb.0:
	cmpl	$0, %edi
	jle	.LBB0_2
# %bb.1:                                # %btrue
	movl	$1, -4(%rsp)
	jmp	.LBB0_3
.LBB0_2:                                # %bfalse
	movl	$2, -4(%rsp)
.LBB0_3:                                # %end
	retq

算上注释,C语言代码9行,汇编语言代码11行,LLVM IR代码14行。这LLVM IR同时比低层次和高层次的代码都长,而且这种模式在真实的代码中出现的次数会非常多,这显然是不可以接受的。究其原因,就是这里把y看成了可变变量。那么,有没有什么办法让y不可变但仍然能实现这个功能呢?

首先,我们来看看同样区分可变变量与不可变变量的Rust是怎么做的:

fn foo(x: i32) {
    let y = if x > 0 { 1 } else { 2 };
    // Do something with y
}

让代码简短的方式很简单,把y看作不可变变量,但同时需要语言支持把if语句视作表达式,当x大于0时,这个表达式返回1,否则返回2。这样,就很简单地实现了我们的需求。

LLVM IR中同样也有这样的指令,那就是select,我们来把上面的例子用select改写:

define void @foo(i32 %x) {
    %result = icmp sgt i32 %x, 0
    %y = select i1 %result, i32 1, i32 2
    ; Do something with %y
}

select指令接受三个参数。第一个参数是用来判断的布尔值,也就是i1类型的icmp判断的结果,如果其为true,则返回第二个参数,否则返回第三个参数。极其合理。

select不仅可以简化LLVM代码,也可以优化生成的二进制程序。在大部分情况下,在AMD64架构中,LLVM会将select指令编译为CMOVcc指令,也就是条件赋值,从而优化了生成的二进制代码。

phi

select只能支持两个选择,true选择一个分支,false选择另一个分支,我们是不是可以有支持多种选择的类似switch的版本呢?同时,我们也可以换个角度思考,select是根据i1的值来进行判断,我们其实可以根据控制流进行判断。这就是在SSA技术中大名鼎鼎的phi指令。

为了方便起见,我们首先先来看用phi指令实现的我们上面这个代码:

define void @foo(i32 %x) {
    %result = icmp sgt i32 %x, 0
    br i1 %result, label %btrue, label %bfalse
btrue:
    br label %end
bfalse:
    br label %end
end:
    %y = phi i32 [1, %btrue], [2, %bfalse]
    ; Do something with %y
    ret void
}

我们看到,phi的第一个参数是一个类型,这个类型表示其返回类型为i32。接下来则是两个数组,其表示,如果当前的basic block执行的时候,前一个basic block是%btrue,那么返回1,如果前一个basic block是%bfalse,那么返回2

也就是说,select是根据其第一个参数i1类型的变量的值来决定返回哪个值,而phi则是根据其之前是哪个basic block来决定其返回值。此外,phi之后可以跟无数的分支,如phi i32 [1, %a], [2, %b], [3, %c]等,从而可以支持多分支的赋值。