# 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](#stage-0-rom-bootloader) 2. [Stage 1: 2nd-Stage Bootloader (ESP-IDF)](#stage-1-2nd-stage-bootloader) 3. [Image Header Format (.bin)](#image-header-format) 4. [Partition Table Format](#partition-table-format) 5. [Flash Cache/MMU](#flash-cachemmu) 6. [Secure Boot & Flash Encryption](#secure-boot--flash-encryption) 7. [Minimum Bare-Metal Boot Requirements](#minimum-bare-metal-boot-requirements) 8. [Complete Address Map Reference](#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 `0x0c` → **flash 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` ```c // 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` ```c 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:161` → `image_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` ```c // 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` ```c // 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 ```c 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` ```c // 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: ```c 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` ```c #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): ```c // 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 ```bash # 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.