为什么要看 Plan9 汇编?如果你是 Go 开发者,去学习和理解一下 Plan9 是很有必要的,因为它可以解决你对一段代码的理解(为什么这样不行?那样却可以?)。
Plan9 不同于 AT&T 和 Intel 汇编器,但是懂这两个汇编语法的话对理解 Plan9 还是有很大帮助的。
疑惑
// 为什么这个函数的返回值会是 -1
func demo1() int {
ret := -1
defer func() {
ret = 1
}()
return ret
}
// output: -1
// 为什么这个函数的返回值会是 1
func demo2() (ret int) {
defer func() {
ret = 1
}()
return ret
}
// output: 1
相信大部分人都看过类似的解答,demo1 中是临时变量导致的,而 demo2 中没有临时变量,这是最终结果。
在汇编层面到底做了什么?本文将会探讨这个问题。(本文所使用的平台是 MacOS AMD64)不同的平台指令集和寄存器都不一样。
基础
通用寄存器
下面是通用通用寄存器的名字在 IA64 和 plan9 中的对应关系:
IA64 | RAX | RBX | RCX | RDX | RDI | RSI | RBP | RSP | R8 | R9 | R10 | R11 | R12 | R13 | R14 | RIP |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
Plan9 | AX | BX | CX | DX | DI | SI | BP | SP | R8 | R9 | R10 | R11 | R12 | R13 | R14 | PC |
应用代码层面会用到的通用寄存器主要是: AX, BX, CX, DX, DI, SI, R8~R15 这 14 个寄存器,虽然 BP 和 SP 也可以用,不过 BP 和 SP 会被用来管理栈顶和栈底,最好不要拿来进行运算。
Plan9 汇编的操作数方向和 Intel 汇编相反的,与 AT&T 类似。
伪寄存器
Go 汇编引入了 4 个伪寄存器,官方定义如下:
- FP: Frame pointer: arguments and locals.
- PC: Program counter: jumps and branches.
- SB: Static base pointer: global symbols.
- SP: Stack pointer: top of stack.
以下针对 FP,SP 做一些描述:
- FP:使用形式如
symbol+offset(FP)
的方式,引用函数的输入参数。eg:first_arg+0(FP)
,second_arg+8(FP)
- SP:SP 是有对应的寄存器的,所以区分 SP 到底是指硬件 SP 还是指虚拟寄存器,需要以特定的格式来区分。eg:
symbol+offset(SP)
则表示伪寄存器 SP。eg:offset(SP)
则表示硬件 SP。
变量声明
使用 DATA 结合 GLOBL 来定义一个变量。
DATA symbol+offset(SB)/width, value
使用 GLOBL 指令将变量声明为 global,额外接收两个参数,一个是 flag,另一个是变量的总大小。
GLOBL divtab(SB), RODATA, $8
GLOBL 必须跟在 DATA 指令之后,下面是一个定义了多个 readonly 的全局变量的完整例子:
DATA pi+0(SB)/8, $3.1415926
GLOBL pi(SB), RODATA, $8
在全局变量中定义数组,字符串,这时候就需要添加 <>
,<>
符号可以使变量使用偏移量操作。例子:
DATA array<>+0(SB)/8, $1
DATA array<>+8(SB)/8, $2
通常建议直接使用 <>
进行变量声明。
函数声明
// 函数声明
// 该声明一般写在任意一个 .go 文件中,例如:add.go
func add(a, b int) int
// 函数实现
// 该实现一般写在与声明同名的 _{Arch}.s 文件中,例如:add_amd64.s
TEXT pkgname·add(SB), NOSPLIT, $0-16
MOVQ a+0(FP), AX
MOVQ a+8(FP), BX
ADDQ AX, BX
MOVQ BX, ret+16(FP)
RET
pkgname
可以不写,一般都是不写的,可以参考 go 的源码, 另外 add 前的 ·
不是 .
参数及返回值大小
|
TEXT pkgname·add(SB),NOSPLIT,$0-16
| | |
包名 函数名 栈帧大小(局部变量+可能需要的额外调用函数的参数空间的总大小,
但不包括调用其它函数时的 ret address 的大小)
以上使用的 RODATA
,NOSPLIT
flag,还有其他的值,可以参考:https://golang.org/doc/asm#directives,
务必注意:对于编译输出 go tool compile -S / go tool objdump 的代码来讲,目前所有的 SP 都是硬件寄存器 SP,无论是否带 symbol。
以下为上图的栈结构示意图,由于没有临时变量,所以伪 SP 和 硬件 SP 在同一个位置。
+------------------+
| return parameter |
+------------------+
| parameter b |
+------------------+
| parameter a | <-- pseudo FP addr
+------------------+
| caller ret addr | <-- pseudo SP addr and hardware SP addr
+------------------+
分析
编译 / 反编译
很多时候我们无法确定一块代码是如何执行的,需要通过生成汇编、反汇编来研究
// 编译
go build -gcflags="-S"
go tool compile -S hello.go
go tool compile -N -S hello.go // 禁止优化
// 反编译
go tool objdump <binary>
基础已经介绍的差不多了,接下来就是深究这两段代码的区别。
// go tool compile -S demo1.go
func demo1() int {
ret := -1
defer func() {
ret = 1
}()
return ret
}
// 编译的汇编 demo1 部分代码
"".demo1 STEXT size=158 args=0x8 locals=0x30
0x0000 00000 (.\scratch.go:5) TEXT "".demo1(SB), ABIInternal, $48-8
// 栈的初始化操作,以及 GC相关的标记等等操作,有兴趣的可以自己研究以下。
...
// 15(SP) 不知道为什么要操作这个,望大佬解释,本人猜测可能跟 deferreturn 有关。
0x002c 00044 (.\scratch.go:5) MOVB $0, ""..autotmp_3+15(SP)
// 这里对 56(SP) 地址进行了赋值操作写了个 0,这个位置其实是返回值地址
0x0031 00049 (.\scratch.go:5) MOVQ $0, "".~r0+56(SP)
// 16(SP) 临时变量 ret,将 -1 写入到了栈中。
0x003a 00058 (.\scratch.go:6) MOVQ $-1, "".ret+16(SP)
// 猜测与 deferreturn 有关。
0x0043 00067 (.\scratch.go:7) LEAQ "".demo1.func1·f(SB), AX
0x004a 00074 (.\scratch.go:7) MOVQ AX, ""..autotmp_4+32(SP)
// 将 16(SP) 的地址给了 AX 寄存器,这个地址里存的是 -1
0x004f 00079 (.\scratch.go:7) LEAQ "".ret+16(SP), AX
// 将 AX 寄存器里的 16(SP) 的地址给了 24(SP)
0x0054 00084 (.\scratch.go:7) MOVQ AX, ""..autotmp_5+24(SP)
0x0059 00089 (.\scratch.go:7) MOVB $1, ""..autotmp_3+15(SP)
// 将 16(SP) 的值给了 AX 寄存器,这个地址里存的是 -1
0x005e 00094 (.\scratch.go:10) MOVQ "".ret+16(SP), AX
// 将 AX 的值给了 56(SP), 56(SP) 上面说过了是返回值地址, 所以当前的返回值是 -1
// 这里也是最后一次操作 56(SP),所以最终的返回值是 -1
0x0063 00099 (.\scratch.go:10) MOVQ AX, "".~r0+56(SP)
0x0068 00104 (.\scratch.go:10) MOVB $0, ""..autotmp_3+15(SP)
// 24(SP) 的值给了 AX,24(SP) 存储的是 16(SP) 的地址, 也就是临时变量的地址
0x006d 00109 (.\scratch.go:10) MOVQ ""..autotmp_5+24(SP), AX
// 将 AX 的值给了 0(SP), 也就是将 16(SP) 的地址给了 0(SP)
// 这里可以 0(SP) 为调用 demo1.func1 的入参
0x0072 00114 (.\scratch.go:10) MOVQ AX, (SP)
0x0076 00118 (.\scratch.go:10) PCDATA $1, $1
// 调用 demo1.func1
0x0076 00118 (.\scratch.go:10) CALL "".demo1.func1(SB)
0x007b 00123 (.\scratch.go:10) MOVQ 40(SP), BP
0x0080 00128 (.\scratch.go:10) ADDQ $48, SP
0x0084 00132 (.\scratch.go:10) RET
0x0085 00133 (.\scratch.go:10) CALL runtime.deferreturn(SB)
0x008a 00138 (.\scratch.go:10) MOVQ 40(SP), BP
0x008f 00143 (.\scratch.go:10) ADDQ $48, SP
0x0093 00147 (.\scratch.go:10) RET
0x0094 00148 (.\scratch.go:10) NOP
0x0094 00148 (.\scratch.go:5) PCDATA $1, $-1
0x0094 00148 (.\scratch.go:5) PCDATA $0, $-2
0x0094 00148 (.\scratch.go:5) CALL runtime.morestack_noctxt(SB)
0x0099 00153 (.\scratch.go:5) PCDATA $0, $-1
0x0099 00153 (.\scratch.go:5) JMP 0
"".demo1.func1 STEXT nosplit size=13 args=0x8 locals=0x0
// 这里的 $0-8 就是只有一个参数没有返回值, go 代码中 defer 后面的函数
0x0000 00000 (.\scratch.go:8) TEXT "".demo1.func1(SB), NOSPLIT|ABIInternal, $0-8
0x0000 00000 (.\scratch.go:8) FUNCDATA $0, gclocals·1a65e721a2ccc325b382662e7ffee780(SB)
0x0000 00000 (.\scratch.go:8) FUNCDATA $1, gclocals·69c1753bd5f81501d95132d08af04464(SB)
// 将 8(SP) 的值给了 AX 寄存器,也就是将 16(SP) 的地址给了 AX
0x0000 00000 (.\scratch.go:9) MOVQ "".&ret+8(SP), AX
// 将 1 给了 AX 寄存器保存的地址的位置上。这个操作像 *a = 1
0x0005 00005 (.\scratch.go:9) MOVQ $1, (AX)
0x000c 00012 (.\scratch.go:10) RET
这里有了第一段汇编的讲解就不做特别具体的描述了,只标注重点关注的地方。
// go tool compile -S demo2.go
func demo2() (ret int) {
defer func() {
ret = 1
}()
return ret
}
// 编译的汇编 demo2 部分代码
"".demo2 STEXT size=138 args=0x8 locals=0x28
0x0000 00000 (.\scratch.go:6) TEXT "".demo2(SB), ABIInternal, $40-8
...
0x002c 00044 (.\scratch.go:6) MOVB $0, ""..autotmp_2+15(SP)
0x0031 00049 (.\scratch.go:6) MOVQ $0, "".ret+48(SP)
0x003a 00058 (.\scratch.go:7) LEAQ "".demo2.func1·f(SB), AX
0x0041 00065 (.\scratch.go:7) MOVQ AX, ""..autotmp_3+24(SP)
// 将返回值地址给了 AX 寄存器
0x0046 00070 (.\scratch.go:7) LEAQ "".ret+48(SP), AX
// 返回值地址给了 16(SP)
0x004b 00075 (.\scratch.go:7) MOVQ AX, ""..autotmp_4+16(SP)
0x0050 00080 (.\scratch.go:10) MOVB $0, ""..autotmp_2+15(SP)
// 又将 返回值地址给了 AX 寄存器
0x0055 00085 (.\scratch.go:10) MOVQ ""..autotmp_4+16(SP), AX
// 将返回值地址给了 0(SP)
// 这里可以 0(SP) 为调用 demo2.func1 的入参
0x005a 00090 (.\scratch.go:10) MOVQ AX, (SP)
0x005e 00094 (.\scratch.go:10) PCDATA $1, $1
0x005e 00094 (.\scratch.go:10) NOP
// 调用了 demo2.func1
0x0060 00096 (.\scratch.go:10) CALL "".demo2.func1(SB)
0x0065 00101 (.\scratch.go:10) MOVQ 32(SP), BP
0x006a 00106 (.\scratch.go:10) ADDQ $40, SP
0x006e 00110 (.\scratch.go:10) RET
0x006f 00111 (.\scratch.go:10) CALL runtime.deferreturn(SB)
0x0074 00116 (.\scratch.go:10) MOVQ 32(SP), BP
0x0079 00121 (.\scratch.go:10) ADDQ $40, SP
0x007d 00125 (.\scratch.go:10) RET
0x007e 00126 (.\scratch.go:10) NOP
0x007e 00126 (.\scratch.go:6) PCDATA $1, $-1
0x007e 00126 (.\scratch.go:6) PCDATA $0, $-2
0x007e 00126 (.\scratch.go:6) NOP
0x0080 00128 (.\scratch.go:6) CALL runtime.morestack_noctxt(SB)
0x0085 00133 (.\scratch.go:6) PCDATA $0, $-1
0x0085 00133 (.\scratch.go:6) JMP 0
"".demo2.func1 STEXT nosplit size=13 args=0x8 locals=0x0
0x0000 00000 (.\scratch.go:7) TEXT "".demo2.func1(SB), NOSPLIT|ABIInternal, $0-8
0x0000 00000 (.\scratch.go:7) FUNCDATA $0, gclocals·1a65e721a2ccc325b382662e7ffee780(SB)
0x0000 00000 (.\scratch.go:7) FUNCDATA $1, gclocals·69c1753bd5f81501d95132d08af04464(SB)
0x0000 00000 (.\scratch.go:8) MOVQ "".&ret+8(SP), AX
// 这里将返回值的值修改成了 1
0x0005 00005 (.\scratch.go:8) MOVQ $1, (AX)
0x000c 00012 (.\scratch.go:9) RET
以上就是两个方法的汇编源码解析,从两个栗子中可以得到结果。
demo1 中的 ret 是临时变量,虽然 defer 确实改了 ret 的值,但这个值跟返回值没一毛钱关系,而且在汇编中 demo1 的返回值在还没调用 demo1.func1 的时候就已经确定了,所以 demo1 返回了 -1。
demo2 中的 ret 则直接指向了返回值的地址,defer 也改了返回值的值, 所以 demo2 就返回了 1。