栈以及函数调用过程

171 阅读4分钟

内存空间

内存结构2

  • 栈(Stack :栈保存函数的局部变量(不包括 static 修饰的变量),参数以及返回值。栈是后进先出(LIFO)的数据结构。栈属于静态内存分配。
  • 堆(Heap :堆保存函数内部动态分配(malloc 或 new)的内存,是另外一种用来保存程序信息的数据结构。堆是先进先出(FIFO)数据结构。堆属于动态内存分配。
  • BSS段(Block Started By Symbol Segment :用来存放程序中未初始化和初始化为 0的全局变量的一块内存区域,在程序载入时由内核清零。BSS段属于静态内存分配。
  • 数据段(Data Segment :通常用来存放程序中已初始化的(非 0)全局变量和静态局部变量。数据段属于静态内存分配。
  • 代码段(Code Segment/Text Segment :用来存放程序执行代码的一块内存区域。这部分区域的大小在程序运行前就已经确定,并且内存区域通常属于只读。在代码段中,也有可能包含一些只读的常数变量,例如字符串常量等。 代码段属于静态内存分配。

什么是栈帧?

栈帧是栈的组成部分,一个栈由多个栈帧依序“堆叠”组成。

栈帧是指函数在被调用时,所拥有的一块独立的用于保存函数状态和变量的栈空间。每个函数都对应一个栈帧。同一个函数多次调用,每次可能会分配到不同的栈帧。

假设一个引用占用4个字节,则栈的结构如下图所示:

执行中的栈帧2

使用的寄存器

  • ebp :基址指针寄存器,指向栈帧的底部(高地址)。
  • esp:栈指针寄存器,指向栈帧的顶部(低地址)。
  • eip:指令指针寄存器,指向即将执行的程序指令的地址。

栈帧寄存器

函数调用

一个函数调用,一般需要以下步骤,以stdcall进行函数调用func(a,b,c)为例:

  1. 保存函数的实参:

    父函数调用时,会把参数从右至左入栈,实现保存函数实参的功能。

    push c  
    push b  
    push a
    
  2. 保存子函数结束后,需要返回的地址(返回到哪里):

    执行call指令。

    call func
    

    call指令实际上做了两个工作,一个是将这个call指令的下一条语句入栈,实现返回地址的保存。然后把执行流跳转到函数里。所以一个call指令从功能上可以拆分为以下两个指令。

    push 本call指令下一条指令的地址  
    jmp func
    
  3. 保存父函数的栈帧信息:

    执行流到了func函数内部,会先进行父函数栈帧信息的保存。此时espebp依然维持父函数的栈帧。 保存父函数的ebp

    push ebp
    

    当前子函数所有的栈中变量被释放后,esp会回到函数调用前的状态,因此无需保存esp,只要保存ebp的信息即可。

  4. 在栈上开辟空间供局部变量使用:

    子函数的栈帧底部变到esp处。

    mov ebp, esp
    

    栈帧底部设置完毕后,为局部变量开辟空间,假设开辟一个32(0x20)字节的栈空间。

    sub esp, 20h
    
  5. 执行函数实现的功能;

  6. 释放局部变量使用的空间:

    add esp, 20h
    
  7. 根据保存的父函数栈帧信息,恢复父函数栈帧:

    此时栈顶为父函数的ebp值,可以依据这个信息恢复父函数的ebp,进而恢复栈帧。

    pop ebp
    
  8. 根据保存的返回地址,恢复父函数执行流,一般是函数调用指令后的下一条指令:

    当前栈顶为返回地址,这时父函数的栈信息已经恢复,只要根据这个返回地址更改执行流,回到父函数call func指令的下一条指令即可。

    retn
    

至此,一个函数的调用流程结束,栈的状态和调用前完全一致,子函数的返回值被存在eax寄存器中