If you have spent more than a weekend building with Espressif silicon, you have inevitably stared at this exact serial monitor output:

Guru Meditation Error: Core 1 panic'ed (Interrupt wdt timeout on CPU1).
rst:0x8 (TG0WDT_SYS_RESET),boot:0x13 (SPI_FAST_FLASH_BOOT)

The TG0WDT_SYS_RESET (Reset Reason 0x8) means the Task Group 0 Watchdog Timer caught a stalled task or a locked interrupt and hard-reset the chip to recover. Unlike a standard brownout or a manual push of the EN button, this is a software or hardware-lockup panic. The ESP32's FreeRTOS kernel detected that a critical background task—often the Wi-Fi stack, the Bluetooth stack, or the Idle task—was starved of CPU time for longer than the configured watchdog timeout (default is usually 5 seconds).

Below is the exact diagnostic framework, hardware verification steps, and compilable code to eliminate this panic from your builds.

Decoding the TG0WDT_SYS_RESET Panic

Before fixing the reset, you need to understand where it sits in the ESP32's reset hierarchy. The bootlog prints a hex code indicating the reset source. rst:0x8 specifically points to the Task Group 0 Watchdog Timer. Here is how it compares to other common reset reasons you will see in the serial monitor.

Reset Code Hex Value Source Typical Cause
POWERON_RESET 0x1 Hardware VCC applied, EN pin pulled high.
SW_RESET 0x3 Software ESP.restart() called in code.
OWDT_RESET 0x4 Hardware Older RTC watchdog timeout (rare in modern IDF).
TG0WDT_SYS_RESET 0x8 Software/RTOS Task Group 0 WDT timeout (Blocking loops, I2C lockup).
TG1WDT_SYS_RESET 0x9 Software/RTOS Task Group 1 WDT timeout (Usually interrupt context lockup).
RTCWDT_RTC_RESET 0xf Hardware RTC Watchdog timeout (Deep sleep wake failure).

In modern ESP32 Arduino Core v3.x (which wraps ESP-IDF v5.x), the Task Watchdog Timer (TWDT) is strictly enforced. If your loop() function hogs the CPU without yielding, or if a hardware peripheral state machine hangs and blocks the Interrupt Service Routine (ISR), the TWDT triggers a system reset to prevent the chip from remaining in a zombie state.

The First Three Things to Check When the WDT Triggers

When the TG0WDT_SYS_RESET hits, do not immediately start rewriting your FreeRTOS tasks. 90% of these panics stem from three specific bottlenecks. Check them in this exact order:

1. I2C Bus Lockup (Missing Pull-Up Resistors)

This is the most common hardware culprit on the bench. If your SDA or SCL lines are floating, electrical noise can pull the SDA line low. The ESP32's I2C hardware state machine will wait indefinitely for the line to go high, locking up the I2C driver. Because the I2C driver runs in an ISR context, the lockup prevents the FreeRTOS scheduler from ticking, triggering the Interrupt Watchdog.

Bench Rule: Never run I2C on an ESP32 without physical pull-up resistors. The internal weak pull-ups (approx. 45kΩ) are insufficient for reliable 400kHz I2C communication. Use 4.7kΩ resistors for 100kHz, or 2.2kΩ for 400kHz, tied from SDA and SCL to 3.3V.

2. Blocking While-Loops Without Yielding

A standard delay(1000) in Arduino is actually a wrapper that yields to the FreeRTOS scheduler. However, a custom while(digitalRead(PIN) == LOW) {} loop waiting for a sensor interrupt will starve the Wi-Fi and RTOS idle tasks. If that pin never goes HIGH, the CPU locks, and the WDT resets the board after 5 seconds.

3. Wi-Fi/Bluetooth Stack Starvation

If you are doing heavy computational work (like FFT audio processing or driving high-density LED matrices via I2S) directly inside the loop() without calling yield() or vTaskDelay(), the background Wi-Fi task cannot process TCP/IP keep-alive packets. The RTOS detects the idle task starvation and triggers the TG0WDT reset.

Hardware Parts and Pin Mapping

To demonstrate a watchdog-safe build, we will wire an I2C environmental sensor to the ESP32. This setup specifically targets the ESP32-WROOM-32E DevKit V1 (4MB Flash, dual-core 240MHz). The 'E' variant features an improved RF shield and is the current standard for new designs over the older V1 modules.

Parts List

  • MCU: ESP32-WROOM-32E DevKit V1 (38-pin or 30-pin variant)
  • Sensor: Adafruit BME280 I2C Temperature/Humidity/Pressure (Product ID: 2652)
  • Resistors: 2x 4.7kΩ (for I2C pull-ups)
  • Wiring: 22 AWG solid core hookup wire

Pin Mapping Table

ESP32-WROOM-32E Pin Direction BME280 Pin Notes
3V3 Power Out VIN / VCC Do not use 5V; BME280 is strictly 3.3V logic.
GND Ground GND Common ground required.
GPIO 21 (SDA) I2C Data SDI / SDA Requires 4.7kΩ pull-up to 3V3.
GPIO 22 (SCL) I2C Clock SCK / SCL Requires 4.7kΩ pull-up to 3V3.

Compilable Code: Taming the Task Watchdog

The code below targets the ESP32-WROOM-32E using Arduino Core v3.x. It implements three critical defenses against the TG0WDT_SYS_RESET:

  1. I2C Timeout Configuration: Prevents the Wire library from hanging indefinitely if the bus locks.
  2. Explicit Task Watchdog Registration: Uses the ESP-IDF esp_task_wdt API to register the loop task and manually feed the watchdog.
  3. Non-Blocking Delays: Uses vTaskDelay to properly yield to the FreeRTOS scheduler.
Library Requirement: Install the 'Adafruit BME280 Library' and 'Adafruit Unified Sensor' via the Arduino Library Manager before compiling.
#include 
#include 
#include 

// --- Pin Definitions ---
#define I2C_SDA_PIN 21
#define I2C_SCL_PIN 22
#define I2C_FREQ    400000 // 400kHz Fast Mode

// --- Watchdog Configuration ---
#define WDT_TIMEOUT_SECONDS 10

Adafruit_BME280 bme;

void setup() {
  Serial.begin(115200);
  delay(1000); // Allow serial monitor to connect
  Serial.println("\n--- ESP32 TG0WDT_SYS_RESET Prevention Build ---");

  // 1. Initialize I2C with explicit pins and frequency
  Wire.begin(I2C_SDA_PIN, I2C_SCL_PIN, I2C_FREQ);
  
  // CRITICAL: Set an I2C timeout to prevent hardware state machine lockups
  // If the bus hangs, Wire will abort after 250ms instead of locking the ISR
  Wire.setTimeout(250); 

  // 2. Initialize the Task Watchdog Timer (TWDT)
  // Note: In ESP-IDF v5.x / Arduino Core v3.x, we use the config struct
  esp_task_wdt_config_t twdt_config = {
    .timeout_ms = WDT_TIMEOUT_SECONDS * 1000,
    .idle_core_mask = (1 << portNUM_PROCESSORS) - 1, // Monitor both cores
    .trigger_panic = true, // Trigger panic if WDT is not fed
  };
  
  if (esp_task_wdt_init(&twdt_config) == ESP_OK) {
    Serial.println("TWDT initialized successfully.");
  } else {
    Serial.println("TWDT init failed, reconfiguring...");
    esp_task_wdt_reconfigure(&twdt_config);
  }

  // Add the current loop task to the watchdog monitor list
  esp_task_wdt_add(NULL);

  // 3. Initialize Sensor with error handling
  if (!bme.begin(0x76, &Wire)) {
    Serial.println("Could not find a valid BME280 sensor, check wiring!");
    // Do not enter an infinite while(1) loop here without feeding WDT!
    while (1) {
      esp_task_wdt_reset(); // Feed watchdog while stuck in error state
      delay(1000);
    }
  }
  Serial.println("BME280 initialized.");
}

void loop() {
  // CRITICAL: Feed the watchdog at the start of every loop iteration
  esp_task_wdt_reset();

  // Read sensor data
  float temp = bme.readTemperature();
  float humidity = bme.readHumidity();

  // Check for NaN (happens if I2C timeout triggered during read)
  if (isnan(temp) || isnan(humidity)) {
    Serial.println("I2C Read Timeout / Bus Error detected. Resetting I2C...");
    Wire.end();
    delay(10);
    Wire.begin(I2C_SDA_PIN, I2C_SCL_PIN, I2C_FREQ);
    Wire.setTimeout(250);
  } else {
    Serial.printf("Temp: %.2f C | Humidity: %.2f %%\n", temp, humidity);
  }

  // CRITICAL: Use vTaskDelay instead of delay() to yield to FreeRTOS Idle tasks
  // pdMS_TO_TICKS converts milliseconds to RTOS ticks
  vTaskDelay(pdMS_TO_TICKS(2000)); 
}

Extending and Simplifying the Build

Once your baseline I2C and Watchdog logic is stable, you will likely want to add network connectivity or low-power states. Here is how to extend the build without re-triggering the TG0WDT_SYS_RESET.

Adding Wi-Fi and OTA Updates

When you introduce WiFi.begin() and Over-The-Air (OTA) updates, the ESP32 spawns background tasks on Core 0. If your sensor polling on Core 1 uses blocking delays, the Wi-Fi stack will drop packets. Always use ArduinoOTA.handle() inside a loop that yields, and never place OTA handling inside an interrupt service routine. For heavy OTA payloads, temporarily increase the WDT timeout using esp_task_wdt_reconfigure() before the flash write sequence begins.

Transitioning to Deep Sleep

If you are moving to a battery-powered node, deep sleep bypasses the TWDT entirely because the CPU cores are powered down. However, the preparation for deep sleep can trigger a WDT panic if you attempt to gracefully disconnect MQTT clients or flush SPIFFS files without yielding. Always call esp_task_wdt_reset() immediately before invoking esp_deep_sleep_start().

Simplifying for ESP-IDF Purists

If you are migrating away from the Arduino framework to pure ESP-IDF, you can configure the watchdog behavior at compile time via menuconfig. Navigate to Component config → ESP System Settings → Task Watchdog Timer. Here you can adjust CONFIG_ESP_TASK_WDT_TIMEOUT_S and disable CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU1 if you are intentionally running a bare-metal polling loop on Core 1 (though this is highly discouraged for production firmware).

For deeper architectural details on the ESP32's dual-core watchdog implementation, refer to the official Espressif Watchdog Timer API Documentation. If you are debugging Wire library lockups specifically, review the Arduino ESP32 Core repository for recent patches to the I2C interrupt handlers.