前言:
概念引入
在这个资源共享的时代,共享单车等带给我们极大的便利,当你使用共享单车后,你是不是要把共享单车放在停车地点?想象一下,把使用共享单车看做内存的一部分,把操作系统看做停车场,那么,使用者占用内存,就是你在骑共享单车的那一段时间,放回共享单车就是把内存还给操作系统,像这样动态化,在栈区合理地使用内存,就是我们所说的函数的栈帧了!栈帧
定义:一个函数执行的环境——就是函数调用过程中在程序的调用栈(call stack)所开辟的空间,这些空间是用来存放:
1.函数参数和函数返回值
2.临时变量(包括函数的非静态的局部变量以及编译器自动生产的其他临时变量)
3.保存上下文信息(包括在函数调用前后需要保持不变的寄存器)。
那什么是栈?什么又是帧呢?
栈:计算机的一块内存,我们临时变量和函数一般在栈区开辟空间。
帧:函数和临时变量所使用的空间就是帧。
把这两个词合起来:在栈区为函数运行开辟的一块空间就是栈帧了!
到这里我们第一个目标就完成了。
注意:栈区的使用是从高地址到低地址使用的,就像堆得很高的一层书一样,先从最高处拿,再从低处拿。
寄存器
定义:CPU内部用来存放数据的一些小型存储区域,用来暂时存放参与运算的数据和运算结果。
我们这篇文章所用的寄存器:
ebp:栈底寄存器
esp:栈顶寄存器
功能:维护当前正在使用的函数空间
eax:通用寄存器,保留临时数据,常用于返回值,也用于赋值。
ebx:通用寄存器,保留临时数据——通常是用于初始化用户栈
eip: 指令寄存器,保存当前指令的下一条指令的地址,确保上一条指令能够继续运行下去。
反汇编指令:
mov:数据转移指令,也就是赋值操作
push:数据入栈,同时esp栈顶寄存器也要发生改变,向低地址处移动。
pop:数据弹出至指定位置,同时esp栈顶寄存器也要发生改变,向高地址处移动。
sub:减法命令
add:加法命令
call:函数调用,1. 压入返回地址 2. 转入目标函数
jump:通过修改eip,转入目标函数,进行调用
ret:恢复返回地址,压入eip,类似pop eip命令
rep:重复前缀指令,不可独立使用。
用途:重复执行一条指令。
形式:位于 stos、lod、ins、outs等传送指令之前,如 rep stosd edi。
运行机制:rep指令是重复执行该指令后面的汇编代码,执行次数由寄存器ecx控制。
stos:传送指令,可独立使用。
用途:将eax(ax,al)寄存器的值传送到指定的内存单元
形式:stos edi,其中edi为CPU寄存器,该寄存器存有目标内存的地址,为寄存器寻址。
运行机制: 将寄存器eax中的内容传输到寄存器EDI所指向的内存单元中。
lod:load effective address加载有效地址
图解:
恭喜你!到这里第二个目标也解决了。
知识准备工作已经完成,下面我们来举个例子详细理解
栈帧的调用
举例函数
#include <stdio.h>
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函数呢?
从这里我们可以看出main函数是由invoke_main 来调用的,那么我们有理由推断invoke_main 也是由一个函数调用的,并且每一部分都有自己的ebp和esp来维护自己的栈帧,这里就不多说了。
到这里第三个问题就解决了。
调用阶段
开始转到反汇编
那么我们会看到如下代码:
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]
//main函数中的核心代码
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
}
下面一句一句的拆解代码:
1.栈帧的创建
起点从上一步开始——也就是invoke_main 调用结束:
00BE1820 push ebp//将ebp(一个整形)压入栈顶也就是下图esp指向元素的上一个元素,然后将esp在向上移动四个字节指向ebp(由于是低地址所以要减去四个字节),这个过程就叫做压栈,此时的ebp为目前ebp的值也就是invoke_main栈底的地址。
00BE1821 mov ebp,esp
//将esp的值赋值给ebp,esp的值不变,这时,esp就会指向原来esp指向的元素,也就是ebp这时的ebp为main函数的栈底
00BE1823 sub esp,0E4h
//然后,esp减上个0E4h(十六进制值为228)这么大的空间作为main函数的栈帧 (空间)
00BE1829 push ebx
//将ebx放在main函数的栈顶然后esp再减去4个字节
00BE182A push esi
//再将esi的值放在main函数的栈顶然后esp再减去4个字节
00BE182B push edi
//再将edi的值放在main函数的栈顶然后esp再减去4个字节
00BE182C lea edi,[ebp-24h]
//将ebp(main函数的栈底)减去24h(36)字节大小
00BE182F mov ecx,9
//将9的值放在ecx中。为啥是9?因为9*4(整形所占的空间)=36(24h)是为了将ebp到adi的值改为目标值,也就是下一句话。
00BE1834 mov eax,0CCCCCCCCh/
/将0CCCCCCCCh的值放在eax中
00BE1839 rep stos dword ptr es:[edi]
//重点:
//这句话的意思是从edi处开始,将eax的值放入从edi指向的值中,执行过后,edi减去四个字节(dword————双字(一个字占两个字节))重复9次操作(ecx的值存的是操作次数)
// 操作类似循环,下面用伪代码辅助理解
伪代码理解:
edi = ebp - 0x24;
ecx = 9;
eax = 0xCCCCCCCC;
for (; --ecx; edi += 4)
{
*(int*)edi = eax;
}
到这里赋值后会使用0xccccc的空间为变量的空间,如果字符只定义不初始化那么就会出现烫烫的文字,0xCCCC(两个连续排列的0xCC)的汉字编码就是“烫”,因此会打印烫字。
到这第四个问题就解决了。
2.变量的空间创建
重点代码:
int a = 3;
00BE183B mov dword ptr [ebp-8],3
//将3放在ebp减8个字节所指地址的值处,这里存放的就是a变量
int b = 5;
00BE1842 mov dword ptr [ebp-14h],5
//将5放在ebp减20个字节所指地址的值处,这里存放的就是b变量
int ret = 0;
00BE1849 mov dword ptr [ebp-20h],0
//将0放在ebp减20个字节所指地址的值处,这里存放的就是ret变量
3.函数的传参
ret = Add(a, b);
00BE1850 mov eax,dword ptr [ebp-14h]
//将ebp减去20字节地址处的值,也就是b放在eax中
00BE1853 push eax
//将eax放在栈顶处,esp再减去四个字节指向eax
00BE1854 mov ecx,dword ptr [ebp-8]
//将ebp减去8字节地址处的值,也就是a放在ecx中
00BE1857 push ecx
//将ecx放在栈顶处,esp再减去四个字节指向eax
//这几句汇编代码其实就是将实参复制作为形参,放在栈顶处供所调用函数的使用。
//第五个问题就解决了
4.函数的调用与销毁
call 指令是要执行函数调用逻辑的,在执行call指令之前先会把call指令的下一条指令的地址进行压栈操作,这个操作是为了解决当函数调用结束后要回到call指令的下一条指令的地方(也就是记住你要回去的地址),继续往后执行。(知道要去哪,并且要知道咋回去,要不然就回不去了)
00BE1858 call 00BE10B4
//调用add函数的指令跳到add函数里
int Add(int x, int y)
{
00BE1760 push ebp //将main函数栈帧的ebp的值放在栈顶,esp减4字节
00BE1761 mov ebp,esp //将main函数的esp的值赋值给ebp,ebp现在是Add函数的ebp
00BE1763 sub esp,0CCh //给esp减0xCC,这是Add函数的esp
00BE1769 push ebx //将ebx的值放在栈顶,esp减4字节
00BE176A push esi //将esi的值放在栈顶,esp减4字节
00BE176B push edi //将edi的值放在栈顶,esp减4字节
int z = 0;
00BE176C mov dword ptr [ebp-8],0 //将0放在ebp-8的地址所指向的值处,其实就是变量z的创建
z = x + y;
//接下来计算的是x+y,结果保存到z中
00BE1773 mov eax,dword ptr [ebp+8] //将ebp加8字节地址处所存的数字存储到eax中
00BE1776 add eax,dword ptr [ebp+0Ch] //将ebp加12字节地址处所存的数字加到eax寄存器中
00BE1779 mov dword ptr [ebp-8],eax //将eax的结果保存到ebp减8字节的地址所指向的值处,其实就是放到z中
return z;
00BE177C mov eax,dword ptr [ebp-8] //将ebp-8地址处的值放在eax中,其实就是把z的值存储到eax寄存器中,这里是想通过eax寄存器带回计算的结果,做函数的返回值。因为寄存器是集成在cpu上的不会随着函数的销毁而销毁
}
//到这第六个问题就解决了。
00BE177F pop edi
//将此时栈顶所存的值赋给edi,esp加上四个字节
00BE1780 pop esi
//将此时栈顶所存的值赋给esi,esp加上四个字节
00BE1781 pop ebx
将此时栈顶所存的值赋给esi,esp加上四个字节
00BE1782 mov esp,ebp
//将此时ebp的值赋给esp,这里的esp就为main函数的esp
00BE1784 pop ebp
//将此时栈顶也就是main函数的ebp(mian函数的ebp)所存的值赋给ebp(add函数的ebp),esp加上四个字节
00BE1785 ret
//此时返回call指令的下一条指令的地址,然后接着执行,并且将地址弹出,esp加上四个字节
5.函数的返回
//返回到这个地址继续往下执行
00BE185D add esp,8
//将8加到esp上,也就是指向edi
00BE1860 mov dword ptr [ebp-20h],eax
//将eax也就是求和的值,放在ebp-20h所指向的值处,也就是ret里面
那么到这就是函数的调用与销毁的全过程了,至于main函数的销毁也大同小异,就交给你了。
整体的流程图:
总结
尝试回答下列问题:如果对答如流,说明你的内功已经炉火纯青了,恭喜恭喜!如果没有还需要加油哦!
第一个问题:函数执行的环境——为函数运行所开辟的一块空间。
第二个问题:存放函数的参数返回值,临时变量,保存上下文信息。
第三个问题:参考目录。
第四个问题:invoke_main 来调用的
第五个问题:两个0xccc转换成字符即为汉字“烫”
第五个问题:参考整体的流程图:
第六个问题:返回值存在eax返回,eax为寄存器集成在cpu上
不会随着函数的销毁而销毁。(可以把eax看成全局变量)
尾序
如果能认真看到这里,我坚信你能收获很多很多!也希望这篇文章能帮助到你,如果觉得不错,请点击一下不要钱的赞,如果有误请温柔的指出,在这里感谢大家了!