本文以野火的教程和代码为基础,对 FreeRTOS 中时间片的概念作了解释,并且给出了实现方式,同时发现并解决了野火教程代码中的 bug。
在前面的文章中,我们已经知道任务根据不同的优先级被放入就绪列表中不同下标的链表中,先执行优先级高的链表。而且我们知道,在一条相同优先级的就绪链表中可以存放多个 TCB。
那么,你有没有想过,当一条相同优先级的就绪链表中存放了多个 TCB 时,怎么执行?有些人可能会想:相同优先级的话就顺序执行呗,执行完任务1就接着执行任务2,这样一直往下执行就可以啦。
那如果我们在一个任务中放入了耗时很长的代码,或者更极端一些,放入一个死循环,那我们的任务岂不是就永远无法执行完毕,也就永远没办法切换到相同优先级的其他任务了。
所以,我们在这里引入了对于时间片的支持。**时间片(time slice)实际上就是为了让 CPU 资源能较为公平地分配到每一个任务中。在相同的优先级中,每个任务按照固定顺序依次执行一个时间片的时间,然后切换到下一个任务。**如果一个任务在时间片结束之前没有执行完,它将被挂起,任务的上下文将被保留,并在下一个循环中重新获得时间片后继续执行上次没有执行完的代码。
通过划分时间片,操作系统能够公平地分享处理器时间给多个任务,使得它们能够以看似同时的方式进行处理,提高了系统的并发性和响应性能。
其实在前面的文章中,我们的代码已经可以支持时间片了,但是为了让代码效率更高,还是需要对代码进行一些优化。主要是以下这些代码:
之前的代码是每进入一次 SysTick 中断后都调用一次 taskYIELD() 进行任务的切换。
但实际上,进入中断后并不一定需要切换任务。需要进行任务切换的有以下这些情况:
所以我们在 SysTick 中断中调用时基计数函数 xTaskIncrementTick(),并在 xTaskIncrementTick() 对上面所说的情况进行判断,返回是否需要进行任务切换,再在 SysTick 中断看情况是否进行任务切换。
/*
*************************************************************************
* SysTick中断服务函数
*************************************************************************
*/
void xPortSysTickHandler( void )
{
/* 关中断 */
vPortRaiseBASEPRI();
{
/* 更新系统时基 */
if( xTaskIncrementTick() != pdFALSE )
{
taskYIELD();
}
}
/* 开中断 */
vPortClearBASEPRIFromISR();
}
实际上,野火的教程中出现了两个 bug:
#define configUSE_PREEMPTION 1
#ifndef configUSE_TIME_SLICING
#define configUSE_TIME_SLICING 1
#endif
//系统时基计数
BaseType_t xTaskIncrementTick( void )
{
TCB_t * pxTCB;
TickType_t xItemValue;
BaseType_t xSwitchRequired = pdFALSE;
const TickType_t xConstTickCount = xTickCount + 1;
xTickCount = xConstTickCount;
/* 如果xConstTickCount溢出,则切换延时列表 */
if( xConstTickCount == ( TickType_t ) 0U )
{
taskSWITCH_DELAYED_LISTS();
}
/* 最近的延时任务延时到期 */
if( xConstTickCount >= xNextTaskUnblockTime )
{
for( ;; )
{
if( listLIST_IS_EMPTY( pxDelayedTaskList ) != pdFALSE )
{
/* 延时列表为空,设置xNextTaskUnblockTime为可能的最大值 */
xNextTaskUnblockTime = portMAX_DELAY;
break;
}
else /* 延时列表不为空 */
{
pxTCB = ( TCB_t * ) listGET_OWNER_OF_HEAD_ENTRY( pxDelayedTaskList );
xItemValue = listGET_LIST_ITEM_VALUE( &( pxTCB->xStateListItem ) );
/* 直到将延时列表中所有延时到期的任务移除才跳出for循环 */
if( xConstTickCount < xItemValue )
{
xNextTaskUnblockTime = xItemValue;
break;
}
/* 将任务从延时列表移除,消除等待状态 */
( void ) uxListRemove( &( pxTCB->xStateListItem ) );
/* 将解除等待的任务添加到就绪列表 */
prvAddTaskToReadyList( pxTCB );
#if ( configUSE_PREEMPTION == 1 )
{
if( pxTCB->uxPriority >= pxCurrentTCB->uxPriority )
{
xSwitchRequired = pdTRUE;
}
}
#endif /* configUSE_PREEMPTION */
}
}
}/* xConstTickCount >= xNextTaskUnblockTime */
#if ( ( configUSE_PREEMPTION == 1 ) && ( configUSE_TIME_SLICING == 1 ) )
{
if( listCURRENT_LIST_LENGTH( &( pxReadyTasksLists[ pxCurrentTCB->uxPriority ] ) )
> ( UBaseType_t ) 1 )
{
xSwitchRequired = pdTRUE;
}
}
#endif /* ( ( configUSE_PREEMPTION == 1 ) && ( configUSE_TIME_SLICING == 1 ) ) */
/* 任务切换 */
//portYIELD();
return xSwitchRequired;
}
那么如果要实现时间片,也就是说在进行任务切换的时候,我们要获取到同一优先级链表下当前任务的下一个任务的指针,然后再切换到下一个任务去执行。这要怎么实现呢?
我们调用任务切换函数后,就会产生 PendSV 中断,中断中会调用上下文切换函数,而在上下文切换函数中,会调用选择优先级最高任务的函数,这个函数中首先确定最高的优先级,然后更新当前任务指针(listGET_OWNER_OF_NEXT_ENTRY())。
实际上,listGET_OWNER_OF_NEXT_ENTRY() 就是实现时间片的关键。
还记得我们在进行链表实现时,链表中有一个 pxIndex 的索引吗,listGET_OWNER_OF_NEXT_ENTRY() 使用这个索引,记住了上一次执行的任务的位置,这样就能在每次调用时获取当前任务的下一个任务的指针。
/* 获取链表节点的OWNER,即TCB */
#define listGET_OWNER_OF_NEXT_ENTRY( pxTCB, pxList ) \
{ \
List_t * const pxConstList = ( pxList ); \
/* 节点索引指向链表第一个节点调整节点索引指针,指向下一个节点,
如果当前链表有N个节点,当第N次调用该函数时,pxInedex则指向第N个节点 */\
( pxConstList )->pxIndex = ( pxConstList )->pxIndex->pxNext; \
/* 当前链表为空 */ \
if( ( void * ) ( pxConstList )->pxIndex == ( void * ) &( ( pxConstList )->xListEnd ) ) \
{ \
( pxConstList )->pxIndex = ( pxConstList )->pxIndex->pxNext; \
} \
/* 获取节点的OWNER,即TCB */ \
( pxTCB ) = ( pxConstList )->pxIndex->pvOwner; \
}
我们定义两个优先级较低(优先级为 2)的任务,并在这两个任务中使用循环延时改变电平,然后再定义一个优先级较高(优先级为 3)的任务,在这个任务中使用阻塞延时改变电平。代码如下:
/* 任务1 优先级为2 */
void Task1_Entry( void *p_arg )
{
for( ;; )
{
flag1 = 1;
//vTaskDelay( 1 );
delay (100);
flag1 = 0;
delay (100);
//vTaskDelay( 1 );
}
}
/* 任务2 优先级为2 */
void Task2_Entry( void *p_arg )
{
for( ;; )
{
flag2 = 1;
//vTaskDelay( 1 );
delay (100);
flag2 = 0;
delay (100);
//vTaskDelay( 1 );
}
}
/* 任务2 优先级为3 */
void Task3_Entry( void *p_arg )
{
for( ;; )
{
flag3 = 1;
vTaskDelay( 2 );
//delay (100);
flag3 = 0;
vTaskDelay( 2 );
//delay (100);
}
}
而如果我们的时间片功能没有实现,那么任务3 在进入阻塞延时后将交出 CPU 使用权,开始执行任务1。由于任务1 的延时是循环延时而不是阻塞延时,那么 CPU 将一直执行任务1 直到任务3 的阻塞延时结束再跳到任务3 执行,而任务2 永远不会得到执行。
在野火的官方例程中,configUSE_TIME_SLICING 的定义被注释掉了,也就是说,野火的代码实际上没有完全实现时间片的功能。
以上文定义的验证代码为例,注释掉configUSE_TIME_SLICING,导致在任务3 交出 CPU 控制权给其他任务后,如果其他任务里面是一个循环,那么在任务3 重新获得 CPU 控制权前将一直执行这个任务;而得益于 listGET_OWNER_OF_NEXT_ENTRY(),在任务3 下一次交出 CPU 控制权后,将会执行和刚才任务不同的另一个任务。
也就是出现了一种怪异的现象:表面看起来优先级较低的任务1 和 2 有轮流执行,似乎实现了时间片算法。但实际上正确的表现应该是在任务3 交出 CPU 控制权后(此时任务3 中变量电平稳定为1 或者 0),任务1 任务2 在任务3 中变量电平稳定期间可以多次切换,实现真正的时间片;而不是直到任务3 改变电平后再进行任务1 和任务2 之间的相互切换。
同样是任务 3 阻塞延时为2ms:
如果您觉得本文写得不错,可以点个赞激励一下作者!
如果您发现本文的问题,欢迎在评论区或者私信共同探讨!
共勉!