The machine task contract
Contract version 4 -- the integer tasks, the depth type, deriving a machine, and the KIND obligation (M17, M24, M39, M45).
src/gen_walk.mcis the target-independent walker;src/machine_arm64.mc(M17 step A) andsrc/machine_x86_64.mc(step B, and M20's Win64 half) are the three machines behind it in the compiler --arm64,x86_64,x86_64-win-- andmachine(name, tab)insrc/hooks.mcis the seam. Since M39 there is a fourth, and it is not in the compiler:examples/kernel/machine_riscv64.mcregistersriscv64from a module underexamples/, which is the proof that the seam is real from outside; since M40 there is a fifth,examples/avr/machine_avr.mc, which is the proof that an 8-bit part with a two-byte pointer is one too (§ The AVR implementation). M40 appends no slot and changes no signature. Since M24 a machine also reads the TYPE of a depth (walk_depth_type), which is howlib/machine_arm64_float.mcanswersfaddwhere the built-in answersadd, and how a module derives a machine withmachine_tab/machine_slot;#machinewas dropped (docs/specs/M24.md§ M9).A version is the list of
MTASK_*slots below, in that order and with those signatures, plus what a machine outsidesrc/may rely on. Adding a task appends a slot and bumps the version; changing one's signature is a breaking change to every machine, and there is exactly one place to look for the answer — this page.Version 1 → 2 appended exactly one slot,
MTASK_RELOC_OFF. Version 1 assumed a relocation patches the instruction from its first byte, which is true of every fixed-width encoding and false of x86:call rel32carries its field one byte in,lea r, [rip + disp32]three. The walker now asks. AArch64 answers 0 and its objects did not move a byte.Version 2 → 3 (M24) appends no slot and changes no signature. The bump is what a module may rely on:
walk_depth_type(d)andwalk_ret_type()(§ 3),machine_tab/machine_slotas the way to derive a table, and the three allocator functions § 3 publishes by name. The float tasks the old § 3 specified — thirteenmf_*slots — were dropped: a second register file needs no contract line once the walker says what type is at a depth, and<float>is a library over that (docs/specs/M24.md).#machinewas dropped with them, for the reasons in § 4.Version 3 → 4 (M45) appends no slot and changes no signature either. The bump is an OBLIGATION: the
tyaty-carrying slot receives may be an id the registry owns, of width 1, 2, 4 or 8 and of kindTK_INTorTK_SINT, and a machine owes extension by kind — zero above the width for aTK_INT, the sign for aTK_SINT— in its loads and itsMTASK_CAST, and truncation by width in its stores. The core itself now registers one such id,i32(language.md § 2), so this is not hypothetical. A machine that keys those slots on the core ids alone is silently wrong: itsMTASK_CAST(i32)is a no-op and itsMTASK_LOCAL_LOAD(i32)reads eight bytes out of a four-byte slot. The rule, stated once: dispatch ontype_width(ty)andtype_kind(ty), never on the id. A machine that will not implement the kind must remove the word from its surface —type_disable(ty_i32)from itsuser_init, which is whatexamples/avrdoes.
Why the split exists #
Until M17 gen_lower did two things at once: it walked the AST (frames, the depth stack, labels,
calls, name resolution) and it selected AArch64 instructions (I_* opcodes, x9..x15 as depth
registers, x16/x17 as scratch). A second instruction set cannot be added without separating
them, and separating them must not change a single byte of the objects the first one produces —
which is why the acceptance criterion is that check-obj (32/32) and check-asm (73/73) stay
identical against the frozen C seed, which is still one monolithic generator.
After the split:
src/gen_resolve.mc— resolution and typing, in a side table indexed by node. Every name is bound and every expression typed before a single instruction is selected (objects.md § 2).src/gen_walk.mc— the walker. It knows nothing about registers. It owns theInsbuffer, the frame in bytes, the label counter, the loop stack, the block scoping of locals, the sections, the globals, the string literals and every symbol; and it drives a machine table: one&fnper task, invoked throughcallp.src/machine_arm64.mc— the register partition, the spill policy, the selection, the encoders and the--dump-asmtext, moved behind that table.src/machine_x86_64.mc(step B) is a second one of exactly that shape.gen_lower,gen_encode_alland the three format writers keep their names and their behaviour, solib/backend_arm64.mc,src/backend_exe.mcandsrc/backend_elf.mcdid not change a line in step A; step B added anEM_X86_64entry point to the ELF writer and touched neither of the other two.
1. Registering a machine #
void machine(uptr name, uptr tab) // tab: MTASK_COUNT entries of &fn, in MTASK_* order
i64 machine_find(uptr name) // index, or -1; searches back to front
void machine_use(uptr name) // make that machine the one in effect
machine() appends to a linear table in registration order and also makes the machine the one in
effect — unlike backend(), a machine is not chosen by a flag but by the target, and the compiler
always has exactly one. The ceiling is fixed (MAXMACHINES 8): a machine does not scale with the
program being compiled, so M23's "no MAX* on tables that grow with the input" does not apply.
src/machine_arm64.mc builds its own table with two helpers and registers it from main(), before
any backend can lower:
void machine_task(i64 task, uptr fn) // one slot of m_arm64
void machine_arm64_init() // fills all 31 slots, then machine("arm64", m_arm64)
src/machine_x86_64.mc is the same two functions under its own names, x86_task and
machine_x86_64_init() — which registers x86_64 and then, from a copy of the same table with
MTASK_PROLOGUE replaced, x86_64-win (M20).
The walker reaches the table through uptr mach(i64 task), which is the only place that reads
mach_tab. A machine that is missing is no machine registered, not a crash.
How a machine is chosen (settled in step B). main() registers every machine and then names
the host's, because every machine() call also makes its own table current:
machine_arm64_init();
machine_x86_64_init();
machine_use("arm64");
From there the object backend picks, as its first statement — backend_elf does
machine_use("arm64"), backend_elf_x86 does machine_use("x86_64"), and backend_coff_x86 does
machine_use("x86_64-win"), which is how the Win64 ABI is reached without target() growing a
fifth column. Step A left two shapes
open: a fifth column on target(), or machine_use from the backend. The backend won, for three
reasons. target() keeps the four columns docs/specs/M33.md § 1 wrote down. An AST-consuming
backend (wasm) needs no machine at all, so a mandatory machine column on every target would be a
column it has to leave empty. And the file format already records the architecture — e_machine,
cputype — so the writer that fills that field is exactly the code that knows which instruction
set produced the bytes; splitting the two across a registry row would let them disagree.
The --dump-* modes never reach a backend, so --machine=NAME (cli.md) is what points
them at a machine other than the host's: mc --dump-asm --machine=x86_64 f.mc.
2. The integer tasks (version 2) #
Every task takes depth indices, never registers: the walker says "the value is at depth 2", and
whether depth 2 lives in a register or in a frame slot is the machine's decision. ty is a core
type id — one of the core constants (TY_U8, TY_U16, TY_U32, TY_U64, TY_I64, TY_UPTR) or
one the registry owns, i32 included — and stands for the access width and, for a load or a
cast, for the kind that says how the bytes above that width are filled (contract version 4:
type_width(ty) and type_kind(ty), never the id); l is a label number; sym a symbol index;
e an Ins record.
| slot | signature | meaning |
|---|---|---|
MTASK_PROLOGUE | void f() | open the frame: the frame record, and a reserve whose size is not known yet |
MTASK_PARAM | void f(i64 ty, i64 i, i64 off) | argument i into the frame slot at off; i past the machine's register table means the caller left it on the stack (M38: MAXPARAMS is 12) |
MTASK_EPILOGUE | void f() | release the frame and return |
MTASK_FRAME_FIX | void f(i64 frame) | the frame size, known only after the whole body |
MTASK_CONST | void f(i64 d, i64 imm) | materialise a constant at depth d |
MTASK_BIN | void f(i64 op, i64 d, i64 d2) | MOP_* of depths d and d2, result at d |
MTASK_CMP | void f(i64 cond, i64 d, i64 d2) | MCOND_*, result 0/1 at d |
MTASK_UN | void f(i64 op, i64 d) | MUN_* in place |
MTASK_BOOL | void f(i64 d) | d = (d != 0) — the normalisation &&/|| needs |
MTASK_CAST | void f(i64 ty, i64 d) | extend depth d to the type's width by its kind: zero-fill for TK_INT, sign-fill for TK_SINT, nothing at width 8 |
MTASK_LOAD | void f(i64 ty, i64 d) | d = [d] at the type's width, zero- or sign-extended by its kind (ld8..ld64 are all TK_INT) |
MTASK_STORE | void f(i64 ty, i64 d) | [d] = d + 1, truncated to the width — the same instruction for both kinds (st8..st64) |
MTASK_LOCAL_ADDR | void f(i64 d, i64 off) | the address of a frame slot |
MTASK_LOCAL_LOAD | void f(i64 ty, i64 d, i64 off) | read a frame slot into d, extended by the type's kind |
MTASK_LOCAL_STORE | void f(i64 ty, i64 d, i64 off) | write depth d into a frame slot |
MTASK_SYM_ADDR | void f(i64 d, i64 sym) | a symbol's address — a global, a string literal, or a function taken with & |
MTASK_GLOBAL_LOAD | void f(i64 ty, i64 d, i64 sym) | read the global at sym into d, extended by the type's kind |
MTASK_GLOBAL_STORE | void f(i64 ty, i64 d, i64 sym) | write depth d into the global at sym |
MTASK_CALL | void f(i64 d, i64 nargs, i64 sym) | a direct call; the arguments are depths d .. d+nargs-1, the result lands at d |
MTASK_CALLP | void f(i64 d, i64 nargs) | an indirect call; the pointer is argument 0, at depth d |
MTASK_RET | void f(i64 d) | depth d into the return position (the walker emits the jump to the epilogue itself) |
MTASK_JUMP | void f(i64 l) | unconditional jump |
MTASK_JZ · MTASK_JNZ | void f(i64 d, i64 l) | jump when depth d is zero / non-zero |
MTASK_LABEL | void f(i64 l) | place label l |
MTASK_WORD | void f(i64 w) | exactly one raw word — what emit() and #opcode reach |
MTASK_INS_SIZE | i64 f(uptr e) | how many bytes instruction e will occupy; 0 for what emits nothing |
MTASK_ENCODE | void f(uptr e, i64 pc, uptr lab, uptr buf) | write instruction e into the section buffer buf |
MTASK_RELOC_KIND | i64 f(uptr e) | the relocation this instruction always carries (R_*), or -1 |
MTASK_RELOC_OFF | i64 f(uptr e) | how many bytes into the instruction that relocation's field starts |
MTASK_DUMP | void f(uptr e) | one --dump-asm line |
The operator vocabulary the tasks speak:
MOP_ADD MOP_SUB MOP_MUL MOP_SDIV MOP_UDIV MOP_SMOD MOP_UMOD
MOP_AND MOP_OR MOP_XOR MOP_SHL MOP_SHR MOP_SAR
MUN_NEG MUN_NOT MUN_LNOT
MCOND_EQ MCOND_NE MCOND_LT MCOND_LE MCOND_GT MCOND_GE
Signed and unsigned are separate operations, not a flag: the walker picks MOP_SDIV over
MOP_UDIV (and MOP_SAR over MOP_SHR) from res_type of the left operand, which is mc's actual
rule — i64 and every TK_SINT divide and shift with sign, everything else does not
(type_signed, M45). Comparisons are always signed, so there is one set of six.
The walker itself issues MTASK_CAST in two places no expression asked for it (M45): after
MTASK_CALL, when the callee's declared result is a TK_INT/TK_SINT narrower than the word and
is not uptr, and before MTASK_RET, when the function's own declared result is. Every ABI leaves
the bits above a narrow result unspecified, so that extension is the compiler's to perform, and
routing it through a slot every machine already fills is what keeps the contract at 31 slots. The
ty those two casts carry is the DECLARED type, and the walker rewrites walk_depth_type(d) to
it before the call-side one, so a derived machine reading the depth type as the cast's SOURCE sees
an integer and not the type of argument 0.
The four divide operations say nothing about a zero divisor or about INT64_MIN / -1: a
machine emits its target's divide instruction and the ISA answers, so a new machine owes no guard
and is not judged on the answer it gives — but it does owe the row in the table below.
Three answers exist today and they are all different: AArch64's sdiv/udiv give 0, x and
INT64_MIN and never trap; x86-64's idiv/div raise SIGFPE and the process dies; RV64M's
div/divu/rem/remu give -1, x and INT64_MIN and never trap
(../core-language.md § "Division by zero, and INT64_MIN / -1"). Shift
counts are the opposite case: mc and all three machines mask them modulo 64.
What the walker keeps, and what it hands over #
| the walker owns | the machine owns |
|---|---|
the depth stack and MAXDEPTH (64, an error not a table) | which depths live in registers, and where the rest spill |
the frame in bytes: slot_new(size) | asking for a spill slot, at most once per depth |
| the label counter, the loop stack, block scoping | nothing about control flow but the encoding |
the Ins buffer, ins_add and e0/e2/e3/ei/el/elr/em | which I_* goes in it |
I_LABEL (opcode 0, reserved) and the pending-reloc() list | every other opcode |
sections, globals, string literals, symbols, reloc_add | MTASK_RELOC_KIND alone |
frame too large (over 4095 bytes) | the reason it is 4095 |
frame too large is deliberately kept in the walker even though the 12-bit limit is AArch64's:
docs/specs/M17.md § step B says a machine with no such limit should keep the language limit
anyway, so the diagnostic is the same on every target.
And a machine with a SMALLER limit pays for it itself. RISC-V's load/store displacement is a
signed 12-bit field and reaches 2047, where 4095 is AArch64's unsigned one; frames of
2048..4095 are legal to the walker and unencodable by a naive machine — a silent wrong address,
not a diagnostic. The obligation is on the machine, not on the walker (docs/specs/M39.md § G7):
examples/kernel/machine_riscv64.mc materialises any offset past 2047 in t2 and adds it, which
is what makes V_ADDI, the eight memory forms and the frame reserve variable-length, and
therefore what makes running the real encoder for MTASK_INS_SIZE mandatory rather than tidy. A
machine that cannot encode the whole 0..4095 range has to say so on this page.
The same rule covers jump range. The walker has no limit on how much code a function may
contain, and the three machines disagree by two orders of magnitude on how far one jump reaches:
AArch64's b 128 MiB, x86-64's rel32 ±2 GiB, RISC-V's jal 1 MiB. A machine whose field is too
small must say so with a diagnostic, never mask the displacement into it — a truncated jump
gives an image that builds, boots and lands in the middle of an instruction, which no later gate
catches. src/machine_arm64.mc does it in br_off (branch too far, checked against the
smallest of its three fields), and examples/kernel/machine_riscv64.mc in rv_jal_off
(riscv jal out of range), and examples/avr/machine_avr.mc in avr_rjmp_off
(avr rjmp out of range, at ±4 KiB — the smallest reach of any machine here, and the one where a
real program can hit it). All three check on the ENCODE pass only: MTASK_INS_SIZE runs before any
label address exists, and both machines' jump forms are fixed width, so the size does not depend
on the answer.
The AArch64 implementation #
The thirty-one slots are filled by a64_prologue, a64_param, a64_epilogue, a64_frame_fix,
a64_const, a64_bin, a64_cmp, a64_un, a64_bool, a64_cast, a64_load, a64_store,
a64_local_addr, a64_local_load, a64_local_store, a64_sym_addr, a64_global_load,
a64_global_store, a64_call, a64_callp, a64_ret, a64_jump, a64_jz, a64_jnz,
a64_label, a64_word, a64_ins_size, a64_encode, dump_ins, a64_reloc_kind and
a64_reloc_off (which returns 0: the word is the field). Under them sit the pieces a backend can
still call by name: gen_imm(rd, v), gen_cast(rd, ty) and gen_gaddr(rd, sym)
(objects.md § 3).
Deviations from docs/specs/M17.md's sketch #
The spec's list is the same contract with three names collapsed, and this page is the normative one:
m_global_addrandm_str_addrare one task,MTASK_SYM_ADDR: both are "the address of this symbol", and&fnis the third caller of it.m_arg_move(d, i)is not a slot. The walker lowers the arguments to depths and then callsMTASK_CALL/MTASK_CALLPonce; where each argument goes, and what has to be saved around the call, is entirely the machine's — which is what letscallpput its pointer inx16without the walker knowing.m_prologue(frame, nparams)is split intoMTASK_PROLOGUE(no arguments), oneMTASK_PARAMper parameter andMTASK_FRAME_FIX(frame), because the frame size is only known after the body: the spill slots are allocated while it is being walked.
The x86-64 implementation (M17 step B) #
src/machine_x86_64.mc fills the same thirty-one slots, and registers two machines out of one
set of functions: x86_64 (System V, M17 step B) and x86_64-win (Win64, M20). It is the proof
that the split is real: not one line of src/gen_walk.mc is architecture-specific, and the ELF
writer is shared with aarch64 down to the section table.
| AArch64 | x86-64 System V | x86-64 Win64 | |
|---|---|---|---|
| machine name | arm64 | x86_64 | x86_64-win |
| depth registers | x9..x15 (0..6) | r8..r11 (0..3) | the same — volatile in both ABIs |
| why those | caller-saved, not argument registers | the same rule leaves exactly four | — |
| scratch | x16, x17, x8 | rax (S1), rcx (S2), rdx | the same |
| why three | — | idiv writes rdx, div needs it zeroed, shifts count in cl | — |
| locals | [sp, #k], fixed up at the end | [rbp - k], correct from the first instruction | the same |
| frame | stp x29, x30 + sub sp | push rbp; mov rbp, rsp; sub rsp / leave | the same |
| arguments | x0..x7, then [sp], [sp+8], … | rdi rsi rdx rcx r8 r9, then [rsp], [rsp+8] | rcx rdx r8 r9, then [rsp+32], … |
| stack parameters | [x29+16], [x29+24], … | [rbp+16], [rbp+24], … | [rbp+48], [rbp+56], … |
| outgoing area | the bottom of the frame, sp never moves | push, given back with add rsp | the same, plus the shadow space |
| shadow space | — | none | 32 bytes, reserved by the caller |
| callee-saved, never touched | x18..x28 | rbx, r12..r15 | those plus rsi, rdi |
| result | x0 | rax | rax |
callp pointer | x16, blr x16 | rax, call rax | the same |
| instruction width | 4 bytes | 1..10 bytes | the same |
x / 0, x % 0, INT64_MIN / -1 | sdiv/udiv: 0, x, INT64_MIN, no trap | idiv/div: SIGFPE, the process dies | the same |
| relocations | BRANCH26 PAGE21 PAGEOFF12 UNSIGNED | R_X86_64_PLT32 PC32 64 | IMAGE_REL_AMD64_REL32 ADDR64 |
| relocation offset | 0 | 1 (call), 3 (lea [rip+d32]) | the same |
The two ABIs are two machines, not a flag. m_x86_64_win is a copy of m_x86_64 with one slot
replaced, MTASK_PROLOGUE; the other thirty entries are literally the same &fn, because
MTASK_INS_SIZE, MTASK_ENCODE, MTASK_DUMP, MTASK_RELOC_KIND and MTASK_RELOC_OFF are pure
functions of the Ins record and know nothing about a calling convention. The convention itself
lives in three globals — the argument table, how many arguments travel in registers, and the
caller's shadow space — set by that prologue, which gen_func always runs before the first
MTASK_PARAM and before any MTASK_CALL, so they can never be stale. Two machines rather than a
runtime flag because --dump-asm --machine=x86_64-win has to be able to show the Win64 sequence,
and a flag the backend sets could not.
Two consequences of variable-length encoding, both already in the contract:
MTASK_ENCODEwrites into the section buffer instead of returning a word, andMTASK_INS_SIZEis what the label pass asks. There is exactly one encoder,x86_put:MTASK_ENCODEis that function, andMTASK_INS_SIZEruns the same function over a scratch buffer and returns its length. The two therefore cannot disagree — and a one-byte disagreement would silently move every later branch, so "cannot" is worth more than "is checked".- One descriptor table drives all three readers of an opcode.
x86_descholds six columns per opcode — form,REX.W, the opcode byte(s), the ModRM extension, an optional0x66prefix, and whether REX is forced — andx86_putand the--dump-asmprinter both dispatch on the form. Thirty of the forty-five opcodes need no code of their own at all. - Functions are still aligned to 4 (
gen_encode_one), so up to three zero bytes sit between them. They are never executed — every function ends inret— but a disassembler decodes them asadd %al, (%rax).
The arguments past the register table are pushed with push r/m64, straight from the frame slot, so
no scratch register is spent on them; an odd count reserves an extra 8 bytes first, because rsp
has to be 16-byte aligned at the call. On Win64 the 32 bytes of shadow space are subtracted
last, so they end up below the pushed arguments and the fifth argument lands at [rsp+32] — the
place the callee's MTASK_PARAM reads it from. The alignment rule does not change: 8*np + 32 is
0 mod 16 exactly when np is even. objects.md § 4c is the full Win64 contract,
including why r8/r9 being argument registers 3 and 4 and depth registers 0 and 1 is safe.
The RISC-V 64 implementation (M39) — from outside the compiler #
examples/kernel/machine_riscv64.mc fills the same thirty-one slots from a module under
examples/, with its own two-line setter (rv_task), and examples/kernel/image.mc is the
writer that consumes it. Nothing in src/ changed to make it possible.
| AArch64 | x86-64 System V | RISC-V 64 (RV64IM) | |
|---|---|---|---|
| machine name | arm64 | x86_64 | riscv64 |
| depth registers | x9..x15 (0..6) | r8..r11 (0..3) | t3..t6 (0..3) |
| why those | caller-saved, not argument registers | the same rule leaves exactly four | the same rule again: s1..s11 are callee-saved and a0..a7 are arguments |
| scratch | x16, x17, x8 | rax, rcx, rdx | t0 (left/dst), t1 (right), t2 (addresses only) |
| why three | — | idiv writes rdx, shifts count in cl | t2 is what the four big-offset fallbacks borrow, so nothing else may live in it |
| locals | [sp, #k], fixed up at the end | [rbp - k], correct at once | [s0 - k], correct at once |
| frame | stp x29, x30 + sub sp | push rbp; mov rbp,rsp; sub rsp / leave | addi sp,sp,-16; sd ra,8(sp); sd s0,0(sp); mv s0,sp + addi sp,sp,-F |
| epilogue patched? | yes, the add sp | no, leave | no: mv sp, s0 releases any frame |
| arguments | x0..x7, then [sp], [sp+8], … | rdi rsi rdx rcx r8 r9, then pushed | a0..a7, then [sp], [sp+8], … |
| stack parameters | [x29+16], [x29+24], … | [rbp+16], [rbp+24], … | [s0+16], [s0+24], … |
| outgoing area | the bottom of the frame, sp never moves | push, given back with add rsp | the bottom of the frame, sp never moves |
| callee-saved, never touched | x18..x28 | rbx, r12..r15 | s1..s11, gp, tp |
| result | x0 | rax | a0 |
callp pointer | x16, moved first | rax, moved first | t0, moved last — its source cannot be an argument register |
| instruction width | 4 bytes | 1..10 bytes | 4 bytes, except li, the frame reserve and the offset fallbacks |
| jump range, and who checks it | 128 MiB (b), br_off checks against the smallest field, 1 MiB | ±2 GiB (rel32), no check needed | 1 MiB (jal), rv_jal_off checks on the encode pass |
x / 0, x % 0, INT64_MIN / -1 | 0, x, INT64_MIN, no trap | SIGFPE, the process dies | -1, x, INT64_MIN, no trap |
| relocations | BRANCH26 PAGE21 PAGEOFF12 UNSIGNED | R_X86_64_PLT32 PC32 64 | two module-private kinds, 32 and 33 |
| relocation offset | 0 | 1 (call), 3 (lea [rip+d32]) | 0 |
narrow load, TK_INT (M45) | ldrb ldrh ldr w | movzx movzx mov r32 | lbu lhu lwu |
narrow load, TK_SINT (M45) | ldrsb ldrsh ldrsw | movsx movsx movsxd | lb lh lw |
cast, TK_INT (M45) | and #0xff and #0xffff mov wd, wn | movzx movzx mov r32,r32 | andi / slli+srli |
cast, TK_SINT (M45) | sxtb sxth sxtw | movsx movsx movsxd r64,r32 | slli+srai / sext.w |
Three things are worth reading it for, beyond the columns.
MTASK_BIN has no special case at all. All thirteen MOP_* are one R-type instruction each:
no msub for the remainder, no cqo/idiv pair, no ModRM. sll/srl/sra already mask the
count modulo 64 and lbu/lhu/lwu/ld already zero-extend, which is exactly what
MTASK_LOAD asks for — and lb/lh/lw are the sign-extending twins contract version 4 asks
for, at no cost. RV64IM is the friendliest set the walker has met.
Two relocations are fused into one instruction. MTASK_SYM_ADDR is a single 8-byte Ins
holding auipc rd,0 + addi rd,rd,0, and MTASK_CALL a single one holding
auipc ra,0 + jalr ra,ra,0, each carrying ONE relocation at offset 0. Emitting the two words as
two instructions would need a second relocation against a local label naming the auipc —
RISC-V's %pcrel_lo(L) — which is a shape neither reloc_add nor sym_* has a name for; fusing
avoids it entirely (docs/specs/M39.md § G3, decision D4). The kinds are 32 and 33, chosen by the
module the way src/machine_x86_64.mc chose 16 and 17.
Addressing is pc-relative and not absolute. lui t2, 0x80000 on RV64 yields
0xFFFFFFFF80000000 — the immediate is sign-extended — so the lui/%hi route is wrong at
exactly the address a virt board loads a kernel at, while auipc at the same place yields
0x0000000080000000 (decision D3).
Verification. Every distinct instruction the machine emits while compiling
examples/kernel/main.mc (234), examples/kernel/tests/sweep.mc (262) and a generated source of
800 functions (1057) re-assembles byte-identically under llvm-mc -triple=riscv64 -mattr=+m; the
pc-relative displacements — 58, 34 and 34 fused pairs plus 51, 53 and 3253 branches — are checked
against a placement of the sections recomputed independently from --dump-syms. And the image
boots: examples/kernel/test.sh asserts the transcript and the exit code under QEMU, on the
owner's machine and on the baremetal-riscv64 CI leg.
The AVR implementation (M40) — the first narrow word #
examples/avr/machine_avr.mc fills the same thirty-one slots from a module under examples/, and
examples/avr/image_avr.mc is the ELF32 writer that consumes it. Nothing in src/ changed to
make it possible either — but one thing the compiler that carries it DECLARES did not exist
before M41: type_set_width(TY_UPTR, 2), called from user_init, which is what makes a pointer
two bytes in a frame slot, in a global and in a uptr[] initializer (docs/specs/M41.md § 4a).
The machine does not read that width for its own decisions; the walker does.
| AArch64 | RISC-V 64 | AVR (ATmega328P) | |
|---|---|---|---|
| machine name | arm64 | riscv64 | avr |
| register width | 64 bits | 64 bits | 8 bits, 32 of them |
| depth registers | x9..x15 (0..6) | t3..t6 (0..3) | none: every depth is an 8-byte frame slot |
| why | caller-saved, not argument registers | the same rule | one 64-bit value costs eight registers; four depths is not reachable |
| accumulator | — | — | r16..r23 (ACC), r8..r15 (TMP) |
| scratch | x16, x17, x8 | t0, t1, t2 | r0, r24, and X (r26:r27) for a far frame access |
| locals | [sp, #k], fixed at the end | [s0 - k], at once | [Y + q], q patched by MTASK_FRAME_FIX |
| frame | stp x29, x30 + sub sp | addi sp,sp,-16 + mv s0,sp | push YH; push YL; in Y,SP + sbiw Y + out SP,Y |
| arguments | x0..x7, then the stack | a0..a7, then the stack | all of them in the caller's frame, 8 bytes each, at [Y+1+8i] |
| stack parameters | [x29+16], … | [s0+16], … | base + 4 + 8i (the return address is 2 bytes, plus the pushed Y) |
| outgoing area | the bottom of the frame | the bottom of the frame | the bottom of the frame, and SP never moves in the body |
| result | x0 | a0 | r16..r23, zero-extended to 8 bytes by the callee |
| callee-saved, never touched | x18..x28 | s1..s11, gp, tp | r2..r7 |
callp pointer | x16, moved first | t0, moved last | Z, moved last, halved: icall takes a word address and &f is a byte address |
| instruction width | 4 bytes | 4 bytes | 2 bytes, except jmp/call/lds/sts, which are 4 |
variable-length Ins | no | li, the frame reserve, the offset fallbacks | almost every one: a task is a byte chain of w instructions |
| local displacement | 4095 (unsigned 12-bit) | 2047 (signed 12-bit), t2 past it | 63 (ldd Y+q, 6 bits), X + post-increment past it |
| jump range, and who checks it | 128 MiB (b), br_off | 1 MiB (jal), rv_jal_off | ±4 KiB (rjmp, signed 12-bit words), avr_rjmp_off |
| frame cap | 4095, the walker's | 4095, the walker's | 1024, the machine's: the part has 2 KiB of SRAM |
x / 0, x % 0 | 0, x, no trap | -1, x, no trap | 0, x, no trap — examples/avr/lib/rt_avr.mc decides, because the part has no divide |
*, /, % | one instruction each | one instruction each | a call into a helper the module ships, written in the language |
| relocations | BRANCH26 PAGE21 PAGEOFF12 UNSIGNED | two module-private kinds, 32 and 33 | two module-private kinds, 34 and 35, plus UNSIGNED at 2 bytes |
Four things are worth reading it for.
A depth is memory, and that is a design and not a shortcut. Every task loads its operands from
frame slots into r16..r23, computes, and stores back. The win is that MTASK_CALL needs no
save_live/restore_live at all — a callee cannot clobber a frame slot — and the cost is size:
about 500 bytes of flash for a statement like check(11, a * 3, 0x0369d0369d0369cd). On a part
with 32 KiB that is the binding constraint, and it is why examples/avr/tests/ is two programs.
Arithmetic happens at the depth's declared width, and it diverges. walk_depth_type(d) (M24)
is what makes u16 + u16 two adds instead of eight, and docs/specs/M40.md D4 accepted the
consequence rather than discovering it:
i64 narrow3(u8 a, u16 b, u32 c) { return a + b + c; }
narrow3(200, 40000, 3000000000) // 8 on avr, 3000040200 on arm64
res_binary types a sum from its LEFT operand and nothing re-widens it, so the addition is done in
eight bits here and in sixty-four there. A source that wants the portable answer casts:
(i64) a + (i64) b + (i64) c. examples/avr/tests/sweep_b.mc asserts both answers, checks 52
and 56, so the divergence cannot drift unnoticed.
Comparison is the one place narrow arithmetic cannot be narrow. MTASK_CMP carries no
signedness and comparison in this language is signed, while u8/u16/u32 are unsigned — so
comparing two zero-extended u16s at two bytes would answer -25536 < 1 for 40000. The machine
compares at max(w1, w2) + 1 bytes when neither operand is i64, and at all eight when one is:
the extra byte is zero by the slot invariant, which turns the signed comparison into the unsigned
one. avr_cmp_width is the whole rule.
Every slot holds eight valid bytes. A value of a type of width w has its bytes above w
filled by its KIND — zero for a TK_INT, the sign for i64 and for a TK_SINT — which is what
lets a consumer read a depth at a width the producer did not use: i64 x = u8v; stores one byte
and reads eight, and both are right. Without that invariant every task would need to know what the
next one intends. This machine implements the zero half only, and the compiler that drives it
therefore takes the one TK_SINT word the core registers back out of the surface
(type_disable(ty_i32) in examples/avr/mc-avr.mc); sign-fill on AVR is a later ask.
Verification. Every distinct instruction the machine emits over the firmware (570) and the two
sweeps (675 and 735) re-assembles byte-identically under llvm-mc -triple=avr -mcpu=atmega328p;
the 337 relative and 226 absolute targets are recomputed against the section bounds; the ELF is
compared field by field with the same program built by avr-gcc -mmcu=atmega328p; and the image
runs under both simavr and qemu-system-avr, with the two sweeps checking their own answers
on the device. examples/avr/test.sh and the baremetal-avr CI leg.
#opcode, emit() and reloc() are architecture-specific by nature — a source full of
hand-encoded AArch64 words is portable to Linux arm64 and nowhere else — so the tests that use them
carry a // skip-x86_64: header with the reason, which scripts/test-linux.sh --arch x86_64
prints.
Verification. Every distinct instruction the machine emitted while compiling src/mc.mc for
x86-64 — 948 of them — was fed back through llvm-mc -triple=x86_64-linux-musl and came out
byte-identical; the relocation shapes (R_X86_64_PC32 at instruction + 3 with addend −4,
R_X86_64_PLT32 at instruction + 1 with addend −4) match clang --target=x86_64-linux-musl -c of
equivalent C, field for field. The suite itself runs: make test-linux-x86_64.
The Win64 half was swept the same way (M20): the 967 distinct instructions the machine emits
while compiling src/mc.mc for windows/x86_64 re-assemble byte-identically under
llvm-mc -triple=x86_64-windows-msvc, and the 9361 pc-relative displacements it wrote were checked
against target - (address + length). The relocation shapes match
clang --target=x86_64-windows-msvc -c of equivalent C: IMAGE_REL_AMD64_REL32 at instruction + 1
for a call and at instruction + 3 for a lea r, [rip+d32], with the in-place field zero and no
addend anywhere (objects.md § 8). The suite itself runs on the windows-2025 CI
leg: make test-windows-x86_64 cross-compiles it.
3. The depth type, and what a taught machine may rely on (version 3) #
A machine with one register file needs nothing here: the walker says "the value is at depth 2"
and depth 2 is an integer. A machine with two — floats in v0..v31, a wide value in a pair, a
vector in ymm — has to know what is at depth 2 before it can pick the file, the instruction and
the ABI register. MTASK_PARAM has carried ty since M17, so the callee side of a float ABI was
already reachable; MTASK_BIN/CMP/UN/BOOL/CALL/RET carry no type at all, and that
asymmetry is exactly what these two functions close.
| function | answers |
|---|---|
i64 walk_depth_type(i64 d) | the type of the value currently at depth d; TY_I64 outside 0..MAXDEPTH-1 |
i64 walk_ret_type() | the type the value about to land at this depth will have — the type of the node whose task is running |
Neither is a task slot. No signature moves, the contract stays additive, and a machine that never reads them emits byte for byte what it emitted under version 2.
How they are maintained. gen_expr is a wrapper around the dispatch: it writes res_type(n)
into the depth before the children run, calls the dispatch, and writes res_type(n) again
after. The second write is the one that matters — the value that actually lands at d is
described by the node that produced it, not by the last child that happened to use the same depth.
That one line covers, in one place, every site where the type changes under the walker's feet: a
comparison (i64 out of two floats), gen_logic's shortcut constant, MUN_LNOT, a cast, an
intrinsic load, and a call — where depth d is overwritten by argument 0 before the result comes
back. walk_ret_type() is saved and restored around each child for the same reason, which is why
a MTASK_CALL handler can still ask what the call returns while dtype[d] holds argument 0's
type. Every depth is reset to TY_I64 at the top of each function.
What this buys. MTASK_BIN(MOP_ADD, d, d2) with walk_depth_type(d) == f64 is an fadd;
MTASK_RET(d) returns in v0; MTASK_CALL(d, na, sym) walks walk_depth_type(d + i) and runs
the AAPCS64 NGRN/NSRN split — the whole float ABI, with no task added. A stale entry is wrong code
rather than a diagnostic, so lib/machine_probe.mc derives a machine that asserts the contract on
every task and is run over the whole of src/mc.mc by scripts/check-surface.sh; the criterion is
that the assertion never fires and the object stays byte for byte the bundled machine's.
The allocator functions a task handler may call #
A machine's register partition and spill policy are private — dslot, in_reg, save_live,
REG_BASE are nobody's business. Exactly three names are published, because a task handler and an
intrinsic handler (hooks.md § 3) written outside src/ have to be able to find where
the allocator put their operands:
| function | answers |
|---|---|
i64 val_reg(i64 d, i64 scratch) | the register holding depth d's value, loading a spilled depth into scratch first |
i64 dst_reg(i64 d) | the register to write depth d's result into |
void dst_done(i64 d, i64 reg) | tell the machine the result is in reg; spills it if depth d is not register-resident |
They exist with these signatures in both bundled machines (src/machine_arm64.mc,
src/machine_x86_64.mc) and the x86-64 ones carry the x86_ prefix (x86_val_reg,
x86_dst_reg, x86_dst_done) because .mc has no file scope. Nothing else of a machine's
internals is contract: a derived table reaches the built-in implementation of any slot through the
pointer it copied, which is what keeps a64_bin/a64_const from being frozen surface.
Deriving a machine #
machine_tab(name) gives the table to copy from and machine_slot(tab, task, fn) writes one slot
of the copy; the recipe, and the one trap in it — delegate through a pristine second copy, not
through the table you patched — is in hooks.md § 3. lib/machine_probe.mc is the
smallest complete example, src/machine_x86_64.mc's Win64 half the oldest one, and
lib/machine_arm64_float.mc the one that adds a register file.
4. Why there is no #machine #
The directive that would have made the table teachable from a source file —
#machine arm64 fadd_f64(rd, rn, rm) 0x1E602800 | (rm << 16) | (rn << 5) | rd
— was specified through M17 and dropped by M24, deliberately, for three reasons worth keeping:
- it is a fourth encoding-template language after
#opcodeandx86_desc; - a task is not an instruction.
MTASK_BIN(MOP_ADD, d, d2)on a spilled depth is a load, an op and a store, so a one-word template is false for every deep expression; - it would be a directive the frozen
stage0/lex.c'sdir_names[]cannot parse, so it could never appear insrc/*.mcanyway.
machine_slot + #opcode + intrinsic + --dump-machine reach the same place with no new
syntax: a module writes an ordinary .mc function, puts it in a copied table, and
--dump-machine (cli.md) prints every task of every machine with the origin of its
slot, so an override is auditable and a wrong one is visible. lib/user_badmach.mc is exactly
that test: one replaced slot lowers + as a subtraction, a program that adds 50 and 8 answers 42,
and the dump reports bin taught on the arm64 row with every other task still bundled.
5. What a module can do instead of writing a machine #
The other seam is the one in objects.md: call gen_lower(root) and replace
gen_encode_all() with your own encoder over gen_ins_at and gen_prel_*. lib/backend_arm64.mc
does exactly that and proves the seam is real by producing byte-identical objects — a
whole-encoder replacement rather than a per-task one. Both seams are live and neither replaces
the other: a machine changes what instructions are chosen, a backend changes how they are written
out.
What a runtime — rather than a backend — may rely on is the register and frame contract in
objects.md § 4: parameters 1..8 in x0..x7 (9..12 at [x29 + 16 + 8*(i-8)])
untouched by the prologue, x0 untouched by
the epilogue, depths in x9..x15, scratch in x8/x16/x17, x18..x28 never written, and the
unconditional stp x29, x30 frame record. Every machine added here has to keep those or say
plainly that it does not — they are what a #opcode syscall wrapper, an atomic and a stack walker
are built on, and scripts/check-surface.sh asserts them against --dump-asm.
That contract is per machine, and the assertions are AArch64's. The RISC-V machine states its
own in examples/kernel/README.md § The ABI, and
examples/kernel/test.sh asserts every line of it against --dump-asm --machine=riscv64 over the
whole kernel — which is what makes the two-instruction context switch in
examples/kernel/lib/sched.mc legitimate rather than lucky. The x86-64 machine states its
own, in the table above and in objects.md § 4b: parameters untouched in
rdi rsi rdx rcx r8 r9, rax untouched by leave; ret, depths in r8..r11, scratch
rax/rcx/rdx, rbx and r12..r15 never written, an unconditional push rbp; mov rbp, rsp
frame record. A runtime written with #opcode is bound to one instruction set anyway.
6. The declared word (M41) #
How wide a pointer is is the one fixed decision of the core a machine may override. It is declared
from user_init(), not from the task table — a width is not a task, and the walker needs the
answer before it lowers the first slot:
void user_init() {
machine_avr_init();
type_set_width(TY_UPTR, 2);
}
type_set_width(ty, w) accepts TY_UPTR only, with w in {1, 2, 4, 8}; every other type is
refused with type_set_width only declares the width of uptr (i64 folds in 64 bits at parse
time and would disagree with the machine). Two functions in src/gen_walk.mc read it, and they
are the whole of what follows:
| function | value | what uses it |
|---|---|---|
walk_word() | type_width(TY_UPTR), 8 by default | the granule slot_new rounds a frame slot to; the size of the pointer a string literal writes into a uptr[] initializer, with an R_UNSIGNED relocation of length log2 of it |
walk_align() | twice the word, 16 by default | the alignment of a frame, of a local array and of a zerofill placement |
Inert with nothing declared: width 8, granule 8, alignment 16, an 8-byte initializer — byte for
byte what the walker did before the mechanism existed, which is what
scripts/check-inert.sh proves over the whole corpus.
A machine does not have to consent to the width: it is the walker that changes, and the machine
sees the smaller offsets in MTASK_LOCAL_LOAD, MTASK_LOCAL_STORE, MTASK_LOCAL_ADDR and
MTASK_FRAME_FIX like any other frame. What it must do is honour them — a machine whose load
instruction cannot reach an odd offset has to say so itself.
The trap, stated once: uptr t[1000] is 8000 bytes and refused as local array too large on
arm64, and 2000 bytes and accepted at width 2. The same source, two answers. sizeof does not
exist in this language, so a record laid out with #define offsets has to write them as N * W
with a per-dialect W.