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 #
- The machine seam.
machine(name, tab)/machine_find/machine_use(src/hooks.mc:384-407),MAXMACHINES 8(src/hooks.mc:373) with three registered today.MTASK_COUNTis 31 (src/gen_walk.mc:95); the contract isdocs/reference/machine.md(version 2 --MTASK_RELOC_OFFwas added by M17 step B). Registering a machine also makes it current (src/hooks.mc:389), deliberately. - The writer seam.
backend(name, &f)(src/hooks.mc:61),backend_find(:72), theunknown backend:diagnostic that lists what is registered (:84). The object model a writer reads is public and documented indocs/reference/objects.md§§ 2, 5-9:gen_lower,gen_encode_all,gen_ins_at(src/gen_walk.mc:1030),sec_at/sec_data/sec_nrel/sec_rel,sym_at/sym_ref/sym_new/sym_set_value,reloc_add,write_file.lib/backend_arm64.mc(M10) is the standing proof that a writer built from outside the compiler reproduces the bytes. - The two machines to copy.
src/machine_arm64.mc(737 lines) andsrc/machine_x86_64.mc(841). The x86 one is the model for a machine whose instruction length is not obvious:x86_ins_size(:702) runs the real encoder over a scratch buffer, so size and bytes cannot disagree. It is also the precedent that a machine may invent relocation kind numbers:R_X86_PC32 16/R_X86_PLT32 17(src/machine_x86_64.mc:52-53), chosen only to stay clear of the Mach-O range, travelling opaquely in the sameRelocrecord. - The target registry.
target(os, arch, obj, exe)(src/hooks.mc:434),MAXTARGETS 16(:421), five pairs registered insrc/main.mc:137-141.exe = 0means "no direct executable, always[linker]".src/driver.mcreads nothing but this table (:76-77,:516-518). MAXPARAMS 12since M38 (src/arena.mc:58): 1..8 in registers, 9..12 on the stack. A new machine must implement the stack half, not stub it (src/gen_resolve.mc:510).mc buildwith no[target]is the host pair (src/driver.mc:513-515, M37), and--compiler-onlybuilds the taught compiler and stops (M21.5).--machine=and--backend=are resolved atsrc/main.mc:220and:232, both afteruser_init()at:218.- The oracle, measured on this machine on 2026-09-04 (not predicted): a hand-assembled raw
RV64 image run as
qemu-system-riscv64 -machine virt -bios none -nographic -kernel k.binprintsokon the UART at0x10000000and terminates through the SiFive test device at0x100000.qemu-system-riscv64is 11.0.1 here (Homebrew) and 8.2.2 inubuntu:24.04viaapt-get install qemu-system-misc;llvm-mcis at/opt/homebrew/opt/llvm/bin.
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.
| choice | why | |
|---|---|---|
| depths 0..3 | t3..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 |
| scratch | t0 (dst/left, and the callp pointer), t1 (right), t2 (address materialisation) | |
| arguments | a0..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 |
| result | a0 | |
| frame | addi sp,sp,-16; sd ra,8(sp); sd s0,0(sp); mv s0,sp then one reserve addi sp,sp,-F | s0 = entry sp - 16, so incoming stack arguments land where the formula says |
| locals | [s0 - off], correct from the first instruction | x86-64's shape: no REG_FRAME, no late fixup, and the epilogue is never patched |
MTASK_FRAME_FIX | patches the single reserve instruction | above 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 written | s1..s11 (x9, x18-x27), gp, tp | the 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.
| # | gap | mechanism | lines | avoidable by the module? |
|---|---|---|---|---|
| G1 | [target] resolved before user_init | defer the resolution | ~25 | taken in M39.5 (18 added / 15 removed code lines in src/driver.mc) |
| G2 | reloc() accepts four kinds only | reloc_kind(name, value, pcrel, len) | ~35 | yes |
| G3 | one implicit relocation per instruction | MTASK_RELOC_KIND2/OFF2 | ~24 | yes |
| G4 | rel_name prints UNSIGNED for unknown kinds | -- | 0 | not a gap today |
| G5 | function alignment fixed at 4 | MTASK_FUNC_ALIGN | ~8 | yes |
| G6 | single-pass label sizing | iterate pass 1 to a fixed point | ~10 | yes |
| G7 | frame ceiling 4095 vs RV's signed 2047 | none wanted | 0 | yes, and must be |
| G8 | ELF relocation map is a two-way branch | elf_arch(em, &rel, &addend) | ~28 | yes |
| G9 | machine_task writes m_arm64 by name | signature or doc fix | 4 or 0 | yes |
| G10 | 8 bytes per value, uptr always 8 | machine-declared word size | ~400 | no -- 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 #
mc builddriving a bare target (G1). Decided in D2 — and done in M39.5, see above.linux/riscv64ELF objects -- needs G3 + G8, ~52 core lines and contract version 3.examples/avr, step 2.qemu-system-avr -machine uno -bios <raw .bin>runs a flash image and its UART reaches stdout. The honest classification: 64-bit ALU, multiply/divide via module-shipped helper routines, Harvard flash-to-SRAM startup copy, 6-bitldd Y+qdisplacements and mixed 16/32-bitemit()are all feasible inside a machine with no core change. What is out of reach is G10:type_widthreturns 8 (src/ast.mc:213) andslot_new(8)is unconditional (src/gen_walk.mc:693,:881), so a ten-local function costs an 80-byte frame on a chip with 2048 bytes of SRAM andtests/024-arena.mc's 4096-byte heap does not fit at all. The blocker is the data model, not the instruction set.examples/avris therefore proposable only with its scope written down first -- UART, one ISR, a blink -- never as a port oftests/. Changing whatuptrmeans is a milestone of its own, adjacent to the 32-bit x86 caveatdocs/plan.mdalready carries. M39's acceptance must not be written as if step 2 followed from it.- MIPS and PIC. MIPS64 lands in the RISC-V shape almost exactly (fixed 32-bit instructions, a
flat register file,
hi/loas a machine detail, branch delay slots fillable fromMTASK_ENCODE) with no new gap. PIC is a different family: banked memory and a hardware call stack of 8-31 levels have no cheap lowering for an unconditional frame record and arbitrary recursion, and G10 bites harder than on AVR. Say so indocs/guide/; do not imply every microcontroller is a machine away. - Of the 32 tests in
scripts/test.sh, 28 would run on a bare-metal target givenwrite(1,...)andexit;025-linecountneeds a filesystem, and031-opcode/032-svc/033-relochand-encode AArch64. Reusing the// skip-<arch>:headerscripts/test-linux.shalready parses would cost zero compiler lines -- but running the suite on RISC-V is not M39.
Files and estimated deltas #
| file | lines | what |
|---|---|---|
examples/kernel/machine_riscv64.mc | ~760 | the 31 slots, RV64IM, machine("riscv64", m_rv64) |
examples/kernel/image.mc | ~300 | backend("rv-image", ...): place, resolve, stub, write |
examples/kernel/kernel_syntax.mc | ~180 | mmio, csrr/csrw, yield (Tier 3) |
examples/kernel/sys_bare.mc | ~170 | UART, halt, _start |
examples/kernel/trap.mc | ~90 | mtvec, trap_entry, mret |
examples/kernel/sched.mc | ~110 | two tasks, cooperative yield |
examples/kernel/main.mc | ~260 | the kernel |
examples/kernel/mc-kernel.mc | ~30 | #include <mc/core> + modules + user_init |
examples/kernel/mc.toml / test.sh / README.md | ~40 / ~200 / ~160 | build, oracle, prose |
Makefile | +12 | check-kernel, guarded, inside check: |
.github/workflows/ci.yml | +30 | artifact upload + the baremetal-riscv64 job |
docs/reference/machine.md | +40 | the riscv64 column, the division answer, the G7 obligation, the G9 correction |
docs/reference/hooks.md | +6 | the 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/ | 0 | the milestone's headline |
Module total ~2100 lines under examples/, half of it the machine.
Acceptance #
- It runs.
build/mc1 build examples/kernel --compiler-onlyprints the path ofbuild/mc-kernel; that binary compilesmain.mctobuild/kernel.bin;qemu-system-riscv64 -machine virt -bios none -nographic -kernel build/kernel.binprints the exact expected transcript (boot,trap,t0 t1 t0 t1 ...,ok). - 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 --0x5555gives exit 0 and(42 << 16) | 0x3333gives exit 42. The exit code carries the guest's verdict and must not be discarded; a run that ends by watchdog is reported ashung, distinct fromran and misbehaved. - Inertness. With nothing registered nothing moves:
check-obj32/32 against the frozen C seed,check-asm/check-ast/check-lexunchanged,check-surface32/32 with its nine ABI assertions untouched,test-exe32/32,test-linux/test-linux-x86_64/test-windowsgreen,bootstrapat a fixed point with the--dump-asmdiff betweenmc1andmc2empty.tests/golden/mc2.sha256is NOT rewritten --src/is untouched, somc2.ocannot move. (If the owner takes D2 in this milestone instead, the golden is rewritten once, only after the empty asm diff andcmp build/mc2.o build/mc3.o, and a copy ofbuild/mc1taken before the change must produce byte-identical objects for all 32tests/*.mcand forsrc/mc.mc-- the protocol M17 step A used.) - 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 underllvm-mc -triple=riscv64 -mattr=+m, and each pc-relative displacement is checked againsttarget - address. Precedents: 948 instructions for x86-64, 967 for Win64. - The machine states its ABI and the script asserts it, as
check-surface.shdoes with its nine AArch64 assertions:a0..a7untouched by the prologue,a0untouched by the epilogue, zero mentions ofs1..s11/gp/tpanywhere in--dump-asm --machine=riscv64over the whole kernel, an unconditional frame record, and stack parameters 9..12 at[s0 + 16 + 8*(i-8)]. - 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.
- Determinism. Two consecutive builds give byte-identical
kernel.bin(cmp); the image contains no path, no date, no host string. - The architecture is module-only.
build/mc1 --backend=rv-image examples/kernel/main.mcprintsunknown backend: rv-imagewith the registered list; the default compiler also refuses the source (mmiofails astype expected at top level), the gateexamples/apiandexamples/languse. - Limits.
mc limits examples/kernelreports both halves withgrow 0at the declared tolerance. - Docs.
make check-docsgreen with the new symbols documented and the twohooks.mdcorrections landed;make check-kernelself-skips without QEMU andmake checkstays green. git diff --stat src/ stage0/ lib/ tests/is empty. This is the milestone.
Risks #
- 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.shasserts stdout and the code, and the CI facts step prints the QEMU version. - No
timeouton macOS.check-kernelruns insidemake checkon the owner's machine and on themacos-15leg. Atimeout -s KILLdependency exits 127 there. The watchdog is written in POSIXsh. MTASK_INS_SIZEdisagreeing withMTASK_ENCODEby 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 aswitchtempting; do not -- the frame reserve and the four big-offset fallbacks are variable. Run the real encoder over a scratch buffer.yieldfights 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.- Function order and the reset vector. Functions land in
.textin definition order and sections in creation order. The writer's synthesizedjal x0, _startat offset 0 removes the trap; without it, boot-into-garbage is silent. - 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.
- Scope creep into
src/. The pull will be "just addMTASK_RELOC_OKwhile we are here". Nine gaps are priced above and eight are avoidable. A thirdsrc/change is escalated to the architect as a priced mechanism, never slipped in.
Decisions (architect, 2026-09-04 -- every recommendation below is adopted) #
- D1 -- Does M39 change
src/at all? Recommend no: zero core lines, golden untouched, and the README states the cost of a new instruction set as one machine, one writer and two registrations, all outside the compiler. Every gap becomes a priced follow-up. - D2 --
mc buildwith a module-registered[target](G1). Recommend deferring it to its own small step (call it M39.5) so M39's inertness proof stays clean, and taking only the deferral form: strings indrv_run, a role passed bydrv_entry, one resolution afteruser_init(). Never a second user entry point. If the owner wants it inside M39, it is step B, gated separately, withscripts/check-build.sh's four diagnostics re-run first (the[target]message would move after thecompile x -> ystep line unlessdrv_stepis emitted after the resolution). - D3 -- Absolute or pc-relative addressing? Decided: pc-relative (
auipc+addi), and this reverses one of the three proposals. Measured on 2026-09-04:lui t2, 0x80000on RV64 yields0xFFFFFFFF_80000000-- the immediate is sign-extended -- whileauipcat the same address yields0x0000000080000000. The absolutelui/%hi + addi/%loroute is therefore wrong at exactly the base QEMU'svirtmachine loads at, and a fused pc-relative pair is both correct and position-independent. - D4 -- Fuse
auipc+addiinto oneIns, or emit two? Recommend one 8-byteInswith one module-private kind. Two would need G3 (a second relocation against a local label), which is 24 core lines and contract version 3 for no gain here. - D5 -- Does the taught compiler dump RISC-V by default? Recommend yes -- registration
makes a machine current (
src/hooks.mc:389), that is documented and deliberate,--machine=still flips back, and a kernel compiler dumping AArch64 would be the surprise. Do not movemachine_use(host_machine())pastuser_init()as a side effect of this milestone; if the owner wants the host to stay the default, it is its own two-line decision. - D6 -- Relocation kind numbers. Recommend the module picks its own (32, 33), following
src/machine_x86_64.mc:52-53's precedent and its stated rule. Do not reuseBRANCH26's integer 2 to meanR_RISCV_CALL: the source would say one thing and mean another. - D7 --
machine_task(G9). Recommend fixing the documentation in M39 (the published recipe corruptsm_arm64) and leaving the signature change to whoever next edits the machines. - D8 -- Is
examples/avrstep 2 approved by this spec? Recommend no: approve only a sweep, in the style ofdocs/specs/M33.md§ 1, that prices the word-size change (G10, ~400 lines, changes whatuptrmeans) before any code. M39 buys step 2 the registration path, not the data model.
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.