If you are moving beyond simple delay() loops and need your microcontroller to handle sensor polling, LED blinking, and WiFi transmission simultaneously, you need a Real-Time Operating System (RTOS). The xTaskCreate function is the core FreeRTOS mechanism for spawning independent execution threads. On the dual-core ESP32, we use its sibling, xTaskCreatePinnedToCore, to explicitly assign tasks to Core 0 or Core 1, preventing resource starvation and watchdog resets.

This guide provides a complete, bench-tested arduino_freertos xtaskcreate example using an ESP32 and a BME280 environmental sensor. We will cover exact stack sizing, dual-core pinning, and how to debug the inevitable 'Guru Meditation' crashes.

Project Overview & Hardware Requirements

Difficulty: Intermediate | Time: 45 Minutes | Cost: ~$12 USD

This build targets the ESP32 DevKit V1 featuring the ESP32-WROOM-32 module (30-pin variant). Do not use an ESP32-C3 for this specific dual-core example, as the C3 is a single-core RISC-V chip. If you are using an ESP32-S3, the code will work, but be aware that the S3 uses Xtensa LX7 cores and has a different memory map.

Parts List

  • MCU: ESP32 DevKit V1 (ESP32-WROOM-32, 30-pin)
  • Sensor: BME280 Breakout Board (I2C variant, 3.3V logic)
  • Indicator: 5mm LED with 220Ω current-limiting resistor
  • Wiring: 22 AWG solid core jumper wires, half-size breadboard
  • Software: Arduino IDE 2.x with 'esp32' board package (v2.0.14 or newer) and Adafruit BME280 Library
Bench Tip: Always power the BME280 from the ESP32's 3.3V pin, never the 5V (VIN) pin. The BME280's internal CMOS will degrade rapidly at 5V, leading to I2C bus lockups within a few hours of operation.

ESP32 FreeRTOS Task Allocation Table

Before writing code, you must plan your task stack sizes. A common beginner mistake is assigning the default minimal stack to a task that performs floating-point math or uses the Wire.h library, resulting in an immediate stack overflow. Note that in the ESP32 Arduino core, stack sizes for xTaskCreate are specified in bytes, unlike standard FreeRTOS which uses words.

Task Name Function Priority (0-25) Stack Size (Bytes) Core Affinity Purpose & Justification
SensorReadTask readBME280 2 4096 Core 0 Reads I2C data. Needs 4KB due to Wire.h buffers and floating-point math for temp/humidity calculations.
BlinkTask blinkLED 1 1024 Core 1 Toggles GPIO. Minimal stack required; isolated on Core 1 to guarantee timing regardless of I2C bus delays.
SerialTask printData 1 2048 Core 0 Formats and prints strings. Needs 2KB for Serial.printf string formatting buffers.
loopTask (System) loop() 1 8192 Core 1 Default Arduino loop. We yield this core to prevent watchdog timeouts while our custom tasks run.

Pin Mapping & Wiring the Peripherals

The ESP32-WROOM-32 has default I2C pins mapped to GPIO 21 (SDA) and GPIO 22 (SCL). While you can remap these, sticking to the defaults ensures compatibility with the underlying ESP-IDF I2C driver.

ESP32 GPIO Direction Target Component Notes
3V3 Power Out BME280 VIN Strictly 3.3V. Do not use 5V.
GND Ground BME280 GND, LED Cathode Common ground required for I2C logic levels.
GPIO 21 I2C SDA BME280 SDA Default I2C Data. Internal pull-ups enabled in code.
GPIO 22 I2C SCL BME280 SCL Default I2C Clock.
GPIO 2 Digital Out LED Anode (via 220Ω) Also the onboard boot LED on most DevKit V1 boards.
  1. Insert the ESP32 DevKit V1 into the breadboard, ensuring pins are fully seated.
  2. Wire the BME280 VCC to 3V3, GND to GND, SDA to GPIO 21, and SCL to GPIO 22.
  3. Connect the 220Ω resistor to GPIO 2, then to the LED anode. Connect the LED cathode to GND.
  4. Double-check that no 5V lines are touching the BME280 breakout.

Complete Arduino FreeRTOS xTaskCreate Example Code

Below is the complete, compilable code. It includes explicit pin definitions, I2C error handling during setup, and return-code checking for task creation. Install the Adafruit BME280 Library and its dependency (Adafruit Unified Sensor) via the Arduino Library Manager before compiling.

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

// --- PIN DEFINITIONS ---
#define I2C_SDA_PIN 21
#define I2C_SCL_PIN 22
#define LED_PIN     2
#define I2C_FREQ    400000 // 400kHz Fast Mode

// --- GLOBAL OBJECTS & HANDLES ---
Adafruit_BME280 bme;
TaskHandle_t SensorTaskHandle = NULL;
TaskHandle_t BlinkTaskHandle = NULL;
TaskHandle_t SerialTaskHandle = NULL;

// Shared variables (In a production build, use FreeRTOS Queues or Mutexes)
float currentTemp = 0.0;
float currentHumidity = 0.0;

// --- TASK 1: SENSOR READING (CORE 0) ---
void readBME280(void * parameter) {
  for(;;) {
    // Read sensor data
    currentTemp = bme.readTemperature();
    currentHumidity = bme.readHumidity();
    
    // Delay for 2000ms (converted to ticks)
    vTaskDelay(2000 / portTICK_PERIOD_MS);
  }
}

// --- TASK 2: LED BLINKING (CORE 1) ---
void blinkLED(void * parameter) {
  bool ledState = false;
  for(;;) {
    ledState = !ledState;
    digitalWrite(LED_PIN, ledState);
    // 500ms delay
    vTaskDelay(500 / portTICK_PERIOD_MS);
  }
}

// --- TASK 3: SERIAL PRINTING (CORE 0) ---
void printData(void * parameter) {
  for(;;) {
    Serial.printf("Temp: %.2f C | Humidity: %.2f %%\n", currentTemp, currentHumidity);
    // 3000ms delay
    vTaskDelay(3000 / portTICK_PERIOD_MS);
  }
}

void setup() {
  Serial.begin(115200);
  delay(1000); // Allow serial monitor to connect
  Serial.println("ESP32 FreeRTOS xTaskCreate Booting...");

  // Initialize GPIO
  pinMode(LED_PIN, OUTPUT);

  // Initialize I2C with explicit pins and frequency
  Wire.begin(I2C_SDA_PIN, I2C_SCL_PIN, I2C_FREQ);

  // Initialize BME280 with error handling
  if (!bme.begin(0x76, &Wire)) {
    Serial.println("[ERROR] Could not find a valid BME280 sensor, check wiring or I2C address!");
    // Halt execution safely rather than crashing in a task later
    while (1) { delay(1000); }
  }
  Serial.println("BME280 initialized successfully.");

  // Create Tasks using xTaskCreatePinnedToCore
  // Parameters: Function, Name, StackSize(bytes), Parameter, Priority, Handle, CoreID
  
  BaseType_t xReturned;

  xReturned = xTaskCreatePinnedToCore(readBME280, "SensorReadTask", 4096, NULL, 2, &SensorTaskHandle, 0);
  if (xReturned != pdPASS) Serial.println("[ERROR] Failed to create SensorTask");

  xReturned = xTaskCreatePinnedToCore(blinkLED, "BlinkTask", 1024, NULL, 1, &BlinkTaskHandle, 1);
  if (xReturned != pdPASS) Serial.println("[ERROR] Failed to create BlinkTask");

  xReturned = xTaskCreatePinnedToCore(printData, "SerialTask", 2048, NULL, 1, &SerialTaskHandle, 0);
  if (xReturned != pdPASS) Serial.println("[ERROR] Failed to create SerialTask");
}

void loop() {
  // In a pure FreeRTOS ESP32 build, the main loop should yield or sleep.
  // Running an empty infinite loop here will trigger the Task Watchdog.
  vTaskDelay(portMAX_DELAY);
}

Debugging: First Three Things to Check When It Fails

When working with RTOS on the ESP32, crashes rarely happen at compile time; they happen at runtime. If your board resets or locks up, check these three failure modes in order.

1. The Stack Overflow (Guru Meditation Error)

Exact Error String: Guru Meditation Error: Core 1 panic'ed (StoreProhibited). exception was unhandled. or Stack canary watchpoint triggered (SensorReadTask).

The Cause: Your task ran out of allocated stack memory. This happens if you declared large local arrays (e.g., char buffer[2000];) inside the task function, or if you are using String objects which cause heap fragmentation and deep call stacks.

The Fix: Increase the stack size parameter in xTaskCreatePinnedToCore (e.g., from 2048 to 4096 bytes). Move large buffers to global scope or allocate them on the heap using malloc (and free them).

2. The Watchdog Timeout

Exact Error String: Guru Meditation Error: Core 1 panic'ed (Interrupt wdt timeout on CPU1) or Task watchdog got triggered. The following tasks did not reset the watchdog in time: loopTask.

The Cause: The ESP32 has a hardware watchdog timer (WDT) that resets the chip if a task hogs the CPU for more than ~1.2 seconds without yielding. This happens if you use a blocking while() loop or standard delay() inside a FreeRTOS task.

The Fix: Never use Arduino's delay() inside an RTOS task. Always use vTaskDelay(). Ensure every infinite for(;;) loop contains a vTaskDelay() or a blocking queue receive call (xQueueReceive) to yield control back to the scheduler.

3. I2C Bus Lockup

Symptom: The serial monitor prints the first reading, then freezes completely without a Guru Meditation error.

The Cause: The I2C bus is waiting for a clock stretch that never ends, or the SDA line is pulled low by a glitch. The Wire.requestFrom() call inside the BME280 library blocks indefinitely.

The Fix: Ensure you have 4.7kΩ pull-up resistors on SDA and SCL (many cheap BME280 breakouts omit these). In code, set a wire timeout before initializing the sensor: Wire.setWireTimeout(50000, true); (timeout in microseconds). This forces the I2C driver to abort rather than hang the RTOS task.

Extending and Simplifying Your Build

Once you have the baseline arduino_freertos xtaskcreate example running, you will likely want to adapt it for your specific project constraints.

How to Simplify (Single-Core & Basic Sensors)

If you are using a single-core board like the ESP32-C3, or you simply don't care about core affinity, swap xTaskCreatePinnedToCore for the standard xTaskCreate. The ESP32 Arduino core will automatically assign the task to whichever core has the most free heap space. If you don't have a BME280, replace the sensor task with a simple analogRead() on GPIO 34 (an ADC1 pin that works safely alongside WiFi) to read a potentiometer or LDR.

How to Extend (Queues, Mutexes, and WiFi)

Passing data via global variables (like currentTemp in our example) works for simple blink-and-read sketches, but it is not thread-safe. To extend this build for a production IoT device:

  • Use FreeRTOS Queues: Create a queue using xQueueCreate(10, sizeof(float)). Have the SensorTask push temperature readings into the queue with xQueueSend, and create a new WiFiTask that blocks on xQueueReceive. This decouples sensor timing from network latency.
  • Protect Shared Hardware: If you add a second I2C device (like an OLED display), both tasks will try to use the Wire object simultaneously, corrupting the bus. Create a Mutex using xSemaphoreCreateMutex() and wrap all I2C calls in xSemaphoreTake() and xSemaphoreGive() blocks.
  • Add Deep Sleep: For battery-powered nodes, delete the infinite tasks after taking a reading, format the WiFi payload, and trigger esp_deep_sleep_start(). RTOS is for active multitasking; deep sleep is for power management.

For deeper architectural guidance on ESP32 task management, refer to the official Espressif ESP-IDF FreeRTOS Documentation, which details the underlying SMP (Symmetric Multiprocessing) modifications Espressif made to standard FreeRTOS.