Files
danos/docs/halting.md
T
Daniel Samson 8754d4e46a Re-organize the source tree as a monorepo mirroring the FHS
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.
2026-07-10 12:55:56 +01:00

5.9 KiB

Halting

Why a kernel needs to halt

An ordinary program ends by returning — main finishes, the C runtime calls exit, and the OS reclaims the process. A kernel has none of that. There is no OS underneath it, no runtime to return to, and no caller waiting. _start is the end of the line. So when the kernel has nothing left to do — whether it finished its work or hit a fatal error — it can't "quit". It has to explicitly park the CPU forever, because if execution ever ran off the end it would just keep fetching whatever bytes follow in memory and execute garbage.

That's what halting is: deliberately stopping the processor so it does nothing, safely, until the machine is reset or powered off.

The core of it: hlt

Everything comes down to one x86 instruction. It's CPU-specific, so it lives in the arch module, system/kernel/architecture/x86_64/cpu.zig (see arch.md), and the generic kernel calls it as arch.halt():

/// Park the core forever. `hlt` drops it into a low-power idle until the next
/// interrupt; the loop re-halts on every wake so the stop is permanent.
pub fn halt() noreturn {
    while (true) asm volatile ("hlt");
}

hlt ("halt") tells the CPU core to stop executing instructions and drop into a low-power idle state. It's not a busy-wait — the core genuinely stops, drawing almost no power and generating almost no heat, until something wakes it.

This is much better than the naïve alternative, a spin loop:

while (true) {}   // "busy-wait" — DON'T do this to idle

A bare while (true) {} keeps the core running flat out, executing the jump back to the top of the loop billions of times a second — 100% CPU, hot, and (on a laptop) draining the battery, all to accomplish nothing. hlt achieves the same "do nothing" outcome while letting the core sleep.

Why the loop around it?

Here's the subtlety the comment points at: hlt is not permanent. It halts the core only until the next interrupt arrives. An interrupt is a signal — from a timer, a keypress, a device — that wakes the CPU so it can respond. When one fires, the core comes out of hlt and executes the next instruction.

If we wrote just a single hlt, the very first stray interrupt would wake the core and execution would continue past it — falling off the end of the function into whatever comes next in memory. Wrapping it in while (true) closes that door: every time an interrupt wakes the core, the loop immediately runs hlt again and it goes back to sleep. The net effect is a permanent halt that still sleeps between the interrupts it can't prevent.

(At this stage danos hasn't set up an interrupt descriptor table, so most interrupts aren't even something we handle — but non-maskable interrupts and system-management interrupts can still wake a halted core regardless. The loop makes us robust to all of them.)

The asm volatile part

hlt has no equivalent in plain Zig, so we drop to inline assembly:

  • asm emits the raw instruction directly into the function.
  • volatile tells the compiler "this has side effects you can't see — do not optimize it away or reorder it." Without it, an optimizing compiler might reason that the assembly produces no value anyone uses and delete it, or hoist it somewhere wrong. volatile pins it exactly where we wrote it.

noreturn: telling the compiler it's the end

halt() is typed noreturn — a real Zig type meaning "this function never gives control back to its caller." That isn't decoration; it changes how the compiler treats the call:

  • Code after a noreturn call is unreachable, so the compiler needn't emit a return sequence, and won't warn about "missing return value" in the callers.
  • It lets kmain and _start themselves be noreturn, which is the honest signature for a kernel entry point — the bootloader jumps in and nothing ever jumps back out.

You can see the chain in system/kernel/main.zig: _start is noreturn, it calls kmain which is noreturn, which ends by calling arch.halt() which is noreturn. The "never returns" property is threaded all the way down.

Where danos halts

There are three halt sites, and they're all the same idea:

  1. Normal end of kernel work — kmain prints its status, then calls arch.halt():

    con.write("\nkernel initialised; nothing left to do, halting.\n");
    arch.halt();
    

    There's genuinely nothing more to do yet, so the kernel parks itself.

  2. Kernel panic — the freestanding panic handler has no OS to report to, so it prints the message in red (if the console is up) and halts via the same arch.halt(). A panic is unrecoverable here, so stopping the machine — rather than limping on with corrupted state — is the safe response.

  3. Bootloader failure — in boot/efi.zig, if boot() fails before handing off to the kernel, main logs the error and parks the machine with the same loop so the message stays on screen:

    boot() catch |err| {
        log("\r\ndanos: boot failed: ");
        logBytes(@errorName(err));
        log("\r\n");
        while (true) asm volatile ("hlt");
    };
    

    (Here it's an inline loop rather than arch.halt() because that lives in the kernel's arch module, and the loader is a separate binary from the kernel.)

Summary

  • A kernel can't "exit" — it must explicitly stop the CPU or it runs off into garbage.
  • hlt parks the core in a low-power idle until the next interrupt — far better than a 100%-CPU spin loop.
  • while (true) hlt makes that halt permanent, since any interrupt would otherwise wake the core and let execution continue.
  • asm volatile emits the instruction and forbids the compiler from removing it; noreturn encodes "control never comes back" into the type system.
  • danos halts on normal completion, on a kernel panic, and on a bootloader error — the same "stop safely and stay stopped" in all three.