本章的内容:
- 什么是函数栈帧?
- 理解函数栈帧能解决什么问题?
- 函数栈帧的创建和销毁解析
本文放到 --> 该专栏内:http://t.csdnimg.cn/poMzA
目录
什么是函数栈帧❓
理解函数栈帧能解决什么问题呢?💢
函数栈帧的创建和销毁解析
预备知识
什么是栈?
认识相关寄存器和汇编指令
相关寄存器
相关汇编命令
必备知识
演示代码:
大体思路:
反汇编代码:
1. _tmainCRTStartup函数栈帧的创建(调用main函数的函数)
2.函数栈帧的创建
3.main函数中的核心代码
🧨call指令🚩
4.Add函数栈帧的创建
5.Add函数中的核心代码🎯
6.Add函数栈帧的销毁
总结:
什么是函数栈帧❓
- 函数参数和函数返回值
- 临时变量(包括函数的非静态的局部变量以及编译器自动生产的其他临时变量)
- 保存上下文信息(包括在函数调用前后需要保持不变的寄存器)
理解函数栈帧能解决什么问题呢?💢
- 局部变量是如何创建的?
- 为什么局部变量不初始化内容是随机的?
- 函数调用时参数时如何传递的?传参的顺序是怎样的?
- 形参和实参的关系是什么呢?
- 函数调用结束后是如何返回的?
函数栈帧的创建和销毁解析
预备知识
什么是栈?
栈(stack )是现代计算机程序里最为重要的概念之一,几乎每一个程序都使用了栈,没有栈就没有函数,没有局部变量,也就没有我们如今看到的所有的计算机语言。在经典的计算机科学中,栈被定义为一种特殊的容器,用户可以将数据压入栈中(入栈,push ),也可以将已经压入栈中的数据弹出(出栈,pop ),但是栈这个容器必须遵守一条规则:先入栈的数据后出栈(First In Last Out , FIFO )。就像叠成一叠的术,先叠上去的书在最下面,因此要最后才能取出。在计算机系统中,栈则是一个具有以上属性的动态内存区域。程序可以将数据压入栈中,也可以将数据从栈顶弹出。压栈操作使得栈增大,而弹出操作使得栈减小。在经典的操作系统中, 栈总是向下增长(由高地址向低地址)的 。在我们常见的i386 或者 x86-64 下,栈顶由成为 esp 的寄存器进行定位的。
认识相关寄存器和汇编指令
相关寄存器
寄存器名称 | 简介 |
eax | 通用寄存器,保留临时数据,常用于返回值 |
ebx | 通用寄存器,保留临时数据 |
ebp | 栈底寄存器(Stack bottom) |
esp | 栈顶寄存器 (stack top) |
eip | 指令寄存器,保存当前指令的下一条指令的地址 |
相关汇编命令
汇编命令 | 解释 |
mov
|
数据转移指令(赋值)
|
push
|
数据入栈,同时
esp栈顶寄存器
也要发生改变
|
pop
|
数据弹出至指定位置,同时
esp栈顶寄存器
也要发生改变
|
sub
|
减法命令
|
add
|
加法命令
|
call
|
函数调用,
1
.
压入返回地址
2.
转入目标函数
|
jump
|
通过修改
eip
,转入目标函数,进行调用
|
ret
|
恢复返回地址,压入
eip
,类似
pop eip
命令
|
必备知识
- 每一次函数调用,都要为本次函数调用开辟空间,就是函数栈帧的空间。
- 这块空间的维护是使用了2个寄存器: esp 和 ebp , ebp 记录的是栈底的地址, esp 记录的是栈顶的地址。
第2点如图所示:
3.函数栈帧的创建和销毁过程,在不同的编译器下创建和销毁是略有差异的,但是大体逻辑是相差不大的,当编译器越高级的时候,函数栈帧的封装越不容易看,所以编译器的环境采用vs2013
演示代码:
#include <stdio.h>
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函数也被调用了,那么它是被谁调用呢?
在VS2013中,main函数也是被其他函数调用的,调用逻辑如下:
反汇编代码:
右击鼠标,打开反汇编
int 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]
//main函数中的核心代码
int a = 3;
00BE183B mov dword ptr [ebp-8],3
int b = 5;
00BE1842 mov dword ptr [ebp-14h],5
int ret = 0;
00BE1849 mov dword ptr [ebp-20h],0
ret = Add(a, b);
00BE1850 mov eax,dword ptr [ebp-14h]
00BE1853 push eax
00BE1854 mov ecx,dword ptr [ebp-8]
00BE1857 push ecx
00BE1858 call 00BE10B4
00BE185D add esp,8
00BE1860 mov dword ptr [ebp-20h],eax
printf("%d\n", ret);
00BE1863 mov eax,dword ptr [ebp-20h]
00BE1866 push eax
00BE1867 push 0BE7B30h
00BE186C call 00BE10D2
00BE1871 add esp,8
return 0;
00BE1874 xor eax,eax
}
1. _tmainCRTStartup函数栈帧的创建(调用main函数的函数)
我们已经知道了,main函数也是被调用的,画出函数栈帧图详解一波:
栈空间的使用是,由高地址到低地址,而main函数是被_tmainCRTStartup的,所以esp与ebp就维护当前的栈帧
①执行push操作
这时候F10按一下, 执行一下push让ebp这个地址压栈
怎么证明ebp压栈成功?
所以说,esp这个栈顶指针指向了ebp这个压栈的值:
②接下来执行mov指令,就是把esp的值赋值给ebp
如下图:
③然后执行sub指令,让esp减去0E4h,换成二进制就是228,,整体流程下图:
当①②执行完后,其实_tmainCRTStartup栈帧的空间已经开辟完毕,当③执行完后,调用了main函数,此时esp、ebp就预开辟好了一块空间给main函数,并维护该栈帧,如下图
2.函数栈帧的创建
接着上文的内容,画出该图:
接着依次push三个寄存器ebx,esi,edi的值入栈中,esp往低地址处移动
通过监视可以看一看
画出图如下:
接下来看这四条指令:
①lea edi, [ebp+FFFFFF1Ch]
解析:
[ebp+FFFFFF1Ch]显示符号名去掉,也就是[ebp-0E4h] (也就是和[esp - OE4h]是同一个位置)
lea - 加载有效地址,即将[ebp-0E4h]的地址加载到edi寄存器中,[ebp-0E4h] - 指向ebp(基准指针寄存器)上减去0E4h(232)个字节位置的内存单元
②mov ecx,39h(准确的次数)
解析:
将立即数 39h
复制到 ecx
寄存器中,使 ecx
寄存器的内容变为 39h
(十进制的57)。
③mov eax,0cccccccCh
解析:
这条指令将立即数 0cccccccCh
复制到 eax
寄存器中,使 eax
寄存器的内容变为 0cccccccCh
④rep stos dword ptr es : [edi]
解析:
- rep 是重复前缀,用于指示指令要重复执行多次,执行的次数由
ecx
寄存器中的计数值决定。 - stos 是字符串存储 (Store String) 的缩写,用于将数据存储到字符串中。
- dword ptr 指明操作数的大小为双字(32位),用于指示要存储的数据的大小。
- es:[edi] 是目标操作数,表示将数据存储到以 es 寄存器为段地址,edi 寄存器为偏移地址的内存位置。
第④点整体来看:该指令的作用是将 eax
寄存器中的值重复写入到以 es:[edi]
为起始地址的内存位置。执行次数由 ecx
寄存器中的计数值确定。
整体①②③④来看:
要把edi这个位置开始(也就是[ebp-0E4h]的地址),向下空间的ecx(次数)放的39h这个值,这么多个dword(4个字节)的数据全部都改成0CCCCCCCCh,图解在下面:
到这,main函数的开辟已经执行完了。
3.main函数中的核心代码
接下来执行以下三句代码:
以a为例子,观察下图:
可得出以下图解:
然后接下来执行以下指令:
首先来看前两条指令:
①
mov
是一个指令,用于将数据从一个位置复制到另一个位置。eax
是一个32位的寄存器,用于存储通用数据。dword ptr
是一个修饰符,用于指示后面的操作数应该被视为32位的双字(即4个字节)。[ebp-14h]
是一个内存引用,它指向位于基址指针ebp
减去14h
(20个字节)的位置。基址指针ebp
是一个用于存储局部变量和函数参数的寄存器。
综上所述,这行代码的作用是将位于 ebp-14h
地址处的32位数据加载到 eax
寄存器中
②
push
是一个指令,用于将数据压入堆栈中。eax
是之前加载了数据的寄存器。
综上所述,这行代码的作用是将 eax
寄存器中的值(20)压入堆栈中
所以,后两条指令同理③④
将位于 ebp-8
地址处的32位数据加载到 ecx 寄存器中,将 ecx 寄存器中的值(10)压入堆栈中
图解:
🧨call指令🚩
函数调用过程
call
是一个指令,用于调用一个函数或子程序。它的作用是将当前指令的下一条指令的地址(返回地址)压入堆栈,并跳转到指定的函数或子程序的地址执行。
按f11,通过call指令就会进入Add函数里面去了(并未真正进入,还要再按一次f11)
call 指令是要执行函数调用逻辑的,在执行call指令之前先会把call指令的下一条指令的地址进行压栈操作,这个操作是为了解决当函数调用结束后要回到call指令的下一条指令的地方,继续往后执行
因此,
call
的作用是将当前指令的下一条指令的地址压入堆栈(00C21450),并跳转到地址为 00C210E1 的函数或子程序的入口点执行
4.Add函数栈帧的创建
再按下f11,这时候才是真正来到add函数内,前面那一堆汇编代码跟main函数栈帧创建逻辑是一样的。
反汇编代码:
前提说明
图解:
5.Add函数中的核心代码🎯
反汇编代码:
①:将值0(初始化z)存储到位于内存中的地址 ebp-8 处的双字(32位)数据中。
②将位于内存中地址 ebp+8 处的双字(32位)数据(当前位置的值为10)加载到寄存器 eax 中。
③将位于内存中地址 ebp+0Ch 处的双字(32位)数据(当前位置的值为20)与寄存器 eax 中的值相加,并将结果存储(两数相加的结果为30)回 eax 寄存器中。
④位于当前堆栈帧中相对于基址寄存器 ebp
偏移 8 字节的内存位置的值(当前值为30)复制到寄存器 eax
中
图解:
6.Add函数栈帧的销毁
代码:
这句代码的意思是:
将位于 ebp-8 地址处的32位数据(值为30)加载到寄存器 eax 中,因为函数出去之后,值就销毁了,但是如果放在寄存器eax内就安全了,相当于用了一个全局的寄存器把返回值保存起来,回到主函数main再用。
然后pop三次,把三个寄存器的地址分别弹出:
接着 mov esp,ebp,就是把ebp当前地址赋值给esp:
接着pop ebp,此时ebp回到main函数函数栈帧的栈底:
说明此时Add函数已经销毁了。
此时最重要的一条指令来了:
当pop ebp之后,只是让我们找到了esp和ebp的栈帧空间,但是当我回到main函数的时候,还应该从call指令的下一调指令的地址开始执行,所以此时恰好栈顶上就放着这个地址
这个ret指令return返回的时候,这个指令其实就是从栈顶弹出了call指令下一条指令的地址,然后跳那去了,接着F10走一下,回来main函数内:
存这个地址(00C21450)就是当函数调用完之后还能回来,从call指令的下一条指令的地址开始执行。
所以图解是这样的:
关于形参变量空间的释放:
返回值是怎么带回来:先把值委托到eax寄存器内,接着回到main函数内部赋值
经过esp+8之后,关于x和y两个形参空间的变量就已经销毁,还给操作系统了。
关于main函数的销毁跟上述Add函数的销毁逻辑相似,也不累赘地列举了。
总结:
1.局部变量是如何创建的?
首先为main函数分配栈帧空间,然后在栈帧空间内初始化一部分空间之后,给局部变量在该栈帧空间内分配一点空间
2.为什么局部变量不初始化内容是随机的?
因为随机值是我们放进去的,如果局部变量给它们初始化,那就是把随机值覆盖了。
3.函数调用时参数时如何传递的?传参的顺序是怎样的?
当我要调用那个函数的的时候,就已经push,push,把这两个参数从右向左开始压栈压进去,当我们进入形参函数Add的时候通过指针的偏移量找回来找到了形参
4.形参和实参的关系是什么呢?
形参确实是我在压栈的时候开辟的空间,形参和实参只是值是相同的,空间是独立的,所以形参是实参的一份临时拷贝,改变形参不会影响实参
5.函数调用结束后是如何返回的?
我们在调用之前就已经把call指令的下一条指令的地址压栈压进去了,当函数调用完要返回的时候,弹出ebp就能找到原始上一个函数调用的ebp,然后指针往下走的时候就能中找到esp的地址,接着跳转到call指令下一条指令的地址,返回值是通过寄存器的方式带回来的
本文结束,感谢来访!