technique

Flashing and the ROM bootloader

How a program gets into flash: download mode, the ROM bootloader's serial protocol, esptool, the offsets that differ per chip, and flashing from a browser.

Before this

This page assumes you are comfortable with:

Why you need this

Your program is useless until it is in the board's flash. Every tool that puts it there, esptool, idf.py flash, Thonny's firmware installer, the ESP32 Inspector, talks to the same small program burned into the chip at the factory. When flashing fails, the cause is almost always in this page: the chip was not in download mode, the port was busy, or a file went to the wrong offset.

The idea

Two ways to start

Every ESP32 chip contains a ROM, read-only memory programmed at the factory that nothing can erase. At reset the ROM reads a few strapping pins, pins whose level at the moment of reset picks a boot mode, and chooses one of two paths:

Mode What runs When
Normal boot The ROM loads the bootloader from flash, which loads your app The boot pin is high (the usual case)
Download mode The ROM's own serial bootloader waits for commands The boot pin is held low at reset

The boot pin differs by chip. Espressif's esptool documentation says the classic ESP32 "will enter the serial bootloader when GPIO0 is held low on reset." The author's notes give GPIO 0 for the S3 boards (the Feather's boot button), GPIO 9 for the C6, and GPIO 35 for the P4. On most boards a button labeled BOOT pulls that pin low and a button labeled EN or RST resets the chip. The manual recipe: hold BOOT, tap RST, release BOOT.

Getting there without buttons

Bridge-chip boards (lx6-uart0, lx7-uart0: the CYD's CH340, the DevKit's CP2102) wire two of the bridge's control lines to the chip. esptool's documentation gives the mapping: RTS drives EN (reset) and DTR drives GPIO0. esptool wiggles them in a sequence that resets the chip with GPIO0 low, so it lands in download mode by itself.

Native-USB boards (lx7-usbjtag, rv32-usbjtag, rv32p4-usbjtag) have no bridge. The chip's own USB-Serial-JTAG controller turns the same line changes into a reset into download mode. Because the USB port is part of the chip, every reset makes the port vanish from the computer and come back, which is called re-enumerating. Tools have to wait for it and reopen the port.

The ROM's serial protocol

Once in download mode, the ROM listens for commands framed with SLIP (Serial Line Internet Protocol): each packet starts and ends with the byte 0xC0, and any 0xC0 or 0xDB inside the packet is replaced by a two-byte escape so it cannot be mistaken for the end. The author's CYD Riprap project implements this protocol from scratch in MicroPython, so one CYD can flash another over a wire, and has cloned a full board that way. This framing function is excerpted from its slip.py (hardware-proven):

END     = 0xC0
ESC     = 0xDB
ESC_END = 0xDC
ESC_ESC = 0xDD

def encode(data):
    """Wrap raw bytes in a SLIP frame (END..payload..END).
    Uses bytes.replace() (C-level) instead of a Python byte loop."""
    escaped = data.replace(b'\xDB', b'\xDB\xDD').replace(b'\xC0', b'\xDB\xDC')
    return b'\xC0' + escaped + b'\xC0'

Inside the frame, every request has the same 8-byte header. This excerpt from CYD Riprap's esp_rom.py (hardware-proven) builds one; its file comment documents the layout as direction byte 0x00, the command number, a 16-bit data length, and a 32-bit checksum, all little-endian:

REQ = 0x00
CHECKSUM_MAGIC = 0xEF
...
def checksum(data):
    """ESP32 ROM data checksum: XOR over data bytes seeded with 0xEF.
    Only meaningful for FLASH_DATA / MEM_DATA payloads."""
    state = CHECKSUM_MAGIC
    for b in data:
        state ^= b
    return state
...
def build_request(op, data, chk=0):
    """Assemble a request packet (no SLIP framing)."""
    return bytes([REQ, op]) + _u16le(len(data)) + _u32le(chk) + data

The checksum is an XOR of every data byte, starting from 0xEF, and only matters for commands that carry flash or RAM data. The commands a flash write uses:

Command Number Job
SYNC 0x08 "Are you there?" The data is 0x07 0x07 0x12 0x20 followed by 32 bytes of 0x55
FLASH_BEGIN 0x02 Erase this many bytes at this offset; data will come in blocks of this size
FLASH_DATA 0x03 One numbered block, with its checksum
FLASH_END 0x04 Finished; optionally reboot into the new program
MEM_BEGIN, MEM_DATA, MEM_END 0x05, 0x07, 0x06 Load a program into RAM and run it

The ROM accepts 1 KB blocks. Tools usually first send a helper program, the stub, into RAM with the MEM commands and run it; the stub speaks the same protocol faster, with larger blocks (CYD Riprap uses 16 KB) and compressed data. Every reply carries a status byte, 0 for success.

esptool

esptool is Espressif's command-line flasher and the reference implementation of the protocol. esptool 5 spells its commands with hyphens: write-flash, read-flash, erase-flash, flash-id, image-info. This command, written for this page and not hardware-tested, flashes the classic ESP32 echo's three files (replace PORT with your COM port or device path):

esptool --chip esp32 -p PORT write-flash 0x1000 bootloader.bin 0x8000 partition-table.bin 0x10000 echo_lx6_uart0.bin

idf.py flash builds the same command from the project's flash_args file.

Offsets by chip

The ROM looks for the second-stage bootloader at a fixed flash offset that depends on the chip:

Chip Bootloader Partition table App
ESP32 (classic) 0x1000 0x8000 0x10000
ESP32-S3 0x0 0x8000 0x10000
ESP32-C6 0x0 0x8000 0x10000
ESP32-P4 0x2000 0x8000 0x10000

The bootloader offsets were verified on the author's boards; the P4 reserves its first two 4 KB sectors, and a bootloader written to 0x0 on the P4 makes the ROM report invalid header in a loop. The table and app offsets are ESP-IDF defaults that every Inspector payload uses. MicroPython firmware is one combined file written at the bootloader offset, which is why the author's notes warn that 0x1000 versus 0x0 is the classic mistake.

Flashing from a browser

Chromium-based desktop browsers expose serial ports to web pages through Web Serial, so a page can speak the ROM protocol directly. The ESP32 Inspector's board detection page uses it to identify a plugged-in board, flash the known-good echo for its config, and check that the board answers. It works only in desktop Chromium, because other browsers do not offer Web Serial.

Worked example

Here is the flash layout of the Inspector's echo payload for two configs, read from each build's flash_args and file sizes (1 KB = 1024 bytes).

lx6-uart0 (classic ESP32) rv32-usbjtag (ESP32-C6)
Bootloader 0x00001000, 25,984 bytes (25.4 KB), ends at 0x00007580 0x00000000, 21,872 bytes (21.4 KB), ends at 0x00005570
Partition table 0x00008000 0x00008000
App 0x00010000, 151,760 bytes, ends at 0x000350D0 0x00010000, 123,216 bytes, ends at 0x0002E150
Flash settings in flash_args DIO mode, 40 MHz, 2 MB DIO mode, 80 MHz, 2 MB

Both bootloaders end well before the table at 0x8000; on the classic chip the gap from 0x1000 to 0x8000 is 28 KB.

Now count the traffic for the classic app. Through the ROM's 1 KB blocks it takes 151,760 / 1024, rounded up, 149 FLASH_DATA commands. Through the stub's 16 KB blocks it takes 10. At 115200 baud a UART carries 10 bits per byte (start bit, 8 data bits, stop bit), so 11,520 bytes per second, and the raw app alone needs about 13.2 seconds before any protocol overhead. That is why tools switch to a higher baud rate and compress the data.

One frame, small enough to follow by hand. A FLASH_DATA block containing just 0x01 0x02 0x03 0x04 gets the checksum 0xEF XOR 0x01 XOR 0x02 XOR 0x03 XOR 0x04 = 0xEB. And if a payload contained the bytes 0x01 0xC0 0x02, SLIP would send them as 0xC0 0x01 0xDB 0xDC 0x02 0xC0.

In an ESP32 project

This is stage 3. Its input is the build from stage 2 (one image for MicroPython or CircuitPython, three files for an ESP-IDF or assembly project). Its output is a chip that resets into the boot sequence. For a bare RAM image on the C6 or P4, the image itself goes at the bootloader offset, 0x0 or 0x2000, because nothing else runs before it.

Common mistakes

  • Port busy. A serial monitor, Thonny, or a second browser tab holds the port; esptool reports it cannot open it. Close the other program.
  • Never reaching download mode. esptool retries "Connecting..." and gives up. On a board without auto-reset, use the BOOT and RST buttons.
  • Wrong offset. A classic ESP32 bootloader written to 0x0, or an S3 one written to 0x1000, gives a boot loop of ROM errors and no app.
  • Native USB stays in download mode. On the Super Mini ESP32-S3 the author found esptool's reset at the end does not leave download mode; the log shows boot:0x23 (DOWNLOAD(USB/UART0)) until you press RST or replug the cable.
  • Two identical boards plugged in. Two CH340 boards can trade port names, so you flash the wrong one. Plug in one at a time.

Cost

The ROM path is slow but needs nothing on the board: at 115200 baud the classic echo's app alone is about 13 seconds of raw transfer, before the bootloader, the table, the protocol overhead, and the erase. The stub, a faster baud rate, and compression exist to cut that. The maker's time goes mostly to the first flash on a new board: drivers for the bridge chip, finding the port, and the boot buttons. After that, edit-to-run is dominated by the write itself, which scales with image size, so a 55-byte assembly change still rewrites the whole 148 KB app.

Going further

  • The esptool documentation's "Serial Protocol" and "Boot Mode Selection" pages.
  • CYD Riprap's esp_rom.py in full: SYNC retries, stub upload, compressed flashing, and an MD5 check of what was written.
  • The boot sequence, for what the chip does after the final reset.
  • Serial over UART and USB, for why native USB ports re-enumerate.
  • Chips, boards, and configs, to find your board's config before choosing offsets.

Leads to

Back to ESP32 development: assembly, C, MicroPython, and CircuitPython