This commit is contained in:
root
2026-09-19 20:21:47 +02:00
commit 0798933b05
62 changed files with 7658 additions and 0 deletions
+305
View File
@@ -0,0 +1,305 @@
package vm
import (
"encoding/binary"
"fmt"
"strconv"
"strings"
)
var mnemonics = map[string]Op{
"nop": NOP, "yield": YIELD, "halt": HALT, "ldi": LDI, "lui": LUI, "mov": MOV,
"add": ADD, "sub": SUB, "mul": MUL, "div": DIV, "mod": MOD, "and": AND,
"or": OR, "xor": XOR, "shl": SHL, "shr": SHR, "sar": SAR, "addi": ADDI,
"jmp": JMP, "beq": BEQ, "bne": BNE, "blt": BLT, "bge": BGE, "call": CALL,
"ret": RET, "push": PUSH, "pop": POP, "ldb": LDB, "ldh": LDH, "ldw": LDW,
"stb": STB, "sth": STH, "stw": STW, "in": IN, "out": OUT,
}
// Assemble converts assembly text into a program. Syntax:
//
// label: define a label
// .equ NAME value define a constant
// li rd, value pseudo-op: load a 32-bit constant (always 2 instructions)
// ldw rd, [rb+off] memory access (also ldb/ldh/stb/sth/stw)
// in rd, port | out port, rs
// beq ra, rb, label branches and jmp/call take labels
//
// Registers are r0..r15 (sp = r15). Comments start with ';' or '#'.
func Assemble(src string) ([]byte, error) {
type line struct {
no int
text string
}
var lines []line
consts := map[string]int64{}
labels := map[string]int{}
n := 0 // instruction count
for i, raw := range strings.Split(src, "\n") {
t := raw
if j := strings.IndexAny(t, ";#"); j >= 0 {
t = t[:j]
}
t = strings.TrimSpace(t)
for {
j := strings.Index(t, ":")
if j < 0 || strings.ContainsAny(t[:j], " \t,[") {
break
}
labels[t[:j]] = n
t = strings.TrimSpace(t[j+1:])
}
if t == "" {
continue
}
f := strings.Fields(t)
f[0] = strings.ToLower(f[0])
if f[0] == ".equ" {
if len(f) != 3 {
return nil, fmt.Errorf("line %d: .equ NAME value", i+1)
}
v, err := parseNum(f[2], consts)
if err != nil {
return nil, fmt.Errorf("line %d: %v", i+1, err)
}
consts[f[1]] = v
continue
}
if f[0] == "li" {
n += 2
} else {
n++
}
lines = append(lines, line{i + 1, t})
}
var out []byte
emit := func(op Op, ra, rb int, imm int32) {
out = binary.LittleEndian.AppendUint32(out, Encode(op, ra, rb, imm))
}
for _, l := range lines {
name, rest, _ := strings.Cut(l.text, " ")
name = strings.ToLower(name)
args := splitArgs(rest)
err := func() error {
pc := len(out) / 4
if name == "li" {
if len(args) != 2 {
return fmt.Errorf("li rd, value")
}
rd, err := parseReg(args[0])
if err != nil {
return err
}
v, err := parseNum(args[1], consts)
if err != nil {
return err
}
emit(LDI, rd, 0, int32(int16(uint32(v))))
emit(LUI, rd, 0, int32(int16(uint32(v)>>16)))
return nil
}
op, ok := mnemonics[name]
if !ok {
return fmt.Errorf("unknown mnemonic %q", name)
}
target := func(s string) (int32, error) {
if a, ok := labels[s]; ok {
return int32(a - (pc + 1)), nil
}
v, err := parseNum(s, consts)
return int32(v), err
}
need := func(k int) error {
if len(args) != k {
return fmt.Errorf("%s takes %d operands", name, k)
}
return nil
}
switch op {
case NOP, YIELD, HALT, RET:
if err := need(0); err != nil {
return err
}
emit(op, 0, 0, 0)
case LDI, LUI, ADDI:
if err := need(2); err != nil {
return err
}
ra, err := parseReg(args[0])
if err != nil {
return err
}
v, err := parseNum(args[1], consts)
if err != nil {
return err
}
if v < -32768 || v > 65535 {
return fmt.Errorf("immediate %d out of 16-bit range (use li)", v)
}
emit(op, ra, 0, int32(int16(v)))
case MOV, ADD, SUB, MUL, DIV, MOD, AND, OR, XOR, SHL, SHR, SAR:
if err := need(2); err != nil {
return err
}
ra, err := parseReg(args[0])
if err != nil {
return err
}
rb, err := parseReg(args[1])
if err != nil {
return err
}
emit(op, ra, rb, 0)
case JMP, CALL:
if err := need(1); err != nil {
return err
}
t, err := target(args[0])
if err != nil {
return err
}
emit(op, 0, 0, t)
case BEQ, BNE, BLT, BGE:
if err := need(3); err != nil {
return err
}
ra, err := parseReg(args[0])
if err != nil {
return err
}
rb, err := parseReg(args[1])
if err != nil {
return err
}
t, err := target(args[2])
if err != nil {
return err
}
emit(op, ra, rb, t)
case PUSH, POP:
if err := need(1); err != nil {
return err
}
ra, err := parseReg(args[0])
if err != nil {
return err
}
emit(op, ra, 0, 0)
case LDB, LDH, LDW, STB, STH, STW:
if err := need(2); err != nil {
return err
}
ra, err := parseReg(args[0])
if err != nil {
return err
}
rb, off, err := parseMem(args[1], consts)
if err != nil {
return err
}
emit(op, ra, rb, off)
case IN:
if err := need(2); err != nil {
return err
}
ra, err := parseReg(args[0])
if err != nil {
return err
}
p, err := parseNum(args[1], consts)
if err != nil {
return err
}
emit(op, ra, 0, int32(int16(p)))
case OUT:
if err := need(2); err != nil {
return err
}
p, err := parseNum(args[0], consts)
if err != nil {
return err
}
ra, err := parseReg(args[1])
if err != nil {
return err
}
emit(op, ra, 0, int32(int16(p)))
}
return nil
}()
if err != nil {
return nil, fmt.Errorf("line %d: %v", l.no, err)
}
}
return out, nil
}
func splitArgs(s string) []string {
s = strings.TrimSpace(s)
if s == "" {
return nil
}
parts := strings.Split(s, ",")
for i := range parts {
parts[i] = strings.TrimSpace(parts[i])
}
return parts
}
func parseReg(s string) (int, error) {
s = strings.ToLower(s)
if s == "sp" {
return SP, nil
}
if strings.HasPrefix(s, "r") {
if n, err := strconv.Atoi(s[1:]); err == nil && n >= 0 && n < NumRegs {
return n, nil
}
}
return 0, fmt.Errorf("bad register %q", s)
}
func parseNum(s string, consts map[string]int64) (int64, error) {
if v, ok := consts[s]; ok {
return v, nil
}
v, err := strconv.ParseInt(s, 0, 64)
if err != nil {
return 0, fmt.Errorf("bad number or unknown name %q", s)
}
return v, nil
}
// parseMem parses "[rb]" or "[rb+off]" / "[rb-off]".
func parseMem(s string, consts map[string]int64) (int, int32, error) {
if !strings.HasPrefix(s, "[") || !strings.HasSuffix(s, "]") {
return 0, 0, fmt.Errorf("bad memory operand %q", s)
}
s = strings.TrimSpace(s[1 : len(s)-1])
regPart, offPart := s, ""
if i := strings.IndexAny(s, "+-"); i >= 0 {
regPart, offPart = strings.TrimSpace(s[:i]), strings.TrimSpace(s[i:])
offPart = strings.ReplaceAll(offPart, " ", "")
}
rb, err := parseReg(regPart)
if err != nil {
return 0, 0, err
}
var off int64
if offPart != "" {
sign := int64(1)
if offPart[0] == '-' {
sign = -1
}
v, err := parseNum(offPart[1:], consts)
if err != nil {
return 0, 0, err
}
off = sign * v
}
if off < -32768 || off > 32767 {
return 0, 0, fmt.Errorf("offset %d out of range", off)
}
return rb, int32(off), nil
}
+325
View File
@@ -0,0 +1,325 @@
// Package vm implements the bytecode CPU that runs ship programs.
//
// The machine has 16 32-bit registers (r15 is the stack pointer), a program
// ROM and a byte-addressable little-endian data RAM. Peripherals are reached
// through IN/OUT port instructions. Every instruction is 4 bytes:
//
// byte 0: opcode | byte 1: ra<<4 | rb | bytes 2-3: signed 16-bit immediate
//
// Branch/jump immediates are offsets in instructions relative to the next
// instruction. Each tick a CPU runs up to a cycle budget or until it executes
// YIELD; state persists between ticks so an over-long computation simply
// continues on the next tick.
package vm
import (
"encoding/binary"
"fmt"
)
type Op uint8
const (
NOP Op = iota
YIELD
HALT
LDI
LUI
MOV
ADD
SUB
MUL
DIV
MOD
AND
OR
XOR
SHL
SHR
SAR
ADDI
JMP
BEQ
BNE
BLT
BGE
CALL
RET
PUSH
POP
LDB
LDH
LDW
STB
STH
STW
IN
OUT
numOps
)
const (
NumRegs = 16
SP = 15
)
type Status uint8
const (
Running Status = iota
Yielded // finished this tick's work voluntarily
Halted
Faulted
)
func (s Status) String() string {
return [...]string{"running", "yielded", "halted", "faulted"}[s]
}
// Bus connects the CPU to peripherals.
type Bus interface {
In(port uint16) int32
Out(port uint16, v int32)
}
type CPU struct {
R [NumRegs]int32
PC uint32 // byte address into Prog
Prog []byte
RAM []byte
Status Status
Fault string
}
// New creates a CPU with the program loaded and the stack pointer at the top
// of RAM.
func New(prog []byte, ramBytes int) (*CPU, error) {
if len(prog)%4 != 0 {
return nil, fmt.Errorf("program length %d is not a multiple of 4", len(prog))
}
c := &CPU{Prog: append([]byte(nil), prog...), RAM: make([]byte, ramBytes)}
c.R[SP] = int32(ramBytes)
return c, nil
}
func (c *CPU) fault(format string, args ...any) {
c.Status = Faulted
c.Fault = fmt.Sprintf(format, args...)
}
func cost(op Op) int {
switch op {
case MUL:
return 2
case DIV, MOD:
return 8
}
return 1
}
// Run executes instructions until the budget is spent, YIELD, HALT or a
// fault, returning the cycles used. A yielded CPU resumes on the next call.
func (c *CPU) Run(bus Bus, budget int) int {
if c.Status == Halted || c.Status == Faulted {
return 0
}
c.Status = Running
used := 0
for used < budget {
if int(c.PC)+4 > len(c.Prog) {
// Falling off the end of the program halts the CPU.
c.Status = Halted
break
}
w := binary.LittleEndian.Uint32(c.Prog[c.PC:])
op := Op(w)
ra, rb := (w>>12)&0xf, (w>>8)&0xf
imm := int32(int16(w >> 16))
if op >= numOps {
c.fault("illegal opcode %d at pc=%d", op, c.PC)
break
}
used += cost(op)
next := c.PC + 4
r := &c.R
switch op {
case NOP:
case YIELD:
c.Status = Yielded
case HALT:
c.Status = Halted
case LDI:
r[ra] = imm
case LUI:
r[ra] = int32(uint32(imm)<<16 | uint32(r[ra])&0xffff)
case MOV:
r[ra] = r[rb]
case ADD:
r[ra] += r[rb]
case SUB:
r[ra] -= r[rb]
case MUL:
r[ra] *= r[rb]
case DIV, MOD:
d := r[rb]
if d == 0 {
c.fault("division by zero at pc=%d", c.PC)
break
}
switch {
case d == -1: // avoid MinInt32 / -1 overflow panic semantics
if op == DIV {
r[ra] = -r[ra]
} else {
r[ra] = 0
}
case op == DIV:
r[ra] /= d
default:
r[ra] %= d
}
case AND:
r[ra] &= r[rb]
case OR:
r[ra] |= r[rb]
case XOR:
r[ra] ^= r[rb]
case SHL:
r[ra] = int32(uint32(r[ra]) << (uint32(r[rb]) & 31))
case SHR:
r[ra] = int32(uint32(r[ra]) >> (uint32(r[rb]) & 31))
case SAR:
r[ra] >>= uint32(r[rb]) & 31
case ADDI:
r[ra] += imm
case JMP:
next = uint32(int64(next) + int64(imm)*4)
case BEQ, BNE, BLT, BGE:
var t bool
switch op {
case BEQ:
t = r[ra] == r[rb]
case BNE:
t = r[ra] != r[rb]
case BLT:
t = r[ra] < r[rb]
case BGE:
t = r[ra] >= r[rb]
}
if t {
next = uint32(int64(next) + int64(imm)*4)
}
case CALL:
if c.push(int32(next)) {
next = uint32(int64(next) + int64(imm)*4)
}
case RET:
if v, ok := c.pop(); ok {
next = uint32(v)
}
case PUSH:
c.push(r[ra])
case POP:
if v, ok := c.pop(); ok {
r[ra] = v
}
case LDB, LDH, LDW:
n := accessSize(op)
if a, ok := c.addr(r[rb]+imm, n); ok {
var v uint32
for i := n - 1; i >= 0; i-- {
v = v<<8 | uint32(c.RAM[a+i])
}
r[ra] = int32(v)
}
case STB, STH, STW:
n := accessSize(op)
if a, ok := c.addr(r[rb]+imm, n); ok {
v := uint32(r[ra])
for i := 0; i < n; i++ {
c.RAM[a+i] = byte(v >> (8 * i))
}
}
case IN:
r[ra] = bus.In(uint16(imm))
case OUT:
bus.Out(uint16(imm), r[ra])
}
if c.Status == Faulted {
break
}
c.PC = next
if c.Status != Running {
break
}
}
return used
}
func accessSize(op Op) int {
switch op {
case LDB, STB:
return 1
case LDH, STH:
return 2
}
return 4
}
func (c *CPU) addr(a int32, n int) (int, bool) {
if a < 0 || int(a)+n > len(c.RAM) {
c.fault("memory access out of range: %d", a)
return 0, false
}
return int(a), true
}
func (c *CPU) push(v int32) bool {
a, ok := c.addr(c.R[SP]-4, 4)
if !ok {
return false
}
c.R[SP] -= 4
binary.LittleEndian.PutUint32(c.RAM[a:], uint32(v))
return true
}
func (c *CPU) pop() (int32, bool) {
a, ok := c.addr(c.R[SP], 4)
if !ok {
return 0, false
}
c.R[SP] += 4
return int32(binary.LittleEndian.Uint32(c.RAM[a:])), true
}
// Encode builds one instruction word.
func Encode(op Op, ra, rb int, imm int32) uint32 {
return uint32(op) | uint32(rb&0xf)<<8 | uint32(ra&0xf)<<12 | uint32(uint16(imm))<<16
}
// MarshalState serialises the mutable CPU state (not the program).
func (c *CPU) MarshalState() []byte {
b := make([]byte, 0, NumRegs*4+8+len(c.RAM))
for _, r := range c.R {
b = binary.LittleEndian.AppendUint32(b, uint32(r))
}
b = binary.LittleEndian.AppendUint32(b, c.PC)
b = append(b, byte(c.Status), 0, 0, 0)
return append(b, c.RAM...)
}
// UnmarshalState restores state produced by MarshalState.
func (c *CPU) UnmarshalState(b []byte) error {
hdr := NumRegs*4 + 8
if len(b) != hdr+len(c.RAM) {
return fmt.Errorf("state size %d does not match expected %d", len(b), hdr+len(c.RAM))
}
for i := range c.R {
c.R[i] = int32(binary.LittleEndian.Uint32(b[i*4:]))
}
c.PC = binary.LittleEndian.Uint32(b[NumRegs*4:])
c.Status = Status(b[NumRegs*4+4])
copy(c.RAM, b[hdr:])
return nil
}
+114
View File
@@ -0,0 +1,114 @@
package vm
import "testing"
type testBus struct {
in map[uint16]int32
out map[uint16]int32
}
func (b *testBus) In(p uint16) int32 { return b.in[p] }
func (b *testBus) Out(p uint16, v int32) { b.out[p] = v }
func run(t *testing.T, src string, budget int) (*CPU, *testBus) {
t.Helper()
prog, err := Assemble(src)
if err != nil {
t.Fatal(err)
}
c, err := New(prog, 256)
if err != nil {
t.Fatal(err)
}
b := &testBus{in: map[uint16]int32{5: 42}, out: map[uint16]int32{}}
c.Run(b, budget)
return c, b
}
func TestSumLoop(t *testing.T) {
c, b := run(t, `
ldi r0, 0 ; sum
ldi r1, 1 ; i
ldi r2, 11
loop:
add r0, r1
addi r1, 1
blt r1, r2, loop
out 7, r0
halt
`, 1000)
if c.Status != Halted || b.out[7] != 55 {
t.Fatalf("status=%v out=%d", c.Status, b.out[7])
}
}
func TestLiCallStackMemory(t *testing.T) {
c, b := run(t, `
li r0, 0x12345678
call double
stw r0, [sp-8] ; below current sp
ldw r3, [sp-8]
in r4, 5
add r3, r4
out 1, r3
halt
double:
add r0, r0
ret
`, 1000)
want := int32(0x12345678)*2 + 42
if c.Status != Halted || b.out[1] != want {
t.Fatalf("status=%v fault=%q out=%x want %x", c.Status, c.Fault, b.out[1], want)
}
}
func TestYieldAndBudget(t *testing.T) {
prog, _ := Assemble("l: addi r0, 1\n jmp l")
c, _ := New(prog, 64)
b := &testBus{}
if used := c.Run(b, 100); used != 100 || c.Status != Running {
t.Fatalf("used=%d status=%v", used, c.Status)
}
r0 := c.R[0]
c.Run(b, 100)
if c.R[0] != r0+50 {
t.Fatalf("did not resume: %d -> %d", r0, c.R[0])
}
}
func TestFaults(t *testing.T) {
c, _ := run(t, "ldi r0, 1\n ldi r1, 0\n div r0, r1", 100)
if c.Status != Faulted {
t.Fatal("expected div fault")
}
c, _ = run(t, "ldi r1, 300\n ldw r0, [r1]", 100)
if c.Status != Faulted {
t.Fatal("expected memory fault")
}
}
func TestStateRoundTrip(t *testing.T) {
c, _ := run(t, "ldi r0, 9\n stb r0, [r1+3]\n yield\n ldi r0, 1", 100)
d, _ := New(c.Prog, 256)
if err := d.UnmarshalState(c.MarshalState()); err != nil {
t.Fatal(err)
}
if d.R != c.R || d.PC != c.PC || d.RAM[3] != 9 || d.Status != Yielded {
t.Fatal("state mismatch")
}
}
func TestAssemblerCaseInsensitiveDirectives(t *testing.T) {
// LI expands to two instructions; label distances must agree in any case.
lower, err := Assemble(".equ K 5\n li r1, 0x12345\n jmp end\n nop\nend: halt")
if err != nil {
t.Fatal(err)
}
upper, err := Assemble(".EQU K 5\n LI r1, 0x12345\n JMP end\n NOP\nend: HALT")
if err != nil {
t.Fatal(err)
}
if string(lower) != string(upper) {
t.Fatal("upper- and lower-case source assembled differently")
}
}