汇编基础

汇编基础

高级过程

本章将介绍子程序调用的底层结构,重点集中于运行时的堆栈操作。本讲内容对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
2
3
4
5
6
7
.data
val1 DWORD 5
val2 DWORD 6
.code
push val2
push val1
call AddTwo

这段汇编等效于C++里的int sum = AddTwo(val1, val2)

1
2
3
push OFFSET val2
push OFFSET val1
call Swap

这段等效于Swap(&val1, &val2)

而当传递数组时,通常汇编的写法是push OFFSET array将地址压入堆栈

访问堆栈参数

我们仍以一个简单的C语言程序为例

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

上述的汇编写法如下:

1
2
3
4
5
6
7
8
AddTwo PROC
push ebp ;EBP入栈保存当前值
mov ebp, esp ;堆栈帧基址
mov eax, [ebp+12] ;第二个参数
add eax, [ebp+8] ;第一个参数
pop ebp
ret
AddTwo ENDP

示意图.png

另一种写法是显示的堆栈参数(explicit stack parameter)

1
2
3
4
5
6
7
8
9
10
11
x_param EQU [ebp+12]
y_param EQU [ebp+8]

AddTwo PROC
push ebp
mov ebp, esp
mov eax, x_param
add eax, y_param
pop ebp
ret
AddTwo ENDP

我们使用了符号常量来定义,用EQU伪指令把一个符号和表达式链接起来,可读性更好

清除堆栈

子程序返回时,必须将参数清除,否则会导致内存泄露,堆栈遭到破坏
示意图.png
仍以上图为例,当我们循环调用该addtwo过程时,每次都会在堆栈中遗留两个参数,最终会导致溢出。更可怕的是如下:

1
2
3
4
5
6
7
8
9
10
11
main PROC
call Process1
....
main ENDP

Process1 PROC
push 6
push 5
call AddTwo
ret ;破坏堆栈
Process1 ENDP

当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
2
3
4
5
6
7
8
9
10
11
12
13
14
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的位移量(栈向下方增长)
restore-register.png

局部变量(local variable)

局部变量创建于运行时的堆栈,通常位于基址指针(EBP)之下。尽管不能再汇编时给他们分配初始值,但可以在运行时初始化他们。
仍以C/C++函数的反汇编来看看如何创建局部变量

1
2
3
4
void Mysub(){
int x = 10;
int y = 20;
}
变量 字节数 堆栈偏移量
x 4 EBP-4
y 4 EBP-8
1
2
3
4
5
6
7
8
9
10
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

同样也可以使用EQU来定义局部变量的符号x_local EQU DWORD PTR [ebp-4]

LEA指令

LEA返回间接操作数的地址,以例子来看

1
2
3
4
5
6
void initArray(){
char myString[30];
for(int i = 0; i < 30; i++){
myStirng[i] = '*';
}
}

与之等效的汇编代码在堆栈中为myString分配空间,并将地址(间接操作数)赋给ESI。

1
2
3
4
5
6
7
8
9
10
11
12
13
initArray PROC
push ebp
mov ebp, esp
sub esp, 32
lea esi, [ebp-30]
mov ecx, 30
L1:
mov BYTE PTR [esi], '*'
loop L1
add esp, 32
pop ebp
ret
initArray ENDP

虽然数组只有30个字节,但是ESP还是递减了32以对齐双字边界。
我们不能使用OFFSET来获取堆栈参数的地址,因为OFFSET只适用于编译时已知的地址

递归(recursive subrountine)

递归计算阶乘

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
INCLUDE Irvine32.inc
.code
main PROC
push 3 ;计算3!
call Factorial
call WriteDec
call Crlf
exit
main ENDP
Factorial PROC
;-------------------------
;计算阶乘
;参数:[ebp+8]=需要计算的数
;返回:EAX=结果
;-------------------------
push ebp
mov ebp, esp
mov eax, [ebp+8]
cmp eax, 0 ;n>0?
ja L1 ;否,返回0!=1
mov eax, 1
jmp L2
L1:
dec eax ;n-1
push eax
call Factorial

;每次递归调用返回时都要执行下面的指令
ReturnFact:
mov ebx, [ebp+8] ;获取n
mul ebx ;EDX:EAX = EAX * EBX
L2:
pop ebp ;返回EAX
ret 4
Factorial ENDP
END main

每次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的下一句
递归.jpg

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
2
3
4
push TYPE array
push LENGTHOF array
push OFFSET array
call DumpArray

等价写法,注意列表中的参数逆序排列

1
2
3
4
INVOKE DumpArray,
OFFSET array,
LENGTHOF array,
TYPE array

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
2
3
4
5
6
7
AddTwo PROC,
val1:DWORD,
val2:DWORD
mov eax, val1
add eax, val2
ret
AddTwo ENDP

汇编时MASM生成的代码:

1
2
3
4
5
6
7
8
AddTwo PROC
push ebp
mov ebp, esp
mov eax, dword ptr [ebp+8]
add eax, dword ptr [ebp+0Ch]
leave
ret 8
AddTwo ENDP

LEAVE 指令恢复被调用的ESP和EBP的值,执行mov esp, ebppop 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过去

写在后面

用汇编实现递归是真的困难,稍微疏忽就会出错…
模块的内容放在了实验部分,这一章的题目不多

Author

Ctwo

Posted on

2019-08-05

Updated on

2020-10-25

Licensed under

Comments