freertos-multicore-esp32-dual-core

FreeRTOS Multicore on ESP32

  • 7 min

The multicore on ESP32 is the ability to execute FreeRTOS tasks on multiple physical cores, with task affinity and shared memory.

We’ve reached one of the most interesting and differentiating points of this course. If you’re coming from Arduino Uno (ATmega328) or STM32 (Cortex-M), you’re used to having a single core.

In a Single Core system, multitasking is very fast switching. As we’ve seen, the Scheduler switches between tasks, but in reality no two instructions are executing simultaneously.

The classic ESP32 has two Xtensa LX6 cores sharing RAM, so it allows true parallelism. The ESP32-S3 also has two, but models like ESP32-S2, C3, C6, and H2 only have one.

Today we’ll learn to harness this power, decide which task goes to which core, and avoid the disasters that occur when two brains try to write in the same notebook at the same time.

Architecture: PRO_CPU and APP_CPU

In Espressif’s nomenclature, the two cores are not entirely symmetric regarding their recommended use (although technically they are nearly identical).

  1. Core 0 (PRO_CPU): Runs several internal tasks. The WiFi driver is usually pinned to this core.
  2. Core 1 (APP_CPU): This is the usual core for application code in the classic ESP32 Arduino setup.

In the standard configuration of the classic ESP32 with Arduino, setup() and loop() run inside loopTask on Core 1. This value is configurable and changes on single-core chips.

FreeRTOS on ESP32 is modified to be SMP (Symmetric Multi-Processing). This means the Scheduler runs on both cores and can manage tasks on both sides.

Task Affinity

When creating a task, we need to decide where it will live. For this, we use the function we already know: xTaskCreatePinnedToCore.

The last parameter (xCoreID) defines the Affinity:

Task pinned to a core (0 or 1)

We force the task to always run on that core.

  • Useful when a task depends on resources tied to a core or we want to control interference and migration.
  • Useful for separating responsibilities: “All sensor tasks go to Core 1, all heavy mathematical calculations go to Core 0”.

Task without affinity (tskNO_AFFINITY)

We tell the Scheduler: “Put this task wherever there’s room”.

  • The Scheduler can run the task on Core 0 at a given moment, pause it, and resume it on Core 1.
  • Advantage: Gives the Scheduler more options to use an available core.
  • Disadvantage: Can be slightly less efficient due to cache misses (when jumping cores, data isn’t in the other core’s cache).
// Create task on Core 0 (WiFi core)
xTaskCreatePinnedToCore(Task, "Name", 2048, NULL, 1, &handle, 0);

// Create task on Core 1 (Arduino core)
xTaskCreatePinnedToCore(Task, "Name", 2048, NULL, 1, &handle, 1);

// Let the Scheduler choose (Auto-balancing)
xTaskCreatePinnedToCore(Task, "Name", 2048, NULL, 1, &handle, tskNO_AFFINITY);
Copied!

The Challenge of True Concurrency

Even on a single core, counter++ is not an atomic operation and another task can interleave if the Scheduler preempts the CPU. With two cores, two accesses can physically occur at the same time.

In Dual Core, Task A (Core 0) and Task B (Core 1) can access the same variable EXACTLY at the same instant.

This makes Race Conditions much more likely and aggressive.

  • Solution: The tools we already know (Queues, Mutexes, Semaphores) are Thread-Safe and Multicore-Safe.
  • FreeRTOS internally handles inter-core synchronization when you use xQueueSend or xSemaphoreTake.

Important idea: if you are going to communicate data between tasks from different cores, use queues, mutexes, or another synchronization mechanism. Do not use unprotected global volatile variables, as they will fail sooner or later.

Critical Sections and Spinlocks

We mentioned portENTER_CRITICAL in the previous article. On the ESP32, this function does more than just disable interrupts.

Consider that Core 0 can mask its interrupts to touch a variable while Core 1 continues executing and accesses it. To prevent this, the ESP32 uses a Spinlock.

When Core 0 enters a critical section:

  1. It disables its interrupts.
  2. It atomically acquires a shared spinlock.
  3. If Core 1 tries to acquire the same spinlock, it waits actively until Core 0 releases it.

This reduces performance. Therefore, critical sections must be extremely short.

Practical Example: Distributing Work Between Cores

In this example, Core 1 executes the heavy calculation and Core 0 runs a lightweight interface task. System tasks on Core 0 have higher priorities and can preempt the CPU when needed.

#include <Arduino.h>

QueueHandle_t resultQueue;

// --- Heavy Task (Worker) ---
// Will run on Core 1
void MathTask(void *pvParameters) {
    Serial.print("Math Task starting on Core: ");
    Serial.println(xPortGetCoreID());

    int number = 0;
    
    for(;;) {
        // Simulate a very expensive calculation by blocking the CPU
        // Calculating the square root of millions of numbers
        volatile float result = 0; // volatile prevents the compiler from eliminating the simulated load.
        long startTime = millis();
        
        for(int i=0; i<1000000; i++) {
            result += sqrtf((float)i * number);
        }
        long duration = millis() - startTime;
        
        // Send the duration to the other core via the queue.
        xQueueSend(resultQueue, &duration, portMAX_DELAY);
        
        number++;
        vTaskDelay(pdMS_TO_TICKS(10));
    }
}

// --- UI Task ---
// Will run on Core 0
void UITask(void *pvParameters) {
    Serial.print("UI Task starting on Core: ");
    Serial.println(xPortGetCoreID());

    long lastData = 0;
    
    for(;;) {
        // 1. Smooth blinking (Heartbeat)
        digitalWrite(LED_BUILTIN, HIGH);
        vTaskDelay(pdMS_TO_TICKS(100));
        digitalWrite(LED_BUILTIN, LOW);
        vTaskDelay(pdMS_TO_TICKS(100));
        
        // 2. Check if Core 0 sent us something
        if(xQueueReceive(resultQueue, &lastData, 0) == pdTRUE) {
            Serial.printf("[Core 0] Received result from Core 1: %ld ms\n", lastData);
        }
    }
}

void setup() {
    Serial.begin(115200);
    pinMode(LED_BUILTIN, OUTPUT);
    
    resultQueue = xQueueCreate(10, sizeof(long));

    if (resultQueue == NULL) {
        Serial.println("Could not create queue");
        return;
    }

    // Create heavy task on CORE 1
    xTaskCreatePinnedToCore(
        MathTask, "Math", 10000, NULL, 1, NULL, 1
    );

    // Create UI task on CORE 0
    xTaskCreatePinnedToCore(
        UITask, "UI", 2048, NULL, 1, NULL, 0
    );
}

void loop() {
    delay(1000);
}
Copied!

Result Analysis

If you run this code, you’ll see both tasks progressing in parallel:

  • The LED maintains its rhythm while the other core performs the calculation.
  • Messages about calculations taking hundreds of milliseconds appear on the serial port.

If we did this on an Arduino Uno or a single core without an RTOS, the LED would freeze every time the mathematical calculation was running. Here, Core 0 sweats bullets calculating square roots, while Core 1 is fresh as a daisy handling the LED.

Considerations Regarding Core 0

Although it’s tempting to use Core 0 for all the heavy lifting, remember that the WiFi driver usually runs there.

  • If an application task hogs Core 0 without blocking, it can delay network services or trigger the watchdog.
  • Design any Core 0 application task to block frequently and use a priority consistent with system tasks.