Skip to content

P8X: An 8-Bit TTL CPU — Bus/Backplane Card Architecture

Reorganized around a passive backplane with six plug-in CPU cards (plus a PS/2 input card) and the 4×16-bit pointer register bank as the architectural centerpiece. The PC, SP, and MAR of the original SAP-8X design are all subsumed by the pointer bank.

Cards: 1. Control / Microcode card (clock, reset, sequencer, microcode EPROMs, IR, front-panel run controls) 2. Register Bank card (P0–P3, the 16-bit up/down pointer set) 3. ALU card (A, B, T, T2 registers, 74181 ALU, shifter, flags) 4. Memory card (ROM, RAM, address decode) 5. I/O card (toggle-switch input port, LED output port, RS-232 via 6850 ACIA) 6. CF-IDE card (CompactFlash in 8-bit True IDE mode, memory-mapped at $FF10–$FF17) 7. PS/2 card (keyboard + mouse at $FF58–$FF5F; optional — see ps2-card/)

(The optional front-panel LED display card of the first plan was dropped; the I/O card carries the bus-monitor LEDs. A bus test card for bring-up, and the backplane, complete the board set: hardware/. All boards are designed and routed in KiCad, 280 × 140 mm for the plug-in cards; none is fabricated yet.)

Total: ~130 logic ICs across the six CPU cards. Each card is independently testable on the backplane. (The per-card "≈ N chips" notes below are original design estimates; the authoritative live count is generators/gen_bom.py → hardware/p8x-bom.csv.)


1. Programmer's Model

Register Width Role
P0 16 Program counter
P1, P2 16 General pointers (Forth IP/W, BASIC text pointer, etc.)
P3 16 Stack pointer (empty-descending: points at next free byte)
A 8 Accumulator (ALU operand A)
B 8 Operand register (ALU operand B)
FLAGS 4 C, Z, N, V
T, T2 8 Hidden microcode temporaries (not programmer-visible)

Every pointer supports synchronous load, hold, increment, decrement (full 16-bit carry/borrow). The address bus is always driven by the currently selected pointer — there is no separate MAR and no address mux. An instruction fetch is simply "select P0, read memory, increment."

All I/O is memory-mapped in page $FF00–$FFFF.

Memory map (rev E, single-sourced in generators/gen_memmap.py — see the memory card): - $0000–$17FF EEPROM, 6 KB (monitor + BIOS, ~4.9 KB used; a 28C256, or a 28C64) — shrunk from 8 KB on 2026-09-14 - $1800–$FEFF SRAM, 58 KB (2× 62256); $1800–$1FFF is the OS/BIOS scratch island, the OS loads at $2000, programs at $5900 - $FF00–$FFFF I/O page (RAM disabled here)

Reset forces P0 to $0000 (pointer clear via 74169 synchronous load of zeros — see §4.2).


2. Backplane

Passive backplane, DIN 41612 96-pin (rows A/B/C) connectors; the cards were first planned as 100×160 mm Eurocards and are laid out in KiCad at 280×140 mm. Row B is mostly ground guard between the signal rows, but B3–B26 alternate: odd pins are GND, even pins are spare bus lines SPARE12–SPARE23 (rev D); B27 = CLRC, B28 = BSEL, B29 = IRQ (rev C), B30 = SPARE11. See p8x-bus-definition.md for the full pin map.

2.1 Bus signals

Group Signals Count Driven by
Data bus D0–D7 8 one card per microcycle (one-hot by decode)
Address bus A0–A15 16 Register Bank card (always)
Data source select DOE0–3 4 Control card
Data destination select DLD0–3 4 Control card
Pointer select PSEL0–2 3 Control card
Pointer count PINC, PDEC 2 Control card
ALU function ALUS0–3, ALUM, CIN 6 Control card
Shifter SH0–SH1 2 Control card
Flag latch LDF 1 Control card
Clock CLK, CLK̄ 2 Control card
Reset RES̄ 1 Control card
Power +5 V, GND rest PSU

≈ 48 signals + power/ground, plus FC/FZ/FN/FV flag lines and 20 spares (SPARE4–11 plus the rev-D row-B SPARE12–23) — fits the 96-pin connector with room to spare.

2.2 Distributed field decoding

The 4-bit DOE (data output enable) and DLD (data load) fields are broadcast encoded on the backplane; each card carries its own 74154 (or 74138) decoder and responds only to its assigned codes. This keeps the backplane narrow, makes bus contention structurally impossible (one-hot per field), and means adding a card never requires rewiring the others.

DOE field assignments (who drives D0–D7):

Code Source Card
0 none (bus idle, pulled up) —
1 A ALU
2 B ALU
3 T ALU
4 T2 ALU
5 ALU result (via shifter) ALU
6 FLAGS ALU
7 MEM (read) Memory / I/O
8 PTRL — low byte of selected pointer Reg Bank
9 PTRH — high byte of selected pointer Reg Bank

DLD field assignments (who latches D0–D7 at the clock edge):

Code Destination Card
0 none —
1 A ALU
2 B ALU
3 T ALU
4 T2 ALU
5 FLAGS (restore) ALU
6 IR Control
7 MEMW — memory/I-O write strobe Memory / I/O
8 PTRL — low byte of selected pointer Reg Bank
9 PTRH — high byte of selected pointer Reg Bank

Note MEMW is just another destination: the Memory and I/O cards decode DLD=7 and generate a write pulse (gated with CLK̄ for clean timing).


3. Control / Microcode Card

3.1 Microcode addressing (13 bits → 8K × 32)

Bits Source
12–5 IR (opcode, 74273)
4–1 Step counter (74161)
0 Selected condition (74151 mux: 0, 1, C, Z, N, V, …)

3.2 Control word (32 bits, 4× 28C64 EPROM)

As implemented (rev C). The authoritative bit map is the w() encoder + header comment in microcode/genucode.py; this table mirrors it.

Bits Field
0–3 DOE
4–7 DLD
8–10 PSEL (P0–P3 + PT=4 + PT2=5 hidden scratch; 3-bit since rev B)
11 PINC
12 PDEC
13–16 ALU S0–S3
17 ALU M
18 CIN (pin, active-low carry)
19 SH0 (shift left)
20 SH1 (shift right)
21 LDF (latch all four flags)
22–24 FCOND (condition mux: never/always/C/Z/N/V/LT=N^V/LE=(N^V)|Z)
25 µRESET (step counter → 0, ends instruction)
26 HALT (gates clock off; resume via front panel)
27 LDZN (latch Z,N from the bus on loads)
28 SHCIN (shifter shift-in = C, for rotates)
29 SETC (force C=1)
30 CLRC (force C=0)
31 BSEL (ALU B-input mux: 0=B register, 1=T register)

Pipeline latch: the 32 EPROM outputs are registered in 4× 74374 clocked on the opposite edge (CLK̄), so glitching ROM outputs never reach the backplane. Non-negotiable for reliability.

3.3 Also on this card

  • Crystal oscillator + 74161 divider (÷1/2/4/8 selectable)
  • Power-on/pushbutton reset (RC + 7414), drives RES̄ and clears step counter and IR
  • Front-panel run controls: RUN/HALT toggle, single-STEP pushbutton (debounced 74279), clocked through a 7474 synchronizer so the machine always stops on a microcycle boundary. HALT microcode bit ORs into the same stop logic.

BOM: 4× 28C64, 4× 74374, 74273 (IR), 74161 (step), 74161 (clk div), 74151 (cond), 74154 (DLD decode for IR), 7414, 7474, 74279, osc, glue ≈ 15 chips


4. Register Bank Card

The biggest card, and the heart of the machine.

4.1 Structure

  • 16× 74169 synchronous up/down counters: 4 chips per pointer, RCO→ENT cascaded for full 16-bit carry
  • Pointer selection: PSEL → 74139; the selected pointer's outputs are enabled onto an on-card 16-bit pointer bus via 2× 74244 per pointer (8× 74244 total)
  • Address bus drivers: pointer bus → 2× 74244 → backplane A0–A15 (always enabled — this card owns the address bus)
  • Byte readback: pointer bus hi/lo → 2× 74257 mux → 74244 → data bus (DOE codes 8/9)
  • Byte loads: DLD codes 8/9 + PSEL → 74138 → eight load strobes (4 pointers × 2 bytes). A byte load asserts L̄D̄ on just the two 74169s of that byte; the other byte's chips hold
  • Inc/Dec: PINC ∨ PDEC → count-enable on the selected pointer's slices (gated through the 74139 selection); PDEC drives U/D̄. Count direction/enable apply to all 4 slices so carry propagates 16 bits

4.2 Reset behavior

RES̄ forces a synchronous load of $0000 into P0 (gates the P0 load strobes and pulls the load inputs low via the data-bus pull-downs / forced-zero buffer). One 74244 wired to all-zeros, enabled at reset, does this cleanly.

4.3 Timing note

74169s are synchronous: during a microcycle the current value drives the address bus; load/inc/dec take effect at the clock edge. So "read MEM at P3 and decrement P3" in one microcycle uses the pre-decrement address — which is exactly what the empty-descending stack convention wants (write-then-dec for push, inc-then-read for pop).

BOM: 16× 74169, 8× 74244 (select), 2× 74244 (addr drive), 2× 74257 + 74244 (readback), 74139, 74138, zero-buffer 74244, gates ≈ 32 chips

(If one Eurocard gets crowded, this splits naturally into two half-bank cards — P0/P1 and P2/P3 — sharing the bus pinout.)


5. ALU Card

  • A, B, T, T2: each 74377 (load) + 74244 (bus drive) = 8 chips. A and B feed the 74181 inputs directly
  • ALU: 2× 74181 + 74182, function lines straight from backplane
  • Shifter: 2× 74157 after the ALU (pass / <<1 / >>1, carry in/out for rotates), then 74244 to the bus
  • Flags: 74175 (C, Z, N, V), Z from 74260+gate over the shifter output, latched on LDF. FLAGS↔bus paths for push/pop of status
  • Decode: 1× 74154 for DOE codes 1–6, 1× 74154 for DLD codes 1–5

BOM: ≈ 18 chips


6. Memory Card

  • 28C256 EEPROM, selected when A15 = 0
  • 62256 SRAM, selected when A15 = 1 and not the I/O page: 7430 (8-input NAND on A8–A15) detects $FFxx and inhibits RAM CS
  • 74245 transceiver to the data bus: direction from read (DOE=7) vs write (DLD=7); enabled only for on-card addresses
  • Write pulse: DLD=7 ∧ CLK̄ → W̄Ē, giving address/data setup in the first half-cycle and a clean strobe in the second

BOM: 28C256, 62256, 74245, 7430, 74154 or 74138 + gates ≈ 6 chips


7. I/O Card

Decodes the $FFxx page (same 7430 trick) plus A1–A2 via a 74138 → up to 8 port selects.

Address Port
$FF00 Switch input: 8 toggle switches → 74244 → data bus on read
$FF02 LED output: 74374 latch → 8 LEDs (write)
$FF04–05 6850 ACIA control/status + data, RS-232
  • 6850 ACIA + MAX232 (the one non-TTL concession for RS-232 levels — alternatively 1488/1489 with ±12 V) + baud clock from a 74161 divider chain or a dedicated 2.4576 MHz can ÷16
  • Bus-monitor LEDs (passive): 3× 74244 permanently buffering A0–A15 and D0–D7 to LED banks. Costs nothing logically, and with the single-step button on the control card it gives you a full Altair-style "watch the machine think" front panel
  • Software handles everything: the monitor program in ROM polls the switches and ACIA — no bus-mastering front panel needed since the ROM bootstraps the machine

BOM: 6850, MAX232, 74244 (switches), 74374+LEDs, 7430, 74138, baud divider, 3× 74244 monitors ≈ 10 chips

Expansion I/O beyond this card. The I/O card owns only $FF00–$FF0F. Later ports live on their own cards / on the FPGA graphics card and are recorded in p8x-bus-definition.md §5: the 2nd ACIA ($FF08), the MDU ($FF30–$FF3F), the GL graphics port ($FF50–$FF54), and the PS/2 keyboard+mouse window ($FF58–$FF5F, see ps2-card/ — a standalone design). Every I/O symbol is single-sourced in generators/gen_memmap.py.


8. Microcode Examples

Notation: one line per microcycle. Every instruction begins with the shared fetch cycle.

Fetch (all instructions, step 0)

PSEL=P0, DOE=MEM, DLD=IR, PINC

LDA (P1)+ — load A indirect via P1, post-increment

0: fetch
1: PSEL=P1, DOE=MEM, DLD=A, PINC, LDF, µRESET
Two cycles. This is the instruction that makes the interpreter easy.

LDA #imm

0: fetch
1: PSEL=P0, DOE=MEM, DLD=A, PINC, LDF, µRESET

STA (P2)

0: fetch
1: PSEL=P2, DOE=A, DLD=MEMW, µRESET        ; add PINC/PDEC variants as separate opcodes

JMP abs

0: fetch                                    ; P0 → operand lo
1: PSEL=P0, DOE=MEM, DLD=T, PINC            ; target lo → T
2: PSEL=P0, DOE=MEM, DLD=PTRH               ; target hi → P0H (no PINC!)
3: PSEL=P0, DOE=T,   DLD=PTRL, µRESET       ; T → P0L
Step 2 reads memory using the old P0 (synchronous load — value changes at the edge), so loading P0H mid-fetch is safe.

JZ abs (condition bit selects between two microcode paths)

cond=0: steps 1–2 just PINC twice past the operand, µRESET
cond=1: same as JMP abs

JSR abs (pushes address of operand-lo; RTS compensates)

0: fetch                                    ; P0 → operand lo = return-2
1: PSEL=P0, DOE=PTRH, DLD=T2                ; return hi → T2
2: PSEL=P0, DOE=PTRL, DLD=T                 ; return lo → T
3: PSEL=P3, DOE=T2,  DLD=MEMW, PDEC         ; push hi
4: PSEL=P3, DOE=T,   DLD=MEMW, PDEC         ; push lo
5: PSEL=P0, DOE=MEM, DLD=T,  PINC           ; target lo → T
6: PSEL=P0, DOE=MEM, DLD=PTRH               ; target hi → P0H
7: PSEL=P0, DOE=T,   DLD=PTRL, µRESET       ; → P0L

RTS

0: fetch
1: PSEL=P3, PINC                            ; SP → pushed lo
2: PSEL=P3, DOE=MEM, DLD=T, PINC            ; lo → T
3: PSEL=P3, DOE=MEM, DLD=T2                 ; hi → T2  (wait, hi is at SP now)
   — order: push was hi-then-lo, so pop is lo-then-hi: step 2 reads lo, step 3 reads hi ✓
4: DOE=T2, DLD=PTRH (PSEL=P0)
5: DOE=T,  DLD=PTRL (PSEL=P0)
6: PSEL=P0, PINC
7: PSEL=P0, PINC, µRESET                    ; skip the operand bytes

Forth NEXT (P1 = IP, jump indirect through threaded list)

0: fetch (the NEXT opcode itself, if implemented as an instruction)
1: PSEL=P1, DOE=MEM, DLD=T,    PINC         ; word addr lo
2: PSEL=P1, DOE=MEM, DLD=PTRH, PINC → P0H   ; word addr hi
3: DOE=T, DLD=PTRL (PSEL=P0), µRESET
Four cycles for the Forth inner interpreter. At 2 MHz that's 500k threaded dispatches/sec.


9. Suggested Instruction Set Additions over Rev 1

Beyond the Rev 1 set (loads/stores, ALU ops, jumps, stack, JSR/RTS):

Mnemonic Operation
LDA/STA (Pn)+ / (Pn)− / (Pn) indirect with post-inc / post-dec / plain, n = 1,2
LDP n,#imm16 load pointer immediate (3 bytes)
INP n / DEP n 16-bit pointer inc/dec
TPA n / TAP n (lo/hi) pointer byte ↔ A transfers
PHP / PLP push/pop flags
PSH n / POP n push/pop a full pointer (microcoded via T/T2)

Implemented in rev D (pure microcode, opcodes $74–$77) — added to shrink compiler-generated code, where 16-bit word moves to/from the stack and pointer loads dominate:

Mnemonic Operation
PHW a push the 16-bit word at memory address a (replaces LDA/PHA ×2)
PLW a pop a 16-bit word into memory address a (replaces PLA/STA ×2)
LPW1 a / LPW2 a load pointer P1 / P2 from the 16-bit word at a (replaces LDA/TAPnL/LDA/TAPnH)
MOVW dst,src ($78) 16-bit memory→memory move (replaces LDA/STA/LDA/STA)

PHW/PLW/LPW1/LPW2 are pure microcode. MOVW needs one hardware addition: a second hidden scratch pointer PT2 (PSEL = 5) as the write cursor, since a mem→mem move holds two addresses live at once. PT2 is another 74169 counter set on the register-bank card — no backplane change (PSEL is already 3 bits: PSEL0–2 on C20/C21/C27, and U33 already decodes select 5). Making it work on real hardware also requires upgrading PT from load-only 74377 latches to 74169 counters, because PHW/PLW/LPW (and MOVW) increment PT — a requirement the earlier "pure-microcode" ops introduced but the regbank card has not yet been revised for. See the register-bank card theory (rev D).

Tier A — the C-compiler ISA (2026-09, pure microcode, 39 opcodes). The next step after rev D, from the measured cost breakdown of compiled programs (see p8x-isa-c-extensions.md): a real 16-bit pointer load, displacement addressing, and 16-bit memory-word arithmetic. Every one is built from the existing datapath — PT/PT2 as address scratch, T as the ALU's second operand, and the condition planes as the carry chain (an ALU step latches C, the next step routes it to the plane mux, the step after is a C=0/C=1 pair). No new register, no new bus line; the emulator and the FPGA run the regenerated u0–u3.bin unchanged; the TTL machine needs its control-store EPROMs reburned.

Mnemonic Operation
LDPn #imm16 ($38–$3A) Pn := imm16 in one 3-byte instruction (was the LPLn/LPHn pair, 4 bytes)
ADDP3 / SUBP3 #imm8 ($3C/$3D) P3 ± imm8 — allocate / free a stack frame
LDA / STA (Pn+d) ($88–$8A / $8C–$8E) byte at Pn + d, d an unsigned 8-bit displacement (computed into PT)
LDW a,(Pn+d) / STW (Pn+d),a ($90–$92 / $94–$96) a 16-bit frame local to / from a memory word
LDW a,#imm8 / #imm16 ($98/$99) a 16-bit constant into a memory word (the compiler's most frequent idiom, 10 → 4 bytes)
ADDW / SUBW / CMPW a,b ($9A–$9C) 16-bit memory-word arithmetic, carry chained; CMPW sets flags only
INCW / DECW a ($9E/$9F) 16-bit increment / decrement in memory
ADDW / SUBW / CMPW a,#imm8 ($A0–$A2) the same with an 8-bit immediate (zero-extended): x + k, pointer stepping, if (n < k)
ADDW / SUBW / CMPW a,#imm16 ($B1–$B3) 16-bit immediate: x + &table, if (n == 1300)
ANDW / ORW / XORW a,b ($B4–$B6), a,#imm8 ($B7–$B9), a,#imm16 ($BA–$BC) 16-bit bitwise on a memory word; ANDW x,#1 / JZ tests a bit, XORW x,#$FFFF is bitwise NOT
PHW (Pn+d) ($BD–$BF) push the word at Pn + d (high byte first) — a C argument straight from its frame slot, 2 bytes
LEAW a,(Pn+d) ($A4–$A6) word at a := Pn + d — the address of a frame local (arrays, &x)
LPW3 a ($79) P3 := word at a — restore a saved stack pointer
JMP / BZ / BNZ / BCP / JNC / BLT / BGE / BLE / BGT rel8 ($A8–$B0) 2-byte relative branches, signed displacement from the next instruction. Taken: 8 steps, A and the flags are clobbered (B kept); not taken: 2 steps, nothing touched. Until the 2026-09-11 speed audit the taken path saved and restored A and the flags (14 steps) — 11 cycles per taken branch against the 3-step absolute form, 6.5% of a compiled program's time — so that was dropped and the compiler's dependent idioms rewritten. The assembler emits them for .relax sources (compiler output) or an explicit .R suffix; .A forces the absolute form, which the compiler uses for every always-taken jump

Contracts: the memory-to-memory forms clobber A (it is the ALU's only A input) and latch the flags; d is unsigned; after ADDW/SUBW/CMPW, C is the 16-bit carry / no-borrow and N^V the signed order. Z is the full 16-bit result on every immediate form (a,#imm8 / a,#imm16, 14 steps: after the low byte the microcode keeps a 0/1 marker of its Z in T2 and, when the high byte comes out zero, re-latches Z from the marker through the Z plane — N is 0 in exactly that case, so LDZN from a 0/1 value is correct) but high byte only on the a,b forms, which have no spare steps. The rev-D upgrade of PT/PT2 to 74169 counters covers these too.

PHW byte order (changed 2026-09-11): PHW now pushes the high byte first, so the pushed word lies little-endian at P3+1..P3+2 — the same layout JSR leaves for a return address, and what LDW a,(P3+d) reads. That is what lets the C compiler keep its call frames on the hardware stack: arguments pushed with PHW are plain frame words to the callee. PHW/PLW were only ever used as a pair, so nothing else observed the order.

Opcode space: 256 slots, 143 used.


10. Build & Bring-Up Order

  1. Backplane + PSU: verify power on every connector, grounds solid
  2. Control card alone: scope CLK/CLK̄, verify reset, single-step, and that the pipeline latch outputs a stable all-zeros word with blank-pattern EPROMs
  3. + Register Bank: burn microcode that does nothing but PSEL=P0, PINC forever → watch the address LEDs count. Then test load, dec, each pointer
  4. + Memory card: program ROM with $EA-style NOPs, microcode the fetch → IR should track ROM contents (probe IR or temporarily bus it)
  5. + ALU card: registers first (bus loopback A→bus→B), then ALU functions against a truth-table program, then flags
  6. + I/O card: LED port write from microcode, switch read, then ACIA loopback (TX→RX jumper) before wiring real RS-232
  7. Monitor program in ROM ($0000, serial console): examine/modify (E), dump (D), CF init/format/boot (I/F/B), go (G), help (?/H), plus a BIOS jump table at $0100 — from there everything else is software. See p8x-monitor.md for the full command reference.

11. Power & Practical Notes

  • ~130 LS-TTL chips ≈ 1.5–2 A at 5 V; size the PSU at 4–5 A with per-card 10 µF bulk + 0.1 µF per chip
  • Keep CLK/CLKB on adjacent backplane pins with guard traces; AC termination (100 Ω + 150 pF to GND) footprints are provided DNP at the far slot — populate only if scope shows ringing. Do not use Thevenin termination: it biases lines into the HCT threshold region and wastes 25 mA per line at idle (see p8x-backplane-design.md §3)
  • Wire-wrap or PCB both fine at ≤4 MHz; keep the 74181 carry chain and the 74169 RCO cascades short
  • 74169 vs 74193: 74169 is fully synchronous (single clock + direction pin), which is why it's specified here; 74193's dual-clock scheme is glitch-prone in this application