ESP32 Deep Dive

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

Beyond the Datasheet

What happens between esptool.py write_flash and a booting chip: strap pins, the SLIP wire protocol, the stub loader, flash geometry, eFuses, JTAG, brick recovery. Reference tooling is esptool.py v4.x, espefuse.py, espsecure.py, idf.py, and Espressif's OpenOCD fork. The family matrix lives in Variants, the CPU architecture detail in Cores.

The board on your desk

        ESP32-DevKitC V4 · ESP32-WROOM-32 module · top view, USB at the bottom

                          ┌───────────────────────────┐
                3.3V out ─┤ 3V3                   GND ├─ ground
         reset · CHIP_PU ─┤ EN                   IO23 ├─ VSPI MOSI
  input-only · ADC1 · 36 ─┤ VP                   IO22 ├─ I2C SCL
  input-only · ADC1 · 39 ─┤ VN                    TX0 ├─ UART0 TX · esptool + console
       input-only · ADC1 ─┤ IO34                  RX0 ├─ UART0 RX · esptool + console
       input-only · ADC1 ─┤ IO35                 IO21 ├─ I2C SDA
                           IO32                  GND ├─ ground
                           IO33                 IO19 ├─ VSPI MISO
                    DAC1 ─┤ IO25                 IO18 ├─ VSPI SCK
                    DAC2 ─┤ IO26                  IO5 ├─ VSPI CS · strap
                           IO27                 IO17 
                HSPI SCK ─┤ IO14                 IO16 
 strap · MTDI · VDD_SDIO ─┤ IO12                  IO4 
                  ground ─┤ GND                   IO0 ├─ strap · BOOT button
               HSPI MOSI ─┤ IO13                  IO2 ├─ strap · low/float to flash
      flash · do not use ─┤ SD2                  IO15 ├─ strap · MTDO · ROM log
      flash · do not use ─┤ SD3                   SD1 ├─ flash · do not use
      flash · do not use ─┤ CMD                   SD0 ├─ flash · do not use
             5V from USB ─┤ 5V                    CLK ├─ flash · do not use
                          └──────────┤ USB ├──────────┘
                               micro-B · CP2102 · DTR/RTS auto-reset wired to EN + IO0

    strap · UART0 · power · bonded to internal flash — leave alone · in-only pins have no pull-ups
This pinout is classic ESP32 only The boot strap moves per variant: GPIO0 (classic/S2/S3), GPIO9 (C2/C3/C6/H2), GPIO28 (C5), GPIO35 (P4). Input-only pins, DACs, and the flash-bonded pins move too. The strap table in Boot Modes covers every chip; JTAG pins are in JTAG & OpenOCD.

What's inside the package

   ┌─────────────────────────── ESP32-family SoC ───────────────────────────┐
                                                                           
      ┌─────────────────┐  ┌───────────────┐  ┌──────────────────────────┐ │
       CPU core(s)        boot ROM         radio                     
       Xtensa LX6/LX7     mask ROM +       Wi-Fi · BT · 802.15.4     
       or RISC-V, 1-2     UART loader      varies · none on P4       
      └────────┬────────┘  └───────────────┘  └──────────────────────────┘ │
                                                                          
      ┌────────┴────────┐  ┌───────────────┐  ┌──────────────────────────┐ │
       cache / MMU        SRAM             RTC / LP domain           
       maps flash into    272-768 KB       ULP or LP core, RTC       
       IROM + DROM                         mem, runs in sleep        
      └────────┬────────┘  └───────────────┘  └──────────────────────────┘ │
                                                                          
      ┌────────┴────────┐  ┌───────────────┐  ┌──────────────────────────┐ │
       SPI0/1 flash +     eFuse OTP        peripherals               
       PSRAM controller   MAC · keys ·     UART · SPI · I2C · ADC    
                          config bits      USB on newer chips        
      └────────┬────────┘  └───────────────┘  └──────────────────────────┘ │
                                                                          
   └────────────┼───────────────────────────────────────────────────────────┘
                
      SPI flash · external or in-package · the thing esptool writes
How to read this Each tab is independent. Brick on the desk: Boot Modes + Brick Recovery. Upload failures: SLIP Wire + Stub Loader. Flash size mismatch: Flash Geometry. The diagrams are the load-bearing part, not the prose.

Variant Comparison

Eleven shipping SoC families on two CPU architectures: Xtensa (LX6 in the original ESP32, LX7 in S2/S3) and RISC-V (everything since the C3, Espressif's first RISC-V chip, announced November 2020). All of them run ESP-IDF. The single most important selection axis is the radio: only C6 (2.4 GHz) and C5 (dual-band 2.4/5 GHz) combine Wi-Fi 6 + BLE 5 + 802.15.4. H2 has 802.15.4 + BLE but no Wi-Fi. S2 has Wi-Fi but no Bluetooth. The original ESP32 is the only one with Bluetooth Classic (BR/EDR). P4 has no radio at all. Chip names in the matrix link to per-core architecture detail in Cores.

Quick reference matrix

Chip Arch / Cores Max MHz Wi-Fi Bluetooth 802.15.4 SRAM GPIO USB Notable
ESP32Xtensa LX6 / 2240b/g/n4.2 BR/EDR+LE520 KB34Only BT Classic; Ethernet MAC; 2×DAC
ESP32-S2Xtensa LX7 / 1240b/g/n320 KB43FS OTGWi-Fi + USB, no BT; 2×DAC; touch
ESP32-S3Xtensa LX7 / 2240b/g/n5 (LE)512 KB45FS OTGAI SIMD/vector; camera/LCD; octal PSRAM
ESP32-C2RISC-V / 1120b/g/n5.3 LE272 KB14Cheapest/smallest (sold as ESP8684 SiP)
ESP32-C3RISC-V / 1160b/g/n5 (LE)400 KB22Serial/JTAGMainstream low-cost RISC-V
ESP32-C6RISC-V HP+LP160Wi-Fi 6 (2.4G)5 (LE)yes512+16 KB30/22Serial/JTAGMatter Wi-Fi / Thread hub
ESP32-C5RISC-V HP+LP240Wi-Fi 6 dual-band5 (LE)yes384 KB29Serial/JTAGOnly 5 GHz part; PSRAM support
ESP32-C61RISC-V / 1160Wi-Fi 6 (2.4G)5 (LE) + Mesh 1.1320 KBfewSerial/JTAGAffordable Wi-Fi 6; quad PSRAM
ESP32-H2RISC-V / 1965.3 LEyes320 KB19Serial/JTAGRadio-only Thread/Zigbee endpoint
ESP32-P4RISC-V HP×2 + LP400768 KB55HS+FS OTGVision/HMI host; MIPI, H.264, no radio

CPU & memory

Chip CPU SRAM / ROM Flash & PSRAM Package
ESP32Dual LX6 @240 MHz (single-core in some variants). CoreMark 1080 dual.520 KB / 448 KB + 16 KB RTCExternal flash to 16 MB, external RAM to 8 MB; in-package options (U4WDH 4 MB flash)QFN48
ESP32-S2Single LX7 @240 MHz, 7-stage pipeline, no FPU. ULP: PicoRV32 + FSM.320 KB / 128 KB + 16 KB RTCQSPI/OSPI flash + PSRAM, up to 1 GB addressable; S2FH4 / S2FN4R2 in-package partsQFN56
ESP32-S3Dual LX7 @240 MHz with FPU + 128-bit SIMD "PIE" vector ops. CoreMark 1182.512 KB / 384 KB + 16 KB RTCOctal flash/PSRAM; in-package up to 8 MB flash and 8 MB PSRAM (R8/R8V)QFN56
ESP32-C2Single RISC-V RV32IMAC @120 MHz, no FPU.272 KB / 576 KBSiP flash 1/2/4 MB (ESP8684H1/H2/H4)QFN28
ESP32-C3Single RISC-V RV32IMC @160 MHz, no FPU.400 KB / 384 KB + 8 KB RTCExternal to 16 MB; C3FH4/C3FN4 in-package 4 MB. Rev v1.1 has 10-35 KB more usable SRAM than v0.4QFN32
ESP32-C6RISC-V HP core @160 MHz + LP core @20 MHz, no FPU.512 KB HP + 16 KB LP / 320 KBExternal to 16 MB; some QFN32 parts with in-package flashQFN40 / QFN32
ESP32-C5RISC-V HP core @240 MHz + LP core @40 MHz.384 KB / 320-384 KBExternal PSRAM support; modules to 8 MB PSRAM + 16/32 MB flashQFN48 / QFN40
ESP32-C61Single RISC-V @160 MHz, no FPU.320 KB / 256 KBQuad SPI PSRAM to 120 MHz; in-package variants (2 MB PSRAM + 8 MB flash modules)QFN
ESP32-H2Single RISC-V @96 MHz. CoreMark 303.320 KB / 128 KB + 4 KB LPSiP flash 2 or 4 MBQFN28/32/40
ESP32-P4Dual RISC-V RV32IMAFC @400 MHz with FPU + 128-bit AI/DSP vector extensions, plus LP core @40 MHz.768 KB HP L2MEM / 128 KB + 16 KB LP16 or 32 MB in-package PSRAM (P4NRW16X/32X); external flash + PSRAM via SPI/OPIQFN104

Peripheral standouts

Everything has SPI/I2C/UART/timers/ADC. What differs is the stuff at the edges.

Chip Has that others don't Missing
ESP32Ethernet MAC, SD/SDIO/MMC host, 2×8-bit DAC, Hall sensor, 10 touch channels, 16 LED PWMNative USB of any kind
ESP32-S2USB OTG (FS), DVP camera interface, LCD interface, 14 touch channels, 2×DACBluetooth, MCPWM, Ethernet, SD host
ESP32-S3USB OTG (FS), camera + LCD interfaces, SD/MMC host, 14 touch, MCPWMDAC
ESP32-C220 dBm high-power BLE modeTouch, USB, most of the peripheral set
ESP32-C3Debug Assist, RMT, TWAIDAC, touch
ESP32-C6SDIO, MCPWM, three radios on one antennaDAC, touch
ESP32-C5SDIO 2.0 slave, dedicated Key Manager, TEE (APM + PMP)DAC, touch
ESP32-C61Zero-crossing comparator, Event Task Matrix802.15.4, DAC, touch
ESP32-H2Certified Zigbee-Compliant Platform + Thread 1.3 ComponentWi-Fi entirely (deliberate)
ESP32-P4USB 2.0 HS OTG, MIPI-CSI + MIPI-DSI, H.264 encoder (1080p30), JPEG codec, ISP, pixel-processing accelerator, 10/100 Ethernet MAC, I3C, SDIO 3.0 host, 5×UART, touch, voice activity detection, Key Management UnitAny radio

Power (datasheet, bare chip)

Chip Deep sleep Light sleep Active RF notes
ESP32~10 µAULP sensor-monitored pattern ~100 µA at 1% ADC duty
ESP32-S2~20 µA (revised from advertised 2-4 µA; boards ~30 µA)ULP touch-monitored ~22 µA at 1% duty
ESP32-S3~7 µA (RTC on) / ~1 µA (RTC off)~240 µAWi-Fi TX peak ~310 mA; modem-sleep ~15 mA
ESP32-C35 µA130 µATX 802.11b @21 dBm 335 mA; RX 84-87 mA; modem-sleep 13-28 mA
ESP32-C67 µA headline (35 µA in Table 5-11)180 µAax TX @16.5 dBm ~252 mA; 802.15.4 TX @20 dBm ~305 mA
ESP32-C5very low per datasheet~80 mA active at high throughput, ~4 mA modem-sleep (third-party)
ESP32-H2~8 µA (module, measured)~24 mA active average
ESP32-P4Preliminary datasheet; specific µA/mA values not publicly extractable — consult ESP32-P4 datasheet Section 5.6 directly
Datasheet vs dev board reality Every sleep figure above is chip-level, measured on Espressif test fixtures. On a dev board, the LDO (an AMS1117-3.3 idles at ~5 mA), the USB-UART bridge (a CP2102N draws 2-5 mA even idle) and the power LED push measured deep sleep into milliamps — often 1000× the datasheet number. A DevKitC V4 measures ~3.8 mA in deep sleep against the ~10 µA datasheet figure. You need a bare module on a custom PCB to approach datasheet numbers.

Which one do I pick

  1. Bluetooth Classic (A2DP audio, SPP) or Ethernet MAC + Wi-Fi in one chip Original ESP32 — the only member with BR/EDR. If you only need BLE, move to C3 (cheaper) or S3 (more capable).
  2. On-device AI / vision / voice, camera + LCD, USB HID ESP32-S3. Dual LX7 + SIMD + octal PSRAM is unmatched among the connectivity chips. If you exceed ~1080p or need H.264/MIPI, step up to P4.
  3. Matter smart home / Thread / Zigbee ESP32-C6 (mains or moderate duty) or ESP32-H2 (battery mesh endpoint, no Wi-Fi). H2 as an RCP plus an S3/ESP32 host makes a Thread border router or Zigbee-to-Matter bridge.
  4. 5 GHz Wi-Fi (congested 2.4 GHz, streaming, IP cameras) ESP32-C5, the only dual-band part. Reconsider for tiny battery sensors — its 5 GHz active power is higher; a C6 or C3 may fit better.
  5. High-volume, cost-critical Wi-Fi + BLE (plugs, bulbs) ESP32-C2 for the floor, ESP32-C3 for mainstream, ESP32-C61 when you want Wi-Fi 6 TWT battery savings + PSRAM but not 802.15.4.
  6. Camera/display-heavy HMI, edge vision, kiosks ESP32-P4 plus a companion radio chip (commonly C6, via ESP-Hosted over SDIO/SPI). Only if you genuinely need the media engine; otherwise S3 is cheaper and self-contained.
  7. Wi-Fi + USB but no Bluetooth, low cost ESP32-S2. USB OTG + touch + 43 GPIO.
Naming and revision traps ESP32-C2 and ESP8684 are the same silicon (ESP8684 is the SiP name; ESP8685 is likewise a C3 SiP). C3 revision v1.1 vs v0.4 differ in usable SRAM and required IDF versions. Several part numbers are EOL and superseded (ESP32-D0WDR2-V3, ESP32-P4NRW32). BLE minor versions in marketing copy drift between 5.0/5.2/5.3 for the same chip — trust the certification in the datasheet, not the product page. Always pull the exact sub-variant datasheet before committing a design.

The Cores

Two ISAs in one family. Xtensa (LX6 in classic, LX7 in S2/S3) is licensed, configurable core IP from Cadence/Tensilica with Espressif-specific instruction extensions bolted on. Everything since the C3 is RISC-V, which Espressif can extend without per-core royalties and which mainline GCC, LLVM, GDB, and objdump understand out of the box. ESP-IDF papers over the difference almost completely; the seams show in debugging, assembly, and performance work.

Per-chip core matrix

Chip Core ISA / microarch FPU SIMD / vector CoreMark LP / ULP companion
ESP322× Xtensa LX6 @240 MHzXtensa windowed ABI + MAC16single-precision1080 (540 single-core)ULP FSM coprocessor
ESP32-S21× Xtensa LX7 @240 MHzXtensa windowed ABI, 7-stage543PicoRV32 ULP + FSM, 8 KB
ESP32-S32× Xtensa LX7 @240 MHzXtensa windowed ABIsingle-precisionPIE, 128-bit1182PicoRV32 ULP + FSM, 8 KB
ESP32-C21× RISC-V @120 MHzRV32IMAC, 4-stage in-order
ESP32-C31× RISC-V @160 MHzRV32IMC, 4-stage in-order
ESP32-C6RISC-V HP @160 MHzRV32IMACLP RISC-V @20 MHz
ESP32-C5RISC-V HP @240 MHzRV32 (IMAC-class)LP RISC-V @40 MHz
ESP32-C611× RISC-V @160 MHzRV32
ESP32-H21× RISC-V @96 MHzRV32303
ESP32-P42× RISC-V HP @400 MHzRV32IMAFC, 5-stage, branch predictor, HW loopssingle-precision128-bit AI/DSP extensionLP RISC-V @40 MHz, 8 KB TCM

Xtensa LX6 / LX7 — classic, S2, S3

Xtensa is not an ARM competitor so much as a construction kit: the licensee picks options (register windowing, zero-overhead loops, a 16-bit MAC on classic ESP32) and can add custom instructions through TIE (Tensilica Instruction Extension). That configurability is how the S3 grew 128-bit vector ops without a new ISA — and why you need Espressif's toolchain builds (xtensa-esp32-elf-, xtensa-esp32s3-elf-) rather than a generic compiler.

The windowed register file

Code sees 16 registers (a0-a15), but they're a sliding window onto a larger physical file (64 AR registers). CALL4/CALL8/CALL12 rotate the window by 4/8/12 instead of pushing to the stack; when the physical file wraps, a window-overflow exception spills old frames to the stack behind your back. Calls are cheap, but the consequences leak everywhere: backtraces have to walk spilled register windows (IDF's panic decoder does this for you), stack corruption produces Guru Meditation errors with plausible-looking but rotated register values, and the debugger must be window-aware — plain GDB cannot unwind an Xtensa stack.

What the S3 adds: PIE

The Processor Instruction Extensions are 128-bit SIMD: wide loads/stores, parallel MAC, saturation arithmetic. ESP-DSP and ESP-NN are hand-written around them, which is the entire hardware basis of the S3's AI positioning — dot products and convolutions, several-fold faster than scalar code. You get at them via those libraries or intrinsics, not from the C compiler's autovectorizer.

FPU fine print

Classic ESP32 and S3 have a single-precision FPU; S2 has none; double is software-emulated on every Xtensa chip. The FPU is also disabled in ISRs by default (context-save cost), so a stray float in an interrupt handler is a classic panic source.

RISC-V — C2, C3, C5, C6, C61, H2, P4

The letters after RV32 are the feature string: I integer base, M hardware multiply/divide, A atomics, C 16-bit compressed instructions, F single-precision float. So C3 = RV32IMC, C2 and C6 = RV32IMAC, and P4 = RV32IMAFC plus Espressif's own 128-bit vector/DSP extension and hardware-loop instructions. The C-series cores are small in-order 4-stage machines; the P4's HP cores add a branch predictor and a 5-stage pipeline at 400 MHz.

HP + LP core pairs

C6, C5, and P4 pair the main core with a second low-power RISC-V core (20 / 40 / 40 MHz) that keeps running through deep sleep. It's the successor to the classic ULP FSM — except you program it in C with the ULP-RISC-V toolchain instead of a four-instruction assembly language.

Debug and toolchain

This is where RISC-V quietly wins. riscv32-esp-elf- is ordinary mainline GCC/LLVM, objdump output reads like the ISA spec, mainline GDB unwinds it, and you get 4 hardware breakpoints instead of Xtensa's 2. If you live in disassembly or on the JTAG port, the RISC-V chips are simply nicer neighborhoods.

Moving code between the two ESP-IDF hides nearly all of it. What actually bites: hand-written assembly and PIE/MAC16 intrinsics (rewrite), anything that pokes FreeRTOS port internals (window spill vs plain stack frames), ISR float-usage rules, and performance assumptions — dual 240 MHz LX7 to a single 160 MHz RV32 is not a lateral move. Check image_info's CHIP_ID before assuming a binary is portable; it never is.

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.
// variants · cores · boot · wire · stub · flash · rw · image · efuse · idf · serial · jtag · brick //