协同式任务切换
01. 任务和任务切换概述
多任务系统中,每个任务都有自己的任务状态段TSS和局部描述符表LDT,当前任务是由任务寄存器TR指示,指向当前任务的任务状态段TSS、局部描述符表寄存器LDTR也指向当前局部描述符表LDT。
多任务系统是指可以同时执行两个或者两个以上任务的系统,即使一个任务没有执行完也可以执行下一个任务,多任务切换时TR和LDTR也要切换到新的任务中。
多任务切换方式:
- 协同式任务切换:需要当前任务主动请求暂时放弃执行权、或者在通过调用门请求操作系统服务时由操作系统乘机切换到另一个任务中。因此这种方式很依赖于当前执行任务的自律性,当一个任务失控时可能其它任务都得不到执行的机会。
- 抢占式任务切换:这种方式可以安装一个定时器中断,在中断信号产生的时候进行任务切换。硬件中断会定时发生,不管处理器在做什么,当产生中断时任务一定会执行切任务换操作,这样所有任务都有平等的执行机会,当一个任务失控时也不会导致其他任务没有机会执行。
多任务系统:
任务的组成是灵活的,不一定由不同的特权级组成,也不一定由内核和用户程序组成
例如本章将创建三个任务:
- 任务1由单独的内核组成,0特权级。内核除了是一个单独的任务,也是其他任务的全局部分。
- 任务2由任务2的私有部分和内核组成,其是3特权级。
- 任务3由任务3的私有部分和内核组成,其也是3特权级。
在本章中,当处理器加电复位之后,进入保护模式之后就直接创建和执行内核的0特权级任务→之后切换到任务2的私有部分→之后切换到内核→之后切换到任务3的私有部分。
每个任务都有自己的状态,特别是当一个任务再执行时,所有段寄存器和通用寄存器都和当前任务息息相关。段寄存器指向当前任务自己的段,通用寄存器保存着当前任务执行的数据和临时结果、标志寄存器保存着当前任务执行产生的各个标志位。
当前任务要切换出去,必须将当前任务的所有状态都保存起来以便将来恢复,这叫做保护现场。被切换到的那个任务也必须恢复到原先它被打断时的状态,叫做恢复现场。
为了保护现场和恢复现场,使用每个任务的TSS来保存数据:CR3和分页有关。
保存当前任务的现场:
恢复目标任务的现场:
恢复之后被切换到的任务变成当前任务。
02. 内核任务的创建和当前I/O特权级LOPL
本章程序:
引导程序:c13_mbr0.asm,加载执行内核。
内核程序:c15_core0.asm,加了新的内容。
用户程序:c15_app0.asm,加了新的内容。
I/O许可位图,之前讲过特权指令,即只有0特权级才能执行的指令。但是有一些低特权级的程序也需要使用这些指令。
如下:
为了控制哪些任务能够访问硬件端口,需要用到标志寄存器EFLAGS:
当前特权级CPL若高于IOPL(I/O Privilege Level),数值上CPL ≤ IOPL,则表示所有I/O访问都是被允许的。
03. I/O特权级的修改和POPF指令
标志寄存器中的标志是会随着任务的执行而改变的,比如:ZF、CF;有些则需要特定的指令来改变,比如DF。但是IOPL不会自动随程序修改,也没有特定的指令来修改,修改IOPL需要执行以下操作:
先将标志寄存器压栈
然后对栈中IOPL内容进行修改
将IOPL修改为01。
最后将栈中修改后的内容弹出到寄存器。
04. 任务的用户态和内核态
每个任务都有TSS,其中保存了EFLAGS,有IOPL字段。
多任务系统特点:可以在内核任务和用户任务之间来回切换、也可以再两个任务之间来回切换。
每当一个任务被切换回后台时,它与之相关的状态都会保存再它的TSS中,当它恢复时,会从它的TSS中将各种状态恢复到处理器中。显然每个任务都受自己的IOPL所限制。
每个任务都可以对自己的标志和状态进行修改,比如标志寄存器中的内容,需要使用如下指令进行压栈、修改、再出栈返回到标志寄存器中。
其中pushf、pushfd在任务特权级下均可执行,但是popf、popfd执行时标志寄存器中的有些标志位是否会受到影响(如IOPL字段)是否能被修改则取决于当前特权级CPL:
- CPL为0,那么执行popf、popfd指令时,标志寄存器的IOPL字段会被修改;
- CPL为1,那么执行popf、popfd指令时,标志寄存器的IOPL字段类似于只读,不会受到影响;
即低特权级指令无法使用popf、popfd指令修改IOPL字段。popf、popfd并不是特权指令,特权指令是只能在0特权级下执行,popf、popfd指令在低特权级下也可以执行,只不过在低特权级下执行时一些标志位不受其影响。
内核任务只能在内核态执行,用户任务可以在内核态和用户态中执行。
05. I/O许可位串和TSS的I/O许可位映射区
当前CPL高于等于IOPL(数值上CPL ≤ IOPL),则所有I/O操作都是被允许的;
当前CPL低于IOPL(数值上CPL ≥ IOPL),也并非意味着所有I/O操作都是不被允许的,而是需要进一步指定哪些允许,哪些不允许。在输入输出(I/O许可位串)中指定。
TSS中基本长度是104字节,当然也可以包括I/O许可位映射区
TSS描述符及其布局:其中段界限是包括I/O许可位映射区的。
其中I/O许可位的偏移M若大于TSS描述符的段界限,则意味着没有I/O许可位。在这种情况下,如果当前CPL ≥ IOPL,就意味着必须检查I/O许可位串,但是没有I/O许可位串就意味着不允许访问硬件端口,执行任何硬件I/O指令都会引发处理器的异常中断。
处理器检查I/O许可位方法如下:
- 先根据端口号计算它在I/O许可位映射区的哪个字节中;
- 然后读取该字节,并测试那个byte位,
如out 0x09, al指令:端口0x09位于第二个byte,而且位于第二个字节的位1.处理器读取并测试这个byte位是0还是1来决定是否允许执行这个out指令。
I/O端口是按照字节编址的,即每个端口只能用来读取一个字节的数据,那些多字节的端口其实是合并了几个端口组成一个多字节端口的:
由于I/O端口是按照字节编址的原因,当处理器执行一个字或者双字的I/O指令时,会检查许可位串中的2个或者4个连续的byte,而且要求它们必须都是0,否则引发异常中断。
麻烦在于这些连续的byte有可能是跨字节的,即一些byte位于前一个字节有些位于后一个字节,
如下:
所以处理器每次都从I/O许可位映射区读取2个连续的字节,而不是1个字节。
这种操作方式也导致了另一个问题,即要检查的byte如果在最后一个字节中这样的读操作就会导致越界。为了防止这种情况发生处理器要求I/O许可位映射区最后必须附加一个额外的字节,其值为0xFF。
如下:
若I/O许可映射区本身只有11个字节,除去最后一位0xFF,只剩下10个字节,那么只能映射80个端口,访问更高地址的端口(高于79号端口)将引发异常中断。
06. 任务切换的方法以及内核任务的确立
内核本身要当作一个独立的任务,内核正在执行现在要为其补一个合法的手续。
创建内核的TSS,接着要在TSS中填充一些内容,在任务切换之前提前准备好。
设置内核任务的TSS:
;为内核任务的TSS分配内存空间
mov ecx,104 ;为该任务的TSS分配内存
call sys_routine_seg_sel:allocate_memory
mov [es:esi+0x14],ecx ;在内核TCB中保存TSS基地址
;在程序管理器的TSS中设置必要的项目
mov word [es:ecx+96],0 ;没有LDT。处理器允许没有LDT的任务。
mov word [es:ecx+102],103 ;没有I/O位图。0特权级事实上不需要。
mov word [es:ecx+0],0 ;反向链=0
mov dword [es:ecx+28],0 ;登记CR3(PDBR)
mov word [es:ecx+100],0 ;T=0
;不需要0、1、2特权级堆栈。0特级不
;会向低特权级转移控制。
- 内核任务不需要LDT,所以在内核TSS偏移0x96的地方填写数字0即可;
- 内核任务也不需要I/O许可位映射区,内核是0特权级,始终可以进行所有I/O操作。这里偏移填写103为内核TSS的界限值,即表示保存在I/O许可位映射区;
- 反向链相关,硬件任务切换在64位处理器上不再支持,除非以兼容模式允许运行。
- 用CALL指令发起任务切换时,任务之间会形成一个任务链,可以通过任务链反向切换到原来的任务中。
- 所以在TSS中偏移为0的位置,置零即可:
- 和分页相关的位暂时先清0
- 设置T位为0,因为内核特权级为0,不会向低特权级实施控制转移。
创建内核任务的TSS莫舒服,安装到GDT中:
;创建TSS描述符,并安装到GDT中
mov eax,ecx ;TSS的起始线性地址
mov ebx,103 ;段长度(界限)
mov ecx,0x00008900 ;TSS描述符,特权级0
call sys_routine_seg_sel:make_seg_descriptor
call sys_routine_seg_sel:set_up_gdt_descriptor
mov word [es:esi+0x18],cx ;登记TSS选择子到TCB
mov word [es:esi+0x04],0xffff ;任务的状态为“忙”
;任务寄存器TR中的内容是任务存在的标志,该内容也决定了当前任务是谁。
;下面的指令为当前正在执行的0特权级任务“程序管理器”后补手续(TSS)。
ltr cx
;现在可认为“程序管理器”任务正执行中
mov ebx,core_msg1
call sys_routine_seg_sel:put_string
07. 用户任务的创建和初始化
TCB中偏移为0x04位置:
任务状态为0表示就绪、为0xFFFF表示忙状态、为0x3333表示任务已经终止。
为用户任务创建TCB:
;以下开始创建用户任务
mov ecx,0x46
call sys_routine_seg_sel:allocate_memory
mov word [es:ecx+0x04],0 ;任务状态:就绪
call append_to_tcb_link ;将此TCB添加到TCB链中
之后加载和重定位用户程序,并将其创建为任务。
push dword 50 ;用户程序位于逻辑50扇区
push ecx ;压入任务控制块起始线性地址
call load_relocate_program
在例程load_relocate_program中:
创建LDT;
加载用户程序;
创建用户程序每个段的描述符,并将其安装到LDT中;
重定位用户程序的符号地址检索表SALT,本章在SALT中新增一个条目InitTaskSwitch用于任务切换。
创建0、1、2特权级的栈段描述符及其选择子,为通过调用门转移控制而准备的;
在GDT中登记LDT描述符;
创建用户任务的TSS;
在用户任务的TSS中登记相关的信息,参考TSS结构;
- 填写任务的反向链
- 填写0、1、2特权级的栈段选择子和栈指针
- 登记LDT选择子
- 登记I/O许可位映射区偏移
- 登记T标志(TSS+100的调试位)
- 登记CR3(和分页有关)
- 登记其它信息
- 以前内核不是单独的任务而是用户任务的私有部分,所以用户程序加载之后模拟调用门返回,从任务的全局部分返回任务的私有部分。本章中内核为一个独立的任务,是正在执行的任务,所以当我们创建了用户任务之后将使用任务切换的方式从内核任务切换到用户任务。
- 切换到用户任务时,一定会从用户任务的TSS中恢复现场,即使是用户任务的第一次执行,为了确保用户任务的第一次切换成功,需要在用户任务的TSS中设置哪些内容呢?
- 首先是0、1、2特权级的栈段选择子和栈指针;接着是通用寄存器的内容,一般都是运行时自动设置,也有一些需要单独设置(如EFLAGS中的LOPL字段、EIP要设置为用户任务入口点的偏移量)。
- 段寄存器的内容,可以提前设置也可以在程序中用指令初始化,CS必须在这里设置为用户程序入口点的代码段选择子。
- 若用户任务有LDT,则需要设置LDT段选择子。
- 若用户任务有I/O许可位映射区,则需要设置映射区的偏移。
创建用户任务的TSS描述符,并将其安装在GDT中,安装之后在CX中返回TSS的选择子,将其登记在用户任务控制块TCB中。
从例程load_relocate_program中返回。
创建一个用户任务之后还可以创建其它任务:
;可以创建更多的任务,例如:
;mov ecx,0x46
;call sys_routine_seg_sel:allocate_memory
;mov word [es:ecx+0x04],0 ;任务状态:空闲
;call append_to_tcb_link ;将此TCB添加到TCB链中
;push dword 50 ;用户程序位于逻辑50扇区
;push ecx ;压入任务控制块起始线性地址
;call load_relocate_program
之后就是任务管理的循环,用来发起内核任务到用户任务的切换、回收已经终止任务的资源、也可以选择创建新的任务。
.do_switch:
;主动切换到其它任务,给它们运行的机会
call sys_routine_seg_sel:initiate_task_switch
mov ebx,core_msg2
call sys_routine_seg_sel:put_string
;这里可以添加创建新的任务的功能,比如:
;mov ecx,0x46
;call sys_routine_seg_sel:allocate_memory
;mov word [es:ecx+0x04],0 ;任务状态:空闲
;call append_to_tcb_link ;将此TCB添加到TCB链中
;push dword 50 ;用户程序位于逻辑50扇区
;push ecx ;压入任务控制块起始线性地址
;call load_relocate_program
;清理已经终止的任务,并回收它们占用的资源
call sys_routine_seg_sel:do_task_clean
mov eax,[tcb_chain]
.find_ready:
cmp word [es:eax+0x04],0x0000 ;还有处于就绪状态的任务?
jz .do_switch ;有,继续执行任务切换
mov eax,[es:eax]
or eax,eax ;还有用户任务吗?
jnz .find_ready ;一直搜索到链表尾部
;已经没有可以切换的任务,停机
mov ebx,core_msg3
call sys_routine_seg_sel:put_string
hlt
08. 简单的任务调度和切换策略
接上一节
所有的任务都是平等的参与任务切换,切换到用户任务时做的是自己的私事,切换到内核任务时做的是管理整个系统。
使用例程initiate_task_switch进行任务切换,从任务链表中找到下一个就绪状态的任务,进行切换。tcb_chain中记录每个任务的TCB,其中有下一个任务的地址和这个任务的状态(0表示就绪、为0xFFFF表示忙状态、为0x3333表示任务已经终止)。
任务调度策略:顺着TCB链表找到当前正在执行的任务,继续往后再找到一个就绪的任务,然后切换到这个就绪的任务。切换之后将任务的状态都进行改变(忙改为就绪、就绪改为忙)。
特殊情况1:如上图,如果系统只有一个任务则不执行切换。
特殊情况2:如上图,每次都是从tcb_chain链表头部开始搜索,先找到忙的任务再往后找到就绪的任务。如果忙的任务处于链表尾部,则需要返回链表头部从头开始查找状态为就绪的任务,这样的轮转能保证每个任务都能有公平的轮转机会。
09. 遍历TCB链表寻找忙任务和就绪任务
本节是分析例程initiate_task_switch,具体看代码和视频。
10. 通过JMP FAR执行任务切换的过程
上一节中找到了状态忙的任务和状态就绪的任务。
在64位处理器上不再提供硬件任务切换,操作系统也不适用硬件任务切换
使用**jmp far [edi+0x14]**指令进行任务切换:
- EDI保存就绪任务的TCB线性地址,TCB偏移0x14的地方,存放TSS的基地址和16位TSS选择子;
- 处理器执行这条指令时使用DS描述符高速缓存器中的基地址 + 段内偏移(EDI + 0X14),取出6个字节,假定它们是段选择子和段内偏移;当处理器发现后两个字节保存的段选择子是TSS选择子,则前面的4个字节段内偏移量会被忽略(实际上我们在这里保存的是TSS基地址)。
- 用这个选择子到GDT中寻找对应的描述符,处理器发现这是TSS描述符,就知道需要发起任务切换
- 保存旧任务的状态
当前CS指向内核公共例程段,EIP指向下一条指令,则保存状态时,CS和EIP保存的是上述值。
- 设置新任务的状态
第一次是从内核任务切换到用户任务,在创建用户任务时,已经在用户任务的TSS中登记了各种信息,包括0、1、2三个特权级的栈段选择子SS和栈指针ESP,接着登记LDT选择子,之后是入口点信息,包括CS、EIP。一旦从用户任务的TSS中恢复这些信息,处理器就转移到了用户程序执行。
11. 内核任务与用户任务轮流执行的过程
看视频、代码即可,主要讲解了从内核任务切换到用户任务的执行流程。
12. 任务的中止和清理
看视频、代码即可,主要讲解了从用户任务切换到内核任务的执行流程,其中会对任务进行清理。
虚拟机执行结果:
- 标题: 协同式任务切换
- 作者: xiaoeryu
- 创建于 : 2022-12-11 22:47:00
- 更新于 : 2023-10-03 13:00:13
- 链接: https://github.com/xiaoeryu/2022/12/11/29-协同式任务切换/
- 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。