32位保护模式下的分段(多任务的隔离和特权级保护)

在保护模式下,通过将内存分成大小不等的段,并用描述符对每个段的用途、类型和长度进行指定,就可以在程序运行时由处理器硬件施加访问保护。

  • 比如,当程序试图让处理器去写一个可执行的代码段时,处理器就会阻止这种企图;
  • 再比如,当程序试图让处理器访问超过段界限的内存区域时,处理器也会引发异常中断。

段保护是处理器提供的基本保护功能,但是只通过 GDT 是无法解决现实中所有的需求的,因此还需要引入其他的机制,比如:特权级保护。

如果对 GDT 有疑问的,可以看我之前的博客文章:32位保护模式下的分段(GDT相关)

(一) 任务的隔离

32 位处理器是为多任务系统而设计的。所谓多任务系统,是指能够同时执行两个以上程序的系统,即使前一个程序没有执行完,其他程序也可以开始执行。在单处理器(核)的系统中,多个程序并不可能真的同时执行,但是,处理器可以在多个任务之间周期性地切换和轮转。这样,它们都处于走走停停的状态,快速的处理器加上高效的任务切换,在外界看来,多个任务都在同时运行中。

多任务系统,对任务之间的隔离和保护,以及任务和操作系统之间的隔离和保护都提出了要求,这可以看做对段保护机制的进一步强化。同时,在多任务系统中,操作系统居于核心软件的位置,为各个任务服务,负责任务的加载、创建和执行环境的管理,并执行任务之间的调度,对操作系统的保护显得尤为重要。事实上,对于这种要求,基本的段保护机制已经无能为力了。

1. 任务的区分

程序是记录在载体上的指令和数据,总是为了完成某个特定的工作,其正在执行中的一个副本,叫做任务(Task)。

按处理器的要求标准,要使一个程序成为“任务”,并且能够参与任务切换和调度,那不是简简单单就能行的,每个任务必须要有 LDT 和 TSS (即每一个任务都有自己的 LDT 和 TSS)

  • LDT
    为了有效地在任务之间实施隔离,处理器建议每个任务都应当具有自己的描述符表,称为局部描述符表LDT(Local Descriptor Table),并且把专属于自己的那些段放到 LDT 中。
    和 GDT 一样,LDT 也是用来存放描述符的。不同之处在于,LDT 只属于某个任务。或者说,每个任务都有自己的 LDT,每个任务私有的段,都应当在 LDT 中进行描述。
  • TSS
    在一个多任务的环境中,当任务切换发生时,必须保护旧任务的运行状态,或者说是保护现场,保护的内容包括通用寄存器、段寄存器、栈指针寄存器 ESP、指令指针寄存器 EIP、状态寄存器 EFLAGS,等等。否则的话,等下次该任务又恢复执行时,一切都会变得茫然而毫无头绪。所以为了保存任务的状态,并在下次重新执行时恢复它们,每个任务都应当用一个额外的内存区域保存相关信息,这叫做任务状态段(Task State Segment:TSS)。

2. 多任务系统的组成

image

  • GDTR 寄存器(全局描述符表寄存器)
    追踪全局描述符表(GDT),访问它内部的描述符。
    全局描述符表(GDT)是全局性的,为所有任务服务,是它们所共有的,整个系统只需要一个全局描述符表(GDT)
  • LDTR 寄存器(局部描述符表寄存器)
    追踪和访问 LDT,LDTR 寄存器只有一个,它只用于指向当前任务的 LDT
    因为每一个任务都有一个 LDT,所以局部描述符表(LDT)的数量则不止一个,具体有多少,视任务的多少而定。
  • TR 寄存器(任务寄存器 Task Register)
    和 LDT 一样,处理器用 TR 寄存器来指向当前任务的 TSS
    和 GDTR、LDTR 一样,TR 寄存器在处理器中也只有一个。当任务切换发生的时候,TR 寄存器的内容也会跟着指向新任务的 TSS。

3. LDTR 和 TR 寄存器

image

4. 局部描述符表(LDT)的概述

LDT 的组成和 GDT 基本一样,请看上一篇博客:32位保护模式下的分段(GDT相关)

5. 任务状态段(TSS)的概述

任务状态段具有固定的格式,最小尺寸是 104 字节,如下图所示:(图中所标注的偏移量是十进制的

image

6. TSS 切换

在最初的操作系统中,真正完成进程切换是依靠任务状态段(Task State Segment,简称 TSS)的切换来完成的,比如 Linux 0.11。
注意:

  • 无论是 Linux 还是 Windows,进程/线程的切换都没有使用 Intel 提供的这种 TSS 切换手段,而都是通过堆栈实现的。
  • 因为 TSS 切换只用一条指令就能完成任务切换,但这指令的执行时间却很长,这条 ljmp 指令在实现任务切换时大概需要 200 多个时钟周期。而通过堆栈实现任务切换可能要更快,而且采用堆栈的切换还可以使用指令流水的并行优化技术,同时又使得 CPU 的设计变得简单。

在设计 “Intel 架构”(即 x86 系统结构)时,每个任务(进程或线程)都对应一个独立的 TSS,TSS 就是内存中的一个结构体,里面包含了几乎所有的 CPU 寄存器的映像。有一个任务寄存器(Task Register,简称 TR)指向当前进程对应的 TSS 结构体,所谓的 TSS 切换就将 CPU 中几乎所有的寄存器都复制到 TR 指向的那个 TSS 结构体中保存起来,同时找到一个目标 TSS,即要切换到的下一个进程对应的 TSS,将其中存放的寄存器映像“扣在” CPU 上,就完成了执行现场的切换,如下图所示。

基于 TSS 的进程切换

Intel 架构不仅提供了 TSS 来实现任务切换,而且只要一条指令就能完成这样的切换,即图中的 ljmp 指令。

具体的工作过程是:

  1. 首先用 TR 中存取的段选择符在 GDT 表中找到当前 TSS 的内存位置,由于 TSS 是一个段,所以需要用段表中的一个描述符来表示这个段,和在系统启动时论述的内核代码段是一样的,此处的 TSS 也是用 GDT 中的某个表项描述,而 TR 寄存器是用来表示这个段用 GDT 表中的哪一项来描述,所以 TR 和 CS、DS 等寄存器的功能是完全类似的。
  2. 找到了当前的 TSS 段(就是一段内存区域)以后,将 CPU 中的寄存器映像存放到这段内存区域中,即拍了一个快照。
  3. 存放了当前进程的执行现场以后,接下来要找到目标进程的现场,并将其扣在 CPU 上,找目标 TSS 段的方法也是一样的,因为找段都要从一个描述符表中找,描述 TSS 的描述符放在 GDT 表中,所以找目标 TSS 段也要靠 GDT 表,当然只要给出目标 TSS 段对应的描述符在 GDT 表中存放的位置——段选择子就可以了。
  4. 一旦将目标 TSS 中的全部寄存器映像扣在 CPU 上,就相当于切换到了目标进程的执行现场了,因为那里有目标进程停下时的 CS:EIP,所以此时就开始从目标进程停下时的那个 CS:EIP 处开始执行,现在目标进程就变成了当前进程,所以 TR 需要修改为目标 TSS 段在 GDT 表中的段描述符所在的位置,因为 TR 总是指向当前 TSS 段的段描述符所在的位置。

7. 任务控制块(TCB)

加载程序并创建一个任务,需要用到很多数据,比如程序的大小、加载的位置,等等。当任务执行结束,还要依据这些信息来回收它所占用的内存空间。

多任务系统是多个任务同时运行的,特别是在一个单处理器(核)的系统中,为了在任务之间切换和轮转,必须能追踪到所有正在运行的任务,记录它们的状态,或者根据它们的当前状态来采取适当的操作。

为了满足以上的要求,内核应当为每一个任务创建一个内存区域,来记录任务的信息和状态,称为任务控制块(Task Control Block,TCB)。任务控制块不是处理器的要求,是我们自己为了方便而发明的。

image

8. TCB 链

为了能够追踪到所有任务,应当把每个任务控制块 TCB 串起来,形成一个链表。

TCB 链的起始地址放在内核数据段中。

  • TCB 链的示意图如下:
    image

  • 向 TCB 链上追加 TCB 的流程图:
    image

9. 与任务相关的各部分逻辑关系示意图

image

(二) 特权级保护

1. 特权级保护

引入 LDT 和 TSS,只是从任务层面上进一步强化了分段机制,从安全保障的角度来看,只相当于构建了可靠的硬件设施。仅有设施是不够的,还需要规章制度,还要有人来执行,处理器也一样。为此,在分段机制的基础上,处理器引入了特权级,并由固件负责实施特权级保护。

特权级(Privilege Level),也叫特权级别,是存在于描述符及其选择子中的一个数值,当这些描述符或者选择子所指向的对象要进行某种操作,或者被别的对象访问时,该数值用于控制它们所能进行的操作,或者限制它们的可访问性。

image

Intel 处理器可以识别 4 个特权级别,分别是 0 到 3,较大的数值意味着较低的特权级别,反之亦然。如上图所示,这是 Intel 处理器所提供的 4 级环状保护结构。

  • 特权级 0
    因为操作系统是为所有程序服务的,可靠性最高,而且必须对软硬件有完全的控制权,所以它的主体部分必须拥有特权级 0,并处于整个环形结构的中心。也正是因为这样,操作系统的主体部分通常又被称做内核(Kernel、Core)。
  • 特权级 1
  • 特权级 2
    特权级 1 和 2 通常赋予那些可靠性不如内核的系统服务程序,比较典型的就是设备驱动程序。当然,在很多比较流行的操作系统中,驱动程序与内核的特权级别相同,都是0。
  • 特权级 3
    应用程序的可靠性被视为是最低的,而且通常不需要直接访问硬件和一些敏感的系统资源,调用设备驱动程序或者操作系统例程就能完成绝大多数工作,故赋予它们最低的特权级别 3。

应用程序编写时,不需要考虑 GDT、LDT、分段、描述符这些东西,它们是在程序加载时,由操作系统负责创建的,应用程序的编写者只负责具体的功能就可以了。应用程序的加载和开始执行,也是由操作系统所主导的,而操作系统一定会将它放在特权级 3 上。当应用程序开始执行时,当前特权级 CPL 自然就会是 3。

2. CPL、RPL 和 DPL

(1) CPL

在实模式下,段寄存器存放的是段地址;而在保护模式下,段寄存器存放的是段选择子,段地址则位于描述符高速缓存器中。当处理器正在一个代码段中取指令和执行指令时,那个代码段的特权级叫做当前特权级(Current Privilege Level,CPL)。正在执行的这个代码段,其选择子位于段寄存器 CS 中,其最低两位就是当前特权级的数值。

image

在保护模式下访问一个段时,传送到段选择器的是段选择子。
它由三部分组成:

  • 第一部分是描述符的索引号,用来在描述符表中选择一个段描述符。
  • 第二部分是 TI,TI 是描述符表指示器(Table Indicator),TI=0 时,表示描述符在 GDT 中;TI=1 时,描述符在 LDT 中。
  • 第三部分是 RPL,RPL 是请求特权级,表示给出当前选择子的那个程序的特权级别,正是该程序要求访问这个内存段。

(2) RPL

RPL 的意思是请求特权级(Requested Privilege Level)。我们知道,要将控制从一个代码段转移到另一个代码段,通常是使用 jmp 和 call 指令,并在指令中提供目标代码段的选择子,以及段内偏移量(入口点)。而为了访问内存中的数据,也必须先将段选择子加载到段寄存器 DS、ES、FS 或者 GS 中。不管是实施控制转移,还是访问数据段,这都可以看成是一个请求,请求者提供一个段选择子,请求访问指定的段。从这个意义上来说,RPL 也就是指请求者的特权级别(Requestor’s Privilege Level)。

引入请求特权级(RPL)的原因是处理器在遇到一条将选择子传送到段寄存器的指令时,无法区分真正的请求者是谁。但是,引入 RPL 本身并不能完全解决这个问题,这只是处理器和操作系统之间的一种协议,处理器负责检查请求特权级 RPL,判断它是否有权访问,但前提是提供了正确的 RPL;内核或者操作系统负责鉴别请求者的身份,并有义务保证 RPL 的值和它的请求者身份相符,因为这是处理器无能为力的。

因此,在引入 RPL 这件事上,处理器的潜台词是,仅依靠现有的 CPL 和 DPL,无法解决由请求者不同而带来的安全隐患。那么,好吧,再增加一道门卫,但前提是,操作系统只将通行证发放给正确的人。

(3) DPL

实施特权级保护的第一步,是为所有可管理的对象赋予一个特权级,以决定谁能访问它们。如下图显示,图中,每个描述符都有一个两比特的 DPL 字段,可以取值为 00、01、10 和 11,分别对应特权级 0、1、2 和 3。DPL 是每个描述符都有的字段,故又称描述符特权级(Descriptor Privilege Level)。描述符总是指向它所描述的目标对象,代表着该对象,因此,该字段实际上是目标对象的特权级。

image

(4) 基本的特权级检查规则

  1. 首先,将控制直接转移到非依从的代码段,要求当前特权级 CPL 和请求特权级 RPL 都等于目标代码段描述符的 DPL。
  2. 其次,要将控制直接转移到依从的代码段,要求当前特权级 CPL 和请求特权级 RPL 都低于,或者和目标代码段描述符的 DPL 相同。
  3. 第三,高特权级别的程序可以访问低特权级别的数据段,但低特权级别的程序不能访问高特权级别的数据段。访问数据段之前,肯定要对段寄存器DS、ES、FS 和 GS 进行修改在这个时候,要求当前特权级 CPL 和请求特权级 RPL 都必须高于,或者和目标数据段描述符的 DPL 相同。
  4. 最后,处理器要求,在任何时候,栈段的特权级别必须和当前特权级 CPL 相同。
    a. 任何时候,当前栈的特权级别必须和 CPL 是一样的。进入不同特权级别的段执行时,要切换栈。
    b. 通过调用门实施特权级之间的控制转移时,可以使用 jmp far 指令,也可以使用 call far 指令。如果是后者,会改变当前特权级 CPL。因为栈段的特权级必须同当前特权级保持一致,因此,还要切换栈,即,从低特权级的栈切换到高特权级的栈。比如,一个特权级为 3 的程序必须使用自己的 3 特权级栈工作。当它通过调用门进入 0 特权级的代码段执行时,当前特权级由 3 变为 0。此时,栈也要跟着切换,从 3 特权级的栈切换到 0 特权级的栈。这主要是为了防止因栈空间不足而产生不可预料的问题,同时也是为了防止栈数据的交叉引用。
    c. 为了切换栈,每个任务除了自己固有的栈之外,还必须额外定义几套栈,具体数量取决于任务的特权级别。0 特权级任务不需要额外的栈,它自己固有的栈就足够使用,因为除了调用返回外,不可能将控制转移到低特权级的段;1 特权级的任务需要额外定义一个描述符特权级 DPL 为 0 的栈,以便将控制转移到 0 特权级时使用;2 特权级的任务则需要额外定义两个栈,描述符特权级 DPL 分别是 0 和 1,在控制转移到 0 特权级和 1 特权级时使用;3 特权级的任务最多额外定义 3 个栈,描述符特权级分别是 0、1 和 2,在控制转移到 0、1 和 2 特权级时使用。
    d. 不要担心,这些额外的栈,也会由操作系统加载程序时自动创建,这些额外创建的栈,其描述符位于任务自己的 LDT 中。同时,还要在任务的 TSS 中登记。

(三) 加载用户程序

当用户程序被读入内存,并处于运行或者等待运行的状态时,就视为一个任务。任务有自己的代码段和数据段(包括栈),这些段必须通过描述符来引用,而这些描述符可以放在 GDT 中,也可以放在任务自己私有的 LDT 中,但最好是放在 LDT 中。GDT 用于存放各个任务公有的描述符,比如公共的数据段和公共例程。

每个任务都允许有自己的 LDT,而且可以定义在任何内存位置。整体事件的流程是:

  1. 分配一块内存,作为 TCB 来用,然后将其添加到 TCB 链上。
  2. 加载并重定位用户程序。
    1). 分配一块内存,作为 LDT 来用,为创建用户程序各个段的描述符做准备。
    2). 将 LDT 的大小和起始线性地址登记在任务控制块 TCB 中。
    3). 分配内存,并加载用户程序到刚分配的内存中,并将它的大小和起始线性地址登记到 TCB 中。
    4). 用户程序已被加载到内存中,接着就是在 LDT 中创建段描述符,如:程序头部段描述符、程序代码段描述符、程序数据段描述符、程序堆栈段描述符。
    5). 通过调用门的控制转移通常会改变当前特权级 CPL,同时还要切换到与目标代码段特权级相同的栈。为此,必须为每个任务定义额外的栈。对于用户程序为 3 特权级任务来说,应当创建特权级 0、1 和 2 的栈。而且,应当将它们定义在每个任务自己的 LDT 中。(即创建特权级 0、1 和 2 的栈,然后将它们登记到 TCB 和 LDT 中)
    6). 在 GDT 中登记 LDT 描述符。
    7). 登记 LDT 选择子到 TCB 中。
    8). 创建用户程序的 TSS,登记 TSS 界限值到 TCB,登记 TSS 基地址到 TCB。
    9). 登记基本的 TSS 表格内容,如:登记 0、1、2 特权级堆栈段选择子到 TSS 中;登记任务的 LDT 选择子到 TSS 中。
    10). 访问用户程序头部,获取数据填充 TSS,如:用户程序加载的基地址、程序入口点、程序代码段(CS)选择子、程序数据段(DS)选择子、程序堆栈段(SS)选择子。
    11). 在 GDT 中登记 TSS 描述符,登记 TSS 选择子到 TCB 中。

(四) 参考书籍

《x86汇编语言:从实模式到保护模式》

posted @ 2022-10-30 16:40  夏夜星空晚风  阅读(243)  评论(0编辑  收藏  举报