FreeRTOS ist ein – in Partnerschaft mit führenden Chipherstellern – permanent weiterentwickeltes und etabliertes Echtzeitbetriebssystem (RTOS) für eingebettete Systeme. FreeRTOS ist quelloffen und wird unter der MIT Open-Source-Lizenz vertrieben.
Das Betriebssystem besteht im Wesentlichen aus einem Kernel mit Echtzeitscheduling und verschiedenen Bibliotheken. Die Software steht auf freertos.org kostenlos zur Verfügung. Das Echtzeitscheduling ermöglicht es, FreeRTOS in Anwendungen zu implementieren, in denen Echtzeitanforderungen gestellt werden.
FreeRTOS eignet sich grundsätzlich für Anwendungen mit harten und weichen Echtzeitanforderungen. Der Kernel unterstützt deterministisches Scheduling und priorisierte Tasks, sodass zeitkritische Abläufe zuverlässig ausgeführt werden können. Ob jedoch tatsächlich harte Echtzeit erreicht werden kann, hängt nicht allein vom Betriebssystem ab, sondern maßgeblich von der zugrunde liegenden Hardware und Systemarchitektur. Nur Mikrocontroller ohne nicht-deterministische Hintergrundprozesse, zusätzliche Funk-Stacks oder unbekannte Interruptquellen können harte Echtzeit garantiert unterstützen. In allen anderen Fällen spricht man von weicher Echtzeit, bei der Deadlines in der Regel eingehalten, aber nicht formal garantiert werden können.
Einige wesentliche Vorteile von FreeRTOS sind die weite Verbreitung, bewährte Robustheit und Verfügbarkeit für viele unterschiedliche Plattformen (über 40 Architekturen) wie z.B. Cadence Xtensa, ARM Cortex und RISC-V CPUs.
Neben dem Verwalten von Computerressourcen stellt das Betriebssystem eine Schnittstelle zwischen Anwenderprogrammen und der darunterliegenden Hardware zur Verfügung. Mit Hilfe eines Betriebssystems wird Anwendern das Programmieren wesentlich vereinfacht, im Gegensatz zur direkten Programmierung der Hardware.
Im Folgenden werden einige wichtige Elemente eines Betriebssystems erläutert.
ESP-IDF FreeRTOS
Vanilla FreeRTOS ist nicht gleich dem ESP-IDF FreeRTOS. Hier gibt es einige wesentliche Unterschiede, die es zu verstehen gibt.
Das FreeRTOS auf freertos.org, ist ein Single-Core Betriebssystem und das ESP-IDF FreeRTOS unterstützt Dual-Core. Das ESP-IDF FreeRTOS switcht dabei zwischen den Kernen, je nach Bedarf, hin und her, soweit die Kerne nicht gepinnt sind.
ESP-IDF startet im Hintergrund immer Tasks, wie z.B.
- WIFI-Task,
- BT-Task,
- Event Loop Task,
- TCP/IP Task,
- Idle Task,
- Timer Task,
- etc.
Wenn man Echtzeit will, darf die Echtzeit-Task nicht auf dem Kern laufen, auf dem WLAN aktiv ist. Der WiFi-Stack läuft überwiegend auf Kern 1 (Core 0) und blockiert dort regelmäßig CPU-Zeit.
Zudem gibt es Erweiterungen, die FreeRTOS von sich aus nicht bietet, wie
- xTaskCreatePinnedToCore,
- esp_timer,
- esp_event_loop,
- eigene Startup-Sequenz,
- andere IST-Handler,
- Memory Pools, Heap-Caps (internal, IRAM, DMA),
- Tick Rate Timing
- etc.
Zudem ist ADC2 gesperrt, sobald WiFi aktiv ist. Das ist zwar kein RTOS Feature, aber immerhin ein spezielles Verhalten welches nur in ESP-IDF FreeRTOS existiert.
Das ESP-IDF unterstützt echte Mehrkernplanung und nutzt einen Symmetric Multi-Processing Scheduler. Tasks können auf beiden Kernen laufen oder fix auf einem Kern gepinnt werden (SMP-Scheduler).
Der FreeRTOS Einstiegspunkt ist die Funktion void app_main(void). Sie ist vergleichbar mit der main() in C. ESP-IDF erzeugt dafür automatisch eine eigene FreeRTOS-Task.
Ein weiterer Aufruf von vTaskStartScheduler() und vTaskEndScheduler() ist in der ESP-IDF nicht erforderlich, da FreeRTOS von der ESP-IDF automatisch gestartet wird. Der Scheduler wird bereits vor app_main() gestartet.
ESP-IDF und FreeRTOS auf dem ESP32 sind für weiche Echtzeit ausgelegt. Der ESP32 ist ein Dual-Core-WiFi/BLE-SoC mit SMP-FreeRTOS, der typische Echtzeitanforderungen im Millisekundenbereich stabil erfüllt. Für harte Echtzeit (deterministische µs-Latenzen ohne Jitter) ist die Architektur aufgrund von Funk-Subsystemen und SMP nicht ausgelegt. ESP-IDF bietet zuverlässige weiche Echtzeit, aber keine garantierte harte Echtzeit.
Scheduling
Beim Scheduling handelt es sich um eine Ablaufsteuerung für Tasks. Der Scheduler bestimmt nach verschiedenen Verfahren, welcher Task wann ausgeführt wird. Unterschieden werden diese Verfahren in präemptive (verdrängende) und nicht präemptive (nicht verdrängende) Verfahren. Wichtig für uns ist nur das präemptive Scheduling, da FreeRTOS mit Prioritäten arbeitet. In Abb. 1 kann man sehen, wie höhere Prioritäten die niedrigeren verdrängen.
Abb. 1 – Präemtives Verfahren (Quelle: The FreeRTOS Reference Manual, v1.1.0, Seite 90)
Eine einfache verdrängende Variante ist das Round-Robin-Scheduling (RRS). Beim RRS hat jede Task die gleiche Priorität. Alle Tasks bekommen einen kleinen Zeitabschnitt (Time Slice), in dem sie ausgeführt und dann pausiert werden, damit eine andere Task ausgeführt werden kann. Haben mehrere Tasks dieselbe Priorität, verwendet FreeRTOS Time-Slicing (Round-Robin), wie in Abb. 2 gezeigt.
Abb. 2 – Zwei Tasks mit gleicher Priorität im RRS-Verfahren (Quelle: The FreeRTOS Reference Manual, v1.1.0, Seite 56)
FreeRTOS unterstützt das präemptive Scheduling mit festen Prioritäten. Das bedeutet, dass das Betriebssystem dafür sorgt, dass Tasks mit der höchsten Priorität immer in bestimmten Zeitfenstern abgearbeitet werden, während Tasks mit niedriger Priorität nur ausgeführt werden, wenn die höher priorisierten Tasks gerade keine CPU-Zeit benötigen.
Sind mehrere Tasks mit gleicher Priorität vorhanden, dann werden diese gleich behandelt und abwechselnd im RRS-Verfahren verarbeitet, bis sie wieder von Tasks höherer Priorität verdrängt werden.
Der Programmierer muss darauf achten, Tasks mit hoher Priorität bei jedem Schleifendurchlauf für einige Ticks mit vTaskDelay() zu blocken, da es sonst passieren kann, dass eine Task mit der höchsten Priorität 100 % der CPU-Zeit für sich beansprucht und keine andere Task ausgeführt wird. Das führt beim ESP32 meist zu schwer lokalisierbaren Fehlern und Abstürzen.
Tasks
Bei der Programmierung ohne Betriebssystem, der sogenannten Bare-Metal Programmierung (z. B. im klassischen Superloop), werden Funktionen nacheinander abgearbeitet. Unabhängig von der Ausführungszeit, die eine Funktion zur Abarbeitung benötigt, muss die darauf folgende Funktion immer warten, bis die vorherige Funktion vollständig abgearbeitet ist. Jede Funktion blockiert die nächste, bis sie vollständig abgearbeitet ist.
Tasks funktionieren anders: Sie werden von der main-Funktion einmalig aufgerufen, enthalten jeweils eigene Endlosschleifen und laufen quasi parallel ab. Tasks mit sehr kurzer Ausführungszeit müssen nicht auf andere Tasks mit langer Ausführungszeit warten, sondern werden vom Scheduler parallel verarbeitet.
Tasks erhalten im Gegensatz zu Superloops ihre eigenen
- Stacks,
- Prioriäten und
- ihren eigenen Ausführungskontext.
Superloops teilen den Systemstack. Der Scheduler entscheidet anhand der Priorität, welche Task laufen darf. Tasks mit hoher Priorität werden bevorzugt und in ihren vorgesehenen Zeitfenstern abgearbeitet.
Diese Eigenschaften ermöglichen es, jede Task so zu behandeln, als wäre sie das Einzige, was der Prozessor gerade zu tun hat. So kann beispielsweise eine Echtzeit-Task präzise Ein- und Ausgaben abarbeiten, während sich niedrig priorisierte Tasks mit LC-Display-Ausgaben oder Tastureingaben beschäftigen. Das Betriebssystem sorgt dabei stets dafür, dass die höchst priorisierte Task ihre Arbeit zuerst erledigt.
Queues
Vereinfacht ausgedrückt sind Queues Ringpuffer, die nach dem First-In-First-Out (FIFO) Prinzip arbeiten und primär der Kommunikation zwischen Tasks dienen (Intertaskkommunikation). Üblicherweise werden Daten am Ende einer Queue eingefügt und am Anfang gelesen, sodass Daten, die als Erstes in die Queues geschrieben wurden, auch als Erstes wieder gelesen werden. Daten werden dabei in die Queue kopiert und nicht per Zeiger übergeben.
Queues bieten einige Eigenschaften, wodurch sie globalen Variablen überlegen sein können. Eine dieser Eigenschaften ist die Threadsicherheit. Durch die Threadsicherheit können im Gegensatz zu globalen Variablen, Lese- oder Schreibvorgänge auf Queues, nicht von anderen Tasks unterbrochen werden. Eine weitere Eigenschaft ist, dass alle Datentypen, auch Strukturen, über eine Queue versendet werden können.
Wie bereits erwähnt, sind Queues nicht die einzige Möglichkeit, Daten zwischen Tasks zu transferieren. Auch globale Variablen können an dieser Stelle ihren Zweck erfüllen. Wenn Sende-Tasks wesentlich häufiger Daten in einer Queue senden, als sie von der Empfänger-Task verarbeiten werden können, und auf diese Weise sowieso Daten verloren gehen, kann überlegt werden, ob eine Queue Sinn macht.
Mutual Exclusion (Mutex)
Mutual Exclusion (Mutex, auf dt. wechselseitiger Ausschluss) verwaltet geteilte Ressourcen, die von unterschiedlichen Tasks verwendet werden. Greift eine Task auf eine geteilte Ressource zu, können durch Einsatz eines Mutex keine anderen Tasks auf sie zugreifen, bis die aktuelle Task den Zugriff beendet und die Ressource freigibt. Tasks beanspruchen eine Ressource mithilfe von Mutexen für sich selbst, bis sie die Ressource nicht mehr benötigen.
Ein Mutex ist eine Variable, die zwei Zustände annehmen kann: Gesperrt oder nicht gesperrt. Sie kann folglich die Zustände 0 oder 1 annehmen.
Möchte Task A auf eine Ressource zugreifen, so prüft sie, ob der Mutex den Wert 0 oder 1 hat. Ist der Wert 1, so ist die Ressource frei und Task A kann auf sie zugreifen. Ist der Wert 0, so ist die Ressource gesperrt, da sie von Task B genutzt wird. Sobald Task B mit der Ressource fertig ist, gibt sie sie frei und setzt den Mutex auf 1. Jetzt kann Task A bei Bedarf wieder auf den kritischen Bereich zugreifen. Dafür setzt Task A den Mutex auf 0 und sperrt die Ressource für andere Teilnehmer.
Auf diese Weise wird verhindert, dass Task A während einer Lese- und Schreiboperation von Task B verdrängt werden kann. Das Verdrängen von Task A könnte dazu führen, dass Task A zwar den Wert aus der Variablen liest, aber nicht mehr dazu kommt, ihn zu ändern und den neuen Wert zurückzuschreiben. So liest Task B wiederum den gleichen unveränderten Wert, den auch Task A gelesen hat, inkrementiert diesen, schreibt ihn in die globale Variable zurück und gibt die Ressource frei.
Sobald Task A wieder den Zugriff auf die Ressource erhält, arbeitet sie noch lokal mit dem alten Wert, den sie beim ersten Zugriff gelesen hat, inkrementiert diesen und schreibt ihn in die globale Variable.
Task A und Task B schreiben schließlich den gleichen Wert in die globale Variable. Hier handelt es sich um eine sogenannte „Race Condition“.
Ein Mutex wird typischerweise von derselben Task freigegeben, die ihn gelockt hat (Owner concept). Das unterscheidet ihn von Semaphoren.
Semaphore
Semaphore werden zur Synchronisation von Tasks eingesetzt. Sie sind – wie auch Mutexe – Variablen, die nicht negative Integerwerte annehmen können, beanspruchen aber keine Ressourcen für sich. Im Gegensatz zu Mutexen können die Integerwerte nicht nur 0 oder 1 sein (außer binäre Semaphore), sondern beliebige nicht negative ganzzahlige Werte.
Es gibt zwei Arten von Semaphoren, binäre-Semaphoren und zählende-Semaphoren. Zählende Semaphore werden eingesetzt, um die Anzahl von Tasks zu begrenzen, die auf eine Ressource zugreifen dürfen. Hier kann es sich um einen Speicher handeln, der nur genügend Kapazität für den Zugriff von zwei Tasks geleichzeitig hat.
Sollen maximal zwei Tasks gleichzeitig auf den Speicher zugreifen dürfen, wird der Zähler der zählenden Semaphore mit zwei initialisiert. Jeder Task, der auf den Speicher zugreift, prüft, ob der Wert Null oder größer ist. Wenn der Zähler größer Null ist, kann der Task zugreifen und dekrementiert gleichzeitig den Semaphor um eins. Greifen zwei Tasks gleichzeitig auf den Speicher zu, so ist der Wert Null und ein weiterer Task muss warten, bis einer der beiden Tasks die Ressource wieder freigibt und den Wert inkrementiert.
Binäre Semaphore sind ähnlich den zählenden Semaphoren, können aber nur Werte von 0 und 1 annehmen.
Wichtig bei Semaphoren und Mutexen ist, dass die Aktionen atomar sind. Das bedeutet, dass kein anderer Prozess die Zähler verändern oder jegliche Zugriffe auf Semaphoren oder Mutexe haben darf, solange die Ressourcen nicht freigegeben sind.
Weiterführende Links
- Installation ESP-IDF unter Windows 10/11
- Erstes Programmbeispiel, die blinkende LED
- Visual Studio Code und ESP-IDF Extension
- ESP32 – Das Entwicklungsboard
- ESP32 JTAG-Debugger
- FreeRTOS am ESP32 – Grundlagen, Tasks und Scheduling
- ESP-IDF Menuconfig – Überblick und wichtigste Optionen

