1. C语言的函数调用惯例
所谓"调用惯例(calling convention)"是调用方和被调用方对于函数调用的一个明确的约定,包括:函数参数与返回值的传递方式、传递顺序。只有双方都遵守同样的约定,函数才能被正确地调用和执行。如果不遵守这个约定,函数将无法正确执行。
C语言中,一般使用gcc将C语言编译成汇编代码是分析函数调用的最常见方式,比如以下的代码:
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:
c
.file "main.c"
.text
.globl my_function
.type my_function, @function
my_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, @function
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
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个:
c
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个参数放到寄存器中,但是后面的参数会通过栈传递。
c
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语言的调用惯例:
c
package main
func 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个的以上通过栈传递参数。
c
"".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,剩下的也是用栈返回的,注意是在入参栈的下面再开辟栈,所以不会占据传参的栈。
c
"".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的寄存器对比如下表:
总结如下:
- 当Go语言的函数传参和返回值在9个及以下时,按顺序使用AX、BX、CX、DI、SI、R8、R9、R10和R11作为传递的寄存器,注意传参和返回值一致;
- 当Go语言的函数传参和返回值大于9个时,多于9个的部分使用栈传递;
2.1 结构体参数如何传参
当结构体中的参数能够被寄存器装下时,则采用寄存器传递结构体中的参数。
如下代码:
c
package main
type 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,
})
}
编译后的代码是:
c
"".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拷贝到栈中。
c
"".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,这个局部变量会发生逃逸。
c
package main
import "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()())
}
以上代码执行的结果是:
c
1
2
3
1
2
0x104400280
1
当执行incr1, incr2 := incr(), incr()时就会生成两个闭包,可以想象,闭包incr1和incr2保存这个一个对i的引用,可以理解为incr1有一个指向i的指针。
incr()是一个函数,打印的是一个函数地址;incr()()是这个函数执行,打印的是这个函数的执行结果。