原著:John R. Levine
原文:收藏
翻译:lover_P
连接器和加载器,连同编译器和汇编器,都能够敏锐地感觉到架构的细节,既包括硬件架构也包括其目标计算机上的操作系统的架构转换需求。在这一章中我们涵盖了足够多的计算机架构以理解连接器所必须完成的工作。这里所有对于计算机 架构的描述都故意作得不完整,而且省略了并不影响连接器的部分,如浮点运算和I/O。
硬件架构的两个方面影响着连接器:程序地址和指令格式。连接器要做的一件事是修改数据存储器和指令中的地址和偏移量。无论是修改数据还是指令,连接器都必须确保它的修正能够匹配计算机所使用的地址方案;在修正指令时它还必须进一步确保其修正不会导致无效指令。
在这一章的结束,我们还关注了地址空间架构,也就是说,一个程序必须工作在哪些地址的集合上。
[内容]
2.1 应用程序二进制接口 2.2 存储器地址 2.2.1 字节顺序和对齐 2.3 地址格式 2.4 指令格式 2.5 程序调用和可定址性 2.5.1 程序调用 2.6 数据和指令引用 2.6.1 IBM 370 2.6.2 SPARC 2.6.2.1 SPARC V8 2.6.2.2 SPARC V9 2.6.3 Intel x86 2.7 分页和虚拟存储 2.7.1 程序地址空间 2.7.2 映射文件 2.7.3 共享库和程序 2.7.4 位置无关的代码 2.8 Intel 386 中的分段 2.9 嵌入式架构 2.9.1 地址空间槽 2.9.2 非一致地址 2.9.3 存储对齐 2.10 练习2.1 应用程序二进制接口
每个操作系统都为运行在它上面的程序提供了一个应用程序二进制接口(ABI, Application Binary Interface)。ABI由应用程序在操作系统上必须遵守的编程约定(programming convertion)构成。ABI总是包含一组系统调用(system call)和调用(invoke)系统调用的技巧,以及一些关于程序能够使用哪些存储器的规则和使用机器寄存器的规则。从应用程序的观点看,ABI和部分的系统架构以及底层的硬件架构同样重要,因为一个程序无论违反哪一方的约束都会同样引起失败。
在很多情况下,连接器都要完成有关遵从ABI的工作中的重要部分。例如,如果ABI要求每个程序都包含一个罗列了程序中所有例程所用到的静态数据的地址的表,连接器通常就要通过收集连接到程序中的所有例程中的地址信息来建立这个表。ABI最能影响连接器的方面就是对标准程序调用的定义,我们将在这一章稍后的部分回到这一主题。
2.2 存储器地址每台计算机都包含一个主存储器。主存储器总是以一个存储位置的排列出现,其中每个存储位置都有一个数值地址。 这个地址从零开始,增长到由一个地址中的位数所决定的一个较大的数值。
2.2.1 字节顺序和对齐每个存储位置都由数量固定的位(bit)所构成。在过去的50年里,计算机被设计为每个存储位置由多至64位少至1位构成,但是现在几乎所有的计算机成品每个地址字节都有8位。由于很多计算机要处理的很多数据,尤其是程序地址,都比8位要多,因此计算机还可以通过将邻近的字节分组来处理16、32甚至64或128位的数据。在一些计算机上,特别是来自IBM和Motorola的计算机,其多字节数据中的第一个(地址数值较小的)字节是最重要的字节;而其他的,特别是DEC和Intel,这一位是最不重要的,如图1所示。《Gulliver's Travels》中将IBM/Motorola字节顺序称为大尾数的(big-endian),而将DEC/Intel架构称为小尾数的(little-endian)。
图2-1:字节可寻址存储器
通常的存储地址描述
多年以来对这两种架构的优点的比较引起了激烈的争论。实际上,如何选择字节顺序最大的问题在于与旧系统的兼容性,因为在字节顺序相同的机器之间移植程序和数据要比在字节顺序不同的机器之间容易得多。目前很多芯片的设计都能够支持各种数据,可以通过芯片 跳线、系统启动程序,或者少部分情况下甚至可以由每个应用程序来作出选择。(在这些双掷开关芯片上,字节顺序是通过加载和存储指令的变化来处理的,但字节顺序具有固定编码的指令却不能。这种细节使得连接器作者的生活丰富多彩。)
多字节数据通常必须被对齐(align)到自然的边界 (boundary)上。也就是说,四字节数据应该被对齐到一个四字节的边界,双字节应该对齐到双字节,以此类推。还可以这样想:一个N字节的数据应该至少有log2N个为零的低位。在一些系统(Intel x86、DEC VAX、IBM370/390)上,未对齐的数据引用会导致工作效率降低的代价,而在其他一些系统(很多RISC芯片)上,未对齐的数据会导致一个程序的失败。即使在未对齐数据不会导致程序失败的系统上,性能的损失也通常足够值得我们在可能的时候去努力管理对齐。
很多处理器还对程序指令有对齐要求。大部分RISC芯片要求指令必须被对齐到四字节边界。
每个架构还都定义了寄存器(register)——一小组长度固定的高速存储器,其地址可以由程序指令直接引用。一种架构和另一种架构的寄存器数量是不同的,从Intel架构(IA, Intel Architecture)中的至少8个到一些RISC设计中的32个不等。寄存器通常总是和程序地址具有相同的大小,也就是说,在一个32位地址的系统上,寄存器是32位的,而在64位地址的系统上,寄存器是64位的。
2.3 地址格式当程序运行的时候,它从存储器中读取或向存储器写入数据,这些存储器由程序中的指令来检测。指令自身也存放在存储器中,通常是放在一个和程序数据不同的部分中。指令理论上是按照 它们所存储的顺序执行,除了跳转(jump)指令指定一个的新的地址并开始执行(那里的)指令。(一些架构使用术语分支(branch)来表示部分或所有的跳转,但我们在这里将它们全部 称为跳转。)每条指令都引用了数据存储器,而且每个跳转都或者指定了数据加载或存储的地址、或者指定了指令要跳转的位置。所有的计算机都有很多指令格式和地址格式规则,连接器必须能够像在指令中处理重定位地址那样处理这些规则。
尽管在过去的数年中计算机设计者们提出了很多不同的复杂的地址架构,但目前的成品计算机都有一个相对简单的地址架构。(设计者们发现很难为复杂的架构建立一个快速的版本,而且编译器极少能对复杂的地址特性作出很好的利用。)我们以三种架构为例:
IBM 360/370/390(我们仅提到370)。尽管这是现在仍在使用的最古老的架构之一,尽管35年来新特性的修修补补使它破旧不堪,它在芯片上实现后 (译注:IBM最早的实现可不是芯片哦)的效率依然可以与现代的RISC相媲美。 SPARC V8和V9。一个流行的RISC架构,拥有相当简单的地址。V8使用32位寄存器和地址,V9加入了64位寄存器和地址。SPARC的设计和其他RISC架构如MIPS和Alpha类似。 Intel 386/486/Pentium(后来的x86)。仍然在使用的一种不可思议的和不规则的架构,但不可否认是最流行的。 2.4 指令格式每种架构都有很多不同的指令格式。我们只讲解与程序和数据地址相关的格式细节,因为这是影响到连接器的主要细节。370使用一些格式的数据引用和跳转,而SPARC使用不同的格式,x86既有公共的格式又有不同的格式。
每条指令都由一个检测应该完成什么指令的操作码和一个或多个操作数构成。操作数可能被编码到指令本身中(立即数)或者定位到存储器中。存储器中的每个操作数的地址都必须以某种方式进行计算。有的时候这个地址被包含在指令中(直接寻址)。更常见的是这个地址可以在某一个寄存器中找到(寄存器间接寻址),或者通过为寄存器中的内容加上一个常数来计算。如果寄存器中的值是一个存储器区域的地址,而指令中的常数是想得到的数据在这段存储器区域中的偏移量,则这种机制 称为基址寻址。基址和索引地址之间的差别不是定义良好的,很多架构合并了它们,例如,370中有一种寻址方式是在指令中将两个寄存器和一个常数相加,可以随意地称这两个寄存器中的一个为基址寄存器而另一个为索引寄存器,尽管它们表示相同的地址。
其他一些更为复杂的地址计算架构仍在使用,但它们中的大部分都不需要连接器来操心,因为它们不包含任何需要连接器来调整的域。
一些架构使用长度固定的指令,而另一些使用长度可变的指令。所有的SPARC指令都是四个字节长,对齐到四字节边界。IBM 370指令可以是2、4或6个字节长,其中第一个字节的前两位用于检测指令的长度和格式。Intel x86指令可以是1字节到14字节之间的任意长度。其编码非常复杂,部分原因是由于x86最初被设计用于存储器有限的环境,因此要采用一个密集的指令编码;另一部分原因是286、386中添加了新的指令,并且以后的芯片还必须要用到现存的指令集中没有用到的位模式。幸运的是,从连接器作者的角度看,连接器必须进行调整的地址和偏移量域都出现在字节边界,因此连接器通常不必关心指令编码。
2.5 程序调用和可定址性在最早的计算机中,存储器非常小,而且指令包含的地址域足够大,能够包含计算机中所有存储位置的地址,这种方式现在称为直接寻址。到了20世纪六十年代早期,可定址存储器变得大到如果一个指令集在每条指令中包含完整的地址,将会占用过多的仍然珍贵的存储器。为了解决这一问题,计算机架构在一些或所有存储器引用指令中抛弃了直接寻址,而是使用索引和基址寄存器来提供地址中使用的大部分或所有的位。这允许指令变得更短,但要以更为复杂的程序为代价。
在没有直接寻址的架构上,包括IBM 370和SPARC,程序在对待数据地址时会有一种“自举(bootstrapping)”问题。一个例程使用寄存器中的基址来计算数据地址,而将这个基址放入寄存器的标准方法是把它从某个存储器位置加载到寄存器,但这又需要寄存器中有另外一个基址。自举问题就是要在程序的开始 将低一个基址值放入寄存器,以及接下来保证每个例程都拥有其所使用数据的地址的基址值。
2.5.1 程序调用每个ABI都使用硬件定义的调用指令和寄存器/存储器使用约定的组合来定义一个标准的程序调用序列。一个硬件调用指令保存返回地址(调用指令之后的一条指令的地址)并跳转到调用程序。在具有硬件堆栈的架构如x86上,返回地址被压入堆栈;而在其他架构上,返回地址被保存在寄存器中,由软件负责在必要的时候将它存放到存储器中。具有堆栈的架构通常有一个硬件返回指令用来从堆栈中弹出返回地址并跳转到这个地址,而其他架构使用一个“寄存器内地址分支(branch to address in register)”指令来返回。
在一个程序中,数据地址有四种类型:
调用者可以传递参数(argument)给程序。 局部变量(local variable)在程序中分配并在程序返回前释放。 局部静态变量(local static variable)数据存放在存储器中的一个固定区域并属于该程序私有。 全局静态(global static)数据存放在存储器中的一个固定区域并可以由任何不同的程序引用。堆栈存储器中分配给一个单独的程序调用的块称为堆栈帧(stack frame)。图2展示了一个典型的堆栈帧。
图2-2:堆栈帧存储器布局
堆栈帧的图式
参数和局部变量通常分配在堆栈上。某一个寄存器作为堆栈指针,这个寄存器可以用作一个基址寄存器。在由SPARC和x86所使用的这种架构的一个通用的变体中,会在程序开始的时候从堆栈指针处加载一个分立的帧指针或基址指针。这使得将一个可变大小的对象压入堆栈——将堆栈指针寄存器中的值改变为一个难以预知的值,但仍然保证程序地址参数和局部变量相对帧指针有固定的偏移量而不会在程序运行的过程中改变——成为可能。(译注:这句话很难理解,英文原文也很难:This makes it possible to push variable sized objects on the stack, changing the value in the stack pointer register to a hard-to-predict value, but still lets the procedure address argument and locals at fixed offsets from the frame pointer which doesn't change during a procedure's execution. 请多读几遍,试着理解其中的意思。如果您有更好的翻译方法,请告诉我!深表感谢!)假设堆栈从高地址向低地址生长,并且帧指针指向存放返回地址的存储器地址,则参数相对帧指针具有较小的绝对偏移量,而局部变量具有负的偏移量。操作系统通常在一个程序开始之前设置初始的堆栈指针寄存器,因此程序只需要在其弹出和压入数据所必要的时候才更新该寄存器。
对于局部和全局静态数据,编译器可以生成一个表,这个表包含了一个例程所引用的所有静态对象的指针。如果某一个寄存器中包含了这个表的一个指针,该例程就可以通过将表指针寄存器(中的内容)加载到另一个寄存器中,并将这个寄存器作为基址寄存器来获得对象指针,从而定位任何一个所期望的静态对象。这里所需的技巧就是如何将表的地址放到第一个寄存器中。在SPARC上,程序可以使用一系列带有立即数的指令来将这个表地址加载到寄存器中,而在SPARC或370上,程序可以使用子例程调用指令的一个变体来 将程序计数器(存放了当前指令地址的寄存器)加载到一个基址寄存器中,但由于我们后面将要讨论到的原因,这些技术在库代码中会引起一些问题。一个更好的解决方案是强制将加载表指针的工作交给例程调用者,因为调用者自己的表指针已经加载,而且它可以通过自己的表来获得被调用例程的表的地址。
图3展示了一个典型的例程调用序列。Rf
本文地址:http://com.8s8s.com/it/it25916.htm