The ESP32’s dual-core Xtensa LX6 processor is severely underutilized if you confine it to a single-threaded Arduino loop(). By leveraging the native FreeRTOS ESP32 integration, you can execute true parallel tasks—polling sensors on Core 1 while handling network stacks or secondary logic on Core 0. However, moving from bare-metal Arduino to a Real-Time Operating System (RTOS) introduces new failure modes, most notably the dreaded Task Watchdog Timer (TWDT) panic.

This guide provides a complete, bench-tested dual-core FreeRTOS build using the current ESP32-WROOM-32E variant. We will cover exact wiring, compilable code with robust error handling, and a deep-dive debugging framework for the most common RTOS crashes.

Project Spec Sheet and Parts List

Difficulty: Intermediate (Requires understanding of pointers and memory allocation)
Time to Build: 25 minutes
Framework: Arduino IDE (ESP32 Core v2.0.14 or v3.x) / PlatformIO

For this build, we are targeting the ESP32-WROOM-32E module mounted on a standard 38-pin DevKit V1 carrier board. The "-32E" variant is the current production standard (replacing the older -32D), featuring improved RF matching and 4MB of SPI flash. Ensure your dev board has 38 pins; 30-pin variants have different ground and 3V3 breakout layouts that will shift the GPIO mappings below.

  • MCU: ESP32-WROOM-32E DevKit V1 (38-pin layout)
  • Indicators: 1x 5mm Red LED (2.0Vf), 1x 5mm Blue LED (3.0Vf)
  • Current Limiting: 2x 330Ω 1/4W resistors
  • Prototyping: Half-size breadboard, male-to-male jumper wires
  • Power: USB-C/Micro-USB cable (data-capable) connected to a 5V/1A source

Pin Mapping and Wiring the Dual-Core Circuit

The ESP32 GPIOs can source up to 40mA absolute maximum, but Espressif recommends keeping continuous draw under 20mA per pin, with a total package limit of 80mA for all GPIOs combined. Using 330Ω resistors keeps our LED current around 4mA to 10mA, well within safe operating limits for prolonged RTOS task execution.

ESP32 GPIO Component Wiring Notes
GPIO 2 Red LED (Anode) Connect via 330Ω resistor. GPIO 2 is safe for output (avoid strapping pin issues on boot).
GPIO 4 Blue LED (Anode) Connect via 330Ω resistor.
GND LED Cathodes Tie both LED cathodes to any available GND pin on the DevKit.

Complete FreeRTOS ESP32 Dual-Core Code

The following code creates two independent tasks pinned to specific CPU cores. Unlike standard Arduino setup() and loop(), FreeRTOS tasks are C-style functions that accept a void* parameter and must not return. We also implement strict error handling to verify that the RTOS scheduler successfully allocated memory for our tasks.

#include <Arduino.h>

// --- Pin Definitions ---
#define LED_RED_PIN  2
#define LED_BLUE_PIN 4

// --- Task Handles ---
TaskHandle_t Task1_RedLED_Handle = NULL;
TaskHandle_t Task2_BlueLED_Handle = NULL;
TaskHandle_t Task3_Monitor_Handle = NULL;

// --- Task 1: Red LED on Core 0 ---
void Task1_RedLED(void *pvParameters) {
  for (;;) {
    digitalWrite(LED_RED_PIN, HIGH);
    vTaskDelay(pdMS_TO_TICKS(500)); // RTOS-safe delay
    digitalWrite(LED_RED_PIN, LOW);
    vTaskDelay(pdMS_TO_TICKS(500));
  }
}

// --- Task 2: Blue LED on Core 1 ---
void Task2_BlueLED(void *pvParameters) {
  for (;;) {
    digitalWrite(LED_BLUE_PIN, HIGH);
    vTaskDelay(pdMS_TO_TICKS(200));
    digitalWrite(LED_BLUE_PIN, LOW);
    vTaskDelay(pdMS_TO_TICKS(200));
  }
}

// --- Task 3: Stack Monitor on Core 1 ---
void Task3_Monitor(void *pvParameters) {
  for (;;) {
    vTaskDelay(pdMS_TO_TICKS(5000));
    Serial.printf("[Monitor] Task1 High Water Mark: %u bytes\n", uxTaskGetStackHighWaterMark(Task1_RedLED_Handle));
    Serial.printf("[Monitor] Task2 High Water Mark: %u bytes\n", uxTaskGetStackHighWaterMark(Task2_BlueLED_Handle));
  }
}

void setup() {
  Serial.begin(115200);
  while (!Serial) { delay(10); }
  Serial.println("FreeRTOS ESP32 Dual-Core Initialization...");

  pinMode(LED_RED_PIN, OUTPUT);
  pinMode(LED_BLUE_PIN, OUTPUT);

  // Create Task 1 on Core 0
  BaseType_t xReturned1 = xTaskCreatePinnedToCore(
    Task1_RedLED,       // Function
    "Task1_RedLED",     // Name
    2048,               // Stack size (bytes)
    NULL,               // Parameters
    1,                  // Priority
    &Task1_RedLED_Handle, // Handle
    0                   // Core ID (0 = Protocol CPU)
  );

  if (xReturned1 != pdPASS) {
    Serial.println("[FATAL] Failed to create Task 1. Insufficient heap.");
    while(1) { delay(1000); }
  }

  // Create Task 2 on Core 1
  BaseType_t xReturned2 = xTaskCreatePinnedToCore(
    Task2_BlueLED, "Task2_BlueLED", 2048, NULL, 1, &Task2_BlueLED_Handle, 1
  );

  if (xReturned2 != pdPASS) {
    Serial.println("[FATAL] Failed to create Task 2. Insufficient heap.");
    while(1) { delay(1000); }
  }

  // Create Monitor Task
  xTaskCreatePinnedToCore(
    Task3_Monitor, "Task3_Monitor", 4096, NULL, 1, &Task3_Monitor_Handle, 1
  );

  Serial.println("All tasks created successfully.");
}

void loop() {
  // The main Arduino loop() runs on Core 1 as the 'loopTask'.
  // In a pure RTOS build, we delete it or leave it empty to save CPU cycles.
  vTaskDelay(pdMS_TO_TICKS(10000));
}
Pro-Tip: Core Affinity and Wi-Fi
In the ESP32 Arduino core, the Wi-Fi and Bluetooth stacks are hardcoded to run on Core 0 (the Protocol CPU). If your Task 1 on Core 0 performs heavy, uninterrupted computation without yielding, it will starve the Wi-Fi stack, causing dropped packets. For network-heavy projects, pin all your custom application tasks to Core 1 and leave Core 0 exclusively for the RF stacks.

Debugging the "Task Watchdog Got Triggered" Error

The most frequent crash encountered when migrating to FreeRTOS ESP32 is the Task Watchdog Timer (TWDT) panic. The serial monitor will abruptly halt and output this exact error string:

E (2534) task_wdt: Task watchdog got triggered. The following tasks did not reset the watchdog in time:
E (2534) task_wdt: - IDLE (CPU 1)
E (2534) task_wdt: Tasks currently running:
E (2534) task_wdt: CPU 0: IDLE
E (2534) task_wdt: CPU 1: Task1_RedLED

Notice that the error complains about the IDLE task, not your custom task. The TWDT monitors the IDLE task on each core. If your custom task enters an infinite loop and never yields the CPU, the IDLE task never runs, the watchdog is never fed, and the ESP32 reboots to prevent a hard lockup.

The First 3 Things to Check When It Fails

  1. Hunt for standard delay() calls: The standard Arduino delay() function blocks the CPU using a tight while() loop. It does not yield to the RTOS scheduler. Replace every instance of delay(x) with vTaskDelay(pdMS_TO_TICKS(x)).
  2. Check while(1) and for(;;) loops: If you have a loop waiting for a sensor interrupt or a serial character (e.g., while(!Serial.available()) {}), you are starving the scheduler. You must insert a vTaskDelay(1) or taskYIELD() inside the waiting loop.
  3. Inspect I2C/SPI Bus Hangs: If a sensor freezes and pulls the SDA line low, the Wire library will wait indefinitely for the bus to clear, blocking your task. Always initialize I2C with timeouts: Wire.setWireTimeout(100000, true); (timeout in microseconds).

Extending and Simplifying Your FreeRTOS Build

Once your dual-core tasks are stable, the next engineering challenge is memory optimization and inter-task communication.

How to Simplify (Memory Optimization)

RAM is precious on the ESP32 (520KB of usable SRAM). Beginners often over-allocate stack memory (e.g., assigning 8192 bytes to a task that only blinks an LED). Use the uxTaskGetStackHighWaterMark() function included in our Monitor Task above. This function returns the number of unused stack bytes. If you allocated 2048 bytes and the high water mark reports 1800 bytes free, your task is only using 248 bytes. You can safely reduce the stack allocation to 512 bytes, reclaiming 1.5KB of heap memory per task. For deeper architectural guidance, refer to the FreeRTOS Memory Management FAQ.

How to Extend (Inter-Task Communication)

Global variables shared between Core 0 and Core 1 will eventually cause race conditions. To extend this build safely:

  • Queues: Use xQueueCreate() to pass data structures (like sensor readings) from a Core 1 polling task to a Core 0 Wi-Fi transmission task.
  • Mutexes: If both cores need to write to the I2C bus or the Serial port, wrap the hardware calls in a SemaphoreHandle_t mutex using xSemaphoreTake() and xSemaphoreGive() to prevent bus collisions.
  • Task Notifications: For simple binary signals (e.g., "wake up and transmit"), use xTaskNotifyGive(). It is significantly faster and uses less RAM than a queue or semaphore. See the Espressif FreeRTOS API Reference for ESP-IDF specific implementations.

FreeRTOS ESP32 FAQ

Can I run FreeRTOS ESP32 tasks on both cores simultaneously without interference?

Yes, the ESP32 is a true Symmetric Multiprocessing (SMP) system. Core 0 and Core 1 execute independently. However, "interference" occurs at the hardware peripheral level. If Task A on Core 0 and Task B on Core 1 both attempt to write to the same I2C bus or modify the same GPIO port register simultaneously without a software mutex, the hardware bus will corrupt. Always use RTOS mutexes for shared hardware resources.

How much stack memory should I allocate for a FreeRTOS ESP32 task?

There is no universal number, as stack usage depends on local variables and function call depth. A simple GPIO toggling task requires only 512 to 768 bytes. A task performing heavy printf formatting or JSON parsing may require 4096 to 8192 bytes. Always start with a generous allocation (e.g., 4096), measure the high water mark during peak operation, and reduce the allocation to (Total - HighWaterMark) + 256 bytes for a safety margin.

Why does my FreeRTOS ESP32 Wi-Fi drop when I add a new task?

The ESP32 Wi-Fi stack runs on Core 0. If you pin a CPU-intensive task (like FastLED rendering or heavy cryptography) to Core 0 without yielding, the Wi-Fi stack misses its RF calibration and beacon transmission windows, causing the router to drop the connection. Move compute-heavy custom tasks to Core 1, or ensure your Core 0 tasks call vTaskDelay() frequently to let the Wi-Fi background tasks execute.

Do I need to manually delete tasks when they are done?

If a task runs continuously in a for(;;) loop, it never finishes. If you design a task to execute once and terminate, it must call vTaskDelete(NULL); at the very end of its function. Failing to do so will cause the RTOS to attempt to schedule a completed function, resulting in an immediate Guru Meditation Error: Core panic'ed (IllegalInstruction) crash.