本文翻译自:Guide to x86 Assembly
在阅读 Linux 源码之前,我们需要有一些 x86 汇编知识。本指南描述了 32 位 x86 汇编语言编程的基础知识,包括寄存器结构,数据表示,基本的操作指令(包括数据传送指令、逻辑计算指令、算数运算指令),以及函数的调用规则。
一、寄存器(Registers)
如下图,现代 x86 处理器有 8 个 32-bit 的通用寄存器:
由于历史原因,EAX 寄存器过去被用于算术运算,ECX被用于保存循环索引。尽管现在大多数寄存器在现代指令集中已经失去了它们的特殊用途,但有两个寄存器一直保留用于特殊用途——堆栈指针(ESP,用于指示栈顶位置)和基址指针(EBP,用于指示子程序或函数调用的基址指针)。
对于 EAX、EBX、ECX、EDX 四个寄存器来说,其前两个高位字节和后两个低位字节可以独立使用,而后两个低位字节又分为高位(H)和低位(L)部分。这样做的原因主要是为了兼容 16 位程序。
二、内存和寻址模式(Memory and Addressing Modes)
2.1 声明静态数据区(Declaring Static Data Regions)
我们可以使用特殊的汇编指令 .DATA 在 x86 汇编中声明静态数据区域(类似于全局变量),在此指令之后,可以使用指令 DB、DW 和 DD分别声明一个、两个和四个字节的数据大小,声明的数据可以用标签(label)标记,以供以后引用。并且按顺序声明的数据将位于内存中相邻的位置。
下面是一个相关的例子:
.DATA
var DB 64 ; 声明一个字节, 里面的值为 64
var2 DB ? ; 声明一个未初始化的字节
DB 10 ; 声明一个没有 label 的字节, 其值为 10
X DW ? ; 声明一个未初始化的双字节
Y DD 30000 ; 声明一个四字节,值为 30000
x86 汇编语言中的数组只是在内存中连续放置的若干单元格,可以使用 DUP 指令来声明数据数组:
Z DD 1, 2, 3 ; 声明三个四字节的值, 分别初始化为 1, 2, 3; Z+8 位置存放的是 3
bytes DB 10 DUP(?) ; 声明 10 个未初始化的单字节数据
arr DD 100 DUP(0) ; 声明 100 个初始化为 0 的四字节数据
str DB 'hello',0 ; 声明 6 个单字节数据,前五个被初始化为 ASCII 字符, 最后一个初始化为 null(0) 字节
2.2 内存寻址(Addressing Memory)
现代 x86 处理器最多能够寻址 字节大小的内存。在上面的例子中,我们使用标签表示内存区域,这些标签在实际汇编时,均被 32 位的实际地址代替。除了支持这种直接的内存区域描述,X86 还提供了一种灵活的内存寻址方式,即利用最多两个 32 位的寄存器和一个 32 位的有符号常数相加计算一个内存地址,其中一个寄存器可以乘 2, 4 或 8 以表述更大的空间。
下面是正确使用的例子:
mov eax, [ebx] ; 将 ebx 值指示的内存地址中的 4 个字节移动到 eax 中
mov [var], ebx ; 将 ebx 的内容移动到 var 标签所指示的内存地址中
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 ; 最多只能有两个寄存器相加
通常,给定内存地址中数据项的预期大小可以从引用它的汇编代码指令推断出来。当我们加载一个 32 位寄存器时,汇编器可以推断我们引用的内存区域是 4 字节宽。当我们将一个字节寄存器的值存储到内存中时,汇编器可以推断出我们希望地址指向内存中的一个字节。
但对于 mov [ebx], 2 这条指令来说,汇编器无法判断应该将 2 值作为单个字节的数据还是多个字节的数据,这时就需要用到指令 BYTE PTR、WORD PTR 和 DWORD PTR,分别表示 1、2 和 4 字节的大小:
mov BYTE PTR [ebx], 2 ; 将一个字节表示的 2 移动到 ebx 所指向的内存地址处
mov WORD PTR [ebx], 2 ; 将两个字节表示的 2 移动到 ebx 所指向的内存地址处
mov DWORD PTR [ebx], 2 ; 将四个字节表示的 2 移动到 ebx 所指向的内存地址处
三、指令(Instructions)
机器指令通常分为三类:数据移动指令、算术/逻辑指令和控制流指令。在本节中,我们将分别介绍每种类型的重要 x86 指令示例。 这会使用到如下的几种符号表示:
<reg32> | 任意 32 位寄存器 (EAX, EBX, ECX, EDX, ESI, EDI, ESP, or EBP) |
<reg16> | 任意 16 位寄存器 (AX, BX, CX, or DX) |
<reg8> | 任意 8 位寄存器 (AH, BH, CH, DH, AL, BL, CL, or DL) |
<reg> | 任意寄存器 |
<mem> | 内存地址 (e.g., [eax], [var + 4], or dword ptr [eax+ebx]) |
<con32> | 任意 32 位常数 |
<con16> | 任意 16 位常数 |
<con8> | 任意 8 位常数 |
<con> | 任意 32 位、16 位或 8 位常数 |
3.1 数据移动指令(Data Movement Instructions)
mov — Move (Opcodes: 88, 89, 8A, 8B, 8C, 8E, ...)
mov指令将其第二个操作数内容(可以是寄存器、内存或常量)复制到其第一个操作数(寄存器或内存)中。 不能直接用 mov 指令将内存中的值移动到另外一个内存地址。下面是 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 — Push stack (Opcodes: FF, 89, 8A, 8B, 8C, 8E, ...)
push 指令操作数压入内存的栈中。具体来说,push 指令首先将 ESP (栈指针) 减 4(因为 x86 栈向下增长),然后将其操作数放入地址为 [ESP] 位置的内存单元中。用法如下:
push <reg32>
push <mem>
push <con32>
例子:
push eax — push eax on the stack
push [var] — push the 4 bytes at address var onto the stack
pop — Pop stack
pop 指令从栈中取出一个数据放入指定寄存器或内存中。pop 指令首先移动地址为 [ESP] 位置的内存单元中的数据到指定寄存器或内存,然后再将 ESP 加 4。用法如下:
pop <reg32>
pop <mem>
例子:
pop edi — pop the top element of the stack into EDI.
pop [ebx] — pop the top element of the stack into memory at the four bytes starting at location EBX.
lea — Load effective address
lea 指令将第二个操作数指定的内存地址放置到第一个操作数指定的寄存器中。该指令不会加载该内存位置的内容,只计算有效地址并放入寄存器。用法如下:
lea <reg32>,<mem>
例子:
lea edi, [ebx+4*esi] — the quantity EBX+4*ESI is placed in EDI.
lea eax, [var] — the value in var is placed in EAX.
lea eax, [val] — the value val is placed in EAX.
3.2 算术/逻辑指令(Arithmetic and Logic Instructions)
add — Integer Addition
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 — add 10 to the single byte stored at memory address var
sub — Integer Subtraction
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 — subtract 216 from the value stored in EAX
inc, dec — Increment, Decrement
inc 指令将操作数的内容加一,而 dec 指令将操作数的内容减一。用法如下:
inc <reg>
inc <mem>
dec <reg>
dec <mem>
例子:
dec eax — subtract one from the contents of EAX.
inc DWORD PTR [var] — add one to the 32-bit integer stored at location var
imul — Integer Multiplication
imul 指令分为两个操作数和三个操作数两种类型,如果是两个操作数,则让两个操作数相乘,并将结果存入第一个操作数中;如果是三个操作数,则让第二个和第三个操作数相乘,结果存入第一个操作数中,且第三个操作数必须是常数。不管哪种类型,第一个操作数必须是寄存器。用法如下:
imul <reg32>,<reg32>
imul <reg32>,<mem>
imul <reg32>,<reg32>,<con>
imul <reg32>,<mem>,<con>
例子:
imul eax, [var] — multiply the contents of EAX by the 32-bit contents of the memory location var. Store the result in EAX.
imul esi, edi, 25 — ESI → EDI * 25
idiv — Integer Division
idiv 指令将 64 位整数 EDX:EAX (将 EDX 视为高 4 字节,将 EAX 视为低 4 字节) 的内容除以指定的操作数值。除法的商存储在 EAX 中,余数存储在 EDX 中。 用法如下:
idiv ebx — divide the contents of EDX:EAX by the contents of EBX. Place the quotient in EAX and the remainder in EDX.
idiv DWORD PTR [var] — divide the contents of EDX:EAX by the 32-bit value stored at memory location var. Place the quotient in EAX and the remainder in EDX.
and, or, xor — Bitwise logical and, or and exclusive or
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 — clear all but the last 4 bits of EAX.
xor edx, edx — set the contents of EDX to zero.
not — Bitwise Logical Not
not 指令逻辑上对操作数的内容求反 (即翻转操作数中的所有位)。用法如下:
not <reg>
not <mem>
neg — Negate
neg 指令对操作数内容取负。用法如下:
neg <reg>
neg <mem>
Example
neg eax — EAX → - EAX
shl, shr — Shift Left, Shift Right
shl, shr 指令会左右移动第一个操作数的位,用0填充空的位。操作数最多可以被移动 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>
3.3 控制流指令(Control Flow Instructions)
x86 处理器维持着一个指示当前执行指令的指令指针(IP),当一条指令执行后,此指针自动指向下一条指令。IP 寄存器不能直接操作,但是可以用控制流指令更新。
一般用标签(label)指示程序中的地址,在 x86 汇编代码中,可以在任何指令前加入标签。如:
mov esi, [ebp+8]
begin: xor ecx, ecx
mov eax, [esi]
这种标签只是用于取代 32 位地址值的一种便利的表示方式。这样在其他代码中就可以使用该标签,而不是使用其对应具体的 32 位地址。
jmp — Jump
jmp 指令用于跳转到指定的 label 位置处执行。用法如下:
jmp <label>
Example
jmp begin — Jump to the instruction labeled begin.
jcondition — Conditional Jump
jcondition 类的指令用于条件跳转(即满足条件才跳转)。这些条件存储在一个称为机器状态字 (machine status word) 的特殊寄存器中。一般会在执行下列指令之前先执行 cmp 指令对两个操作数进行比较。用法如下:
je <label> (jump when equal)
jne <label> (jump when not equal)
jz <label> (jump when last result was zero)
jg <label> (jump when greater than)
jge <label> (jump when greater than or equal to)
jl <label> (jump when less than)
jle <label> (jump when less than or equal to)
Example
cmp eax, ebx
jle done
; 如果 eax 小于 ebx 的内容,则跳转到标签 done 所指向的位置处指向,若大于则继续往下执行
cmp — Compare
cmp 指令比较两个操作数指定的值,然后设置适当的机器状态字的状态码。这条指令等同于减法指令,但是会舍弃减法的结果。用法如下:
cmp <reg>,<reg>
cmp <reg>,<mem>
cmp <mem>,<reg>
cmp <reg>,<con>
Example
cmp DWORD PTR [var], 10
jeq loop
call, ret — Subroutine call and return
call 和 ret 指令用于实现子程序的调用和返回。
call 指令首先将当前代码执行位置压入栈中,然后无条件跳转到 label 操作数指定的位置。与简单的跳转指令不同,该指令会保存当前代码执行位置,以便子程序执行完后返回。
ret 指令实现子程序的返回。该指令首先从栈弹出 call 指令保存的代码执行位置,然后无条件返回到该位置继续执行。
call <label>
ret
四、调用约定(Calling Convention)
为了加强程序员之间的协作及简化程序开发进程,设定一个函数调用约定非常必要,函数调用约定规定了函数调用及返回的规则,只要遵照这种规则写的程序均可以正确执行,从而程序员不必关心诸如参数如何传递等问题;另一方面,在汇编语言中可以调用符合这种规则的高级语言所写的函数,从而将汇编语言程序与高级语言程序有机结合在一起。有了这个约定,我们就可以在汇编代码中调用 C 函数,或者在 C 代码中调用汇编实现的函数。
调用约定分为两个方面,及调用者约定和被调用者约定,如一个函数 A 调用一个函数 B,则 A 被称为调用者 (Caller),B 被称为被调用者 (Callee)。
下图显示一个调用过程中的内存中的栈布局:
4.1 Caller Rules
调用者规则包括一系列约定:
- 在调用子程序之前,调用者应该保存一系列被设计为调用者应该保存的寄存器的值。调用者应该保存的寄存器有 EAX、ECX、EDX。由于被调用的子程序允许修改这些寄存器,如果调用者在子程序返回后还需要用到这些寄存器的值,则调用者必须将这些寄存器中的值压入堆栈(以便在子例程返回后可以恢复它们)。
- 要将参数传递给子例程,需要在调用之前将它们压入栈。参数应该按倒序推送 (即最后一个参数先 push),因为栈是向下增长的。
- 使用 call 指令调用子程序。该指令将返回地址放在栈的顶部,并转到子程序执行(子程序的执行将按照被调用者的规则执行)。
在子例程返回之后,调用者可以期望在寄存器 EAX 中找到子程序的返回值。为了恢复调用子程序执行之前的状态,调用者应该执行以下操作:
- 清除栈中的参数。
- 将栈中保存的 EAX 值、ECX 值以及 EDX 值出栈,恢复 EAX、ECX、EDX 的值(当然,如果其它寄存器在调用之前需要保存,也需要完成类似入栈和出栈操作)。
例子:
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
上述例子展示了一个调用者执行的操作,在调用 _myFunc 函数之前,先保存函数的三个传入参数 eax、216 和 var,然后使用 call 指令调用函数,返回后再将 esp(栈指针)的值加 12(三个参数,每个四字节),加 12 的目的是为了清空栈中的三个参数。
_myFunc 的返回值保存在 eax 中。若该函数会改变 ecx 和 edx 中的值,调用者还必须在调用之前将其也保存在栈中,并在调用结束之后,出栈恢复ecx和edx的值。
4.2 Callee Rules
- 将 EBP(基址寄存器)入栈(push ebp),并将 ESP 中的值拷贝到 EBP 中(mov ebp esp),目的是保存调用子程序之前的基址指针,基址指针用于寻找栈上的参数和局部变量。当一个子程序开始执行时,基址指针保存栈指针指示子程序的执行。为了在子程序完成之后调用者能正确定位调用者的参数和局部变量,ebp的值需要返回。
- 在栈上为局部变量分配空间。
- 保存 callee-saved 寄存器的值,callee-saved 寄存器包括 ebx,edi 和 esi,将 ebx,edi 和 esi 压栈。
在上述三个步骤完成之后,子程序开始执行,当子程序返回时,必须完成如下工作:
- 将返回的执行结果保存在 eax 中
- 弹出栈中保存的 callee-saved 寄存器值,恢复 callee-saved 寄存器的值(ESI和EDI)
- 收回局部变量的内存空间。实际处理时,通过改变 EBP 的值即可:mov esp, ebp。
- 通过弹出栈中保存的 ebp 值恢复调用者的基址寄存器值。
- 执行 ret 指令返回到调用者程序。
例子:
.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
子程序首先通过入栈的手段保存 ebp,分配局部变量,保存寄存器的值。
在子程序体中,参数和局部变量均是通过 ebp 进行计算。由于参数传递在子程序被调用之前,所以参数总是在 ebp 指示的地址的下方(在栈中),因此,上例中的第一个参数的地址是 ebp+8,第二个参数的地址是 ebp+12,第三个参数的地址是 ebp+16;而局部变量在 ebp 指示的地址的上方,所有第一个局部变量的地址是 ebp-4,而第二个这是 ebp-8.