直达任务通知是为了提升FreeRTOS中多任务键通讯的效率,降低RAM使用而发明的,自8.2版本之后就有了,自10.4之后的版本支持了单任务多条通知。
直达任务通知有点类似于μC/OS或者FreeRTOS中的Single(信号),但比那些东西好用,直达任务通知是直接发送至任务的事件, 而不是通过中间对象 (如队列、事件组或信号量)间接发送至任务的事件。 向任务发送“直达任务通知” 会将目标任务通知设为“挂起”状态。 正如任务可以阻塞中间对象 (如等待信号量可用的信号量),任务也可以阻塞任务通知, 以等待通知状态变为“挂起”。
直达任务通知具有高度灵活性,使用时无需 创建单独 队列、 二进制信号量、 计数信号量 或事件组。 通过直接通知解除 RTOS 任务阻塞状态的速度和使用中间对象(如二进制信号量)相比快了 45% , 使用的 RAM 也更少 。
不过 这些性能优势也有一些意料之内的使用限制:
在ESP32中,menuconfig中可以设置直达任务通知是否打开,并且可以设置直达任务通知的数量,默认情况下直达任务通知是打开的,并且只有一条可用。
可以通过 configUSE_TASK_NOTIFICATIONS 查看直达任务通知是否打开,通过查看 configTASK_NOTIFICATION_ARRAY_ENTRIES 确认每个任务有多少条直达任务通知。
如果在项目中用不到直达任务通知,建议关闭,关闭后每个任务控制块将节省8个字节的空间(还是那句话,寸土寸金,悠着点用)。
每条任务通知 都有“挂起”或“非挂起”的通知状态, 以及一个 32 位通知值,通知值可以理解为事件组,只不过事件组最多只有3个字节,而且仅能按位使用,而直接任务通知中的数据位有4个字节,不仅可以按位使用,而且还可以作为byte(int8/uint8)、short(int16/uint16)、int(int32/uint32)等值类型存储数据,或者当作指针类值存储地址,灵活性非常强。
代码共享位置: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则表示正常获取,否则有可能是超时。
直达任务通知作为信号量使用时,与真正的信号量有所不同:
直达任务通知包含有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左移方式进行对比。
直达任务通知和事件组有以下不同点:
通过上面的实验我们已经验证,直达任务通知包含一个拥有四字节数据的值,上面例程中已经演示过当做二进制信号量和事件组使用,同样的,我们可以将这个4字节的值作为一个轻量级的消息队列使用,之所以说是“轻量级”,是因为该消息队列中最多只能存储一条消息,当消息已经存在而又想继续发送消息时,我们只能采取三种方式对消息进行处理,一种是覆盖原来的消息,一种是忽略本次发送。
没错,通过这种方式发送消息,无法实现等待,这也是和消息队列不同的地方。
以下例程模拟了一个智能家居网管系统, clock_task 表示时钟模块,通过读取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芯片:
代码共享位置: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