June 2023 · 9 min read

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:

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:

Lateralus is built by bad-antics. Follow development on GitHub or try the playground.