函数栈到底是怎么工作的?

参考
  • https://blog.csdn.net/amghost/article/details/38053101

一些细节

  • 局部变量是存储在堆栈中的:函数当中的局部变量参数都是在函数栈上的局部变量,main 函数当中的局部变量都是在main函数栈上的。
  • 在intel x86的系统中,堆栈在内存中是从高地址向低地址扩展:栈顶是栈当中地址最低的地方。
  • 在32位系统中,堆栈每个数据单元的大小为4字节。==小于等于4字节的数据,比如字节、字、双字和布尔型,在堆栈中都是占4个字节的==;大于4字节的数据在堆栈中占4字节整数倍的空间
  • 和堆栈的操作相关的两个寄存器是==EBP寄存器和ESP寄存器==
    一个cup单元包含8个32位的寄存器。每个寄存器可以存储4个字节的信息。其中EBP,ESP保存着指向程序栈的重要信息位置的指针。只有 栈的管理才能修改这两个寄存器的值。也就是说调用函数进入函数都是需要保存和修改这两个寄存器的值,cpu就是通过这两个寄存器的值去处理函数当中的局部变量。

ESP寄存器存放的是当前函数栈顶的—–指针

栈顶指针的值是栈当中地址最低的。

入栈就是减小栈顶指针ESP的值,修改内存相应位置的数据。出栈就是增加ESP的值,并不会清理相应内存当中的值,不需要这样多余的操作。

  • EBP寄存器是用于访问堆栈中的数据的,它指向堆栈==中间的某个位置==,函数的==参数地址比EBP的值高==,而函数的==局部变量地址比EBP的值低==
  • 就可以通过EBP加减一定的偏移来访问参数和局部变量。
  • 栈当中存储什么信息:函数的参数,函数的局部变量,==寄存器的值==(用以恢复寄存器),函数的返回地址以及用于结构化异常处理的数据
  • 这些数据是按照一定的==顺序组织==在一起的,我们称之为一个堆==栈帧==(Stack Frame)
  • 在函数开始时,对应的堆栈帧已经完整地建立了(所有的局部变量在函数帧建立时就已经分配好空间了,而不是随着函数的执行而不断创建和销毁的);在函数退出时,整个函数帧将被销毁。
  • 把函数的==调用者称为Caller==(调用者),被调用的==函数称为Callee==(被调用者)

具体的步骤

  1. 参数入栈 :参数入栈的顺序是由函数的调用约定(Calling Convention)决定的
  2. 返回地址入栈:函数被调用时,会自动把==下一条指令的地址==压入堆栈,函数结束时,从堆栈读取这个地址,就可以跳转到该指令执行了。
  3. 代码跳转到被调用函数执行: 返回地址入栈后,==代码跳转到被调用函数foo中执行==。到目前为止,堆栈帧的前一部分,是由caller构建的
  4. EBP指针入栈:首先将EBP寄存器的值压入堆栈。因为此时EBP寄存器的值还是用于main函数的,用来访问main函数的参数和局部变量的,因此需要将它==暂存在堆栈中==,在函数==退出时恢复==。同时,给EBP赋于新值,==把ESP的值赋给EBP==。
  5. 为局部变量分配地址:程序并不是将局部变量一个个压入堆栈的,而是将ESP减去某个值,==直接为所有的局部变量分配空间==。
  6. 通用寄存器入栈:因为操作函数当中的数据可能需要用到多个通用寄存器,而这些通用寄存器当中正保存着上层函数的数据,所以需要在修改这些寄存器的值之前先将寄存器的值入栈保存,当前函数返回之前恢复通用寄存器的值然后上层函数就可以恢复原来的现场继续工作。

    • 至此,一个完整的堆栈帧建立起来了。

一个完整的堆栈帧建立起来后,在函数执行的整个生命周期中,它的结构和大小都是保持不变的;不论函数在什么时候被谁调用,它对应的堆栈帧的结构也是一定的

  • EBP寄存器保存的是当前函数栈当中的一个特定的位置地址。

返回值

  • caller和callee在这个问题上要有一个“约定”,由于caller是不知道callee内部是如何执行的,因此caller需要从callee的函数声明就可以知道应该从什么地方取得返回值。同样的,callee不能随便把返回值放在某个寄存器或者内存中而指望Caller能够正确地获得的,它应该根据函数的声明,按照“约定”把返回值放在正确的”地方“。
    1. 如果返回值==等于4字节==,函数将==把返回值赋予EAX寄存器==,通过EAX寄存器返回。例如返回值是字节、字、双字、布尔型、指针等类型,都通过EAX寄存器返回。
    2. 如果返回值==等于8字节==,函数将把返回值赋予==EAX和EDX寄存器==,通过EAX和EDX寄存器返回,EDX存储高位4字节,EAX存储低位4字节。例如返回值类型为__int64或者8字节的结构体通过EAX和EDX返回。
    3. 如果返回值为double或float型,函数将把返回值赋予==浮点寄存器==,通过浮点寄存器返回
    4. 返回值是一个大于8字节的数据,将如何传递返回值呢?这是一个比较麻烦的问题

caller会在压入最左边的参数后,再压入一个指针,我们姑且叫它ReturnValuePointer,ReturnValuePointer指向当前ESP值下方很远的一个地址,这个地址将用来存储函数的返回值。函数返回时,把返回值拷贝到ReturnValuePointer指向的地址中,然后把ReturnValuePointer的地址赋予EAX寄存器。函数返回后,caller通过EAX寄存器找到ReturnValuePointer,然后通过ReturnValuePointer找到返回值。你或许会有这样的疑问,函数返回后,对应的堆栈帧已经被销毁,而ReturnValuePointer是在该堆栈帧中,不也应该被销毁了吗?对的,堆栈帧是被销毁了,但是程序不会自动清理其中的值,因此ReturnValuePointer中的值还是有效的。

堆栈帧的销毁

  • 当函数将返回值赋予某些寄存器或者拷贝到堆栈的某个地方后,函数开始清理堆栈帧,准备退出。堆栈帧的清理顺序和堆栈建立的顺序刚好相反
    1. 如果有对象存储在堆栈帧中,对象的析构函数会被函数调用。

    2. 从堆栈中弹出先前的通用寄存器的值,恢复通用寄存器。
    3. ESP加上某个值,==回收局部变量的地址空间==(加上的值和堆栈帧建立时分配给局部变量的地址大小相同)。
    4. 从堆栈中弹出先前的EBP寄存器的值,恢复EBP寄存器。
    5. 从堆栈中弹出函数的返回地址,准备跳转到函数的返回地址处继续执行。
    6. ESP加上某个值,回收所有的参数地址。

函数的调用约定(calling convention)

  • 函数的调用约定(calling convention)指的是进入函数时,函数的参数是以什么顺序压入堆栈的,函数退出时,又是由谁(Caller还是Callee)来清理堆栈中的参数.
  • 在函数定义时加上修饰符来指定:
    1. __cdecl。这是VC编译器默认的调用约定。其规则是:参数从右向左压入堆栈,函数退出时由caller清理堆栈中的参数。这种调用约定的特点是支持可变数量的参数,比如printf方法。由于callee不知道caller到底将多少参数压入堆栈,因此callee就没有办法自己清理堆栈,所以只有函数退出之后,由caller清理堆栈,因为caller总是知道自己传入了多少参数。
    2. __stdcall。所有的Windows API都使用__stdcall。其规则是:参数从右向左压入堆栈,函数退出时由callee自己清理堆栈中的参数。由于参数是由callee自己清理的,所以__stdcall不支持可变数量的参数。
    3. __thiscall。类成员函数默认使用的调用约定。其规则是:参数从右向左压入堆栈,x86构架下this指针通过ECX寄存器传递,函数退出时由callee清理堆栈中的参数,x86构架下this指针通过ECX寄存器传递。同样不支持可变数量的参数。如果显式地把类成员函数声明为使用__cdecl或者__stdcall,那么,将采用__cdecl或者__stdcall的规则来压栈和出栈,而this指针将作为函数的第一个参数最后压入堆栈,而不是使用ECX寄存器来传递了。

你可能感兴趣的:(计算机基础)