第 3 章:程序的机器级表示
最后更新于
最后更新于
计算机执行机器代码,用字节序列编码低级的操作,包括处理数据、管理内存、读写存储设备上的数据,以及利用网络通信。编译器基于编程语言的规则、目标机器的指令集和操作系统遵循的惯例,经过一系列的阶段生成机器代码。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 调试器检査机器级程序运行时行为的技巧。本章的最后展示了包含浮点数据和操作的代码的机器程序表示。
计算机工业已经完成从 32 位到 64 位机器的过渡。32 位机器只能使用大概 4 GB(232 字节)的随机访问存储器。存储器价格急剧下降,而我们对计算的需求和数据的大小持续增加,超越这个限制既经济上可行又有技术上的需要。当前的 64 位机器能够使用多达 256 TB(字节)的内存空间,而且很容易就能扩展至 16 EB(字节)。虽然很难想象一台机器需要这么大的内存,但是回想 20 世纪 70 和 80 年代,当 32 位机器开始普及的时候,4GB 的内存看上去也是超级大的。 我们的表述集中于以现代操作系统为目标,编译 C 或类似编程语言时,生成的机器级程序类型。x86-64 有一些特性是为了支持遗留下来的微处理器早期编程风格,在此,我们不试图去描述这些特性,那时候大部分代码都是手工编写的,而程序员还在努力与 16 位机器允许的有限地址空间奋战。