前言
函数是 Go 语言的一等公民,掌握和理解函数的调用过程是我们深入学习 Go 无法跳过的,本节将从函数的调用惯例和参数传递方法两个方面分别介绍函数的执行过程。
调用惯例
无论是系统级编程语言 C 和 Go,还是脚本语言 Ruby 和 Python,这些编程语言在调用函数时往往都使用相同的语法:
1
|
somefunction(arg0, arg1)
|
虽然它们调用函数的语法很相似,但是它们的调用惯例却可能大不相同。调用惯例是调用方和被调用方对于参数和返回值传递的约定,本节将为各位读者介绍 C 和 Go 语言的调用惯例。
C 语言
我们先来研究 C 语言的调用惯例,使用 gcc1 或者 clang2 将 C 语言编译成汇编代码是分析其调用惯例的最好方法,从汇编语言中可以了解函数调用的具体过程。
gcc 和 clang 编译相同 C 语言代码可能会生成不同的汇编指令,不过生成的代码在结构上不会有太大的区别,所以对只想理解调用惯例的人来说没有太多影响。作者在本节中选择使用 gcc 编译器来编译 C 语言:
1
2
3
4
5
|
$ gcc --version
gcc (Ubuntu 4.8.2-19ubuntu1) 4.8.2
Copyright (C) 2013 Free Software Foundation, Inc.
This is free software; see the source for copying conditions. There is NO
warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
假设我们有以下的 C 语言代码,代码中只包含两个函数,其中一个是主函数 main,另一个是我们定义的函数 my_function:
1
2
3
4
5
6
7
8
|
// ch04/my_function.c
int my_function(int arg1, int arg2) {
return arg1 + arg2;
}
int main() {
int i = my_function(1, 2);
}
|
我们可以使用 cc -S my_function.c
命令将上述文件编译成如下所示的汇编代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
main:
pushq %rbp
movq %rsp, %rbp
subq $16, %rsp
movl $2, %esi // 设置第二个参数
movl $1, %edi // 设置第一个参数
call my_function
movl %eax, -4(%rbp)
my_function:
pushq %rbp
movq %rsp, %rbp
movl %edi, -4(%rbp) // 取出第一个参数,放到栈上
movl %esi, -8(%rbp) // 取出第二个参数,放到栈上
movl -8(%rbp), %eax // eax = esi = 1
movl -4(%rbp), %edx // edx = edi = 2
addl %edx, %eax // eax = eax + edx = 1 + 2 = 3
popq %rbp
|
我们按照调用前、调用时以及调用后的顺序分析上述调用过程:
- 在 my_function 调用前,调用方 main 函数将 my_function 的两个参数分别存到 edi 和 esi 寄存器中;
- 在 my_function 调用时,它会将寄存器 edi 和 esi 中的数据存储到 eax 和 edx 两个寄存器中,随后通过汇编指令 addl 计算两个入参之和;
- 在 my_function 调用后,使用寄存器 eax 传递返回值,main 函数将 my_function 的返回值存储到栈上的 i 变量中;
1
2
3
|
int my_function(int arg1, int arg2, int ... arg8) {
return arg1 + arg2 + ... + arg8;
}
|
如上述代码所示,当 my_function 函数的入参增加至八个时,重新编译当前程序可以会得到不同的汇编代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
|
main:
pushq %rbp
movq %rsp, %rbp
subq $16, %rsp // 为参数传递申请 16 字节的栈空间
movl $8, 8(%rsp) // 传递第 8 个参数
movl $7, (%rsp) // 传递第 7 个参数
movl $6, %r9d
movl $5, %r8d
movl $4, %ecx
movl $3, %edx
movl $2, %esi
movl $1, %edi
call my_function
|
main 函数调用 my_function
时,前六个参数会使用 edi、esi、edx、ecx、r8d 和 r9d 六个寄存器传递。寄存器的使用顺序也是调用惯例的一部分,函数的第一个参数一定会使用 edi 寄存器,第二个参数使用 esi 寄存器,以此类推。
最后的两个参数与前面的完全不同,调用方 main 函数通过栈传递这两个参数,下图展示了 main 函数在调用 my_function 前的栈信息:

上图中 rbp 寄存器会存储函数调用栈的基址指针,即属于 main 函数的栈空间的起始位置,而另一个寄存器 rsp 存储的是 main 函数调用栈结束的位置,这两个寄存器共同表示了函数的栈空间。
在调用 my_function 之前,main 函数通过 subq $16, %rsp 指令分配了 16 个字节的栈地址,随后将第六个以上的参数按照从右到左的顺序存入栈中,即第八个和第七个,余下的六个参数会通过寄存器传递,接下来运行的 call my_function 指令会调用 my_function 函数:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
my_function:
pushq %rbp
movq %rsp, %rbp
movl %edi, -4(%rbp) // rbp-4 = edi = 1
movl %esi, -8(%rbp) // rbp-8 = esi = 2
...
movl -8(%rbp), %eax // eax = 2
movl -4(%rbp), %edx // edx = 1
addl %eax, %edx // edx = eax + edx = 3
...
movl 16(%rbp), %eax // eax = 7
addl %eax, %edx // edx = eax + edx = 28
movl 24(%rbp), %eax // eax = 8
addl %edx, %eax // edx = eax + edx = 36
popq %rbp
|
my_function 会先将寄存器中的全部数据转移到栈上,然后利用 eax 寄存器计算所有入参的和并返回结果。
我们可以将本节的发现和分析简单总结成 — 当我们在 x86_64 的机器上使用 C 语言中调用函数时,参数都是通过寄存器和栈传递的,其中:
- 六个以及六个以下的参数会按照顺序分别使用 edi、esi、edx、ecx、r8d 和 r9d 六个寄存器传递;
- 六个以上的参数会使用栈传递,函数的参数会以从右到左的顺序依次存入栈中;
而函数的返回值是通过 eax 寄存器进行传递的,由于只使用一个寄存器存储返回值,所以 C 语言的函数不能同时返回多个值。
Go 语言
Go 1.17版本之前,Go采用基于栈的调用约定,即函数的参数与返回值都通过栈来传递,这种方式的优点是实现简单,不用担心底层cpu架构寄存器的差异,适合跨平台;但缺点就是牺牲了一些性能,我们都知道寄存器的访问速度要远高于内存。
大多数平台上的大多数语言实现都使用基于寄存器的调用约定,通过寄存器而不是内存传递函数参数和返回结果,并指定一些寄存器为调用保存寄存器,允许函数在不同的调用中保持状态。
于是Go在1.17版本决定向这些语言看齐,在amd64架构下率先实现了从基于堆栈的调用惯例到基于寄存器的调用惯例的切换。
在Go 1.17的版本发布说明文档中有提到:切换到基于寄存器的调用惯例后,一组有代表性的Go包和程序的基准测试显示,Go程序的运行性能提高了约5%,二进制文件大小典型减少约2%。我们可以通过反汇编对其进行简单的观察。这里依然为了简化问题,我们只用 int 参数(float 使用的不是通用寄存器)。
1
2
3
4
5
6
7
8
9
10
11
|
package main
//go:noinline
func add(x int, y int, z int, a, b, c int, d, e, f int, g, h, l int) (int, int, int, int, int, int, int, int, int, int, int) {
println(x, y)
return 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11
}
func main() {
println(add(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12))
}
|
稍微多传一些参数方便我们观察,输入 12 个参数,返回 11 个值。
直接看反汇编的结果,首先是对 main.add 的调用部分:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
TEXT main.main(SB) /Users/xargin/test/abi.go
abi.go:15 0x1054e60 4c8da42478ffffff LEAQ 0xffffff78(SP), R12
abi.go:15 0x1054e68 4d3b6610 CMPQ 0x10(R14), R12
abi.go:15 0x1054e6c 0f865a020000 JBE 0x10550cc
abi.go:15 0x1054e72 4881ec08010000 SUBQ $0x108, SP
abi.go:15 0x1054e79 4889ac2400010000 MOVQ BP, 0x100(SP)
abi.go:15 0x1054e81 488dac2400010000 LEAQ 0x100(SP), BP
abi.go:16 0x1054e89 48c704240a000000 MOVQ $0xa, 0(SP) // 第 10 个参数
abi.go:16 0x1054e91 48c74424080b000000 MOVQ $0xb, 0x8(SP) // 第 11 个参数
abi.go:16 0x1054e9a 48c74424100c000000 MOVQ $0xc, 0x10(SP) // 第 12 个参数
abi.go:16 0x1054ea3 b801000000 MOVL $0x1, AX // 第 1 个参数,后面以此类推
abi.go:16 0x1054ea8 bb02000000 MOVL $0x2, BX
abi.go:16 0x1054ead b903000000 MOVL $0x3, CX
abi.go:16 0x1054eb2 bf04000000 MOVL $0x4, DI
abi.go:16 0x1054eb7 be05000000 MOVL $0x5, SI
abi.go:16 0x1054ebc 41b806000000 MOVL $0x6, R8
abi.go:16 0x1054ec2 41b907000000 MOVL $0x7, R9
abi.go:16 0x1054ec8 41ba08000000 MOVL $0x8, R10
abi.go:16 0x1054ece 41bb09000000 MOVL $0x9, R11
abi.go:16 0x1054ed4 e807fdffff CALL main.add(SB)
abi.go:16 0x1054ed9 48898424f8000000 MOVQ AX, 0xf8(SP)
|
可以看到,官方只使用了 9 个通用寄存器,依次是 AX,BX,CX,DI,SI,R8,R9,R10,R11,超出部分,按顺序放在栈上。
然后是 main.add 的返回值部分:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
TEXT main.add(SB) /Users/xargin/test/abi.go
.... 省略 print 的部分
abi.go:6 0x1054c2f 48c74424400a000000 MOVQ $0xa, 0x40(SP) // 第 10 个返回值
abi.go:6 0x1054c38 48c74424480b000000 MOVQ $0xb, 0x48(SP) // 第 11 个返回值
abi.go:6 0x1054c41 b801000000 MOVL $0x1, AX // 第 1 个返回值,后面以此类推
abi.go:6 0x1054c46 bb02000000 MOVL $0x2, BX
abi.go:6 0x1054c4b b903000000 MOVL $0x3, CX
abi.go:6 0x1054c50 bf04000000 MOVL $0x4, DI
abi.go:6 0x1054c55 be05000000 MOVL $0x5, SI
abi.go:6 0x1054c5a 41b806000000 MOVL $0x6, R8
abi.go:6 0x1054c60 41b907000000 MOVL $0x7, R9
abi.go:6 0x1054c66 41ba08000000 MOVL $0x8, R10
abi.go:6 0x1054c6c 41bb09000000 MOVL $0x9, R11
abi.go:6 0x1054c72 488b6c2418 MOVQ 0x18(SP), BP
abi.go:6 0x1054c77 4883c420 ADDQ $0x20, SP
abi.go:6 0x1054c7b c3 RET
|
返回值和输入使用了完全相同的寄存器序列,同样在超出 9 个返回值时,多出的内容在栈上返回。
在传统的调用规约中,一般会区分 caller saved registers 和 callee saved registers,但在 Go 中,所有寄存器都是 caller saved,也就是由 caller 负责保存,在 callee 中不保证不对其现场进行破坏。
这里可以看到,返回值直接就把入参使用的寄存器直接覆盖掉了,也可以证明这一点。
因为函数调用不需要通过栈来传参了,所以在一些函数调用嵌套层次比较深的场景下,goroutine 栈本身使用的内存也有一定概率会降低。
Go的函数调用规约
调用规约是程序员需要遵守的关于函数调用顺序的约定。
如果所有人都遵守同样的规则的话,那么就可以流畅地完成合作。一旦有人破坏规则,例如,修改或者在一个函数中不恢复 rbp 的值,那么可能会发生:啥事都没有,稍后程序崩溃,或者程序马上就崩。由于其它函数在编写的时候都假定自身以外的函数是遵守这些调用规则的,且保证 rbp 寄存器不被修改。
调用规约定义的其中之一就是参数的传递算法。我们这里使用传统的 *nix 的 x86 64 下的习惯(在[24]中详细定义),下面的描述对于函数如何被调用是一个相对精确的近似。
1.首先,需要对值进行保护的寄存器需要保存好原来的值。除了七个 callee-saved 寄存器(rbx,rbp,rsp,r12 - r15)都可能被被调用函数所修改,所以如果这些寄存器的值重要的话,就需要把这些寄存器的值保存起来(一般都在栈上保存)。
2.寄存器和栈都会被参数填充。 每个参数都会被 round 到 8 字节。 参数会被分为三个列表: (a) 整数和指针参数 (b) Float 和 double 参数 (c) 通过内存中的栈传入的参数 第一个列表中的前六个参数通过六个通用寄存器传入(rdi,rsi,rdx,rcx,r8 和 r9)。第二个列表中的前八个参数通过 xmm0 ~ xmm7 这八个寄存器传入。如果前两个列表还有更多的参数传入,那么这些多余的参数会被以反序存储在栈上传入。也就是说,在函数被执行前,传入的最后一个参数应该是在栈顶上。 整数和浮点数参数传递比较简单,结构体传入则稍微复杂一些。 如果一个结构体大于 32 字节,或者有未对齐的字段,那么就会通过内存传入。 小结构体会按照其字段被分解为多个字段,每一个字段都被分别处理,如果结构体内又有结构体,那么也会被递归做相同处理。所以一个包含两个元素的结构体可以用两个参数的同样的方式进行传入。如果结构体的某个字段被认为是 “内存”,那么就会冒泡到结构体本身。 rbp 寄存器像我们即将看到的,会用来定位通过内存传入的参数以及局部变量。 返回值往哪里填呢?整数和指针会存储在 rax 和 rdx 中返回。浮点数会在 xmm0 和 xmm1 返回。大结构体会以一个指针形式返回,该指针以隐藏的附加参数返回,像下面这个例子:
1
2
3
4
5
6
7
8
9
10
11
|
struct s {
char vals[100];
};
struct s f( int x ) {
struct s mys;
mys.vals[10] = 42;
return mys;
}
void f( int x, struct s* ret ) {
ret->vals[10] = 42;
}
|
3.然后就可以调用 call 指令了。call 的参数是需要调用的函数的第一条指令的地址。call 指令会将该地址 push 到栈上。
每一个程序都可以有同一个函数的多个实例同时执行,这些同时执行的函数并不一定是在不同的线程中,且有可能是由于递归导致的多实例。这个函数的每一个实例都会被存储在栈上,因为栈的主要规则是后进先出,因此该特性会反映在函数的运行和销毁上。一个函数 f 在运行后调用了函数 g,那么 g 会先被销毁(而 g 从时间上来讲是后被调用的),f 之后才被销毁 (然而 f 在时间上是先被调用的)。
栈帧是为某一函数所专用的栈的一部分。栈帧保存了局部变量,临时变量和保存的寄存器。
函数代码一般会被一对 prologue 代码和 epilogue 代码包裹,对所有函数来说都一样。prologue 用来初始化栈帧,epilogue 用来逆向初始化(销毁)。
函数执行过程中,rbp 保持不变并一直指向该函数栈帧的起始位置。这样就可以用 rbp 寄存器外加偏移量来对局部变量进行寻址了。列表 14-1 中的代码对此有所反映。
Listing 14-1.prologue.asm
1
2
3
4
5
|
func:
push rbp
mov rbp, rsp
sub rsp, 24 ; given 24 is total size of local variables
|
老的 rbp 值被存储起来以便之后在 epilogue 中恢复。然后 rbp 被设置为当前的栈的栈顶值(顺便说一下,栈顶现在存储的是 rbp 的老值)。接下来为局部变量分配空间的话,就只需要让 rsp 的值减去该变量的大小就可以了。这也是我们在栈上分配 buffer 空间的方式。
局部变量现在若要分配内存则可以直接用 rsp 减去其大小
函数结束的 epilogue 片段如列表 14-2 所示。
Listing 14-2.epilogue.asm
1
2
3
|
mov rsp, rbp
pop rbp
ret
|
通过将栈帧的起始地址移动到 rsp,我们可以确保所有在栈上分配的空间都被释放掉了。然后老的 rbp 值也就被恢复了,现在 rbp 会指向前一个栈帧的起始地址。最后 ret 指令会将返回地址从栈弹出到 rip 中。
编译器一般使用的是一组完全等价的替代指令,参见列表 14-3。
Listing 14-3.epilogue_alt.asm
leave 指令是特别为栈帧销毁所发明的指令。其反义指令 enter 则不太被很多编译器所接受,因为这条指令提供了比列表 14-1 更多的功能。指令本身是针对那些内嵌函数支持的编程语言设计的。
4.在离开函数之后,并不是说我们的工作就结束了。由于一些参数是通过内存(栈)传入的,我们也需要把这些也清理掉。
参数传递
除了函数的调用惯例之外,Go 语言在传递参数时是传值还是传引用也是一个有趣的问题,不同的选择会影响我们在函数中修改入参时是否会影响调用方看到的数据。我们先来介绍一下传值和传引用两者的区别:
- 传值:函数调用时会对参数进行拷贝,被调用方和调用方两者持有不相关的两份数据;
- 传引用:函数调用时会传递参数的指针,被调用方和调用方两者持有相同的数据,任意一方做出的修改都会影响另一方。
不同语言会选择不同的方式传递参数,Go 语言选择了传值的方式,无论是传递基本类型、结构体还是指针,都会对传递的参数进行拷贝。本节剩下的内容将会验证这个结论的正确性。
整型和数组
我们先来分析 Go 语言是如何传递基本类型和数组的。如下所示的函数 myFunction 接收了两个参数,整型变量 i 和数组 arr,这个函数会将传入的两个参数的地址打印出来,在最外层的主函数也会在 myFunction 函数调用前后分别打印两个参数的地址:
1
2
3
4
5
6
7
8
9
10
11
|
func myFunction(i int, arr [2]int) {
fmt.Printf("in my_funciton - i=(%d, %p) arr=(%v, %p)\n", i, &i, arr, &arr)
}
func main() {
i := 30
arr := [2]int{66, 77}
fmt.Printf("before calling - i=(%d, %p) arr=(%v, %p)\n", i, &i, arr, &arr)
myFunction(i, arr)
fmt.Printf("after calling - i=(%d, %p) arr=(%v, %p)\n", i, &i, arr, &arr)
}
|
1
2
3
4
|
$ go run main.go
before calling - i=(30, 0xc00009a000) arr=([66 77], 0xc00009a010)
in my_funciton - i=(30, 0xc00009a008) arr=([66 77], 0xc00009a020)
after calling - i=(30, 0xc00009a000) arr=([66 77], 0xc00009a010)
|
当我们通过命令运行这段代码时会发现,main 函数和被调用者 myFunction 中参数的地址是完全不同的。
不过从 main 函数的角度来看,在调用 myFunction 前后,整数 i 和数组 arr 两个参数的地址都没有变化。那么如果我们在 myFunction 函数内部对参数进行修改是否会影响 main 函数中的变量呢?这里更新 myFunction 函数并重新执行这段代码:
1
2
3
4
5
|
func myFunction(i int, arr [2]int) {
i = 29
arr[1] = 88
fmt.Printf("in my_funciton - i=(%d, %p) arr=(%v, %p)\n", i, &i, arr, &arr)
}
|
1
2
3
4
|
$ go run main.go
before calling - i=(30, 0xc000072008) arr=([66 77], 0xc000072010)
in my_funciton - i=(29, 0xc000072028) arr=([66 88], 0xc000072040)
after calling - i=(30, 0xc000072008) arr=([66 77], 0xc000072010)
|
我们可以看到在 myFunction 中对参数的修改也仅仅影响了当前函数,并没有影响调用方 main 函数,所以能得出如下结论:Go 语言的整型和数组类型都是值传递的,也就是在调用函数时会对内容进行拷贝。需要注意的是如果当前数组的大小非常的大,这种传值的方式会对性能造成比较大的影响。
结构体和指针
接下来我们继续分析 Go 语言另外两种常见类型 — 结构体和指针。下面这段代码中定义了一个结构体 MyStruct 以及接受两个参数的 myFunction 方法:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
type MyStruct struct {
i int
}
func myFunction(a MyStruct, b *MyStruct) {
a.i = 31
b.i = 41
fmt.Printf("in my_function - a=(%d, %p) b=(%v, %p)\n", a, &a, b, &b)
}
func main() {
a := MyStruct{i: 30}
b := &MyStruct{i: 40}
fmt.Printf("before calling - a=(%d, %p) b=(%v, %p)\n", a, &a, b, &b)
myFunction(a, b)
fmt.Printf("after calling - a=(%d, %p) b=(%v, %p)\n", a, &a, b, &b)
}
|
1
2
3
4
|
$ go run main.go
before calling - a=({30}, 0xc000018178) b=(&{40}, 0xc00000c028)
in my_function - a=({31}, 0xc000018198) b=(&{41}, 0xc00000c038)
after calling - a=({30}, 0xc000018178) b=(&{41}, 0xc00000c028)
|
从上述运行的结果我们可以得出如下结论:
- 传递结构体时:会拷贝结构体中的全部内容;
- 传递结构体指针时:会拷贝结构体指针;
修改结构体指针是改变了指针指向的结构体,b.i 可以被理解成 (*b).i,也就是我们先获取指针 b 背后的结构体,再修改结构体的成员变量。我们简单修改上述代码,分析一下 Go 语言结构体在内存中的布局:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
type MyStruct struct {
i int
j int
}
func myFunction(ms *MyStruct) {
ptr := unsafe.Pointer(ms)
for i := 0; i < 2; i++ {
c := (*int)(unsafe.Pointer((uintptr(ptr) + uintptr(8*i))))
*c += i + 1
fmt.Printf("[%p] %d\n", c, *c)
}
}
func main() {
a := &MyStruct{i: 40, j: 50}
myFunction(a)
fmt.Printf("[%p] %v\n", a, a)
}
|
1
2
3
4
|
$ go run main.go
[0xc000018180] 41
[0xc000018188] 52
[0xc000018180] &{41 52}
|
在这段代码中,我们通过指针修改结构体中的成员变量,结构体在内存中是一片连续的空间,指向结构体的指针也是指向这个结构体的首地址。将 MyStruct 指针修改成 int 类型的,那么访问新指针就会返回整型变量 i,将指针移动 8 个字节之后就能获取下一个成员变量 j。
如果我们将上述代码简化成如下所示的代码片段并使用 go tool compile 进行编译会得到如下的结果:
1
2
3
4
5
6
7
8
|
type MyStruct struct {
i int
j int
}
func myFunction(ms *MyStruct)*MyStruct {
return ms
}
|
1
2
3
4
5
6
|
$ go tool compile -S -N -l main.go
"".myFunction STEXT nosplit size=20 args=0x10 locals=0x0
0x0000 00000 (main.go:8) MOVQ $0, "".~r1+16(SP) // 初始化返回值
0x0009 00009 (main.go:9) MOVQ "".ms+8(SP), AX // 复制引用
0x000e 00014 (main.go:9) MOVQ AX, "".~r1+16(SP) // 返回引用
0x0013 00019 (main.go:9) RET
|
在这段汇编语言中,我们发现当参数是指针时,也会使用 MOVQ "".ms+8(SP), AX
指令复制引用,然后将复制后的指针作为返回值传递回调用方。

所以将指针作为参数传入某个函数时,函数内部会复制指针,也就是会同时出现两个指针指向原有的内存空间,所以 Go 语言中传指针也是传值。
传值
当我们验证了 Go 语言中大多数常见的数据结构之后,其实能够推测出 Go 语言在传递参数时使用了传值的方式,接收方收到参数时会对这些参数进行复制;了解到这一点之后,在传递数组或者内存占用非常大的结构体时,我们应该尽量使用指针作为参数类型来避免发生数据拷贝进而影响性能。
转载
4.1 函数调用
简单看看 Go 1.17 的新调用规约