251 lines
16 KiB
Markdown
251 lines
16 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.
|
|
|
|
## 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, vaddr}`, pass `cap` on
|
|
an `ipc_call`, receiver `shm_map(cap) → vaddr`). 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
|
|
|
|
A `display` test case ([tests.zig](../system/kernel/tests.zig), `python3
|
|
test/qemu_test.py display`) boots the kernel, spawns the service and a synthetic
|
|
`display-demo` client (the hardware-free analog of
|
|
[`input-source`](../system/services/input-source/)) that drives layers — a wallpaper, a
|
|
moving rectangle, a cursor. It passes on a serial heartbeat (`display: presented frame N`
|
|
/ `display-demo: ok`), proving a frame travelled client → compositor → screen, exactly
|
|
as the [input test](input.md) proves an event travels source → service → subscriber.
|
|
|
|
## 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.
|