Files
danos/docs/framebuffer.md
T
Daniel Samson e854f65623 docs: full docs-vs-code audit — fix every stale claim across 40 docs
Every doc verified claim-by-claim against the code by parallel audit agents,
then fixed and adversarially re-verified. Two waves of staleness corrected:
the originally audited findings (higher-half boot handoff, kernel VFS
takeover, fault isolation + claim release + driver restart, AML/S5 moving to
ring 3, threading's shipped design, USB+FAT landing) and a second pass of
adjacent claims the verifiers caught (smp.md 'not built yet' intro,
system-requirements' PS/2-only and no-storage claims, halting.md's red-panic
and no-IDT text, testing.md's serial mirroring, router-era vfs-protocol
wording, capsule-first boot loading).

threading.md now documents the shared-fate gap explicitly: the design says a
process dies whole, the kernel today kills only the offending thread.

Also fixes three stale code comments (isr.s exceptionHandler, acpi.zig
sleepValue, build.zig boot-volume) — comments only, no behavior change.
2026-07-22 09:09:53 +01:00

109 lines
4.3 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# The Framebuffer
## What a framebuffer is
A **framebuffer** is just a big region of memory where each element is one
pixel's color. The display hardware continuously scans this memory and turns
each value into light on the screen. There's no drawing API involved — you
write a 32-bit value to the right address, and a pixel changes color. That's
exactly what `Console.pixel` does:
```zig
self.rowPtr(y)[x] = color; // system/kernel/console.zig
```
Our `Framebuffer` struct (`system/boot-handoff.zig`) is the four facts you need to
address it:
| Field | Meaning |
|----------|---------|
| `base` | the memory address where pixel data starts |
| `width` | visible pixels per row (e.g. 1920) |
| `height` | visible rows (e.g. 1080) |
| `pitch` | **bytes** from the start of one row to the start of the next |
The bootloader (UEFI GOP, in our case) sets all this up and hands it over. The
kernel just writes into it: no firmware, no driver — just pixels.
## The mental model: it's 1D memory pretending to be 2D
The screen is a grid, but memory is a flat line of bytes. So the pixels are
stored row after row, laid end to end:
```
row 0: [px0][px1][px2]...[width-1] <padding?>
row 1: [px0][px1][px2]...[width-1] <padding?>
row 2: ...
```
To find pixel `(x, y)` you compute:
```
address = base + y * (bytes per row) + x * (bytes per pixel)
```
## So what is pitch?
**Pitch is "bytes per row"** — sometimes called *stride*. The obvious guess
would be `pitch = width * 4` (4 bytes = 32 bits per pixel). And often it is.
**But not always** — and that's the whole reason the field exists.
Hardware frequently wants each row to start at a nicely aligned address (a
multiple of 32, 64, or a page). If `width` doesn't land on that boundary, the
firmware pads the end of every row with a few extra unused bytes. That padding
is invisible — it's never shown — but it's physically there in memory between
the last pixel of one row and the first pixel of the next.
Example: a 1366-pixel-wide display at 32bpp:
- `width * 4` = 1366 × 4 = **5464 bytes** of actual pixels
- but `pitch` might be **5504 bytes** (padded up to a multiple of 64)
- those extra 40 bytes per row are dead space
This is exactly why `rowPtr` uses `pitch`, not `width`, to step between rows:
```zig
inline fn rowPtr(self: *Console, y: u32) [*]volatile u32 {
const base: [*]volatile u8 = @ptrFromInt(self.fb.base);
return @ptrCast(@alignCast(base + y * self.fb.pitch)); // <- pitch, not width*4
}
```
Note the deliberate detail: `base` is cast to a **byte** pointer
(`[*]volatile u8`) *before* adding `y * pitch`, because pitch is measured in
bytes. Then it's cast to a `u32` pointer so that `[x]` indexes whole pixels. If
you'd done the arithmetic on a `u32` pointer, `+ pitch` would step `pitch`
*pixels* (4× too far).
### Why you must use pitch, not `width * 4`
If you assumed rows were `width * 4` apart on a display where
`pitch > width * 4`, every row would start a little too early. The error
accumulates: row 0 is fine, row 1 is off by (pitch − width×4) bytes, row 2 by
twice that, and so on. The image ends up **skewed diagonally** — a slanted,
sheared picture — because each row creeps sideways relative to where the
hardware actually reads it.
Using `pitch` is what keeps each row landing exactly where the scanout expects
it.
## Two subtleties worth noting
1. **`width` vs `pitch` in the loops.** In `fillRow` we iterate `x` up to
`self.fb.width` — the *visible* count — but jump between rows with
`pitch`. That's the correct pairing: touch only real pixels, but skip the
full stride (including padding) to reach the next row. We never write into
the padding, which is right. (A `copyRow` used to sit alongside it; it's
gone — `scroll` was since rewritten as a writes-only screen clear, because
reading VRAM back is uncached-slow on real hardware.)
2. **`volatile`.** The pointer is `volatile` because this memory is special —
it's watched by the display hardware. `volatile` tells the compiler *"don't
optimize these writes away or reorder/coalesce them"*; every store must
actually hit memory, because something outside the CPU's knowledge (the
scanout engine) is reading it.
Bottom line: **width is how wide the picture is; pitch is how wide the memory
rows are.** They're usually equal (×4) but not guaranteed to be, so always
advance rows by pitch.