c语言|《伏C录》凝丹篇-函数栈帧理解手册

目录
一、前面
二、预备知识
三、栈帧创建与销毁
四、总结

古岂无人!孤标凌云道为朋,剑宿吾命,亦狂亦侠亦超尘。
c语言|《伏C录》凝丹篇-函数栈帧理解手册
文章图片

一、前面
本章将以汇编视角看函数栈帧的内存是如何使用与回收的,为了降低汇编语言的理解成本,以图示的方式讲解每一步汇编指令所带来的效果,来逐步展示函数栈帧的形成与销毁的整个过程。
展示环境:win10 && vs2019
二、预备知识 这些预备知识理解与否对本篇文章并无很大关系,之所以预备这些知识是为了让读者能够更加相信函数栈帧的形成与销毁过程就是如此。
栈区:内存四区之一,内存为了使用和管理,被划分为四部分,其中栈区就是内存被划分的区域之一,栈的使用习惯是,先使用高地址部分,在使用底地址部分。
函数栈帧:即在调用函数时,为函数开辟的一块内存空间,由于该内存空间在栈区,因此该空间被称作函数栈帧,简称栈帧。
栈顶:故名思意,就是栈的顶部,更确切的说是指向存放在栈区数据的顶部。
栈底:栈的底部。
寄存器:寄存器cpu内部用来存放数据的一些小型存储区域,用来暂时存放参与运算的数据和运算结果。简单来说就是独立于内存,用来存储少量数据的器件。
ebp:栈底指针寄存器
esp:栈底指针寄存器
其它寄存器:ebx、esi、edi、ecx、eax
入栈(压栈):先将栈顶指针向上移动四字节的大小空间,再将寄存器的数据放入那四字节空间。这里的向上移动是指向低地址处移动。
入栈指令:push a。
图解:以push a为例。
c语言|《伏C录》凝丹篇-函数栈帧理解手册
文章图片


出栈:将栈顶指针向下移动四字节,这里的向下是往低地址处移动四个字节的空间。并将这四个字节的数据放入某个寄存器中。
出栈指令:pop a。
图解:以popa为例。
c语言|《伏C录》凝丹篇-函数栈帧理解手册
文章图片

简单汇编操作指令
mov a b:将b赋值给a,c语言表示就是a=b。
sub a b:将a-b的结果赋值给a,c语言表述就是a=a-b。
add a b :将a+b的结果赋值给a,c语言表述就是a=a+b。
由于理解成本的原因,遇到的其它汇编指令本文会直接指出它的作用效果。
三、栈帧创建与销毁 以Add函数调用为例
#define _CRT_SECURE_NO_WARNINGS 1 #include int Add(int x, int y) { int z = x + y; return z; } int main() { int a = 10; int b = 20; int z = 0; z = Add(a, b); printf("%d\n", z); return 0; }

该代码对应的汇编指令如下:
c语言|《伏C录》凝丹篇-函数栈帧理解手册
文章图片

需要说明的是,main函数也被别的函数调用的,调用关系是:__mainCRTStartup调用main函数,mainCRTStartup函数调用__mainCRTStartup。
【c语言|《伏C录》凝丹篇-函数栈帧理解手册】再调用main函数之前,栈区是这样的。
c语言|《伏C录》凝丹篇-函数栈帧理解手册
文章图片

指令分说:
int main() { 00F71E40pushebp 00F71E41movebp,esp 00F71E43subesp,0E4h

以上图为参照。
第一条指令:将寄存器ebp的值压栈
第二条指令:将寄存器esp的值赋值给ebp
第三条指令:将esp-0E4h赋值给寄存器esp,形象的表述是esp向低地址方向移动4个字节,上端为低地址,下端为高地址,即向上移动4字节空间。
栈区视图变为:
c语言|《伏C录》凝丹篇-函数栈帧理解手册
文章图片

这三条指令,简单来说就是为main函数在栈区开辟了一块空间(这块空间大小系统会帮我们自动开辟好。)
指令分说:
00F71E49pushebx 00F71E4Apushesi 00F71E4Bpushedi

将三个寄存器的值压入栈中
栈区视图变为:
c语言|《伏C录》凝丹篇-函数栈帧理解手册
文章图片

指令分说:
00F71E4Cleaedi,[ebp-24h] 00F71E4Fmovecx,9 00F71E54moveax,0CCCCCCCCh 00F71E59rep stosdword ptr es:[edi]

这四条指令我们就解读了,效果就是将main函数的栈帧空间以16进制值cccccccc填充。
栈区视图变为:
c语言|《伏C录》凝丹篇-函数栈帧理解手册
文章图片

指令分说:
00F71E5Bmovecx,0F7C003h 00F71E60call00F7130C

这两条指令是编译器检查用的,初学不必花费更多时间了解更细节的部分。
vs2013没有这一检查部分,vs2019检查很严格。
指令分说:
int a = 10; 00F71E65movdword ptr [ebp-8],0Ah int b = 20; 00F71E6Cmovdword ptr [ebp-14h],14h int z = 0; 00F71E73movdword ptr [ebp-20h],0

第一条汇编指令:将0Ah放入[ ebp-8 ]这块空间中,即把a放入那块空间。
第二条汇编指令:将14h放入[ ebp-14h ]这块空间中,即把b放入那块空间中。
第三条汇编指令:将0放入[ ebp-20h ]这块空间中。即把z放入那块空间中。
栈区图示:
c语言|《伏C录》凝丹篇-函数栈帧理解手册
文章图片

简单来说:就是将局部变量放入对应的函数栈帧中。
指令分说:
z = Add(a, b); 00F71E7Amoveax,dword ptr [ebp-14h] 00F71E7Dpusheax 00F71E7Emovecx,dword ptr [ebp-8] 00F71E81pushecx

第一条指令:将【ebp-20】这块空间4字节的数据放入eax中。即把b=20的数据放入eax中。
第二条指令:将eax的数据压栈。
第三条指令:将【ebp-8】这块空间4字节的数据放入ecx中。即把a=10的数据放入ecx中。
第四条指令:将ecx的数据压栈。
栈区视图:
c语言|《伏C录》凝丹篇-函数栈帧理解手册
文章图片

这里的20和10,就是我们传过去的实参,之后Add函数调用的x和y就是指这两块空间。
那么我们可以知道:函数传参是从右向左传的。这里就是先传的b再传的a。
指令分说:
00F71E82call00F710B4 调用的函数: int Add(int x, int y) { 00F71740pushebp 00F71741movebp,esp 00F71743subesp,0CCh 00F71749pushebx 00F7174Apushesi 00F7174Bpushedi 00F7174Cleaedi,[ebp-0Ch] 00F7174Fmovecx,3 00F71754moveax,0CCCCCCCCh 00F71759rep stosdword ptr es:[edi] 00F7175Bmovecx,0F7C003h 00F71760call00F7130C int z = x + y; 00F71765moveax,dword ptr [ebp+8] 00F71768addeax,dword ptr [ebp+0Ch] 00F7176Bmovdword ptr [ebp-8],eax return z; 00F7176Emoveax,dword ptr [ebp-8] } 00F71771popedi 00F71772popesi 00F71773popebx 00F71774addesp,0CCh 00F7177Acmpebp,esp 00F7177Ccall00F71235 00F71781movesp,ebp 00F71783popebp 00F71784ret

第一条汇编指令:call是调用指令,调用Add函数。
经过上次的指令,这里我就直接介绍效果了。
00F71740pushebp 00F71741movebp,esp 00F71743subesp,0CCh

这三条指令,为Add函数在栈区开辟对应的空间大小。
栈区图示:
c语言|《伏C录》凝丹篇-函数栈帧理解手册
文章图片

00F71749pushebx 00F7174Apushesi 00F7174Bpushedi

将ebx,esi,edi入栈。
图示:
c语言|《伏C录》凝丹篇-函数栈帧理解手册
文章图片

00F7174Cleaedi,[ebp-0Ch] 00F7174Fmovecx,3 00F71754moveax,0CCCCCCCCh 00F71759rep stosdword ptr es:[edi]

对Add函数栈帧做初始化,将里面的数据置换为cccccccc。(用于初始化栈帧的具体数值取决于编译器)
c语言|《伏C录》凝丹篇-函数栈帧理解手册
文章图片

00F7175Bmovecx,0F7C003h 00F71760call00F7130C

编译器做的检查,不必理会。
int z = x + y; 00F71765moveax,dword ptr [ebp+8] 00F71768addeax,dword ptr [ebp+0Ch] 00F7176Bmovdword ptr [ebp-8],eax

取[ebp+8]空间的数据放入eax中
取 [ebp+0Ch]与eax的数据相加后放入eax中。
将eax的值放入ptr [ebp-8]中。
图示:
c语言|《伏C录》凝丹篇-函数栈帧理解手册
文章图片

return z; 00F7176Emoveax,dword ptr [ebp-8]

返回时,通过寄存器的方式,将返回值交给寄存器。
00F71771popedi 00F71772popesi 00F71773popebx 00F71774addesp,0CCh 00F7177Acmpebp,esp 00F7177Ccall00F71235 00F71781movesp,ebp 00F71783popebp 00F71784ret

代码分说:
00F71771popedi 00F71772popesi 00F71773popebx

将edi、esi、ebx出栈
图示:
c语言|《伏C录》凝丹篇-函数栈帧理解手册
文章图片


00F71774addesp,0CCh 00F7177Acmpebp,esp 00F7177Ccall00F71235 00F71781movesp,ebp 00F71783popebp

0CCh是Add函数栈帧的大小
所以esp向下移动到dbp的位置。
之后pop ebp,由于栈顶指向的是main函数栈帧的栈底,因此出栈ebp指向main函数栈帧的栈底。
图示:
c语言|《伏C录》凝丹篇-函数栈帧理解手册
文章图片

调用Add返回之后,继续执行以下指令。
00A717F7addesp,8 00A717FAmovdword ptr [ebp-20h],eax return 0; 00A717FDxoreax,eax } 00A717FFpopedi 00A71800popesi 00A71801popebx 00A71802addesp,0E4h 00A71808cmpebp,esp 00A7180Acall00A71235 00A7180Fmovesp,ebp 00A71811popebp 00A71812ret

第一条指令:将esp向下移动8个字节,即销毁x和y这两块连续的形参。
第二条指令:将寄存器eax保存的Add函数的返回值交给z。
图示:
c语言|《伏C录》凝丹篇-函数栈帧理解手册
文章图片

之后的指令就是回收main函数的栈帧了,回收过程都差不多,就不细细讲解了。

四、总结 以下函数调用为例。
#define _CRT_SECURE_NO_WARNINGS 1 #include int Add(int x, int y) { int z = x + y; return z; } int main() { int a = 10; int b = 20; int z = 0; z = Add(a, b); return 0; }

初始:mainCRTStartup函数调用__mainCRTStartup、__mainCRTStartup调用main函数。
栈区上先为以上两个函数分配函数栈帧。
调用main函数时,为main函数分配函数栈帧(该大小是自动开辟的)开辟好空间后,用cccccccc数值填充main函数栈帧。(具体用什么数值初始化函数栈帧取决与编译器)。
执行到int a = 10时,将局部变量a的值放入main函数栈帧的某块空间中,int b =20、int z=0也是如此,它们的空间都在main函数的函数栈帧中。
当执行到z=Add(a,b)时。
先传参,传参顺序是从右向左,所有先将b压入栈中,在将a压入栈中。
这两块空间就是y和、x。注意y和x并不在Add函数栈帧中,而是在main函数栈帧和Add函数栈帧之间的一块独立的空间。
然后为Add函数开辟函数栈帧,并ccccccc数值填充Add函数栈帧。(具体用什么数值初始化函数栈帧取决与编译器)。
当执行到z=x+y时,在Add函数栈帧中取一块空间作为局部变量z使用,在取出y和x空间的值,放入z中。(z是在Add函数栈帧中的)。
当执行到return z时,将z的值放入寄存器中。
之后再销毁Add函数的栈帧、销毁形参x和y、将寄存器的值交给z。
之后销毁main函数也是如此。
这里的销毁不是将Add函数的栈帧数据置为0或者其他数,它里面的数据并不是直接丢失的,而是直接告诉操作系统,这块空间我不需要了,Add函数栈帧里的数据还是存在的,只不过当你调用新函数时,Add函数栈帧这块空间会被新函数占用,并初始化为cccccccc这样的数值,那么Add函数栈帧空间数据也就丢失了。

    推荐阅读