一、 前言
我们完成程序的编写之后,经过编译,编译器会将我们的程序编译成一行行机器指令,放到一个可执行文件中;程序执行时,可执行文件被加载到内存,机器执行被放置到虚拟内存的“代码段”,并分配以及初始化程序运行过程中需要的堆栈。会形成如下的结构:

最上面为高地址,最下面为低地址,分配时由高地址向低地址增长。函数的地址由低地址向高地址方向存放。
从高地址到低地址依次为 栈空间、堆空间、全局静态变量区(数据区)、代码区。
二、函数栈帧
函数执行时需要由足够的内存空间,用于存放 局部变量、返回值、参数等,这段空间对应内存中的栈。栈最上面是高地址,向下增长。

分配给函数的栈空间,称为 函数栈帧(function stack frame),栈底称为 栈基(bp),栈顶称为 栈指针(sp)。函数调用结束后又会释放这个栈帧。bp 和 sp 始终指向正在执行的函数的栈帧。如果出现 A 调用 B,B 调用 C,C 调用 D,那么会出现由上到下分别为A的栈帧->B的栈帧->C的栈帧->D的栈帧的情况:

计算机执行函数时,会有专门的寄存器存放栈基 bp、栈指针 sp 和下一条要执行的指令 ip。
所有的函数的栈帧布局都遵循统一的约定,所以被调用者是通过栈指针加上偏移量来定位到每个参数和返回值的。
Go 在分配栈帧时是 一次性分配(主要是为了防止栈访问越界) :(首先函数栈帧的空间在编译时期是可以确定的)确定栈基 bp,然后直接将栈指针 sp 移到所需最大栈空间的位置。之后通过栈指针 sp+偏移值这种相对寻址方式来使用函数栈帧。(例如需要将 3 和 4 依次入栈,则对应的指令分别是 sp+16 处存放 3,sp+8 处存放 4)
由于函数栈帧的大小,可以在编译时期确定,对于栈消耗较大的函数,Go 编译器会在函数头部加上检测代码,如果发现需要进行栈增长,就会另外分配一块足够大的栈空间,并把原来栈上的数据拷过来,同时释放掉原来的栈空间。
三、函数调用过程
有两个指令:call 和 ret。函数 call 指令实现跳转,而每个函数开始时都会分配栈帧,结束前又会释放自己的栈帧,ret 指令又会把栈恢复到之前的样子。
call的过程:
- 将下一条指令的地址入栈,这就是返回地址,被调用函数执行结束后会回到这里;
- 跳转到被调用函数的入口处执行,这后面就是被调用函数的栈帧了。
ret过程:
- 弹出返回地址;
- 跳转到这个返回地址
Go 与 C 语言不同的是,C 是通过寄存器和栈传递参数和返回值的,而 Go 是通过栈。下面通过举例说明 Go 中一个栈帧的结构以及函数调用过程中栈帧的变化:
设有函数 A 和 B,在 A 内部调用了 B:
func A() {
x,y := 2,3
z := B(x,y)
fmt.Println(x,y,z)
}
func B(m, n int) k int {
return m + n
}
首先需要了解的是,**被调用者的参数和返回值,都在调用者的函数栈帧中。**它们在栈中的顺序由上到下依次是:
- A 的局部变量
- 被调用函数 B 的返回值
- 传递给被调用函数 B 的参数(注意,参数顺序与实际书写书序相反)
- B 调用结束后的返回地址(A 中调用 B 之后要执行的命令,即 fmt.Println(x, y, z))
- 调用者 A 的 bp
结构如下:

而具体执行上述代码第 3 行也就是函数调用的详细过程如下:
- 执行 call 函数:
a. 将调用者的下一条指令(第 4 行代码)入栈,这就是返回地址,被调用函数执行结束后会回到这里;
b. 跳转到被调用者处(修改 ip 寄存器的值) - 在被调用函数开始处有三步:
a. 将 sp 向下移动到足够的空间处(如 sp-24 处);
b. 调用者栈基(当前 bp 的值)入栈(调用者栈)(如存放到 sp+16 处); - 此时 bp 的值是被调用者 B 的栈基
- 结果是:bp 和 sp 始终指向正在执行的函数的栈帧;
- 接下来执行被调用函数剩下的部分;
a. 被调用者结束调用时,在 ret 函数前面还有两步:
1). 恢复调用者的栈基 bp 地址——第 2 步中的第 2 步,将栈该处的值赋给寄存器 bp
2). 释放自己的栈帧空间——第 2 步中的第 1 步,分配时向下移动了 24,则释放时向上移动多少 - 结果是:此时 bp 和 sp 已经恢复到调用者的栈帧了
- 执行 ret 步骤:
a. 弹出 call 指令的返回地址(对应过程 1 中的第 1 步)
b. 跳转到弹出的这个地址(修改 ip 寄存器) - 结果是:“被调用者”调用完毕,执行的是调用者的下一个指令,即调用完成(执行完被调用者)后,继续执行调用者函数。
如果在 B 中出现了defer操作,那么应该先执行defer,还是先执行return呢,还是先执行ret过程呢?
答案是:Go 中的 return 并不是真正的返回,真正的返回操作是ret操作,return的作用仅仅是给返回值赋值,之后再执行defer操作,最后才是ret过程(释放自己的栈帧)。
四、传参与返回值
理论部分已经全部说完了,下面通过一些实战来加深理解:
- 为何有时通过函数交换变量位置却不成功?
func swap(a, b int) {
a,b = b,a
}
func main() {
a,b := 1,2
swap(a, b)
fmt.Println(a,b) // 输出 1 2
// 交换失败
}
过程如下:
- 函数第 6 行,栈中从上到下为 a=1, b=2(对应1.A 的局部变量)
- 函数第 7 行,栈中入栈 b=2, a=1(入栈顺序与调用顺序相反)(没有返回值,对应3.传递给被调用函数 B 的参数)
- 执行 “a,b = b,a”,交换的是第 7 行入栈的两个变量而不是第 6 行入栈的调用者的局部变量
- 执行 ret 过程,返回之后,栈中 A 的局部变量并没有被改变,所以还是 a=1, b=2
再看下面的函数:
func swap(a, b *int) {
*a, *b = *b, *a
}
func main() {
a,b := 1,2
swap(&a, &b)
fmt.Println(a,b) // 输出 2 1
// 交换成功
}
过程如下:
- 函数第 6 行,栈中从上到下为 a=1, b=2(对应1.A 的局部变量)
- 函数第 7 行,栈中入栈 b=2 的地址, a=1 的地址(对应3.传递给被调用函数 B 的参数)
- 执行 “*a,*b = *b,*a”,传递的是 A 中变量的地址,实际上进行的是 A 中的变量的 b 和 A 中的变量的 a 交换
- 执行 ret 过程,返回之后,栈中 A 的局部变量被改变
- 有返回值,匿名返回值
func incr1(a int) int {
var b int
defer func() {
a++
b++
}()
a++
b = a
return b
}
func main() {
var a, b int
b = incr1(a)
fmt.Println(a, b) // 输出 0 1
}
过程如下:前面说过,return 的作用相当于给返回值赋值,之后再执行 defer 函数,之后才是 ret 过程
- 第 15 行,栈中从上到下为 a=0, b=0
- 第 16 行,incr1 的返回值,默认 0 值
- 第 2 行,incr1 的局部变量 b=0
- 第 9 行,incr1 的参数 a=0,自增后变成 2
- 第 10 行,incr1 的局部变量 b=1
- 第 11 行,incr1 的返回值被改变为 1
- 之后执行 defer 函数,incr1 的局部变量 a=3,incr1 的局部变量 b=1(注意,这里改变的是 incr1 的局部变量,而不是返回值)
- 返回,返回值依旧是 1
- 有返回值,非匿名返回值(命名返回值)
func incr2(a int) (b int) {
defer func() {
a++
b++
}()
a++
return a
}
func main() {
var a, b int
b = incr1(a)
fmt.Println(a, b) // 输出 0 2
}
过程与上述类似,只不过返回值变成了 incr1 中的 b,在第 8 步时首先被赋值 1,之后再 defer 中又自增,变成 2,因此返回值变成了 2。