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 match on the 0x0 group 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 uses as usize for indexing — see Type Casting.