本指南介绍了 32 位 x86 汇编语言编程的基础知识,涵盖了可用指令和汇编指令的一小部分但很有用的子集。有几种不同的汇编语言用于生成 x86 机器代码。我们将在 CS216 中使用的是 Microsoft 宏汇编器 (MASM) 汇编器。 MASM 使用标准 Intel 语法编写 x86 汇编代码。
完整的 x86 指令集庞大而复杂(英特尔的 x86 指令集手册包含 2900 多页),我们不会在本指南中涵盖所有内容。例如,x86 指令集有一个 16 位子集。使用 16 位编程模型可能相当复杂。它具有分段内存模型,对寄存器使用的更多限制等等。在本指南中,我们将把注意力限制在 x86 编程的更现代方面,并仅深入研究指令集以获取 x86 编程的基本感觉。
资源
- Guide to Using Assembly in Visual Studio — 在 Visual Studio 中构建和调试汇编代码的教程
- Intel x86 Instruction Set Reference
- Intel’s Pentium Manuals (完整的细节)
寄存器
现代(即 386 及更高版本)x86 处理器有 8 个 32 位通用寄存器,如图 1 所示。寄存器名称大多是历史名称。例如,EAX 曾经被称为累加器,因为它被许多算术运算使用,而 ECX 被称为计数器,因为它用于保存循环索引。尽管大多数寄存器在现代指令集中已经失去了它们的特殊用途,但按照惯例,有两个是为特殊用途而保留的——堆栈指针 (ESP) 和基指针 (EBP)。
对于 EAX、EBX、ECX 和 EDX 寄存器,可以使用子部分。例如,EAX 的最低有效 2 个字节可以被视为一个称为 AX 的 16 位寄存器。 AX 的最低有效字节可用作称为 AL 的单个 8 位寄存器,而 AX 的最高有效字节可用作称为 AH 的单个 8 位寄存器。这些名称指的是同一个物理寄存器。当一个两个字节的数量被放入 DX 时,更新会影响 DH、DL 和 EDX 的值。这些子寄存器主要是旧的 16 位指令集版本的保留。但是,在处理小于 32 位的数据(例如 1 字节 ASCII 字符)时,它们有时会很方便。
在汇编语言中引用寄存器时,名称不区分大小写。例如,名称 EAX 和 eax 指的是同一个寄存器。
内存和寻址模式
声明静态数据区域
为此,您可以使用特殊的汇编器指令在 x86 汇编中声明静态数据区域(类似于全局变量)。数据声明之前应该有 .DATA 指令。在该指令之后,指令 DB、DW 和 DD 可用于分别声明一、二和四字节数据位置。声明的位置可以用名称标记以供以后参考 - 这类似于按名称声明变量,但遵守一些较低级别的规则。例如,按顺序声明的位置将在内存中彼此相邻。
示例声明:
.DATA
var DB 64 ;声明一个字节,称为 location var,包含值 64
var2 DB ? ;声明一个未初始化的字节,称为位置 var2
DB 10 ;声明一个没有标签的字节,包含值 10。它的位置是 var2 + 1
X DW ? ;声明一个 2 字节的未初始化值,称为位置 X
Y DD 30000;声明一个 4 字节的值,称为位置 Y,初始化为 30000。
与高级语言中的数组可以有多个维度并通过索引访问不同,x86 汇编语言中的数组只是多个连续位于内存中的单元。可以通过列出值来声明数组,如下面的第一个示例所示。用于声明数据数组的另外两种常用方法是 DUP 指令和使用字符串文字。 DUP 指令告诉汇编器复制一个表达式给定的次数。例如,4 DUP(2) 等价于 2,2,2,2。
一些例子:
Z DD 1,2,3 ;声明三个 4 字节值,初始化为 1、2 和 3。位置 Z + 8 的值将为 3
bytes DB 10 DUP(?) ;声明从位置字节开始的 10 个未初始化字节
arr DD 100 DUP(0);声明从位置 arr 开始的 100 个 4 字节字,全部初始化为 0
str DB 'hello',0 ;声明从地址 str 开始的 6 个字节,初始化为 hello 的 ASCII 字符值和空 (0) 字节。
寻址存储器
现代 x86 兼容处理器能够寻址多达 232 字节的内存:内存地址为 32 位宽。在上面的示例中,我们使用标签来引用内存区域,这些标签实际上被汇编器替换为指定内存中地址的 32 位数量。除了支持通过标签(即常量值)引用内存区域外,x86 还提供了一种灵活的计算和引用内存地址的方案:最多可以将两个 32 位寄存器和一个 32 位有符号常量相加在一起计算内存地址。可以选择将寄存器之一预乘以 2、4 或 8。
寻址模式可以与许多 x86 指令一起使用(我们将在下一节中描述它们)。在这里,我们说明了一些使用 mov 指令在寄存器和内存之间移动数据的示例。该指令有两个操作数:第一个是目标,第二个指定源。
使用地址计算的 mov 指令的一些示例:
mov eax, [ebx] ;将内存中 EBX 中包含的地址的 4 个字节移动到 EAX 中
mov [var], ebx ;将 EBX 的内容移动到内存地址 var 的 4 个字节中(注意,var 是一个 32 位常量)
mov eax, [esi-4] ;将内存地址 ESI + (-4) 处的 4 个字节移动到 EAX
mov [esi+eax], cl ;将 CL 的内容移动到地址 ESI+EAX 的字节中
mov edx, [esi+4*ebx] ;将地址 ESI+4*EBX 的 4 字节数据移入 EDX
无效地址计算的一些示例包括:
mov eax, [ebx-ecx] ;只能添加寄存器值
mov [eax+esi+edi], ebx ;地址计算中最多 2 个寄存器
尺寸指令
一般来说,给定内存地址的数据项的预期大小可以从引用它的汇编代码指令中推断出来。例如,在所有上述指令中,内存区域的大小可以从寄存器操作数的大小推断出来。当我们加载一个 32 位寄存器时,汇编器可以推断出我们所指的内存区域是 4 字节宽。当我们将单字节寄存器的值存储到内存中时,汇编器可以推断出我们希望地址指向内存中的单字节。
但是,在某些情况下,引用的内存区域的大小是不明确的。考虑指令 mov [ebx], 2。该指令是否应该将值 2 移动到地址 EBX 的单个字节中?也许它应该将 2 的 32 位整数表示移动到从地址 EBX 开始的 4 字节中。由于任何一个都是有效的可能解释,因此必须明确指示汇编器哪个是正确的。大小指令 BYTE PTR、WORD PTR 和 DWORD PTR 用于此目的,分别指示 1、2 和 4 字节的大小。
例如:
mov BYTE PTR [ebx], 2 ;将 2 移动到存储在 EBX 中的地址处的单个字节中
mov WORD PTR [ebx], 2 ;将 2 的 16 位整数表示移动到从 EBX 中的地址开始的 2 个字节中
mov DWORD PTR [ebx], 2;将 2 的 32 位整数表示移动到从 EBX 中的地址开始的 4 个字节中
指令
机器指令通常分为三类:数据移动、算术/逻辑和控制流。在本节中,我们将从每个类别中查看 x86 指令的重要示例。本节不应被视为 x86 指令的详尽列表,而是一个有用的子集。如需完整列表,请参阅英特尔的指令集参考。
我们使用以下符号:
| 符号 | 含义 |
|---|---|
<reg32> | 任意 32 位寄存器 (EAX, EBX, ECX, EDX, ESI, EDI, ESP, 或 EBP) |
<reg16> | 任意 16 位寄存器 (AX, BX, CX, 或 DX) |
<reg8> | 任意 8 位寄存器 (AH, BH, CH, DH, AL, BL, CL, 或 DL) |
<reg> | 任意寄存器 |
<mem> | 一个内存地址 (例如 [eax], [var + 4], 或双字指针 [eax+ebx]) |
<con32> | 任意 32 位常量 |
<con16> | 任意 16 位常量 |
<con8> | 任意 8 位常量 |
<con> | 任意 32 或 16 或 8 位常量 |
数据移动指令
-
mov— 移动(操作码:88、89、8A、8B、8C、8E,…)mov指令将其第二个操作数(即寄存器内容、内存内容或常量值)引用的数据项复制到其第一个操作数(即寄存器或内存)引用的位置。虽然寄存器到寄存器的移动是可能的,但直接的内存到内存的移动是不可能的。在需要内存传输的情况下,必须首先将源内存内容加载到寄存器中,然后才能将其存储到目标内存地址。句法:
mov <reg>,<reg> mov <reg>,<mem> mov <mem>,<reg> mov <reg>,<const> mov <mem>,<const>例子:
mov eax, ebx ;将 ebx 中的值复制到 eax mov byte ptr [var], 5;将值 5 存储到位置 var 的单个字节中 -
push— 推送堆栈(操作码:FF、89、8A、8B、8C、8E、…)push指令将其操作数放在内存中硬件支持的堆栈的顶部。具体来说,push首先将ESP递减 4,然后将其操作数放入地址[ESP]处的 32 位位置的内容中。由于 x86 堆栈向下增长,因此ESP(堆栈指针)通过push递减 - 即堆栈从高地址增长到低地址。句法:
push <reg32> push <mem> push <con32>例子:
push eax ;将 eax 压入堆栈 push [var];将地址 var 处的 4 个字节压入堆栈 -
pop- 弹出堆栈pop指令将 4 字节数据元素从硬件支持的堆栈顶部移除到指定的操作数(即寄存器或内存位置)中。它首先将位于内存位置[SP]的 4 个字节移动到指定的寄存器或内存位置,然后将SP加 4。句法:
pop <reg32> pop <mem>例子:
pop edi ;将堆栈的顶部元素弹出到 EDI pop [ebx];将堆栈的顶部元素弹出到从位置 EBX 开始的四个字节中 -
lea— 加载有效地址lea指令将其第二个操作数指定的地址放入其第一个操作数指定的寄存器中。注意,不加载内存位置的内容,只计算有效地址并将其放入寄存器。这对于获取指向内存区域的指针很有用。句法:
lea <reg32>,<mem>例子:
lea edi, [ebx+4*esi];EBX+4*ESI 的值放入 EDI。 lea eax, [var] ;var 的值放入 EAX lea eax, [val] ;val 的值放入 EAX
算术和逻辑指令
-
add- 整数加法add指令将它的两个操作数相加,将结果存储在它的第一个操作数中。请注意,虽然两个操作数都可以是寄存器,但最多一个操作数可以是内存位置。句法:
add <reg>,<reg> add <reg>,<mem> add <mem>,<reg> add <reg>,<con> add <mem>,<con>例子:
add eax, 10 ;EAX ← EAX + 10 add BYTE PTR [var], 10;将 10 添加到存储在内存地址 var 的单个字节 -
sub- 整数减法子指令将其第一个操作数的值减去其第二个操作数的值的结果存储在其第一个操作数的值中。与添加一样。
句法:
sub <reg>,<reg> sub <reg>,<mem> sub <mem>,<reg> sub <reg>,<con> sub <mem>,<con>例子:
sub al, ah ;AL ← AL - AH sub eax, 216;从存储在 EAX 中的值中减去 216 -
inc,dec- 增量,减量inc指令将其操作数的内容加一。dec指令将其操作数的内容减一。句法:
inc <reg> inc <mem> dec <reg> dec <mem>例子:
dec eax ;从 EAX 的内容中减去 1 inc DWORD PTR [var];将 1 加到存储在位置 var 的 32 位整数 -
imul- 整数乘法imul指令有两种基本格式:二操作数(上面的前两个语法列表)和三操作数(上面的最后两个语法列表)。双操作数形式将其两个操作数相乘并将结果存储在第一个操作数中。结果(即第一个)操作数必须是寄存器。
三操作数形式将其第二个和第三个操作数相乘并将结果存储在其第一个操作数中。同样,结果操作数必须是寄存器。此外,第三个操作数被限制为常数值。
句法:
imul <reg32>,<reg32> imul <reg32>,<mem> imul <reg32>,<reg32>,<con> imul <reg32>,<mem>,<con>例子:
imul eax, [var] ;将 EAX 的内容乘以内存位置 var 的 32 位内容。将结果存储在 EAX 中 imul esi, edi, 25;ESI → EDI * 25 -
idiv- 整数除法idiv指令将 64 位整数EDX:EAX的内容(通过将EDX视为最高有效四个字节,将EAX视为最低有效四个字节来构造)除以指定的操作数值。除法的商结果存入EAX,余数存入EDX。句法:
idiv <reg32> idiv <mem>例子:
idiv ebx ;将 EDX:EAX 的内容除以 EBX 的内容。将商放在 EAX 中,将余数放在 EDX 中 idiv DWORD PTR [var];将 EDX:EAX 的内容除以存储在内存位置 var 的 32 位值。将商放在 EAX 中,将余数放在 EDX 中 -
and,or,xor- 按位逻辑与,或和异或这些指令对其操作数执行指定的逻辑运算(分别为逻辑位与、或和异或),并将结果放在第一个操作数位置。
句法:
and <reg>,<reg> and <reg>,<mem> and <mem>,<reg> and <reg>,<con> and <mem>,<con> or <reg>,<reg> or <reg>,<mem> or <mem>,<reg> or <reg>,<con> or <mem>,<con> xor <reg>,<reg> xor <reg>,<mem> xor <mem>,<reg> xor <reg>,<con> xor <mem>,<con>例子:
and eax, 0fH;清除 EAX 的最后 4 位以外的所有位。 xor edx, edx;将 EDX 的内容设置为零 -
not- 按位逻辑非逻辑否定操作数内容(即翻转操作数中的所有位值)。
句法:
not <reg> not <mem>例子:
not BYTE PTR [var];否定内存位置 var 字节中的所有位。 -
neg- 否定对操作数内容执行二进制补码求反。
句法:
neg <reg> neg <mem>例子:
neg eax;EAX → - EAX -
shl,shr- 左移,右移这些指令将其第一个操作数内容中的位向左和向右移动,用零填充得到的空位位置。移位后的操作数最多可移位 31 位。要移位的位数由第二个操作数指定,它可以是 8 位常量或寄存器
CL。在任何一种情况下,大于 31 的移位计数都以 32 为模执行。句法:
shl <reg>,<con8> shl <mem>,<con8> shl <reg>,<cl> shl <mem>,<cl> shr <reg>,<con8> shr <mem>,<con8> shr <reg>,<cl> shr <mem>,<cl>例子:
shl eax, 1 ;将 EAX 的值乘以 2(如果最高有效位为 0) shr ebx, cl;将 EBX 的值除以 2n 的结果的下限存储在 EBX 中,其中 n 为 CL 中的值
控制流指令
x86 处理器维护一个指令指针 (IP) 寄存器,它是一个 32 位值,指示内存中当前指令开始的位置。通常,它递增以指向内存中的下一条指令在执行一条指令后开始。 IP 寄存器不能直接操作,而是由提供的控制流指令隐式更新。
我们使用符号 <label> 来指代程序文本中的标记位置。通过输入标签名称后跟冒号,可以在 x86 汇编代码文本中的任何位置插入标签。例如,
mov esi, [ebp+8]
begin: xor ecx, ecx
mov eax, [esi]
此代码片段中的第二条指令标记为开始。在代码的其他地方,我们可以使用更方便的符号名称 begin 来引用该指令在内存中所在的内存位置。这个标签只是表达位置的一种方便方式,而不是它的 32 位值。
-
jmp- 跳转将程序控制流转移到操作数指示的内存位置处的指令。
句法:
jmp <label>例子:
jmp begin;跳转到标记为 begin 的指令 -
jcondition- 条件跳转这些指令是基于一组条件代码的状态的条件跳转,这些条件代码存储在称为机器状态字的特殊寄存器中。机器状态字的内容包括关于最后执行的算术运算的信息。例如,该字的一位表示最后一个结果是否为零。另一个指示最后的结果是否为阴性。基于这些条件代码,可以执行许多条件跳转。例如,如果最后一个算术运算的结果为零,
jz指令将跳转到指定的操作数标签。否则,控制按顺序进行到下一条指令。许多条件分支的名称直观地基于最后执行的操作是特殊的比较指令
cmp(见下文)。例如,jle和jne等条件分支基于首先对所需操作数执行cmp操作。句法:
je <label> ;相等时跳转 jne <label>;不等时跳转 jz <label> ;最后的结果为 0 时跳转 jg <label> ;大于时跳转 jge <label>;大于等于时跳转 jl <label> ;小于时跳转 jle <label>;小于等于时跳转例子:
cmp eax, ebx jle done ;如果 EAX 的内容小于或等于 EBX 的内容,则跳转到标签 done。否则,继续下一条指令 -
cmp- 比较比较两个指定操作数的值,适当地设置机器状态字中的条件代码。该指令等效于
sub指令,只是将减法的结果丢弃而不是替换第一个操作数。句法:
cmp <reg>,<reg> cmp <reg>,<mem> cmp <mem>,<reg> cmp <reg>,<con>例子:
cmp DWORD PTR [var], 10 jeq loop ;如果存储在位置 var 的 4 个字节等于 4 字节整数常量 10,则跳转到标记为 loop 的位置 -
call,ret— 子程序调用和返回这些指令实现子程序调用和返回。
call指令首先将当前代码位置压入内存中硬件支持的堆栈(详见push指令),然后无条件跳转到标号操作数所指示的代码位置。与简单的跳转指令不同,调用指令保存子程序完成时返回的位置。ret指令实现子程序返回机制。该指令首先从硬件支持的内存堆栈中弹出一个代码位置(有关详细信息,请参见弹出指令)。然后它执行无条件跳转到检索到的代码位置。句法:
call <label> ret
Comments