feat: enhance ESP32 emulation with SYSTIMER and interrupt handling, update GPIO pin mapping for power/GND

pull/47/head
David Montero Crespo 2026-03-17 10:08:06 -03:00
parent fdbc37b69b
commit 220346f220
5 changed files with 238 additions and 14 deletions

View File

@ -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

View File

@ -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
}
});
}

View File

@ -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 sourceCPU-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());
}
}

View File

@ -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

View File

@ -134,6 +134,9 @@ const ESP32_PIN_MAP: Record<string, number> = {
'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 */