最初接触GDT时,一直觉得这个设计很尴尬,为什么一定非要访问一块在内存中的表来确定段地址呢?这个过程要比实模式下简洁的基址加偏移复杂得多,这个开销有必要么?还有实模式和保护模式为什么这么叫? 从历史的角度看问题。
8088有1M的地址空间,访存没有虚存映射的过程,完全是对物理地址的访问(00000~FFFFF)。20位的地址涉及到两个寄存器,selector和offset,通常是[selector:offset]的形式,如[f000:fff0]。实际地址的计算规则是selector左移4位加上offset,即
selector≪4+offset
从8088身上可以看到处理器天地初开之时设计者的想法,遇到问题在所难免:
总而言之,8088模式下,段太小,不隔离,运行时地址不确定。
对程序员而言,他们期望看到的是逻辑地址空间(基地址从0开始的一片存储区域),至于程序装入系统运行时进行的地址他们是不关心的。8086时期,OS的一个重要工作是将这些段进行重定位。为何不在一开始就定义好这些重定位信息,让硬件来做这个事情呢?
286要保证地址隔离,那么怎么个隔离法呢?
对每个段设定其大小和特定的权限,CPU在进行地址访问时,判断地址是否在允许范围内,判断当前程序是否有权访问(比如用户程序就不能访问内核数据结构)。这样一来,每个段除了自身的基地址,还需要定义其地址范围,特权级等属性,这样的数据结构称之为描述符(Descriptor)。
每个段对应一个描述符,对该段进行访问,CPU会自动根据地址判断其合理性(是否在界限以内,是否符合权限),并计算出对应的线性地址。
好了,又引出了一个概念,线性地址空间。为什么要这么叫呢?从字面上理解,最直观的感受是该地址空间是一维的。程序员希望看到的是不同的段,和段内的地址,这是个二维空间。286提出的分段机制,就是将二维的空间映射成一维的空间。而这个映射过程,最好是由CPU硬件自动完成。那么,了解了why和what之后,问题到了how。
如何由分段的地址空间映射到线性的地址空间呢?
回答这个问题之前,先提出几个子问题: 为什么要存在GDT?为什么要有从selector到descriptor的映射机制? 从程序员的视角上看,虚拟地址空间是不同的段。段和段之间怎么区分呢?编号,那么就需要用到selector来索引每个段对应的descriptor。Descriptor保存在哪里呢?CPU里?代价太高了,存在内存中吧。那么就有了在内存中的描述符表。
接下来看看selector,descriptor,GDT是什么样子吧。
这里比较巧妙的一点是,descriptor大小为64bit,8个字节,索引每个descriptor索引号的低三位是没用的。正好把低三位利用起来,T1标示是GDT还是LDT,RPL标示特权级。把这两个标志位放在selector中而不是descriptor里,应该是为了保证效率吧。这两项CPU希望在访问selector的时候就看到,而不是要进行一系列复杂机制访问到descriptor才能得到。
GDT是存于内存中的,CPU需要一个寄存器来指明GDT的位置和大小,即GDTR。
有了分段机制后,CPU访问段内数据的过程是:
实模式切换到保护模式的过程:
行文至此,我们回顾一下保护模式的命名。命名的缘由是,286提供了段间的保护机制,防止程序间胡乱访问地址带来的问题。为了与8088兼容并以示区别,286以后的工作模式成为保护模式,8088成为实模式。
386体系结构的两点重大变化:
和286的主要区别是,286整个段都在物理内存中,386可以做到一部分在物理内存,而目前用不到的页,放在外存里。
大多OS利用分页机制,为每个进程分配了一张页目录,即4G的地址空间,系统内核一般都在高地址,这部分对应的物理页都是一致的。引入了内核态和用户态的切换,保护kernel不受用户程序的影响。同时用户进程都有各自的页表,从映射关系上保证不同进程之间的隔离。实现了更好的“保护”。
如今x86架构的OS,大都都把分段视为是兼容性的考虑(类似于对实模式的处理),在系统初始化阶段象征性地初始化GDT,之后的运行就没分段啥事儿了。地址空间的保护模型都来自分页,像ARM体系结构就不支持分段,仅靠MMU进行保护。
所以,实模式&分段都是历史问题了。