保护模式-2
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描述的任务的整个步骤如下:
-
增加一个32位的代码段;本节代码中原本的那个32位的代码段 [SECTION .32] 用来从实模式跳入保护模式,然后从该段中跳入LDT代码段 [SECTION .la] ;如果有更多的任务,就需要增加新的LDT代码段;最后一个LDT代码段,负责跳回到GDT中描述的16位代码段,然后返回实模式。
-
增加一个LDT段,内容是LDT描述符表,可以有多个描述符描述多个段;注意,使用选择子的时候 TI 位为 1;
-
在GDT中新增一个描述符,用来描述这个新的LDT,同时定义其选择子;
-
增加GDT中新的描述符的初始化代码,主要用来设置段基址;
-
用新加的LDT描述的局部任务准备完毕;
-
先用 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 三种特权级,处理器进行特权级检验。
-
CPL 当前执行的程序或任务的特权级:
它存储于 CS和SS 的第 0,1 两位上,通常情况下CPL等于代码所在段的特权级;
程序转移到不同特权级的代码段时,CPL会被处理器改变;
转移到一致代码段时,CPL则会延续不变,因为一致代码段可以被相同或低特权级的代码访问 -
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规则是一致的。 -
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 | ; 门 |
上面,我们完成了准备调用门的工作,门指向 SelectorCodeDest:0 即标号 LABEL_SEG_CODE_DEST 处的代码。然后,用call使用它:
1 | ... |
调用门本质上是一个入口地址,只是增加了一些属性罢了。上面的例子中调用门完全等同于一个地址,可以将使用调用门进行跳转的指令改为:
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在整个转移过程中做的工作,即调用门从外层到内层的全过程:
-
根据目标代码段的 DP (新的 CPL) 从 TSS 中选择应该切换到哪个 ss,esp ;
-
从 TSS 中读取新的 ss,esp 。如果发现 ss,esp 或者 TSS 界限错误都会报无效TSS异常(#TS);
-
对 ss 描述符进行检验,如果错误,同样发生 #TS 异常;
-
暂时保存当前 ss,esp 的值;
-
加载新的 ss,esp ;
-
将刚刚保存下来的 ss,esp 的值压入新的 ss,esp 指向的新栈;
-
从调用者堆栈中将参数复制到被调用者堆栈(新堆栈)中,复制参数的数目由调用门中 ParamCount 来决定,如果是零的话,不复制参数;ParamCount 有5个字节,最大可以表示31个参数,更大时需要让其中的一个参数变成指向一个数据结构的指针,或者通过保存在新堆栈中的 ss,esp 访问旧堆栈中的参数;
-
将当前的 cs,eip 压入新栈;
-
加载调用门中指定的新的 cs,eip ,开始执行被调用者过程。
反过来,ret是call的反过程,只是带参数的ret指令会同时释放事先被压栈的参数。ret不仅可以实现长短返回,而且可以实现带有特权级变换的长返回。由被调用者到调用者的返回过程如下:
-
检查保存的 cs 上的 RPL 以判断返回时是否需要变换特权级;
-
加载被调用者堆栈上的 cs, eip ,进行代码段描述符和选择子类型与特权级检验;
-
如果ret指令含有参数,则增加 esp 的值跳过参数。然后 esp 指向被保存过的调用者的 ss, esp 。注意,ret的参数必须对应调用门中的 ParamCount 的值;
-
加载 ss, esp ,切换到调用者堆栈,被调用者的 ss, esp 被丢弃。这里将进行 ss 描述符、esp 以及 ss 段描述符的检验;
-
如果ret指令含有参数,增加 esp 的值跳过参数,此时已经处于调用者堆栈中;
-
检查 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: