Skip to main content
Back to articles
2026 / 02
| 6 min read

Inside Five CPU Emulators

Five classic CPU cores in Rust, compiled to WebAssembly: 8008, 8080, 8088, Z80, and 6502, and the edge cases that keep emulation honest.

emulator cpu rust webassembly 8008 8080 8086 z80 6502 retrocomputing

I didn’t set out to build five CPU emulators. I set out to build one — the 6502, because that’s where everyone starts — and then kept finding reasons to add another, until I had a pile of Rust crates all pretending to be microprocessors from the 1970s.

Once you squint, these chips are far more alike than different. Each one reads bytes, does something, and tells you how long it took. I ended up with a loose contract across all five: step() runs one instruction and returns a cycle count, get_state() dumps everything to JSON, set_state() restores it. It isn’t a Rust trait — I kept meaning to formalize it and never did — but it’s consistent enough that save states and debugging work the same way everywhere.

Whether that abstraction holds up under harder cases, I don’t know. It works for what I’m doing, and I expect someone with more emulation experience would find holes in it.

The 6502 came first

I started with the MOS 6502 because it’s the classic beginner’s target, and because the Apple II and Commodore 64 nostalgia runs deep. It’s a strange little chip: only three registers that matter (A, X, Y), but a rich set of addressing modes that let you treat zero page almost like extra registers.

The addressing modes took most of my time. Zero page gives you fast 8-bit lookups, absolute gives you the full 64KB, and then there’s indexed indirect and indirect indexed, which sound similar and behave nothing alike. (I had them backwards at least three times before the tests caught it.) The 6502 also has the famous bug where JMP ($xxFF) wraps within the page instead of crossing into the next one: jump indirect through $12FF and it reads the low byte from $12FF and the high byte from $1200, not $1300. Real software depends on that, so you emulate it.

I also implemented decimal mode for BCD arithmetic, and the undocumented opcodes that aren’t in any datasheet but that some games and demos use anyway. Emulate what the silicon did, not what the manual claimed.

Then the Z80, because CP/M

Once the 6502 worked I wanted something more complex. The Z80 is what the 8080 wanted to be when it grew up: the same basic register layout, plus shadow registers for fast context switches, indexed addressing via IX and IY, and three interrupt modes.

The shadow registers are the interesting part. EX AF, AF' swaps A and flags; EXX swaps BC/DE/HL with their primed counterparts. It’s hardware support for interrupt handlers that need to preserve state without pushing everything to the stack. The core tracks both register banks and which one is current, which adds bookkeeping but isn’t conceptually hard.

The undocumented instructions were hard. The Z80 has around forty opcodes Zilog never documented that work consistently across silicon revisions — accessing the high and low halves of IX and IY as separate 8-bit registers (IXH, IXL, IYH, IYL), or the DDCB and FDCB prefix combinations where the result gets copied into a register even when the opcode doesn’t look like it should. I ran the emulator against raxoft/z80test overnight and woke up to a list of failures that took a full day to work through. If your emulator disagrees with real hardware, your emulator is wrong.

The 8088 for DOS nostalgia

The Intel 8088 is where it got messy. It powered the original IBM PC and introduced segmented memory: a 20-bit address space reached through 16-bit registers and 16-bit segments, where physical = (segment << 4) + offset. That one formula produces the entire DOS memory map, aliasing included, where the same physical address is reachable through multiple segment

pairs.

I spent more time on flag behaviour here than on any other core, particularly the auxiliary carry flag, which tracks carries out of bit 3 for BCD operations. I ran the SingleStepTests suite and watched the pass rate climb from 74% to 96% as I fixed one flag edge case after another. A handful of obscure instructions I’m still not confident about; the common ones work.

The BIOS uses INT 0x80 instead of the DOS-style INT 10h/INT 13h vectors. That’s historically inaccurate and gives me a cleaner service interface. If you want to run actual DOS programs, you’ll be disappointed. If you want to write assembly that talks to a simulated serial port, it works.

The 8080 filled in the gap

I built the 8008 before the 8080, which was a mistake. The 8008 — Intel’s first 8-bit processor, from 1972 — is so limited it’s barely useful: 14-bit addressing caps you at 16KB, the stack is an 8-level internal array rather than RAM, and there are no RST interrupt vectors to hook. I built it for completeness and because I wanted to know how minimal a CPU could be and still be programmable.

Then I found out I’d got the instruction encoding wrong. The 8008 numbers its registers differently from the 8080 family: A is 000 on the 8008 and 111 on the 8080. I’d used the 8080 encoding because that’s what I’d been staring at. Rewriting the opcode decoder meant replacing instructions that don’t exist on the 8008 (like INR A) with the ones that do (like ADI 1).

The 8080 came after, and it’s the 8008 grown up: a real stack pointer in RAM, a 64KB address space, an auxiliary carry flag for BCD math. Once the stack pointer is real, the BIOS can use ordinary CALL and RET, and interrupts work sensibly. The chip stops being a fancy calculator. I ran the 8080EXM diagnostics, which execute somewhere near 24 billion cycles, and found an auxiliary carry bug in the decrement instruction that had been there since the first commit. (Inverted. AC should be set when there’s no borrow from bit 4.)

What I actually learned

Five cores is more than any one project needs. Building them did teach me which parts of CPU emulation are universal and which are personality. Universal: step, count cycles, serialize. Personality: the addressing modes, the undocumented opcodes, the bugs that software depends on.

Cycle accuracy mattered more than I expected. Every core returns cycle counts from step(), and those numbers drive UART pacing, video timing, anything pretending to have real time. Get them wrong and audio drifts, video tears, and the test suites start lying to you. Overnight test runs kept producing commit messages like “fix AF flag clearing in update_flags_logic” and “fix run_cycles() to properly wake from HALT state on interrupt.”

The whole thing compiles to WebAssembly, which is convenient for browser embedding but wasn’t a design driver. I’d have made the same architectural choices targeting native code; WASM just means nobody has to install anything.