The Decision Matrix: Do You Actually Need FreeRTOS?

Before rewriting your entire sketch to use an RTOS, you need to know if the overhead is justified. FreeRTOS introduces context-switching latency, stack memory fragmentation, and a steep debugging curve. Use this decision path to determine your architecture:

ConditionArchitecture Pick
Polling 1-2 sensors under 10Hz, no blocking network I/OStick to bare-metal Arduino loop() with millis() timers.
Need deterministic sub-millisecond motor control (FOC)Use hardware timers and ISRs; avoid RTOS context switches.
Concurrent I2C sensor sampling at 100Hz + WiFi/BLE streamingConcrete Pick: ESP32-WROOM-32E running native FreeRTOS.

If your project falls into the third category, the ESP32 DevKit V1 (ESP32-WROOM-32E variant) is your target board. Unlike standard AVR Arduinos (Uno/Nano) which struggle with the 2KB+ RAM overhead of an RTOS, the ESP32's 520KB SRAM and dual-core 240MHz Xtensa processors are purpose-built for FreeRTOS. In the Arduino-ESP32 core, FreeRTOS is not an add-on library; it is the underlying operating system running the Arduino API itself.

Hardware Spec Sheet and Pin Mapping

For this build, we are creating a multitasking environmental logger. Task 0 reads the sensor, Task 1 blinks a heartbeat LED, and Task 2 formats and streams the data over Serial. We pass data between tasks using a thread-safe FreeRTOS Queue.

Component Selection Note: Use the Adafruit BME280 (Product ID: 2652) or a generic 3.3V-native BME280 breakout. Do not use 5V breakouts with the ESP32 without a bidirectional logic level shifter, or you will fry the ESP32's GPIO pins over time due to I2C bus overvoltage.

Parts List

  • Microcontroller: ESP32 DevKit V1 (ESP32-WROOM-32E, 4MB Flash, 520KB SRAM)
  • Sensor: Bosch BME280 (I2C variant, 3.3V logic)
  • Indicator: 5mm LED with 330Ω current-limiting resistor (or use onboard GPIO 2 LED)
  • Wiring: 22 AWG solid core jumper wires

Pin Mapping Table

ESP32 GPIOComponentFunctionNotes
GPIO 21BME280I2C SDADefault Wire SDA on ESP32
GPIO 22BME280I2C SCLDefault Wire SCL on ESP32
GPIO 2LEDHeartbeatActive HIGH (also onboard LED)
3V3BME280VCCDo not use VIN/5V for 3.3V sensors
GNDBME280 / LEDGroundCommon ground required

The Build: Complete Multitasking FreeRTOS Code

The following code is fully compilable in the Arduino IDE (ensure you have the ESP32 board manager installed and the Adafruit BME280 Library via the Library Manager). It explicitly pins the sensor read task to Core 0 and the Serial print task to Core 1, preventing WiFi stack interruptions on Core 1 from delaying your sensor reads.

Stack Size Gotcha: Standard FreeRTOS defines stack size in words (4 bytes per word on 32-bit). The ESP-IDF implementation used in the Arduino core defines stack size in bytes. If you allocate '1024' thinking it is 4KB, you are actually giving it 1KB, which will cause an immediate stack overflow panic. We use 4096 bytes below to be safe.
#include <Wire.h>
#include <Adafruit_BME280.h>

// --- Pin Definitions ---
#define I2C_SDA 21
#define I2C_SCL 22
#define STATUS_LED 2

// --- RTOS Handles ---
QueueHandle_t sensorDataQueue;

// --- Data Structure for Queue ---
struct SensorReading {
  float temperature;
  float humidity;
  float pressure;
  unsigned long timestamp;
};

Adafruit_BME280 bme;

// --- Task 1: Sensor Read (Pinned to Core 0) ---
void TaskReadSensor(void *pvParameters) {
  SensorReading reading;
  Wire.begin(I2C_SDA, I2C_SCL);
  
  if (!bme.begin(0x76, &Wire)) {
    Serial.println("[FATAL] BME280 init failed. Check I2C wiring.");
    while (1) { vTaskDelay(1000 / portTICK_PERIOD_MS); }
  }

  for (;;) {
    reading.temperature = bme.readTemperature();
    reading.humidity = bme.readHumidity();
    reading.pressure = bme.readPressure() / 100.0F;
    reading.timestamp = millis();

    // Send to queue, wait up to 10 ticks if full
    xQueueSend(sensorDataQueue, &reading, pdMS_TO_TICKS(10));
    
    // Sample at 2Hz (500ms)
    vTaskDelay(500 / portTICK_PERIOD_MS);
  }
}

// --- Task 2: Heartbeat LED (Pinned to Core 1) ---
void TaskBlinkLED(void *pvParameters) {
  pinMode(STATUS_LED, OUTPUT);
  for (;;) {
    digitalWrite(STATUS_LED, HIGH);
    vTaskDelay(100 / portTICK_PERIOD_MS);
    digitalWrite(STATUS_LED, LOW);
    vTaskDelay(900 / portTICK_PERIOD_MS);
  }
}

// --- Task 3: Serial Telemetry (Pinned to Core 1) ---
void TaskSerialPrint(void *pvParameters) {
  SensorReading receivedData;
  for (;;) {
    // Block indefinitely until data arrives in queue
    if (xQueueReceive(sensorDataQueue, &receivedData, portMAX_DELAY) == pdTRUE) {
      Serial.printf("T:%.2fC H:%.1f%% P:%.1fhPa @ %lums\n",
                    receivedData.temperature,
                    receivedData.humidity,
                    receivedData.pressure,
                    receivedData.timestamp);
    }
  }
}

void setup() {
  Serial.begin(115200);
  while (!Serial) { delay(10); }
  Serial.println("[BOOT] Initializing FreeRTOS Tasks...");

  // Create queue: 10 items deep, each item is sizeof(SensorReading)
  sensorDataQueue = xQueueCreate(10, sizeof(SensorReading));

  // Create Tasks (Stack sizes in BYTES for ESP32)
  xTaskCreatePinnedToCore(TaskReadSensor, "SensorRead", 4096, NULL, 2, NULL, 0);
  xTaskCreatePinnedToCore(TaskBlinkLED, "Heartbeat", 1024, NULL, 1, NULL, 1);
  xTaskCreatePinnedToCore(TaskSerialPrint, "Telemetry", 4096, NULL, 1, NULL, 1);
}

void loop() {
  // Empty. In ESP32 Arduino, the default 'loopTask' runs here.
  // We delete it to free up Core 1 resources, or just let it idle.
  vTaskDelay(10000 / portTICK_PERIOD_MS);
}

Debugging: "Task Watchdog Got Triggered" Panic

When you transition from bare-metal Arduino to FreeRTOS, you will inevitably encounter the ESP32 Task Watchdog Timer (TWDT) panic. It looks like this in your serial monitor:

E (5432) task_wdt: Task watchdog got triggered. The following tasks did not reset the watchdog in time:
E (5432) task_wdt: - IDLE1 (CPU 1)
E (5432) task_wdt: Tasks currently running: CPU 0: SensorRead, CPU 1: loopTask

The TWDT monitors the Idle tasks. If your user tasks hog the CPU and never yield, the Idle task cannot run to reset the hardware watchdog, and the ESP32 reboots to prevent a hard lock. According to the Espressif ESP-IDF Watchdog Documentation, the default timeout is 5 seconds.

The First 3 Things to Check When It Fails

  1. Missing Yield in Infinite Loops: Every for(;;) or while(1) loop inside a task must contain a vTaskDelay(), xQueueReceive() with a timeout, or an explicit yield(). If you use a blocking while(!Serial.available()) {}, you will trigger the watchdog.
  2. I2C Clock Stretching Lockup: The standard Arduino Wire.h library has no internal timeout. If your BME280 SDA line gets pulled low by a glitch, Wire.requestFrom() will hang forever. Fix this by initializing with Wire.setWireTimeout(50000, true); (50ms timeout in microseconds) immediately after Wire.begin().
  3. Stack Overflow Corruption: If you allocated 1024 bytes for a task that uses Serial.printf() (which consumes ~1.5KB of stack space locally), the stack overflows, corrupts the Task Control Block (TCB), and the scheduler loses track of the task, triggering the watchdog. Increase stack size to 4096 or 8192 bytes.

Extending and Simplifying the Architecture

Once your baseline is stable, you will need to adapt the architecture to your specific product requirements. Here is how to scale the build up or down without breaking the scheduler.

How to Simplify (Resource Constrained)

If you are porting this to a smaller board like the ESP32-C3 (single core, 400KB SRAM) or only logging data once every 60 seconds, drop the Queue and the Serial Task entirely. Context switching and queue management consume CPU cycles. Instead, simply call Serial.printf() directly inside the TaskReadSensor loop. A 60-second vTaskDelay() means the overhead of a dedicated telemetry task is mathematically unjustified.

How to Extend (Adding a Second I2C Sensor)

If you add an SCD40 CO2 sensor to the same I2C bus, you cannot safely read both sensors in separate tasks concurrently. The Arduino Wire library is not thread-safe. You must implement a Mutex (Mutual Exclusion Semaphore) to protect the bus.

Create the mutex in setup():

SemaphoreHandle_t i2cMutex = xSemaphoreCreateMutex();

Then, wrap your I2C reads in both sensor tasks using the FreeRTOS Semaphore API:

if (xSemaphoreTake(i2cMutex, pdMS_TO_TICKS(100)) == pdTRUE) {
  // Safe to use Wire.h here
  float co2 = scd40.readCO2();
  xSemaphoreGive(i2cMutex); // Release the bus
} else {
  // Handle timeout (bus locked by another task)
}

By terminating your architecture decisions with concrete constraints—using explicit byte-counts for ESP32 stacks, pinning network-heavy tasks to Core 1, and wrapping shared hardware buses in Mutexes—you transform FreeRTOS from a source of random reboots into a deterministic, production-ready framework.