From 220346f220f1d68fe2f2a91a3fea9a672545a197 Mon Sep 17 00:00:00 2001 From: David Montero Crespo Date: Tue, 17 Mar 2026 10:08:06 -0300 Subject: [PATCH] feat: enhance ESP32 emulation with SYSTIMER and interrupt handling, update GPIO pin mapping for power/GND --- backend/app/services/esp32_worker.py | 15 +- .../components/simulator/SimulatorCanvas.tsx | 5 +- frontend/src/simulation/Esp32C3Simulator.ts | 100 +++++++++++++- frontend/src/simulation/RiscVCore.ts | 129 +++++++++++++++++- frontend/src/utils/boardPinMapping.ts | 3 + 5 files changed, 238 insertions(+), 14 deletions(-) diff --git a/backend/app/services/esp32_worker.py b/backend/app/services/esp32_worker.py index dd2eed1..d286c09 100644 --- a/backend/app/services/esp32_worker.py +++ b/backend/app/services/esp32_worker.py @@ -308,6 +308,17 @@ def main() -> None: # noqa: C901 (complexity OK for inline worker) _init_done.set() lib.qemu_main_loop() + # With -nographic, qemu_init registers the stdio mux chardev which reads + # from fd 0. If we leave fd 0 as the JSON-command pipe from the parent, + # QEMU's mux will consume those bytes and forward them to UART0 RX, + # corrupting user-sent serial data. Redirect fd 0 to /dev/null before + # qemu_init runs so the mux gets EOF and leaves our command pipe alone. + # Save the original pipe fd for the command loop below. + _orig_stdin_fd = os.dup(0) + _nul = os.open(os.devnull, os.O_RDONLY) + os.dup2(_nul, 0) + os.close(_nul) + qemu_t = threading.Thread(target=_qemu_thread, daemon=True, name=f'qemu-{machine}') qemu_t.start() @@ -341,9 +352,9 @@ def main() -> None: # noqa: C901 (complexity OK for inline worker) threading.Thread(target=_ledc_poll_thread, daemon=True, name='ledc-poll').start() - # ── 8. Command loop (main thread reads stdin) ───────────────────────────── + # ── 8. Command loop (main thread reads original stdin pipe) ─────────────── - for raw_line in sys.stdin: + for raw_line in os.fdopen(_orig_stdin_fd, 'r'): raw_line = raw_line.strip() if not raw_line: continue diff --git a/frontend/src/components/simulator/SimulatorCanvas.tsx b/frontend/src/components/simulator/SimulatorCanvas.tsx index 4f91c8b..73d5798 100644 --- a/frontend/src/components/simulator/SimulatorCanvas.tsx +++ b/frontend/src/components/simulator/SimulatorCanvas.tsx @@ -499,11 +499,12 @@ export const SimulatorCanvas = () => { `[WirePin] component=${component.id} board=${otherEndpoint.componentId}` + ` kind=${lookupKey} pinName=${otherEndpoint.pinName} → gpioPin=${pin}` ); - if (pin !== null) { + if (pin !== null && pin >= 0) { subscribeComponentToPin(component, pin, selfEndpoint.pinName); - } else { + } else if (pin === null) { console.warn(`[WirePin] Could not resolve pin "${otherEndpoint.pinName}" on ${lookupKey}`); } + // pin === -1 → power/GND pin, skip silently } }); } diff --git a/frontend/src/simulation/Esp32C3Simulator.ts b/frontend/src/simulation/Esp32C3Simulator.ts index 6bb84e6..41aa23c 100644 --- a/frontend/src/simulation/Esp32C3Simulator.ts +++ b/frontend/src/simulation/Esp32C3Simulator.ts @@ -40,9 +40,31 @@ const GPIO_W1TC = 0x0C; // GPIO_OUT_W1TC — clear bits (write-only) const GPIO_IN = 0x3C; // GPIO_IN_REG — input value (read-only) const GPIO_ENABLE = 0x20; // GPIO_ENABLE_REG +// ── SYSTIMER @ 0x60023000 ──────────────────────────────────────────────────── +// The SYSTIMER runs at 16 MHz (CPU_HZ / 10). FreeRTOS programs TARGET0 to +// fire every 1 ms (16 000 SYSTIMER ticks = 160 000 CPU cycles) and routes the +// alarm interrupt to CPU interrupt 1 via the interrupt matrix. +const SYSTIMER_BASE = 0x60023000; +const SYSTIMER_SIZE = 0x100; +// Register offsets (ESP32-C3 TRM) +const ST_INT_ENA = 0x04; // TARGET0/1/2 enable bits +const ST_INT_RAW = 0x08; // raw interrupt status +const ST_INT_CLR = 0x0C; // write-1-to-clear +const ST_INT_ST = 0x10; // masked status (RAW & ENA) +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 + +// ── Interrupt Controller (no-op passthrough) @ 0x600C5000 ─────────────────── +// FreeRTOS configures source→CPU-int routing here; we handle routing ourselves. +const INTC_BASE = 0x600C5000; +const INTC_SIZE = 0x800; + // ── Clock ─────────────────────────────────────────────────────────────────── const CPU_HZ = 160_000_000; const CYCLES_PER_FRAME = Math.round(CPU_HZ / 60); +/** CPU cycles per FreeRTOS tick (1 ms at 160 MHz). */ +const CYCLES_PER_TICK = 160_000; export class Esp32C3Simulator { private core: RiscVCore; @@ -55,6 +77,10 @@ export class Esp32C3Simulator { private gpioOut = 0; private gpioIn = 0; + // SYSTIMER emulation state + private _stIntEna = 0; // ST_INT_ENA register + private _stIntRaw = 0; // ST_INT_RAW register (bit0 = TARGET0 fired) + public pinManager: PinManager; public onSerialData: ((ch: string) => void) | null = null; public onBaudRateChange: ((baud: number) => void) | null = null; @@ -93,6 +119,8 @@ export class Esp32C3Simulator { this._registerUart0(); this._registerGpio(); + this._registerSysTimer(); + this._registerIntCtrl(); this.core.reset(IROM_BASE); // Initialize SP to top of DRAM — MUST be after reset() which zeroes all regs @@ -152,7 +180,7 @@ export class Esp32C3Simulator { if (changed & (1 << bit)) { const state = !!(this.gpioOut & (1 << bit)); this.onPinChangeWithTime?.(bit, state, timeMs); - this.pinManager.setPinState(bit, state); + this.pinManager.triggerPinChange(bit, state); } } } @@ -160,6 +188,49 @@ export class Esp32C3Simulator { ); } + private _registerSysTimer(): void { + this.core.addMmio(SYSTIMER_BASE, SYSTIMER_SIZE, + (addr) => { + const off = addr - SYSTIMER_BASE; + const wordOff = off & ~3; + const byteIdx = off & 3; + let word = 0; + switch (wordOff) { + case ST_INT_ENA: word = this._stIntEna; break; + case ST_INT_RAW: word = this._stIntRaw; break; + case ST_INT_ST: word = this._stIntRaw & this._stIntEna; break; + 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; + } + return (word >> (byteIdx * 8)) & 0xFF; + }, + (addr, val) => { + const off = addr - SYSTIMER_BASE; + const wordOff = off & ~3; + const shift = (off & 3) * 8; + switch (wordOff) { + case ST_INT_ENA: + this._stIntEna = (this._stIntEna & ~(0xFF << shift)) | ((val & 0xFF) << shift); + break; + case ST_INT_CLR: + this._stIntRaw &= ~((val & 0xFF) << shift); + break; + } + }, + ); + } + + /** Interrupt-controller MMIO — FreeRTOS writes source→CPU-int routing here. + * We handle routing via direct triggerInterrupt() calls so this is a no-op. */ + private _registerIntCtrl(): void { + this.core.addMmio(INTC_BASE, INTC_SIZE, + (_addr) => 0, + (_addr, _val) => {}, + ); + } + // ── HEX loading ──────────────────────────────────────────────────────────── /** @@ -291,9 +362,11 @@ export class Esp32C3Simulator { reset(): void { this.stop(); - this.rxFifo = []; - this.gpioOut = 0; - this.gpioIn = 0; + this.rxFifo = []; + this.gpioOut = 0; + this.gpioIn = 0; + this._stIntEna = 0; + this._stIntRaw = 0; this.dram.fill(0); this.iram.fill(0); this.core.reset(IROM_BASE); @@ -319,9 +392,24 @@ export class Esp32C3Simulator { private _loop(): void { if (!this.running) return; - for (let i = 0; i < CYCLES_PER_FRAME; i++) { - this.core.step(); + + // Execute in 1 ms chunks so FreeRTOS tick interrupts fire at ~1 kHz. + // Each chunk corresponds to one SYSTIMER TARGET0 period (160 000 CPU cycles + // at 160 MHz = 16 000 SYSTIMER ticks at 16 MHz). + let rem = CYCLES_PER_FRAME; + while (rem > 0) { + const n = rem < CYCLES_PER_TICK ? rem : CYCLES_PER_TICK; + for (let i = 0; i < n; i++) { + this.core.step(); + } + rem -= n; + + // Raise SYSTIMER TARGET0 alarm → CPU interrupt 1 (FreeRTOS tick). + // mcause = 0x80000001: bit31=interrupt, bits[4:0]=CPU interrupt number 1. + this._stIntRaw |= 1; + this.core.triggerInterrupt(0x80000001); } + this.animFrameId = requestAnimationFrame(() => this._loop()); } } diff --git a/frontend/src/simulation/RiscVCore.ts b/frontend/src/simulation/RiscVCore.ts index f4dd5e3..df80d50 100644 --- a/frontend/src/simulation/RiscVCore.ts +++ b/frontend/src/simulation/RiscVCore.ts @@ -8,9 +8,12 @@ * Memory model: flat Uint8Array, caller supplies base address mappings. * MMIO: caller installs read/write hooks at specific address ranges. * + * Machine-mode CSR support: + * mstatus, mie, mtvec, mscratch, mepc, mcause, mtval, mcycle/cycle + * MRET, ECALL (cause=11), external interrupt dispatch via triggerInterrupt() + * * Limitations (acceptable for educational emulation): - * - No privilege levels / CSR side-effects (CSR reads return 0) - * - No interrupts / exceptions (ECALL/EBREAK are no-ops) + * - No privilege levels below M-mode * - No misalignment exceptions * - No RV32A (atomic) or floating-point extensions */ @@ -33,6 +36,24 @@ export class RiscVCore { /** CPU cycle counter */ cycles = 0; + // ── Machine-mode CSR registers ──────────────────────────────────────────── + /** 0x300 mstatus — bit3=MIE (global enable), bit7=MPIE, bits[12:11]=MPP */ + private mstatus = 0; + /** 0x304 mie — per-source interrupt enable mask */ + private mie = 0; + /** 0x305 mtvec — trap-vector base address + mode (bit0=vectored) */ + private mtvec = 0; + /** 0x340 mscratch — scratch register (used by FreeRTOS context switch) */ + private mscratch = 0; + /** 0x341 mepc — address of interrupted/excepting instruction */ + private mepc = 0; + /** 0x342 mcause — trap cause; bit31=interrupt, bits[4:0]=cause number */ + private mcause = 0; + /** 0x343 mtval — trap value (fault address / instruction bits) */ + private mtval = 0; + /** Pending async interrupt cause (bit31=1). Null when none pending. */ + pendingInterrupt: number | null = null; + private readonly mem: Uint8Array; private readonly memBase: number; private readonly mmioRegions: MmioRegion[] = []; @@ -55,6 +76,53 @@ export class RiscVCore { this.regs.fill(0); this.pc = resetVector; this.cycles = 0; + this.mstatus = 0; + this.mie = 0; + this.mtvec = 0; + this.mscratch = 0; + this.mepc = 0; + this.mcause = 0; + this.mtval = 0; + this.pendingInterrupt = null; + } + + /** + * Raise a machine-level interrupt. The cause is stored and will be taken at + * the next instruction boundary when mstatus.MIE (bit3) is set. + * Bit31=1 for asynchronous interrupts; bits[4:0] = CPU interrupt number. + */ + triggerInterrupt(cause: number): void { + this.pendingInterrupt = cause >>> 0; + } + + // ── CSR helpers ───────────────────────────────────────────────────────── + + private readCsr(addr: number): number { + switch (addr) { + case 0x300: return this.mstatus; + case 0x304: return this.mie; + case 0x305: return this.mtvec; + case 0x340: return this.mscratch; + case 0x341: return this.mepc; + case 0x342: return this.mcause; + case 0x343: return this.mtval; + case 0xB00: case 0xC00: return this.cycles >>> 0; // mcycle / cycle (low 32) + case 0xB80: case 0xC80: return 0; // mcycleh / cycleh + default: return 0; + } + } + + private writeCsr(addr: number, val: number): void { + switch (addr) { + case 0x300: this.mstatus = val; break; + case 0x304: this.mie = val; break; + case 0x305: this.mtvec = val; break; + case 0x340: this.mscratch = val; break; + case 0x341: this.mepc = val; break; + case 0x342: this.mcause = val; break; + case 0x343: this.mtval = val; break; + // cycle counters are read-only; ignore writes + } } // ── Memory access helpers ─────────────────────────────────────────────── @@ -309,6 +377,26 @@ export class RiscVCore { * for this simple model — real chips have variable latency). */ step(): number { + // ── Interrupt check ─────────────────────────────────────────────────── + // Take a pending interrupt if global interrupt enable (mstatus.MIE) is set. + if (this.pendingInterrupt !== null && (this.mstatus & 0x8)) { + const cause = this.pendingInterrupt; + this.pendingInterrupt = null; + const mieOld = (this.mstatus >> 3) & 1; // current MIE + this.mstatus = (this.mstatus & ~0x88) // clear MPIE (bit7) and MIE (bit3) + | (mieOld << 7); // MPIE = old MIE + this.mepc = this.pc; + this.mcause = cause; + const intNum = cause & 0x1f; + // Vectored mode (mtvec[1:0]==1): PC = base + 4*intNum + // Direct mode (mtvec[1:0]==0): PC = base + this.pc = ((this.mtvec & 3) === 1) + ? ((this.mtvec & ~3) >>> 0) + (intNum << 2) + : (this.mtvec & ~3) >>> 0; + this.cycles++; + return 1; + } + // RV32C: if bits [1:0] != 0b11, it's a 16-bit compressed instruction const half = this.readHalf(this.pc); let instr: number; @@ -461,9 +549,42 @@ export class RiscVCore { case 0x0f: break; - // SYSTEM (ECALL, EBREAK, CSR* — treat as no-op) - case 0x73: + // SYSTEM — CSR instructions, MRET, ECALL, EBREAK, WFI + case 0x73: { + const funct12 = (instr >> 20) & 0xfff; + if (funct3 === 0) { + // Privileged instructions (not CSR) + if (funct12 === 0x302) { + // MRET — return from machine trap + const mpie = (this.mstatus >> 7) & 1; + this.mstatus = (this.mstatus & ~0x8) | (mpie << 3); // MIE = MPIE + this.mstatus |= (1 << 7); // MPIE = 1 + nextPc = this.mepc >>> 0; + } else if (funct12 === 0x000) { + // ECALL — synchronous exception (cause=11 for M-mode) + // Used by FreeRTOS portYIELD to trigger a context switch. + const mieOld = (this.mstatus >> 3) & 1; + this.mstatus = (this.mstatus & ~0x88) | (mieOld << 7); + this.mepc = this.pc; // points at ecall; trap handler adds +4 + this.mcause = 11; // ecall from M-mode + nextPc = (this.mtvec & ~3) >>> 0; // always direct for exceptions + } + // EBREAK (0x001), WFI (0x105) → no-op (advance PC normally) + break; + } + // CSR instructions (funct3 != 0) + const csrAddr = funct12; + const csrOld = this.readCsr(csrAddr); + const isImm = (funct3 & 4) !== 0; // CSRRWI / CSRRSI / CSRRCI + const operand = isImm ? rs1 : this.reg(rs1); // zimm (5-bit) or register + this.setReg(rd, csrOld); + switch (funct3 & 3) { + case 1: this.writeCsr(csrAddr, operand); break; // CSRRW/I + case 2: if (operand !== 0) this.writeCsr(csrAddr, csrOld | operand); break; // CSRRS/I + case 3: if (operand !== 0) this.writeCsr(csrAddr, csrOld & ~operand); break; // CSRRC/I + } break; + } default: // Unknown opcode — skip instruction to avoid infinite loop diff --git a/frontend/src/utils/boardPinMapping.ts b/frontend/src/utils/boardPinMapping.ts index 9da39bd..00d2d1f 100644 --- a/frontend/src/utils/boardPinMapping.ts +++ b/frontend/src/utils/boardPinMapping.ts @@ -134,6 +134,9 @@ const ESP32_PIN_MAP: Record = { 'GPIO36': 36, 'GPIO39': 39, // ADC aliases 'VP': 36, 'VN': 39, + // Power / GND — not real GPIOs; mapped to -1 so WirePin skips silently + 'GND': -1, 'GND1': -1, 'GND2': -1, + 'VCC': -1, '3V3': -1, '3V3_OUT': -1, '5V': -1, 'VIN': -1, 'EN': -1, }; /** All known board component IDs in the simulator */