提示:文章写完后,目录可以自动生成,如何生成可参考右边的帮助文档
目录
前言
一、前面的困惑
二、什么是函数栈帧
三、关于函数栈帧的基础知识
1.栈
2.寄存器
2.1 什么是寄存器
2.2 相关的寄存器
2.3 相关汇编命令
2.4 预备知识
四、解析函数栈帧的创建和销毁
1.转到反汇编
2. 函数栈帧的创建
3.进入自定义函数:
4. 函数栈帧的销毁
总结
前言
前期在学习的时候,我们可能有很多问题,比如局部变量如何创建的等......,学完这章的知识点,就都会了
一、前面的困惑
局部变量是如何创建的?
为什么局部变量不初始化内容是随机的?
函数调用时参数时如何传递的?传参的顺序是怎样的?
函数的形参和实参分别是怎样实例化的?
函数的返回值是如何带会的?让我们一起走进函数栈帧的创建和销毁的过程中。
二、什么是函数栈帧
我们在写C语言代码的时候,经常会把一个独立的功能抽象为函数,所以C程序是以函数为基本单位的。
那函数是如何调用的?函数的返回值又是如何待会的?函数参数是如何传递的?这些问题都和函数栈帧有关系。
函数栈帧(stack frame)就是函数调用过程中在程序的调用栈(call stack)所开辟的空间,这些空间是用来存放:
函数参数和函数返回值临时变量(包括函数的非静态的局部变量以及编译器自动生产的其他临时变量)保存上下文信息(包括在函数调用前后需要保持不变的寄存器)。
三、关于函数栈帧的基础知识
1.栈
栈(stack)是现代计算机程序里最为重要的概念之一,几乎每一个程序都使用了栈,没有栈就没有函数,没有局部变量,也就没有我们如今看到的所有的计算机语言。
在经典的计算机科学中,栈被定义为一种特殊的容器,用户可以将数据压入栈中(入栈push),也可以将已经压入栈中的数据弹出(出栈,pop),但是栈这个容器必须遵守一条规则:先入栈的数据后出栈(First In Last Out, FIFO)。就像叠成一叠的术,先叠上去的书在最下面,因此要最后才能取出。在计算机系统中,栈则是一个具有以上属性的动态内存区域。程序可以将数据压入栈中,也可以将数据从栈顶弹出。压栈操作使得栈增大,而弹出操作使得栈减小。
在经典的操作系统中,栈总是向下增长(由高地址向低地址)的。
在我们常见的i386或者x86-64下,栈顶由成为 esp 的寄存器进行定位的 ;
2.寄存器
2.1 什么是寄存器
寄存器(Register)是计算机硬件中的小型、高速的存储单元,它们直接连接到CPU(中央处理器)。寄存器主要用于暂存数据或指令地址,以便CPU能快速访问,提高计算效率。相比于主内存,寄存器的读写速度更快,但容量有限。常见的寄存器有通用寄存器(如ALU的运算结果寄存器)、程序计数器(PC,保存当前指令的位置)、标志寄存器(用于存放运算结果的状态信息)等。寄存器的作用至关重要,尤其是在处理频繁使用的数据或指令时,能够显著提升系统的性能。
2.2 相关的寄存器
以下就是等下讲解的时候会出现的寄存器:
eax:通用寄存器,保留临时数据,常用于返回值
ebx:通用寄存器,保留临时数据
ebp:栈底寄存器
esp:栈顶寄存器
eip:指令寄存器,保存当前指令的下一条指令的地址
2.3 相关汇编命令
mov:数据转移指令
push:数据入栈,同时esp栈顶寄存器也要发生改变
pop:数据弹出至指定位置,同时esp栈顶寄存器也要发生改变
sub:减法命令
add:加法命令
call:函数调用,1. 压入返回地址 2. 转入目标函数
jump:通过修改eip,转入目标函数,进行调用
ret:恢复返回地址,压入eip,类似pop eip命令
2.4 预备知识
我们浅浅的举个例子:
#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;
}
我们先简单来说一下:
其实我们不难发现:
1. 每一次函数调用,都要为本次函数调用开辟空间,就是函数栈帧的空间。
2. 这块空间的维护是使用了2个寄存器: esp 和 ebp , ebp 记录的是栈底的地址, esp 记录的是栈顶的地址。
即:
注:函数栈帧的创建和销毁过程,在不同的编译器上实现的方法大同小异,本次演示以VS2022为例。
首先我们先来看看下面的调试内容(还是以上面的例子为例):
所以在VS2022中,main函数也是被其他函数调用的
函数调用堆栈是反馈函数调用逻辑的,那我们可以清晰的观察到, main 函数调用之前,是由invoke_main 函数来调用main函数。
在 invoke_main 函数之前的函数调用我们就暂时不考虑了。
这里相当于打个预防针,方便理解后面的栈帧的创建与销毁;
那接下来我们从main函数的栈帧创建开始讲解:
四、解析函数栈帧的创建和销毁
1.转到反汇编
调试到main函数开始执行的第一行,右击鼠标转到反汇编。
注:VS编译器每次调试都会为程序重新分配内存,本文中的反汇编代码是一次调试代码过程中数据,每次调试略有差异。
1.调试右击转到反汇编并关闭显示符号名:
接下来我们来解读这上面的代码:
int main()
{
00007FF60EC718A0 push rbp
00007FF60EC718A2 push rdi
00007FF60EC718A3 sub rsp,148h
00007FF60EC718AA lea rbp,[rsp+20h]
int a = 10;
00007FF60EC718AF mov dword ptr [rbp+4],0Ah
int b = 20;
00007FF60EC718B6 mov dword ptr [rbp+24h],14h
int c = 0;
00007FF60EC718BD mov dword ptr [rbp+44h],0
c = Add(a, b);
00007FF60EC718C4 mov edx,dword ptr [rbp+24h]
00007FF60EC718C7 mov ecx,dword ptr [rbp+4]
00007FF60EC718CA call 00007FF60EC71348
00007FF60EC718CF mov dword ptr [rbp+44h],eax
printf("%d\n", c);
00007FF60EC718D2 mov edx,dword ptr [rbp+44h]
00007FF60EC718D5 lea rcx,[00007FF60EC79C24h]
00007FF60EC718DC call 00007FF60EC71195
return 0;
00007FF60EC718E1 xor eax,eax
}
2. 函数栈帧的创建
这里我看到 main 函数转化来的汇编代码如上所示。
接下来我们就一行行拆解汇编代码
00007FF60EC718A0 push rbp
00007FF60EC718A2 push rdi
00007FF60EC718A3 sub rsp,148h
00007FF60EC718AA lea rbp,[rsp+20h]
(这里的rbp与rsp就如上讲的esp与ebp一致,上面说过,不同的编译器,甚至不同的版本都有一些差别,但不影响我们理解)
上面我就已经提过了main不是直接调用的, main 函数调用之前,是由invoke_main 函数来调用main函数。 也就是说main函数调用前,已经存在了栈顶指针rsp与栈底指针rbp指向invoke_main函数的栈帧
我们再进行操作:
第一句:
00007FF60EC718A0 push rbp
//把rbp寄存器中的值进行压栈,此时的rbp中存放的是
//invoke_main函数栈帧的rbp,rsp-8;
在执行前我们还可调出内存窗口看看是否如此:
执行前:
执行后:
我们看到rsp的地址减去了8(64位)
第二句:
同理:
00007FF60EC718A2 push rdi
//把rdi寄存器中的值进行压栈,rsp-8
//不用知道这个rdi存的是什么,对本次理解不影响
第三句:
00007FF60EC718A3 sub rsp,148h
//sub会让rsp中的地址减去一个16进制数字148h,产生新的
rsp,此时的rsp是main函数栈帧的rsp;
如图:
第四句:
00007FF60EC718AA lea rbp,[rsp+20h]
//lea的意思是load effecitive address“加载有效地址”也就是将后面的地址给rbp
//这里的rbp与前面的rsp就形成了main函数的栈帧
//,这一段空间中将存储main函数中的局部变量,临时数据已经调试信息等。
我们接着看下面句:
int a = 10;
00007FF60EC718AF mov dword ptr [rbp+4],0Ah
int b = 20;
00007FF60EC718B6 mov dword ptr [rbp+24h],14h
int c = 0;
00007FF60EC718BD mov dword ptr [rbp+44h],0
这几句的意思一样:我们只说第一句
int a = 10;
00007FF60EC718AF mov dword ptr [rbp+4],0Ah //将10存储到rbp+4的地址处,rbp+4的位置其实就是a变量
其他的两句同理:
接着往下看:
c = Add(a, b);
00007FF60EC718C4 mov edx,dword ptr [rbp+24h]
00007FF60EC718C7 mov ecx,dword ptr [rbp+4]
00007FF60EC718CA call 00007FF60EC71348
00007FF60EC718CF mov dword ptr [rbp+44h],eax
前面很好理解,就是将后面地址存的数据(其实不难发现,就是前面的a,b),读取到前面的寄存器;
对函数有很深的理解的同学就会发现,这不就是函数传参吗,对,确实是这样的;
我们不难发现,函数传参是从b到a即从右到左;
我们这个时候监视变量页可以看看是否与我们预想的一样:
与我们预想的一致(截图中显示的是16进制)
我们接着往下看,注意此时栈顶指针的值,和栈顶的值:
call的意思就要进入函数了,此时调试要进入函数就要摁F11了
执行后:
我们看到了,他不仅执行了一个压栈操作,压入的值不是一个随便的数,而是call的下一句代码的地址,这是为了执行完Add函数后可以回来:
3.进入自定义函数:
int Add(int x, int y)
{
00007FF60EC717A0 mov dword ptr [rsp+10h],edx
00007FF60EC717A4 mov dword ptr [rsp+8],ecx
00007FF60EC717A8 push rbp
00007FF60EC717A9 push rdi
00007FF60EC717AA sub rsp,0E8h
00007FF60EC717B1 mov rbp,rsp
int z = 0;
00007FF60EC717B4 mov dword ptr [rbp+4],0
z = x + y;
00007FF60EC717BB mov eax,dword ptr [rbp+0000000000000108h]
00007FF60EC717C1 mov ecx,dword ptr [rbp+0000000000000100h]
00007FF60EC717C7 add ecx,eax
00007FF60EC717C9 mov eax,ecx
00007FF60EC717CB mov dword ptr [rbp+4],eax
return z;
00007FF60EC717CE mov eax,dword ptr [rbp+4]
}
00007FF60EC717D1 lea rsp,[rbp+00000000000000E8h]
00007FF60EC717D8 pop rdi
00007FF60EC717D9 pop rbp
00007FF60EC717DA ret
下面我们继续解读:
有了前面的基础我们理解起来就很容易了:
00007FF60EC717A0 mov dword ptr [rsp+10h],edx
00007FF60EC717A4 mov dword ptr [rsp+8],ecx
00007FF60EC717A8 push rbp
00007FF60EC717A9 push rdi
00007FF60EC717AA sub rsp,0E8h
00007FF60EC717B1 mov rbp,rsp
int z = 0;
00007FF60EC717B4 mov dword ptr [rbp+4],0
z = x + y;
00007FF60EC717BB mov eax,dword ptr [rbp+0000000000000108h]
00007FF60EC717C1 mov ecx,dword ptr [rbp+0000000000000100h]
00007FF60EC717C7 add ecx,eax
00007FF60EC717C9 mov eax,ecx
00007FF60EC717CB mov dword ptr [rbp+4],eax
00007FF60EC717CE mov eax,dword ptr [rbp+4]
将rbp+4地址处的值放在eax中,其实就是
把z的值存储到eax寄存器中,这里是想通过eax寄存器带回计算的结果,做函数的返回值。
4. 函数栈帧的销毁
00007FF60EC717D1 lea rsp,[rbp+0E8h]
00007FF60EC717D8 pop rdi
00007FF60EC717D9 pop rbp
//弹出栈顶的值存放到rbp,栈顶此时的值恰好就是main函数的rbp,rsp+4,此时恢复了main函数的栈帧维//护,rsp指向main函数栈帧的栈顶,rbp指向了main函数栈帧的栈底。
00007FF60EC717DA ret
//ret指令的执行,首先是从栈顶弹出一个值,此时栈顶的值就是call指
//令下一条指令的地址,此时rsp+4,然后直接跳转到call指令下一条指令的地址处,继续往下执行。
回到了call指令的下一条指令的地方:
继续执行下面的代码:
00007FF60EC718CF mov dword ptr [rbp+44h],eax
//将eax放到rbp+44h(c存的地方)
这里虽然后面还有不少的代码,我们不做了解了
总结
这期比较抽象,小编写这章主要然大家了解函数的调用,现在,你能回答上面的问题了吗?
下期见!