ESP32 FreeRTOS Tutorial: Tasks, Queues & beide CPU-Kerne richtig nutzen
Dein ESP32 hat zwei vollwertige CPU-Kerne mit je 240 MHz – aber dein Arduino-Sketch nutzt davon meistens nur einen. Der zweite Kern langweilt sich, während dein loop() Sensoren abfragt, Displays aktualisiert und WLAN-Anfragen beantwortet – alles schön nacheinander. Schluss damit: Unter der Haube läuft auf jedem ESP32 das Echtzeitbetriebssystem FreeRTOS, und du kannst es direkt aus der Arduino IDE nutzen. In diesem Tutorial lernst du, wie du eigene Tasks erstellst, sie gezielt auf beide Kerne verteilst, Daten sicher per Queue zwischen Tasks austauschst und die typischen Anfängerfallen wie Watchdog-Resets und Stack-Overflows vermeidest.
Was ist FreeRTOS – und warum läuft es längst auf deinem ESP32?
FreeRTOS ist ein Echtzeitbetriebssystem (Real-Time Operating System) für Mikrocontroller. Es verwaltet sogenannte Tasks – unabhängige Mini-Programme, die scheinbar gleichzeitig laufen. Ein Scheduler entscheidet viele tausend Mal pro Sekunde, welcher Task gerade Rechenzeit bekommt. Auf einem Dual-Core-ESP32 laufen sogar wirklich zwei Tasks physisch parallel – einer pro Kern.
Das Beste daran: Du musst nichts installieren. Das ESP32-Arduino-Framework ist eine FreeRTOS-Anwendung. Dein vertrauter loop() ist in Wahrheit selbst nur ein FreeRTOS-Task (der standardmäßig auf Kern 1 läuft), und WLAN- und Bluetooth-Stack laufen als eigene Tasks auf Kern 0. Du klinkst dich also nur in ein System ein, das ohnehin schon aktiv ist.
ℹ️ Wann lohnt sich FreeRTOS? Sobald dein Projekt mehrere Dinge „gleichzeitig“ tun soll: Sensordaten im Sekundentakt loggen, während ein Webserver Anfragen beantwortet. Eine LED-Animation flüssig laufen lassen, während Messwerte per WLAN rausgehen. Für einfache Projekte reicht oft schon nicht-blockierender Code mit millis() – aber wenn einzelne Aufgaben lange dauern (Displayaufbau, HTTP-Requests), spielt FreeRTOS seine Stärken aus.
Das brauchst du
⚠️ Single-Core-Hinweis: ESP32-C3 und ESP32-C6 haben nur einen Kern. Tasks, Queues und Mutexe funktionieren dort genauso – nur die Beispiele mit xTaskCreatePinnedToCore() auf Kern 0 und 1 brauchen echtes Dual-Core wie den klassischen ESP32 oder den S3.
Verkabelung der Test-LEDs
| Bauteil | Anschluss | ESP32-Pin |
|---|---|---|
| LED 1 (Anode, langes Bein) | über 220 Ω Widerstand | GPIO 25 |
| LED 1 (Kathode, kurzes Bein) | direkt | GND |
| LED 2 (Anode, langes Bein) | über 220 Ω Widerstand | GPIO 26 |
| LED 2 (Kathode, kurzes Bein) | direkt | GND |
Wie du den passenden Vorwiderstand selbst berechnest, erklärt der Artikel LED-Vorwiderstand berechnen.
1Dein erster Task: zwei LEDs, zwei Rhythmen
Das klassische Problem: Zwei LEDs sollen in unterschiedlichen Intervallen blinken. Mit delay() im loop() geht das nicht sauber – mit zwei Tasks ist es trivial. Jeder Task bekommt seine eigene Endlosschleife und darf sogar vTaskDelay() benutzen, ohne den anderen zu blockieren:
const int LED1 = 25;
const int LED2 = 26;
// Task-Funktion: Signatur ist immer void fn(void* parameter)
void blinkTask1(void* parameter) {
for (;;) { // Endlosschleife - Tasks kehren nie zurueck
digitalWrite(LED1, HIGH);
vTaskDelay(200 / portTICK_PERIOD_MS); // 200 ms schlafen
digitalWrite(LED1, LOW);
vTaskDelay(200 / portTICK_PERIOD_MS);
}
}
void blinkTask2(void* parameter) {
for (;;) {
digitalWrite(LED2, HIGH);
vTaskDelay(700 / portTICK_PERIOD_MS);
digitalWrite(LED2, LOW);
vTaskDelay(700 / portTICK_PERIOD_MS);
}
}
void setup() {
pinMode(LED1, OUTPUT);
pinMode(LED2, OUTPUT);
// Funktion Name Stack Param Prio Handle
xTaskCreate(blinkTask1, "Blink1", 2048, NULL, 1, NULL);
xTaskCreate(blinkTask2, "Blink2", 2048, NULL, 1, NULL);
}
void loop() {
// kann komplett leer bleiben - die Tasks laufen unabhaengig
}
Die Parameter von xTaskCreate() im Überblick: die Task-Funktion, ein Name für Debug-Ausgaben, die Stack-Größe in Bytes (2048 reicht für einfache Tasks), ein optionaler Parameter-Zeiger, die Priorität (höher = wichtiger, loop() läuft mit 1) und optional ein Handle, über das du den Task später pausieren oder löschen kannst.
💡 vTaskDelay ist das bessere delay: Während delay() im klassischen Arduino-Sketch nur Zeit verbrennt, gibt vTaskDelay() die Rechenzeit aktiv an andere Tasks ab. Der Kern kann in der Wartezeit also sinnvolle Arbeit erledigen – genau so soll Multitasking funktionieren.
2Dual-Core: Tasks gezielt auf Kern 0 und Kern 1 verteilen
Mit xTaskCreatePinnedToCore() bestimmst du, auf welchem Kern ein Task läuft. Der folgende Sketch zeigt live im Seriellen Monitor, welcher Kern gerade arbeitet:
void rechenTask(void* parameter) {
for (;;) {
long summe = 0;
for (long i = 0; i < 1000000; i++) summe += i; // simulierte Rechenlast
Serial.printf("Rechen-Task auf Kern %d fertig\n", xPortGetCoreID());
vTaskDelay(1000 / portTICK_PERIOD_MS);
}
}
void sensorTask(void* parameter) {
for (;;) {
Serial.printf("Sensor-Task auf Kern %d liest Messwert\n", xPortGetCoreID());
vTaskDelay(500 / portTICK_PERIOD_MS);
}
}
void setup() {
Serial.begin(115200);
// Funktion Name Stack Param Prio Handle Kern
xTaskCreatePinnedToCore(rechenTask, "Rechnen", 4096, NULL, 1, NULL, 0);
xTaskCreatePinnedToCore(sensorTask, "Sensor", 4096, NULL, 1, NULL, 1);
}
void loop() {
vTaskDelay(1000 / portTICK_PERIOD_MS);
}
Beide Tasks laufen jetzt echt parallel – die Rechenlast auf Kern 0 bremst den Sensor-Task auf Kern 1 nicht aus. Eine bewährte Aufteilung für IoT-Projekte:
| Kern | Läuft dort standardmäßig | Gut geeignet für deine Tasks |
|---|---|---|
| Kern 0 | WLAN-Stack, Bluetooth-Stack | Netzwerk-nahe Aufgaben: MQTT, HTTP-Requests, Datenversand |
| Kern 1 |
setup() und loop()
|
Zeitkritisches: Sensoren auslesen, Displays, LED-Animationen |
⚠️ Kern 0 nicht dauerhaft blockieren: Auf Kern 0 wohnt der WLAN-Stack. Ein Task, der dort ohne Pausen mit hoher Priorität rechnet, kann WLAN-Aussetzer und Watchdog-Resets verursachen. Gönn deinen Tasks regelmäßig ein vTaskDelay() – schon 1 ms reicht, damit das System durchatmen kann.
3Queues: Daten sicher zwischen Tasks austauschen
Sobald zwei Tasks auf dieselbe Variable zugreifen, wird es gefährlich: Wenn Task A schreibt, während Task B liest, entstehen korrupte Werte – sogenannte Race Conditions. Die saubere Lösung ist eine Queue: eine threadsichere Warteschlange, in die ein Task Daten hineinlegt und aus der ein anderer sie herausholt. Hier misst ein Task (simulierte) Sensorwerte und ein zweiter verarbeitet sie:
struct Messwert {
float temperatur;
unsigned long zeitpunkt;
};
QueueHandle_t messQueue;
void messTask(void* parameter) {
for (;;) {
Messwert m;
m.temperatur = 20.0 + (esp_random() % 100) / 10.0; // Demo-Wert
m.zeitpunkt = millis();
// Wert in die Queue legen (wartet max. 100 ms, falls Queue voll)
xQueueSend(messQueue, &m, 100 / portTICK_PERIOD_MS);
vTaskDelay(1000 / portTICK_PERIOD_MS);
}
}
void verarbeitungsTask(void* parameter) {
Messwert empfangen;
for (;;) {
// portMAX_DELAY = warte beliebig lange auf neue Daten
if (xQueueReceive(messQueue, &empfangen, portMAX_DELAY) == pdTRUE) {
Serial.printf("[%lu ms] Temperatur: %.1f Grad\n",
empfangen.zeitpunkt, empfangen.temperatur);
}
}
}
void setup() {
Serial.begin(115200);
// Queue fuer 10 Elemente vom Typ Messwert anlegen
messQueue = xQueueCreate(10, sizeof(Messwert));
xTaskCreatePinnedToCore(messTask, "Messen", 2048, NULL, 1, NULL, 1);
xTaskCreatePinnedToCore(verarbeitungsTask, "Verarbeiten", 4096, NULL, 1, NULL, 0);
}
void loop() {}
Der Verarbeitungs-Task schläft, solange keine Daten anliegen – er verbraucht in der Wartezeit praktisch keine Rechenzeit. Genau dieses Muster steckt hinter stabilen Datenloggern und Smart-Home-Knoten: Messen auf dem einen Kern, Versenden auf dem anderen. Wie du solche Messwerte anschließend per MQTT ins Smart Home bringst, zeigt das Tutorial MQTT mit ESP32 & Home Assistant.
4Mutex: gemeinsame Ressourcen schützen
Nicht alles lässt sich über Queues lösen. Wenn mehrere Tasks dieselbe Ressource nutzen – etwa den Seriellen Monitor oder einen I2C-Bus – brauchst du einen Mutex (Mutual Exclusion): ein Schloss, das immer nur ein Task gleichzeitig halten darf:
SemaphoreHandle_t serialMutex;
void sicherAusgeben(const char* text) {
// Schloss nehmen (wartet max. 1000 ms)
if (xSemaphoreTake(serialMutex, 1000 / portTICK_PERIOD_MS) == pdTRUE) {
Serial.println(text);
xSemaphoreGive(serialMutex); // Schloss zurueckgeben - nie vergessen!
}
}
void taskA(void* parameter) {
for (;;) {
sicherAusgeben("Task A meldet sich vom ESP32");
vTaskDelay(300 / portTICK_PERIOD_MS);
}
}
void taskB(void* parameter) {
for (;;) {
sicherAusgeben("Task B funkt dazwischen - aber sauber");
vTaskDelay(450 / portTICK_PERIOD_MS);
}
}
void setup() {
Serial.begin(115200);
serialMutex = xSemaphoreCreateMutex();
xTaskCreatePinnedToCore(taskA, "TaskA", 2048, NULL, 1, NULL, 0);
xTaskCreatePinnedToCore(taskB, "TaskB", 2048, NULL, 1, NULL, 1);
}
void loop() {}
Ohne Mutex würden sich die Ausgaben beider Tasks mitten im Satz vermischen. Dasselbe Muster gilt für I2C-Sensoren: Wenn zwei Tasks denselben Bus abfragen, gehört jeder Zugriff zwischen xSemaphoreTake() und xSemaphoreGive(). Grundlagen zum I2C-Bus findest du im Artikel I2C-Bus mit Arduino verstehen.
Praxistipps für stabile FreeRTOS-Projekte
🧠 Die wichtigsten Regeln auf einen Blick
-
Stack großzügig bemessen: 2048 Bytes für einfache Tasks, 4096+ sobald
printf, Strings oder Bibliotheken im Spiel sind. MituxTaskGetStackHighWaterMark(NULL)prüfst du zur Laufzeit, wie viel Reserve bleibt - Prioritäten sparsam einsetzen: Meist reicht Priorität 1 für alles. Nur wirklich zeitkritische Tasks (z. B. Signalerfassung) bekommen 2 oder 3 – zu viele hohe Prioritäten verhungern die niedrigen Tasks
-
Jeder Task braucht eine Wartefunktion:
vTaskDelay(),xQueueReceive()mit Timeout oder ähnliches – ein Task ohne Pausen blockiert seinen Kern komplett -
Keine Endlos-Tasks ohne Not löschen: Wenn ein Task fertig ist, beendet er sich sauber mit
vTaskDelete(NULL)– einfach aus der Funktion zurückkehren führt zum Absturz
Perfekt für: Webserver + Sensorik, Datenlogger mit Live-Display, LED-Animationen neben WLAN
Troubleshooting
⚠️ „Task watchdog got triggered“ im Seriellen Monitor: Ein Task läuft zu lange ohne Unterbrechung. Baue ein vTaskDelay(1) in enge Schleifen ein – das genügt dem Watchdog schon.
⚠️ ESP32 startet ständig neu (Guru Meditation Error): Häufigste Ursache ist ein zu kleiner Task-Stack. Verdopple die Stack-Größe testweise auf 4096 oder 8192 Bytes. Zweithäufigste Ursache: Zugriff auf eine gemeinsame Variable ohne Mutex oder Queue.
⚠️ WLAN bricht ständig ab: Ein rechenintensiver Task auf Kern 0 verdrängt den WLAN-Stack. Verschiebe ihn per xTaskCreatePinnedToCore(..., 1) auf Kern 1 oder senke seine Priorität.
⚠️ Ausgaben im Seriellen Monitor sind zerhackt: Mehrere Tasks schreiben gleichzeitig auf Serial. Schütze die Ausgabe mit einem Mutex wie in Schritt 4.
⚠️ Sketch kompiliert, aber Tasks starten nicht: Prüfe den Rückgabewert: xTaskCreate() liefert pdPASS bei Erfolg. Schlägt es fehl, ist meist der Heap voll – reduziere Stack-Größen oder die Anzahl der Tasks.
🚀 Dual-Core-Power für deine Projekte
Diese Boards aus dem Shop bringen FreeRTOS ab Werk mit:
- ESP32 Entwicklungsboard (USB-C) – zwei Kerne, 240 MHz, der Klassiker für den Einstieg
- ESP32-S3 N16R8 Dev Kit – Dual-Core plus 8 MB PSRAM für große Puffer und viele Tasks
- ESP32-C3 Mini – Single-Core, aber voll FreeRTOS-fähig für kompakte Projekte
- Breadboard 400 Kontakte + LED-Sortiment – für alle Experimente aus diesem Tutorial
Fazit
FreeRTOS klingt nach schwerem Betriebssystem-Stoff, ist auf dem ESP32 aber erstaunlich zugänglich: Mit xTaskCreate() hast du in fünf Zeilen deinen ersten parallelen Task, mit xTaskCreatePinnedToCore() verteilst du Arbeit gezielt auf beide Kerne, Queues transportieren Daten sicher von Task zu Task und ein Mutex schützt gemeinsame Ressourcen wie Serial oder den I2C-Bus.
Die drei Merksätze zum Schluss: Jeder Task braucht regelmäßige Wartepunkte, sonst meldet sich der Watchdog. Gemeinsame Daten gehören in Queues, gemeinsame Ressourcen hinter einen Mutex. Und im Zweifel lieber 2 KB mehr Stack als ein mysteriöser Absturz um Mitternacht. Damit steht deinem ersten echten Multitasking-Projekt nichts mehr im Weg – viel Spaß beim Bauen!