12 KiB
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— EOF04— extended linear address (for programs > 64 KB; not needed for ATmega328P's 32 KB flash but included for future-proofing)
AVRHarness API
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 0–1 %. 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 KKKK — d = 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:
- Set DDRD bit 6 → pin 6 as output
- Configure Timer0 for Fast PWM 8-bit, non-inverting on OC0A (D6)
- ADMUX = 0x60 → AVCC reference, ADLAR=1 (left-adjust), channel 0
- ADCSRA = 0x87 → ADC enable + prescaler 128 (ADC clock = 125 kHz)
- 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
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:
- 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 theadcReadProgram, and verify the register content matches the expected ADC code within ±2 LSB. - Sketch → PWM → ngspice RC → DC. The
potToPwmProgramsketch 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 matchesduty × 5 Vwithin 100 mV. - 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.