universalisos/docs/esp32-boot-chain-audit.md
Fábio Coutada 98ed638f3c WIP: emergency commit — ESP32 GDB stub, boot chain work, docs, AGENTS.md rules
All uncommitted work from ESP32 GDB stub development session.
Includes:
- GDB stub (uos_gdbstub.c/.h/_entry.S)
- Startup vector table rewrite
- ESP32 HAL integration files
- Boot chain design docs
- AGENTS.md absolute rules (commit before refactor, no unauthorized changes)
- All prior deepseek session work

This commit prevents further data loss. No claims of correctness.
2026-07-17 01:36:58 +01:00

26 KiB
Raw Blame History

ESP32 Complete Boot Chain Audit

Source: ESP-IDF v5.5.4 + esp-qemu
Audited: components/bootloader_support, components/spi_flash, esp-qemu hw/xtensa/esp32.c, hw/misc/esp32_dport.c
Target: Bare-metal UniversalisOS on ESP32 (Xtensa LX6 dual-core)


Table of Contents

  1. Stage 0: ROM Bootloader
  2. Stage 1: 2nd-Stage Bootloader (ESP-IDF)
  3. Image Header Format (.bin)
  4. Partition Table Format
  5. Flash Cache/MMU
  6. Secure Boot & Flash Encryption
  7. Minimum Bare-Metal Boot Requirements
  8. Complete Address Map Reference

Stage 0: ROM Bootloader

The ROM bootloader is in mask ROM (not flash). It executes from power-on/reset. It is not user-modifiable.

What the ROM Bootloader Does

  1. Reads strap pins to determine boot mode:

    • If strap mode has bit 4 set (strap & 0x10) or strap is 0x0cflash boot mode (normal)
    • Otherwise → download mode
    • Source: esp32.c:156: bool flash_boot_mode = ((strap_mode & 0x10) || (strap_mode & 0x1f) == 0x0c);
  2. Reads the image header at 0x1000 (CONFIG_BOOTLOADER_OFFSET_IN_FLASH):

    • The ROM expects the ESP32 image format (magic byte 0xE9)
    • It reads the 24-byte esp_image_header_t from flash offset 0x1000
    • From the header, it learns: segment count, SPI flash mode/speed/size, entry address
  3. Configures SPI flash based on the image header fields:

    • spi_mode (QIO/QOUT/DIO/DOUT)
    • spi_speed (div by 1/2/3/4)
    • spi_size (1MB to 128MB)
    • Sets up the WP pin and drive strength from header fields
  4. Loads segments to RAM:

    • Iterates through each segment header (load_addr, data_len)
    • Segments with load addresses in IRAM/DRAM ranges are copied to RAM
    • Segments with addresses in IROM/DROM ranges are left in flash (mapped via cache later)
  5. Hardware state left for the 2nd-stage bootloader:

    • CPU0 (PRO CPU) running, CPU1 (APP CPU) in reset/stalled
    • Stack: Valid ROM stack (top at 0x3FFE_3F20 = SOC_ROM_STACK_START)
    • SPI flash configured (mode/speed/size from header)
    • Flash cache: Initially enabled for ROM (DROM0 unmasked in normal boot, masked in serial boot)
    • Watchdogs: RWDT and MWDT0 flashboot protection auto-enabled
      • RWDT: RTC WDT flash boot mode
      • MWDT0: Timer Group 0 WDT flash boot mode
      • These will reset the chip if the bootloader takes too long
    • Entry point: PC set to entry_addr from the image header
    • CPU clock: May be at default/low speed (bootloader will increase it)

ROM Bootloader → call_start_cpu0()

The ROM bootloader loads the 2nd-stage bootloader image from 0x1000 and jumps to its entry point. In ESP-IDF, the entry point is call_start_cpu0() in bootloader_start.c.


Stage 1: 2nd-Stage Bootloader

Entry point: call_start_cpu0() in components/bootloader/subproject/main/bootloader_start.c

Call Flow

call_start_cpu0()                          [bootloader_start.c:26]
  ├─ bootloader_before_init()              (optional hook)
  ├─ bootloader_init()                     [bootloader_esp32.c:164]
  │    ├─ bootloader_init_mem()            (memory init)
  │    ├─ bootloader_clear_bss_section()
  │    ├─ bootloader_common_vddsdio_configure()
  │    ├─ bootloader_check_rated_cpu_clock()
  │    ├─ bootloader_clock_configure()     (set CPU to 240MHz/80MHz APB)
  │    ├─ bootloader_console_init()        (UART, now can use ESP_LOG)
  │    ├─ bootloader_reset_mmu()           (full MMU/cache reset)
  │    ├─ bootloader_flash_update_id()
  │    ├─ bootloader_flash_xmc_startup()
  │    ├─ bootloader_read_bootloader_header()
  │    ├─ bootloader_check_bootloader_validity()  (verify own image)
  │    ├─ bootloader_init_spi_flash()
  │    ├─ bootloader_check_wdt_reset()     (dump WDT reset info if applicable)
  │    ├─ bootloader_config_wdt()          (disable flashboot WDT)
  │    └─ bootloader_enable_random()
  │
  ├─ bootloader_utility_load_partition_table()  [bootloader_utility.c:144]
  │    └─ Reads partition table from 0x8000 (ESP_PARTITION_TABLE_OFFSET)
  │
  ├─ bootloader_utility_get_selected_boot_partition()
  │    └─ Determines boot partition (factory/OTA/test)
  │
  └─ bootloader_utility_load_boot_image()
       ├─ bootloader_load_image()          [esp_image_format.c:313]
       │    └─ image_load()                (verify + load segments)
       └─ load_image() → unpack_load_app() → set_cache_and_start_app()
            ├─ Configure flash MMU for DROM/IROM
            ├─ Enable cache buses
            ├─ Cache_Read_Enable(0)
            └─ Jump to entry_addr (function pointer call)

Watchdog Configuration (bootloader_config_wdt)

Source: bootloader_init.c:70-100

// 1. Disable RWDT flashboot protection
wdt_hal_write_protect_disable(&rwdt_ctx);
wdt_hal_set_flashboot_en(&rwdt_ctx, false);   // RWDT flashboot OFF
wdt_hal_write_protect_enable(&rwdt_ctx);

// 2. Optionally enable bootloader RWDT (CONFIG_BOOTLOADER_WDT_ENABLE)
//    Default: 9000ms timeout

// 3. Disable MWDT0 flashboot protection
wdt_hal_write_protect_disable(&mwdt_ctx);
wdt_hal_set_flashboot_en(&mwdt_ctx, false);   // MWDT0 flashboot OFF
wdt_hal_write_protect_enable(&mwdt_ctx);

Key point: The ROM bootloader enables two flashboot watchdogs:

  • RWDT (RTC WDT): In RTC_CNTL, flash boot protection stage
  • MWDT0 (Timer Group 0 WDT): In TIMG0, flash boot protection stage

Both auto-expire after a timeout (~9 seconds for MWDT0). The 2nd-stage bootloader must disable both or it will be reset. This is likely why bare-metal code crashes—the watchdogs fire.

MMU/Cache Reset (bootloader_reset_mmu)

Source: bootloader_esp32.c:43-69

Cache_Read_Disable(0);   // Disable cache for CPU0
Cache_Read_Disable(1);   // Disable cache for CPU1
Cache_Flush(0);          // Flush cache for CPU0
Cache_Flush(1);          // Flush cache for CPU1
mmu_init(0);             // Initialize MMU table for CPU0
mmu_init(1);             // Initialize MMU table for CPU1

// Clear DROM0 mask (ROM boot leaves it unmasked, serial boot masks it)
DPORT_REG_CLR_BIT(DPORT_PRO_CACHE_CTRL1_REG, DPORT_PRO_CACHE_MASK_DROM0);

Partition Table Loading

Source: bootloader_utility.c:144-273

  1. Memory-maps 0xC00 bytes from 0x8000 (ESP_PARTITION_TABLE_OFFSET)
  2. Calls esp_partition_table_verify() — checks magic bytes 0x50AA on each entry
  3. Iterates entries, populates bootloader_state_t:
    • bs->factory — factory app partition position
    • bs->ota[N] — OTA app partition slots
    • bs->test — test app partition
    • bs->ota_info — OTA data partition
  4. Terminated by entry with type=0xFF, subtype=0xFF

App Image Loading

Source: esp_image_format.c:161image_load()

  1. Read 24-byte header from partition offset
  2. Verify magic = 0xE9 (ESP_IMAGE_HEADER_MAGIC)
  3. Process each segment:
    • Read 8-byte segment header (load_addr, data_len)
    • If should_load(load_addr)memcpy data from flash-mmap to load_addr
    • If should_map(load_addr) → skip (will be cache-mapped at startup)
  4. Checksum: XOR all words, compare with appended checksum byte (initial value 0xEF)
  5. SHA-256 (if hash_appended flag set): verify appended 32-byte hash
  6. Signature (if secure boot): verify appended signature

should_load() vs should_map() Decision

Source: esp_image_format.c:889-940

// should_map(): flash-mapped, NOT loaded to RAM
bool should_map(uint32_t load_addr) {
    bool is_irom = (load_addr >= 0x400D0000) && (load_addr < 0x40400000);  // IROM
    bool is_drom = (load_addr >= 0x3F400000) && (load_addr < 0x3F800000);  // DROM
    return (is_irom || is_drom);
}

// should_load(): copied to RAM (IRAM/DRAM/RTC)
bool should_load(uint32_t load_addr) {
    if (should_map(load_addr)) return false;     // Flash-mapped segments not loaded
    if (load_addr < 0x10000000) return false;    // Reserved (padding, MD5 block)
    // RTC memory skipped during deep sleep wake
    return true;
}

Jump to Application (set_cache_and_start_app)

Source: bootloader_utility.c:1036-1158

// 1. Disable cache
Cache_Read_Disable(0);
Cache_Flush(0);

// 2. Reset MMU table
mmu_hal_unmap_all();

// 3. Map DROM segment (rodata in flash)
//    DROM vaddr: 0x3F400000-0x3F800000
cache_flash_mmu_set(0, 0, drom_load_addr, drom_addr, 64, drom_page_count);
cache_flash_mmu_set(1, 0, drom_load_addr, drom_addr, 64, drom_page_count);

// 4. Map IROM segment (text in flash)
//    IROM vaddr: 0x400D0000-0x40400000
cache_flash_mmu_set(0, 0, irom_load_addr, irom_addr, 64, irom_page_count);
cache_flash_mmu_set(1, 0, irom_load_addr, irom_addr, 64, irom_page_count);

// 5. Enable cache buses for both cores
cache_ll_l1_enable_bus(0, bus_mask);
cache_ll_l1_enable_bus(1, bus_mask);

// 6. Re-enable cache
Cache_Read_Enable(0);

// 7. Jump to entry point
typedef void (*entry_t)(void) __attribute__((noreturn));
entry_t entry = ((entry_t) entry_addr);
(*entry)();   // NEVER RETURNS

Image Header Format

esp_image_header_t (24 bytes)

Source: esp_app_format.h:81-111

Offset  Size  Field
------  ----  -----
0x00    1     magic           = 0xE9 (ESP_IMAGE_HEADER_MAGIC)
0x01    1     segment_count    (max 16)
0x02    1     spi_mode         (0=QIO, 1=QOUT, 2=DIO, 3=DOUT, 4=FAST_READ, 5=SLOW_READ)
0x03    1     [7:4] spi_speed  (0=÷2, 1=÷3, 2=÷4, 0xF=÷1)
              [3:0] spi_size   (0=1MB, 1=2MB, 2=4MB, 3=8MB, 4=16MB, ...)
0x04    4     entry_addr       (little-endian, address to jump to)
0x08    1     wp_pin           (WP pin config, 0xEE=disabled by IDF bootloader)
0x09    3     spi_pin_drv[3]   (drive strength for SPI flash pins)
0x0C    2     chip_id          (0x0000=ESP32, 0x0002=S2, 0x0005=C3, ...)
0x0E    1     min_chip_rev     (minimum chip revision)
0x0F    2     min_chip_rev_full (major*100 + minor)
0x11    2     max_chip_rev_full
0x13    4     reserved[4]
0x17    1     hash_appended    (1 = SHA-256 appended after checksum)

Total: 24 bytes. Verified: ESP_STATIC_ASSERT(sizeof(esp_image_header_t) == 24).

Segment Header (8 bytes each)

Source: esp_app_format.h:117-120

Offset  Size  Field
------  ----  -----
0x00    4     load_addr   (destination virtual address)
0x04    4     data_len    (length of segment data, must be 4-byte aligned)

Complete Binary Layout

[0x1000 for bootloader, or partition offset for app]
│
├── esp_image_header_t (24 bytes)
│   magic=0xE9, segment_count=N, entry_addr=0xXXXX...
│
├── Segment 0
│   ├── esp_image_segment_header_t (8 bytes): load_addr, data_len
│   └── data (data_len bytes, 4-byte aligned)
│
├── Segment 1
│   ├── esp_image_segment_header_t (8 bytes)
│   └── data
│
├── ... (up to 16 segments)
│
├── Checksum (1 byte)
│   XOR of all data words (header + segments), initial value 0xEF
│
├── [Optional] SHA-256 hash (32 bytes)
│   Present if hash_appended=1 in header
│   SHA-256 of everything from magic byte to checksum byte (inclusive)
│
└── [Optional] Secure boot signature
    Present if secure boot enabled

Checksum Algorithm

uint8_t checksum = 0xEF;  // ESP_ROM_CHECKSUM_INITIAL
// For each 32-bit word in header + all segment data:
checksum ^= (word & 0xFF) ^ ((word >> 8) & 0xFF) ^
            ((word >> 16) & 0xFF) ^ ((word >> 24) & 0xFF);
// Append as single byte after last segment

Partition Table Format

Location

  • Offset: 0x8000 (CONFIG_PARTITION_TABLE_OFFSET, default)
  • Size: 0x1000 (one flash sector = 4096 bytes)
  • Max data: 0xC00 bytes (ESP_PARTITION_TABLE_MAX_LEN)

Entry Format (32 bytes each)

Source: esp_flash_partitions.h:94-101

Offset  Size  Field
------  ----  -----
0x00    2     magic      = 0x50AA (ESP_PARTITION_MAGIC, little-endian: bytes AA 50)
0x02    1     type       (0x00=APP, 0x01=DATA, 0x02=BOOTLOADER, 0x03=PART_TABLE)
0x03    1     subtype    (APP: 0x00=factory, 0x10+0n=OTA, 0x20=test; DATA: 0x00=ota_data, 0x01=rf, 0x02=wifi, ...)
0x04    4     offset     (flash offset, little-endian)
0x08    4     size       (partition size, little-endian)
0x0C    16    label      (null-terminated ASCII name, padded with 0x00)
0x1C    4     flags      (bit 0: encrypted, bit 1: readonly)

Terminator Entry

magic    = 0x50AA  → Wait, no.
type     = 0xFF
subtype  = 0xFF

Actually the terminator uses type=0xFF, subtype=0xFF but the magic is still 0x50AA. No—the standard is:

  • Normal entries: magic = 0x50AA
  • MD5 checksum entry: magic = 0xEBEB, followed by 16-byte MD5 of all prior entries
  • End marker: type=0xFF, subtype=0xFF (with valid magic 0x50AA)

Minimum Partition Table

For bare-metal, you need at minimum:

Entry 0 (app):
  magic=0x50AA, type=0x00, subtype=0x00 (factory)
  offset=0x10000, size=0x100000 (1MB)
  label="factory\0\0\0\0\0\0\0\0\0"
  flags=0x00000000

Entry 1 (terminator):
  magic=0x50AA, type=0xFF, subtype=0xFF
  offset=0, size=0
  label=all zeros
  flags=0

Why You Need a Partition Table

Even for bare-metal, the 2nd-stage bootloader requires a valid partition table to find the app image. The bootloader calls bootloader_utility_load_partition_table() which fails (and reboots) if:

  • The magic bytes don't match 0x50AA
  • The table verification fails

Alternative: You can skip the partition table entirely by NOT using the 2nd-stage bootloader. Place your code directly at 0x1000 (the bootloader offset), and the ROM bootloader will load it directly. This is the true "bare minimum" approach.


Flash Cache/MMU

How Memory-Mapped Flash Works

The ESP32 has a flash cache subsystem that maps portions of SPI flash into the CPU address space. This allows executing code directly from flash (XIP - eXecute In Place).

MMU Table

Source: esp32_dport.c + esp32_dport.h

Register addresses (in DPORT address space, base 0x3FF0_0000):

Table Register Base Size
PRO DROM0 MMU 0x3FF1_0000 64 entries × 4 bytes = 256 bytes
PRO IRAM0 MMU 0x3FF1_0100 64 entries × 4 bytes = 256 bytes
APP DROM0 MMU 0x3FF1_2000 64 entries × 4 bytes = 256 bytes
APP IRAM0 MMU 0x3FF1_2100 64 entries × 4 bytes = 256 bytes

Each MMU entry is 32 bits:

  • Bits [8:0]: Physical page number in flash (MMU_ENTRY_MASK = 0x1FF = 512 pages)
  • Bit [8]: Invalid flag (0x100 = ESP32_CACHE_MMU_INVALID_VAL)
  • Each page = 0x10000 (64KB) = ESP32_CACHE_PAGE_SIZE

Total mappable per region: 64 pages × 64KB = 4MB per cache region.

Cache-Mapped Address Ranges (ESP32)

Source: soc.h:170-191

Region Start End Size Purpose
DROM 0x3F40_0000 0x3F80_0000 4MB Data ROM (rodata from flash)
IROM 0x400D_0000 0x4040_0000 3MB+ Instruction ROM (code from flash)
DRAM 0x3FFA_E000 0x4000_0000 ~330KB Internal SRAM (data)
IRAM 0x4008_0000 0x400A_A000 ~168KB Internal SRAM (instruction)
D/IRAM 0x3FFE_0000 0x4000_0000 128KB Dual-port (byte-swapped)
RTC IRAM 0x400C_0000 0x400C_2000 8KB RTC fast memory (instruction)
RTC DRAM 0x3FF8_0000 0x3FF8_2000 8KB RTC fast memory (data)
RTC DATA 0x5000_0000 0x5000_2000 8KB RTC slow memory
EXTRAM 0x3F80_0000 0x3FC0_0000 4MB PSRAM (if present)

Cache Control Registers

Source: dport_reg.h

Register Address Key Bits
DPORT_PRO_CACHE_CTRL 0x3FF0_0040 bit 3: CACHE_ENABLE
DPORT_PRO_CACHE_CTRL1 0x3FF0_0044 bit 0: MASK_IRAM0, bit 3: MASK_DRAM1, bit 4: MASK_DROM0
DPORT_APP_CACHE_CTRL 0x3FF0_0048 bit 3: CACHE_ENABLE
DPORT_APP_CACHE_CTRL1 0x3FF0_004C Same bit layout as PRO

When a MASK bit is set, that cache region is disabled (masked). The bootloader clears these bits to enable mapping.

How MMU Mapping Works (from QEMU source)

Source: esp32_dport.c:238-270

// For each MMU entry that changed:
uint32_t mmu_entry = crs->mmu_table[i];  // entry value
if (mmu_entry & 0x100) {                 // INVALID flag
    // Fill with illegal access return value
    fill_val = crs->illegal_access_retval;
} else {
    // Calculate physical flash address
    phys_addr = (mmu_entry & 0x1FF) * 0x10000;   // page_num * 64KB
    // Read from flash block device
    blk_pread(flash_blk, phys_addr, 0x10000, cache_page, 0);
}

The mapping formula is:

virtual_page_index = MMU table index (0-63)
physical_page = MMU_entry & 0x1FF

vaddr = DROM_BASE + virtual_page_index * 0x10000
paddr = physical_page * 0x10000

cache_flash_mmu_set() ROM Function

The bootloader uses this ROM function (from esp32/rom/cache.h) to set MMU entries:

int cache_flash_mmu_set(int cpu_no, int table_no, uint32_t vaddr,
                        uint32_t paddr, int psize, int num_pages);
// cpu_no: 0=PRO, 1=APP
// table_no: 0=DROM0, 1=IRAM0
// psize: 64 (page size in KB)
// Fills MMU table entries mapping vaddr→paddr for num_pages pages

Secure Boot & Flash Encryption

Secure Boot: OFF by Default

Secure boot is NOT enabled by default. It must be explicitly configured via Kconfig and eFuses.

Source: esp_image_format.c:36-46

#ifdef CONFIG_SECURE_SIGNED_ON_BOOT
#define SECURE_BOOT_CHECK_SIGNATURE 1
#else
#define SECURE_BOOT_CHECK_SIGNATURE 0   // Default: no signature check
#endif

Secure boot is controlled by eFuses:

  • ESP32 V1: ABS_DONE_0 eFuse bit
  • ESP32 V2: Secure boot key + signature verification

Once burned, secure boot is IRREVERSIBLE. Factory ESP32 chips ship with secure boot disabled.

Can Bare-Metal Ignore Secure Boot?

YES — if secure boot has never been enabled (eFuses not burned):

  • No signature verification occurs
  • No flash encryption is active
  • Your binary does not need any signature

NO — if secure boot has been previously enabled:

  • The ROM bootloader will reject unsigned images
  • You cannot bypass it (eFuse is one-time-programmable)
  • Flash contents must be encrypted if flash encryption is also on

Flash Encryption: OFF by Default

Flash encryption is also disabled by default. It's controlled by the FLASH_CRYPT_CNT eFuse. The bootloader auto-enables it only if CONFIG_SECURE_FLASH_ENC_ENABLED is set in Kconfig (default: NO).


Minimum Bare-Metal Boot Requirements

Option A: True Bare-Metal (No 2nd-Stage Bootloader)

Place your code directly at 0x1000. The ROM bootloader will load it.

Flash layout:

0x0000: [empty / bootloader digest area]
0x1000: YOUR BINARY (ESP32 image format)

Your binary must have:

  1. Valid 0xE9 magic byte
  2. Correct segment count
  3. At least one segment with load address in IRAM (0x4008_0000+) or DRAM (0x3FFA_E000+)
  4. entry_addr pointing to your code in IRAM
  5. Valid checksum byte after last segment
  6. SHA-256 hash appended (if hash_appended=1)

Your code's first instructions must:

  1. Disable the flashboot watchdogs (THE #1 REASON FOR CRASHES):
    // Disable RWDT flashboot protection
    WRITE_PERI_REG(RTC_CNTL_WDTWPROTECT_REG, 0x50D83AA1);  // write protect key
    CLEAR_PERI_REG_MASK(RTC_CNTL_WDTCONFIG0_REG, RTC_CNTL_WDT_FLASHBOOT_EN);
    SET_PERI_REG_MASK(RTC_CNTL_WDTWPROTECT_REG, 0);  // re-enable protection
    
    // Disable MWDT0 flashboot protection
    WRITE_PERI_REG(TIMG_WDTWPROTECT_REG(0), 0x50D83AA1);
    CLEAR_PERI_REG_MASK(TIMG_WDTCONFIG0_REG(0), TIMG_WDT_FLASHBOOT_EN);
    SET_PERI_REG_MASK(TIMG_WDTWPROTECT_REG(0), 0);
    
  2. Set up stack pointer (ROM leaves one, but you may want your own)
  3. Configure CPU clock if needed (default may be low)
  4. Initialize UART if you want serial output
  5. If executing from flash: configure flash cache/MMU

To execute code from flash (XIP), you need to:

  1. Set up MMU entries mapping your flash offset to 0x400D_0000 (IROM) or 0x3F40_0000 (DROM)
  2. Clear mask bits in DPORT_PRO_CACHE_CTRL1_REG
  3. Enable cache in DPORT_PRO_CACHE_CTRL_REG (bit 3)

Simplest approach: Put ALL code in IRAM. The ROM bootloader loads IRAM segments directly to RAM. No cache/MMU setup needed.

Option B: Use ESP-IDF Bootloader + Bare-Metal App

This is easier — let the ESP-IDF bootloader do the hard work.

Flash layout:

0x0000: [bootloader digest area]
0x1000: ESP-IDF bootloader (from esptool/IDF build)
0x8000: Partition table (minimum: 1 app entry + terminator)
0x10000: YOUR APP (ESP32 image format, loaded by bootloader)

Your app binary:

  1. Must have valid ESP32 image format (magic 0xE9, segments, checksum, hash)
  2. Entry point will be called by the bootloader as void entry(void) (naked function call)
  3. The bootloader has already:
    • Disabled watchdogs
    • Configured SPI flash and cache
    • Set CPU to full speed (240MHz)
    • Mapped DROM/IROM if your app has flash segments
    • Enabled cache

Your app's entry function receives:

  • No arguments
  • Valid stack (from bootloader)
  • Cache enabled
  • Watchdogs disabled
  • CPU at full speed
  • Single core (CPU1 still in reset)

Option C: Use esptool to Create Proper Images

# Create a proper ESP32 image from raw binary
esptool.py --chip esp32 image_info your_app.bin

# Or use esptool's elf2image
esptool.py --chip esp32 elf2image your_app.elf -o your_app.bin

# Flash the binary
esptool.py --chip esp32 --port /dev/ttyUSB0 write_flash 0x10000 your_app.bin

Why Bare-Metal Likely Crashes

  1. Watchdog timeout (MOST LIKELY): Flashboot RWDT/MWDT0 fire after ~9 seconds. The ROM bootloader enables them. If your code doesn't disable them, the chip resets.
  2. No valid image header: If the binary at 0x1000 or 0x10000 doesn't start with 0xE9 magic, the ROM bootloader or 2nd-stage bootloader rejects it.
  3. Invalid checksum: The 1-byte XOR checksum must be correct.
  4. Code in flash without cache/MMU: If your entry point is in IROM/DROM address range but you haven't set up the flash cache, the CPU fetches garbage.
  5. Stack issues: The ROM stack is at 0x3FFE_3F20. If your code uses deep stack before setting up its own, it may corrupt bootloader data.
  6. Wrong entry address: entry_addr in the header must point to valid, loaded code.

Complete Address Map Reference

Flash Layout (Default ESP-IDF)

Offset Size Content
0x0000 0x1000 Bootloader secure boot digest (if secure boot V1)
0x1000 0x7000 2nd-stage bootloader image
0x8000 0x1000 Partition table
0x9000 varies OTA data / NVS / other data partitions
0x10000 varies Application image (factory partition)

CPU Memory Map (ESP32)

0x00000000 - 0x1FFFFFFF  [Reserved / frame buffer]
0x20000000 - 0x2FFFFFFF  [Frame buffer (QEMU)]
0x3F400000 - 0x3F7FFFFF  DROM (flash rodata via cache, 4MB max)
0x3F800000 - 0x3FBFFFFF  EXTRAM (PSRAM, 4MB max)
0x3FF00000 - 0x3FF01FFF  DPORT registers
0x3FF10000 - 0x3FF12FFF  Flash MMU tables (PRO + APP, DROM0 + IRAM0)
0x3FF40000 - 0x3FF5FFFF  APB peripherals (UART, SPI, GPIO, etc.)
0x3FF80000 - 0x3FF81FFF  RTC FAST memory (data, 8KB)
0x3FF90000 - 0x3FF9FFFF  [Internal SRAM, byte-accessible]
0x3FFAE000 - 0x3FFFFFFF  DRAM (internal SRAM, ~330KB)
0x3FFE0000 - 0x3FFFFFFF  D/IRAM region (128KB, byte-swapped IRAM alias)
0x40000000 - 0x4006FFFF  IROM (mask ROM, cached)
0x40070000 - 0x40077FFF  PRO cache (ICACHE0, 32KB)
0x40078000 - 0x4007FFFF  APP cache (ICACHE1, 32KB)
0x40080000 - 0x400A9FFF  IRAM (internal SRAM for instructions, ~168KB)
0x400A0000 - 0x400BFFFF  D/IRAM region (IRAM alias, 128KB)
0x400C0000 - 0x400C1FFF  RTC FAST memory (instruction, 8KB)
0x400D0000 - 0x403FFFFF  IROM (flash instruction via cache, 3MB+ max)
0x50000000 - 0x50001FFF  RTC SLOW memory (8KB, data/instruction)

Key Register Addresses

Register Address Purpose
DPORT_PRO_CACHE_CTRL_REG 0x3FF00040 PRO cache enable (bit 3)
DPORT_PRO_CACHE_CTRL1_REG 0x3FF00044 PRO cache mask (bit 0=IRAM0, bit 3=DRAM1, bit 4=DROM0)
DPORT_APP_CACHE_CTRL_REG 0x3FF00048 APP cache enable
DPORT_APP_CACHE_CTRL1_REG 0x3FF0004C APP cache mask
DPORT_FLASH_MMU_TABLE_PRO 0x3FF10000 PRO DROM0 MMU (64 entries)
PRO IRAM0 MMU 0x3FF10100 PRO IRAM0 MMU (64 entries)
DPORT_FLASH_MMU_TABLE_APP 0x3FF12000 APP DROM0 MMU (64 entries)
APP IRAM0 MMU 0x3FF12100 APP IRAM0 MMU (64 entries)
RTC_CNTL WDT regs 0x3FF48000+ RWDT configuration
TIMG0 WDT regs 0x3FF5F000+ MWDT0 configuration

MMU Entry Format

Bits [8:0]  = Physical flash page number (each page = 64KB = 0x10000)
Bit  [8]    = Invalid flag (0x100) - if set, accesses return illegal_access_retval
Bits [31:9] = Reserved/unused

Maximum flash addressable: 512 pages × 64KB = 32MB.


Summary: Minimum Steps to Boot Bare-Metal on Real ESP32

Absolute Minimum (Code at 0x1000, all-IRAM)

  1. Create image with 0xE9 magic at flash offset 0x1000
  2. Single segment: load_addr = 0x40080000 (IRAM), data = your code
  3. entry_addr = address within that IRAM range
  4. Append checksum: XOR all 4-byte words (init 0xEF), append as 1 byte
  5. First instructions in your entry code:
    • Disable RWDT flashboot (write RTC_CNTL_WDTWPROTECT = 0x50D83AA1, clear flashboot bit, re-protect)
    • Disable MWDT0 flashboot (same pattern with TIMG0_WDTWPROTECT)
    • Set up stack pointer
    • Jump to main

Easier Path (Use ESP-IDF Bootloader)

  1. Flash ESP-IDF bootloader at 0x1000
  2. Flash minimal partition table at 0x8000 (1 factory entry + terminator)
  3. Flash your app at 0x10000 with proper image format
  4. Your app entry receives a clean environment (watchdogs off, cache on, CPU at 240MHz)
  5. The bootloader jumps to your entry as void entry(void) — just start executing

The #1 crash cause is almost certainly the flashboot watchdogs not being disabled if you're going true bare-metal at 0x1000. If using the IDF bootloader path, watchdogs are already handled.