ESP32 Deep Dive

SLIP Wire · Stub Loader · Flash Geometry · eFuse · JTAG
esptool / espefuse / espsecure / OpenOCD // Boot ROM to Brick Recovery // classic · S2 · S3 · C3 · C5 · C6 · H2 · P4

Beyond the Datasheet

Most people interact with ESP32 chips through one command: esptool.py write_flash. It works, the LED blinks, you move on. This document is for when it doesn't work, or when you need to do something more interesting than blink the LED, or when you want to understand why an ESP32-C3 has its bootloader at 0x0 while ESP32-classic has it at 0x1000.

Scope: ESP32 family chips (classic, S2, S3, C3, C5, C6, H2, P4) talking to a host computer over UART or native USB. Reference tooling is esptool.py v4.x, espefuse.py, espsecure.py, and the ESP-IDF wrapper idf.py. Where chip variants differ, called out explicitly.

How to read this Each section is independent. If you only have 10 minutes and a brick on your desk, read Boot Modes and Brick Recovery. If you're debugging upload failures, read SLIP Wire and Stub Loader. If you're chasing a flash size mismatch, read Flash Geometry. The diagrams are the load-bearing part, not the prose.

Boot Modes & Strap Pins

Every ESP32 chip samples a handful of GPIO pins in the first few microseconds after reset comes high. Those samples decide whether the chip boots from flash, enters the UART download bootloader, or does something weird. If you have flashing problems and the wiring looks right, it's almost always strap pins.

The strap pins per chip

Chip Boot strap (LOW = download) Other straps
ESP32 GPIO0 GPIO2 (must be low/floating), GPIO12 MTDI (VDD_SDIO 1.8/3.3v select), GPIO15 MTDO (silences boot log if low)
ESP32-S2 GPIO0 GPIO45 (VDD_SPI), GPIO46 (must be low at reset)
ESP32-S3 GPIO0 GPIO45 (VDD_SPI), GPIO46 (ROM print on UART0)
ESP32-C3 GPIO9 GPIO8 (ROM print log enable), GPIO2 (VDD_SPI)
ESP32-C6 GPIO9 GPIO8 (ROM log), GPIO15 (JTAG select)
ESP32-H2 GPIO9 GPIO8 (ROM log), GPIO25 (JTAG)
ESP32-C5 GPIO28 (low = download); 3-pin code GPIO26/27/28 GPIO27 (ROM log), GPIO7 (JTAG select — no internal pull, must be driven). Download Boot 0 (USB/UART/SPI): GPIO28=0, GPIO27=1. Download Boot 1 (UART/SDIO): GPIO27=0, GPIO28=0.
ESP32-P4 GPIO35 (low = download; pull-up default); 4-pin code GPIO35/36/37/38 GPIO36 (ROM log), GPIO34 (JTAG select — no internal pull, must be driven). No RF (no WiFi/BT); 16/32 MB PSRAM integrated in package. USB 2.0 HS OTG + USB Serial/JTAG.
C5 and P4 use multi-pin boot codes Unlike the classic/S/C3/C6/H2 chips where a single GPIO (0 or 9) low = download mode, the C5 and P4 use a 3- and 4-pin strapping code respectively. For C5: GPIO28 low triggers download mode; GPIO27 selects between Download Boot 0 (USB Serial/JTAG + UART) and Download Boot 1 (UART + SDIO). For P4: GPIO35 low + GPIO36 high = Joint Download Boot (USB OTG FS + UART + SPI Slave). Neither C5 nor P4 has the auto-reset transistor circuit on most dev boards — check your specific module.
ESP32-P4 is a different animal P4 is a high-performance application MCU, not a connectivity SoC. It has no built-in RF (no WiFi, no Bluetooth, no 802.15.4) and is designed to pair with a C5 module for wireless. It runs a dual-core RISC-V at 400 MHz, has 768 KB of L2 RAM, and ships with 16 or 32 MB of PSRAM sealed in the package. The strap pins are on GPIO34–38, nowhere near GPIO0 or GPIO9. Its USB Download Boot goes through the USB OTG HS controller at FS speed — not through UART and not through the USB Serial/JTAG bridge used for debugging.

Boot strap is sampled at the rising edge of CHIP_PU (also called EN or RST). The sample is latched: changing the pin after the chip has come out of reset does nothing for boot mode. To re-enter download mode you must reset again.

The auto-reset circuit

Dev boards like NodeMCU, Wemos, and ESP32-DevKitC all carry the same little NPN-transistor circuit so esptool can flash without you touching a button. It uses the two RS-232 modem control lines that USB-serial chips expose: DTR and RTS. The trick is that the transistors invert the lines, and they're wired so that asserting DTR alone or RTS alone does nothing.

    USB-serial            transistor pair             ESP32
    ──────────            ───────────────             ─────

      DTR  ────┬─────┐                                EN
                │     │ Q1                              ▲
                │     ├──── BASE                        │
                │     │                                 │
      RTS  ──┐ │  ┌──┴── BASE  Q2                    GPIO0
              │ │  │                                    ▲
              └─┼──┘                                    │
                │                                       │
                └───────────────────────────────────────┘

    Truth table  (DTR/RTS at the chip pin, after inversion)

      DTR=H  RTS=H   →   EN=H, GPIO0=H   // normal run
      DTR=L  RTS=L   →   EN=H, GPIO0=H   // normal run
      DTR=H  RTS=L   →   EN=L, GPIO0=H   // reset only
      DTR=L  RTS=H   →   EN=H, GPIO0=L   // boot only

The cross-wiring is intentional. Each line by itself does nothing useful: only the transition from DTR=H,RTS=L (reset asserted) to DTR=L,RTS=H (reset released, GPIO0 still pulled low) actually triggers download mode. Meanwhile RTS=H,DTR=H at idle keeps both pins high so the chip boots normally. This is why opening the port in a normal terminal doesn't accidentally reset the chip.

esptool reset sequences

esptool exposes the reset behaviour through --before and --after. Each name corresponds to a specific dance on DTR/RTS.

Sequence What it does When to use
default_reset Classic reset: DTR low, RTS high, sleep, swap, sleep, release. Drives chip into download mode. Default --before. Works on every dev board with the auto-reset circuit.
usb_reset USB-CDC reset: hits DTR/RTS through the chip's native USB stack rather than through external transistors. S2/S3/C3 USB-OTG and USB-Serial-JTAG when no transistor circuit exists.
no_reset Skip the dance entirely. Talk directly to whatever's already in download mode. Custom hardware with a manual button, or chips already held in bootloader.
no_reset_no_sync Skip dance and skip the SYNC handshake too. Used when caller already synced. Chained tool flows.
hard_reset After-flash: pulse EN low to reboot the chip cleanly into the new firmware. Default --after. Almost always what you want.
soft_reset Send a soft-reset command to the stub loader; chip jumps to user code without toggling EN. Rare. Useful when EN is hard-wired and you can't toggle it.
no_reset_stub Leave the stub running on the chip after we're done. Chained workflows where the next tool wants the stub already up.
The classic GPIO12 brick On classic ESP32 with embedded flash, GPIO12 (MTDI) is sampled at boot to decide whether VDD_SDIO is 1.8V or 3.3V. If your application code sets GPIO12 high during normal operation and you don't burn the eFuse to lock the strap, the next reset reads VDD_SDIO as 1.8V and the flash chip browns out. Symptom: boots fine while powered, dies after first reset. Cure: espefuse.py set_flash_voltage 3.3V.

Manual entry into download mode

When the auto-reset circuit isn't there, doesn't work, or you've cut its traces, you do it by hand. The sequence on every ESP32 variant is the same:

  1. Hold BOOT Press and hold the boot button (GPIO0 on classic / S2 / S3, GPIO9 on RISC-V variants).
  2. Pulse EN While still holding BOOT, press and release the EN/RST button. Some boards need you to hold EN for a half-second.
  3. Release BOOT Wait at least 100ms after releasing EN, then release BOOT.
  4. Verify Open a terminal at 115200 baud. The ROM bootloader on classic ESP32 prints waiting for download. RISC-V variants print nothing by default but respond to esptool.py chip_id.

SLIP Wire Protocol

esptool talks to the ROM bootloader using a binary command protocol over UART, framed with SLIP (Serial Line Internet Protocol, RFC 1055). Reading a SLIP-framed esptool exchange from a logic analyzer is one of the fastest ways to understand what's happening when uploads fail.

SLIP framing rules

Each packet is delimited by the 0xC0 byte (END). Inside a packet, two bytes need escaping: the END byte itself becomes 0xDB 0xDC, and the escape byte 0xDB becomes 0xDB 0xDD.

slip framing
// raw payload
A1 B2 C0 D4 DB F6

// after slip escaping (with END framing)
C0 A1 B2 DB DC D4 DB DD F6 C0
^^      ^^^^^      ^^^^^      ^^
|       escape     escape    end
start   for C0     for DB    delimiter

Decode is the reverse: read until you see an unescaped C0, then walk the buffer replacing escape pairs back to their original bytes. Because escape bytes can never appear unescaped inside a packet, framing is unambiguous even if you join mid-stream and miss the start.

Command packet structure

esptool command packet (pre-slip-encoding)
 0       1       2       3       4       5       6       7
+-------+-------+-------+-------+-------+-------+-------+-------+
| DIR   | CMD   | SIZE (LE)     | CHECKSUM (LE32)             |
+-------+-------+-------+-------+-------+-------+-------+-------+
| DATA ...                                                       |
+---------------------------------------------------------------+

  DIR      0x00 = request, 0x01 = response
  CMD      one of the command opcodes (table below)
  SIZE     uint16, little-endian, length of DATA in bytes
  CHECKSUM uint32, little-endian, XOR-fold of DATA bytes with seed 0xEF
           (only used by FLASH_DATA / MEM_DATA; ignored otherwise)
  DATA     command-specific payload

// the entire structure above is then SLIP-framed before transmit

Command opcodes

Code Name Available in Purpose
0x02FLASH_BEGINROM + stubBegin flash write: erase region, set up state.
0x03FLASH_DATAROM + stubOne block of flash data (typically 4KB).
0x04FLASH_ENDROM + stubEnd flash session, optional reboot flag.
0x05MEM_BEGINROM + stubBegin RAM upload: where to load and how big.
0x06MEM_ENDROM + stubEnd RAM upload, optional jump to entry point.
0x07MEM_DATAROM + stubOne block of RAM data.
0x08SYNCROM + stubInitial handshake. Sent 5x with timing.
0x09WRITE_REGROM + stubWrite a 32-bit memory-mapped register.
0x0AREAD_REGROM + stubRead a 32-bit memory-mapped register.
0x0BSPI_SET_PARAMSROM + stubTell SPI controller flash size + page/sector geometry.
0x0DSPI_ATTACHROM + stubConfigure SPI pins (relevant for octal/quad PSRAM combos).
0x0FCHANGE_BAUDRATEstub onlySwitch UART baud mid-session.
0x10FLASH_DEFL_BEGINstub onlyBegin compressed flash write (zlib).
0x11FLASH_DEFL_DATAstub onlyOne block of deflate-compressed data.
0x12FLASH_DEFL_ENDstub onlyEnd compressed flash session.
0x13SPI_FLASH_MD5stub onlyCompute MD5 over a flash region; used by verify_flash.
0x14ERASE_FLASHstub onlyWhole-chip erase via SPI flash command 0xC7.
0x15ERASE_REGIONstub only4KB-aligned sector erase loop.
0x16READ_FLASHstub onlyStream a flash region back to host.

The SYNC handshake

Every esptool session starts with sync. The host sends a fixed 36-byte SYNC payload up to seven times (with retries on no response). The chip echoes back, and from the echo esptool fingerprints which chip variant it's talking to.

sync payload
// 36 bytes total, sent as the DATA of a SYNC command (0x08)
07 07 12 20                                       // magic prefix
55 55 55 55 55 55 55 55 55 55 55 55 55 55 55 55   // 32 bytes of 0x55
55 55 55 55 55 55 55 55 55 55 55 55 55 55 55 55

// timing
// host sleeps ~100ms between sync attempts to let chip latch baud rate
// chip echoes empty SYNC response when it accepts

The eight 0x55 bytes (binary 01010101) are deliberate: alternating bits help a UART auto-baud routine lock onto the host's baud rate. ESP32 ROMs do auto-baud detection on the first SYNC, which is why esptool can connect at any rate from 9600 up to 115200 without telling the chip in advance.

"Failed to connect" is almost always strapping If sync fails for seven retries, esptool gives up with the famous A fatal error occurred: Failed to connect. The host ran out of patience waiting for an echo. The chip never entered download mode, or the auto-reset circuit fired but didn't actually drive the right pins, or the USB-serial chip is so jittery that the SYNC bytes corrupt before the chip's UART can lock baud. Try lower baud (--baud 115200), then manual button entry, then a different USB cable.

A complete sync + chip_id capture

logic analyzer view, host → chip in green
// host sends SYNC
C0 00 08 24 00 00 00 00 00 07 07 12 20 55 55 55 55 ... 55 C0
                                    ^ data: 36 bytes

// chip responds (echoes 8 trailing 0x55, status 0/0)
C0 01 08 04 00 00 00 00 00 00 00 00 00 C0

// host sends READ_REG against EFUSE_BLK0_RDATA3 (0x3FF5A00C on classic)
C0 00 0A 04 00 00 00 00 00 0C A0 F5 3F C0
                                    ^ register address, LE32

// chip responds with the register value (32-bit LE)
C0 01 0A 04 00 A4 7C 24 BC 00 00 00 00 C0
                  ^ value goes in the "value" field, not data

READ_REG and WRITE_REG don't use the data field for results. The 32-bit register value comes back in what would be the checksum slot of the response packet. Awkward, but that's how the protocol grew.

The Stub Loader

The ROM bootloader on every ESP32 chip is fixed silicon-or-mask-ROM and can't be patched. It's also pretty minimal: no compression, no faster baud, no MD5 verification, slow flash writes. To work around this, esptool uploads a small program to RAM at the start of every session called the stub loader. The stub re-implements the same protocol but with extra commands and fewer bugs.

Stub upload sequence

    HOST                                        CHIP (ROM)
                                                  
        SYNC ──────────────────────────────────▶ 
        ◀──────────────────────────────── ack    
                                                  
        MEM_BEGIN(addr=stub.text_addr,            
                  size=stub.text_size,            
                  blocks=N, blocksize=4096) ────▶ 
        ◀──────────────────────────────── ack    
                                                  
        MEM_DATA(seq=0, payload=text_block_0) ─▶ 
        MEM_DATA(seq=1, payload=text_block_1) ─▶ 
        ...                                       
                                                  
        MEM_BEGIN(addr=stub.data_addr, ...) ────▶ 
        MEM_DATA(seq=0, payload=data_block_0) ─▶ 
        ...                                       
                                                  
        MEM_END(entry=stub.entry_addr) ────────▶ 
                                                    jump to entry
                                                    STUB now running
        ◀──── 4 bytes "OHAI" over UART            prints OHAI
                                                  
        CHANGE_BAUDRATE(921600) ────────────────▶ 
        ◀───────────────────────────────── ack    
                                                  
        FLASH_DEFL_BEGIN / FLASH_DEFL_DATA ─────▶   fast path now

The OHAI string is literally that: four bytes 0x4F 0x48 0x41 0x49, sent unsolicited by the stub on entry. esptool reads four bytes and either matches the magic and continues, or assumes the upload failed.

What the stub adds

Feature ROM only With stub
Max baud115200 (auto-baud limit)921600+ via CHANGE_BAUDRATE
Compressed flashnoyes (zlib via FLASH_DEFL_*)
Flash MD5noyes (SPI_FLASH_MD5)
Read flashnoyes (READ_FLASH streams blocks)
Write throughput~22 KB/s typical200-300 KB/s typical
Block bufferingflush per blockqueue blocks while previous flushes

Where the stub lives in RAM

Stub addresses are chip-specific because each variant has different IRAM/DRAM layouts and the stub has to fit somewhere that won't collide with the ROM bootloader's own variables. esptool ships pre-compiled stubs (one per chip) embedded as hex blobs in the source.

Chip Stub text load Stub data load Source location
ESP320x4009_00000x3FFE_8000flasher_stub/stub_flasher_chip.bin
ESP32-S20x4002_40000x3FFE_8000same dir, different binary
ESP32-S30x4037_80000x3FCB_8000same dir
ESP32-C30x4038_00000x3FCD_C000same dir
ESP32-C60x4080_80000x4082_8000same dir

Skipping the stub

Two situations want raw ROM mode: when the stub itself is misbehaving (rare, but happens on early production chips and on some clones), and when you're testing pure ROM bootloader behavior. Pass --no-stub to esptool. You lose compression, MD5, fast baud, and read_flash. Most operations still work, just slower.

stub vs no-stub
esptool.py --port /dev/ttyUSB0 write_flash 0x10000 firmware.bin
# default: uploads stub, uses compressed write at 460800 baud

esptool.py --no-stub --port /dev/ttyUSB0 write_flash 0x10000 firmware.bin
# raw ROM: uncompressed, 115200 baud max, no md5 verify after
The "Stub running..." line that hides errors esptool prints Uploading stub... then Stub running... as if these are sequential certainties. They're not. If MEM_END fired but OHAI never came back, the stub crashed or didn't upload cleanly, and esptool reports a generic error two seconds later. When you see weird timeouts immediately after stub upload, retry with --no-stub to confirm the ROM path works. If ROM works and stub doesn't, you have a flaky USB connection or marginal power supply.

Flash Geometry

Where things live on the SPI flash chip is one of the most chip-variant-specific aspects of the ESP32 family. The flash itself is a generic 25-series SPI chip (Winbond, GigaDevice, ISSI, XMC), but the address at which the bootloader expects to start is hardcoded into the silicon ROM. Putting the bootloader at the wrong offset means a chip that successfully accepts the write but never boots.

Bootloader offset by chip

Chip Bootloader offset Why
ESP320x1000First 4KB of flash is reserved for the secure boot V1 verifier digest area; bootloader proper starts at 0x1000.
ESP32-S20x1000Same reservation as classic.
ESP32-S30x0000Newer ROM does not require the 4KB reservation; bootloader can start at the very front.
ESP32-C30x0000RISC-V chips skip the reservation entirely.
ESP32-C60x0000Same.
ESP32-H20x0000Same.
ESP32-P40x2000Bigger reserved area for the high-perf SoC's eFuse/digest space.

Standard ESP-IDF layout

classic ESP32, default 4MB flash
0x000000  ┌─────────────────────────────────┐  // reserved (secure boot digest area)
          │  empty / digest                 │
0x001000  ├─────────────────────────────────┤
          2nd-stage bootloader  // bootloader.bin (~25KB)
          │                                 │
0x008000  ├─────────────────────────────────┤
          partition table  // 4KB, ends with MD5 entry
0x009000  ├─────────────────────────────────┤
          nvs (data/wifi)                │  // 24KB key-value store
0x00f000  ├─────────────────────────────────┤
          phy_init  // 4KB RF calibration
0x010000  ├─────────────────────────────────┤
          │                                 │
          factory app (or ota_0)          │  // 1MB+, your code
          │                                 │
0x110000  ├─────────────────────────────────┤
          ota_1 (if dual-OTA layout)      │  // matches ota_0 size
          │                                 │
0x210000  ├─────────────────────────────────┤
          spiffs / littlefs / userdata    │
          │                                 │
0x3FF000  ├─────────────────────────────────┤
          otadata  // 8KB, holds active OTA slot
0x400000  └─────────────────────────────────┘  // end of 4MB flash

The partition table

At 0x8000 (default; configurable in menuconfig) lives a 4KB partition table. It's a list of 32-byte entries, terminated by an MD5-checksum entry. ESP-IDF reads it on boot to figure out where every other partition lives.

partition table entry layout
 0       1       2       3       4       5       6       7
+-------+-------+-------+-------+-------+-------+-------+-------+
| MAGIC (LE16)  | TYPE  | SUB   | OFFSET (LE32)             |
+-------+-------+-------+-------+-------+-------+-------+-------+
| SIZE (LE32)                  | LABEL (16 bytes, null-padded)
+-------+-------+-------+-------+-------+-------+-------+-------+
                                | FLAGS (LE32)              |
                                +-------+-------+-------+-------+

  MAGIC   0xAA50  (regular entry) or 0xEBEB (md5 trailer)
  TYPE    0x00 = app, 0x01 = data, 0x40-0xFE = custom
  SUB     subtype within type. app: 0x00=factory, 0x10/0x11=ota_0/1
                              data: 0x00=otadata, 0x01=phy, 0x02=nvs,
                                    0x82=spiffs, 0x83=littlefs
  FLAGS   bit 0 = encrypted, bit 1 = readonly

Reading the partition table from a running chip

dump partition table
esptool.py --port /dev/ttyUSB0 read_flash 0x8000 0x1000 partitions.bin
parttool.py -q get_partition_info --partition-table-file partitions.bin --info name offset size

# or via the IDF python helper
python $IDF_PATH/components/partition_table/gen_esp32part.py partitions.bin

# typical output
# nvs,data,nvs,0x9000,24K,
# phy_init,data,phy,0xf000,4K,
# factory,app,factory,0x10000,1M,
# spiffs,data,spiffs,0x110000,1M,

Flash mode, frequency, and size

These three parameters live in the bootloader image header and tell the ROM how to talk to the SPI flash chip. Wrong values are the second-most-common cause of "boots once, won't boot again" symptoms (the first is bad partition tables).

Parameter Values Meaning
--flash_mode qio, qout, dio, dout, opi How many SPI lines and which direction. qio = quad in/out (4 data lines), dio = dual in/out (2). Octal (opi) is S3-only and needs a compatible flash chip.
--flash_freq 20m, 26m, 40m, 80m, 120m SPI clock. 40m is the safe default. 80m needs a flash chip rated for it. 120m is S3 + octal flash.
--flash_size 1MB, 2MB, 4MB, 8MB, 16MB, 32MB, 64MB, 128MB Total chip capacity. Wrong value here means esptool refuses to write past where it thinks the chip ends.
setting flash params at write time
esptool.py --chip esp32 --port /dev/ttyUSB0 write_flash -z \
  --flash_mode dio --flash_freq 40m --flash_size 4MB \
  0x1000  bootloader.bin \
  0x8000  partitions.bin \
  0x10000 firmware.bin

# detect what the flash chip says about itself
esptool.py flash_id
# Manufacturer: ef    (Winbond)
# Device:       4017  (W25Q64)
# Detected flash size: 8MB

Read, Write & Verify

The everyday operations. The flags below mostly compose: --port, --baud, --chip, --before, --after apply to all of them.

Common flags

Flag Default Notes
--port / -pauto-detectSerial device. Linux: /dev/ttyUSB0, /dev/ttyACM0. macOS: /dev/cu.usbserial-XXXX. Windows: COM5.
--baud / -b115200Initial baud. Stub bumps to 460800 unless overridden. Some CH340 clones max at 921600; FTDI does 3M.
--chip / -cautoesp32, esp32s2, esp32s3, esp32c3, esp32c6, esp32h2, esp32p4. Auto works but is slower because it has to fingerprint.
--connect-attempts7How many times to retry SYNC before giving up.
--traceoffPrint every byte going in and out of the serial port. Indispensable for debugging.

chip_id, flash_id, read_mac

identification commands
esptool.py chip_id
# Chip is ESP32-D0WD-V3 (revision v3.0)
# Features: WiFi, BT, Dual Core, 240MHz, VRef calibration in efuse
# Crystal is 40MHz
# MAC: 24:6f:28:ab:cd:ef

esptool.py flash_id
# Manufacturer: ef
# Device:       4016
# Detected flash size: 4MB

esptool.py read_mac
# MAC: 24:6f:28:ab:cd:ef
# BASE MAC, WIFI_STA, WIFI_AP, BT, ETH all derived

Each ESP32 has a single 48-bit base MAC burned into eFuse at the factory. The four interface MACs (Wi-Fi STA, Wi-Fi AP, Bluetooth, Ethernet) are derived deterministically from base MAC plus a small offset. Don't randomize them; they're meant to be unique per silicon.

write_flash

write_flash with all the flags
esptool.py --chip esp32 --port /dev/ttyUSB0 --baud 921600 write_flash \
  -z                          # compress with zlib (stub only)
  --flash_mode dio              # SPI mode
  --flash_freq 40m              # SPI clock
  --flash_size detect           # let chip report
  --erase-all                  # erase whole chip first (optional)
  --verify                      # MD5 check after write
  --encrypt                     # encrypt during write (flash encryption enabled chips)
  --ignore-flash-encryption-efuse-setting   # override safety check
  0x1000  bootloader.bin
  0x8000  partitions.bin
  0x10000 firmware.bin
  0x310000 spiffs.bin

The pairs of address file at the end can repeat as many times as you want. esptool sorts them by address before sending and writes each region with FLASH_BEGIN / FLASH_DATA / FLASH_END (or the DEFL variants under -z).

read_flash

read flash to file
# dump entire 4MB flash
esptool.py read_flash 0 0x400000 dump.bin

# dump just the partition table
esptool.py read_flash 0x8000 0x1000 parts.bin

# dump the running firmware partition (find offset+size from partition table first)
esptool.py read_flash 0x10000 0x100000 app.bin

# skip the read of unprogrammed (0xFF) sectors to save time
esptool.py read_flash 0 ALL flash_dump.bin
# "ALL" reads up to the detected flash size

verify_flash

verify without rewriting
esptool.py verify_flash 0x10000 firmware.bin
# Verifying 0x100000 (1048576) bytes @ 0x00010000 in flash against firmware.bin...
# -- verify OK (digest matched)

# mismatch case
# -- verify FAILED: 0x40 (64) bytes differ at @ 0x00012E40
# first byte differs: file=0xA1 flash=0xFF

verify_flash uses the SPI_FLASH_MD5 stub command to ask the chip for an MD5 hash of a region, then compares against an MD5 it computes locally over the file. No data is read back, just the digest. This is much faster than read-then-compare for large regions.

erase_flash, erase_region

erasing
# whole-chip erase (issues SPI command 0xC7)
esptool.py erase_flash
# takes 8-30s depending on flash size

# just the NVS partition (24KB starting at 0x9000)
esptool.py erase_region 0x9000 0x6000
# both args must be 4KB-aligned (one sector)

# nuke OTA data so chip falls back to factory
esptool.py erase_region 0xd000 0x2000
The "won't connect after erase" gotcha erase_flash wipes everything including the bootloader. After it finishes, the chip has nothing to boot. esptool.py chip_id still works because that hits the ROM, not flash. But power-cycle and the chip prints garbage on the UART (the ROM trying to verify a bootloader that's all 0xFF). Always have a fresh bootloader+app+partitions ready to write before you erase.

Image Format & elf2image

The .bin file you flash is not just a copy of your .elf. It's a structured container with a header, a list of segments, and a checksum (and on newer chips, a SHA-256). The conversion happens via esptool.py elf2image, which the IDF build system runs automatically.

Image header layout

esp32 firmware image header (24 bytes)
 0       1       2       3       4       5       6       7
+-------+-------+-------+-------+-------+-------+-------+-------+
| MAGIC | SEG#  | FMODE | FSF   | ENTRY POINT (LE32)        |
+-------+-------+-------+-------+-------+-------+-------+-------+
| WP_PIN| CLK_DRV / Q_DRV ...        | CHIP_ID (LE16) |       |
+-------+-------+-------+-------+-------+-------+-------+-------+
| MIN_REV...                | MAX_REV ...                | RESVD |
+-------+-------+-------+-------+-------+-------+-------+-------+
| HASH_APPENDED                                                  |
+-------+

  MAGIC          0xE9
  SEG#           number of segments that follow
  FMODE          flash mode (0=qio, 1=qout, 2=dio, 3=dout, 4=opi)
  FSF            high nibble = flash size (0=1MB, 1=2MB, ..., 4=16MB)
                 low nibble  = flash freq (0=40m, 1=26m, 2=20m, 0xF=80m)
  ENTRY POINT    32-bit jump address after image is loaded
  CHIP_ID        0=ESP32, 2=S2, 5=C3, 9=S3, 12=C2, 13=C6, 16=H2, ...
  HASH_APPENDED  1 = SHA-256 follows after last segment

Segment layout

segment (variable length)
+-------+-------+-------+-------+
| LOAD ADDR (LE32)            |
+-------+-------+-------+-------+
| DATA LEN (LE32)             |
+-------+-------+-------+-------+
| DATA bytes ...                              |
+--------------------------------+

  // repeats SEG# times
  // padded so total image length is multiple of 16 bytes
  // 1-byte XOR checksum at the next 16-byte boundary - 1
  // (last byte of the padded image)
  // then optional 32-byte SHA-256 if HASH_APPENDED
  // then optional signature block if signed (secure boot)

elf2image conversion

elf2image
esptool.py --chip esp32 elf2image \
  --flash_mode dio --flash_freq 40m --flash_size 4MB \
  --min-rev 0 --max-rev 0x100 \
  -o firmware.bin firmware.elf

# inspect the resulting image
esptool.py image_info firmware.bin
# File size: 1234560 bytes
# Image version: 1
# Entry point: 0x40080a44
# Segments: 5
# Segment 1: len 0x024df0 load 0x3f400020 file_offs 0x00000018 [DROM]
# Segment 2: len 0x00400 load 0x3ffb0000 file_offs 0x00024e10 [BYTE_ACCESSIBLE,DRAM]
# Segment 3: len 0x094a0 load 0x3ffb0400 file_offs 0x00025218 [DRAM]
# Segment 4: len 0x06bcc load 0x40080000 file_offs 0x0002e6c0 [IRAM]
# Segment 5: len 0x1604c load 0x400d0020 file_offs 0x00035294 [IROM]
# Checksum: 0xa1 (valid)
# Validation hash: SHA256 fb29... (valid)

Memory regions in segment names

Region Address (classic) Purpose
IROM0x400D_0000 ...Instruction-fetched-from-flash, mapped via flash cache.
DROM0x3F40_0000 ...Data-fetched-from-flash (constants, strings).
IRAM0x4008_0000 ...Internal SRAM mapped for instruction fetch. Fast, used for ISRs.
DRAM0x3FFB_0000 ...Internal SRAM mapped as data. The heap lives here.
RTC_IRAM0x400C_0000 ...Survives deep sleep on classic ESP32.
RTC_DRAM0x3FF8_0000 ...Survives deep sleep, holds RTC variables.

merge_bin

For factory programmers, OTA cloud uploads, or anywhere that wants a single contiguous image, merge_bin stitches the bootloader, partition table, app, and any other regions into one file you can flash at offset 0 (or wherever the chip's bootloader expects).

merge_bin
esptool.py --chip esp32 merge_bin -o merged.bin \
  --flash_mode dio --flash_freq 40m --flash_size 4MB \
  0x1000  bootloader.bin \
  0x8000  partitions.bin \
  0x10000 app.bin

# now flash the single file at offset 0
esptool.py write_flash 0x0 merged.bin

# or pad to full flash size (good for production imaging)
esptool.py merge_bin -o factory.bin --fill-flash-size 4MB \
  0x1000 bootloader.bin 0x8000 partitions.bin 0x10000 app.bin
image_info on a stripped binary Run esptool.py image_info file.bin on any unknown ESP32 binary you have lying around. If the magic byte at offset 0 is 0xE9, esptool can decode the segments and tell you which chip variant the image targets, what the entry point is, and whether the SHA-256 / signature checks pass. This is faster than running file or strings when triaging a folder of mystery firmware.

eFuse & Security

eFuses are one-time-programmable (OTP) bits inside every ESP32 chip. They store the factory MAC address, the chip revision, calibration values, and a small set of user-burnable security keys and flags. Once a fuse is burned (set to 1), it cannot be unburned. Misusing espefuse.py is a fast way to brick a chip permanently.

eFuse layout

Block Bits Contents (classic ESP32)
BLK0256Factory MAC, chip rev, package, ADC calibration, user-config flags (UART_DOWNLOAD_DIS, JTAG_DISABLE, etc.)
BLK1256Flash encryption key (256-bit AES)
BLK2256Secure boot V1 key
BLK3256User block (custom data, customer ID)

S2/S3/C3/etc. expand this to BLK0-BLK10 with multiple key slots, key-purpose bits, and dedicated digest blocks. The principle is the same: read-or-write protectable banks of OTP bits.

espefuse.py

summary: read every eFuse
espefuse.py --port /dev/ttyUSB0 summary
### EFUSE_BLK0 fields ###
# MAC                : 24:6f:28:ab:cd:ef
# CHIP_VER_REV1      : 1
# CHIP_VERSION       : 0
# CHIP_PACKAGE       : 1
# UART_DOWNLOAD_DIS  : False  R/W (0b0)
# DISABLE_JTAG       : False  R/W (0b0)
# FLASH_CRYPT_CNT    : 0      R/W (0b0000000)
# ABS_DONE_0         : False  R/W (0b0)   // secure boot v1 enable
# ...
burn fuses (DESTRUCTIVE - irreversible)
# burn an arbitrary user fuse
espefuse.py burn_efuse WR_DIS 0x1

# burn a key into a key block
espefuse.py burn_key BLOCK1 flash_encryption_key.bin

# burn flash encryption key + enable + lock down (full deployment)
espefuse.py burn_key BLOCK1 flash_enc_key.bin
espefuse.py burn_efuse FLASH_CRYPT_CNT 0x7
espefuse.py burn_efuse FLASH_CRYPT_CONFIG 0xF
espefuse.py write_protect_efuse FLASH_CRYPT_CNT  # can no longer disable

# raw 256-bit data into a user block
espefuse.py burn_block_data BLOCK3 customer_data.bin

# lock specific fuses against further changes
espefuse.py read_protect_efuse BLOCK1   # key can never be read out
espefuse.py write_protect_efuse BLOCK1  # key can never be re-written
The fuses you regret UART_DOWNLOAD_DIS permanently kills the ROM UART download path. DISABLE_DL_ENCRYPT + DISABLE_DL_DECRYPT on a flash-encrypted chip means you can no longer flash plaintext images, ever. JTAG_DISABLE kills hardware debugging. Burning FLASH_CRYPT_CNT to 0x7F with no key burned makes the chip try to decrypt zeros and refuse to boot. Always read espefuse.py summary twice before burn_efuse, and always test on a sacrificial chip first.

espsecure.py

Where espefuse deals with the chip side of security, espsecure is the host-side counterpart: it generates keys, signs images, and prepares encrypted payloads.

secure boot v2 workflow
# generate signing key (RSA-3072 for V2, ECDSA also supported)
espsecure.py generate_signing_key --version 2 secure_boot_signing_key.pem

# compute the public key digest (this is what gets burned to eFuse)
espsecure.py digest_sbv2_public_key \
  --keyfile secure_boot_signing_key.pem \
  --output sb_public_digest.bin

# sign an application image
espsecure.py sign_data --version 2 \
  --keyfile secure_boot_signing_key.pem \
  --output firmware_signed.bin firmware.bin

# verify a signed image (no chip needed)
espsecure.py verify_signature --version 2 \
  --keyfile secure_boot_signing_key.pem firmware_signed.bin
flash encryption workflow
# generate AES-256 key
espsecure.py generate_flash_encryption_key --keylen 256 fe_key.bin

# encrypt a binary using that key (offset matters - tweak depends on it)
espsecure.py encrypt_flash_data \
  --keyfile fe_key.bin \
  --address 0x10000 \
  --output firmware_enc.bin firmware.bin

# decrypt for inspection
espsecure.py decrypt_flash_data \
  --keyfile fe_key.bin \
  --address 0x10000 \
  --output firmware_dec.bin firmware_enc.bin

Secure boot V1 vs V2

Property V1 (classic ESP32 only) V2 (S2/S3/C3/C6/H2 + classic v3)
AlgorithmAES-256 ECB + SHA-256 digestRSA-PSS-3072 or ECDSA-P256
Key in eFuse256-bit symmetric (BLK2)SHA-256 digest of public key (1-3 slots)
Key revocationimpossible (single key, burned forever)up to 3 slots, individually revocable
Image trailer32-byte digestsignature block (1216 bytes per signature)
Recommendationdo not use on new designsrequired for production

ESP-IDF: idf.py

Before idf.py, an ESP-IDF build was a CMake invocation, a Python script to merge images, and a separate esptool call. The wrapper folds all of it into one CLI. Under the hood it still calls esptool; you can see the exact command with -v.

Lifecycle commands

Command What it does
idf.py set-target esp32s3Configure project for chip variant. Wipes build/, regenerates sdkconfig.
idf.py menuconfigncurses configurator over Kconfig. Edits sdkconfig.
idf.py reconfigureRe-run cmake without rebuilding everything. Used after git pull on IDF itself.
idf.py buildCompile, link, generate .bin, image_info. Cached via ninja.
idf.py appBuild only the app, not bootloader and partition table.
idf.py cleanRemove built artifacts but keep config.
idf.py fullcleanNuke build/ entirely. Required after some IDF version bumps.
idf.py -p PORT flashRun esptool to write all three images (bootloader, partitions, app).
idf.py -p PORT app-flashFlash only the app partition. Faster iteration when bootloader unchanged.
idf.py -p PORT erase-flashWrapper for esptool.py erase_flash.
idf.py -p PORT monitorOpen the IDF monitor (smart serial terminal with crash decoding).
idf.py -p PORT flash monitorThe combo. Flash, then immediately attach monitor.
idf.py sizePrint size of static + heap regions used by the linked image.
idf.py size-componentsPer-component size breakdown.
idf.py size-filesPer-object-file size breakdown. Most useful for hunting bloat.
idf.py partition-tablePrint the active partition table CSV.
idf.py partition-table-flashFlash only the partition table.
idf.py coredump-infoDecode a core dump produced by IDF's panic handler.
idf.py gdb / openocdLaunch xtensa-esp32-elf-gdb attached to OpenOCD with the right scripts.

idf.py monitor: more than a terminal

The monitor is not just screen. It pipes the serial stream through addr2line so that addresses in panic backtraces become file:line references, decodes coredumps if you point it at the elf, recognizes esp_log color codes, and lets you trigger flash/build cycles without quitting.

monitor key bindings
Ctrl+T  Ctrl+H  help screen
Ctrl+T  Ctrl+R  reset chip (hardware EN pulse via DTR)
Ctrl+T  Ctrl+F  build & flash app, then resume monitoring
Ctrl+T  Ctrl+A  build & flash app only (no monitor break)
Ctrl+T  Ctrl+P  pause chip (suspend output)
Ctrl+T  Ctrl+Y  toggle log printing on/off
Ctrl+T  Ctrl+L  toggle timestamp prefix on each line
Ctrl+T  Ctrl+T  reset chip + enter download mode (button-less)
Ctrl+]          quit (this is also Ctrl+5 on some keyboards)

The Ctrl+T sequence exists because Ctrl+] is the GNU screen escape and they didn't want collisions. The trade-off is that anyone unfamiliar with the binding spends a minute discovering they can't quit without a kill -9. Ctrl+] is the safety hatch.

The build directory

build/ after idf.py build
build/
├── bootloader/
│   └── bootloader.bin               # 0x1000 (classic) or 0x0 (newer)
├── partition_table/
│   └── partition-table.bin          # 0x8000
├── ota_data_initial.bin             # 0xd000 if OTA
├── my_app.bin                       # the actual application image (0x10000)
├── my_app.elf                       # elf with debug symbols, for addr2line/gdb
├── my_app.map                       # linker map; use to chase symbol bloat
├── flasher_args.json                # everything esptool needs in one file
├── flash_args                       # shell-style equivalent
└── compile_commands.json            # for clangd, vscode, etc

flasher_args.json is the single source of truth for what idf.py flash will run. If you want to recreate the flash command outside of idf.py (CI, factory programmer, third-party tool), that file has every offset, mode, freq, and binary path. Read it with jq for scripting.

Serial Monitors & Discovery

Half of ESP32 troubleshooting is figuring out which /dev/ttyXXX the board mounted as, what driver it's using, and whether the host's getty has stolen it. These are the commands.

Identifying the device

Linux: USB-serial discovery
# freshly plug in the board, then
dmesg | tail -30
# [12345.678] usb 1-1.4: new full-speed USB device number 7 using ehci-pci
# [12345.789] usb 1-1.4: New USB device found, idVendor=10c4, idProduct=ea60
# [12345.789] usb 1-1.4: Product: CP2102 USB to UART Bridge Controller
# [12345.890] cp210x 1-1.4:1.0: cp210x converter detected
# [12345.901] usb 1-1.4: cp210x converter now attached to ttyUSB0

# list every serial-class device with metadata
python -m serial.tools.list_ports -v
# /dev/ttyUSB0
#     desc: CP2102 USB to UART Bridge Controller
#     hwid: USB VID:PID=10C4:EA60 SER=01234567 LOCATION=1-1.4

# udev's view of the device
udevadm info -a -n /dev/ttyUSB0 | grep -E 'idVendor|idProduct|serial'

# live USB topology
lsusb -tv

USB-serial chips found on ESP32 boards

Chip VID:PID Driver Notes
CP2102 / CP210410c4:ea60cp210xSilicon Labs. Common on official Espressif kits. Reliable.
CH340 / CH3411a86:7523ch341WCH. On cheap NodeMCU clones. Older macOS needs a kernel extension.
CH91021a86:55d4ch343 / ch9102Newer WCH. Higher baud rates than CH340.
FT232R / FT231X0403:6001 / 6015ftdi_sioFTDI. Best at high baud, costs more, what serious boards use.
FT2232H / FT4232H0403:6010 / 6011ftdi_sioMulti-channel FTDI. Used on JTAG-equipped dev kits where one channel is UART, another is JTAG.
native USB-CDC303a:1001 etccdc_acmS2/S3/C3/C6/H2 native USB. Mounts as /dev/ttyACM0. No driver needed on Linux.
native USB-Serial-JTAG303a:1001cdc_acm + libusbS3/C3/C6 with built-in JTAG bridge. Two endpoints: one CDC for serial, one for OpenOCD.

udev rules for stable names + permissions

/etc/udev/rules.d/99-esp32.rules
# grant access to dialout group, create stable symlink
SUBSYSTEM=="tty", ATTRS{idVendor}=="10c4", ATTRS{idProduct}=="ea60", \
  MODE="0666", GROUP="dialout", SYMLINK+="esp32-cp210x"

SUBSYSTEM=="tty", ATTRS{idVendor}=="1a86", ATTRS{idProduct}=="7523", \
  MODE="0666", GROUP="dialout", SYMLINK+="esp32-ch340"

SUBSYSTEM=="tty", ATTRS{idVendor}=="303a", ATTRS{idProduct}=="1001", \
  MODE="0666", GROUP="dialout", SYMLINK+="esp32-native"

# reload
sudo udevadm control --reload-rules
sudo udevadm trigger

Then your scripts and IDE configs can use the stable /dev/esp32-cp210x path instead of guessing whether it'll be ttyUSB0 or ttyUSB2 today. The SERIAL attribute lets you distinguish two boards of the same type if you populate it with the chip's serial number.

Terminal programs comparison

Tool Quit binding Notes
screenCtrl+A then KAlready installed everywhere. screen /dev/ttyUSB0 115200. No flow control.
picocomCtrl+A Ctrl+XLightweight. picocom -b 115200 /dev/ttyUSB0. Good for scripts.
minicomCtrl+A XHas a config file (~/.minirc.dfl). Old-school.
tioCtrl+T QModern (Rust). tio /dev/ttyUSB0 -b 115200. Hexdump mode, scripts, line tracing.
pyserial-minitermCtrl+]python -m serial.tools.miniterm /dev/ttyUSB0 115200. Always available with esptool.
idf.py monitorCtrl+]Decodes panic addresses, color logs, can trigger flash without quitting.
cu~. (tilde dot)BSD heritage, on macOS by default. cu -l /dev/cu.usbserial -s 115200.
The "Resource busy" mystery Linux distributions sometimes spawn a getty or ModemManager probe on USB-serial devices. The probe holds the port open for 30+ seconds at boot and esptool fails with could not open port. Fix: sudo systemctl stop ModemManager; sudo systemctl disable ModemManager if you don't have a real modem. Or add the device's idVendor/idProduct to /etc/usb_modeswitch.d as ignored. macOS has the same issue with usbmuxd grabbing serial pretending to be an iPhone.

JTAG, OpenOCD, GDB

For anything beyond printf debugging — single-stepping, watching memory, breaking on hardware events — you need JTAG. Espressif provides a customized OpenOCD fork that knows about Xtensa LX6/LX7 (ESP32, S2, S3) and standard RISC-V (C3, C6, H2, P4) cores.

JTAG pin assignments

Chip TCK TMS TDI TDO Built-in?
ESP32GPIO13GPIO14GPIO12GPIO15no, external probe
ESP32-S2GPIO39GPIO42GPIO41GPIO40no, external probe
ESP32-S3GPIO39GPIO42GPIO41GPIO40yes, native USB-Serial-JTAG
ESP32-C3GPIO6GPIO5GPIO4GPIO7yes, native USB-Serial-JTAG
ESP32-C6GPIO6GPIO5GPIO4GPIO7yes, native USB-Serial-JTAG
ESP32-H2GPIO27GPIO26GPIO25GPIO28yes

Classic ESP32 needs an external probe (FT2232H, J-Link, Olimex ARM-USB-OCD, ESP-Prog). The newer chips with built-in USB-Serial-JTAG let you skip the probe entirely: plug the board's USB-OTG port into your host and OpenOCD talks JTAG over the same USB connection that's giving you the serial console.

Launching OpenOCD

openocd against various targets
# classic ESP32 over FT2232H (ESP-Prog or generic)
openocd -f board/esp32-wrover-kit-3.3v.cfg

# ESP32-S3 over its built-in USB-JTAG
openocd -f board/esp32s3-builtin.cfg

# ESP32-C3 over its built-in USB-JTAG
openocd -f board/esp32c3-builtin.cfg

# custom: explicit interface + target chain
openocd \
  -f interface/ftdi/esp32_devkitj_v1.cfg \
  -f target/esp32.cfg \
  -c "adapter speed 20000"

# typical output
# Open On-Chip Debugger v0.12.0-esp32-20231004
# Info : ftdi: clock auto-adapt set to 10000 kHz
# Info : esp32.cpu0: hardware has 2 break/watch points
# Info : Listening on port 3333 for gdb connections
# Info : Listening on port 4444 for telnet connections

OpenOCD listens on three ports by default: 3333 for GDB remote-serial-protocol, 4444 for an interactive telnet command shell, and 6666 for tcl. The telnet shell is where you do one-off operations like halt, reset, flash erase_address 0x10000 0x100000, or mdw 0x3FF42000 16 to peek at memory.

GDB session

debug an existing build
# in one terminal: openocd
openocd -f board/esp32s3-builtin.cfg

# in another: gdb attached
xtensa-esp32s3-elf-gdb build/firmware.elf
(gdb) target remote :3333
(gdb) monitor reset halt           # OpenOCD telnet command from inside gdb
(gdb) flushregs                    # refresh after monitor reset
(gdb) hb app_main                  # hardware breakpoint (limited count)
(gdb) c                            # continue
(gdb) bt                           # once we hit, full backtrace
(gdb) info threads                 # FreeRTOS task list with names
(gdb) thread 4                     # switch to a different task
(gdb) x/64xw 0x3FFB0000           # hex dump 64 words from DRAM
(gdb) disassemble                  # show current function asm
Hardware breakpoints are scarce Xtensa LX6 has 2 hardware breakpoints + 2 watchpoints, total. RISC-V variants typically have 4. Software breakpoints (regular b) work in IRAM/DRAM (where ROM/IDF can patch instructions) but not in IROM (flash-mapped code, read-only). Use hb (hardware breakpoint) when setting breakpoints on flash-resident code or you'll get the cryptic "cannot insert breakpoint" error.

idf.py gdb / openocd shortcuts

idf.py wrappers
# start openocd in a child process
idf.py openocd

# start openocd + gdb together, attached to the right elf
idf.py gdb

# or use the gdbgui frontend
idf.py gdbgui

# start gdb in batch mode running a script
idf.py gdb --batch --commands debug_script.gdb

Brick Recovery

The flowchart for "my ESP32 doesn't work anymore." Most apparent bricks are not actual silicon failures, they're recoverable software states.

Diagnosis tree

START: chip doesn't boot or behaves strangely

  
  
  does esptool.py chip_id work?
  ├── no  ────▶ go to "no comms" branch
  └── yes ───▶ go to "comms ok" branch


no comms branch
  
  ├──▶ manual download mode? (hold BOOT, pulse EN)
  │      ├── now works ──▶ auto-reset circuit broken: solder, swap board, or use --before no_reset
  │      └── still fails ──▶ continue
  
  ├──▶ different USB cable + port? swap.
  
  ├──▶ dmesg shows USB enumeration?
  │      ├── no ──▶ dead USB-serial chip or board power, check 3.3V rail
  │      └── yes ──▶ continue
  
  ├──▶ ROM is reachable but won't enter download:
  │      check eFuse UART_DOWNLOAD_DIS via espefuse summary IF YOU CAN STILL READ EFUSES
  │      if burnt: chip is permanently locked. recover via JTAG only if not also disabled.
  
  └──▶ last resort: lift flash chip, reflash externally, resolder

comms ok branch
  
  ├──▶ erase + reflash from clean source
  │      esptool.py erase_flash
  │      esptool.py write_flash 0x0 merged.bin
  │      power cycle and observe
  
  ├──▶ still bad: check flash params (mode/freq/size) match the board
  │      esptool.py flash_id   ──▶ get manufacturer/device/size
  │      cross-reference datasheet for max freq + valid modes
  
  ├──▶ still bad: check eFuse summary for unexpected writes
  │      espefuse.py summary | grep -i "True"
  │      anything that says SECURE_BOOT, FLASH_CRYPT, DISABLE_*
  │      that you didn't set means somebody else burned a fuse
  
  └──▶ still bad with all known-good binaries: silicon defect or RF damage

The "weird boot loop" diagnosis

capture the ROM bootloader output
# put board in normal boot mode (don't hold BOOT)
# open serial at 115200 (most chips) or 74880 (classic ESP32 ROM is 74880!)
picocom -b 74880 /dev/ttyUSB0
# press EN/RST

# on classic ESP32 you'll see the cryptic ROM line first:
# rst:0x1 (POWERON_RESET),boot:0x13 (SPI_FAST_FLASH_BOOT)
# configsip: 0, SPIWP:0xee
# clk_drv:0x00,q_drv:0x00,d_drv:0x00,cs0_drv:0x00,hd_drv:0x00,wp_drv:0x00
# mode:DIO, clock div:2
# load:0x3fff0030,len:1184
# ho 0 tail 12 room 4
# load:0x40078000,len:13260
# load:0x40080400,len:3028
# entry 0x400805e4
# [then second-stage bootloader takes over at 115200]

The ROM speaks at 74880 baud (an artifact of the 26MHz crystal divisor). If the second-stage bootloader never starts, you'll only see ROM output. rst:0x10 means RTC_WDT_RTC_RESET — a watchdog timeout caused the reset. boot:0x13 is SPI fast flash boot, the normal mode. Other boot codes indicate strap pin states. The header lines are dense but they tell you exactly what mode the chip thinks it's in.

Reset reason codes

Code Name Meaning
0x01POWERON_RESETCold boot, just powered on.
0x03SW_RESETSoftware called esp_restart() or reset_reason set to 0x03.
0x04OWDT_RESETLegacy watchdog (deprecated path).
0x05DEEPSLEEP_RESETCame out of deep sleep normally.
0x06SDIO_RESETReset from SDIO slave host.
0x07TG0WDT_SYS_RESETTimer group 0 watchdog.
0x08TG1WDT_SYS_RESETTimer group 1 watchdog.
0x09RTCWDT_SYS_RESETRTC watchdog. Firmware hung early in boot.
0x0BINTRUSION_RESETTamper-detect (rare).
0x0CTGWDT_CPU_RESETTimer-group watchdog targeting CPU.
0x0FRTCWDT_BROWN_OUT_RESETBrownout: VDD dropped below threshold. Power supply problem.
0x10RTCWDT_RTC_RESETRTC watchdog full reset.

External flash reflashing (last resort)

If the ESP32 itself is dead or its UART path is locked, but the SPI flash chip is socketed (some boards) or accessible with hot air, you can read/write it directly with any SPI flash programmer.

flashrom on a soic-8 clip
# identify chip first
flashrom --programmer ch341a_spi

# dump existing
flashrom --programmer ch341a_spi -r dump.bin

# write new image (must be padded to flash size)
flashrom --programmer ch341a_spi -w merged_full.bin

# or use a Bus Pirate as the SPI programmer
flashrom --programmer buspirate_spi:dev=/dev/ttyUSB0,spispeed=1M -r dump.bin
Power the flash chip from the programmer, not the board A clip programmer drives 3.3V down the SOIC clip's VCC pin. If the ESP32 is also powered (via USB or otherwise), the two power sources fight and you get garbage reads or damage. Disconnect the board from USB. Some ESP32 modules tie ESP_EN to a pull-up that itself draws current — lift the EN pin or hold it to ground while reading flash externally.

Further Rabbit Holes

Things to read and experiment with to keep deepening. Each one leads to better questions.

Topic Why it matters Where to start
esptool source Understand the SLIP encoder, stub upload, command implementations. The Python is readable. esptool/loader.py and esptool/cmds.py in github.com/espressif/esptool
flasher_stub source The stub itself is C with a tiny linker script. Worth a read if you want to add commands. flasher_stub/ directory, same repo
ROM bootloader docs The undocumented but widely reverse-engineered ESP32 ROM. Espressif published most of the entry points eventually. esp-idf-bootloader-plus, esp32-rom-tools, the ESP32 TRM Appendix B
Xtensa LX6/LX7 ISA Read disassembly without panicking. Branch hints, windowed registers, weird call/ret semantics. Xtensa Instruction Set Architecture (ISA) Reference Manual, Cadence
RISC-V on C/H/P-series C3/C5/C6/H2: RV32IMC/RV32IMAC single-core. C5 also has an LP RISC-V core. P4: dual-core RISC-V at 400 MHz + LP core. Much simpler than Xtensa. GDB + objdump just work. RISC-V User-Level ISA Specification + chip-specific TRM
esp-idf bootloader source The second-stage bootloader. Where partition table parsing, OTA selection, and image verification live. components/bootloader_support/ in esp-idf
app_trace and SystemView Real-time tracing without halting the chip. Uses JTAG bandwidth as a sideband channel. ESP-IDF Application Level Tracing docs + Segger SystemView
esp_coredump Panic-time core dumps to flash or UART. Decoded later with idf.py coredump-info. components/espcoredump/ in esp-idf
Flash encryption modes Development vs Release mode. Tweak algorithm. AES-XTS on newer chips. Implications for OTA. ESP-IDF Security guide, Flash Encryption section
DFU on S2/S3 USB DFU via ROM. Alternative to UART download for chips with native USB. dfu-util -d 303a:0002, ESP-IDF DFU docs
USB-Serial-JTAG protocol The dual-channel CDC + JTAG bridge that ate the external JTAG probe market. esp-usb-jtag in esp-idf-tools, OpenOCD esp_usb_jtag driver
PartitionTool / nvs_partition_gen Generate NVS partitions offline (provisioning), build partition tables programmatically. components/nvs_flash/nvs_partition_generator/ in esp-idf
SPI flash chip variations Why setting flash_size wrong sometimes works (chip detected) and sometimes doesn't (datasheet lies). The 25-series command set. Winbond W25Q datasheet, GigaDevice GD25 datasheet, JEDEC JESD216
The meta-question to hold Every time the ESP32 flow feels magical, ask: what bytes went over UART, what bytes are in flash, what bits are in eFuse. esptool is a thin tool. There's no hidden layer. Every operation decomposes into SLIP packets, SPI commands, and OTP burns. If you can't trace it to bytes, you don't understand it yet.
// boot · wire · stub · flash · rw · image · efuse · idf · serial · jtag · brick //