336 lines
12 KiB
Markdown
336 lines
12 KiB
Markdown
# AVR Integration & Mixed-Signal Bridge
|
||
|
||
Location: [`test/test_circuit/src/avr/`](../../test/test_circuit/src/avr/), [`test/test_circuit/src/spice/AVRSpiceBridge.js`](../../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`:
|
||
|
||
```typescript
|
||
// 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();
|
||
```
|
||
|
||
```javascript
|
||
// 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`](../../test/test_circuit/src/avr/intelHex.js):
|
||
|
||
```javascript
|
||
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`](../../test/test_circuit/src/avr/AVRHarness.js)
|
||
|
||
```javascript
|
||
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`](../../test/test_circuit/src/avr/asm.js) exposes a mini-assembler covering the opcodes we need.
|
||
|
||
### Supported opcodes
|
||
|
||
```javascript
|
||
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:
|
||
```c
|
||
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`](../../test/test_circuit/src/spice/AVRSpiceBridge.js)
|
||
|
||
### Constructor
|
||
|
||
```javascript
|
||
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
|
||
|
||
```javascript
|
||
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.
|