The display's first use of threads (docs/threading.md, docs/display.md). The
compositor stays the single owner of the framebuffer — only the main
service.run loop touches the backend and layer stack — and a dedicated
mouse-listener thread runs beside it:
- Listener: blocks on input.subscribeMouse(), accumulates relative dx/dy
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 parked next() lets the core halt.
- CursorChannel: a single-slot latest-value cell under a Thread.Mutex (the
renderer wants where the cursor is now, not a replay of deltas), with a
coalesced self-ipc.send poke that wakes the main loop — parked in
replyWait — as a message-notification. At most one poke is queued while
the last is undrained, so a fast mouse can't flood the endpoint.
- Render: the cursor is a top-z compositor layer; on the poke the main loop
moves it via configure + present (which damages old + new footprints).
The display binary opts into threads (addThreadedUserBinary), and
input-source gains a "mouse" mode that publishes pure motion to drive it.
Two kernel-level findings this surfaced, both fixed:
1. IPC handles do not cross threads. The handle table lives on the Task, 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.
2. Concurrent IPC from two threads raced unlocked kernel state. The display
is the first process issuing IPC syscalls from two threads at once, which
exposed a data race (flaky #GP in installEntry): create_ipc_endpoint /
ipc_register / ipc_lookup allocate from the kernel heap and mutate the
global registry, endpoint refcounts, and handle tables without the big
kernel lock. They were safe only while a process couldn't race itself.
They now sync.enter() like call/reply_wait/send already did (the kernel
heap has no lock of its own yet — heap.zig: "a lock comes with
threads/SMP" — so the big lock keeps its callers serialized).
Test: -Dtest-case=display-cursor (smp:4) spawns the input service, the
threaded display, and input-source in mouse mode; asserts the display's
"cursor tracking mouse ok" marker once the cursor has tracked a run of motion
end to end. Verified green 6/6 under stress (the race hit ~1-in-4 before the
lock fix) and in the full 29-case QEMU guardrail suite; zig build test clean.
303 lines
19 KiB
Markdown
303 lines
19 KiB
Markdown
# The display service: a framebuffer compositor
|
|
|
|
The [framebuffer](framebuffer.md) the loader hands over is a flat block of pixel
|
|
memory, and the kernel's [bootstrap console](../system/kernel/console.zig) 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](vision.md)), the sibling of the [input service](input.md).
|
|
|
|
This note is the architecture and the reasoning behind it. The concrete build order
|
|
lives in [display-plan.md](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: [`queryFramebuffer`](../boot/efi.zig)
|
|
reads the monitor's EDID, picks the native mode, and calls `set_mode` **before**
|
|
exiting ([gop.md](gop.md)). Once the kernel runs, GOP is **gone** — no `set_mode`, no
|
|
mode list, no EDID. What survives is the frozen snapshot in
|
|
[`BootInformation.framebuffer`](../system/boot-handoff.zig): `{base, width, height,
|
|
pitch, format}`, 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`](../build.zig), the Bochs VBE/DISPI model) the `base` GOP handed
|
|
you *is* that device's linear-framebuffer BAR — the same physical memory, seen through
|
|
a different door. On a real discrete GPU, GOP's `base` is an aperture inside the GPU's
|
|
VRAM BAR. danos already decodes this device
|
|
([pci-class.zig](../system/devices/pci-class.zig) has the full `display` namespace, and
|
|
`pci-bus` already reports it to the [device manager](device-manager.md) 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`](ipc.md) primitive — the display service runs straight into two limits the
|
|
rest of the system hasn't had to face:
|
|
|
|
1. **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`](../system/kernel/console.zig). It is *not* a
|
|
[devices-broker](../system/kernel/devices-broker.zig) node, so
|
|
`device.claim`/`mmio_map` cannot reach it, and there is no framebuffer
|
|
[syscall](syscall.md). A user-space display service needs a **new mechanism just to
|
|
touch the pixels**. (See "The handoff" below — this is built.)
|
|
|
|
2. **danos has no cross-process shared memory.** The memory syscalls are `mmap`
|
|
(private, zeroed), `mmio_map` (a *claimed device's* MMIO), and `dma_alloc` (new
|
|
pinned physical). The block driver's "pass a buffer by physical address" trick
|
|
([block/protocol.zig](../system/services/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 allows. This
|
|
is deferred (see "What v1 does not do"), because v1 sidesteps it entirely.
|
|
|
|
## Architecture
|
|
|
|
```
|
|
kernel ── owns the boot framebuffer; bootstrap console only
|
|
│ seeds a "display0" device node from BootInformation.framebuffer
|
|
│ (ResourceKind.memory = [base, height*pitch], write-combining hint,
|
|
│ plus DisplayInfo{width, height, pitch, format})
|
|
▼
|
|
display service (system/services/display/, ServiceId.display) ← the compositor
|
|
│ device.claim(display0) → 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 list
|
|
│ loop: composite dirty layers → back buffer → present dirty rects → front
|
|
│ backend is an INTERNAL interface: {gop-fb} today; {bochs-dispi, virtio-gpu} later
|
|
▼ reached by name (ipc_lookup); clients drive it over the display protocol
|
|
┌────────────────────────────────────┬──────────────────────────────────────┐
|
|
drawing clients (v1) surface clients (deferred)
|
|
runtime.display commands: runtime.display surfaces:
|
|
create_layer / configure_layer shm_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`](../system/drivers/usb-xhci-bus/usb-xhci-bus.zig) shape
|
|
(claim → `mmio_map` → run loop) — and the request/reply service shell is the
|
|
[FAT](../system/services/fat/fat.zig) / [input](../system/services/input/input.zig) shape
|
|
([`runtime.service.run`](../library/runtime/service.zig) 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](resilience.md)
|
|
story: a crashed display service returns the LFB to the kernel, and its restart
|
|
re-claims it).
|
|
|
|
- The kernel seeds a synthetic **`display0`** node into the
|
|
[devices-broker](../system/kernel/devices-broker.zig) at init, from
|
|
`BootInformation.framebuffer`: one `ResourceKind.memory` resource spanning
|
|
`[base, height*pitch]`, tagged **write-combining**, plus a small
|
|
`DisplayInfo{width, height, pitch, format}` (the memory resource says *where* and *how
|
|
big*; `DisplayInfo` says how to *interpret* the bytes).
|
|
- The service `device.claim`s it and `mmio_map`s the resource. The map is
|
|
**write-combining**, not the strong-uncacheable that `mmio_map` uses for register
|
|
MMIO. The kernel already programs a WC PAT slot for its own console
|
|
([`setupPat`](../system/kernel/architecture/x86_64/paging.zig)); 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
|
|
`vfs`/`input`/`device-manager` ([init.zig](../system/services/init/init.zig)), and it
|
|
self-discovers `display0` with `device.enumerate`. The [device manager](device-manager.md)
|
|
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](framebuffer.md) note already establishes carry over: step rows by `pitch`,
|
|
not `width*4`; and handle both `rgbx` and `bgrx` [pixel formats](gop.md).
|
|
|
|
## 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.
|
|
|
|
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` | composite dirty layers and flush to the screen |
|
|
|
|
Text is intentionally *not* an operation — a client renders glyphs by blitting tiles
|
|
(the [PSF font](../system/kernel/font.psf) 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.
|
|
|
|
## `runtime.display`
|
|
|
|
Clients speak the protocol through a new [`library/runtime/display.zig`](../library/runtime/runtime.zig),
|
|
the [`runtime.block`](../library/runtime/block.zig) 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
|
|
runtime, 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](threading.md): 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 relative `dx`/`dy` motion 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 parked `next()` leaves its core
|
|
free to halt ([halting.md](halting.md)).
|
|
- **The channel.** A single-slot *latest-value* cell (`CursorChannel`) guarded by a
|
|
`runtime.Thread.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 in `replyWait`, so the listener posts a
|
|
zero-payload `ipc.send` to the compositor's endpoint, which arrives as a
|
|
message-notification ([ipc.md](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` + `present` path
|
|
(it damages the old and new footprints, so only those two rectangles repaint).
|
|
|
|
Two threading facts shape this (both in [threading.md](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](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 the missing cross-process shared-memory primitive — best built as
|
|
the natural generalization of the existing M13 [capability passing](driver-model.md)
|
|
from *endpoints* to *memory objects* (`shm_create(len) → {cap, virtual_address}`, pass `cap` on
|
|
an `ipc_call`, receiver `shm_map(cap) → virtual_address`). v1 avoids it because server-owned
|
|
surfaces already prove the whole pipeline.
|
|
|
|
- **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 is
|
|
Bochs DISPI — the register interface QEMU's `-device VGA` exposes — 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](../system/kernel/tests.zig), `python3
|
|
test/qemu_test.py <case>`), each layering on the last:
|
|
|
|
- **`display`** — the kernel handoff: the seeded `display` device is shaped correctly and
|
|
the claim → `mmio_map` leaf 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 — logging `display: compositor self-check ok`.
|
|
- **`display-demo`** — the full pipeline from a separate process: the hardware-free
|
|
[`display-demo`](../system/services/display-demo/) client (the
|
|
[`input-source`](../system/services/input-source/) analog) drives layers — a wallpaper, a
|
|
sliding rectangle, a cursor — through the layer client API and heartbeats
|
|
`display-demo: ok`, proving a frame travelled client → compositor → screen, exactly as
|
|
the [input test](input.md) proves an event travels source → service → subscriber. The
|
|
visible motion itself is a screenshot away via `zig build run-x86-64`.
|
|
- **`display-cursor`** — the mouse-listener thread end to end: with the `input` service up,
|
|
`input-source mouse` publishes pure motion, and the display's listener thread accumulates
|
|
it into a cursor position handed to the render loop over the `CursorChannel`. Once the
|
|
cursor has tracked a run of that motion, the service logs
|
|
`display: cursor tracking mouse ok`. Runs `smp: 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](framebuffer.md) — the linear framebuffer, pitch vs. width, `volatile`.
|
|
- [gop.md](gop.md) — GOP, and why only linear RGBX/BGRX modes are paintable.
|
|
- [input.md](input.md) — the sibling service; the async `ipc_send` fan-out.
|
|
- [driver-model.md](driver-model.md) — claim / `mmio_map`, capability passing, the trust model.
|
|
- [device-manager.md](device-manager.md) — matching and supervision (the native backend's route).
|
|
- [display-plan.md](display-plan.md) — the ordered build-out.
|