Bitwise Operations

An opcode is a 16-bit number, but it is not really a number — it is four fields packed into two bytes. The first four bits identify the instruction category. The next four bits identify a register. The last eight bits carry an immediate value. Before the CPU can do anything with an opcode, it has to unpack those fields.

That unpacking is done entirely with bitwise operations. AND isolates the bits you want. Shift moves them to the right position. OR combines separate pieces into one value. These three operations appear in literally every opcode implementation in this emulator, so understanding them is not optional.

Binary: the foundation

Every integer is stored as a sequence of bits. A u8 has 8 bits, a u16 has 16:

decimal 42 = binary 0010 1010

Each bit position represents a power of 2, from right to left: 128, 64, 32, 16, 8, 4, 2, 1. With 8 bits, the maximum value is 255 (all bits set). With 16 bits, it is 65535.

AND: & — isolate bits

AND compares each bit pair. The result is 1 only where both input bits are 1. Everything else becomes 0.

  0x6A42  =  0110 1010 0100 0010
& 0x0F00  =  0000 1111 0000 0000
= 0x0A00  =  0000 1010 0000 0000

This is called masking: the second operand (the mask) zeroes out every bit position we do not want, leaving only the bits we care about.

In opcode decoding, masking extracts fields:

#![allow(unused)]
fn main() {
let x  = (opcode & 0x0F00) >> 8;   // second nibble: register index
let nn = (opcode & 0x00FF) as u8;  // lower byte: immediate value
}

Right shift: >> — move bits down

Moves all bits to the right by N positions. Zeroes fill in from the left. Each step divides the value by 2.

0x0A00 >> 8 = 0x000A = 10

After masking a field into its original bit position, shift it down to zero-based so you can use it as a number or array index:

#![allow(unused)]
fn main() {
let x = ((opcode & 0x0F00) >> 8) as usize;  // register index 0–15
}

Left shift: << — move bits up

Moves all bits to the left. Used during fetch to combine two separate bytes into a single 16-bit opcode:

#![allow(unused)]
fn main() {
let high: u8 = 0xAB;
let low:  u8 = 0xCD;
let opcode: u16 = (high as u16) << 8 | (low as u16);
// = 0xABCD
}

The cast to u16 before shifting is required — without it, the shift would push bits beyond the 8-bit boundary and overflow.

OR: | — combine bits

Result is 1 where at least one input bit is 1. This is how the two bytes above are merged into one value: high occupies the upper byte, low occupies the lower byte, and OR merges them without conflict.

XOR: ^ — toggle bits

Result is 1 where the bits differ. XOR with 1 flips a bit; XOR with 0 leaves it alone. XOR a value with itself and you always get zero.

#![allow(unused)]
fn main() {
let a: u8 = 0b10101010;
println!("{}", a ^ a); // 0 — identical bits cancel out
}

XOR is how CHIP-8 draws pixels. Each sprite bit is XORed against the current screen state. If a lit pixel meets another lit pixel, it turns off — which is how the hardware detects sprite collisions.

The three patterns you will see everywhere

#![allow(unused)]
fn main() {
// 1. Combine two memory bytes into a 16-bit opcode
let opcode: u16 = (high as u16) << 8 | low as u16;

// 2. Extract a nibble — mask then shift
let x = ((opcode & 0x0F00) >> 8) as usize;

// 3. Extract the lower byte — mask only, no shift needed
let nn = (opcode & 0x00FF) as u8;
}

Once these patterns are familiar, reading opcode implementations becomes straightforward — each line is just one of these three in a different form.


In the emulator: Pattern 1 appears in step() during fetch. Patterns 2 and 3 appear in every opcode implementation. See The First Opcode: JP for the first time all three are used together. The as casts are explained in Type Casting, and the match that follows extraction is in Pattern Matching.