knekt
knekt is the kernel core. most of what the kernel does lives in the named parts around it, and knekt is the piece that wires them together.
hand-off
GRUB boots the kernel through Multiboot and lands in a short piece of hand-written
assembly, which sets up just enough of the machine to call
c0 code, then calls knekt's main. the
two sides cannot call each other by name. c0 has no way to reference an
externally-defined assembly symbol, and main takes no
parameters.so the Multiboot info pointer GRUB leaves in a register does not arrive as an argument, the assembly stashes it at a fixed address as its very first instruction, and c0 reads it back from that literal address.
everything they exchange goes through fixed memory slots, pinned by the
linker. the assembly writes the addresses of its interrupt stubs into known slots
for c0 to read, and c0 writes back the addresses of the structures it builds (the
interrupt table, the page tables) for the assembly to load.
linker-defined symbols such as the scratch buffer, the interrupt descriptor table
and the dispatch table are resolved through c0's extern
declarations, so a rebuild never needs an address patched by hand.
bring-up
main wakes the machine a piece at a time.
first the framebuffer is probed (punkt), with VGA
text as the fallback, then the keyboard's scancode tables, then
jenna comes up in stages, the physical page
allocator from the bootloader's memory map, the kernel heap on top of it, and
finally page tables the kernel builds for itself. last, main fills the
interrupt table and programs the timer, and returns to the assembly, which switches
to the new page tables, loads the table, and enables interrupts. only then does the
kernel draw its first pixels, tile the screen into windows
(kakel), and start a shell in each of them
(skalman) along with the scheduler
(chrone), and brings up the
in-memory filesystem (jakel).
everything is bytes
c0 can read and write memory one byte or eight bytes at a time, and nothing else.
knekt supplies the helpers that bridge the gap, a 32-bit little-endian read
reassembled from four bytes (Multiboot's info structure is all 32-bit fields), and
16- and 32-bit writes for building interrupt table entries. they are plain c0
functions.c0 has no bitwise operators, so "the low byte" is spelled v - (v / 256) * 256. the same divide-and-remainder idiom recurs anywhere the kernel needs a bit or a field out of a word.
when things go wrong
every CPU exception vector is installed. the page fault handler recovers, demand-mapping the missing page and letting the faulting instruction retry (that is jenna's demand paging). the rest share a single panic handler that prints the vector, the error code, the faulting address, and the instruction pointer (RIP) over serial, then halts the CPU. the double fault, the exception that fires when the exception path itself is broken, gets a dedicated stack of its own,a double fault usually means the interrupted stack is unusable. handling it on that same stack would fault again, and a triple fault silently resets the machine. the dedicated stack is what turns that reset into a readable panic. so even a wrecked stack ends in a readable message rather than a silent reboot.
ring 3
user programs run in ring 3 and talk to the kernel through the
syscall instruction. the gdt has seven entries, which are
null, kernel code, kernel data, the task-state segment (tss, which takes
two), user data and user code. the star, lstar and sfmask msrs are set
up at boot and efer.sce is enabled, so syscall and
sysretq are valid instructions.
two hand-written assembly trampolines live in a dedicated
.ring3_text section. syscall_entry is the
target of lstar. its first act is to switch to the calling task's own
kernel stack, the one the tss rsp0 field points at, because unlike an
interrupt gate syscall does not switch stacks on its
own.no register is free to carry the user's stack pointer across that switch, every one of them is either syscall abi data or callee-saved, so it goes through a scratch memory slot and is pushed onto the new stack in the very next instruction, before anything else can run.
it then saves the ring-3 registers, marshals the syscall abi's argument
registers into the sysv calling convention, calls
sys_dispatch in c0, which switches on the syscall number
and runs the matching call from the table below, then restores
everything and does sysretq back to ring 3.
enter_ring3 is how a process starts. it stores the task's
kernel stack top in tss rsp0, loads the process's own page-table root
into cr3 and builds an iretq frame with the user selectors,
the entry point and the user stack, then executes it.
every ring-3 process gets its own page-table root
(see jenna) rather than a
slice of the kernel's identity map, its own kernel stack and its own
slot in the scheduler's per-task tables. programs arrive as elf64
executables, usually read out of
jakel. the loader checks
the header (64-bit, little-endian, x86-64, statically linked) and maps
the pt_load segments into the private address space, and
nothing else. there are no relocations, no shared libraries and no
section headers, since a static executable does not need any of them.
the user stack page is placed right after the highest loaded segment
and a fixed-size heap region is mapped eagerly after that for
SYS_ALLOC to hand out. a tiny hand-built flat binary,
a few instructions and a string assembled in the kernel's scratch
buffer, is kept around as a smoke test of the same path.
gnista syscalls
beyond exit and debug print, the ring-3 syscall surface is the set of calls gnista, the game engine, runs on.
| num | name | args | returns |
|---|---|---|---|
| 2 | SYS_WIN_INFO | none | width * 65536 + height |
| 3 | SYS_FILL | x, y, w, h, color | none |
| 4 | SYS_KEY_POLL | none | make_code * 2 + is_break or -1 |
| 5 | SYS_BLIT | buf, x, y, w, h | none |
| 6 | SYS_ALLOC | size | address, or 0 if exhausted |
| 7 | SYS_TICKS | none | the kernel's monotonic ~100hz pit tick count |
| 8 | SYS_READFILE | name, buf, max | bytes read, or the size when buf is 0, or -1 |
| 9 | SYS_PRESENT | buf | 0, or -1 with no window |
| 10 | SYS_WIN_MAX | none | width * 65536 + height |
SYS_FILL fills a rectangle in the task's own kakel window
with a 32-bit RRGGBB colour, clipping to window bounds and framebuffer
edges. SYS_KEY_POLL drains a ring buffer that the
keyboard isr pushes every make and break into, independently of the
shell's line-buffered input path, so a ring-3 game can poll for raw key
events while the shell still receives its own keystrokes. there is one
such ring per task rather than one for the whole system, and the section on
routing below is about why.
SYS_BLIT copies a caller-owned buffer of raw bgr0 pixels
into the task's window in one call, so a multi-step draw (erase, draw,
overlay) can be composited locally and presented atomically, rather
than torn by a task switch landing between several separate draw
calls. SYS_ALLOC hands each ring-3 process its own
fixed-size heap, bump-allocated, no free, a game process's heap lives and
dies with the process. SYS_TICKS gives a ring-3 program
real elapsed time to drive frame-rate-independent movement and
animation, instead of tying speed to raw loop-iteration speed.
SYS_READFILE is what lets a ring-3 program load an asset
at run time rather than having every sprite baked into the executable
by a host script, where changing one pixel means a rebuild. calling it
with a null buffer
returns the file's size rather than copying anything, which is how a
program sizes its alloc() first, which is why there is no
separate stat call. it is also the only one of these that WRITES
through a caller-supplied pointer rather than merely reading one, so
unlike the others it checks that the buffer really is a user address.
that is not a security model, ring 3 has none here yet, it is just refusing the one
operation that could have the kernel scribble over itself.
SYS_PRESENT is SYS_BLIT's opposite number, for
programs that redraw everything. a scrolling camera changes every pixel
when it moves, and blit's per-pixel work then becomes the frame budget.
it rebuilds each pixel from four bytes, tests it against colour 0 so
sprites can have holes, and writes it through a chain that bottoms out
in four single-byte stores, with divisions on the way. call it eight
memory accesses and ten arithmetic operations per pixel. present does
none of that, it copies whole rows, eight bytes at a time, no per-pixel branch
and no arithmetic at all. it can, because a full-window present has no
transparency to honour, every pixel being replaced anyway, and because
kakel's geometry is cell aligned, so a row is always a whole number of
8-byte groups. that is the same alignment argument kakel's own scroll
already leans on.
the subtle part is which pitch it reads the caller's buffer at. it uses
the buffer's own geometry, meaning whatever SYS_WIN_INFO
last reported to that task, not the window's size today. those are the
same number right up until the layout reflows underneath a running
program, which happens on every split, close and windowed spawn. reading
at the window's pitch instead would be wrong in both directions, a
wider window would read each source row past its end into the start of
the next and eventually off the mapped part of the heap, and a narrower
one would read rows short and shear the image diagonally. reading at the
buffer's own pitch and copying the overlap of the two rectangles keeps
the picture intact and correctly shaped. it simply does not fill a
window that has grown, or shows only the top-left corner of one that
has shrunk. wrong size, right picture, which is a much better failure
than a page fault.
SYS_WIN_MAX is what lets a program stop being wrong-sized
at all, and it exists because of a constraint two syscalls up the table.
SYS_ALLOC is bump-only with no free, so a program cannot
grow its frame buffer when its window grows. so it does not try. it asks
once for the largest window this machine can ever hand out, the full
framebuffer minus the status bar, allocates for that worst case, and
renders a sub-rectangle. the distinction from SYS_WIN_INFO
is the whole point. win_info is a measurement and changes on every
reflow, win_max is a ceiling and never does. one is what you draw, the
other is what you budget for. it is on purpose not reduced by a
tavla's title inset, because that inset only makes a window smaller and
a program that budgeted for the un-inset size is still correct.
matching builtins (win_info(), fill(),
key_poll(), blit(), alloc(),
ticks(), readfile(), present(),
win_max()) are available in coff's
--elf mode, which is how
gnista reaches them.
all three of the drawing calls translate window-relative coordinates into absolute screen ones, which means they need the size of a character cell, and they take it from punkt's font constants rather than carrying a literal of their own.
where a keystroke goes
one keyboard, one interrupt, and several things that all reasonably want the keys, the shell, a text editor, a pixel editor and any number of graphical programs in their own windows. deciding where a keystroke goes is two separate questions. the first is whether the shell should act on a key, and that is settled by focus. the second is whether a ring-3 program should see it, and that has to be asked separately, because a program does not go through the focus path at all. it reads its poll ring directly, so if the isr pushed to that ring unconditionally, typing at a focused shell would also drive the game in the window next to it. the isr therefore pushes only when a tavla holds focus, and only to that window's owning task.
there is one ring per task slot, carved out of a single allocation and indexed by task id, the same shape the window table and the per-process heaps have. a single shared ring would be fine exactly as long as one graphical program ran at a time, and the moment two could, whichever one polled first would drain events meant for the other. input is that kind of resource too.
two smaller decisions inside the ring are worth recording. the ring is deep, because a game tracking keys as held state treats a dropped release as a key that never came back up, so the player walks off on their own until the direction is pressed and released again, and a program descheduled for a moment can easily bank more events than a small ring holds. and when a full ring has to discard something it discards the newest event rather than the oldest. a program that is not draining is already behind, and throwing away what it has not seen yet desynchronises make and break pairs worse than refusing the new one. the ring is also cleared when a task slot is reused, so a fresh process never inherits keystrokes typed at whatever ran there before it.