165 lines
6.7 KiB
TypeScript
165 lines
6.7 KiB
TypeScript
/**
|
|
* RP2040 real-time scheduler
|
|
*
|
|
* The RP2040 core (125 MHz Cortex-M0) is ~8x heavier to emulate than the AVR.
|
|
* A `delay()` on the arduino-pico core BUSY-WAITS (polls the timer in a tight
|
|
* loop) instead of sleeping, so the WFI fast-path never triggers and a host
|
|
* that cannot sustain 125 M instr/s would render a 1 s blink every 4-5 s.
|
|
*
|
|
* Two mechanisms keep simulated time locked to wall-clock:
|
|
* 1. the frame budget is derived from the measured wall-clock delta, and
|
|
* 2. IdleSpinDetector recognises a side-effect-free busy-wait spin so the
|
|
* scheduler advances the clock over it instead of grinding every cycle.
|
|
*
|
|
* These tests cover the detector in isolation (the risky heuristic) and the
|
|
* end-to-end scheduler against a real rp2040js core running a hand-assembled
|
|
* busy-wait loop — no compiled firmware fixture required.
|
|
*/
|
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
import { RP2040 } from 'rp2040js';
|
|
import { RP2040Simulator, IdleSpinDetector } from '../simulation/RP2040Simulator';
|
|
import { PinManager } from '../simulation/PinManager';
|
|
|
|
// ── IdleSpinDetector — the heuristic that decides what is safe to skip ───────
|
|
describe('IdleSpinDetector', () => {
|
|
const constGpio = () => 0;
|
|
|
|
it('detects a stable, side-effect-free spin after the threshold', () => {
|
|
const d = new IdleSpinDetector(32);
|
|
// Drive the real PC sequence: L, L+2, L, L+2, ... (backward branch each loop)
|
|
const L = 0x100;
|
|
let detected = false;
|
|
d.observe(L, constGpio);
|
|
for (let i = 0; i < 40; i++) {
|
|
d.observe(L + 2, constGpio);
|
|
detected = d.observe(L, constGpio) || detected; // L < L+2 → backward branch
|
|
}
|
|
expect(detected).toBe(true);
|
|
});
|
|
|
|
it('never elides a bit-bang loop (GPIO changes every iteration)', () => {
|
|
const d = new IdleSpinDetector(32);
|
|
const L = 0x100;
|
|
let toggling = 0;
|
|
const changingGpio = () => (toggling ^= 1); // different value each read
|
|
let detected = false;
|
|
d.observe(L, changingGpio);
|
|
for (let i = 0; i < 200; i++) {
|
|
d.observe(L + 2, changingGpio);
|
|
detected = d.observe(L, changingGpio) || detected;
|
|
}
|
|
expect(detected).toBe(false);
|
|
});
|
|
|
|
it('never elides straight-line code (no backward branch)', () => {
|
|
const d = new IdleSpinDetector(8);
|
|
let detected = false;
|
|
for (let pc = 0x100; pc < 0x100 + 8 * 100; pc += 2) {
|
|
detected = d.observe(pc, constGpio) || detected;
|
|
}
|
|
expect(detected).toBe(false);
|
|
});
|
|
|
|
it('resets on a long forward jump (loop that calls out)', () => {
|
|
const d = new IdleSpinDetector(4);
|
|
const L = 0x100;
|
|
let detected = false;
|
|
// Each "iteration" jumps far away (a bl to a subroutine) then comes back.
|
|
for (let i = 0; i < 50; i++) {
|
|
d.observe(L, constGpio);
|
|
d.observe(L + 2, constGpio);
|
|
d.observe(L + 0x4000, constGpio); // long forward jump → reset
|
|
detected = d.observe(L, constGpio) || detected; // backward, but count was reset
|
|
}
|
|
expect(detected).toBe(false);
|
|
});
|
|
|
|
it('noteElided() makes it re-accumulate before signalling again', () => {
|
|
const d = new IdleSpinDetector(4);
|
|
const L = 0x100;
|
|
const tick = () => {
|
|
d.observe(L + 2, constGpio);
|
|
return d.observe(L, constGpio);
|
|
};
|
|
d.observe(L, constGpio);
|
|
let detected = false;
|
|
for (let i = 0; i < 4; i++) detected = tick() || detected;
|
|
expect(detected).toBe(true);
|
|
d.noteElided();
|
|
// Immediately after eliding, it must NOT re-signal until the loop runs again.
|
|
expect(tick()).toBe(false);
|
|
});
|
|
});
|
|
|
|
// ── End-to-end scheduler against a real rp2040js core ────────────────────────
|
|
describe('RP2040Simulator — real-time scheduler', () => {
|
|
const RAM = 0x20000000;
|
|
const NOP = 0xbf00;
|
|
const B_BACK_1 = 0xe7fd; // b .-2 (branch to the previous 16-bit instruction)
|
|
|
|
let sim: RP2040Simulator;
|
|
let rp: RP2040;
|
|
|
|
/** Build a simulator wrapping a fresh core preloaded with `opcodes` at RAM. */
|
|
function withProgram(opcodes: number[]): void {
|
|
rp = new RP2040();
|
|
opcodes.forEach((op, i) => rp.writeUint16(RAM + i * 2, op));
|
|
rp.core.PC = RAM;
|
|
sim = new RP2040Simulator(new PinManager());
|
|
// Inject the bare core directly — we are unit-testing the scheduler, not
|
|
// the bootrom/flash loader.
|
|
(sim as unknown as { rp2040: RP2040 }).rp2040 = rp;
|
|
}
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
sim?.stop();
|
|
});
|
|
|
|
it('elides a busy-wait spin: sim-time tracks wall-time with few instructions', () => {
|
|
withProgram([NOP, B_BACK_1]); // nop; loop forever — a side-effect-free spin
|
|
const before = sim.getCurrentCycles();
|
|
const { cyclesAdvanced, instructionsExecuted } = sim.runFrameForTime(16); // 16 ms
|
|
|
|
// 16 ms of wall-clock at 125 MHz == 2 000 000 simulated cycles.
|
|
expect(cyclesAdvanced).toBeGreaterThan(1_900_000);
|
|
expect(sim.getCurrentCycles() - before).toBeGreaterThan(1_900_000);
|
|
// ...yet almost none of those cycles were actually executed (the win).
|
|
expect(instructionsExecuted).toBeLessThan(5_000);
|
|
});
|
|
|
|
it('does NOT elide when GPIO keeps changing (bit-bang safety)', () => {
|
|
withProgram([NOP, B_BACK_1]);
|
|
// Same loop, but make every GPIO snapshot differ — mimics a pin toggling
|
|
// each iteration. The detector must refuse to skip and grind every cycle.
|
|
let n = 0;
|
|
Object.defineProperty(rp, 'gpioValues', { configurable: true, get: () => n++ });
|
|
|
|
const { cyclesAdvanced, instructionsExecuted } = sim.runFrameForTime(1); // 1 ms
|
|
// No skipping: instructions executed are on the order of cycles advanced.
|
|
expect(instructionsExecuted).toBeGreaterThan(cyclesAdvanced / 4);
|
|
});
|
|
|
|
it('locks the cycle budget to the measured wall-clock delta', () => {
|
|
withProgram([NOP, B_BACK_1]);
|
|
const small = sim.runFrameForTime(4).cyclesAdvanced;
|
|
const big = sim.runFrameForTime(16).cyclesAdvanced;
|
|
// 4x the wall-time => ~4x the simulated cycles (within scheduling slack).
|
|
expect(big).toBeGreaterThan(small * 3);
|
|
// And the long-delta clamp keeps a backgrounded tab from over-running.
|
|
const clamped = sim.runFrameForTime(100_000).cyclesAdvanced; // 100 s wall-clock
|
|
expect(clamped).toBeLessThan(50 /*MAX_DELTA_MS*/ * 125_000 + 200_000);
|
|
});
|
|
|
|
it('still fires scheduled pin changes during an elided frame (not skipped past)', () => {
|
|
withProgram([NOP, B_BACK_1]);
|
|
const setPin = vi.spyOn(sim, 'setPinState');
|
|
const at = sim.getCurrentCycles() + 1000;
|
|
sim.schedulePinChange(2, true, at); // external edge 1000 cycles into the future
|
|
sim.runFrameForTime(16);
|
|
// advanceClock caps each jump at the next scheduled change, so it is
|
|
// applied on time rather than swallowed by the idle skip.
|
|
expect(setPin).toHaveBeenCalledWith(2, true);
|
|
});
|
|
});
|