第 3 章 程序的机器级表示

第 3 章 程序的机器级表示

   计算机执行机器代码,用字节序列编码低级的操作,包括 处理数据、管理内存、读写存储设备上的数据,以及利用网络通信。编译器基于 编程语言的规则目标机器的指令集操作系统遵循的规则,经过一系列的阶段生成机器代码。GCC C 语言编译器以 汇编代码的形式 产生输出,汇编代码是机器代码的文本表示,给出程序中的每一条指令。然后 GCC 调用汇编器和链接器,根据汇编代码生成可执行的机器代码。在本章中,我们会近距离的观察机器代码,以及人类可读的代码,即 汇编代码。
   当我们用高级语言编程的时候(例如 C 语言,Java 语言更是如此),机器屏蔽了机器的细节,即 机器级的实现。与此相反,当用汇编代码编程的时候(就像早期的计算),程序员必须指定程序用来执行计算的低级指令。高级语言提供的抽象级别比较高,大多数时候,在这种抽象级别上工作效率会更高,也更可靠。编译器提供的类型检查能帮助我们发现许多程序错误,并能够保证按照一致的方式来引用和处理数据。通常情况下,使用现代的优化编译器产生的代码至少与一个熟练的汇编语言程序员手工编写的代码一样有效。最大的优点是,用 高级语言编写的程序可以在很多不同的机器上编译和执行,而 汇编代码则与特定机器密切相关的
   那么我们为什么还要花时间学习机器代码呢? 即使 编译器承担了生成汇编代码的大部分工作,对于严谨的程序员来说,能够阅读和理解汇编代码仍是一项很重要的技能。以适当的命令行选项调用编译器,编译器就会产生一个以 汇编代码形式表示的输出文件。通过阅读这些汇编代码,我们能够理解编译器的优化能力,并分析代码中隐含的低效率。就像我们将在第 5 章体会的那样,试图最大化一段关键代码性能的程序员,通常会尝试源代码的各种形式,每次编译并检查产生的汇编代码,从而了解程序将要运行的效率如何。此外,也有些时候, 高级语言提供的抽象层会隐藏我们想要了解的程序的运行时行为。例如,第12章会讲到,用线程包写并发程序时,了解不同的线程是如何共享程序数据或保持数据私有的,以及准确知道如何在哪里访问共享数据,都是很重要的。这些信息在机器代码级是可见的。另外再举一个例子,程序遭受攻击(使得恶意软件侵扰系统)的许多方式中,都涉及程序存储运行时控制信息的方式的细节。许多攻击利用了系统程序中的漏洞重写信息,从而获得了系统的控制权。了解这些漏洞是如何出现的,以及如何防御它们,需要具备程序机器级表示的知识。程序员学习汇编代码的需求随着时间的推移也发生了变化,开始时要求程序员能直接用汇编语言编写程序,现在则要求他们能够阅读和理解编译器产生的代码。
   在本章中,我们将详细学习一种特别的汇编语言,了解如何将C程序编译成这种形式的机器代码。阅读编译器产生的汇编代码,需要具备的技能不同于手工编写汇编代码。我们必须了解典型的编译器在将C程序结构变换成机器代码时所做的转换。相对于C代码表示的计算操作,优化编译器能够重新排列执行顺序,消除不必要的计算,用快速操作替换慢速操作,甚至将递归计算变换成迭代计算。源代码与对应的汇编代码的关系通常不太容易理解—就像要拼出的拼图与盒子上图片的设计有点不太一样。这是一种逆向工程(reverseengineering)—–通过研究系统和逆向工作,来试图了解系统的创建过程。在这里,系统是一个机器产生的汇编语言程序,而不是由人设计的某个东西。这简化了逆向工程的任务,因为产生的代码遵循比较规则的模式,而且我们可以做试验,让编译器产生许多不同程序的代码。本章提供了许多示例和大量的练习,来说明汇编语言和编译器的各个不同方面。精通细节是理解更深和更基本概念的先决条件。有人说:“我理解了一般规则,不愿意劳神去学习细节!”他们实际上是在自欺欺人。花时间研究这些示例、完成练习并对照提供的答案来检查你的答案,是非常关键的。
   我们的表述基于 x86-64,它是现在笔记本电脑和台式机中最常见处理器的机器语言,也是驱动大型数据中心和超级计算机的最常见处理器的机器语言。这种语言的历史悠久,开始于Intel 公司1978年的第一个16位处理器,然后扩展为32位,最近又扩展到64位。一路以来,逐渐增加了很多特性,以更好地利用已有的半导体技术,以及满足市场需求。这些进步中很多是Intel自己驱动的,但它的对手AMD(Advanced Micro Devices)也作出了重要的贡献。演化的结果是得到一个相当奇特的设计,有些特性只有从历史的观点来看才有意义,它还具有提供后向兼容性的特性,而现代编译器和操作系统早已不再使用这些特性。我们将关注GCC和 Linux使用的那些特性,这样可以避免x86-64的大量复杂性和许多隐秘特性。
   我们在 技术讲解之前,先快速 浏览C语言汇编代码 以及 机器代码之间的关系。然后介绍 x86-64 的细节,从数据的表示和处理以及控制的实现开始。了解如何实现C语言中的控制结构,如if、while和switch语句。之后,我们会讲到 过程的实现,包括程序如何维护一个运行栈来支持过程间数据和控制的传递,以及局部变量的存储。接着,我们会考虑 在机器级如何实现像数组、结构和联合这样的数据结构。有了这些机器级编程的背景知识,我们会讨论 内存访问越界的问题,以及 系统容易遭受缓冲区溢出攻击的问题。在这一部分的结尾,我们会给出一些用GDB调试器检查机器级程序运行时行为的技巧。本章的最后展示了包含 浮点数据操作的代码的机器程序表示
在这里插入图片描述
   计算机工业已经完成从32位到64位机器的过渡。32位机器只能使用大概4GB(232字节)的随机访问存储器。存储器价格急剧下降,而我们对计算的需求和数据的大小持续增加,超越这个限制既经济上可行又有技术上的需要。当前的64位机器能够使用多达256TB(248字节)的内存空间,而且很容易就能扩展至16EB(264字节)。虽然很难想象一台机器需要这么大的内存,但是回想20世纪70和80年代,当32位机器开始普及的时候,4GB的内存看上去也是超级大的。
   我们的表述集中于以现代操作系统为目标,编译C或类似编程语言时,生成的机器级程序类型。x86-64有一些特性是为了支持遗留下来的微处理器早期编程风格,在此,我们不试图去描述这些特性,那时候大部分代码都是手工编写的,而程序员还在努力与16位机器允许的有限地址空间奋战。

3.1 历史观点

   Intel处理器系列俗称x86,经历了一个长期的、不断进化的发展过程。开始时,它是第一代单芯片、16位微处理器之一,由于当时集成电路技术水平十分有限,其中做了很多妥协。以后,它不断地成长,利用进步的技术满足更高性能和支持更高级操作系统的需求。
   以下列举了一些Intel处理器的模型,以及它们的一些关键特性,特别是影响机器级编程的特性。我们用实现这些处理器所需要的晶体管数量来说明演变过程的复杂性。其中,“K”表示1000,“M”表示1000 000,而“G”表示1000 000 000。
在这里插入图片描述
   每个后继处理器的设计都是后向兼容的——较早版本上编译的代码可以在较新的处理器上运行。正如我们看到的那样,为了保持这种进化传统,指令集中有许多非常奇怪的东西。Intel处理器系列有好几个名字,包括IA32,也就是“Intel 32位体系结构(IntelArchitecture 32-bit)”,以及最新的Intel64,即 IA32 的64位扩展,我们也称为x86-64。最常用的名字是“x86”,我们用它指代整个系列,也反映了直到i486处理器命名的惯例。
在这里插入图片描述
   这些年来,许多公司生产出了与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.c和 p2.c。我们用Unix命令行编译这些代码:

linux> gcc -og -o p pi.c p2.c

   命令gcc指的就是 GCC C 编译器。因为这是 Linux 上默认的编译器,我们也可以简单地用 cc 来启动它。编译选项 -og 告诉编译器使用会生成符合原始 C 代码整体结构的机器代码的优化等级。使用较高级别优化产生的代码会严重变形,以至于产生的机器代码和初始源代码之间的关系非常难以理解。因此我们会使用 -og 优化作为学习工具,然后当我们增加优化级别时,再看会发生什么。实际中,从得到的程序的性能考虑,较高级别的优化(例如,以选项 -o1 或 -○2 指定)被认为是较好的选择。
   实际上 gcc 命令调用了一整套的程序,将源代码转化成可执行代码。首先,C预处理器扩展源代码,插人所有用#include命令指定的文件,并扩展所有用 #define 声明指定的宏。其次,编译器产生两个源文件的汇编代码,名字分别为 p1.s和 p2.s。接下来,汇编器会将汇编代码转化成二进制目标代码文件 p1.o 和 p2.o。目标代码是机器代码的一种形式,它包含所有指令的二进制表示,但是还没有填人全局值的地址。最后,链接器将两个目标代码文件与实现库函数(例如 printf )的代码合并,并产生最终的可执行代码文件p(由命令行指示符 -o p 指定的)。可执行代码是我们要考虑的机器代码的第二种形式,也就是处理器执行的代码格式。我们会在第 7 章更详细地介绍这些不同形式的机器代码之间的关系以及链接的过程。

3.2.1 机器级代码

   正如在 1.9.3 中讲过的那样,计算机系统使用了多种不同形式的抽象,利用更简单的抽象模型来隐藏实现的细节。对于 机器级编程 来说,两种抽象尤为重要。第一种是由 指令集体系结构指令集架构 来定义机器级程序的格式和行为,它定义了 处理器状态指令的格式、以及 每条指令对状态的影响。大多数 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,所以地址实际上能够指定的是 248> 或 64 TB 范围内的一个字节。较为典型的程序只会访问几兆字节或几千兆字节的数据。操作系统 负责 管理虚拟地址空间,将 虚拟地址 翻译成 实际处理器内存中的物理地址
       一条机器指令只执行一个非常基本的操作。例如,将存放在寄存器的两个数字相加,在存储器和寄存器之间传送数据,或是条件分支转移到新的指令地址。编译器必须产生这些指令的序列,从而实现(像 算术表达式求值、循环或过程调用和返回这样的)程序结构。
    在这里插入图片描述

3.2.2 代码示例

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

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,但是不作其他进一步的工作。(通常情况下,它还会继续调用汇编器产生目标代码文件)。
   汇编代码文件包含各种声明,包括下面几行:

multstore:
  pushq     %rbx
  movq      %rdx, %rbx
  call      mult2
  movq      %rax, (%rbx)
  popq      %rbx
  ret

   上面代码中每个缩进去的行都对应于一条机器指令。比如,pushq 指令表示 应该将 寄存器文%rbx 的内容压入程序栈中。这段代码中已经除去了所有关于局部变量名或数据类型的信息。
   如果我们使用 -C 命令行选项, GCC 会编译并汇编该代码:

linux> gcc -Og -c mstore.c

   这样会产生目标文件 msote.o,它是二进制格式的,所以无法直接查看。1368 字节的文件 mstore.o 中有一段 14 字节的序列,它的十六进制表示为:
53 48 89 d3 e8 00 00 00 00 48 98 03 5b c3
   这就是上面列出的汇编指令对应的目标代码。从中得到一个重要信息,即 机器执行的程序只是一个字节序列,它是 一系列指令的编码。机器对产生这些指令的源代码几乎一无所知。
在这里插入图片描述
   要查看机器代码文件的内容,有一类称为 反汇编器(disassmbler)的程序非常有用。这些程序根据机器代码产生一种类似于汇编代码的格式。在 Linux 中,带 -d 命令标志的程序 OBJDMUP(表示 object jump)可以充当这个角色。

linux> objdump -d mastore.o

   结果如下(这里,我们在左边增加了行号,在右边增加了斜体表示的注解):
在这里插入图片描述
   在左边,我们看到按照前面给出的字节顺序列出 14 个十六进制字节值,它们分成了若干组,每组有 1~5 个字节。每组都是一条指令,右边是等价的汇编语言。
   其中有一些关于机器代码和它的反汇编表示的特性值得注意:

  • x86-64的指令长度从1-15个字节不等。常用的指令以及操作较少的指令所需的字节较少,而那些不常用或操作数较多的指令所需字节数较多。
  • 设计指令格式的方式是,从某个给定位置开始,可以将字节唯一的解码成机器指令。例如,只有指令 pushq%rbx 是以字节 53 开头的。
  • 反汇编器只是基于机器代码文件中的字节序列来确定汇编代码。它不需要访问该程序的源代码或汇编代码。
  • 反汇编器使用的指令命名规则与 GCC 生成的汇编代码使用的有些细微的差别。在我们的示例中,它省略了很多指令结尾的 q。这些后缀是大小指示符,在大多数情况下可以省略。相反,反汇编器给 call 和 ret 指令添加了 q 后缀,同样,省略这些后缀也没有问题。
       生成实际可执行的代码 需要对 一组目标代码文件运行链接器,而这一组目标代码文件中 必须含有一个 main 函数。假设在文件 main.c 中有下面这样的函数:
#inculde <sadio.h>

void multstore(long, long, long 
  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值