freeRTOS Snippets zum Lernen

Moin in die Runde,

ich hatte vor ca. 4 Wochen (als die Welt noch in Ordnung schien) den ersten Kontakt mit freeRTOS, einer äußerst schlanken Multitasking Umgebung für embedded Controller, die auch in der Arduino IDE funktioniert. Da ich die offizielle Doku von freeRTOS als ausgesprochen abschreckend empfinde, habe ich mich zum Lernen mehr auf die wenigen, verfügbaren externe Tutorials und die deutlich bessere freeRTOS Doku auf den Espressif Seiten konzentriert.

Anfangs - und angesichts der momentanen Situation - hatte ich größte Probleme, meine Zeit zum Lernen zu nutzen. Mit der fortschreitenden „Gewöhnung“ an die extreme Isolation wird dies jedoch von Tag für Tag etwas besser.

Lernt man freeRTOS, so wird man zwangsläufig mit Begriffen wie „Queue“, „Semaphore“ und/oder „Mutex“ konfrontiert. Diese verlieren ihren Schrecken, wenn man sie isoliert und in simplen Beispielen betrachtet. Solange dies hier nicht unerwünscht ist, möchte ich euch an meinen Lernerfolgen teil haben lassen – und euch gleichzeitig ermuntern, auch mich mit eurem Wissen zu „bestäuben“. Mein Versuchsaufbau besteht aus einem ESP32, einem Taster und einem LCD Display. Beim ESP32 unter der Arduino IDE steht freeRTOS IMMER zur Verfügung (es muss nichts zusätzlich eingebunden werden), bei einem klassischen Arduino muss im Header die FreeRTOS Lib eingebunden werden. Gegebenenfalls auch Unterbibliotheken (kann ich nicht prüfen, da ich meinen Aufbau nicht jedes mal ändern will).

Für den ESP32 gibt es freeRTOS Erweiterungen, die echtes Multitasking (auf den 2,5 verfügbaren Kernen) erlauben. Ich bemühe mich speziell um Beispiele, die nicht exklusiv für den ESP32 gelten, also auf einem „Nano“ identisch ablaufen sollten (von mir ungeprüft).

Für wen ist dies hier geeignet?
Für alle User, die „Blink without delay“ grundsätzlich verstanden haben, und die „ihr“ Multitasking auf den nächsten Level heben wollen. Ich werde kein Fritzing nutzen. Ihr solltet also in der Lage sein, die minimale Verkabelung aus dem Quellcode zu entnehmen. Zudem kommentiere ich in einfachem Englisch.


Teil 1: Die „Queue“

Im ersten Beispiel nutzen wir eine primitive „Queue“ um Daten zwischen zwei Tasks (asynchron) zu übertragen. Die erste Task (“Count Task”) zählt ewig von 0 bis 9, und gibt den jeweiligen Zählerstand per „xQueueSend“ in die Queue. Die zweite Task (“Display Task”) wird nur wach, wenn es einen neuen Eintrag in der Queue gibt. In diesem Fall gibt sie den Wert auf einem LCD Display aus (könnte natürlich auch die Serial Konsole sein). Beide Tasks haben die gleiche Priorität (1). Der Scheduler von freeRTOS sorgt dafür, dass beide Tasks die nötige Aufmerksamkeit erhalten.

Dies ließe sich natürlich auch mit nur einer Task erzielen. Die LCD Ausgabe ist jedoch vergleichsweise langsam, und würde so die eine Task stark ausbremsen. Mittels separater Tasks geschieht dies nicht. Ist also als Real-World-Beispiel gut geeignet.

Auf einem ESP32 muss man nichts weiter einbinden, auf einem Arduino muss freeRTOS eingebunden werden (ggf. kann die Stack Size in den Tasks kleiner gewählt werden (ESP32 zählt in Bytes, freeRTOS Default (beim Arduino) in Words):

#include <Arduino_FreeRTOS.h>
#include <queue.h> // prüfen, ob tatsächlich noch erforderlich?

Der Beispielscode:

#include <Wire.h>
#include <LiquidCrystal_I2C.h>

// Create a Queue Handle for later use
QueueHandle_t queueLcd;

LiquidCrystal_I2C lcd(0x27, 20, 4);

void setup() {
  Serial.begin(115200);
  lcd.init();
  lcd.backlight();
  lcd.clear();
  lcd.setCursor (0, 1);
  lcd.print(F("Let's multitask"));
  lcd.setCursor (0, 2);
  lcd.print(F("Counter: "));

  // Setup a queue for 1 entry of 1 int length.
  queueLcd = xQueueCreate(1, sizeof(int));
  if (queueLcd == NULL) {
    Serial.println("Queue creation error");
  }
  xTaskCreate(task_count, "Count Task", 2048, NULL, 1, NULL); // On Arduino, required stack size (2048) might be smaller
  xTaskCreate(task_display, "Display Task", 2048, NULL, 1, NULL); // On Arduino, required stack size (2048) might be smaller
}

/* First task counts from 0 to 9 and feeds the current value
   into the queue for consumption by the other task */
void task_count(void * pvParameters) {
  int counter = 0;
  while (1) {   // do forever
    // Feed the current value of the counter into our queue
    // If the queue is not available, wait for it (portMAX_DELAY means "forever")
    xQueueSend(queueLcd, &counter, portMAX_DELAY);
    counter++;
    if (counter == 10) {
      counter = 0;
    }
    // Idle for 1 second (will be used by other tasks)
    vTaskDelay(pdMS_TO_TICKS(1000));
  }
}

/* Second tasks does NOTHING UNTIL new data for the LCD is available in the queue
   In that case, it outputs data to the LCD */
void task_display(void * pvParameters) {
  int numToDisplay = 0;
  while (1) {  // do forever
    if (xQueueReceive(queueLcd, &numToDisplay, portMAX_DELAY) == pdPASS) {  // pdPASS: only use NEW queue entry
      lcd.setCursor (8, 2);
      lcd.print(numToDisplay);
    }
  }
}
void loop() {
  // Just do nothing here - or do whatever you want
}

Viel Erfolg - und bleibt gesund!

Teil 2 – Semaphore Mutex

Wie verhindere ich in einer Multitasking Umgebung, dass unterschiedliche Tasks sich ins Gehege kommen, wenn sie auf eine gemeinsam genutzte Ressource (wie z.B. Serial Konsole, LCD,…) zugreifen? Eine Antwort darauf lautet „Mutex“ (mutual exclusion, zu Deutsch: wechselseitiger Ausschluss). Ein Mutex ist eine besondere Form des Semaphores, und im Grunde nichts weiter als eine Art „Fahne“ für exklusive Rechte. Eine Task kann diese Fahne anfordern (xSemaphoreTake), um nach dem Erhalt auf eine Ressource exklusiv zugreifen zu können. Da diese Fahne nur 1x existiert, kann eine andere Task sie nicht in Besitz nehmen, solange die erste Task sie noch in Händen hält. Diese zweite Task bleibt solange im Ruhemodus, bis die erste Task die Fahne zurück gibt (xSemaphoreGive). Jetzt schnappt sich die 2. Task die Fahne und nutzt die Ressource, während die erste im Ruhemodus verbleibt. Dies geht mit beliebig vielen Tasks.

Ein minimalistisches Beispiel ist die Serial Konsole. Würden zwei Tasks (fast oder tatsächlich) zeitgleich darauf zugreifen, so gäbe es im besten Fall Buchstabensalat.

Der Beispielscode:

// #include <Arduino_FreeRTOS.h>  // Uncomment on AVR (not on ESP32)
// #include <semphr.h>            // Uncomment on AVR (not on ESP32)

// Create a handle for the Serial Mutex
SemaphoreHandle_t xSerialMutex;

void task_1(void *pvParameters) {
  while (1) {
    xSemaphoreTake(xSerialMutex, portMAX_DELAY);
    Serial.println("Task 1 data");
    xSemaphoreGive(xSerialMutex);
    vTaskDelay(pdMS_TO_TICKS(100));
  }
}
void task_2(void *pvParameters) {
  while (1) {
    xSemaphoreTake(xSerialMutex, portMAX_DELAY);
    Serial.println("Task 2 data");
    xSemaphoreGive(xSerialMutex);
    vTaskDelay(pdMS_TO_TICKS(100));
  }
}
void setup() {
  Serial.begin(115200);
  // Build the Mutex Semaphore
  xSerialMutex = xSemaphoreCreateMutex();
  
  if (xSerialMutex != NULL) {
    xTaskCreate(task_1, "Task 1", 2048, NULL, 1, NULL);
    xTaskCreate(task_2, "Task 2", 2048, NULL, 1, NULL);
    //    vTaskStartScheduler(); // Uncomment on AVR (not on ESP32)
  } else {
    Serial.println("Mutex creation error");
  }
}

void loop() {
  // Do nothing here - or do whatever you want
}

Teil 3 – Task Notification

Im ersten Beispiel (Die „Queue“) haben zwei Tasks Daten ausgetauscht, ohne direkt miteinander zu „sprechen“. Zur Übermittlung diente eine Queue, also ein Kommunikationsobjekt, in welches eine Task schreibt, während eine andere daraus liest. Oft müssen aber gar keine Daten übermittelt werden, sondern lediglich Ereignisse. In diesem Falle geht es auch eine Nummer kleiner.

Tasks können direkt miteinander interagieren. Dazu dient die „Task Notification“, welche schneller ist als eine Queue und welche auch weniger Speicher erfordert. Es gibt aber auch kleine Einschränkungen: Mehr als 2 Tasks können nicht mittels „Task Notification“ interagieren und Tasks können keine Daten an ISR schicken, wenn sie „Task Notification“ nutzen.

Das folgende Beispiel benötigt nur einen Taster und die Serial Konsole. Der Taster wird per Interrupt überwacht und entprellt, und eine Task wird per „Task Notification“ (vTaskNotifyGiveFromISR) benachrichtigt, sobald er betätigt wurde. Diese gibt darauf den Tastendruck auf der Serial Konsole aus. Wird die Taste nicht betätigt, so bleibt die Ausgabe Task im Tiefschlaf.

Der Beispielscode:

// #include <Arduino_FreeRTOS.h>  // uncomment on AVR (not on ESP32)

#define BUTTON_PIN 32   // use pin with pullup capabilities or add a resistor.

TaskHandle_t xTask_1_handle = NULL;

void task_1(void * pvParameters) {
  while (1) {
    if (ulTaskNotifyTake(pdTRUE, portMAX_DELAY) != 0) {
      Serial.println("Button down");
    }
  }
}

// void isr() {     // uncomment on AVR, comment on ESP32
void IRAM_ATTR isr() {  // uncomment on ESP32, comment on AVR
  static unsigned long last_interrupt_time = 0;
  unsigned long interrupt_time = millis();
  // If interrupts come faster than 200ms, assume it's a bounce and ignore
  if (interrupt_time - last_interrupt_time > 200) {
    vTaskNotifyGiveFromISR(xTask_1_handle, NULL);
  }
  last_interrupt_time = interrupt_time;
}

void setup() {
  Serial.begin(115200);
  Serial.println("Let's check the button");
  pinMode(BUTTON_PIN, INPUT_PULLUP);
  attachInterrupt(digitalPinToInterrupt(BUTTON_PIN), isr, RISING);
  xTaskCreate(task_1, "Task 1", 2048, NULL, 1, &xTask_1_handle);
  // vTaskStartScheduler();  // uncomment on AVR
}

void loop() {
  // Do nothing here - or do whatever you want
}

Nur kurz, damit Du nicht denkst, das interessiert keinen. Ich kopiere mir jede Folge in eine lokale Datei, damit es mir nicht verloren geht. Also weiter so.

Gruß mTommy

Ja, ich schaue auch zu!

:o und noch keinen Grund zum meckern gefunden :o

Das freut mich zu hören - und ich mache gerne weiter, zudem es mich auch ablenkt.

Grüße, Thomas

Teil 4 – Event Groups

An früheren Beispielen haben wir gesehen, dass es verschiedene Möglichkeiten gibt, eine Task ruhen zu lassen, bis ein bestimmtes Ereignis eintritt, oder Daten zur Verarbeitung anstehen. Bislang war dies eher ein singuläres Ereignis. Was aber, wenn eine Task auf mehrere Ereignisse warten soll, die vielleicht auch noch von unterschiedlichen Tasks generiert werden? Soll heißen: Tue nix, bis alle anderen relevanten Tasks ihre Jobs verrichtet haben – und werde dann aktiv. Natürlich könnte man hier mit mehreren, verschachtelten Semaphores arbeiten. Dies wäre aber unnötig komplex und fehleranfällig.

Besser geht es mit „Event Groups“. Eine Event Group erlaubt es einer Task, im blockierten Status zu warten, bis ein oder mehrere Events eingetreten sind. Dies können durchaus sehr viele sein, denn es stehen 24 Bit zur Verfügung. Jedes Bit repräsentiert einen Event, der eintreten kann, oder auch nicht. Man kann einzeln auf diese Events triggern, oder auf eine ganze (beliebige) Gruppe.

Im folgenden Beispiel geben 3 Tasks ein Lebenszeichen in der Serial Console aus, und setzen zusätzlich „ihr“ Bit in der Event Group. Eine der Tasks (die erste) prüft (zusätzlich zu ihrer normalen Ausgabe), ob alle 3 Tasks gelaufen sind. Ist dies der Fall, so gibt sie „Task_1 - all tasks have run“ aus.

Der Beispielscode:

// #include <Arduino_FreeRTOS.h>  // Uncomment on AVR (not on ESP32)
// #include "event_groups.h"      // Uncomment on AVR (not on ESP32)

/* define event bits */
#define TASK_1_BIT        ( 1 << 0 ) // 1
#define TASK_2_BIT        ( 1 << 1 ) // 10
#define TASK_3_BIT        ( 1 << 2 ) // 100
#define ALL_TASKS_BITS    (TASK_1_BIT | TASK_2_BIT | TASK_3_BIT) // = 111

/* create event group handle */
EventGroupHandle_t e;

void task_1( void * parameter )
{
  while (1) {
    Serial.println("Task_1 done");
    /* Now Task_1 sets it's event bit */
    EventBits_t uxBits = xEventGroupSync(e, TASK_1_BIT, ALL_TASKS_BITS, portMAX_DELAY );
    /* Only run this part if all event bits from all tasks have been set*/
    if ( ( uxBits & ALL_TASKS_BITS ) == ALL_TASKS_BITS ) {
      Serial.println("Task_1 - all tasks have run");
    }
  }
}

void task_2( void * parameter )
{
  while (1) {
    Serial.println("Task_2 done");
    /* Before Task_2 finishes it sets it's event bit */
    EventBits_t uxBits = xEventGroupSync( e, TASK_2_BIT, ALL_TASKS_BITS, portMAX_DELAY );
  }
}

void task_3( void * parameter )
{
  while (1) {
    Serial.println("Task_3 done");
    /* Before Task_3 finishes it sets it's event bit */
    EventBits_t uxBits = xEventGroupSync( e, TASK_3_BIT, ALL_TASKS_BITS, portMAX_DELAY );
  }
}

void setup() {

  Serial.begin(115200);
  // Build event group
  e = xEventGroupCreate();

  xTaskCreate(task_1, "Task_1", 2048, NULL, 2, NULL);  // Highest priority task
  xTaskCreate(task_2, "Task_2", 2048, NULL, 1, NULL);
  xTaskCreate(task_3, "Task_3", 2048, NULL, 1, NULL);
//    vTaskStartScheduler(); // Uncomment on AVR (not on ESP32)
}

void loop() {
  // Do nothing here - or do whatever you want
}

Teil 5: Stack Size

Heute soll es nicht um Task Interaktionen gehen, sondern um die Ermittlung der Stack Size, die bei der Definition einer Task erforderlich ist. Beim Vanilla freeRTOS, welches auch auf AVR‘s läuft (zumindest wenn die Lib eingebunden ist), wird diese in Words angegeben, beim ESP32 hingegen in Bytes. Da die meisten Arduinos nicht gerade mit einem Überfluss an Speicher ausgestattet sind, macht gerade hier die Ermittlung des tatsächlich benötigten Speichers besonders Sinn.

Ein erster Ansatz kann hier ein banales Trial-and-Error sein. Man startet mit größerer Stack Size, testet die Funktion, und reduziert, wenn noch alles läuft. Usw. Hat man es übertrieben, und eine zu geringe Stack Size gewählt, so reagiert ein ESP32 z.B. wie folgt:

Guru Meditation Error: Core  0 panic'ed (Unhandled debug exception)
Debug exception reason: Stack canary watchpoint triggered (Task 2)

Es gibt jedoch auch eine freeRTOS Debug-Funktion (uxTaskGetStackHighWaterMark), die bei der Ermittlung des benötigten Stacks hilfreich ist. Sie ist sehr „teuer“, was aber in diesem Falle egal ist. Diese „Stack Hochwassermarke“ gibt den minimalen, nicht genutzten Teil des Stacks zurück, beim Arduino in Words, beim ESP32 in Bytes. Die Doku dazu ist ausgesprochen dürftig, und der zurück gegebene Wert scheint auch nur eine grobe Schätzung zu sein. Je kleiner der Rückgabe Wert, desto dichter ist man am Stack-Overflow. Etwas Reserve sollte immer bleiben. Die Position der Debug Ausgabe innerhalb der Task Funktion scheint egal (mein Eindruck nach mehreren Tests mit unterschiedlichen Positionen). Sicher bin ich mir hier jedoch nicht.

UBaseType_t uxTaskGetStackHighWaterMark(TaskHandle_t xTask)

Die Nutzung des Handles ist nur erforderlich, wenn man „uxTaskGetStackHighWaterMark“ aus einer anderen Task aufrufen will. Ruft man es direkt in der zu überwachenden Task auf, so reicht ein:

Serial.print("HWM_Task2: ");Serial.println(uxTaskGetStackHighWaterMark( NULL ));

Was meint "teuer" im Bezug auf die "Hochwassermarke" der Stackgröße?