ESP32-Arduino中的FreeRTOS使用

在platformio中不需要引入FreeRTOS的头文件,直接可用

FreeRTOS使用第一步:任务的创建与删除

下面的代码启用了两个任务,并且在执行10次之后进行删除,如果不删除的话,你们直接使用while(1)在里面循环。

void task1( void * parameter )
{
    for( int i = 0;i<10;i++ ){
        Serial.println("Hello from task 1");
        delay(1000);
    }
    Serial.println("删除task1");
    vTaskDelete( NULL );  //任务的删除
}
 
  
 
void task2( void * parameter)
{
    for( int i = 0;i<10;i++ ){
        Serial.println("Hello from task 2");
        delay(1000);
    }
    Serial.println("删除task2");
    vTaskDelete( NULL );  //任务的删除
}

void setup() {
  Serial.begin(115200);
    xTaskCreate(
                    task1,          //指定任务函数,也就是上面那个task1函数
                    "TaskOne",        //任务名称
                    10000,            //任务堆栈大小
                    NULL,             //作为任务输入传递的参数
                    1,                //优先级
                    NULL);            //任务句柄
 
  xTaskCreate(
                    task2,         
                    "TaskTwo",        
                    10000,            
                    NULL,             
                    1,               
                    NULL); 
}


void loop() {
  delay(1);
}

这里的任务创建使用了xTaskCreate函数,它一般在单核上使用,由于ESP32是双核的,接下来我会在两个核上创建简单任务让他们跑起来。

接下来介绍下xTaskCreatePinnedToCore 这个函数

这个函数就是在创建任务,而传入的参数上面的代码注释里面已经有了,最最注意的是最后一个参数,他有几种选择,esp32 的 FreeRTOS 是设计运行在单核上. 但 ESP32 是双核的,包含 Protocol CPU (称为 CPU 0 或PRO_CPU)和 Application CPU (称为 CPU 1 或 APP_CPU). 这两个核实际上是相同的,并且共享相同的内存. 这允许任务在两个核之间交替运行,而这里最后一个参数是 xCoreID.此参数指定任务运行在那个核上. PRO_CPU 为 0, APP_CPU 为 1,或者 tskNO_AFFINITY 允许任务在两者上运行

 还有几个官方提示应该注意的点

1.为了避免esp32 的FreeRTOS 调度器将在 Ready 状态下具有相同优先级的多个任务之间实施循环调度时跳过任务.我们最好是不要让自己的多个任务有相同的优先级

2.挂起调度器:在 ESP-IDF 中挂起调度器 FreeRTOS 只会影响调用核上的调度器.换句话说,在 PRO_CPU 上调用 vTaskSuspendAll() 不会阻止 APP_CPU 进行调度,反之亦然.使用临界区或信号量代替同时访问保护。他的意思应该就是两个核的任务调度各自独立,具有独立性。

3.滴答中断同步:PRO_CPU 和 APP_CPU 的滴答中断不同步. 不要期望使用 vTaskDelay() 或 vTaskDelayUntil() 作为在两个核之间同步任务执行的准确方法. 使用计数信号量,因为它们的上下文切换不会因抢占而与滴答中断相关联. 这个的意思我觉得应该是告诉我们,不要指望通过延时(这个延时会把CPU让出来让给目前合适的优先级的任务去运行)来让两个CPU长时间精确同步,简而言之,可能是这张情况,比如你分别在两个核创建了一个任务,都是每隔1000ms让LED灯翻转一次,那么开始的时候还能看到两个灯的同步闪烁,但是时间一积累就会发现由于滴答中断不同步而导致两个核运行节奏不一样,开始交替闪烁

4.vTaskDelete() 仍然是任务删除函数

5.浮点运算:ESP32 支持单精度浮点运算 (float) 的硬件加速.然而,硬件加速的使用导致 ESP-IDF FreeRTOS 中的一些行为限制.因此,如果没有这样做,使用 float 的任务将自动固定到核.此外, float 不能用于中断服务程序.

6.临界区和禁用中断:在 ESP-IDF FreeRTOS 中,临界区是使用互斥锁实现的.进入临界区涉及获取互斥锁,然后禁用调度器和调用核的中断.然而,另一个核不受影响.如果另一个核尝试使用相同的互斥锁,它将自旋直到调用核通过退出临界区释放互斥锁。这个是锁以及一些信号量之类的内容后面还会更用法,其目的就是资源分配,大家应该有死锁的基本概念,就是两个任务各自占有对方想要的资源但是两个任务一不愿意放手目前手中的资源,二想抢夺对方手里的资源好让自己运行下去而导致两个任务都停滞的情况,这些东西存在的目地基本就是避免这些情况的,合理高效分配内部资源用的

 接下来讲解下xTaskCreatePinnedToCore这个函数的参数讲解:

xTaskCreatePinnedToCore(task,"task_name",stack_depth,some_param,priority,handler,core_id);

字段

含义

task

任务指针,创建任务用的那个函数名

task_name

任务名称

stack_depth

栈空间,根据任务类型设置大小,空间不够会有串口debug报错

some_param

可选参数,一般不填

priority

优先级

handler

任务句柄,用于后续挂起或恢复运行

core_id

核ID,esp32共两个核,Protocol CPU(0) 和 Application CPU(1)

这里顺便讲解下FreeRTOS任务的一些简单函数:

挂起任务

vTaskSuspend(handler);
  • handler是那个任务的句柄

恢复任务

vTaskResume(handler);

延时

vTaskDelay(1000);  //ms

讲到了任务就不得不讲一下队列:

队列是什么?
队列是可以从一个任务向其他任务以并发安全的方式发送消息的机制,也就是说他的目标是实现任务间的通讯,比如A任务向名为Queue的队列中发布了数据,那么B任务就可以从Queue这个队列中又把数据给取出来,并且,这个数据是复制式的,也就是说把数据复制一份送入队列,B任务取出后对原来A的数据完全不会造成影响(实际上是形象的描述,本质上是通过一系列指针实现的),好了知道一些概念就行了,重要的是知道怎么用。

话不多说上代码:

#include <Arduino.h>
#include <soc/soc.h> 
#include <soc/rtc_cntl_reg.h>
#include <stdio.h>
#include <stdlib.h>

//这是队列数据结构体,数据是可以以结构体存在的
typedef struct{
  int sender;
  char *msg;
}Data;

//以下句柄名字以x开头,用作双核版本的队列测试
xQueueHandle xqueue0;  //创建的测试队列句柄,我们定义数据为int型
xQueueHandle xqueue1;  //创建的测试队列句柄,我们定义数据为字符串型
xQueueHandle xqueue2;  //创建的测试队列句柄,我们定义数据为结构体型,结构体里面包括一个int型以及一个字符串型
TaskHandle_t xTask0;   //任务0的句柄
TaskHandle_t xTask1;   //任务1的句柄

void Task1(void *pvParameters) {
  //在这里可以添加一些代码,这样的话这个任务执行时会先执行一次这里的内容(当然后面进入while循环之后不会再执行这部分了)
  Data data_send;  //创建一个数据结构体用于发送数据
  data_send.sender = 1314;  //这个数据结构体的整数直接赋值为1314
  BaseType_t xStatus;  //用于状态返回在下面会用到
  BaseType_t xStatus1;
  BaseType_t xStatus2;
  int send_int = 615;
  char send_str[10] = "cx??";
  const TickType_t xTicksToWait = pdMS_TO_TICKS(100);   // 阻止任务的时间,直到队列有空闲空间 ,应该是如果发送需要阻滞等待(比如队列满了)或者别的情况需要用到的
  while(1)
  {
    vTaskDelay(1200);
    Serial.print("PRO_CPU正在运行:");
    Serial.println(xPortGetCoreID());
    Serial.print("发送任务固定在: ");
    Serial.println(xTaskGetAffinity(xTask0));    //获取任务被固定到哪里,xTask1 就是 Task0任务本身的句柄
    data_send.msg = (char *)malloc(20);  //分配所需的内存空间,并返回一个指向它的指针,里面传入的参数是SIZE。
    memset(data_send.msg, 0, 20);  //清空这个data_send,也就是上面分配的这个空间
    // 从存储区 str2 复制 n 个字节到存储区 str1。 str2就是"hello world" ,str1就是data_send.msg , strlen("hello world")就是n
    memcpy(data_send.msg, "hello world", strlen("hello world"));  
    xStatus = xQueueSendToFront( xqueue2, &data_send, xTicksToWait );  //发送data_send这个数据结构体到 xqueue2 队列
    
    xStatus1 = xQueueSendToFront( xqueue0, &send_int, xTicksToWait );  //发送send_int这个数据结构体到 xqueue0 队列
    xStatus2 = xQueueSendToFront( xqueue1, &send_str, xTicksToWait );  //发送send_str这个数据结构体到 xqueue1 队列
    if( xStatus == pdPASS && xStatus1 == pdPASS && xStatus2 == pdPASS) {
      Serial.println("send data OK");  // 发送正常 
    }
    Serial.println("******************************************************************");
  }
}
 
void Task2(void *pvParameters) {
  //  这些变量作用与上面相同,只是这里的data_get就是我们从队列里面获取的东西了
  BaseType_t xStatus;
  BaseType_t xStatus1;
  BaseType_t xStatus2;
  const TickType_t xTicksToWait = pdMS_TO_TICKS(50);  //这里就是用于取数据阻塞了,我觉得本来这种收发就不可能同步,但是应该接收来满足发送,接收速度大于发送速度才行
  Data data_get;
  int get_int;
  char get_str[10];
  while(1)
  {
    vTaskDelay(800);
    Serial.print("APP_CPU正在运行:");
    Serial.println(xPortGetCoreID());
    Serial.print("获取任务固定在:");
    Serial.println(xTaskGetAffinity(xTask1));
    xStatus = xQueueReceive( xqueue2, &data_get, xTicksToWait );  //从队列2中取一条数据
    xStatus = xQueueReceive( xqueue0, &get_int, xTicksToWait );
    xStatus = xQueueReceive( xqueue1, &get_str, xTicksToWait );
    if(xStatus == pdPASS){
      free(data_get.msg);  //释放数据结构体的字符串部分的空间
      Serial.print("获取结构体数据整数部分:");
      Serial.println(data_get.sender);
      Serial.print("获取结构体数据字符串部分:");
      Serial.println(data_get.msg);
      Serial.print("队列零的整数获取:");
      Serial.println(get_int);
      Serial.print("队列1的字符串获取:");
      Serial.println(get_str);
    }
    Serial.println("******************************************************************");
  }
}

void setup() {
  WRITE_PERI_REG(RTC_CNTL_BROWN_OUT_REG, 0);//关闭低电压检测,避免无限重启
  Serial.begin(112500);
  delay(1000);
  Serial.print("获取Setup的优先级: ");
  Serial.println(uxTaskPriorityGet(NULL));  //优先级获取函数
  
  xqueue0 = xQueueCreate( 10, sizeof( int ) );
  xqueue1 = xQueueCreate( 10, sizeof( char[10] ) );
  xqueue2 = xQueueCreate(10, sizeof(Data));
  
  xTaskCreatePinnedToCore(Task1, "Task1", 10000, NULL, 11, &xTask0,  0);  //最后一个参数至关重要,决定这个任务创建在哪个核上.PRO_CPU 为 0, APP_CPU 为 1,或者 tskNO_AFFINITY 允许任务在两者上运行.
  xTaskCreatePinnedToCore(Task2, "Task2", 10000, NULL, 12, &xTask1,  1);  //xTaskGetAffinity(xTask1)  是可以查询到任务被固定到哪里的
  Serial.print("获取xTask0的优先级: ");
  Serial.println(uxTaskPriorityGet(xTask0));  //优先级获取函数
  Serial.print("获取xTask1的优先级: ");
  Serial.println(uxTaskPriorityGet(xTask1));  //优先级获取函数
  if(xqueue0 != NULL && xqueue1 != NULL && xqueue2 != NULL) Serial.println("开始队列测试!!!");
  Serial.println("******************************************************************");
}
 
void loop() {
}

先说说上面这段代码实现了什么
我分别在两个核上创建了一个任务,一个用于向队列发送数据,一个用于向队列接收数据,而队列,我创建了三个,一个是int型的,一个是char[10],也就是定长字符串型的,还有一个是结构体型,没错,队列不仅可以实现单类型,还可以实现结构体型,结构体里面你可以自己选择用那些形式的数据.

队列的创建与初始化

xQueueHandle xqueue2;  //创建队列的句柄,详细看上面的代码
xqueue2 = xQueueCreate(10, sizeof(Data));  //这个是初始化队列,Data就是队列的数据类型,允许结构体型
//这个是从队列中读取,xqueue2是队列的句柄,data_get是我们传入的数据,xTicksToWait 是等待时间
xStatus = xQueueReceive( xqueue2, &data_get, xTicksToWait ); 
//下面这个是发送的,传入参数与上面类似
xStatus = xQueueSendToFront( xqueue2, &data_send, xTicksToWait );

上面写的是双核版的队列测试,接下来写一下单核版本的,实现起来也比双核的要简单,也便于我们写STM32上的FreeRTOS,话不多说,上代码:

#include <Arduino.h>
QueueHandle_t queue;    //单核多进程测试队列,这是队列的句柄
TaskHandle_t Task1;   //任务0的句柄
TaskHandle_t Task2;

void task1( void * parameter )
{
  int data_get = 520;
  BaseType_t Status;
  const TickType_t xTicksToWait = pdMS_TO_TICKS(100);
    while(1)
    {
      vTaskDelay(800);
      Status = xQueueReceive(queue, &data_get, xTicksToWait);  //单核调用队列接收的函数
      if(Status == pdPASS)
      {
        Serial.print("成功收到数据:");
        Serial.println(data_get);
      }
      Serial.print("接收数据任务的优先级:");
      Serial.println(uxTaskPriorityGet(Task1));  // 就是获取任务的优先级,而传入的参数就是任务的句柄
      Serial.println("***********************************************************");
    }
}
 
  
 
void task2( void * parameter)
{
  int data_send = 625;
  BaseType_t Status;
  const TickType_t xTicksToWait = pdMS_TO_TICKS(200);
    while(1)
    {
      vTaskDelay(1200);  //每1200ms向队列中发送一次数据
      Status = xQueueSend(queue, &data_send, xTicksToWait);  //向队列中发送数据
      if(Status == pdPASS)
      {
        Serial.print("成功发送数据:");
      }
      Serial.print("发送数据任务的优先级:");
      Serial.println(uxTaskPriorityGet(Task2));
      Serial.println("***********************************************************");
    }
}


void setup() {
  Serial.begin(115200);
  queue = xQueueCreate( 10, sizeof( int ) );  //创建一个队列,用的是整数型
  //下面是创建两个任务
  xTaskCreate(
                    task1,          //指定任务函数,也就是上面那个task1函数
                    "TaskOne",        //任务名称
                    10000,            //任务堆栈大小
                    NULL,             //作为任务输入传递的参数
                    1,                //优先级
                    &Task1);            //任务句柄,可以不用创建,直接用NULL
 
  xTaskCreate(
                    task2,         
                    "TaskTwo",        
                    10000,            
                    NULL,             
                    3,               
                    &Task2); 
  if(queue == NULL){
    Serial.println("创建队列失败");
  }
}
  
void loop() {
  Serial.println("主程序仍然在运行");
  delay(1000);
}

以上就是一些FreeRTOS的基础运用,接下来补上一些FreeRTOS的编码标准和命名风格。

1. FreeRTOS 的编码标准

FreeRTOS 核心源码文件的编写遵循 MISRA 代码规则,同时支持各种编译器。但考虑到有些编译器的性能还比较弱,不支持 C 语言的新标准 C99 和 C11 的一些特性和语法,所以 FreeRTOS 的源码中就没有引入 C99 和 C11 的新特性,但是有一个例外,源码中有用到头文件 stdint.h(这个文件是C99标准才引入的)。

2. FreeRTOS 的命名规则

变量

  1. uint32_t 定义的变量都加上前缀 ul。 u 代表 unsigned 无符号,l 代表 long 长整型。
  2. uint16_t 定义的变量都加上前缀 us。 u 代表 unsigned 无符号,s 代表 short 短整型。
  3. uint8_t 定义的变量都加上前缀 uc。 u 代表 unsigned 无符号,c 代表 char 字符型。
  4. stdint.h 文件中未定义的变量类型,在定义变量时需要加上前缀 x,比如 BaseType_t 和
  5. TickType_t 定义的变量。
  6. stdint.h 文件中未定义的无符号变量类型,在定义变量时要加上前缀 u,比如 UBaseType_t 定义
  7. 的变量要加上前缀 ux。
  8. size_t 定义的变量也要加上前缀 ux。
  9. 枚举变量会加上前缀 e。
  10. 指针变量会加上前缀 p,比如 uint16_t 定义的指针变量会加上前缀 pus。
  11. 根据 MISRA 代码规则,char 定义的变量只能用于 ASCII 字符,前缀使用 c。
  12. 根据 MISRA 代码规则,char *定义的指针变量只能用于 ASCII 字符串,前缀使用 pc。


函数

  1. 加上了 static 声明的函数,定义时要加上前缀 prv,这个是单词 private 的缩写。
  2. 带有返回值的函数,根据返回值的数据类型,加上相应的前缀,如果没有返回值,即 void 类型,函数的前缀加上字母 v。
  3. 根据文件名,文件中相应的函数定义时也将文件名加到函数命名中,比如 tasks.c 文件中函数vTaskDelete,函数中的 task 就是文件名中的 task。

宏定义

  1. 根据宏定义所在的文件,文件中的宏定义声明时也将文件名加到宏定义中,比如宏定义configUSE_PREEMPTION 是定义在文件 FreeRTOSConfig.h 里面。 宏定义中的 config 就是文件名中的 config。 另外注意,前缀要小写。
  2. 除了前缀,其余部分全部大写,同时用下划线分开。

 3. FreeRTOS 中数据类型

  • FreeRTOS 使用的数据类型主要分为 stdint.h 文件中定义的和自己定义的两种。 其中 char 和 char *定义的变量要特别注意。
  • FreeRTOS 主要自定义了以下四种数据类型:
  • TickType_t

        如果用户使能了宏定义 configUSE_16_BIT_TICKS,那么 TickType_t 定义的就是 16         位无符号数,如果没有使能,那么 TickType_t 定义的 就是 32 位无符号数。 对于 32 位 架构的处理器,一定要禁止此宏定义,即设置此宏定义数值为 0 即可。

  • BaseType_t

        这个数据类型根据系统架构的位数而定,对于 32 位架构,BaseType_t 定义的是 32 位有符号数,对

于 16 位架构,BaseType_t 定义的是 16 位有符号数。 如果 BaseType_t 被定义成了 char 型,要特别

注意将其设置为有符号数,因为部分函数的返回值是用负数来表示错误类型。

  • UBaseType_t

        这个数据类型是 BaseType_t 类型的有符号版本。StackType_t栈变量数据类型定义,这个数量类型由系统架构决定,对于 16 位系统架构,StackType_t 定义的是16 位变量,对于 32 位系统架构,StackType_t 定义的是 32 位变量。