共计 4738 个字符,预计需要花费 12 分钟才能阅读完成。
本文旨在探讨 Go 函数中的一个问题:为什么 Go 函数能反对多参数返回,而 C /C++、java 不行?这其实牵涉到了一个叫做函数调用常规的问题。
调用常规
在程序代码中,函数提供了最小性能单元,程序执行实际上就是函数间互相调用的过程。在调用时,函数调用方和被调用方必须恪守某种约定,它们的了解要统一,该约定就被称为函数调用常规。
函数调用常规往往由编译器来规定,本文次要关怀两个点:
- 函数的参数(入参加出参)是通过栈还是寄存器传递?
- 如果通过栈传递,是从左至右,还是从右至左入栈?
栈
栈是古代计算机程序里最为重要的概念之一,没有栈就没有函数,也没有局部变量。栈保留了一个函数调用所须要的保护信息,这经常被称为堆栈帧 (Stack Frame) 或流动记录 (Activate Record)。堆栈帧个别包含如下几方面内容:
- 函数的返回地址和参数。
- 长期变量: 包含函数的非动态局部变量以及编译器主动生成的其余长期变量。
- 保留的上下文信息: 包含在函数调用前后须要放弃不变的寄存器。
一个堆栈帧能够用 指向栈顶 的栈指针寄存器 SP 与保护以后栈帧的基准地址 的基准指针寄存器 BP 来示意。因而,一个典型的函数流动记录能够示意为如下
在参数及其之后的数据即以后函数的流动记录。BP 固定在图中所示的地位(通过它便于索引参数与变量等),它不会随着函数的执行而变动。而 SP 始终指向栈顶,随着函数的执行,SP 会一直变动。在 BP 之前是该函数的返回地址,在 32 位机器示意为 BP+4,64 位机器示意为 BP+8,再往前就是压入栈中的参数。BP 所间接指向的数据是调用该函数前 BP 的值,这样在函数返回的时候,BP 能够通过读取这个值复原到调用前的值。
汇编代码解析
上面,咱们来比照剖析 C 和 Go 调用常规差别。
- C 调用常规
假如有 main.c 的 C 程序源文件,其中 main 函数调用 add 函数,具体代码如下。
// main.c
int add(int arg1, int arg2, int arg3, int arg4,int arg5, int arg6,int arg7, int arg8) {return arg1 + arg2 + arg3 + arg4 + arg5 + arg6 + arg7 + arg8;}
int main() {int i = add(10, 20, 30, 40, 50, 60, 70, 80);
}
咱们通过 clang 编译器在 x86_64 平台上进行编译。
$ clang -v
Apple clang version 12.0.0 (clang-1200.0.32.29)
Target: x86_64-apple-darwin19.5.0
main.c 编译后失去的汇编代码如下
$ clang -S main.c
...
_main:
...
subq $32, %rsp
movl $10, %edi // 将参数 1 数据置于 edi 寄存器
movl $20, %esi // 将参数 2 数据置于 esi 寄存器
movl $30, %edx // 将参数 3 数据置于 edx 寄存器
movl $40, %ecx // 将参数 4 数据置于 ecx 寄存器
movl $50, %r8d // 将参数 5 数据置于 r8d 寄存器
movl $60, %r9d // 将参数 6 数据置于 r9d 寄存器
movl $70, (%rsp) // 将参数 7 数据置于栈上
movl $80, 8(%rsp) // 将参数 8 数据置于栈上
callq _add // 调用 add 函数
xorl %ecx, %ecx
movl %eax, -4(%rbp)
movl %ecx, %eax // 最终通过 eax 寄存器承载着返回值返回
addq $32, %rsp
popq %rbp
retq
...
_add:
...
movl 24(%rbp), %eax
movl 16(%rbp), %r10d
movl %edi, -4(%rbp) // 将 edi 寄存器上的数据搁置于栈上
movl %esi, -8(%rbp) // 将 esi 寄存器上的数据搁置于栈上
movl %edx, -12(%rbp) // 将 edx 寄存器上的数据搁置于栈上
movl %ecx, -16(%rbp) // 将 ecx 寄存器上的数据搁置于栈上
movl %r8d, -20(%rbp) // 将 r8d 寄存器上的数据搁置于栈上
movl %r9d, -24(%rbp) // 将 edi 寄存器上的数据搁置于栈上
movl -4(%rbp), %ecx // 将栈上的数据 10 搁置于 ecx 寄存器
addl -8(%rbp), %ecx // 理论为:ecx = ecx + 20
addl -12(%rbp), %ecx // ecx = ecx + 30
addl -16(%rbp), %ecx // ecx = ecx + 40
addl -20(%rbp), %ecx // ecx = ecx + 50
addl -24(%rbp), %ecx // ecx = ecx + 60
addl 16(%rbp), %ecx // ecx = ecx + 70
addl 24(%rbp), %ecx // ecx = ecx + 80
movl %eax, -28(%rbp)
movl %ecx, %eax // 最终通过 eax 寄存器承载着返回值返回
popq %rbp
retq
...
因而,在 main 函数调用 add 函数之前,其参数寄存如下图所示
调用 add 函数后的数据寄存如下图所示
因而,对于默认的 C 语言调用常规(cdecl调用常规),咱们能够得出以下论断
- 当函数参数不超过六个时,其参数会依照程序别离应用 edi、esi、edx、ecx、r8d 和 r9d 六个寄存器进行传递;
- 当参数超过六个,那么超过的参数会应用栈传递,函数的参数会以从右到左的程序顺次入栈
C 语言函数的返回值是通过寄存器传递实现的,不过依据返回值的大小,有以下三种状况。
- 小于 4 字节,返回值存入 eax 寄存器,由函数调用方读取 eax 的值
- 返回值 5 到 8 字节,采纳 eax 和 edx 寄存器联结返回
- 大于 8 个字节,首先在栈上额定开拓一部分空间 temp,将 temp 对象的地址做为暗藏参数入栈。函数返回时将数据拷贝给 temp 对象,并将 temp 对象的地址用寄存器 eax 传出。调用方从 eax 指向的 temp 对象拷贝内容。
能够看到,因为采纳了寄存器传递返回值的设计,C 语言的返回值只能有一个,这里答复了 C 为什么不能实现函数多值返回。
- Go 函数调用常规
假如有 main.go 的 Go 程序源文件,和 C 中例子一样,其中 main 函数调用 add 函数,具体代码如下。
package main
func add(arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8 int) int {return arg1 + arg2 + arg3 + arg4 + arg5 + arg6 + arg7 + arg8}
func main() {_ = add(10, 20, 30, 40, 50, 60, 70, 80)
}
应用go tool compile -S -N -l main.go
命令编译失去如下汇编代码
"".main STEXT size=122 args=0x0 locals=0x50
// 80 代表栈帧大小为 80 个字节,0 是入参和出参大小之和
0x0000 00000 (main.go:7) TEXT "".main(SB), ABIInternal, $80-0
...
0x000f 00015 (main.go:7) SUBQ $80, SP
0x0013 00019 (main.go:7) MOVQ BP, 72(SP)
0x0018 00024 (main.go:7) LEAQ 72(SP), BP
...
0x001d 00029 (main.go:8) MOVQ $10, (SP) // 将数据填置栈上
0x0025 00037 (main.go:8) MOVQ $20, 8(SP)
0x002e 00046 (main.go:8) MOVQ $30, 16(SP)
0x0037 00055 (main.go:8) MOVQ $40, 24(SP)
0x0040 00064 (main.go:8) MOVQ $50, 32(SP)
0x0049 00073 (main.go:8) MOVQ $60, 40(SP)
0x0052 00082 (main.go:8) MOVQ $70, 48(SP)
0x005b 00091 (main.go:8) MOVQ $80, 56(SP)
0x0064 00100 (main.go:8) PCDATA $1, $0
0x0064 00100 (main.go:8) CALL "".add(SB) // 调用 add 函数
0x0069 00105 (main.go:9) MOVQ 72(SP), BP
0x006e 00110 (main.go:9) ADDQ $80, SP
0x0072 00114 (main.go:9) RET
...
"".add STEXT nosplit size=55 args=0x48 locals=0x0
// add 栈帧大小为 0 字节,72 是 8 个入参 + 1 个出参 的字节大小之和
0x0000 00000 (main.go:3) TEXT "".add(SB), NOSPLIT|ABIInternal, $0-72
...
0x0000 00000 (main.go:3) MOVQ $0, "".~r8+72(SP) // 初始化返回值,将其置为 0
0x0009 00009 (main.go:4) MOVQ "".arg1+8(SP), AX // 开始将栈上的值搁置在 AX 寄存器上
0x000e 00014 (main.go:4) ADDQ "".arg2+16(SP), AX // AX = AX + 20
0x0013 00019 (main.go:4) ADDQ "".arg3+24(SP), AX
0x0018 00024 (main.go:4) ADDQ "".arg4+32(SP), AX
0x001d 00029 (main.go:4) ADDQ "".arg5+40(SP), AX
0x0022 00034 (main.go:4) ADDQ "".arg6+48(SP), AX
0x0027 00039 (main.go:4) ADDQ "".arg7+56(SP), AX
0x002c 00044 (main.go:4) ADDQ "".arg8+64(SP), AX
0x0031 00049 (main.go:4) MOVQ AX, "".~r8+72(SP) // 将后果 AX 填置到对应栈上地位
0x0036 00054 (main.go:4) RET
...
同样的,咱们将 main 函数调用 add 函数时,其参数寄存可视化进去如下所示
这里咱们能够看到,add 函数的入参压栈程序和 C 一样,都是从右至左,即最初一个参数在凑近栈底方向的 SP+56~SP+64,而第一个参数是在栈顶 SP~SP+8。
调用 add 函数后的数据寄存如下图所示
留神,这里与 C 中调用不同的是,因为通过栈传递参数,所以并不需要将寄存器中保留的参数再拷贝至栈上。在本例中,add 帧间接调用 main 帧栈上的数据进行计算即可。通过将后果累加到 AX 寄存器上,最初再将最终的返回值置回栈中即可,返回值的地位是在最初一个入参之上。
因而咱们晓得,Go 函数的出入参均是通过栈来传递的。所以,如果想返回多值,那么仅须要在栈上多调配一些内存即可。到这里也就答复了文章结尾的问题。
总结
在函数调用常规中,C 语言和 Go 语言选择了不同的实现形式。C 语言同时应用了寄存器与栈传递参数,而 Go 语言除了在函数计算过程中会长期应用例如 AX 这种累加寄存器之外,全副是通过栈实现参数的传递。
任何抉择都会有它的优劣所在,总体来讲,C 语言实现形式更多地是思考性能,Go 语言实现形式更多地是思考复杂度。上面,咱们具体比拟一下两种调用常规。
C 语言形式
CPU 拜访寄存器的效率会显著高于栈;
不同平台的寄存器存在差别,须要为每种架构设定对应的寄存器传递规定;
参数过多时,须要同时应用寄存器与栈传递,减少了实现复杂度,且此时函数调用性能和 Go 语言形式差异不再大;
只能反对一个返回值。
Go 语言形式
遵循 Go 语言的跨平台编译理念:都是通过栈传递,因而不必放心架构不同带来的寄存器差别;
参数较少的状况下,函数调用性能会比 C 语言形式低;
编译器易于保护;
能够反对多返回值。