Skip to content
Write-up

Inside the Z80 Emulator

How the Z80 emulator is put together: where the CPU comes from, how 192×128 pixels end up being plain memory, how a browser tab paces a 3.5 MHz clock without melting, and why compiling C needs a second service and a sandbox.

Ionut-Alexandru Popescu · · 11 min read

The Z80 emulator is the largest thing on this site: a whole little computer that boots in a browser tab. You type assembly or C, press Run, and watch a 1976 CPU execute it one instruction at a time, with its registers, its memory and a screen you can draw on.

This is how it's put together — where the CPU came from, why the screen and the buttons are nothing but memory, how a browser tab paces a 3.5 MHz clock without setting your laptop on fire, and why compiling C needed a second service and a sandbox. The site around it is a separate write-up.

What it is

The emulator is a Zilog Z80 with 64 KiB of address space, part of it a memory-mapped display and a byte of buttons. All of it is Rust compiled to WebAssembly, and all of it runs on your machine: once the page has loaded, nothing about running a program involves the server. There is no account needed, no session, no telemetry about your code. What you type stays in the tab (and in localStorage, so it's still there tomorrow).

What you get on the page:

Programs can be stepped one instruction at a time, run until a breakpoint, or paused mid-flight; the whole 64 KiB can be saved to a file and loaded back.

The machine: a CPU and 64 KiB

The CPU is not written for this site. It comes from rs_emu_lib, an emulation library of mine that predates the site by some years, and which the site depends on as a Git dependency pinned to an exact commit. That pin matters: a CPU core is precisely the kind of thing whose behaviour you want frozen while you debug something above it.

The library gives me a Z80 and a Memory built out of devices, which is what makes the memory map a few lines rather than a subsystem:

0x0000 ─ 0x3FEF   16368 B RAM    code, by convention
0x3FF0 ─ 0x3FFE      15 B I/O    reserved, reads 0
0x3FFF                1 B I/O    the buttons, read-only
0x4000 ─ 0x9FFF   24 KiB screen  192 × 128 pixels, one byte each
0xA000 ─ 0xFFFF   24 KiB RAM     data, and the stack growing down

Four devices in a row: RAM, the input registers, the display, RAM. The CPU knows nothing about any of it — it reads and writes addresses, and the device sitting at that address decides what happens. Apart from the input registers, the map is the one the original desktop version of this emulator used, so programs and memory images written for that still run here.

Nothing enforces the convention that code goes low and data goes high. A program is free to execute out of the screen and watch itself scroll past as pixels, which is exactly the sort of thing a Z80 ought to let you do.

A screen that is only memory

There is no video chip, no frame-buffer API, no draw calls. The display is a memory device 24,576 bytes long, one byte per pixel, and drawing a pixel is LD (HL), A. Each byte is a colour in RGB332 — three bits of red, three of green, two of blue:

bit    7  6  5    4  3  2    1  0
       R  R  R    G  G  G    B  B
       └── red ───┴── green ─┴─ blue

0xE0 = red       0x1C = green       0x03 = blue

Eight bits per pixel is a deliberately generous choice for a machine of this vintage — a real Z80 micro would have had a palette and bit-planes and a great deal of cleverness — but it makes the first program somebody writes a three-line loop instead of a research project. Turning those bytes into something a canvas can show is a 256-entry lookup table, computed at compile time by a const function, so there is no conversion cost at startup at all.

Two details make this cheap enough to do sixty times a second:

The canvas itself is 192×128 — its true size — and CSS scales it up with nearest-neighbour, so the pixels stay pixels at any window size. The conversion writes into one reused buffer that is allocated once, which matters more than it sounds: at 60 fps, allocating 96 KB per frame is how you get a sawtooth in a memory profile.

Eight buttons, in one read-only byte

A computer you can only watch is a demo. The emulator has four arrows and four numbered buttons, and they work the way the screen does — not through an API, but as an address. One byte at 0x3FFF, one bit per button:

bit    7  6  5  4    3  2  1  0
       4  3  2  1    R  L  D  U
       └ buttons ┘   └ arrows ┘

AND 0x0F → any arrow held      AND 0xF0 → any button held

Reading a button is LD A, (0x3FFF) and then BIT 0, A for up. The arrows are the low nibble and the numbered keys the high one, so AND 0x0F asks "is any direction held" in one instruction and AND 0xF0 asks the same of the buttons. Bits, not bytes, because several are held at once constantly: a diagonal is two bits, and jumping while running is three. One byte polled once a frame is also the cheapest thing a Z80 can possibly do about input.

It is read-only, and that turned out to be the one decision with teeth in it. Writing to it doesn't silently do nothing, which is what a real machine would do and what would have you staring at a working program wondering why nothing moves — it stops the program, with attempted to write memory at read only address: 0x3FFF in the log. The CPU writes through the same path the library uses for ROM, so an error there aborts the run, and that is exactly the behaviour I wanted: in a debugger, failing loudly beats failing politely.

The same read-only rule applies to the host, not just to programs, and that's less obvious. Loading a program clears all 64 KiB first, and loading a saved memory image writes every address — so if those could write this byte, pressing a key and loading a program at the same moment would hand your program a key that is held and can never be released. The byte refuses the emulator's own writes too. The buttons own it; nothing else gets a say.

Which is also why the memory view shows those sixteen bytes as read-only cells rather than editable ones. They would have taken your typing and shown it back while memory never changed — a small lie, but exactly the kind that costs an hour.

Pressing a key works while a program is running, and the mechanism is the same trick as the screen's: the byte is an atomic behind an Arc, so the pad holds a clone of the same byte the memory map holds. A press writes it directly and never touches the machine — which matters, because during a frame the run loop owns the machine, and because telling every panel "something changed" sixty times a second on the off-chance you're holding an arrow would cost more than the emulation.

The emulator's C example is a two-player Pong built on that byte — one player on the arrows, the other on buttons 1 and 3 — which is as good a demonstration as I can give that a program really does see a key go down while it runs.

Fifteen of the sixteen bytes read zero and do nothing. They are there so that the next input device — a keyboard, sound, a timer — doesn't move the memory map again, which brings us to where this block sits. It is below the screen, not above it, and that is not for elegance: 0xA000 is where the C toolchain puts every program's variables. Putting registers there would have meant relinking every compiled program, regenerating the committed test binaries, and a flag-day where a site and a compiler deployed minutes apart disagree about where data lives — the kind of skew that corrupts silently rather than failing. Sixteen bytes off the top of the code region cost one number in the compiler's size check and nothing else.

An assembler in front of a parser

rs_emu_lib knows how to turn one bare instruction into bytes. That is genuinely most of the hard part — the Z80's encoding is a thicket of prefixes and special cases — but it is not an assembler. An assembler is what turns a file into bytes, and files have labels in them, which means you cannot know what any jump encodes until you know where everything is.

So the site has a small two-pass assembler sitting in front of the library's parser:

  1. Pass one walks the lines, works out how long each instruction is and therefore the address of each one, and so learns where every label points. It doesn't encode anything yet.
  2. Pass two rewrites each operand into the one spelling the library's parser accepts — a hex literal, and a 16-bit value as exactly four hex digits — substitutes the labels it now knows, and asks the parser for the bytes.

On top of that it adds the things programs actually need and a lone instruction doesn't: labels, ORG, EQU, DB / DW / DS for data, comments, and numbers in whatever base you felt like writing them in.

One conversion is worth singling out, because it's the one everybody trips over when they write Z80 by hand. JR and DJNZ don't encode an address; they encode a signed one-byte distance from the next instruction. Writing those by hand is miserable and writing them wrong is silent. The assembler lets you write a target address or a label and works out the displacement, and refuses the ones that are out of reach instead of wrapping them into a jump somewhere surprising. The disassembler does the favour in reverse: a JR is shown with its raw displacement and an arrow to where it actually lands.

Errors come back as a list, one per bad line with its line number, rather than stopping at the first one — if you've mistyped four labels you would like to hear about four labels.

Pacing a 3.5 MHz clock at 60 fps

The clock starts at 3,579,545 Hz, which is the NTSC colour-burst frequency the original machine divided down for its CPU, and which I kept out of sentiment. A browser gives you an animation frame roughly every 16 ms, so each frame has to run about 57,600 cycles. Getting that to feel right took four small rules, all of them in one file:

What the page shows you as the measured clock is a smoothed average of what actually ran — a plain exponential moving average, because an unsmoothed number jitters too much to read. It is also the honest one: set the clock to 50 MHz and you'll watch the measured figure settle well below it, which is the emulator telling you it has run out of frame.

Stepping, breakpoints and disassembly

A debugger is where an emulator earns its keep, and the Z80 is small enough that you can show all of it at once. Everything is live:

The whole 64 KiB can be saved to a file and loaded back, which is the lowest-tech snapshot mechanism imaginable and has never once confused me about what it does.

C, in a service of its own

Assembly assembles in the browser. C cannot: compiling C for a Z80 means z88dk, a real and venerable toolchain of preprocessors, compilers, assemblers and linkers — none of which runs in WebAssembly, and all of which would be running on source typed by strangers. That doesn't belong anywhere near the process serving the website, so it lives in a service of its own:

The toolchain is pointed at the emulator's memory map: code and constants from 0x0000 up to the input registers, data and variables from 0xA000, the stack coming down from the top. zcc will happily build a program far too large for any of that, so the service checks the sizes itself and reports a program that doesn't fit as a diagnostic, which is friendlier than a machine that mysteriously misbehaves because its variables are underneath the screen.

Two niceties fell out of having a real toolchain available. The editor can show you your C as the assembly it compiled to, with the C interleaved as comments — a very good way to find out what a Z80 thinks of your loop — and it can reformat your source, since the same container already had a formatter in it.

All of it in one lazy island

The site renders pages to plain HTML on the server and sends WebAssembly only for the parts that have to react to you. The emulator is the extreme case of that: the CPU, the assembler and the entire debugger UI are one lazy island, compiled into a separate WebAssembly file that only this page ever downloads. Nothing else on the site pays a byte for it.

Inside the island, keeping sixty frames a second was a matter of keeping the machine out of the reactive system. A naive version makes the registers, the memory and the screen all reactive state, and then every instruction notifies everything, and you get a few thousand frames' worth of work per frame. Instead the machine is stored outside it, and a single version counter is bumped once per frame while it runs. Each panel derives what it shows from that counter through a memo that compares its own value — so the registers view only re-renders the registers that changed, and the memory view only the rows that did.

The page is server-rendered as inert markup first, which flips to live when the island starts. It means the layout doesn't jump around while that WebAssembly arrives, and a search engine — which will never run any of it — still gets a page that says what this is.

Things that went wrong

Four that cost real time, and all of which left something behind in the code:

What I'd change next

The honest list. There is no sound, and a Z80 without a square wave somewhere is missing a trick — that's what the next reserved byte is for. Eight buttons is enough to play something but not to type, so a proper keyboard register is on the list too. Interrupts work at the CPU level but nothing on the machine generates them, so there's no vertical blank to synchronise to, and a game has to poll the buttons and guess at the frame. And I'd like symbolic debugging for C, so a breakpoint could be set on a line of source rather than on an address you worked out by reading the generated assembly.

If you want to see what it actually does, the emulator is right there, with an example already in the editor that draws a band of every one of its 256 colours. Press Run. The C tab has a two-player Pong, if you can find somebody to share a keyboard with.

All projects