在一个多任务环境中,当任务发生切换时,必须保存现场(比如通用寄存器,段寄存器,栈指针等),根据这些保存的被切换任务的状态,可以在下次执行时恢复现场。每个任务都应当有一片内存区域,专门用于保存现场信息, X86 提供了响应任务状态段 TSS(Task State Segment)用于保存现场。
X86 所希望的任务,和 Linux 或 XV6 操作系统所定义的任务并不一致, Linux / XV6 在任务(进 程)切换时,并不切换 TSS,但是利用了 TSS 提供的堆栈切换的信息和处理器寄存器现场的信息。虽然 Intel 设计的初衷是用它来做任务切换,然而,在现代操作系统中(无论是 Windows 还是 Linux),都没有使用这种方式来执行任务切换,比如线程切换和进程切换。主要原因是这种切换速度非常慢,一条指令要消耗 200 多个时钟周期。Linux 中只用到一个 TSS,所以我们必须提前创建一个 TSS,并且至少初始化 TSS 中的 ss0
和 esp0
。因此我们使用 TSS 的唯 一理由就是为 0 特权级的任务提供栈。当用户模式下发生中断时候,CPU 会自动的从 TSS 中 取出 0 级栈,然后一系列 push
指令。
在学习调用门,中断门和陷阱门已经知道,代码发生提权的时候,是需要切换栈的。之前遗留的一个问题是,栈段描述符和栈顶指针从哪里来?那时只是简单的讲了一下是从 TSS 中 来的。 提权的时候,CPU 就从这个 TSS 里把 SS0
和 ESP0
取出来,放到 ss
和 esp
寄存器中。
下面来学习 TSS 的相关细节知识。
TSS(Task State Segment,任务状态段),是一块位于内存中的结构体而已。需要注意,不要把它和任务切换动作直接关联起来,虽然任务切换可能依赖于 TSS 信息。
在创建一个任务的时候,我们要为这个任务创建 TSS 并填写其中的字段。
前一任务链接(TSS Back Link):TSS 内偏移 0 处是前一个任务的 TSS 描述符的选择子 (Previous Task Link)。
当 Call 指令、中断或者异常造成任务切换,处理器会把旧任务的 TSS 选择子复制到新任务的 TSS 的 Back Link 字段中,并且设置新任务的 NT(EFLAGS 的 bit 14)为 1,以表明新任务嵌套于旧任务中。 XV6 只用一个 TSS。
SS0,SS1,SS2 和 ESP0,ESP1,ESP2 分别是 0/1/2 特权级堆栈的选择子和栈顶指针。 这些内容应当由任务的创建者填写,且属于填写后一般不变的静态字段。
CR3 和分页有关,如果没有启用分页,可以填写 0。
偏移为 0x20~0x5C 的区域是处理器各个寄存器的快照部分,用于任务切换时保存现场。 在一个多任务环境中,每次创建一个任务,内核至少要填写 EIP,EFLAGS,ESP,CS,DS,SS,ES,FS 和 GS。当任务首次执行时,处理器从这些寄存器中加载初始执行环境,从 CS:EIP
处开始执行任务的第一条指令。
LDT 选择子是当前任务的 LDT 选择子(由内核填写),以指向当前任务的 LDT。该信息由处理器在任务切换时使用,在任务运行期间保持不变。
T(Debug Trap)位用于软件调试。在多任务环境中,如果 T=1,则每次切换到该任务的时候,会引发一个调试异常中断(INT 1)。
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 起始处的偏移。
有几点需要说明:
0xFF
。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 域值。
任务切换
处理器可以通过下列四种形式之一切换到其他任务执行:
JMP
或 CALL
指令转到 GDT 中 TSS 描述符。(直接任务转换)JMP
或 CALL
指令转到 GDT 或当前 LDT 中一个任务门描述符。(间接任务转换)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 域值。
进入内核态后使用专门的内核栈,这不仅是内核数据与用户态数据的保护级别不同,也是因为内核代码的执行与用户态代码没有紧密联系的原因。
TSS与堆栈切换
由于内核代码执行完毕后还需要返回到用户态继续执行,因此用户态的断点必须保存在该进程的内核栈中。所以当进程执行系统调用,执行 INT n
指令从用户态进入到内核态时,该 INT 指令实际完成了以下几条操作:
但是如果已经在内核态(例如在分配内存),此时如果执行 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 表,而是依赖于内核栈中的数据。