340 lines
22 KiB
Markdown
340 lines
22 KiB
Markdown
# The display service: a framebuffer compositor
|
|
|
|
The [framebuffer](../os-development/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](../os-development/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, 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`](../../build/qemu.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](../../library/device/pci/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](../os-development/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 had no cross-process shared memory.** At v1 the memory syscalls were `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](../../library/protocol/block/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](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/, /protocol/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 (open /protocol/display); 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`](../../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
|
|
([`service.run`](../../library/kernel/service.zig) over the dispatch table its
|
|
protocol module generates through
|
|
[`envelope.Define`](../os-development/protocol-namespace.md) — one request and
|
|
reply type per verb, and the layer id in the packet header's `target`).
|
|
|
|
**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](../os-development/resilience.md)
|
|
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](../../system/kernel/devices-broker.zig) at init (`seedDisplay`), from
|
|
`BootInformation.framebuffer`: one `ResourceKind.memory` resource spanning
|
|
`[base, height*pitch]`, tagged **write-combining**, plus a small
|
|
`DisplayInfo{width, height, pitch, format, refresh_hz}` (the memory resource says *where*
|
|
and *how big*; `DisplayInfo` says how to *interpret* the bytes — and `refresh_hz`, the
|
|
panel refresh the loader computed from EDID before `ExitBootServices`, seeds the
|
|
compositor's frame clock). The node carries no name or index; it is identified purely by
|
|
its `display` device class.
|
|
- 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
|
|
`input`/`device-manager`/`fat` ([init.zig](../../system/services/init/init.zig)), and it
|
|
self-discovers the display node with `device.enumerate` (matching on `DeviceClass.display`). 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](../os-development/framebuffer.md) note already establishes carry over: step rows by `pitch`,
|
|
not `width*4`; and handle both `rgbx` and `bgrx` [pixel formats](../os-development/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.
|
|
Damage is tracked by one of two interchangeable trackers behind a compile-time
|
|
`damage_mode` A/B switch ([display.zig](../../system/services/display/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](../../system/services/display/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 |
|
|
|
|
**A layer belongs to the client that created it.** The id is a slot in a
|
|
sixteen-entry table — small, dense, guessable — so every verb above that names one is
|
|
answered only for the task whose `create_layer` produced it, and a layer that is
|
|
somebody else's is refused exactly as one that never existed (`-ENOENT`), so a client
|
|
cannot use the refusal to learn which ids are live
|
|
([protocol-namespace.md](../os-development/protocol-namespace.md): handles are scoped
|
|
per client, validated against the badge). The compositor's own layers — the cursor
|
|
sprite and the startup self-check's pair — are marked service-owned and are created by
|
|
direct call rather than over the protocol, so no client can move or destroy the
|
|
cursor. A dead client's layers are released on its exit notification, the same sweep
|
|
the FAT server runs for open files.
|
|
|
|
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.
|
|
|
|
`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](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`](../../library/client/display/display.zig),
|
|
the [`block`](../../library/device/block/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
|
|
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](../os-development/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](../os-development/halting.md)).
|
|
- **The channel.** A single-slot *latest-value* cell (`CursorChannel`) guarded by a
|
|
`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](../os-development/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](../os-development/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](driver-model.md)
|
|
from *endpoints* to *memory objects* (`shared_memory_create(len) → {cap, virtual_address}`, pass `cap` on
|
|
an `ipc_call`, receiver `shared_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](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](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](../../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`](../test/system/services/input-source/) analog) drives layers — a wallpaper and
|
|
a sliding rectangle — 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. 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 spawns `input`
|
|
alongside it to keep that independence honest). 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](../os-development/framebuffer.md) — the linear framebuffer, pitch vs. width, `volatile`.
|
|
- [gop.md](../os-development/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.
|