前言:本期是关于函数栈帧创建与销毁的详细介绍,分三个模块:
模块1:什么是函数栈帧?
模块2:理解函数栈帧能解决何种问题?
模块3:函数栈帧创建和销毁的分析详解
理解掌握函数栈帧的创建和销毁,对代码的理解更上一层楼,也即“修炼内功”,有助于在未 来的编程路上行稳致远
模块1:
什么是函数栈帧?
我们在编写代码时常常会把一些独立的功能编写成函数,而函数的调用,函数参数的传递,函数返回值的带回都与函数栈帧有千丝万缕的关系。
函数栈帧就是函数调用过程中在程序的调用栈所开辟的空间,这些空间用于存放:
1.函数参数和函数返回值
2.临时变量:包括函数非静态的局部变量和编译器自动产生的其他临时变量
3.保存上下文的信息:包括在函数调用前后需要保持不变的寄存器
模块2:
理解函数栈帧能解决何种问题?
理解了函数栈帧,我们就能很好理解以下问题:
1.局部变量是如何创建的?
2.为何局部变量不初始化内容是随机的?
3.函数调用时参数如何传递?传参顺序是什么?
4.函数的形参和实参分别怎样实例化?
5.函数的返回值如何带回?
模块3:
函数栈帧创建和销毁的分析详解:
part 1:什么是栈?
栈被定义为一种特殊的容器,用户可以将数据:
*压入栈中(入栈,push),压栈操作使得栈增大,
*可以将已压入栈中的数据弹出(出栈,pop),出栈操作使得栈减小
*同时栈这个容器遵循着一个规则:
先入栈的数据后出栈
举个形象点的例子:我们叠放书籍,第一本书总是叠放在地面,最后一本书叠放在这一堆的最上层,现在取出书籍,我们自然是从最上面的一本书开始一本本取。
*栈总是向下增长的,即由高地址向低地址
*栈顶由成为esp的寄存器进行定位
part 2:认识相关寄存器和汇编指令
相关寄存器:
eax:通用寄存器,保留临时数据,常用于返回值
ebx:通用寄存器,保留临时数据
ebp:栈底寄存器
esp:栈顶寄存器
eip:指令寄存器,保存当前指令的下一条指令的地址
相关汇编命令:
mov:数据转移指令
push:数据入栈,同时esp栈顶寄存器也要发生改变
pop:数据弹出至指定位置,同时esp栈顶寄存器也要发生改变
sub:减法命令
add:加法命令
call:函数调用,1. 压入返回地址 2. 转入目标函数
jump:通过修改eip,转入目标函数,进行调用
ret:恢复返回地址,压入eip,类似pop eip命令
part 3 解析函数栈帧的创建和销毁
预备知识:
*每一次函数调用都需要为本次函数调用开辟空间,就是函数栈帧的空间
*这块空间的维护使用了esp和ebp这两个寄存器,esp记录的是栈顶的地址,ebp记录的是栈底的地址
理解图示如下:
解析开始:
下面我以在vs2019上对Add函数的调用为例解析函数栈帧的创建和销毁,代码如下:
int Add(int x, int y)
{
int z = 0;
z = x + y;
return z;
}
int main()
{
int a = 3;
int b = 5;
int ret = 0;
ret = Add(a, b);
printf("%d\n", ret);
return 0;
}
注意:main函数也是被调用的, 我们可以看到main函数调用之前,invoke_main函数调用了main函数
图示:
我们进行调试,转到反汇编:
int main()
{
//函数栈帧的创建
00BE1820 push ebp
00BE1821 mov ebp,esp
00BE1823 sub esp,0E4h
00BE1829 push ebx
00BE182A push esi
00BE182B push edi
00BE182C lea edi,[ebp-24h]
00BE182F mov ecx,9
00BE1834 mov eax,0CCCCCCCCh
00BE1839 rep stos dword ptr es:[edi]
//
int a = 3;
00BE183B mov dword ptr [ebp-8],3
int b = 5;
00BE1842 mov dword ptr [ebp-14h],5
int ret = 0;
00BE1849 mov dword ptr [ebp-20h],0
ret = Add(a, b);
00BE1850 mov eax,dword ptr [ebp-14h]
00BE1853 push eax
00BE1854 mov ecx,dword ptr [ebp-8]
00BE1857 push ecx
00BE1858 call 00BE10B4
00BE185D add esp,8
00BE1860 mov dword ptr [ebp-20h],eax
printf("%d\n", ret);
00BE1863 mov eax,dword ptr [ebp-20h]
00BE1866 push eax
00BE1867 push 0BE7B30h
00BE186C call 00BE10D2
00BE1871 add esp,8
return 0;
00BE1874 xor eax,eax
}
配合理解图示:
tips:ebp和esp这两个寄存器中存放的是地址,这两个地址是用于维护函数栈帧空间的
00BE1820 push ebp 把ebp寄存器中的值进行压栈,此时ebp中存放的是invoke_main函数栈帧的ebp
move指令把esp的值存放在ebp中,等效于产生了main函数栈帧的ebp,此时ebp中的值就是invoke_main函数栈帧的esp
00BE1821 mov ebp,esp
sub指令会把esp中的地址减去一个十六进制的数字0xe4,产生新的esp,等效于产生了main函数栈帧的esp,结合上条mov指令产生的ebp和当前的esp,ebp和esp之间维护了一个块栈空间,这个块栈空间就是为main函数开辟的,即main函数的栈帧空间,用于存放main函数的局部变量,临时数据以及调试信息等
00BE1823 sub esp,0E4h
00BE1829
00BE182A
00BE182Bpush
push
pushebx
esi
edi//将寄存器ebx的值压栈,esp-4
//将寄存器esi的值压栈,esp-4
//将寄存器edi的值压栈,esp-4这三条指令将三个寄存器的值保存在栈区上,因为这3个寄存器的值在函数的随后执行过程中可能会被修改,所以先保存寄存器原来的值,以便在函数退出时恢复
下面代码用于初始化main函数栈帧空间:
00BE182C
00BE182F
00BE1834
00BE1839lea
mov
mov
rep stosedi,[ebp-24h]
ecx,9
eax,0CCCCCCCCh
dword ptr es:[edi]将ebp-0x24这个地址存放在edi中
将9存放在ecx中
将0xcccccccc存放在eax中
将从ebp-0x24到ebp这一段的内存的每一个字节都初始化为0xcc
还记得代码中出现过的”烫烫烫“吗?
main函数调用时,在栈区上开辟的空间,它的每一个字节都被初始化为0xcc,arr数组是在这块空间内创建的,又未给arr数组初始化,而0xcccc的汉字编码就是”烫“,所以这就是缘由。
以上就是main函数栈帧的创建部分
下面分析main函数中的核心代码:
配合理解图示:
int a = 3;
00BE183B mov dword ptr [ebp-8],3 将3存储到ebp-8的地址处
int b = 5;
00BE1842 mov dword ptr [ebp-14h],5 将5存储到ebp-14的地址处
int ret = 0;
00BE1849 mov dword ptr [ebp-20h],0 将0存储到ebp-20的地址处
以上代码就是局部变量a,b,ret的创建和初始化,局部变量的创建是在局部变量所在函数的栈帧空间内进行的
调用Add函数:
ret = Add(a, b);
调用Add函数时的传参:传参就是把参数压栈(push)到栈帧空间中
00BE1850 mov eax,dword ptr [ebp-14h] 传递参数b,将ebp-14h地址处存放的5放入eax寄存器中
00BE1853 push eax 将eax的值进行压栈,esp-4
00BE1854 mov ecx,dword,ptr [ebp-8] 传递参数a,将ebp-8地址处存放的3放入ecx寄存器中
00BE1857 push ecx 将ecx的值进行压栈,esp-4
以上就是函数调用时的传参操作
tips:传值调用,形参是实参的一份临时拷贝,对形参的修改不会影响实参
理解图示:
函数调用过程:
00BE1858 call 00BE10B4 00BE185D add esp,8 00BE1860 mov dword ptr [ebp-20h], eax
配合理解图示:
call指令是要执行函数调用逻辑的,在执行call指令之前要先把call指令的下一条指令的地址进行压栈(push),这样做是为了函数调用结束后能回到call指令的下一条指令的地方,继续执行后面的代码操作
下面是Add函数的具体调用:
反汇编:
int Add(int x, int y)
{ 0
0BE1760 push ebp
00BE1761 mov ebp,esp
00BE1763 sub esp,0CCh
00BE1769 push ebx
00BE176A push esi
00BE176B push edi
int z = 0;
00BE176C mov dword ptr [ebp-8],0
z = x + y;
//接下来计算的是x+y,结果保存到z中
00BE1773 mov eax,dword ptr [ebp+8]
00BE1776 add eax,dword ptr [ebp+0Ch]
00BE1779 mov dword ptr [ebp-8],eax
return z;
00BE177C mov eax,dword ptr [ebp-8]
} 0
0BE177F pop edi
00BE1780 pop esi
00BE1781 pop ebx
00BE1782 mov esp,ebp
00BE1784 pop ebp
00BE1785 ret
配合理解图示:
Add函数栈帧空间的开辟,局部变量的创建的初始化和main函数相似,不再赘述
下面是z = x + y 代码的执行分析:
z = x + y;
00BE1773 mov eax,dword ptr [ebp+8] 将ebp+8地址处的数字(3)存储到eax中 00BE1776 add eax,dword ptr [ebp+0Ch] 将ebp+12地址处的数字(5)加到eax寄存中 00BE1779 mov dword ptr [ebp-8],eax 将eax的结果放入ebp-8的地址处(z) return z;
00BE177C mov eax,dword ptr [ebp-8] 将ebp-8地址处的值(z的值)存放在eax寄存器中,通过eax寄存器带回计算结果(函数返回值)
函数栈帧的销毁:
00BE177F pop edi 在栈顶弹出一个值,存放在edi中,esp+4
00BE1780 pop esi 在栈顶弹出一个值,存放在esi中,esp+4
00BE1781 pop ebx 在栈顶弹出一个值,存放在ebx中,esp+4
00BE1782 mov esp,ebp 将Add函数ebp的值赋给esp,等效于回收Add函数的栈帧空间
在栈顶弹出一个值存放在ebp中,此时栈顶的值恰好就是main函数的ebp,esp+4,恢复了对main函数栈帧空间的维护,此时esp指向main函数栈帧的栈顶,ebp指向main函数栈帧的栈底
00BE1784 pop ebp ret指令的执行,首先在栈顶弹出一个值,此时栈顶的值恰好就是call指令下一条指令的地址,esp+4,然后直接跳转到call指令下一条指令的地址处,继续执行后面的代码
00BE1785 ret
回到call指令下一条指令的地方(图示):
调用完Add函数回到main函数内部,执行main函数内部后面的代码:
00BE185D add esp,8 将esp+8,等效于跳过了main函数中的 a ',b '
00BE1860 mov dword ptr [ebp-20h],eax 将eax中的值(Add函数的计算结果)存放到ebp-20h的地址处(变量ret内)
可以看到,函数的返回值是由寄存器eax带回的
拓展:返回对象是内置类型时,一般是通过寄存器带回的,若返回对象是较大的对象时,一般会在主调函数的栈帧空间内开辟一块空间,然后将这块空间的地址隐式传递给被调函数,在被调函数中通过此地址就可以找到主调函数栈帧空间中预留的那一块空间,直接将返回值保存到主调函数
完。