3.1~4 处理器架构IA32/x86介绍+IA32架构下汇编基本介绍

一、处理器发展历史

Intel的x86流水线(line)有很长的发展历史。它以单芯片的十六位微处理器为开端,由于当时集成电路技术的能力有限,不得不做出许多妥协。从那以后,它开始利用技术进步,满足对更高性能和支持更先进操作系统的需求。

下面的列表显示了英特尔处理器的一些型号及其一些关键功能,特别是那些影响机器级编程的功能。我们使用构建处理器所需的晶体管数量来表示它们是如何在复杂度上进化的(K表示1000,M表示1000000)。

  • 8086: (1978, 29 K 晶体管). 最早的单芯片16位微处理器之一。8088是8086的一个变种,带有8位外部总线,构成了最初IBM个人计算机的核心。IBM与当时的微软签订了开发MS-DOS操作系统的合同。最初的型号有32768字节的内存和两个软盘驱动器(没有硬盘驱动器)。在体系结构上,这些机器被限制在655360字节的地址空间中,地址只有20位长(1048576字节可寻址),操作系统保留了393216字节供自己使用。1980年,英特尔推出8087浮点协处理器(45k晶体管),与8086或8088处理器并行运行,执行浮点指令。8087为x86流水线建立了浮点模型,通常称为“x87”
  • 80286: (1982, 134 K 晶体管). 添加了更多(现在已经过时)寻址模式。形成了IBM PC-AT个人计算机的基础,这是MS Windows的原始平台。
  • i386: (1985, 275 K 晶体管). 将架构扩展到32位。添加了Linux和Windows系列操作系统的最新版本所使用的线性寻址模型。这是该系列中第一台支持Unix操作系统的机器。
  • i486: (1989, 1.2 M 晶体管). 提高了性能,并将浮点单元集成到处理器芯片上,但没有显著改变指令集。
  • Pentium: (1993, 3.1 M 晶体管).提高了性能,但只对指令集添加了少量扩展。
  • PentiumPro: (1995, 5.5 M 晶体管). 提高了性能,但只对指令集添加了少量扩展。介绍了一种全新的处理器设计,内部称为P6微体系结构。向指令集添加了一类“条件移动”指令。
  • Pentium II: (1997, 7 M 晶体管). P6微体系结构的延续。
  • Pentium III: (1999, 8.2 M 晶体管).引入了SSE,一类用于处理整数或浮点数据向量的指令。每个数据可以是1、2或4字节,压缩成128位的向量。由于芯片上二级缓存的存在,该芯片的更高版本增加到24M晶体管。
  • Pentium 4: (2000, 42 M 晶体管). 将SSE扩展到SSE2,添加了新的数据类型(包括双精度浮点),以及用于这些格式的144条新指令。有了这些扩展,编译器可以使用SSE指令而不是x87指令来编译浮点代码。引入了NetBurst微体系结构,它可以在非常高的时钟速度下工作,但代价是高功耗。
  • Pentium 4E: (2004, 125 M 晶体管). 增加了超线程(hyperreading),一种在单个处理器上同时运行两个程序的方法,以及EM64T,这是英特尔对由高级微设备(Advanced Micro Devices,AMD)开发的IA32的64位扩展的实现,我们称之为x86-64。
  • Core 2: (2006, 291 M 晶体管). 返回到类似于P6的微体系结构。第一个多核英特尔微处理器,在一个芯片上实现多个处理器。不支持超线程。
  • Core i7: (2008, 781 M transistors). 合并了超线程和多核,初始版本支持每个核上两个执行程序,每个芯片上最多四个核。

每个后续处理器都被设计为向前兼容,能够运行为任何早期版本编译的代码。由于这个传统,指令集中存在许多奇怪的部件。英特尔的处理器系列有好几个名字,包括“Intel Architecture 32-bit”的IA32,以及最近的Intel64,IA32的64位扩展,我们称之为x86-64。

多年来,有几家公司已经生产出与英特尔处理器兼容的处理器,能够运行完全相同的机器级程序。其中最主要的就是AMD。多年来,AMD在技术上一直落后于英特尔,这迫使他们采取了一种营销策略,即生产价格较低但性能稍低的处理器。他们在2002年前后变得更具竞争力,率先打破了商用微处理器1千兆赫的时钟速度障碍,并推出了x86-64,这是IA32广泛采用的64位扩展。

原始8086中提供的内存模型及其在80286中的扩展已经过时。相反,Linux使用所谓的线性寻址,程序员将整个内存空间看作一个大的字节数组。

正如我们在上述处理器的演变过程中看到的,x86中添加了许多格式和指令,用于处理小整数和浮点数的向量。增加这些功能是为了提高多媒体应用程序的性能,如图像处理、音频和视频编解码以及三维计算机图形。在其默认的32位执行调用中,gcc假设它正在为i386生成代码,尽管这些1985年代的微处理器中很少有在继续运行的。只有通过提供特定的命令行选项,或者通过编译64位操作,编译器才能使用最新的扩展。

二、程序编码

假设我们编写了两个C语言源程序p1.c和p2.c。我们可以在一台IA32机器上利用如下的Unix命令行语句来编译这两个源程序:

unix> gcc -O1 -o p p1.c p2.c

gcc指令指代GCC C编译器。这是Linux平台的默认编译器,我们也可以用cc来调用它。命令行选项-O1指示编译器使用第一等级的优化。通常来讲,提高优化等级可以使程序运行的更快,但是通常会增加编译时间并且会使得debug程序难以运行。调用更高等级的优化也会生成大量转化过的代码,这将使得生成的机器码和源代码区别很大,让我们难以理解。我们通常使用一级优化进行学习,在实践过程中,二级优化被认为是获得程序性能的更好选择。

gcc命令实际上会调用一系列程序来将源代码转换为可执行代码。首先,C预处理器扩展源代码,以包含用#include命令指定的任何文件,并扩展用#define声明指定的任何宏。第二,编译器生成名为p1.s和p2.s的两个源文件的汇编代码版本。接下来,汇编程序将汇编代码转换为二进制目标代码文件p1.o和p2.o。目标代码是机器代码的一种形式,它包含所有指令的二进制表示,但全局值的地址尚未确定。最后,链接器将这两个目标代码文件与实现库函数的代码(例如printf)合并,并生成最终的可执行代码文件p。可执行代码是机器代码的第二种形式,我们将认为它是由处理器执行的代码的确切形式。

2.1 机器级别指令

计算机系统采用几种不同的抽象形式,通过使用更简单、抽象的模型来隐藏实现的细节。其中两种抽象对于机器级编程特别重要。首先,机器级程序的格式和行为是由指令集体系结构(instruction set architecture,ISA)定义的,ISA定义处理器状态、指令格式以及这些指令对状态的影响。大多数isa(包括IA32和x86-64)都将程序的行为描述为顺序执行每条指令,一条指令在下一条指令开始之前完成。处理器硬件要复杂得多,可以同时执行许多指令,但它们采用了保护措施,以确保整体行为与ISA指定的顺序操作相匹配。第二,机器级程序使用的内存地址是虚拟地址,提供了一个看起来像非常大的字节数组的内存模型。内存系统的实际实现涉及多个硬件内存和操作系统软件的组合。

编译器在整个程序编译中完成大部分工作,它将用C语言表示的相对抽象的程序转换成处理器执行的非常基本的指令。汇编代码表示与机器代码非常接近。它的主要特点是,与机器代码的二进制格式相比,它是一种可读性更强的文本格式。理解汇编代码及其与原始C代码的关系是理解计算机如何执行程序的关键步骤。

IA32机器码和我们初始的C语言代码有很大的区别,处理器的很多状态对于C语言编程者而言都是隐藏的:

  • 程序计数器(program counter,通常被称为PC,在IA32中用eip寄存器表示)指示了程序将要执行的下一条指令的内存地址。
  • 八个通用寄存器可以存储一些32位值。这些寄存器可以存储地址或者整型数据。一些寄存器被用来追踪程序关键部分的状态,而另一些用来存储一些临时性的数据,例如过程调用中的局部变量或者程序的返回值
  • 条件指令寄存器用来存储最近进行的算术或逻辑运算的状态信息。这些信息被用来控制程序的走向,例如if和while状态
  • 一些浮点寄存器,用于存储浮点值

C提供了一个模型,在该模型中,不同数据类型的对象可以在内存中声明和分配,而机器代码将内存看作一个大的、字节可寻址的数组。C中的聚合数据类型(如数组和结构)在机器代码中表示为连续的字节集合。即使对于标量数据类型,汇编代码也不区分有符号或无符号整数、不同类型的指针,甚至在指针和整数间也不做任何区分。

程序内存包含程序的可执行机器代码、操作系统所需的一些信息、用于管理过程调用和返回的运行时堆栈以及用户分配的内存块(例如,通过使用malloc库函数)。如前所述,程序内存使用虚拟地址寻址。在任何给定的时间,只有有限的虚拟地址子范围被认为是有效的。例如,尽管IA32的32位地址可能跨越4gb的地址值范围,但一个典型的程序只能访问几兆字节。操作系统管理这个虚拟地址空间,将虚拟地址转换成实际处理器内存中值的物理地址

一个单独的机器指令只能进行十分基础的操作。例如,指令可能将寄存器中两个数字相加,在内存和寄存器间传递数据,或者有条件转移到某个指令地址处。编译器必须生成一个指令的序列来实现程序需要的功能(逻辑运算,循环以及程度调用等功能)。

二、代码例子

假设我们要编写如下的C语言文件code.c,包含如下的一个函数:

int accum=0;int sum(int x,int y){int t=x+y;accum+=t;return t;
}

为了观察C编译器生成的汇编代码,我们可以在命令行使用-S选项:

unix> gcc -O1 -S code.c

在上述情况下,编译器在生成汇编代码后就会停止。上述文件生成的代码如下:

sum:pushl %ebpmovl %esp, %ebpmovl 12(%ebp), %eaxaddl 8(%ebp), %eaxaddl %eax, accumpopl %ebpret

每一行缩进的代码都对应一个机器指令。例如,pushl指令指示将ebp寄存器的内容储存到程序堆栈上。所有的局部变量名以及数据类型没有显示出来。我们可以看到关于全局变量accum的引用,因为此时编译器还没有决定在内存的哪部分存储这个变量。

如果我们使用了-c选项,那么GCC会进行编译和汇编的过程:

unix> gcc -O1 -c code.c

这会生成目标文件code.o,这个文件是二进制形式的,我们无法直接查看。在八百个字节码构成的文件code.o中有这样大小为17的字节学列,十六进制表示如下:

55 89 e5 8b 45 0c 03 45 08 01 05 00 00 00 00 5d c3

这就是上面汇编指令对应的目标文件码。

在这里插入图片描述
如果想要查看机器码(也就是上述的十六进制表示的字节序列),我们可以利用一种名为反汇编器的程序。反汇编器利用机器码生成一种类似于汇编代码的输出。在Linux系统下,程序OBJDUMP带着-d命令行选项可以实现这个任务:

unix> objdump -d code.o

生成的结果如下:
在这里插入图片描述
在左边,我们看到前面的字节序列中列出的17个十六进制字节值,它们被划分为1到6个字节大小的组。这些组中的每一个都是一条指令,右边显示的是等效的汇编语言。

在上述的机器码以及机器码对应的汇编代码表示中,我们需要注意以下几点:

  • IA32指令的长度范围为1到15字节。指令编码的设计使常用指令和操作数较少的指令所需的字节数小于不常用指令或操作数较多的指令。
  • 指令格式(instruction format)的设计方式是,从给定的起始位置开始,将字节唯一地解码为机器指令。例如,只有pushl %ebp指令可以以字节值55开头。
  • 反汇编程序纯粹基于机器代码文件中的字节序列来确定汇编代码。它不需要访问程序的源代码或汇编代码版本。
  • 反汇编程序对指令使用的命名约定与gcc生成的汇编代码稍有不同。在我们的示例中,它从许多指令中省略了后缀“l”。这些后缀是大小指示符,在大多数情况下可以省略。

生成实际的可执行文件需要链接器以及一系列目标文件,且目标文件中的一个需要包含main函数。假设在main.c中我们有如下的函数:

int main(){return sum(1,3);
}

接下来,我们就可以按照如下的形式生成可执行文件prog:

unix> gcc -O1 -o prog code.o main.c

此时prog文件已经增长到9123字节,因为它不仅包含两个过程(procedure)的代码,还包含用于启动和终止程序以及与操作系统交互的信息。我们可以反汇编这个prog程序:

unix> objdump -d prog

这时反汇编器将会提取出如下的代码序列:

Disassembly of function sum in executable file prog08048394 <sum>:
Offset 		Bytes 					Equivalent assembly language
8048394: 	55 						push %ebp
8048395: 	89 e5 					mov %esp,%ebp
8048397: 	8b 45 0c 				mov 0xc(%ebp),%eax
804839a: 	03 45 08 				add 0x8(%ebp),%eax
804839d: 	01 05 18 a0 04 08 		add %eax,0x804a018
80483a3: 	5d 						pop %ebp
80483a4: 	c3 						ret

此代码几乎与code.c反汇编生成的代码相同。一个重要的区别是,左侧列出的地址不同(链接器已将此代码的位置移动到不同的地址范围)。第二个区别是链接器已经确定了存储全局变量acum的位置。在代码.o的反汇编的第6行,acum的地址被列为0。在prog的反汇编中,地址被设置为0x804a018。这在指令的汇编代码格式副本中显示。它也可以在指令的最后4个字节中看到,从最低有效到最高有效列为18 a0 04 08

三、汇编格式的一些注意点

GCC生成的汇编代码对于我们来讲是非常难理解的。一方面,它包含了我们不必关心的信息,而另一方面,它没有提供程序的任何描述或它是如何工作的。例如,假设文件simple.c包含以下代码:

int simple(int *xp,int y){int t=*xp+y;*xp=t;return t;
}

当在命令行带有选项-S和-O1运行GCC时,它会生成如下的simple.s代码:
在这里插入图片描述
所有的.开头的都是帮助编译器和链接器运行的标号(directive)。我们可以直接忽略它们。同时,我们可以看出没有任何解释性的标记指出机器指令和源码间的关系。

为了给汇编代码提供一种更清晰的解释,我们将会以一种省略大部分标号的形式给出代码。同时会给出行号以及一些注释。例如,上面汇编代码的注释版本如下图所示:
在这里插入图片描述

3.3 数据格式

Intel使用术语字(word)表示16位数据类型,将32字节称为双字(double words),将64字节称为四字(quad words)。我们所使用的大多数指令都是在字节或双字上进行运算。

下图展示了部分IA32下C语言基本数据类型的表示:
在这里插入图片描述
可以看出,大部分常用数据类型都采用双字。这包括常用的long以及int。此外,所有的指针(用char *表示)都是四字节的大小。字节通常被用来处理字符数据。一些最近的C版本支持long long数据类型,这种数据类型使用8字节表示。但IA32并没有在硬件层次支持这种数据类型。此外,编译器必须生成一串指令来操作32位操作数。浮点数总共有三种形式:单精度浮点数(四字节)对应于C语言中的float,双精度浮点数(八字节)对应于C语言中的double,此外还存在扩展精度(10字节)值。gcc使用long double数据类型来引用扩展精度浮点值。它还将它们存储为12字节量,以提高内存系统性能,后面将讨论这一点。使用long double数据类型(在ISO C99中引入)使我们能够访问x86的扩展精度功能。对于大多数其他机器,此数据类型将使用与普通双精度数据类型相同的8字节格式来表示。

如上图所示,gcc生成的大多数汇编代码指令都有一个表示操作数大小的单字符后缀。例如,数据移动指令有三种变体:movb(移动字节)、movw(移动字)和movl(移动双字)。后缀“l”用于双字,因为32位的数量被认为是“长字”,这是从16位字号是标准的时代遗留下来的。请注意,汇编代码使用后缀“l”来表示4字节整数和8字节双精度浮点数。这不会引起歧义,因为浮点运算涉及一组完全不同的指令和寄存器。

四、访问信息

IA32架构下的CPU(central processing unit)包含八个存储32位值的寄存器。这些寄存器用来存储整型数据和指针。下图展示了这八个寄存器。

在这里插入图片描述

4.1 操作数说明符

大多数指令都有一个或多个操作数,用于指定执行操作时要引用的源值以及要将结果放入的目标位置。IA32支持多种操作数形式,如下图所示:
在这里插入图片描述
源值可以作为常量给出,也可以从寄存器或内存中读取。结果可以存储在寄存器或内存中。因此,不同的操作数可能性可分为三种类型。第一种类型立即数(immediate)用于常量值。在AT&T格式的汇编代码中,这些代码是用“$”后跟一个使用标准C表示法的整数来编写的,例如,$-577或$0x1F。第二种类型–寄存器(register)表示其中一个寄存器的内容,即用于双字操作的八个32位寄存器(例如,%eax)中的一个,用于字操作的八个16位寄存器(例如,%ax)中的一个,或用于字节操作的八个单字节寄存器元素(例如,%al)中的一个。在上图中,我们使用符号EaE_aEa表示任意寄存器a,并用引用R[Ea]R[E_a]R[Ea]表示其值,这种表示法类似于数组的索引表示。

第三种操作数是内存引用(memory reference),在内存引用中,我们根据计算出的地址(通常称为有效地址)访问某些内存位置。因为我们将内存看作一个大的字节数组,所以我们使用符号Mb[Addr]M_b[Addr]Mb[Addr]来表示从Addr地址开始的对内存中存储的b字节值的引用。为了简化,我们通常会去掉下标b。

如图3.3所示,存在许多不同的寻址模式,这就允许很多形式的内存引用。最通用的形式显示在表的底部,语法为Imm(Eb,Ei,s)Imm(E_b,E_i,s)ImmEbEis。这种引用有四个组件:立即数偏移量ImmImmImm、基址寄存器EbE_bEb、索引寄存器EiE_iEi和比例因子s,其中s必须是1、2、4或8。有效地址的计算方式为为Imm+R[Eb]+R[Ei]×sImm+R[E_b]+R[E_i]times sImm+R[Eb]+R[Ei]×s。 在引用数组元素时,通常会看到这种一般形式。其他形式只是这种一般形式的特例,其中省略了一些组件。

练习题(加深对上面各种寻址方式的理解,注意对比图3.3进行练习)
在这里插入图片描述
答案:
在这里插入图片描述

4.2 数据移动指令

那些将数据从一个位置复制到另一个位置的指令是最常用的。操作数表示法的通用性允许一条简单的数据移动指令执行许多机器需要大量指令的操作。下图列出了重要的数据移动指令。可以看出,我们将许多不同的指令分组到指令类中,其中一个类中的指令执行相同的操作,但操作数大小不同。例如,mov类由三条指令组成:movb、movw和movl。所有这三条指令都执行相同的操作;它们的不同之处在于,它们分别对大小为1、2和4字节的数据进行操作。
在这里插入图片描述
mov类中的指令将它们的源值复制到它们的目标。源操作数可以是一个立即数,也可以是存储在寄存器或存储在内存中的值。目标操作数是寄存器或内存地址的位置。IA32规定了一条数据移动指令的两个操作数不能都为内存地址。将一个值从一个内存位置复制到另一个内存位置需要两条指令,第一条指令将源值加载到寄存器,第二条指令将该寄存器值写入目标。注意在AT&T格式下,源操作数排在第一位,目标操作数排在第二位:

movl $0x4050,%eax 		Immediate--Register, 4 bytes
movw %bp,%sp 			Register--Register, 2 bytes
movb (%edi,%ecx),%ah 	Memory--Register, 1 byte
movb $-17,(%esp) 		Immediate--Memory, 1 byte
movl %eax,-12(%ebp) 	Register--Memory, 4 bytes

movs和movz指令类都用于将少量的源数据复制到较大的数据位置,通过符号扩展(movs)或零扩展(movz)填充高位。通过符号扩展,目的地的高位用源值的最高有效位的值填充。对于零扩展,高位用零填充。可以看出,这些类中的每个类都有三条指令,涵盖了1字节和2字节源大小以及2字节和4字节目标大小的所有情况(省略了movsww和movzww的冗余组合)。

在这里插入图片描述
最后两个数据移动操作用于将数据推送到程序堆栈并从中弹出数据。堆栈在处理过程调用中起着至关重要的作用。堆栈可以用数组实现,此时我们总是在数组的一端插入和删除元素,这一端称为栈顶。在IA32架构下,程序堆栈存储在内存的某个区域中。如图3.5所示,堆栈向下扩展,使得堆栈的顶部元素具有所有堆栈元素中最低的地址。(按照惯例,我们将堆栈颠倒绘制,堆栈“top”显示在图的底部)。堆栈指针%esp保存顶部堆栈元素的地址。

在这里插入图片描述
pushl指令提供将数据推送到堆栈上的能力,而popl指令则将其弹出。这些指令均接受一个操作数—用于推送的数据源和用于弹出的数据目标。

将一个双字值推送到堆栈需要首先将堆栈指针减4,然后将该值写入新的堆栈顶部地址。因此,指令pushl %ebp的行为等同于这对指令的行为

subl $4,%esp 		Decrement stack pointer
movl %ebp,(%esp) 	Store %ebp on stack

但是pushl指令在机器代码中编码为单个字节,而上面显示的一对指令总共需要6个字节。图3.5中的前两列说明了当%esp为0x108,%eax为0x123时执行指令pushl %eax的效果。首先将%esp减4,得到0x104,然后将0x123存储在内存地址0x104处。

弹出一个双字涉及从堆栈顶部位置读取,然后将堆栈指针增加4。因此,指令popl %eax相当于以下一对指令:

movl (%esp),%eax 		Read %eax from stack
addl $4,%esp 			Increment stack pointer

图3.5的第三列说明了在执行pushl之后立即执行指令popl %edx的效果。值0x123从内存中读取并写入寄存器%edx。寄存器%esp被加回到0x108。如图所示,值0x123保留在内存位置0x104,直到它被覆盖(例如,通过另一个推操作)。但是,堆栈顶部始终被视为%esp指示的地址。任何存储在堆栈顶部之外的值都被视为无效。

由于堆栈包含在与程序代码和其他形式的程序数据相同的内存中,因此程序可以使用标准内存寻址方法访问堆栈中的任意位置。例如,假设堆栈的最顶层元素是双字,则指令movl 4(%esp),%edx将从堆栈中复制第二个双字到寄存器%edx。

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

Published by

风君子

独自遨游何稽首 揭天掀地慰生平