【C/C++】函数栈帧的创建和销毁

【C/C++】函数栈帧的创建和销毁

  • 这篇文章篇目较长,内容涉及较深,理解起来可能有些许困难,但希望读者能慢慢阅读下去,相信会收获满满;
  • 文章对该问题的解析方法是通过实验进汇编语言行分析(不会汇编语言也没关系会带着大家一起分析),并且会通过分析绘制在栈区中的图示,希望读者在阅读时可以跟着写写画画,跟上节奏,当然可以跟着动手实操就更好。

摘要

  1. 在这篇文章中,将从编译层面出发,介绍函数栈帧创建和销毁过程,也会介绍在函数执行过程中,局部变量创建,函数的形参调用和函数值返回的过程以及调用结束后的收尾工作;

  2. 并且希望在了解函数栈帧创建和销毁过程后,能从中回答出以下问题:

    (1)局部变量怎么创建?

    (2)为什么局部变量的值是随机值?

    (3)函数时怎么传参的呢?传参顺序怎么样?

    (4)形参和实参是什么关系?

    (5)函数调用怎么做?结束后怎么做?能抽象出一个怎么样的模型?

    (6)函数调用结束后是怎么返回的?

  3. 最后,还会建立简易模型更抽象的理解函数栈帧问题?

一. 预备知识

  1. ebp 与 esp 寄存器:该两个寄存器中存放的是地址,是用来维护函数栈帧的;

  2. eax ebx ecx edx… 作为寄存器,了解即可;

  3. 函数栈帧:每个函数调用需要在栈去创建一个空间(一般从高地址到低地址),而所开辟的空间称为该函数的函数栈帧。这部分空间就有ebp与esp来维护。图例如下:在这里插入图片描述

  4. main()函数的调用:在编译器中的文件crtexe.c中可以找到main函数由__tmainCRTStartup()函数调用,在此只需了解即可。

    __tmainCRTStartup(
            void
            )
    {
    
    ...........
    
    #ifdef WPRFLAG
                __winitenv = envp;
                mainret = wmain(argc, argv, envp);
    #else  /* WPRFLAG */
                __initenv = envp;
                mainret = main(argc, argv, envp);
    ...........
    }
    

二. 正文

​ 在此我们通过实验来完成对这个问题的探究:

启动步骤:

编译器:VS2019(不同编译器的实验结果大体一样只会有略微区别)

​ ① 在项目源文件下编写以下代码:

#define _CRT_SECURE_NO_WARNINGS 1
#include<stdio.h>

int Add(int x, int y) {
	return x + y;
}


 int main() 
 {
	int a = 10;
	int b = 20;
	int c = 0;

	c = Add(a, b);

	printf("%d", c);
	return 0;
}

​ ② 按 f10 进入调试状态,通过通过鼠标右键进入反汇编,通过调试栏选择内存与监视,大概会实现以下界面:
在这里插入图片描述

​ ③ 在反汇编窗口右击鼠标,将显示符号名关闭,方便观察地址变化。

​ 完整的反汇编代码如下:

int main() 
 {
008B1E50  push        ebp  
008B1E51  mov         ebp,esp  
008B1E53  sub         esp,0E4h  
008B1E59  push        ebx  
008B1E5A  push        esi  
008B1E5B  push        edi  
008B1E5C  lea         edi,[ebp-0E4h]  
008B1E62  mov         ecx,39h  
008B1E67  mov         eax,0CCCCCCCCh  
008B1E6C  rep stos    dword ptr es:[edi]  
008B1E6E  mov         ecx,offset _E605F406_Test@c (08BC003h)  
008B1E73  call        @__CheckForDebuggerJustMyCode@4 (08B130Ch)  
	int a = 10;
008B1E78  mov         dword ptr [a],0Ah  
	int b = 20;
008B1E7F  mov         dword ptr [b],14h  
	int c = 0;
008B1E86  mov         dword ptr [c],0  

	c = Add(a, b);
008B1E8D  mov         eax,dword ptr [b]  
008B1E90  push        eax  
008B1E91  mov         ecx,dword ptr [a]  
008B1E94  push        ecx  
008B1E95  call        _Add (08B10B4h)  
008B1E9A  add         esp,8  
008B1E9D  mov         dword ptr [c],eax  

	printf("%d", c);
008B1EA0  mov         eax,dword ptr [c]  
008B1EA3  push        eax  
008B1EA4  push        offset string "%d" (08B7BCCh)  
008B1EA9  call        _printf (08B13A2h)  
008B1EAE  add         esp,8  
	return 0;
008B1EB1  xor         eax,eax  
}
008B1EB3  pop         edi  
008B1EB4  pop         esi  
008B1EB5  pop         ebx  
008B1EB6  add         esp,0E4h  
008B1EBC  cmp         ebp,esp  
008B1EBE  call        __RTC_CheckEsp (08B1235h)  
008B1EC3  mov         esp,ebp  
008B1EC5  pop         ebp  
008B1EC6  ret  
int Add(int x, int y) {
008B1740  push        ebp  
008B1741  mov         ebp,esp  
008B1743  sub         esp,0C0h  
008B1749  push        ebx  
008B174A  push        esi  
008B174B  push        edi  
008B174C  lea         edi,[ebp+FFFFFF40h]  
008B1752  mov         ecx,30h  
008B1757  mov         eax,0CCCCCCCCh  
008B175C  rep stos    dword ptr es:[edi]  
008B175E  mov         ecx,8BC003h  
008B1763  call        008B130C  
	return x + y;
008B1768  mov         eax,dword ptr [ebp+8]  
008B176B  add         eax,dword ptr [ebp+0Ch]  
}
008B176E  pop         edi  
008B176F  pop         esi  
008B1770  pop         ebx  
008B1771  add         esp,0C0h  
008B1777  cmp         ebp,esp  
008B1779  call        008B1235  
008B177E  mov         esp,ebp  
008B1780  pop         ebp  
008B1781  ret  

开始分析

① __tmainCRTStartup()函数的函数栈帧建立

​ 通过预备知识(3)可知,在调用main()函数之前,会想调用__tmainCRTStartup()函数,所以该函数在调用main()函数之前,它的函数栈帧已经建立好了,结合预备知识中的(1)(2)两点,可以画出在栈区中的内存分配情况,图示如下:

在这里插入图片描述

② main()函数的函数栈帧建立

​ 首先分析在main()函数汇编语言中,管理ebp与esp建立main()函数的函数栈帧的过程,相关代码(对此也通过序号与注释写下分析过程)如下:

 int main() 
 {
008B1E50  push        ebp  
// 1)将__tmainCRTStartup()的ebp压栈 ,上述步骤同时会使得esp改变指向位置,指向栈顶
008B1E51  mov         ebp,esp  
// 2)将esp的值给ebp
008B1E53  sub         esp,0E4h  
// 3)将esp的值减去0E4h(八进制)
008B1E59  push        ebx  
008B1E5A  push        esi  
008B1E5B  push        edi  
// 4)这三句只是将ebx esi edi的值压入栈中,对这次分析实验无作用,所以可以不理会,但注意esp也会随着栈顶的改变而改变。
008B1E5C  lea         edi,[ebp-0E4h]  
// 5)lea: load effective address ,意为加载在edi到ebp-0E4h之间的有效地址
008B1E62  mov         ecx,39h   //将39h放到ecx中去
008B1E67  mov         eax,0CCCCCCCCh  //将0CCCCCCCCh  放入eax中去
008B1E6C  rep stos    dword ptr es:[edi]  //将edi开始的ecx中储存的39h个doword的空间内容全部改为eax的0CCCCCCCCh值
008B1E6E  mov         ecx,offset _E605F406_Test@c (08BC003h)  
008B1E73  call        @__CheckForDebuggerJustMyCode@4 (08B130Ch)  

​ 1) 008B1E50 push ebp //将__tmainCRTStartup()的ebp压栈 ,上述步骤同时会使得esp改变指向位置,指向栈顶

​ 在push之后ebp与esp的地址:

在这里插入图片描述

​ 2)008B1E51 mov ebp,esp // 将esp的值给ebp

​ 在mov之后的地址:
在这里插入图片描述

​ 3)008B1E53 sub esp,0E4h // 将esp的值减去0E4h(八进制),实际就是指向到低地址的某一个区域,而在ebp与esp之间的范围就是为main函数开辟的空间。

​ 在sub之后的地址:
在这里插入图片描述

​ 4) 008B1E59 push ebx

008B1E5A push esi

008B1E5B push edi

​ //这三句只是将ebx esi edi的值压入栈中,对这次分析实验无作用,所以可以不理会,但注意esp也会随着栈顶的改变而改变。

​ 5) 008B1E5C lea edi,[ebp-0E4h] //lea: load effective address 意为加载在edi到ebp-0E4h之间的有效地址

​ 不难发现,ebp-0E4h为 2)中esp所寄存的地址

​ 6) 008B1E62 mov ecx,39h //将39h放到ecx中去

008B1E67 mov eax,0CCCCCCCCh //将0CCCCCCCCh 放入eax中去

008B1E6C rep stos dword ptr es:[edi] //将edi开始的ecx中储存的39h个doword的空间内容全部改为eax的0CCCCCCCCh 值

​ 执行该汇编语言后的效果:

在这里插入图片描述

​ 最后,我们通过上述步骤的实验根据理解画出相应的栈区图示:

在这里插入图片描述

③ main()函数中局部变量的创建

​ 分析局部变量在main()函数栈帧内的创建过程,相关代码(对此也通过序号与注释写下分析过程)如下:

	int a = 10;
008B1E78  mov         dword ptr [ebp-8],0Ah  //将0Ah值放入到地址ebp-8的内存块中
	int b = 20;
008B1E7F  mov         dword ptr [ebp-14h],14h   //将14h值放入到地址ebp-14h的内存块中
	int c = 0;
008B1E86  mov         dword ptr [ebp-20h],0   //将0值放入到地址ebp-20h的内存块中

​ 1) 008B1E78 mov dword ptr [ebp-8],0Ah //将0Ah值放入到地址ebp-8的内存块中

​ 2) 008B1E7F mov dword ptr [ebp-14h],14h //将14h值放入到地址ebp-14h的内存块中

​ 3)008B1E86 mov dword ptr [ebp-20h],0 //将0值放入到地址ebp-20h的内存块中

​ mov之后对应内存图像为:

在这里插入图片描述

在这里插入图片描述

同理,我们通过上述步骤画出相应的栈区图示(通过蓝色加以区分):

在这里插入图片描述

④ 分析ADD()函数(上):参数准备

​ 相关代码(对此也通过序号与注释写下分析过程)如下:

	c = Add(a, b);
008B1E8D  mov         eax,dword ptr [ebp-14h]  //将[ebp-14h]中的值放到eax中,实际上就是将局部变量b的值放到eax中
008B1E90  push        eax   //将eax压栈
008B1E91  mov         ecx,dword ptr [ebp-8]  //将[ebp-8h]中的值放到eax中,实际上就是将局部变量b的值放到eax中
008B1E94  push        ecx  //将ecx压栈
008B1E95  call        008B10B4    //调用call指令,将call的下一条指令放到栈顶,为了在实行完call指令后回到主函数继续执行指令
008B1E9A  add         esp,8  
008B1E9D  mov         dword ptr [ebp-20h],eax  

​ 1)008B1E8D mov eax,dword ptr [ebp-14h] //将[ebp-14h]中的值放到eax中,实际上就是将局部变量b的值放到eax中

​ 2)008B1E90 push eax //将eax压栈

​ 执行语句后的内存监控图:

在这里插入图片描述
在这里插入图片描述

​ esp的地址为0x0118FAAC,为栈顶地址,而eax此时为栈顶,其中的值就是刚刚mov进去的[ebp-14h]中的值(变量b)的值

​ 3)008B1E91 mov ecx,dword ptr [ebp-8] //将[ebp-8h]中的值放到eax中,实际上就是将局部变量b的值放到eax中

​ 4)008B1E94 push ecx //将ecx压栈

​ 分析过程与eax同理,将ecx压入栈后,ecx处于栈顶,esp也需要改变指向栈顶,此时栈顶储存的值是刚刚mov进去的[ebp-8] 的值(变量a),内存检测图为:
在这里插入图片描述
在这里插入图片描述

​ 5)008B1E95 call 008B10B4 //调用call指令,将call的下一条指令放到栈顶,为了在实行完call指令后回到主函数继续执行指令

​ 按下f11,跳转到Add()函数中,其中汇编代码如下:

int Add(int x, int y) {
008B1740  push        ebp  
008B1741  mov         ebp,esp  
008B1743  sub         esp,0C0h  
008B1749  push        ebx  
008B174A  push        esi  
008B174B  push        edi  
008B174C  lea         edi,[ebp+FFFFFF40h]  
008B1752  mov         ecx,30h  
008B1757  mov         eax,0CCCCCCCCh  
008B175C  rep stos    dword ptr es:[edi]  
008B175E  mov         ecx,8BC003h  
008B1763  call        008B130C  
	return x + y;
008B1768  mov         eax,dword ptr [ebp+8]  
008B176B  add         eax,dword ptr [ebp+0Ch]  
}
008B176E  pop         edi  
008B176F  pop         esi  
008B1770  pop         ebx  
008B1771  add         esp,0C0h  
008B1777  cmp         ebp,esp  
008B1779  call        008B1235  
008B177E  mov         esp,ebp  
008B1780  pop         ebp  
008B1781  ret  

​ 分析 1) ~ 5) 的过程画出相应的栈区图示(通过红色加以区分):

在这里插入图片描述

⑤ 分析ADD()函数(中):函数栈帧创建及执行

​ 相关代码(对此也通过序号与注释写下分析过程)如下:

int Add(int x, int y) {
008B1740  push        ebp  
008B1741  mov         ebp,esp  
008B1743  sub         esp,0C0h  
008B1749  push        ebx  
008B174A  push        esi  
008B174B  push        edi  
008B174C  lea         edi,[ebp-0C0h]  
008B1752  mov         ecx,30h  
008B1757  mov         eax,0CCCCCCCCh  
008B175C  rep stos    dword ptr es:[edi]  
008B175E  mov         ecx,offset _E605F406_Test@c (08BC003h)  
008B1763  call        @__CheckForDebuggerJustMyCode@4 (08B130Ch)  
	return x + y;
008B1768  mov         eax,dword ptr [x]  
008B176B  add         eax,dword ptr [y]  
}
008B176E  pop         edi  
008B176F  pop         esi  
008B1770  pop         ebx  
008B1771  add         esp,0C0h  
008B1777  cmp         ebp,esp  
008B1779  call        __RTC_CheckEsp (08B1235h)  
008B177E  mov         esp,ebp  
008B1780  pop         ebp  
008B1781  ret  

不难发现,在Add()函数的开头部分与main()函数的开头部分高度相似,其实他们都是同样的套路,就是构建自身的函数栈帧,以下我们做个对比,并且对Add()函数对栈帧的构建通过注释的方式简单的描述。

​ 对比如下:

int Add(int x, int y) {
008B1740  push        ebp  
// 1)main()的ebp压栈 ,上述步骤同时会使得esp改变指向位置,指向栈顶
008B1741  mov         ebp,esp  
// 2)将esp的值给ebp
008B1743  sub         esp,0C0h  
// 3)将esp的值减去0C0h(八进制),实际就是为Add()函数开辟函数栈帧空间
008B1749  push        ebx  
008B174A  push        esi  
008B174B  push        edi  
// 4)这三句只是将ebx esi edi的值压入栈中,对这次分析实验无作用。
008B174C  lea         edi,[ebp-0C0h]  
// 5)lea: load effective address ,意为加载在edi到ebp-0C0h之间的有效地址
008B1752  mov         ecx,30h   //将30h放到ecx中去
008B1757  mov         eax,0CCCCCCCCh  //将0CCCCCCCCh  放入eax中去
008B175C  rep stos    dword ptr es:[edi]  //将edi开始的ecx中储存的30h个doword的空间内容全部改为eax的0CCCCCCCCh值
//再次完成了对开辟空间的赋值
008B175E  mov         ecx,offset _E605F406_Test@c (08BC003h)  
008B1763  call        @__CheckForDebuggerJustMyCode@4 (08B130Ch)  

 int main() 
 {
008B1E50  push        ebp  
// 1)将__tmainCRTStartup()的ebp压栈 ,上述步骤同时会使得esp改变指向位置,指向栈顶
008B1E51  mov         ebp,esp  
// 2)将esp的值给ebp
008B1E53  sub         esp,0E4h  
// 3)将esp的值减去0E4h(八进制)
008B1E59  push        ebx  
008B1E5A  push        esi  
008B1E5B  push        edi  
// 4)这三句只是将ebx esi edi的值压入栈中,对这次分析实验无作用,所以可以不理会,但注意esp也会随着栈顶的改变而改变。
008B1E5C  lea         edi,[ebp-0E4h]  
// 5)lea: load effective address ,意为加载在edi到ebp-0E4h之间的有效地址
008B1E62  mov         ecx,39h   //将39h放到ecx中去
008B1E67  mov         eax,0CCCCCCCCh  //将0CCCCCCCCh  放入eax中去
008B1E6C  rep stos    dword ptr es:[edi]  //将edi开始的ecx中储存的39h个doword的空间内容全部改为eax的0CCCCCCCCh值
008B1E6E  mov         ecx,offset _E605F406_Test@c (08BC003h)  
008B1E73  call        @__CheckForDebuggerJustMyCode@4 (08B130Ch)  

​ 内存检测图:

​ 开辟前:

在这里插入图片描述

在这里插入图片描述

​ 开辟后:

在这里插入图片描述
在这里插入图片描述

​ 相应的栈区图示(通过绿色加以区分):

在这里插入图片描述

我们继续分析,终于要执行我们Add()函数中的 x + y ,我们根据汇编来分析一下如何进行的

	return x + y;
008B1768  mov         eax,dword ptr [ebp+8]  
008B176B  add         eax,dword ptr [ebp+0Ch]  

​ 1) 008B1768 mov eax,dword ptr [ebp+8] //将地址[ebp+8] 的值放入到寄存器eax中

​ 实际上就是将main函数中的ecx放入到寄存器eax中,为什么呢?我们观察一下上一个栈区图示,发现,此时ebp+8(就是在ebp的地址中往高地址移动)发现恰好到是main函数中创建的ecx的值。

​ 2) 008B176B add eax,dword ptr [ebp+0Ch] //将地址[ebp+0Ch] 的值加到到寄存器eax中

​ 实际上就是将main函数中的eax放入到寄存器eax中,同样我们观察一下上一个栈区图示,发现,此时ebp+0Ch(就是在ebp的地址中往高地址移动)发现恰好到是main函数中创建的eax的值。

​ 这样我们就可以很直观的体验到函数的传参方式:形参是实参的一份临时拷贝

​ 栈区图示为(请关注紫色线条和红色字体部分):

在这里插入图片描述

有了形参调用后,我们开始分析返回值的汇编过程

​ 可能有人会问,在Add()函数调用完后,函数开辟的各个空间不就被销毁了吗,那还怎么接受返回值呢,在此我们使用了寄存器来接受返回值,并在Add()函数销毁后可以继续获取

⑥ 分析ADD()函数(下):函数栈帧销毁过程(简化分析)

相关代码(对此也通过序号与注释写下分析过程)如下:

008B176E  pop         edi  
008B176F  pop         esi  
008B1770  pop         ebx  
008B1771  add         esp,0C0h  
008B1777  cmp         ebp,esp  
008B1779  call        __RTC_CheckEsp (08B1235h)  
008B177E  mov         esp,ebp  
008B1780  pop         ebp  
008B1781  ret  

​ 1) 008B176E pop edi

008B176F pop esi

008B1770 pop ebx

​ // 在前三句代码中,我们可以看到pop的出栈指令,就是将edi 与esi 和ebx 完成出栈操作,同时需要改变esp的值

​ 2) 008B177E mov esp,ebp //将ebp的值赋给

​ 3) 008B1780 pop ebp //将ebp 完成出栈操作,并且通过原本寄存在main()函数的ebp找到ebp应该存放的地址值,同时需要改变esp的值

​ 4) 008B1781 ret //返回, 执行在栈顶上的执行下一条指令并将其弹出

栈区图示如下(橙色框部分):
在这里插入图片描述

⑦ 收尾工作

​ 最后分析一下收尾部分,但只分析小部分,因为后面的分析与前面极其相似,读者可以通过思考分析来检验自身掌握程度

​ 相关代码(对此也通过序号与注释写下分析过程)如下:

	c = Add(a, b);
008B1E8D  mov         eax,dword ptr [ebp-14h]  
008B1E90  push        eax  
008B1E91  mov         ecx,dword ptr [ebp-8]  
008B1E94  push        ecx  
008B1E95  call        008B10B4  
008B1E9A  add         esp,8  
008B1E9D  mov         dword ptr [ebp-20h],eax  

008B1E9A add esp,8 //将esp中的地址加8,实际上是消除为了传形参而创建的空间,此时记得也需要移动esp

​ 栈区图示如下(黄色框部分)
在这里插入图片描述

三. 总结

(1)函数调用怎么做?结束后怎么做?能抽象出一个怎么样的模型?

答:

​ 在函数调用之前,会先建立函数栈帧,会通过改变ebp与esp的地址来开辟新的栈区空间,作为函数栈帧空间,完成对几个寄存器的入栈并且执行汇编指令完成初始化。简单来说,就是在栈区开辟空间,并完成初始化;

​ 在函数调用时,我们在上文分析了形参的使用,在此不多赘述;

​ 在函数调用结束后,弹出相应的寄存器,移动esp到ebp处,再弹出ebp,并通过在栈顶的上一个函数的ebp,重新找到新的ebp,返回到调用该函数的函数栈帧之中,最后通过ret指令,执行下一条指令。简化来说就是,将函数出栈,继续执行下一条指令。

​ 并完成收尾工作,将因为需要完成形参对实参的调用而在内存开辟的空间给出栈。

​ 简化模型的建立如下:

在这里插入图片描述

(2)局部变量怎么创建?

答:在函数栈帧初始化后,在特定的区域赋值。

(3)为什么局部变量的值是随机值?

答:我们在实验中发现,其实局部变量初始化的值并不是随机值,而是0CCCCCCCCh,也就是我们的“烫烫烫烫烫”,是编译器开辟函数栈帧完成初始化后赋予的。

(4)函数时怎么传参的呢?传参顺序怎么样?

答: 在函数调用之前,就已经将需要传参的值通过寄存器从右向左压入栈中(对应步骤为④),进入函数后,通过指针对ebp的偏移量去获取形参的值(对应步骤⑤)。注意顺序是从右向左(对应步骤⑤)。在函数调用结束后,也会将寄存器中原本压栈的值弹出。

(5)形参和实参是什么关系?

答:形参是在函数栈帧中重新开辟了的空间,形参与实参的值是相同的,空间是独立的,所以形参是实参的一份临时拷贝,改变形参不会影响实参。

(6)函数调用结束后是怎么返回的?

答:在函数调用处理完返回值后,将返回值存入寄存器中,当需要使用时,就会在寄存器中取出相应的值。

“免死”声明:

  • 作者写博客的C/C++内容主要是想形成一套实用的使用手册,主要追求的方便,以及记录一下自己在重新看C和C++内容的思考,所以常规文章一般都比较追求实用性,深层次内容是不及各位大神所写的博客那样深刻清晰,请轻喷;
  • 因为这篇文章篇目较长,可能会出现纰漏,当然文章也会有许多不足之处,欢迎各位在评论区中指正指导批评,非常感谢;
  • C/C++的代码将会放到: https://gitee.com/liu-hongtao-1/c–c–review.git ,欢迎查看!
  • 14
    点赞
  • 11
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 11
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 11
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Fat one

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值