diff --git a/CLAUDE.md b/CLAUDE.md index 10aa2a0..f3cbfd5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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) @@ -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` diff --git a/crates/loon-cli/src/main.rs b/crates/loon-cli/src/main.rs index 2eb4c4d..9408bea 100644 --- a/crates/loon-cli/src/main.rs +++ b/crates/loon-cli/src/main.rs @@ -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: .img next to the source) + #[arg(short, long)] + out: Option, + }, /// Run a Loon file (interpreter) Run { file: PathBuf, @@ -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), @@ -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, diff --git a/crates/loon-kernel/.cargo/config.toml b/crates/loon-kernel/.cargo/config.toml new file mode 100644 index 0000000..4a796c5 --- /dev/null +++ b/crates/loon-kernel/.cargo/config.toml @@ -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" diff --git a/crates/loon-kernel/.gitignore b/crates/loon-kernel/.gitignore new file mode 100644 index 0000000..2f7896d --- /dev/null +++ b/crates/loon-kernel/.gitignore @@ -0,0 +1 @@ +target/ diff --git a/crates/loon-kernel/Cargo.lock b/crates/loon-kernel/Cargo.lock new file mode 100644 index 0000000..f97bef9 --- /dev/null +++ b/crates/loon-kernel/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "loon-kernel" +version = "0.7.0" diff --git a/crates/loon-kernel/Cargo.toml b/crates/loon-kernel/Cargo.toml new file mode 100644 index 0000000..4b981ba --- /dev/null +++ b/crates/loon-kernel/Cargo.toml @@ -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 diff --git a/crates/loon-kernel/Makefile b/crates/loon-kernel/Makefile new file mode 100644 index 0000000..8abda5c --- /dev/null +++ b/crates/loon-kernel/Makefile @@ -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 diff --git a/crates/loon-kernel/README.md b/crates/loon-kernel/README.md new file mode 100644 index 0000000..aee5e79 --- /dev/null +++ b/crates/loon-kernel/README.md @@ -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. diff --git a/crates/loon-kernel/boot/init.oo b/crates/loon-kernel/boot/init.oo new file mode 100644 index 0000000..74b614e --- /dev/null +++ b/crates/loon-kernel/boot/init.oo @@ -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"]]]] diff --git a/crates/loon-kernel/build.rs b/crates/loon-kernel/build.rs new file mode 100644 index 0000000..5f6e022 --- /dev/null +++ b/crates/loon-kernel/build.rs @@ -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()); +} diff --git a/crates/loon-kernel/link.ld b/crates/loon-kernel/link.ld new file mode 100644 index 0000000..dac4997 --- /dev/null +++ b/crates/loon-kernel/link.ld @@ -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) } +} diff --git a/crates/loon-kernel/src/eir/decode.rs b/crates/loon-kernel/src/eir/decode.rs new file mode 100644 index 0000000..1e726e5 --- /dev/null +++ b/crates/loon-kernel/src/eir/decode.rs @@ -0,0 +1,269 @@ +//! Boot image → `Module`. +//! +//! Mirrors `loon_lang::eir::image::encode` byte for byte. A malformed image +//! is a hard error: this runs before anything else, and a VM built on a +//! half-decoded module fails much later and much more confusingly. + +use super::*; +use alloc::string::{String, ToString}; +use alloc::vec::Vec; + +pub const MAGIC: &[u8; 8] = b"LOONIMG\0"; +pub const VERSION: u32 = 1; + +pub struct Dec<'a> { + buf: &'a [u8], + pos: usize, +} + +type R = Result; + +impl<'a> Dec<'a> { + fn take(&mut self, n: usize) -> R<&'a [u8]> { + if self.pos + n > self.buf.len() { + return Err("boot image truncated".to_string()); + } + let s = &self.buf[self.pos..self.pos + n]; + self.pos += n; + Ok(s) + } + fn u8(&mut self) -> R { + Ok(self.take(1)?[0]) + } + fn u16(&mut self) -> R { + Ok(u16::from_le_bytes(self.take(2)?.try_into().unwrap())) + } + fn u32(&mut self) -> R { + Ok(u32::from_le_bytes(self.take(4)?.try_into().unwrap())) + } + fn i64(&mut self) -> R { + Ok(i64::from_le_bytes(self.take(8)?.try_into().unwrap())) + } + fn f64(&mut self) -> R { + Ok(f64::from_le_bytes(self.take(8)?.try_into().unwrap())) + } + fn str(&mut self) -> R { + let n = self.u32()? as usize; + let b = self.take(n)?; + core::str::from_utf8(b) + .map(|s| s.to_string()) + .map_err(|_| "boot image has a non-utf8 string".to_string()) + } + fn reg(&mut self) -> R { + Ok(Reg(self.u32()?)) + } + fn regs(&mut self) -> R> { + let n = self.u32()? as usize; + let mut v = Vec::with_capacity(n); + for _ in 0..n { + v.push(self.reg()?); + } + Ok(v) + } +} + +pub fn decode(buf: &[u8]) -> R { + let mut d = Dec { buf, pos: 0 }; + if d.take(8)? != MAGIC { + return Err("not a loon boot image".to_string()); + } + let v = d.u32()?; + if v != VERSION { + return Err(alloc::format!( + "boot image version {v}, this kernel speaks {VERSION}" + )); + } + + let n = d.u32()? as usize; + let mut strings = Vec::with_capacity(n); + for _ in 0..n { + strings.push(d.str()?); + } + + let n = d.u32()? as usize; + let mut ctors = Vec::with_capacity(n); + for _ in 0..n { + ctors.push(Ctor { + name: d.str()?, + tag: d.u16()?, + arity: d.u16()?, + }); + } + + let n = d.u32()? as usize; + let mut builtins = Vec::with_capacity(n); + for _ in 0..n { + let tag = d.u16()?; + builtins.push((tag, d.str()?)); + } + + let entry = FuncId(d.u32()?); + + let n = d.u32()? as usize; + let mut funcs = Vec::with_capacity(n); + for _ in 0..n { + funcs.push(func(&mut d)?); + } + + Ok(Module { + funcs, + strings, + ctors, + builtins, + entry, + }) +} + +fn func(d: &mut Dec) -> R { + let name = if d.u8()? == 1 { Some(d.str()?) } else { None }; + let params = d.u32()?; + let captures = d.u32()?; + let evidence = d.u32()?; + let regs = d.u32()?; + + let n = d.u32()? as usize; + let mut blocks = Vec::with_capacity(n); + for _ in 0..n { + let params = d.regs()?; + let n_ops = d.u32()? as usize; + let mut ops = Vec::with_capacity(n_ops); + for _ in 0..n_ops { + ops.push(op(d)?); + } + blocks.push(Block { + params, + ops, + end: end(d)?, + }); + } + + Ok(Func { + name, + params, + captures, + evidence, + regs, + blocks, + }) +} + +fn op(d: &mut Dec) -> R { + Ok(match d.u8()? { + 0 => Op::Lit(d.reg()?, lit(d)?), + 1 => Op::Mov(d.reg()?, d.reg()?), + 2 => Op::Upval(d.reg()?, d.u16()?), + 3 => { + let dst = d.reg()?; + let o = binop(d.u8()?)?; + Op::Bin(dst, o, d.reg()?, d.reg()?) + } + 4 => { + let dst = d.reg()?; + let o = match d.u8()? { + 0 => UnOp::Neg, + 1 => UnOp::Not, + t => return Err(alloc::format!("unknown unop tag {t}")), + }; + Op::Un(dst, o, d.reg()?) + } + 5 => Op::Call(d.reg()?, FuncId(d.u32()?), d.regs()?), + 6 => Op::Invoke(d.reg()?, d.reg()?, d.regs()?), + 7 => Op::Close(d.reg()?, FuncId(d.u32()?), d.regs()?), + 8 => Op::Vec(d.reg()?, d.regs()?), + 9 => { + let dst = d.reg()?; + let n = d.u32()? as usize; + let mut kvs = Vec::with_capacity(n); + for _ in 0..n { + kvs.push((d.reg()?, d.reg()?)); + } + Op::Map(dst, kvs) + } + 10 => Op::Set(d.reg()?, d.regs()?), + 11 => Op::Tup(d.reg()?, d.regs()?), + 12 => Op::Adt(d.reg()?, d.u16()?, d.regs()?), + 13 => { + let dst = d.reg()?; + let src = d.reg()?; + let sel = match d.u8()? { + 0 => Selector::Index(d.u16()?), + 1 => Selector::Key(StringId(d.u32()?)), + 2 => Selector::Name(StringId(d.u32()?)), + t => return Err(alloc::format!("unknown selector tag {t}")), + }; + Op::Field(dst, src, sel) + } + 14 => Op::Tag(d.reg()?, d.reg()?), + 15 => { + let dst = d.reg()?; + let eff = StringId(d.u32()?); + let o = StringId(d.u32()?); + let args = d.regs()?; + // Evidence is decoded and dropped: like the host VM, dispatch is + // dynamic, because capturing a continuation needs the prompt + // boundary that only the handler stack records. + if d.u8()? == 1 { + let _ = d.u32()?; + } + Op::Perform(dst, eff, o, args) + } + 16 => Op::Builtin(d.reg()?, d.u16()?, d.regs()?), + 17 => Op::PushHandler(d.reg()?, StringId(d.u32()?), StringId(d.u32()?)), + 18 => Op::PopHandler, + t => return Err(alloc::format!("unknown op tag {t}")), + }) +} + +fn binop(t: u8) -> R { + Ok(match t { + 0 => BinOp::Add, + 1 => BinOp::Sub, + 2 => BinOp::Mul, + 3 => BinOp::Div, + 4 => BinOp::Rem, + 5 => BinOp::Eq, + 6 => BinOp::Ne, + 7 => BinOp::Lt, + 8 => BinOp::Gt, + 9 => BinOp::Le, + 10 => BinOp::Ge, + 11 => BinOp::And, + 12 => BinOp::Or, + 13 => BinOp::Concat, + t => return Err(alloc::format!("unknown binop tag {t}")), + }) +} + +fn lit(d: &mut Dec) -> R { + Ok(match d.u8()? { + 0 => Lit::Int(d.i64()?), + 1 => Lit::Float(d.f64()?), + 2 => Lit::Bool(d.u8()? != 0), + 3 => Lit::Str(StringId(d.u32()?)), + 4 => Lit::Keyword(StringId(d.u32()?)), + 5 => Lit::Unit, + t => return Err(alloc::format!("unknown literal tag {t}")), + }) +} + +fn end(d: &mut Dec) -> R { + Ok(match d.u8()? { + 0 => End::Ret(d.reg()?), + 1 => End::Jmp(BlockId(d.u32()?), d.regs()?), + 2 => End::Br(d.reg()?, BlockId(d.u32()?), BlockId(d.u32()?)), + 3 => { + let scrut = d.reg()?; + let n = d.u32()? as usize; + let mut arms = Vec::with_capacity(n); + for _ in 0..n { + arms.push((d.u16()?, BlockId(d.u32()?))); + } + End::Switch(scrut, arms, BlockId(d.u32()?)) + } + 4 => End::Tail(FuncId(d.u32()?), d.regs()?), + 5 => End::TailInvoke(d.reg()?, d.regs()?), + 6 => End::Recur(d.regs()?), + 7 => End::Trap, + t => return Err(alloc::format!("unknown terminator tag {t}")), + }) +} diff --git a/crates/loon-kernel/src/eir/mod.rs b/crates/loon-kernel/src/eir/mod.rs new file mode 100644 index 0000000..40c1247 --- /dev/null +++ b/crates/loon-kernel/src/eir/mod.rs @@ -0,0 +1,154 @@ +//! EIR, as the unikernel sees it: already lowered, already checked. +//! +//! The host compiler owns everything upstream of this point. What arrives +//! here is a boot image — a flat instruction graph with a string pool — and +//! the only job left is to run it. + +pub mod decode; +pub mod val; +pub mod vm; + +use alloc::string::String; +use alloc::vec::Vec; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct Reg(pub u32); +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct FuncId(pub u32); +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct StringId(pub u32); +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct BlockId(pub u32); + +/// Decoded in full even where the interpreter does not consult every field +/// yet — a partial decode that silently skips bytes is how image formats +/// drift apart. +#[allow(dead_code)] +pub struct Module { + pub funcs: Vec, + pub strings: Vec, + pub ctors: Vec, + /// Tag → variant name for every builtin this image references. Dispatch + /// goes through the name so a reordered host enum cannot silently + /// remap an intrinsic. + pub builtins: Vec<(u16, String)>, + pub entry: FuncId, +} + +impl Module { + pub fn builtin_name(&self, tag: u16) -> Option<&str> { + self.builtins + .iter() + .find(|(t, _)| *t == tag) + .map(|(_, n)| n.as_str()) + } + + pub fn string(&self, id: StringId) -> &str { + self.strings + .get(id.0 as usize) + .map(|s| s.as_str()) + .unwrap_or("") + } +} + +#[allow(dead_code)] +pub struct Ctor { + pub name: String, + pub tag: u16, + pub arity: u16, +} + +#[allow(dead_code)] +pub struct Func { + pub name: Option, + pub params: u32, + pub captures: u32, + pub evidence: u32, + /// Frame size: one past the highest register the body mentions. + pub regs: u32, + pub blocks: Vec, +} + +pub struct Block { + pub params: Vec, + pub ops: Vec, + pub end: End, +} + +#[derive(Debug, Clone)] +pub enum Op { + Lit(Reg, Lit), + Mov(Reg, Reg), + Upval(Reg, u16), + Bin(Reg, BinOp, Reg, Reg), + Un(Reg, UnOp, Reg), + Call(Reg, FuncId, Vec), + Invoke(Reg, Reg, Vec), + Close(Reg, FuncId, Vec), + Vec(Reg, Vec), + Map(Reg, Vec<(Reg, Reg)>), + Set(Reg, Vec), + Tup(Reg, Vec), + Adt(Reg, u16, Vec), + Field(Reg, Reg, Selector), + Tag(Reg, Reg), + Perform(Reg, StringId, StringId, Vec), + Builtin(Reg, u16, Vec), + PushHandler(Reg, StringId, StringId), + PopHandler, +} + +#[derive(Debug, Clone)] +pub enum End { + Ret(Reg), + Jmp(BlockId, Vec), + Br(Reg, BlockId, BlockId), + Switch(Reg, Vec<(u16, BlockId)>, BlockId), + Tail(FuncId, Vec), + TailInvoke(Reg, Vec), + Recur(Vec), + Trap, +} + +#[derive(Debug, Clone)] +pub enum Lit { + Int(i64), + Float(f64), + Bool(bool), + Str(StringId), + Keyword(StringId), + Unit, +} + +#[derive(Debug, Clone)] +pub enum Selector { + Index(u16), + Key(StringId), + Name(StringId), +} + +/// Tags must match `loon_lang::eir::BinOp` declaration order. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BinOp { + Add, + Sub, + Mul, + Div, + Rem, + Eq, + Ne, + Lt, + Gt, + Le, + Ge, + And, + Or, + Concat, +} + +/// Tags must match `loon_lang::eir::UnOp` declaration order. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UnOp { + Neg, + Not, +} diff --git a/crates/loon-kernel/src/eir/val.rs b/crates/loon-kernel/src/eir/val.rs new file mode 100644 index 0000000..e4decc7 --- /dev/null +++ b/crates/loon-kernel/src/eir/val.rs @@ -0,0 +1,150 @@ +//! Runtime values. +//! +//! Plain `Rc`-backed enum rather than the host VM's NaN-boxed `Value64`. +//! The unikernel's bottleneck is not value representation yet, and an +//! obvious layout is worth more here than a fast one. + +use alloc::rc::Rc; +use alloc::string::String; +use alloc::vec::Vec; + +use super::FuncId; + +#[derive(Clone)] +pub enum Val { + Unit, + Int(i64), + Float(f64), + Bool(bool), + Str(Rc), + Keyword(Rc), + Vec(Rc>), + Tup(Rc>), + Set(Rc>), + /// Insertion-ordered association list. Small-map territory; a real + /// hash map only pays off past sizes the kernel does not yet see. + Map(Rc>), + Adt(u16, Rc>), + Closure(FuncId, Rc>), + /// A captured continuation — the value `resume` is bound to. + Cont(Rc), +} + +impl Val { + /// Loon truthiness: only `false` and unit are falsey. Matches the + /// canonical ruling the host VM implements — 0 and "" are truthy. + pub fn truthy(&self) -> bool { + !matches!(self, Val::Bool(false) | Val::Unit) + } + + pub fn type_name(&self) -> &'static str { + match self { + Val::Unit => "unit", + Val::Int(_) => "int", + Val::Float(_) => "float", + Val::Bool(_) => "bool", + Val::Str(_) => "string", + Val::Keyword(_) => "keyword", + Val::Vec(_) => "vector", + Val::Tup(_) => "tuple", + Val::Set(_) => "set", + Val::Map(_) => "map", + Val::Adt(..) => "adt", + Val::Closure(..) => "function", + Val::Cont(_) => "continuation", + } + } +} + +impl PartialEq for Val { + fn eq(&self, other: &Self) -> bool { + match (self, other) { + (Val::Unit, Val::Unit) => true, + (Val::Int(a), Val::Int(b)) => a == b, + (Val::Float(a), Val::Float(b)) => a == b, + (Val::Int(a), Val::Float(b)) | (Val::Float(b), Val::Int(a)) => (*a as f64) == *b, + (Val::Bool(a), Val::Bool(b)) => a == b, + (Val::Str(a), Val::Str(b)) | (Val::Keyword(a), Val::Keyword(b)) => a == b, + (Val::Vec(a), Val::Vec(b)) + | (Val::Tup(a), Val::Tup(b)) + | (Val::Set(a), Val::Set(b)) => a == b, + (Val::Map(a), Val::Map(b)) => { + a.len() == b.len() + && a.iter() + .all(|(k, v)| b.iter().any(|(k2, v2)| k == k2 && v == v2)) + } + (Val::Adt(t1, a), Val::Adt(t2, b)) => t1 == t2 && a == b, + _ => false, + } + } +} + +/// Display, in the same shape the host VM prints. +pub fn show(v: &Val) -> String { + let mut s = String::new(); + write_val(&mut s, v); + s +} + +fn write_val(out: &mut String, v: &Val) { + use core::fmt::Write; + match v { + Val::Unit => out.push_str("()"), + Val::Int(n) => { + let _ = write!(out, "{n}"); + } + Val::Float(f) => { + let _ = write!(out, "{f}"); + } + Val::Bool(b) => out.push_str(if *b { "true" } else { "false" }), + Val::Str(s) => out.push_str(s), + Val::Keyword(s) => { + out.push(':'); + out.push_str(s); + } + Val::Vec(xs) | Val::Set(xs) => { + out.push_str(if matches!(v, Val::Set(_)) { "#{" } else { "#[" }); + for (i, x) in xs.iter().enumerate() { + if i > 0 { + out.push(' '); + } + write_val(out, x); + } + out.push(if matches!(v, Val::Set(_)) { '}' } else { ']' }); + } + Val::Tup(xs) => { + out.push('('); + for (i, x) in xs.iter().enumerate() { + if i > 0 { + out.push(' '); + } + write_val(out, x); + } + out.push(')'); + } + Val::Map(kvs) => { + out.push('{'); + for (i, (k, val)) in kvs.iter().enumerate() { + if i > 0 { + out.push(' '); + } + write_val(out, k); + out.push(' '); + write_val(out, val); + } + out.push('}'); + } + Val::Adt(tag, fields) => { + let _ = write!(out, "'); + } + Val::Closure(f, _) => { + let _ = write!(out, "", f.0); + } + Val::Cont(_) => out.push_str(""), + } +} diff --git a/crates/loon-kernel/src/eir/vm.rs b/crates/loon-kernel/src/eir/vm.rs new file mode 100644 index 0000000..185ac3a --- /dev/null +++ b/crates/loon-kernel/src/eir/vm.rs @@ -0,0 +1,968 @@ +//! The EIR interpreter, on bare metal. +//! +//! Structurally this is the host VM with the host removed: same frame stack, +//! same dynamic 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. Divergence from the host here would mean a program means +//! one thing on Linux and another on hardware, which is the whole thing we +//! are trying not to build. + +use alloc::rc::Rc; +use alloc::string::{String, ToString}; +use alloc::vec; +use alloc::vec::Vec; + +use super::val::{show, Val}; +use super::*; + +/// What the VM reaches for when an effect has no Loon handler left. On this +/// target that means hardware. +pub trait Host { + fn write(&mut self, s: &str); + /// Monotonic ticks since boot. + fn ticks(&mut self) -> i64; +} + +pub struct Frame { + func: FuncId, + block: BlockId, + ip: usize, + regs: Vec, + captures: Rc>, + /// Where the callee's value lands in this frame, or `DISCARD`. + ret_reg: u32, +} + +/// A frame whose callee's result is thrown away — used to park a caller +/// while Rust drives a nested run, where the value comes back directly. +const DISCARD: u32 = u32::MAX; + +/// A suspended computation: the frames between a `perform` and its prompt. +pub struct Continuation { + saved: Vec, + func: FuncId, + block: BlockId, + ip: usize, + regs: Vec, + captures: Rc>, + perform_dst: u32, + /// Handlers that lived at or above the prompt, with depths stored + /// relative to it so they can be re-established wherever this segment + /// is resumed. + prompt_handlers: Vec, +} + +#[derive(Clone)] +struct DynHandler { + effect: StringId, + op: StringId, + closure: Val, + prompt_depth: usize, + /// Re-established by a resume rather than by a `PushHandler`, so it has + /// no matching `PopHandler` and must be pruned by frame depth instead. + ephemeral: bool, +} + +pub struct Vm<'m, H: Host> { + m: &'m Module, + host: &'m mut H, + frames: Vec, + handlers: Vec, + func: FuncId, + block: BlockId, + ip: usize, + regs: Vec, + captures: Rc>, + /// Bounds runaway programs; there is no watchdog timer to save us yet. + fuel: u64, +} + +pub type VmResult = Result; + +impl<'m, H: Host> Vm<'m, H> { + pub fn new(m: &'m Module, host: &'m mut H) -> Self { + let entry = &m.funcs[m.entry.0 as usize]; + Vm { + regs: vec![Val::Unit; entry.regs as usize], + captures: Rc::new(Vec::new()), + func: m.entry, + block: BlockId(0), + ip: 0, + frames: Vec::new(), + handlers: Vec::new(), + m, + host, + fuel: u64::MAX, + } + } + + pub fn with_fuel(mut self, fuel: u64) -> Self { + self.fuel = fuel; + self + } + + // ── Register access ──────────────────────────────────────────────── + + fn r(&self, r: Reg) -> Val { + self.regs.get(r.0 as usize).cloned().unwrap_or(Val::Unit) + } + + fn w(&mut self, r: Reg, v: Val) { + let i = r.0 as usize; + if i >= self.regs.len() { + self.regs.resize(i + 1, Val::Unit); + } + self.regs[i] = v; + } + + fn read(&self, rs: &[Reg]) -> Vec { + rs.iter().map(|r| self.r(*r)).collect() + } + + fn func_def(&self, f: FuncId) -> VmResult<&'m Func> { + self.m + .funcs + .get(f.0 as usize) + .ok_or_else(|| alloc::format!("bad function id {}", f.0)) + } + + // ── Driving ──────────────────────────────────────────────────────── + + /// Run the entry point to completion. + pub fn run(&mut self) -> VmResult { + let base = self.frames.len(); + self.run_until(base) + } + + /// Execute until the frame stack drops back to `base` and the current + /// function returns. Nested runs (a builtin calling back into Loon) use + /// the same loop with a higher floor. + fn run_until(&mut self, base: usize) -> VmResult { + loop { + if self.fuel == 0 { + return Err("out of fuel: the program did not terminate".to_string()); + } + self.fuel -= 1; + + // Borrow the code out of the module reference, not out of `self`: + // `'m` outlives this loop, so ops stay borrowed while `self` is + // mutated. Cloning each instruction instead would allocate on + // every dispatch, which for ops carrying operand vectors is most + // of them. + let m = self.m; + let f = m + .funcs + .get(self.func.0 as usize) + .ok_or_else(|| alloc::format!("bad function id {}", self.func.0))?; + let block = f + .blocks + .get(self.block.0 as usize) + .ok_or_else(|| alloc::format!("bad block id {}", self.block.0))?; + + if self.ip < block.ops.len() { + let op = &block.ops[self.ip]; + self.ip += 1; + self.exec(op)?; + continue; + } + + match &block.end { + End::Ret(r) => { + let v = self.r(*r); + if self.frames.len() == base { + return Ok(v); + } + self.ret(v)?; + } + End::Jmp(b, args) => { + let vals = self.read(args); + self.jump(*b, &vals)?; + } + End::Br(c, t, e) => { + let target = if self.r(*c).truthy() { *t } else { *e }; + self.jump(target, &[])?; + } + End::Switch(scrut, arms, dflt) => { + let tag = match self.r(*scrut) { + Val::Adt(t, _) => Some(t), + Val::Int(n) => Some(n as u16), + _ => None, + }; + let target = tag + .and_then(|t| arms.iter().find(|(a, _)| *a == t).map(|(_, b)| *b)) + .unwrap_or(*dflt); + self.jump(target, &[])?; + } + End::Recur(args) => { + let vals = self.read(args); + self.jump(BlockId(0), &vals)?; + } + End::Tail(callee, args) => { + let vals = self.read(args); + self.enter(*callee, &vals, Rc::new(Vec::new()))?; + } + End::TailInvoke(f, args) => { + let callee = self.r(*f); + let vals = self.read(args); + // A tail call must not push a frame — that is the whole + // promise — so it cannot reuse `invoke`'s path. + match callee { + Val::Closure(fid, caps) => self.enter(fid, &vals, caps)?, + Val::Cont(k) => { + let v = vals.into_iter().next().unwrap_or(Val::Unit); + self.resume(&k, v)?; + } + other => { + return Err(alloc::format!( + "cannot call a {} in tail position", + other.type_name() + )) + } + } + } + End::Trap => { + return Err("reached unreachable code (non-exhaustive match?)".to_string()) + } + } + } + } + + /// Jump within the current function, binding the target's block params. + fn jump(&mut self, b: BlockId, args: &[Val]) -> VmResult<()> { + let f = self.func_def(self.func)?; + let target = f + .blocks + .get(b.0 as usize) + .ok_or_else(|| alloc::format!("bad block id {}", b.0))?; + let params = target.params.clone(); + for (p, v) in params.iter().zip(args.iter()) { + self.w(*p, v.clone()); + } + self.block = b; + self.ip = 0; + Ok(()) + } + + /// Replace the current frame with a call to `callee` (tail position). + fn enter(&mut self, callee: FuncId, args: &[Val], caps: Rc>) -> VmResult<()> { + let f = self.func_def(callee)?; + if f.blocks.is_empty() { + return Err("function has no blocks".to_string()); + } + // Arguments land in registers 0..n, not in the entry block's params: + // lowering numbers parameters first and the entry block inherits + // them rather than being jumped to with operands. + let n = (f.regs as usize).max(args.len()); + let mut regs = vec![Val::Unit; n]; + for (i, v) in args.iter().enumerate() { + regs[i] = v.clone(); + } + self.func = callee; + self.block = BlockId(0); + self.ip = 0; + self.regs = regs; + self.captures = caps; + Ok(()) + } + + /// Push a frame and call `callee`, returning into `ret_reg`. + fn call( + &mut self, + callee: FuncId, + args: &[Val], + ret_reg: u32, + caps: Rc>, + ) -> VmResult<()> { + self.frames.push(Frame { + func: self.func, + block: self.block, + ip: self.ip, + regs: core::mem::take(&mut self.regs), + captures: core::mem::replace(&mut self.captures, Rc::new(Vec::new())), + ret_reg, + }); + if self.frames.len() > 8192 { + return Err("call stack exhausted".to_string()); + } + self.enter(callee, args, caps) + } + + fn ret(&mut self, v: Val) -> VmResult<()> { + let fr = self + .frames + .pop() + .ok_or_else(|| "return with no caller".to_string())?; + self.func = fr.func; + self.block = fr.block; + self.ip = fr.ip; + self.regs = fr.regs; + self.captures = fr.captures; + if fr.ret_reg != DISCARD { + self.w(Reg(fr.ret_reg), v); + } + // A resumed segment can leave ephemeral handlers scoped to a prompt + // frame that just left the stack; drop them before they shadow a + // later handle for the same effect. + self.prune_ephemeral(); + Ok(()) + } + + /// Call a Loon value from Rust (a builtin taking a function, say) and + /// run it to completion. + fn apply(&mut self, f: &Val, args: &[Val]) -> VmResult { + match f { + Val::Closure(fid, caps) => { + let base = self.frames.len(); + // Park the caller so the nested run has somewhere to return + // to; reg 0 is scratch, the value comes back through `run_until`. + self.call(*fid, args, DISCARD, caps.clone())?; + let out = self.run_until(base + 1)?; + // `run_until` stops *before* popping, so unwind by hand. + self.ret(out.clone())?; + Ok(out) + } + Val::Cont(k) => { + let v = args.first().cloned().unwrap_or(Val::Unit); + let base = self.frames.len(); + self.resume(k, v)?; + self.run_until(base) + } + other => Err(alloc::format!("cannot call a {}", other.type_name())), + } + } +} + +// ── Instruction execution ────────────────────────────────────────────── + +impl<'m, H: Host> Vm<'m, H> { + fn exec(&mut self, op: &Op) -> VmResult<()> { + match op { + Op::Lit(d, l) => { + let v = self.lit(l); + self.w(*d, v); + } + Op::Mov(d, a) => { + let v = self.r(*a); + self.w(*d, v); + } + Op::Upval(d, i) => { + let v = self.captures.get(*i as usize).cloned().unwrap_or(Val::Unit); + self.w(*d, v); + } + Op::Bin(d, o, a, b) => { + let (x, y) = (self.r(*a), self.r(*b)); + let v = self.binop(*o, x, y)?; + self.w(*d, v); + } + Op::Un(d, o, a) => { + let x = self.r(*a); + let v = match o { + UnOp::Neg => match x { + Val::Int(n) => Val::Int(-n), + Val::Float(f) => Val::Float(-f), + v => return Err(alloc::format!("cannot negate a {}", v.type_name())), + }, + UnOp::Not => Val::Bool(!x.truthy()), + }; + self.w(*d, v); + } + Op::Call(d, f, args) => { + let vals = self.read(args); + self.call(*f, &vals, d.0, Rc::new(Vec::new()))?; + } + Op::Invoke(d, f, args) => { + let callee = self.r(*f); + let vals = self.read(args); + match callee { + Val::Closure(fid, caps) => self.call(fid, &vals, d.0, caps)?, + Val::Cont(k) => { + let v = vals.into_iter().next().unwrap_or(Val::Unit); + self.resume_at(&k, v, Some(d.0))?; + } + other => { + return Err(alloc::format!("cannot call a {}", other.type_name())); + } + } + } + Op::Close(d, f, caps) => { + let vals = self.read(caps); + self.w(*d, Val::Closure(*f, Rc::new(vals))); + } + Op::Vec(d, rs) => { + let vals = self.read(rs); + self.w(*d, Val::Vec(Rc::new(vals))); + } + Op::Tup(d, rs) => { + let vals = self.read(rs); + self.w(*d, Val::Tup(Rc::new(vals))); + } + Op::Set(d, rs) => { + let mut vals: Vec = Vec::new(); + for v in self.read(rs) { + if !vals.contains(&v) { + vals.push(v); + } + } + self.w(*d, Val::Set(Rc::new(vals))); + } + Op::Map(d, kvs) => { + let mut out: Vec<(Val, Val)> = Vec::with_capacity(kvs.len()); + for (k, v) in kvs { + let (k, v) = (self.r(*k), self.r(*v)); + match out.iter_mut().find(|(k2, _)| *k2 == k) { + Some(slot) => slot.1 = v, + None => out.push((k, v)), + } + } + self.w(*d, Val::Map(Rc::new(out))); + } + Op::Adt(d, tag, rs) => { + let vals = self.read(rs); + self.w(*d, Val::Adt(*tag, Rc::new(vals))); + } + Op::Tag(d, a) => { + let v = match self.r(*a) { + Val::Adt(t, _) => Val::Int(t as i64), + _ => Val::Int(-1), + }; + self.w(*d, v); + } + Op::Field(d, a, sel) => { + let base = self.r(*a); + let v = self.field(&base, sel)?; + self.w(*d, v); + } + Op::Builtin(d, tag, args) => { + let vals = self.read(args); + let v = self.builtin(*tag, &vals)?; + self.w(*d, v); + } + Op::PushHandler(h, eff, o) => { + let closure = self.r(*h); + self.handlers.push(DynHandler { + effect: *eff, + op: *o, + closure, + // The prompt is the `handle`'s own frame; the body runs + // above it. + prompt_depth: self.frames.len(), + ephemeral: false, + }); + } + Op::PopHandler => { + // Depth-matched, not a blind pop: if the body performed, the + // capture already moved this handle's handlers into the + // continuation, and popping blindly would take some outer + // handle's instead. + let depth = self.frames.len(); + if let Some(i) = self.handlers.iter().rposition(|h| h.prompt_depth == depth) { + self.handlers.remove(i); + } + } + Op::Perform(d, eff, o, args) => { + let vals = self.read(args); + self.perform(*d, *eff, *o, vals)?; + } + } + Ok(()) + } + + fn lit(&self, l: &Lit) -> Val { + match l { + Lit::Int(n) => Val::Int(*n), + Lit::Float(f) => Val::Float(*f), + Lit::Bool(b) => Val::Bool(*b), + Lit::Str(s) => Val::Str(Rc::new(self.m.string(*s).to_string())), + Lit::Keyword(s) => Val::Keyword(Rc::new(self.m.string(*s).to_string())), + Lit::Unit => Val::Unit, + } + } + + fn field(&self, base: &Val, sel: &Selector) -> VmResult { + Ok(match sel { + Selector::Index(i) => match base { + Val::Tup(xs) | Val::Vec(xs) | Val::Adt(_, xs) => { + xs.get(*i as usize).cloned().unwrap_or(Val::Unit) + } + v => return Err(alloc::format!("cannot index a {}", v.type_name())), + }, + Selector::Key(s) | Selector::Name(s) => { + let key = self.m.string(*s); + match base { + Val::Map(kvs) => kvs + .iter() + .find(|(k, _)| match k { + Val::Str(t) | Val::Keyword(t) => t.as_str() == key, + _ => false, + }) + .map(|(_, v)| v.clone()) + .unwrap_or(Val::Unit), + v => { + return Err(alloc::format!( + "cannot read field '{key}' of a {}", + v.type_name() + )) + } + } + } + }) + } + + // ── Effects ──────────────────────────────────────────────────────── + + fn prune_ephemeral(&mut self) { + let depth = self.frames.len(); + self.handlers + .retain(|h| !h.ephemeral || h.prompt_depth < depth); + } + + /// Perform an effect: find the innermost handler, capture everything + /// between here and its prompt as a continuation, and run the clause at + /// the prompt with `resume` bound to that continuation. + fn perform(&mut self, dst: Reg, eff: StringId, o: StringId, args: Vec) -> VmResult<()> { + let found = self + .handlers + .iter() + .rev() + .find(|h| h.effect == eff && h.op == o) + .map(|h| (h.closure.clone(), h.prompt_depth)); + + let Some((hval, prompt_depth)) = found else { + // Nothing in Loon handles this, so it falls through to hardware. + let v = self.hardware(eff, o, &args)?; + self.w(dst, v); + return Ok(()); + }; + + // Deep-handler semantics: every handler at or above the prompt moves + // into the snapshot, including the prompt's own. That is what makes a + // clause re-performing its own effect forward *outward* instead of + // recursing into itself. + let prompt_handlers: Vec = self + .handlers + .iter() + .filter(|h| h.prompt_depth >= prompt_depth) + .map(|h| DynHandler { + prompt_depth: h.prompt_depth - prompt_depth, + ..h.clone() + }) + .collect(); + self.handlers.retain(|h| h.prompt_depth < prompt_depth); + + let saved: Vec = self.frames.split_off(prompt_depth + 1); + let k = Continuation { + saved, + func: self.func, + block: self.block, + ip: self.ip, + regs: core::mem::take(&mut self.regs), + captures: core::mem::replace(&mut self.captures, Rc::new(Vec::new())), + perform_dst: dst.0, + prompt_handlers, + }; + let k = Val::Cont(Rc::new(k)); + + // Restore the prompt frame as current; its ret_reg is where the whole + // `handle` expression's value belongs. + let f0 = self + .frames + .pop() + .ok_or_else(|| "perform with no prompt frame".to_string())?; + let handle_ret = f0.ret_reg; + self.func = f0.func; + self.block = f0.block; + self.ip = f0.ip; + self.regs = f0.regs; + self.captures = f0.captures; + self.prune_ephemeral(); + + match hval { + Val::Closure(fid, caps) => { + let mut call_args = vec![k]; + call_args.extend(args); + self.call(fid, &call_args, handle_ret, caps) + } + other => Err(alloc::format!( + "handler for {}.{} is a {}, not a function", + self.m.string(eff), + self.m.string(o), + other.type_name() + )), + } + } + + fn resume(&mut self, k: &Continuation, v: Val) -> VmResult<()> { + self.resume_inner(k, v, None) + } + + fn resume_at(&mut self, k: &Continuation, v: Val, dst: Option) -> VmResult<()> { + self.resume_inner(k, v, dst) + } + + /// Re-install a captured segment and run on from the `perform` that + /// produced it, with `v` as that perform's value. + fn resume_inner(&mut self, k: &Continuation, v: Val, dst: Option) -> VmResult<()> { + if let Some(dst) = dst { + // Park the clause's frame as a fresh prompt, so the continuation + // stays self-contained even when resumed after its original + // `handle` has already exited. + self.frames.push(Frame { + func: self.func, + block: self.block, + ip: self.ip, + regs: core::mem::take(&mut self.regs), + captures: core::mem::replace(&mut self.captures, Rc::new(Vec::new())), + ret_reg: dst, + }); + } + + // Snapshot depths are relative to the prompt; the saved frames go + // directly above the current top, so absolute = prompt + relative. + let prompt = self.frames.len().saturating_sub(1); + for h in &k.prompt_handlers { + self.handlers.push(DynHandler { + prompt_depth: prompt + h.prompt_depth, + ephemeral: true, + ..h.clone() + }); + } + for f in &k.saved { + self.frames.push(Frame { + func: f.func, + block: f.block, + ip: f.ip, + regs: f.regs.clone(), + captures: f.captures.clone(), + ret_reg: f.ret_reg, + }); + } + + let mut regs = k.regs.clone(); + let pd = k.perform_dst as usize; + if pd >= regs.len() { + regs.resize(pd + 1, Val::Unit); + } + regs[pd] = v; + self.func = k.func; + self.block = k.block; + self.ip = k.ip; + self.regs = regs; + self.captures = k.captures.clone(); + Ok(()) + } + + /// The bottom of the handler stack: effects nothing in Loon caught. + /// + /// On a hosted runtime these reach the OS. Here there is no OS below to + /// reach, so the set is exactly what the machine can do — and anything + /// outside it is a hard error, never a silent `()`. + fn hardware(&mut self, eff: StringId, o: StringId, args: &[Val]) -> VmResult { + let effect = self.m.string(eff); + let op = self.m.string(o); + match (effect, op) { + ("Console", "write") | ("IO", "print") => { + let s = args.first().map(show).unwrap_or_default(); + self.host.write(&s); + Ok(Val::Unit) + } + ("Console", "line") | ("IO", "println") => { + let s = args.first().map(show).unwrap_or_default(); + self.host.write(&s); + self.host.write("\n"); + Ok(Val::Unit) + } + ("Clock", "now") | ("Clock", "ticks") => Ok(Val::Int(self.host.ticks())), + ("Fail", "fail") => Err(alloc::format!( + "unhandled failure: {}", + args.first().map(show).unwrap_or_default() + )), + _ => Err(alloc::format!( + "unhandled effect {effect}.{op} — this machine has no handler for it" + )), + } + } +} + +// ── Operators and intrinsics ─────────────────────────────────────────── + +impl<'m, H: Host> Vm<'m, H> { + fn binop(&mut self, o: BinOp, a: Val, b: Val) -> VmResult { + use BinOp::*; + // Comparison and logic first: they accept anything. + match o { + Eq => return Ok(Val::Bool(a == b)), + Ne => return Ok(Val::Bool(a != b)), + And => return Ok(if a.truthy() { b } else { a }), + Or => return Ok(if a.truthy() { a } else { b }), + Concat => return self.concat(a, b), + _ => {} + } + + // String `+` concatenates, matching the host. + if let (Add, Val::Str(x), Val::Str(y)) = (o, &a, &b) { + let mut s = String::with_capacity(x.len() + y.len()); + s.push_str(x); + s.push_str(y); + return Ok(Val::Str(Rc::new(s))); + } + + let num = |v: &Val| -> Option { + match v { + Val::Int(n) => Some(*n as f64), + Val::Float(f) => Some(*f), + _ => None, + } + }; + let (Some(x), Some(y)) = (num(&a), num(&b)) else { + return Err(alloc::format!( + "cannot apply {o:?} to a {} and a {}", + a.type_name(), + b.type_name() + )); + }; + + // Integer arithmetic stays integral; a mixed operand promotes. + let ints = matches!((&a, &b), (Val::Int(_), Val::Int(_))); + Ok(match o { + Lt => Val::Bool(x < y), + Gt => Val::Bool(x > y), + Le => Val::Bool(x <= y), + Ge => Val::Bool(x >= y), + Div if y == 0.0 => return Err("division by zero".to_string()), + Rem if y == 0.0 => return Err("remainder by zero".to_string()), + _ if ints => { + let (i, j) = (x as i64, y as i64); + Val::Int(match o { + Add => i.wrapping_add(j), + Sub => i.wrapping_sub(j), + Mul => i.wrapping_mul(j), + Div => i / j, + Rem => i % j, + _ => unreachable!(), + }) + } + Add => Val::Float(x + y), + Sub => Val::Float(x - y), + Mul => Val::Float(x * y), + Div => Val::Float(x / y), + Rem => Val::Float(x % y), + _ => unreachable!(), + }) + } + + fn concat(&mut self, a: Val, b: Val) -> VmResult { + Ok(match (&a, &b) { + (Val::Vec(x), Val::Vec(y)) => { + let mut v = x.as_ref().clone(); + v.extend(y.iter().cloned()); + Val::Vec(Rc::new(v)) + } + _ => { + let mut s = show(&a); + s.push_str(&show(&b)); + Val::Str(Rc::new(s)) + } + }) + } + + fn builtin(&mut self, tag: u16, args: &[Val]) -> VmResult { + let name = self + .m + .builtin_name(tag) + .ok_or_else(|| alloc::format!("boot image references unknown builtin tag {tag}"))?; + let a0 = || args.first().cloned().unwrap_or(Val::Unit); + let a1 = || args.get(1).cloned().unwrap_or(Val::Unit); + + let seq = |v: &Val| -> Option>> { + match v { + Val::Vec(xs) | Val::Tup(xs) | Val::Set(xs) => Some(xs.clone()), + _ => None, + } + }; + + Ok(match name { + "Println" => { + let mut out = String::new(); + for (i, a) in args.iter().enumerate() { + if i > 0 { + out.push(' '); + } + out.push_str(&show(a)); + } + out.push('\n'); + self.host.write(&out); + Val::Unit + } + "Print" => { + let mut out = String::new(); + for (i, a) in args.iter().enumerate() { + if i > 0 { + out.push(' '); + } + out.push_str(&show(a)); + } + self.host.write(&out); + Val::Unit + } + "Str" => { + let mut out = String::new(); + for a in args { + out.push_str(&show(a)); + } + Val::Str(Rc::new(out)) + } + "Len" => Val::Int(match a0() { + Val::Str(s) => s.chars().count() as i64, + Val::Map(kvs) => kvs.len() as i64, + v => seq(&v).map(|x| x.len()).unwrap_or(0) as i64, + }), + "Empty" => Val::Bool(match a0() { + Val::Str(s) => s.is_empty(), + Val::Map(kvs) => kvs.is_empty(), + Val::Unit => true, + v => seq(&v).map(|x| x.is_empty()).unwrap_or(false), + }), + "Not" => Val::Bool(!a0().truthy()), + "TypeOf" => Val::Str(Rc::new(a0().type_name().to_string())), + "SomeP" => Val::Bool(!matches!(a0(), Val::Unit)), + "NoneP" => Val::Bool(matches!(a0(), Val::Unit)), + "VecP" => Val::Bool(matches!(a0(), Val::Vec(_))), + "MapP" => Val::Bool(matches!(a0(), Val::Map(_))), + "Range" => { + let (lo, hi) = match (a0(), a1()) { + (Val::Int(a), Val::Int(b)) => (a, b), + (Val::Int(n), Val::Unit) => (0, n), + _ => return Err("range expects integers".to_string()), + }; + Val::Vec(Rc::new((lo..hi).map(Val::Int).collect())) + } + "Nth" | "Get" => { + let base = a0(); + match (&base, a1()) { + (Val::Map(kvs), key) => kvs + .iter() + .find(|(k, _)| *k == key) + .map(|(_, v)| v.clone()) + .unwrap_or(Val::Unit), + (_, Val::Int(i)) => seq(&base) + .and_then(|xs| xs.get(i as usize).cloned()) + .unwrap_or(Val::Unit), + _ => Val::Unit, + } + } + "First" => seq(&a0()) + .and_then(|xs| xs.first().cloned()) + .unwrap_or(Val::Unit), + "Last" => seq(&a0()) + .and_then(|xs| xs.last().cloned()) + .unwrap_or(Val::Unit), + "Reverse" => { + let mut xs = seq(&a0()).map(|x| x.as_ref().clone()).unwrap_or_default(); + xs.reverse(); + Val::Vec(Rc::new(xs)) + } + "Conj" => { + let mut xs = seq(&a0()).map(|x| x.as_ref().clone()).unwrap_or_default(); + xs.extend(args.iter().skip(1).cloned()); + Val::Vec(Rc::new(xs)) + } + "Cons" => { + let mut xs = vec![a0()]; + xs.extend(seq(&a1()).map(|x| x.as_ref().clone()).unwrap_or_default()); + Val::Vec(Rc::new(xs)) + } + "Sum" => { + let xs = seq(&a0()).ok_or_else(|| "sum expects a sequence".to_string())?; + let mut acc = Val::Int(0); + for x in xs.iter() { + acc = self.binop(BinOp::Add, acc, x.clone())?; + } + acc + } + "Concat" => { + let mut acc = a0(); + for b in args.iter().skip(1) { + acc = self.concat(acc, b.clone())?; + } + acc + } + "Join" => { + let xs = seq(&a0()).unwrap_or_default(); + let sep = match a1() { + Val::Str(s) => s.as_ref().clone(), + Val::Unit => String::new(), + v => show(&v), + }; + let mut out = String::new(); + for (i, x) in xs.iter().enumerate() { + if i > 0 { + out.push_str(&sep); + } + out.push_str(&show(x)); + } + Val::Str(Rc::new(out)) + } + // Higher-order intrinsics re-enter the interpreter. + "Map" => { + let xs = seq(&a0()).ok_or_else(|| "map expects a sequence".to_string())?; + let f = a1(); + let mut out = Vec::with_capacity(xs.len()); + for x in xs.iter() { + out.push(self.apply(&f, core::slice::from_ref(x))?); + } + Val::Vec(Rc::new(out)) + } + "Filter" => { + let xs = seq(&a0()).ok_or_else(|| "filter expects a sequence".to_string())?; + let f = a1(); + let mut out = Vec::new(); + for x in xs.iter() { + if self.apply(&f, core::slice::from_ref(x))?.truthy() { + out.push(x.clone()); + } + } + Val::Vec(Rc::new(out)) + } + "Each" => { + let xs = seq(&a0()).ok_or_else(|| "each expects a sequence".to_string())?; + let f = a1(); + for x in xs.iter() { + self.apply(&f, core::slice::from_ref(x))?; + } + Val::Unit + } + "Fold" => { + let xs = seq(&a0()).ok_or_else(|| "fold expects a sequence".to_string())?; + let mut acc = a1(); + let f = args.get(2).cloned().unwrap_or(Val::Unit); + for x in xs.iter() { + acc = self.apply(&f, &[acc, x.clone()])?; + } + acc + } + "AssertEq" => { + let (a, b) = (a0(), a1()); + if a != b { + return Err(alloc::format!( + "assertion failed: {} != {}", + show(&a), + show(&b) + )); + } + Val::Unit + } + "MatchFail" => return Err(alloc::format!("no match arm matched {}", show(&a0()))), + "UnboundSym" => return Err(alloc::format!("unbound symbol '{}'", show(&a0()))), + // Everything else exists on the host but has not been ported. + // Saying so is the point: a silently wrong answer on hardware is + // far worse than a refusal. + other => { + return Err(alloc::format!( + "builtin '{other}' is not implemented in the unikernel runtime" + )) + } + }) + } +} diff --git a/crates/loon-kernel/src/heap.rs b/crates/loon-kernel/src/heap.rs new file mode 100644 index 0000000..80805b4 --- /dev/null +++ b/crates/loon-kernel/src/heap.rs @@ -0,0 +1,125 @@ +//! The kernel heap: a first-fit free-list allocator over the RAM left above +//! the image. +//! +//! Single-hart and non-reentrant, which is why the lock is a bare `Cell` +//! guard rather than a real spinlock — there is nothing to race with yet. +//! Preemption lands before SMP does, and that is when this needs revisiting. + +use core::alloc::{GlobalAlloc, Layout}; +use core::cell::UnsafeCell; +use core::ptr; + +/// A free region. Stored in the first bytes of the region itself. +#[repr(C)] +struct Block { + size: usize, + next: *mut Block, +} + +const MIN_BLOCK: usize = core::mem::size_of::(); + +pub struct Heap { + free: UnsafeCell<*mut Block>, +} + +// Single hart, interrupts off: no concurrent access exists. +unsafe impl Sync for Heap {} + +impl Heap { + pub const fn new() -> Self { + Heap { + free: UnsafeCell::new(ptr::null_mut()), + } + } + + /// # Safety + /// `start..start+size` must be untouched, writable RAM that outlives all + /// allocations, and this must be called exactly once. + pub unsafe fn init(&self, start: usize, size: usize) { + let block = start as *mut Block; + (*block).size = size; + (*block).next = ptr::null_mut(); + *self.free.get() = block; + } + + /// Splice a region back into the address-ordered free list, coalescing + /// with whichever neighbours it now touches. + unsafe fn insert(&self, region: *mut Block) { + let mut prev: *mut Block = ptr::null_mut(); + let mut cur = *self.free.get(); + while !cur.is_null() && (cur as usize) < (region as usize) { + prev = cur; + cur = (*cur).next; + } + + (*region).next = cur; + if prev.is_null() { + *self.free.get() = region; + } else { + (*prev).next = region; + } + + // Coalesce forward, then backward. + if !cur.is_null() && (region as usize) + (*region).size == cur as usize { + (*region).size += (*cur).size; + (*region).next = (*cur).next; + } + if !prev.is_null() && (prev as usize) + (*prev).size == region as usize { + (*prev).size += (*region).size; + (*prev).next = (*region).next; + } + } +} + +unsafe impl GlobalAlloc for Heap { + unsafe fn alloc(&self, layout: Layout) -> *mut u8 { + let align = layout.align().max(core::mem::align_of::()); + let size = align_up(layout.size().max(MIN_BLOCK), core::mem::align_of::()); + + let mut prev: *mut Block = ptr::null_mut(); + let mut cur = *self.free.get(); + while !cur.is_null() { + let base = cur as usize; + let start = align_up(base, align); + let end = start + size; + + if end <= base + (*cur).size { + let head = start - base; + let tail = base + (*cur).size - end; + + // Unlink, then give back whatever is left at either end — + // but only if the remainder can hold a link of its own. + let next = (*cur).next; + if prev.is_null() { + *self.free.get() = next; + } else { + (*prev).next = next; + } + if tail >= MIN_BLOCK { + let t = end as *mut Block; + (*t).size = tail; + self.insert(t); + } + if head >= MIN_BLOCK { + (*cur).size = head; + self.insert(cur); + } + return start as *mut u8; + } + prev = cur; + cur = (*cur).next; + } + ptr::null_mut() + } + + unsafe fn dealloc(&self, p: *mut u8, layout: Layout) { + let size = align_up(layout.size().max(MIN_BLOCK), core::mem::align_of::()); + let block = p as *mut Block; + (*block).size = size; + self.insert(block); + } +} + +fn align_up(n: usize, align: usize) -> usize { + (n + align - 1) & !(align - 1) +} diff --git a/crates/loon-kernel/src/main.rs b/crates/loon-kernel/src/main.rs new file mode 100644 index 0000000..16af00b --- /dev/null +++ b/crates/loon-kernel/src/main.rs @@ -0,0 +1,137 @@ +//! Loon as a unikernel. +//! +//! There is no userspace here and no syscall boundary: the Loon program *is* +//! the kernel, and what would be a syscall elsewhere is an effect performed +//! into a handler that happens to touch hardware. + +#![no_std] +#![no_main] + +extern crate alloc; + +use core::arch::global_asm; + +#[global_allocator] +static HEAP: heap::Heap = heap::Heap::new(); + +#[macro_use] +mod uart; +mod eir; +mod heap; +mod mmio; +mod sbi; + +// Set up a stack and clear .bss before anything Rust-shaped runs. `a0` holds +// the hart id and `a1` the device tree pointer; we keep them for `kmain`. +global_asm!( + r#" + .section .text.entry + .globl _start +_start: + la sp, __stack_top + + la t0, __bss_start + la t1, __bss_end +1: bgeu t0, t1, 2f + sd zero, 0(t0) + addi t0, t0, 8 + j 1b + +2: tail kmain +"# +); + +extern "C" { + static __heap_start: u8; + static __heap_end: u8; +} + +/// `rdtime` at kernel entry, for the boot-to-init measurement. +static BOOT: core::sync::atomic::AtomicU64 = core::sync::atomic::AtomicU64::new(0); + +#[no_mangle] +pub extern "C" fn kmain(hart: usize, dtb: usize) -> ! { + BOOT.store(now(), core::sync::atomic::Ordering::Relaxed); + let (start, end) = unsafe { + ( + &__heap_start as *const u8 as usize, + &__heap_end as *const u8 as usize, + ) + }; + unsafe { HEAP.init(start, end - start) }; + + println!(); + println!("loon unikernel — hart {hart}, dtb {dtb:#x}"); + println!( + "heap {:#x}..{:#x} ({} KiB)", + start, + end, + (end - start) / 1024 + ); + + let image = include_bytes!(env!("LOON_BOOT_IMAGE")); + println!("init image {} bytes", image.len()); + println!(); + + let t0 = now(); + match run_init(image) { + Ok(()) => { + println!(); + println!( + "init exited cleanly in {} us ({} us since entry)", + micros_since(t0), + micros_since(BOOT.load(core::sync::atomic::Ordering::Relaxed)), + ); + sbi::shutdown(false) + } + Err(e) => { + println!(); + println!("init failed: {e}"); + sbi::shutdown(true) + } + } +} + +/// The machine, as the VM sees it. Effects that no Loon handler caught +/// arrive here, which is the only place in the system that touches hardware. +struct Machine; + +impl eir::vm::Host for Machine { + fn write(&mut self, s: &str) { + print!("{s}"); + } + + fn ticks(&mut self) -> i64 { + let t: u64; + unsafe { core::arch::asm!("rdtime {}", out(reg) t) }; + t as i64 + } +} + +/// QEMU's `virt` machine ticks the RISC-V `time` CSR at 10 MHz. +const TIMEBASE_HZ: u64 = 10_000_000; + +fn now() -> u64 { + let t: u64; + unsafe { core::arch::asm!("rdtime {}", out(reg) t) }; + t +} + +/// Microseconds between two `rdtime` reads. +fn micros_since(start: u64) -> u64 { + now().saturating_sub(start) * 1_000_000 / TIMEBASE_HZ +} + +fn run_init(image: &[u8]) -> Result<(), alloc::string::String> { + let module = eir::decode::decode(image)?; + let mut machine = Machine; + let mut vm = eir::vm::Vm::new(&module, &mut machine).with_fuel(500_000_000); + vm.run()?; + Ok(()) +} + +#[panic_handler] +fn panic(info: &core::panic::PanicInfo) -> ! { + println!("\nkernel panic: {info}"); + sbi::shutdown(true) +} diff --git a/crates/loon-kernel/src/mmio.rs b/crates/loon-kernel/src/mmio.rs new file mode 100644 index 0000000..8c48c5b --- /dev/null +++ b/crates/loon-kernel/src/mmio.rs @@ -0,0 +1,21 @@ +//! Raw memory-mapped IO. +//! +//! Everything a driver does to hardware bottoms out here. Keeping it in one +//! module is what lets a driver be simulated instead of executed: the same +//! driver code over a different `Mmio` is a test, not a boot. + +/// Volatile read of a device register. +/// +/// # Safety +/// `addr` must be a valid MMIO register for the current machine. +pub unsafe fn read8(addr: usize) -> u8 { + core::ptr::read_volatile(addr as *const u8) +} + +/// Volatile write of a device register. +/// +/// # Safety +/// `addr` must be a valid MMIO register for the current machine. +pub unsafe fn write8(addr: usize, val: u8) { + core::ptr::write_volatile(addr as *mut u8, val) +} diff --git a/crates/loon-kernel/src/sbi.rs b/crates/loon-kernel/src/sbi.rs new file mode 100644 index 0000000..0fa0ba2 --- /dev/null +++ b/crates/loon-kernel/src/sbi.rs @@ -0,0 +1,29 @@ +//! The thin slice of the RISC-V SBI we need from OpenSBI. + +use core::arch::asm; + +fn ecall(eid: usize, fid: usize, a0: usize, a1: usize) -> isize { + let err: isize; + unsafe { + asm!( + "ecall", + inlateout("a0") a0 => err, + in("a1") a1, + in("a6") fid, + in("a7") eid, + options(nostack), + ); + } + err +} + +/// Power off the machine. Returns only if the firmware refuses. +pub fn shutdown(failure: bool) -> ! { + const SRST: usize = 0x5352_5354; + let reason = if failure { 1 } else { 0 }; + ecall(SRST, 0, 0, reason); // system_reset(SHUTDOWN, reason) + ecall(0x08, 0, 0, 0); // legacy shutdown, for older firmware + loop { + core::hint::spin_loop(); + } +} diff --git a/crates/loon-kernel/src/uart.rs b/crates/loon-kernel/src/uart.rs new file mode 100644 index 0000000..66e71cf --- /dev/null +++ b/crates/loon-kernel/src/uart.rs @@ -0,0 +1,71 @@ +//! NS16550a UART — the console driver. +//! +//! QEMU's `virt` machine puts one at 0x1000_0000. OpenSBI has already +//! initialised it by the time we get control, so transmit needs no setup +//! beyond waiting for the holding register to drain. + +use crate::mmio; + +const BASE: usize = 0x1000_0000; +const THR: usize = BASE; // transmit holding register +const RBR: usize = BASE; // receive buffer register +const LSR: usize = BASE + 5; // line status register + +const LSR_RX_READY: u8 = 1 << 0; +const LSR_TX_IDLE: u8 = 1 << 5; + +pub struct Uart; + +// The receive half is unused until init wants a console to read from; it is +// kept because a driver that can only talk is not a console driver. +#[allow(dead_code)] +impl Uart { + pub fn putc(&self, c: u8) { + unsafe { + while mmio::read8(LSR) & LSR_TX_IDLE == 0 { + core::hint::spin_loop(); + } + mmio::write8(THR, c); + } + } + + pub fn getc(&self) -> Option { + unsafe { + if mmio::read8(LSR) & LSR_RX_READY == 0 { + None + } else { + Some(mmio::read8(RBR)) + } + } + } +} + +impl core::fmt::Write for Uart { + fn write_str(&mut self, s: &str) -> core::fmt::Result { + for b in s.bytes() { + // The console is line-oriented; QEMU's terminal wants CRLF. + if b == b'\n' { + self.putc(b'\r'); + } + self.putc(b); + } + Ok(()) + } +} + +#[macro_export] +macro_rules! print { + ($($arg:tt)*) => {{ + use core::fmt::Write; + let _ = write!($crate::uart::Uart, $($arg)*); + }}; +} + +#[macro_export] +macro_rules! println { + () => { $crate::print!("\n") }; + ($($arg:tt)*) => {{ + use core::fmt::Write; + let _ = writeln!($crate::uart::Uart, $($arg)*); + }}; +} diff --git a/crates/loon-lang/src/eir/image.rs b/crates/loon-lang/src/eir/image.rs new file mode 100644 index 0000000..3b5ac0b --- /dev/null +++ b/crates/loon-lang/src/eir/image.rs @@ -0,0 +1,389 @@ +//! The boot image: EIR serialized for a runtime that cannot compile. +//! +//! The unikernel has no parser, no checker and no lowering — it is handed a +//! finished `Module` and interprets it. This module is the wire format +//! between the two, and it is deliberately dumb: little-endian, length- +//! prefixed, no compression, no relocation. Spans and types are dropped +//! because nothing on the far side reads them. + +use super::*; + +pub const MAGIC: &[u8; 8] = b"LOONIMG\0"; +pub const VERSION: u32 = 1; + +#[derive(Default)] +struct Enc(Vec); + +impl Enc { + fn u8(&mut self, v: u8) { + self.0.push(v); + } + fn u16(&mut self, v: u16) { + self.0.extend_from_slice(&v.to_le_bytes()); + } + fn u32(&mut self, v: u32) { + self.0.extend_from_slice(&v.to_le_bytes()); + } + fn i64(&mut self, v: i64) { + self.0.extend_from_slice(&v.to_le_bytes()); + } + fn f64(&mut self, v: f64) { + self.0.extend_from_slice(&v.to_le_bytes()); + } + fn str(&mut self, s: &str) { + self.u32(s.len() as u32); + self.0.extend_from_slice(s.as_bytes()); + } + fn regs(&mut self, rs: &[Reg]) { + self.u32(rs.len() as u32); + for r in rs { + self.u32(r.0); + } + } +} + +/// Serialize a module into a boot image. +pub fn encode(m: &Module) -> Vec { + let mut e = Enc::default(); + e.0.extend_from_slice(MAGIC); + e.u32(VERSION); + + e.u32(m.strings.len() as u32); + for s in &m.strings { + e.str(s); + } + + e.u32(m.ctors.len() as u32); + for c in &m.ctors { + e.str(&c.name); + e.u16(c.tag); + e.u16(c.arity); + } + + // Builtins are referenced by numeric tag, but a tag is just an enum + // discriminant — reorder `Built` and every previously-built image would + // silently call the wrong intrinsic. Ship the names alongside so the + // runtime dispatches on something stable and can refuse what it lacks. + let mut used: Vec = Vec::new(); + for f in &m.funcs { + for b in &f.blocks { + for o in &b.ops { + if let Op::Builtin(_, built, ..) = o { + if !used.contains(built) { + used.push(*built); + } + } + } + } + } + e.u32(used.len() as u32); + for b in &used { + e.u16(*b as u16); + e.str(&alloc_name(*b)); + } + + e.u32(m.entry.0); + + e.u32(m.funcs.len() as u32); + for f in &m.funcs { + func(&mut e, f); + } + e.0 +} + +/// The variant name, which is what the runtime dispatches on. +fn alloc_name(b: Built) -> String { + format!("{b:?}") +} + +fn func(e: &mut Enc, f: &Func) { + match &f.name { + Some(n) => { + e.u8(1); + e.str(n); + } + None => e.u8(0), + } + e.u32(f.params.len() as u32); + e.u32(f.captures.len() as u32); + // Evidence params are appended after the declared params; the runtime + // only needs to know how many slots to reserve for them. + e.u32(f.evidence.len() as u32); + e.u32(max_reg(f) + 1); + + e.u32(f.blocks.len() as u32); + for b in &f.blocks { + e.regs(&b.params); + e.u32(b.ops.len() as u32); + for o in &b.ops { + op(e, o); + } + end(e, &b.end); + } +} + +/// Highest register mentioned anywhere in the function — the frame size. +fn max_reg(f: &Func) -> u32 { + let mut hi = 0u32; + let mut bump = |r: Reg| hi = hi.max(r.0); + for b in &f.blocks { + for r in &b.params { + bump(*r); + } + for o in &b.ops { + bump(o.dst()); + for r in op_srcs(o) { + bump(r); + } + } + for r in end_srcs(&b.end) { + bump(r); + } + } + hi +} + +fn op_srcs(o: &Op) -> Vec { + match o { + Op::Lit(..) | Op::Upval(..) | Op::PopHandler(_) => vec![], + Op::Mov(_, a, _) | Op::Un(_, _, a, _) | Op::Field(_, a, _, _) | Op::Tag(_, a, _) => { + vec![*a] + } + Op::Bin(_, _, a, b, _) => vec![*a, *b], + Op::Call(_, _, rs, _) + | Op::Close(_, _, rs, _) + | Op::Vec(_, rs, _) + | Op::Set(_, rs, _) + | Op::Tup(_, rs, _) + | Op::Adt(_, _, rs, _) + | Op::Builtin(_, _, rs, _) => rs.clone(), + Op::Invoke(_, f, rs, _) => { + let mut v = vec![*f]; + v.extend(rs.iter().copied()); + v + } + Op::Map(_, kvs, _) => kvs.iter().flat_map(|(k, v)| [*k, *v]).collect(), + Op::Perform(_, _, _, rs, ev, _) => { + let mut v = rs.clone(); + v.extend(ev.iter().copied()); + v + } + Op::PushHandler(r, ..) => vec![*r], + } +} + +fn end_srcs(e: &End) -> Vec { + match e { + End::Ret(r) | End::Br(r, _, _) | End::Switch(r, _, _) => vec![*r], + End::Jmp(_, rs) | End::Tail(_, rs) | End::Recur(rs) => rs.clone(), + End::TailInvoke(f, rs) => { + let mut v = vec![*f]; + v.extend(rs.iter().copied()); + v + } + End::Trap => vec![], + } +} + +fn op(e: &mut Enc, o: &Op) { + match o { + Op::Lit(d, l, _) => { + e.u8(0); + e.u32(d.0); + lit(e, l); + } + Op::Mov(d, a, _) => { + e.u8(1); + e.u32(d.0); + e.u32(a.0); + } + Op::Upval(d, i, _) => { + e.u8(2); + e.u32(d.0); + e.u16(*i); + } + Op::Bin(d, o2, a, b, _) => { + e.u8(3); + e.u32(d.0); + e.u8(*o2 as u8); + e.u32(a.0); + e.u32(b.0); + } + Op::Un(d, o2, a, _) => { + e.u8(4); + e.u32(d.0); + e.u8(*o2 as u8); + e.u32(a.0); + } + Op::Call(d, f, rs, _) => { + e.u8(5); + e.u32(d.0); + e.u32(f.0); + e.regs(rs); + } + Op::Invoke(d, f, rs, _) => { + e.u8(6); + e.u32(d.0); + e.u32(f.0); + e.regs(rs); + } + Op::Close(d, f, rs, _) => { + e.u8(7); + e.u32(d.0); + e.u32(f.0); + e.regs(rs); + } + Op::Vec(d, rs, _) => { + e.u8(8); + e.u32(d.0); + e.regs(rs); + } + Op::Map(d, kvs, _) => { + e.u8(9); + e.u32(d.0); + e.u32(kvs.len() as u32); + for (k, v) in kvs { + e.u32(k.0); + e.u32(v.0); + } + } + Op::Set(d, rs, _) => { + e.u8(10); + e.u32(d.0); + e.regs(rs); + } + Op::Tup(d, rs, _) => { + e.u8(11); + e.u32(d.0); + e.regs(rs); + } + Op::Adt(d, tag, rs, _) => { + e.u8(12); + e.u32(d.0); + e.u16(*tag); + e.regs(rs); + } + Op::Field(d, a, sel, _) => { + e.u8(13); + e.u32(d.0); + e.u32(a.0); + match sel { + Selector::Index(i) => { + e.u8(0); + e.u16(*i); + } + Selector::Key(s) => { + e.u8(1); + e.u32(s.0); + } + Selector::Name(s) => { + e.u8(2); + e.u32(s.0); + } + } + } + Op::Tag(d, a, _) => { + e.u8(14); + e.u32(d.0); + e.u32(a.0); + } + Op::Perform(d, eff, o2, rs, ev, _) => { + e.u8(15); + e.u32(d.0); + e.u32(eff.0); + e.u32(o2.0); + e.regs(rs); + match ev { + Some(r) => { + e.u8(1); + e.u32(r.0); + } + None => e.u8(0), + } + } + Op::Builtin(d, b, rs, _) => { + e.u8(16); + e.u32(d.0); + e.u16(*b as u16); + e.regs(rs); + } + Op::PushHandler(r, eff, o2, _) => { + e.u8(17); + e.u32(r.0); + e.u32(eff.0); + e.u32(o2.0); + } + Op::PopHandler(_) => e.u8(18), + } +} + +fn lit(e: &mut Enc, l: &Lit) { + match l { + Lit::Int(v) => { + e.u8(0); + e.i64(*v); + } + Lit::Float(v) => { + e.u8(1); + e.f64(*v); + } + Lit::Bool(v) => { + e.u8(2); + e.u8(*v as u8); + } + Lit::Str(s) => { + e.u8(3); + e.u32(s.0); + } + Lit::Keyword(s) => { + e.u8(4); + e.u32(s.0); + } + Lit::Unit => e.u8(5), + } +} + +fn end(e: &mut Enc, t: &End) { + match t { + End::Ret(r) => { + e.u8(0); + e.u32(r.0); + } + End::Jmp(b, rs) => { + e.u8(1); + e.u32(b.0); + e.regs(rs); + } + End::Br(r, a, b) => { + e.u8(2); + e.u32(r.0); + e.u32(a.0); + e.u32(b.0); + } + End::Switch(r, arms, dflt) => { + e.u8(3); + e.u32(r.0); + e.u32(arms.len() as u32); + for (tag, b) in arms { + e.u16(*tag); + e.u32(b.0); + } + e.u32(dflt.0); + } + End::Tail(f, rs) => { + e.u8(4); + e.u32(f.0); + e.regs(rs); + } + End::TailInvoke(f, rs) => { + e.u8(5); + e.u32(f.0); + e.regs(rs); + } + End::Recur(rs) => { + e.u8(6); + e.regs(rs); + } + End::Trap => e.u8(7), + } +} diff --git a/crates/loon-lang/src/eir/mod.rs b/crates/loon-lang/src/eir/mod.rs index 62955c6..ebfbfaa 100644 --- a/crates/loon-lang/src/eir/mod.rs +++ b/crates/loon-lang/src/eir/mod.rs @@ -4,6 +4,7 @@ //! Every backend (Register VM, WASM, Cranelift) lowers from this IR. pub mod backend; +pub mod image; pub mod lower; #[cfg(feature = "native")] pub mod native; diff --git a/crates/loon-lang/tests/boot_image.rs b/crates/loon-lang/tests/boot_image.rs new file mode 100644 index 0000000..a706d08 --- /dev/null +++ b/crates/loon-lang/tests/boot_image.rs @@ -0,0 +1,90 @@ +//! The boot image is an ABI between two crates that never link together. +//! +//! `loon-kernel` decodes these tags by number. Nothing in the type system +//! connects the two sides, so reordering an enum here would silently remap +//! an operator in every image the kernel runs. These tests are the seam. + +use loon_lang::eir::image; +use loon_lang::eir::{BinOp, UnOp}; + +#[test] +fn binop_tags_are_pinned() { + // Changing any of these means changing `binop()` in + // crates/loon-kernel/src/eir/decode.rs to match. + let expected = [ + (BinOp::Add, 0), + (BinOp::Sub, 1), + (BinOp::Mul, 2), + (BinOp::Div, 3), + (BinOp::Rem, 4), + (BinOp::Eq, 5), + (BinOp::Ne, 6), + (BinOp::Lt, 7), + (BinOp::Gt, 8), + (BinOp::Le, 9), + (BinOp::Ge, 10), + (BinOp::And, 11), + (BinOp::Or, 12), + (BinOp::Concat, 13), + ]; + for (op, tag) in expected { + assert_eq!( + op as u8, tag, + "{op:?} moved: the unikernel decodes it as {tag}" + ); + } +} + +#[test] +fn unop_tags_are_pinned() { + assert_eq!(UnOp::Neg as u8, 0); + assert_eq!(UnOp::Not as u8, 1); +} + +/// Compile a source string the way `loon image` does. +fn image_of(src: &str) -> Vec { + let exprs = loon_lang::parser::parse(src).expect("parse"); + let mut checker = loon_lang::check::Checker::new(); + checker.check_program(&exprs); + let module = loon_lang::eir::lower::lower(&checker); + image::encode(&module) +} + +#[test] +fn image_has_a_versioned_header() { + let img = image_of("[println [+ 1 2]]"); + assert_eq!(&img[..8], image::MAGIC); + assert_eq!( + u32::from_le_bytes(img[8..12].try_into().unwrap()), + image::VERSION + ); +} + +#[test] +fn image_names_every_builtin_it_references() { + // The kernel dispatches intrinsics on these names, so an image that + // uses `println` must carry the string "Println". + let img = image_of("[println \"hi\"]"); + let text = String::from_utf8_lossy(&img); + assert!( + text.contains("Println"), + "builtin name table missing from the image" + ); +} + +#[test] +fn effect_programs_survive_encoding() { + // Handlers are the whole point of the exercise; make sure a program + // with a `handle` encodes at all rather than tripping an unreachable. + let img = image_of( + r#" + [effect Console [write [String] Unit]] + [fn main [] + [handle + [fn [] [Console.write "x"]] + [Console.write s] + [do [print s] [resume []]]]] + "#, + ); + assert!(img.len() > 12, "encoded a suspiciously empty image"); +} diff --git a/crates/loon-lang/tests/unikernel_boot.rs b/crates/loon-lang/tests/unikernel_boot.rs new file mode 100644 index 0000000..c6870c4 --- /dev/null +++ b/crates/loon-lang/tests/unikernel_boot.rs @@ -0,0 +1,61 @@ +//! Boot the unikernel under QEMU and check it agrees with the host. +//! +//! This is the phase-3 exit criterion in test form: the same Loon program, +//! compiled once, must produce identical output whether the effects land on +//! a host syscall or on a UART. Skipped (not failed) when the bare-metal +//! toolchain is absent, since most contributors will not have it. + +use std::path::PathBuf; +use std::process::Command; + +fn workspace_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .and_then(|p| p.parent()) + .expect("workspace root") + .to_path_buf() +} + +fn have(cmd: &str, args: &[&str]) -> bool { + Command::new(cmd) + .args(args) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false) +} + +#[test] +fn unikernel_boots_and_matches_the_host() { + let root = workspace_root(); + let kernel_dir = root.join("crates/loon-kernel"); + + if !have("qemu-system-riscv64", &["-version"]) { + eprintln!("skipping: qemu-system-riscv64 not installed"); + return; + } + let targets = Command::new("rustup") + .args(["target", "list", "--installed"]) + .output(); + let has_target = targets + .map(|o| String::from_utf8_lossy(&o.stdout).contains("riscv64gc-unknown-none-elf")) + .unwrap_or(false); + if !has_target { + eprintln!("skipping: rustup target riscv64gc-unknown-none-elf not installed"); + return; + } + + let out = Command::new("make") + .arg("check") + .current_dir(&kernel_dir) + .output() + .expect("running `make check` in crates/loon-kernel"); + + let stdout = String::from_utf8_lossy(&out.stdout); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + out.status.success() && stdout.contains("identical output"), + "unikernel boot diverged from the host.\n--- stdout ---\n{stdout}\n--- stderr ---\n{stderr}" + ); +} diff --git a/docs/plans/2026-07-01-loon-os.md b/docs/plans/2026-07-01-loon-os.md index 53303cf..cb19c4d 100644 --- a/docs/plans/2026-07-01-loon-os.md +++ b/docs/plans/2026-07-01-loon-os.md @@ -423,6 +423,32 @@ interrupt gives real preemption (an interrupt is the runtime injecting a Exit demo: a Loon web service booting in <10ms as a VM, syscall cost = function call cost. +> **It boots (2026-08-18).** `crates/loon-kernel` is a RISC-V unikernel whose +> kernel is a Loon program: `make -C crates/loon-kernel run` boots +> `boot/init.oo` under QEMU `virt`, and `make check` diffs that run against +> `loon run` on the same source — byte-identical, which is the property that +> matters. There is no userspace and no syscall boundary; `Console.write` +> falls through the Loon handler stack to a 16550 driver instead of to Linux. +> +> **Shape.** The frontend stays hosted. `loon image` serializes a lowered +> `Module` (`eir/image.rs`), `build.rs` invokes it, and the kernel embeds and +> interprets the result — so no parser, checker or lowering is in the +> bare-metal build graph. The kernel interpreter mirrors the host VM's +> structure deliberately (same frame stack, same prompt-depth handler stack, +> same continuation capture on `perform`); `init.oo` exercises handler +> forwarding, abort-without-resume, and non-tail resume on hardware, all +> matching the host. +> +> **Not yet.** Preemption (no timer interrupt — cooperative only, so a pure +> loop owns the machine), SMP, static handler resolution, and most of the +> builtin set (missing ones raise a loud error naming the builtin, never a +> silent `()`). Performance is untuned and it shows: ~0.9 µs per interpreted +> op, because a call allocates a register file and each op allocates an +> operand vector on a first-fit allocator costing ~0.5 µs per allocation. +> "Syscall cost = function call cost" is not yet demonstrated — a function +> call is itself the expensive thing right now. Boot-to-init-exit for the +> demo program is ~30 ms, essentially all interpreter. + ### Phase 4 — Bare metal (optional grind) Tiny effect-router microkernel on real hardware. Only worth it after phases 1–3