【前言】本文主要介绍了栈帧在函数调用中的创建与销毁,对其进行一个深度的理解有助于我们去理解在函数调用中的其他地方。比如,你是否有以下的疑惑
1.局部变量是如何创建的?为何局部变量不初始化会出现随机值?
2.在函数调用时参数如何传递?又以何种方式传递?
3.函数返回值是如何带回主函数?
当你看完这篇文章,相信你会有所收获。
tip:因为不同的编译器带来的反编译效果不同,所以当你们进行测试时会与文章展示有所差异,但不影响理解。推荐vs2013,对整个栈帧创建和销毁的过程展示的更加全面。接下来就以一份以vs2013为平台进行编译的代码进行分析。
目录
1.阅读前准备?
1.1何谓栈帧?
c语言离不开函数,函数也作为其基本单位,二者密不可分。函数调用和返回值的带回等都和栈帧有联系。
一句话来说,栈帧就是函数调用过程中在程序的调用栈所开辟的空间。
那么栈又是什么呢?栈是现代计算机里非常重要的一环,没有了栈,就不存在函数的说法。相应的,计算机语言的发展会受到很大影响。
栈被当作一种特殊的容器,数据可以被压入栈(push),也可以将压入的数据弹出(pop),并遵守着先入栈的后出栈的规则。因为先进入的数据被压到了最下面,想要取出必须要取出上面全部的数据才行。
1.2 相关寄存器与汇编指令
寄存器
eax:通用寄存器,保留临时数据,通常用于返回值。
ebx:与eax无异。
ebp:栈底寄存器。
esp:栈顶寄存器。
eip:指令寄存器。
汇编指令
mov:数据转移
push:数据入栈,esp随着发生改变
pop:数据弹出到指定位置, esp随着发生改变。
sub:减
add:加
call:函数调用
先混个眼熟,具体看到再具体解释。
1.3使用代码示例
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", ret);
return 0;
}
如何调试?调试进入add函数,使用调用堆栈选项(右击勾选显示外部代码)。
在调用堆栈中我们可以发现,main函数在调用前,还有其他的函数来调用main函数。 例如invoke main。在invoke main前也有函数来调用他。我们可以从这里知道,每个函数都有自己的栈帧。
那么我们从main函数开始。
记得调试到第一行右击鼠标转到反汇编,那么正篇就开始了。
2.main函数栈帧
//函数栈帧的创建
00BE1820 push ebp
00BE1821 mov ebp,esp
00BE1823 sub esp,0E4h
00BE1829 push ebx
00BE182A push esi
00BE182B push edi
00BE182C lea edi,[ebp-24h]
00BE182F mov ecx,9
00BE1834 mov eax,0CCCCCCCCh
00BE1839 rep stos dword ptr es:[edi]
这是第一部分,栈帧的创建。
从第一行开始,push edp代表着将edp寄存器的值进行压栈,并且在此时,edp中存放的是invoke main函数栈帧的edp。
第二行,move指令的意思就是将esp的值存到edp中。这个步骤就是产生了main函数的edp,这个值也正好是invoke main的esp。
第三行,给esp地址进行了一个减法,进而产生了新的esp。注意,地址是由低到高的。
那么此时,esp和edp的位置也就如上图所示,在他们之间维护了为main函数开辟的空间。
第四行到第六行,将三个寄存器进行了压栈,所以保存了三个寄存器的值在栈区里。三个寄存器之后会被修改,所以保存一份便于恢复。注意使用push的时候esp也会随着变化哦。
七行开始的操作为:
将edp-24h的地址放于edi中。将9放在ecx中,将0ccc~cch放在eax中,最后一行很关键,他的意思是将从edp到之后的9个整型空间这一段的每个字节都初始化为了0xcc,同时edi的位置也被指定。
这里提一个小tip,如果你打印未初始化的数组,大概会打印出很多的“烫”字,因为烫的编码就是0xcccc啦。
那么到这里,栈区就变成了这样。
到这里,main函数的栈帧就创建完成了。
下一步,进入main函数的代码段,以注释的形式标明,更加了然。
int a = 3;
00BE183B mov dword ptr [ebp-8],3 //将3存储到ebp-8的地址处,ebp-8的位置其实就
是a变量
int b = 5;
00BE1842 mov dword ptr [ebp-14h],5 //将5存储到ebp-14h的地址处,ebp-14h的位置
其实是b变量
int ret = 0;
00BE1849 mov dword ptr [ebp-20h],0 //将0存储到ebp-20h的地址处,ebp-20h的位
置其实是ret变量
//调用Add函数
ret = Add(a, b);
//调用Add函数时的传参
//其实传参就是把参数push到栈帧空间中
00BE1850 mov eax,dword ptr [ebp-14h] //传递b,将ebp-14h处放的5放在eax寄存器
中
00BE1853 push eax //将eax的值压栈,esp-4
00BE1854 mov ecx,dword ptr [ebp-8] //传递a,将ebp-8处放的3放在ecx寄存器中
00BE1857 push ecx //将ecx的值压栈,esp-4
//跳转调用函数
00BE1858 call 00BE10B4
00BE185D add esp,8
00BE1860 mov dword ptr [ebp-20h],eax
通过以上不难看出,在main函数里创建了三个空间,分别容纳了a,b,ret,代码表示的变量a,b,ret的创建和初始化,其实是局部变量的创建时在局部变量所在函数的栈帧空间中创建的。
创建了变量之后,就是一个调用add函数的过程。那么就该传参了。传参也是从左到右来传,所以先传的就是b,再是a。
接下来就是这样。
传参这块很好理解,重点还是在call指令上。
call指令主要是调用函数逻辑,在执行call指令前会先将call的下一条指令的地址进行压栈,这样做的目的就是解决函数调用结束后需要回到call的下一条指令来继续执行。
也就是esp在ab传参时变化两次,call时又变化了一次。
注意:如果是你自己在调试,到call指令时,需要按F11进行逐语句调试。
那么下一步就是跳转到add函数了。
3.add函数栈帧
跳转过来,第一步仍然像是invoke main函数调用main一样,为add创建其专属的栈帧空间,其形式与main的创建一样,就不多言了。
int Add(int x, int y)
{
00BE1760 push ebp //将main函数栈帧的ebp保存,esp-4
00BE1761 mov ebp,esp //将main函数的esp赋值给新的ebp,ebp现在是Add函数的ebp
00BE1763 sub esp,0CCh //给esp-0xCC,求出Add函数的esp
00BE1769 push ebx //将ebx的值压栈,esp-4
00BE176A push esi //将esi的值压栈,esp-4
00BE176B push edi //将edi的值压栈,esp-4
int z = 0;
00BE176C mov dword ptr [ebp-8],0 //将0放在ebp-8的地址处,其实就是创建z
z = x + y;
//接下来计算的是x+y,结果保存到z中
00BE1773 mov eax,dword ptr [ebp+8] //将ebp+8地址处的数字存储到eax中
00BE1776 add eax,dword ptr [ebp+0Ch] //将ebp+12地址处的数字加到eax寄存中
00BE1779 mov dword ptr [ebp-8],eax //将eax的结果保存到ebp-8的地址处,其实
就是放到z中
return z;
00BE177C mov eax,dword ptr [ebp-8] //将ebp-8地址处的值放在eax中,其实就是
把z的值存储到eax寄存器中,这里是想通过eax寄存器带回计算的结果,做函数的返回值。
}
//销毁
00BE177F pop edi
00BE1780 pop esi
00BE1781 pop ebx
00BE1782 mov esp,ebp
00BE1784 pop ebp
00BE1785 ret
进行x+y的计算时,可以发现,计算的是两个寄存器中所存储的值,地址分别为edp+8和edp+12,也就是先前的a‘,b’的位置。所以这里有力的佐证了形参是实参的一份临时拷贝。在add里改变x,y是不会对a,b有任何影响的,想要建立联系,必须要依靠指针的力量。
也有人会有疑惑,函数调用完不是要进行销毁吗?怎么还能return z?你仔细地去看反编译的语句,z的值被存进了寄存器,通过寄存器带回了main函数。而z则是满足函数的生命周期,出了{}便会销毁。
接下来就是最后一个步骤----销毁。
4.栈帧销毁
00BE177F pop edi //在栈顶弹出一个值,存放到edi中,esp+4
00BE1780 pop esi //在栈顶弹出一个值,存放到esi中,esp+4
00BE1781 pop ebx //在栈顶弹出一个值,存放到ebx中,esp+4
00BE1782 mov esp,ebp //再将Add函数的ebp的值赋值给esp,相当于回收了Add函数的栈
帧空间
00BE1784 pop ebp //弹出栈顶的值存放到ebp,栈顶此时的值恰好就是main函数的ebp,
esp+4,此时恢复了main函数的栈帧维护,esp指向main函数栈帧的栈顶,ebp指向了main函数栈帧的栈
底。
00BE1785 ret //ret指令的执行,首先是从栈顶弹出一个值,此时栈顶的值就是call指
令下一条指令的地址,此时esp+4,然后直接跳转到call指令下一条指令的地址处,继续往下执行
此时函数完成了工作,返回了带有z值得寄存器,就开始进行销毁了。先将edi,esi,ebx出栈,再将esp指向了edp,这样add得栈帧空间就被回收了。此时再弹出栈顶值存放至edp,此时栈顶里装的就是main函数的edp,所以对main函数的栈帧维护被恢复。esp还是指向这,成为了main的栈顶,而edp则存储了出栈的main的edp,回到了栈底。
ret则是正好取到了0x00be185d,正好就进行了返回,继续执行。
00BE185D add esp,8 //esp直接+8,相当于跳过了main函数中压栈的
a'和b'
00BE1860 mov dword ptr [ebp-20h],eax //将eax中值,存档到ebp-0x20的地址处,
其实就是存储到main函数中ret变量中,而此时eax中就是Add函数中计算的x和y的和,可以看出来,本次函
数的返回值是由eax寄存器带回来的。程序是在函数调用返回之后,在eax中去读取返回值的
这样就完成了整个函数的栈帧的分析。现在再回头去看开头的几个问题,你能否用栈帧的知识进行一个解释呢?
【end】