在《动态分派与接口表》 一文中提到的动态分派其实可以理解为方法动态分派,无法在编译时确定调用具体的方法地址,就需要在运行时中根据动态地确定应该调用哪个方法。但问题是在运行时确定具体调用方法的时机是什么时候?是方法调用的时候动态确定呢,还是在将具体类型赋值给接口的时候呢?
现在让我们从一个 main.go 文件开始:(注意以下讨论全基于 go1.22 windows/amd64)
package main
type Stringer interface {
String() string
}
type Binary uint64
func (i Binary) String() string {
return "strconv.FormatUint(i.Get(), 2)"
}
func Println(s2 Stringer) {
if s2 == nil {
return
}
println(s2.String())
}
func main() {
var s Stringer
s = Binary(200)
Println(s)
}
在 main.go 文件目录下打开终端,执行命令go tool compile -N -l main.go。然后我们就会获得由编译器编译后的目标文件 main.o 。我们也可以执行命令go tool compile -S -N -l main.go将目标文件内容直接打印在终端上。
我们先看main函数编译后的内容:
main.main STEXT size=62 args=0x0 locals=0x28 funcid=0x0 align=0x0
0x0000 00000 TEXT main.main(SB), ABIInternal, $40-0
0x0000 00000 CMPQ SP, 16(R14)
0x0004 00004 PCDATA $0, $-2
0x0004 00004 JLS 55
0x0006 00006 PCDATA $0, $-1
0x0006 00006 PUSHQ BP
0x0007 00007 MOVQ SP, BP
0x000a 00010 SUBQ $32, SP
0x000e 00014 FUNCDATA $0, gclocals·g2BeySu+wFnoycgXfElmcg==(SB)
0x000e 00014 FUNCDATA $1, gclocals·EaPwxsZ75yY1hHMVZLmk6g==(SB)
0x000e 00014 MOVUPS X15, main.s+16(SP)
0x0014 00020 LEAQ go:itab.<unlinkable>.Binary,<unlinkable>.Stringer(SB), AX
0x001b 00027 MOVQ AX, main.s+16(SP)
0x0020 00032 LEAQ main..stmp_0(SB), BX
0x0027 00039 MOVQ BX, main.s+24(SP)
0x002c 00044 PCDATA $1, $0
0x002c 00044 CALL main.Println(SB)
0x0031 00049 ADDQ $32, SP
0x0035 00053 POPQ BP
0x0036 00054 RET
0x0037 00055 NOP
0x0037 00055 PCDATA $1, $-1
0x0037 00055 PCDATA $0, $-2
0x0037 00055 CALL runtime.morestack_noctxt(SB)
0x003c 00060 PCDATA $0, $-1
0x003c 00060 JMP 0
0x0000 49 3b 66 10 76 31 55 48 89 e5 48 83 ec 20 44 0f I;f.v1UH..H.. D.
0x0010 11 7c 24 10 48 8d 05 00 00 00 00 48 89 44 24 10 .|$.H......H.D$.
0x0020 48 8d 1d 00 00 00 00 48 89 5c 24 18 e8 00 00 00 H......H.$.....
0x0030 00 48 83 c4 20 5d c3 e8 00 00 00 00 eb c2 .H.. ]........
rel 2+0 t=R_USEIFACE type:main.Binary+0
rel 23+4 t=R_PCREL go:itab.<unlinkable>.Binary,<unlinkable>.Stringer+0
rel 35+4 t=R_PCREL main..stmp_0+0
rel 45+4 t=R_CALL main.Println+0
rel 56+4 t=R_CALL runtime.morestack_noctxt+0
这段汇编我们主要看三个符号:go:itab.<unlinkable>.Binary,<unlinkable>.Stringer、main..stmp_0以及main.Println,其分别所代表的是组合对[Stringer, Binary]的接口表itab、类型数据的值200和函数Println。这三个符号可以在目标文件中找到定义,这里只先给出前面两个的内容:
go:itab.<unlinkable>.Binary,<unlinkable>.Stringer SRODATA dupok size=32
0x0000 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
0x0010 85 9a 09 9b 00 00 00 00 00 00 00 00 00 00 00 00 ................
rel 0+8 t=R_ADDR type:<unlinkable>.Stringer+0
rel 8+8 t=R_ADDR type:<unlinkable>.Binary+0
rel 24+8 t=RelocType(-32767) <unlinkable>.(*Binary).String+0
main..stmp_0 SRODATA static size=8
0x0000 c8 00 00 00 00 00 00 00 ........
从符号go:itab.<unlinkable>.Binary,<unlinkable>.Stringer的定义中我们可以知道,该符号大小 32 字节,有三个需要rel重定位的信息。
rel在目标文件中表示需要重定位的信息,这些信息会告诉链接器将需要链接的信息填充在何处。至于为什么需要重定位。这是因为编译器在编译期间并不会知道每个符号中所需信息的具体地址,对于那些不知道的地址的符号信息就需要重定位,让链接器来填充[1]。
比如go:itab.<unlinkable>.Binary,<unlinkable>.Stringer中相对符号起始地址偏移为 0-8 位置,这个位置实际对应itab中的第一个字段interfacetype的位置,用于存储接口信息。但此时我们可以看见 0-8 位置所有十六进制数据都是 0,也就是目标文件此时不知道这个itab结构的接口信息的地址,就需要链接器后续补上。rel 0+8 t=R_ADDR type:<unlinkable>.Stringer+0就是编译器为此而提供的重定位信息。
等到链接器执行完后,目标文件中这些缺乏的地址信息都会被补上。其实这也说明在运行时中,Go 是知道这些符号信息的,可以直接访问。
现在让我们回到main的汇编代码中去。可知,main函数所需栈空间为 40 字节,且在调用Println函数之前分别将go:itab.<unlinkable>.Binary,<unlinkable>.Stringer的地址存入16(SP)(SP 为栈顶指针,此处意为偏离栈顶 16 字节的位置),将main..stmp_0的地址存入24(SP)(main..stmp_0保存着数值200,从符号的定义上也可以看出来,0xc8 == 0d200)。此时main函数栈如下:
main函数的这部分操作其实会让我们联想到非空接口的底层结构iface。还记得iface的结构吗?我们再来看一遍。
type iface struct {
tab *itab
data unsafe.Pointer
}
iface由两个指针组成,第一个指针指向接口表itab,第二个指针指向具体类型的值。而这与我们刚才探讨的main函数的内容几乎吻合,itab的地址被存入16(SP)(第一个指针),符号main..stmp_0的地址(指向数值200)被存入24(SP)(第二个指针)。也就是说在 16(SP) - 32(SP) 之间存储的其实是一个iface(请留意此时 AX、BX 寄存器中存储的什么,不久后会用到)。
题外话:空接口的结构 eface 的构建过程与此处 iface 构建过程的几乎一致。
继续往下讨论函数Println的调用。先看符号main.Println的定义:
main.Println STEXT size=121 args=0x10 locals=0x28 funcid=0x0 align=0x0
0x0000 00000 TEXT main.Println(SB), ABIInternal, $40-16
0x0000 00000 CMPQ SP, 16(R14)
0x0004 00004 PCDATA $0, $-2
0x0004 00004 JLS 94
0x0006 00006 PCDATA $0, $-1
0x0006 00006 PUSHQ BP
0x0007 00007 MOVQ SP, BP
0x000a 00010 SUBQ $32, SP
0x000e 00014 FUNCDATA $0, gclocals·xHaoWvF9dWwWDyl5o/zypw==(SB)
0x000e 00014 FUNCDATA $1, gclocals·2LBwIrSqEkQdj2i3Dyd/1A==(SB)
0x000e 00014 FUNCDATA $5, main.Println.arginfo1(SB)
0x000e 00014 MOVQ AX, main.s+48(SP)
0x0013 00019 MOVQ BX, main.s+56(SP)
0x0018 00024 TESTQ AX, AX
0x001b 00027 JNE 31
0x001d 00029 JMP 88
0x001f 00031 TESTB AL, (AX)
0x0021 00033 MOVQ 24(AX), CX
0x0025 00037 MOVQ BX, AX
0x0028 00040 PCDATA $1, $1
0x0028 00040 CALL CX
0x002a 00042 MOVQ AX, main..autotmp_1+16(SP)
0x002f 00047 MOVQ BX, main..autotmp_1+24(SP)
0x0034 00052 PCDATA $1, $2
0x0034 00052 CALL runtime.printlock(SB)
0x0039 00057 MOVQ main..autotmp_1+16(SP), AX
0x003e 00062 MOVQ main..autotmp_1+24(SP), BX
0x0043 00067 PCDATA $1, $1
0x0043 00067 CALL runtime.printstring(SB)
0x0048 00072 CALL runtime.printnl(SB)
0x004d 00077 CALL runtime.printunlock(SB)
0x0052 00082 ADDQ $32, SP
0x0056 00086 POPQ BP
0x0057 00087 RET
// 以下省略部分汇编代码、数据部分和重定位信息
...
在正式开始讨论此段汇编的内容之前,我们先给出Println调用过程中函数栈布局情况,如下:
此处注意,main函数和Println所需栈空间均为 40 字节,但上图所示的栈空间占用了 40+8+40 = 88 字节。其中多出的 8 字节用于存储返回地址。返回地址都知道吧,由 CALL 指令把调用位置处的下一条指令地址压栈,以便被调用函数执行完之后可以回到原来的位置继续执行,这个地址就是返回地址。
回到正题,还记得前面让我们留意的 AX、BX 寄存器吗?在main函数中调用Println之前,AX 中存储着itab的地址,BX 存储着符号main..stmp_0地址。调用Println后,Println会将 AX 中的itab地址存入 48(SP)(即调用Println之前函数栈 0(SP) 的位置),BX 中的main..stmp_0地址存入 56(SP)。
题外话:讨论到这里我们可以发现函数栈中存储了两个 iface 结构,而这得赖我们举例的例子,前后两个分别对应main函数的局部变量的 s 和Println函数的参数 s。这部分推荐去看看 Go 的函数调用约定 和 从栈上理解 Go语言函数调用。
接着看这一段指令MOVQ 24(AX), CX。AX 中存储的是itab的地址,而 24(AX) 存储的是什么呢?让我先回过头去看看itab的结构。
// 以下均为 64 位平台下的字节占用情况
type itab struct {
inter *interfacetype // 占用 8 字节。
_type *_type // 占用 8 字节。
hash uint32 // 占用 4 字节。
_ [4]byte // 占用 4 字节。使下一个字段对齐到 8 字节边界
fun [1]uintptr // 占用 8 字节。
}
根据这个结构我们可知:inter指针占用前 8 个字节(相对结构起始地址偏移 0-7);_type指针占用接下来的 8 个字节(偏移 8-15);hash占用 4 个字节(偏移 16-19);填充的 4 个字节用于对齐(偏移 20-23);最后是fun,一个指针数组,数组的第一个元素位于偏移 24 字节处(偏移 24-31)。
所以指令MOVQ 24(AX), CX实际是在把接口表itab中存储的具体方法地址加载到 CX 寄存器中,为接下来的调用做准备。而这个方法是谁都应该知道了吧。若不清楚可以回去看目标文件中符号go:itab.<unlinkable>.Binary,<unlinkable>.Stringer的定义,其重定位信息有所描述rel 24+8 t=RelocType(-32767) <unlinkable>.(*Binary).String+0,它会告诉链接器从符号的起始地址偏移 24 字节处(fun[0]的位置)存储的是(*Binary).String方法的地址,让链接器在此处填充对应的信息[1]。
再后面应该就是符号main.(*Binary).String的内容了,(*Binary).String返回值是一个string类型,在它调用结束后会分别通过 AX、BX 寄存器传递字符串的地址和字符串的长度。但这些对于本节都不重要,所以不需要再继续往下讨论了。
在以上的讨论中,我们能够了解到,调用接口方法时,至少在本节函数Println调用Stringer变量 s 的方法时,运行时直接就通过itab获取方法String并调用了。这证明在此之前itab就已经存在,动态分派并不是在方法调用时才进行的,也就只能是在将Binary(200)赋值给接口变量 s 时进行的。