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:
parent
27032a4878
commit
3f23a7950c
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue