简单的51单片机多任务操作系统(C51)

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

在网上看到这段代码,所以自己尝试了,可以跑起来,但是没有精确的定时功能,仅仅是任务的调度而已。

数组中是11,而不是12。这里写错了。。。

/*

简单的多任务操作系统

其实只有个任务调度切换,把说它是OS有点牵强,但它对于一些简单的开发应用来说,
简单也许就是最好的.尽情的扩展它吧.别忘了把你的成果分享给大家.

这是一个最简单的OS,一切以运行效率为重,经测试,切换一次任务仅20个机器周期,
也就是在标准51(工作于12M晶振)上20uS.
而为速度作出的牺牲是,为了给每个任务都分配一个私有堆栈,而占用了较多的内存.
作为补偿,多任务更容易安排程序逻辑,从而可以节省一些用于控制的变量.
任务槽越多,占用内存越多,但任务也越好安排,以实际需求合理安排任务数目.
一般来说,4个已足够.况且可以拿一个槽出来作为活动槽,换入换入一些临时任务.

task_load(函数名,任务槽号)
装载任务

os_start(任务槽号)
启动任务表.参数必须指向一个装载了的任务,否则系统会崩溃.

task_switch()
切换到其它任务


.编写任务函数注意事项:
KEIL C编译器是假定用户使用单任务环境,所以在变量的使用上都未对多任务进行处理,
编写任务时应注意变量覆盖和代码重入问题.

1.覆盖:编译器为了节省内存,会给两个没用调用关系的函数分配同一内存地址作为变量空间.
这在单任务下是很合理的,但对于多任务来说,两个进程会互相干扰对方.
解决的方法是:凡作用域内会跨越task_switch()的变量,都使用static前辍,
保证其地址空间分配时的唯一性.

2.重入:重入并不是多任务下独有的问题,在单任务时,函数递归同样会导致重入,
即,一个函数的不同实例(或者叫作"复本")之间的变量覆盖问题.
解决的方法是:使用reentrant函数后辍(例如:void function1() reentrant{...}).当然,根本的办法还是避免重入,因为重入会带来巨大的目标代码量,
并极大降低运行效率.

3.额外提醒一句,在本例中,任务函数必须为一个死循环.退出函数会导致系统崩溃.


.任务函数如果是用汇编写成或内嵌汇编,切换任务时应该注意什么问题?

由于KEIL C编译器在处理函数调用时的约定规则为"子函数有可能修改任务寄存器",
因此编译器在调用前已释放所有寄存器,子函数无需考虑保护任何寄存器.
这对于写惯汇编的人来说有点不习惯: 汇编习惯于在子程序中保护寄存器.
请注意一条原则:凡是需要跨越task_switch()的寄存器,全部需要保护(例如入栈).
根本解决办法还是,不要让寄存器跨越任务切换函数task_switch()
事实上这里要补充一下,正如前所说,由于编译器存在变量地址覆盖优化,
因此凡是非静态变量都不得跨越task_switch().


任务函数的书写:
void 函数名(void)
{	//任务函数必须定义为无参数型
	while(1)
	{
		//任务函数不得返回,必须为死循环
		//....这里写任务处理代码

		task_switch();//每执行一段时间任务,就释放CPU一下,
		让别的任务有机会运行.
	}
}


任务装载:
task_load(函数名,任务槽号)

装载函数的动作可发生在任意时候,但通常是在main()中.要注意的是,
在本例中由于没考虑任务换出,
所以在执行os_start()前必须将所有任务槽装满.之后可以随意更换任务槽中的任务.

启动任务调度器:
os_start(任务槽号)

调用该宏后,将从参数指定的任务槽开始执行任务调度.
本例为每切换一次任务需额外开销20个机器周期,用于迁移堆栈.
*/


#include <reg52.h>

sbit LED1 = P2 ^ 0;
sbit LED2 = P2 ^ 1;
void func1();
void func2();

/*============================以下为任务管理器代码============================*/

//任务槽个数.在本例中并未考虑任务换入换出,所以实际运行的任务有多少个,
//就定义多少个任务槽,不可多定义或少定义
#define MAX_TASKS 5

//任务的栈指针
unsigned char idata task_sp[MAX_TASKS];

//最大栈深.最低不得少于2个,保守值为12.
//预估方法:以2为基数,每增加一层函数调用,加2字节.
//如果其间可能发生中断,则还要再加上中断需要的栈深.
//减小栈深的方法:1.尽量少嵌套子程序 2.调子程序前关中断.
#define MAX_TASK_DEP 12

unsigned char idata task_stack[MAX_TASKS][MAX_TASK_DEP];//任务堆栈.

unsigned char task_id;		//当前活动任务号


//任务切换函数(任务调度器)
void task_switch()
{
    task_sp[task_id] = SP;		//保存当前任务的栈指针

    if (++task_id == MAX_TASKS)	//任务号切换到下一个任务
        task_id = 0;

    SP = task_sp[task_id];		//将系统的栈指针指向下个任务的私栈
}




//任务装入函数.将指定的函数(参数1)装入指定(参数2)的任务槽中.
//如果该槽中原来就有任务,则原任务丢失,但系统本身不会发生错误.
//将各任务的函数地址的低字节和高字节分别入在
//task_stack[任务号][0]和task_stack[任务号][1]中
void task_load(unsigned int fn, unsigned char tid)
{
    //task_sp[tid] = task_stack[tid][1];
    task_sp[tid] = task_stack[tid] + 1;
    task_stack[tid][0] = (unsigned int)fn & 0xff;
    task_stack[tid][1] = (unsigned int)fn >> 8;
}

//从指定的任务开始运行任务调度.调用该宏后,将永不返回.
#define os_start(tid) {task_id = tid,SP = task_sp[tid];return;}




/*============================以下为测试代码============================*/


unsigned char stra[3], strb[3];//用于内存块复制测试的数组.


//测试任务:复制内存块.每复制一个字节释放CPU一次
void task1()
{
    //每复制一个字节释放CPU一次,控制循环的变量必须考虑覆盖
    static unsigned char i;//如果将这个变量前的static去掉,会发生什么事?
    i = 0;

    while (1) //任务必须为死循环,不得退出函数,否则系统会崩溃
    {
        stra[i] = strb[i];
        if (++i == sizeof(stra))
            i = 0;

        //变量i在这里跨越了task_switch(),因此它必须定义为静态(static),
        //否则它将会被其它进程修改,因为在另一个进程里也会用到该变量所占用的地址.
        task_switch();//释放CPU一会儿,让其它进程有机会运行.如果去掉该行,则别的进程永远不会被调用到
    }
}

//测试任务:复制内存块.每复制一个字节释放CPU一次.
void task2()
{
    //每复制一个字节释放CPU一次,控制循环的变量必须考虑覆盖
    static unsigned char i;//如果将这个变量前的static去掉,将会发生覆盖问题.
    //task1()和task2()会被编译器分配到同一个内存地址上,当两个任务同时运行时,i的值就会被两个任务改来改去
    i = 0;

    while (1) //任务必须为死循环,不得退出函数,否则系统会崩溃
    {
        stra[i] = strb[i];
        if (++i == sizeof(stra))
            i = 0;

        //变量i在这里跨越了task_switch(),因此它必须定义为静态(static),
        //否则它将会被其它进程修改,因为在另一个进程里也会用到该变量所占用的地址.
        task_switch();//释放CPU一会儿,让其它进程有机会运行.如果去掉该行,则别的进程永远不会被调用到
    }
}

//测试任务:复制内存块.复制完所有字节后释放CPU一次.
void task3()
{
    //复制全部字节后才释放CPU,控制循环的变量不须考虑覆盖
    unsigned char i;//这个变量前不需要加static,
    //因为在它的作用域内并没有释放过CPU

    while (1) //任务必须为死循环,不得退出函数,否则系统会崩溃
    {
        i = sizeof(stra);
        do
        {
            stra[i-1] = strb[i-1];
        }
        while (--i);

        //变量i在这里已完成它的使命,所以无需定义为静态.
        //你甚至可以定义为寄存器型(regiter)
        task_switch();//释放CPU一会儿,让其它进程有机会运行.如果去掉该行,
        //则别的进程永远不会被调用到
    }
}



/*
my first task
*/

void func1()
{
    static unsigned char data i;
    i = 0;

    while (1)
    {
        //25ms
        if (i < 250)
        {
            i++;
        }
        if (i >= 250)
        {
            LED1 = ~LED1;
            i = 0;
        }
        task_switch();
    }
}

//经过仿真计算得j=10,即等待1ms
void func2()
{
    static unsigned int data j;
    j = 0;

    while (1)
    {
        //65ms
        if (j < 650)
        {
            j++;
        }
        if (j >= 650)
        {
            LED2 = ~LED2;
            j = 0;
        }
        task_switch();
    }
}

/*
keyscan task

void task3()
{
	while(1)
	{
        i = 5;
        do
		{
                sigl = !sigl;
        }while(--i);
        task_switch();
    }
}

*/

/*
void func1()
{
    register char data i;
    while(1)
	{
        i = 5;
        do
		{
                sigl = !sigl;
        }while(--i);
        task_switch();
    }
}
void func2()
{
    register char data i;
    while(1)
	{
        i = 5;
        do
		{
                sigl = !sigl;
        }while(--i);
        task_switch();
    }
}
*/

void main()
{
    //在这个示例里并没有考虑任务的换入换出,所以任务槽必须全部用完,否则系统会崩溃.
    //这里装载了三个任务,因此在定义MAX_TASKS时也必须定义为5
    task_load(task1, 0);//将task1函数装入0号槽
    task_load(task2, 1);//将task2函数装入1号槽
    task_load(task3, 2);//将task3函数装入2号槽
    task_load(func1, 3);//将task3函数装入3号槽
    task_load(func2, 4);//将task3函数装入4号槽

    os_start(0);//启动任务调度,并从0号槽开始运行.参数改为1,则首先运行1号槽.
    //调用该宏后,程序流将永不再返回main(),也就是说,该语句行之后的所有语句都不被执行到.
}

原文如下所示

给51DIY超轻量级多任务操作系统  2009-05-30 2149


想了很久,要不要写这篇文章最后觉得对操作系统感兴趣的人还是很多,写吧.我不一定能造出玉,但我可以抛出砖. 

包括我在内的很多人都对51使用操作系统呈悲观态度,因为51的片上资源太少.但对于很多要求不高的系统来说,使用操作系统可以使代码变得更直观,易于维护,所以在51上仍有操作系统的生存机会. 

流行的uCos,Tiny51等,其实都不适合在2051这样的片子上用,占资源较多,唯有自已动手,以不变应万变,才能让51也有操作系统可用.这篇贴子的目的,是教会大家如何现场写一个OS,而不是给大家提供一个OS版本.提供的所有代码,也都是示例代码,所以不要因为它没什么功能就说LAJI之类的话.如果把功能写全了,一来估计你也不想看了,二来也失去灵活性没有价值了. 


下面的贴一个示例出来,可以清楚的看到,OS本身只有不到10行源代码,编译后的目标代码60字节,任务切换消耗为20个机器周期.相比之下,KEIL内嵌的TINY51目标代码为800字节,切换消耗100~700周期.唯一不足之处是,每个任务要占用掉十几字节的堆栈,所以任务数不能太多,用在128B内存的51里有点难度,但对于52来说问题不大.这套代码在36M主频的STC12C4052上实测,切换任务仅需2uS. 


#include reg51.h  

#define MAX_TASKS 2       任务槽个数.必须和实际任务数一至  
#define MAX_TASK_DEP 12   最大栈深.最低不得少于2个,保守值为12.  
unsigned char idata task_stack[MAX_TASKS][MAX_TASK_DEP];任务堆栈.  
unsigned char task_id;    当前活动任务号  


任务切换函数(任务调度器)  
void task_switch()
{  
        task_sp[task_id] = SP;  

        if(++task_id == MAX_TASKS)  
                task_id = 0;  

        SP = task_sp[task_id];  
}  

任务装入函数.将指定的函数(参数1)装入指定(参数2)的任务槽中.如果该槽中原来就有任务,则原任务丢失,但系统本身不会发生错误.  
void task_load(unsigned int fn, unsigned char tid)
{  
        task_sp[tid] = task_stack[tid] + 1;  
        task_stack[tid][0] = (unsigned int)fn & 0xff;  
        task_stack[tid][1] = (unsigned int)fn  8;  
}  

从指定的任务开始运行任务调度.调用该宏后,将永不返回.  
#define os_start(tid) {task_id = tid,SP = task_sp[tid];return;}  




============================以下为测试代码============================  

void task1()
{  
        static unsigned char i;  
        while(1)
        {  
                i++;  
                task_switch();编译后在这里打上断点  
        }  
}  

void task2()
{  
        static unsigned char j;  
        while(1)
        {  
                j+=2;  
                task_switch();编译后在这里打上断点  
        }  
}  

void main()
{  
        这里装载了两个任务,因此在定义MAX_TASKS时也必须定义为2  
        task_load(task1, 0);将task1函数装入0号槽  
        task_load(task2, 1);将task2函数装入1号槽  
        os_start(0);  
}  




这样一个简单的多任务系统虽然不能称得上真正的操作系统,但只要你了解了它的原理,就能轻易地将它扩展得非常强大,想知道要如何做吗

 一.什么是操作系统 



人脑比较容易接受类比这种表达方式,我就用公交系统来类比操作系统吧. 

当我们要解决一个问题的时候,是用某种处理手段去完成它,这就是我们常说的方法,计算机里叫程序(有时候也可以叫它算法). 
以出行为例,当我们要从A地走到B地的时候,可以走着去,也可以飞着去,可以走直线,也可以绕弯路,只要能从A地到B地,都叫作方法.这种从A地到B的需求,相当于计算机里的任务,而实现从A地到B地的方法,叫作任务处理流程 

很显然,这些走法中,并不是每种都合理,有些傻子都会采用的,有些是傻子都不采会用的.用计算机的话来说就是,有的任务处理流程好,有的任务处理流程好,有的处理流程差. 
可以归纳出这么几种真正算得上方法的方法 
有些走法比较快速,适合于赶时间的人;有些走法比较省事,适合于懒人;有些走法比较便宜,适合于穷人. 
用计算机的话说就是,有些省CPU,有些流程简单,有些对系统资源要求低. 

现在我们可以看到一个问题 
如果全世界所有的资源给你一个人用(单任务独占全部资源),那最适合你需求的方法就是好方法.但事实上要外出的人很多,例如10个人(10个任务),却只有1辆车(1套资源),这叫作资源争用. 
如果每个人都要使用最适合他需求的方法,那司机就只好给他们一人跑一趟了,而在任一时刻里,车上只有一个乘客.这叫作顺序执行,我们可以看到这种方法对系统资源的浪费是严重的. 
如果我们没有法力将1台车变成10台车来送这10个人,就只好制定一些机制和约定,让1台车看起来像10台车,来解决这个问题的办法想必大家都知道,那就是制定公交线路. 
最简单的办法是将所有旅客需要走的起点与终点串成一条线,车在这条线上开,乘客则自已决定上下车.这就是最简单的公交线路.它很差劲,但起码解决客人们对车争用.对应到计算机里,就是把所有任务的代码混在一起执行. 
这样做既不优异雅,也没效率,于是司机想了个办法,把这些客户叫到一起商量,将所有客人出行的起点与终点罗列出来,统计这些线路的使用频度,然后制定出公交线路有些路线可以合并起来成为一条线路,而那些不能合并的路线,则另行开辟行车车次,这叫作任务定义.另外,对于人多路线,车次排多点,时间上也优先安排,这叫作任务优先级. 
经过这样的安排后,虽然仍只有一辆车,但运载能力却大多了.这套车次路线的按排,就是一套公交系统.哈,知道什么叫操作系统了吧它也就是这么样的一种约定. 




操作系统 


我们先回过头归纳一下 
汽车                                            系统资源.主要指的是CPU,当然还有其它,比如内存,定时器,中断源等. 
客户出行                                        任务 
正在走的路线                                    进程 
一个一个的运送旅客                              顺序执行 
同时运送所有旅客                                多任务并行 
按不同的使用频度制定路线并优先跑较繁忙的路线    任务优先级 


计算机内有各种资源,单从硬件上说,就有CPU,内存,定时器,中断源,IO端口等.而且还会派生出来很多软件资源,例如消息池. 
操作系统的存在,就是为了让这些资源能被合理地分配. 
最后我们来总结一下,所谓操作系统,以我们目前权宜的理解就是为解决计算机资源争用而制定出的一种约定. 
 二.51上的操作系统 

对于一个操作系统来说,最重要的莫过于并行多任务.在这里要澄清一下,不要拿当年的DOS来说事,时代不同了.况且当年IBM和小比尔着急将PC搬上市,所以才抄袭PLM(好象是叫这个名吧记不太清)搞了个今天看来很粗制滥造的DOS出来.看看当时真正的操作系统---UNIX,它还在纸上时就已经是多任务的了. 

对于我们PC来说,要实现多任务并不是什么问题,但换到MCU却很头痛 

1.系统资源少 
在PC上,CPU主频以G为单位,内存以GB为单位,而MCU的主频通常只有十几M,内存则是Byts.在这么少的资源上同时运行多个任务,就意味着操作系统必须尽可能的少占用硬件资源. 
2.任务实时性要求高 
PC并不需要太关心实时性,因为PC上几乎所有的实时任务都被专门的硬件所接管,例如所有的声卡网卡显示上都内置有DSP以及大量的缓存.CPU只需坐在那里指手划脚告诉这些板卡如何应付实时信息就行了. 
而MCU不同,实时信息是靠CPU来处理的,缓存也非常有限,甚至没有缓存.一旦信息到达,CPU必须在极短的时间内响应,否则信息就会丢失. 
就拿串口通信来举例,在标准的PC架构里,巨大的内存允许将信息保存足够长的时间.而对于MCU来说内存有限,例如51仅有128字节内存,还要扣除掉寄存器组占用掉的8~32个字节,所以通常都仅用几个字节来缓冲.当然,你可以将数据的接收与处理的过程合并,但对于一个操作系统来说,不推荐这么做. 
假定以115200bps通信速率向MCU传数据,则每个字节的传送时间约为9uS,假定缓存为8字节,则串口处理任务必须在70uS内响应. 


这两个问题都指向了同一种解决思路操作系统必须轻量轻量再轻量,最好是不占资源(那当然是做梦啦). 

可用于MCU的操作系统很多,但适合51(这里的51专指无扩展内存的51)几乎没有.前阵子见过一个圈圈操作系统,那是我所见过的操作系统里最轻量的,但仍有改进的余地. 

很多人认为,51根本不适合使用操作系统.其实我对这种说法并不完全接受,否则也没有这篇文章了. 
我的看法是,51不适合采用通用操作系统.所谓通用操作系统就是,不论你是什么样的应用需求,也不管你用什么芯片,只要你是51,通通用同一个操作系统. 

这种想法对于PC来说没问题,对于嵌入式来说也不错,对AVR来说还凑合,而对于51这种贫穷型的MCU来说,不行. 
怎样行量体裁衣,现场根据需求构建一个操作系统出来! 

看到这里,估计很多人要翻白眼了,大体上两种 
1.操作系统那么复杂,说造就造,当自已是神了 
2.操作系统那么复杂,现场造一个会不会出BUG 
哈哈,看清楚了问题出在复杂上面,如果操作系统不复杂,问题不就解决了 

事实上,很多人对操作系统的理解是片面的,操作系统不一定要做得很复杂很全面,就算几个多任务并行管理能力,你也可以称它操作系统. 
只要你对多任务并行的原理有所了解,就不难现场写一个出来,而一旦你做到了这一点,为各任务间安排通信约定,使之发展成一个为你的应用系统量身定做的操作系统也就不难了. 

为了加深对操作系统的理解,可以看一看演变这份PPT,让你充分了解一个并行多任务是如何一步步从顺序流程演变过来的.里面还提到了很多人都在用的状态机,你会发现操作系统跟状态机从原理上其实是多么相似.会用状态机写程序,都能写出操作系统. 
 三.我的第一个操作系统 


直接进入主题,先贴一个操作系统的示范出来.大家可以看到,原来操作系统可以做得么简单. 
当然,这里要申明一下,这玩意儿其实算不上真正的操作系统,它除了并行多任务并行外根本没有别的功能.但凡事都从简单开始,搞懂了它,就能根据应用需求,将它扩展成一个真正的操作系统. 

好了,代码来了. 
将下面的代码直接放到KEIL里编译,在每个task()函数的task_switch();那里打上断点,就可以看到它们的确是同时在执行的. 


#include reg51.h 

#define MAX_TASKS 2       任务槽个数.必须和实际任务数一至 
#define MAX_TASK_DEP 12   最大栈深.最低不得少于2个,保守值为12. 
unsigned char idata task_stack[MAX_TASKS][MAX_TASK_DEP];任务堆栈. 
unsigned char task_id;    当前活动任务号 


任务切换函数(任务调度器) 
void task_switch()
{ 
        task_sp[task_id] = SP; 

        if(++task_id == MAX_TASKS) 
                task_id = 0; 

        SP = task_sp[task_id]; 
} 

任务装入函数.将指定的函数(参数1)装入指定(参数2)的任务槽中.如果该槽中原来就有任务,则原任务丢失,但系统本身不会发生错误. 
void task_load(unsigned int fn, unsigned char tid)
{ 
	u 
	task_stack[tid][0] = (unsigned int)fn & 0xff; 
	task_stack[tid][1] = (unsigned int)fn  8; 
} 

从指定的任务开始运行任务调度.调用该宏后,将永不返回. 
#define os_start(tid) {task_id = tid,SP = task_sp[tid];return;} 




============================以下为测试代码============================ 

void task1()
{ 
        static unsigned char i; 
        while(1)
        { 
                i++; 
                task_switch();编译后在这里打上断点 
        } 
} 

void task2()
{ 
        static unsigned char j; 
        while(1)
        { 
                j+=2; 
                task_switch();编译后在这里打上断点 
        } 
} 

void main()
{ 
        这里装载了两个任务,因此在定义MAX_TASKS时也必须定义为2 
        task_load(task1, 0);将task1函数装入0号槽 
        task_load(task2, 1);将task2函数装入1号槽 
        os_start(0); 
} 



限于篇幅我已经将代码作了简化,并删掉了大部分注释,大家可以直接下载源码包,里面完整的注解,并带KEIL工程文件,断点也打好了,直接按ctrl+f5就行了. 




现在来看看这个多任务系统的原理 

这个多任务系统准确来说,叫作协同式多任务. 
所谓协同式,指的是当一个任务持续运行而不释放资源时,其它任务是没有任何机会和方式获得运行机会,除非该任务主动释放CPU. 
在本例里,释放CPU是靠task_switch()来完成的.task_switch()函数是一个很特殊的函数,我们可以称它为任务切换器. 
要清楚任务是如何切换的,首先要回顾一下堆栈的相关知识. 

有个很简单的问题,因为它太简单了,所以相信大家都没留意过 
我们知道,不论是CALL还是JMP,都是将当前的程序流打断,请问CALL和JMP的区别是什么 
你会说CALL可以RET,JMP不行.没错,但原因是啥呢,为啥CALL过去的就可以用RET跳回来,JMP过去的就不能用RET来跳回呢 

很显然,CALL通过某种方法保存了打断前的某些信息,而在返回断点前执行的RET指令,就是用于取回这些信息. 
不用多说,大家都知道,某些信息就是PC指针,而某种方法就是压栈. 
很幸运,在51里,堆栈及堆栈指针都是可被任意修改的,只要你不怕死.那么假如在执行RET前将堆栈修改一下会如何往下看 
当程序执行CALL后,在子程序里将堆栈刚才压入的断点地址清除掉,并将一个函数的地址压入,那么执行完RET后,程序就跳到这个函数去了. 
事实上,只要我们在RET前将堆栈改掉,就能将程序跳到任务地方去,而不限于CALL里压入的地址. 

重点来了...... 
首先我们得为每个任务单独开一块内存,这块内存专用于作为对应的任务的堆栈,想将CPU交给哪个任务,只需将栈指针指向谁内存块就行了. 接下来我们构造一个这样的函数 

当任务调用该函数时,将当前的堆栈指针保存一个变量里,并换上另一个任务的堆栈指针.这就是任务调度器了. 

OK了,现在我们只要正确的填充好这几个堆栈的原始内容,再调用这个函数,这个任务调度就能运行起来了. 
那么这几个堆栈里的原始内容是哪里来的呢这就是任务装载函数要干的事了. 

在启动任务调度前将各个任务函数的入口地址放在上面所说的任务专用的内存块里就行了!对了,顺便说一下,这个任务专用的内存块叫作私栈,私栈的意思就是说,每个任务的堆栈都是私有的,每个任务都有一个自已的堆栈. 

话都说到这份上了,相信大家也明白要怎么做了 

1.分配若干个内存块,每个内存块为若干字节 
这里所说的若干个内存块就是私栈,要想同时运行几少个任务就得分配多少块.而每个子内存块若干字节就是栈深.记住,每调一层子程序需要2字节.如果不考虑中断,4层调用深度,也就是8字节栈深应该差不多了. 

unsigned char idata task_stack[MAX_TASKS][MAX_TASK_DEP] 

当然,还有件事不能忘,就是堆指针的保存处.不然光有堆栈怎么知道应该从哪个地址取数据啊 
unsigned char idata task_sp[MAX_TASKS] 

上面两项用于装任务信息的区域,我们给它个概念叫任务槽.有些人叫它任务堆,我觉得还是槽比较直观 

对了,还有任务号.不然怎么知道当前运行的是哪个任务呢 
unsigned char task_id 
当前运行存放在1号槽的任务时,这个值就是1,运行2号槽的任务时,这个值就是2.... 

2.构造任务调度函函数 
void task_switch()
{ 
        task_sp[task_id] = SP;保存当前任务的栈指针 

        if(++task_id == MAX_TASKS)任务号切换到下一个任务 
                task_id = 0; 

        SP = task_sp[task_id];将系统的栈指针指向下个任务的私栈. 
} 


3.装载任务 
将各任务的函数地址的低字节和高字节分别入在 
task_stack[任务号][0]和task_stack[任务号][1]中 

为了便于使用,写一个函数  task_load(函数名, 任务号) 

void task_load(unsigned int fn, unsigned char tid)
{ 
        task_sp[tid] = task_stack[tid] + 1; 
        task_stack[tid][0] = (unsigned int)fn & 0xff; 
        task_stack[tid][1] = (unsigned int)fn  8; 
} 

4.启动任务调度器 
将栈指针指向任意一个任务的私栈,执行RET指令.注意,这可很有学问的哦,没玩过堆栈的人脑子有点转不弯这一RET,RET到哪去了嘿嘿,别忘了在RET前已经将堆栈指针指向一个函数的入口了.你别把RET看成RET,你把它看成是另一种类型的JMP就好理解了. 

SP = task_sp[任务号]; 
return; 

做完这4件事后,任务并行执行就开始了.你可以象写普通函数一个写任务函数,只需(目前可以这么说)注意在适当的时候(例如以前调延时的地方)调用一下task_switch(),以让出CPU控制权给别的任务就行了. 


最后说下效率问题. 
这个多任务系统的开销是每次切换消耗20个机器周期(CALL和RET都算在内了),贵吗不算贵,对于很多用状态机方式实现的多任务系统来说,其实效率还没这么高--- case switch和if()可不像你想像中那么便宜. 

关于内存的消耗我要说的是,当然不能否认这种多任务机制的确很占内存.但建议大家不要老盯着编译器下面的那行字DATA = XXXbyte.那个值没意义,堆栈没算进去.关于比较省内存多任务机制,我将来会说到. 
概括来说,这个多任务系统适用于实时性要求较高而内存需求不大的应用场合,我在运行于36M主频的STC12C4052上实测了一把,切换一个任务不到3微秒. 


下回我们讲讲用KEIL写多任务函数时要注意的事项. 
下下回我们讲讲如何增强这个多任务系统,跑步进入操作系统时代.

四.用KEIL写多任务系统的技巧与注意事项 

C51编译器很多,KEIL是其中比较流行的一种.我列出的所有例子都必须在KEIL中使用.为何,不是因为KEIL好所以用它(当然它的确很棒),而是因为这里面用到了KEIL的一些特性,如果换到其它编译器下,通过编译的倒不是问题,但运行起来可能是堆栈错位,上下文丢失等各种要命的错误,因为每种编译器的特性并不相同.所以在这里先说清楚这一点. 
但是,我开头已经说了,这套帖子的主要目的是阐述原理,只要你能把这几个例子消化掉,那么也能够自已动手写出适合其它编译器的OS. 

好了,说说KEIL的特性吧,先看下面的函数 

sbit sigl = P1^7; 
void func1()
{ 
        register char data i; 
        i = 5; 
        do{ 
                sigl = !sigl; 
        }while(--i); 
} 

你会说,这个函数没什么特别的嘛!呵呵,别着急,你将它编译了,然后展开汇编代码再看看 

   193 void func1(){  
   194         register char data i;  
   195         i = 5;  
C0x00C3    7F05     MOV      R7,#0x05 
   196         do{  
   197                 sigl = !sigl;  
C0x00C5    B297     CPL      sigl(0x90.7) 
   198         }while(--i);  
C0x00C7    DFFC     DJNZ     R7,C00C5 
   199 }  
C0x00C9    22       RET       

看清楚了没这个函数里用到了R7,却没有对R7进行保护! 
有人会跳起来了这有什么值得奇怪的,因为上层函数里没用到R7啊.呵呵,你说的没错,但只说对了一半事实上,KEIL编译器里作了约定,在调子函数前会尽可能释放掉所有寄存器.通常性况下,除了中断函数外,其它函数里都可以任意修改所有寄存器而无需先压栈保护(其实并不是这样,但现在暂时这样认为,饭要一口一口吃嘛,我很快会说到的). 
这个特性有什么用呢有!当我们调用任务切换函数时,要保护的对象里可以把所有的寄存器排除掉了,就是说,只需要保护堆栈即可! 

现在我们回过头来看看之前例子里的任务切换函数 

void task_switch()
{ 
        task_sp[task_id] = SP;保存当前任务的栈指针 

        if(++task_id == MAX_TASKS)任务号切换到下一个任务 
                task_id = 0; 

        SP = task_sp[task_id];将系统的栈指针指向下个任务的私栈. 
} 

看到没,一个寄存器也没保护,展开汇编看看,的确没保护寄存器. 


好了,现在要给大家泼冷水了,看下面两个函数 

void func1()
{ 
        register char data i; 
        i = 5; 
        do
        { 
                sigl = !sigl; 
        }while(--i); 
} 
void func2()
{ 
        register char data i; 
        i = 5; 
        do
        { 
                func1(); 
        }while(--i); 
} 

父函数fun2()里调用func1(),展开汇编代码看看 
   193 void func1(){  
   194         register char data i;  
   195         i = 5;  
C0x00C3    7F05     MOV      R7,#0x05 
   196         do{  
   197                 sigl = !sigl;  
C0x00C5    B297     CPL      sigl(0x90.7) 
   198         }while(--i);  
C0x00C7    DFFC     DJNZ     R7,C00C5 
   199 }  
C0x00C9    22       RET       
   200 void func2(){  
   201         register char data i;  
   202         i = 5;  
C0x00CA    7E05     MOV      R6,#0x05 
   203         do{  
   204                 func1();  
C0x00CC    11C3     ACALL    func1(C00C3) 
   205         }while(--i);  
C0x00CE    DEFC     DJNZ     R6,C00CC 
   206 }  
C0x00D0    22       RET       

看清楚没函数func2()里的变量使用了寄存器R6,而在func1和func2里都没保护. 
听到这里,你可能又要跳一跳了func1()里并没有用到R6,干嘛要保护没错,但编译器是怎么知道func1()没用到R6的呢是从调用关系里推测出来的.一点都没错,KEIL会根据函数间的直接调用关系为各函数分配寄存器,既不用保护,又不会冲突,KEIL好棒哦!!等一下,先别高兴,换到多任务的环境里再试试 

void func1(){ 
        register char data i; 
        i = 5; 
        do{ 
                sigl = !sigl; 
        }while(--i); 
} 
void func2(){ 
        register char data i; 
        i = 5; 
        do{ 
                sigl = !sigl; 
        }while(--i); 
} 

展开汇编代码看看 

   193 void func1(){  
   194         register char data i;  
   195         i = 5;  
C0x00C3    7F05     MOV      R7,#0x05 
   196         do{  
   197                 sigl = !sigl;  
C0x00C5    B297     CPL      sigl(0x90.7) 
   198         }while(--i);  
C0x00C7    DFFC     DJNZ     R7,C00C5 
   199 }  
C0x00C9    22       RET       
   200 void func2(){  
   201         register char data i;  
   202         i = 5;  
C0x00CA    7F05     MOV      R7,#0x05 
   203         do{  
   204                 sigl = !sigl;  
C0x00CC    B297     CPL      sigl(0x90.7) 
   205         }while(--i);  
C0x00CE    DFFC     DJNZ     R7,C00CC 
   206 }  
C0x00D0    22       RET       


看到了吧哈哈,这回神仙也算不出来了.因为两个函数没有了直接调用的关系,所以编译器认为它们之间不会产生冲突,结果分配了一对互相冲突的寄存器,当任务从func1()切换到func2()时,func1()中的寄存器内容就给破坏掉了.大家可以试着去编译一下下面的程序 

sbit sigl = P1^7; 
void func1(){ 
        register char data i; 
        i = 5; 
        do{ 
                sigl = !sigl; 
                task_switch(); 
        }while(--i); 
} 
void func2(){ 
        register char data i; 
        i = 5; 
        do{ 
                sigl = !sigl; 
                task_switch(); 
        }while(--i); 
} 

我们这里只是示例,所以仍可以通过手工分配不同的寄存器避免寄存器冲突,但在真实的应用中,由于任务间的切换是非常随机的,我们无法预知某个时刻哪个寄存器不会冲突,所以分配不同寄存器的方法不可取.那么,要怎么办呢 
这样就行了 

sbit sigl = P1^7; 
void func1(){ 
        static char data i; 
        while(1){ 
                i = 5; 
                do{ 
                        sigl = !sigl; 
                        task_switch(); 
                }while(--i); 
        } 
} 
void func2(){ 
        static char data i; 
        while(1){ 
                i = 5; 
                do{ 
                        sigl = !sigl; 
                        task_switch(); 
                }while(--i); 
        } 
} 

将两个函数中的变量通通改成静态就行了.还可以这么做 

sbit sigl = P1^7; 
void func1(){ 
        register char data i; 
        while(1){ 
                i = 5; 
                do{ 
                        sigl = !sigl; 
                }while(--i); 
                task_switch(); 
        } 
} 
void func2(){ 
        register char data i; 
        while(1){ 
                i = 5; 
                do{ 
                        sigl = !sigl; 
                }while(--i); 
                task_switch(); 
        } 
} 

即,在变量的作用域内不切换任务,等变量用完了,再切换任务.此时虽然两个任务仍然会互相破坏对方的寄存器内容,但对方已经不关心寄存器里的内容了. 

以上所说的,就是变量覆盖的问题.现在我们系统地说说关于变量覆盖. 

变量分两种,一种是全局变量,一种是局部变量(在这里,寄存器变量算到局部变量里). 
对于全局变量,每个变量都会分配到单独的地址. 
而对于局部变量,KEIL会做一个覆盖优化,即没有直接调用关系的函数的变量共用空间.由于不是同时使用,所以不会冲突,这对内存小的51来说,是好事. 
但现在我们进入多任务的世界了,这就意味着两个没有直接调用关系的函数其实是并列执行的,空间不能共用了.怎么办呢一种笨办法是关掉覆盖优化功能.呵呵,的确很笨. 

比较简单易行一个解决办法是,不关闭覆盖优化,但将那些在作用域内需要跨越任务(换句话说就是在变量用完前会调用task_switch()函数的)变量通通改成静态(static)即可.这里要对初学者提一下,静态你可以理解为全局,因为它的地址空间一直保留,但它又不是全局,它只能在定义它的那个花括号对{}里访问. 
静态变量有个副作用,就是即使函数退出了,仍会占着内存.所以写任务函数的时候,尽量在变量作用域结束后才切换任务,除非这个变量的作用域很长(时间上长),会影响到其它任务的实时性.只有在这种情况下才考虑在变量作用域内跨越任务,并将变量申明为静态. 
事实上,只要编程思路比较清析,很少有变量需要跨越任务的.就是说,静态变量并不多. 

说完了覆盖我们再说说重入. 
所谓重入,就是一个函数在同一时刻有两个不同的进程复本.对初学者来说可能不好理解,我举个例子吧 
有一个函数在主程序会被调用,在中断里也会被调用,假如正当在主程序里调用时,中断发生了,会发生什么情况 

void func1()
{ 
        static char data i; 
        i = 5; 
        do
        { 
                sigl = !sigl; 
        }while(--i); 
} 

假定func1()正执行到i=3时,中断发生,一旦中断调用到func1()时,i的值就被破坏了,当中断结束后,i == 0. 

以上说的是在传统的单任务系统中,所以重入的机率不是很大.但在多任务系统中,很容易发生重入,看下面的例子 
void func1(){ 
.... 
delay(); 
.... 
} 
void func2(){ 
.... 
delay(); 
.... 
} 
void delay(){ 
        static unsigned char i;注意这里是申明为static,不申明static的话会发生覆盖问题.而申明为static会发生重入问题.麻烦啊 
        for(i=0;i10;i++) 
                task_switch(); 
} 

两个并行执行的任务都调用了delay(),这就叫重入.问题在于重入后的两个复本都依赖变量i来控制循环,而该变量跨越了任务,这样,两个任务都会修改i值了. 
重入只能以防为主,就是说尽量不要让重入发生,比如将代码改成下面的样子 
#define delay() 
{static unsigned char i; for(i=0;i10;i++) task_switch();}
i仍定义为static,但实际上已经不是同一个函数了,所以分配的地址不同. 
void func1()
{ 
.... 
delay(); 
.... 
} 
void func2()
{ 
.... 
delay(); 
.... 
} 

用宏来代替函数,就意味着每个调用处都是一个独立的代码复本,那么两个delay实际使用的内存地址也就不同了,重入问题消失. 
但这种方法带来的问题是,每调用一次delay(),都会产生一个delay的目标代码,如果delay的代码很多,那就会造成大量的rom空间占用.有其它办法没 

本人所知有限,只有最后一招了 
void delay() reentrant
{ 
        unsigned char i; 
        for(i=0;i<10;i++) 
                task_switch(); 
} 
加入reentrant申明后,该函数就可以支持重入.但小心使用,申明为重入后,函数效率极低! 



最后附带说下中断.因为没太多可说的,就不单独开章了. 
中断跟普通的写法没什么区别,只不过在目前所示例的多任务系统里因为有堆栈的压力,所以要使用using来减少对堆栈的使用(顺便提下,也不要调用子函数,同样是为了减轻堆栈压力) 
用using,必须用#pragma NOAREGS关闭掉绝对寄存器访问,如果中断里非要调用函数,连同函数也要放在#pragma NOAREGS的作用域内.如例所示 

#pragma SAVE 
#pragma NOAREGS  使用using时必须将绝对寄存器访问关闭 
void clock_timer(void) interrupt 1 using 1 使用using是为了减轻堆栈的压力 
} 
#pragma RESTORE 

改成上面的写法后,中断固定占用4个字节堆栈.就是说,如果你在不用中断时任务栈深定为8的话,现在就要定为8+4 = 12了. 
另外说句废话,中断里处理的事一定要少,做个标记就行了,剩下的事交给对应的任务去处理. 



现在小结一下 

切换任务时要保证没有寄存器跨越任务,否则产生任务间寄存器覆盖.        使用静态变量解决 
切换任务时要保证没有变量跨越任务,否则产生任务间地址空间(变量)覆盖.  使用静态变量解决 
两个不同的任务不要同时调用同一个函数,否则产生重入覆盖.          使用重入申明解决 

其中os_start()函数可以这么写

//从指定的任务开始运行任务调度.调用该宏后,将永不返回.
//#define os_start(tid) {task_id = tid,SP = task_sp[tid];return;}
void os_start(char tid)
{
    task_id = tid;
    SP = task_sp[tid];
    //return;
}