新闻  |   论坛  |   博客  |   在线研讨会
设计自己的嵌入式操作系统--- 实现任务切换
mayer | 2009-05-22 18:48:12    阅读:7055   发布文章

设计自己的嵌入式操作系统--- 实现任务切换

 

一、概述

在较小的嵌入式系统中,常使用前后台系统的方式处理。而对于较大的系统,则通常由多个任务对系统“分而治之”。这些任务由操作系统提供机制进行管理与相互之间的协调。使用嵌入式操作系统的好处在于,允许多个任务分别实现各个系统功能,同时在各个任务间提供了协调的机制,任务只需关心要实现的功能。并且操作系统提供了诸如延时,存储管理的机制简化了代码的设计。

通常的小型嵌入式系统仅包含单个CPU。在单CPU上运行多任务,是通过快速的进行任务切换实现任务间的"并发"运行。对于CPU而言,它看到的只是一连串的指令序列;而在逻辑上来看,多个任务如同在同时运行。

所谓的任务其实只是完成某种功能的指令序列。在产ucos中,任务即表现为一函数。当该函数正在执行时,即表明正在执行该任务。当外部中断发生或因为等待外部事件时,当前任务必须暂停执行,切换至另一任务时,操作系统需要保存当前任务在执行时所有相关的信息,包含了处理器中寄存器的值,任务运行状态。等待该任务再次恢复执行时,操作系统将保存的信息恢复,任务再次开始运行时,诸如CPU寄存器的值和刚被中断时一样,任务会认为没有发生中断。这即为任务的上下文保存与恢复.

此次设计,实现了多任务的启动以及任务级的切换中断级任务切换及任务延时支持.

二、eos中的任务

在eos中,任务表现为以下形式:

    Void  task( void * pdata )

 { 

   // do something

   while( 1 )

   {

       // do something

    }

  }

对每个任务,在任务创建时会为其指定一优先级,任务之间的优先级可以相同。eos 的任务运行是抢占式的,即高优先级任务可以中断低优先级的任务。所以eos总是运行系统中具有最高优先级的任务,而同优先级的任务按时间片进行调度(R-R)。任务也可以主动放弃CPU 

系统的优先理论上为256个,但实际限于单片机的内部资源及系统的运行效率,一般设为最多16级。其优先级0最高,每个优先级任务数不限。最低优先级仅被空闲任务所有。

当前,任务的状态转换图为:

点击看大图

睡眠状态:即仅任务的代码驻留在存储器(flash)中,没有调用TaskCreate()由操作系统创建任务,没有为任务分配相应的资源如TCB

就绪状态:即为任务已具备运行条件,但因更高优先任务运行或同优先级的其它任务运行而暂时等待CPU.

运行状态:即为任务代码正为CPU所执行。如果运行时间片到,或有更高优先级任务就绪时,则被迫放度CPU

阻塞状态:当前系统仅支持由于延时而将任务进行的阻塞。当延时期满事件发生时,任务被置为就绪态。

三、任务相关数据结构

1)、定义基本的数据类型

    在config.h中声明如下:

typedef unsigned char   bool_t;

typedef unsigned char   u8_t; // 无符号8位整型

typedef signed char    s8_t;

typedef unsigned short  u16_t;

typedef signed short   s16_t;

typedef unsigned long   u32_t;

typedef signed long    s32_t; 

   为提高可移植性,使用了类型的别名。如无符号32位整型以u16_t替代( u---无符号, s---有符号 )。在不同的编译器下,unsigned int的位数可能为16位,也可能为32位,以u16_t 替代int,方便eos的移植。

在core.h中:

typedef unsigned char  STACK; /* 堆栈类型 */

typedef void * EVENT; /* 事件类型 */

typedef void ( * TASK )( void * pdata ); /* 任务入口代码 */

STACK代表了处理压栈的单元。在Atmega32上,以字节为单位进行压栈EVENT,当前未用。

typedef struct _TCB /* TCB控制块  */

{

STACK * stktop; /* 任务栈顶 */

    char name[ CFG_TASK_NAME_LEN ]; // 任务命名

struct _TCB * WNext, * WPre;

struct _TCB * TNext, * TPre;

u8_t prio;  /* 任务优先级*/

u16_t delay; /* 任务延时值 */

void * event; /* 事件 */

}TCB;

   在eos中,TCB包含了任务运行所需要的所有信息。可以说TCB的存储标识了一个任务的存在。

.     stktop ----- 指向了任务的栈顶。当任务运行时,其所指的存储区域会作为该任务的堆栈保护变量及任务的上下文。

   .name ---- 保护在任务创建时指定的命名串,方便调试。

   .WNext, .WPre ----- 用于将任务链接至就绪、等待队列中。

   .TNext, TPre -------- 所有创建的任务都会链接至同一双向链表,该指针将TCB链接至链表中.

   .event -------- 目前未用。

    对TCB的支持操作:

     void TCBInit( char * name, TCB * tcb, u8_t prio, STACK * stktop ); // 初始化TCB.

typedef struct /* 等待队列 */

{

TCB  * tbl[ OS_PRIO_MAX + 1 ];

}OSWaitQ;

OSWaitQ定义了一优先级队列。它实际上是指针数组,每个数组元素保存了一双向循环链表的头指针。该双向循环链表的结点为任务TCB。对于所有的就绪任务,维护了一个就绪队列OSRdyQ,其中按优先级链接了所有任务的TCB。其结构如下图所示;

    该多级队列用于支持任务调度中的优先级调度。OS从该就绪队列中取出最高优先级的任务运行。也可用于信号量等实现中的任务等待队列。

  

另外,在eos中,一旦任务被创建,其TCB就被插入至TCBList所指向的双向链表中,由.TNext, TPre指针完成链表的链接。结构大致如上图所示,但不存在多优先级,而只有一个双向链表。该链表主要用在任务延中。在系统的时钟中断处理中,中断服务程序会遍历该链表,将延时期满的任务置为就绪态。采用双向循环链表的好处在于容易实现插入与删除操作。

OSWaitQ的支持函数:

1)、void OSWaitQInit( OSWaitQ * q ); 将指针数组清空。

2)、void OSWaitQPut( OSWaitQ * q, TCB * tcb, u8_t prio ); 将tcb按优先级插入相应的队列中。

3)、void OSWaitQRemove( OSWaitQ * q, TCB * tcb ); 将tcb从队列中移除.

4)、void OSWaitQMove( OSWaitQ * src, OSWaitQ * dest );从源队列中取最高优先级任务插入至目的队列中。用于支持将阻塞的任务置为就绪态或反之,这里未用。

   对TCBList所在的双向循环链表支持函数:

   1)void TCBListInsert( TCB * tcb ); 将TCB插入TCBList指向的链表

四、eos启动运行的支持模块

   1)、临界区的保护:

   临界区的保护,用于处理多个任务访问共享变量或资源可能出现的数据不一致问题。

   在config.h中,提供了如下临界区保护:

#define OS_ARCH_VAR // 变量声明

#define OS_ARCH_PROTECT()  asm("cli")    // 进入临界区

#define OS_ARCH_UNPROTECT()  asm("sei")    // 退出临界区

    cli, sei 用于实现开关中断。当任务进入临界区时,简单的关掉中断,避免在访问共享变量或资源时被中断。

2)、SCB模块

  在mem.h, mem.c中完整的实现了SCB模块。SCB模块即system control block ,负责管理系统控制块,如TCB的分配与回收。简单的说,即实现系统控制块的存储分配与回收。不同于C库中的malloc,free,这里的管理机制仅针对于操作系统内核的核心数据结构的管理。

SCB模块实现了一种按多种固定大小存储块的分配与回收。在系统初始化时,查找SCBSize, SCBNum表取出每种类型控制块大小及数量,再从SCBMem指事定的存储区依次取出相应大小和数量的存储块,按类型链接在SCBLinks中的不同链表中。最终的结果是SCBLinks中每一项所向某一种类型的空闲控制块。eos在申请诸如TCB这样的块时,即从该链表中上取下。

目前,eos只负责管理TCB的分配与回收。

SCB支持操作:

  1)void SCBInit( void );        系统存储控制块管理器的初始化

 2)、void * SCBGet( enum SCB_TYPE type ); 根据类型分配一个控制块

  3)void SCBFree( void * scb );          将控制块返回至系控制块池

  在ucos中,对于不同的控制块,将其构成了多个不同的链表。每次进行分配时必须以不同的代码实现,实际的操作是相同的。如果考虑使用动态存储管理机制来实现,显然对嵌入式操作系统而言,显然太复杂,效率也太低;这里使用的是基于固定大小的存储块管理,其分配与回收操作类似于“链式栈”的操作,只需在相应的链表的表头进行插入与删除操作即可。

3)、调度器的实现

这里仍然参考ucos的实现。实现于core.h, core.c中。

首先,对eos内所有的就绪任务,提供了一多级队列结构。相关变量定义如下:

u8_t SchedulerLockCnt;     // 调度锁

u8_t IntLockCnt;                                 // 中断锁

TCB * TCBCur; // 当前运行任务TCB

OSWaitQ OSRdyQ; // 就绪多级队列

其中OSRdyQ保存了所有已就绪任务的TCB。调度器从OSRdyQ中取出最高优先级的任务TCB,将CPU分派给该任务,并将TCBCur指向它。

SchedulerLockCnt 为一调度锁,当其不为0时,调度被禁止,此时中断仍可被响应。

    支持函数:

void SchedulerLock( void ); // 加锁

void SchedulerUnlock( void ) // 解锁

IntLockCnt 为一中断锁,当进入中断是,调用IntLock()加锁。

   支持函数:

        void    IntLock( void ) // 加锁

         void IntUnLock( void ); // 解锁

  调度函数scheduler()

      scheduler()被设计中既支持任务级调度,也可在中断中直接调用。所谓任务级调度,即在任务中直接调用scheduler()其工作流程如下:

点击看大图

为了支持中断级调度和任务级调度,scheduler()实现有些复杂。主要注意的几点:

1)、eos支持中断的嵌套。IntLockCnt记数即为嵌套的层次。当处理最外层中断处理程序时,即IntLockCnt == 1时,需要在中断中进行任务调度以切换至任务。

2)、从OSRdyQ中找出最高优先级任务的方法,采用了线性查找的方式。简单,占用空间小。

3)、任务级的上下文切换TaskYield与TaskIntYeild都是切换至TCBCur,但由于在中断服务中断级切换是在中断服务程序中,中断的入口处即已保存任务的上下文,因为只需要简单的恢复TCBCur的上下文即可。详细见后文所述.

4)、任务延时机制的实现

前面提到,TCBList维护了系统中所有已创建任务TCB的链表,eos的延时机制正是基于此链表实现。

当调用void TaskDly( u16_t ticks ); ( task.c)将TCBCur->delay置为要延时的ticks数,并简单的将其从就绪队列,使得其不再进行调度。

  在系统时钟中断服务程序中,将调用OSTimeTick() ( core.c )中,遍历所有TCBList所有结点,并将TCB->delay--,当其为0时,再将其插入就绪队列OSRdyQ中等待CPU调度.

5)任务的创建与管理

当前eos仅支持任务的创建,由

TCB * TaskCreate( char * name, TASK task, void *pdata, STACK * stk_base, u16_t stk_len, u8_t prio ); ( task.c )支持。

其基本操作是:1、分配TCB2、初始化任务栈; 3、初始TCB;4、将任务插入相应队列中。其中关于堆栈的初始化在后面说明。

五、eos的启动流程

eos的启动过程大致为: main ------ TaskInit() -- TaskStart() ---- 多任务.

TaskInit() 负责整个系统的初始化。主要初始化内部用到的变量,数据结构,及创建空闲任务。

在TaskInit()可添加创建其它任务的代码。

TaskStart() 打开调度锁,并切换至最高优先级任务,由此启动多任务。

六、多任务实现的机制

在单处理机中,所谓的“多任务”采用并发的方式,即某一时刻,只有一多任务运行,cpu通过在不同的时间进行快速的任务切换,以实现各任务的并行运行的“假像”。

  前面提到,在eos中,任务表现为以下形式:

    Void  task( void * pdata )

 { 

   // do something

   while( 1 )

   {

       // do something

    }

  }

当处理器运行任务A的代码时,因为事件的触发,如时间片到,被高优先级任务抢占,任务被中断,相应任务执行环境的上下文(主要是cpu寄存器)被保存,当任务再次运行时,该上下文被恢复,任务可从断点完全的恢复,对任务而言,仿佛其完全占用cpu。

所以,多任务实现的关键在于任务上下文的保护。

eos将任务上下文保存在堆栈中,每个任务在创建时会指定一堆栈,在TCB中的stk_top指定该堆栈的栈顶。 针对Atmega32,上下文的保存主要指保存R0--R31, SREG. PC.其中R0-R31为通用寄存器,SREG为状态寄存器,PC为程序计数器。

当调用TaskCreate()创建任务时, 堆栈的初始化由TaskStkInit()完成。在初始化中,实际只需要关心保存task的起始运行地址,其它寄存器中的内容可以随意设置..

stk = TaskStkInit( task, stk_top, pdata );

STACK * TCBStkInit( TASK task, void * pdata, STACK * stk )

{

STACK * ptos = stk;

*ptos-- = ( (u16_t)task ) & 0xff; /* PC,保存任务起始运行地址 */

*ptos-- = ( (u16_t)task >> 8 ) & 0xff; //* PC,保存任务起始运行地址 */

*ptos-- = 0; /* Register from r0 to r31 */

*ptos-- = 0; /* 注意,一般R10 */

*ptos-- = 2;

*ptos-- = 3;

*ptos-- = 4;

*ptos-- = 5;

*ptos-- = 6;

*ptos-- = 7;

*ptos-- = 8;

*ptos-- = 9;

*ptos-- = 10;

*ptos-- = 11;

*ptos-- = 12;

*ptos-- = 13;

*ptos-- = 14;

*ptos-- = 15;

*ptos-- = 16;

*ptos-- = 17;

*ptos-- = 18;

*ptos-- = 19;

*ptos-- = 20;

*ptos-- = 21;

*ptos-- = 22;

*ptos-- = 23;

*ptos-- = (u16_t)pdata & 0xff; /* Save 任务参数in r26:r25 */

*ptos-- = ( (u16_t)pdata>>8) & 0xff;

*ptos-- = 26;

*ptos-- = 27;

*ptos-- = 28;

*ptos-- = 29;

*ptos-- = 30;

*ptos-- = 31;

*ptos-- = 0x80; /* Save SREG, interrpt enabled */

return ptos;

}

本来在任务创建时,其堆栈应该是空的。但是当首次运行任务时,是通过scheduler()实现的,而scheduler()通过调用TaskYield或TaskIntYield恢复需要运行任务的上下文。所以,对已创建好但未运行过的任务,必须初始化堆栈,为恢复任务上下文提供必要的信息。这里stkInit压栈顺序相反,首先恢复SREG, R31, R30,..., R0, 再执行一条ret / reti 指令,即可返回到执行task地址处的代码。任务开始运行。

eos任务的切换

  任务的切换是在scheduler()通过调用TaskYield或TaskIntYield实现。

任务的切换即为当前任务保存上下文,待运行任务恢复上下文.A为待运行任务,B为当前任务,进行任务切换时,cpu与任务A,B的状态如图所示:

点击看大图

切换时,任务B首先调用schdueler(),schdueler()找出下一待运行任务TCB,再调用TaskYeild()硬件自动保存返回地址至当前堆栈,保存R0, R1 -- R31, SREG。再将当前的SP保存至任务B的TCB中的stktop中,以备上下文的恢复.。再取任务ATCB中的的stktop恢复至SP, 恢复SREG, R31,R30 -- R0, 当执行ret时,恢复taskPCPC转至任务A中的代码执行.

七、eos的实际运行--小试牛刀

在eos的第一任务MainTask()中,创建了5个任务。这些任务共用同一代码Task.且优先组相同。

int main( void )

{

TaskInit();

    TaskCreate("t1", task1, (void *)0, &stk[0][99], 0, 1 );

    TaskCreate("t2", task2, (void *)0, &stk[1][99], 0, 2 );

    TaskCreate("t3", task3, (void *)0, &stk[2][99], 0, 3 );

    TaskCreate("t4", task4, (void *)0, &stk[3][99], 0, 4 );

    TaskStart();

return 0;

}

void    task1( void * pdata)

{

TimerInit();                            // 初始化时钟节拍

    DDRB = 0xff;

    while(1) 

    {

        TaskDly( 20 );

        PORTB ^= 0x1;

    }

}

task1()中调用了TimerInit()初始化时钟节拍。该节拍由一定时器提供。在Atmega32上,由定时器0,在1MHZ频率下,节拍周期约为10ms。在时钟中断中调用了OSTimeTick()进行任务延时处理及实现同优先级的RR调度。

main()创建了4个任务,分别名为t1~t4,优先级分别为1~4。在各自的任务代码中,延时约20个tick后,对端口B的相应位进行取反。在proteus仿真中表现为闪烁LED(见proteus仿真文件).

八、设计心得与体会

1、目前eos所实现的机制还是非常简单的,而且在诸如中断的处理以及临界区的管理上还存在一些问题。但整个系统已经可以启动,多任务已经运行。正如ucos作者所说的,写一个操作系统真的有那么难吗?,当然写一个优秀的不是容易的事。但有什么关系呢?我初步的目的已经达到了,基本的内核雏形已经有了。这不就是一大进步么?

  2、想起以前看《自己动手写操作系统》那本书,以及看ucos的源码分析书时,当时有很多不解。很多细节始终弄不明白。特别是看ucos源码时,总是“只见树木,不见森林”。而只有在自己尝试写一个简单的任务切换程序后,豁然开朗,终于明白整个系统是如何运行的。而这次写的内核雏形,进一步加深了对操作系统、数据结构及相关硬件的了解,也开始了解到在代码实现需要考虑的一些问题。

3、总的来说,代码编写最难最容易出现问题的是涉及到堆栈的操作,主要是在汇编代码中。一旦压栈与出栈稍有差错,就会发生程序跑飞的情况。不过还好,好的工具有效的帮助解决问题。eos的整个实现,除了一部分与底层硬件相关外,其它的就是些与数据结构相关的东西。在本文开始就分析了这些。设计的好的数据结构能极大的简化实现,提高效率。

  4、在涉及寄存器的直接操作,如上下文的保存与恢复时,以及任务调度代码中,可直接采用C语言及C内嵌汇编。可能很多人认为直接使用汇编比用C要高的多,但我认为,这还要先看代码编写者的水平。可能编写出的汇编代码质量比编译器生成的代码质量要差。

5、在设计eos时,首要的问题是明白需求是什么,要达到什么样的目标?再者是才虑采用什么样的数据结构。数据结构基本上决定整个OS的实现。复杂的数据结构能实现复杂技的功能,但占用的存储空间要多,操作起来也相对复杂。实际的代码编写中,还需要在存储空间与执行时间上作出一定的权衡。在数据结构知识的掌握上,链表和队列的东西相对好些,其它的还有待加强。

目前的代码量也就4、5百行的样子,相比之前写的差不多1000行,缩压了将近一半。前期的代码完全按数据结构进行实现,虽然层次清晰,但效率太低,后进行了简化。为了效率,不得不作如此决定。

6、接下来的设计,将实现完整的任务管理机制。

*博客内容为网友个人发布,仅代表博主个人观点,如有侵权请联系工作人员删除。

参与讨论
登录后参与讨论
推荐文章
最近访客