微型抢占式多任务实时内核设计
扫描二维码
随时随地手机看文章
摘要:介绍引入事件驱动观念的抢占式多任务微型实时内核——MicroStar的设计与实现;提出基于事件的优先级这一新概念。 关键词:事件驱动 优先级 任务管理 消息 信号 同步 市面上有很多优秀的嵌入式实时操作系统(RTOS),但在中低端微控制器(MCU)上运行性能良好的RTOS内核并不多。在高档机下,功能强大、运行极好的嵌入式实时操作系统,移植到中低端机上时性能很可能大幅度下降。一个很重要的原因就是它的大部分功能对中低档系统来说是不需要的,反而成为制约性能的累赘。中低档微控制器与高档机相比,一方面,寻址能力有限,处理速度慢,在相同的实时性能要求下,对内核的代码效率的要求更为严格;另一方面,中低档机完成的任务相对简单,减少了对内核的功能需求,比如可以不需要内存管理。从嵌入式系统的共性来说,大多数情况下用户程序和系统内核是紧密结合在一起的,运行时存储器容量消耗、任务的数量、执行时间和结果都是可以预计的,这可进一步缩小对内核的功能需求。 事件驱动的观点认为,任务应该是被动地响应外界发生的各种事件,而不是主动地去“查询”,浪费处理器时间。采用事件驱动编程的方法,不仅提高了运行效率,而且降低了事件处理之间的耦合,使程序流程非常清晰,从而可大大提高开发效率。 充分考虑中低端微控制器的硬件特点和嵌入式系统软件的需求,引入“事件驱动”的观念,笔者开发了一个微型的抢占式多任务RTOS内核—— MicroStar。支持任务的动态创建、删除、睡眠、挂起和恢复,提供消息(message)和信号(signal)两种任务间的通信方案、完善的定时器服务和功能齐全的任务同步函数库。限于篇幅,着重论述几个与众不同的设计思路和实现难点。 1 调度策略 1.1 基于事件的优先级 对内核的实时性能来说,调度策略是关键。好的调度策略,既要体现各任务因所处理的事件对实时性的不同要求而带来的优先级差异,又要保证一定的公平性,避免出现低优先级任务长时间得不到执行的极端情形。常用的调度策略有两种:一种是按时间片轮转(round robin)调度,如RTX51;另一种是严格按优先级的占先式调度,如μC/OS。 按时间片轮转调度能很好地保证公平,但优先级的差异是通过对处理器的占用时间的多少来体现的。如果各个任务都不主动放弃执行,高优先级的任务能够比低优先级任务获得更多的处理器时间;但在嵌入式系统中,某个事件要求实时处理,并不意味着该处理需要较长的时间,而往往是要求尽快响应。因此,采用按时间片轮转调度,实时性不会太好。 如果严格按任务的优先级来调度,可极大地提升系统的实时性,但却欠缺公平。如果高优先级任务是个无等待的死循环,低优先级任务就无法获得执行机会。 一个好的办法是两者的结合,即可由任务的优先级产生调度,也可以由时间片到产生新的任务调度,如VxWorks;但是实现起来较为复杂,不一定适合中低档MCU。为此,基于以下事实,提出“基于事件的优先级(events based priority)”这一新观念。 ① 一个任务往往处理多个事件,各个事件对实时性的要求不尽相同。一般的RTOS下,任务的优先级是根据这些事件中对实时性要求最高的一个来确定的。因此,高优先级任务在处理对实时性要求不高的事件时,完全可能会妨碍低优先级任务处理具有一定实时性要求的事件。 ② 有些情况下,对同一事件的处理可分为前台处理和后台处理:前台处理所需时间短,对实时性有较高的要求;后台处理花费时间长,对实时性则无多大要求。 如果根据正在处理和等待处理的事件对实时性的不同要求,更细致地按事件处理的前后台阶段,动态地调整任务的优先级,采用优先级调度策略,既可发挥实时性好的优点,又可在一定限度内保证公平。这种情况下,任务的优先级不再是一成不变的,而是动态地取决于所处理的事件和处理阶段,这就是所谓的“基于事件的优先级”。 1.2 在MicroStar中的实现 MicroStar中任务的优先级是由静态优先级和动态优先级共同决定的。静态优先级等同于其它RTOS中的优先级;动态优先级为基于事件的优先级 ——由内核根据任务正在处理和等待处理的事件动态调整。静态优先等级限定为0"15级,不允许创建静态优先级相同的任务。动态优先等级目前只有0(亦称紧急级)、1(亦称普通级)两级。任务的实际优先等级可由下式来计算: 优先等级=动态优先等级%26;#215;16 + 静态优先等级。 优先等级值越大,优先级越低。可以看出,动态优先级起决定作用。 怎样实现优先级动态可调呢?首先简要介绍MacroStar中任务的四个状态: 休眠(dormant)——任务因调用睡眠函数、挂起函数或者等待内核同步对象而进入休眠态; 等待(waiting)——任务因等待消息或者信号(勿与“信标”、“信号量”相混淆)而进入等待态; 就绪(ready)——任务运行的条件都已俱备,只等被调度,称为就绪态,亦称可调度态; 运行(running)——任务正在使用处理器的资源,称为运行态。 这些状态都是用标志位来实现的。16个静态优先级对应的任务的某一状态刚好可用一个16位的二进制数来标识。休眠态用os_slpState来表示,从高位算起,第N位为0表示静态优先级为N的任务处于休眠态。等待态是依据“事件驱动”观念而专为消息和信号而设计的,用os_rdyhState和 os_rdyState两个16位的变量来记录。只有当os_rdyhState和os_rdyState的第N位均为0时,才表示静态优先级为N的任务处于等待态。如果任务处于非等待状态,意味着任务已在处理事件或者有事件要处理(可以认为任务一开始就处理“启动”这个“虚拟事件”),这时,才有动态优先级的概念。如果os_rdyhState中的第N位为1,表示静态优先级为N的任务的动态优先级为紧急级;如果os_rdyhState第N位为0,则表示静态优先级为N的任务的动态优先级为普通级。要求实时处理的事件发生后,内核简单将os_rdyhState相应位置1,提升任务的动态优先级;当前事件处理完毕后,如果已无实时性要求较高的事件等待处理,简单地将os_rdyhState相应位清0,降低任务的动态优先级。由此,即可实现优先级的动态可调。只有当任务既不处在休眠态也不处在等待态时,任务才是可以调度的。 2 任务管理 2.1 任务控制块 多任务系统中用任务控制块(TCB)来记录任务的各种属性。在这些属性中,最重要的是任务堆栈栈顶地址。进行上下文切换(context switch)时,被停止执行的任务的所有寄存器状态、下一条代码的地址都要入栈保护,因而这个属性是必需的。如果允许修改任务的优先级,优先级属性也是必需的。所以,将任务控制块简化如下: typedef struct{ uint_16 msg[2]; /*消息接收区*/ int * sp; /*堆栈栈顶指针*/ uchar priority; /*静态优先级*/ uchar reserved; /*保留 */ }TCB,*PTCB; TCB os_tcbs[ USER_TASK_NUM +1 ]; /*用户任务数最多为15个*/ msg用来存储发送给任务的消息,两个16位的二进制可按位存放32个消息。sp指向任务堆栈栈顶。priority记录任务的静态优先级。数组 os_tcbs用来记录系统所有任务的信息,其下标与任务的ID号相对应,即ID号为N的任务的控制块为os_tcbs[N]。 2.2 任务的创建 os_CreateTask函数用来创建一个任务: void os_CreateTask( TASKPROC task, //任务函数的指针 uchar taskId, //任务的ID号 uchar priority, //优先级 int * pStack, //任务堆栈栈底地址 void * param //任务函数的入口参数 ); typedef void (*TASKPROC)( void * param); 创建任务时,内核要做以下几方面的工作:① 初始化任务控制块;② 初始化任务堆栈,使其如同被其它任务抢断时的情形;③ 将任务状态置为就绪态。该函数是依赖于处理器的,图1是较为通用的描述。 中断程序中,在高优先级任务剥夺低优先级任务之前,内核将断点时的各寄存器状态入栈保护,这部分区域即为寄存器映像区。将任务退出函数os_Exit 的地址先于任务函数MyTask入栈,以使MyTask函数退出后返回到os_Exit中去,由此来实现任务的自动删除。 2.3 任务切换 与任务创建一样,任务切换代码与硬件相关。在PC机上,代码和步骤如下: void interrupt os_Schedule( ) …………(1) { if( os_nLayers )return; os_nLayers++; …………(2) _DX = (int)os_pCurTCB; /*os_pCurTCB指向当前任务的控制块*/ *(int*)(_DX+4) = _SP; *(int*)(_DX+6) = _SS; …………(3) os_GetReadyTask( ); …………(4) _DX = (int)os_pCurTCB; _SP = *(int*)(_DX+4); _DX = *(int*)(_DX+6); _SS = _DX; …………(5) os_nLayers--;? …………(6) UNLOCK_INT( ); } …………(7) (1)利用C语言interrupt关键字使各寄存器入栈保护。(2)锁定调度器,不允许重调度。(3)将当前任务的栈顶地址(由堆栈段寄存器SS和栈指针寄存器SP组成)保存在os_pCurTCB->sp中(PC机下,TCB中的sp定义为远指针类型)。(4)选出优先级最高的就绪任务(方法类似于μC/OS),并将os_pCurTCB指向新任务的控制块。(5)栈寄存器指向新任务的栈顶地址。(6)解锁调度器。(7)各寄存器出栈,恢复到上次被中断时的情形。 3 消息与信号 为很好地支持事件驱动编程,MicroStar借鉴了Windows的“基于消息,事件驱动”观念,并加以扩展。在MicroStar中,事件不仅可以触发消息、信号,而且由事件触发的消息或信号是有优先级的,这是因为不同事件对处理的实时性要求是不同的。内核正是根据消息、信号的优先级来动态调整任务的动态优先级的。 3.1 消 息 消息是一种很友好的通信方式。考虑中低档单片机的内存容量和需求,将消息简化为一个0"31的值。采用固定位图存储格式,将这32个值映射到任务控制块的msg域,这大大减小了存储空间。可将msg域看作一个32位的二进制变量,第i位为1,表示有值为i的消息,因此消息的存取只需通过简单的“与”、 “或”运算。消息的优先级依值而定,值越大,优先级越低。在系统范围内,消息优先级又分为两级:紧急级(值0"15)与普通级(值16"31)。当有紧急消息发送给任务时,内核会提升任务的动态优先级,从而提高消息处理的实时性。当任务无紧急消息要处理时,内核就降低它的动态优先级。发送消息的核心代码如下: /*const uint_16 os_maskTable[16] ={ 0x8000,0x4000, .....,0x0008,0x0004,0x0002,0x0001 */ if( msg%26;amp;0xF0 ) { /*普通级消息*/ pTCB->msg[1] |= os_maskTable[msg%26;amp;0x0F]; /*普通级消息存在msg[1]中*/ os_rdyState |= os_maskTable[pTCB->priority]; } else { /*紧急级消息*/ pTCB->msg[0] |= os_maskTable[msg]; ; /*紧急级消息存在msg[0]中*/ os_rdyhState |= os_maskTable[pTCB->priority]; /*提升动态优先级*/ } 与先进先出(FIFO)方式的消息队列不同,内核总是取出优先级最高的消息来交给任务处理。消息接收函数os_GetMessage设计思路如下:如果消息接收区中无紧急消息,则降低任务的动态优先级;如果消息接收区中有消息,则取出优先级最高的消息;如果没有消息,则将任务转为等待态。考虑有时候不希望任务进入等待态,MicroStar还提供了非阻塞的os_PeekMessage消息接收函数。 3.2 信 号 在嵌入式系统编程中,常利用标志位来实现前后台程序或不同的任务间的通信。MicroStar也提供了类似的任务间的通信方式——信号(signal)。它避免了用户程序因不断查询标志位而带来的时间浪费,而且支持信号间的“与”、“或”运算。通俗来说,信号就是标志位,用来标识某个事件的发生。同消息一样,信号也有紧急级与普通级之分。与消息不同的是,信号完全由用户程序创建和维护,内核只是帮助用户程序等待信号,以避免低效率的标志位查询。使用起来不如消息直观,但执行效率较高。实现起来非常简单,请参见源码。 图1 4 定时器 定时器在嵌入式系统有着大量的用途,如LED的定时刷新、串口通信中的超时检查。对定时器的需求分为两类,一种是周期性重复定时,比如每隔10ms去刷新LED;另一种是仅需定时一次的一次性定时。定时时长以系统时钟节拍(tick,又译作滴达)作为单位。两次系统定时中断之间的时间间隔为一个节拍。定时器结构体如下: typedef struct{ uint_16 elapse; /*定时时长的余值*/ uint_16 backTime; /*定时时长的备份值*/ MSG timerId; /*定时器ID号*/ uchar taskId; /*拥有该定时器的任务的ID*/ TIMERPROC lpTimerFunc; /*定时调用的函数指针*/ }TIMER,*PTIMER; TIMER os_timers[USER_TIMER_NUM]; /*最多为16个*/ 周期性定时和一次性定时是通过timerId来区分的。如果timeId为64,为一次性定时;如果timerId不大于32,则为周期性定时。用 os_timers数组记录定时器信息,用16位的os_timerState表示定时器的状态。如果os_timerState的二进制数的第N位为 1,则表示os_timers[N]空闲可用。 对周期性定时器,每隔定时时长的时间,内核就调用的lpTimerFunc指向的函数,并且将timerId以消息的方式发送给任务,对任务的动态优先级的影响与普通消息一样。因此,要想取得实时性较好的定时器,只需将timerId设在0"15之间。与一次性定时相关的是睡眠函数和限时等待同步对象的函数。任务使用这两个函数而进入休眠态后,在定时时间到时,内核将其恢复为就绪态,并自动释放定时器资源。系统定时处理的核心代码如下: if( !(--pTimer->elapse) ){ /*elapse减为零表示时间到*/ if( pTimer->lpTimerFunc)(*pTimer->lpTimerFunc)(pTimer-> taskId,pTimer->timerId); switch( pTimer->timerId%26;amp;0xF0 ){ case SLEEP_ID: /*一次性定时*/ os_slpState |= taskMask; /*结束休眠态*/ os_timerState |= timerMask; /*释放定时器*/ break; case 0x00: /*发送紧急级定时器消息*/ pTCB->msg[0] |= os_maskTable[pTimer->timerId]; os_rdyhState |= os_maskTable[pTCB->priority ];; break; case 0x10: : /*发送普通级定时器消息*/ pTCB->msg[1] |= os_maskTable[pTimer->timerId%26;amp;0x0f]; os_rdyState |= os_maskTable[pTCB->priority ];; } } 5 同 步 抢占式多任务下,低优先级的任务可以被高优先级任务打断执行。以常规方式访问共享变量或资源时,会出现奇怪的结果。比如,一个任务调用 printf(“12345”)试图在输出设备上输出“12345”,但执行中被高优先级任务打断;而高优先级任务也调用printf(“67890”) 试图输出“67890”,最终的输出结果可能是“1267890345”之类。这就是多任务环境下的任务同步问题。 同步方式有两种,一种为用户同步方式,不需要与内核打交道,具有速度快的优点,但只适合保护执行时间短的代码;另一种是内核同步方式,需要通过内核来实现,速度相对较慢,但可保护执行时间长的代码。 5.1 用户同步方式 用户方式下的同步是通过关键代码段(critical section)保护来实现。关键代码段是指这样一小段代码,它执行时必须独占对某些共享资源的访问权,不允行被其它试图访问该资源的代码打断。最简单的是得用关/开中断来实现,优点是速度极快,缺点是带来中断延迟,只适合执行时间极短的代码段。另一简单的方案是通过加锁/解锁调度器来实现,即在关键代码段执行期间禁止内核进行任务切换。采用这种方法,不会带来中断延迟,但带来了调度延迟。在MicroStar中,对os_nLayers加1即可锁定调度器,减1即可解锁。但直接利用解锁调度器来离开关键代码段并不合适。如果在关键代码段执行中,发生了中断,使更高优先级任务就绪。但由于调度器被锁定,中断程序退出时不能进行任务切换以使高优先级任务执行。因此我们希望,最好一旦调度器解锁,马上就切换到高优先级任务。为此,专门用变量os_flag的最低位作为标志位,中断程序中调用任何可以使任务就绪的系统函数都会影响到该标志位,如os_PostMessage、 os_SetEvent,os_Notity。退出关键代码段时以此来判断是否需要进行任务调度。离开临界代码段时的代码如下: if( (os_flag%26;amp;0x01) %26;amp;%26;amp; (!(--s_nLayers ) ) ) {--os_Schedule( ); } 5.2 内核同步对象 如果要保护执行时间较长的代码,就要使用内核同步对象来同步。常用的内核同步对象有事件(event)、信标(semaphore,亦称信号量)和互斥量(mutex)。 事件对象用来通知事件或者操作已经完成,它用一个布尔值来表示该事件处于通知还是未通知状态。信标对象用于对资源进行计数。它记录了当前可用的资源数目。当用1来初始化信标对象的可用资源数目时,信标对象实际上成为了互斥对象。MicroStar提供事件和信标两种同步对象,支持查询、限时等待或无限时等待操作。内核同步对象的结构如下: typedef struct{ uint_16 waiter; /*等待列表*/ uchar num; /*可用资源数目或者事件状态*/ uchar type; /*同步对象类型*/ }OBJECT,*POBJECT,*HOBJECT,*HEVENT,*HSEMAPHORE; 当一个任务因等待同步对象而进入休眠态时,它的静态优先级按位存放在waiter域中。如果静态优先级为N的任务在等待某个同步对象,则waiter 二进制数中第N位置1,以示等待。当type为EVENT_OBJECT时,表示事件对象,此时num为事件状态,1表示通知态,0表示未通知态;为 SEMAPHORE_OBJECT时,表示信标对象,对应的num为可用资源数。 内核同步对象不是嵌入式多任务系统特有的,通用的多任务操作系统如Windows都提供齐全的同步函数,在此不作介绍。 6 运用和使用示例 在MicroStar中,各个功能模块是分开的,因而可裁减度高。移植MicroStar也比较容易,只需改写与硬件相关的任务创建和调度函数。 MicroStar1.0的PC机完全版本的代码约为10KB,针对96单片机用汇编语言写成的版本为1.4KB。本文附带的演示示例,都在TC2.0下编译通过,可直接在PC机上运行。第一个示例启动了三个用户任务:① WatchTask任务在屏幕中央显示一个以10ms为计时单位的跑表。② KeyTask 任务每隔200ms读一次键盘,按“Q”键系统退出执行。③ MicroStar 任务显示MicroStar相关信息,每隔1.5s更新一帧。 演示程序及内核源码见本刊网站(www.dpj.com.cn)。 结 语 本文提出了基于事件的优先级这一观念,使任务优先级的安排更为合理。介绍了微型多任务实时内核——MicroStar的设计与实现。消息和信号两种通信方式的提供,使其对事件驱动编程有很好的支持。较为完善的定时器服务和齐全的任务同步函数库,给用户提供了更多、更灵活的选择。有限的功能,使其与其它实时操作系统相比,减小了从技术掌握上所花费的时间。加上较低的存储器消耗,总体上说,MicroStar是比较适合在中低端MCU平台上运行的。