If you are moving beyond simple delay() loops and want to run concurrent operations without blocking your main loop, you need a Real-Time Operating System (RTOS). While classic 8-bit AVRs can technically run a stripped-down RTOS, the modern standard for FreeRTOS for Arduino development is the dual-core ESP32. The ESP32 Arduino Core (v3.x, based on ESP-IDF 5.x) has FreeRTOS baked directly into the silicon abstraction layer, giving you true preemptive multitasking out of the box.
In this guide, we will build a dual-core environmental monitor. Core 0 will handle polling a BME280 sensor, while Core 1 manages an I2C OLED display. We will use FreeRTOS queues to pass data safely between the cores, avoiding the race conditions that plague beginner multitasking attempts.
Project Overview & Hardware Requirements
This build assumes you are using the standard 30-pin ESP32 DevKit V1. Do not use the ESP32-C3 or ESP32-S2 for this specific dual-core tutorial, as those variants feature single-core RISC-V or Xtensa architectures and will throw compilation errors when you attempt to pin tasks to "Core 1".
Parts List
- Microcontroller: ESP32-WROOM-32 DevKit V1 (30-pin, dual-core Xtensa LX6 @ 240MHz)
- Sensor: Adafruit BME280 I2C Breakout (Part #2652) or equivalent generic BME280 module
- Display: Adafruit SSD1306 128x32 OLED I2C (Part #938) or generic 128x32 0.91" OLED
- Wiring: 22 AWG solid core hookup wire, half-size breadboard
Pin Mapping Table
Both the BME280 and the OLED share the same I2C bus. The ESP32's default hardware I2C pins are GPIO 21 (SDA) and GPIO 22 (SCL). Always use 3.3V for I2C logic on the ESP32; feeding 5V into these pins will permanently damage the silicon.
| Component Pin | ESP32 GPIO | Notes |
|---|---|---|
| BME280 VCC / OLED VCC | 3V3 | Do not use VIN/5V for 3.3V logic modules |
| BME280 GND / OLED GND | GND | Common ground required |
| BME280 SDA / OLED SDA | GPIO 21 | Default I2C Data |
| BME280 SCL / OLED SCL | GPIO 22 | Default I2C Clock |
Wiring the Dual-Core Sensor Node
- Power the Rails: Connect the ESP32 3V3 pin to the breadboard's positive rail and GND to the negative rail.
- Wire the I2C Bus: Run jumpers from GPIO 21 to the SDA pins on both the BME280 and the OLED. Run jumpers from GPIO 22 to the SCL pins on both modules.
- Verify Addresses: The BME280 default I2C address is usually
0x76(or0x77depending on the manufacturer's jumper pad). The SSD1306 128x32 OLED is almost always0x3C. Because they are different, they can peacefully coexist on the same bus. - Double-Check Voltages: Before plugging the ESP32 into USB, use a multimeter in continuity mode to ensure VCC is not shorted to GND, and verify you haven't accidentally wired 5V to the SDA/SCL lines.
The Complete FreeRTOS Arduino Code
The following code creates two distinct tasks. SensorTask is pinned to Core 0, and DisplayTask is pinned to Core 1. We use a FreeRTOS Queue (xQueueCreate) to pass a custom struct containing the sensor readings. This is vastly superior to using global variables with volatile flags, as queues handle thread-safety and memory synchronization natively.
#include <Arduino.h>
#include <Wire.h>
#include <Adafruit_BME280.h>
#include <Adafruit_SSD1306.h>
// --- Pin & Hardware Definitions ---
#define I2C_SDA 21
#define I2C_SCL 22
#define SCREEN_WIDTH 128
#define SCREEN_HEIGHT 32
#define OLED_RESET -1
#define BME_I2C_ADDR 0x76
#define OLED_I2C_ADDR 0x3C
// --- FreeRTOS Task Handles & Queue ---
TaskHandle_t SensorTaskHandle = NULL;
TaskHandle_t DisplayTaskHandle = NULL;
QueueHandle_t sensorDataQueue;
// Data structure to pass between cores
struct SensorData {
float temperature;
float humidity;
float pressure;
};
// Hardware Objects
Adafruit_BME280 bme;
Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, OLED_RESET);
// --- Core 0 Task: Sensor Polling ---
void sensorTask(void * parameter) {
SensorData data;
for(;;) {
data.temperature = bme.readTemperature();
data.humidity = bme.readHumidity();
data.pressure = bme.readPressure() / 100.0F; // Convert to hPa
// Send to queue. portMAX_DELAY means block until space is available.
if(xQueueSend(sensorDataQueue, &data, portMAX_DELAY) != pdTRUE) {
Serial.println("[ERROR] Failed to send to queue.");
}
// Sleep for 2 seconds. NEVER use delay() in FreeRTOS; it starves the watchdog.
vTaskDelay(pdMS_TO_TICKS(2000));
}
}
// --- Core 1 Task: Display Rendering ---
void displayTask(void * parameter) {
SensorData receivedData;
for(;;) {
// Wait indefinitely for data to arrive in the queue
if(xQueueReceive(sensorDataQueue, &receivedData, portMAX_DELAY) == pdTRUE) {
display.clearDisplay();
display.setTextSize(1);
display.setTextColor(SSD1306_WHITE);
display.setCursor(0,0);
display.print("Temp: "); display.print(receivedData.temperature, 1); display.println(" C");
display.setCursor(0,10);
display.print("Hum: "); display.print(receivedData.humidity, 1); display.println(" %");
display.setCursor(0,20);
display.print("Pres: "); display.print(receivedData.pressure, 0); display.println(" hPa");
display.display();
}
}
}
void setup() {
Serial.begin(115200);
delay(500); // Allow serial monitor to connect
Wire.begin(I2C_SDA, I2C_SCL);
if (!bme.begin(BME_I2C_ADDR)) {
Serial.println("[FATAL] BME280 init failed. Check wiring and I2C address.");
while(1) { vTaskDelay(pdMS_TO_TICKS(1000)); }
}
if(!display.begin(SSD1306_SWITCHCAPVCC, OLED_I2C_ADDR)) {
Serial.println("[FATAL] SSD1306 init failed. Check wiring.");
while(1) { vTaskDelay(pdMS_TO_TICKS(1000)); }
}
display.clearDisplay();
display.display();
// Create Queue: 5 slots deep, each slot holds sizeof(SensorData) bytes
sensorDataQueue = xQueueCreate(5, sizeof(SensorData));
if(sensorDataQueue == NULL) {
Serial.println("[FATAL] Queue creation failed. Out of heap.");
while(1) { vTaskDelay(pdMS_TO_TICKS(1000)); }
}
// Create Tasks and Pin to Cores
// Note: Stack sizes are 4096 and 8192 bytes respectively.
xTaskCreatePinnedToCore(sensorTask, "SensorTask", 4096, NULL, 1, &SensorTaskHandle, 0);
xTaskCreatePinnedToCore(displayTask, "DisplayTask", 8192, NULL, 1, &DisplayTaskHandle, 1);
}
void loop() {
// The Arduino loop() runs on Core 1 by default.
// Since our tasks handle everything, we suspend the loop task to free up CPU cycles.
vTaskDelay(portMAX_DELAY);
}
Debugging the "Stack Canary Watchpoint" Panic
When working with FreeRTOS for Arduino on the ESP32, the most common and terrifying error you will encounter is the stack overflow panic. It looks exactly like this in your serial monitor:
Guru Meditation Error: Core 1 panic'ed (Stack canary watchpoint triggered (DisplayTask))
This happens when a task writes past its allocated stack memory, corrupting the "canary" value the RTOS places at the end of the stack block to detect overflows. The ESP32 hardware watchdog catches this and immediately reboots the chip to prevent erratic behavior.
Ranked Causes & Fixes
- Undersized Stack Allocation (Most Common): You passed a value like
1024or2048into theusStackDepthparameter ofxTaskCreatePinnedToCore. I2C operations and the Adafruit GFX library require significant stack space for internal buffering. Fix: Increase the stack size to 4096 for simple sensor reads, and 8192 for display rendering tasks. - Large Local Variables: You declared a large array inside the task function (e.g.,
char jsonBuffer[2048];). Local variables live on the stack. Fix: Move large buffers to the global scope, declare them asstaticinside the function, or allocate them on the heap usingmalloc()(and free them properly). - Deep Call Chains & String Formatting: Using
sprintf,Stringobjects, or deep recursive function calls inside the task. The ArduinoStringclass is notorious for causing heap fragmentation and stack spikes. Fix: Use standard C-arrays andsnprintfinstead of theStringclass inside RTOS tasks.
1. Look at the task name in the panic string (e.g.,
DisplayTask) and double its stack size parameter.2. Scan the task's
for(;;) loop for any local arrays larger than 64 bytes.3. Ensure you are using
vTaskDelay() instead of delay() to yield control back to the RTOS scheduler.
For deeper architectural details on how the ESP32 implements this, refer to the official Espressif FreeRTOS Documentation, which details the SMP (Symmetric Multiprocessing) extensions unique to the ESP32.
Extending and Simplifying the Build
Once you have the basic queue mechanics working, you will inevitably want to scale the project. Here is how to adapt the architecture based on your end goals.
How to Simplify (The Mutex Approach)
If you only have one sensor and one display, a Queue might feel like overkill. You can simplify the build by removing the queue entirely. Instead, declare a global SensorData struct and protect it with a Mutex (SemaphoreHandle_t). The sensor task locks the mutex, updates the struct, and unlocks it. The display task locks the mutex, reads the struct, and unlocks it. This uses slightly less heap memory but requires careful lock management to avoid deadlocks.
How to Extend (Adding WiFi & MQTT)
To turn this into an IoT node, add a third task: WiFiTask. Pin it to Core 0 alongside the sensor task. Instead of sending data to a queue, the sensor task can write to a thread-safe ring buffer, and the WiFi task can read from it, packaging the JSON payload and pushing it to an MQTT broker via the PubSubClient library. Because the ESP32's native WiFi stack already runs as a hidden high-priority task on Core 0, keeping your network logic on Core 0 prevents cross-core bus contention on the internal memory arbiters.
For a comprehensive breakdown of RTOS queue and semaphore theory, the FreeRTOS Kernel Features Guide remains the definitive reference.
FreeRTOS for Arduino FAQ
Can I use FreeRTOS on Arduino Uno or just ESP32?
You can use FreeRTOS on an Arduino Uno (ATmega328P) by installing the Arduino_FreeRTOS library from the Library Manager. However, the Uno only has 2KB of total SRAM. Since every FreeRTOS task requires a minimum stack allocation (usually 128 to 256 bytes just to start), you will run out of memory almost immediately if you try to run more than two or three basic tasks. The ESP32 has 520KB of SRAM, making it the only practical choice for serious FreeRTOS projects in the Arduino ecosystem.
How do I share variables between FreeRTOS tasks safely?
Never share variables using standard global variables without protection; this leads to race conditions where one core reads a half-updated value. You have two safe options:
1. Queues (xQueueCreate): Best for passing discrete events or sensor readings from one task to another. It copies the data safely.
2. Mutexes (xSemaphoreCreateMutex): Best for sharing a large, complex state object (like a configuration struct) where copying the data into a queue would waste CPU cycles. The mutex acts as a token; only the task holding the token can read or write the variable.
Why does my ESP32 reboot with a Task Watchdog Timeout instead of a Stack Canary error?
A Task Watchdog Timeout (TWDT) is a different error than a stack overflow. It occurs when a high-priority task hogs the CPU and never yields control back to the scheduler. This usually happens if you use a blocking while() loop waiting for a sensor, or if you use the standard Arduino delay() function instead of the RTOS-native vTaskDelay(). The Idle Task needs CPU time to reset the hardware watchdog; if your task starves the Idle Task, the chip resets. Always use vTaskDelay(pdMS_TO_TICKS(x)) inside your for(;;) loops.






