System log

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

Thursday, July 09, 2026

gk208-videobios, part 8/8 — The Datasheet: Handing a Live Controller to an Operating System

Last in a series on building a firmware-free VideoBIOS for RISC-V. In Part 7 the card drew text I controlled — a grid of cells turned into pixels by a compute kernel on the GPU's own engines, with no firmware anywhere. That made it a video controller. This post is about the part that makes a controller worth building: letting something else drive it.


The story so far. A cold GK208, brought up from nothing by RISC-V code, now shows text I choose, generated on the card's own engines. But it was still my program doing the drawing. A controller that only its author can drive is not a controller.

The thing that draws is not the thing that stays

A video facility at power-on cannot be a program that has to keep running. The moment I began this series for — text on the screen before an operating system exists — works because the firmware draws it and then hands the machine on. So my program's job is narrow, and it ends: cold-init the card, draw the first thing on the screen, and then it is done. What it leaves behind has to be enough for the next stage to take over, because my code will not be there to help.

A block of memory that says a controller is up

Nothing in the GK208's silicon records that a text controller exists. The cell array is memory I chose; the repaint is a kernel I wrote; the hardware knows about neither. So the only authoritative sign that a controller is up is one I publish myself: a block of memory, marked with a private GUID and a magic word, holding everything a consumer needs — where the cells live, how a cell is encoded, where the framebuffer is, how to repaint. I called it the Controller Handoff Block. A consumer does not probe the PCI bus or read a single card register to find it, since a cold card would tell it nothing useful anyway. It looks for the block. Presence is the whole signal.

Exit, and persist

Handing off across an exit has one hard part. The block cannot live inside my program, because the program unloads when it exits — the description would leave with the describer. So the .efi copies the block into memory the firmware keeps after an application is gone, and registers it there as a configuration table. Then it exits. The controller stays up on its own — scanout is autonomous, the picture holds — and the instructions for driving it survive in a pool that outlives the program that wrote them.

The doorbell

Scanout holding by itself is not the same as new text appearing. When a consumer writes new cells, something has to turn them into pixels — wake the compute kernel — and that wake is a small fixed sequence: flush the framebuffer, write one entry into the channel's ring, preempt, then bump the put-pointer. You write all the cells first and ring once, never per character. The last step is where the hardware taught me something I had assumed wrong, and I left the lesson in a comment:

/* Writing GP_PUT here pokes the PBDMA's doorbell — a PRAMIN/VRAM write does not. */

The doorbell is a specific address that means "go," and writing the same value anywhere else in memory does nothing. Ring it and the kernel repaints the grid; write the cells without ringing and the screen never changes.

The loop closes

On the 1st of July 2026 the whole thing ran end to end. A cold GK208: my .efi inits it, draws a logo and a cursor, publishes the handoff block, and exits. Then the next boot stage — mr-bml, the loader that will become part of QSOE — finds the block by its GUID, writes its own menu into the cells, rings the doorbell, and its menu appears on the monitor. My program was already gone. The screen was being driven by software that had never touched the card, only the block of memory I left behind. That is the line between a demo and a controller, and it had finally been crossed.

Writing the datasheet

What is left is the work of making it a thing other software wants to use, and lately that has meant small, deliberate additions to the handoff block. A consumer-writable palette, so the operating system sets its own colours instead of mine. And a transparent cell: one bit that tells the generator to leave a cell's pixels untouched, so a BIOS can cut a rectangle out of the text grid and draw a logo straight into the framebuffer beneath it, with the text intact around the hole. The bit had to come from somewhere, and the cell had one to spare — glyph codepoints never reach 0x2600, so the top bit of every cell was free to take:

[31] transparent   [30:16] symbol (UCS-2)   [15] blink   [14:8] bg   [7:0] fg

In the kernel it costs three instructions: mask the bit, test it, and return without writing if it is set. The GPU documents none of this — not the cells, not the doorbell, not the transparent bit. The handoff block is the datasheet for a controller that exists only because I wrote it, and writing that datasheet down plainly, so someone else can build against it, is the actual product.

Saying hello

Now the RISC-V Personal Computer I've built 5 years ago finally has proper means of announcing information on the screen when it's powered on. It's very satisfying, and it paves the way towards the next challenge: real BIOS, with real POST screen and real Set-Up program.

Tuesday, July 07, 2026

gk208-videobios, part 7/8 — First Pixel: Content I Control, and the Character Generator

Seventh in a series on building a firmware-free VideoBIOS for RISC-V. In Part 6 a cold GK208, driven only by my own modeset, put out a live 720×400 analog signal the monitor recognized — but the picture it scanned held nothing I had put there. This post is about crossing that last gap: making the card show pixels I chose, and then making text with no text mode to lean on.


The story so far. With the x86 firmware gone, my own EVO modeset locked the pixel clock and the monitor read the exact mode back to me. The display pipe was live. What it presented was not — the head was scanning a surface I did not control.

An empty raster

A locked clock gives you timing, not a picture. The head was scanning, the sync was clean, and what reached the glass was whatever happened to sit in the surface the head pointed at — not a framebuffer I had filled. To present pixels I choose, the base channel has to show a real surface out of VRAM with an output LUT in the path, and that is where First Light stopped.

My first instinct was to add it on top of what already worked: keep the modeset that locked the clock, and poke in the base surface and the LUT after the fact.

INVALID_STATE

The display engine refused. The atomic update that added the surface and the LUT came back with an error I would stare at for days — 0x6101f0 reading 0x5080, a type-5 INVALID_STATE — and it refused every variation of the same idea. The reason turned out to be a property of the hardware, not a wrong value of mine: you cannot extend a head that has no coherent surface state by poking new pieces into it. The engine takes exactly one coherent initialization, and half a modeset plus later additions is not a state it will accept.

That is the same shape of wall as Part 4, and the same lesson in a new place: the engine advances when the state is real and whole, and rejects it when it is assembled in pieces.

One commit

So I threw out the bolt-on and built the modeset the way nouveau builds it — as a single atomic commit. Everything the head needs goes in together: mode timing, the core backing surface, the output LUT, the usage bounds, the DAC, procamp and dither and the viewport, all pushed into the 907D core channel; the real framebuffer, at VRAM 0x02000000, pushed into the 907C base channel; and the whole assembly made to take at once by one interlocked UPDATE — the core committing interlocked with the base, the base interlocked with the core, so neither lands without the other. Pushed as one consistent state, the engine accepted it. The wall that had stood for a very long time was gone in the time it takes to issue two methods.

First pixel

On the 25th of June 2026 I painted the letters GK208 into the framebuffer and they stood on the monitor — white glyphs on black, cold card, no nouveau anywhere, no firmware underneath. After First Light, where the monitor knew the mode but showed nothing of mine, this was the first time the screen carried something I had put there on purpose — five characters, and I sat looking at them.

The one register with colour in it

For a while everything I drew was some shade of gray, and I did not question it, because everything I drew was white on black anyway. When I finally pushed a colour through the pipe, the head handed me gray. The saturation had been zero the whole time — Kepler's procamp block wants a specific neutral value, and zero is not it:

push_mthd(0x0498, 1);
push_data(0x00040000);   /* SAT_COS = 0x400 (unity). Writing 0 here
                            zeroes saturation and the head outputs GRAYSCALE. */

One method, one value — 0x400, unity saturation — and the colour I was already sending arrived as colour. It was the one bit of Part 4 again, this time a single register: nothing downstream was wrong, one field upstream was neutralizing everything.

A character generator, not a mode

Here is what a cold Kepler will not give you: a text mode. The legacy VGA text path lights nothing on this card, which Part 6 settled. So text is not a mode you select — it is glyphs you draw, one bitmap at a time, into the framebuffer you now control. The first GK208 was exactly that, done on the CPU: walk the string, look up each glyph in the font, set the pixels.

That prints a logo. It is not a controller. A controller cannot spend a CPU drawing every character of every repaint, so the drawing has to move onto the card's own engines — and on these Kepler parts those engines run on nouveau's open microcode, with no signed firmware blob to load, so I could bring them up firmware-free. To put five letters on a screen I had ended up bringing a 2D engine and then a compute pipeline to life on a cold GPU, a stretch of yak-shaving I can only defend by where it led. It led to a character generator: a compute kernel of my own, hand-written and compiled to the card's native instruction set, that reads a grid of cells and a font and writes the glyph pixels itself. Each cell is one word:

[31:16] symbol (UCS-2)   [15] blink   [14:8] bg   [7:0] fg

The screen becomes a grid of those cells, and turning the grid into pixels is the kernel's work, not the CPU's.

It was never a demo

Once text is a cell array that the card's own engine turns into pixels, the program I had been writing quietly changed into something else. It was not a thing that draws a logo and holds it. It was a video controller — a text surface an operating system could write into and repaint, the way a PC's software has always driven the text console it was handed. I had set out to give my computer a voice at power-on and built, without quite naming it, the controller that voice would speak through. Handing that controller to an operating system — the ABI, the doorbell, the exit — is the last post.

Next: The Datasheet — Handing a Live Controller to an Operating System.

Monday, July 06, 2026

gk208-videobios, part 6/8 — First Light: the Modeset That Finally Locked the Clock

Sixth in a series on building a firmware-free VideoBIOS for RISC-V. In Part 5 a cold GK208 woke with no x86 anywhere in the machine — live chip ID, memory controller, privilege ring — and the monitor still showed black. This post is the modeset I wrote to light it, and the ordering that finally locked the pixel clock.


The story so far. I abandoned the card's x86 firmware and replayed a captured cold-init sequence from C on the RISC-V CPU. The silicon came up firmware-free. The screen did not, because waking the chip and lighting the display are different jobs, and the second one had killed the old road too.

One bit, again

The black screen from Part 4 had a single cause, and it had not changed: the pixel clock never locked. VPLL0's control register at 0x614140 read 0x02010002 on my card and 0x02030002 on a working one — bit 17, the lock bit, clear. On the x86 road I could blame the firmware for it, since it sat on a timed gate waiting for a warm machine mine was not. Now there was no firmware. The sequence going into the display engine was mine, and so was the unlit bit — days into the June 2026 rewrite, with the cold card freshly awake, nothing stood between it and a lit screen but code I had written.

VGA registers light nothing

I started where the capture pointed: program the legacy CRTC and attribute registers for mode 3, set the raster geometry, upload the 8×16 font. On these cards that scans out nothing. The VGA path Kepler carries is vestigial — present for compatibility, wired to nothing that reaches the DAC on its own. The display is driven by EVO, the class-based display channel: you allocate a core channel and push methods into it, and no amount of poking CRTC registers coaxes a raster to life. I had been aiming at the wrong altitude.

A modeset in the open

So I read nouveau again, the same way as before — as a specification, not code to ship. An EVO modeset is a sequence of methods on the core channel: bind a context DMA for the framebuffer, configure the head with the raster timings and the 28.322 MHz pixel clock, attach an output resource, then issue one UPDATE that makes the whole assembled state take at once. I wrote that in my own C, against the golden register values I had read off the working card. The live output on this board is DAC-1, not DAC-0 — a fact that had already cost me an evening in Part 4 — and this time I attached the right one from the start.

The cargo cult I left behind

In Part 4 I had tried to move the display supervisor by faking the signals it waits on, driving the advance myself so the firmware would go and program its clock. That is cargo-cult modesetting: reproducing the motions the state machine expects without establishing the state those motions are supposed to leave behind. It went through some of the phases and never locked. The coherent EVO sequence is the opposite. It puts the engine into the exact state the supervisor is checking for, so the supervisor advances because the modeset is real, not because I mimed the parts it could see.

The thing that locked the clock

Even with the method sequence right, the VPLL stayed unlocked until one more thing happened: the output was attached to the head. The lock is not something I could set by writing the PLL register directly — it follows from binding the DAC to the head that drives it. My lock check after programming the coefficients is deliberately short, because I learned it will never pass this early, and the comment says why:

/* on this board the VPLL does NOT lock until the DAC is attached
 * later in the supervisor handshake, so a long wait here always caps. */
while (!(rd(0x614140) & 0x00020000u) && ++spins < 400)
    usleep(5);

That 0x00020000 is bit 17, the same lock bit that read clear in Part 4. Attach the DAC, and it sets. The ordering has the shape of a rain dance, an arcane sequence performed in a fixed order to placate the hardware, and for a while I treated it as one. It is not voodoo. The clock locks because the output path the PLL feeds is finally whole, and calling that magic would only mean I had not yet understood it.

First light

I ran the cold init from Part 5, then the modeset, on a GK208 that had never been POSTed in its life. 0x614140 read 0x02030002 — the lock bit set, the same value the working card shows at a login screen. The monitor recognized the signal and, in its own on-screen menu, read the mode back to me: 720×400 at 70 Hz, analog — the exact timing I had programmed. There was no x86 running anywhere, no emulator in the binary, no firmware underneath: a RISC-V CPU had taken a stone-cold card to a live raster on its own.

A signal, not a picture

What lit was the pipe, not a picture I had chosen — the display engine scanning out a framebuffer whose contents I did not yet control. That is the honest size of it: the gate that had stopped both roads was open, and the clock the firmware could never lock on my machine locked on the first coherent modeset of my own. Putting content I choose into that raster — text, from a character generator of my own, with no VGA text mode to lean on — is a different problem, and it is the next post.

Next: First Pixel — Content I Control, and the Character Generator.

Saturday, July 04, 2026

gk208-videobios, part 5/8 — Burning the Boats: Abandoning x86 Entirely

Fifth in a series on building a firmware-free VideoBIOS for RISC-V. In Part 4 the card's own x86 firmware ran to a clean far return and the monitor still showed black, because that firmware was written for a warm PC my board is not. This post is about giving up on it — and the better approach that giving up made obvious.


The story so far.I got a RISC-V CPU to run the GK208's x86 video BIOS: full init, sign-on banner, clean far return — onto a card that stayed dark. The firmware was waiting for a machine mine isn't, and more fidelity would not change that.

The last thing I did on the old road

The last commit on the x86 project is not a fix. It is a small tool that only watches: while a known-good driver brings the card up, it logs every register write — address and value — to a text file. I wrote it as one more debugging instrument, but it was the end of the x86 road. With that log, the question answers itself: if I can see every write a working card receives, why run the x86 firmware at all?

What the capture meant

A GPU init is a sequence of register writes. The x86 firmware is one elaborate way to produce that sequence — self-decompressing, ROM-reading, assuming a PC around it — but the card does not care which CPU issues the writes; the same values in the same order bring it up the same way. Everything I had built on the x86 road — the emulator, the disassembler fixes, the decompression hole, the warm-card handshake I never satisfied — existed only to generate that sequence, and I already had it captured.

Reading a driver instead of running a firmware

The known-good driver is nouveau, the open Linux driver for these cards. It mattered as a reference to read, not as code to run — I am not going to ship a Linux kernel to light the screen at power-on. Unlike the legacy VBIOS, nouveau is a cold-start driver: it assumes nothing warm underneath and brings the card up from nothing, in readable C. Where the firmware was a black box, nouveau was a specification. None of it ships; what ships is my own sequence, in my own C, checked against a driver that does the same job in the open.

Burning the boats

The hard part was throwing away work I liked. The emulator I had compiled for RISC-V, the disassembler I had repaired, the patches that went upstream — all of it served running the firmware, and I was deciding that running the firmware was the wrong goal. Not a failed attempt at a good plan, but a good attempt at a plan that could not work, because the firmware wants a PC and my machine is not one. Cortés burned his ships so no one could turn back; a new, empty repository with no x86 in it was the same move. Mostly it was a relief.

A cold init is a sequence of writes

The new program is short to describe: replay the captured sequence from C, on the RISC-V CPU, into the register window. It is not a flat list, because a cold init has to wait for the hardware in places, so the recipe becomes a small op-table of three step kinds — write a value, poll a register until it reads what I expect, wait a fixed time. Each poll is bounded by the GPU's own timer, so a step that never completes fails in milliseconds instead of hanging the machine. Clocks, memory controller, privilege ring: a few hundred lines, no interpreter, no x86 ROM.

Replay is not parroting

A blind replay does not work. Some steps are only values in order, but others depend on the state the card is in when you reach them, and the privilege ring is the clear case: it has to be brought up and re-enumerated before whole regions of registers will answer, and writing to them early returns a fault pattern instead of data. You have to know what each step is for, so that when a captured value does not produce the captured result you can tell whether you are early, wrong, or reading a region the ring has not connected yet.

The card wakes, firmware-free

I powered a cold GK208 that had never been POSTed, ran the op-table, and it came up: PMC_BOOT_0 read a live chip ID, the memory controller started, the privilege ring stood, the register map answered. No x86 ran anywhere, and no emulator was even in the binary.

And the monitor was still black. Waking the silicon and lighting the display are different jobs, and the x86 road had died at the second one — the clock that would not lock, the supervisor stuck at phase one. Burning the boats did not remove that problem; it only meant I would solve it in my own modeset, with no firmware underneath me. That is the next post.

Next: First Light — the Modeset That Finally Locked the Clock.

Thursday, July 02, 2026

gk208-videobios, part 4/8 — The Wall: Why a POSTed Card Still Showed Black

Fourth in a series on building a firmware-free VideoBIOS for RISC-V. In Part 3 the card's own x86 firmware ran to a clean far return on my RISC-V machine, sign-on banner and all. This post is about what happened next: by every register I could read the card was up, and the monitor still showed nothing.


The story so far. I taught a RISC-V CPU to emulate x86 well enough to run the GK208's video BIOS from the first byte to its sign-on banner to a clean far return. Counted in instructions, POST finished. The screen stayed black.

Did the display code even run?

First I had to rule out the boring explanation: that the firmware's display code never actually executed and I'd fooled myself with a nice-looking trace.

The device-init handlers in this firmware are dispatch records with a code pointer a few bytes in. Once I found that structure I put a log on the pointer and watched the display handlers get called and run. So they ran. The problem was past "did it run."

Walking back from the glass

When the monitor is black, you start at the connector and walk backwards, asking at each stage whether the signal is there.

The DAC I could power on. Cost me an evening to learn the live output on this board is DAC-1, not DAC-0. The 8×16 font uploaded into the right plane. The mode-3 raster geometry programmed fine. Everything near the monitor looked plausible.

The signal died at the clock. The pixel clock on this head comes from a PLL the hardware calls VPLL0, and VGA text mode wants it locked at 28.322 MHz. No locked clock, no timing; no timing, no sync; no sync, black — no matter what's downstream. My VPLL0 wasn't locking.

One bit

To see what a locked VPLL looks like, I did what I'd end up doing over and over: booted the card-equipped machine into Linux with nouveau, let it bring up a login screen, and read the display registers off a card that was demonstrably working. That became my golden reference.

The difference was one bit. The VPLL control register at 0x614140 read 0x02030002 on the nouveau card and 0x02010002 on mine — bit 17, the lock bit, set on the one showing a login screen and clear on the one showing black. Everything else in my modeset matched the golden values closely enough not to matter. So it wasn't a wrong color or a font in the wrong plane. The display engine just never crossed from "configured" to "on," and it said so with one unlit bit.

The supervisor stuck at phase 1

That bit stays clear because of the display supervisor — a small state machine in Kepler's display controller that runs a modeset as a sequence of phases and enables the output at the end. Mine stalled at phase 1 and never advanced. No advance, no output enable, no PLL lock, black screen.

I spent days pushing on it. Instrumented the output teardown to find where it wedged. Wrote code to fake the supervisor-advance signals so the firmware would go program its own VPLL — and it did, I got the VBIOS programming the pixel clock itself. Still no lock.

So I took apart the display script the firmware decompresses at runtime (the self-inflating hole from Part 3), decoded its opcode table, and found the instruction that wouldn't let go: a timed condition-wait, one opcode among many, waiting for a hardware condition that on my machine never came true. That was the gate. Nothing was broken and my emulation wasn't wrong — the firmware was correctly waiting for something my cold card never gave it.

Two firmwares in one ROM

What it was waiting for got clearer when I looked harder at the ROM. It isn't one program. The image I'd been running for months is the legacy x86 video BIOS, the option-ROM that answers INT 10h. Sitting next to it in the same ROM is a second, separate image: a 64-bit x86 UEFI GOP driver — the module a modern PC's UEFI loads to drive this exact card.

That re-framed the first image. A GK208 in a real PC isn't brought up by the INT 10h path I'd been so carefully emulating; it's brought up by UEFI running the GOP driver, inside a pile of services and prior init the legacy path has quietly come to depend on. The old text-mode firmware is still there for compatibility, but it no longer carries the whole recipe for taking a stone-cold card to a lit display, because on the machines it actually runs on it never has to.

The warm-card model

This is what I started calling the warm-card model. The firmware doesn't expect the card I was handing it. It expects one that's already partway warm — touched by a real PC's UEFI, by the GOP driver, by the handshake a motherboard does around the video BIOS — and its display path is written for that. On a genuinely cold card with nothing under it but a RISC-V CPU running x86, the supervisor hits its timed gate, waits for a condition only a warm machine would have set up, and never advances.

The thing I was proud of in Part 3 — running the real firmware faithfully, with nothing faked — is exactly why it couldn't finish. A real firmware run faithfully will faithfully wait for the world it was written for.

The floor

This is the wall, and it's worth being clear about which kind it is: not the kind more of the same effort climbs. I could make the x86 emulation flawless, serve every byte of the PROM, match the golden registers digit for digit — and the card would still sit at that gate. The missing piece was never fidelity. It was a whole warm-machine context, a UEFI and a GOP and a PC, that my board isn't and that I had no intention of building. I'd set out to give my computer a small voice at power-on and found that this road ended at rebuilding most of a PC just to say hello.

The last commit on this road

At the very tip of that project's history there's a quiet commit, and I like it in hindsight, because it's where the whole approach turns. After all the fighting to make someone else's firmware finish, the last thing I did was write code to just watch: log the full sequence of register writes a real, working init performs, and save it. I didn't quite see it that night, but that little capture tool was me giving up on running the x86 firmware and starting to ask a better question — if I can see exactly what a working card wants written to it, why do I need the x86 firmware at all?

The rest of the series is the answer. It starts with throwing away everything above.

Next: Burning the Boats — Abandoning x86 Entirely.

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.

gk208-videobios, part 2/8: The Idea, and a Buried Emulator

Second in a series on building a firmware-free VideoBIOS for RISC-V. In Part 1 I explained why I wanted a RISC-V machine that greets its monitor at power-on the way an old IBM PC does. This post is about the first idea I had for how to get there — and the first-in-the-world compile that it needed.


The story so far. I built a RISC-V personal computer — a SiFive HiFive Unmatched with a real NVIDIA graphics card and a real monitor — and it stayed dark until Linux loaded the nouveau driver. I wanted it to speak to the screen before any operating system. The question was how.

The card already knows how to wake itself

Here is a fact that took me an embarrassingly long time to fully appreciate, even though I had known it in the abstract for years:

Every PC graphics card carries its own little program that knows how to bring itself up.

It is called the video BIOS — the VBIOS — and it lives in a small ROM on the card. When an old PC powers on, the motherboard firmware finds that ROM, and runs it. The VBIOS initializes the card's memory, sets up a display mode, wires up the outputs, and hands back a card that will happily show text on a monitor. That is the reason a PC can greet you on screen before it has loaded a single byte from disk: the intelligence to light the display is already sitting on the card, and the CPU simply executes it.

So my card — a GK208, a modest little NVIDIA Kepler, sold as a GeForce GT 710 — already contained, in ROM, a complete recipe for waking itself into a usable display. I did not have to invent that recipe. It was right there.

There was just one problem, and it is the whole reason this series is not three paragraphs long.

The VBIOS is an x86 program. It is machine code for Intel processors. My computer is RISC-V. My CPU cannot execute a single instruction of it.

What if the RISC-V CPU could pretend to be x86?

The idea that started everything was simple to state: if my RISC-V processor cannot run the card's x86 firmware directly, perhaps it can emulate an x86 processor well enough to run it. Interpret the x86 instructions one by one, in software, on the RISC-V core. Slow, yes — but this program runs exactly once, at boot, for a fraction of a second. Speed does not matter. Correctness does.

It felt almost too obvious. Surely, I thought, this is a solved problem. And in a sense, it was — which brings me to the buried treasure.

The emulator that was already there

U-Boot — the bootloader I use on the Unmatched — turns out to already contain a small x86 emulator. It lives in drivers/bios_emulator/, and its lineage goes back to the old XFree86 / SciTech x86emu, a compact interpreter that was used, years ago, to POST video cards on non-x86 machines — PowerPC Macs, some ARM systems — for this exact purpose: running a card's x86 VBIOS on a CPU that is not an x86.

It was, in other words, precisely the tool I needed. It had just been sitting there, more or less forgotten, aimed at architectures nobody uses for this anymore.

The obvious next step: build it for RISC-V and point it at my GK208.

Except, as far as I could tell, nobody had ever compiled bios_emulator for RISC-V. It had support for x86, PowerPC, MIPS, ARM, SH — the architectures of a previous era. RISC-V simply was not in the code. And when I tried to build it anyway, it did exactly what old, architecture-specific code does when you point it at an architecture it has never met: it fell over.

What follows is the story of the small, stubborn fixes it took to make a decades-old x86 emulator compile and link on RISC-V — as best I can tell, for the first time. None of them are large. Several are almost comically small. Together they are the difference between "does not build" and "builds."

Fix 1: teach the assembler how to write a comment

The very first wall was the smallest imaginable. The emulator's header, x86emu.h, needs to know which character starts a comment in the target's assembler — because parts of it emit inline assembly. It had an entry for x86, for ARM, for MIPS, for PowerPC, for SH… and nothing for RISC-V. So the build stopped before it really started.

The fix is one line — really, one word:

#if defined(CONFIG_ARM)
#define GAS_LINE_COMMENT	"@"
-#elif defined(CONFIG_MIPS) || defined(CONFIG_PPC) || defined(CONFIG_X86)
+#elif defined(CONFIG_MIPS) || defined(CONFIG_PPC) || defined(CONFIG_X86) || defined(CONFIG_RISCV)
#define GAS_LINE_COMMENT	"#"

RISC-V uses # for assembler comments, same as x86, MIPS and PowerPC. That was the entire fix. And yet I find it a perfect little emblem of the whole endeavor: the first thing standing between a RISC-V CPU and an x86 BIOS was that the emulator did not know how RISC-V writes down a comment. You have to earn every inch.

Fix 2: MTRRs are an x86 thing

Next, a neighbouring video driver (vesa.c) refused to compile. It used MTRRs — Memory Type Range Registers — which are a specific x86 mechanism for controlling how regions of memory are cached. There is no such thing on RISC-V. The code simply assumed everyone had them.

The fix was to wrap the MTRR use in preprocessor guards so it is compiled only on x86, and skipped everywhere else. Not glamorous, but it is the recurring theme of this entire post: code written when x86 was the only world that needed it.

Fix 3: the PCI config calls had the wrong first argument

Then the emulator's "back-end system" file, besys.c — the part that lets the emulated BIOS talk to real PCI hardware — produced a wall of compiler warnings on RISC-V. A batch of them came from calling U-Boot's pci_read_config_* / pci_write_config_* helpers with the wrong type of first argument. On x86 the old signatures had quietly matched; on a modern RISC-V build they did not. Fixing the argument types cleared a whole class of warnings and, more importantly, made those calls actually correct.

Fix 4: x86 has I/O ports. RISC-V does not.

This one is my favourite, because it is not really a compile fix at all — it is the moment the fundamental mismatch between the two worlds first showed its face.

The x86 architecture has two separate address spaces: normal memory, and a distinct I/O port space that you reach with dedicated IN and OUT instructions. A great deal of old PC hardware — including video cards — is poked through those I/O ports. The emulator models this with a set of macros, PM_inpb / PM_outpb and friends, which on a real x86 turn into actual in/out instructions.

RISC-V has no I/O port space at all: everything is memory-mapped. There is no IN instruction to map those macros onto. And the existing code, on a non-x86 target, did something quietly dangerous: those port accesses would degrade into reads and writes of more or less random memory locations — while also generating a heap of rightful compiler warnings.

I was not ready to properly re-plumb every I/O port access yet, but I absolutely could not leave macros that scribble on random memory. So I replaced them, on non-x86 builds, with small functions that do the safe and honest thing: warn, and move on.

static inline u8 PM_inpb(u16 port)
{
	printf("x86 port 0x%x read attempt, returning 0\n", port);
	return 0;
}

static inline void PM_outpb(u16 port, u8 val)
{
	printf("x86 port 0x%x write attempt, ignoring\n", port);
}

Now, when the emulated BIOS reached for an x86 I/O port that does not exist on my machine, U-Boot would tell me so, plainly, instead of corrupting memory. This turned out to be enormously useful later — those warnings became a running commentary on everything the VBIOS assumed about the machine underneath it. And in hindsight, this little fix is the first faint crack in the whole approach: an x86 BIOS expects an x86-shaped world, and my machine is not one.

Fix 5, 6, 7: includes, malloc, and a BAR that would not fit

Three more, briefly, because bring-up is always a long tail of small things:

  • Missing includes in debug.c. With the emulator's debug tracing turned on, two header files were missing and the compiler complained. Added them. (I would spend a lot of time in that debug tracing later — more on that in the next post.)
  • The SPL malloc pool was too small. The first time I actually asked U-Boot to run the card's BIOS — via its dm_pci_run_vga_bios() path — it stopped dead, reporting that its early-stage memory pool was exhausted. The emulator needs room to work. So I bumped the default pool size when both the emulator and RISC-V are configured.
  • The card's BAR0 did not fit in the memory window. This was a proper head-scratcher. U-Boot's PCI setup was handing the card a small memory region — "region 2," 16 MB, starting at 0x7000000. But the GK208's BAR0 (its main register window) is itself 16 MB and simply would not fit. The fix was to tell U-Boot to use the much larger "region 1" at 0x60090000 instead — which, reassuringly, is the very same address the Linux kernel uses for this card:

    U-Boot should use "region 1" instead (the one which starts at 0x60090000), because it has much bigger size, and easily accommodates BAR0 of the video card. Linux kernel also uses 0x60090000 as bus_start/phys_start.

It compiles — and it went upstream

Somewhere in this sequence, a decades-old x86 emulator quietly compiled and linked for RISC-V, I believe for the first time. That was a genuinely happy evening.

And these were not just private hacks. Several of them I cleaned up and sent to the U-Boot mailing list, and two of them — the malloc-pool bump and the PCI region fix — came back with a Reviewed-by: from a U-Boot maintainer. Small patches, but real ones, now part of the conversation about running video BIOSes on RISC-V. That mattered to me more than the size of the diffs suggests.

But compiling is not running

Here is the sober note to end on. Making the emulator build was the easy half. An emulator that compiles is not an emulator that has successfully brought a graphics card to life. All I had, at this point, was a program that was willing to try — and a card whose x86 firmware had never in its existence been executed by anything other than a real Intel-compatible CPU.

The moment I actually pointed the emulator at the GK208's BIOS and let it start interpreting, a much stranger and harder adventure began — one where I would end up so deep inside the emulator that I had to fix its disassembler just to be able to read what the card's own firmware was doing.

That is the next post.

Next: Teaching a RISC-V Chip to Dream in x86.