x86汇编导论
这篇导论讲述了32位x86汇编语言的基础,涵盖了可用指令和汇编程序指令小而精的一部分。实际上有几种不同的汇编语言可用于生成x86机器代码,但是在CS216中,我们将会使用MASM(Microsoft Macro Assembler)汇编器。MSAM使用了标准的intel语法编写x86汇编代码。
资源
- Guide to Using Assembly in Visual Studio - 在VS中构建和调试程序集代码的教程
- Intel x86 Instruction Set Reference
- Intel’s Pentium Manuals
寄存器
现代x86处理器有8个32位通用寄存器,如Figure 1所示。这些寄存器的命名大多都具有历史原因。例如EAX
寄存器叫做累加器因为它以前被许多算术计算使用,还有ECX
被称为计数器因为它被用来存储循环索引。尽管大多数的寄存器在现在的指令集中已经不在具有特殊的意义,但是仍然有两个寄存器有着专门的用途,栈寄存器(stack pointer)ESP,以及基址寄存器(base pointer)EBP。
对于EAX
,EBX
,ECX
,EDX
四个寄存器,我们通常会将他们的一部分当做一个独立寄存器,例如,EAX的最低2个有效字节可以当做一个16位寄存器AX
,AX
的最低有效字节又可以当做一个8位寄存器叫做AL
,高有效字节作为8位寄存器叫做AH
,这几个不同的名称指代的是用一个物理寄存器,当一个2bytes
大小的指令被放入DX
,整个EDX
,包括DH
,DL
的值都会改变,这些子寄存器主要是为了兼容以前的16位版本的指令集。然而,有时候在处理小于32位(eg: 1个字节的ASCII字符)时,它们更加的方便。
当我们使用汇编语言引用寄存器的时候,名称不区分大小写。例如,名字EAX
和eax
是指代同一个寄存器。
声明静态数据区域
在x86汇编中,你可以通过使用特殊汇编指令专门地声明静态数据区域(类似于全局变量)。数据声明应该紧跟着.DATA
指令,在这个指令之后,指令DB
,DW
以及DD
分别可以被用来声明1个,2个,4个字节的数据内存,被声明的位置可以用名称标记以供之后的引用,这类似于按名称声明变量,但是它遵循着一些比较低级别的规则。例如,按顺序声明的空间将在内存中彼此相邻的位置。
一个数据定义的例子:
1 | .DATA |
不同于高级语言中数组可以有多个维度,可以通过索引值访问,在x86汇编语言中的数组是一些在内存中连续存放的简单单元格。只需要列出数组的值就可以声明数组,如下面第一个例子所示。还有其他两个用于声明数组的方法
是使用DUP
指令,以及使用字符串文字。DUP
指令告诉汇编器将表达式重复指定的次数,例如,4 DUP(2)
等同于2, 2, 2, 2
。
一些例子:
1 | Z DD 1, 2, 3 ; 声明3个4字节变量,初始化位1, 2, 3 z + 8 的值为 3 |
寻址模式
现代的x86寄存器在内存中能够寻址的地址多达$2^{32}$字节:内存地址是32位宽,在上面我们使用标签指代内存区域的例子中,这些标签实际上被汇编器以一个32位的内存地址所代替。除了支持通过标签引用存储区域(i.e. constant values), x86 还提供了一种用于计算和引用内存地址的灵活方案:最多可以将两个32位寄存器和一个32位带符号的常量加在一起计算存储器地址。单个寄存可可以选择预乘2,4或者8。
寻址模式可以与许多其他x86指令一起使用(接下来的部分会讲到).在这里,我们距离说明一些使用mov
指令在寄存器和内存中移动数据的示例。这个指令有两个操作数:第一个是目的,第二个是源。
一些mov
指令寻址的例子如下:
1 | mov eax, [ebx] ; 将ebx中包含的地址处的内存中的4个字节移动到eax中 |
非法的示例如下:
1 | mov eax, [ebx-ecx] ;寄存器的值只能使用加法 |
指令大小
一般情况下,数据项被分配的内存大小可以从引用该数据项的汇编代码指令中推断出来。例如,在以上的所有指令中,我们可以从寄存器操作数的大小推断出存储区域的大小。当我们加载一个32位的寄存器的时候,汇编器可能会推断出我们所指的内存区域为4个字节宽。当我们将1个字节大小的寄存器的值存入内存中的时候,汇编程序可以推断出我们希望该地址引用内存中的1个字节。
但是,在某些情况下,引用的存储区域的大小是不确定的,例如指令mov [ebx], 2
这个指令是否是将值2移入到地址为ebx
的单个字节中?或许他应该是将2的32位整数表示形式移到从地址ebx
开始的4个字节。由于任何一种解释都是有效可行的,汇编器必须明确的知道哪一种是正确的。大小指令BYTE PTR
,WORD PTR
,以及DWORD PTR
正是用来指定值的大小,他们分别指示1, 2, 4个字节的大小。
eg:
1 | mov BYTE PTR [ebx], 2 ; 将2移动到ebx中存储的地址中,以单字节存储 |
指令集
机器指令通常可以分为三类:数据移动,算术/逻辑运算,控制流。在这部分中,我们将会研究每一个类别中重要的x86指令的示例。本节中不会详细的讨论整个x86的详尽指令,只会讨论一个有用的子集。有关的完整列表,请参阅英特尔指令集参考。
我们将会使用以下符号:
1 | <reg32> 任意的32位的寄存器 (EAX, EBX, ECX, EDX, ESI, EDI, ESP, or EBP) |
数据移动指令
mov - Move(opcodes:88, 89,8A,8B,8C,8E,…)
mov
指令将其第二个操作数所引用的数据项(即寄存器内容,寄存器内容或者常数值)复制到第一个操作数所引用的位置(一个寄存器或者内存地址)。虽然可以进行寄存器到寄存器之间的移动,但是不能直接从内存到内存的移动。如果需要在内存中传输内容,必须先将源存储器的内容加载到寄存器中,然后才能将其存储到目标存储器的地址中。
语法:
1 | mov <reg>,<reg> |
例子:
1 | mov eax, ebx — copy the value in ebx into eax |
push - push stack(Opcodes:FF,89,8A,8B,8C,8E,…)
push
指令将其操作数放置在内存中受硬件支持的栈顶部。具体而言,push
指令首先将ESP
递减4,然后将其操作数放在[esp]
所指代的地址中。因为x86的栈是向下增长的-即从高地址到低地址,因此ESP`(栈指针)递减。
语法:
1 | push <reg32> |
例子:
1 | push eax — push eax on the stack |
pop - pop stack
pop
指令将4字节的数据元素从栈的顶部移除并移动到指定的位置中(寄存器或者内存),它首先将位于存储器位置[sp]
的4个字节移动到指定的就成年期或者存储器位置,然后sp
递增4。
语法:
1 | pop <reg32> |
例子:
1 | pop edi — pop the top element of the stack into EDI. |
lea - load effective address
lea
指令将第二个操作数指定的地址放入第一个操作数指定的寄存器。值得注意的是,我们不会加载内存地址中的内容,仅仅计算有效地址并将它放入寄存器。这对于获得指向存储区域的指针很有用。
语法:
1 | lea <reg32>,<mem> |
例子:
1 | lea edi, [ebx+4*esi] — the quantity EBX+4*ESI is placed in EDI. |
算术和逻辑指令
add - Integer Addition
加法指令将两个操作数相加,并将结果存在第一个操作数里。注意,尽管两个操作数都可以是寄存器,但是最多只允许一个操作数是内存。
语法:
1 | add <reg>,<reg> |
例子:
1 | add eax, 10 — EAX ← EAX + 10 |
sub - Integer Subtraction
sub
指令将第一个操作数减去第二个操作数,并将结果存储在第一个操作数中,与加法类似
语法:
1 | sub <reg>,<reg> |
例子:
1 | sub al, ah — AL ← AL - AH |
inc,dec - Increment, Decrement
inc
指令令其操作数内容 + 1。dec指令将其操作数的内容 - 1。
语法:
1 | inc <reg> |
例子:
1 | dec eax — subtract one from the contents of EAX. |
imul - Integer Multiplication
imul
指令具有两种基本格式: 二操作数(语法中上面的两个)和三操作数(语法中的下面两个)
二操作数形式将两个操作数相乘并将结果存储在第一个操作数中。结果(即第一个)操作数必须是一个寄存器。
三操作数形式将其第二和第三操作数相乘,并将结果存储在第一个操作数中。同样,结果操作数必须是一个寄存器。此外第三个操作数应该是一个常量。
语法:
1 | imul <reg32>,<reg32> |
例子:
1 | imul eax, [var] — multiply the contents of EAX by the 32-bit contents of the memory location var. Store the result in EAX. |
idiv - Integer Division
idiv
指令将64位整数EDX:EAX
的内容除以指定的操作数(通过将EDX视为最高有效4个字节,将EAX视为最低有效4个字节来构造)。除法的商存储在EAX
中,余数存储在EDX
中。
语法:
1 | idiv <reg32> |
例子:
1 | idiv ebx — divide the contents of EDX:EAX by the contents of EBX. Place the quotient in EAX and the remainder in EDX. |
and, or, xor - Bitwise logical and, or and exclusive or
这些指令在其操作数上执行指定的逻辑运算(分别是逻辑按位和,或以及异或),并将结果存放在第一个操作数。
语法:
1 | and <reg>,<reg> |
例子:
1 | and eax, 0fH — clear all but the last 4 bits of EAX. |
not - Bitwise Logical Not
逻辑取反操作数的内容(即翻转操作数中的所有位值)
语法:
1 | not <reg> |
例子:
1 | not BYTE PTR [var] — negate all bits in the byte at the memory location var. |
neg - Negate
执行操作数内容的二进制补码求反
语法:
1 | neg <reg> |
例子:
1 | neg eax — EAX → - EAX |
shl,shr - Shift Left, Shift Right
这些指令左右移动第一个操作数的内容中的比特值,并用零填充结果的空位位置。移位之后的操作数最多可以移位31位。要移动的位数由第二个操作数指定,可以是8位常数或者是寄存器CL
。在任何情况下,如果数字大于31的话,将其模32再运算。
语法:
1 | shl <reg>,<con8> |
例子:
1 | shl eax, 1 — Multiply the value of EAX by 2 (if the most significant bit is 0) |
控制流指令
x86处理器维护一个指令指针寄存器(IP,instruction pointer)该寄存器是一个32位的值,指示当前指令在内存中的位置。通常来说,它递增以指向内存中当前执行指令之后的下一条指令。IP寄存器被不能直接操作,而应该通过提供的控制流指令隐式操作。
我们通过使用符号<label>
来引用程序文本中带有标签的位置。通过输入的标签名称和冒号,可以将标签插入x86汇编代码文本中的任何位置。例如:
1 | mov esi, [ebp+8] |
该代码段中第二条指令标记为begin
,在代码的其他地方,我们可以使用更方便的名称begin
来引用此指令在内存中的存储位置。标签仅仅是一个方便的方式用来替代一个32位的地址值。
jmp - jump
将程序控制流转移到操作数指示的存储位置处的指令。
语法:
1 | jmp <label> |
例子:
1 | jmp begin — Jump to the instruction labeled begin. |
jcondition - Conditional Jump
这些指令是基于一组条件代码的状态的条件跳转,这些条件代码被存储在一个被称为机器状态字的特殊寄存器中。机器状态字的内容是有关最后执行的算术运算的信息。例如,该字的最后一位指示结果是否为0,另一个指示最后结果是否为负数。基于这些条件代码,可以执行许多条件跳转。例如,如果最后一次算术运算结果为0,那么jz
指令将会跳转到指定的操作数标签。偶尔控制顺序进行到下一条指令。
许多条件分支的名称都根据上次执行的操作直观地给出,这个操作就是比较指令cmp
(参考下文),例如,条件分支(例如jle
和jne
)基于首先对所需操作数执行cmp
操作。
语法:
1 | je <label> (jump when equal) |
例子:
1 | cmp eax, ebx |
如果EAX
的内容小于或者等于EBX
的内容,就跳转到完整标签,否则继续下一条指令。
cmp - Compare
比较两个指定操作数的值,在机器状态字中适当地设置条件代码。该指令除里将计算结果丢弃而未存入第一个操作数之外,等同于sub
指令。
语法:
1 | cmp <reg>,<reg> |
例子:
1 | cmp DWORD PTR [var], 10 |
如果存储在位置var的4个字节等于4个字节的整数常量10,则跳转到标记为loop的位置。
call, ret - Subroutine call and return
这些程序实现了子程序的调用和返回。首先call
指令将当前代码位置压栈,然后无条件跳转到标签操作数指示的代码位置。不同于简单的跳转指令,call
指令回保存子程序完成后的返回地址。
ret
指令实现了子程序返回的机制。该指令首先从栈中弹出代码位置,然后它无条件跳转到检索到的代码的位置。
语法:
1 | call <label> |
调用约定
为了允许不同的程序员共享代码并开发供其他程序使用的库,并简化子程序的使用。程序员通常采取一个通用的调用约定。调用约定是一个有关如何调用子程序并从子程序中返回的协议。例如,给定一组调用约定规则,程序员无需检查子程序的定义去确定如何将参数传递给子程序。此外,给定一组调用约定规则,高级语言也可以使用成遵循这些规则的编译器,从而允许手动编码的汇编语言程序和高级语言程序之间相互调用。
实际上,许多的调用约定都是可能的。我们将会使用广泛使用的C语言调用约定。遵循此约定,将允许我们编写可以从C&C++代码中安全调用汇编子程序的代码,还能使得我们从汇编语言中调用C库语言。
C调用约定在很大程度上基于硬件支持的堆栈的使用。它基于push
,pop
,call
,ret
指令。子程序参数在栈中传递。寄存器保存在栈中,子程序使用的局部变量也放在内存的栈中。在大多数处理器上是想的绝大多数高级过程语言都使用了类似的调用约定。
调用约定分为两组规则。第一组规则由子程序的调用者使用,第二组规则被子程序的编写者(被调用者)使用。应当强调的是,如果在遵循这些规则时出现了错误会迅速导致致命的程序错误,因为栈的状态将会处于一个不一致的状态;因此在自己的子程序中实现调用约定的时候应该格外的小心。
Caller Rules
要进行一个子程序调用,调用者应该知道:
- 在调用子程序之前,调用者应该保存
caller-saved
寄存器的内容。caller-saved
寄存器包括:EAX
,EBX
,ECX
,EDX
。因为允许被调用的子程序修改这些寄存器的内容,如果子程序返回之后调用者依赖于它们的值,调用者必须将这些寄存器中的值入栈(以便于在子程序返回之后将其恢复)。 - 要将参数传递给子程序,请在子程序被调用之前将参数压入栈中。应当按相反的顺序入栈(即最后一个参数最先入栈)。由于栈向下扩张,因此第一个参数将存储在最低位置(参数的翻转曾经被用来允许函数传递可变数量的参数)
- 要调用一个子程序,请使用
call
指令。这个指令将返回地址放在栈的顶部,并跳转到子程序代码。这将调用子程序,子程序应当遵循以下的被调用者规则。
在子程序返回后(紧随着调用指令之后),调用者应当能在EAX中找到该子程序返回地址的值。要恢复机器状态,调用者应该:
- 从栈中释放参数。这会将栈恢复到执行调用之前的状态。
- 将
caller-saved
寄存器的值从栈中弹出恢复。
例子:
下面的代码展示了一个遵循调用者规则的函数调用。调用者正在调用带有三个整数参数的函数_myFunc
。第一个参数是EAX
,第二个参数是常数,第三个参数在内存var
中。
1 | push [var] ; Push last parameter first |
请注意,在调用返回之后,调用者使用了add指令清理堆栈。我们在栈上有12 bytes大小的空间(3个参数,每一个4 bytes),栈是向下扩展,因此要清除参数,我们可以简单地将12加到SP
上
_myFunc
产生的结果现在寄存器EAX
中使用,调用者保存的寄存器(ECX和EDX)的值可能已更改。如果调用者在调用之后会使用它们,则需要在调用之前将它们保存在栈中,并在调用之后将其恢复。
Callee Rules
在子程序开始的时候,子程序的定义应该遵循以下的规定;
将
EBP
入栈,然后使用以下的指令将ESP
的值赋给EBP
:1
2push ebp
mov ebp, esp初始操作将保存
EBP
。根据惯例,基址指针被用来当做在栈上查找参数和局部变量的参考点。当子程序执行时,基址指针保存该子程序开始执行时的栈指针副本值,参数和局部变量将始终位于距基本指针值已知的恒定偏移量处。我们将旧的基址指针在子程序的初始化位置入栈以便于在子程序返回的时候可以为调用者恢复基址指针。谨记调用者不希望子程序改变基址指针的值。之后我们将栈指针移入ESP
,用于获得访问参数和局部变量的参考值。然后,在栈上分配内存用于存放局部变量。再重复一遍,栈向下扩展,因此为了在栈上分配空间,
SP
指针应该减小。SP
该减少多少取决于所需的局部变量的数量和大小。例如,如果有3个整数类型的局部变量(每一个4字节),那么sp
指针需要减12来为这几个变量分配空间(sub esp, 12
).与参数一样,局部变量位于距离BP
指针已知常量的偏移处。接下来,保存接下来将会使用的
callee-saved
寄存器的值。为了保存寄存器的值,将他们入栈。callee-saved
寄存器包括:EBX
,EDI
,ESI
(ESP
和EBP
也将通过调用约定保留,但是在这个步骤中不需要将他们入栈)
在执行了上面个的三个动作之后,子程序的主体程序开始执行,在子程序返回的时候,它们必须遵循以下的约定:
- 将返回值存放在
EAX
中。 - 通过从栈上弹出寄存器来恢复所有已修改的
callee-saved
寄存器(EDI
&ESI
)的值。寄存器的值应该按照入栈的顺序逆序弹出。 - 释放局部变量已分配内存。最明显的方法可能是将
SP
指针加上合适的值(因为我们是通过将sp
指针减去适当的值来分配内存的)。实际上在释放内存时错误率较小的方法是将基址指针中的值移入栈指针move esp, ebp
。这之所以可行是因为基址指针始终保存在分配局部变量之前的栈指针的值。 - 在返回之前,通过从栈中弹出
EBP
来活肤调用者的BP
的值。回想一下,我们在进入子程序的时候所做的第一件事情就是讲基址指针入栈保存它原来的值。 - 最后,通过执行
ret
指令返回到调用者函数。这个指令将从栈中查找然后删除正确的返回地址。
请注意,被调用者规则被分为了两部分,但是实际上他们是彼此的镜像。规则的第一部分适用于函数的开头。通常被定义为方法的 prologue*。第二部分适用于函数的结尾,因此通常被称为函数的 *epilogue
例子:
这里是一个遵循被调用者规则的示例函数:
1 | .486 |
子程序的序言执行标准动作将栈指针的”快照”保存在EBP
中,通过减小栈指针位局部变量分配内存,然后将寄存器的值保存在栈上。
在子程序的主体程序中,我们可以看到ESP
的使用。在子程序执行的过程中,参数和局部变量都和基址指针保持了恒定的偏移量。实际上,我们可以观察到,由于参数实在调用子程序之前就入栈,因此他们在栈中始终位于基址指针的下方(即位于一个较高的地址)。子程序的第一个参数始终可以在内存位置ESP + 8
中找到,第二个参数可以在EBP + 16
中找到,第三个参数可以在EBP + 16
中找到。同样,由于局部变量是在基址指针入栈之后才分配空间,因此他们在栈中始终位于基址指针的上方。实际上,第一个变量始终位于EBP - 4
的位置,第二个局部变量始终位于EBP - 8
的位置,依次类推。基址指针的这种常规使用方法使得我们可以快速识别函数体内部的局部变量和参数的使用。
程序尾声基本而上是函数序言的一个镜像。从栈中恢复调用者寄存器的值,然后通过重置栈指针来释放局部变量,调用者的基址指针恢复,最后ret
指令被用来返回到调用者函数的适当位置处。