8.2 汇编栈帧

一、堆栈参数

子程序可以接收寄存器参数,也可以接收堆栈参数。在32位的环境中,Windows API函数总是接收堆栈参数,但是在64位工作模式下,Windows函数选择了一种堆栈参数和寄存器参数相结合的方式。

堆栈栈帧(stack frame)也称为活动记录(activation record),它是为传递的参数、子例程的返回地址、局部变量和保存的寄存器保留的堆栈空间。堆栈框架是按以下步骤创建的:

  • 如果有传递的参数,则压入堆栈
  • 子例程被调用,子例程的返回地址压入堆栈
  • 子例程在开始执行时,EBP被压入堆栈
  • EBP设为ESP的值,从这时开始,EBP就被作为寻址所有子例程参数的基址指针使用了
  • 如果有局部变量,ESP减去一个数值,以便在堆栈上为局部变量保留空间
  • 如果任何寄存器需要保存,则压入堆栈

二、寄存器参数的缺点

很多年以来,微软都在使用一种称为fastcall的参数传递方式。这种方式在调用子例程(subroutine)前直接将参数放入寄存器中(如果将参数放入运行时栈中,运行速度就会慢很多)。传递参数所使用的寄存器通常包括EAX,EBX,ECX和EDX,有时候也会使用EDI和ESI寄存器。但需要注意的是,这些寄存器在许多时候都有自己特殊的用途,比如ECX经常被用来设置循环次数。下面的例子展示了从Irvine32库中调用DumpMem时的情况:

push ebx 					; save register values
push ecx
push esi
mov esi,OFFSET array 		; starting OFFSET
mov ecx,LENGTHOF array 		; size, in units
mov ebx,TYPE array 			; doubleword format
call DumpMem 				; display memory
pop esi 					; restore register values
pop ecx
pop ebx

这些额外的push和pop操作不仅会造成很大程度的代码混乱,而且抵消掉了我们想要提升的性能。

而堆栈参数提供了一种更灵活的使用方式。当一个子程序被调用时,参数会被压入堆栈中。例如,假设DempMem使用堆栈参数,那么可以使用下面的代码进行调用:

push TYPE array
push LENGTHOF array
push OFFSET array
call DumpMem

在进行子例程调用时,在堆栈上压入了两类参数:

  • 值参数(变量和常量的值)
  • 引用参数(变量的地址)

传递值:通过在堆栈上压入变量值的一份副本的方式传递参数,这个过程称为传递值,或简称为传值。假设要调用一个子程序AddTwo,向该过程传递两个32位整数,那么过程如下:

.data
val1 DWORD 5
val2 DWORD 6

.code
push val2
push val1
call AddTwo

下面是call指令执行之前的堆栈示意图:
在这里插入图片描述

C++中等价的函数调用可以是:

int sum=AddTwo(val1,val2);

传递引用:传递引用的参数是一个对象的地址。下面的语句调用了swap,通过引用传递两个参数:

push OFFSET val1
push OFFSET val2
call Swap

下面是调用Swap前堆栈的示意图:
在这里插入图片描述

这是等价的C/C++代码:

Swap(&val1,&val2);

传递数组:在传递数组参数时,高级语言总是传递引用,因为通过传递值的方式传递大量数据是完全不切合实际的,直接在堆栈上压入数据,会降低程序的执行速度并且用光宝贵的堆栈空间。下面的语句向子例程ArrayFIll传递array的偏移地址:

.data
array DWORD 50 DUP

.code
push OFFSET array
call ArrayFill

三、堆栈参数的访问

在调用函数时,C/C++程序使用标准的方法进行初始化和访问参数。C/C++中的函数以序言(prologue)开始,序言部分的代码保存了EBP寄存器,并使EBP指向当时堆栈的顶部,函数还有可能把一些寄存器压栈,这些寄存器的值将在函数返回的时候恢复。函数以收尾(epilogue)代码结束,在这部分代码中,EBP寄存器被恢复,RET指令从函数返回。

还是以AddTwo为例,下面用C编写的AddTwo函数接收两个整数参数(通过传值的方式传递)并返回其和:

int AddTwo(int x,int y){
	return x+y;
}

下面是等价的一个汇编语言实现,首先,AddTwo把EBP压栈以保存其值:

push ebp

接下来,ebp设置为esp的值,此时ebp可作为AddTwo的堆栈框架的基址指针使用:

mov ebp,esp

在这两条指令执行完后,堆栈框架的内容如下图所示,图中所示堆栈的每个数据项都是双字:
在这里插入图片描述

AddTwo还可以把其他的寄存器压栈,但是这并不会改变堆栈中参数的地址相对于EBP的偏移,随后的代码中,ESP有可能会改变其值,但是EBP不会。


访问堆栈参数:C/C++函数使用相对基址寻址方式访问堆栈参数,EBP用作基址寄存器,偏移部分是一个常量。函数一般通过eax返回一个32位的值,下面AddTwo的实现代码把两个堆栈参数相加并在eax中返回它们的和:

push ebp
mov  ebp,esp
mov  eax,[ebp+12]
add  eax,[ebp+8]
pop  ebp

显式堆栈参数

当堆栈参数是用表达式来指令,例如ebp+8这种形式,我们称其显式堆栈参数(explicit stack parameters)。使用这个术语的原因是汇编代码直接指示了偏移地址。一些程序员更喜欢定义一些别名来表示显式堆栈参数,这样可以让程序更易读:

y_param EQU [ebp + 12]
x_param EQU [ebp + 8]
AddTwo PROC
	push ebp
	mov ebp,esp
	mov eax,y_param
	add eax,x_param
	pop ebp
	ret
AddTwo ENDP

堆栈的清理:在子例程返回时,必须要有某种方法清除堆栈上的参数,否则就会导致内存泄露以及堆栈的破坏,假设main中调用AddTwo的语句如下:

push 6
push 5
call AddTwo

如果AddTow离开时在堆栈上留下了这两个变量,那么下面是从调用后返回堆栈的示意图:
在这里插入图片描述

如果我们在一个循环中调用了AddTwo,堆栈就有可能溢出,因为每次调用都会留下8个字节的堆栈空间。如果一个Example1子程序调用了AddTwo,main又调用了Example1,那么此时会导致更严重的问题:

main PROC
	call Example1
	exit
main ENDP
Example1 PROC
	push 6
	push 5
	call AddTwo
	ret
Example1 ENDP

Example1中的RET指令返回时,ESP指向5而非main中的返回地址,此时程序转移到地址5执行,这将使程序崩溃:
在这里插入图片描述

四、32位调用惯例

接下来我们将介绍Windows编程环境下两种最常用的32位程序调用惯例。首先,C调用(C Calling Convention)由C语言建立。STDCALL调用惯例描述了调用WindowsAPI函数的协议。这两种调用惯例都很重要,因此我们很可能会从C和C++程序中调用汇编程序,并且我们的汇编程序也很可能调用很多的WindowsAPI函数。


C调用

C调用被C和C++程序语言使用。在这种惯例下,子程序参数被以反序存储在堆栈上,所以一个进行如下调用的C程序AddTwo(A,B)会首先在堆栈上存储B,然后存储A。

C调用堆栈参数问题的方式是,在CALL指令后使用一条ADD指令给esp加上一个值,以使esp指向正确的返回地址:

Example1 PROC
	push 6
	push 5
	call AddTwo
	add esp,8				;从堆栈中移除变量
	ret
Example1 ENDP

可以看出,以C和C++语言写成的程序总是在调用程序中进行移除堆栈变量的工作,移除工作在子程序返回后完成。


STDCALL调用惯例:处理堆栈变量问题的另一种方法是使用STDCALL调用惯例。在这种惯例下,我们在AddTwo过程中的RET指令后提供一个整数参数以修复esp的值,这个整数值必须等于堆栈参数消耗的堆栈空间字节数:

AddTwo PROC
	push ebp
	mov  ebp,esp
	mov  eax,[ebp+12]
	add  eax,[ebp+8]
	pop  ebp
	ret  8
AddTwo ENDP

到现在为止,我们就有两种堆栈清理的方法,这两种方式都有各自的优缺点:STDCALL减少了为子例程调用生成的代码数量(只有一条指令)并且能够保证调用者永远不会忘记清理堆栈;但另一方面,C调用约定允许子例程声明可变数目的参数,由调用者决定要传递多少参数。例子之一就是printf函数,其参数数目决定于格式化串参数中格式符的数量:

int x=5;
float y=3.2;
char z='Z';
printf("Printing values: %d,%f,%c",x,y,z);

C编译器在堆栈上按(和参数列表)相反的顺序压入参数,并(通过宏)传递实际参数数目,函数本身(通过宏)获取参数的数目并(通过宏)逐个对参数进行访问。在函数中,由于事先不知道调用者要传递多少个参数,所以没有在RET指令后指定参数占用堆栈空间字节数的便捷方法,清理堆栈的责任就只能留给调用者了。


保存和恢复寄存器

子过程通常在修改寄存器之前保存其原来的值,以便在过程返回之前进行恢复。理想情况下,要保存的寄存器应在ebp设为esp的值之后,为局部变量保留空间之前压栈,这有助于避免改变堆栈参数的相对偏移,例如下面的过程MySub有一个堆栈参数,它在把ebp设为esp的值(以作为堆栈框架的基址指针)之后把ecx和edx寄存器压栈,然后把堆栈参数装入eax:

MySub PROC
	push ebp
	mov  ebp,esp
	push ecx
	push edx
	mov  eax,[ebp+8]
	...
	...
	pop  edx
	pop  ecx
	pop  ebp
	ret
MySub ENDP

ebp初始化之后,在整个过程内其值保持不变,ecx和edx的压栈不影响堆栈中的参数相对于ebp的偏移,如下图所示:
在这里插入图片描述

五、局部变量

在高级语言程序中,在单个过程中创建、使用和销毁的变量称为局部变量。局部变量与过程之外声明的全局变量相比有明显的优点:

  • 只有在局部变量所在过程之内的语句才能看到和修改局部变量。这个特点有助于避免程序源码中多处修改一个变量导致的bug
  • 局部变量使用的存储空间在过程结束后即释放
  • 一个过程内的局部变量的名字而可以和其他过程内的局部变量名相同,不会发生名字冲突。
  • 对递归过程以及可能由多个线程同时执行的过程而言,局部变量是必须的。

局部变量是在运行时栈上创建的。在内存中其位置通常在基址指针(ebp)之下,尽管在汇编时不能给定默认值,但可以在运行时初始化。在汇编语言中创建局部变量时,可以使用和C/C++类似的技术。

例子:下面的C++函数声明了局部变量X和Y:

void MySub(){
	int X=10;
	int Y=20;
}

我们以编译后的C++程序为例,看看C++编译器是如何分配局部变量的。每个堆栈项都是32位的双字,因此每个变量的大小是4个字节(按4的倍数向上取整),程序为这两个变量保存了8个字节的空间:

变量占用的字节数堆栈偏移
X4ebp-4
Y4ebp-8

理论上来讲,反汇编代码如下:

MySub PROC
	push ebp
	mov  ebp,esp						;创建局部变量
	sub  esp,8
	mov  DWORD PTR [ebp-4],10
	mov  DWORD PTR [ebp-8],20
	mov  esp,ebp						;和上面相对应,这步进行的操作是从堆栈上删除局部变量
	pop  ebp
	ret
MySub ENDP

利用radare2查看反汇编代码(可以看到还是有一些区别,但是大体上来说是相同的):
在这里插入图片描述
下图显示了局部变量初始化之后堆栈框架的情况:
在这里插入图片描述


局部变量符号

为使代码更加易读,可以给每个变量的引用地址都定义一个符号并在代码中使用这些符号:

X_local EQU DWORD PTR [ebp-4]
Y_local EQU DWORD PTR [ebp-8]
MySub PROC
	push ebp
	mov  ebp,esp
	sub  esp,8
	mov  X_local,10
	mov  Y_local,20
	mov  esp,ebp
	pop  ebp
	ret
MySUb ENDP

六、引用参数

子过程通常使用相对基址寻址方式访问引用参数,这是由于每个引用参数都是一个指针,需要被装入到寄存器中作为间接操作数来使用的。例如,假设一个指向数组的指针位于堆栈位置[ebp+12]处,下面的语句把该指针复制到esi中:

mov esi,[ebp+12]

ArrayFill例子

ArrayFill函数利用一串十六位的伪随机数填充数组。它接收两个变量:一个指向数组,另一个是数组的长度。第一个变量通过引用传递,第二个通过值传递:

.data
count=100
array WORD count DUP(?)

.code
push OFFSET array
push count
call ArrayFill

在ArrayFill内部,下列的序言会初始化堆栈寄存器(EBP):

ArrayFill PROC
	push ebp
	mov  ebp,esp

现在栈帧情况如下所示:
在这里插入图片描述

接下来ArrayFill会保存一些通用寄存器,检索变量,并进行数组的填充:

ArrayFill PROC
	push ebp
	mov  ebp,esp
	pushad
	mov  esi,[ebp+12]
	mov  ecx,[ebp+8]
	cmp  ecx,0
	je   L2

L1:	mov  eax,10000h					;从0-FFFFh的范围内获取随机数			
	call RandomRange
	mov  [esi],ax
	add  esi,TYPE WORD
	loop L1

L2:	popad
	pop ebp
	ret 8
ArrayFill ENDP

七、lea指令

lea指令返回间接操作数的偏移地址。由于间接操作数可能使用一个或多个寄存器,因此其偏移值是在运行时计算的。下面通过一个例子来说明lea指令如何使用,这个程序声明了一个局部的字节数组并在赋值时引用;

void makeArray(){
	char myString[30];
	for(int i=0;i<30;i++)
		myString[i]='*';
}

下面等价的汇编代码在堆栈上为myString分配空间并把myString的地址赋予间接操作数esi,虽然数组只有30个字节,但esp还是减去了32,以使其按双字边界对齐:

makeArray PROC
	push ebp
	mov  ebp,esp
	sub  esp,32
	lea  esi,[ebp-30]		;加载myString的地址
	mov  ecx,30
L1:	mov  BYTE PTR [esi],'*'
	inc  esi
	loop L1
	add  esp,32
	pop  ebp
	ret
makeArray ENDP

在上面的情况下,使用OFFSET操作符获取堆栈参数的地址是不可能的,因为OFFSET只能获取在编译时就已知的地址值。下面的语句汇编时将出错:

mov esi,OFFSET [ebp-30]

八、ENTER和LEAVE指令

ENTER指令自动为被调用过程创建栈帧,它为局部变量保存堆栈空间并在堆栈上保存EBP,该指令执行以下三个动作:

  • 在堆栈上压入EBP
  • 把EBP设为栈帧的基指针mov ebp,esp
  • 为局部变量保留空间sub esp,numbytes

ENTER指令有两个操作数,第一个操作数是个常量,用于指定要为局部变量保留出多少堆栈空间,第二个操作数指定过程的嵌套层次:

enter numbytes,nestinglevel

两个操作数都是立即数。numbytes总是向上取整为4的倍数,以使esp按双字边界对齐;nestinglevel决定了从调用过程复制到当前堆栈框架中的栈帧指针的数目。(在后面的程序中,我们设置nestinglevel总为0,Intel IA-32手册解释了ENTER指令在块结构的语言中是如何支持嵌套层次的)。

例子:下面的例子声明了一个没有任何局部变量的过程:

MySub PROC
	enter 0,0

这与下面的指令是等价的:

MySub PROC
	push ebp
	mov  ebp,esp

例子:enter指令为局部变量保留了8字节的堆栈空间

MySub PROC
	enter 8,0

它与下面的指令是等价的:

MySub PROC
	push ebp
	mov  ebp,esp
	sub  esp,8

下图给出了ENTER指令执行前后的堆栈示意图:
在这里插入图片描述

注意:如果要使用ENTER指令的话,强烈建议在同一过程的结尾处使用LEAVE指令。否则,为局部变量创建的堆栈空间有可能不会被释放,此外还可能导致RET指令从堆栈上弹出错误的返回地址。


LEAVE指令

LEAVE指令释放一个过程的堆栈框架。LEAVE指令执行与前面ENTER指令相反的动作,把EBP和ESP恢复为过程开始时的值。再次以MySub过程为例:

MySub PROC
	enter 8,0
	...
	...
	leave
	ret
MySub ENDP

下面的指令与上面的指令是等价的,它首先为局部变量保留8字节的堆栈空间然后丢弃:

MySub PROC
	push ebp
	mov  ebp,esp
	sub  esp,8
	...
	...
	mov  esp,ebp
	pop  ebp
	ret
mySub ENDP

九、LOCAL伪指令

local伪指令在过程内声明一个或多个局部变量,并同时赋予变量相应的尺寸属性(ENTER仅仅为局部变量保存一块堆栈空间)。local语句必须紧接在PROC伪指令所在行之后,格式如下所示:

LOCAL 变量列表

其中变量列表是一系列的变量定义,中间以逗号分隔,列表可能会占用多行。每个变量定义的格式如下:

标号:类型

标号可以是任何有效的标识符,类型既可以是标准类型(WORD,DWORD等),也可以是用户自定义的类型。

例:

MySub过程包含一个名为var1,类型是BYTE的局部变量:

MySub PROC
	LOCAL var1:BYTE

BubbleSort过程包含一个名为temp的双字局部变量和一个名为SwapFlag的字节类型局部变量:

BubbleSort PROC
	LOCAL temp:DOWRD,SwapFlag:BYTE

Merge过程包含一个名为pArray,类型是PTR DWORD的指向一个16位整数的局部变量:

Merge PROC
	LOCAL pArray:PTR WORD

局部变量TempArray是一个包含10个双字的数组,注意指定数组大小的方括号的用法:

LOCAL TempArray[10]:DWORD

masm生成的代码

通过查看汇编代码,可以看到在使用LOCAL伪指令时masm生成的相应代码是什么,下面的Example1过程只定义了一个双字局部变量:

Example1 PROC
	LOCAL temp:DWORD
	mov eax,temp
	ret
Example1 ENDP

masm为Example1生成的代码如下所示,从中可以看到ESP减掉了4以便给双字局部变量留出空间:

push ebp
mov  ebp,esp
add  esp,0FFFFFFFCh				;esp加-4
mov  eax,[ebp-4]
leave
ret

下面是Example1的堆栈框架示意图:
在这里插入图片描述

十、微软x64调用惯例

/待补充 336

已标记关键词 清除标记
相关推荐
【为什么还需要学习C++?】 你是否接触很多语言,但从来没有了解过编程语言的本质? 你是否想成为一名资深开发人员,想开发别人做不了的高性能程序? 你是否经常想要窥探大型企业级开发工程的思路,但苦于没有基础只能望洋兴叹?   那么C++就是你个人能力提升,职业之路进阶的不二之选。 【课程特色】 1.课程共19大章节,239课时内容,涵盖数据结构、函数、类、指针、标准库全部知识体系。 2.带你从知识与思想的层面从0构建C++知识框架,分析大型项目实践思路,为你打下坚实的基础。 3.李宁老师结合4大国外顶级C++著作的精华为大家推出的《征服C++11》课程。 【学完后我将达到什么水平?】 1.对C++的各个知识能够熟练配置、开发、部署; 2.吊打一切关于C++的笔试面试题; 3.面向物联网的“嵌入式”和面向大型化的“分布式”开发,掌握职业钥匙,把握行业先机。 【面向人群】 1.希望一站式快速入门的C++初学者; 2.希望快速学习 C++、掌握编程要义、修炼内功的开发者; 3.有志于挑战更高级的开发项目,成为资深开发的工程师。 【课程设计】 本课程包含3大模块 基础篇 本篇主要讲解c++的基础概念,包含数据类型、运算符等基本语法,数组、指针、字符串等基本词法,循环、函数、类等基本句法等。 进阶篇 本篇主要讲解编程中常用的一些技能,包含类的高级技术、类的继承、编译链接和命名空间等。 提升篇: 本篇可以帮助学员更加高效的进行c++开发,其中包含类型转换、文件操作、异常处理、代码重用等内容。
©️2020 CSDN 皮肤主题: 游动-白 设计师:白松林 返回首页