The Direct Answer: What Causes the spinlock_acquire Panic?

If your ESP32 suddenly reboots and spits out the assert failed: spinlock_acquire spinlock.h error string (often accompanied by a Guru Meditation Error: Core 1 panic'ed (Interrupt wdt timeout)), your code has violated the fundamental rules of FreeRTOS critical sections. A spinlock is a low-level synchronization primitive that disables interrupts on the current core to protect shared data. When the ESP32 fails to acquire or release this lock within the hardware watchdog window (typically 300ms for the Interrupt WDT), the system panics and resets to prevent a permanent deadlock.

The First Three Things to Check When It Fails:
  1. Blocking I/O in Critical Sections: Are you calling Wire.requestFrom(), Serial.print(), or delay() between portENTER_CRITICAL() and portEXIT_CRITICAL()? Never do this.
  2. I2C Clock Stretching Lockups: If an I2C sensor holds the SCL line low while your ESP32 is inside a critical section with interrupts masked, the Wire library will hang indefinitely, triggering the Interrupt Watchdog (IWDT) and the spinlock panic.
  3. ISR Spinlock Contention: Is an Interrupt Service Routine (ISR) trying to acquire the exact same portMUX_TYPE spinlock that the main loop is currently holding? This guarantees a deadlock.

Hardware & Software Spec Sheet

To demonstrate safe concurrency and replicate the failure modes, we are using a standard I2C sensor setup with an external interrupt trigger. This mimics a real-world data-logging scenario where sensor reads and interrupt-driven events collide.

ComponentExact Variant / VersionNotes
MicrocontrollerESP32-WROOM-32 DevKit V1 (30-pin)Dual-core Xtensa LX6, 240MHz
Arduino Corearduino-esp32 v2.0.14 / v3.0.xBased on ESP-IDF 4.4 / 5.1
I2C SensorBME280 or SSD1306 (Addr 0x3C/0x76)Used to demonstrate I2C bus locking
Logic AnalyzerSaleae Logic Pro 16 (or generic 8ch)Crucial for spotting I2C clock stretching

Difficulty Rating: Intermediate. Requires understanding of FreeRTOS multitasking, hardware interrupts, and I2C bus physics.

Pin Mapping & Test Circuit

Wire your circuit exactly as specified below. We use GPIO 25 as a hardware interrupt pin (simulating a reed switch or encoder) and the default I2C pins for the sensor.

ESP32 PinTarget PeripheralFunction / Constraint
GPIO 21I2C SDARequires 4.7kΩ pull-up to 3.3V
GPIO 22I2C SCLRequires 4.7kΩ pull-up to 3.3V
GPIO 25Interrupt TriggerInternal pull-up enabled, trigger on FALLING
GPIO 2Onboard LEDVisual heartbeat indicator

Ranked Causes & Fixes for the Spinlock Timeout

When you see the spinlock_acquire assertion fail, the ESP-IDF kernel is telling you that a task tried to grab a lock, but the core was stuck in a state where interrupts were masked for too long, or the lock was recursively requested. Here are the ranked causes from most to least common in Arduino environments.

1. Blocking I/O Inside a Critical Section (Most Common)

The Scenario: You want to read an I2C sensor and update a shared variable, so you wrap the whole block in portENTER_CRITICAL(&mux).
The Failure: I2C relies on interrupts (or at least, the underlying ESP-IDF I2C driver does for timeout handling and clock stretching). By masking interrupts with the spinlock, the I2C driver hangs waiting for a hardware event that can no longer trigger an interrupt. The Interrupt Watchdog (IWDT) trips at ~300ms, causing the panic.
The Fix: Keep critical sections under 20 microseconds. Read the sensor into a local variable outside the critical section, then only use the spinlock to copy that local variable into the shared global variable.

2. Recursive Spinlock Acquisition on the Same Core

The Scenario: Function A acquires sensorMux. Function A calls Function B. Function B also tries to acquire sensorMux.
The Failure: ESP32 spinlocks are not recursive. The core disables interrupts, tries to grab the lock, sees it is already held (by itself), and spins infinitely waiting for it to be released. The IWDT fires.
The Fix: Audit your call tree. If functions share a lock, use a standard FreeRTOS Mutex (xSemaphoreTake) which supports task ownership tracking, or restructure your code so the lock is only acquired at the highest level.

3. Starving the FreeRTOS IDLE Task

The Scenario: A high-priority task hogs the CPU using a while(1) loop with a spinlock, never yielding.
The Failure: The FreeRTOS IDLE task is responsible for feeding the Task Watchdog Timer (TWDT). If your task never yields (vTaskDelay or yield()), the IDLE task starves, and the TWDT resets the chip. While this usually throws a TWDT error, heavy spinlock contention can blur the lines and trigger the interrupt WDT.
The Fix: Never use spinlocks for long-running loops. Use xSemaphoreCreateMutex() for operations taking longer than a few microseconds.

For deeper architectural guidance on ESP32 concurrency, refer to the Espressif FreeRTOS API Reference and the FreeRTOS Critical Section documentation.

Compilable Code: Safe Critical Sections & Error Handling

This code targets the ESP32-WROOM-32 DevKit V1. It demonstrates the correct way to handle an interrupt, read an I2C bus safely without triggering the spinlock_acquire panic, and manage shared state. It includes I2C error handling to prevent bus lockups.

#include <Arduino.h>
#include <Wire.h>

// --- Pin Definitions ---
#define I2C_SDA_PIN 21
#define I2C_SCL_PIN 22
#define INTERRUPT_PIN 25
#define LED_PIN 2
#define I2C_SENSOR_ADDR 0x76 // BME280 default

// --- Concurrency Primitives ---
// Initialize the hardware spinlock mutex
portMUX_TYPE sensorMux = portMUX_INITIALIZER_UNLOCKED;

// Shared state variables
volatile int isrTriggerCount = 0; // Updated in ISR
int safeSensorReading = 0;        // Updated in loop, read by other tasks

// --- Interrupt Service Routine ---
void IRAM_ATTR handleInterrupt() {
  // NEVER use portENTER_CRITICAL inside an ISR.
  // NEVER call I2C, Serial, or delay() here.
  // Only manipulate volatile variables or push to a FreeRTOS Queue.
  isrTriggerCount++;
}

// --- I2C Safe Read Function ---
int readSensorSafely() {
  Wire.beginTransmission(I2C_SENSOR_ADDR);
  Wire.write(0xF7); // Example register for BME280 pressure data
  uint8_t i2cError = Wire.endTransmission(false);
  
  if (i2cError != 0) {
    Serial.printf("I2C Bus Error: %d\n", i2cError);
    // Attempt to clear a hung bus by toggling SCL if necessary
    return -1; 
  }
  
  uint8_t bytesReceived = Wire.requestFrom(I2C_SENSOR_ADDR, (uint8_t)3);
  if (bytesReceived == 3) {
    uint32_t raw = Wire.read();
    raw = (raw << 8) | Wire.read();
    raw = (raw << 8) | Wire.read();
    return (int)(raw >> 4); // Simplified parsing
  }
  return -1;
}

void setup() {
  Serial.begin(115200);
  delay(1000); // Allow serial monitor to connect
  
  pinMode(LED_PIN, OUTPUT);
  pinMode(INTERRUPT_PIN, INPUT_PULLUP);
  
  // Attach hardware interrupt
  attachInterrupt(digitalPinToInterrupt(INTERRUPT_PIN), handleInterrupt, FALLING);
  
  // Initialize I2C with explicit pins and a reasonable timeout
  Wire.begin(I2C_SDA_PIN, I2C_SCL_PIN);
  Wire.setClock(400000); // 400kHz Fast Mode
  // Crucial: Set a timeout to prevent infinite I2C hangs that cause WDT panics
  Wire.setTimeOut(50); // 50ms timeout for I2C operations
  
  Serial.println("System Initialized. Safe Spinlock Demo Running.");
}

void loop() {
  // 1. Perform blocking/slow I/O OUTSIDE the critical section
  int localSensorVal = readSensorSafely();
  
  // 2. Enter Critical Section ONLY for memory copy
  // This disables interrupts on this core for a few nanoseconds
  portENTER_CRITICAL_ISR(&sensorMux);
  
  if (localSensorVal != -1) {
    safeSensorReading = localSensorVal;
  }
  int localIsrCount = isrTriggerCount;
  isrTriggerCount = 0; // Reset counter safely
  
  portEXIT_CRITICAL_ISR(&sensorMux);
  // Interrupts are restored immediately
  
  // 3. Perform serial printing and delays OUTSIDE the critical section
  if (localIsrCount > 0) {
    Serial.printf("ISR Triggers: %d | Sensor Val: %d\n", localIsrCount, safeSensorReading);
  }
  
  // Heartbeat LED
  digitalWrite(LED_PIN, !digitalRead(LED_PIN));
  
  // Yield to FreeRTOS IDLE task to feed the Task Watchdog
  vTaskDelay(pdMS_TO_TICKS(100)); 
}

Extending and Simplifying the Build

How to Simplify: If you are struggling with spinlock timing and your sensor reads take longer than 50µs, abandon portMUX_TYPE entirely. Switch to a standard FreeRTOS Mutex. Replace portENTER_CRITICAL with xSemaphoreTake(myMutex, portMAX_DELAY) and portEXIT_CRITICAL with xSemaphoreGive(myMutex). Mutexes do not disable hardware interrupts; they simply put the calling task to sleep until the resource is free, completely eliminating the Interrupt WDT panic vector.

How to Extend: For multi-core ESP32 applications (e.g., pinning WiFi to Core 0 and sensor reading to Core 1), avoid sharing spinlocks across cores whenever possible. Cross-core spinlocks cause massive cache-coherency traffic and CPU stalls. Instead, use FreeRTOS Queues (xQueueSend / xQueueReceive) to pass data payloads between cores asynchronously. For comprehensive watchdog tuning across cores, review the ESP-IDF Watchdog Timer API.

Frequently Asked Questions

Why does spinlock_acquire fail only when WiFi is enabled?

When WiFi is active, the ESP32's RF subsystem generates frequent, high-priority interrupts on Core 0. If your application code on Core 0 enters a critical section and masks interrupts for too long, it blocks the WiFi stack from processing incoming packets. The ESP-IDF kernel detects this starvation and triggers the spinlock_acquire assert or an Interrupt WDT panic to protect the radio stack. Always pin heavy WiFi tasks to Core 0 and keep critical sections on Core 1, or keep them under 10µs.

What is the difference between portENTER_CRITICAL and xSemaphoreTake on the ESP32?

portENTER_CRITICAL (using a portMUX_TYPE) is a hardware spinlock. It physically disables interrupts on the CPU core executing it. It is blazing fast (nanoseconds) but will crash the system if held too long. xSemaphoreTake is a software mutex. It leaves interrupts enabled and tells the FreeRTOS scheduler to pause the current task and run something else while waiting for the lock. Use spinlocks for updating a single integer or flag; use mutexes for I2C, SPI, or Serial operations.

How do I increase the Interrupt Watchdog timeout in the Arduino IDE?

You generally shouldn't, as a 300ms interrupt delay means your system is fundamentally broken. However, if you have a highly specific, unavoidable hardware requirement, you can increase the IWDT timeout via the ESP-IDF menuconfig. In the Arduino IDE, this requires installing the esp32-arduino-libs locally and modifying the sdkconfig file, specifically changing CONFIG_ESP_INT_WDT_TIMEOUT_MS. A better approach is to fix the code by moving blocking I/O outside of your portENTER_CRITICAL blocks.