fix(sim): emulate AVR EEPROM (fixes #203)

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<<EEPE))` waiting for the write-complete bit to clear,
and with no peripheral driving EECR that bit never cleared (issue #203 —
EEPROM.update(0,123) + EEPROM.read(0) hangs instead of printing 123).

Wire AVREEPROM to the CPU in both loadHex() and reset() via a new
attachEeprom() helper. The EEPROMMemoryBackend is created once per
simulator instance and reused across firmware reloads and resets, so a
value written in one run is still readable on the next boot — matching
real hardware, where re-flashing leaves EEPROM intact. Sizes per variant
(Uno 1024 B, Mega2560 4096 B, ATtiny85 512 B); ATtiny85 gets its own
register map (EECR 0x3C / EEDR 0x3D / EEARL 0x3E / EEARH 0x3F) since
avr8js's default eepromConfig targets the ATmega328P.

Adds eeprom.test.ts: drives the EEPROM register protocol against the
production AVRSimulator (loadHex + step), asserting a byte round-trips,
the EEPE poll terminates (no hang), and contents survive a reset.
This commit is contained in:
David Montero 2026-06-16 04:18:51 +02:00
parent 27032a4878
commit 3f23a7950c
2 changed files with 172 additions and 0 deletions

View File

@ -0,0 +1,122 @@
/**
* EEPROM regression tests (GitHub issue #203 "Arduino EEPROM").
*
* Before the fix, AVRSimulator never instantiated avr8js's AVREEPROM
* peripheral, so the Arduino EEPROM library's `while (EECR & (1<<EEPE))`
* write-completion poll never exited and any EEPROM.read/write/update hung
* the sketch. These tests drive the EEPROM register protocol against the
* *production* AVRSimulator (via loadHex + step) and assert that:
* 1. a byte round-trips through EEPROM and the EEPE poll terminates, and
* 2. EEPROM contents survive a reset (persist "between boots").
*
* The programs are hand-assembled with the mini-assembler in
* ./helpers/avrTestHarness so the tests don't depend on arduino-cli.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { AVRSimulator } from '../simulation/AVRSimulator';
import { PinManager } from '../simulation/PinManager';
import { assemble, LDI, STS, LDS, SBRC, RJMP } from './helpers/avrTestHarness';
// ATmega328P EEPROM registers (data-space addresses, as avr8js uses them).
const EECR = 0x3f;
const EEDR = 0x40;
const EEARL = 0x41;
const EEARH = 0x42;
const EEMPE = 0x04; // master write enable (bit 2)
const EEPE = 0x02; // write enable (bit 1)
const EERE = 0x01; // read enable (bit 0)
/** Encode a flash image (16-bit words, little-endian) as Intel HEX. */
function toIntelHex(words: Uint16Array): string {
const hh = (n: number) => (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);
});
});

View File

@ -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<<EEPE))` write-completion
* poll never exits and the sketch hangs on the first EEPROM access.
*/
private attachEeprom(): void {
const cpu = this.cpu;
if (!cpu) return;
const size =
this.boardVariant === 'mega' ? 4096 : this.boardVariant === 'tiny85' ? 512 : 1024;
const backend = this.eepromBackend ?? new EEPROMMemoryBackend(size);
this.eepromBackend = backend;
const config = this.boardVariant === 'tiny85' ? attiny85EepromConfig : eepromConfig;
this.eeprom = new AVREEPROM(cpu, backend, config);
}
/**
* Load compiled hex file into simulator
*/
@ -482,6 +526,8 @@ export class AVRSimulator {
}
}
this.attachEeprom();
this.lastPortBValue = 0;
this.lastPortCValue = 0;
this.lastPortDValue = 0;
@ -915,6 +961,10 @@ export class AVRSimulator {
}
}
// Re-attach EEPROM to the new CPU. attachEeprom() reuses the existing
// backend, so EEPROM survives a Reset (persists between boots).
this.attachEeprom();
this.lastPortBValue = 0;
this.lastPortCValue = 0;
this.lastPortDValue = 0;