danos/docs/os-development/architecture.md

6.2 KiB

Architecture split

danos targets x86_64 today, but is meant to grow onto other systems later — a Raspberry Pi, say, which is AArch64 and has no UEFI. To keep that possible without a rewrite, CPU-specific kernel code lives behind a boundary: the generic kernel never names an architecture, and each architecture plugs in behind it.

The seam is a build-time module named architecture

The mechanism is deliberately boring — no vtables, no function-pointer tables, no runtime dispatch. build.zig exposes one architecture's code as a module called architecture:

const architecture_module = b.addModule("architecture", .{
    .root_source_file = b.path("system/kernel/architecture/x86_64/cpu.zig"),
});

and the generic kernel imports it by that name:

const architecture = @import("architecture");
// ...
architecture.halt();          // never says "x86_64"

Adding a second architecture is then a build-time choice: create system/kernel/architecture/aarch64/, and point the architecture module at it when the target CPU is AArch64. kernel.zig and console.zig don't change. That compiler-checked module boundary is the architecture interface — when a new architecture is missing a function the generic kernel calls, the build fails and names exactly what's missing.

What's arch-specific vs generic

The split follows a simple test: does it name a CPU instruction, a hardware register, or a memory-management structure? If so, it's arch-specific.

Arch-specific — system/kernel/architecture/x86_64/ Generic — kernel core
cpu.zig: CPU state, trap-frame accessors, paging, SMP console.zig — pure pixel math, framebuffer drawing
gdt.zig, idt.zig, tss.zig — descriptor tables kernel.zig — kernel orchestration, scheduler, IPC
paging.zig — page-table setup and management process.zig — process lifecycle, address spaces
apic.zig, ioapic.zig — interrupt controllers scheduler.zig — task scheduling and context switch
serial.zig, io.zig — UART, I/O primitives vfs.zig — filesystem abstraction
isr.s, smp.zig — exceptions, AP bring-up, context switch irq.zig, ipc*.zig — interrupt dispatch, messaging
linker.ld — kernel link layout, load address

Notice the framebuffer console is generic: it just writes pixels into whatever framebuffer it's handed, so it needs no per-arch version. Most of the kernel should end up on the generic side; the architecture module stays small.

Two axes, kept separate

There are really two independent questions, and it's worth not conflating them:

  • CPU architecture (x86_64 vs AArch64): instructions, MMU, interrupts → system/kernel/architecture/<cpu>/.
  • Boot protocol (UEFI vs Raspberry Pi firmware + device tree): handled separately, because loaders are their own binaries. boot/efi.zig builds BOOTX64.efi, a distinct executable from the kernel ELF. On a Pi there is no separate loader at all — the firmware jumps straight into the kernel with a device-tree pointer, so that entry work would live in the AArch64 architecture code. Either path converges on the same neutral BootInformation.

Current x86_64 contents

  • system/kernel/architecture/x86_64/cpu.zig — the architecture module root. Exposes the trap-frame CpuState and accessors, init() (bring up the descriptor tables), enablePaging(), enterUser()/userExit() for ring-0 ↔ ring-3 transitions, address-space management, and SMP entry points (see halting.md, interrupts.md, paging.md, scheduling.md).
  • system/kernel/architecture/x86_64/gdt.zig / idt.zig / tss.zig — the GDT, IDT and TSS plus CPU-exception handling (see interrupts.md).
  • system/kernel/architecture/x86_64/paging.zig — the kernel's page tables and address-space management (see paging.md).
  • system/kernel/architecture/x86_64/apic.zig / ioapic.zig — the Local APIC, its timer, and the I/O APIC for device interrupts (see device-interrupts.md).
  • system/kernel/architecture/x86_64/serial.zig / io.zig — the COM1 UART (the kernel's machine-readable log channel, see testing.md) and the shared port-I/O + MSR primitives.
  • system/kernel/architecture/x86_64/smp.zig / per-cpu.zig — application-processor bring-up and per-CPU state (GS base, system-call entry point, see scheduling.md).
  • system/kernel/architecture/x86_64/isr.s — the exception stubs, the lgdt/lidt/ltr load helpers, ring-0 ↔ ring-3 transitions, and the context switch — real assembly, since Zig inline asm can't express them (see scheduling.md).
  • system/kernel/architecture/x86_64/linker.ld — the kernel link layout (fixed low load address, one PT_LOAD per permission set).

The kernel entry point _start lives in the architecture-specific isr.s (x86_64 here). On x86_64 it sets up the kernel stack in BSS and jumps to kmain() in kernel.zig. This is already per-architecture — an AArch64 port would have its own isr.s entry that parses the device-tree pointer from a register and jumps to the same kmain(). The entry interface is minimal and emerges naturally from the boot-handoff contract both share.

The discipline

The thing that makes this help rather than hurt: only extract what's provably architecture-specific, and let the interface emerge with the second implementation. With a single architecture you're guessing at the seam, and a wrong guess encoded as elaborate abstraction is expensive to undo. So:

  • Move code into architecture/ only when it genuinely names CPU-specific machinery.
  • Grow the architecture surface one function at a time, as steps need it.
  • Don't pre-design the interrupt or paging interfaces before writing them.

Directory hygiene is cheap and reversible; premature abstraction is neither. When architecture #2 lands and something doesn't fit, reshaping a few hundred lines is nothing — unwinding an abstraction empire is not.