[译] 简明 x86 汇编指南(2017)
译者序
Translated from CS216, University of Virginia.
一份非常好的 x86 汇编教程,国外 CS 课程所用资料,篇幅简短,逻辑清晰,合适作为入 门参考。以原理为主,有两个例子帮助理解。其开始提到使用 MicroSoft MASM 和 Visual Studio,但非必须, 事实上如果你有 Linux 更好。
本文根据原文内容意译,而非逐词逐句翻译,如需了解更多,推荐阅读原文 .
内容:寄存器, 内存和寻址, 指令, 函数调用约定(Calling Convention)
本文介绍 32bit x86 汇编基础,覆盖其中虽小但很有用的一部分。 有多种汇编语言可以生成 x86 机器代码。我们在 CS216 课程中使用的是 MASM( Microsoft Macro Assembler)。MASM 使用标准 Intel 语法。
整套 x86 指令集庞大而复杂(Intel x86 指令集手册超过 2900 页),本文不会全部覆盖。
1. 参考资料
- Guide to Using Assembly in Visual Studio — a tutorial on building and debugging assembly code in Visual Studio
- Intel x86 Instruction Set Reference
- Intel’s Pentium Manuals (the full gory details)
2. 寄存器
Fig 2.1 x86 registers
现代 x86 处理器有 8 个 32 bit 寄存器,如图 1 所示。寄存器名字是早期计算机历史上 流传下来的。例如,EAX 表示 Accumulator,因为它用作算术运算的累加器,ECX 表示 Counter,用来存储循环变量(计数)。大部分寄存器的名字已经失去了原来的意义,但有 两个是例外:栈指针寄存器(Stack Pointer)ESP 和基址寄存器( Base Pointer)EBP。
对于 EAX
, EBX
, ECX
, EDX
四个寄存器,可以再将 32bit 划分成多个子寄存器,
每个子寄存器有专门的名字。例如 EAX
的高 16bit 叫 AX
(去掉 E, E 大概表示
Extended),低 8bit 叫 AL
(Low), 8-16bit 叫 AH
(High)。如图 1
所示。
在汇编语言中,这些寄存器的名字是大小写无关的,既可以用 EAX
,也可以写 eax
。
3. 内存和寻址模式
3.1 声明静态数据区
.DATA
声明静态数据区。
数据类型修饰原语:
DB
: Byte, 1 Byte(DB
的D
可能表示 Data)DW
: Word, 2 BytesDD
: Double Word, 4 Bytes
例子:
.DATA
var DB 64 ; 声明一个 byte 值, referred to as location var, containing the value 64.
var2 DB ? ; 声明一个未初始化 byte 值, referred to as location var2.
DB 10 ; 声明一个没有 label 的 byte 值, containing the value 10. Its location is var2 + 1.
X DW ? ; 声明一个 2-byte 未初始化值, referred to as location X.
Y DD 30000 ; 声明一个 4-byte 值, referred to as location Y, initialized to 30000.
和高级语言不同,在汇编中只有一维数组,只有没有二维和多维数组。一维数组其实就
是内存中的一块连续区域。另外,DUP
和字符串常量也是声明数组的两种方法。
例子:
Z DD 1, 2, 3 ; 声明 3 个 4-byte values, 初始化为 1, 2, and 3. The value of location Z + 8 will be 3.
bytes DB 10 DUP(?) ; 声明 10 个 uninitialized bytes starting at location bytes.
arr DD 100 DUP(0) ; 声明 100 个 4-byte words starting at location arr, all initialized to 0
str DB 'hello',0 ; 声明 6 bytes starting at the address str, 初始化为 hello and the null (0) byte.
3.2 内存寻址 (Addressing Memory)
有多个指令可以用于内存寻址,我们先看使用 MOV
的例子。MOV
将在内存和寄存器之
间移动数据,接受两个参数:第一个参数是目的地,第二个是源。
合法寻址的例子:
mov eax, [ebx] ; Move the 4 bytes in memory at the address contained in EBX into EAX
mov [var], ebx ; Move the contents of EBX into the 4 bytes at memory address var. (Note, var is a 32-bit constant).
mov eax, [esi-4] ; Move 4 bytes at memory address ESI + (-4) into EAX
mov [esi+eax], cl ; Move the contents of CL into the byte at address ESI+EAX
mov edx, [esi+4*ebx] ; Move the 4 bytes of data at address ESI+4*EBX into EDX
非法寻址的例子:
mov eax, [ebx-ecx] ; 只能对寄存器的值相加,不能相减
mov [eax+esi+edi], ebx ; 最多只能有 2 个寄存器参与地址计算
3.3 数据类型(大小)原语(Size Directives)
修饰指针类型:
BYTE PTR
- 1 ByteWORD PTR
- 2 BytesDWORD PTR
- 4 Bytes
mov BYTE PTR [ebx], 2 ; Move 2 into the single byte at the address stored in EBX.
mov WORD PTR [ebx], 2 ; Move the 16-bit integer representation of 2 into the 2 bytes starting at the address in EBX.
mov DWORD PTR [ebx], 2 ; Move the 32-bit integer representation of 2 into the 4 bytes starting at the address in EBX.
4. 指令
三大类:
- 数据移动
mov
push
pop
lea
- Load Effective Address
- 算术/逻辑运算
add
,sub
inc
,dec
imul
,idiv
and
,or
,xor
not
neg
shl
,shr
- 控制流
jmp
je
,jne
,jz
,jg
,jl
…cmp
call
,ret
5. 调用约定
这是最重要的部分。
子过程(函数)调用需要遵守一套共同的调用约定(Calling Convention)。 调用约定是一个协议,规定了如何调用以及如何从过程返回。例如,给定一组 calling convention rules,程序员无需查看子函数的定义就可以确定如何将参数传给它。进一步地 ,给定一组 calling convention rules,高级语言编译器只要遵循这些 rules,就可以使 得汇编函数和高级语言函数互相调用。
Calling conventions 有多种。我们这里介绍使用最广泛的一种:C 语言调用约定(C Language Calling Convention)。遵循这个约定,可以使汇编代码安全地被 C/C++ 调用 ,也可以从汇编代码调用 C 函数库。
C 调用约定:
- 强烈依赖硬件栈的支持 (hardwared-supported stack)
- 基于
push
,pop
,call
,ret
指令 - 子过程参数通过栈传递: 寄存器保存在栈上,子过程用到的局部变量也放在栈上
在大部分处理器上实现的大部分高级过程式语言,都使用与此相似的调用惯例。
调用惯例分为两部分。第一部分用于 调用方(caller),第二部分用于被调 用方(callee)。需要强调的是,错误地使用这些规则将导致栈被破坏,程序 很快出错;因此在你自己的子过程中实现 calling convention 时需要格外仔细。
Fig 5.1 Stack during Subroutine Call
5.1 调用方规则 (Caller Rules)
在一个子过程调用之前,调用方应该:
-
保存应由调用方保存的寄存器(caller-saved registers):
EAX
,ECX
,EDX
这几个寄存器可能会被被调用方(callee)修改,所以先保存它们,以便调用结 束后恢复栈的状态。
-
将需要传给子过程的参数入栈(push onto stack)
参数按逆序 push 入栈(最后一个参数先入栈)。由于栈是向下生长的,第一个参数 会被存储在最低地址(这个特性使得变长参数列表成为可能)。
-
使用
call
指令,调用子过程(函数)call
先将返回地址 push 到栈上,然后开始执行子过程代码。子过程代码需要遵 守的 callee rules。
子过程返回后(call
执行结束之后),被调用方会将返回值放到 EAX
寄存器,调用方
可以从中读取。为恢复机器状态,调用方需要做:
-
从栈上删除传递的参数
栈恢复到准备发起调用之前的状态。
-
恢复由调用方保存的寄存器(
EAX
,ECX
,EDX
)—— 从栈上 pop 出来调用方可以认为,除这三个之外,其他寄存器的值没有被修改过。
例子
push [var] ; Push last parameter first
push 216 ; Push the second parameter
push eax ; Push first parameter last
call _myFunc ; Call the function (assume C naming)
add esp, 12
5.2 被调用方规则 (Callee Rules)
-
将寄存器
EBP
的值入栈,然后 copyESP
toEBP
push ebp mov ebp, esp
-
在栈上为局部变量分配空间
栈自顶向下生长,故随着变量的分配,栈顶指针不断减小。
-
保存应有被调用方保存(
callee-saved
)的寄存器 —— 将他们压入栈。包括EBX
,EDI
,ESI
以上工作完成,就可以执行子过程的代码了。当子过程返回后,必须做以下工作:
-
将返回值保存在
EAX
-
恢复应由被调用方保存的寄存器(
EDI
,ESI
) —— 从栈上 pop 出来 -
释放局部变量
-
恢复调用方 base pointer
EBP
—— 从栈上 pop 出来 -
最后,执行
ret
,返回给调用方 (caller)
例子
.486
.MODEL FLAT
.CODE
PUBLIC _myFunc
_myFunc PROC
; Subroutine Prologue
push ebp ; Save the old base pointer value.
mov ebp, esp ; Set the new base pointer value.
sub esp, 4 ; Make room for one 4-byte local variable.
push edi ; Save the values of registers that the function
push esi ; will modify. This function uses EDI and ESI.
; (no need to save EBX, EBP, or ESP)
; Subroutine Body
mov eax, [ebp+8] ; Move value of parameter 1 into EAX
mov esi, [ebp+12] ; Move value of parameter 2 into ESI
mov edi, [ebp+16] ; Move value of parameter 3 into EDI
mov [ebp-4], edi ; Move EDI into the local variable
add [ebp-4], esi ; Add ESI into the local variable
add eax, [ebp-4] ; Add the contents of the local variable
; into EAX (final result)
; Subroutine Epilogue
pop esi ; Recover register values
pop edi
mov esp, ebp ; Deallocate local variables
pop ebp ; Restore the caller's base pointer value
ret
_myFunc ENDP
END