目录

基于寄存器调用惯例的Go语言接口调用机制

本文是对此文章的学习与翻译。

摘要

其实这个机制和Java以及C++的接口调用机制差不多,都是基于itable,不过Java由于虚拟机规范中没有对方法调用惯例做出明确的规定,所以对Java而言,方法调用惯例是基于寄存器还是栈,完全取决于JVM的具体实现,可参考此回答

Go版本说明

网上的很多博客对于Go语言的方法调用,尤其是接口方法调用的描述已经过时了。因为从1.17版本开始,在AMD64架构上的方法调用就变为了基于寄存器

Go 1.17 implements a new way of passing function arguments and results using registers instead of the stack. Benchmarks for a representative set of Go packages and programs show performance improvements of about 5%, and a typical reduction in binary size of about 2%. This is currently enabled for Linux, macOS, and Windows on the 64-bit x86 architecture (the linux/amd64, darwin/amd64, and windows/amd64 ports).

1.18版本更进一步,将基于寄存器的方法调用惯例扩展到了其他平台:

Go 1.17 implemented a new way of passing function arguments and results using registers instead of the stack on 64-bit x86 architecture on selected operating systems. Go 1.18 expands the supported platforms to include 64-bit ARM (GOARCH=arm64), big- and little-endian 64-bit PowerPC (GOARCH=ppc64, ppc64le), as well as 64-bit x86 architecture (GOARCH=amd64) on all operating systems. On 64-bit ARM and 64-bit PowerPC systems, benchmarking shows typical performance improvements of 10% or more.

本篇文章基于最新的Go 1.18,架构为AMD64。

样例代码

本篇文章用冒泡排序算法中的内层循环来讲解:

func bubbleUp(x sort.Interface) {
  n := x.Len()
  for i := 1; i < n; i++ {
    if x.Less(i, i-1) {
      x.Swap(i, i-1)
    }
  }
}

这里的sort.Interface是Go标准库里的接口,其定义如下:

type Interface interface {
  Len() int
  Less(i, j int) bool
  Swap(i, j int)
}

只要实现了sort.Interface接口,就可以当作bubbleUp函数的参数x,但最常用的还是slice。

接口的内存布局

接口在Go runtime中的内部表示如下:

type iface struct {
  tab  *itab
  data unsafe.Pointer
}

在64位机器上,tab和data都是64bit,共128bit,也就是两个4字,其中itab类型定义如下:

type itab struct {
  inter *interfacetype
  _type *_type
  hash  uint32 // copy of _type.hash. Used for type switches.
  _     [4]byte
  fun   [1]uintptr // variable sized. fun[0]==0 means _type does not implement inter.
}

这里重点关注fun字段,它是一个可变大小的函数指针数组,这就是分派表

寄存器

目前在AMD64架构上,Go依次使用以下寄存器用作函数调用:

RAX, RBX, RCX, RDI, RSI, R8, R9, R10, R11

有时候,一个参数会占用多个寄存器,比如上面说的iface接口,它占用64*2=128bit,那么如果接口值被当作第一个参数传给函数,则其tab字段和data字段将分别占据RAX和RBX寄存器,如果接口值被当作第二个参数传给函数,此时,第一个参数已经占据了RAX,则其tab字段和data字段将分别占据RBX和RCX寄存器,以此类推。顺便提一下,Go语言使用的Plan9汇编语言不使用前缀R来访问这些寄存器(RAX, RBX, RCX, RDI, RSI),而是直接用AX,BX等名字。

汇编代码

使用如下命令将样例代码反汇编:

go tool compile -S bubble.go

关于Go语言的汇编入门,可以参考这篇文章

得到的完整汇编代码见gist

略去下面这段函数序言

"".bubbleUp STEXT size=182 args=0x10 locals=0x38 funcid=0x0 align=0x0
        0x0000 00000 (bubble.go:3)      TEXT    "".bubbleUp(SB), ABIInternal, $56-16
        0x0000 00000 (bubble.go:3)      CMPQ    SP, 16(R14)
        0x0004 00004 (bubble.go:3)      PCDATA  $0, $-2
        0x0004 00004 (bubble.go:3)      JLS     152
        0x000a 00010 (bubble.go:3)      PCDATA  $0, $-1
        0x000a 00010 (bubble.go:3)      SUBQ    $56, SP
        0x000e 00014 (bubble.go:3)      MOVQ    BP, 48(SP)
        0x0013 00019 (bubble.go:3)      LEAQ    48(SP), BP

略去下面这段GC相关代码:

0x0018 00024 (bubble.go:3)      FUNCDATA        $0, gclocals·09cf9819fc716118c209c2d2155a3632(SB)
0x0018 00024 (bubble.go:3)      FUNCDATA        $1, gclocals·69c1753bd5f81501d95132d08af04464(SB)
0x0018 00024 (bubble.go:3)      FUNCDATA        $5, "".bubbleUp.arginfo1(SB)
0x0018 00024 (bubble.go:3)      FUNCDATA        $6, "".bubbleUp.argliveinfo(SB)

重点来分析函数主体代码:

0x0018 00024 (bubble.go:4)      MOVQ    AX, "".x+64(SP)
0x001d 00029 (bubble.go:4)      MOVQ    BX, "".x+72(SP)
0x0022 00034 (bubble.go:4)      MOVQ    24(AX), CX
0x0026 00038 (bubble.go:4)      MOVQ    BX, AX
0x0029 00041 (bubble.go:4)      CALL    CX

参数暂存

函数开始,如上文所述,接口值将占据AXBX寄存器:其中AX为MOVQ AX, "".x+64(SP)MOVQ BX, "".x+72(SP)将接口值复制到栈上(SP:Stack Pointer),以便为后续AXBX寄存器的使用腾出空间。

找到函数调用地址

接下来,准备调用x.Len(),此时,就需要找到要调用的函数,而这个函数的入口地址就存放在24(AX),其原理是这样的:

  • 如前所述,传递给函数bubbleUp的参数x,其值存放在AXBX寄存器。
  • 根据iface的内存布局,AX寄存器存放的正是指向itab的指针
  • 24(AX)意思是AX+24,这里24是十进制,表示指针地址+偏移量24bytes
  • itab中24bytes偏移量正是指向itab中的fun字段,也就是接口中的第一个函数的地址被放置在寄存器CX
  • sort.Interface接口的第一个方法正是Len()方法,所以最后,xLen()方法的地址就存放在CX寄存器中了

调用函数

函数地址找到了,接下来就是调用函数了。因为Len()方法没有参数,所以唯一一个需要传递的参数就是函数的接收者(receiver),注意到在之前的操作中已经把接口的tab字段和data字段将分别存放到了AXBX寄存器,data字段正是存放了实际的接口值,也就是函数的接收者,所以接下来使用MOVQ BX, AX将函数的接收者放在AX寄存器中,然后用CALL CX调用函数。

函数返回值

Len()方法返回一个整数n,存放在AX寄存器中,这里将返回值存放到了栈上,因为接下来AX寄存器将用作其他用途:

0x002b 00043 (bubble.go:4)      MOVQ    AX, "".n+24(SP)

开始循环

接下来的代码就是for循环的汇编实现:

0x0030 00048 (bubble.go:4)      MOVL    $1, CX
0x0035 00053 (bubble.go:5)      JMP     69                  --->\
0x0037 00055 (bubble.go:5)      MOVQ    "".i+32(SP), DX         |
0x003c 00060 (bubble.go:5)      LEAQ    1(DX), CX               |i++
0x0040 00064 (bubble.go:5)      MOVQ    "".n+24(SP), AX         |
0x0045 00069 (bubble.go:5)      CMPQ    AX, CX              <---/
0x0048 00072 (bubble.go:5)      JLE     142
0x004a 00074 (bubble.go:5)      MOVQ    CX, "".i+32(SP)
0x004f 00079 (bubble.go:6)      MOVQ    "".x+64(SP), DX
0x0054 00084 (bubble.go:6)      MOVQ    32(DX), SI
0x0058 00088 (bubble.go:6)      LEAQ    -1(CX), DI
0x005c 00092 (bubble.go:6)      MOVQ    DI, ""..autotmp_4+40(SP)
0x0061 00097 (bubble.go:6)      MOVQ    "".x+72(SP), AX
0x0066 00102 (bubble.go:6)      MOVQ    CX, BX
0x0069 00105 (bubble.go:6)      MOVQ    DI, CX
0x006c 00108 (bubble.go:6)      CALL    SI
0x006e 00110 (bubble.go:6)      TESTB   AL, AL
0x0070 00112 (bubble.go:6)      JEQ     55
0x0072 00114 (bubble.go:7)      MOVQ    "".x+64(SP), DX
0x0077 00119 (bubble.go:7)      MOVQ    40(DX), SI
0x007b 00123 (bubble.go:7)      MOVQ    "".x+72(SP), AX
0x0080 00128 (bubble.go:7)      MOVQ    "".i+32(SP), BX
0x0085 00133 (bubble.go:7)      MOVQ    ""..autotmp_4+40(SP), CX
0x008a 00138 (bubble.go:7)      CALL    SI
0x008c 00140 (bubble.go:7)      JMP     55
0x008e 00142 (bubble.go:10)     PCDATA  $1, $-1
0x008e 00142 (bubble.go:10)     MOVQ    48(SP), BP

i++的实现:首先将CX寄存器赋初始值1,然后跳转到第69行CMPQ AX, CX(注意此时AX中的值为n),也就是比较ni的大小,并且当i>=n时将跳出循环(JLE 142)。第55行至64行就是i++的实现。

调用x.Less()方法:回想一下,itab中的第一个方法Len()方法的偏移量为24bytes,所以第二个方法,Less()方法的偏移量是32bytes。这里首先将存放于栈上的iface值复制到DX寄存器中,然后再将Less()方法的地址放入SI寄存器,到第105行为止,都是在进行调用x.Less()方法的准备工作。执行到第105行时,此时:

  • Less()方法的接收者xAX寄存器中
  • iBX寄存器中
  • i-1在CX寄存器中

Less()方法返回值存放在AL寄存器中,0代表false,1代表true,这两行代码判断Less()方法返回值,若为false,则直接跳到55行,继续下个循环,否则继续执行Swap()方法:

0x0072 00114 (bubble.go:7)      MOVQ    "".x+64(SP), DX
0x0077 00119 (bubble.go:7)      MOVQ    40(DX), SI
0x007b 00123 (bubble.go:7)      MOVQ    "".x+72(SP), AX
0x0080 00128 (bubble.go:7)      MOVQ    "".i+32(SP), BX
0x0085 00133 (bubble.go:7)      MOVQ    ""..autotmp_4+40(SP), CX
0x008a 00138 (bubble.go:7)      CALL    SI

紧跟着就是for循环的回边语句:

0x008c 00140 (bubble.go:15)  JMP  55

最后就是函数的尾言和返回语句:

0x0093 00147 (bubble.go:10)     ADDQ    $56, SP
0x0097 00151 (bubble.go:10)     RET
0x0098 00152 (bubble.go:10)     N