再次说明FreeRTOS的调度策略:调度器在任何时候总是从当前所有状态为就绪状态的任务中选取优先级最高的那个来让其执行。如果当前处于就绪态的且优先级最高的任务有多个,则调度器将轮流将每个任务转换为运行状态,然后再将它切换回就绪态,使得每个任务在每轮中最多执行一个时间片的时间。即按时间片轮流执行他们。
什么是时间片?和绝大多数RTOS一样,FreeRTOS也会使用宿主硬件提供的系统定时器,来产生周期性的中断,我们把它叫做“tick中断”,例如对于Cortex-M3内核的单片机,就是利用了CM3内核的SysTick系统滴答定时器来实现的。tick中断的频率在FreeRTOSConfig.h中使用configTICK_RATE_HZ宏来定义,单位Hz,典型值为100,一般不会超过1000。tick中断频率的倒数就是一个tick周期,而一个时间片的长度就等于一个tick周期。
为了能在每个时间片结束后选择下一个任务进入到运行态,调度器需要在每个时间片结束时执行。但是调度器并不只会在每个时间片结束时执行。对于Cortex-M3内核的单片机,调度器就是在SysTick中断中实现的。
在FreeRTOS中,一些与时间相关的系统函数都要以tick数为参数,例如前面介绍过的vTeskDelay(TickType_t xTicksToDelay)函数。然而人类世界更习惯用秒(或者毫秒,微秒)单位来度量时间,所以内核提供了一个宏pdMS_TO_TICKS(),用于将毫秒时间转换为当前系统配置下对应的tick数。这个宏定义在projdefs.h头文件中。
TickType_t xTimeInTicks = pdMS_TO_TICKS(500); //500ms对应的tick数
vTaskDelay(pdMS_TO_TICKS(200)); //阻塞延时200ms
需要注意的是,这个宏使用了比例关系和C语言中的整数除法来进行转换的,所以当参数不是tick周期的整数倍时间时,pdMS_TO_TICKS() 宏函数并不能实现精确的转换,例如,tick周期=10ms时,pdMS_TO_TICKS(5) 返回值为0;pdMS_TO_TICKS(12) 返回值为1。
FreeRTOS内核代码中,使用了一个全局变量xTickCount(定义在tasks.c)来记录从调度器运行以来的tick中断次数,并在SysTick的中断函数(xPortSysTickHandler)中递增。xTickCount计数到最大值后会溢出归零,但应用程序不用担心tick count的溢出,FreeRTOS内部会保证时间的一致性。用户可以使用xTaskGetTickCount()或者xTaskGetTickCountFromISR()函数来读取xTickCount的值。
FreeRTOS程序在任意时刻,必须至少有一个任务处于运行状态,为了达到这个要求,FreeRTOS使用了Idle任务:当vTaskStartScheduler调用后,调度器会自动创建Idle任务,这个任务的任务函数就是一个连续性工作的任务,所以他总是可以处于就绪态(在运行态和就绪态之间转换,没有其他状态)。由于Idle任务的优先级是最低的(优先级为0),所以Idle任务不会抢占用户任务的运行。当其他高优先级的任务需要运行时,他们会抢占Idle任务。
忠告:虽然Idle任务优先级最低,但是不代表它不重要。Idle任务主要用于资源回收清理工作,例如当你在程序中删除一个任务后,就需要Idle任务去清理这个任务占用的资源。因此,不要让Idle任务“饿死”,具体而言,不要创建一个优先级比Idle任务优先级高,且连续性工作的任务。如果应用程序也需要一个在背后连续工作的任务,则应该设置其优先级和Idle任务相同。当然这个需求更好的实现方法是通过下面介绍的Idle钩子来完成。
Idle任务钩子函数(Idle任务回调函数)
用户可以将一些需要在后台持续运行的,不太紧要的工作直接插入到Idle任务中,这样用户就无需再另外创建一个任务了,避免额外的系统开销。这是通过Idle钩子函数实现的。下面的代码是FreeRTOS内核定义的Idle任务函数简化后的样子。
//Idle任务函数定义在tasks.c文件中,搜索prvCheckTasksWaitingTermination()就可以定位到
//简化后的Idle任务函数
void prvIdleTask( void *pvParameters )
{
for( ;; ) //任务函数主循环
{
/*如果有任务被删除了,则这里将释放掉这个任务的TCB块存储空间和stack空间*/
prvCheckTasksWaitingTermination();
/*如果configIDLE_SHOULD_YIELD 配置为1*/
#if ( ( configUSE_PREEMPTION == 1 ) && ( configIDLE_SHOULD_YIELD == 1 ) )
{
//如果有优先级与Idle任务相同且就绪的任务
//则调用taskYIELD(); 主动放弃CPU时间片
}
#endif
/*如果使用了Idle钩子函数*/
#if ( configUSE_IDLE_HOOK == 1 )
{
extern void vApplicationIdleHook( void ); //声明Idle钩子函数
/*
调用用户实现的vApplicationIdleHook()函数,用于将用户的后台工作插入到Idle
任务中执行*/
vApplicationIdleHook();
}
#endif /* configUSE_IDLE_HOOK */
}
}
从上面的代码可以发现,为了使用Idle任务钩子函数,必须在FreeRTOSConfig.h中将configUSE_IDLE_HOOK 定义为1,并自己实现vApplicationIdleHook()函数。
忠告:一定不要在vApplicationIdleHook()函数中执行会让Idle任务阻塞或者挂起的代码,否则将导致其它任务无法切换到运行态,也尽量不要让流程卡在这个钩子函数中,vApplicationIdleHook函()数应该在简短的运行后立刻返回。特别是在应用程序有删除任务的需求的时候。因为在Idle任务的主循环中,除了调用vApplicationIdleHook(),还需要执行清理那些被删除的任务的TCB存储空间和分配的stack空间的代码,如果不能保证以上两点,则这些空间无法得到释放,会导致内存泄露。
哪些应用需求可以使用Idle钩子解决?
FreeRTOS支持多种任务调度算法,通过在FreeRTOSConfig.h配置可以轻松选择。这里讲解最常用的一种:带有时间片轮转机制且基于优先级的抢占式调度器算法(Prioritized Pre-emptive Scheduling with Time Slicing)。
为了使用这种调度算法,需要在FreeRTOSConfig.h中将下面两个宏定义为1。
#define configUSE_PREEMPTION 1 //使用抢占式调度算法
#define configUSE_TIME_SLICING 1 //对相同优先级的任务使用时间片轮转
这种调度算法主要有下面三个特点:
固定优先级:内核在调度任务时不会改变任务的优先级,但不阻止任务自己修改自己或者别的任务的优先级。
抢占式:一个正在运行着的任务会被一个优先级比他更高且处于就绪态的任务抢占。抢占意味着调度器把正在运行着的任务从运行态变为就绪态,而让新的更高优先级的就绪态任务变为运行态。
时间片:时间片用于控制优先级相同的多个任务之间的调度行为。时间片机制会让相同优先级的任务轮流执行,让他们有共享CPU处理时间的机会,即便这些任务不主动放弃CPU时间或者进入到阻塞态。在每一次时间片结束后,调度器会选择下一个处于就绪态且优先级相同的任务进入到运行态。在FreeRTOS中,一个时间片就是一个tick周期。时间片轮转机制并不保证优先级相同的任务会均分CPU时间,它只保证处于就绪态且优先级相同的多个任务会轮流从就绪态转为运行态再转为就绪态。例如下图中,Task2和Task3都是连续性工作的任务,而Task1是基于事件驱动的任务。由于Task2和Task3优先级相同且处于就绪态,则他们按照时间片轮转运行,但是在t4~t5中间的某个时刻,Task1等待的事件发生了,它抢占了Task2,导致Task2在t4~t5时间片内实际运行的时间低于一个时间片的长度,总体上Task3的运行时间多于Task2的。通俗的来说,调度器给这些任务运行的机会以及分配的时间是相同的,但是实际运行了多少时间要看机遇。
例子1
如下图所示,假设使用本节介绍的调度算法,有4个优先级不同的任务,Idle任务优先级最低,Task1优先级最高。Task1和Task3是基于事件驱动的,Task2是周期性执行的。
Idle任务:其优先级最低,所以每次有更高优先级的任务处于就绪态时,他就会被抢占,例如在上图中的t3,t5和t9时刻。
Task3:Task3的优先级仅高于Idle任务,它是基于事件驱动的。在大多数时间都在等待它感兴趣的事件发生而处于阻塞态。当这种事件发生时就会转变为就绪态。Task3关注的事件在t3,t5以及t9~t12之间的某个时刻发生。在t3和t5时刻,由于Task3是当时优先级最高的任务,所以他能立刻从就绪态转为运行态。但在t9~t12时间段,由于Task1和Task2的优先级更高且能进入到就绪态,所以任务3无法进入到运行态,直到t12时刻,Task1和Task2都进入了阻塞态,Task3才是那时优先级最高的处于就绪态的任务,才能得到运行。
Task2:是一个需要周期性执行的任务,他在t1,t6和t9时刻会进入到就绪态。在t6时刻,Task2进入到就绪态,他的优先级高于Task3,所以抢占了正在运行的Task3。在t7时刻,Task2再次进入到阻塞态,所以Task3得以继续运行。
Task1:优先级最高,基于事件驱动。在t10时刻,它等待的事件发生,于是抢占了正在运行的Task2。在t11时刻,Task1进入到阻塞态,所以Task2得以继续运行。
例子2
Idle Task和Task2:这2个任务都是持续性工作的任务,优先级相同且最低。调度器用时间片轮转方式轮流执行他们。
Task1:基于事件驱动,在大多数时间都是阻塞态,在t6时刻它关心的事件发生,变为了就绪状态,于是抢占了正在运行的Idle Task,在t7时刻Task1执行完毕再次进入到阻塞态。
前面我们提到,可以使用Idle钩子来将一些需要在背后工作的代码插入到Idle任务中。如果你觉得这样做不太习惯,而更愿意自己单独创建低优先最级(优先级为0)的任务去做(为了方便描述暂且叫他BgTask),且FreeRTOS使用了抢占式调度算法,那么在这种情况下,我们会用到configIDLE_SHOULD_YIELD宏。
在上面例子2中,我们可以发现,Idle任务和Task2任务共享CPU时间片。这样意味Idle会和我们创建的BgTask共享时间片。由于我们已经打算不使用Idle钩子了,那么Idle任务只做一件事,就是检查是否有任务被删除,如果有的话就进行资源回收,除此之外不干别的事情。而我们自己创建BgTask却有业务要做。所以让Idle任务每次占用一个完整的时间片是很浪费的行为。我们希望Idle任务能在回收资源后,主动放弃剩余的时间片,让CPU更多的去执行我们的BgTask。在这种需求下,我们就需要在配置文件中将configIDLE_SHOULD_YIELD配置为1。这个时候,Idle的运行效果如下图所示(Task2就是这里说的BgTask)。
①:Idle在得到时间片后,运行一轮主循环,检查是否有资源需要回收后,主动放弃这个时间片的余下CPU时间,使得调度器可以选择其他同等优先级的任务运行。
②:由于Idle任务提前主动放弃了CPU时间片,Task2在剩余的时间片及时运行。可以发现相比Idle任务,Task2占用了更多的CPU时间,提高了CPU执行效率。