CALL and RET — Subroutines
These two opcodes work as a pair. CALL jumps into a subroutine and saves where to return. RET pops that return address and jumps back. Together they are the CHIP-8's mechanism for reusable code.
CALL 2NNN
Saves the current PC onto the stack, then jumps to address NNN.
2NNN
│└──┘
│ └─ NNN: subroutine address
└──── opcode category 2
#![allow(unused)] fn main() { 0x2 => { self.stack[self.sp as usize] = self.pc; // save return address self.sp += 1; self.pc = opcode & 0x0FFF; // jump to subroutine } }
The return address saved is the PC after the += 2 in step — meaning it points to the instruction immediately after the CALL. When the subroutine returns, execution continues from there.
RET 00EE
Pops the return address and jumps back to it.
#![allow(unused)] fn main() { 0x0 => match opcode { 0x00EE => { self.sp -= 1; self.pc = self.stack[self.sp as usize]; } // ... } }
SP decrements before reading because SP always points to the next free slot, not the top value. To read the top, go back one.
00EE shares the 0x0 prefix with 00E0 (CLS), so a nested match on the full opcode distinguishes them.
Stack state example
initial: sp = 0, stack = [_, ...]
CALL 0x400:
stack[0] = 0x202 ← return address (instruction after CALL)
sp = 1
pc = 0x400
RET:
sp = 0
pc = stack[0] = 0x202
Nesting
The stack supports 16 levels:
CALL 0x300 → stack[0] = 0x202, sp=1, pc=0x300
CALL 0x400 → stack[1] = 0x302, sp=2, pc=0x400
RET → sp=1, pc=0x302
RET → sp=0, pc=0x202
Each CALL pushes one entry; each RET pops one. SP tracks the depth.
Rust concepts used here: The nested
matchon the0x0group is the first time we see multi-level matching — see Pattern Matching. The stack is a fixed array ([u16; 16]), covered in Primitive Types and Arrays. The SP arithmetic usesas usizefor indexing — see Type Casting.