目录
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:用不同颜色来区分函数 main 和 Add)。
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;
}