One of the biggest limitations of the classic Arduino Uno is that it runs everything sequentially on a single core. This means if you are reading a sensor, you cannot simultaneously update a display, and if you are waiting for a network response, everything else is blocked. The ESP32 changes this completely with its dual-core Xtensa LX6 processor and FreeRTOS operating system. This guide explains the fundamental differences in concurrency between Arduino and ESP32, and teaches you how to harness dual-core programming effectively.
Table of Contents
- How Arduino Handles Concurrency (or Doesn’t)
- ESP32 Dual-Core Architecture
- FreeRTOS Basics: Tasks, Priorities, and Scheduling
- Creating and Pinning Tasks to Cores
- Inter-Task Communication: Queues and Semaphores
- Managing Shared Resources Safely
- Real-World Concurrency Patterns
- Common Pitfalls and How to Avoid Them
- FAQ
How Arduino Handles Concurrency (or Doesn’t)
The Arduino Uno, Nano, and Mega all use single-core AVR microcontrollers. The standard Arduino programming model is strictly sequential:
void loop() {
readSensor(); // Runs first
updateDisplay(); // Runs second
checkNetwork(); // Runs third
// ... then back to top
}
Each function must complete before the next one starts. This creates a fundamental problem: if checkNetwork() blocks for 500 ms waiting for an HTTP response, your display does not update and your sensor is not read for that entire half-second.
The traditional Arduino workaround is the cooperative multitasking pattern using millis() timers:
unsigned long lastSensor = 0;
unsigned long lastDisplay = 0;
void loop() {
unsigned long now = millis();
if (now - lastSensor >= 100) { // Every 100ms
readSensor();
lastSensor = now;
}
if (now - lastDisplay >= 500) { // Every 500ms
updateDisplay();
lastDisplay = now;
}
}
This works as long as no individual function blocks for long — it is essentially time-slicing by hand. Libraries like TaskScheduler formalise this pattern, but the fundamental limitation remains: if any function blocks, everything stops.
Interrupts provide a partial solution. The Arduino’s hardware interrupts allow a specific function (ISR — Interrupt Service Routine) to run immediately when a pin changes state, regardless of what the main loop is doing. However, ISRs must be extremely short (microseconds), cannot use most Arduino functions, and cannot block. They are useful for capturing time-critical signals like encoder pulses, but not for running complex tasks.
ESP32 Dual-Core Architecture
The ESP32 (WROOM, WROVER, and C3 variants) contains a dual-core Xtensa LX6 processor running at up to 240 MHz. The two cores are:
- Core 0 (Protocol CPU / PRO_CPU): By default, the Arduino framework runs Wi-Fi, Bluetooth, and system tasks on this core. If you are using Wi-Fi in a sketch, the radio stack occupies Core 0.
- Core 1 (Application CPU / APP_CPU): This is where your Arduino
setup()andloop()run by default. All your application code runs here unless you explicitly create tasks on Core 0.
Both cores share the same memory space (RAM and Flash), the same peripherals (SPI, I2C, UART, etc.), and run FreeRTOS — a real-time operating system that provides pre-emptive multitasking, inter-task communication, and synchronisation primitives.
This means two tasks literally execute simultaneously on separate silicon, not just taking turns rapidly like cooperative multitasking. True parallelism — one core can read a sensor at the exact same moment the other is transmitting a Wi-Fi packet.
FreeRTOS Basics: Tasks, Priorities, and Scheduling
FreeRTOS is already running inside every ESP32 Arduino sketch. When you write setup() and loop(), the Arduino framework wraps your code in a FreeRTOS task. You are already using FreeRTOS without knowing it.
Key FreeRTOS concepts:
Tasks: Independent units of execution, each with their own stack. Unlike functions, tasks run concurrently. Each task must either be deleted when done or run in an infinite loop — they must never return.
Priority: FreeRTOS is pre-emptive. Higher-priority tasks interrupt lower-priority ones immediately when they become ready to run. Priority 0 is the idle task (runs when nothing else needs the CPU). The Arduino loop() task runs at priority 1 by default.
Scheduler: FreeRTOS uses a tick interrupt (default 1 ms) to switch between tasks of equal priority. Higher-priority tasks run as soon as they are ready, without waiting for the tick.
Blocking: Tasks should block when waiting — using vTaskDelay() or waiting on a queue/semaphore. A blocked task yields the CPU to other tasks, which is far more efficient than a busy loop.
// Replace delay() with vTaskDelay() in tasks:
vTaskDelay(pdMS_TO_TICKS(1000)); // Block for 1000ms, yield to other tasks
// vs:
delay(1000); // Also calls vTaskDelay internally in ESP32 Arduino
Creating and Pinning Tasks to Cores
The key function is xTaskCreatePinnedToCore(). This creates a FreeRTOS task and pins it to a specific CPU core:
void Task1_Function(void *pvParameters) {
// Task code here
while (1) {
Serial.println("Task 1 on Core 0");
vTaskDelay(pdMS_TO_TICKS(1000));
}
}
void Task2_Function(void *pvParameters) {
while (1) {
Serial.println("Task 2 on Core 1");
vTaskDelay(pdMS_TO_TICKS(500));
}
}
void setup() {
Serial.begin(115200);
// xTaskCreatePinnedToCore(
// function, // Task function
// name, // Task name (for debugging)
// stackSize, // Stack size in bytes
// parameter, // Parameter to pass (NULL if none)
// priority, // FreeRTOS priority
// taskHandle, // Task handle (NULL if not needed)
// coreID // 0 or 1
// );
xTaskCreatePinnedToCore(
Task1_Function, "Task1", 4096, NULL, 1, NULL, 0
);
xTaskCreatePinnedToCore(
Task2_Function, "Task2", 4096, NULL, 1, NULL, 1
);
}
void loop() {
// Can be empty when using tasks directly
vTaskDelay(pdMS_TO_TICKS(10));
}
Stack size: Each task needs its own stack. 4096 bytes is a safe starting size for most tasks. If a task crashes with a stack overflow, increase the size. Call uxTaskGetStackHighWaterMark(NULL) from inside the task to check remaining stack space.
Core assignment strategy:
- Core 0: Network operations (Wi-Fi callbacks, HTTP requests, MQTT), time-critical ISR-adjacent tasks
- Core 1: User interface updates, sensor reading, business logic, Serial communication
Inter-Task Communication: Queues and Semaphores
When tasks need to share data, you must use FreeRTOS synchronisation primitives. Never access a shared variable from two tasks without protection — this creates race conditions that cause unpredictable behaviour, data corruption, and crashes that are extremely difficult to debug.
Queues — the safest way to pass data between tasks:
QueueHandle_t sensorQueue;
void SensorTask(void *pvParam) {
float temperature;
while (1) {
temperature = readSensor(); // Your sensor read function
xQueueSend(sensorQueue, &temperature, pdMS_TO_TICKS(10));
vTaskDelay(pdMS_TO_TICKS(1000));
}
}
void DisplayTask(void *pvParam) {
float temperature;
while (1) {
if (xQueueReceive(sensorQueue, &temperature, pdMS_TO_TICKS(1100))) {
// Got a new reading, update display
Serial.printf("Temperature: %.2f°Cn", temperature);
}
}
}
void setup() {
Serial.begin(115200);
sensorQueue = xQueueCreate(5, sizeof(float)); // Queue of 5 floats
xTaskCreatePinnedToCore(SensorTask, "Sensor", 4096, NULL, 2, NULL, 0);
xTaskCreatePinnedToCore(DisplayTask, "Display", 4096, NULL, 1, NULL, 1);
}
Queues are thread-safe by design. The sender blocks if the queue is full, and the receiver blocks if the queue is empty — no explicit locking required.
Mutex (Mutual Exclusion Semaphore) — protect shared resources:
SemaphoreHandle_t spiMutex;
void setup() {
spiMutex = xSemaphoreCreateMutex();
}
void TaskA(void *pvParam) {
while (1) {
if (xSemaphoreTake(spiMutex, pdMS_TO_TICKS(100))) {
// Access SPI bus safely
spiWrite(data);
xSemaphoreGive(spiMutex);
}
vTaskDelay(pdMS_TO_TICKS(10));
}
}
Managing Shared Resources Safely
The SPI bus is a particularly common source of corruption in multi-task ESP32 projects. When two tasks try to talk to different SPI devices simultaneously, their transactions interleave and both fail.
Always use a mutex around the entire SPI transaction — including CS pin control:
void safeSPIWrite(SemaphoreHandle_t mutex, uint8_t cs_pin, uint8_t *data, size_t len) {
if (xSemaphoreTake(mutex, pdMS_TO_TICKS(50)) == pdTRUE) {
digitalWrite(cs_pin, LOW);
SPI.transfer(data, len);
digitalWrite(cs_pin, HIGH);
xSemaphoreGive(mutex);
}
}
Similarly, the Serial object should be protected with a mutex when multiple tasks print debug output, otherwise you will see garbled mixed messages in the serial monitor.
For simple variables like a single integer or boolean that only needs to be updated atomically, use portENTER_CRITICAL() and portEXIT_CRITICAL() for extremely brief critical sections (a few instructions maximum):
portMUX_TYPE mux = portMUX_INITIALIZER_UNLOCKED;
volatile int sharedCounter = 0;
portENTER_CRITICAL(&mux);
sharedCounter++; // Atomic increment
portEXIT_CRITICAL(&mux);
Real-World Concurrency Patterns
IoT sensor node with Wi-Fi upload:
- Core 0: Wi-Fi connection management + HTTP POST task (blocks on network I/O, wakes when data is available in queue)
- Core 1: Sensor reading every 30 seconds + push data to queue + update local OLED display
Robot controller:
- Core 0: Motor control PID loop at 200 Hz (time-critical, isolated)
- Core 1: Obstacle detection, path planning, Bluetooth command reception
Audio player:
- Core 0: I2S audio streaming (fills DMA buffer continuously)
- Core 1: SD card decoding, UI updates, button handling
Common Pitfalls and How to Avoid Them
Stack overflow crashes:
- Symptom: ESP32 reboots repeatedly with “Guru Meditation Error: Core X panic’ed (Unhandled debug exception)” and mention of stack
- Fix: Increase the stack size in
xTaskCreatePinnedToCore(). Start at 8192 if 4096 crashes. UseuxTaskGetStackHighWaterMark()to find the minimum safe size
Watchdog timer resets:
- Symptom: Board resets with “Task watchdog got triggered” error
- Cause: A task is running a tight loop without calling
vTaskDelay(), starving the watchdog from being fed - Fix: Add
vTaskDelay(pdMS_TO_TICKS(1))at minimum in any infinite loop, or addtaskYIELD()
Race conditions on Serial output:
- Symptom: Serial monitor shows jumbled text like “HeTaelslok 1 Task 2” mixed together
- Fix: Create a Serial mutex and acquire it before any
Serial.print()call. Or use a single dedicated serial logging task with a queue
Wi-Fi task conflicts on Core 0:
- Avoid creating user tasks on Core 0 when using Wi-Fi or Bluetooth. The radio stack needs Core 0 for time-critical operations
- If you must use Core 0, set your task priority below the Wi-Fi task priority (usually 19-23)
Frequently Asked Questions
Can I use FreeRTOS on a standard Arduino Uno or Mega?
Yes, the Arduino FreeRTOS library (by feilipu, available in the Library Manager) ports FreeRTOS to AVR boards. However, since the Uno is single-core, FreeRTOS provides co-operative multitasking — not true parallelism. Tasks still take turns on the single core. The benefit is cleaner code structure with proper blocking waits instead of millis() gymnastics. For true dual-core parallelism, the ESP32 is the right platform.
What is the maximum number of tasks I can run on ESP32?
FreeRTOS on ESP32 supports up to 32 priorities and theoretically hundreds of tasks, limited only by available RAM. The ESP32 WROOM has 320 KB of internal SRAM (plus up to 8 MB PSRAM on WROVER). Each task stack uses RAM — a task with a 4096-byte stack uses 4 KB. With 200 KB usable for tasks (after system overhead), you could have 50 tasks at 4 KB each. In practice, 5-15 tasks is typical for complex applications.
Should I pin all my tasks to Core 1 to avoid Wi-Fi conflicts?
For most projects, yes — pin all application tasks to Core 1 and let the Wi-Fi stack run on Core 0 without interference. The exception is if you have a time-critical task that needs more CPU than Core 1 alone can provide. In that case, carefully pin it to Core 0 at a priority lower than the Wi-Fi tasks (below priority 19) and thoroughly test for Wi-Fi stability.
What happens if a FreeRTOS task crashes?
If a task triggers a hardware exception (invalid memory access, stack overflow), FreeRTOS panics and the ESP32 resets. This is by design for safety. To handle recoverable errors, use xTaskCreate() return values, check for NULL handles, and implement watchdog-based task restart logic using task notifications. The ESP32 also has a software watchdog per task that can be individually disabled for trusted tasks.
How is ESP32 dual-core programming different from Arduino’s millis() approach?
The millis() cooperative approach gives the illusion of multitasking but is still sequential — one function runs at a time. If one function blocks for any reason, all others wait. ESP32 FreeRTOS with dual cores provides true concurrency: two tasks genuinely execute simultaneously on separate hardware cores. This means real-time tasks (motor control, audio streaming) are never delayed by lower-priority work, even if the lower-priority task is blocked waiting for network I/O.
Understanding concurrency — from Arduino’s cooperative millis() timers to ESP32’s true dual-core FreeRTOS tasks — is a transformative skill that unlocks a new class of embedded projects. Complex IoT devices, robots, and audio systems all rely on these patterns to juggle multiple simultaneous demands without dropping the ball on any of them.
Explore our full range of Arduino and ESP32 development boards at Zbotic.in — from beginner-friendly Uno kits to the Nano RP2040 Connect for advanced multi-core projects, all shipped across India.
Add comment