Spec M40 -- the narrow word: examples/avr, a bare-metal ATmega328P image built by a taught compiler
Owner's need (2026-09-04): microcontrollers with no OS -- AVR (ATmega328P: 8-bit registers, 2 KiB
SRAM, 32 KiB flash, Harvard) and PIC -- where the output is a flash image and the developer adds the
target from the surface, as M39 proved for a 64-bit ISA. docs/specs/M39.md D8 approved only
this: a sweep that prices the word-size change (G10) before any code. This spec is that sweep.
Depends on M39 (the machine seam proved from examples/, the flat-image writer, the --compiler-only
build path) and on M24 landing first -- type_new(name, width, align, kind), walk_depth_type/
walk_ret_type, slot_new(type_width(ty)), syntax_lit, intrinsic, machine_tab/machine_slot.
Line references are to main at 24da371.
What already exists #
- The registration path is complete and proved.
machine(name, tab)/machine_use(src/hooks.mc:384-407), 31 slots, contract v2 (src/gen_walk.mc:64-95);backend(name, &f)(src/hooks.mc:61);examples/kernel/machine_riscv64.mc(780 lines) andexamples/kernel/image.mc(246) are a full machine and a full image writer living underexamples/, withgit diff src/empty. M40 needs no new registration mechanism. - The ELF writer in
src/is ELF64 only --ELFCLASS64,EHDR_SIZE 64,SHDR_SIZE 64,EM_AARCH64/EM_X86_64(src/backend_elf.mc:44-51). An AVR module writes its own ELF32, exactly asexamples/kernel/image.mcwrote its own flat image. - The machine already owns its own limits.
docs/reference/machine.md:176-199: the walker keepsframe too largeat 4095 and jump range is the machine's to diagnose. A machine with a smaller reach pays for it itself (M39 G7,rv_jal_off). emit()is exactly 32 bits (src/gen_walk.mc:579-580) butMTASK_WORDis the machine's slot: what a word means in bytes is already the machine's decision.- Spill slots are lazy, not 64.
src/machine_arm64.mc:108allocatesslot_new(8)for a depth only when that depth actually spills, at most once. M39 G10 anddocs/specs/M24.md:283both say "MAXDEPTH 64 spill slots at 8 bytes is 512 bytes before the first local"; that is false as written -- 512 bytes is the worst case of a 64-deep expression, and an ordinary function pays 8 or 16. Correct the two documents. - The tools are here, measured on this host on 2026-09-04:
/opt/homebrew/bin/simavr(cores includeatmega328pandatmega2560;--helpaccepts "a.hexor an ELF file", ELF preferred),qemu-system-avr11.0.1 (-machine help:arduino-uno= ATmega328P,mega2560= ATmega2560,-biosaccepted),libsimavr.a+include/simavr/under/opt/homebrew/opt/simavr,avr-gcc9.5.0 /avr-as/avr-ld, andllvm-mc -triple=avr -mcpu=atmega328p -show-encoding(LLVM 22.1.8) which assemblesldi r16, 42to0a e2.
1. What "8 bytes per value" is, exactly #
Two piles. The first a narrow-word machine absorbs with zero core lines; the second is the core doing target-dependent arithmetic.
1a. Absorbed by the machine (the i128 pattern of M24, in reverse) #
| site | what it is | why a machine absorbs it |
|---|---|---|
src/gen_resolve.mc:409 | N_INT is TY_I64 | a literal is a 64-bit constant; MTASK_CONST(d, imm) lets the machine put it in eight registers or eight SRAM bytes |
src/gen_resolve.mc:375 | callp returns TY_I64 | a returned pointer is an i64 whose high 6 bytes are zero; costs a cast in the source, nothing in the core |
src/gen_resolve.mc:301-304 | ld64/st64 are typed TY_U64 | MTASK_LOAD/STORE carry ty; eight ld r,Z+ is a legal implementation of one ld64 |
MTASK_BIN/CMP/UN/BOOL | no width in the signature | the machine emits add + 7 adc; with M24's walk_depth_type(d) it can emit two instructions when the depth is u16 (see risk 3) |
MTASK_PARAM/CALL/RET | 8-byte argument slots | wholly the machine's ABI; M38's stack-parameter rule is a formula the machine writes |
src/gen_walk.mc:892 frame align 16 | 16-byte stack alignment | the machine chooses; nothing in the core reads it back |
src/gen_walk.mc:893 frame cap 4095 | AArch64's unsigned 12-bit | 4095 > SRAM; the machine diagnoses its own smaller cap, as machine.md:176-199 already requires |
src/machine_arm64.mc:108 spill slot | slot_new(8) per spilled depth | private to the machine; it may ask for 2 |
src/gen_walk.mc:579-580 emit() 32-bit | one raw word | MTASK_WORD decides the byte count: 2 bytes below 0x10000, 4 above (a convention the module states) |
the 264 * 8 sites in src/ and the 538 ld64/st64 there | the compiler's own records | src/*.mc is never compiled for AVR. mc cross-compiles; it does not self-host on a microcontroller. These are irrelevant to the word size of a target and their inclusion is what inflated G10 to "~400 lines". |
1b. The core doing target-dependent arithmetic #
| # | site | today | what a 2-byte uptr needs |
|---|---|---|---|
| C1 | src/ast.mc:212-218 | type_width default 8 | M24's M1 already makes this a registry read; a machine-declared width is the new part |
| C2 | src/gen_walk.mc:693, :881 | slot_new(8) for a scalar local and a parameter | M24's M5 already makes both slot_new(type_width(ty)) -- the 2 lines M24 pays |
| C3 | src/gen_walk.mc:321-324 | slot_new rounds (size + 7) & ~7 | the granule is the word; on a 2-byte word it wastes 4x and misaligns nothing, but it must become machine-declared, not a global constant (~3 lines) |
| C4 | src/gen_walk.mc:360-361, :689, :892 | zerofill, local arrays and the frame rounded to 16 | 16 bytes of a 2 KiB SRAM per array; machine-declared alignment (~3 lines) |
| C5 | src/gen_walk.mc:374-376 | a string in a uptr[] initializer writes buf_u64(b, 0) + R_UNSIGNED at length 3 (8 bytes) | a 2-byte pointer initializer needs a 2-byte write and a 2-byte relocation kind (~5 lines, or a machine-declared pointer reloc) |
| C6 | src/gen_walk.mc:924-933 + glob_place | global size and element width from type_width | follows C1 for free; width == 2 already takes the buf_u16 branch (:381) |
| C7 | src/parse.mc:1047, :2011 | nel * type_width(ty) > 4095, nel > (1<<30)/type_width(ty) | follows C1 for free -- and this is the semantic trap (§ 2B) |
| C8 | docs/reference/language.md:69 | "uptr | 8" | becomes "8, or what the machine declares" |
| C9 | lib/*.mc, examples/*/lib | 47 #define record offsets, all multiples of 8; lib/sys_windows_host.mc:91 PI_HTHREAD 8 is the shape | 0 lines if the AVR example ships its own layer, as examples/kernel/lib/sys_bare.mc did. These files are never compiled for AVR. |
| C10 | tests/*.mc | 024-arena's u8 heap[4096] | not ported. M39's out-of-scope already decided the suite does not run on a bare target. |
The honest total for a machine-declared word: C1+C3+C4+C5 = about 15 core lines on top of M24's M1/M5, not 400. G10's "~400" counted the compiler's own source. The expensive part of B is not lines; it is C7 and § 2B's trap.
2. The three designs, priced #
A -- the word stays 8 bytes; the machine widens #
Every mc value is 64 bits: eight AVR registers or eight SRAM bytes. uptr is 8 bytes of which the
low 2 are meaningful. Zero lines in src/; zero lines even in M24.
Register budget: 32 GPRs, minus Y (r28:29, frame), Z (r30:31, addressing), X (r26:27, second
address), r0/r1 conventional -- leaves 24, i.e. three depths of eight registers, and only
r16..r25 take ldi/subi/sbci immediates. Realistically depths 0..1 in r8..r23, everything deeper
spills through slot_new(8).
Measured instruction cost (AVR instructions are 2 bytes; jmp/call/lds/sts are 4):
| operation | sequence | bytes |
|---|---|---|
MTASK_CONST 64-bit | 8 x ldi (high regs) or 8 x ldi+mov | 16 / 32 |
| add, sub, and, or, xor | add + 7 x adc (etc.) | 16 |
| neg | 8 x com, subi, 7 x sbci | ~20 |
| shift by a variable | 8 x lsl/rol inside a count loop | ~24 + N x ~50 cycles |
| mul / div / mod 64-bit | module-shipped helper, call | 4 at the site, 150-250 / 120-200 once |
| local load/store, offset <= 63 | 8 x ldd Y+q / std Y+q | 16 |
| local load/store, offset > 63 | movw Z,Y + subi/sbci + 8 x ldd Z+q | 20 |
ld64(p) / st64(p,v) | movw Z,rlo + 8 x ld r,Z+ | 18 |
| prologue / epilogue | push Y, in/out SPL:SPH with cli/sei, frame reserve | ~28 / ~20 |
| direct call | call + 8 bytes moved per argument | 4 + 16/arg |
Estimated flash for the proof program (blink + UART ok + one timer ISR): vector table 104, _start
(#opcode, hand-written 8-bit) ~60, 64-bit helper routines ~250, uart_init+putc+puts ~450,
main/blink/delay ~450, ISR with a ~24-register save/restore ~200 -- roughly 1.5 KiB of 32 KiB.
SRAM: a five-frame call chain with four locals each is 5 x (32 + 8) = 200 bytes of 2048, plus the
string literals copied out of flash. Flash and SRAM are both fine. Design A is viable for the
proof. What it is not is defensible for a real firmware: a delay loop iteration is ~50 cycles
instead of ~4, and an ISR pays ~100 cycles of entry latency because eight registers per live depth
must be pushed.
Cost: 0 lines anywhere. Risk: none to the rest of the repository, by construction.
B -- the type widths become machine-declared #
uptr 2 bytes, i64 still 8 (see below). Consumers: exactly C1..C8 above, ~15 core lines on top of
M24's M1/M5. What it buys: a uptr local is 2 bytes instead of 8; a pointer array is 4x smaller; a
frame with four pointers is 8 bytes instead of 32.
The semantic trap M24 named, stated precisely. With type_width(TY_UPTR) machine-declared,
src/parse.mc:1047 and :2011 are parse-time diagnostics whose answer depends on --machine=:
uptr t[1000] is 8000 bytes and refused as local array too large on arm64, and 2000 bytes and
accepted on AVR. sizeof does not exist in this language, so a source cannot even ask; and every
hand-written record offset (#define TODO_TITLE 8) silently means a different field. The language
today has exactly one width story and docs/reference/language.md:69 states it as a fact. B ends
that. The mitigation, if B is taken: with M24's intrinsic() a module registers ldw/stw
("one word") at zero core lines, and portable sources use those instead of ld64/st64 -- but
#define offsets still have to be written as N * W, which no mechanism can fix.
Should i64 narrow too? No. The type is named i64; fold_binary folds in 64 bits at parse
time (src/parse.mc:958-969), docs/reference/language.md § 2 promises 64-bit semantics, and a
2-byte i64 would make constant folding disagree with the machine. The escape hatch is the opposite
one: write AVR code in u8/u16/uptr and let i64 be the expensive type -- which is only cheap
if u16 + u16 compiles to two instructions, and that is M24's walk_depth_type, already bought,
zero further core lines (with the divergence in risk 3).
C -- uptr stays 8 in the language, 2 in a frame slot (MTASK_SLOT_WIDTH) #
Not coherent. It reduces to A. Three reasons, each checkable:
ld64/st64are typedTY_U64, notTY_UPTR(src/gen_resolve.mc:301-304).st64(q, p)writes eight bytes whateverp's declared type is. A pointer that occupies 2 bytes in a frame and 8 bytes when written throughst64has two sizes at once.glob_placesizes a global fromtype_width(src/gen_walk.mc:924-933), souptr tbl[4]would be 32 bytes in__datawith 8-byte elements whileuptr pon the frame is 2.&pfollowed byld64then reads four bytes of the neighbouring slot.- Making
MTASK_LOAD/STOREnarrowTY_U64to 2 bytes as well fixes 1 and 2 by makingi64a lie -- at which point the language is no longer the one the tests and the docs describe.
The coherent version of C -- "the language says 8, the machine stores 8 bytes of which two matter" -- is design A. Say so and stop.
3. The AVR facts a module must handle under any design #
- Harvard. Code is in flash, data in SRAM;
ld/streach SRAM only,lpmreaches flash. mc has one flat address space and no notion of a second one. Decision: the image writer lays__TEXT,__cstringand__DATA,__datacontiguously in flash as an LMA blob and_startcopies the whole blob to SRAM, so everyld8(p)in the language keeps working with no source discipline. Cost: literals occupy SRAM. The alternative (anintrinsic("lpm8", ...)from M24 plus a flash/SRAM discipline in the source) is cheaper in SRAM and asks the programmer to track two address spaces; it is out of scope for the proof and named in the README as the next step. ldd/stddisplacement is 6 bits (q = 0..63) and only offYorZ. Frames past 63 bytes need a pointer bump (movw Z,Y+subi/sbci Z) -- the same obligation RISC-V took at 2047 (M39 G7), which is what makesMTASK_INS_SIZE"run the real encoder over a scratch buffer" mandatory here too.adiw/sbiwonly reach 63 and only on r24/X/Y/Z.- Branch reach.
rjmp/rcallare 2 bytes, +/-2K words;jmp/callare 4 bytes with a 22-bit word address and exist on ATmega328P (not on every AVR).brne/breqreach only +/-64 words, soMTASK_JZ/JNZemit the inverted pair (brne .+2; rjmp Lor.. jmp L), the shape M39 used. Out of range is a diagnostic, never a masked displacement (machine.md:190-199). - The interrupt vector table lives at flash address 0, 4 bytes per vector on ATmega328P (a
jmp), 26 vectors = 104 bytes. The image writer synthesizes it, entry 0 =jmp _start, every other entry =jmp __bad_isrunless the program defines a symbol the writer looks up by name (_vector_<N>) -- the same licenceexamples/kernel/image.mctakes when it synthesizes the reset stub, andsrc/backend_exe.mcwhen it fabricates__stubs. The vector numbers come from the datasheet and the acceptance item is that the synthesized table matchesavr-gcc -mmcu=atmega328pdisassembled, not that this spec got them right from memory. sei/cli/reti/sleepare single 2-byte#opcodewords (sei= 0x9478,cli= 0x94F8,reti= 0x9518,sleep= 0x9588) in the fixed-register stylelib/sys_svc.mcandlib/sys_linux.mcalready use.- An ISR is not a function the walker can emit. It must save
SREGand every register its body touches and end inreti, notret. Same shape asexamples/kernel/trap.mc: an#opcode-only leaf that pushes, calls an ordinary handler, pops and executes a barereti, with the walker's unconditional epilogue emitting a deadretbehind it. Under design A the save set is ~24 registers. - The stack pointer is an I/O register pair, SPL at data 0x5D / I/O 0x3D and SPH at 0x5E / 0x3E,
reachable with
in/out._startsets it to RAMEND (0x08FF on ATmega328P) before anything else.SREGis 0x5F. - The UART on ATmega328P is above the
in/outwindow (I/O 0x00-0x3F maps to data 0x20-0x5F), soUCSR0A0xC0,UCSR0B0xC1,UCSR0C0xC2,UBRR0L0xC4,UBRR0H0xC5,UDR00xC6 are reached withlds/sts(4 bytes each) -- which in the language isld8/st8at a constant address, no directive.PORTB0x25 /DDRB0x24 for the blink are inside the window. - Little-endian instruction words, low byte first, both halves of a 4-byte
jmp.
4. The image format, and the oracle #
| simavr (primary) | qemu-system-avr (second) | |
|---|---|---|
| accepts | .hex or ELF (--help; ELF preferred) | ELF, Intel HEX, or raw binary via -bios |
| invocation | simavr --mcu atmega328p firmware.elf | qemu-system-avr -machine arduino-uno -bios image.elf -nographic |
| stdout | only through the .mmcu console register, see below -- the standalone binary does not pipe UART0 to stdout (the uart_pty part lives in libsimavrparts, not in run_avr) | UART0 is wired to serial0, which -nographic puts on stdio |
| exit | sleep with interrupts off ends the run: the binary contains the string simavr: sleeping with interrupts off, quitting gracefully; also SIMAVR_CMD_EXIT_CODE_0 / _1 written to the command register | no exit device; the run is ended by the watchdog |
Recommendation: the module writes ELF32 EM_AVR (83), and simavr is the primary oracle. ELF is
what both accept, and only ELF carries the .mmcu section that gives simavr a stdout at all. That
section is a byte-exact blob the image writer synthesizes; the tags and record layouts are in
/opt/homebrew/opt/simavr/include/simavr/avr/avr_mcu_section.h:47-116:
AVR_MMCU_TAG_NAME= 1,{u8 tag, u8 len=64, char[64]}->"atmega328p"AVR_MMCU_TAG_FREQUENCY= 2,{u8 tag, u8 len=4, u32 val}-> 16000000AVR_MMCU_TAG_SIMAVR_COMMAND= 10 andAVR_MMCU_TAG_SIMAVR_CONSOLE= 11, each{u8 tag, u8 len=2, void* what}with a 2-byte SRAM address; writes to the console address print a byte to stdout, andSIMAVR_CMD_EXIT_CODE_0= 4 /_1= 5 written to the command address end the run.
So the transcript channel is st8(SIMAVR_CONSOLE, c) under simavr and st8(UDR0, c) under QEMU --
the same putc behind one #define, which is the reason to keep both oracles rather than one.
Intel HEX is rejected as the format: it carries no .mmcu, so simavr would have no stdout, and it
buys nothing QEMU does not already get from ELF.
The exit code cannot carry the verdict here. M39's RISC-V oracle passes the guest's status
through (exit 42); AVR gives 0 or 1 at best. test.sh asserts stdout, with the exit code as a
coarse pass/fail, and a run ended by the watchdog reported as hung -- the same three-way outcome
M39 wrote, with the roles of the two channels swapped. The exact process status of
SIMAVR_CMD_EXIT_CODE_1 must be measured before test.sh depends on it, the way M39 measured
QEMU.
CI: Ubuntu has simavr (universe) and qemu-system-avr inside qemu-system-misc -- the same package
the baremetal-riscv64 job already installs. The job shape is .github/workflows/ci.yml:1056-1084
verbatim, one architecture over: macOS builds and uploads avr.elf, an baremetal-avr job on
ubuntu-latest downloads and runs it. Both versions go in the toolchain-facts step.
5. The recommended design and the scope of examples/avr #
Recommended: design A, and M40 ships zero lines in src/.
Reasons, in order: it is the only design whose inertness proof is structural rather than tested; the
proof program fits comfortably in flash and SRAM under it (§ 2A); design B's ~15 core lines are cheap
but its cost is C7, a source whose meaning depends on --machine=, and no line of code should be
written to buy that until a real AVR program has been shown to be impossible without it; and M24's
walk_depth_type -- already landing -- gives the machine narrow arithmetic on u8/u16 values,
which is the larger practical win and costs nothing further.
Scope of examples/avr, written down first as M39's out-of-scope demanded: a blink (PORTB toggle),
a UART/console ok, one timer ISR, and exit through simavr's sleep-with-interrupts-off. Nothing
else. Not a port of tests/.
PIC is excluded, and this is why, plainly. The frame model in src/gen_walk.mc is: one
contiguous byte-addressed frame handed out by slot_new, addressed by displacement off a frame
pointer, plus arbitrary recursion depth on a software stack. A PIC's data memory is banked --
an address is meaningful only together with a bank-select register, so a single displacement does not
name a slot -- and its call stack is hardware, 8 to 31 levels deep and not addressable, so the
return address cannot be pushed to the frame and recursion is bounded by silicon. There is no
lowering of MTASK_PROLOGUE/MTASK_LOCAL_LOAD that is both correct and cheap, and G10 bites harder
than on AVR because a PIC word is 8 bits with a 12/14-bit instruction. This is not a machine away;
it is a different frame model. docs/guide/97-a-new-architecture.md says so.
Files and estimated deltas #
| file | lines | what |
|---|---|---|
examples/avr/machine_avr.mc | ~900 | the 31 slots for ATmega328P: three depths of eight registers, Y-relative locals with the 63-byte bump, the 64-bit ALU sequences, MTASK_INS_SIZE over a scratch buffer, MTASK_WORD writing 2 or 4 bytes |
examples/avr/rt_avr.mc | ~220 | mul/div/mod/shift helpers, pushed by the machine as a second source (the sd_rt pattern) |
examples/avr/image_elf32.mc | ~420 | backend("avr-elf", ...): place, resolve, synthesize the 26-entry vector table and the .mmcu section, write ELF32 EM_AVR |
examples/avr/lib/sys_avr.mc | ~200 | _start (#opcode: SP, .data copy, .bss zero), putc behind one #define (console or UDR0), halt |
examples/avr/lib/isr.mc | ~90 | the #opcode-only ISR frame ending in reti |
examples/avr/main.mc | ~120 | blink, ok, one timer ISR, exit |
examples/avr/mc-avr.mc / mc.toml / test.sh / README.md | ~30 / ~50 / ~230 / ~180 | build, both oracles, prose |
Makefile, .github/workflows/ci.yml | +12 / +35 | check-avr guarded inside check:; the baremetal-avr job |
docs/reference/machine.md | +45 | the avr column, the division answer, the 63-byte displacement obligation, the MTASK_WORD byte-count convention |
docs/reference/language.md, docs/guide/97-a-new-architecture.md | +40 | the word-size story stated once, and the PIC exclusion |
docs/specs/M39.md, docs/specs/M24.md | +6/-6 | the lazy-spill correction (§ "What already exists") and G10 repriced |
src/, stage0/, lib/, tests/ | 0 | the milestone's headline, again |
Module total ~2400 lines under examples/.
Acceptance #
git diff --stat src/ stage0/ lib/ tests/is empty, andtests/golden/mc2.sha256is not rewritten. Under design A this is structural, not tested.- Inertness.
check-obj32/32 against the frozen seed,check-asm/check-ast/check-lexunchanged,check-surface32/32,test-exe32/32, the three cross legs green,bootstrapat a fixed point with an empty--dump-asmdiff betweenmc1andmc2. - It runs, under both oracles.
simavr --mcu atmega328p build/avr.elfprints the exact transcript (boot,blink,tickfrom the ISR,ok) and ends through sleep-with-interrupts-off;qemu-system-avr -machine arduino-uno -bios build/avr.elf -nographicprints the same transcript on UART0 and is ended by the watchdog, reported as such. Watchdog in POSIXsh-- notimeout, which does not exist on this macOS host (M39 risk 2). - The ELF is validated against a real toolchain, field by field:
avr-readelf -aandllvm-readobjagree with the same program built byavr-gcc -mmcu=atmega328p, and the synthesized vector table matchesavr-objdump -dof that reference. - The encoder is checked against an oracle, M17-step-B form: every distinct instruction the
machine emits while compiling
main.mcand a large synthetic source re-assembles byte-identically underllvm-mc -triple=avr -mcpu=atmega328p, and each relative displacement is checked againsttarget - address. - The frame edge case is tested, not assumed: a function whose frame crosses 63 bytes (the
ldd Y+qlimit) and one that crosses the machine's own SRAM-derived cap -- the first must be correct, the second must be a diagnostic, never a truncated offset. - The machine states its ABI and
test.shasserts it, ascheck-surface.shdoes with its nine AArch64 assertions: which registers the prologue may not clobber,r0/r1handling, and zero mentions of the ISR save set outsideisr.mc. - Determinism. Two consecutive builds give a byte-identical
avr.elf(cmp); no path, no date, no host string in the image. - The architecture is module-only.
build/mc1 --backend=avr-elf examples/avr/main.mcprintsunknown backend: avr-elfwith the registered list, and the default compiler refuses the source. - Docs.
make check-docsgreen;make check-avrself-skips without simavr and QEMU andmake checkstays green. - If the owner takes design B instead (D2 below): the golden is rewritten once, only after
the empty asm diff and
cmp build/mc2.o build/mc3.o, and a copy ofbuild/mc1from before the change must produce byte-identical objects for all 32tests/*.mc, forsrc/mc.mc, and through the taught compilers forexamples/api,lang,conc,desktopandkernel-- the M17 step A protocol. Goldens move once, and only once.
Risks #
- Design A is honest but embarrassing, and someone will try to fix it mid-milestone. The pull
will be "just make
uptr2 bytes while we are here". § 1b prices that at ~15 lines and § 2B prices its real cost; a core change is escalated to the architect as a priced mechanism, never slipped in. MTASK_INS_SIZEdisagreeing withMTASK_ENCODEby two bytes yields an image that boots and lands mid-instruction. AVR mixes 2- and 4-byte instructions and theY-bump fallback is variable-length, so the real encoder over a scratch buffer is mandatory (M39 risk 3).- Narrow arithmetic diverges from the 64-bit machines. If the machine uses
walk_depth_typeto computeu16 + u16in two instructions, thenu16 a = 0xFFFF; i64 x = a * a;gives0x0001on AVR and0xFFFE0001on arm64, becauseres_binarytypes the product from the left operand (src/gen_resolve.mc:398-404) and nothing re-widens it. This is a real, testable divergence. It must be either forbidden (compute in 64 bits always) or declared, documented indocs/reference/machine.mdand covered by a test that names it. Decided in D4. - The
.mmcusection is a struct layout copied from a C header. Get a field wrong and simavr silently has no console. Check it against a reference ELF built byavr-gccwithAVR_MCU_SIMAVR_CONSOLEbefore writing a line of the writer. - The two oracles disagree about stdout by construction (console register vs UART0). One
#defineinsys_avr.mcand twotest.shlegs; if it ever becomes two source files the example has lost the point. - The ISR save set is the riskiest 20 lines, exactly as
yieldwas in M39. Write it and simulate it first, before the machine is finished. - 2 KiB is small and the proof may not fit if the example grows. The fallback is real and
already available:
mega2560/atmega2560(8 KiB SRAM, 256 KiB flash) is supported by both simulators here. Falling back is a scope decision, not a redesign -- but it must be recorded, not drifted into. - A fifth taught compiler to keep alive. It is a leaf, and it is the second consumer of the machine seam, which is what makes it worth the cost.
Decisions (architect, 2026-09-04 -- every recommendation below is adopted) #
- D1 -- Design A, B or C? Recommend A: zero core lines, structural inertness, and a proof
that fits. C is incoherent for the three reasons in § 2C and reduces to A. B is only ~15 core lines
but buys a source whose meaning depends on
--machine=(C7); it is spec'd here so that it is a decision, and deferred until a real AVR program is shown to need it. - D2 -- If B is ever taken, in what form? Recommend machine-declared, not global: a width the
machine states for
TY_UPTRalone, with theslot_newgranule and the frame alignment following it, and the 8-byte string-pointer initializer (src/gen_walk.mc:374-376) parameterised with it. Nevertype_set_widthas a free-standing setter -- M24 D8 already refused a mechanism with no caller, and a global mutable width would change arm64's frames. - D3 --
i64narrower than 64 bits? Recommend no, ever.fold_binaryfolds at 64 bits at parse time; a 2-bytei64would make the constant folder disagree with the machine. AVR code is written inu8/u16/uptr. - D4 -- Narrow arithmetic on
u8/u16depths viawalk_depth_type? Recommend yes, declared: it is the single biggest performance lever and costs zero further core lines, but risk 3's divergence goes indocs/reference/machine.mdbeside the division answer, with a test that demonstrates it. If the architect prefers bit-compatibility across machines, take 64-bit arithmetic everywhere and accept design A's cost -- but decide it, do not discover it. - D5 -- Image format? Recommend ELF32
EM_AVR, written by the module, not Intel HEX and not raw:src/backend_elf.mcis ELF64-only (:44-51), both oracles take ELF, and only ELF carries.mmcu, which is simavr's only stdout. - D6 -- Which oracle is primary? Recommend simavr, with
qemu-system-avrsecond and both inmake checkand CI. simavr terminates on its own (sleep with interrupts off) and gives a real process status; QEMU gives the UART transcript a real board would give. Neither alone is enough. - D7 -- Harvard:
lpmor a startup copy? Recommend the startup copy of__cstring+__datainto SRAM, sold8needs no source discipline. Recordlpmvia M24'sintrinsicas the SRAM-saving follow-up, priced at zero core lines and one address-space rule in the source. - D8 --
emit()on a 2/4-byte ISA. Recommend the module's convention: a value below0x10000is one 2-byte word, at or above it two words high-half-first, stated inmachine.md. M24's deferredemitb(v, n)+MTASK_BYTES(~40 lines, one appended slot) is the clean answer and stays deferred until a source file needs to name a 4-byte AVR instruction that this convention cannot spell. - D9 -- PIC. Recommend excluded, permanently, with the reason written in
docs/guide/97-a-new-architecture.md: banked data memory and a hardware call stack are a different frame model, not a machine. Do not leave it as "future work". - D10 -- Corrections to the record. M39 G10 and
docs/specs/M24.md:283both overstate the spill cost (spill slots are lazy,src/machine_arm64.mc:108) and both count the compiler's own 264* 8sites, which are never compiled for a target. Reprice G10 in this milestone from "~400 lines, out of reach" to "~15 core lines and one semantic decision" -- the number was wrong and the conclusion (do not take it yet) still stands for a different reason.
Adopted, one line each: D1 design A (zero core lines; structural inertness); D2 if B is ever
taken it is machine-declared for TY_UPTR alone, never a free-standing setter; D3 i64 is never
narrower than 64 bits; D4 narrow u8/u16 arithmetic through walk_depth_type, declared in
machine.md with the divergence test of risk 3; D5 ELF32 EM_AVR written by the module; D6 simavr
primary, qemu-system-avr second, both in make check and CI; D7 the startup copy, lpm as the
priced follow-up; D8 the module's emit() convention; D9 PIC excluded with the reason in the
guide; D10 the G10 and M24 spill-cost corrections land in this milestone. Sequencing: M40 depends
on M24 (D4 needs walk_depth_type) and follows it. The architect's additions: (a) the ISR frame is
written and simulated first (risk 6), as yield was in M39; (b) the exit status of
SIMAVR_CMD_EXIT_CODE_1 is measured before test.sh depends on it; (c) the .mmcu layout is
checked against an avr-gcc reference ELF before the writer is written (risk 4); (d) the
baremetal-avr job joins the required checks at merge.
Amendment (owner, 2026-09-04, later the same day): override, debloat, re-arch #
The owner's direction supersedes D1: the developer RECREATES the compiler -- without touching src/
-- with overrides of the core's remaining fixed decisions, new primitives, a new machine, and the
REMOVAL of the primitives, writers and machines the target does not use; then builds the program
with that compiler. Under that framing "a source whose meaning depends on the compiler" is not a
trap but a dialect the developer chose, exactly as examples/lang is. So:
- D1 becomes design B, from the surface. The AVR module declares
uptras 2 bytes through a registration made inuser_init()(the machine-declared form of D2: forTY_UPTRalone, with theslot_newgranule, the frame alignment and the 8-byte string-pointer initializer following it -- the ~15 core lines of § 1b). M24's D8 (type_set_widthrefused for having no caller) is reversed: the caller is this module. The registration lands with M24's registry or as M40's first commit, whichever comes first, and is inert for every compiler that does not call it (the golden moves once, the pre/post inertness proof of M17 step A applies). - Debloat is the other half and is its own milestone, M41:
<mc/core>becomes composable (a minimal core -- lexer, parser, resolver, walker, hooks -- with the object writers, the two host machines, the driver, the bundle and the sysroot resolver as parts an entry file includes or omits), and primitives the target does not use can be removed (types and intrinsics unregistered; writers, machines and targets simply not registered).examples/avris the first recreated, debloated compiler: no Mach-O, ELF64 or COFF writer, no arm64 or x86-64 machine,uptrof two bytes, the AVR machine and its ELF32 writer only. - Everything else in this spec stands (the AVR facts, the oracles, the
.mmcusection, the PIC exclusion, D3..D10). Sequencing: M24, then M41's composable core, then M40 as its first consumer.
What was built (delivered 2026-09-04) #
The Amendment's design B, from the surface: examples/avr is 3579 lines under examples/ (3321 of code, gate and oracle, plus this README), and
git diff --stat src/ stage0/ lib/ tests/ for the milestone is empty. type_set_width(TY_UPTR,
2) is called from user_init, which is the caller M41 § 4a built the registration for. The
compiler is RECREATED and not extended: <mc/core_min> + <mc/core_build> + an AVR machine + an
ELF32 writer + four taught words, 339 187 bytes against mc's 776 467 (macho-exe building
both), with no arm64, no x86-64, no Mach-O/ELF64/COFF and no bundle.
Measured on this host: mc build examples/avr writes a 15255-byte image byte-identical to the
single-file CLI's; simavr prints boot / blink / tick / sum 352 / ok and exits 0, and the same
firmware with halt(1) exits 1; simavr 1.6, the version Debian and Ubuntu ship, prints the same
five lines and reports the same verdict (finding 11); qemu-system-avr -machine arduino-uno
prints the same five lines on UART0; the two on-device sweeps report 0 failed under all three;
1979 distinct instructions over the three images re-assemble byte for byte under
llvm-mc -triple=avr (0 mismatches, 337 relative and 226 absolute targets recomputed); and the ELF
header, the three LOAD segments, the section headers and the vector table agree field for field
with the same program built by avr-gcc -mmcu=atmega328p.
Deviations from this spec's text, each with its reason.
- One image and one transcript channel, no
#define(risk 5, § 4). The spec expected the two simulators to disagree about stdout. Measured: this simavr prints UART0 as well (in green, one escape pair per line), so the firmware writesUDR0only and both oracles read the same register..mmcuis still there for the exit code, which is the one thing QEMU cannot give:SIMAVR_COMMANDatGPIOR1, plusNAMEandFREQUENCY. TheSIMAVR_CONSOLEtag is not written, because nothing writes that register. - TIMER1_OVF (vector 13), not TIMER0_OVF (vector 16).
qemu-system-avrmodels the 16-bit timer of an ATmega328P and not the two 8-bit ones, so a TIMER0 overflow never arrives there and the firmware would wait for it forever. simavr models both. - A reset stub, 16 bytes at flash 0x68, like M39's and for the same reason: the compiler's
frame record is unconditional, so
_start's ownpush r29is the first instruction of the program and SP has to mean something before it. The spec's § 3 only had the vector table. - No depth registers at all. § 2A priced "depths 0..1 in r8..r23"; the machine that was
written puts EVERY depth in an 8-byte frame slot and uses
r16..r23as an accumulator. Four 64-bit depths in 32 eight-bit registers is not reachable once Y, Z, X and r0/r1 are spoken for, and the memory model makesMTASK_CALLneed nosave_live/restore_liveat all. The cost is code size (§ 7 below). rjmpfor every jump, and therefore ±4 KiB per function. Ajmpfallback is not available:jmptakes an absolute word address, and at encode time a label is a byte offset inside the function -- where the section lands in flash is the writer's decision, and a relocation names a symbol, not a local label. Past the field the machine saysavr rjmp out of range(the M39 review's rule). This is the smallest jump reach of any machine in the repository and the only one a real program can hit.- Three caps, all diagnostics: a frame past 1024 bytes (
avr frame too large for 2 KiB of SRAM), an image past 32 KiB of flash, and the jump above. The flash one was missing at first and a 41 KB image was written for a part with 32 KiB; it is now refused. - The sweep is two programs. § 2A's estimate of "roughly 1.5 KiB of 32 KiB" for the proof is
low by an order of magnitude: the firmware is 13.5 KiB and a non-trivial statement costs about
500 bytes, which is what a memory-to-memory 64-bit machine on an 8-bit part costs. Splitting
tests/sweep_a.mc(22 KiB) fromtests/sweep_b.mc(25 KiB) is what keeps each image inside the device. The firmware itself fits comfortably. lpm8is an intrinsic, not a follow-up. D7 namedlpmviaintrinsic()as the SRAM-saving next step; the startup copy itself needs to read flash, so the machine registerslpm8(p)from the start and_startuses it. What stays a follow-up is keeping literals in flash instead of copying them.MAXPARAMS 12costs nothing here. Every argument is eight bytes in the caller's frame, so there is no register/stack split in the ABI and no ninth-argument special case.- The narrow-arithmetic divergence (D4) is asserted from both sides, not only documented:
examples/avr/tests/sweep_b.mccheck 52 asserts the AVR answer (8) and check 56 the portable one (3000040200), which is the same expression with(i64)casts. -
There are two simavrs, and the image has to be laid out for the older one. This was found after everything above was green, by running the images under the simavr the CI leg actually has instead of the one this host has, and it is the reason
examples/avr/oracle/exists.Homebrew builds simavr from git master. Debian and Ubuntu ship 1.6, and 1.6's ELF loader (
simavr/sim/sim_elf.c) does not read program headers at all: it takes the CONTENTS of the section named.text, immediately followed by the contents of the section named.data(flashsize = text + data, twomemcpyat offset 0 and atsizeof(.text)), and IGNORES every address in the file. Master reads thePT_LOADheaders instead.The first layout put
.mmcubetween the code and the load image of the data, whereavr-gccputs it. Under master that is correct --.mmcuis in noPT_LOAD, so nothing moved. Under 1.6 the two sections it copies are.text(which then included.mmcu, since.mmcuwas ALLOC and inside the code segment) and.data, so the data image landed 78 bytes below the address_data_lmanames;_start's startup copy read unprogrammed flash, every pointer in__databecame0xffff, and the firmware died on its first string:CORE: *** Invalid read address PC=034a SP=0885 O=8100 Address ffff out of ram (08ff) avr_sadly_crashed
1.6 answers
avr_sadly_crashedby starting its GDB stub and WAITING, so in CI this was a fifteen minute hang with an empty transcript, not an error.The fix is a layout, not a workaround, and it satisfies both loaders at once: the load image of the data goes immediately after the code,
.mmcugoes last, withsh_flags0 andsh_addr0x910000, in noPT_LOADand therefore in nothing 1.6 copies.sh_addris not decoration: master warnsWarning: ELF .mmcu section at 0 may be loaded.for any.mmcubelow 0x860000 (sim_elf.c), and 0x910000 is what simavr's own examples link it at (--section-start=.mmcu=0x910000). Measured: the warning is gone, and the image is byte for byte what it was apart from the section header. Both simulators find.mmcuby NAME in the section table, so nothing was lost.What each oracle prints, measured, with the streams redirected to files.
transcript per line halt(0)/halt(1)simavr master (Homebrew HEAD-66eca78)stdout ESC[32mlineESC[0mexit 0 / exit 1 simavr 1.6 ( 1.6+dfsg-3build2)stderr ESC[32mline.thenESC[0mexit 0 / exit 0 qemu-system-avr8.2 / 10.2 / 11.0stdout line no exit device 1.6 renders the
\nthe firmware wrote as a trailing.and breaks the line after it, and its command enum stops atSIMAVR_CMD_UART_LOOPBACK-- there is noSIMAVR_CMD_EXIT_CODE_0/_1, so the write to the command register is logged ascode 0x05 has no handler (wrong MMCU config)and the process exits 0 whatever the firmware asked for. So the verdict is asserted on the CHANNEL, which both versions expose at-v -v -v(_avr_cmd_io_write: 0x04forhalt(0),0x05forhalt(1)), and the process status is additionally required to match on the version that can carry it -- which version that is, is DETECTED from thehas no handlerline, never assumed from a version string.All of that lives in one script,
examples/avr/oracle/simavr-run.sh, called byexamples/avr/test.shand by thebaremetal-avrCI leg, so the two cannot drift.examples/avr/oracle/Dockerfile(ubuntu:latest+simavr qemu-system-misc) is what puts 1.6 in front of a developer on macOS:make check-avrbuilds it on demand, caches it by tag, runs the four images under it, and self-skips with a printed reason when there is no Docker. Every run of either version also fails on anyInvalid read,Invalid writeoravr_sadly_crashedline, and each is guarded by a 60-second watchdog, so the hang above is now a 10-second failure with the crash line quoted.One thing this found on the way:
test.sh's comparison of section headers againstavr-gccwas vacuous.readelf -Swrites[ 1] .text, so[and1]are twoawkfields and the filter on$2matched nothing at all -- it was diffing two empty files, which is why an ALLOC.mmcuhad passed it. The index is stripped first now, the three sections both toolchains emit are compared for real, and.mmcuis asserted separately: PROGBITS at 0x910000, no flag column, and absent fromreadelf -l's section-to-segment mapping.
One gap in src/ found, reported, and fixed upstream while this was being
built. backend_default() (M41 § 2) had no effect: mc_main resolved the
default backend before it called user_init() (src/cli.mc), so a recreated
compiler whose only backend is registered from user_init still needed
--backend=NAME -- and needed it even for --dump-ast/--dump-asm, which never
reach a backend. mc build was unaffected ([target] is resolved after
user_init since M39.5), which is why examples/avr built end to end from
mc.toml throughout. The post-M41 review batch (#15, CLI object slot after
user_init()) moved the resolution below user_init() and below the dumps for
its own reason -- --exe hardcoded macho-exe on a Linux host -- and that fixes
this too: on the merged tree mc-avr --dump-asm x.mc and mc-avr x.mc -o x.elf
both work with no flag. examples/avr still passes --backend=avr-image
everywhere, because it was written against the older order and there is no
reason to make the gate depend on the newer one.