The ESP32 is a dual-core powerhouse, but running everything in the default Arduino loop() leaves half your silicon idle and risks blocking your WiFi stack. Using ESP32 FreeRTOS allows you to explicitly pin tasks to Core 0 (typically reserved for the RF/WiFi protocol stack) and Core 1 (the default Arduino core). This separation prevents sensor polling from starving your network connection, but it introduces concurrency hazards like race conditions and stack overflows.
This guide targets the ESP32-WROOM-32 DevKit V1 (30-pin variant). We will build a dual-core data acquisition system, route tasks explicitly, and debug the exact memory panics that occur when task management goes wrong.
Hardware Spec Sheet and Pin Mapping
Before writing concurrency code, lock down your hardware assumptions. The code provided below uses the internal ADC and an onboard LED to eliminate external library dependencies, ensuring it compiles cleanly on a fresh ESP32 Arduino Core installation.
| Component | Exact Variant / Model | Role in Build |
|---|---|---|
| Microcontroller | ESP32-WROOM-32 DevKit V1 (30-pin) | Dual-core Xtensa LX6 @ 240MHz |
| Sensor (Simulated) | Internal 12-bit SAR ADC (GPIO 34) | Analog data acquisition on Core 1 |
| Indicator | Onboard Blue LED (GPIO 2) | Visual heartbeat for Core 0 task |
| Power | 5V via Micro-USB or 3.3V on 3V3 pin | Do not exceed 5.5V on USB or 3.6V on 3V3 |
Pin Mapping Table
| ESP32 GPIO | Function | Direction | Notes |
|---|---|---|---|
| GPIO 34 (VP) | ADC1_CH6 | Input | Input only; no internal pull-up. Tie to a voltage divider (0-3.3V). |
| GPIO 2 | Digital Out | Output | Drives onboard LED. Also used during boot; must be LOW to flash. |
The Dual-Core FreeRTOS Build
Standard Arduino code runs entirely on Core 1. To utilize Core 0, we use xTaskCreatePinnedToCore(). In this build, Core 1 reads the ADC and pushes the value into a thread-safe FreeRTOS Queue. Core 0 pulls from that queue, toggles the LED, and prints the payload to the Serial monitor.
uxTaskGetStackHighWaterMark() and trim it down to save SRAM.
Complete Compilable Code
Copy this directly into your Arduino IDE. It requires no external libraries beyond the default ESP32 board package.
#include <Arduino.h>
#include <WiFi.h>
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
#include <freertos/queue.h>
// --- PIN DEFINITIONS ---
#define ADC_PIN 34
#define LED_PIN 2
// --- TASK CONFIGURATION ---
#define CORE_0_TASK_STACK 2048
#define CORE_1_TASK_STACK 2048
#define CORE_0_TASK_PRIO 1
#define CORE_1_TASK_PRIO 2
// Create a Queue handle to pass data between cores
QueueHandle_t adcQueue = NULL;
// --- CORE 1 TASK: Sensor Reading ---
void readSensorTask(void *pvParameters) {
uint16_t adcValue = 0;
while (1) {
adcValue = analogRead(ADC_PIN);
// Send to queue with a 10ms timeout
if (xQueueSend(adcQueue, &adcValue, pdMS_TO_TICKS(10)) != pdPASS) {
Serial.println("[Core 1] ERROR: Queue send failed. Queue full?");
}
// CRITICAL: Never use delay() in FreeRTOS. Use vTaskDelay().
vTaskDelay(pdMS_TO_TICKS(500));
}
}
// --- CORE 0 TASK: Processing and Output ---
void processDataTask(void *pvParameters) {
uint16_t receivedValue = 0;
bool ledState = false;
while (1) {
// Wait indefinitely for data from the queue
if (xQueueReceive(adcQueue, &receivedValue, portMAX_DELAY) == pdPASS) {
ledState = !ledState;
digitalWrite(LED_PIN, ledState);
Serial.printf("[Core 0] ADC Value: %u | Stack High Water Mark: %u bytes\n",
receivedValue,
uxTaskGetStackHighWaterMark(NULL));
} else {
Serial.println("[Core 0] ERROR: Queue receive failed.");
}
vTaskDelay(pdMS_TO_TICKS(100));
}
}
void setup() {
Serial.begin(115200);
pinMode(LED_PIN, OUTPUT);
pinMode(ADC_PIN, INPUT);
// Initialize the Queue (holds 10 items of type uint16_t)
adcQueue = xQueueCreate(10, sizeof(uint16_t));
if (adcQueue == NULL) {
Serial.println("FATAL: Failed to create Queue. Rebooting...");
ESP.restart();
}
// Pin Sensor Task to Core 1 (Arduino default core)
xTaskCreatePinnedToCore(
readSensorTask, // Function
"SensorTask", // Name
CORE_1_TASK_STACK, // Stack size in bytes
NULL, // Parameters
CORE_1_TASK_PRIO, // Priority
NULL, // Task handle
1 // Core ID (1)
);
// Pin Processing Task to Core 0 (Protocol/WiFi core)
xTaskCreatePinnedToCore(
processDataTask, // Function
"ProcessTask", // Name
CORE_0_TASK_STACK, // Stack size in bytes
NULL, // Parameters
CORE_0_TASK_PRIO, // Priority
NULL, // Task handle
0 // Core ID (0)
);
}
void loop() {
// The Arduino loop() runs on Core 1 by default.
// Since our main logic is in FreeRTOS tasks, we just yield here.
vTaskDelay(pdMS_TO_TICKS(10000));
}
Debugging the "Guru Meditation Error" and Watchdog Resets
When an ESP32 FreeRTOS task misbehaves, the hardware memory protection unit triggers a panic. The most common and dreaded output in the Serial monitor is:
Guru Meditation Error: Core 1 panic'ed (Interrupt wdt timeout on CPU1)
This exact error string means the Interrupt Watchdog Timer (WDT) detected that an interrupt service routine (ISR) or a high-priority task starved the CPU, preventing the system from resetting the watchdog timer. It is almost always a software logic flaw, not a hardware defect.
The First Three Things to Check When It Fails
- Check for
delay()inside ISRs or High-Priority Tasks: The Arduinodelay()function uses a busy-wait loop that blocks the core entirely. If called in a task with a priority higher than the idle task (which feeds the watchdog), the WDT will time out. Fix: Replace all instances ofdelay()withvTaskDelay(pdMS_TO_TICKS(ms)). - Check Stack High Water Marks: If a task exceeds its allocated stack memory, it silently overwrites adjacent memory (often the FreeRTOS kernel structures) before triggering a panic. Fix: Add
Serial.println(uxTaskGetStackHighWaterMark(NULL));inside your task loop. If the returned value is under 200 bytes, increase the stack size inxTaskCreatePinnedToCore. - Check for I2C/SPI Bus Lockups: If a task is waiting on a hardware peripheral (like an I2C sensor) that has locked up due to missing pull-up resistors or a loose wire, the task will hang indefinitely, starving the watchdog. Fix: Implement timeout parameters in your hardware read functions, and ensure 4.7kΩ pull-up resistors are present on SDA/SCL lines.
Ranked Causes of Stack Overflow Panics
If your error string looks like Core 0 panic'ed (StoreProhibited) or Unhandled debug exception, you likely have a stack overflow. Here are the most common culprits:
| Rank | Cause | Fix |
|---|---|---|
| 1 | Large local arrays (e.g., char buffer[2048]) inside the task function. |
Move large buffers to global scope or allocate them dynamically using heap_caps_malloc(). |
| 2 | Deeply nested function calls or recursive logic within the task. | Flatten the logic or increase the stack allocation by 1024-byte increments. |
| 3 | Using Serial.printf() with complex formatting on a tight stack. |
printf consumes significant stack space. Increase stack to at least 3072 bytes if heavy logging is required. |
For deeper architectural understanding of task scheduling and memory protection, refer to the official Espressif FreeRTOS API Reference and the FreeRTOS Official Documentation.
Extending and Simplifying Your FreeRTOS Build
How to Extend: Adding Mutexes for Shared Resources
If you need both Core 0 and Core 1 to write to the Serial port or access the I2C bus simultaneously, a Queue is not enough. You must use a Mutex (Mutual Exclusion semaphore) to prevent data corruption.
SemaphoreHandle_t i2cMutex = xSemaphoreCreateMutex();
// Inside your task:
if (xSemaphoreTake(i2cMutex, pdMS_TO_TICKS(100)) == pdTRUE) {
// Safe to use Wire.h here
Wire.beginTransmission(0x76);
// ... read sensor ...
Wire.endTransmission();
xSemaphoreGive(i2cMutex); // Release the lock
} else {
Serial.println("Failed to acquire I2C mutex");
}
How to Simplify: Collapsing to a Single Core
Not every project needs dual-core routing. If your sensor reads are fast (under 5ms) and you aren't doing heavy local DSP or cryptography, the overhead of managing queues and mutexes isn't worth the complexity. To simplify, delete the Core 0 task entirely, move the processing logic into the Core 1 sensor task, and change xTaskCreatePinnedToCore to standard xTaskCreate (which lets the scheduler decide the core), or simply run everything inside the standard Arduino loop() using non-blocking millis() timers.
ESP32 FreeRTOS FAQ
How much stack memory does an ESP32 FreeRTOS task actually need?
The absolute minimum stack size for a basic FreeRTOS task on the ESP32 is around 768 bytes, but this leaves almost no room for local variables or function calls. A safe baseline for a task doing simple GPIO toggling and queue operations is 2048 bytes. If your task uses Serial.printf(), WiFi functions, or JSON parsing (like ArduinoJson), you should allocate 4096 to 8192 bytes. Always use uxTaskGetStackHighWaterMark(NULL) during testing to measure the lowest point the stack pointer reached, then add a 20% safety margin.
Why does my ESP32 FreeRTOS task crash when calling delay() instead of vTaskDelay()?
The standard Arduino delay() function halts the CPU core it is running on by spinning in a tight loop until the time elapses. It does not yield control back to the FreeRTOS scheduler. If this happens in a high-priority task, the Idle Task (which is responsible for resetting the hardware watchdog timer and managing background memory cleanup) never gets CPU time. The hardware watchdog assumes the chip has locked up and triggers a reset. vTaskDelay(), conversely, puts the task into a "Blocked" state, allowing the scheduler to run other tasks and feed the watchdog.
Can I pin the default Arduino setup() and loop() to Core 0 instead of Core 1?
By default, the ESP32 Arduino Core initializes the main application on Core 1, leaving Core 0 for the underlying ESP-IDF RF and WiFi stack. While it is technically possible to reconfigure the Arduino core to run on Core 0 via the sdkconfig file in a pure ESP-IDF environment, doing so in the Arduino IDE is highly discouraged. Pinning your heavy application logic to Core 0 can starve the WiFi/Bluetooth baseband tasks, leading to dropped packets, poor RF range, and random disconnects. Always keep WiFi-heavy tasks on Core 0 and sensor/UI tasks on Core 1.
How do I safely share variables between ESP32 FreeRTOS cores without a mutex?
If you are sharing a simple variable (like an int or bool) and want to avoid the overhead of a Mutex, you can use atomic operations or the volatile keyword combined with careful design. For a single writer and single reader, declaring the variable as volatile int sharedVar = 0; prevents the compiler from caching the value in a CPU register. However, for 32-bit or 64-bit variables on a 32-bit architecture, reads and writes might not be strictly atomic if interrupted. For guaranteed thread safety without Mutex overhead, use FreeRTOS Event Groups or stick to Queues, which are specifically optimized for safe cross-core data handoffs on the ESP32 architecture.






