Golang-函数调用栈细节全都知道了?

161 篇文章 12 订阅

栈:一种【先进后出】的数据结构,相当于一个容器。

当需要往容器里添加元素时,只能放在最上面元素之上,取出元素时,也只能从最上面的元素开始往外取,通常将添加操作称为入栈(push),取出操作称为出栈(pop)。

以快餐店吃饭为例,店内一般有一摞干净的盘子让顾客取用,类比为栈,取盘子通常是拿走最上面那一个(pop),当盘子快被拿完的时候,服务员会拿一些干净的盘子放在之前的盘子上面(push),取放盘子这一端用栈的术语来说叫做栈顶和栈底。

来看栈pop和push操作流程图,如下:

之后来看进程在内存中的布局,严格来说是进程在虚拟地址空间中的布局。

操作系统把磁盘上的可执行文件加载到内存之前,会做很多工作。最重要的一个环节就是把可执行文件中的代码、数据放在内存中合适的位置上,并分配和初始化程序运行中所必须的堆和栈,当所有操作完成后,操作系统才会调度程序运行起来。

程序运行时在内存中的布局如下:

进程在内存中的布局主要分为四个部分:代码区、数据区、堆和栈,先来看下除栈之外的其它三个部分,如下:

  1. 代码区。包括能被CPU执行的机器代码(指令)和只读数据(比如字符串常量),当程序加载完毕后,代码区大小不会再进行变化。

  2. 数据区。包括程序的全局变量和静态变量(C有,Go没有),与代码区一样,当程序加载完毕后,代码区大小不会再进行变化。

  3. 堆。程序运行是动态分配的内存都位于堆中,这部分内存由内存分配器进行管理。

堆大小随程序运行而进行改变,也就是说,当向堆请求分配内存,但内存分配器发现堆内存不足时,内存分配器会向操作系统内核申请向高地址方向扩展堆的大小,反之当释放内存将其归还于堆时,若果内存分配器发现空闲内存太多,则会向操作系统内核请求向低地址方向收缩堆的大小。

从上述内存的申请和释放流程可以看到,从堆上分配的内存用完之后必须归还给堆,否则内存分配器可能会反复的向操作系统内核申请扩展堆的大小,从而导致堆内存越用越多,最后出现内存不足的情况,也就是所谓的内存泄露。

传统的C/C++处理内存的分配和释放必须慎之又慎,而Go则有垃圾回收器负责处理,程序只管申请内存,而不需操心内存的释放。

接下来看下本文的主角,函数调用栈,可简称栈。

程序运行过程,不管是函数的执行还是调用,栈都起着非常重要的作用,用途如下:

  1. 保存函数的局部变量。

  2. 向被调用函数传递参数。

  3. 返回函数的返回值。

  4. 保存函数的返回地址。

返回地址是指从被调用函数返回后调用者应该继续执行的指令在内存中的地址。

每个函数在执行过程中都需要用栈来保存上述的值,此时被使用的栈就称为这个函数的栈帧(stack frame)

当函数发生调用时,因为调用者还没有执行完,其栈中保存的数据还有用,所以被调用的函数不能覆盖调用者的栈帧,只能把被调用函数的栈帧push到栈上,等被调用函数执行完毕后,再将其栈帧从栈上pop出去,如此,栈大小就会随着函数调用层级的增加而生长,随着函数的返回而缩小,也可理解为,函数调用层级越深,消耗栈空间就越大。

栈的生长和收缩都是由编译器插入的代码自动完成的,因此位于栈中的局部函数变量所使用的内存会随函数的调用而分配,随函数的返回而自动释放,所以不管是有没有垃圾回收的高级编程语言都不需要自己去释放局部变量所使用的的内存,这点和堆上分配的内存迥然不同。

ADM64 Linux下,栈是从高地址向低地址方向生长的,那为啥栈会采用这种反常的生长方向呢?

具体原因现在没办法考究,不过看上面那张进程的内存布局图可猜测,当初这么设计的计算机科学家是希望尽量利用内存地址空间,才采用了堆和栈相向生长的方式。因为程序运行之前无法确认堆和栈谁会消耗更多的内存,如果栈和堆一样向高地址方向生长的话,栈底的位置就不好确定了,离堆太近又有可能会导致堆内存不够用,离堆太远又可能导致栈内存不够用,于是就采用了现在的这种相向的生长方式。

ADM64 CPU提供了两个与栈相关的寄存器,如下:

  1. rsp寄存器,始终指向函数调用栈栈顶元素。

  2. rbp寄存器,一般用来指向函数栈帧的起始位置。

接下来用两个图例来说明函数调用栈以及rsp/rbp寄存器与栈之间的关系。

假设有如下函数调用链,且正在执行C():

A()->B()->C()

那么函数A、B、C的栈帧以及rsp/rbp寄存器的状态,大致会如下图所示(提醒:栈从高地址向低地址方向生长):

说明如下:

  1. 调用函数时,参数和返回值是存在调用者的栈帧之中,而不是被调用函数栈之中。

  2. 当前正在执行C,且调用链为A->B->C,以栈帧维度来看,C栈帧目前位于栈顶。

  3. CPU硬件寄存器rsp指向整个栈的栈顶,也指向C的栈帧的栈顶,而rbp则指向C函数的栈帧的起始位置。

  4. 图中A、B、C三个栈帧看起来大致相似,但在真实程序中,每个栈帧的大小因为不同函数局部变量的个数以及所占内存的多少而不尽相同。

  5. 有些编译器(如:gcc)会把参数和返回值放在寄存器中而不是栈中,Go参数以及返回值则都是放在栈上的。

随着程序运行,C、B执行完毕且返回到A中继续执行,此时状态如下:

因为C、B都执行完毕且返回到A中,所以C、B的栈帧都已经被pop出栈了,也就是说它们所消耗的栈内存已经被自动回收了,也因为正在执行A,所以寄存器rsp和rbp只想的都是A函数的栈中的相应位置。

如果A执行过程中有调用到了函数D,则栈中的变化如下:

可以看到,现在D用的栈帧其实是之前B、C所使用的栈内存,这没什么影响,因为B、C已经执行完毕了,现在只是D重新使用这块儿内存而已。

上述问题也是C中为什么不让返回函数局部变量的地址,因为同一个地址的栈内存会被重新使用,会造成意外的bug,但Go没有这方面的顾虑。因为Go编译器比较智能,当它发现程序返回了某个局部变量的地址,编译器会把这个变量放到堆上去,而不会放到栈上。还需注意的是,现在rsp和rbp这两个寄存器此时都指向了D的栈帧。

综合上面的分析,可以看出rsp和rbp这两个寄存器始终指向正在执行的函数的栈帧。

最后,看一个Go递归的例子,案例代码如下:

func f(n int) {   if n <= 0 { //递归结束条件 n <= 0       return  }   ......   f(n - 1) //递归调用f函数自己   ......}

由上述代码可知,f是递归函数,它会一直递归的调用自己到参数n小于等于0,如果在它某个函数里调用了f(10),且现在正在执行f(8),则其栈状态如下图:

由上图可知,即使是同一个函数,每次调用也会产生不同的栈帧,因此对于递归函数来说,每递归一次都会消耗一定的栈内存,如果递归的层数太多,就有可能导致栈溢出,这也是为什么实际开发中尽量避免使用递归函数处理问题的原因之一,另外一个原因就是递归函数执行效率低,因为它需反复调用函数,而调用函数就会有较大的性能开销。

本文到这里就结束了,总结来看的话,主要说明的就是栈基本概念及其在程序运行过程中的作用,有部分细节没有聊到,后续文章中会结合实际来说明。

各位喜欢的话,就来个三连击吧。

获取更多优质内容,请扫码关注公众号获取。   

 

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

luyaran

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值