Add bare-metal ESP32-C3 blink example with simulator support

- Implemented a simple LED blink program in C for the ESP32-C3, targeting RV32IMC architecture.
- Created a linker script to define memory layout for the ESP32-C3.
- Added build script to compile the blink program using the riscv32-esp-elf-gcc toolchain.
- Developed Esp32C3Simulator to emulate ESP32-C3 behavior in the browser, including GPIO and UART functionality.
- Implemented utility functions to parse ESP32 flash images and handle loading of binary data.
- Included disassembly output for debugging purposes.
- Added necessary test fixtures for the blink example.
This commit is contained in:
David Montero Crespo 2026-03-15 18:40:08 -03:00
parent 7ab0f132a3
commit 74e7ec58c1
15 changed files with 1563 additions and 27 deletions

View File

@ -293,14 +293,12 @@ describe('Esp32Bridge — WebSocket protocol', () => {
s3Bridge.disconnect();
});
it('ESP32-C3 bridge sends esp32-c3 in start_esp32', () => {
it('ESP32-C3 no longer uses Esp32Bridge — uses browser-side Esp32C3Simulator', () => {
// ESP32-C3 was moved from QEMU to the browser RV32IMC emulator.
// Creating an Esp32Bridge manually still works (the class is not removed),
// but addBoard('esp32-c3') will now create an Esp32C3Simulator instead.
const c3Bridge = new Esp32Bridge('test-esp32-c3', 'esp32-c3');
c3Bridge.connect();
const c3Ws = (c3Bridge as any).socket as MockWebSocket;
c3Ws.open();
const msg = JSON.parse(c3Ws.sent[0]);
expect(msg.data.board).toBe('esp32-c3');
c3Bridge.disconnect();
expect(c3Bridge.boardKind).toBe('esp32-c3'); // bridge still instantiatable
});
});
@ -330,10 +328,12 @@ describe('useSimulatorStore — ESP32 boards', () => {
expect(getEsp32Bridge(id)?.boardKind).toBe('esp32-s3');
});
it('addBoard("esp32-c3") creates bridge with correct boardKind', () => {
it('addBoard("esp32-c3") creates an Esp32C3Simulator, not an Esp32Bridge', () => {
const { addBoard } = useSimulatorStore.getState();
const id = addBoard('esp32-c3', 300, 100);
expect(getEsp32Bridge(id)?.boardKind).toBe('esp32-c3');
// ESP32-C3 uses the browser-side RV32IMC emulator — no QEMU bridge
expect(getEsp32Bridge(id)).toBeUndefined();
expect(getBoardSimulator(id)).toBeDefined();
});
it('addBoard creates a file group with sketch.ino (not script.py)', () => {

View File

@ -0,0 +1,192 @@
/**
* ESP32-C3 blink integration test.
*
* Compiles a bare-metal C program with riscv32-esp-elf-gcc (the toolchain
* bundled with arduino-cli's ESP32 package), loads the raw binary into
* Esp32C3Simulator, and verifies that GPIO 8 toggles as expected.
*
* Why bare-metal and not a full Arduino sketch?
* The full ESP-IDF/Arduino framework requires dozens of peripherals
* (cache controller, interrupt matrix, RTC, FreeRTOS scheduler, etc.) that
* the browser-side emulator does not implement. A bare-metal program that
* writes directly to GPIO MMIO registers tests exactly what the emulator
* is built for: pure RV32IMC instruction execution with GPIO/UART MMIO.
*
* Source: frontend/src/__tests__/fixtures/esp32c3-blink/blink.c
*/
import { describe, it, expect, vi, beforeAll } from 'vitest';
import { execSync } from 'child_process';
import { readFileSync, existsSync } from 'fs';
import { join } from 'path';
import { Esp32C3Simulator } from '../simulation/Esp32C3Simulator';
import type { PinManager } from '../simulation/PinManager';
// ── Mocks (Node has no requestAnimationFrame) ─────────────────────────────────
vi.stubGlobal('requestAnimationFrame', (cb: () => void) => { cb(); return 0; });
vi.stubGlobal('cancelAnimationFrame', () => {});
// ── Paths ─────────────────────────────────────────────────────────────────────
const FIXTURE_DIR = join(__dirname, 'fixtures/esp32c3-blink');
const BIN_PATH = join(FIXTURE_DIR, 'blink.bin');
const DIS_PATH = join(FIXTURE_DIR, 'blink.dis');
const BUILD_SH = join(FIXTURE_DIR, 'build.sh');
// ── Helpers ───────────────────────────────────────────────────────────────────
function mockPinManager(): PinManager {
return {
setPinState: vi.fn(),
getPinState: vi.fn(() => false),
} as unknown as PinManager;
}
/** Run N CPU steps on the simulator's internal core */
function runSteps(sim: Esp32C3Simulator, n: number): void {
const core = (sim as unknown as { core: { step(): void } }).core;
for (let i = 0; i < n; i++) core.step();
}
// ── Build the binary once before all tests ────────────────────────────────────
let buildError: string | null = null;
let binData: Uint8Array;
beforeAll(() => {
// Rebuild whenever the source changes (or if binary is missing)
try {
execSync(`bash "${BUILD_SH}"`, { stdio: 'pipe' });
} catch (err: unknown) {
buildError = String((err as NodeJS.ErrnoException).stderr ?? err);
if (!existsSync(BIN_PATH)) return; // binary missing AND build failed
// Binary exists from a previous build — use it but warn
console.warn('[esp32c3-blink] Build script failed; using existing binary.\n' + buildError);
buildError = null;
}
binData = new Uint8Array(readFileSync(BIN_PATH));
});
// ── Tests ─────────────────────────────────────────────────────────────────────
describe('ESP32-C3 bare-metal blink (compiled with riscv32-esp-elf-gcc)', () => {
it('build.sh produces a non-empty blink.bin', () => {
if (buildError) throw new Error('Build failed:\n' + buildError);
expect(binData.byteLength).toBeGreaterThan(0);
expect(binData.byteLength).toBeLessThan(4096); // sanity: tiny bare-metal binary
console.log(`Binary size: ${binData.byteLength} bytes`);
});
it('binary starts with a valid RV32 instruction at 0x42000000', () => {
if (buildError) throw new Error('Build failed:\n' + buildError);
// First 4 bytes must decode as a valid 32-bit or 16-bit RISC-V instruction
const b0 = binData[0], b1 = binData[1];
const half = b0 | (b1 << 8);
const is32bit = (half & 0x3) === 0x3;
const is16bit = (half & 0x3) !== 0x3;
expect(is32bit || is16bit).toBe(true);
// First instruction in the disassembly should be _start at 0x42000000
if (existsSync(DIS_PATH)) {
const dis = readFileSync(DIS_PATH, 'utf8');
expect(dis).toContain('42000000 <_start>');
console.log('\nDisassembly (first 20 lines):\n' + dis.split('\n').slice(0, 20).join('\n'));
}
});
it('loadBin() loads the binary and resets PC to 0x42000000', () => {
if (buildError) throw new Error('Build failed:\n' + buildError);
const sim = new Esp32C3Simulator(mockPinManager());
sim.loadBin(binData);
const core = (sim as unknown as { core: { pc: number } }).core;
expect(core.pc).toBe(0x42000000);
});
it('GPIO 8 goes HIGH after the first SW to GPIO_W1TS', () => {
if (buildError) throw new Error('Build failed:\n' + buildError);
const pm = mockPinManager();
const sim = new Esp32C3Simulator(pm);
const pinEvents: Array<{ pin: number; state: boolean; timeMs: number }> = [];
sim.onPinChangeWithTime = (pin, state, timeMs) => pinEvents.push({ pin, state, timeMs });
sim.loadBin(binData);
// The blink program writes GPIO_W1TS within the first ~10 instructions
runSteps(sim, 20);
const gpio8Events = pinEvents.filter(e => e.pin === 8);
expect(gpio8Events.length).toBeGreaterThan(0);
expect(gpio8Events[0].state).toBe(true); // first event is LED ON
expect(gpio8Events[0].timeMs).toBeGreaterThan(0); // has a meaningful timestamp
console.log(`GPIO 8 went HIGH at ${gpio8Events[0].timeMs.toFixed(4)} ms (simulated)`);
});
it('GPIO 8 toggles ON and OFF within a full blink cycle', () => {
if (buildError) throw new Error('Build failed:\n' + buildError);
const pm = mockPinManager();
const sim = new Esp32C3Simulator(pm);
const pinEvents: Array<{ pin: number; state: boolean }> = [];
sim.onPinChangeWithTime = (pin, state) => pinEvents.push({ pin, state });
sim.loadBin(binData);
// delay(50) = ~200 instructions; two delays + GPIO writes ≈ 410 instructions/cycle
// Run 2000 steps → ~4 complete blink cycles
runSteps(sim, 2000);
const gpio8 = pinEvents.filter(e => e.pin === 8);
const highEvents = gpio8.filter(e => e.state === true);
const lowEvents = gpio8.filter(e => e.state === false);
expect(highEvents.length).toBeGreaterThanOrEqual(2); // at least 2 ON events
expect(lowEvents.length).toBeGreaterThanOrEqual(2); // at least 2 OFF events
// Events should strictly alternate: HIGH, LOW, HIGH, LOW ...
for (let i = 0; i < gpio8.length - 1; i++) {
expect(gpio8[i].state).not.toBe(gpio8[i + 1].state);
}
console.log(`GPIO 8 toggled ${gpio8.length} times in 2000 steps (${highEvents.length} ON, ${lowEvents.length} OFF)`);
});
it('PinManager.setPinState is called with correct pin and state', () => {
if (buildError) throw new Error('Build failed:\n' + buildError);
const pm = mockPinManager();
const sim = new Esp32C3Simulator(pm);
sim.loadBin(binData);
runSteps(sim, 2000);
// PinManager must have received GPIO 8 state changes
expect(pm.setPinState).toHaveBeenCalledWith(8, true);
expect(pm.setPinState).toHaveBeenCalledWith(8, false);
});
it('timestamps increase monotonically across blink events', () => {
if (buildError) throw new Error('Build failed:\n' + buildError);
const sim = new Esp32C3Simulator(mockPinManager());
const times: number[] = [];
sim.onPinChangeWithTime = (pin, _state, timeMs) => {
if (pin === 8) times.push(timeMs);
};
sim.loadBin(binData);
runSteps(sim, 2000);
expect(times.length).toBeGreaterThan(2);
for (let i = 1; i < times.length; i++) {
expect(times[i]).toBeGreaterThan(times[i - 1]);
}
const totalSimMs = times[times.length - 1];
console.log(`Simulated ${totalSimMs.toFixed(4)} ms over 2000 steps`);
});
it('reset() after run clears GPIO state', () => {
if (buildError) throw new Error('Build failed:\n' + buildError);
const sim = new Esp32C3Simulator(mockPinManager());
sim.loadBin(binData);
runSteps(sim, 500);
sim.reset();
expect(sim.isRunning()).toBe(false);
const core = (sim as unknown as { core: { pc: number }; gpioOut: number }).core;
expect(core.pc).toBe(0x42000000);
});
});

View File

@ -0,0 +1,543 @@
/**
* ESP32-C3 Browser Emulation Tests
*
* Tests the RV32IMC (Integer + Multiply + Compressed) emulator used
* for browser-side ESP32-C3 simulation without a QEMU backend.
*
* Test groups:
* 1. RV32M multiply/divide instructions (via RiscVCore directly)
* 2. RV32C 16-bit compressed instructions (via RiscVCore directly)
* 3. Esp32C3Simulator UART0 serial output
* 4. Esp32C3Simulator GPIO pin toggling
* 5. Lifecycle start/stop/reset
*/
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
import { RiscVCore } from '../simulation/RiscVCore';
import { Esp32C3Simulator } from '../simulation/Esp32C3Simulator';
import type { PinManager } from '../simulation/PinManager';
// ── Node environment stubs ───────────────────────────────────────────────────
let rafDepth = 0;
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
// Allow a bounded number of recursive RAF calls to test the loop
if (rafDepth < 2) { rafDepth++; cb(0); rafDepth--; }
return 1;
});
vi.stubGlobal('cancelAnimationFrame', () => {});
// ── Helpers ──────────────────────────────────────────────────────────────────
function writeWord(mem: Uint8Array, offset: number, val: number): void {
mem[offset] = val & 0xFF;
mem[offset + 1] = (val >> 8) & 0xFF;
mem[offset + 2] = (val >> 16) & 0xFF;
mem[offset + 3] = (val >> 24) & 0xFF;
}
function writeHalf(mem: Uint8Array, offset: number, val: number): void {
mem[offset] = val & 0xFF;
mem[offset + 1] = (val >> 8) & 0xFF;
}
/** Run exactly n steps on a core */
function runSteps(core: RiscVCore, n: number): void {
for (let i = 0; i < n; i++) core.step();
}
/** Create a minimal mock PinManager */
function mockPinManager(): PinManager {
return {
setPinState: vi.fn(),
getPinState: vi.fn(() => false),
registerPin: vi.fn(),
unregisterPin: vi.fn(),
} as unknown as PinManager;
}
/** Access the simulator's internal RiscVCore for direct testing */
function getCore(sim: Esp32C3Simulator): RiscVCore {
return (sim as unknown as { core: RiscVCore }).core;
}
/** Access the simulator's internal flash buffer for direct programming */
function getFlash(sim: Esp32C3Simulator): Uint8Array {
return (sim as unknown as { flash: Uint8Array }).flash;
}
// ── Test Group 1: RV32M (multiply/divide) ────────────────────────────────────
describe('RV32M — multiply/divide extension', () => {
let mem: Uint8Array;
let core: RiscVCore;
beforeEach(() => {
mem = new Uint8Array(64);
core = new RiscVCore(mem, 0);
core.reset(0);
});
it('MUL: 6 × 7 = 42', () => {
writeWord(mem, 0, 0x00600093); // ADDI x1, x0, 6
writeWord(mem, 4, 0x00700113); // ADDI x2, x0, 7
writeWord(mem, 8, 0x022081B3); // MUL x3, x1, x2
runSteps(core, 3);
expect(core.regs[3]).toBe(42);
});
it('MUL: negative × positive = negative', () => {
writeWord(mem, 0, 0xFFF00093); // ADDI x1, x0, -1
writeWord(mem, 4, 0x00300113); // ADDI x2, x0, 3
writeWord(mem, 8, 0x022081B3); // MUL x3, x1, x2
runSteps(core, 3);
expect(core.regs[3]).toBe(-3);
});
it('MULH: signed upper — (-1) × (-1) upper 32 bits = 0', () => {
// (-1) * (-1) = 1; upper 32 bits of 64-bit result = 0
writeWord(mem, 0, 0xFFF00093); // ADDI x1, x0, -1
writeWord(mem, 4, 0xFFF00113); // ADDI x2, x0, -1
writeWord(mem, 8, 0x022091B3); // MULH x3, x1, x2
runSteps(core, 3);
expect(core.regs[3]).toBe(0);
});
it('MULHU: unsigned upper — 0xFFFFFFFF × 0xFFFFFFFF upper 32 bits', () => {
// 0xFFFFFFFF * 0xFFFFFFFF = 0xFFFFFFFE_00000001; upper = 0xFFFFFFFE
writeWord(mem, 0, 0xFFF00093); // ADDI x1, x0, -1 (= 0xFFFFFFFF unsigned)
writeWord(mem, 4, 0xFFF00113); // ADDI x2, x0, -1
writeWord(mem, 8, 0x022081B3 | (3 << 12)); // MULHU x3, x1, x2 (funct3=3)
runSteps(core, 3);
expect(core.regs[3] >>> 0).toBe(0xFFFFFFFE);
});
it('DIV: 42 / 7 = 6', () => {
writeWord(mem, 0, 0x02A00093); // ADDI x1, x0, 42
writeWord(mem, 4, 0x00700113); // ADDI x2, x0, 7
// DIV x3, x1, x2: opcode=0x33, rd=3, funct3=4, rs1=1, rs2=2, funct7=1
// = (1<<25)|(2<<20)|(1<<15)|(4<<12)|(3<<7)|0x33 = 0x0220C1B3
writeWord(mem, 8, 0x0220C1B3); // DIV x3, x1, x2
runSteps(core, 3);
expect(core.regs[3]).toBe(6);
});
it('DIV: signed — -7 / 2 = -3 (truncate toward zero)', () => {
writeWord(mem, 0, 0xFF900093); // ADDI x1, x0, -7
writeWord(mem, 4, 0x00200113); // ADDI x2, x0, 2
writeWord(mem, 8, 0x0220C1B3); // DIV x3, x1, x2
runSteps(core, 3);
expect(core.regs[3]).toBe(-3);
});
it('DIV: divide by zero returns -1 (0xFFFFFFFF)', () => {
writeWord(mem, 0, 0x00500093); // ADDI x1, x0, 5
writeWord(mem, 4, 0x00000113); // ADDI x2, x0, 0
writeWord(mem, 8, 0x0220C1B3); // DIV x3, x1, x2
runSteps(core, 3);
expect(core.regs[3]).toBe(-1);
});
it('REM: 10 % 3 = 1', () => {
writeWord(mem, 0, 0x00A00093); // ADDI x1, x0, 10
writeWord(mem, 4, 0x00300113); // ADDI x2, x0, 3
// REM x3, x1, x2: funct3=6 → (1<<25)|(2<<20)|(1<<15)|(6<<12)|(3<<7)|0x33 = 0x0220E1B3
writeWord(mem, 8, 0x0220E1B3); // REM x3, x1, x2
runSteps(core, 3);
expect(core.regs[3]).toBe(1);
});
it('REM: divide by zero returns dividend', () => {
writeWord(mem, 0, 0x00700093); // ADDI x1, x0, 7
writeWord(mem, 4, 0x00000113); // ADDI x2, x0, 0
writeWord(mem, 8, 0x0220E1B3); // REM x3, x1, x2
runSteps(core, 3);
expect(core.regs[3]).toBe(7);
});
});
// ── Test Group 2: RV32C (compressed instructions) ───────────────────────────
describe('RV32C — 16-bit compressed instruction extension', () => {
let mem: Uint8Array;
let core: RiscVCore;
beforeEach(() => {
mem = new Uint8Array(64);
core = new RiscVCore(mem, 0);
core.reset(0);
});
it('C.LI x1, 5: loads immediate 5 into x1 and advances PC by 2', () => {
// C.LI: funct3=010, imm[5]=0, rd=x1(00001), imm[4:0]=00101, op=01
// Encoding: bit15..0 = 0_100_0_00001_00101_01 = 0x4095
writeHalf(mem, 0, 0x4095);
core.step();
expect(core.regs[1]).toBe(5);
expect(core.pc).toBe(2);
});
it('C.LI x2, -1: sign-extends negative immediate', () => {
// C.LI: funct3=010, imm[5]=1, rd=x2(00010), imm[4:0]=11111, op=01
// Encoding: bit15..0 = 0_100_1_00010_11111_01 = 0x5105
// Checking: bit12=1, bits[11:7]=00010=2, bits[6:2]=11111=31, bits[1:0]=01
// Value: 0_1_0_0 | 1_0_0_0 | 1_0_1_1 | 1_1_1_0_1 ... let me compute:
// op=01: bit1=0,bit0=1; imm[4:0]=11111: bit6=1,bit5=1,bit4=1,bit3=1,bit2=1
// rd=00010: bit11=0,bit10=0,bit9=0,bit8=1,bit7=0
// imm[5]=1: bit12=1; funct3=010: bit15=0,bit14=1,bit13=0
// = 0100 1000 1111 1101 wait...
// bit15=0,14=1,13=0,12=1,11=0,10=0,9=0,8=1,7=0,6=1,5=1,4=1,3=1,2=1,1=0,0=1
// = 0101 0001 0111 1101 = 0x517D? Let me just compute the halfword value:
// Bits (from bit15 to bit0):
// 0,1,0,1 | 0,0,0,1 | 0,1,1,1 | 1,1,0,1
// wait: funct3=010 → bits[15:13]=010: bit15=0,bit14=1,bit13=0
// bit12=1 (imm[5]=1)
// rd=2=00010: bit11=0,bit10=0,bit9=0,bit8=1,bit7=0
// imm[4:0]=11111: bit6=1,bit5=1,bit4=1,bit3=1,bit2=1
// op=01: bit1=0,bit0=1
// = 0100_1000_1111_1101 = 0x48FD? Wait let me re-group:
// bits 15..12: 0,1,0,1 = 0x5... no: bit15=0,bit14=1,bit13=0,bit12=1 → 0101 = 5? That's nibble 0101=5
// bits 11..8: 0,0,0,1 → 0001 = 1
// bits 7..4: 0,1,1,1 → 0111 = 7
// bits 3..0: 1,1,0,1 → 1101 = D
// So 0x517D. Let's verify the C.LI decode:
// half=0x517D: op=0x1, funct3=(0x517D>>13)&7=(0x28)&7=... 0x517D=20861, 20861>>13=2, 2&7=2 ✓ (funct3=2=C.LI)
// bit12=(0x517D>>12)&1=5&1=1 ✓
// rd=(0x517D>>7)&31=(0xA2)&31... 0x517D>>7=163, 163&31=3? Hmm that gives rd=3 not rd=2...
// 0x517D in binary: 0101 0001 0111 1101
// bits[11:7]: bit11=0,bit10=0,bit9=0,bit8=1,bit7=0 = 00010 = 2 ✓
// But (0x517D>>7) = 0101 0001 0 = 162, 162&31=162-160=2 ✓ (I miscalculated before)
// bits[6:2]: bit6=1,bit5=1,bit4=1,bit3=1,bit2=1 = 11111 = 31 ✓
// imm6 = sext((1<<5)|(31), 6) = sext(63, 6) = sext(0b111111, 6) = -1 ✓
writeHalf(mem, 0, 0x517D);
core.step();
expect(core.regs[2]).toBe(-1);
expect(core.pc).toBe(2);
});
it('C.ADDI x1, 3: adds immediate to register', () => {
// Preset x1=10
writeWord(mem, 0, 0x00A00093); // ADDI x1, x0, 10 (32-bit)
// C.ADDI x1, 3: funct3=000, imm[5]=0, rd=x1, imm[4:0]=00011, op=01
// bit12=0, bits[11:7]=00001, bits[6:2]=00011, bits[1:0]=01
// = 0000 0000 1000 1101 = 0x008D
writeHalf(mem, 4, 0x008D);
runSteps(core, 2);
expect(core.regs[1]).toBe(13);
expect(core.pc).toBe(6);
});
it('C.MV x5, x1: copies register (ADD x5, x0, x1)', () => {
// Preset x1=42
writeWord(mem, 0, 0x02A00093); // ADDI x1, x0, 42
// C.MV x5, x1: funct3=100, bit12=0, rd=x5(00101), rs2=x1(00001), op=10
// bit15=1,14=0,13=0,12=0,bits[11:7]=00101=5,bits[6:2]=00001=1,bits[1:0]=10
// = 1000 0010 1000 0110 = 0x8286
writeHalf(mem, 4, 0x8286);
runSteps(core, 2);
expect(core.regs[5]).toBe(42);
});
it('C.ADD x1, x2: adds two registers', () => {
writeWord(mem, 0, 0x00300093); // ADDI x1, x0, 3
writeWord(mem, 4, 0x00400113); // ADDI x2, x0, 4
// C.ADD x1, x2: funct3=100, bit12=1, rd=x1(00001), rs2=x2(00010), op=10
// bit15=1,14=0,13=0,12=1,bits[11:7]=00001,bits[6:2]=00010,bits[1:0]=10
// = 1001 0000 1000 1010 = 0x908A
writeHalf(mem, 8, 0x908A);
runSteps(core, 3);
expect(core.regs[1]).toBe(7);
});
it('C.J +4: jumps forward 4 bytes from compressed instruction', () => {
// C.J with offset=4, starting at PC=0
// CJ format: funct3=101, imm[3:1]=010 → bits[5:3]=010 → bit4=1, bits[1:0]=01
// = 1010_0000_0001_0001 = 0xA011
writeHalf(mem, 0, 0xA011);
core.step();
expect(core.pc).toBe(4); // 0 + 4
});
it('C.BEQZ x8, offset: branch taken when register is zero', () => {
// x8 is 0 (default), so branch should be taken
// C.BEQZ x8, +4: rs1'=x8(=0 encoded as 0b000), offset=4
// CB format: funct3=110, imm[8]=0, rs1'=000, imm[7:6]=00, imm[2:1]=10, imm[5]=0, op=01
// offset=4: imm[2:1]=10 → bits[4:3]=10=2, other imm bits=0
// bit15=0,14=1,13=1,12=0,bit11=0,bit10=0,bits[9:7]=000,bit6=0,bit5=0,bits[4:3]=10,bit2=0,bit1=0,bit0=1
// = 0110 0000 0001 0001 ... let me compute more carefully
// CB: bits[15:13]=110, bit[12]=imm[8]=0, bits[11:10]=imm[4:3]=00, bits[9:7]=rs1'=000
// bits[6:5]=imm[7:6]=00, bits[4:3]=imm[2:1]=10, bit[2]=imm[5]=0, bits[1:0]=01
// For offset=4: imm[2:1]=10 → bits[4:3]=10 → bit4=1, bit3=0
// = 1100_0000_0001_0001 = 0xC011
writeHalf(mem, 0, 0xC011);
core.step();
expect(core.pc).toBe(4);
});
it('C.BEQZ x8, offset: branch NOT taken when register is non-zero', () => {
core.regs[8] = 5;
writeHalf(mem, 0, 0xC011); // C.BEQZ x8, +4
core.step();
expect(core.pc).toBe(2); // falls through
});
it('C.SWSP + C.LWSP: stack round-trip', () => {
// Set sp (x2) to offset 32 within our buffer (so stack writes stay in bounds)
core.regs[2] = 32;
core.regs[1] = 0xDEAD;
// C.SWSP rs2=x1, offset=0:
// CSS: funct3=110, uimm[5:2]=bits[12:9]=0000, uimm[7:6]=bits[8:7]=00, rs2=bits[6:2]=00001, op=10
// = 1101 0000 0000 0110 = 0xD006? let me compute:
// bit15=1,14=1,13=0,12=0,bits[11:10]=uimm[5:4]=00,bits[9:7]=uimm[3:1]=000,bits[6:2]=rs2=00001,bits[1:0]=10
// Wait the spec says: bits[12:9]=uimm[5:2], bits[8:7]=uimm[7:6]
// For offset=0: all uimm bits=0 → bits[12:9]=0000, bits[8:7]=00
// = 1101 0000 0000 0110 = 0xD006?
// bit15=1,bit14=1,bit13=0,bit12=0 → 1100
// bits[11:10]=00, bits[9:8]=00 → 0000
// bits[7]=0 → 0
// bits[6:2]=00001 → bit6=0,bit5=0,bit4=0,bit3=0,bit2=1
// bits[1:0]=10
// = 1100 0000 0000 0110 = 0xC006
writeHalf(mem, 0, 0xC006); // C.SWSP x1, 0(sp)
// C.LWSP rd=x3, offset=0:
// CI: funct3=010, bit12=uimm[5]=0, rd=x3=00011, bits[6:4]=uimm[4:2]=000, bits[3:2]=uimm[7:6]=00, op=10
// = 0100 0001 1000 0010 = 0x4182
writeHalf(mem, 2, 0x4182); // C.LWSP x3, 0(sp)
runSteps(core, 2);
expect(core.regs[3]).toBe(0xDEAD);
});
});
// ── Test Group 3: Esp32C3Simulator — UART ───────────────────────────────────
describe('Esp32C3Simulator — UART0 serial output', () => {
let sim: Esp32C3Simulator;
beforeEach(() => {
sim = new Esp32C3Simulator(mockPinManager());
});
afterEach(() => {
sim.stop();
});
it('writing to UART0 FIFO (0x60000000) triggers onSerialData', () => {
const received: string[] = [];
sim.onSerialData = (ch) => received.push(ch);
const flash = getFlash(sim);
const core = getCore(sim);
// Program at IROM offset 0 (= address 0x42000000):
// LUI a1, 0x60000 → a1 = 0x60000000 (UART0_BASE)
// ADDI a0, x0, 72 → a0 = 72 = 'H'
// SB a0, 0(a1) → write byte to UART0 FIFO
writeWord(flash, 0, 0x600005B7); // LUI a1, 0x60000
writeWord(flash, 4, 0x04800513); // ADDI a0, x0, 72
writeWord(flash, 8, 0x00A58023); // SB a0, 0(a1)
core.reset(0x42000000);
runSteps(core, 3);
expect(received).toEqual(['H']);
});
it('writing multiple bytes emits each character', () => {
const received: string[] = [];
sim.onSerialData = (ch) => received.push(ch);
const flash = getFlash(sim);
const core = getCore(sim);
// LUI a1, 0x60000 → a1 = UART0_BASE
// ADDI a0, x0, 65 ('A')
// SB a0, 0(a1)
// ADDI a0, x0, 66 ('B')
// SB a0, 0(a1)
writeWord(flash, 0, 0x600005B7); // LUI a1, 0x60000
writeWord(flash, 4, 0x04100513); // ADDI a0, x0, 65 ('A')
writeWord(flash, 8, 0x00A58023); // SB a0, 0(a1)
writeWord(flash, 12, 0x04200513); // ADDI a0, x0, 66 ('B')
writeWord(flash, 16, 0x00A58023); // SB a0, 0(a1)
core.reset(0x42000000);
runSteps(core, 5);
expect(received).toEqual(['A', 'B']);
});
it('serialWrite injects bytes into RX FIFO, firmware can read them', () => {
const flash = getFlash(sim);
const core = getCore(sim);
sim.serialWrite('X');
// Program: LB a0, 0(a1) — reads from UART0_FIFO
// LUI a1, 0x60000 → a1 = 0x60000000
// LBU a0, 0(a1) → a0 = UART0_FIFO read
writeWord(flash, 0, 0x600005B7); // LUI a1, 0x60000
writeWord(flash, 4, 0x00058503); // LBU a0, 0(a1)
core.reset(0x42000000);
runSteps(core, 2);
expect(core.regs[10]).toBe('X'.charCodeAt(0)); // a0 = 88 = 'X'
});
});
// ── Test Group 4: Esp32C3Simulator — GPIO ────────────────────────────────────
describe('Esp32C3Simulator — GPIO pin toggling', () => {
let sim: Esp32C3Simulator;
beforeEach(() => {
sim = new Esp32C3Simulator(mockPinManager());
});
afterEach(() => {
sim.stop();
});
it('SW to GPIO_OUT_W1TS (offset +8) sets GPIO0 high', () => {
const pinChanges: Array<{ pin: number; state: boolean }> = [];
sim.onPinChangeWithTime = (pin, state) => pinChanges.push({ pin, state });
const flash = getFlash(sim);
const core = getCore(sim);
// LUI t1, 0x60004 → t1 = 0x60004000 (GPIO_BASE)
// ADDI t0, x0, 1 → t0 = 1 (bit 0 = GPIO0)
// SW t0, 8(t1) → write to GPIO_OUT_W1TS
writeWord(flash, 0, 0x60004337); // LUI t1, 0x60004
writeWord(flash, 4, 0x00100293); // ADDI t0, x0, 1
writeWord(flash, 8, 0x00532423); // SW t0, 8(t1) [offset 8 = W1TS]
core.reset(0x42000000);
runSteps(core, 3);
expect(pinChanges).toContainEqual({ pin: 0, state: true });
});
it('SW to GPIO_OUT_W1TC (offset +12) clears GPIO0', () => {
const pinChanges: Array<{ pin: number; state: boolean }> = [];
sim.onPinChangeWithTime = (pin, state) => pinChanges.push({ pin, state });
const flash = getFlash(sim);
const core = getCore(sim);
// First set GPIO0 high via W1TS, then clear via W1TC
writeWord(flash, 0, 0x60004337); // LUI t1, 0x60004
writeWord(flash, 4, 0x00100293); // ADDI t0, x0, 1
writeWord(flash, 8, 0x00532423); // SW t0, 8(t1) — set bit 0 (W1TS)
writeWord(flash, 12, 0x00532623); // SW t0, 12(t1) — clear bit 0 (W1TC)
core.reset(0x42000000);
runSteps(core, 4);
expect(pinChanges).toContainEqual({ pin: 0, state: true });
expect(pinChanges).toContainEqual({ pin: 0, state: false });
});
it('SW to GPIO_OUT sets multiple pins via direct write', () => {
const setPins: number[] = [];
sim.onPinChangeWithTime = (pin, state) => { if (state) setPins.push(pin); };
const flash = getFlash(sim);
const core = getCore(sim);
// Write 0b101 (bits 0 and 2) to GPIO_OUT (offset +4)
writeWord(flash, 0, 0x60004337); // LUI t1, 0x60004
writeWord(flash, 4, 0x00500293); // ADDI t0, x0, 5 (0b101)
writeWord(flash, 8, 0x00532223); // SW t0, 4(t1) — GPIO_OUT
core.reset(0x42000000);
runSteps(core, 3);
expect(setPins).toContain(0);
expect(setPins).toContain(2);
expect(setPins).not.toContain(1);
});
it('pinManager.setPinState is called on GPIO change', () => {
const pm = mockPinManager();
const s = new Esp32C3Simulator(pm);
const flash = getFlash(s);
const core = getCore(s);
writeWord(flash, 0, 0x60004337); // LUI t1, 0x60004
writeWord(flash, 4, 0x00100293); // ADDI t0, x0, 1
writeWord(flash, 8, 0x00532423); // SW t0, 8(t1)
core.reset(0x42000000);
runSteps(core, 3);
expect(pm.setPinState).toHaveBeenCalledWith(0, true);
s.stop();
});
});
// ── Test Group 5: Lifecycle ──────────────────────────────────────────────────
describe('Esp32C3Simulator — lifecycle', () => {
it('starts not running', () => {
const sim = new Esp32C3Simulator(mockPinManager());
expect(sim.isRunning()).toBe(false);
sim.stop();
});
it('start() sets running, stop() clears it', () => {
const sim = new Esp32C3Simulator(mockPinManager());
sim.start();
expect(sim.isRunning()).toBe(true);
sim.stop();
expect(sim.isRunning()).toBe(false);
});
it('reset() stops simulator and clears register state', () => {
const sim = new Esp32C3Simulator(mockPinManager());
const core = getCore(sim);
core.regs[1] = 999;
sim.start();
sim.reset();
expect(sim.isRunning()).toBe(false);
expect(core.regs[1]).toBe(0);
expect(core.pc).toBe(0x42000000);
});
it('reset() clears GPIO output state', () => {
const pinChanges: Array<{ pin: number; state: boolean }> = [];
const sim = new Esp32C3Simulator(mockPinManager());
sim.onPinChangeWithTime = (pin, state) => pinChanges.push({ pin, state });
const flash = getFlash(sim);
const core = getCore(sim);
writeWord(flash, 0, 0x60004337);
writeWord(flash, 4, 0x00100293);
writeWord(flash, 8, 0x00532423);
core.reset(0x42000000);
runSteps(core, 3);
sim.reset();
const gpioOut = (sim as unknown as { gpioOut: number }).gpioOut;
expect(gpioOut).toBe(0);
});
it('double start() is a no-op (does not create duplicate loops)', () => {
let rafCalls = 0;
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
rafCalls++;
// Don't recurse
return rafCalls;
});
const sim = new Esp32C3Simulator(mockPinManager());
sim.start();
sim.start(); // second call should be ignored
expect(rafCalls).toBe(1);
sim.stop();
});
});

View File

@ -0,0 +1,35 @@
/**
* ESP32-C3 bare-metal LED blink no ESP-IDF, no FreeRTOS.
*
* Targets RV32IMC directly. Compiled with the riscv32-esp-elf-gcc toolchain
* bundled inside the arduino-cli ESP32 package.
*
* Memory map (matches Esp32C3Simulator):
* Flash (IROM) @ 0x42000000 code lands here
* DRAM @ 0x3FC80000 stack/data
* GPIO_W1TS @ 0x60004008 set pin high
* GPIO_W1TC @ 0x6000400C set pin low
*
* LED: GPIO 8 (ESP32-C3-DevKit onboard LED)
*/
#define GPIO_W1TS (*(volatile unsigned int *)0x60004008u)
#define GPIO_W1TC (*(volatile unsigned int *)0x6000400Cu)
#define LED_PIN 8u
#define LED_BIT (1u << LED_PIN)
/* Short delay — small enough for the test to run fast */
static void delay(int n) {
for (volatile int i = 0; i < n; i++);
}
/* Entry point — the linker script places _start at 0x42000000 */
void _start(void) {
while (1) {
GPIO_W1TS = LED_BIT; /* LED ON — sets bit 8 of gpioOut */
delay(50);
GPIO_W1TC = LED_BIT; /* LED OFF — clears bit 8 of gpioOut */
delay(50);
}
}

View File

@ -0,0 +1,29 @@
E:/Hardware/wokwi_clon/frontend/src/__tests__/fixtures/esp32c3-blink/blink.elf: file format elf32-littleriscv
Disassembly of section .text:
42000000 <_start>:
42000000: 60004737 lui x14,0x60004
42000004: 1141 addi x2,x2,-16
42000006: 00870613 addi x12,x14,8 # 60004008 <_start+0x1e004008>
4200000a: 10000593 li x11,256
4200000e: 10000513 li x10,256
42000012: 03100693 li x13,49
42000016: c208 sw x10,0(x12)
42000018: c602 sw x0,12(x2)
4200001a: 47b2 lw x15,12(x2)
4200001c: 00f6db63 bge x13,x15,42000032 <_start+0x32>
42000020: c74c sw x11,12(x14)
42000022: c402 sw x0,8(x2)
42000024: 47a2 lw x15,8(x2)
42000026: fef6c8e3 blt x13,x15,42000016 <_start+0x16>
4200002a: 47a2 lw x15,8(x2)
4200002c: 0785 addi x15,x15,1
4200002e: c43e sw x15,8(x2)
42000030: bfd5 j 42000024 <_start+0x24>
42000032: 47b2 lw x15,12(x2)
42000034: 0785 addi x15,x15,1
42000036: c63e sw x15,12(x2)
42000038: b7cd j 4200001a <_start+0x1a>

View File

@ -0,0 +1,70 @@
#!/usr/bin/env bash
# Compile the bare-metal ESP32-C3 blink program using the riscv32-esp-elf-gcc
# toolchain that ships with the arduino-cli ESP32 package.
#
# Output:
# blink.elf — ELF with debug info
# blink.bin — raw binary, loaded directly into Esp32C3Simulator flash buffer
# blink.dis — disassembly for debugging
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# ── Locate toolchain ──────────────────────────────────────────────────────────
ARDUINO15="${LOCALAPPDATA:-$HOME/.arduino15}/Arduino15"
TOOLCHAIN_BASE="$ARDUINO15/packages/esp32/tools/riscv32-esp-elf-gcc"
if [ ! -d "$TOOLCHAIN_BASE" ]; then
echo "ERROR: ESP32 RISC-V toolchain not found at $TOOLCHAIN_BASE"
echo "Install with: arduino-cli core install esp32:esp32"
exit 1
fi
# Pick the newest version directory
TOOLCHAIN_VER=$(ls "$TOOLCHAIN_BASE" | sort -V | tail -1)
TOOLCHAIN_BIN="$TOOLCHAIN_BASE/$TOOLCHAIN_VER/bin"
GCC="$TOOLCHAIN_BIN/riscv32-esp-elf-gcc"
OBJCOPY="$TOOLCHAIN_BIN/riscv32-esp-elf-objcopy"
OBJDUMP="$TOOLCHAIN_BIN/riscv32-esp-elf-objdump"
if [ ! -f "$GCC.exe" ] && [ ! -f "$GCC" ]; then
echo "ERROR: riscv32-esp-elf-gcc not found in $TOOLCHAIN_BIN"
exit 1
fi
# On Windows inside Git Bash, .exe is appended automatically
GCC="${GCC}.exe" 2>/dev/null || GCC="$GCC"
OBJCOPY="${OBJCOPY}.exe" 2>/dev/null || OBJCOPY="$OBJCOPY"
OBJDUMP="${OBJDUMP}.exe" 2>/dev/null || OBJDUMP="$OBJDUMP"
echo "Toolchain: $TOOLCHAIN_BIN"
echo "Compiler: $GCC"
# ── Compile ───────────────────────────────────────────────────────────────────
"$GCC" \
-march=rv32imc \
-mabi=ilp32 \
-Os \
-ffunction-sections \
-fdata-sections \
-nostdlib \
-nostartfiles \
-T "$SCRIPT_DIR/link.ld" \
-Wl,--gc-sections \
-o "$SCRIPT_DIR/blink.elf" \
"$SCRIPT_DIR/blink.c"
# ── Extract raw binary (flash offset 0 → loads at IROM_BASE 0x42000000) ───────
"$OBJCOPY" -O binary "$SCRIPT_DIR/blink.elf" "$SCRIPT_DIR/blink.bin"
# ── Disassembly for inspection ─────────────────────────────────────────────────
"$OBJDUMP" -d -M numeric "$SCRIPT_DIR/blink.elf" > "$SCRIPT_DIR/blink.dis"
BIN_SIZE=$(wc -c < "$SCRIPT_DIR/blink.bin" | tr -d ' ')
echo ""
echo "✓ blink.bin — ${BIN_SIZE} bytes"
echo "✓ blink.dis — disassembly"
echo ""
echo "Entry point: 0x42000000 (first instruction in flash)"

View File

@ -0,0 +1,33 @@
/*
* Linker script for ESP32-C3 bare-metal blink.
*
* Places .text at 0x42000000 (IROM_BASE — the flash instruction region
* that Esp32C3Simulator maps as its primary memory).
* Stack lives at the top of DRAM (0x3FC80000 + 384KB).
*/
OUTPUT_ARCH(riscv)
ENTRY(_start)
MEMORY {
IROM (rx) : ORIGIN = 0x42000000, LENGTH = 4M
DRAM (rwx) : ORIGIN = 0x3FC80000, LENGTH = 384K
}
SECTIONS {
.text 0x42000000 : {
KEEP(*(.text.startup))
*(.text*)
*(.rodata*)
} > IROM
.data : {
*(.data*)
} > DRAM AT > IROM
.bss : {
*(.bss*)
*(COMMON)
} > DRAM
/DISCARD/ : { *(.comment) *(.note*) *(.eh_frame*) }
}

View File

@ -131,7 +131,7 @@ export const EditorToolbar = ({ consoleOpen, setConsoleOpen, compileLogs: _compi
const handleRun = () => {
if (activeBoardId) {
const board = boards.find((b) => b.id === activeBoardId);
const isQemuBoard = board?.boardKind === 'raspberry-pi-3' || board?.boardKind === 'esp32' || board?.boardKind === 'esp32-s3' || board?.boardKind === 'esp32-c3';
const isQemuBoard = board?.boardKind === 'raspberry-pi-3' || board?.boardKind === 'esp32' || board?.boardKind === 'esp32-s3';
if (isQemuBoard || board?.compiledProgram) {
startBoard(activeBoardId);
setMessage(null);
@ -213,7 +213,7 @@ export const EditorToolbar = ({ consoleOpen, setConsoleOpen, compileLogs: _compi
const boardsList = useSimulatorStore.getState().boards;
for (const board of boardsList) {
const isQemu = board.boardKind === 'raspberry-pi-3' ||
board.boardKind === 'esp32' || board.boardKind === 'esp32-s3' || board.boardKind === 'esp32-c3';
board.boardKind === 'esp32' || board.boardKind === 'esp32-s3';
if (!board.running && (isQemu || board.compiledProgram)) {
startBoard(board.id);
}
@ -306,7 +306,7 @@ export const EditorToolbar = ({ consoleOpen, setConsoleOpen, compileLogs: _compi
{/* Run */}
<button
onClick={handleRun}
disabled={running || (!['raspberry-pi-3','esp32','esp32-s3','esp32-c3'].includes(activeBoard?.boardKind ?? '') && !compiledHex && !activeBoard?.compiledProgram)}
disabled={running || (!['raspberry-pi-3','esp32','esp32-s3'].includes(activeBoard?.boardKind ?? '') && !compiledHex && !activeBoard?.compiledProgram)}
className="tb-btn tb-btn-run"
title="Run"
>

View File

@ -0,0 +1,327 @@
/**
* Esp32C3Simulator Browser-side ESP32-C3 emulator.
*
* Wraps RiscVCore (RV32IMC) with:
* - ESP32-C3 memory map: Flash IROM/DROM @ 0x42000000/0x3C000000,
* DRAM @ 0x3FC80000, IRAM @ 0x4037C000
* - UART0 MMIO @ 0x60000000 (serial I/O)
* - GPIO MMIO @ 0x60004000 (pin output via OUT/W1TS/W1TC registers)
* - 160 MHz clock, requestAnimationFrame execution loop
* - Same public interface as AVRSimulator / RiscVSimulator
*/
import { RiscVCore } from './RiscVCore';
import type { PinManager } from './PinManager';
import { hexToUint8Array } from '../utils/hexParser';
import { parseMergedFlashImage } from '../utils/esp32ImageParser';
// ── ESP32-C3 Memory Map ──────────────────────────────────────────────────────
const IROM_BASE = 0x42000000; // Flash instruction region (mapped via MMU)
const DROM_BASE = 0x3C000000; // Flash data region (read-only alias of same flash)
const DRAM_BASE = 0x3FC80000; // Data RAM
const IRAM_BASE = 0x4037C000; // Instruction RAM
const IROM_SIZE = 4 * 1024 * 1024; // 4 MB flash buffer
const DRAM_SIZE = 384 * 1024; // 384 KB DRAM
const IRAM_SIZE = 384 * 1024; // 384 KB IRAM
// ── UART0 @ 0x60000000 ──────────────────────────────────────────────────────
const UART0_BASE = 0x60000000;
const UART0_SIZE = 0x400;
const UART0_FIFO = 0x00; // write TX byte / read RX byte
const UART0_STATUS = 0x1C; // TXFIFO_CNT in bits [19:16] (0 = empty = ready)
// ── GPIO @ 0x60004000 ───────────────────────────────────────────────────────
const GPIO_BASE = 0x60004000;
const GPIO_SIZE = 0x200;
const GPIO_OUT = 0x04; // GPIO_OUT_REG — output value (read/write)
const GPIO_W1TS = 0x08; // GPIO_OUT_W1TS — set bits (write-only)
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
// ── Clock ───────────────────────────────────────────────────────────────────
const CPU_HZ = 160_000_000;
const CYCLES_PER_FRAME = Math.round(CPU_HZ / 60);
export class Esp32C3Simulator {
private core: RiscVCore;
private flash: Uint8Array;
private dram: Uint8Array;
private iram: Uint8Array;
private running = false;
private animFrameId = 0;
private rxFifo: number[] = [];
private gpioOut = 0;
private gpioIn = 0;
public pinManager: PinManager;
public onSerialData: ((ch: string) => void) | null = null;
public onBaudRateChange: ((baud: number) => void) | null = null;
public onPinChangeWithTime: ((pin: number, state: boolean, timeMs: number) => void) | null = null;
constructor(pinManager: PinManager) {
this.pinManager = pinManager;
// Flash is the primary (fast-path) memory region
this.flash = new Uint8Array(IROM_SIZE);
this.dram = new Uint8Array(DRAM_SIZE);
this.iram = new Uint8Array(IRAM_SIZE);
this.core = new RiscVCore(this.flash, IROM_BASE);
// DROM — read-only alias of the same flash buffer at a different virtual address
const flash = this.flash;
this.core.addMmio(DROM_BASE, IROM_SIZE,
(addr) => flash[addr - DROM_BASE] ?? 0,
() => {},
);
// DRAM (384 KB)
const dram = this.dram;
this.core.addMmio(DRAM_BASE, DRAM_SIZE,
(addr) => dram[addr - DRAM_BASE],
(addr, val) => { dram[addr - DRAM_BASE] = val; },
);
// IRAM (384 KB)
const iram = this.iram;
this.core.addMmio(IRAM_BASE, IRAM_SIZE,
(addr) => iram[addr - IRAM_BASE],
(addr, val) => { iram[addr - IRAM_BASE] = val; },
);
this._registerUart0();
this._registerGpio();
this.core.reset(IROM_BASE);
// Initialize SP to top of DRAM — MUST be after reset() which zeroes all regs
this.core.regs[2] = (DRAM_BASE + DRAM_SIZE - 16) | 0;
}
// ── MMIO registration ──────────────────────────────────────────────────────
private _registerUart0(): void {
this.core.addMmio(UART0_BASE, UART0_SIZE,
(addr) => {
const off = addr - UART0_BASE;
if (off === UART0_FIFO) return this.rxFifo.length > 0 ? (this.rxFifo.shift()! & 0xFF) : 0;
if (off === UART0_STATUS) return 0; // TXFIFO always empty = ready to accept data
return 0;
},
(addr, val) => {
if (addr - UART0_BASE === UART0_FIFO) {
this.onSerialData?.(String.fromCharCode(val & 0xFF));
}
},
);
}
private _registerGpio(): void {
this.core.addMmio(GPIO_BASE, GPIO_SIZE,
(addr) => {
const off = (addr - GPIO_BASE) & ~3; // word-align for register lookup
const byteIdx = (addr - GPIO_BASE) & 3;
if (off === GPIO_OUT) return (this.gpioOut >> (byteIdx * 8)) & 0xFF;
if (off === GPIO_IN) return (this.gpioIn >> (byteIdx * 8)) & 0xFF;
if (off === GPIO_ENABLE) return 0xFF;
return 0;
},
(addr, val) => {
const off = (addr - GPIO_BASE) & ~3;
const byteIdx = (addr - GPIO_BASE) & 3;
const shift = byteIdx * 8;
const byteMask = 0xFF << shift;
const prev = this.gpioOut;
if (off === GPIO_W1TS) {
// Set bits — each byte write sets corresponding bits
this.gpioOut |= (val & 0xFF) << shift;
} else if (off === GPIO_W1TC) {
// Clear bits
this.gpioOut &= ~((val & 0xFF) << shift);
} else if (off === GPIO_OUT) {
// Direct write — reconstruct 32-bit value byte by byte
this.gpioOut = (this.gpioOut & ~byteMask) | ((val & 0xFF) << shift);
}
const changed = prev ^ this.gpioOut;
if (changed) {
const timeMs = (this.core.cycles / CPU_HZ) * 1000;
for (let bit = 0; bit < 22; bit++) { // ESP32-C3 has GPIO0GPIO21
if (changed & (1 << bit)) {
const state = !!(this.gpioOut & (1 << bit));
this.onPinChangeWithTime?.(bit, state, timeMs);
this.pinManager.setPinState(bit, state);
}
}
}
},
);
}
// ── HEX loading ────────────────────────────────────────────────────────────
/**
* Load an Intel HEX file. The hex addresses must be relative to IROM_BASE
* (0x42000000), or zero-based (the parser will treat them as flash offsets).
*/
loadHex(hexContent: string): void {
this.flash.fill(0);
const bytes = hexToUint8Array(hexContent);
// hexToUint8Array returns bytes indexed from address 0.
// If the hex records used IROM_BASE-relative addressing, the byte array
// will start at offset IROM_BASE within a huge buffer — we can't use that.
// Support both:
// a) Small array (< IROM_SIZE) → direct flash offset mapping
// b) Large array → slice from IROM_BASE offset if present
if (bytes.length <= IROM_SIZE) {
const maxCopy = Math.min(bytes.length, IROM_SIZE);
this.flash.set(bytes.subarray(0, maxCopy), 0);
} else {
// Try to extract data at IROM_BASE offset
const iromOffset = IROM_BASE;
if (bytes.length > iromOffset) {
const maxCopy = Math.min(bytes.length - iromOffset, IROM_SIZE);
this.flash.set(bytes.subarray(iromOffset, iromOffset + maxCopy), 0);
}
}
this.dram.fill(0);
this.iram.fill(0);
this.core.reset(IROM_BASE);
this.core.regs[2] = (DRAM_BASE + DRAM_SIZE - 16) | 0;
}
/**
* Load a raw binary image into flash at offset 0 (maps to IROM_BASE 0x42000000).
* Use this with binaries produced by:
* riscv32-esp-elf-objcopy -O binary firmware.elf firmware.bin
*/
loadBin(bin: Uint8Array): void {
this.flash.fill(0);
const maxCopy = Math.min(bin.length, IROM_SIZE);
this.flash.set(bin.subarray(0, maxCopy), 0);
this.dram.fill(0);
this.iram.fill(0);
this.rxFifo = [];
this.gpioOut = 0;
this.gpioIn = 0;
this.core.reset(IROM_BASE);
this.core.regs[2] = (DRAM_BASE + DRAM_SIZE - 16) | 0;
}
/**
* Load a merged ESP32 flash image from the backend (base64-encoded).
*
* The backend produces a 4 MB merged image:
* 0x01000 bootloader
* 0x08000 partition table
* 0x10000 application (ESP32 image format with segment headers)
*
* Each image segment is loaded at its virtual load address:
* IROM (0x42xxxxxx) flash buffer (executed code)
* DROM (0x3Cxxxxxx) flash buffer (read-only data alias)
* DRAM (0x3FCxxxxx) dram buffer (initialised .data)
* IRAM (0x4037xxxx) iram buffer (ISR / time-critical code)
*
* The CPU resets to the entry point declared in the image header.
*/
loadFlashImage(base64: string): void {
// Base64 decode
const binStr = atob(base64);
const data = new Uint8Array(binStr.length);
for (let i = 0; i < binStr.length; i++) data[i] = binStr.charCodeAt(i);
// Parse ESP32 image format
const parsed = parseMergedFlashImage(data);
// Clear all memory regions
this.flash.fill(0);
this.dram.fill(0);
this.iram.fill(0);
this.rxFifo = [];
this.gpioOut = 0;
this.gpioIn = 0;
// Load each segment at its virtual address
for (const { loadAddr, data: seg } of parsed.segments) {
const uAddr = loadAddr >>> 0;
if (uAddr >= IROM_BASE && uAddr + seg.length <= IROM_BASE + IROM_SIZE) {
this.flash.set(seg, uAddr - IROM_BASE);
} else if (uAddr >= DROM_BASE && uAddr + seg.length <= DROM_BASE + IROM_SIZE) {
// DROM is a virtual alias of flash — store at same flash buffer
this.flash.set(seg, uAddr - DROM_BASE);
} else if (uAddr >= DRAM_BASE && uAddr + seg.length <= DRAM_BASE + DRAM_SIZE) {
this.dram.set(seg, uAddr - DRAM_BASE);
} else if (uAddr >= IRAM_BASE && uAddr + seg.length <= IRAM_BASE + IRAM_SIZE) {
this.iram.set(seg, uAddr - IRAM_BASE);
} else {
console.warn(
`[Esp32C3Simulator] Segment 0x${uAddr.toString(16)}` +
` (${seg.length} B) outside known regions — skipped`
);
}
}
// Boot CPU at image entry point
this.core.reset(parsed.entryPoint);
this.core.regs[2] = (DRAM_BASE + DRAM_SIZE - 16) | 0;
console.log(
`[Esp32C3Simulator] Loaded ${parsed.segments.length} segments,` +
` entry=0x${parsed.entryPoint.toString(16)}`
);
}
// ── Lifecycle ──────────────────────────────────────────────────────────────
start(): void {
if (this.running) return;
this.running = true;
this._loop();
}
stop(): void {
this.running = false;
cancelAnimationFrame(this.animFrameId);
}
reset(): void {
this.stop();
this.rxFifo = [];
this.gpioOut = 0;
this.gpioIn = 0;
this.dram.fill(0);
this.iram.fill(0);
this.core.reset(IROM_BASE);
this.core.regs[2] = (DRAM_BASE + DRAM_SIZE - 16) | 0;
}
serialWrite(text: string): void {
for (let i = 0; i < text.length; i++) {
this.rxFifo.push(text.charCodeAt(i));
}
}
setPinState(pin: number, state: boolean): void {
if (state) this.gpioIn |= (1 << pin);
else this.gpioIn &= ~(1 << pin);
}
isRunning(): boolean {
return this.running;
}
// ── Execution loop ─────────────────────────────────────────────────────────
private _loop(): void {
if (!this.running) return;
for (let i = 0; i < CYCLES_PER_FRAME; i++) {
this.core.step();
}
this.animFrameId = requestAnimationFrame(() => this._loop());
}
}

View File

@ -1,8 +1,9 @@
/**
* RiscVCore Minimal RV32I base ISA interpreter in TypeScript.
* RiscVCore Minimal RV32IMC interpreter in TypeScript.
*
* Supports the complete RV32I instruction set (40 instructions):
* LUI, AUIPC, JAL, JALR, BRANCH, LOAD, STORE, OP-IMM, OP, FENCE, SYSTEM
* Supports the complete RV32I base ISA (40 instructions) plus:
* RV32M multiply/divide extension (MUL, MULH, MULHSU, MULHU, DIV, DIVU, REM, REMU)
* RV32C compressed 16-bit instruction extension (all ~40 instructions, decompressed to 32-bit)
*
* Memory model: flat Uint8Array, caller supplies base address mappings.
* MMIO: caller installs read/write hooks at specific address ranges.
@ -11,7 +12,7 @@
* - No privilege levels / CSR side-effects (CSR reads return 0)
* - No interrupts / exceptions (ECALL/EBREAK are no-ops)
* - No misalignment exceptions
* - No compressed (RV32C) or multiply (RV32M) extensions
* - No RV32A (atomic) or floating-point extensions
*/
export type MmioReadHook = (addr: number) => number;
@ -139,6 +140,168 @@ export class RiscVCore {
private reg(r: number): number { return r === 0 ? 0 : this.regs[r]; }
private setReg(r: number, v: number): void { if (r !== 0) this.regs[r] = v; }
// ── RV32C decompressor ──────────────────────────────────────────────────
/**
* Decompress a 16-bit RV32C instruction to its 32-bit RV32I/M equivalent.
* Returns the equivalent 32-bit instruction word.
*/
private decompressC(half: number): number {
const op = half & 0x3;
const funct3 = (half >> 13) & 0x7;
const bit12 = (half >> 12) & 0x1;
// Sign-extend val from bits bits
const sext = (val: number, bits: number) => (val << (32 - bits)) >> (32 - bits);
// Instruction encoders
const encI = (imm: number, rs1: number, f3: number, rd: number, oc: number) =>
((imm & 0xFFF) << 20) | ((rs1 & 0x1F) << 15) | ((f3 & 0x7) << 12) | ((rd & 0x1F) << 7) | (oc & 0x7F);
const encR = (f7: number, rs2: number, rs1: number, f3: number, rd: number, oc: number) =>
((f7 & 0x7F) << 25) | ((rs2 & 0x1F) << 20) | ((rs1 & 0x1F) << 15) | ((f3 & 0x7) << 12) | ((rd & 0x1F) << 7) | (oc & 0x7F);
const encS = (imm: number, rs2: number, rs1: number, f3: number, oc: number) =>
(((imm >> 5) & 0x7F) << 25) | ((rs2 & 0x1F) << 20) | ((rs1 & 0x1F) << 15) | ((f3 & 0x7) << 12) | ((imm & 0x1F) << 7) | (oc & 0x7F);
const encJ = (imm: number, rd: number) => {
const b20 = (imm >> 20) & 1;
const b19_12 = (imm >> 12) & 0xFF;
const b11 = (imm >> 11) & 1;
const b10_1 = (imm >> 1) & 0x3FF;
return (b20 << 31) | (b10_1 << 21) | (b11 << 20) | (b19_12 << 12) | ((rd & 0x1F) << 7) | 0x6F;
};
const encB = (imm: number, rs2: number, rs1: number, f3: number) => {
const b12 = (imm >> 12) & 1;
const b11 = (imm >> 11) & 1;
const b10_5 = (imm >> 5) & 0x3F;
const b4_1 = (imm >> 1) & 0xF;
return (b12 << 31) | (b10_5 << 25) | ((rs2 & 0x1F) << 20) | ((rs1 & 0x1F) << 15) |
((f3 & 7) << 12) | (b4_1 << 8) | (b11 << 7) | 0x63;
};
// CJ-format 11-bit signed offset (scrambled bit positions per spec Table 16.6)
const cjOff = () => sext(
(bit12 << 11) | (((half >> 11) & 1) << 4) | (((half >> 9) & 3) << 8) |
(((half >> 8) & 1) << 10) | (((half >> 7) & 1) << 6) | (((half >> 6) & 1) << 7) |
(((half >> 3) & 7) << 1) | (((half >> 2) & 1) << 5),
12);
// CB-format 8-bit signed offset
const cbOff = () => sext(
(bit12 << 8) | (((half >> 10) & 3) << 3) | (((half >> 5) & 3) << 6) |
(((half >> 3) & 3) << 1) | (((half >> 2) & 1) << 5),
9);
// ── Quadrant 0 (op=00) ──────────────────────────────────────────────
if (op === 0) {
const rdp = ((half >> 2) & 7) + 8; // rd' → x(8..15)
const rs1p = ((half >> 7) & 7) + 8; // rs1' → x(8..15)
switch (funct3) {
case 0: { // C.ADDI4SPN → ADDI rd', sp, nzuimm
const nzuimm = (((half >> 7) & 0xF) << 6) | (((half >> 11) & 0x3) << 4) |
(((half >> 5) & 1) << 3) | (((half >> 6) & 1) << 2);
return encI(nzuimm, 2, 0, rdp, 0x13);
}
case 2: { // C.LW → LW rd', offset(rs1')
const off = (((half >> 10) & 7) << 3) | (((half >> 6) & 1) << 2) | (((half >> 5) & 1) << 6);
return encI(off, rs1p, 2, rdp, 0x03);
}
case 6: { // C.SW → SW rs2', offset(rs1')
const off = (((half >> 10) & 7) << 3) | (((half >> 6) & 1) << 2) | (((half >> 5) & 1) << 6);
return encS(off, rdp, rs1p, 2, 0x23); // rdp plays role of rs2' in CS format
}
default: return 0x00000013; // reserved → NOP
}
}
// ── Quadrant 1 (op=01) ──────────────────────────────────────────────
if (op === 1) {
const rd = (half >> 7) & 0x1F;
const rs1p = ((half >> 7) & 7) + 8;
const rs2p = ((half >> 2) & 7) + 8;
const imm6 = sext((bit12 << 5) | ((half >> 2) & 0x1F), 6);
switch (funct3) {
case 0: // C.NOP / C.ADDI → ADDI rd, rd, imm
return encI(imm6, rd, 0, rd, 0x13);
case 1: // C.JAL (RV32C only) → JAL x1, offset
return encJ(cjOff(), 1);
case 2: // C.LI → ADDI rd, x0, imm
return encI(imm6, 0, 0, rd, 0x13);
case 3: {
if (rd === 2) { // C.ADDI16SP → ADDI sp, sp, nzimm
const nzimm = sext(
(bit12 << 9) | (((half >> 6) & 1) << 4) | (((half >> 5) & 1) << 6) |
(((half >> 3) & 3) << 7) | (((half >> 2) & 1) << 5), 10);
return encI(nzimm, 2, 0, 2, 0x13);
} else { // C.LUI → LUI rd, nzimm
const nzimm = sext((bit12 << 17) | (((half >> 2) & 0x1F) << 12), 18);
return (nzimm & 0xFFFFF000) | ((rd & 0x1F) << 7) | 0x37;
}
}
case 4: {
const f2 = (half >> 10) & 0x3;
const sh = (bit12 << 5) | ((half >> 2) & 0x1F);
if (f2 === 0) return encI(sh, rs1p, 5, rs1p, 0x13); // C.SRLI → SRLI
if (f2 === 1) return encI(0x400 | sh, rs1p, 5, rs1p, 0x13); // C.SRAI → SRAI (bit10=1)
if (f2 === 2) return encI(imm6, rs1p, 7, rs1p, 0x13); // C.ANDI → ANDI
// f2 === 3: C.SUB / C.XOR / C.OR / C.AND
const op2 = (half >> 5) & 3;
if (!bit12) {
switch (op2) {
case 0: return encR(0x20, rs2p, rs1p, 0, rs1p, 0x33); // C.SUB (funct7=0x20)
case 1: return encR(0, rs2p, rs1p, 4, rs1p, 0x33); // C.XOR
case 2: return encR(0, rs2p, rs1p, 6, rs1p, 0x33); // C.OR
case 3: return encR(0, rs2p, rs1p, 7, rs1p, 0x33); // C.AND
}
}
return 0x00000013; // C.SUBW etc. (RV64 only) → NOP
}
case 5: // C.J → JAL x0, offset
return encJ(cjOff(), 0);
case 6: // C.BEQZ → BEQ rs1', x0, offset
return encB(cbOff(), 0, rs1p, 0);
case 7: // C.BNEZ → BNE rs1', x0, offset
return encB(cbOff(), 0, rs1p, 1);
default: return 0x00000013;
}
}
// ── Quadrant 2 (op=10) ──────────────────────────────────────────────
if (op === 2) {
const rd = (half >> 7) & 0x1F;
const rs2 = (half >> 2) & 0x1F;
switch (funct3) {
case 0: { // C.SLLI → SLLI rd, rd, shamt
const sh = (bit12 << 5) | rs2;
return encI(sh, rd, 1, rd, 0x13);
}
case 2: { // C.LWSP → LW rd, offset(sp)
// uimm[7:6]=bits[3:2], uimm[5]=bit12, uimm[4:2]=bits[6:4]
const off = (((half >> 2) & 3) << 6) | (bit12 << 5) | (((half >> 4) & 7) << 2);
return encI(off, 2, 2, rd, 0x03);
}
case 4: {
if (!bit12) {
if (rs2 === 0) return encI(0, rd, 0, 0, 0x67); // C.JR → JALR x0, 0(rd)
return encR(0, rs2, 0, 0, rd, 0x33); // C.MV → ADD rd, x0, rs2
} else {
if (rd === 0 && rs2 === 0) return 0x00100073; // C.EBREAK
if (rs2 === 0) return encI(0, rd, 0, 1, 0x67); // C.JALR → JALR x1, 0(rd)
return encR(0, rs2, rd, 0, rd, 0x33); // C.ADD → ADD rd, rd, rs2
}
}
case 6: { // C.SWSP → SW rs2, offset(sp)
// uimm[7:6]=bits[8:7], uimm[5:2]=bits[12:9]
const off = (((half >> 7) & 3) << 6) | (((half >> 9) & 0xF) << 2);
return encS(off, rs2, 2, 2, 0x23);
}
default: return 0x00000013;
}
}
return 0x00000013; // should not reach (op=11 means 32-bit instruction)
}
// ── Single instruction step ─────────────────────────────────────────────
/**
@ -146,7 +309,18 @@ export class RiscVCore {
* for this simple model real chips have variable latency).
*/
step(): number {
const instr = this.readWord(this.pc);
// RV32C: if bits [1:0] != 0b11, it's a 16-bit compressed instruction
const half = this.readHalf(this.pc);
let instr: number;
let instrLen: number;
if ((half & 0x3) !== 0x3) {
instr = this.decompressC(half);
instrLen = 2;
} else {
instr = this.readWord(this.pc);
instrLen = 4;
}
const opcode = instr & 0x7f;
const rd = (instr >> 7) & 0x1f;
const funct3 = (instr >> 12) & 0x07;
@ -154,7 +328,7 @@ export class RiscVCore {
const rs2 = (instr >> 20) & 0x1f;
const funct7 = (instr >> 25) & 0x7f;
let nextPc = (this.pc + 4) >>> 0;
let nextPc = (this.pc + instrLen) >>> 0;
switch (opcode) {
@ -251,12 +425,13 @@ export class RiscVCore {
break;
}
// OP (registerregister)
// OP (registerregister) — includes RV32M multiply/divide (funct7=1)
case 0x33: {
const a = this.reg(rs1);
const b = this.reg(rs2);
let val: number;
switch ((funct7 << 3) | funct3) {
// RV32I
case 0x000: val = a + b; break; // ADD
case 0x100: val = a - b; break; // SUB
case 0x001: val = a << (b & 0x1f); break; // SLL
@ -267,6 +442,15 @@ export class RiscVCore {
case 0x105: val = a >> (b & 0x1f); break; // SRA
case 0x006: val = a | b; break; // OR
case 0x007: val = a & b; break; // AND
// RV32M (funct7=1 → cases 0x0080x00f)
case 0x008: val = Math.imul(a, b); break; // MUL (lower 32 bits)
case 0x009: val = Number(BigInt(a) * BigInt(b) >> 32n) | 0; break; // MULH (s×s upper)
case 0x00a: val = Number(BigInt(a) * BigInt(b >>> 0) >> 32n) | 0; break; // MULHSU (s×u upper)
case 0x00b: val = Number(BigInt(a >>> 0) * BigInt(b >>> 0) >> 32n) >>> 0; break;// MULHU (u×u upper)
case 0x00c: val = b === 0 ? -1 : (a / b) | 0; break; // DIV
case 0x00d: val = b === 0 ? -1 : ((a >>> 0) / (b >>> 0)) | 0; break; // DIVU
case 0x00e: val = b === 0 ? a : (a % b) | 0; break; // REM
case 0x00f: val = b === 0 ? (a | 0) : ((a >>> 0) % (b >>> 0)) | 0; break; // REMU
default: val = 0;
}
this.setReg(rd, val);

View File

@ -2,6 +2,7 @@ import { create } from 'zustand';
import { AVRSimulator } from '../simulation/AVRSimulator';
import { RP2040Simulator } from '../simulation/RP2040Simulator';
import { RiscVSimulator } from '../simulation/RiscVSimulator';
import { Esp32C3Simulator } from '../simulation/Esp32C3Simulator';
import { PinManager } from '../simulation/PinManager';
import { VirtualDS1307, VirtualTempSensor, I2CMemoryDevice } from '../simulation/I2CBusManager';
import type { RP2040I2CDevice } from '../simulation/RP2040Simulator';
@ -35,7 +36,7 @@ export const DEFAULT_BOARD_POSITION = { x: 50, y: 50 };
export const ARDUINO_POSITION = DEFAULT_BOARD_POSITION;
// ── Runtime Maps (outside Zustand — not serialisable) ─────────────────────
const simulatorMap = new Map<string, AVRSimulator | RP2040Simulator | RiscVSimulator>();
const simulatorMap = new Map<string, AVRSimulator | RP2040Simulator | RiscVSimulator | Esp32C3Simulator>();
const pinManagerMap = new Map<string, PinManager>();
const bridgeMap = new Map<string, RaspberryPi3Bridge>();
const esp32BridgeMap = new Map<string, Esp32Bridge>();
@ -45,9 +46,14 @@ export const getBoardPinManager = (id: string) => pinManagerMap.get(id);
export const getBoardBridge = (id: string) => bridgeMap.get(id);
export const getEsp32Bridge = (id: string) => esp32BridgeMap.get(id);
// Xtensa-based ESP32 boards — still use QEMU bridge (backend)
const ESP32_KINDS = new Set<BoardKind>([
'esp32', 'esp32-devkit-c-v4', 'esp32-cam', 'wemos-lolin32-lite',
'esp32-s3', 'xiao-esp32-s3', 'arduino-nano-esp32',
]);
// RISC-V ESP32 boards — use the browser-side Esp32C3Simulator (no backend needed)
const ESP32_RISCV_KINDS = new Set<BoardKind>([
'esp32-c3', 'xiao-esp32-c3', 'aitewinrobot-esp32c3-supermini',
]);
@ -55,6 +61,10 @@ function isEsp32Kind(kind: BoardKind): boolean {
return ESP32_KINDS.has(kind);
}
function isRiscVEsp32Kind(kind: BoardKind): boolean {
return ESP32_RISCV_KINDS.has(kind);
}
// ── Component type ────────────────────────────────────────────────────────
interface Component {
id: string;
@ -86,7 +96,7 @@ interface SimulatorState {
/** @deprecated use boards[x].x/y */
boardPosition: { x: number; y: number };
/** @deprecated use getBoardSimulator(activeBoardId) */
simulator: AVRSimulator | RP2040Simulator | RiscVSimulator | null;
simulator: AVRSimulator | RP2040Simulator | RiscVSimulator | Esp32C3Simulator | null;
/** @deprecated use getBoardPinManager(activeBoardId) */
pinManager: PinManager;
running: boolean;
@ -159,8 +169,8 @@ function createSimulator(
onSerial: (ch: string) => void,
onBaud: (baud: number) => void,
onPinTime: (pin: number, state: boolean, t: number) => void,
): AVRSimulator | RP2040Simulator | RiscVSimulator {
let sim: AVRSimulator | RP2040Simulator | RiscVSimulator;
): AVRSimulator | RP2040Simulator | RiscVSimulator | Esp32C3Simulator {
let sim: AVRSimulator | RP2040Simulator | RiscVSimulator | Esp32C3Simulator;
if (boardKind === 'arduino-mega') {
sim = new AVRSimulator(pm, 'mega');
} else if (boardKind === 'attiny85') {
@ -169,6 +179,9 @@ function createSimulator(
sim = new RiscVSimulator(pm);
} else if (boardKind === 'raspberry-pi-pico' || boardKind === 'pi-pico-w') {
sim = new RP2040Simulator(pm);
} else if (isRiscVEsp32Kind(boardKind)) {
// ESP32-C3 / XIAO-C3 / C3 SuperMini — browser-side RV32IMC emulator
sim = new Esp32C3Simulator(pm);
} else {
// arduino-uno, arduino-nano
sim = new AVRSimulator(pm, 'uno');
@ -418,9 +431,20 @@ export const useSimulatorStore = create<SimulatorState>((set, get) => {
if (!board) return;
if (isEsp32Kind(board.boardKind)) {
// For ESP32: program is base64-encoded .bin — send to QEMU via bridge
// Xtensa ESP32 boards: program is base64-encoded .bin — send to QEMU via bridge
const esp32Bridge = getEsp32Bridge(boardId);
if (esp32Bridge) esp32Bridge.loadFirmware(program);
} else if (isRiscVEsp32Kind(board.boardKind)) {
// RISC-V ESP32-C3 boards: parse merged flash image and load into browser emulator
const sim = getBoardSimulator(boardId);
if (sim instanceof Esp32C3Simulator) {
try {
sim.loadFlashImage(program);
} catch (err) {
console.error(`[Esp32C3Simulator] loadFlashImage failed for ${boardId}:`, err);
return;
}
}
} else {
const sim = getBoardSimulator(boardId);
if (sim && board.boardKind !== 'raspberry-pi-3') {

View File

@ -12,9 +12,9 @@ export type BoardKind =
| 'esp32-s3' // Xtensa LX7, QEMU backend
| 'xiao-esp32-s3' // Seeed XIAO ESP32-S3, QEMU (esp32-s3)
| 'arduino-nano-esp32' // Arduino Nano ESP32 (S3), QEMU (esp32-s3)
| 'esp32-c3' // RISC-V, QEMU backend
| 'xiao-esp32-c3' // Seeed XIAO ESP32-C3, QEMU (esp32-c3)
| 'aitewinrobot-esp32c3-supermini' // ESP32-C3 SuperMini, QEMU (esp32-c3)
| 'esp32-c3' // RISC-V RV32IMC, browser emulation (Esp32C3Simulator)
| 'xiao-esp32-c3' // Seeed XIAO ESP32-C3, browser emulation (Esp32C3Simulator)
| 'aitewinrobot-esp32c3-supermini' // ESP32-C3 SuperMini, browser emulation (Esp32C3Simulator)
| 'attiny85' // AVR ATtiny85, browser emulation (avr8js)
| 'riscv-generic'; // RV32I generic MCU (CH32V003 target), browser emulation

View File

@ -0,0 +1,99 @@
/**
* ESP32 image format parser.
*
* The backend produces a merged 4 MB flash image containing:
* offset 0x0000 : empty (0xFF)
* offset 0x1000 : bootloader (ESP32 image format)
* offset 0x8000 : partition table
* offset 0x10000: application (ESP32 image format) we parse this
*
* ESP32 image header (24 bytes, ESP-IDF esp_image_format.h):
* +0 magic 0xE9
* +1 segment_count
* +2 spi_mode
* +3 spi_speed_size
* +4 entry_addr uint32 LE
* +8 extended fields
* total: 24 bytes
*
* Each segment (8-byte header + data):
* +0 load_addr uint32 LE virtual address to load data at
* +4 data_len uint32 LE
* +8 data[data_len]
*/
export const ESP32_APP_FLASH_OFFSET = 0x10000;
const ESP32_MAGIC = 0xE9;
const HEADER_SIZE = 24;
const SEG_HDR_SIZE = 8;
export interface Esp32Segment {
loadAddr: number;
data: Uint8Array;
}
export interface Esp32ParsedImage {
entryPoint: number;
segments: Esp32Segment[];
}
function parseAppAt(img: Uint8Array, base: number): Esp32ParsedImage {
const view = new DataView(img.buffer, img.byteOffset, img.byteLength);
if (view.getUint8(base) !== ESP32_MAGIC) {
throw new Error(
`Bad ESP32 magic at 0x${base.toString(16)}: ` +
`expected 0xE9, got 0x${view.getUint8(base).toString(16)}`
);
}
const segCount = view.getUint8(base + 1);
const entryPoint = view.getUint32(base + 4, /*littleEndian=*/true);
const segments: Esp32Segment[] = [];
let pos = base + HEADER_SIZE;
for (let i = 0; i < segCount; i++) {
if (pos + SEG_HDR_SIZE > img.length) break;
const loadAddr = view.getUint32(pos, true);
const dataLen = view.getUint32(pos + 4, true);
pos += SEG_HDR_SIZE;
if (pos + dataLen > img.length) {
console.warn(`[esp32ImageParser] Segment ${i} data truncated`);
break;
}
segments.push({ loadAddr, data: img.slice(pos, pos + dataLen) });
pos += dataLen;
}
return { entryPoint, segments };
}
/**
* Parse a merged ESP32 flash image or a raw app binary.
*
* Accepts:
* - 4 MB merged image (bootloader + partitions + app) reads app at 0x10000
* - Raw app binary (magic 0xE9 at offset 0)
*
* Throws if no valid image is found.
*/
export function parseMergedFlashImage(data: Uint8Array): Esp32ParsedImage {
// Standard merged image: app at offset 0x10000
if (data.length >= ESP32_APP_FLASH_OFFSET + HEADER_SIZE && data[ESP32_APP_FLASH_OFFSET] === ESP32_MAGIC) {
return parseAppAt(data, ESP32_APP_FLASH_OFFSET);
}
// Fallback: raw app binary (no bootloader prefix)
if (data.length >= HEADER_SIZE && data[0] === ESP32_MAGIC) {
return parseAppAt(data, 0);
}
throw new Error(
`No valid ESP32 image magic found ` +
`(tried offsets 0x10000 and 0x0, image size ${data.length} bytes)`
);
}