velxio/scripts/asm8080.py

257 lines
8.9 KiB
Python

"""Tiny two-pass 8080 assembler.
Just enough to assemble the bundled-chip ROM monitors that ship with
Velxio's i8080-repl / i8080-counter custom chips. Not a full assembler:
no macros, no expressions, no LO/HI operators. Plain labels and
immediates only. Outputs a Python literal byte list which we paste
straight into the .c chip ROM table.
Usage: python asm8080.py <input.s> > rom.txt
"""
from __future__ import annotations
import re
import sys
# Tables driven by Intel opcode prefixes. Each entry maps to (size, encoder).
REG = {'B': 0, 'C': 1, 'D': 2, 'E': 3, 'H': 4, 'L': 5, 'M': 6, 'A': 7}
RP = {'B': 0, 'D': 1, 'H': 2, 'SP': 3, 'PSW': 3} # PSW only valid for PUSH/POP
def _atom(tok: str, labels: dict[str, int]) -> int:
if tok.startswith("'") and tok.endswith("'") and len(tok) == 3:
return ord(tok[1])
if tok in labels:
return labels[tok]
if tok.startswith('0x') or tok.startswith('0X'):
return int(tok, 16)
if tok.endswith('h') or tok.endswith('H'):
return int(tok[:-1], 16)
if tok.lstrip('-').isdigit():
return int(tok)
raise ValueError(f"can't parse atom {tok!r}")
def parse_imm(tok: str, labels: dict[str, int]) -> int:
tok = tok.strip().rstrip(',').strip()
# Support simple addition/subtraction of two atoms (e.g. "'Z'+1", "'A'-10").
# Don't split inside a char literal.
for op_char, sign in [('+', 1), ('-', -1)]:
# Find op outside char/string literal.
in_q = False
for i, ch in enumerate(tok):
if ch == "'":
in_q = not in_q
elif ch == op_char and not in_q and i > 0:
left = tok[:i].strip()
right = tok[i + 1:].strip()
return _atom(left, labels) + sign * _atom(right, labels)
return _atom(tok, labels)
def imm16(val: int) -> tuple[int, int]:
val &= 0xFFFF
return (val & 0xFF, (val >> 8) & 0xFF)
def assemble(src: str) -> bytes:
# Strip comments + split into (label, mnemonic, args) tuples.
raw_lines = []
for ln in src.splitlines():
ln = ln.split(';', 1)[0].rstrip()
if not ln.strip():
continue
label = None
# Find the first ':' that's outside a string literal.
in_str = False
colon = -1
for i, ch in enumerate(ln):
if ch == '"':
in_str = not in_str
elif ch == ':' and not in_str:
colon = i; break
if colon >= 0:
label = ln[:colon].strip()
ln = ln[colon + 1:]
ln = ln.strip()
if not ln:
raw_lines.append((label, None, []))
continue
m = re.match(r'\s*(\S+)\s*(.*)$', ln)
mnem = m.group(1).upper()
args_raw = m.group(2).strip()
args = [a.strip() for a in args_raw.split(',')] if args_raw else []
raw_lines.append((label, mnem, args))
# Pass 1: size and labels (with a fake label table; immediates that look
# like labels get resolved in pass 2).
sizes = []
labels: dict[str, int] = {}
pc = 0
for (label, mnem, args) in raw_lines:
if label:
labels[label] = pc
if mnem is None:
sizes.append(0); continue
if mnem == 'ORG':
new = parse_imm(args[0], labels)
# pad to new pc — but our ROM starts at 0 and ORG is only used
# before any code, so this is a hard set, not a pad.
pc = new
sizes.append(0)
continue
if mnem == 'DB':
n = 0
for a in args:
if a.startswith('"'):
n += len(bytes(a[1:-1], 'utf-8').decode('unicode_escape'))
else:
n += 1
sizes.append(n)
pc += n
continue
if mnem == 'DW':
sizes.append(2 * len(args)); pc += 2 * len(args); continue
size = INSTR_SIZE.get(mnem)
if size is None:
raise ValueError(f"unknown mnemonic {mnem}")
sizes.append(size); pc += size
# Pass 2: emit.
out = bytearray()
out_pc = 0
# Track an explicit ORG pad
org_pad_target: int | None = None
for (idx, (label, mnem, args)) in enumerate(raw_lines):
if mnem == 'ORG':
tgt = parse_imm(args[0], labels)
if tgt < out_pc:
raise ValueError(f"ORG cannot move backwards (at {out_pc} -> {tgt})")
while out_pc < tgt:
out.append(0x00); out_pc += 1
continue
if mnem is None:
continue
if mnem == 'DB':
for a in args:
if a.startswith('"'):
raw_bytes = bytes(a[1:-1], 'utf-8').decode('unicode_escape').encode('latin1')
out.extend(raw_bytes); out_pc += len(raw_bytes)
else:
v = parse_imm(a, labels) & 0xFF
out.append(v); out_pc += 1
continue
if mnem == 'DW':
for a in args:
lo, hi = imm16(parse_imm(a, labels))
out.append(lo); out.append(hi); out_pc += 2
continue
emit = INSTR_ENCODE[mnem]
bs = emit(args, labels)
out.extend(bs); out_pc += len(bs)
return bytes(out)
# ── Encoders for the small subset of instructions our ROMs use ──────────────
def e_simple(opc): return lambda a, l: bytes([opc])
def e_imm(opc): return lambda a, l: bytes([opc, parse_imm(a[0], l) & 0xFF])
def e_addr(opc): return lambda a, l: bytes([opc, *imm16(parse_imm(a[0], l))])
def e_mov(a, l):
d = REG[a[0].upper()]; s = REG[a[1].upper()]
return bytes([0x40 | (d << 3) | s])
def e_mvi(a, l):
d = REG[a[0].upper()]
return bytes([0x06 | (d << 3), parse_imm(a[1], l) & 0xFF])
def e_lxi(a, l):
rp = RP[a[0].upper()]
lo, hi = imm16(parse_imm(a[1], l))
return bytes([0x01 | (rp << 4), lo, hi])
def e_alu_r(base):
# ADD/SUB/etc r form
return lambda a, l: bytes([base | REG[a[0].upper()]])
def e_inr(a, l): return bytes([0x04 | (REG[a[0].upper()] << 3)])
def e_dcr(a, l): return bytes([0x05 | (REG[a[0].upper()] << 3)])
def e_push(a, l): return bytes([0xC5 | (RP[a[0].upper()] << 4)])
def e_pop(a, l): return bytes([0xC1 | (RP[a[0].upper()] << 4)])
def e_inx(a, l): return bytes([0x03 | (RP[a[0].upper()] << 4)])
def e_dcx(a, l): return bytes([0x0B | (RP[a[0].upper()] << 4)])
def e_dad(a, l): return bytes([0x09 | (RP[a[0].upper()] << 4)])
INSTR_SIZE = {}
INSTR_ENCODE = {}
def _reg(name, size, fn):
INSTR_SIZE[name] = size
INSTR_ENCODE[name] = fn
# 1-byte simple
for n, opc in [('NOP', 0x00), ('HLT', 0x76), ('RET', 0xC9), ('XCHG', 0xEB),
('XTHL', 0xE3), ('SPHL', 0xF9), ('PCHL', 0xE9), ('EI', 0xFB),
('DI', 0xF3), ('CMA', 0x2F), ('STC', 0x37), ('CMC', 0x3F),
('RLC', 0x07), ('RRC', 0x0F), ('RAL', 0x17), ('RAR', 0x1F),
('DAA', 0x27), ('RNZ', 0xC0), ('RZ', 0xC8), ('RNC', 0xD0),
('RC', 0xD8), ('RPO', 0xE0), ('RPE', 0xE8), ('RP', 0xF0),
('RM', 0xF8)]:
_reg(n, 1, e_simple(opc))
# 2-byte immediate
for n, opc in [('ADI', 0xC6), ('ACI', 0xCE), ('SUI', 0xD6), ('SBI', 0xDE),
('ANI', 0xE6), ('XRI', 0xEE), ('ORI', 0xF6), ('CPI', 0xFE),
('IN', 0xDB), ('OUT', 0xD3)]:
_reg(n, 2, e_imm(opc))
# 3-byte address
for n, opc in [('JMP', 0xC3), ('JNZ', 0xC2), ('JZ', 0xCA), ('JNC', 0xD2),
('JC', 0xDA), ('JPO', 0xE2), ('JPE', 0xEA), ('JP', 0xF2),
('JM', 0xFA), ('CALL', 0xCD), ('CNZ', 0xC4), ('CZ', 0xCC),
('CNC', 0xD4), ('CC', 0xDC), ('CPO', 0xE4), ('CPE', 0xEC),
('CP', 0xF4), ('CM', 0xFC), ('LDA', 0x3A), ('STA', 0x32),
('LHLD', 0x2A), ('SHLD', 0x22)]:
_reg(n, 3, e_addr(opc))
# MOV/MVI/LXI
_reg('MOV', 1, e_mov)
_reg('MVI', 2, e_mvi)
_reg('LXI', 3, e_lxi)
# ALU r-form (8 variants)
for n, base in [('ADD', 0x80), ('ADC', 0x88), ('SUB', 0x90), ('SBB', 0x98),
('ANA', 0xA0), ('XRA', 0xA8), ('ORA', 0xB0), ('CMP', 0xB8)]:
_reg(n, 1, e_alu_r(base))
_reg('INR', 1, e_inr); _reg('DCR', 1, e_dcr)
_reg('PUSH', 1, e_push); _reg('POP', 1, e_pop)
_reg('INX', 1, e_inx); _reg('DCX', 1, e_dcx); _reg('DAD', 1, e_dad)
# LDAX/STAX (rp = B or D only)
def e_ldax(a, l):
return bytes([{'B':0x0A,'D':0x1A}[a[0].upper()]])
def e_stax(a, l):
return bytes([{'B':0x02,'D':0x12}[a[0].upper()]])
_reg('LDAX', 1, e_ldax); _reg('STAX', 1, e_stax)
if __name__ == '__main__':
if len(sys.argv) < 2:
print("usage: asm8080.py <input.s>", file=sys.stderr); sys.exit(2)
with open(sys.argv[1], 'r', encoding='utf-8') as f:
src = f.read()
rom = assemble(src)
# Emit as a C byte array
print(f"// {len(rom)} bytes")
cols = 12
print("static const uint8_t ROM[] = {")
for i in range(0, len(rom), cols):
chunk = ', '.join(f'0x{b:02x}' for b in rom[i:i+cols])
print(f" {chunk},")
print("};")