feat: enhance ESP32 emulation with SYSTIMER and interrupt handling, update GPIO pin mapping for power/GND
parent
fdbc37b69b
commit
220346f220
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
Loading…
Reference in New Issue