Skip to content

Phase 3: boot Loon as a unikernel under QEMU - #90

Merged
ecto merged 2 commits into
mainfrom
claude/os-work-progress-212c0b
Aug 18, 2026
Merged

Phase 3: boot Loon as a unikernel under QEMU#90
ecto merged 2 commits into
mainfrom
claude/os-work-progress-212c0b

Conversation

@ecto

@ecto ecto commented Aug 18, 2026

Copy link
Copy Markdown
Owner

A RISC-V machine whose kernel is a Loon program. No userspace, no syscall
boundary, no OS underneath: boot/init.oo performs effects, and the outermost
handler is a UART driver instead of a call into Linux.

brew install qemu
rustup target add riscv64gc-unknown-none-elf

make -C crates/loon-kernel run      # boot it
make -C crates/loon-kernel check    # boot it, diff against `loon run`

This is the phase-3 exit criterion from docs/plans/2026-07-01-loon-os.md,
and the thing that makes the syscalls-as-effects thesis concrete rather than a
design doc.

Shape

The frontend stays hosted. A new loon image subcommand serializes a lowered
EIR Module (crates/loon-lang/src/eir/image.rs); the kernel's build.rs
invokes it, embeds the bytes, and interprets them. No parser, checker or
lowering enters the bare-metal build graph.

crates/loon-kernel/src/main.rs entry, .bss clear, stack, the Host impl that is the machine
crates/loon-kernel/src/eir/ boot-image decoder and the EIR interpreter
crates/loon-kernel/src/heap.rs first-fit allocator over the ~120 MiB above the image
crates/loon-kernel/src/uart.rs NS16550a console driver
crates/loon-kernel/src/mmio.rs the one place that touches device registers
crates/loon-kernel/src/sbi.rs the slice of SBI we need (power off)
crates/loon-kernel/boot/init.oo init — ordinary Loon

The invariant that matters

make check diffs the machine against loon run on the same source, and the
output is byte-identical. A language whose semantics depend on where it runs is
not what we are building, so the kernel interpreter mirrors the host VM's
structure rather than reimplementing it freely: same frame stack, same handler
stack keyed by prompt depth, same continuation capture on perform, same
depth-pruned ephemeral handlers.

Deep-handler semantics are load-bearing — a clause that re-performs its own
effect must forward outward — so boot/init.oo deliberately exercises
handler forwarding, abort (a clause that never resumes), and non-tail resume
on hardware. All three match the host.

Reviewer notes

  • crates/loon-kernel is its own workspace ([workspace] in its
    Cargo.toml). It only builds for riscv64gc-unknown-none-elf and must not be
    swept into cargo build --workspace. Host-side cargo commands run from that
    directory must cd ../.. first or they inherit the bare-metal target from
    its .cargo/config.toml — this bit both build.rs and the Makefile.
  • Cross-crate ABI with no type-system link. The image carries a builtin
    name table so intrinsics dispatch on names rather than enum discriminants.
    BinOp/UnOp are still numeric tags, pinned by
    crates/loon-lang/tests/boot_image.rs — reordering either enum without
    updating decode.rs would otherwise silently remap an operator.
  • One real bug found along the way: EIR call arguments bind to registers
    0..n positionally, not to the entry block's params. Getting this wrong
    fails far from its cause ("cannot call a unit in tail position").
  • crates/loon-lang/tests/unikernel_boot.rs runs make check and skips (does
    not fail) when qemu or the riscv target is absent, so contributors without
    the bare-metal toolchain are unaffected.
  • The kernel builds with zero warnings; cargo test --workspace is green.

What this does not deliver

  • "Syscall cost = function call cost" is not demonstrated. A function call
    is currently the expensive thing. Measured rather than assumed: ~0.9 µs per
    interpreted op, ~0.5 µs per allocation, with the UART accounting for only
    ~0.4 ms of the ~30 ms demo run. A call allocates a register file and every op
    allocates an operand vector.
  • No preemption. Cooperative only — no timer interrupt, so a pure loop owns
    the machine.
  • No SMP (the allocator's "lock" is a bare cell because nothing races with
    it yet), and no static handler resolution.
  • Partial builtin set. Unimplemented intrinsics raise a loud error naming
    the builtin; they never silently return ().

Next, in order: a slab allocator plus operand-vector reuse to make the perf
claim real, then the timer interrupt for preemption.

🤖 Generated with Claude Code

A RISC-V machine whose kernel is a Loon program. No userspace, no syscall
boundary, no OS underneath: init performs effects and the outermost handler
is a UART driver instead of a call into Linux.

    make -C crates/loon-kernel run      # boot it
    make -C crates/loon-kernel check    # boot it, diff against `loon run`

The frontend stays hosted. `loon image` serializes a lowered EIR module
(eir/image.rs); the kernel's build.rs invokes it, embeds the result and
interprets it, so no parser, checker or lowering enters the bare-metal build
graph. The kernel is its own workspace — it only builds for
riscv64gc-unknown-none-elf and must not be swept into --workspace.

The kernel interpreter mirrors the host VM's structure rather than
reimplementing it freely: same frame stack, same handler stack keyed by
prompt depth, same continuation capture on `perform`. Deep-handler semantics
are load-bearing, so boot/init.oo exercises handler forwarding, abort
(a clause that never resumes) and non-tail resume on hardware. `make check`
diffs the machine against the host byte for byte, and a skip-if-unavailable
test (tests/unikernel_boot.rs) keeps that honest in CI. A language whose
semantics depend on where it runs is not what we are building.

The image carries a builtin name table so intrinsics dispatch on names, not
on enum discriminants; tests/boot_image.rs pins the operator tags that are
still numeric, since nothing in the type system connects the two crates.

Not yet: preemption (cooperative only — a pure loop owns the machine), SMP,
static handler resolution, most of the builtin set (missing ones raise a
loud error naming the builtin, never a silent unit). Untuned: ~0.9 us per
interpreted op, since a call allocates a register file and each op allocates
an operand vector over a first-fit allocator at ~0.5 us per allocation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Aug 18, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
loon Ready Ready Preview Aug 18, 2026 2:22pm

Request Review

CI's `cargo fmt --check` rejected three of the new files; formatting only,
no behaviour change.

Also clean the kernel's own clippy run (it sits outside the workspace, so CI
never reaches it): drop a stray blank line after an attribute, and pass the
higher-order intrinsics' element through `slice::from_ref` instead of cloning
it into a one-element array — one less clone per element in map/filter/each.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@ecto
ecto merged commit c963545 into main Aug 18, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant