3.2 保护模式进阶

内存访问

保护模式下字符串寻址:写内存的过程中,使用了一个常量 OffsetStrTest = StrTest - $$ ,等价于字符串 StrTest 相对于本节开始处 LABEL_DATA 处的偏移。

如果我们看下面初始化段描述符的过程,就会发现数据段的基址就是 LABEL_DATA 的物理地址,因此,OffsetStrTest 既是该字符串相对于 LABEL_DATA 的偏移,也是在数据段中的偏移。保护模式下,我们使用的就是这个偏移而不再是实模式下的地址。

从保护模式到实模式

chapter3/b/pmtest2.asm 中从实模式到保护模式,需要初始化GDT中的描述符,准备GdtPtr和加载GDTR,关中断,打开A20,修改CR0的PE位,最后一个跳转就可以了。

而从保护模式返回实模式,也需要做许多工作:

  • 需要加载一个合适的描述符的选择子到段寄存器,以包含合适的段界限和属性;

  • 另外,不能从32位代码段中返回实模式,只能从16位代码段中返回。因为无法实现从32位代码段返回时,cs高速缓冲寄存器中的属性符合实模式的要求(实模式不能改变段属性);

  • 结果是新增了 LABEL_DESC_NORMAL 的描述符,对应段 [SECTION .s16code] ,返回实模式之前将该段对应的选择子 SelectNormal 加载到 ds,es,fs,gs,ss 。

  • 然后,关闭CR0的 PE 位,进行跳转。

LDT(Local Descriptor Table)

同GDT一样,LDT简单来说也是一种描述符表,只不过它的选择子的 TI 位必须置为 1 。

(1) 代码和分析
下面的代码来自 chapter3/c/pmtest3.asm :

  • 增加了两个新的节,一个是新的描述符表LDT [SECTION .ldt] ,其中有一个描述符对应 [SECTION .la] ;[SECTION .la] 是该LDT的代码段,在GDT中无定义;

  • [SECTION .ldt] 在GDT中有对应的描述符 LABEL_DESC_LDT 和选择子,以及描述符的初始化代码;

  • [SECTION .la] 中将打印字符 L ,实现时调用了GDT中的 SelectorVideo ;

在运用LDT时,需要先用 lldt 指令加载 ldtr ,lldt 的操作数是 Selector ,对应的是 GDT 中用来描述 LDT 的描述符 LABEL_DESC_LDT (lgdt [GdtPtr] 加载 gdtr ,其操作数是一个 GdtPtr 的数据结构)。也就等同于LDT是GDT中描述的一个段,对应特别的寄存器ldtr,该段中又有描述符描述一些LDT段,只属于这个LDT段。

另外,此处的LDT有一个描述符LABEL_LDT_DESC_CODEA ,和GDT中的描述符没有区别;但是选择子却有不同,SelectorLDTCodeA 多了一个属性 SA_TIL —— 定义在 pm.inc ,SA_TIL EQU 4 。这是区别GDT和LDT选择子的关键所在,加上这个属性,会使得选择子 SelectorLDTCodeA 的 TI 位被置为 1。

LDT总结

上例的LDT很简单,只有一个代码段。我们完全可以在其中增加更多的段,比如数据段、堆栈段等,我们甚至可以把一个单独任务用到的所有东西都封装在一个LDT中,这种思想是后面章节中的多任务处理的一个雏形。

增加一个用LDT描述的任务的整个步骤如下:

  1. 增加一个32位的代码段;本节代码中原本的那个32位的代码段 [SECTION .32] 用来从实模式跳入保护模式,然后从该段中跳入LDT代码段 [SECTION .la] ;如果有更多的任务,就需要增加新的LDT代码段;最后一个LDT代码段,负责跳回到GDT中描述的16位代码段,然后返回实模式。

  2. 增加一个LDT段,内容是LDT描述符表,可以有多个描述符描述多个段;注意,使用选择子的时候 TI 位为 1;

  3. 在GDT中新增一个描述符,用来描述这个新的LDT,同时定义其选择子;

  4. 增加GDT中新的描述符的初始化代码,主要用来设置段基址;

  5. 用新加的LDT描述的局部任务准备完毕;

  6. 先用 lldt 加载 ldtr ,用 jmp 指令跳转等运行。

特权级概述

描述符属性中的 DPL (Descriptor Privilege Level)和选择子中的 RPL (Requested Privilege Level) 都是用来表示特权级的。前面所有代码都运行在最高特权级下——DPL=RPL=0 。

在 IA32 的段式内存机制中,特权级从高到低是 0~3 ,如下图。核心代码和数据,被放置在高特权级中,用以防止低特权级任务在不被允许的情况下访问高特权级的段:

CPL、DPL、RPL

通过识别 CPL (Current Privilege Level) 和 DPL,RPL 三种特权级,处理器进行特权级检验。

  1. CPL 当前执行的程序或任务的特权级:

    它存储于 CS和SS 的第 0,1 两位上,通常情况下CPL等于代码所在段的特权级;
    程序转移到不同特权级的代码段时,CPL会被处理器改变;
    转移到一致代码段时,CPL则会延续不变,因为一致代码段可以被相同或低特权级的代码访问

  2. DPL(Descriptor Privilege Level) 段或者门的特权级:

    它被存储在 段描述符或者门描述符 的 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 的最低特权级。(Task State Segment)
    总的来说,数据段、调用门、TSS三者的DPL规则是一致的。

  3. RPL (Requested Privilege Level)

    它存在于段选择子的 0,1 位,根据代码中不同段的跳转来确定,以动态刷新 CPL ;
    处理器通过检查 RPL,CPL 确认一个访问请求是否合法,不仅提出访问请求的段需要有足够的CPL特权级,RPL也要够高。如果RPL>CPL(RPL特权级更低),RPL对访问合法性其决定作用,反之亦然;
    操作系统用RPL避免低特权级程序访问高特权级的数据:
    (操作系统过程)被调用过程从一个(应用程序)调用过程中接受到一个选择子时,会将选择子的RPL设置为调用者的特权级;
    然后,操作系统用这个选择子访问特殊的段时,处理器会用调用过程的RPL(已存储到CPL中),而不是更高的操作系统过程的特权级CPL进行特权检验。

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

这里,我们会看一下不同特权级代码段之间的转移情况。

从一个代码段转移到另一个代码段之前,目标代码段的选择子会被加载到 cs 中;
然后,处理器将检查段描述符的界限、类型、特权级等;
如果检验成功,cs 会被加载,程序控制权转移到新的代码段中,从 eip 指示的位置开始执行。
程序控制转移的发生,常常由 jmp, call, ret, sysenter, sysexit, int n, iret 引起,亦可能是中断和异常处理机制引起。其中,使用 jmp,call 可以实现4种转移:

目标操作数包含目标代码段的段选择子;
目标操作数指向一个包含目标代码段选择子的调用门描述符;
目标操作数指向一个包含目标代码段选择子的TSS;
目标操作数指向一个任务门,该任务门指向一个包含目标代码段选择子的TSS。
其中,第一种是通过 jmp,call 的直接转移,是一类;另外三种是通过某个描述符的间接转移,是第二类。下面将开始详细的阐述。

特权级转移

(1) 通过jmp或者call进行直接转移

通过前面的讨论,我们可以总结出下面的规则:

  • 目标代码段是非一致代码段,则要求 CPL 必须等于目标段的 DPL ,同时 RPL <= DPL ;

  • 目标代码段是一致代码段,则要求 CPL 必须小于等于目标段的 DPL,RPL 不做检查。转移到目标段后,CPL 不会变成目标代码段的 DPL 。

  • 这样,jmp和call进行的代码段间直接转移很有限:

    • 对于非一致代码段,只能够在相同特权级代码段间转移;

    • 对于一致代码段,最多能从低到高,而且 CPL 不会改变。

(2) 调用门体验

门也是一种描述符 Gate Descriptor ,其结构如下,和之前提到的描述符很不相同:

直观来看,一个门描述符定义了目标代码对应段的一个选择子、入口地址偏移指定的线性地址(程序通过这个地址进行转移)、一些属性,属性中 BYTE5 和以前的描述符完全相同,S 位固定为零。门描述符的类型有四种:

  • 调用门 Call Gates

  • 中断门 Interrupt Gates

  • 陷阱门 Trap Gates

  • 任务门 Task Gates

中断门和陷阱门是特殊的,先不介绍,而是先介绍调用门。下面的例子用到调用门但先不涉及特权级转换。在 pmtest3.asm 的基础上增加一个代码段,作为通过调用门转移的目标段:(ch3/d/pmtest4.asm)

我们注意到,代码末尾是 retf 指令,因为我们要用call指令调用这个建立的调用门。下面加入代码段的描述符、选择子、及初始化这个描述符的代码:

调用门的代码,门描述符的属性为 DA_386CGate ,表明是一个调用门;选择子是 SelectorCodeDest ,表明目标代码段是刚刚添加的代码段;偏移地址是 0 ,即跳转到目标段的开头;另外,DPL=0 :

1
2
; 门
LABEL_CALL_GATE_TEST: Gate SelectorCodeDest, 0, 0, DA_386CGate + DA_DPL0

上面,我们完成了准备调用门的工作,门指向 SelectorCodeDest:0 即标号 LABEL_SEG_CODE_DEST 处的代码。然后,用call使用它:

1
2
3
4
5
6
...
; 测试调用门(无特权级变换), 打印字符'C'
call SelectorCallGateTest:0
...
jmp SelectorLDTCodeA:0 ; 跳入局部任务, 打印字符'L'
...

调用门本质上是一个入口地址,只是增加了一些属性罢了。上面的例子中调用门完全等同于一个地址,可以将使用调用门进行跳转的指令改为:

1
call SelectorCodeDest:0

如果我们想要在不同的特权级代码间转移的话,还需要学习使用调用门进行转移时特权级检验的规则:

  • 调用一个调用门G,从代码A转移到代码B(调用门G中目标选择子指向的段),中间涉及到了 CPL 、RPL 、G的DPL(DPL_G)、B的DPL(DPL_B);

  • 代码A访问G调用门时,其规则等同于访问一个数据段,要求 CPL,RPL <= DPL_G 。即 CPL,RPL 要在更高的特权级上;

  • 此外,系统还要比较 CPL 和 DPL_B 。如果是一致代码段,则 DPL_B <= CPL,即 CPL 特权级要么相等要么较低;如果是非一致代码段,则jmp和call有所不同,call时要求 DPL_B <= CPL ;jmp时只能是 DPL_B = CPL 。

  • 也就是说,通过调用门和CALL,无论目标是一致还是非一致代码段,都可以实现从低特权级到高特权级的跨越。

总的来说,调用门使用时特权级检验规则如下:

(3) 长短调用时的堆栈变化

长跳转/调用 far jmp/call 和短跳转/调用 near jmp/call 的不同:

  • 对于jmp来说,长跳转对应段间,短跳转对应段内,结果没什么不同;

  • 对于call来说,由于call指令会影响堆栈,长短调用对堆栈的影响也不同。

    • 对于短调用,先是将参数依次入栈,call执行将下一条指令 nop 的地址——调用者eip 压入栈,对应下图的 esp (指向当前堆栈的栈顶)的变化:

      然后,到ret执行时,这个 eip 会从堆栈被弹出,执行前后的 esp 变化如下:

    • 长调用的情况类似,不过由于跨了段,因此在call指向时压入栈的不仅有 eip (下一条指令的地址),还有 cs

      ret执行后返回,需要调用者的 cs 和 eip ,因此弹出两者:

(4) 特权级变换时的堆栈变化

联系起通过调用门的转移,我们很容易想到,call一个调用门也是长调用。但是不同的是,特权级变化的时候,堆栈也要发生切换,即call执行前后的堆栈不再是同一个。

处理器的这种机制避免了高特权级的过程由于栈空间不足而崩溃;另外,如果不同特权级共享同一个堆栈的话,高特权级程序可能因此受到有意或无意的干扰。无疑,这也是一种保护。

但是这种变化也给我们带来了困扰,如果我们压入参数和返回时地址,需要使用的时候却发现堆栈已经变成了另外一个,该怎么办呢?Intel提供了一种机制——将堆栈A的内容复制到堆栈B中,如下图:

上面仅仅涉及到两个堆栈,但是,由于每个任务最多可能在4个特权级间切换,因此每个任务实际需要4个堆栈。无奈我们只有一个 ss 和一个 esp ,如果发生堆栈切换,该从哪里得到其他堆栈的 ss,esp 呢?这里涉及到 TSS (Task-State Segment),它是一个数据结构,包含多个字段。32位TSS如下图:

cs是代码段寄存器
存放当前正在运行的程序代码所在段的段基址,表示当前使用的指令代码可以从该段寄存器指定的存储器段中取得,相应的偏移量则由IP提供。

ds是数据段寄存器
当前程序使用的数据所存放段的最低地址,即存放数据段的段基址

ss是堆栈段寄存器
当前堆栈的底部地址,即存放堆栈段的段基址

es是扩展段寄存器
当前程序使用附加数据段的段基址,该段是串操作指令中目的串所在的段

gs是全局段寄存器

gs是80386起增加的两个辅助段寄存器之一,在这之前只有一个辅助段寄存器ES

fs是标志段寄存器

重点关注偏移4到偏移27的3个 ss 和3个 esp 。当发生堆栈切换时,内层的 ss 和 esp 就是从这里取得的。如果我们从 ring3->ring1 ,堆栈将自动切换到 ss1,esp1 指定的位置。由于只是从低特权级到高特权级切换时新堆栈才会从TSS中取得,所以TSS中没有位于最外层的 ring3 (最低特权级) 的堆栈信息。

书上总结了CPU在整个转移过程中做的工作,即调用门从外层到内层的全过程:

  1. 根据目标代码段的 DP (新的 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 的值压入新的 ss,esp 指向的新栈;

  7. 从调用者堆栈中将参数复制到被调用者堆栈(新堆栈)中,复制参数的数目由调用门中 ParamCount 来决定,如果是零的话,不复制参数;ParamCount 有5个字节,最大可以表示31个参数,更大时需要让其中的一个参数变成指向一个数据结构的指针,或者通过保存在新堆栈中的 ss,esp 访问旧堆栈中的参数;

  8. 将当前的 cs,eip 压入新栈;

  9. 加载调用门中指定的新的 cs,eip ,开始执行被调用者过程。

反过来,ret是call的反过程,只是带参数的ret指令会同时释放事先被压栈的参数。ret不仅可以实现长短返回,而且可以实现带有特权级变换的长返回。由被调用者到调用者的返回过程如下:

  1. 检查保存的 cs 上的 RPL 以判断返回时是否需要变换特权级;

  2. 加载被调用者堆栈上的 cs, eip ,进行代码段描述符和选择子类型与特权级检验;

  3. 如果ret指令含有参数,则增加 esp 的值跳过参数。然后 esp 指向被保存过的调用者的 ss, esp 。注意,ret的参数必须对应调用门中的 ParamCount 的值;

  4. 加载 ss, esp ,切换到调用者堆栈,被调用者的 ss, esp 被丢弃。这里将进行 ss 描述符、esp 以及 ss 段描述符的检验;

  5. 如果ret指令含有参数,增加 esp 的值跳过参数,此时已经处于调用者堆栈中;

  6. 检查 ds, es, fs, gs 的值,如果哪个寄存器指向的段的 DPL < CPL ,则加载一个空描述符到对应寄存器。此规则不适用于一致代码段。

(5) 进入ring3

在ret执行前,堆栈中应该准备好了目标代码段的 cs,eip ,以及 ss, esp 和参数等。我们的例子中,ret前的参数如下:

执行完ret之后,就可以转移到低特权级代码中了。在pmtest4.asm 基础上做一下修改,形成 pmtest5a.asm 。

首先添加一个 ring3 的代码段 [SECTION .ring3] 和一个 ring3 的堆栈段 [SECTION .s3] ,代码段很简单,同样是打印一个字符,会在"In Protect Mode now."下方显示。不过由于其运行在 ring3 ,但是写显存要访问 VIDEO 段,为了避免错误,我们把 VIDEO 的 DPL 改为3。同时,新的代码段对应描述符的属性加上 DA_DPL3 ,相应选择子的 SA_RPL3 也将 RPL 设为了3:

这样,代码段和堆栈段都准备好了,现在将 ss, esp, cs, eip 依次压栈,执行 retf 指令:

至此实现由ring0到ring3、从高特权级到低特权级的转移。

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

在 [SECTION .ring3] 中增加了使用调用门的代码,修改调用门的描述符和选择子使其满足 CPL, RPL 都小于等于 DPL 的条件:
在这里插入图片描述
接着,从低特权级到高特权级转移时,需要用到TSS,下面准备一个TSS:

在这里插入图片描述
添加初始化TSS描述符的代码后,开始加载TSS: