velxio/docs/wiki/circuit-emulation-avr-bridg...

12 KiB
Raw Permalink Blame History

AVR Integration & Mixed-Signal Bridge

Location: test/test_circuit/src/avr/, test/test_circuit/src/spice/AVRSpiceBridge.js

Mirroring Velxio's AVRSimulator.ts

The sandbox harness is a deliberately faithful reproduction of what Velxio already does in frontend/src/simulation/AVRSimulator.ts:

// Velxio (trimmed)
this.cpu = new CPU(programWords, 8192);
this.portB = new AVRIOPort(this.cpu, portBConfig);
this.portC = new AVRIOPort(this.cpu, portCConfig);
this.portD = new AVRIOPort(this.cpu, portDConfig);
this.adc  = new AVRADC(this.cpu, adcConfig);
this.peripherals = [
  new AVRTimer(this.cpu, timer0Config),
  new AVRTimer(this.cpu, timer1Config),
  new AVRTimer(this.cpu, timer2Config),
  new AVRUSART(this.cpu, usart0Config, 16_000_000),
  new AVRSPI(this.cpu, spiConfig, 16_000_000),
  new AVRTWI(this.cpu, twiConfig, 16_000_000),
];

// Execution loop:
avrInstruction(this.cpu);
this.cpu.tick();
// Sandbox: test/test_circuit/src/avr/AVRHarness.js
this.cpu = new CPU(program, 8192);
this.ports.B = new AVRIOPort(this.cpu, portBConfig);
this.ports.C = new AVRIOPort(this.cpu, portCConfig);
this.ports.D = new AVRIOPort(this.cpu, portDConfig);
this.adc = new AVRADC(this.cpu, adcConfig);
this.timers = [
  new AVRTimer(this.cpu, timer0Config),
  new AVRTimer(this.cpu, timer1Config),
  new AVRTimer(this.cpu, timer2Config),
];
this.usart = new AVRUSART(this.cpu, usart0Config, 16_000_000);

// Execution loop:
avrInstruction(this.cpu);
this.cpu.tick();

If it works in the sandbox, it works in Velxio. Confirmed with fixtures/blink.hex which is a byte-for-byte copy of frontend/src/__tests__/fixtures/avr-blink/avr-blink.ino.hex.

Intel HEX parser

Velxio uses utils/hexParser.ts. The sandbox reimplements the same Intel HEX format from scratch in src/avr/intelHex.js:

export function parseIntelHex(text) {
  const bytes = [];
  let highAddr = 0;
  for (const rawLine of text.split('\n')) {
    const line = rawLine.trim();
    if (!line.startsWith(':')) continue;
    const byteCount = parseInt(line.slice(1, 3), 16);
    const addr = parseInt(line.slice(3, 7), 16);
    const type = parseInt(line.slice(7, 9), 16);
    if (type === 0) {
      const fullAddr = (highAddr << 16) | addr;
      for (let i = 0; i < byteCount; i++) {
        bytes[fullAddr + i] = parseInt(line.slice(9 + i*2, 11 + i*2), 16);
      }
    } else if (type === 1) break;       // EOF
    else if (type === 4) highAddr = parseInt(line.slice(9, 13), 16);
  }
  return new Uint8Array(bytes);
}

export function bytesToProgramWords(bytes, wordCount = 0x8000 / 2) {
  const prog = new Uint16Array(wordCount);
  for (let i = 0; i < bytes.length; i += 2) {
    prog[i >> 1] = (bytes[i] || 0) | ((bytes[i + 1] || 0) << 8);
  }
  return prog;
}

Handles record types:

  • 00 — data (the bulk)
  • 01 — EOF
  • 04 — extended linear address (for programs > 64 KB; not needed for ATmega328P's 32 KB flash but included for future-proofing)

AVRHarness API

src/avr/AVRHarness.js

const avr = new AVRHarness();

// Load program (two ways)
avr.load(hexText);                       // Intel HEX string
avr.loadProgram(uint16ArrayOfWords);     // pre-assembled

// Execute
avr.runCycles(16_000_000);               // 1 second at 16 MHz

// Read pin state
avr.getPin(13);                          // 0 or 1 (D13 = PORTB bit 5)
avr.getPin(6);                           // D6 = PORTD bit 6
avr.getPin(14);                          // A0 as digital = PORTC bit 0

// Register for pin changes
const unsub = avr.onPinChange(13, (state) => console.log('D13 now', state));

// Inject analog voltage (0..5V) on ADC channel 0..5
avr.setAnalogVoltage(0, 2.5);            // A0 = 2.5V

// Read PWM duty from OCR register (0..1)
avr.getPWMDuty(6);                       // D6 → Timer0A → OCR0A at 0x47
avr.getPWMDuty(9);                       // D9 → Timer1A → OCR1AL at 0x88

// Raw CPU access (for testing / debugging)
avr.cpu.data[0x79];                      // ADCH register
avr.cpu.data[0x88];                      // OCR1AL register
avr.cpu.cycles;                          // total executed cycles

// USART TX (serial output)
avr.getSerialOutput();                   // string of all bytes transmitted so far

Pin-to-port mapping (Arduino Uno convention)

Arduino pin Port Bit Used for
D0 PORTD 0 RX
D1 PORTD 1 TX
D2 PORTD 2 Interrupt 0
D3 PORTD 3 Timer2B PWM
D4 PORTD 4
D5 PORTD 5 Timer0B PWM
D6 PORTD 6 Timer0A PWM
D7 PORTD 7
D8 PORTB 0
D9 PORTB 1 Timer1A PWM (16-bit!)
D10 PORTB 2 Timer1B PWM
D11 PORTB 3 Timer2A PWM
D12 PORTB 4
D13 PORTB 5 LED_BUILTIN
A0 PORTC 0 ADC ch 0
A1 PORTC 1 ADC ch 1
A2 PORTC 2 ADC ch 2
A3 PORTC 3 ADC ch 3
A4 PORTC 4 ADC ch 4 / SDA
A5 PORTC 5 ADC ch 5 / SCL

PWM OCR register addresses (ATmega328P)

Pin Timer Register Address
D3 Timer2B OCR2B 0xB4
D5 Timer0B OCR0B 0x48
D6 Timer0A OCR0A 0x47
D9 Timer1A OCR1AL (low byte of 16-bit) 0x88
D10 Timer1B OCR1BL 0x8A
D11 Timer2A OCR2A 0xB3

Our harness reads the low byte and divides by 255 to estimate duty. For Timer1 this is valid only when the timer is configured for 8-bit PWM mode (which our potToPwmProgram does not use — it uses Timer0 instead via OCR0A).

ADC register model

avr8js's AVRADC exposes channelValues: number[] (one slot per channel). Writing a value in volts (0..5) injects it; the ADC performs the 10-bit quantization automatically on the next analogRead().

To read the result directly without writing a sketch that stores it to a register, you can also read cpu.data[0x78] (ADCL) and cpu.data[0x79] (ADCH).

Right-adjusted (default, ADLAR=0):

ADCH = 0b000000xx   ; top 2 bits of 10-bit result
ADCL = 0bxxxxxxxx   ; bottom 8 bits
result = (ADCH << 8) | ADCL;

Left-adjusted (ADLAR=1) — useful if you only want to read ADCH:

ADCH = 0bxxxxxxxx   ; top 8 bits
ADCL = 0bxx000000   ; bottom 2 bits
result = (ADCH << 2) | (ADCL >> 6);

Gotcha we hit: the potToPwmProgram sketch initially read only ADCH and wrote it to OCR0A. With ADLAR=0 this only gave the top 2 bits (0..3) — duty was stuck at 01 %. Changing ADMUX from 0x40 to 0x60 (enable ADLAR) fixed it.

Hand-assembled Arduino programs

src/avr/asm.js exposes a mini-assembler covering the opcodes we need.

Supported opcodes

LDI(rd, k)       // Load immediate, rd ∈ [16,31], k ∈ [0,255]
OUT(A, rr)       // Out to I/O, A ∈ [0,63]
IN(rd, A)        // In from I/O
STS(k, rr)       // Store to data space (32-bit instruction)
LDS(rd, k)       // Load from data space (32-bit)
RJMP(offset)     // Relative jump (12-bit signed word offset)
SBRC(rr, b)      // Skip if bit in register clear
SBRS(rr, b)      // Skip if bit in register set
NOP()            // No operation

// Assemble a list (numbers = 1 word, arrays = 2 words)
assemble([ LDI(16, 0x40), OUT(0x0A, 16), RJMP(-1) ])
   Uint16Array [0xE400, 0xB90A, 0xCFFF]

Encoding reference (ATmega AVR instruction set)

Opcode Encoding
LDI Rd, K 1110 KKKK dddd KKKKd = Rd 16
OUT A, Rr 1011 1AAr rrrr AAAA
IN Rd, A 1011 0AAd dddd AAAA
STS k, Rr 1001 001r rrrr 0000 + 16-bit k
LDS Rd, k 1001 000d dddd 0000 + 16-bit k
RJMP k 1100 kkkk kkkk kkkk (signed 12-bit offset from PC+1)
SBRC Rr, b 1111 110r rrrr 0bbb
SBRS Rr, b 1111 111r rrrr 0bbb
NOP 0000 0000 0000 0000

The two test programs

potToPwmProgram()

Equivalent Arduino sketch:

void setup() {
  pinMode(6, OUTPUT);
}
void loop() {
  int v = analogRead(A0);      // 10-bit
  analogWrite(6, v >> 2);       // map to 8-bit PWM on D6
}

Actual implementation:

  1. Set DDRD bit 6 → pin 6 as output
  2. Configure Timer0 for Fast PWM 8-bit, non-inverting on OC0A (D6)
  3. ADMUX = 0x60 → AVCC reference, ADLAR=1 (left-adjust), channel 0
  4. ADCSRA = 0x87 → ADC enable + prescaler 128 (ADC clock = 125 kHz)
  5. Loop:
    • Write ADSC bit to start conversion
    • Busy-wait until ADSC clears
    • Read ADCH (top 8 bits of left-adjusted result)
    • Write to OCR0A (PWM duty)

22 words (44 bytes).

adcReadProgram()

Simpler variant used by the thermistor test: reads ADC repeatedly and stores the raw bytes into registers r20 (ADCH) and r21 (ADCL). The host test then reads them from cpu.data[r20_addr] or reconstructs the 10-bit result from (ADCH << 2) | (ADCL >> 6).

AVRSpiceBridge — the co-simulation layer

src/spice/AVRSpiceBridge.js

Constructor

const bridge = new AVRSpiceBridge(avr, {
  sliceMs: 1,                // AVR runs in 1 ms slices between ngspice solves
  analogChannels: [           // which ngspice nodes feed which ADC channels
    { channel: 0, node: 'a0' },
    { channel: 1, node: 'a1' },
  ],
});

Runtime

await bridge.run(totalMs, (pinSnapshots, sliceStartMs, sliceEndMs) => {
  // Return a full ngspice netlist string.
  // pinSnapshots[6] = { type: 'pwm', duty: 0.5 }  (only if duty > 0)
  //               or { type: 'digital', v: 0 | 5 }
  return `My circuit
V_PIN6 pin6 0 DC ${pinSnapshots[6].type === 'pwm' ? pinSnapshots[6].duty * 5 : pinSnapshots[6].v}
R1 pin6 out 10k
C1 out 0 1u IC=0
.tran 10u 1m
.end`;
});

Algorithm, step by step

for slice in slices:
  # 1. Run the AVR for this slice
  avr.runCycles(16_000_000 * sliceMs / 1000)

  # 2. Snapshot pin states
  snapshot = {}
  for pin in 0..13:
    duty = avr.getPWMDuty(pin)
    if duty is not null and duty > 0:
      snapshot[pin] = { type: 'pwm', duty }
    else:
      snapshot[pin] = { type: 'digital', v: avr.getPin(pin) * 5 }

  # 3. Build netlist
  netlist = buildNetlist(snapshot, t0, t1)

  # 4. Solve it
  result = await runNetlist(netlist)

  # 5. Inject voltages back into ADC channels
  for { channel, node } in analogChannels:
    v = result.vec(f'v({node})')
    v_end = v[-1]                       # last time point
    avr.setAnalogVoltage(channel, v_end)
    adcSamples.push({ t: t1/1000, channel, node, v: v_end })

Design choices

  • Slice-based: PWM duty and digital levels are treated as constant within one slice. Works because our analog circuits have time constants (RC filters, ADC sample-and-hold) that are an order of magnitude slower than the slice length.
  • PWM → DC-equivalent: we convert PWM to its duty-averaged DC voltage and hand that to ngspice as a constant source. If you need to study the PWM ripple itself, you would instead emit a PULSE() source — at the cost of ngspice having to take sub-microsecond timesteps.
  • Chicken-and-egg at slice 0: the first slice runs the AVR before ngspice has computed any voltage. The AVR therefore starts with channelValues[ch] = 0. By slice 2 the ADC sees the real voltage. In practice, for tests we run several slices to let the system settle; in production UIs this is unnoticeable.

Limitations

  • Not cycle-accurate. Tight feedback loops (an analog oscillator whose output drives an MCU interrupt input with microsecond-tight requirements) cannot be expressed.
  • No back-annotation of MCU GPIO from SPICE. We inject ADC voltages but we don't let an analog node drive a digital input pin with logic-level thresholds. Supported in principle — you'd read the SPICE result and call avr.ports.X.setPin(bit, value) — but the harness doesn't expose that today.

Showcase test

test/spice_avr_mixed.test.js runs three co-simulated scenarios:

  1. NTC → ngspice → ADC → sketch. At 0/25/50 °C, ngspice solves the NTC+pullup divider, we feed the result into AVRHarness.setAnalogVoltage(0, v), run the adcReadProgram, and verify the register content matches the expected ADC code within ±2 LSB.
  2. Sketch → PWM → ngspice RC → DC. The potToPwmProgram sketch computes a PWM duty from a simulated pot voltage; the DC-equivalent of the PWM is fed to an RC filter in ngspice; the settled voltage matches duty × 5 V within 100 mV.
  3. Full bridge loop — pot wiper move. The bridge runs 10 slices (5 ms total). Wiper at 0.25 → ADC reads 256. Wiper moves to 0.75 → ADC reads 768. Monotonic, ±5 LSB accuracy.