C++函数调用过程解析

编译环境:Windows 10 + VS2015。

0、引言

函数调用的过程实际上也就是一个中断的过程,本文演示和深入分析参数入栈、函数跳转、保护现场、恢复现场等函数调用过程。

首先对三个常用的寄存器进行说明:

  • EIP:指令指针,即指向下一条即将执行的指令的地址。
  • EBP:基址指针,常用来指向栈底。
  • ESP:栈指针,常用来指向栈顶。

先看简单程序,并在Visual Studio 2015中查看并分析汇编代码。

图 1

1、函数调用

g_fun函数调用的汇编代码如图2所示,调用g_fun函数(call指令)之前,EBP保存main函数栈基地址:0x0113FC28。

图2

调用call指令之前,需要执行三条push指令,分别将三个参数压入栈中。执行三条push指令指挥,可以查看栈中的数据进行验证(从汇编指令可以看出,参数压栈顺序为从右向左)。如图3所示,从右边的实时寄存器表中可以看到ESP(栈顶指针)值为0x0113FB50,然后从内存表中找到内存地址0x005BFD08处,可以看到内存依次存储了0x00000001(即参数a),0x00000002(即参数b),0x00000003(即参数c),此时栈顶存储的是三个参数的值,说明压栈成功。

图3

然后可以看到call指令跳到地址0x02C1302。继续执行,可以看到指令调转到0x02C1700。此时,EBP值依然是0x0113FC28(main函数栈基址),说明仍然运行main函数中的指令,暂未跳转至g_fun函数基址0x0C1700。

图4

执行jmp指令后,调转到了g_fun函数内部,图5显示0x0C1700确实是g_fun函数起始地址,如此实现了到g_fun函数的跳转。

图5

2、保存现场

此时,查看栈中数据,如图6所示,此时ESP(栈顶)值为0x0113FB4C,在内存表中可以看到栈顶存放的地址是0x002C1769,下面还是前面压栈的参数(1,2,3)。也就是执行call指令后,系统默认的往栈中压入了一个数据(0x002C1769),再看图3,call指令后面一条指令的地址就是0x002C1769,实际上就是调用函数结束后需要继续执行的指令地址,函数返回后会跳转到该地址。这就是我们常说的函数中断前的“保护现场”。这一过程是编译器隐含完成的。实际上是将EIP(指令指针)压栈,即隐含执行了一条push eip指令,在中断函数返回时,再从栈中弹出该值到EIP,程序继续往下执行。

图6

继续往下执行,进入g_fun函数后第一条指令是push ebp,即将epb入栈。因为每一个函数都有自己的栈区域没所以基地址也是不一样的。现在进入了一个中断函数,函数执行过程中也需要ebp寄存器,而在进入函数之前的main函数的ebp值怎么办呢?为了不覆盖,将它压入栈中保存。执行push ebp指令后,查看寄存器和内存中数据显示EBP存放的地址(main函数基地址0x0113FC28)确实压入ESP所指向的栈顶地址0x0113FB48。

图7

下一条mov ebp, esp将此时的栈顶地址作为该函数的栈基址,确定g_gunc函数的栈区域,EBP栈底地址为0x0113FB48,ESP栈顶地址为0x0113FB48。

图8

再往下的指令是sub esp,D8h,指令的字面意思是将栈顶指针往上移动D8h Byte。这个区域为间隔区域,将两个函数的栈区域隔开一段距离。如图8所示。而该间隔区域大小固定为D0h,即208Byte,然后还要预留出存储局部变量的内存区域。g_fun函数有两个局部变量x和y,所有esp需要移动的长度为D0h+8h=D8h。

图9

执行sub esp,D8h指令后,EBP栈基址不变为,仍为0x0113FB48。ESP栈顶地址在0x0113FB48基础上往上移动往上移动D8h Byte,变为0x0113FA70。如图10所示:

图10

接下来的三条压栈指令,分别将EBX,ESI,EDI压入栈中,这也是属于保护现场的一部分,这些是属于main函数的一些数据。EBX,ESI,EDI分别为基址寄存器,源变址寄存器,目的变址寄存器。

图11

接下来的几条指令(如下)是刚才留出的D8h的内存区域赋值为0x0CCCCCCCh。

002C170C  lea         edi,[ebp-0D8h]  
002C1712  mov         ecx,36h  
002C1717  mov         eax,0CCCCCCCCh  
002C171C  rep stos    dword ptr es:[edi]

如图12所示:

图12

3、执行函数

继续往下看,接下来是局部变量x和y的赋值,汇编指令中怎样去计算x和y的地址呢?如图13所示,是基于ebp去计算的,分别是[ebp-4-4]和[epb-4-8],为什么需要多每次计算多要先行上移4个Byte呢?应该是变量之间增加间隔区域(固定值为4 Byte),保护变量之间互不影响,跟函数间隔区域类似。查看内存表可以看到响应的内存区域已经存入了0x11111111和0x22222222。

图13

此时我们对整个内存中存储的内容应该非常清晰了。如图14所示:

图14

4、恢复现场

这时,子函数部分的代码已经执行完毕,继续往下看,编译器会做一些事后处理工作,如图15所示。首先是三条出栈指令,分别从栈顶读取EDI,ESI和EBX值。从图9的内存数据分别我们可以得知此时栈顶的数据确实是EDI,ESI和EBX,这样就恢复了调用前的EDI,ESI和EBX值。这是“恢复现场”的一部分。

图15

第四条指令是mov esp,ebp即将epb的值赋给esp。什么意思呢?看看图14的内存数据分布就明白了,这条语句是让ESP指向EBP所指向的内存单元,也就是让ESP跳过一段区域,很明显掉过的区域恰好是间隔区和局部数据区域,因为函数已经退出了,这两个区域都已经没有用处了。实际上这条语句是进入函数时创建间隔区的语句sub esp,D8h的相反操作。这也刚好说明了调用函数时在栈上自动申请内存,调用结束后自动释放内存的操作。

图16

再往下是pop ebp,我们从图14的内存数据分布可以看出此时栈顶确实是存储的前EBP值,这个就恢复了调用前的EBP值(0x0113FC28),这也是“恢复现场”的一部分。该指令执行完后,内存数据分布如图17和图18所示。

图17

图18

再往下是一条ret指令,即返回指令。注意再执行指令前ESP值和EIP值(如图19所示),ESP指向栈顶地址0x0113FB4C存放的是地址0x002C1769(调用g_fun函数call指令的下一个指令地址)。

图19

执行ret指令后,查看ESP和EIP值(如图20所示),此时ESP为0x0113FB50,即往下移动了4Byte。显然此处编译器隐含执行了一条pop指令。这个值怎么这么熟悉呢!它实际上就是栈顶的4Byte数据,所以这里隐含执行的指令应该是pop eip。而这个值就是前面讲到过的,在调用call指令前压栈的call的下一条指令的地址。从图20中可以看出,正是因为EIP的值变成了0x002C1769,所以程序跳转到了call指令后面的一条指令,又回到了中断前的地方,这就是所谓的恢复断点。

 图20

还没有完全结束,此时还有最后一条指令add esp, 0Ch。这个就很简单了,从图20中可以看出现在栈顶的数据是1,2,3,也就是函数调用前压入的三个实参。这是函数已经执行完了,显然这三个参数没有用处了。所以add esp, 0Ch就是让栈顶指针往下移动12Byte的位置,ESP地址由0x0113FB50变成0x0113FB5C。为什么是12Byte呢,很简单,因为入栈的是3个int数据。这样由于函数调用在栈中添加的所有数据都已清除,栈顶指针(ESP)真正回到了函数调用前的位置,所有寄存器的值也恢复到了函数调用之前。如图21所示:

图21

到处为止,函数调用结束。 

posted @ 2021-03-31 16:41  钟齐峰  阅读(1927)  评论(0编辑  收藏  举报