汇编基础
高级过程
本章将介绍子程序调用的底层结构,重点集中于运行时的堆栈操作。本讲内容对C和C++程序员也是很有价值的,因为在调试程序运行于操作系统或设备驱动程序层的底层子程序时,他们也必须检查运行时的堆栈内容
堆栈帧
前面的章节中,子程序接受寄存器参数。本章展示子程序如何用堆栈接受参数
Q(判断):
1.子程序的堆栈帧总是包含主调程序的返回地址和子程序的局部变量
2.为了避免被复制到堆栈,数组通过引用来传递
3.子程序开始部分的代码总是将EBP入栈
4.堆栈指针加上一个正值即可创建局部变量
5.32位模式下,子程序调用中最后入栈的参数保存于EBP+8的位置
6.引用传递意味着将参数的地址保存在运行时的堆栈中
A:T T T F T T
K:
堆栈参数
堆栈帧的创建步骤如下:
1)被传递的实际参数,若有,则压入堆栈
2)子程序被调用时,子程序返回值(下一行的地址)压入栈
3)子程序开始执行时,EBP被压入栈
4)设置EBP等于ESP,从这时开始,EBP就变成了该子程序所有参数的引用基址
5)若有局部变量,修改ESP以便在堆栈中为这些变量预留空间
6)如果需要保存寄存器,就将它们压入堆栈
当子程序被调用时,有两种常见类型的参数会被压入堆栈:值参数,引用参数
1 | .data |
这段汇编等效于C++里的int sum = AddTwo(val1, val2)
1 | push OFFSET val2 |
这段等效于Swap(&val1, &val2)
而当传递数组时,通常汇编的写法是push OFFSET array
将地址压入堆栈
访问堆栈参数
我们仍以一个简单的C语言程序为例
1 | int AddTwo(int x, int y){ |
上述的汇编写法如下:
1 | AddTwo PROC |
另一种写法是显示的堆栈参数(explicit stack parameter)
1 | x_param EQU [ebp+12] |
我们使用了符号常量来定义,用EQU伪指令把一个符号和表达式链接起来,可读性更好
清除堆栈
子程序返回时,必须将参数清除,否则会导致内存泄露,堆栈遭到破坏
仍以上图为例,当我们循环调用该addtwo过程时,每次都会在堆栈中遗留两个参数,最终会导致溢出。更可怕的是如下:
1 | main PROC |
当Process1的ret指令执行时,ESP本该指向返回地址,却因为多压栈了5,6而指向了5,导致出现无法意料的情况
32位调用规范
C规范
C规范适用于C/C++子程序的参数按逆序入栈,而调用完后手动移动esp将参数从堆栈中移除,在上面的代码中ret前加上add esp, 8
STDCALL规范
STDCALL与C相似,参数逆序入栈,通过在ret处添加一个整数参数指定返回ret 8
,不仅减少了程序调用生成的代码量,保证了程序永远不会忘记清除堆栈
保存和恢复寄存器
通常,子程序在修改寄存器之前要将它们的当前值保存到堆栈。相关寄存器的设置应该在EBP等于ESP之后,在为局部变量保留空间之前
1 | Mysub PROC |
EBP被初始化后,整个过程中值将不会改变!ECX和EDX的入栈不会影响EBP的位移量(栈向下方增长)
局部变量(local variable)
局部变量创建于运行时的堆栈,通常位于基址指针(EBP)之下。尽管不能再汇编时给他们分配初始值,但可以在运行时初始化他们。
仍以C/C++函数的反汇编来看看如何创建局部变量
1 | void Mysub(){ |
变量 | 字节数 | 堆栈偏移量 |
---|---|---|
x | 4 | EBP-4 |
y | 4 | EBP-8 |
1 | Mysub PROC |
同样也可以使用EQU来定义局部变量的符号x_local EQU DWORD PTR [ebp-4]
LEA指令
LEA返回间接操作数的地址,以例子来看
1 | void initArray(){ |
与之等效的汇编代码在堆栈中为myString分配空间,并将地址(间接操作数)赋给ESI。
1 | initArray PROC |
虽然数组只有30个字节,但是ESP还是递减了32以对齐双字边界。
我们不能使用OFFSET来获取堆栈参数的地址,因为OFFSET只适用于编译时已知的地址
递归(recursive subrountine)
递归计算阶乘
1 | INCLUDE Irvine32.inc |
每次call Factorial时都会压栈返回地址和ebp,直到eax为0,此时执行L2,开始返回;
弹出EBP,此时EBP指向上一次EBP的位置,ESP指向了返回地址,ret 4将清除栈中保存的N=0,此时从call后开始执行ReturnFact,[EBP+8]就是N=1,mul ebx
得到了1x1的结果,如此反复直到返回地址为main里call的下一句
INVOKE,ADDR,PROC,PROTO
Q:(判断)
1.CALL指令不能包含过程参数
2.INVOKE伪指令最多包含3个参数
3.INVOKE伪指令只能传递内存操作数,不能传递寄存器值
4.PROC伪指令可以包含USES运算符,但PROTO不可以
A:T F F T
K:
INVOKE
INVOKE只用于32位模式,将参数入栈并调用该过程,是call的一个方便的替代品
1 | push TYPE array |
等价写法,注意列表中的参数逆序排列
1 | INVOKE DumpArray, |
INVOKE使用的参数若小于32位,会扩展参数,常常会覆盖EAX和EDX,当需要时,记得在调用前保存EAX和EDX
ADDR
ADDR可以传递指针参数,且只能和INVOKE一起使用
INVOKE FillArray, ADDR array
但传递给ADDR的参数必须是汇编时的常数,下面为错误写法
INVOKE FillArray, ADDR [ebp+12]
PROC
label PROC [attribute] [USES reglist], parameter_list
实例:AddTwo过程接受两个双字数值,用EAX返回和数
1 | AddTwo PROC, |
汇编时MASM生成的代码:
1 | AddTwo PROC |
LEAVE 指令恢复被调用的ESP和EBP的值,执行mov esp, ebp
和pop ebp
与之相对的ENTER则执行push ebp
, mov ebp, esp
, sub esp, xx
来执行EBP入栈,设置EBP为基址,为局部变量保留空间
ENTER numbtyes, nestinglevel
当没有局部变量时enter 0,0
,当保留8字节堆栈空间enter 8,0
PROTO
PROTO声明过程原型,每个INVOKE调用的过程都需要有原型,PROTO必须在INVOKE之前出现,除了当过程实现在程序的前面出现时的情况,此时PROC就是自己的原型
编写原型的方法:
- 将关键字改为PROTO
- 如有UESE运算符,将其与寄存器列表一起删掉
- 其余形参不变的copy过去
写在后面
用汇编实现递归是真的困难,稍微疏忽就会出错…
模块的内容放在了实验部分,这一章的题目不多