前言(可能有的疑问)
我们都知道,C程序是以函数为基本单位的。
我们在调用时不免会产生疑问:
- 函数是如何被调用的?
- 函数的返回值是如何保存或者带回的?
- 函数参数是如何传递的?
- 局部变量是如何创建的?
- 为什么局部变量不初始化内容是随机的?
- 函数调用时参数是如何传递的?传递顺序是怎样的?
这些问题不免都会与函数栈帧扯上关系
希望在看完本篇博客后,大家能对函数调用和维护有更深层次的理解。
在开始介绍函数栈帧之前,想给大家介绍一下关于内存方面的知识
函数栈帧的创建和销毁解析
关于计算机内存
在计算机内存中,主要分为三个区域,分别是栈区,堆区和静态区
堆区中,主要存放动态内存分配,就是通过new,malloc,realloc分配的内存块,编译器不负责释放工作,需要用程序区专门释放。分配方式类似数据结构的链表。“内存泄漏”常说的就是堆区。
静态区,主要存放程序全局变量和静态变量(static),程序结束后系统自动释放。
最后,栈区,在经典计算机科学中,栈被定义为一种特殊的容器,用户可以将数据压入栈中(入栈,push),也可以将以及压入栈中的数据弹出(出栈,pop),但这个容器必须遵循一条规则:先入栈的数据后出栈。就像一个装卡牌的盒子,最先放进去的卡牌最后后才能拿出。在计算机系统中,栈是一个具有以上属性的动态区域。程序可以将数据压入栈中,也可以将数据从栈中弹出。压栈占用栈区内存,而出栈相当于释放占用的那一部分内存。
在经典的操作系统中,栈总是向下增长(高地址到低地址),翻译成人话:栈中储存数据是先从高地址开始依次往低地址储存。
我们常见的i386或者x86-64下,栈顶由成为esp的寄存器进行定位。
认识相关寄存器和汇编指令
相关寄存器
eax:通用寄存器,保留临时数据,常用与返回值
ebx:通用寄存器,保留临时数据
ebp:栈底寄存器
esp:栈顶寄存器
eip:指令寄存器,保存当前指令下一条指令的地址
相关汇编命令
mov:数据转移指令
push:数据入栈,同时esp栈顶寄存器发生改变
pop:数据弹出指定位置,同时esp栈顶寄存器发生改变
sub:减法命令
add:加法命令
call:函数调用,1.压入返回地址 2.转入目标函数
jump:通过修改eip,转入目标函数,进行调用
ret:恢复返回地址,压入eip类似pop eip命令
预备知识
首先我们了解一些预备知识才能帮助我们理解函数栈帧的创建和销毁
- 每一次函数调用,都要在栈区为本次函数调用开辟空间,也就是函数栈帧的空间。
- 这块空间的维护使用了2个寄存器:esp和ebp,ebp记录的是栈底的地址,esp记录的是栈顶的地址。
如图:
3.不同的编译环境,函数调用的过程中栈帧的创建和销毁略有差异,具体取决于编译器的实现。
函数调用栈堆
演示代码
#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; }
这段代码,我们在VS2019上调试,运行进入堆栈后,我们观察堆栈的调用,【现实外部代码】,就可以观察到下图:
我们可以清晰的看到Add函数在栈区中确实被调用了,甚至可以观察到在main函数之前,是由其他函数的调用进而调用main函数的,但今天main之前的调用暂时先不考虑。
在每个函数在调用的过程中,都应该有自己的栈帧,同样,今天我们主要讲main和Add函数的栈帧。
代码反汇编(主函数)
在VS2019编译器中,我们可以将这段代码反汇编,来观察它的底层运行逻辑
int main() { //函数栈帧的创建 00BE1820 push ebp //将ebp压入栈中 00BE1821 mov ebp,esp //esp指针指向的位置赋给ebp使ebp指向栈底 00BE1823 sub esp,0E4h//将esp指针上移,相当于为main函数运行开辟空间 00BE1829 push ebx //三个push将ebx,esi,edi三个寄存器压入栈中,本篇不具体讨论 00BE182A push esi 00BE182B push edi 00BE182C lea edi,[ebp-24h]//将ebp-24h的地址赋给edi 00BE182F mov ecx,9 //以下三条指令主要作用是将新开辟空白空间初始化为0xCC 00BE1834 mov eax,0CCCCCCCCh 00BE1839 rep stos dword ptr es:[edi] //main函数中的核心代码 int a = 3; 00BE183B mov dword ptr [ebp-8],3//在ebp-8的位置开辟a的空间并赋值 int b = 5; 00BE1842 mov dword ptr [ebp-14h],5//在ebp-16h的位置开辟b的空间并赋值 int ret = 0; 00BE1849 mov dword ptr [ebp-20h],0//和a,b同理 ret = Add(a, b); 00BE1850 mov eax,dword ptr [ebp-14h]//将b的值赋给寄存器eax 00BE1853 push eax//将eax压入栈中,相当于传参 00BE1854 mov ecx,dword ptr [ebp-8]//将a的值赋给寄存器ecx 00BE1857 push ecx//将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 }
解析以上代码,不免需要用图来解释了
//函数栈帧的创建 00BE1820 push ebp //将ebp压入栈中 00BE1821 mov ebp,esp //esp指针指向的位置赋给ebp使ebp指向栈底 00BE1823 sub esp,0E4h//将esp指针上移,相当于为main函数运行开辟空间 00BE1829 push ebx //三个push将ebx,esi,edi三个寄存器压入栈中,本篇不具体讨论 00BE182A push esi 00BE182B push edi 00BE182C lea edi,[ebp-24h]//将ebp-24h的地址赋给edi 00BE182F mov ecx,9 //以下三条指令主要作用是将新开辟空白空间初始化为0xCC 00BE1834 mov eax,0CCCCCCCCh 00BE1839 rep stos dword ptr es:[edi]
以上这段代码初始化了main函数的代码空间
再继续往下介绍之前,是否注意到为被使用的空间统一初始化成了0xCC,当你恰好再这个地方开辟了空间却没有赋值时,打印出来就回是0xCC的汉字编码,也就是大家曾遇到的“烫烫烫”,是不是感觉有趣的知识又增加了呢?
而再往下一直到调用Add函数的代码,具体过程如下
我再专门出一张关于函数调用的图解,这一部分相对复杂些
同时我们也应时刻注意,在调用主函数的过程中,esp和ebp是始终维护着main函数的栈帧,esp随着压栈和弹出也做着变动。 在此段代码里,我们已经可以看到call函数开始调用以及eax,ecx等寄存器开始传参了,接下来,将具体讲讲传参后栈的空间分配及运行。
代码反汇编(Add函数)
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//这三句重新将两个维护Add函数的指针返回去维护主函数 00BE1784 pop ebp 00BE1785 ret
下图较为完善的图解了在调用Add函数过程中esp,ebp指针的变换,以及内存的分配过程
在图解了Add函数的调用和运行之后,就到了最后栈帧销毁的环节
代码反汇编(栈帧销毁)
这里截取Add函数栈帧销毁的那一部分详细讲解
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指令下一条指令的地址处,继续往下执行。
最终运行(寄存器返回值)
在调用完Add函数,回到main函数的时候,继续往下执行,可以看到:
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中去读取返回值的。
填坑
讲到这里,鉴于对大家理解力的信任,大家对前面我提出的问题应该解决了个七七八八,但是作为一个有坑必填的博主,还是想专门把前面的疑问都一一解答。
- 函数是如何被调用的?
先从右向左将参数压入栈中,再通过改变esp,ebp的指针,开辟一片空间供函数运行。
- 函数的返回值是如何保存或者带回的?
通过将返回值放入寄存器eax,最后返回eax的值就是带回的返回值
- 函数参数是如何传递的?
从右往左依次将参数压入栈中,同时根据ebp指针位置适时调用传入参数
- 局部变量是如何创建的?
通过占用栈帧开辟的一部分空间
- 为什么局部变量不初始化内容是随机的?
未初始化的随机变量是上一次运行遗留下来的或者开辟空间时初始化出来的值,所以其中的内容是随机的
- 函数调用时参数是如何传递的?传递顺序是怎样的?
通过将参数压栈,传递顺序从右往左
结语
到这里给大家完整的演示和解说了main函数栈帧的创建,Add函数栈帧的创建和销毁的过程,相信大家已经更加了解函数的调用过程,函数传参方式了。
最后,如果本篇文章有任何讲解错误以及不正确表达,可以在评论区指出,我也会积极去改正。
如果觉得本篇文章对你有帮助的话,还请点个小小的赞加个收藏,感谢大家的支持!
比心(> - <)---♥