硬核知识点
什么是plan9汇编
我们知道,CPU是只认二进制指令的,也就是一串的0101;人类无法记住这些二进制码,于是发明了汇编语言。汇编语言实际上是二进制指令的文本形式,它与指令可以一一对应。
每一种CPU指令都是不一样的,因此对应的汇编语言也就不一样。人类写完汇编语言后,把它转换成二进制码,就可以被机器执行了。转换的动作由编译器完成。
Go语言的编译器和汇编器都带了一个-S参数,可以查看生成的最终目标代码。通过对比目标代码和原始的Go语言或Go汇编语言代码的差异可以加深对底层实现的理解。
Go汇编语言实际上来源于plan9汇编语言,而plan9汇编语言最初来源于Go语言作者之一的Ken Thompson为plan9系统所写的C语言编译器输出的汇编伪代码。这里强烈推荐一下春晖大神的新书《Go语言高级编程》,即将上市,电子版的点击阅读原文可以看到地址,书中有一整个章节讲Go的汇编语言,非常精彩!
理解Go的汇编语言,哪怕只是一点点,都能对Go的运行机制有更深入的理解。比如我们以前讲的defer,如果从Go源码编译后的汇编代码来看,就能深刻地掌握它的底层原理。再比如,很多文章都会分析Go的函数参数传递都是值传递,如果把汇编代码秀出来,很容易就能得出结论。
汇编角度看函数调用及返回过程
假设我们有一个这样年幼无知的例子,求两个int的和,Go源码如下:
package main
func main() {
_ = add(4,6)
}
func add(a, b int) int {
return a+b
}
使用如下命令得到汇编代码:
go tool compile -S main.go
go tool compile
命令用于调用Go语言提供的底层命令工具,其中-S
参数表示输出汇编格式。
我们现在只关心add函数的汇编代码:
"".add STEXT nosplit size=19 args=0x18 locals=0x0
0x0000 00000 (main.go:7) TEXT "".add(SB), NOSPLIT, $0-24
0x0000 00000 (main.go:7) FUNCDATA $0, gclocals·54241e171da8af6ae173d69da0236748(SB)
0x0000 00000 (main.go:7) FUNCDATA $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
0x0000 00000 (main.go:7) MOVQ "".b+16(SP), AX
0x0005 00005 (main.go:7) MOVQ "".a+8(SP), CX
0x000a 00010 (main.go:8) ADDQ CX, AX
0x000d 00013 (main.go:8) MOVQ AX, "".~r2+24(SP)
0x0012 00018 (main.go:8) RET
看不懂没关系,我目前也不是全部都懂,但是对于理解一个函数调用的整体过程而言,足够了。
0x0000 00000 (main.go:7) TEXT "".add(SB), NOSPLIT, $0-24
这一行表示定义add
这个函数,最后的数字$0-24
,其中0
表示函数栈帧大小为0;24
表示参数及返回值的大小:参数是2个int型变量,返回值是1个int型变量,共24字节。
再看中间这四行:
0x0000 00000 (main.go:7) MOVQ "".b+16(SP), AX
0x0005 00005 (main.go:7) MOVQ "".a+8(SP), CX
0x000a 00010 (main.go:8) ADDQ CX, AX
0x000d 00013 (main.go:8) MOVQ AX, "".~r2+24(SP)
代码片段中的第1行,将第2个参数b
搬到AX
寄存器;第2行将1个参数a
搬到寄存器CX
;第3行将a
和b
相加,相加的结果搬到AX
;最后一行,将结果搬到返回参数的地址,这段汇编代码非常简单,来看一下函数调用者和被调者的栈帧图:
(SP)指栈顶,b+16(SP)表示参数1的位置,从SP往上增加16个字节,注意,前面的b仅表示一个标号;同样,a+8(SP)表示实参0;~r2+24(SP)则表示返回值的位置。
具体可以看下面的图:
上面add函数的栈帧大小为0,其实更一般的调用者与被调用者的栈帧示意图如下:
最后,执行RET
指令。这一步把被调用函数add
栈帧清零,接着,弹出栈顶的返回地址
,把它赋给指令寄存器rip
,而返回地址
就是main
函数里调用add
函数的下一行。
于是,又回到了main
函数的执行环境,add
函数的栈帧也被销毁了。但是注意,这块内存是没有被清零的,清零动作是之后再次申请这块内存的时候要做的事。比如,声明了一个int型变量,它的默认值是0,清零的动作是在这里完成的。
这样,main函数完成了函数调用,也拿到了返回值,完美。