Anatomy of a Retro BIOS
Designing firmware for emulated retro computers—RST vectors, UART I/O, C-to-binary toolchains, and why floppy drives need to make noise.
Anatomy of a Retro BIOS
The firmware nobody sees, doing the work everybody expects
When you power on a vintage computer, something has to happen before your program runs. The CPU wakes up in a state of confusion—it doesn’t know where to find memory, how to talk to a keyboard, or what a “disk drive” even is. It just starts executing whatever instruction happens to be at address zero (or, for x86 systems, whatever’s at the reset vector near the top of memory).
That’s where the BIOS comes in. Basic Input/Output System. The firmware that makes hardware usable. And when you’re building an emulator that pretends to be a 1980s computer, you need one too.
I’ve now written three different BIOS implementations for emulator.ca—one for the Z80, one for the 8080, and one for the 8088. They’re all slightly different, because the CPUs are all slightly different. But they share a common philosophy: provide a clean abstraction layer so programs don’t have to care about hardware details.
Let me show you what goes into building firmware for computers that never existed.
What Is a BIOS Anyway?
A BIOS is fundamentally an API for hardware. Instead of your BASIC interpreter needing to know the exact I/O port address of the UART transmit register, it just calls “print character.” The BIOS handles the messy details.
On real vintage hardware, the BIOS lived in ROM—a chip that contained code burned in at the factory. You couldn’t change it without replacing the chip. (Some adventurous souls did exactly that, but I digress.) The BIOS provided services for:
- Console input/output (reading the keyboard, printing to the screen)
- Disk I/O (reading and writing sectors)
- System information (how much RAM, what peripherals exist)
- Low-level hardware control (setting video modes, initializing devices)
For emulator.ca, I don’t have real hardware to abstract. Instead, the BIOS provides a bridge between the emulated CPU and the JavaScript world that surrounds it. When a Z80 program calls the “print character” service, the BIOS catches that call and routes it to the browser’s terminal emulator.
It’s turtles all the way down, really.
The Boot Sequence
Every BIOS starts with the same problem: the CPU just woke up and needs to be told what to do. Here’s what happens when you “power on” an emulated Z80:
Power-On Self Test (POST)
Real computers did extensive hardware testing at boot. Memory checks. Peripheral enumeration. Beep codes if something was wrong. My emulated BIOS does… considerably less, since there’s no actual hardware to fail.
But I still go through the motions:
/**
* bios_init - Initialize BIOS subsystems
*
* Currently just initializes the UART. In a real system, this would
* initialize all hardware and subsystems.
*/
void bios_init(void) {
uart_init();
}
That uart_init() call is essentially a no-op in an emulated environment—the JavaScript UART is always ready. But the structure is there for when (if?) I ever implement more complex hardware simulation.
Memory Detection
On real machines, the BIOS would probe memory to figure out how much was installed. Walk through addresses, write a pattern, read it back, see if it stuck. Classic stuff.
My Z80 BIOS just… knows:
/**
* sys_mem_size - Return total system memory in KB
*
* Returns: Memory size in KB (always 64 for this system)
*/
uint16_t sys_mem_size(void) {
return 64;
}
Yeah, I hardcoded it. The emulator always has 64KB of memory. Sue me.
The Startup Banner
Here’s where things get more interesting. A proper vintage computer printed something when it booted—a version string, maybe some system information. It told you the machine was alive and ready.
void sys_info(void) {
bios_print(sys_name);
bios_print("\r\n");
bios_print(sys_copyright);
bios_print("\r\n");
bios_print(sys_cpu);
bios_print("\r\n");
bios_print(sys_memory);
bios_print("\r\n");
}
Simple string printing. Nothing fancy. But it gives the user that moment of recognition—yes, this thing is working.
Peripheral Enumeration
And here’s where emulator.ca diverges from the purely functional. Real computers detected whatever hardware was physically present. My emulator detects what the user owns.
The BIOS queries PeripheralManager to see what storage devices the user has acquired through the BBS economy. If they own a 5.25” floppy drive, the boot sequence shows “Detecting Floppy A:…” and plays the head calibration sound. If they have a hard drive, they get seek clunks during “Detecting Primary Master…”
This isn’t strictly accurate to how vintage BIOS worked. But it’s emotionally accurate. That moment of watching the BIOS probe for devices, hearing each one respond—that’s the experience I wanted to recreate.
The Satisfying Startup Sounds
Speaking of sounds, let me tell you about the best feature nobody asked for: authentic startup sounds.
When the BIOS detects a floppy drive, it plays the head calibration sweep—that rhythmic stepping sound as the head moves from track 80 back to track 0. It’s pure nostalgia encoded in Tone.js oscillators.
Hard drives get seek clunks. The BIOS pretends to read the MBR and shows fake sector counts while playing synthesized head movement sounds. Is any of this functional? No. Does it feel right? Absolutely.
The audio is routed through globalPeripheralBus, which means it respects the master volume control and can be muted if the user prefers silence. Some people don’t want to hear floppy drives at 2 AM. I understand. (I disagree, but I understand.)
Multiple BIOS Implementations
I maintain three separate BIOS implementations. This is either dedication or insanity, depending on your perspective.
Z80 BIOS (C with SDCC)
The Z80 BIOS is my most complete implementation. I wrote it in C and compiled it with SDCC (Small Device C Compiler), which targets 8-bit processors. The result is actual Z80 machine code that runs on the emulated CPU.
Here’s the memory layout:
Memory Layout:
0x0000-0x1FFF ROM (8 KiB) - Code, constants, reset vectors
0x2000-0xFFFF RAM (56 KiB) - Data, BSS, heap, stack
The first 8KB is “ROM”—it contains the BIOS code and RST vectors. The remaining 56KB is RAM for user programs. This split mirrors real Z80 systems where the BIOS lived in ROM at the bottom of the address space.
The Z80’s RST instructions are single-byte calls to specific addresses. RST 00h jumps to 0x0000, RST 08h jumps to 0x0008, and so on. I use RST 08h as the system call interface:
; RST 08h - BIOS service call
; All BIOS functions called via this vector
; Service number passed in C register
.org 0x0008
RST_08:
jp BIOS_DISPATCH ; Jump to BIOS dispatcher
.ds 5 ; Padding
Programs put the service number in the C register and execute RST 08h. The BIOS dispatcher figures out which function to call.
8080 BIOS with UART via RST 1
The Intel 8080 BIOS is simpler, mostly because I built it later and learned from the Z80 experience. It uses RST 1 for UART I/O—a convention that dates back to CP/M.
The 8080 lacks some Z80 niceties (no alternate register set, no IX/IY indexing), but the basic structure is the same: service number in a register, RST instruction, BIOS handles it.
8088 BIOS for PC Compatibility
The 8088 BIOS is a different beast entirely. x86 architecture uses interrupt vectors differently—the BIOS lives at the top of the address space (segment F000), and services are called via software interrupts.
; INT 0x80 Handler - Dispatch BIOS services
; Service number in AH register
int80_handler:
STI ; Enable interrupts during handler
PUSH BX
PUSH CX
PUSH DX
; ... save other registers ...
; Dispatch based on service number in AH
CMP AH, BIOS_INIT
JE .do_init
CMP AH, BIOS_GETC
JE .do_getc
CMP AH, BIOS_PUTC
JE .do_putc
; ... more comparisons ...
I use INT 0x80 (Unix-style) rather than the traditional INT 10h/13h/etc. because this isn’t trying to be PC-compatible—it’s trying to provide a clean interface for my emulated environment.
The reset vector at F000:FFF0 jumps to the BIOS initialization code. This is where a real PC starts execution, and my emulator respects that convention.
UART/Serial I/O
All three BIOS implementations eventually need to do one thing: move bytes between the program and the outside world. That’s what the UART (Universal Asynchronous Receiver/Transmitter) code does.
Port-Based I/O
The Z80 and 8080 use port-based I/O. You read from or write to numbered I/O ports, which are separate from the memory address space. Here’s the Z80 UART driver:
; Hardware port definitions
UART_DATA = 0x00
UART_STATUS = 0x01
UART_RX_READY = 0x01
UART_TX_READY = 0x02
;------------------------------------------------------------------------------
; _uart_putc - Write byte to UART
;
; C prototype: void uart_putc(uint8_t ch);
;------------------------------------------------------------------------------
.globl _uart_putc
_uart_putc:
; SDCC passes the character in A register
out (UART_DATA), a ; Output to UART port
ret
Two ports: one for data, one for status. The status port tells you if there’s data waiting to be read (bit 0) or if the transmitter is ready (bit 1). Simple and elegant.
On the JavaScript side, when the emulated CPU writes to port 0x00, the emulator catches that operation and routes the byte to the terminal. When the CPU reads the status port, the emulator reports whether any input is queued.
RST Vectoring for I/O Calls
Programs don’t call the UART directly—they go through the BIOS service interface. This abstraction is crucial. If I later decide to change how UART works (different ports, different protocol), programs don’t need to change.
; Example: print "Hello" via BIOS
ld hl, message ; Pointer to string
ld c, #0x03 ; BIOS_PRINT service number
rst 0x08 ; Call BIOS
message:
.asciz "Hello, World!\r\n"
The RST instruction is compact (single byte) and fast. It’s why CP/M used this pattern, and why I copied it.
Memory-Mapped I/O (8088)
The 8088 BIOS uses port-based I/O too, but at different addresses. PC-style systems traditionally put the UART at ports 0x3F8-0x3FF (COM1). I follow this convention even though there’s no real hardware:
; Hardware I/O ports
UART_DATA EQU 0x3F8 ; UART data register
UART_STATUS EQU 0x3F9 ; UART status register
; BIOS_PUTC - Put character to UART
bios_putc_impl:
MOV DX, UART_DATA
OUT DX, AL ; Write character
RET
Same concept, different address, slightly different syntax.
Peripheral Detection
Here’s where the emulator’s BIOS gets weird (good weird, I think).
Dynamic vs Hardcoded Detection
A real BIOS probes hardware to see what’s present. My BIOS queries the JavaScript PeripheralManager:
// Pseudocode - the actual implementation is more complex
const ownedPeripherals = peripheralManager.getOwnedPeripherals();
if (ownedPeripherals.includes('floppy-525')) {
displayDetecting('Floppy A:');
await playHeadCalibration();
displayDetected('360KB');
}
if (ownedPeripherals.includes('hard-drive')) {
displayDetecting('Primary Master');
await playSeekSounds();
displayDetected('40MB');
}
The BIOS adapts to what the user owns. First-time visitors see a minimal boot. Power users with full peripheral collections get the full experience—multiple drive detections, longer boot sequences, more sounds.
The Drive Calibration Sounds
When you insert a floppy disk in a real drive, the head calibrates itself by seeking to track 0. It steps inward until it hits the track 0 sensor, confirming its position. This makes a distinctive rhythmic stepping sound.
My emulator synthesizes this with Tone.js:
// Floppy head calibration sweep - track 80 to track 0
async playHeadCalibration() {
const stepDelay = 12; // milliseconds between steps
const numSteps = 80;
for (let i = 0; i < numSteps; i++) {
// Generate a short click at slightly varying frequency
// Real drives had mechanical variance
const freq = 800 + (Math.random() * 100);
this.playTone(freq, 0.005);
await delay(stepDelay);
}
}
The frequency variance simulates the mechanical imprecision of real drives. No two calibrations sound exactly the same. It’s a small detail, but it makes the experience feel more authentic.
Hard drives get similar treatment—seek sounds that vary based on “distance” traveled, spin-up whines, the occasional clunk.
Browser Audio Challenges
Synthesizing retro computer sounds in a browser is harder than it should be.
Autoplay Policies
Modern browsers block audio playback until the user interacts with the page. This is a reasonable policy (nobody wants websites playing sounds immediately), but it wreaks havoc on boot sequences.
The BIOS can’t play startup sounds if Tone.js hasn’t been initialized. And Tone.js can’t initialize without user interaction.
My solution: a power button.
Before the boot sequence starts, users see a power button. Clicking it satisfies the browser’s audio policy and lets the boot sequence proceed with sound. It’s not historically accurate (vintage computers didn’t have “click to start” dialogs), but it’s the best compromise between authenticity and browser reality.
The Tone.js Initialization Timeout
Even with user interaction, Tone.js initialization can fail or hang. Maybe the AudioContext doesn’t start properly. Maybe WebAudio is disabled. Maybe something else goes wrong.
I added a timeout:
async initAudio(): Promise<boolean> {
try {
// Give Tone.js 3 seconds to initialize
const result = await Promise.race([
Tone.start(),
timeout(3000).then(() => { throw new Error('Timeout'); })
]);
this.audioReady = true;
return true;
} catch (e) {
console.warn('[BIOS] Audio init failed, continuing without sound');
this.audioReady = false;
return false;
}
}
If audio fails, the boot sequence continues silently. The experience is degraded but not broken. Users without working audio still get their emulated computer; they just don’t hear the floppy drives.
The Onboarding Flow
First-time visitors get special treatment. Before they even see the BIOS, they see ASCII art welcome screens.
The FirstVisit class displays a series of screens introducing the emulator:
- Welcome banner with ASCII art logo
- Brief explanation of what this is
- List of suggested phone numbers to dial
- “Press any key to continue”
Only after completing (or skipping) onboarding does the BIOS boot sequence begin. This creates a clean progression: onboarding teaches you what the system is, then the BIOS shows you the system coming to life.
The ASCII art is nothing fancy—just careful arrangement of text characters. But there’s something charming about an elaborate welcome screen rendered in 80-column monospace. It feels right for a retro computer emulator.
Building BIOS Images
Writing C code is one thing. Getting it to run on an emulated Z80 is another.
The SDCC Toolchain
SDCC (Small Device C Compiler) targets 8-bit processors like the Z80, 8051, and others. It takes C code and produces assembly, which then gets assembled and linked into a binary.
My build process:
# Compiler flags
CFLAGS = -mz80 --std-sdcc99 --opt-code-speed --no-std-crt0 -I$(INC_DIR)
# Linker flags - Code starts at 0x0000 (ROM), data at 0x2000 (RAM)
LDFLAGS = -mz80 --no-std-crt0 --code-loc 0x0000 --data-loc 0x2000
# Build steps:
# 1. Assemble crt0.s (startup code with RST vectors)
# 2. Compile C source files
# 3. Assemble remaining assembly files
# 4. Link using custom linker script
# 5. Convert to binary format
The --no-std-crt0 flag is important—I provide my own startup code that sets up the RST vectors properly. SDCC’s default CRT0 doesn’t know about my custom memory layout.
C to Assembly to Binary
Let’s trace what happens to bios_print():
// io.c
void bios_print(const char *str) {
if (str == NULL) {
return;
}
while (*str != '\0') {
uart_putc(*str);
str++;
}
}
SDCC compiles this to Z80 assembly:
_bios_print:
; HL = pointer to string (SDCC calling convention)
ld a, h
or l
ret z ; Return if NULL
_bios_print_loop:
ld a, (hl) ; Load character
or a
ret z ; Return if '\0'
call _uart_putc ; Print it
inc hl ; Next character
jr _bios_print_loop
Then the assembler produces relocatable object files, the linker combines them with the correct addresses, and makebin converts the Intel HEX output to a raw binary.
The final bios.bin is exactly 65536 bytes—a complete 64KB memory image with the BIOS at the bottom and empty RAM above it.
Memory Layout Constraints
The Z80 BIOS has to fit in 8KB. That’s the “ROM” region from 0x0000 to 0x1FFF. The RST vectors at the very bottom take up the first 64 bytes, then the cold start routine, then the dispatcher, then all the service implementations.
It fits. Barely. The current BIOS compiles to about 4KB, leaving room for future expansion. But I’m always conscious of that 8KB limit when adding features.
The RAM region starts at 0x2000. User programs load at 0x2100 (leaving a small gap for BIOS data structures). The stack starts at 0xFFFF and grows downward. Classic Z80 memory organization.
Lessons Learned
Building three BIOS implementations taught me a few things:
1. Abstractions Matter
The whole point of a BIOS is to hide hardware details behind a clean interface. Even in an emulator where the “hardware” is JavaScript, this abstraction pays off. I can change how UART works without touching any of the game backends that use BIOS services.
2. Authenticity Is a Feeling
The floppy calibration sounds, the boot banners, the peripheral detection messages—none of this is strictly necessary. The emulator would work fine without it. But these details create an emotional connection. They remind users what vintage computing felt like, not just what it did.
3. Browser Audio Is a Minefield
WebAudio APIs are powerful but finicky. Autoplay policies vary between browsers. Audio context state can be unpredictable. Always have a fallback, always have a timeout, never assume audio will just work.
4. C Is Still Useful for 8-Bit
SDCC isn’t perfect—its Z80 code generation could be more efficient—but being able to write BIOS functions in C instead of assembly is a huge productivity win. The dispatcher has to be assembly (it manipulates registers directly), but everything else can be high-level.
5. Documentation Matters
I wrote proper manuals for these BIOS implementations. The emulator includes documentation for each architecture, complete with service numbers, register conventions, and example code. Future me (or future contributors) will thank present me for this.
The Sound of Booting
There’s a moment during the emulated boot sequence where everything comes together. The POST messages scroll by. The peripheral detection strings appear one by one. Floppy drives step and click. Hard drives spin up with that characteristic whine.
It takes about four seconds, give or take. A real vintage computer would boot in similar time (sometimes longer, depending on the memory test).
Those four seconds are pure theater. The emulator could skip straight to a running system. But watching a computer boot—hearing it probe its hardware and announce what it found—that’s part of the experience.
The BIOS makes that moment possible. It’s the firmware nobody sees, doing the work everybody expects.
See also: Deep Dive: KCS Cassette Storage — another vintage peripheral implementation
See also: Deep Dive: Audio Bus Architecture — how all those BIOS sounds get routed
BIOS documentation available at emulator.ca