如果我对本翻译内容享有所有权。允许任何人复制使用本文章,不会收取任何费用。如有平台向你收取费用与本人无任何关系
第三章 . 任务管理
范围
这个章节是为了让用户对以下内容有所理解:
- Freertos如何给每一个程序里面的任务分配进程时间
- Freertos如何确认当前时间运行哪一个任务
- 任务优先级对系统行为的影响
- 一个任务可能存在的状态
除此以外读者对以下内容也应有所理解:
- 怎样实现任务
- 如何创建一个和多个任务实例
- 如何使用任务参数
- 如何改变一个已经创建任务优先级
- 什么时候执行空闲任务,如何使用它
本章的都是如何使用Freertos和Freertos应用的基础概念,也是本书最为详细的一个章节。
任务函数
任务使用C函数实现的,唯一特别的就是它的格式,它返回一个空,传递一个空指针参数,下面是任务函数的原型:
void aTaskFunction(void *pvParameters)
每一个任务都是有单独权限的小程序。有一个进入点,会在一个内链循环中一直运行,不会退出。下面是一个任务函数的基本结构
void aTaskFunction(void *pvParameters){
/* 变量可以被声名为普通函数,每个使用此样板函数的任务实现都会有其本身的变量复制。除非变量被申明为静态的,如果申明为静态变量内存中只会有一个此变量,所有的任务会共享此变量。变量名字前面的前缀在第1章第五节有描述,数据格式和编码指南有介绍*/
int32_t lVariableExample = 0;
/* 一个任务通常在其内链循环中实现 */
for(;;){
/* 任务的具体实现代码放在这里 */
}
/* 如果任务因为break退出上面的循环,在到达这个函数末尾前删除当前任务。传递给vTaskDelete()函数一个NULL参数,表示删除当前任务。这种约定的介绍位于第0节。对于V9.0.0以前的Freertos项目,必须要一个heap_n.c才能编译。对于V9.0.0以后版本只有在将configSUPPORT_DYNAMIC_ALLOCATION设置为1,或configSUPPORT_DYNAMIC_ALLOCATION没有在FreeRTOSconfig.h中配置时,才需要heap_n.c文件。第二章堆内存数据格式和编码规范有介绍。*/
vTaskDelete(NULL);
}
Freertos的任务实现函数不允许任何形式的返回,也就是说不能包含任何return语句,也不能运行到程序的末尾。如果一个程序不再有用,它应该被明确的删除,这个上面的注释中也有提到。
可以用一个单独的任务来创建多个任务。每一个创建的任务成为执行实例的一部分。每一个创建任务都拥有独立的堆栈和局部变量。
顶层任务状态
一个程序由多个任务组成。如果处理器是单核的,每次就只能有一个任务在运行。意味着任务有两种状态,运行和不运行。这种简单的模型首先被注意到,但它却太简单了。下面的章节将不运行又分成几个状态。
当一个任务是运行状态,处理器就执行这个任务。一个任务不在执行状态,它就是休眠的。它的状态被保存为就绪,即任务调度器决定下次恢复它执行将它的状态改变为执行。当一个任务恢复执行,它首先会恢复上次终止前的状态。
一个任务从不运行转换成运行被称作’switched in’或’swaped in’。相反从运行切换到不运行又叫’switched out’或’swaped out’。Freertos任务调度器是唯一可以进行这种切换的函数。
创建任务
xTaskCreate()接口函数
Freertos V9.0.0已经包含xTaskCreate()函数,它在编译的时候分配一个任务结构需要的内存。任务创建是通过Freertos的xTaskCreate()接口函数。这可能是所有函数中最有用的,不幸的是你必须首先学会它,并熟练使用它,他是所有多任务系统组成的关键,这本书所有实例都用到这个函数,所以后面有很多它的使用例子。
1.5章节,数据格式和编码规范,描述了数据格式和命名规则
// xTaskCreate函数原型。列表13
BaseType_t xTaskCreate(
TaskFunction_T pvTaskCode,
const char * pcName,
uint16_t usStackDepth,
void * pvParameters,
BaseType_t uxPriority,
TaskHandle_t * pxCreatedTask
);
pvTaskCode:
任务只是永不退出的C函数,因此用内链循环实现,pvTaskCode变量只是一个实现这个任务函数的简单指针。其实只是这个函数的名字
pcname:
任务的名字描述,Freertos没有以任何方式使用它。只是作为一个调试提示。对于人类用一个名字识别一个任务远比用他的句柄容易。
configMAX_TASK_NAME_LEN
限制了名字的最长字符数,它是包含结尾的空字符的。字符串太长会被截断。
usStackDepth:
每个任务都有唯一的栈,他是任务创建时每个分配给任务的,usStackDepth决定每个给任务分配多大的栈。
这个值是以字为单位的,不是字节。比如栈是32位宽,usStackDepth传递100,会分配400个字节的栈空间(4 * 100),栈空间由栈宽度决定,栈宽度不能低于16位的uint16_t
。低于16位就是51单片机了。哈哈
空闲任务的栈空间由常量configMINIMAL_STACK_SIZE
确定,这个常量定义在Freeetos的实例程序中,是推荐的最小任务空间。如果任务会用很多的栈空间,你应该分配一个大的空间。
没有简单的方法确定任务的栈空间。它可以计算,但大部分程序员只是随便写一个可接受值。然后用freertos提供的功能确定它是否合适的,内存也没有被大量浪费。12.3栈溢出讲述了如何确定一个任务的最大栈深度。
pvParameters:
任务函数接受一个void *格式的参数,这个变量就是传给任务函数的变量。这本书的一些例子演示了如何传递参数。
uxPriority:
任务优先级。最小的任务等级是0,最大的是configMAX_PRIORITIES
。configMAX_PRIORITIES
是用户定义的,会在3.5章节介绍。
pxCreatedTask:
当前创建任务句柄。这个句柄可以用于后面传递给任务管理的用户接口函数,比如删除任务或改变任务优先级函数。
Returned Value:
有两个可能的返回值,pdass和pdFalse。pdPass表示任务创建成功。pdFalse表示任务创建失败。可能是因为没有足够的空间分配给任务数据结构和栈。第二章给出了堆管理的信息。
例1:创建任务
这个例子演示了创建两个简单任务的步骤,然后开始执行任务。这些任务只是打印字符串。使用一个空循环做延迟。两个任务都以相同的优先级创建,用不同的打印字符串区分,下面是具体实现:
void task1(void *pvParameters){
const char *pcTaskName = "Task1 is running\r\n";
volatile uint32_t ul; /* volatile 防止编译器优化掉,不进行循环*/
/* 大多数任务,都以这样的内联循环运行*/
for(;;){
/* 打印任务名字 */
vPrintString( pcTaskName );
/* 延迟 */
for(ul = 0; ul < mainDELAY_LOOP_COUNT; ul++)
{
/* 这个循环只是延迟实现,啥都不干。后面的例子会用delay或sleep函数替代*/
}
}
}
void task2(void *pvParameters){
const char *pcTaskName = "Task2 is running\r\n";
volatile uint32_t ul; /* volatile 防止编译器优化掉,不进行循环*/
/* 大多数任务,都以这样的内联循环运行*/
for(;;){
/* 打印任务名字 */
vPrintString( pcTaskName );
/* 延迟 */
for(ul = 0; ul < mainDELAY_LOOP_COUNT; ul++)
{
/* 这个循环只是延迟实现,啥都不干。后面的例子会用delay或sleep函数替代*/
}
}
}
// 主函数开始调度器之前创建任务
int main(void){
/* 创建2个任务。真正的程序应当检测xTaskCreate()函数的返回值,确保函数成功执行。*/
xTaskCreate(task1, "Task1", 1000, NULL, 1, NULL);
xTaskCreate(task2, "Task2", 1000, NULL, 1, NULL);
/* 开启调度器 */
vTaskStartScheduler();
/* 如果一切顺利,主函数就不会运行到接下来的循环。如果主函数运行到这个循环,就说明没有足够的堆用于创建空闲任务。第二章提供了更加详细的堆管理信息。*/
for(;;);
}
下面是执行的结果:
Task1 is running
Task2 is running
Task1 is running
Task2 is running
Task1 is running
Task2 is running
Task1 is running
Task2 is running
...
可以看到任务一旦执行就打印当前执行的是哪个任务。手册中有一个执行结果的截图,windows的cmd模拟器不是实时显示的,从写入数据到调用系统调用会有部分延迟所以实际执行你可能会感觉到卡顿。在没有阻塞的同步显示模拟器中运行同样的代码就会得到你想要的结果。每个任务交替执行,打印当前执行的任务名字。
上面演示了两个程序在模拟器中执行情况。尽管程序感觉像是在处理器中同时运行,但这不是实事。实际上两个任务交替进入和退出运行状态,两个任务都以同等优先级运行。它们会共享处理器的时间。最后的执行结果就像上面那样。
# 两个任务的实际执行情况
Task1 |---| |---| |---| |
Task2 | |---| |---| |---|
time T1 T2 T3 T4 ...
上面展示了随着时间从T1开始流逝,—表示任务在此时间段运行。比如,Task1在T1到T2时间段就是运行状态,在T2到T3就是非运行状态。
任意时间上只有一个任务处于执行状态。所以一旦一个任务进入执行状态,就会伴随另外一个任务进入非执行状态(切出)。
实例1在main()函数中创建了2和任务,然后启动调度器。也可以在其他的任务中创建任务。下面的代码就展示了,在Task1中创建Task2:
void Task1(void * pvParameters){
const char *pcTaskName = "Task 1 is running\r\n";
volatile uint32_t ul;
/* 如果要执行这段代码,那么调度器就必须已经调用。进入内联循环前创建另外一个任务 */
xTaskCreate(Task2, "Task2", 1000, NULL, 1, NULL);
for(;;){
/* 打印任务名字*/
vPrintString(pcTaskName);
// 延时
for(ul = 0; ul < mainDELAY_LOOP_COUNT; ul++) ;
}
}
例2:使用任务参数
例1中的两个任务是基本相同的,它们只有打印的字符串不一样。重复的部分是可以删除的。而不是必须创建两个任务。任务参数可以用来传递每个任务需要打印的字符串。
下面就是例2的代码,只有一个任务函数。这个单独的任务函数替代了例1中的两个任务函数(Task1和Task2)。注意任务参数是如何转换成char *
格式,以便用于打印的。
// 单独任务函数用来创建2个任务
void vTaskFunction(void *pvParameters){
char *pcTaskName;
volatile uint32_t ul; // volatile 关键字防止编译器将它优化掉,不进行延迟循环*/
/* 打印出的字符串是通过这个参数传递的,注意转化为char *格式*/
pcTaskName = (char *)pvParameters;
/* 和大多数任务一样的内联循环*/
for(;;){
// 打印任务名字
vPrintString(pcTaskName);
// 延迟循环
for(ul = 0; ul < mainDELAY_LOOP_COUNT; ul++) ;
}
}
尽管现在只有1个单独的任务函数(vTaskFunction),却不只一个任务实现可以被创建。每个任务实现都可以在任务调度器控制下单独的运行。
下面就教你如何在xTaskCreate函数中传递不同的字符串给任务函数。
// 例2的main()函数
/* 首先定义将要传递给任务函数的衣服串。它们被定义为常量,而不是栈上的变量,是为了保证任务执行中也可以访问到*/
static const char *pcTextForTask1 = "Task1 is running\r\n";
static const char *pcTextForTask2 = "Task2 is running\r\n";
int main(void){
// 创建第一个任务
xTaskCreate(vTaskFunction,"Task1", 1000, pcTextForTask1, 1, NULL);
/* 同样的方式创建另外一个任务。注意这次的任务创建使用的是相同的任务实现(vTaskFunction)。只有传递的参数不一样。两个相同实现的任务就创建成功了。*/
xTaskCreate(vTaskFunction,"Task2", 1000, pcTextForTask2, 1, NULL);
/* 开启调度器以使任务开始执行 */
vTaskStartScheduler();
/* 如果一切正常,main()函数不会执行到这里,因为调度器应该正在执行任务。如果main()函数运行到了这里,那么应该是没有足够的空间用于创建空闲任务了。可以查看第二章获取更多关于堆管理的细节*/
for(;;);
}
任务优先级
xTaskCreate函数的uxPriority参数是任务创建时的初始优先级。这个优先级可以使用vTaskPrioritySet函数在调度器运行之后改变。
最大的可用优先级在FreeRTOSConfig.h文件中配置,具体一点就是configMAX_PRIORITIES
宏。低优先级的数字代表低优先级任务,0是最低的优先级。尽管优先级是从0到configMAX_PRIORITIES - 1
,有些任务可以使用相同的优先级。确保最大的设计灵活性。
FreeRTOS系统可以在2个中选一个方法决定哪个任务运行。configMAX_PRIORITIES可以被设置为最大多大取决于使用那种方法:
- 通常做法
通常做法是用C实现的,所有的FreeRTOS架构都可以使用。
当使用通常方法时,FreeRTOS系统不会限制configMAX_PRIORITIES
可以设置的最大值。尽管一般都建议让configMAX_PRIORITIES
尽量小,因为越大就需要更大的内存空间,也会导致更多不必要的浪费。每次任务调度都会查找这个优先级队列。
FreeRTOSConfig.h中的configUSE_PORT_OPTIMISED_TASK_SELECTION
设置为0或没有被设置时就是使用通常的方法。或者当前FreeRTOS只支持通常做法也就只能用通常做法了。 - 平台优化法
平台优化法使用了少量的汇编代码,所以它比通常做法快。configMAX_PRIORITIES
不会对运行时间有坏影响。
如果使用平台优化法,configMAX_PRIORITIES
不能大于32。就像通常方法中建议configMAX_PRIORITIES
尽量小,因为更大的值导致更多的内存浪费。FreeRTOSCofig.h文件中的configUSE_PORT_OPTIMISED_TASK_SELECTION
设置为1时使用平台优化法。但注意不是所有的平台都支持这种方法。
FreeRTOS会始终保持高优先级任务处于执行状态。多个同等优先级任务可以允许的,调度器会轮流执行这些任务。
时间度量和Tick中断
3.12章节调度器数据结构,描述了时间片的概念。目前所有的例子都用了时间片,这是从输出观察的结果。在例子中所有的任务都有优先级,都需要运行。因此每个任务运行一个时间片,时间片开始时进入运行状态,时间片结束退出运行状态。上面的T1,T2之间的时间就是一个时间片。
为了选出下一个要运行的任务,每个时间片结束时必须执行调度器。tick中断是一个周期性中断,就是用来启动调度器。tick中断频率就是用来设置每个时间片的长度。它被定义在FreeRTOSConfig.h中的configTICK_RATE_HZ
宏上。比如,configTICK_RATE_HZ
设置为100Hz哪个每个时间片就是10ms。两个tick中断间隔叫tick周期,它等于时间片长度。
上面的Task1,Task2的运行时序,同样演示了调度器的运行规矩。下面就展示了带调度器的运行时序图。从一般任务到tick中断和从tick中断到一般任务就完成了一个调度器的调用。
# tick中断的执行顺序
Kernel(tick) | -| -| -| -| -|
Task1 |--- | |--- | |--- |
Task2 | |--- | |--- | |
最佳的configTICK_RATE_HZ
值依赖于程序本身,100只是一个常用值。
FreeRTOS中常用各种时钟周期来指代时间,它们经常简单的用’tick’来表示。pdMS_TO_TICKS
宏就是将ms时间转换为具体的tick。它的分辨率依赖于具体的tick频率,pdMS_TO_TICKS
在tick频率超过1000Hz的时候不能使用(如果configTICK_TATE_HZ
大于1000)。下面段落描述了pdMS_TO_TICKS()
如何将200ms转换为一个tick数值。
/* pdMS_TO_TICKS()接受一个以毫秒为单位的时间值,将它转化为tick的周期数。下面例子中xTimeTicks被设置为tick周期数,这些周期数刚好花费200ms*/
TickType_t xTimeTicks = pdMS_TO_TICKS(200);
注意: 不推荐在程序中直接用tick数指定时间,而是使用pdMS_TO_TICKS
宏方式的ms指定时间,这样做可以确保即使tick周期改变,程序指定的时间也不会改变。
tick计数是从调度器运行以来总共发生的tick中断的总数,假设tick数量没有溢出。用户程序指定延迟周期数时不用考虑溢出问题,因为FreeRTOS会自动给我们管理好。
优先级使用经验
调度器总是保证优先级高的任务处于运行态。在我们接下来的例子中,用同样的优先级创建2个任务,因此它们轮流运行。这个例子是为了观察,当2个任务中1个的优先级改变会发生什么。这次第1个任务优先级设置为1,第2个任务优先级设置为2。创建的代码就在下面的一段。单独实现任务的任务函数没有改变。它仍然只是打印任务名称,用一个空循环进行延迟。
//创建两个不同优先级任务
/* 定义2个要传递给任务函数的字符串。它们定义为静态常量,以至于可以在任务函数中访问*/
static const *pcTextForTask1 = "Task1 is running\r\n";
static const *pcTextForTask2 = "Task2 is running\r\n";
int main(void){
/* 创建一个优先级为1的任务*/
xTaskCreate(vTaskFunction, "Task1", 1000, (void *)pcTextForTask1, 1, NULL);
/* 创建一个优先级为2的任务*/
xTaskCreate(vTaskFunction, "Task2", 1000, (void *)pcTextForTask2, 2, NULL);
/* 开启调度器 */
vTaskStartScheduler();
/* 不应该运行到这里 */
return 0;
}
调度器会一直选择高优先级任务运行。任务2的优先级高于任务1,因此任务2会一直处于运行状态。因为任务1一直没有进入运行态,它不会打印字符串。任务1相对于任务2就处于"starved"–饥饿状态。
# 两个不同优先级任务运行结果
Task2 is running
Task2 is running
Task2 is running
Task2 is running
Task2 is running
Task2 is running
Task2 is running
任务2能够一直运行,因为它没有等待任何事–也没有进入一个空循环,或向终端打印。
# 不同优先级任务运行时序
FreeRTOS(Tick) | -| -| -| -|
Task1 | | | | |
Task2 |--- |--- |--- |--- |
t1 t2 t3 t4 t5
扩展非运行状态
目前为止,所有的任务都有事情做没有等待什么东西–它们不用等待一些东西。总是可以进入运行状态。这种连续处理任务作用有限。因为它们只能在最低优先级下运行。因为如果它们运行在其他优先级,它们会完全阻碍更低优先级任务运行。
为了让任务模型有用,必须用事件驱动方式重写。一个事件驱动的任务,只有在时间发生后才会执行,事件发生前是不能执行的。调度器选择最高优先级任务执行。高优先级任务不能执行,就是调度器不会选择执行它们,而是选择一个低优先级可以执行的任务。因此,事件驱动模型意味着,任务可以以不同优先级创建,而且高优先级任务不会使低优先级任务处于"饥饿"状态。
阻塞状态blocked
任务在等待事件发生就叫做阻塞态(blocked),它是非运行态的子状态。
任务可以在阻塞态等待两种事件:
- 时间关键事件–事件可以是延迟周期期满,也可以是一个绝对时间的到来。比如等待10ms
- 同步事件–事件源自其他任务或中断。比如一个任务可能在等待一个队列接受到数据。同步事件包含很多种事件类型。
FreeRTOS队列,二进制信号量,普通信号量,互斥锁,继承互斥锁,事件组和任务通知都可以用来创建同步事件。所有的功能这本书后面都有介绍。
对于一个任务,完全可能阻塞在一个同步事件上,而且没有超时时间。或者同时阻塞在各种同步事件上。比如一个任务可以选择等待10ms或等待队列接收到数据。任务即会在10ms内接收到数据也会在10ms到时间停止阻塞。
暂停状态
暂停状态是非运行态的子状态。调度器不会执行暂停状态的任务。唯一将任务设置为暂停状态的方法是调用vTaskSuspend()接口函数,退出暂停的方法是调用vTaskResume()或vTaskResumeISR()接口函数。大部分程序不会使用暂停状态。
就绪状态
程序没有在运行也没有在阻塞,同时没有在暂停状态就被叫做就绪状态。它们可以运行,因此是准备运行,但当前没有在运行。
状态转换
下图扩展了前面的运行和非运行状态图,包含了所有非运行状态的子状态图。以前创建的任务没有使用阻塞和暂停态。它们只是在就绪态和运行态之间切换。
----------------------
|非运行 |
| >暂停态< |
| / ^ | \ |
| / | 恢复 \ |
| / 暂停 | \| vTaskSuspend
| / | v |\----
| / 就绪态-----|-----> 运行态
| | ooooo <----|------ ooooooo
| \ ^ |/-----
| vTask | /| Blocking API
| Suspend 事件 / |
| \ | / |
| \ | / |
| 阻塞态< |
| |
----------------------
使用阻塞状态创建一个延迟
之前所有创建的任务都是周期性的——他们都周期性延迟并打印自己任务名称。然后再次延迟,如此循环。以上的任务都是使用null循环做粗糙的延迟——任务持续增加一个值,直到这个值到一个固定值。例3抛弃了这种拙劣的做法。高优先级任务在执行空循环的时候依然保持执行状态,会让比他低优先级的任务一直处于"饥饿"状态。
有很多拙劣的轮训方式,它们都很低效。任务不会通过轮训做什么具体任务,但它依然占用处理器时间,并且浪费处理器时钟。例4用vTaskDelay()接口函数代替了空循环这种低效轮训行为。它的原型就在下面展示。新的任务定义也在下面。注意只有INCLUDE_vTaskDelay
在FreeRTOS.h文件中定义为1时可以使用。
vTaskDelay()将调用它的任务状态改为阻塞状态,一段固定数量的tick中断后恢复。阻塞状态中任务不会使用处理器时间,因此任务只有在真正工作时才占用处理器。
// 原型
void vTaskDelay(TickType_t xTicksToDelay);
// 参数: xTicksToDelay 在任务返回就绪状态前将会保持阻塞状态的tick中断数。
// 例: 当前tick计数是10000,一个任务调用了vTaskDelay(100),这个任务会进入阻塞态,保持阻塞状态直到tick计数到10100。
// pdMS_TO_TICKS可以用来和vTaskDelay共同使用,用于阻塞固定的时间。例:调用vTaskDelay(pdMS_TO_TICKS(100))将会保持阻塞状态100ms。
// 用vTaskDelay()代替空循环延迟函数
void vTaskFunction(void *pvParameters){
char *pcTaskName;
const TickType_t xDelay250ms = pdMS_TO_TICKS(250);
/* 通过这个变量传给字符串,注意强制转换为char * */
pcTaskName = (char *)pvParameters;
/* 同大多数任务一样的内联循环 */
for(;;){
/* 打印任务名字 */
vPrintString(pcTaskName);
/* 延迟一个周期,这次调用vTaskDelay(),它将当前任务状态改为阻塞状态,直到延迟周期到达。它的参数是以tick数为单位的,所以使用pdMS_TO_TICKS宏实现和时间时间的转换,本例中延迟250ms */
vTaskDelay(xDelay250ms);
}
}
下面是运行结果。尽管用不同优先级创建的任务,2个任务都有运行。
Task2 is running
Task1 is running
Task2 is running
Task1 is running
Task2 is running
Task1 is running
Task2 is running
Task1 is running
Task2 is running
Task1 is running
下图是执行时序图,显示了为什么两个任务都运行,尽管它们优先级不同。为了简单起见,我们省略了调度器执行。
Task1 | - | | | | | | - | |
Task2 |- | | | | | |- | |
Idle | --|----|----|----|----|----| --|----|
t1 t2 t3 t4 time tn
只是改变了任务的实现,而没有改变任务的功能。对比用空循环和vTaskDelay延迟的代码可以得到结论,这个功能以更有效的方式实现。
用null循环延迟时运行模式是任务一直都在运行。它用掉了100个处理器时钟片。本例的运行模式是,当任务进入阻塞态,进入延迟周期后。只有有事情处理的任务才会使用处理器时钟(本例中就是打印任务名字)。因此导致只会使用很少量的处理器资源。
这里假设,每次任务离开阻塞状态只会使用一小部分tick周期,之后就再次进入阻塞态。大部分时间没有任务在运行(没有任务处于就绪状态),因此没有任务是运行状态。当这种事情发生时,空闲任务就会运行。空闲任务分配的处理器时间是判断系统备用能力的重要指标。使用一个事件驱动的实时系统,可以通过程序实现事件驱动来增加备用处理器能力。
下图展示了任务状态转换关系:
start -> Ready -> Running --Blocking API-> Blocked --Event-> Ready -> Running ....
vTaskDelayUntil()接口
vTaskDelayUntil和vTaskDelay相似。就像展示的一样,vTaskDelay的参数指定了2次阻塞状态之间应该发生的tick中断数。也就是说阻塞状态保持时间就是vTaskDelay()的参数。但任务离开阻塞状态的时间和vTaskDelay()调用时间有关。
vTaskDelayUntil特点。这个外部tick数是任务从阻塞态变为就绪态的tick数。vTaskDelayUntil应用于运行周期固定的任务(你一样你的任务固定频率运行)。特定时间绝对无阻塞调用任务。而不是和什么时候调用vTaskDelay()相关。
// 原型
void vTaskDelayUntil( TickType_t * pxPreviousWakeTime, TickType_t xTimeIncrement );
// 参数
pxPreviousWakeTime: 这里假定任务用vTaskDelayUntil实现固定频率周期性调用任务的基础上,命名的这个参数。这样的前提下,pxPreviousWakeTime保存最后离开阻塞态的时间(醒来时间)。这个时间用来作为应用指针,方便计算出任务下一次离开阻塞状态的时间。
xTimeIncrement:这个参数也是在vTaskDelayUntil()用于实现固定频率的周期任务调用前提下的,任务频率就是使用这个参数设置。xTimeIncrement的单位和vTaskDelay一样也是以tick为单位,可以使用pdMS_TO_TICKS将时钟时间换算成tick数量,实现特定的频率。
vTaskDelayUntil使用
例4中的两个任务是周期性的。但用vTaskDelay()不能保证频率是固定的,因为任务离开阻塞状态的时间取决于调用vTaskDelay()函数。如果用vTaskDelayUntil()代替vTaskDelay()就可以解决这个问题。
// vTaskDelayUntil()实现任务函数实例
void vTaskFunction(void *pvParameters){
char *pcTaskName;
TickType_t xLastWakeTime;
/* 待打印字符串捅咕这个参数传递,注意强制转换为char * */
pcTaskName = (char *)pvParameters;
/* xLastWakeTime需要使用当前tick数初始化。注意这是唯一一次这个变量明确写入的地方。从这以后xLastWakeTime都是vTaskDelayUntil()函数自动管理*/
xLastWakeTime = xTaskGetTickCount();
/* 和其他任务一样的内联循环 */
for(;;){
/* 打印任务名称 */
vPrintString(pcTaskName);
/* 这个任务每250ms执行一次,和之前的vTaskDelay()一样,需要使用pdMS_TO_TICKS(250)将时间转换成相应的tick数。xLastWakeTime是vTaskDelayUntil()函数自动更新的,而不是由任务明确更新*/
vTaskDelayUntil(&xLastWakeTime, pdMS_TO_TICKS(250));
}
}
这里的输出和例4的输出是一样的。
合并阻塞和非阻塞任务
之前的例子分开测试了轮训和阻塞任务。这个例子通过合并两种模式,再次探讨系统状态和任务执行时序。如下:
- 两个任务优先级都是1。都只打印任务名称。它们都不会调用可以进入阻塞状态的接口函数,所以它们不是在运行就是在就绪态。这种类型任务叫做连续处理任务,因为它们总是有事情需要处理。连续处理任务的源代码如下。
- 第3个任务以优先级2创建,比它低级的任务就有两个。第3个任务也只打印任务名字。但它是周期性的,因此使用vTaskDelayUntil()接口函数来间隔两次退出阻塞态的时间。
// 连续处理任务
void vContinueousProcessingTask(void *pvParameters){
char *pcTaskName;
/* 打印字符串通过这个变量传递,注意强制转换为char * */
pcTaskName = (char *)pvParameters;
/* 和其他任务一样的内联循环 */
for(;;){
/* 打印任务名称 */
vPrintString(pcTaskName);
/* 没有延迟函数,也不进入阻塞状态*/
}
}
// 周期性任务
void vPeriodicTask(void *pvParameters){
TickType_t xLastWakeTime;
const TickType_t xDelay3ms = pdMS_TO_TICKS(3);
/* xLastWakeTime需要使用当前tick数初始化,后面vTaskDelayUntil()函数会初始化它*/
xLastWakeTime = xTaskGetTickCount();
/* 和其他任务一样的内联循环 */
for(;;){
/* 打印 */
vPrintString("Periodic task is running\r\n");
/* 任务每3ms执行一次,ms和tick转化看常量定义*/
vTaskDelayUntil(&xLastWakeTime, xDelay3ms);
}
}
下面是执行结果:
continuous task 1 running
continuous task 1 running
Periodic task is running
continuous task 1 running
continuous task 1 running
continuous task 1 running
continuous task 2 running
continuous task 2 running
continuous task 2 running
continuous task 2 running
continuous task 2 running
continuous task 1 running
continuous task 1 running
continuous task 1 running
Periodic task is running
continuous task 2 running
continuous task 2 running
continuous task 2 running
continuous task 1 running
continuous task 1 running
continuous task 1 running
时序图:
Periodic | | | | |- | |
Task1 |----| |----| | ---| |
Task2 | |----| |----| |----|
idle | | | | | | |
t1 t2 t3 time t5 t6 t7
空闲任务和空闲任务勾子
例4的任务中很大一部分时间是在阻塞状态。当任务在阻塞状态,不能运行,不能被调度器选中。
最少要有一个任务处于运行状态。为了满足这一点,调度器会自动创建一个任务,在调用vTaskStartScheduler()的时候。空闲任务比一个循环都小,就像开始的初始化例子一样,它总是可以运行的。
空闲任务优先级最低——0,确保不会阻碍更高优先级任务进入运行状态——虽然没有什么可以阻碍设计人员。因此空闲任务优先级也是可以共享的。FreeRTOSConfig.h文件中的configIDLE_SHOULD_YIELD
常量可以用于阻止空闲任务抢占其他任务需要的处理器宝贵时间。3.12调度器数据结构会描述configIDLE_SHOULD_YIELD
。
空闲任务以最低优先级运行,确保高优先级任务尽快进入运行态。这可以从例4时序图看出来,空闲任务立即切出,允许Task2执行切入处理器。Task2这种叫先占空闲任务,先占是自动发生的,任务也不知道会被先占。
注意:如果一个任务使用vTaskDelete()函数,空闲任务不会使其他任务饥饿。这是因为空闲任务的职责只是在删除任务后清理内核资源。
空闲任务勾子
可以通过空闲任务勾子给空闲任务增加特定函数(空闲回调)——每个空闲任务周期自动调用的函数。
空闲任务勾子常用情形:
- 执行低优先级,背景的,连续处理函数。
- 度量程序备用能力空间(空闲任务只有在其他高优先级任务无事做时才运行,因此可以用于测量程序备份空间,以判断系统能力)
- 处理器进省电模式,提供一个简单自动的省电方式,没有程序执行就进省电模式。尽管省的电量比第10章(低电量支持)的更少tick空闲模式低。
空闲任务勾子限制
空闲任务勾子函数必须遵守以下规定:
- 不试图阻塞或暂停。阻塞空闲任务可能导致没有任务可以进入运行状态。
- 如果任务调用vTaskDelete函数,空闲勾子函数必须返回给调用者一个可用的时间周期。这是因为空闲任务的职责就是删除任务后清理系统资源。如果空闲任务永久处于空闲任务勾子函数中,清理动作就不会发生。
以下是空闲任务勾子函数原型:
// 空闲任务勾子函数原型
void vApplicationIdleHook( void );
定义一个空闲任务勾子函数
例4中使用的vTaskDelay()阻塞函数生成了很多空闲任务,因为有两个任务都处于阻塞状态。例7通过增加空闲任务勾子函数来使用这些空闲时间,源码如下:
// 一个简单的空闲任务勾子函数
/* 一个会被空闲任务勾子函数增加的变量 */
ul uint32_t ulIdelCycleCount = 0UL;
/* 空闲任务勾子函数函数名必须是vApplicationIdleHook(),没有参数,返回void */
void vApplicationIdleHook(){
/* 只增加循环次数 */
ulIdelCycleCount++;
}
FreeRTOSConfig.h中的空闲任务勾子函数调用configUSE_IDLE_HOOK
需要设置为1。
任务实现函数变更为打印任务名称和空闲函数调用次数。
void vTaskFunction(void *pvParameters){
char *pcTaskName;
const TickType_t xDelay250ms = pdMS_TO_TICKS(250);
/* 打印字符串 通过这个变量传递,注意强制类型转换char * */
pcTaskName = (char *)pvParameters;
/* 如常内联*/
for(;;){
/* 打印任务名字和循环次数 */
vPrintStringAndNumber(pcTaskName, ulIdelCycleCount);
/* 延迟250ms */
vTaskDelay(xDelay250ms);
}
}
输出如下:。显示每个任务循环之间的时间,空闲任务勾子函数被调用了400多万次。空闲任务勾子函数调用次数取决于硬件速度。
# 空闲任务勾子函数实例输出
Task2 is running
ulIdleCycleCount = 0
Task1 is running
ulIdleCycleCount = 0
Task2 is running
ulIdleCycleCount = 3869504
Task1 is running
ulIdleCycleCount = 3869504
Task2 is running
ulIdleCycleCount = 8564623
Task1 is running
ulIdleCycleCount = 8564623
Task2 is running
ulIdleCycleCount = 13181489
Task1 is running
ulIdleCycleCount = 13181489
Task2 is running
ulIdleCycleCount = 17838406
Task1 is running
ulIdleCycleCount = 17838406
改变已经完成初始化的任务优先级
vTaskPrioritySet()接口函数
vTaskPrioritySet()接口函数可以用来在调度器启动后改变已有任务的优先级。值得注意的是vTaskPrioritySet()接口函数只有在FreeRTOSConfig.h中的INCLUDE_vTaskPrioritySet
设置为1时才可以使用。
// vTaskPrioritySet()原型和参数
void vTaskPrioritySet( TaskHandle_t pxTask, UBaseType_t uxNewPriority );
// 参数:
// pxTask: 要改变优先级的任务句柄——可以查看前面章节的xTaskCreate()接口函数的pxCreatedTask参数说明了解更多相关信息。
// uxNewPriority: 任务要将被设置成的优先级。会自动检查是否小于configMAX_PRIORITIES,最大就是configMAX_PRIORITIES-1。configMAX_PRIORITIES是FreeRTOSConfig.h中的一个配置选项。
uxTaskPriorityGet()接口函数
uxTaskPriorityGet()接口函数可以用来获取任务优先级。注意只有在FreeRTOSConfig.h中的INCLUDE_uxTaskPriorityGet
被设置为1时,uxTaskPriorityGet()接口函数才能使用。
// uxTaskPriorityGet原型
UBaseType_t uxTaskPriorityGet( TaskHandle_t pxTask );
// 参数 - pxTask:结合vTaskPrioritySet()接口函数看,就一目了然了。就是它的第一个参数。—可以查看前面章节的xTaskCeate()接口函数的pxCreatedTask参数说明了解更多相关信息。可以通过传递NULL参数获取当前任务的优先级。
// 返回值:相关任务优先级
改变任务优先级
调度器总是选择最高优先级任务执行。例8展示了vTaskPrioritySet接口函数的使用,用它改变两个任务的优先级。
例8创建2个不同优先级任务,它们都不会进入阻塞状态,都是处于运行和就绪状态。因此高优先级的任务总是会被调度器选中执行。
例8的说明:
- Task1以一个最高优先级创建,因此会首先执行。他会一直打印出它的任务名称,直到任务2优先级持续增加到可以降低Task1优先级。
- Task2在处于最高优先级的时候停止运行,因为只有一个任务处于运行状态,Task2运行,Task1就处于就绪态。
- Task2打印一个消息,然后降低自己的优先级,并低于Task1优先级。
- Task2降低自身优先级后,Task1再次成为优先级最高的任务,Task1再次进入运行态,强制Task2进入就绪态。
// 例8Task1源码
void vTask1(void *pvParameters){
UBaseType_t uxPriority;
/* 在Task2优先级更高前这个任务会一直运行,Task1和Task2父都不会阻塞,所有都处于运行或就绪态*/
/*获取当前任务优先级*/
uxPriority = uxTaskPriorityGet(NULL);
for(;;){
// 打印
vPrintString("Task1 is running\r\n");
// 增加Task2优先级,直到Task2优先级不小于Task1优先级,Task2开始执行。注意vTaskPrioritySet()中xTask2Handle句柄的使用。创建任务时会初始化Task2任务句柄*/
vPrintString("About to raise the Task 2 priority/r/n");
vTaskPrioritySet(xTask2Handle, (uxPriority + 1));
/* Task1只有在优先级比Task2任务优先级高时才会运行。因此对于这个任务,为了达到这个目标,Task2必须已经执行,并且Task2任务优先级降低且低于Task1任务优先级*/
}
}
//Task2源码
void vTask2(void *pvParamerters){
UBaseType_t uxPriority;
/*Task1会在这个任务之前一直运行,行为Task1以更高的优先级创建。Task1和Task2都不会进入阻塞态,所以不是处于运行就是处于就绪态*/
/*通过给uxTaskPriorityGet()接口函数传递NULL参数获取当前任务优先级*/
uxPriority = uxTaskPriorityGet(NULL);
for(;;){
vPrintString("Task2 is running\r\n");
/*降低当前任务优先级。传递NULL意味着改变当前任务优先级。当当前任务优先级低于Task1优先级时,Task1立即开始运行,就会抢占当前任务*/
vPrintString("About to lower the Task2 priority/r/n");
vTaskPrioritySet(NULL, uxPriority - 2);
}
}
每个任务都不用通过具体的句柄设置和获取任务优先级,只需要简单的传递一个NULL参数给函数。相反的如果改变或获取的是其他的任务就需要传递相应句柄参数了,比如这里Task1改变Task2优先级。Task1就必须要使用Task2的句柄参数,而Task2的句柄就必须在创建时保存起来,下面的源码就展示了如何做到创建任务时保存相关句柄参数。
//例8 main()函数实现
/* 声名一个句柄变量用来保存Task2句柄 */
TaskHandle_t xTask2Handle = NULL;
int main(void){
/* 以优先级2创建第一个任务。任务函数参数这里没有使用传递NULL,任务句柄参数设置为NULL不使用。*/
xTaskCreate(Task1,"Task1",1000,NULL,2,NULL);
/* Task1优先级为2 */
/* 以优先级1创建第二个任务,它的优先级比Task1优先级更低。任务参数也没有使用,设置为NULL。但任务句柄有使用到,因此传递任务句柄地址给xTaskCreate()函数使用,此参数是最后一个参数*/
xTaskCreate(Task2, "Task2", 1000, NULL, 1, &xTask2Handle);
/* 开启调度器 */
vTaskStartScheduler();
/* 如果一切顺利,main()函数就不会运行到这里,因为调度器现在执行任务。如果main()函数运行到这里可能是因为没有足够的堆给idle任务使用。更加详细的堆管理可以查看第二章查看详情*/
for(;;);
}
下面就是例8的运行时序图:
------------------- --------------------------
|Task1优先级最高先| |Task1优先级任务比Task2任 |
|运行 | ----------|务优先级高时再次运行, |
------------------- / |Task2优先级增加比Task1高 |
\ / |时Task2又运行,如此往复 |
\ / --------------------------
\ /
v v
Task1 |- - -| --------------------------
Task2 | - - | |空闲任务从未运行,因为两 |
Idle | ^ | |个任务总是在运行且优先级 |
t1 | t2 time |都比空闲任务优先级高 |
| --------------------------
|
--------------------
|Task2每运行1次, |
|Task1设置Task2优先 |
|级为最高 |
--------------------
下面是运行结果:
About to raise the Task2 priority
Task2 is running
About to lower the Task2 priority
Task1 is running
About to raise the Task2 priority
Task2 is running
About to lower the Task2 priority
Task1 is running
About to raise the Task2 priority
Task2 is running
About to lower the Task2 priority
Task1 is running
About to raise the Task2 priority
Task2 is running
About to lower the Task2 priority
Task1 is running
About to raise the Task2 priority
Task2 is running
About to lower the Task2 priority
Task1 is running
删除任务
这是一个简单的例子,主要做了以下工作:
- main()函数中以1的优先级创建一个任务。当它运行时以优先级2创建任务2。任务2现在是最高优先级任务,因此立即开始执行。后面会给出main()和任务1函数的源码。
- 任务2除了删除自己之外啥都不做。它可以通过传递给vTaskDelete()函数NULL参数而不是自身任务句柄完成删除操作。下面也给出了任务2函数的实现代码。
- 任务2已经被删除后,任务1再次成为最高优先级任务,因此继续开始执行,从它调用vTaskDelay()进入一个短暂的阻塞态哪里。
- 任务1进入阻塞态时空闲任务执行,释放分配给已经删除的任务2的内存。
- 任务1离开阻塞态,它再次成为最高优先级任务,进入就绪态抢占空闲任务。因此进入运行状态,再次创建任务2。如此循环。
// 例9 任务2函数实现
void Task2(void *pvParameters){
/* 任务2除了删除自己什么都不做。为了删除自己需要调用vTaskDelete(),使用参数NULL作为参数,而不需要传递传递自身的句柄*/
vPrintString("Task2 is running and about to delete itself\r\n");
vTaskDelete(NULL);
}
// 例9 任务1函数实现
TaskHandle_t xTask2Handle = NULL;
void Task1(void *pvarameters){
const TickType_t xDelay100ms = pdMS_TO_TICKS(100);
for(;;){
/* 打印任务名字 */
vPrintString("Task1 is running\r\n");
/* 以更高的优先级创建任务2。任务函数参数不使用传NULL,任务句柄要使用,传递xTask2Handle变量的地址作为最后一个参数*/
xTaskCreate(Task2, "Task2", 1000, NULL, 2, &xTask2Handle);
/* 任务2优先级更高,因此对于任务1到这里时,任务2已经执行并删除了自己,延迟100ms*/
vTaskDelay(xDelay100ms);
}
}
// 例9 main()实现
int main(){
/* 以优先级1创建一个任务1,任务参数不用传递NULL,任务句柄也不使用传NULL*/
xTaskCreate(Task1, "Task1", 1000, NULL, 1, NULL);
// 开启调度器
vTaskStartScheduler();
// main()不应该执行到这里,因为调度器应该在执行任务 */
for(;;);
}
下面是执行结果:
Task1 is running
Task2 is running and about to delete itself
Task1 is running
Task2 is running and about to delete itself
Task1 is running
Task2 is running and about to delete itself
Task1 is running
Task2 is running and about to delete itself
Task1 is running
Task2 is running and about to delete itself
Task1 is running
Task2 is running and about to delete itself
Task1 is running
Task2 is running and about to delete itself
Task1 is running
Task2 is running and about to delete itself
下面是时序图:
--------------------------
|任务2只是删除自己,允许 |
|执行任务1 |
--------------------------
Task2 | - | | - |
Task1 |- - | |- - |
Idle | -|------------------------| --|---
t1 t2 time tn
------------------- -------------------------
|任务1运行创建任务 | |任务1调用vTaskDelay(), |
|2,任务2有更高优先 | |允许空闲任务运行直到时 |
|级立即执行 | |间到,然后再次重复 |
------------------- -------------------------
调度器算法
任务状态和事件概述
当前运行的任务处于运行态。单核处理器中一次只能有一个任务处于运行态。
当前没有运行,也不是阻塞和暂停态的任务,就是就绪态。它们可以被调度器选中进入运行态。调度器总是选择优先级最高的任务运行。
任务可以在等待一个事件处于阻塞态,当任务发生时它们会自动被移动到就绪态。暂停任务出现在特定的时间,比如,一个阻塞时间到期,而它通常用来实现周期任务或超时任务。同步任务发生在一个任务或中断服务用任务通知,队列,事件组或各种信号量发送信息。它们都通常用作同步信号,比如接收到一个硬件信息。
设置调度器程序
调度器程序是用来决定选择哪个就绪任务执行的。
之前所有的例子都使用同一个调度器程序,但调度器程序是可以使用FreeRTOS.h文件中的configUSE_PREEMPTION
和configUSE_TIME_SLICING
常量配置的。
第三个配置常量configUSE_TICKLESS_IDLE
也会影响调度器程序,它可以用来关闭tick中断很长一段时间。configUSE_TICKLESS_IDLE
是一个最新提供的特殊选项,可以用电量最小化。第十章低电支持会对此具体介绍(手册中并没有介绍)。本章都假设它设置为0,如果配置文件没有定义,默认值也是0。
FreeRTOS调度器所有可能的配置会确保任务共享一个优先级时会依次执行。依次执行是指循环调度。循环调度算法并不保证同等优先级任务执行时间相等。只有处于就绪态的相同优先级任务会被执行。
优先级任务抢占时间片
将configUSE_PREEMPTION
和configUSE_TIME_SLICING
都设置为1时,FreeRTOS调度器称作带时间片固定优先级抢占任务调度器,它是最多小RTOS程序所使用的调度器程序,之前所有的例子都用这种调度器。表15提供了调度器名字列表。
名字 | 描述 |
---|---|
固定优先级 | 固定优先级调度器不会更改调度器调用的任务优先级,但也不会阻碍自身或其他任务更改优先级 |
抢占 | 对于抢占调度器,一个比执行中任务优先级更高优先级任务进入就绪态,调度算法会立即发生抢占行为。被抢占意味着非自愿切出运行态,不会阻塞和方式运行而是变成就绪态,允许不同任务进入运行态 |
时间片 | 时间片用于相同优先级任务共享处理器时间,即使任务没有主动放弃或者进入阻塞态。调度器会在每个时间片结束时选择一个新任务执行,如果有相同优先级任务处于就绪状态。一个时间片等于两个tick中断之间的时间 |
— | — |
下图展示了一个带时间片固定优先级抢占调度器是如何调度任务的。前一个显示运行时序,其中程序中任务有唯一优先级情况下哪个任务会被选中进入运行态。后一个图展示以下时序,程序中两个任务具有相同优先级情况下哪个任务会被选中执行。
# 不同优先级任务调度器中运行情况
Task1(最高优先级,事件) | - |
Task2(中等优先级,周期) |--- --- -- - |
Task3(最低优先级,事件) | -- - -- --- |
Idle Task(连续) | ----- ---- ----- -|
t1 t2 t3t4 t5t6t7t8 t9 t11 t13
t10t12
说明:
Idle Task:最低优先级运行,因此每当有更高优先级任务就绪就被抢占,比如t3,t5,t9
Task3:事件驱动任务,以相对低优先级运行,但比空闲任务高。它花费大量时间阻塞等待事件发生,好在事件发生时从阻塞态进入就绪态。所有的内部消息(任务通知,队列,信号量,事件组等)都可以用作事件信号解除任务阻塞态。
时间发生在t3,t5还有t9到t12之间。t3,t5时刻发生的事件处理器立刻就进行处理,在这些时刻,任务3是当时可以运行的最高优先级任务。在t9和t12之间发生的事件,处理器等到t12时刻才进行处理,因为t9到t12时间段内任务1和任务2任然在运行。只有在t12时刻以后任务1和任务2才都处于阻塞状态,任务3成为最高优先级任务转为就绪状态。
Task2:一个周期任务,优先级高于Task3低于Task1。周期性意味着在t1,t6,t9执行
t6时刻,任务在运行,但任务2优先级更高,可以对任务3进行抢占,立即进入运行状态。任务2在t7时刻执行完成,再次进入阻塞,任务3再进入运行态完成它的任务,任务3在t8进入阻塞态。
Task1:也是事件驱动任务。有最高优先级,因此可以抢占程序中所有任务。唯一的事件发生在t10时刻。这时任务1抢占任务2。任务2只能在任务1再次进入阻塞态后才能完成它的工作,即在t11时刻。
# 相同优先级任务执行时序,图27
Task1(高,事件) | -- |
Task2(空闲,连续) | --- --- - --- |
Idle Task(连续) |--- --- - --- ---|
t1 t2 t3 t4 t5 t8 t9 t10
t6t7
说明:
空闲任务和任务2:两个都是连续处理任务,都是优先级0——最低优先级。只有在没有更高优先级任务时,调度器才会分配处理器时间给任务,它们才能执行,并且都是0优先级的任务共享处理器时间。每个时间片都在每个tick中断开始,是上图的t1,t2,t3,t4,t5,t8,t9,t10。
空闲任务和任务2轮流执行,导致两个任务以相同时间片执行,就像t5和t8之间发生的那样。
任务1:任务1拥有最高优先级。是事件驱动的,因此花费大量的时间等待事件发生,并处于阻塞态。事件发生后由阻塞变成就绪。
t6时刻发生事件,因此t6时刻成为优先级最高任务,抢占空闲任务时间片。t7运行完成,再次进入阻塞态。
程序展示一个程序中空闲任务和普通任务共享0优先级。空闲任务占用大量处理器时间,而我们创立的使用空闲任务优先级的任务又有任务做,空闲任务就不应大量占用处理器时间。configIDLE_SHOULD_YEILD
就可以用来改变如何调度空闲任务:
- 如果
configIDLE_SHOULD_YIELD
设置为0,空闲任务依然会和0优先级任务抢处理器时间片,除非有更高优先级任务。 - 设置为1。一旦还有任务处于就绪状态即使优先级为0。空闲任务也不会抢处理器时间,而是会礼让其他任务执行。
图27显示的是configIDLE_SHOULD_YIELD
为0的时序图,下面的图28展示的是configIDLE_SHOULD_YIELD
为1的时序结果。
# 相同优先级任务configIDLE_SHOULD_YIELD为1结果,图28
Task1(高,事件) | - |
Task2(空闲,连续) | --- --- --- --- - - --- --- ---|
Idle Task(连续) |- - - - - - - - |
t1 t2 t3 t4 t5 t8 t9 t10 |
t6t7
图28展示了configIDLE_SHOULD_YIELD
为1时的情形,idle任务执行一小会二后就开始交给其他任务执行。而不是一直执行到时间片结束才放弃处理器时间片。
不带时间片优先级调度
不带时间片固定优先级调度器任务选择和抢先算法和前面章节的一样,但不会用时间片来给相同优先级任务分配处理器时间。
configUSE_PREEMPTION
设置为1,configUSE_TIME_SLICING
设置为0就是不带时间片的固定优先级调度器。
就像图27展示那样,如果有多个最高优先级任务处于就绪态,调度器通过tick中断选择一个新的任务进入执行状态(tick中断发生在时间片结束时)。如果不使用时间片,调度器只会在下面2个情况下选择新任务执行:
- 一个更高优先级任务进入就绪态
- 当前运行任务进入阻塞或暂停态
不用时间片相比使用时间片会降低任务上下文切换,因此可以关闭时间片减少任务调度消耗。但关闭时间片会导致相同优先级任务得到的处理时间差别巨大。图29就是一个例子。考虑到这个原因,关闭时间片只作为一个提升选项给有经验的程序员。
#相同优先级任务configUSE_TIME_SLICING为0,图29
Task1(高,事件) | -- -- |
Task2(空闲,连续) | - |
Idle Task(连续) |----------------- ----------|
t1 t2 t3 t4 t5 t8 t11 t12 |
t6t7t9t10
图29假设configUSE_TIME_SLICING
为0的相关说明:
tick中断:tick中断发生在t1,t2,t3,t4,t5,t8,t11,t12。
Task1:任务1是高优先级事件驱动任务,大部分时间都在阻塞等待事件发生。事件发生后从阻塞态变成就绪态,图29显示t6到t7之间在处理事件任务,t9到t10之间也在再次处理事件任务。
Idle和Task2:空闲任务和任务2都是优先级为0的连续任务。不会进入阻塞态。
由于没有时间片,因此在被任务1抢占前一直运行。
图29中空闲任务t1运行,直到t6被任务1抢占。进入运行状态后超过4个tick周期。
任务2在t7时刻才运行,这个时候任务1进入阻塞态等另外一个事件。任务2一直运行直到被任务1抢占在t9时刻。任务2的运行时间不到一个tick周期。
t10时刻空闲任务再次运行,考虑到空闲任务已经运行超过4个周期,而任务2运行不到1个周期远远小于空闲任务执行时间。所以才说不用时间片,相同优先级任务占用处理器时间差异巨大。不建议新手使用。
合作调度器
这本书专注抢先调度器,但FreeRTOS也支持合作调度器。FreeRTOSconfig.h中的configUSE_PREEMPTION
设置为0,configUSE_TIME_SLICING
设置为任意值,就是合作调度器。
当使用合作调度器时,只有在当前任务进入阻塞态或任务主动放弃执行(通过调用taskYEILD())时才进行任务切换。任务永远不会发生抢占,因此时间片也不能使用。
图30展示了合作调度器的行为。
# 合作调度器运行时序,图30
Task1(高) | --- |
Task2(中) | -------- |
Task3(低) |---------- --------|
t1 t2 t3 t4 t5 t6
t2时刻,任务3写了一个队列,任务2解除阻塞态
t3时刻,一个中断写了一个信号量,任务1解除阻塞态
t4时刻,任务3调用taskYEILD(),允许任务1运行
t5时刻,任务1进入阻塞态,任务2进入运行态
以下是图30说明:
任务1:任务1有最高优先级。刚好开始是阻塞的,等待一个信号量。t3时刻一个中断给了这个信号量,导致任务1离开阻塞态,进入就绪态。
在t3时刻任务1是最高优先级任务,如果是抢占调度器就应该会选中任务1进运行态。但合作调度器中任务1不会被选中进入运行态,只有在任务3调用taskYEILD()放弃CPU后才能被调用,即t4时刻。
任务2:任务2优先级介于任务1和任务3之间。开始阻塞态,等待一个队列消息。这个消息由任务3在t2时刻发送。
在t2时刻任务2是最高优先级的就绪任务,如果是在抢占调度器,就会被选中执行。但在合作调度器中任务2只能继续处于就绪态,直到运行的任务进入阻塞态调用taskYEILD()。
运行的任务在t4时刻调用taskYEILD(),但任务1是处于就绪态的最高优先级任务,所以不会立即进入运行态,直到任务1再次在t5时刻进入阻塞态。
t6时刻任务2再次进入阻塞态等待一个队列消息,任务3再次成为最高优先级任务进入运行态。
在多任务程序中,程序员必须注意资源可以被多任务同时访问。同时访问可能会破坏资源。考虑如下例子,一个串口在访问资源。两个任务在向这个串口写字符串;任务1写"absdefghijklmnop",任务2写"123456789";
- 任务1在运行状态开始写它的字符串。它写了"abcdefg"到串口,但在写完所有数据前离开运行态。
- 任务2进入运行态,向串口写了"123456789"后离开运行态。
- 任务1再次进入运行态继续向串口写剩下的数据。
在上面的情况下,最终串口会收到的数据是"abcdefg123456789hijklmnop"。任务1没有按照期望的顺序写入正确的字符串。所以它是错误的,因为任务2的写入行为打断了任务1的写入行为。
有一个简单的方法避免因为同时访问资源引起的错误,就是用合作调度器而不是抢占调度器:
- 使用抢占调度器的运行中的任务,可以随时被其他任务抢占,即使它和其他任务共享的资源处于一个不合理的状态时也会被抢占。就像上面串口例子一样,在一个不合理时间离开资源会引起数据错误。
- 在合作调度模式下,程序写控制时可以切换到另外的程序。程序写数据时如果数据处于矛盾状态,程序可以确保不切换到另外的任务。
- 在上面的串口例子中,程序写数据时,可以确保任务1在写入完整数据之前不离开运行态。保证了数据不会被其他使用串口资源的任务损坏。
就像图30展示那样,使用合作调度模式没有抢占模式反应灵敏:
- 抢先模式会立即运行最高优先级任务。这是实时系统基础,它需要一个高优先级事件周期运行,FreeRTOS中是tick中断。
- 合作模式中高优先级任务只会切换为就绪态,不会立即运行,只有在当前任务阻塞或调用taskYEILD()才能运行。但它是解决同时访问资源矛盾的主要手段。