本篇内容开始讲解多任务。本篇内容结构很简单,先讲解任务切换的原理,再讲解任务切换的代码实践。但是涉及到的知识不少,理解上也有些难度。
1. 任务切换与多任务原理
1.1 多任务与任务切换
所谓多任务,指的是操作系统同时运行多个任务。但是这种说法实际上是不准确的。如果只有一个CPU,是无法事实上实现同时运行多个任务的。而之所以给用户以多个任务在同时运行的错觉,其实是因为多个任务之间在快速地切换。
为了造成这种错觉,切换的间隔时间不能很长;但同时,过于频繁地切换又会严重消耗CPU的处理能力。二者平衡来看,一般的操作系统选择每0.01s进行一次切换,这样消耗在切换过程的CPU处理能力大概是1%,就可以忽略不计了。
讲清楚了多任务与任务切换的关系,下面来讲任务切换的过程。
1.2 任务切换过程
CPU接收到任务切换指令时,会将所有寄存器的值保存在内存中。这是为了以后切换回来时可以从中断的地方继续运行。接下来,为了运行下一个程序,CPU又会从内存中取出另一组寄存器的值,完成一次切换。而切换所需的时间,实际上就是从内存读写寄存器的时间。
1.3 TSS
寄存器中的内容如何写入内存呢?这里引入一种数据结构TSS(Task status segment,任务状态段)。TSS也是内存段的一种,需要在GDT中进行注册才能使用。
struct TSS32{int backlink, esp0, ss0, esp1, ss1, esp2, ss2, cr3;int eip, eflags, eax, ecx, edx, ebx, esp, ebp, esi, edi;int es, cs, ss, ds, fs, gs;int ldtr, iomap;
}
TSS中的内容有26个int成员,共104字节。第一行的内容与任务设置相关,可以暂时忽略;第二行是32位寄存器,第三行是16位寄存器。EIP是“extended instruction pointer”的缩写,扩展指令指针寄存器。E表示是32位的寄存器,16位的版本就是IP。EIP中存放的是CPU下一条需要执行指令的地址。每执行一条指令,EIP寄存器中的值会自动累加,保证一直指向下一条需要执行的指令。
实际上JMP指令也利用了EIP寄存器。JMP 0x1234实际执行了向EIP赋值,改变EIP的值后,下一条指令就从新的地址取出,也就实现了跳转。
将EIP的值保存下来,切换回来的时候CPU就知道从哪里开始继续执行了。
第四行的ldtr和iomap也是与任务设置相关的部分,需要正确赋值。这里暂时将ldtr设置位0,将iomap设置为0x40000000。
1.4 任务切换实践
TSS讲解完了,继续来看任务切换的过程。进行任务切换实际上还是需要用到JMP指令。JMP指令分为两种:只改写EIP的称为near模式,同时改写EIP和CS的称为far模式。CS是代码段寄存器,修改了CS就表示要跳转到其他的段了。
如果一条JMP指令所指定的目标地址段不是可执行的代码,而是TSS,那么CPU就不会执行通常的改写CS与EIP的操作,而是将这条指令理解为任务切换。
1.4.1 切换前的任务设置
接下来实践一下,准备两个任务A和B,做从A切换到B的操作。
首先创建两个任务的TSS:
struct TSS32 tss_a, tss_b;
给他们的ldtr和iomap赋值为合适的值:
tss_a.ldtr = 0;
tss_a.iomap = 0x40000000;
tss_b.ldtr = 0;
tss_b.iomap = 0x40000000;
此外还要注册到GDT中:
struct SEGMENT_DESCRIPTOR *gdt = (struct SEGMENT_DESCRIPTOR *) ADR_GDT;set_segmdesc(gdt + 3, 103, (int) &tss_a, AR_TSS32);set_segmdesc(gdt + 4, 103, (int) &tss_b, AR_TSS32);
将tss_a定义为gdt的3号,段长限制为103字节,tss_b也采用类似的定义。
TR(task register)寄存器存放的是当前执行的任务,进行任务切换的时候,TR寄存器的值也会发生变化。我们给TR寄存器赋值为3*8,即GDT的3号,因为给TR寄存器赋值需要将GET编号乘以8。给TR寄存器赋值需要通过汇编语言的LTR指令:
load_tr(3 * 8);_load_tr: ; void load_tr(int tr);LTR [ESP+4] ; trRET
1.4.2 任务切换过程
接下来还要执行far模式的跳转指令,这里还是需要用汇编语言进行编写。
_taskswitch4: ; void taskswitch4(void);JMP 4*8:0RET
通常情况下,JMP指令后面的RET指令是没有意义的。但是对于用作任务切换的JMP指令,重新返回这个任务时,程序会从这条JMP指令之后继续运行。这里就是执行RET,从汇编语言函数返回C语言主程序。
如果far-JMP指令用于任务切换,则地址段4*8一定要指向TSS,而偏移量则可以忽略,这里写为0即可。
执行切换的函数写好了,我们在主程序中调用就可以实现切换。在哪里调用呢?我们放在超时10s的处理里面:
else if (i == 10) { /* 10s计时器} */putfonts8_asc_sht(sht_back, 0, 64, COL8_FFFFFF, COL8_008484, "10[sec]", 7);taskswitch4();}
这样程序启动10s后,就会执行切换。
到这里切换的过程就完成了吗?其实还没有。运行taskswitch4函数可以切换到任务B,但我们还没有设置好任务B的TSS,这些工作其实是在初始化时完成的。
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;
从后半段寄存器赋值来看,给CS赋值为GDT的2号,其他的寄存器设置为1号,其实是使用了与bootpack.c相同的地址段。使用其他的地址段也没有问题这里只是为了举个例子。
在eip中需要定义好切换到这个任务时从哪里开始运行,于是把task_b_main的地址赋值给eip。task_b_main就是任务B要运行的函数,目前其实什么都没做,只是执行了HLT。
void task_b_main(void)
{for (;;) { io_hlt(); }
}
task_b_esp是为任务B定义的栈。切换任务的时候,每个任务都有自己专门的栈。
int task_b_esp;
task_b_esp = memman_alloc_4k(memman, 64 * 1024) + 64 * 1024;
到这里也就切换的过程也就全部完成了。由于任务B只是执行HLT,所以运行的结果是10s之后停住,鼠标和键盘都没有反应了。
完成了切换到任务B,我们再从任务B切换回任务A。
void task_b_main(void)
{struct FIFO32 fifo;struct TIMER *timer;int i, fifobuf[128];fifo32_init(&fifo, 128, fifobuf);timer = timer_alloc();timer_init(timer, &fifo, 1);timer_settime(timer, 500);for (;;) {io_cli();if (fifo32_status(&fifo) == 0) {io_sti();io_hlt();} else {i = fifo32_get(&fifo);io_sti();if (i == 1) { /* 超时时间为5s */taskswitch3(); /* 返回任务A */}}}
}_taskswitch3: ; void taskswitch3(void);JMP 3*8:0RET
改写后的任务B程序与主程序类似,并且定义了一个5s的定时器。超时时间一到,就执行taskswitch3切换回任务A。有了前面的基础,这些修改也不难理解了。
1.5 多任务实践
完成了任务切换的功能,只需要再实现快速交替切换任务,就实现了多任务的目的,也不难做到。
首先将任务切换的函数改写的更加通用一些。
_farjmp: ; void farjmp(int eip, int cs);JMP FAR [ESP+4] ; eip, csRET
使用JMP FAR指令时,需要指定一个地址。CPU会从指定的地址中读出4字节数据存入EIP,再继续读取2字节数据存入CS。这样我们调用_farjump(eip,cs)时,在[ESP + 4]的位置就存放了EIP的值,[ESP + 8]的位置则存放了CS的值,就可以实现预期的JMP FAR了。
因此taskswitch3就可以改写为farjmp(0, 38),taskswitch4就可以改写成farjmp(0, 48)。
至于缩短时间间隔,我们只需要在任务A和任务B中分别准备一个0.02s的定时器,每隔0.02s就执行一次切换,这样就完成了。
timer_ts = timer_alloc();timer_init(timer_ts, &fifo, 2);timer_settime(timer_ts, 2);for (;;) {io_cli();if (fifo32_status(&fifo) == 0) {io_stihlt();} else {i = fifo32_get(&fifo);io_sti();if (i == 2) {farjmp(0, 4 * 8);timer_settime(timer_ts, 2);
……
可以看出主程序也就是任务A中设置了定时器ts,达到0.02s的超时时间后就执行切换,而切换返回后再执行timer_settime重新设置超时时间。
void task_b_main(void)
{struct FIFO32 fifo;struct TIMER *timer_ts;int i, fifobuf[128], count = 0;char s[11];struct SHEET *sht_back;fifo32_init(&fifo, 128, fifobuf);timer_ts = timer_alloc();timer_init(timer_ts, &fifo, 1);timer_settime(timer_ts, 2);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);io_cli();if (fifo32_status(&fifo) == 0) {io_sti();} else {i = fifo32_get(&fifo);io_sti();if (i == 1) { /* 任务切换 */farjmp(0, 3 * 8);timer_settime(timer_ts, 2);}}}
}
任务B的程序也与此类似。但如何确认任务B确实在运行呢?这里我们让任务B执行计数功能。不过还存在一个问题,任务B中没有定义sht_back变量,需要在切换的时候传进来。如何传进来呢?这里先比较随便地将sht_back存在一个地址0x0fec中,切换到任务B时再从这个地址中获取。
*((int *) 0x0fec) = (int) sht_back;sht_back = (struct SHEET *) *((int *) 0x0fec);
这样运行一下,由于切换速度很快,就给人以同时运行的感觉。
但是通过一个随意的地址来传送sht_back变量肯定是不合适的。从汇编语言的角度考虑,传入的参数就存放在内存地址ESP+4中,因此可以进行如下改写:
task_b_esp = memman_alloc_4k(memman, 64 * 1024) + 64 * 1024 - 8;*((int*)(task_b_esp+ 4)) = (int)sht_back;
分配的内存地址为64K,假设是从0x01234000开始,则task_b_esp的地址为0x0123ff8,ESP+4的地址即为0x0123ffe。从这里写入4字节,恰好不会超出64KB的空间。而运行B任务时,ESP+4的地址中已经存入了sht_back变量,B任务就会将其作为参数进行处理了。
在task_b_main程序中是不能使用return语句的。因为return语句归根结底是返回函数调用位置的一条JMP指令。由于task_b_mian这个程序不是由其他程序直接调用的,没有确定的调用位置,使用return会使程序无法正常运行。
到这里我们已经实现了一种多任务,但却还不是真正的多任务。因为当前的任务切换函数在任务A和任务B中执行,如果任务自身出了问题,可能会出现无法切换的情况。所谓真正的多任务,是在程序本身没有感知的情况下实现任务切换。
创建这样一个函数:
struct TIMER *mt_timer;
int mt_tr;void mt_init(void)
{mt_timer = timer_alloc();timer_settime(mt_timer, 2);mt_tr = 3 * 8;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;
}
mt_init函数设置了初始化了mt_tr的值,并设置了一个0.02s的定时器。这里超时后不向fifo中写入数据,因此不需要使用timer_init。mt_tr实际存放了TR寄存器的值,mt_taskswitch则根据当前mt_tr的值确定下一个mt_tr的值,重新设置定时器并且通过farjmp实行切换,还是比较简单的。
这样我们也需要修改一下inthandler20函数。
void inthandler20(int *esp)
{struct TIMER *timer;char ts = 0;io_out8(PIC0_OCW2, 0x60); timerctl.count++;if (timerctl.next > timerctl.count) {return;}timer = timerctl.t0;for (;;) {if (timer->timeout > timerctl.count) {break;}/* 超时 */timer->flags = TIMER_FLAGS_ALLOC;if (timer != mt_timer) {fifo32_put(timer->fifo, timer->data);} else {ts = 1; /* mt_timer超时 */}timer = timer->next; }timerctl.t0 = timer;timerctl.next = timer->timeout;if (ts != 0) {mt_taskswitch();}return;
}
如果是mt_timer发生了超时,则将ts变量设置为1,在主程序中判断如果ts变量不为0,则执行mt_taskswitch进行任务切换。
为什么不在中断处理函数inthandler20中直接执行任务切换呢?
原因在于调用mt_taskswitch进行任务切换的过程中,中断允许标志IF的值可能会被重设为1(因为切换任务的同时会切换EFLAGS)。如果此时中断处理还没完成,开启中断,可能会有下一个中断进来,这样就会导致程序出错。
本篇的内容终于完成了。关于任务切换的基本过程,不清楚的知识还真不少,阅读了三遍才算基本理清。下一篇继续硬核的多任务,敬请期待。