多任务(1)
? 挑战任务切换(harib12a)
? 任务切换进阶(harib12b)
? 做个简单的多任务(1)(harib12c)
? 做个简单的多任务(2)(harib12d)
? 提高运行速度(harib12e)
? 测试运行速度(harib12f)
? 多任务进阶(harib12g)
1 挑战任务切换(harib12a)
“话说,多任务到底是啥呢?”我们今天的内容,就从这个问题开始吧。
多任务,在英语中叫做“multitask”,顾名思义就是“多个任务”的意思。简单地说,在Windows等操作系统中,多个应用程序同时运行的状态(也就是同时打开好几个窗口的状态)就叫做多任务。
对于生活在现代社会的各位来说,这种多任务简直是理所当然的事情。比如你会一边用音乐播放软件听音乐一边写邮件,邮件写到一半忽然有点东西要查,便打开Web浏览器上网搜索。这对于大家来说这些都是家常便饭了吧。可如果没有多任务的话会怎么样呢?想写邮件的时候就必须关掉正在播放的音乐,要查东西的时候就必须先保存写到一半的邮件,然后才能打开Web浏览器……光想象一下就会觉得太不方便了。
然而在从前,没有多任务反倒是普遍的情形(那个时候大家不用电脑听音乐,也没有互联网)。在那个年代,电脑一次只能运行一个程序,如果要同时运行多个程序的话,就得买好几台电脑才行。
就在那个时候,诞生了*初的多任务操作系统,大家都觉得太了不起了。从现在开始,我们也要准备给“纸娃娃系统”添加执行多任务的能力了。连这样一个小不点儿操作系统都能够实现多任务,真是让人不由地感叹它生逢其时呀。
■■■■■
稍稍思考一下我们就会发现,多任务这个东西还真是奇妙,它究竟是怎样做到让多个程序同时运行的呢?如果我们的电脑里面装了好多个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%的处理能力消耗在任务切换上,基本上就可以忽略不计了。
■■■■■
关于多任务是什么的问题,已经大致讲得差不多了,接下来我们来看看如何让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在读取下一条指令时,就会去读取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,指令本身是没有任何区别的。
■■■■■
好了,枯燥的讲解就到这里,让我们实际做一次任务切换吧。我们准备两个任务:任务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); 本次的naskfunc.nas节选
_load_tr: ; void load_tr(int tr);
LTR [ESP+4] ; tr
RET
对了,LTR指令的作用只是改变TR寄存器的值,因此执行了LTR指令并不会发生任务切换。
要进行任务切换,我们必须执行far模式的跳转指令,可惜far跳转这事C语言还是无能为力,这种语言还真是不方便啊。没办法,这个函数我们也得在naskfunc.nas里创建。
本次的naskfunc.nas节选
_taskswitch4: ; void taskswitch4(void);
JMP 4*8:0
RET
也许有人会问,在JMP指令后面写个RET有意义吗?也对,通常情况下确实没意义,因为已经跳转到别的地方了嘛,后面再写什么指令也不会被执行了。不过,用作任务切换的JMP指令却不太一样,在切换任务之后,再返回这个任务的时候,程序会从这条JMP指令之后恢复运行,也就是执行JMP后面的RET,从汇编语言函数返回,继续运行C语言主程序。
另外,如果far-JMP指令是用作任务切换的话,地址段(冒号前面的4*8的部分)要指向TSS这一点比较重要,而偏移量(冒号后面的0的部分)并没有什么实际作用,会被忽略掉,一般来说像这样写0就可以了。
现在我们需要在HariMain的某个地方来调用taskswitch(),可到底该写在哪里呢?唔,有了,就放在显示“10[sec]”的语句后面好了。也就是说,程序启动10秒以后进行任务切换。
本次的HariMain节选
} else if (i == 10) { /* 10秒计时器*/
putfonts8_asc_sht(sht_back, 0, 64, COL8_FFFFFF, COL8_008484, "10[sec]", 7);
taskswitch4(); /*这里! */
} else if (i == 3) { /* 3秒计时器 */
■■■■■
大功告成了?不对,我们还没准备好tss_b呢。在任务切换的时候需要读取tss_b的内容,因此我们得在TSS中定义好寄存器的初始值才行。
本次的HariMain节选
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号,其他寄存器都置为GDT的1号,asmhead.nas的时候也是一样的。也就是说,我们这次使用了和bootpack.c相同的地址段。当然,如果你用别的地址段也没问题,不过这次我们只是想随便做个任务切换的实验而已,这种麻烦的事情还是以后再说吧。
继续看剩下的部分,关于eflags的赋值,如果把STI后的EFLAGS的值通过io_load_eflags赋给变量的话,该变量的值就显示为0x00000202,因此在这里就直接使用了这个值,仅此而已。如果还有看不懂的地方,大概就是eip和esp的部分了吧。
■■■■■
在eip中,我们需要定义在切换到这个任务的时候,要从哪里开始运行。在这里我们先把task_b_main这个函数的内存地址赋值给它。
本次的bootpack.c节选
void task_b_main(void)
{
for (;;) { io_hlt(); }
}
这个函数只执行了一个HLT,没有任何实际作用,后面我们会对它进行各种改造,现在就先这样吧。
■■■■■
task_b_esp是专门为任务B所定义的栈。有人可能会说,直接用任务A的栈不就好了吗?那可不行,如果真这么做的话,栈就会混成一团,程序也无法正常运行。 本次的HariMain节选
int task_b_esp; task_b_esp = memman_alloc_4k(memman, 64 * 1024) + 64 * 1024;
总之先写成这个样子了。我们为任务B的栈分配了64KB的内存,并计算出栈底的内存地址。请各位回忆一下向栈PUSH数据(入栈)的动作,ESP中存入的应该栈末尾的地址,而不是栈开头的地址。
■■■■■
好了,我们已经讲解得够多了,现在总算是万事俱备啦,马上“make run”一下吧。这个程序如果运行正常的话应该是什么样子呢?嗯,启动之后的10秒内,还是跟以前一样的,10秒一到便执行任务切换,task_b_main开始运行。因为task_b_main只有一句HLT,所以接下来程序就全部停止了,鼠标和键盘也应该都没有反应了。
唔……这样看起来好像很无聊啊,算了,总之我们先来“make run”吧。10秒钟的等待还真是漫长……哇!停了停了!
看来我们的**任务切换获得了圆满成功。
输入到一半就停住了哦!
2 任务切换进阶(harib12b)
刚才我们只是实现了一次性从任务A切换到任务B,现在我们要尝试再切换回任务A。好,那我们就在切换到任务B的5秒后,让它再切换回任务A吧。
这其实很容易,只要稍微改写一下task_b_main就可以了。
本次的bootpack.c节选
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_stihlt();
} else {
i = fifo32_get(&fifo);
io_sti();
if (i == 1) { /*超时时间为5秒 */
taskswitch3(); /*返回任务A */
}
}
}
}
你看,这样就搞定了。在这里所使用的变量名,比如fifo、timer等,和HariMain里面是一样的,不过别担心,计算机会把它们当成不同的变量来处理。无论我们对这里的变量如何赋值,都不会影响到HariMain中的对应变量。这并不是因为它们处于不同的任务,而是因为它们名字虽然一样,但实际上根本是不同的变量(之前一直没有机会解释这一点,现在稍微晚了点,不过还是在这里讲一下吧)。
对了,taskswitch3还没有创建,我们需要创建它。
本次的naskfunc.nas节选
_taskswitch3: ; void taskswitch3(void);
JMP 3*8:0
RET
好了,准备完毕!
■■■■■
我们来“make run”一下。哇,经过10秒之后光标停止闪烁,鼠标没有反应,键盘也无法输入文字了。然而又过了5秒,光标又重新开始闪烁,刚才键盘没反应的时候打进去的字一口气全都冒了出来,鼠标也又能动了。
这就说明我们已经成功回到了任务A并继续运行了,真顺利呀。
3 做个简单的多任务(1)(harib12c)
接下来,我们要实现更快速的,而且是来回交替的任务切换。这样一来,我们就可以告别光标停住、鼠标卡死、键盘打不了字等情况,从而让两个任务看上去好像在同时运行一样。
在开始动手之前,笔者认为像taskswitch3、taskswitch4这种写法实在不好。假设我们有100个任务,难道就要创建100个任务切换函数不成?这样肯定不行,*好是写成一个函数,比如像taskswitch(3);这样。
为了解决这个问题,我们先创建这样一个函数。
本次的naskfunc.nas节选
_farjmp: ; void farjmp(int eip, int cs);
JMP FAR [ESP+4] ; eip, cs
RET
“JMP FAR”指令的功能是执行far跳转。在JMP FAR指令中,可以指定一个内存地址,CPU会从指定的内存地址中读取4个字节的数据,并将其存入EIP寄存器,再继续读取2个字节的数据,并将其存入CS寄存器。当我们调用这个函数,比如farjmp(eip,cs);,在[ESP+4]这个位置就存放了eip的值,而[ESP+8]则存放了cs的值,这样就可以实现far跳转了。
因此我们需要将调用的部分改写如下:
taskswitch3(); → farjmp(0, 3 * 8);
taskswitch4(); → farjmp(0, 4 * 8);
■■■■■
现在我们来缩短切换的间隔。在任务A和任务B中,分别准备一个timer_ts变量,以便每隔0.02秒执行一次任务切换。这个变量名中的ts就是“task switch”的缩写,代表“任务切换计时器”的意思。
本次的bootpack.c节选
void HariMain(void)
{
(中略) 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);
} else if (256 <= i && i <= 511) { /*键盘数据*/
(中略)
} else if (512 <= i && i <= 767) { /*鼠标数据*/
(中略)
} else if (i == 10) { /* 10秒计时器*/
putfonts8_asc_sht(sht_back, 0, 64, COL8_FFFFFF, COL8_008484, "10[sec]", 7);
} else if (i == 3) { /* 3秒计时器*/
putfonts8_asc_sht(sht_back, 0, 80, COL8_FFFFFF, COL8_008484, "3[sec]", 6);
} else if (i <= 1) { /*光标用计时器*/
(中略)
}
}
}
} void task_b_main(void)
{
struct FIFO32 fifo;
struct TIMER *timer_ts;
int i, fifobuf[128]; fifo32_init(&fifo, 128, fifobuf);
timer_ts = timer_alloc();
timer_init(timer_ts, &fifo, 1);
timer_settime(timer_ts, 2); for (;;) {
io_cli();
if (fifo32_status(&fifo) == 0) {
io_stihlt();
} else {
i = fifo32_get(&fifo);
io_sti();
if (i == 1) { /*任务切换*/
farjmp(0, 3 * 8);
timer_settime(timer_ts, 2);
}
}
}
}
上面的代码应该没有什么难点,不过还是稍微解释一下吧。在每个任务中,当从farjmp返回的时候,我们都将计时器重新设定到0.02秒之后,以便让程序在返回0.02秒之后,再次执行任务切换。
■■■■■
好了,这样做是不是能像我们所设想的那样,让键盘和鼠标持续响应呢?我们来“make run”……不错,键盘打字、鼠标操作、光标闪烁,全都运行正常,完全没有卡住。我们成功了。
不过,我们真的成功了吗?感觉不是很靠谱啊,task_b_main到底有没有运行啊?嗯,下面我们想办法来确认一下。
4 做个简单的多任务(2)(harib12d)
为了确认task_b_main到底有没有运行,我们需要让task_b_main显示点什么东西出来,*好是显示点会动的东西,要不还是让它数数吧……喂喂,是谁在下面叫“又来了啊”?(笑)
本次的bootpack.c节选
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; (中略) 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 {
(中略)
}
}
}
写到这里,我们遇到了一个问题,那就是sht_back。HariMain知道这个变量的值,但task_b_main可不知道。怎么办呢?怎样才能把这个变量的值从任务A传递给任务B呢?随便找一个内存地址存进去,然后再从那里读出来,这样应该可以吧。好,就用0x0fec这个地址,这个地址是BOOTINFO?4。
本次的HariMain节选
*((int *) 0x0fec) = (int) sht_back;
本次的task_b_main节选
sht_back = (struct SHEET *) *((int *) 0x0fec);
这里用了很多强制数据类型转换操作,代码比较难读,不过就先这样吧。
■■■■■
现在让我们来运行一下。不知道结果如何,心里好紧张啊。“make run”,哇,动了动了!task_b_main和HariMain在同时运行!当然,实际上只是因为切换速度很快,所以造成了在同时运行的假象。无论如何,我们的多任务取得了圆满成功!
多任务成功
(其实我们在harib12c的时候就已经成功实现了多任务,只不过当时还没有加入显示功能,所以无法实际感受到而已。)
5 提高运行速度(harib12e)
刚开始看到harib12d的成果还觉得挺感动的,过段时间头脑冷静下来以后再看的话,发现task_b_main数数的速度即便在真机环境下运行还是非常慢,我们得想办法提高它的运行速度。Harib10i在7秒钟的时间内可以数到0099969264,相比之下,harib12d也太慢了。任务A和任务B交替运行的情况下,性能下降到原来的一半还可以理解,如果比这个还慢的话就让人无法忍受了。
那运行速度为什么会这么慢呢?因为我们的程序每计1个数就在画面上显示一次,但1秒钟之内刷新100次以上的话,人眼根本就分辨不出来,所以我们不需要计1个数就刷新一次,只要每隔0.01秒刷新一次就足够了。
本次的bootpack.c节选
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);
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);
}
}
}
}
基本上就是这个样子。对了,代码开头的sht_back我们改为作为函数的参数来传递了,关于这一点我们以后会讲到,大家不必担心。
另外,上面的代码还把任务切换计时器超时的时候向FIFO写入的值改为了2。其实不改也没什么问题,只不过因为这个计时器定了0.02秒这个数,所以就顺手改成2了。
还有,count数值的显示格式改成了11位数字,因为运行速度变快了的话,说不定数字位数会不够用呢(笑)。
■■■■■
关于将sht_back的值从HariMain传递过来的方法,*((int *) 0x0fec)这样的写法感觉实在是不好看,于是果断废弃了,我们用栈来替代它。
举个例子,load_tr(123);这样的函数调用,如果从汇编语言的角度来考虑的话,参数指定的数值(123)就放在内存中,地址为ESP+4,这是C语言的一个既定机制。
既然有这种机制,那么我们可以反过来利用一下,也就是说,在HariMain里面这样写:
本次的HariMain节选
task_b_esp = memman_alloc_4k(memman, 64 * 1024) + 64 * 1024 - 8; *((int *) (task_b_esp + 4)) = (int) sht_back;
这样一来,在任务B启动的时候,[ESP+4]这个地址里面就已经存入了sht_back的值,因此我们就欺骗了task_b_main,让它以为自己所接收到的sht_back是作为一个参数传递过来的。
可能有人不明白为什么我们要把task_b_esp的地址减8,减4不就可以了吗?我们当然不能减4,只要仔细思考一下就能搞清楚这里的奥妙。
假设memman_alloc_4k分配出来的内存地址为0x01234000,由于我们申请分配了64KB的内存空间,那么我们可以自由使用的内存地址就是从0x01234000到0x01243fff为止的这一块。如果在这里我们既不减4也不减8,而是直接加上64 * 1024的话,task_b_esp即为0x01244000。如果我们减去4,task_b_esp即为0x01243ffc,但我们写入sht_back的地址是task_b_esp + 4,算下来就变成了0x01244000,如果把4字节的sht_back值写入这个地址的话,就超出了分配给我们的内存范围(0x01234000~0x01243fff),这样不行。
而如果我们减去8,task_b_esp即为0x01243ff8,写入sht_back的地址是task_b_esp + 4,即0x01243ffc,从这个地址向后写入4字节的sht_back值,则正好在分配出来的内存范围(0x01234000~
0x01243fff)内完成操作,这样就不会出问题了。
■■■■■
好,我们来运行一下,看看是不是变快了?还有,task_b_main有没有被我们欺骗而顺利接收到sht_back的值呢?如果这一招不成功的话,sht_back的值就会出现异常,画面上也就应该显示不出数字了。“make run”,哇,成功了,而且速度飞快(请注意,右图显示的是程序还没有运行到10秒的状态,这时就已经数到这么大的数字了!)。
即便是模拟器环境下运行速度也已经相当快了
COLUMN-9 千万不能return?
在这一节中,task_b_main已经变得像一个普通函数一样了,但是在这个函数中千万不能使用return。
return的功能,说到底其实是返回函数被调用位置的一个JMP指令,但这个task_b_main并不是由某段程序直接调用的,因此不能使用return。如果强行return的话,就会像“执行数据”一样发生问题,程序无法正常运行。
HariMain的情况也是一样的,也禁止使用return。
我们在15.1节中讲过,为了记住现在正在执行的指令所在的内存地址,需要使用EIP寄存器,那么return的时候要返回的地址又记录在哪里呢?对于记性不好的CPU来说,肯定会把这个地址保存在某个地方,没错,它就保存在栈中,地址是[ESP]。
因此,我们不仅可以利用[ESP+4],还可以利用[ESP]来欺骗CPU,其实只要向[ESP]写入一个合适的值,告诉CPU应该返回到哪个地址,task_b_main中就可以使用return了。
6 测试运行速度(harib12f)
我们的程序运行得很快,可是到底有多快呢?我们得想个办法测一下。可能有人会说,别搞这种节外生枝的玩意儿了,赶快继续往下讲吧!嗯,要说也是,这的确是有点不务正业了,不过该玩的时候也要玩一玩嘛!
我们向task_b_main添加了一些代码。
本次的bootpack.c节选
void task_b_main(struct SHEET *sht_back)
{
struct FIFO32 fifo;
struct TIMER *timer_ts, *timer_put, *timer_1s;
int i, fifobuf[128], count = 0, count0 = 0;
char s[12]; (中略)
timer_1s = timer_alloc();
timer_init(timer_1s, &fifo, 100);
timer_settime(timer_1s, 100); for (;;) { count++;
io_cli();
if (fifo32_status(&fifo) == 0) {
io_sti();
} else {
i = fifo32_get(&fifo);
io_sti();
if (i == 1) {
(中略)
} else if (i == 2) {
(中略)
} else if (i == 100) {
sprintf(s, "%11d", count - count0);
putfonts8_asc_sht(sht_back, 0, 128, COL8_FFFFFF, COL8_008484, s, 11);
count0 = count;
timer_settime(timer_1s, 100);
}
}
}
}
■■■■■
我们来运行一下,先得看看它是不是能正常工作,“make run”。不错,在模拟器环境下运行成功。
上面的数字显示的是速度哦
■■■■■
现在我们到真机环境运行一下。哇,好快!果然真机环境就是快,速度已经达到大约4638200 了。
我们把这个速度和harib10i做个对比。harib10i在7秒内计数到0099969264,即速度为每秒14281323,相比之下性能是现在的3倍。咦,怎么会这样?如果是2倍的话还可以理解,3倍就有点过分了。
看到这个结果心里当然会很不爽,我们来找找原因。嗯,每隔0.01秒刷新显示是不是不太好呢?如果显示计数是导致速度慢的原因,那干脆就别显示了吧。我们把开头的timer_settime (timer_put, 1);一句删掉,这样一来由于计时器没有被设定,就不会引起超时中断,也就不会触发显示了。
现在仅显示速度值了
那么在真机环境下运行情况如何呢?哇,速度真的快了不少,现在的成绩是6774100,和14281323相比,性能差距为2.1倍,这样已经很令人满意了。大概JMP的地址也会影响计数的速度,另外,如果把速度显示改成每隔5秒刷新一次,任务切换间隔再改成0.03秒的话,估计性能差距可以更加接近理想的2.0倍,不过现在这个阶段我们就不去一一尝试了。
7 多任务进阶(harib12g)
到现在为止,我们所做的多任务都是依靠在HariMain和task_b_main中写入负责任务切换的代码来实现的。有人会说,这种多任务方式“不是真正的多任务”(即便如此,应该也不至于被说成是“假的”多任务)。
那么真正的多任务又是什么样的呢?真正的多任务,是要做到在程序本身不知道的情况下进行任务切换。既然如此,我们就来为“纸娃娃系统”添加真正的多任务吧。
首先我们来创建这样一个函数。
本次的mtask.c节选
struct TIMER *mt_timer;
int mt_tr; void mt_init(void)
{ mt_timer = timer_alloc();
/*这里没有必要使用timer_init */
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_timer和mt_tr的值,并将计时器设置为0.02秒之后,仅此而已。在这里,变量mt_tr实际上代表了TR寄存器,而不需要使用timer_init是因为在发生超时的时候不需要向FIFO缓冲区写入数据。具体内容请继续往下看。
接下来,mt_taskswitch函数的功能是按照当前的mt_tr变量的值计算出下一个mt_tr的值,将计时器重新设置为0.02秒之后,并进行任务切换,很简单吧。
■■■■■
下面我们来改造一下timer.c的inthandler20。
本次的timer.c节选
void inthandler20(int *esp)
{
char ts = 0;
(中略)
for (;;) {
/* timers的计时器全部在工作中,因此不用确认flags */
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; /*将下一个计时器的地址赋给timer */
}
timerctl.t0 = timer;
timerctl.next = timer->timeout;
if (ts != 0) {
mt_taskswitch();
}
return;
}
在这里,如果产生超时的计时器是mt_timer的话,不向FIFO写入数据,而是将ts置为1。*后判断如果ts的值不为0,就调用mt_taskswitch进行任务切换。
看了上面这段代码,你可能会问,为什么要用ts这个变量呢?在 /* 超时 */ 的地方直接调用mt_taskswitch不就好了吗?也就是下面这样:
出问题的例子
void inthandler20(int *esp)
{
(中略)
for (;;) {
/* timers的计时器全部在工作中,因此不用确认flags */
if (timer->timeout > timerctl.count) {
break;
}
/*超时*/
timer->flags = TIMER_FLAGS_ALLOC;
if (timer != mt_timer) {
fifo32_put(timer->fifo, timer->data);
} else {
mt_taskswitch();
}
timer = timer->next; /*将下一个计时器的地址赋给timer */
}
timerctl.t0 = timer;
timerctl.next = timer->timeout;
return;
}
为什么不这样写呢?这样写的确可以让代码更简短,但是会出问题。
出问题的原因在于,调用mt_taskswitch进行任务切换的时候,即便中断处理还没完成,IF(中断允许标志)的值也可能会被重设回1(因为任务切换的时候会同时切换EFLAGS)。这样可不行,在中断处理还没完成的时候,可能会产生下一个中断请求,这会导致程序出错。
因此我们需要采用这样的设计——等中断处理全部完成之后,再在必要时调用mt_taskswitch。
■■■■■
接下来我们只需要将HariMain和task_b_main里面有关任务切换的代码删掉即可。删代码没什么难度,而且HariMain又很长,为了节约纸张我们就省略了,只把task_b_main写在下面吧。
本次的bootpack.c节选
void task_b_main(struct SHEET *sht_back)
{
struct FIFO32 fifo;
struct TIMER *timer_put, *timer_1s;
int i, fifobuf[128], count = 0, count0 = 0;
char s[12]; fifo32_init(&fifo, 128, fifobuf);
timer_put = timer_alloc();
timer_init(timer_put, &fifo, 1);
timer_settime(timer_put, 1);
timer_1s = timer_alloc();
timer_init(timer_1s, &fifo, 100);
timer_settime(timer_1s, 100); 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 == 100) {
sprintf(s, "%11d", count - count0);
putfonts8_asc_sht(sht_back, 0, 128, COL8_FFFFFF, COL8_008484, s, 11);
count0 = count;
timer_settime(timer_1s, 100);
}
}
}
}
像这样,把有关任务切换的部分全部删掉就可以了。
■■■■■
好,我们来试试看能不能正常工作吧。“make run”,成功了,真开心!不过看上去和之前没什么区别。
和上一节相比,为什么现在的设计可以称为“真正的多任务”呢?因为如果使用这样的设计,即便在程序中不进行任务切换的处理(比如忘记写了,或者因为bug没能正常切换之类的),也一定会正常完成切换。之前那种多任务的话,如果任务B因为发生bug而无法进行切换,那么当切换到任务B以后,其他的任务就再也无法运行了,这样会造成无论是按键盘还是动鼠标都毫无反应的悲剧。
真正的多任务也成功了!
真正的多任务不会发生这样的问题,因此这种方式更好……话虽如此,但其实即便是harib12g,在任务B发生bug的情况下,也有可能出现键盘输入失去响应的问题。例如,明明写了io_cli();却忘记写io_sti();的话,中断就会一直处于禁止状态,即使产生了计时器中断请求,也不会被传递给中断处理程序。这样一来,mt_taskswitch当然也就不会被调用,这意味着任务切换也就不会被执行。
其实CPU已经为大家准备了解决这个问题的方法,因此我们稍后再考虑这个问题吧。
好,我们在真机环境下运行一下,看看速度会不会变慢。咦?速度非但没有变慢,反而变快了?运行结果是6493300,和之前的14281323相比,性能的差距是2.2倍。harib12f的时候还是差3倍来着,这次也太快了吧。我们再把timer_settime(timer_put,1);删掉,看看如果不显示计数只显示速度会怎样?说不定速度会变得更快呢?哇!结果出来了,6890930,居然达到了2.07倍,离理想值2.0倍又近了一步呢。
现在想想看,为什么速度反而会变快呢?我想这是因为在任务切换的时候,我们不再使用FIFO缓冲区的缘故。之前我们向FIFO中写入超时的编号,然后从中读取这个编号来判断是否执行任务切换,相比之下,现在的做法貌似对于CPU来说负担更小些,一定是这个原因吧。
哎呀,不知不觉就已经很晚了。今天就先到这里吧,我们明天继续。