前言
C语言中,在函数被调用的时候,会在栈区为该函数创建一块空间,这块空间被称为函数栈帧,并且使用栈底指针gbp,栈顶指针gsp来维护这块空间。(gbp,gsp都是寄存器)
一、关于栈和栈区
1.1什么是栈
栈是数据结构中的一种只允许在一端插入和删除数据的存储结构(线性表),它遵循后进先出的原则。
其实,栈就类似一个去掉顶层的长方体盒子,当我们要把一本书放入盒子中,是不是只能由盒子的顶层放入到盒子底层。当我们要拿出一本书时,是不是只能从最后一本书开始,依次往外拿书,一直到下一本书是我们想要的。
如图所示,是我们依次放入盒子的五本书,其中,book1是最先放入的,book5是最后放入的,当我们想要拿出book1时,无法直接拿出,只能先拿走当前盒子里最顶层的内本书籍,也就是book5,当拿出book5时,最顶层的书籍变成了book4,再依次拿出book4,book3,book2后才能拿到book1。这就是栈所说的后进先出的涵义所在。而放入书本,就是插入数据(压栈),拿出书本,就是删除数据(出栈)。
1.2栈顶和栈底
我们把插入和删除数据的内一端叫做栈顶,另一端叫做栈底。
那么我们插入和删除数据具体是在哪个位置呢?
如上图,当栈非空时,栈顶总是指向栈顶元素的下一位置。
当栈为空的时候,栈底和栈顶指向同一位置,
如下图,
总结:栈底的位置是始终不变的,栈顶的位置是可以变化的,栈空时,栈顶和栈底指向位置相同,栈非空时,它指向的是栈当前栈顶元素的下一个位置(谁在最上面谁就是栈顶元素),同时栈顶一端也是插入和删除元素的一端。
1.3栈区
C语言内存四区之一,用来存储局部变量,以及在函数调用的时候,为该函数创建一块空间(函数栈帧)。
1.4栈区的使用规则
优先使用高地址处的空间。存储数据的规则和栈相同,且优先从高地址处存储数据(对应栈底)。
栈区须知:
栈区空间的创建和销毁由编译器控制,无需我们操心,我们直接使用即可。栈区。简单来说,我们就把栈区当作我们所理解的栈,这是完全没有问题的,栈底位置是高地址处,从栈底到栈顶,地址是逐渐递减的。
二、函数栈帧
函数栈帧是:在栈区上开辟的、在调用函数时为该函数所创建的一块空间的统称
1.函数栈帧的维护
用两个存放地址的寄存器(esp,ebp)来维护对应函数栈帧所在的空间,其中ebp又被称为栈底指针,指向当前函数栈帧的栈底,esp又被称为栈顶指针,指向当前函数栈帧栈顶位置,随着数据的入栈和出栈会指向新的栈顶。他们的作用是用来维护函数栈帧,也就是因为函数调用在栈区创建的空间
2.需要了解的基本汇编指令
push:执行压栈操作,和栈的使用相同 ,压栈后栈顶指针会指向新的栈顶.例如push a 语句表示将a的值压入栈中
mov:转移的意思,可以理解为是赋值。例如 mov b,a语句表示将a的值转移给b
pop:出栈,同样会使栈顶指针指向新的栈顶。例如pop b语句,表示取出栈中存放的b的值。
sub:减法。例如:sub a,b语句表示a=a-b(也就是给前面的值减去b)
add:加法。例如:add a,b语句表示a=a+b(也就是给前面的值加上b)
call:调用目标函数,并且会将call指令的下一条语句的地址压入栈中
jmp:进入到目标函数内部
ret:回到进入函数前的下一条指令
3.实例剖析函数栈帧的创建
如下是一段简单的代码,我们将在VS2019的32位平台下,一步步剖析main和add函数栈帧的创建和销毁
#define _CRT_SECURE_NO_WARNINGS
#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 ", c);
return 0;
}
在观察main的函数栈帧前,我们需要了解,main函数也是由别的函数所调用,我们将通过调用堆栈来观察是什么函数调用的main函数
如图所示:
按下F10,开始调试代码,鼠标右击,选择转到反汇编,我们就看到了main()函数的汇编代码
在汇编代码一直按F10,来到ret处,按下F11,进入到调用mian函数的函数,如下图
F10调试来到ret处
F11按下后,可以看到我们来到了invoke_main()函数,也就是调用main函数的函数
调用main函数的关系链条并不止如此,但是我们是为了观察main函数的栈帧,因此,只需知道是谁调用的main函数即可(invoke_main()函数)
而当main函数被调用的时候,调用main函数的函数的函数栈帧已经被创建好,如下图所示
接下来,我们将解读main函数的汇编代码
第一到三句:开辟main函数栈帧空间,并用esp,ebp维护
push ebp 语句解释:
当前虽然在main函数内,但并没有真正执行main函数的代码,其实这部分的操作是在为main函数的栈帧开辟做准备,push表示压栈操作,ebp是栈底指针,指向的是当前函数栈帧的底部(也就是指向了该函数在栈区创建的函数栈帧空间),这行语句表示的意思是将ebp的值(该函数函数栈帧空间的起始地址)压入到栈中,其实,压入ebp,其实就是保存调用main函数的函数的栈帧的地址,压入完成后,esp指向新的栈顶,如图
move ebp,esp 语句解释:
表示将esp(指向当前栈顶,存放的是地址)的值(地址)赋值给ebp,该语句会使栈底指针ebp指向当前栈顶指针指向的位置,如图
sub esp,0E4h 语句解释:
sub表示减的意思,整体的意思是esp=esp-0E4h,这会让esp指向一个新的地址,同时,在栈空间中,从下到上,地址是由高到低的,我们假设esp到了上方的某一个区域,从ebp到esp的空间也是为main函数开辟的栈帧空间,如图
第四到六句:压栈,保存调用main的函数的栈帧起始地址
表示依次将ebx,esi,edi(我们简单理解为指针,可以存储地址)压入栈中,同时esp会指向新的栈顶,如图
第七到十句:初始化main函数栈帧区域
lea是load effective address的缩写,即加载有效地址,将ebp-24h的地址赋值给edi,
edi此时的地址是:0x008ffbb4
压入ebx,esi,edi前esp的地址是0x008ffbd8
edi的地址高于压入ebx,esi,edi前esp的地址,如图
mov ecx,9 表示将9赋值给ecx,
mov eax,0CCCCCCCCh 表示将0CCCCCCCCh赋值给eax, 这三条语句加上rep...这条语句,表示将从edi位置开始向下的9个地址初始化为eax的值,我们可以在监视窗口,通过内存来看看是不是这样
确实如此,刚好到ebp时,并没有初始化
第十二到十二句:我们直接忽略,下图是勾选了显示符号名后的显示结果
,第十三到十九句:将main函数内创建的局部变量放到对应的地址处,即main函数栈帧初始化区域处,在调用add函数后,首先进行add函数传参操作,参数从右到左依次压入栈中(eax,ecx保存的是变量b,a)
第二十到二十一句子:在call指令处按F11调用add函数,再次按F11,通过jum指令进入add函数内部,并在栈中压入下一条指令的地址(008118F7)
如上图,当前栈顶是esp对应的0a000000
当call指令执行后,其上多了一个地址是f7188100,恰好是call指令下一条指令的地址,如下图
在进入add函数后,我们会发现汇编代码和进入main函数时相差无几,都是在为add函数栈帧的开辟做准备,先是压入了main函数ebp的值,再预开辟add函数的栈帧空间,在对空间进行初始化后,将add函数内部创建的局部变量z放入初始化空间 中,通过ebp找到了在main函数传入的参数,并利用add指令计算了两个参数之和,最后保存到z变量的地址中去
关于add函数传参问题,在进入到add函数时,我们已经将两个实参进行拷贝(压入栈中),add函数在使用参数的时候,也是找到实参的拷贝当成自己的形参
函数返回值问题:函数在执行完成后,其中的局部变量会被立即销毁,那返回值是如何被带回的呢?其实,当返回值被计算出来的时候,会将返回值放入到一个全局的寄存器中,而寄存器不会随着函数的销毁而销毁 ,就这样借助寄存器带回了返回值。
4.函数栈帧的销毁(对应实例里的代码)
函数栈帧在函数调用完成后,会进行销毁,如下图所示的指令是add函数内销毁add函数栈帧的操作
下图三条指令,会弹出add函数栈帧空间顶部的三个数据
如下指令通过把add函数的ebp传给esp,销毁add函数的栈帧空间,在通过弹出main函数的ebp,让add函数的ebp重新指向main函数的esp位置,并ret返回到指向main函数里call指令的下一条指令地址处(该指令地址在call指令执行后已经被压入栈)
如下图所示
再次来到main函数后,先通过add指令使地址增长销毁了形参的空间,并将add函数寄存器(eax)存储的返回值放入到main函数的c变量存储空间去,如下图分别是有符号名和无符号名时的代码
再利用和销毁add函数栈帧空间相同的方法,来销毁main函数的栈帧空间,我们会发现,在销毁栈帧空间时,我们在创建栈帧空间时保存的每个函数的ebp派上了大用场,它可以帮助我们销毁当前栈帧空间后,使得内层函数的esp,ebp(被销毁函数)能够指向外层函数的esp,ebp,借此一步步完成对每个调用函数栈帧的销毁。下图是main函数销毁栈帧的指令,与add函数销毁栈帧的指令逻辑完全相同。