1. 前言
编程之路,大道至简。在学习编程时,我们需要一些“功法”来帮助自己修炼,让自己和他人之间拉开距离。本篇功法致力于讲解函数栈帧部分知识,让你对从前无法解答的函数相关知识,做出更好的理解,接下来,让anduin带领大家入门这篇功法——《函数栈帧的创建与销毁》。
2. 问题引入
在学习基础知识时,我们对于以下问题都有许多困惑?
这夺命七连招
你是否能接住?
- 局部变量是怎么创建的?
- 为什么未初始化局部变量的值是随机值?
- 函数如何传参?传参顺序是怎样的?
- 形参和实参是什么关系?
- 函数是怎么调用的?
- 函数调用后如何返回?
- 返回值如何返回?
如果无法做出“招式”来应对,不必担忧,这是每一名程序员在筑基时总会遇到的瓶颈期,只要吃透这本功法,筑基就入门了,让我们一起修炼💥!
3. 前提准备
在学习函数栈帧的销毁和创建前,我们需要对讲解过程中的一些寄存器和汇编指令初步了解。
3.1 寄存器
- eax:常用寄存器,常用于存储函数调用的返回值
- esp(重要):栈顶寄存器,记录栈顶的地址
- ebp(重要):栈底寄存器,记录栈底的地址
其他寄存器均为常用寄存器,用于保留数据。
3.2 汇编指令
mov:数据移动
sub:减法命令
add:加法命令
push:压栈,从栈顶放一个元素,改变esp的位置
pop:出栈,从栈顶删除一个元素,改变esp的位置
call:函数调用
lea:load effective address 加载有效地址
rep:重复指令
stos:把寄存器中的值拷贝到指定地址处
4. 函数栈帧的维护
在创建函数时,操作系统都会在栈区
上为函数开辟一块空间。而ebp
和esp
就分别处于栈顶
和栈底
,他们之间的区域就是这个函数的函数栈帧
。ebp在栈底,储存栈底的地址,叫做栈底指针
,esp在栈顶,储存栈顶的地址,叫做栈顶指针
。
栈底到栈顶地址由高变低。
图示中,esp和ebp维护的是main函数的函数栈帧
,但是当调用
另一个函数时,esp和ebp就需要维护调用函数的函数栈帧
。
5. 如何调用堆栈
调用堆栈是编译器的一种机制,可以在程序调用多个函数时,追踪每个函数在完成执行时应该返回控制的点,观察函数之间的调用关系。
要调用堆栈。这一操作需要按F10
,进入调试状态,再在窗口中点击调用堆栈
。
继续按F10,到程序结束时,调用堆栈界面会出现如下情况:
而__tmainCRTStartup()
和mainCRTStartup()
这两个函数又是什么?在crtexe.c
文件中观察:
所以发现main函数是被__tmainCRTStartup()调用的而__tmainCRTStartup()又被mainCRTStartup()调用,而Add函数又被main函数调用。而平常main函数的返回值,就被放在mainret中。
上面我们说到,每一个函数被调用时都会开辟空间,那么这么多函数被调用,对于这个程序,在栈区上函数的栈帧就是这样:
6. 函数栈帧的创建和销毁
通过以上两部分内容的了解,我们对函数栈帧有了一个初步的认识,接下来就进入正题。
以简单函数作为讲解案例:
#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; }
鼠标右击代码,转到反汇编
,查看汇编代码,并取消勾选符号名
,从汇编角度进行讲解:
6.1 main函数栈帧的创建
首先,由于main函数是被__tmainCRTStartup()
所调用的,所以一开始栈区为:
接着我们再观察main函数中的汇编代码:
汇编指令讲解:
- push ebp:将ebp中的值压栈,此时ebp的值位于
栈顶
,栈顶指针esp的位置要移到栈顶的ebp
处,而地址从上到下由低到高
,所以esp处的地址的值减 4
。
move ebp,esp:将esp的值放入ebp中,也就是说栈底指针ebp的值改变,ebp位置移动
到__tmainCRTStartup的esp处
,此时esp和ebp的值相等。产生了main函数的ebp
。
sub esp,0E4h:将esp中的值减0E4h。这时esp的值减小,esp的位置上移,这时就产生了main函数的esp。此时esp到ebp的一块很大的空间就为main函数的函数栈帧。
push ebx:将ebx的值压栈,此时ebx的值位于栈顶,栈顶指针esp的位置上移,esp的值减4。
push esi:将esi的值压栈,此时esi的值位于栈顶,栈顶指针esp的位置上移,esp的值减4。
push edi:将edi的值压栈,此时edi的值位于栈顶,栈顶指针esp的位置上移,esp的值减4。
查看内存
和监视
,发现以上三个寄存器的次序为edi -> esi -> ebx
,且esp的值与edi的地址:0x00dEF66C
相同。
lea edi,[ebp+FFFFFF1Ch]:显示符号名,将[ebp - 0E4h]加载到edi中。这时的这个位置就是之前main函数的函数栈帧顶部的位置,将这个值放入edi。
move ecx,39h:把39h放入ecx寄存器中。
move eax,0CCCCCCCCh:把0CCCCCCCCh放入eax寄存器中。
rep stos dword ptr es:[edi]:rep重复指令,将ecx寄存器中数据的值为次数,每次重复ecx的值都会减少,stos表示把eax的值拷贝到指定的地址,word为两个字节,d为double,也就是双字,告诉stos一次拷贝双字的地址,也就是拷贝CCCCCCCC到目的地址。
拷贝的地址的范围:ebp - 0E4h ~ ebp
模块对应过程图:
经以上过程,main函数的函数栈帧就开辟完成
。
6.2 main函数局部变量的创建和函数调用
函数栈帧开辟完成后,需要创建局部变量
,以及调用Add函数
,我们查看以下过程的汇编代码:
注:之前开辟的CCCCCCCC的区域大小均为4个字节
。
汇编指令讲解:
6.2.1 局部变量初始化
move dword ptr [ebp - 8],0Ah:将0Ah(十进制:10)放到ebp - 8中,即ebp向上2个单位处。
(想象一下,如果这里变量并没有初始化,那么这一动作,在变量中放的值为CCCCCCCC,这就是烫烫烫,就是随机值)
move dword ptr [ebp - 14h],14h:将14h(10进制:20),放到ebp - 20中,即ebp向上5个单位处。所对应数据在a变量向上3个单位处(位置取决于编译器)。
move dword ptr [ebp - 20h],0:将0,放到ebp - 32中,即ebp向上8个单位处。所对应数据在b变量向上3个单位处。
这三步对应的内存分布:
6.2.2 函数调用和传参
move eax,dowrd ptr [ebp - 14h]:将ebp - 14h放到eax寄存器中,也就是将20放到eax中。该步为局部变量b传参。
push eax:将eax的值压栈,此时eax的值位于栈顶,栈顶指针esp的位置上移,esp的值减4。
move ecx,dowrd ptr [ebp - 8]:将ebp - 8放到ecx寄存器中,也就是将10放到ecx中。该步为局部变量a传参。
push ecx:将ecx的值压栈,此时ecx的值位于栈顶,栈顶指针esp的位置上移,esp的值减4。
通过这四步不难观察到函数传参为从右向左
传参。
call 009F11E0:调用Add函数,请记住这条指令的下一条指令的地址009F1A50
,紧接着按F11,观察内存变化,ecx上方两个单位处的值改变,这个值为该指令的的下一条指令的地址,即让把call下一条指令的地址压栈
。
这五条命令对应的内存分布:
模块对应过程图:
6.3 Add函数调用过程
6.3.1 Add函数栈帧的创建
该过程汇编指令和main函数栈帧创建的汇编指令相似:
汇编指令讲解:
push ebp:将ebp的值压栈,此时ebp的值位于栈顶,栈顶指针esp的位置上移,esp的值减4。
mov ebp,esp:将esp的值放到ebp中,此时ebp(来自main函数),移动到栈顶,和main函数的esp位置相同。
sub esp,0CCh:将esp中的值减0CCh。esp的位置上移,这时就产生了add函数的esp。此时esp到ebp的一块很大的空间就为add函数的函数栈帧。
push ebx:将ebx的值压栈,此时ebx的值位于栈顶,栈顶指针esp的位置上移,esp的值减4。
push esi:将esi的值压栈,此时esi的值位于栈顶,栈顶指针esp的位置上移,esp的值减4。
push edi:将edi的值压栈,此时edi的值位于栈顶,栈顶指针esp的位置上移,esp的值减4。
lea edi,[ebp-0CCh] :显示符号名,将[ebp - 0CCh]加载到edi中,这时的这个位置就是之前add函数的函数栈帧顶部的位置,将这个值放入edi。
mov ecx,33h:把33h放入ecx寄存器中。
mov eax,0CCCCCCCCh:把0CCCCCCCCh放入eax寄存器中。
rep stos dword ptr es:[edi]:将eax中0CCCCCCCCh的值拷贝33h次到相应地址,地址范围:ebp - OCCh ~ ebp
6.3.2 局部变量初始化和计算过程
汇编指令讲解:
mov dword ptr [ebp-8],0 :将0放到ebp - 8中,即ebp向上2个单位处,该步骤为初始化局部变量z。
mov eax,dword ptr [ebp+8] :将ebp + 8放到eax中,即ebp向下2个单位处,为main函数参数a传参的值。
add eax,dword ptr [ebp+0Ch]:将ebp + 0Ch中的值加到eax中,ebp + 0Ch为ebp + 12,为main函数参数b传参的值,也就是10和20相加,此时eax中的值为30。
mov dword ptr [ebp - 8],eax:将eax的值,放到ebp - 8中,ebp - 8就是局部变量z。
当Add函数中x和y变量相加时,发现形参并不是在Add函数中创建的,而是我使用了main函数传参时压栈压进去的空间,说明形参是实参的一份临时拷贝。
6.3.3 计算结果返回
- mov eax,dword ptr [ebp-8] :结果返回时,函数
结束调用
,局部变量z销毁
,为了让值安全返回,将z = 30放入eax寄存器
中。
模块对应过程图:
6.4 Add函数栈帧的销毁
随着计算结果的返回,函数也将结束调用,这时Add
的函数栈帧开始销毁
:
汇编指令讲解:
pop edi:出栈,将edi数据弹出,esp向下移动一个单位。
pop esi:出栈,将esi数据弹出,esp向下移动一个单位。
pop ebx:出栈,将ebx数据弹出,esp向下移动一个单位。
mov esp,ebp:把ebp指向esp,也就是将Add的栈顶指针esp,移向Add函数的栈底指针处。
pop ebp:出栈,把ebp的数据弹出,也就是将Add的栈底指针弹出,这时Add的栈顶和栈底指针均已出栈,Add函数栈帧销毁,且由于ebp弹出,这时的esp为维护main函数的esp,指向位置为call指令下一条指令的地址处。
ret:从栈顶弹出一个值,此时栈顶的值为call指令的下一条指令,于是弹出该值,跳转到call指令下一条指令处,继续执行main函数。
模块对应过程图:
6.5 调用结束
当Add函数栈帧销毁后,栈顶
值为call指令下一条指令
,弹出该值并跳转到该指令处,汇编指令继续执行:
add esp,8:将esp + 8,原先esp由于call指令下一条指令的值弹出而指向下个元素,下两个元素为局部变量a,b传参时开辟的空间,esp + 8跳过两个单位,这时栈顶指针esp位于寄存器ebi处。
mov dword ptr [ebp-20h],eax:将寄存器eax的值放入ebp - 20h(ebp - 32)中,也就是变量c中,此时c的值为30。
这就说明,程序在函数结束调用后,从eax中读取返回值。
而接下来的过程就是main函数销毁栈帧的过程,这里就不再赘述,有兴趣可以自己理解一下…
7. 总过程图
8. 问题解答
局部变量是怎么创建的?
所在的函数栈帧创建完成并初识化为CCCCCCCC后,在函数栈帧区域内,以一块空间作为该局部变量的区域。
为什么未初始化局部变量的值是随机值?
函数栈帧创建完毕后,区域内存放的值为CCCCCCCC,若变量未初始化,则该区域值不变,于是生成随机值。
函数如何传参?传参顺序是怎样的?
实参在传参的时候,会从右往左传参,参数依次放入寄存器中,并压栈,当函数调用时,函数会通过指针偏移量来找到该参数,
传参顺序从右往左。
形参和实参是什么关系?
形参和实参的值相同,但是调用函数找到形参是通过指针偏移量来寻找的,所以改变形参的值不会对实参造成影响。
形参是实参的一份临时拷贝。
函数是怎么调用的?
以原函数的栈顶作为调用函数的栈底指针,为调用函数开辟新的栈帧,用call指令调用函数。
函数调用后如何返回?
调用前在栈顶压入call指令下一条地址,并将上一个函数的栈顶指针作为调用函数的ebp,在函数调用结束后,ebp出栈,找到上一次函数的ebp,回到栈帧空间,由于记住了call指令的下一条地址,用ret指令返回该地址,回到call指令的下方。
返回值如何返回?
通过eax寄存器带回。
在学习这门功法后,这夺命七连招
你是否接住了?如果接住了,那么恭喜你,你的功法已入门。
9. 结语
到这里,本篇功法就到此为止了,编程之路,路途遥远,虽然可能会遇到根骨欠佳(基础不扎实),招式杀伤力低(刷题少),空有其形(画图能力不太行),元神不稳(编码习惯不足),但这些都可以通过自身的努力来完善,希望在我们可以共同在编程之路上领悟我们自己的法则。
如果觉得本篇功法还不错的话,还请道友留下宝贵的三连!
我是anduin,一个C语言初学者,希望我的博客可以为您带来帮助,我们下期见!