接Go汇编(一),理解Go
一些前置条件之后,接下来通过实际分析来进一步分析Go
源码编译之后的汇编语言。
有下面一段代码:
func add(a int, b int) int {
c := 1
n := sub(a, b, c)
return n + a + b
}
func sub(a int, b int, c int) int {
d := 3
return a - b - c - d
}
func main() {
add(1, 2)
}
其中:main
调用add
函数,add
函数调用了sub
函数。
sub函数 – callee
首先看最简单的sub
函数,观察sub
函数:
- 3个
int
参数 - 1个
int
返回值 - 1个
int
局部变量
结合上一篇分析的go
汇编的caller-save
模式,可以画出sub
的函数调用栈如下:
接下来结合汇编语言进行分析:
注:以下代码都低于
go1.17
版本编译,go1.17
版本更新了通过寄存器传参,因此编译结果可能存在较大差异。
使用GOOS=darwin GOARCH=amd64 go tool compile -N -l -S main.go
将其编译为汇编。
-N :
关闭优化
-l:
关闭内联
-S:
:打印编译结果
"".sub STEXT nosplit size=65 args=0x20 locals=0x10 funcid=0x0 //golang汇编固定开头 args:参数大小32字节 locals:局部变量大小16字节
TEXT "".sub(SB), NOSPLIT|ABIInternal, 16-32 //定义sub函数,栈帧大小16字节,参数+返回值大小32字节
SUBQ16, SP //扩展16字节的栈帧
MOVQ BP, 8(SP) //保存BP寄存器原本的值
LEAQ 8(SP), BP //将当前SP+8的地址保存在BP寄存器中
FUNCDATA 0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
FUNCDATA1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
MOVQ 0, "".~r3+48(SP) //初始化返回值
MOVQ3, "".d(SP) //初始化局部变量d
MOVQ "".a+24(SP), AX //将参数a的值移动复制给AX
SUBQ "".b+32(SP), AX //AX=AX-b
SUBQ "".c+40(SP), AX //AX=AX-c
ADDQ -3, AX //AX=AX+-3
MOVQ AX, "".~r3+48(SP) //将AX的值移动给返回值的地址
MOVQ 8(SP), BP //恢复BP寄存器的值
ADDQ16, SP //回收栈空间
RET //返回
结合上面的函数调用栈分析:
TEXT "".sub(SB), NOSPLIT|ABIInternal, $16-32
,这一行主要是用于定义函数
TEXT
: 定义函数,定义数据一般用GLBOAL
"".sub(SB)
: 函数名,这一行表示通过sub
表示sub
函数在全局内存中的地址。NOSPLIT
: 告诉编译器不要插入分裂栈检查点,更多标志参考官方文档ABIInternal
:程序二进制接口内部(Application Binary Interface Internal)
$16-32
:- 函数帧大小16字节,结合上图看包括一个
caller BP
和一个int
类型的局部变量 - 函数参数+函数返回值为32字节,结合上图看包括3个
int
参数和一个int
返回值。
- 函数帧大小16字节,结合上图看包括一个
SUBQ $16, SP
通过减少SP
寄存器的地址实现增加sub
函数的栈帧,用于后面存方法局部变量
MOVQ BP, 8(SP)
: 保存caller BP
,8(SP)
对应上图的caller BP*
,8(SP)
表示SP
寄存器指向的地址加8个字节,注意这里的SP
是硬件SP
LEAQ 8(SP), BP
: 将SP
地址+8的地址保存在BP
寄存器中。
到目前为止,仅仅是做了增加栈帧空间,并将之前的BP
中的值保存在栈上。
这里可以看到,
sub
函数相当于将add
函数保存在BP
的内容保存在自己栈上,同时将自己的伪(SP)寄存器指向的地址存放在了BP
寄存器中,不难想象,如果sub
函数还调用了其他函数,那么被调用函数获取到的BP
寄存器的值则是sub
所保存的值,所以这一步叫保存caller BP
,具体有什么作用,暂时没有资料说明。在后面我们可以看到,在
sub
函数返回之前,会将BP
的值恢复到之前的值。
FUNCDATA
: GC写屏障相关,这里忽略。
MOVQ $0, "".~r3+48(SP)
: 初始化返回值。
~r3
: 因为我们的返回值并没有命名,所以这里生成了临时symbol
~r3+48(SP)
: 表示这个变量的起始地址SP
寄存器所指向的地址再加上48字节。MOVQ
: 这个变量大小为4字节- 这里只是对返回值进行初始化零值
48字节可以对应上图的函数调用栈理解,
return value0
距离hardware SP
为6个Word
,6*8=48
MOVQ $3, "".d(SP)
: 初始化变量d
为3,对应d:=3
.d
对应局部变量d
的名字$3
:表示常量3"".d(SP)
: 表示变量d
的地址为SP
寄存器指向的地址+0,可以对应上图local variable
MOVQ
:变量d
的大小为4字节
MOVQ "".a+24(SP), AX
: 保存变量a
到AX
寄存器
"".a+24(SP)
:对应SP
寄存器+24,可以看到对于参数,从下到上是从左往右保存的。
SUBQ "".b+32(SP), AX
: 用AX
寄存器中的值减去b
的值并将结果保存在AX
寄存器中,对应:a-b
SUBQ "".c+40(SP), AX
:用AX
寄存器中的值减去c
的的值并将结果保存在AX
寄存器中,对应a-b-c
ADDQ $-3, AX
: 将AX
寄存器中的值加上-3
,对应a-b-c-3
- 可以看到这里其实并没有使用局部变量
d
,如果我们允许编译器优化,估计d:=3
会被直接优化掉
MOVQ AX, "".~r3+48(SP)
:将AX
寄存器中的值保存在r3
中
到这里,便完成了方法的主要功能:计算a-b-c
并将返回值保存在栈对应的位置上。接下来就是恢复BP
寄存器的值和返回。
MOVQ 8(SP), BP
: 恢复BP
寄存器的值为caller-BP
,这一行对应LEAQ 8(SP), BP
将BP
寄存器的值修改为8(SP)
ADDQ $16, SP
: 释放栈帧
RET
: 函数返回
可以看到,对于caller-save
模式来说,函数不用管理局部变量和参数的栈空间大小,但是函数需要负责初始化和将结果保存在栈上。callee
返回后,caller
可以直接从栈上拿到返回结果。
add 函数 – caller
接下来分析add
函数,来观察caller
如果分配栈帧。
首先观察add
函数:
- 2个
int
类型的参数 - 1个
int
返回值 - 2个
int
局部变量 - 调用子函数
sub
因此我们可以画出add
函数的函数调用栈:
贴上add()
方法的汇编语言:
"".add STEXT size=125 args=0x18 locals=0x38 funcid=0x0
TEXT "".add(SB), ABIInternal, 56-24
MOVQ (TLS), CX
CMPQ SP, 16(CX)
PCDATA0, -2
JLS 118
PCDATA0, -1
SUBQ56, SP //增加56字节栈帧
MOVQ BP, 48(SP)
LEAQ 48(SP), BP
FUNCDATA 0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
FUNCDATA1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
MOVQ 0, "".~r2+80(SP) //初始化返回值
MOVQ1, "".c+40(SP) //初始化局部变量c
MOVQ "".b+72(SP), AX //保存参数b到AX
MOVQ "".a+64(SP), CX //保存参数a到CX
MOVQ CX, (SP) //将CX的值拷贝到栈上
MOVQ AX, 8(SP) //将AX的值拷贝到栈上
MOVQ 1, 16(SP) //将1的(对应局部变量c)拷贝到栈上
PCDATA1, 0
CALL "".sub(SB) //执行sub方法
MOVQ 24(SP), AX //将返回值移动到AX寄存器中
MOVQ AX, "".n+32(SP) //将AX寄存器的值移动到栈的局部变量位置
MOVQ "".a+64(SP), CX //将参数a的值移动到CX寄存器中
ADDQ CX, AX //执行n+a
ADDQ "".b+72(SP), AX //执行n+a+b并将结果保存在AX寄存器中
MOVQ AX, "".~r2+80(SP) //将AX寄存器的值存放在栈的return value的位置上
MOVQ 48(SP), BP //回复BP的值
ADDQ56, SP //回收56字节的栈空间
RET //返回结果
NOP
PCDATA 1,-1
PCDATA 0,-2
CALL runtime.morestack_noctxt(SB)
PCDATA 0,-1
JMP 0
这里挑一些关键语句:
TEXT "".add(SB), ABIInternal, $56-24
: 定义add
函数
$56
:栈帧56字节。对应16字节局部变量+ 32 字节sub
函数的大小+8字节的caller BP
大小return address
的大小不用手动分配,call
指令会自动增加栈帧大小。- 这里可以看见,
add
函数中没有NOSPLIT
的标志,因此函数中会包含一些判断是否需要栈扩容的代码。这里暂时先忽略这些代码
MOVQ $0, "".~r2+80(SP)
: 初始化返回值
"".~r2+80(SP)
: 可以看到返回值的地址为SP
寄存器指向的地址加80,也就是距离SP
指针10个字。由于此时还没有调用call
指令,因此returnAddress
的空间还没有分配。因此SP
寄存器指向的地址为arg0
的位置。从后面的MOVQ CX, (SP)
也可以印证SP
此时指向arg0
其他相关的语句可以结合函数调用栈和上面的解释具体分析。下面再来一张main()->add()->sub()
的整体的函数调用栈。
Caller BP
最后,我们通过汇编来验证第一篇所说的Caller BP
。
在第一篇我们说到:
对于caller BP
,这是由编译器在编译的时候自动插入的。具体作用官方并没有解释,同时caller BP
并不是一个必选项,其需要同时满足以下两个条件才会自动插入caller BP
- 当前函数的栈帧大小大于0,注意是当前函数,不包括
caller frame
- 下面的方法返回值为
true
func Framepointer_enabled(goos, goarch string) bool {
return framepointer_enabled != 0 && goarch == "amd64" && goos != "nacl"
}
可以看到,如果栈帧大小为0,则不会有保存caller BP
的操作。我们以下sub
方法,删除局部变量:
func sub(a int, b int, c int) int {
return a - b - c
}
然后执行:GOOS=darwin GOARCH=amd64 go tool compile -N -l -S main.go
编译:
"".sub STEXT nosplit size=30 args=0x20 locals=0x0 funcid=0x0
TEXT "".sub(SB), NOSPLIT|ABIInternal, 0-32
FUNCDATA0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
FUNCDATA 1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
MOVQ0, "".~r3+32(SP)
MOVQ "".a+8(SP), AX
SUBQ "".b+16(SP), AX
SUBQ "".c+24(SP), AX
MOVQ AX, "".~r3+32(SP)
RET
对比之前的sub
函数,可以看到的确少了BP
寄存器的保存和恢复的操作。
参考资料: