🚀 How to correctly jump to a bare-metal app in external Flash (0x110000) ?

maksmatvienko
Posts: 1
Joined: Thu Jul 09, 2026 7:05 pm

🚀 How to correctly jump to a bare-metal app in external Flash (0x110000) ?

Postby maksmatvienko » Thu Jul 09, 2026 7:11 pm

🏷️ Labels: question, bootloader, esp32, xtensa, bare-metal
📌 Summary
I'm implementing a custom bare-metal bootloader for the original ESP32 (Xtensa LX6) and I'm stuck at the final step: jumping from the bootloader to an application image stored in external Flash at physical address 0x110000.
The application image is valid (magic 0xE9 is correct, headers parse fine), but the jump itself either triggers an exception or executes garbage. I need to understand the correct hardware sequence for performing this jump without any libraries — no ESP-IDF, no esp_rom_*, no HAL, only direct register access.
🛠️ Environment
Chip: ESP32 (original, Xtensa LX6, dual-core)
Flash size: 4 MB
App image location: Physical Flash address 0x110000
Compiler: ESP_IDF (bare-metal flags)
Framework: PlatformIO — pure C/C++ + inline asm
❓ Problem Statement
I need to answer three things:
How to convert the physical Flash address 0x110000 into the correct virtual IROM address that the CPU can execute from?
What is the exact hardware sequence for MMU reconfiguration + cache invalidation on Xtensa LX6?
What CPU state (stack pointer, registers, interrupts, windowed register file) must be prepared before executing jx?
💻 What I've Tried
Step 1 — Compute MMU page

Code: Select all

#define FLASH_PAGE_SIZE  0x10000UL   // 64 KB
uint32_t phys_page = 0x110000 / FLASH_PAGE_SIZE;   // = 0x11
Step 2 — Write MMU table entry

Code: Select all

#define IBUS_MMU_TABLE   0x3FF10000UL
volatile uint32_t* mmu = (volatile uint32_t*)IBUS_MMU_TABLE;
mmu[0] = (phys_page & 0xFF) | (1 << 8);   // page + valid bit
Step 3 — Invalidate I-cache

Code: Select all

#define PRO_CACHE_CTRL   0x3FF14000UL
volatile uint32_t* ctrl = (volatile uint32_t*)PRO_CACHE_CTRL;
*ctrl |= (1 << 0);
while (*ctrl & (1 << 0)) { /* spin */ }
Step 4 — Jump

Code: Select all

uint32_t entry_addr = /* parsed from image header */;

__asm__ __volatile__(
    "movi a1, 0x3FFD0000\n"   // reset stack pointer
    "mov  a2, %0\n"           // load entry into a2
    "jx   a2\n"               // jump
    :
    : "r"(entry_addr)
    : "a1", "a2", "memory"
);
🔴 Result
Either a Load/Store Prohibited exception, or
Execution of garbage instructions, or
Silent hang right after jx a2.
🤔 Specific Questions
Q1. What is the correct formula to derive the virtual IROM address from a physical Flash address on ESP32?
Is it 0x400D0000 + (phys_addr & 0xFFFF) or something else?
Q2. Is writing directly to the MMU table at 0x3FF10000 enough, or does the TRM require a specific sequence like: disable cache → configure MMU → enable cache → wait for ready flag?
Q3. On Xtensa LX6 with windowed registers, do I need to:
disable all interrupts first?
reset the register window (e.g. wsr a0, WINDOWBASE)?
clear a0 (return address) before the jump?
Q4. Are there any reserved memory regions that the bootloader must not touch?
📚 References I've Checked
ESP32 Technical Reference Manual — chapters on External Memory, Cache, MMU
https://www.espressif.com/sites/default ... ual_en.pdf
esp_image_loader.c from ESP-IDF (I understand the logic, but I want to reimplement it from scratch)
Xtensa ISA documentation for jx and cache instructions.

🙏 Request
Any of the following would be extremely helpful:
A minimal working snippet of a bare-metal jump to an app in external Flash on ESP32.
Pointers to the exact TRM sections describing the required cache/MMU sequence.
A reference implementation (even in another Espressif bootloader) that I can study.
A list of common pitfalls when doing this kind of low-level jump on Xtensa LX6.
Thanks a lot for your time! 🙌

MicroController
Posts: 2684
Joined: Mon Oct 17, 2022 7:38 pm
Location: Europe, Germany

Re: 🚀 How to correctly jump to a bare-metal app in external Flash (0x110000) ?

Postby MicroController » Fri Jul 10, 2026 7:07 am

A reference implementation (even in another Espressif bootloader) that I can study.
https://github.com/espressif/esp-idf/tr ... bootloader

igrr
Espressif staff
Espressif staff
Posts: 2077
Joined: Tue Dec 01, 2015 8:37 am

Re: 🚀 How to correctly jump to a bare-metal app in external Flash (0x110000) ?

Postby igrr » Fri Jul 10, 2026 9:57 am

Any of the following would be extremely helpful:
A minimal working snippet of a bare-metal jump to an app in external Flash on ESP32.
Please have a look at https://github.com/igrr/p155310-boot-demo. Disclaimer: code written by AI; I reviewed it afterwards, it looks correct.
Q1. What is the correct formula to derive the virtual IROM address from a physical Flash address on ESP32?
Is it 0x400D0000 + (phys_addr & 0xFFFF) or something else?
there is no formula applied to the address — the mapping is whatever you program into the flash MMU (in 64 KB pages). The CPU0's MMU table starts at 0x3FF10000 (DPORT_PRO_FLASH_MMU_TABLE) and contains 256 32-bit entries. Each entry holds a physical flash page number (flash_offset >> 16), bit 8 of the entry marks the entry invalid. The entry index determines the virtual page.

For the data bus (DROM): entry 0..63 -> vaddr 0x3F400000 + n*0x10000
For the instruction bus (IROM): entry 64..127 -> vaddr 0x40000000 + n*0x10000

(there is an additional range for PSRAM; check the link to mmu_ll.h below for more details)

So 0x400D0000 is entry 64 + 0xD/1 = 77 (at 0x3FF10134). To make flash 0x110000 executable at 0x400D0000, you need to write 0x11 (0x110000/64k) to entry 77.

Your code had two bugs here, as far as I can tell: mmu[0] maps the DROM window (0x3F400000) while you are trying to map IROM. For 0x400D0000 you need entry 77, and the valid/invalid bit is inverted in your code.
Q2. Is writing directly to the MMU table at 0x3FF10000 enough, or does the TRM require a specific sequence like: disable cache → configure MMU → enable cache → wait for ready flag?
The sequence is like this: disable the cache, flush the cache, wait for the flush to be finished, clear all the MMU entries (write 0x100 to each), then program your entry. Unmask IROM & DROM buses, enable the cache. See this and this.
Q3. On Xtensa LX6 with windowed registers, do I need to:
disable all interrupts first?
reset the register window (e.g. wsr a0, WINDOWBASE)?
clear a0 (return address) before the jump?
I think you are overcomplicating the jump, you can make just a regular C call to the app entry point, no need to write any Assembly here. Then you can switch to the new stack in the app itself. See the linked project below for an illustration. (In other words: a jump plus a stack switch is more complex to do than just a jump and then a stack switch.)
Q4. Are there any reserved memory regions that the bootloader must not touch?
The only regions to be aware of are: bootloader stacks (inherited from the ROM/1st-stage bootloader, one stack per CPU) and ROM static data (.data/.bss). If you never ever call a ROM function, you can clobber ROM .data/.bss, just remember that you are doing so in case you want to sneak in an ets_printf some time later...
Pointers to the exact TRM sections describing the required cache/MMU sequence.
They don't exist, sorry about that. The internals of the cache/MMU are not publicly documented in any of the ESP chips. However their operation is pretty straightforward, you can check the code in IDF: or ask AI to summarize the hardware description based on these files.
A reference implementation (even in another Espressif bootloader) that I can study.
Both ESP-IDF 2nd stage bootloader and MCUboot can be used as a reference.
A list of common pitfalls when doing this kind of low-level jump on Xtensa LX6.
If you aren't stuck with the original ESP32 for some specific reason, I would recommend working with a newer ESP32 series RISC-V chip instead. ESP32-C6 and newer chips have unified instruction and data address spaces which saves you some headaches when writing bare-metal code like this. Add to that the fact that RISC-V is just a bit better documented than Xtensa, and that ESP TRMs for these chips actually contain chapters about the CPU, interrupt controller, and so on. (No cache/MMU, still, though.)

In general, make sure the code is loaded/mapped correctly before making the jump. Dump a few words from the code region before you jump, or connect OpenOCD and use `mdw` command there to check what you actually have in memory. Once the code and data are in correct locations, the jump itself shouldn't be a problem.

Who is online

Users browsing this forum: ChatGPT-User, meta-externalagent, PerplexityBot and 3 guests