函数调用规约?如果你是第一次听到这个名词可能会有疑惑,这是在说什么?难道两个函数之间调用还需要约定什么吗?难道不是定好入参出参就可以了吗?没错函数的调用规约其实就是:我在调用其他函数的时候我的参数和返回值要如何分布?

那么其实在 golang 底层函数的调用还是有很多细节的,比如你的入参放在哪里?返回值存放在哪里?相信看完这篇你就都明白了。

首先我们定一下基调,因为我们今天讨论的是函数调用规约,所以我们今天的主角是栈,没有堆什么事,也就是说,所有变量都默认分配在栈上,不考虑逃逸的情况

栈的样子

先来看看我们今天主角的样子:

image-20211107220239152

我们今天说的要说的栈,其实应该叫调用栈 call stack 而不是我们平常说的数据结构中栈。栈的增长方向是从高位地址到低位地址向下进行增长的,栈底是我们的高地址,而栈顶是我们的低地址,栈的作用是存放程序执行过程中使用的局部变量。

调用规约

说简单也简单,说复杂也复杂,这里准备由浅入深,首先用一张图来直接描述 go 里面的函数调用规约究竟是怎么样的

image-20211107221858502

  • 左边是调用者栈情况,右边是被调用者栈情况
  • 可以看到调用者栈里有本地的一些变量、当前调用函数的返回值、调用函数的参数
  • 而被调用者存储的也是自己本地的一些变量、函数返回值、入参等

函数调用规约最重要的就是参数的传递,入参和出参是如何传递的,故从图中我们可以看到:

  1. 在调用函数之前,调用方需要将参数和返回值都存放在栈空间上
  2. 调用方有自己的栈存储参数
  3. 当调用函数完成后,被调用方的栈可以直接被销毁,因为所有返回值是被分配在调用方的栈上的

没错 go 中的函数调用规约就是这样简单,所以其实你也就明白了 go 函数的多返回值是如何实现的。

卷卷汇编

当然如果就是这样那也太敷衍了,上面是我告诉你的,函数调用规约是这样,但是实际真的是这样吗?我怎么样能看到函数调用的这个过程呢?下面我们就从汇编的角度看看。

生成汇编

我们用一个最简单的函数调用来看看具体到汇编层面函数调用究竟是怎么样实现的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
package main

import (
"fmt"
)

func main() {
c := Add(123, 321)
fmt.Println(c)
}

func Add(a, b int) (c int) {
return a + b
}

使用命令 go tool compile -S -N -l main.go>> main.md 生成汇编代码,下面代码中省略和函数调用无关的代码

当前编译使用 go 版本为 1.16

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
"".main STEXT size=236 args=0x0 locals=0x78 funcid=0x0
0x0000 00000 (main.go:7) TEXT "".main(SB), ABIInternal, $120-0
0x0000 00000 (main.go:7) MOVQ (TLS), CX
0x0009 00009 (main.go:7) CMPQ SP, 16(CX)
0x000d 00013 (main.go:7) PCDATA $0, $-2
0x000d 00013 (main.go:7) JLS 226
0x0013 00019 (main.go:7) PCDATA $0, $-1
0x0013 00019 (main.go:7) SUBQ $120, SP
0x0017 00023 (main.go:7) MOVQ BP, 112(SP)
0x001c 00028 (main.go:7) LEAQ 112(SP), BP
0x0021 00033 (main.go:7) FUNCDATA $0, gclocals·69c1753bd5f81501d95132d08af04464(SB)
0x0021 00033 (main.go:7) FUNCDATA $1, gclocals·ce02aabaa73fa33b1b70f5cfd490303f(SB)
0x0021 00033 (main.go:7) FUNCDATA $2, "".main.stkobj(SB)
0x0021 00033 (main.go:8) MOVQ $123, (SP) // 将当前 SP 的位置赋值为 123
0x0029 00041 (main.go:8) MOVQ $321, 8(SP) // 将当前 SP+8 的位置赋值为 321
0x0032 00050 (main.go:8) PCDATA $1, $0
0x0032 00050 (main.go:8) CALL "".Add(SB) // 调用 Add 方法
0x0037 00055 (main.go:8) MOVQ 16(SP), AX // 将 SP+16 的值赋值给 AX 寄存器
0x003c 00060 (main.go:8) MOVQ AX, "".c+48(SP) // 将 AX 寄存器的值赋值给 c 也就是 SP+48 的位置
...........................
"".Add STEXT nosplit size=25 args=0x18 locals=0x0 funcid=0x0
0x0000 00000 (main.go:12) TEXT "".Add(SB), NOSPLIT|ABIInternal, $0-24
0x0000 00000 (main.go:12) FUNCDATA $0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
0x0000 00000 (main.go:12) FUNCDATA $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
0x0000 00000 (main.go:12) MOVQ $0, "".c+24(SP) // 初始化 c 也就是 SP+24 为 0
0x0009 00009 (main.go:13) MOVQ "".a+8(SP), AX // 将 a 也就是 SP+8 赋值给 AX 寄存器
0x000e 00014 (main.go:13) ADDQ "".b+16(SP), AX // 将 b 也就是 SP+16 加到 AX 寄存器
0x0013 00019 (main.go:13) MOVQ AX, "".c+24(SP) // 将 AX 寄存器的值赋值给 c 也就是 SP + 24
0x0018 00024 (main.go:13) RET

其中重要的语句我给到了注释说明,我们可以再看看图就很清楚了

image-20211107225733219

左边是调用之前,中间是调用中,右边是调用完成

  1. 可以看到,很明显调用之前,调用方就分配好了入参和出参栈上的空间用于存储
  2. 在执行 call 指令时,会将返回地址压栈,这里 SP 会向低地址移动 8
  3. 在执行 ret 指令时,会将返回地址出栈,这里 SP 会向高地址移动 8

go1.17 实现

既然都分析到这里了,我们就来看看 go1.17 是怎么样的,为什么要看 1.17 呢?因为 go 在 1.17 有一个优化,就是将函数调用由原来的栈分配改为了寄存器分配,加速了函数调用的速度,那我们来看看是怎么样做的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
"".main STEXT size=186 args=0x0 locals=0x60 funcid=0x0
0x0000 00000 (main.go:7) TEXT "".main(SB), ABIInternal, $96-0
0x0000 00000 (main.go:7) CMPQ SP, 16(R14)
0x0004 00004 (main.go:7) PCDATA $0, $-2
0x0004 00004 (main.go:7) JLS 176
0x000a 00010 (main.go:7) PCDATA $0, $-1
0x000a 00010 (main.go:7) SUBQ $96, SP
0x000e 00014 (main.go:7) MOVQ BP, 88(SP)
0x0013 00019 (main.go:7) LEAQ 88(SP), BP
0x0018 00024 (main.go:7) FUNCDATA $0, gclocals·69c1753bd5f81501d95132d08af04464(SB)
0x0018 00024 (main.go:7) FUNCDATA $1, gclocals·ce02aabaa73fa33b1b70f5cfd490303f(SB)
0x0018 00024 (main.go:7) FUNCDATA $2, "".main.stkobj(SB)
0x0018 00024 (main.go:8) MOVL $123, AX // 将 123 放入 AX 寄存器
0x001d 00029 (main.go:8) MOVL $321, BX // 将 312 放入 BX 寄存器
0x0022 00034 (main.go:8) PCDATA $1, $0
0x0022 00034 (main.go:8) CALL "".Add(SB) // 调用 Add 函数
0x0027 00039 (main.go:8) MOVQ AX, "".c+24(SP) // 将当前 AX 寄存器中存储的结果放到 c 中
...................................
"".Add STEXT nosplit size=56 args=0x10 locals=0x10 funcid=0x0
0x0000 00000 (main.go:12) TEXT "".Add(SB), NOSPLIT|ABIInternal, $16-16
0x0000 00000 (main.go:12) SUBQ $16, SP // 将 SP 向低地址移动 16 bytes;意思是分配 16 bytes 栈空间
0x0004 00004 (main.go:12) MOVQ BP, 8(SP) // SP + 8 = BP;将 BP 的值存储到栈上
0x0009 00009 (main.go:12) LEAQ 8(SP), BP // BP = SP + 8
0x000e 00014 (main.go:12) FUNCDATA $0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
0x000e 00014 (main.go:12) FUNCDATA $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
0x000e 00014 (main.go:12) FUNCDATA $5, "".Add.arginfo1(SB)
0x000e 00014 (main.go:12) MOVQ AX, "".a+24(SP) // 将 AX 寄存器中的值赋值给 a
0x0013 00019 (main.go:12) MOVQ BX, "".b+32(SP) // 将 BX 寄存器中的值赋值给 b
0x0018 00024 (main.go:12) MOVQ $0, "".c(SP) // 将 c 变量初始化为 0 值
0x0020 00032 (main.go:13) MOVQ "".a+24(SP), AX // AX = 123
0x0025 00037 (main.go:13) ADDQ "".b+32(SP), AX // AX = AX + 321
0x002a 00042 (main.go:13) MOVQ AX, "".c(SP) // c = AX;c 就拿到了计算结果 444
0x002e 00046 (main.go:13) MOVQ 8(SP), BP // 将当前 BP 的值复原
0x0033 00051 (main.go:13) ADDQ $16, SP // 将 SP 向高地址移动 16 bytes;删除使用的栈空间
0x0037 00055 (main.go:13) RET // 函数返回

很明显对比之下就和原来的不一样了,函数的入参 a 和 b 是通过 AX BX 两个寄存器去传递的,而不是通过原有的栈去传递的,返回值也是通过 AX 寄存器得到的。

因为寄存器和 CPU 关系更好离的更近,传递速度就更快,从而就优化了函数调用的速度。

延伸一下

那么从函数调用规约里面我们还能联系到之前的哪些知识点呢?

为什么 go 不能直接调用 c 而需要 cgo?

因为 c 的函数调用规约和 go 不一致

return 和 defer 到底谁先?

return 其实包含两个步骤,一个步骤是给返回值复制,而另一个步骤是函数返回,也就是出栈了,所以其实 defer 加在中间,defer 的问题后面还会出一期来详细说说

总结

简单总结两条:

  1. 在 1.17 之前 go 的函数调用规约很简单,由调用方分配好入参和返回值的空间,调用完成之后可以直接销毁被调用方的栈空间

  2. go1.17 函数调用中可以使用寄存器来传递参数