In Part 1 we covered the MOS 6502’s architecture, walked through its instruction set, and decoded a small test program by hand. That gave us the basic structure of a CPU emulator:

while running:
    opcode = memory[PC]
    instruction = decode(opcode)
    instruction.execute(operands)
    PC += instruction.length

The pseudocode is clean, but there are obvious pieces missing. We need to define the decode function, implement the execution logic for each instruction, and emulate both the memory and the registers. Time to turn that sketch into real code.

I decided to use Rust for this project, partly as an excuse to brush up on my Rust skills, and partly because Rust’s type system and pattern matching turn out to be a great fit for emulator work. Enums for instructions, enums for addressing modes, match statements for decoding, it all maps very naturally.

Project Structure

The solution ended up as five files, each with a clear responsibility:

cpu.rs            - CPU state, instruction execution logic
instructions.rs   - Opcode decoding (hex byte -> instruction + addressing mode + length)
lib.rs            - Helper functions and shared enums
main.rs           - CLI program, command interpreter, display routines, main CPU loop
memory.rs         - Memory array with read/write methods

Let’s walk through the interesting parts.

Decoding Instructions

The heart of the decoder is a large match statement in instructions.rs that maps each opcode byte to its instruction, addressing mode, and byte length. The 6502 has 256 possible opcode values, and each one maps to a specific combination:

pub fn decode_opcode(opcode: u8) -> Result<DecodedInstruction, String> {
    use Instruction::*;
    use AddressingMode::*;
    
    match opcode {
        // LDA - Load Accumulator
        0xA9 => Ok(DecodedInstruction { instruction: LDA, mode: Immediate, length: 2 }),
        0xA5 => Ok(DecodedInstruction { instruction: LDA, mode: ZeroPage, length: 2 }),
        0xB5 => Ok(DecodedInstruction { instruction: LDA, mode: ZeroPageX, length: 2 }),
        // ... and so on for all 56 instructions across their addressing modes
    }
}

This is one of those cases where a big match statement is actually the right approach. Each opcode has a fixed, known mapping, there’s no pattern or formula to derive it from. You just need the lookup table. Rust’s exhaustive matching helps catch any gaps, and the compiler will tell you if you accidentally handle the same opcode twice.

The DecodedInstruction struct bundles everything the executor needs to know:

  • Which instruction (LDA, STA, ADC, etc.)
  • Which addressing mode (Immediate, ZeroPage, Absolute, etc.)
  • How many bytes the full instruction occupies (1, 2, or 3)

The length field is important because it tells the main loop how far to advance the program counter after execution.

CPU State

The CPU state struct holds the registers as individual fields rather than packing the status flags into a single byte. This makes the execution logic much more readable, you can write self.state.flag_carry = true instead of bit-masking:

pub struct CpuState {
    // Registers
    pub pc: u16,  // Program Counter (16-bit)
    pub a: u8,    // Accumulator (8-bit)
    pub x: u8,    // X Index Register (8-bit)
    pub y: u8,    // Y Index Register (8-bit)
    pub sp: u8,   // Stack Pointer (8-bit, points to 0x0100-0x01FF)

    // Status Register flags (NV-BDIZC format)
    pub flag_carry: bool,
    pub flag_zero: bool,
    pub flag_interrupt_disable: bool,
    pub flag_decimal: bool,
    pub flag_break: bool,
    pub flag_overflow: bool,
    pub flag_negative: bool,
}

When we need to push or pull the status register (for PHP/PLP or interrupts), we pack and unpack the bools into a byte. But for day-to-day instruction execution, working with named booleans is much cleaner than shifting and masking bits.

Executing Instructions

Each instruction’s logic lives in cpu.rs as a branch of another match statement. The executor fetches the operand based on the addressing mode, then applies the instruction’s logic. Here’s CMP (Compare Accumulator) as an example:

CMP => {
    let value = self.fetch_operand(decoded.mode);
    let result = self.state.a.wrapping_sub(value);
    // Carry is set if A >= M (no borrow)
    self.state.flag_carry = self.state.a >= value;
    self.state.update_zero_negative(result);
}

CMP is a good example because it shows how the 6502’s flag behaviour works. The comparison is really just a subtraction that throws away the result. The carry flag acts as a “greater than or equal” indicator, and the zero and negative flags update based on the subtraction result. This is how 6502 programs do conditional logic: compare, then branch based on the flags.

The fetch_operand method handles the addressing mode resolution. For immediate mode it reads the next byte after the opcode. For zero page it reads the next byte as an address into the first 256 bytes of memory. For absolute mode it reads a full 16-bit address from the next two bytes. The instruction logic doesn’t need to care about any of that, it just gets a value to work with.

Most instructions follow this same pattern: fetch operand, do the operation, update flags. The tricky ones are ADC and SBC (which need to handle the carry flag and overflow detection), and the branch instructions (which do signed arithmetic on the program counter).

Memory

The memory model is straightforward. The 6502 addresses 64KB, so we use a flat byte array:

pub struct Memory {
    ram: [u8; 65536],
}

Read and write methods handle byte access, and there’s a helper for reading 16-bit values (little-endian, as the 6502 expects). The input to the emulator is a raw binary file that gets loaded directly into this array at a specified start address. No file format parsing, no headers, just raw bytes into memory.

The Interactive Emulator

With all the pieces in place, main.rs ties everything together into an interactive command-line emulator. You give it a binary file and a start address, and it drops you into a step-through interface:

6502 CPU Emulator

Welcome to the 6502 CPU Emulator!
This emulator loads a 64KB binary file and executes 6502 instructions.

Enter binary file path: test.bin
Enter start address (hex, e.g., 0x8000 or 8000): 0000

Emulator initialized successfully!
Binary file: test.bin
Start address: 0x0000

6502 CPU Emulator - Interactive Mode
Commands: s/n (step), c (continue) r (run), p (pause), q (quit)

┌─────────────────────────────────────────────────────────┐
│ PC: 0x0000  A: 0x00  X: 0x00  Y: 0x00  SP: 0xFF         │
│ Flags: NV-BDIZC                                         │
│        00100000                                         │
│        --------                                         │
│ Instruction: LDA $42                                    │
└─────────────────────────────────────────────────────────┘

> 

The display shows all the register values, the status flags as both a label row and a binary row (so you can see at a glance which flags are set), and the next instruction to be executed. The two main commands are s (step), which executes one instruction and updates the display, and c (continue), which runs the program until it hits a BRK or the end of memory.

Stepping through the test program from Part 1 with this interface is satisfying. You can watch $42 load into the accumulator, see it transfer to X and Y, watch the increment and decrement operations tick the values up and down, and see the flags update in real time. It makes the fetch-decode-execute cycle tangible in a way that reading about it doesn’t quite capture.

What’s Missing

This gets us a working emulator with a basic step-through interface, which is a solid foundation. But there are some obvious gaps for any serious debugging work:

  • No way to view memory contents, you can see registers but not what’s stored in RAM
  • No breakpoints, you either step one instruction at a time or run to completion
  • No way to edit registers or memory on the fly to test “what if” scenarios

In Part 3, we’ll add these debugging features: a memory viewer, breakpoints, and the ability to edit registers and memory while the program is paused. We’ll also add a simple framebuffer display so we can start thinking about graphics output.

The full source code is available of this version: 6502 Rust Emulator initial release

To run, extract zip file

cd cpu_6502_emulator
cargo run