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 #

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) #

sitewhat it iswhy a machine absorbs it
src/gen_resolve.mc:409N_INT is TY_I64a 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:375callp returns TY_I64a 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-304ld64/st64 are typed TY_U64MTASK_LOAD/STORE carry ty; eight ld r,Z+ is a legal implementation of one ld64
MTASK_BIN/CMP/UN/BOOLno width in the signaturethe 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/RET8-byte argument slotswholly the machine's ABI; M38's stack-parameter rule is a formula the machine writes
src/gen_walk.mc:892 frame align 1616-byte stack alignmentthe machine chooses; nothing in the core reads it back
src/gen_walk.mc:893 frame cap 4095AArch64's unsigned 12-bit4095 > SRAM; the machine diagnoses its own smaller cap, as machine.md:176-199 already requires
src/machine_arm64.mc:108 spill slotslot_new(8) per spilled depthprivate to the machine; it may ask for 2
src/gen_walk.mc:579-580 emit() 32-bitone raw wordMTASK_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 therethe compiler's own recordssrc/*.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 #

#sitetodaywhat a 2-byte uptr needs
C1src/ast.mc:212-218type_width default 8M24's M1 already makes this a registry read; a machine-declared width is the new part
C2src/gen_walk.mc:693, :881slot_new(8) for a scalar local and a parameterM24's M5 already makes both slot_new(type_width(ty)) -- the 2 lines M24 pays
C3src/gen_walk.mc:321-324slot_new rounds (size + 7) & ~7the 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)
C4src/gen_walk.mc:360-361, :689, :892zerofill, local arrays and the frame rounded to 1616 bytes of a 2 KiB SRAM per array; machine-declared alignment (~3 lines)
C5src/gen_walk.mc:374-376a 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)
C6src/gen_walk.mc:924-933 + glob_placeglobal size and element width from type_widthfollows C1 for free; width == 2 already takes the buf_u16 branch (:381)
C7src/parse.mc:1047, :2011nel * type_width(ty) > 4095, nel > (1<<30)/type_width(ty)follows C1 for free -- and this is the semantic trap (§ 2B)
C8docs/reference/language.md:69"uptr | 8"becomes "8, or what the machine declares"
C9lib/*.mc, examples/*/lib47 #define record offsets, all multiples of 8; lib/sys_windows_host.mc:91 PI_HTHREAD 8 is the shape0 lines if the AVR example ships its own layer, as examples/kernel/lib/sys_bare.mc did. These files are never compiled for AVR.
C10tests/*.mc024-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):

operationsequencebytes
MTASK_CONST 64-bit8 x ldi (high regs) or 8 x ldi+mov16 / 32
add, sub, and, or, xoradd + 7 x adc (etc.)16
neg8 x com, subi, 7 x sbci~20
shift by a variable8 x lsl/rol inside a count loop~24 + N x ~50 cycles
mul / div / mod 64-bitmodule-shipped helper, call4 at the site, 150-250 / 120-200 once
local load/store, offset <= 638 x ldd Y+q / std Y+q16
local load/store, offset > 63movw Z,Y + subi/sbci + 8 x ldd Z+q20
ld64(p) / st64(p,v)movw Z,rlo + 8 x ld r,Z+18
prologue / epiloguepush Y, in/out SPL:SPH with cli/sei, frame reserve~28 / ~20
direct callcall + 8 bytes moved per argument4 + 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:

  1. ld64/st64 are typed TY_U64, not TY_UPTR (src/gen_resolve.mc:301-304). st64(q, p) writes eight bytes whatever p's declared type is. A pointer that occupies 2 bytes in a frame and 8 bytes when written through st64 has two sizes at once.
  2. glob_place sizes a global from type_width (src/gen_walk.mc:924-933), so uptr tbl[4] would be 32 bytes in __data with 8-byte elements while uptr p on the frame is 2. &p followed by ld64 then reads four bytes of the neighbouring slot.
  3. Making MTASK_LOAD/STORE narrow TY_U64 to 2 bytes as well fixes 1 and 2 by making i64 a 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 #

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
invocationsimavr --mcu atmega328p firmware.elfqemu-system-avr -machine arduino-uno -bios image.elf -nographic
stdoutonly 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
exitsleep 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 registerno 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:

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.

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 #

filelineswhat
examples/avr/machine_avr.mc~900the 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~220mul/div/mod/shift helpers, pushed by the machine as a second source (the sd_rt pattern)
examples/avr/image_elf32.mc~420backend("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~90the #opcode-only ISR frame ending in reti
examples/avr/main.mc~120blink, ok, one timer ISR, exit
examples/avr/mc-avr.mc / mc.toml / test.sh / README.md~30 / ~50 / ~230 / ~180build, both oracles, prose
Makefile, .github/workflows/ci.yml+12 / +35check-avr guarded inside check:; the baremetal-avr job
docs/reference/machine.md+45the 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+40the word-size story stated once, and the PIC exclusion
docs/specs/M39.md, docs/specs/M24.md+6/-6the lazy-spill correction (§ "What already exists") and G10 repriced
src/, stage0/, lib/, tests/0the milestone's headline, again

Module total ~2400 lines under examples/.

Acceptance #

  1. git diff --stat src/ stage0/ lib/ tests/ is empty, and tests/golden/mc2.sha256 is not rewritten. Under design A this is structural, not tested.
  2. Inertness. check-obj 32/32 against the frozen seed, check-asm/check-ast/check-lex unchanged, check-surface 32/32, test-exe 32/32, the three cross legs green, bootstrap at a fixed point with an empty --dump-asm diff between mc1 and mc2.
  3. It runs, under both oracles. simavr --mcu atmega328p build/avr.elf prints the exact transcript (boot, blink, tick from the ISR, ok) and ends through sleep-with-interrupts-off; qemu-system-avr -machine arduino-uno -bios build/avr.elf -nographic prints the same transcript on UART0 and is ended by the watchdog, reported as such. Watchdog in POSIX sh -- no timeout, which does not exist on this macOS host (M39 risk 2).
  4. The ELF is validated against a real toolchain, field by field: avr-readelf -a and llvm-readobj agree with the same program built by avr-gcc -mmcu=atmega328p, and the synthesized vector table matches avr-objdump -d of that reference.
  5. The encoder is checked against an oracle, M17-step-B form: every distinct instruction the machine emits while compiling main.mc and a large synthetic source re-assembles byte-identically under llvm-mc -triple=avr -mcpu=atmega328p, and each relative displacement is checked against target - address.
  6. The frame edge case is tested, not assumed: a function whose frame crosses 63 bytes (the ldd Y+q limit) 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.
  7. The machine states its ABI and test.sh asserts it, as check-surface.sh does with its nine AArch64 assertions: which registers the prologue may not clobber, r0/r1 handling, and zero mentions of the ISR save set outside isr.mc.
  8. Determinism. Two consecutive builds give a byte-identical avr.elf (cmp); no path, no date, no host string in the image.
  9. The architecture is module-only. build/mc1 --backend=avr-elf examples/avr/main.mc prints unknown backend: avr-elf with the registered list, and the default compiler refuses the source.
  10. Docs. make check-docs green; make check-avr self-skips without simavr and QEMU and make check stays green.
  11. 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 of build/mc1 from before the change must produce byte-identical objects for all 32 tests/*.mc, for src/mc.mc, and through the taught compilers for examples/api, lang, conc, desktop and kernel -- the M17 step A protocol. Goldens move once, and only once.

Risks #

  1. Design A is honest but embarrassing, and someone will try to fix it mid-milestone. The pull will be "just make uptr 2 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.
  2. MTASK_INS_SIZE disagreeing with MTASK_ENCODE by two bytes yields an image that boots and lands mid-instruction. AVR mixes 2- and 4-byte instructions and the Y-bump fallback is variable-length, so the real encoder over a scratch buffer is mandatory (M39 risk 3).
  3. Narrow arithmetic diverges from the 64-bit machines. If the machine uses walk_depth_type to compute u16 + u16 in two instructions, then u16 a = 0xFFFF; i64 x = a * a; gives 0x0001 on AVR and 0xFFFE0001 on arm64, because res_binary types 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 in docs/reference/machine.md and covered by a test that names it. Decided in D4.
  4. The .mmcu section 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 by avr-gcc with AVR_MCU_SIMAVR_CONSOLE before writing a line of the writer.
  5. The two oracles disagree about stdout by construction (console register vs UART0). One #define in sys_avr.mc and two test.sh legs; if it ever becomes two source files the example has lost the point.
  6. The ISR save set is the riskiest 20 lines, exactly as yield was in M39. Write it and simulate it first, before the machine is finished.
  7. 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.
  8. 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) #

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:

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.

  1. 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 writes UDR0 only and both oracles read the same register. .mmcu is still there for the exit code, which is the one thing QEMU cannot give: SIMAVR_COMMAND at GPIOR1, plus NAME and FREQUENCY. The SIMAVR_CONSOLE tag is not written, because nothing writes that register.
  2. TIMER1_OVF (vector 13), not TIMER0_OVF (vector 16). qemu-system-avr models 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.
  3. 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 own push r29 is the first instruction of the program and SP has to mean something before it. The spec's § 3 only had the vector table.
  4. 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..r23 as 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 makes MTASK_CALL need no save_live/restore_live at all. The cost is code size (§ 7 below).
  5. rjmp for every jump, and therefore ±4 KiB per function. A jmp fallback is not available: jmp takes 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 says avr 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.
  6. 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.
  7. 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) from tests/sweep_b.mc (25 KiB) is what keeps each image inside the device. The firmware itself fits comfortably.
  8. lpm8 is an intrinsic, not a follow-up. D7 named lpm via intrinsic() as the SRAM-saving next step; the startup copy itself needs to read flash, so the machine registers lpm8(p) from the start and _start uses it. What stays a follow-up is keeping literals in flash instead of copying them.
  9. MAXPARAMS 12 costs 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.
  10. The narrow-arithmetic divergence (D4) is asserted from both sides, not only documented: examples/avr/tests/sweep_b.mc check 52 asserts the AVR answer (8) and check 56 the portable one (3000040200), which is the same expression with (i64) casts.
  11. 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, two memcpy at offset 0 and at sizeof(.text)), and IGNORES every address in the file. Master reads the PT_LOAD headers instead.

    The first layout put .mmcu between the code and the load image of the data, where avr-gcc puts it. Under master that is correct -- .mmcu is in no PT_LOAD, so nothing moved. Under 1.6 the two sections it copies are .text (which then included .mmcu, since .mmcu was ALLOC and inside the code segment) and .data, so the data image landed 78 bytes below the address _data_lma names; _start's startup copy read unprogrammed flash, every pointer in __data became 0xffff, 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_crashed by 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, .mmcu goes last, with sh_flags 0 and sh_addr 0x910000, in no PT_LOAD and therefore in nothing 1.6 copies. sh_addr is not decoration: master warns Warning: ELF .mmcu section at 0 may be loaded. for any .mmcu below 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 .mmcu by NAME in the section table, so nothing was lost.

    What each oracle prints, measured, with the streams redirected to files.

    transcriptper linehalt(0) / halt(1)
    simavr master (Homebrew HEAD-66eca78)stdoutESC[32m line ESC[0mexit 0 / exit 1
    simavr 1.6 (1.6+dfsg-3build2)stderrESC[32m line . then ESC[0mexit 0 / exit 0
    qemu-system-avr 8.2 / 10.2 / 11.0stdoutlineno exit device

    1.6 renders the \n the firmware wrote as a trailing . and breaks the line after it, and its command enum stops at SIMAVR_CMD_UART_LOOPBACK -- there is no SIMAVR_CMD_EXIT_CODE_0/_1, so the write to the command register is logged as code 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: 0x04 for halt(0), 0x05 for halt(1)), and the process status is additionally required to match on the version that can carry it -- which version that is, is DETECTED from the has no handler line, never assumed from a version string.

    All of that lives in one script, examples/avr/oracle/simavr-run.sh, called by examples/avr/test.sh and by the baremetal-avr CI 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-avr builds 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 any Invalid read, Invalid write or avr_sadly_crashed line, 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 against avr-gcc was vacuous. readelf -S writes [ 1] .text, so [ and 1] are two awk fields and the filter on $2 matched nothing at all -- it was diffing two empty files, which is why an ALLOC .mmcu had passed it. The index is stripped first now, the three sections both toolchains emit are compared for real, and .mmcu is asserted separately: PROGBITS at 0x910000, no flag column, and absent from readelf -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.

Edit this page