栈、栈帧与函数调用

栈是一个先入后出队列。关于算法栈,请自行搜索或参考:https://zh.wikipedia.org/wiki/堆栈 。 在操作系统中,一般用栈来保存函数的状态和局部变量。

Linux的栈位于程序内存空间的末端,从高地址向低地址增长。 在x86架构中,使用esp寄存器指向栈顶的内存地址;在x64架构中,使用rsp寄存器指向栈顶。一般可以简称为sp。

栈帧

栈帧是指函数在被调用时,所拥有的一块独立的用于存放函数所使用的状态和变量的栈空间。

每个函数都对应有一个栈帧。同一个函数多次进入,每次可能会分配到不同的栈帧。整个栈的内容在同一个时刻可以看作是由许多栈帧依序“堆叠”组成的。

对于一个运行中的函数,其使用的栈帧区域被sp和bp寄存器限定(对于x86,sp等价esp,bp等价rsp;对于x64,sp等价rsp,bp等价rbp)。bp指向栈帧的底部,sp指向栈帧的顶部。

在函数中使用的所有变量(本地变量、实参),一般使用bp定位。设N为整型字节数,bp+2N是第一个实参的地址,bp-N是第一个本地变量的地址。

函数调用

一个函数调用,一般需要以下步骤

  1. 保存函数的实参

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

  3. 保存父函数的栈帧信息

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

  5. 执行函数实现的功能

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

  7. 根据保存的父函数栈帧信息,恢复父函数栈帧

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

共有多种函数调用方式,这里介绍一种:stdcall。stdcall是标准调用约定,还有其他调用约定:cdecl、PASCAL、fastcall、thiscall,可以自行搜索。

在stdcall中,调用一个函数func(a,b,c)会有一些比较固定的代码(x86架构)。

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

push c  
push b  
push a

然后执行call指令:

call func

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

push 本call指令下一条指令的地址  
jmp func

执行流到了func函数内部,会先进行父函数栈帧信息的保存。此时esp和ebp依然维持父函数的栈帧。 当前子函数所有的栈中变量被释放后,esp会回到函数调用前的状态,因此无需保存esp,只要保存ebp的信息即可。

push ebp

此时,子函数的栈帧底部变到esp处:

mov ebp, esp

栈帧底部设置完毕后,可以为局部变量开辟空间,这里开辟了一个32(0x20)字节的栈空间:

sub esp, 20h

注意栈是从高地址到低地址增长的,故这里做减法。

然后就可以开始进行函数的操作,通过ebp定位函数的参数和局部变量空间,并将函数返回值放在eax寄存器中。

在函数的功能代码全部执行完毕后,释放之前开辟的栈空间:

add esp, 20h

此时esp恢复到压入ebp之后的状态,这时栈顶为父函数的ebp值,可以依据这个信息恢复父函数的ebp,进而恢复栈帧:

pop ebp

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

retn

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

Last updated