i++扫盲

猜,下面程序会输出什么?

func main() {
  i := 7758
  j := i++
  fmt.Println(i,j)
}

在没有遇到过之前,笔者也觉得这是大学生的期末考试题,认为该程序会输出7759 7758,因为i++常规操作是先用后加,所以j就是7758,i就是7759。

但是正确答案是会报错,确切的说这段程序在编译期间就会出错,将这段代码放到IDE就会发现爆红。

这是因为Go中的i++不同于C中的i++,Go中的i++是语句,而C中的i++则是表达式。谈下我所理解的表达式和语句的区别:

表达式是一段可以被求值的代码,也就是可以有接收者;而语句是可以被执行的代码,不一定会有接收者。从上面的例子来看,Go中的i++是语句,它不能有接收者,相当于一条可以被编译器识别的命令,类似于break,goto这种语句,所以在程序在编译期间就会报错。

既然原理不同,笔者就想通过汇编来对比下C的i++与Go的i++两者有什么不同点。不要听到汇编就劝退哦,笔者列举的都是很简单的语句。


对比汇编

接下来简化下程序,只保留一个声明和一个自增。

//C语言示例
#include <stdlib.h>
int main(){
    int i = 7758;
    i++;
}
//Go语言示例
package main
func main() {
  i := 7758
  i++
}

先把C语言反汇编看下,看下主要部分,可以看到自增的过程,如下:

$ gcc -o plusplustestc -g plusplus.c
$ objdump -S plusplus
......
int i = 7758;
movl $0x1e4e,-0x4(%rbp) #将7758赋值到rbp寄存器
i++;
addl $0x1,-0x4(%rbp)    #将rbp寄存器加1
......

再把Go反汇编看下,发现了奇怪的现象,为了产生对比效果,我也使用objdump生成汇编语句,发现这里直接用自增后的7759覆盖了先前的7758,而这之间并没有计算过程。

$ go build --gcflags="-l -N" -o plusplustestgo plusplus.go
$ objdump -S plusplustestgo
......
i := 7758
movq $0x1e4e,(%rsp) #将7758赋值到rsp寄存器
i++
movq $0x1e4f,(%rsp) #将7759赋值到rsp寄存器
......

这是因为Go的编译器做了优化,我们看到的Plan9汇编这些,都是在编译最后阶段生成的,在这中间编译器做了大量的优化,省去了许多无用代码(dead code),比如上述代码就是Go编译器SSA(Static Single Assignment静态单赋值)做的优化,Go语言编译器在将.go文件编译为机器码过程中会生成几十个版本的中间代码,中间会伴随着代码优化,删除不会被用到的片段,而上述程序的7758自增为7759的过程就被编译器“优化”了,只保留将7759覆盖到寄存器的过程。


中间代码

我们可以使用GOSSAFUNC环境变量构建从源代码到机器码这中间几十次中间代码的迭代过程,该方法最后会生成ssa.html文件,便于用户查看,方法如下:

这里仍然用原来的Go文件示例。

package main
func main() {
  i := 7758
  i++
}

接下来进入该文件的同级目录下,这里可能要切换至root权限,执行命令

# 命令如下
# GOSSAFUNC=<函数名> go build <.go文件>
# 实际执行
$ GOSSAFUNC=main go build plusplus.go 
# runtime
dumped SSA to /usr/local/go-1.14/src/runtime/ssa.html
# command-line-arguments
dumped SSA to ./ssa.html

此时中间代码已经生成到了ssa.html文件中,我们用浏览器打开。可以通过点击红框中的字体查看每一步中间码的生成,也可以点击任意一行代码查看中间代码转化关系。

image.png
image.png

上面俩图中间还有一长串的中间代码,这里就不贴了。

在这里浅色的字体代表被编译器”优化“的代码即dead code,这些代码不会被编进最后的机器码中。


可能有些细心的同学会发现,这里最终编出来的机器码genssa中也没有我上述贴的代码中赋值寄存器的操作啊,而且怎么,为什么会造成不一致呢?

这就是 -gcflags="-l -N" 的作用了,在上面生成汇编时候加了这个参数防止内联(-l)以及编译优化(-N),所以我们可以看到对寄存器赋值的语句。同样的,我们也可以在SSA生成时候加上这个参数,这样,一些中间代码就不会被优化掉了,就可以看到对应的中间代码了,如下。

$ GOSSAFUNC=main go build --gcflags="-l -N" plusplus.go 
# runtime
dumped SSA to /usr/local/go-1.14/src/runtime/ssa.html
# command-line-arguments
dumped SSA to ./ssa.html

此时再次查看生成的ssa.html,就是禁止内联以及编译优化的机器码的生成步骤了,感兴趣的同学可以自己尝试下。

image.png
image.png


延伸阅读


郭朝
24 声望7 粉丝