函数栈帧的创建与销毁
我们都知道计算机语言可以划分为机器语言,汇编语言,高级语言三大类。其中机器语言,汇编语言都是对计算机硬件的直接操作,而计算机硬件能直接理解的语言只有由‘1’‘0’所组成的二进制序列串,显然这对人类来说不是多么友好,因此机器语言,汇编语言被冠以“低级”之名,为了降低计算机语言的学习门槛,同时也为了使程序具有更高的可移植性,高级语言诞生了。高级语言与汇编语言有什么关联呢?与汇编语言实际上是把二进制串用某些特定英文字母,字符串代替不同,高级语言与汇编语言并不是单纯的替代关系,而是一种转化关系,你可以这样理解:我先用汇编语言写出一道程序,然后在这个程序中执行高级语言程序,汇编语言程序会把高级语言中的各种指令转化成汇编语言,然后,汇编语言又会被汇编程序1替换成二进制串,这样计算机就可以读懂这些指令了。
下面我们将通过VS20132去观察C语言转化成汇编语言后的运行逻辑,进而理解函数栈帧的创建与销毁。
我们写一个足够简单的代码进行演示:
#include<stdio.h>
int Add(int x,int y)
{
int z = 0;
z = x + y;
return z;
}
int main()
{
int a = 10;
int b = 20;
int c = 0;
c = Add(a,b);
printf("%d\n",c);
return 0;
}
我们知道,函数实际上就是实现各种功能的子程序,如同一个人想要做某些事需要各种各样的信息一样,函数实现某些功能也需要各种各样的信息,比如函数的局部变量,参数,地址等,这些信息存在哪里呢?它们被存在内存中一个叫栈区的地方,构成了一个名为函数栈帧的区域,函数栈帧为函数的执行提供了必要的环境。为了便于对栈区中各种信息的查找,有必要引入地址这一概念,每个地址都相当于一个小房子,是计算机存储空间的基本单位,其容量是一个字节;如果把栈区比作一个水桶,那函数的信息就相当于水,水进入水桶会到水桶的底部,而信息进入栈区也会到达栈区的底部,需要注意的是,栈区的底部是高地址,顶部则是低地址,栈区写入信息是从高地址往低地址写的。
为了明确函数栈帧的边界或者范围,我们把函数栈帧的始终地址储存在两个寄存器中,起始地址或者说高地址存储在名为“ebp”的寄存器中,终止地址或者说低地址存储在名为“esp”的寄存器中。
下面进入实操环节,首先在int main()前设置断点,将光标移到该行,按下F9,int main()前出现红点断点即设置成功,随后按下F5或者依次点击“调试”“启动调试”进入调试模式,再点击“调试”“窗口”“调用堆栈”,随后多按几下F10,直到光标移到main函数的最后一行,然后再按下F10,就会发现,进入了一个名为“__tmainCRTStartup”的堆栈帧,箭头指到“mainret = main(argc, argv, envp);”这一行,这意味着main函数的返回值会赋给mainret,观察调用堆栈窗口会发现函数__tmainCRTStartup又是被函数mainCRTStartup所调用的;我们一直说main函数是程序的入口,就像一个人去找某个很大公共场所的入口时需要指引一样,计算机找main函数也是需要指引的,而__tmainCRTStartup和mainCRTStartup这两个函数的功能就是去引导计算机找到main函数。
现在,多按几下F10,直到程序跳出“crtexe.c”文件,重新进入“main.c”文件,然后右键鼠标,点击“转到反汇编”,点击查看选项,取消“显示符号名”就可以看到由C语言转化而成的汇编语言:
int main()
{
00461410 push ebp
00461411 mov ebp,esp
00461413 sub esp,0E4h
00461419 push ebx
0046141A push esi
0046141B push edi
0046141C lea edi,[ebp+FFFFFF1Ch]
00461422 mov ecx,39h
00461427 mov eax,0CCCCCCCCh
0046142C rep stos dword ptr es:[edi]
int a = 10;
0046142E mov dword ptr [ebp-8],0Ah
int b = 20;
00461435 mov dword ptr [ebp-14h],14h
int c = 0;
0046143C mov dword ptr [ebp-20h],0
在对其进行逐个说明之前,我们先要明确一下当前"ebp"和“esp”这两个指针寄存器所指向的地址,之前我们就已经说过,“esp”和“ebp”是用来明确函数栈帧的边界和范围的,而main函数又是被名为__tmainCRTStartup的函数所调用的,所以当前ebp和esp所指向的地址应该是这样的:
然后我们就可以看汇编指令了:
第一行:push ebp,意思就是把寄存器ebp中储存的地址放置在现在调用的函数(也就是__tmainCRTStartup)栈帧之上,这个操作叫做“压栈”;压栈之后函数栈帧的范围就扩大了,esp所指向地址的地址就会自动发生变化,把地址ebp也给囊括进来。变成这样:
如果你想看的再清楚一点,那就打开“调试”“窗口”“监视”监视1“,再右键鼠标选中十六进制显示,对esp和ebp进行监视,现在箭头指向的是push ebp,我们按下F10,观察esp是否发生变化:
push ebp 执行前:
push ebp 执行后:
我们观察到,寄存器esp中储存的值由0x007df9f4变为了0x007df9f0,要注意,栈区中写入信息是从高地址往低地址写的,所以现象是符合预测的。
或者也可以打开“调试”“窗口”“内存”“内存1”实际看一下内存情况,在查找中输入esp回车,就可以看到esp所指向的地址中确实储存了ebp中的地址信息:
下一行是 mov ebp esp,意思是把esp中储存的地址赋给ebp。让我们观察一下监视窗口:
mov ebp,esp 执行前:
mov ebp,esp 执行后:
下一行是 sub esp,0E4h 意思就是把esp中存储的地址减去一个0E4h。
sub esp,0E4h 执行前:
sub esp,0E4h 执行后:
这个新创建的空间其实就是main函数的栈帧,而0E4h则是计算机通过一些算法计算出的,具体多少要看main函数。
下一行是push ebx 意思与上面的push ebp 相似,这里的ebx指的是寄存器ebx中存储的数据。push esi和push edi 于此类似,都是“压栈”操作。
push ebx:
push esi:
push edi:
接下来,为了方便观察,我们将“显示符号名”再次勾选上,汇编就变成了:
0046141C lea edi,[ebp-0E4h]
00461422 mov ecx,39h
00461427 mov eax,0CCCCCCCCh
0046142C rep stos dword ptr es:[edi]
lea 的全称是“load effective address” ,它与mov类似,区别是mov edi,[ebp-0E4h]指的是把地址为ebp-0E4h的数据赋给edi,而lea edi,[ebp-0E4h]是直接把ebp-0E4h这个地址赋给edi;mov ecx,39h 是把39h赋给ecx; mov eax,0CCCCCCCCh 是把0CCCCCCCCh 赋给eax;rep是重复的意思,重复干什么呢?重复stos,stos又是什么呢?stos是赋值的意思,怎么赋值呢?是把eax中的数据(也就是0CCCCCCCCh ) 赋给从地址为edi开始(es:[edi])的双字节数据(dword ptr),重复39h次,每重复一次,edi所指向的地址就变化一次,如果设置了direction flag, 那么edi所指向的地址会减小, 如果没有设置direction flag, 那么edi所指向的地址会增大,这里没有设置direction flag,所以是增大,我们来对比一下:
rep stos dword ptr es:[edi]执行前
rep stos dword ptr es:[edi]执行后
可以看到,有一大片地址所储存的数据都变成了 0CCCCCCCCh,而在执行后,edi的值也等于ebp了。
还是为了观察,再取消“显示符号名”:
int a = 10;
0046142E mov dword ptr [ebp-8],0Ah
int b = 20;
00461435 mov dword ptr [ebp-14h],14h
int c = 0;
0046143C mov dword ptr [ebp-20h],0
下一行是mov dword ptr [ebp-8],0Ah,意思就是把0Ah(也就是10),赋给ebp-8处的(dword ptr)双字节数据,这就是对局部变量a进行初始化,所以你现在知道为什么要对局部变量初始化了吧,如果不初始化,现在的a==0CCCCCCCCh;mov dword ptr [ebp-14h],mov dword ptr[ebp-20h],0 都是一样的,都是给某个地址处的数据赋值,你可以观察内存窗口,获得更直观的理解。
c = Add(a, b);
00461443 mov eax,dword ptr [ebp-14h]
00461446 push eax
00461447 mov ecx,dword ptr [ebp-8]
0046144A push ecx
0046144B call 004610E1
00461450 add esp,8
00461453 mov dword ptr [ebp-20h],eax
mov eax,dword ptr [ebp-14h] 的意思是把ebp-14h处的数据(也就是20)赋给寄存器eax;push eax是压栈。
压栈前:
压栈后:
push ecx mov ecx,dword ptr [ebp-8]也是同样道理:
压栈后:
这两步实际上就是对函数Add准备进行传参。
call 004610E1 可以细分为两步:
第一步,跳转到Add处,第二步,对下一个指令的地址(也就是00461450)进行压栈处理。
执行前:
按下F11,而不是F10,执行call 004610E1:
再次按下F11,正式进入Add函数:
int Add(int x, int y)
{
004613C0 push ebp
004613C1 mov ebp,esp
004613C3 sub esp,0CCh
004613C9 push ebx
004613CA push esi
004613CB push edi
004613CC lea edi,[ebp+FFFFFF34h]
004613D2 mov ecx,33h
004613D7 mov eax,0CCCCCCCCh
004613DC rep stos dword ptr es:[edi]
这些指令和上面都是一样的,就不说了。
push ebp:
mov ebp,esp
sub esp,0CCh
push ebx
push esi
push edi
勾选“显示符号名”
004613CC lea edi,[ebp-0CCh]
004613D2 mov ecx,33h
004613D7 mov eax,0CCCCCCCCh
004613DC rep stos dword ptr es:[edi]
也是把Add函数的栈帧中的数据初始化成0CCCCCCCCh。
初始化前:
初始化后:
然后取消“显示符号”:
int z = 0;
004613DE mov dword ptr [ebp-8],0
z = x + y;
004613E5 mov eax,dword ptr [ebp+8]
004613E8 add eax,dword ptr [ebp+0Ch]
004613EB mov dword ptr [ebp-8],eax
return z;
004613EE mov eax,dword ptr [ebp-8]
}
mov dword ptr [ebp-8],0 的意思是,把储存在ebp-8处的数据赋为0(也就是z)。
mov eax,dword ptr [ebp+8] 的意思是,把ebp+8处地址的数据赋给eax,我们之前不是说有两步是对函数Add准备传参吗?那ebp+8处的数据不就是ecx(即10)吗。
add eax,dword ptr [ebp+0Ch] 意思是把地址为ebp+0Ch(0Ch就是12)的数据eax(20)加到(add的意思)eax中,10+20等于30,所以现在eax==30。
mov dword ptr [ebp-8],eax 就是把eax中的数据赋给地址为ebp-8处的数据(即z)。
moveax,dword ptr [ebp-8] 就是把地址为ebp-8处的数据(z)赋给eax.
004613F1 pop edi
004613F2 pop esi
004613F3 pop ebx
004613F4 mov esp,ebp
004613F6 pop ebp
004613F7 ret
然后是pop edi ,pop与push相对,push是压栈,而pop是出栈:并且把栈的数据赋给edi
pop esi :
pop ebx :
mov esp,ebp 就是把esp指向的地址变为ebp指向的地址,就变成了这样:
pop ebp: ebp所指向的地址变为main的ebp:
ret 就是跳转到00461450,并弹出00461450 :当初储存00461450就是为了方便回来。
弹出前:
弹出后:
c = Add(a, b);
00461450 add esp,8
00461453 mov dword ptr [ebp-20h],eax
add esp,8 :把esp所指向的地址+8 如下:
mov dword ptr [ebp-20h],eax :把eax中的值赋给地址为ebp-20h的数据,要注意eax中的值是Add的返回值。所以你现在知道这个返回值是怎么返回的吧。