首页 > 代码库 > 在51上写一个“OS”原型
在51上写一个“OS”原型
自己在51单片机上实现任务调度器的记录过程,下面的文本内容,完整的图文文档传送到了文库。传送门
闲来无聊,便有了想写操作系统的念头。之前也用过ucso、rtt、raw-os,虽然没怎么深入应用,但对操作系统也有些认识。好奇心的驱使,终于在国庆这段时间里实现了这个“OS”。于是,便有了本文,用来记录自己实现一个OS的过程。当然,这个OS,可不像上面说的几个rtos那样,这个OS只是实现了任务调度功能,还不能算真正意义的OS,甚至编码上看起来很丑陋。由于51单片机相对简单,尽管资源上比较有限,但还是选择了51作为本次的主角。
本文的目标很简单。有两个任务:一个让led闪烁;一个让数码管显示0~9;两个任务每50ms切换一次。
由于涉及到汇编,所以在开始写这个OS之前,特意学习了一下51的汇编。因为有一定基础,学起来也不是很难。只是开始会有点不习惯。
搞定了汇编后,就可以真正的来写这个“OS”了,这里使用了KEIL和proteus来验证。其实可以只使用KEIL,不过配合proteus用起来形象很多。
首先,来复习几个刚开始学习单片机会接触到的内容。单片机中有个PC寄存器(16Bit)。它指向了下一条要被执行的指令。最简单的程序,可能PC就只是递增而已。如果程序中出现跳转指令或者CALL指令,则PC的变化可能就不规则了。当程序运行到某个地方,调用了一个子函数。假设这个地方为A(PC指向A的下一条指令),函数的入口为B。那么当子函数返回(RET)后,程序应该从A的下一条指令开始运行。
CALL指令的结果就是PC的值等于函数的入口地址值(B)。这样一CALL,PC就变了,那么A就不见了。如果在PC值变成B时,没有把A的下一条指令地址保存起来,则程序就再也回去不了。所以便有了个叫栈的东西用来保存这个地址,由寄存器SP所指示栈顶。
从这张图可以看出来,程序运行时,寄存器和RAM的某一时刻情况。此时,程序正准备调用第一个task_reg子函数。这个时候:
1) SP的值为0xd0;
2) 第二个task_reg子函数的入口地址为0x01B1;
3) SP所指向的RAM里面的内容为 00 9B 01 00 …
这张图是调用子函数后,寄存器和RAM的变化情况。重点看SP发生的变化:
1) SP的值为0xd2;
2) SP所指向的RAM里面的内容为 00 B1 01 …
很明显的看出来,CALL指令将会使CPU对PC中的执行地址进行压栈PUSH。而当函数返回(RET)时,CPU会将栈中保存的地址弹出POP。保证了程序可以准确运行。另外,SP指向的是栈顶,压栈前SP会加一(向上增长)。我们可以总结成下面这种很常见的图。
弄清楚这么一个机制之后,就可以开始来实现我们这个简单的OS了。在开始动手之前,先约定几个概念。
task(任务):为实现某种功能的一个函数,这个函数实际上不会return;其真正去实现某种功能的动作是在一个死循环中;
任务栈:用来存放task各种信息(task被切换前PC的值)的一种数据结构,是task私有的;
系统时钟:用来告诉调度器到时间切换task了,使用定时器0来作为这个OS的系统时钟;
调度器:用于切换task的一个程序块,当一个系统时钟到来时,保存当前PC值到当前task的栈中。再将SP指向另一个task的栈,并POP上次运行到的程序地址到PC。
具体过程:
task与栈:task实质上就是一个函数,栈则是一个数组。具体代码如下:
这里需要说明一下,task中依然使用了传统的delay延时,似乎与使用OS的初衷矛盾。其实这里为了使proteus显示效果好看点。在最开始的设计里,我们只要看到每一次系统时钟到来时,task发生切换就行了。等设计深入后,我们再来让我们的OS看起来更像OS。同时,在最初的设计里,忽略切换task的时候对R0~R7的PUSH POP。
接着,我们需要对task和栈做一个绑定。也就是把函数的地址压入栈中。51的地址是16bit,也就是两个字节,但是Rn寄存器却是一个字节,所以我们任务栈的也是unsigned char类型。在把PC压栈时,需要考虑大小端问题。从keil仿真可以看出来,先压入低字节。出栈则先出高字节
绑定函数的代码如下:
> 8; //高字节 tab[0] = ((int)task) & 0xff; if(index != 16) list_tab[index++] = tab;}" v:shapes="圆角矩形_x0020_46">
Pfunc 是一个函数指针,其指向的是一个不带参数、不带返回值的函数。而这个指针刚好就是这个函数的入口地址。然后把这个地址存入task自己的栈。这样就实现了“绑定”。把栈指针SP指向task的私有栈,就能得到task上次被切换出去时的各种信息了。
绑定好后,就可以启动OS了,启动task代码如下:
这段代码的作用就是把栈指针SP指向task表中第一个task的栈顶。list_tab是一个存放task栈地址的数组。在使用task_reg函数绑定task和其任务栈的同时,把任务栈的地址保存到这个数组中来。list_tab、task_stack、task之间关系大概是这样子的:
到此,就差调度器还没有实现了。对于CPU来讲,栈只有一个,它就是SP所指向的一块内存空间。在每一次CPU要调用(CALL)子函数的时候,CPU都会把PC值压入栈中,退出(RET/RETI)子函数时把栈中存储的PC值送回去。调度器要做的就是压入PC值到当前task的栈里面去,然后改变SP所指向的内存空间,最后退出(RET/RETI)。从而实现任务调度的效果。因为是要定时的改变任务,所以我们要用到定时器,这里选用定时器0,因为定时器1串口要用到。具体代码由C语言和汇编语言组成。具体如下:
> 8; //重置定时器 TL0 = tick & 0xff; TF0 = 0; return list_tab[task_now] + 1; //返回栈的地址}" v:shapes="圆角矩形_x0020_53">
到这里,我们这个运行于51单片机上面的“OS”就完成了。但这并不是一个真正的OS,这只是一个初具OS样子的一个OS原型。在这个OS的设计过程中,最终目的很简单,就是实现两个任务切换,让整体看起来很像两个任务同时运行,而忽略了很多东西。比如经常提到的“现场保护”。另外,task_switch函数在切换task的时候,总是取出栈的固定位置的内容,也就是说task_switch函数并不知道栈顶在哪里!最明显的就是,task中有多级函数嵌套,但task_switch只是找到最外一层函数。
说白了,这个“OS”要变成OS还需要改进。
继续完善
尽管OS跑起来了,但跑起来却很奇怪。上面也说到了,“现场保护”。用个具体的例子来说明一下:
在led和数码管两个task中都调用了delay函数。从Keil的反汇编可以看出来,delay函数用到了R5、R6、R7三个寄存器。也就是说led和数码管两个task中的delay函数,是各自拥有R5、R6、R7的。每次切换task前,必须把当前task的这几个值保存到各自的栈里面去。同时把数码管task中delay函数的R5、R6、R7从栈里面恢复到CPU的R寄存器中去。另外,ACC、PSW、DPL、DPH也要保存起来。总共有13个寄存器(13B),加上task运行地址(2B)和task中函数嵌套产生的压栈地址(每嵌套一个2B)。每个任务栈至少也要15B了。而51内部RAM才256(暂不考虑那些增强型51,类似与cc2530、cc2540那种几个K的RAM),也就难怪会有51不适合跑OS了。需要注意的是,要确保任务栈足够大,否则可能会破坏其他任务栈里面的内容(假设任务栈的地址是相邻的)。
如果画个图来表示,大概应该是这个样子的了:task1调用A运行到here的时候,产生了一个任务调度,这时需要先把CPU寄存器里面的内容保存到task1栈里面去。接着把task2栈里面的内容还原到CPU寄存器里面去。这里假设task2上次被剥夺CPU使用权的时候也调用了A。
考虑了“现场保护”后,问题又来了。原先设计的task_switch,return的结果始终都是task中next的值!而task_switch的作用就是用来改变SP的。如果我们在改变SP之前,把现在的SP存起来,那么下次return的值才是真正的栈顶。于是,调度器的代码应该是这样的了:
> 8; TL0 = tick & 0xff; return list_tab[task_now];}" v:shapes="圆角矩形_x0020_70">
这里要注意的是,调用task_switch的时候,这条语句的地址也会被压入任务栈里面去,但是这个地址并不需要保存。因为这个函数return后就会执行这一句,没必要再把它保存起来了。
加入的“现场保护”后,考虑这样一个现象。想想之前“绑定”的实现。当启动OS的时候,第一次进行任务调度时,当SP指向另一个task后,会执行13次POP指令。那么RETI的结果是啥?没错,不能进入task2。因为之前“绑定”后,栈的长度是2,但却执行了13次POP。真正的入口老早就被POP掉了。于是,又得改!
> 8; tab[0] = ((int)task) ; if(list_tab[index] != 16) list_tab[index++] = tab + 14;}" v:shapes="圆角矩形_x0020_72">
对应的,启动list_tab中首个任务的代码也得改。因为首个任务启动前,没有执行POP。
到这里,我们这个OS才看起来像是一个OS。好吧,其实这只是个任务调度器。那怎么才更像一个OS呢?起码要不delay去掉,换成sleep吧。还有,得加个idle吧。还有什么信号量,互斥量,邮箱,and so on。当然,这种资源有限的51单片机,加这些东西有点压力吧。还有,你真的像自己写一个完整的OS吗?如果你的回答是肯定的话,那我的这文章就帮不了你啦。不过,我希望这文章有助于你了解一点点OS。当然,文章可能存在表达不严谨的地方。
在51上写一个“OS”原型