【C】深入浅出之栈帧

  首先,从调试的角度来看下面这段代码,

  可以发现这是一个死循环,那么这是为什么呢?

  且听我慢慢道来,在执行main函数时,栈区会为其开辟一片空间,高地址在下,低地址在上。由于变量i先定义,那么先为i分配高地址的空间(ps:压栈),接着为数组arr分配空间(ps:压栈)。我们知道,一维数组是连续存放在内存中的,并且元素地址依次递增,那么可以推断出元素arr[0]是存放在低地址处的。依次压栈数组的其他元素,i=9时,arr[9]=1,这时循环还没有结束,数组越界,在监视中可以发现当i=12时,arr[12]恰巧也是12,即就是i的值,这时候给arr[12]赋值为1,那么i又从1开始执行循环,从而导致该程序死循环。

  实际上,在 VS 中编译器会为数组预留2个整型空间(也就是8个字节)提醒数组越界,在 Linux 中编译器会为数组预留1个整型空间(也就是4个字节)才提醒数组越界,在VC++6.0中编译器会预留0个整型空间提醒数组越界。

  接下来通过对函数运行时堆栈,也就是函数栈帧加以剖析,来加深对函数调用过程的理解。所谓栈帧,就是编译器用来实现函数调用的一种数据结构。

  为了方便解释,在VC++6.0中调试下面这段代码:

# include <stdio.h>

int Add(int x, int y)
{
	int z = 0;
	z = x + y;

	return z;
}

int main(void)
{
	int a = 3;
	int b = 5;
	int ret = 0;

	ret = Add(a, b);

	return 0;
}

  按下F10进行调试并打开Call Stack,可以发现执行main函数前,先调用mainCRTStartup()函数,

                 

  接着为mainCRTStartup()函数开辟空间,esp(保存栈顶地址)指向栈顶,ebp(保存栈底地址)指向栈底(ps:ebp常用于现场保护),栈是一种由高地址指向低地址的数据结构。(注意:esp会随着栈空间的开辟而移动指向位置)

   

  查看ebp寄存器和esp寄存器的地址,发现ebp指向高地址,esp指向低地址。

               

  下面给出反汇编代码:

12:   {
0040D460   push        ebp            
0040D461   mov         ebp,esp
0040D463   sub         esp,4Ch
0040D466   push        ebx
0040D467   push        esi
0040D468   push        edi
0040D469   lea         edi,[ebp-4Ch]
0040D46C   mov         ecx,13h
0040D471   mov         eax,0CCCCCCCCh
0040D476   rep stos    dword ptr [edi]
13:       int a = 3;
0040D478   mov         dword ptr [ebp-4],3
14:       int b = 5;
0040D47F   mov         dword ptr [ebp-8],5
15:       int ret = 0;
0040D486   mov         dword ptr [ebp-0Ch],0
16:
17:       ret = Add(a, b);
0040D48D   mov         eax,dword ptr [ebp-8]
0040D490   push        eax
0040D491   mov         ecx,dword ptr [ebp-4]
0040D494   push        ecx
0040D495   call        @ILT+0(_Add) (00401005)
0040D49A   add         esp,8
0040D49D   mov         dword ptr [ebp-0Ch],eax
18:
19:       return 0;

   0040D460   push        ebp

  压栈,把ebp的地址保存起来,esp地址减4,

         

        

   0040D461   mov         ebp,esp

  把esp地址赋给ebp,

            

         

   0040D463   sub         esp,4Ch

  esp的地址减去0X4C,相当于在esp上方为main()函数预开辟4C字节空间,此时的栈顶地址是0x0018fefc。

         

         

   0040D466   push        ebx

   0040D467   push        esi

   0040D468   push        edi

  分别压栈ebx、esi、edi,每个寄存器占用4个字节,此时栈顶地址变成了0x0018fef0,

       

        

   0040D469   lea         edi,[ebp-4Ch]

  lea:load effective address(加载有效地址),

  ebp-4Ch 就是0x0018fefc这个位置。

   0040D46C   mov         ecx,13h

   0040D471   mov         eax,0CCCCCCCCh

   0040D476   rep stos    dword ptr [edi]

  重复拷贝从0x0018fefc这个位置ebp之间eax的内容(0XCCCCCCCC)13h次(即(77-1)/4=19次),拷贝内容所占内存为dword(也就是双字,4个字节)。

 

      

   13:       int a = 3;

   0040D478   mov         dword ptr [ebp-4],3

  把3赋给ebp-4这个位置(ebp向上移动4个字节的位置),通过查看内存,发现3已经存进去了,

 

      

   14:       int b = 5;

   0040D47F   mov         dword ptr [ebp-8],5

  把5赋给ebp-8这个位置,通过查看内存,发现5已经存进去了,

 

     

   15:       int ret = 0;

   0040D486   mov         dword ptr [ebp-0Ch],0

  把0赋给ebp-0C(ps:0C也就是12)这个位置,通过查看内存,发现0已经存进去了,

 

   

  16:

  17:       ret = Add(a, b);

  0040D48D   mov         eax,dword ptr [ebp-8]

 把ebp-8(也就是b=5这个位置)赋给eax,

  0040D490   push        eax

 eax在栈顶(esp)压栈,可以看到5被压进去了。实际上,传参的时候是先传入括号最右边的值。

 

   

  0040D491   mov         ecx,dword ptr [ebp-4]

 把ebp-4(也就是a=3这个位置)赋给ecx,

  0040D494   push        ecx

 ecx在栈顶(esp)压栈,可以看到3被压进去了,栈顶地址变成了0x0018fee8,

 

   

   

  0040D495   call        @ILT+0(_Add) (00401005)

  0040D49A   add         esp,8

 按下F11调试,进入Add()函数内部,发现0X40D49A就是Add()函数的入口地址,这个地址很重要,因为每次调用函数结束后要返回到主函数的执行逻辑上,

 

   

   

 反汇编代码如下:

1:    # include <stdio.h>
2:
3:    int Add(int x, int y)
4:    {
00401020   push        ebp
00401021   mov         ebp,esp
00401023   sub         esp,44h
00401026   push        ebx
00401027   push        esi
00401028   push        edi
00401029   lea         edi,[ebp-44h]
0040102C   mov         ecx,11h
00401031   mov         eax,0CCCCCCCCh
00401036   rep stos    dword ptr [edi]
5:        int z = 0;
00401038   mov         dword ptr [ebp-4],0
6:        z = x + y;
0040103F   mov         eax,dword ptr [ebp+8]
00401042   add         eax,dword ptr [ebp+0Ch]
00401045   mov         dword ptr [ebp-4],eax
7:
8:        return z;
00401048   mov         eax,dword ptr [ebp-4]
9:    }
0040104B   pop         edi
0040104C   pop         esi
0040104D   pop         ebx
0040104E   mov         esp,ebp
00401050   pop         ebp
00401051   ret

 再次按下F11,查看Add()函数内部实现细节,

  1:    # include <stdio.h>
  2:
  3:    int Add(int x, int y)
  4:    {

  00401020   push        ebp

 压栈main()函数的ebp(0x0018ff48),此时栈顶地址变为0x0018fee0,

   

 为了区分ebp,修改如下:

 

 

  00401021   mov         ebp,esp

 把esp赋给ebp,

 

    

  00401023   sub         esp,44h

 esp减去44h,表示栈顶上移,为Add()函数预开辟44个字节空间,此时栈顶变为0x0018fe9c,

 


  00401026   push        ebx

  00401027   push        esi

  00401028   push        edi

 分别压栈ebx、esi、edi,每个寄存器占用4个字节,此时栈顶地址变成了0x0018fe90。    

   

   

  00401029   lea         edi,[ebp-44h]

 ebp-44h 就是0x0018fe9c这个位置,

  0040102C   mov         ecx,11h

  00401031   mov         eax,0CCCCCCCCh

  00401036   rep stos    dword ptr [edi]

 重复拷贝从0x0018fe9c到ebp之间eax的内容(0XCCCCCCCC)11h(即(70-2)/4=17次),拷贝内容所占内存为dword(也就是双字,4个字节)。

        

   

  5:        int z = 0;

  00401038   mov         dword ptr [ebp-4],0

 把0赋给ebp-4这个位置,通过查看内存,发现0已经存进去了,

 

     

  6:        z = x + y;

 x,y均是形参,可以看到并没有创建x,y,

  0040103F   mov         eax,dword ptr [ebp+8]

 ebp+8,也就是a=3(形参)的位置,把它赋给eax,

  00401042   add         eax,dword ptr [ebp+0Ch]

 ebp+0Ch,也就是b=5(形参)的位置,与eax相加,

  00401045   mov         dword ptr [ebp-4],eax

 把eax(此时值为3+5=8)赋给ebp-4(也就是存放z=0的位置),

  

  7:
  8:        return z;

  00401048   mov         eax,dword ptr [ebp-4]

 将ebp-4的值(8)存放在eax寄存器中,

  9:    }

  0040104B   pop         edi

  0040104C   pop         esi

  0040104D   pop         ebx

 edi、esi、ebx出栈,栈顶变为0x0018fe9c,相当于销毁了这三次压栈的空间,

 

      

  0040104E   mov         esp,ebp

 ebp赋给esp,相当于销毁了预开辟的Add()函数空间,

 

  00401050   pop         ebp

 ebp出栈,相当于弹出去销毁,回到压栈之前的位置,因为保存了ebp的地址,就可以找到原来的栈空间,这也叫做恢复现场。

    

    

  00401051   ret

 在调用前记录了调用函数的地址,这里会根据保存的地址直接跳转到调用函数那里,实际上相当于弹出去这个保存的地址,此时栈顶变为0x0018fee8,

  

 此时回到了主函数,

  0040D49A   add         esp,8

 esp+8,相当于esp下移,然后把那两个形参弹出去,此时栈顶变为0x0018fef0,

 

    

  0040D49D   mov         dword ptr [ebp-0Ch],eax

 把寄存器eax保存的值(8)赋给ebp-0Ch(即就是ret=0的位置),

 

    

  0040D4A0   xor         eax,eax

 eax异或eax,相同为0,相当于销毁eax寄存器,

  20:   }

  0040D4A2   pop         edi

  0040D4A3   pop         esi

  0040D4A4   pop         ebx

 edi、esi、ebx依次出栈,栈顶变为0x18fefc,

  

  

  0040D4A5   add         esp,4Ch

 esp+4Ch,相当于销毁预开辟的main()函数空间,此时栈顶变为0x0018ff48,此时esp=ebp,

  

   

  0040D4A8   cmp         ebp,esp

 比较两寄存器地址是否一样,

  0040D4AA   call        __chkesp (00401060)

  0040D4AF   mov         esp,ebp

 把ebp赋给esp,

  0040D4B1   pop         ebp

    

 ebp出栈,回到最初的位置,

  0040D4B2   ret

 销毁所有创建的空间,运行结束。

   

 附上函数栈帧整个过程的图(ps:用不同颜色来区分函数main和Add)。

  上述内容对函数栈帧做了一些介绍,不妨趁热打铁,来看下面这段代码输出什么结果(ps:在VC++6.0中运行)?

# include <stdio.h>

void fun(void)
{
	int tmp = 10;
	int * p = (int *)(*(&tmp+1));
	*(p-1) = 20;
}

int main(void)
{
	int a = 0;

	fun();

	printf("a = %d\n", a);
	
	return 0;
}

  上述代码在VC++6.0中的运行结果是20,是不是很奇怪呢?

  且听我慢慢道来其中缘由,不妨画出函数调用过程中的栈帧示意图:

   

  &tmp+1表示取整型变量tmp的地址,向下移动4个字节,也就是上图中ebp的地址,*(&tmp+1)表示解引用ebp指向的地址,即就是得到ebp(main),相当于找到了main函数栈帧。

    

  *(p-1)表示解引用p-1,而p-1指的是a=0这块内存的地址,*(p-1)=20就相当于修改了a的值,因此输出20。

  其实掌握了栈帧之后,我们还可以做一些恶作剧,比如,不调用函数,但是可以访问它,代码如下,注意:在VC++6.0中运行,输出结果为: hello world!

# include <stdio.h>

void hello(void)
{
	printf("hello world!\n");

	exit(0);

	return;
}

void test(void)
{
	int tmp = 10;
	//把hello函数的地址赋给*(&tmp+2)
	*(&tmp + 2) = hello;

	return;
}

int main(void)
{
	test();

	return 0;
}

  本人能力有限,难免会有一些不足或者错误之处,还请读者海涵,假如发现错误请告知于我,多谢!

猜你喜欢

转载自blog.csdn.net/sustzc/article/details/79985817