CHIP-8 Emulator

Posted on Thu 04 September 2025 in posts

Introduction

As I was learning about the Rust programming language via Rust in Action textbook, I was most excited to learn about CPU emulation.

Back in April of 2025, I finally got to the chapter on CHIP-8 emulation. This seemed like a way to finally look under the hood of CPU emulation and hardware in general.

What is CHIP-8

CHIP-8 was developed by Joseph Weisbecker in the 1970s. It was a programming language designed to run on on 8-bit microprocessors.

The processor is assumed to have 4096 memory locations. In real 8-bit systems, the limitation was that CHIP-8 required 512 bytes to store itself. However, we will avoid this limitation since our system has much more space available.

The system has 16 registers to store data. The CHIP-8 system's also assumes 16 (at least in our implementation here) positions.

We will also require a stack pointer which will help us recover data from stack into our active memory registers.

So what is CHIP-8? It is a language which uses register addresses to tell a hypothetical (or real!) CPU how to manipulate data.

Rust Implementation of Basic Commands

There are 35 opcodes supported by the system. Each opcode is constructed following a pattern:

  • a 16-bit word
  • the first 4 bits usually denote the type of operation
  • the rest can be an address in memory, location of variables etc.

Let's create a Rust structure to represent our hypothetical CPU:

pub struct Cpu {
    pub registers: [u8; 16],
    pub register_i: u16,
    pub memory: [u8; 0x1000],
    pub prog_counter: usize, // program counter
    pub stack: [u16; 16],
    pub stack_pointer: usize,
}

You can see that we are operating in unsigned integers with at most 8 bits per number.

Let's look at some examples of opcode patterns:

  • NNN: address
  • NN: 8-bit constant
  • N: 4-bit constant

And some example commands:

  • 0NNN: Call routine at address NNN
  • 3XNN: Check if value at register X equals NN, if true then next command is skipped
  • 8XYZ: Commands that start with 8 denote mathematical operations on X and Y. The Z-bits denote type of operation
  • 8XY0: assigns value at X to value at Y
  • 8XY4: adds values at X and Y together and stores at Y

How to implement this by hand in Rust? First, let's see how to initialize this CPU object:

let mut cpu = Cpu {
        registers: [0; 16],
        register_i: 0,
        memory: [0; 4096],
        prog_counter: 0,
        stack: [0; 16],
        stack_pointer: 0,
    };

    cpu.registers[0] = 5;   // numbers to be operated on
    cpu.registers[1] = 10;  // number to be operated on

    let mem = &mut cpu.memory; // we will skip fully populating memory
    // op code 0x2100, call function at address 100
    mem[0x000] = 0x21;
    mem[0x001] = 0x00;

We skip populating the memory array. But once mem, a mutable reference to our CPU memory, is populated with opcodes for commands, we go through each location and read them. Using bit operations, we execute commands. It is quite simple, but requires a lot of careful implementation.

    fn read_op_code(&self) -> u16 {
        let p = self.prog_counter;
        let op_byte1 = self.memory[p] as u16;
        let op_byte2 = self.memory[p + 1] as u16;
        op_byte1 << 8 | op_byte2
    }

Given the program counter, which points to the location in memory, we grab the first and second bytes then concatenate them using 8-bit shift and or operation.

Once the opcode is read, it's time to match it to the action.

First, as shown in the listing below, we grab the address for a possible function call. Then we get address kk of the register for skip checks (see above). Finally we obtain each individual 4-bit value.

let opcode = self.read_op_code();
self.prog_counter += 2;
let nnn = opcode & 0x0FFF; // address for function call: 0x2nnn
let kk = (opcode & 0x00FF) as u8;
let c = ((opcode & 0xF000) >> 12) as u8; // operation code (8 signifies 2-arg operation)
let x = ((opcode & 0x0F00) >> 8) as u8; // first arg (index in register)
let y = ((opcode & 0x00F0) >> 4) as u8; // second arg (index in register)
let d = (opcode & 0x000F) as u8; // the operation code (4 means addition)

Then, as we match the (c, x, y, d) quadruplet to a possible sequence of 4-bit values! These sequences are all available in the table of opcodes. In Rust, we can do this as follows

match (c, x, y, d) {
    (0, 0, 0, 0) => {
        return;
    }
    (0, 0, 0xE, 0xE) => self.rtrn(),
    (0x1, _, _, _) => self.jump(nnn),
    (0x2, _, _, _) => self.call(nnn),
    // ... omitted for brevity
    (0x8, _, _, 0x4) => self.add_xy(x, y),
    (0x8, _, _, 0x5) => self.sub_xy(x, y),
}

In my experiments I only focused on operations that did not include drawing or displaying anything on screen for simplicity.

The operations called by each matched pattern have to be implemented. Here is the addition, for instance:

fn add_xy(&mut self, x: u8, y: u8) {
    // addition
    let arg_1 = self.registers[x as usize];
    let arg_2 = self.registers[y as usize];
    let (val, overflow) = arg_1.overflowing_add(arg_2);
    self.registers[x as usize] = val;
    if overflow {
        self.registers[0xF] = 1;
    } else {
        self.registers[0xF] = 0;
    }
}

You can see how we account for overflow using Rust's internal u8 implementation as well as taking advantage of a special byte F which is used to store special values and is never used outside of storing things like carries, overflow flags, pixel collisions, etc.

Conclusion

I was very interested in implementing the arithmetical and Boolean operations in Rust for this project.

Hopefully, I will return to supplement to supplement this project with

  • pixel rendering (emulated)
  • reading programs from file
  • something else? Who knows!

Thanks for reading this far. I greatly appreciate you reaching this point in the post! The full code is located at this repo.