调用约定 (calling convention) 是调用方 (caller) 与被调用方 (callee) 对于函数调用的参数与返回值的传递方式、传递顺序的约定,只有双方遵守相同的约定,才能保证参数的正确传递,以及保证函数栈的平衡。
Go 1.17 版本之前,采用基于栈的调用约定,即函数的参数与返回值都通过栈来传递,这种方式的优点是实现简单,无需考虑不同架构下cpu寄存器的差异,更容易实现跨平台。然而由于内存的访问时延远高于寄存器,导致 Golang 的函数调用性能要低于 C/C++ 等使用寄存器传递参数的编程语言。
Golang 在 1.17 版本中引入了基于寄存器的函数调用约定,本文将简要介绍该调用约定,并通过具体的实验来验证并加深理解。
Go internal ABI specification
Go internal ABI specification 详细介绍了 Go 1.17 之后的应用程序二进制接口 (Application Binary Interface, ABI)。
首先是各种基础类型的大小与内存对齐约定,例如 int
类型在 64 位架构下的大小为 8 字节,并且需要对齐到 8 字节。栈上的变量如果不对齐,编译器会插入 padding 使之对齐。
Type | 64-bit | 32-bit | ||
---|---|---|---|---|
Size | Align | Size | Align | |
bool, uint8, int8 | 1 | 1 | 1 | 1 |
uint16, int16 | 2 | 2 | 2 | 2 |
uint32, int32 | 4 | 4 | 4 | 4 |
uint64, int64 | 8 | 8 | 8 | 4 |
int, uint | 8 | 8 | 4 | 4 |
float32 | 4 | 4 | 4 | 4 |
float64 | 8 | 8 | 8 | 4 |
complex64 | 8 | 4 | 8 | 4 |
complex128 | 16 | 8 | 16 | 4 |
uintptr, *T, unsafe.Pointer | 8 | 8 | 4 | 4 |
栈帧结构如下:栈帧由高地址向低地址增长,依次是上一个函数的程序计数器 (Program Counter, PC)、上一个函数的基址寄存器 (Base Pointer, BP)、函数的局部变量、调用函数的返回值、调用函数的参数。
+------------------------------+
| return PC |
| RBP on entry |
| ... locals ... |
| ... outgoing arguments ... |
+------------------------------+ ↓ lower addresses
寄存器约定 (amd64):
- 用于整型参数和返回值的通用寄存器:RAX、RBX、RCX、RDI、RSI、R8、R9、R10、R11
- 用于浮点参数和返回值的浮点寄存器:X0~X14
- 其他是用于特殊目的的寄存器:
寄存器 | 名称含义 | 值含义 |
---|---|---|
RSP | 栈顶指针 (Stack pointer) | 固定 |
RBP | 栈帧指针(基址)(Frame pointer) | 固定 |
RDX | 闭包上下文指针 | 临时 |
R12 | 无 | 临时 |
R13 | 无 | 临时 |
R14 | 当前 go routine | 临时 |
R15 | GOT reference temporary | 动态链接时固定 |
X15 | 零值寄存器 | 固定为0 |
变量在栈上的排列顺序:从低地址到高地址依次为通过栈传递的 receiver 参数、通过栈传递的若干个参数、通过栈传递的函数返回值、寄存器参数溢出空间。其中所有参数都会根据其类型处理内存对齐,不对齐的情况下编译器计算对齐所需的 padding 空间使之对齐。
+------------------------------+
| . . . |
| 2nd reg argument spill space |
| 1st reg argument spill space |
| <pointer-sized alignment> |
| . . . |
| 2nd stack-assigned result |
| 1st stack-assigned result |
| <pointer-sized alignment> |
| . . . |
| 2nd stack-assigned argument |
| 1st stack-assigned argument |
| stack-assigned receiver |
+------------------------------+ ↓ lower addresses
函数的调用者负责保存寄存器,被调用者无需保存寄存器,因此可以会覆盖任何没有固定含义的寄存器,包括参数寄存器。
代码示例
示例代码如下,保存为 main.go
。
package main
func main() {
r0, r1, r2, r3, r4, r5, r6, r7, r8, r9, r10, r11, r12 := test(71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83)
ret := r0 + r1 + r2 + r3 + r4 + r5 + r6 + r7 + r8 + r9 + r10 + r11 + r12
println(ret)
}
func test(a0 int, a1 int, a2 int, a3 int, a4 int, a5 int, a6 int, a7 int, a8 int, a9 int, a10 int, a11 int, a12 int) (int, int, int, int, int, int, int, int, int, int, int, int, int) {
var arr [13]int
arr[0] = a0 + 31
arr[1] = a1 + 32
arr[2] = a1 + 33
arr[3] = a1 + 34
arr[4] = a1 + 35
arr[5] = a1 + 36
arr[6] = a0 + 37
arr[7] = a1 + 38
arr[8] = a1 + 39
arr[9] = a1 + 40
arr[10] = a1 + 41
arr[11] = a1 + 42
arr[12] = a1 + 43
arr[2] = arr[0] + arr[1]
arr[4] = arr[2] + arr[3]
arr[6] = arr[4] + arr[5]
arr[8] = arr[6] + arr[7]
arr[10] = arr[8] + arr[9]
arr[12] = arr[10] + arr[11]
arr[1] = arr[1] * 3
arr[1] = arr[1] + 11
return arr[0], arr[1], arr[2], arr[3], arr[4], arr[5], arr[6], arr[7], arr[8], arr[9], arr[10], arr[11], arr[12]
}
编译并输出到 main.s
文件中。go 版本为 go1.18.7 linux/amd64
。
go tool compile -S main.go > main.s
在 go 代码中,main 函数首先调用 test 函数,传递了 13 个 int 参数,分别为 71~83 之间依次递增的数字。
在汇编代码中,main 函数通过 SUBQ $152, SP
开辟了 152 字节的栈空间,然后将函数调用方的 BP
保存到栈底即 144(SP)
处,接着将 144(SP)
处设置为新的 BP
。FUNCDATA
指令和 PCDATA
指令与垃圾回收有关,这里不讨论。
main 函数的主要逻辑中,首先将 80 移动到栈顶,然后将 81 移动到栈顶的下一个 slot,将 82 和 83 移动到接下来的 slot 中,接着依次将 71~79 移动到 AX
~R11
寄存器中,然后调用 test 函数。在 test 函数返回之后,从 AX
~R11
、32(SP)~56(SP)
中取出返回值,累加到 DX
寄存器上,随机将 DX
中的值写入 136(SP)
也就是 ret
变量,后面的一段代码是内联的 println
代码,printlock
与 printunlock
表示加锁与解锁,加锁后将 136(SP)
写入 AX
作为参数,调用 runtime.printnl
函数输出,忽略返回值。最后将 144(SP)
即调用方 BP
写回 BP
,通过 ADDQ $152, SP
释放 main 函数的栈空间,然后执行 RET
指令恢复 PC
"".main STEXT size=235 args=0x0 locals=0x98 funcid=0x0 align=0x0
0x0000 00000 (main.go:3) TEXT "".main(SB), ABIInternal, $152-0
0x0000 00000 (main.go:3) LEAQ -24(SP), R12
0x0005 00005 (main.go:3) CMPQ R12, 16(R14)
0x0009 00009 (main.go:3) PCDATA $0, $-2
0x0009 00009 (main.go:3) JLS 225
0x000f 00015 (main.go:3) PCDATA $0, $-1
0x000f 00015 (main.go:3) SUBQ $152, SP
0x0016 00022 (main.go:3) MOVQ BP, 144(SP)
0x001e 00030 (main.go:3) LEAQ 144(SP), BP
0x0026 00038 (main.go:3) FUNCDATA $0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
0x0026 00038 (main.go:3) FUNCDATA $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
0x0026 00038 (main.go:4) MOVQ $80, (SP)
0x002e 00046 (main.go:4) MOVQ $81, 8(SP)
0x0037 00055 (main.go:4) MOVQ $82, 16(SP)
0x0040 00064 (main.go:4) MOVQ $83, 24(SP)
0x0049 00073 (main.go:4) MOVL $71, AX
0x004e 00078 (main.go:4) MOVL $72, BX
0x0053 00083 (main.go:4) MOVL $73, CX
0x0058 00088 (main.go:4) MOVL $74, DI
0x005d 00093 (main.go:4) MOVL $75, SI
0x0062 00098 (main.go:4) MOVL $76, R8
0x0068 00104 (main.go:4) MOVL $77, R9
0x006e 00110 (main.go:4) MOVL $78, R10
0x0074 00116 (main.go:4) MOVL $79, R11
0x007a 00122 (main.go:4) PCDATA $1, $0
0x007a 00122 (main.go:4) CALL "".test(SB)
0x007f 00127 (main.go:5) LEAQ (BX)(AX*1), DX
0x0083 00131 (main.go:5) ADDQ CX, DX
0x0086 00134 (main.go:5) ADDQ DI, DX
0x0089 00137 (main.go:5) ADDQ SI, DX
0x008c 00140 (main.go:5) ADDQ R8, DX
0x008f 00143 (main.go:5) ADDQ R9, DX
0x0092 00146 (main.go:5) ADDQ R10, DX
0x0095 00149 (main.go:5) ADDQ R11, DX
0x0098 00152 (main.go:5) ADDQ 32(SP), DX
0x009d 00157 (main.go:5) ADDQ 40(SP), DX
0x00a2 00162 (main.go:5) ADDQ 48(SP), DX
0x00a7 00167 (main.go:5) ADDQ 56(SP), DX
0x00ac 00172 (main.go:5) MOVQ DX, "".ret+136(SP)
0x00b4 00180 (main.go:6) CALL runtime.printlock(SB)
0x00b9 00185 (main.go:6) MOVQ "".ret+136(SP), AX
0x00c1 00193 (main.go:6) CALL runtime.printint(SB)
0x00c6 00198 (main.go:6) CALL runtime.printnl(SB)
0x00cb 00203 (main.go:6) CALL runtime.printunlock(SB)
0x00d0 00208 (main.go:7) MOVQ 144(SP), BP
0x00d8 00216 (main.go:7) ADDQ $152, SP
0x00df 00223 (main.go:7) NOP
0x00e0 00224 (main.go:7) RET
0x00e1 00225 (main.go:7) NOP
0x00e1 00225 (main.go:3) PCDATA $1, $-1
0x00e1 00225 (main.go:3) PCDATA $0, $-2
0x00e1 00225 (main.go:3) CALL runtime.morestack_noctxt(SB)
0x00e6 00230 (main.go:3) PCDATA $0, $-1
0x00e6 00230 (main.go:3) JMP 0
test 函数中申请了一个长度为 13 的数组,首先将参数依次加上一个不同的值,复制给数组中对应顺序的元素。
test 函数的汇编代码如下,首先通过 SUBQ $112, SP
开辟了 112 字节的栈空间,然后将调用方也就是 main
函数的 BP
放入栈底 104(SP)
的位置,然后将 104(SP)
的地址设置为新的 BP
。
接着开始计算 ,LEAQ 31(AX), R12
可以理解为 R12 = AX + 31
,随后 MOVQ R12, "".arr(SP)
将 R12
保存到 (SP)
处,即 arr[0]
,故这两条汇编指令实现了 arr[0] = a0 + 31
。接下来的汇编指令以此类推,使用 R12
或 DX
作为数据寄存器,存放中间结果,接着将结果保存到栈中。有一个例外是 DI
,由于数据清零指令 DUFFZERO
会修改 DI
,因此先将 DI
保存到 DX
,并用 DX
完成对应的计算。从 arr[9] = a9 + 40
开始,变成先 MOVQ "".a9+120(SP), DX
从栈中取出参数存到 DX
,然后 ADDQ $40, DX
对 DX
做加法,最后 MOVQ DX, "".arr+72(SP)
将结果写入数组。
接下来是将数组中的两个元素相加并赋值给另一个元素,例如 arr[2] = arr[0] + arr[1]
。汇编代码中,首先 MOVQ "".arr(SP), DX
将 arr[0]
存入 DX
,然后 ADDQ "".arr+8(SP), DX
将 8(SP)
添加到 DX
上,最后 MOVQ DX, "".arr+16(SP)
将 DX
写入 16(SP)
。
对于 arr[1] = arr[1] * 3
,首先 MOVQ "".arr+8(SP), R13
将 arr[1]
存入 R13
,接着 LEAQ (R13)(R13*2), R15
相当于 R15=R13+R13*2
,从而实现 3 倍的乘法,然后 MOVQ R15, "".arr+8(SP)
将 R15
写回栈中。
计算完成后依次将 a[0]
~ a[12]
写入 AX
`R15`、`152(SP)`176(SP)
。最后 MOVQ 104(SP), BP
恢复 BP
,ADDQ $112, SP
恢复 SP
,RET
恢复 PC
。
注1:RET
指令与 CALL
指令配对使用,每一个函数都应该以 RET
结尾。CALL
指令首先将 CALL
指令的下一条指令入栈(等价于PUSH
,会修改 SP
),然后修改 PC
跳转到被调函数处执行,被调函数返回时调用 RET
指令,从栈中弹出 PC
(等价于 POP
,会修改SP
)从而执行 CALL
指令的下一条指令。
注2:MOVUPS X15, "".arr(SP)
指令表示 arr
位于栈顶,X15
是零值寄存器,MOVUPS
从 X15
中取出 0,赋值到 arr 的前 16 个字节中。DUFFZERO
用于清空内存地址,从而实现栈上变量的 0 值初始化。参数没看懂。
"".test STEXT nosplit size=395 args=0x88 locals=0x70 funcid=0x0 align=0x0
0x0000 00000 (main.go:9) TEXT "".test(SB), NOSPLIT|ABIInternal, $112-136
0x0000 00000 (main.go:9) SUBQ $112, SP
0x0004 00004 (main.go:9) MOVQ BP, 104(SP)
0x0009 00009 (main.go:9) LEAQ 104(SP), BP
0x000e 00014 (main.go:9) FUNCDATA $0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
0x000e 00014 (main.go:9) FUNCDATA $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
0x000e 00014 (main.go:9) FUNCDATA $5, "".test.arginfo1(SB)
0x000e 00014 (main.go:9) FUNCDATA $6, "".test.argliveinfo(SB)
0x000e 00014 (main.go:9) PCDATA $3, $1
0x000e 00014 (main.go:10) MOVUPS X15, "".arr(SP)
0x0013 00019 (main.go:9) MOVQ DI, DX
0x0016 00022 (main.go:10) LEAQ "".arr+8(SP), DI
0x001b 00027 (main.go:10) PCDATA $0, $-2
0x001b 00027 (main.go:10) LEAQ -32(DI), DI
0x001f 00031 (main.go:10) NOP
0x0020 00032 (main.go:10) DUFFZERO $331
0x0033 00051 (main.go:11) PCDATA $0, $-1
0x0033 00051 (main.go:11) LEAQ 31(AX), R12
0x0037 00055 (main.go:11) MOVQ R12, "".arr(SP)
0x003b 00059 (main.go:12) LEAQ 32(BX), R12
0x003f 00063 (main.go:12) MOVQ R12, "".arr+8(SP)
0x0044 00068 (main.go:13) LEAQ 33(CX), R12
0x0048 00072 (main.go:13) MOVQ R12, "".arr+16(SP)
0x004d 00077 (main.go:14) ADDQ $34, DX
0x0051 00081 (main.go:14) MOVQ DX, "".arr+24(SP)
0x0056 00086 (main.go:15) LEAQ 35(SI), DX
0x005a 00090 (main.go:15) MOVQ DX, "".arr+32(SP)
0x005f 00095 (main.go:16) LEAQ 36(R8), DX
0x0063 00099 (main.go:16) MOVQ DX, "".arr+40(SP)
0x0068 00104 (main.go:17) LEAQ 37(R9), DX
0x006c 00108 (main.go:17) MOVQ DX, "".arr+48(SP)
0x0071 00113 (main.go:18) LEAQ 38(R10), DX
0x0075 00117 (main.go:18) MOVQ DX, "".arr+56(SP)
0x007a 00122 (main.go:19) LEAQ 39(R11), DX
0x007e 00126 (main.go:19) MOVQ DX, "".arr+64(SP)
0x0083 00131 (main.go:20) MOVQ "".a9+120(SP), DX
0x0088 00136 (main.go:20) ADDQ $40, DX
0x008c 00140 (main.go:20) MOVQ DX, "".arr+72(SP)
0x0091 00145 (main.go:21) MOVQ "".a10+128(SP), DX
0x0099 00153 (main.go:21) ADDQ $41, DX
0x009d 00157 (main.go:21) MOVQ DX, "".arr+80(SP)
0x00a2 00162 (main.go:22) MOVQ "".a11+136(SP), DX
0x00aa 00170 (main.go:22) ADDQ $42, DX
0x00ae 00174 (main.go:22) MOVQ DX, "".arr+88(SP)
0x00b3 00179 (main.go:23) MOVQ "".a12+144(SP), DX
0x00bb 00187 (main.go:23) ADDQ $43, DX
0x00bf 00191 (main.go:23) MOVQ DX, "".arr+96(SP)
0x00c4 00196 (main.go:25) MOVQ "".arr(SP), DX
0x00c8 00200 (main.go:25) ADDQ "".arr+8(SP), DX
0x00cd 00205 (main.go:25) MOVQ DX, "".arr+16(SP)
0x00d2 00210 (main.go:26) MOVQ "".arr+24(SP), R12
0x00d7 00215 (main.go:26) ADDQ R12, DX
0x00da 00218 (main.go:26) MOVQ DX, "".arr+32(SP)
0x00df 00223 (main.go:27) MOVQ "".arr+40(SP), R12
0x00e4 00228 (main.go:27) ADDQ R12, DX
0x00e7 00231 (main.go:27) MOVQ DX, "".arr+48(SP)
0x00ec 00236 (main.go:28) MOVQ "".arr+56(SP), R12
0x00f1 00241 (main.go:28) ADDQ R12, DX
0x00f4 00244 (main.go:28) MOVQ DX, "".arr+64(SP)
0x00f9 00249 (main.go:29) MOVQ "".arr+72(SP), R12
0x00fe 00254 (main.go:29) ADDQ R12, DX
0x0101 00257 (main.go:29) MOVQ DX, "".arr+80(SP)
0x0106 00262 (main.go:30) MOVQ "".arr+88(SP), R12
0x010b 00267 (main.go:30) ADDQ DX, R12
0x010e 00270 (main.go:30) MOVQ R12, "".arr+96(SP)
0x0113 00275 (main.go:32) MOVQ "".arr+8(SP), R13
0x0118 00280 (main.go:32) LEAQ (R13)(R13*2), R15
0x011d 00285 (main.go:32) MOVQ R15, "".arr+8(SP)
0x0122 00290 (main.go:33) LEAQ (R13)(R13*2), BX
0x0127 00295 (main.go:33) LEAQ 11(BX), BX
0x012b 00299 (main.go:33) MOVQ BX, "".arr+8(SP)
0x0130 00304 (main.go:35) MOVQ "".arr(SP), AX
0x0134 00308 (main.go:35) MOVQ "".arr+16(SP), CX
0x0139 00313 (main.go:35) MOVQ "".arr+24(SP), DI
0x013e 00318 (main.go:35) MOVQ "".arr+32(SP), SI
0x0143 00323 (main.go:35) MOVQ "".arr+40(SP), R8
0x0148 00328 (main.go:35) MOVQ "".arr+48(SP), R9
0x014d 00333 (main.go:35) MOVQ "".arr+56(SP), R10
0x0152 00338 (main.go:35) MOVQ "".arr+64(SP), R11
0x0157 00343 (main.go:35) MOVQ "".arr+72(SP), R13
0x015c 00348 (main.go:35) MOVQ "".arr+88(SP), R15
0x0161 00353 (main.go:35) MOVQ R13, "".~r9+152(SP)
0x0169 00361 (main.go:35) MOVQ DX, "".~r10+160(SP)
0x0171 00369 (main.go:35) MOVQ R15, "".~r11+168(SP)
0x0179 00377 (main.go:35) MOVQ R12, "".~r12+176(SP)
0x0181 00385 (main.go:35) MOVQ 104(SP), BP
0x0186 00390 (main.go:35) ADDQ $112, SP
0x018a 00394 (main.go:35) RET
栈帧图示
上述代码中两个函数对应的栈帧可以由下图表示。
左边为 main 函数的栈帧,从高地址到低地址依次为:
- main 函数的调用者的
PC
:这是由CALL
指令自动压入栈的,并因此导致调用者的SP
增加了 8 字节。 - main 函数的调用者的
BP
:main 函数在申请栈空间后,将上一个函数的BP
放到栈底,并且调整BP
指向该位置。 - main 函数的局部变量
ret
:占 8 个字节的int
类型。 - 寄存器参数的溢出区域:如果被调函数中参数非常多,寄存器不够用了,寄存器参数就存到这些位置。
- 栈上返回值:存放在栈上的 test 函数的返回值。
- 栈上入参:存放在栈上的 test 函数的入参。
右边为 test 函数的栈帧,从高地址到低地址依次为:
- main 函数的
PC
:这是由CALL
指令自动压入栈的,并因此导致SP
增加了 8 字节。 - main 函数的
BP
:test 函数在申请栈空间后,将 main 函数的BP
放到栈底,并且调整BP
指向该位置。 - test 函数的局部变量
arr
:一共 13 个int
类型的数组。
由于没有调用其他函数,因此 test 函数的栈帧中没有参数、返回值、寄存器溢出区。
值得注意的是,这两个函数的栈帧在内存中是连续的,即途中虚线连接的部分,在内存中是完全相同的区域,只是由于两个函数的 SP
值不同,相对的偏移量也不同。
函数的参数与返回值存放在调用方的栈帧中,栈上变量的访问通常不是使用 PUSH/POP
而是使用 SP
加上偏移量访问,类似于数组。
栈的平衡由被调方负责,编译器计算函数所需的栈空间,函数首先将 SP
减去所需栈空间用于调整栈顶,实现“批量入栈”的效果,随后将调用方的 BP
保存到栈底。函数返回之前先从栈中恢复 BP
,然后将 SP
加上最初减去的值,实现“批量出栈”的效果。
函数的调用与返回过程中的跳转由 CALL/RET
指令负责。CALL
指令首先将 CALL
指令的下一条指令的地址通过 PUSH
入栈,然后 JUMP
到被调函数的地址(修改 PC
)运行。被调函数返回之前最后一条指令是 RET
,它将栈中存放的指令地址 POP
到 PC
寄存器中,跳转到 CALL
指令的下一条指令开始执行。
小结
Golang 函数调用过程中同时使用寄存器与栈进行参数传递,优先使用寄存器,寄存器不够用了才使用栈。函数的第 1~9 个参数依次使用 AX、BX、CX、DI、SI、R8、R9、R10、R11 寄存器。从第 10 个参数开始,使用栈传递,并且是从栈顶向栈低依次排列。执行顺序方面,入栈操作先执行,然后再对寄存器赋值。由于寄存器溢出区域的存在,寄存器传参并不会减小栈内存占用。
使用寄存器传递参数指令数量比使用栈传递参数更少。在上面的例子中,使用寄存器传递的参数在计算时可以直接计算,然后存到栈上,而使用栈传递的参数则需要先从栈中加载到寄存器上,然后计算,最后存到栈上。更重要的是,寄存器的读写速度远高于 CPU Cache 与内存,而栈则是存放在内存中。一组具有代表性的 Go 包和程序的基准显示性能提高了约 5%,二进制文件大小通常减少了约 2%。
网友评论