The First Opcode: JP
With the CPU structure defined and memory in place, we can implement the fetch-decode-execute cycle. The first opcode to implement is JP — it is simple enough to understand the full cycle, and it exercises fetch, bitwise decoding, and the program counter.
The cycle
Every CPU works by repeating three steps:
1. Fetch — read the next instruction from memory
2. Decode — figure out what it says
3. Execute — do it
In CHIP-8, every instruction is exactly 2 bytes. The fetch step reads both bytes and combines them into a single 16-bit value.
Fetch
#![allow(unused)] fn main() { pub fn step(&mut self) { // read two bytes from memory at the current PC let high = self.memory.read(self.pc) as u16; let low = self.memory.read(self.pc + 1) as u16; let opcode = (high << 8) | low; // advance PC before executing self.pc += 2; self.execute(opcode); } }
high is shifted 8 bits left to occupy the upper byte, then OR'd with low to fill the lower byte. The result is the full 16-bit opcode.
PC advances by 2 before execute. This means when execute runs, PC already points to the next instruction. Jump opcodes simply overwrite PC with a new address.
Opcode 1NNN — JP addr
JP jumps to address NNN. The next instruction executed will be whatever lives at that address.
1NNN
│└──┘
│ └─ NNN: 12-bit target address
└──── opcode category 1
Example: 0x1300 means "jump to address 0x300".
Decode and execute
#![allow(unused)] fn main() { fn execute(&mut self, opcode: u16) { let nibble = (opcode & 0xF000) >> 12; // first nibble: category match nibble { 0x1 => { let nnn = opcode & 0x0FFF; // lower 12 bits: address self.pc = nnn; } _ => println!("unknown opcode: {:#06X}", opcode), } } }
Mask 0xF000 isolates the first nibble. The shift moves it down from bits 15–12 to bits 3–0, producing a value from 0 to 15. That value drives the match.
Mask 0x0FFF isolates the lower 12 bits — the address. No shift needed because the address goes directly into pc.
Test
#![allow(unused)] fn main() { #[test] fn test_jp() { let mut cpu = Cpu::new(); // 0x1300 = JP 0x300 cpu.memory.load_rom(&[0x13, 0x00]); cpu.step(); assert_eq!(cpu.pc, 0x300); } }
Rust concepts used here: Bitwise masking and shifting are covered in Bitwise Operations. The
matchon the first nibble is the pattern used for every opcode — see Pattern Matching. Theas u16casts in fetch are explained in Type Casting. The{:#06X}format specifier prints hex values with0xprefix, padded to 6 characters.