From 3f23a7950c6c5a27d947586242e68d4a30b754e4 Mon Sep 17 00:00:00 2001 From: David Montero Date: Tue, 16 Jun 2026 04:18:51 +0200 Subject: [PATCH] fix(sim): emulate AVR EEPROM (fixes #203) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AVRSimulator never instantiated avr8js's AVREEPROM peripheral, so any EEPROM.read/write/update hung the sketch: the Arduino EEPROM library spins on `while (EECR & (1< (n & 0xff).toString(16).toUpperCase().padStart(2, '0'); + const bytes: number[] = []; + for (const w of words) bytes.push(w & 0xff, (w >> 8) & 0xff); + let out = ''; + for (let i = 0; i < bytes.length; i += 16) { + const chunk = bytes.slice(i, i + 16); + let sum = chunk.length + ((i >> 8) & 0xff) + (i & 0xff); + let rec = `:${hh(chunk.length)}${hh(i >> 8)}${hh(i)}00`; + for (const b of chunk) { + rec += hh(b); + sum += b; + } + out += `${rec}${hh((0x100 - (sum & 0xff)) & 0xff)}\n`; + } + return `${out}:00000001FF\n`; +} + +/** Set EEAR to `addr` (r16 is clobbered). */ +const setEepromAddr = (addr: number) => [ + LDI(16, addr & 0xff), + STS(EEARL, 16), + LDI(16, (addr >> 8) & 0xff), + STS(EEARH, 16), +]; + +/** + * Write `value` to EEPROM `addr` (atomic), poll EEPE until the write + * completes, then read it back into r20 — mirroring avr-libc's + * eeprom_write_byte / eeprom_read_byte sequence. + */ +function eepromWritePollReadProgram(addr: number, value: number): Uint16Array { + return assemble([ + ...setEepromAddr(addr), + LDI(16, value), + STS(EEDR, 16), + LDI(16, EEMPE), + STS(EECR, 16), // EEMPE = 1 + LDI(16, EEPE), + STS(EECR, 16), // EEPE = 1 → start atomic write (within 4 cycles of EEMPE) + // poll: while (EECR & EEPE) ; + LDS(17, EECR), + SBRC(17, 1), // skip the RJMP once EEPE (bit 1) is clear + RJMP(-4), + // read back + ...setEepromAddr(addr), + LDI(16, EERE), + STS(EECR, 16), // EERE = 1 → EEDR = backend[addr] + LDS(20, EEDR), // r20 = read value + RJMP(-1), // spin + ]); +} + +describe('EEPROM (issue #203)', () => { + let pm: PinManager; + let sim: AVRSimulator; + + beforeEach(() => { + pm = new PinManager(); + sim = new AVRSimulator(pm); + }); + + afterEach(() => sim.stop()); + + it('round-trips a byte and the EEPE write-completion poll terminates (no hang)', () => { + sim.loadHex(toIntelHex(eepromWritePollReadProgram(0, 123))); + + // Step until the program reaches its read (r20 == 123). The cap is the + // regression guard: with the EEPROM peripheral missing, EEPE never clears, + // the poll spins forever, r20 stays 0, and the cap is hit -> assertion fails. + const cpu = (sim as unknown as { cpu: { data: Uint8Array } }).cpu; + for (let i = 0; i < 300000 && cpu.data[20] !== 123; i++) sim.step(); + + expect(cpu.data[20]).toBe(123); + const backend = (sim as unknown as { eepromBackend: { readMemory(a: number): number } }) + .eepromBackend; + expect(backend.readMemory(0)).toBe(123); + }); + + it('persists EEPROM contents across a reset (between boots)', () => { + sim.loadHex(toIntelHex(eepromWritePollReadProgram(7, 42))); + const cpu = (sim as unknown as { cpu: { data: Uint8Array } }).cpu; + for (let i = 0; i < 300000 && cpu.data[20] !== 42; i++) sim.step(); + expect(cpu.data[20]).toBe(42); + + // A reset re-boots the CPU but must leave EEPROM intact. + sim.reset(); + + const backend = (sim as unknown as { eepromBackend: { readMemory(a: number): number } }) + .eepromBackend; + expect(backend.readMemory(7)).toBe(42); + }); +}); diff --git a/frontend/src/simulation/AVRSimulator.ts b/frontend/src/simulation/AVRSimulator.ts index c1a5c8e4..2a410a47 100644 --- a/frontend/src/simulation/AVRSimulator.ts +++ b/frontend/src/simulation/AVRSimulator.ts @@ -27,6 +27,9 @@ import { twiConfig, ATtinyTimer1, attinyTimer1Config, + AVREEPROM, + EEPROMMemoryBackend, + eepromConfig, } from 'avr8js'; import type { AVRTimerConfig } from 'avr8js/dist/esm/peripherals/timer'; import type { ADCConfig, ADCMuxConfiguration } from 'avr8js/dist/esm/peripherals/adc'; @@ -212,6 +215,22 @@ const attiny85AdcConfig: ADCConfig = { ], }; +// ATtiny85 EEPROM register map. avr8js's default eepromConfig targets the +// ATmega328P (EECR 0x3F …); the ATtiny85 keeps the same EECR bit layout but +// at different data-space addresses (I/O addr + 0x20, e.g. EECR I/O 0x1C → +// 0x3C). Vectors are 1-word RJMP so the ready-interrupt is the raw index +// (_VECTOR(6) EE_RDY). The Arduino EEPROM library polls EEPE rather than +// using the interrupt, so only the register addresses matter in practice. +const attiny85EepromConfig: typeof eepromConfig = { + eepromReadyInterrupt: 0x06, + EECR: 0x3c, + EEDR: 0x3d, + EEARL: 0x3e, + EEARH: 0x3f, + eraseCycles: 28800, + writeCycles: 28800, +}; + const attiny85Timer0Config: AVRTimerConfig = { bits: 8, captureInterrupt: 0, @@ -288,6 +307,11 @@ export class AVRSimulator { public spi: AVRSPI | null = null; public usart: AVRUSART | null = null; public twi: AVRTWI | null = null; + // EEPROM peripheral + its backing store. The backend (the actual cells) is + // created once and reused across firmware reloads and resets so written + // values persist between boots, like real hardware (GitHub issue #203). + private eeprom: AVREEPROM | null = null; + private eepromBackend: EEPROMMemoryBackend | null = null; public i2cBus!: I2CBusManager; private program: Uint16Array | null = null; private running = false; @@ -338,6 +362,26 @@ export class AVRSimulator { return PWM_PINS_UNO; } + /** + * Wire avr8js's EEPROM peripheral to the freshly-built CPU. Called after + * every CPU (re)construction. The backend (the actual cells) is created + * once per simulator instance and reused, so a value written in one run is + * still there on the next boot — matching real hardware, where re-flashing + * a sketch leaves EEPROM intact (GitHub issue #203). Without this peripheral + * the Arduino EEPROM library's `while (EECR & (1<