序言
本教程描述了32位x86汇编语言编程的基础知识,涵盖了可用指令和汇编器指令的一小部分但很有用的子集。
有几种不同的汇编语言可用于生成x86机器码。在这里我们使用Microsoft Macro Assembler (MASM)
作为示例。MASM使用标准的Intel语法编写x86汇编代码。完整的x86指令集十分庞大复杂(英特尔的x86指令集手册长达2900多页),我们在本教程中不会全部介绍。例如,x86指令集有一个16位的子集。使用16位编程模型可能相当复杂。它采用分段内存模型,对寄存器使用有更多限制,等等。
在本教程中,我们将把注意力集中在x86编程的更多现代方面上,并深入研究指令集,以便对x86编程有一个基本的了解。
寄存器
现代(即386或更高)x86处理器有8个32位通用寄存器,如图1所示。寄存器名称大多是历史名称。例如,EAX过去被称为累加器(accumulator),因为它被许多算术运算使用,而ECX被称为计数器(counter),因为它被用来保存循环索引。虽然大多数寄存器在现代指令集中已经失去了它们的特殊用途,但按照惯例,有两个寄存器被保留用于特殊用途——堆栈指针(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的通用寄存器有eax、ebx、ecx、edx、edi、esi。这些寄存器在大多数指令中是可以任意使用的,但有些指令限制只能用其中某些寄存器做某种用途。比如idiv、系统中断……
x86的特殊寄存器有ebp、esp、eip、eflags。eip是程序计数器(program counter,用于存放下一条指令所在单元的地址)。eflags保存计算过程中产生的标志位,包括进位、溢出、零、负数四个标志位,在x86的文档中这几个标志位分别称为CF、OF、ZF、SF。ebp和esp用于维护函数调用的栈帧(详见调用约定
)。
内存空间和寻址模式
声明静态数据区域
为此,您可以使用特殊的汇编指令在x86汇编中声明静态数据区域(类似于全局变量)。数据声明前面应该有.DATA
指令。在此指令之后,可以使用指令DB、DW和DD分别声明一个、两个和四个字节的数据位置。声明的位置可以用名称标记以供以后引用——这类似于按名称声明变量,但遵循一些较低级别的规则。例如,相邻定义的标签在内存中是连续存放的。
示例声明:
.DATA
var DB 64 ; 声明一个字节,位置为var,值为6
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(?) ; 从位置bytes开始声明10个未初始化的字节
arr DD 100 DUP(0) ; 声明100个从位置arr开始的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处的单字节空间中?也许它应该将32位整数表示的2移到地址EBX开始的4个字节中。由于这两种解释都是有效的可能解释,因此必须明确指示汇编程序哪种解释是正确的。大小指令BYTE PTR
、WORD PTR
和DWORD PTR
用于此目的,分别表示1、2和4字节的大小。
例如:
mov BYTE PTR [ebx], 2 ; 将2移入EBX指向的内存地址的单字节
mov WORD PTR [ebx], 2 ; 将16位整数表示的2移动到从EBX指向的地址开始的2个字节中
mov DWORD PTR [ebx], 2 ; 将32位整数表示的2移动到从EBX指向的地址开始的4个字节中
常用指令
机器指令通常分为三类:数据移动、算术/逻辑和控制流。在本节中,我们将查看每个类别中的重要x86指令示例。本节不会详尽地列出所有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> ; 任何8、16、32位常量
数据移动说明
mov — Move (操作码: 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 — Push stack (操作码: FF, 89, 8A, 8B, 8C, 8E, ...)
PUSH指令将其操作对象放在内存中硬件支持堆栈的顶部。具体地说,PUSH首先将ESP递减4,然后将其操作对象放入内存地址[ESP]
处的32位大小的区域中。ESP(堆栈指针)通过push递减,因为x86堆栈向下增长——即堆栈从高位地址增长到低位地址。
Syntax
push <reg32>
push <mem>
push <con32>
示例
push eax ; 将eax入栈
push [var] ; 将地址var处开始的4个字节入栈
pop — Pop stack
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中
算术和逻辑指令
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 ; 将存储在内存地址var的单字节值加上10
sub — 整数减法
SUB指令在其第一个操作对象的值中存储从其第一个操作对象的值中减去其第二个操作对象的值的结果。与ADD一样。
语法
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指令将其操作对象的内容加1。DEC指令将其操作对象的内容减1。
语法
inc <reg>
inc <mem>
dec <reg>
dec <mem>
示例
dec eax ; 从EAX的内容中减去1
inc DWORD PTR [var] ; 将存储在位置var的32位整数加1
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 指令触发(翻转)操作对象中的所有位。其结果被称为反码。
语法
not <reg>
not <mem>
示例
not BYTE PTR [var] ; 取反内存位置var的字节中的所有位
neg — 求补
NEG是汇编指令中的求补指令,对操作对象执行求补运算:用零减去操作对象,然后结果返回操作对象。求补运算也可以表达成:将操作对象按位取反后加1。
语法
neg <reg>
neg <mem>
示例
neg eax ; EAX → - EAX
shl, shr — 左移,右移
这些指令将其第一个操作对象内容中的位左右移位,用零填充产生的空位位置。移位后的操作对象最多可以移位31位。要移位的位数由第二个操作对象指定,该操作对象可以是8位常量,也可以是寄存器CL。在任一情况下,以32为模执行大于31的移位计数。
语法
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的值除以2^n^的结果的下限存储在EBX中,其中n是CL中的值
控制流指令
x86处理器维护一个指令指针(IP)寄存器,它是一个32位值,指示当前指令在内存中的起始位置。通常,在执行一条指令后,它会递增以指向内存中的下一条指令的起始位置。IP寄存器不能直接操作,而是由提供的控制流指令隐式更新。
我们使用符号<LABEL>
来表示代码中已标记的位置。通过输入标签名称后跟冒号,可以在x86汇编代码中的任意位置插入标签。例如:
mov esi, [ebp+8]
begin: xor ecx, ecx
mov eax, [esi]
此代码段中的第二条指令被标记为BEGIN。在代码的其他地方,我们可以使用更方便的符号名称BEGIN来引用此指令所在的内存中的位置。这个标签只是表示位置的一种方便方式,而不是它的32位值。
JMP — 跳转
将程序控制流转移到操作对象指示的内存位置上
语法
jmp <label>
示例
jmp begin ; 跳到标记为begin的指令位置
jcondition — 条件跳转
这些指令是基于一组条件码状态判断是否进行跳转,该条件码被存储在称为机器状态字的特殊寄存器中。机器状态字的内容包括有关上次执行的算术运算的信息。例如,此字的某一比特位表示最后结果是否为零,某另一个比特位指示上次结果是否为负数。基于这些条件码,可以执行多个条件跳转。例如,如果上次算术运算的结果为零,则JZ指令执行到指定操作对象标签的跳转。否则,控制按顺序前进到下一条指令。
许多条件分支的名字都是根据上一次执行的特殊比较指令cmp命名的(见下文)。例如,条件分支(如JLE和JNE)基于首先对所需操作对象执行CMP操作。
语法
je <label> ; 相等时跳转
jne <label> ; 不相等时跳转
jz <label> ; 最后结果为零时跳转
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指令),然后无条件跳转到标签操作对象指示的代码位置。与简单的跳转指令不同,CALL指令保存当前位置,并在子程序完成时返回到此处。
RET指令实现子程序返回机制。此指令首先从硬件支持的内存堆栈中弹出代码位置(有关详细信息,请参阅POP指令),然后无条件跳转至该代码位置。
语法
call <label>
ret
调用约定
为了允许单独的程序员共享代码,开发供多个程序使用的库,并且为了简化子程序的使用,程序员通常采用共同的调用约定。调用约定是关于如何调用程序和从程序返回的协议。例如,给定一组调用约定规则,程序员不需要检查子程序的定义来确定应该如何将参数传递给该子程序。此外,给定一组调用约定规则,可以使高级语言编译器遵循这些规则,从而允许手工编码的汇编语言程序和高级语言程序相互调用。
在实践中,存在许多调用约定。我们采用广泛使用的C语言调用约定。遵循此约定将允许您编写可从C(和C++)代码安全调用的汇编语言子程序,还将使您能够从汇编语言代码调用C库函数。
C调用约定在很大程度上基于硬件支持的堆栈的使用。它基于PUSH、POP、CALL和RET指令。子程序参数通过堆栈传递。寄存器保存在堆栈上,子程序使用的局部变量放在堆栈的内存中。在大多数处理器上实现的绝大多数高级过程语言都使用了类似的调用约定。
调用约定分为两组规则:第一组规则由子程序的调用者使用,第二组规则由子程序的编写者(被调用者)遵守。应该强调的是,在实现这些约定时产生的马虎会导致致命的程序错误,因为堆栈将处于不一致的状态。因此,在您自己的子程序中实现调用约定时应该非常小心。
可视化调用约定操作的一个好方法是在子程序执行期间绘制堆栈附近区域的内容。上图描述了具有三个参数和三个局部变量的子程序执行期间的堆栈内容。堆栈中描述的单元是32位宽的内存空间,因此单元的内存地址相隔4字节。第一个参数位于距基指针8字节的偏移量处。调用指令在堆栈上的参数上方(基指针下方)放置返回地址,从而导致从基指针到第一个参数的额外4个字节的偏移量。当RET指令用于从子程序返回时,它将跳转到堆栈上存储的返回地址。
调用方规则
要进行子程序调用,调用方应:
-
在调用子程序之前,调用方应保存某些寄存器的内容,这些寄存器被称为
caller-saved
。调用方保存寄存器为EAX、ECX、EDX。由于允许被调用子程序修改这些寄存器,因此如果调用者在子程序返回后依赖于它们的值,则调用者必须将这些寄存器中的值压入堆栈(以便在子程序返回后恢复它们。 -
若要将参数传递给子程序,请在调用之前将它们压入堆栈。参数应按倒序压入(即最后一个参数先入栈)。由于堆栈向下生长,第一个参数将存储在最低地址(这种参数的倒序在历史上用于允许向函数传递可变数量的参数)。
-
要调用子程序,请使用CALL指令。此指令将返回地址放在堆栈上的参数之上,并跳转到子程序代码。这将调用子程序,该子程序应遵循下面的被调用者规则。
子程序返回后(紧跟在CALL指令之后),调用者可以期望在寄存器EAX中找到该子程序的返回值。要恢复机器状态,调用方应:
-
从堆栈中删除参数。这会将堆栈恢复到执行调用之前的状态。
-
通过从堆栈中弹出调用方保存寄存器(EAX、ECX、EDX)的内容来恢复这些寄存器的内容。调用者可以假设该子程序没有修改任何其他寄存器。
示例
下面的代码显示了遵循调用方规则的函数调用。调用方正在调用一个函数_myFunc,该函数接受三个整数参数。第一个参数在EAX中,第二个参数是常量216;第三个参数在内存位置var中。
push [var] ; 先压入最后一个参数
push 216 ; 再将倒数第二个参数入栈
push eax ; 最后将第一个参数入栈
call _myFunc ; 调用函数_myFunc
add esp, 12
请注意,调用返回后,调用方使用Add指令清理堆栈。我们在堆栈上有12个字节(3个参数*每个参数大小4个字节),堆栈向下生长。因此,要去掉这些参数,我们只需在堆栈指针上加12即可。
_myFunc产生的结果现在可以在寄存器EAX中使用。调用方保存寄存器(ECX和EDX)的值可能已经被更改,如果调用方在调用之后想继续使用它们,则需要在调用之前将它们保存在堆栈中,并在调用之后恢复它们。
被调用方规则
子程序在初始化时应遵循以下规则:
- 将EBP的值压入堆栈,然后按照以下说明将ESP的值复制到EBP中:
push ebp
mov ebp, esp
-
此初始操作维护基指针EBP。按照惯例,基指针用作查找堆栈上的参数和局部变量的参考点。当子程序执行时,基指针保存该子程序开始执行时的堆栈指针值的副本。参数和局部变量将始终位于距离基指针值的已知常量偏移量处。我们在子程序的开始处压入旧的基指针值,以便稍后当子程序返回时恢复调用方的基指针值。请记住,调用方并不期望子程序更改基指针值。然后,我们将堆栈指针移动到EBP所指示的内存地址,以获得访问参数和局部变量的参考点。
-
接下来,通过在堆栈上腾出空间来分配局部变量。回想一下,堆栈向下增长,因此为了在堆栈顶部腾出空间,堆栈指针应该递减。堆栈指针递减的数量取决于所需的局部变量的数量和大小。例如,如果需要3个整数局部变量(每个4字节),堆栈指针将需要减12,以便为这些局部变量腾出空间(即,
sub esp,12
)。与参数一样,局部变量将位于距基指针已知偏移量处。 -
接下来,保存函数将使用的被调用者保存寄存器(
callee-saved
)的值。要保存寄存器,请将它们压入堆栈。被调用者保存寄存器是EBX、EDI和ESI(ESP和EBP也将根据调用约定保留,但在此步骤中不需要推入堆栈)。
在执行这三个动作之后,子程序的主体可以继续。当子程序返回时,它必须遵循以下步骤:
-
将返回值保留为EAX。
-
恢复任何已修改的被调用方保存寄存器(EDI和ESI)的旧值,通过从堆栈中弹出寄存器内容来恢复它们,寄存器应该以与它们被推入相反的顺序弹出。
-
取消分配局部变量。最显而易见的方法可能是将堆栈指针加上相应的偏移量(因为空间是通过从堆栈指针中减去所需的量来分配的)。实际上,释放变量的一种不太容易出错的方法是将堆栈指针指向基指针值:
mov esp,ebp
。这是可行的,因为基指针在分配局部变量之前存入了堆栈指针的值。 -
在返回之前,通过从堆栈中弹出EBP来恢复调用方的基指针值。回想一下,我们在执行子程序时所做的第一件事就是压入基指针以保存其旧值。
-
最后,通过执行RET指令返回给调用方。此指令将从堆栈中查找适当的返回地址并删除它。
请注意,被调用者规则可以干净利落地分为两个部分,它们基本上是彼此的对称镜像。规则的前半部分应用于函数的开头,规则的后半部分应用于函数的末尾。
示例
以下是遵循被调用方规则的示例函数定义:
.486
.MODEL FLAT
.CODE
PUBLIC _myFunc
_myFunc PROC
; 子程序开头
push ebp ; 保存旧的基指针值
mov ebp, esp ; 设置新的基指针值
sub esp, 4 ; 为一个4字节的局部变量腾出空间
push edi ; 保存该函数将会修改的寄存器的值
push esi ; 此函数使用EDI和ESI(无需保存EBX、EBP或ESP)
; 子程序主体
mov eax, [ebp+8] ; 将参数1的值移动到EAX中
mov esi, [ebp+12] ; 将参数2的值移动到ESI中
mov edi, [ebp+16] ; 将参数3的值移动到EDI中
mov [ebp-4], edi ; 将EDI移入局部变量
add [ebp-4], esi ; 将ESI加到局部变量上
add eax, [ebp-4] ; 将局部变量的内容加到EAX上,(最终结果)
; 子程序结尾
pop esi ; 恢复寄存器的值
pop edi
mov esp, ebp ; 销毁局部变量
pop ebp ; 恢复调用方的基指针值
ret
_myFunc ENDP
END
子程序开头执行以下标准操作:在EBP中保存堆栈指针的快照(基指针),通过递减堆栈指针来分配局部变量,以及在堆栈上保存寄存器值。
在子程序的主体中,我们可以看到基指针的使用。在子程序执行期间,参数和局部变量都位于基指针的常量偏移量上。特别地,我们注意到,由于参数是在调用子程序之前放到堆栈上的,所以它们总是位于堆栈上的基指针之下(即更高的地址)。子程序的第一个参数始终位于内存位置EBP+8
,第二个参数位于EBP+12
,第三个参数位于EBP+16
。类似地,由于局部变量是在基指针设置之后分配的,因此它们始终位于堆栈的基指针上方(即较低的地址)。特别是,第一个局部变量始终位于EBP-4
,第二个局部变量位于EBP-8
,依此类推。基指针的这种常规用法允许我们快速识别函数体中局部变量和参数的使用。
函数结尾基本上是函数开头的镜像。从堆栈中恢复调用方的寄存器值、通过重置堆栈指针来释放局部变量、恢复调用方的基指针值、并使用RET指令返回到调用方代码的适当位置。
AT&T和Intel格式的区别
上面讲到的汇编格式是Intel的,下面介绍一下AT&T和Intel格式的区别。
一、AT&T和Intel格式区别
- 在 AT&T 汇编格式中,寄存器名要加上 '%' 作为前缀;而在 Intel 汇编格式中,寄存器名不需要加前缀。例如:
AT&T 格式 | Intel 格式 |
---|---|
pushl %eax |
push eax |
- 在 AT&T 汇编格式中,用 '$' 前缀表示一个立即操作数(直接操作数);而在 Intel 汇编格式中,立即数的表示不用带任何前缀。例如:
AT&T 格式 | Intel 格式 |
---|---|
pushl $1 |
push 1 |
- AT&T 和 Intel 格式中的源操作数和目标操作数的位置正好相反。在 Intel 汇编格式中,目标操作数在源操作数的左边;而在 AT&T 汇编格式中,目标操作数在源操作数的右边。例如:
AT&T 格式 | Intel 格式 |
---|---|
addl $1, %eax |
add eax, 1 |
- 在 AT&T 汇编格式中,操作数的字长由操作符的最后一个字母决定,后缀'b'、'w'、'l'分别表示操作数为字节(byte,8 比特)、字(word,16 比特)和长字(long,32比特);而在 Intel 汇编格式中,操作数的字长是用 "
byte ptr
" 和 "word ptr
" 等前缀来表示的。例如:
AT&T 格式 | Intel 格式 |
---|---|
movb val, %al |
mov al, byte ptr val |
-
在 AT&T 汇编格式中,绝对转移和调用指令(jump/call)的操作数(也即转移或调用的目标地址),前要加上'*'作为前缀(有点像C语言里的指针),而在 Intel 格式中则不需要。
-
远程转移指令和远程子调用指令的操作码,在 AT&T 汇编格式中为
ljump
和lcall
,而在 Intel 汇编格式中则为jmp far
和call far
,即:
AT&T 格式 | Intel 格式 |
---|---|
ljump $section, $offset |
jmp far section:offset |
lcall $section, $offset |
call far section:offset |
- 与之相应的远程返回指令则为:
AT&T 格式 | Intel 格式 |
---|---|
lret $stack_adjust |
ret far stack_adjust |
- 对于间接寻址的一般格式,在 Intel 汇编格式中,内存操作数的寻址方式为:
section:[base + index*scale + disp]
。 - 而在 AT&T 汇编格式中,内存操作数的寻址方式是
section:disp(base, index, scale)
。由于 Linux 工作在保护模式下,用的是 32 位线性地址,所以在计算地址时不用考虑段基址和偏移量,而是采用如下的地址计算方法:disp + base + index * scale
。其中disp
和scale
必须是常数,base
和index
必须是寄存器。 - 注意在AT&T格式中隐含了所进行的计算。例如,当section省略,index和scale 也省略,base为ebp,而disp(位移)为4时,表示如下:AT&T格式
-4(%ebp)
,Intel格式[ebp - 4]
。 - 在AT&T格式的括号中如果只有一项base,就可省略逗号,否则不能省略,所以
(%ebp)
相当于(%ebp,,)
,进一步相当于(%ebp, 0, 0)
。有如,当index为eax,scale为4(32位),disp为foo,而其他均省略,则表示为:AT&T格式foo(, %EAX, 4)
,Intel格式[foo+EAX*4]
。这种寻址方式常常用于在数据结构数组中访问特定元素内的一个字段,base为数组的起始地址,scale为每个数组元素的大小,index为下标。如果数组元素是数据结构,则disp为具体字段在结构中的位移。 - 下面是一些内存操作数的例子:
AT&T 格式 | Intel 格式 |
---|---|
movl -4(%ebp), %eax |
mov eax, [ebp - 4] |
movl array(, %eax, 4), %eax |
mov eax, [eax*4 + array] |
movw array(%ebx, %eax, 4), %cx |
mov cx, [ebx + 4*eax + array] |
movb $4, %fs:(%eax) |
mov fs:eax, 4 |
二、Hello World 示例
既然所有程序设计语言的第一个例子都是在屏幕上打印一个字符串 "Hello World!",那我们也以这种方式来开始介绍 Linux 下的汇编语言程序设计。
在 Linux 操作系统中,你有很多办法可以实现在屏幕上显示一个字符串,但最简洁的方式是使用 Linux 内核提供的系统调用。使用这种方法最大的好处是可以直接和操作系统的内核进行通讯,不需要链接诸如 libc 这样的函数库,也不需要使用 ELF 解释器,因而代码尺寸小且执行速度快。
Linux 是一个运行在保护模式下的 32 位操作系统,采用 flat memory
模式,目前最常用到的是 ELF 格式的二进制代码。一个 ELF 格式的可执行程序通常划分为如下几个部分:.text
、.data
和 .bss
,其中 .text
是只读的代码区,.data
是可读可写的数据区,而 .bss
则是可读可写且没有初始化的数据区。代码区和数据区在 ELF 中统称为 section,根据实际需要你可以使用其它标准的 section,也可以添加自定义 section,但一个 ELF 可执行程序至少应该有一个 .text
部分。下面给出我们的第一个汇编程序,用的是 AT&T 汇编语言格式:
例1. AT&T 格式
#hello.s
.data # 数据段声明
msg : .string "Hello, world!\\n" # 要输出的字符串
len = . - msg # 字串长度
.text # 代码段声明
.global _start # 指定入口函数
_start: # 在屏幕上显示一个字符串
movl $len, %edx # 参数三:字符串长度
movl $msg, %ecx # 参数二:要显示的字符串
movl $1, %ebx # 参数一:文件描述符(stdout)
movl $4, %eax # 系统调用号(sys_write)
int $0x80 # 调用内核功能
# 退出程序
movl $0,%ebx # 参数一:退出代码
movl $1,%eax # 系统调用号(sys_exit)
int $0x80 # 调用内核功能
初次接触到 AT&T 格式的汇编代码时,很多程序员都认为太晦涩难懂了,没有关系,在 Linux 平台上你同样可以使用 Intel 格式来编写汇编程序:
例2. Intel 格式
; hello.asm
section .data ; 数据段声明
msg db "Hello, world!", 0xA ; 要输出的字符串
len equ $ - msg ; 字串长度
section .text ; 代码段声明
global _start ; 指定入口函数
_start: ; 在屏幕上显示一个字符串
mov edx, len ; 参数三:字符串长度
mov ecx, msg ; 参数二:要显示的字符串
mov ebx, 1 ; 参数一:文件描述符(stdout)
mov eax, 4 ; 系统调用号(sys_write)
int 0x80 ; 调用内核功能
; 退出程序
mov ebx, 0 ; 参数一:退出代码
mov eax, 1 ; 系统调用号(sys_exit)
int 0x80 ; 调用内核功能
上面两个汇编程序采用的语法虽然完全不同,但功能却都是调用 Linux 内核提供的 sys_write
来显示一个字符串,然后再调用 sys_exit
退出程序。在 Linux 内核源文件 include/asm-i386/unistd.h
中,可以找到所有系统调用的定义。
AT&T的JMP之直接跳转和间接跳转
假如标签叫做mylabel,它的地址是0x8048377
,而且有个全局变量b,b存储的内容就是mylabel的地址,而b的地址是0x80494A8
。有这样的赋值(加载)语句:
movl $mylabel, %eax ; 把mylabel的地址加载到eax寄存器中
movl %eax, b ; 把mylabel的地址加载到b中
movl $b, %ebx ; 把b的地址加载到ebx寄存器中
我们考虑下面的语句:
jmp mylable
jmp 0x8048377
jmp %eax
jmp *%eax
jmp *(%ebx)
jmp *0x80494A8
jmp *b
jmp $0x5
下面来探究一下这7句jmp语句分别都做了什么:
1.不用说,跳转到mylabel标签处继续执行代码,但是,是如何跳转的呢?就是PC加上了mylabel标签处对于jmp处的一个偏移地址!可执行的二进制代码是这样表示的:eb 03
,就是说,pc+0x03
就可以了。
2.这里,0x8048377
是mylabel的地址,我以前研究过,标签的作用,跟它的地址的作用是等效的。所以,这里的执行效果跟1中的相同。但是,还有些不一样!这里的二进制代码成了:e9 03 00 00 00
这里用了32位表示了这个偏移,而在1中,只用了8位!
3.在编译链接的时候,这句代码会有警告:warning:indirect jmp without '*'
。间接跳转没有‘*’符号,但是,执行起来,还是没有错。看一下二进制的可执行文件的代码,发现,给补上了个‘*’号!而且二进制是:ff e0
.
4.其实,4是3的补充版,正常的形式就是4,而三是有警告的被补充的版本。
5.%ebx
是b的地址,那么(%ebx)
表示ebx(ebx的值为地址)指向的地方。这里指向了b的内容,也就是mylabel的地址!于是,化简后,5也就等效与2,但是,二进制表示是:ff 23
。
6.0x80494A8
是b的地址,这里看做内存数,那么实质上,b指向的值是mylabel的地址,于是,化简后同2,二进制代码是:ff 25 a8 94 04 08
。
7.b是标签,代表一个地址,所以,这里同6,二进制代码也同6
8.这句话是错误的,jmp不支持立即数!
所以说,正确的写法有:
jmp mylable ; eb 03
jmp 0x8048377 ; e9 03 00 00 00
jmp *%eax ; ff e0
jmp *(%ebx) ; ff 23
jmp *0x80494A8 ; ff 25 a8 94 04 08
jmp *b ; ff 25 a8 94 04 08
1和2叫做间接寻址,就是算偏移量的。后面没有‘*’号,而是直接一个标签或者地址(标签就可以看做是地址),所以说,就是一个直接的地址的值。间接跳转的二进制代码是eb或者e9,是e开头的。
3,4,5,6叫做直接寻址,直接寻址的标识就是这个‘*’号!直接寻址,就是PC直接赋值某个地址,而不是加偏移量。所以,‘*’号后面的部分,其实是一个要赋给PC的值,那么,取值的方式就好想象了!直接跳转的二进制代码是ff开头的。
3是寄存器直接取值;4是寄存器间接取值;5是内存数取值;6是标签取值(实质上同5)。