Linkers And Loaders 02

来源:互联网 发布:手写美工字体惊喜 编辑:程序博客网 时间:2024/05/10 17:56
Chapter 02:计算机体系结构
链接器,装载器,编译器和汇编器都对计算机的体系结构非常敏感,包括硬件结构和操作系统的软件结构。本章介绍众多的计算机体系结构来深入理解链接器的作用,不过省略了一些对链接没有影响部分,例如浮点操作和I/O操作。
硬件结构有2个方面影响到链接器:程序地址格式和指令格式。链接器的工作之一就是修正内存中指令和数据的地址和偏移。无论何时,链接器都要确定它的改动是否符合计算机当前的寻址方式;当它修改指令的时候,更要确定不会使其他的指令失效。
在本章结尾,还介绍了地址空间结构(address space architecture),即程序是在怎样的地址规则下运行的。
二进制应用接口
每个操作系统都提供了一个二进制应用接口(ABI,Application Binary Interface)。ABI注意包括一些编程协定,使应用程序能在操作系统下正确的运行。其中必然会有一套系统调用(system calls)和相应的调用方法,以及其他一些规则,比如程序可以使用哪些内存地址,寄存器的使用规则等。在应用程序看来,ABI和底层硬件结构一样都是系统体系结构,因为程序一旦违背它们,都会运行失败。
在很多情况下,链接器的一个重要工作就是遵守ABI接口。比如,如果ABI要求所有程序都包含一个列表,记录所有的静态数据的地址,链接器就需要收集所有模块中的地址信息来创建这个列表。ABI中最常影响到链接器的是标准程序调用(standard procedure call)的定义,稍后将较细讨论。
内存地址
计算机都有一个主内存,通常是一块连续的存储介质,以一个数值表示地址。地址0和地址XXXX只是表示内存中的不同位置罢了。
字节序和对齐
每个存储位置的大小是固定的位长(bits)。过去50年里,计算机的一个存储位置的大小从1比特(bit)增加到了64比特。现在的计算机几乎都以8比特作为一个字节,因为计算机处理的许多数据都大于8比特,例如程序地址。计算机也能处理16,32,64,甚至128比特的数据,只要把相邻数据作为一个整体就行了。在有些计算机里,例如IBM和Motorola制造的计算机,第一个字节(地址最低的字节)往往是数据的高位,另一些如DEC和Intel制造的计算机则相反,所以IBM/Motorola机器的字节序叫做升序(big-endian),而DEC/Intel机器的字节序叫做降序(little-endian)。
这两种方式的优点已经激烈讨论了很多年。在实践中主要的问题却集中到了与旧系统的兼容性上,毕竟在同样的字节序机器间通用程序要更容易些。许多现代的计算机芯片对两者都支持,可以通过不同的接线方式,系统启动时的程序设置来选择一种,少数情况下甚至可以通过应用程序来选择(在这些开关命中(switch-hitting)型芯片里,可变数据的字节序可以改变,因为它们由装载和存储指令处理;而常数的字节序是不变的,因为它们已经编码到了指令中。正是这些细节使链接器程序员能保住金饭碗)。
多字节数据必须对齐到一个自然数边界,即4字节的数据必须对齐到4倍数的边界地址,2字节数据对齐到2倍数的边界地址,以此类推。另一种理解方式就是,4字节的数据的起始必须是4的倍数,例如0x12300,而不能是0x12310,其余同理。在一些系统里(Intel x86, DEC VAX, IBM 370/390),引用没有字节对齐的数据会导致性能下降,而在其他系统(大部分RISC精简指令集计算机)里甚至导致程序错误。即使在不会导致程序错误的系统里,字节对齐规则也应该遵守,以提高程序性能。
许多处理器还对程序指令有对齐要求,例如大部分RISC芯片要求指令对齐到4字节边界。
每一种体系结构里都有寄存器(registers),即一些容量固定,程序指令可以直接访问的高速存储设备。各个体系结构里寄存器的数目变化很大,Intel只有8个,有些RISC有32个。寄存器大小和程序地址空间的大小是相等的,即在一个32位地址的系统里,寄存器肯定是32位大小的,而在64位地址系统里,寄存器也是64位的。
地址格式
当计算机执行一个程序的时候,会不停地从内存读取数据和把数据写入内存,而指令自己也是存储在内存里,只是和程序的数据在不同的地方。指令执行的顺序和它存储的顺序一样,除非使用了跳转指令把程序执行位置转移到了别的地方(有些结构里使用分支来代替跳转,这里统一用跳转指代)。每条指令都会引用内存数据,而每个跳转指令都要知道具体的地址。所有计算机的指令格式和地址格式多种多样,而这些都是链接器必须能处理的。
尽管计算机设计者们以前设计了数不清的地址格式,现代的大部分计算机都使用一种相对简单的方式(设计者们发现一个过于复杂的系统很难运行得快,而一个复杂的地址方式也不会很有用)。我们以3种体系结构作为例子:
        IBM 360/370/390系列(我们称为370)
虽然它们是很老的系统了,但是完美的设计使它们现在仍在使用,且口碑很好。最新的嵌入到芯片里的实现,性能达到了RISC系统的水平。
        SPARC V8和V9
一种很流行的RISC结构,寻址非常简单。V8用32位寄存器和地址,V9用64位寄存器和地址。SPARC的设计和其他RISC结构相同,例如MIPS和Alpha。
        Intel 386/486/Pentium(即x86)
一种神秘的,不合常理的结构,但无疑也是使用最广泛的结构。
指令格式
每种体系结构都有几种不同的指令格式,我们只讲与程序和数据寻址有关的格式细节,因为只有这些才影响到了链接器。370进行数据引用和命令跳转的指令格式是相同的,SPARC却采用不同的格式,x86则2者兼有。
每条指令包括一个操作码(opcode,规定这条指令做什么)和多个操作数(operands)。操作数可能编码到了指令自身里(直接操作数),或在内存的某个地方。在内存其他位置的操作数需要把地址计算出来。通常地址存在一个寄存器里,或把指令里的一个常数加到寄存器上计算得到。如果寄存器里的数值是一个存储区域的地址,而指令里的常数表示数据在这个存储区域的偏移量,那么这种方式就是众所周知的基址寻址方式(based addressing)。如果两者角色对换,寄存器里的是偏移量,那么这种方式就是索引寻址方式(indexed addressing)。这2种寻址方式并没有本质上的差别,许多体系结构里都混合使用。比如370里有一种寻址模式是把2个寄存器和一个常数加起来,随便叫哪个是基址寄存器或索引寄存器都行。
还有一些更复杂的地址计算方式,但是大部分链接器都不用管,因为它们对链接器的地址调整没有影响。
有些系统采用固定长度的指令,有些使用变长指令。所有的SPARC指令都是4字节长,对齐到4字节边界。370的指令可以是2,4,6字节长,其中第1个字节的头2比特暗示指令的长度和格式。Intel x86的指令长度可以是1到14的任意字节,编码非常复杂。部分原因是x86系统当初是给内存受限的环境设计的,所以采用了密集的指令编码形式。而另一部分原因就是当286,386和后来的芯片增加了新的指令后,为了向前兼容,不得不妥协而保留以前的蹩脚格式。幸运的是,链接器要做的主要是地址和偏移量的字节对齐工作,所以一般不用管指令的编码。
程序调用和寻址能力
以前的计算机内存很小,一条指令可以把一个内存地址完全包含进来,即直接寻址。到了20世纪60年代,内存已经大到一条指令容不下一个地址。如果把指令的长度加大,又会严重浪费内存。为了解决这个问题,各种计算机体系结构纷纷放弃了直接寻址,而采用索引和基址寄存器存储大部分的地址位。这样就使指令变短,代价就是编程变复杂。
在没有直接寻址能力的体系结构中,例如IBM 370和SPARC,程序在数据定位的时候有一个“bootstrapping”问题。即,一个函数使用寄存器中的基址值来计算数据的地址,但为了把这个基址值存入寄存器,一个标准的做法就是从内存中的某个地方加载,这样又需要另一个基址寄存器参与。解决的办法就是程序开始的时候把一个基址值加载到寄存器里,然后保证后续的函数都有可用的基址来定位数据。
程序调用
每个ABI都定义了标准的程序调用顺序,包括硬件定义的调用指令,和寄存器与内存的使用约定。硬件的调用指令保存返回地址(即调用完成后的指令运行地址)并跳转到相应程序。在有硬件堆栈的体系结构(如x86)里,返回地址被压到堆栈里,而在其他体系结构里则被保存在寄存器中。有硬件堆栈的体系结构一般也有一个返回指令,把返回地址从堆栈里弹出来然后跳转运行,其他的体系结构使用“分支到寄存器指定的地址”指令返回。
在一个程序里,需要寻址的数据有4种类别:
          调用者传递给程序的参数
         由程序分配空间并在程序返回前释放的本地变量
          存储在一个指定的内存地址并且为程序私有的本地静态数据
         存储在一个指定的内存地址并可以被许多不同的程序引用的全局静态数据
分配给一个函数使用的程序堆栈的一部分叫做堆栈桢(stack frame)。下图显示了一个典型的堆栈桢:
参数和本地变量通常在栈里分配空间。有一个寄存器保存了栈顶指针,可以作为基址寄存器。SPARC和x86使用这个方案的一个变体,即在程序开始的时候就把栈指针加载到一个独立的桢指针或基址指针寄存器里,于是就可以向栈里压入任意多的对象,随意改变栈指针寄存器的值,不过保持桢指针寄存器的值不变,把程序的参数和本地变量放到桢指针的后面。假定栈地址从高到低变化,桢指针指向内存中返回值的地址,那么各个参数的地址到桢指针的偏移必然是正数(因为参数先入栈,地址为高位),本地变量的偏移则为负数(本地变量后入栈,地址为低位,如上图所示)。操作系统通常在程序启动前设置好初始的栈指针寄存器,所以程序只需在压栈和出栈的时候更新寄存器就行了。
对于本地和全局的静态数据,编译器会创建一个指针列表指向它们。只要某个寄存器指向这个列表,函数就可以得到列表里的任何静态对象的地址,并保存到另一个寄存器里。关键就是开始的列表指针寄存器。在SPARC系统里,函数把列表的地址加载到寄存器的方法是通过一系列有立即操作数(immediate operands)的指令实现。而且在SPARC或370系统里,函数能使用各种子函数调用指令来加载程序计数器(program counter,一个保存当前运行指令地址的寄存器)到一个基址寄存器里,在后面会讨论,这里仅指出这个技术会在库代码里导致问题。一个更好的解决方法是把加载静态数据列表指针的任务交给函数调用者,因为调用者的静态数据列表指针应该是早已加载好了的,并且很容易就能从中找到被调用函数的静态数据列表地址。
下图显示了一个典型的函数调用指令序列。
Rf是桢指针,Rt是列表指针,Rx是一个临时使用的指针。调用者把自己的静态数据指针列表地址存在自己的堆栈桢上,然后加载被调用函数的地址和静态数据指针列表地址到寄存器里,然后开始调用。被调用的函数于是可以通过Rt里的指针找到所有需要的静态数据。如果它再调用别的函数,只要重复这样做就行了。
我们还能做些优化。在很多情况下,一个模块里的所有函数都共享一个静态数据列表,所以模块内的函数调用不需要改变列表指针。SPARC系统的约定是,整个库共享一个由链接器创建的静态数据列表,所以模块内的调用可以保持列表指针寄存器的值不变。模块内的函数调用一般有专门的指令,它只需把被调用函数的偏移编码到指令中就行了,省略了加载地址到寄存器。经过这些优化,模块内的函数调用指令序列只需一个调用指令。
但是这里还有一个最初的列表指针的来源问题。如果每个函数的列表指针都是从先前的函数得来的,那么最初的函数从哪里得到它的列表指针?答案有很多,但都是作为特殊情况来处理。比如主程序的列表可能存在一个指定的地址,或者初始的指针值可能被标记在可执行文件里让操作系统在程序启动前载入。无论采用哪种技术,都需要链接器的额外帮助。
数据和指令引用
现在我们更具体的学习一下所介绍的三种体系结构里程序对数据的寻址。
         IBM 370
20世纪60年代,古老的360系统采用了一种非常直接的数据寻址方案,但是后来在370和390里演变成了复杂得多的方案。每条引用了内存数据的指令都要计算指令里一个12比特的偏移加一个基址寄存器的值的和,得到数据地址,有时可能还有一个索引寄存器。系统里共有16个常规寄存器,每个都是32比特,编号从0到15,但只有一个能被用作索引寄存器。如果寄存器0被指定了,那么就用数字0,而不是寄存器的内容(实际上寄存器0用于数学计算,而不是寻址)。在那些从寄存器里得到跳转目的地址的指令里,寄存器0表示放弃跳转。
下图显示了主要的指令格式。
一条RX指令包含一个寄存器操作数和一个内存操作数,内存地址通过计算指令里的偏移加一个基址寄存器的值加索引寄存器的值得到。多数情况下索引寄存器为0,于是地址就等于基址加偏移。在RS,SI和SS格式下,12比特的偏移加到了基址寄存器里。一条RS指令只有一个内存操作数,另一个操作数是一个8比特的立即数(immediate value)。一条SS指令包含2个内存操作数,这是一个内存到内存的操作。RR指令格式有2个寄存器操作数,没有内存操作数,但是有些RR指令可能把这1或2个寄存器当成指向内存的指针。370和390系统对这些格式做了少量修改,但数据寻址格式没有变。
指令通过把基址寄存器置0,可以直接寻址内存的低4096字节(偏移12比特=212=4096)。这个特性对底层系统编程很重要,但在使用基址寄存器寻址的应用程序里从不会用到。
注意这3种指令格式(RS,SI和SS)里,12比特的地址偏移总是存储在一个字(16比特)的低12比特。这样就可以在目标文件里确定地址的偏移值,而不用管指令的格式,因为偏移格式都是一样的。
最初的360可以进行24位寻址,因为它把内存地址存在一条32比特双字的低24比特里,而其余的高8比特被忽略。370把寻址扩展到31比特。但是不幸的是,许多程序,包括当时最流行的360的操作系统,都在32比特的地址双字的高位里存了某些标志或数据,所以扩展肯定不能简单的直接占用。于是,系统就区分了24比特和31比特地址的模式,并且同一时间CPU只翻译一个24位地址或32位地址。软硬件厂商的一个联合申明规定,当地址双字的最高位为1时,其于的31位是地址;而当最高位是0时,低24位是地址。所以,当程序包含了不同年代的函数,而在不同的地址模式之间切换时,链接器必须都能处理。由于历史原因,370的链接器还能处理16比特地址,因为早期的360小型机的主存经常小于64K,而且程序都是通过装载和存储一个字(2字节)来操作内存地址值的。
后来的370和390机型增加了分段地址空间(segmented address spaces),和x86系列有些相似。这样操作系统就能为程序定义多倍于31位的地址空间,但是访问控制的规则和地址空间的切换也变复杂了。据我现在所知,还没有哪个编译器或链接器支持这个特性,因为它主要用在高性能的数据库系统里。我们也不作过多介绍。
370里的指令寻址也相对简单。在最初的360里,跳转指令(或称为分支指令)都是RR或RX格式的。在RR格式跳转指令里,第二个寄存器操作数包含的是跳转目标寄存器代号,如果为寄存器0则放弃跳转。在RX格式跳转指令里,内存操作数是跳转目标。程序调用过程包括分支和链接(Branch and Link,后来被31位寻址下的分支和保存(Branch and Store)代替),即先把返回地址保存到指定的寄存器里,然后跳转到RR格式或RX格式指令的相应目标地址。
当在函数代码(routine)内进行跳转的时候,程序必须建立起“定位能力(addressability)”,即需要有一个基址寄存器指向(或至少接近)函数代码的开始位置,以便让RX指令使用。根据约定,15号寄存器包含了一个函数代码的入口,能被用作基址寄存器。另一种选择是,还可以用RR格式跳转指令来设定基址寄存器,原理就是当RR指令的第二个寄存器操作数为0时,它会把后续指令的地址(如果你并不知道)存入第一个操作数指定的寄存器,而且并不跳转,于是第一个操作数寄存器就是基址寄存器了。因为RX指令有12比特的偏移域,所以单个基址寄存器能“覆盖”4K的代码块(这是在程序代码里进行跳转,而且寻址方式是“基址+偏移”)。如果函数代码比4K大,就必须使用多个基址寄存器来覆盖函数的所有代码。
390增加了一些新的跳转格式。在这些新格式里,指令包含了一个有符号的16比特偏移,寻址时逻辑左移1位(因为指令地址都是偶数对齐的),然后加到指令本身的地址来得到跳转目标的地址。这些新格式没有用到寄存器,把跳转限定在±64K字节以内,但已满足绝大多数的函数内的跳转要求了。
         SPARC
SPARC现在越来越像它的名字,成为一个精简指令集处理器。但是它的体系结构仍然进化到了第9个版本,比最初的简单设计复杂的多了。SPARC直到V8都是32位体系,SPARC V9扩展到了64位体系。
SPARC V8
SPARC有4种主要的指令格式,和31种次要的指令格式,4种跳转格式和2种数据寻址模式,如下图所示。
在SPARC V8里,有31个常规寄存器,每个都是32比特,编号从1到31。0号寄存器是一个值总为0的伪寄存器(pseudo-register)。
有一个寄存器窗口(register window)方案试图减少程序调用和返回时需要的寄存器的数目。这个方案对链接器的影响很小,所以我们不作讨论(寄存器窗口起源于Berkeley的RISC设计,而SPARC是从它演化来的)。
数据引用有2种寻址模式。一种是把2个寄存器的值相加得到地址(如果一个寄存器已经包含了全部地址,另一个寄存器可以是r0)。另一种模式是一个基址寄存器加上一个13比特的有符号偏移。
程序调用指令和大部分的有条件跳转指令(在SPARC文献里被称为分支)使用相似的寻址方式,偏移大小可以从16比特变化到30比特。无论偏移有多大,跳转指令都把偏移左移2位,因为所有的指令都是4字节对齐的。符号位(sign把结果扩展到32或64比特,最后把这个值加到跳转或调用指令的本身地址来得到目标地址。调用指令使用了30比特的偏移,意味着它可以跳转到32位V8地址空间的任意地址。调用指令把返回地址存到15号寄存器。其他的跳转指令使用16、19或22比特的偏移,这已足够在任何实际的函数代码里进行跳转了。16比特格式里,偏移还被分为2比特的高位和14比特的低位,并把地位存储在指令的另一个地方,但这都不会给链接器造成什么麻烦。
SPARC还有一个“跳转并链接(Jump and Link)”指令,采用和数据引用指令同样的方式计算目标地址,即把2个寄存器值相加或一个基址寄存器加一个固定偏移。它同样也能把返回地址存到目标寄存器里。
程序调用可以使用调用指令或跳转并链接指令,后者把返回地址存入15号寄存器,并跳转到目标地址。程序返回使用JMP8[r15],返回调用之后的2条指令(SPARC的调用和跳转指令会被“延迟”,并可以选择在跳转之前执行随后的指令)。
SPARC V9
SPARC V9把所有的寄存器扩充到64位,在旧的32位程序里只使用低32位。所有存在的程序都能和以前一样工作,只不过寄存器现在是64位而不是32位了。新的装载和存储指令可以处理64位数据,新的分支指令可以测试32位或64位的结果。SPARC V9没有增加新指令来合成64位地址,也没有新的调用指令。合成64位全地址需要一系列操作,包括利用SETHI或OR指令创建好全地址的2个32位部分,然后把高32位的部分左移,最后用OR指令把2部分合起来。在实际中,64位地址从一个指针列表载入。模块内的调用指令把地址从列表里载入到寄存器里,然后用跳转并链接指令进行调用。
         Intel x86
Intel x86体系是现在为止我们讨论的最复杂的结构。它的特点就是不对称的指令集和分段的地址。它有6个32比特的常规寄存器(EAX,EBX,ECX,EDX,ESI和EDI),2个主要用于寻址的寄存器(EBP和ESP),和6个特殊的16比特段寄存器(CS,DS,ED,FS,GS和SS)。每个32位寄存器的低16位可以单独使用,分别叫做AX,BX,CX,DX,SI,DI,BP和SP。而且从AX到DX寄存器的高字节和低字节又可以作为8位寄存器,分别叫做AL,AH,BL,BH,CL,CH,DL和DH。8086,186和286芯片的许多指令都要求其操作数存在于特定的寄存器里,不过386和后来的芯片把大部分(不是所有)指令推广到了任意寄存器。ESP是硬件栈指针,并总是包含当前堆栈的地址。EBP通常用作桢指针,指向当前堆栈桢的基址(这只是指令集的建议,不强制如此)。
在任何时刻,x86只能运行于以下3个模式之一:实模式(real mode),模拟最初的16位8086芯片;16位的保护模式(16 bit protected mode),在286芯片里增加的模式;和32位保护模式(32 bit protected mode),在386里增加的模式。保护模式里有x86声名狼藉的分段,我们先不讨论。
大部分定位内存地址的指令使用一个通用的指令格式,见下图。
地址由指令中有符号的1、2或4字节的偏移,加上一个任意32位基址寄存器,和另一个除ESP外的32位索引寄存器得到。索引可以逻辑左移0、1、2或3位,以便更好的访问多字节值的数组。
虽然也可以在单条指令里包含所有的偏移、基址和索引,但大部分指令只使用了32比特的偏移(提供直接寻址),或一个基址加1到2字节的偏移(提供栈寻址和指针解引用(dereferencing))。从链接器看来,直接寻址允许一条指令或数据地址植入到程序的任何字节边界对齐的位置。
有条件、无条件跳转和子程序调用都使用相似的寻址方式。任何跳转指令都包含1、2或4字节的偏移,然后加上指令本身的地址得到目的地址。调用指令要么包含4字节的绝对地址,要么使用上面说过的通常的寻址方式,来引用内存中的目标地址。这就允许跳转到或调用到32位地址空间的任何地方。无条件跳转指令和调用指令可以使用上面说过的全部方式来计算目标地址。调用指令把返回地址压入ESP指向的栈顶。
无条件跳转指令和调用指令里还可以有一个6字节分段/偏移地址,或计算出分段/偏移地址所在的位置。这些调用指令把返回地址和调用者的段编号压入堆栈,允许段内的调用和返回。
分页和虚拟内存
在现代大部分计算机里,每个程序理论上可以寻址巨大的地址空间,在典型的32位机器上是4G字节。但很少计算机真的有4G的内存,更别说一个程序就有那么多内存了。分页硬件把程序的地址空间分割成固定大小(一般为2K或4K字节)的页(pages),并把计算机的物理内存分成同样大小的多个页桢(page frames)。分页表(page tables)由硬件管理,包含了每一页地址空间的入口,如下图。
一个分页表入口可能是真正的内存页桢,也可能是表示“页不存在”的标志位。当应用程序试图使用一个不存在的页时,硬件就产生页错误(page fault)并由操作系统来进行处理。操作系统于是可以把需要的页的内容从磁盘上复制到一个空闲的页桢,然后继续运行应用程序。通过把分页在主内存和磁盘上来回移动,操作系统能为应用程序提供比真实内存大得多的虚拟内存。
但是虚拟内存也是有代价的。单条指令的运行时间一般只有零点几微秒,但是一个页错误和随后的进页(page in)或出页(page out)(即把磁盘上的内容复制到内存,或相反)则需要数微秒,因为有数据传输。程序产生的页错误越多,运行得越慢。最坏的情况下程序会崩溃(thrash),不停的产生页错误,但是任何工作也做不了。程序需要的页越少,它产生的页错误自然也就越少。所以如果链接器能把相关的程序代码集中到一个页或少数几个页里,就能提高分页的性能。
如果页被标记为只读,性能同样能提高。只读页不需要出页,因为它们没有改变,可以从最初的源读取。如果相同的页逻辑上出现在多个地址空间(在同一个程序的多个副本同时运行的时候),实际上只需要一个物理页面就够了(然后通过内存映射到不同的地址空间)。
32位寻址和4K分页的x86系统需要有220个入口的分页表来映射整个地址空间(232/212=220)。而每个分页表入口一般有4字节大小,那么整个分页表将占用4M的空间,这太浪费了。于是分页体系结构又对分页表进行了分页(段页机制),即有一个上级分页表,每个入口指向一个下级分页表,而这些下级分页表的每个入口指向各个实际的虚拟地址页。
在370系统里,上级分页表(又叫分段表,segment table)的每个入口都映射到1MB的地址空间,所以31位寻址模式下的分段表最多可以包含2048个入口。分段表的每个入口可能为空(表示整个段都不存在)或指向某个下级分页表(该分页表必然映射到段内的某个页)。每个下级分页表最多有256个入口,每个都指向段内一个4K的内存连续地址空间。
x86对分页表的分割很简单,不过边界与370不同。每个上级分页表(又叫页目录,page directory)映射到4M的地址空间,所以上级分页表有1024个入口。每个下级分页表也有1024个入口,每个入口映射4K的地址空间(一个页),总共4M的地址空间。SPARC系统也定义4K为一页,但是有3级的分页表,而不是2级。
无论2级或3级的分页表,对硬件上层的程序都是不可见的,但是有一个例外就是操作系统。操作系统可以通过改变上级分页表的一个入口,来改变一大块内存地址空间的映射(在370里是1MB,x86里是4MB,SPARC里是256K或16MB)。同样因为效率的原因,进程切换的时候操作系统也是通过改变2级分页表中的几个入口来管理地址空间,从而不用重新装载整个分页表。
程序地址空间
每个应用程序都运行在计算机硬件和操作系统共同定义的地址空间里。链接器和装载器需要创建一个符合这个地址空间的可运行程序。
最简单的地址空间类型是由Unix的PDP-11版本提供的。地址空间从0开始,有64K大小。只读的程序代码从地址0开始装载,然后是可读写的数据。PDP-11的页大小为8K,所以数据起始于代码后8K的边界处。堆栈向下增长,开始于地址64K−1处,并且随着堆栈和数据的增长,页也自动增长,直到程序地址空间的尽头。VAX Unix继承了PDP-11的这个方案。VAX Unix系统里每个程序的开始2字节都是0(这是寄存器保存掩码,但是不保存任何东西)。结果,一个值为0的null指针也是有效的,而且如果一个C程序使用0值作为字符串指针,那么在地址0的那个字节(值为0)就作为字符串的结尾了。这就导致20世纪80年代的Unix程序包含很多难以发现的null指针漏洞,而且此后很多年里Unix向别的计算机体系结构移植的时候都把地址0的字节设置为0,因为这样做比找到和解决那些null指针问题容易多了。
Unix系统把每个程序放进独立的地址空间里,操作系统本身在一个地址空间,逻辑上与应用程序独立。其他的系统把多个程序放进相同的地址空间,从而使链接器尤其是装载器的工作更复杂,因为一个程序的实际地址直到真正开始运行的时候才能知道。
在x86上的MS-DOS系统没有用任何硬件保护,所以操作系统和应用程序共享同样的地址空间。当系统运行一个程序时,它找到地址空间里最大的一块空闲内存,把程序加载进去,然后开始运行。IBM大型机操作系统的做法大致相同,找到可用地址空间里的一块可用内存,把程序加载进去。在这2种情况下,程序装载器或程序自己必须作调整以适应程序加载的位置。
MS Windows采用不寻常的装载方案。每个程序链接的时候假定被装载到一个标准的起始地址,但是在可执行文件里却包含重定位的信息。当Windows装载程序时,它尽可能把程序放到标准的起始地址,不行就放到别的地方。
映射文件
虚拟内存要求在真实内存和磁盘间来回移动数据,当真实内存中的数据不对时就要和磁盘进行换页。起初,换页使用的是磁盘上的“匿名”空间,即与文件系统里的有名文件分开的。随后设计者们发现可以把分页系统和文件系统结合起来,使分页系统使用文件系统里的一个有名文件。当操作系统把一个文件映射到程序地址空间的一部分,操作系统会把该部分地址空间里所有的页标记为不存在,然后把该文件作为该部分地址空间的分页磁盘(paging disk),如下图所示。程序只能通过引用该部分地址空间来访问该文件,这时分页系统就会把需要的页从磁盘装载到内存。
有3种不同的方法可以防止映射文件被改写。最简单的就是映射只读(RO)文件,这样也使分页存储失败,通常导致程序中止。第二种方法是映射可读写(RW)文件,这样对内存的更改在解除该映射的时候会改写磁盘文件。第三种是映射写时复制(COW,copy-on-write)文件,即在程序需要存储换页之前映射文件为只读,当存储换页时,操作系统复制一份页面的副本,把它当作一个私有页(private page),而不是一个由文件映射的页面。(译注:此处实际上是指把多个页映射到一个文件的情况。采用3种方法防止文件被改写,是防止一个分页的改动影响到其他分页。所以第1,2种方法分别过于严格和松散。第3种方法就是,如果没有页面被更改,当然最好;一旦有页面做了改变,就给它另起炉灶,从而不影响到其他共享这个文件的页面。)
从程序的角度看,映射一个COW文件就像是给程序申请了一个全新的匿名内存区域,然后把原先文件的内容复制进去,于是程序所作的修改只对该程序可见,对其他可能映射到同一个文件的程序则没有影响。
共享库和程序
在几乎每个允许多程序同时运行的操作系统里,每个程序都有一组独立的分页表,以便把每个程序的地址空间从逻辑上独立。这样系统的安全性和稳定性就增加了,因为错误程序或恶意程序不会再影响和窥探到正常的程序,但这样也降低了系统的性能。当单个程序或单个程序库在多个地址空间里使用,系统能通过让所有的地址空间共享一份程序或库的物理副本来节省大量的内存。这对操作系统来说很容易做到——只要把可执行文件映射到每个程序的地址空间就行了。不可重定位代码(Unrelocated code)和只读数据映射到RO,可写数据映射到COW。操作系统也能为所有进程里映射到该文件的RO数据和未被改写的COW数据使用同一个物理页桢(physical page frames)。如果在装载时代码必须要重定位,那么重定位进程就要改变代码页为COW,而不是RO。
为了使这种共享能工作,需要许多链接器的帮助。链接器需要把程序里所有的可执行代码集中到一起,以便能映射为RO;并把所有数据集中以便映射为COW。无论在逻辑地址空间还是物理文件里,每个部分都必须起始于页边界。当多个程序使用一个共享库时,链接器需要给它们进行标记,这样当每个程序启动时,共享库就映射到程序的地址空间。
位置无关代码
当一个程序文件在多个地址空间里使用时,操作系统一般能把程序装载到各个地址空间里相同的位置。这样就大大简化了链接器的工作,因为它能把程序里所有的地址绑定到确定的位置,而且程序装载时就不需要重定位了。
但是共享库却又使情况变复杂了。在一些简单的共享库设计里,每个库在创建时或系统导入时会被赋予一个全局唯一的内存地址。这样每个库就有了一个确定的地址,但是代价就是给共享库管理制造了严重的麻烦,因为全局的共享库内存地址列表需要让操作系统来管理。如果以后某个库的新版本出现了,而且比以前要大,不能适应以前的地址空间,那么整个共享库,甚至引用到它们的所有程序,都要重新链接。
一个解决办法就是允许程序把库映射到地址空间中不同的位置。这样简化了库的管理,但是编译器、链接器和程序装载器就必须进行合作,使程序无论在地址空间的哪个位置都能工作。最简单的实现就是在库里包含标准重定位信息,当库被映射到各个地址空间时,装载器能通过程序的装载地址计算出库的重定位地址。但是不幸的是,计算重定位地址的进程需要对库代码和数据进行改写,这样的话,如果把相应的分页映射为COW,就不能共享;如果把分页映射成RO,程序则会崩溃。
为了解决这个问题,共享库代码使用位置无关代码(PIC,Position Independent Code),即无论加载到内存的何处都能运行的代码。所有共享库里的代码一般都是PIC,于是能映射为只读。数据页里一般还是会含有需要重定位的指针,但因为数据分页映射成为COW,所以仍有部分共享。
最重要的是,PIC很容易创建。本章介绍的所有3种体系结构都使用相似的跳转指令,所以函数内的跳转不需要任何重定位。对堆栈上的本地数据的引用使用基址寄存器来定位,也不需要重定位。真正的挑战只有对非共享库函数的调用,和全局数据的引用。直接寻址和SPARC的高低寄存器寻址技巧都不行,因为它们都需要运行时的重定位。不过还有许多其他的技巧使PIC可以处理共享库内的函数调用和全局数据。我们在介绍完共享库细节之后的第9和10章讨论它们。
Intel 386的分段
本章最后的论题就是Intel体系结构里声名狼藉的分段系统。除了一些来自Burroughs和Unisys公司的大型机以外,x86系列是现在唯一还在广泛使用的分段体系结构。也正是由于它如此流行,我们不得不能对付它。但我们马上就会看到,32位操作系统并没有充分发挥分段的重要作用,反而老式系统和非常流行的16位嵌入式x86系统广泛地使用了分段。
最初的8086是作为Intel当时很流行的8位8080和8085微处理器的后续产品。8080有16位的地址空间,于是8086的设计者们分为了两派。一派主张保持16位的地址空间,这样就容易和8085系列兼容,并允许更简洁的代码。另一派主张提供更大的地址空间,以便给未来的大程序留下扩展余地。双方最后妥协,提供多个16位的地址空间,每个16位的地址空间被称为一个段。
一个运行的x86程序有4个活动段(active segments),由4个段寄存器定义。CS寄存器定义代码段,即提取指令的地方;DS寄存器定义数据段,即存储大部分数据的地方;SS寄存器定义堆栈段,即压栈和出栈指令的操作目标,当程序被调用和返回时,需要把程序地址压栈或出栈,还有其他任何使用EBP和ESP作为基址指针的数据引用都用到堆栈段;ES寄存器定义了扩展段(extra segment),由少数几个字符串操作指令使用。386和后来的芯片又定义2个段寄存器FS和GS。任何数据引用都可以用段自动寻址(segment override)直接定位到特定的段内。例如,指令
MOV EAX, CS: TEMP
从代码段的TEMP位置取得一个数据,而不是数据段。FS和GS都只在段自动寻址时使用。
段寄存器的值不必各不相同。大部分程序让DS和SS的值相同,这样指向堆栈变量的指针和全局变量的指针可以混合使用。有些小程序让上面4个寄存器全部相同,在所谓的紧凑模式下提供单一的地址空间。
在8086和186体系结构里,每个段编号(segment number)都映射到固定的内存地址,把段编号左移4位得到。比如段编号0x123将从内存位置0x1230开始,这就是众所周知的实模式。程序员通常把一个段编号能定位的16字节内存单元称为节(paragraphs)。
286增加了保护模式,使操作系统能把段映射到真实内存的任意位置,并可以标记段不存在,从而提供基于虚拟内存的分段。每个段都能标记为可执行(executable)、只读或可读写,并提供分段级的保护。386把保护模式扩展到32位地址,于是每个段最大可达4GB,而不是以前的64K了。
既然要支持16位寻址,那么除了最小的程序外,其他所有程序都要处理分段地址。改变段寄存器的内容非常耗时,在486上要9个时钟周期(clock cycles),而改变一个常规寄存器只需1个时钟周期。所以程序和程序员都希望把代码和数据集中在尽量少的段里,减少改变段寄存器的可能性。链接器能做的帮助就是提供“组(groups)”把相关的代码或数据收集到一起成为一个段。代码和数据指针可以是近(near)指针或远(far)指针,前者只有偏移值,没有段编号;后者兼有。
编译器能给各种内存模型生成代码,这些内存模型规定了代码或数据地址在默认情况下是远还是近。小模型代码的所有指针都为近,并只有一个代码段和数据段。中等模型代码有多个代码段(每个程序源文件一个代码段),但只有一个默认的数据段。大模型代码有多个代码段和数据段,且所有指针都默认为远指针。写出高效的分段代码很需要技巧,并且在其他文档里有很好的介绍。
分段寻址对链接器提出了很高的要求。程序里的每个地址都有一个段和偏移。目标文件由多个代码块组成,每块代码都已由链接器分好了段。在实模式下运行的程序必须标记出所有出现的段编号,这样它们才能在程序载入时重定位到实际的段。在保护模式下运行的程序更需要标记哪些数据加载到哪些段里,并把每个段的保护位(代码、只读数据或可读写数据)设置好。
虽然386支持所有16位和32位的分段特性,大部分32位的程序不使用分段。386新增加的分页机制有分段机制的大部分优点,却没有那么大的性能损耗和复杂的分段操作代码。大部分的386操作系统在紧凑模式下运行应用程序,不过现在都称为平模式(flat model),因为386下的一个段不再“紧凑”了。386的每个代码段和数据段有4GB之大,并映射到32位全地址空间。即使程序只使用了一个段,这个段也能有整个地址空间那么大。
386能让一个程序既使用16位段,又使用32位段,而且少数几个操作系统,Windows 95和98,已经运用了这个优势。Windows 95和98从Windows 3.1继承了很多代码,运行在地址空间的16位分段里。其他新的32位程序则运行在自己的紧凑模式地址空间里,然后把16位程序的地址空间映射进来,以便函数的相互调用。
嵌入式体系结构
嵌入式系统的链接过程出现了许多其他环境下没有的问题。嵌入式芯片的内存和性能都很有限,但因为嵌入式程序往往要在无数设备的芯片里运行,所以我们有足够的理由让程序使用尽量少的内存并运行得尽量快。有些嵌入式系统使用廉价的通用芯片,比如80186,而其他一些系统又使用专门的处理器,比如Motorola 56000系列数字信号处理器(DSP,digital signal processor)。
地址空间差异
嵌入式系统都只有很小的地址空间,并且设计古怪。一个64K的地址空间可能既有高速的芯片(on-chip)ROM与RAM,又有低速的非芯片(off-chip)ROM与RAM,甚至还有芯片或非芯片的外围设备。ROM和RAM可能由几个不连续的区域组成,56000就有3个64K大小的24位地址空间,其中每个都包含了RAM,ROM和外围设备。
嵌入式系统开发使用包含其他辅助逻辑芯片的系统主板。经常同一个处理器的不同开发主板有不同的内存设计。不同的芯片模型有不同的RAM和ROM容量,所以程序员不得不权衡,是努力把程序压缩到更小的内存,还是使用有更多内存也更贵的芯片。
嵌入式系统的链接器需要从很小的细节上设计被链接的程序,把特殊的代码和数据,甚至单个函数和变量,安排到特殊的地址。
内存差异
芯片内存的访问速度比非芯片内存快,所以在二者兼有的系统里,最关键的函数代码要在高速内存里。有时能在链接时把所有程序里的关键代码放入高速内存。其他时候则需要把代码或数据从低速内存复制到高速内存,所以可能有多个函数在不同时候共享高速内存。对于这个技巧,只要能告诉链接器“把这段代码放在XXXX位置,但是假定它是在YYYY位置进行链接的”,那么代码在运行时就能从XXXX低速内存复制到YYYY高速内存并运行正确了。
内存对齐
DSP经常对一些数据结构有很严格的内存对齐要求。比如560000系列的寻址模式可以非常有效的处理循环缓冲(circular buffers),只要缓冲区的基址对齐于一个2的指数边界,并且对齐量不小于缓冲区本身的大小(比如一个50字节的缓冲区要对齐在64字节的边界)。快速傅立叶变换(FFT,Fast Fourier Transform)是最重要的信号处理计算,需要对内存数据进行位操作,也要求被操作的数据处于2的指数对齐边界上。和常规的体系结构不同,这些对齐要求依赖于数据数组本身的大小,所以需要很多的技巧和很大的耐心来高效的处理它们。