文章目录
前言
路漫漫其修远兮,吾将上下而求索;
引言:
在写代码的过程中,你是否会有疑问,当你越界打印数据的时候,屏幕上会出现"烫烫烫烫……" 或者 “屯屯屯屯……” ,这是为什么呢?
- 这是由于在Debug 模式的第四步中,会将所有分配出来的栈空间的每一个字节均初始化为0xCC,而 0xCCCC所对应的汉字编码便是"烫" ; 在堆区中,编译器会将分配出来的堆空间中的每一个字节初始化为 0xCD ,而0xCDCD所对应的汉字编码便是“屯” ;
- 局部变量是怎么创建的?
- 为什么局部变量的值为随机值?
- 函数名可以表示函数的地址,函数如何调用的?
- 其传参的过程又是怎样
- 以及当函数执行结束后便会被销毁,倘若这个函数有返回值,其返回值又是怎么带回的……
这些问题均与函数栈帧的创建与销毁有关;
想要进一步地掌握函数栈帧地创建与销毁,见下文;
一、什么是栈?
在了解栈之前,我们可以先来回顾一下内存的区域划分:
上图中的箭头标明了几个可变的区的尺寸增长方向,即栈向低地址增长,堆向高地址增长;当栈或者堆中的空间不够用的时候,它们便会按照图中所示的方向扩大自己空间的大小,直到预留的空间被用为止;
- 几乎每一个程序均会使用栈,没有栈便没有函数、局部变量;
用户可以将数据压入栈(入栈,push) ,也可以将已经压入栈的数据弹出(出栈 ,pop) ;如此看来,栈便相当于一个容器,是一块具有动态开辟属性的区域;栈所要遵守的规则:先入栈的数据后出栈(Fist In Last Out ,FIFO) ;
- 由于栈先使用高地址的空间,向低地址处增长,所以压栈的操作(push) 会使得栈顶(由寄存器 esp 维护)的地址减小,弹出的操作(pop) 会使得栈顶的地址增大;
假设上图中所开辟的空间是一个函数所对应的空间,这块空间是怎么维护的呢?
- 这块空间是由两个寄存器 ebp、esp 维护的;即此时正在调用哪一个函数,寄存器ebp、esp 维护的便是此函数在栈区上所开辟的空间,这块空间也叫做当前这个函数的函数栈帧;
上图中栈底是 0xffffffff ,寄存器ebp 会标明栈底,寄存器esp 会标明栈顶;当在栈上压入数据的时候,会使得esp 减小(向低地址方向开辟空间), 弹出数据会使得esp 增大;同理,当esp 减小的时候,便说明此时在栈上开辟空间,当esp 增大的时候便说明此时在栈上回收空间;
二、汇编的相关知识
提起汇编,你是否还有印象呢?我们先来大致回顾一下源文件经过编译、链接形成可执行程序的全过程,(详解戳此链接:C语言-程序环境 #预处理 #编译 #汇编 #链接 #执行环境-CSDN博客);
在编译(预处理结束后的编译) 之后,C语言代码被转换成了汇编代码;
1、寄存器:
(eax, ebx, ecx, edx, esi, edi, ebp, esp等都是X86 汇编语言中CPU上的通用寄存器的名称,是32位的寄存器。倘若用C语言来解释,可将这些寄存器当作变量来使用,例如:add eax,-2 ; //可认为将变量eax加上一个值 -2 )
- esp: 存放着栈顶空间的地址,以标明栈顶;
- ebp:基址指针(Base Pointer),又被称为帧指针(Frame Pointer), 会指向一个函数活动记录的一个固定位置即栈底(因为空间是向低地址处开辟的,那么栈底的地址在此函数调用的过程之中便不会发生变化)
- ebx : 基地址(base)寄存器 , 在内存寻址时存放基地址。
- esi : 源索引寄存器(destination index) , es:esi 指向源串
- edi : 目标索引寄存器(Destination Index) , es:edi 指向目标串.
- ecx : 计数器(counter) , 是重复(rep)前缀指令和 loop 指令的内定计数器。
- eax : 累加器(accumulator) , 它是很多加法乘法指令的寄存器。
2、一些汇编指令:
- push ebp push 压栈操作,将 ebp 压入栈中
- mov esp,ebp mov 移动,将寄存器ebp中的值赋给寄存器esp
- sub esp,0E4h sub 减,将寄存器esp中的值减0E4h,其中0h 为十六进制数字的标志
- add esp,8 add 加 ,将寄存器esp 中的值加8
- lea edi , [ ebp + FFFFFF1Ch] lea 加载有效地址(load effective address) , 将地址[ ebp + FFFFFF1Ch] 加载到 寄存器 edi 之中
- dword ptr [地址] 该地址所指向的四字节的数据
- rep stos dword ptr es:[edi] rep 重复,rep stos 重复执行指令;覆盖从edi开始的内存为eax,edi减小4,循环ecx次。
- mov ecx , dword ptr [ ebp - 8] 将[ ebp - 8] 所指向的四字节的数据放到寄存器ecx 中
- pop ebp 把栈顶元素先出栈,再赋值给寄存器ebp
- call:函数调用;1. 压入返回地址 2. 转入目标函数
- jump:通过修改eip,转入目标函数,进行调用
- ret: 从栈中取得返回地址,并跳转到该位置
三、函数栈帧的创建与销毁的理论
在参数之后的数据(包括参数)即为当前函数栈帧的活动记录,寄存器 ebp 会固定在如上图所示的位置上,并不会随着此函数的执行而变化,相反,因为寄存器esp 始终指向栈顶,因此随着函数的执行,esp 会不断地变化。
函数栈帧是靠寄存器ebp esp 来维护的;
固定不变的ebp 可以用来定位函数活动记录中的各个数据。在ebp 之前首先是这个函数的返回地址,它的地址为ebp-4 , 再往前便是压入栈中的参数,它们的地址分别 ebp - 8 、ebp - 12 等,所减的值由参数的个数与其大小而决定;
ebp 所直接指向的数据是调用当前函数前的ebp 中的地址,这样的话,当当前函数返回的时候,ebp 便可以通过读取这个值恢复到调用此函数前的位置上去。
上图中函数栈帧活动记录的形成源于函数调用的过程:
- 把所有或一部分参数压入栈中,如果有其他参数未入栈,那么便使用某些特定的寄存器进行传递
- 将当前指令的下一条指令的地址压入栈中
- 跳转到函数体执行
其中,上述的第二步与第三步是由call 指令一起执行的;
当跳转到函数体之后,便会开始执行当前函数,再i386CPU中函数体的“标准”开头是这样的(不同的CPU中可能会存在差异):
- push ebp: 将ebp 压入栈中,也就是上图中的old ebp;
- mov ebp,esp: ebp=esp (这时ebp 指向栈顶,而此时的栈顶是 old ebp)
- 【可选】sub esp ,XXX : 在栈上分配XXX大小的空间
- 【可选】push XXX : 如果有必要,会保存名为XXX的寄存器(可重复多个); (例如:edi ecx eac 等)
将ebp 压入栈中是为了在函数返回的时候便于恢复到调用函数前原ebp 所在的位置上去;
而之所以会保存一些寄存器,在于编译器可能要求某些寄存器在调用的前后保持不变,那么函数就可以在调用开始时将这些寄存器的值压入栈中,在结束后再取出 (函数的返回值便是这么返回的,将值存入寄存器中,然后再将其放到存储这个返回值的变量之中) ;
函数返回时所进行的"标准"结尾 正好与"标准"开头相反:
【可选】pop XXX : 如果有必要,回复保存过的寄存器(可重复多个)
- mov esp , ebp : 恢复esp 的同时回收局部变量空间(函数栈帧的销毁)
- pop ebp : 从栈中恢复保存的ebp 的值
- ret : 从栈中取得返回的地址,并跳转到该位置上;
四、结合例子具体分析
1、调试 - 窗口 - 调用堆栈
2、结合例子
2.1 源代码:
#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;
}
2.2 调试- 转到反汇编 如下:
Add函数的汇编代码如下:
2.3 分析:
2.3.1 保存ebp ,让ebp 指向目前的栈顶
- push ebp : 将ebp 压入栈中
- mov enp,esp : 将esp 中的值赋给ebp .即让寄存器ebp 指向栈顶
将旧ebp 的地址压入栈中,由于esp 始终指向栈顶,故而esp 会随着栈顶的变化而变化;然后再将esp 的值赋给ebp ,即让寄存器ebp 也指向栈顶;
2.3.2 再栈上开辟一块空间
- sub esp,0E4h : 让寄存器esp中额值减去0E4h ,即开辟一块大小为 0E4h 的空间
2.3.3 保存ebx、esi、edi 寄存器
- push ebx : 将寄存器ebx 压入栈中
- push esi : 将寄存器esi 压入栈中
- push edi : 将寄存器edi 压入栈中
2.3.4 加入调试信息
- lea edi,[ebp -24h] : 将有效地址[ebp - 24h] 加载到寄存器edi 之中
- mov ecx,9 : 将值9 放入寄存器ecx 之中
- mov eax,0CCCCCCCCh : 将0cCCCCCCCh放到寄存器eax 之中
- rep stos dword ptr es:[edi] : 将存放在寄存器edi中的地址( 即 [ebp - 24h] )下的四字节空间(向高地址处)初始化为寄存器eac中的数据(即 0CCCCCCCCh ),并重复执行 寄存器ecx中的数据(即 9 ) 次;
0E4h 是什么意思:
- 0h 代表着十六进制的数据,故而0E4h 转换为十进制为: 4*(16^0)+14*(16^1) = 228 byte ;
如何理解 rep stos dword ptr es:[edi] ?
rep --> repeat 重复
stos --> store into String 存入字符串
dword ptr [地址] : 从该地址向高地址的四字节大小的空间
es:edi : 指向目标串
故而连起来便可以理解为:从edi 中存的地址向高地址处的四字节空间用eax 中的数据初始化 ,重复执行ecx次;
2.3.5 局部变量的创建
- mov dword ptr [ebp-8],3 : 将数值3 放到地址为[ebp-8]向高地址方向的四字节空间中
- mov dword ptr [ebp-14h],5 : 将数值5 放到地址为[ebp-14h]向高地址方向的四字节空间中
- mov dword ptr [ebp-20h],0 : 将数值0 放到地址为[ebp-20h]向高地址方向的四字节空间中
2.3.6 Add函数的调用
2.3.6.1 Add 函数传参
- mov eax,dword ptr[ebp-14h] : 将起始地址为[ebp-14h]向高地址处的四字节空间的数据存放到寄存器eac 之中
- push eax : 将寄存器eax 压入栈中
- mov ecx,dword ptr [ebp-8] : 将起始地址为[ebp-8] 向高地址处的四字节空间的数据存放到寄存器ecx 之中
- push ecx : 将寄存器ecx 压入栈中
2.3.6.2 调用Add 函数
当调试到这一句的时候,便按下F11 进入Add函数内部;
call : 将call 指令下一条指令的地址压入栈中,并且跳转到函数体执行;
为什么要记住call 指令下一条指令的地址呢?
- 当执行完call 指令之后,便会马上去执行Add函数(调试进入了Add函数内部);而当调用完Add 函数之后,还要返回到call 指令的下一条指令的位置处;而此处恰好将call 指令下一条指令的地址压入栈中,那么当调试完Add 函数返回时便会用上此地址,利用此地址跳转到该继续执行的语句的位置上;
2.3.6.3 为Add函数准备函数栈帧
2.3.6.4 变量的的准备、计算、返回
int z = 0;
- mov dword ptr [ebp-8],0 : 将数值0 放在起始地址为[ebp-8] 向高地址处的四字节的空间之中
z = x + y;
- mov eax,dword ptr [ebp + 8] : 将起始地址为[ebp+8] 向高地址处的四字节空间的数据放到寄存器eax 之中
- add eax,dword ptr [ebp 0Ch] : 将起始地址为[ebp+0Ch] 向高地址处的四字节空间的数据加到寄存器eax 之中
- mov dword ptr [ebp-8] , eax : 将寄存器eax 中的数据放到起始地址为 [ebp-8] 向高地址处的四字节的空间之中
return z ;
- mov eax,dword ptr [ebp-8] : 将起始地址为[ebp-8] 向高地址处的四字节空间的数据 放到寄存器eax 之中
寄存器eax 中的变化:
可见,函数的返回值是先保存在寄存器中,然后利用寄存器带回的;
2.3.6.5 Add 函数栈帧的销毁
- pop edi : 将寄存器edi 弹出(出栈)
- pop esi :将寄存器esi 弹出 (出栈)
- pop ebx : 将寄存器ebx 弹出 (出栈)
- mov esp,ebp : 将寄存器ebp 中的值赋给寄存器esp ,即Add 函数栈帧的销毁
- pop ebp 把栈顶元素先出栈,再赋值给寄存器ebp
- ret : 从栈中取得返回地址,并跳转到该位置上
2.3.7 Add函数的返回值
其返回值是通过寄存器eax 进行传递的
- add esp,8 : 将数值8加到寄存器esp 中,也就是说又销毁了8byte 的空间
- mov dword ptr [ebp-20h],eax : 将eax 中的值8 放到起始地址为 [ebp-20h] 向高地址处的四字节的空间之中
从上图可知,此时将函数Add的返回值放到了变量ret 之中,即此时的ret 为8;
2.3.8 printf 函数
printf 函数的调用与Add 函数的调用如出一辙,此处便不再演示
2.3.9 return 返回,main 函数栈帧的销毁
总结
看完上文,引言所述的问题你或许已经有了答案;
局部变量的创建?
- 首先会为局部变量所在的函数创建栈帧空间,在栈帧空间中初始化好一部分空间之后便会为局部变量分配一些空间;
为什么局部变量的值为随机值?
- 因为在为局部变量分配空间前会为所开辟的函数栈帧中的部分空间进行初始化的操作;
函数是如何传参的?其传参的顺序是如何的?
- 当要去调用一个函数的时候,在调用之前便会将这个函数的参数从右向左进行push 压栈的操作,当真正进入该函数的时候,在此函数栈帧中通过对ebp 的偏移量,便可以找到形参;
注:函数传参的顺序是由调用惯例决定的,即函数的调用方和被调用方对于函数如何调用和遵守的约定;
形参与实参的关系?
- 形参所在的空间确实是压栈时所开辟的空间,形参与实参的值是相同的,但其空间时独立的;故而进行传值调用的时候,形参是实参的临时拷贝,改变形参并不会影响实参;
函数调用之后是如何返回的?
- 在调用该函数(eg.Add )之前,便会把main 函数中call 指令下一条指令的地址压入栈中;再将main 函数(当前所调用函数(eg.Add )上一个函数的ebp 的地址) 栈帧寄存器ebp 的地址也压入栈中;当此函数(eg.Add) 调用结束之后要返回的时候,会弹出ebp ,便找到了main 函数原来的ebp 的位置;记住了call 指令下一条指令的地址,当这个函数(eg .Add )返回时, 便可以跳转到call 指令的下一条指令,然后继续向下执行;
函数的返回值是通过寄存器带回来的;