要了解系统是如何使用栈来实现函数调用,必须要先对X86的寄存器有一定了解。下图是X86寄存器表:
在函数调用过程中,主要涉及到的是通用寄存器,X86中有8个通用寄存器。 |
8个通用寄存器:EAX、EBX、ECX、EDX、ESI、EDI、ESP、EBP。
EAX:累加器(Accumulator), 它的低16位即是AX,而AX又可分为高8位AH和低8位AL。EAX是很多加法乘法的缺省寄存器, 存放函数
的返回值,用累加器进行的操作可能需要更少时间,在80386及其以上的微处理器中可以用来存放存储单元的偏移地址。AX寄存器是
算术运算的主要寄存器。
EBX:基地址寄存器(Base Register), 它的低16位即是BX,而BX又可分为高8位BH和低8位BL。主要用于在内存寻址时存放基地址。
ECX:计数寄存器(Count Register),它的低16位即是CX,而CX又可分为高8位CH和低8位CL。在循环和字符串操作时,要用它
来控制循环次数;在位操作中,当移多位时,要用CL来指明移位的位数;是重复(REP)前缀指令和LOOP指令的内定计数器。
EDX:数据寄存器(Data Register),它的低16位即是DX,而DX又可分为高8位DH和低8位DL。在进行乘、除运算时,它可作为默认
的操作数参与运算,也可用于存放I/O的端口地址;且总是被用来放整数除法产生的余数。
ESI/EDI:分别叫做源/目标索引寄存器(Source/Destination Index Register),它们的低16位分别是SI、DI。它们主要用于存放
存储单元在段内的偏移量,用它们可实现多种存储器操作数的寻址方式,为以不同的地址形式访问存储单元提供方便。 在很多字符串操
作指令中, DS:ESI指向源串,而ES:EDI指向目标串。此外,它们又作为通用寄存器可以进行任意的常规的操作,如加减移位或普通的内存
间接寻址。
EBP/BSP:分别是基址针寄存器(Base Pointer Register)/堆栈指针寄存器(Stack Pointer Register),低16位是BP、SP,其
内存分别放着一个指针,该指针永远指向系统栈最上面一个栈帧的栈顶/底部。主要用于存放堆栈内存储单元的偏移量,用它们可实现多
种存储器操作数的寻址方式,为以不同的地址形式访问存储单元提供方便。指针寄存器不可分割成8位寄存器。作为通用寄存器,也可存
储算术逻辑运算的操作数和运算结果。并且规定:BP为基指针(Base Pointer)寄存器,用它可直接存取堆栈中的数据;SP为堆栈指针
(Stack Pointer)寄存器,用它只可访问栈顶。在32位平台上,ESP每次减少4字节。EBP最经常被用作高级语言函数调用的"框架指针"
(frame pointer),EBP 构成了函数的一个框架,在C++反汇编中EBP通常是局部变量、传进来的参数。这里要注意在X86系统中栈是
向下生长的(栈越扩大其值越小,堆恰好相反)。在通常情况下ESP是可变的,随着栈的生长而逐渐变小,而ESB寄存器是固定的,只有当函数
的调用后,发生入栈操作而改变,在函数执行结束之后需要还原。
X86中,栈向低地址方向增长,所以压栈是减小栈指针(寄存器esp)的值,并将数据存放到内存中,而出栈是从内存中读数据,并增加栈指针的值。
Current Frame是被调函数栈帧。
下面用一个例子来讲一下调用过程。(环境VS2019 Debug x86、反汇编)
1)在执行这个函数之前,需要先把函数的参数,从右到左装入栈中。
2)准备好参数之后,再 call 被调函数。call指令需要做两件事,
1、先将函数调用语句后的下一条指令的地址装入栈中,作为返回地址。
2、然后修改指针eip让它指向被调函数的开始。(CPU每次执行指令都要先读取EIP寄存器的值,然后定位EIP指向的内存地址(偏移地址),并且读取汇编指令,最后执行。)
所以在调用函数开始的时候,栈应该是这样:
Argument N
...
Argument 2
Argument 1
Return address <--- (%esp)
3)然后进入到被调函数中
第一条指令push ebp是保存当前的基指针寄存器(aka frame pointer)ebp。基指针是用于访问函数参数和局部变量的特殊寄存器。堆栈帧由两个指针分隔:ebp用作指向堆栈帧底部的指针,esp用作指向堆栈帧顶部的指针。如前所述,每个函数调用都有自己的堆栈帧。一旦当前函数(即被调用方)完成,我们需要恢复调用方函数的执行。这意味着在完成被调用函数后,我们需要还原调用方的基指针寄存器ebp。因此,我们需要保存当前的基指针寄存器,它是调用方的,用于将来的调用方堆栈帧还原。
保存完调用方的ebp后,现在可以通过执行mov ebp,esp来设置当前堆栈帧的ebp。使得当前的ebp等于esp指向原来的ebp,现在的栈是这样:
Argument N <--- N*4+4(%ebp)
...
Argument 2 <--- 16 (%ebp)
Argument 1 <--- 12(%ebp)
Return address <--- 4(%ebp)
Old %ebp <--- (%esp) and (%ebp)
然后函数在堆栈中为它需要的局部变量保留空间。只需将堆栈指针esp减少适当的量,就可以在堆栈上为没有指定初始值的数据分配空间。如截图中的 sub esp,0CCh,就是通过减少esp的值来分配空间。
此时我们需要按约定将所有被调存储寄存器推送到堆栈上。按照惯例,寄存器eax、edx、ecx被分类为调用方保存寄存器,而ebx、esi和edi被分类为被调用方保存寄存器。调用方保存寄存器意味着调用方函数负责保存这些寄存器值,因为被调用方可以自由重写这些寄存器值。另一方面,被调用者保存寄存器意味着被调用者函数必须保存这些寄存器值,方法是在覆盖它们之前将它们推送到堆栈上,并在返回之前还原它们,因为调用者可能需要这些值来进行将来的计算。
如反汇编截图所示,接下来保存被调寄存器ebx,第二个参数寄存器esi和第一个参数寄存器edi,然后为局部变量分配空间,此时栈是这样:
Argument N <--- N*4+4(%ebp)
...
Argument 2 <--- 12 (%ebp)
Argument 1 <--- 8(%ebp)
Return address <--- 4(%ebp)
Old ebp <--- (%esp) and (%ebp)
//压入环境变量
ebx <--- -4(%ebp)
esi <--- -8(%ebp)
edi <--- -12(%ebp) and (%esp)
//为局部变量分配空间
Local variable1 <--- -16(%ebp) and (%esp)
4)当被调函数执行完毕,需要做一下几件事。
1.将返回值存储在eax中,如反汇编截图中mov eax,dword ptr [c]。
2.它会弹出先前保存的寄存器,如图中pop edi, pop esi, pop ebx。
3.它通过向堆栈指针添加相同的量,如图中add esp,OCCh 来释放它分配的堆栈空间
4.它将堆栈重置为调用它时的状态(它去掉当前堆栈帧并使调用方的堆栈帧重新生效),使esp指向当前的ebp。
5.然后弹出ebp,使得当前的ebp指向最开始的位置(调用前的位置)。
6.最后使用ret指令,让EIP装入最开始保存的返回地址。然后CPU检查EIP,继续执行后续的指令,调用结束。