display: pace the frame clock by the panel's EDID refresh rate
Both EDID moments the system has are now captured and carried to the compositor's frame clock: - EFI: the loader derives refresh from the preferred detailed timing (pixel clock / total pixels) while GOP is still alive — the only moment it is readable — and hands it through the boot handoff into the display0 node's DisplayInfo (new refresh_hz field, 0 = unknown). - GPU: the virtio-gpu driver derives the same figure from its own EDID read and carries it in the attach_scanout announce (request.y). updateFrameClock() re-derives the interval from the active backend's info at bring-up and again on every backend change — the boot framebuffer's clock dies with the GOP floor at upgrade, replaced by the GPU's rate. Unknown rate defaults to 60 Hz; the result is clamped to [30, 120] Hz so a mis-parsed EDID can neither starve nor flood the compositor. Rate only, never phase: without vblank, presents still free-run (docs/display-v2.md, 'Fenced is not vsync'). Observed in QEMU: OVMF exposes no EDID for the VGA adapter, so the GOP floor logs 'frame clock 62 Hz (default)' (real firmware does expose it); the virtio-gpu EDID advertises 75 Hz and the upgrade logs 'frame clock 76 Hz (panel EDID)'. The display kernel test asserts refresh_hz rides the seeded node; all 7 display QEMU cases pass.
This commit is contained in:
@@ -37,6 +37,11 @@ pub const Framebuffer = extern struct {
|
||||
height: u32, // visible rows (e.g. 1080)
|
||||
pitch: u32, // bytes from the start of one row to the start of the next
|
||||
format: PixelFormat,
|
||||
/// The panel's refresh rate in Hz, computed from its EDID preferred timing (pixel
|
||||
/// clock / total pixels per frame) while GOP was still alive — the one moment it is
|
||||
/// readable (docs/gop.md). 0 = unknown (no EDID). The display service paces its
|
||||
/// frame clock by it; without vblank this fixes the *rate*, never the *phase*.
|
||||
refresh_hz: u32 = 0,
|
||||
|
||||
/// Whether a usable framebuffer was handed over.
|
||||
pub fn present(self: Framebuffer) bool {
|
||||
|
||||
@@ -97,6 +97,7 @@ pub const DisplayInfo = extern struct {
|
||||
height: u32 = 0, // visible rows
|
||||
pitch: u32 = 0, // bytes from one row's start to the next
|
||||
format: u32 = 0, // a DisplayFormat value
|
||||
refresh_hz: u32 = 0, // panel refresh rate from EDID (0 = unknown); see boot-handoff
|
||||
};
|
||||
|
||||
/// `DeviceDescriptor.parent` for a device with no parent — a root of the device tree.
|
||||
|
||||
@@ -61,6 +61,10 @@ var fence_next: u64 = 1;
|
||||
/// Whether the device offered VIRTIO_GPU_F_EDID, so `get_edid` is worth issuing.
|
||||
var edid_available = false;
|
||||
|
||||
/// The panel refresh rate parsed from the EDID preferred timing (0 = unknown). Carried to
|
||||
/// the compositor in the announce so its frame clock paces to the panel, not a guess.
|
||||
var edid_refresh_hz: u32 = 0;
|
||||
|
||||
/// The control virtqueue. We drive it synchronously — one command, notify, poll the used
|
||||
/// ring — so a depth of 16 is ample; we ask the device to shrink to it (virtio 1.0 lets the
|
||||
/// driver reduce queue_size), keeping the whole ring inside one page.
|
||||
@@ -468,10 +472,18 @@ fn readEdid() void {
|
||||
}
|
||||
// The first detailed timing descriptor (EDID base-block offset 54) is the preferred mode:
|
||||
// active pixels are 12-bit, low byte + high nibble (bytes 2/4 horizontal, 5/7 vertical).
|
||||
// The refresh rate is derived from the same descriptor: pixel clock (bytes 0-1, 10 kHz
|
||||
// units) over total (active + blanking) pixels per frame — the loader does the identical
|
||||
// computation for the boot framebuffer (boot/efi.zig edidNative).
|
||||
const e = &response.edid;
|
||||
const h_active = @as(u32, e[56]) | (@as(u32, e[58] & 0xF0) << 4);
|
||||
const v_active = @as(u32, e[59]) | (@as(u32, e[61] & 0xF0) << 4);
|
||||
log("virtio-gpu: EDID preferred mode {d}x{d}\n", .{ h_active, v_active });
|
||||
const clock_hz = (@as(u64, e[54]) | (@as(u64, e[55]) << 8)) * 10_000;
|
||||
const h_blank = @as(u64, e[57]) | (@as(u64, e[58] & 0x0F) << 8);
|
||||
const v_blank = @as(u64, e[60]) | (@as(u64, e[61] & 0x0F) << 8);
|
||||
const total = (@as(u64, h_active) + h_blank) * (@as(u64, v_active) + v_blank);
|
||||
if (total != 0) edid_refresh_hz = @intCast((clock_hz + total / 2) / total);
|
||||
log("virtio-gpu: EDID preferred mode {d}x{d} @ {d} Hz\n", .{ h_active, v_active, edid_refresh_hz });
|
||||
}
|
||||
|
||||
/// Present the whole surface: copy the guest backing into the host resource, then flush it to
|
||||
@@ -550,6 +562,7 @@ fn announce() void {
|
||||
var request = dp.Request{
|
||||
.operation = @intFromEnum(dp.Operation.attach_scanout),
|
||||
.x = max_width, // the shared surface's row stride in pixels (it is sized to the max mode)
|
||||
.y = edid_refresh_hz, // the panel refresh from EDID (0 = unknown) — the frame-clock seed
|
||||
.width = current_width,
|
||||
.height = current_height,
|
||||
.colour = display_format_bgrx,
|
||||
|
||||
@@ -61,7 +61,7 @@ pub fn init(device_tree: *const platform.DeviceTree) void {
|
||||
/// [[boot-handoff]], not the device tree), so it is seeded explicitly, after `init`.
|
||||
/// Returns the new device id, or null when there is no framebuffer (headless) or the
|
||||
/// table is full. Idempotent-ish: only ever call once per boot.
|
||||
pub fn seedDisplay(base: u64, width: u32, height: u32, pitch: u32, format: u32) ?u64 {
|
||||
pub fn seedDisplay(base: u64, width: u32, height: u32, pitch: u32, format: u32, refresh_hz: u32) ?u64 {
|
||||
if (base == 0 or width == 0 or height == 0) return null; // headless
|
||||
if (count >= maximum_devices) {
|
||||
dropped += 1;
|
||||
@@ -79,7 +79,7 @@ pub fn seedDisplay(base: u64, width: u32, height: u32, pitch: u32, format: u32)
|
||||
.len = @as(u64, height) * pitch,
|
||||
.flags = device_abi.resource_flag_write_combining,
|
||||
};
|
||||
d.display = .{ .width = width, .height = height, .pitch = pitch, .format = format };
|
||||
d.display = .{ .width = width, .height = height, .pitch = pitch, .format = format, .refresh_hz = refresh_hz };
|
||||
devices[count] = d;
|
||||
display_device = d.id;
|
||||
count += 1;
|
||||
|
||||
@@ -200,8 +200,8 @@ fn kmain(boot_information: *const BootInformation) noreturn {
|
||||
// Publish the loader's framebuffer as a claimable `display` device, so a
|
||||
// user-space display service can take it over the same claim + mmio_map path as
|
||||
// any other hardware (it is not firmware-discovered; it rides the boot handoff).
|
||||
if (devices_broker.seedDisplay(fb.base, fb.width, fb.height, fb.pitch, @intFromEnum(fb.format))) |display_id| {
|
||||
log.print("/system/kernel: framebuffer device {d} seeded ({d}x{d}, pitch {d}, write-combining)\n", .{ display_id, fb.width, fb.height, fb.pitch });
|
||||
if (devices_broker.seedDisplay(fb.base, fb.width, fb.height, fb.pitch, @intFromEnum(fb.format), fb.refresh_hz)) |display_id| {
|
||||
log.print("/system/kernel: framebuffer device {d} seeded ({d}x{d}, pitch {d}, {d} Hz, write-combining)\n", .{ display_id, fb.width, fb.height, fb.pitch, fb.refresh_hz });
|
||||
}
|
||||
|
||||
// Install the device-IRQ trampolines, so a driver's irq_bind has vectors to
|
||||
|
||||
@@ -3404,6 +3404,7 @@ fn displayTest(boot_information: *const BootInformation) void {
|
||||
|
||||
check("the node is class display", d.class == @intFromEnum(device_abi.DeviceClass.display));
|
||||
check("it carries the framebuffer geometry", d.display.width == fb.width and d.display.height == fb.height and d.display.pitch == fb.pitch);
|
||||
check("it carries the panel refresh rate", d.display.refresh_hz == fb.refresh_hz);
|
||||
check("it has exactly one resource", d.resource_count == 1);
|
||||
const r = d.resources[0];
|
||||
check("that resource is a memory window", r.kind == @intFromEnum(device_abi.ResourceKind.memory));
|
||||
|
||||
@@ -16,8 +16,10 @@ const scanout_protocol = runtime.scanout_protocol;
|
||||
const Rect = compositor.Rect;
|
||||
const Surface = compositor.Surface;
|
||||
|
||||
/// The current display mode, as a backend reports it.
|
||||
pub const Info = struct { width: u32, height: u32, pitch: u32, format: u32 };
|
||||
/// The current display mode, as a backend reports it. `refresh_hz` is the panel's
|
||||
/// refresh rate from EDID (0 = unknown) — the frame clock's pacing seed; without vblank
|
||||
/// it fixes the rate, never the phase (docs/display-v2.md, "Fenced is not vsync").
|
||||
pub const Info = struct { width: u32, height: u32, pitch: u32, format: u32, refresh_hz: u32 };
|
||||
|
||||
/// Enumeration scratch — a `DeviceDescriptor` is large, and only one scan is ever needed.
|
||||
var device_table: [64]device.DeviceDescriptor = undefined;
|
||||
@@ -35,13 +37,14 @@ pub const Gop = struct {
|
||||
height: u32,
|
||||
pitch: u32,
|
||||
format: u32,
|
||||
refresh_hz: u32, // from the boot EDID via the display0 node (0 = unknown)
|
||||
|
||||
/// 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 };
|
||||
const Found = struct { id: u64, width: u32, height: u32, pitch: u32, format: u32, refresh_hz: 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
|
||||
@@ -52,7 +55,7 @@ pub const Gop = struct {
|
||||
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 .{ .id = d.id, .width = d.display.width, .height = d.display.height, .pitch = d.display.pitch, .format = d.display.format, .refresh_hz = d.display.refresh_hz };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -93,11 +96,12 @@ pub const Gop = struct {
|
||||
.height = found.height,
|
||||
.pitch = found.pitch,
|
||||
.format = found.format,
|
||||
.refresh_hz = found.refresh_hz,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn info(self: *const Gop) Info {
|
||||
return .{ .width = self.width, .height = self.height, .pitch = self.pitch, .format = self.format };
|
||||
return .{ .width = self.width, .height = self.height, .pitch = self.pitch, .format = self.format, .refresh_hz = self.refresh_hz };
|
||||
}
|
||||
|
||||
/// The cacheable compose target (the back buffer).
|
||||
@@ -169,10 +173,11 @@ pub const VirtioGpu = struct {
|
||||
width: u32, // the active mode
|
||||
height: u32,
|
||||
format: u32,
|
||||
refresh_hz: u32, // from the driver's EDID read, carried in the announce (0 = unknown)
|
||||
scanout: ipc.Handle, // the driver's present + mode channel (looked up on `.scanout`)
|
||||
|
||||
pub fn info(self: *const VirtioGpu) Info {
|
||||
return .{ .width = self.width, .height = self.height, .pitch = self.stride * 4, .format = self.format };
|
||||
return .{ .width = self.width, .height = self.height, .pitch = self.stride * 4, .format = self.format, .refresh_hz = self.refresh_hz };
|
||||
}
|
||||
pub fn surface(self: *const VirtioGpu) Surface {
|
||||
return .{ .pixels = self.pixels, .stride = self.stride, .width = self.width, .height = self.height };
|
||||
|
||||
@@ -88,9 +88,29 @@ var damage_grid: compositor.TileGrid = .{};
|
||||
/// this is the software stand-in, the same strategy Linux uses atop virtio-gpu. Bring-up
|
||||
/// paths that need pixels on screen *now* (initialise, the self-checks) still call
|
||||
/// `present()` directly.
|
||||
const frame_interval_milliseconds = 16;
|
||||
///
|
||||
/// The interval comes from the *active backend's* panel refresh rate (EDID: the loader
|
||||
/// captures it for the GOP floor while firmware still runs; the native driver reads its
|
||||
/// own and carries it in the announce). `updateFrameClock` re-derives it whenever the
|
||||
/// backend changes — the boot framebuffer's clock dies with the GOP floor at upgrade.
|
||||
/// Without a rate the clock defaults to 60 Hz, and it is clamped to [30, 120] Hz so a
|
||||
/// mis-parsed EDID can neither starve nor flood the compositor.
|
||||
var frame_interval_milliseconds: u64 = 16;
|
||||
var frame_timer_armed = false;
|
||||
|
||||
/// Derive the frame-clock interval from the active backend's refresh rate and log what
|
||||
/// the clock is now pacing to. Called at bring-up and again on every backend change.
|
||||
fn updateFrameClock() void {
|
||||
const reported = backend.info().refresh_hz;
|
||||
const rate: u64 = if (reported == 0) 60 else @min(@max(reported, 30), 120);
|
||||
frame_interval_milliseconds = @max(1000 / rate, 1);
|
||||
var line: [96]u8 = undefined;
|
||||
_ = system.write(std.fmt.bufPrint(&line, "display: frame clock {d} Hz ({s})\n", .{
|
||||
1000 / frame_interval_milliseconds,
|
||||
if (reported == 0) "default" else "panel EDID",
|
||||
}) catch return);
|
||||
}
|
||||
|
||||
/// Arm the frame clock unless a tick is already pending: any number of requests inside
|
||||
/// one interval coalesce into that single tick's present.
|
||||
fn schedulePresent() void {
|
||||
@@ -284,7 +304,7 @@ fn verifyNativePresent() void {
|
||||
/// present channel, switch the backend to virtio-gpu, and queue a full-screen repaint. The
|
||||
/// present is deferred to a timer (see `service_endpoint`) so it happens after this reply
|
||||
/// unblocks the driver and it starts serving `.scanout`.
|
||||
fn attachScanout(stride: u32, width: u32, height: u32, format: u32, capability: ?ipc.Handle, reply: []u8) usize {
|
||||
fn attachScanout(stride: u32, width: u32, height: u32, format: u32, refresh_hz: u32, capability: ?ipc.Handle, reply: []u8) usize {
|
||||
const cap = capability orelse return fail(reply);
|
||||
if (width == 0 or height == 0 or stride < width) return fail(reply);
|
||||
const mapped = runtime.shm.map(cap) orelse return fail(reply);
|
||||
@@ -303,9 +323,11 @@ fn attachScanout(stride: u32, width: u32, height: u32, format: u32, capability:
|
||||
.width = width,
|
||||
.height = height,
|
||||
.format = format,
|
||||
.refresh_hz = refresh_hz,
|
||||
.scanout = scanout,
|
||||
} };
|
||||
background = protocol.pack(format, 0x20, 0x30, 0x48); // re-pack the wallpaper for the mode
|
||||
updateFrameClock(); // the GOP floor's clock dies here — pace by the GPU's EDID now
|
||||
addDamage(screenRect()); // the whole new surface must be painted
|
||||
pending_native_verify = true;
|
||||
if (!reattach) pending_modeset_check = true; // the mode-set self-check runs once, on first upgrade
|
||||
@@ -564,6 +586,7 @@ fn initialise(endpoint: ipc.Handle) bool {
|
||||
_ = system.write(std.fmt.bufPrint(&line, "display: online {d}x{d} pitch {d} format {d}\n", .{
|
||||
mode.width, mode.height, mode.pitch, mode.format,
|
||||
}) catch "display: online\n");
|
||||
updateFrameClock();
|
||||
_ = system.write("display: presented frame 0\n");
|
||||
|
||||
selfCheck();
|
||||
@@ -631,7 +654,7 @@ fn onMessage(message: []const u8, reply: []u8, sender: u32, capability: ?ipc.Han
|
||||
return ok(reply);
|
||||
},
|
||||
@intFromEnum(protocol.Operation.attach_scanout) => {
|
||||
return attachScanout(request.x, request.width, request.height, request.colour, capability, reply);
|
||||
return attachScanout(request.x, request.width, request.height, request.colour, request.y, capability, reply);
|
||||
},
|
||||
@intFromEnum(protocol.Operation.set_mode) => {
|
||||
if (!backend.setMode(request.width, request.height)) return fail(reply);
|
||||
|
||||
@@ -24,11 +24,13 @@ pub const Operation = enum(u32) {
|
||||
damage = 6,
|
||||
/// present(): composite the dirty layers and flush to the screen.
|
||||
present = 7,
|
||||
/// attach_scanout(x=stride, width, height, colour=format) + <surface capability>: a native
|
||||
/// scanout driver announces itself, handing over the shared scanout surface as an `ipc_call`
|
||||
/// send_cap. The compositor maps it, looks up the driver's `.scanout` present channel, and
|
||||
/// upgrades off the GOP floor (docs/display-v2.md V4). `x` is the surface's row stride in
|
||||
/// pixels, `colour` the DisplayFormat.
|
||||
/// attach_scanout(x=stride, y=refresh_hz, width, height, colour=format) + <surface
|
||||
/// capability>: a native scanout driver announces itself, handing over the shared scanout
|
||||
/// surface as an `ipc_call` send_cap. The compositor maps it, looks up the driver's
|
||||
/// `.scanout` present channel, and upgrades off the GOP floor (docs/display-v2.md V4).
|
||||
/// `x` is the surface's row stride in pixels, `y` the panel refresh rate from the
|
||||
/// driver's EDID read (0 = unknown; paces the compositor's frame clock), `colour` the
|
||||
/// DisplayFormat.
|
||||
attach_scanout = 8,
|
||||
/// set_mode(width, height): change the display resolution — only a native backend that
|
||||
/// reports `canModeSet` honours it; on the GOP floor it fails (docs/display-v2.md V5).
|
||||
|
||||
Reference in New Issue
Block a user