The machine task contract
This whole page is frozen surface: the contract's version (one line, extracted by
scripts/surface-extract.sh as the machine kind) may only go up, and only in a commit that adds
a Version N → M paragraph like the ones below — check-freeze enforces both
(hooks.md § 8).
Contract version 6 -- the integer tasks, the depth type, deriving a machine, the KIND obligation, the register allocator and the unsigned comparisons (M17, M24, M39, M45, M49).
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.Version 4 → 5 (M49) APPENDS SIX SLOTS and changes no signature —
MTASK_REG_COUNT,REG_LOAD,REG_STORE,REG_SAVE,REG_RESTOREandPARAM_REG,MTASK_COUNT31 → 37 (§ 5). They are how the walker asks a machine to keep a local in a callee-saved register instead of in a frame slot, and they are reached only when the developer said--opt=1(cli.md). The version's second half is the null-slot rule: for slots 31 and up the walker reads the table entry itself and treats 0 as "this machine does not do this". A machine table is a zero-filled global ofMTASK_COUNTentries, so a machine that fills the first 31 answers 0 toMTASK_REG_COUNT, is never asked the other five, and emits byte for byte what it emitted under version 4 — with no edit.examples/kernel/machine_riscv64.mcandexamples/avr/machine_avr.mcare exactly that case, andscripts/check-opt.shproves it by comparing their artefacts on both roads.Version 5 → 6 appends no slot and changes no signature. It appends four values to ONE argument:
MTASK_CMP'scondmay now beMCOND_ULT,MCOND_ULE,MCOND_UGTorMCOND_UGE(6..9) as well as the six it has always carried. Every comparison used to be signed, so au64or auptrwith bit 63 set read as negative and2 >= (1 << 63)answered TRUE. The walker now picks the unsigned code when neither operand is a signed type and one of them is eight bytes wide (§ 3); a narrow unsigned operand keeps the signed code, because the slot invariant zero-fills it and a signed 64-bit compare of two such values is already right. A machine that does not map the four is wrong, not merely old: there is no null-slot escape here, because the codes arrive in an argument and not in a table entry. The obligation is one line per code in whatever the machine already dispatches on — a condition-code table on arm64 and x86-64, a choice ofsltagainstsltuon riscv64, a choice ofbrlt/brgeagainstbrlo/brshon AVR — and a machine that receives a code it does not know must refuse (unknown condition) rather than encode the signed twin. All five machines in this tree map them.
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 — unlike backend(), a machine is not
chosen by a flag but by the target, and the compiler always has exactly one in effect. A
registration takes that place when there is no machine yet, when the name is new, or when it
replaces the name that is in effect; a registration of some other registered name does not
(the full rule, and what it is for, is in hooks.md § machine).
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 the first registration is what is current by default:
machine_arm64_init();
machine_x86_64_init();
machine_use_if(host_machine());
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) | one of the ten 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 six register-allocation slots (version 5) #
They are appended after MTASK_DUMP in the MTASK_* order --dump-machine prints, and none of
them is mandatory: a 0 entry in any of the six reads as "the machine does not do this", which
is the null-slot rule. r is an allocatable register index, 0 .. count - 1; what register that
IS, is the machine's business and appears nowhere above this file.
| slot | signature | meaning |
|---|---|---|
MTASK_REG_COUNT | i64 f() | how many callee-saved registers this machine lets the walker allocate. 0 = none, and a null slot reads as 0. Asked once per function, before MTASK_PROLOGUE's parameters |
MTASK_REG_LOAD | void f(i64 d, i64 r) | depth d now holds the value of allocatable register r |
MTASK_REG_STORE | void f(i64 ty, i64 d, i64 r) | register r = depth d, truncated to type_width(ty) and extended by type_kind(ty) — the same obligation version 4 put on MTASK_CAST, at the store instead of at the load |
MTASK_REG_SAVE | void f(i64 r, i64 off) | write register r into the frame slot at off, in the prologue, after the parameters |
MTASK_REG_RESTORE | void f(i64 r, i64 off) | read it back, before MTASK_EPILOGUE. It must not touch the result register: a return has already put the value there |
MTASK_PARAM_REG | void f(i64 ty, i64 i, i64 r) | argument i straight into register r, extended by the type's kind. Argument registers are READ and never written, exactly as in MTASK_PARAM |
MTASK_REG_LOAD carries no type on purpose: a register holds the fully extended eight bytes,
exactly as a spill slot does, so the extension is performed once at the store and never at a load.
The save area is ordinary frame slots, asked of slot_new like any other, which is what keeps
every claim of objects.md § 4 true on the optimized road too — the frame record is
still unconditional, the epilogue is still add sp / ldp / ret, and a stack walker or a
debugger finds a saved register at a fixed [sp, #k].
The walker asks for the count through i64 walk_reg_count(), which answers 0 whenever
walk_opt() is 0: an optimizer that is off asks nothing of the machine at all. A machine that
answers a non-zero count and leaves one of the other five null is a bug in that machine, and it is
said so: mc: machine answers MTASK_REG_COUNT but leaves a version 5 slot empty.
Two obligations come with the version, and both were found by running the code.
- Read a depth through
val_reg(d, scratch), never asREG_BASE + d. Since version 5 the value of a depth may live in an allocatable register rather than in the depth's own — that is whatMTASK_REG_LOADrecords, and it emits no instruction at all — soval_regis the only function that knows where it is. A derived machine that computes the register itself reads a stalex9.lib/machine_arm64_float.mcdid exactly that in its ownfa_save_liveand in its stack-argument path and was corrected with this version;lib/machine_x86_64_float.mchad the same three (itsfx_save_live, itsfx_restore_liveand its stack-argument path) andlib/i128.mc'sxw_calla fourth, all corrected when the x86-64 allocator landed (D2). Nothing else inlib/orexamples/computes a depth register by hand. - A machine that overrides
MTASK_PARAMMUST overrideMTASK_PARAM_REG. The bundleda64_param_regreads argumentiout ofx_i, because on an integer-only ABI the source index is the register number. On a float ABI it is not —fa_paramwalks its own NGRN/NSRN/stack counters, so a float in position 0 and an integer in position 1 makeiand the register disagree from the second parameter on. Inheriting the bundled slot put the wrong register in the local, measured:tests/float/019-putf64.mcprinted0. -1.3 0.4where it prints3.500 -1.25 0.lib/machine_arm64_float.mcandlib/i128.mc(whose own counter skips a register for the even-pair rule) each carry a*_param_regof their own for this reason, and so do their x86-64 halves,fx_param_regandxw_param_reg— on Win64 the float and integer argument slots are SHARED, soiand the register disagree there for a different reason again.
What the arm64 allocator does #
a64_reg_count() answers 10: x19..x28, with r meaning x(19 + r). x18 is never
allocated. The saves and restores are plain str/ldr into frame slots, so the allocator adds no
instruction form at all — measured, the set of distinct mnemonics in src/mc.mc's object is the
same on both roads, and the 2284 distinct instructions of the optimized object re-assemble byte for
byte under llvm-mc -triple=aarch64-apple-macos.
Two things make an allocated local usable as an operand directly, and both are private to the machine:
- the alias table.
MTASK_REG_LOAD(d, r)emits nothing and recordsdalias[d] = 19 + r;val_reg(d, …)answers that register, and every task that produces a NEW value atdclears the entry (one line indst_done, which every value-producing task already ends with).save_liveandrestore_liveskip an aliased depth entirely: its value is in a register the callee is required to preserve, so the frame round trip around a call is dead work. Aliases are cleared at everyMTASK_LABEL— a control-flow merge, where a value carried in an alias could have arrived by another path — and after everyMTASK_REG_STORE. - the store rewrite.
MTASK_REG_STORE(ty, 0, r)looks at the instruction just emitted; if it WROTE the depth's own register (a whitelist: the eleven three-operand forms,msub,mvn,neg,cset, the three immediate forms,mov/mov w, the three sign extensions and the load half of the memory table — never a store, whoserdis its SOURCE, and nevermovz/movk, which come in chains of up to four writing the same register), its destination is retargeted at the local's register and nomovis emitted. That is what makesx = x * C + Kend inadd x19, x9, x10andi = i + 1inadd x21, x21, x10.
Two more emission-time peepholes fuse a comparison and the branch that reads it. Neither is a slot
and neither is a pass: the machine already sees the instruction it just emitted, and each pattern is
"the instruction I am about to emit consumes the one just emitted and nothing else can". Both are
guarded by walk_opt() != 0 and by adjacency — an I_LABEL between would BE the last instruction
and is not an I_CSET, so the guard fails and the plain lowering stands.
- P1 — the branch fusion.
a64_jz/a64_jnz: when the last instruction is acset rd, ccwhoserdis the depth's own register, drop it (I_NOPgenerates no word) and branch on the flags the precedingcmpleft.JNZ(branch when the boolean is true) emitsb.cc;JZ(branch when it is false) emitsb.<cc ^ 1>— the negation of everyC_*pair (EQ/NE, GE/LT, GT/LE) is exactlycc ^ 1, the same inversion thecsetencoder itself applies. Thecset's register is dead after a branch at every walker site (gen_ifat depth 0,gen_logicoverwrites the depth on both paths), so no liveness beyond adjacency is needed. - P2 — the LNOT flip.
a64_unforMUN_LNOT: when the last instruction is acset rd, ccon the depth's register, flip its condition in place (cset rd, cc ^ 1) and emit nothing — instead of the plaincmp #0; cset eqa logical NOT would otherwise cost.
b.<cond> (I_BCOND) is the one instruction form the peephole adds to the sweep; it was already
encoded, dumped and in lib/backend_arm64.mc and simply had no emitter before. Together P1 and P2
turn the prelude's five-instruction while condition — cmp; cset lt; cmp #0; cset eq; cbz — into
cmp; b.lt plus the loop's own unconditional branch.
What the x86-64 allocators do (step D2) #
x86_reg_count() answers 5 on BOTH machines: rbx, r12, r13, r14, r15 — the five that
System V and Win64 agree are the callee's to preserve. rdi and rsi are callee-saved on Win64 and
argument registers 1 and 2 on System V, so a count that depended on which prologue last ran would be
stale for the first function of a unit; two registers are not worth a second code path (D11), and
the assertion in scripts/check-surface.sh is that x86_64-win's optimized lowering of src/mc.mc
does not NAME them at all. The register partition does not move: depths stay in r8..r11 and
scratch stays rax/rcx/rdx. r is an index and x86_allocreg_at(r) is the map — arithmetic
rather than a base, because the set is not contiguous.
The saves and restores are mov [rbp - k], rbx / mov rbx, [rbp - k] into ordinary frame slots, so
they add no instruction form: leave still ends every function, MTASK_FRAME_FIX still patches
only the prologue's sub rsp, and a stack walker's view does not change.
The alias table is the same mechanism, in the same place (xalias_at, the second half of the
xdslot array), with one difference that is the architecture and not a choice:
- the store rewrite is a much smaller whitelist. x86 is two-operand, so
add rd, rnmeansrd = rd + rn: retargeting its destination at the local's register would add to a register that does not hold the old value. Soadd/sub/and/or/xor/imul/shl/shr/sar/neg/notare all out, every store is out (itsrdis its SOURCE),setccis out (it writes one byte),call r'srdis the target andidiv/div's is the divisor. What is left is every form that only WRITES: the threemovs, the twoleas, the five registermovzx/movsxand the seven loads — which is what a local's initialiser, a constant, a global read and a comparison's boolean all end in.x = x + 1therefore costs onemovon x86-64 where it costs none on AArch64. - far more tasks have to materialise an aliased depth first, for the same reason:
x86_own(d)isa64_own's counterpart and is called byMTASK_BIN(whose destination IS its left operand),MTASK_UN,MTASK_BOOLandMTASK_CAST.
The two peepholes are the same two patterns over the pair x86 needs for a boolean, setcc rd, cc
followed by movzx rd, rd (setcc writes one byte, so the movzx is never separable from it):
- P1 —
x86_jz/x86_jnz: when the last two instructions are that pair on the depth's own register, drop both (X_NOPgenerates no bytes and no dump line) and branch on the flags thecmpleft, instead of thetest rd, rd; jccthe plain road emits.JNZusescc,JZusescc ^ 1— x86 condition codes are defined in negation pairs (the low bit oftttn), so one xor invertse/ne,l/geandle/galike. - P2 —
x86_unforMUN_LNOT: the same pair, condition flipped in place, nothing emitted.
jcc rel32 is NOT a new form, which is a correction to docs/specs/M49.md § 4.7: x86_jz has
always ended in X_JCC, so the descriptor row and the encoder were already there and already swept.
What the peephole adds is four new CONDITION VALUES of that existing form — jl (0f 8c), jge
(0f 8d), jle (0f 8e), jg (0f 8f) beside the je/jne the plain road already emits — and
the mnemonic set of the optimized object is the plain one's plus exactly those four, on System V and
on Win64 alike, with 0 mismatches in the llvm-mc sweep.
Loop-invariant values are allocated the same way, as pseudo-locals. A register a taken local
did not want goes to a value that a loop rebuilds every iteration, and there are two kinds: an
N_INT whose immediate costs two or more instructions (any 16-bit part above the low one non-zero
-- one movz plus up to three movk) and whose type is an integer of the word width or less,
and the address of a GLOBAL, which is an adrp + add pair.
That type condition is TK_INT or TK_SINT with type_width <= 8 — exactly what the walker asks
of a local, and for exactly the same reason. A taught literal is an N_INT too: <float>'s f64
literal is an N_INT whose type is TK_FLOAT and whose val is the IEEE bit pattern, and that
value belongs in a float register, so putting it in x19..x28 and handing the depth an integer
alias makes a derived machine read a register that never held it (measured: 0.0 + 2.5 three times
came out 0x56e6e8cc4576e6a1 instead of 0x401e000000000000 before the condition existed;
tests/float/028-lit-in-loop.mc is the gate). A GLOBAL's address needs no such condition: an
array's decay is a uptr whatever its elements are. The walker records each DISTINCT value once, in first-occurrence order -- constants by value,
globals by their index in the global table -- scores it with the same 8^depth weight and the same
threshold of 3, and takes what the locals left. A taken one is materialised ONCE, at function entry,
right after the parameters (MTASK_CONST or MTASK_SYM_ADDR at depth 0, then
MTASK_REG_STORE(TY_I64, 0, r)), and every use of that value anywhere in the function -- inside the
loop and outside it -- becomes an MTASK_REG_LOAD, which is to say an alias-table entry and no
instruction at all.
Entry materialisation runs even when the loop is never entered. Two to four instructions cannot fault: a symbol address is a relocation, not a load, and a constant is an immediate.
A machine sees nothing new: the tasks are the ones it already fills, at the depth it already
handles, and a machine that answers 0 to MTASK_REG_COUNT never reaches the code. On the plain
road the candidate list is empty and the three loops that walk it have zero iterations.
Not hoisted, on record: a STRING literal's address and a FUNCTION's address. Both would have to be
keyed by a symbol index and both CREATE their symbol on first use (str_sym also appends to
__cstring), so hoisting one would move symbol creation and literal placement inside a function for
a gain the measurement below never needed. A global's symbol already exists -- gen_globals runs
before the first gen_func.
Measured on bench/mc/bench.mc (Apple M4, --exe --opt=1, best of 11 interleaved with the
reference): the whole workload is 0.54 s against 0.79 s on the plain road and 0.42 s for
clang -O2 — 1.30x. Per phase, mix 0.45 -> 0.22 s, primes 0.21 -> 0.19 s, fib 0.13 s
unchanged (its cost is the call count, which nothing here touches). --dump-asm of the workload
goes from 370 lines on the plain road to 319, and the mix loop body from 50 instructions per
iteration to 18 — no memory traffic, and no constant costing more than the single movz a shift
count is worth. The last two steps' own
shares are each at the edge of this host's 10 ms timer -- the branch peephole moved mix 0.22 ->
0.21 and primes 0.20 -> 0.19, hoisting moved mix 0.22 -> 0.216, primes 0.206 -> 0.198 and the
whole workload 0.551 -> 0.542 -- because these loops are latency-bound on the dependent
mul/add chain once the frame round trip the allocator removed is gone. src/mc.mc's own
__text is 480 420 B with --opt=1 against 535 720 B plain, -10.3%: two instructions per
allocated register buy back more than they cost.
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
MCOND_ULT MCOND_ULE MCOND_UGT MCOND_UGE
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). Comparison is the same shape and, since contract version 6, the same rule:
ten codes, and cmp_unsigned in src/gen_walk.mc is the whole of it —
unsigned iff neither operand is a signed type, both are TK_INT,
and one of them is eight bytes wide
— so u64 and uptr compare as addresses and everything else keeps the signed code. EQ and NE
have no signed form to be the other of, which is why there are ten and not twelve. Three things
follow, and each is deliberate:
- A narrow unsigned operand keeps the signed code. A
u8,u16oru32is zero-filled above its width by the slot invariant (§ 5), so both values are far below 2^63 and a signed 64-bit comparison of them is already the unsigned one. This is what let every object in the corpus come out byte for byte what it was. - A comparison with an
i64on either side stays signed, which is NOT C's rule. C makes the whole expression unsigned and turns a negativei64into a huge number; here the signed side wins, so no program that works today changes meaning. A plain integer literal isi64(language.md § 3), so a constant with bit 63 set belongs in au64variable. - A float, a wide type and an opaque type are excluded by the kind test, so
<float>'sf64(eight bytes, not signed) and<i128>'su128keep receiving the six codes their ownMTASK_CMPknows.
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 (x19..x28 are the allocator's, M49) | rsi, rdi (System V argument registers, so only on Win64) | — |
allocatable (MTASK_REG_COUNT, M49) | 10: x19..x28 | 5: rbx, r12..r15 | the same five (D11) |
| 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. Two narrow operands reach a
machine with a SIGNED condition — the walker only picks an unsigned one when a side is eight bytes
wide (§ 3) — while u8/u16/u32 are themselves 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. A version 6 unsigned condition reaches it too, at eight bytes, and avr_cmp answers it with
brlo/brsh — the C flag the cp/cpc chain leaves — where the signed twin takes brlt/brge.
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 |
i64 walk_opt() | the optimization level in effect, 0 or 1 (M49). A machine reads it to gate anything it only does when the developer asked — the peephole, for instance. void set_walk_opt(i64 n) is the writer, and only the CLI and the driver call it, before gen_lower |
i64 walk_reg_count() | how many registers the machine in effect offers the allocator: MTASK_REG_COUNT's answer, or 0 when the slot is null or when walk_opt() is 0 |
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.
Where a callp's answer comes from. MTASK_CALLP(d, na) reads walk_ret_type() exactly as
MTASK_CALL does, and an indirect call has no callee to read a signature from — so the answer is
what the cast around the call declared: (f64) callp(p, x) types the call node f64 and the
handler is told f64 (docs/reference/language.md § 7). With no cast the node is i64 and the
handler is told i64, which is what every callp has been told since M10. A machine that reads
walk_ret_type() in MTASK_CALLP therefore needs no new slot and no new rule; before this
contract it was told i64 unconditionally and a float result was never moved out of d0/xmm0.
The narrow half follows the same rule: (i32) callp(...) extends at the call, the way M45 extends
a direct call, and walk_depth_type(d) holds i32 when the written cast's own MTASK_CAST runs.
Take the result before you restore. A machine that saves its live depths around a call has to
move the value out of the ABI's return register before it reloads them, because the two can be
the same register: on Win64 <float> puts the float depths in xmm0..xmm5 (xmm6..xmm15 are
callee-saved), so depth 0's register is xmm0, and restoring first overwrites what the callee
returned. AArch64 (v16..v23 against d0), SysV (xmm8..xmm13 against xmm0) and every integer
half (rax/x0 is nobody's depth) do not show it, so one order has to serve them all — result,
then restore, in MTASK_CALL and MTASK_CALLP alike.
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.
The opcode bands #
A derived machine that adds instructions the bundled one does not have gives them opcode numbers
of their own, above every I_* / X_* the core uses (52 today). Those numbers are shared by
every machine derived from the same table, so two modules that pick the same base cannot be
loaded into one compiler — and because derivation is ordered and last-wins, which of the two loses
depends on the registration order.
Two rules, and a registry. Both are obligations of a derived machine:
- Bound the band at both ends. Every slot that dispatches on
ins_op— the encoder, the dump,MTASK_INS_SIZE,MTASK_RELOC_KIND,MTASK_RELOC_OFF, a store-rewrite whitelist — asksop >= BASE && op < MAXOPand delegates to the pristine table otherwise.op >= BASEalone claims every opcode ANOTHER module put above it. Write the test once, as one predicate the slots share, so they cannot disagree about where the band ends. - Take a free base from the registry below, one band per module per architecture, and record it there in the pull request that adds it.
| band | arm64 | x86-64 |
|---|---|---|
| 0..99 | the bundled machine (I_*, 0..52) | the bundled machine (X_*, 0..52) |
| 100..199 | <float> — lib/machine_arm64_float.mc, 100..141 | <float> — lib/machine_x86_64_float.mc, 100..133 |
| 200..299 | <i128>/<u128> — lib/i128.mc, 200..209 | <i128>/<u128> — lib/i128.mc, 200..203 |
| 300..399 | <f16> — lib/f16.mc, 300..303 | — |
| 400..499 | — | examples/avx — examples/avx/avx.mc, 400..403 |
| 500.. | free | free |
A module-private RELOCATION kind is a separate space and has its own convention
(examples/kernel/machine_riscv64.mc uses 32 and 33).
Seen from the bundled side, the rule has a second half: an opcode no machine claims is
refused. src/machine_arm64.mc and src/machine_x86_64.mc check op < I_COUNT / op < X_COUNT
and die with
mc: opcode 303 is not x86-64's: no machine claims it
naming the opcode and the table. It is the one message either of them has for a band whose owner is
not in the compiler that is running — which happens when a module's intrinsic registrations
outlive its machine: <f16> drives arm64 alone, so an f16 program compiled for x86-64 through
--backend= or --machine= (both read after user_init, so a module cannot see them) reaches
the x86 table with HI_* in hand. On arm64 the tables are searched and the old answer was
instruction with no dump / instruction with no encoder, which named neither; on x86-64
x86_desc and x86_name are indexed by the opcode, so the encoder read past the end of the
array and emitted whatever the next rows happened to say — a program that segfaulted with no
diagnostic anywhere. The guard sits in x86_d and x86_name_at, the two accessors every reader
(x86_form, x86_put for MTASK_ENCODE and MTASK_INS_SIZE, x86_dump) goes through, and at
the head of a64_ins_size, encode and dump_ins.
tests/wide/035-coexist.mc is the gate: <float>, <f16> and <i128>/<u128> in one compiler,
built in both registration orders (lib/mc_float_wide.mc, lib/mc_wide_float.mc), producing the
same object and the same output.
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.