《30天自制操作系统》笔记(12)——多任务入门
进度回顾
介绍了设置显示器高分辨率的方法。本篇讲一下操作系统实现多任务的方法。
什么是多任务
对程序员来说,也许这是废话,不过还是说清楚比较好。
多任务就是让电脑同时运行多个程序(如一边写代码一边听音乐一边下载电影)。
电脑的CPU只有固定有限的那么一个或几个,不可能真的同时运行多个程序。所以就用近似的方式,让多个程序轮换着运行。当轮换速度够快(0.01秒),给人的感觉就是"同时"运行了。
多任务之不实用版
我们首先从最基本的想法开始,做一个不实用版的多任务作为例子。在学习这个例子的过程中引入真正的多任务必须的TSS、TR、far模式JMP的概念,为后续内容打基础。
当你向CPU发出任务切换的指令时,CPU会先把寄存器中的值全部写入内存某处;然后,从内存另一位置把所有寄存器的值读取出来。这就完成了一次任务切换。
任务切换消耗的时间就是读写内存消耗的时间,大概为0.0001秒。
任务状态段TSS
存取全部寄存器的值这件事,当然需要有一个数据结构,这就是"任务状态段"(Task Status Segment)简称TSS。
1 struct TSS322 {3 int backlink, esp0, ss0, esp1, ss1, esp2, ss2, cr3;4 int eip, eflags, eax, ecx, edx, ebx, esp, ebp, esi, edi;5 int es, cs, ss, ds, fs, gs;6 int ldtr, iomap;7 };
TSS32中第一行(从backlink到cr3)暂时不用理会。
第二、三行(从eip到gs)都是寄存器。其中EIP是CPU用来记录下一条需要执行的指令位于内存中的地址的寄存器,因此被称为"指令指针"。实际上JMP指令就是修改了EIP的值。
第四行也不用理会。
TSS中的信息会存储到内存某处(记为X),而X的地址会注册到GDT中。()
寄存器TR
寄存器TR是作用是让CPU记住当前在运行哪个任务。其存储的值是"当前任务所在的段号*8"。只需在操作系统启动时对其赋值一次,以后进行任务切换时,CPU会自动调整TR的值。给TR赋值只能用汇编实现。
1 _load_tr: ; void load_tr(int tr);2 LTR [ESP+4] ; tr3 RET
LTR指令只是改变TR的值,不会发生任务切换。所以我感觉TR像是一个标识变量。正是由于这一点我才有了后文的猜想。
切换任务就是执行JMP指令
JMP指令分两种,即"只改写EIP的near模式"与"同时改写EIP和CS的far模式"。CS是代码段寄存器(code segment)。
平时使用的都是near模式。
在asmhead.nas中跳转到bootpack.c中的主函数用的是far模式,即
这条指令在向EIP写入0x1b时,也向CS写入2*8(即16)。
像这样在JMP目标地址中带冒号(:)的,就是far模式。
切换任务时,我们使用far模式的JMP指令。
CPU执行far模式的JMP指令前,会根据GDT中注册的TSS情况,判断JMP的目标地址是可执行代码还是TSS。如果是可执行代码,那么CPU就认为这只是一个普通的far模式的JMP;如果是TSS,则认为这是一个任务切换指令,会切换到目标地址指定的TSS所记录的任务中,也就是JMP到另一个任务那里去了。
所以普通的far模式的JMP和任务切换的JMP指令,其机器码是同一个。
Demo:两个任务切换
我们把操作系统启动时运行的程序记作任务A,即如下代码。
1 void HariMain(void) 2 { 3 /* 略 */ 4 timer_ts = timer_alloc(); 5 timer_init(timer_ts, &fifo, 2); 6 timer_settime(timer_ts, 2); 7 /* 略 */ 8 for (;;) { 9 io_cli();10 if (fifo32_status(&fifo) == 0) {11 io_stihlt();12 } else {13 i = fifo32_get(&fifo);14 io_sti();15 if (i == 2) {16 farjmp(0, 4 * 8);17 timer_settime(timer_ts, 2);18 } else if (256 <= i && i <= 511) { /* 键盘数据 */19 /* 略 */20 } else if (512 <= i && i <= 767) { /* 鼠标数据 */21 /* 略 */22 } else if (i == 10) { /* 10秒计时器 */23 putfonts8_asc_sht(sht_back, 0, 64, COL8_FFFFFF, COL8_008484, "10[sec]", 7);24 } else if (i == 3) { /* 3秒计时器 */25 putfonts8_asc_sht(sht_back, 0, 80, COL8_FFFFFF, COL8_008484, "3[sec]", 6);26 } else if (i <= 1) { /* 光标用计时器 */27 /* 略 */28 }29 }30 }31 }
下面是任务B执行的函数。
1 void task_b_main(void) 2 { 3 struct FIFO32 fifo; 4 struct TIMER *timer_ts; 5 int i, fifobuf[128]; 6 7 fifo32_init(&fifo, 128, fifobuf); 8 timer_ts = timer_alloc(); 9 timer_init(timer_ts, &fifo, 1);10 timer_settime(timer_ts, 2);11 12 for (;;) {13 io_cli();14 if (fifo32_status(&fifo) == 0) {15 io_sti();16 io_hlt();17 } else {18 i = fifo32_get(&fifo);19 io_sti();20 if (i == 1) { /* 任务切换 */21 farjmp(0, 3 * 8);22 timer_settime(timer_ts, 2);23 }24 }25 }26 }
任务A执行0.02秒后就进入farjmp(0, 4 * 8);,从而自行切换到任务B,任务B执行0.02秒后就进入farjmp(0, 3 * 8);,从而自行切换到任务A。周而复始。
像这种在应用代码中编写任务切换的方式,明显不实用。不过用于研究多任务还是很方便的。
多任务截图没有意义,就此作罢。
真正的多任务
大体上说,实现多任务的方法就是利用前面提到的定时器PIT(Programmable Interval Timer)能够定时产生中断的功能,在其中断处理函数中实现任务切换的目的。
时间片轮转调度算法是一种最基本的任务调度算法,它让每个任务依次执行相同的一段时间(如0.01秒)。在此基础上,可以为任务添加"休眠"、"优先级"等功能和属性,根据属性值调整执行时间和执行顺序。
1 void inthandler20(int *esp) 2 { 3 /* 略 */ 4 char ts = 0; 5 for (;;) { 6 /* timers的计时器全部在工作中,因此不用确认flags */ 7 if (timer->timeout > timerctl.count) { 8 break; 9 }10 /* 超时 */11 timer->flags = TIMER_FLAGS_ALLOC;12 if (timer != mt_timer) {13 fifo32_put(timer->fifo, timer->data);14 } else {15 ts = 1; /* mt_timer超时 */16 }17 timer = timer->next; /* 将下一个计时器的地址赋给timer */18 }19 timerctl.t0 = timer;20 timerctl.next = timer->timeout;21 if (ts != 0) {22 mt_taskswitch();23 }24 return;25 }26 void mt_taskswitch(void)27 { /* demo演示只有两个固定任务的切换过程 */28 if (mt_tr == 3 * 8) {29 mt_tr = 4 * 8;30 } else {31 mt_tr = 3 * 8;32 }33 timer_settime(mt_timer, 2);34 farjmp(0, mt_tr);35 return;36 }
为了简化非核心代码,我用demo版的mt_taskswitch(void)代替了有复杂数据结构的真实版本,这样方便理解整个代码的原理。其中的farjmp是用汇编实现的。
1 _farjmp: ; void farjmp(int eip, int cs);2 JMP FAR [ESP+4] ; eip, cs3 RET
根据C语言编译器的规则,调用这个farjmp函数时,在[ESP+4]处存放了EIP的值,在 [ESP+8]处存放了CS的值。给eip赋值0,给cs赋值要切换到的任务所在的段号(乘8),就可以正确调用farjmp。
一般发生JMP后,不会执行后面的RET指令了。但是,执行任务切换的JMP后,再返回这个任务的时候,程序会从JMP指令之后的地方恢复运行,也就是JMP后面这个RET指令会被执行。因此这里的RET必不可少。
任务切换的时机
我提出一个问题,任务切换是在farjmp中执行JMP FAR [ESP+4]时发生的吗?
从不实用版的代码看来,答案应该是"是"。因为确实在任务A执行了farjmp中的JMP FAR [ESP+4]指令后切换到了任务B,之后切换回任务A时,又从JMP FAR [ESP+4]指令后面的RET指令开始执行了。
但是在真正的多任务中,CPU调用中断处理函数,在inthandler20中执行了farjmp中的JMP FAR [ESP+4]。如果答案是"是",那么此时就会从中断处理函数中切换到另一个任务A中了。
可是,还会不会切换回中断处理函数inthandler20呢?
如果会,那么中断处理函数不就也成了一个任务吗?
如果会,那么中断处理函数函还没有return不就中断了吗?此时再来一个中断的话,会怎么样呢?栈就乱套了。
如果不会,那么中断处理函数最前面用汇编写的PUSH各种寄存器的指令就没有相应的POP指令了呀,时间一长栈就溢出了呀。这明显不对。
所以,我猜想只有一种情况是可行的。那就是:farjmp中的JMP FAR [ESP+4]指令并没有完成任务切换,它只是让CPU记录了一个标识(比如上文的寄存器TR的作用),标识应该运行的任务是X。然后,当CPU完成中断处理函数,再次执行某个任务A中的指令时,它会发现"现在应该执行任务X"中的指令了,所以它就切换到任务X中去。任务切换实际上此时才完成。
我查了一些资料,只能暂时作此猜想。
多任务优化
为了提高多任务运行效率,下面就对其进行优化。
休眠和唤醒
"休眠"就是从tasks链表中去掉一个任务A,"唤醒"就是把这个任务A重新加入tasks链表。
休眠的时机:任务A的消息队列为空(没有待处理的消息)时。
唤醒的时机:任务A的消息队列获得新的消息时。
任务优先级
把任务分到Level0、Level1、Level2这三个层中的一个,当Level0有活动的任务(非休眠状态)时,只在Level0的任务间切换。当Level0没有任务或均处于休眠状态时,在Level1的任务间切换。Level2同理。
闲置任务
这与"哨兵"的思路相同。就是在Level2中添加一个只HLT的任务。如果操作系统里没有其他任何任务的话,就会执行这个"哨兵"任务,即HLT掉(直至有中断发生)。
哨兵的好处就是简化代码,使得逻辑处理没有特殊情况。
总结
操作系统利用CPU的far模式的JMP指令、寄存器TR、GDT、TSS和PIT中断这些功能实现了多任务,可见CPU在设计时就考虑到了计算机要具有多任务处理的能力。也就是说,CPU、PIC等硬件支持什么功能,操作系统才能实现什么功能。这又肯定了硬件为操作系统提供API的看法。