21 KiB
The display service: a framebuffer compositor
The framebuffer the loader hands over is a flat block of pixel
memory, and the kernel's bootstrap console draws text
into it directly. That console is a stop-gap. The display service
(system/services/display/) is the real thing: an ordinary ring-3 process that owns
the framebuffer, composes a stack of layers into an off-screen back buffer, and
presents finished frames to the screen — the display half of the GUI track
(vision.md), the sibling of the input service.
This note is the architecture and the reasoning behind it. The concrete build order lives in display-plan.md.
First, a distinction that shapes everything: GOP vs. the PCI device
It is tempting to think "the GOP framebuffer" and "the VGA-compatible display controller in the PCIe tree" are two different things. They are not — they are two interfaces to the same silicon, at different times and different levels, and knowing which one you're holding decides what you can do.
-
GOP is firmware's temporary driver for the display controller. It gives you a linear framebuffer pointer and can set video modes — but only until
ExitBootServices. The loader already leans on this:queryFramebufferreads the monitor's EDID, picks the native mode, and callsset_modebefore exiting (gop.md). Once the kernel runs, GOP is gone — noset_mode, no mode list, no EDID. What survives is the frozen snapshot inBootInformation.framebuffer:{base, width, height, pitch, format, refresh_hz}, and nothing more. -
The PCI class-0x03 device is the raw controller — BARs, config space, registers, IO ports. It is what you actually own after boot. On QEMU's emulated adapter (
-device VGA,edid=on, the Bochs VBE/DISPI model) thebaseGOP handed you is that device's linear-framebuffer BAR — the same physical memory, seen through a different door. On a real discrete GPU, GOP'sbaseis an aperture inside the GPU's VRAM BAR. danos already decodes this device (pci-class.zig has the fulldisplaynamespace, andpci-busalready reports it to the device manager with its class triple) — but nothing binds it yet.
What that difference costs you, concretely:
| You want to… | Dumb GOP framebuffer (boot handoff) | Native device driver (PCI 0x03) |
|---|---|---|
| Report the current mode | ✅ from the handoff | ✅ |
| Change resolution / bpp at runtime | ❌ GOP is gone | ✅ program DISPI regs / virtio-gpu queue |
| Re-read EDID, enumerate monitor modes | ❌ | ✅ the device exposes an EDID block |
| Refresh rate | ❌ (virtual anyway) | only a real KMS driver — far future |
| vblank / tear-free present | ❌ no vblank signal | ✅ vblank IRQ + page-flip (real GPUs) |
| Works on the Pi (no PCI VGA) | ✅ VideoCore hands a simple FB | ✗ per-device |
The lesson: the portable base for the whole GUI stack is the GOP / boot-handoff linear framebuffer. Runtime mode-setting is a per-device upgrade layered on top — and on the Raspberry Pis there is no PCI VGA at all, so the neutral framebuffer is the only thing all three target machines share. That is why the display service is built on the dumb framebuffer first, with the native backend as an optional module behind the same interface.
Two constraints this service exists to meet
Like the input service — which existed partly to motivate the asynchronous
ipc_send primitive — the display service runs straight into two limits the
rest of the system hasn't had to face:
-
The framebuffer is kernel-only today. It arrives through the boot handoff, is mapped into the kernel's physmap, and is touched only by
console.zig. It is not a devices-broker node, sodevice.claim/mmio_mapcannot reach it, and there is no framebuffer syscall. A user-space display service needs a new mechanism just to touch the pixels. (See "The handoff" below — this is built.) -
danos had no cross-process shared memory. At v1 the memory syscalls were
mmap(private, zeroed),mmio_map(a claimed device's MMIO), anddma_alloc(new pinned physical). The block driver's "pass a buffer by physical address" trick (block/protocol.zig) works only because its consumer is DMA hardware. A compositor that CPU-reads and blends client layers can't use it — it would have to map another process's memory, which nothing allowed. v1 sidesteps it entirely (see "What v1 does not do"); v2 has since built the primitive (shared_memory_create/shared_memory_map/shared_memory_physical— display-v2.md).
Architecture
kernel ── owns the boot framebuffer; bootstrap console only
│ seeds a display-class device node from BootInformation.framebuffer
│ (ResourceKind.memory = [base, height*pitch], write-combining hint,
│ plus DisplayInfo{width, height, pitch, format, refresh_hz})
▼
display service (system/services/display/, ServiceId.display) ← the compositor
│ device.claim(display node) → mmio_map(WRITE-COMBINING) = FRONT buffer (the LFB)
│ mmap(cacheable) a BACK buffer of the same geometry
│ owns: an ordered LAYER STACK + a per-frame DAMAGE tracker (rect list or tile grid)
│ loop: composite dirty layers → back buffer → present dirty rects → front
│ backend is an INTERNAL interface: {gop-fb} at boot; {virtio-gpu} on hot-attach (v2)
▼ reached by name (ipc_lookup); clients drive it over the display protocol
┌────────────────────────────────────┬──────────────────────────────────────┐
drawing clients (v1) surface clients (deferred)
display commands: display surfaces:
create_layer / configure_layer shared_memory_create → pass as a capability →
fill_rect / blit_tile / damage the compositor maps & composites the
present client-rendered bitmap directly
The bring-up sequence mirrors a hardware driver's — it is the
usb-xhci-bus initialise shape
(claim → mmio_map → run loop) — and the request/reply service shell is the
FAT / input shape
(service.run with a protocol.zig of
extern struct messages and an Operation tag).
One process, for now. v1 is a single service that both owns the framebuffer and
composites — it does not split a "framebuffer driver" from a "compositor" the way input
splits ps2-bus from the input service. The backend (dumb FB vs. a native GPU) is an
internal interface, not a process boundary. That boundary earns its keep only when a
second backend or a second monitor appears; until then it is complexity with no payoff.
The handoff: a device node + a write-combining map
The framebuffer crosses into user space through the machinery that already exists for every other device, rather than a bespoke syscall — so it inherits ownership, release-on-death, and re-claim-on-restart for free (the resilience story: a crashed display service returns the LFB to the kernel, and its restart re-claims it).
- The kernel seeds a synthetic display-class node into the
devices-broker at init (
seedDisplay), fromBootInformation.framebuffer: oneResourceKind.memoryresource spanning[base, height*pitch], tagged write-combining, plus a smallDisplayInfo{width, height, pitch, format, refresh_hz}(the memory resource says where and how big;DisplayInfosays how to interpret the bytes — andrefresh_hz, the panel refresh the loader computed from EDID beforeExitBootServices, seeds the compositor's frame clock). The node carries no name or index; it is identified purely by itsdisplaydevice class. - The service
device.claims it andmmio_maps the resource. The map is write-combining, not the strong-uncacheable thatmmio_mapuses for register MMIO. The kernel already programs a WC PAT slot for its own console (setupPat); this reaches it from the user mapping path. This matters: an uncacheable framebuffer makes the back→front blit unusably slow. - On
claim, the kernel's bootstrap console goes quiet, so the two never fight over the LFB. A panic is the one exception — by then the service is likely dead anyway, and a panic on screen wins.
The display service is a named boot service: init spawns it by name alongside
input/device-manager/fat (init.zig), and it
self-discovers the display node with device.enumerate (matching on DeviceClass.display). The device manager
matching path (PCI class 0x03 → a driver) is reserved for the future native backend, not
this singleton synthetic node.
Double buffering and the write-combining discipline
Two buffers, with deliberately different memory types:
- The front buffer is the LFB — write-combining: fast to write, slow to read. The rule is therefore never read the front buffer. Only ever stream into it, sequentially.
- The back buffer is ordinary cacheable RAM (
mmap), the same geometry. All compositing happens here, where reads and read-modify-write blends are cheap.
So a frame is: compose every dirty layer into the cacheable back buffer, then present
— copy the changed regions back→front in sequential, WC-friendly writes. Two details the
framebuffer note already establishes carry over: step rows by pitch,
not width*4; and handle both rgbx and bgrx pixel formats.
Flicker vs. tearing — what double buffering does and doesn't buy
These are two different artifacts, and the dumb framebuffer fixes exactly one of them:
- Flicker is the user seeing intermediate, half-drawn states (a clear-then-redraw flash). Double buffering eliminates it completely — the screen only ever receives whole, finished frames.
- Tearing is a present landing while the display's scanout beam is mid-frame, so the top of the screen shows the new frame and the bottom the old. Avoiding it requires presenting during the vertical blank (vsync) — which needs a vblank signal. A dumb GOP framebuffer has no vblank.
So v1 is flicker-free, and it minimizes the tear window by presenting only damaged rectangles (less to copy → a smaller window in which the beam can catch a half-updated frame), but it is not tear-free. Genuine vsync waits for a backend with a vblank IRQ or a flush/flip path — a native-device capability, not something the firmware framebuffer can offer. Stated plainly here so the limitation is understood, not discovered.
Layers and the client protocol
The compositor holds an ordered stack of layers. Each layer has a rectangle, a
z-order, a visibility flag, and a surface. Presenting walks the stack bottom-to-top,
painting each dirty layer into the back buffer, then flushes the damage to the front.
Damage is tracked by one of two interchangeable trackers behind a compile-time
damage_mode A/B switch (display.zig): a
free-form dirty-rectangle list (tight bounds, heuristic merging) or a fixed 64-px
tile grid (exact O(1) merging, tile-quantized repaints) — the grid is the default;
compositor.zig has both, with the trade-off
discussion.
In v1 the surfaces are server-owned, and clients draw into them with a small immediate-mode command protocol — essentially the model early X used, and enough for a shell, a terminal, a cursor, and a wallpaper:
| Operation | Meaning |
|---|---|
info |
report {width, height, pitch, format} of the display |
create_layer |
allocate a server-owned surface, return a layer handle |
configure_layer |
set a layer's rect, z-order, visibility |
destroy_layer |
release a layer |
fill_rect |
fill a rectangle of a layer with a colour |
blit_tile |
copy a small client-supplied pixel tile into a layer (inline) |
damage |
mark a region of a layer dirty |
present |
request a repaint: composited at the next frame-clock tick |
Text is intentionally not an operation — a client renders glyphs by blitting tiles (the PSF font path the console already uses can move into a client). Keeping the protocol to rectangles and tiles keeps the compositor small and the policy in the client.
present is a request, not an immediate flush: the compositor runs a ~60 Hz frame
clock (a one-shot kernel timer re-armed on demand), and each tick composites all the
damage accumulated since the last one. Any number of client presents and cursor moves
inside one interval coalesce into a single repaint — the software stand-in for vblank
pacing on backends that have none (all of them today; see
display-v2.md, "Fenced is not vsync"). Bring-up paths that must put
pixels on screen synchronously (initialisation, the self-checks) bypass the clock.
display
Clients speak the protocol through a new library/client/display/display.zig,
the block shape (a cached .display lookup
with a boot-race retry): display.info(), a Layer handle with fill / blitTile /
damage, and present(). Application code never issues the raw syscalls — it calls the
client module, as with every other danos service.
The cursor: a mouse-listener thread feeding the compositor
The compositor is the single owner of the framebuffer — only the main service.run loop
touches the backend and the layer stack. Tracking the mouse without breaking that
ownership is the display's first use of threads: the service is built
multi-threaded (addThreadedUserBinary) and, at startup, spawns a mouse-listener
thread beside the compositor loop.
- Listener thread. Blocks on the input service's mouse stream
(
input.subscribeMouse()), accumulates the relativedx/dymotion into an absolute cursor position clamped to the screen, and hands it to the compositor. It never touches the compositor — so no lock guards the framebuffer. A parkednext()leaves its core free to halt (halting.md). - The channel. A single-slot latest-value cell (
CursorChannel) guarded by aThread.Mutex: the renderer wants where the cursor is now, not a replay of every delta, so a new position overwrites the old. The listener also pokes the compositor awake — the main loop is parked inreplyWait, so the listener posts a zero-payloadipc.sendto the compositor's endpoint, which arrives as a message-notification (ipc.md). The poke is coalesced: at most one is queued while the main loop has not drained the last, so a fast mouse cannot flood the endpoint. - Render. On the poke, the main loop takes the latest position and moves the cursor —
which is just a top-z compositor layer — with the existing
configure+presentpath (it damages the old and new footprints, so only those two rectangles repaint).
Two threading facts shape this (both in threading.md). IPC handles do
not cross threads, so the listener can't reuse the main loop's endpoint handle — it
ipc.lookup(.display)s its own handle to the same endpoint to poke through. And a
multi-threaded service doing concurrent IPC is why the kernel's endpoint-create / register
/ lookup syscalls now serialize under the big kernel lock. Shared fate applies: a fault in
the listener takes the whole display down, and the supervisor restarts the process
(resilience.md).
What v1 does not do (and why that's fine)
Two capabilities are deliberately out of the first cut. Neither reshapes anything above; both are clean additions behind the interfaces v1 establishes.
-
Client-rendered surfaces (shared memory). The fast path for a bitmap-heavy app is to render into its own buffer and hand the compositor a reference, not a stream of commands. That needs a cross-process shared-memory primitive — the natural generalization of the existing M13 capability passing from endpoints to memory objects (
shared_memory_create(len) → {cap, virtual_address}, passcapon anipc_call, receivershared_memory_map(cap) → virtual_address). v1 avoids it because server-owned surfaces already prove the whole pipeline; v2 has since built exactly that primitive (display-v2.md) — the client-surface path on top of it is still open. -
Runtime mode-setting (a native backend). Detecting the EDID mode list and changing resolution / bpp at runtime needs the raw PCI device. The first native backend — since built by v2 (display-v2.md) — is virtio-gpu, behind the same internal backend interface the dumb framebuffer sits behind. Refresh-rate and colour management (a gamma LUT) are real-GPU-KMS territory, far beyond this.
Verifying it
Four QEMU test cases (tests.zig, python3 test/qemu_test.py <case>), each layering on the last:
display— the kernel handoff: the seededdisplaydevice is shaped correctly and the claim →mmio_mapleaf is genuinely write-combining (PAT entry 4), asserted at the page-table level.display-service— the compositor comes up: it claims the framebuffer, allocates the cacheable back buffer, presents a cleared frame through the double-buffer path (display: online … / presented frame 0), and a startup self-check composites two overlapping layers on the real framebuffer and reads them back — overlap = the top layer — loggingdisplay: compositor self-check ok.display-demo— the full pipeline from a separate process: the hardware-freedisplay-democlient (theinput-sourceanalog) drives layers — a wallpaper and a sliding rectangle — through the layer client API and heartbeatsdisplay-demo: ok, proving a frame travelled client → compositor → screen, exactly as the input test proves an event travels source → service → subscriber. It draws no cursor and reads no input — the cursor is the service's own (below), and the demo animates on its own frame timer, independent of the mouse (the test spawnsinputalongside it to keep that independence honest). The visible motion itself is a screenshot away viazig build run-x86-64.display-cursor— the mouse-listener thread end to end: with theinputservice up,input-source mousepublishes pure motion, and the display's listener thread accumulates it into a cursor position handed to the render loop over theCursorChannel. Once the cursor has tracked a run of that motion, the service logsdisplay: cursor tracking mouse ok. Runssmp: 4— the compositor and listener threads execute on different cores, which is what surfaced the IPC-under-lock requirement above.
The compositor's pixel math (rectangle clipping, fill, composite, tile blit) and colour
packing are additionally covered by pure host unit tests under zig build test.
See also
- framebuffer.md — the linear framebuffer, pitch vs. width,
volatile. - gop.md — GOP, and why only linear RGBX/BGRX modes are paintable.
- input.md — the sibling service; the async
ipc_sendfan-out. - driver-model.md — claim /
mmio_map, capability passing, the trust model. - device-manager.md — matching and supervision (the native backend's route).
- display-plan.md — the ordered build-out.