深入,并且广泛
-沉默犀牛
计算机执行机器代码
,用字节序列编码低级的操作,包括处理数据、管理内存、读写存储设备上的数据,以及利用网络通信。编译器基于编程语言的规则、目标机器的指令集和操作系统遵循的惯例,经过一系列的阶段生成机器代码。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个字节。每组都是一条指令,右边是等价的汇编语言。
一些关于机器代码和它的反汇编表示的特性值得注意:
生成实际可执行的代码需要一组目标代码文件运行链接器,而这一组目标代码文件中必须含有一个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语言书写的代码合并起来。第二种方法是,我们可以使用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 这样的局部变量通常保存在寄存器中,而不是内存中。访问寄存器比访问内存要快得多。
我把每种数据类型的占用的字节数再贴一下:
指针的间接引用(pointer dereferencing)
,C操作符 * 执行指针的间接引用。 语句 xp = y, 正好相反——它将参数 y 的值写到 xp 所指的位置。这也是指针间接引用的一种形式(所以有操作符 ‘‘),但是它表明的是一个写程序,因为它在赋值语句的左边。最后两个数据传送操作可以将数据压入程序栈中,以及从程序栈中弹出数据,如下图。正如我们将看到的,栈在处理过程调用中起到至关重要的作用。栈是一种数据结构,可以添加或者删除值,不过要尊村“后进先出”的原则。通过 push 操作把数据压入栈中,通过 pop 操作删除数据;它具有一个属性:弹出的值永远是最近被压入而且仍然在栈中的值。栈可以实现位一个数组,总是从数组的一段插入和删除元素。这一端被称为栈顶
。在x86-64中,程序栈存放在内存中某个区域。如下下图,栈向下增长,这样一来,栈顶元素的地址是所有栈中元素地址中最低的。(根据惯例,我们的栈是倒过来画的,栈顶在图的底部。)栈指针 %rsp 保存着栈顶元素的地址。
pushq 指令的功能是把数据压入到栈上,而 popq指令是弹出数据。这些指令都只有一个操作数——压入的数据源和弹出的数据目的。
将一个四字值压入栈中, 首先要将栈指针减8,然后将值写到新的栈顶地址,因此,指令 pushq %rbp 的行为等价于下面两条指令:
它们之间的区别是在机器代码中 pushq 指令编码为 1个字节,而上面那两条指令一共需要8个字节。下图中前两栏给出的是,当 %rsp 为 0x108,%rax 为 0x123时,执行指令 pushq %rax 的效果。首先 %rsp 会减 8,得到 0x100,然后会将 0x123 存放到内存地址 0x100处。
弹出一个四字的操作包括从栈顶位置读出数据,然后将栈指针加8,。因此 popq %rax 等价于下面两条指令:
上图的第三栏说明的是,在执行完 pushq 后立即执行指令 popq %rdx 的效果。先从内存中读出值 0x123,再写到寄存器 %rdx中,然后,寄存器 %rsp 的值将增加回到 0x108。如图所示,值 0x123 仍然会保持在内存的 0x100 中,直到被覆盖(例如被另一条入栈操作覆盖)。无论如何, % rsp 指向的地址总是栈顶。
因为栈和程序代码以及其他形式的程序数据都是放在同一内存中,所以程序可以用标准的内存寻址方法访问栈内的任意位置。例如,假设栈顶元素时四字,指令 movq 8(%rsp),%rdx 会将第二个四字从栈中复制到寄存器 % rdx。
下图列出了x86-64的一些整数和逻辑操作。大多数操作都分成了指令类
。这些指令类
有各种带不同大小操作数的变种(只有 leaq 没有其他大小的变种)。例如,指令类 ADD 由四条加法指令组成:addb、addw、addl 和 addq,分别是字节加法、字加法、双字加法 和 四字加法。事实上,给出的每个指令类
都有对这四种不同大小数据的指令。这些操作被分为四组:加载有效地址、一元操作、二元操作 和 移位。二元操作
有两个操作数,而一元操作
有一个操作数。这些操作数的描述方法与 上面所讲的一样。
加载有效地址(load effective address)
指令 leaq 实际上是 movq 指令的变形。它的指令形式是从内存读取到寄存器,但实际上它根本就没有引用内存。它的第一个操作数看上去是一个内存引用,但该指令并不是从指定的位置读入数据,而是将有效地址写入到目的操作数。在上图中,我们用C语言的地址操作符 &S 说明这种计算。这条指令可以为后面的内存引用产生指针。另外,它还可以简洁地描述普通的算术操作。例如,如果寄存器 %rdx 的值为 x ,那么指令 leaq 7(%rdx ,%rdx,4), %rax 将寄存器 %rax 的值设置为 5x+7 。编译器经常发现 leaq 的一些灵活用法,根本就与有效地址计算无关。目的操作数必须是一个寄存器。
为了说明 leaq 在编译出的代码中的使用,看下面的C程序:
编译时,该函数的算术运算以三条 leaq 指令实现,就像右边注释说明的的那样:
leaq指令能执行加法和有限形式的乘法,在编译如上简单的算术表达式时,是很有用的。
第二组中的操作是一元操作,只有一个操作数,既是源又是目的。这个操作数可以是一个寄存器,课可以是一个内存位置。比如说,指令 incq (%rsp)会使得栈顶的 8 字节元素加1。这种语法让人想起C语言中的加1运算符(++)和减1运算符(–)。
第三组是二元操作,其中,第二个操作数既是源又是目的。这种语法让人想起C语言中的赋值运算符,例如 x -= y 。不过要注意,源操作数是第一个,目的操作数是第二个,对于不可交换操作来说,这看上去很奇特。例如,指令 subq %rax,%rdx 使寄存器 %rdx的值减去 %rax中的值。(将指令解读成“从 %rdx 中减去 %rax”)。第一个操作数可以是立即数、寄存器或是内存位置。第二个操作数可以是寄存器或是内存位置。注意,当第二个操作数位内存地址时,处理器必须从内存读出值,执行操作,再把结果写回内存。
最后一组是移位操作,先给出移位量,然后第二项给出的是要移位的数。可以进行算术和逻辑右移。移位量可以是一个立即数,或者放在单字节寄存器 %cl 中。(这些指令很特别,因为只允许以这个特定的寄存器为操作数)。原则上说,1个字节的移位量使得移位量的编码范围可以达到 2的8次方 - 1 = 255。x86-64 中,移位操作对 w 位长的数据值进行操作,移位量是由 %cl 寄存器的低 m 位决定的,这里2的m次方 = w。高位会被忽略。所以,例如当寄存器 %cl 的十六进制值位 0xFF 时,指令 salb 会移7位,salw 会移动15位,sall会移31位,而salq会移63位。
[salb 说明移位的数是 8 位,即m = 3,0xFF 的低3位是 111,就是 7位,同理, sall 移位的数是32位, m = 5 ,低5位是 11111,就是 31位。]
左移指令有两个名字:SAL 和 SHL 。两者的效果是一样的,都是将右边填上0.右移指令不同,SAR 执行算术移位(填上符号位),而SHR 执行逻辑移位(填上0)。移位操作的目的操作数可以是一个寄存器或是一个内存位置。
我们上面看到的大多数指令,既可以用于无符号运算,也可以用于补码运算。
只有右移操作要求区分有符号和无符号数。这个特性使得补码运算成为实现有符号整数运算的一种比较好的方法的原因之一。
下图给出了一个执行算术操作的函数示例,以及它的汇编代码。参数 x、y和z初始时分贝存放在内存 %rdi、%rsi 和 %rdx中。汇编代码指令和C源代码行对应很紧密。第2行计算 x^y的值。指令3和4用 leaq 和移位指令的组合来实现 z * 48。第5行计算 t1 和 0x0F0F0F0F 的 AND值。第6行计算最后的减法。 由于减法的目的寄存器是 %rax ,函数会返回这个值。
在上图的汇编代码中,寄存器 %rax 中的值先后对应于程序值 3 * z、z * 48 和 t4(作为返回值)。通常,编译器产生的代码中,会用一个寄存器存放多个程序值,还会在寄存器之间传送程序值。
正如之前看到的,两个64位有符号或无符号整数相乘得到的乘积需要 128 位来表示。x86-64指令集对 128位(16字节)数的操作提供有限的支持。延续字(2字节)、双字(4字节)和四字(8字节)的命名惯例,Intel 把16字节的数称为 八字(oct word)
。下图描述的是支持产生两个64位数字的全128位乘积以及整数除法的指令。
imulq 指令有两种不同的形式。其中一种,是IMUL 指令类中的一种。这种形式的 imulq 指令是一个 “双操作数” 乘法指令。它从两个 64 位操作数产生一个 64位乘积(当将乘积截断位 64 位时,无符号乘 和 补码乘 的位级行为是一样的。)
此外,x86-64 指令集还提供了两条不同的 “单操作数”乘法指令,以计算两个64位值得全 128位乘积——一个是无符号乘法(mulq),另一个是补码乘法(imulq)。这两条指令都要求一个参数必须在寄存器 %rax 中,而另一个作为指令的源操作数给出。然后乘积存放在寄存器 %rdx (高64位)和 %rax(低64位)中。虽然 imulq 这个名字可以用于两个不同的乘法操作,但是汇编语言可以听过计算操作数的数目,分辨出用哪条指令。
下面这段C代码是一个示例,说明了如何从两个无符号 64 位数字 x 和 y 生成 128位的乘积:
在这个程序中,我们显式地把 x 和 y 声明为 64 位的数字,使用文件 inttypes.h
中声明的定义,这是对标准C扩展的一部分。不幸的是,这个标准没有提供 128位的值。所以我们只好依赖GCC提供的 128位整数支持,用名字__int128来声明。代码用 typedef 声明定义了一个整数类型 uint128_t,沿用的 inttypes.h 中其他数据类型的命名规律。这段代码指明得到的乘积应该存放在指针 dest 指向的16字节处。
可以观察到,存储乘积需要两个 movq 指令:一个存储低8个字节,一个存储高8个字节。由于生成这段代码针对的是小端法机器,所以高位字节存储在大地之,正如地址8(%rdi)表明的那样。
前面的算术表没有列出除法或取模操作。这些操作是由单操作数除法指令来提供的,类似于单操作数乘法指令。有符号除法指令 idivl 将寄存器 %rdx 和 %rax 中的128位数作为被除数,而除数作为指令的操作数给出。指令将商存储在寄存器 %rax 中,将余数存储在寄存器 %rdx 中。
对于大多数 64位除法应用来说,除数也常常是一个64位的值。这个值应该存放在 %rax 中,%rdx 的位应该设置为全0(无符号运算)或者 %rax 的符号位(有符号运算)。后面这个操作可以用指令 cqto 来完成。这条指令不需要操作数——它隐含的读出 %rax 的符号位,并将它复制在 %rdx 的所有位。
我们用下面这个C函数来说明 x86-64 如何实现除法,它计算了两个 64 位有符号的商和余数:
该函数编译得到如下汇编代码:
在上述代码中,必须首先把参数 qp 的地址保存到另一个寄存器中,因为除法操作要使用参数寄存器 %rdx。接下来,准备被除数,复制并符号扩展 x。除法之后,寄存器 %rax 中的商被保存在 qp,而寄存器 %rdx 中的余数被保存在 rp。
无符号除法使用 divq 指令。通常,寄存器 %rdx 会事先设置为0。
到目前为止,我们只考虑了直线
代码的行为,也就是指令一条一条的顺序执行。C语言中的某些结构,比如条件语句、循环语句 和 分支语句,要求有条件的执行,根据数据测试的结果来决定操作执行的顺序。机器代码提供两种基本的低级机制来实现由条件的行为:测试数据值,然后根据测试的结果来改变控制流或者数据流。
与数据相关的控制流是实现由条件行为的更一般和常见的方法,所以我们现在介绍它。通常,C语言中的语句和机器代码中的指令都是按照它们在程序中出现的次序,顺序
执行的。用 jump 指令可以改变一组机器代码指令的执行顺序,jump 指令指定控制应该被传递到程序的某个其他部分,可能是依赖于某个测试的结果。编译器必须产生构建在这种低级机制基础之上的指令序列,来实现C语言的控制结构。
本文会先涉及实现条件操作的两种方式,然后描述表达循环 和 switch 语句的方法。
出了整数寄存器,CPU还维护着一组单个位的条件码(condition code)
寄存器,它们描述了最近的算术或逻辑操作的属性。 可以检测这些寄存器来执行条件分支指令。最常用的条件码有:
CF:进位标志。最近的操作使最高位产生了进位。可以来检查无符号操作的溢出。
ZF:零标志。最近的操作得出的结果为0。
SF:符号标志。最近的操作得到的结果为负数。
OF:溢出标志。最近的操作导致一个补码溢出——正溢出或负溢出。
比如说,假设我们有一条 ADD 指令完成等价于 C 表达式 t = a + b 的功能,这里变量 a、b 和 t 都是整型的。然后,根据下面的C表达式来设置条形码:
leaq 指令不改变任何条件码,因为它是用来进行地址计算的。对于逻辑操作,例如 XOR,进位标志和溢出标志会设置成0。对于移位操作,进位标志将设置为最后一个被溢出的位,而溢出标志设置为0。INC 和 DEC 指令会设置溢出和零标志,但是不会改变进位标志,至于原因,我们就不在这里深入探讨了。
有两类指令只设置条件码而不改变任何其他寄存器;如下图,CMP指令根据两个操作数之差来设置条件码。除了只设置条件码而不更新目的寄存器之外,CMP指令与SUB指令的行为是一样的。在ATT格式中,列出操作数的顺序是相反的,这使代码有点难度。如果两个操作数相等,这些指令会将零标志设置为1,而其他的标志可以用来确定两个操作数之间的大小关系。TEST 指令的行为与 AND指令 一样,出了它们只设置条件码而不改变目的寄存器的值。典型的用法是,两个操作数是一样的(例如,testq %rax,%rax 用来检查 %rax是负数、零还是正数),或其中的一个操作数是一个掩码,用来指示哪些位应该被测试。
条件码通常不会直接读取,常用的使用方法有三种:
正常执行的情况下,指令按照它们出现的顺序一条一条地执行。跳转(jump)
指令会导致执行切换到程序中一个全新的位置。在汇编代码中,这些跳转的目的地通常有一个标号(label)
指明。
在产生目标代码文件时,汇编器会确定所有带标号指令的地址,并将跳转目标
( 目的指定的地址)编码为跳转指令的一部分。
下图列举了不同的跳转指令。jmp指令是无条件跳转。它可以是直接跳转
,即跳转目标是作为指令的一部分编码的;也可以是间接跳转
,即跳转目标目标时从寄存器或内存位置中读出的。汇编语言中,直接跳转是给出一个标号作为跳转目标的,举个例子:
jmp *%rax (用寄存器 %rax 中的值作为跳转目标)
jmp *(% rax) (以 %rax中的值作为地址,从内存中读出跳转目标)
上表中所示的其他跳转指令都是有条件的
——它们根据条件码的某种组合,或者跳转,或者继续执行代码序列中下一条指令。这些指令的名字和跳转条件与 SET 指令的名字和设置条件是相匹配的。同 SET 指令一样,一些底层的机器指令有多个名字。条件转移只能是直接挑战。
虽然我们不关心机器代码格式的细节,但是理解跳转指令的目标如何编码,对之后研究链接非常重要。此外,它也能帮助理解反汇编器的输出。在汇编代码中,跳转目标用符号标号书写。汇编器,以及后来的链接器,会产生跳转目标的适当编码。跳转指令有几种不同的编码,但是最常用都是 PC 相对的(PC - relative)。也就是,它们会将目标指令的地址与紧跟在跳转指令后面那条指令的地址之间的差作为编码。这些地址偏移量可以编码为1、2或4个字节。第二种编码方式是给出“绝对”地址,用4个字节直接指定目标。汇编器和链接器会选择适当的跳转目的编码。
下面是一个 PC相对寻址 的例子,这个函数的汇编由编译文件 branch.c 产生。它包含两个跳转:第2行的 jmp 指令前向跳转到更高的地址,而第7行的 jg 指令后向跳转到较低的地址。
右边反汇编器产生的注释中,第2行中跳转指令的跳转目标指明为 0x8,第5行中的跳转指令的跳转目标是 0x5(反汇编器以十六进制格式给出所有的数字)。不过,观察指令的字节编码,会看到第一条跳转指令的目标编码(在第二个字节中)位 0x03。把它加上 0x5 就是下一条指令的地址,就得到跳转目标地址 0x8,也就是第4行指令的地址。
类似,第二个跳转指令的目标用单字节、补码表示编码为 0xf8,将这个数加上 0xd,即第6行指令的地址,我们得到 0x5,即 第3行指令的地址。
这些例子说明,当执行PC相对寻址时,程序计数器的值是跳转指令后面的那条指令的地址,而不是跳转指令本身的地址。这种管理可以追溯到早起,当时的处理器会将更新程序计数器作为执行一条指令的第一步。
【总结一下,跳转目标地址 = (跳转指令)下一条指令的地址 + 跳转指令的目标编码位(在第二个字节中,并且是十六进制的补码表示)】
下面是链接后的程序反汇编版本:
这些指令被重定位到不同的地址,但是第2行和第5行中跳转目标的代码并没有变。通过使用与 PC相对的跳转目标编码,指令编码很简洁,而且目标代码可以不做改变就移到内存中不同的位置。
【这里的D答案,0xffffff73 + 0x004005ed = 0x100400560 截断后为 0x00400560】
将条件表达式和语句从C语言翻译成机器代码,最常用的方式是结合有条件和无条件跳转。(另一种方式在下一节会看到,有些条件可以用数据的条件转移实现,而不是用控制的条件转移来实现)。例如,下图a给出了一个计算两数之差绝对值的函数的C代码。这个函数有一个副作用,会增加两个计数器,编码为全局变量 It_cnt 和 ge_cnt 之一。GCC产生的汇编代码如下图c所示。把这个机器代码再转换成C语言,我们称之为函数 gotodiff_se (下图b)。它使用了C语言中的 goto 语句,这个语句类似于汇编代码中的无条件跳转。使用 goto语句通常认为是一种不好的编程风格,因为它会使代码非常难以阅读和调试。本文中使用 goto 语句,是为了构造描述汇编代码程序控制流的C程序。我们称这样的编程风格为“goto 代码”。
在 goto 代码中,第5行中的 goto x_ge_y 语句会导致跳转到第9行中的标号 x_ge_y出(当x >= y时会进行跳转),从这一点继续执行,完成函数 adsdiff_se 的 else 部分并返回。另一方面,如果测试 x >= y 失败,程序会计算 adbdiff_se 的 if 部分指定的步骤并返回。
汇编代码的实现(下图c)首先比较了两个操作数吗,设置条件码。如果比较的结果表明 x >= y 。那么它就会跳转到第8行 ,增加全局变量 ge_cnt,计算 x - y 作为返回值并返回。由此我们可以看到 adsdiff_se 对应汇编代码的控制流非常类似于 gotodiff_se 的goto代码。
C语言中的 if-else 语句的通用形式魔板如下:
对于这种通用形式,汇编实现通常会使用下面这种形式,这里,用C语法来描述控制流:
也就是,汇编器位 then-statement 和 else-statement 产生各自的代码块。它会插图条件和无条件分支,以保证能执行正确的代码块。
【小提示:可能你没能一次记住跳转指令的条件,本题目中的 jge 是 “大于等于”则跳转】
实现条件操作的传统方法是通过使用控制
的条件转移。当条件满足时,程序沿着一条执行路径执行,当条件不满足时,就走另外一条路径。这种机制简单通用,但是再现代处理器上,它可能会非常低效。
一种替代的策略是使用数据
的条件转移。这种方法计算一个条件操作的两种结果,然后再根据条件是否满足从中选取一个。只有在一些受限制的情况中,这种策略才可行,但是如果可行,就可以用一条简单的条件传送
指令来实现它,条件传送指令更符合现代处理器的性能特性。
【看起来只有计算两种结果的成本不高的时候才有效】
下图a给出了一个可以用条件传送编译的示例代码。这个函数计算参数 x 和 y 差的绝对值,和前面的例子一样。不过在前面的例子中,分支里有副作用,会修改 lt_cnt 和 ge_cnt 的值,而这个版本只是简单地计算函数要返回的值。
GCC为该函数产生的汇编代码如图c所示,它与图b中所示的C函数 cmovdiff 有相似的形式。研究这个C版本,我们可以看到它既计算了 y - x ,也计算了 x - y,分别命名为 rval 和 eval。然后再测试最后返回结果。
为了理解为什么基于条件数据传送
的代码会比基于条件控制转移
的代码性能好,我们先了解一些关于现代处理器如何运行的知识。处理器通过使用流水线(pipelining)
来获得高性能,在流水线中,一条指令的处理要经过一系列的阶段,每个阶段执行所需操作的一小部分(例如,从内存取指令、确定指令类型、从内存读数据、执行算术运算、向内存写数据,以及更新程序计数器)。这种方法通过重叠连续指令的步骤来获得高性能,例如,在取一条指令的同事,执行它前面一条指令的算术运算。要做到这一点,要求能够事先确定要执行的指令序列,这样才能保持流水线中充满了待执行的指令。当机器遇到条件转移时,只有当分支条件求值完成后,才能决定分支往哪里走。处理器采用非常精密的分支 预测逻辑
来猜测每条跳转指令是否会执行。只要它的猜测还比较可靠(现代微处理器设计试图达到 90% 以上的成功率),指令流水线中就会充满着指令。另一方面,错误预测一个跳转,要求处理器丢掉它为该跳转指令后所有指令已做的工作,然后再开始从正确位置处起始指令去填充流水线。正如我们会看到的,这样一个错误预测会招致很严重的惩罚,浪费大约15-30个时钟周期,导致程序性能严重下降。
作为一个示例,我们在Intel Haswell 处理器上运行 adsdiff 函数,用两种方法来实现条件操作。在一个典型的应用中, x < y的结果非常地不可预测,因此即使是最精密的分支预测硬件也只能有大约 50% 的概率猜对。对此,两个代码序列中的计算执行都只需要一个时钟周期。因此,分支预测错误除法主导着这个函数的性能。对于包含条件跳转的 x86-64 代码,我们发现当分支行为模式很容易预测时,每次调用函数需要大约 8 个时钟周期;而分支行为模式随机的时候,每次大约 17.5 个时钟周期。由此我们可以推断出分支预测错误的处罚是大约 19 个时钟周期。这就意味着函数需要的时间范围大约在 8 到 27 个周期之间,这依赖于分支预测是否准确。
另一方面,无论测试的数据是什么,编译出来使用条件传送的代码所需的时间都是大约 8 个时钟周期。控制流不依赖于数据,这使得处理器更容易保持流水线是满的。
下图中例举了 x86-64 上一些可用的条件传送指令。每条指令都有两个操作数:源寄存器 或者 内存地址 S,和目的寄存器 R。源值可以从内存或者源寄存器中读取,但是只有在指定的条件满足时,才会被复制到目的寄存器中。
为了理解如何通过条件数据传输来实现条件操作,考虑下面的条件表达式和赋值的通用形式:
用条件控制转移的标准方法
来编译这个表达式会得到如下:
这段代码包含两个代码序列:then-expr求值,else-expr求值。条件跳转和无条件跳转结合起来使用是为了保证只有一个序列执行。
基于条件传送
的代码,会对 then-expr 和 else-expr 都求值,最终值的选择基于对 test-expr 的求值。
这个序列中的最后一条语句是用条件传送实现的——只有当测试条件 t 满足时, vt 的值才会被复制到 v 中。
不是所有的条件表达式都可以用条件传送来编译。最重要的是,无论结果如何,我们给出的抽象代码会对 then-expr 和 else-expr 都求值。如果这两个表达式中的任意一个可能产生错误条件或者副作用,就会导致非法的行为。
看看这个例子:
乍一看,很适合被编译成使用条件传送,当指针位空时将结果设置为0,
不过,这个实现是非法的,因为即使当测试为假的时,movq指令对 xp 的间接引用还是发生了,导致一个间接引用空指针的错误。
使用条件传送也不总是会提高代码的效率。例如,如果 then-expr 或者 else-expr 的求值需要大量的计算,那么当相对应的条件不满足时,这些工作就白费了。编译器必须考虑浪费的计算和由于分支预测错误所造成的性能处罚之间的相对性能。说实话,编译器并不具有足够的信息来做出可靠的决定。例如,它们不知道分支会多好地遵循可预测的模型。对GCC的实验表明,只有当两个表达式都很容易计算式,它才会使用条件传送。根据经验,即使许多分支预测错误的开销会超过更复杂的计算,GCC还是会使用条件控制转移。
所以总的来说,条件数据传送提供了一种条件控制转移来实现条件操作的代替策略。它们只能用于非常受限制的情况,但是这些情况还是相当常见的,而且与现代处理器的运行方式更契合。
【小提示:cmovns 的传送条件为 非负数(通过判断 SF 的值),之前的 testq 会影响 SF 的值】
【我不知道为啥 “负数要加偏移量” 】
C语言提供了多种循环结构,即 do-while 和 for。汇编中没有相应的指令存在,可以用条件测试和跳转组合起来实现循环的效果。GCC和其他汇编器产生的循环代码主要基于两种基本的循环模式。我们会循序渐进地研究循环的翻译,从 do-while 开始,然后研究具有更复杂实现的循环,并覆盖这两种模式。
这种通用形式可以被翻译成如下所示的条件个 goto 语句:
也就是说,每次循环,程序会执行循环体里的语句,然后执行测试表达式。如果测试为真,就回去再执行一次循环。
图a 给出了一个函数的实现,用do-while循环来计算函数参数的阶乘,写出n!。这个函数只计算 n > 0 时 n的阶乘的值。
图b所示的 goto 代码展示了如何把循环变成低级的测试和条件跳转的组合。result 初始化之后,程序开始循环。首先执行循环体,包括更新变量 result 和 n。然后测试 n > 1,如果是真,跳转到循环开始处。图c 所示的汇编代码就是 goto 代码的原型。条件跳转指令 jg 是实现循环的关键指令,它决定了是需要基础重复还是退出循环。
逆向工程像图c 中那样的汇编代码,需要确定哪个寄存器对应的是哪个程序值。本例中,这个对应关系很容易确定:我们知道 n 在寄存器 %rdi 中传递给函数。可以看到寄存器 %rax 初始化为 1 。(注意,虽然指令的目的寄存器是 %eax,它实际上还会把 %rax 的高4字节设置为0)。还可以看到这个寄存器还会在第4行被乘法改变值。此外,% rax 用来返回函数值,所以通常会用来存放需要返回的程序值。因此我们断定 %rax 对应程序值 result。
我们描述 fact_do 的过程对于逆向工程循环来说,是一个通用的策略。看看在循环之前如何初始化寄存器,在循环中如何更新和测试寄存器,以及在循环之后又如何使用寄存器。这些步骤中的每一步都提供了一个线索,组合起来就可以解开谜团。做好准备,你会看到令人惊奇的变换,其中有些情况很明显是编译器能够优化代码,而有些情况很难解释编译器为什么要选用那些奇怪的策略。GCC常常做的一些变换,非但不能带来性能好处,反而甚至可能降低代码性能。
与 do-while 不同之处在于,在第一次执行 body-statement 之前,它会对 test-expr 求值,循环有可能就中止了。有很多方法将while 循环翻译成机器代码,GCC在代码生成和使用其中的两种方法。这两种方法使用同样的循环结构,与 do-while 一样,不过它们实现初始测试的方法不同。
第一种翻译方法,我们称之为跳转到中间(jump to middle),它执行一个无条件跳转跳到循环结尾处的测试,以此来执行初始的测试,如下所示:
作为一个示例,图a 给出了使用 while 循环的阶乘函数的实现。这个函数能够精确地计算 0! = 1。它旁边的函数 fact_while_jm_goto 是GCC带优化命令行选项 -Og 时产生的汇编代码的C语言翻译。比较 face_while 和 face_do 的代码,可以看到它们非常相似,区别仅在于循环前的 goto test 语句使得程序在修改 result 或 n 的值之前,先执行对 n 的测试。图c 给出的是实际产生的汇编代码。
第二种翻译方法,我们称之为 guarded-do ,首先用条件分支,如果初始条件不成立就跳过循环,把代码变换为 do-while 循环。当使用较高优化等级编译时,例如使用命令行选项 -O1 ,GCC会采用这种策略。可以用如下模板来表达这种方法,把通用的 while 循环格式翻译成 do-while 循环:
翻译成 goto 代码如下:
利用这种实现策略,编译器常常可以优化初始的测试,例如认为测试条件总是满足。
再来看个例子,下图给出了所示阶乘函数同样的C代码,不过给出的是GCC使用命令行选项 -O1 时的编译。图c给出的是实际生成的汇编代码,图b 是这个汇编代码更易读的C语言表示。根据 goto 代码,可以看到如果对于 n 的初始值有 n <= 1,那么将跳过该循环。该循环本身的基本结构与该函数 do-while 版本产生的结构一样。不过,一个有趣的特性是,循环测试从原始C代码的 n > 1 变成了 n ≠ 1。编译器知道只有当 n > 1时才会进去循环,所以将 n 减 1 意味着 n > 1 或者 n = 1。因此,测试 n ≠ 1 就等价于测试 n <= 1。
C语言标准说明,这样一个循环的行为与下面这段使用 while 循环的代码的行为一样:
程序首先对初始表达式 init-expr 求值,然后进入循环;在循环中它先对测试条件 test-expr 求值,如果测试结果为假就会推出,否则执行循环体 body-statement ;最后最更新表达式 update-expr 求值。
GCC为for循环产生的代码是while循环的两种翻译之一,这取决于优化的等级,也就是,跳转到中间策略会得到如下 goto 代码:
如上述代码所示,用 for 循环编写阶乘函数最自然的方式就是将从 2 一直到 n 的因子乘起来,因此,这个函数与我们使用 while
或者 do-while 循环的代码很不一样。
用这些部分替换前面给出的模板中相应的位置,就把 for 循环转换成了 while 循环,得到如下代码:
对 while 循环进行跳转到中间变换,得到如下 goto 代码:
确实,仔细看使用命令行选项 -Og 的GCC产生的汇编代码,会发现它非常接近于以下模板:
综上,C语言中三种形式的所有的循环——do-while 、while 和 for ——都可以用一种简单的策略来翻译,产生包含一个或多个条件分支的代码。控制的条件转移提供了将循环翻译成机器代码的基本机制。
switch 语句可以根据一个整数索引值进行多重分支(multiway branching)。在处理具有多种可能结果的测试时,这种语句特别有用。它们不仅提高了C语言的可读性,而且通过使用跳转表(jump table)
这种数据结构使得实现更加高效。跳转表是一个数组,表项 i 是一个代码段的地址,这个代码段实现当开关索引值等于 i 时程序应该采取的动作。程序代码有ongoing开关索引值来执行一个跳转表内的数组引用,确定跳转指令的目标。和使用一组很长的 if-else 语句相比,使用跳转表的优点是执行开关语句的时间与开关情况的数量无关。GCC根据开关情况的数量和开关情况值得稀疏程度来翻译开关语句。当开关情况数量比较多(例如 4 个以上),并且值的范围跨度比较小时,就会使用跳转表。
下图a 是一个C语言 switch 语句的示例。这个例子有些有意思的特征,包括情况表号(case table)跨过一个不连续的区域(对于101 和 105 没有标号),有些情况有多个标号(104 和 106),而有些情况则会落入其他情况之中(102),因为对应该情况的代码段没有以 break 语句结尾。
下图是编译 switch_eg时产生的汇编代码。这段代码的行为用C语言来描述就是上图b中的过程 switch_eg_impl。这段代码使用了GCC提供的对跳转表的支持,这是对C语言的扩展。数组 jt 包含 7 个表项,每个都是一个代码块的地址。这些位置由代码中的标号定义,在 jt 的表项中由代码指针指明,由标号加上’&&'前缀组成。(回想运算符 & 创建一个指向数据值的指针。在做这个扩展时,GCC的作者们创造了一个新的运算符 && ,这个运算符创建一个指向代码位置的指针。)
原始的C语言有针对值 100、102-104 和 106 的情况,但是开关变量 n 可以是任意整数。编译器首先将 n 减去 100,把取值范围移到 0 - 6 之间,创建一个新的程序变量,在我们的C版本中称为 index。补码表示的负数会映射成无符号表示的大正数,利用这一事实,将 index 看作 无符号
值,从而进一步简化了分支的可能性。因此可以通过测试 index 是否大于 6 来判定 index 是否在 0 - 6 的范围之外。在C和汇编代码中,根据 index 的值,有五个不同的跳转位置:loc_A(在汇编代码中表示为.L3)、loc_B(.L5)、loc_C(.L6)、loc_D(.L7) 和 loc_def(.L8),最后一个是默认的目的地址。每个标号都标识一个实现某个情况分支的代码块。在C和汇编代码中,程序都是讲 index 和 6 做比较,如果大于 6 就跳转到默认的代码处。
执行 switch 语句的关键步骤是通过跳转表来访问代码位置。在 C 代码中是第 16 行,一条 goto 语句引用了跳转表 jt。GCC支持计算 goto(computed goto),是对C语言的扩展。在我们的汇编代码版本中,类似地操作是在第 5 行,jmp 指令的操作数有前缀 ‘ * ’,表明这是一个间接跳转,操作数指定一个内存位置,索引由寄存器 %rsi 给出,这个寄存器保存着 index 的值。
C 代码将跳转表声明为一个有 7 个元素的数组,每个元素都是一个指向代码位置的指针。这些元素跨越 index 的值 0-6,对应于 n 的值 100 - 106。可以观察到,跳转表对重复情况的处理就是简单地对表项 4 和 6 用同样的代码表号(loc_D),而对于缺失的情况的处理就是对表项 1 和 5 使用默认情况的标号(loc_def)。
这些声明表明,在叫做“.rodata(只读数据,Read-Only Data)”的目标代码文件的段中,应该有一组 7 个“四”字(8个字节),每个字的值都是与指定的汇编代码标号(例如.L3)相关联的指令地址。标号.L4标记出这个分配地址的起始。与这个标号相对应的地址会作为间接跳转(第5行)的基地址。
不同的代码块(C标号 loc_A 和 loc_D 和 loc_def)实现了 switch 语句的不同分支。它们中的大多数只是简单地计算了 val 的值,然后跳转到函数的结尾。类似地,汇编代码块计算了寄存器 % rdi 的值,并且跳转到函数结尾处由标号.L2指示的位置。只有情况标号 102 的代码不是这种模式的,正好说明在原始 C代码中情况 102 会落到情况 103 中。具体处理如下:以标号.L5起始的汇编代码块中,在快结尾处没有 jmp 指令,这样代码就会继续执行下一个块。类似的,C版本 switch_rg_impl 中以标号loc_B 起始的块的结尾处也 goto 语句。
检查所有这些代码需要很仔细的研究,但是关键是领会使用跳转表是一种非常有效的实现多重分支的方法。在我们的例子中,程序可以只用一次跳转表引用就分支到 5 个不同的位置。甚至当 switch 语句有上百种情况的时候,也可以只用一次跳转表访问去处理。
过程是软件中一种很重要的抽象。 它提供了一种封装代码的方式,用一组指定的参数和一个可选的返回值实现了某种功能。然后,可以在程序中不同的地方调用这个函数。设计良好的软件用过程作为抽象机制,隐藏某个行为的具体实现,同时又提供清晰简洁地接口定义,说明要计算的是哪些值,过程会对程序状态产生什么样的影响。不同编程语言中,过程的形式多样:函数(function)、方法(method)、子例程(subroutine)、处理函数(handler)等等,但是它们有一些共有的特性。
要提供对过程的机器级支持,必须要处理许多不同的属性。为了讨论方便,假设过程 P 调用过程 Q,Q执行后会返回到 P。这些动作包括一个或多个机制:
传递控制
。在进入过程 Q 的时候,程PC必须被设置为 Q 的代码的起始地址,然后在返回时,要把PC设置为 P 中调用 Q后面那条指令的地址。
传递数据
。P 必须能够想 Q 提供一个或多个参数,Q 必须能够向 P 返回一个值。
分配和释放内存
。在开始时,Q 可能需要为局部变量分配空间,而在返回前,又必须释放这些存储空间。
x86-64 的过程实现包括一组特殊的指令和一些对机器资源(例如寄存器和程序内存)使用的约定规则。人们花了大量的力气来尽量减少过程调用的开销。所以,它遵循了被认为是最低要求策略的方法,只实现上述机制中每个过程所必须的那些。接下来,我们一步步地构建起不同的机制,先描述控制,再描述数据传递,最后是内存管理。
C语言过程调用机制的一个关键特性(大多数其他语言也是如此)在于使用了栈数据结构提供的后进先出的内存管理原则。在过程 P 调用过程 Q 的例子中,可以看到当 Q 在执行时,P 以及所有在向上追溯到 P 的调用链中的过程,都是暂时被挂 起的。当 Q 运行时,它只需要为局部变量分配新的存储空间,或者设置到另一个过程的调用。另一方面,当 Q 返回时,任何它所分配的局部存储空间都可以被释放。因此,程序可以用栈来管理它的过程所需要的存储空间,栈和程序寄存器存放着传递控制和数据、分配内存所需要的信息。当 P 调用 Q 时,控制和数据信息添加到栈尾。当 P 返回时,这些信息会释放掉。
x86-64 的栈向低地址方向增长,而栈指针 %rsp 指向栈顶元素。可以用 pushq 和 popq 指令将数据存入栈中或是从栈中取出。将栈指针减少一个适当的量可以为没有指定初始值的数据在栈上分配空间。类似地,可以通过增加栈指针来释放空间。
当 x86-64 过程需要的存储空间超过寄存器能够存放的大小时,就会在栈上分配空间。这个部分称为过程的栈帧(stack fram)
。下图给出了运行时栈的通用结构,包括把它划分为栈帧。当前正在执行的过程的帧总是在栈顶。当过程 P 调用过程 Q 时,会把返回地址压入栈中,指明当 Q 返回时,要从 P 程序的哪个位置继续执行。我们把这个返回地址当做 P 的栈帧的一部分,因为它存放的是与 P 相关的状态。 Q 的代码会扩展当前栈的边界,分配它的栈帧所需的空间。在这个空间中,它可以保存寄存器的值,分配局部变量空间,为它调用的过程设置参数。大多数过程的栈帧都是定长的,在过程的开始就分配好了。但是有些过程需要边长的帧,这个问题会在之后讨论。通过寄存器,过程 P 可以传递最多 6 个整数值(也就是指针和整数),但是如果 Q 需要更多的参数,P 可以在调用 Q 之前在自己的栈帧里存储好这些参数。
为了提高空间和时间效率,x86-64 过程只分配自己所需要的栈帧部分。例如,许多过程有 6 个或者更少的参数,那么所有的参数都可以通过寄存器传递。因此,上图中画出的某些栈帧部分可以忽略。实际上,许多函数根本不需要栈帧。当所有的局部变量都可以保存在寄存器中,而且该函数不会调用任何其他函数时,就可以这样处理。例如,上面例举过得所有函数都不需要栈帧。
将控制从函数 P 转移到函数 Q 只需要简单地把PC设置为 Q 的代码的起始位置。不过,当稍后从 Q 返回的时候,处理器必须记录好它需要继续 P 的执行的代码位置。在 x86-64 机器中,这个信息是用指令 call Q 调用过程 Q 来记录的。该指令会把地址 A 压入栈中,并将 PC 设置为 Q 的起始地址。压入的地址 A 被称为返回地址,是紧跟在 call 指令后面的那条指令的地址。对应的指令 ret 会从栈中弹出地址 A,并把 PC 设置为 A。
call 指令有一个目标,即指明被调用过程起始的指令地址。同跳转一样,调用可以是直接的,也可以是间接的。在汇编代码中,直接调用的目标时一个标号,而间接调用的目标时 * 后面跟一个操作数指示符。
在main 函数中,地址为 0x400563 的 call 指令调用函数 multstore。此时如下图a的状态,指令了栈指针 %rsp 和 PC %rip 的值。 call 的效果是将返回地址 0x400568 压入栈中,并调到函数 multstore 的第一条指令,地址为 0x0400540。函数 multstore 继续执行,知道遇到地址 0x40054d 处的 ret 指令。这条指令从栈中弹出值 0x400568,然后跳转到这个地址,就在 call 指令之后,继续 main 函数的执行。
在来看一个更详细说明在过程间传递控制的例子,下图给出了两个函数 top 和 leaf 的反汇编代码,以及 main 函数中调用 top 处的代码。每条指令都以标号标出:L1 ~ L2(leadf中),T1 ~ T4(main中)和 M1 ~ M2(main中)。
下图给出了这段代码执行的详细过程,main调用 top(100),然后 top 调用 leaf(95)。函数 leaf 向 top 返回 97,然后 top 向 main 返回 194。前面三列描述了被执行的指令,包括指令标号、地址和指令类型。后面四列给出了在该指令执行前程序的状态,包括寄存器 %rdi、%rax 和 %rsp的内容,以及位于栈顶的值。这张表的内容说明了运行时栈在管理支持过程调用和返回所需存储空间中的重要作用。
leaf 的指令 L1 将 %rax 设置为 97,也就是要返回的值。然后指令 L2 返回,它从栈中弹出 0x400054e。通过将 PC 设置为这个弹出的值,控制转移回 top 的T3指令。程序成功完成对 leaf 的调用,返回到 top。
指令 T3 将 %rax 设置为 194,也就是要从 top 返回的值。然后指令 T4 返回,它从栈中弹出 0x400560,因此将PC设置为 main 的M2 指令。程序成功完成对 top 的调用,返回到main。可以看到,此时栈指针也恢复成了 0x7fffffffe820,即调用 top 之前的值。
可以看到,这种把返回地址压入栈的简单的机制能够让函数在稍后返回到程序中正确的点。C语言标准的调用/返回机制刚好与栈提供的后进先出的内存管理方法吻合。
当调用一个过程时,出了要把控制传递给它并在过程返回时再传递回来之外,过程调用还可能包括把数据作为参数传递,从而过程返回还有可能包括返回一个值。x86-64 中,大部分过程间的数据传送是通过寄存器实现的。例如,我们已经看到之前的函数示例,参数在寄存器 %rdi 、%rsi 和其他寄存器中传递。当过程 P 调用过程 Q 时,P的代码必须首先把参数复制到适当的寄存器中。类似地,当 Q 返回到 P 时,P的代码可以访问寄存器 %rax 中的返回值。本节中,更详细地探讨这些规则。
x86-64 中,可以通过寄存器最多传递 6 个整型(例如整数和指针)参数。寄存器的使用是有特殊顺序的,寄存器使用的名字取决于要传递的数据类型的大小,如下表所示,会根据参数在参数列表中的顺序为它们分配寄存器。可以通过 64 位寄存器适当的部分访问小于 64 位的参数。例如,如果第一个参数是 32 位的,那么可以用 %edi 来访问它。
如果一个函数有大于6个整型参数,超过6个的部分就要通过栈来传递。假设过程 P 调用过程 Q,有n个整型参数,且 n > 6。那么 P 的代码分配的栈帧必须要能够容纳 7 到 n 号参数的存储空间,要把参数 1 ~ 6 复制到对应的寄存器,把参数 7 ~ n 放到栈上,而参数 7 位于栈顶。通过栈传递参数时,所有的数据大小都向 8 的倍数对齐。参数到位以后,程序就可以执行 call 指令将控制转移到过程 Q 了。过程 Q 可以听过寄存器访问参数,有必要的话可以通过栈访问。相应的,如果 Q 也调用了某个有超过 6 个参数的函数,它也需要在自己的栈帧中为超过 6 个部分的参数分配空间,还记得之前描述栈的图中的“参数构造区”吗?
作为参数传递的示例,考虑下图a 所示的C函数 proc。这个函数有 8 个参数,包括字节数不同的整数(8、4、2 和 1)和不同类型的指针,每个都是 8 字节的。
上图b 中给出 proc 生成的汇编代码。前面 6 个参数通过寄存器传递,后面 2 个通过栈专递,就像下图画出来的那样。可以看到,作为过程调用的一部分,返回地址被压入栈中。因而这两个参数位于相对于栈指针距离为 8 和 16 的位置。在这段代码中,可以看到根据操作数的大小,使用了 ADD 指令的不同版本:a1(long)使用 addq,a2(int)使用 addl,a3(short)使用 addw,而 a4(char)使用 addb。注意第 6 行的 movl 指令从内存读入 4 字节,而后面的 addb 指令只使用其中的低位一字节。
到目前为止,我们看到的大多数示例都不需要超出寄存器大小的本地存储区域。不过有些时候,局部数据必须存放在内存中,常见的情况包括:
来看一个处理地址运算符的例子,下图a 中给出的两个函数。函数 swap_add 交换指针 xp 和 yp 指向的两个值,并返回这两个值的和。函数 caller 创建到局部变量 arg1 和 arg2 的指针,把它们传递给 swap _add。下图b展示了 caller 是如何用栈帧来实现这些局部变量的。caller 的代码开始的时候把栈指针减掉 16,实际上这就是在栈上分配了 16 个字节。S表示栈指针的值,可以看到这段代码计算 &arg2 为 S + 8(第5行),而 &arg1 为 S。因此可以推断局部变量 arg1 和 arg2 存放在栈帧中相对于栈指针偏移量为 0 和 8 的地方。当对 swap_add 的调用完成后,caller 的代码会从栈上取出这两个值(第8-9行),计算它们的差,再乘以 swap_add 在寄存器 %rax 中返回的值(第10行)。最后,该函数把栈指针加16,释放栈帧(第11行)。通过这个例子可以看到,运行时栈提供了一种简单的、在需要时分配、函数完成时释放局部存储的机制。
函数 call_proc 是一个更复杂的例子,说明 x86-64 栈行为的一些特性。尽管这个例子有点长,但还是值得研究。它给出了一个必须在栈上分配局部变量存储空间的函数,同时还要向有 8 个参数的函数 proc 传递至。该函数创建一个栈帧。
看看 call_proc 的汇编代码可以看到,代码中一大部分(2 ~ 15)是为调用 proc 做准备。其中包括为局部变量和函数参数建立栈帧,将函数参数加载至寄存器。如下图,在栈上分配局部变量 x1 ~ x4,它们具有不同的大小:24 ~ 31(x1)、20 ~ 23(x2)、18 ~ 19(x3)和17(x4)。用 leaq 指令生成到这些位置的指针,(第7、10、12、14行)。参数 7 (值为4)和 8(指向 x4 的位置的指针)存放在栈中相对于栈指针偏移量为 0 和 8 的地方。
当调用过程 proc 时,程序会开始执行上图b中的代码,参数7 和 8 现在位于相对于栈指针偏移量 8 和 16 的地方,因为返回地址时已经被压入栈中了。
当程序返回 call_proc 时,代码会去除 4 个局部变量(第17 ~ 20行),并执行最终的计算,在程序结束前,把栈指针加 32 ,释放这个栈帧。
寄存器组是唯一被所有过程共享的资源。虽然在给定时刻只有一个过程是活动的,我们仍然必须确保当一个过程(调用者)调用另一个过程(被调用者)时,被调用者不会覆盖调用者稍后会使用的寄存器值。为此,x86-64 采用了一组统一的寄存器使用惯例,所有的过程(包括程序库)都必须遵循。
根据惯例,寄存器 %rbx、%rbp 和 %r12 ~ %r15 被划分为被调用者保存
寄存器。当过程 P 调用过程 Q 时,Q 必须保存这些寄存器的值,保证它们的值在 Q 返回到 P 时与 Q 被调用时是一样的。过程 Q 保存一个寄存器的值不变,要么就是根本不去改变它,要么就是把原始值压入栈中,改变寄存器的值,然后在返回前从栈中弹出旧值。压入寄存器的值会在栈帧中创建标号为“保存的寄存器”的一部分,有了这条惯例,P 的代码就能安全地把值存在被调用者保存寄存器中(当然,要先把之前的值保存到栈上),调用 Q,然后继续使用寄存器中的值,不同担心值被破坏。
所有其他的寄存器,除了栈指针 %rsp ,都分类为调用者保存
寄存器。这就意味着任何函数都能修改它们。可以这样来理解“调用者保存”这个名字:过程 P 在某个此类寄存器中有局部数据,然后调用过程 Q。因为 Q 可以随意修改这个寄存器,所以在调用之前首先保存好这个数据是P(调用者)的责任。
看个例子,下图a中的函数 P。它两次调用 Q。在第一次调用中,必须保存 x 的值以备后面使用。类似地,在第二次调用中,也必须保存 Q(y) 的值。图b中,可以看到GCC生成的代码使用了两个被调用者保存寄存器:%rbp 保存 x 和 %rbx 保存计算出来的Q(y)的值。在函数的开头,把这两个寄存器的值保存到栈中(第2 ~ 3 行)。在第一次调用 Q 之前,把参数 x 复制到 %rbp(第5行)。在第二次调用 Q 之前,把这次调用的结果复制到 %rbx (第8行)。在函数的结尾,(第13 ~ 14行),把它们从栈中弹出,恢复这两个被调用者保存寄存器的值。注意它们的弹出顺序与压入顺序相反,说明了栈的后进先出规则。
前面已经描述的寄存器和栈的惯例使得 x86-64 过程能够递归地调用它们自身。每个过程调用在栈中都有它自己的私有空间,因此多个未完成调用的局部变量不会相互影响。此外,栈的原则很自然地就提供了适当的策略,当过程被调用时分配局部存储,当返回时释放存储。
下图给出了递归地阶乘函数的C代码和生成的汇编代码。可以看到汇编代码使用寄存器 %rbx 来保存参数 n,先把已有的值保存在栈上(第2行),随后在返回前恢复该值(第11行)。根据栈的使用特性和寄存器保存规则,可以保证当递归调用 rfact( n - 1 )返回时(第9行),(1)该次调用的结果会保存在寄存器 %rax 中,(2)参数 n 的值仍然在寄存器 %rbx 中。把这两个值相乘就能得到期望的结果。
从这个例子中我们可以看到,递归调用一个函数本身与调用其他函数是一样的 。栈桂策提供了一种机制,每次函数调用都有它自己私有的状态信息(保存的返回位置和被调用者寄存器保存的值)存储空间。如果需要,它还可以提供局部变量的存储。栈分配和释放的规则很自然地就与函数调用-返回的顺序匹配。这种实现函数调用和返回的方法甚至对更复杂的情况也使用,包括相互递归调用(例如,P 调用 Q,Q 再调用 P)。
C语言中的数组是一种将标量数据聚集成更大数据类型的方式。C语言实现数组的方式非常简单,因此很容易翻译成机器代码。C语言的一个不同寻常的特点就是可以产生指向数组中元素的指针,并针对这些指针进行运算。在机器代码中,这些指针会被翻译成地址计算。
优化编译器非常善于简化数组索引所使用的地址计算。不过这使得 C 代码和它到机器代码的翻译之间的对应关系有些难以理解。
对于数据类型 T 和整型常数 N,声明如下:
T A[N];
起始位置表示为 xa。这个声明有两个效果,首先,它在内存中分配了一个 L * N 字节的连续区域,这里 L 是数据类型 T 的大小(单位为字节)。其次,它引入了标识符 A,可以用 A 来作为指向数组开头的指针,这个指针的值就是 xa。可以用 0 ~ N-1 的整数索引来访问该数组元素。数组元素 i 会被存放在地址为 sa + L * i 的地方。
作为示例,看看下面的声明:
这些声明会产生带下列参数的数组:
数组 A 由 12 个单字节(char)元素组成。数组 C 由 6 个整数组成,每个需要 8 个字节。 B 和 D 都是指针数组,因此每个数组元素都是 8 个字节。
x86-64 的内存引用指令可以用来简化数组访问。例如,假设 E 是一个 int 型的数组,而我们想计算 E[i],在此,E 的地址存放在寄存器 % rdx 中,而 i 存放在寄存器 % rcx中。然后,指令 movl (%rdx,%rcx,4),%eax 会执行地址计算 xe + 4 * i ,读这个内存位置的值,并将结果存放到寄存器 %eax 中。允许的伸缩因子1、2、4 和 8覆盖了所有基本简单数据类型的大小。
C 语言允许对指针进行运算,而计算出来的值会根据该指针引用的数据类型的大小进行伸缩。也就是说,如果 p 是一个指向类型为 T 的数据的指针,p 的值为 xp,那么表达式 p + i 的值为 xp + L* i,这里 L 是数据类型 T 的大小。
单操作数操作符‘ & ’和‘ * ’可以产生指针和间接引用指针。也就是,对于一个表示某个对象的表达式 Expr ,&Expr 是给出该对象地址的一个指针。对于一个表示地址的表达式 AExpr,&AExpr 给出该地址处的值。因此,表达式 Expr 与 * &Expr是等价的。可以对 数组和指针应用数组下标操作。数组引用 A[i] 等同于表达式 *(A + i)。它计算第 i 个数组元素的地址,然后访问这个内存位置。
扩展一下之前的例子,假设整形数组 E 的起始地址和整数索引 i 分别存放在寄存器 %rdx 和 %rcx 中。下面是一些与 E 有关的表达式。我们还给出了每个表达式的汇编代码实现,结果存放在寄存器 %eax(如果是数据)或寄存器 %rax(如果是指针)中。
(上图第二行汇编代码改为 movl (%rdx),%eax)
在这些例子中,可以看到返回数组值的操作类型为 int,因此设计 4 字节操作(例如 movl)和寄存器(例如 %eax)。那些返回指针的操作类型为 int *,因此涉及 8 字节操作(例如 leaq)和寄存器(例如 %rax)。最后一个例子表明可以计算同一个数据结构中的两个指针之差,结果的数据类型为 long,值等于两个地址之差除以该数据类型的大小。
当我们创建数组的数组时(多维数组),数组分配和引用的一般原则也是成立的。例如,声明 int A[5][3]; 等价于下面的声明:
数据类型 row3_t 被定义为一个 3 个整数的数组,数组 A 包含 5 个这样的元素,每个元素需要 12 个字节来存储 3个整数。整个数组的大小就是 12 * 5 = 60字节。
数组 A 还可以被看成一个 5行3列 的二维数组,用 A[0][0] 到 A[4][2]来引用。数组元素在内存中按照“行优先”的顺序排列,意味着第 0 行的所有元素,可以写作 A[0],后面跟着第 1 行的所有元素(A [1]),以此类推,如下图。
这种排列顺序是嵌套声明的结果。将 A 看作一个有 5 个元素的数组,每个元素都是 3 个 int 的数组,首先 A[0],然后是 A[1],以此类推。
要访问多维数组的元素,编译器会移数组起始为基地址(可能需要经过伸缩)偏移量为索引。产生计算期望的元素的偏移量,然后使用某种 MOV 指令。通常来说,对于一个声明如下的数组:
T D[R][C];
这里, L 是数据类型 T 以自己为单位的大小。作为一个示例,考虑前面定义的 5 * 3 的整形数组 A。假设 xa、i 和 j 分别在寄存器 %rdi、%rsi 和 %rdx 中。然后,可以用下面的代码将数组元素 A[i][j] 复制到寄存器 %eax 中:
正如可以看到的那样,这段代码计算元素的地址为 xa + 12i + 4j = xa + 4(3i + j),使用了 x86-64 地址运算的伸缩和加法特性。
C语言编译器能够优化定长多维数组上的操作代码。这里我们展示优化等级设置为 -o1 时GCC采用的一些优化。假设我们用如下方式将数据类型 fix_matrix 声明为 16 * 16 的整形数组:
(这个例子说明了一个很好的编码习惯。当程序要用一个常数作为数组的维度或者缓冲区的大小时,最好通过 #define 声明将这个常数与一个名字联系起来,然后在后面一直使用这个名字代替常数的数值)。下图a中的代码计算矩阵 A 和 B 乘积的元素 i,k,即 A 的行 i 和 B 的列 k 的内积。GCC产生的代码(我们再反汇编成 C),如下图b中函数 fix_prod_ele_opt 所示。这段代码包含很多聪明的优化。它去掉了整数索引 j ,并把所有的数组引用都转换成了指针间接引用,其中包 括(1)生成一个指针,命名为 Aptr,指向 A 的行 i 中连续的元素;(2)生成一个指针,命名为 Bptr,指向 B 的列 k 中连续的元素;(3)生成一个指针,命名为 Bend,当需要终止该循环时,它会等于 Bptr 的值。Aptr 的初始值是 A 的行 i 的第一个元素的地址,由 C 表达式 &A[i][0] 给出。Bptr 的初始值是 B 的列 k 的第一个元素的地址,由 C 表达式 &B[0][k] 给出。Bend 的值是假象中 B 的列 j 的第(n + 1)个元素的地址,由 C 表达式 &B[N][k]给出。
下面给出的是 GCC 为函数 fix_prod_ele 生成的这个循环的实际汇编代码。我们看到 4 个寄存器的使用如下: %eax 保存 result,%rdi 保存 Aptr,%rcx 保存 Bptr,而 %rsi 保存 Bend。
历史上,C语言只支持大小在编译时就能确定的多维数组(对第一维可能有些例外)、程序员需要变长数组时不得不用 malloc 或 calloc 这样的函数为这些数组分配存储空间,而且不得不显式地编码,用行优先索引将多维数组映射到一维数组。ISO C99 引入了一种功能,允许数组的维度是表达式,在数组被分配的时候才计算出来。
在变长数组的 C 版本中,我们可以将一个数组声明如下:
int A[expr1][expr2];
它可以作为一个局部变量,也可以作为一个函数的参数,然后在遇到这个声明的时候,通过对表达式 expr1 和 expr2 求值来确定数组的维度。因此,例如要访问 n * n 数组的元素 i ,j ,我们可以写一个如下的函数:
参数n 必须在参数 A[n][n] 之前,这样函数就可以在遇到这个数组的时候计算出数组的维度。
正如注释所示,这段代码计算元素 i,j 的地址为 xa + 4(n * i) +4j = xa + 4(n * i + j)。这个地址的计算类似于定长数组的地址计算,不同点在于(1)由于增加了参数 n,寄存器的使用变化了;(2)用了乘法指令来计算 n * i(第2行),而不是用 leaq 指令来计算 3i。因此引用变长数组只需要对定长数组做一点儿概括。动态的版本必须用乘法指令对 i 伸缩 n 倍,而不能用一系列的移位和加法。在一些处理器中,乘法会招致严重的性能处罚,但是再这种情况中无可避免。
在一个循环中引用变长数组时,编译器常常可以利用访问模式的规律性来优化索引的计算。例如,下图a 给出的C代码,它计算两个 n * n 矩阵 A 和 B 乘积的元素 i, k。GCC产生的汇编代码,我们再重新变为 C代码。这个代码与固定大小数组的优化代码风格不同,不过这更多的是编译器选择的结果,而不是两个函数有什么根本的不同造成的。图b的代码保留了循环变量 j。用以判断循环是否结束和作为 A 的行 i 的元素组成的数组的索引。
我们看到程序既使用了伸缩过得值 4n(寄存器 %r9)来增加Bptrt,也使用了 n 的值(寄存器 %rdi)来检查循环的边界。C 代码中并没有体现出需要这两个值,但是由于指针运算的伸缩,才使用了这两个值。
可以看到,如果允许使用优化, GCC能够识别出程序访问多维数组的元素的步长。然后生成的代码会避免直接应用等式会导致的乘法。不论生成基于指针的代码,还是基于数组的代码,这些优化都能显著提高程序的性能。
C 语言提供了两种将不同类型的对象组合到一起创建数据类型的机制:结构(structure)
,用关键字 struct 来声明,将多个对象集合到一个单位中;联合(union)
,用关键词 union 来声明,允许用几种不同的类型来引用一个对象。
C 语言的 struct 声明创建一个数据类型,将可能不同类型的对象聚合到一个对象中。用名字来引用结构的各个组成部分。类似于数组的实现,结构的所有组成部分都存放在内存中一段连续的区域内,而指向结构的指针就是结构第一个字节的地址。编译器维护关于每个结构类型的信息,指示每个字段(field)的字节偏移。它以这些偏移作为内存引用指令中的位移,从而产生对结构元素的引用。
可以声明一个 struct rect 类型的变量r,并将它的字段值设置如下:
这里表达式 r.llx 就会选择结构 r 的 llx 字段。
将指向结构的指针从一个地方传递到另一个地方,而不是复制它们,这是很常见的。例如,下面的函数计算长方形的面积,这里,传递给函数的就是一个指向长方形 struct 的指针:
表达式(rp).width 间接引用了这个指针,并且选取所得结构的 width 字段。这里必须要用括号,因为编译器会将表达式rp.width 解释为 *(rp.width),而这时非法的。间接引用和字段选取结合起来非常常见,以至于 C 语言提供了一种替代的表示法->。即 rp->width 等价于表达式(*rp).width。例如,我们可以写一个函数。它将一个长方形顺时针旋转90度:
C++ 和 Java 的对象比 C 语言中的结构要复杂精细得多,因为它们将一组可以被调用来执行计算的方法与一个对象联系起来。在C语言中,我们可以简单地把这些方法写成普通函数,就像上面所示的函数 area 和 rotate_left。
让我们来看看这样一个例子,考虑下面这样的结构声明:
这个结构包括 4 个字段:两个 4 字节 int、一个由两个类型为 int 的元素组成的数组和一个 8 字节整型指针,总共是 24 个字节:
可以观察到,数组 a 是嵌入到这个结构中的。上图中顶部的数字给出的是各个字段相对于结构开始处的字节偏移。
为了访问结构的字段,编译器产生的代码要将结构的地址加上适当的偏移。例如,假设 struct rec* 类型的变量 r 放在寄存器 %rdi 中。那么下面的代码将元素 r -> i 复制到元素 r -> j :
因为字段 i 的偏移量为 0,所以这个字段的地址就是 r 的值。为了存储到字段 j,代码要将 r 的地址加上偏移量 4。
要产生一个指向结构内部对象的指针,我们只需将结构的地址加上该字段的偏移量。例如,只用加上偏移量 8 +4 * 1 = 12,就可以得到指针&(r -> a[1])。对于在寄存器 %rdi 中的指针 r 和在寄存器 %rsi 中的长整数变量 i,我们可以用一条指令产生指针&(r -> a[i])的值:
综上所述,结构的各个字段的选取完全是在编译时处理的。机器代码不包含关于字段声明或字段名字的信息。
联合提供了一种方式,能够规避 C 语言的类型系统,允许以多种类型来引用一个对象。联合声明的语法与结构的语法一样,不过语义相差比较大。它们是用不同的字段来引用相同的内存块。
考虑下面的声明:
在一台 x86-64 Linux 机器上编译时,字段的偏移量、数据类型 S3 和 U3 的完整大小如下:
(稍后会解释 S3 中 i 的偏移量为什么不是 1 而是 4,以及为什么 v 的偏移量是 16 而不是 9 或者 12)对于类型 union U3 * 的指针 p,p -> c、p -> i [0] 和 p -> v 引用的都是数据结构的起始位置。还可以观察到,一个联合的总的大小等于它最大字段的大小。
在一些上下文中,联合十分有用。但是,它也会引起一些讨厌的错误,因为它们绕过了 C 语言类型提供提供的安全措施。一种应用情况是,我们事先知道对一个数据结构中两个不同字段的使用是互斥的(就是用了一个,就不会用另一个),那么将这两个字段声明为联合的一部分,而不是结构的一部分,会减少分配空间的总量。
例如,假设我们想实现一个二叉树的数据结构,每个叶子节点都有两个 double 类型的数据值,而每个内部节点都有指向两个孩子节点的指针,但是没有数据。如果声明如下:
那么每个节点需要 32 个字节,每种类型的节点都要浪费一半的字节。相反,如果我们如下声明一个节点:
那么,每个节点就只需要 16 个字节。如果 n 是一个指针,指向 union node_u * 类型的节点,我们用 n -> data[0] 和 n -> data[1] 来引用叶子节点的数据,而用 n -> internal.left 和 n -> internal.right 来引用内部节点的孩子。
不过,如果这样编码,就没有办法来确定一个给定的节点到底是叶子节点,还是内部节点。通常的方法是引入一个枚举类型,定义这个联合中可能的不同选择,然后再创建一个结构,包含一个标签字段和这个联合:
这个结构总共需要 24 个字节:type 是 4 个字节,info.internal.left 和 info.internal.right 各要 8 个字节,或者是info.data 要 16 个字节。我们后面很快会谈到,在字段 type 和联合的元素之间需要 4 个字节的填充,所以整个结构大小为 4 + 4 + 16 = 24。对于由较多字段的数据结构,这样的节省会更加吸引人。
联合还可以用来访问不同数据类型的位模式。例如,假设我们使用简单的强制类型转换将一个 double 类型的值 d 转换为 unsigned long 类型的值 u:
值 u 会是 d 的整数表示。除了 d 的值为 0.0 的情况以外,u 的位模式会与 d 的很不一样。再看下面这个代码,从一个 double 产生一个 unsigned long 类型的值:
在这段代码中,我们以一种数据类型来存储联合中的参数,又以另一种数据类型来访问它。结果会是 u 具有和 d 一样的位模式,包括符号位字段、指数 和 尾数。u 的数值与 d 的数值没有任何关系,除了 d 等于 0.0 的情况。
当用联合来将各种不同大小的数据类型结合到一起时,字节顺序问题就变得很重要了。例如,假设我们写了一个过程,它以两个 4 字节的 unsigned 的位模式,创建一个 8 字节的 double。
在 x86-64 这样的小端法机器上,参数 word0 是 d 的低位4个字节,而 word1 是高位4个字节,在大端法机器上则相反。
【我很奇怪这个答案倒数第二行的用法算做上面说的"互斥"的情况,第二行 %rax 少写了r ,第三行 addq 的立即数应该是 $a】
许多计算机系统对基本数据类型的合法地址做出了一些限制,要求某种类型对象的地址必须是某个值K(通常是2、4 或 8)的倍数。这种对齐限制
简化了形成处理器和内存系统之间接口的硬件设计。例如,假设一个处理器总是从内存中取 8 个字节,则地址必须为 8 的倍数。如果我们能保证将所有的 double 类型数据的地址对齐成 8 的倍数,那么就可以用一个内存操作来读或者写值了。否则,我们可能需要执行两次内存访问,因为对象可能被分放在两个 8 字节内存块中。
无论数据是否对齐,x86-64 硬件都能正确工作。不过,Intel 还是建议要对齐数据以提高内存系统的性能。对齐原则是任何 K 字节的基本对象的地址必须是 K 的倍数。可以看到这条原则会得到如下对齐:
确保每种数据类型都是按照指定方式来组织和分配,即每种类型的对象都母案组它的对齐限制,就可保证实施对齐。编译器在汇编代码中放入指令,指明全局数据所需的对齐。例如,在之前跳转表的汇编代码声明在第 2 行包含下面这样的命令: .align 8
这就保证了它后面的数据(在此,是跳转表的开始)的起始地址是 8 的倍数。因为每个表项长 8 个字节,后面的元素都会遵循 8 字节对齐的限制。
对于包含结构的代码,编译器可能需要在字段的分配中插入间隙,以保证每个结构元素都满足它的对齐要求。而结构本身对它的起始地址也有一些对齐要求。
比如,考虑下面的结构声明:
假设编译器用最小的 9 字节分配,画出来是这样的:
它是不可能满足字段i(偏移为0)和j(偏移为5)的4字节对齐要求的。取而代之地,编译器在字段 c 和 j 之间插入一个 3 字节的间隙:
结果,j 的偏移量为 8 ,而整个结构的大小为 12 字节。此外,编译器必须保证任何 struct S1 * 类型的指针 p 都满足 4 字节对齐。用我们前面的符号,设指针 p 的值为 xp。那么,xp 必须是 4 的倍数。这就保证了 p -> i (地址xp)和p -> j(地址xp + 8 )都满足它们的 4 字节对齐要求。
另外,编译器结构的末尾可能需要一些填充,这样结构数组中的每个元素都会满足它的对齐要求。例如,考虑下面这个结构声明:
如果我们将这个结构打包成 9 个字节,只要保证结构的起始地址满足 4 字节对齐要求,我们仍然能够保证满足字段 i 和 j 的对齐要求。不过考虑下面的声明:
分配 9 个字节,不可能满足 d 的每个元素的对齐要求,因为这些元素的地址分别是 xd、xd + 9、xd + 18、 xd + 27。相反,编译器会为结构 S2 分配 12 个字节,最后 3 个字节是浪费的空间:
这样一来,d 的元素的地址分别为 xd 、xd + 12、xd + 24 和 xd + 36。只要 xd 是 4 的倍数,所有的对齐限制就都可以满足了。
因此,任何针对 x86-64 处理器的编译器和运行时系统都必须保证分配用来保存可能会被 SSE 寄存器读写的数据结构的内存,都必须满足 16 字节对齐。这个要求有两个后果:
到目前为止,我们已经分别讨论机器级代码如何实现程序的控制部分和如何实现不同的数据结构。在本节中,我们会看看数据和控制如何交互。首先,深入审视一下指针,它是 C 编程语言中最重要的概念之一,但是许多程序员对它的理解都非常浅显。我们复习符号调试器 GDB 的使用,用它自己检查机器级程序的详细运行。接下来,看看理解机器级程序如何帮我们研究缓冲区溢出,这是现实世界许多系统中一种很重要的安全漏洞。最后,查看机器级程序如何实现函数要求的栈空间大小在每次执行时都可能不同的情况。
指针是 C 语言的一个核心特色。它们以一种统一方式,对不同数据结构中的元素产生引用。对于编程新手来说,指针总是会带来很多的困惑,但是基本概念其实非常简单。在此,重点介绍一些指针和它们映射到机器代码的关键原则。
变量 ip 是一个指向 int 类型对象的指针,而 cpp 指针指向的对象自身就是一个指向 char 类型对象的指针。通常,如果对象类型为 T,那么指针的类型为 T *。特殊的 void * 类型代表通用指针。比如说,malloc 函数返回一个通用指针,然后通过显式强制类型转换或者赋值操作那样的隐式强制类型转换,将它转换成一个有类型的指针。指针类型不是机器代码中的一部分:它们是 C 语言提供的一种抽象,帮助程序员避免寻址错误。
每个指针都有一个值
。这个值是某个指定类型的对象的地址。特殊的 NULL(0)值表示该指针没有指向任何地方。指针用'&'运算符创建
。这个运算符可以应用到任何 lvalue 类的 C 表达式上,lvaule 意指可以出现在赋值语句左边的表达式。这样的例子包括变量以及结构、联合 和 数据的元素。我们已经看到,因为 leaq 指令是设计用来计算内存引用的地址的,& 运算符的机器代码实现常常用这条指令来计算表达式的值。* 操作符用于间接引用指针
。其结果是一个值,它的类型与该指针的类型一致。间接引用是内存引用来实现的,要么是存储到一个指定的地址,要么是从指定的地址读取。数组与指针紧密联系
。一个数组的名字可以像一个指针变量一样引用(但是不能修改)。数组引用(例如 a [ 3 ])与指针运算和间接引用(例如 * (a + 3))有一样的效果。数组引用的指针运算都需要用对象大小对偏移量进行伸缩。当我们写表达式 p + i,得到的地址计算为 &p + L * i,这里 L 是与 p 相关联的数据类型的大小。将指针从一种类型强制转换成另一种类型,只改变它的类型,而不改变它的值。
强制类型转换的一个效果是改变指针运算的伸缩。例如,如果 p 是一个 char * 类型的指针,它的值为 &p,那么表达式(int *)p + 7 计算为&p + 28,而 (int *)(p + 7 )计算为 &p + 7。(强制类型转换的优先级高于加法。)指针也可以指向函数
。这提供了很强大的存储和向代码传递引用的功能,这些引用可以被程序的某个其他部分调用。例如,如果我们有一个函数,用下面这个原型定义:然后,我们可以声明一个指针 fp,将它赋值为这个函数,代码如下:
int (*fp)(int,int *);
fp = fun;
然后用这个指针来调用这个函数:
int y = 1;
int result = fp(3,&y);
函数指针的值是该函数机器代码表示中第一条指令的地址。
*f 两边的括号是必须的,否则声明变成
int *f(int *);
它会被解读成
(int *)f(int *);
也就是说,它会被解释成一个函数原型,声明了一个函数 f,它以一个 int * 作为参数并返回一个 int *。
GUN的调试器 GDB 提供了许多有用的特性,支持机器级程序的运行时评估和分析。对于本书中的示例和联系,我们试图通过阅读代码,来推断出程序的行为。有了GDB,可以观察正在运行的程序,同时又对程序的执行有相当的控制,这使得研究程序的行为变为可能。
下图给出一些 GDB 命令的例子,帮助研究机器级 x86-64 程序。先运行 OBJ-DUMP 来获得程序的反汇编版本,是很有好处的。我们的示例都基于对文件 prog 运行 GDB,程序的描述和反汇编在
2.3 节。我们用下面的命令来启动 GDB:
linux> gdb prog
通常的方法是在程序中感兴趣的地方附近设置断点。断点可以设置在函数入口后面,或是一个程序的地址处。程序在执行过程中遇到一个断点时,程序会停下来,并将控制返回给用户。在断点处,我们能够以各种方式查看各个寄存器和内存位置。我们也可以单步跟踪程序,一次只执行几条指令,或是前进到下一个断点。
我们已经看到,C 对于数组引用不进行任何边界检查,而且局部变量和状态信息(例如保存的寄存器值和返回地址)都存放在栈中。这两种情况结合到一起就能导致严重的程序错误,对越界的数组元素的写操作会存储在栈中的状态信息。当程序使用这个被破坏的状态,试图重新加载寄存器或执行 ret 指令时,就会出现很严重的错误。
一种特别常见的状态破坏称为缓冲区溢出(buffer overflow)
。通常,在栈中分配某个字符数组来保存一个字符串,但是字符串的长度超出了为数组分配的空间。下面这个程序示例就说明了这个问题:
前面的代码给出了库函数 gets 的一个实现,用来说明这个函数的严重问题。它从标准输入读入一行,在遇到一个回车换行字符或某个错误情况时停止。它将这个字符串复制到参数 s 指明的位置,并在字符串结尾加上 null 字符。在函数 echo 中,我们只用了 gets 这个函数只是简单地从标准输入中读入一行,再把它回送到标准输出。
gets 的问题是它没有办法确定是否为保存整个字符串分配了足够的空间。在 echo 示例中,我们故意将缓冲区设置地非常小——只有 8 个字节长。任何长度超过 7 个字符的字符串都会导致写越界。
检查 GCC 为 echo 产生的汇编代码,看看栈是如何组织的:
下图画出了 echo 执行时栈的组织。该程序把栈指针减去了24,在栈上分配了 24 个字节。字符数组 buf 位于栈顶,可以看到,%rsp 被复制到 %rdi 作为调用 gets 的 puts 的参数。这个调用的参数和存储的返回指针之间的 16 字节是未被使用的。只要用户输入不超过 7 个字符,gets 返回的字符串(包括结尾的 null)就能够放进 buf 分配的空间里。
不过,长一些的字符串就会导致 gets 覆盖栈上存储的某些信息。随着字符串变长,下面的信息会被破坏:
字符串到 23 个字符之前都没有严重的后果,但是超过以后,返回指针的值以及更多可能的保存状态会被破坏。如果存储的返回地址的值被破坏了,那么 ret 指令会导致程序跳转到一个完全意想不到的位置。如果只看 C 代码,根本就不可能看出会有上面这些行为。只有通过研究机器代码级别的程序才能理解像 gets 这样的函数进行的内存越界写的影响。
我们的 echo 代码很简单,但是有点太随意了。更好一点的版本是使用 fgets 函数,它包括一个参数,限制待读入的最大字节数。通常,使用 gets 或任何能导致存储溢出的函数,都是不好的编程习惯。不幸的是,很多常用的库函数,包括 strcpy、strcat 和 sprintf,都有一个属性——不需要告诉它们目标缓冲区的大小,就产生一个字节序列。这样的情况就会导致缓冲区溢出漏洞。
【下面的反汇编可以看出来栈指针减少了16字节,所以画图的时候 %rsp 只在第二个空格处(一个空格 8 字节)。B答案返回地址修改为 0x0400034。】
缓冲区溢出的一个更加致命的使用,就是让程序执行它本来不愿意执行的程序。这是一种最常见的通过计算机网络攻击系统安全的方法。通常,输入给程序一个字符串,这个字符串包含一些可执行代码的字节编码,称为攻击代码*(exploit code)
,另外,还有一些字节会用一个指向攻击代码的指针覆盖返回地址。那么,执行 ret 指令的效果就是跳转到攻击代码。
在一种攻击形式中,攻击代码会使用系统调用启动一个 shell 程序,给攻击者提供一组操作系统函数。在另一种攻击形式中,攻击代码会执行一些未授权的任务,修复对栈的破坏,然后第二次执行 ret 指令,(表面上)正常返回到调用者。
让我们来看一个例子,在 1988年11月,著名的 Internet 蠕虫病毒通过 Internet 以四种不同的方法获取对许多计算机的访问。一种是对 finger 守护进程 fingerd 的缓冲区溢出攻击,fingerd 服务 FINGER 命令请求。通过以一个适当的字符串调用 FINGER,蠕虫可以使远程的守护进程缓冲区溢出并执行一段代码,让蠕虫访问远程系统。一旦蠕虫获得了对系统的访问,它就能自我复制,几乎完全地消耗掉计算机上所有的计算资源。结果,在安全专家制定出如何消除这种蠕虫的方法之前,成百上千的机器实际上都瘫痪了。这种蠕虫的始作俑者最后被抓住并被起诉。时至今日,人们还是不断地发现遭受缓冲区溢出攻击的系统安全漏洞,这更加突显了仔细编写程序的必要性。任何到外部环境的接口都应该是“防弹的”,这样,外部代理的行为才不会导致系统出现错误。
缓冲区溢出攻击的普遍发生给计算机系统造成了许多的麻烦。现代的编译器和操作系统实现了很多机制,以避免遭受这样的攻击,限制入侵者通过缓冲区攻击获得系统控制的方式。在本节中,我们会介绍一些 Linux 上最新 GCC 版本所提供的机制。
1…栈随机化
为了在系统中插入攻击代码,攻击者既要插入代码,也要插入指向这段代码的指针,这个指针也是攻击字符串的一部分。产生这个指针需要知道这个字符串放置的栈地址。在过去,程序的栈地址非常容易预测。对于所有运行同样程序和操作系统版本的系统来说,在不同的机器之间,栈的位置是相当固定的。因此,如果攻击者可以确定一个常见的 Web 服务器所使用的栈空间,就可以设计一个在许多机器上都能实施的攻击。以传染病来打个比方,许多系统都容易受到一种病毒的攻击,这种现象常被称为“安全单一化(security monoculture)
”。
栈随机化的思想使得栈的位置在程序每次运行时都有变化。因此,即使许多机器都运行同样的代码,它们的栈地址都是不同的。实现的方式是:程序开始时,在栈上分配一段 0 ~ n 字节的随机大小空间,例如,使用分配函数 alloca 在栈上分配指定字节数量的空间。程序不使用这段空间,但是它会导致程序每次执行时后续的栈位置发生变化。分配的范围 n 必须足够大,才能获得足够多的栈地址变化。但是又要足够小,不至于浪费程序太多的空间。
下面的代码是一种确定“典型的”栈地址的方法:
这段代码只是简单地打印出 main 函数中局部变量的地址。在 32 位 Linux 上运行这段代码 10000 次,这个地址的变化范围为 0xff7fc59c 到 0xffffd09c ,范围大约为 2的32次方。在更新一点的机器上运行 64 位Linux,这个地址的变化范围约是 2的32次方。
在 Linux 系统中,栈随机化已经变成了标准行为。它是更大的一类技术中的一种,这类技术称为地址空间布局随机化(Address-Space Layout Randomization)
,或者简称 ASLR。采用 ASLR,每次运行时程序的不同部分,包括程序代码、库代码、栈、全局变量 和 堆数据,都会被加载到内存的不同区域。这就意味着在一台机器上运行一个程序,与在其他机器上运行同样的程序,它们的地址映射大相径庭。这样才能够对抗一些形式的攻击。
然而,一个执着的攻击者总是能够用蛮力克服随机化,他可以反复地用不同的地址进行攻击。一种常见的把戏就是在实际的攻击代码前插入很长一段的 nop 指令。执行这种指令对程序计数器加一,使之除了指向下一条指令之外,没有任何的效果。只要攻击者能够猜中这段序列中的某个地址,程序就会经过这个序列,到达攻击代码。这个序列常用的术语是“空操作雪橇(nop sled)”,意思是程序会“滑过”这个序列。如果我们建立一个 256 个字节的 nop sled,那么枚举 2的15次方 个起始地址,就能破解 2的23次方 的随机化,这对于一个顽固的攻击者来说,是完全可行的。我们可以看到栈随机化和其他一些 ASLR 技术能够增加攻破一个系统的难度,因而大大降低了病毒或者蠕虫的传播速度,但是也不能提供完全的安全保障。
2.栈破坏检测
计算机的第二道防线是能够检测到何时栈已经被破坏。我们在 echo 函数示例中看到,破坏通常发生在超越局部缓冲区的边界时。在 C 语言中,没有可靠的方法来防止对数组的越界写。但是,我们能够在发生了越界写的时候,在造成任何有害结果之前,尝试检测到它。
最近的 GCC 版本在产生的代码中加入了一种 栈保护者(stack protector)
机制,来检测缓冲区越界。其思想是在栈帧中任何局部缓冲区与栈状态之间存储一个特殊的金丝雀值(canary)
,如下图所示。这个金丝雀值,也称为哨兵值(guard value)
,是在程序每次运行时随机产生的,因此,攻击者没有简单的办法能够知道它是什么。在恢复寄存器状态和从函数返回之前,程序检查这个金丝雀值是否被该函数的某个操作或者该函数调用的某个函数的某个操作改变了。如果是的,那么程序异常中止。
最近的 GCC 版本会试着确定一个函数是否容易遭受栈溢出攻击,并且自动插入这种溢出检测。实际上,面对前面的栈溢出展示,我们其实用了命令行选项“ -fno-stack-protector”来阻止 GCC 启用栈保护者。当不用这个选项来编译 echo 函数时,也就是允许使用栈保护者,得到下面的汇编代码:
这个版本的函数从内存中读出一个值,再把它存放在栈中相对于 %rsp 偏移量为 8 的地方。指令参数 %fs :40 指明金丝雀值是用段寻址(segmented addressing)
从内存中读入的,段寻址机制可以追溯到80286 的寻址,而在现代系统上运行的程序中已经很少见到了。将金丝雀值存放在一个特殊的段中,标志位“只读”,这样攻击者就不能覆盖存储的金丝雀值。在恢复寄存器状态和返回钱,函数将存储在栈位置处的值于金丝雀值做比较(通过第11行的 xorq 指令)。如果两个数相同,xorq 指令就会得到 0,函数会按照正常的方式完成。非零的值表明栈上的金丝雀值被修改过,那么代码就会调用一个错误处理例程。
栈保护很好地防止了缓冲区溢出攻击破坏存储在程序栈上的状态。它只会带来很小的性能损失,特别是因为 GCC 只在函数中有局部 char 类型缓冲区的时候才插入这样的代码。当然,也有其他一些方法会破坏一个正在执行的程序的状态,但是降低栈的易受攻击性能够对抗许多常见的攻击策略。
3.限制可执行代码区域
最后一招是消除攻击者向系统中插入可执行代码的能力。一种方法是限制哪些内存区域能够存放可执行代码。在典型的程序中,只有保存编译器产生的代码的那部分内存才需要是可执行的。其他部分可以被限制为只允许读和写。下面将会看到,虚拟内存空间在逻辑上被分成了页(page)
,典型的每页是 2048 或者 4096 个字节。硬件支持多种形式的内存保护,能够指明用户程序和操作系统所允许的访问形式。许多系统允许控制三种访问形式:读(从内存读数据)、写(存储数据到内存)和执行(将内存的内容看做机器及代码)。以前,x86 体系结构将读和执行访问合并成一个 1 位的标志,这样任何被标记为可读的页也都是可执行的。栈必须是既可读又可写的,因而栈上的字节也都是可执行的。已经实现的很多机制,能够限制一些页是可读但是不可执行的,然而这些机制通常会带来严重的性能损失。
最近,AMD 为它的 64 位处理器的内存保护引入了“NX”(No-Execute,不执行)位,将读和执行访问模式分开,Intel 也跟进了。有了这个特性,栈可以被标记为可读和可写,但是不可执行,而检查页是否可执行由硬件来完成,效率上没有损失。
有些类型的程序要求动态产生和执行代码的能力。例如,“即时(just-in-time)”编译技术为解释语言(例如 java)编写的程序动态地产生代码,以提高执行性能。是否能够将可执行代码限制在由编译器在创建原始程序时产生的那个部分中,取决于语言和操作系统。
我们讲到的这些技术——随机化、栈保护和限制哪部分内存可以存储可执行代码——是用于最小化程序缓冲区溢出攻击漏洞的三种最常见的机制。它们都具有这样的属性,即不需要程序员做任何特殊的努力,带来的性能代价都非常小,甚至没有。单独每一种机制都降低了漏洞的等级,而组合起来,它们变得更加有效。不幸的是,仍然有方法能够供给计算机,因而蠕虫和病毒继续危害着许多机器的完整性。
到目前为止,我们已经检查了各种函数的机器级代码,但它们有一个共同点,记编译器能够预先确定需要为栈帧分配多少空间。但是有些函数,需要的局部存储是变长的。例如,当函数调用 alloca 时就会发生这种情况。alloca 是一个标准库函数,可以在栈上分配任意字节数量的存储。当代码声明一个局部变长数组时,也会发生这种情况。
虽然本节介绍的内容实际上是如何实现过程的一部分,但我们还是把它推迟到现在才将,因为它需要理解数组和对齐。
下图a 的代码给出了一个包含变长数组的例子。该函数声明了 n 个指针的局部数组 p,这里 n 由第一个参数给出。这要求在栈上分配 8n 个字节,这里 n 的值每次调用该函数时都会不同。因此编译器无法确定要给该函数的栈帧分配多少空间。此外,该程序还产生一个对局部变量 i 的地址引用,因此该变量必须存储在栈中。在执行工程中,程序必须能够访问局部变量 i 和数组 p 中的元素。返回时,该函数必须释放这个栈帧,并将栈指针设置为存储返回地址的位置。
为了管理变长栈帧,x86-64 代码使用寄存器 %rbp 作为帧指针(frame pointer)
(有时称为基指针(base pointer)
,这也是 %rbp 中 bp 两个字母的由来。)当使用帧指针时,栈帧的组织结构于下图中函数 vframe 的情况一样。
代码必须把 %rbp 之前的值保存到栈中,因为它是一个被调用者保护寄存器。然后在函数的整个执行过程中,都使得 %rbp 指向那个时刻栈的位置,然后用固定长度的局部变量(例如 i)相对于 %rbp 的偏移量来引用它们。
上图b 是 GCC 为函数 vframe 生成的部分代码。在函数的开始,代码建立栈帧,并为数组 p 分配空间。首先把 %rbp 的当前值压入栈中,将 %rbp 设置为指向当前的栈位置(第2 - 3 行)。然后,在栈上分配 16 个字节,其中前 8 个字节用于存储局部变量 i,而后 8 个字节是未被使用的。接着,为数组 p 分配空间(第5 - 11 行)。当程序到第 11 行的时候,已经(1)在栈上分配了 8n 字节,并(2)在已分配的区域内放置好数组 p ,至少有 8n 字节可供其使用。
初始化循环的代码展示了如何引用局部变量 i 和 p 的例子。第 13 行表明数组元素 p[ i ] 被设置为 q。该指令用寄存器 %rcx 中的值作为 p 的起始地址。我们可以看到修改局部变量 i(第 15 行)和读局部变量(第 17 行)的例子。i 的地址是引用 -8(%rbp),也就是相对于栈指针偏移量为 -8 的地方。
在函数的结尾,leave 指令将帧指针恢复到它之前的值(第 20 行)。这条指令不需要参数,等价于执行下面两条指令:
也就是,首先把栈指针设置为保存 %rbp 值的位置,然后把该值从栈中弹出到 %rbp。这个指令组合具有释放整个栈帧的效果。
在较早版本的 x86 代码中,每个函数调用都使用了帧指针。而现在,只在栈帧长可变的情况下才使用,就像函数 vframe 的情况一样。历史上,大多数编译器在生成 IA32 代码时会使用栈指针。最近的 GCC 版本放弃了这个惯例。可以看到把使用栈指针的代码和不使用栈指针混在一起是可以的,只要所有的函数都把 %rbp 当做被调用者保存寄存器来处理即可。
【%rsp是 16 个字节,所以为了对齐,s2(指针数组地址) 必须是 16 的倍数,因此,给 i 分配了 16 字,s2 和 s1 之间也要相隔 16 的倍数。而数组里面的元素是 long 类型,long 类型占了 8 个字节,所以 p 是以 8 的倍数对齐】
处理器的浮点体系结构
包括多个方面,会影响对数据操作的程序如何被映射到机器上,包括:
简要回顾历史会对理解 x86-64 的浮点体系结构有所帮助。 1997 年出现了 Pentium/MMX,Intel 和 AMD 都引入了持续数代的媒体(media)
指令,支持图形和图像处理。这些指令本意是允许多个操作以并行模式执行,称为单指令多数据
或 SIMD(读作 sim-dee)。这种模式中,对多个不同的数据并行执行同一个操作。近年来,这些扩展有了长足的发展。名字经过了一系列大的修改,从 MMX 到 SSE(Streaming SIMD Extension,流式 SIMD 扩展),以及最新的 AVX(Advanced Vector Extension,高级向量扩展)。每一代中,都有一些不同的版本。每个扩展都是管理寄存器组中的数据,这些寄存器组在 MMX 中称为 “MM”寄存器,SSE 中称为“XMM”寄存器,而在 AVX中称为“YMM”寄存器;MM寄存器是 64 位的,XMM 是 128 位的,而 YMM 是256 位的。所以,每个 YMM 寄存器可以存放 8 个 32 位值,或 4 个 64 位值,这些值可以是整数,也可以是浮点数。
2000 年 Pentium 4 中引入了 SSE2,媒体指令开始包括那些对标量
负点数据进行操作的指令,使用 XMM 或 YMM 寄存器的低3 32 位或 64 位中的单个值。这个标量模式提供了一组寄存器和指令,它们更类似于其他处理器支持浮点数的的方式。所有能够执行 x86-64 代码的处理器都支持 SSE2 或更高的版本。因此 x86-64 浮点数是基于 SSE 或 AVX 的,包括传递过程参数和返回值的规则。
我们的讲述基于 AVX2,即 AVX 的第二个版本,它是在 2013 年 Core i7 Haswell 处理器中引入的。当给定命令行参数 -mavx2 时,GCC 会生成 AVX2 代码。基于不同版本的 SSE 以及第一个版本的 AVX 的代码从概念上来说是类似的,不过指令名和格式有所不同。我们只介绍用 GCC 编译浮点程序时会出现的那些指令。其中大部分是标量 AVX 指令,我们也会说明对整个数据向量进行操作的指令出现的情况。后文中的网络旁注 OPT:SIMD 更全面地说明了如何利用 SSE 和 AVX 的 SIMD 功能读者可能希望参考 AMD 和 Intel 对每条指令的说明文档。和整数操作一样,注意我们表示中使用的 ATT 格式不同于这些文档中使用的 Intel 格式。特别地,这两种版本中列出指令操作数的顺序是不同的。
下图所示,AVX 浮点体系结构允许数据存储在 16 个 YMM 寄存器中,它们的名字位 %ymm0 ~ %ymm15。每个 YMM 寄存器都是 256 位(32字节)。当对标量数据操作时,这些寄存器只保存浮点数,而且只使用低 32 位(对于 float)或 64 位(对于 double)。汇编代码用寄存器的 SSE XMM 寄存器名字 %xmm0 ~ %xmm15 来引用它们,每个 XMM 寄存器都是对应的 YMM 寄存器的低 128 位(16 字节)。
下图给出了一组在内存和 XMM 寄存器之间以及从一个 XMM 寄存器到另一个不做任何转换的传送浮点数的指令。引用内存的指令是标量
指令,意味着它们只对单个而不是一组封装好的数据值进行操作。数据要么保存在内存中(由表中的M32 和 M64 指明),要么保存在 XMM 寄存器中(在表中用 X 表示)。无论数据对齐与否,这些指令都能正确执行,不过代码优化则建议 32 位内存数据满足 4 字节对齐, 64 位数据满足 8 字节对齐。内存引用的指定方式与整数 MOV 指令一样,包括偏移量、基址寄存器、变址寄存器 和 伸缩因子的所有可能的组合。
GCC 只用标量传送操作从内存传送数据到 XMM 寄存器或从 XMM 寄存器传送数据到内存。对于在两个 XMM 寄存器之间传送的数据,GCC 会使用两种指令之一,即用 vmovaps 传送单精度数,用 vmovapd 传送双精度数据。对于这些情况,程序复制整个寄存器还是只复制低位值既不会影响程序功能,也不会影响执行速度。所以使用这些指令还是针对标量数据的指令没有差别。指令名字中的字母 a 表示 aligned(对齐的)。当用于读写内存时,如果地址不满足 16 字节对齐,它们会导致异常。在两个寄存器之间传送数据,绝不会出现错误对齐的状况。
这个例子中可以看到它使用了 vmovaps 指令把数据从一个寄存器复制到另一个,使用了 vmovss 指令把数据从内存复制到 XMM 寄存器以及从 XMM 寄存器复制到内存。
下两图给出了在浮点数和整数数据类型之间以及不同浮点格式之间进行转换的指令集合。这些都是对单个数据值进行操作的标量指令。
上图中的指令把一个从 XMM 寄存器或内存中读出的浮点值进行转换,并将结果写入一个通用寄存器(例如 %rax、%ebx等)。把浮点值转换成整数时,指令会执行截断(truncation),把值向 0 进行舍入,这是 C 和大多数其他编程语言的要求。
上图中的指令把整数转换成浮点数。它们使用的是不太常见的三操作数格式,有两个源和一个目的。第一个操作数从内存或一个通用目的寄存器中读。这里可以忽略第二个操作数,因为它的值只会影响结果的高位字节。而我们的目标必须是 XMM 寄存器。在最常见的使用场景中,第二个源和目的操作数都是一样的,就像下面这条指令:
vcvtsi2sdq %rax ,%xmm1,%xmm1
这条指令从寄存器 %rax 读出一个长整数,把它转换成数据类型 double,并把结果存放进 XMM 寄存器 %xmm1 的低字节中。
最后,要在两种不同的浮点格式之间转换,GCC 的当前版本生成的代码需要单独说明。假设 %xmm0 的低位 4 字节保存着一个单精度值,很容易就想到用下面这条指令:
vcvtss2sd %xmm0,%xmm0,%xmm0
把它转换成一个双精度值,并将结果存储在寄存器 %xmm0 的低 8 字节。
【浮点数的这一节我实在没兴趣看,我先不看了,回头如果用到这部分知识,再回来补充好了。嘻嘻】
在本章中,我们窥视了 C 语言提供的抽象层下面的东西,以了解机器级编程。通过让编译器产生机器级程序的汇编代码表示,我们了解了编译器和它的优化能力,以及机器、数据类型和指令集。在第 5 章,我们会看到,当编写能有效映射到机器上的程序时,了解编译器的特性会有所帮助。我们还更完整地了解了程序如何将数据存储在不同的内存区域中。在第 12 章会看到许多这样的例子,应用程序元需要知道一个程序变量是在运行时栈中,是在某个动态分配的数据结构中,还是全局程序数据的一部分。理解程序如何映射到机器上,会让理解这些存储类型之间的区别容易一些。
机器级程序和它们的汇编代码表示,与C程序的差别很大。各种数据类型之间的差别很小。程序时以指令序列来表示的,每条指令都完成一个单独的操作。部分程序状态,如寄存器和运行时栈,对程序员来说是直接可见的。本书仅提供了低级操作来支持数据处理和程序控制。编译器必须使用多条指令来产生和操作各种数据结构,以及实现像条件、循环和过程这样的控制结构。我们讲述了 C 语言和如何编译它的许多不同方面。我们看到 C 语言中缺乏边界检查,使得许多程序容易出现缓冲区溢出。
我们只分析了 C 到 x86-64 的映射,但是大多数内容对其他语言和机器组合来说也是类似的。例如,编译 c ++ 和 编译 C 就非常相似。实际上,C++ 的早期实现就只是简单地执行了从 C++ 到 C 的源到源的转换,并对结果运行 C 编译器,产生目标代码。C++ 的对象用结构来表示,类似于 C 的 struct。C++ 的方法是用指向实现方法的代码的指针来表示的。相比而言,Java 的实现方式完全不同。Java 的目标代码是一种特殊的二进制表示,称为Java字节代码
。这种代码可以看成是虚拟机的机器级程序。这种机器并不是直接用硬件实现的,而是用软件解释器处理字节代码,模拟虚拟机的行为。另外,有一种称为及时编译(just-in-time compilation)
的方法,动态地将字节代码序列翻译成机器指令。当代码要执行多次时(例如在循环中),这种方法执行起来更快。用字节代码作为程序的低级表示,优点是相同的代码可以在许多不同的机器上执行,而在本章探讨的机器代码只能在 x86-64 机器上运行。