Skip to main content

ESP32 Multitasking: Assigning FreeRTOS Tasks to Specific Cores (Core 0 vs Core 1)

 If you have ever pushed an ESP32 to its limit with heavy sensor aggregation or cryptographic calculations while maintaining a Wi-Fi connection, you have likely encountered the infamous Task Watchdog Got Triggered error or unexplained network disconnects.

The ESP32 is a dual-core system, yet many firmware engineers treat it like an Arduino Uno (single-core), dumping all logic into the main loop or generic FreeRTOS tasks. This results in the "Application" code fighting for CPU cycles with the "Protocol" (Wi-Fi/Bluetooth) stack. When your blocking code wins, the Wi-Fi stack starves, the watchdog bites, and the system resets.

To build industrial-grade firmware, you must explicitly leverage the symmetric multiprocessing (SMP) capabilities of the ESP32 by pinning tasks to specific cores.

The Root Cause: PRO_CPU vs. APP_CPU

The ESP32 architecture consists of two Tensilica Xtensa LX6 microprocessors:

  1. Core 0 (Protocol CPU / PRO_CPU): By default, the ESP-IDF (and Arduino) handles Wi-Fi, Bluetooth, and TCP/IP stack events here.
  2. Core 1 (Application CPU / APP_CPU): In the Arduino context, the setup() and loop() functions run here.

The crash occurs due to resource starvation. The Wi-Fi stack requires frequent CPU time to handle beacons and keep-alive packets. If you create a FreeRTOS task with xTaskCreate (which, depending on configuration, might default to Core 0 or tskNO_AFFINITY) and that task executes a long-running while(true) loop without yielding (vTaskDelay), Core 0 hits 100% utilization. The Wi-Fi background tasks get blocked, and the hardware watchdog timer (WDT) triggers a hard reset to save the system from an unresponsive state.

The Fix: Explicit Core Pinning

To solve this, we must offload heavy computational work to Core 1 (or Core 0, with extreme caution) using xTaskCreatePinnedToCore. This API forces the scheduler to run a specific task only on the designated CPU, ensuring true parallelism rather than just time-sliced concurrency.

Below is a modern C++ implementation demonstrating a thread-safe architecture. We will simulate a heavy blocking task (e.g., FFT analysis or image processing) running on Core 1, leaving Core 0 completely free to handle HTTP requests.

Implementation

We will use std::atomic or FreeRTOS Queues for Inter-Process Communication (IPC) to prevent race conditions, as memory access is now truly simultaneous.

#include <Arduino.h>
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
#include <freertos/queue.h>

// Configuration Constants
constexpr int BAUD_RATE = 115200;
constexpr int QUEUE_LEN = 10;
constexpr int MSG_SIZE  = sizeof(int);

// Task Handles
TaskHandle_t heavyTaskHandle = nullptr;
TaskHandle_t netTaskHandle   = nullptr;

// Queue for Inter-Core Communication
QueueHandle_t dataQueue;

/**
 * @brief Task running on Core 1: Heavy Computation.
 * This simulates a blocking sensor reading or DSP algorithm.
 */
void heavyComputationTask(void *parameter) {
    int counter = 0;

    // A task must contain an infinite loop
    for (;;) {
        // 1. Simulate heavy blocking work (e.g., 500ms processing)
        // In a real scenario, this is where your FFT or Encryption happens.
        unsigned long start = millis();
        while (millis() - start < 500) {
            // Busy wait simulation - effectively blocking this core
            asm("nop"); 
        }

        counter++;

        // 2. Send data to the Network Task safely
        if (xQueueSend(dataQueue, &counter, portMAX_DELAY) != pdPASS) {
            Serial.println("Queue Full: Could not send data.");
        }

        // 3. Yield to IDLE task on Core 1 to prevent WDT on this core
        // Even heavy tasks need to breathe occasionally.
        vTaskDelay(pdMS_TO_TICKS(10)); 
    }
}

/**
 * @brief Task running on Core 0: Network/Protocol Handling.
 * Keeps the loop tight to allow RF stack high priority.
 */
void networkTask(void *parameter) {
    int receivedValue = 0;

    for (;;) {
        // 1. Wait for data from Core 1 (Blocking wait)
        if (xQueueReceive(dataQueue, &receivedValue, portMAX_DELAY) == pdPASS) {
            
            // 2. Simulate Network Transmission
            // Because this is pinned to Core 0, it shares time with Wi-Fi/BT background tasks.
            // Using Serial.print here to visualize, but this represents MQTT/HTTP logic.
            Serial.printf("[Core %d] Sending Data: %d\n", xPortGetCoreID(), receivedValue);
        }

        // 3. Crucial Yield
        // On Core 0, failing to yield often is fatal for Wi-Fi.
        vTaskDelay(pdMS_TO_TICKS(100)); 
    }
}

void setup() {
    Serial.begin(BAUD_RATE);
    
    // Initialize IPC Queue
    dataQueue = xQueueCreate(QUEUE_LEN, MSG_SIZE);
    if (dataQueue == NULL) {
        Serial.println("Error creating the queue");
        return;
    }

    Serial.printf("Main Loop running on Core: %d\n", xPortGetCoreID());

    // ----------------------------------------------------------------
    // CRITICAL SECTION: Task Creation
    // ----------------------------------------------------------------

    // Create Task on Core 1 (APP_CPU) for Heavy Lifting
    xTaskCreatePinnedToCore(
        heavyComputationTask,   // Function to implement the task
        "HeavyCalc",            // Name of the task
        10000,                  // Stack size in words
        NULL,                   // Task input parameter
        1,                      // Priority
        &heavyTaskHandle,       // Task handle
        1                       // Core ID (1 = APP_CPU)
    );

    // Create Task on Core 0 (PRO_CPU) for Network Logic
    // Note: Wi-Fi background tasks also run on Core 0 with high priority.
    // We set our priority to 1 to ensure we don't starve the actual Wi-Fi driver.
    xTaskCreatePinnedToCore(
        networkTask,            // Function to implement the task
        "NetLogic",             // Name of the task
        10000,                  // Stack size
        NULL,                   // Parameter
        1,                      // Priority
        &netTaskHandle,         // Handle
        0                       // Core ID (0 = PRO_CPU)
    );
}

void loop() {
    // The Main Loop can be deleted or used for low-priority monitoring.
    // Since we assigned work explicitly, we can simply suspend this task.
    vTaskDelete(NULL); 
}

The Explanation

1. xTaskCreatePinnedToCore

This is the standard xTaskCreate function with one critical addition: the last parameter (xCoreID).

  • 0: Pins the task to the Protocol Core. Use this for logic that interacts intimately with the network stack, but keep the logic non-blocking.
  • 1: Pins the task to the Application Core. Use this for heavy math, display drivers, or sensor polling loops.
  • tskNO_AFFINITY: Allows the scheduler to run the task on whichever core is free. While flexible, this is dangerous for high-frequency loops as they might drift onto Core 0 and crash the Wi-Fi.

2. Thread Safety (IPC)

When code runs on two physical cores simultaneously, global variables are unsafe. In the example above, we use xQueueCreate and xQueueSend/Receive. This acts as a thread-safe buffer. Core 1 pushes calculation results into the queue, and Core 0 reads them when ready. This decouples the processing speed from the network transmission speed.

3. Priority Management

Notice we set the priority of networkTask to 1. The internal Wi-Fi tasks usually run at a much higher priority (typically 19-23). By keeping our user-land network logic at a lower priority on Core 0, we ensure that if the hardware receives a Wi-Fi beacon, the OS effectively pauses our networkTask, handles the radio event, and then resumes our task.

Conclusion

The ESP32 is not just a faster Arduino; it is a parallel processing system. Treating it as such resolves the majority of "random" disconnects and resets.

By pinning computational loads to Core 1 and leaving Core 0 primarily for the RF stack (and light network coordination), you separate the "thinking" from the "communicating." This separation of concerns is the hallmark of stable embedded firmware.