freertos-multicore-esp32-dual-core

FreeRTOS multicore en ESP32

  • 7 min

El multicore en ESP32 es la capacidad de ejecutar tareas de FreeRTOS en varios núcleos físicos, con afinidad de tareas y memoria compartida.

Llegamos a uno de los puntos más interesantes y diferenciadores de este curso. Si vienes de Arduino Uno (ATmega328) o STM32 (Cortex-M), estás acostumbrado a tener un solo núcleo.

En un sistema Single Core, la multitarea es una alternancia muy rápida. Como hemos visto, el Scheduler cambia entre tareas, pero en realidad no hay dos instrucciones ejecutándose a la vez.

El ESP32 clásico tiene dos núcleos Xtensa LX6 que comparten la memoria RAM, así que permite paralelismo real. El ESP32-S3 también tiene dos, pero modelos como ESP32-S2, C3, C6 y H2 solo tienen uno.

Hoy vamos a aprender a domar esta potencia, decidir qué tarea va a qué núcleo y evitar los desastres que ocurren cuando dos cerebros intentan escribir en el mismo cuaderno a la vez.

Arquitectura: PRO_CPU y APP_CPU

En la nomenclatura de Espressif, los dos núcleos no son totalmente simétricos en cuanto a su uso recomendado (aunque técnicamente son casi idénticos).

  1. Core 0 (PRO_CPU): Ejecuta varias tareas internas. El driver WiFi suele estar fijado a este núcleo.
  2. Core 1 (APP_CPU): Es el núcleo habitual para el código de aplicación en la configuración Arduino del ESP32 clásico.

En la configuración habitual del ESP32 clásico con Arduino, setup() y loop() se ejecutan dentro de loopTask en el Core 1. El valor es configurable y cambia en chips de un solo núcleo.

FreeRTOS en ESP32 está modificado para ser SMP (Symmetric Multi-Processing). Esto significa que el Scheduler corre en ambos núcleos y puede gestionar tareas en ambos lados.

Afinidad de tareas

Cuando creamos una tarea, tenemos que decidir dónde va a vivir. Para esto usamos la función que ya conocemos: xTaskCreatePinnedToCore.

El último parámetro (xCoreID) es el que define la Afinidad:

Tarea anclada a un núcleo (0 o 1)

Forzamos a la tarea a ejecutarse siempre en ese núcleo.

  • Útil cuando una tarea depende de recursos ligados a un núcleo o queremos controlar la interferencia y la migración.
  • Útil para separar responsabilidades: “Todo lo que sea sensores al Core 1, todo lo que sea cálculo matemático pesado al Core 0”.

Tarea sin afinidad (tskNO_AFFINITY)

Le decimos al Scheduler: “Pon esta tarea donde haya sitio”.

  • El Scheduler puede ejecutar la tarea en el Core 0 en un momento dado, pausarla, y reanudarla en el Core 1.
  • Ventaja: Da al Scheduler más opciones para usar un núcleo disponible.
  • Desventaja: Puede ser ligeramente menos eficiente debido a los fallos de caché (al saltar de núcleo, los datos no están en la caché del otro núcleo).
// Crear tarea en el Core 0 (El del WiFi)
xTaskCreatePinnedToCore(Tarea, "Nombre", 2048, NULL, 1, &handle, 0);

// Crear tarea en el Core 1 (El de Arduino)
xTaskCreatePinnedToCore(Tarea, "Nombre", 2048, NULL, 1, &handle, 1);

// Dejar que el Scheduler elija (Balanceo automático)
xTaskCreatePinnedToCore(Tarea, "Nombre", 2048, NULL, 1, &handle, tskNO_AFFINITY);
Copied!

El reto de la concurrencia real

Incluso en un solo núcleo, contador++ no es una operación atómica y otra tarea puede intercalarse si el Scheduler se apropia de la CPU. Con dos núcleos, además, dos accesos pueden ocurrir físicamente a la vez.

En Dual Core, la Tarea A (Core 0) y la Tarea B (Core 1) pueden acceder a la misma variable EXACTAMENTE en el mismo instante.

Esto hace que las Condiciones de Carrera sean mucho más probables y agresivas.

  • Solución: Las herramientas que ya conocemos (Colas, Mutex, Semáforos) son Thread-Safe y Multicore-Safe.
  • FreeRTOS se encarga internamente de gestionar la sincronización entre núcleos cuando usas xQueueSend o xSemaphoreTake.

Idea importante: si vas a comunicar datos entre tareas de distintos núcleos, usa colas, mutex u otro mecanismo de sincronización. No uses variables globales volatile sin protección, porque fallarán tarde o temprano.

Secciones críticas y spinlocks

Mencionamos en el artículo anterior portENTER_CRITICAL. En el ESP32, esta función hace algo más que deshabilitar interrupciones.

Piensa que el Core 0 puede enmascarar sus interrupciones para tocar una variable mientras el Core 1 sigue ejecutándose y accede a ella. Para evitar esto, el ESP32 usa un Spinlock.

Cuando el Core 0 entra en crítica:

  1. Deshabilita sus interrupciones.
  2. Adquiere atómicamente un spinlock compartido.
  3. Si el Core 1 intenta adquirir el mismo spinlock, se queda esperando activamente hasta que el Core 0 lo libere.

Esto reduce el rendimiento. Por eso, las secciones críticas deben ser extremadamente cortas.

Ejemplo práctico: repartir trabajo entre núcleos

En este ejemplo, el Core 1 ejecuta el cálculo pesado y el Core 0 mantiene una tarea de interfaz ligera. Las tareas de sistema del Core 0 tienen prioridades superiores y pueden apropiarse de la CPU cuando lo necesiten.

#include <Arduino.h>

QueueHandle_t colaResultados;

// --- Tarea Pesada (Worker) ---
// Se ejecutará en el Core 1
void TareaMatematica(void *pvParameters) {
    Serial.print("Tarea Matemática arrancando en Core: ");
    Serial.println(xPortGetCoreID());

    int numero = 0;
    
    for(;;) {
        // Simulamos un cálculo muy costoso bloqueando la CPU
        // Calculamos la raíz cuadrada de millones de números
        volatile float resultado = 0; // volatile evita que el compilador elimine la carga simulada.
        long tiempoInicio = millis();
        
        for(int i=0; i<1000000; i++) {
            resultado += sqrtf((float)i * numero);
        }
        long duracion = millis() - tiempoInicio;
        
        // Enviamos la duración al otro núcleo mediante la cola.
        xQueueSend(colaResultados, &duracion, portMAX_DELAY);
        
        numero++;
        vTaskDelay(pdMS_TO_TICKS(10));
    }
}

// --- Tarea UI ---
// Se ejecutará en el Core 0
void TareaUI(void *pvParameters) {
    Serial.print("Tarea UI arrancando en Core: ");
    Serial.println(xPortGetCoreID());

    long ultimoDato = 0;
    
    for(;;) {
        // 1. Parpadeo fluido (Heartbeat)
        digitalWrite(LED_BUILTIN, HIGH);
        vTaskDelay(pdMS_TO_TICKS(100));
        digitalWrite(LED_BUILTIN, LOW);
        vTaskDelay(pdMS_TO_TICKS(100));
        
        // 2. Revisar si el Core 0 nos ha mandado algo
        if(xQueueReceive(colaResultados, &ultimoDato, 0) == pdTRUE) {
            Serial.printf("[Core 0] Recibido resultado del Core 1: %ld ms\n", ultimoDato);
        }
    }
}

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

    if (colaResultados == NULL) {
        Serial.println("No se pudo crear la cola");
        return;
    }

    // Crear tarea pesada en CORE 1
    xTaskCreatePinnedToCore(
        TareaMatematica, "Math", 10000, NULL, 1, NULL, 1
    );

    // Crear tarea UI en CORE 0
    xTaskCreatePinnedToCore(
        TareaUI, "UI", 2048, NULL, 1, NULL, 0
    );
}

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

Análisis del Resultado

Si ejecutas este código, verás que ambas tareas progresan en paralelo:

  • El LED mantiene su ritmo mientras el otro núcleo realiza el cálculo.
  • Por el puerto serie aparecen mensajes de cálculos que tardan cientos de milisegundos.

Si hiciéramos esto en un Arduino Uno o en un solo núcleo sin RTOS, el LED se congelaría cada vez que el cálculo matemático estuviera ejecutándose. Aquí, el Core 0 suda la gota gorda calculando raíces cuadradas, mientras el Core 1 está fresco como una lechuga atendiendo al LED.

Consideraciones sobre el Core 0

Aunque es tentador usar el Core 0 para todo lo pesado, recuerda que el driver WiFi suele ejecutarse ahí.

  • Si una tarea de aplicación acapara el Core 0 sin bloquearse, puede retrasar servicios de red o activar el watchdog.
  • Diseña cualquier tarea de aplicación del Core 0 para que se bloquee con frecuencia y usa una prioridad coherente con las tareas del sistema.