Intel x86 CPU系列的寻址方式

Intel可以说是资格最老的微处理器芯片制造商了,历史上的第一个微处理器芯片4004就会Intel制造的。所谓x86系列,是指Intel从16位微处理器8086开始的整个CPU芯片系列,系列汇总的每种型号都保持与前面的各种型号兼容,主要有8086、8088、80186、80286、80386、80486以及各种型号的Pentium芯片。自从IBM选择8088用于PC个人计算机以后,x86系列的发证就与IBM PC及其兼容机的发展休戚相关了。其中80186并不广为人知,就与IBM当初决定停止在PC机中使用80186有关。限于篇幅,本系列博客不对这个系列的系统结构做全面的介绍,而只是结合linux内核的内存管理对其寻址方式做一些简要的说明。

在x86系列中,8086和8088是16位处理器,而从80386开始的32位处理器,80286则是该系列从8086到80386,也就是从16位到32位过渡时期的一个中间步骤。80286虽然仍是16位处理器,但是在寻址方式上开始从实地址模式到保护模式的过渡。

当我们说一个CPU是16位还是32位时,指的是处理器中算术逻辑单元(ALU)的宽度。系统总线中的数据部分,称为数据总线,通常与ALU具有相同的宽度(但有例外)。那么地址总线的宽度呢?最自然的地址总线宽度与数据总线一致。这是因为从程序设计的角度来说,一个地址,就是一个指针,最好是与一个整数的长度一致。但是,如果从8位CPU寻址能的角度来考虑免责这实际上是不现实的,因为一个8位的地址只能用来寻访256个不同的地址单元,这显然太小了。所以,一般8位CPU的地址总线都是16位的。这也造成了一些8位CPU在内部结构上的一些不均匀性,在8位CPU的指令系统中常常会发现一些实际上是16位的操作。当CPU的技术从8位发展到16位的时候,本来地址总线的宽度是可以跟数据总线抑制了,但是当时人们已经觉得由16位地址所决定的地址空间(64K)还是太小,还应该加大。加大多大呢?结合当时人们索能看到的微型机的应用前景,以及存储器芯片的价格,Intel决定采用1M,也就是说64K的16倍,那时候觉得应该是足够了。确实,1M字节的内存空间在当时已经很使一些程序员激动不已了,那时候配置齐全的小型机,甚至大型机也只不过是4M字节的内存空间。在计算机的发展史上,几乎每一个技术决策,往往很快就被事后出现的事实证明是估计不足的。

既然Intel决定了在其16位CPU,即8086中采用1M字节的内存地址空间,地址总线的宽度也就是相应地确定了,那就是20位。这样,一个问题就摆在了Intel的设计人员面前:虽然地址总线的宽度是20位,但是CPU中ALU的宽度却是16位,也就是说可直接加以运算的指针的长度是16位的。如何来填补这个空隙呢?可能的解决方案当然有很多种。例如,可以像在一些8位CPU中那样,增设一些20位的指令专用于地址运算和操作,但是那样又会造成CPU内部结构的不均匀性。再如,当时的PDP-11小型机也是16位的,但是结合其MMU(内存管理单元)可以将16位的地址映射待24位的地址空间。结果,Intel设计了一种在当时看来还不失为巧妙的方法,即分段的方法。

Intel在8086CPU中设置了四个段寄存器:CS、DS、SS和ES,分别用于可执行代码即指令、数据、堆栈和其他。每个段寄存器都是16位的,对应于地址总线中的高16位。每条访内指令中的内部地址都是16位的,但是在送上地址总线之前都在CPU内部自动地与某个段寄存器中的内容相加,形成一个20位的实际地址。这样,就实现了从16位内部地址到20位实际地址的转换,或者映射。这里要注意段寄存器中的内容对应于20位地址总线中的高16位,所以在相加时实际上是那内部地址中的高12位于段寄存器中的16位相加,而内部地址中的低4位保留不变。这个方法与操作系统理论中的段式内存管理相似,但并不完全一样,主要没有地址空间的保护机制。对于每一个由段寄存器的内容确定的基地址,一个进程总是能够访问从此开始的64K字节的连续地址空间,而无法加以限制。同时,可以用来改变段寄存器内容的指令也不是什么特权指令,也就是说,通过改变段寄存器的内容,一个进程可以随心所欲地访问内存中的任何一个单元,而丝毫不受限制。不能对一个进程的内存访问加以限制,也就谈不上对其他进程已系统本身的保护。与此相应,一个CPU如果却反对内存访问的限制,或者说保护,就谈不上什么内存管理,也就谈不上是现代意义上的中央处理器。由于8086的这种内存寻址方式缺乏对内存空间的保护,所以为了区别于后来出现的保护模式,就称为实地址模式。
显然,在实地址模式上时无法建立起现代意义上的操作系统的。

针对8086的这种缺陷,Intel从80286开始实现其保护模式(protected mode,但是早期的80286只能从实地址模式转入保护模式,却不能从保护模式转回实地址模式)。同时,不久以后32位的80386CPU也开发成功了。这样,从8088/8086到80386就完成了一次从比较原始的16位CPU到现代的32位CPU的飞跃,而80286则变成这次飞跃的一个中间步骤。从80386以后,Intel的CPU经历80486、Pentium、Pentium II等等型号,虽然在速度上提高了好几个量级,功能上也有不小的改进,但基本上属于同一种系统结构中的改进和加强,而并无重大的质的改变,所以统称为i386结构,或i386 CPU。下面我们将以80386为背景,介绍i386系列的保护模式。

80386是个32位CPU,也就是说它的ALU数据总线是32位的。我们在前面说过,最自然的地址总线宽度是与数据总线一致。当地址总线的宽度达到32位时,其寻址能力达到了4G对于内存来说似乎是足够了。所以,如果新设计一个32位CPU的话,其结构应该是可以做到很简洁、很自然的。但是,80386却无法做到这一点。作为一个产品系列中的一员,80386必须维持那些段寄存器,还必须支持实地址模式,在此同时又要能支持保护模式。而保护模式时完全另搞一套,还是建立在段寄存器的基础上以保持风格上的一致,并且还能节约CPU的内部资源呢?这对于Intel的设计人员来说无疑又是一次挑战。

Intel选择了在段寄存器的基础上构筑保护模式的构思,并且保留段寄存器为16位(这样才可以利用原有的资格段寄存器),但是却又增添了两个段寄存器FS和GS。为了实现保护模式,光是用段寄存器来确定一个基地址是不够的,至少还得有一个地址段的长度,并且还需要一些其他的信息,如访问权限之类。所以,这里需要的是一个数据结构,而并非一个单纯的基地址。对此,Intel设计人员的基本思路是:在保护模式下改变段寄存器的功能,使其从一个单纯的基地址(变相的基地址)变成指向这样一个数据结构的指针。这样,当一条访内指令发出一个内存地址时,CPU就可以这样来归纳出实际上应该放在数据总线的地址:

  1. 根据指令的性质来确定应该使用哪一个段寄存器,例如转移指令中的地址在代码段,而取数据指令中的地址在数据段。这一点与实地址模式相同。
  2. 根据段寄存器的内容,找到相应的地址段描述结构。
  3. 从地址段描述结构中得到基地址。
  4. 将指令中发出的地址作为位移,与段描述结构中规定的段长度相比,看看是否越界。
  5. 根据指令的性质和段描述符中的访问权限来确定是否越权。
  6. 将指令中发出的地址作为位移,与基地址相加而得出实际的物理地址。

虽然段描述结构存储在内存中,在实际使用时却将其装入CPU中的一组影子结构,而CPU在运行时则使用其在CPU中的影子。从保护的角度考虑,在由(指令给出的)内部地址(或者说逻辑地址)转换成物理地址的过程中,必须要在某个环节上对访问权限进行比对,以防止不具备特权的用户程序通过玩弄某些诡计(例如修改段寄存器的内容,修改段描述结构的内容等),得以非法访问其他进程的空间或系统空间。

明白了这个思路,80386的段式内存管理机制就比较地容易理解了(还是很复杂)。下面就是此机制的实际实现。

首先,在80386CPU中增设了两个寄存器:一个是全局性的段描述表寄存器GDTR(global descriptor table register),另一个是局部性的段描述表寄存器LDTR(local descriptor table register),分别可以用来指向存储在内存中的一个段描述结构数组,或者称为段描述表。由于这两个寄存器是新增设的,不存在于原有的指令是否兼容的问题,访问这两个寄存器的专用指令便是设计成特权指令。

在此基础上,段寄存器的高13位(低3位另作他用)用作访问段描述表中具体描述结构的下标(index),如图所示:

GDTR或LDTR中的段描述表指针和段寄存器中给出的下标结合在一起,才决定了具体的段描述表项在内存中的什么地方,也可以理解成,将段寄存器内容的低3位屏蔽掉以后与GDTR或LDTR中的基地址相加得到描述表项的起始地址。因此就无法通过修改描述表项的内容来玩弄诡计,从而起到保护的作用。每个段描述表项的大小是8个字节,每个描述表项含有段的基地址和段的大小,再加上其他的一些信息,其结构如图所示:

结构中 的B31-B24和B23-B16分别为基地址的bit16-b23和bit24-bit31。而L19-L16和L15-L0则为段长度(limit)的bit0-bit15和bit16-bit19。其中DPL是个2位的位段,而byte是一个4位的位段。它们所在的整个字节分解如图所示:

我们也可以用一段伪代码来说明整个段描述结构:

type struct {

     unsigned int base24_31:8 /*基地址的最高8位*/
     unsigned int g:1;        /*granularity,表段的长度单位,0表示字节,1表示4KB*/
     unsigned int d_b:1;      /*default operation size 存取方式,0=16,1=32位*/
     unsigned int avl:1;      /*available,可供系统软件使用*/
     unsigned int seg_limit_16_19:4;/*段长度的最高4位*/
     unsigned int p:1;  /*segment present,为0时表示该段的内容不在内存中*/
     unsigned int dpl:2;/*descriptor  privilege level,访问本段所需权限*/
     unsigned int s:1;/*描述项类型,1表示系统,0表示代码或数据*/
     unsigned int type:4;/*段的类型,与上面的S标志一起使用*/
     unsigned int base_0_23:24;/*基地址的低24位*/
     unsigned int seg_limit_0_15:16;/*段长度的低16位*/
}段描述项;

以这里的位段type为例,":4"表示其宽度为4位。整个数据结构的大小为64位,即8个字节。

读者一定会问:为什么把段描述项定义成这样一种奇怪的结构?例如,为什么基地址的高8位和低24位不连在一起?最自然也最合理的解释就是:开始时Intel的意图是24位地址空间,后来又改成32位地址空间。这也可以从段长度字段也是拆成两节得到印证:当g标志位为1时,长度的单位为4KB,而段长度字段的低16位的容量时64K,所以一个段的最大可能长度为64K*4K=256M,而这正是24位地址空间的大小。所以,可以看出,Intel起先意欲使用24位地址空间,不久又认识到应该用32位,但是80286已经发售出去了,于是就只好修修补补。当时的Intel确实给人一种小脚女人走路的感觉。

每当一个段寄存器的内容改变时(通过MOV、POP等指令或发生中断等事件),CPU就把由这段寄存器的新内容所决定的段描述项装入CPU内部的一个影子描述项。这样,CPU中有几个段寄存器就有几个影子描述项,所以也可以看做是对段寄存器的扩充。扩充后的段寄存器分成两部分,一部分是可见的(对程序而言),还与原先的段寄存器一样;另一部分是不可见的,就是用来存放影子描述项的空间,这一部分是专供CPU内部使用的。

在80386的段式内存管理的基础上,如果把每个段寄存器都指向同一个描述项,而在该描述项中则将基地址设成0,并将段长度设成最大,这样便形成一个从0开始覆盖整个32位地址空间的一个整段。由于基地址为0,此时的物理地址与逻辑地址相同,CPU放到地址总线上去的地址就是指令中给出的地址。这样的地址有别于由段寄存器、位移量构成的层次式地址,所以Intel称其为平面flat地址。linux内核的源代码(更确切地应该说是gcc)采用平面地址。这里要指出,平米阿尼地址的使用并不意味着绕过了段描述表、段寄存器这一整套段式内存管理的机制,而只是段式内存管理的一种使用特例。

关于80386的段式内存管理就先介绍这些,以后随着代码分析的进展视需要再加以补充。读者想要了解完整的细节可以参阅Intel的有关技术资料。

利用80386对段式内存管理的硬件支持,可以实现段式虚存管理。如前所述,当一个段寄存器的内容改变时,CPU要根据新的段寄存器内容以及GDTR或LDTR的内容找到相应的段描述项并将其装入CPU中。在此过程中,CPU会检查该描述项中的p标志位(表示present),如果p标志位为0,就表示该描述项所指的那一段内容不在内存中(也就是说,在磁盘上的某个地方),此时CPU会产生一次异常(exception,类似于中断),而相应的服务程序便可以从磁盘交换区将这一段的内容读入内存中的某个地方,并据此设置描述项中的基地址,再将p标志位设置成1。相应地,内存中暂时不用的存储段则可以写入磁盘,并将其描述项中的P标志位改成0。

对段式内存管理的支持只是i386保护模式的一个组成部分。如果没有系统状态和用户状态的分离,以及特权指令(只允许在系统状态下使用)的设立,那么尽管有了前述的段式内存管理,也还不能起到保护的效果。前面已经提到过特权指令的设置,如用来装入和存储GDTR和LDTR的指令LGDT/LLDT和SGDT/SLDT等就都是特权指令。正是由于这些指令都只能在系统状态(也就是在操作系统的内核中)使用,才使得yoghurt程序不但不能改变GDTR/LDTR的内容。还因为既无法确知其段描述表在内存中的位置,又无法访问其段描述表所在的空间(只能在系统状态下才能访问),从而无法通过修改段描述项来打破系统的保护机制。那么,80386怎么来分离系统状态和用户状态,并且提供在两种状态之间切换的机制呢?

80386并不只是像一般CPU通常所做的那样,划分出系统状态和用户状态,而是划分成四个特权级别,其中0级为最高,3级为最低。每一条指令也都有其适用级别,如前述的LGDT,就只有在0级的状态下才能适用,而一般的输入、输出指令(IN、OUT)则规定为0级或1级。通常,用户的应用程序都是3级。一般程序的当前运行级别由其代码段的局部描述项(即由段寄存器CS所指向的局部段描述项)中的dpl字段决定(dpl表示descriptor privilege level)。当然,每个描述项中的dpl字段都是在0级状态下由内核设定的。而全局段描述的dpl字段,则又有所不同,它是表示所需的级别。

前面讲过,16位的段寄存器中的高13位用作下标来访问段描述表,而低3位时干什么的呢?我们还是通过一段伪代码来说明:

typedef struct {
    unsigned short seg_idx:13;/*13位的段描述项下标*/
    unsigned short ti:1;/*段描述表指示位,0表示GDT,1表示LDT*/
    unsigned short rpl:2;/*requested privilege level,要求的优先级别*/
}段寄存器;

当段寄存器中的ti标志位为1,表示要使用全局段描述表,为0时,则表示要使用局部段描述表而rpl则表示所要求的的权限。当改变一个段寄存器的内容时,CPU会加以检查,以确保该段程序的当前执行权限和段寄存器所指定要求的权限均不低于所要访问的那一段内存的权限dpl。

至于怎么在不同的执行权限之间切换,我们将在进程调度、系统调用和中断处理的有关博客中讨论。此外,除了全局段描述表指针寄存器GDTR和局部段描述表指针LDTR两个寄存器外,其实i386 CPU中还有个中断向量表指针寄存器LDTR、与进程(在Intel术语中称为任务,task)有关的寄存器TR以及描述任务状态的任务状态段TSS等,这些都将在其他博客中有需要时再加以介绍。Intel在实现i386的保护模式时将CPU的执行状态分成4级,意图是为满足更为复杂的操作系统和运行环境的需要。有些操作系统,如OS/2中,也确实用了。但是很多人都怀疑是否真有必要搞得这么复杂。事实上,几乎所有广泛使用的CPU都没有这么复杂。而且,在80386上实现的各种Unix版本,包括linux,都只用了两个级别,即0级和3级,作为系统状态和用户状态。在以后的讨论中将沿用Unix的传统称之为系统状态和用户状态。

猜你喜欢

转载自blog.csdn.net/guoguangwu/article/details/121051917
今日推荐