这一章节的概念是理解如何使用 FreeRTOS 和 FreeRTOS 应用行为的基础。
3.1 Task Function
// Task 函数原型
void ATaskFunction(void* pvParameters);
// 例子
void ATaskFunction(void* pvParameters) {
int32_t lVariableExample = 0;
for (;;) {};
// NULL 表示的是销毁的任务应该是调用销毁函数的那个任务
vTaskDelete(NULL);
}
每个任务都是一个小程序。任务函数有一个入口,并且通常会在死循环中执行,不会退出循环。任务禁止在他们的实现函数中以任何方式 return,不允许包含任何 return 语句,并且不允许执行到函数的结尾。如果一个任务不再被需要,他应该被显示的销毁。一个任务的定义可以用来创建任意数量的任务实例,每个实例都有其栈和任务函数中定义的变量的拷贝副本。
3.2 顶级任务状态(Top Level Task States)
一个应用可以由多个任务组成。单核上执行应用,则任意时刻最多只有一个任务在运行。因此一个任务可以看作两种状态:运行态或未运行。这是一个过度简化的模型。其实未运行态还有很多子状态。
- 运行态:处理器执行相关 task 的代码
- 非运行态:任务处于休眠,他的一些状态信息应该保存好,以便下次调度器决定让其进入运行态时可以恢复执行相关代码
- 恢复执行:当一个任务恢复执行时,他应该从上次离开运行态之前将要执行的指令开始执行。
一个任务从非运行态切换到运行态叫做换入,相反叫做换出。在整个系统中只有 FreeRTOS 调度器可以切换任务进出。
3.3 创建任务
xTaskCreate API
FreeRTOS v9.0.0 也包含了 xTaskCreateStatic 函数,可以在编译阶段静态的创建一个任务,并为其分配所需内存。
差不多是所有 API 中最复杂的,上来就遇到不是很幸运,但是是必须的。因为创建任务是多任务系统的最基础的组件。
BaseType_t xTaskCreate( TaskFunction_t pvTaskCode,
const char* const pcName,
uint16_t usStackDepth,
void* pvParameters,
UBaseType_t uxPriority,
TaskHandle_t* pxCreatedTask );
- pvTaskCode:是简单的永远不退出的 C 函数。因此常用一个无限循环实现。 pvTaskCode 是一个指向任务实现函数的指针。(实际上函数指针,typedef void (* TaskFunction_t)( void * ) )
- pcName:任务的描述性名称。FreeRTOS 不会使用,纯粹是为了 debug 方便。以人可阅读的方式标识一个任务。通过常量 configMAX_tASK_NAME_LEN 定义最大长度,超过则自动截断。长度 = 字符个数 + 终止符。
- usStackDepth:当任务创建的时候,每个任务都会有一个由内核分配的栈。该参数告诉内核创建多大的栈。单位是字长。例如,栈是 32 位宽的,那么传入 100,将会分配 400 字节。栈深度乘以栈宽度不能超过 uint16_t 可以表示的最大值。在 demo 应用中,根据使用的处理器架构为常量所赋的值是推荐给任何任务的最小值。如果任务需要很多栈空间,那么值一定要比这个值大。没有很好的方法确定一个任务需要的栈空间。可以尝试去计算,但是大多数情况下是赋一个差不多合理的值,然后通过 FreeRTOS 提供的特性确保栈空间充足而且不会造成不必要的浪费。Stack Overflow 包含了如何查询任务使用过的最大栈空间
- pvParameters:void* 类型的指针,指向了要传给任务的参数。
- uxPriority:定义了任务以什么样的优先级执行。优先级范围 [0, configMAX_PRIORITIES - 1]。传入大于最大值的值,会默认赋予任务最大的合法优先级。
- pxCreatedTask:传递一个正在创建的任务的 handle,这个 handle 可以在其他 API 调用中引用这个任务,例如 改变任务优先级,销毁任务等。如果不使用则传入 NULL。
- Returned value:pdPASS、pdFAIL。前者说明任务成功创建,后者说明没有足够的 heap 空间可用,来分配给任务的数据结构和任务。
xTaskCreate 函数流程:
- 如果栈增长方向,地址减小,那么先为任务分配栈,后分配 TCB(Task Control Block),否则先 TCB 后分配栈。(保证栈增长不会占用 TCB,那会不会越界到其他内核对象或应用的地址里呢?)下面 m1, m2 代指分配顺序第一个和第二个。为 Stack 分配的字节数为 depth * sizeof(StackType_t) 即深度 * 栈的位宽
- 分配 m1,成功则下一步,否则释放 m1 后结束分配
- 分配 m2,成功则用 TCB->Stack 指向 Stack 的地址,否则释放 m1 后结束分配
- 分配成功则进行下一步,否则返回指定的错误代码,函数结束
- 如果支持动态分配, 把 TCB 标记为动态创建的,防止之后被删除(【暂时不太理解为什么会被删除】源代码是这样备注的)。
- 根据函数指针、函数名字、栈深度、任务参数、优先级、handle、TCB 起始地址调用初始化新任务的函数
- 把 TCB 加入到 ReadList
- 返回 pdPASS
初始化新的任务:
- 任务是否应以私有模式创建?(【暂时不理解私有模式创建任务】)
- 栈是否需要设置为已知的值(以帮助 debug。此外应尽量减少对 memset 的依赖【为什么需要减少未知】)
- 根据栈增长方向,取到栈顶,然后内存对齐。(TCB->pxEndOfStack 记录栈的高地址,而不是记录栈空间用完后的位置)
- 按 UBaseType_t 类型拷贝任务名字(遇到 0x00 就意味着肯定拷贝完了,就停止)
- 检查优先级是否合法(大于等于最大优先级,则置为最大优先级 - 1),并赋给 TCB
- 初始化列表项(State 列表项、Event 列表项,确保列表项不属于任何列表,并设置两个检查值,防止列表项被复写造成数据污染)
- 设置 State\Event 列表项所属的 TCB
- 设置 Event 列表项的 item value 值为 最高优先级减去其优先级(代码中这里注释,事件总是按优先级排序,我猜测这里就是从小到大排序,值越小表明优先级越高)
- 然后就是一些杂七杂八的配置(【MPU wrapper】这里重点是根据 MPU 及其是否有 wrapper 的工作比较重要,需要 CPU 一些知识)
加入创建新任务到 ReadList:
- 使用 taskENTER_CRITICAL 进入操作任务队列的临界区,确保中断不会访问正在更新的列表
- 如果 pxCurremtTCB 当前没有指向一个任务(如果是第一个任务,则初始化一下任务列表,当前任务指向 创建的新任务。【当前任务指调度器开始运行应该运行的任务】)
- 否则如果 pxCurremtTCB 已经有指向,检查调度器如果还未开始运行(pxCurremtTCB 指向优先级较大的任务)
- 任务数量 ++
- 把 TCB 加入到就绪任务列表(其实是按优先级的指针数组),并设置其状态为就绪
- 退出临界区使用 taskEXIT_CRITICAL
- 如果调度器还未运行且当前任务优先级小于新创建的,那么执行 taskYIELD_IF_USING_PREEMPTION(当前任务让出,如果使用抢占式调度器)
example1:普通的创建任务
void vTask1 ( void* pvParameters )
{
const char* pcTaskName = "task1 is running\r\n";
volatile uint32_t ul;
for (;;)
{
vPrintString( pcTaskName );
for ( ul = 0; ul < mainDELAY_LOOP_COUNT; ul ++ )
{
}
}
}
void vTask2 ( void* pvParameters )
{
const char* pcTaskName = "task2 is running\r\n";
volatile uint32_t ul;
for (;;)
{
vPrintString( pcTaskName );
for ( ul = 0; ul < mainDELAY_LOOP_COUNT; ul ++ )
{
}
}
}
int main()
{
/*......*/
xTaskCreate( vTask1, // function pointer
"task 1", // pcName
1000, // stack depth
NULL, // task parameters
1, // task priority
NULL ); // task handle
xTaskCreate( vTask2, // function pointer
"task 2", // pcName
1000, // stack depth
NULL, // task parameters
1, // task priority
NULL ); // task handle
vTaskStartScheduler(); // 任务调度器
/*......*/
}
众所周知的一张图,就不详细介绍了,意思就是单核处理器上任务一、二肯定不是同时运行的,而是每个任务占一定的时间,之后就会被换出,换另一个任务执行。
example2:任务中创建任务
void vTask1 ( void* pvParameters )
{
const char* pcTaskName = "task1 is running\r\n";
volatile uint32_t ul;
xTaskCreate( vTask2, // function pointer
"task 2", // pcName
1000, // stack depth
NULL, // task parameters
1, // task priority
NULL ); // task handle
for (;;)
{
vPrintString( pcTaskName );
for ( ul = 0; ul < mainDELAY_LOOP_COUNT; ul ++ )
{
}
}
}
example3:一个任务函数创建多个任务实例
void vTaskFunction( void* pvParameters )
{
char* pcTaskName;
volatile uint32_t ul;
char* pcTaskName = (char*) pvParameters;
for (;;)
{
vPrintString( pcTaskName );
for ( ul = 0; ul < mainDELAY_LOOP_COUNT; ul ++ )
{
}
}
}
int main()
{
/*......*/
xTaskCreate( vTaskFunction, // function pointer
"task 1", // pcName
1000, // stack depth
(void*) "task1 is running\r\n", // task parameters
1, // task priority
NULL ); // task handle
xTaskCreate( vTaskFunction, // function pointer
"task 2", // pcName
1000, // stack depth
(void*) "task2 is running\r\n", // task parameters
1, // task priority
NULL ); // task handle
vTaskStartScheduler(); // 任务调度器
/*......*/
}
3.4 任务优先级
通过 xTaskCreate 的 uxPriority 参数可以为创建的任务赋一个初始优先级,优先级也可以在调度器开始运行之后,通过 vTaskPrioritySet 修改。
FreeRTOSConfig.h 配置 configMAX_PRIORITIES 来设置优先级范围为 [0, configMAX_PRIORITIES - 1],值越小优先级越低。
FreeRTOS 调度器(scheduler)总是会确保被选中进入运行态的任务总是准备好执行的优先级最高的任务。当有多个任务满足条件时(即某时刻优先级最高的任务有多个),那么调度器会按顺序依次换入换出每个任务(【这里说的应该是和上面 example1 所给的图相同的情况】)
调度方法:
- 通用方法:用 C 语言实现, 所有 port 通用。不会吧优先级限制到 configMAX_PRIORITY 设置的可以设置的最大值,但是建议 configMAX_PRIORITY 尽量设的小一点,因为越大,意味着消耗的 RAM 越多(【不是很理解】是不是意味着中断可以产生的嵌套越多?),最坏情况下任务运行时间就会越大。
- 特定架构的优化方法:使用一少部分汇编代码,比通用方法更快。configMAX_PRIORITY 不会影响最坏情况的执行时间,但是不能大于 32。原因同通用方法。不是所有的 port 都提供了优化方法。配置 configUSE_PORT_OPTIMISED_TASK_SELECTION 为 1 使用优化方法。
3.5 时间测量和时钟中断
之后调度算法中会描述一个可选的特性,时间片。效果见 3.3 example1。即任务只会占用一个时间片,在时间片的开始进入运行态,在时间片的结束退出运行态。为了能够选择下一个任务执行,调度器必须在时间片结束时开始执行。一个周期性的中断,即时钟中断(tick interrupt)就是用作此用途。时间片的长度可以通过时钟中断的频率设置,即通过配置 FreeRTOSConfig.h 中的 configTICK_RATE_HZ 常量来设置。两次时钟中断之间叫做时钟周期,即一个时间片的长度。
时
间
片
时
长
=
1
时
钟
频
率
(
单
位
:
H
Z
)
(
单
位
:
秒
)
时间片时长 = \frac{1}{时钟频率(单位:HZ)} (单位:秒)
时间片时长=时钟频率(单位:HZ)1(单位:秒)
时钟频率最佳的值需要依据要开发的应用设定,通常都是 100。
FreeRTOS 总是在几段时钟周期内的特定时间调用一些 API,几段时钟周期也常被引用为 ticks。pdMS_TO_TICKS 宏把一个以毫秒为单位的时间转换为一个以 tick 为单位的时间。这个转换仅在 configTICK_RATE_HZ 被设置,且频率小于等于 1000hz 时可用。
推荐在应用程序内通过宏 pdMS_TO_TICKS 直接以毫秒为单位指定时间而不是 ticks,这样在配置有不同频率的 FreeRTOS,其时间大小不会改变。 tick_count 是从调度器开始运行至现在为止,时钟中断的次数,假设他不会溢出。应用程序指定 delay 时间不需要考虑溢出,因为时间一致性是由 FreeRTOS 内部管理的。
之后在调度算法中会讲解配置常量参数来改变调度器何时会选择新任务来执行,时钟中断何时会执行。
example4:不同优先级的两个任务运行
int main()
{
/*......*/
xTaskCreate( vTaskFunction, // function pointer
"task 1", // pcName
1000, // stack depth
(void*) "task1 is running\r\n", // task parameters
1, // task priority
NULL ); // task handle
xTaskCreate( vTaskFunction, // function pointer
"task 2", // pcName
1000, // stack depth
(void*) "task2 is running\r\n", // task parameters
2, // task priority
NULL ); // task handle
vTaskStartScheduler(); // 任务调度器
/*......*/
}
运行结果:由于 task2 的优先级更高,所以调度器总是选择 task2 进入运行态,这也叫做 task1 处于饥饿状态。
3.6 扩展非运行态(Not Running State)
目前为止创建的任务总是拥有时间片时就立即执行,并且从来不需要等待什么。因为他们没有请求过任何其他资源,他们总是准备好进入运行态(Running State)的。这种任务的作用十分有限,因为他们只能以低优先级的方式被创建。一旦以高优先级创建,就可能会让其他任务陷入饥饿,从而永远也没有机会执行。
为了让任务有用,他们必须被重写为事件驱动的。一个事件驱动的任务仅当触发事件发生,才会(有需要)执行,并且事件不发生永远也不能进入运行态。调度器总是选择优先级最高的就绪任务来执行。当一个高优先级的任务未就绪,意味着调度器不能选择让他进入运行态,相反只能选一个更低优先级的就绪任务进入运行态。因此事件驱动的任务可以以不同的优先级创建,而且高优先级的任务不会让低优先级任务的处理时间处于饥饿(Straving)。
阻塞态(The Block State)
一个任务等待某个事件就是阻塞态。
任务可以进入阻塞态来等待两种事件:
- 时间相关事件:例如,事件是等待一段时间到期,或者一个绝对时间到达
- 同步事件:事件来自于另一个任务或者中断。例如,一个任务进入阻塞态,等待数据到达某个队列。同步事件包含了很广泛的事件类型。
FreeRTOS 队列、01信号量、计数信号量、互斥锁、递归锁、事件组和任务通知都可以被用来创建同步任务。
一个任务可以带着超时时间阻塞在一个同步事件上,即同时阻塞在两种事件上。例如,一个任务等待数据到达队列最多等待 10ms,那 10ms 内数据到达或者超过 10ms 没有数据到达任务都会离开阻塞态。
挂起态(The Suspended State)
挂起态的任务意味着任务对于调度器不可用。唯一进入挂起状态的方式是通过调用 vTaskSuspend,唯一退出挂起态的方式是调用 vTaskResume 或者 xTaskResumeFromISR。大多数应用不使用挂起态。
就绪态(The Ready State)
既不阻塞也不挂起的处于 Not Running 的任务即就绪态。他们可以运行,只是现在不处于运行态。
【存疑】这个图显示,进入挂起态的程序 resume 之后进入就绪态,那么原本挂起的任务也会进入就绪态吗?显然是不合理的,可能是图画的不好,后续读代码或者看文档再来验证吧。
example5:进入阻塞代替空循环实现延迟
任务不断轮询一个计数器知道其达到指定的值。这种空循环实现的延迟,会导致高优先级的任务在执行空循环时保持在运行态(【引申】其实非空循环,但是循环的时候大部分条件判断不通过,也会导致类似空循环一样的效果),使得低优先级的执行时间饥饿。
轮询的缺点不仅仅是低效,在轮询期间什么也做不了,但是一直在使用最大可执行时间,并且浪费处理器周期。本例使用 vTaskDelay 代替了空循环,在 FreeRTOSConfig.h 中配置 INCLUDE_vTaskDelay 为 1 即可用。
void vTaskDelay( TickType_t xTicksToDelay );
// xTicksToDelay 调用此函数的任务需要保持阻塞状态的时钟中断次数
// 阻塞状态结束后会进入就绪态
// vTaskDelay( pdMS_TO_TICKS(times) )
void vTaskFunction( void* pvParameters )
{
char* pcTaskName = (char*) pvParameters;
const TickType_t xDelay250ms = pdMS_TO_TICKS(250);
for (;;)
{
vPrintString( pcTaskName );
vTaskDelay( xDelay250ms );
}
}
int main()
{
/*......*/
xTaskCreate( vTaskFunction, // function pointer
"task 1", // pcName
1000, // stack depth
(void*) "task1 is running\r\n", // task parameters
1, // task priority
NULL ); // task handle
xTaskCreate( vTaskFunction, // function pointer
"task 2", // pcName
1000, // stack depth
(void*) "task2 is running\r\n", // task parameters
2, // task priority
NULL ); // task handle
vTaskStartScheduler(); // 任务调度器
/*......*/
}
运行结果:
运行时序:
当调度器开始运行时,会有一个空闲任务自动被创建,确保总是至少有一个任务可以运行。在上述场景中,任务离开阻塞态后他们在一个时钟周期的一小部分时间内执行,大多数时间都没有应用可以执行(没有就绪态的任务)。此时,空闲任务就会执行。分配给空闲任务的时间,可以衡量系统空闲处理能力。使用 RTOS 系统通过让应用变成完全事件驱动可以显著提高系统的空闲处理能力。
example6:vTaskDelayUntil
vTaskDelayUntil
vTaskDelay 指定的是进入阻塞状态的时钟周期数。
在系统启动的时候有个 tick count 变量,记录发生时钟中断的次数(也就是时钟周期数)。vTaskDelayUntil 的参数需要指定一个 tick count,调用 vTaskDelayUntil 的任务会在指定的 tick count 时从阻塞状态移到就绪态。 适用于需要固定执行周期的场景,因为任务从阻塞态移动到就绪态是一个绝对的时间,和任务何时调用这个函数无关。
void vTaskDelayUntil( TickType_t* pxPreviousWakeTime,
TickType_t xTimeIncrement );
- pxPreviousWakeTime:指向的位置,保存了上一次任务离开阻塞态的 tick count,作为计算下一次应该离开时 tick count 的参考。vTaskDelayUntil 内部会自动更新这个参数,应用程序不需要更新,但是必须初始化。如下面代码中所示。
- xTimeIncrement:意如其名
example5 虽然实现了周期性的执行,但是不能确保固定的频率,因为下一次任务从阻塞态移到就绪态和本次调用 vTaskDelay 的时间相关。而就绪态的任务又不一定会马上执行,可能需要等待高优先级的任务先执行,这样就可能造成延迟。可以使用 vTaskDelayUntil 解决。
void vTaskFunction( void* pvParameters )
{
char* pcTaskName;
TickType_t xLastWakeTime;
pcTaskName = (char*) pvParameters;
xLastWakeTime = xTaskGetTickCount();
for (;;)
{
vPrintString( pcTaskName );
vTaskDelayUntil( &xLastWakeTime, pdMS_TO_TICKS( 250 ) );
}
}
下图展示了区别:
example7:多种任务混合
// 低优先级的连续输出
void vContinuousProcessingTask( void* pvParameters )
{
char* pcTaskName;
pcTaskName = (char*) pvParameters;
for (;;)
{
vPrintString( pcTaskName );
}
}
// 较高优先级的周期输出
void vPeriodicTask( void* pvParameters )
{
TickType_t xLastWakeTime;
const TickType_t xDelay3ms = pdMS_TO_TICKS( 3 );
xLastWakeTime = xTaskGetTickCount();
for (;;)
{
vPrintString( "Periodic task is running\r\n" );
vTaskDelayUntil( &xLastWakeTime, xDelay3ms );
}
}
int main()
{
/* ...... */
xTaskCreate( vContinuousProcessingWork, // function pointer
"task 1", // pcName
1000, // stack depth
(void*) "Task1 is running\r\n", // task parameters
1, // task priority
NULL ); // task handle
xTaskCreate( vContinuousProcessingWork, // function pointer
"task 2", // pcName
1000, // stack depth
(void*) "Task2 is running\r\n", // task parameters
1, // task priority
NULL ); // task handle
xTaskCreate( vPeriodicTask, // function pointer
"task 3", // pcName
1000, // stack depth
NULL, // task parameters
2, // task priority
NULL ); // task handle
vTaskStartScheduler(); // 任务调度器
/* ...... */
}
3.7 空闲任务和空闲任务钩子函数
当调度器启动的时候(vTaskScheduler 被调用)一个空闲任务就被自动创建了,空闲任务就是一个循环,确保任何时候都可以执行,也因此他的优先级为 0(最低)。以确保不会阻止更高优先级的任务进入运行态,如果需要,应用程序设计者也可以在空闲任务优先级上创建任务。
FreeRTOSConfig.h 中的 configIDLE_SHOULD_YIELD 可以被用来阻止空闲任务消耗处理器时间,如果这个处理器时间分配给应用程序任务更有效的话。
空闲任务处于低优先级,确保了只要有高优先级任务处于就绪态,空闲任务就会退出运行态。高优先级任务就被认为是抢占了空闲任务,抢占自动发生,并且被抢占的任务不知道。
空闲任务钩子函数
hook function = callback function
通过使用钩子函数可以将特定应用程序的功能直接加入到空闲任务。空闲任务钩子函数会在空闲任务循环的每次迭代中被调用一次。
常见用途:
- 执行低优先级、环境相关的或者连续处理的工作
- 测量空闲处理能力(当所有高优先级的任务没有工作,空闲任务才会执行;因此,测量处理器分配给空闲任务的时间可以指示有多少时间处理器是空闲的)
- 把处理器置为低功耗模式,无论何时没有应用需要执行的时候就可以节省功耗的简单、自动的方法。(尽管使用这种方法省的电比使用 tick-less 空闲模式省的电要少)
实现空闲任务钩子函数的限制
- 一个空闲任务钩子函数禁止尝试去阻塞或挂起(会造成没有任务可执行的场景)
- 如果应用使用 vTaskDelete 函数,那么空闲任务钩子函数必须在一个合理的时间段内返回。因为空闲任务负责在任务销毁后清理内核资源,如果空闲任务如果一直陷入钩子函数,那么清理就不会执行。
空闲任务钩子函数所需遵守的函数原型:
void vApplicaitionIdleHook( void );
example8:实现一个空闲钩子函数
使用钩子函数:FreeRTOSConfig.h 中的 configUSE_IDLE_HOOK 置为 1。
volatile uint32_t ulIdleCycleCount = 0UL;
void vApplicationIdleHook( void )
{
ulIdleCycleCount ++;
}
void vTaskFunction( void* pvParameters )
{
char* pcTaskName;
const TickType_t xDelay250ms = pdMS_TO_TICKS( 250 );
pcTaskName = (char*) pvParameters;
for (;;)
{
vPrintStringAndNumber( pcTaskName, ulIdleCycleCount );
vTaskDelay( xDelay250ms );
}
}
作者所给的图可以看出,其空闲任务在 250ms 内差不多迭代 4000000(4 millions)次。
3.8 改变任务优先级
vTaskPrioritySet
配置 FreeRTOSConfig.h 中的 INCLUDE_vTaskPrioritySet 为 1,即可使用 vTaskPrioritySet 在调度器启动后修改任务优先级。
void vTaskPrioritySet( TaskHandle_t pxTask, UBaseType_t uxNewPriority );
- pxTash 类型:TaskHandle_t 其实是 TCB* 类型,即任务控制块的指针类型。(【猜测】使用的时候应该就是外面创建一个 TaskHandle_t 类型的变量,创建任务传入其地址,这样创建任务就会自动初始化其句柄,之后就可以使用句柄,包括修改任务优先级)
- uxNewPriority 要修改成的目标优先级。
vTaskPriorityGet
获取任务优先级
UBaseType_t uxTaskPriorityGet( TaskHandle_t pxTask );
example9:改变任务优先级
两个任务不同优先级,每个任务都不会阻塞而是可以一直执行(即无非是就绪态或者运行态)。因此两个任务优先级相对最高的那个会一直被调度器选中进入运行态。
这里查询和设置优先级时,如果是操作自己的任务的优先级,那么 TaskHandle 可以设置为 NULL,当需要操作其他任务是才需要具体的 TaskHandle。
该例描述如下:
- Task1 处于较高优先级,因此在改动 Task2 之前会一直输出一系列字符串。
- Task2 处于较低优先级,单核情况下,只有改变优先级大于 Task1 才会运行。
- 修改 Task2 的优先级再低于 Task1,此时 Task2 又不能被调度器选中进入运行态。
- 所以此时是 Task1 又开始执行。
void vTask1( void* pvParameters )
{
UBaseType_t uxPriority;
uxPriority = uxTaskPriorityGet( NULL );
// 传入 NULL 意思是查询正在调用的任务的优先级
for (;;)
{
vPrintString( "Task1 is running\r\n" );
vPrintString( "About to raise the priority of Task2\r\n" );
vTaskPrioritySet( xTask2Handle, (uxPriority + 1) );
}
}
void vTask2( void* pvParameters )
{
UBaseType_t uxPriority;
uxPriority = uxTaskPriorityGet( NULL );
// 传入 NULL 意思是查询正在调用的任务的优先级
for (;;)
{
vPrintString( "Task2 is running\r\n" );
vPrintString( "About to lower the priority of Task2\r\n" );
vTaskPrioritySet( NULL, (uxPriority - 2) );
}
}
TaskHandle_t xTask2Handle = NULL;
int main()
{
/* ...... */
xTaskCreate( vTask1,
"Task1"
1000,
NULL,
2,
NULL );
xTaskCreate( vTask2,
"Task2"
1000,
NULL,
1,
&xTask2Handle );
vTaskStartScheduler();
/* ...... */
}
3.9 销毁任务
vTaskDelete
配置 FreeRTOSConfig.h 中 INCLUDE_vTaskDelete 来使用 vTaskDelete。
可以销毁自己也可以销毁其他任务。销毁意味着任务不再存在且不能再运行。删除之后释放所分配的空间是空闲任务要做的事。因此使用销毁函数的应用不要使空闲任务的处理时间完全饥饿。(只有内核分配给任务的内存才会被自动释放,在任务中申请分配的内存,必须由显示的释放【我的理解即,自己销毁之前手动释放】)
void vTaskDelete( TaskHandle_t pxTaskToDelete );
// NULL 即删除自己
example10:销毁任务
TaskHandle_t xTask2Handle = NULL;
void vTask2( void* Parameters )
{
vPrintStrng( "Task2 is running and about to delete itself\r\n" );
vTaskDelete( xTask2Handle );
}
void vTask1( void* Parameters )
{
const TickType_t xDelay100ms = pdMS_TO_TICKS( 100 );
for (;;)
{
vPrintString( "Task1 is running\r\n" );
xTaskCreate( vTask2, // function pointer
"Task2", // pcName
1000, // stack depth
NULL, // task parameters
2, // task priority
&xTask2Handle ); // task handle
xTaskDelay( xDelay100ms );
}
}
int main()
{
/* ...... */
xTaskCreate( vTask1, // function pointer
"Task1", // pcName
1000, // stack depth
NULL, // task parameters
1, // task priority
NULL ); // task handle
vTaskStartScheduler();
/* ...... */
}
- 任务1 以优先级 1 创建。
- 任务1 运行进入循环,输出字符串后,创建任务2,任务2 优先级为 2 。
- 任务2 优先级更高,因此抢占了任务1,然后任务2 执行,输出字符串,然后销毁自己(其实可以输入 NULL 直接销毁自己)。
- 任务1 又开始执行,进入阻塞状态。空闲任务开始执行,回收释放销毁任务的资源。然后任务1 就绪又开始新的循环。
3.10 调度算法
任务和事件的回顾
一个任务可以处于运行态、非运行态。非运行态分为就绪态、阻塞态、挂起态。调度器总是会选取就绪态优先级最高的任务执行。
任务等待事件时会进入阻塞态,事件发生时自动移入就绪态。事件分两种,时间相关的通常用来实现周期性或者超时后的行为,例如阻塞时间到期;另一种是同步事件,当一个任务或中断服务程序发出信息通过如任务通知、队列、事件组、信号量等,同步事件通常用于通知异步事件,例如串行接口的数据到达。
配置调度算法
调度算法是软件程序,用来决定将就绪态的哪个任务换入执行态。可以使用 FreeRTOSConfig.h 中的 configUSE_PREEMPTION 和 configUSE_TIME_SLICING 配置调度算法。configUSE_TICKLESS_IDLE 也会影响调度算法,是一个专门提供给必须要最小化功耗的应用的高级选项。会在低功耗支持中详细论述,此处假设其值为 0,即默认未定义。
所有的调度都会确保相同优先级的就绪任务轮流进入运行态(轮流或者说轮询调度)。轮流调度不会确保同优先级的任务得到的时间是均分的,只会确保每个任务都会进入运行态执行。
具有时间片的固定优先抢占调度算法
- configUSE_PREEMPTION 1
- configUSE_TIME_SLICING 1
被大多数小型 RTOS 应用使用的,也是目前为止所展示的代码示例中使用的算法。
- 固定优先级:调度算法不改变任务优先级,但也不阻止他们修改自己和别的任务的优先级
- 抢占式:当一个比正在运行的任务优先级高的任务进入就绪态,调度器会立即把正在运行的任务换出运行态。被抢占意味着不由自主地(没有明显的让步和阻塞)移出运行态。
- 时间片:一个时间片等于一个时钟周期或两次时钟中断的间隔。使用时间片意味着,如果优先级同样较高的任务有多个,那么每个时间片结束,调度器就会选择新的任务进入运行态。
每个任务都优先级不同:
同优先级的任务的执行:
有时候不想分配给空闲任务那么多时间,那么我们可以配置常量来修改:
- configIDLE_SHOULD_YIELD 0:空闲任务不会让步,在他的时间片只给大于他优先级的任务让步
- configIDLE_SHOULD_YIELD 1:如果有同优先级的就绪任务,空闲任务会资源放弃分配给他的时间片。(我们对同优先级的任务实现为固定频率和周期,就可以实现分配时间比例)
优先级抢占调度
选择任务和抢占算法和前一个算法相同,只不过是没有时间片。这意味着优先级同样最高的几个任务不会轮流执行。
- configUSE_PREEMPTION 1
- configUSE_TIME_SLICING 0
这种情况下什么时候会发起调度呢?(【我也不理解】为啥再次没有再次进入就绪态,此处存疑,待我研究一哈代码)
- 出现了高优先级的就绪任务
- 当前任务进入阻塞或者挂起态
不适用时间片,意味着减少了上下文切换次数,减少了处理器开销,然而不适用时间片会导致同等优先级的任务可以得到的处理时间有较大差异(极其不均衡),如下图。
协作式调度
- configUSE_PREEMPTION 0
- configUSE_TIME_SLICING 0/1
上下文切换只会发生在任务进入阻塞态,或者任务通过调用 taskYIELD 显示让步(即手动调用 API 请求重新调度)。任务永远不会被抢占,因此也不需要时间片。
- 任务1 优先级最高,在等待一个信号量,t3 时刻信号量到达,任务1 进入就绪态,由于是协作式,因此不会发生抢占,任务1 直到 t4 空闲任务退让之后,才会执行。
- 任务2 优先级次之,他一直在等待任务3 发送消息,t2 时刻任务3 成功写了消息,任务2 进入就绪态,此时他是优先级最高的就绪任务,但是由于是协作式调度,同样不会抢占。t4 时刻任务1 优先级最高,任务1 先执行,直到 t5 时刻,任务1 进入阻塞态,任务2 才进入运行态。
在一个多任务应用中,应用的作者应该关注不要让多个任务同时访问一个资源,因为同时访问可能会造成数据污染。例如,两个任务都要访问 UART(串口),task1 要写入 ”abcdefg“,task2 要写入 “123456789”:
- task1 进入运行态写入 ”abc",然后由于一些原因离开运行态
- task2 进入运行态写入 “123456789” 然后结束
- task1 进入写入剩余字符
那么我们收到的就是 “abc123456789defg”,显然和我们预期收到的 task1 的完整字符串是相违背的,因为中间 task2 访问了 UART,且此时 task1 还未完成。
相比于抢占式调度,协作式调度避免同时访问临界资源的问题相对简单。
- 抢占式调度时,一个正在运行的任务可以被任意时刻抢占,包括当他和其他任务共享的临界资源处于不一致的状态时(例如上面串口的例子)。
- 当使用协作式调度时,应用作者控制着何时一个任务切换到另一个任务。因此,应用作者可以确保,当临界资源处于不一致状态时不会切换到另一个任务。
上述串口的例子,应用作者可以通过确保 task1 不离开运行态直到所有字符写完。这样做就消除了字符串被其他任务的活动所污染的可能。
相比于抢占式系统,协作式系统的响应速度更慢。
- 当使用一个抢占式调度时,当有优先级更高的就绪任务,调度器会马上运行他。这在实时系统中很关键,即必须在一定时间内响应更高优先级的事件。
- 协作式系统,一个任务的切换只会发生在进入阻塞态或者主动请求退让时。