XV6

在一个多任务环境中,当任务发生切换时,必须保存现场(比如通用寄存器,段寄存器,栈指针等),根据这些保存的被切换任务的状态,可以在下次执行时恢复现场。每个任务都应当有一片内存区域,专门用于保存现场信息, X86 提供了响应任务状态段 TSS(Task State Segment)用于保存现场。

X86 所希望的任务,和 Linux 或 XV6 操作系统所定义的任务并不一致, Linux / XV6 在任务(进 程)切换时,并不切换 TSS,但是利用了 TSS 提供的堆栈切换的信息和处理器寄存器现场的信息。虽然 Intel 设计的初衷是用它来做任务切换,然而,在现代操作系统中(无论是 Windows 还是 Linux),都没有使用这种方式来执行任务切换,比如线程切换和进程切换。主要原因是这种切换速度非常慢,一条指令要消耗 200 多个时钟周期。Linux 中只用到一个 TSS,所以我们必须提前创建一个 TSS,并且至少初始化 TSS 中的 ss0esp0。因此我们使用 TSS 的唯 一理由就是为 0 特权级的任务提供栈。当用户模式下发生中断时候,CPU 会自动的从 TSS 中 取出 0 级栈,然后一系列 push 指令。

在学习调用门,中断门和陷阱门已经知道,代码发生提权的时候,是需要切换栈的。之前遗留的一个问题是,栈段描述符和栈顶指针从哪里来?那时只是简单的讲了一下是从 TSS 中 来的。 提权的时候,CPU 就从这个 TSS 里把 SS0ESP0 取出来,放到 ssesp 寄存器中。

下面来学习 TSS 的相关细节知识。

1. TSS 内容

TSS(Task State Segment,任务状态段),是一块位于内存中的结构体而已。需要注意,不要把它和任务切换动作直接关联起来,虽然任务切换可能依赖于 TSS 信息。

在创建一个任务的时候,我们要为这个任务创建 TSS 并填写其中的字段。

  1. 前一任务链接(TSS Back Link):TSS 内偏移 0 处是前一个任务的 TSS 描述符的选择子 (Previous Task Link)。

    当 Call 指令、中断或者异常造成任务切换,处理器会把旧任务的 TSS 选择子复制到新任务的 TSS 的 Back Link 字段中,并且设置新任务的 NT(EFLAGS 的 bit 14)为 1,以表明新任务嵌套于旧任务中。 XV6 只用一个 TSS。

  2. SS0,SS1,SS2 和 ESP0,ESP1,ESP2 分别是 0/1/2 特权级堆栈的选择子和栈顶指针。 这些内容应当由任务的创建者填写,且属于填写后一般不变的静态字段。

  3. CR3 和分页有关,如果没有启用分页,可以填写 0。

  4. 偏移为 0x20~0x5C 的区域是处理器各个寄存器的快照部分,用于任务切换时保存现场。 在一个多任务环境中,每次创建一个任务,内核至少要填写 EIP,EFLAGS,ESP,CS,DS,SS,ES,FS 和 GS。当任务首次执行时,处理器从这些寄存器中加载初始执行环境,从 CS:EIP 处开始执行任务的第一条指令。

  5. LDT 选择子是当前任务的 LDT 选择子(由内核填写),以指向当前任务的 LDT。该信息由处理器在任务切换时使用,在任务运行期间保持不变。

  6. T(Debug Trap)位用于软件调试。在多任务环境中,如果 T=1,则每次切换到该任务的时候,会引发一个调试异常中断(INT 1)。

  7. I/O 位图基地址用来决定当前的任务是否可以访问特定的硬件端口。

在 X86 设计理念中,TSS 在任务切换过程中起着重要作用,通过它实现任务的挂起和恢复。Intel 为 X86-32 设计的任务切换过程为:挂起当前正在执行的任务,恢复或启动另一任务的执行。在任务切换过程中,首先,处理器中各寄存器的当前值被自动保存到 TR(任务寄存器) 所指定的 TSS 中;然后,下一任务的 TSS 的选择子被装入 TR;最后,从 TR 所指定的 TSS 中取出各寄存器的值送到处理器的各寄存器中。由此可见,通过在 TSS 中保存任务现场各寄存器状态的完整映象,实现任务的切换。Linux / XV6 的进程 / 线程切换并不遵循上述方案,而使用了一 个更快速的方法。

**I/O 许可位图(I/O Permission Bit Map) **

读者可以先跳过 I/O 许可位图小节,在涉及到 I/O 访问时再回来查看本节内容。

EFLAGS 寄存器的 IOPL 位决定了当前任务的 I/O 特权级别。如果在数值上 CPL<=IOPL,那么所有的 I/O 操作都是允许的,针对任何硬件端口的访问都可以通过;如果在数值上 CPL>IOPL, 也并不是说就不能访问硬件端口。事实上,处理器的意思是总体上不允许,但个别端口除外。 至于个别端口是哪些端口,要找到当前任务的 TSS,并检索 I/O 许可位图。

I/O 许可位图(I/O Permission Bit Map)是一个比特序列,因为处理器最多可以访问 65536 个端口,所以这个比特序列最多允许 65536 比特(即 8 KB)。TSS 段里面就记录了这个 8 KB 位图的起点。

在 TSS 内偏移为 102 字节的那个字单元,是 I/O 位图基地址字段,它指明了 I/O 许可位图相对于 TSS 起始处的偏移。

有几点需要说明:

  1. 如果 I/O 位图基地址的值大于等于 TSS 的段界限(就是 TSS 描述符中的段界限),就表示没有 I/O 许可位图。
  2. 处理器要求 I/O 位图的末尾必须附加一个全 1 的字节,即 0xFF
  3. 处理器不要求为每一个 I/O 端口都提供位映射,对于那些没有在位图中映射的位,处理器假定它对应的比特是 1(禁止访问)。
  4. TSS 描述符中的界限值包括 I/O 许可位图在内,也包括最后附加的 0xFF。TSS 的界限值是 149(总大小 150 减去 1)。

1.2 TSS 描述符

CPU 是通过 TR(Task Register)寄存器来确定 TSS 的位置的。和 GDTR,IDTR 不同的是,TR 寄存器是段寄存器(保护模式下的术语称为段选择子),对应其他段寄存器 有 CS,DS,ES,SS,FS,GS。

和 LDT 一样,必须为每个 TSS 在 GDT 中创建对应的描述符。

B 位是 “忙” 位(Busy)。在任务刚刚创建的时候,它应该为 0,表明任务不忙。当任务开始执行时,或者处于挂起状态(临时被中断执行)时,由处理器固件把 B 位置 1。

任务是不可重入的。就是说在多任务环境中,如果一个任务是当前任务,那么它可以切换到其他任务,但是不能从自己切换到自己。在 TSS 描述符中设置 B 位,并由处理器固件进行管理,可以防止这种情况的发生。

在一个任务门描述符中,TSS 段选择子域要在装入 TR 后指向 GDT 中的一个 TSS 描述符, 该段选择子中的 RPL 并没有被使用。在任务切换期间,任务门描述符的 DPL 控制着对 TSS 描述符的访问,当一个程序或过程通过一个任务门来调用或跳转到一个任务时,指向任务门选择子的 CPL 和 RPL 域值必须小于或等于任务门描述符的 DPL 域值。

任务切换

处理器可以通过下列四种形式之一切换到其他任务执行:

  1. 在当前程序、任务或过程中执行一条 JMPCALL 指令转到 GDT 中 TSS 描述符。(直接任务转换)
  2. 在当前程序、任务或过程中执行一条 JMPCALL 指令转到 GDT 或当前 LDT 中一个任务门描述符。(间接任务转换)
  3. 通过一个中断或异常矢量指向 IDT 中的一个任务门描述符。(间接任务转换)
  4. 当标志位 EFLAGS·NT 设置时,当前任务执行指令 IRET(或 IRETD,用于 32 位程序转换。(直接任务转换)

对于指令 JMP、CALL、IRET、中断和异常,它们都是程序执行时的一种控制流转向机制。一个 TSS 描述符、一个任务门(调用或跳转到一个任务)或标志位 NT(执行指令 IRET 时)的状态共同决定了是否发生任务切换。

任务连接

TSS 的先前任务连接域和标志位 EFLAGS·NT 用于返回到先前任务执行,标志位 EFLAGS·NT 说明当前执行的任务是否嵌套于嵌套的任务中,如果处于嵌套状态,在当前任务 TSS 的先前任务连接域(反向链)中,保存有嵌套链中较高级别任务的 TSS 选择子,如下图:

当执行一条 CALL 指令、一次中断或一次异常产生一次任务切换时,处理器将当前 TSS 的 段选择子复制到新任务 TSS 的先前连接域中,然后设置标志位 EFLAGS·NT,标志位 EFLAGS·NT 说明 TSS 的先前连接域通过一个被存储的 TSS 段选择子已经被保存。如果软件中通过执行指令 IRET 来恢复新任务(实际上就是过去被挂起的任务),处理器通过先前连接域中的值和标志位 NT 来返回到先前的任务,即如果标志位 EFLAGS·NT 设置,处理器执行一次任务切换,切换到先前连接域中所指定的任务。

与 CALL 指令不同的是:当执行一条 JMP 指令产生一次任务切换时,新任务是不允许嵌套的,即如果标志位 NT 清除,先前连接域没有用,一般采用指令 JMP 派遣一个新任务。

任务门描述符

一个任务门描述符(简称任务门)正像一个门为一个任务的执行提供控制和保护,一个任务门描述符可以位于 GDT、LDT 或 IDT 中。

在一个任务门描述符中,TSS 段选择子域要在装入 TR 后指向 GDT 中的一个 TSS 描述符, 该段选择子中的 RPL 并没有被使用。在任务切换期间,任务门描述符的 DPL 控制着对 TSS 描述符的访问,当一个程序或过程通过一个任务门来调用或跳转到一个任务时,指向任务门选择子 的 CPL 和 RPL 域值必须小于或等于任务门描述符的 DPL 域值。

1.3 内核执行现场

进入内核态后使用专门的内核栈,这不仅是内核数据与用户态数据的保护级别不同,也是因为内核代码的执行与用户态代码没有紧密联系的原因。

TSS与堆栈切换

由于内核代码执行完毕后还需要返回到用户态继续执行,因此用户态的断点必须保存在该进程的内核栈中。所以当进程执行系统调用,执行 INT n 指令从用户态进入到内核态时,该 INT 指令实际完成了以下几条操作:

  1. 如果 INT 指令发生了不同优先级之间的控制转移,则首先从 TSS(任务状态段)中 获取高优先级的核心堆栈信息(SS 和 ESP),然后把低优先级堆栈(即进程用户态堆栈)信息 (SS 和 ESP)保留到高优先级堆栈(即内核栈)中。
  2. 把 EFLAGS,当前处理器的 CS,EIP 寄存器值压入高优先级堆栈(核心栈)中。
  3. 通过 IDT 加载中断处理入口代码的 CS,EIP(控制转移至中断处理函数)。

但是如果已经在内核态(例如在分配内存),此时如果执行 INT n 系统调用则无须记录 SS 和 ESP。

硬件完成上述堆栈切换后,将根据 IDT 表跳转到指定的处理代码完成不同的中断处理。

如果是在内核态发生中断,则不需要进行堆栈切换,因为已经使用内核态堆栈了,于是直接保存断点现场即可。

当服务完成后,执行 IRET 指令返回到被打断的执行流(可能是内核态也可能是用户态)。其操作是从内核栈弹出并恢复相关寄存器的值,其中的 CS:EIP 则用于回复原来被打断的执行流。

IDT 表

X86 完成前面的堆栈切换工作之后,就需要跳转到中断号对应的不同处理代码。IDT 表利用中断机制服务于处理器内部和外部硬件中断、指令异常、软中断指令。每个上述的事件都对应一个编号,将该编号作为索引在 IDT 表中找到服务处理程序的入口地址,从而可以转向内核中特定的服务代码。

X86 的 IDT 表共有 256 项,有部分项是与确定事件(除 0、非法指令等异常)相关联的, 它们占据了 0~31 项,剩余的表项可以自由使用。XV6 将 32~63 共 32 个表项与 32 个硬件外设中断相关联,另一些与系统调用的 INT 指令相关联。

IDT 表仅用于进入中断服务程序,而从中断返回的 IRET 指令则不依赖于 IDT 表,而是依赖于内核栈中的数据。