FRISC OS: Building a GUI on Bare Metal
Most OS tutorials stop at a blinking cursor. We kept going until we had a composited graphical desktop on bare-metal RISC-V.
Framebuffer discovery
QEMU's virt machine exposes a VirtIO GPU device. We wrote a VirtIO driver that negotiates a framebuffer — a linear array of pixels in memory that the virtual GPU scans out to the display.
The pixel pipeline
Everything in FRISC's graphics stack is pipeline-based:
fn render_frame(windows: Vec<Window>, cursor: Cursor) {
windows
|> sort_by(|w| w.z_order)
|> each(|w| blit(framebuf, w.surface, w.x, w.y))
blit(framebuf, cursor.sprite, cursor.x, cursor.y)
gpu.flush()
}
Font rendering
We embedded a bitmap font (8x16 pixels per glyph) directly in the kernel. Text rendering is a pipeline from string to pixel coordinates to framebuffer writes.
Window management
FRISC uses a simple stacking window manager. Each window owns a pixel buffer. The compositor blits them back-to-front with optional transparency. Mouse events are dispatched by hit-testing the window stack.
Performance
On QEMU with VirtIO, we achieve 30 FPS at 800x600 with 4-5 windows open. The bottleneck is the VirtIO flush, not our rendering code. On real hardware with a proper GPU, this would be significantly faster.
The VirtIO GPU driver
Writing a VirtIO GPU driver from scratch was the biggest challenge of the graphics stack. VirtIO devices communicate through shared-memory ring buffers called "virtqueues":
struct VirtQueue {
descriptors: [VirtDescriptor; 256],
avail_ring: AvailRing,
used_ring: UsedRing,
free_head: u16,
num_free: u16,
}
fn gpu_create_resource(width: u32, height: u32) -> ResourceId {
let cmd = GpuResourceCreate2d {
header: GpuCtrlHeader { type_: RESOURCE_CREATE_2D },
resource_id: next_resource_id(),
format: RGBA8888,
width: width,
height: height,
}
virtqueue_send(&gpu_queue, &cmd)
virtqueue_wait_response(&gpu_queue)
cmd.resource_id
}
The driver negotiates features with the virtual GPU, allocates a 2D resource (the framebuffer), attaches guest memory to it, and then issues TRANSFER_TO_HOST and FLUSH commands to update the display. Each flush is one frame.
Double buffering
Writing directly to the visible framebuffer causes tearing. We use double buffering — two framebuffers that swap on vsync:
let front_buf = Framebuffer::alloc(800, 600)
let back_buf = Framebuffer::alloc(800, 600)
fn render_loop() {
loop {
// Draw everything to the back buffer
back_buf.clear(Color::BLACK)
windows
|> sort_by(|w| w.z_order)
|> each(|w| back_buf.blit(w.surface, w.x, w.y))
back_buf.blit(cursor.sprite, cursor.x, cursor.y)
// Swap buffers atomically
swap(&front_buf, &back_buf)
gpu.flush(front_buf)
// Wait for vsync (16.6ms for 60 FPS)
wait_for_vsync()
}
}
Input handling
Mouse and keyboard input come through VirtIO input devices. We wrote drivers for both:
- Keyboard — VirtIO keyboard sends USB HID scancodes. We translate them to Lateralus key events with modifier tracking (Shift, Ctrl, Alt, Super).
- Mouse — absolute coordinates from VirtIO tablet device (no relative mouse capture needed in QEMU). We track button state for click, drag, and scroll events.
- Event dispatch — input events are dispatched to the focused window. If no window claims the event, it goes to the window manager for global keybindings.
The widget toolkit
FRISC includes a minimal widget toolkit for system UI (task bar, dialogs, menus):
fn create_shutdown_dialog() -> Dialog {
Dialog::new("System")
|> add(Label::new("Shut down FRISC OS?"))
|> add(HBox::new()
|> add(Button::new("Cancel", |_| dialog.close()))
|> add(Button::new("Reboot", |_| arch::reboot()))
|> add(Button::new("Shut Down", |_| arch::poweroff()))
)
|> center_on_screen()
}
The toolkit renders directly to pixel buffers — no X11, no Wayland, no external dependencies. Buttons are rectangles with text. Focus is indicated by a colored border. It's simple, but it's ours and it runs on bare metal.
What we learned
Building a GUI on bare metal teaches you things no framework-based project ever will:
- Every pixel on screen is a number in a memory-mapped buffer. There's no magic.
- Font rendering is surprisingly hard. We started with bitmap fonts because TrueType parsing is a project in itself.
- Compositing is just blitting rectangles in the right order. Alpha blending is one multiply per pixel per channel.
- The hardest part isn't rendering — it's input handling. Getting click targeting, focus management, and keyboard routing right took more time than the entire rendering pipeline.