FreeRTOS是一个面向嵌入式系统的实时操作系统(RTOS),提供了任务、时间管理、中断和信号量等基本功能,支持多种架构和芯片。在FreeRTOS中,任务被认为是一个执行特定功能的轻量级线程,每个任务都拥有自己的独立空间,相互之间不会干扰。以下是FreeRTOS任务机制的详细介绍:
FreeRTOS的调度器是RTOS内核的核心组件之一,主要负责任务的调度和管理。在FreeRTOS中,调度器是一个协程式调度器,采用优先级抢占式调度策略,可以支持单核、多核和对称/非对称多处理器(SMP/AMP)架构等多种系统配置。
当RTOS系统启动后,调度器会初始化所有的任务控制块(TCB),并根据任务的优先级和状态,将它们安排到一个任务就绪列表中。每个任务都有一个状态码,可以是就绪、阻塞或挂起状态。当一个任务处于就绪状态时,它已经准备好运行,并等待调度器为其分配CPU时间片。
调度器的运行机制根据不同的配置有所不同:
FreeRTOS的调度算法采用优先级抢占式调度策略,具体包括以下几个方面:
TCB任务控制块(Task Control Block),是操作系统内核中用于描述任务(或线程)信息的数据结构。每个任务(或线程)都会拥有自己的TCB,在TCB中保存了任务在运行时需要的一系列信息,例如任务的状态、优先级、程序计数器等等。
TCB的作用主要有两个方面:
typedef struct tskTaskControlBlock
{
volatile StackType_t *pxTopOfStack; /* 栈顶指针 */
ListItem_t xStateListItem; /* 任务状态列表项 */
StackType_t *pxStack; /* 栈基指针 */
char pcTaskName[configMAX_TASK_NAME_LEN]; /* 任务名称 */
UBaseType_t uxPriority; /* 任务优先级 */
TaskFunction_t pxTaskCode; /* 任务执行函数指针 */
#if ( configUSE_TRACE_FACILITY == 1 )
UBaseType_t uxTCBNumber; /* TCB编号 */
#endif
#if ( configUSE_MUTEXES == 1 )
UBaseType_t uxBasePriority; /* 任务基本优先级 */
UBaseType_t uxMutexesHeld; /* 当前持有的互斥量数量 */
#endif
#if ( ( portSTACK_GROWTH > 0 ) || ( configSTACK_DEPTHCHECK == 0 ) )
StackType_t *pxEndOfStack; /* 栈底指针 */
#endif
#if( configUSE_TASK_NOTIFICATIONS == 1 )
volatile uint32_t ulNotifiedValue;/* 任务通知值 */
#endif
#if ( configUSE_APPLICATION_TASK_TAG == 1 )
TaskHookFunction_t pxTaskTag;
#endif
} TCB_t;
TCB块的大小取决于FreeRTOSConfig.h头文件中的设置
在FreeRTOS中维护着两个栈的指针,分别是MSP主堆栈指针(Main stack pointer)和PSP进程堆栈指针(Process stack pointer)。
通常MSP指针用于系统内核和中断服务函数,PSP指针用于用户的任务。
RTOS中的延时叫做阻塞延时,即任务需要延时时,会放弃CPU的使用权,CPU可以去做其他事情,当任务延时时间时,重新获取CPU使用权,继续执行任务。FreeRTOS中至少有一个任务叫做空闲任务,如果没有可以执行的任务,CPU就执行空闲任务,空闲任务是优先级最低的任务。
因为 ESP32-S3 是双核的,所以 FreeRTOS 为每个核心都创建了一个空间任务,名称都为IDLE。
在之前的课程中,我们已经对创建任务和删除任务使用过多次,但任务的其他方法一直没有接触过,本章节中,对任务其他方法做一些补充。
在FreeRTOS 中,如果我们向查看当前运行了哪些任务,最直接方法就是使用 vTaskList 函数进行查询,可惜的是,这个函数被隐藏在任务状态跟踪系列函数中,但为了节省Flash空间,提升CPU执行效率,默认情况下这些函数是不可见不可使用状态,如果需要使用,则必须在配置文件中打开 configUSE_STATS_FORMATTING_FUNCTIONS 选项。
如果通过 Menuconfig 配置,则需要打开 Component config -> FreeRTOS -> Enable FreeRTOS trace facility选项,并勾选新增加的 Enable FreeRTOS stats formatting functions 选项,以及 Enable display of xCoreID in vTaskList 选项(这个选项是在任务列表中列出任务是运行在哪个CPU上的)
但可惜的是,在Arduino环境下,我们无法使用这个方法。
在正式运行的环境中,一般情况下我们不需要使用这些 统计数据采集函数 ,只有在调试过程中才可能会用到,所以为了保证CPU运行效率以及节省Flash空间,请在正式环境中尽量关闭这些功能(当然,如果正式环境中需要使用,比如实时向服务器传送CPU及内存使用率,则可以打开)。
以下代码演示了使用 vTaskList 如何获取任务列表。
#include <stdio.h>
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
void loop(void);
void task(void *param_t)
{
while (1)
{
printf("任务 [%s] 优先级: %u 运行核心:%d\n", pcTaskGetName(NULL), uxTaskPriorityGet(NULL), xPortGetCoreID());
vTaskDelay(pdMS_TO_TICKS(1000));
}
}
void task2(void *param_t)
{
while (1)
{
vTaskDelay(1);
}
}
void app_main(void)
{
xTaskCreatePinnedToCore(task, "LED-TASK", 10240, NULL, 1, NULL, 0);
xTaskCreatePinnedToCore(task, "GateWay", 10240, NULL, 2, NULL , 1);
xTaskCreatePinnedToCore(task, "Display", 10240, NULL, 3, NULL, 0);
xTaskCreate(task, "Keyboard", 10240, NULL, 4, NULL);
xTaskCreate(task2, "NULLTASK", 1024, NULL, 5, NULL);
loop();
}
void loop(void)
{
while (1)
{
printf("%-16s %-8s %-12s %-9s %-7s %8s\n", "名称", "状态", "优先级", "水位线", "编号", "核心");
char pcWriteBuffer[1024];
vTaskList(pcWriteBuffer);
printf("%s", pcWriteBuffer);
vTaskDelay(pdMS_TO_TICKS(1000));
}
}
执行结果如下:
名称 状态 优先级 水位线 编号 核心
main X 1 1096 4 0
IDLE R 0 792 6 1
IDLE R 0 784 5 0
Keyboard B 4 8360 10 -1
GateWay B 2 8328 8 1
Display B 3 8352 9 0
LED-TASK B 1 8384 7 0
NULLTASK B 5 308 11 -1
esp_timer S 22 3328 3 0
ipc1 B 24 792 2 1
ipc0 B 24 784 1 0
运行状态:X:运行中,B:阻塞态,R:就绪态,S:挂起态,D:待删除
水位线:最小剩余内存,单位是字节,用于查看当前内存的使用状态,如果位0表示已经溢出了,越接近零越危险
编号:任务创建的顺序
核心:当前任务运行在哪个核心,-1表示不确定,不是任务不确定,是当时运行的时候没有指定在哪个核心运行,如果通过xTaskCreatePinnedToCore指定,则序号位0或者1
在Arduino下,为了演示获取任务列表,我们采用一些变通的手段也是可以完成的。
代码共享位置:https://wokwi.com/projects/364059788182509569
#include <FreeRTOS.h>
#include <task.h>
void task(void *param_t){
while(1){
printf("任务 [%s] 优先级: %u 运行核心:%d\n", pcTaskGetName(NULL),uxTaskPriorityGet(NULL),xPortGetCoreID());
delay(1000);
}
}
void task2(void *param_t){
while(1){
delay(100);
}
}
void setup() {
// put your setup code here, to run once:
Serial.begin(115200);
Serial.println("Hello, ESP32-S3!");
xTaskCreatePinnedToCore(task, "LED-TASK", 10240, NULL, 1, NULL, 0);
xTaskCreatePinnedToCore(task, "GateWay", 10240, NULL, 2, NULL, 1);
xTaskCreatePinnedToCore(task, "Display", 10240, NULL, 3, NULL, 0);
xTaskCreatePinnedToCore(task, "Keyboard", 10240, NULL, 4, NULL, 1);
xTaskCreatePinnedToCore(task2, "NULLTASK", 1024, NULL, 5, NULL, 1);
}
// 遍历任务列表,代替vTaskList功能
void traverse(){
// 获得空闲任务,第一个任务
String STATUS[] = {"运行中","就绪","已阻塞","已挂起","已删除"};
// TaskHandle_t xHandle = xTaskGetIdleTaskHandle();
TaskHandle_t xHandle =xTaskGetCurrentTaskHandle(); // 这种获取方式不正确,有可能不是第一个任务
printf("-------------------------------------------------\n");
printf("%-16s %-4s %8s %8s %8s\n","名称","优先级","状态","核心", "剩余内存");
while (xHandle != NULL){
uint32_t core_id = 0;
printf("%-16s %-4d %8s %4d %8d\n",
pcTaskGetName(xHandle), // 获取任务名称
uxTaskPriorityGet(xHandle), // 获取任务优先级
STATUS[eTaskGetState(xHandle)], // 获取任务当前状态
xTaskGetAffinity(xHandle), // 获取任务运行在哪个核心
uxTaskGetStackHighWaterMark(xHandle)); // 获取任务堆栈水位线
xHandle = pxTaskGetNext(xHandle);
}
printf("-------------------------------------------------\n");
}
void loop() {
traverse();
delay(1000); // this speeds up the simulation
}
上面例程中,我们手写了一个 traverse 函数代替vTaskList,但这种方式不是完全正确的方法,该方法在 loop中被调用,通过获取当前任务句柄,并通过当前任务句柄在任务链表中查询之后其他线程句柄的方式间接的获得了一些任务的信息,这种方式虽然可以打印出一些任务的状态及数据,但实质上还是无法完全获取所有任务列表。
在 traverse 函数中,首先通过调用 xTaskGetCurrentTaskHandle 函数获取了当前任务句柄,因为这个函数在loop中运行,所以当前任务是 loopTask,然后通过以下一系列函数获取任务信息:
pcTaskGetName :根据句柄返回任务名称
uxTaskPriorityGet : 根据任务句柄返回该任务的优先级
eTaskGetState : 根据任务句柄返回当前任务的运行状态,0:运行中,1:就绪态,2:阻塞态,3:挂起态,4:待删除
xTaskGetAffinity : 根据句柄查询并返回该任务在哪个核心运行
uxTaskGetStackHighWaterMark : 根据句柄返回该任务最小剩余内存,如果返回值为零,则任务可能已溢出堆栈。 如果返回值接近于零,则任务已接近堆栈溢出。
最后,通过 pxTaskGetNext 函数,传入当前任务句柄,从链表中查询下一个任务的句柄,如果返回 NULL 表示链表到头了,没有其他任务。
以此来模拟vTaskList的打印输出。
a: 调用xTaskCreate()函数将新建一个任务的时候,该任务就处于就绪态,新建的任务会加入到就绪列表,若新建的任务的优先级足够高,调度器会立即将CPU资源分配给他,使它进入运行态。
b: 调度器检查就绪列表中优先级高的任务,并将CPU资源分配给它,使他进入运行态。
c: 运行态的任务可能创建/恢复了新的更高优先级的任务,或者因其操作了某事件(如发送了一个更高优先级的任务需要的消息),使更高优先级的任务进入了就绪表时,再或者更高优先级的任务延时到期自动加入就绪表时,更高优先级的任务将得到CPU资源,使当前运行的任务重新进入就绪态(虽然他还在就绪列表,但由于优先级低,而不被分配CPU资源)。
d: 任务对申请处理的事件操作成功、或者处理失败但发起获取申请时设置阻塞时间为0,将继续保留CPU资源,继续运行。
e: 正在运行的任务(毫无疑问,此时它是就绪表中优先级最高的任务之一),可能想要处理一个事件(如想获取一条消息队列中的消息),此时它将向事件资源管理者(即消息队列)发起申请。如使用以 xQueueReceive() 函数尝试获取队列中的一条消息。
f: 任务对申请处理的事件操作失败、并且发起获取申请时设置阻塞时间大于0,则任务会从就绪列表,加入到阻塞延时列表;在等待期间尝试操作事件成功。
n: 运行态的任务调用 vTaskDelay() ,将把自己从就绪列表搬移到主动延时列表,并让就绪表的其他任务获取CPU资源。
h: **延时态(阻塞态)**的任务在延时结束时,若优先级比当前任务优先级高,就加入就绪队列,并立即获得CPU资源,进入运行。
g: **延时态(阻塞态)**的任务在延时结束时,若优先级比当前任务优先级低,就加入就绪队列,但是由于优先级不够,只能等待获取CPU资源。
i: 处于就绪态的任务被正在执行的高优先级的任务挂起,将从就绪列表加入到挂起列表,注意,在挂起列表中的任务,是被打入“冷宫”的任务,除非程序员在写代码时主动调用vTaskResume ()恢复该任务,否则该该任务永远不会回到就绪列表了,更不会被执行。
j: 运行态的任务,主动调用vTaskSuspend()函数挂起自己,将使自己从就绪列表加入挂起列表,并立即执行就绪列表中优先级高的其他任务。
k: 挂起态的任务,不会获得CPU资源了,因此只能等待被正在运行的任务(或中段)唤醒。
m: 同上,被唤醒的任务会被从挂起列表加入到就绪列表,若被唤醒的任务的优先级比当前正在运行的任务优先级高,就立即将CPU资源分配给被唤醒的任务。
当然,我们还可以使用vTaskDelete()函数删除一个任务,删除一个任务会将该任务变为未创建态(它什么资源都没有了),除非重新调用vTaskCreat()函数重建它,否则永远不会加入到就绪列表了。
A : 正在运行的任务,触发调度器工作的途径有:(但凡有可能引出高优先级任务或中断的操作都会触发调度器检查)
B : 中断中触发调度器工作的途径有:(但凡有可能使高优先级任务就绪的操作都会触发调度器检查)
对于初学者,有时候会纠结任务优先级设置为多少合适,因为任务优先级设置多少是没有标准的。对于这个问题,我们这里为大家推荐一个标准,任务优先级设置推荐方式如下图所示:
这里的优先级分配方案是我们推荐的一种方式,实际项目也可以不采用这种方法。 调试出适合项目需求的才是最好的。
部分初学者也容易在这两个概念上面出现问题。 简单的说,这两个之间没有任何关系,不管中断的优先级是多少,中断的优先级永远高于任何任务的优先级,即任务在执行的过程中,中断来了就开始执行中断服务程序。
任务创建函数有三组,分别是 xTaskCreate,xTaskCreateStatic,以及多核MCU特有的 xTaskCreatePinnedToCore 和
xTaskCreateStaticPinnedToCore,以及 针对 MPU 所制定的 xTaskCreateRestricted 和 xTaskCreateRestrictedStatic。
FreeRTOS-MPU 创建任务的方式这里不过多讨论(原因是我也没咋用过,不太清楚到底是干什么的,只知道使用此函数会创建一个
受到 MPU的保护的任务)
BaseType_t xTaskCreate( TaskFunction_t pxTaskCode, // 函数指针, 任务函数
const char * const pcName, // 任务的名字
const configSTACK_DEPTH_TYPE usStackDepth, // 栈大小,单位为word,10表示40字节
void * const pvParameters, // 调用任务函数时传入的参数
UBaseType_t uxPriority, // 优先级
TaskHandle_t * const pxCreatedTask ); // 任务句柄, 以后使用它来操作这个任务
参数 | 描述 |
---|---|
pxTaskCode | 函数指针,可以简单地认为任务就是一个C函数。它稍微特殊一点:永远不退出,或者退出时要调用"vTaskDelete(NULL)" |
pcName | 任务的名字,FreeRTOS内部不使用它,仅仅起调试作用。长度为:configMAX_TASK_NAME_LEN |
usStackDepth | 每个任务都有自己的栈,这里指定栈大小。单位是word,比如传入100,表示栈大小为100 word,也就是400字节。最大值为uint16_t的最大值。怎么确定栈的大小,并不容易,很多时候是估计。精确的办法是看反汇编码。ESP32栈的宽度为8位字节 |
pvParameters | 调用pvTaskCode函数指针时用到:pvTaskCode(pvParameters) 传递给任务函数的参数。 |
uxPriority | 优先级范围:0~(configMAX_PRIORITIES – 1)数值越小优先级越低,如果传入过大的值,xTaskCreate会把它调整为(configMAX_PRIORITIES – 1) |
pxCreatedTask | 用来保存xTaskCreate的输出结果:task handle。以后如果想操作这个任务,比如修改它的优先级,就需要这个handle。如果不想使用该handle,可以传入NULL |
返回值 | 成功:pdPASS;失败:errCOULD_NOT_ALLOCATE_REQUIRED_MEMORY(失败原因只有内存不足)注意:文档里都说失败时返回值是pdFAIL,这不对。pdFAIL是0,errCOULD_NOT_ALLOCATE_REQUIRED_MEMORY是-1。 |
TaskHandle_t xTaskCreateStatic( TaskFunction_t pxTaskCode, // 函数指针
const char * const pcName, // 任务名称
const uint32_t ulStackDepth, // 栈大小,单位为word,10表示40字节
void * const pvParameters, // 调用任务函数时传入的参数
UBaseType_t uxPriority, // 优先级
StackType_t * const puxStackBuffer, // 任务堆栈
StaticTask_t * const pxTaskBuffer ) // 任务块
参数 | 描述 |
---|---|
pxTaskCode | 函数指针,可以简单地认为任务就是一个C函数。它稍微特殊一点:永远不退出,或者退出时要调用"vTaskDelete(NULL)" |
pcName | 任务的名字,FreeRTOS内部不使用它,仅仅起调试作用。长度为:configMAX_TASK_NAME_LEN |
ulStackDepth | 任务堆栈大小, 由于本函数是静态方法创建所以用户给出任务堆栈大小,一般是一个数组,此参数就是这个数组的大小 ESP32栈的宽度为8位字节 |
pvParameters | 调用pvTaskCode函数指针时用到:pvTaskCode(pvParameters) 传递给任务函数的参数。 |
uxPriority | 优先级范围:0~(configMAX_PRIORITIES – 1)数值越小优先级越低,如果传入过大的值,xTaskCreate会把它调整为(configMAX_PRIORITIES – 1) |
puxStackBuffer | 任务堆栈,一般为 数组类型要StackType_t类 |
pxTaskBuffer | 任务控制块 |
返回值 | NULL: 任务创建失败, puxStackBuffer或 pxTaskBuffer为 NULL的时候会导致这个 的时候会导致这个 错误的发生。 |
BaseType_t xTaskCreatePinnedToCore( TaskFunction_t pvTaskCode, // 函数指针
const char * const pcName, // 任务名称
const uint32_t usStackDepth, // 栈大小,单位为word,10表示40字节
void * const pvParameters, // 调用任务函数时传入的参数
UBaseType_t uxPriority, // 优先级
TaskHandle_t * const pvCreatedTask, // 任务句柄, 以后使用它来操作这个任务
const BaseType_t xCoreID); // 指定的核心编号
参数 | 描述 |
---|---|
pxTaskCode | 函数指针,可以简单地认为任务就是一个C函数。它稍微特殊一点:永远不退出,或者退出时要调用"vTaskDelete(NULL)" |
pcName | 任务的名字,FreeRTOS内部不使用它,仅仅起调试作用。长度为:configMAX_TASK_NAME_LEN |
usStackDepth | 每个任务都有自己的栈,这里指定栈大小。单位是word,比如传入100,表示栈大小为100 word,也就是400字节。最大值为uint16_t的最大值。怎么确定栈的大小,并不容易,很多时候是估计。精确的办法是看反汇编码。ESP32栈的宽度为8位字节 |
pvParameters | 调用pvTaskCode函数指针时用到:pvTaskCode(pvParameters) 传递给任务函数的参数。 |
uxPriority | 优先级范围:0~(configMAX_PRIORITIES – 1)数值越小优先级越低,如果传入过大的值,xTaskCreate会把它调整为(configMAX_PRIORITIES – 1) |
pxCreatedTask | 用来保存xTaskCreate的输出结果:task handle。以后如果想操作这个任务,比如修改它的优先级,就需要这个handle。如果不想使用该handle,可以传入NULL |
xCoreID | 该任务运行的核心,可以写0或者1 |
返回值 | 成功:pdPASS;失败:errCOULD_NOT_ALLOCATE_REQUIRED_MEMORY(失败原因只有内存不足)注意:文档里都说失败时返回值是pdFAIL,这不对。pdFAIL是0,errCOULD_NOT_ALLOCATE_REQUIRED_MEMORY是-1。 |
TaskHandle_t xTaskCreateStaticPinnedToCore( TaskFunction_t pxTaskCode, // 函数指针
const char * const pcName, // 任务名称
const uint32_t ulStackDepth, // 栈大小,单位为word,10表示40字节
void * const pvParameters, // 调用任务函数时传入的参数
UBaseType_t uxPriority, // 优先级
StackType_t * const puxStackBuffer, // 任务堆栈
StaticTask_t * const pxTaskBuffer // 任务块
const BaseType_t xCoreID ) // 指定的核心编号
参数 | 描述 |
---|---|
pxTaskCode | 函数指针,可以简单地认为任务就是一个C函数。它稍微特殊一点:永远不退出,或者退出时要调用"vTaskDelete(NULL)" |
pcName | 任务的名字,FreeRTOS内部不使用它,仅仅起调试作用。长度为:configMAX_TASK_NAME_LEN |
ulStackDepth | 任务堆栈大小, 由于本函数是静态方法创建所以用户给出任务堆栈大小,一般是一个数组,此参数就是这个数组的大小 ESP32栈的宽度为8位字节 |
pvParameters | 调用pvTaskCode函数指针时用到:pvTaskCode(pvParameters) 传递给任务函数的参数。 |
uxPriority | 优先级范围:0~(configMAX_PRIORITIES – 1)数值越小优先级越低,如果传入过大的值,xTaskCreate会把它调整为(configMAX_PRIORITIES – 1) |
puxStackBuffer | 任务堆栈,一般为 数组类型要StackType_t类 |
pxTaskBuffer | 任务控制块 |
xCoreID | 该任务运行的核心,可以写0或者1 |
返回值 | NULL: 任务创建失败, puxStackBuffer或 pxTaskBuffer为 NULL的时候会导致这个 的时候会导致这个 错误的发生。 |
vTaskDelete( TaskHandle_t xTaskToDelete )
参数 | 描述 |
---|---|
xTaskToDelete | 要删除的任务句柄 |
以下代码通过四种方式创建任务并执行。
代码共享位置:https://wokwi.com/projects/364431248282339329
#define STACK_SIZE 1024 // 栈的大小,实际计算方式为1024 * 栈宽度,ESP32栈的宽度为8位字节
static TaskHandle_t xDTaskHandle = NULL; // 动态任务句柄
static TaskHandle_t xStaticTaskHandle = NULL; // 静态任务句柄
static TaskHandle_t xTaskHandle_Core0 = NULL; // 在核心0 创建的静态任务句柄
static TaskHandle_t xStaticTaskHandle_Core1 = NULL; // 在核心1 创建的静态任务句柄
static StaticTask_t xTaskBuffer; // 静态任务控制块
static StaticTask_t xTaskBuffer_Core1; // 在核心1 运行的静态任务控制块
static StackType_t uxTaskStack[STACK_SIZE]; // 静态任务栈空间
static StackType_t uxTaskStack_Core1[STACK_SIZE]; // 在核心1运行的 静态任务栈空间
void task(void *params){
while(1){
delay(10);
}
}
void setup() {
// put your setup code here, to run once:
Serial.begin(115200);
Serial.println("Hello, ESP32-S3!");
// 创建动态任务
xTaskCreate(task, "D-Task", STACK_SIZE, NULL, 1, &xDTaskHandle);
// 创建静态任务
xStaticTaskHandle = xTaskCreateStatic(task, "S-Task", STACK_SIZE, NULL, 2, uxTaskStack, &xTaskBuffer);
// 在核心0 创建动态任务
xTaskCreatePinnedToCore(task, "D-Task-C0", STACK_SIZE, NULL, 3, &xTaskHandle_Core0, 0);
// 在核心1 创建静态任务
xStaticTaskHandle_Core1 = xTaskCreateStaticPinnedToCore(task, "S-Task-C1", STACK_SIZE, NULL, 4, uxTaskStack, &xTaskBuffer, 1);
}
void loop() {
// put your main code here, to run repeatedly:
delay(10); // this speeds up the simulation
}
创建静态任务的时候要注意,因为静态任务栈空间和任务控制块在这个任务删除之前都需要使用,所以必须保证在该静态任务删除之前任务控制块和栈空间不能被释放,所以尽量在公共域内创建,如果需要在其他任务函数中创建,则一定要保证该任务的空间足够,并且是一个长期运行不退出的任务才可以,否则会产生内存溢出的错误。
在 FreeRTOS 中,任务的优先级是一个从 0 到 configMAX_PRIORITIES-1 的整数,其中 configMAX_PRIORITIES 是 FreeRTOS 宏定义的最大优先级数目,在默认情况下ESP32设定为 25。
任务的优先级决定了任务在调度时的相对重要性。当多个任务同时处于就绪态,即没有任何阻塞操作(例如等待信号量、延时等等)时,FreeRTOS 内核会将优先级最高的任务移动到运行态,并分配给该任务处理器时间片。如果新的更高优先级的任务被添加到系统中,则它会立即抢占当前正在运行的任务。任务的优先级越高,其获得 CPU 时间的机会就越大,在任务数量较多、且有大量任务同时需要处理时,通过合理设置任务优先级可以有效地保证系统的响应性、稳定性和可靠性。
对于在 FreeRTOS 中优先级相同的任务,它们会以先进先出 (FIFO) 的顺序进行调度,即当多个优先级相同、都处于就绪态的任务时,FreeRTOS 内核会依次将这些任务加入到就绪队列中,并按照加入的顺序进行调度运行,直到当前任务阻塞或者解除阻塞。
需要注意的是,由于内核调度器的时间片轮转机制,即使有多个优先级相同的任务,它们的执行时间也可能不完全相等。在时间片轮转时,内核会为每个任务分配固定的时间片,在当前任务的时间片用完后,内核会将控制权交给下一个就绪的相同优先级任务。如果任务耗时较长,超过了时间片的长度,那么任务将被挂起,控制权转移到下一个任务上,直到轮到该任务再次运行。
因此,尽管优先级相同的任务会以轮流运行的方式进行调度,但它们的执行顺序和时间仍然可以有细微的差别。在实际应用中,为了更好地控制任务行为和响应性能,可以通过设置任务的优先级和时间片长度等参数来调整任务的调度策略。
FreeRTOS 内核会在以下情况下进行任务调度:
只要当前指定的任务已经完成了它的工作或者阻塞,FreeRTOS 内核就会进行任务调度,以查找一个优先级最高的就绪任务来运行。这样可以确保系统能够及时响应各种事件,并保证任务按照一定优先级的顺序得到执行。
void vTaskPrioritySet( TaskHandle_t xTask, UBaseType_t uxNewPriority );
参数 | 描述 |
---|---|
xTask | 正在设置优先级的任务的句柄。空句柄会设置调用任务的优先级。 |
uxNewPriority | 将要设置任务的优先级。 |
UBaseType_t uxTaskPriorityGet( TaskHandle_t xTask );
参数 | 描述 |
---|---|
xTask | 待查询的任务句柄。传递 NULL 句柄会导致返回调用任务的优先级。 |
返回值 | 返回查询任务的优先级 |
在 FreeRTOS 中,任务的挂起是指将任务暂时从调度器的就绪队列中移除,并将其放入一个阻塞队列中,以等待某个条件达成或者事件发生。而任务的恢复则是指将一个已经被挂起的任务重新放回到调度器的就绪队列中,使得这个任务能够再次获得 CPU 时间片并开始执行。
vTaskSuspend() 和 vTaskResume():这两个函数可以分别用来挂起和恢复一个特定的任务。当任务被挂起后,它将不再被调度器执行,直到通过 vTaskResume() 函数将其重新放回到就绪队列中。需要注意的是,在FreeRTOS中,对 vTaskSuspend 的调用不会累积次数,例如:若在同一任务上调用 vTaskSuspend () 两次,将仍然仅需调用一次 vTaskResume (),即可准备完毕暂停的任务。
void vTaskSuspend( TaskHandle_t xTaskToSuspend );
void vTaskResume( TaskHandle_t xTaskToResume );
BaseType_t xTaskAbortDelay( TaskHandle_t xTask );
强制任务离开阻塞状态,并 进入“准备就绪”状态,即使任务在阻塞状态下等待的事件没有发生, 并且任何指定的超时没有过期。
以下例程中演示了创建任务、删除任务、提升任务优先级、降低任务优先级、强制唤醒任务、挂起任务、恢复任务等操作。
代码共享位置:https://wokwi.com/projects/364069272446878721
#include <FreeRTOS.h>
#include <task.h>
#include <Keypad.h>
TaskHandle_t xTask = NULL;
TaskHandle_t xLoopMain = NULL;
void task(void *param_t){
while(1){
delay(1000);
}
}
// 创建任务用的函数
void mytask(void *param_t){
while(1){
printf("任务 [%s] 优先级: %u 运行核心:%d\n", pcTaskGetName(NULL),uxTaskPriorityGet(NULL),xPortGetCoreID());
delay(5000);
}
}
void keyboard_task(void *param_t){
const uint8_t ROWS = 4;
const uint8_t COLS = 4;
char keys[ROWS][COLS] = {
{ '1', '2', '3', 'A' },
{ '4', '5', '6', 'B' },
{ '7', '8', '9', 'C' },
{ '*', '0', '#', 'D' }
};
uint8_t colPins[COLS] = { 7, 6, 5, 4 };
uint8_t rowPins[ROWS] = { 18, 17, 16, 15 };
Keypad keypad = Keypad(makeKeymap(keys), rowPins, colPins, ROWS, COLS);
while(1){
char key = keypad.getKey();
if (key != NO_KEY) {
switch(key){
case '1':
if(xTask==NULL){
xTaskCreatePinnedToCore(mytask, "MY_TASK", 10240, NULL, 6, &xTask, 0);
printf("任务创建成功!\n");
}else{
printf("任务已经创建,不能重复执行!\n");
}
break;
case '2':
if(xTask!=NULL){
// 获得任务优先级,并提升
UBaseType_t rp= uxTaskPriorityGet(xTask);
if(rp+1<=configMAX_PRIORITIES-1){
vTaskPrioritySet(xTask, rp+1);
UBaseType_t np= uxTaskPriorityGet(xTask);
printf("提升任务优先级,从 %d 提升到了 %d\n",rp, np);
}else{
printf("优先级已是最高!\n");
}
}else{
printf("任务还没有创建!\n");
}
break;
case '3':
if(xTask!=NULL){
// 获得任务优先级,并提升
UBaseType_t rp= uxTaskPriorityGet(xTask);
if(rp-1>0){
vTaskPrioritySet(xTask, rp-1);
UBaseType_t np= uxTaskPriorityGet(xTask);
printf("提升任务优先级,从 %d 降低到了 %d\n",rp, np);
}else{
printf("优先级已是最低!\n");
}
}else{
printf("任务还没有创建!\n");
}
break;
case '4':
if(xTask!=NULL){
printf("挂起任务!\n");
vTaskSuspend(xTask);
}else{
printf("任务还没有创建!\n");
}
break;
case '5':
if(xTask!=NULL){
printf("恢复已挂起任务!\n");
vTaskResume(xTask);
}else{
printf("任务还没有创建!\n");
}
break;
case '6':
if(xTask!=NULL){
printf("强制唤醒任务!\n");
if(xTaskAbortDelay(xTask) == pdFAIL){
printf("任务不在阻塞状态,无法唤醒....\n");
}
}else{
printf("任务还没有创建!\n");
}
break;
case '*':
if(xTask!=NULL){
vTaskDelete(xTask);
xTask = NULL;
printf("任务已删除!\n");
}else{
printf("任务还没有创建!\n");
}
break;
case 'A':
traverse();
break;
default:
printMenu();
break;
}
}else{
delay(10);
}
}
}
void printMenu(){
Serial.println("---== 键盘使用规则 ==---");
Serial.println(" 1 : 创建任务");
Serial.println(" 2 : 提升任务优先级");
Serial.println(" 3 : 降低任务优先级");
Serial.println(" 4 : 暂停任务");
Serial.println(" 5 : 恢复任务");
Serial.println(" 6 : 强制唤醒任务");
Serial.println(" * : 删除任务");
Serial.println(" # : 打印任务列表");
Serial.println("请选择按键:");
}
/**
* 按键依次是,创建任务,提升优先级,降低优先级,暂停任务,恢复任务,强制唤醒,删除任务
**/
void setup() {
Serial.begin(115200);
xTaskCreatePinnedToCore(keyboard_task, "Keyboard", 10240, NULL, 4, NULL, 1);
printMenu();
}
// 遍历任务列表,代替vTaskList功能
void traverse(){
// 获得空闲任务,第一个任务
String STATUS[] = {"运行中","就绪","已阻塞","已挂起","已删除"};
// TaskHandle_t xHandle = xTaskGetIdleTaskHandle();
TaskHandle_t xHandle =xLoopMain;
printf("-------------------------------------------------\n");
printf("%-16s %-4s %8s %8s %8s\n","名称","优先级","状态","核心", "剩余内存");
while (xHandle != NULL){
uint32_t core_id = 0;
printf("%-16s %-4d %8s %4d %8d\n",
pcTaskGetName(xHandle), // 获取任务名称
uxTaskPriorityGet(xHandle), // 获取任务优先级
STATUS[eTaskGetState(xHandle)], // 获取任务当前状态
xTaskGetAffinity(xHandle), // 获取任务运行在哪个核心
uxTaskGetStackHighWaterMark(xHandle)); // 获取任务堆栈水位线
xHandle = pxTaskGetNext(xHandle);
}
printf("-------------------------------------------------\n");
}
void loop() {
xLoopMain = xTaskGetCurrentTaskHandle();
// traverse();
delay(1000); // this speeds up the simulation
}
在以往的延时中,我们一直使用 vTaskDelay 这个函数,这个函数传入一个Tick,用于表示延时的时间,模拟器中1ms产生一个Tick,在实际开发中,可以通过调整 portTICK_PERIOD_MS 来设置多少ms产生一个Tick(在IDF开发中,默认是10ms产生一个Tick)。
通过 vTaskDelayUntil 的延时也是精确延时,只不过这个延时指的是在调用 vTaskDelay 时候开始延时,而不是整个大循环的延时,距离说明:
void delay_task(void *params){
while(1){
// 程序将做一些其他的事情
uint32_t i = random(0xFFF,0xFFFF);
while(i>0){
i--;
}
uint32_t tick = xTaskGetTickCount();
printf("当前时间:%d\n", tick);
// 开始延时
vTaskDelay(pdMS_TO_TICKS(1000));
}
}
void setup() {
// put your setup code here, to run once:
Serial.begin(115200);
Serial.println("Hello, ESP32-S3!");
xTaskCreate(delay_task, "DELAY", 10240, NULL, 1, NULL);
vTaskDelete(NULL);
}
void loop() {
}
以上例程代码共享位置:https://wokwi.com/projects/364519955675265025
我们所有任务不只有延时函数,所以在做其他事情的时候有可能耗时长,有可能耗时短,就如上面例子代码中演示的,我们使用随机循环演示了在延时函数调用前程序做的事情,只要加入一些不确定性的操作(即便是if,switch这件简单的操作),程序都会消耗一定时间,这就会影响了延时的精度。
并不是说 vTaskDelay 不精准,相反,它十分精准,是因为我们程序中其他代码影响了整个大循环的延时精度。
如果我们能够计算出延时前的代码的执行时间不就能解决延时问题了吗?
代码共享位置:https://wokwi.com/projects/364521827720147969
void delay_task(void *params){
while(1){
// 程序将做一些其他的事情
TickType_t begin = xTaskGetTickCount();
uint32_t i = random(0xFFF,0xFFFF);
while(i>0){
i--;
}
TickType_t end = xTaskGetTickCount();
TickType_t tick = xTaskGetTickCount();
printf("当前时间:%d\n", tick);
// 开始延时
vTaskDelay(pdMS_TO_TICKS(1000-(end-begin)));
}
}
void setup() {
// put your setup code here, to run once:
Serial.begin(115200);
Serial.println("Hello, ESP32-S3!");
xTaskCreate(delay_task, "DELAY", 10240, NULL, 1, NULL);
vTaskDelete(NULL);
}
void loop() {
}
从结果看,比之前好多了,但是依然不理想,依然没有做到精确延时。
问题出在哪?
代码共享位置:https://wokwi.com/projects/364521186016760833
void delay_task(void *params){
TickType_t xLastWakeTime = xTaskGetTickCount();
while(1){
// 程序做一些其他的事情
uint32_t i = random(0xFFF,0xFFFF);
while(i>0){
i--;
}
TickType_t tick = xTaskGetTickCount();
printf("当前时间:%d\n", tick);
// 精准延时
vTaskDelayUntil(&xLastWakeTime, 1000);
}
}
void setup() {
// put your setup code here, to run once:
Serial.begin(115200);
Serial.println("Hello, ESP32-S3!");
xTaskCreate(delay_task, "DELAY", 10240, NULL, 1, NULL);
vTaskDelete(NULL);
}
void loop() {
}
在大循环开始之前,首先获取一次当前的时间(程序运行的Tick计数),然后开始大循环。
需要延时的时候,将原来的 vTaskDelay 改为 vTaskDelayUntil ,该函数传入2个值,第一个是开始记录的运行时间,第二个值是大循环延时的时长。
vTaskDelayUntil 会对整个大循环做精确的延时,并将本次延时后的时间记录到 xLastWakeTime (就是第一个参数传入的变量)中,因为该值传入的时候使用了指针传递,所以 vTaskDelayUntil 会将本次延时后的时间反写入到这个值中。
之前我们了解过,portTICK_PERIOD_MS 被设置为了1,而 FreeRTOS 最大计数器可以运行50天左右,但50天之后 vTaskDelayUntil 会怎样?但Tick计数器归零的时候,上次记录的时间要远大于当前时间,会不会一直Delay下去?
这一点 FreeRTOS 的作者已经想到,放心使用即可
在 FreeRTOS 中,内存管理是由内核提供的。FreeRTOS 支持多种内存管理方案,包括静态、动态和混合内存分配模式。
静态内存分配:
动态内存分配:
混合内存分配:
FreeRTOS 还提供了一些内存管理相关的 API,包括:
需要注意的是,在使用动态内存分配时,必须在 FreeRTOSConfig.h 中选择一个适合的内存管理方案,并且根据实际情况调整分配的大小。同时,需要十分谨慎地使用动态内存分配,避免内存泄漏和内存碎片等问题。
FreeRTOS 支持 5 种不同类型的堆内存管理方式,分别为 Heap_1 - Heap_5。
Heap_1: 采用线性查找的方式进行内存块的管理。该堆管理方式比较简单,适合在内存资源不充足、任务数量较少的场景下使用。但是,在任务数量较多的情况下,由于需要线性遍历内存块,这种方式容易导致系统运行效率低下。
Heap_2: 采用位图算法对内存块进行管理。该方式相对于 Heap_1 的线性查找方式,在任务数量较多的情况下更加高效。其实现原理是将每个内存块的使用情况保存到一片固定大小的内存中,用位图来描述每个内存块的使用情况。
Heap_3: 是 Heap_2 的改进版本,支持内存块合并功能。在释放内存块时,会检查相邻的内存块是否可合并,从而减少内存碎片的产生。相对于 Heap_2,Heap_3 可以更加有效地利用内存资源。
Heap_4: 采用二叉树结构进行内存块的管理。该方式综合了 Heap_2 和 Heap_3 的优点,可以快速定位内存块和采用内存块合并的方式。此外,Heap_4 还可以进行内存块的分裂操作,即当一个较大的内存块被分配时,可以将它分割成多个较小的内存块,从而提高内存利用率。
Heap_5: 是一种可选的动态内存分配实现方式,允许用户自定义内存分配和释放函数。这种方式需要用户提供一些管理数据结构和操作函数,并在 FreeRTOSConfig.h 中指定使用哪种堆内存管理方式。
不同的内存管理方式会带来不同的内存性能和效率,选择合适的内存管理方式需要考虑实际应用场景和硬件条件,ESP32-S3默认使用了Heap_4的内存管理方式。
Heap_4 是一种基于二叉树结构的堆内存管理方式,它是 FreeRTOS 中默认的堆内存管理方式,并且在许多应用场景下都表现出了优秀的性能和可靠性。下面对 Heap_4 的主要特点和实现原理进行详细介绍。
1. 二叉树结构
Heap_4 使用二叉树结构来管理内存块,每个内存块都是一个节点,内存块之间通过指针相连,组成了一颗二叉树。具体来说,Heap_4 中的节点包含了以下几个字段:
2. 块合并和分裂
Heap_4 中,当某个内存块被释放时,系统会检查其前后相邻的内存块是否也是空闲的,并将它们合并成一个更大的内存块。这样做有助于解决内存碎片问题,提高内存利用率。另外,Heap_4 还支持内存块的分裂操作,当一个内存块被分配时,系统会将其分裂成更小的内存块,以便更好地管理内存。
3. 内存对齐
在 Heap_4 中,为了避免内存碎片和提高内存利用率,系统会对分配出的内存块进行内存对齐操作。具体来说,Heap_4 中的内存块大小通常是 8 的倍数,并且分配的内存地址总是 8 的倍数。这样做可以保证内存之间没有空隙,从而最大限度地节省内存空间。
Heap_4 的性能和可靠性都非常优秀,而且支持内存块合并和分裂操作,能够较好地处理内存碎片问题,因此在许多应用场景下被广泛使用。不过,在一些特殊的应用场景下,Heap_4 可能会出现一些问题,例如堆内存的分配耗时较长、内存利用率不够高等。因此,在选择堆内存管理方式时,需要根据实际应用场景和硬件条件进行选择和优化。
如果向详细了解内存管理的方式,请阅读:https://blog.csdn.net/o0onlylove0o/article/details/127720172
如果在Arduino中,可是使用 ESP.getHeapSize() 和 ESP.getFreeHeap() 查看内存的总空间和已经使用的空间,但在ESP-IDF中,这两个函数是无法使用的,预支代替的是 configTOTAL_HEAP_SIZE 和 *xPortGetFreeHeapSize() , 第一个是宏定义,测试后发现不是太好用。
代码共享位置:https://wokwi.com/projects/364588324630753281
void task(void *params){
while(1){
delay(100);
}
}
void setup() {
Serial.begin(115200);
Serial.println("Hello, ESP32-S3!");
TaskHandle_t t;
int heapSize = ESP.getHeapSize(); // 获得现有内存大小
printf("ESP32 总内存大小:%dbyte\n", heapSize);
int before = ESP.getFreeHeap(); // 创建任务之前剩余内存大小
xTaskCreate(task, "TASK", 1024, NULL, 1, &t);
int after = ESP.getFreeHeap(); // 创建任务之前剩余内存大小
printf("创建任务前剩余内存大小:%dbyte, 创建任务之后剩余内存大小:%dbyte,使用了:%dbyte\n",before, after, (before-after));
printf("TCB 控制块大小:%dbyte\n", sizeof(StaticTask_t));
printf("新创建后内存剩余:%dbyte\n", uxTaskGetStackHighWaterMark(t));
vTaskDelete(NULL);
}
void loop() {
}
在IDF开发中代码如下:
#include <stdio.h>
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
#include <freertos/portable.h>
void loop(void);
void task(void *param_t)
{
while (1)
{
vTaskDelay(100);
}
}
void app_main(void)
{
// size_t totalHeapSize = (size_t)configTOTAL_HEAP_SIZE;
// printf("系统总内存空间大小:%dbyte\n", totalHeapSize );
TaskHandle_t t;
int before = xPortGetFreeHeapSize(); // 创建任务之前剩余内存大小
xTaskCreate(task, "TASK", 1024, NULL, 1, &t);
int after = xPortGetFreeHeapSize(); // 创建任务之前剩余内存大小
printf("创建任务前剩余内存大小:%dbyte, 创建任务之后剩余内存大小:%dbyte,使用了:%dbyte\n",before, after, (before-after));
printf("TCB 控制块大小:%dbyte\n", sizeof(StaticTask_t));
printf("新创建后内存剩余:%dbyte\n", uxTaskGetStackHighWaterMark(t));
vTaskDelete(NULL);
}
经过测试,Arduino中,任务控制块大小为344字节,而新建一个任务后占用系统总空间是1400个字节,剔除任务栈本身的1024个字节,以及任务控制款344字节,还有32个字节的,这32个字节中,有8个字节是被 before和 after占用的,剩余24个字节应该是Arduino消耗的,具体干什么不知道。
这一点我们通过IDF编程中可以算出:通用的代码,任务创建之后只占用了1384个字节,而在这里面任务控制块的大小是352个字节(因为配置项不同导致),1384-1024-352=8,正好是 before和 after占用的那八个字节。
所以,为什么我们最终还得学习使用IDF编程——因为Arduino消耗资源太多,虽然开发速度快,但程序过于臃肿,在实际业务开发中大多不会选择它。
通过 uxTaskGetStackHighWaterMark 查看任务创建后内存使用的最高水位线,根据官方指导建议,在程序运行一段时间后进行测试,我们尽可能的设置内存大小是最高水位线的2倍,但保守一点,可以设置最高水位线的120%~150%即可,寸土寸金,省点是点。
通过查看最后剩余内存,Arduino中得数是 256,IDF中剩余内存是336,还是原生的更胜一筹。
但为什么新创建的任务就已经消耗了 768 字节的内存了呢?
在任务创建后,在任务的栈空间中会存放一些任务的初始化数据,主要包含以下内容:
当有其他任务需要获得 CPU 的执行权时,FreeRTOS 的调度器会根据任务优先级和调度算法选择合适的任务进行执行,并将当前任务的上下文切换出去,保存至其自己的任务栈中。而当该任务再次获得执行权时,调度器则会从该任务的任务栈中恢复上下文,将寄存器和状态等信息恢复到切换出去时的状态,使其能够继续运行。
有些教程中讲到,任务栈空间内开始部分存放的是任务控制块,这是不对的,因为任务控制块只创建一次,对于动态任务,任务控制块倍存放在总的内存堆中(Heap),与任务的栈空间根本不在一块,对于静态任务,任务控制块存放位置取决于作用域,也不再本任务的栈空间中,这一点必须要正确理解。
在项目开发中,除了要关心内存的消耗,CPU的消耗也是非常关键的考核点,但在Arduino中,我们并不能直观的查看CPU使用率(就是不能),在 ESP-IDF 开发中,可以通过使用 FreeRTOS 的任务状态查询函数和计时器来实现 CPU 占用率的统计,以及每个任务占用 CPU 的百分比。而这些,需要在FreeRTOSConfig.h中打开 configUSE_TRACE_FACILITY,如果是在Menuconfig中,可以设置打开 Component config -> FreeRTOS -> Enable FreeRTOS trace facility 实现。
FreeRTOS操作系统是使用任务的累计运行时间来统计每一个任务自系统开始运行到当前时刻的CPU占用时间,即该任务的CPU使用率。
可能听起来比较难以理解,比如:系统上电到当前时刻一共运行了100s,其中任务A运行了1s,任务B运行了2s,剩下的97s由空闲任务在运行,那么在10s的时间内,任务A的CPU使用率是1%,任务B的CPU使用率是2%,空闲任务的CPU使用率是97%。
下图是FreeRTOS系统CPU使用率统计示意图:
在IDF编程中,我们可以通过使用 vTaskGetRunTimeStats 函数来输出CPU使用率,这个函数和 vTaskList 函数一样,都属于统计类函数,在正式环境中如果没有必要尽量将其关闭,这样可以提升CPU的使用性能,打开这个函数需要将 FreeRTOSConfig.中的 configGENERATE_RUN_TIME_STATS 和 configUSE_TRACE_FACILITY 都打开,在Menuconfig中,可以设置打开 Component config -> FreeRTOS -> Enable FreeRTOS to collect run time stats实现。
#include <stdio.h>
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
static void task_1(void *arg)
{
while(1) {
vTaskDelay(pdMS_TO_TICKS(100));
}
}
static void task_2(void *arg)
{
while(1) {
vTaskDelay(pdMS_TO_TICKS(50));
}
}
void app_main()
{
xTaskCreate(task_1, "task_1", 1024, NULL, 1, NULL);
xTaskCreate(task_2, "task_2", 1024, NULL, 2, NULL);
char buffer[1024];
while(1){
vTaskGetRunTimeStats(buffer);
printf("%-18s %-18s %-18s\n", "任务名", "运行计数", "CPU占用率");
printf("%s\n",buffer);
vTaskDelay(pdMS_TO_TICKS(2000));
}
vTaskDelete(NULL);
}
输出三列内容依次是任务名称,运行占用CPU的周期数,CPU统计占用率。
该任务主要查看每个核心中 IDLE 任务的运行效率,如果 IDLE 任务占用 CPU 时间 较大,说明 CPU 比较空闲,如果 IDLE 小于90%(长时间小于90%),这需要考虑对程序进行优化。