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.
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.
| 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. |
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.
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 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. |
espefuse.py set_flash_voltage 3.3V.
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:
waiting for download. RISC-V variants print nothing by default but respond to esptool.py chip_id.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.
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.
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
| Code | Name | Available in | Purpose |
|---|---|---|---|
| 0x02 | FLASH_BEGIN | ROM + stub | Begin flash write: erase region, set up state. |
| 0x03 | FLASH_DATA | ROM + stub | One block of flash data (typically 4KB). |
| 0x04 | FLASH_END | ROM + stub | End flash session, optional reboot flag. |
| 0x05 | MEM_BEGIN | ROM + stub | Begin RAM upload: where to load and how big. |
| 0x06 | MEM_END | ROM + stub | End RAM upload, optional jump to entry point. |
| 0x07 | MEM_DATA | ROM + stub | One block of RAM data. |
| 0x08 | SYNC | ROM + stub | Initial handshake. Sent 5x with timing. |
| 0x09 | WRITE_REG | ROM + stub | Write a 32-bit memory-mapped register. |
| 0x0A | READ_REG | ROM + stub | Read a 32-bit memory-mapped register. |
| 0x0B | SPI_SET_PARAMS | ROM + stub | Tell SPI controller flash size + page/sector geometry. |
| 0x0D | SPI_ATTACH | ROM + stub | Configure SPI pins (relevant for octal/quad PSRAM combos). |
| 0x0F | CHANGE_BAUDRATE | stub only | Switch UART baud mid-session. |
| 0x10 | FLASH_DEFL_BEGIN | stub only | Begin compressed flash write (zlib). |
| 0x11 | FLASH_DEFL_DATA | stub only | One block of deflate-compressed data. |
| 0x12 | FLASH_DEFL_END | stub only | End compressed flash session. |
| 0x13 | SPI_FLASH_MD5 | stub only | Compute MD5 over a flash region; used by verify_flash. |
| 0x14 | ERASE_FLASH | stub only | Whole-chip erase via SPI flash command 0xC7. |
| 0x15 | ERASE_REGION | stub only | 4KB-aligned sector erase loop. |
| 0x16 | READ_FLASH | stub only | Stream a flash region back to host. |
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.
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.
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 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.
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.
| Feature | ROM only | With stub |
|---|---|---|
| Max baud | 115200 (auto-baud limit) | 921600+ via CHANGE_BAUDRATE |
| Compressed flash | no | yes (zlib via FLASH_DEFL_*) |
| Flash MD5 | no | yes (SPI_FLASH_MD5) |
| Read flash | no | yes (READ_FLASH streams blocks) |
| Write throughput | ~22 KB/s typical | 200-300 KB/s typical |
| Block buffering | flush per block | queue blocks while previous flushes |
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 |
|---|---|---|---|
| ESP32 | 0x4009_0000 | 0x3FFE_8000 | flasher_stub/stub_flasher_chip.bin |
| ESP32-S2 | 0x4002_4000 | 0x3FFE_8000 | same dir, different binary |
| ESP32-S3 | 0x4037_8000 | 0x3FCB_8000 | same dir |
| ESP32-C3 | 0x4038_0000 | 0x3FCD_C000 | same dir |
| ESP32-C6 | 0x4080_8000 | 0x4082_8000 | same dir |
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
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.
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.
| Chip | Bootloader offset | Why |
|---|---|---|
| ESP32 | 0x1000 | First 4KB of flash is reserved for the secure boot V1 verifier digest area; bootloader proper starts at 0x1000. |
| ESP32-S2 | 0x1000 | Same reservation as classic. |
| ESP32-S3 | 0x0000 | Newer ROM does not require the 4KB reservation; bootloader can start at the very front. |
| ESP32-C3 | 0x0000 | RISC-V chips skip the reservation entirely. |
| ESP32-C6 | 0x0000 | Same. |
| ESP32-H2 | 0x0000 | Same. |
| ESP32-P4 | 0x2000 | Bigger reserved area for the high-perf SoC's eFuse/digest space. |
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
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
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,
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
The everyday operations. The flags below mostly compose: --port, --baud, --chip, --before, --after apply to all of them.
| Flag | Default | Notes |
|---|---|---|
| --port / -p | auto-detect | Serial device. Linux: /dev/ttyUSB0, /dev/ttyACM0. macOS: /dev/cu.usbserial-XXXX. Windows: COM5. |
| --baud / -b | 115200 | Initial baud. Stub bumps to 460800 unless overridden. Some CH340 clones max at 921600; FTDI does 3M. |
| --chip / -c | auto | esp32, esp32s2, esp32s3, esp32c3, esp32c6, esp32h2, esp32p4. Auto works but is slower because it has to fingerprint. |
| --connect-attempts | 7 | How many times to retry SYNC before giving up. |
| --trace | off | Print every byte going in and out of the serial port. Indispensable for debugging. |
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 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 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 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.
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
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.
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.
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 (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 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)
| Region | Address (classic) | Purpose |
|---|---|---|
| IROM | 0x400D_0000 ... | Instruction-fetched-from-flash, mapped via flash cache. |
| DROM | 0x3F40_0000 ... | Data-fetched-from-flash (constants, strings). |
| IRAM | 0x4008_0000 ... | Internal SRAM mapped for instruction fetch. Fast, used for ISRs. |
| DRAM | 0x3FFB_0000 ... | Internal SRAM mapped as data. The heap lives here. |
| RTC_IRAM | 0x400C_0000 ... | Survives deep sleep on classic ESP32. |
| RTC_DRAM | 0x3FF8_0000 ... | Survives deep sleep, holds RTC variables. |
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
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.
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.
| Block | Bits | Contents (classic ESP32) |
|---|---|---|
| BLK0 | 256 | Factory MAC, chip rev, package, ADC calibration, user-config flags (UART_DOWNLOAD_DIS, JTAG_DISABLE, etc.) |
| BLK1 | 256 | Flash encryption key (256-bit AES) |
| BLK2 | 256 | Secure boot V1 key |
| BLK3 | 256 | User 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.
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
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.
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
| Property | V1 (classic ESP32 only) | V2 (S2/S3/C3/C6/H2 + classic v3) |
|---|---|---|
| Algorithm | AES-256 ECB + SHA-256 digest | RSA-PSS-3072 or ECDSA-P256 |
| Key in eFuse | 256-bit symmetric (BLK2) | SHA-256 digest of public key (1-3 slots) |
| Key revocation | impossible (single key, burned forever) | up to 3 slots, individually revocable |
| Image trailer | 32-byte digest | signature block (1216 bytes per signature) |
| Recommendation | do not use on new designs | required for production |
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.
| Command | What it does |
|---|---|
| idf.py set-target esp32s3 | Configure project for chip variant. Wipes build/, regenerates sdkconfig. |
| idf.py menuconfig | ncurses configurator over Kconfig. Edits sdkconfig. |
| idf.py reconfigure | Re-run cmake without rebuilding everything. Used after git pull on IDF itself. |
| idf.py build | Compile, link, generate .bin, image_info. Cached via ninja. |
| idf.py app | Build only the app, not bootloader and partition table. |
| idf.py clean | Remove built artifacts but keep config. |
| idf.py fullclean | Nuke build/ entirely. Required after some IDF version bumps. |
| idf.py -p PORT flash | Run esptool to write all three images (bootloader, partitions, app). |
| idf.py -p PORT app-flash | Flash only the app partition. Faster iteration when bootloader unchanged. |
| idf.py -p PORT erase-flash | Wrapper for esptool.py erase_flash. |
| idf.py -p PORT monitor | Open the IDF monitor (smart serial terminal with crash decoding). |
| idf.py -p PORT flash monitor | The combo. Flash, then immediately attach monitor. |
| idf.py size | Print size of static + heap regions used by the linked image. |
| idf.py size-components | Per-component size breakdown. |
| idf.py size-files | Per-object-file size breakdown. Most useful for hunting bloat. |
| idf.py partition-table | Print the active partition table CSV. |
| idf.py partition-table-flash | Flash only the partition table. |
| idf.py coredump-info | Decode a core dump produced by IDF's panic handler. |
| idf.py gdb / openocd | Launch xtensa-esp32-elf-gdb attached to OpenOCD with the right scripts. |
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.
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.
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.
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
| Chip | VID:PID | Driver | Notes |
|---|---|---|---|
| CP2102 / CP2104 | 10c4:ea60 | cp210x | Silicon Labs. Common on official Espressif kits. Reliable. |
| CH340 / CH341 | 1a86:7523 | ch341 | WCH. On cheap NodeMCU clones. Older macOS needs a kernel extension. |
| CH9102 | 1a86:55d4 | ch343 / ch9102 | Newer WCH. Higher baud rates than CH340. |
| FT232R / FT231X | 0403:6001 / 6015 | ftdi_sio | FTDI. Best at high baud, costs more, what serious boards use. |
| FT2232H / FT4232H | 0403:6010 / 6011 | ftdi_sio | Multi-channel FTDI. Used on JTAG-equipped dev kits where one channel is UART, another is JTAG. |
| native USB-CDC | 303a:1001 etc | cdc_acm | S2/S3/C3/C6/H2 native USB. Mounts as /dev/ttyACM0. No driver needed on Linux. |
| native USB-Serial-JTAG | 303a:1001 | cdc_acm + libusb | S3/C3/C6 with built-in JTAG bridge. Two endpoints: one CDC for serial, one for OpenOCD. |
/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.
| Tool | Quit binding | Notes |
|---|---|---|
| screen | Ctrl+A then K | Already installed everywhere. screen /dev/ttyUSB0 115200. No flow control. |
| picocom | Ctrl+A Ctrl+X | Lightweight. picocom -b 115200 /dev/ttyUSB0. Good for scripts. |
| minicom | Ctrl+A X | Has a config file (~/.minirc.dfl). Old-school. |
| tio | Ctrl+T Q | Modern (Rust). tio /dev/ttyUSB0 -b 115200. Hexdump mode, scripts, line tracing. |
| pyserial-miniterm | Ctrl+] | python -m serial.tools.miniterm /dev/ttyUSB0 115200. Always available with esptool. |
| idf.py monitor | Ctrl+] | 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. |
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.
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.
| Chip | TCK | TMS | TDI | TDO | Built-in? |
|---|---|---|---|---|---|
| ESP32 | GPIO13 | GPIO14 | GPIO12 | GPIO15 | no, external probe |
| ESP32-S2 | GPIO39 | GPIO42 | GPIO41 | GPIO40 | no, external probe |
| ESP32-S3 | GPIO39 | GPIO42 | GPIO41 | GPIO40 | yes, native USB-Serial-JTAG |
| ESP32-C3 | GPIO6 | GPIO5 | GPIO4 | GPIO7 | yes, native USB-Serial-JTAG |
| ESP32-C6 | GPIO6 | GPIO5 | GPIO4 | GPIO7 | yes, native USB-Serial-JTAG |
| ESP32-H2 | GPIO27 | GPIO26 | GPIO25 | GPIO28 | yes |
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.
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.
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
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 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
The flowchart for "my ESP32 doesn't work anymore." Most apparent bricks are not actual silicon failures, they're recoverable software states.
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
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.
| Code | Name | Meaning |
|---|---|---|
| 0x01 | POWERON_RESET | Cold boot, just powered on. |
| 0x03 | SW_RESET | Software called esp_restart() or reset_reason set to 0x03. |
| 0x04 | OWDT_RESET | Legacy watchdog (deprecated path). |
| 0x05 | DEEPSLEEP_RESET | Came out of deep sleep normally. |
| 0x06 | SDIO_RESET | Reset from SDIO slave host. |
| 0x07 | TG0WDT_SYS_RESET | Timer group 0 watchdog. |
| 0x08 | TG1WDT_SYS_RESET | Timer group 1 watchdog. |
| 0x09 | RTCWDT_SYS_RESET | RTC watchdog. Firmware hung early in boot. |
| 0x0B | INTRUSION_RESET | Tamper-detect (rare). |
| 0x0C | TGWDT_CPU_RESET | Timer-group watchdog targeting CPU. |
| 0x0F | RTCWDT_BROWN_OUT_RESET | Brownout: VDD dropped below threshold. Power supply problem. |
| 0x10 | RTCWDT_RTC_RESET | RTC watchdog full reset. |
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
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 |