When makers search for "FreeRTOS Arduino," they are almost always looking for how to run real-time operating system tasks on the ESP32 using the Arduino IDE. Native 8-bit AVR boards like the Arduino Uno lack the RAM and flash to run a practical RTOS. The ESP32, however, runs FreeRTOS natively under the hood of its Arduino core, exposing dual-core multitasking, queues, and semaphores directly through standard Arduino sketches.

This guide walks through building a robust dual-core sensor polling and WiFi-ready architecture. We will pin a BME280 environmental sensor read to Core 0, leaving Core 1 free for WiFi/MQTT stack operations, and thoroughly debug the most common crashes you will encounter.

Project Spec Sheet & Parts List

Difficulty: Intermediate | Time: 45 Minutes | Target Board: ESP32-WROOM-32 (38-pin DevKit V1)

To replicate this build exactly, source the following specific variants. Using clone boards with different GPIO strapping pin behaviors (like the 30-pin ESP32-S variants) may require adjusting the I2C pin definitions in the code.

Component Exact Variant / Model Estimated Cost (2026)
Microcontroller ESP32-WROOM-32 DevKit V1 (38-pin, Type-C or Micro-USB) $6.00 - $8.50
Sensor Adafruit BME280 I2C / SPI (STEMMA QT / Qwiic variant, Product ID 2652) $19.95
Wiring 28 AWG silicone stranded wire, 4-pin JST-SH STEMMA QT cable $5.00
Pull-up Resistors 4.7kΩ 1/4W metal film (Only if using raw breakout, Adafruit board has onboard 10kΩ) $0.10

Pin Mapping & Hardware Wiring

The ESP32-WROOM-32 DevKit V1 exposes default I2C pins on GPIO 21 (SDA) and GPIO 22 (SCL). While the Arduino Wire library allows software remapping of these pins, sticking to the hardware defaults ensures the most stable I2C bus timing, which is critical when multiple FreeRTOS tasks are competing for bus access.

BME280 STEMMA QT Pin ESP32 DevKit V1 Pin Notes
VIN (or 3Vo) 3V3 Do NOT use 5V; the BME280 logic level is strictly 3.3V.
GND GND Ensure a solid common ground to prevent I2C ACK failures.
SDA GPIO 21 Default I2C Data line on ESP32-WROOM-32.
SCL GPIO 22 Default I2C Clock line on ESP32-WROOM-32.
Bench Tip: If your I2C bus locks up randomly when the WiFi radio transmits, it is almost always a power brownout on the 3.3V rail. Solder a 100µF electrolytic capacitor and a 0.1µF ceramic capacitor directly across the 3V3 and GND pins on the ESP32 dev board.

Complete FreeRTOS Arduino Code (ESP32 Dual-Core)

This code targets the ESP32 Dev Module board selection in the Arduino IDE (ensure ESP32 core v2.0.14 or v3.x is installed). It creates a dedicated task pinned to Core 0 for sensor reading, utilizing xTaskCreatePinnedToCore. Core 1 is left to handle the Arduino loop() and background WiFi/RTOS daemons.

#include <Arduino.h>
#include <Wire.h>
#include <Adafruit_BME280.h>

// --- Pin Definitions ---
#define I2C_SDA_PIN 21
#define I2C_SCL_PIN 22
#define LED_STATUS_PIN 2 // Built-in LED on most DevKit V1 boards

// --- Task Handles & Parameters ---
TaskHandle_t SensorTaskHandle = NULL;
const int SENSOR_TASK_STACK_SIZE = 4096;
const int SENSOR_TASK_PRIORITY = 1;
const int SENSOR_TASK_CORE = 0;

Adafruit_BME280 bme;

// --- Sensor Task Function (Pinned to Core 0) ---
void sensorReadTask(void *pvParameters) {
  // Initialize I2C inside the task to ensure it runs on Core 0's context
  Wire.begin(I2C_SDA_PIN, I2C_SCL_PIN);
  
  if (!bme.begin(0x77, &Wire)) {
    Serial.println("[ERROR] Could not find a valid BME280 sensor, check wiring!");
    // Halt task gracefully if hardware fails
    vTaskDelete(NULL);
    return; 
  }
  
  Serial.println("[INFO] BME280 initialized successfully on Core 0.");

  for (;;) {
    float temp = bme.readTemperature();
    float humidity = bme.readHumidity();
    
    Serial.printf("[Core %d] Temp: %.2f C | Humidity: %.2f %%\n", 
                  xPortGetCoreID(), temp, humidity);
    
    // Toggle LED to show task is alive
    digitalWrite(LED_STATUS_PIN, !digitalRead(LED_STATUS_PIN));

    // Check stack high water mark every 10 cycles
    UBaseType_t highWater = uxTaskGetStackHighWaterMark(NULL);
    Serial.printf("[DEBUG] Sensor Task Stack High Water Mark: %u bytes\n", highWater);

    // CRITICAL: Yield to prevent Task Watchdog Trigger
    vTaskDelay(pdMS_TO_TICKS(2000));
  }
}

void setup() {
  Serial.begin(115200);
  delay(1000); // Allow serial monitor to connect
  Serial.println("\n--- FreeRTOS Dual-Core ESP32 Boot ---");
  
  pinMode(LED_STATUS_PIN, OUTPUT);

  // Create Task pinned to Core 0
  BaseType_t xReturned = xTaskCreatePinnedToCore(
    sensorReadTask,       // Function to implement the task
    "SensorReadTask",     // Name of the task
    SENSOR_TASK_STACK_SIZE, // Stack size in bytes
    NULL,                 // Task input parameter
    SENSOR_TASK_PRIORITY, // Priority of the task
    &SensorTaskHandle,    // Task handle
    SENSOR_TASK_CORE      // Core where the task should run
  );

  if (xReturned != pdPASS) {
    Serial.println("[FATAL] Failed to create Sensor Task. Insufficient heap?");
    ESP.restart();
  }
}

void loop() {
  // Core 1 handles this loop and background WiFi/BT stacks.
  // Always include a delay in loop() to feed the Core 1 IDLE task.
  vTaskDelay(pdMS_TO_TICKS(1000));
}

Debugging: First Three Things to Check When It Fails

When migrating from bare-metal Arduino to FreeRTOS on the ESP32, you will inevitably hit a crash. Here is exactly how to diagnose the two most common fatal errors.

1. The Task Watchdog Error

Exact Error String: E (12345) task_wdt: Task watchdog got triggered. The following tasks did not reset the watchdog in time:

Ranked Causes & Fixes:

  1. Missing vTaskDelay() or yield(): FreeRTOS relies on the IDLE task to reset the hardware watchdog. If your task runs an infinite while(1) loop without yielding, the IDLE task never runs. Fix: Add vTaskDelay(1) inside your loop.
  2. Blocking I2C/SPI operations: A locked-up I2C bus (due to missing pull-ups) will cause Wire.requestFrom() to block indefinitely. Fix: Set I2C timeout using Wire.setWireTimeout(50000, true) (50ms timeout).
  3. Core 1 Loop Hogging: Forgetting to put a delay in the main Arduino loop() function starves the Core 1 background tasks. Fix: Always put vTaskDelay(pdMS_TO_TICKS(10)) at the end of loop().

2. The Guru Meditation Panic

Exact Error String: Guru Meditation Error: Core 1 panic'ed (Interrupt wdt timeout on CPU1).

Ranked Causes & Fixes:

  1. Interrupt Service Routine (ISR) taking too long: If you have an interrupt attached to a pin, and the ISR executes delay() or prints to Serial, it will trigger the Interrupt WDT. Fix: Keep ISRs under 50 microseconds. Use xQueueSendFromISR to pass data to a task.
  2. Stack Overflow: If your task allocates large local arrays (e.g., char buffer[2048]), it overflows the allocated FreeRTOS stack, corrupting memory and causing a panic. Fix: Increase SENSOR_TASK_STACK_SIZE or move large buffers to the heap using malloc or ps_malloc.
Safety & Hardware Note: Never hot-swap I2C sensors while the ESP32 is powered. The ESP32's I2C peripheral lacks robust over-voltage tolerance on the SDA/SCL lines. A hot-swap transient can permanently fuse the GPIO pin to 3.3V, requiring you to remap the I2C bus to different GPIO pins in software.

Extending and Simplifying the Build

How to Simplify: If you do not need strict dual-core isolation, delete the xTaskCreatePinnedToCore block entirely. Move the BME280 initialization into setup() and the reading logic into the standard Arduino loop(). The ESP32 Arduino core still runs FreeRTOS in the background to manage WiFi, but your code will behave like a standard single-threaded sketch.

How to Extend: To pass data safely from Core 0 (Sensor) to Core 1 (WiFi MQTT publish), implement a FreeRTOS Queue. Add QueueHandle_t sensorQueue = xQueueCreate(5, sizeof(float)); in your global scope. Inside the sensor task, replace the Serial print with xQueueSend(sensorQueue, &temp, pdMS_TO_TICKS(10));. In your loop() on Core 1, use xQueueReceive() to pull the temperature and publish it via MQTT without ever blocking the sensor polling rate.

For comprehensive API references, consult the official Espressif ESP-IDF FreeRTOS documentation and the FreeRTOS reference manual.

FreeRTOS Arduino FAQ

Can I run FreeRTOS on an Arduino Uno or Nano?

Technically yes, using third-party libraries like FreeRTOS-Arduino, but practically no. The ATmega328P has only 2KB of SRAM. A single FreeRTOS task requires a minimum stack allocation (usually 128-256 bytes just for context switching), leaving almost no memory for your actual application logic, strings, or buffers. For RTOS capabilities, upgrade to an ESP32, Raspberry Pi Pico (RP2040), or Teensy 4.0.

How do I check FreeRTOS stack high water marks on ESP32?

Use the uxTaskGetStackHighWaterMark(TaskHandle_t xTask) function. Passing NULL returns the water mark for the currently executing task. The returned value is the number of unused bytes remaining in the stack. If this number drops below 200 bytes during operation, your task is at high risk of a stack overflow crash and you must increase the stack size parameter in xTaskCreate.

Why does my ESP32 reboot with a Guru Meditation Error in FreeRTOS?

A Guru Meditation Error is the ESP32's hardware exception handler catching a fatal fault. In FreeRTOS contexts, this is almost always caused by a stack overflow (writing past the allocated task memory), an illegal memory access (dereferencing a null pointer), or an Interrupt Watchdog timeout (an ISR taking longer than 300ms). Check your serial monitor backtrace and use the ESP32 Exception Decoder tool to map the hex addresses to your specific lines of code.

Does enabling WiFi on the ESP32 interfere with my FreeRTOS tasks?

Yes, if not managed correctly. The ESP32 WiFi and Bluetooth stacks run as high-priority background FreeRTOS tasks, primarily on Core 1. If you pin a CPU-intensive task to Core 1 without yielding, you will starve the WiFi stack, leading to dropped connections and Task watchdog errors. Always pin heavy sensor or DSP tasks to Core 0, and leave Core 1 for the loop() and network operations.