MyOS 之 多任务

在一般操作系统中,切换的动作为0.01~0.03秒就会执行一次,本身切换会占用0.0001秒左右。基本上会占据1%的算力。

切换时,将当前寄存器等所有上下文保存起来,读取下一个上下文,就完成了一次切换。关键就是在于TSS。

TSS(任务状态段)是由程序员来提供,CPU进行维护。程序员提供是指需要我们定义一个结构体,里面存放任务要用的寄存器数据。CPU维护是指切换任务时,CPU会自动把旧任务的数据存放的结构体变量中,然后将新任务的TSS数据加载到相应的寄存器中,也就是说,在硬件上就提供了这种进程转换的能力。

TSS和之前所说的段一样,本质上也是一片存储数据的内存区域,CPU用这块内存区域保存任务的最新状态。所以也需要一个描述符结构来表示它,这个描述符就是TSS描述符,它的结构如下

00000000 0000 0000 1000 1001 00000000 16段基址 16段界限

                                      00(基址) 0(控制符)0(段界限)89 28 0000(不一定) 0067

00008928 xxxx0067

当G标志是0时,界限字段的值大于或等于67H(103字节比TSS(104字节)的大小少一个字节)。如果切换到任务的TSS界限字段小于67H会产生无效TSS异常(#TS)。AVL域主要供系统软件使用。

P=1表示描述符对地址转换是有效的,或者说该描述符所描述的段存在,即在内存中;P=0表示描述符对地址转换无效,即该段不存在。使用该描述符进行内存访问时会引起异常。

这个描述符主要关注于它type字段中的B位,B位为1时表示任务繁忙。初始化时自然是0.

任务繁忙有两方面的意义,一是此任务正在CPU上运行,二是此任务嵌套调用了新的任务,CPU此时正在执行这个新的任务,此任务暂时挂起,等到新任务运行完了之后返回此任务继续执行

这个B位是由CPU自动维护的,任务被调到上CPU的时候,CPU自动将此B位置1,被换下CPU的时候,自动将其置0,因此我们初始化为0就可以了。

但这个段基址是什么?段基址不会使用GDT中的段,而是完完全全的TSS描述符 的地址。

我认为是其他参数的问题。

前面说的TSS描述符是用来描述TSS的,TSS的结构如下图

TSS结构中的数据就是我们保存任务时需要存储的数据,我们提供的保存TSS的数据结构也要照着这个设计。

ss0,esp0是什么意思呢?代码发生提权的时候,是需要切换栈的。如果代码从3环跨到0环,现在观察上面的图或者结构体,可以看到确实存在这么一个 SS0 和 ESP0。提权的时候,CPU就从这个TSS里把SS0和ESP0取出来,放到 ss 和 esp 寄存器中。

什么是提权呢?之前已经学过,代码段描述符中,有 DPL 字段,它被存储在段描述符或者门描述符(8字节的)的DPL字段中,它是专门用来描述目的代码段级别的。例如Windows 的GDT 表中段描述符,就有 0 环的代码段和 3 环代码段。这里的 DPL 表示,只允许相同级别以及已下的程序跳过来执行,除非被我同意,低特权级的程序(DPL比较高)才能跳进来执行。CPU 是不允许 CPL = 3 的程序直接跳转到 DPL=0 的代码中去执行。

CPL是当前执行的程序或任务的特权级。CPL这个值可以始终保存在 cs 或者 ss 段寄存器的低 2 位(段选择子后三位是参数,一个TI两个CPL)。它被存储在CS和SS的第0位和第1位上。通常情况下,CPL代表代码所在的段的特权级。当程序转移到不同特权级的代码段时,处理器将改变CPL。只有0和3两个值,分别表示用户态和内核态。DPL将会和CPL以及段或者门选择子的RPL相比较,根据段或者门类型的不同,DPL将会区别对待。

RPL是通过段选择子(2字节的)的第0和第1位表现出来的。RPL是代码中根据不同段跳转(表示用到不同的段选择子)而确定(一般是委托时继承的),以动态刷新CS里的CPL,在代码段选择符中。而且RPL对每个段来说不是固定的,两次访问同一段时的RPL可以不同(因为不同的段选择子可以指向同一个段描述符啊)。操作系统往往用RPL来避免低特权级应用程序访问高特权级段内的数据,即便提出访问请求的段有足够的特权级,如果RPL不够也是不行的,当RPL的值比CPL大的时候,RPL将起决定性作用。也就是说,RPL相当于附加的一个权限控制,只有当RPL>DPL的时候,才起到实际的限制作用。

为甚麽在CPL之外增加一个RPL,比方说:A进程的DPL为0,C进程的DPL为1,现在有一个B进程他的DPL为2,这B进程想委托A进程(外围程序可以访问一致代码段的内核)去访问C的数据(内核可以访问外围数据),如果没有RPL来限制的话,这样的委托访问是可以成功的,但这样是非常不安全的。有了RPL以后,A进程在访问C的时候还要受到RPL的约束,此时可以将访问C的选择子的RPL设为B的DPL,这样A的访问权限就相当为EPL=max(RPL,DPL)=2,这样他就无法代表B去越权访问C了。(那还要委托A干嘛?反正B如果不够权限,委托谁都没用;如果B有权限,不用委托别人也可以啊?)

什么是一致代码呢?例如你调用一个权限比较大的函数----远跳转的call!这时候你就可以用调用门来调用它。

 什么是一致代码段:简单理解,就是操作系统拿出来被共享的代码段,可以被低特权级的用户直接调用访问的代码(不用调用门或任务门就能调用,但它就是内核函数,就是特权级0的,例如各种api)。执行这段一致代码时,程序以当前特权级继续执行。通常这些共享代码不涉及受保护的资源。对于一致性代码来说,即是说核心态不允许调用用户态的数据;特权级低的程序可以访问到特权级高的数据.但是特权级不会改变:用户态还是用户态。

那什么是非一致性代码段呢?可以理解为,为了避免低特权级的访问而被操作系统保护起来的系统代码(正统的内核代码)。向不同特权级的非一致代码段转移将导致一般保护异常,除非使用了任务门或者调用门。产生一致性代码和非一致性代码的主要原因是:单纯的0-3特权级只能保证高特权级可以访问特权级的东西,而低特权级的段有时候要访问内核数据段,此时就需要一些灵活策略。对于非一致代码段来说,只允许同级间访问,绝对禁止不同级访问:核心态不用用户态,而用户态也不使用核心态。
  每当调用门用于把程序控制转移到一个更高级别的非一致性代码段时,CPU会自动切换到目的代码段特权级的堆栈去(这是有必要的)。每个任务只能定义最多4个栈,分别对应4个特权级(环)。每个栈都位于不同的段中,并且使用段选择符和段中偏移值指定。

指令 jmp 可以实现跨段执行代码,但是它并不能提权(无法改变当权特权级),也就是说,即便你跨到了 DPL = 0 的段,你的 CPL 也不会发生任何改变。 除非你原来就是 0.使用 jmp 跨到 0 环代码段,也是有要求的,除非这个0环代码段描述符同意(这就是所谓的一致代码段)。

当段描述符描述代码段时,TYPE 字段的 c 位置 1 说明该段是一致代码段,否则是非一致代码段。

  • 只有得到段描述符的同意,才允许低权限的程序跳转进去执行。这种段称为一致代码段
  • 而有些代码段描述符,绝对不允许低权限的程序跳转进去执行,这种段称为非一致代码段

CPU只允许CPL为0、1、2的程序访问高2G内存。无论如何,在3环下你也读不了这个地址的内存。唯一的方法就是,让你的特权级变成0、1、或者2。这意味着,你得想办法把你的CS段寄存器的后两位修改为 0、1、2。为了后文实验方便,我们以后只说把特权级改为 0.通过 jmp 是行不通的,因为 jmp 不能提权。

调用门是CPU提供给我们的一个功能,它允许 3 环程序(CPL=3)通过这扇“门”达到修改 cs 段寄存器的目的,同时达到提权的目的。

“门”,是一种系统段描述符(段描述符的 S=0),这个描述符的结构和数据段描述符和代码段描述符有很大区别,这种描述符中嵌入了选择子。如果你在“门”嵌入DPL=0的代码段选择子,那么你在 3 环,就可以通过这扇门,到达0环领空,这时候你的CPL=3就变成CPL=0。

调用门就具备了这种功能。你可以在调用门中嵌入选择子 0x08,这个选择子指向的是 DPL = 0的代码段。然后使用 call far + 调用门描述符的段选择子,跨段到 0x0008 指向的代码段。实际上门的意义就是在于提权。

如果你的程序发生提权,该指令首先会切换堆栈。切换堆栈的意思是,改变 ss 段寄存器的值。还记得当时是如何讲解 CPL 的概念的吗?没错,cs 或 ss 段寄存器的最低 2 位的值。记得段选择子的序号总是要前移3个位,没错,后三个位就是做这个用的。倘若你的程序发生提权,这时候 cs 的低 2 位变成了 0,那么 ss 的低两位不变,岂不是很矛盾?所以,提权的时候,切换栈是必须的。毕竟栈也是系统栈,不是自己维护的。

那么问题来了,cs 段的选择子我们可以填进调用门描述符里去,ss 段的选择子从何处而来?(TSS中来,eip也是)

还有就是,call far 指令会在新栈 ss0 (表示0环栈)中压入哪些值?

当使用 call far 指令提权的时候,发生栈的切换。同时,CPU在0环栈中压入3环栈和栈指针,以及3环cs段选择子,以及返回地址 eip3(用于返回)。如果CPU不保存ss3,当调用门结束返回的时候,如何找的到 3 环栈呢(毕竟此时TSS没用了)?如果CPU不保存 3 环 cs 段选择子,如何能够再退回到原来 CPL=3的权限呢?仔细想想,CPU做这些事情是很有道理的。自己可以不用管。

跨段不提权,这种情况,要简单很多,不提权,意味着,不需要切换栈,也不需要保存3环栈段寄存器 ss 和 esp了。但是cs和eip还是需要的。

有一点需要注意的是,call far 函数指令的格式虽然是 call far cs:eip,但是这后面的 eip 已经被废弃。而真正的 eip 是填写到了调用门中。

接下来就是应用了:

内存切换就是要用jmp了。jmp分为两种,只改写ip就是near跳转,同时改写ip和cs就是远跳转。如果一条jmp指令所指定的目标地址段不是可执行的代码,而是TSS的话,CPU就不会执行通常的改写EIP和CS的操作,而是将这条指令理解为任务切换,这是最关键的。CPU每次执行带有段地址的指令时,都会去确认一下GDT的设置,判断接下来要执行的JMP指令到底是far-jmp,还是任务切换,在汇编代码上,这两者没有任何区别,因此需要硬件判断。TSS段内容会被放到GDT中!TR寄存器的目的就是让CPU知道现在正在执行的是哪一个任务。当任务切换的时候,TR寄存器的值也会自动变化。我们每次给TR寄存器赋值的时候,必须把GDT的编号乘以8,这是硬性规定,但是我们一般不用操作TR寄存器,只有在初始化时载入。
当发生中断时,CPU会先把寄存器中的值全部写入内存中,写入哪里的内存呢?就是TSS段(因此需要初始化TR),TSS的一个结构共包含26个int成员,总计104字节。前面的几项是TSS的属性参数(不是寄存器也不会被写入,与进程无关),后面的一堆寄存器(应该是所有的寄存器)值,然后最后的几项也是属性参数,有关任务设置的。

typedef struct TSS {
// 链接字段
// 链接字段安排在TSS内偏移0开始的双字中,其高16位未用。在起链接作用时,地16位保存前一任务的TSS描述符的选择子。

如果当前的任务由段间调用指令CALL或中断/异常而激活,那么链接字段保存被挂起任务的 TSS的选择子,并且标志寄存器EFLAGS中的NT位被置1,使链接字段有效。在返回时,由于NT标志位为1,返回指令RET或中断返回指令IRET将使得控制沿链接字段所指恢复到链上的前一个任务。一般函数调用函数用
    DWORD link; // 保存前一个 TSS 段选择子,使用 call 指令切换寄存器的时候由CPU填写。
// 内层堆栈指针区域
// 为了有效地实现保护,同一个任务在不同的特权级下使用不同的堆栈。例如,当从外层特权级3变换到内层特权级0时,任务使用的堆栈也同时从3级变换到0级堆栈;当从内层特权级0变换到外层特权级3时,任务使用的堆栈也同时从0级堆栈变换到3级堆栈。所以,一个任务可能具有四个堆栈,对应四个特权级。四个堆栈需要四个堆栈指针。
// TSS的内层堆栈指针区域中有三个堆栈指针,它们都是48位的全指针(16位的选择子和32位的偏移),分别指向0级、1级和2级堆栈的栈顶,依次存放在TSS中偏移为4、12及20开始的位置。当发生向内层转移时,把适当的堆栈指针装入SS及ESP寄存器以变换到内层堆栈,外层堆栈的指针保存在内层堆栈中。没有指向3级堆栈的指针,因为3级是最外层,所以任何一个向内层的转移都不可能转移到3级。因为这是内层堆栈指针,外层指针是被放到内层堆栈的。
// 但是,当特权级由内层向外层变换时,并不把内层堆栈的指针保存到TSS的内层堆栈指针区域。实际上,处理器从不向该区域进行写入,除非程序设计者认为改变该区域的值。这表明向内层转移时,总是把内层堆栈认为是一个空栈。因此,不允许发生同级内层转移的递归,一旦发生向某级内层的转移,那么返回到外层的正常途径是相匹配的向外层返回。
    // 这 6 个值是固定不变的,用于提权,CPU 切换栈的时候用
    DWORD esp0; // 保存 0 环栈指针
    DWORD ss0;  // 保存 0 环栈段选择子
    DWORD esp1; // 保存 1 环栈指针
    DWORD ss1;  // 保存 1 环栈段选择子
    DWORD esp2; // 保存 2 环栈指针
    DWORD ss2;  // 保存 2 环栈段选择子
// 寄存器保存区域
    // 下面这些都是用来做切换寄存器值用的,切换寄存器的时候由CPU自动填写。
// 当TSS对应的任务正在执行时,保存区域是未定义的;在当前任务被切换出时,这些寄存器的当前值就保存在该区域。当下次切换回原任务时,再从保存区域恢复出这些寄存器的值,从而,使处理器恢复成该任务换出前的状态,最终使任务能够恢复执行。
    DWORD cr3; 
    DWORD eip;  
    DWORD eflags;
    DWORD eax;
    DWORD ecx;
    DWORD edx;
    DWORD ebx;
    DWORD esp;
    DWORD ebp;
    DWORD esi;
    DWORD edi;
    DWORD es;
    DWORD cs;
    DWORD ss;
    DWORD ds;
    DWORD fs;
    DWORD gs;
// 地址映射寄存器区域
// 从虚拟地址空间到线性地址空间的映射由GDT和LDT确定,与特定任务相关的部分由LDT确定,而LDT又由LDTR确定。如果采用分页机制,那么由线性地址空间到物理地址空间的映射由包含页目录表起始物理地址的控制寄存器CR3确定。所以,与特定任务相关的虚拟地址空间到物理地址空间的映射由LDTR和CR3确定。显然,随着任务的切换,地址映射关系也要切换。
// TSS的地址映射寄存器区域由位于偏移1CH处的双字字段(CR3)和位于偏移60H处的字字段(LDTR)组成。在任务切换时,处理器自动从要执行任务的TSS中取出这两个字段,分别装入到寄存器CR3和LDTR。这样就改变了虚拟地址空间到物理地址空间的映射。

但是,在任务切换时,处理器并不把换出任务但是的寄存器CR3和LDTR的内容保存到TSS中的地址映射寄存器区域。事实上,处理器也从来不向该区域自动写入。因此,如果程序改变了LDTR或CR3,那么必须把新值人为地保存到TSS中的地址映射寄存器区域相应字段中。可以通过别名技术实现此功能。
    DWORD ldt;
    // 为了实现输入/输出保护,要使用I/O许可位图。任务使用的I/O许可位图也存放在TSS中,作为TSS的扩展部分。为0x40000000
// 其它字段
// 为了实现输入/输出保护,要使用I/O许可位图。任务使用的I/O许可位图也存放在TSS中,作为TSS的扩展部分。在TSS内偏移66H处的字用于存放I/O许可位图在TSS内的偏移(从TSS开头开始计算)。关于I/O许可位图的作用,以后的文章中将会详细介绍。

在TSS内偏移64H处的字是为任务提供的特别属性。在80386中,只定义了一种属性,即调试陷阱。该属性是字的最低位,用T表示。该字的其它位置被保留,必须被置为0。在发生任务切换时,如果进入任务的T位为1,那么在任务切换完成之后,新任务的第一条指令执行之前产生调试陷阱。 
    DWORD io_map;
} TSS;

TSS的用途,总结一下,第一是一次性切换一堆寄存器,没有TSS表,TSS段是被存在GDT表中,内存中有多份不同的TSS(视任务的数量而定),总有一个TSS是当前正在使用的,也就是tr寄存器指向的那个TSS(除了初始化用到ltr,其他时候就直接用jmp就可以了),通俗的来讲,TSS等价于一个段了。GDT就是用来切换任务的。第二就是用于提权。

现在就要更改一下GDT的设置函数了,这里的被设置在了GDT的3,4位置,用的就是TSS_x的地址,用了103字节(足够了吧,TSS一共104字节,限制是减1),最好不要用GDT_Set了,用TSS_Set就足够了(虽然用的GDT地址)。

TSS只能放到GDT中,无法放到LDT中,它的描述符为:

这里写图片描述

 

因此我应该什么样呢?首先呢,TSS完全可以用GDT_Set完成,但是例如G就不可以了,G必须为0,0x89是10001001,后面是对上了,DPL也是00,P是1,不知道P是什么,反正这就是TSS固定了。

现在就先计划蹦到两个额外的函数了。测试一下。

还有这个栈的问题,你push和pop用得爽,它到底存在哪里了呢?这里EBP是0,ESP是0x7c00.

(1)ESP:栈指针寄存器(extended stack pointer),其内存放着一个指针,该指针永远指向系统栈最上面一个栈帧的栈顶。

(2)EBP:基址指针寄存器(extended base pointer),其内存放着一个指针,该指针永远指向系统栈最上面一个栈帧的底部。

栈是向下生长的,栈顶:栈的最上方(低地址区)。栈低:栈的最下方(高地址区)。变换TSS,必须切换系统栈。但是现在内存还没有开辟,这就很糟糕了。。。只能随便取吧。

还有TR寄存器,它应该也只是初始化一次,用来锚定当前任务,锚定好后,它会自动填充对应的TSS,当然,其他的TSS还需要自己手动设置的。

    .ldtr: dd 0
    .iomap: dd 0x40000000

不知道为什么,固定的。

几个段寄存器不变,为什么,都从0开始,没有任何关系。

b的eip与esp都需要设置。

而且栈还是专门开辟的。。。

ltr是必须乘以8的->

0没法用都是0,1是数据段,2是代码段,3就应该是操作系统的TSS段了,按理说,ltr是没有任何问题啊?

检查一下,这个地址应该是0x00270000+3*8=0x270018,这个是地址

GDT一共8字节,整体应该是00008928 xxxx0067

完全正确。。。

既然是基址,那么应该就是物理地址,毕竟它跟GDT是同级的,它不可能加上代码段的基址吧?

现在GDT里的确是有,而且也是正确的,那个104字节的自然存在,而且根本不用管好吧。唯一的办法,就是弄到分页前,看看是这么回事,但是我觉得跟分页问题不大。

发现错误了,必须

mov ax, 3*8

ltr ax

而不是

ltr [3*8]

我在那里get到的这个陋习啊?

现在就是跳过去。。。

看4*8里面有什么啊。。。0x270020

首先说,这里完全没错。然后它就会开始解析,段基址都是不变的话,ip是什么?

这究竟又是哪里的问题?

原来自己写TSS段少写了一项。。。

总之,现在进程切换也成功了!!!

每日小常识:

我都是把EBP当作基址,ESP在EBP上盖楼来理解的。所以某个函数或者进程什么的要崩溃,你就崩到EBP就好了,超过EBP就不是你的东西了。当然EBP,ESP要存放到一个安全的地方,不那么容易丢失的地方,所以才会有那么多的push ebp  mov ebp,esp.

简单说,就是这两个寄存器保存着你程序在哪段内存上运行的信息。ebp 的默认段也是 SS,这方面和 esp 一样

push ebp
mov ebp, esp
...
pop ebp
ret

相当于进了一个新的函数栈,实际上是套在原函数栈上的。

push和pop命令,会自动来更改 esp寄存器。

push时候, esp自动减小;pop时候,esp自动增加。

push时候, esp自动减小;pop时候,esp自动增加。具体增减的数量,需要看后面的字节数

通常,在调用函数时候。在子程序中,
1. 首先会 将 ebp压栈。pushl %ebp
2. 然后将esp赋值给 ebp,也就是让 ebp指向 esp指向的内存位置。如此该内存位置就是这个子程序的 ebp。 movl %esp %ebp
3. 接着,会将 esp减少一定量。具体的数目也会根据该子程序会用到的栈大小来确定

如此以后,对于这个子程序,所使用的栈大小应该就是 %ebp - %esp的大小

Linux进程切换分两步:

1.切换页目录以使用新的地址空间

2.切换内核栈和硬件上下文

关于上下文切换我仅仅参考linux内核的实现从技术角度来解释:
在linux中一个叫做task_struct结构体代表一个线程,linux调度器会对一个结构体:sched_entity结构体感兴趣并对其进行调度,而它正好嵌入到task_struct中。因此对可以看出linux调度是线程级的。那具体怎么调度呢?
Linux用红黑树存所有可运行的进程(注意是可运行),使用等待队列wait_queue记录休眠(被阻塞)线程。用一个例子来介绍调度和上下文切换的细节,例如网卡产生一个中断通知有网络数据,执行中的线程阻塞(从执行状态剥离并放入等待队列),然后再到红黑树里面选一个来执行。这个过程的详细过程是:虚拟内存映射和处理器状态均要切换到新线程,前一个线程寄存器、栈信息还有其他状态信息被保存。而新线程的栈信息和寄存器信息被恢复,刚好是反操作。我们把上述过程叫做上下文切换。等到网络数据读取就绪,在等待队列中的线程又被唤醒,接着放入红黑树中,成为可执行态,等待被执行。

它们不用TSS切换的方法可能仅仅是自己实现切换而已,毕竟比200条指令还少,只能自己实现了。

现在就是初始化好两个TSS后,注册进GDT中 ,然后jmp调用就好了。

现在就启用了前4MB的,就是完全对应的。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值