在写c语言程序时,通常会把独立的功能抽象为函数,所以C程序是以函数为基本单位。而函数的调用、返回值和传参等这些问题都和函数栈帧有关。
函数栈帧:函数调用过程中在程序的调用栈所开辟的空间。
这批空间用来存放:
- 函数参数和返回值
- 临时变量
- 保存上下文信息(包括一些寄存器)
栈:栈被定义为一种特殊的容器,其遵循FIFO规则(也就是先入后出)。栈是一块动态内存区域,并且总是向下增长(由高地址向低地址)的。
寄存器:
eax:通用寄存器,保留临时数据,常用于返回值
ebx:通用寄存器,保留临时数据
ebp:栈底寄存器
esp:栈顶寄存器
eip:指令寄存器,保存当前指令的下一条指令的地址
汇编命令:
mov:数据转移指令
push:数据入栈
pop:数据弹出至指定位置
sub:减法命令
add:加法命令
call:函数调用
- 压入返回地址
- 转入目标函数
jump:通过修改eip,转入目标函数,进行调用
ret:恢复返回地址
认识:
- 每一次函数调用,都要为本次函数调用开辟空间。也就是函数栈帧空间
- 这块空间的维护使用了2个寄存器:esp和ebp。
测试代码:
#include
int Add(int x, int y)
{
int z = 0;
z = x + y;
return z;
}
int main()
{
int a = 3;
int b = 5;
int ret = 0;
ret = Add(a, b);
printf("%d\n", ret);
return 0;
}
函数调用堆栈是反馈函数调用逻辑的,从上图可以观察到,main函数之前是由invoke_main函数调用的。再之前就不用考虑了。可以确定invoke_main函数也有自己的栈帧,main函数和Add函数也会维护自己的栈帧,每个函数栈帧都有自己的ebp和esp来维护栈帧空间。
测试代码是上面函数调用堆栈的代码
int Add(int x, int y)
{
//函数栈帧创建
00311760 push ebp
00311761 mov ebp,esp
00311763 sub esp,0CCh
00311769 push ebx
0031176A push esi
0031176B push edi
int z = 0;
0031176C mov dword ptr [ebp-8],0
z = x + y;
00311773 mov eax,dword ptr [ebp+8]
00311776 add eax,dword ptr [ebp+0Ch]
00311779 mov dword ptr [ebp-8],eax
return z;
0031177C mov eax,dword ptr [ebp-8]
}
0031177F pop edi
00311780 pop esi
00311781 pop ebx
00311782 mov esp,ebp
00311784 pop ebp
00311785 ret
int main()
{
//函数栈帧的创建
00311820 push ebp
00311821 mov ebp,esp
00311823 sub esp,0E4h
00311829 push ebx
0031182A push esi
0031182B push edi
0031182C lea edi,[ebp-24h]
0031182F mov ecx,9
00311834 mov eax,0CCCCCCCCh
00311839 rep stos dword ptr es:[edi]
//main函数核心代码
int a = 3;
0031183B mov dword ptr [ebp-8],3
int b = 5;
00311842 mov dword ptr [ebp-14h],5
int ret = 0;
00311849 mov dword ptr [ebp-20h],0
ret = Add(a, b);
00311850 mov eax,dword ptr [ebp-14h]
00311853 push eax
00311854 mov ecx,dword ptr [ebp-8]
00311857 push ecx
00311858 call _Add (03110B4h)
0031185D add esp,8
00311860 mov dword ptr [ebp-20h],eax
printf("%d\n", ret);
00311863 mov eax,dword ptr [ebp-20h]
00311866 push eax
00311867 push offset string "%d\n" (0317B30h)
0031186C call _printf (03110D2h)
00311871 add esp,8
return 0;
00311874 xor eax,eax
}
解析代码:
int main()
{
//函数栈帧的创建
00311820 push ebp //把ebp寄存器中的值进行压栈,此时的ebp中存放的是invoke_main函数栈帧的ebp,esp-4
00311821 mov ebp,esp //move指令会把esp的值放到ebp中,相当于产生了main函数的ebp,这个值就是invoke_main函数栈帧的esp
00311823 sub esp,0E4h //sub,让esp中的地址减去一个16进制的数字0xe4,产生新的esp。此时esp是main函数栈帧的esp。————————到此ebp和esp之间维护了一块栈空间,这就是main函数的栈帧空间。这段空间中存储main函数中的局部变量,临时数据和已经调试信息等
00311829 push ebx //将寄存器ebx的值压栈,esp-4
0031182A push esi //将寄存器esi的值压栈,esp-4
0031182B push edi //将寄存器edi的值压栈,esp-4
//上面三条指令保存了3个寄存器的值在栈区,这3个寄存器在函数随后执行中可能会被修改,所以先保存寄存器原来的值,以便退出函数后恢复
//下面的代码是在初始化main函数的栈帧空间
//1. 先把ebp-24h的地址,放在edi中。lea:load effection address
//2. 把9放在ecx中
//3. 把0xCCCCCCCC放在eax中
//4. 将从edp-0x24h到ebp这一端的内存的每一个字节都初始化成0xCC
0031182C lea edi,[ebp-24h]
0031182F mov ecx,9
00311834 mov eax,0CCCCCCCCh
00311839 rep stos dword ptr es:[edi] //dword:四个字节
//...
}
上面的代码后四句,等价于下面的伪代码
edi = ebp-0x24;
ecx = 9;
eax = 0xCCCCCCCC;
for(; ecx != 0; --ecx, edi+=4)
{
*(int*)edi = eax;
}
通过上面,我们就可以知道,为什么以前有些代码会打出“烫”这个字,因为main函数调用的时候,在栈区开辟的空间其中每一个字节都被初始化成0xCC,而两个连续的0xCCCC就是“烫”。
解析代码:
int a = 3;
0031183B mov dword ptr [ebp-8],3 //将3存储到ebp-8的地址处,ebp-8的位置其实就是a变量
int b = 5;
00311842 mov dword ptr [ebp-14h],5 //将5存储到ebp-14h的地址处,也就是b变量
int ret = 0;
00311849 mov dword ptr [ebp-20h],0 //将0存储到ebp-20h的地址处,也就是ret变量
//以上汇编,就是三个变量的创建和初始化,这就是局部变量的创建和初始化,在其所在的函数栈帧空间创建的
//调用Add函数
ret = Add(a, b); //函数传参本质就是把参数push到栈帧空间
00311850 mov eax,dword ptr [ebp-14h] //传递b,将ebp-14h处放的5放到eax寄存器中
00311853 push eax //将eax的值压栈,esp-4
00311854 mov ecx,dword ptr [ebp-8] //传递a,将ebp-8处放的3放在ecx寄存器中
00311857 push ecx //将ecx的值压栈,esp-4
//上面四条语句是函数传参
//跳转调用函数
00311858 call _Add (03110B4h)
0031185D add esp,8
00311860 mov dword ptr [ebp-20],eax
call指令执行前,还有一个压栈操作:
call指令执行前,把call指令的下一条指令的地址进行压栈操作,这个操作的作用:解决当函数调用结束后要回到call指令的下一条指令的地方,继续往后执行。
//跳转调用函数
00311858 call _Add (03110B4h)
0031185D add esp,8 对0031185D压栈
00311860 mov dword ptr [ebp-20],eax
int Add(int x, int y)
{
//函数栈帧创建
00311760 push ebp //将main函数栈帧的ebp保存,esp-4
00311761 mov ebp,esp //将main函数的esp赋值给新的ebp,此时ebp是Add函数的
00311763 sub esp,0CCh //给esp-0xCC,求出Add函数的esp
00311769 push ebx //将ebx的值压栈,esp-4
0031176A push esi //将esi的值压栈,esp-4
0031176B push edi //将edi的值压栈,esp-4
int z = 0;
0031176C mov dword ptr [ebp-8],0 //将0放在ebp-8的地址处。(创建z)
z = x + y;
00311773 mov eax,dword ptr [ebp+8] //将ebp+8地址处的数字存储到eax中
00311776 add eax,dword ptr [ebp+0Ch] //将ebp+12地址处的数字加到eax中
00311779 mov dword ptr [ebp-8],eax //将eax的结果保存到ebp-8的地址处,放到z中
return z;
0031177C mov eax,dword ptr [ebp-8] //将ebp-8地址处的值放在eax中,就是把z的值存储到eax中,通过寄存器eax带回计算结果
}
0031177F pop edi
00311780 pop esi
00311781 pop ebx
00311782 mov esp,ebp
00311784 pop ebp
00311785 ret //回到call指令的下一条指令
当代码开始执行Add函数时,就开始创建Add函数的栈帧空间,基本和main函数的栈帧空间创建大差不差。
既然有函数栈帧的创建,那无疑也有销毁,接下分析一下函数栈帧销毁的反汇编
0031177F pop edi
00311780 pop esi
00311781 pop ebx
//前面三个命令弹出三个寄存器,esp+12
00311782 mov esp,ebp //再将Add函数ebp的值赋值给main的esp,回收Add函数的栈帧空间
00311784 pop ebp //弹出栈顶的ebp,也就是main函数的ebp,esp+4。到这里恢复了main函数的栈帧维护
00311785 ret //栈顶弹出一个值,这个值就是call指令下一个指令的地址,esp+4
回到main函数:
0031185D add esp,8 //esp+8,也就是把参数部分跳过
00311860 mov dword ptr [ebp-20h],eax //eax的值被存到ebp-20的地址处,也就是ret变量中。本次函数的返回值是由eax的寄存器带回来的
注:返回对象是内置类型,一般是寄存器带回返回值,当返回对象较大,一般会在主调函数的栈帧中开辟空间,然后把空间的地址,隐式传递给被调函数,在被调函数中通过地址找到主调函数预留的空间。