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
: addressNN
: 8-bit constantN
: 4-bit constant
And some example commands:
0NNN
: Call routine at addressNNN
3XNN
: Check if value at registerX
equalsNN
, if true then next command is skipped8XYZ
: Commands that start with8
denote mathematical operations onX
andY
. TheZ
-bits denote type of operation8XY0
: assigns value atX
to value atY
8XY4
: adds values atX
andY
together and stores atY
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.