display: hot-attach a virtio-gpu native backend over the GOP floor (v2 V4)

The compositor now boots on the GOP framebuffer and upgrades to the virtio-gpu driver
the moment it announces itself — the pluggable-scanout payoff.

The shared surface. The scanout resource is an shm region the driver creates
(shm_physical, a new syscall, hands it the guest-physical for attach_backing) and passes
to the compositor as a capability. The compositor maps it and composites straight into
it: on x86 DMA is cache-coherent, so the cacheable shared pages the CPU paints are exactly
what the device transfers-and-flushes — no copy, no explicit flush.

The handshake. After bring-up the driver looks up .display and sends attach_scanout with
the geometry + the surface capability. The compositor maps the surface, looks up the
driver's .scanout endpoint itself (the driver registered it — no need to pass it), switches
to backend.VirtioGpu, and re-composites the current frame. present() over the native
backend is a present request on .scanout -> transfer-to-host + resource flush. The first
native present is deferred to a one-shot timer: presenting inline from the announce handler
would deadlock, since the driver is still blocked on our reply and not yet serving .scanout.
After it lands, the compositor reads a pixel back from the shared surface to confirm the
frame reached the device's backing.

- shm_physical (syscall 36) + runtime.shm.physical.
- scanout-protocol (the compositor->driver present channel), separate from the
  client-facing display protocol; the display protocol gains attach_scanout.
- backend.VirtioGpu joins backend.Gop in the tagged union; select() still boots GOP.
- the virtio-gpu driver's scanout backing is now shm (was DMA); it announces + serves
  .scanout present requests (transfer-to-host + flush of the shared surface).

Also fixes a latent framebuffer-geometry corruption the display service hit only when it
enumerated the device tree alongside a busy device-manager: Gop.init now captures the
geometry into a small value the instant device_enumerate returns (rather than re-reading
the 328-byte descriptor across the later claim/mmio_map syscalls) and retries on a zero
geometry. The underlying device-table clobber is a separate kernel bug, tracked apart.

Gate: python3 test/qemu_test.py display-native (QEMU -device virtio-gpu-pci) — "display:
scanout upgraded to virtio-gpu" + "display: native present verified" + "display-demo: ok",
passing 3/3. host tests, display-service, display-demo, shm, and virtio-gpu still pass.
This commit is contained in:
Daniel Samson
2026-07-14 12:21:37 +01:00
parent 6e0e0a62c6
commit 58927ed7e5
13 changed files with 379 additions and 58 deletions
+66 -13
View File
@@ -11,6 +11,8 @@ const compositor = @import("compositor.zig");
const system = runtime.system;
const device = runtime.device;
const ipc = runtime.ipc;
const scanout_protocol = runtime.scanout_protocol;
const Rect = compositor.Rect;
const Surface = compositor.Surface;
@@ -34,11 +36,23 @@ pub const Gop = struct {
pitch: u32,
format: u32,
fn findDisplay() ?device.DeviceDescriptor {
/// The framebuffer's id and geometry, captured together. `findDisplay` reads these out of
/// the enumeration table and returns them by value, so the caller never re-reads the table
/// across later syscalls (`device_enumerate` writes the whole table straight into this
/// process's memory; reading a descriptor's tail again after other syscalls have run is a
/// window we simply avoid by copying the few fields we need up front).
const Found = struct { id: u64, width: u32, height: u32, pitch: u32, format: u32 };
/// The first `display`-class device with a *valid* (non-zero) geometry, or null. A zero
/// geometry is treated as "not ready yet" so the caller retries — a real framebuffer always
/// has a non-zero width, height, and pitch.
fn findDisplay() ?Found {
const total = device.enumerate(&device_table);
const n = @min(total, device_table.len);
for (device_table[0..n]) |d| {
if (d.class == @intFromEnum(device.DeviceClass.display)) return d;
for (device_table[0..n]) |*d| {
if (d.class != @intFromEnum(device.DeviceClass.display)) continue;
if (d.display.width == 0 or d.display.height == 0 or d.display.pitch == 0) continue;
return .{ .id = d.id, .width = d.display.width, .height = d.display.height, .pitch = d.display.pitch, .format = d.display.format };
}
return null;
}
@@ -48,7 +62,7 @@ pub const Gop = struct {
pub fn init() ?Gop {
var tries: u32 = 0;
const found = while (tries < 100) : (tries += 1) {
if (findDisplay()) |d| break d;
if (findDisplay()) |f| break f;
system.sleep(50);
} else {
_ = system.write("display: no framebuffer device (headless?)\n");
@@ -65,8 +79,7 @@ pub const Gop = struct {
_ = system.write("display: could not map the framebuffer\n");
return null;
};
const geometry = found.display;
const size = @as(usize, geometry.height) * geometry.pitch;
const size = @as(usize, found.height) * found.pitch;
const back_base = system.mmap(size, system.PROT_READ | system.PROT_WRITE);
if (system.mmapFailed(back_base)) {
_ = system.write("display: could not allocate the back buffer\n");
@@ -76,10 +89,10 @@ pub const Gop = struct {
.device_id = found.id,
.front = @ptrFromInt(front_base),
.back = @ptrFromInt(back_base),
.width = geometry.width,
.height = geometry.height,
.pitch = geometry.pitch,
.format = geometry.format,
.width = found.width,
.height = found.height,
.pitch = found.pitch,
.format = found.format,
};
}
@@ -113,10 +126,48 @@ pub const Gop = struct {
}
};
/// The native virtio-gpu backend: the compositor composes into a **shared** scanout surface
/// (an `shm` region the driver created and handed over) and `present` asks the driver to put
/// a frame on the panel over its `.scanout` endpoint. Unlike GOP there is no local copy — the
/// surface *is* the device's resource backing, so compositing writes land straight where the
/// driver transfers-and-flushes from (x86 DMA is cache-coherent, so the cacheable shared pages
/// need no explicit flush). Built by the display service when a driver announces (V4); mode-set
/// and vsync stay off until V5.
pub const VirtioGpu = struct {
pixels: [*]u32, // the shared scanout surface, mapped into the compositor
width: u32,
height: u32,
format: u32,
scanout: ipc.Handle, // the driver's present channel (looked up on `.scanout`)
pub fn info(self: *const VirtioGpu) Info {
return .{ .width = self.width, .height = self.height, .pitch = self.width * 4, .format = self.format };
}
pub fn surface(self: *const VirtioGpu) Surface {
return .{ .pixels = self.pixels, .stride = self.width, .width = self.width, .height = self.height };
}
/// Ask the driver to present. The composited pixels are already in the shared surface, so
/// this is a single request over `.scanout`; the driver transfers + flushes. V4 presents
/// the whole surface (the damage-rect fast path is a later refinement).
pub fn present(self: *const VirtioGpu, damage: Rect) void {
_ = damage;
var request = scanout_protocol.Request{
.operation = @intFromEnum(scanout_protocol.Operation.present),
.x = 0,
.y = 0,
.width = self.width,
.height = self.height,
};
var reply: [scanout_protocol.reply_size]u8 = undefined;
_ = ipc.call(self.scanout, std.mem.asBytes(&request), &reply) catch {};
}
};
/// The pluggable scanout backend. A tagged union so the compositor holds one value and
/// dispatches without caring which is active; a `virtio` variant joins `gop` at V4.
/// dispatches without caring which is active; the `virtio` native backend joins `gop` at V4.
pub const Backend = union(enum) {
gop: Gop,
virtio: VirtioGpu,
pub fn info(self: *const Backend) Info {
return switch (self.*) {
@@ -133,16 +184,18 @@ pub const Backend = union(enum) {
inline else => |*b| b.present(damage),
}
}
/// Whether this backend supports runtime mode-setting (GOP: no; a native driver: yes).
/// Whether this backend supports runtime mode-setting (GOP: no; virtio-gpu: not until V5).
pub fn canModeSet(self: *const Backend) bool {
return switch (self.*) {
.gop => false,
.virtio => false,
};
}
/// Whether this backend has a vblank/fence for tear-free present (GOP: no).
/// Whether this backend has a vblank/fence for tear-free present (not until V5).
pub fn hasVsync(self: *const Backend) bool {
return switch (self.*) {
.gop => false,
.virtio => false,
};
}
};