In this blog I share my observations, thoughts and experience about computers, linguistics, philosophy and many other things that interest me.

Wednesday, July 01, 2026

gk208-videobios, part 3/8: Teaching a RISC-V Chip to Dream in x86

Third in a series on building a firmware-free VideoBIOS for RISC-V. In Part 2 I got a decades-old x86 emulator to compile and link for RISC-V for the first time. This post is about what happened when I finally let it run — when a RISC-V processor started interpreting, one instruction at a time, the x86 firmware that my graphics card had carried in ROM since the day it was manufactured.


The story so far. My RISC-V machine cannot execute the x86 program that knows how to wake up the graphics card, so I resurrected U-Boot's forgotten bios_emulator and made it build for RISC-V. Making it compile, though, was only ever the easy half.

From "it builds" to "it runs"

There is a particular kind of quiet that settles over you the first time you point an emulator at a piece of firmware that has never in its life been executed by anything but a real Intel-compatible CPU. The GK208's video BIOS had spent years being run only by the machines it was designed for, and now a RISC-V core was going to walk through it byte by byte, pretending — convincingly, I hoped — to be the x86 processor it expected to find.

The mechanics are less mysterious than they sound. The emulator maps the card's ROM image into the classic option-ROM location in emulated memory — segment C000 — sets up the x86 registers the way a real PC firmware would before calling a video BIOS, and starts interpreting at the entry point. The card's firmware announces itself the old-fashioned way, with the option-ROM signature 0x55AA at its very first bytes, followed by the PCIR and NPDE structures that identify it as the BIOS for this particular device. All of that parsed correctly, which was already a small thrill: the emulator and the firmware agreed on what they were looking at.

Then the firmware started executing in earnest, and it did what complex, real firmware always does when it meets a world that is subtly not the one it expects. It crashed.

Fixing the disassembler just to read the map

When an emulated program goes off the rails, the first thing you want is a readable trace — a running account of which instruction executed where, so you can find the exact point at which the firmware's expectations and my emulated machine parted ways. The emulator can produce exactly this, an instruction-by-instruction disassembly of everything it runs, and it became my single most important tool for the entire x86 adventure.

The trouble was that the disassembler itself was subtly broken, in ways that had apparently gone unnoticed for years because nobody had leaned on it this hard. Certain instructions printed garbled — the bit-scan BSF, the whole family of conditional SET instructions, and the near CALL and JMP forms that take an immediate address all came out either mangled or with the wrong target printed next to them. A disassembler that lies to you about jump and call targets is worse than no disassembler at all, because you spend hours chasing control flow that never happened, so before I could trust a single trace I had to stop and repair the emulator's own disassembly of those instructions. It is a strange feeling to fix the map before you can begin the journey, but that is genuinely where a lot of the early time went, and those fixes are among the patches that later made their way upstream.

With traces I could finally believe, two real mysteries came into focus.

The zero hole: code that unpacks itself

The first mystery was a hole. Reading through the ROM image, there is a stretch — roughly from offset 0xb000 to 0xf200 — that is simply full of zeros, and the firmware, at a certain point in its run, would happily jump straight into that empty region and execute nothing, which is to say it would run off into the weeds.

The explanation, once I saw it, was elegant and slightly infuriating. That region is not meant to be empty at runtime, because it holds compressed code, and early in its execution the firmware is supposed to run a decompressor that inflates the packed payload into the hole before anything tries to call into it. The packing turned out to be ordinary DEFLATE, the same algorithm that lives inside every zip file, which meant the card's firmware carries its own little inflate routine and unpacks part of itself into place at boot. On my emulated machine that decompressor was never firing, so the hole stayed zero and the firmware leapt into the void.

Finding where the decompressor lived was not something a static read of the ROM could tell me, because its destination is computed rather than obvious, so I did the thing you do in this situation and set a write-watchpoint across the whole 0xb0000xf200 region, asking the emulator to stop and tell me the instant any instruction wrote into the hole. The moment the unpacker fired, the watchpoint caught it red-handed with its exact address, and from there the region filled in correctly and the firmware could call into code that actually existed.

The card reads its own ROM — and I had given it only half

The second mystery was stranger, and it turned out to be the one that had been quietly poisoning everything.

Buried in the firmware is a routine — I came to know it intimately as the code at 0x1a40 — that is, in effect, an option-ROM reader. It reaches back into the card's own PROM through a hardware window, parses the familiar 0x55AA / PCIR / NPDE structures, and pulls out data the rest of the init sequence depends on. In other words, the firmware does not treat its ROM as a passive image that someone else loads for it; partway through waking the card up, it turns around and reads itself.

Here is where my own shortcut came back to bite me. The ROM image I had been feeding the emulator was a truncated dump — enough, I had assumed, to hold the code that mattered. But when the firmware's own ROM-reader reached past the end of my truncated dump, looking for content that exists on the real card but not in my partial copy, the emulator did the only honest thing it could and read off the end of the buffer, and the whole thing came down with a segmentation fault:

exit 139 (segfault) — reading the real PROM beyond our dump runs past the buffer

The fix was almost embarrassingly simple once I understood the cause, which was to stop being clever and serve the firmware the entire 256 KB PROM exactly as it exists on the card, so that when it read itself it found everything it expected. The truncated dump had been the root of a whole family of failures I had previously blamed on far more exotic causes, and replacing it with the full image changed everything at once.

A clean return

With the full PROM in place and the hole filling itself in, the firmware ran, and ran, and then it did something I had half stopped believing I would see: it finished.

An x86 video BIOS, when it is done, returns to whoever called it with a far return, a RETF, back to the caller's address. Mine did exactly that, jumping from c000:2dcb — deep inside the card's firmware — back out to the return address a real PC firmware would have set, and the emulator exited cleanly with status zero and not a single skipped handler or faked-up redirect along the way. Everything the firmware asked for, it got, and everything it did, it did for real.

The part that made me trust the result was that it was boringly reproducible, run after run landing in the same place with the same amount of work behind it:

run 1: EXIT=0  RETF_hits=2  mem.wr=20041  mmio=4240
run 2: EXIT=0  RETF_hits=2  mem.wr=20041  mmio=4278

Twenty thousand memory writes, a few thousand hardware register accesses, two passes through the return path, and a clean exit both times. In emulation terms, the card's power-on self-test had completed — the firmware had walked its full initialization from entry to exit on a RISC-V processor, which as far as I know had never happened before.

It even tried to say its name

There was one more detail that, more than the clean return itself, made me feel I was genuinely close.

Near the end of its run, just before that final RETF, the firmware calls into a sign-on routine — its banner printer — sitting at 0x2c9e, invoked from 0x2d4c. That routine walks a small table of strings, the ones every one of us has seen a thousand times without thinking, the card's name and the BIOS version and the copyright line, and it prints each of them character by character through a helper at 0x2c6e. And the way that helper puts a character on screen is the most gloriously period-authentic thing in the whole story, because it issues the old INT 10h teletype call, AH=0Eh, through the firmware's own interrupt-10 handler at 0x1afb, pulling the text attribute from a byte the firmware had stashed away earlier — the exact same mechanism by which every PC has printed its boot text since before I ever sat down in front of that PS/2.

So the firmware did not merely initialize the card and return, it ran the complete software path to announce itself, character by character, through the classic video teletype service, precisely as it would have on a real motherboard. On the level of executed instructions, my RISC-V machine had run a real x86 video BIOS from its first byte to its sign-on banner to its clean far return.

The victory that wasn't quite

I want to be honest about how this felt, because it felt like winning. The card's own firmware, running to completion on a processor it was never written for, doing real work against real hardware, walking its banner path on the way out — measured in instructions executed, this was everything I had set out to do in the autumn of 2024, and there were a few evenings there where I let myself believe the hard part was behind me.

It was not, and the reason is a gap that sits at the very center of this whole project, which is the difference between the firmware ran its display code to a clean return and the monitor showed me something. Everything I have described so far happened inside the emulator and inside the card's registers, in a place I could see only through traces and memory dumps, and the one thing I could not produce — the one thing this entire quest was about — was a single readable character glowing on the actual screen in front of me.

Understanding why a fully, correctly, reproducibly initialized card still stared back at me with a black screen is where the x86 road stopped being an engineering problem and started being an education. That is the next post.

Next: The Wall: Why a POSTed Card Still Showed Black.

No comments: