Anatomy of a Retro BIOS
Designing firmware for emulated retro computers—reset vectors, service calls, UART I/O, toolchains, and the boot theatre that makes it feel real.
On this page
The first BIOS I wrote for the emulator did nothing but echo characters. Input byte, output byte. That was enough to prove the CPU was running, and it took about twenty lines of assembly. Six months later, the same system boots with an AMI-style hardware detection sequence, plays floppy drive calibration sounds through Tone.js, and supports four different CPU architectures—but every one of them still funnels through that same primitive: read a byte, write a byte.
That constraint turned out to be the design. The BIOS is not a boot logo or a splash screen; it is the contract surface between a CPU that knows only where to start and a peripheral world that knows how to speak. Everything else—memory maps, service numbers, calling conventions—exists to make that contract portable.
The reset vector is the first promise
A CPU at reset does exactly one thing: fetch instructions from a fixed address. The BIOS must be waiting there, and it must immediately establish the rules of engagement.
Each CPU family gives you a different doorway:
- Z80: The CPU jumps to
$0000on reset. I placeJP COLD_STARTthere, then useRST 08has the service gate. The service number lives in registerC, and every BIOS call collapses to a singleRST 08hinstruction—eight bytes of code per caller. - 8088: The reset vector at
F000:FFF0jumps into the BIOS image. Services useINT 0x80with the service number inAH, mimicking the Unix system call convention rather than the IBM PC’s scattered interrupt assignments. - 6502: No restart vectors exist, so the BIOS exposes a jump table at
$F000. ProgramsJSRto fixed addresses—$F003forGETC,$F006forPUTC—and the three-byteJMPinstructions redirect into the implementation. - 8008: The CPU only addresses 14 bits and its
CALLencoding is limited, so the BIOS lives in the first 256 bytes with a similar jump table.
Those entry points all funnel into UART I/O, because that is the smallest promise I can make: if a program can read and write characters, it can boot, prompt, and feel alive.
Ports are the BIOS’s real API
The service call is the public door; the I/O ports are the private machinery behind it.
| Architecture | Data Port | Status Port | RX Ready | TX Ready |
|---|---|---|---|---|
| Z80 / 8080 | $00 | $01 | bit 0 | bit 1 |
| 8088 | $3F8 | $3F9 | bit 0 | bit 1 |
| 6502 | $D000 | $D001 | bit 0 | bit 1 |
| 8008 | port 0 | port 1 | bit 0 | — |
The 8088 uses PC-style COM1 addresses so existing serial code would feel at home if ported. The others use low port numbers because they have no legacy to honour.
The BIOS keeps programs away from these ports. That single indirection means I can rewire the UART backend—connecting it to a WebSocket, a mock, or a different peripheral—without recompiling a single program. The port addresses become an implementation detail, and the service calls become the stable interface.
Memory maps are part of the contract
I maintain more than one BIOS image for some architectures, because each one chooses a different memory story, and each story is a different constraint set.
Z80 (C toolchain variant): ROM-at-bottom. Vectors sit at $0000, BIOS code occupies $0000–$1FFF (8 KB), RAM begins at $2000, and the stack grows down from $FFFF. This layout works well for SDCC, which expects to link user code at a configurable origin.
Z80 (classic variant): ROM-at-top. The restart vectors remain in low memory (they must—the CPU hardwires them), but the bulk of the BIOS lives at $F000–$FFFF (4 KB). User programs get the middle, and the layout feels closer to CP/M machines.
8088: A full 64 KB ROM mapped to segment F000. The reset vector at F000:FFF0 jumps to BIOS initialization, and the INT 0x80 vector is installed in the IVT at 0000:0200. This is not how a real PC works—it would use multiple interrupt numbers—but it simplifies the calling convention enormously.
6502: The BIOS occupies $F000–$FFFF (4 KB), with the CPU vectors at the top ($FFFA, $FFFC, $FFFE). Zero page bytes $00–$0F are reserved for BIOS temporaries; user programs must stay above that.
I call this out explicitly because memory layout is not a detail; it is an API. A program compiled for a $2000 TPA cannot magically fit in a ROM-at-top system, and vice versa. The BIOS documentation must state the layout, or every program becomes a guessing game.
The service surface stays small
All BIOSes expose the same core verbs, even when the calling convention differs:
| Service | Z80 (C) | 8088 (AH) | 6502 Entry | Purpose |
|---|---|---|---|---|
BIOS_INIT | $00 | $00 | $F000 | Initialize system |
BIOS_GETC | $01 | $01 | $F003 | Read character (blocking) |
BIOS_PUTC | $02 | $02 | $F006 | Write character |
BIOS_PRINT | $03 | $03 | $F009 | Print null-terminated string |
BIOS_READLN | $04 | $04 | $F00C | Read line with echo |
BIOS_KBHIT | $05 | $05 | $F00F | Check for pending input |
DISK_READ | $40 | $10 | $F05A | Read sector (stub) |
DISK_WRITE | $41 | $11 | $F05D | Write sector (stub) |
That small list is deliberate. It is just enough to boot a monitor, run BASIC, and keep test programs simple. The Z80 and 6502 BIOSes also include string utilities (STR_LEN, STR_CMP, STR_CPY), memory utilities (MEM_COPY, MEM_FILL), and ANSI terminal control (ANSI_CLEAR, ANSI_GOTOXY, ANSI_COLOR)—but those are conveniences, not requirements. The core contract remains: read, write, prompt, respond.
Disk services are stubs. They return error codes and do nothing else. The emulator does not (yet) model disk drives, so implementing real disk I/O would be premature. But reserving the service numbers means future expansion won’t break existing code.
There is a second BIOS, and it is theatre
The browser-side boot sequence is not the ROM BIOS; it is the emotional prelude. It queries the PeripheralManager for owned hardware, prints classic AMI-style detection text, and plays mechanical sounds through the peripheral audio bus. That sequence lives in frontend-bios-boot.ts, and its job is to make the machine feel present before any CPU core wakes up.
A few constraints drive that design:
- Browser audio policy: Browsers block audio until a user gesture. I gate the boot with a “Press any key to power on” prompt so Tone.js can initialize its AudioContext legally.
- Tone.js startup race: Tone.js can hang indefinitely on
start(). I race it with a 2-second timeout and continue silently if it fails. The boot is more important than the sound. - Peripheral audio bus: All boot sounds pass through an 8 kHz lowpass filter. The result is more “mechanical relay” than “hi-fi sample,” which suits the aesthetic.
The boot theatre takes roughly 4–8 seconds depending on owned peripherals. That is slow in modern terms and correct in 1990s terms. Anyone who remembers waiting for a 486 to count its RAM will feel at home.
Toolchains are part of the BIOS too
The BIOS is assembly when it must be and C when it can be:
- Z80 ROMs are assembled with z80asm. The output is a raw binary that gets loaded at
$0000or$F000depending on the variant. - 8088 ROMs are assembled with NASM, which handles 16-bit x86 cleanly and produces flat binaries.
- 6502 ROMs are assembled with ca65/ld65 (the cc65 toolchain), with a linker script that places vectors at
$FFFA. - 8008 ROMs are hand-assembled. The 8008 instruction set is quirky enough that no modern assembler handles it well, so the binary ships prebuilt.
- Z80 user programs are compiled with SDCC and link against a
bios.hheader that provides C wrappers forRST 08hcalls. The wrappers use inline assembly to load the service number intoCand invoke the restart.
I keep the BIOS manuals alongside the binaries—a markdown file documenting every service number, register convention, and memory map. When I return to the code months later, the manual is faster than re-reading the assembly.
What this makes possible
The BIOS is small. That is the point. It keeps the system honest by declaring the minimum set of truths the CPU can rely on, and then it gets out of the way.
But “small” does not mean “trivial.” Settling on a stable service surface across four architectures took longer than writing the implementations. The temptation to add features—command history, tab completion, a built-in hex viewer—kept pulling the design toward complexity. Each time, I asked: does this belong in the BIOS, or in a program that runs on top of it?
The answer, usually, was “on top of it.” The BIOS is not an operating system. It is a set of promises that make operating systems—and monitors, and games, and test harnesses—possible. The less it does, the more room it leaves for the code that follows.
That framing changed how I think about firmware in general. A good BIOS is not the thing you notice. It is the thing that makes noticing possible.