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

Leads to

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