The source layout now mirrors the runtime filesystem hierarchy
(docs/danos-file-system-hierarchy-FSH.md): what lives under system/ in the
source is what a running danos represents under /system. Each service and
driver is a sub-project directory that is its own Zig module — cross-project
references go by module name, never by a path into another project's files.
Moves (all git mv, history preserved):
- src/ -> system/ (danos internals; the self-representation)
root.zig -> danos.zig (the kernel<->user contract module)
kernel/arch/ -> kernel/architecture/ (arch -> architecture)
device/ -> devices/ (what /system/devices reflects)
boot/ -> /boot (the loaders, top level)
- sbin/ -> split by role:
init, vfs -> system/services/<name>/<name>.zig
hpetd, busd -> system/drivers/<name>/<name>.zig
vfs-test -> system/services/vfs/vfs-test.zig (inside the vfs project)
- lib/ -> library/runtime/ (room for other libraries beside runtime)
The VFS wire protocol becomes its own module, system/services/vfs/protocol.zig
("vfs-protocol"): the vfs sub-project exposes its interface, and the runtime's
file layer imports it by name. First instance of the "protocol module" pattern
(docs/driver-model.md); usb/block will expose theirs the same way.
Also: fix a naming-standard violation in the protocol — Op -> Operation (and
req -> request, _pad -> _padding). Docs updated: /system/services added to the
FHS doc, a repository-layout section added to the docs index, and stale source
paths swept across comments and docs.
Runtime boot paths are unchanged (the bootloader still loads /sbin/init);
aligning the runtime filesystem to the FHS is a separate follow-up. Suite 35/35
plus host tests green.
4.1 KiB
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:
self.rowPtr(y)[x] = color; // system/kernel/console.zig
Our Framebuffer struct (system/danos.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
pitchmight 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:
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
-
widthvspitchin the loops. InfillRow/copyRowwe iteratexup toself.fb.width— the visible count — but jump between rows withpitch. 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. -
volatile. The pointer isvolatilebecause this memory is special — it's watched by the display hardware.volatiletells 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.