分段---纯分段的实现、分段和分页结合

分段

到目前为止我们讨论的虚拟内存都是一维的,虚拟地址从0到最大地址,一个地址接着另一个地址。对许多问题来说,有两个或多个独立的地址空间可能比只有一个要好得多。比如,一个编译器在编译过程中会建立许多表,其中可能包括:

1) 被保存起来供打印清单用的源程序正文(用于批处理系统)。

2) 符号表,包含变量的名字和属性。

3) 包含用到的所有整型量和浮点常量的表。

4) 语法分析树,包含程序语法分析的结果。

5) 编译器内部过程调用使用的堆栈。

前4个表随着编译的进行不断地增长,最后一个表在编译过程中以一种不可预计的方式增长和缩小。在一维存储器中,这5个表只能被分配到虚拟地址空间中连续的块中,如图3-31所示。

考虑一下如果一个程序有非常多的变量,但是其他部分都是正常数量时会发生什么事情。地址空间中分给符号表的块可能会被装满,但这时其他表中还有大量的空间。编译器当然可以简单地打印出一条信息说由于变量太多编译不能继续进行,但在其他表中还有空间时这样做似乎并不恰当。

另外一种可能的方法就是扮演侠盗罗宾汉,从拥有过量空间的表中拿出一些空间给拥有极少量空间的表。这种处理是可以做到的,但是它和管理自己的覆盖一样,在最好的情况下是一件令人讨厌的事,而最坏的情况则是一大堆单调且没有任何回报的工作。

我们真正需要的是一个能够把程序员从管理表的扩张和收缩的工作中解放出来的办法,就像虚拟内存使程序员不用再为怎样把程序划分成覆盖块担心一样。

分段---纯分段的实现、分段和分页结合 

一个直观并且通用的方法是在机器上提供多个互相独立的称为(segment)的地址空间每个段由一个从0到最大的线性地址序列构成各个段的长度可以是0到某个允许的最大值之间的任何一个值不同的段的长度可以不同,并且通常情况下也都不相同。段的长度在运行期间可以动态改变,比如,堆栈段的长度在数据被压入时会增长,在数据被弹出时又会减小。

因为每个段都构成了一个独立的地址空间,所以它们可以独立地增长或减小而不会影响到其他的段。如果一个在某个段中的堆栈需要更多的空间,它就可以立刻得到所需要的空间,因为它的地址空间中没有任何其他东西阻挡它增长。段当然有可能会被装满,但通常情况下段都很大,因此这种情况发生的可能性很小。要在这种分段或二维的存储器中指示一个地址,程序必须提供两部分地址,一个段号和一个段内地址。图3-32给出了前面讨论过的编译表的分段内存,其*有5个独立的段。

分段---纯分段的实现、分段和分页结合  

图3-32   分段存储管理,每一个段都可以独立地增大或减小而不会影响其他的段

需要强调的是,段是一个逻辑实体,程序员知道这一点并把它作为一个逻辑实体来使用。一个段可能包括一个过程、一个数组、一个堆栈、一组数值变量,但一般它不会同时包含多种不同类型的内容

除了能简化对长度经常变动的数据结构的管理之外,分段存储管理还有其他一些优点。如果每个过程都位于一个独立的段中并且起始地址是0,那么把单独编译好的过程链接起来的操作就可以得到很大的简化当组成一个程序的所有过程都被编译和链接好以后,一个对段n中过程的调用将使用由两个部分组成的地址(n,0)来寻址到字0(入口点)

如果随后位于段n的过程被修改并被重新编译,即使新版本的程序比老的要大,也不需要对其他的过程进行修改(因为没有修改它们的起始地址)。在一维地址中,过程被一个挨一个紧紧地放在一起,中间没有空隙,因此修改一个过程的大小会影响其他无关的过程的起始地址,而这又需要修改调用了这些被移动过的过程的所有过程,以使它们的访问指向这些过程的新地址。在一个有数百个过程的程序中,这个操作的开销可能是相当大的。

分段也有助于在几个进程之间共享过程和数据。这方面一个常见的例子就是共享库(shared library)。运行高级窗口系统的现代工作站经常要把非常大的图形库编译进几乎所有的程序中。在分段系统中,可以把图形库放到一个单独的段中由各个进程共享,从而不再需要在每个进程的地址空间中都保存一份。虽然在纯的分页系统中也可以有共享库,但是它要复杂得多,并且这些系统实际上是通过模拟分段来实现的。

因为每个段是一个为程序员所知道的逻辑实体,比如一个过程、一个数组或一个堆栈,故不同的段可以有不同种类的保护。一个过程段可以被指明为只允许执行,从而禁止对它的读出和写入;一个浮点数组可以被指明为允许读写但不允许执行,任何试图向这个段内的跳转都将被截获。这样的保护有助于找到编程错误

读者应该试着理解为什么保护在分段存储中有意义,而在一维的分页存储中则没有。在分段存储中用户知道每个段中包含了什么。例如,一般来说,一个段中不会既包含一个过程又包含一个堆栈,而是只会包含其中的一个。正是因为每个段只包含了一种类型的对象,所以这个段就可以设置针对这种特定类型的合适的保护。图3-33对分段和分页进行了比较。

分段---纯分段的实现、分段和分页结合   

图3-33   分页与分段的比较

页面的内容在某种程度上是随机的,程序员甚至察觉不到分页的事实。尽管在页表的每个表项中放入几位就可以说明其对应页面的访问权限,然而为了利用这一点,程序员必须跟踪他的地址空间中页面的界限。当初正是为了避免这一类管理工作,人们才发明了分页系统。在分段系统中,由于用户会认为所有的段都一直在内存中,也就是说他可以当作所有这些段都在内存中那样去访问,他可以分别保护各个段,所以不需要关心覆盖它们的管理工作。

 纯分段的实现

分段和分页的实现本质上是不同的:页面是定长的而段不是。图3-34a所示的物理内存在初始时包含了5个段。现在让我们考虑当段1被淘汰后,比它小的段7放进它的位置时会发生什么样的情况。这时的内存配置如图3-34b所示,在段7与段2之间是一个未用区域,即一个空闲区。随后段4被段5代替,如图3-34c所示;段3被段6代替,如图3-34d所示。在系统运行一段时间后内存被划分为许多块,一些块包含着段,一些则成了空闲区,这种现象称为棋盘形碎片或外部碎片(external fragmentation)。空闲区的存在使内存被浪费了,而这可以通过内存紧缩来解决。如图3-34e所示。


分段---纯分段的实现、分段和分页结合 

分段和分页结合:MULTICS

如果一个段比较大,把它整个保存在内存中可能很不方便甚至是不可能的,因此产生了对它进行分页的想法。这样,只有那些真正需要的页面才会被调入内存。

MULTICS为每个程序提供了最多218个段(超过250 000个),每个段的虚拟地址空间最长为65 536个(36位)字长。为了实现它,MULTICS的设计者决定把每个段都看作是一个虚拟内存并对它进行分页,以结合分页的优点(统一的页面大小和在只使用段的一部分时不用把它全部调入内存)和分段的优点(易于编程、模块化、保护和共享)。

每个MULTICS程序都有一个段表每个段对应一个描述符。因为段表可能会有大于25万个的表项,段表本身也是一个段并被分页一个段描述符包含了一个段是否在内存中的标志,只要一个段的任何一部分在内存中这个段就被认为是在内存中,并且它的页表也会在内存中。如果一个段在内存中,它的描述符将包含一个18位的指向它的页表的指针(见图3-35a)。因为物理地址是24位并且页面是按照64字节的边界对齐的(这隐含着页面地址的低6位是000000),所以在描述符中只需要18位来存储页表地址。段描述符中还包含了段大小、保护位以及其他的一些条目。图3-35b一个MULTICS段描述符的示例。段在辅助存储器中的地址不在段描述符中,而是在缺段处理程序使用的另一个表中。

分段---纯分段的实现、分段和分页结合 

每个段都是一个普通的虚拟地址空间,用与本章前面讨论过的非分段式分页存储相同的方式进行分页。一般的页面大小是1024字节(尽管有一些MULTICS自己使用的段不分页或以64字节为单元进行分页以节省内存)。

MULTICS中一个地址由两部分构成:段内地址。段内地址又进一步分为页号页内的字,如图3-36所示。在进行内存访问时,执行下面的算法。

1) 根据段号找到段描述符

2) 检查该段的页表是否在内存中。如果在,则找到它的位置;如果不在,则产生一个段错误。如果访问违反了段的保护要求就发出一个越界错误(陷阱)。

3) 检查所请求虚拟页面的页表项,如果该页面不在内存中则产生一个缺页中断,如果在内存就从页表项中取出这个页面在内存中的起始地址。

4) 把偏移量加到页面的起始地址上,得到要访问的字在内存中的地址

5) 最后进行读或写操作

这个过程如图3-37所示。为了简单起见,我们忽略了描述符段自己也要分页的事实。实际的过程是通过一个寄存器(描述符基址寄存器)找到描述符段的页表,这个页表指向描述符段的页面。一旦找到了所需段的描述符,寻址过程就如图3-37所示。

 分段---纯分段的实现、分段和分页结合

如果对于每条指令都由操作系统来运行上面所述的算法,那么程序就会运行得很慢。实际上,MULTICS硬件包含了16个字的高速TLB,对给定的关键字它能并行搜索所有的表项,如图3-38所示。当一个地址被送到计算机时,寻址硬件首先检查虚拟地址是不是在TLB中。如果在,就直接从TLB中取得页框号并生成要访问的字的实际地址,而不必到描述符段或页表中去查找

 分段---纯分段的实现、分段和分页结合 

TLB中保存着16个最近访问的页的地址,工作集小于TLB容量的程序将随着整个工作集的地址被装入TLB中而逐渐达到稳定,开始高效地运行。如果页面不在TLB中,才会访问描述符和页表以找出页框号,并更新TLB使它包含这个页面,最近最少使用的页面被淘汰出TLB生存时间位跟踪哪个表项是最近最少使用的。之所以使用TLB是为了并行地比较所有表项的段号和页号

分段和分页结合:Intel Pentium

Pentium处理器的虚拟内存在许多方面都与MULTICS类似,其中包括既有分段机制又有分页机制。MULTICS有256K个独立的段,每个段最长可以有64K个36位字。Pentium处理器有16K个独立的段,每个段最多可以容纳10亿个32位字。这里虽然段的数目较少,但是相比之下Pentium较大的段大小特征比更多的段个数要重要得多,因为几乎没有程序需要1000个以上的段,但是有很多程序需要大段。

Pentium处理器中虚拟内存的核心是两张表,即LDT(Local Descriptor Table,局部描述符表)和GDT(Global Descriptor Table,全局描述符表)。每个程序都有自己的LDT,但是同一台计算机上的所有程序共享一个GDT。LDT描述局部于每个程序的段,包括其代码、数据、堆栈等;GDT描述系统段,包括操作系统本身。

为了访问一个段,一个Pentium程序必须把这个段的选择子(selector)装入机器的6个段寄存器的某一个中。在运行过程中,CS寄存器保存代码段的选择子,DS寄存器保存数据段的选择子,其他的段寄存器不太重要。每个选择子是一个16位数,如图3-39所示。

分段---纯分段的实现、分段和分页结合 

选择子中的一位指出这个段是局部的还是全局的(即它是在LDT中还是在GDT中),其他的13位是LDT或GDT的表项编号。因此,这些表的长度被限制在最多容纳8K个段描述符。还有两位和保护有关,我们将在后面讨论。描述符0是禁止使用的,它可以被安全地装入一个段寄存器中用来表示这个段寄存器目前不可用,如果使用会引起一次陷阱。

在选择子被装入段寄存器时,对应的描述符被从LDT或GDT中取出装入微程序寄存器中,以便快速地访问。一个描述符由8个字节构成,包括段的基地址、大小和其他信息,如图3-40所示。

分段---纯分段的实现、分段和分页结合 

选择子的格式经过合理设计,使得根据选择子定位描述符十分方便。首先根据第2位选择LDT或GDT;随后选择子被复制进一个内部擦除寄存器中并且它的低3位被清0;最后,LDT或GDT表的地址被加到它上面,得出一个直接指向描述符的指针。例如,选择子72指向GDT的第9个表项,它位于地址GDT+72。

现在让我们跟踪一个描述地址的(选择子,偏移量)二元组被转换为物理地址的过程。微程序知道我们具体要使用哪个段寄存器后,它就能从内部寄存器中找到对应于这个选择子的完整的描述符。如果段不存在(选择子为0)或已被换出,则会发生一次陷阱。

硬件随后根据Limit(段长度)域检查偏移量是否超出了段的结尾,如果是,也发生一次陷阱。从逻辑上来说,在描述符中应该简单地有一个32位的域给出段的大小,但实际上剩余20位可以使用,因此采用了一种不同的方案。如果Gbit(Granularity)域是0,则是精确到字节的段长度,最大1MB;如果是1,Limit域以页面替代字节作为单元给出段的大小。Pentium处理器的页面大小是固定的4KB,因此20位足以描述最大232字节的段。

假设段在内存中并且偏移量也在范围内,Pentium处理器接着把描述符中32位的基址和偏移量相加形成线性地址(linear address),如图3-41所示。为了和只有24位基址的286兼容,基址被分为3片分布在描述符的各个位置。实际上,基址允许每个段的起始地址位于32位线性地址空间内的任何位置。

分段---纯分段的实现、分段和分页结合  

如果禁止分页(通过全局控制寄存器中的一位),线性地址就被解释为物理地址并被送往存储器用于读写操作。因此在禁止分页时,我们就得到了一个纯的分段方案。各个段的基址在它的描述符中。另外,段之间允许互相覆盖,这可能是因为验证所有的段都互不重叠太麻烦太费时间的缘故。

另一方面,如果允许分页,线性地址将通过页表映射到物理地址,很像我们前面讲过的例子。这里惟一真正复杂的是在32位虚拟地址和4KB页的情况下,一个段可能包含多达100万个页面,因此使用了一种两级映射,以便在段较小时减小页表大小。

每个运行程序都有一个由1024个32位表项组成的页目录(page directory)。它通过一个全局寄存器来定位。这个目录中的每个目录项都指向一个也包含1024个32位表项的页表,页表项指向页框,这个方案如图3-42所示。

分段---纯分段的实现、分段和分页结合  

在图3-42a中我们看到线性地址被分为三个域:目录、页面和偏移量。目录域被作为索引在页目录中找到指向正确的页表的指针,随后页面域被用作索引在页表中找到页框的物理地址,最后,偏移量被加到页框的地址上得到需要的字节或字的物理地址。

每个页表项是32位,其中20位是页框号。其余的位包含了由硬件设置供操作系统使用的访问位和“脏”位、保护位和一些其他有用的位。

每个页表有描述1024个4KB页框的表项,因此一个页表可以处理4MB的内存。一个小于4MB的段的页目录中将只有一个表项,这个表项指向一个惟一的页表。通过这种方法,长度短的段的开销只是两个页面,而不是一级页表时的100万个页面。

为了避免重复的内存访问,Pentium处理器和MULTICS一样,也有一个小的TLB把最近使用过的“目录-页面”二元组映射为页框的物理地址。只有在当前组合不在TLB中时,图3-42所示的机制才被真正执行并更新TLB。只要TLB的缺失率很低,则性能就不错。

还有一点值得注意,如果某些应用程序不需要分段,而是需要一个单独的、分页的32位地址空间,这样的模式是可以做到的。这时,所有的段寄存器可以用同一个选择子设置,其描述符中基址设为0,段长度被设置为最大。指令偏移量会是线性地址,只使用了一个地址空间—效果上就是正常的分页。事实上,所有当前的Pentium操作系统都是这样工作的。OS/2是惟一一个使用Intel MMU体系结构所有功能的操作系统。

不管怎么说,我们不得不称赞Pentium处理器的设计者,因为他们面对的是互相冲突的目标,实现纯的分页、纯的分段和段页式管理,同时还要与286兼容,而他们高效地实现了所有的目标,最终的设计非常简洁。

尽管我们已经简单地讨论了Pentium 处理器虚拟内存的全部体系机制,关于保护我们还是值得再说几句的,因为它和虚拟内存联系很紧密。和虚拟内存一样,Pentium处理器的保护系统与MULTICS很类似。它支持4个保护级,0级权限最高,3级最低,如图3-43所示。在任何时刻,运行程序都处在由PSW中的两位域所指出的某个保护级上,系统中的每个段也有一个级别。

分段---纯分段的实现、分段和分页结合   

当程序只使用与它同级的段时,一切都会很正常。对更高级别数据的存取是允许的,但是对更低级别的数据的存取是非法的并会引起陷阱。调用不同级别(更高或更低)的过程是允许的,但是要通过一种被严格控制的方式来进行。为执行越级调用,CALL指令必须包含一个选择子而不单单是一个地址。选择子指向一个称为调用门(call gate)的描述符,由它给出被调用过程的地址。因此,要跳转到任何一个不同级别的代码段的中间都是不可能的,只有正式指定的入口点可以使用。保护级和调用门的概念来自MULTICS,在那里它们被称为保护环(protection ring)。

这个机制的一种典型的应用如图3-43所示。在0级是操作系统内核,处理I/O、存储管理和其他关键的操作。在1级是系统调用处理程序,用户程序可以通过调用这里的过程执行系统调用,但是只有一些特定的和受保护的过程可以被调用。在2级是库过程,它可能是由很多正在运行的程序共享的。用户程序可以调用这些过程,读取它们的数据,但是不能修改它们。最后,运行在3级上的用户程序受到的保护最少。

陷阱和中断使用了一种和调用门类似的机制。它们访问的也是描述符而不是绝对地址,而且这些描述符指向将被执行的特定的过程。图3-40中的Type域用于区别代码段、数据段和各种类型的门。