P8X Programmer's Guide
Instruction set rev 1 — generated from the microcode source (genucode.py); opcode values, byte and cycle counts are extracted from the same tables that build the EPROM images, so this document cannot drift from the hardware.
Programming model
A, B — 8-bit ALU operand registers; results land in A. P0-P3 — four 16-bit pointer registers; the address bus is always driven by one of them. P0 is the program counter. P3 is the stack pointer (empty-descending: push writes then decrements; initialise it early — see note 3). P1 and P2 are general pointers used by the (Pn) addressing modes. FLAGS — C, Z, N, V, latched only by instructions marked in the Flags column.
Memory map (single-sourced in generators/gen_memmap.py): $0000-$17FF ROM (6K) | $1800-$FEFF RAM ($1800-$1FFF scratch island) | $FF00-$FFFF I/O: $FF00 switches (r), $FF02 LEDs (w), $FF04 ACIA status (bit0 RX ready, bit1 TX ready), $FF05 ACIA data.
Reset: P0 is forced to $0000; execution begins there. All other registers (including P3) are undefined on real hardware.
Instruction set
System
| Op |
Mnemonic |
Bytes |
Cycles |
Flags |
Description |
| $00 |
NOP |
1 |
2 |
- |
No operation. |
| $01 |
HLT |
1 |
2 |
- |
Halt the clock. Resume only by reset (or emulator exit). |
| $72 |
CLC |
1 |
2 |
C |
C := 0. |
| $73 |
SEC |
1 |
2 |
C |
C := 1. |
Interrupts (rev C)
| Op |
Mnemonic |
Bytes |
Cycles |
Flags |
Description |
| $02 |
EI |
1 |
2 |
- |
Enable maskable interrupts (IE := 1). |
| $03 |
DI |
1 |
2 |
- |
Disable maskable interrupts (IE := 0). |
| $04 |
RTI |
1 |
8 |
- |
Return from interrupt: pop flags then PC; re-enables IE. |
| $08 |
IRQ |
1 |
10 |
- |
Software interrupt: push PC+flags, vector to $0808. Also the opcode the hardware forcing buffer injects on a maskable IRQ. |
Load / store
| Op |
Mnemonic |
Bytes |
Cycles |
Flags |
Description |
| $10 |
LDA #imm |
2 |
2 |
- |
A := immediate byte. |
| $11 |
LDB #imm |
2 |
2 |
- |
B := immediate byte. |
| $12 |
LDA addr |
3 |
6 |
Z N |
A := byte at addr (absolute). |
| $13 |
LDB addr |
3 |
6 |
Z N |
B := byte at addr (absolute). |
| $14 |
STA addr |
3 |
6 |
- |
Byte at addr := A (absolute). |
| $15 |
LDA (P1)+ |
1 |
2 |
- |
A := memory at P1, then P1 := P1 + 1. |
| $16 |
LDA (P2)+ |
1 |
2 |
- |
A := memory at P2, then P2 := P2 + 1. |
| $17 |
LDA (P3)+ |
1 |
2 |
- |
A := memory at P3, then P3 := P3 + 1. |
| $19 |
STA (P1)+ |
1 |
2 |
- |
Memory at P1 := A, then P1 := P1 + 1. |
| $1A |
STA (P2)+ |
1 |
2 |
- |
Memory at P2 := A, then P2 := P2 + 1. |
| $1B |
STA (P3)+ |
1 |
2 |
- |
Memory at P3 := A, then P3 := P3 + 1. |
| $1D |
STA (P1) |
1 |
2 |
- |
Memory at P1 := A. |
| $1E |
STA (P2) |
1 |
2 |
- |
Memory at P2 := A. |
| $1F |
STA (P3) |
1 |
2 |
- |
Memory at P3 := A. |
| $51 |
LDA (P1) |
1 |
2 |
Z N |
A := memory at P1 (P1 unchanged). |
| $52 |
LDA (P2) |
1 |
2 |
Z N |
A := memory at P2 (P2 unchanged). |
| $53 |
LDA (P3) |
1 |
2 |
Z N |
A := memory at P3 (P3 unchanged). |
| $88 |
LDA (P1+d) |
2 |
7 |
C Z N |
A := byte at P1 + d (d unsigned 0..255). C/V from the address add, then Z/N from A. P1 unchanged. |
| $89 |
LDA (P2+d) |
2 |
7 |
C Z N |
A := byte at P2 + d (d unsigned 0..255). C/V from the address add, then Z/N from A. P2 unchanged. |
| $8A |
LDA (P3+d) |
2 |
7 |
C Z N |
A := byte at P3 + d (d unsigned 0..255). C/V from the address add, then Z/N from A. P3 unchanged. |
| $8C |
STA (P1+d) |
2 |
9 |
C Z N |
Byte at P1 + d := A. A preserved; flags clobbered by the address add. |
| $8D |
STA (P2+d) |
2 |
9 |
C Z N |
Byte at P2 + d := A. A preserved; flags clobbered by the address add. |
| $8E |
STA (P3+d) |
2 |
9 |
C Z N |
Byte at P3 + d := A. A preserved; flags clobbered by the address add. |
ALU (operands A,B; result to A unless noted)
| Op |
Mnemonic |
Bytes |
Cycles |
Flags |
Description |
| $20 |
ADD |
1 |
2 |
C Z N |
A := A + B. |
| $21 |
SUB |
1 |
2 |
C Z N |
A := A - B. |
| $22 |
AND |
1 |
2 |
C Z N |
A := A AND B. |
| $23 |
OR |
1 |
2 |
C Z N |
A := A OR B. |
| $24 |
XOR |
1 |
2 |
C Z N |
A := A XOR B. |
| $25 |
CMP |
1 |
2 |
C Z N |
Flags from A - B; A unchanged. |
| $26 |
INC |
1 |
2 |
C Z N |
A := A + 1. (B not used.) |
| $27 |
DEC |
1 |
2 |
C Z N |
A := A - 1. (B not used.) |
| $28 |
SHL |
1 |
2 |
C Z N |
A := A << 1, 0 into bit 0. (1) |
| $29 |
SHR |
1 |
2 |
C Z N |
A := A >> 1, 0 into bit 7. (1) |
| $2A |
ROL |
1 |
2 |
C Z N |
Rotate A left through carry. |
| $2B |
ROR |
1 |
2 |
C Z N |
Rotate A right through carry. |
ALU with T (rev C; 2nd operand = T register via B-mux; B preserved)
| Op |
Mnemonic |
Bytes |
Cycles |
Flags |
Description |
| $80 |
ADDT |
1 |
2 |
C Z N |
A := A + T. (B preserved.) |
| $81 |
SUBT |
1 |
2 |
C Z N |
A := A - T. (B preserved.) |
| $82 |
ANDT |
1 |
2 |
C Z N |
A := A AND T. (B preserved.) |
| $83 |
ORT |
1 |
2 |
C Z N |
A := A OR T. (B preserved.) |
| $84 |
XORT |
1 |
2 |
C Z N |
A := A XOR T. (B preserved.) |
| $85 |
CMPT |
1 |
2 |
C Z N |
Flags from A - T; A and B unchanged. |
| $86 |
LDT #imm |
2 |
2 |
- |
T := immediate byte. (No flags.) |
| $87 |
LDT addr |
3 |
6 |
- |
T := byte at addr (absolute). (No flags.) |
Pointer registers
| Op |
Mnemonic |
Bytes |
Cycles |
Flags |
Description |
| $31 |
LPL1 #imm |
2 |
3 |
- |
Low byte of P1 := immediate. |
| $32 |
LPL2 #imm |
2 |
3 |
- |
Low byte of P2 := immediate. |
| $33 |
LPL3 #imm |
2 |
3 |
- |
Low byte of P3 := immediate. |
| $35 |
LPH1 #imm |
2 |
3 |
- |
High byte of P1 := immediate. |
| $36 |
LPH2 #imm |
2 |
3 |
- |
High byte of P2 := immediate. |
| $37 |
LPH3 #imm |
2 |
3 |
- |
High byte of P3 := immediate. |
| $54 |
INP1 |
1 |
2 |
- |
P1 := P1 + 1. |
| $55 |
INP2 |
1 |
2 |
- |
P2 := P2 + 1. |
| $56 |
INP3 |
1 |
2 |
- |
P3 := P3 + 1. |
| $58 |
DEP1 |
1 |
2 |
- |
P1 := P1 - 1. |
| $59 |
DEP2 |
1 |
2 |
- |
P2 := P2 - 1. |
| $5A |
DEP3 |
1 |
2 |
- |
P3 := P3 - 1. |
| $5E |
TAP1L |
1 |
2 |
- |
Low byte of P1 := A. |
| $5F |
TAP1H |
1 |
2 |
- |
High byte of P1 := A. |
| $60 |
TAP2L |
1 |
2 |
- |
Low byte of P2 := A. |
| $61 |
TAP2H |
1 |
2 |
- |
High byte of P2 := A. |
| $62 |
TAP3L |
1 |
2 |
- |
Low byte of P3 := A. |
| $63 |
TAP3H |
1 |
2 |
- |
High byte of P3 := A. |
| $68 |
TPA1L |
1 |
2 |
Z N |
A := low byte of P1. |
| $69 |
TPA1H |
1 |
2 |
Z N |
A := high byte of P1. |
| $6A |
TPA2L |
1 |
2 |
Z N |
A := low byte of P2. |
| $6B |
TPA2H |
1 |
2 |
Z N |
A := high byte of P2. |
| $6C |
TPA3L |
1 |
2 |
Z N |
A := low byte of P3. |
| $6D |
TPA3H |
1 |
2 |
Z N |
A := high byte of P3. |
Stack
| Op |
Mnemonic |
Bytes |
Cycles |
Flags |
Description |
| $70 |
PHA |
1 |
2 |
- |
Push A onto the P3 stack. |
| $71 |
PLA |
1 |
3 |
Z N |
Pop A from the P3 stack. |
16-bit memory ops (rev D; compiler space savers). PHW/PLW/LPW pure-microcode; MOVW adds the PT2 scratch pointer
| Op |
Mnemonic |
Bytes |
Cycles |
Flags |
Description |
| $74 |
PHW addr |
3 |
9 |
- |
Push the 16-bit word at addr onto the P3 stack: high byte first, then low, so the word lies LITTLE-ENDIAN at P3+1..P3+2 (readable with LDW a,(P3+d); same layout as a JSR return address). |
| $75 |
PLW addr |
3 |
10 |
- |
Pop a 16-bit word from the P3 stack into addr (low then high). |
| $76 |
LPW1 addr |
3 |
9 |
- |
P1 (16-bit) := the word at addr. |
| $77 |
LPW2 addr |
3 |
9 |
- |
P2 (16-bit) := the word at addr. |
| $78 |
MOVW dst,src |
5 |
13 |
- |
16-bit memory->memory move: the word at src -> dst (via the PT/PT2 scratch pointers). |
| $79 |
LPW3 addr |
3 |
9 |
- |
P3 (16-bit) := the word at addr -- restore a saved stack pointer. |
| $BD |
PHW (P1+d) |
2 |
10 |
C Z N |
Push the 16-bit word at P1 + d onto the P3 stack, high byte first (little-endian at the new P3+1) -- the C compiler's argument push straight from a frame slot. A! (the address add); flags from that add. |
| $BE |
PHW (P2+d) |
2 |
10 |
C Z N |
Push the 16-bit word at P2 + d onto the P3 stack, high byte first (little-endian at the new P3+1) -- the C compiler's argument push straight from a frame slot. A! (the address add); flags from that add. |
| $BF |
PHW (P3+d) |
2 |
10 |
C Z N |
Push the 16-bit word at P3 + d onto the P3 stack, high byte first (little-endian at the new P3+1) -- the C compiler's argument push straight from a frame slot; d is measured BEFORE the push. A! (the address add); flags from that add. |
Tier A: the C-compiler ISA (2026-09; pure microcode. A! = clobbers A; d = unsigned 8-bit displacement)
| Op |
Mnemonic |
Bytes |
Cycles |
Flags |
Description |
| $38 |
LDP1 #imm16 |
3 |
5 |
- |
P1 := imm16. A real 3-byte instruction (was the LPL1/LPH1 pseudo-op pair). |
| $39 |
LDP2 #imm16 |
3 |
5 |
- |
P2 := imm16. |
| $3A |
LDP3 #imm16 |
3 |
5 |
- |
P3 := imm16. |
| $3C |
ADDP3 #imm |
2 |
6 |
C Z N |
P3 := P3 + imm8 (free a stack frame). A!; flags are the low byte's. |
| $3D |
SUBP3 #imm |
2 |
6 |
C Z N |
P3 := P3 - imm8 (allocate a stack frame). A!; flags are the low byte's. |
| $90 |
LDW addr,(P1+d) |
4 |
14 |
C Z N |
Word at addr := the word at P1 + d (a frame local into a memory word). A! |
| $91 |
LDW addr,(P2+d) |
4 |
14 |
C Z N |
Word at addr := the word at P2 + d (a frame local into a memory word). A! |
| $92 |
LDW addr,(P3+d) |
4 |
14 |
C Z N |
Word at addr := the word at P3 + d (a frame local into a memory word). A! |
| $94 |
STW (P1+d),addr |
4 |
14 |
C Z N |
Word at P1 + d := the word at addr (a memory word into a frame local). A! |
| $95 |
STW (P2+d),addr |
4 |
14 |
C Z N |
Word at P2 + d := the word at addr (a memory word into a frame local). A! |
| $96 |
STW (P3+d),addr |
4 |
14 |
C Z N |
Word at P3 + d := the word at addr (a memory word into a frame local). A! |
| $98 |
LDW addr,#imm8 |
4 |
8 |
- |
Word at addr := imm8 zero-extended (4 bytes; the compiler's constant idiom). |
| $99 |
LDW addr,#imm16 |
5 |
9 |
- |
Word at addr := imm16 (5 bytes). |
| $9A |
ADDW dst,src |
5 |
15 |
C Z N V |
Word a := a + b (16-bit, carry chained). C = carry out; N/V from the high byte; Z from the HIGH byte only. A! |
| $9B |
SUBW dst,src |
5 |
15 |
C Z N V |
Word a := a - b (16-bit, borrow chained). C=1 means no borrow (unsigned a >= b). Z high byte only. A! |
| $9C |
CMPW dst,src |
5 |
15 |
C Z N V |
Flags from a - b (16-bit), memory unchanged: C = unsigned a >= b; BLT/BGE/BLE/BGT give the signed order. Z high byte only. A! |
| $9E |
INCW addr |
3 |
9 |
C Z N |
Word at addr := word + 1. A!; flags are the low byte's (C = carry out of it). |
| $9F |
DECW addr |
3 |
9 |
C Z N |
Word at addr := word - 1. A!; flags are the low byte's (C=1: no borrow). |
| $A0 |
ADDW addr,#imm8 |
4 |
15 |
C Z N V |
Word a := a + imm8 (zero-extended), 16-bit; C = carry out; Z of the FULL word. A! |
| $A1 |
SUBW addr,#imm8 |
4 |
15 |
C Z N V |
Word a := a - imm8; C=1 means no borrow (unsigned a >= imm); Z of the full word. A! |
| $A2 |
CMPW addr,#imm8 |
4 |
15 |
C Z N V |
Flags from a - imm8 (16-bit), memory unchanged: C = unsigned a >= imm; Z = (a == imm) over the full word; BLT/BGE signed. A! |
| $A4 |
LEAW addr,(P1+d) |
4 |
14 |
C Z N |
Word at addr := P1 + d -- the ADDRESS of a frame local (arrays, &x). A! |
| $A5 |
LEAW addr,(P2+d) |
4 |
14 |
C Z N |
Word at addr := P2 + d -- the ADDRESS of a frame local (arrays, &x). A! |
| $A6 |
LEAW addr,(P3+d) |
4 |
14 |
C Z N |
Word at addr := P3 + d -- the ADDRESS of a frame local (arrays, &x). A! |
| $B1 |
ADDW addr,#imm16 |
5 |
15 |
C Z N V |
Word a := a + imm16; C = carry out; Z of the full word. A! (5 bytes: x + &table.) |
| $B2 |
SUBW addr,#imm16 |
5 |
15 |
C Z N V |
Word a := a - imm16; C=1 means no borrow; Z of the full word. A! |
| $B3 |
CMPW addr,#imm16 |
5 |
15 |
C Z N V |
Flags from a - imm16, memory unchanged: C = unsigned a >= imm; Z = (a == imm); BLT/BGE/BLE/BGT signed. A! |
| $B4 |
ANDW dst,src |
5 |
15 |
Z N |
Word a := a AND b (16-bit). Z from the high byte only. A! |
| $B5 |
ORW dst,src |
5 |
15 |
Z N |
Word a := a OR b (16-bit). Z high byte only. A! |
| $B6 |
XORW dst,src |
5 |
15 |
Z N |
Word a := a XOR b (16-bit). Z high byte only. A! |
| $B7 |
ANDW addr,#imm8 |
4 |
15 |
Z N |
Word a := a AND imm8 -- the high byte is ANDed with 0, i.e. cleared (a mask is a mask). Z of the full word, so ANDW x,#1 ; JZ tests a bit of a word. A! |
| $B8 |
ORW addr,#imm8 |
4 |
15 |
Z N |
Word a := a OR imm8 (high byte kept). Z of the full word. A! |
| $B9 |
XORW addr,#imm8 |
4 |
15 |
Z N |
Word a := a XOR imm8 (high byte kept). Z of the full word. A! |
| $BA |
ANDW addr,#imm16 |
5 |
15 |
Z N |
Word a := a AND imm16. Z of the full word. A! |
| $BB |
ORW addr,#imm16 |
5 |
15 |
Z N |
Word a := a OR imm16. Z of the full word. A! |
| $BC |
XORW addr,#imm16 |
5 |
15 |
Z N |
Word a := a XOR imm16; #$FFFF is a 16-bit bitwise NOT (the compiler's ~x, and -x with INCW). Z of the full word. A! |
Control flow
| Op |
Mnemonic |
Bytes |
Cycles |
Flags |
Description |
| $40 |
JMP addr |
3 |
4 |
- |
P0 (PC) := addr. |
| $41 |
JSR (P1) |
1 |
9 |
- |
Push return address (high byte first) onto P3 stack, then P0 := P1. Target must already be in P1. |
| $42 |
RTS |
1 |
6 |
- |
Pop return address from P3 stack into P0. |
| $43 |
JSR addr |
3 |
10 |
- |
Push return address, then P0 := addr (absolute call). |
| $48 |
BZ addr |
3 |
4 |
- |
Branch to addr if Z=1. |
| $49 |
BNZ addr |
3 |
4 |
- |
Branch to addr if Z=0. |
| $4A |
BCP addr |
3 |
4 |
- |
Branch if C=1, i.e. the RAW 74181 Cn+4 pin is high. Pin high means NO carry out - see note (2). |
| $4C |
JNC addr |
3 |
4 |
- |
Branch to addr if C=0. (JC/JZ/JNZ are aliases of BCP/BZ/BNZ.) |
| $A8 |
JMP rel8 |
2 |
9 |
C Z N V |
P0 := P0 + rel8 (signed, from the next instruction). 2 bytes; A! flags!; via .relax or JMP.R. An always-taken jump is cheaper as the 3-step absolute JMP (JMP.A), which the compiler emits. |
| $A9 |
BZ rel8 |
2 |
9 |
C Z N V |
Branch rel8 if Z=1. Taken: A and the flags are clobbered (8 steps); not taken: 2 steps, nothing changes. (JZ.R alias.) |
| $AA |
BNZ rel8 |
2 |
9 |
C Z N V |
Branch rel8 if Z=0. Taken: A and the flags are clobbered (8 steps); not taken: 2 steps, nothing changes. (JNZ.R alias.) |
| $AB |
BCP rel8 |
2 |
9 |
C Z N V |
Branch rel8 if C=1. Taken: A and the flags are clobbered (8 steps); not taken: 2 steps, nothing changes. (JC.R alias.) |
| $AC |
JNC rel8 |
2 |
9 |
C Z N V |
Branch rel8 if C=0. Taken: A and the flags are clobbered (8 steps); not taken: 2 steps, nothing changes. |
Signed branches (rev C; after CMP — N^V/Z)
| Op |
Mnemonic |
Bytes |
Cycles |
Flags |
Description |
| $44 |
BLT addr |
3 |
4 |
- |
Branch if signed A < B (N^V=1). Use after CMP. |
| $45 |
BGE addr |
3 |
4 |
- |
Branch if signed A >= B (N^V=0). Use after CMP. |
| $46 |
BLE addr |
3 |
4 |
- |
Branch if signed A <= B ((N^V)|Z). Use after CMP. |
| $47 |
BGT addr |
3 |
4 |
- |
Branch if signed A > B (not (N^V)|Z). Use after CMP. |
| $AD |
BLT rel8 |
2 |
9 |
C Z N V |
Branch rel8 if signed A < B (N^V=1). Use after CMP. Taken: A and the flags are clobbered (8 steps); not taken: 2 steps, nothing changes. |
| $AE |
BGE rel8 |
2 |
9 |
C Z N V |
Branch rel8 if signed A >= B (N^V=0). Taken: A and the flags are clobbered (8 steps); not taken: 2 steps, nothing changes. |
| $AF |
BLE rel8 |
2 |
9 |
C Z N V |
Branch rel8 if signed A <= B ((N^V)|Z). Taken: A and the flags are clobbered (8 steps); not taken: 2 steps, nothing changes. |
| $B0 |
BGT rel8 |
2 |
9 |
C Z N V |
Branch rel8 if signed A > B. Taken: A and the flags are clobbered (8 steps); not taken: 2 steps, nothing changes. |
Notes
- Shifts & rotates: SHL/SHR shift in 0 and latch the shifted-out bit into C. ROL/ROR rotate through C (the shifted-in bit is the current C). This makes multi-byte shifts work the conventional way (SHL low byte, then ROL high byte).
- C flag (rev B): CONVENTIONAL active-high carry. After ADD, C=1 means a carry occurred; after SUB/CMP, C=1 means no borrow (A >= B). JC/BCP branch on C=1, JNC on C=0. CLC/SEC clear/set C without disturbing Z/N/V.
- Stack: JSR pushes the return address high byte then low byte, decrementing after each write (empty-descending). RTS increments then reads. Software must initialise P3 (e.g.
LDP3 #$FEFF) before the first JSR.
- V flag: reads 0 in rev A.
- Absolute addressing: LDA/LDB/STA/JSR accept an absolute address; the hardware forms it in the hidden PT scratch pointer.
Assembler quick reference (p8xasm.py)
label: MNEMONIC operand ; comment
operands: #expr (immediate) | (Pn) / (Pn)+ | expr (16-bit address)
exprs: $1F 0x1F 31 'c' symbol with +/-, <expr = low byte, >expr = high
directives: .org e .byte e,... .word e,... .ascii "s" .asciiz "s"
.fill n[,v] NAME = expr .equ NAME, expr
pseudo-op: LDPn #imm16 ; expands to LPLn #<imm, LPHn #>imm
usage: python3 p8xasm.py prog.asm -o eeprom.bin [-l prog.lst]
Example: print a string
ACIA_D = $FF05
.org 0
LDP2 #ACIA_D ; P2 -> ACIA data register
LDP1 #msg ; P1 -> string
LDB #0
loop: LDA (P1)+ ; fetch byte, advance
OR ; A := A|0 - sets Z on the terminator
BZ done
STA (P2) ; transmit
JMP loop
done: HLT
msg: .asciiz "P8X lives!\r\n"
Writing a program for P8X/OS
Programs launched by the OS RUN command load into the transient program area at $5900 and run via a JSR to their exec address. The program ABI: return to the shell with RTS (P3, the stack, is the OS's — leave it balanced); on entry P2 points at the argument tail — the command text after the program name, NUL-terminated (so RUN EDIT FOO.ASM enters with P2 -> "FOO.ASM"); programs that take no arguments ignore P2. Build with .org $5900 and the host assembler's --base 0x5900, or assemble on-target with ASM. A file created on-target carries load/exec 0, which the OS maps to $5900, so it is directly RUNnable. The BIOS jump table at $0100 (console + CF, the FFIND/FCREATE/FDELETE/FCOMMIT file calls, the FOPEN/FGETB and FWOPEN/FPUTB/FCLOSE byte streams, and FRESOLVE/FNORM/FOPENDIR/FNEXT) is the only entry point a program needs — it must not call into OS internals.