S1-02 FreeRTOS线程控制

发布时间:2024年01月11日

回顾

上节课讲到Android的安装,以及在Android上运行普通的Super Loop程序,也初步一睹FreeRTOS的风采。
通过多线程构建了一个让LED灯闪烁的三个线程的程序。
也通过程序初步了解了串口打印。

本节课的学习任务

  1. 熟悉FreeRTOS中的多线程相关操作
  2. 熟悉各种参数传入的方式
  3. 了解两个创建、一个延迟以及一个删除函数。

将点灯程序改为单参数传入的

共享代码位置:https://wokwi.com/projects/362410134939151361

byte LED1_PIN = 4;
byte LED2_PIN = 5;
byte LED3_PIN = 6;
// 第一个任务,控制红灯,每1秒亮灭一次
void task1(void *param_t){
  // 从参数中取出掺入的Pin端口指针
  byte *pin_p = (uint8_t *)param_t;
  // 从指针中提取出内容
  byte pin = *pin_p;
  pinMode(pin, OUTPUT);
  while(1){
    // 先读取引脚的高低电平,然后翻转,最后重新设置给这个引脚
    digitalWrite(pin, !digitalRead(pin));
    // 等待1秒钟
    vTaskDelay(1000/portTICK_PERIOD_MS);
  }
}
// 第二个任务,控制绿灯,每2秒亮灭一次
void task2(void *param_t){
  // 直接提取内容
  byte pin = *(uint8_t *)param_t;
  pinMode(pin, OUTPUT);
  while(1){
    // 先读取引脚的高低电平,然后翻转,最后重新设置给这个引脚
    digitalWrite(pin, !digitalRead(pin));
    vTaskDelay(2000/portTICK_PERIOD_MS);
  }
}
// 第三个任务,控制蓝灯,每3秒亮灭一次
void task3(void *param_t){
  byte pin = *(uint8_t *)param_t;
  pinMode(pin, OUTPUT);
  while(1){
    // 先读取引脚的高低电平,然后翻转,最后重新设置给这个引脚
    digitalWrite(pin, !digitalRead(pin));
    vTaskDelay(3000/portTICK_PERIOD_MS);
  }
}
void setup() {
  Serial.begin(115200);
  xTaskCreate(task1, "Blink Red",1024,&LED1_PIN,1,NULL);
  Serial.println("第一个任务被创建,将控制红灯每秒亮灭一次");
  xTaskCreate(task2, "Blink Green",1024,&LED2_PIN,1,NULL);
  Serial.println("第二个任务被创建,将控制红灯每2秒亮灭一次");
  xTaskCreate(task3, "Blink Blue",1024,&LED3_PIN,1,NULL);
  Serial.println("第三个任务被创建,将控制红灯每3秒亮灭一次");
}
void loop() {
 
}

这段代码中我们首先定义了三个byte类型的变量,用于保存引脚编号。
然后依然延续我们上一节中的点灯任务,一共三个任务,但不同之处在于,我们通过参数的方式将引脚传入。
xTaskCreate(task1, “Blink Red”,1024,&LED1_PIN,1,NULL);
传入的时候一定要通过取址符(&)以指针方式传入。
在任务入口函数中,通过

byte *pin_p = (uint8_t *)param_t;

将void* 类型指针转换为byte * 类型,然后在通过

byte pin = *pin_p;

方式将pin_p指针中的内容取出,操作比较复杂,大家一定要领会 & 和 *的不同之处
这行可以缩写成

byte pin = *(uint8_t *)param_t;

回顾指针和引用

指针运算符 &
引用运算符 *
代码共享位置:https://wokwi.com/projects/362434637117039617

void setup() {
  // put your setup code here, to run once:
  Serial.begin(115200);
  Serial.println("Hello, ESP32-S3!");
  uint16_t val_a = 10;    // 定义一个变量
  uint16_t val_b = 20;    // 定义第二个变量
  uint16_t *val_p = &val_a;   // 声明一个指针,这里可以不赋值,而&val_a 表示将变量 val_a 的地址付给这个指针
  val_p = &val_b;             // 指针类型的是可以二次赋值的
  uint16_t &val_c = val_b;    // 引用类型的变量相当于给变量起了个别名,他们的地址是相同的
  printf(" val_a= %d\n", val_a);
  printf(" val_b= %d\n", val_b);
  printf(" val_p= 0x%X\n", val_p);    // val_p 的值是val_b 的地址
  printf("&val_p= 0x%X\n", &val_p);   // 但val_p的地址和val_b是不同的
  printf("&val_b= 0x%X\n", &val_b);   // val_b 的地址和 val_c的地址是相同的
  printf("&val_c= 0x%X\n", &val_c);
  printf(" val_b= %d\n", val_b);
  printf(" val_c= %d\n", val_c);      // val_b的值和val_c的值也是相同的
}
void loop() {
  // put your main code here, to run repeatedly:
  delay(10); // this speeds up the simulation
}
void printf(String format, ...) {
  char buffer[128]; // 创建缓冲区
  va_list args; // 定义可变参数列表
  va_start(args, format); // 初始化可变参数列表
  vsnprintf(buffer, sizeof(buffer), format.c_str(), args); // 格式化输出到缓冲区
  va_end(args); // 结束可变参数列表
  Serial.print(buffer); // 输出到串口
}

以上例子说明了指针和引用的区别,
指针变量有自己独立的地址,他所指向地址中的内容和变量的内容是相同的,但指针的地址和变量的地址不同
引用就相当于给变量起了个别名,引用的地址和原始变量的地址是相同的

下面的例子中,演示了参数传递的三种方式,我们在项目开发中,需要根据实际情况选择传值方式。
代码共享位置:https://wokwi.com/projects/362436365397929985

void setup() {
  // put your setup code here, to run once:
  Serial.begin(115200);
  Serial.println("Hello, ESP32-S3!");
  uint16_t a = 10;    // 定义一个变量
  uint16_t b = 20;    // 定义第二个变量
  printf("-> 交换前A和B的地址分别是:%X , %X\n",&a, &b);
  printf("-> 交换前A和B的值分别是:%d , %d\n", a, b);
  swap_1(a, b);
  // swap_2(&a, &b);
  // swap_3(a, b);
  printf("-> 交换后A和B的地址分别是:%X , %X\n",&a, &b);
  printf("-> 交换后A和B的值分别是:%d , %d\n", a, b);
}
void loop() {
  // put your main code here, to run repeatedly:
  delay(10); // this speeds up the simulation
}
// 第一个交换函数,按值传递
void swap_1(uint16_t a, uint16_t b){
  printf("交换前A和B的地址分别是:%X , %X\n",&a, &b);
  printf("交换前A和B的值分别是:%d , %d\n", a, b);
  uint16_t t=a;
  a=b;
  b=t;
  printf("交换后A和B的地址分别是:%X , %X\n",&a, &b);
  printf("交换后A和B的值分别是:%d , %d\n", a, b);
}
// 第二个交换函数,按地址传递
void swap_2(uint16_t *a, uint16_t *b){
  printf("交换前A和B的地址分别是:%X , %X\n",a, b);
  printf("交换前A和B的值分别是:%d , %d\n", *a, *b);
  uint16_t t=*a;
  *a=*b;
  *b=t;
  printf("交换后A和B的地址分别是:%X , %X\n",a, b);
  printf("交换后A和B的值分别是:%d , %d\n", *a, *b);
}
// 第三个交换函数,按引用传递
void swap_3(uint16_t &a, uint16_t &b){
  printf("交换前A和B的地址分别是:%X , %X\n",&a, &b);
  printf("交换前A和B的值分别是:%d , %d\n", a, b);
  uint16_t t=a;
  a=b;
  b=t;
  printf("交换后A和B的地址分别是:%X , %X\n",&a, &b);
  printf("交换后A和B的值分别是:%d , %d\n", a, b);
}
void printf(String format, ...) {
  char buffer[128]; // 创建缓冲区
  va_list args; // 定义可变参数列表
  va_start(args, format); // 初始化可变参数列表
  vsnprintf(buffer, sizeof(buffer), format.c_str(), args); // 格式化输出到缓冲区
  va_end(args); // 结束可变参数列表
  Serial.print(buffer); // 输出到串口
}

代码中swap_1是按值传递的函数,传入之前变量的地址和参数中ab的地址是不同的,说明他们是两个不同的变量,在函数中我们对两个变量进行了交换,可以看到其实在函数中变量已经被交换过来了,但出了函数之后外面的变量并没有交换。
代码中swap_2是按指针传递的,所以在调用函数的时候我们需要用取址符(&)取得两个变量的地址,在传入到函数中a和b的地址与外面的地址是相同的,所以我们对a和b进行操作的时候外面的值也会有相应的变化。
代码中swap_3的函数体基本和swap_1是一样的,知识在形参上有些改变,给变量加了一个&表示引用,而我们调用函数的方法和swap_1也是相同的,但传入到函数体中后,我们可以看到,函数内参数的地址和外面a b的地址是一样的,也就意味着,我们在函数体中操作a b的时候,其实操作的就是外面的a b。

多参数传入

在FreeRTOS编程中,任务的参数是通过指针传入的,而在C语言中,一切皆可指针。
在上一个例程中,任务入口函数的代码大部分都相同,只有LED引脚和延迟时间两个参数有变化,而在上一个例程中我们只传入了引脚这一个参数,导致程序还是过于冗长,如果把引脚编号和时间都作为参数传入,我们就可以把三个任务入口函数合并成一个了,所以这个例程中,我们将传入两个参数完成这一优化。
多参数传入的方式有很多种,这个例程中我们将采用数组方式传入。
众所周知,数组本身就是一个指针,例如 int vals[3] ,当我们使用的时候,只需要将 vals 传入即可,而无需再对 vals 进行取址。

代码分享位置:https://wokwi.com/projects/362410683605524481

// 统一的任务,传入两个参数,第一个是pin,第二个是延迟时间
void led_task(void *param_t){
  // 从传入的参数中提取数组
  uint16_t *arr = (uint16_t *)param_t;
  // 数组第一个值为pin编号
  byte pin = (uint8_t)arr[0];
  // 数字第二个值为延迟时间
  uint16_t delayTime = arr[1];
  pinMode(pin, OUTPUT);
  while(1){
    // 先读取引脚的高低电平,然后翻转,最后重新设置给这个引脚
    digitalWrite(pin, !digitalRead(pin));
    vTaskDelay(delayTime/portTICK_PERIOD_MS);
  }
}
uint16_t LED1[] = {4,1000};
uint16_t LED2[] = {5,2000};
uint16_t LED3[] = {6,3000};
void setup() {
  Serial.begin(115200);
  xTaskCreate(led_task, "Blink Red",1024,LED1,1,NULL);
  Serial.println("第一个任务被创建,将控制红灯每秒亮灭一次");
  xTaskCreate(led_task, "Blink Green",1024,LED2,1,NULL);
  Serial.println("第二个任务被创建,将控制红灯每2秒亮灭一次");
  xTaskCreate(led_task, "Blink Blue",1024,LED3,1,NULL);
  Serial.println("第三个任务被创建,将控制红灯每3秒亮灭一次");
}
void loop() {
 
}

C语言规定了,数组中元素的类型必须都是一样的,而在之前的操作中我们得知,pin的类型是byte类型的,只有一个字节,最大只能表示255,这对于我们动辄1000ms的延迟是有点不够用的,所以我们将类型统一为uint16,这样最大可以表示65535。
例程中,首先声明三个uint16_t的一维数组,长度为2,分别存放引脚编号和延迟时间,这个数组的总大小是4个字节。
这里需要注意,我们将这三个数组的作用域定义为全局;如果把三个数组定义到setup函数中,当三个任务创建完毕后,会退出setup函数,这时有可能任务还没有执行,这就会导致在setup函数中声明的数组空间被释放,而我们的参数是按照指针传递进去的,在任务中取值的时候不会报错,但却有可能出现数据混乱的现象,这点尤为注意。
这个例程中,创建任务函数的第一个入参都是相同的 led_task 函数,这大大降低了代码量。
的那这个例程中仍然存在一些不足,就是我们浪费了1个字节的空间,要知道,整个ESP32-S3只有512K的SRAM,这不比我们PC编程,动辄就 byte[1024],在嵌入式编程中,我们一定要把一个字节掰成八瓣用,本着能省则省的原则,我们必须要把多余的空间省出来。

我们可以选择两个方式:

  1. 声明一个长度为3的byte,其中后两个字节分配给delayTime使用
  2. 使用结构体

第一种操作方式过于复杂,还需要有位移运算,会牺牲CPU的速度(CPU也是很紧缺的资源),所以下面的代码优化中,我们采用结构体的方式。

使用结构体传入参数

代码共享位置:https://wokwi.com/projects/362413677559674881

// 定义LED灯的结构体
typedef struct{
  byte pin;             // 操控引脚
  uint16_t delayTime;   // 延迟时间
}LED;
LED led1,led2,led3;
// 统一的任务,传入两个参数,第一个是pin,第二个是延迟时间
void led_task(void *param_t){
  // 从传入参数中提取结构体
  LED led = *(LED *)param_t;
  byte pin = led.pin;
  uint16_t delayTime =  led.delayTime;
  pinMode(pin, OUTPUT);
  while(1){
    // 先读取引脚的高低电平,然后翻转,最后重新设置给这个引脚
    digitalWrite(pin, !digitalRead(pin));
    vTaskDelay(delayTime/portTICK_PERIOD_MS);
  }
}
void setup() {
  Serial.begin(115200);
  // 设置参数
  led1.pin=4;   led1.delayTime=1000;
  led2.pin=5;   led2.delayTime=2000;
  led3.pin=6;   led3.delayTime=3000;
  xTaskCreate(led_task, "Blink Red",1024,&led1,1,NULL);
  Serial.println("第一个任务被创建,将控制红灯每秒亮灭一次");
  xTaskCreate(led_task, "Blink Green",1024,&led2,1,NULL);
  Serial.println("第二个任务被创建,将控制红灯每2秒亮灭一次");
  xTaskCreate(led_task, "Blink Blue",1024,&led3,1,NULL);
  Serial.println("第三个任务被创建,将控制红灯每3秒亮灭一次");
}
void loop() {
 
}

代码中首先定义了一个结构体,分别有一个byte和一个unit16的成员,总共占用3个字节大小,led_task从原来数组取值编程了从结构体取值,其他基本不变。

ESP32-S3的CPU

之前我们说过,ESP32-S3是双核的MCU。
其中Core-0是系统CPU,又叫SYS-Core,一般运行的是WIFI和蓝牙的协议栈及相关服务程序;
Core-1是应用CPU,又叫APP-Core,我们自己的程序一般会运行在这里。
虽然两个CPU都可以指定程序运行,但强烈建议,如果资源允许的情况下,尽量不要打CPU-0的主意,在物联网开发中,让协议栈安全运行比什么都重要!
那如何控制线程在哪个核心运行呢?
想知道如何运行在指定核心,我们先需要知道我们创建的任务运行在哪个核心上。

首先,在Android的设置中可以进行选择,打开工具 -> Android Run On 这个菜单,可以选择在Core-0还是Core-1运行,在Android中默认是在核心1运行。
但这里仅仅说的是setup和loop函数在哪个核心运行,通过xTaskCreate创建的任务则会随机选择CPU运行。

在代码中,可以通过 xPortGetCoreID 函数可以获得当前任务在哪个CPU运行,这个函数只有在任务中运行的时候才能得到正确结果。

代码共享地址:https://wokwi.com/projects/362415309699772417

void task1(void* patam_t){
  vTaskDelay(300/portTICK_PERIOD_MS);
  Serial.print("任务1运行在 CPU - ");
  Serial.println(xPortGetCoreID());
  vTaskDelete(NULL);
}
void task2(void* patam_t){
  vTaskDelay(600/portTICK_PERIOD_MS);
  Serial.print("任务2运行在 CPU - ");
  Serial.println(xPortGetCoreID());
  vTaskDelete(NULL);
}
void task3(void* patam_t){
  vTaskDelay(900/portTICK_PERIOD_MS);
  Serial.print("任务3运行在 CPU - ");
  Serial.println(xPortGetCoreID());
  vTaskDelete(NULL);
}
void setup() {
  Serial.begin(115200);
  Serial.print("当前CPU:");
  Serial.println(xPortGetCoreID());
  xTaskCreate(task1,"TASK1",1024,NULL,1,NULL);
  xTaskCreatePinnedToCore(task2,"TASK2",1024,NULL,1,NULL,0);
  xTaskCreatePinnedToCore(task3,"TASK3",1024,NULL,1,NULL,1);
}
void loop() {
 
}

之前我们都是通过 xTaskCreate 创建任务,而FreeRTOS提供了另外一个强劲的函数 xTaskCreatePinnedToCore ,这个函数的参数和 xTaskCreate 基本相同,只是在最后加入了一个指定CPU的参数。

在这段程序中我们注意到,每个任务的最后都有一条

vTaskDelete(NULL);

这个函数用于删除当前任务,如果缺失了这行代码,程序会报错,因为任务一旦出了入口函数,调度器将不知道向哪运行,FreeRTOS任务,只有运行中、就绪、挂起、阻塞、等待删除五种状态,我们之前的任务都是放在一个while大循环中运行,永远不会退出,但本次的例程中少了while循环,也就意味着任务会退出,当任务退出后,就不属于这五种状态的任何一种,CPU直接懵圈,索性就挂了……
所以,我们在显性结束任务的时候,必须手动调用 vTaskDelete 函数将任务删除。
vTaskDelete 传入一个参数,就是任务的句柄,当传入为NULL的时候,表示删除当前任务。(NULL这个规则适用于之后的其他函数,如果是针对任务的句柄,传入NULL则表示当前任务)
调用 vTaskDelete 之后任务并不会马上删除,会进入待删除列表,但此时等待任务的之后被删除,是不可恢复的,此时的任务不参与调度,也无法通过 vTaskResume 恢复。 任务删除时,一并释放的还有创建任务时分配的栈空间(如果是静态创建,则栈空间不被回收)。

任务的执行状态

在这里插入图片描述

就绪态 :指任务目前处于等待运行状态,如被创建后、delay之后、结束挂起、被调度等都会到达这个状态
运行态 :指任务处于运行中,每个CPU同时只会有一个任务处于运行中的状态,可通过delay函数进入阻塞,或通过暂停等进入挂起
挂起态 :任务暂时不执行,也不参与调度,只有通过vTaskResume 函数才可解除任务的挂起态
阻塞态 :当调用delay函数,或者等待某个信号时处于阻塞态,此时任务不参与调度,等待时间将状态改变为就绪
待删除 :当任务执行 vTaskDelete 函数之后,任务并没有马上删除,但此时任务已经不参与任何调度,也不会改变为其他状态,唯一的结局就是等待删除。

静态任务

通过静态方法创建任务和动态方法创建任务形同,使用的是:xTaskCreateStaticxTaskCreateStaticPinnedToCore 两个函数。

TaskHandle_t xTaskCreateStatic(TaskFunction_t pvTaskCode, const char *const pcName, const uint32_t ulStackDepth, void *const pvParameters, UBaseType_t uxPriority, StackType_t *const puxStackBuffer, StaticTask_t *const pxTaskBuffer)

该函用于静态方法创建任务,参数分别表示如下:
pvTaskCode 任务入口函数
pcName 任务名称
ulStackDepth 任务栈大小
pvParameters 任务参数
uxPriority 任务优先级
pxTaskBuffer 栈缓冲区首地址
pxTaskBuffer 任务控制块指针
同时,这个函数将返回任务指针
与动态方法创建不同点在于,在创建任务之前首先要定义栈缓冲区和任务控制块,这两个部分在创建时不会动态分配。

指定CPU运行的静态任务创建函数如下:

TaskHandle_t xTaskCreateStaticPinnedToCore(TaskFunction_t pvTaskCode, const char *const pcName, const uint32_t ulStackDepth, void *const pvParameters, UBaseType_t uxPriority, StackType_t *const pxStackBuffer, StaticTask_t *const pxTaskBuffer, const BaseType_t xCoreID)

这两个函数前期阶段我们用到的比较少,待后期有用例的时候我们再详细讲解。

文章来源:https://blog.csdn.net/suolong123/article/details/135518672
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。