Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ loon/
├── crates/
│ ├── loon-lang/ # Core: parser, type checker, interpreter
│ ├── loon-cli/ # CLI: run, repl, fmt, explain
│ ├── loon-kernel/ # RISC-V unikernel: Loon as the kernel (own workspace)
│ ├── loon-lsp/ # Language server protocol
│ └── loon-wasm/ # WASM bindings for browser
├── web/ # Website (written in Loon, uses .loon files)
Expand All @@ -41,8 +42,16 @@ cargo test --workspace # Run all tests
cargo run -p loon-cli -- run samples/hello.oo # Run a sample
cargo run -p loon-cli -- fmt samples/ # Format files
cargo run -p loon-cli -- new test-proj # Creates pkg.oo + src/main.oo
cargo run -p loon-cli -- image prog.oo # Compile to a bare-metal boot image

make -C crates/loon-kernel run # Boot Loon under QEMU (needs qemu + riscv64gc target)
make -C crates/loon-kernel check # Boot it and diff against the host
```

`crates/loon-kernel` is deliberately outside the root workspace — it only
builds for `riscv64gc-unknown-none-elf` and must not be swept into
`cargo build --workspace`.

## Key Patterns

- Module resolution: tries `.oo` first, falls back to `.loon`
Expand Down
61 changes: 61 additions & 0 deletions crates/loon-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@ enum Command {
#[arg(long)]
release: bool,
},
/// Compile a Loon file to a bare-metal boot image (EIR, no host runtime)
Image {
file: PathBuf,
/// Where to write the image (default: <file>.img next to the source)
#[arg(short, long)]
out: Option<PathBuf>,
},
/// Run a Loon file (interpreter)
Run {
file: PathBuf,
Expand Down Expand Up @@ -189,6 +196,7 @@ fn main() {
Command::Check { ref file, json } => check_file(file, json),
Command::Card { json } => print_card(json),
Command::Build { ref file, release } => build_file(file, release),
Command::Image { ref file, ref out } => build_image(file, out.as_deref()),
Command::Repl => repl::run_repl(),
Command::New { ref name } => new_project(name),
Command::Test { ref file } => test_file(file),
Expand Down Expand Up @@ -1060,6 +1068,59 @@ fn print_card(json: bool) {
}
}

/// Compile to a boot image: EIR with the frontend stripped off.
///
/// The unikernel cannot parse or check, so everything that can fail statically
/// has to fail here instead.
fn build_image(path: &PathBuf, out: Option<&std::path::Path>) {
let source = match std::fs::read_to_string(path) {
Ok(s) => s,
Err(e) => {
eprintln!("{} reading {}: {e}", "error".red().bold(), path.display());
std::process::exit(1);
}
};

precheck_source(path, &source);

let exprs = match loon_lang::parser::parse(&source) {
Ok(exprs) => exprs,
Err(e) => {
eprintln!("{}: parse error: {}", "error".red().bold(), e.message);
std::process::exit(1);
}
};
let base_dir = path.parent().unwrap_or_else(|| std::path::Path::new("."));
let mut checker = loon_lang::check::Checker::with_base_dir(base_dir);
checker.check_program(&exprs);
let module = loon_lang::eir::lower::lower(&checker);
let image = loon_lang::eir::image::encode(&module);

let out_path = match out {
Some(p) => p.to_path_buf(),
None => path.with_extension("img"),
};
if let Some(dir) = out_path.parent() {
let _ = std::fs::create_dir_all(dir);
}
if let Err(e) = std::fs::write(&out_path, &image) {
eprintln!(
"{} writing {}: {e}",
"error".red().bold(),
out_path.display()
);
std::process::exit(1);
}
println!(
" {} {} ({} bytes, {} functions, {} strings)",
"Imaged".green().bold(),
out_path.display(),
image.len(),
module.funcs.len(),
module.strings.len(),
);
}

fn build_file(path: &PathBuf, release: bool) {
let source = match std::fs::read_to_string(path) {
Ok(s) => s,
Expand Down
6 changes: 6 additions & 0 deletions crates/loon-kernel/.cargo/config.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
[build]
target = "riscv64gc-unknown-none-elf"

[target.riscv64gc-unknown-none-elf]
rustflags = ["-C", "link-arg=-Tlink.ld"]
runner = "qemu-system-riscv64 -machine virt -cpu rv64 -smp 1 -m 128M -nographic -serial mon:stdio -bios default -kernel"
1 change: 1 addition & 0 deletions crates/loon-kernel/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
target/
7 changes: 7 additions & 0 deletions crates/loon-kernel/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

19 changes: 19 additions & 0 deletions crates/loon-kernel/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
[package]
name = "loon-kernel"
version = "0.7.0"
edition = "2021"
description = "Loon as a unikernel: the EIR VM on bare metal, drivers as effect handlers."

# Deliberately outside the root workspace: this crate only builds for a
# bare-metal target and must not be swept into `cargo build --workspace`.
[workspace]

[dependencies]

[profile.dev]
panic = "abort"

[profile.release]
panic = "abort"
opt-level = "z"
lto = true
33 changes: 33 additions & 0 deletions crates/loon-kernel/Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Loon unikernel. Requires qemu-system-riscv64 and the riscv64gc target:
# brew install qemu
# rustup target add riscv64gc-unknown-none-elf

KERNEL := target/riscv64gc-unknown-none-elf/release/loon-kernel
QEMU := qemu-system-riscv64
QFLAGS := -machine virt -cpu rv64 -smp 1 -m 128M -nographic -bios default

.PHONY: build run check clean

build:
cargo build --release

# Boot the machine. It powers itself off when init returns.
run: build
$(QEMU) $(QFLAGS) -serial mon:stdio -kernel $(KERNEL)

# The same program on the host, for comparison. Run from the workspace root:
# this directory's .cargo/config.toml pins a bare-metal target that the host
# build must not inherit.
host:
cd ../.. && cargo run -q -p loon-cli -- run crates/loon-kernel/boot/init.oo

# Boot, and prove the machine agrees with the host byte for byte.
check: build
@$(QEMU) $(QFLAGS) -serial mon:stdio -kernel $(KERNEL) < /dev/null 2>/dev/null \
| sed -n '/^hello from loon/,/^init done/p' | tr -d '\r' > /tmp/loon-metal.txt
@cd ../.. && cargo run -q -p loon-cli -- run crates/loon-kernel/boot/init.oo 2>/dev/null > /tmp/loon-host.txt
@diff /tmp/loon-host.txt /tmp/loon-metal.txt \
&& echo "ok: identical output on the host and on bare metal"

clean:
cargo clean
59 changes: 59 additions & 0 deletions crates/loon-kernel/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# loon-kernel — Loon as a unikernel

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

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

make run # boot it
make host # run the same program on the host
make check # boot it and diff the two
```

## What is here

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

The host toolchain is not in this crate's build graph. `build.rs` shells out
to `loon image`, which compiles `boot/init.oo` to a boot image; the kernel
embeds that image and interprets it. Everything upstream of EIR — parser,
checker, ownership, lowering — stays on the host, where it belongs.

## Why the output has to match

`make check` diffs the machine against the host. That diff is the point of
the exercise: the same program, the same effects, two entirely different
bottom halves. If they ever disagree, one of the two runtimes is wrong about
what Loon means, and a language whose semantics depend on where it runs is
not the language we are trying to build.

The interpreter therefore 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: a clause that re-performs its own effect must forward
outward, which only works if capturing moves every handler at or above the
prompt into the continuation. `boot/init.oo` exercises forwarding, aborting
(a clause that never resumes) and non-tail resume for exactly this reason.

## Known limits

- **Cooperative only.** No timer interrupt yet, so a pure loop owns the
machine. Preemption is the next real milestone.
- **Single hart.** The allocator's "lock" is a bare cell because nothing
races with it. SMP needs a real one.
- **Partial builtin set.** Intrinsics the runtime lacks raise a loud error
naming the builtin; they never silently return `()`.
- **Slow.** Roughly 0.9 µs per interpreted op — the interpreter allocates a
register file per call and an operand vector per op, on a first-fit
allocator that costs ~0.5 µs per allocation. Nothing here is tuned yet.
79 changes: 79 additions & 0 deletions crates/loon-kernel/boot/init.oo
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
; The unikernel's init program.
;
; Nothing here knows it is running on bare metal. It performs effects; what
; they mean is decided by whoever handles them, and on this machine the
; outermost handler is a UART driver instead of a syscall. That is the whole
; claim of the design, so this file is deliberately ordinary Loon:
;
; loon run crates/loon-kernel/boot/init.oo ; on the host
; make -C crates/loon-kernel run ; on the machine
;
; Both must print the same thing.

[effect Console
[write [String] Unit]]

; The driver. On the host `print` is a libc write; in the unikernel the same
; builtin lands on the 16550 at 0x10000000. Neither is visible from here.
[fn console [thunk]
[handle
[thunk]
[Console.write s]
[do [print s] [resume []]]]]

; Observability as interposition: prefix each line, then forward the write
; to the next handler out. This only terminates because handlers are deep —
; a clause that re-performs its own effect escapes its own handler.
[fn traced [thunk]
[handle
[thunk]
[Console.write s]
[resume [Console.write [str "[log] " s]]]]]

[fn line [s] [Console.write [str s "\n"]]]

; A handler that never resumes: the clause's value becomes the value of the
; whole `handle`, and the rest of the body is dropped on the floor. Aborting
; is the same mechanism as resuming, minus one call.
[effect Halt
[stop [String] Unit]]

[fn guarded [thunk]
[handle
[thunk]
[Halt.stop why]
[str "aborted: " why]]]

; A non-tail resume: work happens after the continuation comes back, so the
; clause frame has to survive the resumed segment.
[effect Ask
[num [] Int]]

[fn doubling [thunk]
[handle
[thunk]
[Ask.num]
[+ 1000 [resume 7]]]]

[fn fib [n]
[if [< n 2] n [+ [fib [- n 1]] [fib [- n 2]]]]]

[fn main []
[console
[fn []
[line "hello from loon, running as the kernel"]

; Pure compute: recursion, closures, the sequence intrinsics.
[line [str "fib 0..14 " [map [range 0 15] fib]]]
[let evens [map [range 1 11] [fn [n] [* 2 n]]]]
[line [str "evens<=20 " evens]]
[line [str "their sum " [fold evens 0 [fn [a b] [+ a b]]]]]

; Interposition: the inner write is handled twice on the way out.
[traced [fn [] [line "this write passed through a wrapping handler"]]]

; Effects that abort, and effects that resume in non-tail position.
[line [str "abort " [guarded [fn [] [do [Halt.stop "on purpose"] "unreachable"]]]]]
[line [str "non-tail " [doubling [fn [] [* 2 [Ask.num]]]]]]

[line "init done"]]]]
42 changes: 42 additions & 0 deletions crates/loon-kernel/build.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
//! Compile `boot/init.oo` into a boot image and hand it to the kernel.
//!
//! The unikernel has no frontend, so this is where a Loon program stops
//! being source. Compiling through the workspace CLI (rather than linking
//! loon-lang directly) keeps the host toolchain entirely out of the
//! bare-metal build graph.

use std::path::PathBuf;
use std::process::Command;

fn main() {
let manifest = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap());
let src = manifest.join("boot/init.oo");
let out = PathBuf::from(std::env::var("OUT_DIR").unwrap()).join("init.img");
let workspace = manifest.join("../../Cargo.toml");

println!("cargo:rerun-if-changed={}", src.display());
println!("cargo:rerun-if-changed=build.rs");

let status = Command::new(std::env::var("CARGO").unwrap_or_else(|_| "cargo".into()))
// Run from the workspace root: this crate's .cargo/config.toml pins
// a bare-metal target, and the nested build must not inherit it.
.current_dir(manifest.join("../.."))
.args(["run", "-q", "--manifest-path"])
.arg(&workspace)
.args(["-p", "loon-cli", "--", "image"])
.arg(&src)
.arg("-o")
.arg(&out)
// Cargo's env leaks the bare-metal target into the nested build and
// makes it try to compile the compiler for riscv; clear it.
.env_remove("CARGO_ENCODED_RUSTFLAGS")
.env_remove("RUSTFLAGS")
.env_remove("CARGO_BUILD_TARGET")
.status()
.expect("failed to run loon-cli to build the boot image");

if !status.success() {
panic!("building the boot image from {} failed", src.display());
}
println!("cargo:rustc-env=LOON_BOOT_IMAGE={}", out.display());
}
36 changes: 36 additions & 0 deletions crates/loon-kernel/link.ld
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/* QEMU `virt` riscv64. OpenSBI runs in M-mode at 0x80000000 and hands us
S-mode control at 0x80200000, so that is where the image must start. */
OUTPUT_ARCH(riscv)
ENTRY(_start)

MEMORY {
RAM (rwxa) : ORIGIN = 0x80200000, LENGTH = 120M
}

SECTIONS {
.text : {
KEEP(*(.text.entry))
*(.text .text.*)
} > RAM

.rodata : { *(.rodata .rodata.*) } > RAM
.data : { *(.data .data.*) *(.sdata .sdata.*) } > RAM

.bss (NOLOAD) : {
__bss_start = .;
*(.bss .bss.*) *(.sbss .sbss.*) *(COMMON)
__bss_end = .;
} > RAM

/* Boot stack, then everything above is the heap. */
. = ALIGN(16);
__stack_bottom = .;
. += 512K;
__stack_top = .;

. = ALIGN(4096);
__heap_start = .;
__heap_end = ORIGIN(RAM) + LENGTH(RAM);

/DISCARD/ : { *(.eh_frame .eh_frame_hdr) }
}
Loading
Loading