序言
这个模块临近C语言的边界,学起来需要一定的时间,不过当我们知道这些知识,在C语言函数这块我们看到的不仅仅是表象了,可以真正了解函数是怎么调用的。不过我的能力有限,下面的的知识若是不当,还请各位斧正。
知识点储备
- 初步了解函数( 这里的所说的函数我们默认为自定义函数)
- 了解C程序地址空间
- 基本的寄存器
- 知道一些汇编语言
函数的概念
函数大家应该都很熟悉了,这里就不细说了。 我们看看就行
ret_type fun_name(para1, * )
{
statement; //语句项
}
ret_type 返回类型
fun_name 函数名
para1 函数参数
C程序地址空间(重点记忆)
我们一直说 :“全局变量的生命周期是所在的整个程序”、“static修饰的变量的生命周期变长了”、以及“最重要的临时变量出函数就要被销毁”。不过我们要知道这是因为什么。 在C语言中我们所创建的每一个变量都会有自己空间的的存储类别,就比如汽车一般不会停在高楼那样,每一个事物都会有自己的集合。
看一下代码,来验证一下
#include<stdio.h>
#include<stdlib.h>
int g_val1 = 10;
int g_val2 = 10;
int g_val3;
int g_val4;
int main()
{
const char* str = "abcdef";
printf("code: %p\n", main);
printf("read only : %p\n", str);
printf("init g_val1 : %p\n", &g_val1);
printf("init g_val2 : %p\n", &g_val2);
printf("uninit g_val2 : %p\n", &g_val3);
printf("uninit g_val2 : %p\n", &g_val4);
char* p1 = (char*)malloc(sizeof(char*) * 10);
char* p2 = (char*)malloc(sizeof(char*) * 10);
printf("heap addr : %p\n", p1);
printf("heap addr : %p\n", p2);
printf("stack addr : %p\n", &str);
printf("stack addr : %p\n", &p1);
printf("stack addr : %p\n", &p2);
return 0;
}
可以看出局部变量存储在栈上且栈空间是沿着向低地址方向开辟的
相关的 寄存器
函数的调用与CPU中的寄存器有很大关系,下面有一些基本知识
- eax:通用寄存器,保留临时数据,常用于返回值
- ebx:通用寄存器,保留临时数据
- ebp:栈底寄存器
- esp:栈顶寄存器
- eip:指令寄存器,保存当前指令的下一条指令的地址,衡量走到了那一步
相关的 汇编语言
- mov:数据转移指令
- push:数据入栈,同时esp栈顶寄存器也要发生改变
- pop:数据弹出至指定位置,同时esp栈顶寄存器也要发生改变
- sub:减法命令
- add:加法命令
- call:函数调用,1. 压入返回地址 2. 转入目标函数
- jump:通过修改eip,转入目标函数,进行调用
- ret:恢复返回地址,压入eip,类似pop eip命令
看了这么多知识,我们一定会感到很是枯燥,觉得这和函数栈帧一点关系都没有,不要着急,下面就开始我们正式的内容。
函数栈帧
这里为了便于理解,我们这么看栈的空间,我们就多画些图片
我们知道 main函数也是一个函数,它也是能够被调用,所以main函数也会形成栈帧。
样例代码
int MyAdd(int a, int b)
{
int c = a + b;
return c;
}
int main()
{
int x = 0xA;
int y = 0xB;
int z = 0;
z = MyAdd(a, b);
printf("z = %d\n",z);
return 0;
}
转到反汇编,打开寄存器
我将汇编代码复制下来,我们一步一步分析这些东西
int main()
{
int main()
{
int main()
{
00821E40 push ebp
00821E41 mov ebp,esp
00821E43 sub esp,0E4h
00821E49 push ebx
00821E4A push esi
00821E4B push edi
00821E4C lea edi,[ebp-24h]
00821E4F mov ecx,9
00821E54 mov eax,0CCCCCCCCh
00821E59 rep stos dword ptr es:[edi]
00821E5B mov ecx,82C003h
00821E60 call 0082130C
int x = 0xA;
00821E65 mov dword ptr [ebp-8],0Ah
int y = 0xB;
00821E6C mov dword ptr [ebp-14h],0Bh
int z = 0;
00821E73 mov dword ptr [ebp-20h],0
z = MyAdd(x, y);
00821E7A mov eax,dword ptr [ebp-14h]
00821E7D push eax
00821E7E mov ecx,dword ptr [ebp-8]
00821E81 push ecx
00821E82 call 008211E5
00821E87 add esp,8
00821E8A mov dword ptr [ebp-20h],eax
printf("z = %d\n", z);
00821E8D mov eax,dword ptr [ebp-20h]
00821E90 push eax
00821E91 push 827BCCh
00821E96 call 008213A2
00821E9B add esp,8
return 0;
00821E9E xor eax,eax
}
00821EA0 pop edi
00821EA1 pop esi
00821EA2 pop ebx
00821EA3 add esp,0E4h
00821EA9 cmp ebp,esp
00821EAB call 00821235
00821EB0 mov esp,ebp
00821EB2 pop ebp
00821EB3 ret
- ebp指向栈底
- esp指向栈顶
- eip指向下一个即将执行的地址 还未执行
第一步
int x = 0xA;
01011E65 mov dword ptr [ebp-8],0Ah
//在ebp-8处在开辟一个空间,将x的值放进去
int y = 0xB;
01011E6C mov dword ptr [ebp-14h],0Bh
//在ebp-14处在开辟一个空间,将y的值放进去
int z = 0;
00821E73 mov dword ptr [ebp-20h],0
//在ebp-20处在开辟一个空间,将z的值放进去
可以看出,x、y、z 的空间是不连续的 ,这是VS保护机制, 防止一些程序员猜测对应的地址。
第二步
00821E7A mov eax,dword ptr [ebp-14h]
把ebp-14(也就是y) 赋值给eax
eax是一个临时的寄存器,保留临时数据,常用于返回值
00821E7D push eax
push命令将eax的值放入栈中,同时栈顶的位置发生变化,变化的大小是4个字节,因为y是int型
push之后的栈顶
00821E7E mov ecx,dword ptr [ebp-8]
把ebp-8(也就是x) 赋值给ecx
00821E81 push ecx
和上面的一样,将ecx的值压入栈内,栈顶的位置发生变化
结论
- 临时变量的形成(实参的临时拷贝)在函数调前就完成了
- 形参实例化的顺序是从右向左依次形成的
- 形参的空间是紧邻的
调用函数
这里先说一下call命令的作用
- 压入返回地址 (最重要的)
- 转入目标函数压入返回地址 ,压入谁?为什么要压入?压入谁?压入的是下一条命令的地址
为什么要压入?根本原因是函数调用完毕,可能就需要返回
00821E82 call 008211E5
jump命令 通过修改eip,转入目标函数,进行调用
jmp前
jmp后
现在我们总算进入了MyAdd()函数了,画一下我们的栈帧图
由于篇幅有限,我们先说到这里,下一篇接着说MyAdd()函数内部的事情。
样例代码
int MyAdd(int a, int b)
{
int c = 0
c = a + b;
return c;
}
int main()
{
int x = 0xA;
int y = 0xB;
int z = 0;
z = MyAdd(x, y);
printf("z = %d\n", z);
return 0;
}
今天的汇编语言
int MyAdd(int a, int b)
{
001E2EC0 push ebp
001E2EC1 mov ebp,esp
001E2EC3 sub esp,0CCh
001E2EC9 push ebx
001E2ECA push esi
001E2ECB push edi
001E2ECC lea edi,[ebp-0Ch]
001E2ECF mov ecx,3
001E2ED4 mov eax,0CCCCCCCCh
001E2ED9 rep stos dword ptr es:[edi]
001E2EDB mov ecx,1EC003h
001E2EE0 call 001E130C
int c = 0;
001E2EE5 mov dword ptr [ebp-8],0
c =a + b;
001E2EEC mov eax,dword ptr [ebp+8]
001E2EEF add eax,dword ptr [ebp+0Ch]
001E2EF2 mov dword ptr [ebp-8],eax
return c;
001E2EF5 mov eax,dword ptr [ebp-8]
}
001E2EF8 pop edi
001E2EF9 pop esi
001E2EFA pop ebx
001E2EFB add esp,0CCh
001E2F01 cmp ebp,esp
001E2F03 call 001E1235
001E2F08 mov esp,ebp
001E2F0A pop ebp
001E2F0B ret
我们的栈帧图
MyAdd函数栈帧的形成
第一步
00821740 push ebp
这条命令是将ebp(也就是栈底)的内容压入栈中,同时栈顶也发生变化
第二步
mov:数据转移指令
00821741 mov ebp,esp
该命令的意思是将esp的内容覆盖到ebp中
- esp的内容直接将ebp的内容给覆盖
- 该过程没有通过内存,直接通过CPU
那么我们可能会发出疑惑,那栈底怎么办,是不是找不回来了?实际上不是的,上一步我们不是把栈底的内容给保存了吗!!!
第三步
sub:减法命令
00821743 sub esp,0CCh
//0CCh 的大小和你定义的函数的规模有关
该命令的意思是esp减去一定的值,结果放在esp中
到这里我们已近形成了MyAdd()的栈帧了
第四步
int c = 0;
001E2EE5 mov dword ptr [ebp-8],0
//在ebp-8处在开辟一个空间,将c的值放进去
这和main的变量开辟一样
第五步
c =a + b;
001E2EEC mov eax,dword ptr [ebp+8]
001E2EEF add eax,dword ptr [ebp+0Ch]
001E2EF2 mov dword ptr [ebp-8],eax
一条一条分析
001E2EEC mov eax,dword ptr [ebp+8]
把ebp+8放在eax中那么ebp+8是多少呢?答案就是我们的x值的拷贝
同理
001E2EEF add eax,dword ptr [ebp+0Ch]
这个命令是将ebp+0Ch的内容和eax加起来放到 eax中ebp+0Ch就是y值的拷贝
001E2EF2 mov dword ptr [ebp-8],eax
这条命令是将eax写入到ebp-8,也就是c中
准备返回
第一步
001E2EF5 mov eax,dword ptr [ebp-8]
保存返回值
第二步
001E2F08 mov esp,ebp
把ebp 覆盖到 esp这一步也可以称为“释放栈帧”
第三步
pop:数据弹出至指定位置,同时esp栈顶寄存器也要发生改变
001E2F0A pop ebp
“弹栈”将main函数的栈底放在ebp中,esp内容改变
第四步
ret:恢复返回地址,压入eip,类似pop eip命令
001E2F0B ret
将之前call的下一个地址放在eip,esp内容改变
第五步
释放临时拷贝的变量
001E1E87 add esp,8
意思是esp+8放在esp中
现在我们已经返回到MyAdd执行之前了
第六步
001E1E8A mov dword ptr [ebp-20h],eax
接收返回值将eax的值放到ebp-20(也就是z)
返回的本质
- 返回到main的栈帧
- 返回到对应的代码处
总结
- 函数的的栈帧是由编译器决定的
- push进去的变量的空间是连续的
- 0CCh 的大小和你定义的函数的规模有关
为什么函数的的栈帧是由编译器决定的?我们C语言中很多数据类型,这就是的编译器有能力知道所有类型变量的大小。
有关栈帧的知识大概都说的差不多了,后面我会看看是否还要补充一些知识。