30天自制操作系统 下载本文

1 挑战任务切换(harib12a) …… 283

■■■■■

稍稍思考一下我们就会发现,多任务这个东西还真是奇妙,它究竟是怎样做到让多个程序同 时运行的呢?如果我们的电脑里面装了好多个CPU的话,同时运行多个程序倒也顺理成章,但实 际上就算我们只有一个CPU,照样可以实现多任务。

其实说穿了,这些程序根本没有在同时运行,只不过看上去好像是在同时运行一样:程序A 运行一会儿,接下来程序B运行一会儿,再接下来轮到程序C,然后再回到程序A……如此反复, 有点像日本忍者的―分身术‖呢(笑)。

为了让这种分身术看上去更完美,需要让操作系统尽可能快地切换任务。如果10秒才切换一 次,那就连人眼都能察觉出来了,同时运行多个程序的戏码也就穿帮了。再有,如果我们给程序 C发出一个按键指令,正巧这个瞬间系统切换到了程序A的话,我们就不得不等上20秒,才能重 新轮到程序C对按键指令作出反应。这实在是让人抓狂啊(哭)。

在一般的操作系统中,这个切换的动作每0.01~0.03秒就会进行一次。当然,切换的速度越 快,让人觉得程序是在同时运行的效果也就越好。不过,CPU进行程序切换(我们称为―任务切 换‖)这个动作本身就需要消耗一定的时间,这个时间大约为0.0001秒左右,不同的CPU及操作 系统所需的时间也有所不同。如果CPU每0.0002秒切换一次任务的话,该CPU处理能力的50%都 要被任务切换本身所消耗掉。这意味着,如果同时运行2个程序,每个程序的速度就只有单独运 行时的1/4,这样你会觉得开心吗?如果变成这种结果,那还不如干脆别搞多任务呢。

相比之下,即便是每0.001秒切换一次任务,单单在任务切换上面也要消耗CPU处理能力的 10%。大概有人会想,10%也没什么大不了的吧?可如果你看看速度快10%的CPU卖多少钱,说 不定就会恍然大悟,―对啊,只要优化一下任务切换间隔,就相当于一分钱也不花,便换上了比 现在更快的CPU嘛……‖(笑),你也就明白了浪费10%也是很不值得的。正是因为这个原因,任 务切换的间隔最短也得0.01秒左右,这样一来只有1%的处理能力消耗在任务切换上,基本上就可 以忽略不计了。

■■■■■

1 2 3 4 5 6 7 8

9 10 11 12 13 14 14 15

284 …… 第 15 天:多任务(1)

关于多任务是什么的问题,已经大致讲得差不多了,接下来我们来看看如何让CPU来处理多 任务。

当你向CPU发出任务切换的指令时,CPU会先把寄存器中的值全部写入内存中,这样做是为 了当以后切换回这个程序的时候,可以从中断的地方继续运行。接下来,为了运行下一个程序, CPU会把所有寄存器中的值从内存中读取出来(当然,这个读取的地址和刚刚写入的地址一定是 不同的,不然就相当于什么都没变嘛),这样就完成了一次切换。我们前面所说的任务切换所需 要的时间,正是对内存进行写入和读取操作所消耗的时间。

■■■■■

接下来我们来看看寄存器中的内容是怎样写入内存里去的。下面这个结构叫做―任务状态段‖ (task status segment),简称TSS。TSS有16位和32位两个版本,这里我们使用32位版。顾名思义, 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字节(摘自CPU的技术资料),我 特意把它们分成4行来写。从开头的backlink起,到cr3为止的几个成员,保存的不是寄存器的 数据,而是与任务设置相关的信息,在执行任务切换的时候这些成员不会被写入(backlink除 外,某些情况下是会被写入的)。后面的部分中我们会用到这里的设定,不过现在你完全可以 先忽略它。

第2行的成员是32位寄存器,第3行是16位寄存器,应该没必要解释了吧……不对,eip好像 到现在还没讲过呢。EIP的全称是―extended instruction pointer‖,也就是―扩展指令指针寄存器‖ 的意思。这里的―扩展‖代表它是一个32位寄存器,也就是说其对应的16位版本叫做IP,类比一 下的话,跟EAX与AX之间的关系是一样的。

EIP是CPU用来记录下一条需要执行的指令位于内存中哪个地址的寄存器,因此它才被称为 ―指令指针‖。如果没有这个寄存器,记性不好的CPU就会忘记自己正在运行哪里的程序,于是程 序就没办法正常运行了。每执行一条指令,EIP寄存器中的值就会自动累加,从而保证一直指向 下一条指令所在的内存地址。

说点题外话,JMP 指令实际上是一个向 EIP 寄存器赋值的指令。JMP 0x1234 这种写法, CPU 会解释为 MOV EIP,0x1234,并向 EIP 赋值。也就是说,这条指令其实是篡改了 CPU 记 忆中下一条该执行的指令的地址,蒙了 CPU 一把。这样一来,CPU 在读取下一条指令时,

1 挑战任务切换(harib12a) …… 285

就会去读取 0x1234 这个地址中的指令。你看,这不就相当于是做了一个跳转吗? 对了,如果你在汇编语言里用MOV EIP,0x1234这种写法是会出错的,还是不要尝试的 好。在汇编语言中,应该使用JMP 0x1234来代替MOV EIP,0x1234。

如果在TSS中将EIP寄存器的值记录下来,那么当下次再返回这个任务的时候,CPU就可以明 白应该从哪里读取程序来运行了。

按照常识,段寄存器应该是16位的才对,可是在TSS数据结构中却定义成了int(也就是

DWORD)类型。我们可以大胆想象一下,说不定英特尔公司的人将来会把段寄存器变成32位的, 这样想想也挺有意思的呢(笑)。

第4行的ldtr和iomap也和第1行的成员一样,是有关任务设置的部分,因此在任务切换时不会 被CPU写入。也许你会想,那就和第1行一样,暂时先忽略好了——但那可是绝对不行的!如果 胡乱赋值的话,任务就无法正常切换了,在这里我们先将ldtr置为0,将iomap置为0x40000000就 好了。

■■■■■

关于TSS的话题暂且先告一段落,我们回来继续讲任务切换的方法。要进行任务切换,其实 还得用JMP指令。JMP指令分为两种,只改写EIP的称为near模式,同时改写EIP和CS的称为far模 式,在此之前我们使用的JMP指令基本上都是near模式的。不记得CS是什么了?CS就是代码段 (code segment)寄存器啦。

说起来我们其实用过一次far模式的JMP指令,就在asmhead.nas的―bootpack启动‖的最后一 句(见8.5节)。

JMP

DWORD 2*8:0x0000001b

这条指令在向EIP存入0x1b的同时,将CS置为2*8(=16) 像这样在JMP目标地址中带冒号。(:) 的,就是far模式的JMP指令。

如果一条JMP指令所指定的目标地址段不是可执行的代码,而是TSS的话,CPU就不会执行

通常的改写EIP和CS的操作,而是将这条指令理解为任务切换。也就是说,CPU会切换到目标TSS 所指定的任务,说白了,就是JMP到一个任务那里去了。

CPU 每次执行带有段地址的指令时,都会去确认一下 GDT 中的设置,以便判断接下来 要执行的 JMP 指令到底是普通的 far-JMP,还是任务切换。也就是说,从汇编程序翻译出来 的机器语言来看,普通的 far-JMP 和任务切换的 far-JMP,指令本身是没有任何区别的。

■■■■■

1 2 3 4 5 6 7 8 9 10 11 12 13 14 14

15

286 …… 第 15 天:多任务(1)

好了,枯燥的讲解就到这里,让我们实际做一次任务切换吧。我们准备两个任务:任务A和 任务B,尝试从A切换到B。

首先,我们需要创建两个TSS:任务A的TSS和任务B的TSS。 本次的HariMain节选

struct TSS32 tss_a, tss_b;

向它们的ldtr和iomap分别存入合适的值。 本次的HariMain节选

tss_a.ldtr = 0;

tss_a.iomap = 0x40000000; tss_b.ldtr = 0;

tss_b.iomap = 0x40000000;

接着将它们两个在GDT中进行定义。 本次的HariMain节选

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也采用类似的定义。

■■■■■

现在两个TSS都创建好了,该进行实际的切换了。

我们向TR寄存器存入3 * 8这个值,这是因为我们刚才把当前运行的任务定义为GDT的3号。 TR寄存器以前没有提到过,它的作用是让CPU记住当前正在运行哪一个任务。当进行任务切换 的时候,TR寄存器的值也会自动变化,它的名字也就是―task register‖(任务寄存器)的缩写。 我们每次给TR寄存器赋值的时候,必须把GDT的编号乘以8,因为英特尔公司就是这样规定的。 如果你有意见的话,可以打电话找英特尔的大叔投诉哦(笑)。

给TR寄存器赋值需要使用LTR指令,不过用C语言做不到。唉,各位是不是都已经见怪不

怪了啊?啥?你早就料到了?(笑)所以说,正如你所料,我们只能把它写进naskfunc.nas里面。 本次的HariMain节选

load_tr(3 * 8);