Go 1.7终于发布了,这个版本的一大特色是引入了一个新的基于SSA的编译器! SSA是一个描述低层次的操作集合的方法,比如隐射到系统底层操作的加载和存储操作。而SSA的特殊之处在于它有无限数量的寄存器。它本身不是特别有趣,除了它启用了一类很容易理解的优化传值这一点以外,因为这个特性使得编译速度更快,编译出的二进制文件更小。Go的新版本发布说明实现正在成熟,并且开始利用llvm(wider world of compiler technology)中的技术和实践。
新的基于SSA后端除了带来的性能提升,还提供了一系列的新工具允许开发者与SSA机制交互。这个工具输出了SSA过程语句,优化传值,和Go特有的汇编。当使用go工具来反汇编时,可以通过设置GOSSAFUNC环境变量为函数名,比如:
$ GOSSAFUNC=main go build
这个调用会输出到终端,但是更有趣的输出是一个HTML格式的文件,名为 ssa.html ,输出到了当前目录。在浏览器打开该文件,你会看到类似这样的内容:
你所看到的是一个表格,这个表格的很多列在右侧,除了第一列和最后一列之外的列代表了通过前述的SSA表单的优化(我数过了,共37个单独的传值)。第一列是编译器的初始化,未优化的SSA输出,最后一列是Go特有的汇编,然后会被转换为机器码,生成最终的二进制文件或者共享库。
虽然这对于新手可以令人生畏,但是SSA是相对简单的设计---每一行代表了赋给一个指令的结果的值(比如一个无限数量的寄存器)或者一个基本块的标签(语句集合,嗯,在花括号之间的源代码),或者基本块的退出,它会跳转到不同的基本块执行(比如if语句控制流或者从一个函数调用返回)。
比如:
v4 = Const64 <int> [42]
意思是赋值给64位的整形常量42给标签为V4的寄存器。
b5: ← b4 v15 = Copy <mem> v14 v16 = StaticCall <mem> {runtime.printnl} v15 Call v16 → b6
意思是b5是有两个语句的基本块的标签。它包含退出Call指令,使得程序执行到另外的一个基本块,b6,从函数中返回时产生了v16值。
像Const64、Copy和StatiCall这样的符号与汇编指令MOV和LEA类似。
有一个特殊的操作是Phi,或者“Phi code”。注意到一个Phi节点带有2个参数,也就是2个值。而且注意到一个带有Phi节点的基本块有2个基本块标签,靠近它自己的标签,这点和其他的基本块不同:
b3: ← b1 b2 v20 = Phi <int> v4 v6 ...
这是个有趣的构造,它和程序控制流相关。一个基本块是通过一个单一的entry和一个单一的退出点来定义,并且有一个可以按照顺序执行的语句集合(比如,没有分支逻辑)。SSA代表“单一的静态赋值”,意味着每个值被赋给一个,而且每次只有一个。但是如果你有一个变量的引用,依赖于if语句,可能有不同的值呢?Phi节点是解决这种明显冲突的一种方式。因为每个if语句的分钟可以定义为赋给唯一的值,所以一个Phi节点可以依赖于最终采用的哪个分支,把它们合并为一个最终的值。所以,你可以它想象在运行时基于一些条件会重新获取值。这也是为什么块在顶部有2个依赖,而不是一个的原因。
让我们写一个无聊的程序来激发更多的例子:
package main func main() { x := 5 if 1 < 0 { x = -42 } println(x) }
我们以最初的基本块b1开始:
b1: v1 = InitMem <mem> v2 = SP <uintptr> v3 = SB <uintptr> v4 = Const64 <int> [5] v5 = ConstBool <bool> [false] v6 = Const64 <int> [-42] v11 = OffPtr <*int64> [0] v2 If v5 → b2 b3
在一些程序初始化后,v4是给本地变量x设置为常量5的赋值语句。 Go在编译阶段就知道1<0肯定是false,所以它直接给v5赋值给false。v6是给x赋值-42,这发生在程序执行时。
最终,我们的基本块退出,If v5 → b2 b3。这个测试了v5的真实值,来决定是执行b2(如果为true)还是b3(如果为false)。这和下面的汇编块类似:
JNZ b2 b3: ... b2: ...
一个关于Go SSA HTML 界面的好事情是,你可以在SSA表单的任何符号上点击,它会高亮引用到该元素或者从该元素得到的相关代码。
我们可以从不同的颜色看到控制流是如何走的。你可以直观地连接那些代码块,这些代码块可以执行、赋值、函数调用,和其他结果分支。
在Phi代码和它的依赖上点击,你可以看到值是来自于前面的控制流的哪个位置。
继续往下,在下面的基本块中函数调用输出了整型值的内容:
b4: ← b3 v9 = Copy <int> v20 v10 = Copy <int64> v9 v12 = Copy <mem> v8 v13 = Store <mem> [8] v11 v10 v12 v14 = StaticCall <mem> {runtime.printint} [8] v13 Call v14 → b5
StaticCall指令从Go 运行时调用了格式化整型的函数,然后在终端打印输出。一个有趣的事情是,注意前面的调用提前设置好了一些事情,也就是在那里产生了printint函数。如果你注意到,v11指回到了在b1设置的值,也就是v2的指针偏移量。而v2是在栈指针SP中设置的,SP在程序初始化的顶部。这样做很有意义,因为当调用需要指针的函数时,产生的汇编语言需要连接内存位置到地址。
还有更多的需要继续深究,包括特殊的优化传值,跟踪独立指令是如何走通到最终的汇编或者被消除。但是,希望这篇文章给你介绍了SSA以及在你的应用程序中的映射机制。