文章目录
- GoLang之函数调用栈系列二(视频版)
- 1.栈帧布局变化
- 1.1栈帧介绍
- 1.2call指令做两件事
- 1.3栈扩张
- 1.4Go语言中栈扩张
- 2.函数call跳转与ret返回
- 3.函数有参(int)无返回值
- 4.函数有参(*int)无返回值
- 5.函数有参(int)有匿名返回值
- 6.函数有参(int)有命名返回值
- 7.调用多个函数
GoLang之函数调用栈系列二(视频版)
1.栈帧布局变化
1.1栈帧介绍
go语言函数栈帧布局是这样的,先是调用者栈基地址
接下来是局部变量
然后是调用函数的返回值
最后是参数
1.2call指令做两件事
call指令只做两件事:
第一:将下一条指定的地址入栈,这就是返回地址,被调用函数执行结束后会跳回到这里
(从现象看,返回地址是被CALL指令压栈的,既不是调用者分配的,也不是被调用者分配的;
逻辑上看,调用者不会访问栈上的返回地址以及位于返回地址之下的地方。但被调用者的视角看,从上到下依次是参数、返回地址、局部变量,所以应该算是被调用者的栈帧,也就是可以理解为被调用者栈帧第一个存的是return addr,第二个存的是调用者bp。)
第二,跳转到被调用函数的入口处开始执行,这后面就是被调用函数的栈帧了
所有的函数的栈帧布局都遵循统一的约定,所以,被调用者是通过栈指针加上相应的偏移来定位到每个参数和返回值的
1.3栈扩张
程序执行时,CPU用特定的寄存器来存储运行时栈基(bp)与栈指针(sp),同时也有指令指针寄存器用于存储下一条要执行的指令地址
如果接下来要执行"把3入栈"这条指令,cpu读取后会将指令移向下一条指令,然后栈指针向下移动,把3存进去
继续下一条指令,再次移动栈指针,把“数字4”入栈
1.4Go语言中栈扩张
不过go语言中的栈不是逐步扩张的,而是一次性分配,也就是在分配栈帧时,直接将栈指针移动到所需最大栈空间的位置,然后通过栈指针+偏移值这种相对寻址方式使用函数栈帧,例如这里sp加16字节处存储3,加8字节存储4,诸如此类
之所以一次性分配,主要是为了避免栈访问越界,就像这里三个goroutine,如果g2的栈用到这里了,接下来要执行的函数要用如下那么大的空间,若函数是逐步扩张的话,执行期间就可能发生栈访问越界。由于函数栈帧的大小可以在编译时期确定,对于栈消耗较大的函数,go语言的编译器会在函数头部插入检测代码,如果发现需要进行“栈增长”,就会另外分配一段足够大的栈空间,并把原来栈上的数据拷贝过来,原来这段栈空间就被释放了
2.函数call跳转与ret返回
接下来我们看看call指令与ret指令是怎样实现函数跳转与返回的:
一个函数A在a1处调用b1处的函数B,跳转前寄存器和栈的情况是这样
然后到call指令这里,它的作用有两点:
第一:把下一条指令执行地址a2入栈保存起来
第二:跳转到指令执行地址b1处,call指令就结束了
函数B开始执行,先把sp向下移动24字节,为自己分配足够大的栈帧,所以栈指针移到s7(被对调用者栈空间是由调用者维护的,也就是这里的参数和返回值)
接下来是b2这条指令,要把调用者栈基s1存到sp+16字节的地方
接下来b3把sp+16存入栈基寄存器,即函数的栈基,接把它存入bp寄存器
下来就是执行函数B剩下的指令了
在ret指令之前,编译器还会插入两条指令:
第一条,指令恢复调用者A的栈基地址,它之前被存储在sp+16字节那里
第二条,指令释放自己的栈帧空间,分配时向下移动多少,释放时就向上移动多少
然后就到ret指令了,它的作用也有两点:
第一,弹出call指令压栈的返回地址
第二,指令指针寄存器跳转到这个返回地址,ok现在可以从a2这里继续执行了
简单来说,函数通过call指令实现跳转,而每个函数开始时会分配栈帧,结束前又会释放自己的栈帧,ret指令又会把栈恢复到栈之前的样子,通过这些指令的配合能够实现函数的层层嵌套。如果函数A调用函数被B,B调用C,C调用D,就会形成这样的栈
如果每次都是调用的都是A,就是递归调用栈了
3.函数有参(int)无返回值
先来看看有参数的情况,这里有一个swap函数,接受两个整形参数,main函数yao通过swap函数来交换局部变量的值到哪但失败了
我们通过函数调用栈看看问题出现在哪了,假设main函数栈帧分配如下在这里,先分配局部变量存储空间,a=1、b=2
这里调用的函数没有返回值,所以局部变量后面就是给被调用函数传入的参数,需要传入两个参数,传参就是值拷贝,参数是值 拷贝,所以拷贝整型变量值,注意:参数入栈顺序是由右至左,先入栈第二个参数再入栈第一个参数,返回值也是一样,这样的话被调用函数通过SP加偏移地址寻址就比较方便了
调用者栈帧后面是call指令存入的是返回地址,再下面分配的就是swap函数栈帧了
当swap函数执行到a,b = b, a是要交换两个参数的值,交换后结果如下图,交换失败的原因找到了
4.函数有参(*int)无返回值
依然要交换两个整型变量的值,但是参数类型改为整型指针,这一次交换成功了,通过“函数调用栈”看看和上一次的有和何不同;
main函数栈帧先分配给局部变量。然后分配参数空间,参数是指针,传参都是值拷贝,这里拷贝的都是a和b的地址,依然由右至左,先入栈B的地址,再入栈A的地址。再后面是返回地址以及swap函数栈帧
sawp执行到*a,*b=*b,*a时交换的是这两个指针指向的数据,也就是这两个地址的数据,所以这一次可以交换成功
5.函数有参(int)有匿名返回值
通常我们认为返回值是通过寄存器传递的,但是Go语言支持多返回值,所以在栈上分配返回值空间更合适;
下面看一个例子,这里是main函数调用incr函数,然后赋给局部变量b
func Demo2(first int) bool {
return true
}
func main() {
a := 4
Demo2(a)
Demo2(m) //在这一行会出错,显示引用不到m变量
m := 9
fmt.Println(m)
}
来看看函数调用栈的情况,main函数栈帧,先是局部变量a=0,b=0,go语言函数栈帧布局中返回值在参数之上,所以incr的返回值在图中所示,初始化为类型零值,然后是参数空间,传参是值拷贝。然后到incr函数栈帧那里,保存调用者的栈基地址后,初始化局部变量b,执行到a++后要把参数自增1
下一步,执行b=a把参数a赋给局部变量b,
执行到return b 后,必须要明确一个关键问题,我们说过函数最后有编译器插入的指令来负责释放函数栈帧,恢复到调用栈者,但在这之前要给返回值赋值并执行defer函数,那么谁先谁后呢,答案是先赋值再defer;
所以会先把局部变量 b的值拷贝到返回值空间
然后再执行注册的defer函数,defer函数里局部变量a先自增,b也再自增,然后incr结束,返回值为1,赋给main函数的局部变量b,所以会输出0与1
6.函数有参(int)有命名返回值
其他都不变,只把这里的局部变量b改成命名返回值,main函数栈帧遇上个例子完全相同,到incr函数这里没有局部变量
当执行到a++这一步时参数a自增1
执行到return a那里先把参数a赋给返回值b
然后 执行defer函数,参数a自增1,然后返回值b也自增1
然后incr结束,返回值最终为2,所以mian的main局部变量赋值为2,最终输出0和2
7.调用多个函数
如果一个函数A调用了两个函数B和C。但是这两个函数的参数和返回值,占用的空间并不相同,我们知道Go语言的函数栈帧是一次性分配的,如果局部变量占这么大,这后面还要以最大的参数加返回值空间为标准来分配,才能满足所有被调函数的需求
B的参数和返回值可以把这里占满没有问题
但是B结束后调用C时,它的参数和返回值,只会占用下面这段空间,虽然上面空出来一块,但是被调用者,通过栈指针相对寻址自己的参数和返回值时会比较方便