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.zigbuildsBOOTX64.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 neutralBootInformation.
Current x86_64 contents
system/kernel/architecture/x86_64/cpu.zig— thearchitecturemodule root. Exposes the trap-frameCpuStateand 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, thelgdt/lidt/ltrload 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
architecturesurface 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.