目录
前言
1. 什么是函数栈帧
2. 函数栈帧的创建和销毁解析
2.1 什么是栈?
2.2 认识相关寄存器和汇编指令
2.3 解析函数栈帧的创建和销毁
2.3.1 预备知识
看看入栈:
再看看出栈:
2.3.2main函数与Add函数
准备环境
转到反汇编
小知识:烫烫烫~
拓展了解:
Add函数调用的总结:
个人总结:
3.开篇问题解答
感谢观看,敬请期待更好的作品吧。
本文希望能通俗易懂地讲讲函数栈帧的创建与销毁(偏底层的知识)
用VS2019观察到main函数也是被调函数,而调用main函数的函数也被另一个函数调用。
写一个函数,通过一步步调试观察汇编代码,从而探究理解函数相关的内容在栈上的行为过程。
阅读前需要有函数知识基础,诸君若是不嫌弃的话还请移步至此处了解了解函数:
一文带你深入浅出C语言函数http://t.csdn.cn/uTgM2
开始前,有几个问题,看看你能答出几个。
1.什么是函数栈帧?
2.函数是如何调用的?
3.函数调用时参数时如何传递的?传参的顺序是怎样的?
4.函数的形参和实参分别是怎样实例化的?(实例化一个对象就是为对象开辟内存空间)
5.形参和实参又是什么样的关系呢?
6.函数的返回值又是如何返回的?
7.局部变量是如何创建的?
8.为什么局部变量若不初始化,内容是随机的?
如果毫无压力秒答的话请大佬高抬贵脚移步他处,莫要在我这浪费时间了。
要是答不出几个来的话,这篇文章你应该读一读,接触一下偏底层的知识,深入了解函数栈帧知识,修炼一下自己的编程内功。
让我们一起走进函数栈帧的创建和销毁的过程中吧。
我们在写C语言代码的时候,经常会把一个独立的功能抽象为函数,所以C程序是以函数为基本单位的。
函数栈帧(stack frame)就是函数调用过程中在程序的调用栈(call stack)所开辟的空间,(函数栈帧放在栈区上),这些空间是用来存放:
1.函数参数和函数返回值
2.临时变量(包括函数的非静态的局部变量以及编译器自动生产的其他临时变量)
3.保存上下文信息(包括在函数调用前后需要保持不变的寄存器)。
内存分区简化图:
栈(stack)是现代计算机程序里最为重要的概念之一,几乎每一个程序都使用了栈,没有栈就没有函数,没有局部变量,也就没有我们如今看到的所有的计算机语言。
在经典的计算机科学中,栈被定义为一种特殊的容器,是一种数据结构,用户可以将数据压入栈中(入栈push),也可以将已经压入栈中的数据弹出(出栈,pop),但是栈这个容器必须遵守一条规则:先入栈的数据后出栈(First In Last Out, FILO)。
是一种单通道的容器,只有栈顶一个出入口,就像叠成一叠的书,先叠上去的书在最下面,要先把上面的书取完,要最后才能取出最先叠上的书。
而在计算机系统中,栈则是一个具有以上属性的动态内存区域。程序可以将数据压入栈中,也可以将数据从栈顶弹出。压栈操作使得栈增大,而弹出操作使得栈减小。
在经典的操作系统中,栈总是向下增长(上面低地址下面高地址)的。
在我们常见的i386或者x86-64下,栈顶由称为 esp 的寄存器进行定位的。
(寄存器不属于内存,而是单独的存储空间)
相关寄存器
eax:通用寄存器,保留临时数据,常用于返回值
ebx:通用寄存器,保留临时数据
ebp:栈底寄存器
esp:栈顶寄存器
eip:指令寄存器,保存当前指令的下一条指令的地址
相关汇编命令
(具体的汇编指令不需要很熟悉,知道大概有什么用就行,主要留心整个过程)
mov:数据转移指令
push:数据入栈,同时esp栈顶寄存器也要发生改变(esp存储地址减小,esp指向上移)
pop:数据弹出至指定位置,同时esp栈顶寄存器也要发生改变(esp存储地址增大,esp指向下移)
sub:减法命令
add:加法命令
call:函数调用,分为两步:1. 向栈压入返回地址 2. 指令转入目标函数
jump:通过修改eip,转入目标函数,进行调用
ret:恢复返回地址,向栈压入eip,类似pop eip命令
首先我们达成一些预备知识才能有效的帮助我们理解,函数栈帧的创建和销毁。
1. 每一次函数调用,都要为本次函数调用开辟空间,就是函数栈帧的空间。
2. 这块空间的维护是使用了2个寄存器: esp 和 ebp , ebp 记录的是栈底的地址, esp 记录的是栈顶的地址。
3. 函数栈帧的创建和销毁过程,在不同的编译器上实现的方法大同小异,整体步骤逻辑基本相同,只是一些数值上略有差异。
如图所示:
本次演示以VS2019为例。
不知道大伙之前有没有过这样一个疑惑:
都说main()函数是程序入口,一般都是作为主调函数调用其他函数的,那为什么会有返回值呢?难不成……main函数也是被调函数么?
bingo! 确实如此,我们打开VS2019按下F10进入逐步调试,打开调试窗口中的调用堆栈窗口。
如果打开后只有main函数的话,右击将”显示外部代码“勾选即可。
函数调用堆栈是反馈函数调用逻辑的,那我们可以清晰的观察到,Add函数是由main函数调用的,而main 函数是由invoke_main 函数来调用的。
在 invoke_main 函数之前的函数调用我们就暂时不考虑了
那我们可以确定,invoke_main 函数应该会有自己的栈帧, main函数和 Add 函数也会维护自己的栈帧,每个函数栈帧都由 ebp 和 esp 来维护栈帧空间。
为了让我们研究函数栈帧的过程足够清晰,不要太多干扰,右击项目点开属性页面,我们可以关闭下面的选项,让汇编代码中排除一些编译器附加的代码:
调试到main函数开始执行的第一行,右击鼠标转到反汇编。
注:VS编译器每次调试都会为程序重新分配内存,这只是一次调试代码过程中数据,每次调试略有差异。
好,我们现在来从汇编代码角度看看程序执行过程中,在内存里到底发生了什么。
旁边这一串东西是什么呢?其实是每一条指令(如push,mov等)对应的地址,指令存储在代码区(CodeText)中,记得吗,前面放过内存分区图,有个代码区:
当然它们不是今天的主角,先别管了,我们重点来看看各条指令。
注意:图中每一个格子代表四个字节
我们进入main函数,先创建函数栈帧:
//为main开辟栈帧空间
001D1820 push ebp //将此时的ebp的值压入栈中,存放的是invoke_main函数栈帧的ebp,esp-4,esp上移
001D1821 mov ebp,esp //move指令会把esp的值存放到ebp中,相当于产生了main函数的ebp,这个值就是原来invoke_main函数栈帧的esp
001D1823 sub esp,0E4h //sub会让esp中的地址减去一个16进制数字0xe4,产生新的esp,此时的esp是main函数栈帧的esp,此时结合上一条指令的ebp和当前的esp,ebp和esp之间维护了一个块栈空间,这块栈空间就是为main函数开辟的,就是main函数的栈帧空间,这一段空间中将存储main函数中的局部变量,临时数据,已经调试信息等。
001D1829 push ebx //将寄存器ebx的值压栈,esp-4,esp上移
001D182A push esi //将寄存器esi的值压栈,esp-4,esp上移
001D182B push edi //将寄存器edi的值压栈,esp-4,esp上移
//上面3条指令保存了3个寄存器的值在栈区,这3个寄存器的在函数随后执行中可能会被修改,所以先保存寄
存器原来的值,以便在退出函数时恢复。
初始化栈帧:
//为栈帧空间初始化
001D182C lea edi,[ebp-24h] //先把ebp-24h的地址,放在edi中
001D182F mov ecx,9 //把9放在ecx中
001D1834 mov eax,0CCCCCCCCh //把0xCCCCCCCC放在eax中
001D1839 rep stos dword ptr es:[edi] //将从ebp-24h到ebp这一段的内存的每个字节都初始化为0xCC
上面的程序输出“烫”这么一个奇怪的字,是因为main函数调用时,在栈区开辟的空间的其中每一 个字节都被初始化为0xCC,而arr数组是一个未初始化的数组,恰好在这块空间上创建的,0xCCCC(两个连续排列的0xCC)的汉字编码就是“烫”,所以0xCCCC被当作文本就是“烫”。
我们继续来看局部变量创建与初始化:
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变量
//以上汇编代码表示的变量a,b,ret的创建和初始化,这就是局部的变量的创建和初始化
//其实局部变量的创建是在局部变量所在函数的栈帧空间中创建的
Add函数调用与传参:
其实传参就是把参数push到栈帧空间中
//调用Add函数
ret = Add(a, b);
//调用Add函数时的传参
00BE1850 mov eax,dword ptr [ebp-14h] //传递b,将ebp-14h处放的5放在eax寄存器中
00BE1853 push eax //将eax的值压栈,esp-4,esp上移
00BE1854 mov ecx,dword ptr [ebp-8] //传递a,将ebp-8处放的3放在ecx寄存器中
00BE1857 push ecx //将ecx的值压栈,esp-4,esp上移
注意到了吗?传参时先压入的是b的值,(参数,参数,...)从右往左压入。
跳转到调用函数Add处
//跳转调用函数
00BE1858 call 00BE10B4 //调用函数的指令,接下来就要跳进Add函数里了
//注意,下面的指令要等到Add函数调用完以后再由先前压入的指令地址回到main函数这个地方,才继续进行
00BE185D add esp,8 //让esp的值+8
00BE1860 mov dword ptr [ebp-20h],eax //把eax的值拷贝到ebp-20h地址处
call 指令是要执行函数调用逻辑的,在执行call指令之前先会把call指令的下一条指令的地址进行压栈操作,这个操作是为了当函数调用结束后能回到call指令的下一条指令的地方,然后继续往后执行。
进入Add函数后,先创建并初始化栈帧:
在Add函数中创建栈帧的方法和在main函数中是相似的,只是在栈帧空间的大小上略有差异而已。
00BE1760 push ebp //将main函数栈帧的ebp保存,esp-4,esp上移
00BE1761 mov ebp,esp //将main函数的esp赋值给新的ebp,ebp现在是Add函数的ebp
00BE1763 sub esp,0CCh //给esp-0xCC,求出Add函数的esp
00BE1769 push ebx //将ebx的值压栈,esp-4,esp上移
00BE176A push esi //将esi的值压栈,esp-4,esp上移
00BE176B push edi //将edi的值压栈,esp-4,esp上移
Add函数内语句执行:
int z = 0;
00BE176C mov dword ptr [ebp-8],0 //将0放在ebp-8的地址处,其实就是创建并初始化z
//接下来计算的是x+y,结果保存到z中
z = x + y;
00BE1773 mov eax,dword ptr [ebp+8] //将ebp+8地址处的数字存储到eax中
00BE1776 add eax,dword ptr [ebp+0Ch] //将ebp+12(即ebp+0Ch)地址处的数字加到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寄存器带回计算的结果,作为函数的返回值。
这里我们也可以看出,赋值与运算都依靠寄存器完成。
图片中的 a' 和 b' 其实就是 Add 函数的形参 x , y 。这里的分析很好的说明了函数的传参过程。
函数在进行值传递调用的时候,形参其实是实参的一份临时拷贝,两者所处空间不同。因此对形参的修改不会影响实参。
Add函数栈帧的销毁:
当函数调用要结束返回的时候,前面创建的函数栈帧也开始销毁。
那具体是怎么销毁的呢?我们看一下反汇编代码。
00BE177F pop edi //在栈顶弹出一个值,存放到edi中,esp+4,esp下移
00BE1780 pop esi //在栈顶弹出一个值,存放到esi中,esp+4,esp下移
00BE1781 pop ebx //在栈顶弹出一个值,存放到ebx中,esp+4,esp下移
00BE1782 mov esp,ebp //再将Add函数的ebp的值赋值给esp,相当于回收了Add函数的栈帧空间
00BE1784 pop ebp //弹出栈顶的值存放到ebp,栈顶此时的值恰好就是main函数的ebp,esp+4,esp下移,此时恢复了main函数的栈帧维护,esp指向main函数栈帧的栈顶,ebp指向了main函数栈帧的栈底。
00BE1785 ret //ret指令的执行,此时栈顶的值是call指令下一条指令的地址,从栈顶弹出,esp+4,esp下移,然后直接跳转到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中去读取返回值的。
main函数栈帧销毁大同小异,就不做赘述。
其实返回对象时内置类型时,一般都是通过寄存器来带回返回值的,返回对象如果时较大的对象时,一 般会在主调函数的栈帧中开辟一块空间,然后把这块空间的地址,隐式传递给被调函数,在被调函数中通过地址找到主调函数中预留的空间,将返回值直接保存到主调函数的。
具体可以参考《程序员的自我修养》一书的第10章。
1. 将main函数的 ebp 压栈
2. 计算新的 ebp 和 esp
3. 将 ebx , esi , edi 寄存器的值保存
4. 计算求和,在计算求和的时候,我们是通过 ebp 中的地址进行偏移访问到了函数调用前压栈进去的函数参数,这就是形参访问。
5. 将求出的和放在 eax 寄存器中准备带回
6.销毁Add函数栈帧,返回eax值
7.程序继续在main函数中运行
内存空间大小是固定的,所谓的开辟内存指的是把一块内存空间变为有效空间得以利用。
用寄存器ebp和esp存储地址来进行内存管理,所谓的创建与销毁内存也是通过对ebp和esp存储的地址的改变来实现的,在ebp和esp之间的空间才是系统分配的空间,其他的都是无权限的空间。
什么是函数栈帧?
答:函数栈帧(stack frame)就是函数调用过程中在程序的调用栈(call stack)所开辟的空间,用以存放
1.函数参数和函数返回值
2.临时变量(包括函数的非静态的局部变量以及编译器自动生产的其他临时变量)
3.保存上下文信息(调用信息)。
函数是如何调用的?
答:先传参,也就是把参数的值分别放在寄存器中,然后再push压入栈中;把主调函数ebp的值和下一条指令的地址push压入栈中,随后进入调用的函数中,创建函数栈帧并初始化,然后执行函数内的语句。
函数调用时参数时如何传递的?传参的顺序是怎样的?
答:其实传参就是把参数push到栈帧空间中,传参时先压入的是后面参数的值,(参数,参数,...)从右往左压入。
函数的形参和实参分别是怎样实例化的?(实例化一个对象就是为对象开辟内存空间)
答:形参通过寄存器的值压栈创建,而实参通过 ebp 存储的地址进行偏移,由编译器决定一块未使用空间创建。
形参和实参又是什么样的关系呢?
答:形参是实参的一份临时拷贝,改变形参不会影响实参。
函数的返回值又是如何返回的?
答:通过寄存器保留副本,不会随栈帧销而销毁,毁待函数调用完栈帧销毁后把寄存器的值拷贝到主调函数中对应的变量里,实现返回值的带回。
局部变量是如何创建的?
答:局部变量是在局部变量所在函数的栈帧空间中创建的,通过 ebp 存储的地址进行偏移,由编译器决定一块未使用空间创建。
为什么局部变量若不人为初始化,内容是随机的?
答:函数栈帧创建后会自动将空间中存储的值全部初始化为一个特定值(如VS2019下为0xcccccccc),编译器不同值也不同。