本文为<x86汇编语言:从实模式到保护模式> 第14章笔记
任务的隔离和特权级保护
任务, 任务的LDT和TSS
- 程序是记录在载体上的指令和数据, 总是为了完成某个特定的工作, 其正在执行中的一个副本, 叫做任务(Task). 这句话的意思是说, 如果一个程序有多个副本正在内存中运行, 那么, 它对应着多个任务, 每一个副本都是一个任务. 为了有效的在任务之间实施隔离, 处理器建议每个任务都应当具有自己的描述符表, 称为局部描述符表LDT, 并且把属于自己的那些段放到LDT中. 和GDT一样, LDT也是用来存放描述符的. 不同之处在于, LDT只属于某个任务. 或者说, 每个任务都有自己的LDT, 每个任务私有的段, 都应当在LDT中进行描述. 另外, LDT的第一个描述符(0号槽位)是有效的, 可以使用的.
- 和GDT不同, 局部描述符表(LDT)的数量则不止一个, 具体有多少, 视任务的多少而定. 为了追踪和访问这些LDT, 处理器使用了局部描述符表寄存器LDTR. 在多任务的系统中, 会有很多任务轮流执行, 正在执行的那个任务, 称为当前任务. 因为LDTR只有一个, 所以, 它只用来指向当前任务的LDT. 每当发生任务切换时, LDTR的内容被更新, 以指向新任务的LDT. 我们知道, 在引用一个段时, 需要给出段选择子, 选择子的位2(TI位)是表指示器, 若TI = 0, 表示从GDT中加载描述符, TI = 1则从当前任务的LDT中加载描述符. 很显然, 应为段选择子是16位的, 而且只有高13位被用做索引号来访问GDT或者LDT, 所以, 每个LDT所能容纳的描述符个数为2^13, 即8192个. 又因每个描述符8字节, LDT的长度最大为64KB.
- 为了保存任务的状态, 并在下次重新执行时恢复他们, 每个任务都应当用一个额外的内存区域保存相关信息, 这叫做任务状态段(Task State Segment: TSS).如图所示, 任务状态段TSS具有固定的格式, 最小尺寸是104字节, 图中所标注的偏移量是十进制的. 处理器能够识别TSS中的每个元素, 并在任务切换的时候读取其中的信息. 和LDT一样, 处理器用TR寄存器来指向当前任务的TSS. TR寄存器也只有一个, 当任务发生切换时, TR寄存器的内容也会跟着指向新任务的TSS. 过程是这样的: 首先, 处理器将当前任务的现场信息保存到由TR寄存器指向的TSS; 然后, 再使TR寄存器指向新任务的TSS, 并从新任务的TSS中恢复现场.
LDT描述符和TSS描述符
LDTR和TR寄存器
灰色部分为隐藏段描述符高速缓存, 见<进入保护模式>
LDT描述符
全局描述符GDT是唯一的, 整个系统只有一个, 所以只需要GDTR寄存器村放其线性地址和段界限即可; 但LDT不同, 每个任务一个, 所以, 为了追踪它们, 处理器要求在GDT中安装每个LDT的描述符. 当要使用这些LDT时, 可以用它们的选择子来访问GDT, 如果不这样, 处理器将没有机会来做存储器和特权级的保护工作.
如上图所示, 这是LDT描述符的格式. LDT也是一种特殊的段, 最大尺寸是64KB. 段基地址指示LDT在内存中的起始地址, 段界限指示LDT的范围; 描述符的G位是粒度位, 适用于LDT描述符, 以表示LDT的界限值是以字节为单位, 还是以4KB位单位, 即使是4KB位单位, 它也不能超过64KB的大小.
D位(或者叫B位)和L位对LDT描述符来说没有意义, 固定为0
AVL和P位的含义和存储器的段描述符相同.
LDT描述符中的S位固定为0, 表示系统的段描述符或者门描述符, 以相对于存储器的段描述符(S = 1), 因为LDT描述符属于系统的段描述符.
在描述符为系统的段描述符时, 即, S = 0的前提下, TYPE字段为0010(二进制)表明这是一个LDT描述符.
TSS描述符
和局部描述符表(LDT)一样, 也必须在GDT中安装TSS的描述符. 这样做, 一方面是为了对TSS进行段和特权级的检查; 另一方面, 也是执行任务切换的需要. 当call far和jmp far指令的操作数是TSS描述符选择子时, 处理器执行任务切换操作.
注意, 界限值必须至少是103, 任何小于该值的TSS, 在执行任务切换时, 都会引发处理器异常中断.
如上图所示, TSS描述符的格式和LDT差不多, 除了TYPE位.
TSS描述符中的B位是"忙"(Busy)位. 在任务刚刚创建的时候, 它应该为二进制的1001, 即, B位为0, 表明任务不忙. 当任务开始执行时, 或者处于挂起状态(临时被中断执行)时, 由处理器固件把B位置1.
任务是不可重入的. 就是说, 在多任务的环境中, 如果一个任务是当前任务, 他可以切换到其他任务, 但不能从自己切换到自己. 在TSS描述符中设置B位, 并由处理器固件进行管理, 可以防止这种情况的发生.
全局空间和局部空间
在多任务系统中, 操作系统肩负着任务的创建, 以及在任务之间调度和切换的工作. 不过, 更为繁重和基础的工作是处理器, 设备及存储器的管理.
比如说, 当中断发生时, 不可能由某个任务来进行处理, 而只能由操作系统来提供中断处理过程, 并采取适当的操作. 这就是说, 如上图所示, 每个任务实际上包括两部分: 全局部分和私有部分. 全局部分是所有任务共有的, 含有操作系统的软件和库程序, 以及可以调用的系统服务和数据; 私有部分则是每个人物各自的数据和代码, 与任务所要解决的具体问题有关, 彼此并不相同.
任务实际上是在内存中运行的, 所谓全局和私有部分, 其实是地址空间的划分, 这里简称全局空间和局部空间. 地址空间的访问是靠分段机制来进行的. 具体的说, 需要现在描述符表中定义各个段的描述符, 然后再通过描述符来访问它们. 因此, 全局地址空间是用全局描述符来指定的, 局部地址空间则是由每个任务私有的局部描述符表来定义的.
从程序员的角度来看, 任务的全局空间包含了操作系统的段, 是由别人开发的, 但是他可以调用这些段的代码, 或者获取这些段中的数据; 任务局部空间的内容是由程序员自己创建的. 通常, 任务会在自己的局部空间运行,当它需要操作系统提供的服务时, 转入全局空间执行.
特权级保护概述
- Intel处理器可以识别4种特权级别, 分别是0到3, 较大的数值意味着较低的特权级别, 反之亦然.
- 每个描述符都由一个2位的DPL字段, 可以取值0~3. DPL是每个描述符都有的字段, 故又称描述符特权级. 描述符总是指向它所描述的目标对象, 代表着该对象, 因此, 该字段实际上是目标对象的特权级. 比如, 对于数据段来说, DPL决定了访问它们所应当具备的最低特权级别. 如果一个数据段, 其描述符的DPL字段为2, 那么, 只有特权级位0, 1, 2的程序才能访问它. 当特权级3的程序试图去读写该段时, 将会被处理器阻止, 并引发异常中断.
- 保护模式下, 段寄存器存放的是选择子, 段地址则位于描述符高速缓存器中. 当处理器正在一个代码段中取指令时, 那个代码段的特权级叫做当前特权级(CPL). 正在执行的这个代码段, 其选择子位于段寄存器CS中, 其最低两位就是当前特权级.
- 只有在当前特权级CPL为0时才能执行的指令, 称为特权指令. 除了那些特权级敏感的程序, 处理器还允许对各个特权级别所能执行的I/O操作进行控制. 通常, 这指的是端口访问的许可权, 因为对设备的访问都是通过端口进行的. 在处理器的标志寄存器EFLAGS中, 位13, 位12是IOPL位, 也就是输入/输出特权级(IOPL), 它代表着当前任务的I/O特权级别. 处理器不限制0特权级程序的I/O访问, 总是允许的. 但是, 可以限制低特权级程序的I/O访问权限.
- 代码段的特权级检查是很严格的. 一般来说, 控制转移只允许发生在两个特权级相同的代码段之间. 如果当前特权级为2, 它可以转移到另一个DPL为2的代码段接着执行, 但不允许转移到DPL为0, 1和3的代码段执行. 不过, 为了让特权级低的应用程序可以调用特权级高的操作系统历程, 处理器也提供了相应的解决办法.
- 将高特权级的代码段定义为依从的. 代码段描述符的TYPE字段有C位, C = 0, 这样的代码段只能供同特权级的程序使用, 否则, 如果C = 1, 这样的代码段称为依从代码段, 可以从特权级比它低的程序调用并进入. 但是, 即使是将控制转移到依从代码段, 也是有条件的, 要求当前特权级CPL必须低于, 或者和目标代码段描述符的DPL相同. 即在数值上(注意这里是数值上, 数值越大, 特权级越低)
CPL >= 目标代码段描述符的DPL
举例来说, 如果一个依从的代码段, 其描述符的DPL为1, 则只有特权级别为1, 2, 3的程序可以调用, 而特权级位0的程序则不能. 在任何时候, 都不允许将控制从较高的特权级转移到较低的特权级. 依从的的代码段不是在它的DPL特权级上运行的, 而是在调用程序的特权级上运行的. 就是说, 当控制转移到依从的代码段上执行时, 不改变当前特权级CPL, 被调用过程的特权级依从与调用者的特权级, 这就是为什么它被称为"依从的"代码段.
- 除了依从代码段, 另一种在特权级之间转移控制的方法是使用门. 门(Gate)是另一种形式的描述符, 称为门描述符, 简称门. 和段描述符不同, 段描述符用于描述内存段, 门描述符则用于描述可执行的代码, 比如一段程序, 一个过程或者一个任务. 根据不同的用途, 门的类型有好几种. 不同特权级之间的过程调用可以用调用门; 中断门/陷阱门是作为中断处理过程使用的; 任务门对应着单个的任务, 用来执行任务切换. 所有描述符都是64位的, 门描述符也不例外. 在调用门描述符中, 定义了目标过程所在代码段的选择子, 以及段内偏移. 要想通过调用门进行控制转移, 可以使用jmp far后者call far指令, 把调用门描述符的选择子作为操作数. 使用jmp far指令, 可以将控制通过门转移到比当前特权级高的代码段, 但不改变当前特权级. 但是, 如果使用call far指令, 则当前特权级会提升到目标代码段的特权级别. 也就是说处理器是在目标代码段的特权级上执行的. 但是, 除了从高特权级别的历程(通常是操作系统历程)返回外, 不允许从特权级高的代码段将控制转移到特权级低的代码段.
- 将高特权级的代码段定义为依从的. 代码段描述符的TYPE字段有C位, C = 0, 这样的代码段只能供同特权级的程序使用, 否则, 如果C = 1, 这样的代码段称为依从代码段, 可以从特权级比它低的程序调用并进入. 但是, 即使是将控制转移到依从代码段, 也是有条件的, 要求当前特权级CPL必须低于, 或者和目标代码段描述符的DPL相同. 即在数值上(注意这里是数值上, 数值越大, 特权级越低)
- 选择子的RPL字段的意思是请求特权级. 绝大多数时候, 请求者都是当前程序自己, 因此, CPL == RPL. 但在一些并不多见的情况下, RPL和CPL并不相同, 如, 特权级为3的程序希望从硬盘读一个扇区, 并传送到自己数据段, 因此, 数据段描述符的DPL同样会是3. 由于I/O特权级的限制, 应用程序无法自己访问硬盘. 好在位于0特权级的操作系统提供了相应的历程, 但必须通过调用门才能使用, 因为特权级间的控制转移必须通过门. 假设, 通过调用门使用操作系统历程时, 必须传入3个参数, cx = 数据段选择子, ebx = 段内偏移, eax = 逻辑扇区号. 高特权级别的程序可以访问低特权级别的数据段, 这是没有问题的. 因此, 操作系统例程会用传入的数据段选择子代入段寄存器, 以便代替应用程序访问那个段: mov ds, cx 执行这条指令时, cx中的段选择子, 其RPL字段的值是3, 当前特权级CPL已经变成0, 因为通过调用门实施控制转移可以改变当前特权级. 显然, 请求者并非当前程序, 而是特权级为3的应用程序, RPL和CPL并不相同.
- 每当处理器执行一个将段选择子传送到段寄存器的指令时, 会检查以下两个条件是否都能满足:
- 当前特权级CPL高于或者和数据段描述符的DPL相同. 即, 在数值上, CPL <= 数据段描述符的DPL
- 请求特权级RPL高于或者和数据段描述符的DPL相同. 即, 在数值上, RPL <= 数据段描述符的DPL
如果以上条件不能同时成立, 处理器就会阻止这种操作, 并引发异常中断.
- 将控制直接转移到非依从的代码段, 要求当前特权级CPL和请求特权级RPL都等于目标代码段描述符的DPL. 即, 在数值上:
CPL == 目标代码段描述符的DPL RPL == 目标代码但描述符的DPL
- 将控制直接转移到依从的代码段, 要求当前特权级CPL和请求特权级RPL都低于, 或者和目标代码段描述符的DPL相同. 即, 在数值上:
CPL >= 目标代码段描述符的DPL RPL >= 目标代码段描述符的DPL
控制转移后, 当前特权级不变.
- 高特权级别的程序可以访问低特权级别的数据段, 但低特权级别的程序不能访问高特权级别的数据段. 访问数据段之前, 肯定要对段寄存器进行修改, 在这个时候, 要求当前特权级CPL和请求特权级RPL都必须高于, 或者和目标数据段描述符的DPL相同. 即, 在数值上:
CPL <= 目标数据段描述符的DPL RPL <= 目标数据段描述符的DPL
- 处理器要求, 在任何时候, 栈段的特权级别必须和当前特权级别CPL相同. 因此, 随着程序的执行, 要对段寄存器SS的内容进行修改时, 必须进行特权级检查. 在对段寄存器SS修改时, 要求当前特权级CPL和请求特权级RPL必须等于目标栈段描述符的DPL. 即, 在数值上:
CPL == 目标栈段描述符的DPL RPL == 目标栈段描述符的DPL
- 0特权级是最高级别, 当一个系统的各个部分位于0特权级别是, 各种特权级别检查总能够获得通过, 就像这种检查和检验并不存在一样.
小结
- 程序员在写程序时, 不需要指定特权级别. 当程序运行时, 操作系统将程序创建为局部空间内容, 并赋予较低的特权级别, 比如3, 操作系统对应任务全局空间的内容. 如果有多个任务, 则操作系统属于所有任务的公共部分.
- 当任务运行在局部空间时, 可以在各个段之间转换控制, 并访问私有数据, 因为它们具有相同的特权级别, 但不允许直接将控制转移到高特权级别的全局空间的段, 除非通过调用门, 或者目标段依从的代码段.
- 当通过调用门进入全局空间执行时, 操作系统可以在全局空间内的各个段之间转移控制并访问数据, 因为它们也具有相同的特权级别. 同时, 操作系统还可以访问任务局部空间的数据, 即低特权级别的数据段. 但除了调用门返回外, 不允许将控制转移到低特权级别的局部空间内的代码段.
- 任何时候, 当前栈的特权级别必须和CPL是一样的. 进入不同特权级别的段执行时, 要切换栈.
调用门
- 调用门(Call-Gate)用于在不同特权级的程序之间进行控制转移. 本质上, 它只是一个描述符, 一个不同于代码段和数据段的描述符, 可以安装在GDT或者LDT中, 该描述符的格式如下图所示, 下面是低32位, 上面是高32位.
如上图所示, 调用门描述符给出了例程所在代码段的选择子, 而不是32位线性地址. 有了段选择子, 就能访问描述符表得到代码段的基地址, 这样做无非是间接了一点, 但却可以在通过调用门进行控制转移时, 实施代码段描述符有效性, 段界限和特权级的检查. 例程在代码段中的偏移量也是在描述符中直接指定的, 只是分成了2个16位的部分, 很显然, 在通过调用门调用例程时, 不使用指令中给出的偏移量.
- 描述符的TYPE字段用于标识门的类型, 共4位, 值"1100"标识调用门.
- P位是有效位, 通常应该是1, 当它为0时, 调用这样的门会导致处理器产生异常中断. 对操作系统来说, 这个机关可能会很有用. 比如, 为了统计调用门的使用频率, 可以将它置0, 然后, 每当因调用该门而产生异常中断时, 在中断处理程序中将该门的调用次数加一, 同时把P位置1, 对于因P位为0而引起的中断来说, 它们属于故障中断, 从中断处理过程返回时, 处理器还会重新执行引起故障的指令. 此时P位已经为1, 所以可以执行. 就当前例子而言, 因为在提供调用门服务的同时, 还要统计门的调用次数, 故, 可以在该调用门所对应的例程中将P为清零. 这样, 下一次该门被调用时, 又会重复以上步骤.
- 通过调用门实施特权级转移时, 可以用jmp far, 也可以用call far指令. 如果是后者, 会改变当前特权级CPL. 因为栈段的特权级必须同当前特权级保持一致, 因此, 还要切换栈, 即, 从低特权级的栈切换到高特权级的栈. 为了切换栈, 每个任务除了自己固有的栈之外, 还必须额外定义几个栈, 具体数量取决于任务的特权级别. 0特权级任务不需要额外的栈, 它自己固有栈就足够使用, 因外除了调用返回外, 不可能将控制转移到低特权级的段; 1特权级的任务需要额外定义一个描述符特权级DPL为0的栈, 以便将控制转移到0特权级时使用; 2特权的任务需要额外定义描述符特权级DPL为0和1的栈, 在控制转移到0和1特权级时使用; 3特权级类似. 这些额外的栈, 其描述符位于任务自己的LDT中. 同时, 还要在任务的TSS中登记, 原因是, 栈切换是处理器固件自动完成的, 处理器需要根据TSS中的信息来完成这一过程. TSS内偏移4~24处登记有特权级0到2的栈段选择子以及相应的ESP初始值. 任务自己固有的栈信息则位于偏移量为56(ESP)和80(SS)的地方. 任务寄存器TR总是指向当前任务的任务状态段TSS, 其内容为该TSS基地址和界限. 在切换栈时, 处理器可以用TR找到当前任务的TSS, 并从TSS中获取新栈的信息. 通过调用门使用高特权级的例程服务时, 调用者有时会通过栈来传递参数, 当栈切换时, 参数还在旧栈中, 为了使例程能获得参数, 必须将参数从旧栈复制到新栈中. 复制参数的工作是由吹起固件完成的. 但它必须知道参数的个数, 所以, 调用门描述符中还有一个参数个数(Param Count)字段.
- 调用门描述符中的DPL和目标代码段描述符的DPL用于决定哪些特权级的程序可以访问此门. 具体规则必须同时符合以下两个条件才行:
- 当前特权级CPL和请求特权级RPL高于, 或者和调用门描述符特权级DPL相同. 即, 在数值上:
CPL <= 调用门描述符的DPL RPL <= 调用门描述符的DPL
- 当前特权级CPL低于, 或者和目标代码段描述符特权级DPL相同. 即, 在数值上:
CPL >= 目标代码段描述符的DPL
- 当前特权级CPL和请求特权级RPL高于, 或者和调用门描述符特权级DPL相同. 即, 在数值上:
举个例子, 如果调用门描述符的DPL 为2, 那么, 只有特权级为0, 1和2的程序才允许使用该调用门, 特权级3的程序使用此门时将引发异常中断. 除此之外, 目标代码段的特权级也是需要考虑的因素. 调用门描述符中有目标代码段的选择子, 它指向目标代码段的描述符. 当一个程序通过调用门转移时, 处理器还要检查目标代码段描述符的DPL. 也就是说, 只有那些特权级低于或者等于目标代码段DPL的程序才允许使用此门.
- 借助call far调用门, 当程序从的执行流从低特权级转入高特权级的代码段时, 如果那是个非依从的代码段, 当前特权级也随之变为目标代码段的特权级. 不过, 如果调用者和被调用者的特权级相同, 则特权级不会变化. 所以, 在控制转移的过程中也不会发生栈切换, 仅仅是把返回地址CS和EIP压入当前栈. 当执行retf指令后, 处理器从栈中恢复CS和EIP的原始内容, 于是又返回到原先的代码段接着执行. 事实上, 能够通过调用门发起控制转移的指令还包括jmp, 但只用与不需要从调用门返回的场合下, 而且不改变当前特权级. 也就是说, 目标代码是在当前特权级上执行.
任务
任务状态段TSS的格式
TSS内偏移0处是前一个任务的TSS描述符选择子. 和LDT一样, 必须在全局描述符表GDT中创建每个TSS的描述符. 当系统中有多个任务同时存在时, 可以从一个任务切换到另一个任务执行, 此时称任务是嵌套的. 被嵌套的任务用这个指针指向前一个任务, 即嵌套它的那个任务, 当控制返回前一个任务时, 处理器需要这个指针来识别前一个任务. 创建TSS时, 可以为0.
SS0, SS1和SS2分别是0,1和2特权级的栈段选择子, ESP0, ESP1和ESP2分别是0, 1和2特权级的栈顶指针. 这些内容应当由任务的创建者填写, 且属于填写后一般不变的静态部分, 当通过门进行特权级之间的控制转移时, 处理器用这些信息来切换栈.
CR3和分页有关, 此处一般由任务的创建者填写, 如果没有使用分页, 可以为0.
偏移为32~92的区域是处理器寄存器的快照部分,用于在进行任务切换时, 保存处理器的状态以便将来恢复现场. 在一个多任务环境中, 每次创建一个任务时, 操作系统或者内核至少要填写EIP, EFLAGS, ESP, CS, SS, DS, ES, FS和GS, 当该任务第一次获得执行时, 处理器从这里加载初始化执行环境, 并从CS:EIP处开始执行任务的第一条指令. 在此之后的任务运行期间, 该区域的内容由处理器固件进行更改.
LDT段选择子是当前任务的LDT描述符选择子. 由内核或者操作系统填写, 以指向当前任务的LDT. 该信息由处理器在任务切换时使用, 在任务运行期间保持不变.
T位用于软件调试. 在多任务的环境中, 如果T位是1, 每次切换到该任务时, 将引发一个调试异常中断.
I/O映射基地址用于决定当前任务是否可以访问特定的硬件端口.
每个任务都有EFLAGS寄存器的副本, 其内容在任务创建的时候由内核或者操作系统初始化, 在多任务系统中, 每次当任务恢复运行时, 就由处理器固件自动从TSS中恢复. EFLAGS寄存器的IOPL位决定了当前任务的I/O特权级别. 如果当前特权级CPL高于, 或者和任务的I/O特权级IOPL相同时, 即, 在数值上:
CPL <= IOPL
时, 所有I/O操作都是允许的, 针对任何硬件端口的访问都可以通过. 相反, 如果当前特权级CPL低于任务的I/O特权级IOPL, 也并不意味着所有发的硬件端口都对当前任务关上了大门. 事实上, 处理器的意思是总体上不允许, 但个别端口除外. 至于个别端口是哪些端口, 要找到当前任务的TSS, 并检索I/O许可位串.
I/O许可位串是一个比特序列, 或者说是一个比特串, 最多允许65536比特(处理器可以访问65536个硬件端口), 即8KB. 从第1比特开始, 各比特用它在串中的位置代表一个端口号, 因此, 第一个比特代表0号端口, 第2个比特代表1号端口.......第65536比特代表第65535号端口. 每个比特的取值决定了相应的端口是否允许访问. 为1时, 禁止访问; 为0时, 允许访问. 处理器检查I/O许可位的方法是先计算它在I/O许可位映射区的字节编号, 并读取该字节, 然后进行测试. 比如, 当执行指令 out 0x09, al 时, 处理器通过计算就可以知道, 该端口对应着I/O许可映射位第2个字节的第2个比特(位1). 于是, 它读取该字节, 并测试那一位.
同其他和任务相关的信息一样, I/O许可位串位于任务的TSS中. 如上图所示 任务状态段TSS的最小长度是104字节, 保存着最基本的任务信息, 但这并不是他的最大长度. 事实上, TSS还可以包括一个I/O许可位串, 它所占用的区域称为I/O许可位映射区. 如上图所示, 在TSS内偏移为0x66的那个字单元, 保存着I/O许可位串的起始地址, 从TSS的起始处(0)算起. 因此, 如果该字单元的内容大于或者等于TSS的段界限(在TSS描述符中), 则表明没有I/O许可位串. 在这种情况下, 如果当前特权级CPL低于当前I/O特权级IOPL, 执行任何硬件I/O指令都会引发处理器异常中断. 说明一下, 和LDT一样, 必须在GDT中创建TSS描述符, TSS描述符中包括了TSS的基地址和界限, 该界限值包括I/O许可位映射区在内.
非常重要的一点是, I/O端口是按字节编址的. 这句话的意思是, 每个端口仅被设计用来读写一个字节的数据, 当以子或者双字访问时, 实际上是访问连续的2个或者4个端口. 由于这个原因, 当处理器执行一个子或者双字I/O指令时, 会检查许可位串中的2个, 或者4个连续位, 而且要求它们必须都是0, 否则引发异常中断. 麻烦在于, 这些连续的位可能是跨字节的. 即, 一些位于前一字节, 另一些位于后一字节, 为此, 处理器每次都要从I/O许可位映射却读两个连续的字节. 这种操作方式直接导致了另一个问题. 即, 如果要检查的比特在最后一字节中, 那么这个两字节的读操作将会越界. 为防止这种情况, 处理器要求I/O许可位映射区的最后必须附加一个额外的字节, 并要求它的所有比特都是1, 即0xff, 当然, 它必须位于TSS的界限之内. 处理器不要求为每一个I/O端口提供位映射, 对于那些没有在该区域内映射的位, 处理器假定它对应的比特是1.
对多数标志寄存器的标志位的修改不会威胁到整个系统的安全, 但是, 如果修改了IOPL和IF位, 就不同了, 能够修改这两个标志的指令是
popf iret cli sti
中断是由操作系统或者内核统一管理的, cli和sti指令不能由低特权级的程序随便执行. 遗憾的是, 这些指令并不是特权指令, 原因很简单, 其他特权级的程序也离不开它们. 最后的办法是用IOPL本身来控制它们. 如果当前特权级CPL高于, 或者和当前I/O特权级IOPL相同, 即, 在数值上
CPL <= IOPL
则允许执行以上4条指令, 也允许访问所有的硬件端口. 否则, 如果当前特权级CPL低于当前的I/O特权级IOPL, 则执行popf和iret指令时, 会引发处理器异常中断; 执行cli和sti时, 不会引发异常中断, 但不改变标志寄存器的IF位. 同时, 是否能够访问特定的I/O端口, 要参考TSS中的I/O许可位映射串.
通过调用门转移控制的完整过程
一个从0特权级到3特权级的控制转移, 通常情况下, 这即不允许, 也不太可能. 办法总是有的, 只不过稍微有一点曲折, 那就是假装从调用门返回. 先来看看完整的调用门控制转移和返回过程是怎样的. 首先, 通过调用门实施控制转移, 可以使用jmp far和call far指令. 指令执行时, 描述符选择子必须指向调用门, 32位偏移量被忽略. 但无论采用哪种控制转移指令, 都会使用下图的特权检查规则. 注意, 图中的比较关系都是数值上的.
从上图可以看出, 当使用JMP FAR指令通过调用门转移控制时, 要求当前特权级和目标代码段的特权级相同, 原因是用jmp far指令通过调用门转移控制时, 不改变当前特权级CPL. 相反, 使用call far指令可以通过调用门将控制转移到较高特权级别的代码段. 之所以说"可以", 是因为, 如果目标代码段是依从的, 则和jmp far指令一样, 不改变当前特权级别; 否则, 如果目标代码段是非依从的, 则在目标代码段的特权级上执行. 其次, 当使用call far指令通过调用门转移控制时, 如果改变了当前的特权级别, 则必须切换栈. 即, 从当前任务的固有栈切换到与目标代码段特权级别相同的栈上, 栈的切换是由处理器估计自动进行的.
当前栈是由段寄存器SS和栈指针寄存器ESP的当前内容指示的; 要切换的新栈位于当前任务的TSS中, 处理器知道如何找到它. 在栈切换前, 处理器要检查新栈是否有足够的空间完成本次控制转移. 栈切换过程如下:
- 使用目标代码段的DPL(也就是新的CPL)到当前任务的TSS中选择一个栈, 包括栈段选择子和栈指针.
- 从TSS中读取所选择的段选择子和栈指针, 并用该选择子读取栈段描述符. 在此期间, 任何违反段界限检查的行为都将引发处理器异常中断(无效TSS).
- 检查栈段描述符的特权级和类型, 并可能引发处理器异常中断(无效TSS).
- 临时保存当前栈段寄存器SS和栈指针ESP的内容.
- 把新的栈段选择子和栈指针带入SS和ESP寄存器, 切换到新栈.
- 将刚才临时保存的SS和ESP的内容压入当前栈, 如下图所示
- 根据调用门描述符"参数个数"字段的指示, 从旧栈中将所有参数都复制到新栈中. 入股哦参数个数为0, 不复制参数, 如下图所示
- 将当前段寄存器CS和指令指针寄存器EIP的内容压入新栈, 如下图所示. 通过过调用门实施的控制转移一定是远转移, 所以要压入CS和EIP.
- 从调用门描述符中依次将目标代码段选择子和段内偏移传送到CS和EIP寄存器, 开始执行被调用过程.
相反, 如果没有改变特权级别, 则不切换栈, 继续使用调用者的当前栈, 只在原来的基础上压入当前段寄存器CS和指令指针寄存器EIP的内容, 如下图所示
再次, 如果通过调用门的控制转移时jmp far指令发起的, 结果就是肉包子打狗, 有去无回. 而且, 没有特权级的变化, 也不需要切换栈. 相反, 如果是通过call far, 则可以通过远返回指令retf把控制返回到调用者.
从同一特权级返回时, 处理器从栈中弹出调用者的代码段选择子和指令指针. 尽管它们通常是有效的, 但是, 为了安全起见, 处理器依然会进行特权检查.
要求特权级变化的远返回, 只能返回到较低的特权级别上. 控制返回的全部过程如下:
- 检查栈中保存的CS寄存器的内容, 根据其RPL字段决定返回时是否需要改变特权级别.
- 从当前栈中读取CS和EIP寄存器的内容, 并针对代码段描述符和代码段选择子的RPL字段实施特权级检查.
- 如果远返回指令是带参数的, 则将参数和ESP寄存器的当前值相加, 以跳过栈中的参数部分. 最后的结果是ESP寄存器指向调用者SS和ESP的压栈值. 注意, retf指令的字节计数值必须等于调用门中的参数乘以参数长度.
- 如果返回时需要改变特权级, 从栈中将SS和ESP的压栈值代入段寄存器SS和指令指针寄存器ESP, 切换到调用者的栈. 在此期间, 一旦检测到有任何界限违例的情况将引发处理器异常中断.
- 如果远返回指令是带参数的, 则将参数和ESP寄存器的当前值相加, 以跳过调用者栈中的参数部分. 最后的结果是调用者的栈恢复到平衡位置.
- 如果返回时需要改变特权级, 检查DS, ES, FS和GS寄存器的内容, 根据它们找到相应的段描述符. 要是有任何一个段描述符的DPL高于调用者的特权级(返回后的新CPL), 即, 在数值上:
段描述符的DPL < 返回后的新CPL
那么, 处理器将把数值0传送到该段寄存器. 那么, 这是为什么呢? 现在做一个假设, 假设一个3特权级的应用程序通过调用门请求0特权级的操作系统服务. 在进入操作系统例程后, 当前特权级CPL变成0. 在该例程内, 操作系统可能会访问自己的0特权级数据段以进行某些内部操作. 当然, 它也必须先执行将选择子代入段寄存器的操作,
mov ds, ax
按道理, 安全的做法是先将旧的DS值压栈, 用完后再出栈. 像这样
push ds mov ds, ax ...... pop ds retf
但是, 如果操作系统例程没有这么做, 一定有他的道理, 而处理器也无权干涉. 唯一可以预料的是, 当控制返回到应用程序时, 段寄存器DS依然指向操作系统数据段. 因此, 应用程序就可以直接在3特权级访问操作系统数据段, 这是因为, 特权级检查只在引用一个段的时候进行. 即, 只在将选择子传送到段寄存器的时候进行. 只要通过了这一关, 后面那些使用段寄存器的内存访问就都是合法的. 为了解决这个问题, 在执行retf指令时, 要检查数据段寄存器, 根据它们找到相应的段描述符. 要是有任何一个段描述符的DPL高于调用者的特权级(返回后的新CPL), 那么, 处理器将把数值0传送到寄存器. 使用这样的段寄存器访问内存, 会引发处理器异常中断.
特别需要注意的是, 任务状态段(TSS)中的SS0, ESP0, SS1, ESP1, SS2, ESP2域是静态的, 除非软件进行修改, 否则处理器从来不会改变它们. 举个例子, 当处理器通过调用门进入0特权级的代码段时, 会切换到0特权级的栈. 返回时, 并不把0特权级栈指针的内容更新到TSS中的ESP0域. 下次再次通过调用门进入0特权级代码段时, 使用的依然是ESP0的静态值, 从来不会改变. 这就是说, 如果你希望通过0特权级栈返回数据, 就必须自己来做这件事, 比如, 在返回到低特权级别的代码段之前, 手工改写TSS中的ESP0域.
高特权级转移到低特权级
前面我们说过, 可以假装从调用们返回来从高特权级转移到低特权级, 我们依次将低特权级的栈段选择子, 栈指针, 代码段选择子和入口偏移压入高特权级的栈中, 然后执行一个远返回指令retf, 假装从调用门返回. 于是控制转移到低特权级代码开始执行.
检查调用者的请求特权级RPL
最后, 我们回过头来聊一聊与请求特权级RPL有关的问题.
一般来说, 用户程序的特权级别很低, 而且不能执行I/O操作. 假设操作系统提供了一个例程, 可以从用户程序那里接受三个参数: 逻辑扇区号, 数据段选择子和段内偏移量, 然后读硬盘, 并把数据传送到用户程序的缓冲区内. 为了使用户程序可以调用此例程, 操作系统把它定义成调用门.
一般来说, 用户程序会提供一个RPL为3的段选择子给操作系统例程. 通过调用门实施控制转移后, 当前特权级CPL变成0, 实际的请求者是用户程序, 选择子的请求特权级RPL为3, 要访问的段属于用户程序, 其描述符的DPL为3, 在数值上符合CPL <= DPL, 并且RPL <= DPL的条件, 可以正常执行.
人类的可恶之处无孔不入, 总爱钻空子. 想象一下, 用户程序的编写者通过钻研, 知道了内核数据段的选择子, 而且希望用这个选择子访问内核数据段. 当然, 他不可能在用户程序里访问内核数据段, 因为那个数据段的DPL为0, 而用户程序工作在特权级为3, 处理器会很机警地把来访者拒之门外.
但是, 他可以借助调用门. 特别是, 他提供的是一个RPL为0的选择子, 而且该选择子指向操作系统的段描述符. 此时, 当前特权级CPL为0, 请求特权级RPL为0, 目标数据段描述符的DPL为0, 同样符合在数值上符合CPL <= RPL, 并且RPL <= DPL的条件, 并且允许内核数据段写入扇区, 他得逞了!
这时有人会说, 通过调用门进入内核例程时, 用户程序的代码段选择子就作为返回地址压在栈中, 代码段选择子的低2位就是用户程序的特权级. 因此, 可以改造处理器固件, 使它能够访问栈, 用这个特权级来进行特权级检查.
但是, 处理器的智商很低, 它不可能知道谁是真正的请求者. 你当然可以通过分析程序的行为来区分它们, 但处理器不能. 怎么办?
RPL只是在原来的基础上多增加了一种检查机制, 并把如何能够通过这种检查的自由裁量权交给软件(的编写者);
引入请求特权级的RPL的原因是处理器在遇到一条将选择子传送到段寄存器的指令时, 无法区分真正的请求者是谁. 但是, 引入RPL本身并不能完全解决这个问题, 这只是处理器和操作系统之间的一种协议, 处理器负责检查请求特权级RPL, 判断他是否有权访问, 但前提是提供了正确的RPL; 内核或者操作系统负责鉴别请求者的身份, 并有义务保证RPL的值和它的请求者身份相符, 因为这是处理器无能为力的.
因此, 在引入RPL这件事上, 处理器的潜台词是, 仅依靠现有的CPL和DPL, 无法解决由请求者不同而带来的安全隐患. 那么, 好吧, 再增加一道门卫, 但前提是, 操作系统只将通行证发给正确的人.
为了帮助内核或者操作系统核查请求者身份, 并提供正确的RPL值, 处理器提供了arpl指令, arpl指令的作用是调整段选择子RPL字段的值, 其格式如下
arpl r/m16, r16
该指令执行时, 处理器检查目的操作数的RPL字段, 如果它在数值上小于源操作数的RPL字段, 则设置ZF标志, 并增加目的操作数RPL字段的值, 使之和源操作数RPL字段的值相同. 否则, ZF标志清0, 而且除此之外什么也不会发生.
arpl是典型的操作系统指令, 它通常用于调整应用程序传递给操作系统的段选择子, 使其RPL字段的值和应用程序的特权级相匹配. 在这种情况下, 传递给操作系统的段选择子是作为目的操作数出现的; 而应用程序的段选择子是作为源操作数出现的(可以从栈中取得). arpl也可以在应用程序中使用.
一旦调整了请求特权级, 那么, 当前特权级CPL为0, 请求特权级RPL为3, 数据段描述符特权级DPL为0, 数值上并不符合CPL <= DPL, 并且RPL <= DPL的条件, 禁止访问, 并引发处理器异常中断.