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