一、相关基础知识介绍
1.压栈与出栈含义
push 压栈:给栈顶放一个元素。
pop 出栈:给栈顶删除一个元素。
2.寄存器是什么
寄存器是CPU内部用于存放是数据的小型存储区域,用于存放暂时运算的数据和运算结果的。
3.寄存器的种类以及分类
寄存器 | 一般寄存器 | AX | 累计存储器 |
---|---|---|---|
BX | 基底存储器 | ||
CX | 计数存储器 | ||
DX | 资料存储器 | ||
索引寄存器 | SI | 来源索引存储器 | |
DI | 目的索引存储器 | ||
堆叠、基底暂存器 | SP | 堆叠、指标存储器 | |
BP | 基底、指标存储器 |
而这里我们所需要了解的是EAX、EBX、ECX、EDX、ESI、EDI、ESP、EBP这几类寄存器。这些都可以理解是上述寄存器的延伸。
EAX | 累加(accumulator)寄存器,相对于其他寄存器,在运算方面比较常用。 |
---|---|
EBX | 基地址(base)寄存器,在内存寻址时存放基地址。 |
ECX | 计数(counter)寄存器,用于循环操作,比如重复的字符存储操作,或者数字统计。 |
EDX | 作为EAX的溢出寄存器,总是被用来放整数除法产生的余数。 |
ESI | 源变址寄存器,主要用于存放存储单元在段内的偏移量。通常在内存操作指令中作为“源地址指针”使用。 |
EDI | 目的变址寄存器,主要用于存放存储单元在段内的偏移量。在很多字符串操作指令中:DS:ESI指向源串;EDI指向目标串。 |
EIP | 控制寄存器,存储CPU下次所执行的指令地址(存放指令偏移地址)。 |
ESP | 栈顶指针,堆栈的顶部是地址小的区域,压入堆栈的数据越多,esp也就越来越小。在32位平台上,esp每次减少4字节。栈指针寄存器(extended stack pointer),其内存放着一个指针,该指针永远指向系统栈最上面一个栈帧的栈顶。是CPU机制决定的,push、pop指令会自动调整esp的值。 |
EBP | 基址指针寄存器(extended base pointer),一般与esp配合使用,可以存取某时刻的esp,这个时刻就是进入一个函数内后,CPU会将esp的值赋给ebp,此时就可以通过ebp对栈进行操作,比如获取函数参数,局部变量等。其内存放着一个指针,该指针永远指向系统栈最上面一个栈帧的底部。 |
4.ESP(栈顶指针)与EBP(栈底指针)
在这里我们着重注意一下ESP与EBP之间的搭配使用。这两个寄存器中存放的是地址,是用来维护函数栈帧的。
那么它是如何来维护的呢?
我们知道,每一个函数调用,都要在栈区创建一个空间。
#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\n", c);
return 0;
}
比如我们要调用main函数时,(如下图)那么ESP(栈顶指针)与EBP(栈底指针)就来维护main函数的函数栈帧。
而这两个寄存器是程序需要调用哪个函数,它们就会去调用哪个函数。例如,在调用Add(int x,int y)函数时,ESP(栈顶指针)与EBP(栈底指针)就会去维护Add函数的函数栈帧。
5.整体函数调用栈区空间的思路轮廓
在调试过程中,我们可以观察到main函数其实也是被其他函数所调用的。那么main函数究竟是被谁调用的呢?进入main函数内部可以发现,main函数时被_tmainCRTStartup,而_mainCRTStartup是被mainCRTStartup调用的。
所以如下图,栈区有mainCRTStartup、_mainCRTStartup、main、Add函数的函数栈帧。其中EBP与ESP是动态变化的。
6.常见的汇编指令
push指令 | 首先减少esp 的值,再将源操作数复制到栈地址,在32位平台上,esp 每次减少4字节。 |
---|---|
pop指令 | 首先把esp 指向的栈元素内容复制到一个操作数中,再增加esp 的值。在32位平台上,esp 每次增加4字节。 |
mov指令 | 用于将一个数据从源地址传送到目标地址,源操作地址的内容不变。 |
sub指令 | 减操作指令,从寄存器中减去<shifter_operand>表示的数值,并将结果保存到目标寄存器中。 |
lea指令 | 是“load effective address”的缩写,简单的说,lea指令可以用来将一个内存地址直接赋给目的操作数。 |
rep指令 | 重复前缀指令,英文缩写 repeat。能够引发其后字符串指令被重复。 |
stos指令 | 串存储指令,英文缩写 store string。 |
call指令 | 将程序下一条指令的位置的IP压入堆栈中,并转移到调用的子程序 |
jmp指令 | 无条件跳转指令。 |
add指令 | 用于将两个运算子相加,并将结果写入第一个运算子。 |
ret指令 | 用于终止当前函数的执行,将运行权交还给上层函数。也就是,当前函数的帧将被回收。 |
二、举例演示函数栈帧的创建与销毁
温馨提示:函数栈帧的创建与销毁在不同的编译器条件下,实力有差异的,大体的逻辑是一致的,其具体的细节取决于编译器的实现。本次使用的环境是VS2022。
上面我们说了分别有四个函数调用,分别是mainCRTStartup、_mainCRTStartup、main、Add,那么我们现在就来详细的讲述一下,它们之间是如何调用的。
1.为main()函数开辟栈帧
写好代码之后,我们按F10进入到调试阶段,然后右击鼠标选择<反汇编>,这样我们就进入了汇编代码中。
为了方便观察地址,我们将这里的符号名不显示。
然后我们逐步阅读语句:
-
push ebp:将ebp压入栈
现在程序已经进入了main函数里面,那么_mainCRTStartup函数一定被创建好了它的栈帧,如图所示:
此时在运行之前监视观察到ebp与esp的值为下面:
现在我们按F10,进入第一条语句, push ebp之后,那么push ebp的意思就是将ebp压入栈,其次在64位中esp减少2个字节,再次观察ebp的值为:
其中动画演示为:
-
mov ebp,esp:将esp的值赋给ebp
此时我们可以看到ebp从原来的 0x00affeec 变成了与esp相同的 0x00affecc:
-
sub esp,0E4h:将esp减去0E4h的值,赋给esp
此时我们可以看到ebp从原来的 0x00affecc 变成了 0x00affde8 。
上面两个过程的动图如下:
-
push ebx;push esi;push edi:将ebx、esi、edi依次分别压栈
这三句现在应该就比较易懂了。首先都是将ebx、esi、edi依次分别压栈,其中每push一次,esp就减少一次。
其中在运行之前ebx、esi、edi的值分别如下图:
现在我们按F10三次,进入三条语句之后,每进行一个push,则esp就减少一次:
ebp从原来的 0x00affde8 变成了 0x00affddc 。
动图如下:
为了看起来方便,在这里放一张变化之后的固定图:
-
lea edi,[ebp-24h]、mov ecx,9、mov eax,0CCCCCCCCh、rep stos dword ptr es:[edi]:
这四行作为一个有效程序:
lea的意思:load effective address 加载有效地址
为了显示方便,单击右键,选择<显示符号名>,则会变为lea edi,[ebp-0E4h]。
那么[ebp-0E4h]是什么呢?我们再来回顾一下刚刚的图:
根据上图,我们易得[ebp-0E4h]的位置。
其次,dword是double Word,一个Word是2个字节,而dword则是4个字节。
所以,翻译一下上面四句:将从ebp-24h(ebp-0E4h)赋值给edi,从edi开始向下的 9 位的4个字节都修改为是 CCCCCCCC ,如图:
运行结果如下:
2.在main()函数中创建变量
上面所有目前只是在为main函数预备空间栈帧,接下来才是进行有效的代码。
-
mov dword ptr [ebp-8],0Ah:将0Ah(10)存放到ebp-8中
-
mov dword ptr [ebp-14h],14h:将14h(20)存放到ebp-14h中
-
mov dword ptr [ebp-20h],0:将0存放到ebp-20h中
其中这三行的图示如下:
所以a、b、c的局部变量是如何创建的呢?
简单来说就是:先要为我的main函数的调用创建函数栈帧,有了这个函数栈帧之后,在它的函数栈帧里面找到一些空间,把a、b、c放进去。
3.调用Add()函数前的准备
在局部变量创建好之后,我们就开始调用Add函数,函数调用时是需要传参的。那么它是如何来传参的呢?我们具体来看一下:
-
mov eax,dword ptr [ebp-14h]:将ebp-14h的值赋给eax
-
push eax:将eax压入栈中
-
mov ecx,dword ptr [ebp-8]:将ebp-8的值赋给ecx
-
push ecx:将ecx压入栈中
我们先来看一下ebp-14在前面易得其实是b的值为20,而ebp-8其实是a的值是10。所以就是将b的值赋给eax,然后将eax压入栈中;将a的值赋给ecx,然后将ecx压入栈中。
其中动态图如下:
为了看起来方便,在这里放一张变化之后的固定图:
4.为Add()函数开辟栈帧
现在我们真正的来到了Add函数之中:
当然与main函数一样,在调用函数之前需要创建栈帧,然而Add函数也不例外。其实从下面的图中,我们可以发现Add函数栈帧的创建其实与main函数的创建大同小异,基本都是相同的。简单解释一下:
-
push ebp:将ebp压入栈中,伴随esp减少4个字节;
-
mov ebp,esp:将esp的值赋给ebp,伴随esp减少4个字节;
-
sub esp,0CCh:esp减去0CCh,意味着esp再次上移;
-
push ebx:将ebx压入栈中;伴随esp减少4个字节;
-
push esi:将esi压入栈中;伴随esp减少4个字节;
-
push edi:将edi压入栈中;伴随esp减少4个字节;
-
lea edi,[ebp-0Ch] :load effective address加在有效地址;
-
mov ecx,3、mov eax,0CCCCCCCCh:将其中的从edi开始到eax都放入CCCCCCCC
动态图如下:
为了看起来方便,在这里放一张变化之后的固定图:
5.在Add()函数中创建变量并运算
完成了Add函数栈帧的创建,现在我们进入到了Add执行函数的过程:
-
mov dword ptr [ebp-8],0:建立z的临时变量;
-
mov eax,dword ptr [ebp+8] :将ebp+8的值赋值给eax;
-
add eax,dword ptr [ebp+0Ch] :给eax的值加上ebp+0Ch的值;
-
mov dword ptr [ebp-8],eax :将eax的值赋值给ebp-8。
由上图易得,ebp+8是10,ebp+0Ch是20,所以eax是30。然后将eax的值赋给ebp-8(Z),则计算出Z的值是30。
函数是如何传参的呢?
在进入Add函数之前,我们就将b与a的值(x与y的值)压入栈中,在处理好Add函数的栈帧之后,计算时,是找回之前压入栈中的值来进行计算。
所以,形参是实参的临时拷贝。
6.Add()栈帧的销毁
算出了最终的结果,那么我们要如何将结果带回来呢?
我们知道在函数调用之后栈帧就会销毁,那么z的值,我们要如何带回来呢?
-
mov eax,dword ptr [ebp-8]:将ebp-8存储到eax寄存器中;
-
pop edi:给栈顶删除edi,伴随esp增加4字节(esp向下)
-
pop esi:给栈顶删除esi,伴随esp增加4字节(esp向下)
-
pop ebx:给栈顶删除ebx,伴随esp增加4字节(esp向下)
其中动态图为:
-
ret:直接返回call指令的下一条指令
而这里的call的目的就是为了在完成Add函数调用之后,还能接着再回来接着进行。回来之后还可以从call指令的下一条指令继续执行。
为了看起来方便,在这里放一张变化之后的固定图:
7.返回main()函数栈帧
由上图得,现在就是main函数栈帧的销毁:
-
add esp,8:给esp加上8,相当于销毁形参x与y
-
mov dword ptr [ebp-20h],eax:将eax(寄存器的值)赋给ebp-20h
在上图我们可以清晰地发现ebp-20h其实就是c,所以将结果赋值给c。最后就是打印结果。main函数栈帧的销毁与Add函数栈帧销毁大同小异,逻辑都是相似的。
三、总结一下
1.局部变量是怎么创建?
首先为函数分配栈帧空间,栈帧空间初始化之后,然后给局部变量在这个栈帧里分配一点空间。
2.为什么局部变量的值是随机值?
随机值是因为,栈帧里的变量是初始化的,随机值是我们自行放进去的。
3.函数是怎么传参的?传参的顺序是怎么样的?
其实在还没有调用函数时,就已经push了参数,从右向左开始压栈,当真正进入形参函数Add时,通过指针的偏移量找到形参。
4.形参与实参的关系是什么?
形参是在压栈的过程中开辟的空间,与实参的值是相同的,空间是独立的,所以形参是实参的临时拷贝,改变形参不会影响实参。
5.函数调用是怎么做的?
当有多个函数相互调用时,按照后调用先返回的原则,函数之间信息传递和控制转移必须借助栈来实现,即系统将整个程序运行时所需的数据空间安排在一个栈中,每当调用一个函数,将在栈顶分配一个存储区,进行压栈操作,每当一个函数退出时,就释放他的存储区,进行出栈操作,当前运行的函数永远都在栈顶位置。
6.函数调用的结果是如何返回的?
在调用之前,就把call指令下一条指令压进去了,当往回返时,就可以跳转到call指令下一条指令的地址,让函数调用的值可以返回,返回值的带回方式是利用寄存器将它带回。
今天就学到这里,我们下次见啦~~~
千般荒凉,以此为梦;万里蹀躞,以此为归。