Emulation 101 · Rust · RP2350

CHIP-8 to Pico 2

A guide for writing a CHIP-8 emulator using Rust, starting with a 4KB interpreter on a desktop and then running the same core bare-metal on a Pico-2 (RP2350) microcontroller.

The CHIP-8 built-in font: 16 glyphs × 5 bytes, drawn here from the 80 bytes loaded into memory[0x50..0xA0]. Each glyph is 4 pixels wide. The low nibble of every byte is unused.

Interpreter scope

CHIP-8 is a bytecode virtual machine created by Joseph Weisbecker in 1977 for the COSMAC VIP microcomputer, implemented as an interpreter on the RCA 1802. Writing a CHIP-8 emulator means writing an interpreter for a virtual machine rather than modeling hardware components. It lacks hardware interrupts, memory mapping, a bus cycle model, and dedicated video processors. Later projects like the Intel 8080 or Game Boy introduce those subsystems.

High level view of the emulator

The emulator consists of a struct holding the machine's state and a loop that advances it. For CHIP-8 the state fits in ten lines:

pc: u16          // program counter, starts at 0x200
i: u16           // index register (addresses, not values)
v: [u8; 16]      // V0..VF — VF doubles as the flag register
ram: [u8; 4096]  // font at 0x50, program from 0x200
stack: [u16; 16] // return addresses only, no frames
sp: u8
delay: u8        // both tick down at exactly 60 Hz
sound: u8        // beeps while nonzero
fb: [bool; 64*32]// naive form; §04 packs this to [u64; 32]
keys: [bool; 16] // hex keypad, not a keyboard

The loop executes three steps: fetch, decode, and execute. Read two bytes at pc (every instruction is 2 bytes, stored high byte first), advance pc by 2, split the 16 bits into four nibbles, and match.

Most bugs in this project come down to incorrect decoding or wrong timing.

Glyphs for text

The font contains sixteen glyphs of five bytes each, totaling 80 bytes. It is conventionally loaded at 0x50, below the program area. Only the high nibble of each byte is used, so each glyph is 4 pixels wide and each byte ends in 0. In binary, the bytes 0xF0, 0x90, 0x90, 0x90, 0xF0 form the digit 0:

0xF0 -> 1111 ....   ####
0x90 -> 1001 ....   #..#
0x90 -> 1001 ....   #..#
0x90 -> 1001 ....   #..#
0xF0 -> 1111 ....   ####

Instruction FX29 sets the index register I to a glyph's address, which DXYN then reads. The address is FONT_START + (VX & 0x0F) * 5. The mask is required because VX is an 8-bit register. Without masking, VX * 5 can point I to address 1355, nearly 1200 bytes past the 80-byte table and inside program memory. Games use this instruction to draw scores. The table:

0xF0,0x90,0x90,0x90,0xF0,  // 0
0x20,0x60,0x20,0x20,0x70,  // 1
0xF0,0x10,0xF0,0x80,0xF0,  // 2
0xF0,0x10,0xF0,0x10,0xF0,  // 3
0x90,0x90,0xF0,0x10,0x10,  // 4
0xF0,0x80,0xF0,0x10,0xF0,  // 5
0xF0,0x80,0xF0,0x90,0xF0,  // 6
0xF0,0x10,0x20,0x40,0x40,  // 7
0xF0,0x90,0xF0,0x90,0xF0,  // 8
0xF0,0x90,0xF0,0x10,0xF0,  // 9
0xF0,0x90,0xF0,0x90,0x90,  // A
0xE0,0x90,0xE0,0x90,0xE0,  // B
0xF0,0x80,0x80,0x80,0xF0,  // C
0xE0,0x90,0x90,0x90,0xE0,  // D
0xF0,0x80,0xF0,0x80,0xF0,  // E
0xF0,0x80,0xF0,0x80,0x80,  // F

Transcribing this table manually makes the sprite format clear, each set bit represents a lit pixel which is the mechanism DXYN relies on.

Three independent clocks

CHIP-8 has three separate clock rates that must not be coupled:

A standard loop structure uses a 60 Hz timer tick that executes N CPU cycles, decrements both timers by one, and presents the frame. On desktop, N ≈ 10 produces 600 Hz. On the RP2350 a hardware timer alarm drives that outer tick.

Isolating the core

Keep windowing, printing, and std dependencies out of the emulator core. The core should receive inputs through methods and expose its framebuffer through a borrowed slice. This separation allows the same crate to compile for desktop, WebAssembly, and the RP2350 without changes. Adding no_std compatibility from the beginning avoids major refactoring later.

Guides, References & Tools

Optional

This is optional if the model above is familiar. Readers who want a concrete grounding in registers, buses, and instruction decoding will find it in this text. Also it's a fantastic book if you haven't read it

Reading through the CPU construction chapters provides helpful context when starting, where binary data is first decoded into mnemonics.

There's two guides worth reading end to end, five references and two community tools below. Definitely start with Tobias V. Langhoff's "Guide to making a CHIP-8 emulator" before writing code, and consult Cowgod and Mikolay during implementation.

Guides

References

Tools

Embedded Rust docs

The quirks and test suites

CHIP-8 implementations diverged between 1977 and 1990 as new interpreters altered instruction behaviors. Software written for one platform frequently fails on another, meaning an emulator that decodes every opcode correctly can still fail to run specific games due to behavioural differences.

Six behaviours account for most compatibility issues. Store each as a bool field in a configuration struct early in development to avoid refactoring later.

QuirkOriginal COSMAC VIPSUPER-CHIP / modernSymptom if wrong
vf_reset
8XY1/2/3
OR/AND/XOR also reset VF to 0VF left untouchedSubtle logic corruption, some games hang
memory
FX55/FX65
I is incremented by X+1I unchanged (or +X on CHIP-48)Save/load of register blocks reads garbage
display_wait
DXYN
Blocks until vblank — caps drawing at 60 sprites/secDraws immediatelyGames run absurdly fast, heavy flicker
clipping
DXYN
Starting coords wrap (x%64, y%32), then the sprite clips at the right and bottom edgesXO-CHIP and many modern emulators wrap the overhanging pixels to the opposite edge insteadSprites tear at the edges, or smear across to the far side
shifting
8XY6/8XYE
VX = VY shiftedVX shifted in place, VY ignoredVery common, breaks lots of games silently
jumping
BNNN
Jump to NNN + V0Read as BXNN: jump to XNN + VXWild jumps into data, instant crash

Automated test suite

github.com/Timendus/chip8-test-suite provides ROMs that render visual pass/fail output directly to the screen once DXYN is functional. The suite contains eight ROMs, numbered in execution order: 1-chip8-logo, 2-ibm-logo, 3-corax+, 4-flags, 5-quirks, 6-keypad, 7-beep, and 8-scrolling (the last requires SUPER-CHIP or XO-CHIP support). Running 1-chip8-logo first is useful because it omits 7XNN and requires only byte-aligned sprite drawing. Running these ROMs headlessly in cargo test by stepping a fixed number of cycles and comparing a hash of the framebuffer provides reliable regression testing.

Implementation details

Build order

This sequence introduces debugging checkpoints at each milestone. Building verification tools before implementing the full opcode set helps isolate decode and timing errors early.

Load and disassemble
Load ROM bytes into ram[0x200..], copy the font table into 0x50, and write a disassembler that prints addresses, raw opcodes, and mnemonics without executing instructions.
Done when the disassembler produces readable assembly for 2-ibm-logo.
Splash screen rendering
Implement five instructions to run 1-chip8-logo: 00E0, 1NNN, 6XNN, ANNN, and DXYN. Next add 7XNN and horizontal bit-shifting for unaligned sprites in 2-ibm-logo. Output the framebuffer to the terminal using # characters. A simple [bool; 2048] array works at this stage; §04 covers packing to [u64; 32].
Done when both 1-chip8-logo and 2-ibm-logo display correctly in terminal output.
Remaining instructions
Implement the rest of the 35 standard opcodes, including subroutine handling (2NNN, 00EE), binary-coded decimal conversion (FX33), and font indexing (FX29). Verify execution against 3-corax+ and 4-flags.
Done when 3-corax+ reports all passes and 4-flags confirms correct VF handling across arithmetic and logic opcodes.
Quirk configuration
Add configuration toggles for the six behaviours detailed in §02, defaulting to original COSMAC VIP behavior. Verify both VIP and modern profiles with 5-quirks.
Done when 5-quirks passes under both COSMAC VIP and SUPER-CHIP configurations.
Input, timers, and audio
Implement the hex keypad mapping, decouple timer decrements to a stable 60 Hz tick, gate a square-wave audio tone on the sound timer, and connect a desktop display window.
Done when playable games like Brix or Pong run at stable speed with responsive input and audio feedback.
Platform extensions or embedded port
Choose an extension path: add SUPER-CHIP and XO-CHIP instructions, or port the core to the RP2350 microcontroller described in §05.
Done when the selected extension passes its test suite or runs standalone on the target hardware.

Diagnostic tools

A ring buffer recording recent (pc, opcode) pairs helps diagnose crashes by dumping trace history on panic or error states. Adding single-step execution and register inspection to the desktop frontend during M3 simplifies tracking bugs before running on microcontroller targets.

Rust workspace architecture

A three-crate Cargo workspace isolates platform-independent emulation logic from platform-specific I/O:

chip8/
├── chip8-core/      #![no_std], zero I/O, all the logic
├── chip8-desktop/   winit + pixels (or minifb)
└── chip8-pico/      #![no_std] + #![no_main], rp235x-hal or embassy-rp

The public API for the core crate requires only a minimal surface:

impl Chip8 {
    pub fn new(cfg: Quirks) -> Self;
    pub fn load_rom(&mut self, bytes: &[u8]);
    pub fn step(&mut self);                 // one instruction
    pub fn tick_timers(&mut self) -> bool;  // true = beep
    pub fn set_key(&mut self, k: u8, down: bool);
    pub fn framebuffer(&self) -> &[u64; 32]; // 1 bit per pixel
}

Implementation choices

Desktop frontends

The aquova guide uses sdl2, which requires linking external SDL2 development libraries. Alternatives include minifb, which manages a window and framebuffer without external C dependencies on macOS or Windows (though Linux requires libx11-dev), or pixels combined with winit for hardware-accelerated presentation.

The Pico 2 track

The Pico-2 with RP2350 is a nice target board for this, easy to get and this project doesn't need wifi/bt, plus Rust easily works with it. The board has dual ARM Cortex-M33 and/or Hazard3 RISC-V cores at 150 MHz with 520 KB of SRAM, and putting a state a machine that needs 4 KB of RAM and 256 bytes of framebuffer so plenty of headroom. If I come across a smaller μC that is easy to get and affordable I might put a guide to get CHIP-8 in Rust on that controller too.

Pick a HAL

OptionStyleTake
rp235x-hal 0.4
rp-rs/rp-hal
Blocking, bare-metal, embedded-hal traitsExposes bare-metal peripherals directly using embedded-hal traits. Now on crates.io — earlier write-ups telling you to vendor the repo are out of date.
embassy-rp 0.9
feature rp235xa
async executor, batteries includedProvides an async executor with built-in timer and USB abstractions. A 60 Hz tick evaluates to a single Ticker instantiation, at the cost of hardware abstraction. Slightly more magic between you and the silicon.

Either works. If the goal is understanding, start with rp235x-hal, if the goal is a working handheld then embassy will get you there faster. I myself started with embassy because that's what I learned embedded Rust with.

Hardware choices

Timing on bare metal

Do not build your loop out of delay_ms. Use the hardware timer to fire a 60 Hz alarm; on each fire, run your N CPU cycles, decrement the timers once, and push the framebuffer to the display. With two cores available you could put the display flush on core 1 and keep the interpreter on core 0 but that sorta feels like overkill for a CHIP-8. The hardware is there so I guess make use of it.

Sequencing advice

Get the desktop version passing the full Timendus suite before you touch the microcontroller. Debugging interpreter logic simultaneously with hardware abstractions via a 128×64 display without logging will be confusing. The port should be a frontend swap, nothing more — and if it isn't, that's useful information about your core.

The aquova book

An Introduction to Chip-8 Emulation using the Rust Programming Language by aquova contains nine chapters, a three-crate workspace (core, desktop, wasm), and bundled test ROMs.

Ideas to use from it

What's stale or missing in 2026

IssueWhat to do
Rust edition 2018 throughoutBump to 2021 or 2024; nothing in the code should break.
rand ^0.7.3 in the core crateReplace with your own xorshift. Required for no_std; this is the blocker for the Pico port.
sdl2 ^0.34.3 for the desktop frontendBump it, or swap for minifb. Expect to install SDL2 natively (brew install sdl2) and possibly fight linker paths on macOS.
wasm-bindgen ^0.2.69 / web-sys ^0.3.46Years old. Bump both sicne the APIs used are stable.
No quirks discussion at allThis is real gap, so do read §02 here and Langhoff's guide alongside chapter 5.
No test ROMs / no test harnessAdd the Timendus suite yourself at M3, don't skip this.
Core is std, not no_stdAdd #![no_std] from the get go. Requires minor modifications, see the BCD row.
Font loaded at 0x000, and FX29 is a bare c * 5 (lib.rs:60, lib.rs:390)The book does not use the conventional 0x50 base. If you follow this guide's layout but the book's FX29, I points at uninitialised memory and scores render as garbage. Pick one convention. Either way add the & 0x0F mask, which the book also omits.
BCD (FX33) uses f32 and .floor() (lib.rs:395-402)Float methods don't exist in core, Float methods do not exist in core, this fails to compile under #![no_std]. Rewrite with integers: vx / 100, (vx / 10) % 10, vx % 10.

Use chapters 1–6 as a structural guide while referencing Langhoff for opcode semantics and the Timendus test ROMs for validation. Implementing the core crate as #![no_std] and removing external rand dependencies early will avoid refactoring during the microcontroller port.

After CHIP-8

This guide covers simple interpretive execution, where an emulator decodes and executes instructions sequentially. While standard for CHIP-8 and introductory projects, other CPU emulation techniques exist along a performance spectrum, including direct-threaded dispatch, dynamic recompilation, and just-in-time (JIT) compilation. High-level emulation (HLE) forms another approach, replacing lower-level hardware execution with native reimplementations of system API calls.

Common subsequent hardware projects, ordered by complexity:

The r/EmuDev subreddit and its Discord community maintain the emudev.org documentation and provide active technical discussion for verifying edge-case hardware behaviors and test results.