Go汇编(二)

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的函数调用栈如下:

image-20220705230552589

接下来结合汇编语言进行分析:

注:以下代码都低于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返回值。

SUBQ $16, SP 通过减少SP寄存器的地址实现增加sub函数的栈帧,用于后面存方法局部变量


MOVQ BP, 8(SP): 保存caller BP8(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: 保存变量aAX寄存器

  • "".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), BPBP寄存器的值修改为8(SP)


ADDQ $16, SP: 释放栈帧

RET: 函数返回


可以看到,对于caller-save 模式来说,函数不用管理局部变量和参数的栈空间大小,但是函数需要负责初始化和将结果保存在栈上。callee返回后,caller可以直接从栈上拿到返回结果。

add 函数 – caller

接下来分析add函数,来观察caller如果分配栈帧。

首先观察add函数:

  • 2个int类型的参数
  • 1个int返回值
  • 2个int局部变量
  • 调用子函数sub

因此我们可以画出add函数的函数调用栈:

image-20220705234032729

贴上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()的整体的函数调用栈。

image-20220706114300595


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寄存器的保存和恢复的操作。

参考资料:

《汇编语言》

《Go语言高级编程》

Go 系列文章3 :plan9 汇编入门

A Quick Guide to Go’s Assembler

go-internals

Go汇编

深入研究goroutine栈

函数运行时在内存中是什么样子?

转: 汇编是深入理解 Go 的基础