S1-09 直达任务通知

直达任务通知

直达任务通知是为了提升FreeRTOS中多任务键通讯的效率,降低RAM使用而发明的,自8.2版本之后就有了,自10.4之后的版本支持了单任务多条通知。
直达任务通知有点类似于μC/OS或者FreeRTOS中的Single(信号),但比那些东西好用,直达任务通知是直接发送至任务的事件, 而不是通过中间对象 (如队列、事件组或信号量)间接发送至任务的事件。 向任务发送“直达任务通知” 会将目标任务通知设为“挂起”状态。 正如任务可以阻塞中间对象 (如等待信号量可用的信号量),任务也可以阻塞任务通知, 以等待通知状态变为“挂起”。
直达任务通知具有高度灵活性,使用时无需 创建单独 队列、 二进制信号量、 计数信号量 或事件组。 通过直接通知解除 RTOS 任务阻塞状态的速度和使用中间对象(如二进制信号量)相比快了 45% , 使用的 RAM 也更少 。
不过 这些性能优势也有一些意料之内的使用限制:

  1. RTOS 任务通知仅可在只有一个任务 可以接收事件时使用。 不过,这个条件在 大多数真实世界情况下是满足的。比如,中断解除了一个任务的阻塞状态,该任务 将处理由中断接收的数据。
  2. 仅可在使用 RTOS 任务通知代替 队列时使用: 当某个接收任务可在阻塞状态下等待通知 (因而不花费任何 CPU 时间)时,发送任务不能 在阻塞状态下等待发送完成(在发送不能立刻完成的情况下) 。

打开或关闭直达任务通知

在ESP32中,menuconfig中可以设置直达任务通知是否打开,并且可以设置直达任务通知的数量,默认情况下直达任务通知是打开的,并且只有一条可用。
可以通过 configUSE_TASK_NOTIFICATIONS 查看直达任务通知是否打开,通过查看 configTASK_NOTIFICATION_ARRAY_ENTRIES 确认每个任务有多少条直达任务通知。
如果在项目中用不到直达任务通知,建议关闭,关闭后每个任务控制块将节省8个字节的空间(还是那句话,寸土寸金,悠着点用)。

每条任务通知 都有“挂起”或“非挂起”的通知状态, 以及一个 32 位通知值,通知值可以理解为事件组,只不过事件组最多只有3个字节,而且仅能按位使用,而直接任务通知中的数据位有4个字节,不仅可以按位使用,而且还可以作为byte(int8/uint8)、short(int16/uint16)、int(int32/uint32)等值类型存储数据,或者当作指针类值存储地址,灵活性非常强。

TaskNotify

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

static TaskHandle_t xTaskWait = NULL; // 等待任务句柄
static TaskHandle_t xTaskGive = NULL; // 发送任务句柄
#define LOWTHREEBITS ( 1UL << 0UL )|( 1UL << 1UL )|( 1UL << 2UL )
// 等待直达任务通知的线程
void wait_task(void *param_t){
  uint32_t ulNotificationValue;   // 任务通知数据
  BaseType_t xResult;             // 任务通知是否已经挂起
  while(1){
    printf("[WAIT] 等待任务通知到达...\n");
    xResult = xTaskNotifyWait(0x00,                 // 在运行等待通知之前需要清零的位
                              0x00,                 // 在获取通知之后需要清零的位
                              &ulNotificationValue, // 记录任务通知的值
                              portMAX_DELAY);       // 等待超时时间
    if(xResult){
      // 如果正常等到了通知,则将受到的值打印出来。
      char bin_str[33];
      itoa(ulNotificationValue, bin_str, 2);
      printf("[WAIT] 收到任务通知:%s\n", bin_str);
    }else{
      printf("[WAIT] 任务通知等待超时!");
    }
  }
}
// 发送直达任务通知的线程
void give_task(void *param_t){
  pinMode(4, INPUT_PULLUP);
  BaseType_t xResult;
  while(1){
    if (digitalRead(4) == LOW) {
      xResult=xTaskNotify(xTaskWait, 0, eIncrement);     // eIncrement 方式每次对值进行+1操作
      // xResult=xTaskNotify(xTaskWait, 0, eNoAction);      // eNoAction 不设置任何值,只对通知进行一次挂起操作
      // xResult=xTaskNotify(xTaskWait, (1UL<<4UL), eSetBits); // eSetBits 表示要设置某些值为1,如果多次设置,不进行覆盖,而是进行 或 操作
      // xResult=xTaskNotify(xTaskWait, LOWTHREEBITS, eSetValueWithOverwrite );    // eSetValueWithOverwrite 将覆盖原来设置的值
      // xResult=xTaskNotify(xTaskWait, LOWTHREEBITS, eSetValueWithoutOverwrite);   // eSetValueWithoutOverwrite 表示如果之前的值已经被处理过了,则覆盖,如果没有被处理,则不进行覆盖,并返回发送失败
      printf("[GIVE] 任务通知发送:%s\n", xResult?"成功":"失败");
      vTaskDelay(100);
    }
    vTaskDelay(100);
  }
}
void setup() {
  Serial.begin(115200);
  Serial.println("Hello, ESP32-S3!");
  printf("是否开启了直达任务通知: %s\n", configUSE_TASK_NOTIFICATIONS?"YES":"NO");
  printf("每个任务通知ID数量:%d\n", configTASK_NOTIFICATION_ARRAY_ENTRIES);
  // 启动两个线程
  xTaskCreate(wait_task, "WAIT", 10240, NULL, 1, &xTaskWait);
  xTaskCreate(give_task, "GIVE", 10240, NULL, 1, &xTaskGive);
  vTaskDelete(NULL);   // 自宫
}
void loop() {
  delay(100);
}

在FreeRTOS中,使用 xTaskNotify 函数取消任务的阻塞状态,这个函数相当于信号量、队列中的 xxxGive 方法,该函数可以通过五种方式设置任务到阻塞状态:
NoAction :保持通知值不做任何变化,只是通过更改状态来达到通知的目的;
Increment :对值进行累加操作,并设置状态标志为挂起状态;
SetBits :是指其中一个或某几个位的状态为1,如果重复调用,执行的是“或”操作;
SetValueWithOverwrite :覆盖之前设置的值,并将新这只的位变更为1;
SetValueWithoutOverwrite :和 SetValueWithOverwrite 操作相同,但如果在设置之前标志仍处于挂起状态的情况下会设置失败。
注意:如果使用二值信号量或者计数器信号量类型的通知,应该使用 xTaskNotify 的更简单类型 ** xTaskNotifyGive** 该函数效率更高。

等待信号到达时,和信号量、消息队列等相同,使用的是 xTaskNotifyWait 函数,但该函数与以往使用的其他通知类型有所不同,原型如下:

BaseType_t xTaskNotifyWait( uint32_t ulBitsToClearOnEntry,
                             uint32_t ulBitsToClearOnExit,
                             uint32_t *pulNotificationValue,
                             TickType_t xTicksToWait );

ulBitsToClearOnEntry:在等待通知之前需要清空的位,如果不清空则可以设置成pdFALSE或者0;
ulBitsToClearOnExit:通知到达后需要清空的位,首先将通知到达时的值返回,然后再置位;
pulNotificationValue:保存通知任务的值,如果设置了 ulBitsToClearOnExit 则会先保存返回值,再清空;
xTicksToWait:等待时间。
该函数会有一个返回值,用于标注是否正确等到了通知,如果返回的是pdFALSE,则有可能是因为超时引起的等待失败。

在使用该方法的时候大家可能已经注意到,与其他Wait方法中少了一个重要的参数,句柄指针。
这是因为在直达任务通知中 Wait 函数等待的仅仅是本函数的通知,所以并不需要传入多余的句柄参数。
此外,因为直达任务通中,每个任务可支持多个任务通知,所以在Give和Take的时候必定可以选择使用哪个通知作为锚点,因此,在所有函数中都有一个Indexed后缀的函数,如:xTaskNotifyWaitIndexed、xTaskNotifyGiveIndexed、xTaskNotifyIndexed 等,该函数中都包含一个 uxIndexToWaitOn 用于标注等待或设置的是哪个通知。(具体请阅读本文档最后列出的文档)

所以接下来的例程中,我们通过各种类型值的运用模拟二进制信号量、事件组、消息队列(邮箱)、以及带附件的邮箱来演示直达任务通知的各种神奇应用。

作为二进制信号量或计数器信号量使用

在直达任务通知中,最直接的应用方式莫过于信号量,在作为信号量使用时,通过 vTaskNotifyGive 或者 vTaskNotifyGiveFromISR 释放信号量,通过 ulTaskNotifyTake 函数在任务中等待一个信号量的到达。
ulTaskNotifyTake 有两个参数,第一个参数 xClearCountOnExit 表示等到通知后,是对值进行递减操作还是清零操作,如果设置为pdFALSE表示仅仅做递减,这种情况用于代替计数器信号量时使用;如果设置为pdTRUE则表示收到通知后立刻将值清零,用于代替二进制信号量时使用。

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

#define KEY_PIN 20
#define LED_PIN 14
static TaskHandle_t xTaskLed = NULL; // 点灯的任务
volatile TickType_t keyDeounce = 0;   // 按下按钮的时间
void led_task(void *param_t){
  pinMode(LED_PIN, OUTPUT);
  uint32_t ulNotificationValue;
  while(1){
    if(xTaskGetTickCount() - keyDeounce<200){
      printf("[LEDP] 等待信号到达...\n");
      ulNotificationValue = ulTaskNotifyTake(pdTRUE,  portMAX_DELAY); // 第一个参数表示取值完毕后清零
      printf("[LEDP] 收到信号,开关灯\n");
      if(ulNotificationValue>0){
        digitalWrite(LED_PIN, !digitalRead(LED_PIN));
      }
      vTaskDelay(1000);
    }
  }
}
// 中断服务函数
void IRAM_ATTR ISR() {
  keyDeounce = xTaskGetTickCountFromISR();    // 记录下按下的时间,用于放抖动,正式开发中不要这样写,有Bug
  vTaskNotifyGiveFromISR(xTaskLed, 0);
}
void setup() {
  Serial.begin(115200);
  xTaskCreate(led_task, "LED-DSP", 10240, NULL, 1, &xTaskLed);
  // 安装中断
  pinMode(KEY_PIN, INPUT_PULLUP);
  attachInterrupt(KEY_PIN, ISR, FALLING);
}
void loop() {
  delay(10);
}

以上例程修改于《信号量中断点灯》的例程,首先在 setup 中安装终端服务函数,当按下按钮后使用 vTaskNotifyGiveFromISR 在中断中出发信号量。
并在 led_task 中通过 ulTaskNotifyTake 捕获通知,当通知到达后将值清零,并判断返回值是否大于0(改值是在修改之前返回的,所以即便是 xClearCountOnExit 参数设置为 pdTRUE,也会返回正确的值),如果大于0则表示正常获取,否则有可能是超时。

直达任务通知作为信号量使用时,与真正的信号量有所不同:

  1. 直达任务通知仅针对于某个线程使用,不能同时多个任务等待信号
  2. 作为计数器信号量使用时,无法设置上限,只能在使用过程中通过Take获取的值进行判断

作为事件组使用

直达任务通知包含有4个字节的通知值,该值中每一位都可以作为一个单独的事件使用。
代码共享位置:https://wokwi.com/projects/363128324119907329

static TaskHandle_t xLEDTask = NULL;
// 拨盘控制线程
void dial_task(void *param_t) {
  const byte INDIALPIN = 14;
  const byte PULSEPIN = 13;
  pinMode(INDIALPIN, INPUT_PULLUP);
  pinMode(PULSEPIN, INPUT_PULLUP);
  byte counter = 0;
  boolean inDialPinLastState;
  boolean pulsPinLastState;
  inDialPinLastState  = digitalRead(INDIALPIN);
  pulsPinLastState    = digitalRead(PULSEPIN);
  while (1) {
    boolean inDialPinState  = digitalRead(INDIALPIN);
    boolean pulsPinState    = digitalRead(PULSEPIN);
    if (inDialPinState != inDialPinLastState) {
      if (!inDialPinState) {
        counter = 0;
      } else {
        if (counter) {
          counter = counter % 10;
          uint32_t ulEventGroup = 1 << counter;
          xTaskNotify( xLEDTask,      // 任务句柄
                       ulEventGroup,  // 设置的值
                       eSetBits);     // 采用设置方式(或运算)
        }
      }
      inDialPinLastState = inDialPinState;
    }
    if (pulsPinLastState != pulsPinState) {
      if (!pulsPinLastState) {
        counter++;
      }
      pulsPinLastState = pulsPinState;
    }
  }
}
//LED控制线程
void led_task(void *param_t){
  byte led_pins[9] = {42, 41, 40, 39, 38, 37, 36, 35, 0};
  for (byte pin:led_pins) pinMode(pin, OUTPUT);   // 初始化引脚为输出状态
  uint32_t ulNotifiedValue;
  while(1){
    xTaskNotifyWait( pdFALSE,           // 等待前不清除状态
                     ULONG_MAX,         // 获得数据后置位所有,ULONG_MAX = 0xFFFFFFFF,也就是0b11111111111111111111111111111111
                     &ulNotifiedValue,  // 获取Wait的值
                     portMAX_DELAY );
    if(ulNotifiedValue & (1 << 0) == 1) {  // 如果是第一位,则表示要关闭所有LED
      for(int i = 1; i <= 9; i++) {
        digitalWrite(led_pins[i - 1], LOW);
      }
    }
    // 循环判断其他9位是否有要点亮
    for (int i=1; i<=9; i++) {
      if (ulNotifiedValue & (1 << i)) {
        digitalWrite(led_pins[i-1], HIGH);
      }
    }
  }
}
void setup() {
  Serial.begin(115200);
  Serial.println("Hello, ESP32-S3!");
  xTaskCreate(dial_task, "Dial-Panel", 10240, NULL, 1, NULL);
xTaskCreate(led_task, "LEDS", 10240, NULL, 1, &xLEDTask);
  vTaskDelete(NULL);  // 自宫
}
void loop() {
  delay(10);
}

该例使用电话拨盘配合LED模拟了开关灯的效果,拨动1~9任意数字,对应的LED灯将打开,当拨动数字0时,所有灯熄灭。
(号码拨盘的使用代码不用纠结,直接在帮助文档中复制,至于他是如何实现数据传递的我们不做过多研究)
在任务通知中我们使用了低10位表示对应的数字:依次表示为:
数字0 : 0x0001 0b0000000001
数字1 : 0x0002 0b0000000010
数字2 : 0x0004 0b0000000100
数字3 : 0x0008 0b0000001000
数字4 : 0x0010 0b0000010000
数字5 : 0x0020 0b0000100000
数字6 : 0x0040 0b0001000000
数字7 : 0x0080 0b0010000000
数字8 : 0x0100 0b0100000000
数字9 : 0x0200 0b1000000000
dial_task 线程中,counter 表示拨动的数字序号,取得序号后通过位移的方式讲事件组对应的位置位。
最后通过 xTaskNotify 将事件组发出,这里采用的是 SetBits 的方式,也就意味着,如果之前已经设置过某一位的值,本次使用的是或操作(|)设置,而不是覆盖。

led_task 任务中,通过 xTaskNotifyWait 方式等待通知到达,等待前不对通知值做任何操作,消息获得后置位所有位,这里使用的是 ULONG_MAX 常量,一个4字节的数据,所有位都是1,十六进制表达方式是0xFFFFFFFF,二进制表达方式则为0b11111111 11111111 11111111 11111111,这样在收到通知后,就会将所有位都设置为0(注意,这里的“1”表示需要清除的位,而不是将某一位设置为1)。
收到数据后首先判断第一位是否为1,如果是,则表示要关闭所有灯。
之后判断哪一位此时为1,并打开对应的灯,这里依然采用对1左移方式进行对比。

直达任务通知和事件组有以下不同点:

  1. 功能不同:直达任务通知是一种针对单个任务的通信机制,可以直接向指定任务发送通知,在任务挂起等待通知时,可以随时取消阻塞并获取最新的通知值。而事件组则是一种用于多任务之间共享和同步多个事件状态的机制,可以实现任务之间的复杂事件组合和等待。
  2. 灵活性不同:直达任务通知的接口简单,使用方便,可以快速地发送和接收通知,并支持多个通知设置为不同的优先级,以及可选地传递一个通知值。而事件组的接口相对复杂一些,需要预先定义好一组事件位,可以对每个事件位进行等待、清除、设置等操作,需要有一定的设计和编码工作量。
  3. 范围不同:直达任务通知仅限于单个任务内部使用,无法跨任务进行通信。而事件组可以在多个任务之间共享,任何任务都可以等待和设置事件组的事件状态,并可在多个任务之间进行同步和信号传递。

作为消息队列使用

通过上面的实验我们已经验证,直达任务通知包含一个拥有四字节数据的值,上面例程中已经演示过当做二进制信号量和事件组使用,同样的,我们可以将这个4字节的值作为一个轻量级的消息队列使用,之所以说是“轻量级”,是因为该消息队列中最多只能存储一条消息,当消息已经存在而又想继续发送消息时,我们只能采取三种方式对消息进行处理,一种是覆盖原来的消息,一种是忽略本次发送。
没错,通过这种方式发送消息,无法实现等待,这也是和消息队列不同的地方。
以下例程模拟了一个智能家居网管系统, clock_task 表示时钟模块,通过读取RTC模块的时间,发送给网关,网关再通过显示器把时间打印出来。

RTC模块

RTC模块全称是实时时钟模块(Real-Time Clock Module),它是一种用于计时和日期记录的集成电路芯片。RTC模块内部包含一个晶体振荡器和一个计数器,通过晶体振荡器提供的持续精确的时钟信号,实现对于时、分、秒等时间单位的准确计时,同时还能够记录当前日期等时间信息。
再PC机中,主板已经自带了RTC模块,所以我们可以直接知道当前准确的时间,但在SoC、MCU等嵌入式系统中,一般为了降低成本,大部分不包含不包含系统时钟的功能,不过我们所熟知的STM32,和我们现在使用的ESP32中存在内置RTC,但一般我们不会直接使用,因为RTC在MCU断电的时候需要外部持续供电才能正常工作,并且内置的RTC一般精度不高。常用的做法就是使用外置RTC模块的形式获取时间,模块自带纽扣电池,与系统隔离,只有在需要校准内部RTC或者读取时间的时候,才会通过IIC、串口等方式进行操作读取外部RTC的精确时间。

RTC模块通常支持多种时钟输出格式及不同的闹钟功能,比如每秒中断、每分钟中断、每小时中断等定时中断功能,在一些需要时间戳记录或者基于时间的控制和管理场景中广泛应用。
常见的 RTC 芯片类型有 DS1307、DS3231 等,其中 DS3231 具有相当高的精度 (±2ppm) 和温度补偿功能,因此在很多精密度要求较高的领域被广泛使用。同时也有一些 MCU 的内置 RTC 模块,例如 STC89C52、STM32 等,这样的内置 RTC 可以大大简化系统设计和布局,降低系统成本和 PCB 空间占用。

在这里插入图片描述

这款模块针对树莓派开发,可以直接插入树莓派的排母中,但因为其是串口操作的,也就意味着其他系统也可以使用
在这里插入图片描述

DS1306/1307也是常用的RTC芯片
DS1306和DS1307都是Maxim公司生产的实时时钟芯片,它们的主要区别在于性能和功能上。
首先是性能方面,在时钟准确性方面,DS1307的频率稳定性更高,误差范围更小,每天的误差只有5秒左右,而DS1306的误差可能会高达2分钟左右。此外,DS1307还支持高速I2C总线,通信速率可达400KHz,而DS1306则只支持标准I2C总线,通信速率为100KHz。
其次是功能方面,DS1307比DS1306多了一些功能,比如有一个IRQ引脚用于输出闹钟、定时器等中断信号,可以设置外设电源控制功能,支持电池切换功能等,而DS1306则没有这些功能。
对于误差,我们有多种方式可以矫正,比如联网后,每间隔1小时通过互联网或者蓝牙进行校时,或通过GPS芯片进行校时等。

其他RTC芯片:

  1. DS1302:DS1302是一种低功耗的实时时钟芯片,由美国MAXIM公司生产。它通过串行总线与单片机进行通信,支持读写操作,具有自动闰年调整和涓流充电等功能。DS1302的时钟精度差异较大,为±2分钟/月至±5分钟/月,通常用于时间要求不高的场合。
  2. DS1307:DS1307也是一种低功耗的RTC芯片,由美国MAXIM公司生产。它采用I²C双向总线接口,具有完整的BCD时钟和日历功能,可以提供秒、分、时、日、月、年和星期等信息。DS1307的时钟精度为±0.01秒至±2秒/天,可以满足大多数应用场合的需求。
  3. DS3231:DS3231是一种高精度、低成本的I²C RTC芯片,由美国MAXIM公司开发。它具有集成的温补晶振(TCXO)和晶体,能够提供极高的时钟精度,最小误差为±2ppm。DS3231还支持温度补偿、任意设定中断、低功耗模式等多种功能,适用于要求时钟精度较高的场合。
  4. PCF8583:PCF8583是一种基于I²C双向总线的时钟和日历芯片,由荷兰恩荷伦NXP公司生产。它具有2048位静态CMOS RAM,支持时钟计时、闹钟、定时器和倒计时器等多种功能,同时还具有自动备份电源和中断输出等特点。PCF8583的时钟精度为±2.5ppm至±6ppm,属于普通级别。
  5. PCF8563:PCF8563也是一种基于I²C双向总线的时钟和日历芯片,由荷兰恩荷伦NXP公司生产。它可以提供年、月、日、星期、小时、分钟和秒的计时信息,还支持两种不同的闹钟和一个可编程计时器。PCF8563的时钟精度为±1分钟/月,适用于时间要求不高的场合。
  6. MCP7940N:MCP7940N是一种带有SRAM的低功耗RTC芯片,由美国Microchip公司生产。它采用I²C总线接口,能够提供高精度的日期和时间信息,支持闹钟、定时器和温度监测等功能,适用于一些要求严格的应用场合。MCP7940N的时钟精度为±2分钟/月至±5分钟/月。
  7. RV-3028-C7:RV-3028-C7是一种低功耗RTC芯片,由瑞士Micro Crystal公司生产。它采用I²C接口,可以提供高精度的时间信息,最小误差为±1ppm。RV-3028-C7还支持温度补偿、低功耗模式和计时器等特性,适用于对时钟精度有较高要求的场合。

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

#include "RTClib.h"
#include <LiquidCrystal_I2C.h>
#include <Wire.h>
#define SCL 16
#define SDA 17
static TaskHandle_t xGatewayTask = NULL;    // 网关线程  
// 时钟获取线程
void clock_task(void *param_t){
  RTC_DS1307 rtc;         // 定义时钟
  if (rtc.begin()) {
    while(1){
      DateTime now = rtc.now();
      /* 获得 年 月 日 时 分 秒 五个参数,封装到4字节数据中,并通过任务通知发送出去
       * 秒(0~59) 占用 1 ~ 6  位,6
       * 分(0~59) 占用 7 ~ 12 位,6
       * 时(0~23) 占用13 ~ 17 位,5
       * 日(1~31) 占用18 ~ 22 位,5
       * 月(1~12) 占用23 ~ 26 位,4
       * 年(0~63) 占用27 ~ 32 位,6
       * 年的计算减去2000
       */
      uint32_t time=0;
      time =  ((now.year()-2000) & 0b111111);
      time <<= 4;   time |= (now.month()  & 0b1111);      
      time <<= 5;   time |= (now.day()    & 0b11111);    
      time <<= 5;   time |= (now.hour()   & 0b11111);    
      time <<= 6;   time |= (now.minute() & 0b111111);    
      time <<= 6;   time |= (now.second() & 0b111111);
      // 发送数据
      xTaskNotify(xGatewayTask, time, eSetValueWithOverwrite);  // 以覆盖形式发送
      vTaskDelay(1000);   // 每间隔一段时间上报一次时间
    }
  }
  vTaskDelete(NULL);  // 自我终结
}
// 物联网网关
void gateway_task(void *param_t){
  uint32_t time;
  LiquidCrystal_I2C lcd(0x27, 20, 4);
  lcd.init();
  lcd.backlight();
  char line1[17]; // 第一行
  char line2[17]; // 第二行
  while(1){
    if(xTaskNotifyWait(0x00, 0x00, &time, 0) == pdTRUE){
      // 收到数据,转换后打印输出
      int second  = time & 0b111111;  time >>= 6;
      int minute  = time & 0b111111;  time >>= 6;
      int hour    = time & 0b11111;   time >>= 5;
      int day     = time & 0b11111;   time >>= 5;
      int month   = time & 0b1111;    time >>= 4;
      int year    =(time & 0b111111) +2000;
      // printf("当前时间:%d-%02d-%02d %02d:%02d:%02d\n",
      //       year, month, day, hour, minute, second);
      sprintf(line1, " %04d - %02d - %02d", year, month, day);
      sprintf(line2, "  %02d : %02d : %02d", hour, minute, second);
      lcd.setCursor(0, 0);
      lcd.print(line1);
      lcd.setCursor(0, 1);
      lcd.print(line2);
    }
    vTaskDelay(200);
  }
}
void setup() {
  // put your setup code here, to run once:
  Serial.begin(115200);
  Serial.println("Hello, ESP32-S3!");
  Wire.begin(SDA, SCL);   // 初始化I2C总线
  xTaskCreate(gateway_task, "GATEWAY", 10240, NULL, 1, &xGatewayTask);
  xTaskCreate(clock_task, "CLOCK", 10240, NULL, 1, NULL);
 
  vTaskDelete(NULL);  // 自宫
}
void loop() {
  delay(3000);
}

DS1302和LCD1602都是通过IIC方式与主机相连,在之前的例程中我们多次用到了IIC通讯,对Wire库也有所了解。
在 setup 函数中,使用 Wire.begin 对IIC进行初始化。
clock_task 任务中首先通过 rtc.begin() 对RTC进行初始化,因为初始化函数中已经对设备地址进行了封装,所以初始化不用传入设备地址,如果初始化失败则退出程序(这里正确的做法应该是向控制台发送一个错误信息,然后重启设备),如果初始化成功,没间隔1秒钟的时间将通过 xTaskNotify 向网关线程发送一个消息,这个消息由年、月、日、时、分、秒组成,但被封装在4个字节(32位)中,之后对这种封装方式做进一步解释。
gateway_task 任务模拟了网关收取消息并显示,首先通过 LiquidCrystal_I2C 库对LCD进行初始化,因为LiquidCrystal_I2C是LCD通用库,所以需要通过地址区分设备,LCD1602 的设备地址是0x27。
初始化完毕后开始通过 xTaskNotifyWait 等待消息到达后再LCD中进行显示,这里不会做任何等待,而且等待前后不会对数据进行任何操作。

数据压缩

直达任务通知只有4字节(32)位数据,而DS1602模块可以返回年、月、周、日、时、分、秒七个数据,我们如何将这么多的数据封装到4个字节中呢?
想做数据压缩,首先需要看数据的规律,考虑存储的时候不能再以字节为单位,而是以位为单位。
分和秒:最大数值是59,按位存储,最多占用6位(6位最大表示63),一个字节中节省2位
小时:最大数字为24,按位存储,最多占用5位(5位最大表示31),一个字节中可节省3位
日:最小数字1,最大数字31,最多占用5位(如果最小值是1,最大值是32,则有可能占用6位,但很浪费,最小数向0对齐可以节省空间)
月:最小数字1,最大数字12,最多占用4位(4位最大表示15)
按照以上算法,6+6+5+5+4=26,还剩余6位可用,6位最大表示63,所以只能将就给年用,但今年是2023年,如果想把这个数字放下,至少需要11位的空间。所以,我们只能考虑使用对齐方式存储数据,年份最小表示到2000年,也就是年份减去2000,这样最大可以表示道2063年。
按照年、月、日、时、分、秒从高到低排列:
年占用第32~27位
月占用第26~23位
日占用第22~18位
时占用第17~13位
分占用第12~7位
秒占用第6~1位
在这里插入图片描述

通过左移和或运算方式将时间压缩到4字节数据中,并通过 xTaskNotify 发送。
接收到数据后开始反向解压缩,即可将时间正确表达。

邮箱的高级用法

上一个例程中,我们只是对时间做了压缩传送,但碍于数据量有限,只能舍弃“周”的数据。
但如果我们在夸任务数据传输大量数据应该如何处理呢?
在消息队列的章节中,我们可以任意定义消息队列的大小,这是一种解决方案。在其他操作系统中(如μC/OS和RT-Thread)都有一种叫做“邮箱”的传输方式,基础类型的邮箱和消息队列的用法是一样的,但邮箱的高级用法中是可以携带一个不定长度的附件数据的(在消息队列章节中没有讲到),通常的做法是邮箱中传输两个4字节数据,第一个数据表示附件的大小(或类型),第二个数据表示附件的指针,如果在消息队列中使用邮箱,可以利用结构体模拟一个类似的附件,但直接任务通知中数据区的大小只有4字节,也就是说数据区域只能放一个指针。

以下例程中,在直达任务通知模拟消息队列的例程基础上增加一个DS18B20的传感器,用于测量实时温度,并通过邮箱方式将数据传递给网关。

DS18B20是一种数字温度传感器,由Maxim(前身为Dallas Semiconductor)公司设计和生产。它可提供9位至12位的摄氏温度测量值,并带有具备非易失性的上下限触发点警报功能。这个特点可以在需要监控温度变化的场合进行使用,例如冰箱、空调和温室等应用。DS18B20采用1-Wire协议进行数据的传输,1-Wire协议由单个数据线进行数据传输,同时支持多个设备在同一数据线上共享。在1-Wire协议中,每个设备都可以通过唯一的64位ROM序列号进行识别和寻址,可以在系统设计时更加灵活和方便。DS18B20可以直接从数据线上获取电源,称为“寄生电源”,无需外部电源输入,这使得系统设计更加简化,特别是在需要在远程环境下进行温度监测的场合,使用非常方便。此外,DS18B20还可以设置多种精度模式,可设置为9、10、11或12位分辨率,以适应不同的应用需求。在每次温度读数之后,还可以进行自我校准,以提高温度测量的精确度。

在这里插入图片描述

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

#include "RTClib.h"
#include <LiquidCrystal_I2C.h>
#include <DallasTemperature.h>
#include <Wire.h>
#include <OneWire.h>
#define SCL 16
#define SDA 17
#define ONE_WIRE_BUS 11
char daysOfTheWeek[7][12] = {"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"};
static TaskHandle_t xGatewayTask = NULL;    // 网关线程
typedef struct{
  uint8_t type;     // 数据类型 1
  uint16_t year;    // 年
  uint8_t month;    // 月
  uint8_t day;      // 日
  uint8_t hour;     // 时
  uint8_t minute;   // 分
  uint8_t second;   // 秒
  uint8_t week;     // 星期
}Data_Time;
typedef struct{
  uint8_t type;     // 数据类型 2
  float temperature;  //温度
}Data_Temperature;
// 温度传感器获取
void temperature_task(void* param_t){
  OneWire oneWire(ONE_WIRE_BUS);        // 初始化一个OneWire对象来控制1-Wire总线
  DallasTemperature sensors(&oneWire);  // 初始化一个DallasTemperature对象来使用DS18B20
  sensors.begin();
  while(1){
    sensors.requestTemperatures();
    Data_Temperature *temp = (Data_Temperature *)pvPortMalloc(sizeof(Data_Temperature));
    temp->type=2;
    temp->temperature = sensors.getTempCByIndex(0);
    // 发送数据,使用指针作为附件
    if(xTaskNotify(xGatewayTask, (uint32_t)temp, eSetValueWithoutOverwrite)!=pdPASS){
      // 不进行数据覆盖,但如果发送失败,则需要就地释放空间,否则运行时间长了会导致溢出
      vPortFree(temp);
    }
    vTaskDelay(random(200,1000));
  }
}
// 时钟获取线程
void clock_task(void *param_t){
  RTC_DS1307 rtc;         // 定义时钟
  if (rtc.begin()) {
    while(1){
      DateTime now = rtc.now();
      Data_Time *time = (Data_Time *)pvPortMalloc(sizeof(Data_Time));
      time->type=1;
      time->year = now.year();
      time->month = now.month();
      time->day = now.day();
      time->hour = now.hour();
      time->minute = now.minute();
      time->second = now.second();
      time->week = now.dayOfTheWeek();
      // 发送数据,使用指针作为附件
      if(xTaskNotify(xGatewayTask, (uint32_t)time, eSetValueWithoutOverwrite)!=pdPASS){
        // 释放空间
        vPortFree(time);
      }
      vTaskDelay(random(200,1000));   // 每间隔一段时间上报一次时间
    }
  }
  vTaskDelete(NULL);  // 自我终结
}
// 物联网网关
void gateway_task(void *param_t){
  uint32_t annex;   // 附件数据
  void *dp= NULL;   // 接收邮件附件用的指针
  LiquidCrystal_I2C lcd(0x27, 20, 4);
  lcd.init();
  lcd.backlight();
  char line[4][21]; //数据
  while(1){
    if(xTaskNotifyWait(0x00, 0x00, &annex, 0) == pdTRUE){
      dp = (void*)annex;
      char *type = (char *)dp;
      if(*type==1){
        // 时间数据
        Data_Time *time = (Data_Time *)dp;
        int second  = time->second;
        int minute  = time->minute;
        int hour    = time->hour;
        int day     = time->day;
        int month   = time->month;
        int year    = time->year;
        int week    = time->week;
        sprintf(line[0], "   %04d - %02d - %02d", year, month, day);
        sprintf(line[1], "     %s",daysOfTheWeek[time->week]);
        sprintf(line[2], "    %02d : %02d : %02d", hour, minute, second);
        for(int i=0; i<3; i++){
          lcd.setCursor(0, i);
          lcd.print(line[i]);
        }
      }else if(*type==2){
        // 温度数据
        Data_Temperature *temp = (Data_Temperature *)dp;
        sprintf(line[3], " Temperature : %.2f", temp->temperature);
        lcd.setCursor(0, 3);
        lcd.print(line[3]);
      }
      vPortFree(dp);    // 一定要释放附件空间
    }
    vTaskDelay(200);
  }
}
void setup() {
  Serial.begin(115200);
  // Serial.println("Hello, ESP32-S3!");
  Wire.begin(SDA, SCL);   // 初始化I2C总线
  xTaskCreate(gateway_task, "GATEWAY", 10240, NULL, 1, &xGatewayTask);
  xTaskCreate(clock_task, "CLOCK", 10240, NULL, 1, NULL);
  xTaskCreate(temperature_task, "TEMP", 10240, NULL, 1, NULL);
  vTaskDelete(NULL);  // 自宫
}
void loop() {
  delay(3000);
}

参照留缓冲区中报文形式,在本例中定义了一个简单的协议包,包头只包含1字节,用于表示消息类型,1位日期,2为温度。
与上一个值传递压缩值的例子不同,本例中传输数据前首先使用 FreeRTOS 自带的内存管理函数 pvPortMalloc 开辟一块内存空间(这块内存空间不在本任务的栈中,而是在堆中),通过 xTaskNotify 发送的时候,如果发送失败,则应该立刻使用 vPortFree 函数释放所开辟的空间。
同样,在收到数据后,也应该立即使用 vPortFree 释放空间。

关于直达任务通知的所有API,可以参考:https://www.freertos.org/zh-cn-cmn-s/RTOS-task-notification-API.html

  • 14
    点赞
  • 22
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值