第二章
多任务的原理
在开始写操作系统之前需要理解一个问题,一个cpu是如何做到“同时”做多个事情的,比如同一时间又亮灯又检测按键又串口输出。其实它不能,一个cpu在一个时间点只能执行一条指令,无法同时执行多个指令。但是从现象上看又确实是同时进行的,这是因为cpu执行指令比人类快很多,他在人类的一瞬间执行了多个指令,当然,这几个指令实现多个功能的时候就好像在这一瞬间这几个功能在同时执行。
好了,到目前为止我们发现一般的前后台方法也同样能实现上面说的效果。如下结构:
For(;;)
{
Do_led();
Do_check_key();
Do_uart_write();
}
这样做也能看起来是同时在做,但是有一个问题无法避免,比如这个函数中如果有长时间占用的话就可能造成实时性降低,如果do_uart_write中如果消耗100ms,那么其他两个函数的执行都要至少间隔100ms,对于按键检测来说最为明显,可能100ms检测一次就会漏掉一些按键事件。
好了,当我们知道了前后台结构的弊端的时候就会想办法来避免这种问题。
下面就开始说说操作系统的多任务实现方法。首先一个任务,可以是一个函数,当有多个任务,也就是有多个函数需要同时运行,而不是依次的运行。也就是函数在运行的过程中需要能够被打断。在单片机编程中,有一种方法是可以实现这个功能的,对的,那就是中断,中断就是在函数运行的任何时间都可以打断当前运行的函数,进入到中断处理函数中。在中断退出的时候被打断的函数还能继续运行,这不就是我们需要的功能吗。这个是最直白的打断恢复机制了,我们可以扩展一下,普通函数是否能够打断普通函数呢?当然可以,比如我们在函数A中调用函数B,在运行到B的时候是不是就是B打断了A呢。而在B退出后A也会继续的运行,当然了,这种情况与中断的方法有一个不同就是这种情况打断和恢复的时机是被确定的。但是从本质来说是一样的,都是打断了之前的函数,运行了新的函数,并且在新的函数退出后继续运行之前的函数。
好了,既然能够找到方法就是研究这种方法实际上做了什么事情,实现如何能够被我们来应用。
首先,我们所编写的代码,无论是C还是汇编都是不能被机器直接执行的,需要转为对应的机器指令,我们的一条命令,可能会被转为一条或者多条机器码,这就是编译过程,编辑之后生成一个bin或hex文件,将这个文件烧写到单片机的flash上,在单片机运行的时候会逐条从flash上读取指令并执行。那么单片机是如何知道即将执行的下一条指令是什么呢?(我们假设可以理解第一条指令是如何被执行的,第一条单片机规定到一个固定地址去取指令,如0地址为单片机启动的第一个条指令地址),有一个PC的指针寄存器,这个寄存器用来记录下一条指令位置,假如单片机第一个指令在0地址位置,在0地址的指令为跳转到0xA0地址指令,那么下一条指令的位置就是0xA0,PC中的值就是0xA0,在第二个指令周期中就会执行0xA0中的指令,比如0xA0中是一个将1赋值给R1寄存器的指令,没有跳转的意思,那么下一条指令就顺序向下执行,PC就是0xA1。
那么当我们在运行一个函数的时候其实单片机早就已经知道了函数的“剧本”,他只是按照剧本的顺序在演出而已。那么打断函数这个事情好像就可以做到了,也就是在需要打断并且跳转到其他函数的时候只需要修改PC指针的值不就行了吗?但是单片机并没有给我们权限去修改PC指针那么问题来了,单片机本身的中断跳转是如何做到的呢?这里就要引入一个栈的概念。
单片机的程序是存储在ROM中,运行过程中的数据则存储在RAM中,对于数据分为全局变量和局部变量,还有静态变量,单片机在编译的时候会为这些变量分配不同的数据区在存放,全局变量和静态变量放在静态存储区中,局部变量放在堆栈存储区中,堆栈存储区也会存放函数跳转的PC指针,这也就是俗称的保护现场,当函数跳转回来的时候也会从堆栈中恢复PC指针使程序能够继续运行,也是俗称恢复现场。
单片机是如何访问堆栈的呢?是否我们可以通过改变堆栈数据来达到我们的多任务目的呢?答案是肯定的,我们可以使用堆栈指针寄存器SP来修改堆栈的位置,同时使用入栈push和出栈pop来达到修改堆栈内容的目的。
我们先来验证一下上面的猜想是否是对的呢?
新建一个STC工程,并写一个最简单demo,我们来看一下单片机是如何分配的ram
代码如下:
void test1()
{
u8 a[8] = {1,2,3,4,5,6,7,8};
a[7] = a[1]+a[2]+a[3]+a[4]+a[5]+a[6]+a[0];
}
void test2()
{
u8 a[8] = {1,2,3,4,5,6,7,8};
test1();
a[7] = a[1]+a[2]+a[3]+a[4]+a[5]+a[6]+a[0];
}
/******************** 主函数 **************************/
void main(void)
{
while(1)
{
//test1();
test2();
}
}
在Listings文件夹中的rtosmyself.m51中有堆栈的信息
LINK MAP OF MODULE: .\Objects\rtosmyself (MAIN)
TYPE BASE LENGTH RELOCATION SEGMENT NAME
-----------------------------------------------------
* * * * * * * D A T A M E M O R Y * * * * * * *
REG 0000H 0008H ABSOLUTE "REG BANK 0"
IDATA 0008H 0001H UNIT ?STACK
* * * * * * * X D A T A M E M O R Y * * * * * * *
XDATA 0000H 0008H UNIT ?XD?TEST1?MAIN
XDATA 0008H 0008H UNIT ?XD?TEST2?MAIN
* * * * * * * C O D E M E M O R Y * * * * * * *
CODE 0000H 0003H ABSOLUTE
CODE 0003H 00F6H UNIT ?C?LIB_CODE
CODE 00F9H 0063H UNIT ?PR?TEST2?MAIN
CODE 015CH 0060H UNIT ?PR?TEST1?MAIN
CODE 01BCH 0010H UNIT ?CO?MAIN
CODE 01CCH 000CH UNIT ?C_C51STARTUP
CODE 01D8H 0006H UNIT ?PR?MAIN?MAIN
Stack是从8H开始的,而函数test1和test2中用到的变量被分配到了xdata中,这是因为我将工程配置为
使用片内扩展ram,这个是由于STC单片机片内除了集成256字节的内部RAM外,还集成了3840字节的扩展RAM,地址范围是0000H~0EFFH。所以编译器会优先使用xdata来存放变量。在KEIL C51中定义了xdata、idata、xdata、code几种域修饰符。这些修饰符决定了变量访问方式。
data:固定指前面0x00-0x7F的128个RAM,可以用acc直接读写,速度最快,生成的代码也最小。
idata:固定指前面0x00-0xFF的256个RAM,其中前128和data的128完全相同,只是访问的方式不同。
xdata:外部扩展RAM。
堆栈的最高地址为FFH,所以这个程序的堆栈大小为FF-08=F7H。这个已经足够大了,对于单片机来说,而且堆栈的初始地址为08H。
通过上面的内容能够了解这么多。
这里建议在学习时添加这个选项
这个是说输出lst时也把汇编语言同时输出,对于学习很有帮助。
下面我们来仿真一下,看一下sp的变化
可见当我们要运行第一个函数时,堆栈的地址指向07H,而且idata中没有数据。这里有人会问为什么指向07H,不应该是08H吗?入栈时先SP+1再将内容压入当前SP所指示的堆栈单元中,出栈则先将SP所指示的内部ram单元中内容送入直接地址寻址的单元中,再将
SP减1。所以SP会指向07H。
在当前指令(0x1D8)的下一条指令为0x1DB,这个时候我们相当于从main函数要进入test2函数,所以我们预测堆栈中会出现PC的下一条指令地址,也就是0x1DB,我们执行一步看看。
果然,堆栈指针地址变化为09,堆栈中也存入了01DB,我们继续往下走,因为test2中还调用了test1,正常也会产生入栈事件。
果然,在进入test1时就发生了入栈,入栈地址为0121,这个地址应该是test2的返回地址。
之后同学们可以单步观察一下出栈的过程,就和上面说的一样。
好了,我们需要的东西都已经准备好了,我们可以想象一下我们的任务切换过程。
首先任务一执行到某处,触发了任务切换,然后保存这个任务的SP指针,并想办法将下一个需要运行的任务的地址放入sp中,然后让sp把地址给PC去执行,这样就实现了我们的多任务切换过程。
想到这里问题又来了,如果我们使用默认的栈,那么会发生其它函数的栈信息被覆盖的问题,如下图
首先任务1和任务2都有了自己的栈地址,并且都使用栈底保存pc地址,当任务1不停的运行时,有可能产生过多的栈使用,就有可能覆盖任务2的PC地址。所以这种方法还是有问题,那么我们应该怎么办呢?我们为每个任务都在静态区创建自己的堆栈区域,这样彼此就不会覆盖,只是每次切换任务的时候需要把自己的SP地址指向自己的任务堆栈即可。
任务私有堆栈
现在我们准备给任务在静态区分配一个属于自己的堆栈。由于sp指针只能指向片内ram,也就是说我们的堆栈最大也就只能使用256字节,也就是下图这个部分
所以我们只能将堆栈使用idata标记,好让keil为我们分配到内部ram中。
代码如下:
idata u8 stack[20]; //建立一个 20 字节的静态区堆栈
void task_0(void)
{
while (1) {
delay_ms(100);
}
}
void start_task_with_stack(void (*pfun)(), u8 *pstack)
{
*pstack++ = (unsigned int)pfun; //将函数的地址高位压入堆栈,
*pstack = (unsigned int)pfun>>8; //将函数的地址低位压入堆栈,
SP = (u8)pstack; //将堆栈指针指向人工堆栈的栈顶
}
/******************** 主函数 **************************/
void main(void)
{
start_task_with_stack(task_0, stack);
}
在start_task_with_stack函数中我们首先把传入的函数指针放入传入的数组最底部,然后将数组的当前指针赋值给SP寄存器,这样当这个函数退出时,会自动将sp中的函数指针给PC,这样就能直接执行传入的函数了。
到这里我们已经拥有了任务切换函数的雏形。下面我们需要添加一些自己的扩展功能。比如堆栈使用情况统计,基本实现的方法就是在堆栈初始化的时候写入一个魔数,当随着堆栈被使用,魔数会被改变,统计的时候我们只要统计有多少没有变化,就能知道堆栈的使用情况了。有人说如果我往堆栈里压入的数据刚好是魔数怎么办?我们可以将魔数暴露给用户更改,如果每次更改的魔数刚好都是你往堆栈中写入的数据,那么只能说你牛。。。。。。
首先修改创建任务函数,添加可选宏STACK_DETECT_MODE,如果开启宏,则添加初始化堆栈数组的for循环。
void start_task_with_stack(void (*pfun)(), u8 *pstack, u8 stack_len)
{
#ifdef STACK_DETECT_MODE
u8 i = 0;
for (; i<stack_len; i++)
pstack[i] = STACK_MAGIC;
#else
stack_len = stack_len; //消除编译警告
#endif
*pstack++ = (unsigned int)pfun; //将函数的地址高位压入堆栈,
*pstack = (unsigned int)pfun>>8; //将函数的地址低位压入堆栈,
SP = (u8)pstack; //将堆栈指针指向人工堆栈的栈顶
}
然后提供一个查询堆栈使用情况的get函数
#ifdef STACK_DETECT_MODE
u8 get_stack_used(u8 *pstack, u8 stack_len)
{
u8 i = stack_len-1;
u8 unused = 0;
while (STACK_MAGIC == pstack[i]) {
unused++;
if (0 == i)
break;
else
i--;
}
return stack_len-unused;
}
#endif
运行结果如下: