使用KEIL C51实现的简单合作式多任务操作系统内核

时间:2022-07-24
本文章向大家介绍使用KEIL C51实现的简单合作式多任务操作系统内核,主要内容包括其使用实例、应用技巧、基本知识点总结和需要注意事项,具有一定的参考价值,需要的朋友可以参考一下。

以前做课程设计时候,在51上实现了一个简单的合作式操作系统内核。写的时候,主要是出于检验自己单片机原理和操作系统知识的目的。这个内核现在看来,功能非常简单,实时性也不高,但是它毕竟是在51单片机上用不到每个线程17B的内存实现了一个多任务并行处理功能,而且完全用C语言写成,没有用到汇编。所以整理发出,权为资料整理。

1 单片机上的多任务操作思路

在本实验当中,涉及到了实时性较高的电机控制,DS18B20的读写有严格的时序要求。而数码管动态显示、特别是按键扫描等涉及到了不定的延时。这两种设备在实时性上有着一定的冲突。因此,实现思路有三种: 1. 无限循环+中断的前后台系统。 2. 有限状态机(FSM)系统。 主要思路如下:一个定时器生成一个系统基准时间systick(如1ms加1) 。其它任务拆分为多个状态放入主循环当中,通过状态转换和systick进行工作。 例如,按键状态机分NOT_PRESSED, PRESS_DELAY, PRESSED,REALEASE_DELAY四个状态。 3. 使用调度器的操作系统。 第一种方式在应用简单的情况下,具有编写容易、系统本身不耗费资源的优点。但当程序复杂时,各模块前后耦合维护复杂,而且很难保证实时性(当高优先级任务需要处理时,会由于低优先级任务正在运行而得不到及时处理)。如果使用中断,则当任务变多时将没有足够的中断可用,而且中断当中加入过多的程序也是稳定性的大忌。 第二种方式主要思路如下:首先使用一个变量systick存放系统运行时间(在1ms定时器中断中自加)。而后每个外设结合systick,根据当前运行状态判断是否进行状态转换,并执行相应操作。该方法实时性好,逻辑性强,且不必对PC,SP进行操作。但缺点是程序编写非常复杂。 第三种方式将不同的模块分为不同的任务,并根据优先程度赋予不同的优先级。在调度器的作用下,各任务在宏观上达到了一个“并行运行”的效果。该方法实时性好,任务编写容易,由于采用了合作式调度器,也不必担心任务的可重入性。缺点是调度器编写复杂,且本身会产生一定开销。

1 多任务切换原理

CPU是依靠PC来确定执行的程序。所以要想在多个函数之间切换,理论上只需要修改PC值即可。但单纯的修改PC值的话,原有的运行状态就会丢失,所以必须保护此时的运行状态(寄存器R0~R8还有PSW,SP)。这个过程很像中断服务程序:函数调用过程中,LCALL指令等的返回值还有被保护的寄存器值将被保存在堆栈当中,待结束之后返回原程序时从堆栈恢复。除此之外,C语言中的一些局部变量也是存放在堆栈当中的。如图:

所以,最基本的调度器如下:在系统的初始化阶段,给每一个任务分配一个私有的栈空间。这样,在任务切换时,只需要将需要保护的现场PUSH入堆栈,将被切换的任务的现场恢复(将被保存的通用寄存器R0~R8和PSW写入),再将SP指向被切换任务的私有栈即可。如图:

2 KEIL C51多任务切换实现

对于KEIL C51而言,情况有所不同。KEIL C编译器在处理函数调用时的约定规则为"子函数有可能修改任务寄存器",因此编译器在调用前已释放所有寄存器,子函数无需考虑保护任何寄存器.因此,只需要修改堆栈SP和PC即可。

基于这一特性,调度器写为了一个C语言函数的形式。

最初写好的基本切换函数如下:

void os_switch()
{  
    task_sp[task_id] = SP;  
    if(++task_id == MAX_TASKS)  
        task_id = 0;   
    SP = task_sp[task_id];
}

逐句解释:

首先,任务A在合适地方调用该函数进行切换,当进入该函数之前,R0~R8已被释放无需保护,而LCALL指令将2字节的PC地址PUSH入堆栈,SP+2。

随后,当前任务A(任务号task_id)的堆栈栈顶SP存入数组task_sp[]中。而后task_id自加指向下一个任务B(溢出则归零)。

而后,SP指向了任务B的堆栈栈顶(被存在了task_sp[task_id])。此时栈顶的是任务B在上一次切换(调用os_switch())时被压入的断点PC地址。

当函数结束,调用RET指令返回时,任务B栈顶的断点PC地址被自动写入PC,函数从任务B上一次切换的位置继续执行。

3 带软件定时器的调度器

以上的基本调度器非常精简,调度开销也非常小。但是它实际上是一个无优先级的调度器,也不具备软件定时器功能。程序流程图如下:

而在一般的应用中,我们往往需要一个软件延时。例如:按键去抖、周期性采样等等。所以,这就要求有一个软件定时器功能。因此,修改调度器如下:

首先定义任务控制器数据结构,加入一个延时记录:

void os_switch()
{  
    task_sp[task_id] = SP;   
    if(++task_id == MAX_TASKS)   
        task_id = 0;   
    SP = task_sp[task_id]; 
}

这样,调度函数改为:

/*
 *    任务调度,转向当前延时时间到且优先级最高(id较小)任务
 */
void os_switch(void)//任务切换
{
        unsignedchar  i=OS_TASK_NUM;
        do{
                i--;
                if(os_task[i].delay==0)//如果有任务延时时间到,则跳转至相应任务
                        SP=os_task[i].sp;
        }while(i); //否则不改变SP,继续执行os_idle()
}

进入过程一样。在函数中首先将各个任务的delay--,如果计数为0则跳转至相应函数(SP赋值为相应的私有堆栈指针)。

可以看出,任务的id越小,优先级越高(例如任务1,2均计时到0,首先会任务2赋值给SP,而后检测到任务1也计时为0,SP会被任务1覆盖)。

但是这样有一个问题,假如任务0调用了os_switch()进行调度。而此时所有任务都尚未计时到0,则SP未修改,重新执行任务0,相当于任务0没能进行延时。这是不允许的。所以,必须加入一空闲任务

/*
 *    调度任务+空闲任务,执行任务调度;当前无需要调度任务则执行本任务
 */
void os_idle(void)
{
        while(1)
        {
                os_switch();
        }
}

空闲任务很简单,只是一个无限循环,不停的进行任务调度。当所有其它任务都挂起时,os_switch()就不会修改SP,因此任务仍然停留在SP当中。

Os_idle()也需要一个固定私有栈空间,由于不需要delay部分,因此只需要简单地定义:

data unsignedchar  os_idle_stack[15];

在其它操作系统中如uc/OS-II中,调度器是放在中断中的,而os_idle()在不加入其它功能时只是一个while(1)。但是,由于C51对中断程序的处理与普通函数不同,会视情况压入不同个数的寄存器(从3个到13个不等)。所以出于简单起见,将调度器放入了idle任务。相比较而言,效率有所下降。

而后,作为一个软件定时器,需要定时对计数变量delay更新,这一工作放入定时器:

void os_update_time(void)
/*
 *    更新任务延时表
 *    注:应定时更新,最好放入定时器中断
 */
{
        unsigned char  i=OS_TASK_NUM;
        do{
                i--;
                if(os_task[i].delay)
                        os_task[i].delay--;
        }while(i);
}

调用间隔可以任意但必须一致,本设计设定为1ms一次。

任务放弃CPU占用可以使用os_switch(),添加延时就需要:

void os_delay(unsigned char id,unsigned char        delay)
//修改任务工作块并跳转入os_idle()进行任务切换
{
        TR0=0;//关中断
        {
                os_task[id].delay+=delay;      //延时设定
                os_task[id].sp=SP;             //保存SP
                SP=os_idle_stack+1;            //SP指向os_idle_stack[1]
                                               //os_delay()结束后跳转os_idle()
        }
        TR0=1;
}

该函数将任务控制器OS_TASK添加延时、保存SP,并跳转入os_idle()执行切换。注意这里必须关闭中断TR0。这是为了防止在该函数中碰到定时器中断(调用os_update_time()),从而出现延时错误。例如:

在某时刻任务1使用os_delay()函数延时1ms,在os_task[id].delay+=delay;之后碰见中断,将os_task[id].delay--,这样os_task[id].delay将等于0,等同于没有进行延时。

4 任务控制器的数据结构和初始化

任务控制器的数据结构在上一节已经说的很清楚,再次列举如下:

typedef    struct
{
        unsigned char  delay;//当前延时剩余时间
        unsigned char        stack[OS_TASK_STACK_SIZE]; //私有堆栈
        unsigned char  sp;//私有堆栈指针
}OS_TASK;//任务工作块。

但有一点必须注意,任务控制器只能放在内存data区(低128B内存),换言之,所有任务控制器占用的RAM少于120B。这是因为51的堆栈只能放在data区,PUSH、POP指令也是操作的data区。

因此,可以说堆栈空间非常有限,任务的数量受到限制。最重要的是,任务中允许中断嵌套的子程序数目有限。私有堆栈当中,最低2B是任务入口;由于中断随时可能发生,因此必须从最坏情况考虑留出13B空间;剩下的才是子程序调用允许使用的。假如子程序中局部变量不多不需要将局部变量放入堆栈,则每嵌套一层子程序需要2B(LCALL压栈PC)。故定义:

#define    OS_TASK_STACK_SIZE        (2+13+2*3)//存放断点2B,中断函数可能压栈13B,子程序每嵌套一层2B
data       OS_TASK       os_task[OS_TASK_NUM];//必须定义为data(因堆栈只能在data区)

由于是全局变量,os_task[]元素初始值为0,故必须初始化。其函数为:

void os_load(unsigned char id,void(*func))
/*
 *    装载任务入对应工作块
 *    参数:任务id,任务函数
 */
{
        os_task[id].sp=os_task[id].stack+1;//私有堆栈指针指向私有堆栈
        os_task[id].stack[0]=(unsignedint)func&0xFF;//私有堆栈栈底存放任务函数入口
        os_task[id].stack[1]=(unsignedint)func>>8;
}

5 多任务系统编写规范

在作了如上处理之后,就可以方便地使用多任务系统了。

void main()
{
        //…初始化外设
        //…初始化os所用定时器
        os_load(0,os_task_0);
        //…初始化其它任务控制器
       os_idle_stack[0]=(unsignedint)os_idle&0xFF;
        os_idle_stack[1]=(unsignedint)os_idle>>8;
        SP=os_task[0].sp;//运行任务0
        return;//跳转,永不返回。
}

在main()函数当中,首先是初始化外设,而后使用os_load()初始化各任务控制器和空闲任务控制器。最后跳转入任务0,永不返回。

而各个任务则各自独立,为超级循环结构。简而言之,与一般程序的main()函数相同:

void os_task_0(void)
{
#define    OS_CUR_ID  (0)
static unsigned char i=0;
        //KEIL一般分配临时变量在RAM不在堆栈
//因此为了防止任务之间改写凡是作用域跨越os_delay()应作为static
        while(1)
        {
                MOTOR_Driver();
                os_delay(OS_CUR_ID,1);
        }
#undef OS_CUR_ID
}

有一点必须注意:局部变量必须定义为static。这是由于KEIL C51为了节省内存,局部变量只要可能就存放在了寄存器R0~R8中。这样,一旦任务切换,局部变量相当于被覆盖。

由于是合作式调度器,不存在抢占式调度器中任务被直接打断的风险。因此,除局部变量必须定义为static外,无需加入任何可重入性代码。

6 主要问题:

1. OS设计:

OS设计思路在第2节有详解,不再赘述。任务如何切换、延时如何加入、调度器位置(在中断中还是idle任务中)、数据结构如何设计、如何优化代码……都是曾经碰到的问题

以上问题固然有难度,但写起来并无“憋屈”之感,反而写完后颇有自得之意。但主要瓶颈在于51的内存特别是能作为堆栈的内存过小,这在程序设计上带来几个重大束缚:

l 可供嵌套的子函数嵌套深度过小,使得子程序设计时不敢嵌套过多,不得不在一个任务中集成过多功能,与模块化的思路不符。

l 可以运行的任务过少,使得任务中不得不加入多个外设控制,并使用状态机切换。这使得多任务运行的优势大大削弱。

l 为了能运行4个任务,不得不将data区(低128B)几乎全部占用。使得其它全局变量不得不放入idata(高128B)乃至pdata(外部RAM的低256B),使得程序运行效率下降

不过,由于本OS的易移植性,如果将该系统移植到可用栈内存更多的CPU上,该缺点即可几乎忽略不计。还可以加入更多更复杂的功能。

2. 准面向对象设计

在外设驱动编写时,起初试图将外设的所需变量和操作方法封装成一个类,例如:

typedef    struct
{
        long disp_num;               //待显示数字,-99999~999999
        unsigned char  ptr;  //动态扫描当前显示位指针
        unsigned char  point_signal;   //小数点标志位,对应位为1则对应位小数点显示
        void (*Set)(NIXIE_DISP_TYPE,long); //按照格式设定示数
        void (*Driver)(void);                             //驱动程序
}NIXIE_STRUCT;
NIXIE_STRUCT   DEV_NIXIE;

这样,驱动程序的组织更有条理,在编辑器中还能直接使用自动完成功能(少敲很多字啊)。更重要的是,当需要不同显示方式(如显示整数/负数/小数)时,只需要将不同的函数指针赋值给NIXIE.Driver(如NIXIE.Driver=NIXIE_Driver_Uint;),就能使用同一个代码(NIXIE.Driver();)调用函数,还减少了使用switch语句的开销,增改也更加容易。

在未加入OS之前,该方法是可行的。但是当加入OS之后,该方法就失效了。通过单步调试发现,当运行NIXIE.Driver();之后,程序跑飞。

上网查询,发现这一原因很复杂。不过简单说来,就是因为LCALL指令之后只能跟addr16这样的立即数,也就是说硬件不支持函数指针。而一般情况下,KEIL通过换算,将指针换算为了地址。例如,NIXIE.Driver();实际上换算为了如:

LCALL 0510H

所以,当使用OS时,程序的顺序执行结构被打乱,所以当然不能使用函数指针了。