test_intel: 8086 + 8259 PIC end-to-end interrupt integration

First test wiring the 8086 CPU to a real 8259 PIC chip on the same
board and proving hardware-interrupt routing works end-to-end:
  IRQ0 input → PIC asserts INT → CPU's INTR pin → CPU runs INTA
  cycle → PIC drives vector 0x40 on AD bus → CPU does do_int(0x40)
  → fetches CS:IP from IVT entry at 0x100 → ISR runs → IRET → main
  resumes from HLT.

Two related chip fixes required to make this work:

1. 8086 INTA cycle no longer drives AD itself.
   Real 8086 INTA bus cycle has the PIC drive the data lines, not
   the CPU. My earlier code did `bus_read_byte(0, false)` which
   first drove AD with addr=0, overwriting whatever the PIC had
   driven. Fix: release_ad → INTA̅ low → sample AD (PIC's INTA
   watcher fires synchronously and drives) → INTA̅ high.

2. 8086 HLT now interruptible.
   on_clock previously early-returned on G.halted, so step()
   never ran and the INTR check never executed. Real 8086 HLT
   wakes on INTR/NMI. Fix: remove the early return; step()'s
   own halted check (later in the function) only no-ops if no
   pending interrupt.

Tests: total test_intel 110 → 111 passing (+1, the integration
test). 0 failed. 11 todo. test_8086 now 11→12 passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
David Montero 2026-05-01 00:35:09 +02:00
parent 479b52634e
commit 555a4315be
2 changed files with 166 additions and 7 deletions

View File

@ -983,13 +983,16 @@ static void step(void) {
return;
}
if (G.intr_line && (G.flags & F_IF)) {
/* Approximate: read the vector byte from the data bus during
an INTA cycle. The actual external 8259 PIC would jam the
vector. Here we synthesise INT 0 if no fixture drives the
bus (reset state); a test fixture can override by driving
the data bus when our chip asserts INTA̅ low. */
/* Hardware interrupt acknowledge cycle. Real 8086 in min mode
runs two INTA̅ pulses; the second has the data bus driven by
the external 8259 PIC with the vector byte. We collapse to
one pulse here. Critically, we must NOT drive AD ourselves
during this cycle the PIC owns the bus. */
release_ad();
vx_pin_write(G.inta, 0);
uint8_t vec = bus_read_byte(0, false); /* dummy read for cycle */
/* PIC's INTA̅-falling-edge watcher fires synchronously and
drives AD0..AD7 with the vector. Sample. */
uint8_t vec = (uint8_t)(read_ad() & 0xFF);
vx_pin_write(G.inta, 1);
do_int(vec);
G.halted = false;
@ -1430,13 +1433,15 @@ static void on_intr(void* user_data, vx_pin pin, int value) {
static void on_clock(void* user_data) {
(void)user_data;
if (G.reset_active) return;
if (G.halted) return;
if (vx_pin_read(G.ready) == 0) return; /* wait state */
if (vx_pin_read(G.hold) == 1) { /* bus hold */
vx_pin_write(G.hlda, 1);
return;
}
vx_pin_write(G.hlda, 0);
/* Do NOT early-return on halted — step() handles that and also
serves an interrupt that wakes us up. Real 8086 HLT is
interruptible. */
step();
}

View File

@ -0,0 +1,154 @@
/**
* 8086 + 8259 PIC integration test.
*
* Wires both chips on one board, configures the PIC, fires an
* IRQ, and verifies the 8086 takes the interrupt and runs an ISR
* that writes a sentinel byte to memory.
*
* This is the first end-to-end test of hardware-interrupt routing
* from an external chip (the PIC) into the CPU's interrupt
* pipeline proving the INTA bus cycle works between two real
* WASM chips.
*/
import { describe, it, expect } from 'vitest';
import { BoardHarness } from '../src/BoardHarness.js';
import { chipWasmExists } from '../src/helpers.js';
const CPU = '8086';
const PIC = '8259-pic';
const skip = !chipWasmExists(CPU) || !chipWasmExists(PIC);
const CLOCK_NS = 200;
function cpuPinMap() {
const m = {
ALE: 'ALE', RD: 'RD', WR: 'WR', MIO: 'MIO', DTR: 'DTR', DEN: 'DEN',
HOLD: 'HOLD', HLDA: 'HLDA',
INTR: 'INTR', NMI: 'NMI', INTA: 'INTA',
RESET: 'RESET', READY: 'READY', TEST: 'TEST', CLK: 'CLK',
MNMX: 'MNMX', BHE: 'BHE',
VCC: 'VCC', GND: 'GND',
};
for (let i = 0; i < 16; i++) m[`AD${i}`] = `AD${i}`;
for (let i = 16; i < 20; i++) m[`A${i}`] = `A${i}`;
return m;
}
function picPinMap() {
// PIC's D bus is the low byte of the 8086's AD bus. PIC's INT pin
// wires to CPU's INTR; PIC's INTA pin wires to CPU's INTA̅. PIC has
// its own A0/CS̅/RD̅/WR̅ — we'd normally wire CS̅ to a chip-select
// decode line, but for this test we just leave it tied to the test
// fixture (we toggle it manually).
const m = {
A0: 'PIC_A0', CS: 'PIC_CS', RD: 'PIC_RD', WR: 'PIC_WR',
INT: 'INTR', // ← shared net with CPU's INTR
INTA: 'INTA', // ← shared net with CPU's INTA̅
CAS0: 'PIC_CAS0', CAS1: 'PIC_CAS1', CAS2: 'PIC_CAS2', SPEN: 'PIC_SPEN',
VCC: 'VCC', GND: 'GND',
};
// PIC's D0..D7 share with CPU's AD0..AD7
for (let i = 0; i < 8; i++) m[`D${i}`] = `AD${i}`;
for (let i = 0; i < 8; i++) m[`IRQ${i}`] = `IRQ${i}`;
return m;
}
describe('8086 + 8259 PIC integration', () => {
it.skipIf(skip)('IRQ0 fires the ISR which writes a sentinel byte', async () => {
const board = new BoardHarness();
// PIC must be added BEFORE the CPU so its INTA-falling watcher
// fires first per advanceNanos and drives D bus with the vector
// before the CPU samples AD.
await board.addChip(PIC, picPinMap());
await board.addChip(CPU, cpuPinMap());
// RAM covering the full 1 MB. ISR vector at 0x40 → table entry
// at physical (0x40 << 2) = 0x100..0x103: { offset_lo, offset_hi,
// segment_lo, segment_hi }. We make the ISR live at CS=0xF000,
// IP=0x0200, so vector entry is { 0x00, 0x02, 0x00, 0xF0 }.
const ram = board.installFake8086Bus({});
// ISR at physical 0xF0200: write 0xAA to [0x9000], then IRET.
const isr = [
0xC6, 0x06, 0x00, 0x90, 0xAA, // MOV byte [0x9000], 0xAA
0xCF, // IRET
];
for (let i = 0; i < isr.length; i++) ram.poke(0xF0200 + i, isr[i]);
// IVT entry for vector 0x40
ram.poke(0x100, 0x00);
ram.poke(0x101, 0x02);
ram.poke(0x102, 0x00);
ram.poke(0x103, 0xF0);
// Boot stub: JMP FAR 0xF000:0x0100 at the reset vector.
ram.poke(0xFFFF0, 0xEA);
ram.poke(0xFFFF1, 0x00);
ram.poke(0xFFFF2, 0x01);
ram.poke(0xFFFF3, 0x00);
ram.poke(0xFFFF4, 0xF0);
// Main program at 0xF0100: STI ; HLT (we'll get interrupted out
// of the HLT). Actually 8086 HLT continues on interrupt — perfect.
const main = [
0xFB, // STI
0xF4, // HLT
];
for (let i = 0; i < main.length; i++) ram.poke(0xF0100 + i, main[i]);
// Quiet inputs.
board.setNet('MNMX', true);
board.setNet('READY', true);
board.setNet('TEST', true);
board.setNet('NMI', false);
board.setNet('HOLD', false);
board.setNet('PIC_CS', true);
board.setNet('PIC_RD', true);
board.setNet('PIC_WR', true);
for (let i = 0; i < 8; i++) board.setNet(`IRQ${i}`, false);
// Reset CPU
board.setNet('RESET', true);
board.advanceNanos(CLOCK_NS * 8);
board.setNet('RESET', false);
// Helper to write to PIC. We need to NOT collide with the CPU's
// bus, but during this test the CPU is still mid-reset / running
// the boot JMP. We'll wait until the CPU is in HLT state (after
// ~2000 cycles) before driving the PIC, to avoid contention.
function picWrite(a0, value) {
board.setNet('PIC_A0', a0 !== 0);
// We use the AD bus for PIC data writes too (since PIC's D maps
// to AD0..AD7). The CPU is halted so AD is idle.
for (let i = 0; i < 8; i++) {
board.setNet(`AD${i}`, ((value >> i) & 1) === 1);
}
board.advanceNanos(20);
board.setNet('PIC_CS', false);
board.setNet('PIC_WR', false);
board.advanceNanos(20);
board.setNet('PIC_WR', true);
board.advanceNanos(20);
board.setNet('PIC_CS', true);
}
// Run a few cycles to get past the JMP-FAR + STI + HLT.
for (let i = 0; i < 2000; i++) board.advanceNanos(CLOCK_NS);
// Configure PIC: ICW1 (single, ICW4-needed) + ICW2 (vector base 0x40)
// + ICW4 (8086 mode) + OCW1 (mask = 0).
picWrite(0, 0x13);
picWrite(1, 0x40);
picWrite(1, 0x01);
picWrite(1, 0x00);
// Fire IRQ0 — should produce INT, INTA cycle drives 0x40 on bus,
// CPU executes do_int(0x40), runs the ISR, RETs back.
board.setNet('IRQ0', true);
for (let i = 0; i < 5000; i++) board.advanceNanos(CLOCK_NS);
expect(ram.peek(0x9000)).toBe(0xAA);
board.dispose();
}, 30_000);
});