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