深入理解计算机系统_第三章_程序的机器级表示

深入,并且广泛
				-沉默犀牛

文章导读

计算机执行机器代码,用字节序列编码低级的操作,包括处理数据、管理内存、读写存储设备上的数据,以及利用网络通信。编译器基于编程语言的规则、目标机器的指令集和操作系统遵循的惯例,经过一系列的阶段生成机器代码。GCC C语言编译器以汇编代码的形式产生输出,汇编代码是机器代码的文本表示,给出程序中的每一条指令,然后GCC调用汇编器链接器,根据汇编代码生成可执行的机器代码。

在本章中,我们会近距离观察机器代码,以及人类可读的表示——汇编代码。
当我们有高级语言编程的时候,机器屏蔽了程序的机器级的实现。而使用汇编语言编程的时候,程序员就必须制定程序用来执行计算的低级指令。通常,使用现代的优化编译器产生的代码至少与一个熟练的汇编语言程序员手工编写的代码一样有效[原文说的是有效,是不是意味着不那么高效?]。最大的优点是,用高级语言编写的程序可以在很多不同的机器上编译和执行,而汇编代码则是与特定机器密切相关的。

那么为什么我们还要花时间学习机器代码呢?对于优秀程序员来说,能够阅读和理解汇编代码仍是一项很重要的技能。通过阅读汇编代码,我们能够理解编译器的优化能力,并分析代码中隐含的低效率。试图最大化一段关键代码性能的程序员,通常会尝试源代码的各种形式,每次编译并检查产生的汇编代码,从而了解程序将要运行的效率如何。有的时候,高级语言提供的抽象层会隐藏我们想要了解的程序的运行时行为。例如,用线程包写并发程序时,了解不同的线程是如何共享程序数据或保持数据私有的,以及准确知道如何在哪里访问共享数据,都是很重要的。这些信息在机器代码是可见的。程序遭到攻击的许多方式中,都涉及程序存储运行时控制信息的方式的细节。许多攻击利用了系统程序中的漏洞重写信息,从而获得了系统的控制权。了解这些漏洞如何出现,以及如何防御它们,需要具备程序机器级表示的知识。程序员学习汇编代码的需求随着时间的推移也发生了变化,开始时要求程序员能够直接用汇编语言写程序,现在则要求能够阅读和理解编译器产生的代码。

在本章中,我们将详细学习一种特别的汇编语言,了解如何将C程序编译成这种形式的机器代码。阅读编译器产生的汇编代码,必须了解典型的编译器在将C程序结构变换成机器代码时所作的转换。相对于C代码表示的计算操作,优化编译器能够重新排列执行顺序,消除不必要的计算,用快速操作替换慢速操作,甚至将递归计算变换成迭代计算。源代码与对应的汇编代码的关系通常不大容易理解——就像要拼出的拼图与盒子上图片的设计有点不太一样。这是一种逆向工程(reverse engineering)——通过研究系统和逆向工作,来试图了解系统的创建过程。

本书中的表述基于x86-64,这是现在笔记本电脑和台式机中最常见处理器的机器语言,也是驱动大型数据中心和超级计算机的最常见处理器的机器语言。我们在技术讲解之前,先快速浏览C语言、汇编代码以及机器代码之间的关系。然后介绍x86-64的细节,从数据的表示和处理以及控制的实现开始。了解如何实现C语言中的控制结构,如if、while、switch语句。之后,我们会讲到过程的实现,包括程序如何维护一个运行栈来支持过程间数据和控制的传递,以及局部变量的存储。接着,我们会考虑机器级如何实现像数据、结构和联合这样的数据结构。有了这些机器级编程的背景知识,我们会讨论内存访问越界的问题,以及系统容易遭受缓冲区溢出攻击的问题。在这一部分的结尾,我们会给出一些用GDB调试器检查机器级运行时行为的技巧。本章的最后展示了包含浮点数据和操作的代码的机器程序表示。

计算机工业已经完成从32位到64位机器的过度。32位机器只能使用大概4GB(2的32次方)的随机访问存储器。存储器价格急剧下降,而我们队计算的需求和数据的大小持续增加,超越这个限制既经济上可行又有技术上的需要。当前的64位机器能够使用多达256TB(2的48次方)的内存空间,而且很容易就能扩展至16EB(2的64次方)。[原来64位机器不是直接就可以使用16EB……]

我们的表述集中于现代操作系统为目标,编译C或类似编程语言时,生成的机器及程序类型。x86-64有一些特性是为了支持遗留下来的微处理器早期编程风格,在此,我们不试图去描述这些特性,那时候大部分代码都是手工编写的,而且程序员还在努力与16位机器允许的有限地址空间奋战。

历史观点

Intel处理器系列俗称 x86,开始,它是第一代单芯片、16位微处理器之一。下面列举Intel处理器的模型,以及他们的一些关键特性,特别是影响机器级编程的特性。我们用实现这些处理器所需要的晶体管数量来说明演变过程的复杂性。其中 K表示1000,M表示 1 000 000,而G表示 1 000 000 000。
8086(1978年,29K个晶体管)[我学习微机的书就是基于8086的啊,怀念]。它是第一代单芯片、16位微处理器之一。8088是8086的一个变种,在8086上增加了一个8位外部总线,构成了最初的IBM个人计算机的心脏。最初的机器型号有 32768字节的内存和两个软驱(没有硬盘驱动器)。从体系结构上来说,这些机器只有 655360字节的地址空间——地址线只有20位长(可寻址范围为1048576字节),而操作系统保留了393216字节自用。1980年,Intel提出了8087浮点协处理器(45K个晶体管),它与一个8086或8088处理器一同运行,执行浮点指令。8087建立了 x86系列的浮点模型,通常称为“x87”
80286(1982年,134K个晶体管)。增加了更多的寻址模式(现在已经废弃了),构成了IBM PC-AT个人计算机的基础,这种计算机是 MS Windows最初的使用平台。
i386(1985年,257K个晶体管)。将体系结构扩展到32位。增加了平坦寻址模式(flat addressing model),Linux和最近版本的 Windows操作系统都是使用的这种寻址。这是Intel系列中第一台全面支持Unix操作系统的机器。
i486(1989年,1.2M个晶体管)。改善了性能,同时将浮点单元集成到了处理器芯片上,但是指令集没有明显的改变。
Pentium(1993年,3.1M个晶体管)。改善了性能,不过只对指令集进行了小的扩展。
PentiumPro(1995年,5.5M个晶体管)。引入了全新的处理器设计,在内部被称为P6微体系结构。指令集中增加了一类“条件传送(conditional move)”指令。
Pentium/MMX(1997年,4.5M个晶体管)。在Pentium处理器中增加了一类新的处理整数向量的指令。每个数据大小可以是1、2或4字节。每个向量总长64位。
Pentium II(1997年,7M个晶体管)。P6微体系结构的延伸。
Pentium III(1997年,8.2M个晶体管)。引入了SSE,这是一类处理整数或浮点数向量的指令。每个数据可以是1、2或4字节,打包成128位向量。由于芯片上包括了二级高速缓存,这种芯片后来的版本最多使用了 24M 个晶体管。
Pentium 4(2000年,42M个晶体管)。SSE扩展到SSE2,增加了新的数据类型(包括双精度浮点数),以及针对这些格式的 144 条新指令。有了这些扩展,编译器可以使用SEE指令(而不是x87指令),来编译浮点代码。
Pentium 4E(2004年,125M个晶体管)。增加了超线程(hyperthreading),这种技术可以在一个处理器上同时运行两个程序;还增加了EM64T,它是Intel对AMD提出的对IA32的64位扩展的实现,我们称之为x86-64。
Core 2(2006年,291M个晶体管)。回归到类似于 P6 的微体系结构。Intel的第一个多核微处理器,即多处理器实现在一个芯片上。但不支持超线程
Core i7,Nehalem(2008年,781M个晶体管)。既支持超线程,也有多核,最初的版本支持每个核上执行两个程序,每个芯片上最多四个核。
Core i7, Sandy Bridge(2011年,1.17G个晶体管)。引入了AVX,这是对SSE的扩展,支持把数据封装近256位向量。
Core i7 , Haswell(2013年,1.4G个晶体管)。将 AVX扩展至AVX2,增加了更多指令和指令格式。
[这些处理器的改革一起罗列到这里,真的是符合摩尔定律啊,不知道以后会变得怎样呢]
每个后继处理器的设计都是向后兼容的——较早版本上编译的代码可以在较新的处理器上运行。正如我们看到的那样,为了保持这种进化传统,指令集中有许多非常奇怪的东西。Intel处理器系列有好几个名字,包括 IA32 ,也就是“Intel 32位体系结构(Intel Architecture 32-bit)”,以及最近的Intel64,即IA32的64位扩展,我们也称为x84-64。最常用的名字是“x86”,我们用它指代整个系列。

这些年来,许多公司生产出了与Intel处理器兼容的处理器,能够运行完全相同的机器级程序。其中,领头的是AMD。数年来,AMD在技术上紧跟Intel,执行的市场策略是:生产性能稍低但是价格更便宜的处理器。2002年,AMD的处理器变得更加有竞争力,它们率先突破了可商用微处理器的1GHz的时钟速度屏障,并且引入了广泛采用的IA32的63位扩展 x86-64。虽然我们讲的是Intel处理器,但是对于其竞争对手生产的与之兼容的处理器来说,这些表述也成立。

对于由GCC编译器产生的、在Linux操作系统平台上运行的程序,感兴趣的人大多不关心x86的复杂性。最初的8086提供的内存模型和它在80286中的扩展,到i386的时候就都已经过时了。原来的x87浮点指令到引入了SSE2以后就过时了。虽然在x86-64程序中,我们能看到历史发展的痕迹,但x86中许多最晦涩难懂的特性已经不会出现了。

程序编码

假设一个C程序,有两个文件p1.c和p2.c。我们有Unix命令行编译这些代码:
linux> gcc -Og -o p p1.c p2.c
命令 gcc指的就是GCC C编译器。因为这是Linux上默认的编译器,我们也可以简单地用 cc 来启动它。编译选项 -Og 告诉编译器使用会生成符合原始C代码整体结构的机器代码的优化等级。使用较高级别优化产生的代码会严重变形,以至于产生的机器代码和初始源代码之间的关系非常难以理解。因此我们会使用 -Og 优化作为学习工具,然后当我们增加优化级别时,再看会发生什么。实际中,从得到的程序的性能考虑,较高级别的优化(例如,以选项 -O1 或 -O2指定)被认为是较好的选择。

实际上gcc命令调用了一整套的程序,将源代码转化成可执行代码。首先,C预处理器扩展源代码,插入所有用 #include 命令指定的文件,并扩展所有用 #define 声明指定的宏。其次,编译器产生两个源文件的汇编代码,名字分别是p1.s 和 p2.s。接下来,汇编器会将汇编代码转化成二进制目标代码文件 p1.o 和 p2.o。目标代码是机器代码的一种形式,它包含所有指令的二进制表示,但是还没有填入全局值的地址。最后, 链接器将两个目标代码文件与实现库函数(例如 printf)的代码合并,并产生最终的可执行代码文件p(由命令行指示符 -o p 指定的)。可执行代码是我们要考虑的机器代码的第二种形式,也就是处理器执行的代码格式。

机器级代码

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

在整个编译过程中,编译器会完成大部分的工作,将把用C语言提供的相对比较抽象的执行模型表示的程序转化成处理器执行的非常基本的指令。汇编代码表示非常接近于机器代码。与机器代码的二进制格式相比,汇编代码的主要特点是它用可读性更好的文本格式表示。能够理解汇编代码以及它与原始C代码的联系,是理解计算机如何执行程序的关键一步。

x86-64的机器代码和原始的C代码差别非常大。一些通常对C语言程序员隐藏的处理器状态都是可见的:

  • 程序计数器(成为“PC”,在x86-64中用%rip表示)给出将要执行的下一条指令在内存中的地址。
  • 整数寄存器文件 包含16个命名的位置,分别存储64位的值。这些寄存器可以存储地址(对应于C语言的指针)或整数数据。有的寄存器被用来记录某些重要的程序状态,而其他的寄存器用来保存临时数据,例如过程的参数和局部变量,以及函数的返回值。
  • 条件码寄存器保存着最近执行的算术或逻辑指令的状态信息。它们用来实现控制或数据流中的条件变化,比如说用来实现if 和 while 语句。
  • 一组向量寄存器可以存放一个或几个整数或浮点数值。

虽然C语言提供给了一种模型,可以在内存中声明的分配各种数据类型的对象,但是机器代码只是简单地将内存看成一个很大的、按字节寻址的数据。C语言中的聚合数据类型,例如数组和结构,在机器代码中用一组连续的字节来表示。即使是对标量数据类型,汇编代码也不区分有符号或无符号整数,不区分各种类型的指针,甚至于不区分指针和整数。

程序内存包含:程序的可执行机器代码,操作系统需要的一些信息,用来管理过程调用和返回的运行时栈,以及用户分配的内存块(比如说用malloc库函数分配的)。正如前面提到的,程序内存用虚拟地址来寻址。在任意给定的时刻,只有有限的一部分虚拟地址被认为是合法的。例如,x86-64的虚拟地址是由64位的字来表示的。在目前的实现中,这些地址的高16位必须设置为0,所以一个地址实际上能够指定的是2的48次方或64TB范围内的一个字节。较为典型的程序只会访问几兆字节或几千兆字节的数据。操作系统负责管理虚拟地址空间,将虚拟地址翻译成实际处理器内存中的物理地址。

一条机器指令只执行一个非常基本的操作。例如,将存放在寄存器中的两个数字相加,在存储器和寄存器之间传送数据,或是条件分支转移到新的指令地址。编译器必须产生这些指令的序列,从而实现(像算术表达式求值、循环或过程调用和返回这样的)程序结构。

代码示例

例如如下的一个C语言代码文件 mstore.c:
在这里插入图片描述
使用下面的编译命令:
Linux> gcc -Og -S mstore.c
这会使GCC运行编译器,产生一个汇编文件mstore.s,但是不做其他进一步的工作。
汇编代码文件包含以下几行:
在这里插入图片描述
上面代码中每一个缩进都对应一条机器指令。比如,pushq指令表示应该将寄存器 %rbx 的内容压入程序栈中。这段代码中已经除去了所有关于局部变量名或数据类型的信息。
如果我们使用如下命令行:
Linux> gcc -Og -c mstore.c
这就会产生目标代码文件mstore.o,它是二进制格式的,所以无法直接查看。1368字节的文件mstore.o 中有一段14字节的序列,它的十六进制表示为:
在这里插入图片描述
这就是上面列出的汇编指令对应的目标代码。从中得到一个重要信息,即机器执行的程序只是一个字节序列,它是对一系列指令的编码。机器对产生这些指令的源代码几乎一无所知。

要查看机器代码文件的内容,有一类称为反汇编器(disassembler)的程序非常有用。这些程序根据机器代码产生一种类似于汇编代码的格式。在Linux系统中,带‘-d’命令行标志的程序OBJDUMP(表示“object dump”)可以充当这个角色:
linux> objdump -d mstore.o
结果如下:
在这里插入图片描述
左边是前面给出的字节顺序排列的14个十六进制字节值,它们分成了若干组,每组有1 - 5个字节。每组都是一条指令,右边是等价的汇编语言。

一些关于机器代码和它的反汇编表示的特性值得注意:

  • x86-64 的指令长度 从1到15个字节不等。常用的指令以及操作数较少的指令所需的字节数少,而那些不太常用或操作数较多的指令所需字节数较多
  • 设计指令格式的方式是,从某个给定位置开始,可以将字节唯一地解码成机器指令。例如,只有指令 pushq % rbx 是以字节值53开头的。
  • 反汇编器只是基于机器代码文件中的字节序列来确定汇编代码。它不需要访问该程序的源代码或汇编代码。
  • 反汇编使用的指令命名规则与GCC生成的汇编代码使用的有些思维的差别。在我们的示例中,它省略了很多指令结尾的q。这些后缀是大小指示符,在大多数情况中可以省略。相反,反汇编给call和ret指令添加了‘q’后缀,同样,省略这些后缀也没有问题。

生成实际可执行的代码需要一组目标代码文件运行链接器,而这一组目标代码文件中必须含有一个main函数。假设main.c中有下面的函数:
在这里插入图片描述用如下命令行生成可执行文件 prog
linux> gcc -Og -o prog main.c mstore.c
文件 prog 变成了8655个字节,因为它不仅包含了两个过程的代码,还包含了用来启动和终止程序的代码,以及用来与操作系统交互的代码。我们可以反汇编 prog 文件:
linux> objdump -d prog
在这里插入图片描述
这段代码与mstore.c反汇编产生的代码几乎完全一样。其中一个主要的区别是左边列出的地址不同——链接器将这段代码的地址移到了一段不同的地址范围中。第二个不同之处在于链接器填上了callq指令调用函数 mult2 需要使用的地址(第4行)。链接器的任务之一就是为函数调用找到匹配的函数的可执行代码的位置。最后一个区别是多了两行代码(第8 、9行)。这两条指令对程序没影响,因为它们出现在返回指令后面。插入这些指令是为了使代码变为16字节,使得就存储器系统性能而言,能更好地放置下一个代码块。

关于格式的注解

GCC产生的汇编代码对我们来说有点难度,一是因为,它包含一些我们不需要关心的信息,二是因为,它不提供任何程序的描述或它是如何工作的描述。例如,假设我们用如下命令生成文件 mstore.s。
linux> gcc -Og -S mstore.c
在这里插入图片描述
所有以‘.’开头的都是指导汇编器和链接器工作的伪指令。我们通常可以忽略这些行。另一方面,也没有关于指令的用途以及它们与源代码之间关系的解释说明。
为了更清楚地说明汇编代码,我们用这样一种格式来表示汇编代码,它省略了大部分伪指令,但包括行数和解释性说明。
在这里插入图片描述通常我们只会给出与讨论内容相关的代码行。每一行的左边都有编号供引用,右边是注释,简单地描述指令的效果以及它与原始C代码中的计算操作的关系。这是一种汇编语言程序员写代码的风格。

我们的表述是ATT格式的汇编代码,这是GCC、OBJDUMP和其他一些我们使用的工具的默认格式,此外还有Intel格式,它们在许多方面有所不同。

  • 把C程序和汇编代码结合起来
    虽然C编译器在把程序中表达的计算转换到机器代码方面表现出色,但是仍然有一些机器特性是C程序访问不到的。例如,每次x86-64处理器执行算术或逻辑运算时,如果得到的运算结果的低8位中有偶数个1,那么就会把一个名为PF的1位条件码(condition code)标志设置为1,否则就设置为0。这里的PF表示“parity flag(奇偶标志)”。在C语言中计算这个信息需要至少7次移位、掩码和异或运算。即使作为每次算术或逻辑运算的一部分,硬件都完成了这项计算,而C语言却无法知道PF条件码标志的值。在程序中插入几条汇编代码指令就能很容易地完成这项任务。

在C程序中插入汇编代码有两种方法,第一种是,我们可以编写完成的函数,放进一个独立的汇编代码文件中,让汇编器和链接器把它和C语言书写的代码合并起来。第二种方法是,我们可以使用GCC的内联汇编(inline assembly)特性,用asm伪指令可以在C程序中包含简短的汇编代码。这种方法的好处是减少了与机器相关的代码量。
当然,在C程序中包含汇编代码使得这些代码与某类特殊的机器相关(例如 x86-64),所以只应该在想要的特定只能以此种方式才能访问到时才使用它。

数据格式

由于是从16位体系结构扩展成32位的,Intel用术语“字(Word)”表示16位数据类型。因此,称32位数为“双字(double words)”,称63位数为“四字(quad words)”。下图给出了C语言基本数据类型对应的x86-64表示。标准int值存储为双字(32位)。指针 (在此用 char * 表示)储存为8字节的四字,64位机器本来就预期如此。x86-64中,数据类型long实现位64字, 允许表示的值范围较大。本章代码示例中的大部分都使用了指针和long数据类型,所以都是四字操作。x86-64 指令集同样包括完整的针对字节、字和双字的指令。
在这里插入图片描述
浮点数主要有两种形式:单精度(4字节)值,对应于C语言数据类型float;双精度(8字节)值,对应于C语言数据类型 double。x86 家族的微处理器历史上实现过对一种特殊的80位(10字节)浮点格式进行全套的浮点运算。可以在C程序中用声明 long double 来指定这种格式。不过我们不建议使用这种格式。它不能移植到其他类型的机器上,而且实现的硬件也不如单精度和双精度算术运算的高效。如上图,大多数GCC生成的汇编代码指令都有一个字符的后缀,表明操作数的大小。例如,数据传送指令有四个变种:movb(传送字节)、movw(传送字)、movl(传送双字)和movq(传送四字)。后缀‘1’用来表示双字,因为32位数被看成是“长字(long Word)”。注意,汇编代码也使用后缀‘1’来表示4字节整数和8字节双精度浮点数。这不会产生歧义,因为浮点数使用的是一组完全不同的指令和寄存器。

访问信息

一个x86-64 的中央处理单元(CPU)包含一组16个存储64位值得通用目的寄存器。这些寄存器用来存储整数数据和指针。下图显示了这16个寄存器。它们的名字都以 %r 开头,不过后面还跟着一些不同的命名规则的名字,这是由于指令集历史演化造成的。最初的 8086 中有8个16位寄存器,即途中的 &ax 到 &bp。每个寄存器都有特殊的用途,它们的名字就反映了这些不同的用途。扩展到IA32架构时,这些寄存器也扩展成32位寄存器,标号从 %eax 到 %ebp。扩展到x86-64后,原来的8个寄存器扩展成64位,标号从 %rax 到 %rbp。除此之外,还增加了8个新的寄存器,它们的标号是按照新的明明规则制定的:%r8 到 %r15。
在这里插入图片描述
如图中嵌套的方框标明的,指令可以对这16个寄存器的低位字节中存放的不同大小的数据进行操作。字节级操作可以访问最低的字节,16位操作可以访问最低的2个字节,32位操作可以访问最低的4个字节,而64位操作可以访问整个寄存器。
后面的章节中,我们会展现很多指令,复制和生成1字节、2字节、4字节 和 8字节。当这些指令以寄存器作为目标时,对于生成小于8字节结果的指令,寄存器中剩下的字节会怎么样,对此有两条规则:生成 1字节 和 2字节数字的指令会保持剩下的字节不变;生成4字节数字的指令会把高位4个字节置0。后面这条规则是作为从 IA32 到 x86-64 的扩展的一部分而采用的。
像图中右边解释说明的那样,在常见的程序里不同的寄存器扮演不同的角色。其中最特别的是栈指针 %rsp ,用来指明运行时栈的结束位置。有些程序会明确地读写这个寄存器。另外15个寄存器的用法更灵活。少量指令会使用某些特定的寄存器。更重要的是,有一组标准的编程规范控制着如何使用寄存器来管理栈、传递函数参数、从函数的返回值,以及存储局部和临时数据。我们会在描述过程的实现时,讲述这些管理。

操作数指示符

大多数指令有一个或多个操作数(operand), 指示出执行一个操作中要使用的源数据值,以及放置结果的目的位置。x86-64 支持多种操作数格式。源数据值可以以常数形式给出,或从寄存器或内存中读出。结果可以存放在寄存器或内存中。因此,各种不同的操作数的可能性被分为三种类型:
第一种,立即数( immediate),用来表示常数值。在ATT格式的汇编代码中,立即数的书写方式是 ‘ ’ 后 面 跟 一 个 用 标 准 C 表 示 法 表 示 的 整 数 , 比 如 , ’ 后面跟一个用标准C表示法表示的整数,比如, C-577 或 $0x1F。不同的指令允许的立即数取值范围不同,汇编器会自动选择最紧凑的方式进行数值编码。
第二种,寄存器(register)它作为某个寄存器的内容,16个寄存器的低位1字节、2字节、4字节或8字节作为一个操作数,这些字节数分别对应于8位、16位、32位和64位。在下图中,我们用符号 ra来表示任意寄存器a,用引用R[ra]来表示它的值,这是讲寄存器集合看成一个数字R,用寄存器标识符作为索引。
第三种,内存引用,它会根据计算出来的地址 (通常称为有效地址)访问某个内存位置。因为将内存看成一个很大的字节数组,我们用符号 Mb[ADDr]表示对存储在内存中从地址ADDr开始的 b个字节值得引用。为了简便,通常省去下标b。
如下图,有多种不同的寻址模式,允许不同形式的内存引用。表中底部用语法Imm(rb,ri,s)表示的是最常用的形式。它有四个 组成部分:一个立即数偏移Imm,一个基址寄存器rb,一个变址寄存器ri 和一个比例因子 s,这里s 必须是1、2、4或8.基址和变址寄存器都必须是64位寄存器。有效地址被计算为 Imm + R[rb] + R[ri] * s 。引用数组元素时,会用到这种通用形式。其他形式都是这种通用形式的特殊情况,只是省略了某些部分。正如我们将看到的,当引用数组和结构元素时,比较复杂的寻址模式是很有用的。
在这里插入图片描述
为了加深理解,马上来看一个例子:
在这里插入图片描述
在这里插入图片描述

数据传送指令

最频繁使用的指令是将数据从一个位置复制到另一个位置的指令 。操作数表示的通用性使得一条简单的数据传送指令能够完成在许多机器中要好几条不同指令才能完成的功能。我们会介绍多种不同的数据传送指令,它们或者源和目的类型不同,或者执行的转换不同,或者具有的一些副作用不同。在讲述中,把许多不同的指令划分为指令类,每一类中的指令执行相同的操作,只不过操作数大小不同。
下图列出的是最简单形式的数据传送指令——MOV类。这些指令把数据从源位置复制到目的位置,不做任何变化。MOV类有四条指令租场:movb、movw、movl 和 movq 。这些指令都执行同样的操作;主要区别在于它们操作的数据大小不同 :分别是1、2、4和8字节。
在这里插入图片描述

源操作数指定的值是一个立即数,存储在寄存器中或者内存中。目的操作数指定一个位置,要么是一个寄存器或者,要么一个内存地址。x86-64 加了一条限制,传送指令的两个操作数不能都指向内存位置。将一个值从一个内存位置复制到另一个内存位置需要两条指令——第一条指令将源值加载到寄存器中,第二条将该寄存器值写入目的位置。这些指令的寄存器操作数可以使16个寄存器有标号部分中的任意一个,寄存器部分的大小必须与指令最后一个字符(‘b’、‘w’、‘l’、‘q’)指定的大小匹配。大多数情况中,MOV指令只会更新目的操作数指定的那些寄存器字节或内存位置。唯一地例外是 movl 指令以寄存器作为目的时,它会把该寄存器的高位4字节设置为0。造成这个例外的原因是 x86-64 采用的惯例,即任何为寄存器生成32位值的指令都会把该寄存器的高位部分置为0。
下面的MOV指令示例给出了源和目的的类型的物种可能的组合。记住,第一个是源操作数,第二个是目的操作数:
在这里插入图片描述
图中记录的最后一条指令是处理64位立即数数据的。常规的 movq 指令只能以表示为32位补码数字的立即数作为源操作数,然后把这个值符号扩展得到64位的值,放到目的位置。 movabsq 指令能够以任意64位立即数作为源操作数,并且只能以寄存器作为目的。

下图记录的是两类数据移动指令,在将较小的源值复制到较大的目的时使用。所有这些指令都把数据从源(在寄存器或内存中)复制到目的寄存器。MOVZ类中的指令把目的中剩余的字节填充为0,而MOVS类中的指令通过符号扩展来填充,把源操作数的最高位进行复制。可以观察到,每条指令名字的最后两个字符都是大小指示符:第一个字符指定源的大小,而第二个指明目的大小。这两个类中每个都有三条指令,包括了所有的源大小为1个和2个字节、目的大小为2个和4个的情况,当然只考虑目的大于源的情况。
在这里插入图片描述

注意上图中并没有一条明确的指令把4字节源值零扩展到8字节目的。这样的指令逻辑上应该被命名为 moxzlq ,但是并没有这样的指令。不过,这样的数据传送可以用以寄存器为目的的movl指令来实现。这一技术利用的属性是,生成4字节值并以寄存器作为目的的指令会把高4字节置为0。对于64位的目标,所有三种源类型都有对应的符号扩展传送,而只有两种较小的源类型有零扩展传送。
图中还给出了cltq指令。这条指令没有操作数:它总是以寄存器 %eax 作为源,%rax作为符号扩展结果的目的。它的效果与指令 movslq %eax, %rax完全一致,不过编码更紧凑。

两个数据传送的例子:
在这里插入图片描述
(下图中3行修改为 movb %dl,%rax,原书打印错了)
在这里插入图片描述

练习题:这个练习题要回去看上面的各个寄存器的字节数
在这里插入图片描述
在这里插入图片描述
[看答案介绍内存引用总是用四字长寄存器给出,选择数据传送指令的时候就看另一个操作数好了。]

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

数据传送示例

作为一个使用数据传送指令的代码示例,考虑下图中所示的数据交换函数,既有C代码,也有GCC产生的汇编代码。
在这里插入图片描述
如上图所示,函数exchange由三条指令实现:两个数据传送(movq),加上一条返回函数被调用点的指令(ret)。我们会在之后讲函数调用和返回的细节。在此之前,知道函数参数通过寄存器传递给函数就足够了。我们对汇编代码添加注释来加以说明。函数通过把值存储在寄存器 %rax 或该寄存器的某个低位部分中返回。
当过程开始执行时,过程参数 xp 和 y 分别存储在寄存器 %rdi 和 % rsi中。然后,指令2从内存中读出x,把它存放到寄存器 %rax 中,直接实现了C程序中的操作 x = *xp。稍后,用寄存器 %rax 从这个函数返回一个值,因而返回值就是 x。指令3将 y 写入到寄存器 %rdi 中的 xp 指向的内存位置,直接实现了操作 *xp = y。这个例子说明如何用 MOV 指令从内存中读值到寄存器(第2行),如何从寄存器写到内存(第3行)。
关于这段汇编代码有两点值得注意。首先,我们看到C语言中所谓的“指针”其实就是地址。间接引用指针就是将该指针放在一个寄存器中,然后在内存引用中使用这个寄存器。其次,像 x 这样的局部变量通常保存在寄存器中,而不是内存中。访问寄存器比访问内存要快得多。
在这里插入图片描述

我把每种数据类型的占用的字节数再贴一下:
在这里插入图片描述

在这里插入图片描述

  • 指针的一些示例
    函数 exchange 提供了一个关于C语言中指针使用的很好说明。参数 xp 是一个指向 long 类型的整数的指针,而 y 是一个 long类型的整数。语句 long x = *xp ,表示我们将读存储在 xp 所指位置中的值,并将它存放到名字为 x 的局部变量中。这个读操作称为指针的间接引用(pointer dereferencing),C操作符 * 执行指针的间接引用。 语句 xp = y, 正好相反——它将参数 y 的值写到 xp 所指的位置。这也是指针间接引用的一种形式(所以有操作符 ‘‘),但是它表明的是一个写程序,因为它在赋值语句的左边。
    下面是调用 exchange 的一个实际例子:
    long a = 4;
    long b = exchange( &a, 3);
    printf( “a = %ld, b = %ld\n”, a, b)
    这段代码会打印出:
    a = 3,b = 4
    C操作符 & (称为“取值”操作符)创建一个指针,在本例中,该指针指向保存局部变量 a 的位置。然后,函数 exchange 将用 3 覆盖存储在 a 中的值,但是返回原来的值 4 作为函数的值。注意如何将指针传递给 exchange,它能修改存在某个远处位置的数据。
    在这里插入图片描述
    在这里插入图片描述

压入和弹出栈数据

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

  • 4
    点赞
  • 14
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值