> 技术文档 > Golang语言基础—函数调用

Golang语言基础—函数调用


1. C语言的函数调用惯例

所谓“调用惯例(calling convention)”是调用方和被调用方对于函数调用的一个明确的约定,包括:函数参数与返回值的传递方式、传递顺序。只有双方都遵守同样的约定,函数才能被正确地调用和执行。如果不遵守这个约定,函数将无法正确执行。
C语言中,一般使用gcc将C语言编译成汇编代码是分析函数调用的最常见方式,比如以下的代码:

int my_function(int arg1, int arg2) { return arg1 + arg2;}int main() { int i = my_function(1, 2);}

通过gcc -S main.c指令生成main.s:

 .file \"main.c\" .text .globl my_function .type my_function, @functionmy_function:.LFB0: .cfi_startproc pushq %rbp .cfi_def_cfa_offset 16 .cfi_offset 6, -16 movq %rsp, %rbp .cfi_def_cfa_register 6 movl %edi, -4(%rbp) // 取出第一个参数放到栈上 movl %esi, -8(%rbp) // 取出第二个参数放到栈上 movl -4(%rbp), %edx // 设置edx = edi = 1 movl -8(%rbp), %eax // 设置eax = esi = 2 addl %edx, %eax // 返回值放在eax,eax = eax + edx = 3 popq %rbp .cfi_def_cfa 7, 8 ret .cfi_endproc.LFE0: .size my_function, .-my_function .globl main .type main, @functionmain:.LFB1: .cfi_startproc pushq %rbp .cfi_def_cfa_offset 16 .cfi_offset 6, -16 movq %rsp, %rbp .cfi_def_cfa_register 6 subq $16, %rsp movl $2, %esi // 设置第二个参数 movl $1, %edi // 设置第一个参数 call my_function movl %eax, -4(%rbp) movl $0, %eax leave .cfi_def_cfa 7, 8 ret .cfi_endproc.LFE1: .size main, .-main .ident \"GCC: (Ubuntu 7.5.0-3ubuntu1~18.04) 7.5.0\" .section .note.GNU-stack,\"\",@progbits

可以看到:

在调用my_function函数前,main函数将两个参数分别存到edi和esi两个寄存器中;
在调用时,最后通过edx和eax接收到入参,并计算值存入eax寄存器(C语言的返回值都是存储在eax寄存器的),然后返回;

如果参数过多会怎么样呢?我们试着将入参拓展到8个:

int my_function(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 = my_function(1, 2, 3, 4, 5, 6, 7, 8);

然后查看汇编代码,可以发现前6个参数放到寄存器中,但是后面的参数会通过栈传递。

main:.LFB1: .cfi_startproc pushq %rbp .cfi_def_cfa_offset 16 .cfi_offset 6, -16 movq %rsp, %rbp .cfi_def_cfa_register 6 subq $16, %rsp pushq $8 pushq $7 movl $6, %r9d movl $5, %r8d movl $4, %ecx movl $3, %edx movl $2, %esi movl $1, %edi call my_function addq $16, %rsp movl %eax, -4(%rbp) movl $0, %eax leave .cfi_def_cfa 7, 8 ret .cfi_endproc

可以总结,在x86_64的机器上使用C语言调用函数时:

  • 6个及以下的参数会按照顺序分别使用edi、esi、edx、ecx、r8d和r9d这六个寄存器传递;
  • 6个以上的参数传递会使用寄存器+栈,前六个参数会按照以上顺序使用寄存器,后面的会按照从右到左的顺序入栈。

2. Go语言的函数调用惯例

在Go v1.17版本之前,Go语言的函数调用是通过栈来传递参数的。根据存储山结构,CPU从寄存器上取值要比从内存取快几百倍,即使局部性高,L1 Cache的缓存命中率高,那也会比寄存器中取值速度慢4倍左右,所以栈传参大大限制了Go语言函数调用的速度。基于栈传递参数和接收返回值的设计大大降低了实现的复杂度,但是牺牲了函数调用的性能,在Go v1.17版本之后引入了寄存器传递函数传参。
我们直接以下面的例子来看一下Go语言的调用惯例:

package mainfunc myFunction(a, b, c, d, e, f, g, h, i, j, k, l int) (int, int, int, int, int, int, int, int, int, int, int, int) { return a, b, c, d, e, f, g, h, i, j, k, l}func main() { myFunction(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12)}

通过go tool compile -S -N -l main.go >> main.s得到汇编代码,可以看到,函数传参,前9个参数都是通过寄存器传入,超过9个的以上通过栈传递参数。

\"\".main STEXT size=118 args=0x0 locals=0x80 funcid=0x0 0x0000 00000 (main.go:7) TEXT \"\".main(SB), ABIInternal, $128-0 0x0000 00000 (main.go:7) CMPQ SP, 16(R14) 0x0004 00004 (main.go:7) PCDATA $0, $-2 0x0004 00004 (main.go:7) JLS 111 0x0006 00006 (main.go:7) PCDATA $0, $-1 0x0006 00006 (main.go:7) ADDQ $-128, SP 0x000a 00010 (main.go:7) MOVQ BP, 120(SP) 0x000f 00015 (main.go:7) LEAQ 120(SP), BP 0x0014 00020 (main.go:7) FUNCDATA $0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB) 0x0014 00020 (main.go:7) FUNCDATA $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB) 0x0014 00020 (main.go:8) MOVQ $10, (SP) // 10入栈 0x001c 00028 (main.go:8) MOVQ $11, 8(SP) // 11入栈 0x0025 00037 (main.go:8) MOVQ $12, 16(SP) // 12入栈 0x002e 00046 (main.go:8) MOVL $1, AX // 1存入AX 0x0033 00051 (main.go:8) MOVL $2, BX // 2存入BX 0x0038 00056 (main.go:8) MOVL $3, CX // 3存入CX 0x003d 00061 (main.go:8) MOVL $4, DI // 4存入DI 0x0042 00066 (main.go:8) MOVL $5, SI // 5存入SI 0x0047 00071 (main.go:8) MOVL $6, R8 // 6存入R8 0x004d 00077 (main.go:8) MOVL $7, R9 // 7存入R9 0x0053 00083 (main.go:8) MOVL $8, R10 // 8存入R10 0x0059 00089 (main.go:8) MOVL $9, R11 // 9存入R11 0x005f 00095 (main.go:8) PCDATA $1, $0 0x005f 00095 (main.go:8) NOP 0x0060 00096 (main.go:8) CALL \"\".myFunction(SB) 0x0065 00101 (main.go:9) MOVQ 120(SP), BP 0x006a 00106 (main.go:9) SUBQ $-128, SP 0x006e 00110 (main.go:9) RET

再看返回值,可以发现返回值也是前9个利用相同的寄存器返回的,但是如果返回值超过9,剩下的也是用栈返回的,注意是在入参栈的下面再开辟栈,所以不会占据传参的栈。

\"\".myFunction STEXT nosplit size=399 args=0x78 locals=0x50 funcid=0x0 0x0000 00000 (main.go:3) TEXT \"\".myFunction(SB), NOSPLIT|ABIInternal, $80-120 0x0000 00000 (main.go:3) SUBQ $80, SP // SP 先减去0x80 0x0004 00004 (main.go:3) MOVQ BP, 72(SP) 0x0009 00009 (main.go:3) LEAQ 72(SP), BP 0x000e 00014 (main.go:3) FUNCDATA $0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB) 0x000e 00014 (main.go:3) FUNCDATA $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB) 0x000e 00014 (main.go:3) FUNCDATA $5, \"\".myFunction.arginfo1(SB) 0x000e 00014 (main.go:3) MOVQ AX, \"\".a+136(SP) 0x0016 00022 (main.go:3) MOVQ BX, \"\".b+144(SP) 0x001e 00030 (main.go:3) MOVQ CX, \"\".c+152(SP) 0x0026 00038 (main.go:3) MOVQ DI, \"\".d+160(SP) 0x002e 00046 (main.go:3) MOVQ SI, \"\".e+168(SP) 0x0036 00054 (main.go:3) MOVQ R8, \"\".f+176(SP) 0x003e 00062 (main.go:3) MOVQ R9, \"\".g+184(SP) 0x0046 00070 (main.go:3) MOVQ R10, \"\".h+192(SP) 0x004e 00078 (main.go:3) MOVQ R11, \"\".i+200(SP) 0x0056 00086 (main.go:3) MOVQ $0, \"\".~r12+64(SP) 0x005f 00095 (main.go:3) MOVQ $0, \"\".~r13+56(SP) 0x0068 00104 (main.go:3) MOVQ $0, \"\".~r14+48(SP) 0x0071 00113 (main.go:3) MOVQ $0, \"\".~r15+40(SP) 0x007a 00122 (main.go:3) MOVQ $0, \"\".~r16+32(SP) 0x0083 00131 (main.go:3) MOVQ $0, \"\".~r17+24(SP) 0x008c 00140 (main.go:3) MOVQ $0, \"\".~r18+16(SP) 0x0095 00149 (main.go:3) MOVQ $0, \"\".~r19+8(SP) 0x009e 00158 (main.go:3) MOVQ $0, \"\".~r20(SP) 0x00a6 00166 (main.go:3) MOVQ $0, \"\".~r21+112(SP) 0x00af 00175 (main.go:3) MOVQ $0, \"\".~r22+120(SP) 0x00b8 00184 (main.go:3) MOVQ $0, \"\".~r23+128(SP) 0x00c4 00196 (main.go:4) MOVQ \"\".a+136(SP), DX 0x00cc 00204 (main.go:4) MOVQ DX, \"\".~r12+64(SP) 0x00d1 00209 (main.go:4) MOVQ \"\".b+144(SP), DX 0x00d9 00217 (main.go:4) MOVQ DX, \"\".~r13+56(SP) 0x00de 00222 (main.go:4) MOVQ \"\".c+152(SP), DX 0x00e6 00230 (main.go:4) MOVQ DX, \"\".~r14+48(SP) 0x00eb 00235 (main.go:4) MOVQ \"\".d+160(SP), DX 0x00f3 00243 (main.go:4) MOVQ DX, \"\".~r15+40(SP) 0x00f8 00248 (main.go:4) MOVQ \"\".e+168(SP), DX 0x0100 00256 (main.go:4) MOVQ DX, \"\".~r16+32(SP) 0x0105 00261 (main.go:4) MOVQ \"\".f+176(SP), DX 0x010d 00269 (main.go:4) MOVQ DX, \"\".~r17+24(SP) 0x0112 00274 (main.go:4) MOVQ \"\".g+184(SP), DX 0x011a 00282 (main.go:4) MOVQ DX, \"\".~r18+16(SP) 0x011f 00287 (main.go:4) MOVQ \"\".h+192(SP), DX 0x0127 00295 (main.go:4) MOVQ DX, \"\".~r19+8(SP) 0x012c 00300 (main.go:4) MOVQ \"\".i+200(SP), DX 0x0134 00308 (main.go:4) MOVQ DX, \"\".~r20(SP) 0x0138 00312 (main.go:4) MOVQ \"\".j+88(SP), DX 0x013d 00317 (main.go:4) MOVQ DX, \"\".~r21+112(SP)// 第10个返回值 0x0142 00322 (main.go:4) MOVQ \"\".k+96(SP), DX 0x0147 00327 (main.go:4) MOVQ DX, \"\".~r22+120(SP)// 第11个返回值 0x014c 00332 (main.go:4) MOVQ \"\".l+104(SP), DX 0x0151 00337 (main.go:4) MOVQ DX, \"\".~r23+128(SP)// 第12个返回值 0x0159 00345 (main.go:4) MOVQ \"\".~r12+64(SP), AX // 第1个返回值 0x015e 00350 (main.go:4) MOVQ \"\".~r13+56(SP), BX // 第2个返回值 0x0163 00355 (main.go:4) MOVQ \"\".~r14+48(SP), CX // 第3个返回值 0x0168 00360 (main.go:4) MOVQ \"\".~r15+40(SP), DI // 第4个返回值 0x016d 00365 (main.go:4) MOVQ \"\".~r16+32(SP), SI // 第5个返回值 0x0172 00370 (main.go:4) MOVQ \"\".~r17+24(SP), R8 // 第6个返回值 0x0177 00375 (main.go:4) MOVQ \"\".~r18+16(SP), R9 // 第7个返回值 0x017c 00380 (main.go:4) MOVQ \"\".~r19+8(SP), R10 // 第8个返回值 0x0181 00385 (main.go:4) MOVQ \"\".~r20(SP), R11 // 第9个返回值 0x0185 00389 (main.go:4) MOVQ 72(SP), BP 0x018a 00394 (main.go:4) ADDQ $80, SP 0x018e 00398 (main.go:4) RET

其中,Go使用的是Plan9汇编,其和C语言直接使用的x86_64的寄存器对比如下表:
Golang语言基础—函数调用
总结如下:

  • 当Go语言的函数传参和返回值在9个及以下时,按顺序使用AX、BX、CX、DI、SI、R8、R9、R10和R11作为传递的寄存器,注意传参和返回值一致;
  • 当Go语言的函数传参和返回值大于9个时,多于9个的部分使用栈传递;

2.1 结构体参数如何传参

当结构体中的参数能够被寄存器装下时,则采用寄存器传递结构体中的参数。

如下代码:

package maintype Request struct { a, b, c, d, e, f, g, h, i int}type Response struct { a, b, c, d, e, f, g, h, i int}func myFunction(req Request) Response { return Response{ a: req.a, b: req.b, c: req.c, d: req.d, e: req.e, f: req.f, g: req.g, h: req.h, i: req.i, }}func main() { myFunction(Request{ a: 1, b: 2, c: 3, d: 4, e: 5, f: 6, g: 7, h: 8, i: 9, })}

编译后的代码是:

\"\".main STEXT size=91 args=0x0 locals=0x50 funcid=0x0 ... 0x0014 00020 (main.go:26) MOVL $1, AX 0x0019 00025 (main.go:26) MOVL $2, BX 0x001e 00030 (main.go:26) MOVL $3, CX 0x0023 00035 (main.go:26) MOVL $4, DI 0x0028 00040 (main.go:26) MOVL $5, SI 0x002d 00045 (main.go:26) MOVL $6, R8 0x0033 00051 (main.go:26) MOVL $7, R9 0x0039 00057 (main.go:26) MOVL $8, R10 0x003f 00063 (main.go:26) MOVL $9, R11 0x0045 00069 (main.go:26) PCDATA $1, $0 0x0045 00069 (main.go:26) CALL \"\".myFunction(SB)

如果我们增加一个结构体参数,就会看到以下的传参,通过DUFFCOPY拷贝到栈中。

\"\".main STEXT size=73 args=0x0 locals=0x58 funcid=0x0 0x0000 00000 (main.go:25) TEXT \"\".main(SB), ABIInternal, $88-0 0x0000 00000 (main.go:25) CMPQ SP, 16(R14) 0x0004 00004 (main.go:25) PCDATA $0, $-2 0x0004 00004 (main.go:25) JLS 66 0x0006 00006 (main.go:25) PCDATA $0, $-1 0x0006 00006 (main.go:25) SUBQ $88, SP 0x000a 00010 (main.go:25) MOVQ BP, 80(SP) 0x000f 00015 (main.go:25) LEAQ 80(SP), BP 0x0014 00020 (main.go:25) FUNCDATA $0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB) 0x0014 00020 (main.go:25) FUNCDATA $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB) 0x0014 00020 (main.go:38) MOVQ SP, DI 0x0017 00023 (main.go:26) LEAQ \"\"..stmp_1(SB), SI 0x001e 00030 (main.go:26) PCDATA $0, $-2 0x001e 00030 (main.go:26) NOP 0x0020 00032 (main.go:26) DUFFCOPY $826 0x0033 00051 (main.go:26) PCDATA $0, $-1 0x0033 00051 (main.go:26) PCDATA $1, $0 0x0033 00051 (main.go:26) CALL \"\".myFunction(SB)

2.2 浮点型如何传参

浮点型参数。由于amd 64架构中,浮点型数据的编码与整形数据编码大不相同,而浮点数的运算会使用专用寄存器和指令。所以浮点数不会使用这9个通用寄存器来传递,而是使用这15个XMM寄存器来传递。这组XMM寄存器是随着多媒体相关的指令集一起引入的,go 语言使用它们来处理浮点数。前15个浮点型参数会依次使用x0到x14这15个寄存器来传递。
如果还有就要使用栈来传递了。

3. Go语言的参数传递

Go中只有传值调用,没有传引用调用!
至于为什么有些操作看起来就像传指针一样,需要明确的是:

切片复制,结构体的底层指针指向同一个地址,所以修改切片已有值会影响原切片底层数组的值,但是append操作不会;
字符串复制和切片复制类似,但是其底层数组值不可修改;
map本质上就是一个指针,所以看起来像传引用,实际上还是传值,只不过这个值是指针;
channel本质上也是个指针;
结构体传值时也会复制对象,所以太大的结构体最好采用指针传值调用。bi

4 闭包

闭包的本质是函数+引用环境,如下,incr函数返回一个匿名函数,其含有一个局部变量i,这个局部变量会发生逃逸。

package mainimport \"fmt\"func incr() func() int { var i int return func() int { i++ return i }}func main() { incr1, incr2 := incr(), incr() fmt.Println(incr1()) fmt.Println(incr1()) fmt.Println(incr1()) fmt.Println(incr2()) fmt.Println(incr2()) fmt.Println(incr()) fmt.Println(incr()())}

以上代码执行的结果是:

123120x1044002801

当执行incr1, incr2 := incr(), incr()时就会生成两个闭包,可以想象,闭包incr1和incr2保存这个一个对i的引用,可以理解为incr1有一个指向i的指针。
incr()是一个函数,打印的是一个函数地址;incr()()是这个函数执行,打印的是这个函数的执行结果。