任务睡眠函数是一个非常有用的操作系统API,几乎每个RTOS都提供了一个类似的API给应用程序调用,在ucosii里,它叫OSTimeDly;在Nucleus里,它叫NU_Sleep;在FreeRTOS里,它叫vTaskDelay。它们的目的都是一样的,告诉操作系统:“我现在没有事情要做,请把CPU分配给其它任务,并在某个时间点把我唤醒”,这个时间点就是函数的入参,一般都是以tick为单位,如下所示:
关于tick和时间片的详细说明见实时操作系统的任务调度示例之时间片。睡眠函数的使用方法非常简单,本文跳过了它的基本介绍,在STM32平台上做了几个平时不常见的睡眠函数实验,结合实验结果讲解了FreeRTOS里的vTaskDelay的实现;后面对比了几种RTOS对于sleep的实现细节的不同之处。
相信很多朋友都在各种资料都看到过这样的说明:中断处理函数里不能调用任务睡眠函数。楼主在也先声明一下,这句话是正确的,确实不能,这里要做这个实验只是为了加深对任务睡眠函数实现的理解。但是如果在中断处理函数里调了,会有什么后果?系统崩溃?或者中断堆栈和任务堆栈互相覆盖?还是跟没发生什么事一样? 答案是“不一定”。请看下面的这个实验:
void Task1Func(void* p)
{
static int cnt = 0;
while (1)
{
USART_OUT(USART1,"Task 1 is running %d\n",++cnt);
if (3 == cnt) //到第3次循环的时候,启动一个定时器中断
{
TIM_ITConfig(TIM3,TIM_IT_Update,ENABLE);
TIM_Cmd(TIM3, ENABLE);
USART_OUT(USART1,"IRQ finished Task1 go on %d\n",++cnt);
}
vTaskDelay(200); //睡眠2秒
}
}
void Task2Func(void* p){
static int cnt = 0;
vTaskDelay(100);
while (1)
{
USART_OUT(USART1,"Task 2 is running %d\n",++cnt);
vTaskDelay(200); //睡眠2秒
}
}
xTaskCreate(Task1Func,( const signed char * )"Task1",64,NULL,tskIDLE_PRIORITY+3,NULL);
xTaskCreate(Task2Func,( const signed char * )"Task2",64,NULL,tskIDLE_PRIORITY+3,NULL);
void TIM3_IRQHandler(void)
{
static int cnt = 0;
if (TIM_GetITStatus(TIM3, TIM_IT_Update) != RESET)
{
cnt++;
TIM_ClearITPendingBit(TIM3, TIM_IT_Update);
USART_OUT(USART1,"IRQ\n"); //输出一句log表明走到了这里
vTaskDelay(600); //调用睡眠函数,6秒
TIM_Cmd(TIM3, DISABLE); //关闭TIM3中断
TIM_ITConfig(TIM3,TIM_IT_Update,DISABLE);
}
}
在main函数里创建了2个Task,都是做着简单的打印、输出log的无限循环。当Task1到第3次循环时,触发一个定时器中断,并在中断里调用任务睡眠函数vTaskDelay(600)。定时器中断的配置在 实时操作系统的忙等延迟实现里有讲述
扫一眼log,就能看出,系统并没有崩溃,而是在正常运行。请重点关注红色框内部的log,10:50:06.531 Task1进入第3次循环,输出一句打印“Task 1 is running 3”之后,触发TIM3的中断,该中断会立即抢占任务执行,输出打印“IRQ”,然后执行vTaskDelay(600),神奇的一幕出现了,这句代码的效果发生在Task1身上!Task睡眠了6秒,才完成睡眠走到IRQ finished Task1 go on。
为什么在中断里睡眠结果却是误把任务“给睡了”?答案当然要去vTaskDelay里面去找。直接看代码吧
void vTaskDelay( portTickType xTicksToDelay )
{
portTickType xTimeToWake;
signed portBASE_TYPE xAlreadyYielded = pdFALSE;
if( xTicksToDelay > ( portTickType ) 0U ) //如果入参设为0,就等于执行一次调度器
{
vTaskSuspendAll(); //关闭调度器
{
xTimeToWake = xTickCount + xTicksToDelay; //计算睡眠醒来的时间
vListRemove( ( xListItem * ) &( pxCurrentTCB->xGenericListItem ) ); //将当前任务从就绪列表里
prvAddCurrentTaskToDelayedList( xTimeToWake ); //将当前任务加入到阻塞任务链表里
}
xAlreadyYielded = xTaskResumeAll(); //打开调度器,返回值的意义是“是否已经完成了一次重新调度”
}
if( xAlreadyYielded == pdFALSE ) //如果没有完成,再强制执行一次任务重新调度
{
portYIELD_WITHIN_API();
}
}
将代码稍微修改一下,TIM3的中断周期设为500ms,第一次直接返回(因为刚打开的时候就会触发一次中断),第二次和前面的操作相同
void TIM3_IRQHandler(void)
{
static int cnt = 0;
if (TIM_GetITStatus(TIM3, TIM_IT_Update) != RESET)
{
TIM_ClearITPendingBit(TIM3, TIM_IT_Update);
if (1 == ++cnt) //第一次直接返回,第二次执行下面的delay函数
return;
vTaskDelay(600);
TIM_Cmd(TIM3, DISABLE);
TIM_ITConfig(TIM3,TIM_IT_Update,DISABLE);
}
}
得到的运行结果就完全不同了,
Task1执行了第3次循环之后500ms,TIM3的中断输出了IRQ打印,之后系统就死了。怎么死的?
此时Task1和Task2都处在睡眠状态,pxCurrentTCB指向的是空闲任务idletask,idletask是什么?这个任务是的系统里的一个酱油任务,它的优先级设置为最低,当所有应用程序的任务都不在就绪状态时,就轮到它执行了。它虽然不执行具体的工作,但它很重要,可以说是整个RTOS的最后一道防线。还是用考虑死机时的运行情况,在程序输出IRQ的时候,系统正在中断里,vTaskDelay将idletask从就绪链表中摘除后,整个系统里就没有一个任务还处在就绪态了。vTaskDelay的结尾会触发PendSV中断,它是低级别的中断,在当前中断执行完毕后,跳到它的入口xPortSendSVHandler,它其实就是执行调度算法,查找当前系统里最高优先级的就绪态任务,并执行它,查找最高优先级就绪任务代码如下:
while( listLIST_IS_EMPTY( &( pxReadyTasksLists[ uxTopReadyPriority ] ) ) )
{
--uxTopReadyPriority; //优先级从大到小循环遍历就绪任务的数组pxReadyTaskList
}
平时即使所有的应用task都睡眠了,也没有关系,因为调度器会反复的执行idletask打打酱油。但是这次不同,连idletask都不在就绪态了,idletask的优先级为0,uxTopReadyPriority一直减小到0都找不到可执行的task,uxTopReadyPriority被翻转了,0-1=0xFFFF!再去访问pxReadyTasksLists的0xFFFF项元素,内存严重越界,死机就是唯一的结果。
一般我们都说睡眠函数都是以tick为单位的,从前面的实验输出的log看来,Task1和Task2任务轮流睡眠1秒,等于100个tick,从时间戳上看还是相当精确的。是不是一直都是这么精确呢?请看下面的实验:
void do_something()
{
m_delay(60);
}
void Task1Func(void* p)
{
static int cnt = 0;
while (1)
{
USART_OUT(USART1,"Task 1 is running %d\n",++cnt);
do_something();
USART_OUT(USART1,"Task 1 work done\n");
vTaskDelay(1);
}
}
该实验只创建了一个任务,它调了一个函数do_something之后睡眠1个tick之后继续循环(为了实验结果更好,将tick间隔设的较大,1个tick是100ms),do_something模拟一项计算量较大的任务,实际上就是原地打转了60ms。按照代码的布置,作者的意图应该是像下面这张图这样的:
实际运行情况是这样吗?看看输出的log:
任务先工作了大约60ms,然后只能睡眠约40ms又开始下次循环,而不是前面分析的期望能睡眠100ms。
从这个实验我们知道任务睡眠1个tick的真正含义是“睡眠,直到下次tick中断到来”,用时间图来表示是这样的
FreeRTOS的实现
前面已经贴过了vTaskDelay的代码,它先将当前任务从就绪链表中摘下来,挂接到delay链表中,
传入的参数xTimeToWake是预期的唤醒时间,vListInsert插入链表的动作会先以唤醒时间的tick count做一个排序,根据唤醒时间由近及远的方式存储每个链表节点,xNextTaskUnBlockTime是链表头节点的唤醒时间,也就是距离现在最近的要唤醒的任务时间。
大多数RTOS的睡眠唤醒、定时器超时等检查都是在tick的中断做的,包括本文所讲的3种系统。FreeRTOS的检查任务唤醒函数如下
void prvCheckDelayedTasks()
{
portTickType xItemValue;
if( xTickCount >= xNextTaskUnblockTime ) //如果当前时间已经大于下一次要唤醒任务的时间
{
for( ;; ) //用了一个循环,因为可能会有多个任务在同一时间要唤醒
{ if( listLIST_IS_EMPTY( pxDelayedTaskList ) == FALSE )
{
pxTCB = ( tskTCB * ) listGET_OWNER_OF_HEAD_ENTRY( pxDelayedTaskList );
xItemValue = listGET_LIST_ITEM_VALUE( &( pxTCB->xGenericListItem ) );
if(xTickCount < xItemValue)
{ //这是下次要超时的任务时间
xNextTaskUnblockTime = xItemValue;
break;
}
vListRemove( &( pxTCB->xGenericListItem ) ); //将要唤醒的任务从delay链表摘下
prvAddTaskToReadyQueue( pxTCB ); //挂接到就绪链表中
}
}
}
}
Nucleus的实现
Nucleus在任务控制块TCB里存有一个timer计时的结构体,它本身是一个双向链表的节点。Nucleus里将任务超时和定时器超时只用了同一条链表来进行管理,只有一个标志位作为区分。任务睡眠时,类似于定时器的启动,将timer结构体按超时时间挂到等待链表上,系统的头节点里保存是“距离当前最近的一个超时定时器或任务的时间”,后面的节点按照时间的由近及远,每个节点里的时间都是相对于前一个的值。详细的描述见这里 高效软件定时器的设计
总结来看,FreeRTOS和Necleus里关于的实现都是比较高效的,二者思路差不多,一个用绝对时间,一个用相对时间,旗鼓相当
最后是ucosii的实现:
在任务睡眠的时候调用的是OSTimeDly,这里有个细节需要点赞,ucosii2.86的代码里,对是否在中断里调用该函数进行了判断(下面图片里的红框),如果在中断上下文就直接返回,避免了前面实验里的错误。 2.52里还没有这个功能。这个函数主要就是做了两件事:
1 在任务就绪表里将该任务的就绪态清除
2 记录睡眠的时间(图片里的黑色框)
关于唤醒的检查,ucosii的实现在每个tick中断里遍历所有任务并检查每个任务的OSTCBDly字段,非常简单粗暴。如果系统里没有任务在睡眠的话,那每个tick周期的循环遍历就没有做任何有意义的事,相对于前面两个RTOS,ucosii的做法有可优化的空间