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.mc is the target-independent walker; src/machine_arm64.mc (M17 step A) and src/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 -- and machine(name, tab) in src/hooks.mc is the seam. Since M39 there is a fourth, and it is not in the compiler: examples/kernel/machine_riscv64.mc registers riscv64 from a module under examples/, 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 how lib/machine_arm64_float.mc answers fadd where the built-in answers add, and how a module derives a machine with machine_tab / machine_slot; #machine was 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 outside src/ 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 rel32 carries 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) and walk_ret_type() (§ 3), machine_tab/machine_slot as the way to derive a table, and the three allocator functions § 3 publishes by name. The float tasks the old § 3 specified — thirteen mf_* 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). #machine was 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 ty a ty-carrying slot receives may be an id the registry owns, of width 1, 2, 4 or 8 and of kind TK_INT or TK_SINT, and a machine owes extension by kind — zero above the width for a TK_INT, the sign for a TK_SINT — in its loads and its MTASK_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: its MTASK_CAST(i32) is a no-op and its MTASK_LOCAL_LOAD(i32) reads eight bytes out of a four-byte slot. The rule, stated once: dispatch on type_width(ty) and type_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 its user_init, which is what examples/avr does.

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:


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.

slotsignaturemeaning
MTASK_PROLOGUEvoid f()open the frame: the frame record, and a reserve whose size is not known yet
MTASK_PARAMvoid 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_EPILOGUEvoid f()release the frame and return
MTASK_FRAME_FIXvoid f(i64 frame)the frame size, known only after the whole body
MTASK_CONSTvoid f(i64 d, i64 imm)materialise a constant at depth d
MTASK_BINvoid f(i64 op, i64 d, i64 d2)MOP_* of depths d and d2, result at d
MTASK_CMPvoid f(i64 cond, i64 d, i64 d2)MCOND_*, result 0/1 at d
MTASK_UNvoid f(i64 op, i64 d)MUN_* in place
MTASK_BOOLvoid f(i64 d)d = (d != 0) — the normalisation &&/|| needs
MTASK_CASTvoid 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_LOADvoid 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_STOREvoid f(i64 ty, i64 d)[d] = d + 1, truncated to the width — the same instruction for both kinds (st8..st64)
MTASK_LOCAL_ADDRvoid f(i64 d, i64 off)the address of a frame slot
MTASK_LOCAL_LOADvoid f(i64 ty, i64 d, i64 off)read a frame slot into d, extended by the type's kind
MTASK_LOCAL_STOREvoid f(i64 ty, i64 d, i64 off)write depth d into a frame slot
MTASK_SYM_ADDRvoid f(i64 d, i64 sym)a symbol's address — a global, a string literal, or a function taken with &
MTASK_GLOBAL_LOADvoid f(i64 ty, i64 d, i64 sym)read the global at sym into d, extended by the type's kind
MTASK_GLOBAL_STOREvoid f(i64 ty, i64 d, i64 sym)write depth d into the global at sym
MTASK_CALLvoid f(i64 d, i64 nargs, i64 sym)a direct call; the arguments are depths d .. d+nargs-1, the result lands at d
MTASK_CALLPvoid f(i64 d, i64 nargs)an indirect call; the pointer is argument 0, at depth d
MTASK_RETvoid f(i64 d)depth d into the return position (the walker emits the jump to the epilogue itself)
MTASK_JUMPvoid f(i64 l)unconditional jump
MTASK_JZ · MTASK_JNZvoid f(i64 d, i64 l)jump when depth d is zero / non-zero
MTASK_LABELvoid f(i64 l)place label l
MTASK_WORDvoid f(i64 w)exactly one raw word — what emit() and #opcode reach
MTASK_INS_SIZEi64 f(uptr e)how many bytes instruction e will occupy; 0 for what emits nothing
MTASK_ENCODEvoid f(uptr e, i64 pc, uptr lab, uptr buf)write instruction e into the section buffer buf
MTASK_RELOC_KINDi64 f(uptr e)the relocation this instruction always carries (R_*), or -1
MTASK_RELOC_OFFi64 f(uptr e)how many bytes into the instruction that relocation's field starts
MTASK_DUMPvoid 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 ownsthe 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 scopingnothing about control flow but the encoding
the Ins buffer, ins_add and e0/e2/e3/ei/el/elr/emwhich I_* goes in it
I_LABEL (opcode 0, reserved) and the pending-reloc() listevery other opcode
sections, globals, string literals, symbols, reloc_addMTASK_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:

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.

AArch64x86-64 System Vx86-64 Win64
machine namearm64x86_64x86_64-win
depth registersx9..x15 (0..6)r8..r11 (0..3)the same — volatile in both ABIs
why thosecaller-saved, not argument registersthe same rule leaves exactly four
scratchx16, x17, x8rax (S1), rcx (S2), rdxthe same
why threeidiv writes rdx, div needs it zeroed, shifts count in cl
locals[sp, #k], fixed up at the end[rbp - k], correct from the first instructionthe same
framestp x29, x30 + sub sppush rbp; mov rbp, rsp; sub rsp / leavethe same
argumentsx0..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 areathe bottom of the frame, sp never movespush, given back with add rspthe same, plus the shadow space
shadow spacenone32 bytes, reserved by the caller
callee-saved, never touchedx18..x28rbx, r12..r15those plus rsi, rdi
resultx0raxrax
callp pointerx16, blr x16rax, call raxthe same
instruction width4 bytes1..10 bytesthe same
x / 0, x % 0, INT64_MIN / -1sdiv/udiv: 0, x, INT64_MIN, no trapidiv/div: SIGFPE, the process diesthe same
relocationsBRANCH26 PAGE21 PAGEOFF12 UNSIGNEDR_X86_64_PLT32 PC32 64IMAGE_REL_AMD64_REL32 ADDR64
relocation offset01 (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:

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.

AArch64x86-64 System VRISC-V 64 (RV64IM)
machine namearm64x86_64riscv64
depth registersx9..x15 (0..6)r8..r11 (0..3)t3..t6 (0..3)
why thosecaller-saved, not argument registersthe same rule leaves exactly fourthe same rule again: s1..s11 are callee-saved and a0..a7 are arguments
scratchx16, x17, x8rax, rcx, rdxt0 (left/dst), t1 (right), t2 (addresses only)
why threeidiv writes rdx, shifts count in clt2 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
framestp x29, x30 + sub sppush rbp; mov rbp,rsp; sub rsp / leaveaddi sp,sp,-16; sd ra,8(sp); sd s0,0(sp); mv s0,sp + addi sp,sp,-F
epilogue patched?yes, the add spno, leaveno: mv sp, s0 releases any frame
argumentsx0..x7, then [sp], [sp+8], …rdi rsi rdx rcx r8 r9, then pusheda0..a7, then [sp], [sp+8], …
stack parameters[x29+16], [x29+24], …[rbp+16], [rbp+24], …[s0+16], [s0+24], …
outgoing areathe bottom of the frame, sp never movespush, given back with add rspthe bottom of the frame, sp never moves
callee-saved, never touchedx18..x28rbx, r12..r15s1..s11, gp, tp
resultx0raxa0
callp pointerx16, moved firstrax, moved firstt0, moved last — its source cannot be an argument register
instruction width4 bytes1..10 bytes4 bytes, except li, the frame reserve and the offset fallbacks
jump range, and who checks it128 MiB (b), br_off checks against the smallest field, 1 MiB±2 GiB (rel32), no check needed1 MiB (jal), rv_jal_off checks on the encode pass
x / 0, x % 0, INT64_MIN / -10, x, INT64_MIN, no trapSIGFPE, the process dies-1, x, INT64_MIN, no trap
relocationsBRANCH26 PAGE21 PAGEOFF12 UNSIGNEDR_X86_64_PLT32 PC32 64two module-private kinds, 32 and 33
relocation offset01 (call), 3 (lea [rip+d32])0
narrow load, TK_INT (M45)ldrb ldrh ldr wmovzx movzx mov r32lbu lhu lwu
narrow load, TK_SINT (M45)ldrsb ldrsh ldrswmovsx movsx movsxdlb lh lw
cast, TK_INT (M45)and #0xff and #0xffff mov wd, wnmovzx movzx mov r32,r32andi / slli+srli
cast, TK_SINT (M45)sxtb sxth sxtwmovsx movsx movsxd r64,r32slli+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.

AArch64RISC-V 64AVR (ATmega328P)
machine namearm64riscv64avr
register width64 bits64 bits8 bits, 32 of them
depth registersx9..x15 (0..6)t3..t6 (0..3)none: every depth is an 8-byte frame slot
whycaller-saved, not argument registersthe same ruleone 64-bit value costs eight registers; four depths is not reachable
accumulatorr16..r23 (ACC), r8..r15 (TMP)
scratchx16, x17, x8t0, t1, t2r0, 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
framestp x29, x30 + sub spaddi sp,sp,-16 + mv s0,sppush YH; push YL; in Y,SP + sbiw Y + out SP,Y
argumentsx0..x7, then the stacka0..a7, then the stackall 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 areathe bottom of the framethe bottom of the framethe bottom of the frame, and SP never moves in the body
resultx0a0r16..r23, zero-extended to 8 bytes by the callee
callee-saved, never touchedx18..x28s1..s11, gp, tpr2..r7
callp pointerx16, moved firstt0, moved lastZ, moved last, halved: icall takes a word address and &f is a byte address
instruction width4 bytes4 bytes2 bytes, except jmp/call/lds/sts, which are 4
variable-length Insnoli, the frame reserve, the offset fallbacksalmost every one: a task is a byte chain of w instructions
local displacement4095 (unsigned 12-bit)2047 (signed 12-bit), t2 past it63 (ldd Y+q, 6 bits), X + post-increment past it
jump range, and who checks it128 MiB (b), br_off1 MiB (jal), rv_jal_off±4 KiB (rjmp, signed 12-bit words), avr_rjmp_off
frame cap4095, the walker's4095, the walker's1024, the machine's: the part has 2 KiB of SRAM
x / 0, x % 00, x, no trap-1, x, no trap0, x, no trap — examples/avr/lib/rt_avr.mc decides, because the part has no divide
*, /, %one instruction eachone instruction eacha call into a helper the module ships, written in the language
relocationsBRANCH26 PAGE21 PAGEOFF12 UNSIGNEDtwo module-private kinds, 32 and 33two 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.

functionanswers
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:

functionanswers
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:

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:

functionvaluewhat uses it
walk_word()type_width(TY_UPTR), 8 by defaultthe 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 defaultthe 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.

Edit this page