Spec M39 -- an architecture taught from the surface: examples/kernel, a bare-metal RISC-V 64 micro-kernel built by a taught compiler

Owner's need (2026-09-04): a developer wants to use mc for MIPS/AVR/PIC. mc has no native support for those architectures, and the answer must be tooling to add a new backend and new primitives without touching src/ -- not a fourth machine in the compiler. Bare metal is the honest form of the question: a microcontroller has no OS, no linker script we control, and the output is a flash image, not an object.

Goal: examples/kernel -- a bare-metal RISC-V 64 micro-kernel (reset stub, NS16550A UART, an mtvec trap handler that catches one ecall and returns, two cooperative tasks, prints ok, terminates through the SiFive test device) compiled by a taught compiler that registers a RISC-V machine, a flat-image writer and a bare-metal system layer, all in .mc under examples/, and run under qemu-system-riscv64 -machine virt -bios none -nographic as the CI oracle. Target core cost: zero lines in src/. The proof of the owner's thesis is the diff.

Depends on M17 step A and step B (the walker/machine split, contract version 2, 31 slots), M10 (backend(), &fn, callp), M12/M21 (Tier 3 surface hooks), M14 (mc build), M38 (stack parameters 9..12). Line references are to main at 988e9ad.

What already exists #

Design #

1. The build path -- zero lines in src/ #

examples/kernel/mc.toml has no [target] key, so drv_run resolves the host pair (src/driver.mc:513-515) and never consults the registry for a bare target. Two steps:

build/mc1 build examples/kernel --compiler-only     # -> build/mc-kernel (a HOST binary)
build/mc-kernel --backend=rv-image --include=lib main.mc -o build/kernel.bin

Step 2 is the single-file CLI, where --backend= is resolved after user_init() has registered rv-image. examples/lang and examples/conc already use --compiler-only for stage 1; only stage 2 is a direct invocation instead of --entry-only. This is the whole reason M39 needs no core change, and it is also the honest statement of the limit: mc build cannot yet drive a bare target end to end (gap G1).

user_init() is the entire seam:

void user_init() {
    rv64_machine_init();                 // fills m_rv64[MTASK_COUNT], registers "riscv64"
    backend("rv-image", &backend_rv_image);
    kernel_syntax_init();                // mmio / csrr / csrw / yield  (Tier 3)
}

Registering the machine makes it current, so build/mc-kernel --dump-asm main.mc dumps RISC-V and --machine=arm64 flips back. That is deliberate and is decided in D5.

2. The machine -- examples/kernel/machine_riscv64.mc (~760 lines) #

Fills the same 31 slots, with its own two-line setter (rv_task), as src/machine_x86_64.mc does at :793. RV64I+M is the friendliest instruction set the walker has met: three-operand register ALU (no msub, no cqo/idiv, no ModRM/REX), sll/srl/sra already mask the count mod 64, and lbu/lhu/lwu/ld already zero-extend -- exactly what MTASK_LOAD asks for. MTASK_BIN is one R-type instruction for all thirteen MOP_*, with no special case at all.

choicewhy
depths 0..3t3..t6 (x28-x31)caller-saved, not argument registers -- the same rule that produced x9..x15 and r8..r11; deeper depths spill through slot_new(8) as on both machines
scratcht0 (dst/left, and the callp pointer), t1 (right), t2 (address materialisation)
argumentsa0..a7; 9..12 at [sp+0], [sp+8], ... at the jalr, read by the callee at [s0 + 16 + 8*(i-8)]byte-for-byte M38's arm64 rule
resulta0
frameaddi sp,sp,-16; sd ra,8(sp); sd s0,0(sp); mv s0,sp then one reserve addi sp,sp,-Fs0 = entry sp - 16, so incoming stack arguments land where the formula says
locals[s0 - off], correct from the first instructionx86-64's shape: no REG_FRAME, no late fixup, and the epilogue is never patched
MTASK_FRAME_FIXpatches the single reserve instructionabove 2047 it re-encodes as li t2,-F; add sp,sp,t2 -- legal because frame_fix runs before any encoding, so INS_SIZE and ENCODE see the same immediate
never writtens1..s11 (x9, x18-x27), gp, tpthe module's half of objects.md § 4

MTASK_SYM_ADDR is one 8-byte Ins = auipc rd,0 + addi rd,rd,0, carrying one module-private relocation kind at offset 0. MTASK_CALL is likewise one 8-byte Ins = auipc ra,0 + jalr ra,ra,0 -- the shape R_RISCV_CALL_PLT is defined on, so the ELF door stays open. Fusing is what makes gaps G2 and G3 disappear instead of being paid for. MTASK_JZ/JNZ emit the fixed 8-byte inverted pair (bne rd,x0,8; jal x0,L), because the label pass is single-pass (G6). MTASK_INS_SIZE runs the real encoder over a scratch buffer. MTASK_RELOC_OFF is 0 everywhere (fixed-width fields start at the instruction).

Division is the machine's third answer to the question docs/reference/machine.md already tabulates: RV64M gives x/0 = -1, x%0 = x, INT64_MIN/-1 = INT64_MIN, and never traps -- different from AArch64 and from x86-64's SIGFPE. It goes in that table.

3. The output -- a flat image, no linker (examples/kernel/image.mc, ~300 lines) #

void backend_rv_image(i64 root, uptr out) {
    machine_use("riscv64");   // the object backend picks the machine, as since M17 step B
    gen_lower(root);
    gen_encode_all();
    rv_image_write(out);
}

rv_image_write reads the public model and does what src/backend_exe.mc did for Mach-O at M11, one architecture over: lay __TEXT,__text, __TEXT,__cstring, __DATA,__data from IMG_BASE 0x80000000 in creation order (each rounded to 1 << sec_align), give __bss an address past the image, rebase sym_value by section base, fill _bss_start/_bss_end/ _data_lma/_stack_top with sym_new so the layer needs no linker script, resolve its own relocations in place, and write_file raw bytes -- no header, no signature. It also synthesizes the reset stub at offset 0 (jal x0, _start), because it knows the addresses. That is the same licence backend_exe.mc takes when it fabricates __stubs, and it is what keeps the reloc() whitelist (G2) out of the milestone.

4. The bare-metal layer and the kernel #

sys_bare.mc (~170): UART at 0x10000000 and halt(code) writing 0x5555 / (code<<16)|0x3333 to the SiFive test device at 0x100000 are ordinary st8/st32 -- no directive needed. csrw/csrr/mret/wfi are #opcode templates with the fixed-register trick lib/sys_svc.mc and lib/sys_linux.mc already use, resting on the machine's own guarantee that the prologue does not clobber a0..a7. _start sets sp with #opcode, zeroes .bss, copies .data, sets mtvec from &trap_entry (M10's &fn, through MTASK_SYM_ADDR) and calls kmain() as an ordinary call -- no reloc() anywhere.

trap.mc (~90): trap_entry is #opcode-only -- save ra/a0..a7 into a bss frame, call trap_handler() normally, restore, mepc += 4, mret. Its last statement is a bare mret word; the walker's unconditional epilogue then emits a dead ret behind it, never reached, so a non-ret exit needs no mechanism. Scope: mcause == 11 (ecall from M-mode) and nothing else -- no CLINT, no timer, no PMP, no S-mode, no MMU, one hart.

sched.mc (~110): two tasks with u8 stackN[4096] in bss; yield() swaps ra/sp/s0..s11 between task records in ~25 #opcode words. It is written as a zero-parameter, zero-local leaf, which docs/reference/objects.md § 4 guarantees has frame == 0 with the record pair still unconditional -- that guarantee is what makes a stack switch expressible at all.

main.mc (~260): uart init, one deliberate ecall, the two tasks alternating five times, ok, halt(0).

5. mc.toml, test.sh, CI #

[project] entry/out/kind, no [target], [compiler] modules = ["machine_riscv64.mc", "image.mc", "kernel_syntax.mc"] out = "build/mc-kernel", [limits] tolerance = 1.0 (a taught compiler this size otherwise doubles nodes/ins mid-build -- the reason recorded in examples/lang/mc.toml), [include] paths = ["lib"].

examples/kernel/test.sh = make check-kernel, inside make check, self-skipping in the shape at Makefile:170-172. It must not depend on timeout: neither timeout nor gtimeout exists on this macOS host and no script in the repository uses one. The watchdog is five lines of POSIX sh -- background the QEMU, poll kill -0 for N seconds, kill -9 and report hung.

CI, per docs/plan.md:372-377: the macos-15 job builds kernel.bin and uploads it as an artifact (the split ci.yml already uses for the Linux and Windows legs); a baremetal-riscv64 job on ubuntu-latest installs qemu-system-misc (verified: QEMU 8.2.2 with qemu-system-riscv64), downloads the image and runs it. qemu-system-riscv64 --version goes in the toolchain-facts step.

Gaps in src/ #

Each is stated as the smallest generic mechanism, with its cost and whether the module can avoid it. None is required by M39.

#gapmechanismlinesavoidable by the module?
G1[target] resolved before user_initdefer the resolution~25taken in M39.5 (18 added / 15 removed code lines in src/driver.mc)
G2reloc() accepts four kinds onlyreloc_kind(name, value, pcrel, len)~35yes
G3one implicit relocation per instructionMTASK_RELOC_KIND2/OFF2~24yes
G4rel_name prints UNSIGNED for unknown kinds--0not a gap today
G5function alignment fixed at 4MTASK_FUNC_ALIGN~8yes
G6single-pass label sizingiterate pass 1 to a fixed point~10yes
G7frame ceiling 4095 vs RV's signed 2047none wanted0yes, and must be
G8ELF relocation map is a two-way branchelf_arch(em, &rel, &addend)~28yes
G9machine_task writes m_arm64 by namesignature or doc fix4 or 0yes
G108 bytes per value, uptr always 8machine-declared word size~400no -- out of reach

G1 — taken in M39.5 (2026-09-04), in the deferral form D2 adopted. drv_run keeps [target].os/.arch as strings (drv_os/drv_arch); drv_entry passes DRV_ROLE_OBJ or DRV_ROLE_EXE where it used to pass drv_obj_backend()/drv_exe_backend(); and drv_backend_for(role) consults the registry inside drv_parse, immediately after user_init() and before parse_unit(). The two diagnostics and the requires [linker]: there is no direct executable check moved with it, unchanged; drv_teach's independent host lookup was not touched; there is no second user entry point. Cost: 18 added and 15 removed code lines in src/driver.mc (the two one-line accessors gone, a 9-line resolver, two #defines, one global, two lines in drv_parse, and the four call sites), plus comments. One behavioural consequence, recorded in docs/reference/diagnostics.md and in scripts/check-build.sh: an unknown [target] is now reported after the entry source has been opened and lexed, so the compile x -> y step line comes first and the three [target] diagnostics in check-build.sh had to name an entry that exists. examples/kernel is the consumer: mc.toml gained [target] os = "none" / arch = "riscv64", mc-kernel.mc registers the pair with rv-image in both roles (a bare board has no separable object step, and the exe slot is what lets kind = "exe" need no [linker]), and mc build examples/kernel writes build/kernel.bin byte-for-byte identical to the image the single-file CLI wrote before.

Two review findings on that change, fixed in the same PR. (1) drv_backend_for(DRV_ROLE_OBJ) returned tgt_obj_at() unguarded, so a module registering target(os, arch, 0, exe) -- a shape only a module can write, and the natural one for a board with no separable object step -- made drv_compile call backend_find(0) and str_eq dereference the null: reproduced as exit 139 in the spawned child, and exit 1 with no message from mc build. It is now <os>/<arch> has no object backend: use kind = "exe" through toml_err_key("target.os", ...), the mirror of the requires [linker] message. (2) mc sysroot stub reaches drv_parse without going through drv_compile, so drv_bname was never a role marker and the M39.5 resolution never ran on that path; a third marker, DRV_ROLE_NONE, makes it run the two registry checks and ask for no backend (a stub needs the os and the arch, never a backend). scripts/check-build.sh grew five cases -- tests/proj/noobj.toml (the diagnostic), toy.toml (the same project with the fix the message names: built through the taught target and run), a [target].arch case in build mode, and the two sysroot stub ones asserted against the build-mode messages byte for byte.

The paragraph below is the statement of the problem as M39 left it.

drv_run validates and resolves [target] at src/driver.mc:516-518, while user_init() runs at :243 inside drv_compile; drv_entry compounds it by evaluating drv_obj_backend()/drv_exe_backend() as arguments at :401, :411, :416. So both processes refuse a pair only the taught compiler knows -- the untaught parent at :516, before it ever reaches drv_teach at :537. The sound mechanism is a deferral, not a second hook: keep [target].os/.arch as strings in drv_run, have drv_entry pass a role (obj/exe) instead of a backend name, and resolve once in drv_compile right after user_init(). The requires [linker]: there is no direct executable check moves with it unchanged, and drv_teach's independent host lookup (:439) is untouched. A user_targets() second entry point is not the answer: mc has no weak or default definitions, and a taught compiler supplies its own user_init without including lib/user_default.mc (examples/api/mc-api.mc), so a core-called optional entry point breaks examples/api, lang, conc, desktop, minimal and every lib/user_* demo until each adds an empty body.

G2. gen_walk.mc:615-616 refuses any kind but the four Mach-O ones, so a hand-written entry shim cannot name a RISC-V call the way lib/sys_linux.mc names BRANCH26. Avoided here because the image writer synthesizes the reset stub and _start calls kmain normally. The honest fix is a registry shaped like type_alias -- it calls def_add so the source can spell the name -- consulted after the four built-ins.

G3. gen_walk.mc:849-853 allows one implicit relocation per instruction, always against ins_sym(e). RISC-V's canonical auipc %pcrel_hi + addi %pcrel_lo(L) needs a second one against a local label naming the auipc, a shape neither reloc_add nor sym_* has a name for. Avoided by fusing the pair into one Ins with one private kind the writer resolves itself. It becomes mandatory the day someone wants an ld-linked linux/riscv64 object.

G4 is not a gap. rel_name (:783-788) has exactly one caller, dump_buf at :807, and it prints only reloc()-pending kinds -- which :615 confines to the four. A machine's MTASK_RELOC_KIND never reaches it. relt_pcrel/relt_len (:790-795) are applied to machine kinds and classify an unknown one as (pcrel 0, len 4), which is wrong-but-inert exactly as it already is for R_X86_PC32: those fields are Mach-O-shaped and only src/macho.mc reads rel_pcrel. Record it; change nothing. It becomes real only if G2 is ever taken.

G7. gen_walk.mc:893 caps the frame at 4095 -- AArch64's unsigned 12-bit displacement -- while RV's store immediate is signed 12-bit and reaches 2047. Frames of 2048..4095 are legal to the walker and unencodable by a naive machine: a silent wrong address, not a diagnostic. Zero core lines by design (the contract keeps the language limit uniform across targets); the machine materialises the offset in t2 above 2047, and acceptance item 6 tests it.

G9. machine_task (src/machine_arm64.mc:702) writes into m_arm64 by name, yet docs/reference/hooks.md:145-146 tells a module author to "copy the table, overwrite the slot with machine_task, and register the copy" -- following the published recipe corrupts arm64's table. src/machine_x86_64.mc:793 already had to write its own x86_task. M39 fixes the documentation (0 lines of code); changing the signature to machine_task(tab, task, fn) is 4 lines plus 31 mechanical call-site edits per machine and belongs to whoever next touches them. While in that file: hooks.md:154-159 says "Four are registered before user_init() runs" and lists four, where src/main.mc:137-141 registers five.

Out of scope #

Files and estimated deltas #

filelineswhat
examples/kernel/machine_riscv64.mc~760the 31 slots, RV64IM, machine("riscv64", m_rv64)
examples/kernel/image.mc~300backend("rv-image", ...): place, resolve, stub, write
examples/kernel/kernel_syntax.mc~180mmio, csrr/csrw, yield (Tier 3)
examples/kernel/sys_bare.mc~170UART, halt, _start
examples/kernel/trap.mc~90mtvec, trap_entry, mret
examples/kernel/sched.mc~110two tasks, cooperative yield
examples/kernel/main.mc~260the kernel
examples/kernel/mc-kernel.mc~30#include <mc/core> + modules + user_init
examples/kernel/mc.toml / test.sh / README.md~40 / ~200 / ~160build, oracle, prose
Makefile+12check-kernel, guarded, inside check:
.github/workflows/ci.yml+30artifact upload + the baremetal-riscv64 job
docs/reference/machine.md+40the riscv64 column, the division answer, the G7 obligation, the G9 correction
docs/reference/hooks.md+6the machine_task recipe fixed; "Four" -> five targets
docs/guide/97-a-new-architecture.md~180"I have an ISA" -> "my image boots", in three registrations
docs/build.md, examples/kernel/README.md, docs/specs/M39.md--the limits on record
src/, stage0/, lib/, tests/0the milestone's headline

Module total ~2100 lines under examples/, half of it the machine.

Acceptance #

  1. It runs. build/mc1 build examples/kernel --compiler-only prints the path of build/mc-kernel; that binary compiles main.mc to build/kernel.bin; qemu-system-riscv64 -machine virt -bios none -nographic -kernel build/kernel.bin prints the exact expected transcript (boot, trap, t0 t1 t0 t1 ..., ok).
  2. stdout AND the exit code are asserted. Measured 2026-09-04 on QEMU 11.0.1 (Homebrew) and 8.2.2 (ubuntu:24.04): the SiFive test status passes straight through to QEMU's process status -- 0x5555 gives exit 0 and (42 << 16) | 0x3333 gives exit 42. The exit code carries the guest's verdict and must not be discarded; a run that ends by watchdog is reported as hung, distinct from ran and misbehaved.
  3. Inertness. With nothing registered nothing moves: check-obj 32/32 against the frozen C seed, check-asm/check-ast/check-lex unchanged, check-surface 32/32 with its nine ABI assertions untouched, test-exe 32/32, test-linux/test-linux-x86_64/test-windows green, bootstrap at a fixed point with the --dump-asm diff between mc1 and mc2 empty. tests/golden/mc2.sha256 is NOT rewritten -- src/ is untouched, so mc2.o cannot move. (If the owner takes D2 in this milestone instead, the golden is rewritten once, only after the empty asm diff and cmp build/mc2.o build/mc3.o, and a copy of build/mc1 taken before the change must produce byte-identical objects for all 32 tests/*.mc and for src/mc.mc -- the protocol M17 step A used.)
  4. The encoder is checked against an oracle, in M17 step B's form: every distinct instruction the machine emits while compiling examples/kernel/main.mc (and a large synthetic source) re-assembles byte-identically under llvm-mc -triple=riscv64 -mattr=+m, and each pc-relative displacement is checked against target - address. Precedents: 948 instructions for x86-64, 967 for Win64.
  5. The machine states its ABI and the script asserts it, as check-surface.sh does with its nine AArch64 assertions: a0..a7 untouched by the prologue, a0 untouched by the epilogue, zero mentions of s1..s11/gp/tp anywhere in --dump-asm --machine=riscv64 over the whole kernel, an unconditional frame record, and stack parameters 9..12 at [s0 + 16 + 8*(i-8)].
  6. The frame edge case is tested, not assumed. A function with a ~3 KB local array (between RV's 2047 and the walker's 4095) compiles and runs correctly.
  7. Determinism. Two consecutive builds give byte-identical kernel.bin (cmp); the image contains no path, no date, no host string.
  8. The architecture is module-only. build/mc1 --backend=rv-image examples/kernel/main.mc prints unknown backend: rv-image with the registered list; the default compiler also refuses the source (mmio fails as type expected at top level), the gate examples/api and examples/lang use.
  9. Limits. mc limits examples/kernel reports both halves with grow 0 at the declared tolerance.
  10. Docs. make check-docs green with the new symbols documented and the two hooks.md corrections landed; make check-kernel self-skips without QEMU and make check stays green.
  11. git diff --stat src/ stage0/ lib/ tests/ is empty. This is the milestone.

Risks #

  1. The exit-code trap, inverted. An earlier reading of this oracle held that PASS and FAIL both exit 0, which would make any exit-code assertion a false green. Measured on both QEMUs it is false -- the status passes through. The residual risk is version drift, so test.sh asserts stdout and the code, and the CI facts step prints the QEMU version.
  2. No timeout on macOS. check-kernel runs inside make check on the owner's machine and on the macos-15 leg. A timeout -s KILL dependency exits 127 there. The watchdog is written in POSIX sh.
  3. MTASK_INS_SIZE disagreeing with MTASK_ENCODE by one byte moves every later branch and yields a plausible image that jumps into the middle of an instruction. RV64 is fixed-width almost everywhere, which makes a switch tempting; do not -- the frame reserve and the four big-offset fallbacks are variable. Run the real encoder over a scratch buffer.
  4. yield fights the ABI, not the machine. It is the single riskiest 20 lines and must be written and QEMU-tested first, before the machine is finished.
  5. Function order and the reset vector. Functions land in .text in definition order and sections in creation order. The writer's synthesized jal x0, _start at offset 0 removes the trap; without it, boot-into-garbage is silent.
  6. A fourth taught compiler to keep alive. It is a leaf (nothing stacks on it) and it is the first example to exercise the machine seam, so it catches a regression class the other three cannot see.
  7. Scope creep into src/. The pull will be "just add MTASK_RELOC_OK while we are here". Nine gaps are priced above and eight are avoidable. A third src/ change is escalated to the architect as a priced mechanism, never slipped in.

Decisions (architect, 2026-09-04 -- every recommendation below is adopted) #

Adopted, in one line each: D1 no src/ change, the golden does not move, and acceptance 11 is the headline; D2 G1 deferred to M39.5 in the deferral form only (strings in drv_run, a role from drv_entry, one resolution after user_init()), never a second entry point; D3 pc-relative; D4 one fused 8-byte Ins per auipc pair; D5 the taught compiler dumps RISC-V by default; D6 module-private kinds 32 and 33; D7 the hooks.md recipe and the target count are fixed in this milestone, the machine_task signature is not; D8 examples/avr is approved only as a priced sweep of G10 (docs/specs/M40.md, the word-size change), never as a port of tests/. The architect's additions: (a) yield is written and QEMU-tested first (risk 4); (b) the baremetal-riscv64 CI job joins the required checks at merge, per docs/plan.md § Rule for every new target; (c) docs/guide/97-a-new-architecture.md is written for the MIPS developer of the owner's question and says plainly which families the data model excludes today.

Edit this page