FreeRTOS实时内核指南——任务管理
FreeRTOS实时内核指南——任务管理
概览
单任务与多任务
- 单任务系统
- 单任务系统的编程方式,即裸机的编程方式,这种编程方式的框架一般都是在 main() 函数中使用一个大循环,在循环中顺序的执行相应的函数以处理相应的事务,这个大循环的部分可以视为应用程序的后台,而应用程序的前台,则是各种中断的中断服务函数。因此单任务系统也叫做前后台系统。
- 前后台系统的实时性很差,因为大循环中函数处理的事务没有优先级之分,必须是顺序地执行处理,不论待处理事务的紧急程度有多高,没轮到就只能等着,虽然中断可以处理一些紧急的事务,但是在大型嵌入式系统中,这样的单任务系统就会显得力不从心。
- 多任务系统
- 多任务系统的多个任务可以“同时”运行,是从宏观的角度而言的,对于单核CPU而言,CPU在同一时刻只能处理一个任务,但是多任务系统的任务调度器可以根据相关的任务调度算法,将CPU的使用权分给任务,在任务获得CPU使用权之后的极短时间(宏观角度)后,任务调度器又会将CPU的使用权分配给其他任务,如此往复,在宏观的角度看来,就像是多个任务同时运行一样。
- 多任务系统的任务具有优先级,高优先级的任务可以像中断一样抢占,抢占低优先级任务的CPU使用权;优先级相同的任务则各自轮流运行一段极短时间,从而产生“同时”运行的错觉。以上就是抢占式调度和时间片轮转调度的基本原理。在任务有了优先级的多任务系统中,用户就可以将紧急的事务放在优先级高的任务中进行处理,那么整个系统的实时性就会大大提高。
软实时与硬实时
实时嵌入式系统中的多任务与桌面电脑的多任务从概念上来讲是相似的。但实时嵌入式系统的侧重点却不同于桌面电脑,特别是当嵌入式系统期望提供“硬实时”行为的时候。
- 软实时:桌面电脑的输入处理可以归类为“软实时”。为了保证用户的最佳体验,计算机对每个输入的响应应当限定在一个恰当的时间范围,但是如果响应时间超出了限定范围,并不会让人觉得这台电脑无法使用。比如说,键盘操作必须在键按下后的某个时间内作出明显的提示。但如果按键提示超出了这个时间,会使得这个系统看起来响应太慢,而不致于说这台电脑不能使用。
- 硬实时:硬实时功能必须在给定的时间限制之内完成。如果无法做到即意味着整个系统的绝对失败。汽车的安全气囊触发机制就是一个硬实时功能的例子。安全气囊在撞击发生后给定时间限制内必须弹出。如果响应时间超出了这个时间限制,会使得驾驶员受到伤害,而这原本是可以避免的。
任务函数
函数原型
1 | void ATaskFunction(void *pvParameters); |
其必须返回 void,而且带有一个 void 指针参数。
每个任务都是在自己权限范围内的一个小程序。其具有程序入口,通常会运行在一个死循环中,不会退出。FreeRTOS 任务不允许以任何方式从实现函数中返回,不能 return,也不能执行到函数末尾。如果一个任务不再被需要,可以显式的将其删除。
一个任务函数可以用来创建若干个任务,创建出的任务均是独立的执行实例,拥有属于自己的栈空间,以及属于自己的自动变量(栈变量),即任务函数本身定义的变量。
典型的任务函数结构
1 | void ATaskFunction(void *Param) |
任务函数内的 static 变量
关于上面所述的任务实例共享 static 变量,在 ESPIDF 上进行了测试,代码如下。
1 | #include <stdio.h> |
int data
:此时 task 1 和 task 2 都有属于自己的data
,在 task 1 改变值为1后,task 2 的值仍然为 100,打印的字符串是 “no change”。static int data
:此时2个 task 共享data
变量,task 1任务执行完后,task 2任务中data
也变为1,打印的字符串是 “already changed”。
顶层任务状态
应用程序可以包含多个任务。如果运行应用程序的微控制器只有一个核(core),那么在任意给定时间,实际上只会有一个任务被执行。这就意味着一个任务可以有一个或两个状态,即运行状态和非运行状态。我们先考虑这种最简单的模型。但请牢记这其实是过于简单,我们稍后将会看到非运行状态实际上又可划分为若干个子状态。
当某个任务处于运行态时,处理器就正在执行它的代码。当一个任务处于非运行态时,该任务进行休眠,它的所有状态都被妥善保存,以便在下一次调试器决定让它进入运行态时可以恢复执行。当任务恢复执行时,其将精确地从离开运行态时正准备执行的那一条指令开始执行。
任务从非运行态转移到运行态被称为“切换入或切入 (switched in)”或”交换入 (swapped in)”。相反,任务从运行态转移到非运行态被称为“切换出或切出 (switched out)”或“交换出 (swapped out)”。FreeRTOS 的调度器是能让任务切入切出的唯一实体。
创建任务
函数原型
1 | portBASE_TYPE xTaskCreate( pdTask_CODE pvTaskCode, |
参数名 | 描述 |
---|---|
pvTaskCode |
任务只是永不退出的 C 函数,实现常通常是一个死循环。参数pvTaskCode 只一个指向任务的实现函数的指针(任务函数名) |
pcName |
具有描述性的任务名。只是单纯地用于辅助调试。应用程序可以通过定义常量 config_MAX_TASK_NAME_LEN 来定义任务名的最大长度,包括’\0’结束符。如果传入的字符串长度超过了这个最大值,字符串将会自动被截断。 |
usStackDepth |
当任务创建时,内核会分为每个任务分配属于任务自己的唯一状态。usStackDepth 值用于告诉内核为它分配多大的栈空间。这个值指定的是栈空间可以保存多少个字(word),而不是多少个字节(byte)。例如 32 位宽的栈空间,传入的usStackDepth 值为 100,则会分配 400 字节的栈空间(100 * 4bytes)。 |
pvParameters |
任务函数接受一个指向 void 的指针(void*)。pvParameters 的值即是传递到任务中的值。上一节任务函数内的 static 变量实践代码中有示例。 |
uxPriority |
指定任务执行的优先级。优先级的取值范围可以从最低优先级 0 到最高优先级(configMAX_PRIORITIES – 1)。优先级号并没有上限(除了受限于采用的数据类型和系统的有效内存空间),但最好使用实际需要的最小数值以避免内存浪费。 |
pxCreatedTask |
pxCreatedTask 用于传出任务的句柄。这个句柄将在 API 调用中对该创建出来的任务进行引用,比如改变任务优先级,或者删除任务。如果应用程序中不会用到这个任务的句柄,则pxCreatedTask 可以被设为 NULL。 |
返回值 |
1.pdTRUE :表明任务创建成功。2.errCOULD_NOT_ALLOCATE_REQUIRED_MEMORY :由于内存堆空间不足,FreeRTOS 无法分配足够的空间来保存任务结构数据和任务栈,因此无法创建任务。 |
- 在任何时刻只可能有一个任务处于运行态。所以一个任务进入运行态后(切入),另一个任务就会进入非运行态(切出)。
- 可以在启动调度器之前先完成两个任务的创建,也可以从一个任务中创建另一个任务。
注意
定义将要通过任务参数传递的字符串。定义为const,且不是在栈空间上,以保证任务执行时也有效。
例如下面代码的pcTextForTask1
和pcTextForTask2
。
1 | static const char *pcTextForTask1 = “Task 1 is running\r\n”; |
如果将其定义在main
函数里,它将存储在栈(Stack)上。根据 ESPIDF 编程指南,app_main
函数返回后主任务将会被删除,栈上的数据会被自动释放。
主任务是指运行
app_main
函数的任务,主任务的堆栈大小和优先级可以在menuconfig
中进行配置。应用程序可以用此任务来完成用户程序相关的初始化设置,比如启动其他的任务。应用程序还可以将主任务用于事件循环和其他通用活动。如果app_main
函数返回,那么主任务将会被删除。
任务优先级
应用程序在文件FreeRTOSConfig.h
中设定的编译时配置常量configMAX_PRIORITIES
的值,即是最多可具有的优先级数目。FreeRTOS 本身并没有限定这个常量的最大值,但这个值越大,则内核花销的内存空间就越多。所以总是建议将此常量设为能够用到的最小值。
调度器保证总是在所有可运行的任务中选择具有最高优先级的任务,并使其进入运行态。如果被选中的优先级上具有不止一个任务,调度器会让这些任务轮流执行。
要能够选择下一个运行的任务,调度器需要在每个时间片的结束时刻运行自己本身。一个称为tick
中断的周期性中断用于此目的。时间片的长度通过心跳中断的频率进行设定,心跳中断频率由FreeRTOSConfig.h
中的编译时配置常量configTICK_RATE_HZ
进行配置。
需要说明的是,FreeRTOS API 函数调用中指定的时间总是以心跳中断为单位(通常的提法为心跳“ticks”)。常量portTICK_RATE_MS
用于将以心跳tick
为单位的时间值转化为以毫秒为单位的时间值。有效精度依赖于系统心跳频率。
心跳计数(tick count)值表示的是从调度器启动开始,心跳中断的总数,并假定心跳计数器不会溢出。用户程序在指定延迟周期时不必考虑心跳计数溢出问题,因为时间连贯性在内核中进行管理。
上图中红色的线段表时内核本身在运行。黑色箭头表示任务到中断,中断再到另一个任务的执行顺序。
扩充“非运行态”
为了使我们的任务切实有用,我们需要通过某种方式来进行事件驱动。一个事件驱动任务只会在事件发生后触发工作(处理),而在事件没有发生时是不能进入运行态的。调度器总是选择所有能够进入运行态的任务中具有最高优先级的任务。一个高优先级但不能够运行的任务意味着不会被调度器选中,而代之以另一个优先级虽然更低但能够运行的任务。因此,采用事件驱动任务的意义就在于任务可以被创建在许多不同的优先级上,并且最高优先级任务不会把所有的低优先级任务饿死。
状态
- 阻塞状态
如果一个任务正在等待某个事件,则称这个任务处于“**阻塞态(blocked)**”。阻塞态是非运行态的一个子状态。
任务可以进入阻塞态以等待以下两种不同类型的事件:- 定时(时间相关)事件:这类事件可以是延迟到期或是绝对时间到点。比如说某个任务可以进入阻塞态以延迟 10ms。
- 同步事件:源于其它任务或中断的事件。比如说,某个任务可以进入阻塞态以等待队列中有数据到来。
任务可以在进入阻塞态以等待同步事件时指定一个等待超时时间,这样可以有效地实现阻塞状态下同时等待两种类型的事件。例如某个任务可以等待队列中有数据到来,但最多只等 10ms。如果 10ms 内有数据到来,或是 10ms 过去了还没有数据到来,这两种情况下该任务都将退出阻塞态。
- 挂起状态
“挂起(suspended)”也是非运行状态的子状态。处于挂起状态的任务对调度器而言是不可见的。 - 就绪状态
如果任务处于非运行状态,但既没有阻塞也没有挂起,则这个任务处于就绪(ready,准备或就绪)状态。处于就绪态的任务能够被运行,但只是“准备(ready)”运行,而当前尚未运行。
状态转移图
阻塞态实现延迟
使用空循环即不停地查询并递增一个循环计数直至计到某个指定值,会一直保持在运行态中执行空循环,可能将其它任务饿死。在查询过程中,任务实际上并没有做任何有意义的事情,但它依然会耗尽所有处理时间,对处理器周期造成浪费。
1 | void vTaskDelay(portTickType xTicksToDelay); |
参数名 | 描述 |
---|---|
xTicksToDelay |
延迟多少个心跳周期。调用该延迟函数的任务将进入阻塞态,经延迟指定的心跳周期数后,再转移到就绪态。 |
1 | void vTaskDelayUntil( portTickType * pxPreviousWakeTime, portTickType xTimeIncrement ); |
参数名 | 描述 |
---|---|
pxPreviousWakeTime |
此参数用于实现某个任务以固定频率周期性执行。这种情况下pxPreviousWakeTime 保存了任务上一次离开阻塞态(被唤醒)的时刻。这个时刻被用作一个参考点来计算该任务下一次离开阻塞态的时刻。 |
xTimeIncrement |
用于实现某个任务以固定频率周期性执行,这个频率就是由xTimeIncrement 指定。xTimeIncrement 的单位是心跳周期,可以使用常量portTICK_RATE_MS 将毫秒转换为心跳周期。 |
vTaskDelay()
的参数用来指定任务在调用vTaskDelay()
到切出阻塞态整个过程包含多少个心跳周期。任务保持在阻塞态的时间量由vTaskDelay()
的入口参数指定,但任务离开阻塞态的时刻实际上是相对于vTaskDelay()
被调用那一刻的。vTaskDelayUntil()
的参数就是用来指定任务离开阻塞态进入就绪态那一刻的精确心跳计数值。可以用于实现一个固定执行周期的需求(当你需要让你的任务以固定频率周期性执行的时候)。**由于调用此函数的任务解除阻塞的时间是绝对时刻,比起相对于调用时刻的相对时间更精确(即比调用vTaskDelay()
可以实现更精确的周期性)**。
空闲任务与空闲任务钩子函数
空闲任务
当创建的任务大部份时间都处于阻塞态时,这种状态下所有的任务都不可运行,所以也不能被调度器选中。
但处理器总是需要代码来执行,所以至少要有一个任务处于运行态。为了保证这一点,当调用vTaskStartScheduler()
时,调度器会自动创建一个空闲任务。空闲任务是一个非常短小的循环,总是可以运行。
空闲任务拥有最低优先级(优先级 0)以保证其不会妨碍具有更高优先级的应用任务进入运行态。运行在最低优先级可以保证一旦有更高优先级的任务进入就绪态,空闲任务就会立即切出运行态。
空闲任务钩子函数
通过空闲任务钩子函数(或称回调,hook, or call-back),可以直接在空闲任务中添加应用程序相关的功能。空闲任务钩子函数会被空闲任务每循环一次就自动调用一次。
通常空闲任务钩子函数被用于:
- 执行低优先级,后台或需要不停处理的功能代码。
- 测试处系统处理裕量(空闲任务只会在所有其它任务都不运行时才有机会执行,所以测量出空闲任务占用的处理时间就可以清楚的知道系统有多少富余的处理时间)。
- 将处理器配置到低功耗模式,提供一种自动省电方法,使得在没有任何应用功能需要处理的时候,系统自动进入省电模式。
空闲任务钩子函数的实现限制
空闲任务钩子函数必须遵从以下规则:
- 绝不能阻或挂起。空闲任务只会在其它任务都不运行时才会被执行(除非有应用任务共享空闲任务优先级)。以任何方式阻塞空闲任务都可能导致没有任务能够进入运行态
- 如果应用程序用到了
vTaskDelete()
函数,则空闲钩子函数必须能够尽快返回。因为在任务被删除后,空闲任务负责回收内核资源。如果空闲任务一直运行在钩子函数中,则无法进行回收工作。
空闲任务钩子函数原型
1 | /* 空闲钩子函数必须命名为vApplicationIdleHook(),无参数也无返回值。 */ |
任务优先级
改变优先级
1 | void vTaskPrioritySet( xTaskHandle pxTask, unsigned portBASE_TYPE uxNewPriority ); |
参数名 | 描述 |
---|---|
pxTask |
被修改优先级的任务句柄。任务可以通过传入 NULL 值来修改自己的优先级。 |
uxNewPriority |
目标任务将被设置到哪个优先级上。 |
查询优先级
1 | unsigned portBASE_TYPE uxTaskPriorityGet( xTaskHandle pxTask ); |
参数名 | 描述 |
---|---|
pxTask |
被修改优先级的任务句柄。任务可以通过传入 NULL 值来查询自己的优先级。 |
返回值 |
被查询任务的当前优先级。 |
删除任务
任务被删除后就不复存在,也不会再进入运行态。空闲任务的责任是要将分配给已删除任务的内存释放掉。因此有一点很重要,那就是使用vTaskDelete()
函数的任务千万不能把空闲任务的执行时间饿死。
需要说明一点,只有内核为任务分配的内存空间才会在任务被删除后自动回收。任务自己占用的内存或资源需要由应用程序自己显式地释放。
1 | void vTaskDelete( xTaskHandle pxTaskToDelete ); |
参数名 | 描述 |
---|---|
pxTaskToDelete |
被删除任务的句柄.任务可以通过传入 NULL 值来删除自己。 |
调度算法
优先级抢占式调度
- 每个任务都赋予了一个优先级。
- 每个任务都可以存在于一个或多个状态。
- 在任何时候都只有一个任务可以处于运行状态。
- 调度器总是在所有处于就绪态的任务中选择具有最高优先级的任务来执行。
这种类型的调度方案被称为“固定优先级抢占式调度”。所谓“固定优先级”是指每个任务都被赋予了一个优先级,这个优先级不能被内核本身改变(只能被任务修改)。“抢占式”是指当任务进入就绪态或是优先级被改变时,如果处于运行态的任务优先级更低,则该任务总是抢占当前运行的任务。
选择任务优先级
作为一种通用规则,完成硬实时功能的任务优先级会高于完成软件时功能任务的优先级。但其它一些因素,比如执行时间和处理器利用率,都必须纳入考虑范围,以保证应用程序不会超过硬实时的需求限制。
单调速率调度(Rate Monotonic Scheduling, RMS)是一种常用的优先级分配技术。其根据任务周期性执行的速率来分配一个唯一的优先级。具有最高周期执行频率的任务赋予高最优先级;具有最低周期执行频率的任务赋予最低优先级。这种优先级分配方式被证明了可以最大化整个应用程序的可调度性(schedulability),但是运行时间不定以及并非所有任务都具有周期性,会使得对这种方式的全面计算变得相当复杂。
任务堆栈设置
函数原型
1 | UBaseType_t uxTaskGetStackHighWaterMark( TaskHandle_t xTask ); |
参数名 | 描述 |
---|---|
xTask |
被查询任务的句柄 |
返回值 |
任务栈空间的实际使用量会随着任务执行和中断处理过程上下浮动。函数返回从任务启动执行开始的运行历史中,栈空间具有的最小剩余量。任务可以通过传入 NULL 值来查询自己的栈空间大小。这个值即是栈空间使用达到最深时的剩下的未使用的栈空间。这个值越是接近 0,则这个任务就越是离栈溢出不远了。 |
看门狗
概述
ESP-IDF 支持以下类型的看门狗定时器:
- 中断看门狗定时器 (IWDT)
- 任务看门狗定时器 (TWDT)
中断看门狗负责确保 ISR(中断服务程序)不被长时间阻塞,TWDT 负责检测任务长时间运行而不让步的情况。
中断看门狗定时器 (IWDT)
IWDT 的目的是,确保中断服务例程 (ISR) 运行不会受到长时间阻塞(即 IWDT 超时)。阻塞 ISR 及时运行会增加 ISR 延迟,也会阻止任务切换(因为任务切换是从 ISR 执行的)。阻止 ISR 运行的事项包括:
- 禁用中断
- 临界区(也会禁用中断)
- 其他相同或更高优先级的 ISR,在完成前会阻止相同或较低优先级的 ISR
当 IWDT 超时后,默认操作是调用紧急处理程序 (Panic Handler),并显示 出错原因( Interrupt wdt timeout on CPU0
或 Interrupt wdt timeout on CPU1
,视情况而定)。根据紧急处理程序的配置行为(参见 CONFIG_ESP_SYSTEM_PANIC),用户可通过回溯、OpenOCD、gdbstub 等来调试 IWDT 超时问题,也可以重置芯片(这在生产环境中可能是首选)。
注意
如果 IWDT 超时是中断或临界区运行超时导致的,可以考虑重写代码:
- 临界区应尽可能短。任何非关键的代码或计算都应放在临界区外。
- 中断处理程序也应尽可能减少计算量。考虑让 ISR 使用队列向任务推送数据,从而将计算推迟到任务中进行。
临界区或中断处理程序都不应阻塞其他事件。如果不能或不希望通过更改代码减少处理时间,可以通过设置 CONFIG_ESP_INT_WDT_TIMEOUT_MS
延长超时时间。
任务看门狗定时器 (TWDT)
任务看门狗定时器 (TWDT) 用于监视特定任务,确保任务在配置的超时时间内执行。TWDT 主要监视每个 CPU 的空闲任务,但其他任务也可以订阅 TWDT 监视。通过监视每个 CPU 的空闲任务,TWDT 可以检测到任务长时间运行没有让出的情况。这可能表明代码编写不当,在外设上自旋循环,或者任务陷入了无限循环。
TWDT 是基于定时器组 0 中的硬件看门狗定时器构建的。超时发生时会触发中断。
可以在用户代码中定义函数esp_task_wdt_isr_user_handler
来接收超时事件,并扩展默认行为。
使用
使用看门狗前先引入头文件#include "esp_task_wdt.h"
,调用以下函数,用 TWDT 监视任务:
esp_task_wdt_add()
为其他任务订阅 TWDT。订阅后,应从任务中调用
esp_task_wdt_reset() 来喂 TWDT。esp_task_wdt_delete()
可以取消之前订阅的任务。esp_task_wdt_init()
初始化 TWDT 并订阅空闲任务。esp_task_wdt_deinit()
取消订阅空闲任务并反初始化 TWDT。