关于函数的调用堆栈

堆栈的概念


堆栈一般指的就是栈,一种内存中存取数据的方式,最大的特点就是后进先出,一个只有一个门的仓库一样,最后放入的东西往往被先拿出来,这种存取模型就叫作栈。

函数的调用堆栈过程

函数的创建都是创建在内存的栈区里面的,所以函数的调用方式也是一种后进先出:


我们先来看一段代码:

#include<stdio.h>
#include<stdlib.h>
int add(int s1, int s2)
{
    int sum = 0;
    sum = s1 + s2;
    return sum;
}
int main()
{
    int a = 2;
    int b = 3;
    int ret = 0;
    ret = add(a,b);
    printf("%d\n",ret);
    system("pause");
    return 0;
}

代码非常的简单:即用函数的方式实现整数的加法。
首先将这段代码放在vc6.0中(因为vc6.0这个编译器设置很简便,没有其他编译器那么臃肿,所以好理解),按f10进入调试,然后右键进入反汇编(因为汇编代码描述的根内存有直接的联系,而且也很容易看的懂)。

  1. 在第一步之前我们需要知道一个东西,那就是 mainCRTStartup()这个函数是vc里面连接器对控制台程序设置的入口函数,现在我们不需要知道它是用来干什么的,但是我们需要知道是它来调用我们的main函数,进行相关操作的。然后我们需要知道两个寄存器,ebp和esp,在栈里面ebp指的是栈的底部的地址,而esp指的就是(已用)栈顶部的地址,也就是你每次往栈里面压入一个东西,esp就会向上挪动一次,保证esp一直指的是栈顶。那么在main函数还没有被调用之前,我们可以这样表示栈的结构:
    函数栈帧
  2. 按一下f10让我们的程序走起来,也就是开始main函数的调用:让我们一句c语言来的分析汇编代码:
00E61810  push        ebp  
00E61811  mov         ebp,esp  
00E61813  sub         esp,0E4h
00E61819  push        ebx  
00E6181A  push        esi  
00E6181B  push        edi  
00E6181C  lea         edi,[ebp-0E4h]  
00E61822  mov         ecx,39h  
00E61827  mov         eax,0CCCCCCCCh  
00E6182C  rep stos    dword ptr es:[edi] 
  • 第一句是将ebp压入栈中,也就是将ebp寄存器的内容压入栈里面,而当前的ebp指的就是mainCRTStartup函数的栈底的地址,为什么要保存呢,之后再讲。
  • 第二句是将esp的值赋给ebp,说白了就是将ebp所指向的内容改变为当前的栈的顶部
  • 第三句是将esp的值减去16进制数的0E4h个,首先我们要知道内存中栈是由高地址向低地址使用,那么减去0E4h就是向再去开辟0E4h的栈空间,假设我们现在使用的栈的方向的上高下低,也就是esp向上挪0E4h个空间
  • 第四句到第六句分别压入了 ebx,esi和edi,这个暂时先不考虑。
  • 第七句lea指的就是加载有效地址,也就是是将ebp-0E4h的结果放在edi里面,什么意思呢?执行第三句时esp和ebp的值是一样的,也就是指向同一个地方,当第三句执行了,因为站的空间多加了,所以esp会向上挪动,但是ebp一直没变,所以现在的edi指向的就是开辟0E4h之后的最顶部的位置
  • 第八句和第九句都是赋值
  • 第十句的意思是从edi处向下拷贝eax的值拷贝ecx次,一次拷贝dword即双字,一个字是两个字节,双字就是四个字节,那么就是拷贝39h * 4 个字节结果刚好是0E4h,所以这句话的意思就是将刚才开辟的0E4h个字节的空间全部初始化为 cch
    main()执行完了,你没有发现系统执行main()这句话时就是在给main函数开辟空间,的确是这样的。那么用图来表示就可以为:
    函数栈帧
    3.main函数的变量
    mian()执行完成之后就为main函数开辟了空间,接下来就要创建main函数的局部变量,汇编代码继续走起:
int a = 2;
00E6182E  mov         dword ptr [ebp-4],2
int b = 3;
00E6182E  mov         dword ptr [ebp-8],3  
int ret = 0;
00E6182E  mov         dword ptr [ebp-0Ch],0 
  • 第一句的意思为从ebp向上的四个字节的位置放入常量2,注意,2在放入的时候时从低地址向高地址放的,而2也就刚好占用四个字节,很奇妙,其实这就是局部变量的创建过程。
  • 第二三句也是一样的,这里就不一一解释了。

放入之后main函数的占空间就变成这样了:
函数栈帧

4.继续汇编代码:

00E61843  mov         eax,dword ptr [ebp-8]  
00E61846  push        eax  
00E61847  mov         ecx,dword ptr [ebp-4]  
00E6184A  push        ecx 
  • 第一句将ebp-8的地址所放的值放在寄存器eax中,并压入栈中
  • 第二句是将ebp-4的地址所放的值放入ecx中,并压入栈中
    这两句就是将刚才创建的变量通过寄存器传值再次压入栈中,注意这块儿是先压后创建的变量b,再压的a。

用图来表示就是:
函数栈帧
5. 继续看汇编代码:

00E6184B  call        _add (0E61122h)  
00E61850  add         esp,8  
  • 下一句是call语句,也就是跳转语句,转去另一条指令_add后面的遗传16进制数字就是转至的位置,call在被运行时,首先会将call指令的下一条指令的地址压入栈中,至于原因,一会儿就会遇到。
  • 注意现在add指令还没运行,但是他的地址已经压入了栈中。
    图像表示为:
    函数栈帧

当这时按一下f11就会跳转到被跳转的指令,也就是add函数中。
6.继续汇编代码:

00E61122  jmp         add (0E617C0h) 
  • 指令已经跳转过来了,但是过来时候是一句jmp语句,即跳转到后面的函数,也就是add函数地址为(0E617C0h)

7.再次按下f10时就进入了add函数,看汇编代码:

00E617C0  push        ebp  
00E617C1  mov         ebp,esp  
00E617C3  sub         esp,0CCh  
00E617C9  push        ebx  
00E617CA  push        esi  
00E617CB  push        edi  
00E617CC  lea         edi,[ebp-0CCh]  
00E617D2  mov         ecx,33h  
00E617D7  mov         eax,0CCCCCCCCh  
00E617DC  rep stos    dword ptr es:[edi]  
  • 你有没有发现这些代码好熟悉,哈哈,没错,这和main函数创建时的代码是一样的,目的是为函数开辟空间。

这时的内存栈状态是这样的:
函数栈帧

8.继续看汇编代码:

00E617DE  mov         dword ptr [ebp-4],0  
    sum = s1 + s2;
00E617E5  mov         eax,dword ptr [ebp+8]  
00E617E8  add         eax,dword ptr [ebp+0Ch]  
00E617EB  mov         dword ptr [ebp-4],eax  
    return sum;
00E617EE  mov         eax,dword ptr [sum]  
  • 相信现在的你可以自行看懂这指令了
  • 第一句ebp-4的地址放上0,也就是在add函数里面申请四个字节放入 0
  • 第二句注意了,如果你对着图看会发现 ebp+8 所指的内容刚好就是刚才通过寄存器压入的数。所以也就是刚才通过寄存器传值压入的变量就是add函数的参数s1和s2,但是传值的时候是先穿的是b的值,再是a的值,顺序刚好和变量创建的顺序相反。
  • 第三句就是将ebp+0Ch地址的值与eax相加并将结果放入eax中。而原来的eax放入的就是上一句传来的值,即ebp+8地址的值。
  • 第四句就是将eax也就是计算结果放在epb-4的地址,也就是sum里面
  • 第五句就是将sum的值传入eax。

9.下面继续看汇编代码:

009717F1  pop         edi  
009717F2  pop         esi  
009717F3  pop         ebx  
009717F4  mov         esp,ebp  
009717F6  pop         ebp  
009717F7  ret  
  • 首先是pop指令,第一句就是将esp当前所指的内容赋给edi,再将其弹出,即esp向下移四个字节。下面两条pop指令是一样的。
  • 接着是将ebp 的内容赋给esp,即让esp直接指向add函数的栈底。
  • 这里要解释一下第五句,因为当前ebp和esp指向的都是add函数的栈底,所以如果再进行pop指令的话,弹出的就是刚才为add函数开辟空间之前压入的main函数的栈底的地址,所以现在这句指令正好就是将ebp重新指向mian函数的栈底。所以现在终于知道刚才开辟add函数的时候为什么要把ebp压入栈中了吧。
  • 最后一步是ret 这个指令的意思是再次弹出一个值,并将转移去执行此数值地址的命令,如果你看着图片,那么你会发现一个很巧妙的现象,现在弹出来的值正好是main函数中调用add函数下一句的地址,那么这句话的意思正好就是add函数调用完成返回main函数下一句的动作。
  • 其实细想一下,这组指令的动作就是将add函数所创建栈空间销毁,但是把函数的结果却存在了寄存器eax中。

图示如下:
函数栈帧
10.继续按f10 下一步将会返回main函数的指令,回来的位置刚好是call add的下一条指令,继续看汇编代码:

00971850  add         esp,8  
00971853  mov         dword ptr [ebp-0Ch],eax
  • 返回来之后第一局就是esp+8,即esp向下移动8个字节,其实,这个动作正好是将之前复制的a和b变量,也就是创造的形参销毁
  • 随后是将eax的值赋给ebp-0Ch地址的空间,结合图看的话会发现ebp-0Ch正号就是main函数创建的用来接收结果的变量ret,而刚才add函数正号将结果送给了eax,所以现在正好将eax的值又传回来,真的非常的巧妙难道不是吗?
  • 其实函数进行到这儿,我们就已经明白了函数的调用方式,下面我们来总结一下。

总结

  1. 首先是main函数是被调用的,不要认为程序直接从main函数开始。
  2. 程序在运行时,都是先给函数开辟足够的空间(包括main函数),然后在进行各种变量的创建,赋值,计算。
  3. 函数在传参时,除了数组(或者结构体)以外,传参的方式都是值传递,也就是变量的一份临时拷贝,并且传参的顺序与函数调用的顺序相反,另外一点不是很重要,参数函数在销毁之后,为其创建的形参也随后销毁。
  4. 函数在返回值的时候是先将结果放入一个寄存器,然后通过寄存器返回值。

猜你喜欢

转载自blog.csdn.net/qq_38590948/article/details/80094986