ESP32-S3 boot loop with no crash log: check your GPIO numbers
The board resets in a loop. There is no panic handler output, no
backtrace, and not one line of your own Serial.print — just
rst:0x8 (TG1WDT_SYS_RST) over and over. The usual suspects (power, flash
settings, a bad library) all check out. Before you spend an evening on build options,
look at which GPIO numbers your code writes to in setup().
The symptom, precisely
What makes this failure distinctive is what is missing:
ESP-ROM:esp32s3-20210327 Build:Mar 27 2021 rst:0x8 (TG1WDT_SYS_RST),boot:0x8 (SPI_FAST_FLASH_BOOT) SPIWP:0xee mode:DIO, clock div:1 load:0x3fce3808,len:0x44c entry 0x403c98d8 <-- nothing. then it all repeats.
Read that carefully, because it is good news disguised as bad. The
entry 0x403c98d8 line means the second-stage bootloader ran and handed
control to your application. The image is fine. The flash settings are fine. Your
code started — and then, a few instructions in, the chip reset without a word.
TG1WDT_SYS_RST is Timer Group 1's watchdog firing. That is the watchdog
that guards early startup: if the application stops making progress before the RTOS is
up, this is what resets it. There is no panic message because there was no exception
— nothing crashed. The CPU simply stopped getting anywhere.
Guru Meditation
Error with a stack trace is a normal bug, and you debug it normally. A silent
watchdog reset right after entry means execution stopped in a way the
exception handler never saw — and on the S3, the classic way to do that is to
break the chip's own access to its flash.Why a pinMode() can reset the chip
An ESP32-S3 does not hold your program in RAM. It executes it from external SPI flash through a cache: instructions are fetched over the SPI bus on demand, transparently, for the entire life of the program. The same is true of PSRAM if your board has it and your code uses it.
That bus is wired to GPIO pins. They are not "reserved" in any way the compiler
understands — pinMode(26, OUTPUT) is a perfectly legal call and will
compile without a murmur. What it does at runtime is take a pin the flash chip is
talking on and reconfigure it as a general-purpose output.
The consequence is immediate and total. The next time the CPU needs an instruction that
is not already in cache, the fetch comes back as garbage. There is no code left to run
— including the code that would have handled the fault. So nothing is printed,
nothing is caught, and a few milliseconds later the watchdog resets the chip. Then it
boots, reaches your setup(), drives the pin again, and does it all over.
This also explains a detail that confuses people: the failure is often
intermittent-looking across builds. If the offending pinMode()
happens to sit in a stretch of code already pulled into cache, execution can limp on for
a few more instructions and reach a different failure. Add a line, change the layout,
and the same bug expresses itself slightly differently.
The pins to avoid on an ESP32-S3
| GPIO | Severity | Why |
|---|---|---|
26–32 |
Fatal | The SPI0/1 bus to the flash chip. Driving any of these breaks instruction fetch immediately. |
33–37 |
Fatal on octal modules | Used by octal (OPI) flash and octal PSRAM. On an N16R8-class module these are in use; on a module with neither, they can be free. Treat as unusable unless you have checked your exact module. |
22–25 |
Fatal | These GPIO numbers do not exist on the S3. The call silently does nothing useful and the pin you meant is untouched — which produces the equally baffling "my relay never switches" bug. |
19, 20 |
Caution | The native USB D−/D+ pads. Usable as GPIO only if you are not using native USB — and that includes the USB-Serial-JTAG console you are probably flashing over. |
43, 44 |
Caution | Default UART0 TX/RX. Taking them costs you the serial console you need to debug everything else. |
0, 3, 45, 46 |
Caution | Strapping pins, read at reset. An external pull on these changes boot mode, flash voltage or JTAG source. Safe as outputs after boot; risky if something holds them at reset. |
Why ESP32 tutorials walk you straight into it
Here is the part that makes this bug a rite of passage rather than a rare accident.
On the original ESP32, GPIO 25, 26 and 27 are excellent pins. They are broken out on every dev board, they have no strapping duties, two of them are the DAC outputs, and as a result they appear in an enormous number of tutorials, library examples, wiring diagrams and Fritzing sketches as the obvious choice for a relay, an LED or a servo.
On the ESP32-S3, GPIO 26 and 27 are flash bus pins and GPIO 25 does not exist.
So the single most-copied ESP32 pin assignment on the internet is precisely the one that bricks an S3 into a silent boot loop. You do not have to make a mistake to hit this — you only have to follow a tutorial written for the older chip, which is most of them. The code compiles, flashes, and produces a board that resets forever with no explanation.
Pins that are safe to start from
For a general-purpose output — a relay, an LED, a servo signal, a NeoPixel data line — these are unencumbered on a standard ESP32-S3 module:
GPIO 4 5 6 7 // no boot duty, no bus duty GPIO 15 16 17 18 // same; 15/16 are also the USB-JTAG alt pins on some boards GPIO 8 9 10 11 12 13 // generally fine; 8 is an ADC/strapping-adjacent pin on some modules
Add a peripheral constraint on top of that: ADC2 channels do not work while Wi-Fi is active on any ESP32 family, so an analog input on a Wi-Fi project should use an ADC1 pin. On the S3 that is GPIO 1–10.
The general rule that saves the most time: pick your pins from your module's
datasheet once, write them down as named constants at the top of the file, and never
inline a raw GPIO number. The bug is much easier to spot in a block of
#define RELAY_1 4 than scattered through two hundred lines of setup.
Finding the offending pin in your own code
- Grep for the numbers before you do anything else. Search your project for
25,26,27and anything in 33–37 appearing as a pin argument. In most cases you will find it in under a minute and can stop here.grep -nE "pinMode|digitalWrite|attach|setPin|ledcAttach" src/*.cpp *.ino
- Comment out all pin initialisation in
setup(). If the board then boots and reaches your firstSerial.print, the cause is confirmed and only the identity of the pin is left. - Reintroduce them in halves. A binary search over the pin-init block finds the exact line in three or four flashes.
- Print before you touch anything. Put a
Serial.begin(115200)and a distinctive marker as the literal first statements ofsetup(), with a shortdelay(200)after. Whether that marker appears tells you whether you are dying before or after your own code starts — which separates this problem from a build-options problem in one step.
In our own tool this is now a pre-flight check rather than a debugging session: ESP-GenUI keeps a table of per-chip pin hazards and flags any diagram whose hardware nodes land on one before you compile, because we lost a genuinely embarrassing amount of time to exactly this — chasing flash and PSRAM build options on a board that was resetting for a completely different reason. The lesson generalises past our tool: validate pin numbers against the target chip at build time, because the runtime failure gives you nothing to work with.
The same trap on other ESP32 chips
Every ESP32 variant reserves a different block for its flash bus, so the "safe" pins from one chip are hazards on another. The families in current use:
| Chip | Flash / PSRAM bus | Also worth avoiding |
|---|---|---|
| ESP32 (classic) | 6–11 | 34–39 are input-only; strapping on 0, 2, 12, 15 |
| ESP32-S2 | 26–32 | 22–25 absent; USB on 19, 20 |
| ESP32-S3 | 26–37 | 22–25 absent; USB 19, 20; UART0 43, 44 |
| ESP32-C3 | 12–17 | USB on 18, 19; strapping on 2, 8, 9 |
| ESP32-C6 | 24–30 | USB 12, 13; UART 16, 17; strapping 4, 5, 8, 9, 15 |
| ESP32-C2 | 12–17 | UART 19, 20; strapping 8, 9 |
| ESP32-H2 | 15–21 | USB 26, 27; UART 23, 24 |
Note how thoroughly the numbers move: GPIO 26 is a flash pin on the S2 and S3, a flash pin on the C6, a USB pin on the H2, and a perfectly good DAC output on the classic ESP32. There is no portable "safe pin" across the family, which is why porting a working project from one ESP32 to another is a pin-map exercise before it is anything else.