实模式和保护模式

2015年07月29日

最初接触GDT时,一直觉得这个设计很尴尬,为什么一定非要访问一块在内存中的表来确定段地址呢?这个过程要比实模式下简洁的基址加偏移复杂得多,这个开销有必要么?还有实模式和保护模式为什么这么叫? 从历史的角度看问题。

8088

8088有1M的地址空间,访存没有虚存映射的过程,完全是对物理地址的访问(00000~FFFFF)。20位的地址涉及到两个寄存器,selector和offset,通常是[selector:offset]的形式,如[f000:fff0]。实际地址的计算规则是selector左移4位加上offset,即

selector≪4+offset

从8088身上可以看到处理器天地初开之时设计者的想法,遇到问题在所难免:

  1. 段大小受限:16位的offset决定了一个段最大只能是64K,大于这个最大值的段就必须要分成两个部分。当执行到下一个段时,CS的值也要变。数据段大于64K也会面临这样尴尬的问题。
  2. 不能保证地址隔离:每个地址并没有对应一个统一的分段地址。比如04808可以被047C:0048,047D:0038,047E:0028,047B:0058等访问到。这导致了本段的数据可以被其它段访问到,本段也可以去偷瞄其它段的数据和代码。不进行地址隔离容易引起的最基本的问题是,数据段不能访问代码段(代码段只读不可写)。在这种非一一对应的模式下,很容易出现地址越界的情况,比如数据段可以对代码段进行修改,这让一些粗心的程序员会犯下不可预知的错误,也会使得一些恶意的程序员强行修改其它程序的段。
  3. 程序运行时的地址不确定:程序每次要装入系统运行时,都需要给它分配一块空闲的内存区域。因为分配给程序的内存区域是不确定的,需要对访问数据和代码跳转时遇到的对绝对地址的访问进行重定位。

总而言之,8088模式下,段太小,不隔离,运行时地址不确定。

80286

对程序员而言,他们期望看到的是逻辑地址空间(基地址从0开始的一片存储区域),至于程序装入系统运行时进行的地址他们是不关心的。8086时期,OS的一个重要工作是将这些段进行重定位。为何不在一开始就定义好这些重定位信息,让硬件来做这个事情呢?

286要保证地址隔离,那么怎么个隔离法呢?

对每个段设定其大小和特定的权限,CPU在进行地址访问时,判断地址是否在允许范围内,判断当前程序是否有权访问(比如用户程序就不能访问内核数据结构)。这样一来,每个段除了自身的基地址,还需要定义其地址范围,特权级等属性,这样的数据结构称之为描述符(Descriptor)。

enter image description here

每个段对应一个描述符,对该段进行访问,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才能得到。

enter image description here

GDT是存于内存中的,CPU需要一个寄存器来指明GDT的位置和大小,即GDTR。

有了分段机制后,CPU访问段内数据的过程是:

  1. 由selector得到descriptor:没有优化的情况是,CPU先访问GDTR,由GDTR + selector得到对应的descriptor,这个过程肯定不会在每次访存时都发生,CPU内部应该有对应当前段的descriptor的一份缓存;
  2. 由descriptor中定义的机制加上段内偏移得到线性地址;

实模式切换到保护模式的过程:

  1. load GDT:把GDT地址赋给GDTR;
  2. 控制寄存器置位;
  3. 跳到下一条指令:段间跳转,改变selector寄存器(CS)的值,将流水线清空;

行文至此,我们回顾一下保护模式的命名。命名的缘由是,286提供了段间的保护机制,防止程序间胡乱访问地址带来的问题。为了与8088兼容并以示区别,286以后的工作模式成为保护模式,8088成为实模式。

80386

386体系结构的两点重大变化:

  1. 32位架构,每个段拥有4G的逻辑地址空间;
  2. 在分段机制的基础上,采取了更细粒度的分页,来适应当年计算机的发展,提高物理内存利用率;

和286的主要区别是,286整个段都在物理内存中,386可以做到一部分在物理内存,而目前用不到的页,放在外存里。

大多OS利用分页机制,为每个进程分配了一张页目录,即4G的地址空间,系统内核一般都在高地址,这部分对应的物理页都是一致的。引入了内核态和用户态的切换,保护kernel不受用户程序的影响。同时用户进程都有各自的页表,从映射关系上保证不同进程之间的隔离。实现了更好的“保护”。

如今x86架构的OS,大都都把分段视为是兼容性的考虑(类似于对实模式的处理),在系统初始化阶段象征性地初始化GDT,之后的运行就没分段啥事儿了。地址空间的保护模型都来自分页,像ARM体系结构就不支持分段,仅靠MMU进行保护。

所以,实模式&分段都是历史问题了。


comments powered by Disqus