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.
26 KiB
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
- Stage 0: ROM Bootloader
- Stage 1: 2nd-Stage Bootloader (ESP-IDF)
- Image Header Format (.bin)
- Partition Table Format
- Flash Cache/MMU
- Secure Boot & Flash Encryption
- Minimum Bare-Metal Boot Requirements
- 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
-
Reads strap pins to determine boot mode:
- If strap mode has bit 4 set (
strap & 0x10) or strap is0x0c→ flash boot mode (normal) - Otherwise → download mode
- Source:
esp32.c:156:bool flash_boot_mode = ((strap_mode & 0x10) || (strap_mode & 0x1f) == 0x0c);
- If strap mode has bit 4 set (
-
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_tfrom flash offset0x1000 - From the header, it learns: segment count, SPI flash mode/speed/size, entry address
- The ROM expects the ESP32 image format (magic byte
-
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
-
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)
- Iterates through each segment header (
-
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_addrfrom 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
- Memory-maps 0xC00 bytes from
0x8000(ESP_PARTITION_TABLE_OFFSET) - Calls
esp_partition_table_verify()— checks magic bytes0x50AAon each entry - Iterates entries, populates
bootloader_state_t:bs->factory— factory app partition positionbs->ota[N]— OTA app partition slotsbs->test— test app partitionbs->ota_info— OTA data partition
- Terminated by entry with type=0xFF, subtype=0xFF
App Image Loading
Source: esp_image_format.c:161 → image_load()
- Read 24-byte header from partition offset
- Verify magic =
0xE9(ESP_IMAGE_HEADER_MAGIC) - Process each segment:
- Read 8-byte segment header (
load_addr,data_len) - If
should_load(load_addr)→memcpydata from flash-mmap toload_addr - If
should_map(load_addr)→ skip (will be cache-mapped at startup)
- Read 8-byte segment header (
- Checksum: XOR all words, compare with appended checksum byte (initial value
0xEF) - SHA-256 (if
hash_appendedflag set): verify appended 32-byte hash - 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:
0xC00bytes (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_0eFuse 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:
- Valid
0xE9magic byte - Correct segment count
- At least one segment with load address in IRAM (
0x4008_0000+) or DRAM (0x3FFA_E000+) entry_addrpointing to your code in IRAM- Valid checksum byte after last segment
- SHA-256 hash appended (if
hash_appended=1)
Your code's first instructions must:
- 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); - Set up stack pointer (ROM leaves one, but you may want your own)
- Configure CPU clock if needed (default may be low)
- Initialize UART if you want serial output
- If executing from flash: configure flash cache/MMU
To execute code from flash (XIP), you need to:
- Set up MMU entries mapping your flash offset to
0x400D_0000(IROM) or0x3F40_0000(DROM) - Clear mask bits in
DPORT_PRO_CACHE_CTRL1_REG - 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:
- Must have valid ESP32 image format (magic
0xE9, segments, checksum, hash) - Entry point will be called by the bootloader as
void entry(void)(naked function call) - 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
- 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.
- No valid image header: If the binary at
0x1000or0x10000doesn't start with0xE9magic, the ROM bootloader or 2nd-stage bootloader rejects it. - Invalid checksum: The 1-byte XOR checksum must be correct.
- 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.
- 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. - Wrong entry address:
entry_addrin 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)
- Create image with
0xE9magic at flash offset0x1000 - Single segment: load_addr =
0x40080000(IRAM), data = your code - entry_addr = address within that IRAM range
- Append checksum: XOR all 4-byte words (init
0xEF), append as 1 byte - 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
- Disable RWDT flashboot (write
Easier Path (Use ESP-IDF Bootloader)
- Flash ESP-IDF bootloader at
0x1000 - Flash minimal partition table at
0x8000(1 factory entry + terminator) - Flash your app at
0x10000with proper image format - Your app entry receives a clean environment (watchdogs off, cache on, CPU at 240MHz)
- 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.