A Tiny CPU, Instruction by Instruction
in reviewThis lesson is complete and being validated before release - details may still change.
in reviewThis lesson is complete and being validated before release - details may still change.
tiny-cpu.ts
import type { Tracer } from "../trace";import { RippleCarryAdder } from "./ripple-carry-adder";/** Sixteen words of memory, each a byte: a 4-bit opcode above a 4-bit address. */export const MEMORY_WORDS = 16;export const WORD_BITS = 8;export const WORD_LIMIT = 2 ** WORD_BITS;/** Cycles before a program that never halts is stopped. */export const MAX_CYCLES = 48;export type Mnemonic = "HALT" | "LOAD" | "STORE" | "ADD" | "SUB" | "JUMP" | "JZ" | "OUT" | "LOADI";/** The instruction set, indexed by opcode: HALT is 0, so an empty word is a halt. */export const OPCODES: readonly Mnemonic[] = ["HALT", "LOAD", "STORE", "ADD", "SUB", "JUMP", "JZ", "OUT", "LOADI"];/** Opcodes that read or write the memory word their operand names. */export const MEMORY_OPS: readonly Mnemonic[] = ["LOAD", "STORE", "ADD", "SUB"];export interface Instruction { opcode: number; mnemonic: Mnemonic | null; operand: number;}export interface Registers { /** The address of the next instruction. */ pc: number; /** The accumulator: the one register arithmetic works on. */ acc: number; /** The instruction register: the word fetched this cycle. */ ir: number; /** The zero flag: whether the accumulator was last left at zero. */ zero: boolean;}export interface CpuResult { halted: boolean; cycles: number; registers: Registers; memory: number[]; output: number[]; /** Why the run stopped early, or null for a clean halt. */ fault: string | null;}/** * Turns assembly lines into memory words: "ADD 14" is opcode 3 above * address 14; a bare number is a data word stored as it is; the * memory is padded with zeros, which are halts. */export class Assembler { static assemble(lines: readonly string[]): number[] { if (lines.length > MEMORY_WORDS) throw new RangeError(`${lines.length} words exceeds the ${MEMORY_WORDS}-word memory`); const words = lines.map((line) => Assembler.word(line.trim())); const words = lines.map((line) => Assembler.memoryWord(line.trim())); while (words.length < MEMORY_WORDS) words.push(0); return words; } /** The word for one line. */ static word(line: string): number { static memoryWord(line: string): number { if (/^\d+$/.test(line)) { const value = Number(line); if (value >= WORD_LIMIT) throw new RangeError(`data ${value} does not fit in a byte`); return value; } const [mnemonic, operand = "0"] = line.split(/\s+/); const opcode = OPCODES.indexOf(mnemonic as Mnemonic); if (opcode === -1) throw new RangeError(`unknown instruction ${line}`); const address = Number(operand); if (!Number.isInteger(address) || address < 0 || address >= MEMORY_WORDS) throw new RangeError(`operand ${operand} is not an address`); return opcode * MEMORY_WORDS + address; } /** A word read as an instruction: the opcode above, the address below. */ static decode(word: number): Instruction { static decode(memoryWord: number): Instruction { const opcode = Math.floor(word / MEMORY_WORDS); const opcode = Math.floor(memoryWord / MEMORY_WORDS); return { opcode, mnemonic: OPCODES[opcode] ?? null, operand: word % MEMORY_WORDS }; return { opcode, mnemonic: OPCODES[opcode] ?? null, operand: memoryWord % MEMORY_WORDS }; } /** The assembly text of a word, or its value when the opcode is not one. */ static disassemble(word: number): string { static disassemble(memoryWord: number): string { const { mnemonic, operand } = Assembler.decode(word); const { mnemonic, operand } = Assembler.decode(memoryWord); if (mnemonic === null) return `${word}`; return mnemonic === "HALT" || mnemonic === "OUT" ? mnemonic : `${mnemonic} ${operand}`; }}/** * An accumulator machine small enough to watch: a program counter, an * accumulator, an instruction register, a zero flag, and sixteen * bytes of memory holding both the program and its data. Every cycle * is the same three moves. Fetch: the word at the program counter goes * into the instruction register and the counter advances. Decode: the * top four bits pick the instruction, the bottom four name an address. * Execute: the accumulator, the memory, or the program counter * changes. The adder is the ripple-carry adder from two lessons ago; * subtraction is addition of the two's complement, the carry in * supplying the plus one. Memory cannot tell code from data, so a jump * into a data word runs it. * * Time: three steps per instruction. Space: 16 bytes and four registers. */export class TinyCpu { private readonly memory: number[]; private readonly registers: Registers = { pc: 0, acc: 0, ir: 0, zero: true }; private readonly output: number[] = []; private readonly adder = new RippleCarryAdder(WORD_BITS); private cycles = 0; constructor( program: readonly number[], private readonly tracer?: Tracer, ) { if (program.length !== MEMORY_WORDS) throw new RangeError(`a program is ${MEMORY_WORDS} words, got ${program.length}`); this.memory = program.map((word) => word % WORD_LIMIT); this.memory = program.map((memoryWord) => memoryWord % WORD_LIMIT); } /** Runs until HALT, or until the cycle cap stops a program that never halts. */ run(): CpuResult { let halted = false; let fault: string | null = null; while (!halted) { if (this.cycles >= MAX_CYCLES) { fault = `still running after ${MAX_CYCLES} cycles`; break; } halted = this.step(); } return { halted, cycles: this.cycles, registers: { ...this.registers }, memory: [...this.memory], output: [...this.output], fault }; } /** One cycle: fetch, decode, execute. True once HALT has executed. */ step(): boolean { this.cycles++; const word = this.fetch(); const memoryWord = this.fetch(); const instruction = this.decode(word); const instruction = this.decode(memoryWord); return this.execute(instruction); } private fetch(): number { const pc = this.registers.pc; const word = this.memory[pc]!; const memoryWord = this.memory[pc]!; this.registers.ir = word; this.registers.ir = memoryWord; this.registers.pc = (pc + 1) % MEMORY_WORDS; // ▸ fetch: the word at the program counter goes into the instruction register, and the counter moves on this.tracer?.emit("fetch", { cycle: this.cycles, pc, word, next: this.registers.pc }); this.tracer?.emit("fetch", { cycle: this.cycles, pc, memoryWord, next: this.registers.pc }); return word; return memoryWord; } private decode(word: number): Instruction { private decode(memoryWord: number): Instruction { const instruction = Assembler.decode(word); const instruction = Assembler.decode(memoryWord); // ▸ decode: the top four bits name the instruction, the bottom four an address this.tracer?.emit("decode", { cycle: this.cycles, word, opcode: instruction.opcode, mnemonic: instruction.mnemonic, operand: instruction.operand }); this.tracer?.emit("decode", { cycle: this.cycles, memoryWord, opcode: instruction.opcode, mnemonic: instruction.mnemonic, operand: instruction.operand }); return instruction; } /** The effect of one instruction; a word whose opcode is not one halts, like an empty word. */ private execute(instruction: Instruction): boolean { const { mnemonic, operand } = instruction; const r = this.registers; const regs = this.registers; let written: number | null = null; let halted = false; switch (mnemonic) { case "LOAD": r.acc = this.memory[operand]!; regs.acc = this.memory[operand]!; break; case "STORE": this.memory[operand] = r.acc; this.memory[operand] = regs.acc; written = operand; break; case "ADD": r.acc = this.adder.add(r.acc, this.memory[operand]!).sum; regs.acc = this.adder.add(regs.acc, this.memory[operand]!).sum; break; case "SUB": // Two's complement: invert the operand and add one through the carry in. r.acc = this.adder.add(r.acc, WORD_LIMIT - 1 - this.memory[operand]!, 1).sum; regs.acc = this.adder.add(regs.acc, WORD_LIMIT - 1 - this.memory[operand]!, 1).sum; break; case "JUMP": r.pc = operand; regs.pc = operand; break; case "JZ": if (r.zero) r.pc = operand; if (regs.zero) regs.pc = operand; break; case "OUT": this.output.push(r.acc); this.output.push(regs.acc); break; case "LOADI": r.acc = operand; regs.acc = operand; break; default: halted = true; } r.zero = r.acc === 0; regs.zero = regs.acc === 0; // ▸ execute: the accumulator, a memory word, or the program counter changes this.tracer?.emit("execute", { cycle: this.cycles, mnemonic: mnemonic ?? "HALT", operand, acc: r.acc, pc: r.pc, zero: r.zero, written, output: [...this.output], halted }); this.tracer?.emit("execute", { cycle: this.cycles, mnemonic: mnemonic ?? "HALT", operand, acc: regs.acc, pc: regs.pc, zero: regs.zero, written, output: [...this.output], halted }); return halted; }}Halted after 15 cycles. Output: 3 2 1.