一个操作系统的实现(5)-关于特权级

时间:2022-08-25 04:53:40

这节讲述IA32分段机制中的特权级。包括CPL、DPL、RPL的介绍以及代码实现不同特权级之间的转换。

IA32的分段机制有四种特权级别,从高到低分别是0、1、2、3。数字越小表示的特权级越大。

处理器引入特权级的目的是为了保护核心代码和数据。核心的代码和数据会被放在较高的层级中。从而避免低特权级(外层)的任务在不被允许的情况下访问位于高特权级(内层)的段。

在开始之前,首先介绍一下一致代码段的概念。

一致代码段

关于一致代码段中一致的理解:程序经常会通过call和jmp实现直接转移操作。当转移的目标是一个特权级更高的一致代码段,当前的特权级会被延续下去,而向特权级更高的非一致代码段转移将会引起常规保护错误(general-protection exception, #GP)。你看完下面CPL的介绍会对这句话有更深刻的理解。

当然是有办法访问特权级更高的非一致代码段的:使用调用门或者任务门。

如何去划分一致代码段与非一致代码段呢?从上面的介绍可以知道,一致代码段的保护较弱,能够被低特权级的代码通过call和jmp访问到。所以,如果系统代码不访问受保护的资源和某些类型的异常处理(比如,除法错误或溢出错误),那么此系统代码可被放在一致代码段中。对于那些为了避免被低特权级的程序访问而保护起来的系统代码应该放到非一致代码段中。

另外,如果目标代码的特权级低,无论它是不是一致代码段,都不能通过call或者jmp转移进去,尝试这样的转移将会导致常规保护性错误。

所有的数据段都是非一致的,这意味着不可能被低特权级的代码访问到。然而,与代码段不同的是,数据段可以被更高特权级的代码访问到,而不需要使用特定的门。

综上,通过call和jmp的转移遵从下表的规则:

引入特权级之后call和jmp能够实现的直接转移类型

 
  特权级`低->高` 特权级`高->低` 相同特权级之间 适用于何种代码
一致代码段 Yes No Yes 不访问受保护的资源和某些
类型的异常处理的系统代码
非一致代码段 No No Yes 避免低特权级的程序访问而
被保护器来的系统代码
数据段 No Yes Yes  

举个例子,假设有代码段AB,数据段D。那么,

当A向跳转到B,有下面两种情况:

当B是一致代码段时,A的特权级低于或等于B的时候才有可能通过call和jmp从A转移到B。

当B是非一致代码段时,A的特权级必须等于B才有可能通过call和jmp从A转移到B。

如果A想要访问数据段D,那么A的特权级必须高于或等于D。

上面讲的所有东西都在这个例子中。从这儿可以看出,引入了特权级指令之后,call和jmp指令并不能满足所有的转移情况。比如想转移到高特权级的非一致代码段,call和jmp就无法实现了,但下面将要讲的调用门能够实现。

不过在讲调用门之前,首先需要了解CPL、DPL、RPL。

CPL、DPL、RPL

1 CPL(Current Privilege Level)

CPL代表的是当前执行的程序或任务的特权级。它被存储在csss的第0位和第1位上

通常情况下,CPL等于代码所在的段的特权级。当程序转移到不同的特权级的代码段时,处理器将改变CPL。

上面说到是通常情况,那么就有一个特例:转移的目标是一致代码段。因为一致代码段可以被相同或者更低特权级的代码访问。所以当处理器访问一个于CPL特权级不同的一致代码段时,CPL不会被改变

2 DPL(Descriptor Privilege Level)

DPL表示段或者门的特权级。它被存储在段描述符或者门描述符的DPL字段中。正如我们先前所看到的那样。当当前代码段试图访问一个段或者门时,DPL将会和CPL以及段或门选择子的RPL相比较,根据段或者门类型的不同,DPL将会被区别对待,下面介绍一下各种类型的段或者门的情况。

  • 数据段 : DPL规定了可以访问此段的最低特权级。比如,一个数据段的DPL是1,那么只有运行在CPL为0或者1的程序才有权访问它。
  • 非一致代码段(不使用调用门的情况下) : DPL规定访问此段的特权级。比如,一个非一致代码段的特权级为0,那么只有CPL为0的程序才可以访问它。
  • 调用门 : DPL规定了当前执行的程序或任务可以访问此调用门的最低特权级(这与数据段的规则是一致的)。
  • 一致代码段和通过调用门访问的非一致代码段 : DPL规定了访问此段的最高特权级。比如,一个一致代码段的DPL是2,那么CPL为0和1的程序将无法访问此段。
  • TSS : DPL规定了可以访问此TSS的最低特权级(这与数据段的规则是一致的)。

3 RPL(Requested Privilege Level)

RPL是选择子的特权级,它是通过选择子的第0位和第1位表现出来的。

处理器通过检查RPL和CPL来确认一个访问请求是否合法。即便提出访问请求的段有足够的特权级,如果RPL不够也是不行的。也就是说,如果RPL的数字比CPL大(数字越大特权级越低),那么RPL将会起决定性作用,反之亦然。

操作系统过程往往用RPL来避免低特权级应用程序访问高特权级段内的数据。当操作系统过程(被调用过程)从一个应用程序(调用过程)接收到一个选择子时,将会把选择子的RPL设成调用者的特权级。于是,当操作系统用这个选择子去访问相应的段时,处理器将会用调用过程的特权级(已经被存到RPL中),而不是更高的操作系统过程的特权级(CPL)进行特权检验。这样,RPL就保证了操作系统不会越俎代庖地代表一个程序去访问一个段,除非这个程序本身是有权限的。什么意思

上面的内容完全出自书本上

介绍完了CPL、DPL、RPL。接下来看看不同特权级代码之间的转移。

不同特权级代码段之间的转移

转移过程:程序从一个代码段转移到另一个代码段之前,目标代码段的选择子会被加载到cs中。在加载之前,处理器会检验描述符的界限类型特权级等内容。如果检验成功,cs将会被加载,程序控制将转到新的代码段中,从eip指示的位置开始执行。

程序控制转移的发生引起原因如下:

指令引起,包括jmpcallretsysentersysexitint niret等指令。

中断和异常引起。

使用jmp和call能够实现的四种转移如下:

  • 目标操作数包含目标代码段的段选择子。
  • 目标操作数指向一个包含目标代码段选择子的调用门描述符。
  • 目标操作数指向一个包含目标代码段选择子的TSS。
  • 目标操作数指向一个任务门,这个任务门指向一个包含目标代码段选择子的TSS。

这四种转移可以看作两大类,一类是通过jmp和call的直接转移(上述第1种),另一类是通过某个描述符的间接转移(上述第2、3、4种)。下面就来分别看一下。

通过jmp和call进行的直接转移

上面介绍了很多通过jmp和call的直接转移。这里总结一下。

目标是非一致代码段的转移条件:CPL==DPLRPL<=DPL

目标是一致代码段的转移条件:CPL>=DPL、RPL此时不做检查

上面已经说过jmp和call进行直接转移的限制条件太多。如果向*地进行不同特权级之间的转移,需要通过门描述符或者TSS。

门描述符结构

一个操作系统的实现(5)-关于特权级

选择子:目标代码的选择子,用来初始化cs。指明转移处的目标代码段。

偏移地址:是用来初始化eip,指明转移到目标代码段的某个偏移处执行。

属性:

BYTE5:与其他描述符完全相同,此时S位为0(代表门描述符)。

BYTE4:转移过程需要从调用者堆栈中将参数复制到被调用者堆栈(新堆栈)中,Param Count指明复制参数的数目。Param Count为0将不会复制参数。

从上面门描述符的结构可以看出,一个门描述了由一个选择子和一个偏移所指定的线性地址(关于虚拟地址,线性地址,物理地址的概念会在后面讨论)。程序就是通过这个地址进行转移的。

门描述符有四种:

  • 调用门(Call gates)
  • 中断门(Interupt gates)
  • 陷阱门(Trap gates)
  • 任务门(Task gates)

下面用代码实现调用门的使用。在下面这个例子中,先不涉及任何特权级的变换,只是实现通过调用门转移代码。

相同特权级下使用调用门

相对于上节的代码,增加如下部分:

通过调用门转移的目标段

从这里看目标段也比较简单。在屏幕第12行第0列打印一个黑底红色的字符C

因为这里打算用call指令调用将要建立的调用门,所以,在这段代码的结尾处调用了一个retf指令。retf表示段间返回。

上述代码段的描述符选择子初始化描述符的代码

调用门

上面可以看到,门描述符的属性是DA_386CGate+DA_DPL0。DA_386CGate表明他是一个调用门;DPL0指定门描述符的DPL为0。上面指定的选择子是SelectorCodeDest,表明目标代码是刚刚新添加的代码段。偏移地址是0,表明将要跳转到目标代码段的开头处执行。DCount代表的是Param Count,这里表明转移时不复制参数到被调用者的堆栈。

这里用一个宏Gate来初始化描述符,Gate的定义在pm.inc中,如下:

在认识保护模式那一节我还不确定%1%2%3…这些符号是什么意思。当时的猜测是传递进去的参数,按照位置分别是1,2,3。与shell中的位置变量类似。最近仔细看了上面的定义。现在可以肯定我的猜测是对的了。如果下次再有这种疑问,可以先猜测,也许在接下来的某天重新看会豁然开朗。

调用门对应的选择子

好了,现在调用门准备就绪,它指向的位置是SelectorCodeDest:0,即标号LABEL_SEG_DESC_DEST处的代码。

下面,使用call指令来使用调用门。

使用调用门

call指令放在jmp之前。因为目标代码以retf结尾,所以call调用结束之后会返回到call下面的那条代码继续执行。因此此段代码最终的结果是在上一节的基础上多了一个字母C。

一个操作系统的实现(5)-关于特权级

其实调用门这种听起来很可怕的东西本质上只不过是个入口地址,只是增加了若干的属性而已。在我们的例子中所用到的调用门完全等同于一个地址,我们甚至可以把使用调用门进行跳转的指令修改为跳转到调用门内指定的地址的指令:

运行一下,效果是完全相同的。

看起来引入调用门有一点多此一举,但事实上并不是。下面将用他来实现不同特权级的代码之间的转移。不过首先你需要知道使用调用门进行转移时的特权级检验规则。

使用调用门进行转移时特权级的检验规则

 
  call jmp
目标是一致代码段 CPL <= DPL_G
RPL <= DPL_G
DPL_B <= CPL
目标是非一致代码段 CPL <= DPL_G
RPL <= DPL_G
DPL_B <= CPL
CPL <= DPL_G
RPL <= DPL_G
DPL_B == CPL

从上表我们能够看出,通过调用门和call指令,可以实现从低特权级到高特权级的转移,无论目标代码段是一致的还是非一致的。

说到这里,你一定又跃跃欲试了,写一个程序实现一个特权级变换应该是件有趣的事情。可是你可能突然发现,调用门只能实现特权级由低到高的转移,而我们的程序一直是在最高的特权级下的。也就是说,我们需要先到相对低一点的特权级下,才可能有机会对调用门亲自实践一番。那么,如何才能到低一点的特权级下呢?先不要慌,调用门的故事还没有讲完。

有特权级变换的转移的复杂之处,不但在于严格的特权级检验,还在于特权级变化的时候,堆栈也要发生变化。处理器的这种机制避免了高特权级的过程由于栈空间不足而崩溃。而且,如果不同特权级共享同一个堆栈的话,高特权级的程序可能因此受到有意或无意的干扰。

使用调用门时的堆栈变化

首先回忆一下8086汇编语言的长跳转和短跳转。

长跳转相当于:

短跳转相当于:

call的返回过程弹出IP(或IP与CS)

从上面可以看出,call指令是影响堆栈的

我们的调用门转移是通过长调用(长跳转)call指令来实现的。上面已经知道,call指令会压栈与出栈。但是在第7小结说过,特权级变化的时候,堆栈也要变化。因此call指令执行前后的堆栈已经不是同一个了。这样一来问题出现了,我们在堆栈A中压入参数和返回地址,等到需要使用他们的时候堆栈已经变成B了,如何解决这个问题呢?

Intel提供了这样一种机制:将堆栈A的诸多内容复制到堆栈B中。

由于每一个任务最多都可能在4个特权级间转移,所以,每个任务实际上需要4个堆栈。可是,我们只有一个ss和一个esp,那么当发生堆栈切换,我们该从哪里获得其余堆栈的ss和esp呢?实际上,这里涉及一样新事物TSS(Task-State Stack),它是一个数据结构,里面包含多个字段,32位TSS如下图所示:

一个操作系统的实现(5)-关于特权级

解释一下TSS的4-27字段的使用方法:比如,我们当前所在的是ring3,当转移至ring1时,堆栈将被自动切换到由ss1和esp1指定的位置。由于只是在由外层到内层(低特权级到高特权级)切换时新堆栈才会从TSS中取得,所以TSS中没有位于最外层的ring3的堆栈信息。

到这里堆栈会变化的问题也解决了,接下来让我们看一下整个的转移过程是怎样的。下面就是CPU的整个过程所做的工作:

通过调用门转移的过程中CPU所做的工作

  1. 根据目标代码段的DPL(新的CPL)从TSS中选择应该切换至哪个ss和esp。
  2. 从TSS中读取新的ss和esp。在这过程中如果发现ss、esp或者TSS界限错误都会导致无效TSS异常(#TS)。
  3. 对ss描述符进行检验,如果发生错误,同样产生#TS 异常。
  4. 暂时性地保存当前ss和esp的值。
  5. 加载新的ss和esp。
  6. 将刚刚保存起来的ss和esp的值压入新栈。
  7. 从调用者堆栈中将参数复制到被调用者堆栈(新堆栈)中,复制参数的数目由调用门中Param Count一项来决定。如果Param Count是零的话,将不会复制参数。
  8. 将当前的cs和eip压栈。
  9. 加载调用门中指定的新的cs和eip,开始执行被调用者过程。

上面就是CPU在整个过程中所做的工作。调用过程结束后会通过ret(retf)返回,那么返回过程中CPU做了哪些工作呢?看下一小节:

调用门转移结束后通过ret返回时CPU所做的工作

  1. 检查保存的cs中的RPL以判断返回时是否要变换特权级。
  2. 加载被调用者堆栈上的cs和eip(此时会进行代码段描述符和选择子类型和特权级检验)。
  3. 如果ret指令含有参数,则增加esp的值以跳过参数,然后esp将指向被保存过的调用者ss和esp。注意,ret的参数必须对应调用门中的Param Count 的值。
  4. 加载ss和esp,切换到调用者堆栈,被调用者的ss和esp被丢弃。在这里将会进行ss描述符、esp以及ss段描述符的检验。
  5. 如果ret指令含有参数,增加esp的值以跳过参数(此时已经在调用者堆栈中)。
  6. 检查ds、es、fs、gs的值,如果其中哪一个寄存器指向的段的DPL小于CPL(此规则不适用于一致代码段),那么一个空描述符会被加载到该寄存器。

通过以上两小节可以看出,使用调用门的过程分为两个部分:

一部分是从低特权级到高特权级,通过调用门和call指令来实现

另一部分是从高特权级到低特权级,通过ret指令来实现。

接下来我们就用ret指令实现由高特权级到低特权级的转移:

通过ret指令从ring0进入ring3

通过上面的分析我们知道,在ret指令执行前,堆栈中应该已经准备好了目标代码的cseipssesp,另外还可能有参数。这些可以是处理器压入栈的,当然,也可以由我们自己压栈。在接下来的例子中,ret前的堆栈如下图所示:

一个操作系统的实现(5)-关于特权级

这样,ret执行之后就可以转移到低特权级代码中了。接下来用代码实现如下:

在原来的代码上添加如下内容:

ring3的代码段ring3的堆栈段

ring3堆栈段与ring3代码段的描述符的初始化代码

由于这段代码运行在ring3,而在其中由于要写显存而访问到了VIDEO段,为了不会产生错误,我们把VIDEO段的DPL修改为3(第25行)。依据上面所说的规则,RPL不需要修改。

上面代码段和数据段都已经初始化好了。接下来将ssespcseip依次压栈,并且执行retf指令。

查看结果,如果出现了红色的3并且不返回到DOS(因为新添加的代码段最后是jmp $),说明我们已经成功进入ring3。

一个操作系统的实现(5)-关于特权级

上面就是从ring0到ring3的过程。接下来开始使用调用门实现ring3到ring0的转移

通过调用门进行有特权级变换的转移

上面已经进入ring3了,接下来通过调用门重新进入ring0。将上面ring3的代码修改如下:

jmp $之前,增加了使用调用门的指令,这个调用门是之前已经定义好了的。修改描述符和选择子是为了满足CPL和RPL都小于等于调用门DPL的条件

不要忘记,从低特权级到高特权级转移的时候,需要用到TSS。因此接下来需要人工准备一个TSS:

因为这里只进入ring0,所以在这里先只初始化0级堆栈。

接下来初始化TSS描述符:

最后需要在特权级变换之前加载TSS:

接下来开始运行,运行结果如下:

一个操作系统的实现(5)-关于特权级

初始的32位代码段(ring0)打印一串字符串,ring3代码段打印数字3,调用门的目标代码段(ring0)打印字母C.因此到这里,我们实现了从rong0到ring3,然后再返回ring0的整个过程。接下来做最后一步,就是使程序顺利返回实模式,只需要将调用局部任务的代码加入到调用门的目标代码([SECTION .sdest])。最后,程序将由这里进入局部任务,然后由原路返回实模式。代码如下:

一个操作系统的实现(5)-关于特权级

从上面的运行结果我们能够知道。到这里就实现了DOS->ring0->ring3->ring0->DOS的整个过程。

源代码