feat(avr/uart): synthesize bit-level TX waveform on PD1/PE1
avr8js intercepts the transmitted byte at the UDR0 register and never
toggles the corresponding GPIO. Real ATmega328P / ATmega2560 hardware
drives PD1 / PE1 with a start bit, 8 data bits LSB-first, and a stop bit
at the configured baud rate the moment TXEN is set. An oscilloscope
probe on D1 therefore showed nothing in Velxio while the same probe in
the real world would resolve the UART frame.
Synthesize the frame from the inside of `onByteTransmit`:
* Read `usart.baudRate`, `usart.bitsPerChar`, `usart.parityEnabled`,
`usart.parityOdd`, `usart.stopBits` so unusual configurations stay
accurate (avr8js already exposes these as public getters).
* Build the bit list start + data(LSB first) + parity? + stopBit(s).
* For each transition vs. previous state (initial = idle HIGH), call
`onPinChangeWithTime(1, state, timeMs)` where
`timeMs = (cpu.cycles + i * cyclesPerBit) / 16_000`. Same
simulator-time clock the existing port-listener path uses, so the
scope draws the UART waveform cycle-accurately alongside other GPIO
activity.
Also hook `onConfigurationChange` to detect TXEN flipping 0→1 and seed
the scope baseline at idle HIGH; without that, the very first byte's
start bit transition would be invisible because the scope's pre-first-
sample default is LOW.
Both USART construction sites (initial setupSimulation around line 423,
re-init after stop around line 749) get the same hook.
Covered by `__tests__/avr-uart-tx-waveform.test.ts` (5 cases): idle seed,
byte with internal transitions, 0xFF edge case, TXEN-disabled no-op,
bit-period timing.
This commit is contained in:
parent
2eb195d671
commit
b587faf1b0
|
|
@ -0,0 +1,148 @@
|
|||
/**
|
||||
* AVR UART TX pin waveform synthesis
|
||||
*
|
||||
* avr8js's USART peripheral only intercepts the transmitted byte at the
|
||||
* UDR0 data register — it never toggles PD1 (Uno/Nano) / PE1 (Mega). The
|
||||
* oscilloscope and any other GPIO consumer therefore see a flat line on
|
||||
* the TX pin during Serial.print, which doesn't match real hardware.
|
||||
*
|
||||
* AVRSimulator.emitUartTxFrame() is the shim that closes that gap: when
|
||||
* onByteTransmit fires it derives the 10-bit UART frame from the byte and
|
||||
* the current USART config, then emits each bit transition through
|
||||
* onPinChangeWithTime so the scope sees the same waveform a real ATmega328P
|
||||
* would put on PD1.
|
||||
*
|
||||
* These tests assert that:
|
||||
* - The TX pin is seeded HIGH (idle) when TXEN flips on.
|
||||
* - Each byte produces a properly-timed start/data(LSB-first)/stop sequence
|
||||
* on pin 1 at the configured baud rate.
|
||||
* - Bytes that need no internal transitions (e.g. 0xFF) still emit the
|
||||
* start-bit drop and the stop-bit rise.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { AVRSimulator } from '../simulation/AVRSimulator';
|
||||
import { PinManager } from '../simulation/PinManager';
|
||||
|
||||
// ATmega328P USART0 register addresses
|
||||
const UCSRA = 0xc0;
|
||||
const UCSRB = 0xc1;
|
||||
const UCSRC = 0xc2;
|
||||
const UBRRL = 0xc4;
|
||||
const UBRRH = 0xc5;
|
||||
|
||||
const UCSRB_RXEN = 0x10;
|
||||
const UCSRB_TXEN = 0x08;
|
||||
const UCSRC_UCSZ1 = 0x04;
|
||||
const UCSRC_UCSZ0 = 0x02;
|
||||
|
||||
const EMPTY_HEX = ':00000001FF\n';
|
||||
|
||||
type PinEvent = { pin: number; state: boolean; timeMs: number };
|
||||
|
||||
function configureUsartFor115200(sim: AVRSimulator): void {
|
||||
const cpu = (sim as unknown as { cpu: { data: Uint8Array } }).cpu;
|
||||
cpu.data[UBRRH] = 0;
|
||||
cpu.data[UBRRL] = 8; // 16M / (16*9) = 111111 baud (Arduino's actual 115200 setting)
|
||||
cpu.data[UCSRC] = UCSRC_UCSZ1 | UCSRC_UCSZ0; // 8 data bits, no parity, 1 stop bit
|
||||
cpu.data[UCSRA] = 0; // U2X=0 → multiplier 16
|
||||
// Trigger the configuration-change hook by simulating a UCSRB write
|
||||
cpu.data[UCSRB] = UCSRB_RXEN | UCSRB_TXEN;
|
||||
// avr8js's writeHook for UCSRB updates internal state; the cleanest way to
|
||||
// trigger it without running the firmware is to call onConfigurationChange
|
||||
// directly (it's the callback we registered, so it's safe to invoke).
|
||||
sim.usart!.onConfigurationChange?.();
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
let counter = 0;
|
||||
let depth = 0;
|
||||
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
|
||||
if (depth === 0) {
|
||||
depth++;
|
||||
cb(0);
|
||||
depth--;
|
||||
}
|
||||
return ++counter;
|
||||
});
|
||||
vi.stubGlobal('cancelAnimationFrame', vi.fn());
|
||||
});
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
describe('AVR USART → TX pin waveform synthesis', () => {
|
||||
let pm: PinManager;
|
||||
let sim: AVRSimulator;
|
||||
let events: PinEvent[];
|
||||
|
||||
beforeEach(() => {
|
||||
pm = new PinManager();
|
||||
sim = new AVRSimulator(pm);
|
||||
events = [];
|
||||
sim.onPinChangeWithTime = (pin, state, timeMs) => {
|
||||
events.push({ pin, state, timeMs });
|
||||
};
|
||||
sim.loadHex(EMPTY_HEX);
|
||||
});
|
||||
afterEach(() => sim.stop());
|
||||
|
||||
it('seeds the TX pin HIGH (idle) when TXEN flips 0 → 1', () => {
|
||||
configureUsartFor115200(sim);
|
||||
|
||||
// The first thing the scope should see on PD1 is an idle-HIGH sample.
|
||||
const txEvents = events.filter((e) => e.pin === 1);
|
||||
expect(txEvents.length).toBeGreaterThanOrEqual(1);
|
||||
expect(txEvents[0].state).toBe(true);
|
||||
});
|
||||
|
||||
it('emits a complete 10-bit UART frame for a byte with internal transitions', () => {
|
||||
configureUsartFor115200(sim);
|
||||
events = []; // discard the idle-seed event so we only inspect the frame
|
||||
|
||||
// 'a' = 0x61 = 0b01100001 → LSB-first bits: 1, 0, 0, 0, 0, 1, 1, 0
|
||||
// start bit0 bit1 bit2 bit3 bit4 bit5 bit6 bit7 stop
|
||||
// LOW HIGH LOW LOW LOW LOW HIGH HIGH LOW HIGH
|
||||
// Transitions vs. prev (starting from idle HIGH):
|
||||
// t0 LOW (start), t1 HIGH (b0), t2 LOW (b1), t6 HIGH (b5),
|
||||
// t8 LOW (b7), t9 HIGH (stop)
|
||||
sim.usart!.onByteTransmit!(0x61);
|
||||
|
||||
const txEvents = events.filter((e) => e.pin === 1);
|
||||
const states = txEvents.map((e) => e.state);
|
||||
expect(states).toEqual([false, true, false, true, false, true]);
|
||||
});
|
||||
|
||||
it('handles 0xFF (all ones) — only start bit drop, then stop-bit rise', () => {
|
||||
configureUsartFor115200(sim);
|
||||
events = [];
|
||||
|
||||
// 0xFF: start LOW, then 8x HIGH (no internal transitions), then stop HIGH.
|
||||
// Only 1 LOW (start) and 1 HIGH (first data bit, which is also the rest).
|
||||
sim.usart!.onByteTransmit!(0xff);
|
||||
|
||||
const txEvents = events.filter((e) => e.pin === 1);
|
||||
expect(txEvents.map((e) => e.state)).toEqual([false, true]);
|
||||
});
|
||||
|
||||
it('does not emit anything when TXEN is disabled', () => {
|
||||
// Don't configure UCSRB — TXEN remains 0.
|
||||
events = [];
|
||||
sim.usart!.onByteTransmit!(0x61);
|
||||
|
||||
const txEvents = events.filter((e) => e.pin === 1);
|
||||
expect(txEvents).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('uses the configured baud rate for bit timing (1 bit ≈ 1/baud seconds)', () => {
|
||||
configureUsartFor115200(sim);
|
||||
events = [];
|
||||
|
||||
// 0x00 produces transitions at: t0 LOW (start) and t9 HIGH (stop only).
|
||||
sim.usart!.onByteTransmit!(0x00);
|
||||
|
||||
const txEvents = events.filter((e) => e.pin === 1);
|
||||
expect(txEvents).toHaveLength(2);
|
||||
const dtMs = txEvents[1].timeMs - txEvents[0].timeMs;
|
||||
// 9 bit periods between start LOW and stop HIGH at 16M/(16*9) = 111111 baud:
|
||||
// bitMs = 1000 / 111111 ≈ 0.009 ms, 9 * 0.009 ≈ 0.081 ms
|
||||
expect(dtMs).toBeCloseTo((9 * 1000) / 111111, 3);
|
||||
});
|
||||
});
|
||||
|
|
@ -305,6 +305,13 @@ export class AVRSimulator {
|
|||
private lastPortCValue = 0;
|
||||
private lastPortDValue = 0;
|
||||
private lastOcrValues: number[] = [];
|
||||
/**
|
||||
* Last known TXEN bit value, used to detect 0→1 transitions and seed the
|
||||
* TX pin baseline at idle HIGH the moment the firmware enables the USART.
|
||||
* Without this seed the oscilloscope shows a floating/LOW baseline until
|
||||
* the first byte transmits, which doesn't match real hardware.
|
||||
*/
|
||||
private lastTxEnable = false;
|
||||
|
||||
constructor(pinManager: PinManager, boardVariant: 'uno' | 'mega' | 'tiny85' = 'uno') {
|
||||
this.pinManager = pinManager;
|
||||
|
|
@ -423,9 +430,14 @@ export class AVRSimulator {
|
|||
this.usart = new AVRUSART(this.cpu, activeUsart0Config, 16000000);
|
||||
this.usart.onByteTransmit = (value: number) => {
|
||||
if (this.onSerialData) this.onSerialData(String.fromCharCode(value));
|
||||
// Synthesize the UART frame on PD1 so the oscilloscope sees a real
|
||||
// waveform during Serial.print. See emitUartTxFrame() for details.
|
||||
this.emitUartTxFrame(value);
|
||||
};
|
||||
this.usart.onConfigurationChange = () => {
|
||||
if (this.onBaudRateChange && this.usart) this.onBaudRateChange(this.usart.baudRate);
|
||||
// Seed idle HIGH on the TX pin the first time TXEN flips on.
|
||||
this.handleUartConfigChange();
|
||||
};
|
||||
|
||||
this.twi = new AVRTWI(this.cpu, activeTwiConfig, 16000000);
|
||||
|
|
@ -509,6 +521,88 @@ export class AVRSimulator {
|
|||
this.scheduledPinChanges.splice(i, 0, { cycle: atCycle, pin, state });
|
||||
}
|
||||
|
||||
/**
|
||||
* Synthesize a real bit-level UART frame on the TX pin so an oscilloscope
|
||||
* sees a waveform during Serial.print, matching real ATmega328P / ATmega2560
|
||||
* behavior. avr8js's USART only intercepts the byte at the UDR0 register
|
||||
* level — it never toggles PD1 (Uno/Nano) / PE1 (Mega), so without this
|
||||
* shim the TX pin is flat in the scope while real hardware would show the
|
||||
* UART frame at the configured baud rate.
|
||||
*
|
||||
* Frame layout (8N1, the Arduino default):
|
||||
* [start LOW] [data LSB ... data MSB] [parity?] [stop1] [stop2?]
|
||||
*
|
||||
* We honour avr8js's USART configuration getters (bitsPerChar, parityEnabled,
|
||||
* parityOdd, stopBits, baudRate) so unusual configurations stay accurate.
|
||||
*
|
||||
* Each transition is emitted via onPinChangeWithTime so the oscilloscope
|
||||
* stamps it with simulator time (cpu.cycles / 16_000 ms), giving bit-level
|
||||
* timing that holds at any sweep speed.
|
||||
*/
|
||||
private emitUartTxFrame(byte: number): void {
|
||||
const usart = this.usart;
|
||||
if (!usart || !this.cpu || !this.onPinChangeWithTime) return;
|
||||
if (!usart.txEnable) return;
|
||||
|
||||
const baud = usart.baudRate;
|
||||
if (!baud || baud <= 0) return;
|
||||
|
||||
// ATmega328P (Uno/Nano) UART0: TX = PD1 → Arduino pin 1
|
||||
// ATmega2560 (Mega) UART0: TX = PE1 → Arduino pin 1 (Mega TX0)
|
||||
// ATtiny85 has no hardware USART so this method is never called.
|
||||
const txPin = 1;
|
||||
|
||||
const freqHz = 16_000_000;
|
||||
const cyclesPerBit = freqHz / baud;
|
||||
const startCycle = this.cpu.cycles;
|
||||
|
||||
// Build the frame bit-by-bit. UART idles HIGH; start = LOW; data LSB first;
|
||||
// optional parity; stop bit(s) HIGH. Idle->start gives the first transition.
|
||||
const dataBits = usart.bitsPerChar; // typically 8
|
||||
const bits: boolean[] = [false]; // start bit
|
||||
let onesCount = 0;
|
||||
for (let i = 0; i < dataBits; i++) {
|
||||
const b = (byte >> i) & 1;
|
||||
bits.push(b !== 0);
|
||||
onesCount += b;
|
||||
}
|
||||
if (usart.parityEnabled) {
|
||||
// Even parity = bit that makes total ones even; odd = total ones odd.
|
||||
const parity = usart.parityOdd ? (onesCount % 2 === 0) : (onesCount % 2 !== 0);
|
||||
bits.push(parity);
|
||||
}
|
||||
for (let i = 0; i < usart.stopBits; i++) bits.push(true);
|
||||
|
||||
// Emit only the bits that change state to keep buffer churn minimal.
|
||||
// The "previous" state at startCycle is idle HIGH.
|
||||
let prevState = true;
|
||||
for (let i = 0; i < bits.length; i++) {
|
||||
if (bits[i] !== prevState) {
|
||||
const timeMs = (startCycle + i * cyclesPerBit) / 16_000;
|
||||
this.onPinChangeWithTime(txPin, bits[i], timeMs);
|
||||
prevState = bits[i];
|
||||
}
|
||||
}
|
||||
// After the stop bit(s) the line is already HIGH (idle) so no trailing
|
||||
// transition is needed — the next byte will start from HIGH automatically.
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed the TX pin at idle HIGH when the firmware sets TXEN for the first
|
||||
* time (typically inside Serial.begin). Without this seed the scope's
|
||||
* "initial state before the first byte" defaults to LOW, hiding the start
|
||||
* bit transition of the very first byte sent.
|
||||
*/
|
||||
private handleUartConfigChange(): void {
|
||||
if (!this.usart || !this.cpu) return;
|
||||
const tx = this.usart.txEnable;
|
||||
if (tx && !this.lastTxEnable && this.onPinChangeWithTime) {
|
||||
const timeMs = this.cpu.cycles / 16_000;
|
||||
this.onPinChangeWithTime(1, true, timeMs);
|
||||
}
|
||||
this.lastTxEnable = tx;
|
||||
}
|
||||
|
||||
/** Flush all scheduled pin changes whose target cycle has been reached. */
|
||||
private flushScheduledPinChanges(): void {
|
||||
if (this.scheduledPinChanges.length === 0 || !this.cpu) return;
|
||||
|
|
@ -749,9 +843,11 @@ export class AVRSimulator {
|
|||
this.usart = new AVRUSART(this.cpu, usart0Config, 16000000);
|
||||
this.usart.onByteTransmit = (value: number) => {
|
||||
if (this.onSerialData) this.onSerialData(String.fromCharCode(value));
|
||||
this.emitUartTxFrame(value);
|
||||
};
|
||||
this.usart.onConfigurationChange = () => {
|
||||
if (this.onBaudRateChange && this.usart) this.onBaudRateChange(this.usart.baudRate);
|
||||
this.handleUartConfigChange();
|
||||
};
|
||||
|
||||
this.twi = new AVRTWI(this.cpu, twiConfig, 16000000);
|
||||
|
|
|
|||
Loading…
Reference in New Issue