The Short Answer: Core 0 for Radio, Core 1 for Logic

To leverage both cores of the ESP32 in the Arduino IDE, you must bypass the standard setup() and loop() paradigm and use FreeRTOS task pinning via xTaskCreatePinnedToCore(). The ESP32-WROOM-32 features two Xtensa LX6 cores: Core 0 (Protocol CPU) handles the WiFi and Bluetooth stacks, while Core 1 (Application CPU) runs your Arduino loop().

The most stable architecture assigns high-speed, timing-critical sensor polling to Core 1, and network-bound or background logging tasks to Core 0. However, sharing hardware buses like I2C across both cores requires a FreeRTOS Mutex to prevent state-machine corruption. Below is the exact blueprint, code, and debugging framework to implement this without triggering the dreaded Task Watchdog Timer (TWDT) panics.

Parts List and Pin Mapping

This build uses a standard dual-core environment logging environmental data locally while transmitting over WiFi. We are using the ubiquitous 30-pin DevKit V1, but the GPIO assignments apply to any ESP32-WROOM-32 variant.

Project Spec Sheet
Difficulty: Intermediate (Requires FreeRTOS concepts)
Time to Build: 45 minutes
Target Board: ESP32-WROOM-32 DevKit V1 (NodeMCU-32S or equivalent)
Core Framework: Arduino ESP32 Core v2.0.14 or v3.0.x
Component Exact Variant / Model ESP32 GPIO Notes
Microcontroller ESP32-WROOM-32 DevKit V1 (30-pin) N/A Ensure 4MB flash minimum
Sensor BME280 I2C (Adafruit or generic 3.3V) SDA: 21, SCL: 22 Do not use 5V modules without logic level shifters
Display SSD1306 128x64 I2C OLED SDA: 21, SCL: 22 Shares I2C bus; requires Mutex in code
Power 5V 2A USB Micro-B / Type-C VIN / 5V WiFi brownouts occur below 500mA

The Complete Dual-Core Arduino Code

The critical E-E-A-T detail most tutorials miss: the Arduino Wire library is not thread-safe across ESP32 cores. If Core 0 and Core 1 call Wire.requestFrom() simultaneously, the I2C peripheral state machine collides, resulting in a hard crash. We solve this by implementing a FreeRTOS Mutex (SemaphoreHandle_t) that forces the cores to take turns accessing the I2C bus.

#include <Wire.h>
#include <Adafruit_BME280.h>
#include <Adafruit_SSD1306.h>
#include <WiFi.h>

// --- Pin Definitions ---
#define I2C_SDA 21
#define I2C_SCL 22
#define SCREEN_WIDTH 128
#define SCREEN_HEIGHT 64
#define OLED_RESET -1
#define SCREEN_ADDRESS 0x3C

// --- WiFi Credentials ---
const char* ssid = "YOUR_SSID";
const char* password = "YOUR_PASSWORD";

// --- Hardware Objects ---
Adafruit_BME280 bme;
Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, OLED_RESET);

// --- FreeRTOS Handles ---
TaskHandle_t TaskNetwork;
SemaphoreHandle_t i2cMutex;

// --- Shared Data ---
float globalTemp = 0.0;
float globalHum = 0.0;

void setup() {
  Serial.begin(115200);
  Wire.begin(I2C_SDA, I2C_SCL);
  
  // Initialize Mutex for I2C bus protection
  i2cMutex = xSemaphoreCreateMutex();
  
  if (!bme.begin(0x76)) {
    Serial.println("BME280 init failed. Check wiring.");
    while (1) { vTaskDelay(1000); }
  }
  
  if (!display.begin(SSD1306_SWITCHCAPVCC, SCREEN_ADDRESS)) {
    Serial.println("SSD1306 init failed.");
    while (1) { vTaskDelay(1000); }
  }
  display.clearDisplay();
  display.setTextSize(1);
  display.setTextColor(SSD1306_WHITE);
  
  WiFi.begin(ssid, password);
  
  // Create Network Task pinned to Core 0
  xTaskCreatePinnedToCore(
    NetworkTaskCode,   // Task function
    "NetworkTask",     // Name
    8192,              // Stack size (bytes)
    NULL,              // Parameters
    1,                 // Priority (lower than sensor task)
    &TaskNetwork,      // Task handle
    0                  // Core ID (0 = Protocol CPU)
  );
}

void loop() {
  // Core 1 (Application CPU) runs loop() by default
  // Take the mutex before touching I2C
  if (xSemaphoreTake(i2cMutex, portMAX_DELAY) == pdTRUE) {
    globalTemp = bme.readTemperature();
    globalHum = bme.readHumidity();
    
    display.clearDisplay();
    display.setCursor(0, 0);
    display.print("Temp: "); display.print(globalTemp); display.println(" C");
    display.print("Hum:  "); display.print(globalHum); display.println(" %");
    display.display();
    
    xSemaphoreGive(i2cMutex); // Release I2C bus
  }
  
  // CRITICAL: Feed the watchdog. Never block Core 1 completely.
  vTaskDelay(500 / portTICK_PERIOD_MS);
}

void NetworkTaskCode(void *pvParameters) {
  for (;;) {
    if (WiFi.status() == WL_CONNECTED) {
      // Simulate network payload transmission
      Serial.printf("[Core 0] TX Payload: T=%.2f, H=%.2f\n", globalTemp, globalHum);
    } else {
      Serial.println("[Core 0] WiFi disconnected. Reconnecting...");
      WiFi.reconnect();
    }
    
    // Feed Core 0 watchdog. Yielding is mandatory in FreeRTOS infinite loops.
    vTaskDelay(2000 / portTICK_PERIOD_MS);
  }
}

Debugging Watchdog Panics and I2C Collisions

When leveraging both cores, you will inevitably encounter hardware panics if you starve the system's idle tasks. The ESP32 has two watchdogs: the Task Watchdog Timer (TWDT) and the Interrupt Watchdog Timer (IWDT).

Exact Error String:
Guru Meditation Error: Core 1 panic'ed (Interrupt wdt timeout on CPU1).
Alternative: E (12345) task_wdt: Task watchdog got triggered. The following tasks did not reset the watchdog in time:

Ranked Causes for Watchdog Panics

  1. Missing vTaskDelay() or yield(): If your loop() or FreeRTOS task contains a while(1) or tight for loop without yielding, the core never switches context to the hidden IDLE task, which is responsible for petting the watchdog.
  2. I2C Bus Collision (No Mutex): If both cores hit the Wire library simultaneously, the hardware I2C state machine hangs indefinitely waiting for an ACK that will never come, triggering the Interrupt Watchdog.
  3. Disabling Interrupts Too Long: Using noInterrupts() for more than a few microseconds prevents the RTOS tick interrupt from firing.

The First Three Things to Check When It Fails

  1. Inject Delays: Add vTaskDelay(1) inside any tight polling loops. A 1-tick delay is enough to reset the TWDT without impacting real-world timing.
  2. Verify Mutex Wrappers: Ensure every single Wire.read(), Wire.write(), or sensor library call is sandwiched between xSemaphoreTake() and xSemaphoreGive().
  3. Increase Stack Size: If the panic mentions "Stack canary watchpoint triggered", your task is overflowing its memory. Increase the stack size parameter in xTaskCreatePinnedToCore from 2048 to 8192 bytes. I2C and OLED libraries allocate heavily on the stack.

Extending and Simplifying the Build

Once the dual-core baseline is stable, you can scale the architecture. To extend the build, replace the global variables (globalTemp) with a FreeRTOS Queue (xQueueCreate). Queues are inherently thread-safe and prevent race conditions where Core 0 might read a temperature value exactly while Core 1 is overwriting it. For detailed API mechanics, refer to the official FreeRTOS xTaskCreatePinnedToCore documentation.

To simplify the build for lower-power applications, abandon Core 0 entirely. If your project only needs to log to an SD card and doesn't require WiFi, pin all tasks to Core 1 and leave Core 0 dormant. This reduces thermal output and prevents the RF subsystem from generating noise that interferes with the ESP32's internal ADC readings. You can review core architecture specifics in the Espressif FreeRTOS API Reference.

Frequently Asked Questions

Can I run WiFi on Core 1 and sensors on Core 0?

Technically yes, but it is highly discouraged. The ESP32 Arduino core hardcodes the WiFi and Bluetooth event handling to Core 0. If you pin your sensor task to Core 0, it will compete for CPU cycles with the RF stack, leading to dropped WiFi packets and increased latency. Always put the network stack and network-adjacent tasks on Core 0, and timing-critical hardware polling on Core 1.

Why does my ESP32 crash when reading I2C from both cores?

The Arduino Wire library was originally designed for single-core AVR microcontrollers. It relies on global state variables to track I2C transactions. When two cores access it concurrently, those variables are corrupted mid-transaction. You must use a FreeRTOS Mutex to lock the I2C bus, ensuring only one core can execute a Wire command at any given millisecond.

How much stack memory should I allocate for FreeRTOS tasks on ESP32?

Unlike some architectures that measure stack in words, ESP32 FreeRTOS measures stack size in bytes. A bare-bones task doing simple math needs 1024 bytes. However, if your task initializes I2C sensors, drives an OLED display, or parses JSON, you must allocate between 4096 and 8192 bytes. Failing to do so results in a "Stack canary watchpoint triggered" panic.

Does the ESP32-S3 use the same core assignments as the original ESP32?

No. The ESP32-S3 architecture changed how the WiFi stack is handled. On the S3, the WiFi MAC and baseband tasks are not strictly pinned to Core 0 in the same rigid way, allowing more flexibility. However, the best practice of using Mutexes for shared hardware buses and yielding in infinite loops remains identical across all ESP32 variants. For more on variant differences, check the Arduino ESP32 Core GitHub repository.

Can I use standard Arduino delays like delay(1000) in FreeRTOS tasks?

You should avoid standard delay() inside pinned FreeRTOS tasks. While delay() does yield to the RTOS scheduler in modern ESP32 Arduino cores, it is safer and more explicit to use vTaskDelay(1000 / portTICK_PERIOD_MS). This guarantees you are interacting directly with the FreeRTOS tick timer, preventing edge-case watchdog timeouts on heavily loaded cores.