|
ESP32 Multi-Core Programming: Dual-Core Task Assignment and Shared Memory Synchronization

ESP32 Multi-Core Programming: Dual-Core Task Assignment and Shared Memory Synchronization

One of the biggest highlights of the ESP32 series chips is that they come equipped with a dual-core Xtensa 32-bit LX6 processor — Core 0 and Core 1. This means you can run different code on both cores simultaneously, achieving true parallel processing.

However, multi-core programming isn’t as simple as “split the code in half and run it on each core.” How do you synchronize shared data? How do you rank task priorities? How do you avoid deadlocks? This hands-on guide covers everything from architecture to code to debugging — all in one go.


Hardware List

ItemDescription
ESP32 dev boardDOIT DEVKIT V1, NodeMCU-32S, or other dual-core models (Note: ESP32-S2 and ESP32-C3 are single-core!)
LED × 25mm, any color, for task visualization
330Ω resistor × 2LED current limiting
Breadboard + jumper wiresFor quick test circuit assembly

⚠️ Important reminder: Not all ESP32 models are dual-core! The ESP32-S2, ESP32-C3, ESP32-C6, and ESP32-H2 are all single-core chips. Always confirm the chip model before purchasing a board.


Understanding the ESP32’s Dual-Core Architecture

The two cores in the ESP32 are referred to in Espressif’s official documentation as:

Core NumberAliasTypical Use
Core 0PRO_CPU (Protocol CPU)Handles Wi-Fi, Bluetooth, and other protocol stacks by default
Core 1APP_CPU (Application CPU)The core where user code runs by default

When you upload code using the Arduino IDE, both setup() and loop() run on Core 1 by default. You can verify this with the following code:

void setup() {
  Serial.begin(115200);
  Serial.print("setup() running on core: ");
  Serial.println(xPortGetCoreID());
}

void loop() {
  Serial.print("loop() running on core: ");
  Serial.println(xPortGetCoreID());
  delay(1000);
}

Open the serial monitor (baud rate 115200), and you’ll see all output showing core: 1.


Pinning Tasks to a Specific Core

FreeRTOS provides the xTaskCreatePinnedToCore() function, which creates a task and pins it to a specific core:

xTaskCreatePinnedToCore(
  TaskFunction,      // Task function pointer
  "TaskName",        // Task name (for debugging)
  10000,             // Stack size (bytes)
  NULL,              // Parameters passed to the task
  1,                 // Priority (0 = lowest)
  &taskHandle,       // Task handle
  0                  // Core ID: 0 = Core 0, 1 = Core 1
);

The last parameter, xCoreID, has three valid values:

  • 0 — Pinned to Core 0
  • 1 — Pinned to Core 1
  • tskNO_AFFINITY — Not pinned; the scheduler assigns it freely

Hands-On: Dual-Core LED Blinking

First, wire it up: GPIO 2 to LED1, GPIO 4 to LED2 (each with a 330Ω resistor in series to GND).

TaskHandle_t Task1;
TaskHandle_t Task2;

const int led1 = 2;
const int led2 = 4;

void setup() {
  Serial.begin(115200);
  pinMode(led1, OUTPUT);
  pinMode(led2, OUTPUT);

  // Create Task 1 → Core 0, 1-second blink
  xTaskCreatePinnedToCore(
    Task1code, "Task1", 10000, NULL, 1, &Task1, 0
  );
  delay(500);

  // Create Task 2 → Core 1, 700ms blink
  xTaskCreatePinnedToCore(
    Task2code, "Task2", 10000, NULL, 1, &Task2, 1
  );
  delay(500);
}

void Task1code(void * pvParameters) {
  Serial.print("Task1 running on core: ");
  Serial.println(xPortGetCoreID());

  for(;;) {
    digitalWrite(led1, HIGH);
    delay(1000);
    digitalWrite(led1, LOW);
    delay(1000);
  }
}

void Task2code(void * pvParameters) {
  Serial.print("Task2 running on core: ");
  Serial.println(xPortGetCoreID());

  for(;;) {
    digitalWrite(led2, HIGH);
    delay(700);
    digitalWrite(led2, LOW);
    delay(700);
  }
}

void loop() {
  // The main loop also runs on Core 1
  // Leave it empty or use it for other things as needed
}

After uploading, the two LEDs will blink at different rates — they are running independently on different cores without blocking each other.


Shared Memory: The Real Challenge in Dual-Core Programming

The ESP32 uses an SMP (Symmetric Multiprocessing) architecture, where both cores share the same RAM. This means Core 0 and Core 1 can directly read and write the same global variable. But here’s the problem — if both cores modify the same variable simultaneously, the data will get corrupted. This is called a Race Condition.

// ❌ Dangerous: both cores modify simultaneously, data may be lost
volatile int sensorValue = 0;

// Core 0 task
void readSensor(void *p) {
  for(;;) {
    sensorValue = analogRead(34);  // May be read by Core 1 at the same time
    delay(10);
  }
}

// Core 1 task
void sendData(void *p) {
  for(;;) {
    // sensorValue may be in the middle of being modified when read
    Serial.println(sensorValue);
    delay(100);
  }
}

To share data safely, you must use synchronization mechanisms.


Synchronization Mechanism #1: Mutex

A mutex is like a lock — only the core that holds the lock can read or write the shared data; all other cores must wait in line.

SemaphoreHandle_t xMutex = NULL;
volatile int sensorValue = 0;

void setup() {
  Serial.begin(115200);

  // Create mutex
  xMutex = xSemaphoreCreateMutex();

  xTaskCreatePinnedToCore(readSensor, "ReadSensor", 10000, NULL, 2, NULL, 0);
  xTaskCreatePinnedToCore(sendData, "SendData", 10000, NULL, 1, NULL, 1);
}

// Core 0: Read sensor
void readSensor(void *p) {
  for(;;) {
    int val = analogRead(34);

    // Acquire lock (wait up to 100ms)
    if (xSemaphoreTake(xMutex, pdMS_TO_TICKS(100)) == pdTRUE) {
      sensorValue = val;
      xSemaphoreGive(xMutex);  // Release lock
    }

    delay(10);
  }
}

// Core 1: Send data
void sendData(void *p) {
  for(;;) {
    int val = 0;

    if (xSemaphoreTake(xMutex, pdMS_TO_TICKS(100)) == pdTRUE) {
      val = sensorValue;
      xSemaphoreGive(xMutex);
    }

    Serial.printf("Sensor: %d\n", val);
    delay(100);
  }
}

void loop() {}

Key points:

  • xSemaphoreTake() acquires the lock, xSemaphoreGive() releases it
  • Use pdMS_TO_TICKS(milliseconds) to set the wait timeout
  • You must call Give after acquiring the lock, or other tasks will be permanently blocked

Synchronization Mechanism #2: Message Queue

If you need to pass data between two cores rather than simply sharing a variable, a FreeRTOS message queue is the safest choice. Queues are inherently thread-safe and don’t require additional locks.

QueueHandle_t sensorQueue = NULL;

void setup() {
  Serial.begin(115200);

  // Create queue: up to 10 elements, each of type int
  sensorQueue = xQueueCreate(10, sizeof(int));

  xTaskCreatePinnedToCore(readSensor, "ReadSensor", 10000, NULL, 2, NULL, 0);
  xTaskCreatePinnedToCore(processData, "ProcessData", 10000, NULL, 1, NULL, 1);
}

// Core 0: Read sensor → send to queue
void readSensor(void *p) {
  for(;;) {
    int val = analogRead(34);

    // Send to queue (wait up to 100ms)
    if (xQueueSend(sensorQueue, &val, pdMS_TO_TICKS(100)) != pdTRUE) {
      Serial.println("Queue full!");
    }

    delay(50);
  }
}

// Core 1: Read from queue → process data
void processData(void *p) {
  int val;

  for(;;) {
    // Receive from queue (wait up to 1 second)
    if (xQueueReceive(sensorQueue, &val, pdMS_TO_TICKS(1000)) == pdTRUE) {
      // Process data
      float voltage = val * (3.3 / 4095.0);
      Serial.printf("Voltage: %.3f V\n", voltage);
    }
  }
}

void loop() {}

The advantages of queues are clear: no manual locking and unlocking required, data delivery is buffered, and you can set timeouts to avoid deadlocks.


Synchronization Mechanism #3: Semaphore

Semaphores are ideal for task synchronization — for example, having Core 1 wait until Core 0 finishes a certain operation before executing.

SemaphoreHandle_t xDataReady = NULL;
volatile int processedData = 0;

void setup() {
  Serial.begin(115200);

  // Create binary semaphore
  xDataReady = xSemaphoreCreateBinary();

  xTaskCreatePinnedToCore(core0Producer, "Producer", 10000, NULL, 2, NULL, 0);
  xTaskCreatePinnedToCore(core1Consumer, "Consumer", 10000, NULL, 1, NULL, 1);
}

// Core 0: Produce data → release semaphore
void core0Producer(void *p) {
  for(;;) {
    int val = analogRead(34);
    processedData = val;

    // Notify Core 1 that data is ready
    xSemaphoreGive(xDataReady);

    delay(200);
  }
}

// Core 1: Wait for semaphore → consume data
void core1Consumer(void *p) {
  for(;;) {
    // Block until Core 0 releases the semaphore
    if (xSemaphoreTake(xDataReady, portMAX_DELAY) == pdTRUE) {
      Serial.printf("Got data: %d\n", processedData);
    }
  }
}

void loop() {}

A Binary Semaphore has only two states — “available” and “not available” — making it suitable for simple notification scenarios. If you need counting (e.g., the producer has produced N items consecutively), use a counting semaphore with xSemaphoreCreateCounting(max, initial).


Hands-On Project: Dual-Core Temperature and Humidity Monitoring System

Below is a complete project example: Core 0 reads data from an SHT30 sensor, and Core 1 formats the data as JSON and outputs it via serial. The two cores communicate through a queue.

#include <Wire.h>
#include <ArduinoJson.h>

// SHT30 I2C address
#define SHT30_ADDR 0x44

QueueHandle_t dataQueue;
const int QUEUE_SIZE = 5;

struct SensorData {
  float temperature;
  float humidity;
  uint32_t timestamp;
};

void setup() {
  Serial.begin(115200);
  Wire.begin();

  // Create queue
  dataQueue = xQueueCreate(QUEUE_SIZE, sizeof(SensorData));

  // Initialize SHT30
  Wire.beginTransmission(SHT30_ADDR);
  Wire.write(0x2C);  // Periodic measurement mode
  Wire.write(0x06);  // Medium repeatability, 0.5Hz
  Wire.endTransmission();

  // Core 0: Read sensor
  xTaskCreatePinnedToCore(
    readSHT30, "ReadSHT30", 10000, NULL, 2, NULL, 0
  );

  // Core 1: Output JSON
  xTaskCreatePinnedToCore(
    outputJSON, "OutputJSON", 10000, NULL, 1, NULL, 1
  );
}

void readSHT30(void *p) {
  uint8_t buf[6];
  SensorData data;

  for(;;) {
    Wire.requestFrom(SHT30_ADDR, 6);
    if (Wire.available() == 6) {
      for (int i = 0; i < 6; i++) {
        buf[i] = Wire.read();
      }

      // Calculate temperature
      int rawTemp = (buf[0] << 8) | buf[1];
      data.temperature = -45.0 + 175.0 * rawTemp / 65535.0;

      // Calculate humidity
      int rawHum = (buf[3] << 8) | buf[4];
      data.humidity = 100.0 * rawHum / 65535.0;

      data.timestamp = millis();

      // Send to queue
      if (xQueueSend(dataQueue, &data, pdMS_TO_TICKS(500)) != pdTRUE) {
        Serial.println("[Core0] Queue full, data dropped");
      }
    }

    delay(2000);  // 0.5Hz sampling
  }
}

void outputJSON(void *p) {
  SensorData data;

  for(;;) {
    if (xQueueReceive(dataQueue, &data, pdMS_TO_TICKS(5000)) == pdTRUE) {
      // Build JSON
      StaticJsonDocument<200> doc;
      doc["temp"] = data.temperature;
      doc["humidity"] = data.humidity;
      doc["ts"] = data.timestamp;

      char json[128];
      serializeJson(doc, json);

      Serial.printf("[Core1] %s\n", json);
    }
  }
}

void loop() {}

This project has a clean architecture:

  • Core 0 is dedicated to sensor reading, unaffected by serial output
  • Core 1 is dedicated to data processing and formatting
  • The queue serves as a safe cross-core communication channel, buffering up to 5 data entries

Common Troubleshooting

1. Task Creation Failure / System Restart

Symptom: The ESP32 restarts or triggers a Guru Meditation Error after calling xTaskCreatePinnedToCore().

Cause: Insufficient stack space. FreeRTOS task stacks are relatively small by default. If your task involves heavy stack consumers like Serial.printf or ArduinoJson, 10000 bytes may not be enough.

Solution: Increase the stack size to 20000 or higher, and use uxTaskGetStackHighWaterMark() to check actual usage:

// Periodically check stack margin inside a task
UBaseType_t highWater = uxTaskGetStackHighWaterMark(NULL);
Serial.printf("Stack remaining: %u bytes\n", highWater);

2. Mutex Deadlock

Symptom: The system freezes with no serial output.

Cause: Task A acquires the lock and waits for a signal from Task B; Task B also tries to acquire the same lock — both wait for each other, and neither can proceed.

Solution:

  • Use timeouts: xSemaphoreTake(mutex, pdMS_TO_TICKS(100)) instead of waiting forever
  • Keep lock acquisition order consistent (all tasks acquire multiple locks in the same order)
  • Avoid calling potentially blocking functions (like delay() or serial output) while holding a lock

3. Wi-Fi/Bluetooth Interference on Core 0

Symptom: Wi-Fi connection becomes unstable after pinning a task to Core 0.

Cause: Core 0 runs the Wi-Fi/Bluetooth protocol stack by default. If your task consumes too much CPU time, the protocol stack gets squeezed out.

Solution:

  • Place user tasks on Core 1
  • Set lower priority (0–1) for tasks on Core 0
  • Use vTaskDelay() to voluntarily yield CPU time slices

4. Shared Variable Missing volatile

Symptom: One core modifies a variable, but the other core still reads the old value.

Cause: The compiler optimization may cache the variable in a register.

Solution: Global variables shared across cores must use the volatile keyword:

volatile int sharedCounter = 0;  // ✅ Correct
int sharedCounter = 0;           // ❌ May be optimized away

Core Selection Guidelines

ScenarioRecommended CoreReason
Sensor readingCore 0Close to peripherals; doesn’t affect user logic
Data processing / algorithmsCore 1More available CPU resources
Wi-Fi/Bluetooth communicationCore 0 (let the system handle it)Protocol stack runs on Core 0 by default
UI refresh / displayCore 1Doesn’t interfere with the network protocol stack
Background loggingCore 1, low priorityDoesn’t affect main logic

Golden rule: Reserve Core 0 for system and peripheral tasks; run your business logic on Core 1. If Core 0 has spare compute capacity, then assign lightweight tasks to it.


Summary

Key takeaways for ESP32 dual-core programming:

  1. Use xTaskCreatePinnedToCore() to assign cores — the last parameter determines which CPU the task runs on
  2. Shared data must be synchronized — use mutexes to protect variables, message queues to pass data, and semaphores to synchronize tasks
  3. Allocate enough stack space — increase it if needed, and monitor with uxTaskGetStackHighWaterMark()
  4. Core 0 runs the system protocol stack — don’t run high-priority, heavy-load tasks on it
  5. Don’t forget volatile — it’s required for cross-core shared variables

Master these, and your ESP32 projects can upgrade from single-core “single-threaded” operation to true dual-core parallel processing, with noticeable improvements in performance and responsiveness. Give it a try!