Time to Build: 45 minutes
Target Board: ESP32 DevKit V1 (30-pin variant)
Why For Loops Crash Your ESP32 (And How to Fix Them)
A standard for loop in Arduino executes sequentially, blocking the processor until the iteration completes. On an 8-bit AVR like the Arduino Uno, a blocking loop just pauses the sketch. But on a dual-core, RTOS-based microcontroller like the ESP32, a for loop containing blocking delays (like delay(1000)) will starve the FreeRTOS background tasks. If the loop runs longer than 5 seconds without yielding control back to the operating system, the hardware Task Watchdog Timer (TWDT) will forcefully reboot the board to prevent a system lockup.
To use for loops Arduino style on modern hardware, you must explicitly yield the processor inside the loop. In this guide, we will wire an ESP32 to an 8-channel 5V relay module to sequence a heavy-load irrigation or lighting system. We will cover the exact hardware gotchas (like the JD-VCC isolation jumper), provide fully compilable code with bounds-checking error handling, and debug the infamous watchdog panic.
Hardware Spec Sheet & Pin Mapping
Before writing the loop, we need to map the hardware. The 8-channel relay module uses PC817 optocouplers to isolate the 3.3V logic from the 5V relay coils. However, the ESP32 GPIO pins output 3.3V, and many cheap relay boards require a solid 5V logic HIGH to turn off the optocoupler LED. We will use an active-LOW configuration, which the ESP32 can drive reliably.
Most 8-channel relay boards have a blue jumper labeled JD-VCC and VCC. If you leave this jumper in place and power the board from the ESP32's 5V pin, the 70mA coil spikes will cause a brownout and reset your microcontroller. Remove the jumper. Connect a dedicated 5V 2A power supply to JD-VCC and GND, and connect the ESP32's GND to the relay board's GND (the optocoupler will bridge the signal without sharing the noisy power rail).
Parts List
- MCU: ESP32 DevKit V1 (30-pin, ESP32-WROOM-32E module)
- Relay Module: 8-Channel 5V Relay Module with Optocoupler (Songle SRD-05VDC-SL-C relays)
- Power: 5V 2A Buck Converter or USB wall adapter (dedicated to relay coils)
- Wiring: 22 AWG solid core wire for logic, 14 AWG stranded for load side
Pin Mapping Table
| ESP32 GPIO | Relay Module Pin | Function / Notes |
|---|---|---|
| GPIO 13 | IN1 | Zone 1 Valve (Active LOW) |
| GPIO 12 | IN2 | Zone 2 Valve (Active LOW) |
| GPIO 14 | IN3 | Zone 3 Valve (Active LOW) |
| GPIO 27 | IN4 | Zone 4 Valve (Active LOW) |
| GPIO 26 | IN5 | Zone 5 Valve (Active LOW) |
| GPIO 25 | IN6 | Zone 6 Valve (Active LOW) |
| GPIO 33 | IN7 | Zone 7 Valve (Active LOW) |
| GPIO 32 | IN8 | Zone 8 Valve (Active LOW) |
| GND | GND | Common Ground (Logic side) |
Note: We intentionally avoided GPIOs 0, 2, 15 (strapping pins) and 34-39 (input-only pins) to prevent boot failures and hardware faults. For more on safe pin selection, refer to the Espressif GPIO API Reference.
The Compilable Code: Safe Iteration with Yielding
The following C++ code is written for the Arduino IDE (ESP32 board package v2.0.x or v3.0.x). It defines an array of pins, iterates through them using a for loop, and includes explicit bounds checking to prevent memory corruption. Crucially, it uses yield() inside the loop to feed the watchdog.
#include <Arduino.h>
// Define the relay pins in an array
constexpr uint8_t RELAY_PINS[] = {13, 12, 14, 27, 26, 25, 33, 32};
constexpr uint8_t RELAY_COUNT = sizeof(RELAY_PINS) / sizeof(RELAY_PINS[0]);
// Timing constants
constexpr unsigned long RELAY_ON_TIME_MS = 2000; // 2 seconds per zone
constexpr unsigned long RELAY_OFF_TIME_MS = 500; // 500ms gap between zones
void setup() {
Serial.begin(115200);
delay(1000); // Allow serial monitor to connect
Serial.println("Initializing Relay Sequence...");
// Error Handling: Verify array bounds and configure pins
if (RELAY_COUNT == 0 || RELAY_COUNT > 16) {
Serial.println("FATAL: Invalid RELAY_COUNT configuration. Halting.");
while(true) { delay(1000); } // Safe halt
}
for (uint8_t i = 0; i < RELAY_COUNT; i++) {
pinMode(RELAY_PINS[i], OUTPUT);
digitalWrite(RELAY_PINS[i], HIGH); // HIGH = OFF for active-LOW relays
}
Serial.println("All relays initialized to OFF state.");
}
void loop() {
Serial.println("--- Starting Sequence ---");
// THE FOR LOOP: Iterating safely with RTOS yielding
for (uint8_t i = 0; i < RELAY_COUNT; i++) {
Serial.printf("Activating Zone %d (GPIO %d)\n", i + 1, RELAY_PINS[i]);
digitalWrite(RELAY_PINS[i], LOW); // Turn ON (Active LOW)
// Non-blocking delay alternative or yield during long delays
unsigned long startTime = millis();
while (millis() - startTime < RELAY_ON_TIME_MS) {
yield(); // CRITICAL: Feeds the Task Watchdog Timer (TWDT)
}
digitalWrite(RELAY_PINS[i], HIGH); // Turn OFF
// Brief pause before next zone
startTime = millis();
while (millis() - startTime < RELAY_OFF_TIME_MS) {
yield();
}
}
Serial.println("--- Sequence Complete. Resting for 10s ---");
unsigned long restStart = millis();
while (millis() - restStart < 10000) {
yield();
}
}
Debugging the "Task Watchdog Got Triggered" Error
If you write a standard Arduino for loop using delay(3000) on an ESP32, your serial monitor will eventually spit out a panic message and reboot. This is the most common point of failure for makers migrating from the Uno to the ESP32.
The Exact Error String
E (5432) task_wdt: Task watchdog got triggered. The following tasks did not reset the watchdog in time:
E (5432) task_wdt: - IDLE (CPU 0)
E (5432) task_wdt: Tasks currently running:
E (5432) task_wdt: CPU 0: loopTask
E (5432) task_wdt: CPU 1: IDLE
E (5432) task_wdt: Aborting.
abort() was called at PC 0x400d4123 on core 0
Guru Meditation Error: Core 0 panic'ed (Interrupt wdt timeout on CPU0)
Ranked Causes & The First 3 Things to Check
When this crash occurs, do not immediately rewrite your logic. Check these three hardware/software intersections first:
- Missing
yield()in Long Loops: The Arduinodelay()function on ESP32 usually handles yielding automatically, but if you are using customwhile(millis())loops or tightforloops performing heavy math/I2C reads without a delay, the idle task starves. Fix: Insertyield()orvTaskDelay(1)inside the loop body. - Array Index Out-of-Bounds: If your
forloop condition isi <= RELAY_COUNTinstead ofi < RELAY_COUNT, you will write to a memory address outside the array. On the ESP32, this corrupts the RTOS heap, leading to a delayed, unpredictable watchdog panic several loops later. Fix: Always use<with array sizes, and usesizeof()calculations as shown in the code above. - I2C/SPI Bus Locking Inside the Loop: If your
forloop polls a sensor (like a BME280) over I2C, and the sensor fails to ACK, the Wire library can hang the thread indefinitely waiting for a clock stretch that never ends. Fix: Set I2C timeouts usingWire.setWireTimeout(50000, true)before the loop begins.
For deeper RTOS debugging, consult the Espressif Watchdog Timer API Documentation.
Extending and Simplifying the Build
The code provided above is a synchronous sequence. It works perfectly for an irrigation timer that does nothing else. However, if you want to add WiFi connectivity, an MQTT dashboard, or a web server to trigger the zones manually, a blocking for loop in the main loop() function will cause your WiFi stack to drop connections.
How to Extend: To make this concurrent, abandon the for loop for the timing mechanism entirely. Instead, use a state-machine approach driven by millis(). Store the currentZoneIndex as a global variable. In the loop(), check if millis() - previousMillis >= interval. If true, turn off the current zone, increment the index (wrapping around using the modulo operator: currentZoneIndex = (currentZoneIndex + 1) % RELAY_COUNT;), and turn on the next zone. This frees the ESP32 to process WiFi packets in the background.
How to Simplify: If you only need to toggle all 8 relays simultaneously (e.g., turning on a bank of grow lights), drop the array iteration. Wire all 8 IN pins to a single ESP32 GPIO (via a logic-level MOSFET like an IRLZ44N to handle the combined 560mA coil current safely) and toggle one pin. For more on basic control structures, see the official Arduino for loop reference.
Frequently Asked Questions (FAQ)
How to use for loops in Arduino with arrays?
To iterate through an array, define the array and calculate its length. Use a for loop where the initialization sets an index variable to 0, the condition checks if the index is less than the array length, and the increment adds 1. Example: for (int i = 0; i < arraySize; i++) { Serial.println(myArray[i]); }. Always ensure your loop condition uses < and not <= to prevent reading past the end of the array, which causes memory corruption.
Why is my Arduino for loop running infinitely?
An infinite for loop usually happens due to a syntax error in the loop signature or variable overflow. If you accidentally use a comma instead of a semicolon (e.g., for(int i=0, i<10, i++)), the compiler will throw an error. If you use a uint8_t (max value 255) and your loop condition is i != 256, the variable will overflow back to 0 before it ever equals 256, creating an infinite loop. Always use < or > for boundary checks.
Can I use a for loop to fade an LED on Arduino?
Yes, a for loop is the standard way to fade an LED using PWM. You can write for (int brightness = 0; brightness <= 255; brightness += 5) and inside the loop call analogWrite(ledPin, brightness) followed by a short delay(30). On the ESP32, you must first configure the LEDC PWM peripheral using ledcSetup() and ledcAttachPin() before the loop, and use ledcWrite() inside the loop instead of analogWrite().
What is the difference between a while loop and a for loop in Arduino?
Functionally, they can achieve the exact same result, but they are used for different logical intents. A for loop is best when you know exactly how many times you need to iterate (e.g., iterating over an 8-pin array or counting to 255 for PWM). A while loop is best when the number of iterations is unknown and depends on a condition being met (e.g., while (Serial.available() == 0) { yield(); } to wait for user input). Under the hood, the AVR-GCC and Xtensa compilers optimize both into nearly identical assembly instructions.






