首先,从调试的角度来看下面这段代码,
可以发现这是一个死循环,那么这是为什么呢?
且听我慢慢道来,在执行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; }
本人能力有限,难免会有一些不足或者错误之处,还请读者海涵,假如发现错误请告知于我,多谢!