本章开始多任务的设计。
一 多任务的说明
多任务(multitask),指的是操作系统中,多个应用程序同时运行的状态。然而,对于单核CPU来说,同一个瞬间只能处理一个事情,不能做到左右互搏、一心二用的效果,那只能通过快速切换运行任务,来实现这种所谓的多任务状态:
在一般的操作系统中,这个切换动作每0.01-0.03秒进行一次(这样CPU大概只有1%的处理能力消耗在任务切换上,可以忽略不计)。这个切换时间不能太慢(会让人感觉到程序卡顿)、也不能太快(消耗CPU的处理能力,功夫都花在切换上面,没时间处理正事了) 。
二 实现任务切换
实现任务的切换,有两个步骤:
(1)将TASK1有关寄存器的值写入到内存中;
(2)将运行TASK2需要的值从内存中读出到寄存器中;
每个任务包含的状态可以归纳为“任务状态段”(Task Status Segment,简称TSS)结构体中:
struct TSS32 {
int backlink, esp0, ss0, esp1, ss1, esp2, ss2, cr3; /* 与任务设置有关的信息 */
int eip, eflags, eax, ecx, edx, ebx, esp, ebp, esi, edi; /* 32位寄存器 */
int es, cs, ss, ds, fs, gs; /* 16位寄存器 */
int ldtr, iomap; /* 与任务设置有关的信息 */
};
TSS总计包含26个int成员(104字节),成员的内容主要是一些寄存器,具体可以参考【操作系统】CPU寄存器详解,这边主要关注一下eip指令寄存器( 指令寄存器可以说是CPU中最最重要的寄存器了,它指向了下一条要执行的指令所存放的地址,CPU的工作其实就是不断取出它指向的指令,然后执行这条指令,同时指令寄存器继续指向下面一条指令,如此不断重复,这就是CPU工作的基本日常)
对指令寄存器赋值(MOV EIP,0x1234),就相当于0x1234地址的指令,也就类似于汇编中的JMP 0x1234实现的效果。对任务实现切换,本质上就是从一个指令执行地址跳转到另一个指令的执行地址,这边还得用JMP指令,JMP指令又分为两种模式(near模式、far模式):
(1)near 模式,只改写 EIP(指令寄存器)
(2)far 模式,同时改写 EIP(指令寄存器) 和 CS(代码段寄存器)
如果一条 JMP 指令所指定的目标地段不是可执行的代码,而是 TSS 的话,CPU 就不会执行通常的改写 EIP 和 CS 操作,而是将这条指令理解为任务切换,换句话说,TSS 里面就是用来执行任务切换的代码。
根据上面的思路,按照下面的步骤就可以进行任务切换了:
【1】首先创建两个TSS:任务A的TSS和任务B的TSS。
struct TSS32 tss_a, tss_b;
【2】向他们的ldtr和iomap赋值:
tss_a.ldtr = 0;
tss_a.iomap = 0x40000000;
tss_b.ldtr = 0;
tss_b.iomap = 0x40000000;
【3】将他们两个在GDT中注册(这边可以参考一下【操作系统】30天自制操作系统--(4)显示字体(汉字)以及GDT/IDT中对于段1和段2的GDT注册):
struct SEGMENT_DESCRIPTOR *gdt = (struct SEGMENT_DESCRIPTOR *) ADR_GDT;
#define AR_TSS32 0x0089
set_segmdesc(gdt + 3, 103, (int) &tss_a, AR_TSS32);//段号3,基地址&tss_a,段长104-1
set_segmdesc(gdt + 4, 103, (int) &tss_b, AR_TSS32);//段号4,基地址&tss_b,段长104-1
【4】赋值TR寄存器(任务寄存器,存储当前正在运行哪一个任务),对它的赋值必须把GDT的编号乘以8,且对TR寄存器的操作必须在汇编的层面完成:
_load_tr: ; void load_tr(int tr)
LTR [ESP+4]
RET
【5】光改变TR的值还不能任务切换,必须执行far模式的跳转指令才行:
; 跳转到段3(任务b-->任务a)
_taskswitch3: ; void taskswitch3(void);
JMP 3*8:0
RET
; 跳转到段4(任务a-->任务b)
_taskswitch4: ; void taskswitch4(void);
JMP 4*8:0
RET
【6】上面完成了从tss_a、tss_b的注册和跳转,这边还需要准备一下tss_b的初始化和执行程序,不然跳转到一个空地址上也不顶用:
初始化如下:
int task_b_esp;
task_b_esp = memman_alloc_4k(memman, 64 * 1024) + 64 * 1024; //为任务b定义新栈
//初始化任务b的寄存器
tss_b.eip = (int) &task_b_main;
tss_b.eflags = 0x00000202; //IF = 1;
tss_b.eax = 0;
tss_b.ecx = 0;
tss_b.edx = 0;
tss_b.ebx = 0;
tss_b.esp = task_b_esp; //栈指针寄存器
tss_b.ebp = 0;
tss_b.esi = 0;
tss_b.edi = 0;
tss_b.es = 1 * 8;
tss_b.cs = 2 * 8; //代码段寄存器
tss_b.ss = 1 * 8;
tss_b.ds = 1 * 8;
tss_b.fs = 1 * 8;
tss_b.gs = 1 * 8;
任务b的执行操作如下(定时5s之后,返回任务a):
void task_b_main(void) {
struct FIFO32 fifo;
struct TIMER *timer;
int i, fifobuf[128];
fifo_init(&fifo, 128, fifobuf);
timer = timer_alloc();
timer_init(timer, &fifo, 1);
// 设置超时时间为 5 秒
timer_settime(timer, 500);
for(;;) {
io_hlt();
if (fifo32_status(&fifo) == 0) {
io_sti();
io_hlt();
} else {
i = fifo32_get(&fifo);
io_sti();
if (i == 1) {
// 返回任务 3
taskswitch3();
}
}
}
}
【7】主函数中10s超时缓存处理中执行跳转(任务a---->任务b),实现10s之后跳转任务b:
/* 中略 */
else if (i == 10) {
putfonts8_asc_sht(sht_back, 0, 64, COL8_FFFFFF, COL8_008484, "10[sec]", 7);
taskswitch4(); //切换段4(任务a-->任务b)
}
/* 中略 */
综上,便实现了一个简单的多任务操作,程序运行10s之后光标停止闪烁、鼠标键盘也失效—(任务a---->任务b成功!)。再隔了5s之后,光标恢复正常,刚鼠标键盘没反应的时候存进缓存的数据也一股脑全部冒了出来(任务b---->任务a成功!)。
三 多任务操作优化
【1】优化点一:重写一个farjump函数来替代原来的taskswitch3、taskswitch4:
_farjump: ; void farjump(int eip, int cs);
JMP FAR [ESP + 4] ; eip, cs
RET
同时在任务a和任务b中,分别准备一个timer变量叫做timer_ts(task switch),以便每隔 0.02s 切换一次:
/* HariMain */
if (i == 2) {
farjmp(0, 4 * 8);
// 任务返回之后,都将计时器设定为 0.02s 之后
timer_settime(timer_ts, 2);
}
/* task_b_main */
if (i == 1) {
// task switch
farjmp(0, 3 * 8);
timer_settime(timer_ts, 2);
}
【2】任务b中增加打印来显示确实任务切换了过来:
/* HariMain 中使用 0x0fec 这个地址将 sht_back 这个变量传给任务 B */
*((int *) 0x0fec) = (int) sht_back;
/* task_b_main */
sht_back = (struct SHEET *) *((int *) 0x0fec);
// ...
for (;;) {
count++;
sprintf(s, "%10d", count);
putfonts8_asc_sht(sht_back, 0, 144, COL8_FFFFFF, COL8_008484, s, 10);
// ...
}
【3】上面任务b的操作,每计一个数都会打印,这个伴随有操作内存的操作,过于频繁地执行会是程序变慢,处理办法是设置定时器,达到超时才会执行打印:
void task_b_main(struct SHEET *sht_back) {
struct FIFO32 fifo;
struct TIMER *timer_ts, *timer_put;
int i, fifobuf[128], count = 0;
char s[12];
fifo32_init(&fifo, 128, fifobuf);
timer_ts = timer_alloc();
timer_init(timer_ts, &fifo, 2);
timer_settime(timer_ts, 2);
timer_put = timer_alloc();
timer_init(timer_put, &fifo, 1);
// every 0.01s
timer_settime(timer_put, 1);
for (;;) {
count++;
io_cli();
if (fifo32_status(&fifo) == 0) {
io_sti();
} else {
i = fifo32_get(&fifo);
io_sti();
if (i == 1) {
sprintf(s, "%11d", count);
putfonts8_asc_sht(sht_back, 0, 144, COL8_FFFFFF, COL8_008484, s, 11);
timer_settime(timer_put, 1);
} else {
if (i == 2) {
farjmp(0, 3 * 8);
timer_settime(timer_ts, 2);
}
}
}
}
}
四 多任务操作进阶
截至目前,多任务的一些基本的切换操作已经具备,但是设想一下这个场景,比如说程序切换到任务b,但在任务b运行的过程中程序挂死,那么任务b就不会切换回其他的任务,这样单个任务异常,会使得整个程序挂死,这样会影响到程序的稳定性。
解决办法是,将跳转的逻辑封装之后从HariMain与task_b_main中抽离出来,放到定时器中断inthandler20中(关于该中断的描述参考【操作系统】30天自制操作系统--(11)定时器1),定时器中断中检查是否是多任务定时器,如果是,则直接进行切换,这样就避免了各个子任务异常对于程序整体的影响:
【1】封装子任务切换逻辑 mt_taskswitch:
#include"bootpack.h"
struct TIMER *mt_timer;
int mt_tr;
void mt_init(void){
mt_timer = timer_alloc();
// 没必要加上 timer_init() 是因为超时时不需要向 FIFO 写数据
timer_settime(mt_timer, 2);
mt_tr = 3 * 8; //初始化为子任务a
return;
}
void mt_taskswitch(void){
if (mt_tr == 3 * 8) {
mt_tr = 4 * 8;
} else {
mt_tr = 3 * 8;
}
timer_settime(mt_timer, 2);
farjmp(0, mt_tr); //切换到另一个子任务中
return;
}
【2】定时器中断 inthandler20 中调用 mt_taskswitch(这边在中断的尾巴调用的原因在于:调用 mt_taskswitch() 进行任务切换的时候,即便中断处理还没有完成,IF(interrupt flag) 也有可能被重置为 1 (因为任务切换时同时也会切换 EFLAGS),而中断处理还没完成的时候产生中断显然是不允许的):
void inthandler20(int *esp){
// 把IRQ-00接收信号结束的信息通知给PIC
io_out8(PIC0_OCW2, 0x60);
timerctl.count++;
if (timerctl.next > timerctl.count) {
return;
}
struct TIMER *timer = timerctl.t0;
char ts = 0;
while (1) {
// timers的计时器全部在工作中,因此不用确认flags
if (timer->timeout > timerctl.count) {
break;
}
timer->flags = TIMER_FLAGS_ALLOC;
if (timer != mt_timer) {
fifo32_put(timer->fifo, timer->data); //(1)不是mt_timer就往缓存中写数据
} else {
ts = 1; //(2)是mt_timer就是达了切换的时候
}
timer = timer->next;
}
timerctl.t0 = timer;
// fucking irritating ...
timerctl.next = timer->timeout;
if (ts != 0) {
mt_taskswitch(); //子任务切换
}
return;
}