摘要:在汇编语言的编程和操作系统的编写过程中,我们经常能听说到“保护模式”这个名词。为什么要叫“保护模式”呢?保护 二字的含义何在?本文主要探讨,“保护模式”下面各种具体的保护机制,这些保护机制产生的原因和具体的影响。阅读本文之前,读者需要了解基本的处理器相关知识,知道分段和分页机制的基本原理,熟悉一些基本的数据结构(段选择符、描述符、GDT、页表、CR系列寄存器等),另外也需要懂一些基本的汇编知识。
发明保护模式是为了进行多任务设计,避免任务之间的相互干扰。保护模式的实现是通过分段和分页机制来进行的。通过设置CR0的PE标志位可以让处理器工作在保护模式下,PG位可以开启分页保护机制。通过分段保护机制,处理器使用段寄存器中选择符(RPL和CPL)和段描述符中的各个字段来执行保护验证。对于分页机制,主要用页目录表和页表项中的R/W和U/S标志来实现保护操作。
本文最重要的部分属于第三部分——通过调用门,在不同特权级别的代码段之间进行转移。
1.段级保护
保护模式下,保护机制根据特权级(4个段保护和2个页保护)提供对段和页的基本访问闲置能力。例如,通过设置系统代码和数据段比普通应用程序具有较高的特权级,应用程序只能按照受控的方式来访问操作系统的代码和数据。保护机制下执行的检查可以分为如下类别:段界限检查、段类型检查、特权级检查、可寻址范围检查、过程入口点限制、指令集限制。
下面,我们逐个分析各类检查:
1.1段限长检查
段限长检查需要依赖三位:颗粒度G、扩展方向标志E和标志B(默认栈指针大小或者上界)。这里,我们可以先回顾一下段描述符中,这几个位的定义和段描述符的结构.
颗粒度G:G=0,limit的范围是0~0xFFFFF(注意从0开始;段限长的表示占用20b)。G=1,limit需要乘以4K,同时,段内偏移地址的低12b不会与limit进行对照。例如,G=1,Limit=0,偏移值从0到0xFFF仍然是合法的。
段的拓展方向标志位E:E=1的时候,段限长具有相同的功能,但是含义不同。对于上扩段,表示从0到limit是合法的;对于下扩段,需要结合默认栈指针大小标志B的设置,从limit到oxfffff(B标志=0)或者oxFFFFFFFF。
除了检查段限长,处理器也会检查段描述符表的长度。GDTR、IDTR、LDTR寄存器中包含有16b的限长值。选择符为0的时候,将产生一个一般保护性异常。
1.2段类型检查
应用程序有数据段和代码段,cpu还有系统段和门描述符。他们用来管理任务、异常和中断。并非所有的描述符都定义一个段,门描述符中存放着一个指向过程入口点的指针。S和Type字段表明了段的类型信息。
操作段选择符和段描述符时,处理器会随时检查类型信息。
(1)加载段选择符进入段寄存器的时候
CS寄存器只能存放可执行段的选择符
不可读可执行段的选择符不能被加载进入数据段寄存器(因为数据段都是可读的)
只有可写数据段选择符才能加载到SS寄存器(SS寄存器一定是可写的)
(2)指令访问一个段,段描述符已经被加载到段寄存器,指令只能用某些预定义的方法来访问某些段(这个和规则1是互相补充的)
不能写可执行段(代码段不可写)
不能写一个可写位没有设置的数据段
不能读可读位没有设置的可执行段
1.3特权级
有四种特权级:0,操作系统核心;1和2,操作系统服务;3应用层。
处理器可以识别以下三种类型的特权级别:
1)当前特权级:当前执行程序或者任务的特权级别,存放在CS和SS和b1和b0,通常,CPL等于当前代码段的特权级。一般情况下,访问不同特权级的代码段会改变CPL。但是对于一致性代码段,那些特权级高于或者等于一致性代码段DPL的任何段都可以访问(一致性代码具有可共享性,所以可以被低特权的代码访问),
此时CPL不会发生修改
2)描述符特权级DPL。DPL是一个段或者门的特权级,存在于段或者门描述符中。访问段或者门的时候,DPL会和CPL和RPL进行比较,根据被访问的段或者门的类型不同,DPL的定义也有所ubutong
@数据段:DPL指出运行访问本数据段的程序所具有的最大特权级数值(就是最小特权级)。
@非一致性代码段(不使用调用门):同数据段。
@调用门:同数据段。
@一致代码段和通过调用门访问的非一致代码段:由于这种段的设计目的,是为了被低特权级的段共享,它的规则和数据段正好相反,DPL表示指出运行访问本数据段的程序所具有的最小特权级数值(就是最大特权级)。
@任务状态段TSS:同数据段。
3)请求特权级RPL:是一种赋予段选择符的超越特权级,存放在段选择符的b0和b1.最终访问的时候,取max{CPL,RPL}来与DPL进行检查,RPL的相关介绍可以参考这篇博文。
当段描述符的段选择符被加载进入段寄存器的时候会进行特权级检查操作,但用于数据访问的检查方式和用于代码段之间进行程序控制转移的检查方式不一样。下面分类说明
2.访问数据段时候的特权级检查
数据段的检查方式在第一部分已经有所介绍,注意的是,加载段选择符的时候就要进行特权级见擦。我们需要注意以下情况:有时候需要将数据保存在代码段中,这时候,访问代码段中的数据需要遵循如下方法
1)将非一致可读代码段的选择符加载到一个数据段寄存器
2)将一致性可读代码段的选择符加载到一个数据段寄存器
3)使用代码段覆盖前缀(CS)来读取一个选择符已经在CS之中的可读代码段。
当使用堆栈段选择符加载SS寄存器也会执行特权级检查。这里,要求CPL=RPL=DPL。
3.代码段之间转移控制时候的特权级检查
程序成一个代码段转移到新的代码段,需要目标代码段的选择符被加载到CS之中,这个过程,将要执行各种限长、类型、特权级检查,通过控制转移指令JMP、RET、INT、IRET以及异常和中断来实现。我们在这里,主要谈谈前四种转移方法。jmp和call指令可以用以下四种方法来引用另外一个代码段。
@目标操作数含有目标代码段的段选择符
@目标操作数指向一个调用门描述符,而该描述符中含有目标代码段的选择符。
@~~~~~~~~~TSS,~~~~~~~~~~~~~~~~~~~~~
@~~~~~~~~~任务门~~~TSS~~~目标代码段选择符
下面,描述前两种引用类型:
3.1直接调用或者跳转到代码段
JMP、CALL、RET指令的近转移知识在当前代码段中执行程序控制转移,不执行特权级检查,远程跳转才会执行。当不通过调用门跳转时,处理器会验证四种特权级检查(CPL,RPL,DPL)和类型(C——一致性标志)信息,如下图:
注意,访问非一致性代码段的时候,段选择符被加载进CS时候,CPL不会发生变化(即使RPL不等于CPL)。访问一致性代码(C=1),要求CPL>=DPL,而且处理器忽略对于RPL的检查。当程序被转移到一致性代码中,CPL并不变(及时目标代码的DPL<转移之前的CPL),这也是CPL和当前代码段DPL不同的唯一情况,因为CPL不变,所以堆栈不会切换。
大多数代码段都是非一致性代码段,所以程序控制只能转移到相同特权的代码中,除非通过调用门。这也是和上一段的第一句话结合起来。
3.2门描述符
我们都知道,利用call和jmp进行的直接转移,无论是一致性代码还是非一致性代码,CPL是不会改变的。如果想自由地进行不同特权级的切换,需要用到其他几种方式——门描述符或者TSS。
为了对不同特权级的代码段,提供受控的访问,我们采用门描述符,有四种
@调用门:type=12
@陷阱门:type=15
@中断门:type=14
@任务门:type=5
这里,我们仅仅说明调用门。
下图给出了调用门描述符的格式,它可以存放在GDT、LDT表中,但是不能放在IDT表中。一个调用主要定义了目标代码段的段选择子、入口地址的偏移+一些属性,门功能如下:
@指定要访问的代码段
@在指定的代码段中定一个过程入口点
@指定访问过程的调用者必须具备的优先级
@如果发生堆栈切换,制定堆栈之间需要复制的可选参数个数
@指明调用门描述符是否有效。
注意,结合上面需求来看描述符格式。linux内核中没有使用调用门,不过它有助于我们理解中断和异常门。
3.3通过调用门来访问代码段
为了访问调用门,我们需要为CALL、JMP指令的操作数提供一个远指针。该指针中的段选择符用于指定调用门,偏移值虽然需要,但CPU不会使用它,所以可以任意指定(因为真正使用的偏移值是在调用门中的)。如下图
通过调用门进程程序的转移控制的时候,CPU会对CPL、调用门选择符中RPL、调用门描述符中的DPL、目的代码描述符中的DPL四种不同的特权级进行检查,如下图:
另外,目的代码的一致性标志C也将受到检查。
CALL、JMP指令拥有不同的优先级检测规则,见下表。
指令 | 特权级检查规则 |
CALL | CPL<=调用门的DPL;RPL<=调用门的DPL 对于一致性和非一致性代码段都只要求DPL<=CPL |
JMP | CPL<=调用门的DPL;RPL<=调用门的DPL 对于一致性代码段要求DPL<=CPL;对于非一致性代码段只要求DPL=CPL |
解释一个这个表的内容:检查分为两个阶段CPL和调用门相关特权级进行检查;CPL和目的代码段进行检查;只有call指令可以将代码通过调用门转移到特权级更高的非一致性代码之中。
对于非一致性代码的成功转移,CPL被目的代码的DPL刷新,会引起堆栈切换;对于一致性代码,不会刷新,也不会切换。
调用门的作用是,让一个代码段中的过程被不同特权级的程序访问。通常用于低特权级代码来访问高特权级的代码段。和jmp和call的直接调用相比,这里共享的只是一个段中的过程,而不是一个完整的段。
3.4堆栈切换
每当调用门用于把程序控制转移到一个更高级别的非一致性代码段时,CPU会自动切换到目的代码段特权级的堆栈去。每个任务只能定义最多4个栈,分别对应4个特权级。每个栈都位于不同的段中,并且使用段选择符和段中偏移值指定。
当特权级3的程序在执行时,特权级3的堆栈段选择符和栈顶指针被分别放在SS和ESP,发生堆栈切换的时候被保存在被调用过程的堆栈上。
特权级0~2的堆栈的初始指针都存放在当前运行任务的TSS段中。TSS段中,这些指针都是只读的,任务运行时候,CPU不会修改它们。当调用更高级别的程序时,CPU才会用他们来建立新堆栈。当从调用过程返回时候,相应堆栈就不存在了。
操作系统负责为所有用到的特权级建立堆栈和堆栈描述符,并且在任务的TSS中初始化指针。每个堆栈必须可读可写,并且有足够的空间,放置如下信息:
@调用过程的SS、ESP、CS、IP
@被调用过程的参数和临时变量所需要使用的空间
@当隐含调用一个异常或者中断过程时,EFLAGS和出错码使用的空间
由于一个过程可调用其他过程,因此每个栈需要足够空间容纳多套上述信息。
当通过调用门执行一个过程调用而改变特权级的时候,CPU会执行以下步骤切换堆栈,并开始在新特权级上执行被调用过程,如下图所示。
1)使用目的代码段的DPL从TSS中选择新栈的指针。从当前TSS读取新栈的段选择符和栈指针(为什么从TSS中读取?How)
2)检查段描述符特权级和类型是否有效
3)临时保存SS和ESP寄存器的当前值,将新栈段选择符和栈指针加载到SS和ESP,然后将临时保存的SS和ESP压入堆栈。
4)复制参数
5)把返回指令指针(当前的CS和EIP,此时是指令切换之前)压入新栈,将新代码段选择符加入CS,同时把调用门中的偏移值加载到EIP。最后开始执行被调用过程。
3.5从被调用过程中返回
RET可以执行近返回,相同/不同特权级之间的远程返回。
@近返回,仅仅检查界限
@同特权级远程返回,弹出EIP和CS,同时执行特权级检查,防止当前过程可能修改指针值EIP
@会发生特权级别改变的远程返回仅仅允许返回到低特权级别程序中(例如,通过retf从ring0进入ring3),即返回目的代码段的DPL>返回前CPL(注意,此处没有等于)。CPU会使用selector寄存器中的RPL字段确定是否要求返回到低特权级。如果RPL>CPL,执行特权级之间的返回操作。当执行远返回到一个调用过程,CPU将执行以下操作:
1)检查保存的CS寄存器的RPL字段(注意,是保存的CS的RPL,而不是当前的RPL),确定在返回的时候特权级别是否需要改变
2)弹出并使用保存的CS和EIP
3)如果RET指令包含一个参数个数操作数,并且返回会改变特权级,将把参数个数值加到(注意,是相加,不是加载)ESP寄存器中,以跳过(无用,所以丢弃)被调用者堆栈上的参数。此时,ESP寄存器指向原来保存的调用者堆栈的指针SS和ESP
4)将保存的SS和ESP,加载到对应寄存器,从而切换回堆栈。
5)检查寄存器DS~GS的内容,如果其中有指向DPL小于新CPL的段(一致性代码段除外),那么CPU就会用NULL选择符加载这个寄存器
4.页级保护
分页机制仅仅识别两级权限,特权级0~2是超级用户,3属于普通用户。普通用于的页面可以设置属性,超级用户的页面对超级用户总是可读/可写/可执行的,但普通用户不能访问。及时用户标注为可读/可执行的页面,超级用户也是可读/可写/可执行。
另外,分段与分页构成一种串行保护机制。
CPU并不维护TLB和页表的相关性,而是操作系统来维护。通过简单的重置CR3寄存器,可以刷新TLB。
特殊情况下,修改页表项不需要刷新TLB。当不存在的页表项被修改,不许要刷新TLB,因为无效的页表项不会被存入TLB。