| git.druid.rocks | index | druid520 | jury | README |
README
jury - a shitty cpu emulator
whats this? jury is a tiny toy cpu i made up, plus an assembler and a
disassembler for it. it used to be 16 bytes of ram and two registers,
opcodes packed a nibble at a time. it outgrew that. its still not
trying to be useful, but it stopped being small enough to hold in your
head in one sitting a while ago - now its just small enough to still
fit in one sitting if you read slow.
three binaries come out of the repo:
jury runs a compiled program
asm assembles jury asm into a raw binary
disasm turns a raw binary back into asm
all three are built off one shared file, src/isa.h and src/isa.c, that
defines every opcode once. jury, asm, and disasm all decode/encode
through it, so they cant quietly disagree with each other about what a
byte means.
build with mk (see druid520/mk):
mk . b
mk . c (clean)
usage:
asm prog.s prog.bin (both args optional, default src.s -> prog.bin,
"-" as prog.s reads source from stdin. also
writes prog.bin.vars if you declared any
variables, see below)
jury prog.bin runs straight through, no per-step output
jury prog.bin -r runs straight through, tracing every step
jury prog.bin -d single steps, full state dump, press enter each time
disasm prog.bin (arg optional, default prog.bin, "-" for stdin,
reads prog.bin.vars if its there)
the machine: 32768 bytes of ram (0x0000-0x7fff), addresses are 16 bits.
five general purpose registers, r0-r4 - theres no hardwired
accumulator anymore, every op that touches a register says which one.
plus sp (stack pointer, 16 bit, starts at 0x8000 meaning "empty"), pc
(program counter, 16 bit, halts if it runs past the end of ram), and
flags (z, c, n).
the stack is the top 256 bytes of ram, 0x7f00-0x7fff. it grows down:
push decrements sp then writes, pop reads then increments. push past
the bottom halts with "stack overflow.", pop past empty halts with
"stack underflow.". call/ret use it too, for a 2 byte return address.
instructions are 1 to 3 bytes, variable length, decided entirely by
what the opcode is. an op byte is always high nibble opcode, low
nibble something op-specific; what follows it depends on the ops
"format":
format low nibble means then example
none unused (write 0) nothing HALT
r a register 0-4 nothing PUSH r0
ir a register 0-4 1 byte immediate LDI 5, r0
ar a register 0-4 2 byte address LD 0x7000, r0
ra a register 0-4 2 byte address ST r0, 0x7000
ca a jump condition 2 byte address JZ 0x0100
a unused (write 0) 2 byte address CALL 0x0040
p unused (write 0) 1 byte, hi:lo pair JMPR r1:r2
pr a register 0-4 1 byte, hi:lo pair LDR r1:r2, r0
rp a register 0-4 1 byte, hi:lo pair STR r0, r1:r2
rr unused (write 0) 1 byte, dest:src pair MOV r1, r0
mode which table, 1-4 (a whole other op) -
anything with two operands is written source first, destination last,
at&t style: "MOV r1, r0" is r0 = r1, "ADDR r1, r0" is r0 = r0 + r1, "LD
0x7000, r0" is r0 = ram[0x7000], "ST r0, 0x7000" is ram[0x7000] = r0.
the thing that gets written to is always the last thing you wrote.
compares (CMP/CMPR/TST) dont write anything, they just sit in the same
order as the op they fake (SUB/SUBR/AND), same as at&t cmp does. it
used to be dest first for some ops and not others, which was exactly
as confusing as it sounds.
ar/ra and pr/rp are the same bytes, just written in opposite orders -
LD and ST (and LDR and STR) share a layout but not a direction, so they
get a format each instead of a special case. rr is written "src, dst"
but the byte stores it dest:src, high nibble dest, because that was
already the encoding and changing how you type it didnt need to change
a single byte of any existing binary.
addresses are 2 bytes, big endian, and have to actually be inside ram
or asm refuses to assemble them.
a register pair (the "p"/"pr"/"rp"/"rr" formats) is one byte, a high
nibble and a low nibble, each picking one of the 5 registers by
number. mode 1 uses a pair as a 16 bit address (hi register is the
high byte, lo register is the low byte) so you can point anywhere in
all 32k without needing a wide immediate. to build an address into a pair, load each
half separately with < and > on a label or number, e.g:
LDI >target, r3 # r3 = high byte of target's address
LDI <target, r4 # r4 = low byte
JMPR r3:r4 # pc = r3:r4
< and > only work on 1 byte immediate operands (ldi and friends), not
on the wide 2 byte address operands - those either fit or they dont.
normal opcodes, always available:
hex asm does
00 HALT stop
1r LDI imm8, r r = imm8
2r ADD imm8, r r = r + imm8
3r SUB imm8, r r = r - imm8
4r AND imm8, r r = r & imm8
5r OR imm8, r r = r | imm8
6r XOR imm8, r r = r ^ imm8
7r CMP imm8, r flags off r - imm8, r untouched
8c Jcc addr pc = addr if condition c holds (see below)
9r LD addr, r r = ram[addr]
Ar ST r, addr ram[addr] = r
Bx CALL addr push pc, pc = addr
Cm MODE m next byte is an extended op, mode m (1-4)
Dr PUSH r push r
Er POP r pop into r
Fx RET pop pc
jump conditions (the low nibble of Jcc): 0 JMP (always), 1 JZ, 2 JNZ,
3 JC, 4 JNC, 5 JN, 6 JNN. the mnemonic is the whole thing, "JZ addr",
not "J 1, addr".
MODE doesnt do anything by itself, same as always: it just says "the
next byte is an extended op, look it up in table m". you never write
MODE yourself though - asm sees you wrote an extended mnemonic and
puts the right MODE byte in front of it for you. mode reverts to
normal right after one op, so nothing sticks.
mode 1, addressing through a register pair - lets you touch any of the
32k without a wide immediate:
LDR hi:lo, r r = ram[hi:lo]
STR r, hi:lo ram[hi:lo] = r
JMPR hi:lo pc = hi:lo
CALLR hi:lo push pc, pc = hi:lo
INCP hi:lo hi:lo = hi:lo + 1, as one 16 bit value
DECP hi:lo hi:lo = hi:lo - 1, as one 16 bit value
mode 2, ops on a single named register:
SHL r r <<= 1, c = old bit 7
SHR r r >>= 1, c = old bit 0
ROL r r <<= 1 through carry, c = old bit 7
ROR r r >>= 1 through carry, c = old bit 0
NOT r r = ~r
NEG r r = 0 - r
INC r r = r + 1
DEC r r = r - 1
OUT r print r as hex, newline
OUTC r print r as a raw character, no newline
mode 3, register/register, source first, dest last:
MOV s, d d = s
SWAP s, d s and d trade places (order doesnt mean anything here,
its just rr like the rest)
ADDR s, d d = d + s
ADCR s, d d = d + s + carry
SUBR s, d d = d - s
SBCR s, d d = d - s - carry
CMPR s, d flags off d - s, d untouched
ANDR s, d d = d & s
ORR s, d d = d | s
XORR s, d d = d ^ s
mode 4, more register/immediate, the ones that needed carry:
ADC imm8, r r = r + imm8 + carry
SBC imm8, r r = r - imm8 - carry
TST imm8, r flags off r & imm8, r untouched
flags: ADD/SUB/ADC/SBC (and their register-register forms ADDR/SUBR/
ADCR/SBCR), plus INC/DEC/NEG, set z, c, and n. AND/OR/XOR/NOT (and
ANDR/ORR/XORR) set z and n only, carry untouched. SHL/SHR/ROL/ROR set
z and n too, plus c to whichever bit got shifted out. INCP/DECP set z,
c, n off the 16 bit pair result. CMP/CMPR/TST wipe and rebuild all
three flags off a throwaway subtraction/and and leave the register(s)
alone. everything else (loads, stores, jumps, stack, MOV, SWAP, OUT,
OUTC) leaves flags exactly as they were.
asm syntax: # starts a comment, runs to end of line. a word ending in
: defines a label at the current byte offset - forward references are
fine, labels resolve after the whole file's read. a mnemonic takes
whatever operands its format wants: a register is r0-r4, a value is
decimal, 0x-hex, or a label (optionally <label / >label for the low or
high byte, on 1 byte operands only), a register pair is "rH:rL". two
hex digits with no mnemonic in front is a raw byte, for when you dont
want to spell something out. dont write MODE yourself, asm adds it.
a word ending in ; instead of : is a variable. its a label like any
other (jump to it, <name/>name it, whatever) but the rest of the line
is its bytes, and asm remembers where it starts and how long it is. a
line starting with a lone ; carries on the variable right before it,
for when one line isnt enough:
msg; 98 111 0x62 10 # "bob\n"
; 0 # and the 0 on the end
a variable's bytes are values, same as any byte operand: decimal,
0x-hex, or <label/>label. NOT the bare two hex digit raw byte thing -
"62" under a ; is sixty two, under a : its 0x62. thats on purpose.
"62" is a perfectly good number in both bases and theres no guessing
which one you meant, so in a variable hex has to say 0x. the old raw
byte lines still work exactly like before, they just dont mix in.
a variable doesnt change the binary at all. msg; above assembles to
the exact same 62 6f 62 0a 00 you'd get from "msg: 62 6f 62 0a 00",
and jury has no idea variables exist. asm just also writes
prog.bin.vars next to prog.bin (whatever you named it, plus .vars),
one line per variable:
msg 001a 0005
name, start address, length, both in hex. no variables, no .vars file
(and an old one left over from a previous build gets deleted, so it
cant go describing the wrong binary).
disasm prints addr, bytes, then valid asm source for that instruction
- a mode prefix and the op it selects show up as one line, the
resolved mnemonic, same as if youd have typed it. a byte that isnt a
valid instruction (or the tail of one, if theres not enough ram left
to finish decoding it) comes out as that one raw byte plus a comment
saying why, and disasm carries on from the next byte - so disasm
output always reassembles back to the exact same binary, byte for
byte, even for programs with garbage in them:
0000: 10 03 LDI 0x03, r0
0002: c3 00 10 MOV r0, r1
if prog.bin.vars is there, disasm doesnt decode variables as code, it
prints them as data, 4 bytes a line, the asm column carrying on the
variable with ; and a comment showing the bytes as characters, od -c
style. no instruction gets decoded across the start of a variable
either. no .vars file (reading stdin, an old binary, you deleted it)
and you get the plain decode, same as it always was:
001a: msg; # 5 byte(s)
001a: 62 6f 62 0a ; 0x62 0x6f 0x62 0x0a # b o b \n
001e: 00 ; 0x00 # \0
that still reassembles, into the same binary and the same .vars.
adds 3 and 4, prints the result, halts:
LDI 3, r0
MOV r0, r1
LDI 4, r0
ADDR r1, r0
OUT r0
HALT
assembles to 10 03 c3 00 10 10 04 c3 20 01 c2 80 00, runs, prints 07,
halts "ok.".
a second one, to actually use the address space - store a byte at
0x7000, which is well past where the entire 16-byte machine used to
end, and read it back:
LDI 0x42, r0
ST r0, 0x7000
LDI 0x00, r0
LD 0x7000, r0
OUT r0
HALT
assembles to 10 42 a0 70 00 10 00 90 70 00 c2 80 00, runs, prints 42,
halts "ok.".
when it halts, the reason printed is one of: "ok." (hit HALT), "pc out
of range." (ran off the end of ram, or a multi-byte op didnt have
enough ram left to finish, without hitting HALT first), "bad address."
(an op computed an address outside 0-0x7fff), "stack overflow."/"stack
underflow.", "bad opcode." (unknown op, or an extended op your mode
table doesnt define), "bad mode." (MODE with anything other than
1-4), "bad register." (a nibble that should be r0-r4 was 5-15 instead
- a nibble holds 0-15, theres just only 5 registers to name with it).
why does this exist. it doesnt need to. the point was writing a whole
tiny stack, vm, assembler, disassembler, in as little cleverness as
possible. if you want an actual usable toy isa go look at chip-8 or
the little man computer, this is dumber than both on purpose - it just
got a bigger address space and two more fingers to count on.