第三章:程序的机器级表示

程序的机器级表示

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

当我们用高级语言编程的时候(例如 C 语言,Java 语言更是如此),机器屏蔽了程序的细节,即机器级的实现。与此相反,当用汇编代码编程的时候(就像早期的计算),程序员必须指定程序用来执行计算的低级指令。高级语言提供的抽象级别比较高,大多数时候,在这种抽象级别上工作效率会更高,也更可靠。编译器提供的类型检査能帮助我们发现许多程序错误,并能够保证按照一致的方式来引用和处理数据。通常情况下,使用现代的优化编译器产生的代码至少与一个熟练的汇编语言程序员手工编写的代码一样有效。最大的优点是,用高级语言编写的程序可以在很多不同的机器上编译和执行,而汇编代码则是与特定机器密切相关的。

那么为什么我们还要花时间学习机器代码呢?即使编译器承担了生成汇编代码的大部分工作,对于严谨的程序员来说,能够阅读和理解汇编代码仍是一项很重要的技能。以适当的命令行选项调用编译器,编译器就会产生一个以汇编代码形式表示的输出文件。通过阅读这些汇编代码,我们能够理解编译器的优化能力,并分析代码中隐含的低效率。就像我们将在第 5 章中体会到的那样,试图最大化一段关键代码性能的程序员,通常会尝试源代码的各种形式,每次编译并检査产生的汇编代码,从而了解程序将要运行的效率如何。此外,也有些时候,高级语言提供的抽象层会隐藏我们想要了解的程序的运行时行为。例如,第 12 章会讲到,用线程包写并发程序时,了解不同的线程是如何共享程序数据或保持数据私有的,以及准确知道如何在哪里访问共享数据,都是很重要的。这些信息在机器代码级是可见的。另外再举一个例子,程序遭受攻击(使得恶意软件侵扰系统)的许多方式中,都涉及程序存储运行时控制信息的方式的细节。许多攻击利用了系统程序中的漏洞重写信息,从而获得了系统的控制权。了解这些漏洞是如何岀现的,以及如何防御它们,需要具备程序机器级表示的知识。程序员学习汇编代码的需求随着时间的推移也发生了变化,开始时要求程序员能直接用汇编语言编写程序,现在则要求他们能够阅读和理解编译器产生的代码。

在本章中,我们将详细学习一种特别的汇编语言,了解如何将 C 程序编译成这种形式的机器代码。阅读编译器产生的汇编代码,需要具备的技能不同于手工编写汇编代码。我们必须了解典型的编译器在将 C 程序结构变换成机器代码时所做的转换。相对于 C 代码表示的计算操作,优化编译器能够重新排列执行顺序,消除不必要的计算,用快速操作替换慢速操作,甚至将递归计算变换成迭代计算。源代码与对应的汇编代码的关系通常不太容易理解——就像要拼出的拼图与盒子上图片的设计有点不太一样。这是一种逆向工程(reverse engineering)——通过研究系统和逆向工作,来试图了解系统的创建过程。在这里,系统是一个机器产生的汇编语言程序,而不是由人设计的某个东西。这简化了逆向工程的任务,因为产生的代码遵循比较规则的模式,而且我们可以做试验,it 编译器产生许多不同程序的代码。本章提供了许多示例和大量的练习,来说明汇编语言和编译器的各个不同方面。精通细节是理解更深和更基本概念的先决条件。有人说:“我理解了一般规则,不愿意劳神去学习细节!” 他们实际上是在自欺欺人。花时间研究这些示例、完成练习并对照提供的答案来检査你的答案,是非常关键的。

我们的表述基于 x86-64,它是现在笔记本电脑和台式机中最常见处理器的机器语言,也是驱动大型数据中心和超级计算机的最常见处理器的机器语言。这种语言的历史悠久,开始于 Intel 公司 1978 年的第一个 16 位处理器,然后扩展为 32 位,最近又扩展到 64 位。一路以来,逐渐增加了很多特性,以更好地利用已有的半导体技术,以及满足市场需求。这些进步中很多是 Intel 自己驱动的,但它的对手 AMD(Advanced Micro Devices)也作出了重要的贡献。演化的结果是得到一个相当奇特的设计,有些特性只有从历史的观点来看才有意义,它还具有提供后向兼容性的特性,而现代编译器和操作系统早已不再使用这些特性。我们将关注 GCC 和 Linux 使用的那些特性,这样可以避免 X86-64 的大量复杂性和许多隐秘特性。

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


网络旁注 ASM: IA32 - IA32 编程
IA32,x86-64 的 32 位前身,是 Intel 在 1985 年提出的。几十年来一直是 Intel 的机器语言之选。今天出售的大多数 x86 微处理器,以及这些机器上安装的大多数操作系统,都是为运行 x86-64 设计的。不过,它们也可以向后兼容执行 IA32 程序。所以,很多应用程序还是基于 IA32 的。除此之外,由于硬件或系统软件的限制,许多已有的系统不能够执行 x86-64oIA32 仍然是一种重要的机器语言。学习过 x86-64 会使你很容易地学会 IA32 机器语言。


计算机工业已经完成从 32 位到 64 位机器的过渡。32 位机器只能使用大概 4 GB(232 字节)的随机访问存储器。存储器价格急剧下降,而我们对计算的需求和数据的大小持续增加,超越这个限制既经济上可行又有技术上的需要。当前的 64 位机器能够使用多达 256 TB(字节)的内存空间,而且很容易就能扩展至 16 EB(字节)。虽然很难想象一台机器需要这么大的内存,但是回想 20 世纪 70 和 80 年代,当 32 位机器开始普及的时候,4GB 的内存看上去也是超级大的。 我们的表述集中于以现代操作系统为目标,编译 C 或类似编程语言时,生成的机器级程序类型。x86-64 有一些特性是为了支持遗留下来的微处理器早期编程风格,在此,我们不试图去描述这些特性,那时候大部分代码都是手工编写的,而程序员还在努力与 16 位机器允许的有限地址空间奋战。

3.1 历史观点

Intel 处理器系列俗称 x86,经历了一个长期的、不断进化的发展过程。开始时,它是第一代单芯片、16 位微处理器之一,由于当时集成电路技术水平十分有限,其中做了很多妥协。以后,它不断地成长,利用进步的技术满足更高性能和支持更高级操作系统的需求。

以下列举了一些 Intel 处理器的模型,以及它们的一些关键特性,特别是影响机器级编程的特性。我们用实现这些处理器所需要的晶体管数量来说明演变过程的复杂性。其中,“K” 表示 1000,“M” 表示 1 000 000,而 “G” 表示 1 000 000 000。

  • 8086(1978 年,29 K 个晶体管)。它是第一代单芯片、16 位微处理器之一。8088 是 8086 的一个变种,在 8086 上增加了一个 8 位外部总线,构成最初的 IBM 个人计算机的心脏。IBM 与当时还不强大的微软签订合同,开发 MS-DOS 操作系统。最初的机器型号有 32 768 字节的内存和两个软驱(没有硬盘驱动器)。从体系结构上来说,这些机器只有 655 360 字节的地址空间——地址只有 20 位长(可寻址范围为 1048576 字节),而操作系统保留了 393216 字节自用。1980 年,Intel 提出了 8087 浮点协处理器(45 K 个晶体管),它与一个 8086 或 8088 处理器一同运行,执行浮点指令。8087 建立了 x86 系列的浮点模型,通常被称为 “x87”。
  • 80286(1982 年,134 K 个晶体管)。增加了更多的寻址模式(现在已经废弃了),构成了 IBM PC-AT 个人计算机的基础,这种计算机是 MS Windows 最初的使用平台。
  • i386(1985 年,275 K 个晶体管)。将体系结构扩展到 32 位。增加了平坦寻址模式(flat addressing model),Linux 和最近版本的 Windows 操作系统都是使用的这种模式。这是 Intel 系列中第一台全面支持 Unix 操作系统的机器。
  • i486(1989 年,1.2 M 个晶体管)。改善了性能,同时将浮点单元集成到了处理器芯片上,但是指令集没有明显的改变。
  • Pentium(1993 年,3.1 M 个晶体管)。改善了性能,不过只对指令集进行了小的扩展。
  • Pentium Pro(1995 年,5.5 M 个晶体管)。引入全新的处理器设计,在内部被称为 F6 微体系结构。指令集中增加了一类“条件传送(conditional move)”指令。
  • Pentium/MMX(1997 年,4.5 M 个晶体管)。在 Pentium 处理器中增加了一类新的处理整数向量的指令。每个数据大小可以是 1、2 或 4 字节。每个向量总长 64 位。
  • Pentium II(1997 年,7 M 个晶体管)。P6 微体系结构的延伸。
  • Pentium III(1999 年,8.2 M 个晶体管)。引入了 SSE,这是一类处理整数或浮点数向量的指令。每个数据可以是 1、2 或 4 个字节,打包成 128 位的向量。由于芯片上包括了二级高速缓存,这种芯片后来的版本最多使用了 24 M 个晶体管。
  • Pentium 4(2000 年,42 M 个晶体管)。SSE 扩展到了 SSE2,增加了新的数据类型(包括双精度浮点数),以及针对这些格式的 144 条新指令。有了这些扩展,编译器可以使用 SSE 指令(而不是 x87 指令),来编译浮点代码。
  • Pentium 4E(2004 年,125 M 个晶体管)。增加了超线程(hyperthreading),这种技术可以在一个处理器上同时运行两个程序;还增加了 EM64T,它是 Intel 对 AMD 提出的对 IA32 的 64 位扩展的实现,我们称之为 x86-64。
  • Core2(2006 年,291 M 个晶体管)。回归到类似于 P6 的微体系结构。Intel 的第一个多核微处理器,即多处理器实现在一个芯片上。但不支持超线程。
  • Core i7,NehaIem(2008 年,781 M 个晶体管)。既支持超线程,也有多核,最初的版本支持每个核上执行两个程序,每个芯片上最多四个核。
  • Core i7,Sandy Bridge(2011 年,1.17 G 个晶体管)。引入了 AVX,这是对 SSE 的扩展,支持把数据封装进 256 位的向量。
  • Core i7,Haswell(2013 年,1.4 G 个晶体管)。将 AVX 扩展至 AVX2,增加了更多的指令和指令格式。

每个后继处理器的设计都是后向兼容的一较早版本上编译的代码可以在较新的处理器上运行。正如我们看到的那样,为了保持这种进化传统,指令集中有许多非常奇怪的东西。Intel 处理器系列有好几个名字,包括 IA32,也就是 “Intel 32 位体系结构(Intel Architecture 32-bit)“,以及最新的 Intel64,即 IA32 的 64 位扩展,我们也称为 x86-64。最常用的名字是 “x86”,我们用它指代整个系列,也反映了直到 i486 处理器命名的惯例。


旁注 - 摩尔定律(Moore’s Law)
如果我们画出各种不同的 Intel 处理器中晶体管的数量与它们出现的年份之间的图(y 轴为晶体管数量的对数值),我们能够看出,增长是很显著的。画一条拟合这些数据的线,可以看到晶体管数量以每年大约 37% 的速率增加,也就是说,晶体管数量每 26 个月就会翻一番。在 x86 微处理器的历史上,这种增长已经持续了好几十年。

1965 年,Gordon Moore,Intel 公司的创始人,根据当时的芯片技术(那时他们能够在一个芯片上制造有大约 64 个晶体管的电路)做出推断,预测在未来 10 年,芯片上的晶体管数量每年都会翻一番。这个预测就称为摩尔定律。正如事实证明的那样,他的预测有点乐观,而且短视。在超过 50 年中,半导体工业一直能够使得晶体管数目每 18 个月翻一倍。
对计算机技术的其他方面,也有类似的呈指数增长的情况出现,比如磁盘和半导体存储器的存储容量。这些惊人的增长速度一直是计算机革命的主要驱动力。


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

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

3.2 程序编码

假设一个 C 程序,有两个文件 p1.cp2.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.sp2.s 。接下来,汇编器会将汇编代码转化成二进制目标代码文件 p1.op2.o。目标代码是机器代码的一种形式,它包含所有指令的二进制表示,但是还没有填入全局值的地址。最后,链接器将两个目标代码文件与实现库函数(例如 printf)的代码合并,并产生最终的可执行代码文件 p(由命令行指示符 -o p` 指定的)。可执行代码是我们要考虑的机器代码的第二种形式,也就是处理器执行的代码格式。我们会在第 7 章更详细地介绍这些不同形式的机器代码之间的关系以及链接的过程。

3.2.1 机器级代码

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

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

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

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

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

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

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


旁注 - 不断变化的生成代码的格式
在本书的表述中,我们给出的代码是由特定版本的 GCC 在特定的命令行选项设置下产生的。如果你在自己的机器上编译代码,很有可能用到其他的编译器或者不同版本的 GCC,因而会产生不同的代码。支持 GCC 的开源社区一直在修改代码产生器,试图根据微处理器制造商提供的不断变化的代码规则,产生更有效的代码。
本书示例的目标是展示如何查看汇编代码,并将它反向映射到高级编程语言中的结构。你需要将这些技术应用到你的特定的编译器产生的代码格式上。


3.2.2 代码示例

假设我们写了一个 C 语言代码文件 mstore.c,包含如下的函数定义:

1
2
3
4
5
long mult2(long, long);
void multstore(long x, long y, long *dest) {
long t = mult2(x, y);
*dest = t;
}

在命令行上使用 -S 选项,就能看到 C 语言编译器产生的汇编代码:

linux> gcc -Og -S mstore.c

这会使 GCC 运行编译器,产生一个汇编文件 mstore.s,但是不做其他进一步的工作。(通常情况下,它还会继续调用汇编器产生目标代码文件)。
汇编代码文件包含各种声明,包括下面几行:

1
2
3
4
5
6
7
multstore:
pushq %rbx
movq %rdx, %rbx
call mult2
movq %rax, (%rbx)
popq %rbx
ret

上面代码中每个缩进去的行都对应于一条机器指令。比如,pushq 指令表示应该将寄存器%rbx 的内容压入程序栈中。这段代码中已经除去了所有关于局部变量名或数据类型的信息。

如果我们使用 -c 命令行选项,GCC 会编译并汇编该代码:

linux> gcc -Og -c mstore.c

这就会产生目标代码文件 mstore.o,它是二进制格式的,所以无法直接查看。1368 字节的文件 mstore.o 中有一段 14 字节的序列,它的十六进制表示为:

53 48 89 d3 e8 00 00 00 00 48 89 03 5b c3

这就是上面列出的汇编指令对应的目标代码。从中得到一个重要信息,即机器执行的程序只是一个字节序列,它是对一系列指令的编码。机器对产生这些指令的源代码几乎一无所知。


旁注 - 如何展示程序的字节表示
要展示程序(比如说 mstore)的二进制目标代码,我们用反汇编器(后面会讲到)确定该过程的代码长度是 14 字节。然后,在文件 mstore.o 上运行 GNU 调试工具 GDB,输入命令:

(gdb) x/14xb multstore

这条命令告诉 GDB 显示(简写为 ‘x’)从函数 multstore 所处地址开始的 14 个十六进制格式表示(也简写为 ‘x’)的字节(简写为 ‘b’)。你会发现,GDB 有很多有用的特性可以用来分析机器级程序,我们会在 3.10.2 节中讨论。


要査看机器代码文件的内容,有一类称为反汇编器(disassembler)的程序非常有用。这些程序根据机器代码产生一种类似于汇编代码的格式。在 Linux 系统中,带 ‘-d’ 命令行标志的程序 OBJDUMP(表示 “object dump”)可以充当这个角色:

linux> objdump -d mstore.o

结果如下(这里,我们在左边增加了行号,在右边增加了斜体表示的注解):

1
2
3
4
5
6
7
8
9
10
11
12
# Disassembly of function sum in binary file mstore.o
0000000000000000 <multstore>:
----------------------------------------------------------
Offset Bytes Equivalent assembly language
----------------------------------------------------------
0: 53 push %rbx
1: 48 89 d3 mov %rdx,%rbx
4: e8 00 00 00 00 callq 9 <multstore+0x9>
9: 48 89 03 mov %rax,(%rbx)
c: 5b pop %rbx
d: c3 retq

在左边,我们看到按照前面给出的字节顺序排列的 14 个十六进制字节值,它们分成了若干组,每组有 1 ~ 5 个字节。每组都是一条指令,右边是等价的汇编语言。

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

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

生成实际可执行的代码需要对一组目标代码文件运行链接器,而这一组目标代码文件中必须含有一个 main 函数。假设在文件 main.c 中有下面这样的函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <stdio.h>

void multstore(long, long, long *);

int main() {
long d;
multstore(2, 3, &d);
printf("2 * 3 --> %ld\n", d);
return 0;
}

long mult2(long a, long b) {
long s = a * b;
return s;
}

然后,我们用如下方法生成可执行文件 prog:

linux> gcc -Og -o prog main.c mstore.c

文件 prog 变成了 8655 个字节,因为它不仅包含了两个过程的代码,还包含了用来启动和终止程序的代码,以及用来与操作系统交互的代码。我们也可以反汇编 prog 文件:

linux> objdump -d prog

反汇编器会抽取出各种代码序列,包括下面这段:

1
2
3
4
5
6
7
8
9
10
11
12
13
# Disassembly of function sum in binary file prog
----------------------------------------------------------
Offset Bytes Equivalent assembly language
----------------------------------------------------------
0000000000400540 <multstore>:
400540: 53 push %rbx
400541: 48 89 d3 mov %rdx,%rbx
400544: e8 42 00 00 00 callq 40058b <mult2>
400549: 48 89 03 mov %rax,(%rbx)
40054c: 5b pop %rbx
40054d: c3 retq
40054e: 90 nop
40054f: 90 nop

这段代码与 mstore.c 反汇编产生的代码几乎完全一样。其中一个主要的区别是左边列出的地址不同一链接器将这段代码的地址移到了一段不同的地址范围中。第二个不同之处在于链接器填上了 callq 指令调用函数 mult2 需要使用的地址(反汇编代码第 4 行)。链接器的任务之一就是为函数调用找到匹配的函数的可执行代码的位置。最后一个区别是多了两行代码(第 8 和 9 行)。这两条指令对程序没有影响,因为它们出现在返回指令后面(第 7 行)。插入这些指令是为了使函数代码变为 16 字节,使得就存储器系统性能而言,能更好地放置下一个代码块。

3.2.3 关于格式的注解
GCC 产生的汇编代码对我们来说有点儿难读。一方面,它包含一些我们不需要关心的信息,另一方面,它不提供任何程序的描述或它是如何工作的描述。例如,假设我们用如下命令生成文件 mstore.s。

liunx> gcc -Og -S mstore.c

mstore.s 的完整内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
        .file   "010-mstore.c"
.text
.globl multstore
.type multstore, @function
multstore:
pushq %rbx
movq %rdx, %rbx
call mult2
movq %rax, (%rbx)
popq %rbx
ret
.size multstore, .-multstore
.ident "GCC: (Ubuntu 4.8.1-2ubuntu1~12.04) 4.8.1"
.section .note.GNU-stack,"",@progbits

所有以 ‘.’ 开头的行都是指导汇编器和链接器工作的伪指令。我们通常可以忽略这些行。另一方面,也没有关于指令的用途以及它们与源代码之间关系的解释说明。

为了更清楚地说明汇编代码,我们用这样一种格式来表示汇编代码,它省略了大部分伪指令,但包括行号和解释性说明。对于我们的示例,带解释的汇编代码如下:

1
2
3
4
5
6
7
8
9
# void multstore(long x, long y, long *dest)
# x in %rdi, y in %rsi, dest in %rdx
multstore:
pushq %rbx # Save %rbx
movq %rdx, %rbx # Copy dest to %rbx
call mult2 # Call mult2(x, y)
movq %rax, (%rbx) # Store result at *dest
popq %rbx # Restore %rbx
ret # Return

通常我们只会给出与讨论内容相关的代码行。每一行的左边都有编号供引用,右边是注释,简单地描述指令的效果以及它与原始 C 语言代码中的计算操作的关系。这是一种汇编语言程序员写代码的风格。

我们还提供网络旁注,为专门的机器语言爱好者提供一些资料。一个网络旁注描述的是 IA32 机器代码。有了 X86-64 的背景,学习 IA32 会相当简单。另外一个网络旁注简要描述了在 C 语言中插入汇编代码的方法。对于一些应用程序,程序员必须用汇编代码来访问机器的低级特性。一种方法是用汇编代码编写整个函数,在链接阶段把它们和 C 函数组合起来。另一种方法是利用 GCC 的支持,直接在 C 程序中嵌入汇编代码。


旁注 - ATT 与 Intel 汇编代码格式
我们的表述是 ATT(根据 “AT&T” 命名的,AT&T 是运营贝尔实验室多年的公司)格式的汇编代码,这是 GCC、OBJDUMP 和其他一些我们使用的工具的默认格式。其他一些编程工具,包括 Microsoft 的工具,以及来自 Intel 的文档,其汇编代码都是 Intel 格式的。这两种格式在许多方面有所不同。例如,使用下述命令行,GCC 可以产生 multstore 函数的 Intel 格式的代码:

linux> gcc -Og -S -masm=intel mstore.c

这个命令得到下列汇编代码:

1
2
3
4
5
6
7
multstore:
push rbx
mov rbx, rdx
call mult2
mov QWORD PTR [rbx], rax
pop rbx
ret

我们看到 Intel 和 ATT 格式在如下方面有所不同:

  • Intel 代码省略了指示大小的后缀。我们看到指令 push 和 mov,而不是 pushq 和 movq。
  • Intel 代码省略了寄存器名字前面的 ‘% ’ 符号,用的是 rbx,而不是 %rbx。
  • Intel 代码用不同的方式来描述内存中的位置,例如是 ‘QWORD PTR [rbx]’ 而不是 ‘(%rbx)’。
  • 在带有多个操作数的指令情况下,列出操作数的顺序相反。当在两种格式之间进行转换的时候,这一点非常令人困惑。

虽然在我们的表述中不使用 Intel 格式,但是在来自 Intel 和 Microsoft 的文档中,你会遇到它。

网络旁注 ASM:EASM - 把 C 程序和汇编代码结合起来

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

在 C 程序中插入汇编代码有两种方法。第一种是,我们可以编写完整的函数,放进一个独立的汇编代码文件中,让汇编器和链接器把它和用 C 语言书写的代码合并起来。第二种方法是,我们可以使用 GCC 的内联汇编(inline assembly)特性,用 asm 伪指令可以在 C 程序中包含简短的汇编代码。这种方法的好处是减少了与机器相关的代码量。

当然,在 C 程序中包含汇编代码使得这些代码与某类特殊的机器相关(例如 x86-64),所以只应该在想要的特性只能以此种方式才能访问到时才使用它。


3.3 数据格式

由于是从 16 位体系结构扩展成 32 位的,Intel 用术语 “字(word)” 表示 16 位数据类型。因此,称 32 位数为 “双字(double words)”,称 64 位数为 “四字(quad words)”。图 3-1 给出了 C 语言基本数据类型对应的 x86-64 表示。标准 int 值存储为双字(32 位)。指针(在此用 char * 表示)存储为 8 字节的四字,64 位机器本来就预期如此。x86-64 中,数据类型 long 实现为 64 位,允许表示的值范围较大。本章代码示例中的大部分都使用了指针和 long 数据类型,所以都是四字操作。x86-64 指令集同样包括完整的针对字节、字和双字的指令。

C声明 Intel数据类型 编代码后缀 大小(字节)
char 字节 b 1
short w 2
int 双字 l 4
long 四字 q 8
char * 四字 q 8
float 单精度 s 4
double 双精度 l 8

图 3-1 C 语言数据类型在 x86-64 中的大小。在 64 位机器中,指针长 8 字节

浮点数主要有两种形式:单精度(4 字节)值,对应于 C 语言数据类型 float;双精度(8 字节)值,对应于 C 语言数据类型 double。x86 家族的微处理器历史上实现过对一种特殊的 80 位(10 字节)浮点格式进行全套的浮点运算(参见家庭作业 2.86)。可以在 C 程序中用声明 long double 来指定这种格式。不过我们不建议使用这种格式。它不能移植到其他类型的机器上,而且实现的硬件也不如单精度和双精度算术运算的高效。

如图所示,大多数 GCC 生成的汇编代码指令都有一个字符的后缀,表明操作数的大小。例如,数据传送指令有四个变种:movb(传送字节)、movw(传送字)、movl(传送双字)和 movq(传送四字)。后缀 ‘l’ 用来表示双字,因为 32 位数被看成是 “长字(long word)”。注意,汇编代码也使用后缀 ‘l’ 来表示 4 字节整数和 8 字节双精度浮点数。这不会产生歧义,因为浮点数使用的是一组完全不同的指令和寄存器。

3.4 访问信息

一个 x86-64 的中央处理单元(CPU)包含一组 16 个存储 64 位值的通用目的寄存器。这些寄存器用来存储整数数据和指针。图 3-2 显示了这 16 个寄存器。它们的名字都以 %r 开头,不过后面还跟着一些不同的命名规则的名字,这是由于指令集历史演化造成的。最初的 8086 中有 8 个 16 位的寄存器,即图 3-2 中的 %ax 到 %bp。每个寄存器都有特殊的用途,它们的名字就反映了这些不同的用途。扩展到 IA32 架构时,这些寄存器也扩展成 32 位寄存器,标号从 %eax 到 %ebp。扩展到 x86-64 后,原来的 8 个寄存器扩展成 64 位,标号从 %rax 到 %rbp。除此之外,还增加了 8 个新的寄存器,它们的标号是按照新的命名规则制定的:从 %r8 到 %r15。


图3.2

如图中嵌套的方框标明的,指令可以对这 16 个寄存器的低位字节中存放的不同大小的数据进行操作。字节级操作可以访问最低的字节,16 位操作可以访问最低的 2 个字节,32 位操作可以访问最低的 4 个字节,而 64 位操作可以访问整个寄存器。
在后面的章节中,我们会展现很多指令,复制和生成 1 字节、2 字节、4 字节和 8 字节值。当这些指令以寄存器作为目标时,对于生成小于 8 字节结果的指令,寄存器中剩下的字节会怎么样,对此有两条规则:生成 1 字节和 2 字节数字的指令会保持剩下的字节不变;生成 4 字节数字的指令会把高位 4 个字节置为 0。后面这条规则是作为从 IA32 到 x86-64 的扩展的一部分而采用的。

就像图 3-2 右边的解释说明的那样,在常见的程序里不同的寄存器扮演不同的角色。其中最特别的是栈指针 %rsp,用来指明运行时栈的结束位置。有些程序会明确地读写这个寄存器。另外 15 个寄存器的用法更灵活。少量指令会使用某些特定的寄存器。更重要的是,有一组标准的编程规范控制着如何使用寄存器来管理栈、传递函数参数、从函数的返回值,以及存储局部和临时数据。我们会在描述过程的实现时(特别是在 3.7 节中),讲述这些惯例。

3.4.1 操作数指示符

大多数指令有一个或多个操作数(operand),指示出执行一个操作中要使用的源数据值,以及放置结果的目的位置。x86-64支持多种操作数格式(参见图3-3)。源数据值可以以常数形式给出,或是从寄存器或内存中读出。结果可以存放在寄存器或内存中。因此,各种不同的操作数的可能性被分为三种类型。

第一种类型是立即数(immediate),用来表示常数值。在ATT格式的汇编代码中,立即数的书写方式是‘$后面跟一个用标准C表示法表示的整数,比如,$-577或$0x1F。不同的指令允许的立即数值范围不同,汇编器会自动选择最紧凑的方式进行数值编码。

第二种类型是寄存器(register),它表示某个寄存器的内容,16 个寄存器的低位 1 字节、2 字节、4 字节或 8 字节中的一个作为操作数,这些字节数分别对应于 8 位、 16 位、32 位或64 位。在图3-3中,我们用符号r。来表示任意寄存器α,用引用 R[ra] 来表示它的值,这是将寄存器集合看成一个数组R,用寄存器标识符作为索引。

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


图3-3 操作数格式

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

3.4.2 数据传送指令

最频繁使用的指令是将数据从一个位置复制到另一个位置的指令。操作数表示的通用性使得一条简单的数据传送指令能够完成再许多机器中要好几条不同指令才能完成的功能。我们会介绍许多种不同的数据传送指令,它们或者源和目的类型不同,或者执行的转换不同,或者具有一些副作用不同。在我们的讲述中,把许多不同的指令划分成指令类,每一类的指令执行相同的操作,只不过操作数大小不同。

下图列出的是最简单形式的数据传送指令–MOV 类。这些指令把数据从源位置复制到目的的位置,不做任何变化。MOV 类由四条指令组成:movb、movw、movl 和 movq 。这些指令都执行同样的操作;主要区别在于它们操作的数据大小不同:分别是 1、2、4、8 字节。


图 3.4 简单的数据传送指令

源操作数指定的值是一个立即数,存储在寄存器中或者内存中。目的操作数指定一个位置,要么是一个寄存器,要么是一个内存地址,x86-64 加了一条限制,传送指令的两个操作数不能都指向内存位置。将一个值从一个内存位置复制到另一个内存位置需要两条指令–第一条指令将源值加载到寄存器中,第二条将寄存器值写入目的位置。参考图 3.2,这些指令的寄存器操作数可以是 16 个寄存器有标号部分中的任意一个,寄存器部分的大小必须与指令最后一个字符(‘b’,’w’,’l’或‘q’)指定的大小匹配。大多数情况中,MOV 指令指挥更新目的操作数指定的那些寄存器字节或内存位置。唯一的例外是 movl 指令以寄存器作为目的时,它会把该寄存器的高4位设置位0,造成这个例外的原因时 x86-64 采用的惯例,即任何为寄存器生成的32位值的指令都会把该寄存器的高位分置成 0。

下面的MOV指令示例给出了源和目的类型的五种可能的组合。记住,第一个时源操作数,第二个时目的操作数:


图3-4中记录的最后一条指令时处理64位立即数据的。常规的movq指令只能以表示32位补码数据的立即数作为源操作数,然后把这个值符号扩展得到64位的值,放到目的位置。movabsq指令能够以任意64位立即数值作为源操作数,并且只能以寄存器作为目的。

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


图 3-5 零扩展数据传送格式


图 3-6 符号扩展数据传送指令


旁注 立即数据传送如果改变目的寄存器

正如我们描述的那样,关于数据传送指令是否以及如果修改目的寄存器的高位字节有两种不同的方法。下面这段代码序列会说明其差别:

在接下来的讨论中,我们使用十六进制表示。在这个例子中,第一行的指令把寄存器%rax 初始化为位模式 0011223344556677。剩下的指令的源操作数值时立即数值-1。回想-1的十六进制表示如FF···F,这里F的数量时表达中字节数量的两倍。因此movb指令(第二行)把%rax的低位字节设置位FF,而movw指令(第三行)把低2位字节设置为FFFF,剩下的字节保持不变。movl指令(第四行)将低4个字节设置为FFFFFFFF,同时把高位4字节设置为00000000。最后movq指令(第五行)把整个寄存器设置为FFFFFFFFFFFFFFFF。

注意图 3-5 中并没有一条明确的指令把 4 字节源值扩展到 8 字节目的。这样的指令逻辑上应该被命名为 movzlq,但是没有这样的指令。不过,这样的数据传送可以用以寄存器为目的的movl指令来实现。

3.4.3 数据传送示例

作为一个数据传送指令的代码示例,考虑图 3-7 中所示的数据交换函数,既有 c 代码 也有 gcc 产生的汇编代码。


图 3-7 exchange 函数的 C 语言和汇编代码。寄存器 %hdi 和 %rsi 分别存放参数 xp 和 y

如图 3-7 b 所示,函数 exchange 由三条指令实现:两个数据传送(movq),加上一条返回函数被调用点的指令(ret)。我们会在 3.7 节中讲述函数调用和返回的细节。在此之前,知道参数通过寄存器传递给函数就足够了。我们对汇编diamond添加注释来加以说明。函数通过把值存储在寄存器 %rax 或该寄存器的某个地位部分中返回。

当过程开始执行时,过程参数 xp 和 y 分别存储寄存器 %rdi 和 %rsi 中。 然后,指令2从内存中的xp指向的内存位置,直接实现了操作 *xp=y。这个例子说明了如何用MOV指令从内存中读值到寄存器(第二行),如何从寄存器写到内存(第三行)。

关于这段汇编代码有两点值得注意。首先,我们看到C语言中的所谓的“指针” 其实就是地址。间接引用指针就是将该指针放在一个寄存器中,然后再内存引用中使用这个寄存器。其次,像x这样的局部变量通常是保存再寄存器中,而不是内存中。访问寄存器比访问内存要快的多。

3.4.4 压入和弹出栈数据

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


图3-8 入栈和出栈指令

pushq 指令的功能时把数据压入到栈上,而popq指令是弹出数据。这些指令都只有一个操作数–压入的数据源和弹出的数据目的。

将一个四字值压入栈中,首先要将栈指针减8,然后将值写到新的栈顶地址。因此指令 pushq %rbp 的行为等价于下面两条指令:

1
2
subq $8,%rsp          Decrement stack point
mvoq %rbp,(%rsp) Stroe %rbp on stack

它们之间的区别分别是在机器代码中pushhq 指令编码为 1 个字节,而上面那两条指令一共需要8个字节。图 3-9 中前两栏给出的是,当 %rsp 为 0x108,%rax 为0x123 时,执行指令 pushq %rax的效果。首先 %rap 会减8,得到 0x100,然后会将 0x123 存放到内存地址 0x100 处。


图 3-9 栈操作说明

弹出一个四字的操作包括从栈顶位置读出数据,然后将栈指针加8.因此,指令popq %rax 等价于下面两条指令:

1
2
movq (%rsp),%rax     Read %rax from stack
addq $8,%rsp Increment stack pointer

图3.9的第三栏说明的时在执行完 pushq 后立即执行指令 popq %rdx 的效果。现从内存中读出值 0x123 ,再写到寄存器 %rdx 中,然后,寄存器 %rap 的值将增加回到 0x108。如图中所示的位置,值0x123任然会保持再内存位置 0x100中,直到被覆盖。无论如何,%rsp指向的地址总时栈顶。

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

3.5 算数和逻辑操作

图 3-10 列出了 x86-64 的一些整数和逻辑操作。大多数操作分成了指令类,这些指令类有各种带不同大小操作数的变种(只有leaq没有其他大小的变种)。例如,指令类ADD由四条加法指令组成:addb、addw、addl、和 addq, 分别是字节加法、字加法、双字加法和四字加法。事实上,给出的每个指令类都有对这四种不同大小数据的指令。这些操作被分为四组:加载有效地址、一元地址、二元操作和移位。二元操作有两杆操作数,而一员操作有一个操作数。这些操作数的描述方法与 3.4 节中所讲的一样。


图 3-10 整数算术操作

3.5.1 加载有效地址

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

为了说明 leaq 在编译出的代码中的使用,看看下面这个C程序:

1
2
3
4
long sacle(long x, long y, long z){
long t = x + 4 * y + 12 * z;
return t;
}

编译时,该函数的算术运算以三条leaq 指令实现,就像右边注释说明的那样:

1
2
3
4
5
6
7
long sacle(long x, long y, long z)
x in %rdi, y in %rsi, z in %rdx
scale:
leaq (%rdi,rdi,4),%rax x + 4*y
leaq (%rdx,rdx,2),%rdx z + 2*z = 3*z
leaq (%rax,%rdx,4),%rax (x+4*y) + 4*(3*z) = x + 4*y + 12*z
ret

leaq 指令能执行加法和有限形式的乘法,在编译如上简单的算术表达式时,时很有用处的。

3.5.2 一元和二元操作

第二组中的操作时一元操作,只有一个操作数,即时源又是目的。这个操作数可以是一个寄存器,也可以是一个内存位置。比如说,指令 incq(%rsp) 会使栈顶的8字节元素加1。这种语法让人想起C语言中的赋值运算符(++)和减1运算符(–)。

第三组是二元操作,其中,第二个操作数即是源又是目的。这种语法让人想起C语言中的赋值运算符,例如 x-=y。不过,要注意,源操作数是第一个,目的操作数是第二个,对于不可交换操作来说,这看上去很奇特。例如,指令 subq %rax,%rdx 使寄存器 %rdx 的值减去 %rax 中的值。第一个操作数可以是立即数、寄存器或者是内存位置。第二个操作数可以是寄存器或者是内存位置。注意,当第二个操作数为内存地址时,处理器必须从内存读出值,执行操作,再把结果写回内存。

3.5.3 移位操作

最后一组是移位操作,先给出位移量,然后第二项给出的是要移位的数。可以进行算术逻辑右移。移位量可以是一个立即数,或者放在单字节寄存器%cl中。原则上来说,1个字节的一位量使得移位量的编码范围可以达到255.x86-64中,移位操作对w位长的数据值进行操作,移位量是由 %cl 寄存器的低m位决定的,这里 2的m次方=w .高位会被忽略。所以例如当寄存器%cl的十六进制值为0xFF 时,指令 salb 会移7位,salw 会移15位,sall会移31位,而salq会移63位。

如图 3-10 所示,左移指令由两个名字:SAL 和 SHL 。两者的效果时一样的,都是将右边填上0。右移指令不同,SAR执行算术移位,而SHR执行逻辑移位。移位操作的目的操作数可以时一个寄存器或者时一个内存位置。图 3-10 中用 >>(a) 和 >>(L) 来表示不同的右移运算。

3.5.5 特殊的算术操作

正如我们在2.3节中看到的,两个 64 位有符号或无符号整数相乘得到的乘积需要 128位来表示。x86-64 指令集对128位 (16字节)数的操作提供有限的支持。延续字(2字节)、双字节(4字节)和四字(8字节)的命名惯例,Intel把16字节的数称为八字(oct word)。图 3-12 描述的时支持产生两个 64 位数字的全128位乘积以及整数除法的指令。


图 3-12 特殊的算术操作

imulq 指令有两种不同的形式。其中一种,如图 3-10 所示,是 IMUL 指令类中的一种。这种形式的 imulq 指令是一个“双操作数” 乘法指令。它从两个 64 位操作数产生一个64位乘积。

此外,x86-64 指令集还提供了两条不同的“单操作数”乘法指令,以计算两个 64 位值得全 128 位乘积 ——一个是无符号得乘法(mulq),而另一个是补码乘法(imulq)。这两条指令都要求一个参数必须存在寄存器 %rax中,而另一个作为指令得源操作数给出。然后乘积存放在寄存器 %rdx (高64位)和 %rax (低64位)中。虽然imulq 这个名字可以用于两个不同得乘法操作,但是汇编器能够通过计算操作数得数目,分辨出想用那条指令。

3.6 控制

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

与数据相关的控制流是实现现有条件行为的更一般和更常见的方法,通常,C语言中的语句和机器代码中的指令都是按照它们在程序中出现的次序,顺序执行的。用jump指令可以改变一组机器代码指令的执行顺序,jump指令指定控制应该被传递到程序的某个其他部分,可能是依赖于某个测试的结果。编译器必须产生构建这种低级机制基础之上的指令序列,来实现C语言的控制结构。

3.6.1 条件码

除了整数寄存器,CPU还维护这一组单个位的条件码(condition code)寄存器,它们描述了最近的算术或逻辑操作的属性。可以检测这些寄存器来执行条件分支指令。最常用的条件码有:
CF: 进位标志。最近的操作使最高位产生了进位。可用来检查无符号操作的溢出。
ZF: 零标志。最近的操作得出的结果为0。
SF:符号标志。最近的操作得到的结果为负数。
OF: 溢出标志。最近的操作数导致一个补码溢出–正溢出或负溢出。

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

3.6.2 访问条件码

条件码通常不会直接读取,常用的使用方法有三种:

  1. 可以根据条件码的某种组合,将一个字节设置为0或者1
  2. 可以条件跳转到程序的某个其他的部分,
  3. 可以有条件地传送数据。

对于第一种情况,图3-14 中描述的指令根据条件码的某种组合,将一个字节设置为0或者1.我们将这一类指令称为 SET 指令;它们之间的区别就在于它们考虑的条件码组合是什么,这些指令名字的不同后缀指明了它们所考虑的条件码的组合。这些指令的后缀表示不同的条件而不是操作数大小,了解这一点很重要。例如,指令 setl 和 setb 表示 “小于时设置(set less)” 和 “低于时设置(set below)”,而不是“设置长字(set long word)”和 “设置字节(set byte)”。

一条 SET 指令的目的操作数是低单字节寄存器元素(图 3-2)之一,或是一个字节的内存位置,指令会将这个字节设置成0或者1。为了得到一个32位或64位结果,我们必须对高位清零。一个计算C语言表达式 a<b 的典型指令序列如下所示,这里 a 和 b 都是long 类型:


图 3-14 SET指令

3.6.3

正常执行的情况下,指令安装它们出现的顺序一条一条地执行。跳转(jump)指令会导致切换到程序中一个全新的位置。在汇编代码中,这些跳转的目的地通常用一个标号(lavel)指明。考虑下面的汇编代码序列(完全是人为编造的):

1
2
3
4
5
xorl %eax,%eax Set %eax to 0
jmp .L1 Goto .L1
movl (%eax),%edx Null pointer dereference
.L1:
popl %edx

指令jmp .L1 会导致程序跳过 movq 指令,而从 pooq指令开始继续执行。在产生目标代码文件时,汇编器会确定所有带标号指令的地址,并将跳转目标编码为跳转指令的一部分。

图3-15 列举了不同的跳转指令。jmp指令时无条件跳转。它可以时直接跳转,即跳转目标是作为指令的一部分编码的;也可以是间接跳转,即跳转目标是从寄存器或内存位置中读出的。汇编语言中,直接跳转是给出一个标号作为跳转目标的,例如上面所示的代码中的标号 “.L1” 。间接跳转的写法是“*”后面跟一个操作数指示符,使用图 3-3 中描述的内存操作数格式中的一种。举个例子,指令
jmp *%rax
用寄存器 %rax 中的值作为跳转目标,而指令
jmp *(%rax)
以 %rax 中的值作为读地址,从内存中读出跳转目标。


图3-15 jump指令

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

3.6.4 跳转指令和编码

在汇编代码中,跳转目标用符号标号书写。汇编器,以及后来的链接器,会产生跳转目标的适当编码。跳转指令有几种不同的编码,但是最常用都是PC相对的(PC-relative)。也就是,它们会将目标指令的地址与紧跟在跳转指令后面那条指令的地址之间的差作为编码。这些地址偏移量可以编码为1、2或4字节。第二种编码方法是给出“绝对”地址,用4个字节之间指定目标。汇编器和链接器会选择适当的跳转目的编码。

当执行PC相对寻址时,程序计数器的值是跳转指令后面的那条指令的地址,而不是跳转指令本身的地址。这种惯例可以追溯到早期的实现,当时的处理器会将更新程序计数器作为执行一条指令的第一步。

3.6.5 用条件控制来实现条件分支

将条件表达式和语句从C语言翻译成机器代码,最常用的方式式结合有条件和无条件跳转。


图3.16 条件语句的汇编

3.6.6 用条件传送来实现条件分支

实现条件操作的传统方法是通过使用控制的条件转移。当条件满足时,程序沿着一条执行路径执行,而当条件不满足时,就走另一条路径。这种机制简单而通用,但是在现代处理器上,它困难会非常低效.
一种替代的策略是使用数据的条件转移。这种方法计算一个条件操作的两种结果,然后再根据条件是否满足从中选取一个。只有再一些受限制的情况中,这种策略才可行,但是如果可行,就可以用一条简单的条件传送指令来实现它,条件传送指令更符合现代处理器的性能特性。


图 3-17 使用条件赋值的条件语句的汇编

处理器通过使用流水线(pipelining)来获得高性能,再流水线中,一条指令的处理要经过一系列的阶段,每个阶段执行所需操作的一小部分(例如,从内存取指令、确定指令类型、从内存读数据、执行算术运算、向内存写数据,以及更新程序计算器)。这种方法通过重叠连续指令的捕捉来获得高性能,例如,在取一条指令的同时,执行它前面一条执行的算术运算。要做到这一点,要求能够事先确定要执行的指令序列,这样才能保持流水线中充满待执行的指令。当机器遇到条件跳转(也称为“分支”)时,只有当条件分支求值完成之后,才能决定分支往那边走。处理器采用非常精密的分支预测逻辑来猜测每条跳转指令是否会执行。只要他的猜测还比较靠谱(现代微处理器设计试图达到百分之九十以上的成功率),指令流水线中就会充满着指令。另一方面,错误的预测一个跳转,要求处理器丢掉它为该跳转指令会所有指令已做的工作,然后再开始用从正确位置起始的指令取填充流水线。正如我们会看到的,这样一个错误预测会招致很严重的惩罚,浪费大约 15~30个时钟周期,导致程序性能严重下降。


图3-18 条件传送指令。当传送条件满足时,指令把源值S复制到目的R。

总的来说,条件传送提供了一种用条件控制来失效条件操作的替代策略。它们只能用于非常受限的情况,但是这种情况还是相当常见的,而且与现代处理器的运行方式更默契。

3.6.7 循环

C语言提供了多种循环结构,即do-while、while 和 for。汇编中没有相应的指令存在,可以用条件测试和跳转组合起来实现循环的效果,GCC和其他汇编器产生的循环代码主要基于两种基本的循环模式。

1.do-while 循环

do-while 语句的通用形式如下:

1
2
3
do
body-statement
while (test-expr);

这个循环的效果就是重复执行 body-statement,对 test-expr 求助,如果求值的结果为非零,那就继续循环。可以看到,body-statement 至少会执行一次。

这种通用形式可以被翻译成如下所示的条件和goto语言:

1
2
3
4
5
loop:
body-statement
t=test-expr;
if (t)
goto loop

也就是说每次循环,程序都会执行循环体里的语句,然后执行测试表达式。如果测试为真,就回去再执行一次循环。


图3-19 阶乘程序的do-while版本的代码

2.while 循环

while 语句的通用形式如下:

1
2
while(test-expr)
body-statement

在第一次执行 body-statement 之前,会对 test-expr 求值,循环有可能就中止了。

第一种翻译方法,跳转到中间(jump to middle),它执行一个无条件跳转跳到循环结尾处的测试,以此来执行初始的测试。

1
2
3
4
5
6
7
  goto test;
loop:
body-statement
test:
t=test-expr;
if (t)
goto loop;


图3-20 中间翻译的阶乘算法while版本

第二种翻译方法,guarded-do,首先用条件分支,如果条件不成立就跳过循环,把代码变换为do-while 循环。当使用较高优化等级编译时,例如使用命令行选项-O1,GCC会采用这种策略。

把通用的while循环格式翻译成 do-while循环:

1
2
3
4
5
6
t = test-expr;
if (t)
goto done;
do body-staement
while (test-expr);
dons;

相应地,还可以把它翻译成 goto 代码如下:

1
2
3
4
5
6
7
8
9
t = test-expr
if (!t)
goto done;
loop;
body-statement;
t = test-expr;
if (t)
goto loop;
done;

利用这种实现策略,编译器常常可以优化初始化的测试,例如认为测试条件总是满足。

guarded=do的阶乘算法while版本的示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
int fib_w(int n)
{
int i = 1;
int val = 1;
int nval = 1;

while (i < n)
{
int t = val + nval;
val = nval;
nval = t;
i++;
}
return val;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
int fib_w_goto(int n)
{
int val = 1;
int nval = 1;
int nmi, t;

if (val >= n)
goto done;
nmi = n - 1;

loop:
t = val + nval;
val = nval;
nval = t;
nmi--;
if (nmi)
goto loop;

done:
return val;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
movl 8(% ebp), % eax          Get n
movl $1, % ebx Set val to 1
movl $1, % ecx Set nval to 1
cmpl % eax, % ebx Compare val : n
jge.L9 If >= goto done :
leal - 1(% eax), % edx nmi = n - 1
.L10 : loop :
leal(% ecx, % ebx), % eax Compute t = nval + val
movl % ecx, % ebx Set val to nval
movl% eax, % ecx Set nval to t
decl% edx Decrement nmi
jnz.L10 if != 0, goto loop :
.L9 : done :

3.for 循环

for 循环的通用形式如下:

1
2
for (init-expr; test-expr; update-expr)
body-statement

这样一个循环的行为与下面这段使用 while 循环的代码的行为一样:

1
2
3
4
5
init-expr;
while (test-expr) {
body-statement
update-expr;
}

GCC为 for 循环产生的代码是 while 循环的两种翻译之一,这取决于优化的等级。也就是,跳转到中间策略会得到如下goto 代码:

1
2
3
4
5
6
7
8
9
  init-expr;
goto test;
loop:
body-systement;
update-expr;
test:
t = text-expr
if (t)
goto loop;

而guarded-do 策略得到:

1
2
3
4
5
6
7
8
9
10
11
  init-expr;
t = test-expr;
if (!t)
goto done;
loop:
body-statement
update-expr;
t = test-expr;
if (t)
goto loop;
done:

3.6.8 switch 语句

switch 语句可以根据一个整数索引值进行多重分支。通过跳转表(jump table)这种数据结构使得实现更加高效。跳转表是一个数组,表项i是一个代码段地址,这个代码段实现当开关索引值等于i时程序应该采取的动作。程序代码用开关索引值来执行一个跳转表内的数组引用,确定跳转指令的目标。GCC根据开关情况的数量和开关情况的稀疏程度来翻译开关语句。

下面是一个 switch 示例,这个例子比较特殊,包括情况标号(case lable)跨过一个不连续的区域(101、105),有些情况有多个标号(104,106),而有些情况会落入其他情况之中(102),因为后面没有 break。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
int switch_eg(int x)
{
int result = x;

switch (x)
{

case 100:
result *= 13;
break;

case 102:
result += 10;
/* Fall through */

case 103:
result += 11;
break;

case 104:
case 106:
result *= result;
break;

default:
result = 0;
}

return result;
}

这段代码的 C 语言描述如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
code* jt[7] = {
loc_A, loc_def, loc_B, loc_C,
loc_D, loc_def, loc_D
};

int switch_eg_impl(int x)
{
unsigned xi = x - 100;
int result = x;

if (xi > 6)
goto loc_def;

/* Next goto is not legal C */
goto jt[xi];

loc_A: /* Case 100 */
result *= 13;
goto done;

loc_B: /* Case 102 */
result += 10;
/* Fall through */

loc_C: /* Case 103 */
result += 11;
goto done;

loc_D: /* Cases 104, 106 */
result *= result;
goto done;

loc_def: /* Default case*/
result = 0;

done:
return result;
}

编译 switch_eg 时产生的汇编代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
//Set up the jump table access
leal - 100(% edx), % eax Compute xi = x - 100
cmpl $6, % eax Compare xi : 6
ja.L9 if > , goto done
jmp* .L10(, % eax, 4) Goto jt[xi]

Case 100
.L4: loc A:
leal(% edx, % edx, 2), % eax Compute 3 * x
leal(% edx, % eax, 4), % edx Compute x + 4 * 3 * x
jmp.L3 Goto done

Case 102
.L5: loc B:
addl $10, % edx result += 10, Fall through

Case 103
.L6: loc C:
addl $11, % edx result += 11
jmp.L3 Goto done

Cases 104, 106
.L8: loc D:
imull % edx, % edx result *= result
jmp.L3 Goto done

Default case
.L9: loc def:
xorl % edx, % edx result = 0

Return result
.L3: done:
movl % edx, % eax Set result as return valu

执行 switch 语句的关键步骤是通过跳转表来访问代码位置。

C 代码将跳转表声明位一个有7个元素的数组,每个元素都是一个执行代码位置的指针,这样元素跨越 index 的值 06 ,对应于 n 的值 100106 .

在汇编代码中,跳转表用以下声明表示,我们添加一些注释:

1
2
3
4
5
6
7
8
9
10
.section .rodata
.align 4 Align address to multiple of 4
.L10:
.long .L4 Case 100: loc_A
.long .L9 Case 101: loc_def
.long .L5 Case 102: loc_B
.long .L6 Case 103: loc_C
.long .L8 Case 104: loc_D
.long .L9 Case 105: loc_def
.long .L8 Case 106: loc_D

3.7 过程

过程是软件中一种很重要的抽象。它提供了一种封装代码的方式,用一组指定的参数和一个可选的返回值实现了某种功能。然后,可以在程序中不同的地方调用这个函数。不同的编程语言中,过程的形式多样:函数(function)、方法(method)、子例程(subroutine)、处理函数(handler)等等。

3.7.1 运行时栈

C 语言过程调用机制的一个关键特性在于使用了栈数据结构提供的先进后出的内存管理原则。


图3-25 通用的栈帧结构

3.7.3

将控制从函数 P 转移到函数 Q 只需要简单地把程序计数器(PC)设置为Q的代码的起始位置。不过,当稍后从 Q 返回的时候,处理器必须记录好它需要继续P的执行的代码位置。在 x86-64 机器中,这个信息时用指令 call Q 调用过程Q来记录的。该指令会把地址 A 压入栈中,并将 PC 设置为 Q 的起始地址。压入的地址A被称为返回地址,是紧跟在 call 指令后面的那条指令的地址。对应的指令 ret 会从栈中弹出地址 A,并把 PC 设置为A。
下表给出的是Call和ret指令的一般形式:

指令 描述
call Label Procedure Call
call *Operand Procedure Call
leave Prepare stack for return
ret Return from cal

3.7.3 数据传送

当调用一个过程时,除了要把控制传递给它并在过程返回时再传递回来之外,过程调用还可能包括把数据作为参数传递,而从过程返回还有可能包括返回一个值。X86-64中,大部分过程间的数据传送都是通过寄存器实现的。

如图 3-28 所示。会根据参数再参数列表中的顺序为它们分配寄存器。可以通过64位寄存器适当的部分访问小于64位的参数。例如,如果第一个参数是32位的,那么可以用 %edi 来访问它。


图 3-28 传递函数参数的寄存器

如果一个函数有大于6个整形参数,超出6个的部分就要通过栈来传递.假设过程 P 调用过程 Q ,有 n 个整形参数, 且 n > 6。 那么 P 的代码分配的栈帧必须能容纳 7 到 n 号参数的存储空间,如图 3-25 所示。要把参数 1-6 复制到对应的寄存器, 把参数 7-n 放到栈上,而参数 7 位于栈顶。通过栈传递参数时,所有的数据大小都向 8 的倍数对齐。

3.7.4 栈上的局部存储

到目前为止我们看到的大多数过程示例都不需要超过寄存器大小的本地存储区域。不过有些时候,局部数据必须存放在内存中,常见的情况包括:

  • 寄存器不足够存放所有的本地数据。
  • 对一个局部变量使用地址运算符 “&”,因此必须能够为它产生一个地址。
  • 某些局部变量是数组或结构,因此必须能够通过数组或者结构的引用被访问到。

一般来说,过程通过减少栈指针在栈上分配空间。分配的结果作为栈帧的一部分,标号为“局部变量”,如图 3-15 所示。

3.7.5 寄存器中的局部存储空间

寄存器组时唯一被所有过程共享的资源。虽然在给定时刻只有一个过程时活动的,我们任然必须确保当一个过程(调用者)调用另一个过程(被调用者)时,被调用者不会覆盖调用者烧毁会使用的寄存器。为此 x86-64 采用了一组统一的寄存器使用管理,所有的过程(包括程序库)都必须遵循。

根据惯例,寄存器 %rbx、%rbp 和 %r12~%r15 被划分为被调用者保护寄存器。当过程 P 调用过程 Q 时,Q 必须保存这些寄存器的值,保证它们的值在 Q 返回到 P 时要与 Q 被调用时是一样的。过程 Q 保存了一个寄存器的值不变,要么就是根本不去改变它,要么就是把原始值压入栈中,改变寄存器的值,然后在返回前从栈中弹出旧值。压入寄存器的值会在栈帧中创建标号为 “保存的寄存器” 的一部分,如图 3-25 所示。有了这条惯例,P 的代码就能安全的把值存在被调用者保存寄存器中,调用 Q ,然后继续使用寄存器中的值,不同担心值被破坏。

所有其他的寄存器,除了栈指针 %rsp ,都分类为调用者保存寄存器。者就意味着任何函数都能修改它们,可以这样来理解 “调用者保存” 这个名字:过程 P 在某个此类寄存器中有局部数据,然后调用过程 Q 。因为 Q 可以任意修改这个寄存器,所以在调用之前首先保存号这个数据是 P(调用者) 的责任。


图 3-34 展示被调用者保存寄存器使用的代码

图 3-34 使用了两个被调用保存寄存器:%rbp 保存 x 和 %rbx 保存计算出来的 Q(y) 的值。在函数的开头把这个两个寄存器的值保存到栈中,在函数的结尾 把他们从栈中弹出,恢复者两个被调用者保存寄存器的值。

3.7.6 递归过程

前面已经描述的寄存器和栈的使用惯例使得 x86-64 过程能够递归的调用它们自身。每个过程调用在栈中都有它自己的私有空间,因此多个未完成调用的局部变量不会相互影响。此外,栈的原则很自然地提供了适当的策略,当过程被调用时分配局部存储,当返回时释放存储。


图 3-35 递归的阶乘程序的代码

3.8 数组分配和访问

C 语言中的数组时一种将标量数据聚集成更大数据类型的方式。C 语言失效数组的方式非常简单,因此很容易翻译成机器代码。C 语言的一个不同寻常的特点时可以产生指向数组中元素的指针,并对这些指针进行运算。在机器代码中,这些指针会被翻译成地址计算。

优化编译器非常上虞简化数组索引使用的地址计算。不过这使得C 代码和它到机器代码的翻译之间的对应关系有些难以理解。

3.8.1 基本原则

对于数据类型 T 和整形常数 N ,声明如下:

T A[N];

起始位置表示为 Xa。这个声明有两个效果。首先,它在内存中分配一个 L*N 字节的连续区域,这里 L 是数据类型 T 的大小(单位是字节)。其次,它引入了标识符 A,可以用A 来作为指向数组开头的指针,这个指针的值就是 Xa ,可以用 0N-1 的整数索引来访问该数组元素。数组元素 i 会被放在地址为 Xa~ + L·i 的地方。

X86-64 的内存引用指令可以用来简化数组访问。例如,假设 E 是一个 int 型的数组,而我们想计算 E[i], 在此, E 的地址存放在寄存器 %rdx 中, 而 i 存放在寄存器 %rcx 中,然而,指令
movl (%rdx,%rcx,4),%eax
会执行地址计算 XE+4i ,读这个内存位置的值,并将结果存放到寄存器 %eax 中,允许的伸缩因子 1,2,4,和 8 覆盖了所有基本简单数据类型的大小。

3.8.2 指针运算

C 语言允许对指针进行运算,而计算出来的值会根据该指针引用的数据类型的大小进
行伸缩。也就是说,如果 P 是一个指向类型为 T 的数据的指针,P 的值为 Xp, 那么表达式
P+i 的值为 Xp+L * i, 这里 L 是数据类型 T 的大小。

单操作数操作符‘&’和 ‘*’ 可以产生指针和间接引用指针。

扩展一下前面的例子,假设整型数组 E 的起始地址和整数索引 i 分别存放在寄存器
%rdx 和%rcx 中。下面是一些与 E 有关的表达式。我们还给出了每个表达式的汇编代码实现,结果存放在寄存器%eax(如果是数据)或寄存器%rax (如果是指针)中。

表达式 类型 汇编代码
E int* XE movq %rdx,%rax
E[0] int M[XE] movl (%rdx),%rax
E[i] int M[XE +4i] movl (%rdx,%rcx,4),%eax
&E[2] int* XE +8 leaq 8(rdx),%rax
E+i-1 int* XE +di-4 leaq -4(%rdx,%rcx,4),%rax
*(E+i-3) int M[XE +4i-12] movl -12(%rdx,%rcx,4),%eax
&E[i]-E long i movq %rac,%rax

3.8.4 嵌套的数组

当我们创建数组的数组时,数组分配和引用的一般原则也是成立的。例如,声明
int A[5][3];
等价于下面的声明

1
2
typedef int row3_t[3]
row3_t A[5];

数据类型 row3_t 被定义为一个 3 个整数的数组。数组 A 包含 5 个这样的元素,每个元素需要 12 个字节$存储 3 个整数。整个数组的大小就是 4X5X3=60 字节。

数组 A 还可以被看成一个 5 行 3 列的二维数组,用 A[0][0]到 A[4][2]来引用。数组元素在内存中按照“行优先”的顺序排列,意味着第 0 行的所有元素,可以写作 A[0],后面跟着第 1 行的所有元素(A[l]), 以此类推,如图 3-36 所示。


图 3-36 按照行优先顺序存储的数组元素

这种排列顺序是嵌套声明的结果。将 A 看作一个有 5 个元素的数组,每个元素都是 3 个 int 的数组,首先是 A[0],然后是 A[l],以此类推。

要访问多维数组的元素,编译器会以数组起始为基地址,(可能需要经过伸缩的)偏移量为索引,产生计算期望的元素的偏移量,然后使用某种 MOV 指令。通常来说,对于一个声明如下的数组:
T D[R][C];
它的数组元素 D[i][j]的内存地址为
&-D[i][j] = XD +L(C • i+j)
这里,L 是数据类型:r 以字节为单位的大小。

3.8.4 定长数组

C 语言编译器能够优化定长多维数组上的操作代码。这里我们展示优化等级设置为-01 时 GCC 采用的一些优化。假设我们用如下方式将数据类型 fix_matrix 声明为 16X16
的整型数组:

1
2
#define N 16
typedef int fix_matrix[N][N];

图 3-37a 中的代码计算矩阵 A 和 B 乘积的元素 i, k,即 A 的行 i 和 B 的列 k 的内积。GCC产生的代码(我们再反汇编成C),如图 3-37b 中函数 fix_prod_ele_opt 所示。这段代码包含很多聪明的优化。它去掉了整数索引 j ,并把所有的数组引用都转换成了指针间接引用,其中包括(1)生成一个指针,命名未Aptr,指向 A 的行 i 中连续的元素;(2)生成一个指针,命名为Bptr,指向 B 列 k 中连续的元素;(3)生成一个指针,命名为Bend,当需要终止该循环时,它会等于 Bptr 的值。Bend的值时假想中B的列 j 的第(n+1)个元素的地址,由 C 表达式 &B[N][K]给出。


图 3-37 原始的和优化过的代码

下面给出的是 GCC 为函数 fix_pryd_ele 生成的这个循环的实际汇编代码。我们看
到 4 个寄存器的使用如下:%eax 保存 result,%rdi 保存 Aptr,%rcx 保存 Bptr,而%rsi 保存 Bend。


图 3-38 实际汇编代码

3.8.5 变长数组

历史上,C 语言只支持大小在编译时就能确定的多维数组(对第一维可能有些例外h
程序员需要变长数组时不得不用 malloc 或 calbc 这样的函数为这些数组分配存储空间,而且不得不显式地编码,用行优先索引将多维数组映射到一维数组,如公式(3.1)所示。ISOC99 时引人了一种功能,允许数组的维度是表达式,在数组被分配的时候才计算出来。在变长数组的 C 版本中,我们可以将一个数组声明如下:
int A[exprl] [expr2]

它可以作为一个局部变量,也可以作为一个函数的参数,然后在遇到这个声明的时候,通
过对表达式 exprl 和 expr2 求值来确定数组的维度。因此,例如要访问 数组的元素我们可以写一个如下的函数:

1
2
3
int var_ele(long n, int A[N][N], long i, long j) {
return A[i][j];
}

参数 n 必须在参数 A[n][n]之前,这样函数就可以在遇到这个数组的时候计算出数组的维度。

3.9 异质的数据结构

C 语言提供了两种将不同类型的对象组合到一起创建数据类型的机制:结构(structure), 用关键字 struct 来声明,将多个对象集合到一个单位中;联合(union),用关键字 union 来声明,允许用几种不同的类型来引用一个对象。

3.9.1 结构

C语言的 struct 声明创建一个数据类型,将可能不同类型的对象聚合到一个对象中。用名字来引用结构的各个组成部分。类似于数组的实现,结构的所有组成部分都存放在内存中一段连续的区域内,而指向结构的指针就是结构第一个字节的地址。编译器维护关于每个结构类型的信息,指示每个字庚(field)的字节偏移。它以这些偏移作为内存引用指令中的位移,从而产生对结构元素的引用。

示例:

1
2
3
4
5
6
struct rec {
int i;
int j;
int a[2];
int *p;
}

这个结构包括 4 个字段:两个 4 字节 int 一个由两个类型为 int 的元素组成的数组和一个 8 字节整型指针,总共是 24 个字节:

1
2
3
0    4    8            16        24
| | | | |
| i | j | a[0] | a[1] | p |

可以观察到,数组 a 是嵌入到这个结构中的。上图中顶部的数字给出的是各个字段相对于结构开始处的字节偏移。
结构的各个字段的选取完全是在编译时处理的。机器代码不包含关于字段声明或字段名字的信息。

3.9.2 联合

联合提供了一种方式,能够避免 C 语言的类型系统,允许以多种类型来引用一个对象。联合声明的语法和结构体的语法意义,只不过语义相差比较大。它们是用不同的字段来引用相同的内存块。
考虑下面的声明:

1
2
3
4
5
6
7
8
9
10
11
struct S3{
char c;
int i[2];
double v;
};

union U3{
char c;
int i[2];
double v;
};

在一台 X86-64 Linux 机器上编译时,字段的偏移量、数据类型 S3 和 U3的完整大小如下

类型 c i v 大小
S3 0 4 16 24
U3 0 0 0 8

对于类型 union U3 * 的指针 p, p-> c,p-> i[0] 和 p-> v 引用的都是数据结构
的起始位置。还可以观察到,一个联合的总的大小等于它最大字段的大小。

在一些下上文中,联合十分有用。但是,它也能引起一些讨厌的错误,因为它们绕过了 C 语言类型系统提供的安全措施。一种应用情况是,我们事先知道对一个数据结构中的两个不同字段的使用是互斥的,那么将这两个字段声明为联合的一部分,而不是结构的一部分,会减小分配空间的总量。

3.9.3 数据对齐

许多计算机系统对基本数据类型的合法地址做出了一些限制,要求某种类型对象的地址必须是某个值 K(通常是 2、4 或 8)的倍数。这种对齐限制简化了形成处理器和内存系统之间接口的硬件设计。
对齐原则是任何 K 字节的基本对象的地址必须是 K 的倍数。

3.10 在机器级程序中将控制与数据结合起来

到目前为止,我们已经分别讨论机器级代码如何实现程序的控制部分和如何实现不同的数据结构。在本节中,我们会看看数据和控制如何交互。

3.10.1 理解指针

指针是 C 语言的一个核心特色。它们以一种统一方式,对不同数据结构中的元素产生引用。指针和它们映射到机器代码的关键原则。

  • 每个指针都对应一个类型。这个类型表明该指针指向的是哪一类对象。
  • 每个指针都有一个值。特殊的 NULL(O)值表示该指针没有指向任何地方。
  • 指针用‘&’运算符创建。因为 leaq 指令是设计用来计算内存引用的地址的,& 运算符的机器代码实现常常用这条指令来计算表达式的值。
    • 操作符用于间接引用指针。
  • 数组与指针紧 密联系。一个数组的名字可以像一个指针变量一样引用(但是不能修改)。
  • 将指针从一种类型强制转换成另一种类型,只改变它的类型 ,而不改变它的值。强制类型转换的一个效果是改变指针运算的伸缩。
  • 指针也可以指向函数。

3.10.2 应用:使用 GDB 调试器

图 3-39 给出了一些 GDB 命令的例子,帮助研究机器级 x86-64 程序。通常的方法是在程序中感兴趣的地方附近设置断点。断点可以设置在函数入口后面,或是一个程序的地址处。程序在执行过程中遇到一个断点时,程序会停下来,并将控制返回给用户。在断点处,我们能够以各种方式查看各个寄存器和内存位置。我们也可以单步跟踪程序,一次只执行几条指令,或是前进到下一个断点。


图 3-39 GDB 命令示例。说明了一些 GDB 支持机器级程序调试的方式

正如我们的示例表明的那样,GDB 的命令语法有点涩,但是在线帮助信息(用 GDB 的 help 命令调用)能克服这些毛病。相对于使用命令行接口来访问 GDB 许多程序员更愿意使用 DDD 它是 GDB 的一个扩展,提供了图形用界面。

3.10.3 内存越界引用和缓冲区溢出

C 对于数组引用不进行任何边界检查,而且局部变量和状态信息(例如保存的寄存器值和返回地址)都存放在栈中。这两种情况结合到一起就能导致严重的程序错误,对越界的数组元素的写操作会破坏存储在栈中的状态信息。当程序使用这个被破坏的状态,试图重新加载寄存器或执行 ret 指令时,就会出现很严重的错误。

一种特别常见的状态破坏称为缓冲区溢出(buffer overflow)。

缓冲区溢出的一个更加致命的使用就是让程序执行它本来不愿意执行的函数。这是一种最常见的通过计算机网络攻击系统安全的方法。通常,输人给程序一个字符串,这个字符串包含一些可执行代码的字节编码,称为攻击代码(exploit code), 另外,还有一字节会用一个指向攻击代码的指针覆盖返回地址。那么,执行 ret 指令的效果就是跳转到攻击代码。

3.10.4 对抗缓冲区溢出攻击

缓冲区溢出攻击的普遍发生给计算机系统造成了许多的麻烦。现代的编译器和操作系统实现了很多机制,以避免遭受这样的攻击,限制人侵者通过缓冲区溢出攻击获得系统控制的方式。

  1. 栈随机化

为了在系统中插人攻击代码,攻击者既要插入代码,也要插人指向这段代码的指针,这个指针也是攻击字符串的一部分。产生这个指针需要知道这个字符串放置的栈地址。在过去,程序的栈地址非常容易预测。对于所有运行同样程序和操作系统版本的系统来说,在不同的机器之间,栈的位置是相当固定的。因此,如果攻击者可以确定一个常见的 Web服务器所使用的栈空间,就可以设计一个在许多机器上都能实施的攻击s 以传染病来打个比方,许多系统都容易受到同一种病毒的攻击,这种现象常被称作安全单一化(security monionoculture )

栈随机化的思想使得栈的位置在程序每次运行时都有变化。因此,即使许多机器都运行同样的代码,它们的桟地址都是不同的。实现的方式是:程序开始时,在栈上分配一段 0~n 宇节之间的随机大小的空间,

在 Linux 系统中,栈随机化已经变成了标准行为。它是更大的一类技术中的一种,这类技术称为地址空间布局随机化(Address-Space Layout Randomization), 或者简称 ASIJR。采用 ASLR, 每次运行时程序的不同部分,包括程序代码、库代码、栈、全局变量
和堆数据,都会被加载到内存的不同区域。

  1. 栈破坏检测

计算机的第二道防线是能够检测到何时栈已经被破坏。我们在 echo 函数示例(图 3-40)中看到,破坏通常发生在当超越局部缓冲区的边界时。在 C 语言中,没有可靠的方法来防止对数组的越界写,但是,我们能够在发生了越界写的时候,在造成任何有害结果之前,试检测到它,


图 3-42 echo 函数具有栈保护者的栈组织(在数组buf 和保存的状态之间放了一个特殊的“金丝雀”值。代码检査这个金丝雀值,确定栈状态是否被破坏)

最近的 GCC 版本在产生的代码中加入了一种栈保护者((stack protector)机制,来检测缓冲区越界。其思想是在栈帧中任何局部缓冲区与栈状态之间存储一个特殊的金丝雀(canary)值,如图 3-42 所示这个金丝雀值,也称为哨兵值(guard value), 是在程序每次运行时随机产生的,因此,攻击者没有简单的办法能够知道它是什么。在恢复寄存器状态和从函数返回之前,程序检査这个金丝雀值是否被该函数的某个操作或者该函数调用的某个函数的某个操作改变了。如果是的,那么程序异常中止。

栈保护很好地防止了缓冲区溢出攻击破坏存储在程序栈上的状态。它只会带来很小的性能损失,特别是因为 GCC 只在函数中有局部 char 类型缓冲区的时候才插人这样的代码,当然,也有其他一些方法会破坏一个正在执行的程序的状态,但是降低栈的易受攻击性能够对抗许多常见的攻击策略。

  1. 限制可执行代码区域
    最后一招是消除攻击者向系统中插人可执行代码的能力。一种方法是限制哪些内存区域能够存放可执行代码。在典型的程序中,只有保存编译器产生的代码的那部分内存才需要是可执行的。其他部分可以被限制为只允许读和写。正如第 9 章中会看到的,虚拟内存空间在逻辑上被分成了页(page),典型的每页是 2048 或者 4096 个字节。硬件支持多种形式的内存护,能够指明用户程序和操作系统内核所允许的访问形式。许多系统允许控制三种访问形式:读(从内存读数据)、写(存储数据到内存)和执行(将内存的内容看作机器级代码)。前,x86 体系结构将读和执行访问控制合并成一个 1 位的标志,这样任何被标记为可读的页也都是可执行的。栈必须是既可读又可写的,因而栈上的字节也都是可执行的。已经实现很多机制,能够限制一些页是可读但是不可执行的,然而这些机制通常会带来严重的性能损失。

最近,AMD 为它的 64 位处理器的内存保护引人了 ”NX“(No-Execme,不执行)位,
将读和执行访问模式分开,Intel 也跟进了。有了这个特性,栈可以被标记为可读和可写,但是不可执行,而检査页是否可执行由硬件来完成,效率上没有损失。

有些类型的程序要求动态产生和执行代码的能力。例如,“即时(just-in-time)” 编译技术为解释语言(例如 Java)编写的程序动态地产生代码,以提高执行性能。是否能够将可执行代码限制在由编译器在创建原始程序时产生的那个部分中,取决于语言和操作系统。我们讲到的这些技术棗 随机化、栈保护和限制哪部分内存可以存储可执行代码棗是用于最小化程序缓冲区溢出攻击漏洞三种最常见的机制。它们都具有这样的属性,即不需要程序员做任何特殊的努力,带来的性能代价都非常小,甚至没有。单独每一种机制都降低了漏洞的等级,而组合起来,它们变得更加有效。不幸的是,仍然有方法能够攻击计算机,因而蠕虫和病毒继续危害着许多机器的完整性。

3.10.5 支持变长栈帧

到目前为止,我们已经检查了各种函数的机器级代码,但它们有一个共同点,即编译器能够预先确定需要为栈分配多少空间,但是有些函数,需要的局部存储时变长的。例如,当函数调用 alloca 时就会发生这种情况。alloca 是一个标准库函数,可以在栈上分配任意字节数量的存储,当代码声明一个局部变长数组时,也会发生这种情况。

图 3-43a 的代码给出了一个包含变长数组的例子。该函数声明了 n 个指针的局部数组这里 n 由第一个参数给出。这要求在栈上分配 8n 个字节,这里 n 的值每次调用该函数时都会不同。因此编译器无法确定要给该函数的栈帧分配多少空间。此外,该程序还产生一个对局部变量 i 的地址引用,因此该变量必须存储在栈中。在执行工程中,程序必须能够访问局部变量 i 和数组 P 中的元素。返回时,该函数必须释放这个栈帧,并将栈指设置为存储返回地址的位置。


图 3-43 需要使用帧指针的函数。变长数组意味着在编译时无法确定栈帧的大小

为了管理变长栈帧,X86-64 代码使用寄存S%rbp 作为帧指针(frame pointer ) (有时称为基指针(base pointer), 这也是%rbp 中 bp两个字母的由来)。 当使用帧指针时,栈帧的组织结构与图 3-44 中函数 vframe 的情况一样。可以看到代码必须把 %rbp之前的值保存到栈中,因为它是一个被调用者保存寄存器。然后在函数的整个执行过程中,都使得 %rbp 指向那个时刻栈的位置,然后用固定长度的局部变量(例如 i)相对于%rbp 的偏移量来引用它们。


图 3-44 函数 vframe 的栈帧结构

图 3-43b 是 GCC 为函数 vframe 生成的部分代码。在函数的开始,代码建立栈帧,并为数组 P 分配空间。首先把 %rbp 的当前值压人栈中,将%rbp 设置为指向当前的栈位置(第 23 行)。然后,在栈上分配 16 个字节,其中前 8 个字节用于存储局部变量 i,而后 8 个字节是未被使用的。接着,为数组 p 分配空间(第 511 行)。当程序到第 11 行的时候,己经(1)在栈上分配了 知字节,并(2)在已分配的区域内放置好数组P,至少有 8n 知字节可供其使用。

初始化循环的代码展示了如何引用局部变量 i 和 P 的例子。第 13 行表明数组元素 p
[i] 被设置为 q。该指令用寄存器 %rcx 中的值作为 p 的起始地址。我们可以看到修改局部变量 i(第 15 行)和读局部变量(第 17 行)的例子。i的地址是引用-8(%rbp), 也就是相对于帧指针偏移量为-8 的地方,
在函数的结尾,leave 指令将帧指针恢复到它之前的值(第 20 行)。 这条指令不需要
参数,等价于执行下面两条指令:

1
2
movq %rbp, %rsp
popq %rbp

也就是,首先把栈指针设置为保存%rbp 值的位置,然后把该值从栈中弹出到%rbp。这个指令组合具有释放整个栈帧的效果。

3.11 浮点代码

处理器的浮点体系结构包括多个方面,会影响对浮点数据的程序如何被映射到机器上,包括:

  • 如何存储和访问浮点数。通常是通过某种寄存器方式来完成。
  • 对浮点数据操作的指令
  • 向函数传递浮点数参数和从函数返回浮点数结果的规则。
  • 函数调用过程中保存寄存器的规则–例如,一些寄存器被指定为调用者保存,而其他的被指定为被调用者保存。

如图 3-45 所示,AVX 浮点体系结构允许数据存储在 16 个 YMM 寄存器中,它们的名字为%ymm0〜%ymml5。每个 YMM 寄存器都是 256 位(32 字节)。 当对标量数据操作时,这些寄存器只保存浮点数,而且只使用低 32 位(对于 float)或 64 位(对于 double),汇编代码用寄存器的 SSE XMM 寄存器名字%xmm0~xmm15 来引用它们,每个 XMM 寄存器都是对应的 YMM 寄存器的低 128 位(16 字节)。


图 3-45 媒体寄存器。这些寄存器用于存放浮点数据。每个 YMM 寄存器保存 32 个字节。低 16 字节可以作为 XMM 寄存器来访问

3.11.1 浮点传送和转换操作

图 3-46 给出了一组在内存和 XMM 寄存器之间以及从一个 XMM 寄存器到另一个不做任何转换的传送浮点数的指令。引用内存的指令是标量指令,意味着它们只对单个而不是一组封装好的数据值进行操作。数据要么保存在内存中(由表中的 M32 和 M64 明), 要么保存在 XMM 寄存器中(在表中用 X 表示)。 无论数据对齐与否,这些指令都能正确执行,不过代码优化规则建议 32 位内存数据满足 4 字节对齐,64 位数据满足 8 字节齐。内存引用的指定方式与整数 MOV 指令的一样,包括偏移量、基址寄存器、变址寄存器和伸缩因子的所有可能的组合。


图 3-46 浮点传送指令。这些操作在内存和寄存器之间以及一对寄存器之间传送值(X:XMM寄存器(例如%xmm3); JVf32: 32 位内存范围; 64 位内存范围)

图 3-47 和图 3-48 给出了在浮点数和整数数据类型之间以及不同浮点格式之间进行转换的指令集合。这些都是对单个数据值进行操作的标量指令。图 3-47 中的指令把一个从XMM 寄存器或内存中读出的浮点值进行转换,并将结果写人一个通用寄存器(例如
%rax、%ebx 等)。 把浮点值转换成整数时,指令会执行截断(truncation),把值向 0进行舍人,这是 C 和大多数其他编程语言的要求。


图 3-47 双操作数浮点转换指令。这些操作将浮点数转换成整数(X: XMM 寄存器(例如%xmm3); R32: 32位通用寄存器(例如%eax); R64: 64 位通用寄存器(例如%rax); M32: 32 位内存范围;M64:64 位内存范围)


图 3-48 三操作数浮点转换指令。这些操作将第一个源的数据类型转换成目的的数据类型。第二个源值对结果的低位字节没有影响(X: XMM 寄存器(例如%xmm3); M32 : 32 位内存范围;M64: 64 位内存范围)

图 3-48 中的指令把整数转换成浮点数。它们使用的是不太常见的三操作数格式,有两个源和一个目的。第一个操作数读自于内存或一个通用目的寄存器。这里可以忽略第二个操作数,因为它的值只会影响结果的高位字节。而我们的目标必须是 XMM 寄存器。在最常见的使用场景中,第二个源和目的操作数都是一样的,就像下面这条指令:
vcvtsi2sdq %rax, %xmml, %xmml
这条指令从寄存器%rax 读出一个长整数,把它转换成数据类型 double, 并把结果存放进 XMM 寄存器%xmml 的低字节中。

最后,要在两种不同的浮点格式之间转换,GCC 的当前版本生成的代码需要单独说
明。假设%xamm 的低位 4 字节保存着一个单精度值,很容易就想到用下面这条指令
vcvtss2sd %xmm0, %xmm0, %xmm0
把它转换成一个双精度值,并将结果存储在寄存器%xmm0 的低 8 字节。不过我们发现 GCC生成的代码如下

1
2
3
Conversion from single to double precision
vunpcklps %xmm0, %xmm0, %xmm0 Replicate first vector element
vcvtps2pd %xmm0, %xmm0 Convert two vector elements to double

vunpcklps 指令通常用来交叉放置来自两个 XMM 寄存器的值,把它们存储到第三个寄存器中。也就是说,如果一个源寄存器的内容为字[s3,s2,s1,s0],另一个源寄存器为[d3,d2,d1,d0],那么目的寄存器的值会是[s1,d1,s0,d0],所以如果原始寄存器的值为[x3,x2,x1,x0],那么该指令会将寄存器的值更新为值[x1,x1,x0,x0]。vcvtps2pd 指令把源 XMM 寄存器中的两个低位单精度值扩展成目的 XMM 寄存器中的两个双精度值。对前面 vunpcklps 指令的结果应用这条指令会得到值[dx0,dx0],这里 dx0 是将 x 转换成双精度后的结果。即,这两条指令的最终效果是将原始的 %xmm0 低位 4 字节中的单精度值转换成双精度值,再将其两个副本保存到%xmm0 中。我们不太清楚 GCC 为什么会生成这样的代码,这样做既没有好处,也没有必要在 XMM 寄存器中把这个值复制一遍。

对于把双精度转换为单精度,GCC 会产生类似的代码:

1
2
3
Conversion from double to single precsion
vmovddup %xmmO, %xmmO Replicate first vector element
vcvtpd2psx %xmmO, %xmmO Convert two vector elements to single

假设这些指令开始执行前寄存器%xmmO 保存着两个双精度值[x1,X0]然后 vmovddup 指令把它设置为[x0,X0],vctpd2pSX 指令把这两个值转换成单精度,再存放到该寄存器的低位一半中,并将高位一半设置为 0, 得到结果[0.0, 0.0, x0,X0](回想一下,浮点值0.0 是由位模式全 0 表示的)。 同样,用这种方式把一种精度转换成另一种精度,而不用下面的单条指令,没有明显直接的意义:

1
vcvtsd2ss %xmm0, %xmm0, %xmmO

3.11.2 过程中的浮点代码

在 x86-64 中,XMM 寄存器用来向函数传递浮点参数,以及从函数返回浮点值。如图 3-45 所示,可以看到如下规则:

  • XMM 寄存器%xmm0 %xmm7 最多可以传递 8 个浮点参数。按照参数列出的顺序使用这些寄存器。可以通过栈传递额外的浮点参数。
  • 函数使用寄存器%xmm0 来返回浮点值。
  • 所有的 XMM 寄存器都是调用者保存的。被调用者可以不用保存就覆盖这些寄存器中任意一个。

当函数包含指针、整数和浮点数混合的参数时,指针和整数通过通用寄存器传递,而浮点值通过 XMM 寄存器传递。也就是说,参数到寄存器的映射取决于它们的类型和排列的顺序。

3.11.3 浮点运算操作

图 3-49 描述了一组执行算术运算的标量 AVX2 浮点指令。每条指令有一个(S1)或两个(S1,S2)源操作数,和一个目的操作数D。第一个源操作数 S1 可以是一个 XMM 寄存器或一个内存位置。第二个源操作数和目的操作数必须是 XMM 寄存器。每个操作都有一条针对单精度的指令和一条针对双精度的指令。结果存放在目的寄存器中。


图 3-49 标量浮点算术运算。这些指令有一个或两个源操作数和一个目的操作数

3.11.4 定义和使用浮点常数

和整数运算操作不同,AVX 浮点操作不能以立即数值作为操作数。相反,编译器必须为所有的常量值分配和初始化存储空间。然后代码在把这些值从内存读入。