C语言之函数栈帧(动图详解)
阿里云国内75折 回扣 微信号:monov8 |
阿里云国际,腾讯云国际,低至75折。AWS 93折 免费开户实名账号 代冲值 优惠多多 微信号:monov8 飞机:@monov6 |
目录
1.什么是栈帧
C程序在调用函数时会先在栈上给函数预先开辟一个足够的空间后续函数中的内容如非静态局部变量返回值等都会保存在这段空间中。这段空间就叫栈帧。
当函数调用时就会形成栈帧当函数返回时栈帧也会被释放。所谓释放是指将某段空间设置为无效使得可以被覆盖而并非清空。
2.相关寄存器和汇编指令
本期我们将通过汇编的角度来了解函数调用前后栈帧的形成与释放过程首先我们先来认识以下一些相关的寄存器和可能用到的汇编命令
1.相关寄存器
寄存器名称 | 功能 |
eax | 通用寄存器保存临时数据常用于返回值 |
ebx | 通用寄存器保存临时数据 |
ebp | 栈底寄存器 |
esp | 栈顶寄存器 |
eip | 指令寄存器保存当前指令的下一条指令的地址 |
2.部分汇编指令
助记符 | 说明 |
mov | 数据转移指令 |
push | 数据入栈同时esp栈顶寄存器也要发生改变 |
pop | 数据弹出至指定位置同时esp栈顶寄存器也要发生改变 |
sub | 减法指令 |
add | 加法指令 |
call | 函数调用1.压入返回地址 2.转入目标函数 |
jump | 通过修改eip转入目标函数进行调用 |
ret | 恢复返回地址压入eip类似于pop eip指令 |
3.程序介绍
下面就是本文研究的样例代码是在vs2022下进行编译(不同编译器效果可能略有不同)
#include<stdio.h>
int Add(int x, int y) //求出两数和并返回
{
int z = 0;
z = x + y;
return z;
}
int main()
{
int a = 0xA; //定义两个变量用16进制
int b = 0xB;
int c = 0;
c = Add(a, b); //求和函数
printf("%d", c); //打印结果
return 0;
}
我们将分别从执行main函数内容形成Add函数栈帧执行Add函数内容Add函数栈帧释放四个阶段来对Add()函数进行演示。
4.过程分析(汇编角度)
1.执行main函数
我们使用VS进入调试打开内存窗口寄存器窗口转到反汇编如下
这里我们需要知道的是main函数也是函数它被_tmainCRTStartup函数调用而_tmainCRTStartup又被mainCRTStartup函数调用mainCRTStartup函数又是被操作系统所调用的。因此main函数也会形成栈帧定义a之前的汇编代码就是用来形成main函数栈帧并进行初始化的与我们后续要讨论的Add函数的栈帧形成过程相同这里就不加讨论。我们直接从int a=0xA之后开始分析
我们F10运行到int a=0xA所对应的汇编指令此时eip指向下一条要指向的指令地址也就是int a所对应的指令:
根据ESPEBP我们得出main函数的栈帧在栈上的位置如下(地址从上到下递增下同)
点击F10执行指令由于栈是从高地址向低地址增长的因此在栈底向上8个字节处开辟a的空间并将数据mov进去。此时栈底寄存器不变ebp-8处内存数据被改为0AH。如下
同时eip自动指向下一条要执行的指令与a同理将bc变量入栈如下
具体过程动图如下
我们可以发现变量与变量之间不是连续的
接下来就要开始执行下一条语句调用函数并给c赋值共有7条指令
首先是调用Add()前(即call指令前)的4条指令我们可以看出前两条指令的作用是先将变量b的值移动到eax寄存器然后以压栈push的方式压入栈中此时栈顶向上移动一个b变量大小
后两条指令也是如此将a变量的值拷贝入ecx寄存器然后压入栈中
具体过程动图如下
这两个临时变量的形成就是我们所说的形参实例化。我们发现形参实例化是在函数正式调用前就形成了(这里指call之前)形参实例化的顺序是按照参数列表从右向左。同时我们可以发现通过压栈形成的变量空间是连续的。
接下来我们将执行函数调用指令由于我们是通过跳转指令修改eip寄存器转入目标函数地址Add函数调用结束后还需返回main函数执行后续内容因此我们需要将下一条指令的地址先保存起来然后进行跳转。因此这个指令分为两步1.将返回地址压入栈中 2.转入目标函数。
点击F11进入函数我们可以发现函数返回后的指令地址被压入栈中(即00EE18F7)然后修改eip进行跳转转入Add()函数
压栈的具体过程动图如下
至此我们进入了Add()函数内部接下来就要开始准备形成Add()函数的栈帧。
2.形成Add()函数栈帧
前3条指令就是栈帧的形成过程而后面几条是对栈帧的初始化与赋值我们重点来解析一下前3条指令
首先是第一条指令单击F10将栈底寄存器的内容压入栈中即把main函数栈底的地址压入栈中
由于是压栈栈顶向低字节偏移4个字节保存main函数栈底的地址具体动图如下
然后是第二条指令单击F10将栈顶寄存器的内容移动到栈底寄存器使得栈顶寄存器和栈底寄存器指向同一个地址空间
最后是第三条指令单击F10将esp栈顶寄存器的内容减去0CCH使其向低地址偏移0CC个字节如下
第二条指令与第三条指令的具体过程动图如下
由此我们便得到的一个大小为0CC个字节的空间这个空间就是Add()函数的栈帧。eap指向这个栈帧的栈顶ebp指向这个栈帧的栈底。
3.执行Add()函数
我们略过初始化栈帧部分运行到int z=0所对应的指令处:
点击F10与前面ab变量相同这条指令就是通过mov给z分配空间将0放入栈底向上8个字节处
具体过程动图如下
单击F10执行z=x+y这条语句有三条指令首先把ebp向下偏移8个字节的内容移动到eax寄存器中然后将ebp向下偏移0CH(12)个字节的内容与eax寄存器中内容相加并存到eax寄存器中最后将eax寄存器的内容存到ebp向上偏移8个字节处
我们不难发现由于压栈得到的地址空间是连续的而我们的栈底向下的几个空间都是通过压栈得到的。因此ebp+8就为临时变量x的地址ebp+0CH就是临时变量y的地址。由此我们就把x和y的值进行相加最后存入ebp-8中也就是变量z中。
具体过程动图如下
最后执行return z语句将ebp-8处的内容(即z)放入eax寄存器中
具体过程动图如下
至此ADD函数调用完毕进入最后一步栈帧释放(销毁)。
4.Add函数栈帧释放与返回
栈帧的销毁我们重点来谈后三条语句前几条语句对应着前面栈帧创建时的初始化操作进行设置我们不做深究 。
首先是第一条mov命令我们单击F10运行ebp栈底寄存器的值赋给esp栈顶寄存器此时ebp与esp指向同一个地址空间
具体动图如下
实际上这时Add函数的栈帧所在空间就被置为无效了栈帧就被释放了
接下来就是恢复main函数栈帧的操作了。
我们单击F10执行下一条pop指令将栈顶内容弹出并放入ebp栈底寄存器中同时esp栈顶寄存器的指向发生改变。由于栈顶处内容为main函数栈底地址因此pop操作完成后ebp就重新指向main栈帧的栈底。
具体过程动图如下
最后执行ret指令ret作用是恢复返回地址压入eip类似于pop eip指令即把栈顶元素(保存的指令返回地址)弹出到eip指令寄存器中改变下一条执行的指令。我们单击F10发现返回到了main函数此时eip的内容就是我们之前保存的下一条main函数指令地址esp栈顶寄存器发生改变
具体过程动图如下
之后执行main函数中的下一条add指令将esp栈顶寄存器的值加8并存回esp栈顶寄存器此时esp向下偏移8个字节指向原main函数栈顶
具体过程动图如下
至此main函数的栈帧恢复完毕 Add函数栈帧被释放Add函数调用过程结束进入main函数后续内容。
最后执行mov语句将存在eax寄存器的Add函数返回值赋值给变量c
具体过程动图如下
以上就是Add()函数栈帧的创建和释放(销毁)的全过程。后续的printf()也是函数与Add()函数也会创建函数栈帧但是总体的步骤都是一样的这里就不再说明。
5.总结
通过以上分析我们可以得出几点结论
1.函数正式调用(call)前会进行形参实例化分配存储空间形参实例化的顺序是从右向左。
2.临时空间的开辟是在对应函数栈帧的内部通过mov命令的方式开辟的。
3.函数调用完毕栈帧结构被释放。
4.临时变量具有临时性的本质是栈帧具有临时性。
5.调动函数是有成本的体现在时间和空间上本质是形成和释放栈帧有成本。
6.函数调用因拷贝而形成的临时变量变量和变量之间的位置关系是有规律的。
7.函数的栈帧是自己形成的esp减多少是由编译器决定的。即栈帧的大小是由编译器决定的。编译器有能力知道所有类型对应定义变量的大小。
以上就是本期的全部内容。
制作不易能否点个赞再走呢qwq