This project is part of a series of 2 blog posts and it's better explained there:
A small 16-bit CPU emulator written in Kotlin, with a tiny assembler (.kasm) and sample programs.
The project models a complete fetch-decode-execute loop with registers, RAM, stack, control flow, arithmetic/logic instructions, and an assembler that supports labels.
- A 16-bit CPU core (
Cpu) with:- 64KB RAM
- 8 general-purpose 16-bit registers (
R0..R7) - program counter (
PC) and stack pointer (SP) - flags: zero, carry, negative
- An ISA executor (
Isa) that decodes 16-bit instructions and dispatches operations. - An ALU (
Alu) with arithmetic, bitwise, move, and shift operations. - A simple assembler pipeline:
KasmReader: strips comments/blank linesKasmParser: parses labels and instructionsKasmEncoder: encodes instructions to 16-bit wordsKasmLoader: writes words into emulated memory
- Example programs in
src/main/resources/*.kasmand tests insrc/test/kotlin.
- Word size: 16-bit instructions and 16-bit register values.
- Endianness in memory: little-endian for 16-bit values (
low byte, thenhigh byte). - Instruction size: every instruction is 2 bytes.
- PC behavior:
fetch()reads memory atPCandPC+1, then incrementsPCby 2.- branch/jump/call offsets are applied as
offset * 2(word-based offsets).
- Register file: 8 registers.
R0is hard-wired to zero (reads as zero, writes ignored).
- Stack:
SPstarts at0xFFFE.- stack grows downward.
pushdecrementsSPby 2 then stores a 16-bit value.popreads 16-bit value atSPthen incrementsSPby 2.
Bits are numbered from Most Significant Bit to Least Significant Bit:
opcode = bits[15..12]- register / immediate fields depend on instruction format
- R-type (ALU)
opcode[15..12] rd[11..9] rs1[8..6] rs2[5..3] aluOp[2..0]
- I-type (6-bit immediate)
opcode[15..12] rd[11..9] rs1[8..6] imm6[5..0]
- J-type (12-bit immediate)
opcode[15..12] imm12[11..0]
- Stack format
opcode[15..12] reg[11..9](remaining bits unused)
imm6is sign-extended from 6 bits: range-32..31.imm12is sign-extended from 12 bits: range-2048..2047.- Label immediates are assembler-generated as relative word offsets:
offset = (target - (pc + 2)) / 2
| Mnemonic | Opcode | Format | Semantics |
|---|---|---|---|
ADD rd, rs1, rs2 |
0x0 + aluOp=000 |
R | rd = rs1 + rs2 |
SUB rd, rs1, rs2 |
0x0 + aluOp=001 |
R | rd = rs1 - rs2 |
AND rd, rs1, rs2 |
0x0 + aluOp=010 |
R | rd = rs1 & rs2 |
OR rd, rs1, rs2 |
0x0 + aluOp=011 |
R | rd = rs1 | rs2 |
XOR rd, rs1, rs2 |
0x0 + aluOp=100 |
R | rd = rs1 ^ rs2 |
MOV rd, rs1 |
0x0 + aluOp=101 |
R | rd = rs1 |
SHL rd, rs1, rs2 |
0x0 + aluOp=110 |
R | rd = rs1 << (rs2 & 0xF) |
SHR rd, rs1, rs2 |
0x0 + aluOp=111 |
R | rd = rs1 >> (rs2 & 0xF) (logical) |
ADDI rd, rs1, imm6 |
0x1 |
I | rd = rs1 + imm6 |
LI rd, imm6 |
0x2 |
I | rd = imm6 (sign-extended to 16-bit) |
LUI rd, imm6 |
0x3 |
I | rd = imm6 << 8 |
LOAD rd, base, off6 |
0x4 |
I | rd = mem16[(base + off6) & 0xFFFE] |
STORE rs, base, off6 |
0x5 |
I | mem16[(base + off6) & 0xFFFE] = rs |
BEQ rs1, rs2, off6 |
0x6 |
I | if rs1 == rs2, PC += off6 * 2 |
BNE rs1, rs2, off6 |
0x7 |
I | if rs1 != rs2, PC += off6 * 2 |
JMP off12 |
0x8 |
J | PC += off12 * 2 |
RET |
0x9 |
J | PC = pop() |
PUSH rs |
0xC |
Stack | push(rs) |
POP rd |
0xD |
Stack | rd = pop() |
CALL off12 |
0xE |
J | push(returnPC); PC += off12 * 2 |
HALT |
0xF |
J | stop execution |
| Mnemonic | Expansion |
|---|---|
NOP |
ADD R0, R0, R0 |
zeroFlag: set when result is0.negativeFlag: set when result has bit15 = 1.carryFlag: updated by arithmetic/shift operations.
Flags are updated by ALU and ADDI paths; control flow in current implementation compares register values directly (BEQ/BNE) rather than checking flags.
KasmReader("file.kasm")- loads from classpath resources
- removes comments (
;) and empty lines
KasmParser(source)- parses instruction and label lines
- resolves label addresses
- converts label references to relative offsets
KasmEncoder- encodes each instruction into
UShort
- encodes each instruction into
KasmLoader(program)- writes encoded words into CPU memory at address
0x0000
- writes encoded words into CPU memory at address
Isa.create(cpu).run()- executes until
HALT
- executes until
The entry point is src/main/kotlin/Main.kt and it loads the file set in KASM:
private const val KASM = "call.kasm"With the current Gradle setup, the simplest way to execute samples is from the IDE:
- Open
src/main/kotlin/Main.kt. - Change
KASMto the program you want (for example:"alu.kasm","beq.kasm","call.kasm"). - Run
main().
alu.kasm: ALU arithmetic/bitwise/shift operationsmov.kasm: register-to-register movememory.kasm: load/store with base+offsetlui.kasm: high-byte immediate behaviorbeq.kasm/bne.kasm: conditional branchesjmp.kasm: unconditional PC-relative jumpcall.kasm:CALL+RETflowloop.kasm: loop with backward branchstack.kasm: explicitPUSH/POP