Imported from JonasKruckenberg/k23 (
AGENTS.md). Install upstream withnpx skills add JonasKruckenberg/k23. Copyright stays with the author.
AGENTS.md
k23 is a research Rust microkernel that runs WebAssembly as its primary execution environment. RISC-V 64 is the current target; aarch64 and x86_64 are in progress. Built with BUCK2 (not Cargo workspaces). no_std throughout; custom allocator (talc), async runtime (kasync), panic/unwinding runtime.
Orientation
New here? Read this section, then manual/src/overview.md for the full architecture.
Mental model. Boot flows loader → kernel → WebAssembly guests. sys/loader/ runs first (it's a UEFI application): it verifies the kernel image, maps it, sets up MMU state, and hands off to sys/kernel/, which brings up trap handling, memory, the kasync runtime (sys/async/), and ultimately runs WASM. Reusable, kernel-agnostic pieces live in lib/ as standalone crates; sys/ holds the parts that only make sense as this kernel. Before adding anything, ask "could this be a lib/ crate?" first.
Where to start reading.
sys/loader/src/main.rs— the first code that runs; boot, image verification, MMU setup, handoff.sys/kernel/src/main.rs— kernel entry; wires uparch/,mem/,wasm/, tracing, and the shell.sys/async/src/lib.rs— thekasyncexecutor,block_on, and sync/time primitives.lib/— look here before hand-rolling a data structure;wavltree,range-tree,arrayvec,spin,sharded-slab,cpu-local,mem-corealready exist.manual/src/— authoritative docs:overview.md,startup.md,arch/(memory layout, KASLR),contributing/.
How work gets done.
- nix provides the toolchain — always work inside
nix develop .#default. justis the entry point to everything (build, test, lint, run). Skim thejustfile;just preflightis the one gate that mirrors CI.- BUCK2 is the build system, not Cargo. Targets look like
//sys:k23-riscv64-qemu. After adding/removing a crate, runjust rust-projectso rust-analyzer picks up the new graph. - QEMU runs the kernel —
just run //sys:k23-riscv64-qemuboots it interactively;just selftestsruns the.wastsuite under it. - Multi-arch: the default lane is the host; prefix recipes with
platform=//platforms:riscv64to target RISC-V. Seeplatforms/README.md.
Conventions that bite (details in the sections below): no_std everywhere; a mandatory per-file license header; hand-written error types (no thiserror); #[expect(…, reason = "…")] over #[allow]; every unsafe block needs a // Safety: line; prefer property/fuzz/loom tests over example-based ones. The eight Critical invariants are load-bearing — skim them before touching traps, page tables, MMIO, atomics, the allocator, or the async core.
Setup
Develop inside the nix devshell: nix develop .#default.
Toolchain is pinned in rust-toolchain.toml (nightly-2025-07-11). Don't bump it casually — nightly features (allocator_api, asm_unwind, thread_local, …) are load-bearing.
Build, test, lint
just preflight is the single gate that mirrors CI.
just preflight # all CI lanes, targets your changes affect
just preflight //... # same gate, whole workspace
just changed-targets # print just the affected target set
just check # quick compile-check
just clippy / check-fmt / fmt # lint, format
just unittests / miri / loom [targets] # host tests (miri = UB, loom = concurrency)
just selftests # boot kernel under QEMU, run .wast tests
just run //sys:k23-riscv64-qemu # interactive kernel boot
preflight = typos → check-fmt → check-license-headers → clippy on riscv64/aarch64/x86_64 → unittests → miri → loom → selftests → buck2 audit → reindeer-clean → cargo-deny. Don't skip steps; license-header and reindeer checks fail PRs that pass locally otherwise.
Recipes take a target list and default to //...; an empty list means no targets and every recipe exits 0 having done nothing, which is how changed-targets reports "nothing affected" without any caller special-casing it.
After adding/removing a crate, run just rust-project so rust-analyzer picks up the new BUCK graph.
Prefer tests that explore the input space — proptest, fuzz targets, loom — over example-based tests. See manual/src/contributing/adding-tests.md.
Repo map
| Path | What |
|---|---|
sys/kernel/ |
Ties subsystems into a complete kernel: trap handling and glue that can't live elsewhere. Anything extractable belongs in lib/ or sys/async/. |
sys/loader/ |
Verifies the kernel image, maps it, sets up MMU state, hands off. |
sys/async/ |
kasync — cooperative async runtime (executor, Park/Notify, time, sync primitives). |
lib/ |
First-party reusable crates (riscv, trap, uart-16550, cpu-local, mem-core, wavltree, unwind, spin, fdt, …). New subsystems start here unless irreducibly kernel-internal. |
sys/panic-unwind/, sys/backtrace/ |
Kernel-only policy: the #[panic_handler]/catch_unwind runtime and the symbolizing backtrace subsystem. Not reusable libraries — they build on lib/unwind. |
build/ |
Toolchain wiring, kernel-config DSL (kcfg.bzl), QEMU/loom/fuzz/bench harnesses. |
platforms/ |
Target constraints (//platforms:riscv64, :aarch64, :x86_64). |
third-party/ |
Vendored deps; third-party/BUCK is generated by reindeer buckify — don't hand-edit. |
tests/ |
Shared .wast fixtures. |
manual/ |
mdbook docs. Authoritative for layout and workflow. |
Code style
- Format:
rustfmt.toml—group_imports = "StdExternalCrate",imports_granularity = "Module". Runjust fmt. - Edition: Rust 2024.
- License header (mandatory): every first-party
.rsfile starts with the canonical license header (copyright2023-Present, a fixed range that never needs bumping). Enforced by//build/license-header-linterviajust check-license-headers;just fix-license-headersprepends it to files missing one. Exclusions (vendored crates, build/VCS dirs) live in the linter'sEXCLUDED_*lists. no_stdeverywhere. Nostd.extern crate allocfor collections; on critical paths prefer stack /arrayvecover heap. The loader is a UEFI application —uefi::helpers::init()installs a firmware-backed global allocator, soalloc(Vec,Box) works there, but only while boot services are live: it's backed by UEFI'sAllocatePool, which dies atexit_boot_services(sys/loader/src/main.rs). Anything that must outlive handoff goes through the frame allocator /arrayvec, not the heap.- Reuse
lib/before hand-rolling a data structure —wavltree,sharded-slab,range-tree,arrayvec,spinexist. - Errors: every crate defines its own error type and hand-implements
Debug,Display,core::error::Error. Nothiserror.anyhow::Result<T>is acceptable inside the kernel where an error enum is infeasible. - Logging:
logfor early-boot code (loader, early kernel, crates used there);tracingfor normal operation. Default tologwhen unsure. - Docs: every
pubitem has a doc comment.# Errorson publicfns returningResult;# Panicson those that can panic;# Safetyonpub unsafe fns (unsafe rule 2). - Comments: terse; explain non-obvious why, not what. Each comment stands on its own and describes the code as it is now — never reference removed behaviour ("used to…", "previously…"). Contributors aren't all osdev / RISC-V / compiler experts — explain low-level concepts and cite the spec section for hardware-derived constants.
- Lint suppression: prefer
#[expect(lint, reason = "…")]over#[allow(lint, reason = "…")].unfulfilled_lint_expectationsis inDENY, so anexpectwhose lint stops firing fails the build — forcing the suppression to be removed once the underlying issue is gone.allowrots silently. Thereason = "…"field is mandatory (clippy::allow_attributes_without_reasonis inDENY). Workspace lint policy lives inbuild/toolchains/lints.bzl.
Unsafe discipline
- Every
unsafe { }block carries a// Safety:comment stating the invariant relied on. One terse line — accuracy over verbosity. - Every
unsafe fnhas a# Safetydoc section listing caller obligations. - A
Safetycomment relying on a critical invariant below cites it by number — e.g.// Safety: per invariant 4 (trap frame layout). - Inside
unsafe fn, wrap unsafe ops in explicit innerunsafe { }blocks (Rust 2024). - Manual
Send/Syncimpls justify themselves against interior state (raw ptrs,Cell, MMIO). slice::from_raw_parts,get_unchecked, raw-ptr deref: only after explicit length/alignment validation. Never on user-controlled sizes without a bound check.
Critical invariants
Breaking these is a soundness or security regression. SAFETY comments relying on one cite it by number.
- MMIO is volatile. Device registers go through
read_volatile/write_volatileonly (seelib/uart-16550/); plain field access into an MMIO region is UB. Config-then-go sequences need an explicit fence — the CPU doesn't order device accesses against normal memory. - Synchronization needs the right ordering. RISC-V and aarch64 are weakly ordered; never lean on x86_64's strong model.
Relaxedonly for counters with no happens-before; synchronization needsAcquire/Releaseor stronger. Mixing MMIO with normal memory needs an explicit fence. - TLB invalidation after page-table edits is mandatory —
sfence.vmaon RISC-V, thetlbi/dsbsequence on aarch64. Skipping it yields stale TLB entries and silent corruption. - Trap frame layout is load-bearing. The asm register-save order in
sys/kernel/src/arch/must matchTrapFrameinlib/trap/; drift silently corrupts state. All caller-saved registers are saved before any Rust runs. Trap handlers may not allocate, panic, or take a lock the interrupted code might hold. - WASM sandbox boundaries. Re-check guest memory bounds (
offset + len ≤ memory.len()) after everymemory.grow(). Host imports must never panic into JIT code — traps unwind cleanly, panics don't. Keep host and guest pointer provenance separate. - Async cancellation safety. A future may be dropped at any
.await. Don't hold a lock or half-built hardware transaction across an await point.Dropglue must run — nevermem::forgetaway cleanup. Watchselect!arms with lossy partial state. - Allocator deadlock avoidance.
talclives in the higher-half direct map so heap growth never touches kernel page tables. Don't allocate while holding the allocator lock, and never allocate in a trap handler. - No panics on critical paths — trap handlers, scheduler, async runtime core (
sys/async: executor, Park/Notify,block_on), page-table/TLB ops, early boot before the allocator is up, loader crypto verification, hot WASM paths. Surface failures asResult/Option; preferdebug_assert!for invariant checks.unwrap/expectonly on compile-time constants or provably non-empty structures. A panic bypassing the loader's integrity check is a kernel-integrity bypass.
Don't touch
third-party/BUCK— generated. Editthird-party/Cargo.toml, regenerate;just reindeer-cleanfails on drift.buck-out/— gitignored; don't edit, but useful to read: holds the BUCK2 prelude, intermediate build artifacts, and the unpacked source of third-party crates.lib/sharded-slab/,lib/wast/— vendored; license-header-exempt; don't reformat.rust-toolchain.toml,flake.lock,Cargo.lock— change deliberately only; touching them triggers full CI.
Further reading
manual/src/overview.md— architecture.manual/src/contributing/— adding a crate / test / dependency.manual/src/arch/— memory layout, KASLR, startup.platforms/README.md— build matrix.