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

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

文章导读

计算机执行机器代码,用字节序列编码低级的操作,包括处理数据、管理内存、读写存储设备上的数据,以及利用网络通信。编译器基于编程语言的规则、目标机器的指令集和操作系统遵循的惯例,经过一系列的阶段生成机器代码。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位机器允许的有限地址空间奋战。

1 历史观点

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”,我们用它指代整个系列,也反映了知道i486处理器命名的惯例。
这些年来,许多公司生产出了与Intel处理器兼容的处理器,能够运行完全相同的机器级程序。其中,领头的是AMD。数年来,AMD在技术上紧跟Intel,执行的市场策略是:生产性能稍低但是价格更便宜的处理器。2002年,AMD的处理器变得更加有竞争力,它们率先突破了可商用微处理器的1GHz的时钟速度屏障,并且引入了广泛采用的IA32的63位扩展 x86-64。虽然我们讲的是Intel处理器,但是对于其竞争对手生产的与之兼容的处理器来说,这些表述也成立。
对于由GCC编译器产生的、在Linux操作系统平台上运行的程序,感兴趣的人大多不关心x86的复杂性。最初的8086提供的内存模型和它在80286中的扩展,到i386的时候就都已经过时了。原来的x87浮点指令到引入了SSE2以后就过时了。虽然在x86-64程序中,我们能看到历史发展的痕迹,但x86中许多最晦涩难懂的特性已经不会出现了。

2 程序编码

假设一个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 指定的)。可执行代码是我们要考虑的机器代码的第二种形式,也就是处理器执行的代码格式。

2.1 机器级代码

如之前说过的那样,计算机系统使用了多种不同形式的抽象,利用更简单的抽象模型来隐藏实现的细节。对于机器级编程来说,其中两种抽象尤为重要。第一种是由指令集体系结构或指令集架构(Instruction Set Architecture, ISA)来定义机器级程序的格式和行为,它定义了处理器状态、指令的格式,以及每条指令对状态的影响。大多数ISA,包括x86-64,将程序的行为描述成好像每条指令都是按照顺序执行的,一条指令结束后,下一条再开始。处理器的硬件远比描述的精细复杂,它们并发地执行许多指令,但是可以采取措施保证整体行为与ISA指定的顺序执行的行为完全一致。第二种抽象是,机器级程序使用的内存地址是虚拟地址,提供的内存模型看上去是一个非常大的字节数组。存储器系统的实际实现是将多个硬件存储器和操作系统软件组合起来。
在整个编译过程中,编译器会完成大部分的工作,将把用C语言提供的相对比较抽象的执行模型表示的程序转化成处理器执行的非常基本的指令。汇编代码表示非常接近于机器代码。与机器代码的二进制格式相比,汇编代码的主要特点是它用可读性更好的文本格式表示。能够理解汇编代码以及它与原始C代码的联系,是理解计算机如何执行程序的关键一步。
x86-64的机器代码和原始的C代码差别非常大。一些通常对C语言程序员隐藏的处理器状态都是可见的:

  • 程序计数器(成为“PC”,在x86-64中用%rip表示)给出将要执行的下一条指令在内存中的地址。
  • 整数寄存器文件 包含16个命名的位置,分别存储64位的值。这些寄存器可以存储地址(对应于C语言的指针)或整数数据。有的寄存器被用来记录某些重要的程序状态,而其他的寄存器用来保存临时数据,例如过程的参数和局部变量,以及函数的返回值。
  • 条件码寄存器保存着最近执行的算术或逻辑指令的状态信息。它们用来实现控制或数据流中的条件变化,比如说用来实现if 和 while 语句。
  • 一组向量寄存器可以存放一个或U盾讴歌整数或浮点数值。
    虽然C语言提供给了一种模型,可以在内存中声明的分配各种数据类型的对象,但是机器代码只是简单地将内存看成一个很大的、按字节寻址的数据。C语言中的聚合数据类型,例如数组和结构,在机器代码中用一组连续的字节来表示。即使是对标量数据类型,汇编代码也不区分有符号或无符号整数,不区分各种类型的指针,甚至于不区分指针和整数。
    程序内存包含:程序的可执行机器代码,操作系统需要的一些信息,用来管理过程调用和返回的运行时栈,以及用户分配的内存块(比如说用malloc库函数分配的)。正如前面提到的,程序内存用虚拟地址来寻址。在任意给定的时刻,只有有限的一部分虚拟地址被认为是合法的。例如,x86-64的虚拟地址是由64位的字来表示的。在目前的实现中,这些地址的高16位必须设置为0,所以一个地址实际上能够指定的是2的48次方或64TB范围内的一个字节。较为典型的程序只会访问几兆字节或几千兆字节的数据。操作系统负责管理
    虚拟地址空间,将虚拟地址翻译成实际处理器内存中的物理地址。
    一条机器指令只执行一个非常基本的操作。例如,将存放在寄存器中的两个数字相加,在存储器和寄存器之间传送数据,或是条件分支转移到新的指令地址。编译器必须产生这些指令的序列,从而实现(像算术表达式求值、循环或过程调用和返回这样的)程序结构。
2.2 代码示例

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

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

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

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

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

2.3 关于格式的注解

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

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

  • 把C程序和汇编代码结合起来
    虽然C编译器在把程序中表达的计算转换到机器代码方面表现出色,但是仍然有一些机器特性是C程序访问不到的。例如,每次x86-64处理器执行算术或逻辑运算时,如果得到的运算结果的低8位中有偶数个1,那么就会把一个名为PF的1位条件码(condition code)标志设置为1,否则就设置为0。这里的PF表示“parity flag(奇偶标志)”。在C语言中计算这个信息需要至少7次移位、掩码和异或运算。即使作为每次算术或逻辑运算的一部分,硬件都完成了这项计算,而C语言却无法知道PF条件码标志的值。在程序中插入几条汇编代码指令就能很容易地完成这项认为。
    在C程序中插入汇编代码有两种方法,第一种是,我们可以编写完成的函数,放进一个独立的汇编代码文件中,让汇编器和链接器把它和C语言书写的代码合并起来。第二种方法是,我们可以使用GCC的内联汇编(inline assembly)特性,用asm伪指令可以在C程序中包含简短的汇编代码。这种方法的好处是减少了与机器相关的代码量。
    当然,在C程序中包含汇编代码使得这些代码与某类特殊的机器相关(例如 x86-64),所以只应该在想要的特定只能以此种方式才能访问到时才使用它。

3 数据格式

由于是从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字节双精度浮点数。这不会产生歧义,因为浮点数使用的是一组完全不同的指令和寄存器。

猜你喜欢

转载自blog.csdn.net/qq_35065875/article/details/85543091