CPU 厂商原本计划的一种任务切换方法,并不是操作系统实例中任务切换的方法, 未采用的原因是此方法效率不高,现代操作系统很少用这种方法切换任务
为了支持多任务, CPU 厂商提供了 LDT TSS 这两种原生支持,他们要求为每个任务分别配一个 LDT TSS (这由咱们操作系统程序员来构建), LDT 中保存的是任务自己的实体资源,也就是数据和代码,TSS 中保存的是任务的上下文状态及三种特权级的栈指针、 I/O 位图等信息。既然 LDT TSS 用来表示任务,那么任务切换就是换这两个结构:将新任务对应的 LDT 信息加载到 LDTR寄存器,对应的 TSS 信息加载到 TR 寄存器。下面我们看看 CPU 是怎样进行任务切换的。
TSS CPU 用于保存任务的状态及任务状态的恢复,而 LDT 是任务的实体资源, CPU 厂商只是建议这样做,其实没有 LDT 的话也是可以的。比如我们可以把任务自己的段描述符放在 GDT 中,或者干脆采用平坦模型直接用那个 4GB 大小的全局描述符。任务的段放在 GDT,还是 LDT中,无非就是在用选择子选择它们时有区别,区别您懂的,就是选择子中 TI 位的取值,0 是从 GDT 中选择段描述符, 1是从 LDT中选择段描述符。描述符及描述符表只是逻辑上对内存区域的划分(当然这也包括其他各种属性,但对此来说并不重要),任务要想执行,归根结底都是用 cs : eip 指向这个任务的代码段内存区域以及 DS 指向其数据段内存区域,所以任务私有的实体资源不是必须放在它自的 LDT 中。综上所述, LDT 是可有可无的,真正用于区分一个任务的标志是 TSS ,所以用于任务切换的根本方法必然是和任务的 TSS 选择子相关。
进行任务切换的方式有中断+任务门,call jmp+任务门和 iretd
通过“中断+任务门”进行任务切换
其实咱们对采用中断这种方式进行任务切换早己熟悉了,目前的线程切换中用的就是时钟中断 中断
是定时发生的,因此用中断进行任务切换的好处是明显的。
• 实现简单。
• 抢占式多任务调度,所有任务都有运行的机会。
一个完整的任务包括用户空间代码及内核空间代码,这两种代码加起来才是任务的全局空间。另外,在 CPU 眼里, TSS 就代表一个任务,TSS 才是任务的标志, CPU 区分任务就是靠TSS 因此,只要 TR 寄存器中的 TSS 信息不换,无论执行的是哪里的指令,也无论指令是否跨越特权级(从用户态到内核态) CPU 都认为还是在同一个任务中
(1) 从该任务门描述符中取出任务的 TSS 选择子。
(2)用新任务的 TSS 选择子在 GDT 中索引 TSS 描述符
(3) 判断该 TSS 描述符的P位是否为1,为1表示该 TSS 描述符对应的 TSS 己经位于内存中 TSS描述符指定的位置,可以访问。否则p不为1表示该 TSS 描述符对应的 TSS 不在内存中,这会导致异常
(4) 从寄存器 TR 中获取旧任务的 TSS位置,保存旧任务(当前任务)的状态到旧 TSS 其中,任务状态是指 CPU 中寄存器的值,这仅包括 TSS 结构中列出的寄存器:8个通用寄存器, 6个段寄存器,指令指针寄存器eip ,栈指针寄存器 esp ,页表寄存器 cr3 和标志寄存器 eflags 等。
(5) 把新任务的 TSS 中的值加载到相应的寄存器中
(6) 使寄存器 TR 指向新任务的 TSS
(7) 将新任务(当前任务〉的 TSS 描述符中的P位置1
(8) 将新任务标志寄存器中 eflags NT 位置1
(9) 将旧任务的 TSS 选择子写入新任务 TSS 中“上一个任务的 TSS 指针”字段中
(10) 开始执行新任务。
call jmp 切换任务
(1) 首先,任务门描述符除了可以在 IDT 中注册,还可以在 GDT, LDT 中注册
(2) 其次,任务以 TSS 为代表,只要包括 TSS 选择子的对象都可以作为任务切换的操作数
综上所述,中断发生时,通过任务门进行任务切换的过程如下。
假设 TSS 选择子定义在 GDT 中第3个描述符位置:
call 0x0018 : 0x1234
(1 CPU 忽略偏移量 0x1234 ,拿选择子 0x0018到GDT 中索引到第3个描述符
(2 检查描述符中的P位,若为0,表示该描述符对应的段不存在,这将引发异常 同时检查该描述符的S与TYPE 的值,判断其类型, 如果是TSS描述符,检查该描述符的B位, B 位若为1将抛出 GP异常,即表示调用不可重入
(3 进行特权级检查,数值上“ CPL和TSS 选择子中的 RPL ”都要小于等于 TSS 描述符的 DPL ,否则抛出 GP 异常
(4 特权检查完成后,将当前任务的状态保存到寄存器 TR 指向的 TSS 中。
(5 加载新任务 TSS 选择子到 TR 寄存器的选择器部分,同时把 TSS 描述符中的起始地址和偏移量属性加载到 TR 寄存器中的描述符缓冲器中。
(6 将新任务 TSS 中的寄存器数据载入到相应的寄存器中,同时进行特权级检查,如果检查未通过,则抛出 GP 异常
(7 CPU 会把新任务的标志寄存器 eflags 中的NT位置为1
(8 将旧任务 TSS 选择子写入新任务 TSS 中的字段“上个任务的 TSS 指针”中,这表示新任务是被旧任务调用才执行的
(9 然后将新任务 TSS 描述符中的B位置为1以表示任务忙, 旧任务 TSS 描述符中的B位不变,依然保持为1,旧任务的标志寄存器 eflags 中的 NT 位的值保持不变,之前是多少就是多少
(10 开始执行新任务,完成任务切换
TSS是x86CPU 的特定结构,被用来定义“任务”,它是内置到处理器原生支持的多任务的一种形式
上文中, 介绍了 CPU 提供的多任务支持,每个任务拥有自己的 TSS ,每个任务也可以有自己的LDT ,看样子还是很简洁的,但为什么 Linux 未采用此方式呢?
上面的过程大概分成 10 步,还是直接用 TSS 选择子进行任务切换的步骤,这已经非常繁琐了,在每一次任务切换过程中, CPU 除了做特权级检查外,还要在 TSS 的加载、保存、设置B位,以及设置标志寄存器 eflags NT 位诸多方面消耗很多精力,这导致此种切换方式效能很低。
其次,常见的指令集有两大派系,复杂指令集 CISC 和精简指令集 RISC.x86 使用的指令集属于 CISC,在此指令集的发展过程中,工程师为了让程序员少写代码,把指令的功能做得越发强大,因此在阳SC多条指令才能完成的工作,在 CISC 中只用一条指令就完成了。看上去感觉很爽的样子,但这只是开发效率上的提升,执行效率却下降了,原因是:表面强大的功能是用内部复杂、数量更多的微操作换来的,也就是说, CISC 的强大需要更多的时钟周期作为代价。虽然 Intel 提供了 call jmp 指令实现任务切换,但这两个指令所消耗的时钟周期也是可观的,都是以百为单位的(据说已经达到 300+,我没测试过)。最后,一个任务需要单独关联一个 TSS, TSS 需要在 GDT 中注册, GDT 中最多支持 8192 个描述符,为了支持更多的任务,随着任务的增减,要及时修改 GDT ,在其中增减 TSS 描述符,修改过后还要重新加载 GDT 。这种频繁修改描述符表的操作还是很消耗 CPU 资源的。
以上是效率方面的原因,除了效率以外,还有便携性和灵活性等原因,不仅 Linux 未来用这种原生的任务切换方法,而且几乎所有 x86 操作系统都未来用
不幸的是,我们是在 CPU 制定的规则上编写程序的,因此始终脱离不了大规则的束缚,还高 件工作必须且只能用 TSS 来完成,这就是 CPU 向更高特权级转移时所使用的战地址,需要提前在 TSS 中写入导致转移到更高特权级的一种情况是在用户模式下发生中断, CPU 会由低特权级进入高特权级,这会发生堆梭的切换。当一个中断发生在用户模式(特权级 ),处理器从当前 TSSsso espO 成员中获取用于处理中断的堆枝。因此,我们必须创建一个 TSS ,并且至少初始化 TSS 中的这些字段尽管 CPU 提供了。、 个特权级,但我们效仿 Linux 只用其中的 个,内核处理特权级 o,用户进程处于特权级结论 我们使用 TSS 唯一的理由是为 特权级的任务提供枝
不过为了“应付”这一指标, Linux 为每个 CPU 创建一个 TSS ,在各个 CPU 上的所有任务共享同TSS ,各 CPU的TR寄存器保存各 CPU 上的 TSS ,在用 ltr 指令加载 TSS 后,该 TR 寄存器永远指向同一个 TSS ,之后再也不会重新加载 TSS 。在进程切换时,只需要把 TSS 中的 sso esp0更新为新任务的内核栈的段地址及栈指针。
您看,实际上 Linux TSS 的操作是一次性加载 TSS到TR,之后不断修改同 TSS 的内容,不再进行重复加载操作。
Linux TSS 中只初始化了 sso esp0和I/O位图字段,除此之外 TSS 便没用了,就是个空架子,不再做保存任务状态之用。