feat: add SPI flash and EXTMEM controller stubs, implement echo-back for peripheral register writes

pull/47/head
David Montero Crespo 2026-03-17 22:11:52 -03:00
parent 507fa0671c
commit dcea546e45
1 changed files with 222 additions and 18 deletions

View File

@ -55,6 +55,25 @@ const ST_UNIT0_OP = 0x14; // write bit30 to snapshot counter
const ST_UNIT0_VAL_LO = 0x54; // snapshot value low 32 bits
const ST_UNIT0_VAL_HI = 0x58; // snapshot value high 32 bits
// ── SPI Flash Controllers ────────────────────────────────────────────────────
// SPI1 @ 0x60002000 — direct flash controller (boot-time flash access)
// SPI0 @ 0x60003000 — cache SPI controller (transparent flash cache)
// SPI_MEM_CMD_REG offset 0x00 bits [1731] are "write 1 to start, HW clears when done".
const SPI1_BASE = 0x60002000;
const SPI0_BASE = 0x60003000;
const SPI_SIZE = 0x200;
const SPI_CMD = 0x00; // SPI_MEM_CMD_REG — command trigger / status
// ── EXTMEM (cache controller) @ 0x600C4000 ──────────────────────────────────
// Manages ICache enable, invalidation, preload, and MMU configuration.
const EXTMEM_BASE = 0x600C4000;
const EXTMEM_SIZE = 0x1000;
// Key register offsets with "done" status bits that must read as 1:
const EXTMEM_ICACHE_SYNC_CTRL = 0x28; // bit1=SYNC_DONE
const EXTMEM_ICACHE_PRELOAD_CTRL = 0x34; // bit1=PRELOAD_DONE
const EXTMEM_ICACHE_AUTOLOAD_CTRL = 0x40; // bit3=AUTOLOAD_DONE
const EXTMEM_ICACHE_LOCK_CTRL = 0x1C; // bit2=LOCK_DONE
// ── Interrupt Controller (no-op passthrough) @ 0x600C5000 ───────────────────
// FreeRTOS configures source→CPU-int routing here; we handle routing ourselves.
const INTC_BASE = 0x600C5000;
@ -91,11 +110,24 @@ export class Esp32C3Simulator {
private _stIntEna = 0; // ST_INT_ENA register
private _stIntRaw = 0; // ST_INT_RAW register (bit0 = TARGET0 fired)
/**
* Shared peripheral register file echo-back map.
* Peripheral MMIO writes that aren't handled by specific logic are stored
* here keyed by word-aligned address so that subsequent reads return the
* last written value. This makes common "write → read-back → verify"
* patterns in the ESP-IDF boot succeed without dedicated stubs.
*/
private _periRegs = new Map<number, number>();
// ── Diagnostic state ─────────────────────────────────────────────────────
private _dbgFrameCount = 0;
private _dbgTickCount = 0;
private _dbgLastMtvec = 0;
private _dbgMieEnabled = false;
/** Track PC at the start of each tick for stuck-loop detection. */
private _dbgPrevTickPc = -1;
private _dbgSamePcCount = 0;
private _dbgStuckDumped = false;
public pinManager: PinManager;
public onSerialData: ((ch: string) => void) | null = null;
@ -148,6 +180,9 @@ export class Esp32C3Simulator {
this._registerTimerGroup(0x60027000); // TIMG1
this._registerTimerGroup(0x6001F000); // TIMG0 alternative (older ESP-IDF)
this._registerTimerGroup(0x60020000); // TIMG1 alternative
this._registerSpiFlash(SPI1_BASE); // SPI1 — direct flash controller
this._registerSpiFlash(SPI0_BASE); // SPI0 — cache SPI controller
this._registerExtMem();
this._registerRomStub();
this._registerRomStub2();
@ -219,12 +254,14 @@ export class Esp32C3Simulator {
}
private _registerSysTimer(): void {
const peri = this._periRegs;
this.core.addMmio(SYSTIMER_BASE, SYSTIMER_SIZE,
(addr) => {
const off = addr - SYSTIMER_BASE;
const wordOff = off & ~3;
const byteIdx = off & 3;
let word = 0;
let handled = true;
switch (wordOff) {
case ST_INT_ENA: word = this._stIntEna; break;
case ST_INT_RAW: word = this._stIntRaw; break;
@ -232,7 +269,12 @@ export class Esp32C3Simulator {
case ST_UNIT0_OP: word = (1 << 29); break; // VALID bit always set
case ST_UNIT0_VAL_LO: word = (this.core.cycles / 10) >>> 0; break;
case ST_UNIT0_VAL_HI: word = 0; break;
default: word = 0; break;
default: handled = false; break;
}
if (!handled) {
// Echo last written value for unknown offsets
const wordAddr = addr & ~3;
word = peri.get(wordAddr) ?? 0;
}
return (word >> (byteIdx * 8)) & 0xFF;
},
@ -247,17 +289,35 @@ export class Esp32C3Simulator {
case ST_INT_CLR:
this._stIntRaw &= ~((val & 0xFF) << shift);
break;
default: {
// Echo-back: store the written value
const wordAddr = addr & ~3;
const prev = peri.get(wordAddr) ?? 0;
peri.set(wordAddr, (prev & ~(0xFF << shift)) | ((val & 0xFF) << shift));
break;
}
}
},
);
}
/** Interrupt-controller MMIO FreeRTOS writes sourceCPU-int routing here.
* We handle routing via direct triggerInterrupt() calls so this is a no-op. */
* We handle routing via direct triggerInterrupt() calls; unknown offsets
* echo back the last written value so that read-back verification succeeds. */
private _registerIntCtrl(): void {
const peri = this._periRegs;
this.core.addMmio(INTC_BASE, INTC_SIZE,
(_addr) => 0,
(_addr, _val) => {},
(addr) => {
const wordAddr = addr & ~3;
const word = peri.get(wordAddr) ?? 0;
return (word >>> ((addr & 3) * 8)) & 0xFF;
},
(addr, val) => {
const wordAddr = addr & ~3;
const prev = peri.get(wordAddr) ?? 0;
const shift = (addr & 3) * 8;
peri.set(wordAddr, (prev & ~(0xFF << shift)) | ((val & 0xFF) << shift));
},
);
}
@ -297,6 +357,7 @@ export class Esp32C3Simulator {
*/
private _registerTimerGroup(base: number): void {
const seen = new Set<number>();
const peri = this._periRegs;
this.core.addMmio(base, 0x100,
(addr) => {
const off = addr - base;
@ -315,24 +376,119 @@ export class Esp32C3Simulator {
const word = (1000000 << 7); // 0x07A12000
return (word >>> ((off & 3) * 8)) & 0xFF;
}
return 0;
// Echo last written value for all other offsets
const wordAddr = addr & ~3;
const word = peri.get(wordAddr) ?? 0;
return (word >>> ((addr & 3) * 8)) & 0xFF;
},
(addr, val) => {
const wordAddr = addr & ~3;
const prev = peri.get(wordAddr) ?? 0;
const shift = (addr & 3) * 8;
peri.set(wordAddr, (prev & ~(0xFF << shift)) | ((val & 0xFF) << shift));
},
);
}
/**
* SPI flash controller stub (SPI0 / SPI1).
*
* SPI_MEM_CMD_REG (offset 0x00) bits [1731] are "write 1 to start operation,
* hardware clears when done". The firmware polls these bits after triggering
* flash reads, writes, erases, etc. We autoclear them so every flash
* operation appears to complete instantly.
*
* Other registers use echoback so configuration writes can be read back.
*/
private _registerSpiFlash(base: number): void {
const peri = this._periRegs;
this.core.addMmio(base, SPI_SIZE,
(addr) => {
const off = addr - base;
const wordOff = off & ~3;
if (wordOff === SPI_CMD) {
// Always return 0 for CMD register — all operations are "done"
return 0;
}
// Echo last written value for all other offsets
const wordAddr = addr & ~3;
const word = peri.get(wordAddr) ?? 0;
return (word >>> ((addr & 3) * 8)) & 0xFF;
},
(addr, val) => {
const wordAddr = addr & ~3;
const prev = peri.get(wordAddr) ?? 0;
const shift = (addr & 3) * 8;
peri.set(wordAddr, (prev & ~(0xFF << shift)) | ((val & 0xFF) << shift));
},
);
}
/**
* EXTMEM cache controller stub (0x600C4000).
*
* The ESP-IDF boot enables ICache, then triggers cache invalidation / sync /
* preload operations and polls "done" bits. We return all "done" bits as 1
* so these operations appear to complete instantly.
*/
private _registerExtMem(): void {
const peri = this._periRegs;
this.core.addMmio(EXTMEM_BASE, EXTMEM_SIZE,
(addr) => {
const off = addr - EXTMEM_BASE;
const wordOff = off & ~3;
// Return "done" bits for operations that the boot polls:
let override: number | null = null;
switch (wordOff) {
case EXTMEM_ICACHE_SYNC_CTRL: override = (1 << 1); break; // SYNC_DONE
case EXTMEM_ICACHE_PRELOAD_CTRL: override = (1 << 1); break; // PRELOAD_DONE
case EXTMEM_ICACHE_AUTOLOAD_CTRL: override = (1 << 3); break; // AUTOLOAD_DONE
case EXTMEM_ICACHE_LOCK_CTRL: override = (1 << 2); break; // LOCK_DONE
}
if (override !== null) {
// Merge override bits with any written value so enable bits are preserved
const wordAddr = addr & ~3;
const word = (peri.get(wordAddr) ?? 0) | override;
return (word >>> ((addr & 3) * 8)) & 0xFF;
}
// Echo last written value for all other offsets
const wordAddr = addr & ~3;
const word = peri.get(wordAddr) ?? 0;
return (word >>> ((addr & 3) * 8)) & 0xFF;
},
(addr, val) => {
const wordAddr = addr & ~3;
const prev = peri.get(wordAddr) ?? 0;
const shift = (addr & 3) * 8;
peri.set(wordAddr, (prev & ~(0xFF << shift)) | ((val & 0xFF) << shift));
},
(_addr, _val) => {},
);
}
/**
* Broad catch-all for the entire ESP32-C3 peripheral address space
* (0x600000000x6FFFFFFF). Returns 0 for any unmapped peripheral register
* so that the CPU doesn't fault or log warnings for writes during init.
* All narrower, more specific handlers (UART0, GPIO, SYSTIMER, INTC,
* RTC_CNTL ) have smaller MMIO sizes and therefore take priority via
* mmioFor's "smallest-size-wins" rule.
* (0x600000000x6FFFFFFF).
*
* Writes are stored in _periRegs so that the firmware's common
* "write config → read back → verify" pattern works for any peripheral
* register we haven't stubbed explicitly. All narrower, more specific
* handlers (UART0, GPIO, SYSTIMER, INTC, RTC_CNTL ) have smaller MMIO
* sizes and therefore take priority via mmioFor's "smallest-size-wins" rule.
*/
private _registerPeripheralCatchAll(): void {
const peri = this._periRegs;
this.core.addMmio(0x60000000, 0x10000000,
() => 0,
(_addr, _val) => {},
(addr) => {
const wordAddr = addr & ~3;
const word = peri.get(wordAddr) ?? 0;
return (word >>> ((addr & 3) * 8)) & 0xFF;
},
(addr, val) => {
const wordAddr = addr & ~3;
const prev = peri.get(wordAddr) ?? 0;
const shift = (addr & 3) * 8;
peri.set(wordAddr, (prev & ~(0xFF << shift)) | ((val & 0xFF) << shift));
},
);
}
@ -347,18 +503,31 @@ export class Esp32C3Simulator {
*/
private _registerRtcCntl(): void {
const RTC_BASE = 0x60008000;
const peri = this._periRegs;
this.core.addMmio(RTC_BASE, 0x1000,
(addr) => {
const off = addr - RTC_BASE;
const wordOff = off & ~3;
// offset 0x70 (RTC_CLK_CONF): TIME_VALID (bit 30) = 1 so rtc_clk_cal() exits.
// offset 0x38 (RESET_STATE): return 1 = ESP32C3_POWERON_RESET (matches QEMU).
const word = wordOff === 0x70 ? (1 << 30)
: wordOff === 0x38 ? 1
: 0;
return (word >>> ((off & 3) * 8)) & 0xFF;
if (wordOff === 0x70) {
const word = (1 << 30);
return (word >>> ((off & 3) * 8)) & 0xFF;
}
if (wordOff === 0x38) {
return off === (wordOff) ? 1 : 0; // byte 0 = 1, rest = 0
}
// Echo last written value for all other offsets
const wordAddr = addr & ~3;
const word = peri.get(wordAddr) ?? 0;
return (word >>> ((addr & 3) * 8)) & 0xFF;
},
(addr, val) => {
const wordAddr = addr & ~3;
const prev = peri.get(wordAddr) ?? 0;
const shift = (addr & 3) * 8;
peri.set(wordAddr, (prev & ~(0xFF << shift)) | ((val & 0xFF) << shift));
},
(_addr, _val) => {},
);
}
@ -410,6 +579,7 @@ export class Esp32C3Simulator {
this.rxFifo = [];
this.gpioOut = 0;
this.gpioIn = 0;
this._periRegs.clear();
this.core.reset(IROM_BASE);
this.core.regs[2] = (DRAM_BASE + DRAM_SIZE - 16) | 0;
}
@ -446,6 +616,7 @@ export class Esp32C3Simulator {
this.rxFifo = [];
this.gpioOut = 0;
this.gpioIn = 0;
this._periRegs.clear();
// Load each segment at its virtual address
for (const { loadAddr, data: seg } of parsed.segments) {
@ -503,6 +674,7 @@ export class Esp32C3Simulator {
this.gpioIn = 0;
this._stIntEna = 0;
this._stIntRaw = 0;
this._periRegs.clear();
this._dbgFrameCount = 0;
this._dbgTickCount = 0;
this._dbgLastMtvec = 0;
@ -620,6 +792,38 @@ export class Esp32C3Simulator {
);
}
// ── Stuck-loop detector ────────────────────────────────────────────
// If the PC hasn't changed across consecutive ticks (160 000 cycles),
// the CPU is stuck in a tight spin. Dump all registers once for
// post-mortem analysis so we can identify which peripheral or stub
// needs attention.
{
const curPc = this.core.pc;
if (curPc === this._dbgPrevTickPc) {
this._dbgSamePcCount++;
if (this._dbgSamePcCount >= 3 && !this._dbgStuckDumped) {
this._dbgStuckDumped = true;
console.warn(
`[ESP32-C3] ⚠ CPU stuck at pc=0x${curPc.toString(16)} for ${this._dbgSamePcCount} ticks — register dump:`
);
const regNames = [
'zero','ra','sp','gp','tp','t0','t1','t2',
's0','s1','a0','a1','a2','a3','a4','a5',
'a6','a7','s2','s3','s4','s5','s6','s7',
's8','s9','s10','s11','t3','t4','t5','t6',
];
for (let i = 0; i < 32; i++) {
console.warn(` x${i.toString().padStart(2)}(${regNames[i].padEnd(4)}) = 0x${(this.core.regs[i] >>> 0).toString(16).padStart(8, '0')}`);
}
console.warn(` mstatus=0x${(this.core.mstatusVal >>> 0).toString(16)} mtvec=0x${(this.core.mtvecVal >>> 0).toString(16)}`);
}
} else {
this._dbgSamePcCount = 0;
this._dbgStuckDumped = false;
}
this._dbgPrevTickPc = curPc;
}
// Raise SYSTIMER TARGET0 alarm → CPU interrupt 1 (FreeRTOS tick).
this._stIntRaw |= 1;
this.core.triggerInterrupt(0x80000001);