购买
下载掌阅APP,畅读海量书库
立即打开
畅读海量书库
扫码下载掌阅APP

4.2 ATT汇编语言基础知识

ATT汇编语言中的ATT是根据AT& T命名的,AT& T是运营贝尔实验室多年的公司。ATT汇编是GCC、objdump和其他工具的默认输出格式。其他编程工具(包括Microsoft的工具)以及来自Intel的文档,其汇编代码都是Intel格式。Intel汇编格式和ATT汇编格式在许多方面有所不同,为简单起见,本书中均采用ATT汇编格式。同时,我们假定代码都运行在IA32(Intel Architecture 32-bit,英特尔32位架构)上。

4.2.1 数据格式

正如第2章中描述的那样,计算机系统使用了多种不同形式的抽象,利用更简单的抽象模型来隐藏实现的细节。对于机器级编程来说,其中两种抽象尤为重要。第一种是机器级程序的格式和行为,这种抽象被定义为指令集体系结构(Instruction Set Architecture,ISA),它定义了处理器状态、指令的格式,以及每条指令对状态的影响。大多数ISA(包括IA32)将程序的行为描述成好像每条指令是按顺序执行的,一条指令结束后,下一条再开始。处理器的硬件远比描述的更加精细和复杂,它们并发地执行许多指令,但是可以釆取措施来保证整体行为与ISA指定的执行顺序完全一致。第二种抽象是,机器级程序使用的存储器地址是虚拟地址,提供的存储器模型看上去像一个非常大的字节数组。

汇编语言的基础知识

扫描上方二维码可观看知识点讲解视频

由于是从16位体系结构扩展成32位体系结构,Intel用术语“字”(Word)表示16位数据类型。因此,称32位数为“双字”(Double Words),称64位数为“四字”(Quad Words)。后面遇到的大多数指令都是对字节或双字进行操作。表4-1给出了C语言基本数据类型对应的IA32表示。大多数常用数据类型都是以双字形式存储的。其中,包括普通整数(Int)和长整数(Long Int),无论它们是否有符号。此外,所有的指针(在此用char*表示)都存储为4字节的双字。

表4-1 C语言基本数据类型对应的IA32表示

如表4-1所示,大多数GCC生成的汇编代码指令都有一个字符后缀,表明操作数的大小。例如,数据传送指令有三个变种,即movb(传送字节)、movw(传送字)和movl(传送双字)。后缀“l”用来表示双字,因为将32位数看成“长字”(Long Word),这是由于以16位字为标准是那个时代的习惯。注意,汇编代码也使用后缀“l”来表示4字节整数和8字节双精度浮点数。这不会产生歧义,因为浮点数使用的是一组完全不同的指令和寄存器。

4.2.2 访问信息

一个IA32中央处理器(CPU)包含一组8个存储32位值的寄存器,这些寄存器用来存储整数数据和指针。图4-2显示了这8个寄存器,它们的名字都以%e开头,不过它们都另有特殊的名字。在最初的8086处理器中,寄存器是16位的,每个寄存器都有特殊的用途,其名字用来反映这些不同的用途。在平坦寻址中,对特殊寄存器的需求已经大大减少。在大多数情况,可以把前6个寄存器看成通用寄存器,对它们的使用没有限制。我们说“大多数情况”是因为有些指令以固定的寄存器作为源寄存器和/或目的寄存器。另外,在过程处理中,对前3个寄存器(%eax、%ebx和%ecx)的保存和恢复惯例不同于接下来的三个寄存器(%edx、%esi和%edi)。在后续的章节中,我们将对此进行详细讨论。最后两个寄存器(%esp和%ebp)中保存着指向程序栈中重要位置的指针。只有根据栈管理的标准惯例才能修改这两个寄存器中的值。

图4-2 IA32的整数寄存器

如图4-2所示,字节操作指令可以独立地读或者写前4个寄存器的2个低位字节。这是为了与之前的16位或8位CPU兼容,当一条字节指令更新这些单字节“寄存器元素”中的一个时,该寄存器余下的3个字节不会改变。类似地,字操作指令可以读或者写每个寄存器的低16位。这个特性源自IA32从16位微处理器演化而来的传统,当对大小指示符为short的整数进行运算时,也会用到这些特性。

4.2.3 操作数与指示符

大多数汇编指令都有一个或多个操作数(Operand),指示执行一个操作中要引用的源数据值,以及放置结果的目标位置。IA32支持多种操作数格式。源数据值可以以常数形式给出,或者从寄存器或存储器中读出,结果可以存放在寄存器或存储器中。因此,操作数被分为三种类型。第一种类型是立即数(Immediate),也就是常数值。在ATT格式的汇编代码中,立即数的书写方式是$后面跟一个用标准C语言表示法表示的整数,比如$-577或$0x1F。任何一个32位字中的数值都可以用作立即数,不过汇编器在可能时会使用一个或两个字节的编码。第二种类型是寄存器(register),它表示某个寄存器中的内容,对双字操作来说,可以是8个32位寄存器中的一个(例如%eax),对字操作来说,可以是8个16位寄存器中的一个(例如%ax),对字节操作来说,可以是8个单字节寄存器中的一个(如%al)。在表4-2中,我们用符号E a 来表示任意寄存器a,用引用R[E a ]来表示它的值,即将寄存器集合看成一个数组R,用寄存器标识符作为索引。

第一个汇编程序

扫描上方二维码可观看知识点讲解视频

表4-2 操作数格式

(续)

第三类操作数是存储器(Memory)引用,它会根据计算出来的地址(通常称为有效地址)访问某个存储器的位置。因为将存储器看成一个很大的字节数组,我们用符号M b [Addr]表示对存储在存储器中从地址Addr开始的b个字节值的引用。为了简便,通常省略下方的b。

如表4-2所示,有多种不同的寻址模式,允许不同形式的存储器引用。表中Imm(E b ,E i ,s)是最常用的存储器引用形式,它有四个组成部分,即立即数偏移Imm、基址寄存器E b 、变址寄存器E i 和比例因子s,这里s必须是1、2、4或者8。有效地址被计算为Imm+R[E b ]+R[E i ]·s。引用数组元素时会用到这种通用形式。其他形式都是这种通用形式的特殊情况,只是省略了某些部分。正如我们将看到的,当引用数组和结构元素时,比较复杂的寻址模式是很有用的。

比例变址寻址

扫描上方二维码可观看知识点讲解视频

绝对寻址

扫描上方二维码可观看知识点讲解视频

间接寻址

扫描上方二维码可观看知识点讲解视频

变址寻址

扫描上方二维码可观看知识点讲解视频

4.2.4 数据传送指令

将数据从一个位置复制到另一个位置的指令是最常使用的指令。操作数表示的通用性使得一条简单的数据传送指令能够完成在许多机器中需要好几条指令才能完成的功能。表4-3列出了一些重要的数据传送指令。正如看到的那样,我们把许多不同的指令分成了指令类,一个指令类中的指令执行同样的操作,只不过操作数的大小不同。例如,MOV类由三条指令组成,即movb、movw和movl,这些指令都执行同样的操作,不同的只是它们分别是在大小为1、2和4字节的数据上进行操作。

MOV指令

扫描上方二维码可观看知识点讲解视频

表4-3 数据传送指令

MOV类中的指令将源操作数的值复制到目的操作数中。源操作数指定的值是一个立即数,存储在寄存器或者存储器中。目的操作数指定一个位置,要么是一个寄存器,要么是一个存储器地址。IA32增加了一条限制,即传送指令的两个操作数不能都指向存储器位置。将一个值从一个存储器位置复制到另一个存储器位置需要两条指令:第一条指令将源值加载到寄存器中,第二条将该寄存器值写入目的位置。这些指令的寄存器操作数,对movl来说可以是8个32位寄存器(%eax~%ebp)中的任意一个,对moves来说可以是8个16位寄存器(%ax~%bx)中的任意一个,而对movb来说可以是单字节寄存器元素(%ah~%bh,%al~%bl)中的任意一个。下面的MOV指令示例给出了源类型和目的类型的五种可能的组合。记住,第一个是源操作数,第二个是目的操作数。

传送数据至内存

扫描上方二维码可观看知识点讲解视频

获取变量在内存的地址

扫描上方二维码可观看知识点讲解视频

MOVS和MOVZ指令类都是将一个较小的源数据复制到一个较大的数据位置,高位用符号位扩展(MOVS)或者零扩展(MOVZ)进行填充。用符号位扩展时,目的位置的所有高位用源值的最高位数值进行填充。用零扩展时,目的位置的所有高位都用零填充。正如看到的那样,这两个类中都分别有三条指令,包括所有的源大小为1字节和2字节、目的大小为2字节和4字节的情况(省略了冗余的组合movsww和movzww)。

在实际的应用中,要注意传送指令movb、movsbl和movzbl之间的差别。在下面的例子中,假设%dh寄存器中的初始值是0xCD,%eax寄存器中的值是0x87654321,分别执行下面的三条指令:

三条指令都是将寄存器%eax的低位字节设置成%edx的第二个字节。movb指令不改变其他三个字节;根据源字节的最高位,movsbl指令将其他三个字节设为全1或全0;movzbl指令在任何情况下都是将其他三个字节设为全0。

最后两个数据传送操作可以将数据压入程序栈,以及从程序栈中弹出数据。正如我们将看到的,栈在处理过程调用中起着至关重要的作用。栈是一个数据结构,可以添加或者删除值,不过要遵循“后进先出”的原则。通过push操作把数据压入栈中,通过pop操作删除数据。栈具有一个属性:弹出的值永远是最近被压入且仍然在栈中的值。栈可以实现为一个数组,总是从数组的一端插入和删除元素,这一端称为栈项。在IA32中,程序栈存放在存储器中的某个区域。如图4-3所示,栈向下增长,因此,栈顶元素的地址是所有栈中元素地址中最小的(根据惯例,栈是倒过来画的,栈顶在图的底部)。栈指针%esp保存着栈顶元素的地址。

栈操作指令

扫描上方二维码可观看知识点讲解视频

图4-3 栈操作说明

pushl指令的功能是把数据压入栈,而popl指令的功能是弹出数据。这些指令都只有一个操作数——压入的数据源和弹出的数据目的寄存器。

将一个双字值压入栈中,首先要将栈指针减4,然后将值写到新的栈顶地址。因此,指令pushl %ebp的行为等价于以下两条指令:

它们之间的区别是在目标代码中pushl指令编码为1字节,而上面两条指令共需要6字节。如图4-3中前两栏所示,当%esp为0x108、%eax为0x123时,执行指令pushl%eax的效果是%esp减4得到0x104,然后将0x123存放到存储器地址0x104处。

弹出一个双字的操作包括从栈顶位置读出数据,然后将栈指针加4。因此,指令popl%eax等价于以下这两条指令:

如图4-3的第三栏所示,在执行完pushl后立即执行指令popl %edx的效果是先从存储器中读出值0x123,再写到寄存器%edx中,然后,寄存器%esp的值将增加回0x108。如图4-3所示,值0x123仍然会保留在存储器位置0x104中,直到被覆盖(例如被另一个入栈操作覆盖)。无论如何,%esp指向的地址总是栈顶。任何存储在栈顶之外的数据都被认为是无效的。

因为栈和程序代码以及其他形式的程序数据都放在同样的存储器中,所以程序可以用标准的存储器寻址方法访问栈内的任意位置。例如,假设栈顶元素是双字,指令movl 4(%esp),%edx会将第二个双字从栈中复制到寄存器%edx。

4.2.5 算术与逻辑操作

表4-4列出了一些整数算术和逻辑操作。大多数操作都分成了指令类,这些指令类有各种带不同大小操作数的变种(只有leal没有其他大小的变种)。例如,指令类ADD由三条加法指令组成,即addb、addw和addl,分别是字节加法、字加法和双字加法。事实上,给出的每个指令类都有对字节、字和双字数据进行操作的指令。这些操作被分为四组:加载有效地址、一元操作、二元操作和移位操作。二元操作有两个操作数,而一元操作有一个操作数。这些操作数的描述方法与4.2.4节中一样。

算术逻辑运算指令

扫描上方二维码可观看知识点讲解视频

表4-4 整数算术和逻辑操作

(续)

1.加载有效地址

加载有效地址(Load Effective Address)指令leal实际上是movl指令的变形。它的指令形式是从存储器读数据到寄存器,但实际上它根本就没有引用存储器。它的第一个操作数看上去是一个存储器引用,但该指令并不是从指定的位置读入数据,而是将有效地址写入目的操作数。在表4-4中,我们用C语言的地址操作符& S说明这种计算。这条指令可以为后面的存储器引用产生指针。另外,它还可以简洁地描述普通的算术操作,例如,如果寄存器%edx的值为x,那么指令leal 7(%edx,%edx,4),%eax将设置寄存器%eax的值为5x+7。在汇编代码中经常能发现leal的一些灵活用法,这些用法与有效地址计算无关。目的操作数必须是一个寄存器。

2.一元操作和二元操作

第二组是一元操作,它只有一个操作数,既是源又是目的。这个操作数可以是一个寄存器,也可以是一个存储器位置。比如,指令incl(%esp)会使栈顶的4字节元素加1。这类似于C语言中的加1运算符(++)和减1运算符(--)。

第三组是二元操作,其中,第二个操作数既是源又是目的。这类似于C语言中的赋值运算符,例如x+=y。例如,指令subl %eax,%edx使寄存器%edx的值减去%eax中的值(将指令解读成“从%edx中减去%eax”会有所帮助)。第一个操作数可以是立即数、寄存器或是存储器位置,第二个操作数可以是寄存器或是存储器位置。不过,同movl指令一样,两个操作数不能同时是存储器位置。

3.移位操作

最后一组是移位操作,先给出移位量,然后给出要移位的位数。该操作可以进行算术和逻辑右移。移位量用单个字节编码,因为只允许进行0~31位的移位(只考虑移位量的低5位)。移位量可以是一个立即数,或者放在单字节寄存器元素%c1中。(这些指令很特别,因为只允许以这个特定的寄存器作为操作数。)如表4-4所示,左移指令有两个名字SAL和SHL,两者的效果是一样的,都是将右边填上0。右移指令不同,SAR执行算术移位(填上符号位),而SHR执行逻辑移位(填上0)。移位操作的目的操作数可以是一个寄存器或是一个存储器位置。表4-4中用>> A (算术)和>> L (逻辑)来表示这两种不同的右移运算。

4.特殊的算术操作

表4-5描述的指令支持产生两个32位数字的全64位乘积以及整数除法。

表4-5 特殊的算术操作

表4-5中的操作提供了有符号和无符号数的全64位乘法和除法,对寄存器%edx和%eax组成一个64位的四字。

表4-5中列出的imull指令称为“双操作数”乘法指令,它从两个32位操作数产生一个32位乘积,当乘积位数超过32位时,只截取32位的结果。IA32还提供了两个不同的“单操作数”乘法指令,以计算两个32位值的全64位乘积——一个是无符号数乘法(mul1),另一个是补码乘法(imull)。这两条指令都要求一个参数必须在寄存器%eax中,而另一个参数作为指令的源操作数给出,乘积存放在寄存器%edx(高32位)和%eax(低32位)中。虽然imull可以用于两个不同的乘法操作,但是汇编器能够通过计算操作数的数目分辨出想用哪一条指令。

举个例子,假设有符号数x和y存储在相对于%ebp偏移量为8和12的位置,我们希望将它们的全64位乘积作为8个字节存放在栈顶。假设x在内存%ebp+8处,y在内存%ebp+12处,代码如下:

可以看到,存储两个寄存器的位置对小端法机器来说是对的——寄存器%edx中的高位存放在相对于%eax中的低位偏移量为4的位置。栈是向低地址方向增长的,也就是说低位在栈顶。

前面的表4-4中没有列出除法或模操作。这些操作由类似于单操作数乘法指令的单操作数除法指令提供。有符号除法指令idivl将寄存器%edx(高32位)和%eax(低32位)中的64位数作为被除数,而除数作为指令的操作数给出,指令将商存储在寄存器%eax中,将余数存储在寄存器%edx中。

4.2.6 控制

到目前为止,我们只考虑了直线代码的行为,即指令一条接着一条地顺序执行。C语言中的某些结构,比如条件语句、循环语句和分支语句,要求有条件地执行,根据数据测试的结果来决定操作执行的顺序。机器代码提供两种基本的低级机制来实现有条件的行为:测试数据值,然后根据测试的结果来改变控制流或者数据流。

数据相关的控制流是实现有条件行为的更通用和更常见的方法,所以我们先来介绍它。

跳转与条件传送

扫描上方二维码可观看知识点讲解视频

循环指令

扫描上方二维码可观看知识点讲解视频

1.条件码

除了整数寄存器之外,CPU还维护着一组单个位的条件码(Condition Code)寄存器,它们描述了最近的算术或逻辑操作的属性。可以通过检测这些寄存器来执行条件分支指令。下面是最常用的条件码。

· CF:进位标志。最近的操作使最高位产生了进位。可以用来检查无符号操作数的溢出。

· ZF:零标志。最近的操作得出的结果为0。

· SF:符号标志。最近的操作得到的结果为负数。

· OF:溢出标志。最近的操作导致一个补码溢出(正溢出或负溢出)。

比如,假设我们用一条ADD指令完成等价于C语言表达式t=a+b; 的功能,这里变量a、b和t都是整型。然后,根据下面的C语言表达式来设置条件码:

leal指令不改变任何条件码,因为它是用来进行地址计算的。除此之外,表4-5中列出的所有指令都会设置条件码。对于逻辑操作,例如XOR,进位标志和溢出标志会被设置成0。对于移位操作,进位标志将被设置为最后一个被移出的位,而溢出标志被设置为0。INC和DEC指令会设置溢出和零标志,但是不会改变进位标志。

除了表4-5中的指令会设置条件码,还有两类指令(有8、16和32位形式),它们只设置条件码而不改变任何其他寄存器,如表4-6所示。CMP指令根据两个操作数之差来设置条件码。除了只设置条件码而不更新目标寄存器之外,CMP指令与SUB指令的行为是一样的。在ATT汇编格式中,列出操作数的顺序是相反的,这使代码难以阅读。如果两个操作数相等,这些指令会将零标志设置为1,而其他的标志可以用来确定两个操作数之间的大小关系。TEST指令的行为与AND指令一样,但它们只设置条件码而不改变目的寄存器的值。典型的用法是,两个操作数是一样的(例如,testl %eax,%eax用来检查%eax是负数、零还是正数)或其中的一个操作数是一个掩码,用来指示哪些位应该被测试。

表4-6 比较和测试指令

(续)

2.访问条件码

条件码通常不会直接读取,常见的使用方法有以下三种:可以根据条件码的某个组合,将一个字节设置为0或者1;可以条件跳转到程序的某个其他的部分;可以有条件地传送数据。对于第一种情况,表4-7中描述的指令根据条件码的某个组合将一个字节设置为0或者1。我们将这一整类指令称为SET指令;它们之间的区别就在于考虑的条件码的组合是什么,这些指令名字的不同后缀指明了它们所考虑的条件码的组合。这些指令名字的后缀表示不同的条件而不是操作数大小,例如,指令setl和seeb表示小于时设置(set less)和低于时设置(set below),而不是设置长字(set long word)和设置字节(set byte)。

表4-7 SET指令

某些底层的机器指令可能有多个名字,我们称之为“同义名”(Synonym)。比如,setg(表示“设置大于”)和setnle(表示“设置不小于等于”)是指同一条机器指令。反汇编器和编译器会随意决定使用哪个名字。

注意,机器代码如何区分有符号值和无符号值是很重要的。与C语言不同,机器代码不会将每个程序值都和一个数据类型联系起来。相反,大多数情况下,机器代码对于有符号和无符号两种情况使用同样的指令,这是因为许多算术运算对无符号和补码算术都有同样的位级行为。有些情况需要用不同的指令来处理有符号和无符号操作,例如,使用不同版本的右移、除法和乘法指令,以及不同的条件码组合。

3.跳转指令

正常情况下,指令按照出现的顺序一条一条地执行。跳转(Jump)指令会导致执行切换到程序中一个全新的位置。在汇编代码中,通常用一个标号(Label)指明这些跳转的目的地。考虑下面的汇编代码序列:

指令jmp.Ll会使程序跳过movl指令,从popl指令开始继续执行。在产生目标代码文件时,汇编器会确定所有带标号指令的地址,并将跳转目标(目的指令的地址)编码为跳转指令的一部分。

表4-8列举了不同的跳转指令。jmp指令是无条件跳转指令。它可以是直接跳转,即跳转目标是作为指令的一部分编码的;也可以是间接跳转,即跳转目标是从寄存器或存储器位置中读出的。在汇编语言中,直接跳转给出一个标号作为跳转目标,例如上面所示代码中的标号“.L1”。间接跳转的写法是“*”后面跟一个操作数指示符,例如:

用寄存器%eax中的值作为跳转目标,而指令:

以%eax中的值作为读地址,从存储器中读出跳转目标。

表4-8中所示的其他跳转指令都是有条件的,它们根据条件码的某个组合,或者跳转,或者继续执行代码序列中的下一条指令。这些指令的名字和它们的跳转条件与SET指令是相匹配的(参见表4-7)。同SET指令一样,一些底层的机器指令有多个名字。条件跳转只能是直接跳转。

表4-8 跳转指令

(续) 1cut7DzGXx3UKNUI5jf7otdUROt1qweTwVV/Z+VQOHiQ9zBqsRSwVd1TV3Ts/w4C

点击中间区域
呼出菜单
上一章
目录
下一章
×

打开