浅谈函数栈帧

Posted 东条希尔薇

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了浅谈函数栈帧相关的知识,希望对你有一定的参考价值。

作者的码云地址:https://gitee.com/dongtiao-xiewei

后续作者会更新力扣的每日一题系列,原代码会全部上传码云,推荐关注哦~笔芯~

 

目录

 

内存管理和函数栈帧

一些准备工作

main函数栈帧

push压栈操作

什么是压栈?出栈?

ebp和esp

main函数空间的开辟

变量的创建以及传参

创建变量

传参(形式参数的创建):

 进入函数内部

Add函数栈帧开辟

 参数传递与返回

重新回到main函数

总结与提示 


内存管理和函数栈帧

我们知道计算机分配内存时主要分为以下的几个区域

我们也知道,函数是在栈区上开辟空间的。

每一次的函数调用(包括main函数,自定义函数等)都会在栈区上开辟足够大的空间 ,用于本次函数中的内部操作(数据保护,局部变量控制),这块空间我们称为函数栈帧。

这篇文章可以为大家解答以下问题:

  1. 局部变量怎么创建?
  2. 为什么局部变量具有随机值?
  3. 函数怎么传参?
  4. 形参与实参的关系?
  5. 函数的调用怎么进行?
  6. 函数怎么返回值?

由于文章较长,而且涉及反汇编,也就是汇编语言的知识,如果读者想知道答案,可以直接看栈区图和总结与提示板块。

我们的研究方式:

  1. VS2019
  2. 编写一段简单的程序
  3. 在调试过程中查看反汇编过程

正文分界线


正文分界线

一些准备工作

首先在VS2019中敲出以下代码

//test.c
int Add(int x,int y)
{
    int z=0;
    z=x+y;
    return z;
    
}
int main()
{
    int a=0;
    int b=0;
    int c=0;
    c=Add(a,b);
    printf("%d\\n",c);
    return 0;
}

按F10开始逐过程调试,F11可以逐语句调试(也就是进入函数内部)

然后点击反汇编选项,将会观察到以下代码

 这是这个代码全部的反汇编代码

int main()
{
000818B0  push        ebp  
000818B1  mov         ebp,esp  
000818B3  sub         esp,0E4h  
000818B9  push        ebx  
000818BA  push        esi  
000818BB  push        edi  
000818BC  lea         edi,[ebp-24h]  
000818BF  mov         ecx,9  
000818C4  mov         eax,0CCCCCCCCh  
000818C9  rep stos    dword ptr es:[edi]  
000818CB  mov         ecx,offset _2A160C42_test@c (08C003h)  
000818D0  call        @__CheckForDebuggerJustMyCode@4 (08131Bh)  
    int a = 0;
000818D5  mov         dword ptr [a],0  
    int b = 0;
000818DC  mov         dword ptr [b],0  
    int c = 0;
000818E3  mov         dword ptr [c],0  
    c = Add(a, b);
000818EA  mov         eax,dword ptr [b]  
000818ED  push        eax  
000818EE  mov         ecx,dword ptr [a]  
000818F1  push        ecx  
000818F2  call        _Add (0810B4h)  
000818F7  add         esp,8  
000818FA  mov         dword ptr [c],eax  
    printf("%d\\n", c);
000818FD  mov         eax,dword ptr [c]  
00081900  push        eax  
00081901  push        offset string "%d\\n" (087B30h)  
00081906  call        _printf (0810D2h)  
0008190B  add         esp,8  
    return 0;
0008190E  xor         eax,eax  
}
00081910  pop         edi  
00081911  pop         esi  
00081912  pop         ebx  
00081913  add         esp,0E4h  
00081919  cmp         ebp,esp  
0008191B  call        __RTC_CheckEsp (081244h)  
00081920  mov         esp,ebp  
00081922  pop         ebp  
00081923  ret  

这里笔者只讲与本文相关,也就是printf以前的汇编指令。

注意:由于编译器的不同,测试结果可能与本文不同,此文仅做参考~

main函数栈帧

push压栈操作

什么是压栈?出栈?

我们知道栈区的使用规则是:先使用高地址,再使用低地址,回收也是先回收最顶上的地址,也就是低地址,再回收高地址,也就是先进后出。

这样我们先分析以下几行反汇编代码:

000818B0  push        ebp  
000818B1  mov         ebp,esp  
000818B3  sub         esp,0E4h 

哦对了,还没为大家介绍ebp和esp是啥,马上补上~

ebp和esp

首先甩出定义:是计算机中可以存放地址的寄存器,用于维护函数栈帧

那么可能大家又好奇了,啥是维护函数栈帧啊?

我们已经知道,ebp和esp寄存器是用于存放地址的,那么是存放哪里的地址呢?

后文大家可以找到答案,不过还是先把结论甩在这儿吧:

  1. esp用于存放栈底指针,指向被维护函数最高地址
  2. ebp用于存放栈顶指针,指向被维护函数最低地址

 所以,位于esp和ebp之间的空间,也就是内存为此次函数分配的空间,也就是函数栈帧啦~

 解释完了,我们接下来执行这几句命令

main函数空间的开辟

这是执行到mov指令时的esp和ebp的地址

可以知道,第一步push指令将预开辟的main函数的最高地址存放至ebp中,第二步将ebp存放的地址同时存放到esp中

接下来执行这几条指令

000818B3  sub         esp,0E4h  
000818B9  push        ebx  
000818BA  push        esi  
000818BB  push        edi  
000818BC  lea         edi,[ebp-24h]    
000818C9  rep stos    dword ptr es:[edi]

第一行,我们将esp往上移动,也就是往低地址移动0E4h这么大的字节

第二行,将ebx,esi,edi,分别执行压栈操作

第四行,LEA(load effective address)读取此时esp的地址,为后文做下铺垫

最后一行,将此时ebp和esp之间的空间全部初始化为cc cc cc cc

这里是main函数空间开辟的关键步骤的栈区图

 这里也可以顺便解释为什么变量没有初始化是随机值的原因~

不同编译器初始化的值不一样,但VS2019是cc cc cc cc,对应汉字也就是大家熟知的烫烫烫。。。

至此,main函数空间开辟完毕~

变量的创建以及传参

创建变量

由以下几段反汇编代码实现

    int a = 0;
000818D5  mov         dword ptr [a],0  
    int b = 0;
000818DC  mov         dword ptr [b],0  
    int c = 0;
000818E3  mov         dword ptr [c],0 

以下是创建变量时的栈区图

传参(形式参数的创建):

先甩结论:形式参数创建在main函数的顶部,并不在函数内部创建

以下几行代码可以解释函数传参

000818EA  mov         eax,dword ptr [b]  
000818ED  push        eax  
000818EE  mov         ecx,dword ptr [a]  
000818F1  push        ecx  

 由于局部变量有先进后出,所以为了确保a能够先被调用,a将比b后创建,这样确保了a会被b先使用,也就是参数是从右向左传递的

这几行代码反应成汉语就是:

将b的值存进eax中并进行压栈操作,再对a进行相同操作

当然,进行了这步操作,为了能维护函数,还需要将ecx最顶端的地址存放在esp中 

然后接下来一条指令

000818F2  call        _Add (0810B4h) 

将add的地址存放在顶部,这次一个伏笔,后文会提到~

 进入函数内部

这是函数内部的反汇编代码

int Add(int x, int y)
{
00081770  push        ebp  
00081771  mov         ebp,esp  
00081773  sub         esp,0CCh  
00081779  push        ebx  
0008177A  push        esi  
0008177B  push        edi  
0008177C  lea         edi,[ebp-0Ch]  
0008177F  mov         ecx,3  
00081784  mov         eax,0CCCCCCCCh  
00081789  rep stos    dword ptr es:[edi]  
0008178B  mov         ecx,offset _2A160C42_test@c (08C003h)  
00081790  call        @__CheckForDebuggerJustMyCode@4 (08131Bh)  
    int z = 0;
00081795  mov         dword ptr [z],0  
    z = x + y;
0008179C  mov         eax,dword ptr [x]  
0008179F  add         eax,dword ptr [y]  
000817A2  mov         dword ptr [z],eax  
    return z;
000817A5  mov         eax,dword ptr [z]  

}
000817A8  pop         edi  
000817A9  pop         esi  
000817AA  pop         ebx  
000817AB  add         esp,0CCh  
000817B1  cmp         ebp,esp  
000817B3  call        __RTC_CheckEsp (081244h)  
000817B8  mov         esp,ebp  
000817BA  pop         ebp  
000817BB  ret

Add函数栈帧开辟

这段代码与main函数同理,为Add开辟函数栈帧

00081770  push        ebp  
00081771  mov         ebp,esp  
00081773  sub         esp,0CCh  
00081779  push        ebx  
0008177A  push        esi  
0008177B  push        edi  
0008177C  lea         edi,[ebp-0Ch]  
0008177F  mov         ecx,3  
00081784  mov         eax,0CCCCCCCCh  
00081789  rep stos    dword ptr es:[edi]  
0008178B  mov         ecx,offset _2A160C42_test@c (08C003h)  
00081790  call        @__CheckForDebuggerJustMyCode@4 (08131Bh)  

 参数传递与返回

    int z = 0;
00081795  mov         dword ptr [z],0  
    z = x + y;
0008179C  mov         eax,dword ptr [x]  
0008179F  add         eax,dword ptr [y]  
000817A2  mov         dword ptr [z],eax  
    return z;
000817A5  mov         eax,dword ptr [z] 

由这几段的代码得出结论,计算可以直接在eax,ecx,也就是临时变量上进行

最后将得出的结果放入eax中

000817A8  pop         edi  
000817A9  pop         esi  
000817AA  pop         ebx  
000817AB  add         esp,0CCh  
000817B1  cmp         ebp,esp  
000817B3  call        __RTC_CheckEsp (081244h)  
000817B8  mov         esp,ebp  
000817BA  pop         ebp  
000817BB  ret

1-3行代码涉及将最顶上三个元素弹出

而其中mov esp,ebp(将ebp的地址存储到esp地址中)这条指令,成功实现了将Add函数栈帧回收

pop ebp 指令将ebp弹入原main最下部,可以继续维护原main函数

而最后的ret指令,可以实现回到main函数中call指令发出的地方,也就是存放地址的地方,可以实现整个程序能够继续按照流程继续下去。

重新回到main函数

000818F7  add         esp,8  
000818FA  mov         dword ptr [c],eax  

第一步,将原来为临时变量x,y开辟的空间回收

第二步,将eax中存放的z的值传给c

至此,成功实现z的返回

总结与提示 

  1. 局部变量在main函数栈帧中创建,数据类型决定了内存修改权限大小
  2. 不同的编译器初始化放进的值不一样,从而导致了不初始化将会出现随机值的情况
  3. 函数传参是在main最顶上执行压栈操作,从右到左传参,并没有创建在函数内部
  4. 形参是实参的一份临时拷贝
  5. 函数执行必须先创建函数栈帧,再进行函数操作
  6. 函数若要返回值,需要先将返回值暂时存在寄存器中,方便函数空间被回收后返回

本期内容到此结束啦!由于作者水平有限,文章中如有不足与疏漏之处在所难免,希望各位大佬提出你们宝贵的意见!笔芯~

以上是关于浅谈函数栈帧的主要内容,如果未能解决你的问题,请参考以下文章

浅谈栈帧

浅谈栈帧(一)

函数栈帧问题

函数栈帧的创建与销毁,带你了解代码底层原理

函数栈帧 详解

函数栈帧 详解