ESP32 boot loop: invalid header: 0xffffffff
Your board resets every few hundred milliseconds and the serial console repeats the same line forever. Nothing you change in your sketch makes any difference — because the chip never gets as far as your sketch. Here is what the ROM loader is actually telling you, and the three things that cause it.
What the message actually means
The line looks like this, repeating until you pull the power:
ets Jul 29 2019 12:21:46 rst:0x10 (RTCWDT_RTC_RESET),boot:0x13 (SPI_FAST_FLASH_BOOT) invalid header: 0xffffffff invalid header: 0xffffffff invalid header: 0xffffffff
An ESP32 boots in two stages. The first-stage bootloader is in ROM —
it is burned into the silicon, you cannot break it, and it is what prints those lines.
Its job is to load the second-stage bootloader from a fixed offset in SPI
flash, and it sanity-checks what it finds there before jumping to it. Every ESP32 image
begins with a header whose first byte is the magic value 0xE9.
0xffffffff is what you read back from erased NOR flash. So the
message is precise and literal: at the address where my bootloader is supposed to
be, the flash is blank. The ROM loader gives up, the watchdog resets the chip, and
the whole thing runs again — forever.
setup() has
not run. Neither has the second-stage bootloader. Nothing you write in your sketch,
no library you add or remove, and no compiler flag in your app can change this
message. Stop debugging your firmware and start looking at what got written to
flash.A related message, invalid header: 0x00000000, means the same thing with a
different filling: zeroes rather than 0xFF. Some other value entirely
(invalid header: 0x0a1b2c3d) means the loader found data there,
just not an image header — usually an image written at the wrong offset, which is
cause 2.
Read the whole boot log, not just the loop
Reset the board with a serial monitor attached at 115200 baud and read the first three lines before the loop starts. They carry more diagnostic weight than the repeated line does.
| Field | Example | What to take from it |
|---|---|---|
ets date | ets Jul 29 2019 |
The ROM build date. It differs per chip family, so it is a rough way to confirm which chip you are actually talking to. |
rst: | 0x10 (RTCWDT_RTC_RESET) |
Why the chip last reset. A watchdog reset here is the ROM loader failing and being restarted — expected in this loop. |
boot: | 0x13 (SPI_FAST_FLASH_BOOT) |
The strapping-pin boot mode. This says it tried to boot from flash. If you see DOWNLOAD_BOOT instead, GPIO0 is held low and the board is waiting to be flashed, which is a different situation. |
If you get no output at all rather than a loop, you have a different problem: wrong baud rate, the wrong serial port, a charge-only USB cable, or a board whose USB bridge is not powered. Confirm you can see the ROM banner before treating this article as relevant.
Cause 1 — the image was built for a different chip
This is by far the most common cause, and it catches experienced people because both halves of the mistake look fine in isolation: the build succeeded, and the flash wrote without error.
The catch is that ESP32 families do not all boot from the same flash offset.
| Chip | Bootloader offset | Chip ID in the image header |
|---|---|---|
| ESP32 (classic) | 0x1000 | 0x0000 |
| ESP32-S2 | 0x1000 | 0x0002 |
| ESP32-S3 | 0x0 | 0x0009 |
| ESP32-C2 | 0x0 | 0x000c |
| ESP32-C3 | 0x0 | 0x0005 |
| ESP32-C6 | 0x0 | 0x000d |
| ESP32-H2 | 0x0 | 0x0010 |
Now trace what happens when you build for a classic ESP32 and flash the result to an
ESP32-S3. Your merged image places the bootloader at 0x1000, because that
is where a classic ESP32 looks for it. The S3 looks at 0x0. Nothing was
ever written there, so it reads 0xFF bytes — and prints
invalid header: 0xffffffff.
The reverse direction fails too, for a subtler reason. Flash an S3-targeted image to a
classic ESP32 and the bootloader lands at 0x0, while the classic part reads
0x1000 — which now holds the middle of the bootloader binary rather
than blank flash. That usually produces invalid header: followed by whatever
bytes happened to be there, not 0xffffffff.
Cause 2 — the image was written to the wrong offset
This one is self-inflicted, usually by a copied-and-pasted esptool command
line. There are two distinct kinds of artifact and they are flashed differently:
- A merged image (often
merged.binor*.factory.bin) already contains the bootloader, the partition table and the app at their correct internal offsets. It goes to0x0— for every chip, including the classic ESP32. The0x1000gap is baked into the file itself. - Separate binaries (
bootloader.bin,partitions.bin,firmware.bin) each need their own offset, and the bootloader's offset is the chip-specific one from the table above.
Writing a merged image to 0x1000 on a classic ESP32 — a very common
copy-paste error, because 0x1000 is the number everyone remembers —
shifts everything by 4 KB and leaves 0x1000 holding the front of the
file rather than the bootloader.
# Merged image: always 0x0, on every chip esptool.py --chip esp32s3 --port /dev/ttyUSB0 write_flash 0x0 merged.bin # Separate binaries on a classic ESP32: bootloader at 0x1000 esptool.py --chip esp32 --port /dev/ttyUSB0 write_flash \ 0x1000 bootloader.bin 0x8000 partitions.bin 0x10000 firmware.bin # Separate binaries on an S3/C3/C6: bootloader at 0x0 esptool.py --chip esp32s3 --port /dev/ttyUSB0 write_flash \ 0x0 bootloader.bin 0x8000 partitions.bin 0x10000 firmware.bin
esptool releases prefer hyphenated
subcommands (write-flash, erase-flash, chip-id);
the underscore spellings above are the long-standing ones and are still widely
accepted. Use whichever your installed version documents in
esptool.py --help.Cause 3 — the write never completed
Less common, but worth ruling out before you rebuild anything. If the flash write was interrupted or corrupted, the first sectors may genuinely be erased even though your build and your offsets are both correct. Suspects:
- A charge-only USB cable, or a marginal one. It enumerates, it powers the board, and it drops bytes under sustained throughput.
- An over-ambitious baud rate. If you are flashing at 921600 and seeing intermittent failures, drop to 115200 and try again. A slow successful flash beats a fast corrupt one.
- Insufficient power. Boards that brown out mid-write — particularly ones with a camera, a motor driver or a display attached — can reset partway through. Disconnect peripherals and flash the bare board.
- An erase that succeeded where the write then failed.
esptoolerases before writing, so an aborted run leaves you with exactly this symptom. Scroll back and confirm the flash actually finished, with a "Hash of data verified" line for each region.
Confirm which one you have
Two commands settle it. First, ask the board what it is — not what you think it is:
esptool.py --port /dev/ttyUSB0 chip_id
That reports the connected chip (for example Detecting chip type... ESP32-S3)
without writing anything. Compare it to the target you built for. If they differ, you have
cause 1 and you are done diagnosing.
If they match, check the image itself. Every ESP32 image header begins with
0xE9, and the extended header carries a 16-bit chip ID at offset +12
from the magic byte. So you can read the target chip straight out of the binary before
you flash it:
# Byte 0 should be e9 for a merged image (or byte 0x1000 for a # classic-ESP32 layout). Bytes +12/+13 are the chip id, little-endian. xxd -l 16 merged.bin # 00000000: e900 0220 ... 0900 .... <- 0x0009 = ESP32-S3
An image whose first byte is not 0xE9 at either 0x0 or
0x1000 is not a bootable ESP32 image at all — you may be flashing an
ELF, an OTA-only app binary, or a partially downloaded file.
Finally, read back what is actually on the chip:
esptool.py --port /dev/ttyUSB0 read_flash 0x0 0x20 head.bin && xxd head.bin
All ff? The flash really is blank there, and the question is only whether
your image put the bootloader somewhere else (cause 1 or
cause 2) or never landed at all (cause 3).
Recover the board
The board is not bricked. The ROM loader lives in silicon and always comes back, so a board in this loop is fully recoverable — there is no state you can write to flash that stops you re-flashing it.
- Put it in download mode. Hold BOOT (GPIO0), tap
RESET/EN, release BOOT. Many dev boards do this
automatically over the USB bridge, but doing it by hand removes a variable. The banner
should change to
DOWNLOAD_BOOT. - Erase the flash completely.
esptool.py --port /dev/ttyUSB0 erase_flash. This clears any half-written image and any stale partition table, both of which can otherwise confuse the next attempt. - Rebuild for the chip you actually have. Not the one the IDE defaulted to. In
Arduino IDE that is Tools → Board; with ESP-IDF,
idf.py set-target esp32s3followed by a clean build. - Flash at the right offset per cause 2, at 115200 if you have any doubt about the cable.
- Watch the reboot. A healthy boot shows the ROM banner, then a
ESP-ROM:.../entry 0x...line, then your own output.
erase_flash itself fails with a connection error, the problem
is the serial link rather than the image: wrong port, a driver missing for the
CP210x/CH340 bridge, another program holding the port open, or the board not being in
download mode.Stop it happening again
The structural fix is to make the build target and the connected board verify each other rather than trusting yourself to remember. Whatever you build with:
- Run
chip_idbefore a flash in any script that does unattended flashing, and fail the script when it disagrees with the build target. - Let
esptoolauto-detect. If you omit--chip, it detects the connected part — and refuses to write an image whose header targets a different one. Pinning--chipto the wrong value is how that safety net gets disabled. - Keep one board per build directory when you work with several ESP32 variants. Most of these incidents are a stale target left over from the last project.
For what it is worth, this is a check we added to ESP-GenUI after hitting
it ourselves: before writing anything, it reads the chip ID out of the image header
(the 0xE9 magic and the +12 field described above), compares it
to the chip reported by the connected board, and blocks the flash on a mismatch rather
than letting you discover it from the serial console afterwards. The technique is not
specific to our tool — it is twenty lines in any language, and worth adding to your
own flashing scripts.