摘要:这个文档描述了TinyOS1.x和TinyOS2.0的任务和任务的调度。
1.介绍
TinyOS有两个操作系统的基本概念:异步事件和任务。早些的版本对任务的定义比较简单,只要求无参数和先进先出(FIFO)。当后来任务调度发生改变的时候,发现将任务与nesC语言结合很困难。任务现在作为TinyOS组件调度更容易定制,并且允许任务作为接口提供给使用者将扩展任务的种类。TinyOS2.0用了两种方法,增加了系统的可靠性。这个文档描述了方法的实现过程,和解释了实现原理。
2. TinyOS 1.x 的任务设计和调度
TinyOS 1.x的内核属于非抢占式,因此任务采用延时调用(DPC)机制,就是必须一个任务执行完毕和才执行下一个任务。
在TinyOS 1.x中,nesC语法有两种方式支持任务,任务声明和任务投递表达式。如下所示:
task void computeTask() {
// Code here
}
和:
result_t rval = post computeTask();
TinyOS 1.x提供单一的任务管理,无参数函数和先进先出(FIFO)策略。投递一个任务进入任务队列可能返回失败,表明该任务当前不能进入任务队列。因为任务可以被多次投递,所以可能发生第一次投递成功,第二次投递不成功的情形,这种情况会导致收到了投递失败的消息,但是任务仍然会被运行。
TinyOS 1.x的任务调度是一组C函数的集合,保存在sched.c文件中。对任务调度的修改可以替换或者更改这个文件。然而,受nesC语法定义的限制,所以对任务做语法上的修改将导致ncc编译不通过。因此,不建议使用者修改sched.c文件。
TinyOS 1.x的任务队列是一个固定的数组,其中存储着任务函数的指针。投递一个任务进入任务队列就是将这个任务的函数指针放到缓存的下一个空位置。如果没有空位置了,则返回错误。这样的模型有几点问题:
l 有些组件对于入队失败没有合理的反应。
l 因为一个任务可以多次入队,造成一个任务可以占用多个位置的浪费。
l 所有的任务共享同样的资源,那么只要一个任务发生错误,就可能造成其他所有任务阻塞。
基本上,为了组件A在入队失败后能够再次投递,另外一个组件B必须调用一个函数(或者是命令,或者是事件)。例如,组件A必须调度一个定时器定时投递,或者希望从它的客户端获得重入的机会。越来越多的任务需求可能导致的结果就是任务队列溢出,这会引起系统崩溃。
上面所述的问题意味着一个严重的缺陷:如果一个组件出现bug,结果很可能导致整个TinyOS系统挂起。考虑下面的情形(曾经在Telos平台上遭遇到的问题):
l 一个无线模块,每发送完一个包就产生一个中断。
l 一个网络组件,获得包发送完中断就投递一个任务入队来响应SendMsg.sendDone。
l 一个传感器组件,当获得ADC.dataReady事件就投递一个任务入队,任务负责处理采样的数据。
l 一个应用程序组件,发送一个包,然后设置ADC采样,不小心的是采样率设置得过高了。
在这种情形下,传感器组件投递任务入队的速度比处理完一个任务的速度要快,因此很快任务队列就被ADC.dataReady事件处理任务填满了。这个时候网络组件的任务就不能入队成功,也就没有办法处理SendMsg.sendDone()发送包完成事件,程序将不能确定包是否发送出去,结果造成网络通讯不正常。
在TinyOS 1.x中解决这个特殊问题的办法之一是把SendMsg.sendDone()发送包完成事件放到发送包结束中断中处理,而不是象上面一样用任务来处理。这样做破坏了严格区分同步/异步的思想,但是我们的理由是“能抓到老鼠的就是好猫”。另外一个不打破同步/异步规则的解决办法是用一个中断周期性的尝试投递网络组件的任务入队,有一定的概率使网络组件的任务入队在传感器组件的任务入队之前发生。第二种办法显然没有第一种办法有效。这个问题的出现与TinyOS 1.x的内核模型有关。
3.TinyOS 2.0 的任务
TinyOS2.0的内核模型发生了改变,因此在任务上的前后的语义也有所不同。做这样的改变是基于解决1.x模型中的限制和运行时的经验,应该说2.0在1.x上更进一步,这点从版本号上也可以看出来。既然TinyOS2.0的内核模型发生了改变,那么对于基于1.x的程序将不再兼容,关于如何移植1.x版的程序到2.0版可以参考网站上的相关文档,但是之前最好先了解一下2.0版的改变。在TinyOS2.0上,任务队列不会再出现多个同样的任务的情形,每个任务被设计成“一个萝卜一个坑”。只有在“坑”里面已经有任务,并且没有开始执行的时候,这个任务投递自己才会返回错误。这是2.0在任务调度方面与1.x的一个最明显的区别。
2.x分配了一个字节表示任务ID,因此系统中最多255个任务。任务的ID越大表示越受关注,但并不表示实际上的重要程度。如果一个任务需要执行多次,可以在任务结束的代码处添加将自己再次投递入队的代码,如下所示:
post processTask();
...
task void processTask() {
// do work
if (moreToProcess) {
post processTask();
}
}
这样定义防止了由于任务队列已满而无法通知分相事件结束的情况出现。因为一个任务只占任务队列的一个位置,不会象1.x版那样每投递成功一次就多占一个位置。
TinyOS 2.x保持一个原则,就是任务应该简单好用。同时也提供对别的任务种类的支持。
TinyOS为此构造了基本任务和任务接口。基本任务就是上面所述的任务函数和任务投递。任务接口则用来支持其他种类的任务。
任务接口允许用户扩展任务的语法和语义。通常,一个任务接口包含异步命令,投递,和一个事件函数。如下例子,定义了一个任务接口,使任务可以接收整型参数:
interface TaskParameter { async error_t command postTask(uint16_t param);
event void runTask(uint16_t param);
}
使用这个任务接口,组件可以实现传给其中的任务uint16_t类型的参数。当任务运行的时候会用传递param参数给runTask事件,由事件函数处理这个参数。这样逻辑上参数被传递给这个任务,并且得到了处理。值得注意的是:参数是动态分配RAM空间的,不会一直占用RAM。进一步而言,因为任何时候一个任务只有一个拷贝在运行,因此只需要简单的将这个参数存储在组件中就可以了。下面2段代码演示了任务接口和基本任务。
任务接口:
call TaskParameter.postTask(34);
...
event void TaskParameter.runTask(uint16_t param) { ...
}
基本任务:
uint16_t param;
...
param = 34;
post parameterTask();
...
task void parameterTask() { // use param
}
可以看到,如果使用基本任务实现将参数传递进任务,需要申请一个全局变量,然后再在任务中使用,这个变量会一直占用RAM空间。另外一点是,对于任务接口而言当任务再次执行的时候使用的参数仍然是34,而基本任务在执行的时候则有可能改变param参数。如果使用基本任务仍然希望使用旧的参数可以用如下的方式解决:
if (post myTask() == SUCCESS) { param = 34;
}
4. TinyOS 2.0 的任务调度
在TinyOS 2.x中,任务调度程序在一个组件中。任务调度程序必须要支持nesC语法的任务,否则不能通过ncc编译器的编译。
在TinyOS 2.x中,基本的任务是无参数,先进先出的。任务也象一般程序一样,按照nesC的语义声明接口,连线到调度程序组件。为了保证每一个任务的ID的独一无二的,所以在声明任务的时候使用unique()函数。
例如,标准的TinyOS调度程序如下所示:
module SchedulerBasicP { provides interface Scheduler;
provides interface TaskBasic[uint8_t taskID];
uses interface McuSleep;
}
一个调度程序必须提供带参数的TaskBasic接口,而且只要调用TaskBasic.postTask()返回的是SUCCESS,调度程序就必须能够运行它。如果第一次调用TaskBasic.postTask()任务的TaskBasic().runTask()事件会被通知,所以TaskBasic.postTask()要返回SUCCESS。
调度程序的执行模块必须提供接口。它的命令(commands)被用来初始化和运行任务。接口定义如下:
interface Scheduler { command void init();
command bool runNextTask(bool sleep);
command void taskLoop();
}
init()命令函数用来初始化任务队列和数据结构。runNextTask()命令函数必须要等到当前任务执行完毕才运行下一个任务,返回值表示是否正在运行一个任务。Bool类型的参数sleep表示任务队列中没有要运行的任务时runNextTask()的状态。如果sleep是FALSE,那么runNextTask()中没有要运行的任务时返回;如果sleep是TRUE,那么runNextTask()将会一直等待有新的任务出现并被执行才会返回,这个时候最好让CPU进入省电模式。调用runNextTask(FALSE)可能返回TRUE或者是FALSE;调用runNextTask(TRUE)总是得到TURE。TaskLoop()命令告诉调度程序进入无限循环任务,只要MCU空闲的时候,就进入低功耗状态。TaskLoop()永远不会返回。
下面是TaskBasic的接口定义:
interface TaskBasic { async command error_t postTask();
void event runTask();
}
在一个组件内用nesC的task关键字声明一个任务,表明它使用了TaskBasic接口的一个实例:任务的主体就是runTask事件。当一个组件使用post关键字投递一个任务时,它调用的是postTask命令。每一个TaskBasic(基本任务)必须用一个独一无二的ID做为参数连线到调度程序。这个独一无二的参数通过调用unique函数获得。不需要手动的对声明的任务进行连线,只要是用task或者post关键字声明了的任务,nesC编译器会自动完成连线工作。
基本调度程序组件SchedulerBasicP就使用任务ID作为任务队列的内容。当运行下一个任务的时候,调度程序动队列中取出下一个任务的ID,然后用它去匹配相应的任务,寻找任务的入口地址。
5. 替换调度程序
基本的TinyOS任务调度非常简单,甚至没有任务超时管理。如果一个任务在执行并且一直不结束,那么其他任务就得不到运行,这种现象称为任务饥饿。通过替换调度程序,可以引入超时管理,即给任务的运行设定一个时限。下面将介绍如何使用自己的调度程序实现基本任务和具有超时管理的任务共存。
TinyOS调度程序由一个叫TinySchedulerC的组件提供。缺省情况下这个调度程序会调用一个模块SchedulerBasicP。缺省的调度程序组件TinySchedulerC是一个配置文件,它提供连线到SchedulerBasicP,由SchedulerBasicP具体实现。
要使用自己的调度程序,开发者应该在它的应用程序目录下放置一个叫TinySchedulerC.nc的文件,这样可以替代默认的叫TinySchedulerC的文件。在新的TinySchedulerC.nc文件中需要提供连线到一个具体的实现模块,就象SchedulerBasicP.nc一样,但是文件名不需要一样。实现模块必须包含带参数的TaskBasic接口,这是为了保证你自己开发的调度程序符合nesC的规则,否则的话将不能通过nesC的编译。
假定具有超时管理的任务提供的接口为TaskEdf,与之对应的基本任务的接口是TaskBasic:
interface TaskEdf { async command error_t postTask(uint16_t deadlineMs);
event void runTask();
}
调度程序的执行模块名称是SchedulerEdfP,提供了2中任务的接口:
module SchedulerEdfP { provides interface Scheduler;
provides interface TaskBasic[uint8_t taskID];
provides interface TaskEdf[uint8_t taskID];
}
在应用程序目录下新建配置文件TinySchedulerC.nc,并且连线到实现模块SchedulerEdfP
configuration TinySchedulerC { provides interface Scheduler;
provides interface TaskBasic[uint8_t taskID];
provides interface TaskEdf[uint8_t taskID];
}
implementation { components SchedulerEdfP;
Scheduler = SchedulerEdf;
TaskBasic = SchedulerEdfP;
TaskEDF = SchedulerEdfP;
}
对于具有超时管理的任务,它的任务ID也是由unique函数生成。用于传递给unique的关键字字符串格式是” TinySchedulerC.TaskInterface”。 TaskInterface是任务接口的名字,对于基本任务来说是TaskBasic,对在这里具有超时管理的任务来说是TaskEDF。通常用宏定义#define做统一管理,例如:
#define UQ_TASK_EDF "TinySchedulerC.TaskEdf"
在下面的例子中,应用程序SomethingP包含2个具有超时管理的任务和1个基本任务。
这是SomethingP的配置文件:
configuration SomethingC { ...
}
implementation { components SomethingP, TinySchedulerC;
SomethingP.SendTask -> TinySchedulerC.TaskEdf[unique(UQ_TASK_EDF)];
SomethingP.SenseTask -> TinySchedulerC.TaskEdf[unique(UQ_TASK_EDF)];
}
nesC编译器会自动生产基本任务的连线,所以在配置文件中不需要对基本任务手动连线。
这是SomethingP的模块文件:
module SomethingP { uses interface TaskEdf as SendTask
uses interface TaskEdf as SenseTask
}
implementation { // The TaskBasic, written with keywords
task void cleanupTask() { ... some logic ... } event void SendTask.runTask() { ... some logic ... } event void SenseTask.runTask() { ... some logic ... }
void internal_() { call SenseTask.postTask(20);
call SendTask.postTask(100);
post cleanupTask();
}
}
从上面的代码可以看到3个任务中有2个任务是具有超时管理的,应对于基本任务cleanupTask来说就不会碰到因为任务阻塞发生饥饿的情况。
如果调度程序提供的2个任务接口都是使用一样的任务接口,那么unique的关键字字符串应该安装接口名称来定。这种情况的确存在,就是调度程序使用2个任务队列,一个高优先级,一个普通优先级。这2个任务队列都是TaskBasic任务接口,实现代码如下:
configuration TinySchedulerC { provides interface Scheduler;
provides interface TaskBasic[uint8_t taskID];
provides interface TaskBasic[uint8_t taskID] as TaskHighPriority;
}
高优先级的任务队列被取名为TaskHighPriority,它的关键字字符串就是” TinySchedulerC.TaskHighPriority”:
configuration SomethingElseC {}implementation { components TinySchedulerC as Sched, SomethingElseP;
SomethingElseP.RetransmitTask -> Sched.TaskHighPriority[unique("TinySchedulerC.TaskHighPriority")];}