Перейти к содержимому

Практические примеры программирования задач FreeRTOS

Добрый день, уважаемые читатели!

Я уже несколько раз писал статьи про то, что такое задачи FreeRTOS, как передавать в задачу данные извне, какие объекты FreeRTOS для этого можно использовать и т.д. На эту тему написано множество прекрасных академических статей и на других ресурсах. Но новичку, который впервые сталкивается с FreeRTOS, бывает довольно сложно понять – а как все это реализовать на практике??? Какой, например, объект синхронизации (очередь, событие, группу бит и т.д.) выбрать в текущей задаче, стоящей перед вами? Зачем хитромудрые разработчики напридумывали так много разного – чтобы жизнь медом не казалась? А вот и нет – для каждого применения свой инструмент. И в этой статье как раз и поговорим на эту тему.

В этот раз я решил подойти к этому вопросу с другой стороны – чисто с практической. Возьмем часто встречающуюся задачу изменения чего-либо и отправки данных куда-либо, а затем автоматического регулирования какого-либо параметра (проще всего температуры) с обратной связью по измененным данным и будем её реализовывать. Ну и  будет все это дело совершенствовать – от простого к сложному. Во время этого процесса я постараюсь на основе опыта продемонстрировать, какие проблемы могут возникнуть на этом пути, на какие грабли можно наступить и какие инструменты можно применить в данной конкретной ситуации.

Однако не следует понимать это так: простые варианты – это однозначно плохо, сложные – однозначно хорошо. Для каждой вашей конкретной задачи вы можете и должны выбрать наиболее подходящий в данный момент вариант. Это и есть ваша главная задача как программиста – находить максимально адекватные способы решения задачи. А для этого их необходимо хотя бы в общих чертах понимать. Советую вам воcпринимать данную статью не как академический текст, а как информацию для размышления. Вполне возможно, и вы сами можете подкинуть мне парочку идей на будущее в комментариях.

 


Общие сведения

1. Несмотря на то, что все примеры из данной статьи написаны для ESP32 и фреймворка ESP-IDF, следует понимать, что фреймворк Arduino32 для ESP32, точно также как и ESP-IDF, функционирует с помощью FreeRTOS, а посему все нижесказанное относится и к Arduino32. С некоторыми отличиями, конечно – в части работы с GPIO например.

2. Заготовку для наших задач применим из урока, посвященного задачам FreeRTOS, саму задачу оформим статическим методом. В примерах к статье я буду записывать каждый вариант прикладной задачи в виде локальной библиотеки проекта, однако при запуске буду использовать только одну из них. Функцию создания прикладной задачи я также перенес в библиотеку, из app_main() вызывается только библиотечная функция app_tast_start(), которая собственно и создает задачу:

#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_random.h"
#include "esp_log.h"
#include "task01.h"

const char* logTAG  = "TASK1";

#define APP_TASK_STACK_SIZE 4096
#define APP_TASK_PRIORITY   5
#define APP_TASK_CORE       1

// Статический буфер под служебные данные задачи
static StaticTask_t xTaskBuffer;
// Статический буфер под стек задачи
static StackType_t xStack[APP_TASK_STACK_SIZE];

// Функция задачи
void app_task_exec(void *pvParameters)
{
  while(1) {
    ...
  };
  vTaskDelete(NULL);
}

void app_task_start()
{
  // Запуск задачи с статическим выделением памяти
  TaskHandle_t xHandle = xTaskCreateStaticPinnedToCore(app_task_exec, "app_task", 
    APP_TASK_STACK_SIZE, NULL, APP_TASK_PRIORITY, xStack, &xTaskBuffer, APP_TASK_CORE);
  if (xHandle == NULL) {
    ESP_LOGE(logTAG, "Failed to task create");
  } else {
    ESP_LOGI(logTAG, "Task started");
  };
}

3. Как вы уже знаете,  любая задача FreeRTOS – это бесконечный цикл. Я, наверное, уже не раз писал – задача FreeRTOS не должна выполняться непрерывно. Несмотря на то, что FreeRTOS и ESP-IDF – это многозадачная система. Иначе будут могут быть проблемы – планировщик в нормальном состоянии отдает управление другой задаче только когда текущая задача ушла в спячку (по любой причине). Да и сторожевой таймер WDT не даст вам этого сделать. Поэтому любая задача должна периодически и добровольно прекращать свою созидательную или бесполезную деятельность. Еще одна причина сделать это – если вы будете слишком часто опрашивать датчики температуры, то они могут само-разогреваться и искажать выходные данные. Поэтому добавим в главный цикл задачи соответствующую паузу на добровольный сон.

Ссылки на демо-проект к статье вы найдете на моем GitHub, как обычно.

 


1. Простой автономный цикл измерений с постоянным интервалом

Для начала давайте создадим задачу, которая что-то там периодически делает, например измеряет какую либо температуру (воздуха в помещении, воды в котле, тела сферического коня в вакууме и т.д.). А затем выводит полученные данные в системный лог – даже ничего не регулирует и никуда не отправляет. Тупо и просто: измерили – напечатали текст в UART. Всё!

То есть под фразой “автономный цикл” здесь понимается то, что задача не получает никаких дополнительных указаний и данных извне. Я написал “измерений”, но на самом деле в задаче может выполняться вообще что угодно.

Дабы не усложнять код, я даже не стал писать код опроса реального датчика, а вместо этого создал простейшую функцию-заглушку – для понимания этого вполне достаточно. При необходимости вы всегда можете вставить туда код чтения данных с сенсора:

// Получение результатов измерений датчика
float readSensor1()
{
  // В данном демопроекте мы ничего мерять не будем - это функция-имитация.
  // Но вы можете вставить сюда реальное чтение датчика
  return (float)esp_random() / 100000000.0;
}

// Функция задачи
void app_task_exec(void *pvParameters)
{
  uint8_t i = 0;

  // Бесконечный цикл задачи
  while(1) {
    // Читаем данные с сенсора
    float value = readSensor1();

    // Выводим сообщение в терминал
    ESP_LOGI(logTAG, "Read sensor: value = %.1f", value);

    // Пауза 10 секунд
    vTaskDelay(pdMS_TO_TICKS(10000));
  };

  // Сюда мы не должны добраться никогда. Но если "что-то пошло не так" - нужно всё-таки удалить задачу из памяти
  vTaskDelete(NULL);
}

Для формирования паузы между измерениями я применил самую обычную функцию vTaskDelay(), которая формирует задержку в тиках операционной системы без блокирования всех остальных задач – она просто отдает управление планировщику на заданное время. Подробнее об этом читайте здесь. Таким образом, мы должны получить паузу в ~ 10 секунд между измерениями. Проверяем, так ли это:

Как видите, пока все работает как часы. А почему бы и не работать? Во-первых, функция-заглушка выполняется за очень короткое, и самое главное, константное время. Во вторых – в остальной части рабочего цикла также нет никаких существенных задержек.

Но на практике такое поведение практически никогда не выполнимо. Как только в цикле задачи появятся условия if (…) { then(); } else { else(); }, либо добавиться, например, отправка данных на сервер за пределами чипа – схема с постоянной задержкой сломается из-за непредсказуемых задержек выполнения кода с переменной длительностью. Как это обойти – мы рассмотрим в следующей части.

Выводы: данный вариант реализации рабочего цикла задачи вполне можно применять, если ваш цикл максимально краток и стабилен по времени выполнения. Либо если вам абсолютно не важно поддерживать “ровные” интервалы времени между измерениями, либо выполнением другой полезной нагрузки.

 


2. Простой автономный цикл измерений с адаптивным интервалом

Данный вариант является усовершенствованным вариантом предыдущего варианта. Здесь также под фразой “автономный цикл” здесь понимается то, что задача не получает никаких дополнительных указаний и данных извне.

Для имитации отправки данных на сервер создадим ещё одну функцию-имитацию, длительность задержки которой будем вычислять случайным образом. Но вызывать её будем не в каждом цикле, а, к примеру, один раз в 30 секунд.

// Получение результатов измерений датчика
float readSensor1()
{
  // В данном демопроекте мы ничего мерять не будем - это функция-имитация.
  // Но вы можете вставить сюда реальное чтение датчика
  return (float)esp_random() / 100000000.0;
}

// Отправка результатов измерений куда-то на сервер
void sendHttpRequest(const float value)
{
  // В данном демопроекте мы ничего отправлять не будем - это функция-имитация.
  // Но вы можете вставить сюда реальную отправку данных на сервер HTTP-запросом
  uint32_t delay = esp_random() / 10000000;
  ESP_LOGI(logTAG, "Send data, delay %u ms", (uint)delay);
  vTaskDelay(pdMS_TO_TICKS(delay));
}

// Функция задачи
void app_task_exec(void *pvParameters)
{
  uint8_t i = 0;

  // Бесконечный цикл задачи
  while(1) {
    // Читаем данные с сенсора
    float value = readSensor1();

    // Выводим сообщение в терминал
    ESP_LOGI(logTAG, "Read sensor: value = %.1f", value);

    // Один раз в 30 секунд отправляем данные на сервер
    i++;
    if (i >= 3) {
      i = 0;
      sendHttpRequest(value);
    };
    
    // Пауза 10 секунд
    vTaskDelay(pdMS_TO_TICKS(10000));
  };

  // Сюда мы не должны добраться никогда. Но если "что-то пошло не так" - нужно всё-таки удалить задачу из памяти
  vTaskDelete(NULL);
}

Если залить данный вариант в ESP32, ситуация с интервалами станет менее радостной:

В принципе, если разница в интервалах между отдельными циклами небольшая, либо если она вам вообще не важна – на это можно закрыть глаза. Но представьте, что в сети резко возрос трафик, и задержки выросли до больших значений, а интервал нам требуется поддерживать более – менее стабильным. Можно исправить ситуацию? Да легко! Используем vTaskDelayUntil() вместо vTaskDelay().

// Получение результатов измерений датчика
float readSensor1()
{
  // В данном демопроекте мы ничего мерять не будем - это функция-имитация.
  // Но вы можете вставить сюда реальное чтение датчика
  return (float)esp_random() / 100000000.0;
}

// Отправка результатов измерений куда-то на сервер
void sendHttpRequest(const float value)
{
  // В данном демопроекте мы ничего отправлять не будем - это функция-имитация.
  // Но вы можете вставить сюда реальную отправку данных на серсер HTTP-запросом
  uint32_t delay = esp_random() / 10000000;
  ESP_LOGI(logTAG, "Send data, delay %u ms", (uint)delay);
  vTaskDelay(pdMS_TO_TICKS(delay));
}

// Функция задачи
void app_task_exec(void *pvParameters)
{
  uint8_t i = 0;
  TickType_t prevWakeup = 0;

  // Бесконечный цикл задачи
  while(1) {
    // Читаем данные с сенсора
    float value = readSensor1();

    // Выводим сообщение в терминал
    ESP_LOGI(logTAG, "Read sensor: value = %.1f", value);

    // Один раз в 30 секунд отправляем данные на сервер
    i++;
    if (i >= 3) {
      i = 0;
      sendHttpRequest(value);
    };
    
    // Пауза 10 секунд
    vTaskDelayUntil(&prevWakeup, pdMS_TO_TICKS(10000));
  };

  // Сюда мы не должны добраться никогда. Но если "что-то пошло не так" - нужно всё-таки удалить задачу из памяти
  vTaskDelete(NULL);
}

Ситуация сразу же исправится – интервалы между циклами вновь стали ровными:

Выводы: данный вариант реализации рабочего цикла задачи вполне можно применять, если вы хотите создать полностью автономный цикл с равномерными интервалами вне зависимости от того, какова реальная длительность этого цикла. 

 


3. Задача, ожидающая входящих данных из входящей очереди

Другой распространенный пример реализации задач FreeRTOS – когда задача запускается в работу только по мере поступления каких-либо данных извне. Классический пример – отправка данных на какой-нибудь web-сервис. В этом случае нам не требуется соблюдать равные интервалы между циклами – это будет только мешать. Поэтому можно ждать входящих данных сколь угодно долго, не потребляя ресурсы процессора.

Для получения данных извне чаще всего удобнее применять очередь, при этом её можно использовать как для получения данных с фиксированной длиной, так и с заранее неизвестным размером – например строк в динамической памяти. Вначале рассмотрим передачу простых типов данных в задачу посредством очереди.

Допустим, какие-либо данные у нас измеряются в одной задаче, а отправлять их в интернет должна другая задача. Данные с фиксированным размером передавать через очередь очень просто. Наша задача получения данных их очереди и отправки их на сервер будет выглядеть так:

// Функция задачи
void app_task_exec(void *pvParameters)
{
  float value = 0.0;

  // Бесконечный цикл задачи
  while(1) {
    // Ждем данные извне
    while (xQueueReceive(dataQueue, &value, portMAX_DELAY) == pdPASS) {
      // Выводим сообщение в терминал
      ESP_LOGI(logTAG, "Value recieved: %.1f", value);

      // Отправляем данные на сервер
      sendHttpRequest(value);
    };
  };

  // Сюда мы не должны добраться никогда. Но если "что-то пошло не так" - нужно всё-таки удалить задачу из памяти
  vTaskDelete(NULL);
}

Функция xQueueRecieve() ждет появления данных в очереди, и ждет бесконечно долго. То есть пока данных нет – задача спит, и не просыпается даже 1 раз в 10 секунд, как прежде. Можно, конечно, сделать так, чтобы этот сон был не так крепок – и этот вариант мы рассмотрим чуть ниже. А пока продолжим.

Чтобы поместить данные в эту самую очередь, напишем еще одну функцию. Напомню, что при помещении данных в очередь создается новая копия в динамической памяти, поэтому для передачи данных легко можно использовать локальные переменные.

// Помещаем данные в очередь данной задачи
void insertValueIntoQueue(const float value)
{
  xQueueSend(dataQueue, &value, portMAX_DELAY);
}

Здесь есть потенциальная проблема. Функция xQueueSend() позволяет указать время ожидания, дабы поместить данные в очередь. И я указал portMAX_DELAY – то есть “ждать вечно”. Хорошо это или плохо? В обычной ситуации – нормально. Но допустим на минуточку, внутри нашей “задачи что-то пошло не так” (интернет пропал, например, и наша отправка данных встала попой кверху) и задача зависла. Что произойдет дальше? Остальные задачи прошивки работают, и прилежно помещают данные в очередь. Через какое-то время очередь заполняется полностью – и все отправляющие в очередь задачи зависают на попытке отправить данные, так как в последнем аргументе задано portMAX_DELAY. Поэтому не стоит делать так как в примере выше – гораздо лучше указывать реальное время ожидания отправки, либо 0 (совсем без ожидания). Ну а для примера – вполне сгодится и так.

Ну а для осуществления передачи данных немного модифицируем главную функцию нашего приложения – перенесем цикл “измерений” туда:

void app_main() 
{
  // Инициализация генератора RNG, если RF (WiFi/BT) отключены
  bootloader_random_enable();

  // Запуск прикладной задачи из локальной библиотеки
  app_task_start();

  // Бесконечный цикл передачи данных в очередь задачи #03
  while (1) {
    insertValueIntoQueue((float)esp_random() / 100000000.0);
    vTaskDelay(pdMS_TO_TICKS(10000));
  };
}

В этом случая интервал между отправками определяется частотой появления данных в входящий очереди, то есть циклом в app_main(), а задержки при отправке данных на сервер не влияют до тех пор, пока они не превышают интервала поступления новых данных. Проверяем, все работает отлично:

Выводы: данный вариант реализации задачи идеально подходит для создания служб периодической отправки данных на внешние сервисы накопления данных, типа народного мониторинга. 

 


4. Задача с передачей строк переменной длины посредством очереди

Ещё один весьма распространенный случай предыдущей реализации – когда через очередь требуется передача строк, размещенных в динамической памяти, и с заранее неизвестной длиной. Например это могут быть сразу несколько параметров HTTP-запроса или текст сообщения в telegram.

Изначально обычная очередь не может манипулировать данными с переменной длиной. Поэтом нам придется размещать строки в динамической памяти, а через очередь будут передаваться только указатели на них. Сама задача не сильно отличается от предыдущего варианта:

// Отправка результатов измерений куда-то на сервер
void sendHttpRequest(char* text)
{
  // В данном демопроекте мы ничего отправлять не будем - это функция-имитация.
  // Но вы можете вставить сюда реальную отправку данных на серсер HTTP-запросом
  uint32_t delay = esp_random() / 10000000;
  ESP_LOGI(logTAG, "Send data, delay %u ms", (uint)delay);
  vTaskDelay(pdMS_TO_TICKS(delay));
}

// Функция задачи
void app_task_exec(void *pvParameters)
{
  char* text = NULL;

  // Бесконечный цикл задачи
  while(1) {
    // Ждем данные извне
    while (xQueueReceive(dataQueue, &text, portMAX_DELAY) == pdPASS) {
      if (text != NULL) {
        // Выводим сообщение в терминал
        ESP_LOGI(logTAG, "Text recieved: %s", text);

        // Отправляем данные на сервер
        sendHttpRequest(text);

        // И не забываем удалить текст из памяти
        free(text);
      };
    };
  };

  // Сюда мы не должны добраться никогда. Но если "что-то пошло не так" - нужно всё-таки удалить задачу из памяти
  vTaskDelete(NULL);
}

Главное – не забывать своевременно удалять обработанные строки из памяти, иначе она (память) закончится очень быстро.

// Помещаем указатель на строку в очередь данной задачи
void insertStringIntoQueue(char* text)
{
  xQueueSend(dataQueue, &text, portMAX_DELAY);
}

Процесс генерации строк для передачи я модифицировал так:

void app_main() 
{
  // Инициализация генератора RNG, если RF (WiFi/BT) отключены
  bootloader_random_enable();

  // Запуск прикладной задачи из локальной библиотеки
  app_task_start();

  // Бесконечный цикл передачи строк в очередь задачи #04
  uint32_t i = 0;
  while (1) {
    i++;
    char* text = malloc_stringf("String value: %i", i);
    insertStringIntoQueue(text);
    vTaskDelay(pdMS_TO_TICKS(10000));
  };
}

Специально для данного демо-проекта я создал микро-библиотечку dyn_strings, которая позволяет “на лету” формировать в куче строки на основе принципов форматирования строк. Её вы найдете в папке lib/task04. Её вы можете использовать и в своих проектах.

Выводы: данный вариант реализации задачи подходит для создания служб периодической отправки или обработки строк или массивов переменной длины. Например его можно использовать для отправки сведений сразу с нескольких датчиков на тот же самый народный мониторинг. Или сообщений в telegram.

 


5. Цикл измерений с фиксированным интервалом и реакцией на внешние события

Хорошо, с простыми случаями разобрались. Теперь давайте усложним условия задачи №1. Нам требуется выполнять те же самые действия в нашей программе (например измерения и обработку / отправку данных) с заданной периодичностью, но теперь нам дополнительно требуется реагировать на внешние события. Например это могут быть:

  • сигналы прерываний от кнопок (GPIO)
  • временные метки начала каждой минуты / часа / суток …
  • системные события, например при подключении к сети и отключения от неё
  • и так далее

В частности в реальных устройствах автоматизации очень часто требуется обработка прерываний от клавиатуры. Конечно, в случае с кнопками можно вообще отказаться от прерываний, а вместо этого просто периодически читать состояние GPIO. Но – читать состояние входов, скажем, один раз в 10 секунд – хм…. весьма и весьма сомнительная идея, а повышать частоту опроса – лишний расход ресурсов и, кроме того, может привести к  саморазогреву сенсоров температуры. Конечно, можно придумать алгоритмы – костыли, но это не наш метод. Давайте сделаем “по науке”, как положено. То есть с обработчиками прерываний.

Здесь кроется другая засада. При работе с прерываниями никогда не стоит забывать, что практически все обработчики прерываний выполняются в ином контексте, нежели наша задача. И поэтому прямой доступ к внутренним переменным задачи из обработчика прерываний крайне не желателен (примечание: если операция записи в какую-либо переменную является атомарной операцией, то можно, конечно, изменить её и напрямую из обработчика прерывания – но это скорее исключение, чем правило). Гораздо правильнее использовать для этих целей либо флаги группы событий либо туже самую очередь, как мы и рассматривали выше.

Обычно в таких я использую группу событий, а разным внешним сигналам назначаю разные биты, при этом одновременно можно использовать до 24 различных сигналов – практически всегда этого достаточно. Причем биты можно устанавливать не только из обработчиков прерываний, но и из других источников – например обработчиков событий. Конечно, не возбраняется использовать и другие средства синхронизации данных между потоками.

Как же теперь сформировать временные интервалы? А очень просто. Давайте вспомним, что в любой функции ожидания (события или данных очереди) есть аргумент xTicksToWait, с помощью которого можно задать время ожидания этого события. Ну например:

EventBits_t xEventGroupWaitBits(EventGroupHandle_t xEventGroup, const EventBits_t uxBitsToWaitFor, const BaseType_t xClearOnExit, const BaseType_t xWaitForAllBits, TickType_t xTicksToWait)

Им мы и воспользуемся. И никакие vTaskDelay() нам уже не понадобятся. Для начала добавим переменные под группу бит, а также определим биты, которые мы будем использовать для оповещения задачи о том, что нажата какая либо кнопка:

// Номера GPIO, к которым подключены кнопки
#define GPIO_BUTTON1            GPIO_NUM_16
#define GPIO_BUTTON2            GPIO_NUM_17
#define GPIO_BUTTON3            GPIO_NUM_18

// Флаги
#define FLG_BUTTON1_PRESSED     BIT0
#define FLG_BUTTON2_PRESSED     BIT1
#define FLG_BUTTON3_PRESSED     BIT2

#define FLGS_BUTTONS            (FLG_BUTTON1_PRESSED | FLG_BUTTON2_PRESSED | FLG_BUTTON3_PRESSED)

Для примера я определил сразу несколько бит – дабы было понятно, как работать с несколькими внешними событиями.

Затем нам потребуется написать функцию – обработчики прерываний:

// Обработчик прерывания по нажатию кнопки
static void IRAM_ATTR isrButton1Press(void* arg)
{
  // Переменные для переключения контекста
  BaseType_t xHigherPriorityTaskWoken, xResult;
  xHigherPriorityTaskWoken = pdFALSE;
  // Поскольку мы "подписались" только на GPIO_INTR_NEGEDGE, мы уверены что это именно момент нажатия на кнопку
  xResult = xEventGroupSetBitsFromISR(xEventGroup, FLG_BUTTON1_PRESSED, &xHigherPriorityTaskWoken);
  // Если другая задача ждет этого события, передаем управление её досрочно (до завершения текущего тика ОС)
  if (xResult == pdPASS) {
    portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
  };
}

В этом обработчике при срабатывании прерывания просто устанавливается в сигнальное состояние соответствующий бит. В данном примере нет никакой программной обработки дребезга контактов, если вам она необходима в реальном проекте, обратитесь с этой статье.

Теперь можно настроить GPIO и прерывания:

// Настройка GPIO и прерываний
void gpioInit()
{
  // Устанавливаем сервис GPIO ISR service
  esp_err_t err = gpio_install_isr_service(0);
  if (err == ESP_ERR_INVALID_STATE) {
    ESP_LOGW("ISR", "GPIO isr service already installed");
  };

  // Настраиваем вывод для кнопки: активный уровень низкий со встроенной подяжкой к питанию
  gpio_reset_pin(GPIO_BUTTON1);
  gpio_set_direction(GPIO_BUTTON1, GPIO_MODE_INPUT);
  gpio_set_pull_mode(GPIO_BUTTON1, GPIO_PULLUP_ONLY);

  // Регистрируем обработчик прерывания на нажатие кнопки
  gpio_isr_handler_add(GPIO_BUTTON1, isrButton1Press, NULL);
  
  // Устанавливаем тип события для генерации прерывания - по низкому уровню
  gpio_set_intr_type(GPIO_BUTTON1, GPIO_INTR_NEGEDGE);
  
  // Разрешаем использование прерываний
  gpio_intr_enable(GPIO_BUTTON1);
}

И, в заключении, модифицируем главный цикл нашей задачи:

// Функция задачи
void app_task_exec(void *pvParameters)
{
  uint8_t i = 0;
  EventBits_t uxBits;

  // Настройка GPIO и прерываний
  gpioInit();

  // Бесконечный цикл задачи
  while(1) {
    // Ждем либо нажатия на кнопку, либо таймаута 10 секунд
    uxBits = xEventGroupWaitBits(
      xEventGroup,               // Указатель на группу событий
      FLGS_BUTTONS,              // Какие биты мы ждем
      pdTRUE,                    // Сбросить уcтановленные биты, после того как они были прочитаны
      pdFALSE,                   // Любой бит (даже один) приведет к выходу из ожидания
      pdMS_TO_TICKS(10000));     // Период ожидания 10 секунд

    // Проверяем, были ли нажатия на какую-либо кнопку
    if ((uxBits & FLG_BUTTON1_PRESSED) != 0) {
      // Была нажата кнопка 1, обрабатываем
      ESP_LOGI(logTAG, "Button 1 pressed");
    } 
    else if ((uxBits & FLG_BUTTON2_PRESSED) != 0) {
      // Была нажата кнопка 2, обрабатываем
      ESP_LOGI(logTAG, "Button 2 pressed");
    } 
    else if ((uxBits & FLG_BUTTON3_PRESSED) != 0) {
      // Была нажата кнопка 3, обрабатываем
      ESP_LOGI(logTAG, "Button 3 pressed");
    } 
    else {
      // Нажатия на кнопку не было, задача была разблокирована по таймауту 10 секунд

      // Читаем данные с сенсора
      float value = readSensor1();

      // Выводим сообщение в терминал
      ESP_LOGI(logTAG, "Read sensor: value = %.1f", value);

      // Один раз в 30 секунд отправляем данные на сервер
      i++;
      if (i >= 3) {
        i = 0;
        sendHttpRequest(value);
      };
    };
  };

  // Сюда мы не должны добраться никогда. Но если "что-то пошло не так" - нужно всё-таки удалить задачу из памяти
  vTaskDelete(NULL);
}

Работает это так:

Функция xEventGroupWaitBits ждет либо любых установленных бит, либо заданного таймаута в 10 секунд. Если было нажатие на кнопку, сработает обработчик прерываний и установит бит GPIO_BUTTON1 ( BIT0 ) в сигнальное состояние. При выходе xEventGroupWaitBits вернет состояние, какие биты были установлены, а какие нет. Для проверки данного факта мы должны сравнить результат через операцию логическое И с нужными нам битами – и исходя из этого мы можем определить, какие именно кнопки были нажаты (в примере инициализирована только одна). Если кнопка была нажата – мы просто обрабатываем это событие, например выводим сообщение в лог. Если произошел таймаут – выполняем обычный цикл.

А что у нас с интервалами? Пока нет внешних событий, интервал между двумя соседними циклами будет всегда равен заданному таймауту ожидания (например 10 секунд), либо больше – если была нажата кнопка (или меньше, если вы решите выполнять обычную работу и при реакции на события тоже). То есть скажем, если “полезная работа” выполняется скажем от 0.5 до 5 секунд в зависимости от обстоятельств, то полное время цикла будет составлять от 10.5 до 15 секунд для данного примера.

Вместо EventGroup можно использовать очередь, как в предыдущих примерах. Но группа событий “по умолчанию” дает возможность реагировать на несколько разных флагов одновременно, а очередь предполагает события одного типа. Впрочем – это больше дело вкуса, через очередь можно также передавать и группы бит (целые числа) и структуры и т.д. – решаете этот вопрос исходя из вашей задачи.

Выводы: данный вариант хорошо подходит для реализации задач с более-менее равномерными интервалами измерений и реакцией на внешние события. Но интервал между двумя “соседними” циклами будет всегда либо равен заданному таймауту, либо немного большим из-за реакции на внешние раздражители.

 


6. Цикл измерений с адаптивным интервалом и реакцией на внешние события

А можно сделать в последнем примере нечто подобное vTaskDelayUntil? Можно конечно! Нам просто придется посчитать время, затраченное на выполнение полезной работы в цикле, и вычесть его из времени ожидания. Тогда наш цикл станет более-менее равномерным.

Добавим в нашу программу парочку дополнительных переменных: время последнего начала “рабочего” цикла и длительность ожидания в тиках:

// Период ожидания 10 секунд "по умолчанию"
TickType_t waitTicks = pdMS_TO_TICKS(10000);
// Время последнего времени чтения данных с сенсоров
TickType_t readTicks = xTaskGetTickCount();
while(1) {
  ...
};

Затем модифицируем цикл задачи следующим образом:

while(1) {
  // Ждем либо нажатия на кнопку, либо таймаута 10 секунд
  uxBits = xEventGroupWaitBits(
    xEventGroup,               // Указатель на группу событий
    FLGS_BUTTONS,              // Какие биты мы ждем
    pdTRUE,                    // Сбросить уcтановленные биты, после того как они были прочитаны
    pdFALSE,                   // Любой бит (даже один) приведет к выходу из ожидания
    waitTicks);                // Расчетный период ожидания в тиках

  // Проверяем, были ли нажатия на какую-либо кнопку
  if ((uxBits & FLG_BUTTON1_PRESSED) != 0) {
    // Была нажата кнопка 1, обрабатываем
    ESP_LOGI(logTAG, "Button 1 pressed");
  } 
  else if ((uxBits & FLG_BUTTON2_PRESSED) != 0) {
    // Была нажата кнопка 2, обрабатываем
    ESP_LOGI(logTAG, "Button 2 pressed");
  } 
  else if ((uxBits & FLG_BUTTON3_PRESSED) != 0) {
    // Была нажата кнопка 3, обрабатываем
    ESP_LOGI(logTAG, "Button 3 pressed");
  } 
  else {
    // Запоминаем время последнего чтения данных с сенсоров и выполения полезной работы
    readTicks = xTaskGetTickCount();

    // Читаем данные с сенсора
    float value = readSensor1();

    // Выводим сообщение в терминал
    ESP_LOGI(logTAG, "Read sensor: value = %.1f", value);

    // Один раз в 30 секунд отправляем данные на сервер
    i++;
    if (i >= 3) {
      i = 0;
      sendHttpRequest(value);
    };
  };
  
  // Считаем время ожидания следующего цикла
  TickType_t currTicks = xTaskGetTickCount();    
  if ((currTicks - readTicks) >= pdMS_TO_TICKS(10000)) {
    // С момента последнего выполнения цикла измерений readTicks прошло больше времени, чем нужно
    waitTicks = 0;
  } else {
    // Из периода ожидания 10 секунд вычитаем то количество тиков, которое уже прошло с момента readTicks
    waitTicks = pdMS_TO_TICKS(10000) - (currTicks - readTicks);
  };
};

В результате этих нехитрых манипуляций мы вновь получим относительно равномерный цикл измерений, и при этом сохраним возможность немедленной реакции на внешние раздражители.

Выводы: данный вариант хорошо подходит для реализации задач с равномерными интервалами измерений и реакцией на внешние события. При этом время между двумя циклами измерения будет поддерживаться по возможности одинаковым.

 


Ссылки на демо-проект к статье вы найдете на моем GitHub, как обычно. Если у вас есть ещё интересные варианты для дополнения статьи – пожалуйста, оставляйте их в комментариях. Разрешите откланяться, с вами был ваш Александр aka kotyara12. До следующих встреч на сайте и на telegram-канале! 

💠 Полный архив статей вы найдете здесь


Пожалуйста, оцените статью:
[ 5 из 5, всего 2 оценок ]

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *