【C】深入浅出之函数栈帧

目录

1. 调试 循环体内修改循环变量

2. x86 下通用寄存器

3. 函数栈帧

3.1 查看反汇编

3.2 函数栈帧图

3.3 趁热打铁

3.4 恶作剧


1. 调试 循环体内修改循环变量

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

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

且听我慢慢道来,在执行 main 函数时,栈区会为其开辟一片空间,高地址在下,低地址在上。

由于变量 i 先定义,那么先为 i 分配高地址的空间(压栈),接着为数组 arr 分配空间(压栈)。

我们知道,一维数组是连续存放在内存中的,并且元素地址依次递增,那么可以推断出元素 arr[0] 是存放在低地址处的。依次压栈数组的其他元素,i = 9 时,arr[9] = 1,这时循环还没有结束,数组越界,在监视中可以发现当 i = 12 时,arr[12] 恰巧也是 12,即就是 i 的值,这时候给 arr[12] 赋值为 1,那么 i 又从 1 开始执行循环,从而导致该程序死循环。

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

2. x86 下通用寄存器

在剖析函数栈帧前,先介绍 x86 下几种通用寄存器:

  • eax
    • 也叫做 累加寄存器,除了用于存储函数的返回值 外,也用于执行计算的操作。许多优化的 x86 指令集都专门设计了针对 eax 寄存器的读写和计算指令。例如最基本的加减、比较以及特殊的乘除操作都有专门的 eax 优化指令。
  • ebx
    • 是一个没有特殊用途的寄存器,它通常作为一个额外的数据储存器。
  • ecx
    • 也叫做计数寄存器,用于循环操作,比如重复的字符存储操作,或者数字统计。有一点很重要,ecx 寄存器的计算是向下而不是向上的(ps:简单理解就是 用于循环操作时是由大减到小的)。
  • edx
    • 也叫做数据寄存器。这个寄存器从本质上来说是 eax 寄存器的延伸,它辅助 eax 寄存器完成更多复杂的计算操作,比如乘法和除法。它虽然也能当作通用寄存器使用,不过更多的是结合 eax 寄存器进行计算操作。
  • eip
    • 总是 指向下一个要执行的指令。当 CPU 执行一个程序的时候(包含成千上万的代码),此时 eip 就会实时地指向当前 CPU 下一个要执行的位置。
  • esi
    • 是源操作数指针,存储着输入的数据流的位置。edi 寄存器是目的操作数指针,存储了计算结果所存储的位置。简而言之,esi(source index)用于读,edi(destination index) 用于写,依靠 esi 和 edi 寄存器能对需要循环操作的数据进行高效的处理
  • esp 和 ebp 分别是 栈顶指针(保存栈顶地址)和栈底指针(保存栈底地址,常用于 现场保护),这两个寄存器共同负责函数的调用和栈的操作,会随着栈空间的开辟而移动指向位置。当一个函数被调用的时候,函数需要的参数被陆续压入栈内,当函数调用完成后,之前压入栈内的参数将被销毁。

3. 函数栈帧

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

为了方便解释,在 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;
}

3.1 查看反汇编

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

                 

接着为 mainCRTStartup() 函数开辟空间,esp 指向栈顶,ebp 指向栈底

   

查看 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 这个位置,通过查看内存,发现 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 就是 call 指令的下一条指令的地址,

  • jmp         Add (0040D49A)

 

这个地址很重要,因为每次调用函数结束后要返回到 被调用的地方,

   

   

反汇编代码如下:

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),即就是保存 ebp 的地址,此时栈顶地址变为 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

由于调用函数前记录了函数调用结束后返回的地址 0X40D49A,这里会根据保存的地址直接跳转到 Call Add() 函数的下一行,实际上相当于弹出去这个保存的地址,此时栈顶变为 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

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

   

3.2 函数栈帧图

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

3.3 趁热打铁

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

#include <stdio.h>

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

int main(void) {
	int a = 0;

	func();

	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。

3.4 恶作剧

其实掌握了栈帧之后,我们还可以做一些恶作剧,比如:不调用函数,但是可以访问它。

VC++6.0 中运行,输出结果为:hello world!

#include <stdio.h>
#include <stdlib.h>

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

	exit(0);
}

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

	return;
}

int main(void) {
	test();

	return 0;
}

  • 5
    点赞
  • 17
    收藏
    觉得还不错? 一键收藏
  • 6
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 6
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值