总结

函数的参数和返回值都是安装从右到左(从后到先)的方向入栈的,因为go语言的参数和返回值的传递是通过,栈指针+偏移的方式实现的,以这个顺序入栈,callee使用的时候,将会更方便,因为第一个参数离得更近,偏移量更小。

基本知识

术语

  • 栈:每个进程、线程、goroutine都有自己的调用栈,参数和返回值传递,函数的局部变量通常是通过栈进行的,和数据结构中的栈一样,内存栈也是后进先出,地址从高地址向低地址生长
  • 栈帧:(stack frame)又被称为帧,一个栈由很多个帧组成,他描述了函数的调用关系,每一帧都对应着一个尚未返回的函数,帧本身也是以栈的形式存放数据
  • caller:调用者
  • callee:被调用者
  • ESP:栈指针寄存器(extended stack pointer),存放着一个指针,该指针指向栈最上面一个栈帧(即当前执行的函数的栈)的栈顶.注意:ESP指向的是已经存储了内容的内存地址,而不是一个空闲的地址.例如从 0xC0000000 到 0xC00000FF是已经使用的栈空间,ESP指向0xC00000FF
  • EBP:基址指针寄存器(extended base pointer),也叫帧指针,存放着一个指针,该指针指向栈最上面一个栈帧的底部.
  • EIP:寄存器存放下一个CPU指令存放的内存地址,当CPU执行完当前的指令后,从EIP寄存器中读取下一条指令的内存地址,然后继续执行. 注意:16位寄存器没有前缀(SP,BP,IP),32位前缀是E(ESP,EBP,EIP),64位前缀是R(RSP,RBP,RIP)

    汇编指令

  • PUSH:进栈指令,PUSH指令执行时会先将ESP减4,接着将内容写入ESP指向的栈内存.

  • POP :出栈指令,POP指令执行时先将ESP指向的栈内存的一个字长的内容读出,接着将ESP加4.注意:用PUSH指令和POP指令时只能按字访问栈,不能按字节访问栈.
  • CALL:调用函数指令,将返回地址(call指令的下一条指令)压栈,接着跳转到函数入口.
  • RET:返回指令,将栈顶返回地址弹出到EIP,接着根据EIP继续执行.
  • LEAVE:等价于 mov esp,ebp; pop ebp;
  • MOVL:在内存与寄存器,寄存器与寄存器之间转移值
  • LEAL:用来将一个内存地址直接赋给目的操作数 注意:8位指令后缀是B,16位是S,32位是L,64位是Q

    进程在内存中的布局

    严格说来这里讲的是进程在虚拟地址空间中的布局,但这并不影响我们的讨论,所以这里我们就不做区分,笼统的称之为进程在内存中的布局。
    操作系统把磁盘上的可执行文件加载到内存运行之前,会做很多工作,其中很重要的一件事情就是把可执行文件中的代码,数据放在内存中合适的位置,并分配和初始化程序运行过程中所必须的堆栈,所有准备工作完成后操作系统才会调度程序起来运行。来看一下程序运行时在内存中的布局图:
    重要|函数调用栈 - 图2
    进程在内存中的布局主要分为4个区域:代码区,数据区,堆和栈。在详细讨论栈之前,先来简单介绍一下其它区域。

  • 代码区:包括能被CPU执行的机器代码(指令)和只读数据比如字符串常量,程序一旦加载完成代码区的大小就不会再变化了。

  • 数据区:包括程序的全局变量和静态变量(c语言有静态变量,而go没有),与代码区一样,程序加载完毕后数据区的大小也不会发生改变。

  • :程序运行时动态分配的内存都位于堆中,这部分内存由内存分配器负责管理。该区域的大小会随着程序的运行而变化,即当我们向堆请求分配内存但分配器发现堆中的内存不足时,它会向操作系统内核申请向高地址方向扩展堆的大小,而当我们释放内存把它归还给堆时如果内存分配器发现剩余空闲内存太多则又会向操作系统请求向低地址方向收缩堆的大小。从这个内存申请和释放流程可以看出,我们从堆上分配的内存用完之后必须归还给堆,否则内存分配器可能会反复向操作系统申请扩展堆的大小从而导致堆内存越用越多,最后出现内存不足,这就是所谓的内存泄漏。值的一提的是传统的c/c++代码就必须小心处理内存的分配和释放,而在go语言中,有垃圾回收器帮助我们,所以程序员只管申请内存,而不用管内存的释放,这大大降低了程序员的心智负担,这不光是提高了程序员的生产力,更重要的是还会减少很多bug的产生。

函数的执行

根据语言定义的语法编写的函数,在编译是会被编译成一堆机器指令,写入可执行文件。
可执行文件被加载到内存,这些机器指令对应到虚拟地址空间中(如果在32位系统中序列地址空间最大为2*10^32,,约等于4G),位于代码段,如果在一个函数中调用另一个函数,编译器就会生成一条call指令,执行到这条指令时就会跳转到被调用函数入口处开始执行,而每个函数最后都有一个ret指令,负责在函数结束后跳回调用处,继续执行。
image.png

函数调用栈

概念

函数调用栈是指程序运行时内存一段连续的区域,用来保存函数运行时的状态信息,包括函数参数与局部变量等。称之为“栈”是因为发生函数调用时,调用函数(caller)的状态被保存在栈内,被调用函数(callee)的状态被压入调用栈的栈顶;在函数调用结束时,栈顶的函数(callee)状态被弹出,栈顶恢复到调用函数(caller)的状态。

栈布局

函数执行时必须要有足够的内存空间,供它存放状态信息,如caller栈指针,局部变量,callee参数,callee返回值等数据,这段空间对应到虚拟地址空间的栈内存,分配给函数的栈空间被称为函数栈帧,栈底称为栈基栈顶又称为栈指针,一般来说所有的函数调用进来第一件事,就是保护调用者的栈指针,以使得返回时可以恢复调用者的栈指针。
当发生函数调用时,因为调用者还没有执行完,其栈内存中保存的数据还有用,所以被调用函数不能覆盖调用者的栈帧,只能把被调用函数的栈帧“push”到栈上,等被调函数执行完成后再把其栈帧从栈上“pop”出去,这样,栈的大小就会随函数调用层级的增加而生长,随函数的返回而缩小,也就是说函数调用层级越深,消耗的栈空间就越大。栈的生长和收缩都是自动的,由编译器插入的代码自动完成,因此位于栈内存中的函数局部变量所使用的内存随函数的调用而分配,随函数的返回而自动释放,所以程序员不管是使用有垃圾回收还是没有垃圾回收的高级编程语言都不需要自己释放局部变量所使用的内存,这一点与堆上分配的内存截然不同。
**

go语言中函数栈帧布局和其他其他语言是有不同的,go语言的布局是:

  • 旧栈基地址
  • 局部变量
  • callee返回值
  • callee参数(从右到左入栈)
  • 返回地址(下一条指令地址)

如下图所示,被调用者都是通过栈指针+偏移定位到参数和返回值

call指令只做两件事,将下一条指令(**被调用函数执行完后**)的地址(代码区)入栈,跳转到被调用函数入口

image.png

go语言函数栈帧的扩张

程序执行时cpu使用特定的寄存器存储栈基、栈指针、指令指针,如果接下来要执行指令3,那么cpu读取后会将指令指针异向下一条指令,栈指针向下移动入栈数字3
image.png
不过go语言中函数栈帧不是这样逐步扩张,而是一次性分配,也就是分配栈帧时,直接将栈指针移动到,所需最大栈空间位置,然后通过栈指针加上偏移值,这种相对寻址方式,使用函数栈帧,如下图所示
image.png
之所以一次性分配,主要是为了避免栈访问越界,如下三个goroutine,如果g2栈帧空间不够接下来将要调用函数使用,那么执行期间就有可能会出现栈访问越界
image.png
由于函数栈帧的大小可以在编译时期确定,对于栈消耗较大的函数,会在函数头部插入检测代码,如果发现需要进行“栈增长”,就会重新分配一段足够大的栈空间,并把原来栈的数据拷贝过来,原来的栈空间就被释放了
image.png

call指令和ret指令

函数被编译成一条条指令,所以下图中,a1-a2是A函数,b1-b3是B函数,一个函数A在a1处调用b1处的函数B,步骤如下:

call指令

  1. call指令前

    ip在a1,将要执行call指令 bp-sp是a函数的栈帧

image.png

  1. 执行call指令,将返回地址a2入栈,跳转到指令地址b1

    返回地址,B函数执行结束后,执行的指令地址(实际上也就是A函数接下来还未执行的指令) ip 变为b1,即将执行b1

image.png

  1. 开始执行b函数,假设b函数需要使用24字节,所以b1指令先将sp下移24字节,为自己分配足够大的栈帧,sp移动到s7

image.png

  1. 将调用者的bp(栈基)存储到,B函数栈帧的bp(栈基)里,也就是sp增加16字节处,在栈地址s5处

image.png

  1. 接下来执行b3,sp+16字节处,就是B函数栈帧的栈基,在s5处

image.png
ret指令

  1. 在ret指令之前,编译器还会插入两条指令
    1. 恢复调用者栈基,被保存到当前栈帧的栈基处
    2. 释放自己的栈帧空间,分配是向下移动多少,释放是就想上移动多少

image.png

  1. 执行ret指令

    ret指令的作用也有两点:

    1. 弹出call的返回地址
    2. 跳转到这个返回地址

image.png

简单来说,就是每个函数开始都会分配栈帧,结束前有会释放自己的栈帧,函数的调用通过call指令实现,函数调用完毕,又会通过ret指令恢复到call之前的样子,通过这些指令的配合能够实现函数的层层嵌套