technique
The boot sequence
What runs between power-on and your first line of code: the ROM, the second-stage bootloader, the app image, and how to read the boot log.
Before this
This page assumes you are comfortable with:
Why you need this
Three programs run before the first line you wrote, and each one prints a few lines over serial as it goes. When a board flashes fine and then does nothing, those lines say which stage stopped and why. Reading them turns "it doesn't work" into "the bootloader rejected the app" or "a watchdog reset the chip."
The idea
The chain
| Stage | Lives in | Its job |
|---|---|---|
| 1. ROM bootloader | ROM inside the chip, unchangeable | Read the strapping pins, print the reset reason and boot mode, then either wait for a flasher (download mode) or load the next stage from flash |
| 2. Second-stage bootloader | Flash, at the chip's bootloader offset (0x1000 classic ESP32, 0x0 S3 and C6, 0x2000 P4) | Set up the flash chip and clocks, turn off the boot watchdogs, read the partition table, check the app image, load it, jump to it |
| 3. App startup | The app partition, usually 0x10000 | ESP-IDF's runtime: caches, heap, the FreeRTOS operating system, then a task that calls app_main |
| 4. Your code | Inside the app | app_main in C or assembly; for MicroPython, the interpreter runs boot.py, then main.py |
The two-stage design keeps the ROM tiny and permanent while the bootloader can be rebuilt with every project. The ROM only knows how to load one simple image. Everything else, such as choosing between two update slots, lives in stage 2.
The image format
The ROM and the bootloader load the same kind of image. The ESP32 Inspector's image.ts, which builds byte-exact images in the browser, documents the layout:
| Part | Contents |
|---|---|
| Header | Magic byte 0xE9, the number of segments, the flash mode (DIO on the Inspector's images), flash speed and size, the entry point (the address to jump to) |
| Extended header | The chip id (13 for the C6, 18 for the P4) and the chip revisions the image accepts |
| Segments | Each one is a load address, a length, and that many bytes |
| Footer | A one-byte XOR checksum of all segment bytes, starting from 0xEF, then an optional SHA-256 hash of everything before it |
A segment is a block of the program with an address. Some are loaded: copied into RAM. Others are mapped: left in flash and read in place through the cache. The boot log says which.
The boot log fields
The ROM's first line has two numbers. rst: is the reset reason, why the chip restarted. boot: is the boot mode, decoded from the strapping pins. esptool's documentation describes it as the strapping value read from the GPIO_STRAP register. Both are chip-specific numbers, so read the words in parentheses, not just the hex.
| Field | Seen on | Meaning |
|---|---|---|
rst:0x1 (POWERON_RESET) |
classic ESP32, from esptool's documentation | Power was just applied |
rst:0x15 (USB_UART_HPSYS) |
C6, author's notes | The USB line (a host opening the port) reset the chip |
rst:0x17 (CHIP_USB_UART_RESET) |
P4, author's capture | The same, on the P4 |
rst:0x7 (TG0_WDT_HP) |
C6, author's notes | A watchdog fired: something did not finish in time |
boot:0xc (SPI_FAST_FLASH_BOOT) |
C6 | Normal boot from flash |
boot:0xf (SPI_FAST_FLASH_BOOT) |
P4 | Normal boot from flash |
boot:0x4 (DOWNLOAD...) |
C6 | Download mode: the ROM waits for a flasher, nothing else runs |
boot:0x3 (DOWNLOAD_BOOT(UART0/UART1/SDIO_REI_REO_V2)) |
classic ESP32, from esptool's documentation | Download mode |
boot:0x23 (DOWNLOAD(USB/UART0)) |
S3 Super Mini, author's notes | Download mode |
The same words get different numbers on different chips: SPI_FAST_FLASH_BOOT is 0xc on the C6 and 0xf on the P4.
Raw images skip stage 2
A bare RAM image, like the one the ESP32 Inspector's C6 assembly page builds, is a single image the ROM loads directly from the bootloader offset. Its one segment is copied into RAM at 0x40870000 on the C6 (0x4FF00000 on the P4) and the ROM jumps to its entry. No partition table, no ESP-IDF runtime. The cost is that the image must do stage 2's jobs itself: set a stack pointer and turn off the watchdogs the ROM left running, and on the C6 also stop a USB-line reset from dropping back into download mode. The startup stub that does this is shown on RISC-V assembly on the ESP32-C6 and P4, and the story of finding each duty is on debugging resets and crashes.
The author's notes list one more condition found by testing on a C6: the image had to use DIO flash mode. A default QIO image was read back wrong by the ROM at boot, which printed Checksum failure and never jumped.
Worked example
This log was captured from the Waveshare ESP32-P4 Core DevKit over its USB port after a reset, with its factory firmware. It is from the author's hardware notes, condensed there; lines below are trimmed with ....
ESP-ROM:esp32p4-eco2-20240710
Build:Jul 10 2024
rst:0x17 (CHIP_USB_UART_RESET),boot:0xf (SPI_FAST_FLASH_BOOT)
SPI mode:DIO, clock div:1
I (27) boot: ESP-IDF v5.5.4-dirty 2nd stage bootloader
I (30) boot: chip revision: v1.3
I (31) boot.esp32p4: SPI Mode : QIO
I (32) boot.esp32p4: SPI Flash Size : 16MB
I (33) boot: Partition Table:
I (33) boot: ## Label Usage Type ST Offset Length
...
I (38) boot: 4 factory factory app 00 00 00110000 00e00000
I (40) boot: Defaulting to factory image
I (465) boot: Loaded app from partition at offset 0x110000
...
I (1659) cpu_start: GPIO 38 and 37 are used as console UART I/O pins
I (1659) cpu_start: cpu freq: 360000000 Hz
...
W (1667) spi_flash: Detected size(32768k) larger than the size in the binary image header(16384k). Using the size in the binary image header.
I (1671) main_task: Calling app_main()
Line by line:
| Line | Stage | What it tells you |
|---|---|---|
ESP-ROM:..., Build:... |
ROM | Which ROM version is in the chip |
rst:0x17 ...,boot:0xf ... |
ROM | Reset over the USB line; strapping pins say boot from flash |
SPI mode:DIO, clock div:1 |
ROM | How the ROM is reading flash to fetch the bootloader |
I (27) boot: ESP-IDF v5.5.4-dirty 2nd stage bootloader |
Bootloader | Stage 2 is running. I means info and 27 is milliseconds since boot |
chip revision: v1.3 |
Bootloader | The silicon revision, which matters on the P4 because rev 1.x and rev 3.x need different builds |
SPI Mode : QIO, SPI Flash Size : 16MB |
Bootloader | Flash is now read in QIO mode (four data lines instead of DIO's two), and the image header says 16 MB of flash |
Partition Table: and its rows |
Bootloader | It read the table at 0x8000; the factory app is at 0x110000, length 0xE00000 |
Loaded app from partition at offset 0x110000 |
Bootloader | The app image passed its checks and was loaded |
cpu_start: ... console UART I/O pins, cpu freq: 360000000 Hz |
App startup | ESP-IDF's runtime is up: console on GPIO 37 and 38, CPU at 360 MHz |
W (1667) spi_flash: Detected size(32768k) larger than ... |
App startup | A warning (W), harmless: the chip holds 32 MB but the image says 16 MB, so it uses 16 MB |
main_task: Calling app_main() |
App startup | About 1.67 s after reset, the runtime hands control to the program |
Everything after Calling app_main() belongs to the program. On this unit the factory program then stalled waiting for a display that was not connected, and the task watchdog printed a report every 5 s: a stage-4 problem, so the stage-1 to stage-3 lines above all look healthy.
The Inspector's boot-flow reference panel annotates the same handoff on a C6 running the echo. These lines are excerpted from it, with its notes shortened into the parentheses; the segment lines show the two kinds of loading:
esp_image: segment 2: vaddr=42000020 ... map (code, read in place from flash)
esp_image: segment 1: vaddr=40800000 ... load (data, copied into SRAM)
boot: Loaded app from partition at offset 0x10000
In an ESP32 project
This is the second half of stage 3, and the first thing stage 6 (debug) reads. A new board, a new partition table, or a raw image all show up here first. For MicroPython and CircuitPython the same chain runs, and the interpreter is the app: stage 4 is your boot.py and main.py, or code.py.
Common mistakes
- Reading
boot:numbers across chips. Treating 0xc as "normal" on a P4 or 0xf as wrong misreads a healthy log. Read the words in parentheses. - Opening the monitor too late on native USB. The port re-enumerates at reset, so the first lines are gone before the terminal reconnects and the log seems to start mid-way or not at all.
- A raw image that skips the stub's work. It prints the ROM lines, runs briefly, then the log repeats with a watchdog reset reason.
- Wrong image for the silicon. The author's notes record that a P4 image built for rev 3.x silicon refuses to boot on a rev 1.3 chip, so the log never reaches
Calling app_main(). - Mistaking a program hang for a boot failure. If the log reaches
Calling app_main(), booting worked; look in your code.
Cost
The P4 log above spends about 1.67 s between reset and app_main, most of it in app startup (that factory build also tests 32 MB of PSRAM). A raw RAM image skips the second stage and the runtime entirely, so it has far less to do before its first instruction, but it gives up partition tables, update slots, and the bootloader's checks. The maker's time is what this page saves: a minute reading the log usually decides whether to look at wiring, flashing, or code.
Going further
- ESP-IDF Programming Guide, "Application Startup Flow" and "Bootloader".
- The esptool documentation, "Boot Mode Selection" and "Firmware Image Format".
- Debugging resets and crashes, for reset loops, watchdogs, and panics.
- Flash, RAM, and partitions, for the table the bootloader reads.
- Over-the-air updates, for how the bootloader picks between two app slots.
Leads to
- techniqueDebugging resets and crashesWhy a board reboots, loops, or goes silent, and a method for finding out: reset reasons, watchdogs, boot modes, and leaving yourself notes in memory that survives a reset.
- techniqueOver-the-air updatesUpdating a board's code without a USB cable: manifests, hashes, safe swaps, and what happens when an update fails.
Back to ESP32 development: assembly, C, MicroPython, and CircuitPython